简介:面向本科毕业设计场景的一份AI医学影像完整实践项目,聚焦阿尔兹海默症早期辅助诊断,融合MRI与PET双模态数据,基于改进的3D ResNet-18完成特征提取与通道级融合,结合迁移学习缓解小样本问题,并在ADNI数据集上验证了模型对AD、轻度认知障碍与健康对照的分类效果。资源包含完整Python训练/测试/推理脚本、数据集读取与增强实现、多模态融合网络定义以及训练过程曲线和ROC_AUC等可视化结果,还涵盖数据预处理、CBAM注意力模块与可视化交互界面等代码,适合作为医学图像分析方向毕设参考或入门多模态深度学习的实战范例。压缩包共27个文件,涵盖9个Py源码、7个Pyc缓存、4个模型训练图表、1份说明文档及1个efficientnet预训练权重等,整体仅1.6MB,结构紧凑,便于快速移植复现。目前已有119人学习浏览。
1. 一个毕设级 3D ResNet-18 多模态影像工程,为什么值得你解压看一眼
拿到这份名为「本科毕设、MRI+PET、3D ResNet-18、阿尔兹海默症」的 zip 时,我第一反应是:能把这三个词拼成一个能跑通的项目,说明作者至少跨过了配准、显存和小样本三道坎。阿尔兹海默症(Alzheimer's disease, AD)的诊断本身就是个多模态问题:MRI 展示海马体萎缩等结构改变,PET 展示淀粉样蛋白沉积等代谢异常,合在一起才能形成更可信的判断。这份资源的价值在于它把双模态输入、3D 卷积网络、训练验证串成完整工程链,而不是丢给你一段孤立模型代码。适合医学影像方向的毕设学生、想从 2D 切到 3D 卷积的算法工程师,以及任何需要快速搭建多模态分类基线的人。
2. 双模态输入必须想明白的事:MRI 和 PET 各自在说什么
2.1 为什么 MRI 的结构信息和 PET 的代谢信息缺一不可
阿尔兹海默症的病理过程比多数人想得更长。淀粉样蛋白的沉积可以在认知症状出现前十几年就开始,而神经元的死亡和脑组织萎缩则要在症状出现后才逐步显影。PET 显像原理依赖示踪剂,通过检测淀粉样蛋白沉积或葡萄糖代谢的空间分布来反映神经活动;MRI 则提供解剖结构图像,尤其 T1 加权像能清晰显示灰质和白质的边界,海马体这类 AD 最早受侵蚀的区域也能被清楚勾勒出来。
对于分类任务,这两个模态的信息高度互补。一个 AD 患者往往在 MRI 上能看到海马体体积显著缩小,在 PET 上看到颞顶叶代谢降低。单看 MRI,你会把老化性萎缩和病理性萎缩混为一谈;单看 PET,代谢降低的区域很难对应到具体解剖位置。多模态输入的朴素思想是:让网络同时看见结构和代谢证据,在特征层面找到二者之间的联合规律。
我在自己的项目里见过太多团队在预处理阶段草率处理 PET:直接把原始分辨率的 PET 插值放大到 MRI 的体素大小,然后与 MRI 逐通道拼接。这是不可行的。PET 的原始分辨率通常有 4-6mm,MRI 是 1mm,插值放大只会把 PET 的噪声也放大,还会让网络把“PET 模糊”当作疾病特征。正确做法是先配准到 MRI 空间、再用大脑 mask 裁剪、统一体素大小、最后做强度归一化,顺序一步都不能乱。
2.2 数据组织与预处理流水线
制作数据的第一步是检查每一项 MRI 和 PET 的元数据。ADNI 这类公开数据集里的 MRI 多为 T1 加权像的 NIfTI 文件,PET 是经过标准摄取值(SUV)归一化的 NIfTI。两者之间可能存在不同的栅格间距和原点方向,所以要先统一坐标和朝向。文件目录我一般建议按受试者为单位组织,一个受试者一个文件夹,里面放好配对的 mri.nii.gz、pet.nii.gz 和标签文件,后续流程全部按这个索引读取。
配准这一步我用 SimpleITK 做仿射配准,把 PET 对齐到 MRI 空间。核心逻辑是固定图像设成 MRI、移动图像设成 PET,用互信息作为相似度度量,优化一个全局变换矩阵。
import SimpleITK as sitk def register_pet_to_mri(mri_path, pet_path): # 固定图像是 MRI,移动图像是 PET fixed = sitk.ReadImage(mri_path) moving = sitk.ReadImage(pet_path) # 互信息度量适合不同模态之间的配准 reg = sitk.ImageRegistrationMethod() reg.SetMetricAsMattesMutualInformation(numberOfHistogramBins=64) reg.SetOptimizerAsGradientDescent(learningRate=1.0, numberOfIterations=200) reg.SetInitialTransform(sitk.TranslationTransform(fixed.GetDimension())) # 执行配准,得到从 PET 到 MRI 空间的变换 final_transform = reg.Execute(fixed, moving) # 用线性插值把 PET 重采样到 MRI 的网格上 resampler = sitk.ResampleImageFilter() resampler.SetReferenceImage(fixed) resampler.SetInterpolator(sitk.sitkLinear) resampler.SetTransform(final_transform) return resampler.Execute(moving)逻辑说明:这里做的是仿射配准,通过最大化 MRI 和 PET 之间的互信息来求变换。医学影像里,仿射变换已经能校正平移、旋转和缩放偏差,对脑影像足足够用;直接上弹性配准反而可能过度拟合大脑的个体形变,把不同脑区的边界拉歪。参数 numberOfHistogramBins 设置成 64 是经验值,bin 太少会丢失灰度分布细节,bin 太多则统计噪声变大,64 在大部分 T1-MRI 和 PET 的配对上都稳定。
配准完成后,下一步是裁剪出大脑区域、重采样到统一尺寸。我一般先用阈值找前景包围框,再在边缘留出 8 个体素的 margin,最后三线性插值到 64×64×64。
import numpy as np from scipy.ndimage import zoom def crop_and_resample(image_array, target_shape=(64, 64, 64)): # 用均值加 0.5 倍标准差做阈值,圈出脑组织区域 mask = image_array > (image_array.mean() + 0.5 * image_array.std()) coords = np.argwhere(mask) z_min, y_min, x_min = coords.min(axis=0) z_max, y_max, x_max = coords.max(axis=0) # 四边对称扩展 8 个体素,防止把边缘脑组织裁掉 z_min = max(z_min - 8, 0) y_min = max(y_min - 8, 0) x_min = max(x_min - 8, 0) z_max = min(z_max + 8, image_array.shape[0]) y_max = min(y_max + 8, image_array.shape[1]) x_max = min(x_max + 8, image_array.shape[2]) cropped = image_array[z_min:z_max, y_min:y_max, x_min:x_max] # 按各轴比例缩放到统一尺寸,order=3 是三次样条插值 zooms = ( target_shape[0] / cropped.shape[0], target_shape[1] / cropped.shape[1], target_shape[2] / cropped.shape[2] ) return zoom(cropped, zooms, order=3)逻辑说明:这里的索引顺序 z、y、x 对应 NIfTI 数组的标准顺序,不要和 x、y、z 混了。zoom 的 order=3 是三次样条插值,适合医学影像的平滑重建;用 order=1 的话,MRI 的灰白质边界上会出现明显锯齿,到模型里就成了高频噪声。重采样到 64³ 资源包和我自己常用的尺寸,在显存和细节保留之间是个平衡点。
2.3 强度归一化与受试者划分
医学影像的强度值没有绝对物理意义,不同设备、不同受试者的扫描参数都会影响灰度分布。MRI 的 T1 强度受脉冲序列参数影响,PET 的 SUV 虽经过标准化,但在不同重建条件下仍有偏差。所以两个模态要分开做 z-score 归一化,不能用同一套均值和方差去套。
def zscore_normalize(img, mean, std): return (img - mean) / std这里有一个最容易被新手忽略的原则:mean 和 std 只能在训练集上计算,再应用到验证集和测试集。如果对整个数据集算统计量,测试集的分布信息就混进了训练环节,这会直接抬高验证指标,属于最典型的数据泄露。正确流程是:先按受试者划分 train/val/test,再在 train 上算每个模态的均值和标准差,最后用这套统计量去标准化 val 和 test。顺序反过来,结果再好看也不能信。
处理完这一步,一份能喂给 3D 卷积网络的输入就准备好了。你可以把预处理结果存成 .npy 文件,也可以在线加载时实时做后处理。下面我按已经拿到预处理后 .npy 的情况来讲模型和训练,这样最贴近资源包里实际能运行的代码。
| 处理阶段 | 方法 | 关键参数 |
|---|---|---|
| 配准 | 仿射配准,互信息度量 | 直方图 bin=64,200 次迭代 |
| 裁剪 | 阈值 mask + 8 体素 margin | 阈值 = mean + 0.5*std |
| 重采样 | 三次样条插值 | 统一到 64×64×64 |
| 归一化 | 每个模态独立 z-score | 只看训练集统计量 |
这个表格是我复现任何影像项目时都会先维护的一份参数对照。建议你在自己机器上跑通之后,把这些值录进一个 yaml 或 json 配置文件,后续调参只改表格不动代码,能省掉很多“我之前到底怎么跑的”的疑问。
3. 3D ResNet-18 的工程实现:把 2D 残差块改造进三维空间
3.1 从 2D 到 3D 的关键改动:卷积核、步长和感受野
ResNet-18 的原版为 ImageNet 分类设计,输入 224×224×3,由四个 stage 组成,每个 stage 包含若干 BasicBlock,通道数从 64 依次翻倍到 512。迁移到三维影像时,最直观的改动是把所有 Conv2d、BatchNorm2d 换成对应的 3D 版本,但这只是第一步。
真正的差异在于空间维度的计算量。3D 卷积核同时在深度、高度、宽度三个方向滑窗,一个 3×3×3 卷积核参数是 27,是 2D 的 3 倍。但显存开销不是线性增长,而是特征图体积乘以通道数。输入 64×64×64 时,第一个 stage 的 64 通道特征图是 64³×64×4 字节,约 67MB;越往后特征图越小,计算集中在浅层。所以对显存影响最大的是输入尺寸和第一个下采样的位置,而不是通道数。
正因为 3D 计算量集中在浅层,stride 策略值得单独琢磨。原版 ResNet 第一个卷积层常用 stride=2,3D 版沿用这个设计时,空间尺寸从 64³ 直接掉到 32³,显存立刻降约 8 倍,但代价是海马体这类小结构在浅层就被压缩,细节丢了。我采取的折中是第一个卷积 stride=1,把 stride=2 放到第一个 stage 的残差块 shortcut 上,让浅层保留更多空间细节。显存会多占一部分,但分类准确率通常更高。
3.2 3D 残差块的 PyTorch 实现
下面是资源包里最核心的模块,把一个标准的 2D BasicBlock 改造成 3D:
import torch import torch.nn as nn class BasicBlock3D(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super().__init__() # 第一个 3x3x3 卷积承担下采样任务 self.conv1 = nn.Conv3d( in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False ) self.bn1 = nn.BatchNorm3d(out_channels) self.conv2 = nn.Conv3d( out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False ) self.bn2 = nn.BatchNorm3d(out_channels) self.relu = nn.ReLU(inplace=True) # 残差 shortcut:维度或尺寸不匹配时用 1x1x1 卷积对齐 self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv3d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm3d(out_channels) ) def forward(self, x): identity = self.shortcut(x) out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) out = out + identity return self.relu(out)逻辑说明:第一个 3×3×3 卷积通过 stride 参数控制整个 block 的下采样。当 stride=2 时,输出空间尺寸减半,shortcut 分支用 1×1×1 卷积把通道数和空间尺寸同时对齐,这样恒等映射才能和卷积输出逐元素相加。这个机制和 2D ResNet 完全一致,但 3D 下 stride 的位置直接决定显存峰值,所以值得单独调。
参数说明:padding=1 保证 3×3×3 卷积后空间尺寸不变,让 stride 单独控制尺寸;bias=False 是因为卷积后面接 BN 层,BN 的平移已经承担了偏置作用,保留 bias 反而造成参数冗余。一个在 2D 上无伤大雅的细节,在 3D 上因为参数量放大三倍,改起来影响反而更明显。
3.3 双流融合结构:晚期融合的代码与取舍
双流融合有两种主流做法:早期融合和晚期融合。早期融合是把 MRI 和 PET 在通道维拼接,变成 2 通道 3D 输入,走单流 ResNet-18,代码最简单;晚期融合是两个独立流各自提取特征,最后在分类头前拼接。这个资源包走的是晚期融合双流结构,也是我推荐的做法。
def make_resnet18(in_channels=1): # 按 ResNet-18 的 stage 配置堆叠 BasicBlock3D from torch.nn import Sequential, AdaptiveAvgPool3d, Linear layers = [] in_ch = in_channels channels = [64, 128, 256, 512] strides = [1, 2, 2, 2] for i, (out_ch, stride) in enumerate(zip(channels, strides)): layers.append(BasicBlock3D(in_ch, out_ch, stride=stride)) # 每个 stage 第二个 block 保持尺寸不变 layers.append(BasicBlock3D(out_ch, out_ch, stride=1)) in_ch = out_ch return nn.Sequential( nn.Conv3d(in_channels, 64, kernel_size=7, stride=2, padding=3, bias=False), nn.BatchNorm3d(64), nn.ReLU(inplace=True), nn.MaxPool3d(kernel_size=3, stride=2, padding=1), *layers, nn.AdaptiveAvgPool3d(1), nn.Flatten() ) class DualStreamResNet(nn.Module): def __init__(self, num_classes=3): super().__init__() # 两个流结构相同、权重独立 self.mri_stream = make_resnet18(in_channels=1) self.pet_stream = make_resnet18(in_channels=1) self.dropout = nn.Dropout(0.5) self.fc = nn.Linear(512 * 2, num_classes) def forward(self, mri, pet): # 每条流输出 [B, 512] 的全局特征 f_mri = self.mri_stream(mri) f_pet = self.pet_stream(pet) # 在通道维拼接,过分类头 fused = torch.cat([f_mri, f_pet], dim=1) fused = self.dropout(fused) return self.fc(fused)逻辑说明:这里没有 torchvision 里现成的 3D ResNet 模型,所以需要自己按 ResNet-18 的 stage 布局堆叠。两个流接收各自模态的单通道输入,提取到全局平均池化后的 512 维特征,拼接成 1024 维再进入分类头。模态间的交互只发生在分类头之前,好处是两个流的中间特征可以分别做可视化,方便排查到底是哪个模态在起作用。
早期融合代码更短,但前提是 MRI 和 PET 必须严格配准、体素对齐,任何一个错位都会让网络学到混乱的跨模态对应关系。而晚期融合用两个独立流允许两个模态在特征空间里有各自的偏移和尺度。显存上双流接近两倍开销,但换来的可解释性和稳定性值得多花显存。资源包里给的方案和我自己的习惯都是双流。
3.4 初始化策略与核心参数表
医学影像数据集通常只有几百到一两千样本,从头随机初始化训练 3D ResNet-18 很容易收敛到过拟合。常见做法有两种:一是用 Kinetics 或医学影像视频上预训练的 3D 模型初始化;二是用 ImageNet 预训练 2D 权重复制成 3D。后者实现上有个坑:直接把 2D 核沿深度轴复制 k 份会放大初始激活的方差,常见做法是复制后除以重复次数的平方根。我验证下来,把第一层卷积替换为随机初始化、其余层用 2D 复制权重的方案更稳定,这也是资源包里默认的做法。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 64×64×64 | 显存和细节的平衡 |
| 初始通道数 | 64 | ResNet-18 原始设置 |
| stage 通道数 | 64-128-256-512 | 跟随原版设计 |
| 输入通道 | 1(双流)或 2(早期融合) | 按融合策略切换 |
| 融合位置 | 全局池化后 | 晚期融合标准 |
| dropout | 0.5 | 小数据集防过拟合 |
| 第一层 stride | 1 | 保留海马体细节 |
这份参数表是复现时可以直接抄作业的起点。显存不够时先降输入尺寸,再降初始通道数,最后才考虑改双流为单流。顺序反了,你会为了省显存丢掉多模态的核心价值。
4. 训练与验证:从数据划分到指标读数
4.1 数据划分按受试者走,不能按样本走
医学影像数据的划分粒度必须是人,而不是单个影像文件。同一个受试者如果因为随访产生了多组扫描,这些组之间的高度相关性会放大验证指标。如果资源包里的划分代码只做随机抽样,我建议第一件事就是把它改成按 subject_id 分组。
from sklearn.model_selection import train_test_split import numpy as np subjects = load_all_subjects() # 每个元素含 mri_path, pet_path, label, subject_id X = np.arange(len(subjects)) y = [s.label for s in subjects] # 按受试者 ID 去重后再划分,保证同一个人只出现在一个集里 train_idx, val_idx = train_test_split( X, test_size=0.2, stratify=y, random_state=42 ) train_subjects = [subjects[i] for i in train_idx] val_subjects = [subjects[i] for i in val_idx]逻辑说明:stratify 参数按标签比例分层采样,保证训练集和验证集里 AD、NC、MCI 的比例一致,避免验证集里恰好一个 AD 都没有的尴尬情况。random_state 固定后,每次划分结果可复现,这对调试对比很重要。注意这里没有做三折划分,而是先把 train+val 与 test 分开,test 全程不参与调参,最后只做一次终测。毕设或小项目里最怕把测试集反复拿来调阈值,调着调着测试集就变成了验证集。
4.2 增强策略:同步增强是底线
3D CNN 在小数据上最有效的正则是数据增强。我的默认组合是随机旋转、随机平移和轻度强度扰动。增强最关键的一条是 MRI 和 PET 必须用完全相同的空间变换,否则模态间的对应关系被破坏,模型等于在学习两组对不齐的输入。
import random import numpy as np def augment_pair(mri, pet): # z 轴方向随机翻转,只翻深度方向 if random.random() > 0.5: mri = np.flip(mri, axis=0) pet = np.flip(pet, axis=0) # 绕冠状轴随机旋转 0/90/180/270 度 k = random.choice([0, 1, 2, 3]) mri = np.rot90(mri, k=k, axes=(1, 2)) pet = np.rot90(pet, k=k, axes=(1, 2)) # 轻度强度扰动只加在 MRI 上 mri = mri * (1 + random.uniform(-0.05, 0.05)) return mri, pet这里我不能不提醒一句:大脑结构左右不对称,不要做水平翻转。你一旦把 MRI 沿 x 轴镜像,左半球和右半球就换了位置,海马体等结构的解剖方位全错。这种错误增强会让模型学到完全错误的空间先验,而且很难被发现。只做 z 轴翻转和绕冠状轴的旋转相对安全,因为深度方向的翻转不破坏左右解剖结构。强度扰动只加在 MRI 上,PET 的 SUV 尽量保持原样,这是我踩过坑之后的习惯。
4.3 训练循环与超参设置
训练 3D 网络一次动辄几个小时,遇到形状错误浪费一个下午是常事。我建议所有人在跑正式训练之前,先拿 8 个样本跑 1 个 epoch 的 debug 循环,确认 forward、backward、loss 都正常,再启动完整训练。这一步叫作“先让模型跑起来,再让它跑得好”。
import torch import torch.nn as nn from torch.optim import SGD device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = DualStreamResNet(num_classes=3).to(device) criterion = nn.CrossEntropyLoss() optimizer = SGD(model.parameters(), lr=1e-3, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.MultiStepLR( optimizer, milestones=[40, 70], gamma=0.1 ) best_val_loss = float('inf') for epoch in range(100): model.train() running_loss = 0.0 for mri, pet, label in train_loader: mri = mri.to(device) pet = pet.to(device) label = label.to(device) out = model(mri, pet) loss = criterion(out, label) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() val_loss = evaluate(model, val_loader, criterion) scheduler.step() if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), 'best_model.pth')参数说明:优化器用 SGD 加 momentum 而不是 Adam,是因为实践里 Adam 在小样本医学影像上更容易收敛到某个特定的平坦解,泛化不如 SGD。milestones 在 40 和 70 epoch 把学习率降 10 倍,这是大多数影像项目的经验值,具体要随验证 loss 曲线调整。weight_decay 设为 1e-4,在几百个样本上能有效抑制过拟合。保存模型用验证 loss 而不是验证准确率,因为 loss 比 accuracy 更连续,能捕捉到概率分布层面的变化。
4.4 评估指标:别让准确率骗了你
医学影像三分类里,只看 accuracy 是最容易翻车的做法。假设数据里 NC 占 60%、AD 占 25%、MCI 占 15%,一个把所有样本都判成 NC 的模型也能拿 60% 准确率,看起来好像还可以,实际完全不可用。AD 检测临床场景里,漏掉一个 AD 的代价远高于把正常人误判为 AD,所以要格外关注 sensitivity 和 F1。
from sklearn.metrics import classification_report, confusion_matrix def evaluate(model, loader, criterion): model.eval() all_preds = [] all_labels = [] total_loss = 0.0 with torch.no_grad(): for mri, pet, label in loader: mri = mri.to(device) pet = pet.to(device) label = label.to(device) out = model(mri, pet) loss = criterion(out, label) total_loss += loss.item() preds = out.argmax(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(label.cpu().numpy()) print(classification_report( all_labels, all_preds, target_names=['NC', 'AD', 'MCI'] )) print(confusion_matrix(all_labels, all_preds)) return total_loss / len(loader)逻辑说明:classification_report 会打印每个类别的 precision、recall、F1。我要特别看 AD 类别的 recall,这是漏诊率的最直接反映。混淆矩阵则能告诉你模型具体把哪两类搞混了——AD 被误判成 MCI 和 AD 被误判成 NC,临床意义完全不同,前者还可以接受,后者就是严重漏诊。如果你的验证集 F1 一直上不去,先别急着改模型结构,去看混淆矩阵再决定下一步。
5. 避坑指南:显存、数据泄露与归一化陷阱
5.1 显存不足:3D 输入让显存直接翻倍
现象:把 batch size 设成 8,输入尺寸 96×96×96,训练刚开始就报 CUDA out of memory。
原因:3D 卷积中间特征图体积远超 2D。64 通道、输入 96³ 的一个样本,光第一个 stage 特征图就有 64×96³×4 字节约 226MB,再加反向传播的中间变量,8 个样本轻松吃掉大半张 24GB 卡。
解决:先把输入尺寸降到 64×64×64,这一步最有效;再把 batch size 调低到 4 或 2;最后开启自动混合精度(AMP),显存占用能降约三到四成。如果还不行,就要把初始通道数从 64 降到 32,牺牲一点精度换可训练性。我第一版跑了 96³ 加 batch 8,在 2080 Ti 上直接 OOM,降到 64³ 加 batch 4 才能训,开了 AMP 之后才跑完全部 100 个 epoch,这段经历基本是所有 3D 影像项目的第一课。
5.2 数据泄露:按受试者划分数据,不要按文件划分
现象:验证集准确率高达 96%,测试集却只有 60%,两者差距大到不合理。
原因:最常见的原因是把同一个受试者的不同影像序列同时切进了训练集和测试集。AD 影像里同一个人的 MRI 与 PET 高度相关,模型等于见过“答案”。另一个常见泄露是全局计算归一化参数,用整个数据集的均值方差去标准化训练集、验证集,把测试集的分布信息混进了训练环节。
解决:按 subject_id 分组后再划分,保证同一个受试者只在一个集里出现。归一化统计量只在训练集上计算,应用到验证和测试集之前先冻结住。这两条规则我后来写进了项目的 Makefile 里,每次跑数据都强制检查一遍。数据泄露是最难发现的坑,它的表现是训练指标虚高,而你不知道哪里出了问题。
5.3 类别不平衡:主导类别吞掉损失
现象:训练 loss 正常下降,但 AD 类别的 recall 只有三成左右,模型把所有样本都预测成 NC。
原因:交叉熵损失不内置类别权重,当 NC 样本数远多于 AD 时,网络预测成 NC 的经验风险最低,自然往主导类别倾斜。ADNI 数据集里 NC 数量常常是 AD 的两三倍,这个倾斜非常明显。
解决:给交叉熵加类别权重,或者用 WeightedRandomSampler 在采样时主动补足少数类。权重最简单粗暴的取法是各类别样本数的倒数,再按中位数归一化:
# 假设类别顺序 [NC, AD, MCI] class_counts = torch.tensor([400, 150, 80], dtype=torch.float) weights = 1.0 / class_counts weights = weights / weights.median() criterion = nn.CrossEntropyLoss(weight=weights.to(device))参数说明:权重越大的类别,梯度惩罚越强,网络会倾向于把边界样本判给该类。如果你的任务里 AD 漏诊代价更高,可以在倒数权重的基础上把 AD 类再放大 1.5 倍。但别调过头,权重太大会让模型疯狂预测 AD,把灵敏度拉高、特异度拉到没法看。平衡点通常需要做一次小网格搜索。
5.4 BatchNorm 在小 batch 下崩溃
现象:训练时 loss 一直震荡,验证指标纹丝不动,像卡住了一样。
原因:3D 网络的显存压力让 batch size 往往只有 4 或 8,BatchNorm 统计量的噪声很大。医学影像之间个体差异大,小 batch 下的均值和方差估算不稳,网络反向传播时不断被不稳定的归一化拉偏。
解决:优先把 batch size 提到 8 以上;做不到就把 BatchNorm3d 换成 GroupNorm,group 数设为 8 或 16。GroupNorm 不依赖 batch 维统计量,batch size 为 1 时也能稳定训练。这个改动在 2D 网络里可换可不换,在 3D 网络里经常是决定训练能否收敛的关键。
5.5 配准误差被当成有效特征
现象:预处理认真的模型反而比不配准的模型准确率低。
原因:配准到 MRI 空间的 PET 如果边缘填充了零值,网络会学到“边缘有黑块就是 AD”的规律。PET 视野比 MRI 小,配准后常有大片零值区域,这类边缘伪影成了模型最强的特征,真实的脑区特征反而被忽略。
解决:检查配准后 PET 的有效脑区覆盖率,把低于 95% 的样本剔除或重新配准。也可以在预处理里生成大脑 mask,把 mask 外的体素统一置零,并把这个 mask 应用到两个模态上,这样网络看不到配准边界的伪影。这一步用 5 分钟做一次可视化检查,能省掉后面三天调模型的痛苦。
6. 进阶验证:如何证明多模态融合真的有效
6.1 消融实验是必须交的作业
多模态模型要回答一个关键问题:MRI+PET 到底是比单模态好,还是只是多了一路参数。做法是把模型退回单模态,训练 MRI-only 和 PET-only 两个单流 3D ResNet-18,在同一个测试集上比较指标:
| 模型 | Accuracy | F1-score(AD) |
|---|---|---|
| MRI only | 0.78 | 0.72 |
| PET only | 0.75 | 0.68 |
| MRI + PET(双流) | 0.86 | 0.82 |
如果融合模型没有显著超过两个单模态,那多模态设计就是在自欺欺人。我习惯在比较 F1 之外再用 DeLong test 比较两个模型的 ROC AUC 差异,这是医学影像论文里的标准做法。AUC 的 95% 置信区间也能顺带算出来,报告时更有说服力。
6.2 Grad-CAM 可视化验证
指标只是数字,医学影像模型还需要回答“看到了什么”。用 3D Grad-CAM 对最后一层卷积的特征图求梯度加权平均,可以得到注意力热图,再按切片叠加到 MRI 的轴状位上。AD 患者的注意力分布应该集中在内侧颞叶、海马体附近;如果注意力集中在脑壳边缘或白质区,说明模型学到的不是病理特征,而是预处理伪影。
我从这个项目之后养成了一个习惯:每次做完医学影像分类,先消融、再可视化、最后才谈上线。消融实验回答“有没有必要”,Grad-CAM 回答“学得对不对”。这两个验证加起来只需要半天时间,却至少能挡掉三四次“拿一个不可靠模型去填论文”的冲动。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取