简介:基于多模态融合的阿尔兹海默症智能诊断方法源码包,面向计算机、人工智能、电子信息等专业学生及毕业设计/课程设计场景,解决脑疾病诊断模型从数据准备、特征融合到模型评估的完整实现问题。项目覆盖数据预处理、多模态特征融合、卷积神经网络(ResNet、CBAM注意力)构建、训练测试与结果可视化等环节,适合作为毕设源码复现、课程设计参考或深度学习入门实战模板。压缩包共26个文件,主体为9个Python脚本与7个Pyc编译文件,分别承担数据集读取、图像变换、模型定义、融合网络与训练评估等功能;另附README说明文档、EfficientNet模型检查点及Loss、Accuracy、ROC曲线结果图,整体仅1.6MB,模块划分清晰,便于按需阅读和复用。目前已吸引196人学习下载,可支撑进一步改进算法或迁移到其他医学影像分类任务。下载后建议先读README理清项目结构,再结合源码理解多模态融合和注意力机制实现细节;代码均已测试通过,作者还提供远程答疑支持,对快速上手、完成课设和答辩准备均有实际参考价值。
1. 多模态融合诊断阿尔兹海默症:这套 PyTorch 毕设源码的打开方式
医学影像类的毕设每年都不少,但真正把“多模态融合”落到代码里、还能跑通出图的并不多。这套基于 Python 的阿尔兹海默症智能诊断项目,核心是把结构 MRI 和功能影像特征揉进同一个分类网络,骨干用 ResNet 加 CBAM 注意力,融合层单独拆成 FusionNet,分类头单独做成 FusionClsN,训练和测试脚本分离,目录里还带了 checkpoints 权重和 Loss、Accuracy、ROC_AUC_Curve 三张结果图。换句话说,它不是只给你一个模型文件,而是给了一条完整的实验链路:数据怎么读、模型怎么搭、训练怎么跑、指标怎么出。
对正在准备毕设答辩、课程设计或者想入门多模态学习的同学来说,这套代码最大的价值是“能复现”。我拿到手第一件事就是按 README 流程把训练跑起来,确认 checkpoints 能直接加载做推理。本文就把拆解过程写清楚,从数据组织到融合设计再到避坑,按真正跑代码的顺序来。
2. 数据与预处理:先读懂 dataset 和 transform 的组织逻辑
2.1 数据集目录结构:双模态样本怎么配对
多模态融合的第一步不是模型,是数据对齐。这个项目里的 dataset 模块,承担的就是“把不同模态的样本组织成一个 batch”的活。MRI 和 PET 影像分别放在不同子目录下,通过文件名里的编号一一对应。我一般会先把整个数据目录梳理一遍,确认每个 subject 都同时存在两个模态的输入,再去碰模型代码。
常见做法是把样本索引写进一个 CSV 或者直接用文件夹名做 subject id。dataset 加载时传入两个路径列表,一个指向 MRI,一个指向 PET,通过共同的索引取值配对。下面这段逻辑就是从项目 dataset 里最能看出坑的地方:
class MultimodalDataset(Dataset): def __init__(self, mri_dir, pet_dir, label_file, transform=None): self.mri_paths = sorted(glob.glob(os.path.join(mri_dir, "*.nii"))) self.pet_paths = sorted(glob.glob(os.path.join(pet_dir, "*.nii"))) self.labels = self._load_labels(label_file) self.transform = transform def __len__(self): return len(self.mri_paths) def __getitem__(self, idx): mri = sitk.ReadImage(self.mri_paths[idx]) pet = sitk.ReadImage(self.pet_paths[idx]) mri = self._normalize(sitk.GetArrayFromImage(mri)) pet = self._normalize(sitk.GetArrayFromImage(pet)) label = self.labels[idx] if self.transform: mri = self.transform(mri) pet = self.transform(pet) return {"mri": mri, "pet": pet, "label": label}这里最关键的是sorted()排序。MRI 和 PET 两个目录里的文件名如果不做统一排序,配对的样本就会错位,训练出来的模型指标会非常诡异:loss 能降,准确率始终上不去。另一个细节是_normalize(),医学影像每个被试的灰度范围差异很大,N4 偏置场校正或者 z-score 归一化至少要做一个。该函数建议实现如下逻辑:
def _normalize(self, volume): volume = (volume - volume.min()) / (volume.max() - volume.min() + 1e-8) volume = volume.astype(np.float32) return volume归一化参数要按每个样本独立计算,不能在整个数据集上算全局统计量。每个被试的头部位置、扫描参数都不一样,全局归一化会把个体差异放大。
2.2 transform.py 里的数据增强组合与参数边界
数据增强在这个项目里不是为了刷分,是为了让模型在小数据集上不快速过拟合。transform.py 中常见的组合是随机裁剪、随机旋转、水平翻转,外加 ToTensor 和 Normalize。但医学影像的增强和自然图像有个关键区别:旋转和平移的幅度不能太大,否则会破坏解剖结构的语义一致性。比如 MRI 图像旋转超过 15 度,脑室形态就会失真,模型学到的是伪影而不是病理特征。
train_transform = transforms.Compose([ transforms.RandomResizedCrop(size=224, scale=(0.85, 1.0)), transforms.RandomRotation(degrees=10), transforms.RandomHorizontalFlip(p=0.5), transforms.ToTensor(), transforms.Normalize(mean=[0.485], std=[0.229]) ])注意这里的RandomResizedCrop的 scale 参数。我通常不会让裁剪比例低于 0.85,因为医学影像里 ROI 往往占比较小,裁剪太狠会把关键脑区裁掉。RandomHorizontalFlip在脑疾病诊断里是否启用有争议,因为左右脑的萎缩模式在某些疾病下是不对称的,但阿尔兹海默症的海马体萎缩很多研究认为大体对称,所以翻转是可接受的。保守做法是用 0.3 的概率而不是 0.5。
val_transform = transforms.Compose([ transforms.Resize(size=(224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485], std=[0.229]) ])验证集和测试集绝对不能加随机增强,这是所有医学影像实验的第一铁律。验证集只用 Resize 加归一化,保证评测结果的确定性。很多同学把训练用的 RandomRotation 也用在测试上,结果每次跑测试指标都不会完全一样,答辩时被评委问一句“你的结果可复现吗”就容易翻车。
3. 骨干与注意力模块:resnet.py 和 cbam.py 的配合方式
3.1 选 ResNet 做骨干的迁移学习考量
多模态医学影像分类的骨干网络选择,ResNet 几乎是默认起点。主要原因有两条:一是 PyTorch 的 torchvision 里自带在 ImageNet 上预训练过的权重,微调成本低;二是 ResNet 的残差结构对深层特征的回传更友好,在数据量不大的医学影像任务上不容易梯度消失。项目里 resnet.py 文件本质上是对 torchvision 模型的封装,核心是替换最后的全连接层。
这里需要留心的是预训练权重的适用范围。ImageNet 是自然图像,和 MRI 的灰度切片差距非常大,所以实践中通常的做法是:加载预训练权重,但把前几层解冻让它们适应医学图像的低级纹理特征,深层保持预训练参数再慢慢微调。这个项目里 resnet.py 的文件名暗示它支持选择不同层数的 ResNet,常见的做法是用 ResNet18 或 ResNet34。层数越深,小数据集上越容易过拟合,不是越深越好。
import torchvision.models as models import torch.nn as nn def get_backbone(backbone_name="resnet18", out_dim=512): if backbone_name == "resnet18": model = models.resnet18(pretrained=True) elif backbone_name == "resnet34": model = models.resnet34(pretrained=True) else: raise ValueError(f"Unsupported backbone: {backbone_name}") # 替换最后的全连接层,输出 512 维特征向量 in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=0.3), nn.Linear(in_features, out_dim), nn.ReLU(inplace=True) ) return model这个替换逻辑有两个参数值得调整。out_dim决定了喂给融合层的特征维度,取 512 是因为与 CBAM 模块的输出维度做拼接时,两个模态各 512 维正好形成 1024 维的融合向量。Dropout的 p 值在数据量只有几百例时建议设到 0.3 或 0.4,防止模型把训练集的噪声模式背下来。输出的 512 维向量不是分类结果,而是给后端融合层用的特征表示,这一点在源码注释里通常会有说明。
if backbone_name in ["resnet34", "resnet50"]: model = models.resnet34(pretrained=True) if backbone_name == "resnet34" else models.resnet50(pretrained=True)3.2 CBAM 注意力模块的实现要点与放置位置
CBAM 在 resnet.py 之后单独存在,说明它是作为一个即插即用的模块插入到骨干网络中的。CBAM 包含两个子模块:通道注意力(Channel Attention)和空间注意力(Spatial Attention),前者告诉网络“看什么特征”,后者告诉网络“看哪里”。对于脑疾病诊断,通道注意力可以理解为筛选对疾病敏感的纹理特征,空间注意力则是定位海马体、脑皮层等关键解剖区域。
import torch import torch.nn as nn class CBAM(nn.Module): def __init__(self, channels, reduction=16, kernel_size=7): super(CBAM, self).__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.mlp = nn.Sequential( nn.Conv2d(channels, channels // reduction, kernel_size=1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(channels // reduction, channels, kernel_size=1, bias=False) ) self.spatial_conv = nn.Conv2d(2, 1, kernel_size=kernel_size, padding=kernel_size // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): # 通道注意力 avg_out = self.mlp(self.avg_pool(x)) max_out = self.mlp(self.max_pool(x)) channel_att = self.sigmoid(avg_out + max_out) x = x * channel_att # 空间注意力 avg_spatial = torch.mean(x, dim=1, keepdim=True) max_spatial, _ = torch.max(x, dim=1, keepdim=True) spatial_cat = torch.cat([avg_spatial, max_spatial], dim=1) spatial_att = self.sigmoid(self.spatial_conv(spatial_cat)) x = x * spatial_att return xreduction=16是通道压缩比例,控制 MLP 中间层的宽度。reduction 太大会丢失通道间的相关性信息,太小则参数量骤增。在这个项目里,因为骨干输出通道有限,我建议 reduction 保持在 16 不变,优先调整 kernel_size。kernel_size=7是空间注意力的卷积核大小,它决定了空间注意力能覆盖多大范围的邻域信息。对于 224x224 的输入特征图,7 是相对安全的取值。
放置位置比模块本身更能影响结果。常见的插入方式有两种:一种是在 ResNet 的每个 BasicBlock 之后插入 CBAM,另一种是只在 layer3 或 layer4 的输出后插入。前一种更精细,但训练显存占用更大;后一种更省资源,而且高层特征的语义信息更丰富。这个项目既然把 cbam.py 单独拆出来,实际使用时大概率是只插入到 layer3 和 layer4 之后,这样既兼顾了性能又不会让训练被显存卡死。
3.3 骨干输出维度的对齐技巧
两个模态的骨干网络结构是相同的,参数是否共享取决于实现方式。常见做法是不共享,因为 MRI 和 PET 的底层纹理特征差异很大,共享参数会迫使两个分支学习到相同的特征表达,反而削弱多模态的互补性。在代码层面,就是实例化两个独立的 get_backbone 对象:
mri_backbone = get_backbone("resnet18", out_dim=512) pet_backbone = get_backbone("resnet18", out_dim=512)每个 backbone 输出的 512 维向量,后续会在融合网络里拼接成 1024 维。如果 backbone 输出的特征图不是一维向量而是二维特征图,就需要先经过全局平均池化再送进 FC 层。项目里 resnet.py 已经替你把model.fc替换成了输出 512 维的序列结构,所以后端的 fusion 模块可以直接拿到向量输入,不必做额外的展平操作。
这里有一个容易踩的坑:如果预训练权重是从 torchvision 加载的,替换 FC 层之前加载的权重会包含原 1000 维分类头的参数,需要确保严格按 state_dict 的键名加载,遇到尺寸不匹配时跳过。常见做法是model.load_state_dict(pretrained_dict, strict=False),这样骨干层的预训练参数正常加载,自定义的 FC 层保持随机初始化。
4. 多模态融合设计:FusionNet 与 FusionClsN 的两级分工
4.1 融合策略对比:为什么选择特征级融合
多模态融合大致分三个层次:数据级、特征级、决策级。数据级融合是把 MRI 和 PET 配准后拼成一个多通道输入,实现最简单但要求两模态严格配准,实际中很难做到像素级对齐。决策级融合是各模态独立出分类概率再加权平均,实现也简单,但丢掉了模态间的相关性信息。特征级融合介于两者之间,先把每个模态提取成向量,再拼接或加权,让分类头学习模态间的交互模式。
项目里 FusionNet 和 FusionClsN 两个文件并存,说明走的就是特征级融合路线。MRI 和 PET 分别通过各自的 ResNet 骨干提取到 512 维特征,在 FusionNet 里拼接成 1024 维向量,再经过全连接层压缩为 256 维,最后交给 FusionClsN 做二分类。这个过程可以用一张示意表来描述:
融合层次 | 实现方式 | 优点 | 缺点 数据级 | 多通道输入 | 实现简单 | 对配准要求苛刻 决策级 | 概率加权平均 | 简单可靠 | 丢失模态间相关性 特征级 | 向量拼接或加权 | 保留互补信息 | 特征维度需要调参
4.2 FusionNet 的拼接与压缩实现
FusionNet 的核心逻辑是接收两个 512 维向量,拼接后经过若干个全连接层和激活函数,得到融合特征。这里的关键参数是中间隐藏层的维度。1024 直接压到 256 会让信息丢失过多,常见做法是分两步压缩:1024 到 512,再 512 到 256。
import torch.nn as nn class FusionNet(nn.Module): def __init__(self, input_dim=1024, hidden_dim=512, fusion_dim=256, dropout=0.3): super(FusionNet, self).__init__() self.fusion = nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(inplace=True), nn.Dropout(p=dropout), nn.Linear(hidden_dim, fusion_dim), nn.ReLU(inplace=True) ) def forward(self, mri_feat, pet_feat): # mri_feat: [B, 512], pet_feat: [B, 512] combined = torch.cat([mri_feat, pet_feat], dim=1) # [B, 1024] return self.fusion(combined) # [B, 256]cat操作沿特征维度拼接,BatchNorm1d 放在全连接层后面,作用是对融合特征的分布做归一化,稳定训练。Dropout 防止两个模态的特征相互过拟合,p=0.3 是折中取值。如果你的显存充足,hidden_dim 可以提高到 768,但对精度提升有限。需要注意的是,BatchNorm1d 在小 batch 下会不稳定,如果你的 batch_size 小于 16,建议把它去掉。
multimodal.py 文件里定义的就是这个层面的内容。它负责把两个骨干输出的特征送到 FusionNet,再把 FusionNet 的输出送到 FusionClsN。严格来说,multimodal.py 是整个项目里最值得读的一个文件,因为多模态融合的全部接口都在这里面,改动它就能切换不同的融合策略。
4.3 FusionClsN 分类头的结构
FusionClsN 是最后一级分类网络,输入是 FusionNet 输出的 256 维特征,输出是 2 个类别(AD 和 NC)的 logits。结构非常简单,但细节里有一个重要的设计:最后一层前接 Dropout,避免分类头过拟合融合特征。
class FusionClsN(nn.Module): def __init__(self, fusion_dim=256, num_classes=2, dropout=0.4): super(FusionClsN, self).__init__() self.classifier = nn.Sequential( nn.Dropout(p=dropout), nn.Linear(fusion_dim, num_classes) ) def forward(self, fused_feat): return self.classifier(fused_feat)分类头不需要太深,因为融合特征已经是从两个骨干网络中提取的高层语义信息,再做多层非线性变换容易过拟合。Dropout 值设到 0.4 比 FusionNet 里高一些,这是为了让分类头对融合特征的扰动更鲁棒。如果你的数据集只有几百例,甚至可以设到 0.5。
4.4 完整的推理链路整合
把 backbone、FusionNet、FusionClsN 串起来,就是一次完整的前向传播。FusionClsN.py 里应该有一个顶层模型封装,负责调用两个骨干和两个融合模块,整体输出预测概率。代码层面的逻辑大概是:
class MultimodalModel(nn.Module): def __init__(self): super(MultimodalModel, self).__init__() self.mri_backbone = get_backbone("resnet18", out_dim=512) self.pet_backbone = get_backbone("resnet18", out_dim=512) self.fusion_net = FusionNet() self.fusion_clsn = FusionClsN() def forward(self, mri, pet): mri_feat = self.mri_backbone(mri) pet_feat = self.pet_backbone(pet) fused = self.fusion_net(mri_feat, pet_feat) logits = self.fusion_clsn(fused) return logits训练时用交叉熵损失,评估时用准确率和 ROC-AUC。整个链路的设计思路是每个模块各司其职,骨干负责模态内特征提取,FusionNet 负责模态间特征融合,FusionClsN 负责最终分类决策。读懂这条链路,你就有能力在它基础上替换骨干、改融合方式,甚至扩展成三模态。
5. 训练测试与跨设备复现避坑指南
5.1 现象:loss 持续下降但准确率纹丝不动
训练时 loss 从 0.6 一路降到 0.3,看起来正常,但训练准确率始终在 60% 左右徘徊,跟随机猜测差不多。这种“loss 在降、指标不变”的组合,在这类双模态项目里最常见的原因是样本配对顺序错乱。MRI 和 PET 文件列表分别做了排序,但两个排序规则不一致,导致模型看到的是一个模态是样本 A、另一个模态是样本 B,学习不到有效特征。
解决方法是打印一个 batch 的样本路径对照检查。在 dataset 的__getitem__里临时加一行 print,输出self.mri_paths[idx]和self.pet_paths[idx],确认两个路径中的编号是否一致。另一个常见原因是标签文件里的标签顺序和图像列表顺序不一致,改法统一以图像列表为准,重新索引标签。从那以后我每次跑多模态任务,都会先做一个“配对可视化”小脚本,把两个模态的样本名和标签一次性打印出来核对。这个问题很多时候表现得很隐蔽,因为你不会马上想到是数据配对错了。
5.2 现象:训练准确率 95%,验证准确率只有 70%
典型的过拟合特征,但在这类毕设里,过拟合有几个常见的加速因素。第一个是数据增强太弱。如果 transform 里只做了 Resize 和 ToTensor,没有旋转、翻转,模型在小数据集上会把训练集的纹理细节背下来。第二个是 Dropout 太少。骨干网络的全连接层替换后如果没加 Dropout,512 维特征会直接暴露给分类头。第三个原因是训练轮数太长,小数据集训练 100 个 epoch,后 20 个 epoch 几乎完全在记忆噪声。
解决方法是把增强强度提升一个档位,比如旋转角度从 5 度提高到 10 度,裁剪比例下限从 0.9 降到 0.85。同时把骨干 FC 层的 Dropout 从 0.3 提到 0.4。还有一个有效手段是降低学习率并配合早停策略,验证集指标连续 5 个 epoch 不升就停止训练。
5.3 现象:CUDA out of memory 报错
这个项目默认输入可能是 224x224,batch_size 设为 8 或 16。但如果你的显卡显存只有 6G,两个 ResNet18 同时前向传播很容易直接爆显存。报错信息会出现在第二个 batch 的前向过程,原因是两个骨干网络的中间特征图同时驻留显存。
解决手段依次尝试:把 batch_size 降到 4;把输入尺寸从 224 降到 192;在 dataloader 里设置pin_memory=True减少 CPU 到 GPU 的拷贝开销。最后一个更有效的手段是开启混合精度训练,PyTorch 的 GradScaler 可以把显存占用降低近一半。这个项目作为毕设,答辩时如果被问“你怎么解决显存问题”,能把混合精度的原理说出来是很加分的。
5.4 现象:加载 checkpoints 时参数名不匹配
checkpoints 目录下保存的权重是用整套模型保存的,直接torch.load后load_state_dict会报Missing key(s)或Unexpected key(s)。主要原因有两种:一是你改了骨干网络的结构,比如把 resnet18 换成了 resnet34,全连接层的参数名就变了;二是保存时用的是torch.save(model.state_dict(), ...),而加载时你传入的是整个模型对象而不是 state_dict。
解决方法是加载时加strict=False,只加载匹配的键。如果修改了 FC 层结构,旧权重中 FC 层的参数会被跳过,不会报错但也不会生效。更稳妥的做法是保存时同时存模型结构参数和 state_dict:
torch.save({ "model_state_dict": model.state_dict(), "backbone": "resnet18", "fusion_dim": 256, "num_classes": 2 }, "checkpoints/best_model.pth")这样一个 checkpoint 文件就携带了完整的训练配置信息,跨设备复现时不容易因为超参数不一致而对不上。
5.5 现象:ROC-AUC 曲线异常但准确率正常
这个现象在二分类不平衡数据集上经常出现。准确率看起来有 85%,但 ROC 曲线的 AUC 只有 0.6,说明模型偏向预测多数类。AD 诊断数据集中,正常对照组(NC)数量通常多于患者,模型学到了“不管输入什么,输出都偏向 NC”的捷径。
解决方法是改用加权交叉熵损失,根据样本比例给少数类更高的权重。在 train.py 里,把nn.CrossEntropyLoss()改成nn.CrossEntropyLoss(weight=torch.tensor([1.0, ratio])),其中 ratio 是多数类样本数与少数类样本数的比值。或者使用 Focal Loss 这类专门应对类别不平衡的损失函数。修改后再次训练,AUC 会明显回升。
6. 进阶用法:加载预训练 checkpoints 做推理与迁移到新数据
6.1 直接用 checkpoints 做单样本推理
checkpoints 目录里的权重已经训练到收敛,直接加载做推理可以验证模型状态。为了不破坏原项目代码,我一般建议单独写一个推理脚本,只依赖模型定义和权重文件:
import torch from models.fusion_net import FusionNet from models.fusion_clsn import FusionClsN from models.resnet import get_backbone checkpoint = torch.load("checkpoints/best_model.pth", map_location="cpu") model = MultimodalModel() model.load_state_dict(checkpoint["model_state_dict"], strict=False) model.eval() with torch.no_grad(): mri_tensor = torch.randn(1, 1, 224, 224) # 模拟一张 MRI pet_tensor = torch.randn(1, 1, 224, 224) # 模拟一张 PET logits = model(mri_tensor, pet_tensor) prob = torch.softmax(logits, dim=1) print(f"AD 概率: {prob[0][1].item():.4f}")如果你要加载的是纯 state_dict 而不是带配置的 checkpoint,请确认你实例化的模型结构与原训练时完全一致。虚幻数据可以验证前向传播通不通,但不能验证模型效果,所以真实推理前一定要替换成真实的 3D MRI 和 PET 数据。
6.2 在 AD 之外的脑疾病数据上做迁移实验
这套代码的设计并不绑定阿尔兹海默症。如果你手里有帕金森、脑肿瘤或者轻度认知障碍(MCI)的影像数据,只需要改两个地方:数据集的标签文件(改成你自己的类别),以及最后的 num_classes(二分类改成你的类别数)。骨架和融合部分不需要改动。
迁移实验有一个必须注意的边界:骨干网络加载的 ImageNet 预训练权重对医学灰质图像的表达能力有限,新数据量如果只有一两百例,建议冻结 backbone 前两层,只训练后三层和融合层。如果数据量超过 500 例,再考虑全量微调。这个取舍直接决定你迁移实验是“收敛良好”还是“剧烈震荡”。
6.3 把 Loss.png、Accuracy.png、ROC 图变成答辩素材的走查顺序
目录下已经给出训练过程的图表,但在答辩前建议自己重新生成一版,用统一的图表风格替换默认的 matplotlib 样式。走查顺序是:先看 Loss 曲线是否平滑下降,是否存在跳变;再看 Accuracy 曲线在验证集上是否震荡;最后看 ROC 曲线的 AUC 值,这是答辩评委最容易问的点。把三条曲线对应到训练代码中的日志输出,评委问任何一条曲线你都能现场指出来自哪个 epoch。
从那以后我每次接触这类多模态毕设项目,都会强制自己走一遍这样的流程:先理清数据配对,再拆分模型结构,然后逐模块替换训练,最后核对指标曲线。这套方法让我少踩了很多隐形的坑,也希望帮到你。拿到资源后别急着跑,按顺序读完 README,把目录结构画一遍,再动手改代码,你会少走很多弯路。
本文还有配套的精品资源,点击获取