简介:这份资源是面向计算机、人工智能、自动化等专业学生与从业者的糖尿病视网膜疾病诊断毕业设计完整项目,基于Jupyter Notebook实现,适合作为毕业设计、课程大作业或期末课设的参考方案,也便于基础较好的学习者在此基础上修改扩展功能。压缩包共30个文件,约30.44MB,包含16个ipynb实验笔记、9张png结果图、2个py脚本、1个csv提交文件及md说明文档,覆盖数据预处理、EDA探索、EfficientNet系列模型训练与交叉验证、stacking集成等完整流程,并附有视网膜病变图像与各折验证可视化结果。项目代码均经过调试测试,可直接运行,已有58人学习下载。读者可从中获得从数据划分、模型搭建到结果提交的完整实现思路,理解医学图像分类任务的处理方法与调参经验,同时借助文档说明快速上手复现,具备较高的学习借鉴价值。

1. 从一张眼底照到诊断结论:糖尿病视网膜病变诊断系统到底在做什么

糖尿病视网膜病变(DR)是糖尿病患者最常见的眼部并发症,也是成年人致盲的主要原因之一。临床上,眼科医生通过观察眼底照片中微动脉瘤、出血点、硬性渗出、新生血管等病灶来判断病变等级。但问题是,基层医院眼科医生严重不足,一个医生一天看几百张眼底照,漏诊和误判几乎不可避免。我见过一个县级医院的真实场景:内分泌科给糖尿病患者做眼底筛查,拍完照没人看,片子堆在系统里,等上级医院专家一周来一次集中读片,中间这一周患者该干嘛干嘛,该转诊的没转诊。

这个标题要解决的就是这件事:用 Jupyter Notebook 搭一套能跑通的糖尿病视网膜疾病诊断系统,包含源码、数据集、模型和文档说明。适合谁?正在做毕业设计的学生、想入门医学图像分类的算法工程师、以及需要快速验证 DR 筛查方案可行性的医疗信息化从业者。Jupyter 在这里的角色不是“随便选个工具”,而是它天然适合做实验记录和结果可视化——每一步预处理、每一轮训练、每一张预测结果都能在同一个 Notebook 里看到,这对毕设答辩和方案汇报来说,比一堆散落的 .py 文件强太多。

2. 数据集怎么选、怎么读、怎么划分才不翻车

2.1 眼底数据集的选择逻辑与常见坑

做 DR 诊断,数据集是第一个分水岭。公开数据集里,Kaggle 的 Diabetic Retinopathy Detection(简称 DR 数据集)是绕不开的,它包含约 35000 张高分辨率眼底照,按 0-4 五级标注。另一个常用的是 Messidor-2,规模小一些但标注质量高。还有 DDR 数据集,标注更细,包含病灶级标注。我一般建议毕设场景优先用 Kaggle DR 数据集,原因有三:样本量大、分级标准明确、社区基线多,遇到问题容易找到参考。

但这里有个血泪经验:Kaggle DR 数据集原始图像分辨率差异极大,从 500×500 到 4000×4000 都有,而且大量图像存在黑边、过曝、欠曝。直接 resize 到 224×224 会把病灶细节抹掉,模型学到的全是背景噪声。常见做法是先做圆形裁剪去掉黑边,再做自适应直方图均衡化(CLAHE)增强对比度,最后才 resize。

import cv2
import numpy as np

def crop_circle_and_clahe(img_path, size=224):
    # 读取图像
    img = cv2.imread(img_path)
    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
    # 灰度化后找圆形眼底区域
    gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
    # 阈值分割,眼底区域通常较亮
    _, thresh = cv2.threshold(gray, 10, 255, cv2.THRESH_BINARY)
    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    # 取最大轮廓作为眼底区域
    c = max(contours, key=cv2.contourArea)
    x, y, w, h = cv2.boundingRect(c)
    # 裁剪矩形区域
    img_crop = img[y:y+h, x:x+w]
    # CLAHE 增强
    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    lab = cv2.cvtColor(img_crop, cv2.COLOR_RGB2LAB)
    lab[:, :, 0] = clahe.apply(lab[:, :, 0])
    img_clahe = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)
    # resize 到目标尺寸
    img_resize = cv2.resize(img_clahe, (size, size))
    return img_resize

这段代码的逻辑是:先通过阈值分割找到眼底圆形区域的外接矩形,裁掉黑边;然后在 LAB 颜色空间的 L 通道上做 CLAHE,避免直接对 RGB 三通道操作导致颜色失真;最后统一 resize。参数方面,clipLimit=2.0 是经验值,太大噪声会被放大,太小增强效果不明显;tileGridSize=(8,8) 适合 224 尺寸的图,如果最终 resize 到 512,可以改成 (16,16)。

2.2 数据划分与类别不平衡处理

DR 数据集有个致命问题:类别极度不平衡。0 级(无病变)占约 73%,4 级(增殖期)不到 2%。如果直接按 8:1:1 随机划分,验证集里可能一个 4 级样本都没有,模型在少数类上的表现完全无法评估。我一般用分层抽样(stratified split),保证每个子集里五类比例一致。

from sklearn.model_selection import train_test_split
import pandas as pd

# 假设 df 包含 image_path 和 label 两列
train_df, temp_df = train_test_split(
    df, test_size=0.3, stratify=df['label'], random_state=42
)
val_df, test_df = train_test_split(
    temp_df, test_size=0.5, stratify=temp_df['label'], random_state=42
)
# 检查分布
print(train_df['label'].value_counts(normalize=True))
print(val_df['label'].value_counts(normalize=True))

分层抽样只能保证划分合理,训练时的类别不平衡还得靠损失函数解决。常见做法是用 Focal Loss 替代交叉熵,或者给少数类更高的样本权重。Focal Loss 的 gamma 参数一般设 2.0,alpha 按类别频率的倒数设置。如果不想改损失函数,也可以在 DataLoader 里用 WeightedRandomSampler,让少数类被采样的概率更高。

注意:不要用简单的随机过采样复制少数类样本,眼底照复制多了模型会过拟合到那几张图的噪声上。要用就用数据增强(旋转、翻转、亮度扰动)生成“新”样本。

3. 模型选型与 Jupyter 里的训练闭环

3.1 从 CNN 到 EfficientNet:骨干网络怎么选

DR 分级本质是细粒度图像分类,病灶区域小、类间差异微妙。早期毕设常用 VGG16 或 ResNet50,但这俩参数量大,在小数据集上容易过拟合,而且推理速度慢。我一般推荐 EfficientNet-B0 或 B3 作为骨干,理由很直接:同等精度下参数量少一个量级,Jupyter 里跑训练显存占用低,笔记本上也能跑。如果追求更高精度且机器允许,可以上 ConvNeXt-Tiny 或 Swin Transformer 的 tiny 版本。

在 Jupyter 里搭模型,我习惯把骨干网络和分类头分开写,方便替换实验。分类头一般用 GlobalAveragePooling + Dropout + 全连接,Dropout 率设 0.3-0.5,取决于训练集大小。

import torch
import torch.nn as nn
from torchvision import models

class DRModel(nn.Module):
    def __init__(self, num_classes=5, backbone='efficientnet_b0', pretrained=True):
        super().__init__()
        if backbone == 'efficientnet_b0':
            self.backbone = models.efficientnet_b0(pretrained=pretrained)
            in_features = self.backbone.classifier[1].in_features
            self.backbone.classifier = nn.Identity()
        elif backbone == 'resnet50':
            self.backbone = models.resnet50(pretrained=pretrained)
            in_features = self.backbone.fc.in_features
            self.backbone.fc = nn.Identity()
        # 自定义分类头
        self.head = nn.Sequential(
            nn.Dropout(0.4),
            nn.Linear(in_features, 256),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(256, num_classes)
        )
    
    def forward(self, x):
        features = self.backbone(x)
        return self.head(features)

这段代码的关键点:把骨干网络的分类层替换成 Identity,只取特征提取部分;然后接自定义分类头。参数方面,Dropout 0.4 和 0.3 是防止过拟合的,如果训练集超过 2 万张可以降到 0.2;中间层 256 维是折中,太小欠拟合,太大过拟合。

3.2 训练循环与 Jupyter 里的实时监控

Jupyter 做训练有个天然优势:每个 epoch 结束可以直接画图看 loss 和 accuracy 曲线,不用等训练完再回头分析。我一般用 tqdm 显示进度条,用 matplotlib 的 inline 模式实时更新曲线。

from tqdm.notebook import tqdm
import matplotlib.pyplot as plt
from IPython.display import clear_output

def train_one_epoch(model, loader, optimizer, criterion, device):
    model.train()
    total_loss, correct, total = 0, 0, 0
    for imgs, labels in tqdm(loader, desc='Training'):
        imgs, labels = imgs.to(device), labels.to(device)
        optimizer.zero_grad()
        outputs = model(imgs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        total_loss += loss.item() * imgs.size(0)
        correct += (outputs.argmax(1) == labels).sum().item()
        total += imgs.size(0)
    return total_loss / total, correct / total

# 训练主循环
for epoch in range(num_epochs):
    train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device)
    val_loss, val_acc = evaluate(model, val_loader, criterion, device)
    # 实时画图
    clear_output(wait=True)
    plt.plot(train_losses, label='train_loss')
    plt.plot(val_losses, label='val_loss')
    plt.legend()
    plt.show()
    print(f'Epoch {epoch+1}: train_acc={train_acc:.4f}, val_acc={val_acc:.4f}')

这里有个坑:Jupyter 里用 clear_output 会清掉之前的输出,如果训练中途崩了,前面的日志全没了。我一般会把每个 epoch 的指标写到一个 list 里,训练结束后存成 CSV,这样即使 Notebook 崩了也能恢复。另外,学习率调度用 CosineAnnealingLR 比 StepLR 更稳,初始学习率设 1e-3,配合 AdamW 优化器,weight_decay 设 1e-4。

3.3 评估指标:为什么准确率会骗人

DR 分级任务里,准确率是最没用的指标。因为 0 级样本占 73%,模型全预测 0 级也能拿到 73% 的准确率,但临床上 4 级漏诊是要出事的。我一般看三个指标:二次加权 Kappa(Quadratic Weighted Kappa)、各类的召回率、以及混淆矩阵。Kappa 能衡量预测和真实标签的一致性,对类别不平衡不敏感,Kaggle 比赛就用这个排名。

from sklearn.metrics import cohen_kappa_score, confusion_matrix
import seaborn as sns

def evaluate_metrics(model, loader, device):
    model.eval()
    all_preds, all_labels = [], []
    with torch.no_grad():
        for imgs, labels in loader:
            imgs = imgs.to(device)
            outputs = model(imgs)
            preds = outputs.argmax(1).cpu().numpy()
            all_preds.extend(preds)
            all_labels.extend(labels.numpy())
    kappa = cohen_kappa_score(all_labels, all_preds, weights='quadratic')
    cm = confusion_matrix(all_labels, all_preds)
    sns.heatmap(cm, annot=True, fmt='d', cmap='Blues')
    plt.xlabel('Predicted')
    plt.ylabel('True')
    plt.show()
    return kappa

Kappa 的解读:0.6 以上算可用,0.8 以上算优秀。如果 Kappa 低于 0.5,先别调模型,回去检查数据预处理和标签有没有错。我见过一个毕设,Kappa 死活上不去,最后发现是数据加载时把 0 级和 1 级的文件夹搞反了。

4. 避坑与排查:那些让模型精度崩掉的细节

4.1 图像读取通道顺序搞反

现象:训练 loss 正常下降,但验证集准确率始终在 20% 左右,和随机猜差不多。原因:OpenCV 读图默认是 BGR 顺序,而 PyTorch 的预训练模型是在 RGB 上训练的。如果直接把 cv2.imread 的结果转 tensor,颜色通道就反了,模型看到的“红色病灶”其实是蓝色的。解决:读图后立刻 cv2.cvtColor(img, cv2.COLOR_BGR2RGB),或者统一用 PIL 读图。

4.2 数据增强把病灶增强没了

现象:加了随机旋转和裁剪后,验证集精度反而下降。原因:DR 病灶集中在眼底特定区域,随机裁剪可能把病灶裁掉,模型学到的是正常区域。解决:裁剪用中心裁剪或随机裁剪但限制裁剪比例不超过 10%,旋转角度限制在 ±15 度以内。翻转要慎用,水平翻转一般安全,垂直翻转可能改变病灶的解剖学位置关系。

4.3 预训练权重加载失败但没报错

现象:用了 pretrained=True,但模型收敛速度和随机初始化差不多。原因:torchvision 的预训练权重下载可能因为网络问题失败,但代码里没检查,backbone 实际是随机初始化的。解决:加载后打印几层权重的均值,确认不是随机值;或者手动指定 weights 参数并捕获异常。

4.4 验证集 loss 震荡大

现象:训练 loss 平稳下降,验证 loss 上下跳动超过 0.5。原因:验证集太小,或者 batch size 太小导致 BatchNorm 统计量不稳定。解决:增大验证集比例到 15%-20%,验证时用 model.eval() 固定 BatchNorm,如果还不行就把 BatchNorm 换成 GroupNorm。

4.5 Jupyter 内存泄漏导致训练中断

现象:训练到第 10 个 epoch 左右,Notebook 卡死或 kernel 重启。原因:每个 epoch 的中间变量(如图像 tensor、loss 值)没有及时释放,GPU 显存或内存被占满。解决:在训练循环里用 del 删除不再使用的变量,每个 epoch 结束后 torch.cuda.empty_cache(),画图时用 plt.close() 关闭 figure。

5. 从 Notebook 到可演示系统:推理封装与结果可视化

5.1 单张推理与 Grad-CAM 热力图

毕设答辩时,光给一个准确率数字是不够的,评委想看模型到底“看”到了哪里。Grad-CAM 能把模型关注区域以热力图形式叠加在原图上,直观展示病灶位置。在 Jupyter 里实现 Grad-CAM 不需要额外装包,用 PyTorch 的 hook 机制就能做。

import torch.nn.functional as F

class GradCAM:
    def __init__(self, model, target_layer):
        self.model = model
        self.target_layer = target_layer
        self.gradients = None
        self.activations = None
        # 注册 hook
        target_layer.register_forward_hook(self.save_activation)
        target_layer.register_backward_hook(self.save_gradient)
    
    def save_activation(self, module, input, output):
        self.activations = output.detach()
    
    def save_gradient(self, module, grad_input, grad_output):
        self.gradients = grad_output[0].detach()
    
    def generate(self, img_tensor, class_idx=None):
        self.model.eval()
        output = self.model(img_tensor)
        if class_idx is None:
            class_idx = output.argmax(1).item()
        # 反向传播
        self.model.zero_grad()
        output[0, class_idx].backward()
        # 计算权重
        weights = self.gradients.mean(dim=(2, 3), keepdim=True)
        cam = (weights * self.activations).sum(dim=1, keepdim=True)
        cam = F.relu(cam)
        cam = F.interpolate(cam, size=img_tensor.shape[2:], mode='bilinear', align_corners=False)
        cam = cam - cam.min()
        cam = cam / (cam.max() + 1e-8)
        return cam.squeeze().cpu().numpy()

这段代码的逻辑:前向传播时保存目标层的激活值,反向传播时保存梯度,然后用梯度的全局平均作为权重对激活值加权求和,最后 ReLU 去掉负值并归一化。target_layer 一般选骨干网络的最后一个卷积层,比如 EfficientNet 的 features[-1]。生成的热力图用 cv2.applyColorMap 叠加到原图上,红色区域就是模型认为最像病灶的地方。

5.2 批量推理与报告生成

实际演示时,评委可能给一张新图让你现场预测。我一般会在 Notebook 最后加一个交互式 cell,用 ipywidgets 做文件上传按钮,上传后自动预处理、推理、显示原图、热力图和预测概率。

import ipywidgets as widgets
from IPython.display import display, Image as IPImage

uploader = widgets.FileUpload(accept='.jpg,.png', multiple=False)
display(uploader)

def on_upload(change):
    # 保存上传的图片
    content = next(iter(uploader.value.values()))['content']
    with open('temp.jpg', 'wb') as f:
        f.write(content)
    # 预处理
    img = crop_circle_and_clahe('temp.jpg')
    tensor = torch.from_numpy(img).permute(2, 0, 1).float().unsqueeze(0) / 255.0
    tensor = tensor.to(device)
    # 推理
    with torch.no_grad():
        output = model(tensor)
        probs = F.softmax(output, dim=1)
    # 显示结果
    pred_class = probs.argmax(1).item()
    print(f'预测等级: {pred_class}, 置信度: {probs[0, pred_class]:.4f}')
    display(IPImage('temp.jpg'))

uploader.observe(on_upload, names='value')

这个交互 cell 在答辩时非常实用,评委可以自己传图测试,比放视频有说服力。注意 ipywidgets 需要和 Jupyter Notebook 版本匹配,如果按钮不显示,先检查 jupyter nbextension 有没有启用。

5.3 模型导出与轻量化部署

如果毕设要求“可部署”,可以把训练好的模型导出为 ONNX 格式,然后用 ONNX Runtime 推理,速度比 PyTorch 快 2-3 倍,而且不依赖 PyTorch 环境。

# 导出 ONNX
dummy_input = torch.randn(1, 3, 224, 224).to(device)
torch.onnx.export(
    model, dummy_input, 'dr_model.onnx',
    input_names=['input'], output_names=['output'],
    dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}},
    opset_version=11
)

# ONNX Runtime 推理
import onnxruntime as ort
sess = ort.InferenceSession('dr_model.onnx')
input_name = sess.get_inputs()[0].name
output = sess.run(None, {input_name: tensor.cpu().numpy()})

导出时 opset_version 建议用 11 或 12,兼容性好。dynamic_axes 让 batch 维度可变,方便批量推理。如果导出后精度下降超过 1%,检查有没有不支持的算子,或者改用 opset 13。

5.4 一个我踩过的坑:Notebook 路径问题

Jupyter Notebook 默认保存路径和当前工作目录经常不一致,导致代码里写的相对路径在别的机器上跑不通。我现在的习惯是:在 Notebook 第一个 cell 里用 os.chdir 切到项目根目录,所有路径基于项目根目录写。另外,数据集路径不要硬编码,用配置文件或环境变量,这样换机器时只改一个地方。

import os
# 切换到项目根目录
PROJECT_ROOT = os.path.expanduser('~/dr_project')
os.chdir(PROJECT_ROOT)
# 数据集路径
DATA_DIR = os.path.join(PROJECT_ROOT, 'data')
MODEL_DIR = os.path.join(PROJECT_ROOT, 'models')
os.makedirs(MODEL_DIR, exist_ok=True)

这个习惯看起来简单,但能省掉大量“在我电脑上能跑”的扯皮时间。希望帮到你。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐