做过强化学习训练的人大概率遇到过这个场景:训练跑了几千个迭代,loss 曲线看着正常,突然某个 step 之后 wandb 上的所有指标变成 NaN,策略彻底废了。更恶心的是重跑同样的配置不一定复现——因为 NaN 的触发往往是一个概率事件的连锁反应。Microduck 的训练代码里塞了四个猴子补丁(monkey patch),每一个都对应一条具体的 NaN 传播链。今天逐个拆开看。
NaN 不是 bug,是 PPO 的结构性脆弱
先说清楚为什么 PPO 特别容易出 NaN。PPO 的数据流是一条链:环境产生 reward → reward 进入 advantage 计算 → advantage 归一化 → loss → 梯度 → 参数更新 → 新策略 → 下一步的 reward。这条链上任何一个环节出现 NaN,后面全部污染。
而且 PPO 是 on-policy 算法,一个 batch 的数据用完就扔。如果一个 NaN 混进了 mini-batch 的 advantage 里,归一化时 mean 或 std 变成 NaN,整个 mini-batch 的梯度都是 NaN,优化器一步就能把所有参数打成 NaN。这不是慢衰减,是瞬间死亡。
Microduck 的 mdp.py 文件开头就是四个补丁,每个补丁拦截链上不同的污染点。
补丁一:奖励计算里的 NaN——先消毒再进 buffer
第一个补丁拦的是最源头的污染:reward 里的 NaN。
mjlab 框架在计算 reward 时有一个时序问题。它先算完所有 reward term,然后才检查环境是否需要 reset。这意味着如果一个环境在某个 step 物理状态已经崩了(比如关节角度超出合理范围导致 MuJoCo 返回 NaN),reward 计算用的就是这个 NaN 状态,所有 reward term 返回 NaN。
正常情况下 reset 会把这个环境拉回初始状态,但 NaN reward 已经写进了 episode buffer 和 PPO 的 storage。下一个 mini-batch 采样到这个环境的数据时,NaN 就进了 loss。
补丁的做法很直接:在 RewardManager.compute 返回结果之前,先用 torch.nan_to_num 把 NaN 替换成 0.0。注意它还额外清理了 _episode_sums——因为 compute 方法在返回前已经更新了 episode 统计,如果不在这里一起清理,wandb 上会显示 NaN 的 episode reward,虽然不影响训练但会让监控失真。
这个补丁不是”修复”了 mjlab 的 bug——reward 在 reset 前计算是 mjlab 的设计选择,改这个时序会影响很多其他逻辑。在返回值上消毒是侵入性最小的方案。
补丁二:advantage 归一化里的爆炸——课程切换的隐形杀手
第二个补丁拦的是一条更隐蔽的链:课程切换时的 advantage 爆炸。
Microduck 的训练用课程调度,不同阶段 reward weight 会阶跃式跳变——比如某个惩罚项的权重从 0.4 直接跳到 1.0。这个跳变对 value function 来说是一次冲击:value function 是在旧权重下训练的,新权重下所有 TD error 同时大幅偏移。
数学上发生了什么?advantage A = R – V(s)。V 是在旧 reward scale 下学的,R 突然变了,所有 A 同时偏移。归一化时 std(advantages) 变得非常小(因为所有 A 偏移方向一致,相对差异小),然后 (A - mean) / (std + 1e-8) 就会出现极大的值。这些极大值通过 loss 传到梯度,优化器把 policy std 推到负数或 NaN。
补丁的做法是在 PPO.compute_returns 之后,对 advantages 和 returns 做 nan_to_num,把 NaN 和 Inf 替换成 0.0。这不是”正确”的解法——正确的解法是在课程切换时 warm-start value function。但它是一个足够好的防御:把爆炸后的极端值清零,至少不会一步毁掉整个策略。策略会在后续迭代中逐渐适应新的 reward scale。
CLAUDE.md 里还提到了第三个补丁——ActorCritic 的 std-clamp,在 rsl_rl 5.0.1 迁移后被移除了。原来 rsl_rl 重构了 policy 模块,distribution 移到了新类里。这个补丁的作用是防止 std 被优化器推到负值。迁移后发现没有它也能正常训练,但如果 std 爆炸问题复现,需要在新类上重新打补丁。这是一个”已知的技术债”——不是消失了,是当前版本碰巧没触发。
补丁四:ONNX 导出里的被动关节陷阱
第四个补丁不是关于 NaN 的,而是关于另一个安静崩溃的场景:导出 ONNX 时 KeyError。
新版 Microduck 模型里有被动关节——下巴连杆通过等式约束闭合,属于 articulation 的一部分但没有 XML actuator。上游的导出器遍历 robot.joint_names(16 个),然后查 joint_name_to_ctrl_id(14 个映射),碰到 passive_* 就 KeyError 崩掉。
补丁的逻辑是过滤掉 passive 前缀的关节,只导出 14 个 servo 关节的元数据。这保证了 ONNX 的 action space 跟训练时一致——14 维输出,不多不少。
这个补丁看似简单,但它揭示了一个更深的问题:Microduck 有多种机器人模型(walk、allcollisions、rollers、backlash),每种模型的关节数量和布局不同。walk 模型没有被动关节,backlash 模型每个 servo 多一个 backlash hinge,rollers 模型多两个轮子。如果代码里硬编码关节索引,在不同模型上就会选错关节。
_servo_joint_ids 辅助函数就是解法。它用正则 ^(?!passive_).* 筛选非 passive 关节,返回索引列表。在 plain 模型上它是 identity(0-13),在 backlash 模型上它跳过穿插在中间的 passive joint,正确返回 14 个 servo 的索引。所有 reward、observation、event 代码都通过这个函数访问关节,不直接用索引。
NaN 传播链的完整画面
把这四个补丁连起来看,PPO 训练里的 NaN 传播路径就清楚了。
路径一:物理状态 NaN → reward NaN → episode buffer NaN → mini-batch NaN → loss NaN → 梯度 NaN → 参数全 NaN。补丁一在第二步截断。
路径二:课程切换 → value function 失准 → TD error 集体偏移 → std(adv) 极小 → 归一化后 adv 极大 → std 梯度异常 → policy std 变 NaN。补丁二在归一化后截断。
路径三(已移除):std 变负或 NaN → 下一步采样 torch.normal(mean, std) 崩溃。补丁三原本在采样前 clamp,现在依赖 rsl_rl 5.0.1 的新实现不触发。
路径四(非 NaN):passive 关节混入导出 → KeyError → 无法导出 ONNX。补丁四在遍历时过滤。
四条路径,四个补丁。每个补丁都不到十行代码,但每一个都对应着一个会让训练白跑几小时的真实失败模式。
对自己训练 RL 的启示
如果你在自己的 RL 项目里遇到 NaN 崩溃,Microduck 的做法有几个值得借鉴的思路。
第一,在 reward 返回值上做 nan_to_num。这不需要改框架代码,monkey patch 一个 wrapper 就行。成本几乎为零,收益是堵住最常见的污染源。
第二,课程切换后盯着 wandb 上的 advantage std。如果它在某个 step 突然塌缩到接近零,说明 value function 还没适应新的 reward scale。要么拉长当前阶段让 value 收敛,要么在归一化前对 advantage 做 clip。
第三,任何涉及关节索引的代码都走 helper 函数,不要硬编码。不同机器人模型可能有不同的被动关节布局,硬编码的索引在换模型时安静地选错关节,比直接报错更危险——因为策略会”正常”训练出来,但学到的行为是错的。
第四,每个补丁都写注释说明它拦的是什么故障、为什么不能用其他方式修。这比单纯打补丁重要——三个月后回来维护代码时,你需要知道这个补丁是结构性必需还是临时创可贴。Microduck 的注释做得很好,每个补丁都有背景说明,包括被移除的第三个补丁也留了说明,方便未来判断是否需要在新版本上重新打。
NaN 不是什么高深的数学问题,它是工程问题。PPO 的数据链太长,每个环节都有可能引入 NaN,光靠”希望别出 NaN”是不够的。Microduck 的做法是承认这条链会断,在每个断点放一个兜底——不优雅,但能让训练跑完 4000 个迭代不崩。
—
数据来源:pollen-robotics/microduck_rl 仓库 src/mjlab_microduck/tasks/mdp.py(四个猴子补丁完整实现、_servo_joint_ids 辅助函数、reset_with_forward_velocity 课程函数),CLAUDE.md(补丁背景说明、移除的第三个补丁注释、课程切换导致 advantage 爆炸的机制描述、PPO 数据流不变量),README.md(任务列表、模型变体、被动关节命名约定)。
