眼底图像病变分类实战:从机器学习基线到深度学习调参

发布时间:2026/9/11 22:50:41
眼底图像病变分类实战:从机器学习基线到深度学习调参 简介这是一份基于机器学习和深度学习的眼底图像病变分类项目资料面向医学影像分析初学者、眼科AI方向研究人员以及需要快速搭建分类实验的开发者主要解决眼底图像正常与异常、可分级与不可分级等病变类别的自动识别问题。压缩包共46个文件容量约459KB其中包含28个Python脚本用于模型训练、推理、预处理、激活图叠加等环节4个npy文件保存训练/验证索引数据另有标签校验目录与Shell脚本辅助标签校验和快速运行。项目采用CNN等算法覆盖DIARETDB1、DRIVE等公开数据集的处理流程并给出TensorFlow/PyTorch环境搭建与Docker复现说明。已有344人浏览学习适合边读代码边对照环境说明进行复现也可参考其模型训练与数据增强思路迁移至其他医学图像分析任务。1. 别急着上 CNN先搞清楚这个问题真正难在哪把 MNIST 那套深度学习流程原样搬到眼底彩照上十有八九会在验证集上翻车。眼底图像病变分类这个标题看起来像是一个打包好的课程设计资源实际内容却不是「跑通一个网络」这么简单输入是眼底相机拍出的彩色照片输出是病种标签或者病变分级比如糖尿病视网膜病变DR的 0 到 4 期、青光眼、黄斑水肿等。这类任务有三个特点样本量通常只有几千张、类别之间极度不平衡、不同拍摄设备的亮度和色温差异很大。真正决定交付质量的反而是数据集怎么切分、怎么预处理、传统机器学习基线能不能站起来以及深度学习模型在类别不平衡下怎么调参。环境搭建说明也从来不是装个 PyTorch 就完事而是要把数据路径、版本依赖和随机种子都固定下来。这篇文章就沿着「数据 → 机器学习 → 深度学习 → 验证」这条路径把一套能复现、能写进报告的做法讲清楚。2. 眼底图像数据集目录规范、预处理与类不平衡要先于模型解决标题里「内含数据集」这几个字意味着拿到压缩包之后的第一件事不是解压就训练而是把数据从「一堆图片」整理成「一份可复现的数据集物件」。这个环节做得越细后面换模型、调参数、做对比实验时就越省事。2.1 公开数据集、标签定义与「内含数据集」的差别大多数以「内含数据集」形式打包的资源图片来源并不是拍摄机构直接提供的原始数据而是从公开竞赛和公开数据整理而来。整理质量的差异很大有的保留了完整的标签 CSV有的把标签写在了文件名里还有的混入了重复图或标注噪声。拿到数据后先确认两件事标签有几种取值各种取值分别有多少张同一患者的双眼图像是否可能同时出现在训练集和验证集里。眼底图像领域常见的公开数据集有下面这些它们的任务定义差异很大拿到「内含数据集」时可以先对照着看标签格式更接近哪一种。数据集图像规模标签类型常见用途APTOS 2019训练集三千余张DR 0-4 五级分类单眼五分类ODIR约 8000 张8 个疾病标签多标签多标签分类IDRiD数百张高清病变分割 分级分割与分级联合DRIVE40 张血管二分类掩膜血管分割如果「内含数据集」的标签是类似0,1,2,3,4的整数大概率就是 DR 分级任务如果标签是N, D, G, C, A, H, M, O这样的字母那就是 ODIR 风格的多标签分类。这两个任务前者用CrossEntropyLoss后者要用BCEWithLogitsLoss模型最后一层的输出维度也不一样动手前先确认清楚。2.2 环境搭建与目录初始化环境搭建说明一般以 README 形式附在包里但里面的步骤往往只覆盖「安装依赖」不会告诉你目录怎么组织。我一般会建一个和深度学习框架无关的目录骨架让数据、源码、权重各归其位避免训练到一半发现项目里到处都是脚本副本。project/ ├── data/ │ ├── raw/ # 原始图片解压后不再改动 │ ├── labels.csv # 文件名和标签的映射 │ ├── train.txt # 划分后的训练列表 │ └── val.txt # 划分后的验证列表 ├── src/ │ ├── preprocess.py │ ├── dataset.py │ └── train.py ├── weights/ # 按时间戳保存模型 └── requirements.txt这一步看起来和模型无关但train.txt和val.txt是后续所有实验的基准线。同一批图片每次训练前重新划分得到的结果就没法横向比较了。环境搭建方面优先用 conda 创建独立环境再按顺序安装PyTorch 和 torchvision 放在最前面因为它们决定 CUDA 版本然后是 opencv-python、scikit-learn、pandas 这类依赖。提示先确认 CUDA 版本再装 torchvisionpip install torch torchvision默认装的是 CPU 版还是 GPU 版取决于运行平台装完立即用一个小张量验证 CUDA 是否可用不要等训练报错才发现环境不对。2.3 图像预处理去黑边、尺寸统一与 CLAHE 照度校正眼底相机拍出的图片是圆形视场四角是纯黑背景。如果不处理这些黑边模型会把「黑色像素占比」学成特征——某台设备光圈调大一点黑边变少了模型输出可能就变了。常见的做法是先裁掉黑边再做照度归一化。import cv2 import numpy as np def crop_black_border(img: np.ndarray) - np.ndarray: gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, thresh cv2.threshold(gray, 5, 255, cv2.THRESH_BINARY) coords cv2.findNonZero(thresh) x, y, w, h cv2.boundingRect(coords) return img[y:y h, x:x w] def apply_clahe(img: np.ndarray, clip: float 2.0) - np.ndarray: lab cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimitclip, tileGridSize(8, 8)) l clahe.apply(l) return cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)crop_black_border先用阈值把亮度低于 5 的像素置为背景再用boundingRect求出视场的外接矩形最后裁出有效区域。apply_clahe在 LAB 颜色空间的亮度通道上做对比度限制自适应直方图均衡化clipLimit2.0控制对比度放大幅度太大容易把噪声也拉起来tileGridSize(8, 8)意味着亮度统计在 8x8 的局部块中进行适合眼底图像这种一张图里明暗差异大的情况。注意先裁黑边再做 CLAHE顺序反了的话 CLAHE 会把黑边区域也做均衡化浪费计算量还引入伪纹理。2.4 数据划分与类不平衡统计预处理写完后的下一步是划分数据这一步要遵循一个原则按患者划分不要按图片划分。同一个患者的左右眼图像高度相似如果左眼进了训练集右眼进了验证集模型相当于提前见过答案。划分完成后立刻统计每个类别的样本数。DR 五分类数据集的典型分布是0类占一半以上4类只有几十张。看到这种分布要先决定缓解策略而不是直接开训。常用的两条路是在损失函数里按类别频率加权或者用WeightedRandomSampler对少数类过采样。这两条路后面训练深度学习模型时再展开这里只需要把类别分布记录下来写进报告或 README作为后续实验的对照组信息。3. 机器学习基线用手工特征和线性分类器先拿到下限标题里同时出现「机器学习和深度学习」最常见的实现策略不是二选一而是先用机器学习做一个轻量基线再拿深度学习去冲击更高指标。这符合课程设计和论文对比实验的常见要求也符合工程直觉。3.1 为什么这个标题里的「机器学习」通常不是单选题几千张图像直接训 ResNet如果不加预训练效果未必比一个好调过的传统分类器强。原因是眼底图像数据集小、类别边界模糊纯数据驱动的深度模型在小样本下很容易过拟合验证集上表现出一种「分类准确但泛化不足」的状态。机器学习基线的价值有两个。一是提供一个低方差的下限手工特征固定之后跑十次结果都差不多用来判断深度学习模型到底有没有真正学到东西二是特征可解释哪种类别最容易被错分可以回溯到颜色或纹理特征找到原因。很多教材热衷于 MNIST 数据集上展示深度模型的威力但实际医疗场景里机器学习分类器仍然是不可替代的对照物。3.2 特征工程颜色直方图 LBP 的最小组合对于眼底图像不需要一开始就做复杂的分割和形态学特征。一个容易复现又有区分度的组合是HSV 颜色直方图描述颜色分布局部二值模式LBP描述纹理。出血点和渗出斑在色相上与正常视网膜差异明显而 LBP 能捕捉渗出斑边缘的纹理突变。from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from skimage.feature import local_binary_pattern def extract_features(img): hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) hist cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256]) hist cv2.normalize(hist, hist).flatten() gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) lbp local_binary_pattern(gray, 24, 3, methoduniform) lbp_hist, _ np.histogram(lbp.ravel(), binsnp.arange(0, 27), densityTrue) return np.concatenate([hist, lbp_hist]) X np.stack([extract_features(p) for p in image_paths]) clf make_pipeline(StandardScaler(), SVC(kernelrbf, C10, gammascale)) clf.fit(X, y)cv2.calcHist将 H 通道的 180 级和 S 通道的 256 级分别量化到 16 个区间得到 16x16 的联合直方图。联合直方图比单独通道直方图更能刻画「颜色分布」比如黄白色渗出斑对应 H 通道偏黄、S 通道偏低的组合区域。local_binary_pattern的半径设为 3邻域点数设为 24methoduniform会把模式压缩到 26 个 bin再用densityTrue归一化成概率分布。SVC 的C10控制误分类惩罚gammascale让 RBF 核的带宽根据特征维度自动调整避免手动调这个参数。如果特征提取耗时太长可以对每张图先缩放到 256x256 再提取特征。颜色直方图对分辨率不敏感LBP 统计的是局部模式分布缩放不影响大局。3.3 直接把 SVM 换成树模型行不行很多做机器学习分类器的人会把 SVM 换成随机森林或 LightGBM。这个替换在特征维度不高时通常没有明显收益但在维度上升后会有差异。分类器适合情况注意事项SVM RBF特征维度几百到几千需要标准化类别不平衡时效果下降明显随机森林特征含噪声或缺失对不平衡敏感需要调class_weightLightGBM特征很多且想省时间树模型对类别频率敏感少数类容易学不到眼底图像手工特征通常在几百维左右SVM 的边界决策在小样本下更稳。如果坚持用 LightGBM记得设置class_weightbalanced或手动指定权重否则多数类会主导树的切分。机器学习这一步满足「能对比」就够了不要在这里花太多时间调参把重心留给深度学习部分。4. 深度学习主模型PyTorch 训练流程与关键参数深度学习部分是整个任务的核心也是实现「病变分类」高指标的主力但其中 80% 的效果来自数据策略模型结构反而没那么重要。使用 PyTorch 深度学习框架时最稳妥的路线是加载 ImageNet 预训练权重做微调而不是从零训练。4.1 选 ResNet 还是 EfficientNet预训练权重要不要保留眼底图像虽然和自然图像差异很大但预训练权重中学到的边缘、纹理、颜色渐变等底层特征仍然有效。对于几千张图的规模从零训练一个 ResNet 通常学不到足够泛化的特征微调是必要选项。模型首选 ResNet-18 或 ResNet-50。ResNet-18 在单卡小显存环境下训练速度快ResNet-50 精度上限更高但需要更多数据增强配合。EfficientNet-B0 理论效率更好但需要注意它的classifier结构和 ResNet 不一样改最后一层时不要直接写model.fc ...而是查一下model.classifier的输入维度。import torch from torch import nn from torch.utils.data import DataLoader from torchvision import models, transforms transform_train transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) criterion nn.CrossEntropyLoss(weightclass_weights.to(device)) optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max20)RandomHorizontalFlip和RandomRotation是轻量几何增强对眼底图像这种结构相对固定的数据足够ColorJitter模拟不同设备亮度和对比度差异但幅度不要太大。CrossEntropyLoss中的class_weights是关键参数把类别频率倒数开根号后再归一化即参考1 / sqrt(freq)避免少数类权重过大导致模型整体偏向少数类。4.2 类别不平衡loss 加权和 WeightedRandomSampler 只选一个类别不平衡是这个任务最容易出问题的地方。最常用的两种处理方式是损失函数加权和采样器加权但不要同时开满。CrossEntropyLoss的weight参数直接改变每个样本对梯度贡献的比例。当少数类权重过大时每个 epoch 里少数类样本被重复强调容易过拟合到少数类的个别特征。WeightedRandomSampler通过调整采样概率让每个 epoch 吃到的少数类样本更多但这会让每个 epoch 里总体样本分布跟真实分布不一致验证时如果还用原始分布评估指标会有偏差。我一般倾向于只用 loss 加权且权重设置为1 / sqrt(freq)。这个策略比1 / freq平滑少数类不会被过度放大多数类也不会被完全压制。4.3 关键参数表与最小训练命令训练参数直接照搬别人的配置不一定适合你的数据但下面这个范围可以从大部分眼底图像数据集上手。参数建议范围说明batch size16 到 32显存吃紧时用 8配合梯度累积到等效 32学习率1e-4 到 3e-4微调阶段不要用 1e-3容易破坏预训练特征weight decay1e-4 到 5e-4控制过拟合太小没效果太大会欠拟合轮数20 到 40余弦退火下 30 轮左右足够稳定loss 权重1 / sqrt(freq)先试这个再决定要不要换成采样器训练脚本建议用命令行参数控制这些变量方便记录每次实验的配置python src/train.py \ --data-dir data/ \ --train-txt data/train.txt \ --val-txt data/val.txt \ --backbone resnet18 \ --pretrained 1 \ --batch-size 16 \ --epochs 30 \ --lr 3e-4 \ --weight-decay 1e-4 \ --num-classes 5 \ --seed 42用train.txt和val.txt而不是直接遍历文件夹的原因在于文件列表是划分结果的唯一事实来源训练时只需按行读取路径避免因为文件系统排序差异导致每次训练的数据顺序不一致。--seed 42是我做此类任务时的默认值种子固定之后卷积、dropout、数据增强的随机性都可控方便复现。4.4 训练曲线怎么读val loss 不降先查什么训练过程中记录 train loss 和 val loss而不是只记录准确率。两类现象需要格外注意train loss 一直下降但 val loss 在前 5 个 epoch 后就反弹说明过拟合优先加大weight_decay再考虑增强val loss 整体抖动剧烈先看 batch size 是不是太小再看学习率是不是超过 5e-4。只关注 val accuracy 也会被误导。五分类任务盲猜都有 20% 保底当类别严重不平衡时模型只要把所有样本都预测为最常见类别准确率也能到 50% 以上。所以验证指标至少要看 macro F1 和混淆矩阵。5. 眼底图像病变分类的验证手段与复现自查训练完成后报告或演示里最需要的东西不是一个高 accuracy 数字而是一套能解释错误、能复现结果的验证流程。模型输出 CSV 或者界面展示之前先自己看一遍错误样本。5.1 用错误样本回看替代「准确率崇拜」把验证集上预测错误的样本按置信度降序导出置信度越高的错误样本越值得看因为模型「很有把握地错了」这种样本通常是标注噪声或类别边界本来就模糊的图。def dump_wrong_predictions(model, loader, out_csv): model.eval() rows [] with torch.no_grad(): for imgs, labels, paths in loader: logits model(imgs) probs torch.softmax(logits, dim1) preds probs.argmax(dim1) for path, label, pred, prob in zip( paths, labels, preds, probs.max(dim1).values ): if pred ! label: rows.append((path, label.item(), pred.item(), prob.item())) pd.DataFrame(rows, columns[path, true, pred, prob]).to_csv(out_csv, indexFalse)这里的dump_wrong_predictions假设loader返回三元组(img, label, path)实际使用时需要自定义 Dataset 的__getitem__返回值。导出后按prob降序排列重点看每类最容易和哪一类混淆。比如 1 期 DR 和 2 期 DR 互混其实是标注精细度问题如果渗出斑和出血点互相混淆就要检查图像预处理阶段 ColoJitter 是否把颜色差异拉没了。5.2 复现顺序种子、数据划分、依赖版本拿到别人的结果跑不出来或者自己隔天跑不出同样指标九成出在这三个环节数据划分文件有没有变化、随机种子有没有固定、依赖版本有没有锁住。先看data/train.txt是否带上了患者级分组再看数据增强管线里的随机性是否被种子约束两者都正常时再对比 torch、torchvision、opencv 的版本差异。环境搭建说明里通常不会提到的一点是random.seed、np.random.seed、torch.manual_seed要在同一个函数里一起设置并且DataLoader的shuffle行为还受worker进程随机状态影响所以除主进程种子外必要时给每个worker单独设种子。把这些都对齐之后指标差异才可能是模型本身的问题。本文还有配套的精品资源点击获取