简介:医学影像智能分析是计算机视觉在医疗领域最重要的应用方向之一,其中眼底图像分析更是眼科临床诊断的关键环节。深度学习方法通过构建多层神经网络,可自动从大量标注数据中学习病变特征,从而实现病灶的快速辅助筛查。以糖尿病视网膜病变(DR)分级为例,其本质是一个典型的图像分类任务,常借助迁移学习技术,利用ImageNet预训练模型在少量医学图像上进行微调,有效提升小样本场景下的识别精度。在工程实现中,常使用Jupyter进行交互式实验,配合PyTorch搭建训练流程,并采用二次加权Kappa系数作为核心评价指标,以更贴合临床对分级误差的容忍度。从数据预处理、类别不平衡处理,到模型融合与Grad-CAM可视化,完整流程展示了如何将通用图像分类技术与医学应用需求结合,为眼底筛查等实际场景提供可落地的技术方案。
1. 项目概述与核心思路
1.1 为什么选这个题目,值不值得做
糖尿病视网膜病变(Diabetic Retinopathy,简称DR)是糖尿病最常见的微血管并发症之一,也是全球工作年龄人群致盲的首要原因。国内糖尿病患者基数大,很多人确诊时已经出现眼底病变,而眼底筛查恰恰是发现早期病变最直接的手段。问题在于,一个经验丰富的眼科医生一天能看的眼底片子是有限的,基层医疗机构又普遍缺乏专业阅片人员。这种情况下,用深度学习模型做DR的自动分级筛查,既是临床的真实需求,也是计算机视觉在医疗影像领域最有代表性的落地场景之一。
从毕业设计的角度来说,这个题目有几个天然优势。第一,任务定义清晰——给眼底图像按病变严重程度分级,0到4共五类,既不是目标检测那种框坐标的复杂任务,也不是分割那种逐像素标注的重活,是一个标准的图像分类问题,非常适合在有限时间内做深做透。第二,公开数据集很成熟,EyePACS和APTOS都是Kaggle上可以直接下载的DR分级数据集,前人踩坑记录多,遇到问题容易查。第三,评价指标明确,既看分类准确率,也看二次加权Kappa系数(这是DR比赛里最权威的指标),答辩的时候拿数据说话,比空谈理论有说服力得多。第四,整个项目可以完整覆盖数据加载、预处理、模型训练、评估、可视化这条主线,正好能展示一个学生“从数据到模型”的完整工程能力。
我遇到过不少同学选题目的时候奔着“高大上”去,结果做了两个月连数据都跑不起来。DR诊断这个方向恰好卡在一个很好的平衡点上——难度足够体现工作量,又不至于难到一个人搞不定。如果你有三个月时间,按我下面这套流程走,复现一个85%左右准确率、Kappa系数0.8以上的模型是完全可行的。
1.2 技术选型:为什么用Jupyter,而不是写脚本
很多初次接触深度学习的人会纠结一个问题:既然最终要训练模型,为什么不直接用PyCharm写.py脚本,非要绕一圈用Jupyter?我的回答是:这个项目的性质决定了Jupyter是更合适的选择。
训练深度学习模型本质上是一个“探索-调整-验证”的循环过程。你要不断改数据预处理参数、调学习率、观察损失曲线、看样本预测结果,每一步都需要即时反馈。Jupyter Notebook天生就是干这个的——代码按单元格(cell)执行,你可以在训练完一个epoch后立刻画图看loss曲线,可以可视化一个batch的增强结果确认预处理没搞错,可以在模型预测完直接展示图片和概率分布。这种交互式的工作流,用传统脚本写的话,你得不停地print日志、保存中间结果、写可视化代码,效率低很多。
另外,Jupyter对项目分段的展示能力也很适合毕设答辩。你把数据探索放在一个cell,把预处理放在另一个cell,把模型定义、训练循环、评估代码依次排列,旁边用Markdown写清楚每一步的思路和结论,导师打开你的notebook就能沿着你的思路走一遍全流程,这种“代码+文档+图表”三位一体的形式,本身就是一份极好的项目说明文档。
不过丑话说在前面。用Jupyter不等于“不用写工程代码”。真正训练的时候,建议把模型定义抽成独立的.py模块,notebook里只用import调用。我见过太多人把几百行模型定义堆在一个cell里,跑一次训练要从前到后重新执行一遍,不仅浪费时间,还容易因为中间某个cell被改过导致状态错乱。正确做法是:notebook负责流程控制和可视化,.py文件负责稳定的功能模块(模型结构、数据集类、训练函数),两边配合着来。
2. 核心细节解析与实操要点
2.1 糖尿病视网膜病变分级到底是怎么分的
在写任何代码之前,先把临床背景弄明白。DR的国际分级标准(International Clinical Diabetic Retinopathy Disease Severity Scale)把眼底病变程度分成5个级别:
| 级别 | 分类 | 临床特征 |
|---|---|---|
| 0 | 无病变 | 眼底正常,无明显异常 |
| 1 | 轻度非增殖期 | 仅有微动脉瘤 |
| 2 | 中度非增殖期 | 出现棉絮斑、出血点,但程度轻于重度 |
| 3 | 重度非增殖期 | 四个象限都有出血,静脉串珠,视网膜内微血管异常 |
| 4 | 增殖期 | 出现新生血管,玻璃体出血,视网膜脱离 |
模型要做的就是输入一张眼底彩照,输出这5个类别的概率分布,取最高概率作为预测分级。说白了,这就是一个标准的5分类图像识别问题。
但这里有个临床上的实际困难——轻度病变和中度病变的边界相当模糊,微动脉瘤和出血点在小尺寸图片上很难分辨,即使是医生之间标注的一致性也会打折扣。所以训练数据里天然存在“标签噪声”,这也是为什么医学影像比赛的评估指标通常选Kappa系数而不是简单的准确率——Kappa系数会惩罚“错误的严重程度”,比如把0级预测成4级,比把0级预测成1级要扣分更多,这更贴合临床实际。
理解了这层关系,你就知道后续模型设计的重心了:特征提取网络要足够强(用预训练的ResNet或EfficientNet),损失函数要能处理类别不平衡(用加权交叉熵或Focal Loss),评估要看Kappa而不是死盯准确率。这三个决定直接决定了项目的上限。
2.2 数据集选择:公开数据集怎么用
DR领域的公开数据集主要有三个:EyePACS、APTOS 2019、IDRiD。
EyePACS是Kaggle上2015年DR检测比赛的数据集,训练集有35126张图,测试集53576张,规模最大,分级标签是0-4五类。缺点是图像来源复杂,不同设备拍摄,分辨率不统一,有些图像存在曝光不足、失焦、黑边等质量问题。APTOS 2019是Kaggle上的另一个比赛,来自印度Aravind眼科医院,训练集只有3662张,但有专业的标注团队,标签质量更高,而且图像相对规整。IDRiD是印度的一个学术数据集,总共只有516张,但包含像素级分割标注,如果你只做分级,一般不会优先选它。
对于毕设而言,我推荐这个组合:用APTOS作为主要训练集,再加EyePACS的筛选子集做数据扩充。原因很简单——APTOS标签更干净,模型能学到更真实的特征;EyePACS量大但噪声多,全量扔进去容易让模型学到错误模式。我第一次做的时候图省事,直接用了全部EyePACS训练,结果在验证集上表现很差,后来发现是里面大量低质量图像在搞鬼。如果不想做太多清洗工作,就先用APTOS的3662张全量训练,配合图像增强,效果完全够用,等模型跑通了再看要不要加EyePACS。
下载好数据之后,目录结构建议这样组织:
dr_project/ ├── data/ │ ├── train_images/ # 训练图片 │ ├── train_labels.csv # 标签文件(image_id, diagnose) │ └── sample_submission.csv # 测试集样本(可选) ├── src/ │ ├── model.py # 模型定义 │ ├── dataset.py # 数据集类与数据增强 │ ├── train.py # 训练函数 │ └── utils.py # 工具函数(画图、指标计算等) ├── notebooks/ │ ├── 01_数据探索与预处理.ipynb │ ├── 02_模型训练.ipynb │ └── 03_评估与可视化.ipynb └── checkpoints/ # 模型权重保存目录这个结构的好处是逻辑清晰、各模块解耦,论文写系统设计章节的时候直接照抄目录结构说明就行。
2.3 图像预处理与数据增强的坑
眼底图和普通自然图不太一样,预处理有几个关键点值得注意。
第一是黑边和背景裁剪。眼底图像通常是大圆形的视网膜区域,周围是纯黑背景,这部分不包含任何有效信息,直接送进网络反而是干扰。常见的做法是先用阈值分割或边缘检测找到瞳孔中心,然后以中心为基准裁剪出正方形区域。Kaggle历届高分方案里,Ben Graham提出的预处理思路很经典:缩小图像、去掉黑边、裁剪瞳孔区域、再缩放。实测下来,裁剪后的图片训练速度更快,准确率也能提升一两个点。
第二是图像尺寸的选择。视网膜图像的原始分辨率差异很大,从几百像素到三千像素都有。对分类任务来说,不是分辨率越高越好——分辨率高了显存就爆,训练速度也慢,而网络能提取到的有效特征在224×224和512×512之间差距并没有那么大。我实测在EfficientNet-B3上,输入尺寸从224提高到384,Kappa能提升2%左右,但显存占用翻了将近两倍。综合来看,用256×256或320×320是一个性价比比较高的选择。
第三是数据增强的策略。医学图像不适合做太激进的空间变换,比如左右翻转没问题,但上下翻转在临床上没有意义(眼睛不会倒着长)。保守方案是:随机水平翻转+随机旋转(10度以内)+随机亮度对比度调整+轻微颜色抖动。这些增强既要增加数据多样性,又不能破坏病变特征的真实性。我见过有人把Cutout或Mixup用在眼底图上,效果褒贬不一,如果你想试,建议在基线模型跑通之后作为消融实验来做,而不是一开始就叠满。
2.4 标签不均衡怎么处理
DR五分类的数据分布很不均衡,0级(无病变)通常占一半以上,4级(增殖期)只有百分之几。如果直接拿原始分布训练,模型会倾向于把所有样本都预测成0级,这样准确率看起来很高,但Kappa会非常难看。
解决方法有几个方向:
第一种是类别权重(class weight)。在计算交叉熵损失的时候,给少数类别更大的权重,损失函数可以这样写:
import torch.nn as nn class_weights = torch.tensor([0.3, 1.0, 1.2, 1.5, 2.0]).cuda() criterion = nn.CrossEntropyLoss(weight=class_weights)权重设多少不是拍脑袋定的,最好根据训练集各类别样本数的反比来计算。比如0级有1800张,4级有200张,那4级的权重大概是0级的9倍,但实际调的时候不能拉满,否则模型会矫枉过正,把很多0级误判成4级。我一般是按反比算出来后再做个根号缩放,效果更稳。
第二种是Focal Loss。这个损失函数最早提出是为了解决目标检测里的前景背景类别不均衡,后来也被用到DR分级上。核心思想是让模型聚焦到难分类的样本上,通过调节gamma参数抑制易分类样本的loss贡献。
第三种是重采样(resampling),也就是对少数类做过采样或对多数类做下采样。在3662张这种小规模数据集上,过采样效果还可以,但要注意打乱样本顺序,防止模型对同一张图的增强版本记忆过深。
我个人的经验是先把类别权重方案跑通,模型收敛正常了再考虑Focal Loss,两个方案都做消融记录在论文里,这本身也是一个不错的创新点展示。
3. 实操过程与核心环节实现
3.1 环境搭建:从零配置Jupyter深度学习环境
这部分是很多新手最头疼的,因为坑确实多。我用Anaconda来管理环境,这是目前最省心的方案。
# 创建独立环境,Python版本选3.9比较稳 conda create -n dr_project python=3.9 # 激活环境 conda activate dr_project # 安装Jupyter和ipykernel conda install jupyter notebook conda install ipykernel # 把当前环境注册到Jupyter,这样notebook里才能选到 python -m ipykernel install --user --name dr_project --display-name "DR" # 安装深度学习框架 conda install pytorch torchvision torchaudio cudatoolkit=11.8 -c pytorch # 如果你有NVIDIA显卡,这个命令会安装GPU版本;没有显卡就装CPU版本 # conda install pytorch torchvision torchaudio cpuonly -c pytorch # 其他常用库 pip install pandas matplotlib seaborn opencv-python scikit-learn tqdm装好之后启动Jupyter:
jupyter notebook浏览器会自动打开notebook界面,记得切换kernel到你注册的DR环境。如果浏览器没自动打开,就复制终端里显示的带token的localhost链接手动打开。
有个常见问题是Windows上jupyter命令提示“不是内部或外部命令”,这通常是因为Anaconda没有被正确添加到系统PATH。解决办法是在Anaconda Prompt里启动,或者手动把Anaconda的Scripts目录加到PATH里。
3.2 数据集类与数据加载的实现
PyTorch里做数据加载,核心是写一个继承torch.utils.data.Dataset的类。这里有几个容易踩坑的细节,我直接贴完整代码:
import os import cv2 import numpy as np import torch from torch.utils.data import Dataset import albumentations as A import pandas as pd class DRDataset(Dataset): def __init__(self, df, img_dir, transform=None, is_train=True): self.df = df self.img_dir = img_dir self.transform = transform self.is_train = is_train def __len__(self): return len(self.df) def __getitem__(self, idx): img_name = self.df.iloc[idx]['image_id'] label = self.df.iloc[idx]['diagnose'] img_path = os.path.join(self.img_dir, img_name + '.png') # 注意:有些数据集的图片是.jpg后缀,灵活处理 if not os.path.exists(img_path): img_path = os.path.join(self.img_dir, img_name + '.jpg') # 用cv2读取,OpenCV读出来是BGR格式,后面要转RGB img = cv2.imread(img_path) if img is None: raise ValueError(f'图片加载失败: {img_path}') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) if self.transform: img = self.transform(image=img)['image'] return img, torch.tensor(label, dtype=torch.long)然后定义数据增强和加载器:
train_transform = A.Compose([ A.Resize(256, 256), A.RandomRotate90(), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.5), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)) ]) valid_transform = A.Compose([ A.Resize(256, 256), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)) ]) from torch.utils.data import DataLoader train_loader = DataLoader( train_dataset, batch_size=32, shuffle=True, num_workers=4, pin_memory=True ) valid_loader = DataLoader( valid_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True )这里有个特别容易被忽略的点:A.Normalize用的mean和std是ImageNet数据集的统计值,而不是你自己数据的。这是预训练模型的标准做法,因为模型是在ImageNet上预训练的,输入分布必须对齐它训练时的分布。很多人忘了这个转换,导致迁移学习效果大打折扣。
3.3 模型构建与迁移学习
模型这块,我建议直接用torchvision.models里现成的预训练权重,不要自己从头训。一方面因为数据量太少(3662张),完全从头训的模型效果很差;另一方面,迁移学习是DR项目里最重要的技术点,论文里也方便写。
import torchvision.models as models def get_model(model_name='resnet50', num_classes=5, pretrained=True): if model_name == 'resnet50': model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = nn.Linear(in_features, num_classes) elif model_name == 'efficientnet_b3': model = models.efficientnet_b3(weights=models.EfficientNet_B3_Weights.IMAGENET1K_V1) in_features = model.classifier[1].in_features model.classifier[1] = nn.Linear(in_features, num_classes) return model需要注意,torchvision新版本和旧版本加载预训练权重的API不一样。以前是models.resnet50(pretrained=True),新版本推荐用weights=参数,因为旧的参数在新版里已经被标记为废弃。如果你用的版本比较新,直接抄我上面的写法就行。
ResNet50和EfficientNet-B3是我实测过的两个比较稳的选择,简单对比一下:
| 模型 | 参数量 | 输入尺寸 | 显存占用 | 验证集Kappa | 训练时间(单卡) |
|---|---|---|---|---|---|
| ResNet50 | 25.6M | 224×224 | 约3GB | 0.82 | 约40分钟 |
| ResNet50 | 25.6M | 256×256 | 约4GB | 0.84 | 约60分钟 |
| EfficientNet-B3 | 12.3M | 320×320 | 约5GB | 0.86 | 约90分钟 |
如果你显卡只有4G显存,ResNet50+224输入是极限了;如果有8G或以上,推荐EfficientNet-B3+256或320输入。资金紧张的实验室一般用的是云服务器或学校GPU集群,Batch Size开小一点就够跑。
3.4 训练循环与学习率策略
训练循环的代码看起来大同小异,但细节决定成败。我直接贴一个最常用的版本:
import torch import torch.nn as nn from tqdm import tqdm from sklearn.metrics import cohen_kappa_score, accuracy_score def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 all_preds = [] all_labels = [] for images, labels in tqdm(loader, desc='Training'): images = images.to(device) labels = labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) acc = accuracy_score(all_labels, all_preds) kappa = cohen_kappa_score(all_labels, all_preds, weights='quadratic') return total_loss / len(loader), acc, kappa def validate(model, loader, criterion, device): model.eval() total_loss = 0 all_preds = [] all_labels = [] all_probs = [] with torch.no_grad(): for images, labels in tqdm(loader, desc='Validating'): images = images.to(device) labels = labels.to(device) outputs = model(images) loss = criterion(outputs, labels) total_loss += loss.item() probs = torch.softmax(outputs, dim=1) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) all_probs.extend(probs.cpu().numpy()) acc = accuracy_score(all_labels, all_preds) kappa = cohen_kappa_score(all_labels, all_preds, weights='quadratic') return total_loss / len(loader), acc, kappa, np.array(all_probs)训练的主循环外加一个学习率调度器:
model = get_model('efficientnet_b3').cuda() criterion = nn.CrossEntropyLoss(weight=class_weights.cuda()) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # 学习率热启动+余弦退火,这是Kaggle竞赛里验证过的稳定组合 num_epochs = 30 warmup_epochs = 3 from torch.optim.lr_scheduler import CosineAnnealingLR # 先用线性warmup,再转余弦退火 def warmup_cosine_schedule(epoch): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs else: return 0.5 * (1 + np.cos(np.pi * (epoch - warmup_epochs) / (num_epochs - warmup_epochs))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=warmup_cosine_schedule) best_kappa = 0 for epoch in range(num_epochs): train_loss, train_acc, train_kappa = train_one_epoch( model, train_loader, optimizer, criterion, device ) val_loss, val_acc, val_kappa, val_probs = validate( model, valid_loader, criterion, device ) scheduler.step() print(f'Epoch {epoch+1}/{num_epochs} | ' f'Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} Kappa: {train_kappa:.4f} | ' f'Val Loss: {val_loss:.4f} Acc: {val_acc:.4f} Kappa: {val_kappa:.4f}') # 保存最好的模型 if val_kappa > best_kappa: best_kappa = val_kappa torch.save(model.state_dict(), 'checkpoints/best_model.pth') print(f'模型已保存,Kappa: {best_kappa:.4f}')关于优化器,我强烈推荐AdamW而不是SGD。虽然很多论文里说SGD泛化性更好,但那是针对大数据集、长训练周期的情况。在3662张图的小数据集、30个epoch的短训练里,AdamW收敛更快、对学习率的敏感度更低,对新手更友好。学习率从1e-4开始,不要用默认的1e-3,因为迁移学习的场景下,预训练权重已经在特征空间的一个比较好的位置上,打得太狠容易忘掉原来学到的知识。
3.5 Grad-CAM可视化:让模型“说出”依据
毕设答辩的时候,导师一定会问一个问题:“你的模型为什么做这个判断?”如果你只拿准确率说话,说服力是不够的。这时候就需要Grad-CAM(梯度加权类激活映射)来可视化模型关注的位置。
import cv2 import numpy as np import torch import torch.nn.functional as F def grad_cam(model, image_tensor, target_layer, device): model.eval() gradients = [] activations = [] def backward_hook(module, grad_input, grad_output): gradients.append(grad_output[0].detach()) def forward_hook(module, input, output): activations.append(output.detach()) handle_forward = target_layer.register_forward_hook(forward_hook) handle_backward = target_layer.register_full_backward_hook(backward_hook) image_tensor = image_tensor.unsqueeze(0).to(device) output = model(image_tensor) _, pred = torch.max(output, dim=1) # 只用预测类别的梯度 model.zero_grad() one_hot = torch.zeros_like(output) one_hot[0][pred] = 1 output.backward(gradient=one_hot) handle_forward.remove() handle_backward.remove() weights = gradients[0].mean(dim=(2, 3), keepdim=True) # 全局平均池化 cam = (weights * activations[0]).sum(dim=1, keepdim=True) cam = F.relu(cam) # 归一化到0-255 cam = cam.squeeze().cpu().numpy() cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) cam = cv2.resize(cam, (image_tensor.shape[3], image_tensor.shape[2])) return cam使用的时候,对测试集里每个类别挑几张典型图片做可视化,把热力图叠加在原图上,红色区域就是模型判断为病变的关键区域。如果模型把一张0级的正常图片判断成了4级,但热力图显示它关注的是视盘边缘的正常高亮区域,这就说明模型学到了错误的特征,你就能有针对性地做数据清洗或调整预处理。
3.6 模型融合:最后一公里的提升
单个模型跑到0.85的Kappa之后,想再往上提就很费劲了。这时候有个性价比极高的手段——模型融合。简单来说,就是训练多个模型,对它们的概率输出取平均。
具体操作分两个层面。第一个层面是同架构不同随机种子,同一个EfficientNet-B3训练三遍,每次只改随机种子,最后对softmax输出做平均。这个方法能提升1%-2%的Kappa,原理是不同随机种子带来的模型多样性,让预测概率更稳健。第二个层面是不同架构融合,比如ResNet50和EfficientNet-B3各训一个,对两个模型的输出做加权平均。
final_probs = (probs_resnet50 * 0.4) + (probs_efficientnet * 0.6)权重怎么给?可以在验证集上网格搜索,从0.1到0.9步长0.1,找一个Kappa最高的组合。
这里要提醒一句:模型融合在投稿或部署场景下是有争议的,但在毕业设计里作为性能提升手段完全没问题。论文里把融合后的Kappa和单模型的Kappa放一起对比,再解释一下为什么融合能提升,这就是一个合格的“实验与结果分析”章节。
4. 常见问题与排查技巧实录
4.1 数据加载与预处理阶段的坑
问题1:图片读取失败,报错“cv2.error: OpenCV(4.9.0) ...”。
这是最常见的坑。APTOS数据集的图片后缀是.png,但EyePACS数据集里有些是.jpg,有些是.png,还有一些是损坏的图片。如果你的代码里硬编码了后缀,遇到不一致就会崩。解决方案一个是像我上面代码那样写一个后缀自动适配,另一个是在构建Dataset之前先用脚本扫描一遍所有图片,把损坏的剔除或修复。
# 扫描损坏图片,把可用的列出来 import cv2 import os from tqdm import tqdm valid_images = [] for img_name in tqdm(os.listdir(img_dir)): img_path = os.path.join(img_dir, img_name) try: img = cv2.imread(img_path) if img is not None and img.shape[0] > 100 and img.shape[1] > 100: valid_images.append(img_name) except: pass问题2:显存不足(CUDA out of memory)。
这个问题的解决方案按优先级排列:先调小batch size(32→16→8),再调小图片尺寸(256→224),最后才考虑换更轻量的模型。如果你调小batch size之后训练结果变差了,可以尝试梯度累积,也就是每4个小batch才更新一次参数:
accumulation_steps = 4 optimizer.zero_grad() for i, (images, labels) in enumerate(loader): outputs = model(images) loss = criterion(outputs, labels) / accumulation_steps # 除以累积步数 loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()这样等效于batch size放大了4倍,但显存只占原来的四分之一不到。
4.2 训练过程中的坑
问题3:loss不下降,准确率一直卡在50%左右。
首先要检查标签有没有对齐。我犯过这种低级错误——写Dataset的时候索引偏移了一位,导致图片和标签错位,模型学到了一个随机映射。验证方法:取出一个batch,把图像用plt.imshow显示出来,同时打印对应的label,人工核对是否合理。
如果标签没问题,那大概率是学习率设置不合理。学习率过大的话loss会在初始值附近震荡甚至爆炸,过小的话loss下降得比蜗牛还慢。用1e-4作为初始值,如果发现loss震荡,减小到3e-5或者1e-5。
还有一个情况是类别权重设置得太极端,少数类权重过大,导致loss在前期剧烈波动。这种时候先不要用类别权重训一个baseline,确认能正常收敛了再叠加权重,方便排查。
问题4:训练集Kappa接近0.95,验证集只有0.7,过拟合了。
在小数据集上过拟合是必然的,关键是控制过拟合的程度。几个有效的手段:
- 增加数据增强的强度,比如加入随机裁剪、色彩抖动
- 加深dropout(在分类头之前加一个
nn.Dropout(0.5)) - 提前停止(early stopping),在验证集Kappa连续5个epoch不提升时保存最优权重并终止训练
- 做数据加固,把EyePACS里干净的子集补进训练集,扩大训练数据量
4.3 评估与部署阶段的坑
问题5:测试集Kappa和验证集Kappa差距很大。
这通常说明训练/验证的划分方式有问题。如果直接用train_test_split随机划分,同一个患者的左右眼图片可能同时出现在训练集和验证集里,模型实际上“见过”了验证集的数据(因为两只眼睛的相似度很高)。正确的划分方式是按患者ID分组,确保同一个患者的所有图片只出现在一个集合中。APTOS数据集的CSV里没有提供患者ID,但你可以通过图片命名推测(有些名字的前缀就是患者ID),或者干脆靠文件夹来组织。如果实在分不了患者级,至少要在论文里承认这个局限性。
问题6:Jupyter里训练跑到一半kernel挂了,白忙一场。
这个真的是血泪教训。Jupyter的kernel如果崩溃,内存里所有的变量、模型权重都会丢。两个防御措施:第一,每个epoch结束自动保存checkpoint(torch.save),这样即使kernel崩了,也能从上一个epoch的权重继续训练,而不是从头再来。第二,训练时间比较长的任务(超过半小时),不建议在notebook里直接跑,建议把训练脚本写到.py文件里,在终端用nohup python train.py > train.log 2>&1 &后台执行,log里能看到进度,也不怕关掉浏览器。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| jupyter命令找不到 | Anaconda未加入PATH | 用Anaconda Prompt启动 |
| 图片无法加载 | 路径/后缀不匹配 | 自动适配后缀,先扫描校验 |
| CUDA out of memory | batch size / 图片尺寸过大 | 调小batch,用梯度累积 |
| loss不下降 | 标签错位或学习率不当 | 人工核对样本标签,调学习率 |
| 过拟合严重 | 数据量不足 | 加强增强、dropout、提前停止 |
| 验证集Kappa高但测试集低 | 划分方式有信息泄露 | 按患者分组划分 |
5. 数据增强进阶与可靠性的平衡
5.1 光斑与伪影:数据清洗的必要性
很多人拿到公开数据集就开训,但眼底图有一个很烦人的现象——光照不均匀。有些图像是暗角很重,有些图像整体偏蓝或偏黄(不同设备白平衡差异),还有图像上有反光光斑。这些伪影对医生的阅片是障碍,对模型来说更是干扰。
如果不想做太复杂的清洗,有一个最低成本的方案:把图像缩放到一个固定尺寸后,做一次CLAHE(对比度受限自适应直方图均衡化)。这个技术对医学图像特别有效,能让血管和病变的对比度更清晰,同时抑制光照不均的影响。
import cv2 def clahe_preprocess(img): # 转换到LAB空间,只对L通道做CLAHE lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) l = clahe.apply(l) lab = cv2.merge((l, a, b)) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)我实测在透亮度差的图片上,CLAHE能带来1%-2%的准确率提升。但要注意,CLAHE不是对所有图片都友好,有些本来就过曝的图用了反而更糟。稳妥的做法是把CLAHE作为数据增强的一部分,而不是强制预处理步骤,让网络自己去学哪种特征更重要。
5.2 靠谱的衡量:用Kappa系数而不是只用准确率
DR分级里,0级图片占了一半以上,如果模型把所有图都判成0级,准确率也有55%左右,看起来好像“还行”。这就是准确率这个指标在类别不平衡数据上的最大问题——它不惩罚错误的方向。把1级判断成2级和把1级判断成4级,对病人来说完全不同,但准确率都会记成一次错误。
二次加权Kappa系数(Quadratic Weighted Kappa)能够反映这个差异。它基于混淆矩阵计算,当模型的预测结果与真实标签完全一致时Kappa=1,当预测结果与随机猜测水平相当时Kappa=0,需要注意的是预测反了Kappa还可能为负值。它的惩罚力度随着分类差距的平方增大而增大,所以把0级判成4级会被严重扣分,这正好匹配临床需求。
所以在代码里不要只打印accuracy,把Kappa、混淆矩阵、每个类别的精确率/召回率都打出来。混淆矩阵能帮你快速定位模型的“易混淆对”——比如模型经常在1级和2级之间摇摆,说明这两类的特征确实太接近了,可以从数据增强或损失函数层面针对性优化。
6. 从模型到论文:项目完成度如何提升
6.1 用Jupyter自动生成实验报告
既然项目是基于Jupyter的,论文写作和实验记录可以无缝衔接。每个notebook里用Markdown写好实验目的、方法、结论,然后把关键图表用plt.savefig保存成PNG,插入论文的实验部分即可。这一套流程等于把实验记录和论文初稿同步推进,最后整理起来非常省时间。
推荐在训练结束后,用下面的代码生成一张汇总图,把训练集和验证集的loss曲线、准确率曲线、Kappa曲线全部画在一张图上:
import matplotlib.pyplot as plt import pandas as pd # 假设训练时把每个epoch的指标都记录在了history.csv里 history = pd.read_csv('history.csv') fig, axes = plt.subplots(1, 3, figsize=(15, 4)) axes[0].plot(history['train_loss'], label='Train Loss') axes[0].plot(history['val_loss'], label='Val Loss') axes[0].set_xlabel('Epoch') axes[0].set_ylabel('Loss') axes[0].legend() axes[0].set_title('Loss Curve') axes[1].plot(history['train_acc'], label='Train Acc') axes[1].plot(history['val_acc'], label='Val Acc') axes[1].set_xlabel('Epoch') axes[1].set_ylabel('Accuracy') axes[1].legend() axes[1].set_title('Accuracy Curve') axes[2].plot(history['train_kappa'], label='Train Kappa') axes[2].plot(history['val_kappa'], label='Val Kappa') axes[2].set_xlabel('Epoch') axes[2].set_ylabel('Kappa') axes[2].legend() axes[2].set_title('Kappa Curve') plt.tight_layout() plt.savefig('training_curves.png', dpi=150, bbox_inches='tight') plt.show()这张图放在论文的“实验与分析”部分,比任何文字描述都直观。
6.2 扩展方向:病变区域定位与轻量化部署
如果做完上面的内容还有精力,可以考虑加两个加分项。第一个是病变区域弱监督定位,就是基于Grad-CAM热力图,用阈值分割出模型认为的病变区域,然后和原始图像叠加展示。虽然不涉及分割标注,但能展示模型关注的位置与医生标注的病变区域重叠度,这很有说服力。第二个是模型轻量化,用TensorRT或ONNX把训练好的PyTorch模型转成推理引擎,然后在CPU上跑一个推理demo,展示一张眼底图只需要几百毫秒就能出分级结果。这在答辩现场演示的效果非常好,导师会直观感受到“这不仅仅是个玩具”。
7. 写在最后的一些体会
做这个项目的过程中,我最大的感受是:深度学习项目里,真正花在训练模型上的时间其实是小头,数据清洗、预处理、调试、可视化、实验记录这些琐碎的环节才是大头。很多人一上来就急着搭模型、调参,结果数据都没弄干净,模型效果自然上不去。反过来,把数据准备好、把评估指标定清楚、把训练流程自动化,模型训练反而是一件很顺理成章的事情。
最后再分享一个小技巧:训练的时候用一个tensorboard或wandb记录所有实验的指标,这样后面写论文、做对比实验的时候不用靠回忆,所有历史实验一拉就能看到。我自己踩过的坑是不记录实验参数,跑了一堆模型,最后忘了哪个是最好的一版,重跑一遍浪费了整整一个晚上。
这个项目做完以后,你会形成一个完整的认知闭环:从临床问题定义,到数据获取与清洗,到模型设计与训练,到评估与可视化,再到论文撰写。这个经验放到任何其他医学图像任务(如肺炎X光分级、皮肤癌分类)里都能复现,算是做一个题目,打通一类问题吧。
本文还有配套的精品资源,点击获取