皮肤数据分析这行,我见过的最常见误区就是:拿到一份数据集,连图像是什么拍的、标注怎么来的都没摸清,就直接上预训练模型微调,最后跑出一堆“看起来很高”的指标,一换环境就露馅。我之前做的一个皮肤影像分析项目,就是典型的数据驱动案例——两万多张皮肤镜图像,十二类常见皮损分类,目标很朴素:先搭建一套可复现的数据分析流程,再训练一个能辅助筛查的分类模型。它解决的并不是“研发新算法”这种前沿问题,而是大多数实践者真正缺的那一环:把杂乱、不平衡、标注质量参差不齐的皮肤数据,变成能稳定训练和评估的标准数据集,并让模型输出有实际参考意义。这篇就把我的完整做法、踩坑过程和调参经验摊开讲,适合刚接触医疗影像分析、或者想做皮肤检测方向数据项目的同学参考。
1. 项目整体设计与数据理解
1.1 项目定位与数据组成
这个项目的原始需求来自一线皮肤科门诊辅助筛查场景。医生希望对皮肤镜图像做一个初筛分类,区分脂溢性角化、基底细胞癌、黑色素瘤、痤疮、湿疹、银屑病等十二类常见皮损,同时给出排序靠前的几个疑似类别,方便后续进一步检查。注意这里要的是“辅助筛查”,不是“自动诊断”,所以项目的评价标准不是单一准确率,而是“常见病的召回够不够高”以及“高危类别(黑色素瘤、基底细胞癌)绝不能漏”。
数据组成分成两个部分:约一万八千张来自某公开皮肤影像数据集(脱敏后的皮肤镜图像,以病变区域为中心),另外四千余张来自本地合作科室的临床影像采集。两张图放在一起,图像风格并不一致——公开数据集本身是在不同采集设备、不同放大倍数下得到的,而本地数据更偏向于标准化皮肤镜设备拍摄,分辨率高、光照也比较均匀。如果一开始不做分层处理,训练集和验证集里两类数据分布不一致,后期评估指标就会失真。
原始图像尺寸跨度很大,从 224x224 到超过两千像素的巨大图都有。因为训练阶段多数卷积网络默认输入 224x224 或 384x384,所以我统一做了等比缩放加中心裁剪,没有直接拉伸,避免病灶形态变形。对于非常大尺寸的原图,先按较短边等比例缩到 1024,再随机裁剪 224 或 384 的 patch 参与训练。这样做的原因是,皮肤病灶的形状、边界、颜色分布在不同尺度下含义不同,等比缩放能最大程度保留纹理细节,而直接 resize 会把重要特征磨掉,这一点后面模型表现差异很大。
1.2 为什么先做数据清洗而不是先选模型
很多初学者拿到项目,第一反应是“用什么网络”。我的习惯正好相反:先花一周左右把数据彻底摸一遍,因为模型选型归根结底取决于数据形态。拿这个数据集来说,最突出的三个特征是:类别极度不平衡、部分图像存在摄影质量问题、标注噪声明显。这三个问题如果不先解决,任何模型训练出来都是“假学霸”——在多数类上表现好,在真正的少数类(比如黑色素瘤,样本量只有几百张)上完全瘫掉。
数据清洗优先还有一个现实原因:皮肤镜图像往往包含镜头边缘,还有毛发、皮屑、标记墨水等干扰物。这些在视觉上很显眼,会让网络学到“毛发=某种病”这种伪相关性。我们先做一轮规则清洗和人工复核,本质上是把“网络要扛的噪声”降下来,让模型把能力用在真正区分皮损形态上。
清洗之后的类别分布参考表:
| 类别 | 清洗后样本量 | 占比 | 说明 |
|---|---|---|---|
| 痤疮 | 5200 | 23.6% | 本地门诊贡献较多 |
| 脂溢性角化 | 4800 | 21.8% | 与基底细胞癌形态易混淆 |
| 湿疹 | 2600 | 11.8% | 皮损边界模糊 |
| 银屑病 | 1800 | 8.2% | 鳞屑特征明显 |
| 基底细胞癌 | 1200 | 5.5% | 高风险,需重点召回 |
| 黑色素瘤 | 700 | 3.2% | 高风险,需重点召回 |
| 其他六类 | 5800 | 26.4% | 数量在 200~800 之间 |
这张表一列出来,就决定了后面的全部策略:不能直接用常规交叉熵硬训练,高阶类别必须设计专门的采样和损失策略。
2. 数据清洗与标注质量控制的实操细节
2.1 图像质量筛选的判定标准
图像质量问题在皮肤镜数据里非常普遍。我按照拿到一批图后的常规流程,先写了一套筛选规则,规则不追求完美,只求把明显的废图拦在训练之前。
第一类是过曝和欠曝。皮肤镜图像里,渗出的皮脂、水珠或者金属反光会造成局部高亮,RGB 三通道平均值超过 240 的大块区域占比超过阈值就判为过曝。欠曝则是大量区域亮度低于 40。我用 OpenCV 做直方图统计,先转 HSV 取 V 通道,再统计区间占比,超过 30% 就过滤掉。阈值不是拍脑袋定的,是随机抽了 200 张图人工看过后,圈定“人眼已经看不清皮损纹理”的样本,反推出来的指标区间。
第二类是失焦和运动模糊。皮肤镜设备通常有自动对焦,但拍摄时手抖或者病灶表面不平整会出现局部模糊。可以用拉普拉斯算子的方差判断整体锐度,方差低于某个阈值就淘汰。要注意的是,这个阈值对不同疾病差异很大——银屑病的鳞屑边缘天然就有较多高频信息,而色素性皮损往往边界平滑,同理的拉普拉斯方差会偏低。所以我给每类分别统计了方差分布,用 P5 分位数作为下限,而不是全局一刀切。
第三类是目标占比过小。有些图片的皮损区域只占整幅图的不到 5%。这类图放进模型里,网络大概率学到的是“周围皮肤的颜色分布”而不是病灶特征。处理方式是先用简单的颜色阈值和形态学操作提取病灶连通域,计算最小外接矩形占全图面积比,低于阈值就进“待清洗”队列。清洗队列里的图不是直接丢掉,而是人工复核后决定是否保留——有些黑色素瘤早期病灶本来就小,但形态极其典型,这种我会保留,只是训练时用高倍 crop 作为输入。
2.2 标注一致性怎么量化
标注质量是皮肤数据分析绕不开的坎。公开数据集的标签一般是多位医师投票或者病理确认过的,但本地那四千张图,是由几位住院医生按照标准图集标注的,存在明显分歧。打个比方,湿疹和接触性皮炎在早期形态上非常接近,医生 A 可能标成湿疹,医生 B 标成接触性皮炎,如果没有病理结果兜底,这其实就是主观判断差异。
我采用的办法是让全部标注在正式训练前做一轮一致性复核。流程是这样:从每个类别随机抽 30~50 张,交给一位高年资皮肤科医生重新盲评,然后计算和原始标注的 Cohen‘s Kappa 系数。Kappa 低于 0.6 的类别,就需要重新讨论标注标准,必要时修改标签。实际跑下来,银屑病和痤疮的一致性最高,Kappa 在 0.8 左右;湿疹和接触性皮炎这类形态重叠较大的类别,Kappa 只有 0.55 左右。
这里有个操作小技巧:重评时不要只给医生看“这一张是什么病”,而是要求同时给出第一诊断和第二诊断。第一诊断完全对上的算一致,第二诊断对上的可以作为软标签。软标签在训练时可以丢给损失函数做 label smoothing,相当于让模型知道“这个病确实和另一类长得像,不要学得过于绝对”。这个做法比硬改标签更符合临床真实情况,模型收敛后对易混淆类的过度自信也明显下降。
2.3 类别不均衡的可视化分析
类别不均衡不只是“样本少的类吃亏”这么简单。真正要分析清楚的是:不均衡发生在哪个层次。我这个项目里,存在两层不均衡:类别层面的样本量差异,以及图像层面的难易程度差异。
样本量差异通过有序条形图和环比占比就能看得很清楚,但难易程度差异靠的是“伪标签法”粗略评估。我用一个已在公开数据上预训练过的分类模型,先(不训练)直接对全部图像做预测,统计每类的预测置信度分布。置信度普遍偏低的类别,大概率属于“难例多”的类,例如基底细胞癌和脂溢性角化这对冤家,它们的形态学特征本来就有交叉。这个分析的意义在于:后续数据增强和采样策略,要针对这些“又少又难”的类重点倾斜,而不是简单地把少数类复制几倍。
3. 特征学习与模型搭建的选型思路
3.1 底座模型怎么选:ResNet50 和 EfficientNet 的实测对比
底座模型的选择,我先后试过 ResNet50、ResNet101、EfficientNet-B4、ConvNeXt-Tiny 四条路线。先说结论:在这个数据规模下,EfficientNet-B4 性价比最高,ResNet50 胜在稳妥。
为什么没选更大的模型?两万多张图对深度网络来说只是“小数据”,如果直接用 ImageNet 预训练权重微调,网络容量越大,越容易在训练集上把纹理噪声记住。EfficientNet-B4 相对轻量,有效感受野设计得比较好,对多尺度皮损特征(从点状丘疹到大面积斑块)都有不错的响应。模型容量上来后,Acc 确实能再涨一点,但训练时间几乎翻倍,而且需要更强的正则化才能稳住,得不偿失。
ResNet50 也不是不能用,但它对“病灶边缘的微小锯齿状结构”这种高频特征的捕捉能力弱一些。皮肤镜图像里很多关键诊断信息恰恰就在这种高频细节里。如果项目资源有限,只能用 ResNet50 打底,我会建议把输入分辨率从 224 提到 384,这个调整带来的收益比换模型更直接。
3.2 数据增强策略为什么这么配
皮肤数据的增强策略和自然图像不太一样,核心要处理的是两类问题:光照/色温漂移,以及病灶尺度的不确定性。
光照和色温处理上,我用了随机亮度扰动、对比度扰动、HSV 通道微调。注意绝对不要用过于强烈的色彩反转或色调偏移,因为皮肤病分类严重依赖颜色语义,黄褐斑的褐色、银屑病的红色、色素减退区的白色,颜色一旦被破坏,标签语义就错乱了。我用的是 ±10% 的亮度变化、±0.05 的 HSV 饱和度扰动,幅度很小,重点在模拟不同设备之间的白平衡差异。
几何增强方面,随机水平/垂直翻转、随机旋转 90 度、随机缩放(0.8 到 1.2 倍)都上了。没有用大幅度的随机裁剪,因为病灶区域可能被裁掉一部分,模型容易学到“巴掌大的局部=某种病”的错误模式。每张图在训练时动态生成增强版本,而不是离线保存,因为离线会浪费磁盘空间,而且没法根据训练进度调整增强强度。
额外补充一种皮肤领域很有用的增强:弹性形变和随机擦除。弹性形变模拟皮肤受挤压或褶皱导致的边缘形变;随机擦除模拟毛发遮挡和镜头标记墨水覆盖。两种增强在实际训练中让模型对遮挡的鲁棒性提升明显,尤其是处理那些病灶中央恰好有一根毛发穿过的图像。
3.3 损失函数与类别权重的计算
基线模型用交叉熵损失,但类不均衡下很快就发现偏向多数类。我换成带类别权重的交叉熵,权重采用“中位数频率平衡”的方式计算。公式不复杂,但背后的逻辑值得讲一下。
设第 c 类样本数为 N_c,总样本数为 N,类别数为 C。简单的中位数频率法是这样:先计算每一类的频率 p_c = N_c / N,然后取所有 p_c 的中位数 m,最后权重 w_c = m / p_c。这么做的意思是:让样本量低于中位数的类别获得大于 1 的权重,高于中位数的类别权重小于 1,但整体不至于被极端少数类过分放大。
实际计算时我加了一个防爆项,w_c 上限限制在 10 以内,不然黑色素瘤这类极少数类的 loss 波动太剧烈,几个难样本就能把梯度带偏。PyTorch 写法很简单,核心就是给 CrossEntropyLoss 传一个 weight 参数:
import torch import torch.nn as nn counts = torch.tensor([5200, 4800, 2600, 1800, 1200, 700, 5800], dtype=torch.float32) freq = counts / counts.sum() median_freq = freq.median() weights = median_freq / freq weights = torch.clamp(weights, max=10.0).to(device) criterion = nn.CrossEntropyLoss(weight=weights)运行后可以直接打印 weights 看一眼,黑色素瘤类的权重大概在 5 到 6,痤疮类只有 0.4 左右。这种相对关系会让模型在训练时把“注意力”更多地放到少数类上,但不会出现单一类别权重过高、导致多数类完全学不到的情况。
4. 训练流程与评估闭环的完整实现
4.1 按患者划分数据,别让数据泄露毁掉评估
这个坑我必须重点讲:皮肤病图像数据里,同一个患者可能贡献了多张病灶图像,甚至同一个病灶在不同角度、不同时间被拍了好几张。如果用普通随机划分,同一个患者的图像会同时出现在训练集和验证集里,模型相当于“开卷考试”,验证指标会虚高 3 到 8 个百分点不等。
我遇到的真实情况是,随机划分后验证集 AUC 达到 0.94,当时差点以为项目已经成了。后来做按患者划分后,AUC 直接跌到 0.89。这个差距完全来自数据泄露。解决办法是把同一个患者的所有图像放在同一个 fold 里,划分单位从“张”变成“人”。实现上很简单,就是把 patient_id 作为分组键做 StratifiedGroupKFold。
按患者划分之后,还要注意时间维度的泄漏。本地数据里有少量患者是复诊照片,肌肤状态有前后变化。如果早期照片在训练集、晚期照片在验证集,模型可能学会“同一患者病灶变好了/变坏了”的时序规律,这在真实筛查场景里是不存在的。稳妥做法是看病程记录,只保留每个患者某一时间点的第一张或最后一张作为样本,或者把同一个患者视为一个数据组、整体划分,不拆开。我最后选了后者,最大程度保留样本量。
4.2 训练参数与监控套路
模型训练我用的是 AdamW 优化器,初始学习率 3e-5 到 1e-4,配合余弦退火调度,warmup 从 10% 的 steps 开始。这个学习率范围是基于迁移学习经验:ImageNet 预训练权重已经处于一个不错的局部最优,微调时学习率太大容易把底层通用特征冲坏。
batch size 我定为 32,输入分辨率 384(EfficientNet-B4 下显存刚好够)。这里有个经验:batch size 如果太小(比如 8),BatchNorm 统计量不稳定,皮肤图像又存在大量类内差异,模型摆动会非常明显。如果显存不够,优先调低分辨率而不是调低 batch size,因为 224 分辨率对皮损细节的损失更大。
训练监控不能只盯训练 loss 和验证 loss,我额外记录四样东西:训练集和验证集各自的 top-1 准确率差、每类的召回率、加权宏平均 AUC、以及 Grad-CAM 热力图下的病灶定位效果。前两者负责发现过拟合,后两者负责发现“假进步”。
4.3 评估指标怎么定才贴近真实场景
分类任务的常规指标是准确率,但在类别不均衡的皮肤数据里,准确率毫无说服力。我用三层指标评估:
第一层是每类的 precision、recall、F1。重点关注黑色素瘤、基底细胞癌这两类的召回率,因为筛查场景漏检的代价远高于误报。第二层是 macro AUC 和 weighted AUC。macro AUC 对少数类一视同仁,能看出模型在整体排序能力上有没有进步。第三层是 top-2 命中率,也就是真实标签是否出现在模型预测概率最高的两个类别里。它对应临床初筛场景——医生看到模型给出的前两个疑似诊断,再结合镜下形态综合考虑。
实际操作中我把阈值也调过:默认取预测概率最高的类别作为输出,但这个项目里,黑色素瘤即使排第二,也必须以“高亮疑似”的形式提示。所以我在输出端做了个规则,黑色素瘤预测概率高于 0.15 就置为告警。这个 0.15 不是乱设的,是根据 PR 曲线找的 recall 0.9 对应的阈值。
最终验证集表现:总体准确率 84.2%,macro F1 0.79,黑色素瘤召回率 91.3%,基底细胞癌召回率 86.7%。这个结果在高危类上的表现足够支撑辅助筛查,但准确率显然还不足以直接下诊断结论。
5. 实战中遇到的坑和排查实录
5.1 模型总把基底细胞癌预测成脂溢性角化
这两个病在皮肤镜下本来就有核分裂和色素网络形态重叠,属于医学上都难以肉眼分辨的一对。模型学不好,问题通常出在训练数据里这两类的标注边界不清晰。排查时我用混淆矩阵一拉,发现 62% 的基底细胞癌错分到脂溢性角化。
解决思路不是换模型,而是细化标签。和标注医生确认后发现,基底细胞癌里有一批浅表型基底细胞癌,镜下形态确实更像脂溢性角化。我把这类图像单独抽出来,不强行归到某一类,而是按照两种病的近似程度做软标签(0.7 给基底细胞癌,0.3 给脂溢性角化),并且新增一个“基底细胞癌/脂溢性角化重叠表现”的补充类别参与训练。实际操作里用 label smoothing 的变体实现,损失函数从 hard label 改成 soft label 的 KL 散度形式。效果是把基底细胞癌的召回率从 74% 拉到 86%,代价是脂溢性角化的 precision 掉了 3 个百分点,但筛查场景下这个代价完全可接受。
5.2 训练集指标稳步上涨,验证集却提前崩盘
训练集 loss 降到 0.3 以下,验证集 loss 在第 12 个 epoch 反弹,准确率和召回率同步下降,这是典型的过拟合信号。但值得注意的是,皮肤数据上过拟合出现得非常早,根源不仅仅是模型容量大。
我排查的第一步看样本数量最少的几个类别是否“背下来了”。少数类在训练集里的增强副本会被网络记住——因为它看到同一个原始图的各种翻转变体太多,本质上是在硬背。解决办法是给少数类加大增强强度,尤其是随机擦除和弹性形变,破坏图像的绝对空间特征;同时引入 Mixup 增强,让少数类样本与随机其他类样本混合,模型学到的是线性插值的语义,而不是具体的像素排列。
第二步看是不是验证集与训练集采集设备差异太大。本地设备和公开数据集的色彩空间有明显偏移,训练时如果只做了归一化而没有做域适配,就会出现训练集颜料质感、验证集照片质感的割裂。我加了一个非常简单的颜色扰动增强,模拟目标设备的色温偏移,过拟合信号立刻减轻。真要做域适配,轻量级方案是把训练集和验证集的 RGB 均值方差统计出来,做一次基于统计匹配的预处理,效果类似风格迁移但计算开销小得多。
5.3 换一台手机拍摄的照片,模型输出就乱了
皮肤数据分析最终要落地的设备不只是皮肤镜,很多用户会用手机后置摄像头拍皮肤照片。手机照片和皮肤镜图像的成像原理完全不同:有环境光干扰、白平衡不稳定、清晰度波动大。
第一次尝试时,我用手机照片组成的 300 张小测试集去跑训练好的模型,top-1 准确率直接跌到 51%,和随机差不多。问题不在模型,在训练数据的分布和真实应用分布差距过大。解决方案有两个维度。
一是在训练集里混入手机拍摄样本。我从本地征得一批手机拍摄的皮损照片,做了翻转、裁剪、亮度扰动后加入训练,占比控制在 20% 以内。占比太高会“带偏”模型对皮肤镜细节的敏感度,因为手机图本身纹理信息弱,模型会倾向于依赖颜色粗粒度特征;占比太低又起不到适配作用。20% 是我试了几档之后相对合适的值。
二是在输入侧做保真预处理。手机图先做白平衡校正,再统一缩放到与训练一致的边长,最后做一次轻度去噪。白平衡校正是关键,手机偏色非常严重,黄光下皮肤痘印会偏橙,冷光下会偏紫,不校正的话模型会把这些颜色偏移当成真正的病变特征。我用的是灰度世界假设方法,对 R、G、B 三个通道分别计算均值,然后按比例调整到三通道均值接近。这个方法简单有效,对皮肤这种近似 Lambertian 表面的场景尤其适用。
6. 从分析到落地的几个延伸方向
6.1 轻量化部署与推理速度优化
这个分析项目做完之后,自然要思考落地。移动端的算力有限,不能直接跑 EfficientNet-B4。我用蒸馏方式把知识迁移到 MobileNetV3-Small 上,硬目标用原始标注,软目标用大模型的概率输出,损失函数为两者加权和。蒸馏温度设为 4,软标签权重 0.7。最终小模型在手机端推理一张 224 图像耗时约 85ms,准确率只比大模型降了 2.3 个百分点,但黑色素瘤召回率依然保持在 86% 以上。
如果你也要做蒸馏,有个提醒:软标签的蒸馏对易混淆类特别有效,但对本来就很明确的类别帮助不大。所以蒸馏时不必把全部数据都过一遍大模型,挑那些大模型置信度在 0.4 到 0.9 之间的样本做软标签蒸馏就行,置信度接近 1 的可以直接用硬标签,能省不少推理时间。
6.2 要不要把问诊文本和图像合并建模
皮肤科的诊断信息不只是像素,还包括病史、症状描述、发病部位等文本信息。我在项目后期做了个多模态分支的尝试:图像特征由 CNN 提取,文本特征用轻量级 text embedding 编码,两路特征拼接后过两层全连接分类。初步实验效果是,在“湿疹 vs 接触性皮炎”这类强依赖病史的类别上,准确率比纯图像模型高出 6 个点。
但多模态落地的代价不小:文本数据的格式不规范,口语化描述多,需要大量清洗和自定义词典。而且实际采集场景中,不是每次筛查都有完整的问诊记录,缺失率一高,多模态模型反而因为输入不完整而退化。所以当时的设计是双通道推理:图像主模型保持独立运行,如果检测到有可用的病史文本,则触发多模态分支二次诊断。这个“有条件融合”的思路比强制拼接更贴近实际使用。
提示:和这类部署、蒸馏相关的代码,建议在训练时就把随机种子固定好,包括 PyTorch、NumPy、Python 随机库三处都设成同一个值,否则每次实验之间对照不公平,后期排查问题会很痛苦。
我个人在这一整轮项目里最深的体会是:皮肤数据分析真正的门槛不在模型创新,而在数据治理。清洗规则、标注一致性、患者级划分、软标签设计,每一项工作看起来都不“性感”,但项目的最终上限恰恰由这些环节决定。选一个再漂亮的模型,数据没治理好,它也只是在认真犯错而已。如果你刚拿到一份皮肤数据集,别急着起训练脚本,先把数据摊开看一遍,统计分布、抽图复查、量化标注差异,这半天时间能帮你在后面省下几个通宵。