第 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 设计错误往往不会在代码层面报错,只会在训练数百万步之后才以"行为看起来不对"的方式暴露出来。
本章目标
学完本章后,你应该能够:
- 解释 reward shaping 的数学基础——从 MDP 的最优策略不变性定理到 potential-based shaping 的理论保证
- 构建 四足 locomotion 的四层 reward 分解(tracking / regularization / style / contact),理解每层的物理意义和相互冲突
- 设计 curriculum learning 的三条轴(地形难度 / 命令范围 / reward 严格度),并判断何时使用 performance-driven vs step-driven 推进
- 区分 true termination 和 truncation 的语义差异,正确配置
time_out标记以保证 PPO critic 的 bootstrap 逻辑正确 - 实施 reward ablation 实验,用控制变量法验证每个 reward term 的必要性
- 编写 双框架中完整的 reward/termination/curriculum 配置,并能在 TensorBoard 中诊断 reward hacking
- 对比 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)\) 的目标是最大化从初始状态出发的期望折扣回报:
这个公式看起来简单,但隐藏着一个深层问题:我们想让机器人学会的行为(如"自然地走路")和我们能直接定义的数学目标之间,存在巨大的语义鸿沟。"自然地走路"意味着速度跟踪命令、动作平滑不抖、姿态保持直立、脚步规律交替、支撑脚不打滑、落脚冲击小——这些目标彼此耦合甚至冲突,无法用一个简洁的标量函数完美表达。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}\),使得
时,shaped MDP 的最优策略集合与原始 MDP 完全相同。
为什么 potential-based 是安全的? 直觉解释:telescoping(伸缩求和)。在无限时域折扣设置下,potential-based shaping reward 的累积和为:
当 \(\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 的——这没有关系。定理的工程价值在于提供一个"理论安全"的子类作为参考,而不是作为设计约束。
练习
- [推导题] 证明 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\) 有界时该和与策略无关。
- [思考题]
action_rate_l2惩罚 \(\|a_t - a_{t-1}\|^2\)。它是 potential-based 的吗?如果不是,它改变了最优策略的哪个方面?这种改变在工程上是否是期望的? - [编程题] 用 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_velocity 和 Episode_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)。分层的价值在于提供组织框架和调参入口,不在于分类的绝对正确性。
练习
- [手推题] 给定命令 \(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 的贡献。
- [设计题] 为机械臂抓取任务设计四层 reward。Tracking 对应什么?Regularization 对应什么?Contact 对应什么?是否需要 Style?
- [编程题] 用 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 使用指数核:
这里 \(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 的方式给了更多调参自由度。
角速度跟踪的对称设计 ⭐⭐
角速度跟踪使用完全对称的指数核:
只跟踪 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 逐步收紧。
练习
- [计算题] \(\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 变为多少?解释两者对梯度的影响。
- [设计题] 对一个高速奔跑任务(命令 \(v_x\) 最大 3.0 m/s),早期策略的速度误差可能达到 3.0 m/s。选择合适的初始 \(\sigma\) 和 curriculum schedule,确保训练全程都有 tracking 梯度信号。
- [跨框架对比题] 在 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_l2 从 env.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。
练习
- [分析题] 如果去掉
feet_air_time但保留foot_slip,策略会学到什么行为?(提示:考虑"脚贴地面滑动"是否被 foot_slip 完全惩罚。) - [编程题] 写一个自定义 reward term,惩罚左右脚的 air time 差异(鼓励步态对称性)。在 mjlab 中用 class-based term 实现,在 Isaac Lab 中用 function-based term 实现。
- [跨章综合题] 回顾 Ch05 的 obs 设计。
foot_contact_forces在 actor obs 中吗?如果不在,foot_slipreward 使用了 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 的值会被系统性"稀释"。
练习
- [计算题] 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的日志值。 - [思考题] 把
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_buf。RslRlVecEnvWrapper.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——是价值函数定义的一部分,不是日志细节。
练习
- [分类题] 把以下条件分成 true termination 和 truncation:(a) 摔倒,(b) 时间到,(c) 地形 edge 到达,(d) NaN 物理,(e) 越出生成地形。说明每个对 bootstrap 的影响。
- [设计题] "跳过障碍物"任务:策略跳跃成功后 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 设计的辅助手段,不是替代品。
练习
- [设计题] 设计一个 command curriculum:先学前后走(只有 \(v_x\)),再学侧移(加 \(v_y\)),最后学转弯(加 \(\omega_z\))。写出 stage 配置。
- [分析题] 地形 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_over。Go1 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 操作。
练习
- [源码题] 在 mjlab 中找到
RewardManager.reset()的实现。确认Episode_Reward/<name>的值是如何计算的——是 sum/actual_length 还是 sum/max_length? - [编程题] 写一个脚本,在训练前打印 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。
练习
- [实验题] 对 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。
- [分析题] 如果 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 主链
src/mjlab/envs/manager_based_rl_env.py→step()中各 manager 调用顺序(termination 在 reward 之前计算)src/mjlab/managers/reward_manager.py→compute(dt)、reset(env_ids)、scale_by_dtsrc/mjlab/managers/termination_manager.py→time_out标记和 terminated/truncated 分离src/mjlab/rl/vecenv_wrapper.py→step()中terminated | truncated到 RSL-RL donessrc/mjlab/managers/curriculum_manager.py→compute(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 对等路径
source/isaaclab/envs/manager_based_rl_env.py→ env.step() lifecyclesource/isaaclab/managers/reward_manager.py→ compute + loggingsource/isaaclab/managers/termination_manager.py→ truncated/terminated 分离source/isaaclab_rl/rsl_rl/vecenv_compat.py→ wrapper 转换
Isaac Lab 的 manager 调用顺序与 mjlab 完全镜像——这是设计使然。两个框架共享 manager-based 架构的语义契约,差异仅在实现细节(config 模式、tensor 访问路径)。
路线 C:velocity task reward 分层阅读
src/mjlab/tasks/velocity/mdp/rewards.py→ 按 tracking / regularization / style / contact 分组阅读src/mjlab/tasks/velocity/mdp/curriculums.py→terrain_levels_vel和commands_velsrc/mjlab/tasks/velocity/velocity_env_cfg.py→ rewards / terminations / curriculum 配置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) 函数。weight 和 std 分别在不同的位置生效——weight 在 RewardManager.compute() 中乘入,std 作为 params 传给函数。搞清楚这个映射是正确修改 reward 配置的前提。
路线 D:TienKung-Lab reward 精读(进阶)
TienKung-Lab/.../.../rewards.py→ AMP-style rewards + periodic gait rewards- 对比 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 对比(进阶)
src/mjlab/tasks/manipulation/lift_cube_env_cfg.py→ staged reward 配置src/mjlab/tasks/manipulation/mdp/rewards.py→staged_position_reward实现- 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 设计与诊断"模块。你现在应该能够:
- 为 velocity task 配置完整的四层 reward(在 mjlab 和 Isaac Lab 中)
- 正确区分 true termination 和 truncation,确认
time_out标记 - 设计 terrain + command + reward 三条 curriculum 轴
- 运行 reward ablation 实验并解读结果
- 从 TensorBoard 日志诊断 reward hacking 和 curriculum 问题
累积项目检查点:在开始下一章之前,确保你已经:
- 在 mjlab 中训练了 Go1 velocity flat 任务至少 500 iteration
- 查看了 TensorBoard 中所有 Episode_Reward/* 分项
- 确认了 Episode_Termination/* 中 time_out 和 fell_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 |