简介:这份资源面向计算机、人工智能相关专业的毕业设计、课程设计与项目开发学习者,提供一套基于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_state=42) sizes = [] for _, row in sample.iterrows(): img = cv2.imread(str(DATA_ROOT / f"raw/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 / f"raw/train/{img_name}.png")) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) axes[level].imshow(img) axes[level].set_title(f"Level {level}") axes[level].axis("off") plt.tight_layout() plt.savefig("../outputs/class_samples.png", dpi=120) 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_size=0.15, stratify=labels["level"], random_state=42 ) train, val = train_test_split( train_val, test_size=0.176, stratify=train_val["level"], random_state=42 ) print(f"train={len(train)}, val={len(val)}, test={len(test)}") train.to_csv(DATA_ROOT / "labels/train_split.csv", index=False) val.to_csv(DATA_ROOT / "labels/val_split.csv", index=False) test.to_csv(DATA_ROOT / "labels/test_split.csv", index=False)stratify参数是这里的关键,它保证切分后每个子集的类别比例和原始一致。test_size=0.176这个数字不是随便写的:先切 15% 做测试,剩下的 85% 里再切 17.6%,最终验证集占总量的 15%。切完把三个 csv 落盘,后面训练和评估都读这三个文件,避免每次重新切导致结果不可复现。
注意:切分用的 random_state 一旦定下就不要改。答辩时如果老师让你重跑,结果对不上会非常尴尬。
3. 预处理与增强:让眼底图像真正能被模型吃进去
3.1 眼底图像预处理的三个必做步骤
原始眼底图直接丢给模型,效果通常很差,原因是:圆形视野外有大量黑色背景、不同设备拍摄的亮度对比度差异大、血管和病灶的细节容易被噪声淹没。标准预处理流程是裁剪视野、去噪、增强对比度三步。
import numpy as np import cv2 def crop_circle(img, tol=7): """裁掉圆形视野外的黑色边框""" gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mask = gray > tol if mask.sum() == 0: return img rows = np.any(mask, axis=1) cols = np.any(mask, axis=0) 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, clip=2.0, grid=8): """CLAHE 限制对比度自适应直方图均衡,增强血管与病灶""" lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(grid, grid)) l = clahe.apply(l) return cv2.cvtColor(cv2.merge((l, a, b)), cv2.COLOR_LAB2BGR) def preprocess(path, size=512): 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 导致颜色失真。clipLimit=2.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_state=1)["image"].tolist() for i, name in enumerate(sample_imgs): raw = cv2.cvtColor(cv2.imread(str(DATA_ROOT / f"raw/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", dpi=120) 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(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.RandomBrightnessContrast(p=0.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(drop=True) 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(image=img)["image"] return img, torch.tensor(row["level"], dtype=torch.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_classes=5, freeze_backbone=True): model = models.resnet50(weights=models.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", classes=np.arange(5), y=train_df["level"].values ) class_weights = torch.tensor(class_weights, dtype=torch.float).to(device) criterion = nn.CrossEntropyLoss(weight=class_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_samples=len(sample_weights), replacement=True) train_ds = DRDataset(train_df, DATA_ROOT / "raw/train", train_tf) train_loader = DataLoader(train_ds, batch_size=32, sampler=sampler, num_workers=4)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()), lr=1e-3) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=EPOCHS) best_acc = 0.0 for epoch in range(EPOCHS): model.train() running_loss = 0.0 for imgs, labels_batch in tqdm(train_loader, desc=f"Epoch {epoch+1}"): 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(f"Epoch {epoch+1} 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(dim=1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels_batch.numpy()) print(classification_report(all_labels, all_preds, digits=4)) cm = confusion_matrix(all_labels, all_preds) sns.heatmap(cm, annot=True, fmt="d", cmap="Blues") plt.xlabel("Predicted"); plt.ylabel("True") plt.savefig("../outputs/confusion_matrix.png", dpi=120) 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), dtype=np.uint8),或者统一用cv2.IMREAD_COLOR并检查返回值。更稳妥的是在数据探查阶段就把所有读不出来的文件名列出来。
坑二:验证集准确率很高,测试集一塌糊涂。现象是 val acc 0.9,test 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(drop=True) 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 / f"raw/train/{row['image']}.png")), cv2.COLOR_BGR2RGB) ax.imshow(img) ax.set_title(f"True:{all_labels[idx]} Pred:{all_preds[idx]}") ax.axis("off") plt.tight_layout() plt.savefig("../outputs/misclassified.png", dpi=120) plt.show()6. 让这份工程真正能交付:文档、复现与一个提分技巧
项目做到能跑通只是及格线,能不能交付、别人能不能复现,才是拉开差距的地方。我一般会在 notebooks 同级放一个 README,写清楚环境依赖、数据放置路径、四个 Notebook 的执行顺序,以及每个 Notebook 大概跑多久。依赖用 requirements.txt 固定版本,尤其是 torch、torchvision、albumentations 这三个,版本不匹配是最常见的「在我电脑上能跑」问题。
# requirements.txt 示例 torch==2.1.0 torchvision==0.16.0 albumentations==1.3.1 opencv-python==4.8.1.78 scikit-learn==1.3.2 pandas==2.1.1 seaborn==0.13.0 tqdm==4.66.1关于复现,有一个容易被忽略的点:把随机种子在训练 Notebook 开头统一固定,包括 torch、numpy、random 三个来源。
import random, numpy as np, torch def set_seed(seed=42): 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.deterministic=True会让训练稍慢,但能保证每次结果一致,答辩重跑时不会翻车。如果追求速度可以关掉,但要在文档里说明。
最后分享一个我常用的提分技巧:两阶段微调 + 测试时增强(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), dim=1) p2 = torch.softmax(model(torch.flip(img_tensor, dims=[3])), dim=1) p3 = torch.softmax(model(torch.flip(img_tensor, dims=[2])), dim=1) return ((p1 + p2 + p3) / 3).argmax(dim=1)我自己做这类项目最大的教训是:别一上来就追求 SOTA 模型,先把数据探查、预处理、切分这三步做扎实,用 ResNet50 跑出一个可信的 baseline,再谈优化。很多翻车都不是模型的问题,而是数据没看清楚。希望帮到你。
本文还有配套的精品资源,点击获取