PWC-Net 在 Paddle 下的光流训练复现与调优实战
复现做到第三篇,真正耗时间的部分才刚刚开始。前面把特征金字塔、warp、代价体这些前向模块对齐完,跑通一次推理只花十分钟,但训练这一关,从数据管道到学习率调度,随便哪个环节出岔子都能让你盯着 loss 曲线怀疑人生。这篇就专门讲 PWC-Net 在 Paddle 下训练部分的完整落地:从超参数的来源、数据增强的取舍、warp 和代价体的实现细节,到多尺度损失怎么加、显存怎么估、loss 炸了怎么查。不管你是刚把网络结构搬完想跑通第一个 epoch,还是已经能训练但结果对不上参考精度,下面的内容应该都能直接拿去改代码。我踩过的那些坑,尽量一次给你讲完,省得你再来一遍。
1. 训练方案的整体设计与取舍
1.1 为什么训练要分成"预训练+微调"两个阶段
光流估计这个任务有个很现实的困难:真实的稠密光流标注几乎拿不到。激光雷达、结构光、机械臂标定这些手段成本高、覆盖场景有限,而网络需要的是每个像素的位移向量。所以业界的通用做法是用合成数据先把网络喂饱,再用少量真实场景数据做精调。
PWC-Net 的训练策略也是这条路子:先在椅子类合成数据上跑一个很长的阶段(几十万到上百万次迭代),让网络学会最基本的匹配能力和金字塔各级的粗到细逻辑;然后在更复杂的合成场景数据上做第二阶段微调,提升对复杂运动和大位移的适应性;最后如果手上有真实场景带稀疏标注的数据,再做一轮小学习率的收尾微调。
这么分阶段的核心逻辑是课程学习。椅子类合成数据的运动模式非常单一——基本就是刚性物体在小范围内的平移加轻微旋转,背景干净,遮挡少。网络在这种数据上收敛很快,但它学到的是一套"简化世界"的映射。直接拿这种权重去跑真实道路场景,遇到大面积天空、反光路面、运动模糊,结果会惨不忍睹。第二阶段换成场景更复杂、运动更多样的合成数据,相当于给网络增加难度;第三阶段用真实数据,把合成域和真实域之间的差距抹平一部分。
我的建议是:如果你只是想验证复现的正确性,第一阶段跑个几万次迭代,看 loss 是否稳定下降、EPE 是否进入合理区间就够了;如果要做实际应用,那三个阶段的完整流程绕不开,尤其是在真实数据上的微调,往往能让 EPE 再降 10%~20%,这个收益比调网络结构来得实在。
1.2 PyTorch 到 Paddle 的迁移映射关系
迁移训练代码时,最容易出问题的不是模型定义,而是那些"看起来一样、实际语义不同"的 API。我在迁移过程中整理了一份对照表,供你参考:
| 功能 | 参考实现写法 | Paddle 写法 | 需要留意的点 |
|---|---|---|---|
| 双线性采样 | F.grid_sample(x, grid, align_corners=True) | F.grid_sample(x, grid, mode='bilinear', padding_mode='border', align_corners=True) | grid 最后一维顺序是 (x, y),两边一致;但 padding_mode 默认值不同 |
| 张量拼接 | torch.cat(dim=1) | paddle.concat(axis=1) | 参数名不同,dim 改成 axis |
| 矩阵乘 | torch.matmul(a, b) | paddle.matmul(a, b) | 高维广播规则基本一致 |
| 优化器 | torch.optim.Adam(params, lr=1e-4) | paddle.optimizer.Adam(parameters=..., learning_rate=...) | 参数顺序和命名都变了 |
| 学习率衰减 | torch.optim.lr_scheduler.StepLR | paddle.optimizer.lr.StepDecay | Paddle 的调度器要作为 learning_rate 传进优化器 |
| 梯度裁剪 | torch.nn.utils.clip_grad_norm_ | paddle.nn.ClipGradByGlobalNorm | Paddle 是在优化器构造时传参,不是每步调用 |
| 保存权重 | torch.save(model.state_dict()) | paddle.save(model.state_dict()) | 键名会带层级前缀,加载时要对齐 |
这张表里最坑的是grid_sample的padding_mode。参考实现里默认是zeros,而我在 Paddle 里一开始漏写了这个参数,用的是默认值,训练前期 loss 下降得很正常,到两三万次迭代之后误差开始异常增大——原因是金字塔高层的光流预测超出边界后,边界外补零会导致采样值突变,梯度被放大。后来改成border(边界复制),训练才稳定下来。光流任务里,边界外采样用border几乎总是比zeros更合理,因为光流在边界外本来就没有定义,复制边界值至少不会引入剧烈的数值跳变。
1.3 训练超参数的来源与我的实际调整
超参数这块我不建议你自己瞎试,PWC-Net 论文和几个主流复现仓库给出的配置已经相当成熟。我实际使用的配置如下:
- 优化器:Adam,
beta1=0.9,beta2=0.999,epsilon=1e-8 - 初始学习率:第一阶段
1e-4,微调阶段1e-5到5e-5 - 批大小:8(单卡显存允许的话,8 是论文的原始配置)
- 输入尺寸:随机裁剪到 384×512(高度×宽度)
- 迭代次数:第一阶段 30 万~120 万次迭代,取决于你的算力和目标精度
- 学习率衰减:每 20 万次迭代乘以 0.5
这里解释一下为什么学习率要用1e-4而不是更大。光流预测的输出是直接的像素位移,量级在几到几十之间,如果用1e-3这种量级的学习率,前期光流输出会出现几十上百像素的跳变,warp 之后的特征图完全乱掉,代价体的匹配信号变成噪声,网络会陷入一个很难爬出来的局部极小。我试过3e-4,前两千次迭代 loss 直接冲到几千,后来加了梯度裁剪才勉强稳住,但收敛速度并没有比1e-4快。
批大小 8 也是一个权衡后的结果。更大的批能带来更稳的梯度估计,但代价体的显存占用是随批大小线性增长的,而代价体恰恰是 PWC-Net 里最吃显存的部分。我在一张 16GB 显存的卡上,批大小 8 加上混合精度,峰值占用大概 12GB,再往上加就要减输入分辨率了,而分辨率对光流精度的影响比批大小更直接,所以宁可保分辨率。
2. 数据管道搭建与增强策略
2.1 数据集的目录组织与加载接口设计
数据管道这块,我建议一开始就把接口设计成通用的,因为后面三个阶段要换不同的数据集,如果每换一个数据集就改一次 Dataset 类,维护成本会很高。我的做法是定义一个抽象的数据集基类,只约定两件事:给一个索引能拿到一对图像和对应的光流,以及能告诉我这个样本的有效区域。
import os import numpy as np import paddle from paddle.io import Dataset class FlowDatasetBase(Dataset): """光流数据集基类,约定 __getitem__ 返回 (img1, img2, flow)""" def __init__(self, root, crop_size=(384, 512), flip=True, train=True): self.root = root self.crop_h, self.crop_w = crop_size self.flip = flip self.train = train self.samples = [] # 由子类填充: [(img1_path, img2_path, flow_path), ...] def __len__(self): return len(self.samples) def _load_pair(self, idx): raise NotImplementedError def __getitem__(self, idx): img1, img2, flow = self._load_pair(idx) # H W 3 uint8, H W 2 float32 if self.train: img1, img2, flow = self._random_crop(img1, img2, flow) if self.flip and np.random.rand() < 0.5: img1 = img1[:, ::-1, :] img2 = img2[:, ::-1, :] flow = flow[:, ::-1, :].copy() flow[..., 0] *= -1.0 img1 = img1.astype('float32') / 255.0 img2 = img2.astype('float32') / 255.0 img1 = img1.transpose(2, 0, 1) img2 = img2.transpose(2, 0, 1) flow = flow.transpose(2, 0, 1) return img1, img2, flow这里有一个细节值得展开:水平翻转时,光流的 x 分量必须取反。这个逻辑看起来简单,但我第一次写的时候只在 flow 上做了通道翻转,忘了 x 分量取反,结果网络在翻转样本上学到的全是反向位移,训练 loss 卡在一个明显偏高但不再下降的位置,排查了大半天才发现。类似的问题还有垂直翻转,如果要加,y 分量也要取反。
_random_crop里还有一个坑:裁剪时必须保证图像和光流用同一组随机偏移。如果两个样本各自随机裁,图像对之间的位移关系就被破坏了,网络会学到完全错误的对应关系。我见过有人把图像和光流分别用np.random调两次,这种 bug 特别隐蔽,因为 loss 还是会下降,只是精度上不去。
2.2 裁剪、翻转与颜色增强的具体做法
裁剪尺寸的选择不是随便定的。PWC-Net 的特征金字塔有 6 层,最深一层是 1/64 分辨率。如果输入是 384×512,最深层特征图是 6×8,已经小到快要失去空间结构了。如果输入再小,比如 256×256,最深层就只有 4×4,光流在这一层基本退化成全局平移,金字塔的粗到细逻辑就失效了。
还有一个约束是下采样对齐。网络的 6 次下采样都是 stride 2,如果输入尺寸不能被 64 整除,做 warp 和上采样的时候会碰到尺寸不匹配的问题。我一般把输入裁成 64 的整数倍,比如 384×512、448×576。这个约束建议在数据管道层面就强制保证,而不是在模型里到处写 padding 逻辑——后者会让代码变得非常难调试。
颜色增强只在微调阶段用,第一阶段不加。原因是合成数据的光照本身就很干净,加颜色扰动反而会拖慢收敛。微调阶段加颜色扰动(亮度、对比度、饱和度、色调的随机抖动)是为了缩小合成域和真实域的外观差距。我的实现:
def color_augment(img, brightness=0.4, contrast=0.4, saturation=0.4, hue=0.1): # img: H W 3, 取值 [0, 1] if np.random.rand() < 0.5: b = 1.0 + np.random.uniform(-brightness, brightness) img = np.clip(img * b, 0, 1) if np.random.rand() < 0.5: c = 1.0 + np.random.uniform(-contrast, contrast) mean = img.mean(axis=(0, 1), keepdims=True) img = np.clip((img - mean) * c + mean, 0, 1) return img注意颜色增强不能改变几何关系,所以只对单张图做,而且两张图要分别独立做(模拟真实的曝光差异),但不能对光流做任何处理。有些实现会两张图用同一组参数,那样其实削弱了增强的效果,因为网络会更倾向于学习"颜色匹配"这条捷径。分别独立增强能迫使网络更依赖结构信息。
2.3 多进程读取与内存占用的平衡
数据量大的时候,IO 很容易成为瓶颈。Paddle 的 DataLoader 支持num_workers参数,我的经验是设成 CPU 核数的一半左右比较合适。设太大反而会因为进程间通信开销拖慢整体速度。
loader = DataLoader( dataset, batch_size=8, shuffle=True, num_workers=6, drop_last=True, use_shared_memory=True, )use_shared_memory=True这个参数值得开,它能让子进程和主进程共享内存,避免大数据张量在进程间反复拷贝。我实测过,在同样配置下开启这个选项,一个 epoch 的耗时能降 10% 左右。
还有一个容易忽略的点是单样本预加载。光流数据集的读取涉及三张图(两张 RGB、一张 flow),如果每次都从磁盘读,即使是 SSD 也会拖慢速度。我的做法是在内存里缓存最近用过的若干样本,或者在数据集较小的时候直接全量加载到内存。椅子类合成数据大概十几万到二十几万对,全量加载到内存需要十几 GB,普通机器扛不住,所以用 LRU 缓存控制上限,比如缓存 2000 个样本,大概占 2~3GB 内存,性价比不错。
3. 前向计算中几个容易被忽略的细节
3.1 warp 操作的坐标对齐
warp 是 PWC-Net 的核心操作之一,也是实现上最微妙的地方。它的语义是:根据当前层的光流,把第二张图(或其特征)反向采样到第一张图的坐标系下。用公式表达就是warped(x) = I2(x + flow(x))。
实现的关键在于坐标归一化。grid_sample要求输入的坐标在[-1, 1]范围内,而光流是像素单位,所以要做换算。当align_corners=True时,像素坐标 i 对应的归一化坐标是2*i/(W-1) - 1;当align_corners=False时是(2*i+1)/W - 1。这两个公式必须和grid_sample的参数严格对应,否则会产生半个像素的系统性偏移。
半个像素的偏移听起来很小,但它在金字塔的每一层都会累积,最后在输出层上体现为一到两个像素的误差。更麻烦的是,这个误差不会让训练发散,loss 照样下降,只是最后精度上不去,你很难判断到底是训练不充分还是实现有 bug。
我在第一版实现里就踩了这个坑。当时用的是align_corners=False配合2*i/(W-1)-1的归一化,训练出来的模型在合成验证集上 EPE 是 1.8 左右,参考实现能到 1.2。后来把归一化改成和align_corners匹配的公式,EPE 直接降到 1.3。这就是半个像素的威力。
def warp(x, flow): """x: B C H W, flow: B 2 H W, 返回按 flow 采样后的 x""" B, C, H, W = x.shape grid_x = paddle.arange(W, dtype='float32') grid_y = paddle.arange(H, dtype='float32') grid_x, grid_y = paddle.meshgrid(grid_x, grid_y) # H W grid = paddle.stack([grid_x, grid_y], axis=-1) # H W 2 grid = grid.unsqueeze(0).tile([B, 1, 1, 1]) grid = grid + flow.transpose([0, 2, 3, 1]) # B H W 2 # align_corners=True 对应的归一化 norm_x = 2.0 * grid[..., 0] / max(W - 1, 1) - 1.0 norm_y = 2.0 * grid[..., 1] / max(H - 1, 1) - 1.0 norm_grid = paddle.stack([norm_x, norm_y], axis=-1) return paddle.nn.functional.grid_sample( x, norm_grid, mode='bilinear', padding_mode='border', align_corners=True)这段代码每次调用都要重建基础网格,有额外开销。我在实际训练里把基础网格按 (H, W) 缓存起来,避免重复构建,一个 step 能省下几毫秒。看着不多,但乘以几十万次迭代就是几十分钟。
3.2 代价体的内存占用与计算方式
代价体是 PWC-Net 里显存占用最大的一环。它的构造逻辑是:对于第二张图的每个像素,在第一张图对应位置的一个邻域窗口内,计算特征向量的内积,得到一个匹配代价的分布。
实现上有个容易走弯路的地方。最直观的写法是计算完整的 4D 相关性矩阵[B, H*W, H*W],但这个尺寸完全不可行——384×512 的图上就是B × 196608 × 196608,直接爆掉。正确做法是只计算局部窗口:对第一张图的特征做 9 次整体平移(对应 3×3 邻域),每次平移后与第二张图特征逐元素相乘并沿通道求和,得到[B, 9, H, W],最后拼接成[B, 81, H, W]。
def cost_volume(f1, f2, max_disp=4): """f1, f2: B C H W -> B (2r+1)^2 H W""" B, C, H, W = f1.shape f2 = f2 / paddle.sqrt(paddle.to_tensor(float(C), dtype=f2.dtype)) costs = [] for dy in range(-max_disp, max_disp + 1): for dx in range(-max_disp, max_disp + 1): if dx == 0 and dy == 0: shifted = f1 else: const_flow = paddle.full([B, 2, H, W], 0.0, dtype=f1.dtype) const_flow[:, 0] = float(dx) const_flow[:, 1] = float(dy) shifted = warp(f1, const_flow) costs.append(paddle.sum(shifted * f2, axis=1, keepdim=True)) return paddle.concat(costs, axis=1)为什么要除以sqrt(C)?这是为了控制内积的数值范围。特征通道数越大,内积的绝对值越大,如果不做归一化,代价体的数值会随通道数增长,后面接的卷积层需要重新学习一个合适的尺度,训练前期会不稳定。除以sqrt(C)相当于把内积变成余弦相似度的近似,数值范围稳定在合理区间。
网格半径max_disp=4对应 9×9 的搜索窗口,输出 81 通道。这个参数直接影响能处理的最大位移。在金字塔的粗层(1/64 分辨率),81 像素的搜索范围对应原图 5000 多像素的位移,完全够用;在细层(1/4 分辨率),对应原图 36 像素,对于剩余的小位移也足够。所以这个配置在整个金字塔上是自洽的。
3.3 上下文网络与上采样的实现要点
上下文网络的作用是对最终估计出的光流做一次精修。它的输入是最后一层的光流,加上第二张图经过 warp 后的特征,输出是光流的残差。结构上用的是一系列不同膨胀率的卷积(1、2、4、8、16),目的是在不损失分辨率的前提下扩大感受野。
这里有个实践上的经验:膨胀卷积的堆叠顺序会影响最终精度。我试过从大到小(16→1)和从小到大(1→16)两种顺序,从小到大略好一点,大概 0.02 的 EPE 差距。差异不大,但既然免费,就用好的那种。
上采样部分,从 1/4 分辨率的输出上采样到原图尺寸,用的是双线性插值,同时光流值要乘以缩放系数 4。这个乘法容易忘,忘了的话输出的光流整体小四倍,训练 loss 会很大但依然能下降,很容易误导你以为是别的问题。
4. 多尺度损失的设计与落地
4.1 各层损失权重的来源与取值
PWC-Net 在金字塔的多个层级都输出光流预测,训练时每一层都算损失再加权求和。这种多尺度监督的意义在于给中间层提供直接的梯度信号,避免只有最后一层有监督导致深层网络训练缓慢。
权重的经典配置是从粗到细逐渐减小:[0.32, 0.08, 0.02, 0.01, 0.005]。为什么粗层权重反而大?因为粗层的预测决定了整个金字塔的初始光流,如果粗层偏了,后面所有层都在错误的起点上做修正,网络很难通过细层的残差把大误差补回来。反过来,细层本身的预测范围小,即使权重低,它对细节的贡献也能通过残差连接传递下去。
我一开始觉得这个权重分布有点反直觉,试着改成均匀权重,结果训练前期收敛变慢,最终精度也差了一点。后来想明白了:均匀权重相当于逼着每一层都独立做准确的预测,但细层的输入已经包含了粗层 warp 后的信息,它的任务本来就是"修正"而不是"从头预测"。权重配置实际上是在告诉网络各层的职责分工。
4.2 有效像素掩码与异常值处理
光流数据集有个特点:不是所有像素都有有效标注。遮挡区域、超出图像边界的区域,标注往往是零或者无效值。如果在这些位置也算损失,相当于在教网络输出错误的答案。
处理方式是为每个尺度构造有效掩码:把光流标注中"位移后仍在图像内"且"标注值不是哨兵值"的位置标记为有效。这个掩码在数据加载阶段就按原图尺寸算好,训练时随光流一起下采样到各层尺度。
def build_valid_mask(flow, H, W, sentinel=1e9): """flow: H W 2, 返回 H W 的 0/1 掩码""" valid = (np.abs(flow) < sentinel).all(axis=-1) xx, yy = np.meshgrid(np.arange(W), np.arange(H)) tx = xx + flow[..., 0] ty = yy + flow[..., 1] inside = (tx >= 0) & (tx < W) & (ty >= 0) & (ty < H) return (valid & inside).astype('float32')这里还有个细节:掩码下采样的时候不能用平均池化,因为它会把边界上的有效和无效像素混在一起,产生 0.5 这种中间值,让边界产生虚假的软权重。应该用最近邻插值下采样,保持掩码的 0/1 性质。
在真实场景数据上,标注往往非常稀疏(只有几个百分点的像素有真值),这时候 loss 的归一化方式就很重要。如果直接用sum(loss) / numel,损失会被大量无效像素稀释,梯度信号极弱。正确的做法是sum(loss * mask) / (sum(mask) + 1e-6),只在有效像素上取平均。这个改动在稀疏标注数据上非常关键,我一开始按常规方式算,训练了几万次迭代 loss 几乎不动,换成掩码归一化之后立刻就正常了。
4.3 loss 曲线的判读方法
训练过程中,loss 曲线是最直接的诊断工具,但要会看。我总结了几种典型形态:
| 曲线形态 | 可能原因 | 处理动作 |
|---|---|---|
| 前 500 次迭代快速下降到某个值后长期平坦 | 学习率过大导致跳过了好的区域,或数据管道有 bug | 降学习率到 5e-5 重试,同时检查图像对是否对齐 |
| 缓慢下降但有明显周期性抖动 | 批内样本难度差异大,属正常现象 | 观察滑动平均,不必处理 |
| 突然出现尖峰后恢复 | 遇到异常样本(饱和、全黑、标注错乱) | 加梯度裁剪,检查数据清洗 |
| 直接变 NaN 或 inf | 数值溢出,常见于代价体内积过大或学习率过大 | 检查特征归一化,加梯度裁剪,降到 fp32 |
| 下降后缓慢回升 | 过拟合,或学习率衰减策略不合适 | 提前停止,加数据增强,或调整衰减节点 |
我个人习惯在训练时同时记录三个量:总 loss、各尺度 loss、以及每层的平均光流幅值。第三个量很有用,如果某一层的光流幅值突然变成 0 或者几百,说明这一层出问题了,能比 loss 更早发现异常。
另外,光流任务的 loss 数值不能跨阶段直接比较。第一阶段用椅子类合成数据,光流幅值小,loss 大概在 1~3 之间;第二阶段换成复杂场景,光流幅值大,loss 可能到 5~8。有人看到微调时 loss 变大就慌了,其实完全正常,换数据分布本来就会这样。
5. 训练循环的完整实现
5.1 优化器配置与学习率调度
Paddle 的学习率调度器和 PyTorch 的使用方式差别挺大,Paddle 要求把调度器对象直接传给优化器的learning_rate参数,而不是训练中手动调用step()。这个设计上的差异我一开始没注意到,导致调度器根本没生效,训练到后期学习率一直停在初始值。
import paddle lr_scheduler = paddle.optimizer.lr.StepDecay( learning_rate=1e-4, step_size=200000, # 每 20 万次迭代 gamma=0.5, # 乘以 0.5 verbose=False, ) optimizer = paddle.optimizer.Adam( learning_rate=lr_scheduler, parameters=model.parameters(), beta1=0.9, beta2=0.999, epsilon=1e-8, grad_clip=paddle.nn.ClipGradByGlobalNorm(clip_norm=1.0), weight_decay=0.0, )注意step_size是按优化器步数算的,不是按 epoch。光流数据集一个 epoch 可能有几千个 batch,如果按 epoch 理解这个参数,衰减会来得太晚。我一般直接把训练组织成固定迭代次数的循环,而不是按 epoch,这样调度逻辑更清晰。
grad_clip建议一定要加。光流任务里,warp 操作在光流幅值较大时会采到图像边缘之外,梯度容易出现尖峰。裁剪阈值 1.0 是个比较稳妥的起点,如果发现梯度范数经常被裁掉,可以调到 5.0;如果几乎从不触发,那说明训练很稳,保留也无妨。
5.2 训练主循环的代码组织
训练循环本身不复杂,但要把日志、保存、评估组织好。
import time from tqdm import tqdm def train_one_stage(model, loader, total_iters, ckpt_dir, log_every=100): model.train() optimizer = build_optimizer(model) global_step = 0 running_loss = 0.0 t0 = time.time() while global_step < total_iters: for img1, img2, flow, mask in loader: # 多尺度真值 num_levels = 5 gts, masks = [], [] for lvl in range(num_levels): s = 2 ** lvl h, w = flow.shape[2] // s, flow.shape[3] // s gts.append(paddle.nn.functional.interpolate( flow, size=[h, w], mode='bilinear', align_corners=True) / s) masks.append(paddle.nn.functional.interpolate( mask.unsqueeze(1), size=[h, w], mode='nearest').squeeze(1)) preds = model(img1, img2) # 从细到粗 loss = multiscale_loss(preds, gts, masks) loss.backward() optimizer.step() optimizer.clear_grad() lr_scheduler.step() global_step += 1 running_loss += float(loss) if global_step % log_every == 0: avg = running_loss / log_every elapsed = time.time() - t0 print(f"step {global_step} | loss {avg:.4f} | " f"lr {optimizer.get_lr():.2e} | {elapsed/global_step:.3f}s/it") running_loss = 0.0 if global_step % 20000 == 0: paddle.save({ 'model': model.state_dict(), 'optimizer': optimizer.state_dict(), 'step': global_step, }, f"{ckpt_dir}/step_{global_step}.pdparams") if global_step >= total_iters: break return model几个实操上的注意点。真值下采样时,光流的值必须同步除以缩放系数,因为位移是以像素为单位的,分辨率减半像素位移也要减半。这一步漏了的话,粗层的监督信号会差几倍,网络学出来的金字塔层级关系是错的。掩码下采样用最近邻,理由前面说过。
另外,模型输出的顺序要和真值顺序对应上。我习惯让模型从细到粗返回,因为最终输出是第一项,评估代码写起来更直观,但损失函数里要保证两者对齐。这个顺序搞反了训练也能跑,loss 也能降,就是精度上不去,属于典型的"静默 bug"。
5.3 断点续训与训练状态恢复
长时间训练一定会遇到中断,断点续训必须一开始就做好。除了模型权重和优化器状态,还要保存迭代步数,否则学习率调度会从头开始。
def resume(model, optimizer, ckpt_path): state = paddle.load(ckpt_path) model.set_state_dict(state['model']) optimizer.set_state_dict(state['optimizer']) return state['step']这里有个 Paddle 特有的问题:optimizer.state_dict()里包含了学习率调度器的状态,所以恢复时调度器会自动接着走,不需要额外处理。但如果你在恢复后重新构造了优化器对象,就必须先set_state_dict再继续训练,否则调度器会从初始学习率重新开始。
我还在 checkpoint 里额外存了一份近期的 loss 记录,恢复后可以在日志里接着打印,方便对比中断前后是否有异常。
6. 精度对齐与训练效果验证
6.1 单批过拟合测试
正式开训之前,一定要先做单批过拟合测试。做法很简单:固定一个 batch 的数据,反复训练几百次,看 loss 能不能降到接近零。这个测试能在几分钟内验证你的整个训练链路(数据加载、前向、损失、反向、优化器)是否正常。
如果单批过拟合都做不到,那大规模训练一定有问题,别浪费时间。我遇到过两次过拟合测试失败:一次是光流真值的通道顺序和模型输出反了,一次是 warp 的坐标归一化公式和align_corners不匹配。这两个问题在正常训练里都表现为"loss 下降但精度不行",很难直接定位。
6.2 EPE 评估与光流可视化
训练过程中要定期在验证集上算 EPE(端点误差),也就是预测光流和真值光流之间的平均欧氏距离。这是光流任务最标准的指标。
def evaluate_epe(model, loader): model.eval() total_err, total_cnt = 0.0, 0.0 with paddle.no_grad(): for img1, img2, flow, mask in loader: pred = model(img1, img2)[0] # 上采样到原图尺寸 pred = paddle.nn.functional.interpolate( pred, size=flow.shape[2:], mode='bilinear', align_corners=True) pred = pred * (flow.shape[3] / pred.shape[3]) err = paddle.norm(pred - flow, p=2, axis=1) * mask total_err += float(err.sum()) total_cnt += float(mask.sum()) return total_err / max(total_cnt, 1.0)除了数值指标,我还强烈建议做可视化。把预测光流按 HSV 色轮转成彩色图,和真值并排看。数值上看不出问题的时候,可视化经常能一眼看出异常,比如整体偏移、局部塌陷、边缘模糊。我调试时发现过一次问题:EPE 数值正常,但可视化显示所有物体边缘的光流都是乱的,最后定位到是上下文网络的膨胀卷积没加 padding,导致边缘像素的感受野不完整。
6.3 混合精度与多卡训练的注意事项
如果显存吃紧,混合精度是最有效的省显存手段。Paddle 的用法:
scaler = paddle.amp.GradScaler(init_loss_scaling=1024) for img1, img2, flow, mask in loader: with paddle.amp.auto_cast(level='O1'): preds = model(img1, img2) loss = multiscale_loss(preds, gts, masks) scaled = scaler.scale(loss) scaled.backward() scaler.step(optimizer) scaler.update() optimizer.clear_grad()注意损失的计算最好放在auto_cast里面,让整个前向链路都用半精度,但scaler.scale之后的反向要在外面。另外,warp里的坐标计算建议强制用 fp32,因为网格坐标对精度敏感,用 fp16 可能引入半像素级别的误差。
多卡训练用paddle.DataParallel就够,批大小按卡数放大时,学习率是否同步放大要慎重。光流任务的批大小放大后,梯度噪声确实会小一些,但学习率放大会让前期不稳定。我的做法是批大小翻倍时学习率只放大到 1.3 倍左右,不用严格线性缩放。
7. 常见问题排查实录
7.1 loss 变 NaN 或 inf 的定位思路
这是最常见的问题,原因通常有三个:学习率过大、数值溢出、数据异常。排查顺序建议是:先把学习率降到1e-5跑几步,如果还炸,那就是数值问题;如果好了,那就是学习率的事。
数值问题最常见的位置是代价体。特征值如果没有归一化,通道数大时内积会到几百上千,后面接的卷积权重在反向时梯度和这个量级相乘,很容易溢出。解决办法就是前面说的除以sqrt(C),同时在代价体之后加一层归一化。
另一个容易被忽略的是 warp 输入的合法性检查。如果光流预测出了 NaN,warp 会不会报错取决于实现——grid_sample对 NaN 坐标的处理是返回 0 而不是报错,于是 NaN 被悄悄吞掉,但梯度已经被污染。建议在训练循环里加一个调试用的断言,只在调试阶段开:
if not paddle.isfinite(preds[-1]).all(): print("检测到非有限值,保存当前 batch 用于离线分析") paddle.save({'img1': img1, 'img2': img2, 'flow': flow}, './debug_bad_batch.pdparams')7.2 显存不足时的几种应对策略
显存不足的表现通常是out of memory,但有时也表现为训练速度突然大幅下降(系统在换页)。按性价比从高到低,我推荐的应对顺序是:
- 开混合精度:显存能省 30%~40%,几乎不影响精度
- 减批大小但保持分辨率:精度损失比降分辨率小
- 减小代价体搜索半径:从 4 降到 3,通道数从 81 降到 49,代价体显存省 40%,但对大位移的适应能力会下降
- 梯度累积:小批多次累积后再更新,能模拟大批效果,但需要额外调 batch norm 的处理
我不推荐的第一反应是降分辨率。分辨率从 384×512 降到 256×320,显存是省了不少,但细层的光流预测精度会明显下降,因为小位移在低分辨率下几乎无法区分。
7.3 训练不收敛的常见根因
如果 loss 完全不下降,或者下降到一个明显偏高的位置卡住,可以按这个清单逐项核查:
- 图像对的顺序反了:光流定义是"从第一张到第二张"的位移,如果加载时两张图顺序反了,网络学到的全是反向流,loss 会卡在 2 倍平均位移的位置
- 光流的通道顺序:x 在前还是 y 在前,要和 warp 的实现一致,反了的话网络相当于在学一个坐标变换
- 真值下采样的除法漏了:粗层监督信号差 2 倍、4 倍、8 倍
- 学习率调度器没生效:检查
optimizer.get_lr()的实际值 - 数据增强破坏了图像对:图像和光流用了不同的随机变换
- 归一化没对齐:
align_corners和坐标公式不匹配
这套清单我基本每次复现都会过一遍,能覆盖八成的收敛问题。
7.4 问题速查表
| 现象 | 优先怀疑 | 快速验证方法 | 修复方式 |
|---|---|---|---|
| loss 前百步就变 NaN | 学习率过大 / 代价体数值溢出 | 降到 1e-5 重跑 100 步 | 降学习率,特征除以 sqrt(C) |
| loss 能降但 EPE 停在 2 以上 | 坐标归一化不对 / 真值缩放漏了 | 单批过拟合测试看能否到 0 | 检查 align_corners 与除法 |
| 视觉上光流整体偏移 | 图像对顺序反了 | 把输入两张图交换后再推理 | 修正数据加载顺序 |
| 微调阶段 loss 突然变大 | 数据分布切换,正常现象 | 观察几百步后是否回落 | 无需处理 |
| 训练速度突然变慢 | 显存换页 / 数据 IO 瓶颈 | 看显存占用是否超限 | 减批大小,开 shared memory |
| 边缘区域光流明显异常 | warp 的 padding_mode 用了 zeros | 检查采样函数参数 | 改为 border |
| 多卡训练比单卡还慢 | 卡间通信开销过大 | 看 GPU 利用率 | 减少卡数或换用更大批大小 |
还有一个我个人的经验:排查问题时,永远从最小的可复现单元开始。不要在全量数据集上跑几万步然后猜哪里错了,而是拿一个 batch、跑二十步、把中间所有张量的形状和数值范围打出来。光流任务的中间张量不多,一共就金字塔特征、光流、代价体这几类,全部打一遍也就几分钟,比你盲猜快得多。
再分享一个实践上的小技巧:训练前先把模型的前向跑一遍,用paddle.summary或者手动打印每一层的输出形状,然后拿笔算一下显存的理论峰值。我一般按"参数量 × 4 字节 + 各层激活 × 批大小 × 4 字节 × 1.5"来粗估,系数 1.5 是给反向传播留的余量。如果估算值超过显存的 80%,就别硬撑了,先把混合精度打开。这个习惯帮我避免了好几次跑到一半 OOM 的尴尬。
最后一个观察:训练到后期,如果 loss 下降明显放缓但还没收敛,与其继续调学习率,不如回头看看数据。我在第二阶段微调时卡了很久,最后发现是数据里有一批样本的光流真值在边缘处存在系统性偏差,把 5% 的异常样本清掉之后,EPE 直接降了 0.1。数据质量这件事,在光流这种稠密预测任务上,回报率往往比调参高得多。