ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

DenseNet121小样本眼疾分类实战:从数据划分到Grad-CAM完整指南

2026/9/28 15:40:23 拓冰建站 浏览量
DenseNet121小样本眼疾分类实战:从数据划分到Grad-CAM完整指南 简介面向医学眼疾图像分类的小样本深度学习项目资源基于DenseNet121构建适合计算机视觉方向学生用于毕业设计或课题实践。资源提供可直接运行的完整工程内含眼疾图像数据集、数据增强与预处理工具、DenseNet121模型定义、原型网络及迁移学习/微调实现覆盖从数据清洗、格式转换到训练评估的完整流程。压缩包共11个文件包含7个Python脚本、2个Jupyter Notebook、1个数据压缩包和1个说明文档整体仅11.37MB轻量易部署。项目中通过稠密块与过渡层实现特征复用并引入数据增强、迁移学习等策略缓解小样本过拟合问题代码模块划分清晰便于二次开发与复现。目前已有484人学习下载可作为理解医学图像分类与少样本学习方法的实用参考。1. DenseNet121 小样本眼疾分类一个能直接跑的毕设方案该具备什么每次看到把 DenseNet121、小样本、医学眼疾图像分类三件事绑在一个标题里的需求我的第一反应不是“模型选得对不对”而是“数据怎么按患者切、预训练权重怎么烧、第几个 epoch 开始炸”。基于 DenseNet121 的小样本医学眼疾图像分类核心难点不在网络本身——DenseNet121 的密集连接对梯度回传和少样本训练天然友好——而在数据划分、微调节奏和验证方法这三件容易被一句话带过的工程细节。这篇文章写给两类人拿这个题目做毕业设计、需要一个可直接运行工程的学生以及想在公开眼底 OCT 数据上快速验证小样本分类效果的工程师。下面按“选型、数据、训练、避坑、可视化验证”把整条链路拆开每一步给出能抄走的代码和参数。2. 为什么是小样本配 DenseNet121从 ResNet 到高斯过程回归的选型推演2.1 DenseNet121 的四个结构性优势梯度短路径、参数效率与特征复用小样本在医学图像场景里是常态。一家医院能拿到的带标注眼底图像能到一两千张已经算富裕放到 ImageNet 那种百万量级下微不足道。小样本的关键矛盾不是“数据少所以训练慢”而是“先验不足导致假设空间太大”——模型有上千万参数可用来约束这些参数的有效样本只有几百张任何一个结构缺陷都会被放大成过拟合。DenseNet121 的核心机制是每一层输入来自前面所有层输出的拼接第 l 层的输出可以写成 x_l H_l([x_0, x_1, ..., x_{l-1}])其中 H_l 是瓶颈结构 BN-ReLU-Conv(1x1)-BN-ReLU-Conv(3x3)增长率 growth rate 固定为 32也就是每层新增 32 个特征图。121 这个数字意味着 1 个初始卷积层、4 组 DenseBlock分别含 6、12、24、16 个瓶颈层中间穿插 Transition 层做通道压缩和空间降采样。这种结构在四个维度上贴合小样本场景。第一密集连接让每一层都能直接拿到前面所有层的梯度误差信号从分类头回传到第一个卷积层的路径极短小样本下网络不容易陷入“浅层学不动”的状态这比同量级 ResNet 更容错。ResNet 的残差是加法合并H_l(x) x这意味着新特征必须通过残差映射叠加在旧特征之上DenseNet 是拼接特征通道直接保留不破坏浅层信息。第二参数效率高DenseNet121 的参数量大约 8MResNet50 大约 25M参数少意味着同样数据下的模型容量压力更小过拟合风险更低。第三特征复用机制对医学图像特别对口病灶边缘、血管纹理这类低级特征在浅层出现后后续层可以通过拼接直接引用而不是重新学习增量。第四瓶颈结构让计算量可控121 这个深度在小样本下不会像 161 或 201 那样出现明显的过参数化训练时间和显存占用也更适合单卡学生党。2.2 眼疾图像的三点特殊性小病灶、类内差异与光照不均眼疾分类和自然图像分类有个本质差异决定类别的信息往往只占图像的很小一部分。糖尿病视网膜病变DR的早期微动脉瘤在眼底照片里可能只有几个到几十个像素黄斑病变的渗出区域也常常小于整个图像的 2%。公开眼底数据集的原图分辨率通常是 1440x1920 甚至更高直接缩放到 224x224 等于把图像面积缩小到原来的约五十分之一几个像素的微动脉瘤直接消失。这种“小病灶决定大类别”的特性直接影响了输入尺寸和数据增强策略。第二点特殊性是类内差异大。同样是 DR 三级非增殖期不同成像设备、不同拍摄角度、不同瞳孔散大程度下拍出来的图颜色分布和血管形态差异很大。这意味着模型不能依赖全局颜色特征做判断而 DenseNet 这种把浅层特征重复拼接到深层的结构比只靠最后一层高级语义的 ResNet 更容易保留局部判别细节。在公开的 ODIR、APTOS 这类数据里同一个患者左眼和右眼的拍摄条件都可能有明显差别这进一步放大了类内差异。第三点是类别严重不均衡。眼疾分级数据集里正常样本往往占了大半病变等级越高的样本越少最严重的增殖期 DR 可能只有几十张。这种分布让小样本问题雪上加霜如果不处理模型会学会“把所有图都判成正常”来骗取低损失。类别权重的计算和选择本文第四章会专门展开。2.3 为什么不选高斯过程回归和 RVM分类任务与回归任务的错位“小样本”这个词容易让人想到另一条技术路线高斯过程回归GPR和稀疏贝叶斯相关向量机RVM。这类核方法在小样本回归预测上确实能打几十个样本就能拟合出像样的映射做小样本仿真数据预测时它们是常见主力模型。但眼疾分类是离散类别任务输出的是类别概率分布而不是连续值。GPR 的核函数比如 RBF对图像的平移、旋转、光照变化没有内在不变性图像像素直接进 GPR 基本不可行RVM 更是需要预先构造特征向量等于把深度学习最擅长的端到端特征学习绕回了手工特征工程。对比项DenseNet121 微调高斯过程回归 GPR相关向量机 RVM输入形式原始图像像素数值特征向量特征向量特征学习端到端自动学习依赖核函数选择依赖手工特征小样本能力有预训练迁移可用适合极少量数值样本适合稀疏特征输出形式类别概率连续值连续值/分类决策与眼疾图像匹配度高低低所以结论很清楚如果你的题目是“基于少量临床数值指标预测病变进展程度”GPR 或 RVM 是合理选项但题目是“图像分类”且手里有几百张以上的标注图微调预训练 CNN 依然是收益最高、工程上最可控的方案。这也是本文后续所有代码围绕 PyTorch DenseNet121 展开的原因。3. 跑通最小闭环目录结构、数据划分与预处理3.1 数据目录结构与类别组织先让脚本能无脑跑起来这类毕设项目最容易劝退的点不是模型训练而是数据格式不统一。你从公开渠道拿到的眼疾数据集大概率是一个 images 文件夹加一份 CSV 标注表而不是像 ImageNet 那样按类别分好的目录。市面上很多“完整代码界面数据集”的分类项目比如鸟类识别系统、目标检测一类的毕设工程代码结构基本都是 data、train、evaluate 三件套。但医学眼疾项目比它们多一道关键工序按患者分组。所以在工程初期就把数据归拢成固定结构是为后续所有脚本打地基。常见做法是在根目录下建 data/把原始图片统一放入 images/标注表 records.csv 至少包含 image_path、patient_id、label 三列。label 是从 0 开始的整数类别眼疾分级任务里 0 通常是 normal1 到 4 是不同病变等级。文件名保留患者 ID 和左右眼信息比如 p001_left.jpg这样即使 CSV 丢了也能从文件名找回分组信息。project/ ├── data/ │ ├── images/ │ │ ├── p001_left.jpg │ │ └── p001_right.jpg │ └── records.csv ├── scripts/ │ ├── split_data.py │ ├── train.py │ └── visualize.py └── README.mdrecords.csv 的格式不需要花哨能保证 pandas 一行读进来就行image_path,patient_id,label data/images/p001_left.jpg,p001,0 data/images/p001_right.jpg,p001,0 data/images/p002_left.jpg,p002,2我一般会在 README 里第一行写清楚 Python 版本3.8 以上、PyTorch 版本和两个核心依赖 torchvision、pandas避免答辩时评委换机器跑不起来。版本不需要追新只要保证 torch 与 torchvision 的 API 兼容即可。数据量不大时不要分多个子目录按类别存放图片一张图同时属于类别和患者两个维度目录树表达不了这种关系CSV 才是唯一可靠的信息源。3.2 按患者划分数据集防止信息泄露的核心代码这里有一个几乎每个做医学图像的人都会踩的坑直接用 sklearn 的 train_test_split 按行随机划分。眼疾数据里同一患者的双眼图像高度相似如果一只眼在训练集、另一只在验证集模型等于变相“见过”这个病人验证集准确率会虚高几个点但一到真实临床数据上立刻缩水。正确做法是按 patient_id 分组划分保证同一患者的全部图像只落在一个划分里。# scripts/split_data.py import pandas as pd from sklearn.model_selection import GroupShuffleSplit df pd.read_csv(data/records.csv) # 先按患者分组切出训练集和临时集验证集 测试集 gss GroupShuffleSplit(n_splits1, test_size0.3, random_state42) train_idx, tmp_idx next(gss.split(df, groupsdf[patient_id])) train_df df.iloc[train_idx] tmp_df df.iloc[tmp_idx] # 再从临时集中按患者切出验证集和测试集 gss2 GroupShuffleSplit(n_splits1, test_size0.5, random_state42) val_idx, test_idx next(gss2.split(tmp_df, groupstmp_df[patient_id])) val_df tmp_df.iloc[val_idx] test_df tmp_df.iloc[test_idx] train_df.to_csv(data/train.csv, indexFalse) val_df.to_csv(data/val.csv, indexFalse) test_df.to_csv(data/test.csv, indexFalse) # 断言三个划分的患者集合无交集 patients { train: set(train_df[patient_id]), val: set(val_df[patient_id]), test: set(test_df[patient_id]), } assert not (patients[train] patients[val]) assert not (patients[train] patients[test])GroupShuffleSplit 是这里的关键 APIgroups 参数指定分组依据切分时同一组的样本不会同时出现在训练集和剩余集。random_state 固定为 42保证每次运行得到相同划分答辩时“结果可复现”这一问不会露怯。test_size 取 0.3 意味着 70% 患者进训练集剩余 30% 再对半给验证集和测试集最终训练、验证、测试的比例大约是 70:15:15。小样本场景下我倾向于把训练集比例提到 70% 到 80%留给验证集和测试集的数据再少也要保证每个类别都出现否则测试指标的置信度太低。分组切完后还要检查类别分布。GroupShuffleSplit 不支持按类别分层所以切完后打印一下三个划分里每个类别的样本数如果训练集某类只有个位数优先考虑在这个类别上做过采样或直接在损失函数里加权重。3.3 预处理与数据增强给小样本的后悔药小样本训练的后悔药就是数据增强。但医学图像增强不能照搬 ImageNet 那套随机裁剪 90% 区域可能正好把病灶裁掉过强的颜色抖动会让血管色调失真。我常用的增强组合是先 Resize 到 256 再 RandomCrop 到 224比直接 Resize 到 224 多了一点随机平移的鲁棒性水平翻转可以接受旋转角度控制在 15 度以内避免眼底图像颠倒这种临床不合理情况亮度和对比度做轻量级抖动CLAHE 对眼底血管和病灶的对比度提升很有效clip_limit2.0 是一个不过分夸张的起点。# scripts/train.py 中的增强部分 import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.Resize(256, 256), A.RandomCrop(224, 224), A.HorizontalFlip(p0.5), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit15, p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.5), A.CLAHE(clip_limit2.0, tile_grid_size(8, 8), p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ]) val_transform A.Compose([ A.Resize(224, 224), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ])验证集和测试集不做任何随机增强只做 Resize 和归一化否则验证指标会掺入增强噪声。这里的 Normalize 统计量沿用 ImageNet 预训练权重的均值方差因为阶段一冻结训练时卷积核的输入分布与预训练分布越接近迁移效果越好。CLAHE 这样的局部对比度操作只对训练集用测试时保持与验证一致避免推理链路上引入额外超参数。增强强度需要控制几百张训练图时ShiftScaleRotate 的 scale_limit 不要超过 0.2旋转不要超过 15 度否则会制造出大量解剖结构不合理的样本模型学到的是扭曲不变性而不是真实的病变判别特征。4. 微调 DenseNet121从冻结到解冻的训练代码与参数详解4.1 加载预训练权重并替换分类头迁移学习的正确打开方式小样本加图像分类基本可以默认一个结论从零训练卷积网络是下策加载 ImageNet 预训练权重再微调是唯一值得考虑的路径。虽然医学图像和自然图像差异很大但预训练权重里学到的边缘、纹理、颜色渐变等底层特征依然有效而且 DenseNet121 的密集连接能让这些底层特征直接传递到分类层附近的特征图迁移收益比单纯用 ResNet 更明显。torchvision 从 0.13 版本之后把预训练权重的加载方式改成了 weights 参数形式旧版的 pretrainedTrue 虽然还能用但会告警。DenseNet121 最后一层的属性名是 classifier不是 fc替换时用 in_features 动态获取维度最保险这个值在 DenseNet121 里是 1024。# scripts/train.py 中的模型构建部分 import torch import torch.nn as nn import torchvision.models as models NUM_CLASSES 5 model models.densenet121(weightsmodels.DenseNet121_Weights.IMAGENET1K_V1) model.classifier nn.Linear(model.classifier.in_features, NUM_CLASSES) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device)如果你的显卡显存不够 8GBbatch_size 就得从 16 降到 8或者把输入尺寸从 224 改到 192。但我不建议一上来就降分辨率眼疾病灶本身就小输入尺寸每降低一档病灶可辨识度就降一档。显存不够优先减小 batch_size 或开启梯度累积不要牺牲输入分辨率。4.2 两阶段训练策略冻结训练与全量微调的节奏控制小样本微调最容易翻车的操作是一上来就全量微调。预训练权重在 ImageNet 上已经收敛直接在医学小数据集上以较大学习率更新全部参数会把积累的底层特征迅速破坏掉表现为训练前几个 epoch 损失异常升高。常见做法是分两阶段第一阶段冻结所有卷积层只训练新换上的分类头第二阶段解冻全部层用小学习率整网微调。# 阶段一冻结特征提取器只训练分类头 for param in model.parameters(): param.requires_grad False for param in model.classifier.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.classifier.parameters(), lr1e-3) # 训练 N 个 epoch 后进入阶段二解冻全部层 for param in model.parameters(): param.requires_grad True optimizer torch.optim.Adam(model.parameters(), lr1e-5)阶段一的 epoch 数取决于数据量。500 张训练图我一般跑 20 个 epoch1000 张以上可以酌情减到 10 到 15 个。判断标准是验证集准确率在连续 5 个 epoch 内不再上升就把权重保存下来进入阶段二。阶段二的学习率必须比阶段一低一到两个量级1e-5 起步最多到 5e-5超过 1e-4 很容易在解冻后头几个 epoch 出现验证损失反弹。整个训练过程里每个 epoch 结束都要在验证集上算一次准确率保存验证指标最优的权重不要保存最后一个 epoch 的权重。best_acc 0 patience 7 bad_epochs 0 for epoch in range(num_epochs): train_one_epoch(model, optimizer, train_loader, criterion, device) val_acc evaluate(model, val_loader, device) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth) bad_epochs 0 else: bad_epochs 1 if bad_epochs patience: break # 早停防止过拟合继续恶化早停的 patience 在小样本场景下取 5 到 7 比较合适。验证集指标本身有噪声patience 设太小容易在正常波动时提前停掉设太大又会让过拟合训练持续太久。保存 best_model.pth 后建议同时保留最后一个 epoch 的 checkpoint两份权重对比着用有时候最后一个 checkpoint 在测试集上的泛化能力更好特别是在验证集比较小、指标噪声大的时候。4.3 损失函数与优化器的参数选择类别权重与学习率的搭配眼疾数据集的类别分布天然不均衡直接用交叉熵会让模型把所有样本预测成占比最多的正常类准确率看着有七成但病变类别的召回率接近零。解决方法很简单从训练集统计出类别频率给少数类更高的损失权重。from sklearn.utils.class_weight import compute_class_weight y_train train_df[label].to_numpy() classes np.unique(y_train) class_weight compute_class_weight(class_weightbalanced, classesclasses, yy_train) class_weight torch.tensor(class_weight, dtypetorch.float).to(device) criterion nn.CrossEntropyLoss(weightclass_weight)compute_class_weight 的 balanced 模式会自动用 n_samples / (n_classes * np.bincount(y)) 给每个类算出权重类别样本越少权重越高不需要手调。注意权重计算只基于训练集不要混入验证集或测试集的分布信息。如果是多标签任务比如同时判断 DR 等级和青光眼损失要改成 BCEWithLogitsLoss加权方式也按标签维度分开算。优化器用 Adam 即可动量参数保持默认。如果训练后期出现明显震荡可以切换到 AdamW 并加 weight_decay1e-4对 DenseNet 这种特征复用强的结构来说权重衰减能有效抑制过拟合。分段学习率方面阶段二我通常配 ReduceLROnPlateau验证损失连续 3 个 epoch 不降时把学习率乘以 0.1训练过程更容易平稳收敛。训练阶段冻结范围学习率建议 epoch 数优化器阶段一除分类头外全部冻结1e-315~20Adam阶段二全部解冻1e-5 ~ 5e-515~30Adam ReduceLROnPlateau这张参数表是经验起点不是最优解。训练集只有两三百张时阶段一的 epoch 数要提到 30 以上给分类头充足的收敛时间训练集超过 2000 张阶段一可以缩短到 10 个 epoch把主要训练时间留给阶段二。小样本场景下不要迷信“训练越久越好”早停机制和最佳权重保存比拉满 epoch 数重要得多。5. DenseNet121 眼疾分类避坑5 个实测翻车点与排查5.1 验证集准确率虚高患者图像泄露现象按行随机切分后验证集准确率轻松冲到 94%觉得模型已经“训练好了”。换到另一批同分布的测试数据上准确率骤降到 62%。原因同一患者左右眼图像高度相似随机切分让同一患者的图像同时出现在训练集和验证集模型记住了患者级别的生物特征而不是病变特征。这在眼疾数据集里几乎必然发生。解决用第三章的 GroupShuffleSplit 按 patient_id 分组切分。改完后验证集准确率通常下降 3 到 8 个点这才是真实水平。如果数据没有 patient_id 字段也要按文件名前缀建一个 group_id。这一步做完后面的指标才有意义否则整个项目都是建立在虚假精度上的空壳。5.2 训练集损失下降但验证集震荡过拟合提前到来现象阶段一训练正常进入阶段二后训练 loss 持续下降验证集准确率像心电图一样来回波动甚至比阶段一结束时还低。答辩时很容易被问“你的模型是不是没练透”。原因小样本加解冻全模型模型容量瞬间超过数据约束过拟合通常发生在解冻后 3 到 5 个 epoch。另外学习率没降够解冻后对预训练权重的扰动太大特征提取器被带偏。解决阶段二学习率降到 1e-5 量级这是最直接的手段。如果仍震荡就把阶段二拆成两段先 lr1e-5 跑 10 个 epoch再 lr1e-6 跑 10 个 epoch。另一个有效办法是加大增强强度比如把 RandomCrop 改成 RandomResizedCrop(scale(0.7, 1.0))给模型多制造一些尺度变化作为正则。最后用验证集上表现最优的 epoch 的权重作为最终权重不要等训练完全跑完。5.3 病变类别召回率趋近于零类别失衡现象训练曲线平滑收敛混淆矩阵里正常类准确率 97%但第 3、4 级病变的召回率是 0。整体准确率 78%表面还凑合分类报告一打出来就露馅。原因损失函数没有对少数类加权。普通交叉熵对所有样本一视同仁模型只要把所有图都判成正常类就能把整体损失压得足够低。解决训练前用 compute_class_weight 算好平衡权重传给 CrossEntropyLoss。如果加权后少数类召回率上来但精确率掉得厉害可以换 Focal Lossgamma2 是常用起点它会让模型更关注难分类样本。眼疾分级任务里临床更看重高风险等级的召回率宁可把一些正常样本误判为轻度病变也不能漏掉重度病变这个指标权衡关系要在答辩时主动讲清楚。5.4 224x224 直接缩放丢病灶小病灶被预处理吃掉现象模型训练指标都正常但测试集里早期微动脉瘤的样本几乎全错。把错误样本放大看病灶区域在预处理后的图像里已经模糊成一团医生都很难分辨。原因眼底原图分辨率高直接 Resize 到 224x224 等于把图像缩到原来的约五十分之一几个像素的微动脉瘤直接消失或与背景融合。DenseNet121 输入尺寸默认 224x224但这个默认值不是硬限制。解决把预处理后的结果可视化跟原图对比确认病灶还在不在。常见做法是先保持 224x224 跑通基线再用 256x256 或 288x288 重跑一遍对比。如果显存允许用随机裁剪替代直接缩放让模型在训练时有机会看到局部细节。更完整的方案是两阶段网络第一阶段用低分辨率判断整体病变等级第二阶段在高分辨率裁剪区域上做精细分类。对毕设来说先跑通 224x224 的基线再提升输入尺寸是最稳妥的路径。5.5 测试集结果复现不出来随机性与推理模式现象同一个 checkpoint昨天跑测试集准确率 82%今天重跑变成 79%把训练代码发给队友他跑出来的结果又不一样。原因多层随机性叠加数据划分的随机种子、数据加载顺序的随机种子、推理时没有关闭 dropout 和批归一化的训练行为。torchvision 预训练权重是确定的但微调过程中的随机丢弃会让结果漂移。解决在训练和推理脚本开头统一设置随机种子推理时务必加上 model.eval() 和 torch.no_grad()否则批归一化层还在用训练统计量dropout 还在随机置零。最后把测试集预测结果连同概率分数导出成 CSV答辩时当场重新推理只要 CSV 和复跑结果一致这个问题就翻篇了。# 推理脚本固定随机种子并切换推理模式 import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) set_seed(42) model.eval() with torch.no_grad(): logits model(input_tensor)6. 用 Grad-CAM 热力图证明模型看了病灶答辩加分技巧模型准确率只是一串数字答辩时评委最常问的问题是“你怎么证明模型关注的是病灶区域而不是图像里的噪声或水印”这时候 Grad-CAM 热力图比任何指标解释都直观。方案是在 DenseNet121 的最后一个卷积层features 的最后一个 DenseBlock注册 forward hook把分类头的输出梯度回传到该层计算每个通道的权重并生成热力图。# scripts/visualize.py import torch import cv2 import numpy as np grads [] features [] def hook_fn(module, input, output): features.append(output) model.features.register_forward_hook(hook_fn) def save_grad(module, grad_input, grad_output): grads.append(grad_output[0]) model.features.register_full_backward_hook(save_grad) model.eval() img_tensor ... # 与训练时相同的预处理流程 output model(img_tensor.unsqueeze(0)) conf, pred torch.max(torch.softmax(output, dim1), 1) # 反向传播到特征层 model.zero_grad() output[0, pred].backward() with torch.no_grad(): # 对梯度做全局平均池化得到每个通道的权重 weights grads[0].mean(dim(2, 3), keepdimTrue) cam (weights * features[0]).sum(dim1, keepdimTrue) cam torch.relu(cam) # 只保留正向贡献 cam cam.squeeze().cpu().numpy() cam cv2.resize(cam, (img_w, img_h)) cam (cam - cam.min()) / (cam.max() - cam.min() 1e-8)注册 hook 拿到的 output 是一个 Tensorbackward 之后保存在 grads 里的梯度形状是 (batch, channels, h, w)对空间维做全局平均得到每个通道的权重再与特征图逐通道加权求和。relu 只保留正向贡献因为 Grad-CAM 关注的是“哪些区域让模型更倾向于预测这个类别”负贡献区域通常是抑制信号不滤掉会让热力图显得杂乱。最后把 CAM 缩放到原图尺寸叠加到原图上红色区域就是模型重点关注的判别区域。以我自己的习惯每次训练完会从错误分类样本里挑出 6 到 8 张图跑一遍 Grad-CAM把热力图存成对比图放进答辩 PPT。如果模型把 3 级病变错判成 2 级但热力图仍然准确落在渗出区域说明模型学到的特征方向是对的只是边界判断不够精确如果热力图散落在图像边缘或血管分叉处说明预处理或增强策略有问题模型在走捷径。第一次完整跑通这套流程后我最大的教训是提前把可视化脚本固化到项目里不要等到答辩前一天才临时写。从数据划分时按患者分组到训练时保存验证集最优权重再到最后的 Grad-CAM 对比图每一步都留好可复现的脚本和记录这个项目才算真正做到了“完整代码数据可直接运行”。希望帮到你。本文还有配套的精品资源点击获取