
简介面向深度学习课程设计与毕业设计场景这份资料聚焦阿尔茨海默症识别任务给出了基于残差网络的优化模型实现涵盖二维切片、三维卷积及医疗影像预训练三种可运行方案适合具备一定Python基础、希望上手图像分类项目的学习者参考。压缩包体积约2.06MB共13个文件包括6个Python脚本、3个Jupyter Notebook、2篇论文、1份预测结果CSV和1个说明文档分别承担模型训练、交互演示、理论参考和结果分析等作用代码与文档分开整理便于按需检索。目前已有90人学习下载。借助代码与论文的对照读者可以理解残差网络在医学影像上的微调方式、三维建模流程以及预训练权重迁移策略同时可参考测试集预测结果辅助调试既能用于课程设计也能作为初期项目立项参考。1. 阿尔茨海默症识别为什么课程设计都盯上ResNet优化模型做深度学习课程设计猫狗分类和手写数字已经很难出彩了阿尔茨海默症AD的 MRI 识别是既好讲又容易出结果的选题。AD 早期脑结构变化微弱普通浅层网络很难抓住ResNet 的残差结构能有效训练深层网络配合预训练模型做迁移学习几百张医学影像也能在课程设计的时间周期里收敛出可用的分类器。这篇文章不是泛泛讲原理而是按我实际跑过的流程写下来数据集怎么准备、ResNet 怎么改造、训练参数怎么调以及几个容易翻车的坑。适合要交课程设计、准备深度学习期末项目或者刚接触医学影像识别的同学。2. 残差结构与迁移学习ResNet能识别AD影像的核心逻辑2.1 残差块解决了什么深层网络不是越深越好在 ResNet 出现之前VGG 已经把网络推到 19 层再往上加层数训练集准确率反而下降。这不是过拟合而是梯度回传时层层衰减浅层权重几乎得不到有效更新网络退化成了一个“深而不深”的模型。ResNet 的关键改动是引入恒等捷径连接让每一层学习的是输入和输出的残差而不是完整映射。用公式说就是y F(x) x当某个残差块已经学得够好时F(x)趋近于零网络可以自动跳过这一块不会因为额外增加层数而变差。这个特性在 AD 识别里很有用。AD 患者的 MRI 关键特征分散在不同尺度颞叶皮质变薄是几十像素级别的局部纹理变化而脑室整体扩大是全局结构变化。浅层网络擅长边缘和纹理深层网络才能提取“海马体萎缩”这种抽象语义特征。ResNet 把这两者通过残差连接融合起来让梯度可以直接流向浅层训练 50 层甚至 101 层都不会出现梯度消失。课程设计里最常见的选择是 ResNet50它比 ResNet18 有更强的特征表达能力又不像 ResNet101 那样对训练数据量要求苛刻。2.2 迁移学习用ImageNet预训练权重还是从零训练医学影像数据量普遍很小ADNI 公开集里 AD/NC 各几百例每例 MRI 切成 2D 切片后能到几千张但对于从零训练的 ResNet50 来说仍然不够。ImageNet 预训练权重学到的是通用视觉特征比如边缘、角点、纹理组合这些底层特征在自然图像和医学影像之间是通用的。MRI 里的脑沟回边缘和 ImageNet 里的边缘检测器本质上没有区别。因此课程设计里几乎都用torchvision提供的预训练权重而不是随机初始化。有人担心 ImageNet 权重和医学影像域差异太大从零训练更“干净”。实际经验是除非你手里有超过一万张标注切片否则从零训练的 ResNet50 在 AD 识别上很容易崩溃。几百张样本根本喂不饱 2500 多万个参数你会看到训练集 loss 能降到 0.1验证集准确率却一直在 60% 上下波动这就是过拟合。而预训练模型只需要把最后一层全连接换掉微调前面几层参数更新量小即使数据少也能稳定收敛。你说这算是“深度学习知识点”还是“工程技巧”我把它归为课程设计里最值得移植的实战经验。2.3 课程设计里的“优化”到底优化什么标题里的“优化模型”不是指把 ResNet 改成某种全新结构而是在原始 ResNet 基础上做针对 AD 影像的适配。常见做法有三种。第一种是改造分类头把原版 ResNet 最后一个全连接层从 1000 类输出改成 2 类或 3 类并加入 Dropout 防止微调时过拟合。第二种是调整训练策略医学影像的 BatchNorm 统计量很容易被小 batch size 干扰所以要把学习率调低、使用余弦退火或 ReduceLROnPlateau。第三种是融入注意力机制比如在残差块后加 SE 模块Squeeze-and-Excitation让网络更关注海马体区域。但要注意注意力机制不是万能的我在课程设计里见过有人加了 SE 后验证集准确率反而掉了 2%因为数据量太小额外参数成了负担。真正的“优化”往往不在网络结构上。数据预处理、按患者划分数据、类别不平衡处理这三件事对最终准确率的影响远大于网络结构微调。下面两章先讲数据再讲模型这是复现一个课程设计最稳的路径。3. 把MRI数据喂给ResNet数据集预处理与数据加载代码3.1 数据集选择与目录组织从ADNI到课程设计能用的子集阿尔茨海默症识别的公开数据集首选 ADNIAlzheimers Disease Neuroimaging Initiative申请后可以拿到 T1 加权 MRI原始格式通常是 NIfTI.nii文件。课程设计不建议直接拿 3D 体积输入模型3D CNN 对显存和训练时间的要求远超学期范围。最常见做法是把 3D MRI 切成 2D 切片取轴状位中间层区域因为海马体、脑室这些关键结构都在中间切片上顶部和底部主要是头皮和颅骨对分类没有帮助。目录组织按 PyTorch 的ImageFolder约定就好。在你的训练目录下建train/AD、train/NC、val/AD、val/NC其中 NC 代表正常对照。注意 ADNI 的原始标签和实际类别不完全一致申请后要先根据诊断字段重新分组不要直接用文件名。每例受试者可能有多条扫描记录同一受试者的所有切片必须放进同一个集合否则会数据泄漏这一点我放到第 5 章专门讲。先把 NIfTI 转成 2D 切片并保存为 PNG这样后面用ImageFolder加载最简单。import SimpleITK as sitk import numpy as np import os from PIL import Image def nii_to_slices(nii_path, out_dir, prefix, start_ratio0.2, end_ratio0.8, step5): img sitk.ReadImage(nii_path) arr sitk.GetArrayFromImage(img) # (depth, height, width) d arr.shape[0] start int(d * start_ratio) end int(d * end_ratio) os.makedirs(out_dir, exist_okTrue) for idx, i in enumerate(range(start, end, step)): slice_2d arr[i, :, :].astype(np.float32) # 将MRI强度值线性映射到0-255避免异常值拉伸 pmin, pmax np.percentile(slice_2d, [1, 99]) slice_2d np.clip((slice_2d - pmin) / (pmax - pmin 1e-6), 0, 255) img_uint8 slice_2d.astype(np.uint8) pil_image Image.fromarray(img_uint8) pil_image.save(os.path.join(out_dir, f{prefix}_{idx:04d}.png))这里有几个说明。step5控制切片间隔同一 MRI 相邻层高度相关全部切出来会产生大量冗余样本模型会过拟合到颈部位置而不是病变特征。percentile裁剪是医学影像的常规操作因为 MRI 强度值有极端离群点直接 min-max 会让图像对比度极低。start_ratio0.2和end_ratio0.8用来避开颅骨和头皮层不同数据集的脑部位置略有差异可以根据arr.shape调整。3.2 用PyTorch写Dataset和DataLoader归一化与数据增强切片保存成 PNG 后最简单的加载方式是torchvision.datasets.ImageFolder。但用它之前要先统计整个训练集的均值和标准差因为 MRI 灰度分布和 ImageNet 的 RGB 分布完全不同。直接用 ImageNet 的mean[0.485, 0.456, 0.406]也不是不行但收敛速度会变慢而且验证集准确率一般会低 1% 左右。我一般先跑一遍数据统计脚本再用统计值做归一化。import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader # 先计算训练集mean/std这里用ImageFolder的路径 train_dir dataset/train tmp_dataset datasets.ImageFolder(train_dir, transformtransforms.ToTensor()) means torch.zeros(3) stds torch.zeros(3) for x, _ in tmp_dataset: for c in range(3): means[c] x[c].mean() stds[c] x[c].std() means / len(tmp_dataset) stds / len(tmp_dataset) print(mean:, means, std:, stds)计算完成后再正式定义训练和验证的数据加载器。这里要注意灰度 PNG 被ImageFolder读取后是单通道但 ResNet 的conv1期望 3 通道输入所以需要在transforms里用Grayscale(num_output_channels3)复制成三通道。train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomRotation(degrees10), # 小角度旋转模拟头部轻微偏移 transforms.RandomHorizontalFlip(p0.5), # 左右翻转海马体左右对称性允许 transforms.ColorJitter(brightness0.05, contrast0.05), transforms.ToTensor(), transforms.Normalize([0.449, 0.449, 0.449], [0.226, 0.226, 0.226]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.449, 0.449, 0.449], [0.226, 0.226, 0.226]) ]) train_dataset datasets.ImageFolder(train_dir, transformtrain_transform) val_dataset datasets.ImageFolder(val_dir, transformval_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue)上面RandomHorizontalFlip是一个有争议的点。AD 患者的左右海马体萎缩往往不对称水平翻转会人为制造大量“左右颠倒”样本让模型学到对称性先验可能削弱对单侧萎缩的敏感性。但对于课程设计翻转能显著增加数据量且 AD 的许多整体性特征脑室扩大、皮质弥漫性萎缩左右都有表现所以我通常会保留只是把概率降到 0.3。另一种做法是只在训练第一个阶段用翻转第二阶段关闭这属于调参的玄学你可以自己试。3.3 类别不平衡与跨患者划分ADNI 里 AD 样本和 NC 样本数量并不均衡如果直接按切片读某些患者切片特别多会导致模型偏向特定个体。先按患者分组再按 8:1:1 拆分训练、验证、测试集这是医学图像识别的底线操作。ADNI 每个患者的文件名前缀带有S编号可以通过解析文件名提取受试者 ID。碰到类别不平衡时我推荐两种办法。第一种是给CrossEntropyLoss传入weight向量让少数类的损失放大。第二种是使用WeightedRandomSampler让每个 batch 里多数类和少数类的比例趋近 1:1。下面是 WeightedRandomSampler 的简单写法。from torch.utils.data import WeightedRandomSampler targets [s for _, s in train_dataset.samples] class_counts [torch.bincount(torch.tensor(targets))] weights_per_class 1.0 / class_counts.float() sample_weights [weights_per_class[t] for t in targets] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size32, samplersampler, num_workers4, pin_memoryTrue)replacementTrue表示有放回采样这样每个 epoch 里少数类被抽到的次数稳定增加但要注意训练轮数过多时模型会对少数类过拟合。比较合理的做法是我上面代码里的不加大采样力度配合weight损失函数一起用见 4.2 节。先跑一个 baseline再看看是否需要重度平衡。4. 微调ResNet并改造分类头完整训练流程与关键参数4.1 加载ResNet预训练模型并改造全连接层模型部分用 PyTorch 的torchvision加载 ResNet50 预训练权重。这里需要注意版本差异新版torchvision不再推荐model models.resnet50(pretrainedTrue)这种写法会提示改用weights参数。我习惯用下面的方式同时把第一层卷积的输入通道也检查一遍因为我们已经把灰度图复制成三通道所以conv1不用改。import torch import torch.nn as nn import torchvision.models as models model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) num_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.4), nn.Linear(num_features, 512), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(512, 2) )这里把最后一层fc替换成了两层的 MLP。为什么要加中间层ResNet50 的fc原本是 2048 维直接映射到 1000 类现在要映射到 2 类如果只接一个Linear(2048, 2)参数虽然少但模型对特征分布的刻画太线性容易欠拟合。中间加一层 512 维的全连接配合 Dropout能更好地拟合 AD 和 NC 在特征空间中的非线性边界。要注意torchvision的resnet50输出是 2048 维所以num_features通常是 2048不要写死。另一个可选的优化是冻结前几层。课程设计训练时间宝贵我通常会冻结layer1及之前的所有参数只微调layer2之后的层和新的fc。因为layer1学习的是低级别边缘特征这些特征在自然图像和 MRI 上是通用的不需要再更新。用requires_grad_可以快速冻结。for name, param in model.named_parameters(): if name.startswith(layer1): param.requires_grad False这样训练参数量从 2500 万降到不到 1500 万显存占用和训练时间都会减少。如果你的电脑没有独立 GPU只靠 CPU 跑这个冻结策略几乎决定你能否在课程设计截止日期前跑完。4.2 优化器、损失函数与学习率策略优化器首选 AdamW因为它把权重衰减和 Adam 的梯度滑动解耦比单纯 Adam 加 L2 正则好调。学习率不能直接用默认的 1e-3微调预训练模型时常用 1e-4 到 3e-4因为网络的初始权重已经在一个较好的解附近学习率太大会直接破坏预训练特征。损失函数用带类别权重的CrossEntropyLoss。在 3.3 节统计好类别比例后计算权重公式通常是w_c total_samples / (n_classes * samples_c)这样多数类权重小于 1少数类权重大于 1。下面给出组合代码。import torch.optim as optim class_counts torch.tensor([250, 120], dtypetorch.float) # 示例: NC250, AD120 total class_counts.sum() weights total / (2 * class_counts) weights weights.to(device) criterion nn.CrossEntropyLoss(weightweights) optimizer optim.AdamW(model.parameters(), lr1e-4, weight_decay5e-4) scheduler optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.5, patience3, verboseTrue )ReduceLROnPlateau的modemin是配合验证集 loss 用的patience3表示连续 3 个 epoch 验证 loss 不下降就把学习率减半。比固定StepLR更灵活不需要手动猜测在第几轮衰减。weight_decay5e-4是 ResNet 迁移学习里的常见取值太大的衰减会让预训练特征被“洗掉”太小则起不到正则效果。4.3 训练循环、早停与模型保存训练循环本身不复杂重点是早停和保存策略。我见过很多课程设计代码把最后一个 epoch 的模型保存下来但这几乎总是让你错过验证集最好的那个点。正确做法是每次验证集准确率提升就覆盖保存同时记录连续多少次没有提升就停止训练。best_val_acc 0.0 patience 5 bad_epochs 0 for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) model.eval() val_loss 0.0 correct 0 total 0 with torch.inference_mode(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) _, preds torch.max(outputs, 1) correct (preds labels).sum().item() total labels.size(0) val_acc correct / total avg_val_loss val_loss / total if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_resnet50_ad.pth) bad_epochs 0 else: bad_epochs 1 scheduler.step(avg_val_loss) if bad_epochs patience: print(f早停在第 {epoch1} 轮最优验证准确率 {best_val_acc:.4f}) break注意这里有两个细节。第一scheduler.step(avg_val_loss)必须传入验证 loss而不是验证准确率虽然准确率也能用但 loss 的变化更平滑不容易被几个 batch 的偶然正确率带偏。第二早停的 patience 设为 5在 30 轮里属于中等偏短如果你数据量大可以放宽到 8但课程设计时间有限5 轮已经很稳妥。模型保存用state_dict而不是整个模型这样只保存参数加载时还需要实例化同样结构的模型。我在课程设计答辩时经常被问“为什么保存的是 state_dict”这个回答能体现你对 PyTorch 内部机制的熟悉程度。5. 训练AD识别模型的五个典型坑现象、原因与规避5.1 训练集准确率99%实际测试一塌糊涂数据泄漏现象是训练和验证准确率都极高但换一批新患者的 MRI准确率落到 60% 左右。原因十有八九是同一个患者的多个切片同时出现在训练集和验证集里。MRI 相邻切片之间高度相似模型只需要记住该患者个体特征就能区分而不是学习 AD 的病理特征。解决方法是按患者 ID 而不是按切片划分数据集。具体实现可以在 3.1 节的转换脚本中把prefix设置为患者 ID之后划分时用prefix分组。这是医学影像深度学习最经典的血泪经验课程设计里踩过的人不少。5.2 灰度图变三通道后Loss不降反升归一化统计量不对现象是训练 loss 一直抖动在 1.0 左右很少下降。原因是用了 ImageNet 的mean[0.485, 0.456, 0.406]来归一化灰度图复制成的三通道数据但灰度的像素分布和自然图像完全不匹配导致预训练权重不知道怎么处理输入。解决方法是自己统计训练集的 mean/std见 3.2 节或者直接使用单一的 mean/std 值复制三份。我个人经验是MRI 强度值在 0 到 255 之间统计出来的 mean 大约在 0.45 左右std 在 0.22 左右。不要偷懒用默认值这一步决定了后续训练能不能正常收敛。5.3 模型每次预测都是NC类别不平衡没处理现象是验证集准确率看着不低但以 AD 患者的召回率极低混淆矩阵里 AD 那一类几乎没有正样本。原因是数据集中 AD 数量远少于 NC加上损失函数默认对所有类别等权模型发现全预测为 NC 也能拿到 70% 的准确率。解决方法是使用类别权重或加权重采样。注意如果课程设计里比较在意准确率和召回率那么应该在报告里同时给出 F1 分数和混淆矩阵而不是只用准确率。这个坑通常是到了写实验报告前才发现处理起来成本最低但需要你训练前就统计好类别分布。5.4 单机显存不够训练直接OOMbatch size和图像尺寸没调整现象是程序跑起来几秒后报CUDA out of memory尤其是批量加载 224x224 三通道图时ResNet50 的中间特征图在 batch size 32 下占用巨大。解决方法是先降 batch size 到 16 或 8如果还不行再把图像 Resize 到 192x192 而不是 224x224。注意 Resize 到 192 会影响 ResNet 的池化输出但 ResNet 是全局平均池化尺寸不完全匹配也能跑只是最后fc的特征维数不会变。另一种更彻底的办法是冻结前几层减少反向传播需要保存的中间激活值这个在 4.1 节已经提过。如果是在 Ubuntu 环境配了 GPU 版深度学习环境仍然 OOM可以用nvidia-smi查看是否有其他进程占用显存。5.5 早停后加载的模型不是最好的保存时机和条件写错现象是训练结束后用保存的模型做测试准确率比训练时验证集显示的结果低。原因很常见保存模型的代码放在每个 epoch 结束时无条件覆盖导致最终保存的是最后一个 epoch 的模型而不是验证集最优的那个。解决方法是只在验证集准确率提升时保存并且用一个变量记录best_val_acc。上面的 4.3 节代码里已经展示。还要注意model.eval()和torch.inference_mode()在测试时不能少否则 BatchNorm 和 Dropout 会继续使用训练模式的行为结果也会有轻微偏差。这种问题排查起来非常容易忽略因为代码不报错但指标就是不对。6. 用Grad-CAM验证模型真的在看海马体可解释性技巧模型训练完准确率可能已经达标但课程设计答辩时老师最常问的是“你凭什么说模型学到的特征是疾病相关的而不是某个噪声”这时 Grad-CAM 是最好的解释工具。它通过把类别预测分数对最后一个卷积层输出的梯度做全局平均池化得到每个特征通道的权重然后加权叠加生成热力图。热力图高亮的位置就是模型做出分类决策时关注的图像区域。如果热力图集中在海马体和侧脑室周围说明模型确实学到了和 AD 相关的脑结构变化。相反如果高亮区域散落在颅骨边缘或背景区域那就要怀疑数据预处理或者数据泄漏导致模型在“偷题”。我把 Grad-CAM 的实现封装成下面这个函数用的是pytorch_grad_cam库事件中没有做额外修改。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget from pytorch_grad_cam.utils.image import show_cam_on_image target_layer model.layer4[-1] cam GradCAM(modelmodel, target_layers[target_layer], use_cudaTrue) input_tensor val_image.unsqueeze(0).to(device) target ClassifierOutputTarget(1) # 1代表AD类别 grayscale_cam cam(input_tensorinput_tensor, targets[target])[0] visualization show_cam_on_image(demo_img, grayscale_cam, use_rgbTrue)运行 Grad-CAM 前必须把模型切到eval()模式否则 Dropout 会随机屏蔽一部分神经元热力图会抖动。实测中哪怕同一个样本两次eval()状态下的热力图也可能有细微差异这是正常现象。如果你没有装pytorch_grad_cam库也可以手写一个几十行的反传过程但课程设计里直接用第三方库更省时间答辩时能解释清楚原理就够了。除了 Grad-CAM课程设计里再补一个混淆矩阵和分类报告就已经很完整。用sklearn.metrics直接输出不需要额外造轮子。我个人的习惯是每次训练完成先跑测试集看混淆矩阵里哪一类容易被混再回头看 Grad-CAM 热力图是不是集中在一个区域。如果你时间有限优先把数据划分和早停做对模型结构选标准 ResNet50 就够用不用刻意加注意力模块。希望这篇笔记能帮你把课程设计从能跑通推进到讲得清少踩我踩过的那些坑。本文还有配套的精品资源点击获取