☰
基于Jupyter的糖尿病视网膜病变诊断:ResNet迁移学习与项目实践
2026/9/26 5:38:18 网站建设 项目流程

简介:面向毕业设计、课程设计与项目开发的糖尿病视网膜病变智能诊断完整项目,基于Jupyter Notebook实现。项目围绕眼底图像分类任务,完整覆盖需求分析、数据来源说明、EDA探索、数据预处理与增强、模型建模、训练调参、交叉验证、错误分析及方案总结,全流程代码可复现,并针对类别间距离不等、训练与测试分布差异明显等实际问题给出了具体处理策略。资源包共30个文件,包含16个ipynb分析训练脚本、2个py工具脚本、9张图表(训练曲线、结果可视化、模型结构图等)、csv提交文件与md项目文档,整体约30.44MB,分层目录便于按模块学习。目前已有179人学习下载。方案提供EfficientNet-B4/B5模型与augment-s10训练配置、多折交叉验证、logits stacking集成思路,读者可直接参考数据增强、调参与错误分析过程,在此基础上扩展降噪、分割等后续研究。

1. 基于Jupyter实现糖网诊断:这个毕设项目到底在做什么

如果你正在准备毕业设计或课程设计,想找一个“既有实际应用场景、又有明确评判指标”的深度学习项目,糖尿病视网膜疾病诊断(Diabetic Retinopathy,简称糖网/DR)几乎是医学图像方向最稳妥的选择。项目的一整套链路——眼底图像数据集、二分类或多分类标签、卷积神经网络训练、混淆矩阵评估——都可以在 Jupyter Notebook 里直接跑完。Jupyter 不只是写代码的地方,它同时充当实验记录本和论文材料生成器:每个 cell 的输入输出、训练过程的loss曲线、中间层特征图,都能被自然保存下来,最后变成项目文档里的“实验过程”章节。

这个项目适合两类人:一类是没做过医学图像课题的同学,想用一套可控的流程完成毕设;另一类是刚入门深度学习,需要一份可扩展的源码模板。前面一种看重“项目完整度”,后面一种看重“代码能不能读懂”。基于 Jupyter 实现的好处是,你不需要把数据和代码分开在两个工具里倒腾,所有里程碑节点缩成一个 notebook,答辩演示时也能直接看到运行痕迹。这篇笔记会把数据集组织、模型训练、项目文档和常见翻车点拆开讲,让你照着做就能复现一套可验收的糖网诊断流程。

2. 从零搭建项目:数据集组织、目录设计和一个可复用的数据加载类

2.1 为什么选Jupyter而不是PyCharm/VS Code

很多初学者在 PyCharm 里写完一个训练脚本,跑完结果就关掉,最后写文档时发现“跑了什么、为什么调参”全都想不起来。Jupyter notebook 的核心价值在于它把代码和解释性文本放到同一个 cell 序列里,天然适合做“实验记录型”项目。训练糖网模型的过程本来就包含大量试错:先小数据量试跑、看分类分布、调整图像增强策略、再上全量训练。每一步的判断依据如果散落在命令行里,回头写毕业论文时很难还原;而放在 notebook 里,你可以在每个 cell 之间穿插 Markdown,把参数选择理由、观察到的现象、下一步准备改动什么,全部记录下来。

另外,Jupyter 对图像类任务的调试非常友好。眼底图像读进来是什么样的、裁剪后是否丢失病变特征、数据增强后的样本是否可接受,这些都可以用matplotlib直接显示在 cell 下方。传统的脚本方式要反复plt.savefig再打开图片检查,效率差很多。如果你之前只会 Jupyter 网页版的基础操作,这个项目足够逼你把“创建文件夹、管理内核、重启 kernel、导出报告”都练一遍,答辩时有东西可讲。

2.2 数据集选择和目录组织

常见做法是使用公开的 APTOS 2019 或 IDRiD 数据集。这类数据集的格式基本都是“一个图片文件夹 + 一个 CSV 标签文件”,标签为0到4,分别对应无病变、轻度、中度、重度和增殖性病变。考虑到毕设周期,我建议从 APTOS 的 train 文件夹里自己划分训练集和验证集,不要把全部数据都塞进训练,否则一次 epoch 就要跑很久。

拿到原始数据后,第一步是建立固定目录结构。我通常这样做:

mkdir -p diabetic-retinopathy/{data/{raw,processed},notebooks,src,models,logs,docs}

这会在项目根目录下生成data/raw(原始图片和CSV)、data/processed(划分好的训练/验证子集)、notebooks(Jupyter 文件)、src(可复用的代码模块)、models(权重文件)、logs(日志)和docs(文档与图表)。把数据、代码、产物分开的好处是:Jupyter 里的相对路径不会因为新增了某个临时文件夹而失效;模型训练出的权重统一放在models下,毕设文档中的目录说明可以直接复制这部分。

CSV 文件里一般有三列:id_code(图片名称)、diagnosis(标签)、有时候会有patient_id。不要全信 label 分布,先做一次最基础的统计:

import pandas as pd df = pd.read_csv('data/raw/aptos_train.csv') print(df['diagnosis'].value_counts()) print(f'总共图片数: {len(df)}')

这段代码的目的不是单纯看数量,而是确认类别不平衡程度。糖网数据集的0类(正常)通常占一半左右,4类(增殖)非常少,后面必须针对这个情况做处理。另一个要看的是有没有patient_id字段。如果存在同一患者的两只眼都出现在数据集里,划分训练集和验证集时就要按患者分组,不能直接随机切,否则会存在数据泄漏,验证结果虚高。

2.3 自定义Dataset类的完整实现

PyTorch 的ImageFolder对“文件夹名即标签”的数据自然好用,但糖网数据集是 CSV 提供标签,而且后续要做患者分层,我更习惯写一个自定义 Dataset。这样加载策略、图像增强路径和样本权重都能集中管理,作为一个独立的.py文件放进src里,notebook 直接导入。

from torch.utils.data import Dataset from PIL import Image import pandas as pd import os import torch class DRDataset(Dataset): def __init__(self, csv_path, img_dir, transform=None): self.df = pd.read_csv(csv_path) self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img_name = str(row['id_code']) + '.png' img_path = os.path.join(self.img_dir, img_name) image = Image.open(img_path).convert('RGB') # 统一转RGB,防止部分图像是灰度 label = int(row['diagnosis']) if self.transform: image = self.transform(image) return image, torch.tensor(label, dtype=torch.long)

这里有两个容易忽略的点。第一,convert('RGB')是必须的,因为眼底相机输出有时是 PNG 灰度模式,如果直接交给预训练模型(要求3通道)会报错或某些库自动广播成单通道导致训练特征不对。第二,返回的 label 要转成torch.long,因为 PyTorch 的交叉熵损失函数要求目标不是 one-hot,而是整数索引的 LongTensor,漏掉这一步会在训练时撞上类型不匹配。

在 notebbook 的调用侧,还需要结合图像增强:

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomRotation(degrees=15), transforms.RandomAffine(degrees=0, translate=(0.05, 0.05)), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) val_transform = 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 是 ImageNet 预训练模型的标准套路。注意不要把随机旋转放在验证集上,验证集必须是可复现的确定性变换。ColorJitter 的亮度调节对眼底图像比较安全,因为原始眼底图存在亮度不均的问题,适当的增强能提高泛化能力,但对比度不要调太大,否则病变区域的细微颜色会被洗掉。

2.4 按患者分层:验证集划分的关键操作

如果你用的数据集没有patient_id,但同一张图可能被复制过两次,也需要先查重。用文件名去重其实不靠谱,因为同患者两只眼的照片内容不一样,只有原始 CSV 里的患者字段才能识别。假设 CSV 中有patient_id,我会用 scikit-learn 的GroupShuffleSplit来划分:

from sklearn.model_selection import GroupShuffleSplit groups = df['patient_id'].values gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=groups)) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx] print(f'训练集样本数: {len(train_df)}') print(f'验证集样本数: {len(val_df)}') print('训练集包含患者数:', train_df['patient_id'].nunique()) print('验证集包含患者数:', val_df['patient_id'].nunique())

GroupShuffleSplit跟普通train_test_split的差别在于,它会保证同一个patient_id的所有行只出现在训练或验证其中一侧。你可以把划分好的 DataFrame 分别保存成train.csv和val.csv,放到data/processed/目录下。这一步虽然简单,但直接决定了后面模型评估是否可信——如果同一个患者的双眼同时出现在训练和验证集,模型相当于“见过”这个人,验证指标会漂亮不少,但这在真实临床场景里不可能,答辩时一旦被问到数据泄漏问题,很难解释。

3. 模型训练:用ResNet迁移学习跑通糖网分类的完整训练脚本

3.1 模型选型:为什么是ResNet34

糖网分类任务本质上是医学图像细粒度识别,病变特征通常出现在血管、出血点、渗出位置,这种任务不适合从头训练大型网络,因为公开数据量远不够。常见做法是采用 ImageNet 预训练权重做迁移学习。以 ResNet 系列为例,ResNet34 与 ResNet50 在单卡 11GB 显存下都能跑,但 ResNet34 参数量更小,训练速度更快,对四分类糖网这种任务精度已经足够;ResNet152 这类深层网络在小数据集上反而更容易过拟合。

torchvision.models里直接加载预训练权重,然后把最后一层全连接替换成自定义输出维度:

import torch.nn as nn import torchvision.models as models def build_resnet34(num_classes=5, pretrained=True): model = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1 if pretrained else None) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, 512), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(512, num_classes) ) return model

加两层 Dropout 是有效抑制过拟合的手段。糖网任务里类别之间差异不明显,模型容易直接“背”训练集,所以全连接层千万不要原样保留,建议换成带 Dropout 的小型分类头。in_features是从原模型动态获取的,这样即使以后换成 ResNet101 也只需要改函数名。

迁移学习时要决定是否微调前面的卷积层。我的习惯是第一轮冻结所有卷积层,只训练新加的分类头,跑 5 到 10 个 epoch,等 loss 下降稳定后,再解冻最后几层卷积进行联合微调。这样避免一开始就乱改预训练特征。

3.2 训练脚本:从优化器到训练循环

训练部分的代码是项目的核心,也是我在多次踩坑后沉淀下来的模板。下面这份源码可以直接放进notebooks/train.ipynb的单元格,也可以存成src/train.py供后台跑,两者共用配置。

import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import datasets, transforms, models from tqdm import tqdm import os import time def train_one_epoch(model, dataloader, criterion, optimizer, device): model.train() total_loss = 0.0 correct = 0 total = 0 for inputs, labels in tqdm(dataloader, desc='Training'): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += torch.sum(preds == labels.data) total += labels.size(0) epoch_loss = total_loss / total epoch_acc = correct.double() / total return epoch_loss, epoch_acc def validate(model, dataloader, criterion, device): model.eval() total_loss = 0.0 correct = 0 total = 0 with torch.no_grad(): for inputs, labels in tqdm(dataloader, desc='Validating'): inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) loss = criterion(outputs, labels) total_loss += loss.item() * inputs.size(0) _, preds = torch.max(outputs, 1) correct += torch.sum(preds == labels.data) total += labels.size(0) epoch_loss = total_loss / total epoch_acc = correct.double() / total return epoch_loss, epoch_acc

这段代码里有两个细节值得说明。第一,total_loss用loss.item() * inputs.size(0)而不是直接累加loss.item(),因为每个 batch 的 loss 是一个均值,如果最后一个 batch 样本数和其他 batch 不同,直接求平均会扭曲整个 epoch 的 loss。第二,validate里必须写with torch.no_grad(),否则验证阶段会额外消耗大量显存,而且会误更新 BatchNorm 的统计量。

训练主循环加上模型保存逻辑:

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = build_resnet34(num_classes=5).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=3, factor=0.5) num_epochs = 30 best_val_loss = float('inf') best_model_path = 'models/best_resnet34.pth' for epoch in range(num_epochs): train_loss, train_acc = train_one_epoch(model, train_loader, criterion, optimizer, device) val_loss, val_acc = validate(model, val_loader, criterion, device) scheduler.step(val_loss) print(f'Epoch {epoch+1}/{num_epochs}: ' f'train_loss={train_loss:.4f}, train_acc={train_acc:.4f}, ' f'val_loss={val_loss:.4f}, val_acc={val_acc:.4f}') if val_loss < best_val_loss: best_val_loss = val_loss torch.save(model.state_dict(), best_model_path) print(f'Best model saved at epoch {epoch+1}')

选择保存“验证集 loss 最低”的模型,而不是“验证集准确率最高”的模型,这是一个容易被忽略的教训。准确率最高的点往往出现在某个峰值过拟合时刻,之后立刻跌落;而 loss 最低对应的模型通常在收敛性和泛化性之间更均衡。ReduceLROnPlateau的patience=3意思是验证集 loss 连续 3 个 epoch 没有下降就把学习率减半,遇到训练曲线震荡时factor=0.5这样改动足够保守,不会一下把学习率降没。

3.3 类别不均衡:class weights 怎么算

如果不做任何处理,模型会把 0 类全部蒙对,其他类别一塌糊涂,最后准确率看着有 70% 以上,但中间类别的召回率低得可怕。缓解办法有两种:第一种是给损失函数加权,第二种是采样器。我两个都做,但权重计算要用实际样本分布:

import collections label_counts = train_df['diagnosis'].value_counts().sort_index().values total_samples = len(train_df) class_weights = [total_samples / (len(label_counts) * count) for count in label_counts] class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)

CrossEntropyLoss的weight参数接受一个形状为[num_classes]的张量,loss 内部会对每个样本的 loss 乘上对应类别权重。上面的公式会让少样本类别获得更高权重,同时又不会出现权重值相差几十倍导致训练发散。除了权重,还可以使用 PyTorch 的WeightedRandomSampler:

sample_weights = [1.0 / label_counts[label] for label in train_df['diagnosis']] from torch.utils.data import WeightedRandomSampler sampler = WeightedRandomSampler(sample_weights, num_samples=len(train_df), replacement=True) train_loader = DataLoader(train_dataset, batch_size=16, sampler=sampler)

注意replacement=True时,每个 epoch 不会保证所有样本都出现,但会强制每个 batch 里各类别比例接近均衡。这种采样法对训练集样本少的类别非常有用,不过采样器只在训练集使用,验证集保持自然分布,才能真实反映模型在临床数据上的表现。

4. 评估与项目文档:把模型指标写进毕设文档的关键过程

4.1 不只盯准确率:kappa、召回率和混淆矩阵

糖网诊断这个领域,Kaggle 竞赛普遍采用 Cohen‘s Kappa 作为主指标,因为它比准确率更关心类别间的一致性,尤其适合分类别间有天然顺序的医学任务。写项目文档时只用准确率会显得不专业。我一般在评估阶段把四个指标一次性算出来:

from sklearn.metrics import cohen_kappa_score, confusion_matrix, classification_report import numpy as np model.load_state_dict(torch.load(best_model_path)) model.to(device) model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) print('Kappa:', cohen_kappa_score(all_labels, all_preds, weights='quadratic')) print('Confusion Matrix:') print(confusion_matrix(all_labels, all_preds)) print('Classification Report:') print(classification_report(all_labels, all_preds, digits=4))

quadratic权重会让“相邻类别预测错误”的惩罚小于“跨两级错误”,这符合糖网病变程度递进的医学逻辑。如果模型把0类预测成2类,是严重低级错误;预测成1类还可以接受。这一点在答辩时非常加分,能显示出你理解了领域评价指标而不只是套用 sklearn 默认函数。

混淆矩阵建议画成热力图,放进论文的“结果”章节:

import matplotlib.pyplot as plt import seaborn as sns cm = confusion_matrix(all_labels, all_preds) class_names = ['0', '1', '2', '3', '4'] plt.figure(figsize=(7, 6)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=class_names, yticklabels=class_names) plt.xlabel('Predicted') plt.ylabel('True') plt.title('Confusion Matrix on Validation Set') plt.tight_layout() plt.savefig('docs/confusion_matrix.png', dpi=150) plt.show()

注意fmt='d',如果热力图里数值是小数,用fmt='d'才能正确显示整数,否则会报格式错误。另外,Matplotlib 的字体问题在国内环境中经常遇到中文乱码,如果论文要求中文标题,建议在代码开头设置:

plt.rcParams['font.sans-serif'] = ['SimHei', 'Arial Unicode MS'] plt.rcParams['axes.unicode_minus'] = False

4.2 训练曲线自动化记录

训练过程中每个 epoch 的 loss 和 accuracy 最好实时记录到日志,而不是手工复制。我会在训练循环里额外收集到 Python 列表,训练结束后绘制两张图:

import matplotlib.pyplot as plt plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(train_losses, label='Train Loss') plt.plot(val_losses, label='Val Loss') plt.xlabel('Epoch') plt.ylabel('Loss') plt.legend() plt.title('Loss Curve') plt.subplot(1, 2, 2) plt.plot(train_accs, label='Train Acc') plt.plot(val_accs, label='Val Acc') plt.xlabel('Epoch') plt.ylabel('Accuracy') plt.legend() plt.title('Accuracy Curve') plt.tight_layout() plt.savefig('docs/training_curves.png', dpi=150) plt.show()

很多同学的论文里训练曲线是后期用 Excel 手搓的,不仅麻烦还容易被质疑真实性。在 Jupyter 里这套代码直接从上一步的变量生成,和实验时间线一一对应。建议在训练结束后立刻保存 plot,同时把train_losses、val_losses等列表保存为 CSV 或 numpy 文件,论文里要补充不同 batch_size 的对比实验时,直接把多次训练结果合并到一张图上。

4.3 项目文档结构和Jupyter导出

毕设项目文档一般包括:需求背景、数据说明、系统设计、模型实现、实验结果、总结与展望。这些章节如果全用 Word 写,代码截图和实验结果很难同步更新。更好的做法是先在 Jupyter 里把每个阶段写成 cell 之间的 Markdown,最后用nbconvert导出 Notebook 作为实验报告附件,再用 Word 写正式论文。导出命令:

jupyter nbconvert --to markdown train.ipynb --output-dir docs/ jupyter nbconvert --to html train.ipynb --output-dir docs/

--to markdown会把 notebook 转成带代码块的 Markdown,可以直接粘贴到 Typora 或 CSDN(如果允许)。--to html则保留所有 cell 的输出结果,方便直接作为材料提交。这里有一个很实用的细节:notebook 里的图片输出默认是 base64 内嵌在 HTML 里,如果文件太大会卡;可以在导出之前用jupyter nbconvert --to markdown --NbConvertApp.output_images_dir=docs/images,把图片单独提取到docs/images目录,Markdown 引用相对路径,这样文档和图片的耦合更清晰。

docs/目录下的文件组织建议是这样的:

路径内容
docs/training_curves.png训练和验证曲线
docs/confusion_matrix.png混淆矩阵
docs/classification_report.txt分类指标输出
docs/train.ipynb带注释的完整笔记本
docs/README.md项目说明和运行入口

README 里写清楚环境依赖(pip install torch torchvision pandas scikit-learn matplotlib)、数据集路径、训练命令。答辩老师通常不看代码细节,但会扫描 README 有没有写清楚复现方法,这一步能让项目完整度提升一个档次。

5. 避坑指南:Jupyter环境、数据集划分和训练过程的5个真实教训

5.1 坑一:anaconda的jupyter打不开,日志里报 kernel 错误

现象:在 Anaconda Prompt 输入jupyter notebook后浏览器里没有页面,或者打开后新建 notebook 时显示“内核已挂掉”。

原因:最常见是 conda base 环境跟系统 Python 的路径冲突,或者是 jupyter 客户端与 jupyter-server 版本不匹配。特别是安装 PyTorch 或 TensorFlow 后,包管理器自动升级了多个依赖,导致当前环境的jupyter_core和jupyter_client版本不一致。

解决:不要盲目重装 Jupyter。先执行:

jupyter --version conda list | findstr jupyter

然后尝试更新所有相关包:

pip install --upgrade jupyterlab notebook jupyter_client jupyter_core

如果还是打不开,考虑是否会串了多个 Python 环境。jupyter notebook 网页版登录入口有时候被很多网上教程绕得云里雾里,其实只需要在终端启动后复制生成的 token 链接,或者用jupyter notebook --ip=127.0.0.1 --port=8888手动指定端口重启。

5.2 坑二:数据集路径包含中文或空格,读图时 FileNotFoundError

现象:明明文件在数据集里,Image.open(img_path)却报FileNotFoundError或者OSError: [Errno 22] Invalid argument。

原因:Windows 系统下的 Python 处理包含中文、空格或括号的路径时有时候没有问题,但如果数据放在桌面上,而你用的图片加载库是从 PIL 或 opencv 两个入口混着读,输入编码不一致就会翻车。另外,pd.read_csv读到的图片名如果带有不可见字符(例如换行符),拼接路径后就会找不到。

解决:把整个项目路径设为纯英文,例如C:\Projects\DiabeticRetinopathy,并在代码里统一使用:

import pathlib img_dir = pathlib.Path('data/raw/train_images') img_path = img_dir / (img_name + '.png')

下载的数据集如果文件名自带中文,建议先批量重命名:

import os import re for fname in os.listdir('data/raw/train_images'): new_name = re.sub(r'[^\w\-.]', '_', fname) os.rename(os.path.join('data/raw/train_images', fname), os.path.join('data/raw/train_images', new_name))

5.3 坑三:显存溢出 CUDA out of memory,训练中断

现象:跑第一、第二个 epoch 正常,第三个 epoch 突然报CUDA out of memory。

原因:训练过程中 PyTorch 不会立即清理中间变量,前向传播时保存的所有激活值在反向传播后可能仍被计算图持有。很多时候是因为验证集使用了过大的batch_size,或者验证集加载时没有torch.no_grad()。

解决:先把 batch size 从 16 降到 8,同时把验证阶段包在with torch.no_grad():里。还可以在每轮结束后调用两个清理语句:

torch.cuda.empty_cache() gc.collect()

注意empty_cache()只清理 PyTorch 缓存,不是直接的显存释放剂,不要把它写进训练循环每 step 调用,否则性能损失很大。如果在多卡训练,还要检查torch.cuda.set_device是否把默认显卡指向了显存更大的卡。

5.4 坑四:训练损失不降,准确率一直在 20% 左右

现象:loss 在 1.5 左右纹丝不动,准确率基本等于随机猜,其中某一类偶尔上升之后又掉下去。

原因:常见原因有三个。第一,学习率太大,模型在 loss 表面震荡;第二,图像没有做归一化,像素值在 0 到 255,而预训练模型期望 0 到 1 之间且按 RGB 均值做标准化;第三,预训练权重加载失败,模型从零初始化的状态开始训练,收敛速度自然很慢。

解决:在build_resnet34里显式打印第一层卷积权重均值:

with torch.no_grad(): print(model.conv1.weight.mean().item())

如果输出是 0.02 左右的小数,说明预训练权重成功加载;如果是随机初始化常见的 0.002 附近,检查pretrained=True是否被外层代码覆盖。另外,把学习率直接设成1e-4,并确认transforms.Normalize的 mean 和 std 是 ImageNet 默认值,不要随手换成自己瞎猜的数字。

5.5 坑五:验证集准确率虚高,提交测试一塌糊涂

现象:训练完在验证集上准确率 90% 以上,杀进测试集或者重新跑一份新数据后直接掉到 50%。

原因:最常见的是没有做患者分层,或者数据增强过度导致模型学到了图片背景噪声。另一个隐蔽原因是验证集是从原始 CSV 里随机抽的,图片可能带有医院设备水印或暗角,模型通过这些伪特征判断了类别。

解决:检查划分代码里是否真的用了GroupShuffleSplit。如果没有患者字段,退而求其次也要保证同一个id_code的重复图片不出现在划分两端。同时在数据探索阶段去看几张对应类别的图片,判断数据集本身是不是存在“某一类图片明显更亮”的系统性偏差。这种系统偏差在糖网公开数据集里很少见,但遇到假按钮病时一定要警惕。

6. 进阶技巧:用Grad-CAM给模型结果做“医学解释”并验证可靠性

6.1 Grad-CAM可视化眼底图像的关注区域

模型预测的可靠性能否被医学解释,几乎决定了一个毕设作品的上限。糖网病变特征集中在血管周围、出血斑点和渗出位置,如果模型能关注到这些区域,说明它学到的是真正的生理特征,而不是背景噪声。Grad-CAM 是最常见的类激活热力图做法,不需要重新训练模型,只要一行代码包装模型即可:

from torchvision.models.feature_extraction import create_feature_extractor def grad_cam(model, image_tensor, target_class): model.eval() model.zero_grad() extractor = create_feature_extractor(model, {'layer4': 'features'}) features, outputs = None, None with torch.enable_grad(): out = extractor(image_tensor) features = out['features'] logits = model(image_tensor) score = logits[0][target_class] score.backward() grads = features.grad.mean(dim=(2, 3), keepdim=True) cam = torch.relu((grads * features).sum(dim=1, keepdim=True)) cam = torch.nn.functional.interpolate(cam, size=(224, 224), mode='bilinear') return cam.squeeze().cpu().detach().numpy()

这段代码要对单张图调用,注意features的requires_grad需要在 extractor 里默认开启,建议直接把模型放到train模式但不用 batch norm 更新统计量时,干脆在with torch.enable_grad()里重建一次前向。实际调试中,用create_feature_extractor很容易把layer4的输出 hook 到错误位置,更稳妥的方法是把模型替换为带register_forward_hook的简易类。不管怎么实现,最终热力图叠加到原始图像上要能看出高亮区域集中在微血管密集区域,这可以作为毕设论文的“讨论”章节素材。

6.2 最后的小习惯

做完 Grad-CAM 之后,记得检查模型在验证集上预测错误的样本,把错分的图片和热力图拼在一起看。如果模型把重度病变预测成轻度,而热力图焦点完全不在病变区域,那就说明特征表征还不够好,可以考虑解冻更多卷积层再训练;如果热力图高亮区域恰好是出血点,哪怕最终指标一般,答辩时也可以讲“模型具有一定可解释性”。另外,每次修改参数都另存一个 notebook,不要在一个文件里反复覆盖——版本是毕设的后悔药。我习惯保存实验记录表:模型名称、学习率、batch size、epoch、验证 kappa、准确率、备注,最后对比表格一出来,论文的“对比实验”章节就有依据。

希望这篇笔记能帮到你,按这个“Jupyter 数据组织 + ResNet 迁移学习 + 项目文档导出”的路线,糖网诊断完全可以在 30 个 epoch 内跑出一个可靠结果。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询