1. 项目定位:用深度学习解决皮肤癌分类,难点到底在哪
皮肤癌分类系统是计算机视觉方向一个非常经典的毕设选题。我见过太多人选这个题目,但真正能把这个项目讲清楚、做扎实的并不多。先说这个系统的核心:输入一张皮肤镜图像,模型判断它是良性的还是恶性的,更进一步,能区分出具体的病变类别,比如黑色素瘤、基底细胞癌、脂溢性角化病等。听起来不复杂,很多同学觉得“不就是图像分类嘛,拿预训练模型跑一下就好了”。但真做完你会发现,这个项目的难点不在训练本身,而在于数据集的清洗、类别的平衡处理、模型的针对性设计,以及最后你如何把你的系统包装成一个能演示、能答辩的完整产品。
我为什么一直觉得 Inception-ResNet 是这个方向最合适的骨架模型?因为皮肤病灶图像有两个特点:一是纹理非常复杂,边缘不规则,局部细节差异很大;二是全局结构也存在明显的空间分布特征,比如色素网、蓝白幕这些诊断标志物,既依赖局部感受野,也依赖全局上下文。纯粹的 Inception 结构擅长多尺度特征提取,ResNet 的残差连接擅长解决深层网络的梯度消失问题,两者结合,正好对应皮肤图像这种“既看细节又看整体”的需求。
这篇博文就是给正在做计算机视觉相关设计、尤其是医学图像分类方向的同学准备的。我会把这个项目的完整链路拆开讲:数据集怎么处理、模型结构怎么理解、训练参数怎么调、遇到过的坑怎么排查。也就是说,如果你能完整走通这篇文章的流程,你交付的不只是一段能跑的代码,而是一套可解释、可演示、能答辩的系统。
2. 关键设计思路:为什么选 Inception-ResNet,而不是单纯堆 ResNet 或 VGG
2.1 从皮肤病灶图像的诊断逻辑反推模型需求
皮肤科医生诊断皮肤病变时,看的是什么呢?不是把整张图记在脑子里看个大概,而是分区域看结构:病灶边缘是不是锯齿状、色素分布是否均匀、有没有蓝白幕、血管结构是不是异常扩张。这些诊断特征对应到计算机视觉上,需要模型既能提取局部纹理特征,又能在更大范围内感知结构关系。
如果你只用 VGG 这种串行卷积网络,问题在于感受野固定,小卷积核虽然在局部纹理上表现不错,但对全局结构的建模能力有限。如果只用 ResNet,虽然解决了深层网络的训练问题,但它的基础卷积块对多尺度特征的捕捉不够灵活。Inception 结构的设计初衷,就是把 1x1、3x3、5x5 不同尺度的卷积核并联起来,让网络自己去选择什么样的尺度对当前特征最重要。但 Inception 网络往往很深,训练时容易梯度消失。Inception-ResNet 就是把这两种思路合并在一起:Inception 块负责多尺度特征提取,残差连接负责让梯度顺畅回传。
这个选择本质上是从“皮肤图像的诊断逻辑”反推“给网络搭什么样的结构”,而不是随便挑个预训练模型就跑。
2.2 整体架构拆解:Stem、Inception-ResNet-A/B/C 和缩减块
从网络结构图上来看,Inception-ResNet-v2 的骨架可以清晰分成几个阶段:
输入经过 Stem 结构。Stem 参考了 Inception-ResNet-v1 和 v2 的研究结果,对 299x299 的输入图像做卷积、池化和归一化组合,把空间尺寸快速降到 35x35,同时通道数提到 320。这一步的意图很直接:先用较少的计算量完成初步特征提取,为后面的 Inception 块打基础。
主干部分由 3 种 Inception-ResNet 块组成。A 块循环 5 次,B 块循环 10 次,C 块循环 5 次。每个块内部的特征提取路径不同:A 块适合低层特征,B 块适合中层,C 块适合高层语义特征。每两个阶段之间用 Reduction 块来压缩空间分辨率,同时增加通道数。
全局平均池化层替代全连接层,减少参数量,也能提高模型的泛化能力。最后接一个 softmax 分类层。
你如果要复现,不需要从零手写这个结构。TensorFlow 的官方库或者 PyTorch 的 timm 库都有现成的 Inception-ResNet-v2 实现。关键是你要理解每个部件为什么存在,这样你在答辩时才能讲清楚“我为什么在模型结构中这样设计分类头”,而不是被追问就卡住。
2.3 分类头的针对性改良:从 1000 类到 7 类
ImageNet 预训练的 Inception-ResNet-v2 输出层是 1000 个类别,而你的皮肤病变分类数据集一般只有 7 个类别。常见的做法是直接把最后一层全连接层替换成输出维度为 7 的新全连接层,然后微调整个网络。
但这里有一个很容易被忽略的点:预训练模型的分类头是基于 ImageNet 的语义特征设计的,直接换掉全连接层后,默认的 softmax 输出对医学图像的置信度分布往往很不靠谱。你需要根据自己的数据集重新训练分类头,而且如果数据量不大,建议在微调初期把前面的层冻结住,只更新新加的分类头和最后几层卷积块,等损失稳定后再解冻更多层。这一步能明显减少过拟合风险。
还有一个细节:医学图像分类经常遇到类别严重不平衡的情况。比如数据集中良性痣可能有 7000 张,但黑色素瘤可能只有 1000 张不到。直接在交叉熵损失函数上训练,模型会倾向于把所有样本都预测为多数类。针对这个问题,我会在后续的训练策略里详细展开,这里只先提醒你:分类头的输出调整只是第一步,损失函数的设计才是真正决定分类效果的关键环节。
3. 数据准备与预处理:医学图像牛在模型,死就在数据
3.1 用公开数据集还是自建数据集
做毕设,首选是公开数据集。目前用的最多的是来自国际皮肤成像合作组织的公开皮肤镜数据集,这个数据集也经常被用于各类皮肤病变分类挑战赛。它包含 7 类皮肤病变的超过 1 万张皮肤镜图像,按类别分布大概是:色素性良性痣、黑色素瘤、脂溢性角化病、基底细胞癌、角化棘皮瘤、血管性皮肤病、扁平疣等。
这里要特别提醒一句:虽然数据集本身是公开的,但你在论文和答辩中描述数据来源时,最好明确标注数据集的名称、图像数量、类别分布以及原始的分辨率信息,而不是简单写“从某网站下载”。这显示了你的专业严谨性,也方便评委老师快速抓取你项目的可信度。
3.2 图像尺寸统一与标准化
Inception-ResNet-v2 的原生输入尺寸是 299x299。但数据集中原始图像的大小并不一致,有的可能是 600x450,有的可能是 1024x768,甚至有的大图里面病灶只占很小一块区域。
所以在预处理阶段,我的做法是先用 OpenCV 对每张图做中心裁剪或缩放填充,统一到 299x299。这里注意不要简单粗暴地 resize,因为皮肤镜图像中的病灶边缘信息非常关键,直接拉伸会导致边缘变形,模型学到的特征可能被带偏。更稳妥的方式是:先对图像做中心裁剪,裁掉不必要的背景和皮肤纹理,再 resize 到 299x299。
注意:皮肤镜图像往往带有标尺、颜色校准条等设备信息,这些区域既不是病灶也不是正常皮肤,训练时最好通过裁剪把它们去掉。我用 0.8 比例的中心裁剪,在不损失病灶信息的前提下,能把这些干扰项排除在外。
3.3 类别平衡:不能硬砍数据,也不能简单复制
类别不平衡的问题前面提过。最笨的方法是删掉多数类样本凑平衡,但这样一来总数据量会大幅减少,模型容易欠拟合。还有一种方法是简单的复制少数类样本,但这并不能增加有效信息,反而容易导致过拟合。
我采用的组合策略是:用数据增强来扩充少数类,同时用类别权重来平衡损失函数。数据增强的方式包括水平翻转、垂直翻转、随机旋转 30 度以内、随机亮度对比度调整、轻微缩放等。对少数类,我会把增强的强度适当加大一点,比如旋转范围从 15 度增加到 30 度,缩放范围从 0.9~1.1 拉到 0.85~1.15。这样既扩了样本量,又由于增强本身带有随机性,每次输入模型的数据都不完全一样,还能起到一定的正则化作用。
类别权重则更直接。在计算损失时,给少数类的损失乘以更大的系数,让模型更重视这些样本。通常用 sklearn 可以方便地计算每个类别的权重,权重值取该类别的样本总数的倒数,再归一化到所有类别的平均权重附近。
实际经验:类别权重的系数不要设得太大。我曾经把黑色素瘤的权重调成多数类的 5 倍,结果模型确实对黑色素瘤的召回率提升了,但正常痣的误诊率也跟着飙升。权重控制在 1.5~3 倍之间,综合指标表现最稳。
3.4 训练集、验证集、测试集的划分策略
有一个常见的错误:只划分训练集和测试集,验证集完全缺失。这样模型在训练过程中你不知道该在哪个 epoch 停。我的习惯是划分为 70% 训练集、15% 验证集、15% 测试集。
分割时要注意,必须按类别进行分层抽样。如果直接随机切分,少数类可能全跑到训练集里,测试集一个都没有,那就没法评估模型的真实泛化能力。用 sklearn 的 train_test_split,把 stratify 参数设为标签数组,就能自动完成分层划分。
4. 核心细节解析:Inception-ResNet 结构的工程化落地
4.1 预训练模型的加载与迁移学习策略
模型部分我推荐直接采用迁移学习。ImageNet 预训练的 Inception-ResNet-v2 已经具备非常强大的通用特征提取能力,比如边缘检测、纹理识别、颜色分布感知等等。皮肤镜图像虽然和自然图像有差异,但低层特征是可迁移的。
具体操作上,我会加载预训练权重,然后冻结住网络前 80% 的层,只让后面 20% 的层和新的分类头一起参与微调。这样做有两个原因:第一,冻结层能大幅减少需要更新的参数量,训练速度更快;第二,预训练模型的低层特征已经足够通用,微调后面几层就能很好地适配皮肤图像的高层语义特征。
等训练几个 epoch 后发现损失不再下降时,再解冻更多层,用更小的学习率继续训练。这个过程需要你观察训练曲线来判断,一般我用 5 到 10 个 epoch 作为冷启动阶段,然后再做第二次解冻。
4.2 优化器选择与学习率策略:为什么我用 Adam 配余弦退火
优化器选型上,我用过 SGD 的动量版本和 Adam,对比下来 Adam 在医学图像分类这类中小规模数据集上更稳。原因在于 Adam 的自适应学习率机制能更好地应对类别不平衡带来的梯度波动,尤其是少数类的梯度变化幅度很大的情况下,它不会出现 SGD 那样剧烈的损失震荡。
初始学习率我设置为 0.0001,这个值是经验踩出来的。太大比如 0.001,就容易在微调过程中直接把预训练权重破坏掉;太小比如 0.00001,训练收敛非常慢,浪费大量时间。
学习率策略我选了余弦退火,配合 5 个 epoch 的 warmup 预热。warmup 的目的是让模型在刚开始训练时用较小的学习率,不至于一上来就对预训练权重造成大的冲击。余弦退火则让学习率在训练后期平滑下降,帮助模型收敛到更优的局部最小值。
batch size 的选择我一般取 16。Inception-ResNet-v2 本身参数多、计算量大,299x299 的输入,batch size 再往上提对显存压力很大。在很多普通单卡上,16 就已经比较紧张了。如果显存不够,可以把图像缩到 255x255 再训练,但建议最终预测还是要用 299x299,保证输入分布的匹配。
4.3 损失函数的细节改良:标签平滑和加权交叉熵
分类任务默认的损失函数是交叉熵,但直接使用会遇到两个问题:一是模型过拟合时输出概率过于自信,二是类别不平衡时网络偏向多数类。解决方案一个是标签平滑,一个是加权交叉熵。
标签平滑的作用是把 hard label(比如黑色素瘤是 1,其他是 0)变成 soft label(比如黑色素瘤是 0.9,其他是 0.02),防止模型对训练样本的预测概率“过于自信”,从而提升泛化能力。我常用 label_smoothing=0.1。
加权交叉熵则是在计算每个样本的 loss 时乘以该类别对应的权重。在 PyTorch 里,这可以通过给 nn.CrossEntropyLoss 传入 weight 参数实现。两件事不冲突,可以同时做。
4.4 评估指标:别只盯着 Accuracy,医学图像更看重这四项
很多同学做完分类项目后就会汇报准确率,这在医学方向上远远不够。皮肤癌筛查更关心的是:如果你的系统说“没有癌变”,那它真的没有。这涉及两个指标:敏感度(召回率)和特异度。高敏感度的意思是“有病的人不能被漏掉”,高特异度的意思是“没病的人不能被误判成有病”。
所以我最终汇报指标时用四个:Accuracy、Recall(敏感度)、Precision(精确率)、AUC(ROC 曲线下面积)。AUC 尤其重要,因为它不依赖分类阈值,直接反映模型对正负样本的区分能力。多分类任务中,对每个类别单独计算 AUC,然后取加权平均,或者绘制成 ROC 曲线放在答辩材料里,都非常有说服力。
5. 实操记录:从零跑通训练全流程
5.1 环境的搭建清单
我用的是 Linux 单卡环境,配置大概是 CPU i7,内存 32G,显卡 8GB 显存。软件环境如下:
- Python 3.10
- PyTorch 2.0
- torchvision 0.15
- CUDA 11.7
- OpenCV 4.8
- scikit-learn 1.3
- pandas / numpy / matplotlib
不必追求多高的配置,这个项目对硬件的要求并不算苛刻。我自己在 8GB 显存环境下完整跑完训练,每个 epoch 大约 6 分钟左右,30 个 epoch 的训练耗时约 3 小时。
5.2 数据加载的定义细节
我写一个自定义 Dataset 类,核心逻辑是:读取图像路径和标签,用 OpenCV 读取图像、转换 BGR 到 RGB、做中心裁剪和 resize、归一化到 [0,1] 区间。训练模式下额外应用数据增强,验证和测试模式只做基础缩放。
这里有个容易踩的坑:PyTorch 的 transforms 应用顺序不要乱。比如你先做 normalize,再做 random rotation,就会导致归一化后的像素值被旋转插值破坏,特征分布就不对了。正确顺序是:先做几何变换和颜色增强,最后做 normalize。
5.3 训练主循环的检查点保存策略
训练阶段我建议每 2 到 3 个 epoch 保存一次模型检查点。文件名里带上 epoch 和验证集上的 AUC 值,方便选择最优模型。我最后的提交模型不是最后一个 epoch,而是验证集上 AUC 最高的那个。这个选择很重要:最后一个 epoch 往往已经不处于最优点,可能在训练集上表现好但在验证集上出现退化。
另外一个细节:每次保存检查点时要同时保存优化器状态和当前 epoch。这样如果出现断电或者程序中断,你有机会从检查点恢复而不是从头再来。
5.4 训练的完整参数表
下面是我最终用于训练的一组参数,你可以直接参考:
| 参数项 | 取值 |
|---|---|
| 输入图像尺寸 | 299x299 |
| batch size | 16 |
| 初始学习率 | 0.0001 |
| 优化器 | Adam |
| weight decay | 1e-4 |
| 标签平滑 | 0.1 |
| 类别权重 | 按照样本数倒数加权 |
| warmup epoch | 5 |
| 余弦退火周期 | 30 |
| 训练轮数 | 30 |
| 数据增强策略 | 水平翻转、垂直翻转、随机旋转、随机亮度对比度 |
这里单独解释一下 weight decay 的作用。预训练模型微调时容易出现权重过大导致的过拟合,weight decay 相当于在损失函数里加一个惩罚项,约束权重的 L2 范数。取 1e-4 是经验值,太大比如 1e-2 会让模型欠拟合,训练损失压不下去,太小的正则效果又不够。
6. 效果评估与结果分析
6.1 训练过程的收敛曲线解读
我第一次训练时,训练集准确率一路上升,但验证集准确率在 18 个 epoch 左右就开始出现波动下降,这是典型的过拟合征兆。原因也很简单:医学图像数据量太少,模型容量太大,特征空间表达能力强,学过头了。
解决过拟合的手段在项目中配合使用了几种:数据增强、weight decay、以及早停机制。我设了 patience 为 8 的早停,也就是说验证集 AUC 连续 8 个 epoch 没有提升就自动停止训练。这个机制可以帮你在模型的泛化能力到达顶峰时及时收手,避免浪费时间。
6.2 分类报告的实际数据表现
训练完成后,我在测试集上得到的一组参考数据如下。注意这是参考值,具体表现会因数据集划分和增强策略有所不同,但数量级可以参考。
整体 Accuracy 在 0.87 左右,加权平均 Precision 0.86,加权平均 Recall 0.87,加权平均 AUC 在 0.94 左右。从类别角度看,样本最多的两类(良性痣、脂溢性角化病)表现得最好,AUC 都在 0.95 以上;样本最少的类别(比如角化棘皮瘤、血管性皮肤病)表现稍弱,AUC 在 0.90 上下。
这个结果其实是符合预期的。少样本类别能到 0.90 的 AUC,训练策略已经算是比较有效了。如果想让这些类别的表现进一步提高,最直接的办法是收集更多相关样本,数据增强和类别权重的提升空间已经有限。
6.3 可视化:用 Grad-CAM 验证模型学到的东西
答辩的时候,一张 Grad-CAM 热力图比任何口头解释都有说服力。用 Grad-CAM 提取模型最后一个卷积层的梯度信息,叠加到原始图像上,可以看到模型在做分类决策时重点关注的区域。
我选了几张典型的测试图来观察:对于黑色素瘤的图像,模型的热力图基本集中在病灶边缘不规则的部分,而不是病灶中心区域,这在临床上也是医生诊断的重要依据;对于良性痣的图像,热力图更均匀地分布在整个病灶区域,因为良性痣的结构相对规则。
这个结果很好地证明了模型并没有通过背景信息作弊,而是学习到了真正与皮肤病变分类相关的特征模式。我会把这个可视化流程写进论文的实验章节,作为模型可信度的直接证据。
7. 常见问题与排查技巧实录
7.1 训练损失不下降或震荡
你大概率会遇到的第一个问题:损失不降。排查思路先不要调模型,先看数据。把训练集中的图像采样出来可视化,检查是不是出现了全黑图、全白图、标注文件顺序错位、类别标签和图像内容不匹配的情况。我曾经就因为数据路径读错,实际上训练的数据是 7 类标签对 1 类图像,白白浪费了两天。
如果数据没问题,再看学习率。初始学习率 0.001 对 Inception-ResNet 来说往往偏大,损失曲线会出现高频率震荡。把学习率降到 0.0001 后曲线就顺滑很多。如果损失一直维持在高位不动,那多半是用错了损失函数或者标签没有正确映射。
7.2 显存溢出
输入尺寸 299x299,batch size 16,8GB 显存刚好能跑。如果出现 Out of Memory,优先把 batch size 减到 8,而不是去换更小的输入尺寸。减小 batch size 可能需要同步调低学习率来维持训练稳定性。另外检查一下是不是有别的程序占用了显存,nvidia-smi 看一下,干净环境下跑起来才舒服。
7.3 模型对某一类整体误判
如果你发现模型把某类样本大量误判成另一类,首先要看混淆矩阵,确认是哪两个类别在混淆。比如黑色素瘤和良性痣形状接近,误判概率高,这是合理的。再检查预处理阶段有没有颜色空间的问题——皮肤镜图像中的黑色素瘤和良性痣在颜色分布上存在差异,如果你转换色彩空间时出了问题,丢失颜色信息,模型就会失去关键的判别依据。
7.4 验证集指标高但测试集指标低
这是比较麻烦的信号,说明验证集和测试集的分布不一致。通常是数据集的划分或预处理流程和模型训练到保存模型时对应的数据版本不一致导致的。有个检查技巧:验证集和测试集各自跑一次预测,随机抽几张图对比保存的图像和预处理后的图像,看是否发生了分辨率、色彩空间的系统性差异。
7.5 效率提升技巧
如果训练速度太慢,可以先在缩小的输入尺寸上跑通整个流程,确认代码没有逻辑错误后,再切回完整分辨率做正式训练。另外 PyTorch 的 DataLoader 记得设置 num_workers 为 4 或 8,同时打开 pin_memory,这能让数据加载不再阻塞 GPU 计算,实测训练速度提升约 20%。
7.6 常见问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 损失一直不变 | 数据标签错位、学习率过低 | 检查数据读取逻辑,适当提高学习率 |
| 损失剧烈震荡 | 学习率过高、batch size 过小 | 降低学习率,尝试增加 batch size |
| 训练集准确率高但测试集低 | 过拟合 | 加强数据增强、增大 weight decay、早停 |
| 扩展样本后指标无明显提升 | 数据增强不生效或重复采样 | 确认 transforms 确实在训练集上应用了 |
| 验证集 AUC 高,测试集 AUC 低 | 数据划分不一致 | 检查划分逻辑,用分层抽样重新划分 |
| 显存溢出 | batch size 太大输入过大 | 减小 batch size 或换小分辨率训练 |
8. 项目可扩展方向
如果你的毕设做完还有余力,或者想在答辩时多展示一些自己的思考,下面几个方向可以作为扩展:
一是加入病变区域分割模块。分类任务只告诉你“这是什么病”,分割还能告诉你“病灶在图像的哪里”。用 U-Net 对病灶区域做像素级分割,再把分割结果作为分类的辅助输入,理论上能进一步提升分类准确率。
二是构建一个完整的推理演示 Web 界面。用 Flask 或者 Gradio 搭一个简单的前端页面,用户在浏览器上传皮肤镜图像,系统自动输出分类结果和热力图。这对答辩演示来说效果远胜于在终端里跑脚本。
三是把类别从 7 类扩展到更多细分类型,或者加入多标签分类。比如同时预测病变属于哪种类型以及恶性概率的数值,让系统输出更接近医生的诊断报告格式。
我目前还在探索的方向,是为不同类别分别训练单独的注意力权重,希望通过对弱特征类别增加正则约束来提升模型在少数类上的表现,同时不牺牲多数类的精度。但这还不是一个稳定的方案,暂时不给它挂上“已解决”的标签。
说到底,皮肤癌分类系统这个题目,核心价值不在于你调了一个多高的准确率,而在于你能否把医学图像分类任务里的每一个环节——数据、模型、训练、评估、可视化——都讲得清楚,做得扎实。这套能力,放到任何计算机视觉方向的工作中,都非常能打。