☰
UNet图像分割实战:PyTorch实现、训练链路与损失函数选型
2026/10/26 2:40:50 网站建设 项目流程

先说个我自己的经历:去年帮人做一个细胞显微图像的分割练手项目,需求很朴素——把视野里每一张细胞膜轮廓都描出来,输出一张和原图同尺寸的二值掩膜。我第一反应就是 UNet,从拉代码到跑出第一版能看的结果,前后不到十分钟,后面调参才花了两周。这件事让我印象特别深:UNet 分割模型的门槛低到有点反直觉,一个两百行的 PyTorch 实现就能覆盖大部分像素级预测需求,但真正决定结果好坏的东西,全在结构细节和训练链路上。

这篇东西写给两类人:一类是刚学完卷积、想做第一个分割项目的同学,另一类是手里已经有数据、需要一个能直接改的基线模型的人。我会把 UNet 的网络结构逐层拆开讲清楚"为什么这么设计",然后给出完整可跑的 PyTorch 代码,最后把训练链路、损失函数选型、以及我踩过的坑一条条列出来。代码你可以直接复制走,但建议顺手把每一层的尺寸变化在纸上推一遍,这一步省不得。

1. UNet 解决的到底是什么问题

1.1 分割任务和分类、检测的本质区别

很多人第一次接触分割会懵,因为它的输出不是标签也不是框,而是一张和输入同尺寸的图。这个差别决定了模型的整个设计走向。分类任务把一张图压缩成一个概率向量,中间的卷积层可以一路下采样到 7×7、1×1,空间信息丢光也无所谓,反正最后只要知道"这是什么"。检测稍微复杂一点,需要找回位置,所以有了锚框、回归头这些设计。

分割要求的是每一个像素都要给出一个类别判定。输入 512×512,输出就得是 512×512,一个像素都不能少。这就带来一个矛盾:卷积网络要提取高层语义,必须不断下采样扩大感受野;但下采样又把分辨率压没了。怎么在"看得懂"和"画得细"之间取舍,是分割网络设计的核心命题。

我习惯用修图来类比。分类像看图说话,告诉你"这是猫";检测像在图上画个框;分割像用钢笔工具沿着猫的轮廓一点点描边。描边这件事,光知道"这是猫"远远不够,你还得知道每一根毛的走向在哪,这就是高分辨率特征的价值。

更麻烦的是类别不平衡。一张 512×512 的图里,前景目标可能只占 3% 的像素,剩下 97% 全是背景。如果模型学坏了,把所有像素都预测成背景,准确率也有 97%,但模型彻底废了。这个特性直接影响了后面损失函数的选择,埋个伏笔,第 4 节展开。

1.2 编码器-解码器加跳跃连接的组合逻辑

UNet 这个名字来自它的形状,一个 U。左边是收缩路径,也就是编码器,一路卷积加池化,特征图尺寸减半、通道翻倍,把图像的语义信息提炼出来;右边是扩张路径,也就是解码器,一路上采样加卷积,把尺寸还原回去;中间横跨的那几条连接,就是跳跃连接。

为什么是这个结构?我拆成三层来看。

第一层,编码器解决"看懂"。四个下采样阶段,感受野从最初的 5 个像素一路涨到一百多像素,深层特征图上的一个点,实际上是原图一大片区域的抽象。这就是语义来源。

第二层,解码器解决"还原"。上采样把尺寸放大,但单纯放大会让边界变模糊,因为深层特征本身分辨率低,插值放大只是把模糊放得更大。

第三层,跳跃连接解决"对齐"。它把编码器同层的高分辨率特征直接搬到解码器对应层,和上采样后的语义特征在通道维度拼接。这样解码器在还原细节时,手里既有"这块大概是个细胞"的语义判断,又有"这条边在像素 137 附近"的精确坐标。两者一结合,边界就能画准。

我在带新人的时候会强调一句话:UNet 的收益很大一部分来自那几条横线,不是来自 U 形本身。你可以做个实验,把跳跃连接全删掉,网络照样能训,但 IoU 通常会掉 5 到 15 个点,边界尤其糊。反过来,只保留最上面一条跳跃连接,效果居中。这个消融实验做一次,比看十篇解读都管用。

1.3 我为什么建议把 UNet 当作分割入门的第一个模型

现在的分割模型选择太多了,有带空洞卷积的多尺度结构,有基于 Transformer 的各种变体,还有直接拿大模型做提示分割的方案。但如果你让我推荐第一个手写的模型,我还是说 UNet。

理由很实在。一是结构干净,所有模块都是最基础的卷积、池化、反卷积,没有花活,你能清楚知道每个张量的形状;二是参数规模可控,标准版本约 31M,一张 12G 显存的卡跑 512×512 输入、batch 8 完全够用;三是小数据友好,原始设计就是为少量样本的医学图像场景准备的,靠弹性形变增强就能在几百张图上出结果;四是生态成熟,随便搜个实现都大同小异,不会出现"别人的代码我跑不通"的情况。

下面这张表是我常用来做方案对齐的,把几种常见分割结构放在一起比较,你可以根据自己的数据量、算力、精度要求做选择。

结构类型核心思路参数量级小数据表现边界精细度适用场景
全卷积结构去掉全连接,逐像素分类约 130M一般较粗快速验证
UNet编码解码加跳跃连接约 31M好细通用分割基线
对称编解码变体对称结构,池化索引复用约 29M好中等显存受限场景
多尺度空洞结构空洞卷积扩大感受野约 60M一般中等大目标语义分割
Transformer 混合结构自注意力加卷积解码100M 以上需较多数据细数据充足场景

入门阶段我会从中间那一行开始,跑通、跑稳,再考虑往上换。跳过基线直接上重型结构,最大的问题是你分不清是模型不行还是训练链路错了,这是很多人卡住的根本原因。

2. 把 UNet 拆开看:每一层到底在干什么

2.1 收缩路径的四个阶段与通道翻倍规则

标准 UNet 的收缩路径有四个下采样阶段,通道数从 64 开始,每经过一次池化翻一倍:64、128、256、512,最后到最底部的瓶颈层是 1024。输入如果是 256×256,尺寸依次是 256、128、64、32、16。

为什么不从 32 或者 128 开始?这是个经验值。64 起步的版本参数量约 31M,在多数任务上精度和开销平衡得比较好。如果你的数据非常小(几百张图以下),可以试试从 32 起步,参数直接降到原来四分之一,过拟合风险明显下降,代价是大目标的分割精度可能略差。反过来说,如果是高分辨率遥感或者病理全片,从 128 起步会更稳。

每个阶段内部的单元是"两次 3×3 卷积 + 两次归一化 + 两次激活"。为什么是两次而不是一次或者三次?两次 3×3 卷积的等效感受野是 5×5,但参数量比一个 5×5 卷积少(2×9=18 对比 25),而且多了一层非线性。三次叠加等效感受野 7×7,收益开始递减,同时还增加了显存压力。所以两个是性价比最高的选择,这也是从早期网络延续下来的共识。

归一化我默认用批归一化,但这里有个细节要注意:如果你的 batch size 小于 4,批归一化的统计量会很不稳定,训练曲线会抖得厉害。这种情况换成组归一化或者实例归一化,通常立刻见效。我自己在 3D 数据或者小 batch 场景下基本都换组归一化,代价是训练慢一点点。

激活函数用 ReLU 就够了,现在的实现里inplace=True是个小优化,省一点显存。有人会问要不要用更新的激活函数,我的看法是在 UNet 这个量级上提升不明显,别在这上面花时间。

2.2 跳跃连接:UNet 真正的灵魂

跳跃连接具体怎么接?把编码器第 N 阶段的输出(还没池化的那个特征图)保留下来,在解码器对应阶段,把它和上采样后的特征在通道维拼接。注意是拼接不是相加,这一点很关键。相加要求两边通道数相同,语义上是融合;拼接则把信息并排放,让后面的卷积自己决定怎么组合,表达能力强得多。代价是通道数翻倍,显存和参数量上去了,这也是 UNet 显存吃得比较多的重要原因。

我在实测中遇到过一个问题:早期版本的实现里,我在拼接之后忘记调整后面卷积的输入通道数,结果报了个很奇怪的维度错误,排查了半小时。后来养成习惯,写 UNet 的时候先把每一层的输入输出通道算好写成一个表,再照着表写代码。

关于跳跃连接还有一个常见改进思路:原始拼接是无差别的,编码器特征直接灌进解码器。有些实现会在连接上加一个门控或者注意力模块,让网络自己学"哪些区域的特征该被强化"。这在小目标分割上有一定收益,但会增加代码复杂度。我的建议是先跑通原始版本,有了基线数字再去加这些模块,否则你无法判断增益来自哪里。

另外提醒一点,拼接之后通道数变大了,接一个卷积把通道压回去是标准做法。这个"压缩卷积"的通道设置,我的经验是直接压到解码器当前的通道数,不用做过渡,简单有效。

2.3 扩张路径与 1×1 输出层的还原逻辑

扩张路径的每一步包含三件事:上采样、拼接、双卷积。

上采样有两种常见做法。第一种是反卷积,也就是转置卷积,卷积核 2×2、步长 2,它带可学习参数,能学到一种更适合任务的上采样方式,但容易在输出上产生棋盘格伪影。第二种是直接双线性插值,然后接一个普通卷积,参数更少,也不会有伪影问题,现在很多新实现更倾向于这个。

我两个都用过。在小数据集上,双线性插值加卷积往往更稳,因为反卷积多出来的参数容易学偏;数据量大的时候反卷积能多榨出一点精度。你在自己项目里可以两个都跑一下,差别一般在 1 个点以内,不用纠结。

最后是输出层,一个 1×1 卷积把 64 通道映射到类别数。二分类任务输出 1 个通道,配合 Sigmoid 和二元交叉熵;多分类输出 C 个通道,配合 Softmax。这里有个新手经常搞混的地方:二分类用 1 通道加 Sigmoid,等价于 2 通道加 Softmax,但前者的显存和计算量更省,别多此一举。我自己所有二分类分割都用单通道。

还有一点,1×1 卷积之后不加激活函数,直接输出 logits,激活放到损失函数里做(比如用带 logits 的交叉熵)。这叫"数值稳定优先",因为 Sigmoid 和 log 合在一起算比分开算稳得多,特别是当 logits 绝对值很大时。

2.4 感受野与参数量的手算过程

这两个数字我建议每个做分割的人都自己算一遍,算完你会对网络行为有完全不同的理解。

先算感受野,公式是r_out = r_in + (k - 1) × j,其中 j 是累计步长。从输入开始,初始 r=1、j=1:

层操作感受野累计步长
阶段一两个 3×3 卷积51
池化后2×2 最大池化62
阶段二两个 3×3 卷积142
池化后2×2 最大池化164
阶段三两个 3×3 卷积324
池化后2×2 最大池化368
阶段四两个 3×3 卷积688
池化后2×2 最大池化7616
瓶颈层两个 3×3 卷积14016

瓶颈层的感受野是 140 像素。这个数字意味着什么?如果输入是 256×256,深层的一个点已经覆盖了超过一半的画面;如果目标是 30 像素的小物体,那它在瓶颈层的特征图上几乎被"稀释"掉了。这就是为什么小目标分割要依赖跳跃连接——它们保住了浅层的高分辨率信息。理解这一点,你就知道为什么不能随便删跳跃连接。

再算参数量。以基础通道 64、二分类为例,双卷积块(不含偏置)的参数量是(in×out + out×out) × 9。逐块累加:

模块输入通道输出通道参数量
编码器一36438,592
编码器二64128221,184
编码器三128256884,736
编码器四2565123,538,944
瓶颈层512102414,155,776
上采样一10245122,097,152
解码器一10245127,077,888
上采样二512256524,288
解码器二5122561,769,472
上采样三256128131,072
解码器三256128442,368
上采样四1286432,768
解码器四12864110,592
输出层64165

合计约 31.02M。能看到一个反直觉的事实:超过一半的参数集中在瓶颈层和解码器前两级,而干的活却主要是低分辨率的语义推理。这也是为什么把基础通道砍半,参数量能掉到 7.8M 左右,性能损失却没有想象中大——被砍掉的是最"贵"但边际收益最低的那部分。

3. PyTorch 代码实现:从模块到完整网络

3.1 双卷积块、下采样与上采样模块

写 UNet 我喜欢先拆出可复用的小模块,这样改结构的时候只需要动一处。第一个就是双卷积块。

import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): """两次 3x3 卷积 + 归一化 + 激活""" def __init__(self, in_ch, out_ch, norm='bn', mid_ch=None): super().__init__() mid_ch = mid_ch or out_ch if norm == 'bn': norm_layer = lambda c: nn.BatchNorm2d(c) elif norm == 'gn': norm_layer = lambda c: nn.GroupNorm(num_groups=max(1, c // 16), num_channels=c) else: norm_layer = lambda c: nn.Identity() self.block = nn.Sequential( nn.Conv2d(in_ch, mid_ch, kernel_size=3, padding=1, bias=False), norm_layer(mid_ch), nn.ReLU(inplace=True), nn.Conv2d(mid_ch, out_ch, kernel_size=3, padding=1, bias=False), norm_layer(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x)

几个说明。bias=False是有意为之,因为后面跟着归一化层,偏置会被减掉,纯属浪费参数。mid_ch这个参数是为了让我能灵活地把第一个卷积的输出通道调小,做个"瓶颈式"的变体,默认情况下不改变结构。归一化的选择用参数传进来,防止我每次换场景都要改代码。

下采样和上采样我单独抽出来,虽然只有一行,但抽出来之后整个网络的组装逻辑会清爽很多。

class Down(nn.Module): def __init__(self, in_ch, out_ch, norm='bn'): super().__init__() self.block = nn.Sequential( nn.MaxPool2d(kernel_size=2, stride=2), DoubleConv(in_ch, out_ch, norm=norm) ) def forward(self, x): return self.block(x) class Up(nn.Module): def __init__(self, in_ch, out_ch, norm='bn', mode='transpose'): super().__init__() if mode == 'transpose': self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2) else: # 双线性插值 + 3x3 卷积,参数量更少 self.up = nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False), nn.Conv2d(in_ch, in_ch // 2, kernel_size=3, padding=1, bias=False) ) self.conv = DoubleConv(in_ch, out_ch, norm=norm) def forward(self, x1, x2): x1 = self.up(x1) # 处理奇数分辨率时的尺寸差异 diff_h = x2.size(2) - x1.size(2) diff_w = x2.size(3) - x1.size(3) if diff_h != 0 or diff_w != 0: x1 = F.pad(x1, [diff_w // 2, diff_w - diff_w // 2, diff_h // 2, diff_h - diff_h // 2]) x = torch.cat([x2, x1], dim=1) return self.conv(x)

Up里的forward接收两个参数,x1 是来自下层的特征,x2 是来自编码器的跳跃特征。尺寸对齐那几行是必须的,下面 3.3 节会详细说。mode='bilinear'是我留的一个开关,方便两种上采样方式直接切换做对比实验。

3.2 完整 UNet 类与前向传播

把小模块拼起来,主类其实很短。

class UNet(nn.Module): def __init__(self, in_channels=3, num_classes=1, base_ch=64, depth=4, norm='bn', up_mode='transpose'): super().__init__() assert depth == 4, "本实现固定为四层,改深度需要同步调整侧连接" chs = [base_ch * (2 ** i) for i in range(depth + 1)] # [64,128,256,512,1024] self.inc = DoubleConv(in_channels, chs[0], norm=norm) self.down1 = Down(chs[0], chs[1], norm=norm) self.down2 = Down(chs[1], chs[2], norm=norm) self.down3 = Down(chs[2], chs[3], norm=norm) self.down4 = Down(chs[3], chs[4], norm=norm) self.up1 = Up(chs[4], chs[3], norm=norm, mode=up_mode) self.up2 = Up(chs[3], chs[2], norm=norm, mode=up_mode) self.up3 = Up(chs[2], chs[1], norm=norm, mode=up_mode) self.up4 = Up(chs[1], chs[0], norm=norm, mode=up_mode) self.outc = nn.Conv2d(chs[0], num_classes, kernel_size=1) def forward(self, x): x1 = self.inc(x) # 64, H, W x2 = self.down1(x1) # 128, H/2, W/2 x3 = self.down2(x2) # 256, H/4, W/4 x4 = self.down3(x3) # 512, H/8, W/8 x5 = self.down4(x4) # 1024,H/16,W/16 x = self.up1(x5, x4) # 512, H/8, W/8 x = self.up2(x, x3) # 256, H/4, W/4 x = self.up3(x, x2) # 128, H/2, W/2 x = self.up4(x, x1) # 64, H, W return self.outc(x) # num_classes, H, W

通道数的推导值得单拎出来说一遍。以up1为例,输入in_ch = chs[4] = 1024,转置卷积把它变成 512 通道、尺寸翻倍,此时要跟x4(512 通道)拼接,拼完是 1024 通道。DoubleConv(in_ch=1024, out_ch=chs[3]=512)正好吃掉 1024 输出 512。up2同理:输入 512,上采样后 256,与 x3 的 256 拼成 512,输出chs[2]=256。能看到这个通道链条是闭合的,只要Up里上采样输出固定为in_ch // 2,整条链就自洽。

这也是我在Up里用in_ch // 2而不是另传一个参数的原因——少一个可能填错的地方。如果你的基础通道不是 2 的幂,这条链会断,所以别乱设。

3.3 尺寸对齐与奇数分辨率的处理技巧

这一节是踩坑重灾区,我单独拿出来讲。

问题出在池化和上采样的不对称。输入 256×256,四次池化后是 16×16,四次上采样回到 256,完美对齐。但如果输入是 255×255 呢?四次池化分别得到 127、63、31、15,两次上采样后是 30、60、120、240,和编码器侧的 31、63、127 全对不上,torch.cat直接报错。

有三种处理方式,我按推荐度排序。

第一种,填充到 16 的整数倍。写个简单的填充函数,把 H 和 W 补到 2 的 depth 次方的倍数,推理完再裁回去。这是最省心的做法,我默认用这个。

def pad_to_multiple(x, multiple=16): h, w = x.shape[-2:] pad_h = (multiple - h % multiple) % multiple pad_w = (multiple - w % multiple) % multiple if pad_h or pad_w: # 用反射填充,比补零更自然,不会在边缘制造硬边 x = F.pad(x, [0, pad_w, 0, pad_h], mode='reflect') return x, (h, w)

反射填充要注意,如果填充量超过原图尺寸会报错,小图需要退化成补零。

第二种,在网络里做动态对齐,就是Up里那段F.pad。它的好处是网络对任意尺寸输入都鲁棒,不用在外面处理;坏处是对齐是"补零到右下",可能引入轻微的位置偏移。我个人在训练时用第一种,在推理脚本里两种都加,双保险。

第三种,直接约束输入尺寸,在 Dataset 里就统一缩放到固定尺寸。简单粗暴,但会损失原始比例信息,如果目标有各向异性(比如细长的血管),缩放会破坏形状,不建议。

提示:如果你的数据里有大量非正方形、非 2 的幂的尺寸,训练前一定先统计一遍尺寸分布。我曾经遇到过一批 1000×750 的图,直接训练报错,后来统一填充到 1024×768 才跑通,但显存占用涨了不少,最后改成随机裁剪 512×512 才把 batch 提上去。

3.4 权重初始化与网络自检

PyTorch 默认初始化对 UNet 够用,但如果你想复现论文里的收敛速度,可以手动做一次凯明初始化。

def init_weights(m): if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, (nn.BatchNorm2d, nn.GroupNorm)): nn.init.constant_(m.weight, 1) nn.init.constant_(m.bias, 0) model = UNet(in_channels=3, num_classes=1, base_ch=64) model.apply(init_weights)

写完之后别急着上数据,先做三个自检,能省掉大量排查时间。

第一,形状自检。喂一个随机张量,看输出尺寸是否和输入一致。

x = torch.randn(2, 3, 256, 256) y = model(x) print(y.shape) # 期望 torch.Size([2, 1, 256, 256])

第二,参数量自检。sum(p.numel() for p in model.parameters()),基础通道 64 的版本应该落在 31M 附近。数字差很多说明通道配置写错了。

第三,梯度自检。跑一次前向加反向,检查每一层的梯度是否非空、是否有 NaN。

loss = y.mean() loss.backward() for name, p in model.named_parameters(): if p.grad is None: print("无梯度:", name) elif torch.isnan(p.grad).any(): print("梯度含 NaN:", name)

我见过最常见的问题是某个分支因为拼接通道数写错,导致整条路径梯度为 None,训练时损失几乎不动,但又不报错。上面这三步能在两分钟内定位到。

4. 训练链路的搭建与损失函数选型

4.1 数据读取与增强策略

Dataset 的写法没什么花样,重点是掩膜的读取和通道处理。

import numpy as np from torch.utils.data import Dataset import cv2 class SegDataset(Dataset): def __init__(self, img_paths, mask_paths, transform=None): self.img_paths = img_paths self.mask_paths = mask_paths self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img = cv2.imread(self.img_paths[idx], cv2.IMREAD_COLOR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 二值化,防止标注图里有 255 以外的杂值 mask = (mask > 127).astype(np.float32) if self.transform: augmented = self.transform(image=img, mask=mask) img, mask = augmented['image'], augmented['mask'] img = torch.from_numpy(img.transpose(2, 0, 1)).float() mask = torch.from_numpy(mask).unsqueeze(0).float() return img, mask

这里有两个必须做的动作。一是掩膜二值化,很多标注工具导出的图带抗锯齿,边缘有一圈灰色像素,如果不过阈值,标签就不是干净的 0/1,Dice 计算会出偏差。二是掩膜加一维,变成[1, H, W],和网络输出的[1, H, W]对齐,不然广播会出现意想不到的结果。

增强策略上,分割和分类最大的不同是几何变换必须同步作用到图和掩膜上。用 OpenCV 手动写很容易出错,我一般直接用现成的增强库,配置写成字典形式。

import albumentations as A train_tf = A.Compose([ A.RandomResizedCrop(height=512, width=512, scale=(0.5, 1.0), p=1.0), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.0625, scale_limit=0.1, rotate_limit=30, border_mode=cv2.BORDER_REFLECT, p=0.5), A.RandomBrightnessContrast(p=0.3), A.GaussNoise(p=0.2), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ])

医学或者显微图像我还会额外加弹性形变,这对形变丰富的生物结构提升很明显,能有好几个点。但要注意弹性形变的强度,调大了会把细长结构拧断。

注意:Normalize一定要放在最后,放在翻转之前会让数值范围变化,后续增强的假设就不成立了。另外验证集只做 resize 和 normalize,不要加任何随机增强,否则指标不可比。

4.2 BCE、Dice、Focal 的取舍逻辑

损失函数在分割任务里的重要性远超一般任务,因为类别不平衡太严重了。我把常用的几个列成表,附上我的实际使用感受。

损失函数适用场景优点缺点我的使用频率
二元交叉熵正负样本较均衡稳定、收敛快、梯度平滑极端不平衡时被背景主导高,作为基线
Dice 损失前景占比小于 10%直接优化重叠度,对小目标敏感早期训练不稳,梯度尺度大高,与 BCE 组合
Focal 损失极难样本多、噪声大抑制易分样本,聚焦困难区域超参敏感,容易过拟合噪声中,特定场景
组合损失大多数真实项目兼顾稳定性和目标指标需要调权重最高,默认首选

我默认用 BCE 加 Dice 的组合,权重 1:1 起步。理由很直接:BCE 提供稳定平滑的梯度,保证训练前期能正常下降;Dice 直接盯着重叠度这个指标,弥补 BCE 对前景不敏感的问题。两个一配合,收敛曲线通常比单用任何一个都漂亮。

代码实现如下。

class DiceLoss(nn.Module): def __init__(self, smooth=1e-5): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) probs = probs.reshape(probs.size(0), -1) targets = targets.reshape(targets.size(0), -1) inter = (probs * targets).sum(dim=1) union = probs.sum(dim=1) + targets.sum(dim=1) dice = (2 * inter + self.smooth) / (union + self.smooth) return 1 - dice.mean() class ComboLoss(nn.Module): def __init__(self, bce_weight=1.0, dice_weight=1.0): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.dice = DiceLoss() self.bce_weight = bce_weight self.dice_weight = dice_weight def forward(self, logits, targets): return (self.bce_weight * self.bce(logits, targets) + self.dice_weight * self.dice(logits, targets))

smooth那一项看着不起眼,但它是防止除零的关键。如果某个样本完全没有前景(医学图像里很常见),union就是 0,不加平滑项会直接产生 NaN,然后整个训练崩掉。这个坑我踩过,损失突然变 nan,排查了很久才发现是空掩膜引起的。

另外一个细节是 Dice 里的reshape用的是(batch, -1),也就是按样本分别算 Dice 再平均。也有人用全局展平算一个总的 Dice,两者在小 batch 时差别明显。我倾向于按样本算,因为它对单个难样本更敏感。

4.3 优化器与学习率调度

优化器我基本只在这两个里选。

Adam 加学习率 1e-4 是最省事的组合,几乎不用调就能收敛,适合快速验证和中小数据集。缺点是最终精度通常比调好的 SGD 低一点点,泛化也略差。

SGD 加动量 0.9、初始学习率 0.01,配合多项式衰减,是原论文的配置,收敛慢一些但最终精度往往更好。如果你的项目周期长、数据质量高,值得花时间调这个。

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) # 余弦退火,训练轮数需要提前知道 scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs, eta_min=1e-6 ) # 或者用预热 + 余弦,前几轮从很小的学习率爬升,避免早期震荡 warmup = torch.optim.lr_scheduler.LinearLR( optimizer, start_factor=0.1, total_iters=5 ) scheduler = torch.optim.lr_scheduler.SequentialLR( optimizer, schedulers=[warmup, scheduler], milestones=[5] )

我强烈建议加预热。UNet 的批归一化在第一批数据上的统计量是随机初始化的,如果一开始学习率就很大会导致早期输出爆炸。预热五个 epoch,损失曲线会平顺很多,这个改动几乎没有成本。

轮数怎么定?看验证集指标什么时候不涨。我的经验是 50 到 100 轮之间,小数据集 30 轮就够。别用固定轮数硬跑,设个早停,验证 Dice 连续 10 轮不涨就停,省时省电。

4.4 完整训练循环与验证指标

训练循环本身不难,难的是把指标算对。Dice 和 IoU 在实现上有几个常见的雷区,我把它们包在一个函数里。

@torch.no_grad() def compute_metrics(logits, targets, threshold=0.5, eps=1e-7): probs = torch.sigmoid(logits) preds = (probs > threshold).float() preds = preds.reshape(preds.size(0), -1) targets = targets.reshape(targets.size(0), -1) inter = (preds * targets).sum(dim=1) union = preds.sum(dim=1) + targets.sum(dim=1) - inter dice = (2 * inter + eps) / (preds.sum(dim=1) + targets.sum(dim=1) + eps) iou = (inter + eps) / (union + eps) return dice.mean().item(), iou.mean().item()

eps放在分母里,是为了防止空掩膜时算出来 0/0。如果把 eps 加到分子上,会在有值的时候引入偏差,位置一定要搞清楚。

主循环:

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0.0 for imgs, masks in loader: imgs, masks = imgs.to(device), masks.to(device) optimizer.zero_grad(set_to_none=True) logits = model(imgs) loss = criterion(logits, masks) loss.backward() # 梯度裁剪,防止偶发的梯度爆炸 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * imgs.size(0) return total_loss / len(loader.dataset) @torch.no_grad() def validate(model, loader, criterion, device): model.eval() total_loss, dice_sum, iou_sum, n = 0.0, 0.0, 0.0, 0 for imgs, masks in loader: imgs, masks = imgs.to(device), masks.to(device) logits = model(imgs) total_loss += criterion(logits, masks).item() * imgs.size(0) d, i = compute_metrics(logits, masks) dice_sum += d * imgs.size(0) iou_sum += i * imgs.size(0) n += imgs.size(0) return total_loss / n, dice_sum / n, iou_sum / n

zero_grad(set_to_none=True)是个小优化,比默认的清零省一点显存和带宽,新版 PyTorch 里效果不错。梯度裁剪我默认加,max_norm=1.0,对 UNet 来说很少触发,但一旦触发就能救回一次训练。

训练时有个习惯要养成:每个 epoch 结束后固定拿同一批验证图做一次可视化,把原图、真值、预测并排存下来。指标涨了但图看起来更糊的情况我遇到过好几次,光看数字发现不了。

5. 实测踩坑与排查手册

5.1 损失卡住不动、输出全黑或全白

这是新手最常见的第一个问题,我按可能性从高到低排。

第一种可能,学习率太大,第一批就训崩了。表现是损失从第一个 batch 就是 nan 或者巨大值。解法是降到 1e-4 甚至 1e-5,加上预热。

第二种可能,标签没处理好。掩膜是 0/255 但网络输出经过 Sigmoid 是 0 到 1,如果不做归一化就直接算损失,梯度会被放大 255 倍。或者彩色掩膜有三个通道,读进来变成[3, H, W]和单通道输出对不上,广播出一个奇怪的损失。检查方法很简单,打印一个 batch 的掩膜,看它的最大值和形状。

第三种可能,前期被背景主导了。表现是损失在下降但很慢,预测图几乎全黑。这是类别不平衡的典型症状。解法是加 Dice 损失,或者给 BCE 加正样本权重,权重按前景占比的倒数来设,比如前景占 5% 就设 19 左右。

pos_weight = torch.tensor([neg_count / max(pos_count, 1)]).to(device) bce = nn.BCEWithLogitsLoss(pos_weight=pos_weight)

第四种,归一化的动量参数设得太大,统计量跟不上。默认 0.1 一般没问题,但如果 batch 特别小(比如 2),可以调到 0.01 让统计更平滑,或者改用组归一化。

5.2 尺寸不匹配与通道数报错

这类错误一般报得很明确,size mismatch或者invalid combination of arguments,关键是从报错信息反推是哪一层出的问题。

我最常用的排查手段是把每一层的形状打出来。

def forward(self, x): print("input", x.shape) x1 = self.inc(x); print("x1", x1.shape) x2 = self.down1(x1); print("x2", x2.shape) ...

用一次就够,定位完立刻删掉,别留在代码里。打印完你会发现,90% 的通道错误都是拼接之后那个DoubleConv的输入通道忘了乘 2。

还有一种隐蔽的错误:ConvTranspose2d的output_padding没设,遇到奇数尺寸时输出比预期小 1,拼接时对不上。这种时候要么用 3.3 节的F.pad,要么在输出侧也做裁剪,我倾向后者,保持尺寸严格等于输入的H/2。

5.3 边界糊、小目标漏检的几种改善手段

如果指标还行,但边界总是不够锐利,我按投入产出比排几个手段。

第一,检查输入分辨率。很多人的问题是原始数据 1024×1024,训练时为了省显存缩到 256,边界信息在缩放时就丢了。换成 512 训练,效果立竿见影,代价是显存翻几倍。这一步的收益通常比换模型大。

第二,损失函数里加边界相关的项。简单做法是对真值掩膜做形态学腐蚀和膨胀,相减得到边缘带,在损失里给边缘区域加权。代码量不大,边界指标能有明显提升。

第三,后处理。网络输出的是概率图,二值化阈值 0.5 不一定最优。在验证集上扫一遍阈值,从 0.3 到 0.7,取 Dice 最高的那个。这一步免费,很多人却不用。我做过统计,光调阈值就能带来 1 到 2 个点的提升。

小目标漏检的话,主要矛盾在感受野和分辨率。可以试试在编码器末端接一个空洞卷积模块,用膨胀率 2 和 4 在不降分辨率的情况下扩大感受野;或者在跳跃连接上加注意力的门控,抑制背景区域的特征传递。这两个改动我都试过,对 10 像素以下的目标有改善,但对大目标的影响很小,属于针对性优化。

5.4 显存不够与推理加速

显存不够的时候,按这个顺序试。

先砍 batch size,但别小于 4,太小了批归一化不稳定,这时候同步换组归一化。然后开混合精度训练,一行代码的事,显存能省三成、速度快一倍左右,精度损失几乎看不出来。

scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): logits = model(imgs) loss = criterion(logits, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

如果还不够,用梯度累积模拟大 batch。比如实际 batch 是 2,累积 4 步再更新一次,等效 batch 8。注意学习率要按等效 batch 调整,不然收敛行为会变。

再往下就是换更小的基础通道、减深度、或者用带检查点的梯度(用时间换显存)。基础通道从 64 降到 32 是最干脆的一招,参数量掉到四分之一,多数任务只损失一两个点,我在资源受限的项目里用过好几次,完全可接受。

推理加速方面,model.eval()加torch.no_grad()是基本操作,别忘了。再往上是导出成静态图做图优化,或者用半精度推理。如果是对整张大图做滑窗推理,把窗口拼成 batch 一次前向,比逐个窗口跑快好几倍。

5.5 常见问题速查表

现象最可能原因排查手段处理方式
损失变 NaN空掩膜导致除零检查掩膜全零的样本Dice 加平滑项,跳过空样本
损失不降学习率过大或标签未归一化打印首个 batch 的损失和掩膜范围降学习率,掩膜转 0/1
预测全黑类别不平衡统计前景像素占比加 Dice 损失或正样本权重
边界模糊分辨率太低或过度下采样对比不同输入分辨率提高输入尺寸,加边界加权
训练好验证差过拟合对比训练和验证曲线加增强,降参数量,早停
显存溢出batch 太大或分辨率太高看报错的具体层混合精度,梯度累积,降通道
输出尺寸不对池化上采样不对称打印各层形状填充到 16 倍数
指标震荡大batch 小于 4检查 batch size换组归一化

6. 从能跑通到能上线:几个工程化动作

6.1 滑窗推理与多尺度融合

真实场景里的图往往比训练时大得多,比如整张病理切片几万像素。直接整图送进网络显存扛不住,标准做法是滑窗。核心思路是把大图切成带重叠的块,逐块预测,再按权重拼回去,重叠区域做加权平均,避免接缝。

@torch.no_grad() def sliding_window_inference(model, image, window=512, stride=256, device='cuda'): model.eval() _, h, w = image.shape prob_map = torch.zeros((1, h, w), device=device) count_map = torch.zeros((1, h, w), device=device) ys = list(range(0, max(h - window, 0) + 1, stride)) xs = list(range(0, max(w - window, 0) + 1, stride)) if ys[-1] != h - window: ys.append(max(h - window, 0)) if xs[-1] != w - window: xs.append(max(w - window, 0)) for y in ys: for x in xs: patch = image[:, :, y:y + window, x:x + window].unsqueeze(0).to(device) logits = model(patch) prob = torch.sigmoid(logits)[0] prob_map[:, y:y + window, x:x + window] += prob count_map[:, y:y + window, x:x + window] += 1 return prob_map / count_map.clamp(min=1)

stride设成窗口的一半,重叠 50%,接缝基本看不出来。如果边缘效果仍然差,可以给每个窗口加一个高斯权重,中心权重高、边缘低,拼接后更平滑。

多尺度融合是另一个免费涨点的手段。把图缩放到 0.75、1.0、1.25 三个尺度分别推理,再把概率图缩回原尺寸做平均。我实测在边界不规则的目标上能提升 1 到 2 个点,代价是推理时间三倍。上线前先测一下延迟能不能接受。

6.2 后处理:连通域与阈值搜索

网络输出的概率图经常有一些孤立的碎点,尤其是噪声大的图。做连通域分析,把面积小于阈值的连通块直接去掉,视觉上会干净很多。

from scipy import ndimage def clean_prediction(binary_mask, min_area=50): labeled, num = ndimage.label(binary_mask) if num == 0: return binary_mask sizes = ndimage.sum(binary_mask, labeled, range(1, num + 1)) keep = np.zeros_like(binary_mask) for i, s in enumerate(sizes, start=1): if s >= min_area: keep[labeled == i] = 1 return keep

min_area怎么定?看你的目标尺寸分布,取最小目标面积的二分之一比较稳妥。别设太大,会把真实小目标一起删了。

阈值搜索我一般写成一个循环,在验证集上跑,把最优阈值记下来,推理时固定使用。这个数字不要凭感觉设 0.5,也不要拿测试集去搜,那是数据泄漏。

best_t, best_dice = 0.5, 0.0 for t in np.arange(0.30, 0.71, 0.05): dices = [] for logits, masks in val_loader: _, d = compute_metrics(logits, masks, threshold=t) dices.append(d) if np.mean(dices) > best_dice: best_dice, best_t = np.mean(dices), t

6.3 模型导出与推理部署注意事项

导出成静态图之前,有几个坑需要提前排掉。

第一,F.pad里那段动态计算尺寸的代码,在导出时可能被追踪成固定值,换个尺寸就挂了。如果确定部署时输入是固定尺寸,直接写死 pad 的值;如果要动态尺寸,得用动态轴的方式导出,而且验证过多种尺寸。

第二,Up里的if diff_h != 0判断是数据相关的分支,导出工具处理起来容易出问题。最省事的做法是在导出前把输入尺寸约束成 16 的倍数,让这个分支永远走不到,直接把它删掉再导出。

第三,推理时一定要做和训练时完全一致的预处理,包括归一化的均值和标准差、resize 的插值方式、通道顺序。我遇到过通道顺序 BGR 和 RGB 搞反的情况,模型输出看起来还行,但指标掉了七八个点,排查了很久。把预处理写成一个函数,训练和推理共用同一份代码,从源头上避免这种问题。

第四,输出层不要带激活函数,argmax或者阈值判断放到后处理里做。这样你可以灵活调整阈值,而不用重新导出模型。

7. 我个人的几点使用心得

写完这些代码,回过头说几个可能对你有用的体会。

关于网络结构,我现在的默认配置是:基础通道 64、双线性插值上采样、组归一化、五轮预热加余弦退火、BCE 加 Dice 组合损失、输入 512、batch 8、混合精度。这套配置我在四五个不同类型的项目里跑过,每次都能在几十轮内收敛到一个可用的基线,几乎没有需要大改的地方。你可以把它当作起点,先跑通再针对性调。

关于调参优先级,我的排序是:数据质量和分辨率 > 损失函数 > 增强策略 > 网络结构 > 超参。很多人一上来就折腾网络结构,加各种模块,结果发现换个输入分辨率就涨了三个点。先把数据捋顺,把分辨率提上去,比什么都管用。

最后分享一个小技巧。训练初期,拿十张验证图,每个 epoch 存一次可视化对比图,按顺序命名成 GIF 的帧。跑完训练翻一遍,你会直观看到模型是怎么从"一片模糊"逐渐收敛到"边界清晰"的。哪一轮开始过拟合、哪一轮边界突然变好,一眼就能看出来,比盯损失曲线直观得多。我现在的每个分割项目都会留这么一份可视化记录,回头复盘的时候很有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询