Skip to content

第 06 章:Reward、Curriculum 与 Termination 设计

本章定位:这是从"环境能跑"到"策略能学"的关键一步。前一章建立了 observation 和 action 的接口,但接口本身不产生学习信号。策略真正看见的优化目标是 reward,真正经历的任务难度是 curriculum,真正理解的 episode 边界是 termination。这三者共同决定学习梯度从哪里来、指向哪里。如果 reward 设计错误,PPO 会高效且稳定地学到错误行为;如果 curriculum 过于激进,策略会在太难的任务上早期崩溃;如果 termination 语义不清,value function 的 bootstrap 会把失败当成正常截断。本章的目标不是罗列 reward term 的 API,而是建立一个可调、可诊断、可复现实验的设计框架。

前置依赖:Ch05(Observation 与 Action 设计)、PPO 基础概念(policy gradient / advantage / GAE)、MuJoCo 仿真基础

关键文献:Rudin et al. 2021(legged_gym locomotion reward 体系)、Schulman et al. 2017(PPO)、Bengio et al. 2009(Curriculum Learning)、Ng et al. 1999(Reward Shaping 理论)

参考项目:🔧 mjlab velocity reward terms · 🔧 Isaac Lab velocity reward terms · ✅ TienKung-Lab(github.com/Open-X-Humanoid/TienKung-Lab,~300+ Stars)


前置自测

📋 答不出 ≥ 2 题 → 先回前置章节复习

问题 检查目的
一个 reward term 返回的张量 shape 应该是什么?为什么必须是 [num_envs] 而不是标量? 检查是否理解 batch 计算
time_out=True 标记的 episode 结束和普通 termination 有什么区别?对 critic 的 value bootstrap 有什么影响? 检查是否理解 GAE
为什么不能只看 total reward 曲线来判断训练是否成功?需要同时看哪些分项指标? 检查是否有 reward hacking 意识
curriculum learning 应该改变任务难度还是改变物理规律?它和 domain randomization 的根本区别是什么? 检查 curriculum 与 DR 的辨析
给定指数核 reward \(r = \exp(-e^2 / \sigma^2)\),当误差 \(e \to \infty\) 时 reward 趋向什么值?这和直接用负二次误差 \(r = -e^2\) 有什么本质区别? 检查指数核梯度理解
Ch05 的 obs/action 接口与本章的 reward 之间有什么约束关系? 检查跨章联系
scale_by_dt=True 在 RewardManager 中的作用是什么?改变 decimation 后 reward 权重需要调整吗? 检查 dt 归一化理解

本章难度为 ⭐⭐⭐。它不是数学推导最密集的一章,但最容易在工程上犯错——reward 设计错误往往不会在代码层面报错,只会在训练数百万步之后才以"行为看起来不对"的方式暴露出来。


本章目标

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

  1. 解释 reward shaping 的数学基础——从 MDP 的最优策略不变性定理到 potential-based shaping 的理论保证
  2. 构建 四足 locomotion 的四层 reward 分解(tracking / regularization / style / contact),理解每层的物理意义和相互冲突
  3. 设计 curriculum learning 的三条轴(地形难度 / 命令范围 / reward 严格度),并判断何时使用 performance-driven vs step-driven 推进
  4. 区分 true termination 和 truncation 的语义差异,正确配置 time_out 标记以保证 PPO critic 的 bootstrap 逻辑正确
  5. 实施 reward ablation 实验,用控制变量法验证每个 reward term 的必要性
  6. 编写 双框架中完整的 reward/termination/curriculum 配置,并能在 TensorBoard 中诊断 reward hacking
  7. 对比 locomotion reward 与 manipulation reward 的结构差异,理解 staged reward 的门控机制

本章路线图 ⭐

6.1 从 MDP reward 的数学角色到 potential-based shaping 定理(理论基础)→ 6.2 四足 locomotion 的四层 reward 分解(分层框架)→ 6.3 跟踪奖励参数详解与梯度分析(核心 term)→ 6.4 Regularization、Style 与 Contact reward 详解(辅助 term)→ 6.5 Reward 的时间缩放与日志系统(工程细节)→ 6.6 Termination 与 Value Bootstrap(episode 边界)→ 6.7 Curriculum Learning 三条轴(训练调度)→ 6.8 双框架 RewardManager / TerminationManager / CurriculumManager 源码精读(代码映射)→ 6.9 Reward Ablation 方法论(实验设计)→ 6.10 调参攻略与失败案例(实战经验)→ 6.11 源码阅读路线(深入探索)。

前置依赖与本章定位 ⭐

本章假设你已经学完 Ch05 并理解 obs/action 接口——reward 和 obs/action 之间有三个关键约束(见 Ch05 末尾的"与 Ch06 Reward 设计的接口约束"小节):reward term 能访问的信息不超过 env state、reward 的语义应与 actor obs 一致、reward 中使用的坐标系应与 obs 一致。

本章在全书中的定位是"RL 工程的第二个关键接口层"。Ch05 定义了"策略和环境通过什么接口通信",本章定义了"什么是好的行为"。Ch07 将定义"怎么优化策略"。这三章构成了 RL 工程的核心三角——obs/action 定义状态空间和动作空间的结构,reward 定义价值函数的形状,PPO 在这个形状上做优化。改变其中任何一个,训练结果都可能完全不同。


6.1 Reward Shaping 的理论基础 ⭐⭐

这一节解决什么问题:为什么我们需要 reward shaping?随意添加 reward term 会不会改变最优策略?什么样的 shaping 是"安全"的?

从 MDP 到 Reward 的角色 ⭐⭐

强化学习的标准框架是马尔可夫决策过程(MDP),定义为五元组 \((S, A, P, R, \gamma)\),其中 \(S\) 是状态空间,\(A\) 是动作空间,\(P(s'|s,a)\) 是状态转移概率,\(R(s,a,s')\) 是即时奖励函数,\(\gamma \in [0,1)\) 是折扣因子。策略 \(\pi(a|s)\) 的目标是最大化从初始状态出发的期望折扣回报:

\[J(\pi) = \mathbb{E}_{\pi}\left[\sum_{t=0}^{\infty} \gamma^t r_t\right]\]

这个公式看起来简单,但隐藏着一个深层问题:我们想让机器人学会的行为(如"自然地走路")和我们能直接定义的数学目标之间,存在巨大的语义鸿沟。"自然地走路"意味着速度跟踪命令、动作平滑不抖、姿态保持直立、脚步规律交替、支撑脚不打滑、落脚冲击小——这些目标彼此耦合甚至冲突,无法用一个简洁的标量函数完美表达。Reward shaping 就是弥补这一鸿沟的工程手段:把高层次的行为期望拆解为每步都能测量的局部信号,再通过加权组合反馈给策略优化器。

这和本科控制课里的 PID 设计有一个深层类比。PID 控制器的 P 项对应 tracking reward——当前误差越小越好;D 项对应 action rate penalty 和 body angular velocity penalty——变化不要太剧烈;I 项在某种意义上对应 curriculum 的长期目标——逐渐覆盖更宽的任务分布。这个类比不完美:PID 的结构是固定的三项线性组合,reward 的结构完全由我们设计,可以是任意函数的加权和。但二者都在处理同一件核心问题:把抽象的行为目标变成可调的控制信号

还有一个更深层的类比来自多目标优化。四足 locomotion 的 reward 设计本质上是帕累托优化的标量化——把多个互相冲突的行为目标(速度跟踪 vs 动作平滑 vs 姿态稳定 vs 脚步质量)通过加权和压缩成一个标量。改变 reward 权重等价于在帕累托前沿上移动权衡点。设计者的任务不是找到"全局最优"(不存在),而是在可接受的权衡空间中找到一个"我们能接受的点"。

本质洞察:reward shaping 不是给行为打分的艺术,而是把一个不可微、长时域、接触丰富的控制任务,拆成 PPO 能在短 rollout 中估计 advantage 的局部信号。reward 的质量直接决定 PPO 的梯度方向——好的 reward 让梯度指向期望行为,差的 reward 让梯度指向 reward hacking。

Potential-Based Reward Shaping 定理 ⭐⭐⭐

Reward shaping 最大的理论风险是:添加额外的 reward term 可能改变 MDP 的最优策略。1999 年 Ng、Harada 和 Russell 证明了一个重要定理,给出了"安全 shaping"的充分必要条件。

定理(Ng et al., 1999):设原始 MDP 的奖励函数为 \(R(s,a,s')\),添加一个 shaping reward \(F(s,a,s')\) 后变为 \(R'(s,a,s') = R(s,a,s') + F(s,a,s')\)。当且仅当 \(F\)potential-based 的——即存在势函数 \(\Phi: S \to \mathbb{R}\),使得

\[F(s, a, s') = \gamma \Phi(s') - \Phi(s)\]

时,shaped MDP 的最优策略集合与原始 MDP 完全相同。

为什么 potential-based 是安全的? 直觉解释:telescoping(伸缩求和)。在无限时域折扣设置下,potential-based shaping reward 的累积和为:

\[\sum_{t=0}^{\infty} \gamma^t F_t = \sum_{t=0}^{\infty} \gamma^t [\gamma\Phi(s_{t+1}) - \Phi(s_t)] = -\Phi(s_0) + \lim_{T\to\infty}\gamma^{T+1}\Phi(s_{T+1})\]

\(\gamma < 1\)\(\Phi\) 有界时,极限项为零,总和等于 \(-\Phi(s_0)\)——一个与策略无关的常数。任何策略在 shaped MDP 下的总回报只是原始回报加一个常数,不影响策略排序。

工程上的现实:几乎所有 locomotion reward term 都不是 potential-based 的。 action_rate_l2 惩罚 \(\|a_t - a_{t-1}\|^2\),依赖连续两步的状态和动作,不可能写成 \(\gamma\Phi(s') - \Phi(s)\) 的形式。foot slip penalty 依赖足端速度和接触状态的组合,同样不是 potential-based。这意味着我们添加的每一个 regularization/style/contact term 都在改变最优策略——但这正是我们想要的效果:原始的"最小化 velocity tracking error"最优策略可能是一种剧烈抖动、关节过度加速的行为,只有添加 non-potential shaping 才能把解空间约束到物理上合理的区域。

从理论到工程的启示:potential-based shaping 定理告诉我们"什么不会出错",但不告诉我们"什么是好的 reward"。在工程中,关键不是让 reward 满足 potential-based 条件(这会极大限制设计空间),而是承认 non-potential shaping 的影响并通过 ablation 实验验证每个 term 的实际效果。这就是 6.9 节要讲的 reward ablation 方法论。

反事实推理:如果坚持只使用 potential-based shaping 会怎样?我们只能用势函数来加速学习(如 \(\Phi(s) = -\|v_{\text{actual}} - v_{\text{cmd}}\|\) 让策略更快靠近命令速度),但无法表达"动作平滑"、"脚步规律"、"姿态直立"等约束——这些都不是状态的函数,而是涉及动作或历史的函数。结果是策略会找到速度追踪精确但行为完全不可部署的解。

dense vs sparse reward 的工程权衡 ⭐⭐

在 locomotion 中,reward 通常是 dense 的——每一步都有非零 reward。但在某些任务(如"到达目标点"或"成功抓取")中,reward 可能是 sparse 的——只有在成功时才有正 reward。sparse reward 的问题在于 PPO 的 GAE 需要短 rollout 中的 reward 信号来估计 advantage。如果 rollout 长度是 24 步但成功需要 500 步,大部分 rollout 中 reward 全为零——advantage 全为零——策略没有学习信号。

解决 sparse reward 的标准方法就是 reward shaping:把最终目标拆解成中间步骤的 dense 信号。Ch17(机械臂与灵巧手操作)将详细讨论 staged reward 的门控机制——一种把 sparse 任务变成 dense 信号的系统方法。

⚠️ 常见陷阱

⚠️ 编程陷阱:reward term 中使用了部署不可得的信号。 例如 reward 计算足端接触力时直接读取仿真器的 contact_force 真值——这在训练中可以正常工作(reward 不需要部署),但如果你后来想在 actor observation 中复用这个信号作为"可学习的 reward 指示器",就会引入部署不可得的 privileged 信号。正确做法:reward 可以使用任意 env state 信号,但要在设计时标注哪些信号只在 reward 中使用(不进入 actor obs)。

💡 概念误区:认为 reward shaping 就是"加 reward term 直到行为看起来对"。 这种试错法在简单任务中可以工作,但在 20+ reward terms 的 locomotion 任务中会导致"改一个 weight 全盘崩溃"。正确方法是分层设计(6.2 节)+ ablation 验证(6.9 节)。

🧠 思维陷阱:认为 potential-based shaping 定理意味着"安全的 reward 才是好的 reward"。 实际上,几乎所有有用的 reward term 都不是 potential-based 的——这没有关系。定理的工程价值在于提供一个"理论安全"的子类作为参考,而不是作为设计约束。

练习

  1. [推导题] 证明 potential-based shaping 的 telescoping 性质:给定势函数 \(\Phi(s)\),推导无限时域折扣回报中 shaping reward 的累积和为 \(-\Phi(s_0) + \lim_{T\to\infty}\gamma^{T+1}\Phi(s_{T+1})\),解释为什么当 \(\gamma < 1\)\(\Phi\) 有界时该和与策略无关。
  2. [思考题] action_rate_l2 惩罚 \(\|a_t - a_{t-1}\|^2\)。它是 potential-based 的吗?如果不是,它改变了最优策略的哪个方面?这种改变在工程上是否是期望的?
  3. [编程题] 用 Python 实现一个 potential-based shaping reward:定义 \(\Phi(s) = -\|v_{\text{actual}} - v_{\text{cmd}}\|\),计算 \(F = \gamma\Phi(s') - \Phi(s)\),验证多步累积后总和趋向常数。

上节建立了 reward shaping 的理论基础。但理论只告诉我们"什么不会出错"——在工程中,我们需要一个可操作的框架来组织 20+ 个 reward terms。这就是四层 reward 分解要解决的问题。


6.2 四足 Locomotion 的四层 Reward 分解 ⭐⭐⭐

这一节解决什么问题:四足速度跟踪任务的 reward 应该怎样分层组织?每层解决什么问题?层与层之间如何冲突和平衡?

分层框架总览 ⭐⭐

四足 locomotion 不是单目标优化。它是带接触、带机械限制、带风格偏好的多目标优化。Tracking 想让机器人跟命令走,regularization 想让动作温和省力,style 想让姿态和步态看起来自然,contact 想让脚步合理地与地面交互。这四个目标彼此冲突:更大的 tracking 权重会鼓励更激进的身体摆动;更大的 action penalty 会让策略保守到跟不上速度命令;更大的 foot clearance penalty 可能让脚贴近目标高度而牺牲步幅。Reward shaping 的第一原则是承认冲突的存在,不要假装一个权重可以同时修复所有问题。

层级 核心问题 典型 term 权重方向 物理意义
Tracking 是否完成任务 线速度跟踪、角速度跟踪 正(reward) 策略存在的意义
Regularization 是否平滑省力 action rate、关节限制、能耗、关节加速度 负(penalty) 排除不可部署的解
Style 是否像合理步态 upright、posture、步态对称性、body angular velocity 正或负 约束解空间到可接受行为
Contact 脚是否正确交互 air time、slip、landing force、undesired contact 正或负 约束物理交互的合理性

这个四层分解不是 mjlab 或 Isaac Lab 的强制 API——它是从 legged_gym(Rudin et al. 2021)、walk-these-ways(Margolis & Agrawal, RSS 2023)、extreme-parkour(Cheng et al., ICRA 2024)、TienKung-Lab 等多个前沿项目中总结出的事实性共识

四层之间的冲突关系 ⭐⭐⭐

理解层间冲突是 reward 调参的基础。以下是最常见的三组冲突:

冲突一:Tracking vs Regularization。 Tracking 鼓励策略产生大幅度的关节运动来跟踪高速命令,Regularization 的 action rate penalty 惩罚关节运动的变化率。如果 tracking 权重远大于 action rate 权重,策略会用高频抖动来"暴力追踪"速度命令——reward 很高但行为完全不可部署(电机会烧毁)。如果 action rate 权重过大,策略会选择"不动"来最小化动作变化——tracking reward 接近零但 penalty 也接近零,总 reward 可能还不错。

诊断方法:同时观察 TensorBoard 中的 Episode_Reward/track_linear_velocityEpisode_Reward/action_rate_l2。如果 tracking 上升的同时 action_rate 的绝对值也在大幅上升,说明策略在用动作代价换取 tracking——这通常是不健康的。

冲突二:Style vs Tracking。 站立姿态的 style reward(如 upright_posture 惩罚身体倾斜)与高速运动时的自然倾斜冲突。四足动物在高速奔跑时身体会自然前倾来平衡惯性——如果 style reward 强制身体保持竖直,策略在高速命令下无法自然奔跑。

解决方案:使用速度依赖的 style 参数。TienKung-Lab 和 walk-these-ways 都采用了这种模式——站立时(命令接近零)使用紧 std 的 posture reward,行走时放宽 std,奔跑时进一步放宽或关闭。这等价于告诉策略"站着要站直,走快可以倾斜"。

冲突三:Contact vs 快速学习。 严格的 foot slip penalty 和 landing force penalty 会让早期策略(还不会正确步态)大量被罚,导致 reward 信号几乎全是负的——策略无法从中学到"怎样移动才不滑"。如果在训练初期就施加严格的 contact 约束,策略可能学到"完全不动"来避免所有 contact penalty。

解决方案:使用 reward curriculum(6.7 节)——训练初期用宽松的 contact penalty(或不用),等策略学会基本步态后逐步收紧。这与人类学习游泳的过程类似:先不纠正动作细节(否则什么都学不会),等能浮起来再优化姿势。

双框架代码组织 ⭐⭐

reward 的代码在两个框架中分布如下:

# ============= mjlab =============
src/mjlab/managers/reward_manager.py     # RewardManager 基类
src/mjlab/envs/mdp/rewards.py            # 通用 reward 函数
src/mjlab/tasks/velocity/mdp/rewards.py  # velocity 任务特定 reward
src/mjlab/tasks/velocity/velocity_env_cfg.py  # reward 配置(weights, params)

# ============= Isaac Lab =============
source/isaaclab/managers/reward_manager.py    # RewardManager 基类
source/isaaclab/envs/mdp/rewards.py           # 通用 reward 函数
source/isaaclab_tasks/.../velocity/mdp/rewards.py  # velocity 任务特定 reward
source/isaaclab_tasks/.../velocity/velocity_env_cfg.py  # reward 配置

两个框架的 RewardManager 语义完全一致:初始化时 deepcopy 配置(运行时 curriculum 修改内部副本,不污染原始 dataclass),每步 compute(dt) 时清零 _reward_buf,逐 term 调用函数,检查 shape 为 [num_envs],乘权重和 scale(dt 或 1.0),用 torch.nan_to_num 清理 NaN/Inf,累加到 _reward_buf_episode_sums。Reset 时生成 Episode_Reward/<term> 日志。

配置模式的差异与 Ch05 中 observation 的差异完全对应——mjlab 使用 dict 配置,Isaac Lab 使用 class attribute:

# ============= mjlab 配置 =============
rewards = {
    "track_linear_velocity": RewardTermCfg(
        func=track_linear_velocity,
        weight=2.0,
        params={"std": 0.5},
    ),
    "action_rate_l2": RewardTermCfg(
        func=action_rate_l2,
        weight=-0.01,
    ),
    # ... 更多 terms
}
# ============= Isaac Lab 配置 =============
@configclass
class RewardsCfg:
    track_lin_vel_xy_exp = RewTerm(
        func=mdp.track_lin_vel_xy_exp,
        weight=1.5,
        params={"command_name": "base_velocity", "std": math.sqrt(0.25)},
    )
    action_rate_l2 = RewTerm(
        func=mdp.action_rate_l2,
        weight=-0.01,
    )
    # ... 更多 terms

注意两个框架的 reward weight 数值可能不同——这不是 bug,而是因为不同框架的默认任务配置(机器人型号、decimation、PD gains)不同,导致最优 weight 也不同。跨框架迁移 reward 时,不要直接复制 weight,而应该按 6.9 节的 ablation 方法重新调优。

完整 velocity reward 配置表 ⭐⭐⭐

以下表格列出了 Go1 velocity rough 任务在 mjlab 中的全部 reward terms(Isaac Lab 的等价配置在 term 名称上可能略有不同,但物理语义完全对应):

term 名称 层级 weight 核心函数 物理意义
track_linear_velocity Tracking +2.0 \(\exp(-e_{\text{lin}}/\sigma^2)\) 平面速度跟踪
track_angular_velocity Tracking +1.0 \(\exp(-e_{\text{ang}}^2/\sigma^2)\) yaw 角速度跟踪
action_rate_l2 Regularization -0.01 \(-\|a_t - a_{t-1}\|^2\) 动作平滑性
dof_acceleration Regularization -2.5e-7 \(-\|\ddot{q}\|^2\) 关节加速度限制
dof_torque Regularization -1.0e-5 \(-\|\tau\|^2\) 力矩/能耗限制
dof_pos_limits Regularization -5.0 soft 限位惩罚 关节不超限
base_height Style -10.0 \(-(h - h_{\text{target}})^2\) 保持目标高度
flat_orientation Style -2.0 \(-(g_x^2 + g_y^2)\) 保持直立
body_lin_vel_z Style -1.0 \(-v_z^2\) 减少上下弹跳
body_ang_vel_xy Style -0.05 \(-(\omega_x^2 + \omega_y^2)\) 减少翻滚/俯仰
feet_air_time Contact +1.0 bonus per foot per swing 鼓励抬脚
foot_slip Contact -0.1 \(-v_{\text{foot,xy}}^2 \cdot \text{contact}\) 减少支撑脚滑移
undesired_contact Contact -1.0 非法 body 接触 不允许膝盖/大腿碰地

阅读这张表的方法:先看 Tracking 行——这是策略存在的意义。再看 Regularization 行——这是排除不可部署解的约束。然后看 Style 行——这是行为质量的调节器。最后看 Contact 行——这是物理交互的安全网。

一个关键观察:所有 Regularization 和多数 Contact term 的 weight 都是负数——它们是 penalty,扣减总 reward。这意味着如果只看 total reward 曲线,你可能看到"reward 在上升"但实际是因为 penalty 在下降(策略学会了"不犯错"),tracking reward 未必在上升(策略未必在"做对事")。这就是为什么必须看分项 reward——只看 total 曲线是 reward hacking 的温床。

⚠️ 常见陷阱

⚠️ 编程陷阱:添加 reward term 后忘了检查 weight 的符号。 reward(奖励)应该是正 weight,penalty(惩罚)应该是负 weight。如果把 action_rate_l2 的 weight 设成正数,策略会学到"尽可能大幅度抖动"来最大化这个 term。这个 bug 不会在代码层面报错,只会在训练 500 iteration 后以"机器人疯狂抖动"的形式暴露。

💡 概念误区:认为 weight 越大 term 越重要。 weight 的"重要性"取决于 weight × raw_value 的乘积。如果 raw_value 的范围是 \([0, 1]\)(如指数核 tracking),weight=2.0 对应的贡献约 2.0。如果 raw_value 的范围是 \([0, 100]\)(如未归一化的力矩),weight=0.01 对应的贡献也约 1.0。比较 weight 前必须了解 raw_value 的范围。

🧠 思维陷阱:认为"四层分解"是唯一正确的组织方式。 四层分解是当前 locomotion 社区的事实共识,但不是数学定理。对于 manipulation 任务,更合适的分层可能是 reaching / grasping / transporting / placing(见 Ch17)。分层的价值在于提供组织框架和调参入口,不在于分类的绝对正确性。

练习

  1. [手推题] 给定命令 \(v_{\text{cmd}} = (1.0, 0.0)\) m/s,实际速度 \(v = (0.5, 0.2, 0.1)\) m/s,\(\sigma = 0.5\)。计算 tracking 误差和 reward。解释 \(v_z = 0.1\) 对 reward 的贡献。
  2. [设计题] 为机械臂抓取任务设计四层 reward。Tracking 对应什么?Regularization 对应什么?Contact 对应什么?是否需要 Style?
  3. [编程题] 用 Python 绘制指数核 \(r = \exp(-e^2/\sigma^2)\) 和负二次 \(r = -e^2\)\(e \in [0, 5]\) 上的曲线和梯度曲线,解释两者在 \(e = 4\) 附近的梯度差异对学习的影响。
# 练习 3 的起始代码
import numpy as np
import matplotlib.pyplot as plt

e = np.linspace(0, 5, 200)
sigma = 0.5

# 指数核
r_exp = np.exp(-e**2 / sigma**2)
grad_exp = -2 * e / sigma**2 * r_exp  # dr/de

# 负二次
r_quad = -e**2
grad_quad = -2 * e  # dr/de

fig, axes = plt.subplots(1, 2, figsize=(12, 5))
axes[0].plot(e, r_exp, label=f"exp(-e²/σ²), σ={sigma}")
axes[0].plot(e, r_quad, label="-e²")
axes[0].set_title("Reward value")
axes[0].legend()

axes[1].plot(e, grad_exp, label="grad exp")
axes[1].plot(e, grad_quad, label="grad -e²")
axes[1].set_title("Gradient (dr/de)")
axes[1].legend()
plt.tight_layout()
plt.savefig("reward_kernel_comparison.png", dpi=150)

上节给出了四层分解的总览。现在我们深入最重要的一层——Tracking reward 的参数设计。这一层的 \(\sigma\) 参数选择直接决定了策略学习的"视野"和"精度"。


6.3 跟踪奖励参数详解与梯度分析 ⭐⭐⭐

这一节解决什么问题:指数核 reward 的 \(\sigma\) 怎么选?不同 \(\sigma\) 对学习有什么影响?如何理解指数核在大误差和小误差区域的梯度行为?

指数核 tracking reward 的数学分析 ⭐⭐⭐

velocity task 的主 tracking reward 使用指数核:

\[r_{\text{lin}} = \exp\left(-\frac{e_{\text{lin}}}{\sigma^2}\right), \quad e_{\text{lin}} = \|v_{\text{cmd,xy}} - v_{\text{actual,xy}}\|^2 + v_{\text{actual,z}}^2\]

这里 \(e_{\text{lin}}\) 是速度误差的平方和(注意不是欧氏距离,而是平方距离——没有取根号)。\(\sigma^2\) 控制指数核的宽度。

\(\sigma\) 的物理含义。当 \(e_{\text{lin}} = \sigma^2\) 时,\(r = \exp(-1) \approx 0.368\)。这意味着 \(\sigma\) 大致对应"reward 下降到 1/3 时的速度误差幅度"。对于线速度跟踪,如果 \(\sigma = 0.5\)(即 \(\sigma^2 = 0.25\)),误差 \(e_{\text{lin}} = 0.25\) 对应的速度误差约为 \(\|v_{\text{error}}\| \approx 0.5\) m/s——此时 reward 已经下降到约 1/3。

\(\sigma\) 选择的经验法则\(\sigma\) 应约等于"期望可接受误差的 1-2 倍"。

\(\sigma\) 对应 \(\sigma^2\) \(e \approx \sigma\) 时的 reward 训练效果
0.25 0.0625 0.368 非常严格——误差 0.25 m/s 时 reward 就很低
0.5 0.25 0.368 适中——legged_gym 默认值附近
1.0 1.0 0.368 宽容——适合训练初期或高速命令

\(\sigma\) 太小的问题:如果 \(\sigma = 0.1\),那么速度误差只要超过 0.1 m/s,reward 就接近零。在训练初期,策略完全不会走路,速度误差可能是 1-2 m/s,此时 reward 永远约等于零(\(\exp(-4/0.01) \approx 0\))——策略没有任何梯度信号来指引"往哪个方向改进"。这和高中物理中的"场强"概念类比:如果势能只在极小范围内有意义的变化,位于远处的物体感受不到"力"。

\(\sigma\) 太大的问题:如果 \(\sigma = 5.0\),速度误差 2 m/s 时 reward 仍然约 \(\exp(-4/25) \approx 0.85\)——策略会认为"大误差也还行",精度无法提升。

反事实推理:如果不用指数核而直接用 \(r = -e_{\text{lin}}\)?在大误差区域(如 \(e = 4\)),\(r = -4\) 产生很大的负梯度 \(\partial r/\partial e = -1\)(恒定大梯度),策略会把大部分优化预算花在"减少大误差"上——这听起来合理,但问题是大误差时策略可能还没学会稳定站立,此时过大的 tracking 梯度会鼓励"用任何手段追速度",包括不稳定的行为。指数核在大误差时梯度趋于零(\(\partial r/\partial e \to 0\)),相当于说"误差太大时不急着追——先把其他 penalty 降下来"。这种"大误差不管、小误差精调"的特性正是指数核的核心工程价值。

双框架中的 tracking reward 实现 ⭐⭐

# ============= mjlab 实现 =============
# src/mjlab/tasks/velocity/mdp/rewards.py
def track_linear_velocity(
    env: ManagerBasedRLEnv,
    std: float,
    command_name: str = "base_velocity",
) -> torch.Tensor:
    """Track linear velocity command (xy + penalize z)."""
    command = env.command_manager.get_command(command_name)
    root_vel = env.scene.robot.data.root_lin_vel_b  # body frame [N, 3]
    # 误差 = xy 跟踪误差² + z 速度²
    error = torch.sum(
        torch.square(command[:, :2] - root_vel[:, :2]), dim=1
    ) + torch.square(root_vel[:, 2])
    return torch.exp(-error / std**2)  # [num_envs]
# ============= Isaac Lab 实现 =============
# source/isaaclab/envs/mdp/rewards.py
def track_lin_vel_xy_exp(
    env: ManagerBasedRLEnv,
    std: float,
    command_name: str,
    asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> torch.Tensor:
    """Track linear velocity in xy plane with exponential kernel."""
    asset = env.scene[asset_cfg.name]
    lin_vel_error = torch.sum(
        torch.square(
            env.command_manager.get_command(command_name)[:, :2]
            - asset.data.root_lin_vel_b[:, :2]
        ),
        dim=1,
    )
    return torch.exp(-lin_vel_error / std**2)

关键差异:(1) Isaac Lab 版本默认不包含 \(v_z^2\) 惩罚——\(v_z\) 的惩罚通常通过单独的 lin_vel_z_l2 term 实现;(2) Isaac Lab 通过 asset_cfg 参数化 robot 访问(更灵活但更冗长),mjlab 直接用 env.scene.robot

\(v_z\) 惩罚的放置位置。mjlab 把 \(v_z^2\) 放在 tracking 误差内部——这意味着 \(v_z\) 越大,tracking reward 越低,策略会同时追求水平速度准确和垂直弹跳小。Isaac Lab 把 \(v_z^2\) 放在单独的 penalty term 中——这意味着 \(v_z\) 惩罚有独立的 weight,可以单独调节。两种方式在效果上等价,但 Isaac Lab 的方式给了更多调参自由度。

角速度跟踪的对称设计 ⭐⭐

角速度跟踪使用完全对称的指数核:

\[r_{\text{ang}} = \exp\left(-\frac{(\omega_{\text{cmd,z}} - \omega_{\text{actual,z}})^2}{\sigma_{\text{ang}}^2}\right)\]

只跟踪 yaw 角速度 \(\omega_z\)(转弯命令),不跟踪 roll/pitch 角速度——roll/pitch 的期望值永远是零(保持直立),通过 Style 层的 body_ang_vel_xy penalty 实现。

为什么把 yaw tracking 和 roll/pitch penalty 分开? 因为 yaw 有非零命令("请转弯"),而 roll/pitch 的目标永远是零。如果把三个角速度分量都放在一个 tracking term 中,\(\sigma\) 的选择会很尴尬——yaw 命令可能很大(\(\pm 1\) rad/s),roll/pitch 目标永远是零但波动很小(\(\pm 0.1\) rad/s)。分开处理让每个分量都能有合适的 \(\sigma\) 或 weight。

Curriculum 控制 \(\sigma\) 的高级用法 ⭐⭐

训练初期用大 \(\sigma\)(宽容,有梯度信号),训练后期收紧 \(\sigma\)(提高精度)——这就是 reward curriculum 的一种典型应用。

# mjlab curriculum 示例:逐步收紧 tracking std
curriculum = {
    "track_lin_tighten": CurriculumTermCfg(
        func=mdp.reward_curriculum,
        params={
            "reward_name": "track_linear_velocity",
            "stages": [
                {"step": 0, "params": {"std": 0.7}},         # 早期宽容
                {"step": 120000, "params": {"std": 0.5}},     # 中期适中
                {"step": 240000, "params": {"std": 0.35}},    # 后期苛刻
            ],
        },
    ),
}

这个 curriculum 在 step 0 时设 \(\sigma = 0.7\)(大误差也有梯度),step 120000 时收紧到 \(\sigma = 0.5\),step 240000 时进一步收紧到 \(\sigma = 0.35\)。stage 间隔至少几万 step(对应几百个 PPO iteration),确保策略有足够时间适应当前难度。

Isaac Lab 中等价的 curriculum 通过 CurriculumManager 实现,API 语义相同但配置语法不同——使用 @configclass 定义 curriculum terms。

⚠️ 常见陷阱

⚠️ 编程陷阱:把 reward std 和 PPO policy 的 init_std 搞混。 reward kernel 的 std 是物理空间宽度(单位 m/s 或 rad/s),policy 的 init_std 是 raw action 空间的 Gaussian 标准差(无量纲,见 Ch05)。两者概念完全无关,但名字相似容易混淆。正确做法:在代码和文档中始终标明 std 的物理含义和单位。

🧠 思维陷阱:认为"\(\sigma\) 越小 reward 越精确所以越好"。 \(\sigma\) 太小会导致早期策略完全没有 tracking 梯度,可能学不会走路。正确思路是从大 \(\sigma\) 开始,通过 curriculum 逐步收紧。

练习

  1. [计算题] \(\sigma^2 = 0.25\)(即 \(\sigma \approx 0.5\)),速度误差 \(e_{\text{lin}} = 1.0\)(命令 1.5 m/s,实际 0.5 m/s)。计算 tracking reward。如果改为 \(\sigma^2 = 1.0\),reward 变为多少?解释两者对梯度的影响。
  2. [设计题] 对一个高速奔跑任务(命令 \(v_x\) 最大 3.0 m/s),早期策略的速度误差可能达到 3.0 m/s。选择合适的初始 \(\sigma\) 和 curriculum schedule,确保训练全程都有 tracking 梯度信号。
  3. [跨框架对比题] 在 mjlab 和 Isaac Lab 中分别找到 tracking reward 的实现。对比 \(v_z\) 惩罚的处理方式(内嵌 vs 独立 term)。讨论两种方式对 ablation 实验的影响。

tracking reward 定义了"做什么",但策略还需要知道"不能做什么"和"怎样做才优雅"。这正是 Regularization、Style 和 Contact 三层要解决的问题。


6.4 Regularization、Style 与 Contact Reward 详解 ⭐⭐

这一节解决什么问题:除了 tracking 之外的三层 reward 分别包含哪些 term?每个 term 的物理含义和调参方向是什么?

Regularization 层——排除不可部署的解 ⭐⭐

Regularization 层的 terms 都是 penalty(负 weight),目标是排除那些在仿真中可以工作但在真实机器人上会失败的行为。

action_rate_l2:惩罚相邻 env step 的 raw action 差分 \(\|a_t - a_{t-1}\|^2\)。这等价于对动作施加一个低通滤波器——高频抖动被惩罚。注意是 raw action(无量纲),不是 processed action(有物理单位)。

# mjlab 实现
def action_rate_l2(env: ManagerBasedRLEnv) -> torch.Tensor:
    """Penalize changes in actions between consecutive steps."""
    return torch.sum(
        torch.square(env.action_manager.action - env.action_manager.prev_action),
        dim=1,
    )

Isaac Lab 的实现几乎完全相同——mdp.action_rate_l2env.action_manager 读取当前和上一步 action。

dof_acceleration:惩罚关节加速度 \(\|\ddot{q}\|^2\)。关节加速度越大,电机需要的力矩越大,磨损越快。这个 term 的 weight 通常非常小(如 \(-2.5 \times 10^{-7}\)),因为 \(\ddot{q}\) 的量级可能很大(几千 rad/s²)。

dof_torque:惩罚关节力矩 \(\|\tau\|^2\)。直接限制能耗——真实机器人电池有限,过大力矩会导致发热和降低运行时间。

dof_pos_limits:软限位惩罚。当关节位置接近物理限位时施加递增的 penalty。这比硬限位(直接 clip)更友好——硬限位在边界处会产生不连续的梯度。

调参方向总结

现象 首先怀疑 调整方向
高频抖动 action_rate_l2 太弱 增大 |weight|
动作保守 action_rate_l2 + dof_torque 太强 减小 |weight|
关节撞限位 dof_pos_limits 太弱 增大 |weight|
训练初期 reward 全负 所有 penalty 太强 先减小 penalty,等策略有基本行为后再加回来

Style 层——约束行为质量 ⭐⭐

Style 层定义"什么样的行为看起来合理"。它与 Regularization 的区别是:Regularization 排除"硬件不允许"的行为,Style 排除"外观不可接受"的行为。

flat_orientation(或 base_orientation):惩罚 projected gravity 的 x/y 分量偏离零(即身体不直立)。\(r = -(g_x^2 + g_y^2)\),其中 \(g = R^T [0, 0, -1]\) 是世界重力在 body frame 中的投影。注意这个 term 与 Ch05 中 projected_gravity observation 使用完全相同的数据——reward 函数读取 env state 的方式和 observation term 一样。

# mjlab 实现
def flat_orientation(env: ManagerBasedRLEnv, asset_cfg) -> torch.Tensor:
    """Penalize non-flat base orientation using projected gravity."""
    gravity = env.scene[asset_cfg.name].data.projected_gravity_b
    return torch.sum(torch.square(gravity[:, :2]), dim=1)

body_lin_vel_z:惩罚垂直速度 \(v_z^2\)。四足行走不应该有大幅上下弹跳——这与 tracking reward 中可能内嵌的 \(v_z\) 惩罚功能重叠。如果 tracking 已经包含 \(v_z\) 惩罚(mjlab 风格),这个单独的 term 可以不用或减小 weight。如果 tracking 不包含 \(v_z\)(Isaac Lab 风格),则需要这个 term。

body_ang_vel_xy:惩罚 roll/pitch 角速度。与 flat_orientation 互补——前者惩罚"倾斜",后者惩罚"快速倾斜"。类似 PD 控制的 P 项和 D 项。

速度依赖的 posture reward(进阶)。walk-these-ways 和 TienKung-Lab 使用了一种更精细的设计:定义 standing std 和 walking std,根据命令速度在两者之间插值。

# 伪代码:速度依赖的 posture reward
speed = torch.norm(command[:, :2], dim=1)
standing_mask = speed < 0.1
walking_mask = ~standing_mask

# 站立时:紧 std(必须站直)
posture_tight = exp_kernel(orientation_error, std=0.1)
# 行走时:松 std(允许倾斜)
posture_loose = exp_kernel(orientation_error, std=0.5)

reward = torch.where(standing_mask, posture_tight, posture_loose)

Contact 层——约束物理交互 ⭐⭐

Contact 层控制脚与地面的交互质量。

feet_air_time(foot air time bonus):鼓励脚在摆动相抬离地面足够时间。如果没有这个 term,策略可能学到"脚贴着地面快速拖动"来跟踪速度——仿真中接触摩擦可以实现这种"溜冰"行为,但真实机器人会磨损脚底。

# mjlab 实现(简化)
def feet_air_time(env, sensor_cfg, command_name, threshold) -> torch.Tensor:
    """Reward for maintaining foot air time above threshold."""
    contact = env.scene.sensors[sensor_cfg.name].data.net_forces_w_history
    # 检测脚是否离地(contact force < threshold)
    in_air = contact[:, :, 2].abs() < threshold  # [N, num_feet]
    # 计算连续离地时间
    air_time = env.scene.sensors[sensor_cfg.name].data.current_air_time
    # 只在落地瞬间给 bonus(episode 结尾不给)
    first_contact = (air_time > 0) & (~in_air)
    reward = torch.sum((air_time - 0.5) * first_contact, dim=1)
    # 命令接近零时不鼓励抬脚(站立不需要走路动作)
    reward *= torch.norm(command[:, :2], dim=1) > 0.1
    return reward

这个 term 的设计有三个精妙之处:(1) 只在落地瞬间给 reward(不是连续给);(2) 减去 0.5 秒的 threshold——空中时间短于 0.5 秒会得到负贡献(惩罚拖步),长于 0.5 秒才得到正奖励(注意按此公式 reward 随 air time 线性增长,并未对过长的腾空设上限,若需要上限应改用 clamp 或窗口形式);(3) 命令接近零时关闭——站立时不应该鼓励抬脚。

foot_slip:惩罚支撑脚在接触地面时的水平滑动速度。\(r = -\|v_{\text{foot,xy}}\|^2 \cdot \mathbb{1}_{\text{contact}}\)。只有在足端正在接触地面时才计算——空中的脚自然没有"滑移"。

undesired_contact:惩罚不应该接触地面的 body 部位(如膝盖、大腿、躯干)。这是一个安全 penalty——真实机器人的这些部位碰到地面通常意味着摔倒或碰撞。

自定义 Reward Term 的编写模式 ⭐⭐⭐

编写自定义 reward term 的模式与 Ch05 中的 observation term 类似——返回 [num_envs] tensor,不修改 env state:

mjlab 完整示例——步态对称性 reward:

# src/mjlab/tasks/velocity/mdp/rewards.py
import torch
from mjlab.managers import RewardTermBase

class GaitSymmetryReward(RewardTermBase):
    """鼓励左右脚 air time 对称。

    计算左侧脚和右侧脚的平均 air time 差异,
    用指数核映射为 reward。air time 越对称,reward 越高。

    Returns:
        shape: [num_envs],range: [0, 1]
    """
    def compute(self, env, sensor_cfg, std: float = 0.3) -> torch.Tensor:
        sensor = env.scene.sensors[sensor_cfg.name]
        air_time = sensor.data.current_air_time  # [num_envs, num_feet]
        # Go1: feet 0,2 = left side, feet 1,3 = right side
        left_mean = air_time[:, [0, 2]].mean(dim=1)
        right_mean = air_time[:, [1, 3]].mean(dim=1)
        asymmetry = (left_mean - right_mean) ** 2
        return torch.exp(-asymmetry / std**2)

Isaac Lab 等价实现:

# source/isaaclab_tasks/.../velocity/mdp/rewards.py
def gait_symmetry(
    env: ManagerBasedRLEnv,
    sensor_cfg: SceneEntityCfg,
    std: float = 0.3,
) -> torch.Tensor:
    """Encourage symmetric air time between left and right feet."""
    sensor = env.scene.sensors[sensor_cfg.name]
    air_time = sensor.data.current_air_time
    left_mean = air_time[:, [0, 2]].mean(dim=1)
    right_mean = air_time[:, [1, 3]].mean(dim=1)
    return torch.exp(-((left_mean - right_mean) ** 2) / std**2)

在 cfg 中注册自定义 term:

# mjlab
rewards["gait_symmetry"] = RewardTermCfg(
    func=GaitSymmetryReward,
    weight=0.5,
    params={"sensor_cfg": SceneEntityCfg("contact_forces"), "std": 0.3},
)

# Isaac Lab
gait_sym = RewTerm(
    func=gait_symmetry,
    weight=0.5,
    params={"sensor_cfg": SceneEntityCfg("contact_forces"), "std": 0.3},
)

编写 reward term 的四条铁律

第一,返回 shape 必须是 [num_envs]。如果中间计算产生 [num_envs, num_feet],最后必须 reduce(如 torch.sum(..., dim=1))。

第二,reward term 必须是无副作用的纯函数——只读取状态,不修改数据。与 observation term 的要求完全相同。

第三,reward term 中的 NaN 会被 RewardManager 自动清零(torch.nan_to_num),但这掩盖了根因。如果你的 reward term 可能产生 NaN(如除以接触力可能为零),应显式处理。

第四,reward 的数值范围应尽量在 \([-10, 10]\) 以内。如果 raw value 范围很大(如力矩的平方可能达到几千),需要在 weight 中补偿,或在 term 内部做归一化。

Manipulation 的 Staged Reward——门控机制 ⭐⭐⭐

Locomotion 的四层 reward 是并行的——所有 term 同时激活。Manipulation 任务(如 cube lifting)则需要串行的 staged reward:先 reach(手靠近物体)→ 再 grasp(手接触物体)→ 最后 lift(搬运到目标)。如果并行给所有 reward,策略可能同时优化三个目标但没有一个做好。

mjlab 和 Isaac Lab 的 lift cube 任务都使用了乘法门控的 staged reward:

# mjlab 实现 (src/mjlab/tasks/manipulation/mdp/rewards.py)
def staged_position_reward(
    env: ManagerBasedRLEnv,
    command_name: str,
    object_name: str,
    reaching_std: float,
    bringing_std: float,
    asset_cfg: SceneEntityCfg,
) -> torch.Tensor:
    """Staged reward: reach → bring, with multiplicative gating."""
    robot = env.scene[asset_cfg.name]
    obj = env.scene[object_name]
    command = env.command_manager.get_command(command_name)

    # 末端执行器位置(grasp_site)
    ee_pos_w = robot.data.site_pos_w[:, asset_cfg.site_ids].squeeze(1)
    # 物体质心位置
    obj_pos_w = obj.data.root_link_pos_w

    # Stage 1: Reaching(手到物体的距离)
    reach_error = torch.sum(torch.square(ee_pos_w - obj_pos_w), dim=-1)
    reaching = torch.exp(-reach_error / reaching_std**2)

    # Stage 2: Bringing(物体到目标的距离)
    bring_error = torch.sum(torch.square(command.target_pos - obj_pos_w), dim=-1)
    bringing = torch.exp(-bring_error / bringing_std**2)

    # 门控:只有 reaching 好了,bringing 的梯度才有效
    return reaching + reaching * bringing  # ← 乘法门控

门控的数学意义:总 reward = reaching + reaching × bringing。

场景 reaching bringing total 梯度方向
末端远离方块 ≈ 0 ≈ 0 ≈ 0 减小手到物体距离
末端靠近方块 ≈ 1 ≈ 0 ≈ 1 减小物体到目标距离
方块接近目标 ≈ 1 ≈ 1 ≈ 2 维持两者

关键在第二行:当末端已靠近方块时(reaching ≈ 1),总 reward 约为 1 + bringing。此时 bringing 的梯度被完整传递(乘法系数约为 1),策略有动力搬运物体。而在第一行,即使 bringing 有微弱梯度,它被接近零的 reaching 乘法抑制——策略只关注接近。

本质洞察:staged reward 不是简单的奖励堆叠。它是在 MDP 中声明学习顺序:先让末端进入可抓窗口,再让物体进入目标窗口。乘法门控在数学上保证了这种顺序——如果 reaching 没有学好(reaching ≈ 0),bringing 的梯度被自然抑制。

TensorBoard 日志解读指南 ⭐⭐⭐

TensorBoard 是诊断 reward 设计问题的核心工具。以下是逐字段的解读方法:

# TensorBoard 中的关键日志字段(mjlab 和 Isaac Lab 通用)

Loss/mean_reward           # PPO 看到的总 reward(含 dt 缩放)
Loss/mean_value_loss       # Critic loss——是否在准确估计 value
Loss/mean_surrogate_loss   # Actor loss——策略是否在改进

Episode_Reward/track_linear_velocity    # tracking reward rate
Episode_Reward/track_angular_velocity   # yaw tracking
Episode_Reward/action_rate_l2           # 负值,绝对值越小动作越平滑
Episode_Reward/dof_pos_limits           # 关节限位惩罚
Episode_Reward/feet_air_time            # 步态奖励
Episode_Reward/foot_slip                # 负值,绝对值越小滑移越少
Episode_Reward/undesired_contact        # 负值,绝对值越小碰撞越少

Episode_Termination/time_out            # timeout 次数
Episode_Termination/fell_over           # 摔倒次数
Episode_Termination/illegal_contact     # 非法接触次数

Curriculum/terrain_levels/mean          # 当前平均地形难度
Curriculum/commands_vel/stage           # 当前命令范围 stage

解读 workflow(按优先级):

Step 1:看 Episode_Termination/*。如果 fell_over 占主导(>50%),策略还不稳定——先解决稳定性再调 tracking。如果 time_out 占主导(>80%),说明策略能存活到 episode 结束——可以开始关注 tracking 质量。

Step 2:看 Episode_Reward/track_linear_velocity。这个值应该在训练过程中持续上升。如果 500 iteration 后仍然很低(<0.3),检查 \(\sigma\)、命令范围或 action scale。

Step 3:看 penalty 项。action_rate_l2 的绝对值应该随训练下降(动作越来越平滑)。如果下降后回升,可能策略在尝试更激进的行为。

Step 4:如果以上都正常但 play video 显示异常行为,就是 reward hacking——需要 ablation 实验(6.9 节)。

诊断辅助脚本——自动化 reward 健康检查:

# reward_health_check.py
import torch
import os

def check_reward_health(env, num_steps=100):
    """运行 random agent 检查 reward 数值范围是否合理。"""
    report = []
    rm = env.reward_manager

    # 收集 N 步的 reward 统计
    term_stats = {name: [] for name in rm.active_terms}

    obs = env.reset()
    for _ in range(num_steps):
        actions = torch.randn(
            env.num_envs, env.action_manager.total_action_dim,
            device=env.device
        )
        obs, rewards, _, _, _ = env.step(actions)

        # 记录每个 term 的 raw value(不含 weight 和 dt)
        for name in rm.active_terms:
            step_reward = rm._step_reward.get(name)
            if step_reward is not None:
                term_stats[name].append(step_reward.mean().item())

    report.append("Reward Health Check Report")
    report.append("=" * 50)

    for name, values in term_stats.items():
        if not values:
            report.append(f"  {name}: NO DATA (possible registration error)")
            continue
        mean_val = sum(values) / len(values)
        cfg = rm.active_terms[name]
        weighted = mean_val  # already includes weight
        report.append(
            f"  {name}: raw_mean={mean_val:+.4f}, "
            f"weight={cfg.weight:+.4f}"
        )
        # 警告检查
        if abs(mean_val) < 1e-8:
            report.append(f"    ⚠️ Near-zero raw value — term may be inactive")
        if abs(weighted) > 10:
            report.append(f"    ⚠️ Large weighted value — may dominate gradient")

    report_text = "\n".join(report)
    print(report_text)
    return report_text

# 使用:训练前运行
# check_reward_health(env, num_steps=50)

双框架对比脚本——验证 reward 语义一致性:

# compare_rewards.py
def compare_reward_configs(mjlab_env, isaaclab_env):
    """对比两个框架的 reward term 列表和 weight。"""
    mj_rm = mjlab_env.reward_manager
    il_rm = isaaclab_env.reward_manager

    print(f"mjlab terms: {len(mj_rm.active_terms)}")
    print(f"Isaac Lab terms: {len(il_rm.active_terms)}")

    # 逐 term 对比(按物理语义匹配,不按名字)
    mj_names = set(mj_rm.active_terms.keys())
    il_names = set(il_rm.active_terms.keys())

    common = mj_names & il_names
    mj_only = mj_names - il_names
    il_only = il_names - mj_names

    if common:
        print(f"\nCommon terms ({len(common)}):")
        for name in sorted(common):
            mj_w = mj_rm.active_terms[name].weight
            il_w = il_rm.active_terms[name].weight
            match = "✅" if abs(mj_w - il_w) < 0.01 else "⚠️"
            print(f"  {match} {name}: mjlab={mj_w:+.4f}, IL={il_w:+.4f}")

    if mj_only:
        print(f"\nmjlab only: {sorted(mj_only)}")
    if il_only:
        print(f"\nIsaac Lab only: {sorted(il_only)}")

Reward Weight 调优的系统方法 ⭐⭐

reward weight 不是随意设置的——有一个系统化的方法可以从合理的初始值开始:

Step 1(量纲分析):计算每个 term 在 random agent 下的 raw value 平均值。用 check_reward_health() 脚本获取。

Step 2(贡献平衡):调整 weight 使得每层的加权贡献大致在同一数量级。例如:如果 tracking raw ≈ 0.5(range [0,1]),设 weight=2.0,贡献 ≈ 1.0;如果 action_rate raw ≈ 10.0(range [0, 100]),设 weight=-0.01,贡献 ≈ -0.1。tracking 贡献 > penalty 贡献——这是正确的,因为 tracking 应该是主导梯度。

Step 3(初始训练验证):训练 200 iteration,检查 TensorBoard。如果 tracking 在上升且 termination 主要是 timeout(不是 fell_over),说明初始 weight 大致合理。

Step 4(逐项微调):根据 play video 中的行为问题,一次只调一个 weight。记录每次改动和结果。

# Weight 调优日志模板
Experiment: Go1 Velocity Flat
Date: ____
Change: action_rate_l2 weight from -0.01 to -0.05
Expected: less action jitter
Result: jitter reduced, but tracking slightly worse at high speed
Decision: keep -0.03 as compromise

完整 Reward 配置的 Rough vs Flat 对比 ⭐⭐

以下是 Go1 velocity task 中 rough 和 flat 配置的完整 reward 差异:

# ============= rough 配置(基础) =============
rough_rewards = {
    # Tracking (正)
    "track_linear_velocity":  RewardTermCfg(func=..., weight=2.0,  params={"std": 0.5}),
    "track_angular_velocity": RewardTermCfg(func=..., weight=1.0,  params={"std": 0.5}),
    # Regularization (负)
    "action_rate_l2":         RewardTermCfg(func=..., weight=-0.01),
    "dof_acceleration":       RewardTermCfg(func=..., weight=-2.5e-7),
    "dof_torque":             RewardTermCfg(func=..., weight=-1.0e-5),
    "dof_pos_limits":         RewardTermCfg(func=..., weight=-5.0),
    # Style (混合)
    "base_height":            RewardTermCfg(func=..., weight=-10.0),
    "flat_orientation":       RewardTermCfg(func=..., weight=-2.0),
    "body_lin_vel_z":         RewardTermCfg(func=..., weight=-1.0),
    "body_ang_vel_xy":        RewardTermCfg(func=..., weight=-0.05),
    # Contact (混合)
    "feet_air_time":          RewardTermCfg(func=..., weight=1.0),
    "foot_slip":              RewardTermCfg(func=..., weight=-0.1),
    "undesired_contact":      RewardTermCfg(func=..., weight=-1.0),
}

# ============= flat 配置(从 rough 派生) =============
flat_rewards = {
    **rough_rewards,
    # flat 上不需要地形相关 reward,但基础 reward 保持不变
    # 可能调整的是 curriculum:flat 不需要 terrain curriculum
}

关键观察:rough 和 flat 的 reward term 列表通常相同——差异主要在 termination(flat 加 fell_over、rough 移除)和 curriculum(rough 有 terrain curriculum)。这是一个重要的设计决策:让 reward 函数在不同难度下保持一致,通过 termination 和 curriculum 控制难度。

⚠️ 常见陷阱

⚠️ 编程陷阱:feet_air_time 在站立命令时仍然激活。 如果不加"命令接近零时关闭"的保护,站立时策略会不断抬脚踏步来获取 air time bonus——看起来像"原地踏步"。检查方法:用零速度命令 play,观察脚是否在频繁抬起。

💡 概念误区:认为 penalty 越多行为越好。 每增加一个 penalty,就增加了一个可能与 tracking 冲突的梯度。20+ 个 penalty 同时作用可能导致梯度完全混乱——策略找不到"不犯任何错"的行为,转而学到"什么都不做"。推荐做法:先只用 tracking + 最基本的 regularization(action rate + joint limits),确认策略能走路后再逐步添加其他 penalty。

练习

  1. [分析题] 如果去掉 feet_air_time 但保留 foot_slip,策略会学到什么行为?(提示:考虑"脚贴地面滑动"是否被 foot_slip 完全惩罚。)
  2. [编程题] 写一个自定义 reward term,惩罚左右脚的 air time 差异(鼓励步态对称性)。在 mjlab 中用 class-based term 实现,在 Isaac Lab 中用 function-based term 实现。
  3. [跨章综合题] 回顾 Ch05 的 obs 设计。foot_contact_forces 在 actor obs 中吗?如果不在,foot_slip reward 使用了 actor obs 之外的信息——这对策略学习有什么影响?(提示:reward 可以使用任何 env state,即使 actor 看不到。策略通过试错隐式学到 reward 激励的行为模式。)

上节完成了四层 reward 的详细分析。但 reward 的数值含义不仅取决于 term 本身,还取决于时间缩放和日志系统的工程细节——搞不清这些,reward 日志就是一堆不可解释的数字。


6.5 Reward 的时间缩放与日志系统 ⭐⭐

这一节解决什么问题:改变仿真频率后 reward 的数量级为什么会变?日志中的数值是什么含义?

scale_by_dt 的物理意义 ⭐⭐

mjlab 和 Isaac Lab 的 RewardManager 默认 scale_by_dt=True。在 compute(dt) 中,每个 term 被乘以 weight * dt(其中 dt = physics_dt × decimation),这意味着返回给 PPO 的 reward 是按时间积分的量

这个设计解决了一个微妙但重要的问题:如果把仿真的 decimation 从 4 改为 2(policy step 频率加倍),不按 dt 缩放会导致同一秒内的累计 reward 翻倍——PPO 的 loss scale 随之变化,所有精心调好的超参数都需要重新调整。按 dt 缩放让 reward 权重更接近"物理时间权重",类似于物理学中"密度"比"总量"更具物理意义。

# RewardManager.compute() 核心逻辑(两个框架语义一致)
def compute(self, dt: float):
    self._reward_buf[:] = 0.0
    for name, term_cfg in self._terms.items():
        raw_value = term_cfg.func(self._env, **term_cfg.params)  # [num_envs]
        # NaN 清理
        raw_value = torch.nan_to_num(raw_value, nan=0.0, posinf=0.0, neginf=0.0)
        # 按 dt 缩放
        scale = dt if self.cfg.scale_by_dt else 1.0
        weighted = raw_value * term_cfg.weight * scale
        self._reward_buf += weighted
        # 累积到 episode sums(用于日志)
        self._episode_sums[name] += weighted

一个常见的数值混淆。假设 track_linear_velocity 的 raw value = 0.8,weight = 2.0,dt = 0.02s。那么每步贡献的 reward 是 \(0.8 \times 2.0 \times 0.02 = 0.032\)。一个 1000 步的 episode 中 tracking 的累积贡献约 32.0。如果改为 decimation=2(dt=0.01s),每步贡献变为 \(0.8 \times 2.0 \times 0.01 = 0.016\),但 episode 有 2000 步,累积仍约 32.0——这就是 dt 缩放的价值。

Episode Reward 日志的归一化 ⭐⭐

RewardManager.reset() 时,Episode_Reward/<name> 的值等于 episode sum 除以 max_episode_length_s(最大 episode 时长,而非实际 episode 时长)。这使不同长度 episode 的日志可以比较。但如果一个 episode 很短(早期训练时机器人很快摔倒),少量极端 step 的 reward 会被摊到最大 episode 秒数上,数值看起来比实际"稀释"了。因此termination count 必须和 Episode Reward 一起看

日志类别 字段示例 用途
episode reward rate Episode_Reward/foot_slip 判断每个 term 的梯度贡献
behavior metric Metrics/slip_velocity_mean 判断物理行为质量
curriculum state Curriculum/terrain_levels/mean 判断任务难度进展
termination count Episode_Termination/fell_over 判断失败原因分布

⚠️ 常见陷阱

💡 概念误区:认为 Episode_Reward 是 per-step 平均。 它是 episode sum(已按 dt 缩放)除以 max_episode_length_s。短 episode 的值会被系统性"稀释"。

练习

  1. [计算题] sim timestep = 0.005 s,decimation = 4,policy dt = 0.02 s,max episode = 20.0 s。一个 episode 持续 500 步后摔倒。track_linear_velocity 每步 raw value = 0.8,weight = 2.0。计算 Episode_Reward/track_linear_velocity 的日志值。
  2. [思考题]decimation 从 4 改为 8 时,scale_by_dt=True 下同一秒的 tracking reward 累积值怎样变化?gamma 的有效时间尺度怎样变化?

reward 定义了"什么是好的",但 episode 在什么时候结束同样重要——不同类型的"结束"对 PPO 的 value function 有本质不同的影响。


6.6 Termination 与 Value Bootstrap ⭐⭐⭐

这一节解决什么问题:episode 什么时候结束?不同类型的"结束"对 PPO 的 value function 有什么不同影响?

True Termination vs Truncation 的语义差异 ⭐⭐⭐

Termination 分两类。True termination(真失败)意味着 episode 到达了"没有未来"的状态——机器人摔倒了,继续运行没有意义,critic 应将未来价值估计为零。Truncation(截断)意味着 episode 因外部原因(时间到了、离开有效区域)而结束,但任务本身并没有失败——如果继续运行机器人可能还会获得正 reward,critic 不应将未来价值清零,而应用当前 value function 的估计进行 bootstrap

本质洞察:true termination vs truncation 的区别不是"日志细节"——它是 价值函数定义的一部分。错误标记等价于给 critic 错误的训练 target:把 timeout 当 failure 会系统性低估长期稳定行走的价值,把 failure 当 timeout 会高估摔倒状态的价值。这两种错误都会通过 GAE 反向传播到整个 rollout。

这和金融中的"折现"概念类似:一个盈利的企业如果在估值时把未来现金流截断为零(就像把 timeout 当 failure),估值会严重偏低,导致投资决策错误。Critic 的 value function 就是策略的"估值模型",termination 语义的错误等价于估值模型的系统性偏差。

双框架中的 Termination 实现 ⭐⭐

mjlab 用 TerminationTermCfg(time_out=True) 标记截断。TerminationManager 把 timeout term 聚合进 _truncated_buf,非 timeout term 聚合进 _terminated_bufRslRlVecEnvWrapper.step() 合并二者为 RSL-RL 需要的 dones(long tensor),同时在无限时域任务中把 truncated 放入 extras["time_outs"]

# ============= mjlab velocity task termination 配置 =============
terminations = {
    "time_out": TerminationTermCfg(
        func=mdp.time_out,
        time_out=True,  # 标记为 truncation,不是 true failure
    ),
    "fell_over": TerminationTermCfg(
        func=mdp.fell_over,
        time_out=False,  # 默认,true termination
        params={"asset_cfg": SceneEntityCfg("robot")},
    ),
    "illegal_contact": TerminationTermCfg(
        func=mdp.illegal_contact,
        params={"sensor_cfg": ..., "threshold": 1.0},
    ),
}
# ============= Isaac Lab 等价配置 =============
@configclass
class TerminationsCfg:
    time_out = DoneTerm(func=mdp.time_out, time_out=True)
    base_contact = DoneTerm(
        func=mdp.illegal_contact,
        params={"sensor_cfg": ..., "threshold": 1.0},
    )

RSL-RL wrapper 的关键转换(两个框架一致):

# RslRlVecEnvWrapper.step() 中的转换逻辑
dones = (terminated | truncated).to(torch.long)
# 对于无限时域任务:
if not self.unwrapped.cfg.is_finite_horizon:
    extras["time_outs"] = truncated.to(torch.long)
# RSL-RL 在 GAE 计算中使用 time_outs 决定是否 bootstrap

正确标记 time_out 的决策框架 ⭐⭐

条件 是否物理失败 建议 time_out 理由
固定 episode 长度到达 否(若无限时域) True rollout 边界,不应清零未来价值
离开生成地形边界 多数否 True 采样边界而非物理失败
平地上摔倒(大角度倾斜) False 真实失败
大腿/膝盖非法接触 False 真实失败
物理引擎 NaN False 不可恢复错误

Go1 Rough vs Flat 的 Termination 差异 ⭐⭐

Go1 rough config 移除了 fell_over:粗糙地形上四足机器人会明显倾斜,斜坡上的合理姿态可能触发 orientation-based termination,导致合理动作被误杀。Go1 flat 则恢复 fell_over:平地上大角度倾斜通常是真正的摔倒。这说明 termination 不是通用真理——它依赖 terrain 和任务边界

# Go1 rough 配置(移除 fell_over)
rough_terminations = {
    "time_out": TerminationTermCfg(func=mdp.time_out, time_out=True),
    "illegal_contact": TerminationTermCfg(func=mdp.illegal_contact, ...),
    # 注意:没有 fell_over!
}

# Go1 flat 配置(从 rough 派生后添加 fell_over)
flat_terminations = {
    **rough_terminations,
    "fell_over": TerminationTermCfg(func=mdp.fell_over, ...),
}

自定义 Termination Term 示例 ⭐⭐

有时你需要添加任务特定的 termination 条件。例如,对一个"沿直线走"的任务,如果机器人偏离目标路径超过 2 米,可以提前终止 episode:

# 自定义 termination term(mjlab)
def path_deviation(
    env: ManagerBasedRLEnv,
    max_deviation: float = 2.0,
    asset_cfg: SceneEntityCfg = SceneEntityCfg("robot"),
) -> torch.Tensor:
    """Terminate if robot deviates too far from the target path."""
    robot = env.scene[asset_cfg.name]
    # 假设目标路径沿 x 轴
    lateral_deviation = torch.abs(robot.data.root_pos_w[:, 1])  # y 偏移
    return lateral_deviation > max_deviation  # [num_envs] bool

# 在 cfg 中注册
terminations["path_deviation"] = TerminationTermCfg(
    func=path_deviation,
    time_out=True,  # 偏离路径不是物理失败,标记为 truncation
    params={"max_deviation": 2.0},
)

注意 time_out=True——偏离路径不是"摔倒"这样的物理失败,而是"离开有效区域"的边界条件。如果标为 time_out=False(true termination),critic 会认为偏离路径是"失败状态"(\(V = 0\)),策略会学到"宁可不走也不偏离"。

Termination 诊断脚本 ⭐⭐

# termination_diagnostic.py
def diagnose_terminations(env, num_episodes=100):
    """统计每种 termination 的触发频率。"""
    tm = env.termination_manager
    term_counts = {name: 0 for name in tm.active_terms}
    total_episodes = 0

    obs = env.reset()
    for _ in range(num_episodes * 50):  # 足够多的 step 覆盖 N 个 episode
        actions = torch.randn(
            env.num_envs, env.action_manager.total_action_dim,
            device=env.device
        )
        obs, _, terminated, truncated, extras = env.step(actions)

        done = terminated | truncated
        if done.any():
            total_episodes += done.sum().item()
            for name in tm.active_terms:
                # 获取每个 term 的 episode 累积触发数
                count = tm._episode_sums.get(name)
                if count is not None:
                    term_counts[name] += count[done].sum().item()

    print(f"\nTermination Diagnostic ({total_episodes} episodes)")
    print("=" * 50)
    for name, count in sorted(term_counts.items(),
                               key=lambda x: x[1], reverse=True):
        pct = count / max(total_episodes, 1) * 100
        cfg = tm.active_terms[name]
        timeout_str = "TRUNC" if cfg.time_out else "TERM"
        print(f"  [{timeout_str}] {name}: {count:.0f} ({pct:.1f}%)")

    # 健康检查
    if total_episodes > 0:
        timeout_pct = term_counts.get("time_out", 0) / total_episodes * 100
        if timeout_pct < 20:
            print(f"\n⚠️ Only {timeout_pct:.0f}% episodes reach timeout — "
                  f"most terminate early. Check if termination is too strict.")

⚠️ 常见陷阱

⚠️ 编程陷阱:所有 episode 结束都用同一个 done 信号,不区分 truncation 和 true termination。 现象:PPO critic 在 episode 尾部系统性低估。正确做法:使用 time_out=True 标记截断。

🧠 思维陷阱:认为 termination 只影响日志。 它直接影响 value function 的 target——是价值函数定义的一部分,不是日志细节。

练习

  1. [分类题] 把以下条件分成 true termination 和 truncation:(a) 摔倒,(b) 时间到,(c) 地形 edge 到达,(d) NaN 物理,(e) 越出生成地形。说明每个对 bootstrap 的影响。
  2. [设计题] "跳过障碍物"任务:策略跳跃成功后 episode 应该结束吗?标为 true termination 还是 truncation?讨论不同选择的影响。

reward 定义了"什么是好的",termination 定义了"什么时候结束"。但在训练过程中,任务难度如何逐步增加?这是 curriculum learning 要解决的问题。


6.7 Curriculum Learning——控制训练数据分布 ⭐⭐⭐

这一节解决什么问题:如何在训练过程中逐步增加任务难度?三条轴分别控制什么?什么时候用 performance-driven vs step-driven 推进?

Curriculum 的理论基础 ⭐⭐

Curriculum learning 的核心思想源自 Bengio et al. (2009):人类学习时先从简单例子开始逐渐过渡到复杂例子,这种"由易到难"的训练顺序能加速学习并改善最终性能。在 RL 语境中,curriculum 不改变 reward 函数(那是 reward shaping 做的事),而是改变训练数据的采样分布

类比学校教育更能说明问题。地形 curriculum 像按考试成绩升级(会走平地了才上斜坡),命令 range stage 像按学期推进课程(一年级只要求加减法),reward curriculum 像评分标准逐渐严格(一年级作文只要求写够字数)。三者都叫 curriculum 但控制的对象完全不同。

如果不用 curriculum 会怎样?直接让策略在最难的任务上训练(高速命令 + 粗糙地形 + 严格 penalty),早期所有 episode 都会快速失败。Reward 日志只显示低 tracking、高 termination count,策略在"太难以至于每一步都没有信息"的状态中打转。这时再调 PPO 参数通常没有用——问题不是优化器,而是采样分布太难

Curriculum 与 Domain Randomization 的根本区别。Curriculum 改变任务难度(命令范围、地形复杂度),但物理规律不变。Domain Randomization(Ch08)改变物理规律(摩擦、质量、延迟),但任务难度不变。两者可以同时使用,但不要混淆——"把摩擦系数范围从 [0.5, 1.5] 逐步扩大"是 DR,不是 curriculum;"把速度命令从 [0, 1] m/s 扩大到 [0, 3] m/s"是 curriculum。

三条 Curriculum 轴 ⭐⭐⭐

mjlab 和 Isaac Lab 中 curriculum 沿三条轴展开,每条轴控制不同的训练维度。这三条轴不要同时大幅变化,否则训练失败时无法定位原因。

轴一:地形难度(performance-driven)。这是 Rudin et al. 2022 的"game-inspired curriculum"——一个 10 行 × 20 列的地形网格,从前到后难度递增(平地 → 缓坡 → 阶梯 → 粗糙地形 → 间隙地形)。每个 environment 被分配到网格中的一个位置,根据 episode 中的位移决定是否"升级"或"降级":

# mjlab terrain curriculum 核心逻辑(简化)
def terrain_levels_vel(env, command_name):
    """Performance-driven terrain level adjustment."""
    # 计算 episode 内的前进距离
    distance = torch.norm(
        env.scene.robot.data.root_pos_w[:, :2] - env.scene.env_origins[:, :2],
        dim=1,
    )
    # 升级条件:距离 > terrain_size / 2
    move_up = distance > env.scene.terrain.cfg.size[0] / 2
    # 降级条件:距离 < 命令期望距离 / 2
    expected_dist = torch.norm(env.command_manager.get_command(command_name)[:, :2], dim=1)
    expected_dist *= env.max_episode_length_s
    move_down = distance < expected_dist / 2

    terrain_levels = env.scene.terrain.terrain_levels
    terrain_levels += move_up.long() - move_down.long()
    terrain_levels.clamp_(0, env.scene.terrain.max_terrain_level)
    return terrain_levels

Isaac Lab 的地形 curriculum 使用完全等价的逻辑,但通过 TerrainImporterCfg 的 curriculum 属性配置。

轴二:命令范围(step-driven)。根据训练步数(env.common_step_counter)逐步扩大速度命令范围。

# mjlab 命令 curriculum 配置
curriculum = {
    "commands_vel": CurriculumTermCfg(
        func=mdp.commands_vel_curriculum,
        params={
            "command_name": "base_velocity",
            "stages": [
                {"step": 0,            "ranges": {"lin_vel_x": (-1.0, 1.0)}},
                {"step": 5000 * 24,    "ranges": {"lin_vel_x": (-1.5, 2.0)}},
                {"step": 10000 * 24,   "ranges": {"lin_vel_x": (-2.0, 3.0)}},
            ],
        },
    ),
}

注意 step 是 common_step_counter(env steps),不是 PPO iteration。\(5000 \times 24\) 大约对应第 5000 个 PPO update(因为 num_steps_per_env=24)。

轴三:Reward/Termination 严格度(step-driven)。逐步收紧 reward 参数(如 tracking \(\sigma\))或 termination 阈值。这在 6.3 节已经有示例。

策略类型 优点 缺点 适用场景
Performance-driven 自适应,策略没学会就不升级 可能被 reward hacking 欺骗 地形难度
Step-driven 简单稳定,不依赖 reward 质量 可能在策略没学会时强行加难 命令范围、reward 参数

推荐的训练顺序:(1) 固定平地 + 窄命令 + 基础 reward → (2) 平地 + 扩大命令 → (3) 引入粗糙地形 + 适中命令 → (4) 粗糙地形 + 扩大命令 → (5) 后期加入严格 contact/style penalty。

地形 Curriculum 的完整实现 ⭐⭐⭐

Rudin et al. 2022 的 game-inspired terrain curriculum 是 locomotion 训练的标准工具。它的核心思想是:将一个大地形分成网格(通常 10 行 × 20 列),从前到后难度递增,每个 environment 被分配到网格中的一个位置。

# 地形网格结构(俯视图)
     col 0    col 1    col 2    ...    col 19
row 0 [平地]   [平地]   [平地]   ...    [平地]      ← 最简单
row 1 [缓坡]   [缓坡]   [缓坡]   ...    [缓坡]
row 2 [粗糙]   [粗糙]   [粗糙]   ...    [粗糙]
...
row 9 [间隙]   [间隙]   [间隙]   ...    [间隙]      ← 最困难

每个 env 的 terrain_level 对应一个 row。
env reset 时根据 performance 升/降 row。

地形类型配比(典型配置):

# mjlab / Isaac Lab 地形配置
terrain_proportions = [0.1, 0.1, 0.35, 0.25, 0.2]
# 对应:smooth_slope, rough_slope, stairs_up, stairs_down, discrete_obstacles
# 每种类型在网格的不同列中出现

Isaac Lab 地形 curriculum 配置:

# Isaac Lab terrain curriculum
@configclass
class TerrainCfg:
    terrain_type = "generator"
    terrain_generator = TerrainGeneratorCfg(
        size=(8.0, 8.0),
        num_rows=10,
        num_cols=20,
        curriculum=True,  # 启用 curriculum
        sub_terrains={
            "pyramid_stairs": SubTerrainCfg(proportion=0.35, ...),
            "random_rough": SubTerrainCfg(proportion=0.25, ...),
            # ...
        },
    )

Curriculum State 监控 ⭐⭐

训练过程中应持续监控 curriculum 状态来判断训练进度:

# curriculum_monitor.py
def monitor_curriculum(env, iteration):
    """在训练 callback 中调用,打印 curriculum 状态。"""
    cm = env.curriculum_manager

    # 地形 levels
    if hasattr(env.scene, 'terrain'):
        levels = env.scene.terrain.terrain_levels.float()
        print(f"[iter {iteration:5d}] Terrain: "
              f"mean={levels.mean():.1f}, "
              f"max={levels.max():.0f}, "
              f"min={levels.min():.0f}, "
              f"pct_max={( levels >= levels.max() - 1).float().mean() * 100:.0f}%")

    # 命令范围
    for name, state in cm._curriculum_state.items():
        if isinstance(state, dict) and 'stage' in state:
            print(f"[iter {iteration:5d}] {name}: stage={state['stage']}")

Reward Curriculum 的风险与缓解 ⭐⭐

PPO 假设自己在优化一个固定 MDP,但 reward curriculum 正在改变 reward 函数——这导致 non-stationarity 问题。策略为旧 reward 优化得很好的行为在新 reward 下可能不再最优。

缓解方法: - stage 间隔至少几十到几百个 PPO update,让策略有时间适应 - 参数变化幅度不宜过大(如 \(\sigma\) 每次收紧不超过 30%) - 如果训练曲线在 stage 切换点骤降且不恢复,延长间隔或减小变化

反事实推理:如果每个 PPO iteration 都微调 reward 参数会怎样?策略追踪的目标在不断变化,advantage 估计的 baseline 不断失效——等价于在 non-stationary environment 中做 on-policy 学习,收敛性没有保证。

⚠️ 常见陷阱

⚠️ 编程陷阱:curriculum step 使用 PPO iteration 而非 common step。 配置 "step": 5000 以为是第 5000 个 PPO iteration。实际上 mjlab 的 step 是 env.common_step_counter。正确做法:用 iteration_count * num_steps_per_env 换算。

🧠 思维陷阱:认为 curriculum 可以修复 reward 设计错误。 Curriculum 改变的是训练数据分布,不是 reward 的梯度方向。如果 reward 本身指向错误行为,curriculum 只会让策略按部就班地学到错误行为。Curriculum 是好的 reward 设计的辅助手段,不是替代品。

练习

  1. [设计题] 设计一个 command curriculum:先学前后走(只有 \(v_x\)),再学侧移(加 \(v_y\)),最后学转弯(加 \(\omega_z\))。写出 stage 配置。
  2. [分析题] 地形 curriculum 用 performance-driven 策略,但策略学会原地打转(位移大但没跟命令走)。地形 curriculum 会怎样变化?如何修复?

上节完成了 reward/termination/curriculum 的理论和原则讨论。现在我们深入双框架的源码实现,用代码验证前述所有概念。


6.8 最小可运行实验 ⭐⭐

这一节解决什么问题:提供从零开始验证 reward/termination/curriculum 配置的完整命令序列。

四步验证流程 ⭐⭐

以下流程在 mjlab 和 Isaac Lab 中分别执行,验证 reward 配置是否正确:

# ============= mjlab 四步验证 =============

# Step 1: zero agent — 验证 reward 日志字段存在
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent zero --num-envs 4 --viewer viser
# 期望:控制台打印 RewardManager 的 term 列表(名称、weight)
# 如果缺少某个 term,检查 cfg 拼写

# Step 2: random agent — 验证 reward 数值范围合理
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent random --num-envs 4 --viewer viser
# 期望:reward 日志中 tracking ≈ 0.0-0.3(random 行为不会跟踪好)
# penalty 项为负值,绝对值不要太大(<10)
# 如果 total reward 是 NaN,检查 reward term 是否有除零

# Step 3: 极小训练 — 验证 PPO 能消费 reward/done
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 64 --agent.max-iterations 2 \
  --agent.logger tensorboard --agent.upload-model False
# 期望:2 个 iteration 正常完成,TensorBoard 日志包含
# Episode_Reward/* 和 Episode_Termination/* 字段

# Step 4: 中等规模训练 — 验证 reward 梯度方向正确
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 200 \
  --agent.logger tensorboard
# 期望:200 iteration 后 tracking reward 明显上升
# 如果不上升,检查 sigma、command range、action scale
# ============= Isaac Lab 四步验证 =============

# Step 1-2
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 4

# Step 3
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 64 --max_iterations 2

# Step 4
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 1024 --max_iterations 200

诊断脚本——打印 reward 分项在 random agent 下的数值:

# reward_diagnostic.py
import torch

def diagnose_reward_terms(env, num_steps=50):
    """运行 random agent,打印每个 reward term 的统计量。"""
    obs = env.reset()
    rm = env.reward_manager

    term_sums = {name: 0.0 for name in rm.active_terms}
    total_reward_sum = 0.0

    for step in range(num_steps):
        actions = torch.randn(
            env.num_envs, env.action_manager.total_action_dim,
            device=env.device
        )
        obs, rewards, terminated, truncated, extras = env.step(actions)
        total_reward_sum += rewards.mean().item()

        # 获取每个 term 的 step reward
        for name in rm.active_terms:
            raw = rm._step_reward.get(name)
            if raw is not None:
                term_sums[name] += raw.mean().item()

    print(f"\nReward Diagnostic ({num_steps} steps, random agent)")
    print("=" * 60)
    print(f"Total reward (mean per step): {total_reward_sum / num_steps:.4f}")
    print(f"\nPer-term breakdown:")
    for name, total in sorted(term_sums.items(),
                               key=lambda x: abs(x[1]), reverse=True):
        per_step = total / num_steps
        weight = rm.active_terms[name].weight
        layer = classify_layer(name)
        print(f"  [{layer:>12s}] {name:>30s}: "
              f"per_step={per_step:+8.4f}, weight={weight:+.4f}")

def classify_layer(name):
    """根据 term 名称判断所属层级。"""
    if 'track' in name: return 'Tracking'
    if any(k in name for k in ['rate', 'acc', 'torque', 'limit']): return 'Regulariz.'
    if any(k in name for k in ['height', 'orient', 'vel_z', 'vel_xy']): return 'Style'
    if any(k in name for k in ['air', 'slip', 'contact']): return 'Contact'
    return 'Other'

# 使用:diagnose_reward_terms(env, num_steps=50)

Reward 配置的验证 checklist ⭐⭐

在开始正式训练前,逐项检查:

[ ] 所有 term 的 weight 符号正确(reward 正,penalty 负)
[ ] tracking weight 是所有 term 中最大的正值
[ ] random agent 下 total reward 不是 NaN
[ ] random agent 下没有 term 的 raw value 为 NaN
[ ] zero agent 下机器人稳定站立(验证 base_height target 正确)
[ ] termination 中 time_out 标记正确
[ ] 2 iteration smoke train 无 key error 或 shape mismatch
[ ] 200 iteration 后 tracking reward 有明显上升趋势

6.9 双框架 Manager 源码精读 ⭐⭐

这一节解决什么问题:上述理论如何在 mjlab 和 Isaac Lab 代码中实现?配置文件怎么读?

RewardManager 核心流程 ⭐⭐

RewardManager 的生命周期分三阶段:

初始化deepcopy 配置(运行时 curriculum 修改内部副本,不污染原始 dataclass);_prepare_terms() 解析 term,建立 func → cfg 映射。

每步 compute(dt):清零 _reward_buf → 逐 term 调用 func → 检查 shape 为 [num_envs] → 乘 weight × scale(dt 或 1.0)→ torch.nan_to_num 清理 NaN/Inf → 累加到 _reward_buf_episode_sums

Reset:生成 Episode_Reward/<term> 日志 → 清零对应 env 的 episode sums。

# RewardManager.compute() 完整流程(mjlab)
def compute(self, dt: float) -> torch.Tensor:
    self._reward_buf[:] = 0.0
    for name, (func, cfg) in self._class_term_cfgs.items():
        raw = func.func(self._env, **cfg.params)
        # 安全检查
        assert raw.shape == (self._env.num_envs,), f"Term {name} shape mismatch"
        raw = torch.nan_to_num(raw, nan=0.0, posinf=0.0, neginf=0.0)
        # 缩放
        scale = dt if self.cfg.scale_by_dt else 1.0
        weighted = raw * cfg.weight * scale
        self._reward_buf += weighted
        self._episode_sums[name] += weighted
        # 保存 step reward(不含 dt 缩放)用于 viewer
        self._step_reward[name] = raw * cfg.weight
    return self._reward_buf

TerminationManager 核心流程 ⭐⭐

# TerminationManager.compute() 核心逻辑
def compute(self) -> tuple[torch.Tensor, torch.Tensor]:
    self._terminated_buf[:] = False
    self._truncated_buf[:] = False
    for name, (func, cfg) in self._term_cfgs.items():
        done = func.func(self._env, **cfg.params)  # [num_envs] bool
        if cfg.time_out:
            self._truncated_buf |= done
        else:
            self._terminated_buf |= done
        # 记录到 episode sums 用于日志
        self._episode_sums[name] += done.float()
    return self._terminated_buf, self._truncated_buf

关键点:terminated 和 truncated 是分开计算和存储的。wrapper 层合并时保留了区分信息。

CurriculumManager 核心流程 ⭐⭐

# CurriculumManager.compute() 核心逻辑
def compute(self, env_ids: torch.Tensor | None = None):
    for name, (func, cfg) in self._term_cfgs.items():
        state = func.func(self._env, **cfg.params)
        self._curriculum_state[name] = state
        # 记录到 logger
        if isinstance(state, torch.Tensor):
            extras = {"Curriculum/" + name + "/mean": state.float().mean().item()}

curriculum 在每次 env reset 后被调用(不是每步),只对刚 reset 的环境更新难度。

Velocity Base Config 映射 ⭐⭐

make_velocity_env_cfg()(mjlab)组装完整任务。关键参数:

sim timestep:     0.005 s
decimation:       4
policy dt:        0.02 s (50 Hz)
max episode:      20.0 s (~1000 policy steps)
num_envs:         4096 (默认)

Go1 rough 从 base 开始:添加地形、传感器(RayCaster for height scan)、contact penalty,移除 fell_overGo1 flat 从 rough 派生:改为 plane terrain,移除地形相关项,恢复 fell_over

阅读建议:先看 rough config,再看 flat 如何从 rough 删除。不要反过来读——反过来会误以为 flat 是基础。

⚠️ 常见陷阱

⚠️ 编程陷阱:reward term 中计算结果不是 [num_envs] shape。 如果 term 返回 [num_envs, 3](忘了 sum),RewardManager 会报 shape mismatch。自检:每个 reward 函数的最后一行应该是 torch.sum(..., dim=1) 或类似的 reduction 操作。

练习

  1. [源码题] 在 mjlab 中找到 RewardManager.reset() 的实现。确认 Episode_Reward/<name> 的值是如何计算的——是 sum/actual_length 还是 sum/max_length?
  2. [编程题] 写一个脚本,在训练前打印 RewardManager 的所有 term 名称、weight 和 scale_by_dt 设置。在 mjlab 和 Isaac Lab 中分别实现。
# 练习 2 起始代码
def print_reward_config(env):
    rm = env.reward_manager
    print(f"scale_by_dt: {rm.cfg.scale_by_dt}")
    print(f"Number of terms: {len(rm.active_terms)}")
    for name, term_cfg in rm.active_terms.items():
        print(f"  {name}: weight={term_cfg.weight}, func={term_cfg.func.__name__}")

6.10 Reward Ablation 方法论 ⭐⭐⭐

这一节解决什么问题:怎样验证每个 reward term 的必要性?

控制变量法 ⭐⭐

好的 ablation 要严格控制变量:固定 seed、训练步数、命令范围、环境数量、PPO 参数,每次只改变一个 term。

完整 ablation 工作流(mjlab):

# 0. 创建 ablation 目录
export ABLATION_DIR="/tmp/mjlab/ablation_$(date +%Y%m%d)"
mkdir -p $ABLATION_DIR

# 1. baseline
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 1000 \
  --agent.run-name baseline --agent.seed 42 \
  --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

# 2. ablation: 关闭 foot slip penalty
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 1000 \
  --agent.run-name no_foot_slip --agent.seed 42 \
  --env.rewards.foot-slip.weight 0.0 \
  --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

# 3. ablation: 增大 action rate penalty
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 1000 \
  --agent.run-name strong_action_rate --agent.seed 42 \
  --env.rewards.action-rate-l2.weight -0.05 \
  --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

# 4. ablation: 减小 tracking sigma
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 1000 \
  --agent.run-name tight_sigma --agent.seed 42 \
  --env.rewards.track-linear-velocity.params.std 0.25 \
  --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

# 5. ablation: 关闭 feet air time
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 1000 \
  --agent.run-name no_air_time --agent.seed 42 \
  --env.rewards.feet-air-time.weight 0.0 \
  --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

# 6. 用 tensorboard 对比所有实验
tensorboard --logdir $ABLATION_DIR

Isaac Lab ablation 工作流:

# Isaac Lab 需要通过修改 cfg 文件或 hydra overrides
# 典型方式:创建 ablation cfg 文件继承 base cfg

python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 1024 --max_iterations 1000 --seed 42 \
  --run_name baseline

# 修改 velocity_env_cfg.py 中的 reward weight 后重新运行
# 或使用 hydra override(如果框架支持):
# python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
#   rewards.foot_slide.weight=0.0 --run_name no_foot_slide

判据不只有 Final Return ⭐⭐

至少看四类结果:

判据 含义 TensorBoard 字段 怎么看
Final return PPO 是否优化成功 Loss/mean_reward 应持续上升
Tracking reward 主任务是否完成 Episode_Reward/track_* 应接近 1.0
Contact metrics 行为质量是否变差 Metrics/slip_velocity_mean 应趋近零
Play video 是否有 reward hacking 录视频 人工判断行为合理性

Ablation 结果解读决策树 ⭐⭐

去掉 term X 后:
├── total return ↓ + play video 变差
│   └── Term X 是必要的,保留原 weight
├── total return ↓ + play video 不变或变好
│   └── Term X 可能与其他 term 冲突,尝试调 weight 而非删除
├── total return ↑ + play video 变好
│   └── Term X 可能过度约束,考虑去掉或减小 |weight|
├── total return ↑ + play video 变差
│   └── ⚠️ REWARD HACKING!Term X 是必要的安全约束,
│       策略在利用去掉约束获得更高 reward
│       → 必须保留,可能还需增大 |weight|
└── total return ≈ + play video ≈
    └── Term X 贡献可忽略,可去掉简化 cfg

反事实推理:去掉 foot_slip 后 return 变高?不一定说明 term 没用——可能只是少了负项。必须同时看 slip velocity metric 和视频。如果 slip 明显变大但 return 更高,说明策略在"利用"去掉 penalty 获得更高总 reward——这是 reward hacking 的经典表现。

多 seed 鲁棒性验证 ⭐⭐

单个 seed 的结果可能被随机性误导。对关键决策(如"是否保留某个 term"),建议用 3 个不同 seed 运行:

# 多 seed ablation
for SEED in 42 123 456; do
  uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
    --env.scene.num-envs 1024 --agent.max-iterations 1000 \
    --agent.run-name "baseline_s${SEED}" --agent.seed $SEED \
    --agent.logger tensorboard --agent.log-dir $ABLATION_DIR

  uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
    --env.scene.num-envs 1024 --agent.max-iterations 1000 \
    --agent.run-name "no_slip_s${SEED}" --agent.seed $SEED \
    --env.rewards.foot-slip.weight 0.0 \
    --agent.logger tensorboard --agent.log-dir $ABLATION_DIR
done

如果三个 seed 中有一个结果差异很大(如一个收敛另两个不收敛),说明该配置对初始化敏感——不是鲁棒的设计。

Ablation 实验记录模板 ⭐⭐

# Ablation Experiment Log

## Setup
- Task: Mjlab-Velocity-Flat-Unitree-Go1
- Envs: 1024, Iterations: 1000, Seed: 42
- GPU: ____________, Date: ____________

## Results
| Run | Changed Term | Change | tracking | total_return | slip_mean | Visual |
|-----|:-----------|:-------|:---:|:---:|:---:|:---:|
| baseline | — | — | 0.72 | 38.5 | 0.08 | 自然步态 |
| no_slip | foot_slip | w=0 | 0.75 | 42.1 | 0.31 | 脚滑动明显 |
| strong_ar | action_rate | w=-0.05 | 0.65 | 35.2 | 0.05 | 过于保守 |
| tight_σ | track σ | 0.5→0.25 | 0.58 | 33.1 | 0.06 | 高频抖动 |
| no_air | air_time | w=0 | 0.71 | 39.8 | 0.12 | 溜冰步态 |

## Analysis
- no_slip: return↑ but slip↑ → reward hacking, keep foot_slip
- strong_ar: tracking↓ → over-constrained, try w=-0.03
- tight_σ: tracking↓ + jitter → σ=0.25 too aggressive, try 0.4
- no_air: behavior slightly worse → keep with w=0.5

## Decision
Final config: foot_slip(0.1), air_time(0.5), action_rate(-0.03), σ=0.4

⚠️ 常见陷阱

🧠 思维陷阱:认为"reward 曲线高 = 行为好"。 Reward hacking 的定义就是"reward 高但行为不符合设计意图"。唯一能检测 reward hacking 的方法是看分项 reward + metrics + video

练习

  1. [实验题] 对 Go1 velocity flat 任务,运行以下 ablation 实验(各 1000 iteration):(a) baseline (b) 关闭 foot_slip (c) 关闭 feet_air_time (d) tracking \(\sigma\) 从 0.5 改为 0.25。对比四个实验的 tracking reward、action std、play video。
  2. [分析题] 如果 ablation (b) 的 total return 高于 baseline,但 play video 显示脚在明显滑动,你会如何决策?

6.11 调参攻略与失败案例 ⭐⭐

这一节解决什么问题:从失败现象出发的调参指南。

从失败现象出发的调参指南 ⭐⭐

现象 优先怀疑 第一检查项 调整方向
不走路 tracking 不足或命令太难 tracking reward 日志 放宽命令或增大 \(\sigma\)
原地抖动 action rate penalty 太弱 action_rate_l2 weight 增大 |weight|
动作保守 penalty 总体太强 action std 和速度误差 减小 penalty
站立好但跑不动 posture 太紧 pose vs speed reward 放大 walking std
支撑脚打滑 slip penalty 太弱 slip velocity mean 加大 penalty
rough 一直 reset termination 过严 termination counts 移除 orientation termination
曲线高但视频差 reward hacking ablation + video 分层重调
curriculum 切换时曲线骤降 non-stationarity stage 间隔 增大间隔或减小变化幅度

失败案例分析 ⭐⭐

案例一:tracking 权重过大导致跳跃。 总 reward 很高但 landing_force_mean 也高。原因:tracking 压过 contact penalty,跳跃能快速满足速度命令。修复:增大 landing penalty 或降低早期命令范围。

诊断步骤:

# 案例一诊断:检查 tracking 是否压过 contact
def diagnose_tracking_dominance(tensorboard_log_dir):
    """从 TensorBoard 日志检查 tracking 是否主导 reward。"""
    # 读取最后 100 iteration 的分项 reward
    # 如果 |tracking_sum| > 5 * |contact_sum|,tracking 过于主导
    print("检查 Episode_Reward/ 中:")
    print("  1. track_linear_velocity 是否持续接近 1.0")
    print("  2. feet_air_time 和 foot_slip 的绝对值是否很小")
    print("  3. 如果 tracking ≈ 1.0 但行为异常 → tracking 太容易满足")
    print("  → 解决:减小 σ 或增大 penalty weight")

案例二:posture 太紧导致跑不动。 站立漂亮但大速度时腿摆不开。原因:walking/running std 太小,策略被惩罚到不敢大幅度运动。修复:放大 walking std,保持 standing std。

案例三:command curriculum 过快。 训练前期上升后突然下降。原因:策略未掌握当前范围时 stage 强行加难。修复:延后 stage 或减小跨度。

案例四:奖励曲线震荡不收敛。 原因通常不在 reward 本身而在 PPO 超参数——但 reward 设计可以让训练更稳定。检查:tracking 和 penalty 是否在交替主导 advantage 方向。如果是,说明两者量级太接近——稍微增大 tracking weight 让其成为主导梯度。

案例五:rough terrain 上策略总是摔倒。 TensorBoard 显示 Episode_Termination/fell_over 占比 >80%。

诊断:

# 案例五诊断
def diagnose_rough_terrain_failure(env, num_episodes=50):
    """诊断 rough terrain 上频繁摔倒的原因。"""
    tm = env.termination_manager
    obs = env.reset()
    ep_lengths = []
    fell_count = 0
    total_count = 0
    step_count = 0

    for _ in range(num_episodes * 200):
        actions = torch.randn(env.num_envs, env.action_manager.total_action_dim,
                             device=env.device) * 0.1  # 小幅 random
        obs, _, terminated, truncated, _ = env.step(actions)
        step_count += 1
        done = terminated | truncated
        if done.any():
            total_count += done.sum().item()
            # 检查是 fell_over 还是 time_out
            # ...

    avg_length = step_count / max(total_count, 1)
    print(f"Average episode length: {avg_length:.0f} steps")
    if avg_length < 50:
        print("⚠️ Episodes very short — check:")
        print("  1. 是否 fell_over 的 threshold 对 rough terrain 太严格?")
        print("  2. 尝试移除 fell_over(像 Go1 rough config 那样)")
        print("  3. 检查地形 curriculum 是否从最简单开始")

案例六:Manipulation 任务中策略只学到 reach 不学 lift。 Staged reward 的 reaching 部分收敛到 ~1.0 但 bringing 几乎为零。

原因分析:reaching reward 梯度太强——策略在手到达物体后找到了"停在那里不动"的局部最优。bringing 的梯度因为乘法门控确实存在(reaching ≈ 1 时 bringing 梯度完整传递),但 reaching 已经提供了足够高的 reward,策略没有动力去探索风险更大的 lift 动作。

修复方法:(1) 减小 reaching 的 weight(让"只 reach 不 lift"的 reward 不够高);(2) 添加 grasp success bonus(一次性大奖励);(3) 使用 curriculum 先训练 reaching,确认后冻结 reaching 参数再训练 lifting。

从现象到根因的诊断流程 ⭐⭐

策略行为异常
├── 完全不走路
│   ├── 检查 action scale(Ch05)→ 是否太小
│   ├── 检查 command 是否进入 obs(Ch05)→ 策略能否看到命令
│   └── 检查 tracking σ → 是否太小导致无梯度
├── 走路但行为异常
│   ├── 高频抖动 → action_rate 太弱 或 PD 增益太高
│   ├── 溜冰步态 → foot_slip 太弱 或 air_time 太弱
│   ├── 跳跃前进 → tracking 太强 压过 contact penalty
│   └── 只站不走 → penalty 总量 > tracking 量
├── 训练曲线异常
│   ├── 不上升 → reward scale 与 PPO lr 不匹配(Ch07)
│   ├── 上升后骤降 → curriculum 切换太快
│   └── 震荡 → tracking/penalty 量级太接近
└── 训练正常但部署异常
    ├── sim-to-sim 差异 → obs/action 配置不一致(Ch05)
    └── sim-to-real 差异 → DR 不足(Ch08)或 actuator 模型差(Ch12)

Reward 设计工作流(新任务) ⭐⭐⭐

当你为一个全新的任务(不是从 velocity baseline 派生)设计 reward 时,推荐以下六步工作流:

Step 1:定义成功标准。 用自然语言描述"什么行为算成功"——不要跳到 reward term。例如:"机械臂要能够拿起桌上的方块并放到指定位置,动作平滑不碰撞"。

Step 2:拆解为四层信号。 把成功标准分配到 tracking/regularization/style/contact 四层。对 manipulation:"tracking = 物体到目标距离,regularization = action rate + torque,style = gripper orientation,contact = 不碰桌子 + 成功抓取"。

Step 3:选择 reward 形式。 tracking 用指数核(dense),安全约束用负 L2(penalty),成功条件用 staged/gated(序列依赖)。

Step 4:设置初始 weight。reward_health_check.py 在 random agent 下估算每个 term 的 raw value 范围,调整 weight 使 tracking 贡献 > 所有 penalty 之和。

# Step 4 辅助代码
def estimate_initial_weights(env, num_steps=50):
    """估算每个 reward term 的初始 weight。"""
    rm = env.reward_manager
    raw_ranges = {}

    obs = env.reset()
    for _ in range(num_steps):
        actions = torch.randn(env.num_envs, env.action_manager.total_action_dim,
                             device=env.device)
        env.step(actions)
        for name in rm.active_terms:
            raw = rm._step_reward.get(name)
            if raw is not None:
                val = raw.abs().mean().item()
                raw_ranges[name] = max(raw_ranges.get(name, 0), val)

    print("Suggested weight ranges:")
    for name, raw_max in sorted(raw_ranges.items()):
        # 目标:weighted contribution ≈ 1.0 for tracking, ≈ 0.1-0.5 for penalty
        if 'track' in name:
            suggested = 1.0 / max(raw_max, 1e-6)
            print(f"  {name}: raw_max={raw_max:.3f} → suggested weight ≈ +{suggested:.2f}")
        else:
            suggested = 0.1 / max(raw_max, 1e-6)
            print(f"  {name}: raw_max={raw_max:.3f} → suggested weight ≈ -{suggested:.4f}")

Step 5:Smoke test + 200 iter 验证。 运行四步验证流程(6.8 节),确认 reward 梯度方向正确。

Step 6:Ablation + 迭代。 每次只改一个 weight,用 6.10 节的 ablation 方法论验证效果。记录实验日志。


6.12 源码阅读路线 ⭐⭐

路线 A:mjlab reward/termination/curriculum 主链

  1. src/mjlab/envs/manager_based_rl_env.pystep() 中各 manager 调用顺序(termination 在 reward 之前计算)
  2. src/mjlab/managers/reward_manager.pycompute(dt)reset(env_ids)scale_by_dt
  3. src/mjlab/managers/termination_manager.pytime_out 标记和 terminated/truncated 分离
  4. src/mjlab/rl/vecenv_wrapper.pystep()terminated | truncated 到 RSL-RL dones
  5. src/mjlab/managers/curriculum_manager.pycompute(env_ids) 和 curriculum state 日志

路线 A 的重点阅读顺序:先看 step() 中 manager 的调用顺序——注意 termination 在 reward 之前计算(这意味着 reward 函数中可以安全引用 termination 状态,但反过来不行)。再看 RewardManager.compute()scale_by_dt 的实现——确认每个 term 的 raw value 被乘以 weight * dt。最后看 RslRlVecEnvWrapper.step()extras["time_outs"] 的设置——这是 RSL-RL 区分 truncation 和 true termination 的唯一接口。

# 在源码中加断点验证的示例
def debug_step_order(env):
    """在 env.step() 中打印 manager 调用顺序。"""
    # 用 monkey-patch 验证调用顺序
    original_term_compute = env.termination_manager.compute
    original_rew_compute = env.reward_manager.compute

    call_order = []
    def wrapped_term():
        call_order.append("termination")
        return original_term_compute()
    def wrapped_rew(dt):
        call_order.append("reward")
        return original_rew_compute(dt)

    env.termination_manager.compute = wrapped_term
    env.reward_manager.compute = wrapped_rew

    actions = torch.zeros(env.num_envs, env.action_manager.total_action_dim,
                         device=env.device)
    env.step(actions)

    print(f"Manager call order: {' → '.join(call_order)}")
    # 期望输出:termination → reward

    # 恢复原始方法
    env.termination_manager.compute = original_term_compute
    env.reward_manager.compute = original_rew_compute

路线 B:Isaac Lab 对等路径

  1. source/isaaclab/envs/manager_based_rl_env.py → env.step() lifecycle
  2. source/isaaclab/managers/reward_manager.py → compute + logging
  3. source/isaaclab/managers/termination_manager.py → truncated/terminated 分离
  4. source/isaaclab_rl/rsl_rl/vecenv_compat.py → wrapper 转换

Isaac Lab 的 manager 调用顺序与 mjlab 完全镜像——这是设计使然。两个框架共享 manager-based 架构的语义契约,差异仅在实现细节(config 模式、tensor 访问路径)。

路线 C:velocity task reward 分层阅读

  1. src/mjlab/tasks/velocity/mdp/rewards.py → 按 tracking / regularization / style / contact 分组阅读
  2. src/mjlab/tasks/velocity/mdp/curriculums.pyterrain_levels_velcommands_vel
  3. src/mjlab/tasks/velocity/velocity_env_cfg.py → rewards / terminations / curriculum 配置
  4. src/mjlab/tasks/velocity/config/go1/env_cfgs.py → 先看 rough,再看 flat

这条路线的核心目标是理解同一个 reward term 的 cfg 和实现之间的映射。例如,cfg 中的 "track_linear_velocity": RewardTermCfg(func=..., weight=2.0, params={"std": 0.5}) 对应 rewards.py 中的 track_linear_velocity(env, std) 函数。weightstd 分别在不同的位置生效——weightRewardManager.compute() 中乘入,std 作为 params 传给函数。搞清楚这个映射是正确修改 reward 配置的前提。

路线 D:TienKung-Lab reward 精读(进阶)

  1. TienKung-Lab/.../.../rewards.py → AMP-style rewards + periodic gait rewards
  2. 对比 TienKung-Lab 的 humanoid reward 与 Go1 的四足 reward

TienKung-Lab(github.com/Open-X-Humanoid/TienKung-Lab)是一个基于 Isaac Lab 构建的人形机器人 RL 训练框架,整合了 AMP(Adversarial Motion Priors)和周期性步态奖励。它的 reward 设计与四足 locomotion 有三个关键差异:

(1) AMP discriminator reward:用一个判别器网络区分"真实人类动作"和"策略生成动作",把判别器的输出作为 style reward。这比手工设计 posture/gait reward 更表达力强。

(2) Angular momentum penalty:人形机器人因为支撑面窄,角动量管理至关重要。TienKung-Lab 和 HoST 都包含 \(\|L_{\text{base}}\|^2\) 惩罚(\(L = I_{\text{base}} \omega + m \cdot c \times v_{\text{com}}\)),这在四足中通常不需要。

(3) Periodic gait reward:用周期函数定义理想的步态相位,鼓励策略的足端接触模式匹配目标相位。这与四足的 feet_air_time 相比更结构化。

路线 E:manipulation reward 对比(进阶)

  1. src/mjlab/tasks/manipulation/lift_cube_env_cfg.py → staged reward 配置
  2. src/mjlab/tasks/manipulation/mdp/rewards.pystaged_position_reward 实现
  3. Isaac Lab: source/isaaclab_tasks/manager_based/manipulation/lift/ → Franka lift 配置

这条路线与路线 C 形成 locomotion ↔ manipulation 的对比。关键差异:locomotion reward 是并行的四层(所有 term 同时激活),manipulation reward 是串行的 staged 结构(乘法门控)。理解两种模式的适用场景和设计权衡,是设计新任务 reward 的基础。

阅读原则:先 task config → 再 Manager → 最后 base class。这个顺序与 Ch05 相同。


📋 Reward/Termination/Curriculum 设计审查 Checklist

使用方法:在完成新任务的 reward 设计或修改现有配置后,逐项检查。

A. Reward 基础审查

  • [ ] 所有 term weight 符号正确(reward 正,penalty 负)
  • [ ] tracking layer 的 weighted contribution > 所有 penalty 之和
  • [ ] 指数核 σ 在"期望可接受误差 × 1-2 倍"范围内
  • [ ] 没有遗漏 scale_by_dt 设置(默认 True 即可)
  • [ ] random agent 100 步无 NaN reward

B. Termination 审查

  • [ ] time_out 标记正确:物理失败 → False,非失败截断 → True
  • [ ] rough terrain 是否需要移除 fell_over
  • [ ] wrapper 正确传递 extras["time_outs"] 给 RSL-RL

C. Curriculum 审查

  • [ ] 三条轴不同时大幅变化
  • [ ] step-driven curriculum 的 step 单位确认(common step,不是 PPO iteration)
  • [ ] performance-driven curriculum 的升/降条件合理
  • [ ] reward curriculum 的 stage 间隔 ≥ 几十个 PPO update

D. 训练后验证

  • [ ] 200 iteration 后 tracking reward 有上升趋势
  • [ ] Episode_Termination/time_out 占比 > 50%(策略能存活到 timeout)
  • [ ] play video 中行为与 reward 一致(无 reward hacking)
  • [ ] ablation 实验验证了关键 term 的必要性

本章小结

知识点 核心要点 难度
Reward shaping 理论 potential-based 保策略不变,工程中多为 non-potential ⭐⭐⭐
四层 reward 分解 tracking / regularization / style / contact,承认冲突 ⭐⭐
指数核 tracking \(r = \exp(-e^2/\sigma^2)\),大误差时梯度趋零 ⭐⭐⭐
\(\sigma\) 选择 ≈ 期望可接受误差的 1-2 倍 ⭐⭐
dt 缩放 让 reward 权重独立于仿真频率 ⭐⭐
True termination vs truncation 真失败清零未来价值,截断需 bootstrap ⭐⭐⭐
Curriculum 三轴 地形难度 / 命令范围 / reward 严格度 ⭐⭐
Performance-driven vs step-driven 地形用前者,命令/reward 用后者 ⭐⭐
Reward ablation 控制变量,看 return + metrics + video ⭐⭐⭐
Reward hacking 检测 总 reward 高 ≠ 行为好 ⭐⭐⭐

本章覆盖了两大认知模式的转变:

从"一个 reward 函数"到"多目标优化的标量化"。 Reward 设计不是寻找一个正确的函数,而是在帕累托前沿上选择一个可接受的权衡点。承认冲突的存在是正确调参的前提。

从"训练成功 = reward 高"到"训练成功 = 行为对 + reward 高"。 Reward hacking 是 RL 工程中最隐蔽的失败模式。唯一的检测方法是分项 reward + 行为 metrics + 视频验证。


累积项目:本章新增模块

本章为累积项目新增"reward/termination/curriculum 设计与诊断"模块。你现在应该能够:

  1. 为 velocity task 配置完整的四层 reward(在 mjlab 和 Isaac Lab 中)
  2. 正确区分 true termination 和 truncation,确认 time_out 标记
  3. 设计 terrain + command + reward 三条 curriculum 轴
  4. 运行 reward ablation 实验并解读结果
  5. 从 TensorBoard 日志诊断 reward hacking 和 curriculum 问题

累积项目检查点:在开始下一章之前,确保你已经: - 在 mjlab 中训练了 Go1 velocity flat 任务至少 500 iteration - 查看了 TensorBoard 中所有 Episode_Reward/* 分项 - 确认了 Episode_Termination/*time_outfell_over 的比例 - 至少运行了一个 ablation 实验(如关闭 foot_slip)并对比了结果

检查点验证命令:

# 1. 训练 500 iteration
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 500 \
  --agent.logger tensorboard --agent.seed 42 \
  --agent.run-name ch06_checkpoint

# 2. 查看 TensorBoard
tensorboard --logdir /tmp/mjlab/logs/

# 3. Play 验证行为
uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
  --agent.load-run ch06_checkpoint --num-envs 4 --viewer viser

# 4. 运行 ablation(关闭 foot_slip)
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 1024 --agent.max-iterations 500 \
  --env.rewards.foot-slip.weight 0.0 \
  --agent.run-name ch06_ablation_no_slip --agent.seed 42

Isaac Lab 等价命令:

python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 1024 --max_iterations 500 --seed 42

python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0
项目 本章对应任务
项目 A:四足速度跟踪 建立 tracking / style / contact reward 分层,配置 curriculum
项目 C:机械臂操作 把 staged reward 思路用于 approach / contact / lift
后期动态目标项目 把稀疏命中奖励拆成轨迹、姿态、时序奖励(Ch20-Ch22)

下一章(Ch07)将在本章的 reward 基础上进入 PPO 训练管线——learning rate schedule、clip ratio、entropy bonus 等超参数如何与 reward scale 交互,以及如何诊断训练不稳定的根因。


延伸阅读

资料 难度 内容
Ng et al. 1999, "Policy invariance under reward transformations" ⭐⭐⭐ Reward shaping 理论基础
Rudin et al. 2022, "Learning to Walk in Minutes" ⭐⭐ legged_gym locomotion reward 体系
Bengio et al. 2009, "Curriculum Learning" ⭐⭐ Curriculum learning 理论基础
Margolis & Agrawal 2023, "Walk-These-Ways" ⭐⭐ 多命令 locomotion + gait reward
Kim, Kim & Park 2024, "Automated Hyperparameter Tuning" ⭐⭐ 自动化 reward weight 调优
STRIDE (arXiv 2502.04692) ⭐⭐⭐ LLM 驱动的 reward 自动生成
RSL-RL configuration docs PPO 消费 reward/done/extras
Mirza & Singh 2025, "Imitation learning for legged robots survey" ⭐⭐ reward 设计综述

跨章联系提示:本章建立的 reward/termination/curriculum 系统是后续多个章节的基础。Ch07(PPO 训练)的超参数(learning rate、clip ratio)必须与 reward scale 匹配。Ch08(Domain Randomization)的随机化参数影响 reward 的期望值和方差。Ch09(Teacher-Student)的 teacher 和 student 可以使用不同的 reward。Ch17(机械臂与灵巧手操作)将展示 staged reward 的门控机制——一种与 locomotion 完全不同的 reward 架构。


🔧 故障排查手册

症状 可能原因 排查步骤 相关小节
reward 日志全为零 term 未注册或 weight=0 1.检查 cfg term 拼写 2.打印 active_terms 3.确认 weight 非零 6.8
tracking 接近零但机器人在动 \(\sigma\) 太小 1.增大 std 2.检查命令范围 3.打印 raw error 6.3
episode 极短 termination 过严或 curriculum 太难 1.查 Episode_Termination/* 分项 2.移除可疑 termination 3.降初始难度 6.6
曲线升但视频差 reward hacking 1.查分项 reward 2.检查 metrics 3.做 ablation 4.录视频 6.10
curriculum 切换时曲线骤降 non-stationarity 1.增大 stage 间隔 2.减小参数变化幅度 6.7
decimation 改后 reward scale 变 scale_by_dt 设置不一致 1.确认 scale_by_dt=True 2.检查手动 dt 乘法是否重复 6.5
terminated/truncated 不区分 wrapper 不传 time_outs 1.检查 is_finite_horizon 设置 2.确认 wrapper extras 包含 time_outs 6.6
penalty 过强导致不走路 早期 penalty 占主导 1.检查分项 reward 2.减小 penalty weight 3.用 curriculum 延后 penalty 6.4, 6.7