Skip to content

第 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 在真实四足机器人上能否直接测得?若不能,有哪些估计/蒸馏方案? 检查是否理解部署可得性的核心困难

本章目标

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

  1. 设计 一个 locomotion 或 manipulation 任务的 actor/critic observation group,正确区分部署可得信号和训练特权信号,并在 mjlab 和 Isaac Lab 中完成对等配置
  2. 解释 raw action、action scale、default offset 和 processed action 之间的完整变换链,并为新机器人选择合适的 scale
  3. 运用 observation 处理管线(Isaac Lab:compute → modifiers → noise → clip → scale → history;mjlab 另含 delay)诊断训练失败和部署偏移
  4. 分析 HOVER 的多模态 obs group 设计,理解 mask-conditioned observation 在统一控制中的作用
  5. 撰写 完整的 ONNX 部署边界文档,明确网络内与网络外的职责划分
  6. 独立完成 从 zero agent 到 GPU 训练的完整 smoke test 流程,验证接口接线正确
  7. 对比 locomotion 和 manipulation 任务的 obs/action 设计差异,理解物体相对信号、坐标系选择和多段 action 的工程原因
  8. 掌握 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 参数。

练习

  1. [分析题] 回顾上述六个失败案例,为每个案例识别属于"编程错误"、"概念错误"还是"流程错误"。讨论哪些错误可以通过自动化检查发现,哪些只能通过人工审查。
  2. [设计题] 假设你要训练一个 Go1 机器人在崎岖地形上行走,但真实机器人没有足底力传感器。设计 actor 和 critic 的 observation group,说明每个信号的来源和部署可得性。如果你还有一个 depth camera,它应该放在 actor 还是 critic 中?
  3. [跨章综合题] 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 策略的结论不能直接迁移到视觉部署。

练习

  1. [分类题] 对以下信号判断是否适合给 actor、给 critic、还是两者都不给:(a) 风速真值 (b) 摩擦系数真值 (c) 关节温度(有传感器) (d) 下一步的 reward (e) 地形坡度估计(从 IMU 推断)
  2. [推导题] 对于一个速度跟踪任务,假设 actor observation 包含 3D 角速度、3D 重力投影、12D 关节位置、12D 关节速度、12D last action 和 3D command,计算总维度。如果 history_length=3 且展平,维度变为多少?估算当 num_envs=4096num_steps_per_env=24 时,单次 rollout 的 observation 存储量(假设 float32)。
  3. [设计题] 用五条原则为以下场景设计 actor observation:一个 7-DOF 机械臂需要把桌上的杯子抓起来放到指定位置。机械臂有关节编码器和腕部力传感器,末端有一个深度相机,但部署带宽有限只能传 downsampled 点云。逐条检查你的设计是否满足五条原则。
  4. [跨框架对比题] 分别在 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 同时包含 JointPositionActionCfgJointPositionAction)。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_lagdelay_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 使用 CircularBufferhistory_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(最快,默认)、warnsanitizeerror 四种模式。调试新任务时用 warnerror + 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 的策略是"从后往前追踪":

  1. 先检查 observation:在 observation_manager.compute() 后检查每个 term 是否含 NaN。如果某个 term 有 NaN,问题在 env 或物理引擎层面。
  2. 再检查 action:如果 observation 干净但 action 有 NaN,问题在网络(可能是梯度爆炸导致权重 NaN)。
  3. 最后检查 reward/done:如果 observation 和 action 都干净但训练仍然不稳定,检查 reward 计算是否产生了 Inf(比如除以零、log(0))。

mjlab 的 sanitize 模式会把 NaN 替换为 0——这对防止训练崩溃有用,但会掩盖根因。建议在开发阶段用 error 模式(NaN 直接 crash,便于定位),在已知偶发 NaN 的大规模训练中用 warnsanitize

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 和部署流程。

练习

  1. [分析题] 假设 observation 管线的顺序被改为 compute → scale → noise → clip → history → delay。列出至少三个这种错误顺序会导致的具体问题。
  2. [计算题] velocity env 的 physics timestep 为 0.005 s,decimation 为 4。如果真实 IMU 延迟为 30 ms,应该设置多少 delay lag?如果 decimation 改为 8,lag 需要如何调整?
  3. [编程题] 在 mjlab 中编写一个自定义 observation term,在 compute() 中打印 step counter 和当前 action。运行 2 步,验证 observation 反映的是 action 执行后的状态。
  4. [调试题] 在 mjlab 中用 --enable-nan-guard True 运行一个 action scale 故意设为 5.0(过大)的 velocity flat 训练,观察是否产生 NaN dump。如果产生了,用 viz-nan 回放,分析 NaN 发生前的物理状态(哪些关节角度/速度异常?力矩是否饱和?)。
  5. [跨框架对比题] 分别在 mjlab 和 Isaac Lab 中运行 velocity flat 的 zero agent,打印 actor observation 的每个 term 值。对比两个框架的数值差异——重点关注 projected_gravityjoint_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)近似:

\[\hat{A}_t = \sum_{l=0}^{T-t-1} (\gamma \lambda)^l \delta_{t+l}, \quad \delta_t = r_t + \gamma V(o_{t+1}^{\text{critic}}) - V(o_t^{\text{critic}})\]

关键观察: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:actorcritic。RSL-RL config 中默认的 obs_groups 映射是 {"actor": ("actor",), "critic": ("critic",)},告诉 runner 把 env 返回的 actor group 送给 actor 网络,critic group 送给 critic 网络。

Isaac Lab 使用 ObservationsCfg 的嵌套 @configclass,默认的 group 名是 policycritic(而非 mjlab 的 actorcritic)。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 模式——PolicyCfgCriticCfg 是独立的嵌套类,每个类中的 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_groupstime_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 开关:

  • mjlabObservationGroupCfg.enable_corruption = True/False
  • Isaac LabObsGroup.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 应描述"当前状态",不是"未来会发生什么"。

练习

  1. [推导题] 写出 GAE advantage 的完整展开式(取 \(T-t=3\) 步)。解释 \(\lambda=1\)\(\lambda=0\) 分别对应什么估计方式,它们对 value function 精度的依赖有何不同。
  2. [设计题] 为一个 rough terrain locomotion 任务设计 actor 和 critic 的 observation group。actor 不超过 50 维,critic 可以额外增加不超过 30 维 privileged 信号。在 mjlab 和 Isaac Lab 中分别写出 cfg 结构。
  3. [跨章综合题] 回顾 Ch04 中 ManagerBasedRlEnv.load_managers() 的顺序。如果你把 observation manager 的加载放在 command manager 之前,哪些 observation term 会失败?设计一个最小复现实验来验证。
  4. [编程题] 在 mjlab 中编写一个脚本,打印 velocity flat 任务的 actor 和 critic obs 维度。确认 critic dim > actor dim,列出 critic 中额外的 term 名称。然后在 Isaac Lab 中做同样的事情,验证维度和 term 列表是否对等。
  5. [分析题] 假设有人不小心把 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 变换链如下:

\[\text{processed\_action} = \text{raw\_action} \times \text{scale} + \text{offset}\]

如果配置了 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 的设计哲学 ⭐⭐

JointPositionActionCfguse_default_offset=True 选项(两个框架均支持)。当它为真时:

\[\text{target\_joint\_pos} = \text{raw\_action} \times \text{scale} + \text{default\_joint\_pos}\]

这意味着 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 转成关节位置目标:

\[(J^T W J + \lambda^2 I) \Delta q = J^T W \Delta x\]

其中 \(\lambda\) 对应 dampingmax_dq 限制每次 IK solve 的关节位移上限。对 IK action 来说,尺度控制入口不只是 BaseActionCfg.scale,还包括 delta_pos_scaledelta_ori_scalemax_dqdamping

DiffIK 的调参比 joint position 更微妙,因为参数之间存在非线性交互。damping 太小时,在 Jacobian 接近奇异(如手臂完全伸直)时求解会产生极大的关节速度;damping 太大时,IK 解偏离目标过多。max_dq 起安全限制作用——即使 IK 解需要关节大幅运动,也被限制在每步 max_dq rad 以内。一个实用的调参起点是:damping=0.05max_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 权重时,都应重新执行。

练习

  1. [计算题] Go1 某关节 default position 为 0.8 rad,action scale 为 0.5。如果 raw action 为 0.6,计算 processed action。如果关节限位为 \([-0.3, 1.5]\) rad,这个 processed action 是否安全?
  2. [设计题] 为移动操作机器人设计 action terms,包含 base(2D 速度)、arm(6 DOF 关节位置)和 gripper(1D 开合)。给出每段的 scale、offset 和 clip 设计理由,以及 ONNX 部署时的 postprocess 要求。
  3. [编程题] 写一个 Python 脚本,对给定 default_joint_posaction_scale,可视化 raw action 在 \([-1, 1]\) 范围内 uniform 采样时 processed action 的分布,检查是否超出关节限位。
  4. [跨章综合题] init_std(RSL-RL 5.x 的 GaussianDistributionCfg 参数)和 action_scale 共同决定了初始探索的物理幅度。如果 Go1 的 action_scale=0.25init_std=1.0,计算训练初期 95% 的 processed action 变化范围。如果你想让 95% 范围控制在 \(\pm\)0.1 rad 以内(如精细操作任务),应该怎样调整 action_scaleinit_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() 实现可以定位差异。

练习

  1. [分析题] 如果把 observation 计算移到 reward 之前(即 sim.forward() 之前),会产生什么后果?讨论 observation、reward 和 advantage 估计的对齐问题。
  2. [编程题] 写一个自定义 observation term,在 compute() 中打印 step counter 和当前 action。运行 2 步,验证 observation 反映的是 action 执行后的状态。
  3. [计算题] 对 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 后应调为多少来保持等价的物理惩罚?
  4. [跨章综合题] 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 分别独立作用于上半身和下半身,形成组合:

\[s^{g\text{-student}}_t \triangleq M_{\text{sparsity}} \odot [M_{\text{mode}} \odot s^{g\text{-upper}}_t,\; M_{\text{mode}} \odot s^{g\text{-lower}}_t]\]

每个 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:

\[L_{\text{student}} = \mathbb{E}_{m \sim \mathcal{M}} \left[ \| \pi_{\text{student}}(o^{\text{prop}}, o^{\text{target}}_m, m) - a^{\text{teacher}_m} \|^2 \right]\]

其中 \(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:

\[o^{\text{student}}_t = [q, \dot{q}, \omega_{\text{base}}, g]_{t-25:t} \cup [a_{t-25:t-1}]\]

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:

\[L = \|\hat{a}_t - a_t\|^2_2\]

其中 \(\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。关键工程步骤是:

  1. 在 command manager 中生成 mask(类似 velocity command 的 resample 逻辑)
  2. 在 observation terms 中加入 generated_commands() 读取 mask
  3. 在 reward 中根据 mask 选择性计算跟踪奖励
  4. 蒸馏时复用 mjlab 的 DistillationRunner(如果可用)或自定义 runner

这个迁移路径展示了双框架工作流的典型模式:在一个框架中精读前沿项目的设计,在另一个框架中复现核心思想。

⚠️ 常见陷阱

⚠️ 编程陷阱:mask 维度与目标维度不匹配。 如果 mask 有 5 个元素(对应 5 个末端),但目标只有 4 个末端的位姿,第 5 个 mask 位对应的目标是垃圾值。训练不会报错但策略会学到噪声。

💡 概念误区:认为"统一策略一定比专用策略好"。 如果你只需要速度跟踪,为此训练一个支持三种模态的 HOVER 策略是过度工程。统一策略适合部署场景多样的平台;专用策略适合任务明确的场景。

🧠 思维陷阱:认为"大维度 obs 就需要大网络"。 HOVER 的 obs 中大部分是稀疏的目标/mask——很多维度在某种模态下是零。网络不需要把所有维度的组合都学好,只需要学好 mask 激活的子集。这与 Transformer 中的 attention mask 类似——mask 为零的位置不贡献信息。

练习

  1. [分析题] 在 HOVER 的 mask-conditioned distillation 中,如果 student 在关节模态下的 action MSE 很低,但在末端模态下 MSE 很高,可能的原因是什么?如何诊断和修复?
  2. [设计题] 为一个移动底盘 + 双臂的人形机器人设计多模态 obs group,支持"纯行走"和"行走 + 右手跟踪"两种模态。给出 mask 的维度和语义定义。
  3. [前向引用题] 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。

练习

  1. [检查题] 阅读 velocity runner 的 save(),确认 ONNX 导出时是否包含 normalizer。在 mjlab 和 Isaac Lab 中分别验证。
  2. [设计题] 为 velocity 任务写部署边界文档,包含 actor observation 名称和顺序、每个 term 的单位和 scale、action scale/offset/clip、控制频率、history 规格。
  3. [实验题] 按七步 smoke test 完整执行,在 mjlab 中记录每步输出和观察结果。任何步骤失败则记录错误信息并尝试诊断。
  4. [编程题] 修改 diagnostic_print.py,添加 per-term 的统计量打印(而非整个 obs 的统计)。提示:使用 env.observation_manager.active_terms 获取每个 term 的名称和维度切片信息,然后对 concatenated obs tensor 做切片分析。
  5. [编程题] 编写 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。

练习

  1. [排查题] 四足机器人训练后 play 正常,ONNX 在外部 runtime 动作偏移。按 Debug Checklist 排查,最可能原因是什么?设计最小复现实验。
  2. [设计题] 为你的任务编写"observation/action 接口文档",包含所有 actor term 的名称/维度/单位/scale/部署来源,和所有 action term 的 scale/offset/clip/部署后处理。使用 5.8 节的 YAML 模板格式。
  3. [编程题] 写一个 Python 脚本,读取 checkpoint 目录中的 obs_normalizer 统计量(mean 和 std),可视化每个 obs 维度的归一化范围。如果某个维度 std < 1e-6(常数信号),报告该维度可能冗余。
  4. [跨章综合题] 假设你在 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 中,这个结构自然落入 actorcritic 两个 group;在 Isaac Lab 中,落入 policycritic 两个 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 中分别实现上述配置,然后进行对等验证:

  1. 两个框架中分别运行 zero agent,打印 actor obs 的每个 term 值,比较是否一致
  2. 检查 action dim 和 action 变换链(scale/offset/clip)在两个框架中是否等价
  3. 用相同的 random seed 运行 5 步,检查 processed action 数值是否匹配
  4. 确认 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——需要根据任务的不变性需求选择。

练习

  1. [设计题] 为一个双臂人形机器人设计 obs/action,使其能同时行走和用右手推门。按六步工作流给出每一步的具体产出,并在 mjlab 和 Isaac Lab 中写出 cfg 结构(伪代码即可)。
  2. [对比题] 对比 velocity task 和一个 manipulation task(如桌面抓取)的 obs/action 设计。列出至少五个结构性差异(action type、command 语义、坐标系、privileged signals、action scale 量级),分析每个差异背后的工程原因。
  3. [跨章综合题] 假设你将在 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 主链

  1. src/mjlab/envs/manager_based_rl_env.pyload_managers() 顺序、step() 时序
  2. src/mjlab/managers/observation_manager.py — term/group 定义、处理链
  3. src/mjlab/managers/action_manager.py — raw action buffer、action slicing
  4. src/mjlab/envs/mdp/actions/actions.py — scale/offset/clip 顺序、default offset
  5. src/mjlab/envs/mdp/actions/differential_ik.py — 任务空间 IK action

路线 B:Isaac Lab 对等路径

  1. source/isaaclab/envs/manager_based_rl_env.py — 对应 mjlab 的 env 基类
  2. source/isaaclab/managers/observation_manager.py — obs group 和 term 拼接
  3. source/isaaclab/managers/action_manager.py — action term 分发
  4. source/isaaclab/envs/mdp/actions/joint_actions.py — joint position/velocity/effort
  5. source/isaaclab/envs/mdp/actions/differential_ik_actions.py — IK action

路线 C:velocity 任务对比

  1. mjlab: src/mjlab/tasks/velocity/velocity_env_cfg.py — actor/critic terms 差异
  2. mjlab: src/mjlab/tasks/velocity/config/go1/env_cfgs.py — rough/flat 覆盖、action scale
  3. Isaac Lab: source/isaaclab_tasks/velocity/config/anymal_c/ — ANYmal 配置对比
  4. 重点对比: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 接口

  1. src/mjlab/rl/config.py / Isaac Lab rsl_rl config — obs_groupsobs_normalizationclip_actions
  2. src/mjlab/rl/vecenv_wrapper.py / Isaac Lab wrapper — TensorDict、dones/time_outs
  3. 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 任务对照

  1. src/mjlab/tasks/manipulation/lift_cube_env_cfg.py — state-based actor terms(注意 ee-to-cube 和 cube-to-goal 向量的坐标系)
  2. src/mjlab/tasks/manipulation/config/yam/env_cfgs.py — YAM arm action scale 和 gripper 配置
  3. src/mjlab/tasks/manipulation/mdp/observations.py — object/goal/camera observations(视觉变体如何替换低维真值)
  4. 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.pyscripts/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 接口验证"模块。你现在应该能够:

  1. 为机器人任务定义完整的 actor/critic observation group(在 mjlab 和 Isaac Lab 中对等配置)
  2. 选择合适的 action type/scale/offset,并用数值追踪验证合理性
  3. 运行 zero/random agent smoke test 验证接线正确
  4. 记录完整的部署边界文档(使用 5.8 节的模板)
  5. 对比两个框架中同一任务的 obs/action 数值一致性
  6. 用 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_groupsdistribution_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 节的六步工作流。