首页 / 资讯中心 / 文章详情

PWC-Net 在 Paddle 下的光流训练复现与调优实战

PWC-Net 在 Paddle 下的光流训练复现与调优实战 ★ FEATURED ARTICLE
复现做到第三篇真正耗时间的部分才刚刚开始。前面把特征金字塔、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_cornersTrue)F.grid_sample(x, grid, modebilinear, padding_modeborder, align_cornersTrue)grid 最后一维顺序是 (x, y)两边一致但 padding_mode 默认值不同张量拼接torch.cat(dim1)paddle.concat(axis1)参数名不同dim 改成 axis矩阵乘torch.matmul(a, b)paddle.matmul(a, b)高维广播规则基本一致优化器torch.optim.Adam(params, lr1e-4)paddle.optimizer.Adam(parameters..., learning_rate...)参数顺序和命名都变了学习率衰减torch.optim.lr_scheduler.StepLRpaddle.optimizer.lr.StepDecayPaddle 的调度器要作为 learning_rate 传进优化器梯度裁剪torch.nn.utils.clip_grad_norm_paddle.nn.ClipGradByGlobalNormPaddle 是在优化器构造时传参不是每步调用保存权重torch.save(model.state_dict())paddle.save(model.state_dict())键名会带层级前缀加载时要对齐这张表里最坑的是grid_sample的padding_mode。参考实现里默认是zeros而我在 Paddle 里一开始漏写了这个参数用的是默认值训练前期 loss 下降得很正常到两三万次迭代之后误差开始异常增大——原因是金字塔高层的光流预测超出边界后边界外补零会导致采样值突变梯度被放大。后来改成border边界复制训练才稳定下来。光流任务里边界外采样用border几乎总是比zeros更合理因为光流在边界外本来就没有定义复制边界值至少不会引入剧烈的数值跳变。1.3 训练超参数的来源与我的实际调整超参数这块我不建议你自己瞎试PWC-Net 论文和几个主流复现仓库给出的配置已经相当成熟。我实际使用的配置如下优化器Adambeta10.9beta20.999epsilon1e-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), flipTrue, trainTrue): 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, brightness0.4, contrast0.4, saturation0.4, hue0.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), keepdimsTrue) img np.clip((img - mean) * c mean, 0, 1) return img注意颜色增强不能改变几何关系所以只对单张图做而且两张图要分别独立做模拟真实的曝光差异但不能对光流做任何处理。有些实现会两张图用同一组参数那样其实削弱了增强的效果因为网络会更倾向于学习颜色匹配这条捷径。分别独立增强能迫使网络更依赖结构信息。2.3 多进程读取与内存占用的平衡数据量大的时候IO 很容易成为瓶颈。Paddle 的 DataLoader 支持num_workers参数我的经验是设成 CPU 核数的一半左右比较合适。设太大反而会因为进程间通信开销拖慢整体速度。loader DataLoader( dataset, batch_size8, shuffleTrue, num_workers6, drop_lastTrue, use_shared_memoryTrue, )use_shared_memoryTrue这个参数值得开它能让子进程和主进程共享内存避免大数据张量在进程间反复拷贝。我实测过在同样配置下开启这个选项一个 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_cornersTrue时像素坐标 i 对应的归一化坐标是2*i/(W-1) - 1当align_cornersFalse时是(2*i1)/W - 1。这两个公式必须和grid_sample的参数严格对应否则会产生半个像素的系统性偏移。半个像素的偏移听起来很小但它在金字塔的每一层都会累积最后在输出层上体现为一到两个像素的误差。更麻烦的是这个误差不会让训练发散loss 照样下降只是最后精度上不去你很难判断到底是训练不充分还是实现有 bug。我在第一版实现里就踩了这个坑。当时用的是align_cornersFalse配合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, dtypefloat32) grid_y paddle.arange(H, dtypefloat32) 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_cornersTrue 对应的归一化 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, modebilinear, padding_modeborder, align_cornersTrue)这段代码每次调用都要重建基础网格有额外开销。我在实际训练里把基础网格按 (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_disp4): f1, f2: B C H W - B (2r1)^2 H W B, C, H, W f1.shape f2 f2 / paddle.sqrt(paddle.to_tensor(float(C), dtypef2.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, dtypef1.dtype) const_flow[:, 0] float(dx) const_flow[:, 1] float(dy) shifted warp(f1, const_flow) costs.append(paddle.sum(shifted * f2, axis1, keepdimTrue)) return paddle.concat(costs, axis1)为什么要除以sqrt(C)这是为了控制内积的数值范围。特征通道数越大内积的绝对值越大如果不做归一化代价体的数值会随通道数增长后面接的卷积层需要重新学习一个合适的尺度训练前期会不稳定。除以sqrt(C)相当于把内积变成余弦相似度的近似数值范围稳定在合理区间。网格半径max_disp4对应 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, sentinel1e9): 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_rate1e-4, step_size200000, # 每 20 万次迭代 gamma0.5, # 乘以 0.5 verboseFalse, ) optimizer paddle.optimizer.Adam( learning_ratelr_scheduler, parametersmodel.parameters(), beta10.9, beta20.999, epsilon1e-8, grad_clippaddle.nn.ClipGradByGlobalNorm(clip_norm1.0), weight_decay0.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_every100): 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], modebilinear, align_cornersTrue) / s) masks.append(paddle.nn.functional.interpolate( mask.unsqueeze(1), size[h, w], modenearest).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(fstep {global_step} | loss {avg:.4f} | flr {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, sizeflow.shape[2:], modebilinear, align_cornersTrue) pred pred * (flow.shape[3] / pred.shape[3]) err paddle.norm(pred - flow, p2, axis1) * 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_scaling1024) for img1, img2, flow, mask in loader: with paddle.amp.auto_cast(levelO1): 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 输入的合法性检查。如果光流预测出了 NaNwarp 会不会报错取决于实现——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。数据质量这件事在光流这种稠密预测任务上回报率往往比调参高得多。
阅读完成 · 觉得有帮助?
咨询建站