简介:医学图像分类是深度学习在医疗领域的重要应用方向,其核心原理是利用卷积神经网络自动提取图像特征,从而替代或辅助人工诊断。在实际工程中,迁移学习通过在大型自然图像数据集上预训练模型,再在医学影像上微调,有效解决标注数据不足的问题;结合CLAHE预处理和类别均衡策略,可显著提升模型对少数类别的识别能力。该类技术广泛应用于糖尿病视网膜病变等慢性病的早期筛查,能有效缓解基层医疗资源紧张。本文以基于Jupyter的糖尿病视网膜病变诊断项目为例,完整梳理从眼底图像预处理、模型迁移训练到评估指标(如Quadratic Weighted Kappa)选择及项目组织的方法,为医学影像分类方向的毕业设计和工程实践提供可复现的思路参考。 毕业设计题目一栏写着"基于Jupyter实现的糖尿病视网膜疾病诊断",配套资料还标注了"源码+数据集+模型+文档说明",你大概率跟我当年拿到这个题目时一样:一边觉得这个课题很"人工智能",一边又不知道第一行代码该从哪里落笔。其实这个项目的本质,是把糖尿病视网膜病变(Diabetic Retinopathy,简称DR)这个真实的眼科临床问题,拆解成一个多分类图像识别任务——输入一张眼底彩照,模型输出它属于哪一个严重等级(0到4级)。听起来不复杂,但把数据处理、模型训练、结果评估、文档撰写整个链路在Jupyter环境里完整跑通,里面值得讲的东西比想象中多得多。
这篇文章不是给你一份可以直接抄作业的完整源码,而是把做这个毕设时真正花时间想明白的事情讲清楚:数据集怎么选怎么处理、模型在本科阶段做到什么程度算"够了"、评估指标为什么不能只看准确率、Jupyter项目怎么组织才能在答辩时不慌,以及那些不会出现在教程里的坑。如果你正准备做医学图像分类方向的毕设,或者只是想在一周内把这个方向吃透,这篇文章应该能帮你省下不少时间。
1. 这个毕设到底在做什么:从眼科筛查困境到图像分类任务
1.1 糖尿病视网膜病变,一个值得做的临床问题
糖尿病视网膜病变是糖尿病最常见的微血管并发症之一,也是成年人后天致盲的最主要眼病。患者血糖长期控制不佳,视网膜的毛细血管会逐渐受损,出现微动脉瘤、出血点、渗出甚至新生血管。可怕的地方在于,早期DR几乎没有明显症状,等患者觉得视力下降再就诊,往往已经进入中晚期。
临床上靠眼底照相对高危人群做定期筛查是最有效的手段,但专业的眼底病医生数量有限,大量基层影像没人能及时读。于是"AI读片"成了一个很自然的切入点——让模型先筛一遍,有异常的再转给医生复核,可以极大缓解筛查压力。这就是你这个毕设题目存在的现实意义。
1.2 毕设的技术定位:一套完整的图像分类闭环
放在毕业设计的尺度上,你要做的不是真的去医院部署一套系统,而是把整个AI辅助筛查的技术链路完整走一遍,并且留下可复现的结果。具体到代码层面,这个任务就是一个典型的监督学习图像分类问题:训练集是一批带医生标注的眼底彩照,标签是0到4五个严重等级,模型要做的是从图像特征中学到一套能够区分这些等级的映射关系。你最终交付的,是一个能跑通的训练与推理流程,外加一份解释得清楚的实验报告。
这个定位很重要。很多同学做毕设时容易陷入两个极端:要么把目标定得太高,想做出一个包含前端后端、能上传图片实时诊断的完整产品,结果精力被分流得厉害;要么把目标定得太低,随便找几个模型跑一遍准确率就算完事,答辩时讲不出所以然。正确的做法是抓主线——数据、模型、评估、文档四条线,每条线都做到"能解释清楚为什么这么选、为什么这么做"。
1.3 技术栈选择:为什么要用 PyTorch + Jupyter
这个题目既然点名了Jupyter,开发环境就是它。我的建议是:数据处理和模型训练用PyTorch,Notebook负责可视化、实验记录和推理演示。用PyTorch而不是TensorFlow,理由很现实:近几年的论文和开源实现大多基于PyTorch,你在调试时遇到问题,搜到有效解决方案的概率更高;而且PyTorch的动态图机制在Notebook里调试特别顺手,打印张量shape、画损失曲线、临时改网络结构都很直观。
顺带说一句,现在的Jupyter生态已经不是当年那个"丑但能用"的Notebook了,如果环境允许,建议直接装JupyterLab,界面现代化很多,可以分栏看代码和图表,体验好不少。如果实验室统一用Notebook,那按习惯来就好,功能上对完成这个项目没有本质差别。
2. 数据准备:眼底图像处理里的三个隐藏门槛
2.1 数据集选型:APTOS 2019是本科毕设的最优解
这个方向最常用的公开数据集是EyePACS和APTOS。本科毕设强烈建议用APTOS 2019——Kaggle上一个专门为DR分级举办的比赛数据集,图片质量相对统一,标签由有经验的眼科医生标注,规模对个人电脑也友好。训练集有3662张眼底彩照,测试集大约1900张,五分类规则如下:
| 等级 | 分类名称 | 眼底表现 |
|---|---|---|
| 0 | 无DR | 无明显病变 |
| 1 | 轻度NPDR | 仅有微动脉瘤 |
| 2 | 中度NPDR | 病变比1级重但不到3级 |
| 3 | 重度NPDR | 多象限出血、静脉串珠、IRMA |
| 4 | PDR(增殖期DR) | 新生血管或玻璃体积血 |
注意,这里的"严重程度"是眼底照片上的视觉表现,不是视力检查结果。图像分类模型学的是视觉特征到等级的映射。这个表格建议放进毕设文档的"医学背景"章节,答辩时老师很可能会问"你怎么理解每一级之间的区别"——能指着图说出微动脉瘤、渗出、新生血管这些名词,印象分会高很多。
2.2 预处理第一步:去掉黑边,把眼底区域真正露出来
眼科图像和普通图像分类有一个明显区别:照片里除了圆形眼底区域,周围还有一大圈黑色背景。如果直接resize送进模型,黑色区域会带来大量无意义特征,模型的一部分能力就浪费在"学习背景是黑的"这种事上了。所以第一步通常是去除黑边,把圆形眼底区域提取出来。
操作上,用HSV颜色空间就够了。眼底区域的饱和度和亮度跟黑色背景差异很大,通过饱和度通道做阈值可以得到一个二值掩膜,然后按掩膜找到最小外接圆或外接矩形,裁剪出有效区域:
import cv2 import numpy as np def preprocess_fundus(img_path, output_size=224): img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV) # 眼底区域饱和度远高于黑色背景,用阈值分离 mask = cv2.inRange(hsv[:, :, 1], 20, 255) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) x, y, w, h = cv2.boundingRect(contours[0]) center = (x + w // 2, y + h // 2) radius = max(w, h) // 2 mask_circle = np.zeros_like(mask) cv2.circle(mask_circle, center, radius, 255, -1) masked = cv2.bitwise_and(img, img, mask=mask_circle) cropped = masked[y:y + 2 * radius, x:x + 2 * radius] resized = cv2.resize(cropped, (output_size, output_size)) return resized2.3 预处理第二步:色彩归一化与数据划分的细节
去完黑边之后,还需要处理色彩差异。不同医院、不同设备拍出来的眼底图色温差异很大,有的偏黄、有的偏白。直接让模型去适应这种差异也可以,但会让训练变慢。更稳妥的做法是做CLAHE(限制对比度自适应直方图均衡化),把亮度分布拉平。这个操作在医学影像预处理里非常常见:
def apply_clahe(img): lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)数据集的划分则要格外小心。因为数据本身类别不均衡——正常和轻度占大头,重度DR样本少——如果简单随机划分,可能某一折里没有3级或4级样本。用train_test_split的stratify参数,按标签比例分层抽样,保证每一折的类别分布和全集一致:
from sklearn.model_selection import train_test_split train_df, val_df = train_test_split( df, test_size=0.2, stratify=df['level'], # 按标签分层 random_state=42 )这一层如果你忽略了,后面模型在验证集上的表现会忽高忽低,而且你很难判断到底是模型问题还是数据划分问题。真到答辩前才发现这层问题,重跑实验的时间成本就太高了。
3. 模型选型与训练:本科毕设的"够用"标准
3.1 迁移学习:本科毕设最靠谱的起点
很多同学一上来就想去搞最新的ViT、Swin Transformer,或者什么自研的高性能架构。如果你的算力和调参经验都很充足,尝试这些当然没问题,但对绝大多数本科毕设来说性价比不高:训练时间更长、显存要求更高、调参更复杂,而在APTOS这种中等规模数据集上,经典CNN配合迁移学习完全能拿到足够好的结果。
为什么迁移学习在医学图像领域几乎是标配解法?因为医学图像数据集通常只有几千张,而ImageNet有上百万张。用预训练权重初始化网络,等于让模型站在"已经知道怎么识别纹理、边缘、形状"的肩膀上,只需要在眼底图像上做微调。训练速度更快、收敛更稳定、精度更高,三赢。
实际操作上,两个模型最合适:
- ResNet50:最经典、显存友好、训练稳定,本科阶段首选,作为baseline非常合适。
- EfficientNet-B4:同样搭配迁移学习,精度通常比ResNet50高一点,但训练速度慢、显存占用大。如果机器配置不错,可以做对比实验。答辩时一句"我用ResNet50作baseline,再引入EfficientNet做对比"比只跑一个模型有说服力得多。
3.2 模型搭建:只需要改最后几行
PyTorch中构建模型的核心代码其实很简单,关键是把最后的全连接层换成自己的分类头:
import torch.nn as nn from torchvision import models def build_model(num_classes=5, model_name='resnet50'): if model_name == 'resnet50': weights = models.ResNet50_Weights.IMAGENET1K_V1 model = models.resnet50(weights=weights) in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, num_classes) ) elif model_name == 'efficientnet_b4': weights = models.EfficientNet_B4_Weights.IMAGENET1K_V1 model = models.efficientnet_b4(weights=weights) in_features = model.classifier[1].in_features model.classifier = nn.Sequential( nn.Dropout(0.5), nn.Linear(in_features, num_classes) ) return model这里为什么要加一个Dropout层?因为医学图像分类场景里类别间特征差异比较细微,全连接层的参数量又很大,很容易出现过拟合。Dropout在训练时随机丢弃一部分神经元连接,相当于每次前向传播都在训练一个不同的小网络,最后在推理时取平均,对防止模型死记训练集有明显帮助。
3.3 训练配置与类别不均衡处理
一个我自己实测下来比较稳的训练配置:
- 输入尺寸:224×224(用EfficientNet-B4可以尝试更大尺寸,但不强求)
- 损失函数:CrossEntropyLoss,加类别权重
- 优化器:Adam,lr=1e-4,weight_decay=1e-4
- Batch size:ResNet用32或64;EfficientNet-B4在12G显存下建议32以内
- 轮数:15~25轮,配合早停
- 学习率调度:ReduceLROnPlateau,验证集loss连续3轮不降就衰减为原来的0.1
类别不均衡是医疗影像分类的常见问题。如果直接用原始分布训练,模型会倾向把样本预测为占比高的0级和1级,导致少数类别的召回率很低。一种标准做法是给Loss加一个权重系数,让少数类别被错分的代价更高:
from sklearn.utils.class_weight import compute_class_weight import torch class_weights = compute_class_weight( class_weight='balanced', classes=np.array([0, 1, 2, 3, 4]), y=train_df['level'].values ) class_weights = torch.tensor(class_weights, dtype=torch.float32).to(device) criterion = nn.CrossEntropyLoss(weight=class_weights)3.4 数据增强:宁小勿大,别把病变特征增强没了
医学图像的数据增强跟通用分类任务比要保守得多,因为病变本身的形态是临床诊断的核心依据。水平翻转、垂直翻转在眼底图上完全合理,因为眼底左右眼结构本身就存在镜像关系;但如果旋转角度过大,正常血管的位置关系就会失真;颜色抖动如果调得猛,可能把一个真实的出血点给调没了。
我的经验是:水平翻转、垂直翻转、小角度旋转(±10度)、轻微缩放(0.9~1.1)就够用,颜色类增强幅度宁小勿大。用albumentations库实现很方便:
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.RandomResizedCrop(224, 224, scale=(0.9, 1.0)), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.Rotate(limit=10), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ToTensorV2() ])3.5 训练监控与模型保存
训练过程建议在Notebook里记录每个epoch的loss和验证集指标。可以在Notebook里手动画损失曲线,也可以用wandb,但毕设场景下用matplotlib就够了,因为你要在文档里贴图,自画的图更可控。
这里有一个特别重要的习惯:模型不要只保存最后一个epoch。正确做法是每个epoch结束后在验证集上评估一次,如果当前指标超过历史最好,就保存这一份权重。这样即使后几个epoch过拟合了,你手里也有一份最优的模型文件。训练代码里建议加类似这样的逻辑:
if val_score > best_score: best_score = val_score torch.save(model.state_dict(), 'weights/best_model.pt')4. 评估与结果分析:答辩时站得住脚的三个指标
4.1 指标选择:准确率只是起点,Kappa才是关键
这个任务的评估指标很有讲究。如果只看Accuracy,你会看到一个假象:因为0级、1级样本加起来占了数据的一半以上,一个只会猜"0或1"的模型都能刷到六七十的准确率。所以毕设里一定要用以下三个指标一起说:
- Accuracy:总体分类正确的比例,直观但脆弱,受类别分布影响很大。
- Macro F1-Score:对每个类别分别计算F1再取平均,类别不均衡时比Accuracy可靠。
- Quadratic Weighted Kappa(QWK):医学诊断领域最重要的指标。它把"预测错一级"和"预测错三级"分开对待,错得越多,惩罚越重。
QWK值得多说几句。DR分级本身是有序的,0级到4级的严重程度是递增的。一个把2级错判成3级的模型,比把0级错判成4级的模型要"靠谱"不少。但Accuracy对这些错误一视同仁,只有QWK能体现出这种有序关系。这也是当年Kaggle比赛官方排名用的指标。你在答辩里写一句"本实验采用与官方评测一致的Quadratic Weighted Kappa作为主要指标",评委一眼就知道你做过功课。
计算起来也简单:
from sklearn.metrics import cohen_kappa_score kappa = cohen_kappa_score(y_true, y_pred, weights='quadratic')4.2 混淆矩阵与错误案例复盘
混淆矩阵一定要画。它比任何指标都能直观说明模型错在哪里。画法很简单:
import matplotlib.pyplot as plt import seaborn as sns from sklearn.metrics import confusion_matrix cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2, 3, 4]) plt.figure(figsize=(6, 5)) sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=[0, 1, 2, 3, 4], yticklabels=[0, 1, 2, 3, 4]) plt.xlabel('Predicted') plt.ylabel('True') plt.title('Confusion Matrix') plt.show()典型的结果是:0级和1级容易互相混淆,2级容易被分到1级或3级,3级和4级之间也有一定交叉。原因很直观:0级和1级的关键区分点是微动脉瘤等小病灶,在224×224分辨率下细节容易丢失;2级本身是个"中间地带",视觉特征不够鲜明。画出矩阵之后,你会对"模型为什么出错"有一个直观认识,而不是对着准确率数字瞎猜。把这些观察写进毕设文档的"结果分析"章节,整个项目立刻就有了真实的临床感。
4.3 做一个演示级的推理 Demo
建议在Notebook里写一个推理demo cell,随机挑几张测试集图片,把原图、真实等级、预测等级、各类别预测概率打印出来。这个步骤有两个作用:一是你自己能直观判断模型的预测是否符合常理,二是答辩时直接运行给老师看,效果远好于放几张静态截图。
import torch from PIL import Image model.eval() with torch.no_grad(): img = preprocess_fundus('sample.jpg') # 返回 224x224x3 的 ndarray tensor = val_transform(image=img)['image'].unsqueeze(0).to(device) probs = torch.softmax(model(tensor), dim=1).squeeze().cpu().numpy() pred_class = int(probs.argmax()) print(f"预测等级: {pred_class}, 置信度: {probs[pred_class]:.3f}") print("各类别概率:", [round(p, 3) for p in probs])如果模型把一张1级图预测成了2级,你可以分析:是不是图像拍摄角度偏了?是不是白内障影响了成像清晰度?这些观察虽然不能直接改进模型,但会让你在答辩时面对"这个错误怎么解释"这类问题时不慌。
5. 把 Jupyter 项目组织成能答辩的样子:文件结构与文档说明
5.1 Notebook 与 Python 脚本的分工
如果从头到尾所有代码都在一个巨大的Notebook里跑,实验过程中当然没问题,但答辩前翻阅和复现会非常痛苦。我建议把项目拆成两层:Notebook负责"看得见的演示",Python脚本负责"可复用的逻辑"。
一个比较合理的项目结构长这样:
DR_Project/ ├── data/ │ ├── train_images/ # 原始眼底图 │ └── train.csv # 图片名与label ├── src/ │ ├── __init__.py │ ├── dataset.py # 数据集类与数据增强 │ ├── model.py # 模型构建 │ ├── train.py # 训练主流程 │ └── evaluate.py # 评估与可视化 ├── notebooks/ │ ├── 01_EDA.ipynb # 数据探索与可视化 │ ├── 02_DataPrep.ipynb # 预处理流程演示 │ ├── 03_Training.ipynb # 训练记录与曲线 │ └── 04_Inference_Demo.ipynb # 推理演示与结果分析 ├── weights/ │ └── best_model.pt ├── figures/ # 论文/答辩用图 ├── README.md └── requirements.txt这样组织有几个好处。一是Notebook里可以import src模块,让Notebook看起来不像"写代码",而像"用代码做研究";二是提交压缩包时,老师
本文还有配套的精品资源,点击获取