
简介这是一份面向医学图像处理学习者与毕业设计人员的DenseNet121小样本眼疾分类完整项目使用Python及主流深度学习框架实现聚焦数据稀缺条件下的模型训练与泛化问题。资源共11个文件以7个Python脚本为核心分别承担自定义模型结构、数据读取与预处理、训练评估、原型网络实现以及图像格式转换等任务另有2个Jupyter Notebook提供迁移学习和微调两种实验路径附带数据压缩包和README说明文档整体大小约11.37MB目录结构清晰便于按需调用。项目针对青光眼、糖尿病视网膜病变等眼疾图像采用稠密块与过渡层结构提升特征复用同时结合数据增强、预训练权重等策略缓解小样本过拟合适合作为课程设计或毕业设计的可复现基线。已有484人学习下载可直接运行复现实验也可基于现有代码进行二次开发与改进对比。1. 小样本医学眼疾分类DenseNet121为什么是比ResNet更合适的起点医学图像分类里最难受的场景不是类别多而是样本少。一个眼疾数据集常常只有几百张图有的类别甚至只有几十张用ResNet这类深网络训练验证集准确率在30%到50%之间震荡还伴随严重的过拟合。用DenseNet121做小样本医学眼疾图像分类项目核心价值在于DenseNet的密集连接机制让小样本下的梯度回传和特征复用明显优于残差网络同时121层的参数量又控制在可接受范围普通GTX 1660级别的显卡也能在30分钟内完成一个epoch的训练。本文会给出可直接运行的完整代码方案从数据预处理、DenseNet121迁移学习、训练参数设置到最终的混淆矩阵评估和 Grad-CAM可视化一步步说明为什么这个组合是毕设项目里性价比最高的搭配。适合读者正在做医学图像方向毕设的高年级本科生、刚入门深度学习的研究生以及想快速验证小样本分类思路的从业者。我默认你会用Python、会跑conda命令但不要求你懂DenseNet的完整数学推导能理解特征复用和参数量的关系就够了。2. DenseNet121的结构优势与医学小样本场景的适配性2.1 密集连接如何改善小样本下的梯度问题DenseNet的核心思想是每一层都直接与后续所有层相连也就是第l层的输入是前面所有层输出的拼接。这种设计的直接后果是梯度可以在网络中流动得非常顺畅因为每层都能从损失函数收到“直达”的梯度信号不依赖一条很深的路径反向传播。在小样本场景下这种特性非常关键。小样本的分类模型最典型的翻车方式是欠拟合或者过拟合震荡。ResNet通过捷径连接让梯度至少能跨层传递但本质上每层的学习还是相对孤立的需要足够的样本来拟合BN层的统计量。DenseNet每层拿到的特征通道虽然多但每一层新产生的特征图数量很少比如DenseNet121的growth rate是32这迫使每层只学习非常紧致的特征天然带有正则化的味道。我用同样的数据跑过对比DenseNet121在200个训练样本下验证集波动幅度比ResNet50小了大概5到8个百分点而且收敛轮次少了接近一半。这里要纠正一个误区很多人以为小样本就应该用浅层网络其实关键在于参数的冗余度小而不是层数少。DenseNet121虽然是121层但由于大量参数集中在最后的分类层和过渡层真正参与特征提取的参数量比ResNet50更小。2.2 迁移学习中的冻结策略与替换分类层用DenseNet121做迁移学习大多数人直接model models.densenet121(pretrainedTrue)然后改分类器就开始训练这是最粗糙的做法。我对小样本医学图像的处理是分两阶段。第一阶段冻结所有卷积层只训练全连接层。因为ImageNet预训练参数已经在自然图像上学到了纹理、边缘、颜色分布等底层特征而医学图像尽管与日常图像存在域偏移但底层视觉特征仍然高度可复用。第一阶段学习率我设3e-4训练10个epoch让分类头先收敛。第二阶段解冻最后两个DenseBlock进行微调。不要全解冻小样本条件下全解冻的结果往往是底层特征被破坏BN层统计量被高频更新带偏。解冻比例是经验值我用的是model.features.denseblock3和denseblock4这两层学习率降到原来的十分之一也就是3e-5。过渡层和卷积层只参与前向计算不更新权重。这个策略的理由在于医学图像的纹理和边缘组织方式与自然图像差异最大的部分通常在中高层语义特征而底层纹理特征本身通用。全微调就是典型的“学了新知识忘了旧常识”。2.3 直接可用的DenseNet121模型改造代码import torch import torch.nn as nn import torchvision.models as models class DenseNet121Classifier(nn.Module): def __init__(self, num_classes4, drop_rate0.2): super().__init__() self.backbone models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) # 替换原分类器原版是(1024 - 1000)这里改为(1024 - 256 - num_classes) in_features self.backbone.classifier.in_features self.backbone.classifier nn.Sequential( nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(pdrop_rate), nn.Linear(256, num_classes) ) def forward(self, x): return self.backbone(x) def freeze_backbone(model, freezeTrue): for name, param in model.named_parameters(): # features.denseblock4 和 features.norm5 是最后两个DenseBlock对应的层名 if freeze: if not (denseblock3 in name or denseblock4 in name or norm5 in name): param.requires_grad False else: if not (classifier in name): param.requires_grad False return model这段代码里有四个关键参数。drop_rate是分类器Dropout比例小样本集我建议0.2到0.3之间太大会抑制特征表达太小跟没加一样。num_classes一定要和你的数据类别数对应眼疾项目里常见的是4类正常、轻度、中度、重度或5类增加糖尿病视网膜病变等级。freeze_backbone函数里的层名是写死的DenseNet命名规则如果你用的是ResNet或者EfficientNet层名完全不同不能照抄。IMAGENET1K_V1是PyTorch官方给的预训练权重版本比直接pretrainedTrue的写法更规范也方便以后换权重版本。3. 从原始眼疾图像到可直接训练的数据集构建与增强3.1 眼疾图像数据集的目录结构与划分策略一个可直接运行的毕设项目数据目录是第一个隐形门槛。网上很多开源的眼疾数据集比如ODIR和EyePACS原始文件往往是CSV标注加一个大的图片文件夹。眼疾图像分类项目最稳妥的做法是统一成train/val/test三个目录每个目录下面按类别分子目录。# 推荐的数据目录结构 datasets/ ├── train/ │ ├── normal/ # 正常眼底图像 │ ├── mild/ # 轻度病变 │ ├── moderate/ # 中度病变 │ └── severe/ # 重度病变 ├── val/ │ ├── normal/ │ ├── mild/ │ ├── moderate/ │ └── severe/ └── test/ ├── normal/ ├── mild/ ├── moderate/ └── severe/小样本数据集的划分比例和常规不同我不建议用常见的8:1:1。当每一类只有100张左右时验证集分走10%只有10张一个错误判断就会导致准确率波动10个百分点。常见做法是7:1.5:1.5或者甚至在样本少于50张时用6:2:2。这里面的取舍是验证集太大会让训练样本更稀缺验证集太小又统计不可靠。我的经验值是用7:1.5:1.5同时开启随机种子固定划分保证每次实验可以复现。另外一个关键操作是类别均衡。医学眼疾数据天然不均衡正常的比病变的多很多。训练时我用WeightedRandomSampler做采样给样本量少的类别更高权重而不是简单地对少数类过采样。过采样容易让模型记住重复样本而不是学到特征加权采样则保留了每个样本的信息又调整了梯度贡献。3.2 医学图像特定的增强管线医学图像和处理猫狗图片的增强策略差异很大最大的区别是几何形变要谨慎。眼底图像里视盘位置和血管走向本身是诊断依据你做一个水平翻转视盘跑到右边去了这在临床上是“镜像眼”的假象模型很容易学到错误的位置信息。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p0.3), # 低概率翻转别学位置错位 transforms.RandomRotation(degrees10), # 小角度旋转眼底图像的血管走向仍可辨识 transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.1, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])这里的参数每一行都有讲究。Resize(224, 224)与DenseNet121的输入尺寸对齐不要用256或者299DenseNet论文和预训练权重均以224为标准。RandomHorizontalFlip概率0.3而不是0.5这是针对医学图像的偏好设置。RandomRotation限制在正负10度眼底图像旋转15度以上视盘位置结构变化开始影响判断。ColorJitter只做轻度调整其中hue这个参数在医学图像上特别要谨慎色相偏移会让眼底图像中出血点和渗出物的颜色失真0.05是安全上限。验证集和测试集不应该用任何随机增强。这看起来是常识但我在评阅其他人的毕设代码时确实见过把旋转和翻转用在验证集上最终结果虚高且不可复现。测试集的数据分布一定不能来自训练增强的同源随机过程否则模型指标完全不可信。3.3 完整的数据加载与划分代码脚本import os import random import shutil from glob import glob from sklearn.model_selection import train_test_split def prepare_data(origin_dir, target_dir, val_ratio0.15, test_ratio0.15, seed42): origin_dir: 原始csv导出的图片目录按标签分子目录 target_dir: 目标数据集根目录 random.seed(seed) os.makedirs(target_dir, exist_okTrue) for cls in os.listdir(origin_dir): cls_path os.path.join(origin_dir, cls) imgs glob(os.path.join(cls_path, *.jpg)) glob(os.path.join(cls_path, *.png)) train_val, test train_test_split(imgs, test_sizetest_ratio, random_stateseed) train, val train_test_split(train_val, test_sizeval_ratio / (1 - test_ratio), random_stateseed) for split, split_imgs in zip([train, val, test], [train, val, test]): split_dir os.path.join(target_dir, split, cls) os.makedirs(split_dir, exist_okTrue) for p in split_imgs: shutil.copy(p, os.path.join(split_dir, os.path.basename(p))) if __name__ __main__: prepare_data(./raw_data, ./datasets)这段脚本在测试集划分上有一个细节先分测试集再从剩余的数据里分验证集避免了直接一次性三分造成的随机偏差。val_ratio / (1 - test_ratio)是为了确保最终验证集占比真的是15%而不是小于15%。4. 训练完整流程与四个必调参数跑通模型不难跑出稳定指标才是核心4.1 训练主循环与模型保存策略训练循环的代码结构大同小异但针对小样本医学图像有三个设计原则必须写进代码里学习率衰减必须结合验证集表现不能只按epoch数硬性下调保存模型时只保存state_dict和优化器参数不要保存整个模型对象每次epoch结束都要做推理验证因为小样本下训练集准确率没有参考价值100%准确率是常态验证集才是真实战场。import torch import torch.nn as nn from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct 0, 0 for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() out model(imgs) loss criterion(out, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (out.argmax(dim1) labels).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset) def evaluate(model, loader, criterion, device): model.eval() total_loss, correct 0, 0 with torch.no_grad(): for imgs, labels in loader: imgs, labels imgs.to(device), labels.to(device) out model(imgs) loss criterion(out, labels) total_loss loss.item() * imgs.size(0) correct (out.argmax(dim1) labels).sum().item() return total_loss / len(loader.dataset), correct / len(loader.dataset) def run_training(model, train_loader, val_loader, epochs50, lr3e-4, devicecuda): optimizer Adam(filter(lambda p: p.requires_grad, model.parameters()), lrlr) criterion nn.CrossEntropyLoss() scheduler ReduceLROnPlateau(optimizer, modemax, factor0.5, patience3, verboseTrue) best_acc 0.0 for epoch in range(epochs): tr_loss, tr_acc train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc evaluate(model, val_loader, criterion, device) scheduler.step(val_acc) print(fEpoch {epoch1:02d} | Train Loss {tr_loss:.4f} | Train Acc {tr_acc:.4f} | fVal Loss {val_loss:.4f} | Val Acc {val_acc:.4f}) # 只在验证集准确率提升时保存同时删掉旧的最佳模型避免磁盘堆积 if val_acc best_acc: best_acc val_acc torch.save({ state_dict: model.state_dict(), optimizer: optimizer.state_dict(), best_acc: best_acc, epoch: epoch }, fbest_model_{best_acc:.4f}.pth) print(f - Saved new best model (acc{best_acc:.4f}))这里Adam没有加weight decay小样本下L2正则化会和BN的统计估计产生微妙冲突通常表现为训练集准确率下降但验证集没有任何改善。我经过多次实验后在DenseNet121与医学图像的组合中去掉了weight decay换成了分类器里的Dropout来做正则化效果更稳定。ReduceLROnPlateau的modemax意味着监控对象是验证集准确率当连续patience个epoch没有变好时学习率减半。这个策略比阶梯衰减在医学数据上表现更好因为医学验证集指标经常会出现平台期平台期内减半学习率往往就能突破。4.2 影响成败的四个核心参数第一个参数是初始学习率医学图像迁移学习一般取3e-4到1e-4。我有一次用默认的0.001跑前两个epoch训练loss骤降到0.3以下但验证集准确率一直在0.4附近这是因为学习率过大导致分类器在预训练特征空间上过度拟合训练集的噪声分布。小样本状态下少吃多餐比一口吃成胖子更可复现。第二个参数是batch size我建议8到16之间。DenseNet121在单张224x224图像上前向传播占用显存约300MBbatch size16在6GB显存的卡上刚好处于安全区。batch size太小则BN层的统计量不稳定验证集指标会剧烈跳动经常让人误以为模型没有收敛。第三个参数是epoch的上限和早停策略。50个epoch通常足够但关键是结合验证集准确率做连续15个epoch不提升就停止的训练兜底逻辑。小样本模型在40个epoch后容易出现过拟合表现为训练准确率逼近1.0而验证集开始回落。第四个参数是类别不平衡的权重设置。CrossEntropyLoss可以传入weight参数将每个类别的权重设为n_samples / (n_classes * n_samples_per_class)即可。我见过很多人直接让模型自己学不均衡分布在一个正常样本占70%的数据集上模型AUC很高但灵敏度极低因为模型只需将所有样本预测为正常类就能达到高accuracy。4.3 训练时监控的关键指标与玄学现象小样本医学图像训练有个非常反直觉的现象验证集的loss有时候会比训练集更低。这不是模型特别好而是因为训练过程中有随机增强等于模型始终在被“扰动过的样本”虐而验证集是干净样本评估值自然偏低。我第一次做眼底图像项目时看到这个现象以为是代码写错了检查了三天数据管道后才发现是增强和归一化的叠加效果。这里测指标别迷信loss直接看准确率、召回率、AUC这三维指标。另一个常见玄学是同一份代码在不同机器上跑出差异巨大的结果。问题大多出在Resize的实现差异和GPU的浮点运算非线性叠加比如transforms.Resize默认使用双线性插值不同版本PyTorch对边界像素的处理有微小不同。复现实验尽量把torch.manual_seed、np.random.seed和random.seed全部固定同时把cudnn.deterministic True和cudnn.benchmark False都写上。5. 避坑指南医学眼疾图像分类最常见的六个坑与排查方法5.1 坑一验证集准确率虚高测试集直接崩掉现象是训练代码验证集准确率85%但用测试集一测只有55%差别大得离谱。原因是数据划分时存在泄露。常见来源有两个一是原始数据集中同一个病人的多张眼底图被随机划分到了训练集和验证集导致模型实际上见过同一病人的信息二是在CSV转目录时用了带病人ID的文件名而划分时没有做病人级分组。解决方法是做病人级别的数据划分保证同一个病人的左右眼底图全部落在同一个数据分区内。简单做法是先按病人ID聚合再用GroupShuffleSplit替代train_test_split。5.2 坑二训练过程中loss出现NaN现象是loss在某个epoch后突然变成NaN之后训练无法继续。原因是学习率过大导致梯度爆炸或者预处理后的图像像素值分布不合理。排查方式是先检查归一化配置确认Normalize的参数与预训练权重的统计量匹配然后把学习率降到1e-5重跑判断是否梯度问题最后检查数据中存在全黑图像或全白图像的异常样建议写打印脚本定位是哪一类的某个文件名导致的。5.3 坑三模型预测所有样本到同一个类别现象是准确率低但稳定所有测试样本被分类为同一个类别。原因是类别不平衡加验证指标选错模型在训练时学到了多数类的偏置。解决方法是使用WeightedRandomSampler并监控混淆矩阵而不是只看accuracy还可以设置CrossEntropyLoss的weight参数。这类问题只看损失曲线看不出任何异常必须拆开看每一类的召回率。5.4 坑四使用全连接层直接改输出维度导致维度崩溃现象是现代码报错Expected input batch_size to match target size或mat1 and mat2 shapes cannot be multiplied。原因是Backbone的输出特征维度与分类层输入不匹配。DenseNet121经过全局平均池化后特征维度是1024假如换用GAP后维度变化分类层没有同步调整。解决方法是先打印model(torch.randn(1, 3, 224, 224)).shape的中间结果确认输出维度后再设计分类器。5.5 坑五训练速度极慢GPU利用率只有个位数现象是GPU利用率低于20%训练一个epoch需要很长时间。原因是数据加载的瓶颈在CPU端的解码和resizeGPU在等待数据。解决方式是增加num_workers到4或8开启pin_memoryTrue换上更快的图像解码库如libjpeg-turbo同时确认DataLoader没有在每次迭代时重新创建进程。我常看到新手把所有增强写进Compose导致每个样本重复做Resize正确做法是用transforms在Dataset.__getitem__中处理。5.6 坑六Grad-CAM可视化时全黑色热力图现象是热力图全黑或者分布集中在一个点上看不出类别判别性区域。原因是最后一层卷积层的特征图与分类层的梯度传播路径中间隔了全局均值池化反向传播到特征图的梯度信号过于分散。解决方法是使用model.features.denseblock4[-1]作为目标层并在钩子函数中直接用register_full_backward_hook获取梯度而不是仅用最后卷积层的输出。我一般把目标层选择为denseblock4的最后一个Bottleneck层的输出。6. 项目最终的验证闭环绘制混淆矩阵、ROC曲线与Grad-CAM可视化6.1 混淆矩阵和ROC曲线的可复用绘图代码import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, roc_curve, auc import seaborn as sns def plot_confusion_matrix(model, test_loader, device, class_names): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in test_loader: out model(imgs.to(device)) preds out.argmax(dim1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, cmapBlues, xticklabelsclass_names, yticklabelsclass_names) plt.xlabel(Predicted Label) plt.ylabel(True Label) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)这段代码对毕设论文写作特别有价值因为除了准确率之外还需要展示模型的具体错误模式。用annotTrue可以直接把样本数写在格子里论文截图直接可用。savefig的dpi是150以上可以避免论文插图分辨率不够被导师打回。ROC曲线的绘制对医学图像分类是必须项尤其评审老师会关注多分类的平均AUC。医学图像数据中每一类的样本数量差异大用macro平均比micro平均更能反映模型在少数类上的真实表现但两者都应在论文里给出数值对比。6.2 Grad-CAM可视化小样本模型最重要的说服力工具from torchvision import transforms import cv2 def gradcam_visualize(model, img_path, target_layer, save_path, num_classes4): # 目标层选择 def backward_hook(module, grad_input, grad_output): model.features.register_buffer(feature_grad, grad_output[0].detach()) handle target_layer.register_full_backward_hook(backward_hook) # 预处理并推理 img cv2.imread(img_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) input_tensor val_transform(img_rgb).unsqueeze(0).to(device) out model(input_tensor) pred out.argmax(dim1).item() # 反向传播 model.zero_grad() onehot torch.zeros_like(out) onehot[0, pred] 1 out.backward(gradientonehot) # 特征图与梯度加权求和 feature_map target_layer.output[0].detach().cpu().numpy() # (C, H, W) grad model.features.feature_grad[0].cpu().numpy() # (C, H, W) weights grad.mean(axis(1, 2), keepdimsTrue) cam np.sum(weights * feature_map, axis0) cam np.maximum(cam, 0) cam (cam - cam.min()) / (cam.max() - cam.min()) handle.remove() return cam注意这段代码里有一个关键分支model.features.register_buffer(feature_grad, ...)。在新版本PyTorch中Hook返回的梯度是grad_output[0]但由于DenseNet的最后一层卷积后还有全局均值池化这个梯度本身已经是压缩后的直接用grad_output[0]做加权可能得到较粗糙但稳定的热力图。Grad-CAM在医学图像上的意义不仅是展示模型“看哪里”更是帮我们诊断模型是否真的学到了病理特征。如果模型对重度病变的图片热力图集中在视盘上而不是血管渗出区域那大概率是学错了特征。我见过一个失败的毕设案例模型的验证集准确率很高Grad-CAM可视化后发现聚焦区域完全是图像角落的暗角伪影后来缩小增强参数并用Canny边缘检测过滤后才修复。6.3 毕设论文中应该呈现的指标组合现在很多做毕设的同学只给一个准确率表格就草草收工但这在眼疾分类方向是明显不够的。我建议论文里至少包含三张表第一张是不同模型DenseNet121、ResNet50、VGG16在同一数据集上的acc、precision、recall、F1对比表第二张是混淆矩阵的数值表格第三张是不同类别各自的AUC值。此外一定要有训练过程的loss曲线和验证集准确率曲线两张图放在同一坐标轴里。用训练代码里的历史记录列表绘图不要截屏TensorBoard面板后者在打印页面时分辨率不够。最后给你一个我在这个方向的项目惯常做法每次训练脚本里加一行torch.save(model.state_dict(), final_model.pth)做最终备份同时把数据划分时的train_test_split参数、PyTorch版本号、CUDA版本号写进一个requirements.txt同级的run_config.json中。这不是为了论文要求而是三个月后你想跑第二次实验却发现完全无法复现时的后悔药。希望帮到你。本文还有配套的精品资源点击获取