Skip to content

第 25 章 训练诊断与全书调参地图

本章定位:Ch24 解决了"如何把训练跑得更大更快"——多 GPU、NaN 排查、性能优化。但"跑得快"不代表"跑得好"。训练 reward 上涨但机器人抖动?entropy 暴跌但动作单一?KL 突然飙升策略退化?这些都是训练已经在运行、但结果不尽人意的问题。Ch25 的核心目标是建立一套系统性的训练诊断方法论——让你不再靠直觉猜参数,而是从指标组合模式出发,定位问题根源,然后在全书已有的调参工具中找到对应的修复手段。

本章不是一个独立的技术模块,而是一张覆盖前序训练/部署章节、并为 Ch26-Ch28 网球项目提供入口的调参索引地图。你在 Ch06 学会了设计 reward,在 Ch07 学会了配置 PPO 超参,在 Ch08 学会了 DR,在 Ch09 学会了 Teacher-Student——但面对具体的训练问题时,你可能不知道"这个症状应该从哪一章的工具箱里找解法"。Ch25 就是这张从症状到解法的路由表。

前置依赖:Ch04(Manager-Based 架构)、Ch05(Obs/Action 设计)、Ch06(Reward/Curriculum/Termination)、Ch07(训练管线/PPO 超参)、Ch08(Domain Randomization)、Ch24(大规模训练/NaN 排查)

关键参考:🔧 mjlab 内置日志系统 · 🔧 Isaac Lab WandB 集成 · ✅ AGILE(arXiv:2603.20147,github.com/nvidia-isaac/WBC-AGILE)· ✅ unitree_rl_lab(github.com/unitreerobotics/unitree_rl_lab)· ✅ unitree_rl_mjlab(github.com/unitreerobotics/unitree_rl_mjlab)· ✅ ASAP(RSS'25,github.com/LeCAR-Lab/ASAP)· ✅ HoST(RSS'25,github.com/OpenRobotLab/HoST)· ✅ WoCoCo(CoRL'24,github.com/LeCAR-Lab/wococo


前置自测

📋 答不出 ≥ 3 题 → 先回 Ch06-Ch07 复习

  1. [Ch07] PPO 的 desired_kl 参数在 RSL-RL 中起什么作用?当实际 KL 超过该值时,框架做了什么?
  2. [Ch06] 你的 reward 由 tracking + regularization + contact 三类构成,训练时 WandB 上应该看到几条分项 reward 曲线?如果只看到总 reward 曲线,你遗漏了什么配置?
  3. [Ch07] PPO 的 entropy bonus 是什么?它对策略探索有什么影响?entropy 趋近零意味着什么?
  4. [Ch06] TerminationTermCfgtime_out=Truetime_out=False 对 value bootstrapping 有什么不同影响?
  5. [Ch24] 训练中出现 NaN 后,你应该用什么命令收集 NaN dump 数据?

本章目标

学完本章后,你应该能够:

  1. 联合阅读五大训练指标(reward / KL / entropy / value_loss / episode_length),从曲线形态识别出至少 9 种典型的训练模式
  2. 使用症状驱动调参索引表,在 30 秒内从训练症状定位到应该调整的参数和对应章节
  3. 执行从 smoke test → zero agent → random agent → 小规模训练 → 诊断 → 修复的完整调试工作流
  4. 区分mjlab 和 Isaac Lab 的日志系统差异,在两个框架中都能高效获取诊断信息
  5. 设计消融实验来隔离 reward / obs / action / DR 各模块对训练结果的独立影响
  6. 应用高级平滑性分析工具(L2C2、action rescaler)诊断策略在真机上的可部署性

25.1 训练指标的物理直觉:五个仪表盘 ⭐⭐

这一节解决什么问题:建立对五大训练指标的物理直觉,使你能"读懂"训练过程而非只是盯着曲线等它涨。

动机:为什么需要同时看多个指标

回顾 Ch07:PPO 训练循环的核心是 rollout → GAE → update。在那里我们学到了 PPO 的各个超参数(learning_rate、clip_range、gamma、lam 等)如何影响训练稳定性。但 Ch07 关注的是"每个参数单独是什么意思",而实际训练中,你面对的是一组指标的联合行为

一个跨领域类比可以帮助理解:训练指标之于 RL 工程师,就像车辆仪表盘之于驾驶员。油量表(reward)告诉你目标方向是否正确,转速表(KL divergence)告诉你引擎负荷是否合理,水温表(value loss)告诉你散热系统是否正常,时速表(episode length)告诉你实际行驶效率。一个有经验的驾驶员不会只盯着油量表——他会同时扫视所有仪表,因为"油量正常但转速飙红"意味着虽然还在前进但引擎快坏了。同样,"reward 在涨但 KL 也在飙"意味着虽然策略在改善但学习过程在失控。这个类比的边界在于:汽车仪表之间的因果关系大多是单向的(发动机过热→水温升高),而训练指标之间的因果关系是循环的(reward 设计影响 value loss → value loss 影响 GAE → GAE 影响策略更新 → 策略更新影响 reward)。

如果只看 reward 会怎样

反事实推理:假设你的诊断工具只有 reward 曲线。reward 在稳步上升,你认为训练正常。但实际上:(1) KL divergence 已经超出 desired_kl 的 3 倍,意味着每次 update 策略改变太大,训练即将崩溃;(2) entropy 已经降到接近零,策略收敛到了一个确定性动作但那个动作恰好是一种巧妙的 reward hacking;(3) value loss 在振荡,critic 跟不上 actor 的变化速度,GAE 的方差在增大。等 reward 真正掉下来的时候,你已经浪费了数小时训练时间,而且不知道从什么时候开始出了问题。如果一开始就同时监控所有指标,你可以在 reward 下降之前就发现异常征兆。

五大核心指标概览

指标 物理直觉 健康范围(经验值) 异常信号
Reward(总/分项) 策略的"成绩单" 持续上升或稳定在合理水平 突降、振荡、过早饱和
KL Divergence 每次 update 策略"走了多远" 0.005 ~ 0.02(desired_kl 附近) >0.05 学太快,<0.001 学太慢
Entropy 策略的"犹豫程度" 缓慢下降(探索→收敛) 骤降(过早收敛),不降(学不到)
Value Loss Critic 的预测误差 先升后降,最终稳定 持续上升或剧烈振荡
Episode Length 策略"活了多久" 逐步趋近 max_episode_length 卡在很短值不增长

本质洞察:训练诊断的核心不是单个指标的绝对值, 而是多个指标之间的相对变化模式。 一个指标的"异常"往往要通过另一个指标来确认和解释。 Reward 上涨 + KL 稳定 = 健康学习;Reward 上涨 + KL 飙升 = 即将崩溃。

mjlab 中的指标获取

在 mjlab 中,这五大指标通过 RSL-RL 的 PPOTrainer 自动记录。训练启动后,WandB 或 TensorBoard 日志中会出现以下 key:

# mjlab + RSL-RL 自动记录的核心指标
# WandB panel 中的路径:
"Loss/value_function"       # value loss
"Loss/surrogate"            # PPO surrogate loss(策略损失)
"Loss/learning_rate"        # 当前学习率
"Policy/mean_noise_std"     # 动作分布标准差(与 entropy 相关)
"Policy/entropy"            # 策略 entropy
"Perf/total_fps"            # 训练吞吐量
"Train/mean_reward"         # 平均 episode reward
"Train/mean_episode_length" # 平均 episode 长度
"Train/kl"                  # KL divergence(实际值)

关键工程细节:mean_noise_stdentropy 的关系。RSL-RL 中高斯策略的 entropy 由动作分布的标准差决定:\(H = \frac{1}{2} \ln(2\pi e \sigma^2) \times d\),其中 \(d\) 是动作维度。因此 mean_noise_std 下降等价于 entropy 下降。但它们在 WandB 面板上是分开显示的——mean_noise_std 更直观(直接对应动作的随机性幅度),entropy 更数学化(对应信息论度量)。

Isaac Lab 中的指标获取

Isaac Lab 支持多个 RL 后端(RSL-RL、RL Games、SKRL 等),日志路径因后端而异。以 RSL-RL 后端为例:

# Isaac Lab + RSL-RL 后端的核心指标
# 配置文件中启用 WandB:
# --logger wandb --wandb_project my_project

# WandB 中的指标路径与 mjlab 基本一致(因为共享 RSL-RL 后端):
"Loss/value_function"
"Loss/surrogate"
"Policy/mean_noise_std"
"Train/mean_reward"
"Train/mean_episode_length"

使用 RL Games 后端时,指标路径不同:

# Isaac Lab + RL Games 后端
"losses/a_loss"         # actor loss
"losses/c_loss"         # critic loss
"info/kl"               # KL divergence
"info/entropy"          # entropy
"episode_lengths/step"  # episode length
"rewards/step"          # reward

关键差异:RL Games 的 KL 计算方式与 RSL-RL 不完全相同——RL Games 使用近似 KL(基于 ratio 和 log_ratio),而 RSL-RL 使用解析 KL(高斯分布的闭式 KL)。这意味着同一个训练在两个后端的 KL 数值不可直接对比,但变化趋势是一致的。

Episode Length:被低估的诊断信号

Episode length 经常被忽视,但它是一个非常直观的任务成功度指标。在大多数 locomotion 任务中,episode 提前结束意味着机器人摔倒了。因此 episode length 上升 = 机器人活得更久 = 策略在变好。

Episode length 的诊断价值在于它和 reward 的不一致性

Reward vs Episode Length 含义
都涨 健康——策略活得更久且得分更高
Reward 涨但 length 不涨 reward 在短 episode 内拿高分(可能 hacking)
Reward 不涨但 length 涨 策略学会了"存活"但没学会"做任务"
Reward 涨但 length 降 策略变快了——用更短时间拿到同样的分(某些任务中是好的)

特别注意:在 mjlab 和 Isaac Lab 中,max_episode_length 是一个重要的 termination 条件。当 episode 达到 max length 时触发 time_out termination,此时 value bootstrapping 的行为取决于 time_out=True 的设置(回顾 Ch06:time_out=True 意味着这不是真正的"失败终止",PPO 会对最终状态做 value bootstrapping 而非假设后续 return 为零。在那里我们学到错误的 time_out 设置会导致 value function 系统性偏估,现在我们从训练诊断的角度看——如果 episode length 始终达到 max 且 reward 不再增长,可能需要增大 max_episode_length)。

⚠️ 常见陷阱

⚠️ 编程陷阱:WandB 未记录分项 reward - 错误做法:只在 WandB 看到总 mean_reward,但看不到每个 reward term 的分项曲线 - 后果:当 reward 行为异常时无法定位是哪一项出了问题 - 正确做法:在 mjlab 中,RewardManager 自动将每个 RewTerm 的值记录到 Rewards/term_name。确认你的 WandB 面板中能看到每个 term。如果看不到,检查 RewardManager 的 log_items 配置

⚠️ 概念误区:KL 越小越好 - KL 接近零意味着策略几乎没在更新——learning_rate 太小或 clip_range 太紧 - 正确理解:KL 应该围绕 desired_kl(RSL-RL 默认 0.01)波动。过高意味着更新过猛,过低意味着学习停滞

⚠️ 思维陷阱:不同 RL 后端的指标可直接比较 - RSL-RL 和 RL Games 的 entropy / KL / value_loss 计算方式不同 - 正确做法:只比较同一后端的不同实验;跨后端比较时关注趋势而非绝对值

练习

  1. [操作题] 在 mjlab 中启动一个 Mjlab-Velocity-Flat-Unitree-Go2 训练,配置 WandB 日志。训练 500 iteration 后,截图 WandB 面板,标注五大核心指标的位置。哪些指标需要在 WandB 中手动添加到面板?
  2. [分析题] 如果 mean_noise_std 从 1.0 下降到 0.01,但 mean_reward 没有提升,这说明什么?策略学到了什么、没学到什么?
  3. [跨章综合题] 结合 Ch07(PPO 超参)和本节内容:如果你把 num_learning_epochs 从 5 增加到 20,预测 KL divergence 和 value_loss 会如何变化?写出你的推理过程,然后用实验验证。

上节建立了对五大指标的初步认知——它们各自测量训练的哪个维度。但知道"每个仪表盘是什么"还不够,你需要知道"每个仪表盘的刻度应该怎么读"。接下来两节分别深入两组指标:25.2 聚焦 Reward 曲线的解剖,25.3 聚焦 KL/Entropy/Value Loss 这组反映"学习过程健康度"的指标。

25.2 Reward 曲线深度解读 ⭐⭐

这一节解决什么问题:教你从 reward 曲线的形态中读出比"涨了"或"跌了"更丰富的信息。

动机:reward 的六种典型形态

回顾 Ch06:我们学到 reward 设计的核心原则——tracking reward 驱动目标行为,regularization 惩罚不良行为,curriculum 控制难度递进。当时我们重点讲"怎么设计 reward",现在的问题是"设计好之后,训练出来的 reward 曲线告诉了你什么"。

一条 reward 曲线不只是"涨了多少",它的形态(上升速率、平台位置、波动幅度、是否有突变)包含了大量诊断信息。以下是六种典型形态及其含义:

形态 曲线特征 通常含义 对应操作
健康上升 平滑单调上升,逐步减速 策略在稳定学习 继续训练
过早饱和 快速上升后卡在远低于理论上限的值 reward 太容易拿、任务太简单、或 reward hacking 检查 reward 上限、增大任务难度、加 regularization
振荡不收敛 反复涨跌,无明显趋势 learning_rate 过高、KL 过大、reward 信号冲突 降 learning_rate、收紧 clip_range、检查 reward term 符号
突然崩溃 稳定上升后突然暴跌 策略进入不稳定区域、NaN、curriculum 跳变太大 检查 KL 是否同时飙升、检查 NaN guard、回退 curriculum
缓慢下降 训练初期正常但逐步下滑 value function 发散、obs 分布漂移 检查 value_loss、检查 obs normalization
零信号 reward 始终在零附近不动 任务太难、reward 太稀疏、obs 缺信息 简化任务、加 dense reward、检查 obs 是否包含足够信息

分项 reward 的诊断价值

反事实推理:如果只看总 reward 曲线,而不看分项 reward 曲线,你会错过什么?假设总 reward 稳定上升到 100。但分项显示:velocity tracking reward = 150,joint acceleration penalty = -50。策略学到了"暴力加速 → 拿高 tracking 分",代价是关节震动。如果 penalty 权重不够大,总 reward 还在涨,但行为已经不可接受了。这就是 Ch06 提到的"reward 涨但动作丑"的经典症状——只有通过分项 reward 才能定位到底是 tracking 太容易还是 regularization 太弱。

分项 reward 的阅读方法:

分项类别 健康走向 异常走向及含义
Tracking reward(如速度跟踪) 逐步上升 过快饱和→tracking sigma 太大(Ch06);不升→obs 缺信息(Ch05)
Regularization(如关节加速度惩罚) 先增后稳(初期探索粗暴,后期平滑) 持续增大→策略在 hack tracking reward
Contact reward(如足端滑移惩罚) 先高后低(学会正确步态后滑移减少) 不降→地面摩擦参数问题(Ch03/Ch08)
Style reward(如步态周期奖励) 逐步上升 不升→style 奖励权重太低,被 tracking 淹没

本质洞察:总 reward 曲线是一个加权汇总——它隐藏了各分项之间的此消彼长。 真正的诊断信息在分项 reward 中:哪项在涨、哪项在跌、涨跌速率是否匹配设计预期。 这就像看公司财报——总利润增长不代表每个部门都健康,可能是某个部门暴利掩盖了其他部门的亏损。

Reward 尺度与归一化

一个常被忽略的工程问题:不同 reward term 的数量级差异。假设 velocity tracking reward 输出范围是 [0, 1](使用 exp(-error²/sigma) 型),而 energy penalty 输出范围是 [-100, 0](直接用关节力矩的平方和)。即使你把 energy penalty 的权重设为 0.001,它的数值影响仍然可能远大于 tracking reward。

# ❌ 数量级不匹配的 reward 配置
tracking_reward = exp(-vel_error**2 / 0.25)    # 范围 [0, 1]
energy_penalty  = -torch.sum(torques**2, dim=1) # 范围 [-500, 0]

# 即使 weight 设为 0.001,energy 项仍有 [-0.5, 0] 的范围
# 而 tracking 项只有 [0, 1]
# 策略可能先学会"不动"(energy = 0)而不是"走起来"(tracking = 1)
# ✅ 归一化后的 reward 配置
tracking_reward = exp(-vel_error**2 / 0.25)          # [0, 1]
energy_penalty  = -torch.sum(torques**2, dim=1) / N  # 除以关节数归一化
energy_penalty  = torch.clamp(energy_penalty, -1, 0)  # clamp 到 [-1, 0]

# 两项在同一数量级,权重调节才有意义

在 mjlab 中,RewardManager 的每个 RewTerm 可以配置 weight,但框架不会自动归一化各 term 的输出。你需要在 term 函数内部或配置中手动控制输出范围。Isaac Lab 的情况类似——RewardTermCfg.weight 是一个乘法因子,不做归一化。

Online Reward Normalization:PPO 级别的自动缩放

除了手动控制每个 term 的输出范围,还有一种更系统的方法:在 PPO 训练管线中对总 reward 做在线归一化,让 value function 始终处理量级稳定的 target。

AGILE(arXiv:2603.20147)给出了明确的公式。设 \(r_t\) 为原始 reward,在线归一化后的 reward 为:

\[\hat{r}_t = \frac{r_t}{\sigma_r \cdot \varphi_\gamma \cdot c + \epsilon}\]

其中 \(\sigma_r\) 是 reward 的指数移动平均标准差(EMA \(\beta = 0.999\)),\(\varphi_\gamma = \frac{1}{\sqrt{1 - \gamma^2}}\) 是一个与折扣因子相关的缩放因子(对 \(\gamma = 0.99\) 约为 \(7.09\)),\(c\) 是常数通常取 \(1\)\(\epsilon = 0.01\) 防止除零。

这个公式解决什么问题?当 curriculum 升级导致 reward 量级突变时(例如从 \([0, 100]\) 跳到 \([0, 500]\)),未做归一化的 value function 需要大量样本才能适应新的 target 范围,在此期间 GAE 估计不准,策略更新方向混乱。归一化后的 \(\hat{r}_t\) 始终在 \(O(1)\) 量级,value function 的 target 稳定,GAE 质量有保障。

反事实推理:如果不做 online reward normalization,一个常见的失败模式是:你在 Ch06 中精心设计了五个 reward term 和对应权重,训练初期效果很好,但当 curriculum 从"平地"升级到"粗糙地形"后,某些 reward term 的数量级突然翻了 10 倍(因为误差更大了),导致 value loss 飙升、KL 飙升、策略振荡——这实际上不是策略的问题,而是 value function 被突变的 target scale 搞崩了。

在 RSL-RL 中,reward normalization 通过配置启用:

# RSL-RL 配置中启用 reward normalization
class PPOCfg:
    normalize_advantage = True   # 默认开启(对 advantage 归一化)
    # reward normalization 需要在环境层或额外包装器中实现
    # AGILE 的实现在 reward_normalizer.py 中

在 mjlab 和 Isaac Lab 中,normalize_advantage 是对 advantage 做归一化(减均值除标准差),而不是对 reward 做归一化。两者解决的问题不同:advantage normalization 稳定梯度方向,reward normalization 稳定 value target scale。对于有 curriculum 的复杂任务,两者都需要。

scale_by_dt 与 Episode Reward 日志中的隐含陷阱

回顾 Ch06:RewardManager 有一个 scale_by_dt=True 配置(大多数 mjlab/Isaac Lab 任务的默认设置),它让每个 step 的 reward 乘以仿真时间步长 \(dt\),将 reward 从"每步收益率"变为"时间积分收益"。这对 PPO 的 value function 有好处——因为 value function 估计的是累积折扣回报,而累积回报的量级不应该因为你改了 decimation(改变了 \(dt\))就完全不同。

但这个设计在诊断时引入了一个陷阱:WandB 上显示的 Train/mean_rewardepisode 总 reward 除以 max_episode_length_s(最大时长),注意是最大时长而非实际时长。这意味着如果一个 episode 只活了 max_episode_length_s 的 30%(机器人早早摔倒),它的显示 reward 被系统性低估了——因为分母是最大长度。只有 episode 达到 max length 时,显示 reward 才准确反映了策略的每单位时间收益。

工程陷阱:如果你不理解这个机制,可能误判"episode 变长后 reward 反而涨了"是策略变好了——实际上可能只是因为 episode 更长、分母不变,显示值自然更大。正确的做法是同时看 episode length 和 reward 曲线,只有在 episode length 稳定后的 reward 变化才反映策略质量的真实变化。

nan_to_num 对诊断的误导性

在 Ch07 中我们提到过 RewardManager.compute() 内部调用了 torch.nan_to_num——它会悄悄把 NaN 替换为 0.0,把 ±Inf 替换为有限大数。这个设计的初衷是防止单个 reward term 的数值异常导致整个训练崩溃。但从诊断角度看,它有一个严重副作用:reward 曲线"干净" ≠ 物理状态健康

设想以下场景:你的 contact reward term 中有一个除法 reward = contact_force / target_force,当 target_force = 0 时产生 NaN。nan_to_num 把这些 NaN 变成 0——WandB 上的 reward 曲线只是略有波动,完全看不出 NaN 的存在。但 NaN 意味着物理状态已经不正常了——接触力计算有 bug,或者 termination 条件没有正确拦截无效状态。

本质洞察:Reward 曲线是诊断数值问题时最后看的证据,不是最先看的。 nan_to_num 让 reward 曲线成了"报喜不报忧"的信使。 真正的数值问题要通过 obs 统计量(是否有 NaN/Inf)、 物理状态日志(关节位置是否超限)、和 NaN guard(Ch24)来发现。

⚠️ 常见陷阱

⚠️ 编程陷阱:reward term 输出 NaN 被 nan_to_num 静默掩盖 - 某个 reward term 偶尔返回 NaN(例如除以零),nan_to_num 把它变成 0,总 reward 看起来只是有一些小跳动 - 后果:NaN 的根因(物理状态异常)未被修复,积累后可能导致更严重的问题 - 正确做法:在调试阶段,暂时在 reward term 函数中加 assert not torch.isnan(reward).any() 主动捕获 NaN。确认所有 reward term 都没有 NaN 后再正式训练

⚠️ 概念误区:reward 越高策略越好 - reward 是一个设计出来的代理指标,不是任务的真实目标 - 策略可以通过"合法但不期望的方式"获取高 reward(reward hacking) - 正确做法:同时检查行为质量(视觉回放)和 reward 数值

⚠️ 思维陷阱:reward 下降一定是坏事 - 如果 curriculum 刚推进到更难阶段,reward 短暂下降是正常的 - 正确做法:把 curriculum stage 变化也记录到 WandB,用 step annotation 标记 stage 切换时间点

⚠️ 编程陷阱:online reward normalization 与 reward weight 调参矛盾 - 如果开启了 online reward normalization,你精心调的 reward weight 比例会被归一化"摊平" - 正确做法:要么手动控制 term 量级(不用 online normalization),要么全部交给 normalization 并只调 weight 比例。两种方法不要混用

Reward 曲线的时间尺度

阅读 reward 曲线时,时间尺度的选择至关重要。WandB 默认以 step 为横轴,但机器人 RL 中更有意义的横轴是 iteration(每个 iteration 包含一轮 rollout + 若干次 PPO update)或 wall-clock time。

横轴选择 适用场景 注意事项
Step 比较数据效率 不同 num_envs 设置下不可比(4096 envs 一步产生 4096 个 step)
Iteration 比较同配置的不同 seed 不同 num_envs 下一个 iteration 的数据量不同
Wall-clock time 比较端到端效率 受 GPU 型号、环境复杂度影响
Total environment steps 最公平的数据效率比较 = iteration × num_envs × steps_per_rollout

在 mjlab 中,WandB 默认记录 _step 为 iteration 计数。如果你需要 total environment steps 作为横轴,需要在配置中添加自定义 x 轴:

# 记录 total env steps 到 WandB
total_env_steps = iteration * num_envs * num_steps_per_env
wandb.log({"total_env_steps": total_env_steps}, step=iteration)

Reward Smoothing 的陷阱

WandB 和 TensorBoard 默认对曲线做 exponential moving average (EMA) smoothing。smoothing 让曲线更美观、趋势更清晰,但也隐藏了重要信息:

  • 过度 smoothing(WandB smoothing > 0.9)会掩盖突发崩溃——reward 从 100 跌到 0 在高 smoothing 下看起来只是"小下降"
  • 不 smoothing(smoothing = 0)让曲线太嘈杂,难以看趋势

推荐做法:用 smoothing = 0.6 看趋势,同时保留 smoothing = 0 的原始曲线作为参考。在 WandB 中,你可以在同一个 panel 上叠加两条线——一条 smoothed、一条 raw。

练习

  1. [分析题] 一个四足 velocity tracking 训练中,总 reward 在 iteration 2000 达到 200 并饱和。分项 reward 显示 tracking = 250、energy_penalty = -30、foot_slip = -20。这个饱和是健康的还是需要干预?如何判断?
  2. [设计题] 设计一个 WandB Custom Chart,同时显示总 reward 和三个分项 reward 的比例。写出你会使用的 Vega-Lite specification 的关键部分。
  3. [跨章综合题] 结合 Ch06(reward sigma 选择)和本节内容:如果 tracking reward 使用 exp(-error²/0.25) 中的 sigma=0.25,velocity error 需要多小才能拿到 >0.9 的 reward?如果你观察到 tracking reward 始终在 0.3 左右,反推 velocity error 大约是多少?这个计算对调整 sigma 有什么指导意义?

上节深入了 reward 曲线的解剖方法。但 reward 只回答了"策略的成绩如何",不回答"学习过程是否健康"。接下来这一节处理剩余三个反映学习过程质量的指标——KL 散度、Entropy 和 Value Loss。

25.3 KL、Entropy 与 Value Loss:学习过程的健康度 ⭐⭐⭐

这一节解决什么问题:深入理解这三个不直接反映任务成功率、但直接反映学习过程稳定性的指标。

KL Divergence:策略的"步幅"

回顾 Ch07:PPO 的核心思想是限制每次更新的策略变化幅度,用 clipping 防止策略"走太远"。KL divergence 精确测量了这个"步幅"——新策略和旧策略之间的差异。

KL divergence 的直觉:想象你在山上徒步,每步可以选择步幅大小。步幅太大(KL 高),你可能跨过山脊跌入另一个山谷(策略性能突然恶化)。步幅太小(KL 低),你在原地踏步浪费时间。最优步幅取决于地形——平坦的地方可以大步走(reward landscape 平滑的区域 KL 可以大),陡峭的地方需要小步试探(reward landscape 急变的区域 KL 必须小)。

这就是为什么 RSL-RL 实现了自适应学习率。具体来说,这是一个 bang-bang 控制器(不是 PID),在每个 PPO update 后检查实际 KL 并做二值调节:

# RSL-RL 中 KL 自适应学习率的核心逻辑(简化)
if kl > desired_kl * 2.0:
    learning_rate = max(learning_rate / 1.5, min_lr)
elif kl < desired_kl / 2.0:
    learning_rate = min(learning_rate * 1.5, max_lr)
# 注意:只有 >2x 和 <0.5x 时才触发调节
# 在 [0.5x, 2x] 的"死区"内不做调整

从控制论的视角理解这个机制:它是一个带死区的 relay 控制器——只有 KL 偏离目标超过 2 倍时才干预,且每次调节幅度固定(×1.5 或 ÷1.5)。这意味着 learning_rate 的轨迹是阶梯型的,不是平滑的。好处是简单鲁棒(不需要调节 PID 增益),坏处是在 desired_kl 附近存在持续振荡——learning_rate 在两个值之间跳动。这个振荡通常不影响训练质量,但如果你在 WandB 上看到 learning_rate 曲线像锯齿波,不要惊讶,这是正常行为。

反事实推理:如果关掉 KL 自适应(固定 learning_rate),会发生什么?在训练早期,reward landscape 变化剧烈(策略从随机到初步可用),固定 learning_rate 可能导致 KL 暴涨 → 策略崩溃 → 需要从头训练。在训练后期,reward landscape 变平(策略已经不错,在微调),固定 learning_rate 可能导致 KL 过低 → 策略几乎不更新 → 训练停滞。自适应机制的价值正在于此——它让 PPO 在不同训练阶段自动调整"步幅"。

不同任务类型对 KL 目标值的偏好也不同,这是因为 reward landscape 的"地形复杂度"不同:

任务类型 推荐 desired_kl 地形特征 理由
简单 locomotion(平地行走) 0.01-0.02 相对平滑,局部最优少 可以大步走,加速收敛
复杂 locomotion(粗糙地形) 0.008-0.01 较多局部最优 中等步幅,平衡速度与稳定
操作任务(抓取/放置) 0.01-0.02 接触切换导致不连续 接触瞬间 reward landscape 剧变,但大部分时间平滑
高动态任务(后空翻/功夫) 0.005-0.008 极端不连续 必须小步走,否则容易"飞出安全区"
球类击打任务(Ch26-28) 0.005-0.01 稀疏 + 不连续 击中球的瞬间 reward 巨变

Entropy:策略的"犹豫"

Entropy 测量策略输出分布的随机性。对于连续动作空间的高斯策略,entropy 与动作标准差之间有精确的数学关系。设动作维度为 \(d\),每个维度的标准差为 \(\sigma_i\),则:

\[H(\pi) = \frac{d}{2} (1 + \ln 2\pi) + \sum_{i=1}^{d} \ln \sigma_i\]

这个公式告诉我们三件事。第一,entropy 的"初始值"由动作维度决定——一个 12 关节的四足机器人(\(d=12\))的初始 entropy 大约是 \(\frac{12}{2}(1 + \ln 2\pi) + 12 \ln \sigma_0\)。如果 \(\sigma_0 = 1.0\),初始 entropy \(\approx 17.0\)。第二,entropy 的"单位"是 nats(因为用的是自然对数),不是 bits——不同框架可能使用不同的对数底,比较时需注意。第三,entropy 的变化速率等于 \(\sum d\ln\sigma_i / dt\)——如果所有 \(\sigma_i\) 以相同速率下降(均匀收敛),entropy 线性下降;如果某几个维度的 \(\sigma_i\) 快速降到接近零而其他维度不变(不均匀收敛),entropy 也会下降但行为上表现为"部分动作确定性、部分动作随机"。

Entropy 的生命周期:

训练阶段 Entropy 行为 含义
初始化 高(按上式取决于动作维度 \(d\) 和初始 \(\sigma\),如 \(d=12\)\(\sigma_0=1\) 时总 entropy ≈ 17 nats;每维约 1.42 nats) 策略接近随机,广泛探索
早期学习 逐步下降 策略开始收敛到有用的动作范围
中期稳定 缓慢下降或稳定 策略在最优附近微调
收敛 接近某个下限 策略基本确定性,只有少量随机性

两种 entropy 异常需要特别关注:

Entropy 骤降(50-100 iteration 内从初始值降到接近零):策略过早收敛到某个确定性动作。这通常发生在 reward 有一个"容易发现但不是最优"的局部极值时——策略发现了这个 trick 并迅速锁定在上面。解法:增大 entropy_coef(PPO 的 entropy bonus 权重),或检查 reward 是否存在容易 hack 的漏洞。但有一个关键认知需要强调:entropy_coef 不是万能药——它只能延缓收敛,不能修复 reward 设计的缺陷。如果策略收敛到局部最优是因为 reward 有漏洞,正确做法是修 reward,而不是靠 entropy bonus 强行维持探索。

Entropy 不降(训练几千 iteration 后仍在初始值附近):策略学不到有用的信息——obs 缺少关键状态、reward 太稀疏、或者 learning_rate 太低。解法:检查 obs 是否包含完成任务所需的信息(Ch05)、reward 是否提供了足够密集的学习信号(Ch06)。

类比理解:entropy 像一个学生做选择题时的犹豫程度。初学者面对问题完全随机猜(高 entropy),学习一段时间后开始有偏好(entropy 下降),最终对大多数题目都很确定(低 entropy)。如果一个学生做了几百道题后还在随机猜,说明教材有问题;如果做了两道题就对所有题目都很确定,说明可能只是记住了两个 trick 而不是理解了知识。这个类比的边界在于:学生的"犹豫"是离散的(ABCD 四选一),而策略的"犹豫"是连续的(高斯分布的标准差)。

Value Loss:Critic 的预测质量

Value loss 测量 Critic 网络预测的 \(V(s)\) 和实际 return 之间的误差。它的行为通常是先升后降——训练初期 Critic 从随机初始化开始,return 的方差很大(因为策略还在随机探索),所以 value loss 先上升;随着策略稳定,return 变得可预测,value loss 逐步下降。

Value loss 实际上同时承载了三层含义——这是一个双重解读的好例子:

  • 角度 1:Critic 学习进度指标。value loss 下降 = Critic 学得越来越准 = GAE 质量提高 = 策略更新方向更可靠。这是最直接的理解。
  • 角度 2:reward scale 和 obs 质量的间接指标。如果 reward scale 太大(某个 term 输出 [-1000, 1000]),value target 的方差极高,Critic 很难拟合,value loss 居高不下。如果 obs 质量差(包含噪声或无关信息),Critic 找不到从 obs 到 return 的映射,value loss 同样居高不下。因此 value loss 持续偏高,不一定是 Critic 网络容量不够——可能是 reward scale 或 obs 有问题。
Value Loss 行为 含义 操作
先升后降 健康 继续
持续上升 Critic 学习跟不上 Actor 变化 增加 Critic 的 num_learning_epochs 或网络容量
剧烈振荡 Reward scale 过大或 obs 分布不稳 归一化 reward、检查 obs normalization
始终很小 Reward 信号太弱 检查 reward 数量级

Value loss 和 reward 的关系:当 value loss 突然飙升而 reward 还没变时,这是一个预警信号——Critic 已经"跟不上"了,但影响还没传导到 reward。因为 PPO 用 Critic 的 \(V(s)\) 来计算 GAE(advantage estimation),Critic 不准意味着 advantage 不准,advantage 不准意味着策略更新方向不准。这个影响通常在几十到几百个 iteration 后体现在 reward 上。

本质洞察:Value loss 是训练崩溃的领先指标。 Reward 下降是结果,value loss 飙升是原因。 就像发烧是疾病的症状,白细胞异常是发烧的原因。 监控 value loss 让你在 reward 下降之前就能介入。

Multi-Critic 分组诊断:更精细的 Value Loss 分析

在标准 PPO 中只有一个 Critic 估计整体 value。但近期研究——特别是 HoST(RSS'25 Best Systems Paper Finalist,arXiv:2502.08378)——引入了 multi-critic PPO:将 reward 按功能分为若干组(例如 task / style / regularization / post-task),每组有独立的 Critic 网络。每组 Critic 的 advantage 分别归一化后加权求和。

multi-critic 的核心诊断优势在于:如果某一组 Critic 的 value loss 飙升而其他组稳定,你可以精确定位是哪一类 reward 出了问题。例如: - task 组 value loss 飙升 → tracking reward 的量级或难度发生了突变(可能是 curriculum 切换) - style 组 value loss 飙升 → style reward 和 task reward 产生了方向冲突 - regularization 组 value loss 飙升 → 正则项的量级与其他项严重不匹配

HoST 的 multi-critic 配置示例:

# HoST 风格的 multi-critic 分组配置
critic_groups = {
    "task": {
        "terms": ["velocity_tracking", "heading_tracking"],
        "network": MLP(hidden_dims=[256, 256, 128]),
    },
    "style": {
        "terms": ["gait_frequency", "foot_clearance", "body_orientation"],
        "network": MLP(hidden_dims=[128, 128]),
    },
    "regularization": {
        "terms": ["action_rate", "joint_accel", "energy"],
        "network": MLP(hidden_dims=[128, 128]),
    },
}

# 每组独立计算 advantage 并归一化
for group_name, group in critic_groups.items():
    group_return = compute_return(group_rewards, group_values, gamma, lam)
    group_advantage = group_return - group_values
    group_advantage = (group_advantage - group_advantage.mean()) / (group_advantage.std() + 1e-8)
    # WandB 记录每组的 value loss
    wandb.log({f"Loss/value_{group_name}": group_value_loss})

即使不实现完整的 multi-critic,你也可以用一个近似方法获得类似的诊断效果:分别绘制每个 reward 分组的 episode 平均值随时间的变化。如果某组 reward 的方差突然增大(曲线变得嘈杂),对应的 Critic 可能正在挣扎。在 WandB 中,创建一个 custom panel 按 reward 类别分组叠加显示,就能获得这种诊断视角。

⚠️ 常见陷阱

⚠️ 概念误区:entropy_coef 越大探索越好 - 过大的 entropy_coef 阻止策略收敛——就像给学生答题时要求"必须每个选项都选到至少 20% 的概率" - 典型范围:0.001-0.01。超过 0.05 通常会严重干扰学习 - 正确做法:从默认值开始,只在观察到过早收敛时适度增加

⚠️ 编程陷阱:value loss 用了不同的 normalization 标准 - mjlab/RSL-RL 默认对 value target 做 normalization - 如果你关掉了 normalize_value,value loss 的绝对值会大很多,但不代表 Critic 更差 - 正确做法:比较实验时确保 normalization 配置一致

⚠️ 思维陷阱:认为 KL 自适应学习率解决了所有学习率问题 - 自适应只调节 learning_rate 的大小,不调节其他影响"步幅"的参数(如 num_learning_epochs、mini_batches) - 如果 num_learning_epochs 太大,即使 learning_rate 被调低,每个数据 batch 仍然会被反复使用过多次,导致 off-policy 程度过高

⚠️ 编程陷阱:entropy 为负数不代表 bug - 对高维连续动作空间,当 \(\sigma_i\) 很小时 \(\ln\sigma_i < 0\),entropy 可以是负数 - 这在数学上完全合法(微分熵可以为负)。只有当 entropy 突然变成 NaN 或 -Inf 时才说明有数值问题

练习

  1. [分析题] 训练日志显示 KL = 0.001(远低于 desired_kl = 0.01),learning_rate 已经被自适应机制调到了 max_lr。但 reward 仍然不涨。诊断可能的原因(至少列 3 个),并给出每个原因对应的排查方法。
  2. [思考题] 在一个多任务 curriculum 训练中(如 Ch06 的地形 curriculum),每次 curriculum 升级时 entropy 是否应该短暂上升?为什么?
  3. [计算题] 假设一个 12-DoF 四足机器人,初始动作标准差 \(\sigma_0 = 1.0\)。计算初始 entropy 值。如果训练后所有维度的 \(\sigma\) 均匀下降到 0.1,计算最终 entropy 值。entropy 下降了多少 nats?

上三节分别解剖了 reward 和 KL/entropy/value_loss。现在该把它们组合起来了——真正的诊断能力不是单独读每个指标,而是同时看它们的联合模式。这正是本节的主题。

25.4 训练曲线联合诊断:九种典型模式 ⭐⭐⭐

这一节解决什么问题:把五个独立指标组合成可识别的"模式",让你从训练截图中直接判断问题类别。

动机:为什么需要"模式识别"而非"指标查表"

前三节建立了单指标的阅读能力。但在实际训练中,你面对的不是一个指标——你同时看到五条曲线在变化。初学者的做法是逐个检查每个指标是否"正常",然后分别处理异常指标。但这忽略了一个关键事实:指标之间是强相关的。Reward 上升的同时 entropy 可能在下降——这不是两个独立事件,而是同一个学习过程的两个侧面。诊断效率的跃升来自于识别指标组合的模式,而不是孤立地检查每个指标。

类比:一个经验丰富的医生不会逐项看化验单("血糖偏高→开降糖药,血压偏高→开降压药"),而是从症状组合中识别综合征("血糖高+血压高+血脂高→代谢综合征→需要综合治疗方案")。训练诊断同理——"reward 不涨 + entropy 不降 + value_loss 很小"是一个综合征,不能分别处理。

九种典型训练模式

以下是从大量机器人 RL 训练实践中总结出的九种可识别模式。每种模式用五个指标的联合行为描述,附带根因分析和修复路线。


模式一:健康训练 ✅

指标 行为
Reward 平滑上升,后期减速
KL 围绕 desired_kl 波动
Entropy 缓慢单调下降
Value Loss 先升后降
Episode Length 逐步增长趋近 max

诊断:策略在正常学习。不需要干预,但要确认 reward 最终水平是否达到预期。


模式二:Reward Hacking ⚠️

指标 行为
Reward 快速上升到高值
KL 正常
Entropy 骤降到接近零
Value Loss 快速收敛
Episode Length 可能正常,可能很短

诊断:策略发现了一个 reward 漏洞。例如四足机器人发现"把身体压到地面不动"可以避免所有 penalty 同时不触发 termination。修复路线:(1) 回放策略行为(uv run play / Isaac Lab play),确认是否 hacking;(2) 检查 reward term 是否有被利用的缝隙(Ch06);(3) 添加 regularization 或修改 termination 条件。

反事实推理:如果不检查行为而只看 reward 数字,你会认为训练成功。但把策略部署到真机上才发现机器人在地上滑动而不是走路——这是一个在仿真中"合法"但在真实任务中无意义的策略。


模式三:学习停滞 ⚠️

指标 行为
Reward 零附近不动
KL 很低(< 0.001)
Entropy 不降或极缓慢下降
Value Loss 很小(接近零)
Episode Length 卡在很短值

诊断:策略学不到有用信息。可能原因:(1) Reward 太稀疏——策略达不到任何有分的状态(Ch06);(2) Obs 缺少完成任务的关键信息(Ch05);(3) Learning_rate 太低——被 KL 自适应机制压死。

修复路线按优先级:先检查 reward 是否在最简单情况下(如静态目标)能给出非零信号 → 检查 obs 是否包含目标位置等关键信息 → 手动提高 initial_lr


模式四:训练震荡 ⚠️

指标 行为
Reward 大幅度振荡(涨跌幅 > 平均值的 50%)
KL 频繁超过 desired_kl 数倍
Entropy 不稳定
Value Loss 剧烈振荡
Episode Length 跟着 reward 同步振荡

诊断:PPO 更新步幅过大。每次 update 让策略改变太多,导致 value function 的预测失效,GAE 估计不准,下一次 update 方向可能反转。这形成了一个正反馈循环:步幅大 → 估计差 → 步幅更大。

修复路线:(1) 降低 learning_rate;(2) 减少 num_learning_epochs(从 5 减到 2-3);(3) 增大 num_mini_batches(更小的 mini-batch 每次更新幅度更小);(4) 收紧 clip_range(从 0.2 收到 0.1)。


模式五:缓慢退化 ⚠️⚠️

指标 行为
Reward 先升后缓慢下降
KL 逐步增大
Entropy 继续下降
Value Loss 逐步增大
Episode Length 先增后缓慢缩短

诊断:Critic 和 Actor 之间出现了"分歧"。Critic 的 value function 不再准确(可能因为 obs 分布漂移、reward scale 变化、或网络容量不足),导致 GAE 质量下降,策略更新方向偏移。

这是最阴险的失败模式之一——因为退化是渐进的,如果你不定期检查 value_loss 趋势,可能直到训练结束才发现后半段全在浪费。一个有用的预警机制是在训练循环中添加 value_loss 的滑动窗口检测:

# 在训练循环中添加 value_loss 退化检测
value_loss_history = []
def check_value_loss_trend(current_vl, window=200, threshold=1.5):
    value_loss_history.append(current_vl)
    if len(value_loss_history) > window:
        recent = value_loss_history[-window//2:]
        earlier = value_loss_history[-window:-window//2]
        ratio = np.mean(recent) / (np.mean(earlier) + 1e-8)
        if ratio > threshold:
            print(f"⚠️ Value loss increasing: "
                  f"recent={np.mean(recent):.4f}, "
                  f"earlier={np.mean(earlier):.4f}, "
                  f"ratio={ratio:.2f}")

修复路线:(1) 检查是否有 curriculum 变化导致 obs 分布突变;(2) 确认 normalize_obs 正常工作(running mean/std 是否被正确更新);(3) 增大 Critic 网络容量(加层或加宽);(4) 检查 reward scale 是否在训练过程中漂移;(5) 如果退化发生在训练后期,考虑降低 learning_rate 或提前停止(early stopping)。


模式六:突然崩溃 ⚠️⚠️⚠️

指标 行为
Reward 在某个 iteration 突然暴跌到最低
KL 在同一 iteration 飙升到 > 0.1
Entropy 可能突然骤变
Value Loss 同步飙升
Episode Length 骤降到 1-2 step

诊断:策略进入了一个灾难性区域。可能原因:(1) NaN 在网络中传播(Ch24);(2) Curriculum 一次跳了太多级;(3) Domain randomization 参数过激导致大量环境产生物理不稳定。

修复路线:(1) 回滚到崩溃前的 checkpoint;(2) 用 --enable-nan-guard True 重新训练确认是否 NaN(Ch24);(3) 检查崩溃 iteration 附近是否有 curriculum stage 切换。


模式七:Curriculum 阶梯 ✅

指标 行为
Reward 阶梯式上升(每次 curriculum 升级后短暂下降再恢复)
KL 在 curriculum 切换时短暂升高
Entropy 在 curriculum 切换时短暂升高
Value Loss 在 curriculum 切换时短暂升高
Episode Length 阶梯式增长

诊断:Curriculum 正常运作。每次难度提升后策略需要适应新环境,短暂的性能下降和 entropy 回升是健康的。需要关注的是:(1) 每次下降后是否能恢复到之前水平并超过;(2) 恢复时间是否在合理范围内(通常 50-200 iteration)。


模式八:Entropy 崩塌 + Reward 卡住 ⚠️⚠️

指标 行为
Reward 快速上升到某个值后完全停滞
KL 趋近零
Entropy 快速降到接近零
Value Loss 很小但不为零
Episode Length 稳定但不增长

诊断:策略过早收敛到一个局部最优。与模式二(Reward Hacking)的区别在于:这里 reward 没有达到不合理的高值,策略行为可能看起来"还行"但明显不是最优。例如四足机器人学会了一种简单但低效的步态,没有继续探索更好的步态。

修复路线:(1) 增大 entropy_coef(从 0.001 提高到 0.005-0.01);(2) 重新初始化 action_noise_std 到更高的值;(3) 考虑添加 curriculum 或 reference motion reward(Ch10/Ch15)来引导探索方向。


模式九:自杀策略 ⚠️⚠️⚠️

指标 行为
Reward 可能反而上升(或稳定在一个不高的值)
KL 逐步减小
Entropy 下降
Value Loss 很小
Episode Length 趋近零(1-5 step 就结束)

诊断:策略学会了主动触发 termination来避免累积负 reward。这是一个微妙但致命的失败模式——策略发现"活得越久罚得越多",于是选择"早死早超生"。数字上可能看起来还行(reward 不负甚至为正,因为短 episode 积累的负 reward 少),但行为上完全不可用。

根因分析:这个问题几乎总是源于 termination 的 value bootstrapping 设置错误。回顾 Ch06:当 episode 因为机器人摔倒而终止时(time_out=False),PPO 假设终止后的 value 为零(\(V(s_T) = 0\))。如果策略在存活期间持续收到负 reward(例如严厉的 regularization penalty),那么终止状态的 \(V(s_T) = 0\) 反而比继续存活的预期回报更高——策略理性地选择了"自杀"。

这个问题在学术文献中有系统性讨论。Pardo et al. 2018("Time Limits in Reinforcement Learning",arXiv:1712.00378)首次明确分析了 time limit 和 termination 对 value function 的影响,提出了 partial-episode bootstrapping (PEB) 方法:对 time-out 终止使用 \(\gamma V(s_T)\) 而非 \(0\) 作为 bootstrap target。RSL-RL 已经实现了这个机制——在 extras["time_outs"] 为 True 时不将 value 置零。

修复路线:(1) 检查所有 TerminationTermCfgtime_out 设置是否正确——因任务失败(摔倒、违规)的终止应为 time_out=False,因达到时间上限的终止应为 time_out=True;(2) 如果 regularization penalty 太重导致"活着不如死了",降低 penalty 权重或添加存活 bonus(alive_reward);(3) 在 AGILE 的实践中,使用 scale-invariant bonus \(\sigma = 5\)(在 \(\gamma = 0.99\) 时对应 value space 中约 500 的正向激励),确保存活始终比终止更有吸引力。

五种人形 / 足式机器人特有的失败模式

上述九种模式是通用的——适用于任何机器人 RL 任务。但人形和足式机器人有一些独有的失败模式,它们不一定体现在训练曲线上(指标可能看起来正常),必须通过行为回放才能发现。以下五种模式来自 2024-2026 年顶会论文中明确记录的工程经验:

人形模式 A:Foot Lift Hacking(脚不抬起/飞行)

症状:四足或人形机器人"飞行"——脚不离地但身体在移动(滑行),或者反过来脚不着地但 reward 正常。训练曲线上 tracking reward 正常上升,因为机器人确实在移动,但接触力日志显示异常。

诊断方法:在 play 时记录足端接触力,绘制接触力时间线。如果所有脚的接触力长期为零(飞行),或长期为非零(拖地),说明策略找到了物理引擎中的"滑行缝隙"。

修复方案:WoCoCo(CoRL'24,arXiv:2406.06005)提出了 "no fly" reward——当所有足端都不接触地面时施加惩罚:\(r_{\text{no\_fly}} = -w \cdot \mathbb{1}[\text{all feet off ground}]\)。同时确保地面摩擦系数和 condim 设置正确(Ch03)。

人形模式 B:Action Saturation(动作饱和)

症状:策略输出的动作值 \(a\) 大量聚集在 \(\pm 1.0\) 附近(action space 的边界)。行为表现为关节频繁撞击限位、动作粗暴、真机上可能损坏电机。

诊断方法:绘制动作值的直方图。如果在 \(\pm 1.0\) 处有明显的尖峰(而非正态分布形),说明策略在频繁使用极端动作。

修复方案:HoST(RSS'25,arXiv:2502.08378)引入了 action rescaler \(\beta\):实际关节目标 \(p_t^d = p_t + \beta \cdot a_t\),其中 \(\beta \in (0, 1]\) 控制动作空间的有效范围。\(\beta\) 小时动作空间窄、策略安全但表达力低;\(\beta\) 大时动作空间宽但可能不稳定。训练中用 curriculum 让 \(\beta\) 从 0.3 线性升到 1.0。部署时可以用 \(\beta < 1\) 作为"安全带"。同时应加 action_rate penalty(见 25.10 节)。

人形模式 C:Curriculum 边界 NaN

症状:训练在 curriculum 切换时突然 NaN,但 curriculum 切换前完全正常。

诊断方法:在 curriculum 切换前后各冻结 curriculum 运行 100 iteration,分别检查是否 NaN。如果冻结在新 curriculum 级别时仍然 NaN,说明新难度级别的物理设置有问题;如果切换时才 NaN,说明是切换过程中的 reward 权重重缩放导致 value target 突变。

修复方案:AGILE(arXiv:2603.20147)的实践是在 curriculum 切换时不改变 reward 权重——只改变环境难度。如果必须改变权重(例如从 tracking-only 阶段切换到 tracking + regularization 阶段),分 5-10 个 iteration 线性过渡,而非一步到位。

人形模式 D:步态不对称(Gait Asymmetry)

症状:左右腿的行为明显不对称——一条腿迈大步、另一条小步,或一条腿抬得高、另一条拖地。训练曲线完全正常。

诊断方法:分别记录左右腿的关节轨迹,计算左右差异的 L2 范数。健康策略的左右差异应该很小(< 关节范围的 10%)。

修复方案:WoCoCo 和 Mittal et al. 使用 symmetry augmentation——在 rollout 数据中,将左右两侧的 obs 和 action 镜像,作为额外的训练数据。这迫使策略学到对称的行为。在 RSL-RL 中实现:

# 对称性增强的伪代码(需要定义 obs/action 的左右镜像映射)
obs_mirrored = mirror_obs(obs)    # 左右互换
actions_mirrored = mirror_action(actions)  # 左右关节互换
# 将 (obs_mirrored, actions_mirrored) 加入训练数据

关键工程细节:obs/action 的镜像映射必须手动定义(哪些关节是左右对称的、velocity 的 y 分量是否需要取反等),配置错误反而会让不对称更严重。

人形模式 E:Sim OK Real Bad(仿真好真机差)

症状:策略在仿真中表现完美,但部署到真机后行为退化(跌跌撞撞、抖动、无法前进)。

诊断方法:不要先怀疑现实世界——先做 sim-to-sim。将策略从训练框架(如 Isaac Lab)导出 ONNX,在另一个物理引擎(如 mjlab / unitree_mujoco)中加载并评估。如果 sim-to-sim 就失败了,问题在于策略过拟合了训练引擎的物理特性,根本不需要到真机上才能发现。

修复方案:ASAP(RSS'25,arXiv:2502.01143)的系统性方法是:(1) sim-to-sim 交叉验证,(2) 扩大 domain randomization,(3) 如果仍有 gap,训练 delta action model 对齐仿真与真实的物理差异。具体的 sim2real 全链路见 Ch23。

模式识别速查流程

面对一组训练曲线时,按以下顺序判断:

Step 1: Episode Length 趋近零?
  ├── 是 → 模式九(自杀策略)
  │     └── 检查 termination 的 time_out 设置和 alive bonus
  │
  └── 否 → 继续

Step 2: Reward 是否在涨?
  ├── 不涨 → 看 KL 和 Entropy
  │     ├── KL 低 + Entropy 高/不降 → 模式三(学习停滞,obs/reward 信号不足)
  │     ├── KL 高 + 振荡 → 模式四(训练震荡,但方向错误)
  │     └── Entropy 已骤降到很低但 reward 仍不涨 → 过早收敛/局部最优(见模式八)
  │
  └── 在涨 → 看 Entropy
        ├── Entropy 骤降到零 → 模式二(Reward Hacking)或 模式八(局部最优)
        │     └── 区分:回放行为。Hacking = 异常行为高 reward;局部最优 = 正常但次优行为
        │
        └── Entropy 正常下降 → 看 Value Loss
              ├── Value Loss 先升后降 → 模式一(健康 ✅)
              ├── Value Loss 持续升 → 模式五(缓慢退化,即将出问题)
              └── Value Loss 突变 → 模式六(突然崩溃,检查 NaN)

如果通用模式诊断结果是"健康"但行为回放时发现问题 → 进入人形特有模式 A-E 的检查。

⚠️ 常见陷阱

⚠️ 思维陷阱:一个模式只有一个原因 - 模式三(学习停滞)可能由 reward 稀疏、obs 不足、learning_rate 过低中的任何一个导致,也可能是多个原因叠加 - 正确做法:用消融实验逐一排除——先简化任务确认 reward 能给分,再检查 obs,最后调 learning_rate

⚠️ 概念误区:模式二(Hacking)和模式八(局部最优)很容易区分 - 两者在曲线上的唯一区别是 reward 值是否"不合理地高"——但什么算"不合理"依赖于你对任务的理解 - 正确做法:唯一可靠的区分方法是回放行为。Hacking 会产生明显怪异的动作(如腿不动但身体滑动),局部最优的动作看起来"正常但次优"

⚠️ 思维陷阱:人形特有模式只在训练曲线上看 - 模式 A-E 几乎不可能仅从训练曲线发现——曲线可能完全正常 - 正确做法:每 500-1000 iteration 必须做一次行为回放。把回放检查作为固定流程,不是"有空再看"

练习

  1. [诊断题] 一个人形 locomotion 训练中,reward 在 iteration 1000 达到 200 并缓慢继续上升。episode_length 却在 iteration 500 后一直维持在 3-5 step。KL 正常。这是哪种模式?最可能的根因是什么?
  2. [操作题] 实现一个"对称性检查脚本":在 play 时记录左右腿 6 个关节的轨迹(各 1000 step),计算左右 L2 差异的均值和最大值。设定阈值并报告是否存在步态不对称。
  3. [跨章综合题] 结合 Ch06(termination 设计)和本节模式九:设计一个实验来验证 time_out=True vs time_out=False 对"自杀策略"出现概率的影响。用什么指标衡量?需要多少 seed?

知道了如何识别模式之后,下一步是:识别出模式之后,具体应该查哪个参数、读哪一章?本节提供一张从症状到解法的快速路由表——你在 30 秒内就能从训练症状定位到对应的修复方案。

25.5 症状驱动调参索引:全书路由表 ⭐⭐⭐

这一节解决什么问题:提供一张覆盖前序各章的调参速查表,让你从具体症状出发,快速找到应该调什么、怎么调、参考哪一章。

症状索引总表

类别一:Reward 相关症状

症状 可能原因 检查参数 参考章节
Reward 始终为零 任务太难 / reward 太稀疏 / obs 缺信息 reward term 输出值、obs 内容、curriculum 初始难度 Ch05, Ch06
Reward 涨但动作丑 regularization 权重不足 / action scale 过大 reg reward 权重、action_scale、smoothness penalty Ch05, Ch06
Reward 快速饱和 tracking sigma 过大 / 任务太简单 / reward hacking sigma 值、curriculum 设置、行为回放 Ch06
Reward 振荡不收敛 learning_rate 过高 / reward term 冲突 / mini_batch 过大 learning_rate、num_learning_epochs、reward term 符号 Ch07
Reward 突然崩溃 NaN / curriculum 跳变 / DR 过激 NaN guard、curriculum trigger、DR 范围 Ch24, Ch06, Ch08
Reward 先涨后缓慢下降 Critic 发散 / obs normalization 漂移 value_loss、normalize_obs、Critic 网络容量 Ch07

类别二:动作质量症状

症状 可能原因 检查参数 参考章节
关节抖动/高频振荡 action smoothness 缺失 / PD gain 太高 / action repeat 太少 action_rate penalty、Kp/Kd、decimation Ch05, Ch06, Ch12
关节打到限位 未加 joint_limit penalty / obs 缺关节位置 joint_limit penalty、obs term 列表 Ch05, Ch06
动作幅度过大 action_scale 过大 / clip 范围过宽 / action rescaler β 过大 action_scale、action_clip、β Ch05, 25.10
策略输出不变(死策略) entropy 崩塌 / learning 停滞 / actor 权重冻结 entropy、KL、gradient norm Ch07, 本章
对称性破坏(左右腿行为不同) obs/action 顺序不对称 / reward 有隐含偏差 obs term 排列、reward 对称性、symmetry augmentation Ch05, Ch13/Ch14
真机上动作抖动(仿真平滑) actuator delay 未建模 / L2C2 未启用 actuator delay DR 范围、L2C2 正则权重 Ch08, Ch12, 25.10

类别三:训练过程症状

症状 可能原因 检查参数 参考章节
KL 始终很高(> 0.05) learning_rate 过高 / epoch 过多 / data 太 off-policy learning_rate、num_learning_epochs、num_mini_batches Ch07
KL 始终很低(< 0.001) learning_rate 过低 / clip_range 过紧 / 梯度消失 learning_rate、clip_range、网络初始化 Ch07
Entropy 骤降 过早收敛 / entropy_coef 太小 entropy_coef、action_noise_std 初始值 Ch07
Value loss 持续上升 Critic 容量不足 / reward scale 过大 / obs 不稳定 Critic 层数/宽度、reward 归一化、obs normalization Ch07
训练初期 reward 猛涨后快速衰减 Critic 初始化偏差大 / value 估计 bootstrapping 错误 网络初始化、termination 的 time_out 设置 Ch06, Ch07
训练时 env_sps 忽然下降 CUDA Graph 失效 / 内存溢出 / 传感器开销 CUDA Graph 日志、GPU memory、sensor 配置 Ch24
Lagrange 乘子持续振荡(约束 RL) cost signal 与 reward 量级不匹配 lr_lambda、constraint threshold、cost scale Ch17

类别四:Sim-to-Real 症状

症状 可能原因 检查参数 参考章节
仿真好真机差 DR 不足 / actuator 模型偏差 / obs delay 未建模 DR 范围、actuator 类型、obs delay 配置 Ch08, Ch12, Ch23
sim2sim 跨引擎行为不一致 接触模型差异 / 物理参数不同步 solref/solimp vs friction/restitution、gravity Ch03
策略在真机上抖动但仿真中平滑 真机 actuator delay 未建模 / 策略 Lipschitz 常数过大 actuator delay DR 范围、L2C2 权重、Butterworth 滤波器 Ch08, Ch12, 25.10
策略拒绝在真机上运动 obs normalization 不匹配 / ONNX 导出丢失 stats normalization 烘焙、ONNX inference 验证 Ch07, Ch23
ONNX 推理结果与 PyTorch 不一致 joint ordering mismatch / normalization 未冻结 deploy.yaml 中的关节排列、EmpiricalNormalization 冻结 Ch23

类别五:任务特定症状

症状 可能原因 检查参数 参考章节
四足不抬脚(滑行) 足端接触 reward 设计问题 / 摩擦系数过高 contact reward、terrain friction、"no fly" reward Ch06, Ch08, Ch13
人形站不稳 base orientation penalty 太弱 / 初始姿态不对 orientation penalty 权重、default_joint_pos Ch06, Ch14
灵巧手抓不住 接触面太滑 / obs 缺目标位姿 / action 频率太低 friction、obs term、decimation Ch03, Ch05, Ch17
击球时机不准(球类任务) time_to_impact 未进 obs / approach reward 设计 obs term 列表、staged reward 配置 Ch26-Ch28
nconmax/njmax 溢出 DR 添加几何体 / 环境中物体过多 mjData.ncon 分布、nconmax = 1.5 × max observed Ch03, Ch24

类别六:动作平滑性工具选型

当诊断到"动作抖动"或"真机不可部署"时,以下是三种主要平滑正则工具的选型指南:

工具 原理 适用场景 参数 来源
action_rate L2 \(\|a_t - a_{t-1}\|^2\) 标准 locomotion,够用 penalty weight 0.01-0.1 通用
CAPS 时间平滑 + 空间平滑 需要额外空间平滑时 temporal + spatial weights Mysore 2021
L2C2 局部 Lipschitz 约束 高动态 + 真机部署 \(\lambda_\pi=1.0, \lambda_V=0.1\) Kobayashi 2022, HoST

决策流程:先用 action_rate L2 → 如果真机仍然抖动 → 加 L2C2(详见 25.10 节)。

使用方法

  1. 观察症状:从训练曲线或行为回放中识别具体症状
  2. 在上表中定位:找到最匹配的症状行
  3. 检查参数:按"检查参数"列中的顺序逐一排查
  4. 参考章节:如果需要理解参数含义,回到对应章节的相关小节

本质洞察:调参不是"随机搜索超参空间", 而是"从症状出发的因果推理"。 好的工程师和差的工程师的区别不在于谁跑了更多 sweep, 而在于谁能更快地从症状定位到根因。

常见诊断路径实战:从症状到代码级修复

索引表是导航工具,但初学者经常在"查到参数"之后不知道如何在框架中修改。以下用三个高频症状走完从发现到修复的完整路径:

实战路径 1:"reward 涨但动作丑"

这是 Ch06 以来反复提到的经典症状。你在 WandB 上看到 tracking reward 稳定上升到 200,但行为回放中四足机器人走路像"抽搐"——关节高频抖动,步态不自然。

# Step 1: 确认分项 reward 的构成
# 在 WandB 面板中检查 Rewards/ 下的各 term
# 发现:tracking_reward = 250, action_rate_penalty = -5(太小!)
# Step 2: 在 env_cfg 中调整 regularization 权重
# 文件:my_velocity_env_cfg.py
class MyRewardsCfg:
    # ❌ 修改前:action_rate 权重太低
    action_rate_l2 = RewTermCfg(
        func=mdp.action_rate_l2, weight=-0.01  # 太弱!
    )
    # ✅ 修改后:提高到 tracking reward 的 10-20%
    action_rate_l2 = RewTermCfg(
        func=mdp.action_rate_l2, weight=-0.05
    )
    # 进一步添加:joint acceleration penalty
    joint_accel = RewTermCfg(
        func=mdp.joint_acc_l2, weight=-2.5e-7  # 量级需要实验确定
    )
# Step 3: 重新训练并对比
uv run train My-Velocity-Rough-Go2-v0 \
  --num-envs 4096 --max-iterations 3000 \
  --wandb-name go2_rough_fix_action_rate_v2

关键判断:修复后 tracking reward 会略微下降(因为策略不再能"暴力"达成目标),但行为质量应该明显改善。如果 tracking reward 下降超过 30%,说明 regularization 权重加得太重,需要回退。

实战路径 2:"episode length 趋近零"(模式九:自杀策略)

# Step 1: 检查 termination 配置
class MyTerminationsCfg:
    # ❌ 错误:所有 termination 都设为 time_out=False
    base_contact = TerminationTermCfg(
        func=mdp.illegal_contact, time_out=False,
        params={"threshold": 1.0, "sensor_cfg": ...},
    )
    time_limit = TerminationTermCfg(
        func=mdp.time_out, time_out=False,  # ← 这里是 bug!
        params={"max_episode_length_s": 20.0},
    )
    # ✅ 修正:时间限制的 termination 必须 time_out=True
    time_limit = TerminationTermCfg(
        func=mdp.time_out, time_out=True,  # ← PPO 会对这个做 value bootstrap
        params={"max_episode_length_s": 20.0},
    )
# Step 2: 添加 alive bonus 确保"活着比死了好"
class MyRewardsCfg:
    alive_bonus = RewTermCfg(
        func=mdp.is_alive, weight=1.0,  # 每步活着就拿 1.0 分
    )

这个 bug 的诊断关键在于:WandB 上的 reward 可能看起来"还行"(因为短 episode 累积的负 reward 少),但 episode_length 是最直接的红旗。永远把 episode_length 放在诊断面板的第一行。

实战路径 3:"sim-to-sim 行为不一致"

你在 Isaac Lab(PhysX)中训练了一个 Go2 策略,导出 ONNX 后在 mjlab(MuJoCo)中回放,机器人完全不走路。

# Step 1: 核对 joint ordering
python -c "
import onnx
model = onnx.load('policy.onnx')
print('Input shape:', [d.dim_value for d in model.graph.input[0].type.tensor_type.shape.dim])
# 输出:[1, 47]  — obs 维度正确

# 但 joint ordering 可能不同!
# Isaac Lab: hip_roll, hip_pitch, knee, ankle — 每条腿
# mjlab 的 MJCF: 可能是 hip_pitch, hip_roll, knee, ankle
"
# Step 2: 创建 joint mapping
# 根据 AGILE 的 deploy descriptor 理念,手动对齐
isaac_to_mjlab_mapping = {
    'FR_hip_roll':  'FR_roll_joint',   # index 0 → 1
    'FR_hip_pitch': 'FR_pitch_joint',  # index 1 → 0
    'FR_knee':      'FR_calf_joint',   # index 2 → 2
    # ... 12 个关节全部映射
}

# 在 mjlab play 脚本中加入 reorder
obs_mjlab = obs_raw[isaac_to_mjlab_order]
action_isaac = policy(obs_mjlab)
action_mjlab = action_isaac[mjlab_to_isaac_order]

这个问题在 unitree_rl_lab 的 GitHub Issues #82 中有详细记录——多个用户在部署时遇到了完全相同的 joint ordering mismatch。AGILE 的 YAML I/O 描述符(见 25.9 节)正是为了系统性地避免这类问题。

⚠️ 常见陷阱

⚠️ 思维陷阱:只改第一个命中的参数 - 一个症状可能有多个原因,改了一个不一定解决问题 - 正确做法:按优先级逐一排除(先检查最容易验证、影响最大的原因)

⚠️ 概念误区:索引表可以替代理解 - 索引表是路由工具,不是解决方案本身 - 正确做法:找到对应章节后,阅读该章的详细说明再做调整

⚠️ 编程陷阱:同时改多个参数 - 如果同时改了 learning_rate 和 reward weight,reward 变好了但你不知道是哪个改动起了作用 - 正确做法:一次改一个参数,或设计正交的消融实验

练习

  1. [诊断题] 一个人形 locomotion 训练中,reward 在 2000 iteration 达到 150 后缓慢下降到 100(4000 iteration)。同时 value_loss 从 0.5 逐步上升到 2.0。KL 从 0.01 上升到 0.03。在索引表中找到对应症状,列出你会按什么顺序检查哪些参数。
  2. [设计题] 为你自己的训练任务,设计一个 3×3 的消融矩阵:3 个可能有问题的参数 × 3 种取值。写出你的实验计划(每个实验改什么、不改什么、预期看到什么变化)。
  3. [操作题] 实现上述"实战路径 2"中的 alive bonus 修复。在 mjlab 的一个 locomotion 任务中,故意把 time_limittime_out 设为 False,训练 500 iteration 观察 episode_length。然后修正为 True 并添加 alive_bonus,重新训练对比。

知道了如何诊断和查表之后,下一个问题是:诊断信息从哪里来?mjlab 和 Isaac Lab 的日志系统各有不同——本节对比两个框架的日志和可视化工具,确保你在两个框架中都能高效获取诊断数据。

25.6 双框架日志与可视化系统 ⭐⭐

这一节解决什么问题:对比 mjlab 和 Isaac Lab 的训练日志系统,确保你知道"在哪看什么"。

动机:日志是诊断的数据来源

所有诊断方法都依赖高质量的日志数据。如果你不知道如何从框架中提取分项 reward、KL、entropy 等指标,前面所有的诊断方法都无法执行。两个框架的日志系统有共性(都支持 WandB 和 TensorBoard),也有差异(日志路径、指标命名、自定义指标的添加方式)。

AGILE 三大交互式 GUI 工具

在进入日志系统之前,值得介绍一套更前置的诊断工具——AGILE(arXiv:2603.20147)在 Stage 1(Prepare)阶段提供了三个交互式 GUI,让你在训练开始之前就能捕获大量 bug:

Joint Position GUI:逐关节滑块控制,实时显示力矩读数。一个可选的 symmetry 模式会显示镜像机器人,并排对比左右两侧——这可以在几秒钟内发现关节轴的符号错误(例如左髋 roll 和右髋 roll 的旋转方向是否正确镜像)。类比:这就像赛车手上赛道前在 pit lane 逐个检查轮胎、刹车、方向盘——不是开了才知道轮胎没装好。

Object Manipulation GUI:6-DOF 物体定位器,带实时接触传感器可视化。用于验证操作类 reward 是否能正确激活——你手动把物体移到目标位置,检查 reward term 是否输出预期的值。如果手动把物体放到目标位置而 reward 为零,那训练再久也学不会。

Reward Visualizer:每个 reward term 的权重和贡献以叠加条形图实时显示,同时你可以操纵场景。这让你在不跑训练循环的情况下确认 reward 行为。例如:你手动让机器人摔倒,检查 termination 是否触发;你手动让机器人站直,检查 orientation reward 是否为最大值。

这三个工具的投入产出比极高——花 10 分钟做一次 GUI 检查,可以避免几小时的无效训练。在 mjlab 中,可以通过 uv run demo + 自定义脚本实现类似功能;在 Isaac Lab 中,AGILE 的实现是开源的(github.com/nvidia-isaac/WBC-AGILE)。

mjlab 日志系统

mjlab 使用 RSL-RL 的日志模块,支持 TensorBoard 和 WandB。

日志目录结构

logs/
└── Mjlab-Velocity-Flat-Unitree-Go2/
    └── 2026-05-20_14-30-00/
        ├── config.yaml          # 完整训练配置快照
        ├── model_0.pt           # 初始 checkpoint
        ├── model_500.pt         # 中间 checkpoint
        ├── model_last.pt        # 最后 checkpoint
        └── events.out.tfevents  # TensorBoard 事件文件

启用 WandB

uv run train Mjlab-Velocity-Flat-Unitree-Go2 \
  --logger wandb \
  --wandb-project my_project \
  --wandb-name go2_velocity_v1

Isaac Lab 日志系统

Isaac Lab 的日志通过 --logger 参数配置,支持 tensorboard、wandb 和 neptune。

python scripts/rsl_rl/train.py \
  --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --logger wandb \
  --wandb_project my_project

关键差异:Isaac Lab 的日志路径默认在 logs/rsl_rl/ 下,按任务名和时间戳组织。当使用 RL Games 后端时,路径变为 logs/rl_games/,指标命名也完全不同。

双框架日志对照表

功能 mjlab(RSL-RL) Isaac Lab(RSL-RL) Isaac Lab(RL Games)
启用 WandB --logger wandb --logger wandb --logger wandb
Reward 路径 Train/mean_reward Train/mean_reward rewards/step
KL 路径 Train/kl Train/kl info/kl
Entropy 路径 Policy/entropy Policy/entropy info/entropy
Value Loss 路径 Loss/value_function Loss/value_function losses/c_loss
Episode Length Train/mean_episode_length Train/mean_episode_length episode_lengths/step
分项 Reward Rewards/<term_name> Rewards/<term_name> 需手动配置
Checkpoint 格式 .pt(PyTorch state_dict) .pt .pth
Config 保存 config.yaml params/ 目录 config.yaml

RSL-RL EmpiricalNormalization 详解

RSL-RL 使用 Welford 算法维护 running mean/std 对 observation 做在线归一化。配置开关是 actor_obs_normalizationcritic_obs_normalization(分别控制 actor 和 critic 是否看到归一化的 obs)。

这个归一化机制在训练时不断更新统计量(均值和标准差),确保网络输入始终在 \(O(1)\) 量级。但在部署时必须冻结这些统计量——否则 ONNX 模型在真机上收到的 obs 会被一个从零开始的新 normalizer 处理,输出完全是垃圾。

关键 bug(unitree_rl_lab issues #82 相关):训练时开了 normalization 但 ONNX 导出时忘了将 \(\mu\) / \(\sigma\) 烘焙为常量。排查方法:

# 检查 ONNX 模型是否包含 normalization 常量
import onnx
model = onnx.load("policy.onnx")
for initializer in model.graph.initializer:
    if "running_mean" in initializer.name or "running_var" in initializer.name:
        print(f"Found normalization constant: {initializer.name}")
# 如果没有找到 → normalization 未烘焙 → 部署会失败

在 Isaac Lab 的 RSL-RL 后端中,对应的类是 RunningMeanStd,行为与 mjlab 侧的 EmpiricalNormalization 基本一致。

行为回放:视觉诊断的不可替代性

数字指标永远无法替代视觉检查。许多训练问题只有在回放策略行为时才能被发现——reward 数字看起来合理,但机器人在做一些你不希望的事情。

在 mjlab 中回放:

uv run play My-Velocity-Rough-Go2-v0 \
  --checkpoint logs/.../model_2000.pt \
  --num-envs 16
视觉检查项 正常表现 异常表现
步态对称性 左右腿交替、幅度相近 一条腿幅度大、另一条小
身体姿态 基本水平、轻微晃动 严重倾斜、头部下沉
足端轨迹 清晰的抬起-前摆-落地 拖地、打滑、不抬脚
速度跟踪 朝指令方向移动 走错方向或原地转圈
关节行为 平滑连续运动 抖动、突然跳变、打到限位

反事实推理:如果你从不回放策略行为而只看 WandB 面板,你可能训练出一个"reward 很高但动作怪异"的策略。在论文审稿时,reviewer 会通过附件视频发现问题。更严重的是,如果你直接部署到真机,怪异的动作可能损坏硬件。定期行为回放是成本最低的保险——每次看 30 秒就能发现数字面板上看不到的问题。

WandB 诊断面板推荐布局

左列 中列 右列
1 总 Reward KL Divergence Entropy
2 分项 Reward(叠加) Value Loss Episode Length
3 Learning Rate env_sps / total_fps Curriculum Stage

自定义指标:超越框架默认

框架默认记录的指标覆盖了大部分诊断需求,但特定任务可能需要额外指标。以下是一些常用的自定义指标及其在 mjlab 中的实现:

# mjlab 中添加自定义 metrics(在 env_cfg 中配置)
class MyMetricsCfg:
    # 四足任务常用:足端接触率
    contact_rate = MetricTermCfg(
        func=lambda env: (env.contact_forces[:, feet_ids].norm(dim=-1) > 1.0)
                          .float().mean(dim=-1),
        params={},
    )

    # 速度跟踪误差的 episode 平均
    tracking_error = MetricTermCfg(
        func=lambda env: (env.root_lin_vel_b[:, :2]
                          - env.commands[:, :2]).norm(dim=-1),
        params={},
    )

    # 关节接近限位的频率(预警指标)
    joint_limit_proximity = MetricTermCfg(
        func=lambda env: (
            (env.joint_pos - env.joint_pos_limits[:, 0]).abs() < 0.1
        ).float().mean(dim=-1),
        params={},
    )

    # 动作平滑度(部署预诊断指标)
    action_rate_rms = MetricTermCfg(
        func=lambda env: (env.actions - env.previous_actions).norm(dim=-1),
        params={},
    )

MetricsManager 每个 episode 结束时收集数据,记录到 WandB 的 Metrics/ 前缀下。Isaac Lab 中的自定义 metrics 通过类似方式实现,但需要继承 MetricTermCfg 并注册到 MetricsManager

自定义 WandB Alert 配置:对长时间无人值守的训练(如周末挂机),建议配置自动告警。以下是一个实用的 alert 设置方案:

# 在训练脚本中添加 WandB Alert
import wandb

# 训练循环中:
if iteration % 100 == 0:
    current_reward = logger.get("Train/mean_reward")
    if current_reward < best_reward * 0.8:  # reward 下降超过 20%
        wandb.alert(
            title="Training Reward Drop",
            text=f"Reward dropped to {current_reward:.1f} "
                 f"(best: {best_reward:.1f}) at iter {iteration}",
            level=wandb.AlertLevel.WARN,
        )
    if math.isnan(current_reward):
        wandb.alert(
            title="NaN Detected",
            text=f"NaN reward at iteration {iteration}",
            level=wandb.AlertLevel.ERROR,
        )

如果不保存完整 config 会怎样

反事实推理:假设你跑了 10 个实验,两周后想复现其中表现最好的那一个。但你当时没有保存完整的配置,只记得"大概改了 learning_rate 和某个 reward weight"。mjlab 和 Isaac Lab 都会自动保存训练配置的快照,但只包含框架内的配置——你在代码中硬编码的修改不会被保存。因此,所有实验性的参数修改应该通过 CLI override 或配置文件传入,而不是直接改源码。

为了彻底解决复现问题,推荐在 WandB 配置中记录 git 信息:

# 在训练启动时记录环境信息
import subprocess
wandb.config.update({
    "git_hash": subprocess.getoutput("git rev-parse HEAD"),
    "git_branch": subprocess.getoutput("git branch --show-current"),
    "git_dirty": subprocess.getoutput("git diff --stat"),
    "hostname": subprocess.getoutput("hostname"),
    "gpu_name": torch.cuda.get_device_name(0),
})

⚠️ 常见陷阱

⚠️ 编程陷阱:WandB run 名称不包含关键参数 - 跑了 50 个实验后分不清哪个是哪个 - 正确做法:用 --wandb-name 包含关键参数,如 go2_lr3e4_ent001_v3

⚠️ 概念误区:TensorBoard 和 WandB 二选一 - 两者可以同时启用。TensorBoard 适合本地快速查看,WandB 适合多实验对比和团队协作 - 正确做法:开发阶段用 TensorBoard,正式实验用 WandB

⚠️ 编程陷阱:ONNX 导出后 normalization 未冻结 - 最常见的 sim-to-real 失败原因之一。play 时正常是因为 PyTorch 模型自带统计量,但 ONNX 模型可能丢失 - 正确做法:导出后用上面的 onnx 检查脚本验证

练习

  1. [操作题] 在 mjlab 中运行 Mjlab-Velocity-Flat-Unitree-Go2,同时启用 TensorBoard 和 WandB 日志。对比两者中 reward 和 KL 曲线是否一致。如果不一致,排查原因。
  2. [设计题] 你需要记录一个自定义指标:每个 episode 中机器人摔倒的次数。写出在 mjlab 和 Isaac Lab 中分别如何实现这个自定义指标的记录。

掌握了指标阅读、模式识别、调参索引和日志工具之后,是时候把所有这些串起来了。下一节通过一个完整的贯穿案例,演示从 smoke test 到最终诊断修复的全流程。

25.7 贯穿案例:从 Smoke Test 到诊断的完整流程 ⭐⭐⭐

这一节解决什么问题:用一个完整的实战案例,演示如何从零开始系统性地验证和调试一个 RL 训练任务。

动机:为什么需要系统性流程

回顾 writing_guide §七中的实验矩阵(Ch02):从 import smoke test → zero agent → random agent → shape 验证 → 小规模训练 → 大规模训练 → +curriculum → +noise/delay → +DR → 评估 checkpoint → ONNX export。这个 10 步流程在 Ch02 中以清单形式给出,但那时你还不知道每一步应该看什么、出了问题怎么定位。现在结合本章的诊断方法论,我们把这个流程用一个具体案例走一遍。

场景设定:你正在为 Unitree Go2 四足机器人开发一个 rough terrain velocity tracking 任务。你已经写好了 env_cfg(基于 Ch13 的 velocity_env_cfg),现在要验证和训练它。我们将使用 mjlab 作为主框架,Isaac Lab 做对照验证。

Phase 0:Smoke Test(5 分钟)

目标:确认环境能正确注册和实例化,不报 import 错误。

# Step 1: 确认任务注册
uv run list-envs | grep "My-Velocity-Rough-Go2"

# Step 2: 尝试实例化环境(不训练)
uv run demo My-Velocity-Rough-Go2-v0

Isaac Lab 对照

python scripts/rsl_rl/train.py --task My-Velocity-Rough-Go2-IL \
  --num_envs 1 --headless False --max_iterations 0

诊断检查点:✅ 任务出现在 list-envs 中 · ✅ demo 打开后模型姿态正确 · ✅ 地形生成正确

Phase 1:Zero Agent(10 分钟)

目标:给所有动作置零,观察机器人在"不做任何事"时的状态。这暴露了默认姿态、重力响应和被动动力学。

uv run play My-Velocity-Rough-Go2-v0 --policy zero

应该看到什么:Go2 从默认站立姿态在重力下缓慢下蹲(膝关节弯曲),最终趴在地面上。如果默认 PD gain(Kp/Kd)足够大,机器人可能维持一段时间的站立后才下蹲。

不应该看到什么及对应修复

异常现象 根因 修复
机器人飞到空中 重力方向错误或 freejoint 未正确约束 检查 MJCF/USD 的重力设置(z-up vs y-up)
机器人穿过地面 碰撞检测问题 检查 condim/contype(Ch03)、collision geometry
关节瞬间弹到极限 PD gain 初始化错误或 default_joint_pos 不合理 检查 actuator Kp/Kd 和 joint 默认角度
机器人完全静止 actuator 模式为 velocity 且 default velocity = 0 切换到 position 模式或设置合理的初始 target
某些关节方向相反 MJCF 中关节轴定义和代码中的假设不一致 使用 AGILE Joint Position GUI 逐个验证

这一步的诊断价值在于:它在没有策略干预的情况下验证了物理系统的基本行为。如果 zero agent 都有问题,那么任何后续训练都建立在错误的基础上。就像飞行员在起飞前检查仪表——如果静止状态下油表就是零,不需要起飞就知道有问题了。

定量验证:除了视觉检查,还应打印一些数值确认物理系统的正确性:

# 在 zero agent 运行时添加的定量检查
print(f"Gravity direction: {env.sim_params.gravity}")
# 期望:[0, 0, -9.81] 或 [0, -9.81, 0]

print(f"Root height: {env.root_pos[:, 2].mean():.3f} m")
# 期望:Go2 站立高度约 0.34m,应该在缓慢下降

print(f"Joint positions: {env.joint_pos[0].tolist()}")
# 期望:接近 default_joint_pos 的值

print(f"Contact forces: {env.contact_forces[:, feet_ids].norm(dim=-1).mean():.1f} N")
# 期望:站立时理想四足均分约等于 mg/4 ≈ 37N(Go2 含电池约 15kg);实际接触力随姿态/地形/控制器而变

Phase 2:Random Agent(10 分钟)

目标:给随机动作,观察环境的 reward、termination 和 reset 是否正常工作。

uv run play My-Velocity-Rough-Go2-v0 --policy random

应该看到什么:Go2 胡乱挥动四肢,频繁摔倒和 reset。每次 reset 后回到初始姿态,地形不变(除非配置了地形 curriculum)。

关键定量检查

# 在 random agent play 脚本中添加
obs = env.get_observations()
print(f"obs shape: {obs['policy'].shape}")   # 期望 [num_envs, obs_dim]
print(f"obs range: [{obs['policy'].min():.2f}, {obs['policy'].max():.2f}]")

# 检查每个 obs 维度的统计量
obs_mean = obs['policy'].mean(dim=0)
obs_std = obs['policy'].std(dim=0)
for i, (m, s) in enumerate(zip(obs_mean, obs_std)):
    if s < 1e-6:
        print(f"  ⚠️ obs dim {i}: std={s:.2e} (constant! check obs term)")
    if torch.isnan(m) or torch.isnan(s):
        print(f"  ⚠️ obs dim {i}: NaN detected!")

# 检查 reward
info = env.get_extras()
for key, val in info['log'].items():
    if 'reward' in key.lower() or 'Reward' in key:
        print(f"  {key}: {val:.4f}")

# 检查 termination 频率
print(f"Termination rate: {env.reset_buf.float().mean():.2%}")
# 期望:random agent 下应该频繁 termination(>50%)

异常诊断表

观察 可能问题 修复
obs 全为零 obs term 函数返回空或未注册 检查 ObservationGroupCfg
obs 包含 NaN 某个 obs term 计算除以零 在 obs term 中加安全检查
obs 某些维度 std = 0 该维度返回了常量(如未连接的 sensor) 删除该 obs term 或修复 sensor
reward 始终完全相同 reward 函数没用到实际状态 检查 reward term 的 env 参数是否正确传递
机器人不 reset termination 条件未触发 检查 TerminationTermCfg
reset 后姿态不对 EventManager 的 reset 事件配置错误 检查 reset event 中的 default_joint_pos
Termination rate = 0% termination 阈值太宽松 检查 body_height、body_orientation 等 threshold

Phase 3:小规模训练(30 分钟 - 1 小时)

uv run train My-Velocity-Rough-Go2-v0 \
  --num-envs 64 --max-iterations 500 \
  --logger wandb --wandb-name go2_rough_smoke_v1

500 iteration 后的健康检查清单

指标 健康标准 不健康标准
Reward 从初始值有任何正向趋势 完全不动或下降
KL 0.001 ~ 0.03 范围内 > 0.1 或完全为零
Entropy 从初始值有下降趋势 骤降到零
Value Loss 任何有意义的数值 NaN 或完全为零
Episode Length 有增长趋势 始终为 1-2 step

Phase 4:诊断与修复循环

假设在 Phase 3 中你观察到了模式三(学习停滞)——reward 不涨、KL 很低、entropy 不降。按 25.5 的索引表:

排查步骤 1:检查 reward 是否在最简情况下能给分。

# 把所有命令设为零速度,检查"站立不动"是否有 reward
uv run train My-Velocity-Rough-Go2-v0 \
  --num-envs 64 --max-iterations 100 \
  --env.commands.base_velocity.ranges.lin_vel_x "[0.0, 0.0]" \
  --env.commands.base_velocity.ranges.lin_vel_y "[0.0, 0.0]" \
  --env.commands.base_velocity.ranges.ang_vel_z "[0.0, 0.0]"

如果零速度命令下 tracking reward 仍然为零——reward term 函数有 bug。检查 velocity error 的计算是否正确(坐标系、单位)。如果零速度命令下 tracking reward 为 1.0(最大值),说明 reward 函数本身没问题,问题在其他地方。

排查步骤 2:检查 obs 是否包含足够信息。

# 打印 obs term 列表和每个 term 的输出
obs = env.get_observations()
print(f"obs shape: {obs['policy'].shape}")  # 期望 [64, obs_dim]
print(f"obs range: [{obs['policy'].min():.4f}, {obs['policy'].max():.4f}]")

# 逐个检查 obs term
for name, term in env.observation_manager.active_terms.items():
    values = term.func(env)
    print(f"  {name}: shape={values.shape}, "
          f"mean={values.mean():.4f}, std={values.std():.4f}")
    if torch.isnan(values).any():
        print(f"  ⚠️ NaN detected in {name}!")

如果 obs 缺少 commands(目标速度),策略不知道"应该往哪走"——这当然学不到任何东西。如果所有 obs 的 std ≈ 0,说明某些 term 返回了常量,策略无法从中提取有用信息。

排查步骤 3:手动提高 learning_rate。

uv run train My-Velocity-Rough-Go2-v0 \
  --num-envs 64 --max-iterations 500 \
  --agent.algorithm.learning_rate 3e-3

如果 KL 从 0.001 上升到 0.01 且 reward 开始有动静——原始 learning_rate 太低是根因。如果 KL 上升到 0.05 但 reward 仍然不动——问题不在 learning_rate,回到 Step 1-2 继续排查。

排查步骤 4:Isaac Lab 交叉验证。

如果在 mjlab 中排查了前三步仍然无法定位问题,可以在 Isaac Lab 中用尽可能相同的配置跑一次对照。如果 Isaac Lab 中 reward 正常上涨——问题可能在 mjlab 特有的配置上(如 MuJoCo 的 condim/contype 参数、actuator 类型差异)。如果 Isaac Lab 中同样停滞——问题更可能在你的 obs/reward 设计上(与物理引擎无关的逻辑错误)。

python scripts/rsl_rl/train.py \
  --task My-Velocity-Rough-Go2-IL \
  --num_envs 64 --max_iterations 500

这就是双框架教学方法在调试中的实际价值——两个框架像两个独立的"目击者",如果它们的"证词"一致(都说 reward 不涨),你可以排除框架特有的因素,聚焦在任务设计层面。

排查步骤 5:reward shape 验证。

当 Step 1-4 都没有找到问题时,可能是 reward 的"形状"不适合 PPO 学习。一个常见的隐患是 reward 太平坦——策略在不同状态获得的 reward 差异太小,PPO 的 advantage estimation 几乎全是噪声。

# 检查 reward 的"信号强度"
rewards_list = []
for _ in range(100):
    obs, reward, done, info = env.step(random_actions)
    rewards_list.append(reward)

rewards = torch.stack(rewards_list)
print(f"Reward mean: {rewards.mean():.4f}")
print(f"Reward std: {rewards.std():.4f}")
print(f"Reward range: [{rewards.min():.4f}, {rewards.max():.4f}]")
# 如果 std < 0.01 × |mean|,reward 信号太弱

如果 reward std 接近零,说明 reward 函数对状态变化不敏感。可能是 tracking sigma 设得太大(所有 error 值都映射到接近 1.0 的 reward),需要缩小 sigma 让 reward 对好坏动作有更强的区分度。

Phase 5:扩大规模训练 + 定量评估

uv run train My-Velocity-Rough-Go2-v0 \
  --num-envs 4096 --max-iterations 5000 \
  --logger wandb --wandb-name go2_rough_full_v1

在 Phase 5 中,除了监控训练曲线和定期行为回放之外,还应该引入定量行为质量评估——这是 AGILE Stage 3(Evaluate)的核心理念。AGILE 提出了一组 deployment-critical motion metrics,专门用于评估策略是否适合部署到真机:

指标 定义 安全阈值(经验值) 意义
RMS 关节加速度 \(\sqrt{\frac{1}{T}\sum_t \ddot{q}_t^2}\) < 500 rad/s² 关节加速度过大→电机过热
RMS jerk \(\sqrt{\frac{1}{T}\sum_t \dddot{q}_t^2}\) < 5000 rad/s³ Jerk 过大→机械冲击
关节限位违规率 $\frac{1}{T}\sum_t \mathbb{1}[ q_t > q_{\max}]$
高频能量比 \(E_{>10\text{Hz}} / E_{\text{total}}\) < 20% 高频→真机电机追踪不了

这些指标弥补了 reward 曲线的盲区:reward 只衡量"任务完成度",而 motion-quality metrics 衡量"完成方式是否安全"。

Phase 5.5:Sim-to-Sim 交叉验证

在 Phase 5 和最终部署之间,应增加一个 sim-to-sim 验证步骤:将策略从训练框架导出 ONNX,在另一个物理引擎中加载并评估。

# Isaac Lab 训练 → ONNX 导出
python scripts/rsl_rl/export.py --task My-Velocity-Rough-Go2-IL \
  --checkpoint logs/.../model_best.pt

# 在 mjlab / unitree_mujoco 中加载 ONNX 并评估
./unitree_mujoco -i 0 -n eth0 -r go2 -s scene.xml \
  --policy exported/policy.onnx

如果行为不一致,按以下顺序排查:(1) 关节排列顺序是否一致(ONNX 期望的 joint ordering vs 目标引擎的 joint ordering);(2) PD gain 是否一致;(3) 重力方向是否一致(y-up vs z-up);(4) obs normalization 是否正确冻结。

完整流程总结

Phase 0: Smoke Test ──→ 环境能创建吗?
    ↓ ✅
Phase 1: Zero Agent ──→ 物理系统正常吗?
    ↓ ✅
Phase 2: Random Agent ──→ obs/reward/reset 正常吗?
    ↓ ✅
Phase 3: Small Train ──→ reward 有正向趋势吗?
    │ ❌ → Phase 4 诊断循环
    ↓ ✅
Phase 5: Full Train ──→ 监控 + 定期回放 + motion-quality 评估
    ↓ ✅
Phase 5.5: Sim2Sim ──→ 跨引擎行为一致吗?
    ↓ ✅
Phase 6: Deploy ──→ ONNX + 真机(Ch23)

⚠️ 常见陷阱

⚠️ 思维陷阱:跳过 Phase 0-2 直接训练 - Phase 0-2 总共只需要 25 分钟,但可以避免几小时到几天的浪费

⚠️ 编程陷阱:Phase 3 的 num_envs 太大 - 用 4096 环境做 smoke test 训练,出了问题 debug 时 GPU 内存不够同时开 debugger - 正确做法:Phase 3 用 64-256 环境

⚠️ 思维陷阱:跳过 Phase 5.5(sim-to-sim)直接上真机 - sim-to-sim 能在几分钟内暴露 90% 的部署 bug(joint ordering、normalization、PD gain) - 直接上真机才发现这些问题,代价是硬件损坏风险 + 数小时的现场调试

练习

  1. [操作题] 在 mjlab 中从 Phase 0 到 Phase 3 完整走一遍 Mjlab-Velocity-Flat-Unitree-Go2 的验证流程。在每个 Phase 记录你看到了什么、判断标准是什么。
  2. [设计题] 为一个你自己的机器人任务设计从 Phase 0 到 Phase 5.5 的完整验证计划。每个 Phase 的具体检查内容和通过标准是什么?
  3. [跨章综合题] 结合 Ch08(Domain Randomization)和本节流程:在 Phase 5 中,你应该在训练的什么阶段加入 DR?如果一开始就加 DR 且训练失败了,你如何判断是 DR 的问题还是基础环境的问题?

系统性流程走完之后,还有一些超越常规诊断的高级技术——它们在常规方法失效时特别有用。下一节介绍 reward decomposition、gradient 分析、checkpoint forensics、L2C2 平滑正则、action rescaler 和 NaN 系统分类等高级调试手段。

25.8 高级调试:Reward Decomposition、Checkpoint Forensics 与网络级诊断 ⭐⭐⭐

这一节解决什么问题:当常规模式识别无法定位问题时,提供更深入的调试工具。

动机:常规诊断的盲区

前面的模式识别方法对 80% 的训练问题足够有效。但有些问题更微妙——reward 在涨、KL 正常、entropy 正常、value loss 正常,但策略行为就是不好。或者训练曾经很好但在某个不明确的时间点开始退化,你无法精确找到退化的起点。这类问题需要更细粒度的调试工具。

Reward Decomposition:时间维度的显微镜

标准的 WandB 面板只显示每个 reward term 的 episode 平均值。但一个 episode 内部,reward 的时间分布可能非常不均匀。

反事实推理:如果只看 episode 平均值,你可能发现 contact_reward = 0.5(看起来还行)。但时间分解后发现:摆动阶段 contact_reward = 0.0(正确),接地阶段 contact_reward = 1.0(正确),过渡瞬间 contact_reward = -2.0(有问题)。Episode 平均掩盖了过渡瞬间的尖峰惩罚。

# mjlab 中实现 per-step reward 记录
class RewardDecompositionRecorderCfg:
    """Record per-step reward decomposition for selected environments."""
    num_record_envs: int = 4  # 只记录少量环境,避免 I/O 瓶颈
    record_interval: int = 100  # 每 100 iteration 记录一次

# 可视化代码
import matplotlib.pyplot as plt
import numpy as np

def plot_reward_decomposition(step_rewards, episode_id):
    n_terms = len(step_rewards)  # step_rewards 是 {term_name: values} 字典
    fig, axes = plt.subplots(n_terms, 1,
                              figsize=(12, 3*n_terms),
                              sharex=True)
    axes = np.atleast_1d(axes)  # n_terms==1 时 subplots 返回单个 Axes
    for i, (term_name, values) in enumerate(step_rewards.items()):
        axes[i].plot(values, label=term_name)
        axes[i].set_ylabel(term_name)
        axes[i].legend()
    axes[-1].set_xlabel('Step within episode')
    plt.tight_layout()
    plt.savefig(f'reward_decomp_ep{episode_id}.png')

Checkpoint Forensics:时间旅行调试

当训练在某个时间点出现退化但你不确定原因时,checkpoint forensics 可以帮助你精确定位问题起点。

方法:保存高频率的 checkpoint(如每 100 iteration),然后对每个 checkpoint 做相同的评估测试,绘制性能 vs 训练时间的曲线。

# mjlab:保存高频 checkpoint
uv run train My-Velocity-Rough-Go2-v0 \
  --save-interval 100 --max-iterations 5000

# 批量评估所有 checkpoint
for ckpt in logs/.../model_*.pt; do
    uv run play My-Velocity-Rough-Go2-v0 \
      --checkpoint $ckpt --num-episodes 50 \
      --headless True --eval-only True
done

精髓在于:训练时的 reward 是在变化的 obs normalization 和 curriculum 下测量的,而评估时的 reward 是在固定条件下测量的。如果训练 reward 在涨但评估 reward 在降——策略"学会了适应当前训练条件"但"泛化性在退化"。

自动化退化检测脚本:以下是一个完整的检测脚本框架,可以自动找到退化的拐点:

import glob, torch, numpy as np

def forensics_analysis(log_dir, env_cfg, num_eval_episodes=50):
    """批量评估 checkpoint 并检测退化拐点。"""
    checkpoints = sorted(glob.glob(f"{log_dir}/model_*.pt"),
                         key=lambda x: int(x.split("_")[-1].split(".")[0]))

    results = []
    for ckpt_path in checkpoints:
        iteration = int(ckpt_path.split("_")[-1].split(".")[0])
        # 加载 checkpoint 并评估
        eval_reward = evaluate_checkpoint(ckpt_path, env_cfg, num_eval_episodes)
        results.append({"iteration": iteration, "eval_reward": eval_reward})

    # 检测退化拐点:滑动窗口比较
    df = pd.DataFrame(results)
    window = 5  # 比较相邻 5 个 checkpoint
    df["rolling_mean"] = df["eval_reward"].rolling(window).mean()
    df["rolling_diff"] = df["rolling_mean"].diff()

    # 找到第一个显著下降点
    decline_threshold = -0.05 * df["eval_reward"].max()  # 下降超过峰值的 5%
    decline_points = df[df["rolling_diff"] < decline_threshold]
    if not decline_points.empty:
        first_decline = decline_points.iloc[0]["iteration"]
        print(f"⚠️ Performance decline detected at iteration {first_decline}")
        print(f"   Recommend reverting to checkpoint before iter {first_decline}")
    else:
        print("✅ No significant performance decline detected")

    return df

这种"考古式"调试在以下场景特别有用:(1) 你周五晚上挂了一个训练,周一回来发现后半段全在退化;(2) 你怀疑某个 curriculum stage 切换导致了退化但不确定是哪个阶段;(3) 你需要找到"最佳 checkpoint"用于部署而不是直接用最后一个。

网络权重与梯度分析

# 加载 checkpoint 检查权重统计
import torch
ckpt = torch.load("model_3000.pt")
for name, param in ckpt['model_state_dict'].items():
    print(f"{name}: mean={param.mean():.6f}, "
          f"std={param.std():.6f}, "
          f"max_abs={param.abs().max():.6f}, "
          f"has_nan={torch.isnan(param).any()}")

异常信号:某层 std 远大于其他层 → 发散中;某层权重接近零 → 梯度消失;含 NaN → 训练已崩但被掩盖。

RSL-RL 默认启用 gradient clipping(max_grad_norm)。何时调整:

场景 推荐 max_grad_norm 理由
标准 locomotion 1.0(默认) 梯度规模适中
复杂 reward(多项加权) 0.5 reward 项多时梯度可能更大
稀疏 reward 任务 2.0 梯度本身小,clipping 太紧阻碍学习
大 batch(8192+ envs) 0.5-1.0 大 batch 的梯度方差小但幅度可能大

类比:梯度分析就像检查发动机的燃油喷射系统。即使仪表盘上的速度看起来正常(reward 正常),如果某个气缸的喷油量异常大(某层梯度异常),早晚会出问题。

L2C2 平滑正则化:从训练到部署的平滑性保障

前面在 25.5 的选型表中提到了 L2C2。这里深入讲解它的原理和使用方法。

动机:为什么 action_rate penalty 不够?action_rate penalty 惩罚的是 \(\|a_t - a_{t-1}\|^2\)——相邻时步的动作差异。它确保了轨迹的平滑性,但不保证策略函数本身的平滑性。什么意思?假设在 state \(s\) 处策略输出 \(a = 0.5\),在一个非常接近的 state \(s + \epsilon\) 处输出 \(a = -0.5\)。如果训练轨迹没有经过 \(s + \epsilon\) 附近,action_rate penalty 不会发现这个"悬崖"。但部署到真机时,传感器噪声导致 obs 在 \(s\)\(s + \epsilon\) 之间跳动,策略输出就会剧烈振荡。

本质洞察action_rate 保障的是轨迹平滑性(trajectory smoothness), L2C2 保障的是策略平滑性(policy smoothness)。 前者只管"训练数据覆盖到的路径",后者管"整个策略函数的行为"。 部署到有噪声的真机时,策略平滑性比轨迹平滑性更重要。

L2C2(Locally Lipschitz Continuous Constraint,Kobayashi 2022,IROS'22)解决的就是这个问题。它不是惩罚轨迹差异,而是直接约束策略函数在局部邻域内的变化率——即 Lipschitz 常数。

核心机制:在每个时步 \(t\),用 state transition \((s_t, s_{t+1})\) 定义一个局部紧空间,在这个空间内对策略和 value function 施加平滑约束。具体地,生成一个插值输入 \(\tilde{s} = s_t + \alpha (s_{t+1} - s_t)\),其中 \(\alpha \sim U(0, 1)\),然后惩罚:

\[L_{\text{L2C2}} = \lambda_\pi \|\pi(\tilde{s}) - \pi(s_t)\|^2 + \lambda_V \|V(\tilde{s}) - V(s_t)\|^2\]

为什么 L2C2 选择"局部"而非"全局" Lipschitz 约束?全局 Lipschitz 约束(如 spectral normalization)会均匀限制网络在所有输入上的变化率,导致策略在需要快速响应的区域也被迫"迟钝"。而 L2C2 的约束范围由实际 state transition 定义——状态变化大的区域(如跑步时),约束的局部空间也大,允许策略在这个范围内有较大变化;状态变化小的区域(如静止站立时),约束空间小,要求策略输出几乎不变。这正是"局部"Lipschitz 的精妙之处。

在 RSL-RL 中实现 L2C2 需要修改 PPO 训练循环。以下是核心代码:

# L2C2 实现(添加到 RSL-RL 的 PPO update 函数中)
def compute_l2c2_loss(actor_critic, obs_batch, next_obs_batch,
                       lambda_pi=1.0, lambda_v=0.1):
    """计算 L2C2 正则化损失。"""
    batch_size = obs_batch.shape[0]

    # 生成插值系数 alpha ~ U(0, 1)
    alpha = torch.rand(batch_size, 1, device=obs_batch.device)

    # 插值输入
    obs_interp = obs_batch + alpha * (next_obs_batch - obs_batch)

    # 计算原始点和插值点的输出
    with torch.no_grad():
        pi_base = actor_critic.act(obs_batch)  # 不需要梯度
        v_base = actor_critic.evaluate(obs_batch)

    pi_interp = actor_critic.act(obs_interp)   # 需要梯度
    v_interp = actor_critic.evaluate(obs_interp)

    # L2C2 损失
    pi_loss = lambda_pi * (pi_interp - pi_base.detach()).pow(2).mean()
    v_loss = lambda_v * (v_interp - v_base.detach()).pow(2).mean()

    return pi_loss + v_loss
# 在 PPO update 循环中调用
for epoch in range(num_learning_epochs):
    for batch in mini_batches:
        # 标准 PPO losses
        surrogate_loss = ...
        value_loss = ...

        # L2C2 正则化
        l2c2_loss = compute_l2c2_loss(
            actor_critic, batch.obs, batch.next_obs,
            lambda_pi=1.0, lambda_v=0.1
        )

        # 总损失
        total_loss = surrogate_loss + value_loss_coef * value_loss + l2c2_loss
        total_loss.backward()

HoST 和 AGILE 中使用的参数是 \(\lambda_\pi = 1.0\)\(\lambda_V = 0.1\)\(\lambda_\pi\)\(\lambda_V\) 大 10 倍,因为 actor 的平滑性对部署更关键(抖动的动作会损坏硬件),而 critic 的平滑性主要影响训练稳定性。

与 CAPS 和 Grad-CAPS 的对比

方法 平滑对象 邻域定义 额外前向传播 适用场景
action_rate L2 相邻时步动作差异 无(仅时间) 0 基础 locomotion
CAPS(Mysore 2021) 相邻时步 + 空间邻域 固定范围 1 需要空间平滑
L2C2(Kobayashi 2022) 状态转移定义的局部空间 自适应 1 高动态 + 真机部署
Grad-CAPS(Lee 2024) 策略的梯度而非输出 时间维度 1(+反向传播) 理论更严格但计算更贵

反事实推理:如果不用 L2C2 直接把高动态策略(如后空翻、功夫)部署到真机,策略在 obs 噪声下产生的高频振荡可能导致电机过热、齿轮打牙、甚至结构损坏。L2C2 的计算成本(大约增加 10-15% 训练时间)远低于一次硬件损坏的维修成本。

类比:L2C2 相当于给策略网络安装了"减震器"——它允许策略做大幅度动作(不限制绝对值),但要求动作变化必须平滑过渡(限制变化率)。就像高性能赛车的悬挂系统——不是让车跑得慢,而是让快速过弯时仍然稳定。

Action Rescaler β:部署时的安全阀

HoST 引入的 action rescaler 是另一个兼顾训练和部署的工具。回顾 25.4 中人形模式 B(action saturation)——策略输出大量 \(\pm 1.0\) 的极端动作。Action rescaler 的设计是:

\[p_t^d = p_t + \beta \cdot a_t\]

其中 \(p_t\) 是当前关节位置,\(a_t\) 是策略输出(范围 \([-1, 1]\)),\(\beta\) 是缩放因子。这个公式的物理含义是:策略不是直接指定目标关节角度,而是指定相对于当前位置的增量,由 \(\beta\) 控制增量的最大幅度。

从两个角度理解 \(\beta\) 的作用: - 探索角度\(\beta\) 大时动作空间宽,策略可以做大幅度动作(有利于探索早期发现有效行为);\(\beta\) 小时动作空间窄,策略被限制在小幅度动作(限制探索但更安全) - 安全角度\(\beta\) 直接限制了单步内关节位置的最大变化量。\(\beta = 0.3\) 意味着无论策略输出什么,关节位置最多变化 \(0.3 \times \text{action\_range}\)

HoST 在训练中用 curriculum 让 \(\beta\) 从 0.3 线性升到 1.0:

# Action Rescaler curriculum 实现
class ActionRescalerCurriculumCfg:
    initial_beta: float = 0.3    # 训练初期:小幅度动作
    final_beta: float = 1.0      # 训练后期:全幅度动作
    warmup_iterations: int = 3000  # 3000 iter 内线性增长

# 在环境的 step 函数中
def step(self, actions):
    # 计算当前 beta
    progress = min(self.iteration / self.rescaler_cfg.warmup_iterations, 1.0)
    beta = self.rescaler_cfg.initial_beta + \
           progress * (self.rescaler_cfg.final_beta - self.rescaler_cfg.initial_beta)

    # 应用 action rescaler
    scaled_actions = actions * beta  # actions 范围 [-1, 1]
    target_joint_pos = self.default_joint_pos + scaled_actions * self.action_scale

    # 用 PD 控制器追踪目标关节位置
    torques = self.Kp * (target_joint_pos - self.joint_pos) - self.Kd * self.joint_vel
    # ...

部署到真机时可以用 \(\beta = 0.8\) 或更低作为"安全带"——牺牲一些动作幅度换取硬件安全。

反事实推理:如果不用 action rescaler 而直接让策略从训练初始就有 \(\beta = 1.0\) 的完整动作空间,策略在探索阶段可能输出极端动作导致关节频繁撞击限位。这不仅产生不自然的行为,还会让 obs 中的关节位置经常处于极值,影响策略的学习效率。从小 \(\beta\) 开始让策略先学会"小心翼翼地控制",再逐步放开幅度学会"大胆地控制"。

NaN 系统性分类法:四类工程问题

NaN 是训练中最令人恐惧的 bug——它意味着"某个地方出了未知错误"。但 NaN 并非不可分析。结合 Ch24 的框架和调研文档中的工程经验,NaN 可以系统地分为四类:

类别 典型表现 根因域 证据来源 不要先做什么
数值崩溃 RSL-RL 训练循环报 NaN loss 物理状态 / 接触 solver / reward 爆炸 NaN dump(--enable-nan-guard True 调 PPO 学习率(不是 PPO 的问题)
复现失败 "昨天炸今天不炸" 随机种子 / 初始化 / DR 边界条件 rolling buffer 回放 + 固定 seed 把"不复现"当"已修复"
性能退化 steps/s 下降一半 sensor 开销 / manager 积累 / CUDA Graph 失效 benchmark 拆分(逐个关闭 sensor/manager) 降低任务难度(不是难度问题)
容量溢出 OOM 或行为悄悄变异 nconmax/njmax/sensor buffer 容量扫描(记录 mjData.ncon 分布) 减半 num_envs(治标不治本)

类比:NaN 诊断像看急诊——先分诊(外伤?内科?神经?),再针对性检查。不分诊就做全身 CT 既浪费时间又可能漏掉真正的问题。

五种具体 NaN 根因(按频率排序)

NaN 根因 1:接触 solver 发散(最常见)

在高 condim(6)+ 大摩擦系数 + 小 solref 的配置下,MuJoCo 的 PGS/Newton solver 可能在一步内无法收敛,导致接触力包含 NaN。这个 NaN 通过接触力 → 加速度 → 速度 → 位置 → obs 的链条传播到网络。

# 诊断:检查接触力是否包含 NaN
contact_forces = env.contact_forces  # [num_envs, num_bodies, 3]
nan_mask = torch.isnan(contact_forces).any(dim=-1).any(dim=-1)
if nan_mask.any():
    print(f"NaN contacts in {nan_mask.sum()} environments")
    # 打印出问题环境的物理参数
    for idx in nan_mask.nonzero():
        print(f"  env {idx}: ncon={env.data.ncon[idx]}, "
              f"max_force={contact_forces[idx].abs().max():.1f}")

修复:检查 solref / solimp 设置(Ch03),降低摩擦系数到物理合理范围(通常 < 2.0),或增大 solver 迭代次数(niter)。

NaN 根因 2:Reward 爆炸

使用 \(1/d\) 型距离 reward(当目标距离 \(d \to 0\) 时 reward → \(\infty\)),或 \(\log(d)\) 型(当 \(d \to 0\) 时 → \(-\infty\))。这些爆炸的 reward 通过 value target 传播到 Critic,再通过 GAE 传播到 Actor。

# ❌ 危险的 reward 设计
distance_reward = 1.0 / (target_distance + 1e-8)  # d→0 时爆炸

# ✅ 安全的替代方案
distance_reward = torch.exp(-target_distance ** 2 / sigma)  # 有界 [0, 1]
# 或者
distance_reward = 1.0 / (1.0 + target_distance)  # 有界 [0, 1]

NaN 根因 3:Obs 溢出(EmpiricalNormalization 预热不足)

EmpiricalNormalization 使用 Welford 算法计算 running mean/std。在训练刚启动的前几个 iteration 中,如果 std 的估计值接近零(某个 obs 维度的变化极小),归一化操作 (x - mean) / std 会产生巨大的值甚至 NaN。

# 修复:设置 minimum std
class SafeEmpiricalNormalization(EmpiricalNormalization):
    def normalize(self, x):
        safe_std = torch.clamp(self.running_std, min=1e-4)
        return (x - self.running_mean) / safe_std

NaN 根因 4:Log-prob NaN(动作分布 std 变负)

如果 actor 网络直接输出 std(而非 log_std),数值不稳定可能导致某些时刻 std < 0,此时 \(\log(\sigma)\) 产生 NaN。RSL-RL 默认使用一个可学习的全局 std 参数(nn.Parameter 形式的 log/std,而非 actor 逐状态输出 std),所以这个问题在 RSL-RL 中较少见。但如果你自定义了网络结构让 std 可学习,必须确保 std 永远为正。

# ✅ 安全的 std 参数化
raw_std = self.std_layer(features)      # 可以为任意值
std = torch.nn.functional.softplus(raw_std) + 1e-6  # 永远为正
# 或者
log_std = self.log_std_layer(features)  # 可以为任意值
std = torch.exp(log_std.clamp(-5, 2))   # clamp 防止极端值

NaN 根因 5:CUDA Graph 失效(形状不匹配)

DR 改变了环境的内存布局(例如添加了几何体改变了 contact 数组大小),CUDA Graph 的 captured 形状不匹配。这通常不直接产生 NaN,而是产生 CUDA 错误或无声的计算错误(使用了废弃的内存地址)。

修复:在 MuJoCo/mjlab/MuJoCo Warp 场景中把 contacts padding 到固定大小(在 MJCF 中设置 nconmax),或关闭 CUDA Graph capture。注意 nconmax 是 MuJoCo/MJCF 的容量概念,并非 Isaac Lab/PhysX 配置项;在 Isaac Lab(PhysX 后端)中对应的是接触缓冲区/contact sensor 相关配置(且较新版本已移除 SimulationCfg.disable_contact_processing),需按所用版本的官方参数处理。

双框架调试的互补性

调试任务 mjlab 优势 Isaac Lab 优势
Reward decomposition RecorderManager 原生支持 需自定义 wrapper
行为回放 Viser 轻量,远程可用 Isaac Sim Viewer 视觉质量高
Checkpoint 分析 标准 PyTorch .pt 格式 同上(RSL-RL 后端)
梯度监控 RSL-RL 源码易修改 取决于 RL 后端
跨引擎 sim2sim MuJoCo Warp → CPU MuJoCo PhysX → MuJoCo(需额外工具)
传感器调试 export-scene 导出场景快照 Isaac Sim 有内置传感器可视化
AGILE GUI 工具 可用自定义脚本实现 AGILE 原生支持

⚠️ 常见陷阱

⚠️ 编程陷阱:高频 checkpoint 耗尽磁盘空间 - 设置 max_checkpoints 参数只保留最近 N 个

⚠️ 思维陷阱:过度依赖高级调试工具 - 80% 的问题用 25.4 的模式识别解决,20% 的棘手问题才动用本节的工具

⚠️ 概念误区:L2C2 会显著降低策略性能 - L2C2 约束的是"局部"Lipschitz 常数,不是全局平滑。在 HoST 的实验中,L2C2 对 reward 的影响 < 3%,但对部署平滑性的提升超过 50% - 正确理解:L2C2 牺牲的是"策略在极端 state 附近的不平滑自由度",这些自由度在训练中可能带来微小收益但在部署中造成巨大风险

⚠️ 编程陷阱:NaN 出现后只做了一次 nan_to_num 就继续训练 - nan_to_num 掩盖了症状但根因未解决 - 正确做法:NaN 出现后必须找到根因(用上述五种分类法定位),修复后从 clean checkpoint 重新训练

练习

  1. [操作题] 选择一个你正在训练的任务,实现 per-step reward decomposition 记录,绘制一个 episode 的 reward 时间线图。从图中你发现了什么训练数字面板上看不到的信息?
  2. [设计题] 设计一个自动化的 checkpoint forensics 脚本:输入一个训练日志目录,自动加载所有 checkpoint,评估每个 checkpoint 的性能,绘制性能 vs 训练时间的曲线,并标注性能下降的拐点。
  3. [计算题] 假设你有一个 3 层 MLP(256-256-12),L2C2 需要对每个 sample 做一次额外前向传播。如果 batch_size = 24576,L2C2 带来的额外计算量(FLOPs)大约是多少?与 PPO 本身的前向+反向传播比例如何?

高级调试工具备齐后,最后一个问题是:如何为自己的研究项目设计系统性的实验?这不只是"跑一次训练",而是"跑一组对比实验、得出可靠结论"。

25.9 实验设计的诊断视角 ⭐⭐

这一节解决什么问题:从诊断和调参的角度,指导如何设计可比较、可复现的训练实验。

动机:单次训练不可靠

RL 训练有内在的随机性——随机种子、初始化、环境 reset、PPO mini-batch 抽样都会影响结果。一次训练的 reward 曲线不能作为"这个配置好/坏"的结论。

反事实推理:你把 learning_rate 从 3e-4 改到 1e-3,训练一次,reward 提高了 15%。你得出结论"1e-3 更好"。但如果你用同样的配置跑第二次(换一个 seed),可能发现 3e-4 反而更好。在机器人 RL 中,不同 seed 的性能方差可以高达 20-30%,远大于许多参数调整带来的效果。

种子管理

# 多种子实验
for seed in 42 123 456 789 1024; do
    uv run train My-Velocity-Rough-Go2-v0 \
      --seed $seed --wandb-name go2_rough_seed${seed}
done

建议:每个配置至少跑 3 个 seed(5 个更好)。

消融实验设计

消融实验的核心原则是"一次改一个变量"。但在实践中,有些参数高度关联——改了 reward weight 可能需要同时调整 learning_rate。这时需要更谨慎的实验设计。

实验设计模式 适用场景 参数数量 实验次数
单因素消融 验证一个参数的影响 1 3-5 values × 3 seeds = 9-15
正交表 同时探索 2-3 个参数 2-3,每个 2-3 level L9 = 9 × 3 seeds = 27
AGILE scaled-dict 结构化参数组缩放 多参数绑定为 1 标量 3-5 values × 3 seeds
Bayesian 优化 参数空间大、每次实验贵 5+ 自适应(50-100 trials)

消融实验的具体操作流程

  1. 确定 baseline:先用默认配置训练 3 个 seed,确认 baseline 性能和方差
  2. 选择消融维度:基于 25.5 的症状索引,确定最可能影响结果的 1-2 个参数
  3. 设定值域:每个参数选 3 个值——默认值、更大、更小
  4. 执行并记录:每个配置跑 3 个 seed,所有实验使用相同的 seed 集合
  5. 分析结果:用 WandB 的 Parallel Coordinates 视图或手动绘制性能 vs 参数图
# 消融实验脚本示例(mjlab)
TASK="My-Velocity-Rough-Go2-v0"
SEEDS="42 123 456"

# Baseline
for seed in $SEEDS; do
    uv run train $TASK --seed $seed \
      --wandb-name baseline_seed${seed} \
      --wandb-group "ablation_v1"
done

# 消融 1:learning_rate
for lr in 1e-4 3e-4 1e-3; do
    for seed in $SEEDS; do
        uv run train $TASK --seed $seed \
          --agent.algorithm.learning_rate $lr \
          --wandb-name ablation_lr${lr}_seed${seed} \
          --wandb-group "ablation_v1"
    done
done

# 消融 2:entropy_coef
for ent in 0.001 0.005 0.01; do
    for seed in $SEEDS; do
        uv run train $TASK --seed $seed \
          --agent.algorithm.entropy_coef $ent \
          --wandb-name ablation_ent${ent}_seed${seed} \
          --wandb-group "ablation_v1"
    done
done

WandB Sweep 自动化配置

对于更大规模的参数搜索,WandB Sweep 可以自动化整个过程。以下是一个完整的 sweep 配置:

# sweep_config.yaml
program: src/mjlab/scripts/train.py
method: bayes  # 或 grid / random
metric:
  name: Train/mean_reward
  goal: maximize
parameters:
  learning_rate:
    min: 1e-4
    max: 3e-3
    distribution: log_uniform_values
  entropy_coef:
    values: [0.001, 0.005, 0.01]
  num_learning_epochs:
    values: [3, 5, 8]
  desired_kl:
    values: [0.005, 0.01, 0.02]
early_terminate:
  type: hyperband
  min_iter: 500
  max_iter: 5000
  s: 3
# 启动 sweep
wandb sweep sweep_config.yaml
# 输出:Created sweep with ID: abc123

# 在多台 GPU 上启动 agent
wandb agent my_project/abc123  # 每个 agent 认领一个配置并运行

Sweep 结果分析:训练完成后,WandB 的 Parallel Coordinates 视图能揭示参数之间的交互关系。例如,你可能发现"learning_rate 和 num_learning_epochs 之间存在反比关系"——learning_rate 高时 epoch 必须少,反之亦然。这种交互在单因素消融中看不到。

# 使用 WandB API 导出 sweep 结果做进一步分析
import wandb
import pandas as pd

api = wandb.Api()
sweep = api.sweep("my_project/abc123")

# 提取所有 run 的参数和最终性能
results = []
for run in sweep.runs:
    results.append({
        "lr": run.config.get("learning_rate"),
        "ent": run.config.get("entropy_coef"),
        "epochs": run.config.get("num_learning_epochs"),
        "final_reward": run.summary.get("Train/mean_reward"),
        "final_kl": run.summary.get("Train/kl"),
    })

df = pd.DataFrame(results)
print(df.groupby(["lr", "ent"]).agg({"final_reward": ["mean", "std"]}))

Isaac Lab 的对照实验价值:在整本书的双框架教学中,跨框架对照有特殊价值。如果同一个任务配置在两个框架中表现截然不同,说明差异来自框架(物理引擎、contact 模型、数值精度),而不是你的 reward/obs 设计。这帮助你隔离"设计问题"和"框架问题"。

# mjlab 训练
uv run train My-Velocity-Go2-v0 --seed 42 --num-envs 4096

# Isaac Lab 对照(尽可能匹配配置)
python scripts/rsl_rl/train.py \
  --task My-Velocity-Go2-IL --seed 42 --num_envs 4096

如果 mjlab 中 reward 正常但 Isaac Lab 中为零——问题可能在 Isaac Lab 的 obs term 配置上。如果两个框架中 reward 都为零——问题更可能在你的 reward 设计上。

AGILE 的 scaled-dict 方法值得特别说明。在人形机器人的配置中,经常有一组结构性关联的参数——例如所有腿部 PD 增益(6 个关节 × Kp/Kd = 12 个参数)。逐个 sweep 这 12 个参数需要天文数字的实验量。scaled-dict 的做法是将它们绑定到一个标量上:

# AGILE scaled-dict 示例:将腿部 PD 增益绑定到一个 scale 因子
leg_pd_scale = 0.5  # 单一标量
for joint in leg_joints:
    Kp[joint] = base_Kp[joint] * leg_pd_scale
    Kd[joint] = base_Kd[joint] * leg_pd_scale

这样 12 个参数的 sweep 变成了 1 个参数的 sweep(leg_pd_scale 取 0.3, 0.5, 0.7, 1.0, 1.5),保留了组内的相对结构。

AGILE 的描述符驱动 I/O 契约

AGILE 在 Stage 4(Deploy)中引入了一个重要的工程实践:每次训练自动生成一个 YAML I/O 描述符,记录策略的输入输出契约:

# auto-generated deploy descriptor
policy:
  obs_dim: 47
  action_dim: 23
  obs_ordering:
    - "base_ang_vel_x"   # index 0
    - "base_ang_vel_y"   # index 1
    - "base_ang_vel_z"   # index 2
    - "projected_gravity_x"  # index 3
    # ...
  action_ordering:
    - "left_hip_roll"    # index 0
    - "left_hip_pitch"   # index 1
    # ...
  normalization:
    type: "empirical"
    frozen: true
    mean: [0.01, -0.02, ...]
    std: [0.15, 0.20, ...]
  history_length: 5
  action_scale: 0.5
  dt: 0.02

同一个描述符既用于 sim-to-sim 验证器,也用于真机 C++ 控制器。这杜绝了 joint ordering mismatch 的经典 bug。

在你自己的项目中实现类似的描述符非常简单:

# 训练结束后自动生成 deploy descriptor
import yaml

def generate_deploy_descriptor(env, policy, save_path="deploy.yaml"):
    descriptor = {
        "policy": {
            "obs_dim": env.num_obs,
            "action_dim": env.num_actions,
            "obs_ordering": list(env.observation_manager.active_terms.keys()),
            "action_ordering": list(env.action_manager.joint_names),
            "normalization": {
                "type": "empirical" if hasattr(policy, "obs_normalizer") else "none",
                "frozen": True,
                "mean": policy.obs_normalizer.running_mean.cpu().tolist()
                        if hasattr(policy, "obs_normalizer") else None,
                "std": policy.obs_normalizer.running_std.cpu().tolist()
                       if hasattr(policy, "obs_normalizer") else None,
            },
            "history_length": getattr(env, "obs_history_length", 1),
            "action_scale": env.action_scale.cpu().tolist(),
            "dt": env.sim_params.dt * env.cfg.decimation,
        }
    }
    with open(save_path, "w") as f:
        yaml.dump(descriptor, f, default_flow_style=False)
    print(f"Deploy descriptor saved to {save_path}")

结果呈现:生成论文质量图表

消融实验完成后,需要生成论文质量的图表。以下是使用 WandB API + matplotlib 的完整流程:

import wandb, matplotlib.pyplot as plt, numpy as np

api = wandb.Api()
runs = api.runs("my_project",
                filters={"group": "ablation_v1"})

# 按配置分组
groups = {}
for run in runs:
    lr = run.config.get("learning_rate", "default")
    key = f"lr={lr}"
    if key not in groups:
        groups[key] = []
    history = run.history(keys=["Train/mean_reward"], samples=500)
    groups[key].append(history["Train/mean_reward"].values)

# 绘制均值 ± 标准差
fig, ax = plt.subplots(figsize=(8, 5))
colors = plt.cm.Set2(np.linspace(0, 1, len(groups)))
for (name, curves), color in zip(groups.items(), colors):
    curves_array = np.array([c[:min(len(c_) for c_ in curves)]
                             for c in curves])
    mean = curves_array.mean(axis=0)
    std = curves_array.std(axis=0)
    x = np.arange(len(mean))
    ax.plot(x, mean, label=name, color=color)
    ax.fill_between(x, mean - std, mean + std, alpha=0.2, color=color)

ax.set_xlabel("Iteration", fontsize=12)
ax.set_ylabel("Mean Reward", fontsize=12)
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("ablation_results.pdf", dpi=300, bbox_inches='tight')

公平对比的检查清单

检查项 要求 为什么重要
相同随机种子 使用相同的 seed 集合 排除随机性影响
相同环境数量 num_envs 一致 batch size 影响训练动态
相同训练步数 max_iterations 一致 不用"训到收敛"
相同硬件 同一台机器、同一张 GPU 避免浮点精度差异
相同评估条件 评估时 DR 关闭、curriculum 关闭 训练条件可以不同,评估必须统一
统计显著性 均值 ± 标准差,至少 3 seed 单次实验不够

结果呈现的最佳实践

常见问题 正确做法
只展示一条曲线 展示均值 ± 标准差的阴影区域
用不同 num_envs 对比 统一 num_envs 或转换为 total env steps
截取曲线"好看"部分 展示完整训练过程
只展示训练 reward 训练和评估 reward 都展示
不说明 smoothing 参数 在图注中标明 smoothing 值

⚠️ 常见陷阱

⚠️ 思维陷阱:用训练曲线比较算法 - 训练过程中的 reward 受 curriculum、DR、normalization 影响,不反映策略的真实能力 - 正确做法:用固定条件的评估 reward 来比较

⚠️ 编程陷阱:忘记记录配置版本 - 正确做法:WandB 配置中包含 git commit hash

⚠️ 概念误区:更多参数 sweep = 更好的结果 - 先用诊断方法定位最可能有问题的 1-2 个参数,只 sweep 这些

练习

  1. [设计题] 你的论文要对比"有 DR"和"无 DR"两种配置在四足 rough terrain 任务上的性能。设计完整的实验方案。
  2. [思考题] 为什么不能用"训到 reward 不再增长就停"作为公平对比的标准?给出反例。

到此为止,我们的所有诊断和调参工具都假设策略在仿真中运行。但最终目标是部署到真实机器人上——而真机引入了一类全新的诊断需求:策略输出的平滑性是否满足硬件约束。最后这一节聚焦于从策略层面保障真机可部署性。

25.10 策略平滑性与真机部署预诊断 ⭐⭐⭐

这一节解决什么问题:在部署到真机之前,诊断策略输出是否满足硬件安全要求,提供工程工具确保平滑性。

动机:为什么仿真中"好的策略"在真机上会抖

一个在仿真中 reward 很高、步态自然的策略,部署到真机后可能出现严重抖动。这不是 sim-to-real gap 的"经典"问题(参数不匹配、传感器噪声),而是一个更根本的问题:仿真中的"平滑"和真机上的"平滑"标准不同。

仿真器在每个时步都完美执行策略输出的动作——无论动作变化多快。但真机的电机有响应带宽限制(通常 50-200 Hz),超过带宽的高频指令会被电机的低通特性自然滤掉或产生不可预测的振荡。如果策略在相邻时步输出的关节目标差距很大(即使在仿真中这个差距被 PD 控制器"平滑化"了),真机上的电机可能追不上,产生明显的抖动和噪声。

反事实推理:如果不做部署前的平滑性诊断,你到了实验室、架好了机器人、运行策略——机器人开始剧烈抖动、电机过热、甚至齿轮打牙。你花了半天时间调 PD gain、换电机,最后才发现是策略本身的动作变化太快。如果在仿真中就做了平滑性检查,这个问题可以在 5 分钟内发现和修复。

三层平滑性检查

从策略输出到真机执行,平滑性需要在三个层面都得到保障:

第一层:动作层(Action Layer)

测量策略输出 \(a_t\) 的变化率统计量:

# 动作层平滑性检查
def check_action_smoothness(actions: torch.Tensor, dt: float):
    """actions shape: [T, num_envs, action_dim]"""
    # 动作变化率(一阶差分)
    action_rate = (actions[1:] - actions[:-1]) / dt
    # 动作加速度(二阶差分)
    action_accel = (action_rate[1:] - action_rate[:-1]) / dt
    # 动作 jerk(三阶差分)
    action_jerk = (action_accel[1:] - action_accel[:-1]) / dt

    print(f"Action rate  - mean: {action_rate.abs().mean():.2f}, "
          f"max: {action_rate.abs().max():.2f}")
    print(f"Action accel - mean: {action_accel.abs().mean():.2f}, "
          f"max: {action_accel.abs().max():.2f}")
    print(f"Action jerk  - mean: {action_jerk.abs().mean():.2f}, "
          f"max: {action_jerk.abs().max():.2f}")

如果 action rate 的最大值超过了电机的最大角速度 \(\dot{q}_{\max}\),策略指令在真机上一定会被截断。

第二层:关节层(Joint Layer)

将 action 通过 PD 控制器转换为关节力矩和加速度,检查是否超过硬件限制:

指标 计算方法 安全阈值(Unitree G1 为例)
关节速度峰值 $\max_t \dot{q}_t
关节力矩峰值 $\max_t \tau_t
瞬间功率 $\max_t \tau_t \cdot \dot{q}_t

ETH 的羽毛球机器人系统(Science Robotics, 2025)在训练中使用了 constrained PPO(Lagrangian 方法)直接约束关节速度和力矩到安全范围以下。对于网球等高速击打任务(Ch28),这个约束至关重要——击球瞬间球拍末端线速度可以达到约 12 m/s(注意这是线速度而非角速度),不加约束的策略可能输出远超电机额定值的指令。

第三层:网络层(Network Layer)

检查策略网络对 obs 噪声的敏感度——即策略的 Lipschitz 常数。如果策略对 obs 的微小扰动产生大幅动作变化,说明策略函数不够平滑,部署到有传感器噪声的真机上会抖动。

# 网络层敏感度检查(近似 Lipschitz 测试)
def check_lipschitz_sensitivity(policy, obs: torch.Tensor, n_samples=100):
    """在 obs 附近随机扰动,测量动作变化幅度"""
    with torch.no_grad():
        base_actions = policy(obs)
        max_ratio = 0
        for _ in range(n_samples):
            noise = torch.randn_like(obs) * 0.01  # 1% 噪声
            perturbed_actions = policy(obs + noise)
            action_diff = (perturbed_actions - base_actions).norm(dim=-1)
            obs_diff = noise.norm(dim=-1)
            ratio = (action_diff / (obs_diff + 1e-8)).max()
            max_ratio = max(max_ratio, ratio.item())
    print(f"Approximate Lipschitz constant: {max_ratio:.2f}")
    # 如果 > 10,说明策略对噪声过度敏感,需要 L2C2 正则化

如果 Lipschitz 常数 > 10,强烈建议加 L2C2 正则化重新训练(见 25.8 节)。

Butterworth 低通滤波器:部署端的最后防线

WoCoCo(CoRL'24)在部署时使用了一个 4 Hz 二阶 Butterworth 低通滤波器对策略输出做滤波。这是一道硬件安全的"最后防线"——即使策略产生了高频噪声,滤波器也会把它滤掉。

from scipy.signal import butter, lfilter

# 4 Hz Butterworth 低通滤波器(用于 50 Hz 策略频率)
b, a = butter(N=2, Wn=4.0, fs=50.0, btype='low')

# 在部署循环中逐步滤波
class ButterworthFilter:
    def __init__(self, order=2, cutoff=4.0, fs=50.0, num_joints=12):
        self.b, self.a = butter(N=order, Wn=cutoff, fs=fs, btype='low')
        # 维护每个关节的滤波器状态
        from scipy.signal import lfilter_zi
        self.zi = [lfilter_zi(self.b, self.a) * 0.0 for _ in range(num_joints)]

    def filter(self, action):
        """对每个关节独立滤波,维护状态以保证因果性。"""
        filtered = np.zeros_like(action)
        for j in range(len(action)):
            filtered[j], self.zi[j] = lfilter(
                self.b, self.a, [action[j]], zi=self.zi[j]
            )
        return filtered

滤波频率的选择:策略频率的 8-10% 是一个好的起点。50 Hz 策略 → 4-5 Hz 截止;100 Hz 策略 → 8-10 Hz 截止。截止频率太低会让策略变"迟钝"(无法做快速动作),太高则起不到保护作用。

频谱分析辅助决策:如果不确定截止频率应该设多少,可以对策略输出做 FFT 频谱分析:

import numpy as np
from scipy.fft import fft, fftfreq

# 收集 1000 步的动作序列
actions = collect_actions(policy, env, n_steps=1000)  # [1000, 12]

# 对每个关节做 FFT
for joint_idx in range(12):
    yf = fft(actions[:, joint_idx])
    xf = fftfreq(1000, d=1.0/50.0)  # 50 Hz 策略频率
    power = np.abs(yf[:500])  # 单侧频谱

    # 找到 90% 能量对应的频率
    cumulative_power = np.cumsum(power**2) / np.sum(power**2)
    freq_90 = xf[np.searchsorted(cumulative_power, 0.9)]
    print(f"Joint {joint_idx}: 90% energy below {freq_90:.1f} Hz")

如果 90% 的信号能量在 5 Hz 以下(对大多数 locomotion 策略确实如此),那么 5-8 Hz 的截止频率只会滤掉噪声而不影响有用信号。但对于击打类任务(Ch28),击球瞬间可能有 15-20 Hz 的有用信号,截止频率需要相应提高。

AGILE Virtual Harness:训练阶段的渐进安全机制

除了部署端的滤波器,AGILE 在训练阶段也引入了安全机制——virtual harness(虚拟安全绳)。其思想是在训练初期给机器人施加一个外部 PD 力将其拉向参考姿态,随着训练进展通过 curriculum 逐步撤去这个外力。

# Virtual harness 的 curriculum 实现
class VirtualHarnessCfg:
    initial_stiffness: float = 500.0  # 初始 Kp(较强的外力)
    final_stiffness: float = 0.0      # 最终 Kp = 0(完全撤去)
    warmup_iterations: int = 2000     # 经过 2000 iter 线性退火

# 在环境的 post_physics_step 中:
def apply_virtual_harness(self):
    progress = min(self.iteration / self.harness_cfg.warmup_iterations, 1.0)
    stiffness = self.harness_cfg.initial_stiffness * (1.0 - progress)
    # 计算拉向参考姿态的外力
    harness_force = stiffness * (self.reference_pos - self.root_pos)
    self.apply_external_force(harness_force)

Virtual harness 的诊断价值在于:如果策略在 harness 完全撤去后立即崩溃(摔倒),说明策略并没有真正学会平衡,只是在"靠 harness 扶着走"。这类似于小孩学自行车——如果辅助轮拆掉就摔跤,说明还没真正学会。解法:延长 harness 的退火时间,或在 harness 完全撤去前确保策略已经在 Phase 3(小规模训练)中验证了自主平衡能力。

完整的部署前检查清单

综合本节和 Ch23 的内容,以下是从训练完成到真机运行之间应该完成的检查清单:

步骤 检查内容 工具 通过标准
1 动作层平滑性 check_action_smoothness() action rate max < \(\dot{q}_{\max}\) × 0.8
2 关节层安全性 check_joint_safety() 力矩峰值 < \(\tau_{\max}\) × 0.7
3 网络层 Lipschitz check_lipschitz_sensitivity() Lipschitz 常数 < 10
4 频谱分析 FFT 分析 90% 能量 < 电机带宽 × 0.5
5 ONNX 一致性 PyTorch vs ONNX 输出比较 max diff < 1e-5
6 Normalization 冻结 ONNX graph 检查 包含 running_mean/running_var
7 Joint ordering deploy.yaml vs 真机定义 完全匹配
8 Sim-to-sim 跨引擎回放 行为一致(视觉检查)
9 低速测试 真机上以 \(\beta = 0.3\) 运行 无异常振动
10 全速测试 真机上逐步提高 \(\beta\) 到 1.0 行为匹配仿真

步骤 9-10 是真机上的渐进部署:不要直接以 \(\beta = 1.0\) 运行策略——先用小的 action rescaler \(\beta\)(如 0.3)让机器人"小幅度"执行策略,确认方向正确后再逐步提高到 \(\beta = 0.8\)\(1.0\)。这比 Butterworth 滤波器更可控,因为它直接限制了动作幅度而非频率。

从诊断到约束:Constrained PPO 的安全保障

当三层平滑性检查发现策略输出超过硬件安全限制时,有两种修复路径:事后滤波(Butterworth)和事先约束(Constrained PPO)。事后滤波简单但可能影响任务性能(滤掉了策略认为必要的高频动作)。事先约束更优雅——直接在训练目标中加入安全约束,让策略学会"在安全范围内完成任务"。

Constrained PPO 使用 Lagrangian 方法将约束优化转化为无约束优化。设原始目标为最大化 reward \(J(\pi)\),安全约束为 \(C(\pi) \leq \delta\)(如关节速度峰值 \(\leq \dot{q}_{\max}\)),则 Lagrangian 为:

\[L(\pi, \lambda) = J(\pi) - \lambda (C(\pi) - \delta)\]

可参考 P3O(Penalized Proximal Policy Optimization,Zhang et al., IJCAI 2022,独立 safe RL 算法)或 PPO-Lagrangian 思路自行在 PPO 上扩展实现这个机制(RSL-RL 官方算法仅含 PPO 与 Student-Teacher Distillation,并未内置 P3O)。在 AGILE 和 ETH 的羽毛球系统中,约束项包括关节速度限制、力矩限制和自碰撞约束。

# Constrained PPO 配置示例(基于 P3O / PPO-Lagrangian 思路自行扩展,非 RSL-RL 内置)
class ConstrainedPPOCfg(PPOCfg):
    # Lagrange 乘子配置
    cost_limit: float = 0.05        # 约束阈值:允许 5% 的时间步违规
    lambda_lr: float = 0.01         # 乘子学习率(通常远小于策略 lr)
    lambda_init: float = 0.0        # 乘子初始值
    lambda_max: float = 10.0        # 乘子上限,防止发散

# 在训练循环中
cost = (joint_vel.abs() > joint_vel_limit).float().mean()  # 违规率
lambda_loss = -self.lagrange_lambda * (cost - cost_limit)
self.lagrange_lambda = torch.clamp(
    self.lagrange_lambda + lambda_lr * (cost - cost_limit),
    min=0.0, max=lambda_max
)

诊断关注点:Lagrange 乘子 \(\lambda\) 的轨迹本身就是一个重要的诊断信号。\(\lambda\) 持续上升意味着策略无法在当前约束下完成任务(约束太紧或任务太难);\(\lambda\) 持续为零意味着约束从未被触发(约束太松或不必要);\(\lambda\) 在两个值之间振荡意味着约束在"勉强满足"边缘——这通常是最理想的状态。

⚠️ 常见陷阱

⚠️ 思维陷阱:平滑性检查只在部署前做一次 - 如果你修改了 reward 或 curriculum 重新训练,之前的平滑性结论不再有效 - 正确做法:将平滑性检查集成到 Phase 5 的评估流程中,每次训练都自动检查

⚠️ 编程陷阱:Butterworth 滤波器引入了相位延迟 - 二阶 Butterworth 在截止频率 4 Hz 处有约 90° 的相位滞后,对应该频率分量约 1/4 周期,即约 62.5 ms 的延迟;实际控制影响应按数字滤波器的 group delay 计算 - 对大多数 locomotion 任务这是可接受的,但对高速击打任务(Ch28)可能需要零相位滤波(双向滤波,仅在离线评估中可用)或降低滤波器阶数

⚠️ 概念误区:仿真中的 PD 控制器已经做了"平滑" - 仿真中的 PD 控制器确实会把阶跃输入平滑化,但真机的 PD 控制器带宽有限、有延迟、有非线性 - 正确做法:检查的是策略输出(PD 的输入),不是 PD 控制后的关节轨迹

⚠️ 编程陷阱:Constrained PPO 的 lambda_lr 设太大 - 乘子振荡导致策略在"满足约束"和"完成任务"之间反复切换,无法稳定 - 正确做法:lambda_lr 设为策略 lr 的 1/10 到 1/100

练习

  1. [操作题] 对一个已训练好的策略运行三层平滑性检查。报告 action rate、joint velocity、Lipschitz 常数的统计量。是否满足 Unitree Go2 的硬件约束?
  2. [设计题] 设计一个实验比较三种部署平滑化方案的效果:(a) 仅 action_rate penalty 训练,(b) action_rate + L2C2 训练,(c) action_rate 训练 + Butterworth 滤波部署。评估指标:tracking accuracy(任务性能)和 RMS jerk(平滑性)。预测三者的 Pareto 关系。

本章常见误解汇总

误解 正确理解
"Reward 涨就是好" Reward 是代理指标,涨不代表行为好。需要结合行为回放和分项 reward
"KL 越小越好" KL 接近零意味着策略不更新。应围绕 desired_kl 波动
"Entropy 应该快速降到零" 过快收敛通常意味着局部最优或 reward hacking
"Value loss 应该一直下降" 先升后降是健康的。Curriculum 切换时短暂升高是正常的
"一次训练就能判断好坏" RL 训练随机性大,至少 3 个 seed 才有统计意义
"调参就是 grid search" 好的调参是症状驱动的因果推理,不是暴力搜索
"两个框架的数值应该一样" 物理引擎、接触模型、数值精度都不同,趋势一致就好
"高级工具比基础诊断更有价值" 80% 的问题通过基础模式识别就能解决。高级工具是兜底手段
"仿真中平滑的策略真机也平滑" 仿真 PD 完美执行任何指令,真机电机有带宽限制。需做三层平滑性检查
"NaN 无法系统性分析" NaN 可分为四类(数值崩溃/复现失败/性能退化/容量溢出),各有对应诊断路径

本章建立的心智模型

读完本章后,你脑中应该有以下心智模型:

训练运行中
    ↓
[五大仪表盘] → Reward / KL / Entropy / Value Loss / Episode Length
    ↓
[模式识别] → 九种典型模式 + 五种人形特有模式
    ↓
[症状索引] → 查表定位:哪个参数 × 哪一章
    ↓
[消融验证] → 一次改一个变量,多 seed 确认
    ↓
[高级调试](如果需要)→ Reward Decomposition / Checkpoint Forensics / L2C2 / NaN 分类
    ↓
[部署预诊断] → 三层平滑性检查 → Butterworth 兜底

如果你能在看到一组训练曲线后 30 秒内说出"这是模式 X,应该检查参数 Y,详见 Ch Z",说明本章的核心知识已经内化了。


本章小结

知识点总表

编号 知识点 核心要点 对应节 难度
1 五大训练指标 Reward/KL/Entropy/Value Loss/Episode Length 的物理直觉 25.1 ⭐⭐
2 Reward 曲线六种形态 每种形态的含义和对应操作 25.2 ⭐⭐
3 分项 Reward 诊断 总 reward 掩盖了分项之间的此消彼长 25.2 ⭐⭐
4 Online Reward Normalization AGILE 公式 + scale_by_dt 陷阱 + nan_to_num 误导性 25.2 ⭐⭐⭐
5 KL 自适应机制 bang-bang 控制器 + desired_kl 经验值表 25.3 ⭐⭐⭐
6 Entropy 公式与生命周期 高斯 entropy 公式 + 不均匀收敛 + entropy_coef 不是万能药 25.3 ⭐⭐⭐
7 Value Loss 三层含义 Critic 进度 + reward scale + obs 质量;multi-critic 分组诊断 25.3 ⭐⭐⭐
8 九种训练模式 含模式九"自杀策略"(value-bootstrapped termination) 25.4 ⭐⭐⭐
9 五种人形特有模式 foot lift / action saturation / curriculum NaN / asymmetry / sim2real 25.4 ⭐⭐⭐
10 症状驱动索引 六类症状 × 具体参数 × 参考章节的路由表 25.5 ⭐⭐⭐
11 AGILE 三大 GUI 工具 Joint Position / Object Manipulation / Reward Visualizer 25.6 ⭐⭐
12 EmpiricalNormalization Welford 算法 + ONNX 冻结 bug + 排查方法 25.6 ⭐⭐⭐
13 验证六阶段流程 Smoke Test→Zero→Random→Small Train→Full Train→Sim2Sim 25.7 ⭐⭐⭐
14 Motion-quality 定量评估 RMS accel / jerk / 限位违规率 / 高频能量比 25.7 ⭐⭐⭐
15 L2C2 平滑正则化 局部 Lipschitz 约束 + CAPS/Grad-CAPS 对比 + 具体参数 25.8 ⭐⭐⭐
16 Action Rescaler β 动作空间缩放 + curriculum + 部署安全阀 25.8 ⭐⭐⭐
17 NaN 四类分类法 数值崩溃/复现失败/性能退化/容量溢出 + 五种根因 25.8 ⭐⭐⭐
18 AGILE scaled-dict sweep 结构化参数绑定为单标量 + I/O 描述符 25.9 ⭐⭐
19 公平实验设计 种子管理 + 统一评估条件 + 统计显著性 25.9 ⭐⭐
20 三层平滑性检查 动作层 + 关节层 + 网络层 Lipschitz 测试 25.10 ⭐⭐⭐
21 Butterworth 部署滤波 4 Hz 截止 + 相位延迟权衡 25.10 ⭐⭐

累积项目:本章新增模块

本课程的累积项目在前 24 章中积累了完整的环境构建和训练能力。本章新增的模块是训练诊断与部署预诊断能力:你现在拥有了一套从指标模式识别到症状索引查表再到平滑性验证的系统性方法。这个能力将在后续 Part VII(Ch26-Ch28 网球机器人综合项目)中被反复使用——网球击球任务的 reward 极稀疏、curriculum 阶段多、DR 维度广,是训练诊断能力最好的练兵场。

项目进度更新:

阶段 能力 新增于
环境构建 EntityCfg → SceneCfg → ManagerBasedRlEnvCfg → Registry Ch04, Ch22
Obs/Action 设计 五条原则 + 双框架配置 Ch05
Reward/Curriculum 四类奖励 + 渐进 curriculum Ch06
训练管线 PPO 超参 + 多后端适配 Ch07
Domain Randomization EventManager + 分阶段 DR Ch08
Teacher-Student 特权学习 + 蒸馏 Ch09
大规模训练 多 GPU + NaN 排查 + 性能优化 Ch24
训练诊断 九种模式 + 症状索引 + 验证流程 + NaN 分类 Ch25
部署预诊断 三层平滑性 + L2C2 + Butterworth Ch25

延伸阅读

资料 地址 难度 与本章的关系
RSL-RL 源码(PPO 实现) github.com/leggedrobotics/rsl_rl ⭐⭐ KL 自适应、entropy 计算的源码级理解
AGILE 工作流和 GUI 工具 github.com/nvidia-isaac/WBC-AGILE ⭐⭐⭐ 四阶段工作流、reward visualizer、motion-quality 评估
L2C2(Kobayashi 2022) arXiv:2202.07152 ⭐⭐⭐ 策略平滑性正则化的数学基础
HoST(Huang et al., RSS'25) github.com/OpenRobotLab/HoST ⭐⭐⭐ Multi-critic PPO、action rescaler、L2C2 参数
WoCoCo(Zhang et al., CoRL'24) github.com/LeCAR-Lab/wococo ⭐⭐⭐ 对称性增强、"no fly" reward、Butterworth 部署滤波
WandB 文档(Sweeps) https://docs.wandb.ai/guides/sweeps 自动化参数搜索的工具
"Deep RL Doesn't Work Yet"(Irpan, 2018) 搜索博客标题 ⭐⭐ RL 训练不稳定性的经典综述
"The 37 Implementation Details of PPO"(ICLR Blog, 2022) 搜索博客标题 ⭐⭐⭐ PPO 实现细节对训练结果的影响
"Time Limits in RL"(Pardo et al., 2018) arXiv:1712.00378 ⭐⭐ Value-bootstrapped termination 的理论基础
unitree_rl_lab 训练脚本 github.com/unitreerobotics/unitree_rl_lab ⭐⭐ 真实部署项目的训练和 ONNX 导出参考
unitree_rl_mjlab 训练脚本 github.com/unitreerobotics/unitree_rl_mjlab ⭐⭐ mjlab 生态的训练配置参考
ASAP(RSS'25)训练配置 github.com/LeCAR-Lab/ASAP ⭐⭐⭐ 跨引擎 sim2real 项目的调参实践

🔧 故障排查手册

症状 可能原因 排查步骤 相关节
WandB 面板空白 logger 未正确配置 1. 确认 --logger wandb 参数 2. 检查 API key 3. 检查网络 25.6
Reward 始终为零且 KL 为零 环境注册错误或 obs 维度为零 1. Phase 0 smoke test 2. 打印 obs shape 3. 检查 reward term 25.7
训练前 100 iter 正常后全 NaN 梯度爆炸或 reward 产生极值 1. --enable-nan-guard True 2. 检查 reward 范围 3. 降 lr 25.8
两个框架 reward 差异很大 obs/reward 配置不一致或物理差异 1. 对比 obs term 2. 对比 reward weight 3. 检查物理参数 25.6
Curriculum 升级后不恢复 跳变太大或局部最优 1. 减小跳变 2. 延长观察 3. 检查回退机制 25.4
Value loss 持续升高 Critic 容量不足或 reward scale 变化 1. 增加 Critic 宽度 2. 检查 reward normalization 3. 观察后续 reward 25.3
Episode length 趋近零 自杀策略 / termination 设置错误 1. 检查 time_out 设置 2. 添加 alive bonus 3. 检查 regularization 权重 25.4(模式九)
策略在 ONNX 推理时输出垃圾 obs normalization 未冻结 / joint ordering 错 1. 检查 ONNX 是否含 running_mean 2. 核对 deploy.yaml 关节顺序 25.6, 25.9
真机上严重抖动 策略 Lipschitz 常数过大 / 电机带宽不足 1. 三层平滑性检查 2. 加 L2C2 重训 3. 加 Butterworth 滤波 25.10

给下一章的桥

本章建立了训练诊断的完整方法论——从指标阅读、模式识别到症状索引、高级调试和部署预诊断。你现在拥有了"面对训练问题时不慌"的能力。但到目前为止,我们的诊断对象都是"标准"的 locomotion 或 manipulation 任务。Part VII(Ch26-Ch28)将把你带入一个全新的挑战维度——网球机器人综合项目。这个项目的训练难度远超前面所有章节:reward 极稀疏(击中球才有分)、curriculum 阶段多(8 阶段从静态球到高速发球)、接触物理敏感(球拍-球的瞬间接触决定出球方向)、感知与控制耦合(预测误差直接传导到击球失败)。Ch26 将从网球场环境构建开始——这是本章诊断方法论的最佳试炼场。