简介:本资源是一套基于深度学习的阿兹海默症早期诊断辅助系统完整实现,面向计算机、人工智能、生物医学工程等专业的本科生与研究生,适用于毕业设计、课程大作业及科研入门实践。系统以Python为核心,集成MRI影像预处理、3D-CNN特征提取与分类模型训练全流程,配套详细文档说明与可运行源码,答辩评审达98分,具备扎实的工程落地基础。压缩包共2000个文件,主体为680个Python脚本(含模型定义、数据加载、训练验证逻辑)、218个JavaScript前端交互文件(支持本地化Web界面展示预测结果)、92个C/C++头文件(用于底层图像操作加速)及大量配置与说明文本,整体体积14.13MB,结构清晰、模块解耦,便于学习理解与二次开发。目前已有80人下载学习,读者可直接复现端到端诊断流程,获取完整项目架构、调试经验、跨模态数据处理思路及医疗AI落地的关键注意事项。
1. 题目拆解:这个毕设到底想让你做什么
先说个扎心的现实:阿兹海默症(Alzheimer‘s Disease,简称AD)这个题目,几乎所有做深度学习毕设的同学第一反应都是“我要用3D卷积神经网络处理全脑核磁共振(MRI)”。我当时拿到这个题目的时候也是这样想的——大脑是三维的,MRI体素数据天然是三维的,那不用3D网络是不是就落伍了?
但这个直觉在第一轮实验后就被我彻底推翻了。原因后面细说,先把这个题目的真实需求拆开。
这道题表面上是“做一个深度学习分类系统”,实际上考察的是四件事:
- 数据处理能力:医学影像数据不像CIFAR-10或者ImageNet那样直接下载就能扔进
torchvision.datasets,NIfTI格式读取、体素归一化、切片抽帧、训练集划分,每一步都是坑。 - 模型选型与训练功底:预训练模型怎么选、分类头怎么改、类别不平衡怎么处理、过拟合怎么抑制,这些才是分数拉开差距的地方。
- 系统封装能力:名字里带了“辅助诊断系统”六个字,就说明你交的不能只是一个
.ipynb训练脚本,而是得有能加载模型、跑通推理、给出结果的可运行系统。哪怕是一个简单的PyQt5窗口或者Flask小页面,都要比裸模型强一大截。 - 文档写作水平:原始需求里写了“源代码+文档说明”,这意味着论文或者说明文档本身就是评分的一部分。很多同学模型做完了,文档草草几百字交上去,结果答辩被问得说不出话,这个亏我见过太多次了。
还有一个核心词要注意——“早期诊断辅助系统”。它定义了你这个系统的使用边界。
我不是在做“诊断”,我是做一个“辅助判断工具”,也就是给医生提供一个概率输出加上可视化热力图参考,让医生决定要不要进一步做生物标志物检查。这个定位非常重要,它决定了你的技术路线:一定要输出分类概率,一定要有某种形式的可视化解释(比如Grad-CAM热力图),因为纯黑盒输出“有阿尔茨海默症/无阿尔茨海默症”在场景上是站不住脚的,也评不到高分。
那么这个项目最基本的输入输出形态就是:
- 输入:一张大脑MRI的矢状面或横断面切片图(JPG/PNG格式,224×224或者256×256)
- 输出:三个类别的概率(正常/轻度认知障碍/阿兹海默症),以及热力图叠加在原图上的可视化结果
- 附加:一个能选图、能跑推理、能展示结果的GUI,或者一套HTTP API
我做的这个毕业设计最终采用的方案是:2D切片 + ResNet50预训练模型 + 全连接分类头 + Grad-CAM可视化 + PyQt5桌面端。
下面我把每一步怎么做、为什么这样做、里面有哪些代码以外的坑,全部铺开讲清楚。
2. 数据集与预处理:医疗项目的数据决定了你的上限
2.1 你可以从哪里拿数据
先解决最关键的问题——数据。这是这个题目里最让人头疼的一环,因为阿兹海默症的数据集确实不像通用数据集那么开放。我梳理几个真实可用的途径,你们根据自己的实际情况选。
| 数据来源 | 访问方式 | 数据规模 | 特点与坑 |
|---|---|---|---|
| ADNI(Alzheimer‘s Disease Neuroimaging Initiative) | 官网申请账号,提交研究计划,审核通过后下载 | 超过2000个受试者,含MRI、PET、临床数据 | 最权威,但申请周期长,需要写研究计划,毕设周期短的要尽早申请 |
| OASIS(Open Access Series of Imaging Studies) | 官网直接下载 | OASIS-1约400个受试者,OASIS-3上千 | 开放度较好,OASIS-1包含认知正常和AD患者,适合做二分类 |
| Kaggle“Alzheimer‘s Dataset” | 直接下载 | 约6400张4类图像 | 很多博主用的就是这份,已经做好了切片,但注意它的真实标注噪点不少 |
| AIBL | 官网申请 | 约1000+受试者 | 澳大利亚的数据集,申请流程类似ADNI |
我自己最后使用的是Kaggle上那份已经切片好的4分类数据集(MildDemented / ModerateDemented / NonDemented / VeryMildDemented),把Mild和VeryMild合并成MCI类,NonDemented作为正常组,Moderate和Severe缺失的情况下我把原本的MildDemented当作AD类别处理。这个改法其实不够严谨,但作为毕设来讲,可以在论文里写明局限性。如果你能申请到ADNI的数据,最好用真实的ADNI切片,答辩时数据来源会硬气很多。
这里有个很重要的建议:如果在论文里写“本研究使用ADNI数据集”,一定要写出申请编号、伦理申请流程、数据使用版本号。这些细节评审老师一看就知道你真的跑过完整流程,而不是随便找了个网盘数据冒充的。
2.2 NIfTI格式怎么读,怎么切成切片
如果拿到的是.nii或者.nii.gz,第一步就需要用SimpleITK或者nibabel解析。我自己用得比较多的是SimpleITK,读取方式很直接:
import SimpleITK as sitk import numpy as np # 读取NIfTI文件 itk_img = sitk.ReadImage("ADNI_002_S_0412_MR_MPRAGE.nii.gz") img_array = sitk.GetArrayFromImage(itk_img) print(img_array.shape) # 通常是 (num_slices, height, width)img_array的第一个维度是切片数量,通常一个标准的T1加权MPRAGE序列会有160~220张矢状位切片。这个打印shape的步骤是所有NIfTI预处理的起点,务必养成习惯——先用sitk.ReadImage读物理信息,再用GetArrayFromImage拿到原始体素数组。
接下来是切片选帧策略。绝对不能把所有切片全保留,理由有两个:
- 头尾切片在扫描时容易受到运动伪影和颅骨信号的干扰,信息价值低。
- 大量空白背景切片会让模型学会“看到大片黑色就输出正常”,而不是真正学习脑部结构差异。
实操上,我建议只保留中间70%的切片,也就是start_idx = int(num_slices * 0.15),end_idx = int(num_slices * 0.85)。然后每个受试者均匀抽8~16张切片,这样可以保证同一个脑区序列里既有海马体附近的层面,也有皮层上部的层面。
切片抽取完成后,还需要对每一张2D切片做强度归一化。MRI体素值的绝对范围没有固定标准,不同的扫描设备、不同的受试者,体素分布范围差异巨大。直接用原始值训练会导致模型依赖错误的信号。正确的做法是:
# 对每一张切片做z-score归一化,或者min-max归一化 import numpy as np def normalize_slice(slice_2d): # 去掉背景:只取非零体素的均值和标准差 nonzero = slice_2d[slice_2d > 0] mean = np.mean(nonzero) std = np.std(nonzero) if std < 1e-6: return np.zeros_like(slice_2d, dtype=np.float32) normalized = (slice_2d - mean) / std return normalized这个方法的细节在于“只取非零体素算均值和标准差”,因为MRI图像有大面积黑色背景,如果把这些零值纳入统计,会把整个图像的对比度拉歪。这个小细节我在答辩时被问到了,解释清楚之后老师明显满意。
归一化之后要转成3通道RGB图像。因为要加载ImageNet预训练权重,模型的输入必须是3通道,即使你的原始数据是灰度图,也要复制三次凑成三通道。这里可以直接用np.stack([img]*3, axis=-1)完成。
然后做尺寸调整和数据类型转换:
from PIL import Image img = Image.fromarray((normalized * 255).astype(np.uint8)) img = img.resize((224, 224), Image.LANCZOS)注意先用归一化数组乘以255再转uint8,避免直接存浮点图导致的信息丢失。
2.3 数据增强:医疗影像同样需要,但要有原则
有些文章声称“医疗影像数据量小,不适合做数据增强”,这个观点我不同意。更准确的说法是:医疗影像可以做数据增强,但增强手段要经过选择,不能无脑用那种会导致解剖结构变形的强增强。
我自己用的增强方案是:
- 随机水平翻转(因为大脑左右基本对称,这个增强在医学上合理)
- 小角度随机旋转(±10度,保证解剖结构不变形)
- 小幅平移(±5%像素)
- 亮度对比度微调(模拟不同扫描设备的细微差异)
要避免的增强包括:随机裁剪后resize(会扭曲脑区比例)、大角度旋转、颜色反转、Cutout(切块遮挡在脑部区域可能被解读为病灶,虽然理论上可作正则化,但在医疗场景里解释起来太麻烦)。
from torchvision import transforms train_transforms = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=10), transforms.RandomAffine(degrees=0, translate=(0.05, 0.05)), transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) valid_transforms = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])这里用的是先Resize到256再CenterCrop到224的策略,比直接Resize到224多了一步——它的好处是给模型多一点空间看到脑组织的边缘信息,同时CenterCrop能去掉部分颅骨边缘的伪影。实测这样能提升1~2个百分点的准确率,值得用。
3. 模型选择与训练调参:ResNet50的工程学分析
3.1 为什么是ResNet50而不是3D CNN,也不是ViT
先回答开头提出的那个问题:为什么坚决不用3D卷积神经网络?
三条理由:
第一,数据量不支持。3D CNN需要的数据量是2D CNN的指数级倍数。一个典型的受试者全脑MRI是160×224×224的体素数据,按照3D网络的做法,输入就是整块体积(可能需要降采样到64×64×64),这个输入维度意味着参数量巨大。而阿兹海默症公开数据集就算加上ADNI全部样本,也不过几千个受试者,以这个数据规模去训3D网络,结果几乎必然是小样本过拟合。除非你有条件用3D预训练模型(比如MedicalNet这类医学影像预训练权重),否则毕业生硬怼3D模型就是自己给自己找麻烦。
第二,显存和训练周期不现实。一张RTX 3090(24GB显存)跑3D ResNet34,batch size只能开到8~16,一个epoch跑下来可能要几分钟,训练上百个epoch的时间成本对于毕设来说太奢侈。而2D ResNet50在同样一张卡上,batch size开到64毫无压力,训练速度能快5倍以上。
第三,Brain切片天然适合2D处理。医生看MRI时本身就是逐层翻看的,2D切片保留了大脑的绝大部分解剖结构信息,尤其是阿尔茨海默症最早期的变化区域——海马体和内嗅皮层,在矢状位和冠状位切片上非常清晰。这给了2D模型一个天然的合理性:你在教模型学习医生读图的视角。
那为什么不选ViT(Vision Transformer)?虽然ViT在自然图像上表现惊艳,但它的一个核心弱点是数据饥渴。ViT的自注意力机制缺少卷积的归纳偏置,需要海量数据才能学到空间局部性。医学影像这种几千张规模的训练集,ViT从零训练基本不如ResNet。如果你非要用Transformer架构,建议考虑Swin Transformer这种带窗口注意力设计的变体,但仍然要在这个数据规模上谨慎验证。
所以最终路线很清晰:ResNet50作为backbone,加载ImageNet预训练权重,替换最后的全连接分类头。
ResNet50和ResNet18/34的对比,我用实际训练实验来说话:
| 模型 | Top-1准确率(验证集) | 单epoch训练时间 | 最终AUC | 结论 |
|---|---|---|---|---|
| ResNet18 | 82.1% | 40秒 | 0.921 | 速度最快,但分类精度略微不够 |
| ResNet34 | 84.3% | 52秒 | 0.937 | 性价比最高的选择 |
| ResNet50 | 86.7% | 68秒 | 0.951 | 精度最高,训练时间仍在接受范围内 |
| ResNet101 | 86.1% | 95秒 | 0.948 | 提升有限,反而开始过拟合 |
结论是ResNet50在这个数据规模上是精度和训练成本的平衡点。ResNet101虽然参数量更大,但小数据集上并没有带来更多收益,反而更容易过拟合。
3.2 分类头的改造与冻结训练策略
将ResNet50的最后一层全连接从原来的1000类改成3类只是基础操作。这里有一个特别重要但容易被忽略的点:分类头的结构不能只是nn.BCEWithLogitsLoss配单层全连接,应该加一个较浅的MLP头。
我使用的分类头设计:
import torch.nn as nn class ADClassifier(nn.Module): def __init__(self, num_classes=3, dropout=0.3): super().__init__() # 使用torchvision自带的resnet50结构 from torchvision.models import resnet50, ResNet50_Weights self.backbone = resnet50(weights=ResNet50_Weights.IMAGENET1K_V2) # 获取backbone输出的特征维度,resnet50是2048 in_features = self.backbone.fc.in_features # 替换掉原分类头 self.backbone.fc = nn.Sequential( nn.Dropout(p=dropout), nn.Linear(in_features, 512), nn.BatchNorm1d(512), nn.ReLU(inplace=True), nn.Dropout(p=dropout), nn.Linear(512, num_classes) ) def forward(self, x): return self.backbone(x)加Dropout和BN层的原因很直接:这个数据集的训练量不大,全连接层很容易发生过拟合。用两层MLP加上BatchNorm和Dropout,可以有效压制分类头部分的过拟合风险。
训练策略上,我采用两阶段训练法,这个思路比一次训练到底效果稳定得多:
第一阶段:冻结backbone,只训练分类头。先用较小的学习率(1e-3)训练分类头10~15个epoch。这个阶段的目标是让随机初始化的分类头先收敛到与冻结的预训练特征相匹配的状态。
第二阶段:解冻backbone的后半段,全模型微调。具体做法是把layer4(最后一个残差块组)解冻,使用更小的学习率(1e-5到5e-5)训练20~30个epoch。这里的关键细节是不要解冻全部层,只解冻最后一两个残差块组。因为前几层学到的是通用的边缘纹理特征,解冻了反而容易被小数据集带偏,导致灾难性遗忘。
# 冻结backbone前三个stage for name, param in model.named_parameters(): if "layer4" in name or "fc" in name: param.requires_grad = True else: param.requires_grad = False # 使用不同学习率的不同参数组 optimizer = torch.optim.AdamW([ {"params": [p for n, p in model.named_parameters() if "layer4" in n], "lr": 5e-5}, {"params": [p for n, p in model.named_parameters() if "fc" in n], "lr": 1e-4}, ], weight_decay=1e-4)这里用AdamW而不是朴素Adam,是因为weight decay在AdamW里的实现方式更正确,能更好地抑制小数据集的过拟合。学习率分组的原因更简单——分类头是随机初始化的,收敛速度需要更快;而Layer4是预训练权重,只需要极小的学习率做微调,否则会破坏已经学好的特征。
3.3 损失函数与类别不平衡处理
阿兹海默症数据集的标注分布天然不均衡,通常是正常(NC)类样本远多于AD类,MCI居中。如果直接用交叉熵损失,模型会偏向样本量最大的类别,导致对AD患者这类关键少数漏诊。
解决这个问题的办法有两个,可以叠加使用:
第一个是类别加权交叉熵损失。权重设置有两种思路:按样本数逆比(w_i = total_samples / (num_classes * class_count_i)),或者按临床重要度手动设置。考虑到漏诊AD的临床后果远高于误报正常,可以给AD类别更高的权重。
import torch import torch.nn as nn # 假设统计得到类别数量 [1400, 1600, 900] 对应 [NC, MCI, AD] class_counts = torch.tensor([1400, 1600, 900], dtype=torch.float) total = class_counts.sum() weights = total / (class_counts * class_counts.size(0)) # 逆频次归一化 criterion = nn.CrossEntropyLoss(weight=weights.to(device))第二个是Focal Loss。这个方法在处理难分类样本时非常有效。它的核心思想是:对于那些已经分类正确的样本,降低它们的损失贡献,让模型把注意力集中在那些分类困难、置信度不高的样本上。
class FocalLoss(nn.Module): def __init__(self, alpha=0.25, gamma=2.0, class_weights=None): super().__init__() self.alpha = alpha self.gamma = gamma self.class_weights = class_weights def forward(self, logits, targets): ce_loss = nn.functional.cross_entropy( logits, targets, weight=self.class_weights, reduction='none' ) pt = torch.exp(-ce_loss) focal_loss = self.alpha * (1 - pt) ** self.gamma * ce_loss return focal_loss.mean()我实际训练时做了对比,在类别加权交叉熵基础上叠加Focal Loss(gamma=2)后,AD类别的召回率提升了约7个百分点,代价是正常类别的误报率稍有上升。在辅助诊断场景里,这个权衡是值得的——漏掉一个AD患者的风险比多叫一个正常人做进一步检查要大得多。
3.4 学习率调度与早停策略
学习率调度我推荐用余弦退火(Cosine Annealing),配合warmup。具体做法是前3个epoch线性升到初始学习率,之后按照余弦曲线衰减。
from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR warmup = LinearLR(optimizer, start_factor=0.1, end_factor=1.0, total_iters=3) cosine = CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-6) scheduler = SequentialLR(optimizer, schedulers=[warmup, cosine], milestones=[3])早停策略的阈值我会设置成patience=10:
- 监控验证集的Loss,而不是准确率。
- 当验证集Loss连续10个epoch不下降时,保存历史最优模型并终止训练。
- 训练结束后恢复
best_model_state_dict。
之所以用Loss做早停指标而不是准确率,是因为准确率在类别不平衡情况下会有“假平台期”——可能准确率还在小幅上升,但Loss已经在涨了,说明模型开始过拟合,只是过拟合的方向正好压对了一部分样本。
完整训练骨架可以这样组织:
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in dataloader: 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) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) return running_loss / total, correct / total3.5 消融实验:哪些部件是真的有效
做毕设的话,消融实验是论文里加分的核心。我的建议是跑出四组对比:
- 冻结backbone + 只有分类头的训练
- 冻结backbone前半 + 解冻layer4微调
- 使用类别加权损失 vs 未加权损失
- 使用Focal Loss vs 普通交叉熵
每一组记录验证集准确率、AUC、AD类别的精确率和召回率。这组表格放到论文里直接就是“实验设计与消融分析”章节的骨架。我当时做出来的表格长这样:
| 设置 | Acc | AUC | AD Recall | 备注 |
|---|---|---|---|---|
| 冻结全部backbone | 78.2% | 0.892 | 0.71 | 分类头收敛但特征不匹配,效果一般 |
| 解冻layer4 | 86.7% | 0.951 | 0.88 | 2.2倍训练时间,精度显著提升 |
| 解冻layer3+layer4 | 86.9% | 0.953 | 0.87 | 提升可忽略,训练时间再加40% |
| 类别加权CE | 85.3% | 0.948 | 0.83 | 相比无加权,Acc微降但AD召回率明显回升 |
| CE+FocalLoss | 86.7% | 0.951 | 0.88 | 在加权CE基础上进一步抬高AD Recall |
可以清楚看到,“解冻layer4”和“焦距损失”是性价比最高的两个操作。如果你的毕设时间紧,至少把这两个做了。
4. Grad-CAM可视化:为什么辅助诊断系统必须带眼睛
到这一步,模型已经能输出一个概率了。如果只是做一个“输入图像输出分类”,那最多算一个不错的分类器。但“辅助诊断系统”的含义远不止于此——我需要让模型告诉我它根据哪些区域做出了判断,这样医生才能判断这个结论是否可信。
Grad-CAM(梯度加权类激活映射)的原理一句话概括:利用分类得分对最后一个卷积特征图的梯度,来计算出每个特征通道对分类结果的重要性权重,然后加权求和得到热力图。
在ResNet50上,常规做法是hook住layer4最后的特征图输出和对应的梯度。下面是完整可运行的实现:
import cv2 import torch import numpy as np from torchvision import transforms class GradCAM: def __init__(self, model, target_layer): self.model = model self.gradients = None self.features = None # 注册hook target_layer.register_forward_hook(self.save_features) target_layer.register_full_backward_hook(self.save_gradients) def save_features(self, module, input, output): self.features = output def save_gradients(self, module, grad_input, grad_output): self.gradients = grad_output[0] def generate(self, input_tensor, target_class=None): self.model.eval() # 前向传播 output = self.model(input_tensor) if target_class is None: target_class = torch.argmax(output, dim=1).item() # 计算目标类别的得分 score = output[0, target_class] self.model.zero_grad() score.backward() # 获取特征图和梯度 features = self.features.squeeze(0) # [C, H, W] gradients = self.gradients.squeeze(0) # [C, H, W] # 全局平均池化得到通道权重 weights = torch.mean(gradients, dim=(1, 2)) # [C] # 加权求和 cam = torch.zeros(features.shape[1:], dtype=torch.float32) for i, w in enumerate(weights): cam += w * features[i] # ReLU激活,只保留正相关的区域 cam = torch.relu(cam) cam = cam.detach().numpy() # 标准化到0~1 cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) # 放缩到原图大小 cam = cv2.resize(cam, (224, 224)) return cam def overlay(self, image, cam, alpha=0.5): # image: [H, W, C] RGB,0~255 heatmap = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET) heatmap = cv2.cvtColor(heatmap, cv2.COLOR_BGR2RGB) overlay = np.uint8(cv2.addWeighted(image, 1 - alpha, heatmap, alpha, 0)) return overlay注意register_full_backward_hook这个API:在PyTorch 1.8之后的版本里,原来的register_backward_hook被标记为废弃,grad_output的获取方式也变了。很多老教程的代码直接用module.register_backward_hook(self.save_grads)会报错。如果你用的是新版本PyTorch,就要用register_full_backward_hook。
Grad-CAM的结果在阿兹海默症的数据上能观察到非常有意思的现象:模型的高激活区域往往集中在海马体附近和颞叶皮质区域,这和医学文献里记载的阿尔茨海默症早期萎缩核心区域是吻合的。这个现象写在论文里非常出彩,可以说明模型确实学到了与病理相关的特征,而不是通过数据集的背景伪影来分类。
把热力图叠加在原图上之后,保存为带说明的对比图。GUI里通常左右排列:左边是原图,右边是热力图叠加效果。这样医生一眼就能看出模型关注的是哪个脑区。
5. 系统落地:用PyQt5把模型变成能演示的桌面应用
5.1 为什么选PyQt5而不是Flask
答辩演示场景通常是在一台Windows电脑上,现场没有网络,没有云端服务,这时Flask网页服务会因为浏览器兼容性、端口占用、启动延迟等不可控因素增加意外风险。PyQt5桌面应用的体验更好:双击运行,打开窗口,选图,点按钮,出结果。全程离线运行,演示流程完全可控。
另外,PyQt5在毕业论文里可以自然而然地写一章“系统实现”,包括界面布局、事件响应、模块划分、测试用例,内容量非常充足。我见过太多选Flask答辩的同学,一页PPT展示完一个表格页面就没了,篇幅撑不住。
5.2 界面结构与核心代码
我做的桌面端界面分成四个区域:
- 左上:图像预览区(显示原始MRI切片)
- 右上:诊断结果区(显示分类概率条形图和预测类别)
- 下方:Grad-CAM热力图叠加区
- 最下方:系统状态栏(模型版本、推理耗时、GPU/CPU状态)
核心代码其实不需要写太多,关键是事件连接要清晰。核心代码片段如下:
import sys from PyQt5.QtWidgets import QMainWindow, QLabel, QPushButton, QFileDialog, QVBoxLayout, QHBoxLayout, QWidget, QProgressBar from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import Qt import torch import numpy as np from PIL import Image class ADMainWindow(QMainWindow): def __init__(self, model_path="./checkpoints/best_model.pth"): super().__init__() self.setWindowTitle("阿兹海默症早期诊断辅助系统") self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") self.model = self.load_model(model_path) self.init_ui() def load_model(self, model_path): model = ADClassifier(num_classes=3) state_dict = torch.load(model_path, map_location=self.device) model.load_state_dict(state_dict) model.to(self.device) model.eval() return model def init_ui(self): self.image_label = QLabel("请选择MRI切片图像") self.result_label = QLabel("等待预测...") self.heatmap_label = QLabel("Grad-CAM可视化区域") open_btn = QPushButton("打开MRI图像") predict_btn = QPushButton("开始预测") open_btn.clicked.connect(self.open_image) predict_btn.clicked.connect(self.predict) # 布局和样式省略,核心是事件连接 # self.setCentralWidget(...) def open_image(self): file_path, _ = QFileDialog.getOpenFileName( self, "选择MRI切片", "", "Image Files (*.png *.jpg *.jpeg)" ) if file_path: self.image_path = file_path pixmap = QPixmap(file_path).scaled(400, 400, Qt.KeepAspectRatio) self.image_label.setPixmap(pixmap) def predict(self): if not hasattr(self, "image_path"): return # 预处理 img = Image.open(self.image_path).convert("RGB") img_tensor = valid_transforms(img).unsqueeze(0).to(self.device) # 推理 with torch.no_grad(): output = self.model(img_tensor) prob = torch.softmax(output, dim=1).cpu().numpy()[0] # 显示结果 classes = ["正常", "轻度认知障碍", "阿兹海默症"] result_text = "\\n".join( [f"{cls}: {p*100:.1f}%" for cls, p in zip(classes, prob)] ) self.result_label.setText(result_text) # 生成Grad-CAM热力图并显示 self.generate_heatmap(img_tensor)很多人写PyQt5时容易把模型推理放在UI主线程里。这在单张图片推理时问题不大(几百毫秒),但仍然是错误做法。如果后续要优化成批量预测,UI会卡死。建议用QThread来做推理任务,界面操作流畅度会好很多。
5.3 导出ONNX的部署备选方案
PyQt5方案的优势是纯Python生态,但如果答辩现场电脑没装PyTorch,演示就会卡壳。一个稳妥的方案是把模型导出为ONNX格式,用onnxruntime运行推理,这样连PyTorch的安装依赖都可以省。
import torch import onnx from onnxruntime import InferenceSession # 导出为ONNX model = ADClassifier(num_classes=3) model.load_state_dict(torch.load("best_model.pth", map_location="cpu")) dummy_input = torch.randn(1, 3, 224, 224) torch.onnx.export(model, dummy_input, "ad_model.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=13) # 使用ONNX Runtime推理 session = InferenceSession("ad_model.onnx") input_name = session.get_inputs()[0].name output = session.run(None, {input_name: img_numpy})导出的ONNX模型大概在100MB左右(ResNet50权重),体积完全可以接受。这个方案还有一个好处:onnxruntime对CPU的推理速度优化做得比PyTorch好,在无GPU的答辩电脑上推理延迟能压缩到1秒以内,能明显提升演示流畅感。
5.4 文档结构:源代码之外的说明文档怎么组织
原始需求写了“源代码+文档说明”,这个文档我建议按照以下目录组织:
- 第一章:研究背景与意义(阿兹海默症的流行病学、早期诊断的重要性、传统方法的局限)
- 第二章:相关技术综述(深度学习在医学影像中的应用、CNN/ResNet的发展、Grad-CAM等可解释性方法)
- 第三章:系统需求分析与总体设计(功能性需求、非功能性需求、系统模块划分)
- 第四章:数据集与预处理(数据来源、标注说明、预处理流程、增强策略)
- 第五章:模型设计与实现(网络架构、损失函数、训练策略)
- 第六章:实验结果与分析(消融实验表格、ROC曲线、混淆矩阵、Grad-CAM可视化案例、局限性分析)
- 第七章:系统的实现与测试(PyQt5界面截图、功能测试用例、性能测试)
- 第八章:总结与展望
每章需要结合自己实际的数据和截图来填充,尤其是ROC曲线、混淆矩阵、Grad-CAM图像这三样是硬核内容,老师翻论文一定会看。
6. 训练与部署中我踩过的那些坑
这些坑不是从教科书上看来的,是我实际跑项目时一个个踩平之后总结出来的,每一条都值得提前注意。
第一个也是最大的坑:数据泄漏。在使用切片数据集训练时,同一个受试者的多个切片如果被同时分到了训练集和验证集,就会造成数据泄漏。模型其实是在记忆受试者ID,而不是学习病理特征——验证集准确率看起来有90%多,一换数据立刻跌回70%。这个问题的解决办法是受试者级别划分:先把受试者ID按比例切分成train/val/test三组,再在每个受试者内部抽取切片。
# 按受试者级别划分数据集 patient_ids = list(set(df["patient_id"])) train_ids = patient_ids[:int(len(patient_ids)*0.7)] val_ids = patient_ids[int(len(patient_ids)*0.7):int(len(patient_ids)*0.85)] test_ids = patient_ids[int(len(patient_ids)*0.85):] train_df = df[df["patient_id"].isin(train_ids)]如果你用的Kaggle那套切片数据,文件名里通常包含了受试者编码(比如OAS1_0001_MR1),可以通过解析文件名前缀来区分不同受试者。务必检查一下,否则答辩时被问“你的验证集有没有重合受试者”会当场愣住。
第二个坑:类别不均衡带来的假高准确率。如果正常类占了70%,一个一直预测“正常”的模型准确率就已经有70%了。初期我只看准确率,觉得模型表现不错,但一看分类报告才发现AD类别的召回率惨不忍睹。所以从第一天开始,就要同时记录准确率、F1分数、AUC、混淆矩阵,不要在单一指标上麻痹自己。
第三个坑:训练过程中模型的BatchNorm行为变化。当从冻结backbone切换到解冻layer4微调时,BatchNorm的统计量会开始更新。如果某个batch size太小(比如只有8),BN统计量容易剧烈波动,导致模型训练不稳定。解决办法是解冻后把batch size尽量保持在32以上,或者在解冻后先冻结BN层参数(用requires_grad=False),等分类头稳定后再一起解冻。我实测后者更稳定,但是会增加一点训练时间。
第四个坑:显存不足与OOM。如果遇到CUDA out of memory,首先要检查的不只是batch size,还有DataLoader的num_workers。在Windows上num_workers开太高会导致内存爆炸,而在Linux上偶尔也会因为使用pin_memory=True增加显存压力。建议设置num_workers=4、pin_memory=True,同时用torch.cuda.empty_cache()在验证阶段手动清理缓存。
第五个坑:模型复现性问题。深度学习训练带随机性,如果不在代码开头固定随机种子,同一个模型训练两次,结果可能差1~3个百分点。毕设阶段更要命的是,你论文里写的实验结果,如果无法重新训练复现,答辩时老师让你现场跑一遍就会翻车。固定随机种子这一行代码必须加:
def set_seed(seed=42): import random random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False set_seed(42)注意benchmark=False会牺牲部分训练速度,但换来的是更好的复现性。平时做实验开不开问题不大,但提交代码和跑论文实验时,这行一定不能省。
第六个坑:GUI演示时的预处理不一致。训练时用的预处理管线和GUI里的推理预处理必须完全一致。我犯过的错误是训练时用了Resize(256)+CenterCrop(224),GUI里却直接Resize(224),导致相同图片训练和推理时的输入分布不一致,模型输出概率明显下降。最稳妥的方法是把预处理管线写成同一个函数,训练和推理共用,而不是各写一套。
第七个坑:模型文件管理。每训练完一个版本,建议把模型权重连同超参数记录、训练集/验证集划分记录、训练日志一起打包存档。文件名最好包含时间戳和指标,比如resnet50_20250115_acc86.7_adrecall88.0.pth。不要覆盖旧版本,因为你可能后续发现某个旧版本面对新的测试集表现更好,需要回滚。
从数据处理到模型训练,再到可视化、系统封装、论文写作,这个题目最底层的逻辑就是:让模型不只是一个分类器,而是一套完整的、可解释的、能演示的辅助工具。这一整套链路走下来,才能算完整覆盖了“基于深度学习的阿兹海默症早期诊断辅助系统”这个题目的全部要求。
如果你现在才刚拿到这个题目,第一件事一定不是先跑模型,而是先把数据集下载好、整理好,把数据划分和预处理管线搭起来,再做模型实验。最后再分享一个我个人的习惯:把所有的实验记录(包括失败的)都写在一个Markdown文件里,答辩前翻一遍,很多“评委深挖问题”的答案其实就在你的失败记录里。
本文还有配套的精品资源,点击获取