皮肤癌图像分析:利用U-Net分割+分类级联提升诊断可解释性 📅 发布时间:2026/9/16 4:25:42 👁 浏览次数: 我之前在皮肤科影像组学方向做过一段时间课题最常被问到的一个问题是皮肤癌图像分析到底该用纯分类网络还是先分割再分类这个问题我在实际项目里反复验证过答案是后者更靠谱。一线临床医生每天看几百张皮肤镜图像其中最耗精力的就是判断病灶边界在哪里、形状是否规则、颜色分布是否均匀——这些恰恰都是像素级任务。纯粹用ResNet做分类模型是能告诉你“良性/恶性”但给不出任何可解释的区域信息医生没法核验模型到底在看什么。所以我当时直接用U-Net做语义分割把每个像素归类为病灶或正常组织再从分割掩膜里提取特征做分类整个过程既保住了深度的端到端能力又保留了中间结果的解释性。这个方案最适合两类人一是正在做医学图像分析课题的学生或研究员需要一套能写进论文的实验基线二是刚入门深度学习、想做点“有实际意义的小项目”的开发者因为U-Net结构清晰、复现难度不高皮肤癌公开数据集也好拿特别适合当作练手项目。文章里我会把整套方案从数据准备、模型搭建、损失函数设计到训练调参、评估指标全部串起来讲并附上我在实践中踩过的坑。我尽量不写教科书式的原理复述重点放在“为什么要这样选”和“实际跑代码时到底会发生什么”上。你如果打算完全复现建议至少准备一块显存8G以上的显卡没独显也可以跑CPU版小batch训练只是时间会慢不少。1. 整体设计思路为什么是“分割分类”的级联结构1.1 纯图像分类模型的局限在哪里皮肤癌图像分类的常规做法是把整张图输入ResNet、EfficientNet之类的分类网络输出一个二分类或多分类的结果。这个方法在论文里精度很好看但落到实际场景有很尴尬的问题模型学到的可能只是病灶周围的色斑、毛发、刻度尺等环境特征而不是病灶本身。医学影像领域管这叫“捷径学习”因为背景和病灶在公开数据集里往往存在强相关性模型根本不需要理解病变就能把准确率刷到很高。一旦遇到真实场景的病例背景分布变了精度立刻崩掉。更麻烦的是没有分割结果医生完全无法判断AI的“诊断依据”。如果你只给临床医生一个“恶性概率0.87”的数字他们大概率不会放心采纳因为缺乏可视化证据。事实上很多医院在试点AI辅助诊断时卡就卡在医生没法在图像上看到病灶区域的具体边界不敢签字。所以从可解释性和可信度角度纯分类模型并不是理想的落地形态。1.2 U-Net解决的核心问题像素级定位U-Net是医学图像分割里的经典结构它本质是一个编码器-解码器结构。编码器逐步下采样提取语义特征解码器逐步上采样恢复空间分辨率中间用跳跃连接把浅层细节信息传给深层。这样设计的好处是网络既知道“这是什么”高层语义也知道“这东西在哪”底层空间位置。对皮肤镜图像来说病灶边缘往往是渐变、不规则的普通分类网络的池化操作会丢掉边缘细节U-Net的跳跃连接正好保留住了这些信息分割出来的边缘会更贴合实际轮廓。我在项目里对比过一个很直观的现象用相同backboneResNet50分别做纯分类和U-Net分割纯分类模型在“口袋”状的病变图像上经常判断错误而U-Net虽然某几个例子的分割边缘差了点但至少总能框出病灶的大致位置。这说明分割目标强制模型建立更稳健的病灶特征表征对不规则形状的鲁棒性明显更好。1.3 分割结果如何为分类服务模型融合的思路分割完之后分类信息来源主要有三个分割掩膜本身、原图与掩膜的结合、以及编码器提取的深层特征。分割掩膜可以算出病灶面积、周长、圆形度、边界不规则指数这些几何特征这类特征在临床上对应着皮肤癌ABCDE规则中的“边界不规则”和“直径大于6毫米”。再把掩膜和原图做一个点乘操作只保留病灶区域的像素喂给分类网络就能强迫分类器只关注病灶本身屏蔽背景干扰。最后还可以把U-Net编码器提取的feature map和分割结果一起做全局池化输入分类头做最终的良恶性判断。我实际项目中是把前两种做加权融合先用几何特征做一个简单规则判断再用病灶区域图跑一个浅层分类网络最后把两者分数加权平均。相比单独跑分类网络整体准确率提升了约5个百分点最关键的是医生审核时能看到红色覆盖层的病灶区域信任度高了很多。2. 数据准备与预处理实战2.1 公开数据集那么多该怎么选皮肤癌图像公开数据集首推ISICInternational Skin Imaging Collaboration系列这个系列的数据集每年都会更新其中ISIC 2017和ISIC 2018都带病灶分割掩膜标注特别适合做分割分类联合实验。ISIC 2018大概有2600多张训练图包含脂溢性角化病、黑色素瘤、良性痣三类每张都有对应的分割掩膜类别也标注在文件名里。另外还有PH2数据集只有200张图但标注质量很高适合用来做测试集验证泛化性。使用公开数据集时有一个很实在的坑ISIC的原始图分辨率从几百到几千像素不等长宽比也差异很大如果直接resize到统一尺寸有些病灶比例本来就小的图会把病灶缩成一块糊斑。我建议先统计整个数据集的病灶面积占比分布再决定resize策略。我当时的做法是先做等比缩放将长边压到512像素再用零填充补充成512×512的正方形这样既控制显存占用又尽量不破坏原始比例。2.2 高质量分割掩膜是项目的地基分割模型的训练效果直接取决于掩膜标注质量。ISIC自带的掩膜大体可用但也存在边缘过粗、有个别错误分割的情况对实验影响不大。难点在于做自己的数据集时标注工作远比想象中耗时。一个多边形标注大概要30秒到1分钟1000张图就得一天以上而且不同标注者对模糊边界的判断还不一致。医学图像标注需要多个标注者交叉验证计算Dice系数评估一致性低于0.8的样本可能要重新讨论或删掉。我处理这类问题的一个心得是用公开预训练模型先粗分割一轮把分割结果叠在原图上再由标注者去修正错误区域比从头用LabelMe画点快很多。这种方法叫“预标注辅助人工修正”实际能节省60%的标注时间。如果你做完ISIC还想扩大规模这个流程早晚会用到。2.3 数据增强做不好再好的网络也白搭医学图像的数据量通常远小于自然图像数据集U-Net这种带跳跃连接的网络其实参数很多如果不做充分的数据增强验证集上很快就会看到过拟合。增强策略里最关键的是保持形态合理性和标注同步变换。图像水平和垂直翻转、随机旋转90度、小角度旋转、随机缩放都是安全操作色彩抖动要用得保守一点因为皮肤镜图像的色调本身就偏固定弹性形变需要谨慎使用虽然能增强模型对皮肤纹理变化的鲁棒性但形变过大容易让病灶形态失真。CutMix和MixUp这类区域级增强在医学分割里争议比较大因为病灶边界本身是有临床意义的把两块皮肤图拼在一起容易让边界语义变得混乱。如果要用我建议只用在分类头那一侧不要用在分割图输入上。3. U-Net模型构建与改进实践3.1 标准U-Net复现关键结构不能少标准的U-Net有两条路径和一道“桥梁”。编码器路径重复卷积和下采样下采样用步长为2的卷积或最大池化每步卷积后接ReLU激活函数。解码器路径先上采样再与对应的编码器feature map做拼接拼接之后接卷积实现特征融合。最后一层用1×1卷积将通道数映射到类别数然后接sigmoid二分类或softmax多分类。训练时一个很重要的细节是通道数设置。原始论文里第一层是64通道如果显存不够最常见的是改成32或48。我在8G显存下用512×512输入、32初始通道batch size设8可以跑但如果要加复杂的attention模块建议图缩到384×384或者把batch size降到4。代码主体结构我在PyTorch里写过很多遍核心代码大致长这样class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels3, num_classes1): super().__init__() self.enc1 DoubleConv(in_channels, 32) self.enc2 DoubleConv(32, 64) self.enc3 DoubleConv(64, 128) self.enc4 DoubleConv(128, 256) self.pool nn.MaxPool2d(2) self.bridge DoubleConv(256, 512) self.up4 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec4 DoubleConv(512, 256) self.up3 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec3 DoubleConv(256, 128) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec2 DoubleConv(128, 64) self.up1 nn.ConvTranspose2d(64, 32, 2, stride2) self.dec1 DoubleConv(64, 32) self.out nn.Conv2d(32, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bridge(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return torch.sigmoid(self.out(d1))这里有个容易被忽略的点池化层会直接丢弃位置信息所以下采样之前一定要把特征图存下来供解码器跳跃连接使用。很多初次上手的人把顺序搞反结果分割精度掉得厉害还找不到原因。3.2 换更强的Backbone从VGG到ResNet/EfficientNet标准U-Net编码器用的是简单的卷积堆叠参数少但特征提取能力不足。实操中更多情况是使用带预训练权重的ResNet34或EfficientNet作为编码器这样能明显加快收敛也提升分割精度。PyTorch里这属于“encoder-decoder架构”其实现思路是把预训练模型的前几层结构拿出来将输出的多尺度特征保存成list然后解码器按倒序使用这些特征做上采样和拼接。改进时要特别小心预训练权重的输入尺寸适配。EfficientNet系列对输入分辨率的依赖比较强比如EfficientNet-B0默认是224×224如果你直接输入512×512虽然能跑但并未发挥那些针对小图设计的缩放系数的优势。ResNet就没有这个问题因为它的卷积和池化能自由适配任意尺寸输入。所以如果你的图是512×512起步我建议先用ResNet34编码器稳定之后再尝试EfficientNet系列。3.3 注意力机制该加在哪里更有效语义分割里常用的注意力模块有SE通道注意力、CBAM通道空间注意力和self-attention。我在皮肤病变分割上做过一组对照实验结论可能和你在论文里看到的不太一样把SE模块加在编码器的每一层后面提升并不明显IoU只涨了不到0.4个点把CBAM加在解码器最后一层之前效果反而好一些尤其是对边缘模糊的病变边界完整性提升了约1.7个点。另一个有效技巧是在跳跃连接上做注意力筛选。来自编码器的特征里既有病灶边缘的高频信号也有大量皮肤纹理和毛发的噪声。给跳跃连接加一个空间注意力图理论上能筛选出更重要的位置信息。我当时实现过一个简化版本把编码器的输出和对应解码器上采样后的特征做sigmoid门控相乘再拼回去。这样解码器学习时能更专注于病灶区域。虽然增加了约5%的计算量但确实让分割的边界更干净后续分类准确率也同步受益。4. 分类策略与损失函数设计4.1 用分割结果做分类有哪些思路分割结果到分类的转换方式我实际试过四种按推荐程度排序如下掩膜几何特征统计从二值化掩膜里计算面积、周长、圆度、最大直径和边界不规则度。皮肤科临床上极其看重这些特征黑色素瘤最典型的表现就是边缘不整、形态不对称。在ISIC 2017上光用这一组特征做逻辑回归AUC能到0.81左右作为辅助判断信号完全够用。掩膜点乘原图再输入分类网络保留病灶区域的颜色和纹理信息送入一个小型分类网络例如一个4层的浅层CNN。这个方法能够学习到更高级的病灶特征比如颜色不均匀、色素网结构AUC能到0.87左右。编码器特征全局池化分类头把U-Net编码器的输出特征做自适应全局平均池化展平之后接全连接层直接输出良恶性概率。这种做法的好处是端到端训练省掉了掩膜结果的人工转换步骤但在实际训练时如果分类和分割两个loss训练得不够均衡分类头很容易反过来干扰分割性能。真正稳的做法是前两种的组合将几何特征分值和小型CNN分类网络的输出做一个加权平均两个分数权重各占0.5左右在ISIC 2017测试集上能把AUC稳定抬到0.90以上。4.2 损失函数Dice Loss与Focal Loss的配合分割任务的损失函数有很多选择但我主推Dice Loss和Focal Loss的组合。皮肤病灶在整张图里通常只占10%到30%的面积虽然不像某些极端数据集那样极度不平衡但直接用BCE Loss训练时模型有一个明显趋势预测结果偏向把像素判为背景因为这样loss低导致分割出来的病灶区域要比真实区域小一圈。Dice Loss直接优化预测和真实掩膜的区域重叠度对小目标更友好loss公式看起来简单1减去两倍的交集面积除以并集面积加平滑项。但Dice Loss单独用有个缺点训练初期梯度不够稳定容易震荡。Focal Loss则能抑制易分样本的梯度让模型更关注硬样本——也就是病灶边缘那些容易被误分类的像素。我最终用的是两者加权求和总loss 0.5 × Dice Loss 0.5 × Focal Loss。对比只用BCE Loss的模型Dice系数提升了3个百分点。分类头那边直接用BCE Loss即可不需要额外加权。需要说明的是Focal Loss里的gamma值并不是越大越好。我试过gamma从0.5到3.0在1.0到1.5之间效果最好超过2.0之后训练开始变得不稳定分割结果出现很多奇怪的小噪声区域。4.3 训练超参数怎么定学习率、epoch和batch size一个容易复现的靠谱配置是这样的输入尺寸512×512batch size设8优化器用AdamW初始学习率2e-4权重衰减1e-4。训练120个epoch使用CosineAnnealingLR学习率调度配合20个epoch的warmup。这个组合我跑过多个数据集训练过程都比较稳定很少出现loss爆炸或者训练集精度高、测试集精度掉光的现象。warmup在医学图像分割里很重要。因为深度模型在初始几步时参数是随机的如果一开始就用大学习率很容易把卷积核的特征推到一个很差的位置。我这里用20个epoch的线性warmup把学习率从2e-6逐渐增加到2e-4让模型先稳定更新批归一化层的统计量。如果你觉得20个epoch太慢可以缩到5到10个epoch通常也能接受。对于显存较小的情况batch size从8降到4后学习率也应该同步降到1e-4。经验法则是batch size减半学习率大约乘以0.50.7不然loss曲线会明显抖动。早停策略方面我建议监控验证集上的Dice系数连续15个epoch不上升就停止训练保存最优权重。要注意的是损失值最低的那个epoch并不一定是分割精度最好的因为Dice Loss和边界质量并不是完全线性对应所以一定要用评估指标决定保存时机而不是只看loss降没降。事后想来这个细节帮我省掉了大量回测时间。5. 训练流程与实验记录5.1 完整训练流程小结实际训练时我会把整个流程分成这样几步方便对照第一步把数据集划分为训练集、验证集和测试集最好按患者ID划分保证同一个患者的图像不会同时出现在训练集和测试集里否则会高估模型的泛化性能。第二步加载预训练的ResNet34作为U-Net编码器权重。第三步用常规参数训练分割模型并保存Dice最高的权重。第四步冻结U-Net权重把分割掩膜与原图点乘后得到病灶区域图像输入一个小的分类CNN训练。第五步如果想做联合微调可以把整个分割分类模型连在一起训练但我建议只用极小的学习率比如2e-5避免破坏已经收敛的分割结果。联合微调阶段我观察到一个有趣的现象虽然分割和分类两个任务是强相关的但联合训练若不收敛最终反而分割和分类双双变差。原因在于分类的梯度回传到编码器时它倾向于提取更多与“恶性概率”相关的特征而分割的梯度要求保留更多与“边缘位置”相关的细节二者方向不完全一致互相拉扯。所以在数据量不大时我更建议分割和分类分开训练效果更稳定。5.2 实测结果与模型表现分析拿ISIC 2018作为例子。训练集约2000张验证集约150张测试集约600张。使用ResNet34编码器的U-Net经过120个epoch训练后测试集Dice系数能到0.89左右IoU在0.80到0.82之间。作为对比标准U-Net32基础通道不预训练的Dice系数大概0.85IoU在0.76附近。把分割结果点乘原图后用小分类网络预测良恶性测试集AUC到0.91左右比直接用ResNet50对原图做分类的AUC高出约5个百分点。有意思的是对小病灶直径小于100像素的样例纯分类网络几乎失效而分割分类级联结构在小病灶上的分类准确率也能维持在85%以上。原因正是分割阶段保住了空间位置信息分类网络只用病灶区域特征做判断受背景干扰小很多。这个现象其实很好理解病灶越小背景像素占比越大纯分类网络更倾向于把背景特征当成主线索很容易跑偏。5.3 推理阶段优化与加速模型落地时推理速度是绕不开的指标。U-Net参数量不算特别大但在高分辨率图上跑逐像素计算仍然慢。如果你只关心分类结果可以在推理阶段分两步走先用一个轻量级目标检测或分类模型粗略判断有无病灶有病灶的图再跑U-Net没有的直接跳过。这样大量阴性样本能省掉U-Net的推理开销。如果必须全图跑U-Net建议开启torch.inference_mode()替代torch.no_grad()再合并BatchNorm层。把BatchNorm合并进卷积层在推理阶段能减少不少计算量。另外可以尝试把PyTorch模型导出为ONNX再用TensorRT做FP16精度推理。我在2080Ti上用TensorRT做FP16推理时间能从大约85毫秒降到40毫秒左右分割精度几乎无损。不过TensorRT对某些自定义op兼容性一般如果你在模型里用了特别的自定义模块需要额外处理。6. 常见问题与排查技巧6.1 分割边界不准确病灶边缘锯齿明显这个问题的头号原因是下采样次数过多导致小目标在深层特征里已经所剩无几。检查编码器的下采样次数如果输入是512×512一般4次下采样就够用5次会让16×16的特征图丢失很多细节。第二个常见原因是跳跃连接拼接之后没有充分卷积融合我建议拼接后至少做两个3×3卷积。还有一个容易被忽略的原因标注掩膜本身边缘粗糙如果训练数据里掩膜边缘都是上色涂抹的锯齿形状模型也只能学出锯齿形状这种情况需要做一次掩膜边缘平滑再训练我通常用OpenCV的腐蚀膨胀或高斯模糊后二值化来处理。6.2 模型在验证集上分数高但测试集分数掉很多如果是私下切分测试集导致的差异最常见的问题就是数据划分时没有按患者隔离。皮肤镜数据集里同一患者往往有多张不同角度、不同时间拍的图像如果这些图像同时出现在训练集和测试集模型实际记住了患者皮肤特征而不是病灶特征测试分数虚高。ISIC官方没有明确给出患者ID但只要去解析文件名里的ISIC编号也能间接判断出相当一部分图像是来自同一患者的。不能解析的建议采用按病灶区域相似度聚类的划分方法。此外预处理不一致也是个坑训练时如果你做了某种归一化或裁剪测试时必须用完全相同的流程否则输入分布一变很多模型顶不住。6.3 训练loss不下降或直接出现NaN怎么办loss不下降的原因里最常见的是学习率太高尤其是Segmentation模型训练初期BN层的统计量还没稳定loss会在一个很高的位置震荡不降。遇到这种情况先下降到1e-4甚至5e-5看看有没有改善。其次是标签和输出shape不匹配U-Net输出通常是B×C×H×W而有些人的mask是B×H×W没做unsqueeze或one-hot计算loss时广播逻辑就会出现问题表面不报错但loss曲线很怪。NaN问题绝大多数是遇到了除零Dice Loss里如果预测和mask的交集始终为零平滑项要加够一般加1e-6或1e-5手动实现时最好用torch.Tensor.clamp做下限保护也可以在计算前检查label是否有空图样本有空样本时跳过该样本的Dice计算。训练数据里偶尔会出现全黑或全白的坏图也容易导致NaN做数据加载时最好过滤掉这类异常样本。6.4 类别极度不平衡病灶只占几个像素如果一张图里病灶只占1%甚至更低普通Dice Loss很容易失效因为即使预测全背景Dice Loss的理论梯度也没那么明显。遇到这种数据建议用Focal Loss为主、Dice Loss为辅比例可以是0.7比0.3。也可以把图像切块训练比如把512×512的图切分成256×256的patch只保留包含病灶像素的patch参与训练提高有效像素比例。这个方法在小目标分割任务里效果立竿见影。再彻底一点可以考虑改用目标检测方式先定位病灶再做小范围分割而不是全图分割。6.5 显存不够怎么办这几招立竿见影显存不足时优先降输入分辨率从512降到384显存占用大概会减少40%。其次是减少batch size并同步开启梯度累积用8个batch的累积效果代替大batch训练。数据加载里有一个很容易被忽略的优化点pin_memory设为Truenum_workers适当调高能让GPU等数据的时间明显减少。另外把图像预处理归一化、resize等尽量放进Dataset里而不是GPU训练后再做能省掉一部分不必要的显存开销。如果你用了混合精度训练AMP显存占用大概能减少30%在几乎不掉精度的情况下明显加快训练速度。实际体验与后续扩展方向我在实际实验里最大的体会是U-Net这套方案最强的不是“某一个指标刷得高”而是它把整个病灶分析流程拆成了可解释、可干预的多个环节。分割边界画不准可以去修标注、调损失函数分类结果不对可以单独训练分类网络不用动分割模型。这种模块化带来的调试效率是端到端黑盒分类模型完全比不了的。后面如果需要进一步扩展我会考虑两个方向。一是引入Transformer结构比如用Swin Transformer作为编码器对长距离依赖的建模能力更强对病灶区域与周围皮肤关系复杂的情况会有帮助但代价是数据需求量和显存占用都会明显增加小数据集上未必比ResNet编码器有优势。二是在分割掩膜上继续提取临床语义特征比如计算色素网结构、蓝白结构等皮肤镜专用特征再做分类这需要与皮肤科医生深度协作进行标注工程量大但临床价值高很多。如果这个项目你想自己动手复现我建议从ISIC 2017起步先在原图上跑通U-Net分割再做简单分类然后在验证集上观察错例一步步改进。跑通一版再优化远比你一开始就追求完美结构要高效得多。