第 05 章:Observation 与 Action 设计——策略与环境之间的契约
本章定位:这是 RL 设计层的入口章节。前面的章节已经把 Entity/Scene/Actuator/Sensor 和 Manager 串成了一条数据管线,但管线本身只是基础设施。从本章开始,我们正式进入"如何让 RL 策略与物理世界对话"的核心问题。RL 设计的第一道门不是 reward,而是 observation 和 action——observation 决定策略能看见什么,action 决定策略能改变什么,二者共同定义了智能体与环境之间的接口。如果这个接口设计有误,后面的 reward shaping、curriculum learning、PPO 超参调整全部沦为补救。本章的目标是建立一个可部署、可调试、可迁移的观测-动作接口设计框架,并在 mjlab 与 Isaac Lab 两个框架中完整演示。
前置依赖:Ch03(MuJoCo Warp 与仿真基础)、Ch04(Manager-Based 架构精读)、MuJoCo actuator/control 基础、RL 基本概念(MDP、策略梯度、PPO)
关键文献:Pinto et al. 2018 (Asymmetric Actor-Critic for Image-Based Robot Learning), Lee et al. 2020 (Learning Quadrupedal Locomotion over Challenging Terrain), Kumar et al. 2021 (RMA: Rapid Motor Adaptation for Legged Robots), Rudin et al. 2021 (Learning to Walk in Minutes Using Massively Parallel Deep RL), He et al. 2025 (HOVER: Versatile Neural Whole-Body Controller for Humanoid Robots, ICRA'25)
参考项目:🔧 mjlab velocity obs/action 配置 · 🔧 Isaac Lab velocity obs/action 配置 · ✅ HOVER (
github.com/NVlabs/HOVER, ICRA'25, ~720 Stars)
前置自测
📋 答不出 \(\ge\) 3 题 → 先回前置章节复习
| 问题 | 检查目的 |
|---|---|
| actor 可以看到仿真中的 contact force 真值吗? | 检查是否理解部署边界 |
| critic 可以看到 actor 看不到的信号吗?为什么? | 检查是否理解 asymmetric actor-critic |
| raw action 的物理单位是什么? | 检查是否把网络输出和物理量混淆 |
| action scale 变大一定能让策略探索更多吗? | 检查是否理解控制幅度与稳定性的权衡 |
| observation delay 的 lag 单位是什么?它如何换算成物理时间? | 检查是否知道它以 env step 计数 |
history_length=3 且原始 observation 维度为 12 时,展平后维度是多少? |
检查是否理解 buffer 展平 |
| ONNX 文件是否包含 action scale/offset? | 检查是否理解部署后处理边界 |
mjlab 的 observation group 叫 actor/critic,Isaac Lab 默认叫什么? |
检查是否理解双框架命名差异 |
如果把 critic 的 obs_groups 指向不存在的 group,会报错还是静默回退? |
检查是否理解 obs_groups routing(显式错误报错、遗漏 default 才回退) |
MuJoCo 使用 wxyz 四元数约定,这意味着 quat[0] 是什么分量? |
检查是否知道跨框架坐标系约定差异 |
base_lin_vel 在真实四足机器人上能否直接测得?若不能,有哪些估计/蒸馏方案? |
检查是否理解部署可得性的核心困难 |
本章目标
学完本章后,你应该能够:
- 设计 一个 locomotion 或 manipulation 任务的 actor/critic observation group,正确区分部署可得信号和训练特权信号,并在 mjlab 和 Isaac Lab 中完成对等配置
- 解释 raw action、action scale、default offset 和 processed action 之间的完整变换链,并为新机器人选择合适的 scale
- 运用 observation 处理管线(Isaac Lab:compute → modifiers → noise → clip → scale → history;mjlab 另含 delay)诊断训练失败和部署偏移
- 分析 HOVER 的多模态 obs group 设计,理解 mask-conditioned observation 在统一控制中的作用
- 撰写 完整的 ONNX 部署边界文档,明确网络内与网络外的职责划分
- 独立完成 从 zero agent 到 GPU 训练的完整 smoke test 流程,验证接口接线正确
- 对比 locomotion 和 manipulation 任务的 obs/action 设计差异,理解物体相对信号、坐标系选择和多段 action 的工程原因
- 掌握 RSL-RL 5.x 的 obs_groups routing 机制和双框架配置差异(instance-based vs class-inherited),能快速定位 obs/action 接口层面的隐蔽 bug
本章路线图
本章按照"理论→原则→实现→高级→部署→调试→实战"的递进结构组织:
5.1 从 MDP/POMDP 理论回顾 obs/action 的数学定义(为什么这样设计)→ 5.2 五条可操作的设计原则(设计准则)→ 5.3 ObservationManager 处理链精读(工程实现)→ 5.4 Actor/Critic 非对称观测(信息边界)→ 5.5 Action 空间详解(输出接口)→ 5.6 env.step 时序分析(执行顺序)→ 5.7 HOVER 多模态 obs 精读(前沿案例)→ 5.8 ONNX 部署与 Smoke Test(部署验证)→ 5.9 Debug Checklist(故障诊断)→ 5.10 从零设计工作流(实战整合)→ 5.11 源码阅读路线(深入探索)。
如果时间有限,优先阅读 5.2(设计原则)、5.4(actor/critic 分离)和 5.5(action 空间)——这三节涵盖了日常工作中最常遇到的设计决策。5.7(HOVER)适合需要处理多模态控制任务的读者。5.10(实战工作流)适合即将开始新项目的读者。
前置依赖与本章定位 ⭐
本章假设你已经:(1) 学完 Ch04 并理解 manager-based 架构的基本概念——observation/action/reward/termination 由独立 manager 管理,(2) 至少在一个框架中运行过 velocity tracking 任务的 zero agent 或训练脚本,(3) 理解 PPO 的基本训练循环(rollout → GAE → mini-batch update)。如果以上任何一点不满足,建议先回 Ch04 补课。
本章在全书中的定位是"RL 工程的第一个关键接口层"。Ch04 讲了"框架怎么工作",本章讲"策略和环境通过什么接口通信",Ch06 将讲"怎么定义好和坏",Ch07 将讲"怎么优化策略"。这四章构成了 RL 工程的核心循环——理解了这个循环,后续所有高级主题(domain randomization、teacher-student、sim-to-real)都是在这个循环的基础上做扩展。
本章覆盖的双框架比较会贯穿始终——不是在章末集中对比,而是在每个知识点中同时展示两个框架的实现。如果你只使用一个框架,仍然建议阅读另一个框架的内容——理解"同一个概念为什么有两种实现方式"能加深对概念本身的理解,就像学习第二门编程语言能让你更好地理解第一门。
5.1 算法回顾:从 MDP 到 Observation/Action 接口 ⭐⭐
这一节解决什么问题:回顾 MDP/POMDP 理论中 observation 和 action 的数学定义,建立"结构先于目标"的设计哲学,从失败案例出发理解接口错误的代价。
为什么不从 reward 开始 ⭐
很多 RL 初学者在拿到一个新任务时,第一反应是设计 reward function。这个直觉来自监督学习的习惯——在监督学习中,损失函数几乎决定了一切,而输入输出格式通常是确定的(图像进、标签出)。但在 RL for Robotics 中,情况截然不同。策略不是一个静态分类器,而是一个闭环控制器——它的输出(action)会改变世界状态,进而改变它的下一个输入(observation)。如果 observation 缺少关键信息,策略就像一个蒙着眼睛的司机,无论 reward 如何设计都无法安全驾驶;如果 action 空间定义不当,策略就像一个手脚不协调的运动员,即使知道目标也无法精确执行。这就是为什么 observation 和 action 必须在 reward 之前确定——它们是 MDP 的结构性定义,而 reward 只是在这个结构上的优化目标。
这种"结构先于目标"的设计哲学与传统控制工程中的系统建模思想一脉相承。在经典控制论中,你不会先设计控制器再去想传感器测什么、执行器控制什么——你一定是先定义系统的输入输出接口(状态空间模型的 \(\mathbf{y} = C\mathbf{x}\) 和 \(\mathbf{u}\)),再在这个接口上设计控制律。RL 的 observation/action 正是 MDP 框架下的 \(\mathbf{y}\) 和 \(\mathbf{u}\)。如果类比信号处理,observation 是 ADC(模数转换),action 是 DAC(数模转换),它们共同决定了策略能处理的"信号带宽"——超出这个带宽的信息和控制能力,无论算法多强都无法补偿。但这个类比也有不像的地方:ADC/DAC 的带宽在硬件层面是固定的,而 observation/action 的"带宽"可以通过软件设计调整——加入 history 相当于扩展时间带宽,加入 height scan 相当于扩展空间带宽。
本质洞察:observation/action 不是"输入输出张量的格式问题",而是训练系统和部署系统之间的契约(contract)。actor observation 写下了部署端必须提供的传感器协议,action scale/offset 写下了部署端必须执行的后处理协议。只要契约没有写清楚,训练成功就不等于控制器可用。
从 POMDP 到 observation 设计 ⭐⭐
严格来说,几乎所有机器人 RL 任务都是 POMDP(Partially Observable Markov Decision Process),而非完全可观测的 MDP。在完全可观测的 MDP 中,策略 \(\pi(a|s)\) 以完整状态 \(s\) 为输入;在 POMDP 中,智能体只能观测到状态的一部分 \(o = h(s)\),其中 \(h\) 是观测函数。这意味着不同的真实状态可能产生相同的 observation——策略必须在这种"信息不完整"的条件下决策。
考虑一个四足机器人行走的例子。完整的物理状态 \(s\) 包括:机器人 37 维广义位置与速度(19 + 18,含浮动基座:位置 7 + 12,速度 6 + 12)、所有接触点的力和位置、地形的精确几何形状、空气阻力、关节摩擦系数等。但 actor observation \(o\) 通常只有约 48 维——IMU 角速度(3)、重力投影(3)、关节位置(12)、关节速度(12)、上一拍动作(12)、速度命令(3),再加可选的地形扫描。大量状态信息被"遮挡"了:接触力不直接可观、地形精确形状未知、关节摩擦是隐藏参数。
POMDP 的经典理论告诉我们,最优策略需要基于整个 observation history \(\pi(a_t | o_1, o_2, \ldots, o_t)\)。在实践中,我们用两种方法近似这个理想:一种是 history buffer(将最近 \(k\) 帧 observation 拼接成输入,让 MLP 在固定窗口上提取时间特征);另一种是 RNN/Transformer(维护隐状态来压缩任意长度的历史)。mjlab 当前的 velocity baseline 使用 MLP + 可选 history buffer,Isaac Lab 的 locomotion 模板同样默认 MLP——这是工程上最稳健的起点。RNN 虽然更灵活,但会引入隐状态重置、ONNX 输入输出变化、rollout storage 复杂化等额外挑战。
observation 设计就像给一个驾驶员设计仪表盘。完整的车辆状态(发动机内部温度分布、每个轮胎的微观接触面积、路面材质)不可能全部显示在仪表盘上,驾驶员必须通过有限的仪表(速度表、转速表、后视镜、导航)做出驾驶决策。仪表盘的设计原则不是"显示越多越好",而是"显示驾驶决策所需的、且可以可靠获取的关键信息"。但仪表盘和 observation 之间也有不同:仪表盘面向人类驾驶员,可以利用人类的视觉系统做信息融合;observation 面向 MLP 或 RNN,这些网络对输入的数值范围和分布更敏感,因此需要额外的 scale 和 normalization 处理。
四种动作空间理论 ⭐⭐
在确定策略输出的物理含义之前,需要理解四种基本的动作空间类型。它们对应不同的控制抽象层次:
| 类型 | 物理含义 | 适用场景 | 控制精度 | 学习难度 |
|---|---|---|---|---|
| Joint Position | 关节位置目标(rad) | locomotion、基本 manipulation | 中等 | 低 |
| Joint Velocity | 关节速度目标(rad/s) | 高动态任务、连续运动 | 中等 | 中等 |
| Joint Effort | 力矩目标(Nm) | 研究级、需精确力控 | 最高 | 最高 |
| Differential IK | 末端位姿增量(m, rad) | manipulation、task-space 控制 | 任务层高 | 中等 |
Joint Position 是工程中最常见的选择——它在策略和物理世界之间放置了一个 PD 控制器作为缓冲层。策略输出位置目标,PD 控制器负责追踪。这种设计让策略只需要学"去哪里",而不需要学"怎么去"。如果不用位置控制而直接用力矩控制,策略必须同时学会动力学模型——相当于让学生在学开车的同时还要学发动机工程,这在工程上是不合理的。但这并不意味着 effort 控制无价值:在需要精确力交互的任务(如灵巧手操作)中,effort 控制提供了最大的灵活性。
这四种动作空间构成了一个从"高抽象"到"低抽象"的连续谱:
DiffIK (末端空间) → JointPosition (关节空间) → JointVelocity (速度空间) → JointEffort (力矩空间)
↑ 抽象度高 ↑ 抽象度低
学习难度低 学习难度高
控制灵活性低 控制灵活性高
选择哪一层是工程权衡,不是技术优劣。抽象度越高,策略需要学习的内容越少(运动学/动力学知识被编码进 action transform),但灵活性也越低(策略无法做出 transform 不支持的动作)。这与软件工程中的抽象层选择完全类比——用高级语言开发效率高但性能上限低,用汇编效率低但可以做任何事情。
信息瓶颈与维度权衡 ⭐⭐
observation 维度不是越高越好——这个直觉来自信息论中的 rate-distortion 理论。对于 MLP 策略,第一层参数 = input_dim \(\times\) hidden_dim。假设 hidden_dim = 512:
| obs 维度 | 第一层参数量 | rollout 存储(4096 envs \(\times\) 24 steps \(\times\) float32) | 学习效率影响 |
|---|---|---|---|
| 48 | 24,576 | 18.9 MB | 基线 |
| 96 (\(2\times\) history) | 49,152 | 37.7 MB | 略有下降 |
| 192 (\(4\times\) history) | 98,304 | 75.5 MB | 明显下降 |
| 480 (\(10\times\) history) | 245,760 | 188.7 MB | 显著下降 |
维度增加带来两个成本:第一是参数量增加,需要更多样本才能拟合——这在 on-policy 算法中尤其敏感,因为每次更新后旧数据就被丢弃;第二是 rollout 存储增加,可能超出 GPU 显存限制,迫使减小 batch size 或环境数量。
但维度减少也有成本:如果 observation 缺少关键信息,策略面临的 POMDP 问题更严重,可能需要更复杂的网络(如 RNN)或更长的训练来补偿。最优的 observation 维度是"刚好包含决策所需信息"的那个点——信息论中的 sufficient statistic 概念在这里有直接的类比。
历史与生态背景 ⭐
Observation/action 的分层设计来自多个技术传统的汇合。Gym/Gymnasium 风格环境接口定义了 observation space 和 action space 的抽象,策略只通过这两个空间与环境交互。Isaac Lab 的 manager-based 架构把 observation、action、reward、termination 拆成独立 manager,每个 manager 由多个 term 组成,让复杂任务从巨型 step 函数中解耦——mjlab 的 manager 设计与这个生态接近。
从 legged_gym 到 manager-based 的演进。legged_gym(Rudin et al., CoRL 2021, github.com/leggedrobotics/legged_gym,~1.8k Stars)是四足 RL 训练的里程碑,确立了 velocity tracking 的标准 obs 集合(base velocity, angular velocity, gravity projection, joint state, last action, command)和 actor/critic 分组。但 legged_gym 的工程痛点在于:所有逻辑混在一个 legged_robot.py 中——observation 计算、reward 计算、reset 逻辑、domain randomization 交织在一起,导致修改一个 reward 可能影响 observation,添加一个 sensor 需要改动 5 个文件。legged_gym 现已官方废弃(README 明确声明"this repository will receive limited updates and support"),其精神继承者是 Isaac Lab 的 manager-based 架构和 mjlab 的等价设计。
理解这个历史演进对于阅读前人代码至关重要——很多开源项目仍然基于 legged_gym 或 Isaac Gym Preview 的 monolithic 风格。当你把这些项目迁移到 Isaac Lab 或 mjlab 时,第一步就是把混合在一个函数中的 obs/reward/event 逻辑拆分成独立的 manager terms。
Pinto et al. 2018 (Asymmetric Actor Critic for Image-Based Robot Learning, RSS) 为"critic 可以看更多"提供了数学基础——actor 只看 image,critic 看 full state。sim-to-real 的工程约束(传感器延迟、执行器饱和、通信带宽)则要求仿真中的 observation/action 接口必须尊重真实硬件的信息边界。
POMDP 在机器人控制中的工程意义 ⭐⭐
POMDP 框架在理论上预测的问题,在真实机器人 RL 中无处不在。但与教科书 POMDP(通常假设已知 belief 更新规则)不同,机器人 RL 中的部分可观测性更多是"工程设计选择"而非"固有限制"。让我们看几个具体例子:
可以通过 obs 设计消除的部分可观测性:(1) 角速度在单帧 observation 中已经"编码"了旋转的变化率——不需要显式给两帧位置让策略自己差分。(2) 速度命令直接给出了任务目标——如果不给,策略面对多个互斥目标时的"部分可观测性"是人为的。
无法通过 obs 设计消除的部分可观测性:(1) 地面摩擦系数——不同地面看起来一样但物理行为不同,只有通过滑动试探才能推断。(2) 关节磨损和温度导致的动力学变化——这些参数在 episode 内缓慢变化,需要 online adaptation。这类部分可观测性正是 Ch09 teacher-student 蒸馏和 RMA adaptation module 要解决的问题。
RMA(Kumar et al. 2021, RSS)的 obs 设计视角。 RMA 把"固有不可观测参数"(摩擦、质量、电机强度等)编码为一个低维 latent vector \(z_t\)。在 Phase 1,base policy 的 actor 输入是 \([o_t, z_t]\)——\(z_t\) 由一个 environment factor encoder 从真值参数直接编码得到(privileged)。在 Phase 2,一个 adaptation module 学习从 proprioceptive history \([o_{t-k}, ..., o_t]\) 预测 \(\hat{z}_t\)。部署时 actor 输入变为 \([o_t, \hat{z}_t]\)——\(z_t\) 的信息来自历史 observation 的隐式推断,而非真值。这个设计精确对应了"可消除的 POMDP → 消除之;不可消除的 POMDP → 从历史推断"的工程原则。
工程启示:obs 设计的第一目标是消除所有"人为的"部分可观测性(确保策略看到了所有部署可得的相关信号)。只有当部分可观测性是"固有的"(如摩擦系数不可直接测量)时,才需要 history、RNN 或 adaptation module 等更重的工具。
六个典型失败案例 ⭐⭐
理论总是在失败中学到最多。下面列举六个真实训练中常见的 observation/action 设计错误。这些案例的共同特征是:表面看像 reward 问题或 PPO 调参问题,但根因都在 MDP 接口层。
案例一:action scale 过大导致高频抖动。 策略训练了几千个 iteration,TensorBoard 上 reward 逐渐升高,play 时机器人却像被弹簧拉扯一样抖动。根因是 action scale 太大。Go1 通过 JointPositionActionCfg 控制关节位置目标,raw action 是无量纲网络输出,action term 执行 processed_action = raw_action * scale + offset。如果 scale 过大,raw action 中很小的数值变化都会被放大成很大的关节目标变化。PPO 在 raw action 空间中看到的策略标准差可能只有 0.3,但经过 scale=2.0 的放大后,实际进入控制器的位置目标波动已经达到 0.6 rad——对于 Go1 这种小型四足机器人,这已经足以引起剧烈的机械冲击。
案例二:actor 偷看 privileged observation。 在 rough terrain 任务中,actor 直接看到了仿真中的足底接触力真值。训练曲线很好,play 中机器人也能过障碍。但部署到真实机器人时,策略依赖了一个部署时不可用的捷径——这不是 sim-to-real 的普通误差,而是 actor observation 边界的根本错误。正确做法是让 critic 看 privileged signal,actor 只看部署可获得的信号。
案例三:command 没有进入 actor observation。 速度跟踪任务希望机器人跟踪线速度和 yaw 角速度命令。如果 actor 看不到 command,它只能学习一种平均行为。同一个 observation 对应多个互相冲突的目标动作——这等价于在监督学习中用同一个输入标注了四个不同的标签,网络被迫学到均值,通常就是"站着不动、微微抖动"。reward 再怎么设计也无法让一个相同输入对应四个不同动作。
案例四:normalization 部署遗漏。 训练时打开了 actor 的 observation normalization,导出 ONNX 后策略行为完全异常。原因是 normalizer 的 running statistics 是训练状态的一部分,如果 ONNX 没有包含等价的 normalization 层,策略输入的分布会整体偏移。
案例五:盲目添加 history。 训练不稳定时直接把 history_length 从 1 增大到 10。history 确实可以缓解部分可观测性问题,但它按倍数放大 observation 维度(48 维 \(\times\) 10 = 480 维),急剧增加网络参数和显存。如果训练不稳定的根因是 action scale 错误或 reward 设计缺陷,history 只会掩盖问题而非解决问题。
案例六:train/play CLI 参数混用。 训练时错误地使用 --num-envs 4096(这是 play 的参数格式),导致实际只用了默认的少量环境训练。在 mjlab 中,训练配置的环境数量必须写成 --env.scene.num-envs 4096;在 Isaac Lab 中,对应参数也有不同的嵌套路径。
⚠️ 常见陷阱
⚠️ 编程陷阱:在 observation term 中返回错误的 tensor shape。 所有 vectorized env 都把 batch 维放在第 0 维。如果 term 返回 [dim] 而不是 [num_envs, dim],concatenate 时会报错或产生维度歧义。mjlab 和 Isaac Lab 都遵循这一约定。自检方法:在 compute 后打印 obs.shape,确认第 0 维等于 num_envs。
💡 概念误区:认为"observation 越多越好"。 新手可能觉得给 actor 尽可能多的信息总是有利的。但如果信息中包含部署时不存在的真值,策略会学到依赖这些捷径,导致 sim-to-real 失败。observation 的设计原则是"部署可得性优先",不是"信息量最大化"。
🧠 思维陷阱:把训练失败的第一反应定为"调 PPO 超参"。 PPO 是一个相当鲁棒的优化器——如果 MDP 接口定义正确、reward 设计合理,PPO 在默认参数下通常就能工作。正确的排查顺序是:先验证接口(observation group、action scale、command)→ 再检查 reward → 最后才调 PPO 参数。
练习
- [分析题] 回顾上述六个失败案例,为每个案例识别属于"编程错误"、"概念错误"还是"流程错误"。讨论哪些错误可以通过自动化检查发现,哪些只能通过人工审查。
- [设计题] 假设你要训练一个 Go1 机器人在崎岖地形上行走,但真实机器人没有足底力传感器。设计 actor 和 critic 的 observation group,说明每个信号的来源和部署可得性。如果你还有一个 depth camera,它应该放在 actor 还是 critic 中?
- [跨章综合题]
base_lin_vel的部署困境(5.2 节原则二详细讨论了三种方案)如何影响你在本题中的 actor obs 设计?如果选择方案三(teacher-student 蒸馏),teacher 的 actor obs 和 student 的 actor obs 有什么不同?这个不同如何在 mjlab 的 obs_groups 中配置?
上节建立了"结构先于目标"的设计哲学——从 POMDP 理论到六个失败案例,从 action 空间四种类型到 legged_gym → manager-based 的历史演进。但还缺少具体的设计准则:面对一个新任务,怎么判断该放哪些信号、不该放哪些?五条原则是否能覆盖所有场景?这正是下节要解决的问题——五条可操作的 observation 设计原则,每条配备违反症状、修复方法和跨框架验证方式。
5.2 Observation 五条设计原则 ⭐⭐
这一节解决什么问题:给出五条可操作的 observation 设计原则,每条原则配备违反症状和修复方法,建立从 MDP 理论到工程实践的桥梁。
原则一:马尔可夫性——观测必须包含决策所需的充分信息 ⭐⭐
如果 observation 缺少决策关键信息,相同的 observation 可能对应不同的最优动作——MDP 退化为 POMDP,策略被迫学平均行为。速度跟踪任务中不包含 command 就是典型的马尔可夫性违反。诊断方法:如果训练曲线收敛但行为模糊(不跟命令、动作偏保守),先检查 observation 是否遗漏了任务条件变量。
| 违反症状 | 典型原因 | 修复方向 |
|---|---|---|
| 策略不跟命令变化 | command 未进入 actor | 加入 command term |
| 策略在不同地形表现相同 | 缺少地形感知 | 加入 height scan 或深度 |
| 策略动作偏保守、不分化 | 缺少区分不同状态的信号 | 增加 proprioceptive 信号或 history |
如果不遵守这条原则会怎样?考虑一个极端情况:actor 只能看到关节位置,没有角速度、没有重力投影、没有命令。策略必须从静态关节角度中"猜测"身体是否在倾斜、腿是在空中还是着地。这就像让你蒙住眼睛、塞住耳朵,只通过手指关节角度判断身体姿态——理论上不是完全不可能(关节角度的变化率隐含了速度信息),但学习效率极低,且泛化能力差。
原则二:可部署性——actor 只能看到真机可得的信号 ⭐⭐
这是最容易违反、代价最高的原则。把仿真中才有的真值(接触力精确值、地形精确高度、物体精确位姿)放进 actor,等于在训练时给学生开卷考试,部署时却闭卷——成绩必然暴跌。
| 信号 | 典型来源 | actor 可否使用 | critic 可否使用 | 说明 |
|---|---|---|---|---|
| IMU 角速度 | 机载 IMU | ✓ | ✓ | 部署可得,需建模噪声和偏置 |
| projected gravity | IMU 姿态估计 | ✓ | ✓ | 部署可得但有估计误差 |
| joint position | 编码器 | ✓ | ✓ | 精度通常很高 |
| joint velocity | 编码器差分 | ✓ | ✓ | 噪声较大,需注意滤波 |
| last action | 控制器内部 | ✓ | ✓ | 策略自己记住上一拍输出 |
| command | 上层规划/用户 | 必须 | 必须 | 条件策略必须知道目标 |
| contact force 真值 | 仿真接触引擎 | ✗ | ✓ | 真实力传感器语义不等价 |
| terrain height scan | 仿真 raycast | 视部署 | ✓ | 需真实深度/LiDAR 链路 |
| object pose 真值 | 仿真对象状态 | ✗ | ✓ | 真实系统需视觉估计 |
base_lin_vel 的部署困境——最重要的具体案例。 Isaac Lab 官方文档(Sim-to-Real Policy Transfer)明确警告:"While real robot IMU sensors provide angular acceleration (which can be integrated to get angular velocity), they cannot directly measure linear velocity. Therefore, if a policy relies on base linear velocity during training, this information must be removed before real robot deployment."
这个警告揭示了一个微妙但关键的问题:base_lin_vel 在 velocity task 的 actor obs 中普遍存在(包括 legged_gym 和 Isaac Lab 的 locomotion baseline),但在真实机器人上没有直接传感器可以提供等价信号。部署时有三种处理方案:
方案一(最简单):从 actor obs 中移除 base_lin_vel,只保留 base_ang_vel(IMU 可直接测量)。策略需要在没有线速度反馈的情况下学会 velocity tracking——这更难但更真实。DreamWaQ(Nahrendra, Yu, Myung, ICRA 2023)就采用了这种方案。
方案二(最常用):使用状态估计器在真实机器人上推算 base_lin_vel。典型方法是从 IMU 积分或足端运动学约束推算——但估计误差可能很大(尤其是在滑动或快速转向时)。训练时需要在 base_lin_vel 上加入匹配估计器误差的 noise。
方案三(最系统):使用 teacher-student 蒸馏。teacher 使用精确的 base_lin_vel(仿真真值),student 使用 proprioceptive history 来隐式估计线速度。这正是 RMA(Kumar et al. 2021)和 Lee et al. 2020 的核心思路,将在 Ch09 详细讲述。
这三种方案的选择直接影响整个 obs/action 架构设计——它不是一个"以后再处理"的细节,而是项目启动时就需要做出的关键决策。
原则三:低维性——信息瓶颈与学习效率的权衡 ⭐⭐
observation 维度直接影响网络参数量和样本效率。对于 MLP 策略,第一层参数 = input_dim \(\times\) hidden_dim。从 48 维增加到 480 维(10 帧 history),仅第一层就增加 10 倍参数。除非信息增益显著超过维度成本,否则应优先选择低维表示。
这与图像压缩中的速率-失真权衡(rate-distortion tradeoff)完全类比:信息越多(bit rate 越高),重建质量(策略能力)越好,但传输和处理成本也越高。观测设计的目标是找到"够用的最低维度"——就像视频通话不需要 8K 分辨率,720p 足以传达必要信息。
维度预算的实践参考。不同任务类型有不同的典型 obs 维度范围:
| 任务类型 | actor obs 维度 | critic obs 维度 | 典型增量来源 |
|---|---|---|---|
| 四足 velocity (flat) | 40-50 | 50-70 | +contact forces, +foot height |
| 四足 velocity (rough) | 40-50 + height scan | 50-70 + clean height | height_scan ~187 点 |
| 人形 locomotion | 60-100 | 80-120 | +全身关节(20-30 DOF) |
| 桌面操作 (state-based) | 30-50 | 40-60 | +object velocity, +contact |
| 桌面操作 (vision-based) | 30 + 视觉特征 | 40-60 | 视觉编码器输出 64-256D |
| HOVER 多模态 | 100-200 | teacher 专用 | +target poses, +masks |
如果你的 actor obs 维度显著超出了同类任务的范围,检查是否有冗余 term(如同时给了 joint_pos 和 joint_pos_rel——两者只是差一个常数偏移)或未压缩的高维信号(如原始 height map 而非降采样版本)。
降维策略的优先级:(1) 删除冗余 term(最简单,无信息损失)→ (2) 对高维信号降采样(如 height scan 从 400 点降到 100 点)→ (3) 减少 history length(从 10 降到 3-4)→ (4) 使用编码器将高维信号压缩到低维潜在空间(最复杂,可能损失信息但增加了网络结构先验)。
原则四:信噪比——训练时就建模部署噪声 ⭐⭐
如果训练时 observation 是干净的仿真真值,但部署时传感器有噪声,策略会经历分布偏移。正确做法是在训练时就加入与真实传感器匹配的噪声模型(noise term)。但噪声也不是越大越好——过大的噪声会淹没有效信号,降低学习效率。
噪声建模的正确方法是"从传感器 datasheet 出发,而不是凭感觉猜"。每一种传感器都有明确的噪声特征:
| 传感器 | 噪声类型 | 典型量级 | 建模方式 |
|---|---|---|---|
| IMU 陀螺仪 | 白噪声 + 偏置漂移 | \(0.01\)-\(0.05\) rad/s (白噪声),\(0.001\) rad/s/√Hz (漂移) | Uniform/Gaussian noise + 慢变 bias |
| IMU 加速度计 | 白噪声 | \(0.05\)-\(0.2\) m/s² | Gaussian noise |
| 关节编码器 | 量化噪声 | \(10^{-4}\)-\(10^{-3}\) rad | 通常可忽略 |
| 关节速度(差分估计) | 差分放大 | \(0.5\)-\(2.0\) rad/s | Uniform noise(取决于滤波器) |
| 深度相机 | 距离相关 | \(0.01\)-\(0.1\) m | Gaussian,与距离成正比 |
| 力传感器 | 白噪声 + 零漂 | \(1\)-\(10\) N | Gaussian noise + offset |
如果不做噪声建模会怎样?如果训练时角速度是干净的仿真真值(精确到机器精度),策略会学到依赖高精度角速度来做精细平衡调整。部署时 IMU 的噪声和偏置让这些微调变得无效甚至有害——策略对噪声的"过拟合"导致抖动或失稳。这与图像识别中的经典问题完全类似:在无噪声图像上训练的分类器,遇到 JPEG 压缩伪影就性能暴跌。
反事实推理:如果噪声设置得比真实传感器大 10 倍会怎样?策略会学到"忽略高噪声信号,依赖低噪声信号"的策略——角速度噪声大就更多依赖关节编码器。这在部署时反而更鲁棒(因为真实噪声更小),但训练效率会下降,且策略可能无法充分利用高精度传感器。工程上的经验法则是:噪声范围设为真实传感器噪声的 1-3 倍,既保证鲁棒性又不过分牺牲信号。
原则五:跨框架一致性——双框架的 obs 语义必须对齐 ⭐⭐
在双框架工作流中,一个常见的隐蔽错误是:mjlab 和 Isaac Lab 中同名的 observation term 实际返回的坐标系、单位或约定不同。例如 base_lin_vel 在两个框架中都使用 body frame,但 projected_gravity 的计算方式可能有微妙差异(旋转矩阵的转置约定)。跨框架对比实验时,必须先用 zero agent 验证每个 observation term 的数值范围和单位是否一致。
四元数约定差异是最容易被忽视的跨框架陷阱。MuJoCo(包括 MuJoCo Warp / mjlab)使用 wxyz 四元数约定(scalar-first),而 NVIDIA 的 Warp 库使用 xyzw 约定(scalar-last)。mjlab 在内部处理了这个转换(通过 TorchArray 桥),但如果你在自定义 observation term 中直接操作 Warp 的四元数数据,可能会遇到约定不匹配。Isaac Lab(PhysX 后端)同样使用 wxyz 约定。这个差异通常不影响 projected_gravity(因为它已经转换为 3D 向量),但在需要四元数作为 obs 的任务(如 manipulation 中的物体朝向)中可能导致"看起来正常但数值全错"的隐蔽 bug。
具体验证步骤:在两个框架中分别创建 1 个环境,reset 到相同的初始状态(手动设定关节角度为已知值),运行 zero agent 1 步,打印每个 actor obs term 的值。逐 term 对比数值差异——差异应小于 1%(浮点精度和不同引擎的微小数值差异)。如果某个 term 差异大于 5%,检查坐标系约定、单位和计算方法。
| 双框架 obs term 映射 | mjlab | Isaac Lab |
|---|---|---|
| 机体线速度 | base_lin_vel() |
mdp.base_lin_vel() via ObsTerm |
| 机体角速度 | base_ang_vel() |
mdp.base_ang_vel() |
| 重力投影 | projected_gravity() |
mdp.projected_gravity() |
| 关节相对位置 | joint_pos_rel() |
mdp.joint_pos_rel() |
| 关节速度 | joint_vel_rel() |
mdp.joint_vel_rel() |
| 上一拍动作 | last_action() |
mdp.last_action() |
| 命令 | generated_commands() |
mdp.generated_commands() |
五层架构总览 ⭐⭐
Observation/action 设计可以分成五个层次,每个层次回答不同的问题:
第一层:物理状态层——"世界里真实存在什么"。例如 base velocity、contact force、terrain height。仿真中精确可得,真实世界需传感器估计。这一层的全部信息构成了 MDP 的完整状态 \(s_t\)——如果 critic 能看到全部物理状态,它就等价于一个完美的 value function。
第二层:传感器层——"部署时能测到什么"。例如 IMU 角速度、编码器位置、相机图像。传感器层与物理状态层之间存在观测函数 \(o = h(s) + \epsilon\)。传感器层的信号集合定义了 actor observation 的"天花板"——actor 不可能看到传感器测不到的东西(即使仿真中这些信息是精确可得的)。
第三层:MDP 接口层——"策略在每个 env step 接收什么张量、输出什么张量"。这就是 actor observation 和 raw action。这一层由 ObservationManager 和 ActionManager 定义,是策略与环境的唯一交互界面。本章的大部分内容都在这一层。
第四层:训练辅助层——"训练时哪些额外信息能帮助 critic 或 reward"。包括 privileged critic observation。
第五层:部署封装层——"网络外还需要哪些处理才能上机器人"。包括 normalization 参数、history buffer、action scale/offset 和 safety clip。
这五层不能混在一起。把物理真值直接给 actor,会破坏部署边界(第一层侵入第三层);把 action scale 放在 ONNX 外却部署时忘记复现,会破坏控制幅度(第五层遗漏)。
用 Go1 velocity task 映射五层。以 base_ang_vel 这一个 observation term 为例:
第一层(物理状态): MuJoCo/PhysX 内部的刚体角速度真值 → ω_true = [0.03, -0.05, 0.01] rad/s
第二层(传感器): 真实 IMU 陀螺仪测量 → ω_measured = ω_true + bias + noise
第三层(MDP 接口): actor obs term "base_ang_vel" → [0.03, -0.05, 0.01](仿真直接读取)
训练时加 noise 模拟第二层:noise = Uniform(-0.2, 0.2)
第四层(训练辅助): critic group 可能包含无 noise 版本(如果需要)
第五层(部署封装): ONNX 外:确认输入来自 IMU gyroscope driver,单位 rad/s,body frame
注意第二层和第三层之间的关键区别:训练时第三层直接从仿真读取真值(第一层),然后通过 noise term "模拟"第二层的效果。这意味着训练中的 noise 参数必须匹配第二层的传感器特性——不多不少。这就是为什么原则四(信噪比)要求"从传感器 datasheet 出发"。
本质洞察:五层架构的核心价值不是分类本身,而是让每一层的决策在训练之前就确定下来。如果不做分层,你会得到一个"扁平"的接口——所有信号混在一个列表里,训练时能用但不知道哪些信号部署时存在。分层设计把这个决策从"部署时发现"提前到"训练前规划"。
下面这张决策表总结了两个框架中各设计对象的位置和部署要求:
| 设计对象 | mjlab 位置 | Isaac Lab 位置 | 部署是否需要 |
|---|---|---|---|
| actor observation | ObservationGroupCfg("actor") |
ObservationsCfg.policy |
✓ |
| critic observation | ObservationGroupCfg("critic") |
ObservationsCfg.critic |
✗ |
| term-level scale | ObservationTermCfg.scale |
ObsTerm.scale |
需等价复现 |
| model normalization | RslRlModelCfg.obs_normalization |
rsl_rl_cfg.normalize_obs |
需随模型保留 |
| raw action | ActionManager.action |
ActionManager |
ONNX 输出 |
| processed action | BaseAction.process_actions() |
ActionTerm.process_actions() |
需复现 |
| default offset | use_default_offset=True |
use_default_offset=True |
需复现 |
⚠️ 常见陷阱
⚠️ 编程陷阱:observation term 引用了尚未创建的 manager。 回顾 Ch04:ManagerBasedRlEnv.load_managers() 按固定顺序加载 manager——先 event → 再 command → 再 action → 再 observation。如果你在 observation term 中引用 command manager,但 command manager 还没有创建,环境构建阶段就会失败。Isaac Lab 的加载顺序类似,也遵循 manager 间依赖的拓扑排序。
💡 概念误区:认为"state-based policy 就是视觉策略的超集"。 state-based actor 直接看到物体位姿的精确低维向量,vision actor 看到的是像素图像——两者 observation 语义完全不同。state-based 策略的结论不能直接迁移到视觉部署。
练习
- [分类题] 对以下信号判断是否适合给 actor、给 critic、还是两者都不给:(a) 风速真值 (b) 摩擦系数真值 (c) 关节温度(有传感器) (d) 下一步的 reward (e) 地形坡度估计(从 IMU 推断)
- [推导题] 对于一个速度跟踪任务,假设 actor observation 包含 3D 角速度、3D 重力投影、12D 关节位置、12D 关节速度、12D last action 和 3D command,计算总维度。如果
history_length=3且展平,维度变为多少?估算当num_envs=4096且num_steps_per_env=24时,单次 rollout 的 observation 存储量(假设 float32)。 - [设计题] 用五条原则为以下场景设计 actor observation:一个 7-DOF 机械臂需要把桌上的杯子抓起来放到指定位置。机械臂有关节编码器和腕部力传感器,末端有一个深度相机,但部署带宽有限只能传 downsampled 点云。逐条检查你的设计是否满足五条原则。
- [跨框架对比题] 分别在 mjlab 和 Isaac Lab 的 velocity flat 配置中,列出 actor group 的所有 term 和对应的 noise 参数。对比两个框架:(a) term 名称是否完全对应?(b) noise 数值是否一致?(c) 如果不一致,分析原因(可能是不同的 sim-to-real 假设、不同的默认传感器模型、或不同的机器人型号)。
实战示例:五条原则的联合应用 ⭐⭐
为了让五条原则不停留在理论层面,我们用一个具体的移动操作任务来展示如何从零开始应用它们。
场景描述:一个带有 7-DOF 机械臂的移动底盘机器人,需要导航到目标位置并抓取桌上的物体。机器人有 IMU、关节编码器、腕部力矩传感器和头顶 RGB-D 相机。
逐条应用:
原则一(马尔可夫性)检查:策略需要知道"当前在哪→要去哪→目标物在哪→手臂状态如何→是否已经抓住"。因此 actor 至少需要:底盘位姿估计、目标导航点、物体相对位姿估计、臂关节状态、gripper 状态、抓取成功标志。如果缺少物体位姿,策略无法判断"伸手方向"——这与 velocity task 缺少 command 同理。
原则二(可部署性)审查:物体位姿在仿真中是真值,但部署时必须通过视觉估计。因此 actor 应该使用"视觉估计的物体位姿"(有噪声和延迟),而非仿真真值。critic 可以使用仿真真值来降低 value 估计方差。底盘的全局位置在仿真中精确可得,但部署时来自 SLAM 或定位系统——需要在 actor 中建模定位误差。
原则三(低维性)验证:RGB-D 图像原始分辨率可能是 \(640 \times 480 \times 4 = 1,228,800\) 维。直接作为 MLP 输入不可行。选择:(a) 先经过视觉编码器降维到 64-128 维潜在向量,或 (b) 用下游感知模块直接输出物体位姿估计(6D)。方案 (b) 的 obs 维度约 50-80 维,与 locomotion 基线相当,MLP 可以直接处理。方案 (a) 需要端到端训练或预训练编码器——复杂度显著增加。
原则四(信噪比)规划:腕部力传感器噪声约 \(\pm 2\) N(厂商 datasheet),关节编码器噪声可忽略,定位系统误差约 \(\pm 5\) cm。在训练 noise cfg 中:force noise = UniformNoiseCfg(-2.0, 2.0),position noise 根据定位系统特性设定。
原则五(跨框架一致性)确认:在 mjlab 中物体位姿通常通过 env.scene["object"].data.root_pos_w 获取(世界坐标),在 Isaac Lab 中通过 env.scene["object"].data.root_pos_w 获取。两者语义一致但需验证坐标系约定(左手/右手、Z-up vs Y-up)。
上节给出了五条设计原则和一个完整的应用示例,但原则需要通过代码落地。两个框架的 ObservationManager 是如何把这些原则变成可执行的处理管线的?具体来说:noise 在哪一步加入?scale 在 noise 之前还是之后?delay buffer 怎么管理?history 怎么拼接?这些实现细节直接决定了你的 obs 设计能否正确工作——一个原则上正确的设计如果实现顺序搞错,效果可能比不做还差。
此外,mjlab 和 Isaac Lab 在处理链的实现上存在一些重要的架构差异——理解这些差异不仅有助于跨框架迁移,也能帮助你理解每个框架的设计哲学为什么导致了不同的 API 选择。
5.3 ObservationManager 管线精读 ⭐⭐⭐
这一节解决什么问题:完整理解从物理状态到策略输入的处理链,并区分 Isaac Lab 官方链与 mjlab 扩展链,在两个框架中进行源码级对比。
处理链总览 ⭐⭐
两个框架的 per-term 处理顺序不完全相同,要分开记:
Isaac Lab 官方链(compute_group 中按 ObservationManager 源码顺序):
compute → modifiers → noise/corruption → clip → scale → (history buffer)
mjlab 扩展链(在 term cfg 中额外内置 delay):
compute → noise → clip → scale → delay → history
注意两点差异:(1)Isaac Lab 在 noise 之前先应用 modifiers(一组可配置的自定义变换,按列表顺序执行);(2)Isaac Lab 的默认 ObsTerm 没有 per-term delay 阶段(observation delay 需借助 DelayBuffer/自定义/扩展),而 mjlab 把 delay 内置进了 term cfg。history 两个框架都内置,作为 group/buffer 层管理。
为什么是这个顺序? 想象一个信号从物理世界流向策略网络的管线。compute 阶段从仿真状态中提取原始信号("世界是什么样的")。modifiers(Isaac Lab)对原始信号做自定义变换。noise 阶段模拟传感器的测量误差("传感器看到了什么")。clip 阶段防止极端值击穿后续处理("合法的测量范围是什么")。scale 阶段把有物理量纲的值映射到网络友好的数值范围("数值怎样表达更利于学习")。delay 阶段(mjlab)模拟传感器和通信延迟("策略拿到的信息有多旧")。history 阶段把当前帧和历史帧拼接在一起("策略能看到多长的时间窗口")。
如果顺序搞错会怎样?如果先 scale 再 noise,噪声的物理单位就错了——比如角速度 noise 应该在 rad/s 空间加入,先 scale 再加 noise 意味着噪声在 scaled 空间中,大小不对。如果先 history 再 delay,延迟作用在整段历史上而不是单帧上,语义完全不同。如果先 concatenate 再 per-term scale,就无法给不同 term 设不同缩放。
GPU 执行的工程影响。 在 mjlab 中,整个 compute_group() 调用链在 GPU 上连续执行,通过 MuJoCo Warp 的 TorchArray 零拷贝桥直接读取物理状态——没有 CPU↔GPU 传输开销。mjlab 甚至支持将 env.step 的主循环(包括 observation 计算)录制为 CUDA Graph,录制一次后后续 step 直接 replay——这大幅降低了 kernel launch 的 CPU 开销,对 4096+ 环境尤其显著。但 CUDA Graph 有一个限制:图录制时的 tensor shape 和控制流必须固定。如果 observation 的某个 term 有动态 shape(如可变长度的 contact list),CUDA Graph 会失败——需要用固定大小的 buffer 填充。
Isaac Lab 在 PhysX 后端下,observation 计算同样在 GPU 上执行(PhysX 的 GPU pipeline 直接输出 CUDA tensor),但 CUDA Graph 支持取决于 Isaac Sim 版本和具体配置。
双框架处理链对比 ⭐⭐⭐
mjlab 和 Isaac Lab 的处理链在语义上一致,但在 API 细节上有重要差异:
| 处理阶段 | mjlab 实现 | Isaac Lab 实现 | 关键差异 |
|---|---|---|---|
| compute | ObservationTermCfg.func |
ObsTerm.func |
API 签名相似,参数传递方式略不同 |
| noise | UniformNoiseCfg / GaussianNoiseCfg |
UniformNoiseCfg / GaussianNoiseCfg |
参数名一致,数值需独立设置 |
| clip | ObservationTermCfg.clip |
ObsTerm.clip |
语义一致 |
| scale | ObservationTermCfg.scale |
ObsTerm.scale |
标量或 per-dim tensor |
| delay | DelayBuffer (per-term) |
无内置 per-term ObsTerm delay(需 DelayBuffer/自定义/扩展) |
Isaac Lab 默认 ObsTerm 无内置 delay |
| history | CircularBuffer |
ObservationTermCfg.history_length / flatten_history_dim(可在 group 级覆盖) |
两者都内置 history 支持 |
这个对比揭示了一个工程差异:history 两个框架都内置——mjlab 用 CircularBuffer,Isaac Lab 用 ObservationTermCfg.history_length(配合 flatten_history_dim,并可在 ObservationGroupCfg 级别覆盖)。真正的差异在 delay:mjlab 在 ObservationTermCfg 层面内置了 DelayBuffer,而 Isaac Lab 的默认 ObsTerm 没有 per-term observation delay——若需要 obs delay,需借助 DelayBuffer 工具、自定义 modifier/observation function 或社区扩展实现。这反映了设计哲学差异:mjlab 倾向于把常用处理标准化为 cfg 选项。
双框架配置体系的深层差异 ⭐⭐
mjlab 和 Isaac Lab 在 observation/action 配置上的差异不仅是 API 名称——它们反映了两种截然不同的软件设计哲学。理解这些差异对于在两个框架中高效工作至关重要。
Instance-based vs Class-inherited configs。 Isaac Lab 使用 class-inherited dataclass 配置(@configclass 装饰器 + __post_init__ 方法)。当你从 RoughEnvCfg 继承创建 FlatEnvCfg 时,__post_init__ 中的副作用会在子类实例化时自动执行。这带来一个隐蔽问题:如果 RoughEnvCfg.__post_init__ 中注册了 terrain scan sensor 和 height_scan observation term,你的 FlatEnvCfg 继承后如果只覆盖了 terrain cfg 但忘了删除 height_scan term,flat 训练会不必要地计算 raycast——不仅浪费计算,还可能引入 NaN(flat terrain 上 raycast 可能返回异常值)。
mjlab 采用 instance-based 配置——直接用 dataclass 实例赋值,没有 __post_init__ 副作用。从 rough 派生 flat 时,你显式指定要保留的 terms,不会有"不知不觉继承了不需要的 sensor"的问题。这与 Python 社区的"explicit is better than implicit"原则一致。
对于教学来说,instance-based 更容易理解——学生可以直接看到"这个 env 的 actor group 包含哪些 terms",不需要追溯继承链和 __post_init__ 执行顺序。
TorchArray 零拷贝桥。 mjlab 使用 TorchArray 将 MuJoCo Warp 的 GPU 数组直接映射为 PyTorch tensor,不需要 CPU↔GPU 数据传输。这意味着 observation term 的 func 可以直接从 Warp array 读取物理状态——没有 memcpy 开销。但这也带来一个调试陷阱:直接操作 Warp array 会同时修改对应的 PyTorch tensor,反之亦然。如果一个 observation term 无意中修改了传入的 tensor,后续 term 读到的值就是被修改过的——这类 bug 不会报错,只会导致 observation 数值异常。
Isaac Lab 在 PhysX 后端下通过 CUDA tensor 直接交互(因为 PhysX GPU pipeline 本身就输出 CUDA tensor),在概念上与 mjlab 的零拷贝类似。
Co-located definitions。 mjlab 把 ActionTermCfg 和对应的 ActionTerm 放在同一个文件中(如 src/mjlab/envs/mdp/actions/actions.py 同时包含 JointPositionActionCfg 和 JointPositionAction)。Isaac Lab 则可能把 cfg 和 term 分散在不同模块中。对源码阅读来说,co-located 意味着你看到 cfg 就能立即找到实现——不需要跳转到另一个文件。这在 5.11 节的源码阅读路线中会是一个显著的体验差异。
CLI 差异。 mjlab 使用 tyro(一个从 dataclass 自动生成 CLI 的库):uv run train Mjlab-Velocity-Flat-Unitree-Go1 --env.scene.num-envs 4096。Isaac Lab 使用传统的 argparse + hydra 风格:python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 --num_envs 4096。两者的参数覆盖逻辑不同——mjlab 的 tyro 可以覆盖任意嵌套 dataclass 字段(如 --agent.algorithm.clip-param 0.3),Isaac Lab 需要通过 --overrides 或修改 yaml 文件。在 smoke test 时,确认 CLI 覆盖实际生效——打印最终 cfg 验证。
compute 阶段 ⭐⭐
compute 阶段由 ObservationTermCfg.func(mjlab)或 ObsTerm.func(Isaac Lab)完成。func 接收 env 和 params,返回 shape 以 [num_envs, ...] 开头的 tensor。
mjlab 的通用 observation term 定义在 src/mjlab/envs/mdp/observations.py 中:base_lin_vel() 返回机体系线速度(3 维),joint_pos_rel() 返回关节位置相对默认值(12 维),last_action() 返回 action manager 保存的 raw action(12 维),generated_commands() 返回 command manager 的命令(3 维)。
Isaac Lab 的通用 observation term 定义在 omni.isaac.lab.envs.mdp.observations(或 isaaclab.envs.mdp.observations for kit-less)中,提供了功能对等的实现。两者的关键区别在于 Isaac Lab 的 observation function 通常接受 env: ManagerBasedRLEnv 参数,并通过 env.scene["robot"] 访问 articulation 数据,而 mjlab 通过 env.scene.robot(直接属性访问)。
任务特定的 observation 可以做复杂的信号变换。例如 mjlab velocity task 的 foot_contact_forces() 对 contact force 做 sign * log1p(abs(force)) 变换——把大范围力值(0 到几百牛)压缩到个位数级别,同时保留方向信息。这种工程处理在两个框架中都是常见模式。
这个变换值得深入理解它的每一个组成部分。为什么用 log1p 而不是 log?因为 log(0) = -inf,当脚完全腾空、接触力为零时会产生 -inf,进而导致 NaN 传播。log1p(x) = log(1+x) 保证 log1p(0) = 0——零力映射为零观测值,物理含义正确。为什么保留 sign?因为接触力有方向(如 \([0, 0, -100]\) N 表示竖直向下的支撑反力),方向信息对判断"脚在哪个方向受力"很重要。取 abs 后做 log 会丢失方向,所以用 sign 保存方向、abs + log1p 压缩幅度。
这种"非线性预处理"是 observation 设计中一个重要的工具。其他常见的预处理包括:角度用 \((\sin\theta, \cos\theta)\) 而非 \(\theta\) 表示(避免 \(\theta = \pm\pi\) 的不连续性)、距离用 \(\tanh(d / d_{\max})\) 压缩(把无界距离映射到 \([0, 1]\))、四元数用 6D rotation representation 替代(避免 antipodal 等价性问题)。
noise 与 clip 阶段 ⭐⭐
noise 阶段模拟传感器观测腐蚀。velocity actor terms 使用 UniformNoiseCfg,典型的噪声范围如下:
| 信号 | 噪声范围 | 单位 | 物理理由 |
|---|---|---|---|
| base linear velocity | \([-0.5, 0.5]\) | m/s | 状态估计器误差 |
| base angular velocity | \([-0.2, 0.2]\) | rad/s | IMU 偏置和漂移 |
| projected gravity | \([-0.05, 0.05]\) | 无量纲 | 姿态估计误差 |
| joint position | \([-0.01, 0.01]\) | rad | 编码器噪声 |
| joint velocity | \([-1.5, 1.5]\) | rad/s | 差分噪声放大 |
| height scan | \([-0.1, 0.1]\) | m | 深度传感器噪声 |
这些数值不能脱离单位理解——joint velocity 噪声看起来比 joint position 大 150 倍,但两者单位不同,而且速度估计(通常基于编码器差分)本身就比位置估计噪声大得多。如果新任务复制噪声范围,必须先检查信号的物理单位和真实传感器的噪声水平。
clip 阶段限制 observation 数值范围,防止极端值击穿网络输入。clip 应基于传感器的物理范围和训练日志的统计量来选择,不要无条件裁剪到 [-1, 1]——太紧会丢失重要信息,太松则无法发挥保护作用。
scale 阶段 ⭐⭐
term-level scale 与 model-level normalization 有本质区别:
| 特性 | term-level scale | model normalization |
|---|---|---|
| 是否固定 | 固定(设计时确定) | 依赖训练统计(running mean/std) |
| 有无物理含义 | 通常有(如除以最大距离) | 通常无 |
| 执行位置 | env 的 observation manager 中 | RSL-RL model / RL Games model 中 |
| 部署复现 | 写死同一比例 | 随模型状态或 ONNX graph |
| 调试难度 | 低 | 中等 |
本章建议的默认策略:第一,优先使用物理含义明确的 term-level scale;第二,检查 observation 各项范围是否在个位数级别;第三,只有尺度差异仍显著影响训练时再考虑 model normalization;第四,打开 normalization 后把部署边界写清楚。
delay 与 history 阶段 ⭐⭐⭐
delay 使用 DelayBuffer(mjlab 内置),delay_min_lag 和 delay_max_lag 的单位是 env step(不是 physics step)。velocity env 中 physics timestep 是 0.005 s,decimation 是 4,因此 env step 时间间隔为 \(0.005 \times 4 = 0.02\) s = 20 ms。如果 delay lag 是 3,对应延迟约为 \(3 \times 0.02 = 0.06\) s = 60 ms。
delay 和 noise 不能互相替代——这是一个常见的混淆。delay 改变的是时间索引("策略看到的是 60 ms 前的状态"),noise 改变的是数值("当前状态的测量值有随机误差")。对步态控制来说,相位滞后远比数值误差更危险:delay 会让策略在错误的时间做正确的动作,而 noise 只是让动作略有偏差。这就像音乐演奏中的区别——音准偏一点(noise)通常可以容忍,但节拍错了(delay)整首曲子就乱了。
history 使用 CircularBuffer。history_length 表示保留多少帧。如果 flatten_history_dim=True,history 被展平成 [num_envs, obs_dim * history_length]。CircularBuffer 有 backfill 行为——某个 env reset 后,下一次 append 用第一帧填满历史,防止 episode 开头出现垃圾值。
history 维度预算:假设 actor observation 维度 48,history_length=4 且展平后输入变为 192。第一层参数从 \(48 \times 512 = 24576\) 变为 \(192 \times 512 = 98304\)——仅第一层就增加四倍。因此 history 长度应从 2-4 开始,不要一开始设 10。
Isaac Lab 的 history 直接用 ObservationTermCfg.history_length(必要时配合 flatten_history_dim)即可,无需自定义 buffer;但如果需要 observation delay,则常见做法是借助 DelayBuffer 工具、在自定义 observation function/modifier 中维护 buffer,或使用社区扩展包。这种差异意味着:如果你的任务需要 obs delay 作为核心功能,mjlab 的配置化方式更方便;history 则两个框架的 observation 管线几乎等价。
自定义 obs term 的编写模式 ⭐⭐
编写自定义 observation term 的模式在两个框架中类似:
mjlab 完整示例——足端高度 privileged observation:
# src/mjlab/tasks/velocity/mdp/observations.py
import torch
from mjlab.envs.mdp.observations import ObsTermBase
class FootHeightObs(ObsTermBase):
"""足端离地高度——典型的 privileged signal(仅给 critic)。
返回 shape: [num_envs, num_feet],单位 meter。
不适合 actor:真实机器人没有直接测量足端离地高度的传感器。
"""
def compute(self, env, **kwargs) -> torch.Tensor:
# 从 scene 中获取 robot entity
robot = env.scene.robot
# 获取足端在世界坐标系中的位置(shape: [num_envs, num_feet, 3])
foot_pos_w = robot.data.body_pos_w[:, self.params["foot_body_ids"]]
# 提取 z 分量作为离地高度(假设地面在 z=0)
foot_heights = foot_pos_w[:, :, 2] # [num_envs, num_feet]
return foot_heights
Isaac Lab 等价实现:
# source/isaaclab_tasks/locomotion/velocity/mdp/observations.py
import torch
from isaaclab.envs import ManagerBasedRLEnv
from isaaclab.managers import ObservationTermCfg
def foot_height(env: ManagerBasedRLEnv, asset_cfg) -> torch.Tensor:
"""足端离地高度(privileged,仅给 critic)。"""
asset = env.scene[asset_cfg.name]
foot_pos_w = asset.data.body_pos_w[:, asset_cfg.body_ids]
return foot_pos_w[:, :, 2] # [num_envs, num_feet]
两者的关键差异:(1) mjlab 使用类继承模式(ObsTermBase),Isaac Lab 使用函数模式;(2) mjlab 通过 env.scene.robot 直接属性访问,Isaac Lab 通过 env.scene[cfg.name] 字典访问;(3) mjlab 的 params 通过 self.params 获取,Isaac Lab 通过函数参数 asset_cfg 获取。但核心逻辑完全相同——从 scene 中读取物理状态并返回 [num_envs, dim] tensor。
在 cfg 中注册自定义 term:
# mjlab 配置
critic_group = ObservationGroupCfg(
enable_corruption=False,
terms={
# ... 已有 terms ...
"foot_height": ObsTermCfg(
func=FootHeightObs,
params={"foot_body_ids": [5, 10, 15, 20]},
# 无 noise(privileged 不需要模拟传感器噪声)
),
},
)
# Isaac Lab 配置
@configclass
class CriticObsCfg(ObsGroup):
# ... 已有 terms ...
foot_height = ObsTerm(
func=foot_height,
params={"asset_cfg": SceneEntityCfg("robot", body_names=["FL_foot", "FR_foot", "RL_foot", "RR_foot"])},
)
编写 observation term 的三条铁律:
第一,term 必须是 无副作用 的纯函数——只读取状态,不修改任何数据。如果你的 term 修改了 robot.data 中的值,后续 term 读到的就是被污染的数据。在 mjlab 的 TorchArray 零拷贝桥下,修改 PyTorch tensor 会直接影响底层 Warp array——这个 bug 不会报错,只会导致 observation 数值偏移。
第二,返回的 tensor 第 0 维必须是 num_envs。如果你不小心返回了 [dim](忘了 batch 维),concatenate 时会报 shape mismatch 或者产生维度歧义。自检方法:在 compute 后立即 assert result.shape[0] == env.num_envs。
第三,避免在 compute 中做 不可并行化 的操作(如 Python for 循环遍历 env)。所有操作应该是 batch tensor 操作。这在 4096 环境下是性能瓶颈——一个 Python for 循环可能把 throughput 从 10000 steps/s 降到 100 steps/s。
完整的 velocity task obs cfg 双框架对比 ⭐⭐⭐
以下是 Go1 velocity flat 任务的完整 observation 配置,在两个框架中并排展示核心差异:
# ============= mjlab 版本 =============
# src/mjlab/tasks/velocity/velocity_env_cfg.py(简化)
actor_group = ObservationGroupCfg(
enable_corruption=True, # actor 加噪声
terms={
"base_lin_vel": ObsTermCfg(
func=base_lin_vel,
noise=UniformNoiseCfg(n_min=-0.5, n_max=0.5),
),
"base_ang_vel": ObsTermCfg(
func=base_ang_vel,
noise=UniformNoiseCfg(n_min=-0.2, n_max=0.2),
),
"projected_gravity": ObsTermCfg(
func=projected_gravity,
noise=UniformNoiseCfg(n_min=-0.05, n_max=0.05),
),
"joint_pos_rel": ObsTermCfg(
func=joint_pos_rel,
noise=UniformNoiseCfg(n_min=-0.01, n_max=0.01),
),
"joint_vel": ObsTermCfg(
func=joint_vel_rel,
noise=UniformNoiseCfg(n_min=-1.5, n_max=1.5),
),
"last_action": ObsTermCfg(func=last_action),
"command": ObsTermCfg(func=generated_commands),
},
)
critic_group = ObservationGroupCfg(
enable_corruption=False, # critic 不加噪声
terms={
# 复制 actor 的所有 terms(但无 noise)
"base_lin_vel": ObsTermCfg(func=base_lin_vel),
"base_ang_vel": ObsTermCfg(func=base_ang_vel),
# ... 其他 actor terms 无 noise 版本 ...
# 额外 privileged terms
"foot_contact_forces": ObsTermCfg(func=foot_contact_forces),
"foot_height": ObsTermCfg(func=foot_height),
},
)
# ============= Isaac Lab 版本 =============
# source/isaaclab_tasks/.../velocity_env_cfg.py(简化)
@configclass
class ObservationsCfg:
@configclass
class PolicyCfg(ObsGroup):
enable_corruption = True
base_lin_vel = ObsTerm(
func=mdp.base_lin_vel,
noise=Unoise(n_min=-0.1, n_max=0.1),
)
base_ang_vel = ObsTerm(
func=mdp.base_ang_vel,
noise=Unoise(n_min=-0.2, n_max=0.2),
)
projected_gravity = ObsTerm(
func=mdp.projected_gravity,
noise=Unoise(n_min=-0.05, n_max=0.05),
)
joint_pos = ObsTerm(
func=mdp.joint_pos_rel,
noise=Unoise(n_min=-0.01, n_max=0.01),
)
joint_vel = ObsTerm(
func=mdp.joint_vel_rel,
noise=Unoise(n_min=-1.5, n_max=1.5),
)
actions = ObsTerm(func=mdp.last_action)
commands = ObsTerm(func=mdp.generated_commands,
params={"command_name": "base_velocity"})
@configclass
class CriticCfg(ObsGroup):
enable_corruption = False
# ... 同 PolicyCfg 但无 noise ...
# 额外 privileged
foot_contact = ObsTerm(func=mdp.contact_forces,
params={"sensor_cfg": SceneEntityCfg("contact_forces",
body_names=".*_foot")})
policy: PolicyCfg = PolicyCfg()
critic: CriticCfg = CriticCfg()
注意配置模式的核心差异:mjlab 使用 Python dict 定义 terms(terms={"name": cfg}),Isaac Lab 使用 class attribute(name = ObsTerm(...))。这意味着在 mjlab 中 term 顺序由 dict 插入顺序决定(Python 3.7+ dict 保持插入序),在 Isaac Lab 中由 class attribute 的定义顺序决定。两种方式都是确定性的,但迁移时需要确认顺序一致——因为 concatenation 后的 tensor 维度切片依赖顺序。
# mjlab 模式
def my_custom_obs(env: ManagerBasedRlEnv, sensor_name: str) -> torch.Tensor:
"""返回 [num_envs, obs_dim] 的张量。"""
sensor_data = env.scene.sensors[sensor_name].data
# 自定义处理...
return processed_data # shape: [num_envs, dim]
# 在 env_cfg 中注册
class MyObsCfg:
actor = ObservationGroupCfg(
enable_corruption=True,
terms={
"my_obs": ObservationTermCfg(
func=my_custom_obs,
params={"sensor_name": "depth_camera"},
noise=UniformNoiseCfg(n_min=-0.1, n_max=0.1),
clip=(-5.0, 5.0),
),
},
)
# Isaac Lab 模式
def my_custom_obs(env: ManagerBasedRLEnv, sensor_name: str) -> torch.Tensor:
sensor_data = env.scene.sensors[sensor_name].data
return processed_data # shape: [num_envs, dim]
# 在 ObservationsCfg 中注册
@configclass
class ObservationsCfg:
@configclass
class PolicyCfg(ObsGroup):
my_obs = ObsTerm(
func=my_custom_obs,
params={"sensor_name": "depth_camera"},
noise=UniformNoiseCfg(n_min=-0.1, n_max=0.1),
clip=(-5.0, 5.0),
)
policy: PolicyCfg = PolicyCfg()
两者的核心模式完全一致:function signature → registration → noise/clip/scale config。差异在于 Isaac Lab 使用 @configclass 装饰器和嵌套 class 风格,mjlab 使用 dict-based 风格。
NaN 检测策略 ⭐
mjlab 的 ObservationGroupCfg.nan_policy 支持 disabled(最快,默认)、warn、sanitize 和 error 四种模式。调试新任务时用 warn 或 error + nan_check_per_term=True 逐项检查定位来源。正式大规模训练关闭——逐项检查有可观的计算开销。Isaac Lab 中类似功能通过 debug_vis 和自定义 assertion 实现。
NaN 传播链的诊断需要一套系统方法。NaN 在 RL 训练中几乎总是从 env 端产生,然后通过 observation → network → action → env 的循环不断扩散。典型的传播路径是:
物理引擎 solver 不稳定 → state NaN → observation term NaN
→ 网络输出 NaN → action NaN → 更大的物理冲击 → 更多 NaN
定位 NaN 的策略是"从后往前追踪":
- 先检查 observation:在
observation_manager.compute()后检查每个 term 是否含 NaN。如果某个 term 有 NaN,问题在 env 或物理引擎层面。 - 再检查 action:如果 observation 干净但 action 有 NaN,问题在网络(可能是梯度爆炸导致权重 NaN)。
- 最后检查 reward/done:如果 observation 和 action 都干净但训练仍然不稳定,检查 reward 计算是否产生了 Inf(比如除以零、log(0))。
mjlab 的 sanitize 模式会把 NaN 替换为 0——这对防止训练崩溃有用,但会掩盖根因。建议在开发阶段用 error 模式(NaN 直接 crash,便于定位),在已知偶发 NaN 的大规模训练中用 warn 或 sanitize。
Isaac Lab 中,可以在自定义 observation function 中加入显式的 torch.isnan().any() 检查。一个更优雅的方式是使用 PyTorch 的 torch.autograd.set_detect_anomaly(True) 开启异常检测——但这会显著降低速度,只适合调试。
mjlab 的三层 NaN 防护体系 ⭐⭐⭐
mjlab 提供了一套比 nan_policy 更强大的 NaN 诊断工具链,分三个层次:
第一层:NaN Guard(训练时捕获)。 通过 --enable-nan-guard True 启动训练,在每个 physics step 后检测 NaN。一旦发现 NaN,立即保存当前的完整 MjData 状态(包括所有关节位置/速度/力、contact 信息、actuator 状态)到 .npz 文件,同时保存对应的 .mjb model 文件。这让你能在事后精确重现 NaN 发生的物理场景。
# 开启 NaN guard 训练
uv run train Mjlab-Velocity-Flat-Unitree-G1 \
--env.scene.num-envs 16 --agent.max-iterations 2 \
--enable-nan-guard True --agent.logger tensorboard
第二层:NaN Termination(训练时止血)。 检测到 NaN 后 reset 该 environment,让训练继续而不崩溃。这是止血措施,不是治本方法。一个关键原则:如果 NaN 正好发生在任务关键时刻(抓取、落脚、碰撞),policy 会学到"避开所有可能触发 NaN 的激烈动作"——本质上是在学习一种保守但错误的策略。如果环境每 100 步就 NaN 并 reset,有效数据量大幅减少,训练质量严重下降。
第三层:viz-nan(事后回放)。 训练结束后用 viser(mjlab 的 web-based 可视化工具)回放 NaN dump,逐 step、逐 env 查看物理状态。你可以在浏览器中用 slider 切换 step 和 environment,观察 NaN 发生前几帧的物理行为——通常能看到明显的异常(如关节突然加速、穿透碰撞、力矩爆炸)。
# 查看 NaN dump
uv run viz-nan /tmp/mjlab/nan_dumps/nan_dump_latest.npz
NaN 在机器人 RL 中的三个最常见根因(按频率排序):(1) action scale 过大导致力矩爆炸(解决:降低 scale);(2) nconmax/njmax 溢出导致 contact solver 失败(解决:增大 buffer 或减少碰撞 geom);(3) 关节 armature 过小导致惯性矩阵接近奇异(解决:增大 armature 到 0.01 以上)。Isaac Lab 中没有等价的 NaN Guard/viz-nan 工具——需要通过自定义 callback 和 TensorBoard 日志实现类似功能。
处理链的性能考量 ⭐
当环境数量很大(如 4096+)时,observation 管线的计算成本不可忽略。几个性能优化的经验:
term-level noise 使用 torch.rand 而非 torch.randn(Uniform 比 Gaussian 快约 20%)。如果 noise 是 per-env 独立的(通常如此),不需要在每个 term 单独调用 RNG——可以批量生成一次随机数然后切片分配。但在大多数情况下,noise 的开销远小于 sim.step() 的物理仿真开销,不值得过度优化。
history 的内存开销比计算开销更需要关注。history_length=4 意味着 observation manager 需要维护 4 帧缓存。如果同时有 4096 个环境、每帧 48 维、float32,单个 history buffer 占 \(4 \times 4096 \times 48 \times 4 = 3.15\) MB——不多。但如果 obs_dim=480(含 height scan),history_length=4,buffer 变为 \(4 \times 4096 \times 480 \times 4 = 31.5\) MB,在多 group 共存时需要注意总显存。
⚠️ 常见陷阱
⚠️ 编程陷阱:修改 decimation 后忘记更新 delay lag 的物理含义。 如果 decimation 从 4 改成 2,env step 时间从 20 ms 变成 10 ms。同样 lag=3 的延迟从 60 ms 变成 30 ms。如果目标是模拟 60 ms 延迟,需要把 lag 改成 6。
💡 概念误区:认为 history 可以完全替代 RNN。 history 只给 MLP 固定长度窗口。对需要长时间记忆的任务,history 可能不够。但 history 的部署复杂度远低于 RNN——这是工程权衡,不是理论局限。
🧠 思维陷阱:看到 observation 数值范围差异大就立即打开 model-level normalization。 Go1 velocity 默认关闭 normalization——因为 observation 已经通过相对量、term-level scale 和合理噪声设计做了预处理。关闭 normalization 简化了 ONNX 和部署流程。
练习
- [分析题] 假设 observation 管线的顺序被改为 compute → scale → noise → clip → history → delay。列出至少三个这种错误顺序会导致的具体问题。
- [计算题] velocity env 的 physics timestep 为 0.005 s,decimation 为 4。如果真实 IMU 延迟为 30 ms,应该设置多少 delay lag?如果 decimation 改为 8,lag 需要如何调整?
- [编程题] 在 mjlab 中编写一个自定义 observation term,在
compute()中打印 step counter 和当前 action。运行 2 步,验证 observation 反映的是 action 执行后的状态。 - [调试题] 在 mjlab 中用
--enable-nan-guard True运行一个 action scale 故意设为 5.0(过大)的 velocity flat 训练,观察是否产生 NaN dump。如果产生了,用viz-nan回放,分析 NaN 发生前的物理状态(哪些关节角度/速度异常?力矩是否饱和?)。 - [跨框架对比题] 分别在 mjlab 和 Isaac Lab 中运行 velocity flat 的 zero agent,打印 actor observation 的每个 term 值。对比两个框架的数值差异——重点关注
projected_gravity和joint_pos_rel是否一致。如果不一致,分析可能的原因(坐标系约定、关节排序、默认关节角差异)。
上节完成了处理管线的精读——从 compute 到 history 的六步链,以及 mjlab 的 NaN 三层防护体系和双框架配置体系的深层差异。但管线只定义了"信号如何处理",还没有回答"哪些信号给谁看"。actor 和 critic 应该如何划分信息边界?为什么 critic 可以看到 actor 看不到的信号?这个问题的答案直接决定了策略能否从仿真成功迁移到真实机器人——它是 privileged learning 的核心,也是 sim-to-real 成功的第一道关卡。
5.4 Actor/Critic 非对称观测与 Privileged Learning ⭐⭐⭐
这一节解决什么问题:理解 asymmetric actor-critic 的数学原理,掌握双框架中 observation group 的组织方式,学会正确划分 actor 和 critic 的信息边界。
从 PPO 的 advantage 估计谈起 ⭐⭐
要理解为什么 critic 可以"看更多",需要回到 PPO 的核心机制。PPO 更新 actor 时需要 advantage 估计 \(\hat{A}_t\),它衡量"在状态 \(s_t\) 下采取动作 \(a_t\) 比平均好多少"。实际中通常用 GAE(Generalized Advantage Estimation)近似:
关键观察:advantage 的质量直接取决于 value function \(V\) 的估计精度。如果 \(V\) 很准确,\(\delta_t\) 接近真实的 TD 误差,advantage 估计方差小,actor 更新方向稳定;如果 \(V\) 很不准确,advantage 充满噪声,actor 更新像随机游走。
核心洞察:actor 和 critic 的输入可以不同。actor 学习 \(\pi(a_t | o_t^{\text{actor}})\),其中 \(o_t^{\text{actor}}\) 只包含部署可得信号;critic 学习 \(V(o_t^{\text{critic}})\),其中 \(o_t^{\text{critic}}\) 可以包含训练时额外可获得的信号。critic 不需要部署——它只在训练中输出 value。因此 critic 输入更丰富是"免费"的——它改善训练信号质量,却不改变部署时 actor 的输入要求。
这种设计与监督学习中的 teacher-student 框架有相似的直觉——teacher(critic)可以看到更完整的上下文,student(actor)最终只能依赖有限输入。但两者也有本质区别:在 teacher-student 中,teacher 直接输出 student 应该学习的标签;而在 asymmetric actor-critic 中,critic 不是直接告诉 actor 该怎么做——它只提供更准确的 value 估计,让 policy gradient 的方向更可靠。
如果不用 asymmetric actor-critic 会怎样?你有两个选择。选择一:actor 和 critic 看相同的、仅包含部署可得信号的 observation——critic 的 value 估计可能较差,训练效率低。对简单 flat terrain locomotion,这通常没问题;但对 rough terrain 或 manipulation,缺少 privileged 信息的 critic 可能导致训练不收敛。选择二:actor 和 critic 都看完整信息——训练可能很快,但 actor 依赖了部署不可得的信号,sim-to-real 会失败。asymmetric actor-critic 是这两个极端之间的最佳折中。
本质洞察:privileged learning 的本质不是让策略拥有特权,而是让训练中的误差信号(value function、advantage)拥有特权。actor 仍然必须在部署可得的信息下行动。critic 用额外信息把"这个动作到底好不好"的估计做得更准——这就像一个教练在训练时可以看慢动作回放和数据分析,但运动员在比赛中只能依靠自己的感官做决策。教练的分析不是直接告诉运动员"现在迈左脚",而是在训练后给出更准确的反馈"上次那个动作效果很好/不好"——让运动员自己调整策略。
双框架中的 group 组织 ⭐⭐
mjlab 的 observation 按 group 组织。ObservationGroupCfg 包含一组 term,velocity task 有两个 group:actor 和 critic。RSL-RL config 中默认的 obs_groups 映射是 {"actor": ("actor",), "critic": ("critic",)},告诉 runner 把 env 返回的 actor group 送给 actor 网络,critic group 送给 critic 网络。
Isaac Lab 使用 ObservationsCfg 的嵌套 @configclass,默认的 group 名是 policy 和 critic(而非 mjlab 的 actor 和 critic)。RSL-RL runner 通过 config 中的映射来对接。
# Isaac Lab 的 ObservationsCfg 结构
@configclass
class ObservationsCfg:
@configclass
class PolicyCfg(ObsGroup):
# 部署可得信号
base_ang_vel = ObsTerm(func=mdp.base_ang_vel, noise=...)
joint_pos = ObsTerm(func=mdp.joint_pos_rel, noise=...)
# ...
@configclass
class CriticCfg(ObsGroup):
# 包含 PolicyCfg 的所有 term + 额外 privileged
base_ang_vel = ObsTerm(func=mdp.base_ang_vel) # 无 noise
joint_pos = ObsTerm(func=mdp.joint_pos_rel)
foot_contact = ObsTerm(func=mdp.contact_forces, ...) # privileged
# ...
policy: PolicyCfg = PolicyCfg()
critic: CriticCfg = CriticCfg()
注意 Isaac Lab 使用 class-inherited config 模式——PolicyCfg 和 CriticCfg 是独立的嵌套类,每个类中的 ObsTerm 是类属性而非实例字段。这意味着如果你想从 PolicyCfg 继承创建一个变体(如移除某个 term),需要在子类中重新定义所有要保留的属性——因为 Python 的类继承不支持"删除父类属性"。mjlab 的 instance-based config 通过直接赋值避免了这个问题(5.3 节有详细对比)。
这个命名对齐非常关键。如果 env 返回的 group 叫 policy,但 runner 查找 actor,训练启动时会报 key error。如果 env 返回 actor,但 runner 配置把 critic 也指向 actor,critic 就无法使用 privileged terms。
| 概念 | mjlab group 名 | Isaac Lab group 名 | RSL-RL 映射 key |
|---|---|---|---|
| 部署用 actor 观测 | actor |
policy |
框架各自映射 |
| 训练用 critic 观测 | critic |
critic |
一致 |
obs_groups 路由机制深度解析 ⭐⭐⭐
理解 obs_groups 的数据流对于避免一类极难发现的隐蔽 bug 至关重要。RslRlVecEnvWrapper(mjlab)或对应的 Isaac Lab wrapper 是 env 和 RL 算法之间的唯一桥梁。wrapper 的 get_observations() 方法调用 observation_manager.compute() 获取一个 dict(key 是 group 名,value 是 tensor),然后封装为 TensorDict 传给 RSL-RL runner。
runner 通过 obs_groups 配置决定如何使用这个 dict:
# mjlab 默认配置
obs_groups: dict[str, tuple[str, ...]] = {
"actor": ("actor",), # runner 的 actor 从 env 的 "actor" group 取数据
"critic": ("critic",), # runner 的 critic 从 env 的 "critic" group 取数据
}
tuple 的设计意味着一个模型可以接收多个 group 的拼接。例如 "critic": ("actor", "privileged") 表示 critic 接收 actor observation 和 privileged observation 的拼接——这在某些分阶段设计中很有用。
本质洞察:wrapper 不是格式转换器,而是"环境语义进入算法语义的边界"。这与 C++ 中的 ABI(Application Binary Interface)有精确类比——两个模块都能编译不代表能安全链接,函数签名只是表面,内存布局和异常传播才是深层接口。
obs_groups、time_outs、checkpoint state 就是 RL 训练的深层 ABI。
三种隐蔽 bug 及其诊断:
Bug 1:group 名不匹配。 如果 env 返回 {"policy": tensor, "critic": tensor}(Isaac Lab 风格),但 obs_groups 仍然是 {"actor": ("actor",)}——runner 查找 "actor" 时报 KeyError。这类错误在训练启动第一步就暴露,容易修复。
Bug 2:critic 指向了 actor group。 如果 obs_groups = {"actor": ("actor",), "critic": ("actor",)}——critic 使用和 actor 相同的 observation,丢失了 privileged 信息的好处。训练不会报错,不会崩溃,但训练效率下降——advantage 方差增大,收敛变慢。这类 bug 可能要训练 500+ iteration 后才发现"怎么比预期慢这么多"。诊断方法:在训练启动时打印 actor 和 critic 的实际 observation 维度——如果维度相同,很可能 privileged terms 没有进入 critic。
Bug 3:新增 group 未注册到 obs_groups。 如果你为 distillation 新增了 "teacher" observation group,但忘了在 runner config 中添加 "teacher": ("teacher",) 映射——runner 根本不会读取这个 group。env 端计算了这些 observation 但没有人使用,浪费计算且功能缺失。诊断方法:对比 observation_manager.group_obs_dim 的 keys 和 obs_groups 的 values。
RSL-RL 5.x 模型配置与 obs 接口 ⭐⭐
RSL-RL 在 4.0→5.x 版本中经历了重要的 API 重构(Schwarke, Mittal, Rudin, Hoeller, Hutter; arXiv 2509.10771),直接影响 obs/action 接口的配置方式。了解这些变化对于正确配置训练管线至关重要。
核心变更:旧版的统一 RslRlPpoActorCriticCfg 已废弃,改为分别配置 actor 和 critic 的 RslRlMLPModelCfg。这个变化的工程动机是:actor 和 critic 的网络结构、normalization 设置、输入维度经常不同——强制用同一个 config 类描述两者导致大量 if-else 分支。
# RSL-RL 5.x 配置(Go1 velocity 典型值)
actor = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
distribution_cfg=GaussianDistributionCfg(init_std=1.0),
)
critic = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
# critic 没有 distribution_cfg——输出 deterministic value
)
init_std=1.0 与 action scale 的耦合。init_std 是 Gaussian policy 在 raw action 空间的初始标准差(无量纲)。训练初期采样值约在 \(\pm 2\sigma = \pm 2.0\) 范围。结合 5.5 节的 action scale:如果 action_scale=0.25,初始探索的关节位置变化幅度约 \(\pm 2.0 \times 0.25 = \pm 0.5\) rad。对 Go1 来说这是合理的探索范围——足以产生步态但不至于超限位。如果 init_std=0.1(太小),初始探索幅度只有 \(\pm 0.05\) rad——策略可能困在"站着不动"的局部最优。如果 init_std=3.0(太大),初始动作幅度 \(\pm 1.5\) rad,频繁超出关节限位导致 crash。
obs_normalization 与部署的交互。velocity baseline 当前设 obs_normalization=False,因为 actor obs 已在 term 级别做了尺度处理。如果打开 normalization,running statistics 会成为训练状态的一部分——checkpoint 必须保存它们,ONNX 必须包含等价 normalizer,部署端如果自己归一化必须精确匹配训练 statistics。
| 情况 | obs_normalization 建议 | 理由 |
|---|---|---|
| 输入已按物理尺度缩放(如 velocity baseline) | False | 简化部署 |
| 量纲差异巨大(如力 + 角度 + 高度扫描混合) | True | 避免大数值淹没小信号 |
| vision/depth 特征 | True | 像素值分布与物理量差异大 |
| 部署简单优先 | False | 避免 normalizer 同步问题 |
| 只给 critic 打开 | True(仅 critic) | 不影响部署,改善 value 估计 |
group 级 corruption 与 privileged signal 边界 ⭐⭐
两个框架都支持 group 级的 noise 开关:
- mjlab:
ObservationGroupCfg.enable_corruption = True/False - Isaac Lab:
ObsGroup.enable_corruption = True/False
velocity task 中 actor group 打开 corruption(模拟传感器噪声),critic group 关闭 corruption(保持稳定的 value 估计)。这体现了一个重要原则:actor 需要对传感器噪声鲁棒(因为真实传感器有噪声),critic 更需要稳定地估计训练价值(因为 noisy value 估计会增加 advantage 方差)。
在 play 模式(策略评估/可视化)下,corruption 的处理需要特别注意。mjlab 的 play 模式默认关闭 actor corruption——这是为了在可视化中看到"策略在理想输入下的最佳表现",方便对比不同 checkpoint。但如果你想评估策略对噪声的鲁棒性,应该在 play 时显式打开 corruption。Isaac Lab 的 play 模式行为类似,但具体的默认值可能因版本不同而有差异——阅读 play 脚本中的 env_cfg 覆盖逻辑来确认。
一个常见的混淆是:play 时关闭了 corruption,看到策略表现很好;但部署到真机后性能下降——这不是 sim-to-real gap 的问题,而是因为 play 评估条件比真实部署更宽松。正确的鲁棒性评估应该在打开 corruption 的 play 模式下进行。
Play 模式的三个关键修改及其影响。除了关闭 corruption,play 模式还会修改另外两个环境配置。第一,移除 push_robot event——训练时施加的随机外力扰动在评估时被关闭,因此 play 中看到的行为比训练时更稳定。第二,延长 episode length(或设为无限)——策略可能在训练的 20 秒 timeout 后才表现出漂移或能量积累等长期问题,play 能暴露这些。
这三个修改意味着 play 结果不能直接与训练时的 rollout reward 对比——play 更接近"理想化的部署条件"。要评估真实部署场景下的鲁棒性,应该创建一个 "deployment evaluation" 配置,打开 corruption 但关闭 push(真实机器人没有随机推力),使用真实的 episode length。这在两个框架中都可以通过继承 play cfg 并覆盖 corruption 设置来实现。
本质洞察:play 模式评估的是"策略在最佳条件下能做到什么",部署关心的是"策略在最差条件下还能做到什么"。这两个问题同样重要——前者确定性能上限,后者确定安全下限。一个好的 obs/action 设计应该让两者的差距尽可能小。
合理的 privileged signal 应满足三个条件:第一,描述当前状态或当前环境参数(不泄漏未来);第二,训练时可靠可得;第三,不会导致 actor 在部署时也需要获得它。
| privileged signal | 适合 critic 的原因 | 不适合 actor 的原因 |
|---|---|---|
| contact force | 当前接触状态强相关 | 真实力传感不等价于仿真 |
| terrain exact height | 价值估计有用 | 部署时视觉/LiDAR 精度不同 |
| object true pose | 操作任务价值估计有用 | 真实系统需视觉估计 |
| randomized mass/friction | 解释动力学差异 | 真实系统无法直接知道 |
| foot air time | 步态阶段信息强 | 部署可由 contact 估计但质量不稳定 |
velocity task 完整 obs 设计 ⭐⭐
以 Go1 速度跟踪任务为例,展示 5.2 节五条原则的完整应用。这个设计在 mjlab 和 Isaac Lab 中语义一致,只是 cfg 语法不同。
条件策略必须看到 command。 速度跟踪不是"学会走路"——它是"在给定命令下选择合适的动作"。命令是策略输入的一部分。设 observation 为 \(o_t\),command 为 \(c_t\),策略应近似 \(a_t = \pi(o_t, c_t)\)。如果把 command 删除,同一个 \(o_t\) 可能对应前进和后退两个冲突目标,策略被迫学平均动作。在 mjlab 中,velocity command 通过 generated_commands() term 加入,它从 env.command_manager 读取命令——这要求 command manager 先于 observation manager 加载。Isaac Lab 中语义完全对等。
坐标系应服务于策略学习。 observation 不是越接近世界坐标越好。机器人策略更容易学习 body frame 中的相对量——速度任务中 base velocity 使用机体系,manipulation 中目标距离转换到 base frame。如果使用世界坐标,MLP 必须自己学会平移不变性——低维 MLP 不天然具备这个不变性。把输入改成相对坐标是把几何先验从数据驱动变为结构驱动。这与计算机视觉中"将特征对齐到规范坐标系"的思想如出一辙——Spatial Transformer Network 就是做同样的事情,但它需要可微分变换层来学习对齐,而我们可以通过观测设计直接完成。
| actor term | 维度 | 部署来源 | 坐标系 | mjlab/Isaac Lab |
|---|---|---|---|---|
| base_lin_vel | 3 | 状态估计 | body frame | 均有 |
| base_ang_vel | 3 | IMU | body frame | 均有 |
| projected_gravity | 3 | IMU 姿态估计 | body frame | 均有 |
| joint_pos (rel) | 12 | 编码器 | joint space | 均有 |
| joint_vel | 12 | 编码器差分 | joint space | 均有 |
| last_action | 12 | 控制器内部 | raw action space | 均有 |
| command (twist) | 3 | 上层命令 | body frame | 均有 |
| height_scan (rough only) | N | 深度/LiDAR | body frame | 均有 |
| critic 额外 term | 维度 | 为什么适合 critic | 框架支持 |
|---|---|---|---|
| foot_height | 4 | 步态阶段价值估计 | mjlab ✓ / Isaac Lab ✓ |
| foot_air_time | 4 | 步态节律判断 | mjlab ✓ / Isaac Lab ✓ |
| foot_contact | 4 | 支撑状态判断 | mjlab ✓ / Isaac Lab ✓ |
| foot_contact_forces | 12 | 冲击和支撑质量评估 | mjlab ✓ / Isaac Lab ✓ |
| clean height_scan | N | 降低 terrain value 噪声 | mjlab ✓ / Isaac Lab ✓ |
velocity action 使用 JointPositionActionCfg 控制所有 12 个 actuator(Go1 有 12 个驱动关节),使用 default offset,scale 由 robot-specific cfg 覆盖。policy 输出是以默认姿态为中心的关节位置目标偏移坐标,底层 PD 控制器负责追踪。这比直接 torque control 更稳定,也更容易控制探索范围。
从 rough cfg 派生 flat cfg 时的注意事项。 flat 地形不需要 terrain scan sensor,也不需要 height_scan observation term。如果忘记删除,flat 训练会不必要地计算 raycast,浪费计算时间。在两个框架中,flat cfg 通常继承 rough cfg 然后覆盖特定字段——确保继承后的 cfg 中不包含不需要的 sensor 和 term。
一个完整的数值示例 ⭐⭐
为了让 obs 设计不停留在表格层面,我们跟踪一个具体的 env step 中每个 actor term 的数值范围。假设 Go1 正在平坦地面上以 \(v_{cmd} = [0.5, 0.0, 0.0]\) m/s 前进(纯前向),当前处于右前腿摆动相(RF swing phase):
term typical values range context
─────────────────────────────────────────────────────────────────────────
base_lin_vel [0.48, 0.02, -0.01] ~[-2, 2] m/s
base_ang_vel [0.03, -0.05, 0.01] ~[-3, 3] rad/s
projected_gravity [0.01, 0.02, -0.98] ~[-1, 1] (unit vec)
joint_pos_rel [0.12, -0.03, 0.08, ..., -0.15] ~[-0.5, 0.5] rad
joint_vel [1.2, -0.8, 0.3, ..., -2.1] ~[-10, 10] rad/s
last_action [0.15, -0.04, 0.09, ..., -0.18] ~[-1.5, 1.5] (raw)
command [0.5, 0.0, 0.0] ~[-2, 2] m/s or rad/s
注意数值范围的差异:projected_gravity 在 \([-1, 1]\),joint_vel 在 \([-10, 10]\)——相差一个数量级。这就是为什么 term-level scale 或 model normalization 很重要。如果不做任何处理,网络第一层的梯度会被大数值信号主导,小数值信号的学习信号被淹没。velocity baseline 的做法是选择物理含义明确的 term(相对量、body frame),让多数 term 自然落在个位数范围内,不依赖 running normalization。
reset 后第一帧的特殊性。 在 env reset 后,joint_pos_rel 应接近全零(回到默认姿态),base_lin_vel 接近零(从静止开始),last_action 也应为零(没有上一拍动作)。如果 reset 后这些值不符合预期,通常说明 reset 逻辑有误——比如只 reset 了位置没 reset 速度,或者 last_action buffer 没有清零。在 mjlab 中可以用 play --agent zero --num-envs 1 观察 reset 后第一帧的 obs 值来验证。
⚠️ 常见陷阱
⚠️ 编程陷阱:actor 和 critic group 共享 term cfg 对象导致交叉污染。 mjlab 的 ObservationManager._prepare_terms() 会深拷贝 term cfg。如果 critic 覆盖了某个 term 的 noise 设置,深拷贝确保不会影响 actor 的同名 term。Isaac Lab 使用 @configclass 的实例化机制提供类似隔离。自检方法:打印 actor 和 critic 的同名 term cfg,确认它们是不同对象。
💡 概念误区:认为 critic 信息越多训练一定越快。 如果 critic 输入维度过高(比如给了原始高分辨率深度图),critic 网络本身可能难以拟合,反而拖慢训练。privileged 信号应该是低维、高信息密度的。
🧠 思维陷阱:认为"给 critic 越多信息训练一定越好"。 如果给 critic 的信号泄漏了未来信息(比如下一步的 reward 真值),critic 学到的 value function 可能破坏因果结构。合理的 privileged signal 应描述"当前状态",不是"未来会发生什么"。
练习
- [推导题] 写出 GAE advantage 的完整展开式(取 \(T-t=3\) 步)。解释 \(\lambda=1\) 和 \(\lambda=0\) 分别对应什么估计方式,它们对 value function 精度的依赖有何不同。
- [设计题] 为一个 rough terrain locomotion 任务设计 actor 和 critic 的 observation group。actor 不超过 50 维,critic 可以额外增加不超过 30 维 privileged 信号。在 mjlab 和 Isaac Lab 中分别写出 cfg 结构。
- [跨章综合题] 回顾 Ch04 中
ManagerBasedRlEnv.load_managers()的顺序。如果你把 observation manager 的加载放在 command manager 之前,哪些 observation term 会失败?设计一个最小复现实验来验证。 - [编程题] 在 mjlab 中编写一个脚本,打印 velocity flat 任务的 actor 和 critic obs 维度。确认 critic dim > actor dim,列出 critic 中额外的 term 名称。然后在 Isaac Lab 中做同样的事情,验证维度和 term 列表是否对等。
- [分析题] 假设有人不小心把
obs_groups配置为{"actor": ("actor",), "critic": ("actor",)}(critic 也指向 actor group)。预测这会导致什么现象:(a) 训练会崩溃吗?(b) reward 曲线会有什么变化?(c) 策略质量会受影响吗?(d) 你如何在不检查配置文件的情况下发现这个 bug?
上节解决了策略"看到什么"的问题,但策略"能做什么"同样重要。action 空间的设计直接决定了策略的控制精度和探索效率——这正是下节的主题。
5.5 Action 空间详解 ⭐⭐⭐
这一节解决什么问题:理解 raw action 到 physical control target 的完整变换链,掌握 action scale、default offset 和 clip 的设计原则,以及四种 action type 的选型经验。
raw action 必须无量纲 ⭐⭐
RSL policy 输出的 raw action 是神经网络从 Gaussian 分布中采样得到的数值——它没有物理单位,不是 rad、不是 rad/s、不是 Nm。它只是 policy distribution 的输出坐标。在两个框架中,action 变换链如下:
如果配置了 clip,还会对 processed action 进行 clamp。物理单位由 action term 决定,不由 policy 输出决定。这个设计与信号处理中的 DAC(Digital-to-Analog Converter)完全类似——DAC 接收无量纲的数字码字,通过参考电压(scale)和偏置(offset)将其转换为物理电压。但与 DAC 不同的是,raw action 可以超出 \([-1, 1]\) 范围——PPO 的 Gaussian 采样没有硬边界,需要外部 clip 来约束。
| action type | processed action 物理含义 | mjlab cfg | Isaac Lab cfg |
|---|---|---|---|
| Joint Position | 关节位置目标 (rad) | JointPositionActionCfg |
JointPositionActionCfg |
| Joint Velocity | 速度目标 (rad/s) | JointVelocityActionCfg |
JointVelocityActionCfg |
| Joint Effort | 力矩目标 (Nm) | JointEffortActionCfg |
JointEffortActionCfg |
| Differential IK | 末端位姿增量 (m, rad) | DifferentialIKActionCfg |
DifferentialInverseKinematicsActionCfg |
default offset 的设计哲学 ⭐⭐
JointPositionActionCfg 有 use_default_offset=True 选项(两个框架均支持)。当它为真时:
这意味着 raw action 为零时,机器人保持默认姿态(通常是安全的站立姿态)。如果没有 default offset,raw action 为零可能表示所有关节目标为 0 rad——这通常不是站立姿态。策略训练初期的均值动作接近零,如果零动作对应"瘫软"或"劈叉"姿态,训练初期会充满摔倒和冲击。
本质洞察:action scale 和 default offset 不是简单的单位换算,它们定义了 policy 探索的坐标系。raw action 的零点应该对应安全、可解释、接近任务中性的控制目标;raw action 的单位步长应该对应机器人能承受的物理变化。这与控制工程中的"工作点线性化"思想一脉相承——从稳定工作点出发的探索更容易成功。
action scale 选择与诊断 ⭐⭐
action scale 不是任务通用常数,而是依赖 robot 关节范围、默认姿态、电机能力和控制频率的特定参数。判断 scale 是否合适需要观察以下现象:
| 观测现象 | scale 可能问题 | 第一检查项 |
|---|---|---|
| zero agent 姿态不合理 | offset 错 | default joint pos |
| random agent 立即炸飞 | scale 太大或 clip 缺失 | processed action 范围 |
| policy 动作长期贴边 | scale 太小 | raw action 分布 |
| 关节频繁撞限位 | scale 太大或 offset 偏移 | joint limit reward |
| 行为很保守 | scale 太小或 entropy 太低 | action std |
| 高频抖动 | scale 大或 action penalty 弱 | action_rate_l2 |
action type 选型决策树 ⭐⭐⭐
面对一个新任务,如何选择 action type?以下决策流程覆盖了最常见的场景:
任务是否需要精确的末端位姿控制?
├── 是 → DifferentialIK action
│ └── 是否需要底盘移动?
│ ├── 是 → base velocity + arm DiffIK(多 action term 组合)
│ └── 否 → 纯 DiffIK
└── 否 → 任务对力控有特殊需求吗?
├── 是(灵巧手、接触力控) → Joint Effort
└── 否 → Joint Position(默认选择)
└── 速度连续性是否比位置精度重要?
├── 是 → Joint Velocity
└── 否 → Joint Position
对于 locomotion 任务,Joint Position 几乎总是正确的默认选择——它在策略和物理世界之间放置了 PD 控制器作为缓冲层,大幅降低了策略的学习难度。
四种 action type 的深层比较 ⭐⭐⭐
决策树给出了"选什么"的快速判断,但要理解"为什么",需要深入每种 action type 的物理特性。
Joint Position(位置目标)是 locomotion 的标准选择。策略输出关节位置目标 \(q_{\text{target}}\),PD 控制器计算力矩 \(\tau = K_p(q_{\text{target}} - q) + K_d(\dot{q}_{\text{target}} - \dot{q})\)。PD 控制器承担了从"期望位置"到"实际力矩"的映射——策略不需要理解动力学。这与自动挡汽车的类比恰当:驾驶员(策略)只需要踩油门(设位置目标),变速箱(PD 控制器)负责选择档位和转速。但代价是控制带宽受限于 PD 的响应速度和刚度——高频动态行为(如快速跳跃的着地缓冲)可能需要比 PD 更直接的力控制。
Joint Velocity(速度目标)在某些特殊场景下优于 Joint Position。策略输出关节速度目标 \(\dot{q}_{\text{target}}\),底层控制器追踪该速度。velocity action 的优势在于它天然表达"运动连续性"——相邻帧的速度变化比位置变化更平滑。在需要持续匀速运动的任务(如传送带上的物体搬运)中,velocity action 可以避免 position action 的"追赶-超调"振荡。但 velocity action 在需要精确静态位姿保持的任务中表现较差(零速度目标不能保证静止——任何外力都会导致位移累积)。两个框架的 JointVelocityActionCfg 提供了与 position action 平行的 scale/offset/clip 接口。
Joint Effort(力矩目标)是最"底层"的 action type——策略直接输出关节力矩 \(\tau\)。这提供了最大的控制灵活性,但学习难度显著更高:策略需要隐式学习逆动力学。在灵巧手操作(Shadow Hand Rubik's Cube, OpenAI 2019)和接触力控任务中使用。effort action 的 scale 直接对应力矩范围(如 Go1 髋关节最大力矩约 \(\pm 23.7\) Nm),需要精确匹配 actuator specs。一个常见错误是忘记力矩单位在仿真和真机之间可能有差异(gear ratio 的处理方式不同)。
四种 action type 的定量比较:
| 维度 | Joint Position | Joint Velocity | Joint Effort | DiffIK |
|---|---|---|---|---|
| 策略学习难度 | 低 | 中 | 高 | 低(任务空间) |
| 控制带宽 | 受 PD 限制 | 受速度环限制 | 最高 | 受 IK + PD 限制 |
| 静态位姿保持 | 优 | 差 | 需要学习 | 优 |
| 部署匹配度 | 高(多数 SDK 支持) | 中 | 高(但 safety 难) | 需要 IK solver |
| action scale 含义 | rad | rad/s | Nm | m, rad |
| 典型应用 | locomotion, 抓取 | 连续运动 | 灵巧手, 力控 | 操作精确定位 |
反事实推理:如果给 locomotion 任务用 Joint Effort 会怎样?策略需要同时学习"在站姿下需要多少力矩来抵消重力"和"怎样的力矩模式产生步态"——这两个子问题在 position action 中是分开的(PD 控制器处理重力补偿,策略专注步态)。训练时间可能增加 5-10 倍,且更容易产生不稳定的高频力矩抖动。这解释了为什么 Rudin et al. 2022 的 legged_gym 和几乎所有 locomotion baseline 都使用 Joint Position。
clip 的双重位置 ⭐⭐
action clip 在两个框架中都存在于两个位置:
位置一:RSL-RL wrapper 的 clip_actions,在 raw action 进入 env.step 之前 clamp,限制 policy 输出坐标。
位置二:action term cfg 的 clip,在 scale/offset 后 clamp processed action,限制物理控制目标。
如果只配 raw clip,scale/offset 后可能仍越界;如果只配 processed clip,policy 可能长期输出极端 raw action 然后被截断,造成梯度和执行之间的"饱和"效应。正确做法是先把 scale 和 offset 调合理,再用 processed clip 做安全防线。不要用 clip 掩盖 scale 错误。
双框架 action 配置完整代码 ⭐⭐⭐
以下是 Go1 velocity task 的 action 配置在两个框架中的完整对比:
# ============= mjlab 版本 =============
# src/mjlab/tasks/velocity/velocity_env_cfg.py
from mjlab.envs.mdp.actions import JointPositionActionCfg
actions = {
"joint_position": JointPositionActionCfg(
entity_name="robot", # mjlab 用 entity_name(非 asset_name)
actuator_names=(".*",), # 匹配所有 actuator(mjlab 用 actuator_names)
scale=0.25, # raw_action * 0.25 + default_pos
use_default_offset=True, # offset = default joint position
# clip 可选:dict 映射 actuator 名到 (lower, upper) 元组;None 表示不裁剪
clip=None,
),
}
# ============= Isaac Lab 版本 =============
# source/isaaclab_tasks/.../anymal_c/rough_env_cfg.py
from isaaclab.envs.mdp.actions import JointPositionActionCfg
@configclass
class ActionsCfg:
joint_pos = JointPositionActionCfg(
asset_name="robot",
joint_names=[".*"],
scale=0.5, # 注意:Isaac Lab anymal_c 用 0.5
use_default_offset=True,
)
关键差异:Go1(mjlab)的默认 action scale 是 0.25,ANYmal-C(Isaac Lab)的默认 action scale 是 0.5——这不是框架差异,而是 robot 差异(ANYmal 的关节范围更大、PD 增益不同)。当你在一个框架中看到某个 scale 值,不能直接复制到另一个框架的另一种机器人上。
验证 action 配置的代码片段:
# 在 env 创建后运行,验证 action 接线正确
def verify_action_config(env):
"""打印 action 配置详情,用于 smoke test 验证。"""
am = env.action_manager
print(f"Total action dim: {am.total_action_dim}")
# Isaac Lab 的 active_terms 是名称列表(list[str]),不是 dict
print(f"Number of action terms: {len(am.active_terms)}")
for name in am.active_terms:
term = am.get_term(name) # 通过 get_term 拿到 ActionTerm 对象
print(f"\n Term: {name}")
print(f" Type: {type(term).__name__}")
print(f" Dim: {term.action_dim}")
print(f" Scale: {term.cfg.scale}")
if hasattr(term.cfg, 'use_default_offset'):
print(f" Use default offset: {term.cfg.use_default_offset}")
# 验证 zero action 行为
import torch
zero_action = torch.zeros(env.num_envs, am.total_action_dim, device=env.device)
am.process_action(zero_action)
# 注意:am.action 是发给环境的 raw action(这里就是 zero);
# 经过 scale/offset 处理后的控制目标在每个 term 的 processed_actions 中
for name in am.active_terms:
processed = am.get_term(name).processed_actions
print(f"\nTerm {name}: zero action → processed action range: "
f"[{processed.min():.4f}, {processed.max():.4f}]")
print(f" Expected: should equal default_joint_pos if use_default_offset=True")
# 使用:verify_action_config(env)
任务空间 action 与 differential IK ⭐⭐⭐
移动操作任务经常需要任务空间动作。mjlab 的 DifferentialIKActionCfg 和 Isaac Lab 的 DifferentialInverseKinematicsActionCfg 都把 position/orientation command 通过 damped least-squares IK 转成关节位置目标:
其中 \(\lambda\) 对应 damping,max_dq 限制每次 IK solve 的关节位移上限。对 IK action 来说,尺度控制入口不只是 BaseActionCfg.scale,还包括 delta_pos_scale、delta_ori_scale、max_dq 和 damping。
DiffIK 的调参比 joint position 更微妙,因为参数之间存在非线性交互。damping 太小时,在 Jacobian 接近奇异(如手臂完全伸直)时求解会产生极大的关节速度;damping 太大时,IK 解偏离目标过多。max_dq 起安全限制作用——即使 IK 解需要关节大幅运动,也被限制在每步 max_dq rad 以内。一个实用的调参起点是:damping=0.05,max_dq=0.1 rad。
DiffIK action 与 joint position action 的一个关键区别是:DiffIK 的 raw action 语义是"末端位姿增量"(\(\Delta x\), \(\Delta \theta\)),不是关节空间增量。这意味着 action scale 的物理含义完全不同。对 DiffIK,delta_pos_scale=0.01 意味着 raw action 为 1 时末端移动 1 cm——这对桌面操作是合理的;而 delta_pos_scale=0.1 意味着 raw action 为 1 时末端移动 10 cm,可能导致每步跳跃过大。
如果 DiffIK 策略在训练中表现出"手臂锁死"或"末端抖动"的行为,最可能的原因是 IK 求解在工作空间边界附近的数值问题。诊断方法:打印每步的 IK residual(\(\|J \Delta q - \Delta x\|\)),如果 residual 持续很大,说明当前臂构型接近奇异或超出可达范围。
多段 action 的组合设计 ⭐⭐⭐
移动操作的 action 通常分段:base(body-frame velocity)、arm(default-pose-centered joint delta)、end-effector IK(relative task-space delta)、gripper(normalized open/close)。每段有独立的 scale、offset 和 clip——不要用全局 scale 控制所有自由度。多 action term 的切片顺序由 cfg dict 顺序决定(两个框架都是如此),部署端必须按同样顺序解释 ONNX 输出。
一个具体的例子:移动操作机器人的 action 维度划分可能如下:
total_action_dim = 11
├── base_velocity: [0:2] → 2D (vx, wz), scale=[1.0, 1.0], unit=m/s, rad/s
├── arm_joints: [2:9] → 7D, scale=[0.25]*7, offset=default_arm_pos
└── gripper: [9:11] → 2D (left_finger, right_finger), scale=[0.04], offset=0.04
如果部署端把 arm 切片当成 base 切片解释,机器人会在应该移动手臂时移动底盘——后果可想而知。自检方法:(1) 打印 ActionManager 的每个 action term 的名称和 (start_idx, end_idx) 切片范围;(2) 用 zero agent 确认每段 action 的零点行为;(3) 用 random agent 单独给一段 action 非零值,验证物理效果符合预期。
last_action 为什么是 observation ⭐⭐
velocity actor terms 中包含 last_action。它读取的是 raw policy output(不是 processed joint target)。策略知道自己上一拍在 raw action 空间做了什么,有利于平滑动作;reward 中的 action rate penalty 也基于 raw action 差分,策略在同一个坐标系中学习"保持动作连续"。如果 last_action 使用 processed action,当 scale 是 per-joint 不同时,动作历史会混入物理尺度,让 action rate penalty 的物理含义变得不透明。
action pipeline 完整数值追踪 ⭐⭐
让我们用 Go1 的实际参数追踪一个 raw action 从网络输出到物理执行的完整过程。假设 Go1 右前腿髋关节(hip joint)的配置如下:
default_joint_pos (hip) = 0.1 rad (略微外展)
action_scale (hip) = 0.25 (Go1 典型配置)
joint_limit = [-0.8, 0.8] rad
Step 1: Policy 采样。 PPO 的 Gaussian 分布 \(\mathcal{N}(\mu, \sigma^2)\) 输出 raw action。训练初期 \(\mu \approx 0\),\(\sigma \approx 1.0\),采样值通常在 \([-2, 2]\) 范围。假设采样得到 raw_action = 0.6。
Step 2: RSL-RL clip_actions。 如果 clip_actions = 100(默认值很大,相当于不裁剪),raw_action = 0.6 通过。如果设置了 clip_actions = 1.0,则会被 clamp 到 \([-1, 1]\)。
Step 3: Action term process。 processed_action = raw_action × scale + offset = 0.6 × 0.25 + 0.1 = 0.25 rad。这就是发送给 PD 控制器的目标关节位置。
Step 4: Joint limit safety check。 0.25 rad 在 \([-0.8, 0.8]\) 范围内,通过安全检查。如果 raw_action 是 3.0,则 processed_action = \(3.0 × 0.25 + 0.1 = 0.85\) rad,超出关节限位——此时 processed clip 会将其裁剪到 0.8 rad。
Step 5: PD 控制器执行。 物理仿真中的 PD 控制器计算力矩:\(\tau = K_p (q_{target} - q_{current}) + K_d (\dot{q}_{target} - \dot{q}_{current})\)。其中 \(K_p\) 和 \(K_d\) 来自 actuator cfg(Go1 典型 \(K_p = 20\),\(K_d = 0.5\))。如果当前关节位置 \(q_{current} = 0.15\) rad,目标 \(q_{target} = 0.25\) rad,则力矩 \(\tau = 20 × (0.25 - 0.15) + 0.5 × (0 - \dot{q}) = 2.0 - 0.5\dot{q}\) Nm。
这个追踪揭示了 action scale 的深层含义:scale = 0.25 意味着 policy 的 \(\pm 1 \sigma\)(约 68% 的采样)对应 \(\pm 0.25\) rad 的关节位置变化。对 Go1 这样的小型四足,\(\pm 0.25\) rad 是一个合理的探索范围——足以产生步态,但不至于超出关节限位或引发冲击。
action scale 的双重解读。 同一个 scale 参数可以从两个互补角度理解:
角度一(控制论视角):scale 定义了 policy 输出空间到物理控制空间的增益。增大 scale 等于增大控制增益——系统响应更快但更容易不稳定。这与 PD 控制器的 \(K_p\) 完全类比:\(K_p\) 太大会振荡,太小会响应慢。
角度二(优化视角):scale 定义了 policy 梯度在物理空间中的"步长"。增大 scale 意味着 policy 参数的微小变化在物理空间产生更大的效果——梯度信号更强但也更不稳定。这与深度学习中 learning rate 的角色类似:optimal learning rate 取决于 loss landscape 的曲率,optimal action scale 取决于物理系统的灵敏度。
这个双重解读解释了一个常见的调参经验:当策略在训练初期快速收敛但后期振荡时,通常需要减小 scale(降低增益/步长);当策略长期不收敛、reward 几乎不变时,通常需要增大 scale(让探索更有力度)。
⚠️ 常见陷阱
⚠️ 编程陷阱:把 Go1 的 action scale 直接复制到另一个机器人。 每个新机器人都应定义自己的 action scale——用 zero agent 检查默认姿态,用 random agent 检查 processed target 范围。这在 mjlab 和 Isaac Lab 中都是必须的步骤。
💡 概念误区:认为 IK action 比 joint action "更高级"因此一定更好。 IK action 适合末端位姿跟踪任务,但对 locomotion,joint action 通常更合适——步态控制需要精确的关节协调,IK 求解引入额外数值误差和奇异性问题。
🧠 思维陷阱:认为"action rate penalty 越大越平滑"。 过大的 penalty 让策略过于保守,动作幅度极小。合理的 penalty 应在"足够平滑"和"足够灵活"之间平衡。action rate penalty 的物理含义与 decimation 耦合——改变 decimation 后 penalty 的权重需要同步调整(见 5.6 节详细分析)。
⚠️ 编程陷阱:多 action term 配置中搞混了 term 顺序。 ActionManager 按 cfg dict 顺序切片分配 raw action。如果 cfg 中 arm 在 base 前面但 ONNX 端假设 base 在前,所有关节目标错配。自检方法:打印 ActionManager 的 term 名称和 action 切片范围。
⚠️ 编程陷阱:修改 PD gain 后没有重新评估 action scale。 action scale 和 PD gain 存在间接耦合关系。如果 \(K_p\) 从 20 增加到 80,同样的关节位置误差产生 4 倍的力矩——等效于探索幅度增加了 4 倍。这可能导致原本稳定的训练变得不稳定。正确做法是:修改 PD gain 后,用 random agent 重新检查 processed action 是否仍在安全范围内。如果出现大冲击或 NaN,按比例减小 action scale。
action scale 调参的系统方法 ⭐⭐
action scale 不是一个"设一次就忘"的参数——它需要随 robot、task 和其他配置参数的变化而调整。以下是一个实用的系统方法:
Step 1(物理范围估计):从 default pose 出发,计算每个关节到限位的距离。取最小值的一半到三分之一作为 scale 的起点。例如,髋关节 default=0.1,限位=[-0.8, 0.8],到限位距离 min(0.9, 0.7)=0.7,初始 scale \(\approx\) 0.7/3 \(\approx\) 0.23。
Step 2(random agent 验证):运行 random agent 100 步,打印 processed action 的 min/max。95% 的 processed action 应在关节限位的 70% 以内——留出安全余量给 PD 控制器的响应和外部扰动。
Step 3(训练初期监控):训练 50 iteration 后打印 raw action 的 mean 和 std。如果 std < 0.3(探索太弱),增大 scale 或增大 init_std;如果 std > 2.0(探索太强),减小 scale。
Step 4(迭代调整):根据行为观察微调。高频抖动→减小 scale 或增大 action rate penalty。动作太保守→增大 scale。关节频繁撞限位→减小 scale 或检查 offset。
这个四步方法在两个框架中完全通用。每次更换 robot 或显著修改 PD gain、decimation、reward 权重时,都应重新执行。
练习
- [计算题] Go1 某关节 default position 为 0.8 rad,action scale 为 0.5。如果 raw action 为 0.6,计算 processed action。如果关节限位为 \([-0.3, 1.5]\) rad,这个 processed action 是否安全?
- [设计题] 为移动操作机器人设计 action terms,包含 base(2D 速度)、arm(6 DOF 关节位置)和 gripper(1D 开合)。给出每段的 scale、offset 和 clip 设计理由,以及 ONNX 部署时的 postprocess 要求。
- [编程题] 写一个 Python 脚本,对给定
default_joint_pos和action_scale,可视化 raw action 在 \([-1, 1]\) 范围内 uniform 采样时 processed action 的分布,检查是否超出关节限位。 - [跨章综合题]
init_std(RSL-RL 5.x 的GaussianDistributionCfg参数)和action_scale共同决定了初始探索的物理幅度。如果 Go1 的action_scale=0.25和init_std=1.0,计算训练初期 95% 的 processed action 变化范围。如果你想让 95% 范围控制在 \(\pm\)0.1 rad 以内(如精细操作任务),应该怎样调整action_scale和init_std?给出至少两种方案并分析各自的利弊。
上节完整覆盖了 action 空间设计。但 obs 和 action 在 env.step() 中的执行时序直接影响 delay、history 和 reward 的对齐关系——搞不清时序,调试只会事倍功半。
5.6 Observation/Action 在 env.step 中的时序 ⭐⭐
这一节解决什么问题:精确理解策略输出到观测返回之间的完整时序,这是诊断 delay、history 和 reward 对齐问题的关键。
env.step 的完整时序 ⭐⭐
ManagerBasedRlEnv.step() 定义了单个环境步的完整执行序列(mjlab 和 Isaac Lab 的语义一致,以 mjlab 的命名为主):
(1) 清空 extras["log"]
(2) action_manager.process_action(action)
(3) decimation 循环:每个 physics substep 写入同一个 processed target → sim.step()
(4) 更新 episode length 和 step counter
(5) termination_manager 计算 done
(6) reward_manager 计算 reward
(7) 需要 reset 的 env 进入 _reset_idx()
(8) sim.forward() 刷新派生量
(9) command_manager 更新 command
(10) step/interval event 执行
(11) sim.sense() 刷新传感器
(12) observation_manager.compute(update_history=True)
这条时序有三个关键信息。第一,当前 action 先进入仿真再产生下一帧 observation——符合标准 MDP 的 \((s_t, a_t) \to s_{t+1} \to o_{t+1}\) 时序。第二,observation history 每个 env step 更新一次(不是每个 physics substep)。第三,reward/termination 在 sim.forward() 之前计算,observation 在之后计算——reward 读到的派生量有一拍滞后,observation 更接近刷新状态。如果你发现 observation 与 reward 对不上,先读 env.step 时序,不要先改 reward。
Isaac Lab 的 ManagerBasedRLEnv.step() 遵循几乎完全相同的执行顺序,但有两个微妙差异:reset 时机在某些版本中略有不同(pre-reset vs post-reset observation),以及 event manager 的调用点可能更灵活。在跨框架迁移任务时,对比两个 step() 的源码是必要的第一步。
ObservationManager.compute() 有缓存机制:update_history=False 且已有 _obs_buffer 时直接返回缓存。env.step() 使用 update_history=True;wrapper 的 get_observations() 默认不更新 history——这符合 wrapper 读取当前 obs 的语义。
时序与 delay 的交互 ⭐⭐
理解 env.step 时序对正确配置 delay 至关重要。回顾 5.3 节:delay 的 lag 单位是 env step。如果 lag=1,策略在 step \(t\) 看到的 observation 实际上是 step \(t-1\) 结束时计算的值。结合上面的时序:
step t-1: action(t-1) → sim → reward(t-1) → obs(t-1) [存入 delay buffer]
step t: action(t) → sim → reward(t) → obs(t) [存入 delay buffer, 返回 obs(t-1)]
这意味着 delay=1 时,策略在决定 action(t+1) 时用的是 obs(t-1)——是两步前的状态信息。对于 20 ms 的 env step,这对应 40 ms 延迟。对实时步态控制来说,这已经是一个不可忽视的相位滞后。
action rate 与控制频率的耦合 ⭐
action rate penalty 惩罚的是相邻 env step 的 raw action 差,不是每个 physics substep 的变化。在 decimation=4 时,policy 每 4 个 physics steps 输出一次 action。如果改变 decimation,相同的 action rate penalty 物理含义会变——env step 从 20 ms 变成 10 ms 时,同样的 raw action 差分对应更高频的变化。action penalty 和 decimation 不能独立调。这个耦合关系在两个框架中完全一致。
重要补充:reward \(\times\) step_dt 标准化。 在 mjlab 和 Isaac Lab 的 reward_manager 中,所有 reward term 的值会自动乘以 step_dt(env step 时间间隔)。这意味着 reward 的量级与控制频率无关——如果你把 decimation 从 4 改成 2(控制频率翻倍),每步的 step_dt 减半,单步 reward 也减半,但总 reward 近似不变。如果没有这个归一化,改变 decimation 后所有 reward 权重都需要手动调整——这是 legged_gym 中一个常见的痛苦来源,而 manager-based 架构自动处理了它。但需要注意:action_rate penalty 惩罚的是 \(\|a_t - a_{t-1}\|^2 \times dt\),当 dt 减半时单步 penalty 减半,但相邻 action 的时间间隔也减半——物理上应该对更高频的变化给更大惩罚。因此在改变 decimation 时,仍需审视 action_rate 权重是否合理。
精确理解 decimation ⭐⭐
decimation 不仅影响控制频率,还影响整个 obs/action/reward 的时间分辨率。设 physics_dt = 0.005 s,decimation = 4:
physics步骤: 0 1 2 3 4 5 6 7 8 ...
└─ env step 0 ─┘ └─ env step 1 ─┘ └─ env step 2 ─┘
↑ action(0) ↑ action(1) ↑ action(2)
↑ obs(0), reward(0) ↑ obs(1), reward(1)
在 decimation 循环内部,每个 physics substep 使用同一个 processed action target。这意味着 PD 控制器在 4 个 substep 中都在追踪同一个目标——关节实际位置逐步逼近目标,形成类似零阶保持(ZOH, Zero-Order Hold)的效果。
如果 decimation 从 4 改成 2(控制频率从 50 Hz 变成 100 Hz),会同时影响多个设计参数:
| 参数 | decimation=4 (50 Hz) | decimation=2 (100 Hz) | 需要调整吗? |
|---|---|---|---|
| env step 时间 | 20 ms | 10 ms | 自动变化 |
| delay lag=3 对应延迟 | 60 ms | 30 ms | 如果目标延迟不变,需改 lag |
| action rate penalty 物理含义 | \(\Delta a / 20\) ms | \(\Delta a / 10\) ms | 可能需调整权重 |
| PD 控制器追踪时间 | 4 步 converge | 2 步 converge | 可能需调 \(K_p\)/\(K_d\) |
| 训练 throughput | 基线 | ~2\(\times\) sim cost | 视任务需要决定 |
这个耦合关系解释了为什么"把别人的配置 decimation 从 4 改成 2 后训练不出来"——改了一个参数,实际影响了五个设计维度。
reset 时 observation 的边界行为 ⭐⭐
env reset 时 observation 的时序是一个容易出错的边界条件。在 mjlab 中,reset 发生在步骤 (7):done 的环境重新初始化状态,然后在步骤 (12) 计算 reset 后第一帧的 observation。这意味着 reset 后的 observation 反映的是新 episode 的初始状态,不是上一个 episode 的最终状态。
但 RSL-RL 的 rollout storage 需要知道"done 信号对应的是哪一帧 observation"。如果 done 发生在 step \(t\),storage 存储的 \((o_t, a_t, r_t, d_t, o_{t+1})\) 中,\(o_{t+1}\) 是 reset 后的新初始 observation,不是 done 前的最终 observation。这对 GAE 计算是正确的——\(d_t = \text{True}\) 意味着 \(V(o_{t+1})\) 不参与 bootstrap,所以 \(o_{t+1}\) 具体是什么并不影响 advantage 计算。
但如果 reset 前最后一帧的 observation 需要用于 logging(比如记录"episode 结束时策略看到的最终状态"),需要在 reset 之前额外调用 observation_manager.compute(update_history=False) 来缓存——这在自定义任务中偶尔需要。
Isaac Lab 在某些版本中,reset observation 的计算时机略有不同。跨框架时,最安全的做法是在两个框架中都打印 reset 前后的 observation,确认 done 信号和 observation 的对齐方式一致。
⚠️ 常见陷阱
⚠️ 编程陷阱:在自定义 observation term 中调用了 sim.forward() 之前的派生量。 如果你的 term 依赖的传感器数据在 sim.sense() 之前没有刷新,你拿到的是过时数据。这个问题在 mjlab 和 Isaac Lab 中都会出现。
💡 概念误区:认为 action rate penalty 惩罚每个 physics substep 的变化。 它惩罚的是相邻 env step(即相邻 policy step)的 raw action 差。改变 decimation 会改变 action rate 的物理时间间隔。
🧠 思维陷阱:在 Isaac Lab 中看到 reset 后 observation 与 mjlab 不同,就认为两个框架"不兼容"。 实际上差异通常来自 reset 时 observation 的计算时机——是 reset 前最后一帧还是 reset 后第一帧。阅读两个框架的 _reset_idx() 实现可以定位差异。
练习
- [分析题] 如果把 observation 计算移到 reward 之前(即
sim.forward()之前),会产生什么后果?讨论 observation、reward 和 advantage 估计的对齐问题。 - [编程题] 写一个自定义 observation term,在
compute()中打印 step counter 和当前 action。运行 2 步,验证 observation 反映的是 action 执行后的状态。 - [计算题] 对 velocity flat 任务(physics_dt=0.005s, decimation=4),计算以下参数:(a) env step dt, (b) 控制频率, (c) delay lag=3 对应的物理延迟, (d) 如果改成 decimation=2,(a)-(c) 各变为多少, (e) 如果 action_rate penalty 权重在 decimation=4 时为 0.01,改成 decimation=2 后应调为多少来保持等价的物理惩罚?
- [跨章综合题] reward \(\times\) step_dt 归一化意味着 reward 的绝对值与 decimation 无关。但 GAE 中的 \(\gamma^l\) 衰减仍然以 env step 为单位。如果把 decimation 从 4 改成 2(env step 数量翻倍),同一段物理时间对应的 GAE 窗口中会有更多的 step,有效 discount 变化了吗?讨论这个变化对 PPO 训练的影响(提示:考虑 effective horizon 的变化)。
到此为止,我们已经掌握了 obs/action 设计的全部基本工具——从 MDP 理论到处理管线,从 actor/critic 分离到 action 变换链,从 env.step 时序到部署边界。但在真实的高级项目中,observation 的复杂度远超 locomotion baseline——多模态、多控制目标、mask-conditioned 输入是最新工作的核心。这正是下节要通过 HOVER 案例来深入探讨的内容。
5.7 精读:HOVER 多模态 Obs Group 设计 ⭐⭐⭐
这一节解决什么问题:通过精读 HOVER(HOVER: Versatile Neural Whole-Body Controller for Humanoid Robots, He et al., ICRA'25,
github.com/NVlabs/HOVER, ~720 Stars)的多模态 obs group 设计,理解如何在一个统一的策略中支持多种控制模态(关节级、末端级、全身级),以及 mask 机制如何让 observation 适应不同任务需求。HOVER 基于 Isaac Lab 构建,其 obs 设计直接展示了 Isaac Lab 的 configclass 模式在复杂任务中的应用。
HOVER 的工程定位 ⭐⭐
回顾 5.4 节的 asymmetric actor-critic:actor 看部署可得信号,critic 看 privileged 信号。这个框架假设任务是单一的——一个策略对应一套 observation 和一种控制目标。但真实的人形机器人应用场景多种多样:有时需要关节级精确控制(如跳跃),有时需要末端跟踪(如操作),有时需要全身姿态控制(如舞蹈模仿)。为每种场景单独训练一个策略效率低下,部署时切换也麻烦。
HOVER 的核心思想是:用一个统一的策略支持多种控制模态,通过 observation 中的 mask 告诉策略"当前应该关注哪种控制目标"。这是对 5.2 节马尔可夫性原则的高级应用——mask 本身就是"当前任务是什么"的条件信息,与 velocity command 的功能类似,只是粒度更细。HOVER 构建在 Isaac Lab 之上,使用 RSL-RL 作为 RL 后端,这意味着它的 obs/action 配置模式可以直接迁移到 Isaac Lab 的其他任务中。
如果不做多模态统一,你需要为每种控制场景维护一个独立的策略、一套独立的 observation、一个独立的 ONNX 模型,并在部署时实现模态切换逻辑。这不仅增加了工程复杂度,还浪费了跨模态共享的 proprioceptive 特征——三种模态下的关节状态处理其实是共通的。这种冗余在有限的机载算力下尤其浪费。
三种控制模态与共享 obs 架构 ⭐⭐⭐
HOVER 定义了三种控制模态:
| 模态 | 控制目标 | obs 中的目标表示 | 典型应用 |
|---|---|---|---|
| 关节级 (joint) | 全身关节角度目标 | target joint positions + mask | 动作模仿、技能复现 |
| 末端级 (end-effector) | 四肢末端 + 头部的位置/朝向目标 | target ee poses + mask | 操作、遥操作 |
| 全身级 (wholebody) | 根部速度 + 关节角度 + 末端位姿的组合 | 组合目标 + mask | 复杂全身任务 |
三种模态共享同一个策略网络和同一套 proprioceptive observation(关节状态、角速度、重力投影、last action)。区别在于 目标 observation 的组成 和 mask 向量。mask 是一个 binary 或 soft 向量,指示哪些目标维度当前有效。
HOVER 的 obs 结构可以拆解为三个部分:
obs = [proprioception | task_target | task_mask]
└── 共享部分 ──┘ └── 模态相关 ──────────┘
proprioception 部分在所有模态下完全相同:关节位置(相对默认值)、关节速度、base 角速度、projected gravity、last action。这部分对应 5.2 节的"最小集合"——任何 locomotion 策略都需要的核心信号。
task_target 部分根据模态不同:关节模态包含目标关节角度(全身 DOF 维度),末端模态包含 5 个末端(左手、右手、左脚、右脚、头部)的 6D 位姿目标,全身模态包含根速度 + 关节目标 + 末端目标的组合。
task_mask 是 HOVER 最创新的设计元素。研究报告揭示了 mask 机制比表面看到的更精细——HOVER 实际使用 两种独立的 mask,每种分别作用于上半身和下半身:
Mode mask:选择激活哪种控制子空间。对应三个控制子空间(kinematic position tracking、local joint angle tracking、root tracking),mode mask 的每一位决定是否激活对应子空间的目标。
Sparsity mask:在已激活的子空间内,进一步随机屏蔽部分目标维度。例如,即使 kinematic position tracking 被激活,sparsity mask 也可能只激活左手目标而屏蔽右手目标。
两种 mask 分别独立作用于上半身和下半身,形成组合:
每个 mask 的每一位从 Bernoulli 分布 \(B(0.5)\) 采样,在 episode 开始时随机生成并在 episode 内保持固定。这个设计确保 student 在训练中遍历了所有可能的控制模式组合——从"只控制左手"到"全身跟踪"——每种组合出现的概率相等。
这个设计的精妙之处在于:它不需要多个网络,也不需要任务切换逻辑——一切通过 observation 的语义变化完成。从 obs/action 设计的角度看,HOVER 的 mask 就是一种高级的 "command":velocity task 的 command 告诉策略 "往前走 1 m/s",HOVER 的 mask 告诉策略 "现在请跟踪左手末端位姿,忽略右手目标"。
mask-conditioned distillation 详解 ⭐⭐⭐
HOVER 的训练管线使用了 mask-conditioned distillation,分为两个阶段:
阶段一:训练专家 teacher。 为每种模态单独训练一个 teacher 策略。关节模态的 teacher 只接收关节目标和全为 1 的 mask;末端模态的 teacher 只接收末端位姿目标和对应的 mask。每个 teacher 可以使用 privileged critic(包含精确接触力、地形信息等)来加速训练。这一步的目标是为每种模态获得尽可能高的性能上限。
阶段二:蒸馏统一 student。 训练一个统一的 student 策略,通过随机采样 mask 在三种模态之间切换。对于每种 mask 配置,student 的蒸馏目标是对应 teacher 的 action output。蒸馏 loss 通常是 action MSE:
其中 \(m\) 是随机采样的 mask,\(o^{\text{target}}_m\) 是 mask \(m\) 对应的目标 observation,\(a^{\text{teacher}_m}\) 是对应 teacher 的 action。
这与 Ch09 将要详细讲述的 teacher-student 蒸馏有直接联系。HOVER 的特殊之处在于:蒸馏时 student 的 observation 中包含 mask,student 需要学会根据 mask 的值选择"像哪个 teacher"。这对 student 网络的容量要求更高——如果 student 网络太小,它可能无法同时学好三种模态。HOVER 使用了较大的 MLP(三层 [512, 256, 128]),以确保有足够的容量。
Student 的 observation 架构。HOVER student 与 teacher 的关键差异在于 student 使用 proprioceptive history——堆叠最近 25 个 timestep 的 proprioception 和 24 个 timestep 的 past actions:
25 帧历史在 50 Hz 控制频率下对应 0.5 秒的时间窗口——足以让 student 隐式估计地面特性和动力学参数,替代 teacher 使用的 privileged terrain/physics 信息。history 的长度选择是一个工程权衡:太短(如 5 帧 = 0.1 s)无法捕捉慢变的地面属性;太长(如 100 帧 = 2 s)增加网络输入维度和计算成本而收益递减。
蒸馏 loss 使用 MSE action-matching(DAgger 框架),而不是 KL divergence:
其中 \(\hat{a}_t\) 是 teacher 在 student 的当前状态下给出的参考 action,\(a_t\) 是 student 的 action。选择 MSE 而非 KL 的工程原因是:MSE 直接在 action 空间匹配,不需要对 teacher 的 action 分布做参数化假设。teacher 的 deterministic action 足以作为监督信号——不需要 teacher 的方差信息。
从 HOVER 看 obs 设计的扩展方向 ⭐⭐
把 HOVER 的设计放回本章的框架中,可以看到以下趋势:
| 设计维度 | velocity task(5.4 节) | HOVER |
|---|---|---|
| actor obs 维度 | ~48 | ~150+(含目标和 mask) |
| 条件变量 | twist command (3D) | mask + multi-modal target |
| action type | JointPosition (12D) | JointPosition (全身, 20-30D) |
| 策略数量 | 1 | 1(统一) |
| privileged learning | critic 看 contact/terrain | teacher-per-mode → student 蒸馏 |
| 部署复杂度 | 低 | 中等(需维护 mask 和目标生成) |
| 框架 | mjlab 或 Isaac Lab | Isaac Lab(HOVER 原生) |
HOVER 的设计给 5.2 节的五条原则带来了两个重要扩展:
第一,马尔可夫性要求包含"当前任务是什么"的信号。对 velocity task,command 就够了;对多模态控制,mask + multi-modal target 是必须的。如果你的任务需要策略在不同模式间切换,observation 必须包含模式标识。
第二,低维性原则需要与任务复杂度平衡。HOVER 的 obs 维度比 velocity task 高得多,但这不是"违反低维性"——而是任务本身的信息需求更高。低维性原则的本质不是"维度越低越好",而是"维度不应超过任务的信息需求"。
本质洞察:HOVER 的 mask 机制本质上是把 "任务条件" 从硬编码(一个任务对应一个 env cfg)变成了软编码(一个 env cfg 支持多种任务模式,通过 obs 中的 mask 切换)。这种设计在人形机器人和移动操作领域越来越普遍。如果说 velocity task 的 command 是"告诉策略目标值",HOVER 的 mask 则是"告诉策略目标结构"——一个是 what to achieve,另一个是 what to pay attention to。
HOVER 对 mjlab 用户的启示 ⭐⭐
虽然 HOVER 构建在 Isaac Lab 上,但其 obs 设计思想完全可以迁移到 mjlab。mjlab 的 ObservationGroupCfg 支持任意数量的 term,mask 可以作为一个额外的 observation term 加入 actor group。关键工程步骤是:
- 在 command manager 中生成 mask(类似 velocity command 的 resample 逻辑)
- 在 observation terms 中加入
generated_commands()读取 mask - 在 reward 中根据 mask 选择性计算跟踪奖励
- 蒸馏时复用 mjlab 的
DistillationRunner(如果可用)或自定义 runner
这个迁移路径展示了双框架工作流的典型模式:在一个框架中精读前沿项目的设计,在另一个框架中复现核心思想。
⚠️ 常见陷阱
⚠️ 编程陷阱:mask 维度与目标维度不匹配。 如果 mask 有 5 个元素(对应 5 个末端),但目标只有 4 个末端的位姿,第 5 个 mask 位对应的目标是垃圾值。训练不会报错但策略会学到噪声。
💡 概念误区:认为"统一策略一定比专用策略好"。 如果你只需要速度跟踪,为此训练一个支持三种模态的 HOVER 策略是过度工程。统一策略适合部署场景多样的平台;专用策略适合任务明确的场景。
🧠 思维陷阱:认为"大维度 obs 就需要大网络"。 HOVER 的 obs 中大部分是稀疏的目标/mask——很多维度在某种模态下是零。网络不需要把所有维度的组合都学好,只需要学好 mask 激活的子集。这与 Transformer 中的 attention mask 类似——mask 为零的位置不贡献信息。
练习
- [分析题] 在 HOVER 的 mask-conditioned distillation 中,如果 student 在关节模态下的 action MSE 很低,但在末端模态下 MSE 很高,可能的原因是什么?如何诊断和修复?
- [设计题] 为一个移动底盘 + 双臂的人形机器人设计多模态 obs group,支持"纯行走"和"行走 + 右手跟踪"两种模态。给出 mask 的维度和语义定义。
- [前向引用题] HOVER 的蒸馏使用 MSE action-matching(DAgger 框架)。另一个重要的蒸馏案例是 Extreme Parkour(Cheng, Shi, Agarwal, Pathak; ICRA 2024,
github.com/chengxuxin/extreme-parkour)的三阶段管线:Phase 1 用 privileged scandots teacher RL 训练 → Phase 2 用 DAgger 蒸馏到 depth camera student → Phase 3 加入 action delay + camera latency fine-tune。对比 HOVER 和 Extreme Parkour 的 obs 设计:HOVER 的 student 使用 proprioceptive history(25 帧),Extreme Parkour 的 student 使用 depth image。思考:如果你要为一个同时需要视觉和 proprioception 的任务设计蒸馏管线,student 的 obs 应该包含什么?这将在 Ch09 中详细展开。
HOVER 与其他多模态工作的对比 ⭐
HOVER 不是唯一的多模态控制方案。BeyondMimic(Liao et al. 2025, arXiv 2508.08241)使用 diffusion policy 实现多技能控制——与 HOVER 的 mask 机制不同,BeyondMimic 通过 guided sampling 在推理时切换任务。HoST(Huang et al., RSS 2025 Best Systems Paper Finalist, github.com/InternRobotics/HoST)使用多 critic 架构(而非多 teacher 蒸馏)来处理不同姿态的 standing-up 控制。这三种方案代表了多模态控制的三种设计哲学:HOVER 通过 obs mask 实现、BeyondMimic 通过 diffusion guidance 实现、HoST 通过 multi-critic 实现。在 obs/action 设计层面,它们的共同点是:策略的 observation 中必须包含"当前应该做什么"的条件信息——无论这个信息是 mask、reference motion、还是 task embedding。
选择哪种方案取决于你的具体需求:如果模态切换在 episode 内不频繁(如切换控制对象),HOVER 的 mask 方案足够高效;如果需要在连续动作空间中生成新技能(如创造从未见过的动作),BeyondMimic 的 diffusion 方案更灵活;如果多任务之间的 value function 差异很大(如站立 vs 行走 vs 跌倒恢复),HoST 的 multi-critic 方案能提供更准确的 advantage 估计。
这些前沿工作将在 Ch14(人形 locomotion)和 Ch15(motion imitation)中结合具体任务深入讨论。
上节通过 HOVER 案例展示了 obs 设计的前沿方向。现在让我们回到工程实践层面——掌握了所有设计原则后,如何验证接线正确?ONNX 部署的边界在哪里?这些实操问题需要在完成本章之前彻底解决。
5.8 ONNX 部署边界与 Smoke Test ⭐⭐⭐
这一节解决什么问题:明确 ONNX 文件包含什么、不包含什么,建立完整的部署边界意识,提供从 zero agent 到 GPU 训练的 smoke test 流程。
ONNX 不是完整控制器 ⭐⭐
当前 mjlab 的 ONNX 导出路径在 MjlabOnPolicyRunner.export_policy_to_onnx()。Isaac Lab 侧对应的官方导出函数是 export_policy_as_onnx(位于 isaaclab_rl.rsl_rl.exporter,默认输出 policy.onnx,可带 normalizer)。ONNX 通常包含 actor 网络推理(MLP 权重 + activation),可能包含 normalizer。ONNX 不包含环境、manager、传感器驱动和安全逻辑。
| 项目 | 是否在 ONNX 内 | 部署要求 | 双框架一致 |
|---|---|---|---|
| actor MLP 权重 | ✓ | 加载 ONNX | ✓ |
| actor obs 顺序和名称 | 部分 | 读取 metadata 或任务文档 | ✓ |
| term-level scale | ✗ | 部署端复现 | ✓ |
| history buffer | ✗ | 部署端维护 | ✓ |
| command 生成 | ✗ | 上层控制器提供 | ✓ |
| action scale/offset | ✗ | 部署端复现 | ✓ |
| joint target safety clip | ✗ | 控制端复现 | ✓ |
| critic 网络 | ✗ | 不需要 | ✓ |
如果训练使用了 no normalization、no history、简单 MLP 和 joint position action,部署边界最简单。如果使用了 vision、history、RNN 或 normalization,部署文档必须更详细。
双框架的 ONNX 导出路径差异 ⭐⭐
虽然 mjlab 和 Isaac Lab 都使用 RSL-RL 作为 RL 后端,ONNX 导出的具体路径和生成的文件有微妙差异:
mjlab 路径:
# 训练完成后自动导出,或手动导出
uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
--checkpoint /path/to/model_1000.pt --export-onnx
# 输出:policy.onnx + metadata(obs 维度、action 维度)
Isaac Lab 路径:
# 使用 play 脚本导出
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--checkpoint /path/to/model_1000.pt
# 输出:policy.pt + policy.onnx
两者的核心差异在于:mjlab 使用 tyro CLI,参数格式是 --export-onnx;Isaac Lab 使用 argparse,参数格式可能不同。生成的 ONNX 文件在功能上等价,但输入输出的命名约定可能不同(如 mjlab 可能使用 obs 作为输入名,Isaac Lab 可能使用 observations)。部署端在加载 ONNX 时需要确认输入输出名称:
import onnxruntime as ort
session = ort.InferenceSession("policy.onnx")
# 检查输入输出名称
for inp in session.get_inputs():
print(f"Input: {inp.name}, Shape: {inp.shape}, Type: {inp.type}")
for out in session.get_outputs():
print(f"Output: {out.name}, Shape: {out.shape}, Type: {out.type}")
RNN 策略的 ONNX 导出特殊性。 如果策略使用了 LSTM/GRU(通过 RslRlRNNModelCfg),ONNX 的输入不再只有 observation,还包括 hidden state。每次 episode reset 时需要清零 hidden state。这使得部署接口从"单个 forward pass"变成"带状态的序列推理"——复杂度显著增加。如果任务可以用 MLP + history 解决,不要使用 RNN,因为部署更简单。
部署边界文档模板 ⭐⭐⭐
每个训练好的策略都应伴随一份部署边界文档。以下是推荐的模板结构:
# 部署边界文档 - Go1 Velocity Flat
# 框架: mjlab / Isaac Lab
# 训练日期: YYYY-MM-DD
# checkpoint: model_1000.pt
# 1. ONNX 信息
onnx_file: policy.onnx
input_shape: [1, 48] # [batch, obs_dim]
output_shape: [1, 12] # [batch, action_dim]
includes_normalizer: false # 是否包含 obs normalization
# 2. Actor observation 顺序和规格
observations:
- name: base_lin_vel
dim: 3
unit: m/s
frame: body
deploy_source: state_estimator
scale: 1.0
noise_during_train: Uniform(-0.5, 0.5)
- name: base_ang_vel
dim: 3
unit: rad/s
frame: body
deploy_source: IMU_gyroscope
scale: 1.0
noise_during_train: Uniform(-0.2, 0.2)
- name: projected_gravity
dim: 3
unit: dimensionless
frame: body
deploy_source: IMU_attitude_estimate
scale: 1.0
noise_during_train: Uniform(-0.05, 0.05)
- name: joint_pos_rel
dim: 12
unit: rad
frame: joint
deploy_source: encoder - default_joint_pos
scale: 1.0
noise_during_train: Uniform(-0.01, 0.01)
- name: joint_vel
dim: 12
unit: rad/s
frame: joint
deploy_source: encoder_differentiation
scale: 1.0
noise_during_train: Uniform(-1.5, 1.5)
- name: last_action
dim: 12
unit: dimensionless (raw action)
frame: action_space
deploy_source: controller_internal_buffer
scale: 1.0
- name: command
dim: 3
unit: "[m/s, m/s, rad/s]"
frame: body
deploy_source: upper_level_controller
# 3. Action 后处理
action_type: JointPosition
action_scale: [0.25, 0.25, 0.25, ...] # per-joint
action_offset: default_joint_pos # [0.1, 0.8, -1.5, ...]
processed_clip: joint_limits with 5% margin
control_frequency: 50 Hz # decimation=4, physics_dt=0.005
# 4. Joint 映射
joint_order:
- FL_hip, FL_thigh, FL_calf
- FR_hip, FR_thigh, FR_calf
- RL_hip, RL_thigh, RL_calf
- RR_hip, RR_thigh, RR_calf
default_joint_pos: [0.1, 0.8, -1.5, -0.1, 0.8, -1.5, ...]
这份文档是训练环境和部署环境之间的完整契约。缺少任何一项,部署端就无法正确复现策略的输入输出。文档应随 ONNX 文件和 checkpoint 一起版本管理。
ONNX 一致性验证 ⭐⭐
导出 ONNX 后,必须做一致性验证——确保 ONNX 推理和 PyTorch 推理在相同输入下产生相同输出:
# 验证伪代码(两个框架通用)
import onnxruntime as ort
import torch
# 1. 准备固定输入
test_obs = torch.randn(1, obs_dim)
# 2. PyTorch 推理
with torch.no_grad():
pt_output = actor_net(test_obs).numpy()
# 3. ONNX 推理
session = ort.InferenceSession("policy.onnx")
onnx_output = session.run(None, {"obs": test_obs.numpy()})[0]
# 4. 对比
max_diff = abs(pt_output - onnx_output).max()
assert max_diff < 1e-5, f"ONNX mismatch: max diff = {max_diff}"
如果 max_diff > 1e-5,可能的原因包括:(a) 导出时遗漏了 normalization 层,(b) ONNX opset 版本不支持某些 op 导致精度损失,(c) 导出时模型处于 training mode 而非 eval mode(BatchNorm 行为不同)。
train 与 play 的 CLI 差异 ⭐
这个差异在两个框架中都存在,但形式不同:
# mjlab: 训练环境数量使用嵌套字段
uv run train Mjlab-Velocity-Flat-Unitree-Go1 --env.scene.num-envs 4096
# mjlab: 播放环境数量使用顶层字段
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent zero --num-envs 4
# Isaac Lab: 训练环境数量
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Rough-Anymal-C-v0 --num_envs 4096
# Isaac Lab: 播放
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Rough-Anymal-C-v0 --num_envs 4
混用不会报错但参数无效——环境以默认数量创建,实验记录失真。
七步 smoke test 流程 ⭐⭐
以下流程同时适用于 mjlab 和 Isaac Lab(命令格式替换为对应框架)。每一步都有明确的通过标准——如果任何一步失败,不要继续后面的步骤,先修复当前问题。
完整 CLI 命令列表(双框架):
# ===== mjlab 版本 =====
# 实验 1: CLI help
uv run train --help
uv run play --help
# 实验 3: zero agent
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent zero --num-envs 4
# 实验 4: random agent
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent random --num-envs 4
# 实验 5: CPU smoke train (2 iterations)
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 16 --agent.max-iterations 2 \
--agent.logger tensorboard
# 实验 6: GPU train (10 iterations, 512 envs)
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 512 --agent.max-iterations 10
# 实验 7: rough terrain zero agent
uv run play Mjlab-Velocity-Rough-Unitree-Go1 --agent zero --num-envs 4
# ===== Isaac Lab 版本 =====
# 实验 1: CLI help
python scripts/reinforcement_learning/rsl_rl/train.py --help
python scripts/reinforcement_learning/rsl_rl/play.py --help
# 实验 3: zero agent(Isaac Lab 官方零动作 agent 脚本)
python scripts/environments/zero_agent.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 4
# 实验 5: CPU smoke train
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 16 --max_iterations 2
# 实验 6: GPU train
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 512 --max_iterations 10
实验 3 的期望输出示例(mjlab):
[INFO] Creating environment: Mjlab-Velocity-Flat-Unitree-Go1
[INFO] Scene created with 4 environments
[INFO] ObservationManager:
actor: 7 terms, dim=48
base_lin_vel (3), base_ang_vel (3), projected_gravity (3),
joint_pos_rel (12), joint_vel (12), last_action (12), command (3)
critic: 9 terms, dim=72
[actor terms] + foot_contact_forces (12), foot_height (12)
[INFO] ActionManager:
JointPositionAction: dim=12, scale=0.25, use_default_offset=True
[INFO] Agent: zero (all actions = 0.0)
如果打印信息中 actor dim 不是 48 或 action dim 不是 12,立即检查 env cfg——不要继续后续步骤。
诊断辅助脚本——打印 obs/action 统计量:
# diagnostic_print.py —— 在训练早期运行,验证 obs/action 接线
import torch
def print_obs_action_stats(env, num_steps=10):
"""运行 num_steps 步 random agent,打印 obs/action 统计量。"""
obs_dict = env.reset()
for step in range(num_steps):
# random action
actions = torch.rand(env.num_envs, env.action_manager.total_action_dim,
device=env.device) * 2.0 - 1.0
obs_dict, rewards, terminated, truncated, extras = env.step(actions)
# 打印 actor obs 各 term 范围
actor_obs = obs_dict["actor"] # mjlab; Isaac Lab 用 "policy"
print(f"\n--- Step {step} ---")
print(f"actor obs shape: {actor_obs.shape}")
print(f"actor obs range: [{actor_obs.min():.3f}, {actor_obs.max():.3f}]")
print(f"actor obs mean: {actor_obs.mean():.3f}")
print(f"actor obs std: {actor_obs.std():.3f}")
# 打印 action 统计
# 注意:Isaac Lab 的 action_manager.action 是 raw/input action(即上面的 actions),
# 不是 scale/offset 后的控制目标;真正的 processed 目标在各 term 的 processed_actions。
raw_action = env.action_manager.action # raw action sent to env
print(f"raw action range: [{raw_action.min():.3f}, {raw_action.max():.3f}]")
first_term = env.action_manager.active_terms[0]
processed = env.action_manager.get_term(first_term).processed_actions
print(f"processed action range ({first_term}): "
f"[{processed.min():.3f}, {processed.max():.3f}]")
# NaN 检查
if torch.isnan(actor_obs).any():
print("⚠️ WARNING: NaN in actor observation!")
nan_dims = torch.isnan(actor_obs).any(dim=0).nonzero().squeeze()
print(f" NaN dimensions: {nan_dims.tolist()}")
if torch.isnan(processed).any():
print("⚠️ WARNING: NaN in processed action!")
# 使用方法:在 train.py 或 play.py 中 env 创建后调用
# print_obs_action_stats(env, num_steps=5)
这个脚本应该成为你的标准工具——每次创建新任务或修改 obs/action 配置后,先运行 5-10 步 random agent 并检查输出。如果 obs range 超出 \([-50, 50]\) 或 processed action 超出关节限位,说明 scale 或 cfg 有问题。
实验 1-2: 分别运行 train 和 play 的 --help,确认 CLI 参数结构。
通过标准:--help 输出包含环境数量参数和 task id 参数,无 ImportError 或 ModuleNotFoundError。如果这一步失败,说明安装或注册有问题——这不是 obs/action 设计问题,而是环境配置问题。
实验 3: --agent zero --num-envs 4。验证环境创建、manager 打印正常、zero action shape 正确、机器人保持默认姿态。
通过标准:(1) 控制台打印 ObservationManager 和 ActionManager 的摘要信息(group 名、term 数、维度),检查 actor obs dim 和 action dim 是否符合预期。(2) 可视化中机器人处于默认站立姿态不晃动——如果 use_default_offset=True 且 default joint pos 正确,zero action 意味着 raw_action=0,processed_action=default_joint_pos。(3) 没有 NaN 警告。如果机器人姿态异常(劈叉、悬空、穿透地面),先检查 default_joint_pos 和 MJCF/USD 中的 joint qpos0 是否一致。
实验 4: --agent random --num-envs 4。验证 random action 采样正常、环境连续 step、observation 无 NaN。
通过标准:(1) 机器人在 random action 下应该有明显但不极端的运动——如果完全不动,说明 action scale 可能太小或 actuator 没有连接。(2) 如果机器人瞬间飞出画面或穿透地面,说明 action scale 太大。(3) 连续运行 100 steps 无 NaN 或 Inf。(4) 如果打开了 nan_policy="warn",观察是否有 term-level NaN 警告——如果有,定位是哪个 term。
实验 5: 小规模 CPU 训练(2 iteration)。验证无 group key error 或 action dim mismatch。
通过标准:(1) RSL-RL 正常初始化并开始第一个 rollout。(2) 无 TensorDict/key error——最常见的原因是 env 的 obs group 名与 rl_cfg 的 obs_groups 映射不匹配(mjlab 用 actor/critic,Isaac Lab 用 policy/critic)。(3) 第一个 iteration 的 loss 值不是 NaN。
实验 6: 中等规模 GPU 训练(10 iteration, 512 envs)。验证 steps/s 稳定、显存不持续增长、reward 无 NaN。
通过标准:(1) steps/s 在合理范围内(Go1 velocity flat 通常 8000-15000 steps/s,取决于 GPU)。(2) 显存在前 2-3 iteration 增长到稳定值后不再增长——如果持续增长,可能存在 tensor 泄漏。(3) mean reward 不是 NaN,policy loss 和 value loss 在合理范围。
实验 7: 完整任务 zero agent(如 rough terrain 变体)。验证 terrain scan sensor 存在、critic 包含 privileged terms。
通过标准:(1) ObservationManager 打印中 critic group 包含额外的 privileged terms(如 foot_contact_forces、height_scan without noise)。(2) 如果是 rough terrain,scene 中应包含 terrain 和 height scanner sensor。
从 smoke test 到系统性验证 ⭐⭐
七步 smoke test 覆盖了"接线是否正确"的基本面,但更深层的 obs/action 设计问题需要训练 50-100 iteration 后才能暴露。以下是"中期检查"的要点:
reward 分项分析。 在 TensorBoard 中查看 reward 的各个分项(如 lin_vel_tracking、ang_vel_tracking、action_rate、joint_limit)。如果 command tracking reward 不上升但 action_rate penalty 在下降,说明策略在学"不动"——可能是 action scale 太小或 command 没有正确进入 obs。
如果所有 reward 分项都在上升但总 reward 不够高,检查 reward 权重配比——tracking reward 是否被 regularization penalty 淹没了。这属于 Ch06 的范畴,但根因可能在 obs/action:如果 action scale 太大导致高频抖动,action_rate penalty 会占据过大比例,挤压 tracking reward 的贡献。
action 分布检查。 打印 raw action 的 mean 和 std。如果 mean 长期偏离 0(比如 mean > 1.0),说明 default offset 可能不对——策略需要一个持续的偏移来维持基本姿态。如果 std 持续下降到接近 0,说明策略过早收敛到确定性行为——可能是 entropy bonus 太弱或 action scale 太大导致探索代价过高。
action 分布的一个健康信号:训练 200 iteration 后,raw action 的 per-joint std 应该呈现"大关节 std 大、小关节 std 小"的模式——膝关节(主要负责推进的大关节)的动作幅度通常大于髋关节外展(主要负责微调的小关节)。如果所有关节的 std 完全相同,可能是策略还没有学到有意义的行为,或 action scale 不是 per-joint 设定的。
obs 值域检查。 训练 100 iteration 后,打印 actor observation 的 per-term 统计量。如果某个 term 的范围比其他 term 大两个数量级以上(比如 joint_vel 在 \([-20, 20]\) 而其他 term 在 \([-1, 1]\)),考虑加入 term-level scale。
sim-to-sim 验证。 如果同时使用 mjlab 和 Isaac Lab,在两个框架中都训练到 100 iteration,对比 TensorBoard 曲线。如果曲线差异很大(如一个收敛另一个不收敛),说明 obs/action 配置不完全对等——回到 5.2 原则五的验证流程。注意两个框架的物理引擎(MuJoCo Warp vs PhysX)本身就有差异,所以 reward 数值不需要完全相同,但收敛趋势和最终行为应该定性一致。
中期诊断自动化脚本 ⭐⭐
以下脚本可以在训练 100 iteration 后自动执行上述检查,生成诊断报告:
# mid_training_diagnostic.py
import torch
import os
def run_diagnostic(env, runner, log_dir, num_eval_steps=200):
"""在训练中期运行,自动生成 obs/action 诊断报告。"""
report = []
report.append("=" * 60)
report.append("Ch05 Obs/Action Mid-Training Diagnostic Report")
report.append("=" * 60)
# 1. obs 维度和值域检查
obs_dict = env.reset()
actor_obs = obs_dict["actor"] # mjlab; Isaac Lab 用 "policy"
report.append(f"\n[1] Actor obs dim: {actor_obs.shape[1]}")
# 收集 N 步的 obs 统计
obs_buffer = []
action_buffer = []
policy = runner.get_inference_policy()
for _ in range(num_eval_steps):
with torch.no_grad():
actions = policy(actor_obs)
obs_dict, _, _, _, _ = env.step(actions)
actor_obs = obs_dict["actor"]
obs_buffer.append(actor_obs.clone())
action_buffer.append(actions.clone())
all_obs = torch.stack(obs_buffer) # [T, N, obs_dim]
all_act = torch.stack(action_buffer) # [T, N, act_dim]
# 2. per-dim obs 统计
obs_mean = all_obs.mean(dim=(0, 1))
obs_std = all_obs.std(dim=(0, 1))
obs_min = all_obs.min(dim=0).values.min(dim=0).values
obs_max = all_obs.max(dim=0).values.max(dim=0).values
report.append(f"\n[2] Obs per-dim stats (first 10 dims):")
for i in range(min(10, obs_mean.shape[0])):
report.append(f" dim {i:3d}: mean={obs_mean[i]:7.3f}, "
f"std={obs_std[i]:7.3f}, "
f"range=[{obs_min[i]:7.3f}, {obs_max[i]:7.3f}]")
# 3. 检查 obs 值域异常
large_range = (obs_max - obs_min) > 50.0
if large_range.any():
dims = large_range.nonzero().squeeze().tolist()
report.append(f"\n⚠️ Obs dims with range > 50: {dims}")
report.append(" Consider adding term-level scale for these dims.")
# 4. action 统计
act_mean = all_act.mean(dim=(0, 1))
act_std = all_act.std(dim=(0, 1))
report.append(f"\n[3] Action stats:")
report.append(f" raw action mean: {act_mean.tolist()[:6]}...")
report.append(f" raw action std: {act_std.tolist()[:6]}...")
# 检查 action mean 偏移
if act_mean.abs().max() > 1.0:
report.append(f"\n⚠️ Action mean significantly offset from 0 "
f"(max |mean| = {act_mean.abs().max():.3f})")
report.append(" Check default_joint_pos / use_default_offset.")
# 检查 action std 异常
if act_std.mean() < 0.1:
report.append(f"\n⚠️ Action std very low ({act_std.mean():.3f}), "
f"possible premature convergence.")
# 5. NaN 检查
nan_count = torch.isnan(all_obs).sum().item()
report.append(f"\n[4] NaN count in {num_eval_steps} steps: {nan_count}")
if nan_count > 0:
report.append(" 🔴 NaN detected! Use --enable-nan-guard True to debug.")
# 写入文件
report_text = "\n".join(report)
report_path = os.path.join(log_dir, "obs_action_diagnostic.txt")
with open(report_path, "w") as f:
f.write(report_text)
print(report_text)
print(f"\nDiagnostic saved to: {report_path}")
# 使用:在训练 callback 中调用
# run_diagnostic(env, runner, log_dir="/tmp/mjlab/logs/diagnostic")
这个脚本产出一份文本报告,覆盖了"中期检查"的所有要点。建议在训练的第 50、100、500 iteration 各运行一次,对比报告变化——如果 obs 值域在训练过程中显著扩大,可能需要调整 term-level scale 或添加 clip。
双框架对比验证脚本(在跨框架迁移任务时使用):
# cross_framework_verify.py
def compare_obs_across_frameworks(mjlab_env, isaaclab_env, num_steps=5):
"""对比两个框架的 obs term 数值,确认语义一致性。"""
# Reset 到默认状态
mjlab_obs = mjlab_env.reset()["actor"] # mjlab
isaaclab_obs = isaaclab_env.reset()["policy"] # Isaac Lab
print(f"mjlab actor obs dim: {mjlab_obs.shape[1]}")
print(f"Isaac Lab policy obs dim: {isaaclab_obs.shape[1]}")
if mjlab_obs.shape[1] != isaaclab_obs.shape[1]:
print("⚠️ Obs dimensions differ! Check term lists.")
return
# 逐维度对比 reset 后第一帧
diff = (mjlab_obs[0] - isaaclab_obs[0]).abs()
max_diff = diff.max().item()
print(f"\nMax per-dim diff at reset: {max_diff:.6f}")
if max_diff > 0.05:
bad_dims = (diff > 0.05).nonzero().squeeze().tolist()
print(f"⚠️ Dims with >5% diff: {bad_dims}")
print(" Check coordinate conventions, joint ordering, default poses.")
else:
print("✅ All dims match within 5% tolerance at reset.")
⚠️ 常见陷阱
⚠️ 编程陷阱:history 顺序部署反了。 训练中 CircularBuffer.buffer 返回 oldest-to-newest,部署端拼接成 newest-to-oldest。维度一致不会报错,但策略看到的时间语义完全反转。自检方法:用固定序列做单元测试。
💡 概念误区:认为拿到 ONNX 文件就拥有完整控制器。 ONNX 只是"函数体",输入输出约定(observation 顺序、scale、action postprocess)都是"调用约定",必须单独文档化。这与软件工程中的 ABI(Application Binary Interface)完全类比——.so 文件包含编译后的代码,但调用约定、参数类型和返回值规范是 ABI 的一部分,不在 .so 文件内。
⚠️ 编程陷阱:过大 action scale 引发 NaN 传播链。 过大 scale → 物理冲击 → 大 contact force → solver 不稳定 → state NaN → observation NaN → 网络输出 NaN。源头是 action scale,不是 physics bug。
练习
- [检查题] 阅读 velocity runner 的
save(),确认 ONNX 导出时是否包含 normalizer。在 mjlab 和 Isaac Lab 中分别验证。 - [设计题] 为 velocity 任务写部署边界文档,包含 actor observation 名称和顺序、每个 term 的单位和 scale、action scale/offset/clip、控制频率、history 规格。
- [实验题] 按七步 smoke test 完整执行,在 mjlab 中记录每步输出和观察结果。任何步骤失败则记录错误信息并尝试诊断。
- [编程题] 修改
diagnostic_print.py,添加 per-term 的统计量打印(而非整个 obs 的统计)。提示:使用env.observation_manager.active_terms获取每个 term 的名称和维度切片信息,然后对 concatenated obs tensor 做切片分析。 - [编程题] 编写 ONNX 一致性验证脚本。完成以下步骤:(a) 加载 checkpoint 的 actor 网络,(b) 生成 10 个随机 obs 输入,(c) 分别用 PyTorch 和 ONNX Runtime 推理,(d) 对比输出的 max absolute difference,(e) 如果差异 > 1e-5,打印警告。以下是起始框架:
# onnx_verify.py
import torch
import onnxruntime as ort
import numpy as np
def verify_onnx_consistency(pt_model, onnx_path, obs_dim, num_tests=10):
"""验证 ONNX 和 PyTorch 模型的输出一致性。"""
session = ort.InferenceSession(onnx_path)
input_name = session.get_inputs()[0].name
pt_model.eval()
max_diff = 0.0
for i in range(num_tests):
test_obs = torch.randn(1, obs_dim)
# PyTorch 推理
with torch.no_grad():
pt_output = pt_model(test_obs).numpy()
# ONNX 推理
onnx_output = session.run(None, {input_name: test_obs.numpy()})[0]
diff = np.abs(pt_output - onnx_output).max()
max_diff = max(max_diff, diff)
if diff > 1e-5:
print(f"⚠️ Test {i}: max diff = {diff:.2e}")
if max_diff < 1e-5:
print(f"✅ ONNX verification passed. Max diff: {max_diff:.2e}")
else:
print(f"🔴 ONNX mismatch detected! Max diff: {max_diff:.2e}")
print(" Possible causes: missing normalizer, eval/train mode mismatch, "
"opset version issue.")
return max_diff
上节解决了部署边界和 smoke test 的问题。但工程实践中,obs/action 错误的症状往往模糊——需要一套系统性的排查框架。这是调参攻略要解决的问题。
5.9 调参攻略与 Debug Checklist ⭐⭐
这一节解决什么问题:提供系统性的 obs/action 故障诊断框架,覆盖从 CLI 错误到 sim-to-real 失败的全链路。
总调参表 ⭐⭐
| 失败现象 | 优先怀疑 | 第一检查项 | 调整方向 |
|---|---|---|---|
| TensorDict key error | obs group 名不匹配 | env group 与 obs_groups 映射 |
改 group 名(注意 mjlab vs Isaac Lab 命名差异) |
| action shape mismatch | action term 维度不匹配 | total_action_dim |
检查 actuator_names 和 action term 维度 |
| zero agent 姿态异常 | default offset 错 | default_joint_pos | 检查 use_default_offset 和 MJCF/USD 默认角 |
| random agent 数值爆炸 | action scale 太大 | processed action 范围 | 降低 scale 或加 clip |
| policy 不跟命令 | command 缺失 | actor command term | 加 command term,确认 command manager 在 obs 前加载 |
| value loss 高 | critic 输入不足 | critic group terms | 加 privileged term(contact, terrain 等) |
| ONNX play 异常 | preprocessing 缺失 | obs order, scale, normalizer | 对齐部署边界文档 |
| 实机抖动 | delay/scale 不匹配 | sensor latency 建模 | 加 delay 或降 scale |
| sim-to-real 差 | actor 看了 privileged | actor term 部署可得性 | 移除 privileged term 到 critic |
Debug Checklist ⭐⭐
A. CLI 和任务选择:
- train 用正确的嵌套环境数量参数(mjlab: --env.scene.num-envs;Isaac Lab: --num_envs)
- play 用正确的顶层参数
- task id 注册一致
- play mode 关闭噪声和 curriculum
B. Observation group:
- actor/critic group 打印正常(观察 manager 的 __str__() 输出)
- obs_groups 映射与 group 名一致
- actor 不含 privileged 真值(contact force、terrain exact height、object true pose)
- critic 含必要 privileged terms
- actor 含 command term
- 每个 term 的 shape 以 num_envs 开头
C. Observation values:
- zero/random agent 下无 NaN/Inf(用 nan_policy="warn" 检查)
- joint_pos_rel reset 后接近 0
- actor noise 只在 train 启用(play 关闭 corruption)
- 双框架中同名 term 的数值范围一致
D. History and delay:
- delay lag 换算成毫秒(lag \(\times\) env_step_dt)
- history 是否改变了 obs dim(检查 ObservationManager.__str__() 的维度输出)
- reset 后 backfill 正常(CircularBuffer 的 backfill 行为)
- 部署端维护同样的 history buffer
E. Action pipeline: - total_action_dim = policy output dim(打印 ActionManager 检查) - raw action 无量纲 - processed action 范围在关节限位内 - default offset 对应安全站立姿态 - 多 action term 的切片顺序与部署端一致
F. Deployment: - obs_normalization 设置明确(开/关/统计量是否保存) - ONNX metadata 含 obs names - action scale/offset 在 ONNX 外部署端复现 - 控制频率与训练一致(decimation \(\times\) physics_dt)
⚠️ 常见陷阱
⚠️ 编程陷阱:过大 action scale 引发 NaN 传播链。 过大 scale → 物理冲击 → 大 contact force → solver 不稳定 → state NaN → observation NaN → 网络输出 NaN。源头是 action scale,不是 physics bug。在 mjlab 中可以用 MuJoCo 的 mjWARNING 检测 solver 问题,在 Isaac Lab 中可以检查 PhysX 的 contact report。
🧠 思维陷阱:认为"训练调通了就可以部署了"。 训练中 VecEnv 和 manager 承担大量自动化处理。部署必须手动复现 observation 顺序/scale、history、action scale/offset、控制频率和 safety clip。
练习
- [排查题] 四足机器人训练后 play 正常,ONNX 在外部 runtime 动作偏移。按 Debug Checklist 排查,最可能原因是什么?设计最小复现实验。
- [设计题] 为你的任务编写"observation/action 接口文档",包含所有 actor term 的名称/维度/单位/scale/部署来源,和所有 action term 的 scale/offset/clip/部署后处理。使用 5.8 节的 YAML 模板格式。
- [编程题] 写一个 Python 脚本,读取 checkpoint 目录中的 obs_normalizer 统计量(mean 和 std),可视化每个 obs 维度的归一化范围。如果某个维度 std < 1e-6(常数信号),报告该维度可能冗余。
- [跨章综合题] 假设你在 Ch06 的 reward 设计中添加了一个"足端滑移惩罚"(需要 foot velocity 和 contact 信息)。这个 reward term 需要的信息是否在 actor observation 中?如果不在,它是否应该添加到 actor observation?如果不应该添加(因为部署不可得),reward 和 actor obs 之间的这种"信息不对称"对策略学习有什么影响?(提示:reward 可以使用 env state 的任何信息,即使 actor 看不到——策略会通过试错隐式学到 reward 激励的行为模式。)
上节提供了系统化的调参和 debug 工具。但在实际项目中,最常见的挑战是"从零开始为一个全新任务设计 obs/action"——这需要把前面所有知识串联起来形成可执行的工作流。5.10 节提供了一个六步流程,从信号盘点到 smoke test,每一步都有明确的输入、输出和验证标准。如果你正在启动一个新项目,建议直接从 5.10 开始,然后回到前面的节查找具体问题的深入讲解。
5.10 实战工作流:从零设计新任务的 Obs/Action ⭐⭐⭐
这一节解决什么问题:提供一个可操作的、分步骤的工作流,指导如何为一个全新的机器人任务从零设计 observation 和 action 配置。
第一步:任务分析与信号盘点 ⭐⭐
在写任何 cfg 之前,先回答三个核心问题:
Q1:策略的决策信息是什么? 列出策略做出正确决策所需的所有信息。不要从传感器出发,而是从"策略需要知道什么"出发。例如,速度跟踪任务需要知道"当前速度是多少"(状态感知)、"目标速度是多少"(任务条件)、"身体姿态如何"(平衡信息)、"上一拍做了什么"(动作连续性)。这一步的产出是一个信号列表,每个信号标注其物理含义和决策作用。
Q2:哪些信号部署时可得? 对 Q1 的信号列表逐项审查:这个信号在真实机器人上从哪个传感器获取?精度和延迟如何?如果某个信号仿真可得但部署不可得(如精确接触力),标记为 "privileged → critic only"。如果信号部署可得但有噪声(如 IMU 角速度),标记噪声模型参数。
Q3:策略控制什么? 确定 action 的物理含义。对 locomotion 任务,通常是 joint position(通过 PD 控制器)。对 manipulation 任务,可能是 joint position、DiffIK 或混合。确定每个 action 维度的物理范围和安全限位。
第二步:设计 actor/critic group 结构 ⭐⭐
基于第一步的分析,构建 observation group:
# 伪代码——两个框架的逻辑结构相同
actor_terms = {
# 状态感知(部署可得)
"base_ang_vel": ObsTermCfg(func=base_ang_vel, noise=imu_noise),
"projected_gravity": ObsTermCfg(func=projected_gravity, noise=attitude_noise),
"joint_pos_rel": ObsTermCfg(func=joint_pos_rel, noise=encoder_noise),
"joint_vel": ObsTermCfg(func=joint_vel_rel, noise=vel_noise),
# 动作历史
"last_action": ObsTermCfg(func=last_action),
# 任务条件
"command": ObsTermCfg(func=generated_commands),
}
critic_terms = {
**actor_terms, # critic 包含 actor 所有 term
# 额外 privileged(部署不可得)
"foot_contact_forces": ObsTermCfg(func=foot_contact_forces),
"terrain_height": ObsTermCfg(func=terrain_height_scan),
}
这个结构遵循一个简单规则:actor terms \(\subset\) critic terms。critic 永远是 actor 的超集——它看到 actor 看到的一切,再加上额外的 privileged 信号。在 mjlab 中,这个结构自然落入 actor 和 critic 两个 group;在 Isaac Lab 中,落入 policy 和 critic 两个 group。
第三步:确定 action 配置 ⭐⭐
action 配置的核心参数是 scale、offset 和 clip:
scale 的初始估计方法:计算目标关节的最大合理变化幅度(从默认姿态出发),除以 2-3(因为 PPO 的初始 \(\sigma \approx 1\),\(2\sigma\) 覆盖约 95% 的采样)。例如,Go1 髋关节范围约 \([-0.8, 0.8]\) rad,默认位置 0.1 rad,最大偏移约 0.7 rad,初始 scale 约 \(0.7 / 3 \approx 0.25\)。
offset 的设置原则:使用 use_default_offset=True,让零 raw action 对应默认站立姿态。只有在默认姿态不合适的特殊场景才考虑自定义 offset。
clip 的设置原则:processed clip 基于关节物理限位,留 5-10% 安全余量。不要对 raw action 设过紧的 clip——这会截断 PPO 的 Gaussian 分布尾部,干扰梯度估计。
第四步:维度预算与 sanity check ⭐⭐
计算总维度并评估是否合理:
| 检查项 | 健康范围 | 警示信号 |
|---|---|---|
| actor obs dim | 30-100(locomotion),50-200(manipulation) | > 500 说明可能包含冗余或未压缩的高维信号 |
| critic obs dim | actor dim + 10-50 | 远大于 actor → 检查是否给了不必要的 privileged |
| action dim | 等于被控关节数 | 不等 → action term 配置错误 |
| obs dim \(\times\) hidden dim | < 500K(第一层参数) | > 1M → 考虑降维或 term-level scale |
如果维度预算超标,优先降低 history length,其次考虑是否有冗余 term,最后考虑对高维信号(如 height scan)做降采样。
第五步:双框架对等验证 ⭐⭐
在 mjlab 和 Isaac Lab 中分别实现上述配置,然后进行对等验证:
- 两个框架中分别运行 zero agent,打印 actor obs 的每个 term 值,比较是否一致
- 检查 action dim 和 action 变换链(scale/offset/clip)在两个框架中是否等价
- 用相同的 random seed 运行 5 步,检查 processed action 数值是否匹配
- 确认 obs group 名称映射正确(mjlab actor→Isaac Lab policy,mjlab critic→Isaac Lab critic)
如果发现数值不一致,最常见的原因是:(a) 坐标系约定不同(body frame 旋转方向),(b) 默认关节角度的数值来源不同(MJCF vs USD),(c) 关节排序不同。这些差异必须在训练之前解决。
第六步:执行 smoke test 并迭代 ⭐⭐
按 5.8 节的七步 smoke test 流程验证。如果 smoke test 通过,开始 100 iteration 的试训练。观察 reward 分项的变化趋势:
如果 command tracking reward 在 50 iteration 内开始上升——说明 obs/action 基本正确,可以继续调优 reward 权重。
如果 command tracking reward 完全不动——先检查 command 是否在 actor obs 中,再检查 action scale 是否合理(策略能否产生足够的运动幅度)。
如果 reward 上升但行为异常(如高频抖动、不对称步态)——检查 action rate penalty 和 action scale 的配合。
这个"设计→验证→迭代"的循环通常需要 2-3 轮。关键经验是:不要在 obs/action 设计阶段花太多时间追求完美——先用合理的默认值开始训练,然后根据训练中暴露的问题迭代修正。过度设计的 obs/action 不如快速迭代有效。
⚠️ 常见陷阱
⚠️ 编程陷阱:复制粘贴另一个任务的 obs/action cfg 到新任务。 每个任务有独特的信号需求和 action 范围。velocity 的 obs cfg 不能直接用于 manipulation——关节配置、传感器集、command 语义都不同。正确做法是用上述六步流程从零设计,然后参考现有任务的 cfg 作为对照。
🧠 思维陷阱:在 obs/action 设计阶段过度优化。 花一周时间精心设计 observation 的每一个细节,不如花半天做一个合理的初始设计、训练 100 iteration、根据结果迭代。快速反馈循环比一次性设计更有效——因为很多问题只有在训练中才能暴露。
案例对比:Velocity 任务 vs Manipulation 任务的 Obs/Action 差异 ⭐⭐⭐
为了让六步工作流不停留在 locomotion 领域,让我们用 mjlab 的 YAM arm lift cube 任务和 Go1 velocity 任务做一个系统性对比。这个对比展示了同一个框架、同一套设计原则在不同任务类型中的具体应用差异。
| 设计维度 | Go1 Velocity | YAM Lift Cube |
|---|---|---|
| Q1 核心信号 | 当前速度、姿态、关节状态、command | 关节状态、末端位姿、物体位姿、目标位姿 |
| Q2 部署限制 | base_lin_vel 需状态估计器 | 物体位姿需视觉估计 |
| Actor obs 典型组成 | ang_vel(3) + gravity(3) + joint_pos(12) + joint_vel(12) + last_action(12) + cmd(3) = 45D | joint_pos(7) + joint_vel(7) + ee_pos(3) + ee_quat(4) + obj_pos(3) + obj_quat(4) + goal_pos(3) + last_action(7) = 38D |
| Critic 额外 privileged | contact forces, terrain height, true base vel | true object velocity, contact state, gripper force |
| Action type | JointPosition (12D) | JointPosition 7D(6 臂关节 + 1 夹爪,夹爪双指经 equality 耦合,详见 Ch17) |
| Action scale 量级 | ~0.25 rad(步态幅度) | ~0.1 rad(精细定位) |
| Coordinate frame | body frame(平移不变) | world 或 base frame(取决于设计) |
| Command | velocity (3D) | object goal position (3D) |
| 控制频率 | 50 Hz (decimation=4) | 20-50 Hz(取决于接触精度需求) |
这个对比揭示了几个重要的设计原则差异:
物体相对信号是 manipulation 的核心。 Locomotion 的 actor obs 中没有任何"外部物体"信号——机器人只需要知道自己的状态和目标速度。Manipulation 则必须包含 end-effector-to-object 和 object-to-goal 的相对位姿——这些信号是策略判断"手离杯子多远"和"杯子离目标多远"的唯一依据。如果缺少这些信号,策略无法学会抓取。
State-based vs vision-based 是 manipulation 的关键分支。 YAM lift cube 的 state-based 变体直接把物体位姿作为 actor obs(这在仿真中可以精确获取)。Vision 变体则用 camera RGB/depth 替换物体位姿——actor 需要从像素中推断物体位置。这个选择直接决定了 actor 的网络架构(MLP vs CNN)和训练难度。在 mjlab 中,从 state-based 切换到 vision-based 只需要修改 actor group 中的 obs terms——framework 的 manager 架构使得这种切换不需要修改 env 逻辑。
Gripper action 是一个独立的 action term。 Locomotion 的所有关节共用同一种 action type(JointPosition),但 manipulation 通常需要将 arm joints 和 gripper 分开配置。Gripper 的 scale 和 clip 与 arm 完全不同——gripper 的物理范围可能是 0.0 到 0.08 m(finger opening),而 arm joint 的范围是 ±π/2 rad。在 mjlab 中,这通过多个 action term 实现,每个 term 有独立的 ActionTermCfg。
坐标系选择更复杂。 Locomotion 几乎总是使用 body frame(让策略学习平移不变的行为)。Manipulation 则需要在多个坐标系之间做选择:end-effector frame(好处是抓取动作对物体位姿不变)、base frame(好处是导航和操作在同一个坐标系)、world frame(最简单但策略需要学习位置依赖的行为)。velocity baseline 选择 body frame 的经验法则不能直接搬到 manipulation——需要根据任务的不变性需求选择。
练习
- [设计题] 为一个双臂人形机器人设计 obs/action,使其能同时行走和用右手推门。按六步工作流给出每一步的具体产出,并在 mjlab 和 Isaac Lab 中写出 cfg 结构(伪代码即可)。
- [对比题] 对比 velocity task 和一个 manipulation task(如桌面抓取)的 obs/action 设计。列出至少五个结构性差异(action type、command 语义、坐标系、privileged signals、action scale 量级),分析每个差异背后的工程原因。
- [跨章综合题] 假设你将在 Ch08 中为 Go1 velocity task 添加 domain randomization(摩擦系数 0.5-1.5、额外载荷 0-3 kg、电机力矩缩放 0.8-1.2)。这些 DR 参数应该进入 actor obs 还是 critic obs?如果放入 critic obs,它们属于哪类 privileged signal?如果不放入任何 obs,DR 参数如何通过 obs history 被隐式编码?结合 RMA(Kumar et al. 2021)的 adaptation module 思想,说明 obs 设计如何为后续的 teacher-student 蒸馏做准备。
5.11 源码阅读路线 ⭐⭐
这一节解决什么问题:提供四条从浅入深的源码阅读路线,覆盖双框架。
路线 A:mjlab manager 主链
src/mjlab/envs/manager_based_rl_env.py—load_managers()顺序、step()时序src/mjlab/managers/observation_manager.py— term/group 定义、处理链src/mjlab/managers/action_manager.py— raw action buffer、action slicingsrc/mjlab/envs/mdp/actions/actions.py— scale/offset/clip 顺序、default offsetsrc/mjlab/envs/mdp/actions/differential_ik.py— 任务空间 IK action
路线 B:Isaac Lab 对等路径
source/isaaclab/envs/manager_based_rl_env.py— 对应 mjlab 的 env 基类source/isaaclab/managers/observation_manager.py— obs group 和 term 拼接source/isaaclab/managers/action_manager.py— action term 分发source/isaaclab/envs/mdp/actions/joint_actions.py— joint position/velocity/effortsource/isaaclab/envs/mdp/actions/differential_ik_actions.py— IK action
路线 C:velocity 任务对比
- mjlab:
src/mjlab/tasks/velocity/velocity_env_cfg.py— actor/critic terms 差异 - mjlab:
src/mjlab/tasks/velocity/config/go1/env_cfgs.py— rough/flat 覆盖、action scale - Isaac Lab:
source/isaaclab_tasks/velocity/config/anymal_c/— ANYmal 配置对比 - 重点对比:group 命名、noise 数值、action scale 差异
这条路线的阅读目标不是"两个框架怎么写 cfg",而是"同一个任务的 obs/action 设计决策在两个框架中如何体现"。关注以下对比维度:
| 对比维度 | 阅读时关注什么 |
|---|---|
| actor obs 维度 | 是否完全一致?维度不一致通常说明某个 term 有差异 |
| critic 额外 terms | 哪些 privileged signal 被包含?数量和类型是否对等 |
| noise 数值 | 同名 term 的噪声范围是否匹配?不匹配说明 sim-to-real 假设不同 |
| action scale | 同一 robot 在两个框架中的 scale 是否一致?不一致可能反映不同的 actuator 建模 |
| 默认关节角度 | MJCF 和 USD 中的 default pose 数值是否完全一致?这是最常见的差异来源 |
路线 D:RSL-RL 接口
src/mjlab/rl/config.py/ Isaac Lab rsl_rl config —obs_groups、obs_normalization、clip_actionssrc/mjlab/rl/vecenv_wrapper.py/ Isaac Lab wrapper — TensorDict、dones/time_outs- runner ONNX 导出 — 两个框架共用 RSL-RL 导出逻辑
RSL-RL 接口是 env 和 RL 算法之间的桥梁。重点阅读 wrapper 中的 get_observations() 方法——它决定了 env 的 obs dict 如何变成 runner 需要的 TensorDict 格式。如果你新增了一个 obs group 但没有在 runner config 的 obs_groups 映射中注册,runner 根本不会读取这个 group。这是 5.4 节 TensorDict key error 的根本原因。
wrapper 的 step() 方法中还有两个关键的语义转换需要关注。第一,terminated | truncated 合并为 RSL-RL 的 dones(long tensor)——注意 RSL-RL 要求 long 类型而非 bool。第二,truncated 信息被放入 extras["time_outs"],RSL-RL 据此决定是否对截断 episode 做 value bootstrap。如果 time_outs 缺失或错误,超时截断的 episode 会被当作 terminal failure——策略学到"避免活太久"而非"持续表现好"。
路线 E:manipulation 任务对照
src/mjlab/tasks/manipulation/lift_cube_env_cfg.py— state-based actor terms(注意 ee-to-cube 和 cube-to-goal 向量的坐标系)src/mjlab/tasks/manipulation/config/yam/env_cfgs.py— YAM arm action scale 和 gripper 配置src/mjlab/tasks/manipulation/mdp/observations.py— object/goal/camera observations(视觉变体如何替换低维真值)- Isaac Lab:
source/isaaclab_tasks/manager_based/manipulation/lift/— Franka lift 任务对照
这条路线与路线 C 形成 locomotion ↔ manipulation 的对比。关键差异:manipulation actor obs 包含 object-relative 信号(如 end-effector 到物体的相对位姿),这些信号在 locomotion 中不存在;manipulation action 通常包含 gripper 维度(open/close),需要独立的 scale 和 clip;manipulation 的 privileged signal 通常是物体的真实位姿和接触状态——部署时需要视觉估计替代。
路线 F:HOVER 多模态 obs(进阶)
(以下为 NVlabs/HOVER 官方仓库当前路径,截至 2026-06 主分支;该仓库是 Isaac Lab extension)
1. neural_wbc/isaac_lab_wrapper/ — IsaacLab 环境封装、多模态 obs group 和 mask 定义
2. neural_wbc/core/ — 自定义 obs/mask 生成、目标编码等核心实现
3. scripts/rsl_rl/train_teacher_policy.py、scripts/rsl_rl/train_student_policy.py — teacher 训练与 mask-conditioned distillation 的 student 训练脚本
4. 重点阅读:mask 维度与目标维度的对应关系、蒸馏 loss 的实现细节(默认配置为 specialist,generalist 需显式设置 distill_mask_modes)
阅读原则:先 task config → 再 Manager → 最后 base class。这个顺序对两个框架都适用。从 config 出发可以建立"这个任务需要什么"的全局图景,然后再深入 Manager 了解"怎么实现",最后查 base class 了解"为什么这样设计"。如果反过来从 base class 读起,很容易迷失在通用接口中而忘记任务上下文。
本章小结
| 知识点 | 核心结论 | 难度 |
|---|---|---|
| observation 设计第一原则 | 先问部署可得性,再问信息有效性 | ⭐⭐ |
| 五条设计原则 | 马尔可夫性 / 可部署性 / 低维性 / 信噪比 / 跨框架一致性 | ⭐⭐ |
| 五层架构 | 物理状态 → 传感器 → MDP 接口 → 训练辅助 → 部署封装 | ⭐⭐ |
| actor/critic 分离 | actor 只看部署可得信号,critic 可看 privileged | ⭐⭐⭐ |
| privileged learning 本质 | 降低 value 估计方差,不是让 actor 作弊 | ⭐⭐⭐ |
| 处理链顺序 | Isaac Lab:compute → modifiers → noise → clip → scale → history;mjlab:compute → noise → clip → scale → delay → history | ⭐⭐ |
| raw action 无量纲 | 物理单位由 action term 的 scale/offset 引入 | ⭐⭐ |
| default offset | 让零 raw action 对应安全姿态 | ⭐⭐ |
| action type 选型 | locomotion → JointPosition;manipulation → DiffIK | ⭐⭐ |
| delay vs noise | delay 改变时间索引,noise 改变数值,不可互替代 | ⭐⭐⭐ |
| decimation 耦合 | 改变 decimation 同时影响 delay/action rate/PD 追踪/throughput | ⭐⭐ |
| HOVER 多模态 obs | mask 机制让统一策略适应多种控制模态 | ⭐⭐⭐ |
| ONNX 边界 | ONNX 只含 actor 网络,不含完整控制器 | ⭐⭐⭐ |
| 部署边界文档 | 每个 ONNX 必须伴随完整的输入/输出接口规格文档 | ⭐⭐ |
| 双框架 group 命名 | mjlab: actor/critic;Isaac Lab: policy/critic | ⭐ |
| obs_groups routing | tuple 可拼接多 group;显式指定不存在的 group 会 raise ValueError,遗漏 default set(如 critic)时才回退到同名/policy 并发 warning(可能 critic 缺 privileged) |
⭐⭐ |
| instance-based vs class-inherited | mjlab 避免 __post_init__ 副作用,更 explicit |
⭐⭐ |
| base_lin_vel 部署困境 | 三种方案:移除 / 状态估计器 / teacher-student 蒸馏 | ⭐⭐⭐ |
| RSL-RL 5.x API | 独立 actor/critic config,GaussianDistributionCfg |
⭐⭐ |
| viz-nan 三层防护 | NaN Guard → NaN Termination → viz-nan 事后回放 | ⭐⭐ |
| 从零设计工作流 | 信号盘点 → group 设计 → action 配置 → 维度预算 → 对等验证 → smoke test | ⭐⭐ |
| locomotion vs manipulation obs | 物体相对信号、坐标系选择、多段 action 是核心差异 | ⭐⭐ |
本章覆盖了三大认知模式的转变:
从"输入输出格式"到"系统契约"。 Observation 和 action 不仅是张量形状——它们定义了训练系统和部署系统之间的完整协议。学完本章后,你应该把 obs/action 设计当作系统集成问题,而不是数据格式问题。每当你修改一个 observation term 或 action scale,应该问自己:这个修改对部署端有什么影响?部署边界文档需要更新吗?
从"单框架思维"到"双框架对等"。 mjlab 和 Isaac Lab 在 obs/action 的语义层面高度一致,但 API 细节和命名约定存在差异(instance-based vs class-inherited config、actor/critic vs policy/critic、tyro vs argparse)。学会在两个框架中同时验证设计,是工程成熟度的重要标志——它确保你的设计不是依赖某个框架的特定实现细节,而是基于 obs/action 的通用原则。
从"直觉设计"到"原则驱动"。 五条设计原则(马尔可夫性、可部署性、低维性、信噪比、跨框架一致性)不是空洞的指南——每一条都有明确的违反症状和修复方法。在面对新任务时,逐条检查这些原则比凭直觉设计更可靠。本章末尾的 obs/action 接口审查 checklist 把这些原则转化为可操作的检查项——在每次修改配置后执行一遍,可以防止绝大多数常见错误。
一个检验你是否真正理解本章内容的方法:给你一个全新的机器人和任务(比如一个带轮子的双臂机器人需要推购物车),你能否在不参考任何现有配置的情况下,从零设计出完整的 obs/action 配置(使用 5.10 节的六步工作流),在两个框架中完成对等实现(使用 5.2 节的原则五验证),通过 smoke test(使用 5.8 节的七步流程),并撰写部署边界文档(使用 5.8 节的模板)?如果可以,你已经掌握了本章的核心工程能力。
累积项目:本章新增模块
本章为累积项目新增"observation/action 接口验证"模块。你现在应该能够:
- 为机器人任务定义完整的 actor/critic observation group(在 mjlab 和 Isaac Lab 中对等配置)
- 选择合适的 action type/scale/offset,并用数值追踪验证合理性
- 运行 zero/random agent smoke test 验证接线正确
- 记录完整的部署边界文档(使用 5.8 节的模板)
- 对比两个框架中同一任务的 obs/action 数值一致性
- 用 TensorBoard 的 reward 分项和 action 分布诊断 obs/action 设计问题
累积项目检查点:在开始下一章之前,确保你已经:
- 在 mjlab 和 Isaac Lab 中分别运行了 velocity flat 任务的 zero agent 和 random agent
- 打印了 ObservationManager 的 __str__() 输出,确认 actor 和 critic group 的 term 列表和维度
- 打印了 ActionManager 的 term 列表和 action 维度
- 成功训练了 100 iteration 并观察了 reward 分项的变化趋势
- 完成了本章末尾的 obs/action 接口审查 checklist(至少 A-E 部分全部通过)
累积项目实验记录模板:
# Ch05 累积项目实验记录
## 环境信息
- 框架:mjlab / Isaac Lab(选一个或两个都做)
- 任务:Mjlab-Velocity-Flat-Unitree-Go1 / Isaac-Velocity-Flat-Anymal-C-v0
- GPU:_____________
- 日期:_____________
## Experiment 1: Zero Agent
- actor obs dim: ___
- critic obs dim: ___
- action dim: ___
- zero agent 姿态描述:___(应该是稳定站立)
- 异常观察:___
## Experiment 2: Random Agent (100 steps)
- NaN 出现次数:___
- processed action 范围:___(应在关节限位内)
- 机器人行为描述:___(应有随机但不极端的运动)
## Experiment 3: 100 Iteration Training
- steps/s: ___
- final mean reward: ___
- command tracking reward 趋势:上升 / 持平 / 下降
- action rate penalty 趋势:下降 / 持平 / 上升
- raw action mean (iter 100): ___(应接近 0)
- raw action std (iter 100): ___(应在 0.3-1.0 之间)
## Obs/Action 接口 Checklist 结果
- A. 信息边界:全部通过 / 有问题:___
- B. Action 配置:全部通过 / 有问题:___
- C. 处理链:全部通过 / 有问题:___
- D. 跨框架一致性(如果两个框架都做了):___
与 Ch06 Reward 设计的接口约束 ⭐⭐
本章定义的 obs/action 接口对下一章的 reward 设计有直接约束——理解这些约束可以避免一类常见的设计错误。
约束一:reward term 能访问的信息不超过 env state。 reward_manager 在 env.step 的 (6) 步执行(见 5.6 节时序),它可以访问 env 的所有内部状态(包括 actor 看不到的 privileged 信息),但不能访问 env 外部的信息。如果 reward 需要"距离目标点的距离",env 必须有这个信息——通常通过 command manager 或 scene 中的 target 提供。
约束二:reward 的语义应与 actor obs 一致。 如果 reward 鼓励"跟踪线速度命令",但 actor 看不到命令→策略无法学到命令跟踪行为。如果 reward 惩罚"关节接近限位",但 actor 看不到关节位置→策略无法学到避开限位。reward 和 actor obs 的语义对齐是隐含但关键的约束。
约束三:reward 中使用的坐标系应与 obs 一致。 如果 obs 中的 velocity 使用 body frame,reward 中的 velocity tracking error 也应在 body frame 计算。坐标系不一致会导致 reward gradient 方向与 obs 信号方向不一致——策略收到矛盾信号。
约束四(来自调研):所有 reward 自动乘以 step_dt。 Isaac Lab 和 mjlab 的 RewardManager 都会自动将每个 reward term 乘以 env step dt(physics_dt × decimation),使 reward 量级不随控制频率变化。这意味着如果你修改 decimation(从 4 改到 2),单个 reward term 的值会自动缩小一半(因为 step_dt 减半),但 episode 内的 step 数量翻倍——总 episode reward 大致不变。但 GAE 的有效 discount horizon 会因为 step 数量变化而改变——这个耦合关系在 Ch06 中会详细讨论。
一个具体的陷阱:如果你为 action rate penalty 设计了 weight = -0.01,这个权重在 decimation=4(step_dt=0.02s)时的物理含义是"每秒的 action 变化惩罚约 \(-0.01/0.02 = -0.5\)"。如果把 decimation 改为 2(step_dt=0.01s),同样 weight=-0.01 的物理含义变为"每秒惩罚约 \(-0.01/0.01 = -1.0\)"——惩罚翻倍了。这看似矛盾,但实际上 step_dt 自动乘法已经补偿了这个差异。真正需要注意的是 action rate 的定义:\(\Delta a / \text{step\_dt}\) 的数值在不同 decimation 下不同,即使物理行为完全相同。
下一章(Ch06)将在本章的接口基础上进入 reward、termination 和 curriculum 设计——reward 定义了"什么是好的",termination 定义了"什么时候结束",curriculum 定义了"难度怎么增长"。这三者都建立在本章定义的 observation/action 契约之上。
回顾本章的学习路径:我们从 MDP/POMDP 理论出发建立了 obs/action 的数学框架(5.1),提炼出五条可操作的设计原则(5.2),深入 ObservationManager 的六步处理链和双框架架构差异(5.3),理解了 asymmetric actor-critic 的信息边界和 RSL-RL 的 obs_groups routing 机制(5.4),掌握了 action 空间从 raw 到 physical 的完整变换链和四种 action type 的选型(5.5),分析了 env.step 的精确时序和 decimation 耦合(5.6),通过 HOVER 案例学习了多模态 obs 的前沿设计(5.7),建立了 ONNX 部署边界意识和 smoke test 流程(5.8),获得了系统化的 debug 工具(5.9),最后整合为一个可执行的从零设计工作流(5.10)。这条路径从理论到实战,从单框架到双框架,从 locomotion 到 manipulation——构成了后续所有章节的 obs/action 基础设施。
延伸阅读
| 资料 | 难度 | 本章关联 |
|---|---|---|
| Pinto et al. 2018, "Asymmetric Actor Critic for Image-Based Robot Learning" (RSS) | ⭐⭐⭐ | asymmetric actor-critic 原始论文 |
| Lee et al. 2020, "Learning Quadrupedal Locomotion over Challenging Terrain" (Science Robotics) | ⭐⭐⭐ | privileged learning 四足里程碑——teacher-student 蒸馏管线 |
| Kumar et al. 2021, "RMA: Rapid Motor Adaptation for Legged Robots" (RSS) | ⭐⭐⭐ | POMDP adaptation module,从 proprioceptive history 估计环境参数 |
| Rudin et al. 2022, "Learning to Walk in Minutes" (CoRL) | ⭐⭐ | observation/action 工程最佳实践,game-inspired terrain curriculum |
| He et al. 2025, "HOVER: Versatile Neural Whole-Body Controller" (ICRA) | ⭐⭐⭐ | 多模态 obs group 和 mask-conditioned distillation |
| Schwarke et al. 2025, "RSL-RL: A Learning Library for Robotics Research" (arXiv 2509.10771) | ⭐⭐ | RSL-RL 5.x 架构、obs_groups routing、PPO 实现细节 |
| Huang et al. 2022, "The 37 Implementation Details of PPO" (ICLR Blog) | ⭐⭐ | PPO 工程细节,RSL-RL 实现中引用的关键参考 |
| Nahrendra et al. 2023, "DreamWaQ" (ICRA) | ⭐⭐ | 无 base_lin_vel 的 locomotion——implicit terrain imagination |
| Hwangbo et al. 2019, "Learning Agile Locomotion for Quadruped Robots" (Science Robotics) | ⭐⭐⭐ | actuator network——learned MLP 替代 analytical motor model |
| Liao et al. 2025, "BeyondMimic" (arXiv 2508.08241) | ⭐⭐⭐ | 全身运动跟踪 MDP,mjlab motion tracking 任务的算法基础 |
| RSL-RL 官方文档和源码 | ⭐⭐ | obs_groups、distribution_cfg、ONNX 导出 |
| Isaac Lab 官方文档和 arXiv 2511.04831 | ⭐⭐ | manager-based env 设计哲学、observation/action 配置 |
| mjlab 官方文档和 arXiv 2601.22074 | ⭐⭐ | manager 和 observation 管线、NaN guard 工具链 |
| MuJoCo 官方文档 (mujoco.readthedocs.io) | ⭐⭐ | actuator/control 基础、solref/solimp 参数 |
| ONNX Runtime 文档 | ⭐ | ONNX 推理部署参考 |
跨章联系提示:
本章建立的 obs/action 接口是后续多个章节的基础。Ch06(Reward 设计)的 reward term 计算依赖 observation manager 中的 term——reward function 能访问的信息不能超出 env 的 state/obs 边界。Ch08(Domain Randomization)的随机化目标包括本章讨论的 noise 和 delay 参数,随机化范围直接影响 obs 的分布。Ch09(Teacher-Student Distillation)使用 5.4 节的 asymmetric actor-critic 框架作为出发点,进一步扩展到跨模态蒸馏。Ch23(Sim2Real 部署全链路)中的部署边界直接对应 5.8 节的 ONNX 部署文档。建议在学习后续章节时,经常回头参考本章的设计原则和故障排查手册。
🔧 故障排查手册
| 症状 | 可能原因 | 排查步骤 | 相关小节 |
|---|---|---|---|
| TensorDict key error | env obs group 名与 runner obs_groups 不匹配 | 1. 打印 obs dict keys 2. 检查 rl_cfg obs_groups / RSL-RL config 3. 修改 group 名或映射 4. 注意 mjlab 用 actor/critic,Isaac Lab 用 policy/critic |
5.4 |
| zero agent 不在站立姿态 | default offset 错误 | 1. 打印 default_joint_pos 2. 检查 use_default_offset 3. 对比 MJCF/USD 默认角 4. 确认 robot cfg 中的 default pose 数值 | 5.5 |
| random agent NaN 传播 | action scale 过大 | 1. 打印 processed action 范围 2. 降低 scale 到 0.1-0.3 3. 增加 processed clip 4. 检查 MuJoCo/PhysX solver stability | 5.5 |
| ONNX play 与 checkpoint play 不同 | normalizer 或 preprocessing 缺失 | 1. 确认 obs_normalization 设置 2. 对比 ONNX input 和 env obs 的数值范围 3. 检查 term-level scale 是否在 ONNX 外 4. 用固定输入测试 ONNX 和 PyTorch 输出一致性 | 5.8 |
| 训练好但 sim-to-real 失败 | actor 含 privileged signal | 1. 逐项检查 actor terms 部署可得性 2. 标注每个 term 的传感器来源 3. 移 privileged term 到 critic 4. 重新训练 | 5.4 |
| 策略不跟命令变化 | command 未进入 actor obs | 1. 检查 actor group terms 是否包含 command 2. 确认 command manager 先于 obs manager 加载 3. 用不同 command 值运行 zero agent 验证 command 进入 obs | 5.2 |
| 双框架 obs 数值不一致 | 坐标系或单位差异 | 1. 用 zero agent 对比两框架每个 term 的数值 2. 检查 body frame 旋转约定 3. 检查 joint ordering 是否一致 4. 打印 projected_gravity 验证重力方向约定 | 5.2 |
| 训练初期 reward 不上升 | obs 维度或 scale 问题 | 1. 用 random agent 检查 obs 值域是否在合理范围 2. 检查 action scale 是否过小(动作幅度不够) 3. 确认 command 范围是否从零开始(初始 curriculum) 4. 检查 reward 分项权重 | 5.2, 5.5 |
| history 添加后训练变慢 | 维度膨胀 | 1. 计算展平后维度 2. 检查第一层参数量是否增加过大 3. 检查 GPU 显存是否超限 4. 先减少 history_length 到 2-3 | 5.3 |
| 关节频繁撞限位 | action scale + offset 配合不当 | 1. 打印 processed action 在 random agent 下的分布 2. 对比关节限位 3. 调小 scale 4. 检查 offset 是否居中于关节范围 | 5.5 |
| decimation 修改后训练崩溃 | delay/action rate/PD 参数未同步调整 | 1. 重新计算 env step dt 2. 调整 delay lag 维持目标延迟 3. 重新评估 action rate penalty 权重 4. 检查 PD 控制器是否有足够的追踪步数 | 5.6 |
| 多 action term 切片错位 | cfg dict 顺序与部署端假设不一致 | 1. 打印 ActionManager 的 term 名称和 action 切片范围 2. 核对 ONNX 输出的 action 维度划分 3. 写单元测试验证每段 action 的物理效果 | 5.5 |
| mask-conditioned 策略某模态性能差 | student 网络容量不足或蒸馏采样不平衡 | 1. 检查 teacher 在该模态的性能上限 2. 增大 student 网络容量 3. 平衡蒸馏时 mask 采样比例 4. 单独 fine-tune 该模态 | 5.7 |
| obs_normalization 打开后部署异常 | ONNX 不含 normalizer 或 stats 不匹配 | 1. 确认训练 ckpt 中是否保存了 normalizer stats 2. 检查 ONNX 导出是否包含 normalizer 层 3. 在 Python 中对比 ONNX vs PyTorch 对同一输入的输出 4. 如果 ONNX 不含 normalizer,部署端需手动加载 stats 并预处理 | 5.4, 5.8 |
| play 时表现好但加 corruption 后崩溃 | 策略对噪声不鲁棒 | 1. 检查训练时 corruption 是否真的打开了(可能被继承覆盖了) 2. 检查 noise 数值是否匹配传感器 datasheet 3. 检查是否有 term 的 noise 过大导致信号被淹没 4. 增大训练时的 noise 范围重新训练 | 5.2, 5.4 |
| critic 和 actor obs 维度相同 | obs_groups 配置错误导致 critic 没有 privileged | 1. 打印 actor 和 critic 的 obs dim 2. 检查 obs_groups 映射是否正确指向不同 group 3. 确认 critic group 确实包含额外 terms 4. 修复映射后重新训练 | 5.4 |
| DiffIK action 导致手臂锁死 | IK 在奇异构型附近求解失败 | 1. 打印 IK residual \(\|J\Delta q - \Delta x\|\) 2. 检查当前臂构型是否接近工作空间边界 3. 增大 damping(如从 0.01 到 0.1) 4. 减小 delta_pos_scale 限制每步末端移动距离 | 5.5 |
| 训练后 action 分布偏移严重(mean >> 0) | default offset 不准确 | 1. 打印训练 200 iter 后的 raw action mean 和 std 2. 如果 mean 偏离 0 很多,检查 default_joint_pos 是否正确 3. 对比 MJCF/USD 中 qpos0 和 cfg 中的 default 值 4. 修正后 raw action mean 应接近 0 | 5.5 |
使用 MetricsManager 记录 obs/action 统计量 ⭐
在调试 obs/action 设计问题时,你可能需要在训练过程中持续记录统计量(如每个 obs term 的 mean/std、action 的 clip 率、NaN 出现频率)但不希望这些影响 reward 或训练逻辑。mjlab 的 MetricsManager 正是为此设计的——它与 reward manager 平行,记录非 MDP 的诊断指标到 logger。你可以定义一个 MetricsTerm 来计算 obs 统计量,这些数值会出现在 TensorBoard 中但不参与 PPO 的 advantage 计算。Isaac Lab 中类似功能通过 custom callback 或直接在 env 中写 TensorBoard logger 调用实现。
📋 Obs/Action 接口审查 Checklist
使用方法:每次完成新任务的 obs/action 设计或修改现有配置后,逐项检查以下清单。这个 checklist 整合了本章所有关键设计原则和常见陷阱,是部署前的最终防线。
A. 信息边界审查
- [ ] actor group 逐 term 检查:每个 term 标注部署来源(传感器型号/估计器类型)
- [ ] actor 不含 privileged signal:contact force、terrain height、object true pose、randomized physics params 等只在 critic group
- [ ] command term 在 actor group 中:条件策略必须看到命令
- [ ] obs_groups 映射正确:actor 指向 actor group,critic 指向 critic group(不是两者都指向 actor)
- [ ] critic obs dim > actor obs dim:确认 critic 确实包含额外信息
B. Action 配置审查
- [ ] action dim == 被控关节数:打印
ActionManager的 total action dim - [ ] default offset 对应安全姿态:zero agent 站立稳定
- [ ] action scale 合理:random agent 不 NaN,不飞出画面,有明显运动
- [ ] processed action 在关节限位内:打印 random agent 的 processed action 范围
- [ ] 多 action term 顺序明确:打印每个 term 的 name 和 slice range,记录到部署文档
- [ ] last_action 使用 raw action:不是 processed action
C. 处理链审查
- [ ] noise 范围匹配传感器 datasheet:不是凭感觉猜
- [ ] corruption 模式正确:actor group 打开(训练时),critic group 关闭
- [ ] delay lag 换算成物理时间合理:lag × env_step_dt ≈ 真实传感器延迟
- [ ] history 展平后维度可接受:不超过 500 维(除非有特殊需求)
- [ ] NaN 检测已配置:开发阶段
nan_policy="error"或--enable-nan-guard True
D. 跨框架一致性审查
- [ ] 双框架 zero agent obs 对比:每个 term 数值差异 < 1%
- [ ] group 命名映射确认:mjlab actor↔Isaac Lab policy,mjlab critic↔Isaac Lab critic
- [ ] 坐标系约定一致:body frame 旋转方向、gravity 方向、关节排序
- [ ] default joint pos 数值一致:MJCF qpos0 vs USD default joint state
E. 部署就绪审查
- [ ] ONNX 导出通过一致性验证:PyTorch vs ONNX 输出差异 < 1e-5
- [ ] 部署边界文档已完成:使用 5.8 节模板,包含所有 term 的名称/维度/单位/scale/来源
- [ ] obs_normalization 处理明确:如果打开,确认 ONNX 包含 normalizer 或 stats 已导出
- [ ] action scale/offset 在 ONNX 外部:部署端复现 scale/offset/clip 逻辑
- [ ] 控制频率与训练一致:部署端的推理频率 = 训练时的 env step 频率
F. 训练健康度检查(100 iteration 后)
- [ ] reward 分项合理:command tracking reward 上升,penalty 项不占主导
- [ ] raw action mean ≈ 0:如果严重偏离,检查 default offset
- [ ] raw action std 适中:不接近 0(过早收敛)也不持续很大(探索过度)
- [ ] obs 各 term 范围在个位数量级:如果某 term 范围 >> 10,考虑 term-level scale
- [ ] 无持续 NaN 警告:如果有,用 viz-nan 定位根因
最后提醒:这个 checklist 覆盖了"接口是否正确"的问题,但不覆盖"设计是否最优"。最优设计需要训练实验来验证——checklist 通过只意味着接口没有 bug,不意味着 obs/action 选择是最佳的。设计优化的迭代方法见 5.10 节的六步工作流。