看到这个标题,我第一反应是:U-Net不是语义分割网络吗,怎么跟图像分类模型扯到一起了?这两个词放一起容易让人犯迷糊,但它恰恰点出了这个实战项目最有意思的地方——用分割网络去辅助分类任务,最后同时拿到“病灶在哪里”和“病灶是什么”两个答案。这类项目在皮肤癌图像分析里非常典型,深度学习入门者经常在分类和分割两条路之间纠结,这个标题正好把两条路捏在了一条技术链路里。
这篇文章我会从整体思路开始讲,把数据准备、U-Net结构改造、训练策略、评估指标和踩坑经验完整过一遍。适合正在学深度学习的同学参考,也适合那些想做医学影像分析但不知道从哪下手的开发者。整个项目以PyTorch为例,思路可以平移到其他框架,核心是搞清楚分割和分类怎么协同工作,而不是背代码。
1. 项目背景与整体思路拆解
1.1 为什么皮肤癌图像分类要拉上U-Net
皮肤癌图像分类,说白了就是给一张皮肤镜照片打标签,比如判断是良性痣还是恶性黑色素瘤。听起来是个标准的图像分类问题,用ResNet、EfficientNet这些网络直接训练就行,为什么非得把U-Net这个分割网络请出来?
问题出在皮肤病变图像本身的特殊性上。皮肤镜图像里,病灶区域往往只占整张图的一小块,周围还有毛发、血管、皮肤纹理、反光等各种干扰。直接用分类网络看整张图,网络很容易被背景信息带偏,学到一些跟疾病无关的“捷径”特征。更麻烦的是,恶性病变和良性病变在初期可能长得非常像,恰恰是那种低对比度的边缘、不规则的边界在诊断里很重要,而这些细节藏在像素级的位置关系里,靠整图分类很难捕捉到。
U-Net的优势恰好在这里。它是编码器-解码器结构,能把图像里的每个像素都归类,判断它属于病灶还是正常皮肤。分割结果天然带着“位置”信息——哪个区域是病变、边界在哪里、形状什么样。这相当于在分类任务之前先加了一道“注意力过滤”工序:先把病灶区域从背景里抠出来,分类器再集中精力看这块区域,准确率自然比硬看整张图要高。同时,分割产生的mask本身就是一种可解释输出,这在医疗场景里很实用,至少能看到模型“按什么依据”做出了判断。
1.2 “分割+分类”两条主流做法对比
把U-Net和分类任务结合,实际工程里主要有两条路线,选哪条直接决定你的模型结构和训练流程。
第一条是两阶段串行路线。先用U-Net训练一个分割模型,推理时把分割得到的mask拿出来,对原图做掩码或裁剪,拿到病灶区域的图像后,再丢给一个专门的分类网络。两条路线互不干扰,分割模型可以专注于像素精度,分类模型可以专注于类别判断,开发调试都比较简单。缺点是流程长,需要维护两个模型,而且分割模型的误差会直接传导给分类结果。
第二条是多任务联合学习路线。在U-Net的编码器后面同时接两个出口:一个分割head负责输出mask,一个分类head负责输出类别概率。共享编码器,两条任务分支一起训练,分割监督和分类监督互相补充,模型学到的特征既包含像素级的位置信息,又包含全局的类别语义。这种结构端到端一体,推理时高效,通常效果也更好。难点在于损失函数要平衡两个任务的量纲,编码器的设计要考虑两个head都能受益。
这个项目标题写的是“基于U-Net语义分割网络的皮肤癌图像分类模型”,明显更贴近第二条路线:分割网络不是被绕开的,而是核心结构;分类是最终目标,但分割全程参与。我也会以联合学习为主展开,顺带提一下两阶段方案的适用场景。
1.3 这套方案在真实场景里的价值
把分割和分类放一起,不只是为了炫技,是因为皮肤癌诊断这个场景本身就要求“分得清边界”和“判得准类别”同时成立。临床上,医生看病灶不会只看一个总标签,还要观察边缘是否规则、颜色分布是否均匀、形态是否对称,这些信息天然是像素级的。模型如果能输出病灶边界,就有了辅助诊断的证据链。
从技术上看,医学影像数据集普遍样本量小,一张张标注分类标签相对容易,但像素级标注mask成本极高。多任务学习能缓解这个问题:分割任务强迫模型关注局部细节,分类任务提供全局语义约束,两个信号共享同一个特征提取器,相当于用更多监督信息约束模型,减少对小样本的依赖。这也是为什么在ISIC、HAM10000这类皮肤病变数据上,U-Net变体参与的分类方案经常比单纯分类效果好。
2. 数据准备与预处理实操
2.1 先搞懂模型到底需要什么数据
做这个项目,数据需求跟纯分类不一样,需要的是“原图 + mask + 类别标签”三件套。原图就是皮肤镜照片,一般是RGB三通道,分辨率差异很大,从几百像素到上千像素都有;mask是跟原图尺寸一致的单通道二值图,白色区域代表病灶,黑色区域代表背景;类别标签可以是二分类,比如良性/恶性,也可以是多分类,对应不同的病变亚型。
公开数据集方面,ISIC系列和HAM10000是比较常用的资源。HAM10000包含7类皮肤病变,但类别分布很不均衡,色素痣占了接近七成,黑色素瘤之类只有一小部分,这个特点在后面处理类别不平衡时非常关键。
容易踩坑的是图片来源混乱:有的原图是JPG,有的是PNG,有的mask是8位深度,有的是16位深度,像素值不是标准的0和255。我之前就遇到过mask里混着中间灰度值的情况,阈值处理不干净,分割结果里冒出来一堆噪点。我的建议是数据加载的第一步统一做三件事:转RGB、转uint8、mask二值化,宁可多花几分钟清洗,也别指望模型能自己“看明白”。
2.2 原始数据清洗与配对校验流程
数据清洗这一步决定了下游能走多远。很多初学者拿到数据后直接开始训练,结果loss异常、评估指标虚高,回头排查才发现是mask和原图对不上。整理数据我习惯按这个顺序处理。
先做配对校验。写个脚本遍历所有图片,检查每张原图是否都有对应的mask文件,文件名是否一致。皮肤病变数据集经常出现同一患者多张图像、不同时期拍摄的情况,文件名规则可能不统一,这一步不能省。校验完后把数据按固定规则重新命名,比如image_001.jpg对应mask_001.png,后面所有代码都按这个规则读取。
再做mask质量清洗。用连通域分析把mask里面积过小、明显是标注噪声的区域过滤掉;检查mask和原图尺寸是否一致,不一致就做对齐或插值。有些mask的边缘有锯齿或孤立噪点,可以用形态学开运算做一次平滑,但不能过度操作,否则会抹掉真实边界信息。
最后做分层抽样,划分训练集、验证集、测试集。这一步有个关键点:如果是多视角拍摄的数据,要按患者ID切分,确保同一个人的不同照片都落在同一个集合里,防止数据泄漏。否则验证集和测试集里混着训练集患者的其他照片,模型记忆了患者特征而不是病理特征,线上反馈会跟纸面指标差一大截。具体比例我常用70%训练、15%验证、15%测试,也可以按数据量调整,但测试集人数上要保证足够覆盖不同病变类型。
2.3 数据增强策略与参数建议
医学图像数据一般不会太多,数据增强是提升泛化能力的重要手段。但医学图像增强有讲究,不能像自然图像那样随意调颜色。皮肤镜图像里颜色本身就是诊断依据,恶性黑色素瘤经常呈现不均匀的棕色、黑色、蓝白色调,颜色增强过度会把关键病理信息抹掉,模型学到的是“假颜色相关性”。
我推荐的增强组合大致是:随机水平翻转、随机垂直翻转、随机旋转30度以内、随机缩放0.9到1.1倍、随机裁剪。这些几何变换不改变病变的视觉特征,适合作为默认配置。颜色层面只做轻微的亮度对比度调整,幅度控制在0.1以内,用HSV空间的小扰动代替RGB空间的强颜色增强。
还有一个容易被忽略的点:增强操作必须同步作用于原图和mask。翻转、旋转、裁剪时,mask必须跟原图用同一套变换参数,否则分割标签就错位了。强烈建议用albumentations这个库,它专门支持image和mask同步增强,代码写起来干净,不容易出这种低级错误。随机裁剪的尺寸一般跟模型输入尺寸保持一致,我习惯先用Resize统一下到256x256或512x512,再做随机裁剪到224x224或256x256,这样兼顾了全局上下文和局部细节。
2.4 病灶区域裁剪与背景干扰抑制
不直接处理整张图,先做区域裁剪会带来很明显的效果提升。做法很简单:根据mask的包围盒,把病灶所在的矩形区域裁出来,四周留一点上下文余量,然后统一resize到固定尺寸。这样分类网络看到的是“放大后的病灶”,而不是被周围皮肤稀释成一小块的病变区域。对细粒度诊断来说,这一步往往比换更大的模型更有效。
裁剪之后,另一种常见做法是对mask区域做前景覆盖。把mask作为掩码,保留病灶像素,把背景像素置零或填成平均肤色,用这种图去训练分类器,强制分类器只看病灶区域。也可以把mask和原图在通道维拼接起来,做成四通道或者五通道输入,让网络在特征提取的同时明确知道病灶边界在哪里。这些处理方式没有绝对优劣,可以在验证集上对比一下再决定,我在实验中的经验是“裁剪 + 拼接mask”这个组合效果比较稳。
3. 模型搭建与训练关键点
3.1 U-Net结构拆解:编码器、解码器、跳跃连接
U-Net结构并不复杂,但要理解它为什么对医学图像有效,得抓住三个核心设计。
编码器部分负责“看全局”。它通过一系列卷积和下采样一步步把空间尺寸压缩,通道数增加。比如输入256x256的图,经过四次下采样,特征图变成16x16,通道从32到512。这个过程有点像阅读摘要:越往深层,越关注“是什么”,越忽略“在哪里”。对医学图像来说,编码器提取的是组织纹理、颜色分布、形态特征这些高层语义。
解码器部分负责“还原细节”。它把编码器压缩得到的低分辨率特征逐步上采样,一步步恢复到原图尺寸。上采样过程中,分辨率变高了,但信息量没有自动变多,纯粹靠深层特征很难还原出精细的边缘结构,所以引出了第三个关键设计——跳跃连接。
跳跃连接把编码器每一层的高分辨率特征直接接到解码器对应层上,相当于给解码器开了一条“直通快线”,把浅层的细节信息和深层的语义信息拼在一起。生活中打个比方,想象你闭着眼睛摸一个物体,手部触觉是高频细节(编码器浅层),大脑先验知识是低频语义(编码器深层),要准确还原物体形状,必须同时靠触觉和先验,跳跃连接就是“睁眼看物体”的那条通道。正因为这个设计,U-Net在边缘还原精度上远强于单纯的下采样-上采样结构,非常适合皮肤病变这种需要精细边界的任务。
下面给出一个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, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class EncoderBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = DoubleConv(in_ch, out_ch) self.pool = nn.MaxPool2d(2) def forward(self, x): feat = self.conv(x) x = self.pool(feat) return x, feat注意,这个示意图去掉了最底层的连接细节,真实U-Net的完整实现里还需要解码器从最深层特征开始逐级上采样。复现的时候不要自己敲,直接参考成熟的开源实现更靠谱,重点是理解每层输出的尺寸变化。
3.2 在U-Net上加一个分类头:多任务模型设计
U-Net本身只输出像素级的类别概率,为了同时得到“病灶在哪里”和“这是什么病”,需要把编码器产出的高层特征引出一个独立的分类分支。实现方案不复杂,编码器主干最后会得到一个比较小的空间尺寸特征图,再往下走才是解码器;现在从这个特征图上接一个轻量分类头:全局平均池化把空间维度压缩成一个向量,后面跟一个全连接层,输出类别概率。
这里有个容易纠结的细节:分类头应该接编码器的哪一层?接太浅,特征还停留在低级边缘纹理,缺乏全局语义;接太深,比如接最后一层,特征分辨率可能只有16x16甚至8x8,但语义性最强。我的经验是接编码器最深层特征图做全局平均池化,效果最稳定。原因很简单,最深层特征跟分类标签的语义距离最近,模型优化起来更顺。
另一个方案是接U-Net解码器的最后一层输出,也就是分割结果的同时也做全局平均池化,再接分类头。这样分类特征里天然包含了分割掩码的结构信息,不足是计算量稍大。实验对比中两种方案差距不大,我更推荐编码器特征接分类头,训练更快,显存占用也更友好。
简化后的分类分支示意:
class ClassificationHead(nn.Module): def __init__(self, in_dim, n_classes): super().__init__() self.head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(in_dim, 256), nn.ReLU(inplace=True), nn.Dropout(0.3), nn.Linear(256, n_classes), ) def forward(self, x): return self.head(x)有了分类分支后,整个模型的训练风格就变了。分割分支的U-Net解码器照常输出像素级预测,分类分支从共享的编码器特征上学“整图类别”,两个head共用一套基础特征提取器。实际效果是,编码器被迫学习“既能定位、又能分类”的特征,比单一任务学到的特征表达能力更强。
3.3 损失函数:分割和分类怎么组合
多任务训练的第一关键就是损失函数的设计。分割分支常用损失有两种:交叉熵损失(BCE)和Dice损失。BCE逐像素独立计算,训练稳定,但遇到前景背景像素严重不均衡时容易偏向背景;Dice损失直接度量预测mask和真实mask的重叠区域,对类别不均衡不敏感,但单独用收敛不稳定。成熟做法是把这两个按比例混合,我常用的组合是bce_weight * BCE + dice_weight * Dice,比例在0.5到0.7之间,优先保证Dice能量的占比。
分类分支在皮肤病变数据上有个特点:类别严重不均衡,恶性样本少。普通交叉熵会被多数类别主导,所以分类损失用Focal Loss效果更好。Focal Loss的出发点很朴素:让模型更关注难分样本,对已经分对的简单样本降低损失权重。公式不想背也没关系,PyTorch里自己实现很简单,关键就是给交叉熵加上一个调制因子(1 - p_t)^gamma,gamma常用2.0。
联合损失是分割损失和分类损失的加权和:
seg_loss = dice_loss(pred_mask, true_mask) + 0.5 * bce(pred_mask, true_mask) cls_loss = focal_loss(pred_cls, true_cls) total_loss = 0.7 * seg_loss + 0.3 * cls_loss权重分配上没有绝对标准,我的经验是从分割主导开始,因为分割任务是模型的“骨架”,分类任务负责锦上添花。分割稳定了之后,分类权重可以逐步调高,比如从0.3提到0.4或0.5。要时刻观察验证集上的分类准确率和分割Dice是否同步提升,如果分割涨了分类跌了,说明两个任务之间在特征上打架了,优先检查是不是编码器过早地只学了某一类特征。
3.4 训练参数与实验配置参考
训练配置细节决定了模型能不能收敛、能不能复现,这里给出一份我实测比较稳的参考配置。
| 配置项 | 推荐值 | 备注 |
|---|---|---|
| 输入尺寸 | 256x256 | 兼顾精度和显存,512x512效果略好但显存压力大 |
| Batch Size | 16(256x256) | 显存不够时降到8,配合梯度累积 |
| 优化器 | AdamW | 权重衰减建议1e-4到1e-5 |
| 初始学习率 | 1e-4 | 编码器部分可设为1e-5,防止微调时打乱预训练特征 |
| 学习率策略 | Cosine退火 + Warmup | warmup前5个epoch,从1e-5升到1e-4 |
| Epoch总数 | 60-100 | 配合早停,监控验证集AUC |
| 混合精度 | 开启AMP | 显存占用下降明显,训练速度提升明显 |
| 梯度裁剪 | max_norm=1.0 | 防止分割分支偶尔出现的梯度爆炸 |
医疗影像训练的batch size天然不能太大,因为图像分辨率高、样本量少,Batch Size大了反而容易让模型只记住整体分布,忽略了单个样本的细节。Warmup设计的原因也很直接:模型初始状态下权重还没进入正常分布,直接用大学习率容易冲出好的优化区域,先用小学习率走几个epoch,等梯度方向稳定后再把学习率升上去。
训练过程中要定期保存checkpoint,至少保留验证集最佳模型和最后一个epoch模型两个版本。我习惯每个epoch结束都记录一次训练loss、分割Dice、分类AUC,绘制曲线观察是否有过拟合迹象。如果训练loss持续下降但验证AUC停滞,就要警惕过拟合,提前增加数据增强强度或者加大dropout比例。
4. 评估体系与避坑指南
4.1 用什么指标评价这个“分类+分割”模型
评价这套模型不能只看一个指标,因为模型同时输出分割结果和分类结果,两边都要验。
分类部分:准确率(Accuracy)、敏感度(Sensitivity/召回率)、特异度(Specificity)、AUC(ROC曲线下面积)都要看。医疗场景里最需要盯紧的是Sensitivity,也就是真阳性率——恶性病变被漏判造成的后果比良性误判严重得多。一个模型如果Accuracy很高,但Sensitivity只有70%,意味着三成恶性被漏掉,这在辅助诊断上是不能接受的。AUC反映的是模型整体排序能力,适合用来筛选模型和调参,但最终能不能“上线”,还是要看Sensitivity在某个阈值上的具体表现。
分割部分:Dice系数和IoU是最常用的两个。Dice等于预测mask和真实mask交集的两倍除以两者像素数之和,IoU等于交集除并集。两者高度相关,IoU的数值通常比Dice低一些,选一个作为主指标即可。分割质量直接决定后续分类特征的质量,哪怕分类AUC很高,也要确认Dice没过低,否则模型可能是靠背景特征“抄近道”做对的分类。
4.2 训练中常见问题与排查技巧实录
实际训练过程中,问题远不会像教科书那么平滑,把常见问题整理成一张速查表,照着排查效率很高。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 训练loss不降,Dice一直接近0 | mask和原图尺寸/顺序错位,标签信息完全错误 | 可视化几个batch的image和mask叠加图,确认配对正确 |
| 分割效果好,分类在验证集虚高、测试集崩 | 同一患者的数据同时出现在训练和验证集 | 按患者ID分集合,杜绝数据泄漏 |
| 分类准确率很高,但Sensitivity低 | 恶性样本少,模型偏向多数类 | 换Focal Loss,或对恶性样本做过采样 |
| 分割mask边界粗糙,锯齿严重 | 上采样后缺少精细边缘约束 | 增加跳跃连接的浅层特征权重,或对mask加CRF后处理 |
| 训练后期loss曲线抖动严重 | 学习率过大,或Dice损失权重过高 | 调低初始学习率,或把Dice权重降到0.5以下 |
| 验证集AUC很高但分割Dice降低 | 分类分支主导了编码器特征,分割信号被压制 | 调低分类权重,或给分类分支单独加一个低学习率编码器 |
最推荐的排查方法是可视化。每个epoch挑几张验证集样本,把原图、真实mask、预测mask、分类预测概率并排画出来。眼见为实,一眼能看出模型是边缘不准、位置偏移还是完全没学到病灶区域。我见过太多人盯着loss曲线和数据表格猜半天,不如直接把图打印出来看。
4.3 从“能跑通”到“效果好”的优化方向
如果基础版模型已经能跑通,但效果还不够好,有四个方向可以依次尝试,按性价比从高到低排列。
第一个方向是替换编码器主干。U-Net的编码器不一定是原版VGG风格,可以换成带ImageNet预训练权重的EfficientNet或ResNet系列。预训练模型已经学好了通用边缘、纹理、颜色特征,医学数据少也能借助迁移学习的优势快速收敛。实现时注意把预训练编码器的输出接回U-Net解码器,连接层的通道数要对应好。
第二个方向是引入注意力机制。Attention U-Net在跳跃连接上加注意力门控,自动抑制无关背景区域的浅层特征;SE模块嵌入卷积层,自动加权特征通道。这些结构的实现都不复杂,但能明显提升小病灶、低对比度场景下的分割精度,对色素痣内部复杂结构尤其有帮助。
第三个方向是多尺度输入。把原图和裁剪后的病灶图一起送入网络,让模型同时看全局和局部。这个方向技术含量低但效果明显,本质上缓解了“深度网络感受野有限”的问题,尤其适合皮肤病变这种需要看整体对称性、又要看局部颜色细节的任务。
第四个方向是后处理和推理优化。分割mask出来后,用条件随机场(CRF)或者简单的连通域过滤,去掉面积过小的假阳性区域,再计算病灶区域的形态学特征(如直径、边界不规则度),把它和分类概率拼接在一起做最终决策。部署阶段可以把模型导出成ONNX或TensorRT格式,推理速度能提升不少,医疗场景对实时性有要求时这一步是必做的。
写在最后的个人体会
这篇文章把项目从数据到训练到评估过了一遍,最后分享一个我自己多次调试下来的体会:多任务模型最难的不是把每个分支训好,而是让两个任务“互相帮忙而不是互相拖后腿”。我自己折腾过好几版模型,最后发现分割和分类的损失权重真的不是固定的,必须在训练过程中动态观察两个head的验证指标变化来微调,可能要花不少时间比较,才能找到满意的平衡点。另外,真要复现出稳定的效果,建议直接去找一些成熟的检测分割框架作为基础工程模板,比如开源的语义分割库,踩坑会少很多;自己从零写全流程往往时间成本很高,产出反而不一定更好。
对于正在入门深度学习的同学,我的建议是把“会调包、会训练、会看指标”当成第一阶段目标,然后重点吃透U-Net为什么这么设计、多任务联合训练为什么有效这类问题。皮肤癌图像分类这个项目最迷人的地方,是它把一个经典分类任务用分割的思路重新解了一遍,顺便拿到了可解释的病灶区域,这种“一鱼两吃”的设计思路,放到好多实际场景里都值得借鉴。希望这篇实战记录能帮你绕过我踩过的坑,在你自己的数据集和任务上快速跑起来。