如果你的毕设题目里同时出现了“深度学习”“Inception-ResNet”和“皮肤癌分类”这三组词,那你大概率走上了一条表面看着简单、实际细节多到让人头皮发麻的路。我当初选这个题的时候想得很天真:分类任务嘛,套一个预训练模型,换掉全连接层,跑几十个epoch,论文里贴几张曲线,系统页面上传图片出个结果,完事。结果真正动手才发现,数据不平衡、类别混淆、假准确率、训练震荡,每一样都能让一个原本计划两周完成的任务变成两个月。
这篇文章不打算复述那些你随手能搜到的网络结构图,而是把我完整做这整套系统的流程捋一遍:为什么选 Inception-ResNet,数据集怎么处理才不翻车,训练阶段哪些坑值得上墙,最后怎么把模型封装成一个像样的 Demo 系统。适合正在做相关方向课程设计或毕业设计的同学,也适合想快速入门医学图像分类、又不想只停留在“跑通一个 notebook”层面的人。
1. 题目拆解:皮肤癌分类到底是在解决什么问题
1.1 分类任务的边界与标签体系
皮肤癌分类本质上是一个医学图像分类任务,输入是皮肤镜拍摄的皮损图像,输出是病变类型。很多同学一开始会纠结:做成二分类还是多分类?
我的建议是直接做成多分类。公开皮肤镜图像数据集通常把常见病变分成七类:黑色素瘤、色素痣、基底细胞癌、光化性角化病/鲍恩病、良性角化病、皮肤纤维瘤、血管病变。如果论文里只做“良性/恶性”二分类,答辩时大概率会被问到“你的恶性标准是什么,黑色素瘤以外的类别算恶性吗”,反而不好答。做成七分类之后,想输出二分类结果可以在后面聚合,灵活很多。
| 类别缩写 | 对应病变 | 常见说明 |
|---|---|---|
| MEL | 黑色素瘤 | 恶性度最高,是模型重点关注对象 |
| NV | 色素痣 | 良性,但数据量通常最大 |
| BCC | 基底细胞癌 | 常见皮肤癌 |
| AKIEC | 光化性角化病/鲍恩病 | 癌前病变或原位癌 |
| BKL | 良性角化病 | 与黑色素瘤易混淆 |
| DF | 皮肤纤维瘤 | 良性,样本通常较少 |
| VASC | 血管病变 | 良性,样本通常较少 |
这个标签体系最麻烦的地方在于,它不是“猫狗分类”那种一眼就能分清楚的类别。MEL 和 BKL 在肤色、形状上非常接近,有时连医生都需要看病理切片才能确诊,模型能依赖的只有皮损区域的颜色、纹理、边缘结构这些表面信息,所以天然就有分类歧义。
1.2 这个任务的三个真实难点
第一个难点是数据量不够大。公开的皮肤镜数据集虽然有数万张图像,但其中高质量、带确切诊断标签的样本并不多,深度学习模型在这种情况下很容易过拟合。
第二个难点是类别严重不平衡。以最常见的皮肤镜基准数据集为例,NV 一类可能占到全部样本的六成以上,而 DF、VASC 这类往往只有几百张。如果不做处理,模型会把所有输入都倾向预测成 NV,整体准确率看着还不差,但真正危险的黑色素瘤却被漏掉。
第三个难点是图像质量参差不齐。毛发遮挡、标尺刻度、光照不均、拍摄角度变化,都会给模型引入无关的噪声。分类算法如果把这些当成特征学进去,训练曲线再漂亮,换一批图像就崩。
1.3 环境与硬件准备清单
这个项目对硬件要求没有想象中高,我用的是一张 8GB 显存的普通消费级显卡,Batch Size 控制在 16 左右就能正常训练。如果你只有 CPU,也不是不能跑,只是 Inception-ResNet-v2 这种规模想训练完一整个流程会非常煎熬,建议优先用云 GPU 或者实验室机器。
软件环境建议如下:
- Python 3.10 以上
- PyTorch 2.x
- timm 库,用来加载预训练 Inception-ResNet-v2
- Albumentations,做数据增强
- scikit-learn,做数据集划分和指标计算
- Flask,做最后的 Web 端演示
这里特意提 timm 是因为 torchvision 官方没有直接实现 Inception-ResNet-v2,而 timm 里一行代码就能拿到带预训练权重的模型,非常方便。
2. 模型选型逻辑:为什么是 Inception-ResNet 而不是 ResNet50
2.1 皮肤镜图像和 ImageNet 图像差在哪里
先从数据特性说起。ImageNet 里的自然图像有丰富的场景上下文,模型可以依赖物体轮廓、背景、周边物体来辅助判断。而皮肤镜图像是高度标准化的特写图像,背景几乎都被裁剪掉,只有皮损区域,模型必须聚焦于细节纹理和颜色分布。
这意味着模型需要在“全局形态”和“局部纹理”之间同时保持敏感。太浅的网络学不到足够抽象的语义特征,太深的网络在有限数据下又容易过拟合。ResNet 和 Inception 两大流派各自解决了其中一部分问题,把它们结合起来的 Inception-ResNet 就显得很有吸引力。
2.2 ResNet 和 Inception 各自在解决什么问题
ResNet 的核心贡献是残差连接。简单说,网络每一层本来想学一个映射 H(x),残差结构改成学 F(x) = H(x) - x,然后输出 H(x) = F(x) + x。这样梯度在反向传播时可以跳过部分层直接往前传,避免了深层网络训练时的梯度消失和退化问题。皮肤分类不会用到几百层的超深网络,但残差思想对训练稳定性帮助很大。
Inception 想解决另一个问题:卷积核的尺寸到底选多大合适。3×3 适合捕捉局部细节,5×5 适合捕捉更大范围的结构。Inception 模块的做法是多个不同尺寸的卷积核在同一个层上并行计算,再把结果拼一起,让网络自己学习该侧重哪种尺度。对皮肤病变而言,色素网络可能是细纹理,边缘隆起可能是中尺度结构,这种多尺度能力正好匹配。
2.3 Inception-ResNet-v2 是怎么把两者结合的
Inception-ResNet-v2 的基本做法,是把残差连接加到 Inception 模块的输出上。每个 Inception 分支计算出特征增量,乘以一个缩放系数,再加上输入本身。
这个缩放系数值得单独说。论文中通常取 0.1 左右,目的是让残差增量在总输出中占比小一些,恒等映射占主导,训练早期会稳定很多。如果不做这个缩放,深层网络一叠加,特征图数值容易直接爆炸,这是我实际训练时踩过的真实坑。
网络整体结构可以理解为四个阶段:先是 Stem 卷积层把 299×299 的输入逐步降采样到适合处理的尺寸,然后经过 Inception-ResNet-A、Reduction-A、Inception-ResNet-B、Reduction-B、Inception-ResNet-C 几个阶段,最后接全局平均池化和全连接分类头。每一层在做什么不需要背,但你至少要知道 35×35、17×17、8×8 三档分辨率分别对应不同层级的特征提取。
2.4 候选模型对比:我当时的选型依据
选型时我认真对比了几类常见模型,也建议你先做一个小规模的对比实验再定最终方案。下面这个表是我根据自己的实验和常见经验整理的判断,不一定绝对,但方向足够参考:
| 模型 | 训练稳定性 | 在医学小数据上的适配度 | 推理速度 | 我的建议 |
|---|---|---|---|---|
| ResNet50 | 很稳定 | 尚可,适合做基线 | 快 | 作为 baseline 必跑 |
| Inception-ResNet-v2 | 较稳定 | 好,多尺度能力匹配皮损图像 | 中等偏慢 | 最终选用 |
| EfficientNet-B3 | 需要更多调参 | 对数据量敏感,样本少时容易欠拟合 | 快 | 可做对比实验 |
| Vision Transformer | 需要大数据和强正则 | 小数据集不推荐 | 中等偏慢 | 不建议作为主模型 |
EfficientNet 理论上精度不错,但它在训练时需要更多正则技巧,数据不足时表现反而不稳。ViT 在小规模医学数据集上需要非常强的数据增强和较长的训练策略,对毕设来说风险偏高。
2.5 为什么 ImageNet 预训练参数在医学任务上也能用
很多同学会疑问:ImageNet 是自然图像,跟皮肤镜图像差异这么大,直接拿它的预训练权重有意义吗?
有意义。深层网络的前几层学到的往往是通用低级特征——边缘、角点、颜色渐变、纹理基元。这些特征在自然图像和皮肤镜图像之间是可以迁移的。后期层学到的才是与具体任务强相关的高层语义。我们用预训练权重做初始化,本质上不是告诉模型“皮肤病变长什么样”,而是告诉它“如何从图像里提取有用特征”,后面再通过微调让高层语义适配皮肤分类。
3.1 清洗图像:剔除坏图和病损级去重
公开的皮肤镜数据集通常已经做过基础标注,但里面仍然会有一些无法解码的文件、尺寸异常的图片、以及来自同一患者同一病损的多张重复图像。
重复图像这个问题很容易被忽略。同一病损的不同角度照片如果同时出现在训练集和验证集,模型其实是“记住了”这张病损,而不是学会了分类规律,验证集指标会虚高。正确做法是按病损维度分组,确保同一病损的所有图像只出现在一个集合中。用 scikit-learn 的 GroupShuffleSplit 就能完成,分组字段取数据集的病损编号即可。
3.2 尺寸与归一化:让输入符合模型预期
Inception-ResNet-v2 默认输入尺寸是 299×299。你可以在训练脚本里打印模型的 default_cfg 来确认输入大小。如果显存允许,也可以尝试 384×384 甚至 512×512。皮肤镜图像的细节非常密集,更大分辨率保留的信息更多,代价是训练速度明显下降、显存占用上升。我最终选了 299 作为主实验尺寸,因为 8GB 显存跑 512 实在太吃力。
归一化处理建议使用 ImageNet 预训练权重对应的一致参数,不要自己发明均值和标准差。虽然皮肤镜图像的颜色分布和自然图像不同,但迁移学习场景下保持输入分布和预训练一致,能让权重更好地发挥作用。可以把调整后的任务再留到模型训练中期,用额外的增强实验来验证。
3.3 数据增强策略:旋转、翻转与颜色扰动的边界
数据增强是医学图像分类里性价比最高的一步。皮肤镜图像没有严格的“上下”概念,水平翻转、垂直翻转、随机旋转 90 度通常都是安全的。但要注意,超过一定角度的任意旋转可能会改变病变形态的相对方向,建议把随机旋转限制在 ±15 度左右。
颜色抖动要格外克制。色调偏移过大会让原本的红色炎症区域、棕色色素区域变得面目全非,模型会把不存在的颜色变化当真。我当时只用了很小的亮度、对比度扰动,色调偏移控制在 10 以内。这里附一段基于 Albumentations 的增强流水线:
import albumentations as A train_transform = A.Compose([ A.Resize(299, 299), A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.15, p=0.3), A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=15, val_shift_limit=10, p=0.3), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_transform = A.Compose([ A.Resize(299, 299), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里的关键点是训练集和验证集用不同的 transform,验证集不能做随机增强。很多人在这里图方便直接共用一套,最后验证指标忽高忽低,问题就出在这里。
3.4 类别不均衡:三种方案实际都该怎么用
类别不均衡处理得越早,后面省的事越多。我建议至少做两层处理。
第一层用类别加权损失。给少数类更高的权重,让模型在计算 loss 时“偏心”。代码实现很直接:
import torch import torch.nn as nn class_counts = torch.bincount(torch.tensor(all_labels)) class_weights = 1.0 / class_counts.float() class_weights = class_weights / class_weights.sum() criterion = nn.CrossEntropyLoss(weight=class_weights.to(device))第二层用 WeightedRandomSampler 做过采样。每一步采样时,让少数类样本有更高概率被抽中,相当于隐性地重复了少数类。这个技巧比简单复制少数类图像要优雅,不会导致模型在同一个样本上反复过拟合。
from torch.utils.data import WeightedRandomSampler sample_weights = [1.0 / class_counts[label] for label in all_labels] sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights), replacement=True)还有更强的 Focal Loss 可以尝试,它让模型把注意力集中在难分类样本上。但对毕设而言,类别加权损失和采样器已经能解决大部分问题,Focal Loss 调参成本较高,容易让训练变得不稳定,建议作为扩展实验而不是主力方案。
3.5 划分数据集:按病损维度而不是图像维度
划分方式直接决定实验结论是否可信。如果直接随机打乱图片再分训练验证集,很容易出现同一病损的多张图同时出现在两边,验证集结果虚高。这里必须用分组划分:
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(gss.split(df, groups=df["lesion_id"])) train_df = df.iloc[train_idx] val_df = df.iloc[val_idx]用病损编号作为分组依据,能保证同一个病损的照片不会泄漏到验证集里。这个细节我在初版实验里漏掉了,结果验证准确率做到 0.95 以上,觉得模型已经完美,后来重新按病损划分后指标立刻掉了十几个点,才开始正视真正的问题。
4. 模型搭建与训练配置:从预训练权重到可复现的训练脚本
4.1 用 timm 一行加载预训练模型
torchvision 本身没有 Inception-ResNet-v2,这个模型在 timm 库里有实现。加载时直接指定 pretrained=True,它会自动下载官方在 ImageNet 上预训练好的权重:
import timm import torch.nn as nn model = timm.create_model("inception_resnet_v2", pretrained=True, num_classes=7) print(model.default_cfg["input_size"]) # 应该是 (3, 299, 299)我遇到的第一个坑是权重下载。timm 默认会把权重下载到 Hugging Face 的缓存目录,部分地区网络访问很慢。建议提前手动把权重文件下载好,放到对应缓存目录,或者让身边的同学直接把权重文件拷给你,否则训练脚本卡在下载时真的毫无脾气。
4.2 替换分类头与层冻结策略
timm 在 create_model 时传入 num_classes=7,会自动把最后一层全连接换成 7 分类头。但默认情况下所有层都是可训练状态。对于数据量不大的皮肤任务,我建议先冻结大部分底层,只训练分类头和最后几个阶段,让模型先稳定下来。
for name, param in model.named_parameters(): if "classif" in name or "classifier" in name: param.requires_grad = True else: param.requires_grad = False这里的 “classif” 是 timm 里分类头的命名,不同版本可能略有差异,建议先打印一遍 model.named_modules() 确认。冻结阶段跑几个 epoch 后,再把全部层解冻,用更小的学习率微调,效果通常比分阶段直接全量微调更好。
4.3 优化器与学习率:我的初始配置
这个项目实际跑下来,稳定且省心的配置是 AdamW + 线性热身 + 余弦退火。初始学习率 1e-4,Batch Size 16,权重衰减 1e-4。如果直接用 SGD,学习率要适当提高,但收敛速度不如 AdamW 直观。
import torch from torch.optim import AdamW from torch.optim.lr_scheduler import OneCycleLR optimizer = AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) scheduler = OneCycleLR( optimizer, max_lr=1e-4, total_steps=num_train_steps, pct_start=0.1, )OneCycleLR 的好处是在训练前期用较低学习率热身,中期升到峰值,后期再降到极小值,对医学图像这种数据量不大的任务非常友好。也可以换成 CosineAnnealingLR,效果差异不大。
4.4 训练监控与模型保存:不要只看 loss
训练时要在每个 epoch 记录 train loss、val loss、准确率、加权 F1 以及按类别计算的 AUC。这里最需要建立的一个意识是:不要在验证集上依赖准确率做模型选择,尤其在不均衡数据下,准确率是欺骗性最强的指标。
我实际用的保存条件是“验证集加权 F1 最优”或“验证集 macro AUC 最优”。训练循环里比较当前指标和历史最优指标,如果连续若干个 epoch 没有提升就提前停止。
best_f1 = 0.0 for epoch in range(epochs): train_one_epoch(...) f1 = evaluate(...) if f1 > best_f1: best_f1 = f1 torch.save(model.state_dict(), "best_model.pth")不要每次 epoch 都保存,那样会占用大量磁盘空间,而且大概率保存了一堆没用的中间状态。保存一个最优模型就够了,如果还想做分析,再额外存最后一轮的权重。
5. 训练复盘的踩坑记录:从欠拟合到稳定收敛的完整迭代
5.1 第一版翻车:预训练权重没生效,loss 一直乱跳
我的第一版训练非常狼狈。加载模型时我以为 pretrained=True 就万事大吉,训练了十几个 epoch 后 loss 一直居高不下,验证指标在随机水平附近徘徊。后来检查权重加载过程,发现是因为 timm 下载权重文件失败,timm 在默认情况下没有抛异常,而是用随机初始化继续训练了。
这个教训很直接:训练前确认权重的确从预训练状态出发,而不是随机初始化。简单办法是跑一遍模型对一个 batch 的预测,对比加载权重前后的输出分布,如果输出 logit 值和训练前几乎一样,很可能权重就没加载成功。
5.2 Batch Size 和学习率的联动关系
刚开始我把 Batch Size 设成 32,学习率沿用 2e-4,结果 loss 曲线像心电图一样上下乱跳。后来把 Batch Size 降到 16,学习率降到 1e-4,训练立刻稳定下来。
原因是 Batch Size 越小,每个 batch 的梯度方向越不准,学习率必须相应调低。如果你的显存允许 32 甚至 64,学习率可以适当提高到 1e-4 到 2e-4;如果像我一样只能用 16,老老实实用 1e-4 起步,不要在数据集完整训练半小时后再后悔。
5.3 过拟合的正面对抗:早停加正则
皮肤分类数据集通常只有数千到一两万个样本,用几十万张图预训练过的模型微调时,过拟合几乎是必然的。我的观察是,前 10 个 epoch 训练 loss 和验证 loss 一起下降,到后面训练 loss 继续降,验证 loss 开始回升,典型过拟合。
采用的策略是组合拳:早停、权重衰减、Label Smoothing。Label Smoothing 本质上是让模型不要对某个类别过于自信,损失函数里给标签加入一个小的平滑噪声,通常取 0.1。结合早停 patience 设置 10 个 epoch,能在验证指标不再提升时及时停下来,省去后面真正无效的实验时间。
5.4 只看准确率的陷阱:模型退化成了“猜谜游戏”
训练接近完成时,准确率看起来有 0.85 左右,但看混淆矩阵才发现问题严重。模型对 NV 的识别非常准,但 MEL 的召回率只有 0.5 左右,这意味着真正危险的黑素瘤有一半被当成良性。这种结果在医学场景下是不可接受的。
当时如果不看类别指标,很容易被一个 0.85 的准确率迷惑。合理的评估方式要看每一类别的 Precision、Recall、F1,以及 macro AUC。为了清晰展示,我建议在最终报告里放这样一张表:
| 类别 | Precision | Recall | F1 | 样本数 |
|---|---|---|---|---|
| MEL | 0.63 | 0.58 | 0.60 | 400 |
| NV | 0.92 | 0.90 | 0.91 | 4000 |
| BCC | 0.72 | 0.68 | 0.70 | 700 |
| AKIEC | 0.61 | 0.55 | 0.58 | 500 |
| BKL | 0.54 | 0.60 | 0.57 | 800 |
| DF | 0.52 | 0.45 | 0.48 | 200 |
| VASC | 0.71 | 0.78 | 0.74 | 300 |
上表是虚构的示例数据,但形状非常典型:NV 的指标远高于其他类,而 DF 这种少数类很难学,MEL 的精度比 NV 低很多。这就是真实的皮肤分类难点。
6. 从模型到系统:毕设 Demo 的工程化实现路线
6.1 系统架构:训练端、推理端、展示端各管什么
多数同学做这个题目时模型已经训好,差的是“系统”这层。一个能清晰展示的系统不需要做大平台,三个模块足够:
- 训练端:负责数据加载、训练、评估和保存权重,输出最佳模型文件和类别映射文件。
- 推理端:负责加载权重、预处理图片、返回分类结果和置信度。
- 展示端:负责提供网页上传入口,调用推理端并展示结果、概率条以及注意力热力图。
这三个模块之间通过模型文件和接口解耦。训练端哪怕在本地跑完,把权重文件放到服务器上,推理端就能正常工作。这样设计还有个好处——答辩演示时如果现场环境网络不稳定,模型已加载到内存,演示过程不会依赖外网。
6.2 推理接口封装:从 .pth 文件到一键预测
推理端最重要的不是把 model.forward 调用一遍,而是把预处理、模型加载、结果解析封装成一个完整的类:
import torch import timm from PIL import Image import albumentations as A import numpy as np class SkinCancerClassifier: def __init__(self, weight_path, class_names, device="cpu"): self.device = device self.model = timm.create_model( "inception_resnet_v2", pretrained=False, num_classes=len(class_names) ) self.model.load_state_dict(torch.load(weight_path, map_location=device)) self.model.to(device).eval() self.class_names = class_names self.transform = A.Compose([ A.Resize(299, 299), A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) def predict(self, image_path): img = np.array(Image.open(image_path).convert("RGB")) img = self.transform(image=img)["image"] img = torch.from_numpy(img).unsqueeze(0).permute(0, 3, 1, 2).to(self.device) with torch.no_grad(): logits = self.model(img) probs = torch.softmax(logits, dim=1).cpu().numpy()[0] top_idx = int(np.argmax(probs)) result = { "class": self.class_names[top_idx], "confidence": float(probs[top_idx]), "all_probs": { name: float(prob) for name, prob in zip(self.class_names, probs) }, } return result这个封装能直接把训练产物变成可复用的服务。有一点要注意:加载权重时pretrained=False,并且传入和训练时一致的num_classes=7,否则权重形状对不上直接报错。很多人卡在这里,就是因为在推理时又用pretrained=True加载了一次。
6.3 Grad-CAM 可视化:让模型说清楚自己看了哪里
光给用户一个“这是黑色素瘤”的结果,说服力有限。如果同时展示一张热力图,告诉用户模型是依据图像哪个区域下的判断,整个系统的可信度会大幅提升,也是答辩时非常加分的部分。
Grad-CAM 的基本思路是:取模型最后一层卷积层的特征图,根据类别对应的梯度计算每个通道的重要性,加权求和得到注意力热力图。timm 模型里每一层名称可以通过model.named_modules()查看,找到最后一个卷积层或包含空间信息的层作为 target_layer。
我自己实现时参考了常见的 Grad-CAM 写法,借助 PyTorch 的 hook 拿到特征图和梯度。热力图叠加到原图上后,观察到的现象也很直观:判断黑色素瘤时,模型注意力往往集中在色素结构中心附近;判断某些良性病变时,注意力会更分散在皮损边缘。这个可视化过程不只是给老师看的,也可以反向帮你发现模型是不是学了背景、标尺等无关特征。
6.4 Web 端与部署:单机轻量方案
展示端用 Flask 就够了,不需要引入重型框架。核心就一个路由:接收上传文件,调用推理类,返回 JSON 给前端渲染。
部署时需要注意几个容易被忽略的点:限制上传文件扩展名和大小,上传目录加随机文件名防止覆盖,模型只加载一次到全局变量而不是每个请求都重新加载。如果宿主机器配置一般,推理端可以放在 CPU 上跑,Inception-ResNet 对单张图的推理耗时约几百毫秒到一两秒,交互体验完全可接受。
更进一步的方案是用 FastAPI 提供接口,前端通过 fetch 上传图片并异步获取结果。但对毕设而言,Flask + 一个简单的 HTML 页面已经足够完整,没必要把前端工程弄得太复杂。
7. 实验结果复盘:指标怎么读,模型还能往哪走
7.1 医学场景下准确率不是第一指标
医学图像分类里,漏诊和误诊的代价完全不同。对黑色素瘤来说,漏掉一个恶性样本可能带来严重后果。所以在最终实验报告里,我强烈建议把敏感度(Recall)和 AUC 放在与准确率同等重要的位置。
一个容易被忽略的点是“平均 AUC”的计算方式。七分类任务里,通常用 macro-averaged AUC,也就是对每个类别单独计算 AUC 再取平均,避免大类别样本数量主导整体结果。这样模型有没有认真对待少数类,一眼就能看出来。
7.2 错误样本分析:模型为什么会错
训练完成后,我随机抽取了 50 张错误分类样本逐张查看。发现几类典型情况:来自同一设备、存在颜色偏色的图像经常被误判;毛发遮挡严重的样本会让模型注意力飘到毛发区域;MEL 和 BKL 之间的混淆占了错误样本很大一部分。这个现象其实是符合临床经验的,形态学上两者本来就很难区分。
错误样本分析的价值在于,它能帮你在论文里写清楚“局限性与未来工作”。直接承认模型在哪些类别上表现不佳,比全文只强调 0.85 的准确率更有说服力,答辩老师也会更认可你对问题的真实理解。
7.3 后续可做的有效扩展方向
如果你还有时间,可以尝试几个低成本但对结果影响大的方向。
第一是元数据融合。公开皮肤镜数据集里通常包含患者年龄、性别、病变部位等信息,可以在模型最后一层把这些特征拼接进去,相当于给图像分类器补充临床背景知识。第二是做两阶段模型:先区分是否需要重点关注,再在重点关注子集中细分具体类型。第三是伪标签半监督:用当前模型对大量无标签皮肤镜图像打伪标签,加入训练集做第二轮微调,能有效提升少数类性能。
还有一个很实用的改进是置信度拒绝机制。当模型对预测结果置信度低于某个阈值时,系统提示“无法判断,建议咨询专业医生”,而不是强行给出一个可能错误的结果。这个机制实现成本极低,但对系统的完整度和可信度提升非常明显。
7.4 给正在做类似题目的同学几句实在话
如果你也正卡在这个项目里,我想说:先不要急着追求极致准确率,先把数据划分做对,再把评估指标看全,最后把系统闭环跑通。这三件事每件都做好了,你的毕设就已经超过大多数流水账式的图像分类项目了。
模型训练完之后,也别急着写论文。把被分错的图像打印出来一张张看,你会比调参数多获得十倍的感觉。看着模型在那些连人都觉得像良性、却被标注为恶性的样本上挣扎,你会开始理解这个任务真正的难度在哪里——这种真实体会,反而比一个好看的指标更像“研究”。