
简介这份资源面向计算机、人工智能相关专业的毕业设计、课程设计与项目开发学习者提供一套基于Jupyter实现的糖尿病视网膜病变DR诊断完整方案。内容围绕眼底图像分类展开涵盖需求分析、数据来源说明、EDA探索、数据预处理与增强、EfficientNet系列模型建模、训练调参、交叉验证、错误分析与方案总结并针对类别间距不等、训练集与公开测试集分布差异大、过拟合、预处理选择及外部数据利用等实际问题给出处理思路。资源包共30个文件包含16个ipynb笔记本、9张png结果图、2个py脚本、1个csv提交文件及md说明文档压缩包约30.44MB目录按notebooks、src、images等模块组织便于按流程复现。已有180人学习下载适合希望掌握医学图像分类全流程、快速搭建可运行基线并在此基础上延伸改进的读者参考。1. 从一张眼底照到一份可复现的 Jupyter 工程糖尿病视网膜诊断到底难在哪糖尿病视网膜病变DR诊断这个题目几乎每年都会出现在毕业设计和课程设计的选题清单里。真正动手做过的人都知道难点从来不是「搭一个 CNN 跑分类」这么简单而是三件事同时压过来眼底图像质量参差不齐、五级分级标准0 无病变 / 1 轻度 / 2 中度 / 3 重度 / 4 增殖期之间的边界极其模糊、以及答辩时老师一定会问的「你的数据从哪来、怎么划分、模型凭什么可信」。我见过太多项目最后交上去只有一个 train.py 加一个准确率数字连预处理做了什么、验证集怎么切的都说不清这种在答辩现场基本撑不过三个追问。这篇笔记要讲清楚的是怎么用 Jupyter Notebook 把「糖尿病视网膜疾病诊断」做成一份结构完整、每一步都能复现、别人拿到源码和数据集就能跑通的工程。它适合正在做毕业设计、课程设计的学生也适合想快速验证一个医学图像分类 pipeline 的开发者。核心思路是把整个项目拆成数据探查、预处理、模型训练、评估可视化四个 Notebook每个环节都留下可追溯的输出而不是把所有逻辑塞进一个脚本里。Jupyter 在这里的价值不是「好看」而是它天然适合做这种需要反复看中间结果、需要向别人展示推理过程的医学图像任务——你调一次 CLAHE 参数就能立刻在同一页看到增强前后的对比图这是纯脚本给不了的反馈速度。2. 数据集怎么选、怎么切、怎么在 Jupyter 里先看清楚2.1 眼底数据集的选择与目录结构约定糖尿病视网膜病变公开数据集里最常被拿来用的是 Kaggle 上的 Diabetic Retinopathy Detection约 3.5 万张高分辨率眼底图五级标注和 Messidor / Messidor-2。前者规模大、分级全适合做完整训练后者图像数量少但质量较整齐适合做快速验证。如果你的算力有限我一般建议先用 Messidor 或从大集里按类别抽样出一个子集跑通流程再决定要不要上全量。不管用哪个数据集第一步都是把目录结构固定下来。我习惯用下面这种布局后面所有 Notebook 都基于这个约定写路径换数据集时只改一个根目录变量dr_project/ ├── data/ │ ├── raw/ # 原始图像按原始命名存放 │ │ ├── train/ │ │ └── test/ │ ├── labels/ │ │ ├── trainLabels.csv # 列: image, level │ │ └── testLabels.csv │ └── processed/ # 预处理后的图像与缓存 ├── notebooks/ │ ├── 01_data_explore.ipynb │ ├── 02_preprocess.ipynb │ ├── 03_train.ipynb │ └── 04_evaluate.ipynb ├── models/ # 保存的权重 └── outputs/ # 图表、混淆矩阵、报告提示raw 目录永远不要写入。所有裁剪、增强、缩放的结果都落到 processed这样预处理参数改错了可以随时重来不用重新下载数据。2.2 在 Notebook 里做数据探查类别分布与图像尺寸很多人跳过探查直接开训结果训练到一半发现某一类只有几十张或者图像尺寸差异巨大导致 dataloader 报错。第一个 Notebook 就干一件事把数据的底细摸清楚。import pandas as pd import cv2 import matplotlib.pyplot as plt from pathlib import Path from collections import Counter DATA_ROOT Path(../data) labels pd.read_csv(DATA_ROOT / labels/trainLabels.csv) # 1. 类别分布 dist Counter(labels[level]) print(类别分布:, dict(sorted(dist.items()))) print(总样本数:, len(labels)) # 2. 抽样看图像尺寸分布 sample labels.sample(30, random_state42) sizes [] for _, row in sample.iterrows(): img cv2.imread(str(DATA_ROOT / fraw/train/{row[image]}.png)) if img is not None: sizes.append(img.shape[:2]) print(尺寸样本(前10):, sizes[:10]) # 3. 每类抽一张可视化 fig, axes plt.subplots(1, 5, figsize(20, 4)) for level in range(5): img_name labels[labels[level] level][image].iloc[0] img cv2.imread(str(DATA_ROOT / fraw/train/{img_name}.png)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) axes[level].imshow(img) axes[level].set_title(fLevel {level}) axes[level].axis(off) plt.tight_layout() plt.savefig(../outputs/class_samples.png, dpi120) plt.show()这段代码做了三件事统计每级样本数、抽样看尺寸、每类抽一张图并排显示。逻辑上类别分布决定了你后面要不要用加权采样或 focal loss尺寸分布决定了统一 resize 的目标分辨率。参数上sample(30)的 30 只是探查用不用太大random_state固定住保证每次跑结果一致方便截图写进文档。如果发现 Level 3、Level 4 样本明显偏少这在 DR 数据集里几乎是必然的先别急着上重采样记下这个比例到训练那章再处理。探查阶段的任务是「知道」不是「解决」。2.3 训练/验证/测试的切分策略医学图像项目里切分方式直接决定你的指标可不可信。常见做法是 7:1.5:1.5 或 8:1:1但关键是按类别分层切分否则某一级可能整个落进验证集或测试集指标会失真。from sklearn.model_selection import train_test_split # 先切出测试集再从剩余里切验证集全程 stratify 保证分层 train_val, test train_test_split( labels, test_size0.15, stratifylabels[level], random_state42 ) train, val train_test_split( train_val, test_size0.176, stratifytrain_val[level], random_state42 ) print(ftrain{len(train)}, val{len(val)}, test{len(test)}) train.to_csv(DATA_ROOT / labels/train_split.csv, indexFalse) val.to_csv(DATA_ROOT / labels/val_split.csv, indexFalse) test.to_csv(DATA_ROOT / labels/test_split.csv, indexFalse)stratify参数是这里的关键它保证切分后每个子集的类别比例和原始一致。test_size0.176这个数字不是随便写的先切 15% 做测试剩下的 85% 里再切 17.6%最终验证集占总量的 15%。切完把三个 csv 落盘后面训练和评估都读这三个文件避免每次重新切导致结果不可复现。注意切分用的 random_state 一旦定下就不要改。答辩时如果老师让你重跑结果对不上会非常尴尬。3. 预处理与增强让眼底图像真正能被模型吃进去3.1 眼底图像预处理的三个必做步骤原始眼底图直接丢给模型效果通常很差原因是圆形视野外有大量黑色背景、不同设备拍摄的亮度对比度差异大、血管和病灶的细节容易被噪声淹没。标准预处理流程是裁剪视野、去噪、增强对比度三步。import numpy as np import cv2 def crop_circle(img, tol7): 裁掉圆形视野外的黑色边框 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mask gray tol if mask.sum() 0: return img rows np.any(mask, axis1) cols np.any(mask, axis0) rmin, rmax np.where(rows)[0][[0, -1]] cmin, cmax np.where(cols)[0][[0, -1]] return img[rmin:rmax, cmin:cmax] def apply_clahe(img, clip2.0, grid8): CLAHE 限制对比度自适应直方图均衡增强血管与病灶 lab cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimitclip, tileGridSize(grid, grid)) l clahe.apply(l) return cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR) def preprocess(path, size512): img cv2.imread(str(path)) img crop_circle(img) img apply_clahe(img) img cv2.resize(img, (size, size)) return imgcrop_circle用灰度阈值找非黑区域的外接矩形把圆形视野外的黑边去掉减少无效像素。apply_clahe在 LAB 色彩空间的 L 通道上做均衡避免直接改 RGB 导致颜色失真。clipLimit2.0和tileGridSize(8,8)是我在眼底图上比较常用的组合clip 太大噪声会被放大太小增强效果不明显grid 太大局部适应性差太小会引入块状伪影。这两个参数建议在 Notebook 里做成滑块或循环肉眼对比几组再定。3.2 用 Notebook 做增强效果对比Jupyter 在这里的优势体现得最明显你可以把原图、裁剪后、CLAHE 后并排画出来一眼看出参数合不合适。fig, axes plt.subplots(3, 4, figsize(16, 12)) sample_imgs labels.sample(4, random_state1)[image].tolist() for i, name in enumerate(sample_imgs): raw cv2.cvtColor(cv2.imread(str(DATA_ROOT / fraw/train/{name}.png)), cv2.COLOR_BGR2RGB) cropped crop_circle(raw) enhanced apply_clahe(cropped) for ax, im, title in zip( axes[:, i], [raw, cropped, enhanced], [raw, cropped, CLAHE] ): ax.imshow(im) ax.set_title(title) ax.axis(off) plt.tight_layout() plt.savefig(../outputs/preprocess_compare.png, dpi120) plt.show()跑完这一格你会得到一张 3 行 4 列的对比图。如果 CLAHE 后图像出现明显色块或过曝就回去调 clip 和 grid。这一步不要省预处理参数定错后面模型再强也救不回来。3.3 数据增强与 Dataset 封装训练时的增强和预处理要分开预处理是确定性的、验证测试集也要做增强是随机的、只在训练时做。用 albumentations 比较顺手import albumentations as A from albumentations.pytorch import ToTensorV2 from torch.utils.data import Dataset import torch train_tf A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast(p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ]) val_tf A.Compose([ A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ]) class DRDataset(Dataset): def __init__(self, df, root, transform): self.df df.reset_index(dropTrue) self.root root self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img preprocess(self.root / f{row[image]}.png) img self.transform(imageimg)[image] return img, torch.tensor(row[level], dtypetorch.long)翻转和旋转对眼底图是安全的因为病灶的左右眼分布本身就有对称性。但要注意不要用大幅度的仿射变换或裁剪眼底图的圆形视野和病灶位置是有解剖意义的乱切会破坏标签语义。Normalize 用的均值方差是 ImageNet 的因为后面用预训练模型保持一致即可。4. 模型训练从 ResNet 预训练到类别不平衡处理4.1 为什么选预训练 ResNet 而不是从零训DR 数据集即使全量也就几万张从零训练一个 CNN 很容易过拟合而且收敛慢。用 ImageNet 预训练的 ResNet50 做迁移学习是这类任务的稳妥起点。常见做法是冻结前面的层只训最后的 fc训几轮后再解冻全部做微调。import torch.nn as nn from torchvision import models def build_model(num_classes5, freeze_backboneTrue): model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V2) if freeze_backbone: for p in model.parameters(): p.requires_grad False in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) return model device torch.device(cuda if torch.cuda.is_available() else cpu) model build_model().to(device)冻结骨干时只有 fc 的参数在更新显存占用小、收敛快适合先跑通流程。Dropout(0.3)加在 fc 前是为了缓解小数据集上的过拟合。等 fc 收敛后把freeze_backbone设为 False 再训几轮低学习率的微调通常能再涨一两个点。4.2 类别不平衡加权损失与采样DR 数据里 Level 0 往往占七成以上直接训模型会倾向于全预测 0。两种处理方式给损失函数加类别权重或者用 WeightedRandomSampler。我一般两个都用权重按类别频率的倒数算。from torch.utils.data import DataLoader, WeightedRandomSampler from sklearn.utils.class_weight import compute_class_weight train_df pd.read_csv(DATA_ROOT / labels/train_split.csv) class_weights compute_class_weight( balanced, classesnp.arange(5), ytrain_df[level].values ) class_weights torch.tensor(class_weights, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weights) # 采样器让每个 batch 里各类比例更均衡 sample_weights train_df[level].map( {i: w for i, w in enumerate(class_weights.cpu().numpy())} ).values sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_ds DRDataset(train_df, DATA_ROOT / raw/train, train_tf) train_loader DataLoader(train_ds, batch_size32, samplersampler, num_workers4)compute_class_weight(balanced)返回的是n_samples / (n_classes * count)样本越少的类权重越大。把它传给 CrossEntropyLoss 的 weight 参数少数类的错分会被放大惩罚。采样器则从数据层面再平衡一次。两者叠加要注意别过度否则模型会对少数类过拟合验证集上少数类指标虚高。建议先只开损失权重看验证集混淆矩阵再决定要不要加采样器。4.3 训练循环与关键参数from torch.optim import Adam from tqdm import tqdm EPOCHS 15 optimizer Adam(filter(lambda p: p.requires_grad, model.parameters()), lr1e-3) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxEPOCHS) best_acc 0.0 for epoch in range(EPOCHS): model.train() running_loss 0.0 for imgs, labels_batch in tqdm(train_loader, descfEpoch {epoch1}): imgs, labels_batch imgs.to(device), labels_batch.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels_batch) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fEpoch {epoch1} loss{running_loss/len(train_loader):.4f}) # 每个 epoch 存一次最优权重 torch.save(model.state_dict(), ../models/last.pth)学习率 1e-3 是冻结骨干时的常用值解冻微调时要降到 1e-4 甚至更低。CosineAnnealingLR 让学习率按余弦曲线衰减比固定学习率更容易收敛到好的局部解。每个 epoch 存 last.pth 是保底真正的最优权重应该在验证集上评估后再存这部分放到评估 Notebook 里做。提示训练时把 loss 曲线实时画在 Notebook 里比只看 print 数字直观得多。一旦发现 loss 震荡或突然变大立刻停下来查学习率和数据。5. 评估、可视化与那些让我翻过车的坑5.1 混淆矩阵与分级指标的正确读法DR 是五级分类光看准确率会被多数类带偏。必须看混淆矩阵和每类的 precision/recall/F1。from sklearn.metrics import confusion_matrix, classification_report import seaborn as sns model.load_state_dict(torch.load(../models/best.pth)) model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels_batch in val_loader: imgs imgs.to(device) preds model(imgs).argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels_batch.numpy()) print(classification_report(all_labels, all_preds, digits4)) cm confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annotTrue, fmtd, cmapBlues) plt.xlabel(Predicted); plt.ylabel(True) plt.savefig(../outputs/confusion_matrix.png, dpi120) plt.show()看混淆矩阵时重点关注相邻级别之间的误判比如 1 和 2、2 和 3 之间混淆多说明模型对中间级别的边界学得不好这是 DR 任务的固有难点。如果 0 和 4 之间大量混淆那基本是训练出了问题要回去查标签有没有错。classification_report 里的 macro avg 比 weighted avg 更能反映少数类的表现答辩时优先报 macro F1。5.2 避坑五个我真实踩过的坑坑一图像读取返回 None训练中途报错。现象是 dataloader 跑到某一张图时抛异常或者图像全黑。原因是部分眼底图是灰度图或带 alpha 通道cv2.imread 默认按三通道读会失败。解决办法是在 preprocess 里加判断if img is None: return np.zeros((size,size,3), dtypenp.uint8)或者统一用cv2.IMREAD_COLOR并检查返回值。更稳妥的是在数据探查阶段就把所有读不出来的文件名列出来。坑二验证集准确率很高测试集一塌糊涂。现象是 val acc 0.9test acc 0.6。原因通常是切分时没分层或者验证集和训练集有重复图像同一张图的不同增强版本同时进了两个集合。解决办法是切分严格用 stratify并且确认原始数据里没有重复文件名。如果数据集本身有同一患者的多张图最好按患者 ID 切分而不是按图像切分否则就是数据泄漏。坑三CLAHE 参数照搬网上图像过曝。现象是增强后图像一片白血管全看不见。原因是不同数据集的亮度分布差异很大别人调的 clipLimit 不一定适合你的数据。解决办法是在 Notebook 里对 3 到 5 组参数做网格对比肉眼选最合适的一组并把对比图存进项目文档答辩时能直接展示你的调参依据。坑四类别权重设得过大少数类过拟合。现象是 Level 4 的 recall 很高但 precision 很低模型把很多其他级别都预测成 4。原因是 class_weight 的倒数权重在极端不平衡时数值过大。解决办法是把权重做开方或对数缩放比如weights np.sqrt(weights)或者干脆只用采样器不用损失权重二选一。坑五Notebook 里改了预处理函数忘了重跑下游单元格。现象是训练用的还是旧预处理结果指标对不上。原因是 Jupyter 的单元格执行顺序和代码顺序不一致函数改了但缓存没更新。解决办法是养成习惯改完预处理函数后从预处理单元格开始全部重新执行一遍或者把预处理结果落盘成 npy 缓存训练直接读缓存避免每次重算。5.3 把评估结果落成可展示的报告答辩和项目文档需要的是图不是一堆 print。评估 Notebook 最后应该输出混淆矩阵热力图、每类指标表格、几张典型误判样本的对比图真实标签 vs 预测标签。误判样本图特别有用能直观说明模型在哪些边界情况下失效。# 找出预测错误的样本并可视化 val_df pd.read_csv(DATA_ROOT / labels/val_split.csv).reset_index(dropTrue) wrong_idx [i for i, (p, t) in enumerate(zip(all_preds, all_labels)) if p ! t][:6] fig, axes plt.subplots(2, 3, figsize(15, 10)) for ax, idx in zip(axes.ravel(), wrong_idx): row val_df.iloc[idx] img cv2.cvtColor(cv2.imread(str(DATA_ROOT / fraw/train/{row[image]}.png)), cv2.COLOR_BGR2RGB) ax.imshow(img) ax.set_title(fTrue:{all_labels[idx]} Pred:{all_preds[idx]}) ax.axis(off) plt.tight_layout() plt.savefig(../outputs/misclassified.png, dpi120) plt.show()6. 让这份工程真正能交付文档、复现与一个提分技巧项目做到能跑通只是及格线能不能交付、别人能不能复现才是拉开差距的地方。我一般会在 notebooks 同级放一个 README写清楚环境依赖、数据放置路径、四个 Notebook 的执行顺序以及每个 Notebook 大概跑多久。依赖用 requirements.txt 固定版本尤其是 torch、torchvision、albumentations 这三个版本不匹配是最常见的「在我电脑上能跑」问题。# requirements.txt 示例 torch2.1.0 torchvision0.16.0 albumentations1.3.1 opencv-python4.8.1.78 scikit-learn1.3.2 pandas2.1.1 seaborn0.13.0 tqdm4.66.1关于复现有一个容易被忽略的点把随机种子在训练 Notebook 开头统一固定包括 torch、numpy、random 三个来源。import random, numpy as np, torch def set_seed(seed42): 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)cudnn.deterministicTrue会让训练稍慢但能保证每次结果一致答辩重跑时不会翻车。如果追求速度可以关掉但要在文档里说明。最后分享一个我常用的提分技巧两阶段微调 测试时增强TTA。第一阶段冻结骨干训 fc 到收敛第二阶段解冻全部用 1e-4 学习率再训 5 个 epoch。推理时对同一张图做原图、水平翻转、垂直翻转三次预测取平均概率再 argmax。这个技巧在 DR 这种病灶分布近似对称的任务上通常能涨 1 到 2 个点的 macro F1代价只是推理时间翻三倍对毕业设计完全可接受。def predict_tta(model, img_tensor): model.eval() with torch.no_grad(): p1 torch.softmax(model(img_tensor), dim1) p2 torch.softmax(model(torch.flip(img_tensor, dims[3])), dim1) p3 torch.softmax(model(torch.flip(img_tensor, dims[2])), dim1) return ((p1 p2 p3) / 3).argmax(dim1)我自己做这类项目最大的教训是别一上来就追求 SOTA 模型先把数据探查、预处理、切分这三步做扎实用 ResNet50 跑出一个可信的 baseline再谈优化。很多翻车都不是模型的问题而是数据没看清楚。希望帮到你。本文还有配套的精品资源点击获取