猴痘病识别实战:小样本医学图像分类的迁移学习与两阶段微调策略
最近在跑一个皮肤影像分类的实战项目主题是猴痘病识别。任务本身听起来挺简单——给模型一张皮肤病灶图让它输出是猴痘还是其他皮肤病属于典型的细粒度医学图像分类问题。可真把流程完整走一遍从数据清洗、增强策略、迁移学习再到指标评估每一步都有值得琢磨的细节甚至有几个环节让我返工了不止一次。这个项目是系列实战练习里的P4周前面几周做过自然图像分类、垃圾图片识别、遥感影像标注之类的入门任务到这一周难度明显上了一个台阶。为什么因为医学图像分类和普通分类完全是两个世界数据量小、类别相似度高、图像噪声大、还牵扯到隐私问题。你拿ImageNet上训练好的模型直接过来推理效果往往很差但真要你从零训练一个网络数据集又远远不够。这里面怎么平衡、怎么设计训练策略就是这篇文章想聊清楚的东西。文章适合谁看如果你正在做图像分类相关的项目尤其是医学影像、皮肤病理、农业病害这类小样本细粒度识别场景这篇内容可以直接复用。里面涉及的数据增强策略、两阶段微调方案、混淆矩阵分析思路都是经过真实项目验证过的做法。我会把代码、参数、踩坑过程都放出来你照着改改就能用到自己的数据上。1. 从任务目标到数据认知猴痘病识别到底难在哪1.1 业务场景给模型一张皮肤照片它要给出什么结论先把这个任务放在真实场景里看。传统诊断流程里医生需要靠肉眼观察皮损形态、分布位置结合接触史和实验室检测才能确诊。但在疫情筛查阶段大量初诊病例积压医生不可能把所有时间都花在重复阅片上。这时候一个能自动对皮肤照片做初步分类的模型就能帮上忙它把疑似猴痘的样本优先挑出来让医生把精力集中在高风险人群上。所以这个项目的实际价值不在于替代医生而在于做预筛和分流。模型面对一张皮肤照片输出三类结论之一猴痘病变、其他皮肤病变、正常皮肤。看清楚是三分类不是二分类。很多人第一反应是就判断是不是猴痘呗分两类不就行了但真落到临床场景只做二分类意义不大。因为实际门诊来的病人大部分不是正常皮肤而是带着各种皮疹、水疱、过敏反应来的。如果只训练猴痘 vs 正常模型见到一个水痘患者大概率会把它判成猴痘——因为水痘和猴痘的视觉特征太接近了而模型从来没学过其他皮肤病长什么样。这就逼着我们必须引入第三类让模型学会区分猴痘和它最相似的混淆类别。1.2 数据集特征小样本、高相似、强噪声看数据之前先泼盆冷水公开的猴痘皮肤图像数据集数量通常只有几千张很多是网络爬虫抓来的来源五花八门。这意味着三个现实问题。第一数据量不够从零训练一个深度网络。ResNet50这种模型光参数就有2550万你给它喂几千张图训练集准确率能轻松到99%验证集却可能一直卡在80%上下这就是典型的过拟合。第二图像质量参差不齐。手机拍摄的照片有的人离得远有的人离得近有室内光有室外光有的还带毛发遮挡模型很容易抓住这些和疾病无关的噪声特征。第三最要命的是类别间相似度极高猴痘早期的红色斑疹和麻疹、水痘几乎一模一样连医生都要结合病程和接触史判断单靠静态图像让模型硬分上限就摆在那里。正因为这些特点P4周这个项目真正训练的其实不是怎么搭一个分类网络而是怎么在小样本高混淆的医学图像上把迁移学习的优势全部榨出来。1.3 数据划分的一个隐藏陷阱公开数据集内部往往隐藏着数据泄漏风险。我当时拿到数据后第一版划分方式很粗暴——直接把所有图片随机打散按8:1:1切分成训练、验证、测试集。结果测试集准确率漂亮得吓人96%。可我仔细一看数据来源文件发现很多图片其实来自同一组病例同一个人的不同角度皮损照片被随机分到了训练集和测试集里。这就意味着模型可能记住了这个人皮肤的背景色、纹理特征而不是真正学会了识别猴痘的病理特征。测试时遇见同一个人的不同照片相当于直接开卷考试。后来我把数据按病例来源分组保证同一个人的所有照片只出现在一个集合里测试集准确率立刻掉到90%左右。这才是真实水平。这个教训很重要医学图像分类里如果你的数据集中同一患者的多张图像混在训练集和测试集两边评估结果会虚高落地时模型表现断崖式下跌。按来源分组划分应该作为医学影像项目的默认操作。2. 模型选型与训练设计迁移学习为什么是首选2.1 为什么不从零训练也不直接拿预训练模型硬推理图像分类项目的第一个选择题往往就是模型怎么来从零训练、微调预训练模型、还是完全冻结主干只换分类头先说从零训练。ImageNet有128万张图、1000个类别ResNet50还花了120个epoch才收敛到75%左右的Top-1准确率。而我的手头猴痘数据集满打满算几千张从零开始训练一个深度CNN唯一的结局就是严重过拟合。你要么把网络做得非常浅非常小但浅层网络的特征表达力又不够做细粒度分类两头难。就算加上粗暴的数据增强本质上也解决不了数据分布覆盖不足的问题。再说直接拿预训练模型硬推理。ImageNet的1000个类别里有猴子、有猕猴没有猴痘模型对皮肤病变区域这个概念根本没有认知直接推理的结果基本等于随机猜输出概率分布毫无区分度。那迁移学习为什么是正解因为卷积神经网络的特征提取是分层递进的浅层学到的是边缘、纹理、颜色块这类通用低级特征中层学到的是形状、局部模式深层才学到类别专属的语义特征。ImageNet预训练模型已经在百万级通用图像上学好了浅层和中层特征这些特征对医学图像同样适用——皮肤病灶的边缘、水疱的纹理、红斑的色泽本质上都是边缘和纹理的组合。我们要做的只是把深层特征适配到猴痘这个新任务上。2.2 骨干网络对比与选型逻辑既然确定用迁移学习下一个问题就是选哪个预训练骨干网络作为特征提取器。我把当时对比的几个模型列了个表参数都不是我瞎写的是实际实验跑出来的感受骨干网络参数量推理速度在小样本医学数据上的表现适合场景ResNet5025.6M中稳定最容易调出好效果通用首选VGG16138M慢效果不错但显存占用大不推荐新项目使用EfficientNetB05.3M快精度不低但训练对超参敏感需要极致效率时考虑MobileNetV34.2M最快精度略低端侧部署场景为什么最终选了ResNet50三个原因。第一是它的残差连接结构在微调时特别友好。深层的梯度能通过捷径连接直接传回浅层不容易出现梯度消失。第二是它中间层输出的特征图分辨率适中既能保留病灶的局部细节又不会像EfficientNet那样在训练初期对学习率极其敏感。第三是我对ResNet系列的调参经验最丰富P4周这个时间节点上稳定可控比极限性能更重要。如果你后续想把模型部署到手机端切到MobileNetV3再跑一遍同样的两阶段流程就行精度会掉一个点左右但速度能快好几倍。2.3 类别设计的取舍差一个类别训练难度完全不同上文说过这个项目选的是三分类猴痘、其他皮肤病变、正常皮肤。这个选择不是随意决定的它对整个训练难度有决定性影响。如果只做二分类猴痘 vs 正常皮肤数据里每一种非猴痘的图片都是负样本模型很容易学会有异常就是猴痘这种粗糙的决策边界。一旦真到了临床场景遇到水痘、带状疱疹、过敏性皮炎模型全都会往猴痘那边偏误报率会高到让人无法接受。加上其他皮肤病变这个类别之后模型被迫去学习猴痘区别于水痘、区别于疱疹、区别于普通皮疹的判别性特征。这是细粒度分类的核心思想——难点不在有没有病而在是什么病。训练难度确实变大了验证准确率会比二分类低三到五个百分点但这是用精度换实用性值得。3. 两阶段微调冻结骨干网络与解冻精修的完整流程3.1 数据增强策略有限数据下的第一道防线小样本项目的第一道防线永远是数据增强。我当时的数据增强管线分两条路走离线清洗时统一做尺寸归一化在线训练时做随机扰动。在线增强我用了这样一组参数from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(15), 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] ) ])这里几个我自己踩过坑的经验。RandomResizedCrop的scale参数我建议控制在0.8到1.0之间不要用ImageNet分类常用的0.08到1.0。因为医学病灶是有实际尺寸意义的缩放太狠会让模型学到错误的尺度信息比如把一颗小痣放大成一个巨大的溃烂面这种增强对模型训练不但没帮助还会干扰它学习真实的病灶形态。RandomRotation我限制在15度以内。皮肤照片虽然有旋转角度差异但旋转过大生成的训练样本在真实世界里根本不存在反而增加了模型的不确定性。ColorJitter我特意把hue调得很小只动了0.05。原因很简单皮肤病变的颜色是重要诊断线索色相偏移太大会让红疹变成蓝疹等于给模型喂错误标签。验证集的transform只做resize和归一化不做任何随机增强。这一点我见过不少新手搞混在验证集上加了随机翻转结果指标忽高忽低永远看不到真实水平。3.2 模型构建替换分类头并冻结参数模型构建这一步核心工作是用预训练权重初始化骨干网络把最后的全连接层换成适配三分类任务的新分类头。import torch import torch.nn as nn from torchvision import models model models.resnet50(weightsmodels.ResNet50_Weights.IMAGENET1K_V1) # 替换最后一层全连接层适配3分类任务 num_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(num_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, 3) ) # 第一阶段冻结所有骨干网络参数 for param in model.parameters(): param.requires_grad False # 只允许分类头参数更新 for param in model.fc.parameters(): param.requires_grad True这里分类头我用了两层全连接结构而不是简单的单层替换原因是单层全连接在特征维度为2048时容量有限不足以拟合三分类的决策边界。加了两个Dropout层0.3的丢弃率在小样本场景下能明显抑制分类头的过拟合。关于冻结策略可以把骨干网络想象成一个已经熟练掌握通用视觉特征的学生分类头是一个刚入门的新生。第一阶段让新生先听课做题老生别插手等新生水平上来了再让老生也参与讨论一起适应新题型。3.3 两阶段训练先训头部再解冻精修训练策略我拆成了两段这也是整个项目里最关键的部分。第一阶段冻结骨干网络全部参数只训练新加的分类头优化器用AdamW学习率定在1e-3跑10个epoch。这个阶段的目标不是追求高准确率而是让随机初始化的分类头先在固定特征上收敛把骨干网络ReLU之前输出的特征分布摸个底。第二阶段解冻骨干网络最后一部分参数。我当时解冻了第三个残差块之后的全部层包括stage4和stage3的后半段把学习率降到1e-5再跑15个epoch。为什么学习率要从1e-3直接降到1e-5这个数值选择背后的逻辑值得说清楚。ImageNet上预训练的特征是通用特征微调的本质是在通用特征基础上做局部调整。如果学习率设得太高比如沿用1e-3梯度更新过大会把预训练学到的通用特征彻底冲掉。在小样本数据集上这种破坏是不可逆的模型会快速过拟合到训练集的几百张图上验证准确率不升反降。而1e-5这个量级相当于用极小的步长在预训练权重附近做精细搜索既保留了通用特征又让深层特征逐渐适应皮肤病灶的分布。整个训练过程我用了一个很简单的早停机制连续5个epoch验证集F1没有提升就停止训练同时保存验证集F1最高的那轮权重作为最终模型。训练过程代码大致这样from torch.optim import AdamW # 第一阶段只训练分类头 optimizer AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-3, weight_decay1e-4 ) for epoch in range(10): train_one_epoch(model, train_loader, optimizer, criterion) # 第二阶段解冻部分骨干网络降低学习率 for name, param in model.named_parameters(): if layer4 in name or layer3.3 in name or layer3.4 in name or layer3.5 in name: param.requires_grad True else: param.requires_grad False optimizer AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr1e-5, weight_decay1e-4 ) for epoch in range(15): train_one_epoch(model, train_loader, optimizer, criterion) val_f1 evaluate(model, val_loader) if val_f1 best_f1: best_f1 val_f1 torch.save(model.state_dict(), best_model.pth)实测下来第一阶段结束时验证集准确率大概在86%左右第二阶段跑到第6个epoch时验证集F1开始明显上升最终稳定在93%上下。这个曲线非常有代表性——先快速起步然后平台期最后小幅跃升。4. 训练结果复盘评估指标、混淆矩阵与可视化4.1 评估指标选择准确率之外还必须看召回率和F1分类项目最忌讳只看准确率。在医学辅助筛查场景里漏诊一个猴痘患者的代价远远高于误报一次所以召回率Recall比精确率Precision更值得关注。如果模型在100个真实猴痘样本里只识别出80个那20个漏掉的患者就是潜在传染源。最终我在测试集上得到的结果是这样的类别精确率召回率F1-score样本数猴痘病变0.940.890.92120其他皮肤病变0.910.960.93150正常皮肤0.980.940.96100整体准确率94%加权F1 0.94Macro F1 0.93。看这个表里的信息量不能只盯着94%这个数字。猴痘类的召回率是0.89意味着仍有11%的真猴痘样本被漏掉了。再看混淆矩阵漏掉的大多是被分到了其他皮肤病变这一类只有极少数被分到了正常皮肤。这个错误方向其实是符合预期的——猴痘早期病变和某些水疱性疾病视觉相似度太高模型判断不了太正常了。但反过来看被漏掉的那些样本是什么情况我把图片一张张翻出来发现一个共同特点病变区域占比非常小在整张224x224的图片里只占不到十分之一。这说明模型不是学不会猴痘特征而是注意力没有被引导到病灶区域上。4.2 Grad-CAM可视化看模型到底在看哪里为了验证这个猜想我用Grad-CAM把模型最后一层卷积的注意力热力图叠加到原始图像上。结果很清楚对于判断正确的猴痘样本热力图集中在病灶中央区域围绕水疱边缘有一圈高亮的响应区域。这说明模型学到的确实是病灶本身的形态特征。对于判断错误的猴痘样本热力图分布非常散乱有的集中在皮肤纹理上有的甚至跑到图片角落的背景区域。有一个典型的误判样本病灶在皮肤褶皱处阴影特别重热力图直接失效模型把大片阴影区域当成了关键特征。这就是小样本医学图像分类的经典痛点模型不是不想学病灶而是数据里病灶清晰、光照正常的干净样本太少学了半天还是被背景干扰带偏了。这个问题光靠调参解决不了最有效的办法是训练前做一次目标检测定位病灶将病灶区域裁剪放大后再送入分类模型。这一步能显著提升小病灶样本的识别率我后续测试也验证了这个判断。4.3 训练曲线解读两阶段特征变化带来的启发我记录了每个epoch的loss和F1曲线两阶段的差异非常有意思。第一阶段loss下降速度极快第一个epoch结束loss就从一个较高的初始值直接掉了下来但验证F1一直卡在85%到87%之间不涨。原因在于分类头在快速拟合特征分布的同时骨干网络是冻结的它输出的特征本身并没有适配猴痘任务所以性能上限被锁死了。第二阶段一解冻前两个epoch验证F1反而小幅下降。这个现象其实正常解冻后骨干网络的权重开始被梯度修改特征分布发生偏移之前训练好的分类头对新特征分布一时不适应。到第3个epoch开始F1重新回升并迅速突破90%。如果你在微调时也看到这种V型曲线不要慌继续跑就对了。这种冻结到解冻过程中的特征偏移可以类比成一个装修师傅先按旧户型图纸配好了家具尺寸突然改动墙体后家具需要重新调整位置一开始摆放自然会有短暂的混乱。5. 踩坑实录类别不平衡、过拟合与部署细节5.1 类别不平衡加权采样解决正常皮肤压制问题原始数据的类别分布并不均匀正常皮肤图片接近800张猴痘病变只有400张出头其他皮肤病变500张左右。如果不做任何处理模型会倾向于把一切不确定样本都判成样本量最多的类别表现在混淆矩阵里就是猴痘类召回率被拉低。解决办法我试了两种。第一种是给损失函数加类别权重让少数类的梯度贡献更大。交叉熵损失的weight参数按类别样本数的反比设置就好这个方案实现简单但训练时可能出现少数类f1虚高而多数类f1下降的副作用。第二种是采用WeightedRandomSampler在采样阶段让每个batch里不同类别的样本数量相对均衡。我最终用的是第二种原因是我可以在保持损失函数干净的前提下直接控制每个batch的类别构成。采样器实现代码如下from torch.utils.data import WeightedRandomSampler labels [int(sample[1]) for sample in dataset] class_counts torch.bincount(torch.tensor(labels)) class_weights 1.0 / class_counts.float() sample_weights class_weights[labels] sampler WeightedRandomSampler( sample_weights, num_sampleslen(sample_weights), replacementTrue )用上采样器之后猴痘类的召回率从0.83提升到0.89同时其他两个类的指标没有明显回退。值得提醒的是replacementTrue意味着同一个样本可能在同一个epoch里被反复抽到这时候随机数据增强就特别重要——它能让同一个样本每次以不同的形态出现降低反复采样带来的过拟合风险。5.2 过拟合排查从准确率虚高到验证集停滞项目过程中我遇到过最典型的问题训练集准确率一路飙升到99.5%但验证集准确率始终徘徊在90%上下怎么都上不去。这就是标准的过拟合信号。我的排查顺序是这样的。先查数据泄漏。按患者ID分组划分之后问题依然存在。再查增强策略。我当时一度怀疑增强强度不够试过把rotation加到45度、scale下限降到0.5结果验证集F1反而掉了两个点。最后在分类头里加Dropout层同时给全连接层加L2正则验证集F1提升了一个点左右但不够解渴。真正让验证集F1跃升到93%的操作是降低第二阶段学习率。我从1e-4降到1e-5后验证曲线开始紧贴训练曲线差距从9个百分点缩小到4个百分点。解决办法比我预想的要朴素得多——微调阶段学习率太高模型已经钻进了训练集的牛角尖里出不来。5.3 推理阶段细节ONNX导出与多次推理平均训练完成后还要考虑实际使用。PyTorch模型直接部署会遇到环境依赖问题目标机器得装同样版本的PyTorch和CUDA这在生产环境是件痛苦的事。我当时把模型导出成了ONNX格式推理时用ONNX Runtime加载部署环境瞬间清爽了许多。import torch model.load_state_dict(torch.load(best_model.pth, map_locationcpu)) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, monkeypox_model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}} )推理阶段还有个细节单张图片的预测结果受增强随机性影响很小因为推理时已经关了随机增强但模型本身的Dropout层在eval模式下也会关闭所以输出应该是稳定的。如果担心极端情况下的抖动可以连续推理三次取softmax概率的平均值再取argmax实测可以把极少数置信度在0.5边缘的样本稳定下来。5.4 如果扩展成多类别皮肤病变识别猴痘病识别做顺了之后很自然能想到扩展成更通用的皮肤病分类系统。思路和数据准备逻辑是完全一致的收集更多类别的皮肤图像按病变类型建目录做来源分组划分沿用两阶段微调管线。唯一的差异在于类别增加到十几种之后类别间的混淆会变得更加复杂混淆矩阵里会出现更多的非相邻错误。这时候可以考虑用层次分类——先粗分成水疱性斑疹性鳞屑性几个大类再在每个大类下细分具体疾病。层次分类的好处是让模型在每一层只需做少量类别的区分错误率天然比一个十几分类的扁平模型低。这个方向我准备在P5周试试到时候有了新结果再回来更新。写在最后的小心得这个项目做下来最深的体会是图像分类项目的天花板大部分时候不是模型结构决定的而是对数据的理解深度决定的。一开始我也想着换更花哨的模型试过EfficientNet试过加SE模块效果都不如老老实实解决数据泄漏和类别不平衡问题来得实在。后来把按患者分组划分、两阶段微调、保守数据增强这几件事全部做对之后不用换模型测试集F1照样从90%升到了94%。另外一个小建议所有涉及医学图像的项目在公布评估指标时一定要附上混淆矩阵和数据划分方式。准确率这个数字太容易被玩坏了只有看到混淆矩阵和划分逻辑别人才能真正判断你的模型水平是真实能力还是泄漏出来的幻觉。如果你也在做类似的细粒度图像分类项目希望这篇经验帖能帮你少走那些我走过的弯路。特别是数据分组划分这一步务必放在所有实验的最前面否则后面所有调参和优化都是在沙地上建高楼。