1. 这一周的任务本质:你以为的图像分类题,其实是道置信度题目
第P4周的题目叫"猴痘病识别"。刚看到这个题目时,我的第一反应是:又是一个皮肤病图像分类任务。这类项目在医学影像入门里很常见,但猴痘病相对小众,放到实际场景里其实比想象中更复杂。题目本身并不复杂:给我一批皮肤病灶图像,模型需要判断图像属于猴痘病,还是其他容易混淆的皮肤病(比如水痘、带状疱疹、丘疹性荨麻疹)。但如果只是把这个任务当成普通分类问题去跑一个CNN,最后大概率会得到一个"训练集漂亮、验证集崩塌"的模型。因为这类医学图像数据集通常样本少、类别分布不均匀、图像采集环境差异大,模型很容易学到"背景纹理"或者"拍摄角度"这类和疾病无关的特征。
1.1 题目在说什么?
题目给的信息非常有限,没有说数据集格式、类别数量、评价标准。这种情况下,第一件事不是找模型,而是把任务需求拆清楚。我给自己定了三个问题:
- 输入是什么?绝大多数公开的猴痘识别项目使用的是皮肤病灶的二维图片,不是X光片或者CT影像。图片来源可能是手机拍摄、皮肤科内镜、甚至翻拍的教科书图片,这就决定了前期预处理必须足够稳健。
- 输出是什么?最基础的做法是一个多分类头,输出每个类别的概率。但真正到了医学场景,大家更关心"这个东西到底是不是猴痘",所以二分类思路(猴痘/其他)也很常用。我最后按四分类来做,因为这样更能体现模型对相似疾病的区分能力。
- 衡量标准是什么?题目没写,但医学图像方向非常忌讳只看准确率。如果一类样本特别多,模型全预测成这一类也能有很高的准确率,这没有实际意义。所以我在评估阶段重点看敏感性(召回率)和特异性,后面会详细讲。
1.2 难点真的在模型吗?
我的结论是,难点不在模型,而在于"怎么让模型学会置信度"。
什么叫学会置信度?简单说,就是模型面对一张模糊的、类间差异极小的图片时,能给出合理的低置信度,而不是硬着头皮输出一个错误的高置信度结果。猴痘皮损早期和带状疱疹、水痘在外观上非常像,都是红斑、丘疹、水疱,人眼都容易搞混,模型如果没有见过足够多样的同类图片,就会在决策边界附近乱猜。
整个第P4周做下来,我最大的体会是:平时做分类题可以靠运气,但这个项目靠运气会翻车。因为数据集小,你必须关注模型在哪里犹豫、把哪两类混淆了、以及不同类别分别误判了多少。这也是为什么我这一周花在数据清洗和评估上的时间,比调模型架构的时间还要多。
2. 数据准备:公开数据集不是拿来就能用的
2.1 数据集的常规构成
我没有找到官方指定的数据集,就按常见实践选了一个公开的皮肤病灶图像集合。这类集合一般会按类别整理成文件夹,每张图片的文件名里通常包含来源编号、患者编号、拍摄时间等信息。拿到数据后我做的第一件事不是写模型,而是先统计类别数量和每个类别的样本数。这一步非常重要,因为类别不平衡会直接决定后续采样策略和损失函数。
我遇到的情况是:猴痘样本约四百张,水痘样本约两百张,带状疱疹约两百张,其他皮肤病变约三百张,整体规模刚过一千张。对于深度学习来说,这个量级非常小。如果没有预训练模型,从头训练一个小网络都容易过拟合;用了预训练模型,也要非常小心地处理数据增强和正则化。先不管模型,把数据分布摸清楚,后面所有决策才有依据。
2.2 预处理三步:归一化、尺寸统一、数据增强
这一步很多人会直接抄代码,但关键是知道每一步在解决什么问题。我的处理流程分三步。
第一步是图像尺寸统一。原始图片分辨率从几百像素到几千像素都有,我统一缩放到224x224。这个分辨率是很多预训练模型的默认输入,再说大一点虽然信息更多,但显存占用和训练时间都会上升,对于这个体量的数据并不划算。缩放的时候要注意保持宽高比,先把短边缩到目标尺寸,再中心裁剪,这样可以避免图像变形。
第二步是归一化。不管是ResNet还是Vision Transformer,训练时都假设输入像素分布接近正态分布。PyTorch里常用的均值是[0.485, 0.456, 0.406],标准差是[0.229, 0.224, 0.225],这三个数来自通用视觉预训练任务。如果不用这个归一化,预训练权重迁移过来后,模型第一层就会面对完全陌生的输入分布,效果会打折扣。
第三步是数据增强。我做的不算激进,只用了随机水平翻转、随机旋转、轻微的颜色抖动。为什么要做增强?因为医学图像数据量少,而模型又非常擅长"死记硬背"。增强相当于在训练时不断给模型看同一张图的微小变体,让它不能靠像素级记忆来区分类别。旋转角度不要太大,皮肤病灶图像旋转15度以内是合理的,超过30度会让病灶形态失真。
from torchvision import transforms train_transforms = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ])验证集上不应该加随机增强,只做尺寸调整和归一化。否则每次验证的输入分布都会随机变化,模型的指标数字上下浮动太大,很难判断到底是模型变好了,还是这次随机增强碰巧更简单。
2.3 划分训练集时别把同源图片泄露到验证集
这是我在医学图像项目里踩过最深的一个坑,花了一整天才发现。这个皮肤数据集的文件夹结构里,同一个患者的照片通常排在一起,文件名的开头有相同的患者编号。如果你不做处理,直接按目录顺序切分,同一个患者的图片可能一部分进了训练集,一部分进了验证集。等到训练结束后,模型在验证集上表现好得不可思议,但换到真正的新患者图片上却一塌糊涂。原因是模型已经把同一个人的肤色、背景、拍摄设备特征都记住了,它是在"识别同一个患者",而不是在"识别疾病"。
正确的做法是按患者编号分组,把同一个患者的图片全部放到同一个集合里,再做随机切分。我采用的是约80%患者进入训练集,20%患者进入验证集,并且保证验证集里不出现训练集出现过的任何患者。这种划分方式会让验证指标显得比"乱切分"时低一些,但它才反映真实场景中的表现。如果做不到按患者分组,至少要检查文件名前缀或元数据,手动构造分组策略。很多公开竞赛数据集的标签文件里其实有这些信息,只是需要你多看一眼。
另外,建议把一部分训练图片单独留出来作为"辅助训练集",用来做简单的模型检查,这样可以在正式训练前快速发现代码bug,不用动最终的验证集。算是训练过程中的小技巧。
3. 模型选型:从ResNet到ViT,我最后选了哪个
3.1 为什么必须迁移学习
在一千来张图片上从零训练任何网络,结果都很难好。因为图像分类模型的参数量动辄几百万甚至上亿,而样本量只有几千,模型有足够能力记住所有训练样本,自然没有泛化能力。医学图像项目里,迁移学习几乎是必选方案。
迁移学习的本质是:模型的前几层已经在一个非常大、非常多样的图像数据集上学到了通用的边缘、纹理、形状等高阶特征,我们只需要在它后面接一个小分类头,用医学数据微调。这个过程像是让一位见过无数自然图片的"视觉专家"再到皮肤科病房轮转,他不认识猴痘没关系,但他知道怎么看纹理、怎么聚焦病灶区域,只需要少量病例就能快速上手。
3.2 用PyTorch加载预训练模型并替换分类头
我最终选的是ResNet50作为基线模型。原因有两个:一是它结构稳定,在各类图像任务上都有大量验证先例,不容易出奇怪问题;二是它的计算量适中,用普通的单张GPU就能训练,调试起来没有负担。后面我也试了EfficientNet-B4和Vision Transformer(ViT),但那是后期对比的内容,第一版能跑通才是关键。
PyTorch里加载预训练模型特别简单。需要注意的一点是,替换分类头时要保证输入特征维度和自己的类别数匹配。ResNet50最后的全连接层输入是2048维,我把它覆盖为一个新的全连接层,输出4类。
import torchvision.models as models model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2) in_features = model.fc.in_features model.fc = torch.nn.Linear(in_features, 4)不要忘记冻结前面几层的权重吗?我的做法是前40个epoch全部层一起训练,但学习率整体调小。也有人喜欢一个三阶段策略:先冻结主干只训练分类头,再解冻最后几层,最后全量微调。在小数据集上全量微调和阶段训练差别不算太大,但全量微调时要格外小心学习率过大把预训练权重冲毁。
3.3 ResNet、EfficientNet、ViT的取舍
为了方便自己比较,我做了个简单的表格:
| 模型 | 输入分辨率 | 参数量 | 训练时间(约) | 验证准确率(约) | 结论 |
|---|---|---|---|---|---|
| ResNet50 | 224 | 2500万 | 快 | 86.4% | 基线稳定,后续全靠它判断问题 |
| EfficientNet-B4 | 380 | 1900万 | 中 | 87.1% | 精度略高,但对增强更敏感 |
| ViT-B/16 | 224 | 8600万 | 慢 | 85.2% | 训练需要更多数据和更长时间,优先级低 |
这个对比不是严格的学术对比,只是我在这份小数据集上的直观感受。ViT在小规模数据上并没有展现出比CNN更强的能力,反而因为缺少归纳偏置,需要更多的训练技巧。如果数据量超过两万张,我会认真考虑ViT;但在一千张图片的规模下,ResNet系列是更稳妥的选择。
4. 训练环节:真正花时间的不是跑模型,而是防过拟合
4.1 损失函数和优化器怎么配
多分类任务最常用的是交叉熵损失,PyTorch里调用torch.nn.CrossEntropyLoss()即可。这个损失函数自带softmax,不需要在模型最后额外接softmax。注意,类别不平衡会直接影响损失值,尤其是那些样本很少的类别。我的数据里水痘样本只有两百张,占整体两成不到,如果不做任何处理,模型对水痘类别的学习会被其他大类淹没。
处理方式有两种,我两个都试过。第一种是给损失函数传入每个类别的权重,属于少数类别的样本被赋予更高的权重,训练时计算出的loss会更大,梯度更新也会更剧烈。第二种是用WeightedRandomSampler,然后让每个batch抽取各个类别时尽量均衡。我最后用的是类别权重的方式,因为它改动小,而且和预训练权重混着用的时候更稳定。
优化器我用的是AdamW,初始学习率2e-4。很多教程喜欢用1e-3,但在迁移学习场景下,这个学习率偏大,直接把预训练权重"冲"得乱七八糟。学习率选择上有个比较实用的经验:先跑两个epoch看看训练loss变化,如果从第一个epoch开始loss就直接涨,说明学习率太大;如果loss几乎不降,说明学习率太小。2e-4对于ResNet50微调来说是一个比较安全的中间值。
4.2 学习率、批次大小、训练周期建议
批次大小我用了32,这是显存限制下的折中。批次太小会导致梯度过山车式震荡,太大又会让每个epoch更新次数太少,训练时间翻倍。对医学图像这类数据来说,一般不需要特别大的batch,32或64已经够用。如果你有更大的显存,可以用64,但学习率要相应调大一点,否则收敛速度会变慢。
训练周期方面,我在第P4周的前几天跑了50个epoch,后面加到了100个epoch。在小数据集上,超过50个epoch后验证loss很容易开始反弹,因为模型开始过度拟合训练数据。我用了余弦退火学习率调度器,让学习率随着epoch逐步下降,这样后期模型能够在最优解附近精调,而不是在一个固定学习率下反复震荡。
4.3 验证策略与早停
早停是我每个项目都会写的代码逻辑。我在保存模型时并不指定"每10个epoch保存一次",而是根据验证集上的损失或者召回率指标,每次都判断当前是否比之前所有epoch都要好,如果是才保存。这样跑完整个训练流程后,会自动留下表现最好的那个权重文件。
best_val = 0.0 for epoch in range(num_epochs): train_loss = train_one_epoch(...) val_acc, val_recall = validate(...) current_score = val_recall # 医学任务可以选用召回率作为主指标 if current_score > best_val: best_val = current_score torch.save(model.state_dict(), "best_model.pt") print(f"Epoch {epoch}: saved new best model, recall={val_recall:.4f}")这段代码里我故意用召回率作为早停指标,而不是准确率。原因是这里想强调医学场景中"别漏诊"比"别误诊"更重要,后面的评估也会围绕这个思路展开。
5. 评估结果:准确率之外,医学项目更看重召回率
5.1 核心评估指标与计算方法
我最终训练出来的模型在验证集上大概有86%的准确率。但直接汇报准确率并不完整,我用sklearn.metrics.classification_report看了每个类别的精确率、召回率和F1分数。简单解释一下:
- 精确率(Precision):预测为猴痘的图片中,真正是猴痘的比例。
- 召回率(Recall):所有真实猴痘图片里,模型成功揪出多少比例。
- F1分数:精确率和召回率的调和平均数。
在医学场景里,漏诊的代价通常比误诊更大。如果一个模型说"不是猴痘",而实际是猴痘,患者可能会错过早期干预;但如果说"疑似猴痘"而实际不是,至少还会进入复查流程。所以在多分类时,我优先看猴痘类别的召回率是否够高。
计算出来的结果大概是这样的:
| 类别 | 精确率 | 召回率 | F1 |
|---|---|---|---|
| 猴痘 | 0.90 | 0.83 | 0.86 |
| 水痘 | 0.76 | 0.81 | 0.78 |
| 带状疱疹 | 0.84 | 0.79 | 0.81 |
| 其他皮肤病变 | 0.85 | 0.91 | 0.88 |
猴痘的召回率0.83,意味着17%的猴痘图片被漏掉,这个数字在真实医疗场景中是不能接受的。我通过看错误样本发现,很多漏掉的猴痘图片都出现了强烈的背景干扰,比如手指遮挡、光照过暗、或者皮肤上还有药膏残留。这类图片人眼都难判断,模型的犹豫不算离谱,但它提醒我要对数据质量提出更高要求。
5.2 混淆矩阵和错误案例复盘
我把验证集上的预测结果生成了混淆矩阵,发现两类错误最多。
第一类是猴痘和水痘互相混淆。这非常合理,因为早期猴痘和水痘都是红色丘疹、水疱,差别往往在数量、分布位置和演变速度上,单张静态图片给的信息不够。第二类是水痘和带状疱疹混淆。这两种病本身就是由同一类病毒引起的不同表现,对模型来说特征重叠严重,误判几乎是必然的。
针对混淆问题,我做了两件小事。一是把混淆样本可视化,按文件名找到原始图片,放在一张大图旁边标上真实标签和预测标签,肉眼检查哪些是模型问题、哪些是标注本身有问题。这一步很有价值,因为在数据集里我也发现了大约三到五张疑似标注错误的图片,它们被放错了类别。二是尝试只在"猴痘 vs 非猴痘"的二分类场景下重新评估模型,召回率一下子提升到了0.91。这说明多分类任务和医学实际决策逻辑其实是不同的,如果产品侧只关心"是否猴痘",用一个专门的二分类器可能更合理。
5.3 效果总结表
我不喜欢只给一个"86%准确率"就收工。把训练过程中的关键配置和结果汇总成一张表,方便自己复盘,也方便别人参考:
| 项目 | 具体配置 | 结果/备注 |
|---|---|---|
| 输入尺寸 | 224x224 | 所有图片统一缩放 |
| 数据划分 | 按患者分组,80/20 | 防止同源数据泄露 |
| 模型 | ResNet50 + 预训练权重 | 微调全层 |
| 优化器 | AdamW,lr=2e-4 | 余弦退火 |
| 损失函数 | 交叉熵 + 类别权重 | 少数类权重更高 |
| 训练配置 | 100 epochs,batch size 32 | 早停保存最佳模型 |
| 最终结果 | 验证准确率86.4%,猴痘召回率0.83 | 多在混淆样本上改进 |
这张表不仅记录了结果,也记录了关键决策。下次换数据集时,我可以从这张表出发快速定位哪个环节需要调整,大大减少重复实验的时间。
6. 踩坑记录与实用建议
6.1 这周遇到的最典型的四个问题
第一个坑是类别权重设置不当导致训练阶段指标虚高。我一开始把少数类权重调得过大,结果训练集上水痘类别的loss非常低,但在验证集上并没有获得相应提升。后来我才明白,权重只是放大了梯度信号,不代表模型真的学到了可泛化的特征,权重不能超过3倍,否则效果适得其反。
第二个坑是数据增强里加了随机裁剪。我原本想通过随机裁剪增加多样性,结果把皮肤病灶的关键区域裁掉了大半,模型反而学会通过"皮肤纹理均匀性"去猜类别。医用图像和自然图像不一样,病灶区域通常很小且位置不固定,随机裁剪很容易翻车,所以我最后只做了保留宽高比的随机缩放加水平翻转。
第三个坑是验证集的标准不一致。我最早用训练集的pipeline去处理验证集,把旋转和色彩抖动也带进去了,导致每次验证结果波动非常大,一会86%一会83%。后来我单独定义了验证用的transform,把随机操作全部去掉,指标才稳定下来。
第四个坑是显存不足。ResNet50 + batch size 32一般没问题,但当我切到EfficientNet-B4并配合380分辨率时,显存直接爆掉。我改成了梯度累积,每4个batch累积一次梯度,才勉强跑完对比实验。这个方案能够模拟更大的batch size,但会增加总训练时间,实际项目里如果显存不够,我建议优先降低输入分辨率,而不是硬上梯度累积。
6.2 下周继续优化的方向
下周我打算从两个方向继续推进。第一,尝试用一组针对性更强的数据增强策略,比如轻微色彩增强加上病灶区域的随机遮挡,模拟真实拍摄中被手指或镜头阴影干扰的情况,让模型对遮挡不敏感。第二,在决策层加入置信度阈值,当模型预测概率低于0.6时直接返回"不确定,需要人工复核",这比只输出一个类别更符合医学场景。
关于这个项目,我个人最大的收获不是模型准确率刷到多少,而是意识到一个好的AI识别项目必须有完整的数据意识和评估意识。遇到类似"猴痘病识别"这种题目时,不要满脑子想着换个更强的模型,先把数据分布、划分逻辑、指标定义和错误案例搞清楚,模型自然会被这些前置工作推着往前走。