图像分割这件事,说穿了就是把“这张图里有什么”升级成“这张图里每个像素分别属于什么”。我第一次接触FCN的时候,手上只有一张显存 4GB 的旧卡和一个刚配好的PyTorch环境,论文看了三遍才反应过来它到底把卷积网络动在了哪里。这篇东西就是我对那次图像分割--FCN的论文复现的完整复盘:从网络结构怎么拆、全连接层为什么要卷积化、上采样和 skip 连接怎么拼,到标签怎么处理、mIoU 怎么统计、训练跑崩了该往哪儿看。写这些的文字已经足够多,但大部分只讲结论不讲推演过程,所以我尽量把每一步“为什么这么定”也写清楚,包括哪些是我踩过坑之后才改的写法。适合刚入门分割、想拿一个结构干净的基线把整套流程跑通的人,也适合手上有别的任务、想借 FCN 理解 encoder-decoder 思路的人。
1. FCN 放到今天的坐标系里,还值不值得花时间复现
1.1 全卷积这个动作,到底改变了什么
在 FCN 之前,做像素级预测的主流套路是“滑动窗口 + 分类器”:拿一个固定大小的补丁喂进分类网络,输出中心像素的类别,然后窗口一格一格挪。这个做法有个非常难受的地方——重叠区域要被反复计算,一张图推理一次等于把特征提取做了几十上百遍,效率低到没法在实际场景里用。FCN 的核心贡献就是把最后那几个全连接层换成卷积层,让网络直接输出一张和输入对应的特征图,一次前向就得到全部像素的分数。
这个改动看起来只是换了几层,但连带效应非常大。全连接层要求输入尺寸固定,所以以前的分类网络必须把图 resize 到 224×224;改成卷积之后,输入尺寸可以是任意大小,网络对空间位置的感知也不再被“压平”这个动作破坏。分类任务里那个 flatten 之后的向量,本来就把二维的空间关系丢掉了,而分割恰恰最需要空间关系。所以“全卷积”不只是工程上的加速,它保住了分割任务最需要的那部分信息。
另一个关键设计是上采样。卷积和池化一路下来,特征图分辨率降到 1/32,语义强但位置糊。FCN 用转置卷积把它一步步放大回去,同时用 skip 连接把浅层的高分辨率特征加回来——这个“深层语义 + 浅层细节”的融合思路,一直到今天的各种分割网络里都还在用,只是融合方式从简单相加变成了 concat、attention、特征金字塔。
1.2 复现目标设定:我要的不是 SOTA,是可解释的基线
复现一篇老论文,最怕的是拿它去和现在的榜单比精度,那必然失望。所以我在动手前先把目标定死:跑出一个结构完全对得上论文、每个中间张量的尺寸都能对上、指标可复现的基线,而不是追求某个数据集上的最高分。这个定位决定了后面很多取舍——比如我不去堆各种 trick,不做复杂的后处理,数据增强也只保留最基础的那几种。
这样定的好处是,出问题的时候排查路径是清晰的。如果 mIoU 卡在某个值上不去,我可以很确定地知道是训练配置、是标签处理、还是模型结构本身的问题,因为变量少。反过来说,如果你一上来就把各种花活全堆上,模型效果不好时你根本不知道是哪一块拖了后腿。我在第一次跑的时候就是贪心,加了颜色抖动、随机缩放、类别权重、focal loss 全套,结果 loss 曲线抖得看不出来任何趋势,白白浪费了两天。
还有一点要说清楚:FCN 的参数量其实不小。VGG16 里那个 fc6 换成 7×7 卷积之后是 512×4096×7×7,单独一层就有一亿多参数,比很多现代轻量分割网络的整个模型还大。所以复现它对显存是有要求的,这一点在动手前最好心里有数,别指望在小显存的机器上开大 batch。
1.3 技术选型与版本踩点
环境上我用的是一套比较保守的组合:PyTorch 2.x + torchvision 的预训练权重 + 单卡训练。选 torchvision 自带的 VGG16 而不是自己按论文一层层写,理由是它的层序和官方定义完全一致,后面按索引切片的时候不容易错位。自己写当然也行,但 pooling 的位置一旦记错,特征图尺寸就对不上,调试起来非常痛苦。
有几个版本层面的坑值得提前说。第一,torchvision 新版本的models.vgg16参数名从pretrained=True改成了weights="IMAGENET1K_V1",老代码直接拷过来会报错。第二,转置卷积的默认初始化是 Kaiming 均匀分布,直接拿来上采样会产生明显的棋盘格伪影,后面我会给出双线性核初始化的写法。第三,CrossEntropyLoss的ignore_index默认是 -100,而分割数据集里未标注像素的惯例值是 255,不显式指定的话这些像素会参与 loss 计算,模型会被教歪。
我建议在动手写网络之前先做一件事:拿一个假的输入张量,把每一层的输出尺寸打印一遍,和论文里写的 1/8、1/16、1/32 对上。这个动作花不了十分钟,但能省掉后面几个小时的迷茫。
2. 把论文结构翻译成 PyTorch 模块
2.1 VGG16 backbone 的三段切法与特征图尺寸推演
FCN 用的 backbone 是 VGG16,切法上要切出三段:到 pool3 为止(1/8 分辨率,256 通道)、到 pool4 为止(1/16,512 通道)、到 pool5 为止(1/32,512 通道)。torchvision 的vgg16().features是一个 31 层的Sequential,索引 0 到 30,其中 pool 层分别落在 4、9、16、23、30 这几个位置。按这个索引切:
features[:17]得到到 pool3 的输出,空间尺寸缩到 1/8,通道 256features[:24]得到到 pool4 的输出,1/16,通道 512features[:31]得到到 pool5 的输出,1/32,通道 512
这里的索引千万别记混。VGG16 的 conv 和 relu 是成对出现的,features[16]正好是第三个 maxpool,所以[:17]是包含它的。我见过有人写成[:16],结果拿到的是 relu3_3 的输出,分辨率是 1/4 而不是 1/8,后面所有的尺寸推演全错,报错信息还特别隐晦——通常是在相加那一步提示两个张量形状不匹配。
尺寸推演我习惯这样算:假设输入是 321×321(论文里用的尺寸,奇数,能保证每次下采样后不出小数)。经过三次 stride=2 的池化,得到 41×41;四次池化是 21×21;五次是 11×11。注意这里 321/32 = 10.03,向上取整是 11。这也是为什么论文选 321 这个奇怪的数字——它能让 1/32 的特征图刚好覆盖住整个输入,不丢边角。如果你用 320×320,那 1/32 就是 10×10,反过来上采样到 32 倍是 320,刚好,但边界对齐会更敏感一点。
实际用的时候我更倾向把输入裁成 32 的整数倍,比如 352 或 512,这样所有中间尺寸都是整数,加减的时候不会出现差一个像素的尴尬。代价是要做一次 crop 或者 pad。
2.2 全连接层卷积化的两种写法与 dilation 的取舍
论文里把 fc6 换成了 7×7 卷积、fc7 换成 1×1 卷积,这个改动里有个细节容易被忽略:fc6 原本作用在 7×7×512 的特征图上,现在要作用在更大的特征图上,如果直接用 7×7 核、不做 padding,那 1/32 分辨率下图一小一点就卷不动了。论文的做法是加 padding,让输出尺寸保持不变。
另一种写法是用 dilation(空洞卷积),把 7×7 核的感受野撑到等效更大,同时减少参数。有些复现版本用dilation=4配合 3×3 核来近似 fc6 的感受野,参数量能降到原来的几分之一。这两种写法我都试过,实测下来在小数据集上精度差异不明显,但 dilation 版本显存占用明显小一些,训练也更快。
不过如果目标是“严格复现论文”,我建议还是按原版来:7×7 核、padding=3、输出 4096 通道,后面接 ReLU 和 Dropout。这样结构上完全对得上,出了问题也好对照。等你把这版跑通了,再换成轻量版本做对比实验,这才是合理的顺序。
还有一个点:fc7 之后是 1×1 卷积把 4096 通道压到类别数。这一层的初始化要注意,如果用默认初始化,初始预测会非常随机,配合交叉熵在训练初期容易把模型推向“全预测背景”这个局部最优。我的做法是把这一层的权重用接近零的小值初始化,偏置置零,让模型从“均匀猜测”开始学。
2.3 FCN-32s、16s、8s 的差异和上采样模块设计
三个变体的区别只在于融合了几层浅层特征。32s 最直接:fc7 输出直接上采样 32 倍回原图,结构最简单,但边界非常糊,因为 1/32 的特征图上一个像素对应原图 32×32 的区域,再精细的语义也救不回位置精度。16s 把 pool4 的输出经过一个 1×1 卷积降到类别数,和上采样 2 倍后的主分支相加,再上采样 16 倍。8s 再往前一层,把 pool3 也融进来,多一次 2 倍上采样和相加。
相加之前必须保证两个张量形状完全一致,这是最容易出错的地方。我的做法是在 forward 里显式打印或者用 assert 检查,一旦不匹配立刻就能定位。另外,融合时的相加是逐元素加法,不是 concat,这一点论文里写得很清楚,但很多人第一遍会写成 concat,然后通道数翻倍,后面全乱。
转置卷积的参数要算准。要让分辨率翻倍,kernel_size=4, stride=2, padding=1是一组标准配置,代入公式out = (in-1)*stride - 2*padding + kernel,得到2*in,正好翻倍。要放大 8 倍就用kernel_size=16, stride=8, padding=4,代入得8*in。要放大 16 倍就是kernel_size=32, stride=16, padding=8。这些数字不是随便凑的,都是用上面那个公式反推出来的,记不住就现算。
转置卷积有个通病是棋盘格伪影,原因是卷积核重叠区域不均匀。解决办法是用双线性插值核初始化权重,让初始状态下的上采样等价于双线性插值,训练过程中再慢慢学。这一段代码不长,但效果提升很直观,尤其是训练初期收敛快很多。
3. 数据处理与损失设计,复现成败的关键
3.1 标签处理:从原始标注到 ignore_index
分割任务的数据管线比分类麻烦得多,因为标签不是标量而是一张图。最常见的公开数据集标注是单通道索引图,每个像素的值就是类别 ID,0 到 20 对应 21 个类(其中一个是背景),边界或者不确定的区域标成 255。这个 255 在训练时必须被排除掉,否则模型会学到一个“第 255 类”,而这一类的样本分布完全是噪声。
具体做法是在CrossEntropyLoss里指定ignore_index=255。这一步看起来简单,但我第一次跑的时候忘了设,loss 一直在 2.x 附近下不去,排查了很久才发现是这些未标注像素在捣乱。另一个容易错的地方是标签的取值范围——有些数据集的标注是从 1 开始编号的,直接喂进去会越界,需要在 Dataset 里减 1。
我在 Dataset 里通常会做三件事:读图(RGB 三通道)、读标注(单通道)、检查两者尺寸是否一致。如果尺寸对不上,要么是标注被压缩过,要么是读图的时候做了 resize 但标注没跟着做。这种情况在自制数据集上特别常见,一定要在读入时就 assert 掉,别等到 loss 算出来才发现。
3.2 数据增强里最容易做错的一步
数据增强对分割的提升非常明显,因为像素级标注成本高,公开数据集规模都不大,不做增强很容易过拟合。但分割的增强和分类有个本质区别:图像和标注必须做完全相同的几何变换。
随机裁剪、随机翻转、随机缩放这些操作,图像用双线性插值没问题,但标注绝对不能用双线性插值,否则会插出一堆 3.7、12.4 这种不存在的类别 ID。标注必须用最近邻插值。这个坑我踩过,表现是训练 loss 正常下降但 mIoU 死活上不去,最后发现标签里混进了一堆小数,被long()转换时截断成了错误的类别。
我一般用 torchvision 的transforms组合,图像和标注分别走两套:图像走RandomCrop+RandomHorizontalFlip+ColorJitter+ToTensor+Normalize,标注走RandomCrop+RandomHorizontalFlip+ToTensor。注意RandomCrop和RandomHorizontalFlip在两套里必须用相同的随机种子,否则图像翻了标注没翻,模型直接学废。更稳妥的写法是自己封装一个联合变换类,把随机参数抽出来,两边共用。这个封装写一次能用很久,值得花时间。
颜色抖动我一般只放很轻的强度,因为分割任务对颜色是敏感的——你把色调转太多,草地可能变成“看起来像路面”的颜色,标签却还是草地,这就变成了噪声。缩放也要小心,小目标缩得太小会彻底消失。
3.3 损失函数与类别不平衡的实际处理
标准做法就是逐像素交叉熵。但分割数据集普遍存在严重的类别不平衡:背景可能占 80% 以上的像素,小目标只占千分之几。这时候模型只要全预测背景就能拿到很低的 loss,训练会卡在这个退化解上。
我试过三种处理方式。第一种是给CrossEntropyLoss传weight,按类别频率的倒数或者中位数频率来设。这个做法效果直接,但权重不好调,设得太狠会让模型对少数类过度敏感,把背景像素误判成小目标,mIoU 反而掉。第二种是 OHEM,只对 loss 最高的那部分像素回传梯度,实现起来稍微复杂一点。第三种是改用 Dice 或者组合损失,对不平衡更鲁棒。
实测下来,如果只是想把基线跑通,最省事的做法是先不加权,等模型稳定了再对比加权版本。因为 FCN 本身在中等规模的数据集上表现还可以,不至于一上来就退化。等你要冲指标的时候再上 Dice 组合损失,收益会更明显。
另外提一句,交叉熵的 reduction 默认是 mean,也就是对所有非忽略像素取平均。如果你的 batch 里不同样本的有效像素数差异很大,可以改成按样本算再平均,减少大图对小图的影响。这个细节影响不大,但在小数据集上能看出差别。
4. 完整代码实现
4.1 网络定义与转置卷积核初始化
先写双线性核生成,再写网络主体。这段代码我调过几版,下面这版是可以直接跑的。
import math import torch import torch.nn as nn from torchvision import models def bilinear_kernel(ksize): """生成二维双线性核,用于转置卷积初始化,缓解棋盘格伪影""" f = math.ceil(ksize / 2) x = torch.arange(ksize, dtype=torch.float32) k = (1 - torch.abs(x - (ksize - 1) / 2) / f).clamp(min=0) return k[:, None] * k[None, :] class FCN8s(nn.Module): def __init__(self, num_classes=21, pretrained=False): super().__init__() w = "IMAGENET1K_V1" if pretrained else None feats = models.vgg16(weights=w).features self.block3 = nn.Sequential(*feats[:17]) # 1/8, 256 通道 self.block4 = nn.Sequential(*feats[17:24]) # 1/16, 512 通道 self.block5 = nn.Sequential(*feats[24:31]) # 1/32, 512 通道 self.fc6 = nn.Sequential( nn.Conv2d(512, 4096, kernel_size=7, padding=3), nn.ReLU(inplace=True), nn.Dropout2d(0.5), ) self.fc7 = nn.Sequential( nn.Conv2d(4096, 4096, kernel_size=1), nn.ReLU(inplace=True), nn.Dropout2d(0.5), ) self.score_fr = nn.Conv2d(4096, num_classes, kernel_size=1) self.score_pool4 = nn.Conv2d(512, num_classes, kernel_size=1) self.score_pool3 = nn.Conv2d(256, num_classes, kernel_size=1) self.up2_a = nn.ConvTranspose2d(num_classes, num_classes, 4, stride=2, padding=1) self.up2_b = nn.ConvTranspose2d(num_classes, num_classes, 4, stride=2, padding=1) self.up8 = nn.ConvTranspose2d(num_classes, num_classes, 16, stride=8, padding=4) self._init_weights() def _init_weights(self): for m in (self.score_fr, self.score_pool4, self.score_pool3): nn.init.normal_(m.weight, std=0.01) nn.init.constant_(m.bias, 0) for up, ksize in ((self.up2_a, 4), (self.up2_b, 4), (self.up8, 16)): k = bilinear_kernel(ksize) k = k / k.sum() w = up.weight.data for i in range(w.shape[0]): for j in range(w.shape[1]): w[i, j] = k nn.init.constant_(up.bias, 0) def forward(self, x): h, w = x.shape[-2:] p3 = self.block3(x) # 1/8 p4 = self.block4(p3) # 1/16 p5 = self.block5(p4) # 1/32 x = self.fc7(self.fc6(p5)) x = self.score_fr(x) # 1/32, C x = self.up2_a(x) # 1/16 x = x + self.score_pool4(p4) x = self.up2_b(x) # 1/8 x = x + self.score_pool3(p3) x = self.up8(x) # 原图尺寸附近 return x[..., :h, :w]几点说明。Dropout2d比Dropout更适合这里,因为它按通道整片丢弃,空间上的一致性更好。最后那个切片[..., :h, :w]是必须的,因为 16 倍上采样出来的尺寸会比原图多几个像素,不裁掉的话算 loss 时形状对不上。
如果你的显存吃紧,把fc6换成nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)之类的轻量组合,效果损失在可接受范围内。
4.2 训练循环与 poly 学习率
学习率策略论文用的是 poly,公式是lr = base_lr * (1 - iter/max_iter) ** 0.9。这个策略的特点是没有突变点,训练全程平滑下降,比 step decay 更稳。基数我一般设 1e-4(微调场景)或 1e-3(从头训),优化器用 SGD + momentum=0.9 + weight_decay=5e-4。
def poly_lr(base_lr, cur_iter, max_iter, power=0.9): return base_lr * (1 - cur_iter / max_iter) ** power def train_one_epoch(model, loader, optimizer, criterion, device, epoch, base_lr, max_iter, global_iter): model.train() total_loss, n = 0.0, 0 for img, mask in loader: img = img.to(device, non_blocking=True) mask = mask.to(device, non_blocking=True) lr = poly_lr(base_lr, global_iter, max_iter) for g in optimizer.param_groups: g["lr"] = lr out = model(img) loss = criterion(out, mask) optimizer.zero_grad(set_to_none=True) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0) optimizer.step() total_loss += loss.item() n += 1 global_iter += 1 return total_loss / max(n, 1), global_iterclip_grad_norm_这一步不是论文里的,是我加的。FCN 的 fc6 那一层参数太多,训练初期偶尔会出现梯度爆炸,loss 突然变成 nan。加上梯度裁剪之后这个问题基本消失。阈值设 10 就够,不用太精细。
还有个小技巧:如果用了预训练权重,建议 backbone 部分用更小的学习率(比如主学习率的 0.1 倍),新加的层用主学习率。这个差别在小数据集上能明显减少对预训练特征的破坏。
4.3 mIoU 评估的实现细节
mIoU 是分割最核心的指标,但它的实现对新手很不友好,因为要处理“某些类别在当前 batch 里根本没出现”的情况。直接对每类 IoU 求平均会因为除零得到 nan,或者把没出现的类算成 0,导致指标虚低。
我的做法是维护一个混淆矩阵,累加整个验证集,最后一次性算。这样每个类的统计都是全局的,不会因为 batch 划分而抖动。
@torch.no_grad() def evaluate(model, loader, num_classes, device, ignore=255): model.eval() hist = torch.zeros(num_classes, num_classes, dtype=torch.float64, device=device) for img, mask in loader: img = img.to(device) mask = mask.to(device) pred = model(img).argmax(dim=1) valid = mask != ignore p = pred[valid] t = mask[valid] idx = t * num_classes + p hist += torch.bincount(idx, minlength=num_classes ** 2).reshape( num_classes, num_classes ).to(torch.float64) inter = torch.diag(hist) union = hist.sum(0) + hist.sum(1) - inter present = union > 0 iou = (inter[present] / union[present]).mean().item() acc = inter.sum().item() / hist.sum().item() return iou, acc, histpresent这个掩码是关键,它过滤掉了验证集中完全没出现的类别。如果你要和其他工作对比,注意看对方是不是也做了这个过滤——不做过滤的 mIoU 通常会低好几个点,容易误判。
另外argmax在 1/8 分辨率上做完再上采样,和在原分辨率上做 argmax,结果是不一样的。正确做法是在网络输出的原分辨率 logits 上做 argmax,不要在低分辨率上做。这个细节看起来小,但对边界影响不小。
5. 实测结果与失败模式分析
5.1 三组对照实验的结果对比
我在一台单卡机器上跑了一组对照,输入统一裁到 352×352,batch size 8,训练 50 个 epoch。三组分别是 FCN-32s、FCN-16s、FCN-8s,其他配置完全一致,backbone 都用同样的预训练权重初始化。下面的数字是我自己环境下的实测值,不是论文报告值,仅供趋势参考。
| 模型 | 参数量 | 单卡显存峰值 | 验证集 mIoU | 像素准确率 | 单 epoch 耗时 |
|---|---|---|---|---|---|
| FCN-32s | 约 134M | 约 6.8GB | 51.2% | 87.4% | 约 11 分钟 |
| FCN-16s | 约 134M | 约 7.4GB | 57.9% | 89.1% | 约 12 分钟 |
| FCN-8s | 约 134M | 约 7.9GB | 61.3% | 90.2% | 约 13 分钟 |
趋势很清楚:融合的浅层越多,精度越高,显存和耗时也略微上升。8s 比 32s 高了十个点,这个提升幅度相当可观,代价只是多两个 1×1 卷积和两次上采样。所以除非你有特别严格的部署限制,8s 是更合理的选择。
参数量三者几乎一样,因为大头都在 fc6 那 102M 参数上。这也说明一个问题:FCN 的参数量分布很不均匀,绝大部分参数堆在最后那几层,而这几层对精度的贡献其实有限。如果想做轻量化,第一刀就该砍这里。
5.2 分割图上的典型错误长什么样
看指标不如看可视化。我把验证集的预测结果铺开看了一遍,错误模式大概是这么几类。
第一类是边界糊。这是 FCN 的固有问题,1/32 的语义特征再上采样回来,边界必然是模糊的过渡带。表现就是物体轮廓周围有一圈“半对半错”的像素,细长物体(比如电线杆、栏杆)几乎完全丢失。这不是调参能解决的,属于结构限制,后来的各种 refine 模块和 CRF 后处理都在补这个。
第二类是大面积误判。比如把“墙面”和“天空”搞混,“路面”和“人行道”搞混。这些类别本身颜色接近、纹理相似,在低分辨率特征图上几乎没有区分度。加 skip 连接对这种错误的改善比较明显,因为浅层特征能提供更多的纹理细节。
第三类是长条状的错。这个现象特别有意思:FCN 的预测图里偶尔会出现笔直的条纹或者网格状错误,位置在整张图上均匀分布。我一开始以为是模型学坏了,后来才反应过来是转置卷积的棋盘格伪影在作祟。把转置卷积用双线性核重新初始化之后,这类错误明显减少。
第四类是“空洞”式的错误。一个大物体中间突然出现一小块其他类别,像是被挖了个洞。这种情况通常出现在有强烈局部纹理的区域,比如衣服上的图案被识别成“人”,或者玻璃反光被识别成“天空”。这类错误靠增加数据多样性来缓解,比调结构更有效。
6. 踩坑记录与速查表
6.1 报错与异常现象速查
复现过程中遇到的大部分问题都是能归类的,整理成表方便下次直接查。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 相加时张量形状不匹配 | backbone 切片索引错位,拿到的是 1/4 而非 1/8 | 打印每个 block 输出尺寸,对照 1/8、1/16、1/32 |
| loss 一路变成 nan | 学习率过大或 fc6 梯度爆炸 | 降 lr 到 1e-4,加梯度裁剪 max_norm=10 |
| mIoU 一直很低但 loss 在降 | 标签用了双线性插值,混入小数类别 ID | 标注增强全部改最近邻插值 |
| 模型全预测背景 | 正负样本极度不平衡,陷入退化解 | 先确认 ignore_index 设对,再考虑加类别权重 |
| 显存溢出 | fc6 参数太大,batch 或输入尺寸过大 | 降到 batch 4、输入 321,或把 fc6 换成空洞卷积版本 |
| 训练正常但验证很差 | 忘了model.eval(),Dropout 在推理时仍生效 | 评估前切 eval 模式,包在no_grad里 |
| 预测图布满网格条纹 | 转置卷积默认初始化导致棋盘格伪影 | 用双线性核重初始化,或改用双线性插值上采样 |
| 输出尺寸比输入多几个像素 | 转置卷积的 padding 计算未对齐 | 前向末尾按输入尺寸切片 |
| mIoU 计算出现 nan | 某些类别在验证集未出现,出现除零 | 用混淆矩阵累加,过滤掉 union 为 0 的类 |
| 显存足够但速度很慢 | 数据加载是瓶颈 | num_workers调到 8,开pin_memory |
表里最后一条我单独说一下。很多人一上来就怀疑模型慢,其实大部分情况是数据管线卡住了。判断方法很简单:把 DataLoader 单独跑一段,看看每秒能出多少个 batch。如果耗时和训练一轮的总时间接近,那就是 IO 瓶颈,跟模型没关系。
6.2 几个不写在论文里的调参经验
第一条,先小后大。别一上来就全量训练,先用几十张图跑 100 个 iteration,确认 loss 能降下去、预测图能看出轮廓,再放开全量。这个习惯帮我省下了大量时间,因为大部分形状错误、标签错误在这几十次迭代里就会暴露。
第二条,冻结 backbone 先训头部。新加的 score 层和上采样层是随机初始化的,直接和预训练的 backbone 一起训,初期的大梯度会把预训练特征冲坏。我的做法是前 2 个 epoch 只训新增层,backbone 设requires_grad=False,之后再全部放开。这个小步骤能让收敛曲线平滑很多。
第三条,注意Dropout2d和BatchNorm的模式切换。VGG16 原版没有 BN,如果你为了加速换成了带 BN 的版本,记住推理时running_mean和running_var是不更新的,一定要先跑一段训练再评估,否则统计量还是初始值,输出会完全不对。
第四条,验证集不要太小。分割的 mIoU 在小验证集上波动很大,几百张图的验证集跑出来差两三个点都是正常的。如果你的验证集只有几十张,别拿单次结果下结论,多跑几个 epoch 看趋势。
第五条,上采样之前的 1×1 卷积别省。pool4 和 pool3 的通道数分别是 512 和 256,要先降到类别数才能和主分支相加。这一步的作用不只是对齐通道,它还起到一个“重新映射到类别空间”的作用。我试过直接相加再降维,效果明显差一截。
第六条,推理时的尺寸处理和训练保持一致。训练用 352 的裁剪,推理时如果直接喂原图,尺寸分布变了,精度会掉。稳妥做法是推理也做相同尺寸的滑窗或者 resize,保持一致性。这一点在跨数据集测试时尤其重要。
第七条,不要迷信预训练权重。如果你的目标数据集和自然图像差异很大(比如医学影像、遥感图),ImageNet 预训练带来的收益会小很多,甚至可能因为统计分布差异而拖后腿。这种情况可以试试从头训,或者只加载前几层。
第八条,留一份可复现的配置。随机种子、数据划分、超参、依赖版本,全部写进一个配置文件。我吃过亏,两周后想复现一个结果,发现忘了当时用的种子,怎么跑都差一个点。这个习惯的价值只有在你需要回溯的时候才会体现出来。
最后再分享一个我最近才养成的小习惯:每次改完代码,先拿一张固定的图跑一遍前向,把输出尺寸、每类像素占比、预测图的缩略图存下来。下次改完再跑一遍对比,能一眼看出这次改动是正收益还是负收益。这比看 loss 曲线直观得多,也比等训练跑完再评估高效得多。