PyTorch+ResNet50眼科OCT四分类:迁移学习与可复现训练全流程
2026/9/15 6:13:16 网站建设 项目流程

简介:基于PyTorch和ResNet50实现的眼部疾病图片分类项目,是一项面向计算机相关专业课程设计、期末大作业及项目实战训练的高分资源。项目由导师指导并获较高评价,围绕医疗图像分类的完整流程,细致覆盖了数据预处理、训练/验证集划分、ResNet50模型搭建、分类训练与评估、分类推理等关键环节,每个环节均有对应的Python脚本,并配合Markdown文档说明实现原理与使用方式。开发者在编写时充分考虑了项目可迁移性,代码模块划分清晰,便于在此基础上替换数据集或修改模型结构,同时可作为数字图像处理、深度学习课程设计的完整参考方案。压缩包共10个文件,以7个.py代码脚本为主体,配以2个Markdown说明文档和配置文件,整体仅22KB,体量紧凑、便于快速下载与部署。目前已有291人学习使用,源码经过本地编译与严格调试,下载后即可直接运行,能够有效帮助使用者理解ResNet50在眼部图像分类中的实际落地流程,并从数据处理、模型定义、训练评估与可视化呈现中快速获得可复现的完整项目经验。

1. 这个项目真正要交付的不是 model.pth,而是可复现的四分类流程

期末周最常见的场景之一:压缩包里躺着 train.py、test.py、best_model.pth,外加一份 Word 文档,文件名末尾大概率带着“(高分大作业)”。标题里这串名词,实际拆开是四件事:PyTorch 负责搭建与训练、ResNet50 提供卷积骨架、图片分类落在数据组织上、文档说明则回答“为什么你的结果站得住”。真正被低估的反而是后半句——四分类眼底筛查模型收敛到 90% 并不难,难的是验证集和训练集没有被混用、类别不均衡有显式处理、老师或面试官照着 README 重跑一遍不报错。这篇按“一次可复现交付”的顺序讲:先搭预训练 ResNet50 的骨架,再把眼科图像目录整理成标准数据集,最后落到超参数、断点保存与可视化验证。示例统一用 OCT 四分类(Normal、CNV、DME、Drusen),换成眼底彩照只需替换目录结构。

2. ResNet50 预训练权重的边界:残差结构、迁移范围与分层冻结

2.1 残差结构对病灶纹理的价值

ResNet50 的核心不是“深”,而是引入恒等映射。普通网络叠加到五六十层,训练误差反而上升,论文里称为退化问题。残差块让某一层能够学习到近似恒等变换,深层网络至少不劣于浅层。这个特性放到眼科影像上有现实意义:OCT 切片里的病灶往往是一小片高亮或暗区,深层网络要抓住它,依赖的是浅层定位与中层轮廓的组合。捷径连接把浅层细节直接传给深层,梯度在反向传播时也沿这条路少过几个卷积,减轻了梯度消失。

对课设来说,ResNet50 不是用来创造 ImageNet 榜单的记录,而是用 25.6M 参数里的已学特征,在几百到几千张医学图像上快速收敛。它的瓶颈结构是 1×1 降维、3×3 卷积、1×1 升维的三段式,相比 ResNet34 的普通卷积块,同样参数下计算量更低。实际训练时,这直接表现为 batch size 可以开得稍大,或同等显存下能跑更高分辨率输入。

2.2 预训练权重能迁到眼科图像吗

torchvision 里以枚举形式提供在 ImageNet 上预训练好的权重,代码里用models.ResNet50_Weights.DEFAULT就能拿到,首次运行会自动下载。第 1 章到第 3 章阶段,很多人会卡在环境搭建上:用 anaconda 配置 pytorch 环境时,CPU 机器装了 CUDA 版或反之是最常见的翻车位。下载权重慢时,把 pip 和权重缓存指向国内镜像即可,权重文件本身不大,瓶颈往往在 PyTorch 安装包。

迁移学习的边界值得说清楚:ImageNet 的 1000 类是猫狗、汽车、日常物体,OCT 图像是灰阶断层扫描,两者类别完全不重合。能迁移的是网络前几层的边缘、角点、低频纹理滤波器,这些与医学图像的底层信号一致;不能迁移的是最后一层全连接对“猫、狗”的分类语义,以及自然图像的色彩统计。这也是为什么最终要替换掉 fc 层,而不是直接拿来用。

2.3 把 1000 类输出换成四分类,并冻结不需要的部分

import torch import torch.nn as nn from torchvision import models # 加载预训练权重,ResNet50_Weights.DEFAULT 对应 ImageNet 训练好的权重 model = models.resnet50(weights=models.ResNet50_Weights.DEFAULT) num_classes = 4 # 原 fc 层是 2048 -> 1000,这里替换成 2048 -> Dropout -> 4 in_features = model.fc.in_features model.fc = nn.Sequential( nn.Dropout(p=0.3), # 随机丢 30% 神经元,降低尾部过拟合 nn.Linear(in_features, num_classes) ) # 冻结 layer4 之前的参数,只训练高层语义和分类头 for name, param in model.named_parameters(): if name.startswith("layer4") or name.startswith("fc."): param.requires_grad = True else: param.requires_grad = False # 给不同层设置不同学习率 optimizer = torch.optim.AdamW([ {"params": model.layer4.parameters(), "lr": 1e-4}, {"params": model.fc.parameters(), "lr": 1e-3}, ], weight_decay=5e-4)

代码里第一个要点是requires_grad的冻结粒度。layer4是网络第 4 个 stage 的卷积组,包含 3 个 bottleneck,负责最高层的语义抽象;对眼科图像来说,完全冻结会让模型只能做特征组合,解冻layer4则允许高层滤波器做出小幅调整,通常能把验证集 F1 再推高一个点。第二个要点是优化器给了两组参数,fc 层是随机初始化的,学习率给到 1e-3 才学得动;layer4有预训练基础,只允许在 1e-4 附近微调。这个分层策略比所有参数统一 lr 更稳,也是论文里“微调”的常见做法。Dropout 放在全连接之前,对数据量只有几千张的眼科任务很有必要。

Stage模块组成输出尺寸说明
conv17×7, stride 2112×112初始下采样
layer13 个 bottleneck56×56不改变通道数,做拼接
layer24 个 bottleneck28×28第一个块 stride 2 下采样
layer36 个 bottleneck14×14感受野继续扩大
layer43 个 bottleneck7×7最后的卷积特征图
avgpoolAdaptiveAvgPool2d(1)1×1把空间维度压成 2048 维
fcLinear(2048, 4)4分类输出

表里值得关注的是 layer4 之后的空间分辨率只有 7×7。对 224×224 的输入,每个格子对应原图约 32×32 像素的区域,小的病灶在特征图里可能只占一个点。所以医学分类任务里,RandomResizedCrop的缩放范围不能像自然图像那样设到 0.08,我一般限制在 0.8 到 1.0,避免把病灶裁得太碎。

3. 用 PyTorch 把眼科图像目录烧成 DataLoader:增强规则与随机种子

3.1 目录规范直接决定标签顺序

torchvision.datasets.ImageFolder要求数据按类归档,目录结构如下:

data/eye_oct/ train/ NORMAL/ 001.jpg ... CNV/ 002.jpg ... DME/ 003.jpg ... DRUSEN/ 004.jpg ... val/ ... test/ ...

class_to_idx会按文件夹名字母序生成,也就是 CNV=0、DME=1、DRUSEN=2、NORMAL=3。很多代码在输出报告时把标签序号写错,原因就是默认了 NORMAL 是 0。构建 DataLoader 时需要把这份映射打印出来,和训练报告的类别顺序对照一遍。类目文件夹可读性优先,不要只建 0、1、2、3 目录,否则后面画混淆矩阵时你要额外去猜哪个编号是什么。

from torchvision import datasets, transforms from torch.utils.data import DataLoader, Subset from sklearn.model_selection import train_test_split data_dir = "./data/eye_oct" # 灰度 OCT 先转 RGB,否则预训练权重的三通道输入会报错 to_rgb = transforms.Lambda(lambda x: x.convert("RGB")) train_tf = transforms.Compose([ to_rgb, transforms.Resize((224, 224)), transforms.RandomRotation(10), transforms.RandomResizedCrop(224, scale=(0.8, 1.0)), transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ to_rgb, transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) # 先按 train transform 读一遍,用于划分数据集 full_set = datasets.ImageFolder(data_dir, transform=train_tf) idx_train, idx_val = train_test_split( range(len(full_set)), test_size=0.2, stratify=full_set.targets, # 按类别比例划分,避免某一类全落在验证集 random_state=42 ) # 验证集用 val_tf,注意要重新构造 ImageFolder 再取下标 val_base = datasets.ImageFolder(data_dir, transform=val_tf) train_ds = Subset(full_set, idx_train) val_ds = Subset(val_base, idx_val) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4)

这段代码有一个隐蔽的坑:full_setval_base是两个独立的 ImageFolder 实例,但目录相同,所以class_to_idx顺序一致。划分时只用下标去Subset,不会弄乱标签。stratify=full_set.targets保证四类在训练/验证集里的占比一致,眼科数据普遍存在正常样本多、病变样本少的不均衡,这一步比随机划分稳妥得多。num_workers在 Windows 上建议设为 0,Linux 上可以开到 CPU 核数的一半,Windows 下多进程 dataloader 偶尔会抛 BrokenPipe。

3.2 哪些增强操作不能在眼底图上用

眼科图像和自然图像的增强策略差别很大。自然分类里常见的垂直翻转,在眼底彩照上会把黄斑和视盘的位置上下颠倒,相当于观测视角根本不成立;OCT 切片虽然对翻转容忍度高一些,但一般也不做。水平翻转在眼底彩照里可以加,OCT 任务里建议只保留小幅旋转和缩放。

ColorJitter对灰度 OCT 图来说,hue 参数没有意义但会引入额外计算,只调 brightness 和 contrast 就好。RandomResizedCrop的 scale 下限设为 0.8 的原因前面已经说过,病灶区域太小,过度裁剪会让模型学到“图像边缘纹理”而不是病灶本身。训练集只有几百张图时,把这几种增强叠加使用,通常能把验证 F1 从 0.86 拉到 0.9 上下,而代价只是每轮训练多花十几秒。

3.3 固定随机种子,让“再跑一次”成立

课程设计里最影响评分的一件事是:同一个脚本在别人机器上跑出不同结果。数据划分、增强参数、Dropout 都带随机性,不固定种子就等于交出不可复现的结果。在数据加载和训练脚本开头固定三处即可。

import random import numpy as np import 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 # 关闭自动搜索最优算法

torch.backends.cudnn.deterministic = True的作用是让 cuDNN 的卷积实现固定,代价是比 benchmark 模式慢一点,但对 224×224 的输入几乎无感。这行的优先级很高,随机种子写在它前面,否则某些 GPU 上结果仍会漂移。实际工程里我会把 seed 做成命令行参数,默认 42,这样每次实验都在文件里留痕,而不是等到答辩才发现模型权重和 README 对不上。

4. 训练脚本里容易被忽视的六个参数:分层 lr、早停与断点保存

4.1 训练超参数表

参数推荐取值作用与踩坑说明
batch_size32(GPU)/ 16(CPU)眼科图像分辨率不高,32 不会撑爆 8GB 显存;CPU 上 16 更稳
epoch30配合早停,20 轮内基本收敛,30 轮是安全上限
optimizerAdamW比 Adam 多了权重衰减修正,配合 wd=5e-4 效果更稳
lr (fc)1e-3fc 层是随机初始化,需要较大学习率
lr (layer4)1e-4预训练层只微调,学习率超过 1e-3 会把特征改坏
weight_decay5e-4抑制过拟合,医学图像数据量小,这个参数比 dropout 还重要
schedulerCosineAnnealingLR余弦退火,T_max=30,eta_min=1e-6,比 step decay 省心
patience12验证集 F1 连续 12 轮不涨就停,防止过拟合

表格里最容易被忽略的是weight_decay。很多人会把默认值直接留空,但眼科图像样本量通常只有几千张,又叠加了数据增强,网络在 epoch 15 后很容易进入过拟合区间。5e-4 的权重衰减比 Dropout 更平稳地压制了大权重,两个一起用,验证集曲线会更平滑。另一个常被忽略的是eta_min,余弦退火如果最后阶段不降到接近 1e-6,微调层会一直在局部波动,无法落在更优的谷底。

4.2 训练主循环与早停

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for x, y in loader: x, y = x.to(device), y.to(device) optimizer.zero_grad() out = model(x) loss = criterion(out, y) loss.backward() optimizer.step() total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += y.size(0) return total_loss / total, correct / total def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for x, y in loader: x, y = x.to(device), y.to(device) out = model(x) loss = criterion(out, y) total_loss += loss.item() * x.size(0) correct += (out.argmax(1) == y).sum().item() total += y.size(0) return total_loss / total, correct / total

训练循环本身不复杂,但有几个细节会直接影响收敛质量。optimizer.zero_grad()必须在每个 batch 开始前调用,漏掉会导致梯度累加,曲线表现为 loss 震荡。model.eval()with torch.no_grad()在验证阶段必须成对出现,前者关闭 Dropout,后者关掉梯度计算和反向传播图,内存占用降低不少。验证集 loss 在训练循环里不要参与反向传播,这是新手最容易踩的线。

4.3 保存 checkpoint 的准确姿势

best_f1 = 0.0 patience, trigger = 12, 0 for epoch in range(1, 31): train_loss, train_acc = train_one_epoch(...) val_loss, val_acc = evaluate(...) f1 = compute_f1(model, val_loader, device) # 对四分类计算 macro F1 scheduler.step() # 余弦退火每个 epoch 更新一次 if f1 > best_f1: best_f1 = f1 trigger = 0 torch.save({ "epoch": epoch, "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "val_acc": val_acc, "val_f1": f1, "class_to_idx": full_set.class_to_idx, "seed": 42, }, "checkpoints/best_f1.pt") else: trigger += 1 if trigger >= patience: print(f"early stop at epoch {epoch}") break

这里有两个值得展开的点。为什么保存依据用 macro F1 而不是 acc?眼科四分类里正常样本通常偏多,假设正常类占 70%,模型全预测正常也能拿 70% 准确率,acc 曲线看起来很漂亮但病变类别全错。macro F1 对四个类别等权平均,任何一个类别被放弃都会让指标掉下来。第二个点是 checkpoint 里必须带class_to_idxseed,这两个字段加在一起,才允许你或别人在半年后重开这个文件时仍能正确解释每个输出。

断点续训与模型导入导出也是一样:加载权重时只看model_state_dict,如果发现 key 对不上,检查是不是 fc 层尺寸不一样导致的。把 Python 文件里的num_classes改成 4 再重新实例化模型,再load_state_dict,不要在加载后再去改model.fc,否则会报 size mismatch。

5. 让评分/复现的人看到依据:Grad-CAM、混淆矩阵与 README 清单

5.1 用 Grad-CAM 把分类依据变成图片证据

训练完的模型没法解释为什么把这张 OCT 判成 CNV,而 Grad-CAM 可以。核心思路是用类别输出对最后一个卷积层的梯度加权,得到热力图;哪里梯度大,哪里就是对预测贡献最大的区域。

import torch import torch.nn.functional as F target_layer = model.layer4[-1].conv3 features, grads = None, None def forward_hook(module, inp, out): global features features = out.detach() def backward_hook(module, grad_in, grad_out): global grads grads = grad_out[0].detach() target_layer.register_forward_hook(forward_hook) target_layer.register_backward_hook(backward_hook) x = preprocess_single_image("sample.jpg") # 归一化后的 1x3x224x224 张量 out = model(x) idx = out.argmax(1) model.zero_grad() out[0, idx].backward() # 只向目标类别反向传播 weights = grads.mean(dim=(2, 3), keepdim=True) cam = (weights * features).sum(dim=1, keepdim=True) cam = torch.relu(cam) # 只保留正向贡献 cam = F.interpolate(cam, size=(224, 224), mode="bilinear", align_corners=False)

权重grads.mean(dim=(2,3))把每个通道的梯度平均成标量,表示每个通道对目标类别的敏感度;weights * features则按敏感度对特征图加权。注意backward时传入的是out[0, idx]而不整个out,这样才能拿到“选中类别”的激活。把热力图和原图叠加保存,比任何一段文字都更能回答“你凭什么说这张是CNV”,同时这也是医学模型辅助筛查时判断模型关注区域是否落在病灶上的直接证据。

5.2 混淆矩阵与阈值偏移

只用准确率的报告在眼部疾病分类这类医学场景里是不合格的。四分类混淆矩阵 4×4,一眼能看出哪两类容易混淆,比如 DME 和 Drusen 在 OCT 上都是局灶性高反射,经常互判。生成矩阵用sklearn.metrics.confusion_matrix再画成图,输出到results/confusion_matrix.png,比表格更有说服力。

阈值偏移是课设里少有人做但很加分的操作:四分类 softmax 输出里,正常类概率大于 0.7 才判为正常,否则标记为“待人工复核”。这样做会让总准确率略降,但能把病变漏报率压到很低,也更贴近临床辅助筛查的真实需求。文档里写清这两行,评分观感会明显不一样。

5.3 README 唯一要兑现的承诺:再跑一次

文件命名和文档结构按“别人不读你代码也能跑”的标准写,核心是 5 项:环境安装命令、目录预期结构、训练启动命令、预期输出文件、结果图存放位置。不要讲太多原理,重点写清楚“我做完了哪些事”,以及复现的人需要在这个项目里放进什么结构的原始数据。最后将class_to_idx、最终 F1、混合精度开关和随机种子一并写进结果 JSON,让整个交付物像一条可追溯的数据流,而不是一团散落的 py 文件和 pth 文件。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询