尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

深度学习糖尿病视网膜病变分级实战:从图像分类到Kappa评估

深度学习糖尿病视网膜病变分级实战:从图像分类到Kappa评估 简介医学影像智能分析是计算机视觉在医疗领域最重要的应用方向之一其中眼底图像分析更是眼科临床诊断的关键环节。深度学习方法通过构建多层神经网络可自动从大量标注数据中学习病变特征从而实现病灶的快速辅助筛查。以糖尿病视网膜病变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提高到384Kappa能提升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(weightclass_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 python3.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 cudatoolkit11.8 -c pytorch # 如果你有NVIDIA显卡这个命令会安装GPU版本没有显卡就装CPU版本 # conda install pytorch torchvision torchaudio cpuonly -c pytorch # 其他常用库 pip install pandas matplotlib seaborn opencv-python scikit-learn tqdm装好之后启动Jupyterjupyter 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, transformNone, is_trainTrue): 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(imageimg)[image] return img, torch.tensor(label, dtypetorch.long)然后定义数据增强和加载器train_transform A.Compose([ A.Resize(256, 256), A.RandomRotate90(), A.HorizontalFlip(p0.5), A.RandomBrightnessContrast(p0.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_size32, shuffleTrue, num_workers4, pin_memoryTrue ) valid_loader DataLoader( valid_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue )这里有个特别容易被忽略的点A.Normalize用的mean和std是ImageNet数据集的统计值而不是你自己数据的。这是预训练模型的标准做法因为模型是在ImageNet上预训练的输入分布必须对齐它训练时的分布。很多人忘了这个转换导致迁移学习效果大打折扣。3.3 模型构建与迁移学习模型这块我建议直接用torchvision.models里现成的预训练权重不要自己从头训。一方面因为数据量太少3662张完全从头训的模型效果很差另一方面迁移学习是DR项目里最重要的技术点论文里也方便写。import torchvision.models as models def get_model(model_nameresnet50, num_classes5, pretrainedTrue): if model_name resnet50: model models.resnet50(weightsmodels.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(weightsmodels.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(pretrainedTrue)新版本推荐用weights参数因为旧的参数在新版里已经被标记为废弃。如果你用的版本比较新直接抄我上面的写法就行。ResNet50和EfficientNet-B3是我实测过的两个比较稳的选择简单对比一下模型参数量输入尺寸显存占用验证集Kappa训练时间单卡ResNet5025.6M224×224约3GB0.82约40分钟ResNet5025.6M256×256约4GB0.84约60分钟EfficientNet-B312.3M320×320约5GB0.86约90分钟如果你显卡只有4G显存ResNet50224输入是极限了如果有8G或以上推荐EfficientNet-B3256或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, descTraining): 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, weightsquadratic) 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, descValidating): images images.to(device) labels labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() probs torch.softmax(outputs, dim1) _, 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, weightsquadratic) return total_loss / len(loader), acc, kappa, np.array(all_probs)训练的主循环外加一个学习率调度器model get_model(efficientnet_b3).cuda() criterion nn.CrossEntropyLoss(weightclass_weights.cuda()) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-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_lambdawarmup_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(fEpoch {epoch1}/{num_epochs} | fTrain Loss: {train_loss:.4f} Acc: {train_acc:.4f} Kappa: {train_kappa:.4f} | fVal 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, dim1) # 只用预测类别的梯度 model.zero_grad() one_hot torch.zeros_like(output) one_hot[0][pred] 1 output.backward(gradientone_hot) handle_forward.remove() handle_backward.remove() weights gradients[0].mean(dim(2, 3), keepdimTrue) # 全局平均池化 cam (weights * activations[0]).sum(dim1, keepdimTrue) 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 size32→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 训练过程中的坑问题3loss不下降准确率一直卡在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或者干脆靠文件夹来组织。如果实在分不了患者级至少要在论文里承认这个局限性。问题6Jupyter里训练跑到一半kernel挂了白忙一场。这个真的是血泪教训。Jupyter的kernel如果崩溃内存里所有的变量、模型权重都会丢。两个防御措施第一每个epoch结束自动保存checkpointtorch.save这样即使kernel崩了也能从上一个epoch的权重继续训练而不是从头再来。第二训练时间比较长的任务超过半小时不建议在notebook里直接跑建议把训练脚本写到.py文件里在终端用nohup python train.py train.log 21 后台执行log里能看到进度也不怕关掉浏览器。4.4 常见问题速查表问题现象可能原因解决方案jupyter命令找不到Anaconda未加入PATH用Anaconda Prompt启动图片无法加载路径/后缀不匹配自动适配后缀先扫描校验CUDA out of memorybatch 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(clipLimit3.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能够反映这个差异。它基于混淆矩阵计算当模型的预测结果与真实标签完全一致时Kappa1当预测结果与随机猜测水平相当时Kappa0需要注意的是预测反了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], labelTrain Loss) axes[0].plot(history[val_loss], labelVal 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], labelTrain Acc) axes[1].plot(history[val_acc], labelVal 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], labelTrain Kappa) axes[2].plot(history[val_kappa], labelVal 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, dpi150, bbox_inchestight) plt.show()这张图放在论文的“实验与分析”部分比任何文字描述都直观。6.2 扩展方向病变区域定位与轻量化部署如果做完上面的内容还有精力可以考虑加两个加分项。第一个是病变区域弱监督定位就是基于Grad-CAM热力图用阈值分割出模型认为的病变区域然后和原始图像叠加展示。虽然不涉及分割标注但能展示模型关注的位置与医生标注的病变区域重叠度这很有说服力。第二个是模型轻量化用TensorRT或ONNX把训练好的PyTorch模型转成推理引擎然后在CPU上跑一个推理demo展示一张眼底图只需要几百毫秒就能出分级结果。这在答辩现场演示的效果非常好导师会直观感受到“这不仅仅是个玩具”。7. 写在最后的一些体会做这个项目的过程中我最大的感受是深度学习项目里真正花在训练模型上的时间其实是小头数据清洗、预处理、调试、可视化、实验记录这些琐碎的环节才是大头。很多人一上来就急着搭模型、调参结果数据都没弄干净模型效果自然上不去。反过来把数据准备好、把评估指标定清楚、把训练流程自动化模型训练反而是一件很顺理成章的事情。最后再分享一个小技巧训练的时候用一个tensorboard或wandb记录所有实验的指标这样后面写论文、做对比实验的时候不用靠回忆所有历史实验一拉就能看到。我自己踩过的坑是不记录实验参数跑了一堆模型最后忘了哪个是最好的一版重跑一遍浪费了整整一个晚上。这个项目做完以后你会形成一个完整的认知闭环从临床问题定义到数据获取与清洗到模型设计与训练到评估与可视化再到论文撰写。这个经验放到任何其他医学图像任务如肺炎X光分级、皮肤癌分类里都能复现算是做一个题目打通一类问题吧。本文还有配套的精品资源点击获取
返回列表