Skip to content

第 08 章:Domain Randomization——从仿真鲁棒到真机部署

本章定位:前面三章建立了 observation/action 接口(Ch05)、reward/termination/curriculum 设计(Ch06)和 PPO 训练管线(Ch07)——这些让你能在仿真中训练出一个行为正确的策略。但仿真中训练好的策略直接部署到真机上几乎一定会失败——因为仿真器的物理参数(摩擦系数、质量、电机增益、传感器噪声)与真实世界不完全匹配。Domain Randomization(DR)是弥补这一 sim-to-real gap 的核心工程手段:通过在训练时随机化环境参数,让策略在一个参数分布上优化,而非在一个固定参数点上优化。如果真实世界的参数落在训练分布覆盖的范围内,策略就有望在真机上表现良好。

前置依赖:Ch05(Observation 与 Action 设计)、Ch06(Reward, Curriculum, Termination)、Ch07(PPO 训练管线)

关键文献:Tobin et al. 2017(视觉 DR)、Peng et al. 2018(动力学 DR)、OpenAI 2019(ADR / Rubik's Cube)、Rucker & Wensing 2022(Pseudo-inertia)、He et al. 2025(ASAP delta action model)

参考项目:🔧 mjlab EventManager · 🔧 Isaac Lab EventTermCfg · ✅ ASAP(github.com/LeCAR-Lab/ASAP,~1.8k Stars,RSS 2025)


前置自测

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

问题 检查目的
mjlab 的 EventManager 支持哪几种触发模式?每种适合什么场景? 检查 Ch04 manager 架构理解
reward 的 scale_by_dt 和 DR 的关系是什么?DR 改变 decimation 后 reward scale 会变吗? 检查 Ch06 dt 缩放理解
如果只修改质量不修改惯量张量,这个刚体在物理上是否一致? 检查刚体动力学基础
DR 改变环境参数后,PPO 的 advantage 估计会受什么影响? 检查 Ch07 GAE 理解
如果 friction DR 范围设为 [0.01, 5.0],策略会学到什么行为? 检查 DR 范围选择直觉
仿真中训练效果很好但真机上失败,最可能的原因是什么? 检查 sim-to-real gap 概念

本章难度为 ⭐⭐⭐。DR 的理论很简单(在参数分布上训练),但工程实现充满陷阱——从 CUDA Graph 静默失效到物理不一致的惯性参数,每个陷阱都可能让你在完全没有 DR 的情况下误以为策略是鲁棒的。


本章目标

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

  1. 解释 DR 的鲁棒优化视角——为什么在参数分布上训练可以提升 sim-to-real 迁移
  2. 配置 mjlab 和 Isaac Lab 中完整的 DR 方案——startup/reset/interval/step 四种模式各用于什么
  3. 实现 物理一致的惯性随机化——理解 pseudo-inertia 参数化为什么比单独改 mass 更合理
  4. 设计 分阶段 DR 策略——从无 DR baseline 到完整 DR 的递进方案
  5. 诊断 DR 不生效的常见原因——从配置层到 CUDA Graph 的四层排查
  6. 评估 DR 策略的鲁棒性——在固定参数网格上系统测试
  7. 理解 ASAP delta action model 的动机——当传统 DR 不够时的替代方案

本章路线图 ⭐

8.1 理论基础(为什么需要 DR)→ 8.2 EventManager 四种模式(何时触发)→ 8.3 DR 函数映射表(随机化什么)→ 8.4 pseudo-inertia(物理一致性)→ 8.5 外力扰动(push/impulse)→ 8.6 分阶段 DR 策略(怎样递进)→ 8.7 评估方法论(怎样验证)→ 8.8 ASAP 精读(超越 DR)→ 8.9 观测噪声与延迟(传感器 DR)→ 8.10 自定义 Event(扩展框架)→ 8.11 诊断工具箱(一站式诊断)→ 8.12 源码路线(深入探索)。

前置依赖与本章定位 ⭐

本章在全书中的定位是"从仿真到真机的第一道桥"。Ch05-Ch07 建立了在仿真中训练策略的完整能力,本章让这个策略不仅在仿真标称参数下工作,而是在一个参数范围内都工作。Ch09(Teacher-Student 蒸馏)将在此基础上解决另一个 sim-to-real 问题——部署时缺少 privileged 信息。Ch23(Sim2Real 部署全链路)将整合 DR + 蒸馏 + 执行器建模 + 部署工程,完成完整的 sim-to-real 管线。

DR 和 Ch06 的 Curriculum Learning 有交互但本质不同。Curriculum 改变任务难度(命令范围、地形复杂度)但物理规律不变。DR 改变物理规律(摩擦、质量、延迟)但任务目标不变。两者可以同时使用,但不要混淆——"逐步扩大摩擦范围"是 DR,不是 curriculum。


8.1 DR 的理论基础与鲁棒优化视角 ⭐⭐

这一节解决什么问题:为什么在参数分布上训练可以提升 sim-to-real 迁移?DR 的数学本质是什么?

从固定 MDP 到参数化 MDP ⭐⭐

标准 RL 在一个固定的 MDP \(\mathcal{M} = (S, A, P, R, \gamma)\) 上训练。但真实世界的 MDP 参数 \(\theta\)(摩擦、质量、电机增益等)是未知的——仿真器使用的标称参数 \(\theta_0\) 只是一个估计。DR 的核心思想是:不要在 \(\theta_0\) 上训练,而是在一个参数分布 \(p(\theta)\) 上训练。

\[J_{\text{DR}}(\pi) = \mathbb{E}_{\theta \sim p(\theta)}\left[J(\pi, \theta)\right] = \int J(\pi, \theta) \, p(\theta) \, d\theta\]

其中 \(J(\pi, \theta) = \mathbb{E}_\pi\left[\sum_t \gamma^t r_t \mid \theta\right]\) 是策略 \(\pi\) 在参数 \(\theta\) 下的期望回报。DR 优化的是平均性能,而非某个特定参数下的性能

这和保险的精算逻辑有深层类比。保险公司不为某一个特定客户优化保费(那样要么赔钱要么客户付不起),而是在客户群体的风险分布上优化——让平均利润最大化。DR 策略不为某一个特定摩擦系数优化(那样换个地面就摔倒),而是在摩擦分布上优化——让平均 tracking 性能最大化。

DR 不是万能的。DR 优化的是期望(平均情况),不是最坏情况。如果参数分布 \(p(\theta)\) 的某些区域特别难(如极低摩擦 \(\mu = 0.1\)),DR 策略可能选择"放弃"这些区域来提高平均性能——这在数学上是最优的,但在部署时如果遇到这些参数就会失败。这就是为什么 DR 范围的选择如此重要——范围应该反映真实的物理不确定性,而不是盲目扩大。

反事实推理:如果把 DR 目标从期望改为最小值 \(\min_\theta J(\pi, \theta)\)?这就变成了鲁棒优化(minimax),策略会为最坏情况优化——结果通常是极度保守的行为(站着不动是对最坏摩擦的"最优"响应)。工程实践中,DR 的"平均优化"比鲁棒优化的"最坏优化"更实用。

DR 的四个维度 ⭐⭐

DR 不是一个单一的旋钮——它沿四个维度展开,每个维度对应真实世界中不同类型的不确定性:

维度 随机化内容 影响什么 典型参数
动力学 质量/惯量/COM/摩擦/接触刚度 系统对力的响应 body_mass, geom_friction, dof_damping
执行器 电机增益/力矩上限/响应延迟/齿轮间隙 动作的有效幅度和带宽 pd_gains, effort_limits, dof_armature
感知 编码器偏置/IMU 噪声/通信延迟 策略接收到的信息质量 encoder_bias, actor corruption, obs noise
环境 外部扰动/风力/负载变化 任务本身的难度 push velocity, external wrench

这个分类框架的工程价值在于:每个 DR 项都应该能追溯到具体的物理不确定性来源。如果一个随机化项找不到真实的部署对应物(如"每秒改变一次质量"——物理上不可能),它只能作为压力测试,不能作为 sim-to-real 的证据。

Automatic Domain Randomization (ADR) ⭐⭐⭐

OpenAI (2019) 提出的 ADR 自动调节 DR 范围:从无随机化开始,当策略性能超过上阈值时扩大某个参数的范围,当性能低于下阈值时收缩。ADR 在 Shadow Hand 解魔方的任务中展示了 sim-to-real 迁移,且训练出的 LSTM 策略的 hidden state 自发编码了环境参数(类似 meta-learning)。

ADR 的工程挑战:(1) 各参数独立扩展可能产生不可训练的参数组合(如同时低摩擦 + 低电机力矩);(2) 范围扩大后需要重新收集 on-policy 数据——PPO 的旧数据可能不再代表新分布;(3) 超参数(阈值、步长 Δ)本身需要调优。

在当前实践中,ADR 主要用于灵巧操作(Isaac Lab 2.3 DexSuite 集成了 ADR)。对于 locomotion,手动设置 DR 范围 + 分阶段引入仍然是主流方法——因为 locomotion 的参数不确定性范围通常可以从工程规格书中获得,不需要自动搜索。

delta action model 的动机 ⭐⭐

当 DR 不够时怎么办?ASAP(He et al., RSS 2025)提出了一种替代思路:不是把仿真器的参数分布做得更宽,而是训练一个残差模型来修正仿真器和真实世界的差异。这个 delta action model 在 8.8 节详细讲解。

本质洞察:DR 和 delta action model 解决 sim-to-real gap 的方式完全不同。DR 说"我不知道真实参数是什么,所以我在一大堆可能的参数上训练"。delta model 说"我知道仿真和真实有差距,让我用真机数据学一个修正项"。DR 不需要真机数据但可能过于保守,delta model 需要真机数据但更精确。两者不互斥——ASAP 第一阶段在仿真中训练 motion-tracking base policy,通常也会配合合理 DR(但注意其公开代码的训练命令默认 +domain_rand=NO_domain_rand,是否叠加 DR 取决于具体配置,并非论文强制步骤)。

⚠️ 常见陷阱

⚠️ 编程陷阱:DR 配置写了但没生效。 从配置到物理生效需要经过四层(配置→管理→字段→仿真),任何一层断裂都会导致 DR 静默失效——训练正常运行,日志中 reward 也在上升,但策略实际上是在无 DR 的环境中训练的。自检方法:训练开始后打印已扩展的 model fields 列表。

💡 概念误区:DR 范围越大越鲁棒。 过大的 DR 范围会把原本可学的任务变成不可学的鲁棒优化问题。在 [0.01, 5.0] 的摩擦范围下,极低摩擦可能让任何步态都无法稳定——策略被迫学习极度保守的行为。DR 范围应该反映真实的物理不确定性,不是"越大越好"。

🧠 思维陷阱:把 DR 等同于数据增强。 图像增强改变的是输入的表示形式,不改变任务本身。DR 改变的是环境动力学——它改变了状态转移函数 \(p(s'|s,a;\theta)\)。在低摩擦环境中,相同的动作会产生完全不同的接触力和滑移。DR 本质上是在训练策略同时解一族不同的控制问题。

练习

  1. [推导题] 证明当 DR 分布 \(p(\theta) = \delta(\theta - \theta_0)\)(退化为点分布)时,\(J_{\text{DR}}\) 退化为标准 MDP 目标 \(J(\pi, \theta_0)\)
  2. [分析题] 如果 Go1 的标称质量是 12.5 kg,装配误差在 [-5%, +8%],负载最多 2 kg,计算 body_mass 的合理 DR 范围。
  3. [思考题] 为什么 DR 通常选择均匀分布而不是正态分布?提示:考虑尾部行为和边界覆盖。

上节建立了 DR 的理论动机。但理论不告诉你"什么时候触发随机化"——这由 EventManager 的模式决定。模式选错会导致从训练不稳定到物理不一致的各种问题。


8.2 EventManager 四种模式 ⭐⭐⭐

这一节解决什么问题:四种事件触发模式各适合什么场景?选错模式的后果是什么?

模式总览 ⭐⭐

mjlab 和 Isaac Lab 的 EventManager 支持四种(Isaac Lab 多一种 prestartup)触发模式。每种模式对应不同的物理语义:

模式 触发时机 典型用途 适合随机化什么
startup 环境初始化后一次 硬件之间的固定差异 质量、惯量基线
reset episode 重置时 每集可能变化的参数 摩擦、PD 增益、COM 偏移
interval 随机间隔触发 稀疏外部扰动 push velocity, 重力扰动
step 每个 env step 需要精确时序的外力 impulse 生命周期管理

Isaac Lab 额外有 prestartup 模式——在仿真启动前的 USD 层面执行,用于 randomize_rigid_body_scale 等必须在 PhysX 初始化前完成的操作。mjlab 不需要此模式因为 MuJoCo 没有 USD 中间层。

双框架配置对照 ⭐⭐⭐

以下是 Go1 velocity task 中典型 DR 事件的双框架配置:

# ============= mjlab 事件配置 =============
# src/mjlab/tasks/velocity/velocity_env_cfg.py
events = {
    # startup: 模拟硬件差异(每个 env 不同但 episode 内不变)
    "foot_friction": EventTermCfg(
        func=mdp.randomize_geom_friction,
        mode="startup",
        params={
            "entity_cfg": SceneEntityCfg("robot", geom_names=".*foot.*"),
            "friction_range": (0.5, 1.25),
            "operation": "scale",
        },
    ),
    "base_mass": EventTermCfg(
        func=mdp.randomize_body_mass,
        mode="startup",
        params={
            "entity_cfg": SceneEntityCfg("robot", body_names="base"),
            "mass_distribution_params": (-1.0, 3.0),
            "operation": "add",
        },
    ),
    "encoder_bias": EventTermCfg(
        func=mdp.add_body_offset,
        mode="startup",
        params={
            "entity_cfg": SceneEntityCfg("robot"),
            "offset_range": (-0.05, 0.05),
        },
    ),

    # reset: 每集环境变化
    "reset_robot_state": EventTermCfg(
        func=mdp.reset_root_state_uniform,
        mode="reset",
        params={
            "pose_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5), "yaw": (-3.14, 3.14)},
            "velocity_range": {
                "x": (-0.5, 0.5), "y": (-0.5, 0.5), "z": (-0.5, 0.5),
                "roll": (-0.5, 0.5), "pitch": (-0.5, 0.5), "yaw": (-0.5, 0.5),
            },
        },
    ),
    "reset_joints": EventTermCfg(
        func=mdp.reset_joints_by_offset,
        mode="reset",
        params={
            "position_range": (-0.5, 0.5),
            "velocity_range": (-0.5, 0.5),
        },
    ),

    # interval: 稀疏扰动
    "push_robot": EventTermCfg(
        func=mdp.push_by_setting_velocity,
        mode="interval",
        interval_range_s=(10.0, 15.0),
        params={
            "velocity_range": {
                "x": (-1.0, 1.0), "y": (-1.0, 1.0),
                "roll": (-0.25, 0.25), "pitch": (-0.25, 0.25),
            },
        },
    ),
}
# ============= Isaac Lab 事件配置 =============
# source/isaaclab_tasks/.../velocity/velocity_env_cfg.py
@configclass
class EventCfg:
    robot_physics_material = EventTerm(
        func=mdp.randomize_rigid_body_material,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("robot", body_names=".*"),
            "static_friction_range": (0.7, 1.3),
            "dynamic_friction_range": (1.0, 1.0),
            "restitution_range": (1.0, 1.0),
            "num_buckets": 250,  # PhysX 材质 bucket 限制
        },
    )
    robot_joint_stiffness_and_damping = EventTerm(
        func=mdp.randomize_actuator_gains,
        mode="reset",
        params={
            "asset_cfg": SceneEntityCfg("robot", joint_names=".*"),
            "stiffness_distribution_params": (0.75, 1.5),
            "damping_distribution_params": (0.3, 3.0),
            "operation": "scale",
            "distribution": "log_uniform",
        },
    )
    push_robot = EventTerm(
        func=mdp.push_by_setting_velocity,
        mode="interval",
        is_global_time=True,
        interval_range_s=(10.0, 15.0),
        params={
            "velocity_range": {"x": (-1.0, 1.0), "y": (-1.0, 1.0)},
        },
    )

关键差异

维度 mjlab Isaac Lab
摩擦随机化 直接修改 geom_friction 字段 通过 randomize_rigid_body_material + num_buckets
PD 增益 修改 actuator_kp / actuator_kd randomize_actuator_gains + log_uniform
材质限制 无限制(MuJoCo 无 bucket 概念) PhysX 限 64,000 unique materials → 必须用 bucket
prestartup 不需要(无 USD 层) USD-level 操作必须用 prestartup
字段扩展 expand_model_fields() + CUDA Graph 重 capture PhysX 内部管理

模式选择决策树 ⭐⭐

这个参数在真实世界中多久变化一次?
├── 出厂后不变(硬件公差、装配误差)
│   └── startup 模式
│       例:base_mass, encoder_bias, link_inertia
├── 每个任务/场景可能不同
│   └── reset 模式
│       例:ground_friction, PD_gains, initial_pose
├── 任务中偶尔突发
│   └── interval 模式
│       例:push(被人撞了一下), wind_gust, gravity_tilt
└── 每一步都在变(连续扰动)
    └── step 模式(或 actor corruption in obs)
        例:sensor_noise(通常通过 obs corruption 实现更高效)

反事实推理:如果把应该用 startup 的质量随机化放到 interval 模式会怎样?每隔几秒机器人的质量就会突然改变——这在物理上违反了质量守恒。策略会被迫学习一种非物理的"质量跳变"响应,部署到真机上不会遇到这种情况,训练出的应对能力完全浪费。更糟的是,质量变化可能触发 set_const 级别的派生常量重算,导致训练吞吐量大幅下降。

EventManager 内部调度机制 ⭐⭐

# EventManager.apply() 的核心逻辑(简化)
def apply(self, mode: str, env_ids: torch.Tensor | None = None):
    """按模式执行所有对应的事件。"""
    for name, (func, cfg) in self._mode_term_cfgs[mode].items():
        if mode == "interval":
            # interval 模式使用 per-env 计时器
            env_ids_to_apply = self._check_interval_timer(name, env_ids)
            if env_ids_to_apply is None or len(env_ids_to_apply) == 0:
                continue
            func.func(self._env, env_ids_to_apply, **cfg.params)
        elif mode == "reset":
            # reset 模式只对刚被 reset 的 env 执行
            func.func(self._env, env_ids, **cfg.params)
        elif mode == "startup":
            # startup 模式在初始化时对所有 env 执行一次
            all_env_ids = torch.arange(self._env.num_envs, device=self._env.device)
            func.func(self._env, all_env_ids, **cfg.params)
        elif mode == "step":
            # step 模式每步对所有 env 执行
            all_env_ids = torch.arange(self._env.num_envs, device=self._env.device)
            func.func(self._env, all_env_ids, **cfg.params)

interval 计时器的实现。interval 模式使用 per-env 计时器避免所有环境同步触发——如果 4096 个环境在同一步都收到 push,会导致 batch 内的 transition 高度相关,GAE 的 advantage 估计方差暴增。per-env 计时器让每个 env 独立触发 push,保持 batch 的多样性。

# interval 计时器逻辑
def _check_interval_timer(self, term_name, env_ids):
    """检查哪些 env 应该在当前步触发 interval 事件。"""
    lo, hi = self._term_cfgs[term_name].interval_range_s

    # 每个 env 的下一次触发时间
    self._interval_timers[term_name] -= self._env.step_dt

    # 找到计时器到期的 env
    expired = self._interval_timers[term_name] <= 0
    env_ids_to_apply = expired.nonzero().squeeze(-1)

    # 重置到期 env 的计时器(重新采样间隔)
    if len(env_ids_to_apply) > 0:
        # 必须指定 device,否则 CPU 张量赋值给 CUDA 张量会触发 device mismatch
        new_intervals = torch.rand(len(env_ids_to_apply), device=self._env.device) * (hi - lo) + lo
        self._interval_timers[term_name][env_ids_to_apply] = new_intervals

    return env_ids_to_apply

⚠️ 常见陷阱

⚠️ 编程陷阱:interval 事件的 is_global_time=True 导致同步触发。 Isaac Lab 的 is_global_time=True 让所有 env 共享计时器——所有 env 同时收到 push。对 locomotion 来说这通常不是期望行为。推荐 is_global_time=False(默认),让每个 env 独立计时。

💡 概念误区:认为 reset 模式 = startup 模式重复执行。 startup 在环境创建后只触发一次,之后所有 episode 都使用相同的参数。reset 在每次 episode 重置时重新采样。startup 模拟"每台机器人出厂不同但使用中不变",reset 模拟"每次任务环境可能不同"。

🧠 思维陷阱:认为"事件配置写了就一定生效"。 从配置到物理生效需要经过四层。自检方法:训练开始后打印 sim.expanded_fields(mjlab)或检查 PhysX material count(Isaac Lab),确认目标字段确实被随机化了。

练习

  1. [分类题] 把以下 DR 项分配到正确的模式:(a) 足底摩擦系数 (b) base mass (c) 外部推力 (d) encoder bias (e) 初始关节位置 (f) 重力方向微扰。说明每个的物理理由。
  2. [源码题] 在 mjlab velocity task 的 cfg 中找到所有事件配置。画出事件触发时间线(哪些在 startup、哪些在每次 reset、哪些在 episode 中间)。
  3. [调试题] 训练有 DR 和无 DR 的效果完全一样。列出至少 3 个可能的原因和排查步骤。

上节讲了"什么时候触发"。接下来的核心问题是"随机化什么"——每个物理参数对应哪个 API,两个框架有什么差异。


8.3 DR 函数映射表与双框架配置 ⭐⭐

这一节解决什么问题:双框架中可随机化的物理参数有哪些?API 怎么调用?参数范围怎么选?

完整 DR 函数对照表 ⭐⭐⭐

物理参数 mjlab 函数 Isaac Lab 函数 推荐模式 典型范围
刚体质量 randomize_body_mass randomize_rigid_body_mass startup/reset add: (-1, 3) kg
地面摩擦 randomize_geom_friction randomize_rigid_body_material startup/reset scale: (0.5, 1.25)
PD 刚度 randomize_actuator_gains randomize_actuator_gains reset log_uniform: (0.75, 1.5)
PD 阻尼 randomize_actuator_gains randomize_actuator_gains reset log_uniform: (0.3, 3.0)
关节 armature randomize_dof_armature (via joint properties) startup scale: (0.9, 1.1)
COM 偏移 randomize_body_com randomize_rigid_body_mass (recompute_inertia) startup add: (-2cm, 2cm)
弹性系数 randomize_geom_restitution (via randomize_rigid_body_material) startup (0.0, 0.5)
外部推力 push_by_setting_velocity push_by_setting_velocity interval (-1, 1) m/s
脉冲力 apply_body_impulse apply_external_force_torque step (0, 50) N
重力方向 randomize_gravity randomize_physics_scene_gravity interval ±5° tilt
初始姿态 reset_root_state_uniform reset_root_state_uniform reset xyz, rpy 范围
初始关节 reset_joints_by_offset reset_joints_by_offset reset ±0.5 rad
编码器偏置 add_body_offset (via obs corruption) startup ±0.05 rad
惯量张量 pseudo_inertia (手动实现) startup alpha: (0.8, 1.2)

采样分布的选择 ⭐⭐

DR 函数通常支持三种采样分布。选择哪种分布取决于参数的物理特性:

均匀分布(uniform):适用于参数范围明确且均匀覆盖的情况。摩擦系数、初始姿态偏移通常用均匀分布。

对数均匀分布(log_uniform):适用于参数跨越多个数量级的情况。PD 增益的阻尼系数可能从 0.3 到 3.0——在线性空间中,0.3-1.0 占范围的 30% 但在对数空间中占 50%。log_uniform 确保低值和高值都有足够的采样概率。

# 对数均匀采样的实现
import torch

def log_uniform(low: float, high: float, size: tuple) -> torch.Tensor:
    """在对数空间均匀采样。"""
    log_low = torch.log(torch.tensor(low))
    log_high = torch.log(torch.tensor(high))
    return torch.exp(torch.rand(size) * (log_high - log_low) + log_low)

# 示例:PD 阻尼从 0.3 到 3.0
damping_samples = log_uniform(0.3, 3.0, (4096, 12))
# 线性空间中:约 50% 的样本在 [0.3, 1.0],50% 在 [1.0, 3.0]
# 这比 uniform(0.3, 3.0) 更好——uniform 只有 26% 的样本在 [0.3, 1.0]

高斯分布(gaussian):适用于参数集中在标称值附近但有长尾的情况。观测噪声通常用高斯分布。注意需要 clip 防止极端值。

operation 模式 ⭐⭐

DR 函数的 operation 参数控制如何应用采样值:

operation 含义 公式 适用场景
"add" 在标称值上加偏移 \(\theta_{\text{new}} = \theta_0 + \Delta\) 质量负载、编码器偏置
"scale" 按比例缩放标称值 \(\theta_{\text{new}} = \theta_0 \times s\) 摩擦、PD 增益
"abs" 直接设置绝对值 \(\theta_{\text{new}} = v\) 需要精确控制范围的参数

选择原则:如果参数的不确定性与标称值成比例(如"摩擦系数可能偏离标称值 ±30%"),用 scale。如果不确定性是绝对量(如"负载 ±2 kg"),用 add

自定义 DR 函数的编写模式 ⭐⭐

# mjlab 自定义 DR 函数模板
def my_custom_randomization(
    env: ManagerBasedRLEnv,
    env_ids: torch.Tensor,
    entity_cfg: SceneEntityCfg,
    range_params: tuple[float, float],
    operation: str = "scale",
):
    """自定义 DR 函数必须遵循的签名。

    Args:
        env: 环境引用
        env_ids: 需要随机化的 env indices [N]
        entity_cfg: 目标实体配置(机器人、关节等)
        range_params: (low, high) 采样范围
        operation: "add" | "scale" | "abs"
    """
    # 1. 获取目标实体
    entity = env.scene[entity_cfg.name]

    # 2. 采样随机值
    low, high = range_params
    num_envs = len(env_ids)
    random_values = torch.rand(num_envs, device=env.device) * (high - low) + low

    # 3. 获取当前参数值
    current = entity.data.some_parameter[env_ids]  # 取决于具体参数

    # 4. 应用 operation
    if operation == "add":
        new_values = current + random_values
    elif operation == "scale":
        new_values = current * random_values
    else:  # abs
        new_values = random_values

    # 5. 写回(通过 mjlab 的 model field API)
    entity.write_some_parameter_to_sim(new_values, env_ids)

Isaac Lab 的自定义 DR 函数签名略有不同——通过 asset_cfg 而非 entity_cfg 访问:

# Isaac Lab 自定义 DR 函数模板
def my_custom_randomization_isaac(
    env: ManagerBasedRLEnv,
    env_ids: torch.Tensor,
    asset_cfg: SceneEntityCfg,
    distribution_params: tuple[float, float],
    operation: str = "scale",
    distribution: str = "uniform",
):
    """Isaac Lab DR 函数签名。"""
    asset = env.scene[asset_cfg.name]

    if distribution == "uniform":
        values = torch.rand(len(env_ids), device=env.device) * (
            distribution_params[1] - distribution_params[0]
        ) + distribution_params[0]
    elif distribution == "log_uniform":
        values = log_uniform(distribution_params[0], distribution_params[1], 
                            (len(env_ids),))
    elif distribution == "gaussian":
        mean, std = distribution_params
        values = torch.randn(len(env_ids), device=env.device) * std + mean

    # 应用 operation 到资产...

DR 配置验证工具 ⭐⭐⭐

以下工具在训练开始前验证 DR 配置是否正确——包括字段扩展、entity 匹配和数值范围:

# dr_verify.py — DR 配置验证工具
import torch

def verify_dr_config(env, verbose=True):
    """验证 DR 配置的完整性和正确性。"""
    em = env.event_manager
    issues = []

    print("DR Configuration Verification")
    print("=" * 60)

    # 1. 检查各模式的事件数量
    for mode in ["startup", "reset", "interval", "step"]:
        terms = em._mode_term_cfgs.get(mode, {})
        count = len(terms)
        print(f"\n  [{mode:>8s}] {count} events:")
        for name, (func, cfg) in terms.items():
            print(f"    - {name}: func={func.func.__name__}")
            if hasattr(cfg, 'params'):
                for k, v in cfg.params.items():
                    if 'range' in k.lower() or 'distribution' in k.lower():
                        print(f"        {k} = {v}")

    # 2. 检查 expanded fields(mjlab 特有)
    if hasattr(env.sim, 'expanded_fields'):
        fields = env.sim.expanded_fields
        print(f"\n  Expanded model fields: {fields}")
        if not fields:
            issues.append("⚠️ No expanded fields — DR may not be writing to sim!")

    # 3. 检查 entity pattern 匹配
    for mode, terms in em._mode_term_cfgs.items():
        for name, (func, cfg) in terms.items():
            if hasattr(cfg, 'params') and 'entity_cfg' in cfg.params:
                entity_cfg = cfg.params['entity_cfg']
                # 检查 entity 是否存在于 scene 中
                try:
                    entity = env.scene[entity_cfg.name]
                    # 检查 body/geom/joint pattern 是否匹配到内容
                    if hasattr(entity_cfg, 'body_names') and entity_cfg.body_names:
                        matched = entity.find_bodies(entity_cfg.body_names)
                        if len(matched) == 0:
                            issues.append(
                                f"⚠️ {name}: body pattern '{entity_cfg.body_names}' "
                                f"matched 0 bodies!")
                        else:
                            print(f"    {name}: matched {len(matched)} bodies")
                except KeyError:
                    issues.append(f"🔴 {name}: entity '{entity_cfg.name}' not in scene!")

    # 4. 范围合理性检查
    for mode, terms in em._mode_term_cfgs.items():
        for name, (func, cfg) in terms.items():
            if hasattr(cfg, 'params'):
                for k, v in cfg.params.items():
                    if isinstance(v, (tuple, list)) and len(v) == 2:
                        lo, hi = v
                        if isinstance(lo, (int, float)) and isinstance(hi, (int, float)):
                            if lo >= hi:
                                issues.append(f"⚠️ {name}.{k}: range [{lo}, {hi}] is empty!")
                            if 'mass' in k.lower() and lo < 0 and 'add' not in str(cfg.params.get('operation', '')):
                                issues.append(f"⚠️ {name}.{k}: negative mass range!")

    # 报告
    print(f"\n{'='*60}")
    if issues:
        print(f"Found {len(issues)} issues:")
        for issue in issues:
            print(f"  {issue}")
    else:
        print("✅ All DR checks passed!")

    return len(issues) == 0

# 使用:在训练前调用
# verify_dr_config(env)

DR 生效性测试——before/after 对比 ⭐⭐

# dr_effectiveness_test.py — 验证 DR 是否真正影响了物理仿真
def test_dr_effectiveness(env, param_name="geom_friction", num_envs_to_check=5):
    """验证 DR 是否产生了 per-env 不同的物理参数。"""
    print(f"\nDR Effectiveness Test: {param_name}")
    print("=" * 50)

    # Reset 触发 DR
    env.reset()

    # 检查不同 env 的参数值是否不同
    # (具体实现取决于框架和参数类型)
    if hasattr(env.sim, 'model'):
        model = env.sim.model
        if hasattr(model, param_name):
            field = getattr(model, param_name)
            if field.dim() >= 2:  # per-env field
                for i in range(min(num_envs_to_check, env.num_envs)):
                    print(f"  env[{i}]: {field[i].cpu().numpy()[:4]}...")

                # 检查 env 间的差异
                if field.shape[0] > 1:
                    variance = field.var(dim=0).mean().item()
                    print(f"  Cross-env variance: {variance:.6f}")
                    if variance < 1e-10:
                        print(f"  ⚠️ All envs have SAME {param_name} — DR not working!")
                    else:
                        print(f"  ✅ Envs have different {param_name} — DR is active")
            else:
                print(f"  ⚠️ {param_name} is shared (not per-env) — not expanded!")

    # 功能测试:在不同摩擦下同一 action 应产生不同 next state
    obs = env.reset()
    actions = torch.zeros(env.num_envs, env.action_manager.total_action_dim,
                         device=env.device)
    obs_after, _, _, _, _ = env.step(actions)

    # 如果 DR 生效,不同 env 的 next state 应该不同
    actor_obs = obs_after.get("actor", obs_after.get("policy"))
    env_variance = actor_obs.var(dim=0).mean().item()
    print(f"  Obs variance across envs (same action): {env_variance:.6f}")
    if env_variance < 1e-8:
        print(f"  ⚠️ Same action → same obs in all envs — DR may not be effective!")

完整的 DR Checklist ⭐⭐

在训练完成后、进入 Ch09 之前,逐项验证:

[ ] 1. Phase 0 clean baseline tracking > 0.7
[ ] 2. Phase 4 full DR tracking > 0.55(允许比 Phase 0 低 15-25%)
[ ] 3. 摩擦网格评估:最低摩擦处 tracking > 0.3, fall < 30%
[ ] 4. 质量网格评估:±3 kg 处 tracking > 0.4
[ ] 5. Push recovery:1 m/s push 后 2 秒内恢复
[ ] 6. 分布外测试:范围外 ±20% 参数下平缓退化(非突然崩溃)
[ ] 7. verify_dr_config() 无 ⚠️ 或 🔴
[ ] 8. test_dr_effectiveness() 确认 per-env 参数不同
[ ] 9. TensorBoard 中 tracking 和 penalty 的 DR 开关 A/B 对比合理

⚠️ 常见陷阱

⚠️ 编程陷阱:entity pattern 空匹配导致 DR 静默失效。 用正则 "foot.*" 匹配足端 geom,但 MJCF 中名字是 left_foot_1right_foot_2——pattern 空匹配,摩擦随机化从未被应用。自检:在小 env 中先打印 pattern 匹配的 entity ids,确认非空。

⚠️ 编程陷阱:Isaac Lab 的 num_buckets 设置不当。 PhysX 限制每个场景 64,000 个 unique materials。如果 num_buckets=None(无 bucket),4096 envs × 若干 body = 可能超限导致崩溃。推荐设 num_buckets=250

练习

  1. [编程题] 在 mjlab 中为 Go1 velocity task 添加一个自定义 DR:随机化 dof_damping,使用 log_uniform 分布,范围 (0.5, 2.0),operation="scale",mode="reset"。
  2. [计算题] damping_distribution_params=(0.3, 3.0) + distribution="log_uniform" + operation="scale"。标称阻尼 \(d_0 = 1.0\)。计算随机化后阻尼的范围和中位数。
  3. [跨框架题] 对比 mjlab 和 Isaac Lab 中 foot friction DR 的配置差异。两者实际效果等价吗?

8.4 物理一致的惯性随机化:pseudo-inertia ⭐⭐⭐

这一节解决什么问题:为什么单独随机化 mass 是不够的?如何实现物理一致的刚体惯性参数随机化?

问题:朴素质量随机化的物理不一致 ⭐⭐⭐

一个刚体的惯性特征由 10 个参数完整描述:质量 \(m\)(1 个)、质心位置 \(\mathbf{c}\)(3 个)、惯量张量 \(\mathbf{I}\)(6 个独立参数,\(\mathbf{I}\) 是对称正定矩阵)。这 10 个参数之间有物理约束——不是所有 \((m, \mathbf{c}, \mathbf{I})\) 组合都对应一个真实存在的刚体。

如果只随机化 \(m\) 而不动 \(\mathbf{I}\)\(\mathbf{c}\),得到的刚体物理上不一致——质量翻倍但惯量不变,意味着平动响应变了但旋转响应没变。这就像一辆车质量翻倍但转弯半径不变——物理上不可能。MuJoCo 在这种参数下不会报错但行为可能不自然,PhysX 可能静默接受或产生微妙的接触异常。

Pseudo-Inertia 参数化(Rucker & Wensing 2022) ⭐⭐⭐

解决方案是用 pseudo-inertia 矩阵 \(\Sigma \in \mathbb{R}^{4 \times 4}\) 统一表示所有 10 个参数:

\[\Sigma = \begin{pmatrix} \frac{1}{2}\text{tr}(\mathbf{I})\mathbf{I}_3 - \mathbf{I} + m \mathbf{c}\mathbf{c}^T & m\mathbf{c} \\ m\mathbf{c}^T & m \end{pmatrix}\]

关键性质:\(\Sigma\) 对称正定 \(\Leftrightarrow\) \((m, \mathbf{c}, \mathbf{I})\) 物理一致。因此,只要在对称正定矩阵空间中随机采样,就保证了物理一致性。

Cholesky 分解方法\(\Sigma = LL^T\)\(L\) 是下三角矩阵(10 个独立参数)。在 \(L\) 的元素上做随机扰动,通过 \(LL^T\) 恢复正定矩阵,再提取 \((m, \mathbf{c}, \mathbf{I})\)

# pseudo-inertia 随机化的核心逻辑(简化自 mjlab 实现)
import torch

def randomize_pseudo_inertia(
    nominal_mass: torch.Tensor,         # [num_envs, num_bodies]
    nominal_com: torch.Tensor,          # [num_envs, num_bodies, 3]
    nominal_inertia: torch.Tensor,      # [num_envs, num_bodies, 3, 3]
    alpha_range: tuple[float, float],   # 全局密度缩放范围
    env_ids: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
    """物理一致的惯性参数随机化。

    Returns:
        new_mass, new_com, new_inertia — 保证物理一致
    """
    num_envs = len(env_ids)
    num_bodies = nominal_mass.shape[1]

    results_mass = nominal_mass[env_ids].clone()
    results_com = nominal_com[env_ids].clone()
    results_inertia = nominal_inertia[env_ids].clone()

    for b in range(num_bodies):
        m = nominal_mass[env_ids, b]          # [N]
        c = nominal_com[env_ids, b]           # [N, 3]
        I = nominal_inertia[env_ids, b]       # [N, 3, 3]

        # 构造 pseudo-inertia 矩阵 [N, 4, 4]
        sigma = torch.zeros(num_envs, 4, 4, device=m.device)
        sigma[:, :3, :3] = (0.5 * torch.diagonal(I, dim1=1, dim2=2).sum(1, keepdim=True).unsqueeze(-1) 
                            * torch.eye(3, device=m.device).unsqueeze(0) - I 
                            + m.unsqueeze(-1).unsqueeze(-1) * torch.bmm(c.unsqueeze(2), c.unsqueeze(1)))
        sigma[:, :3, 3] = m.unsqueeze(-1) * c
        sigma[:, 3, :3] = m.unsqueeze(-1) * c
        sigma[:, 3, 3] = m

        # Cholesky 分解
        L = torch.linalg.cholesky(sigma)  # [N, 4, 4]

        # 全局密度缩放(alpha)
        alpha = torch.rand(num_envs, 1, 1, device=m.device) * (alpha_range[1] - alpha_range[0]) + alpha_range[0]
        L_perturbed = L * torch.sqrt(alpha)

        # 恢复正定矩阵
        sigma_new = torch.bmm(L_perturbed, L_perturbed.transpose(1, 2))

        # 提取物理参数
        eye = torch.eye(3, device=m.device).unsqueeze(0)
        m_new = sigma_new[:, 3, 3]
        c_new = sigma_new[:, :3, 3] / m_new.unsqueeze(-1)
        Sigma_new = sigma_new[:, :3, :3]  # 关于原点的二阶矩 Σ_origin
        # 逆映射 I = tr(Σ)·I₃ − Σ;再用平行轴定理从原点移回质心:减去 m(‖c‖²I₃ − ccᵀ)
        I_origin = (torch.diagonal(Sigma_new, dim1=1, dim2=2).sum(1, keepdim=True).unsqueeze(-1) * eye
                    - Sigma_new)
        I_recovered = I_origin - m_new.unsqueeze(-1).unsqueeze(-1) * (
            c_new.square().sum(1, keepdim=True).unsqueeze(-1) * eye
            - torch.bmm(c_new.unsqueeze(2), c_new.unsqueeze(1)))

        results_mass[:, b] = m_new
        results_com[:, b] = c_new
        results_inertia[:, b] = I_recovered

    return results_mass, results_com, results_inertia

expand_model_fields 机制 ⭐⭐⭐

mjlab 的 DR 需要 per-env 存储来支持每个环境独立的物理参数。默认情况下,MuJoCo Warp 的模型参数是所有环境共享的(节省显存)。expand_model_fields() 负责把共享字段扩展为 per-env 数组:

# expand_model_fields 的工程语义
# 扩展前:body_mass shape = [num_bodies]        (所有 env 共享)
# 扩展后:body_mass shape = [num_envs, num_bodies]  (每个 env 独立)

# 这在 EventManager 初始化时自动完成:
# 1. EventManager 扫描所有事件配置
# 2. 找到所有需要随机化的 model 字段
# 3. 调用 sim.expand_model_fields() 扩展这些字段
# 4. 重建 CUDA capture graph(关键!)

expand_model_fields 的完整实现逻辑(简化自 sim/randomization.py):

# expand_model_fields 核心实现
def expand_model_fields(sim, field_names):
    """把共享 model 字段扩展为 per-env 数组。"""
    for field_name in field_names:
        original = getattr(sim.model, field_name)

        if field_name in sim._expanded_fields:
            continue  # 已经扩展过了

        # 创建 per-env 版本
        if original.ndim == 1:
            expanded = original.unsqueeze(0).repeat(sim.num_worlds, 1)
        elif original.ndim == 2:
            expanded = original.unsqueeze(0).repeat(sim.num_worlds, 1, 1)
        else:
            raise ValueError(f"Unsupported ndim for {field_name}: {original.ndim}")

        # 替换 model 字段
        setattr(sim.model, field_name, expanded)
        sim._expanded_fields.add(field_name)

        # 保存默认值快照(用于 scale/add 操作的基准)
        sim._default_snapshot[field_name] = expanded.clone()

    # 必须在所有字段扩展后重新 capture Graph
    sim._graph_captured = False
    print(f"Expanded fields: {field_names}, total: {sim._expanded_fields}")

CUDA Graph 与字段扩展的时序约束。MuJoCo Warp 使用 CUDA Graph capture 来优化仿真性能——把一系列 GPU kernel 的调用顺序记录下来,后续 replay 时跳过 CPU 端的调度开销。expand_model_fields() 替换了 model 的 GPU 数组(从共享到 per-env),必须在 CUDA Graph capture 之前完成。

mjlab 的初始化时序保证了这一点:

1. env.__init__()
2. EventManager.__init__() → 收集所有 event 需要的字段名
3. sim.expand_model_fields(all_needed_fields)  ← 字段扩展
4. startup events 执行(写入 per-env 初始值)
5. sim.capture_graph()  ← CUDA Graph capture
6. 训练循环开始(Graph replay 正确读取 per-env 数组)

步骤 3 必须在步骤 5 之前。如果在 capture 之后扩展字段,Graph 失效:

# ❌ 错误:在 capture 后扩展
sim.capture_graph()  # Graph 记录了 body_mass 的旧指针
sim.expand_model_fields(["body_mass"])  # 替换了指针
# Graph replay 读旧指针 → DR 值被忽略

# ✅ 正确:mjlab 自动在 capture 前扩展
# 用户只需在 EventTermCfg 中声明需要的字段
# EventManager 自动收集并在 init 时扩展

DR 核心写入引擎 ⭐⭐

当 DR 事件触发时,值如何写入 per-env 数组?以 randomize_body_mass 为例:

# DR 写入核心逻辑(简化自 dr/_core.py)
def _randomize_model_field(
    sim, field_name, env_ids,
    distribution_params, operation, distribution
):
    """DR 核心引擎:采样值并写入 per-env model 字段。"""
    # 1. 获取 per-env 数组
    field = getattr(sim.model, field_name)
    assert field.ndim >= 2, f"Field {field_name} not expanded! Run expand first."

    # 2. 获取默认值(scale/add 操作的基准)
    default = sim._default_snapshot[field_name]

    # 3. 采样
    lo, hi = distribution_params
    shape = (len(env_ids),) + field.shape[1:]

    if distribution == "uniform":
        samples = torch.rand(shape, device=sim.device) * (hi - lo) + lo
    elif distribution == "log_uniform":
        log_lo = torch.log(torch.tensor(lo, device=sim.device))
        log_hi = torch.log(torch.tensor(hi, device=sim.device))
        samples = torch.exp(
            torch.rand(shape, device=sim.device) * (log_hi - log_lo) + log_lo
        )
    elif distribution == "gaussian":
        mean, std = lo, hi
        samples = torch.randn(shape, device=sim.device) * std + mean

    # 4. 应用操作
    if operation == "scale":
        new_values = default[env_ids] * samples
    elif operation == "add":
        new_values = default[env_ids] + samples
    elif operation == "abs":
        new_values = samples

    # 5. 关键:原地写入(不替换指针!CUDA Graph 安全)
    field[env_ids] = new_values
    # 注意:field[env_ids] = ... 是 tensor 的 __setitem__
    # 它修改数组内容,不改变 GPU 指针地址
    # CUDA Graph replay 仍然有效

    # 6. 按需重算派生量
    if field_name in {"body_mass", "body_ipos", "body_inertia"}:
        sim.recompute_constants(env_ids)

步骤 5 的原地写入是整个 DR 链路中最关键的工程约束。field[env_ids] = new_values 修改数组内容但不改变 GPU 指针地址,CUDA Graph replay 仍然读取同一块内存——只是内容已更新。如果不小心写成 field = new_values(替换整个 tensor),指针变了,Graph 失效。

per-env 存储的显存分析 ⭐⭐

# 显存估算脚本
def estimate_dr_memory(num_envs, num_bodies, expanded_fields):
    """估算 DR per-env 存储的额外显存。"""
    field_sizes = {
        "body_mass": num_bodies * 1,          # [num_bodies] float32
        "body_inertia": num_bodies * 6,       # [num_bodies, 6]
        "body_ipos": num_bodies * 3,          # [num_bodies, 3]
        "geom_friction": num_bodies * 3,      # [num_geoms, 3]
        "dof_damping": num_bodies * 1,        # [num_dofs] float32
        "dof_armature": num_bodies * 1,       # [num_dofs]
    }

    total_bytes = 0
    for field in expanded_fields:
        if field in field_sizes:
            field_bytes = num_envs * field_sizes[field] * 4  # float32
            total_bytes += field_bytes
            print(f"  {field}: {field_bytes / 1024:.1f} KB")

    total_mb = total_bytes / (1024 * 1024)
    print(f"  Total DR memory: {total_mb:.1f} MB")
    return total_mb

# Go1: 12 bodies, 4096 envs, 扩展 mass + inertia + friction
# estimate_dr_memory(4096, 12, ["body_mass", "body_inertia", "geom_friction"])
# → 约 2-3 MB,可忽略

RecomputeLevel 与性能影响 ⭐⭐

MuJoCo 的模型中,许多量是从基础参数派生的。修改基础参数后,可能需要调用 recompute_constants() 来更新派生量:

等级 含义 典型字段 性能影响
none 无需重算 geom_friction, dof_damping 几乎为零
set_const_0 重算 armature、qpos0 dof_armature, qpos0 中等
set_const 完整重算所有派生常量 body_mass, body_ipos 较大

工程含义:不要在 step 或高频 interval 模式中触发 set_const 级别的字段修改。质量随机化(set_const 级别)应放在 startup 或 reset 模式——每 episode 只触发一次,性能影响可接受。

性能测量方法

# 测量 DR 的性能开销
import time

def measure_dr_overhead(env, num_steps=1000):
    """测量有/无 DR 的训练吞吐量差异。"""
    actions = torch.zeros(env.num_envs, env.action_manager.total_action_dim,
                         device=env.device)

    # 热身
    for _ in range(50):
        env.step(actions)

    # 测量
    start = time.perf_counter()
    for _ in range(num_steps):
        env.step(actions)
    elapsed = time.perf_counter() - start

    steps_per_sec = num_steps * env.num_envs / elapsed
    print(f"  Steps/s: {steps_per_sec:.0f}")
    return steps_per_sec

# 使用:
# 1. 先关闭所有 DR,测量 baseline steps/s
# 2. 打开 DR,测量新的 steps/s
# 3. 如果下降 >30%,检查是否有 set_const 字段在高频模式

⚠️ 常见陷阱

⚠️ 编程陷阱:pseudo-inertia 的 alpha 范围过大导致 NaN。 Cholesky 分解要求输入矩阵正定。如果 alpha 范围太极端(如 0.01 到 100),数值精度可能导致分解失败。推荐从 (0.9, 1.1) 开始,逐步扩大到 (0.7, 1.5)。

练习

  1. [计算题] Go1 标称 base 质量 12.5 kg。使用 pseudo-inertia 的 alpha=(0.8, 1.2)。计算随机化后质量的范围。解释为什么这比直接 add(-2.5, 2.5) 更好。
  2. [编程题] 实现一个简单的 pseudo-inertia 验证脚本:给定一组 (m, c, I) 参数,检查它们是否物理一致(pseudo-inertia 矩阵正定)。

8.5 外力扰动 ⭐⭐

这一节解决什么问题:如何在训练中模拟外部推力?频率和强度怎么选?

Push vs Impulse ⭐⭐

两种外力扰动模式:

Push(速度注入):直接设置 base 的线/角速度。物理上等价于瞬间施加一个冲量。简单高效,是 locomotion 训练的标准选择。

# push_by_setting_velocity 的核心逻辑
def push_by_setting_velocity(env, env_ids, velocity_range):
    """通过直接设置 base velocity 模拟外部推力。"""
    robot = env.scene.robot

    # 采样随机速度
    vel_range = velocity_range
    random_vel = torch.zeros(len(env_ids), 6, device=env.device)
    for i, key in enumerate(["x", "y", "z", "roll", "pitch", "yaw"]):
        if key in vel_range:
            lo, hi = vel_range[key]
            random_vel[:, i] = torch.rand(len(env_ids), device=env.device) * (hi - lo) + lo

    # 直接设置 base velocity
    robot.write_root_velocity_to_sim(random_vel, env_ids)

Impulse(力/力矩注入):在指定 body 上施加外力,持续若干步后清零。更物理真实,但需要管理力的生命周期——通常需要 step 模式。

# apply_body_impulse 的核心逻辑(简化)
def apply_body_impulse(env, env_ids, body_name, force_range, duration_steps=5):
    """施加脉冲力,持续 duration_steps 步后自动清零。"""
    robot = env.scene.robot
    body_idx = robot.find_bodies(body_name)

    # 采样随机力
    random_force = torch.zeros(len(env_ids), 6, device=env.device)
    # ... 从 force_range 采样

    # 施加外力(需要在后续步骤中清零)
    robot.set_external_force_and_torque(random_force, body_idx, env_ids)

    # 注册一个 step 事件在 duration_steps 后清零
    # 或使用 step 模式的计数器管理

频率和强度的选择 ⭐⭐

参数 推荐值 理由
Push 间隔 10-15 秒 让策略有恢复时间
Push 线速度 ±1.0 m/s 约等于中等力度人推一下
Push 角速度 ±0.25 rad/s 较小的旋转扰动
Impulse 力 0-50 N 依赖机器人质量
Impulse 持续 3-10 步 太短策略感觉不到,太长变成持续力

分阶段引入 push 的原因:如果策略还不会走路就施加 push,策略在每次被推后都会摔倒——reward 信号几乎全是负的,策略无法从中学到"怎样抵抗推力"。正确做法是先在无 push 环境中训练到基本稳定,再引入 push。这与 Ch06 的 curriculum 思想一致——"先学基础再加难度"。

⚠️ 常见陷阱

⚠️ 编程陷阱:push 过早引入导致策略学到"不动"。 如果 push 在训练第一步就激活,策略发现"站着不动被推倒 → 摔倒 penalty"和"走路时被推倒 → 更大的 penalty"——不动反而更安全。

💡 概念误区:认为 impulse 比 push 更"真实"。 物理上 impulse 确实更精确(通过力而非直接修改速度),但 push_by_setting_velocity 在工程上更常用——因为它不需要管理力的生命周期,且与 MuJoCo 和 PhysX 的不同力模型无关。大多数 locomotion 论文(Rudin 2022, extreme-parkour, Walk-These-Ways)都使用 push_by_setting_velocity。

Push 强度选择的经验法则 ⭐⭐

push velocity 的选择需要与机器人质量和步态速度匹配:

机器人 质量 推荐 push velocity 等效冲量 说明
Go1 ~12 kg ±1.0 m/s ~12 N·s 相当于中等力度手推
Go2 ~15 kg ±1.0 m/s ~15 N·s 与 Go1 类似
ANYmal-C ~50 kg ±0.5 m/s ~25 N·s 大型四足需要更大绝对冲量
G1 (人形) ~35 kg ±0.3 m/s ~10.5 N·s 人形平衡更脆弱,推力要小
H1 (人形) ~47 kg ±0.3 m/s ~14 N·s 同上

经验法则:push velocity 应该使策略在训练充分后有 >80% 的概率在 1 秒内恢复平衡。如果恢复率 <50%,push 太强;如果恢复率 >98%,push 太弱。

Push 频率与训练阶段的交互 ⭐⭐

push 的 interval_range_s 需要与训练阶段配合:

# 早期训练(iteration < 500):长间隔,给策略学习基本步态的时间
events["push_early"] = EventTermCfg(
    func=mdp.push_by_setting_velocity,
    mode="interval",
    interval_range_s=(20.0, 30.0),  # 20-30 秒推一次
    params={
        "asset_cfg": SceneEntityCfg("robot"),
        "velocity_range": {"x": (-0.5, 0.5), "y": (-0.5, 0.5)},
    },
)

# 后期训练(iteration > 500):短间隔,增加挑战
events["push_late"] = EventTermCfg(
    func=mdp.push_by_setting_velocity,
    mode="interval",
    interval_range_s=(8.0, 12.0),  # 8-12 秒推一次
    params={
        "asset_cfg": SceneEntityCfg("robot"),
        "velocity_range": {"x": (-1.0, 1.0), "y": (-1.0, 1.0)},
    },
)

实现 push curriculum 需要在 CurriculumManager 中根据 iteration 切换 push 配置。或者更简单地:Phase 0-3 不配 push,Phase 4 开始 resume 训练时加入 push(与下节六阶段方案一致)。

练习

  1. [设计题] 为一个 12.5 kg 的四足机器人设计 push 配置。计算 1 m/s 的 push velocity 等价于多大的冲量力(假设作用时间 0.02 s = 一个 policy step)。
  2. [实验题] 对比以下三种 push 配置的训练效果:(a) 无 push (b) 从第 0 步开始 push (c) 从第 500 iteration 开始 push。

上节覆盖了"随机化什么"(摩擦/质量/增益/惯量/外力/噪声/延迟)。接下来的核心问题是"按什么顺序随机化"——分阶段 DR 策略。


8.6 分阶段 DR 策略 ⭐⭐⭐

这一节解决什么问题:如何系统地、可调试地引入 DR?

为什么要分阶段 ⭐⭐

一次性打开所有 DR 的问题:训练失败时无法归因。是摩擦太低?质量偏差太大?编码器偏置太强?还是它们的组合?如果同时变化 10 个参数,每个失败视频可能对应无数种根因组合。

分阶段引入的核心原则是单变量递进——每个阶段只增加一类 DR,验证通过后再加下一类。这样每个阶段的失败都能清晰归因。

六阶段方案 ⭐⭐⭐

Phase 0: Clean Baseline(无 DR)
├── 目标:验证 reward/obs/action 设计正确
├── 检查:tracking reward > 0.7, play video 正常
└── 如果失败:问题在 Ch05/Ch06/Ch07,不是 DR

Phase 1: 动力学随机化(startup)
├── 添加:foot_friction, base_mass, com_offset
├── 目标:策略适应不同硬件参数
├── 检查:tracking reward > 0.6(允许略降)
└── 如果失败:收窄范围,逐项排查

Phase 2: 执行器随机化(reset)
├── 添加:PD gains, joint damping, armature
├── 目标:策略适应不同执行器特性
├── 检查:action std 不要过大
└── 如果失败:检查 PD gain 范围是否过大

Phase 3: 感知随机化(startup/step)
├── 添加:encoder_bias, obs noise/corruption
├── 目标:策略适应传感器不确定性
├── 检查:tracking 在有噪声时不崩溃
└── 如果失败:噪声幅度太大

Phase 4: 外力扰动(interval)
├── 添加:push_velocity, gravity_tilt
├── 目标:策略能从扰动中恢复
├── 检查:被推后 1-2 秒内恢复正常步态
└── 如果失败:push 太强或策略 baseline 不够稳

Phase 5: 鲁棒性验证
├── 在固定参数网格上评估(见 8.7 节)
├── 在极端参数组合上压力测试
└── 通过 → 准备 sim-to-real(Ch23)

各阶段的完整 CLI 命令 ⭐⭐

# ============= Phase 0: Clean Baseline =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 300 \
  --agent.run-name phase0_clean --agent.seed 42 \
  --env.events.foot-friction None \
  --env.events.base-mass None \
  --env.events.push-robot None
# 验证:tracking > 0.7, play video 正常

# ============= Phase 1: 动力学随机化 =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 500 \
  --agent.run-name phase1_dynamics --agent.seed 42 \
  --agent.resume True --agent.load-run phase0_clean
# foot_friction 和 base_mass 使用默认配置(已在 cfg 中)
# push 仍然关闭

# ============= Phase 2: 执行器随机化 =============
# 在 cfg 中添加 PD gain randomization
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 800 \
  --agent.run-name phase2_actuator --agent.seed 42 \
  --agent.resume True --agent.load-run phase1_dynamics

# ============= Phase 3: 感知随机化 =============
# 在 cfg 中添加 encoder_bias 和 obs noise
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 1000 \
  --agent.run-name phase3_perception --agent.seed 42 \
  --agent.resume True --agent.load-run phase2_actuator

# ============= Phase 4: 外力扰动 =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 1500 \
  --agent.run-name phase4_push --agent.seed 42 \
  --agent.resume True --agent.load-run phase3_perception
# 此时 push 事件自动从 cfg 中激活

# ============= Phase 5: 鲁棒性验证 =============
# 在不同摩擦下 play
for FRICTION in 0.3 0.5 0.8 1.0 1.5; do
  uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
    --agent.load-run phase4_push --num-envs 4 \
    --env.events.foot-friction.params.friction-range "($FRICTION, $FRICTION)"
done

Isaac Lab 等价命令:

# Phase 0
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 4096 --max_iterations 300 --seed 42

# Phase 1-4: 通过修改 cfg 文件或 hydra overrides
# Isaac Lab 的事件配置在 @configclass 中,需要修改 Python 文件

# Phase 5: Play
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0

各阶段的诊断脚本 ⭐⭐⭐

# phase_diagnostic.py — 各阶段专用诊断
import torch

def diagnose_phase0(env, policy, num_steps=200):
    """Phase 0 诊断:验证 clean baseline 质量。"""
    obs = env.reset()
    actor_obs = obs.get("actor", obs.get("policy"))

    tracking_rewards = []
    fall_count = 0
    total_steps = 0

    for _ in range(num_steps):
        with torch.inference_mode():
            actions = policy(actor_obs)
        obs_dict, rewards, terminated, truncated, extras = env.step(actions)
        actor_obs = obs_dict.get("actor", obs_dict.get("policy"))
        total_steps += 1

        if terminated.any():
            fall_count += terminated.sum().item()

    fall_rate = fall_count / (total_steps * env.num_envs)
    print(f"Phase 0 Diagnostic:")
    print(f"  Fall rate: {fall_rate:.1%}")

    if fall_rate > 0.05:
        print(f"  ⚠️ Fall rate too high for Phase 0!")
        print(f"  → 问题在 Ch05/Ch06/Ch07,不是 DR")
        print(f"  → 先修复 baseline 再加 DR")
        return False

    print(f"  ✅ Clean baseline healthy, ready for Phase 1")
    return True

def diagnose_phase1(env, policy, num_steps=200):
    """Phase 1 诊断:检查动力学 DR 对训练的影响。"""
    obs = env.reset()
    actor_obs = obs.get("actor", obs.get("policy"))

    # 检查 DR 是否生效
    em = env.event_manager
    active_events = {name: cfg for name, (_, cfg) in em._mode_term_cfgs.get("startup", {}).items()}

    print(f"Phase 1 Diagnostic:")
    print(f"  Active startup events: {list(active_events.keys())}")

    if not active_events:
        print(f"  ⚠️ No startup events found! DR may not be configured.")
        return False

    # 检查 expanded fields
    if hasattr(env.sim, 'expanded_fields'):
        print(f"  Expanded fields: {env.sim.expanded_fields}")

    # 运行短 rollout 检查 tracking 下降幅度
    for _ in range(num_steps):
        with torch.inference_mode():
            actions = policy(actor_obs)
        obs_dict, rewards, _, _, _ = env.step(actions)
        actor_obs = obs_dict.get("actor", obs_dict.get("policy"))

    print(f"  ✅ Phase 1 DR active, check TensorBoard for tracking regression")
    return True

def diagnose_phase4_push(env, policy, num_steps=500):
    """Phase 4 诊断:检查 push recovery 能力。"""
    obs = env.reset()
    actor_obs = obs.get("actor", obs.get("policy"))

    push_count = 0
    recovery_count = 0
    fall_after_push = 0

    for step in range(num_steps):
        with torch.inference_mode():
            actions = policy(actor_obs)
        obs_dict, rewards, terminated, truncated, extras = env.step(actions)
        actor_obs = obs_dict.get("actor", obs_dict.get("policy"))

        # 检查 push 事件是否触发(通过 velocity 突变检测)
        base_vel = env.scene.robot.data.root_lin_vel_b[:, :2]
        vel_mag = base_vel.norm(dim=1)

        if terminated.any():
            fall_after_push += terminated.sum().item()

    print(f"Phase 4 Diagnostic:")
    print(f"  Falls in {num_steps} steps: {fall_after_push}")
    if fall_after_push > num_steps * env.num_envs * 0.1:
        print(f"  ⚠️ High fall rate after push!")
        print(f"  → 减小 push velocity range")
        print(f"  → 或延后 push 引入时机")
    else:
        print(f"  ✅ Push recovery acceptable")

分阶段 DR 实验日志模板 ⭐⭐

# Phased DR Experiment Log

## Setup
- Robot: Go1 / G1 / ANYmal-C
- Framework: mjlab / Isaac Lab
- GPU: ________________
- Date: ________________

## Phase Results
| Phase | DR Items | Iterations | tracking | fall_rate | wall_time |
|:-----:|:---------|:----------:|:--------:|:---------:|:---------:|
| 0 | None | 300 | 0.78 | 2% | 3 min |
| 1 | friction, mass | 500 | 0.72 | 5% | 5 min |
| 2 | + PD gains | 800 | 0.68 | 8% | 8 min |
| 3 | + encoder_bias | 1000 | 0.65 | 10% | 10 min |
| 4 | + push | 1500 | 0.62 | 12% | 15 min |

## Robustness Grid
| Friction | tracking | fall_rate |
|:--------:|:--------:|:---------:|
| 0.3 | ____ | ____ |
| 0.5 | ____ | ____ |
| 0.8 | ____ | ____ |
| 1.0 | ____ | ____ |
| 1.5 | ____ | ____ |

## Analysis
- Phase 0→1 tracking 下降幅度: ____% (可接受: <15%)
- Phase 1→2 下降: ____% (可接受: <10%)
- 最弱参数区域: ________________
- 是否需要收窄范围: ________________

## Decision
- [ ] 准备进入 Ch09 (Teacher-Student)
- [ ] 需要调整 DR 范围
- [ ] 需要增加训练 iteration

⚠️ 常见陷阱

🧠 思维陷阱:跳过 Phase 0 直接从 Phase 4 开始。 如果不先验证 clean baseline,DR 引入的任何问题都无法归因——你不知道是 reward 设计错了还是 DR 范围太大。Phase 0 的投资回报最高。

练习

  1. [设计题] 为 G1 人形机器人设计完整的六阶段 DR 方案。G1 有 29 个自由度(vs Go1 的 12 个),需要额外考虑什么?
  2. [分析题] Phase 1 中低摩擦下策略很差。列出 3 种修复方向及其优劣。

8.7 DR 评估方法论 ⭐⭐

这一节解决什么问题:训练完成后如何系统地验证策略的鲁棒性?

固定参数网格评估 ⭐⭐

训练时 DR 在分布上随机采样——但评估时应该在固定的参数网格上系统测试。这样能发现策略在哪些参数区域表现好、哪些区域弱。

# dr_evaluation.py — 完整的 DR 鲁棒性评估脚本
import torch
import numpy as np
from collections import defaultdict

class DRRobustnessEvaluator:
    """系统化评估策略在不同物理参数下的鲁棒性。"""

    def __init__(self, env, policy):
        self.env = env
        self.policy = policy
        self.results = defaultdict(dict)

    def evaluate_single_param(self, param_name, param_values, 
                               num_episodes=50, max_steps=1000):
        """在单个参数的固定值网格上评估。"""
        print(f"\n{'='*50}")
        print(f"Evaluating: {param_name}")
        print(f"{'='*50}")

        for value in param_values:
            # 固定参数(关闭该参数的随机化)
            self._set_fixed_param(param_name, value)

            # 运行评估
            metrics = self._run_episodes(num_episodes, max_steps)

            self.results[param_name][value] = metrics
            print(f"  {param_name}={value:.3f}: "
                  f"tracking={metrics['tracking']:.3f}, "
                  f"fall_rate={metrics['fall_rate']:.0%}, "
                  f"avg_speed={metrics['avg_speed']:.2f} m/s")

        return self.results[param_name]

    def _run_episodes(self, num_episodes, max_steps):
        """运行指定数量的 episode 并收集 metrics。"""
        tracking_rewards = []
        fall_count = 0
        speeds = []

        obs = self.env.reset()
        actor_obs = obs.get("actor", obs.get("policy"))

        episode_count = 0
        step_count = 0
        current_tracking = 0.0

        while episode_count < num_episodes and step_count < max_steps * num_episodes:
            with torch.inference_mode():
                actions = self.policy(actor_obs)

            obs_dict, rewards, terminated, truncated, extras = self.env.step(actions)
            actor_obs = obs_dict.get("actor", obs_dict.get("policy"))
            step_count += 1

            # 收集速度数据
            base_vel = self.env.scene.robot.data.root_lin_vel_b[:, :2]
            speeds.append(base_vel.norm(dim=1).mean().item())

            # 检查 episode 结束
            done = terminated | truncated
            if done.any():
                episode_count += done.sum().item()
                # terminated 形如 [num_envs],直接 sum 统计本步真正摔倒(true termination)的 env 数;
                # 旧写法 terminated.any(dim=0).sum() 会塌缩成标量,每步最多 +1,严重低估摔倒数
                fall_count += terminated.sum().item()

        return {
            "tracking": np.mean(speeds[-100:]) if speeds else 0,
            "fall_rate": fall_count / max(episode_count, 1),
            "avg_speed": np.mean(speeds) if speeds else 0,
            "num_episodes": episode_count,
        }

    def _set_fixed_param(self, param_name, value):
        """固定某个物理参数到指定值(关闭随机化)。"""
        # 这里的实现取决于具体框架
        # mjlab: 直接修改 model field
        # Isaac Lab: 通过 EventManager 设置固定值
        pass

    def evaluate_friction_grid(self):
        """标准摩擦评估网格。"""
        return self.evaluate_single_param(
            "geom_friction",
            [0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.5, 2.0],
        )

    def evaluate_mass_grid(self):
        """标准质量评估网格。"""
        return self.evaluate_single_param(
            "body_mass_offset",
            [-3.0, -1.5, 0.0, 1.5, 3.0, 5.0],
        )

    def evaluate_push_recovery(self, push_velocities, recovery_steps=100):
        """评估策略的推力恢复能力。"""
        print(f"\nPush Recovery Evaluation")
        print(f"{'='*50}")

        for push_vel in push_velocities:
            obs = self.env.reset()
            actor_obs = obs.get("actor", obs.get("policy"))

            # 等待稳定
            for _ in range(50):
                with torch.inference_mode():
                    actions = self.policy(actor_obs)
                obs_dict, _, _, _, _ = self.env.step(actions)
                actor_obs = obs_dict.get("actor", obs_dict.get("policy"))

            # 施加 push
            push = torch.zeros(self.env.num_envs, 6, device=self.env.device)
            push[:, 0] = push_vel  # x 方向 push
            self.env.scene.robot.write_root_velocity_to_sim(push)

            # 测量恢复时间
            recovered = False
            for step in range(recovery_steps):
                with torch.inference_mode():
                    actions = self.policy(actor_obs)
                obs_dict, _, terminated, _, _ = self.env.step(actions)
                actor_obs = obs_dict.get("actor", obs_dict.get("policy"))

                if terminated.any():
                    print(f"  push={push_vel:.1f} m/s: FELL at step {step}")
                    break

                # 检查是否恢复(速度回到命令附近)
                base_vel = self.env.scene.robot.data.root_lin_vel_b[:, :2]
                vel_error = base_vel.norm(dim=1).mean().item()
                if vel_error < 0.3 and step > 10:
                    print(f"  push={push_vel:.1f} m/s: recovered at step {step} "
                          f"({step * 0.02:.2f}s)")
                    recovered = True
                    break

            if not recovered and step == recovery_steps - 1:
                print(f"  push={push_vel:.1f} m/s: did not recover in {recovery_steps} steps")

    def generate_report(self):
        """生成完整的鲁棒性评估报告。"""
        report = []
        report.append("DR Robustness Evaluation Report")
        report.append("=" * 60)

        for param_name, values_dict in self.results.items():
            report.append(f"\n--- {param_name} ---")
            for value, metrics in sorted(values_dict.items()):
                status = "✅" if metrics['fall_rate'] < 0.1 else "⚠️" if metrics['fall_rate'] < 0.3 else "🔴"
                report.append(f"  {status} {value:.3f}: "
                            f"tracking={metrics['tracking']:.3f}, "
                            f"fall_rate={metrics['fall_rate']:.0%}")

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

# 使用示例
# evaluator = DRRobustnessEvaluator(env, trained_policy)
# evaluator.evaluate_friction_grid()
# evaluator.evaluate_mass_grid()
# evaluator.evaluate_push_recovery([0.5, 1.0, 1.5, 2.0, 3.0])
# evaluator.generate_report()

评估的三个层次 ⭐⭐

层次 含义 测试方法 判据
分布内 训练 DR 范围内 在范围内均匀采样网格 tracking > 0.6, fall < 10%
边界 DR 范围的极值 专门测试最低/最高值 tracking > 0.4, fall < 20%
分布外 超出 DR 范围 ±20% 测试范围外参数 平缓退化而非突然崩溃

分布外测试为什么重要? 真实世界的参数不保证落在你的 DR 范围内。了解策略在分布外的退化行为(是平缓下降还是突然崩溃)对部署决策非常有价值。如果策略在摩擦 0.5 处表现良好但在 0.45 处突然摔倒,说明策略没有学到真正的鲁棒性——它只是记住了 [0.5, 1.25] 范围内的最优行为。

DR 效果的 A/B 对比脚本 ⭐⭐

# dr_ab_test.py — 对比有/无 DR 策略的鲁棒性
def compare_dr_policies(env, policy_with_dr, policy_no_dr, 
                         param_name, param_values):
    """A/B 对比有/无 DR 策略在不同参数下的表现。"""
    eval_dr = DRRobustnessEvaluator(env, policy_with_dr)
    eval_no_dr = DRRobustnessEvaluator(env, policy_no_dr)

    results_dr = eval_dr.evaluate_single_param(param_name, param_values)
    results_no_dr = eval_no_dr.evaluate_single_param(param_name, param_values)

    print(f"\n{'='*60}")
    print(f"A/B Comparison: {param_name}")
    print(f"{'='*60}")
    print(f"{'Value':>8s} | {'No DR tracking':>15s} | {'DR tracking':>12s} | {'Δ':>8s}")
    print(f"{'-'*50}")

    for value in param_values:
        t_no_dr = results_no_dr.get(value, {}).get('tracking', 0)
        t_dr = results_dr.get(value, {}).get('tracking', 0)
        delta = t_dr - t_no_dr
        indicator = "↑" if delta > 0.05 else "↓" if delta < -0.05 else "≈"
        print(f"  {value:6.2f} | {t_no_dr:13.3f} | {t_dr:10.3f} | {delta:+6.3f} {indicator}")

DR 与 Curriculum 的交互分析 ⭐⭐

DR 和 Curriculum Learning(Ch06)都会改变训练任务——它们可能互相干扰。

交互模式 效果 建议
DR 范围突然扩大 + Curriculum 同时升级 双重难度跳变,策略可能崩溃 不要同时扩大 DR 和 Curriculum
DR 范围固定 + Curriculum 逐步升级 正常:在固定参数分布上逐步学更难任务 推荐默认组合
DR 逐步扩大 + Curriculum 固定 正常:在固定任务上逐步适应更宽参数 适合分阶段 DR
低摩擦 DR + 粗糙地形 Curriculum 极度困难:低摩擦 + 粗糙地形几乎不可学 先学地形再加摩擦 DR

本质洞察:DR 和 Curriculum 都是在改变训练数据的分布——但 DR 改变的是物理参数分布(让策略更鲁棒),Curriculum 改变的是任务难度分布(让策略更有能力)。它们是正交的维度,不应该同时大幅变化。类似于健身训练中不应该同时增加重量和增加组数——每次只改变一个训练变量才能评估效果。


8.8 ASAP Delta Action Model 精读 ⭐⭐⭐

这一节解决什么问题:当传统 DR 不够时的替代方案——用真机数据学习一个残差模型来修正 sim-to-real gap。

动机:DR 的局限性 ⭐⭐

DR 通过扩大训练分布来覆盖真实参数,但这有两个根本限制:(1) 你必须知道哪些参数需要随机化——如果真实世界的不确定性来源不在你的随机化列表中(如传动间隙、电机非线性、接触模型差异),DR 无法覆盖;(2) 过大的 DR 范围导致策略过于保守——ASAP 论文明确指出 DR 是 agile motion 的瓶颈。

ASAP 五阶段管线 ⭐⭐⭐

Stage A: Pretrain(仿真中训练 base policy)
  └─ 使用 DR 在 Isaac Gym / mjlab 中训练 motion tracking policy
     DR 参数:标准 locomotion DR(摩擦、质量、PD 增益)

Stage B: Real Rollout(真机采集数据)
  └─ 部署 Stage A 策略到 Unitree G1
     使用 MoCap 系统记录 (s_t, a_t, s_{t+1})_real

Stage C: Train δ(训练 delta action model)
  └─ 在仿真中 replay 相同的 a_t
     最小化 ||s_{t+1}^sim - s_{t+1}^real||
     得到 δ(s, a) = a_corrected - a_original

Stage D: Finetune(用 delta 修正仿真器后继续 RL 训练)
  └─ 冻结 δ,在仿真中插入 a_effective = a_policy + δ(s, a)
     用 PPO 继续训练(仿真器现在更接近真实世界)

Stage E: Deploy(直接部署,不需要 δ)
  └─ 只部署 Stage D 的策略,不部署 delta model
     因为 delta 已经通过 finetune 内化到策略中

delta model 的网络结构和训练非常简单——关键是数据采集和对齐。

# ASAP delta action model 的核心思想(概念示意,非官方实现——官方用 open-loop RL/PPO,详见本节末尾说明)
import torch
import torch.nn as nn

class DeltaActionModel(nn.Module):
    """学习仿真和真实世界之间的 action 修正。

    输入:state + action 拼接
    输出:action 的修正量 δa
    """
    def __init__(self, state_dim: int, action_dim: int, hidden_dims=(256, 128)):
        super().__init__()
        input_dim = state_dim + action_dim
        layers = []
        prev_dim = input_dim
        for h in hidden_dims:
            layers.extend([nn.Linear(prev_dim, h), nn.ELU()])
            prev_dim = h
        layers.append(nn.Linear(prev_dim, action_dim))
        self.mlp = nn.Sequential(*layers)

    def forward(self, state, action):
        """返回 action 修正量。"""
        x = torch.cat([state, action], dim=-1)
        return self.mlp(x)

class DeltaTrainer:
    """训练 delta action model 的管线。"""

    def __init__(self, delta_model, sim_env, lr=1e-3):
        self.delta = delta_model
        self.sim = sim_env
        self.optimizer = torch.optim.Adam(delta_model.parameters(), lr=lr)

    def train_from_real_data(self, real_trajectories, num_epochs=100, batch_size=256):
        """从真机采集的轨迹数据训练 delta model。

        Args:
            real_trajectories: list of (s_t, a_t, s_{t+1}_real) tuples
        """
        # 把真机数据转换为 tensor
        states = torch.stack([t[0] for t in real_trajectories])
        actions = torch.stack([t[1] for t in real_trajectories])
        next_states_real = torch.stack([t[2] for t in real_trajectories])

        for epoch in range(num_epochs):
            # 随机打乱
            indices = torch.randperm(len(states))
            total_loss = 0.0

            for start in range(0, len(states), batch_size):
                idx = indices[start:start + batch_size]
                s = states[idx]
                a = actions[idx]
                s_next_real = next_states_real[idx]

                # 计算 delta
                delta_a = self.delta(s, a)

                # 在仿真中 replay a + delta_a
                corrected_action = a + delta_a
                s_next_sim = self.sim.simulate_step(s, corrected_action)

                # Loss: 让仿真的 next state 匹配真实的 next state
                loss = nn.functional.mse_loss(s_next_sim, s_next_real)

                self.optimizer.zero_grad()
                loss.backward()
                self.optimizer.step()
                total_loss += loss.item()

            if (epoch + 1) % 10 == 0:
                avg_loss = total_loss / (len(states) / batch_size)
                print(f"Epoch {epoch+1}: loss={avg_loss:.6f}")

    def create_corrected_env(self):
        """创建用 delta 修正后的仿真环境(Stage D 用)。"""
        # 在 env.step() 中插入 delta correction
        # a_effective = a_policy + delta(s, a_policy)
        pass

# Stage C 的工作流
# delta = DeltaActionModel(state_dim=48, action_dim=12)
# trainer = DeltaTrainer(delta, sim_env)
# trainer.train_from_real_data(real_data, num_epochs=200)
# torch.save(delta.state_dict(), "delta_model.pt")

ASAP 代码仓库结构LeCAR-Lab/ASAP,截至 2026-06 核对官方仓库):

LeCAR-Lab/ASAP/
├── humanoidverse/         # 核心:sim-agnostic 训练框架(基于 HumanoidVerse)
│   ├── train_agent.py     # 统一训练入口(含 motion tracking 与 delta action 训练)
│   ├── envs/              # 环境定义(如 motion_tracking/)
│   ├── agents/            # RL agent(PPO 等)
│   └── config/            # 各阶段 Hydra 配置(如 algo/ppo_train_delta_a.yaml)
├── isaac_utils/           # Isaac 仿真工具
├── sim2real/
│   └── rl_policy/
│       └── listener_deltaa.py  # 真机部署/数据采集
└── scripts/               # 数据处理与可视化脚本

⚠️ 注意:官方仓库没有根目录 asap/,也没有 delta_model.py/train_delta.py 这两个文件——delta action model 复用同一个 humanoidverse/train_agent.py 入口、靠 +exp=... 配置切换。delta model 的训练方式是 open-loop RL(PPO)(reward 最小化 sim/real 状态误差并带 action norm 惩罚),随后再 closed-loop 微调 policy;它不是对仿真 step 直接反传的监督式 MSE。本节上面的 DeltaTrainer/simulate_step 代码仅为概念示意,不对应官方实现,ASAP 也不使用 mjlab(支持 IsaacGym/IsaacSim/Genesis)。

与 DR 的互补关系 ⭐⭐

ASAP 和 DR 不是替代关系——它们互补:

方法 需要真机数据? 策略保守性 适用场景
只用 DR 中-高 大多数 locomotion 任务
DR + System ID 少量(参数辨识) 低-中 需要精确跟踪
DR + ASAP delta 中等(轨迹采集) agile motion, 高精度
只用 delta(无 DR) 大量 低但泛化差 不推荐

对大多数项目来说,只用 DR 就够了(Ch23 的 sim-to-real 案例)。只有在追求 agile 动作(如跳跃、翻滚、快速转向)时,DR 的保守性才成为瓶颈——此时考虑 ASAP。

最新替代方案:SPI-Active ⭐⭐

SPI-Active(Sobanbabu et al., arXiv 2505.14266, 2025)提出了另一种思路:不是用 delta model 修正仿真器,而是用大规模并行采样进行系统辨识——直接找到最接近真实世界的仿真参数,然后在这些精确参数上训练。SPI-Active 在 Unitree Go2 和 G1 上展示了比 DR 更精确的 velocity tracking 和 attitude tracking。这种方法需要的真机数据比 ASAP 更少,但需要能在仿真中高速并行搜索参数——恰好是 mjlab / Isaac Lab + GPU 仿真的优势场景。

⚠️ 常见陷阱

🧠 思维陷阱:认为"有了 delta model 就不需要 DR 了"。 delta model 修正的是 DR 无法覆盖的系统性偏差,二者通常互补:base policy 若完全不做任何鲁棒化,可能在真机上表现太差,连采集有意义的 real rollout 数据都做不到。(注意:ASAP 公开代码的训练命令默认 NO_domain_rand,因此"Stage A 一定用 DR"是配置相关的,不要当成论文硬性要求。)

练习

  1. [分析题] ASAP 的 Stage E 为什么不部署 delta model?如果部署了会有什么问题?
  2. [设计题] 如果你没有 MoCap 系统,如何修改 ASAP 的 Stage B?有哪些替代方案?


8.9 观测噪声与延迟随机化 ⭐⭐

这一节解决什么问题:除物理参数外,传感器噪声和通信延迟也是 sim-to-real gap 的重要来源。

观测噪声配置 ⭐⭐

真实传感器有噪声——IMU 有漂移、编码器有量化误差。如果策略只在 clean observation 上训练,部署时遇到噪声可能行为失控。mjlab 和 Isaac Lab 的 ObservationManager 都支持 per-term noise injection:

# ============= mjlab obs noise 配置 =============
observations = {
    "actor": {
        "joint_pos_rel": ObservationTermCfg(
            func=mdp.joint_pos_rel,
            noise=GaussianNoiseCfg(mean=0.0, std=0.01),  # ±0.01 rad
        ),
        "joint_vel": ObservationTermCfg(
            func=mdp.joint_vel,
            noise=GaussianNoiseCfg(mean=0.0, std=0.05),  # ±0.05 rad/s
        ),
        "base_ang_vel": ObservationTermCfg(
            func=mdp.base_ang_vel,
            noise=GaussianNoiseCfg(mean=0.0, std=0.02),
        ),
        "projected_gravity": ObservationTermCfg(
            func=mdp.projected_gravity,
            noise=GaussianNoiseCfg(mean=0.0, std=0.01),
        ),
    },
}
# ============= Isaac Lab obs noise 配置 =============
@configclass
class ObservationsCfg:
    @configclass
    class PolicyCfg:
        joint_pos = ObsTerm(
            func=mdp.joint_pos_rel,
            noise=GaussianNoise(mean=0.0, std=0.01),
        )
        joint_vel = ObsTerm(
            func=mdp.joint_vel,
            noise=GaussianNoise(mean=0.0, std=0.05),
        )
        base_ang_vel = ObsTerm(
            func=mdp.base_ang_vel,
            noise=GaussianNoise(mean=0.0, std=0.02),
        )

噪声标准差参考值

传感器 典型噪声 推荐 std 来源
关节编码器 ±0.5° 0.01 rad Unitree Go1 spec
关节速度(数值微分) ±2-5% 0.05 rad/s 经验值
IMU 角速度 ±0.5-2°/s 0.02 rad/s MPU-6050 spec
IMU 加速度 ±0.05 m/s² 0.05 m/s² 经验值
projected gravity ±1-3% 0.01 从 IMU 派生
力传感器 ±2-5% 按比例 传感器 spec

关键工程点:obs noise 只加在 actor obs 上——critic 保持 clean observation。这利用了 Ch05 中 asymmetric actor-critic 的设计——critic 看到的 clean obs 让 value function 更准确,而 actor 被迫学会在噪声条件下做出正确决策。

观测延迟模拟 ⭐⭐

真实控制系统的传感器有不同延迟——关节编码器 ~1ms,IMU ~3ms,深度相机 ~33ms。延迟意味着策略收到的不是当前状态,而是过去某一步的状态。

# mjlab obs delay 配置
observations = {
    "actor": {
        "joint_pos_rel": ObservationTermCfg(
            func=mdp.joint_pos_rel,
            noise=GaussianNoiseCfg(mean=0.0, std=0.01),
            delay=UniformIntDelayCfg(min_steps=0, max_steps=2),  # 0-2 步延迟
        ),
        "depth_image": ObservationTermCfg(
            func=mdp.depth_camera,
            delay=UniformIntDelayCfg(min_steps=1, max_steps=3),  # 相机延迟更大
        ),
    },
}

延迟通过 history buffer 实现——ObservationManager 维护每个 term 的最近 N 步值,delay 从 buffer 中选取偏移。延迟步数在每个 episode reset 时重采样(reset 模式语义)。

编码器偏置——固定偏移量 ⭐⭐

装配误差导致的编码器偏置不是每步随机噪声,而是每 episode 固定的偏移:

# 编码器偏置:reset 模式
events = {
    "encoder_bias": EventTermCfg(
        func=mdp.randomize_joint_position_bias,
        mode="reset",
        params={"bias_range": (-0.02, 0.02)},  # ±0.02 rad ≈ ±1.1°
    ),
}

这个偏置在整个 episode 内保持不变——策略需要学会在关节角度有系统性偏差的情况下也能正确控制。它与 obs noise(每步随机)和 obs delay(时间偏移)是三个正交的 DR 维度。

Action 延迟模拟 ⭐⭐

策略输出到电机执行之间的通信延迟(1-5 ms)可以通过 action delay buffer 模拟:

# Action 延迟模拟
class ActionDelayBuffer:
    """在 env 和 wrapper 之间插入 action 延迟。"""
    def __init__(self, env, max_delay_steps=2):
        self.env = env
        self.max_delay = max_delay_steps
        self.buffer = []

    def step(self, actions):
        self.buffer.append(actions.clone())
        # 当前使用的 action 是 N 步前的
        if len(self.buffer) > self.max_delay:
            delayed_actions = self.buffer.pop(0)
        else:
            delayed_actions = torch.zeros_like(actions)
        return self.env.step(delayed_actions)

    def reset(self):
        self.buffer.clear()
        return self.env.reset()

⚠️ 常见陷阱

⚠️ 编程陷阱:obs noise 加在 critic obs 上导致 value loss 不降。 critic 需要 clean 信号来准确估计 value。给 critic 加噪声等于给 PPO 的 value target 加噪声——advantage 质量下降,训练不稳定。

💡 概念误区:认为"obs noise 越大策略越鲁棒"。 过大的 noise 会让策略无法区分信号和噪声——它可能学到"忽略所有 obs 输入"的行为。noise std 应该反映真实传感器的实际噪声水平。

练习

  1. [设计题] 人形机器人的头部 RGB 相机帧率 30 FPS,控制频率 50 Hz。延迟应该是多少个 control step?
  2. [编程题] 在 mjlab 中配置 obs noise,训练 200 iteration。对比有/无 noise 的 tracking reward 和 action std。

8.10 自定义 DR Event 的编写模式 ⭐⭐

这一节解决什么问题:内置 DR 函数不够用时,如何编写自定义 event?

mjlab 自定义 event 示例 ⭐⭐

# 自定义 DR event:模拟随机负载
# src/my_task/mdp/events.py
import torch

def random_payload(
    env, asset_cfg, 
    payload_mass_range=(0.0, 3.0),
    com_offset_range=(-0.05, 0.05),
):
    """在 base 上模拟随机负载。"""
    robot = env.scene[asset_cfg.name]
    num_envs = env.num_envs

    # 采样负载质量
    payload = torch.rand(num_envs, device=env.device) \
        * (payload_mass_range[1] - payload_mass_range[0]) \
        + payload_mass_range[0]

    # 获取当前 base 质量并添加负载
    base_mass = env.sim.model.body_mass[:, 0]  # per-env
    env.sim.model.body_mass[:, 0] = base_mass + payload  # 原地写入

    # 采样质心偏移
    com_offset = torch.rand(num_envs, 3, device=env.device) \
        * (com_offset_range[1] - com_offset_range[0]) \
        + com_offset_range[0]
    env.sim.model.body_ipos[:, 0, :3] += com_offset

    # 重算派生常量
    env.sim.recompute_constants()

# 在 cfg 中注册
events = {
    "payload": EventTermCfg(
        func=random_payload,
        mode="reset",
        requires_model_fields=["body_mass", "body_ipos"],
        params={
            "asset_cfg": SceneEntityCfg("robot"),
            "payload_mass_range": (0.0, 3.0),
            "com_offset_range": (-0.05, 0.05),
        },
    ),
}

Isaac Lab 自定义 event ⭐⭐

# Isaac Lab 自定义 event
def random_payload(
    env: ManagerBasedRLEnv,
    asset_cfg: SceneEntityCfg,
    payload_mass_range: tuple[float, float],
):
    """模拟随机负载。"""
    asset = env.scene[asset_cfg.name]
    num_envs = env.num_envs

    payload = torch.rand(num_envs, device=env.device) \
        * (payload_mass_range[1] - payload_mass_range[0]) \
        + payload_mass_range[0]

    body_masses = asset.root_physx_view.get_body_masses()
    body_masses[:, 0] += payload
    asset.root_physx_view.set_body_masses(body_masses)

# 注册
random_payload_event = EventTerm(
    func=random_payload,
    mode="reset",
    params={
        "asset_cfg": SceneEntityCfg("robot"),
        "payload_mass_range": (0.0, 3.0),
    },
)

编写自定义 event 的三条规则 ⭐⭐

规则一:event 只修改物理参数,不修改 obs/reward/done。DR 的语义是"改变环境"。

规则二:如果修改了 body_mass 等派生量源字段,必须调用 recompute_constants()

规则三:在 mjlab 中,声明 requires_model_fields 让 EventManager 自动扩展字段。否则写入 per-env 数组时会因为字段未扩展而报错或影响所有 env。

DR 事件的测试方法 ⭐⭐

# 测试自定义 DR event
def test_dr_event(env, event_name, num_tests=5):
    """验证 DR event 是否正确修改了不同 env 的参数。"""
    # 记录修改前的值
    before = env.sim.model.body_mass[:num_tests, 0].clone()

    # 触发 reset(会调用 reset 模式的事件)
    env.reset()

    # 记录修改后的值
    after = env.sim.model.body_mass[:num_tests, 0].clone()

    print(f"DR Event '{event_name}' test:")
    for i in range(num_tests):
        changed = "✅ CHANGED" if before[i] != after[i] else "⚠️ SAME"
        print(f"  env[{i}]: {before[i]:.4f} → {after[i]:.4f} {changed}")

⚠️ 常见陷阱

⚠️ 编程陷阱:自定义 event 中使用 field = new_tensor(替换引用)而非 field[:] = new_tensor(原地写入)。 替换引用会导致 CUDA Graph 失效。始终使用索引赋值。


8.11 DR 综合诊断工具箱 ⭐⭐

这一节解决什么问题:提供一站式 DR 诊断脚本。

DR 健康检查脚本 ⭐⭐

# dr_health_check.py — 一站式 DR 诊断
import torch

def dr_health_check(env, num_checks=5):
    """综合诊断 DR 配置是否正确。"""
    print("=" * 60)
    print("  DR Health Check")
    print("=" * 60)

    # Check 1: 字段扩展
    expanded = getattr(env.sim, '_expanded_fields', set())
    print(f"\n[1] Expanded fields: {len(expanded)}")
    for field in sorted(expanded):
        shape = getattr(env.sim.model, field).shape
        print(f"    {field}: shape={list(shape)}")
    if not expanded:
        print("    ⚠️ No fields expanded — DR may not be active!")

    # Check 2: per-env 参数差异
    print(f"\n[2] Per-env parameter variation:")
    for field in list(expanded)[:5]:
        data = getattr(env.sim.model, field)
        if data.ndim >= 2 and data.shape[0] >= 2:
            std_across_envs = data.float().std(dim=0).mean().item()
            print(f"    {field}: cross-env std={std_across_envs:.6f}")
            if std_across_envs < 1e-8:
                print(f"      ⚠️ Zero variation — DR values may be identical!")

    # Check 3: event mode 分布
    print(f"\n[3] Event mode distribution:")
    em = env.event_manager
    mode_counts = {}
    for name, term in em.active_terms.items():
        mode = term.cfg.mode if hasattr(term.cfg, 'mode') else 'unknown'
        mode_counts[mode] = mode_counts.get(mode, 0) + 1
        print(f"    [{mode:>8s}] {name}")
    print(f"    Totals: {mode_counts}")

    # Check 4: entity pattern 匹配
    print(f"\n[4] Entity pattern matching:")
    for name, term in em.active_terms.items():
        cfg = term.cfg
        if hasattr(cfg, 'params') and 'asset_cfg' in cfg.params:
            asset_cfg = cfg.params['asset_cfg']
            if hasattr(asset_cfg, 'body_names') and asset_cfg.body_names:
                import re
                robot = env.scene[asset_cfg.name]
                pattern = asset_cfg.body_names
                matched = [n for n in robot.body_names 
                          if re.match(pattern, n)]
                status = "✅" if matched else "⚠️ EMPTY"
                print(f"    {name}: '{pattern}' → {len(matched)} matches {status}")

    # Check 5: 性能影响估算
    print(f"\n[5] Performance impact estimate:")
    set_const_events = []
    for name, term in em.active_terms.items():
        # 检查事件是否触发 set_const 重算
        # 这里简化为检查字段名
        if hasattr(term.cfg, 'params'):
            for key in term.cfg.params:
                if 'mass' in str(key) or 'inertia' in str(key):
                    mode = term.cfg.mode if hasattr(term.cfg, 'mode') else '?'
                    if mode in ('step', 'interval'):
                        set_const_events.append((name, mode))

    if set_const_events:
        print(f"    ⚠️ High-recompute events in frequent modes:")
        for name, mode in set_const_events:
            print(f"      {name} in mode '{mode}' — consider moving to startup/reset")
    else:
        print(f"    ✅ No high-recompute events in frequent modes")

    print(f"\n{'=' * 60}")
    print("  DR Health Check Complete")
    print(f"{'=' * 60}")

# 使用:训练前运行
# dr_health_check(env)

三档鲁棒性评估脚本 ⭐⭐⭐

# dr_robustness_eval.py — 三档参数评估
import torch

def evaluate_robustness_3level(env, policy, param_configs):
    """在低/中/高三档参数下评估策略鲁棒性。

    Args:
        env: 环境
        policy: 推理策略
        param_configs: dict 如 {
            "low_friction": {"geom_friction": 0.3},
            "nominal": {"geom_friction": 0.7},
            "high_friction": {"geom_friction": 1.5},
        }
    """
    results = {}

    for config_name, params in param_configs.items():
        print(f"\n--- Evaluating: {config_name} ---")

        # 设置固定参数
        for field, value in params.items():
            field_data = getattr(env.sim.model, field)
            if field_data.ndim >= 2:
                field_data[:] = value  # 所有 env 设为相同值

        # 运行 10 个 episode
        ep_rewards = []
        ep_lengths = []
        fell_count = 0
        total_eps = 0

        obs = env.reset()["actor"]
        for step in range(5000):
            with torch.no_grad():
                actions = policy(obs)
            obs_dict, rewards, terminated, truncated, extras = env.step(actions)
            obs = obs_dict["actor"]

            done = terminated | truncated
            if done.any():
                total_eps += done.sum().item()
                fell_count += terminated.sum().item()

        fell_rate = fell_count / max(total_eps, 1)

        results[config_name] = {
            "total_episodes": total_eps,
            "fell_rate": fell_rate,
        }

        status = "✅" if fell_rate < 0.2 else "⚠️" if fell_rate < 0.5 else "🔴"
        print(f"  Episodes: {total_eps}, Fell rate: {fell_rate:.1%} {status}")

    # 总结
    print(f"\n{'=' * 50}")
    print("Robustness Summary:")
    for name, r in results.items():
        print(f"  {name}: fell_rate={r['fell_rate']:.1%}")

    return results

# 使用示例
# configs = {
#     "low_friction": {"geom_friction": torch.tensor([0.3, 0.005, 0.001])},
#     "nominal":      {"geom_friction": torch.tensor([0.7, 0.005, 0.001])},
#     "high_friction": {"geom_friction": torch.tensor([1.5, 0.005, 0.001])},
# }
# evaluate_robustness_3level(env, policy, configs)

DR 对比实验脚本 ⭐⭐

# DR ablation 实验
export LOG_DIR="/tmp/mjlab/dr_ablation"

# 1. No DR baseline
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 500 \
  --agent.run-name no_dr --agent.seed 42 \
  --agent.logger tensorboard --agent.log-dir $LOG_DIR
  # 需要注释掉 events 配置中的所有 DR

# 2. 只有 friction DR
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 500 \
  --agent.run-name friction_only --agent.seed 42 \
  --agent.logger tensorboard --agent.log-dir $LOG_DIR

# 3. 完整 DR
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 500 \
  --agent.run-name full_dr --agent.seed 42 \
  --agent.logger tensorboard --agent.log-dir $LOG_DIR

# 4. TensorBoard 对比
tensorboard --logdir $LOG_DIR

8.12 源码阅读路线 ⭐⭐

路线 A:mjlab DR 主链

  1. src/mjlab/tasks/velocity/velocity_env_cfg.py → events 配置
  2. src/mjlab/managers/event_manager.py → 四种模式调度
  3. src/mjlab/envs/mdp/events.py → push_by_setting_velocity, apply_body_impulse
  4. src/mjlab/envs/mdp/dr/_core.py_randomize_model_field()
  5. src/mjlab/envs/mdp/dr/body.py → pseudo_inertia
  6. src/mjlab/sim/randomization.py → expand_model_fields, CUDA Graph 重 capture

路线 A 的核心阅读目标:理解从 task cfg 中的 events dict 到物理仿真中 per-env 参数变化的完整链路。每一层做了什么转换?字段在哪里被扩展?CUDA Graph 在哪里被重新 capture?

# 在源码中验证 DR 链路的诊断脚本
def trace_dr_pipeline(env, event_name="foot_friction"):
    """追踪一个 DR 事件从配置到物理生效的完整链路。"""
    em = env.event_manager

    # Layer 1: 配置层
    print("Layer 1: Configuration")
    for mode, terms in em._mode_term_cfgs.items():
        for name, (func, cfg) in terms.items():
            if event_name in name:
                print(f"  Found '{name}' in mode '{mode}'")
                print(f"  Func: {func.func.__name__}")
                if hasattr(cfg, 'params'):
                    print(f"  Params: {cfg.params}")

    # Layer 2: 管理层
    print("\nLayer 2: Management")
    print(f"  EventManager has {sum(len(t) for t in em._mode_term_cfgs.values())} total terms")

    # Layer 3: 字段层
    print("\nLayer 3: Field expansion")
    if hasattr(env.sim, 'expanded_fields'):
        print(f"  Expanded fields: {env.sim.expanded_fields}")

    # Layer 4: 仿真层
    print("\nLayer 4: Simulation")
    if hasattr(env.sim, 'model'):
        model = env.sim.model
        if hasattr(model, 'geom_friction'):
            shape = model.geom_friction.shape
            print(f"  geom_friction shape: {shape}")
            is_per_env = shape[0] == env.num_envs if len(shape) >= 2 else False
            print(f"  Per-env: {is_per_env}")

路线 B:Isaac Lab DR 对等路径

  1. source/isaaclab_tasks/.../velocity/velocity_env_cfg.py → EventCfg
  2. source/isaaclab/managers/event_manager.py → 五种模式调度(含 prestartup)
  3. source/isaaclab/envs/mdp/events.py → DR 函数库
  4. 对比 num_buckets 参数(PhysX 特有)和 randomize_rigid_body_material 的实现

Isaac Lab 的 DR 函数库比 mjlab 更完整(Isaac Lab 积累了更长时间的社区贡献)。但 mjlab 的 pseudo-inertia 支持是独有优势——Isaac Lab 要实现等价功能需要手动编写。

路线 C:ASAP delta model

  1. LeCAR-Lab/ASAP/humanoidverse/train_agent.py → 统一训练入口(motion tracking 与 delta action)
  2. LeCAR-Lab/ASAP/humanoidverse/envs/motion_tracking/ → motion tracking 环境
  3. LeCAR-Lab/ASAP/humanoidverse/config/algo/ppo_train_delta_a.yaml → delta action 的 PPO 配置(+exp= 切换)
  4. LeCAR-Lab/ASAP/sim2real/rl_policy/listener_deltaa.py → 真机部署/数据采集

ASAP 的代码结构值得阅读——它展示了如何把 delta action model 接入现有 RL 训练管线。注意 delta model 通过 open-loop RL(PPO)训练、再 closed-loop 微调 policy,复用统一的 train_agent.py 入口,而不是独立的 delta_model.py/train_delta.py

路线 D:对比不同 DR 策略的论文实现

  1. extreme-parkour(Cheng et al., ICRA 2024):aggressive friction DR + terrain curriculum
  2. walk-these-ways(Margolis & Agrawal, RSS 2023):per-gait DR 配置
  3. TienKung-Lab(Open-X-Humanoid):humanoid-specific DR + AMP reward 交互

阅读原则与 Ch05-Ch07 一致:先 task config → 再 manager → 最后底层实现。


📋 DR 设计审查 Checklist

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

A. 配置完整性

  • [ ] 所有 DR 项的 mode 选择有物理理由
  • [ ] entity pattern 经过匹配验证(非空)
  • [ ] 参数范围反映真实物理不确定性
  • [ ] verify_dr_config() 无警告

B. 物理一致性

  • [ ] 质量/惯量使用 pseudo-inertia(或至少 recompute_inertia=True)
  • [ ] 摩擦范围物理合理(>0)
  • [ ] PD 增益范围不会导致不稳定
  • [ ] 没有在 step 模式中触发 set_const 级别的字段修改

C. 训练效果

  • [ ] Phase 0 clean baseline tracking > 0.7
  • [ ] Phase 4 full DR tracking 下降 < 25%
  • [ ] test_dr_effectiveness() 确认 per-env 参数不同
  • [ ] TensorBoard 中 reward 方差增加在可接受范围

D. 鲁棒性验证

  • [ ] 摩擦网格评估通过(最低摩擦 fall < 30%)
  • [ ] Push recovery 测试通过(1 m/s 后 2 秒内恢复)
  • [ ] 分布外 ±20% 参数下平缓退化

本章小结

知识点 核心要点 难度
DR 鲁棒优化本质 在参数分布上优化期望性能 ⭐⭐
四种事件模式 startup/reset/interval/step 各有物理语义 ⭐⭐⭐
DR 函数映射 双框架 API 对照 + 采样分布选择 ⭐⭐
pseudo-inertia Cholesky 参数化保证物理一致 ⭐⭐⭐
expand_model_fields per-env 存储 + CUDA Graph 重 capture ⭐⭐⭐
外力扰动 push(速度注入) vs impulse(力注入) ⭐⭐
分阶段策略 单变量递进,可归因 ⭐⭐⭐
鲁棒性评估 固定参数网格,分布内+边界+分布外 ⭐⭐
ASAP delta model 用真机数据修正 DR 无法覆盖的偏差 ⭐⭐⭐

本章建立了两个关键认知:

从"固定参数训练"到"参数分布训练"。 DR 不是让仿真更逼真——而是让策略更鲁棒。真实世界的参数只是 DR 分布中的一个点。

从"一次性全开 DR"到"分阶段可归因引入"。 DR 的工程挑战不是"写配置"——而是"诊断问题"。分阶段引入让每个失败都能清晰归因到具体的 DR 项。


累积项目:本章新增模块

本章为累积项目新增"Domain Randomization 工程化"模块。你现在应该能够:

  1. 为 velocity task 配置完整的 DR 方案(在 mjlab 和 Isaac Lab 中)
  2. 执行分阶段 DR 训练并记录各阶段的 tracking 变化
  3. 使用固定参数网格评估策略鲁棒性
  4. 诊断 DR 不生效的常见原因
  5. 理解 pseudo-inertia 参数化的物理动机

累积项目检查点 ⭐⭐

完整验证命令序列:

# ============= 1. Phase 0: Clean baseline =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 300 \
  --agent.run-name ch08_phase0 --agent.seed 42

# 验证 Phase 0
uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
  --agent.load-run ch08_phase0 --num-envs 4 --viewer viser

# ============= 2. Phase 1-4: 分阶段 DR =============
# Phase 1: 动力学 DR
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 500 \
  --agent.run-name ch08_phase1 --agent.seed 42 \
  --agent.resume True --agent.load-run ch08_phase0

# Phase 4: 完整 DR + push
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
  --env.scene.num-envs 4096 --agent.max-iterations 1500 \
  --agent.run-name ch08_phase4 --agent.seed 42 \
  --agent.resume True --agent.load-run ch08_phase1

# ============= 3. 鲁棒性评估 =============
# 在不同摩擦下 play(手动观察)
for FRICTION in 0.3 0.5 0.8 1.0 1.5; do
  echo "Testing friction=$FRICTION"
  uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
    --agent.load-run ch08_phase4 --num-envs 4 \
    --env.events.foot-friction.params.friction-range "($FRICTION, $FRICTION)" &
  sleep 10 && kill %1  # 每个摩擦观察 10 秒
done

# ============= 4. TensorBoard 对比 =============
tensorboard --logdir /tmp/mjlab/logs/
# 对比 ch08_phase0 和 ch08_phase4 的 tracking reward 和 fall rate

Isaac Lab 等价验证:

# Phase 0
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
  --num_envs 4096 --max_iterations 300 --seed 42

# Phase 1-4 需要修改 cfg 文件
# Play 验证
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0
项目 本章对应任务
项目 A:四足速度跟踪 添加 foot friction + base mass + push DR
项目 C:机械臂操作 添加 cube mass/friction DR + fingertip friction
后期动态目标项目 添加球/拍 mass/friction + 风力扰动(Ch20-Ch22)

与前置章节的连接 ⭐

Ch06 → Ch08 的耦合点:DR 改变环境参数分布 → reward 的期望值和方差都会变化 → PPO 的 advantage 估计质量受影响。如果 DR 范围太大,不同参数下的 reward scale 差异可能导致 advantage 估计方差暴增——此时可能需要增大 num_steps_per_env(Ch07)来改善 GAE 估计质量。

Ch07 → Ch08 的耦合点:DR 导致的 reward 方差增大可能触发 adaptive KL schedule 频繁降 LR → 训练变慢。如果 Phase 1 引入 DR 后 LR 大幅下降,检查 reward 分项是否有 DR 相关的 spike。

Ch08 → Ch09 的预告:Teacher-Student 蒸馏将使用 DR 训练的 teacher 策略。teacher 的鲁棒性直接影响 student 的蒸馏质量——如果 teacher 在某些 DR 参数下行为不好,student 从这些不好的行为中学到的也是不好的。

下一章(Ch09)将在 DR 的基础上解决另一个 sim-to-real 问题——部署时缺少 privileged 信息。Teacher-Student 蒸馏将利用 DR 训练的 teacher policy 来训练一个只使用部署可得信号的 student policy。


附录:DR 参数范围参考表

以下是各项目中常用的 DR 参数范围汇总,供配置参考:

四足 Locomotion 标准 DR 范围

参数 legged_gym Isaac Lab ANYmal mjlab Go1 物理理由
foot friction (scale) (0.5, 1.25) (0.7, 1.3) (0.5, 1.25) 不同地面材质
base mass (add, kg) (-1, 3) (-1, 3) (-1, 3) 负载变化
COM x/y/z (add, m) (-0.02, 0.02) (-0.02, 0.02) (-0.02, 0.02) 装配误差
Kp (scale, log_uniform) (0.75, 1.5) (0.75, 1.5) (0.75, 1.5) 电机增益不确定
Kd (scale, log_uniform) (0.3, 3.0) (0.3, 3.0) (0.3, 3.0) 阻尼不确定
push lin vel (m/s) (-1, 1) (-1, 1) (-1, 1) 中等推力
push ang vel (rad/s) (-0.25, 0.25) (-0.25, 0.25) (-0.25, 0.25) 轻度旋转
push interval (s) (10, 15) (10, 15) (10, 15) 给恢复时间
encoder bias (rad) (-0.05, 0.05) N/A (-0.05, 0.05) 传感器零点漂移
restitution (0.0, 0.5) (0.0, 0.5) (0.0, 0.5) 接触弹性

Manipulation 标准 DR 范围

参数 范围 物理理由
object mass (scale) (0.5, 2.0) 不同物体重量
object friction (scale) (0.5, 1.5) 不同表面材质
fingertip friction (scale) (0.3, 2.0) 指腹磨损/污染
table height (add, m) (-0.02, 0.02) 放置误差
object initial pose 工作空间内随机 泛化到不同位置

人形 Locomotion 额外 DR 范围

参数 范围 物理理由
trunk mass (add, kg) (-2, 5) 背包/工具负载
ankle friction (scale) (0.5, 1.5) 鞋底材质差异
gravity tilt (deg) (-5, 5) 斜坡近似
motor strength (scale) (0.8, 1.2) 电机老化/温度影响
link length (scale) (0.98, 1.02) 制造公差

DR 范围选择的三条原则

原则一:从物理不确定性出发。 查阅机器人的工程规格书,获得制造公差、传感器精度、负载范围等信息。DR 范围应反映这些真实的不确定性。

原则二:从小范围开始逐步扩大。 先用 ±10% 的范围训练,确认策略稳定后扩大到 ±30%。一次性设太大的范围可能导致无法训练。

原则三:关注 training tracking 的退化幅度。 引入 DR 后 tracking reward 下降是正常的(策略需要在更多参数上平均优化)。但如果下降超过 25%,说明范围可能太大或需要更多训练 iteration。


附录:DR 与 PPO 的数值交互分析

DR 不仅改变了任务——它还改变了 PPO 看到的数据分布。理解这种交互对正确配置训练超参数至关重要。

Reward 方差增大 ⭐⭐

无 DR 时,同一个策略在所有 4096 个环境中面对相同的物理参数——reward 的方差主要来自初始状态和 action 噪声。加 DR 后,不同环境有不同的摩擦、质量、PD 增益——reward 的方差增大,因为策略在低摩擦环境中的表现可能比高摩擦环境差很多。

方差增大对 GAE 的影响:GAE 使用 \(\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)\) 作为 TD error。如果 reward 方差增大,\(\delta_t\) 的方差也增大 → advantage 估计的噪声增大 → 策略梯度的方差增大 → 训练可能不稳定。

缓解方法:(1) 增大 num_steps_per_env(更长的 rollout 让 GAE 有更多步来平均噪声);(2) 增大 num_envs(更多环境 → 更大 batch → 梯度方差更小);(3) 使用 advantage normalization(RSL-RL 已默认启用)。

# 监控 DR 导致的 reward 方差变化
def monitor_reward_variance(env, policy, num_steps=100):
    """对比有/无 DR 时的 reward 方差。"""
    obs = env.reset()
    actor_obs = obs.get("actor", obs.get("policy"))

    rewards_per_env = torch.zeros(env.num_envs, num_steps, device=env.device)

    for step in range(num_steps):
        with torch.inference_mode():
            actions = policy(actor_obs)
        obs_dict, rewards, _, _, _ = env.step(actions)
        actor_obs = obs_dict.get("actor", obs_dict.get("policy"))
        rewards_per_env[:, step] = rewards

    # 计算 per-env 的 episode reward
    env_rewards = rewards_per_env.sum(dim=1)  # [num_envs]

    mean_reward = env_rewards.mean().item()
    std_reward = env_rewards.std().item()
    cv = std_reward / abs(mean_reward) if abs(mean_reward) > 1e-8 else float('inf')

    print(f"Reward statistics ({num_steps} steps):")
    print(f"  Mean: {mean_reward:.3f}")
    print(f"  Std:  {std_reward:.3f}")
    print(f"  CV:   {cv:.2f}")

    if cv > 0.5:
        print(f"  ⚠️ High coefficient of variation — DR may be causing too much reward variance")
        print(f"  → Consider: narrowing DR ranges, increasing num_steps_per_env, or increasing num_envs")
    else:
        print(f"  ✅ Reward variance acceptable")

# 使用:在 Phase 0 和 Phase 4 后分别运行,对比 CV 变化

Value Function 学习难度增大 ⭐⭐

DR 让 value function 的学习更难——因为 critic 需要在不同物理参数下准确估计未来 reward。如果 critic obs 不包含 DR 参数信息(如摩擦系数),critic 在面对同一个 state 但不同摩擦时需要预测一个"平均 value"——这个平均值对任何具体摩擦都不够精确。

这就是为什么 Ch05 的 asymmetric actor-critic 设计在 DR 训练中特别有价值——如果 critic obs 包含当前 DR 参数(作为 privileged information),critic 可以为每个参数下的 state 学到精确的 value。Actor 不需要看到 DR 参数(因为部署时不可得),但 critic 看到它们能让 advantage 估计更准确。

本质洞察:DR 训练中给 critic 提供 DR 参数作为 privileged information,是 DR + asymmetric AC + Teacher-Student 三者协同的关键接口。DR 提供鲁棒性,asymmetric AC 让 critic 在 DR 下更准确,Teacher-Student 把鲁棒性迁移到只用部署可得信号的 student 上。这三者是 sim-to-real 管线的核心三角。


延伸阅读

资料 难度 内容
Tobin et al. 2017, "Domain Randomization for Sim-to-Real" ⭐⭐ 视觉 DR 的开山之作
Peng et al. 2018, "Sim-to-Real with Dynamics Randomization" ⭐⭐ 动力学 DR
OpenAI 2019, "Solving Rubik's Cube" (arXiv 1910.07113) ⭐⭐⭐ ADR + Shadow Hand
Rucker & Wensing 2022, "Smooth Parameterization of Rigid-Body Inertia" ⭐⭐⭐ pseudo-inertia 数学基础
He et al. 2025, "ASAP" (arXiv 2502.01143, RSS 2025) ⭐⭐⭐ delta action model
Sobanbabu et al. 2025, "SPI-Active" (arXiv 2505.14266) ⭐⭐⭐ Sampling-based System ID
Muratore et al. 2022, "Robot Learning from Randomized Simulations" ⭐⭐⭐⭐ DR 理论分析

🔧 故障排查手册

症状 可能原因 排查步骤 相关小节
DR 配置生效但策略行为不变 CUDA Graph 持旧指针 1.打印 expanded_fields 2.检查 graph capture 3.对比 no-DR 训练 8.4
低摩擦下转向打滑 friction 范围未覆盖 1.做三档摩擦评估 2.检查 geom pattern 3.记录 slip velocity 8.7
pseudo_inertia NaN alpha 范围过大 1.收窄 alpha 到 (0.9, 1.1) 2.检查正定性 3.逐步扩大 8.4
push 训练摔倒率 >90% 扰动太早或太强 1.验证 clean baseline 2.减小 push 幅度 3.延后引入 8.5, 8.6
steps/s 下降 >30% 高频 set_const 重算 1.统计 recompute 次数/step 2.检查 event mode 3.移至 reset 8.4
entity pattern 空匹配 正则表达式不匹配 MJCF 名字 1.打印 entity names 2.修正 pattern 3.在小 env 验证 8.3
Isaac Lab material 超限 num_buckets 未设或过大 1.检查 num_buckets 设置 2.设为 250 8.3
DR 后 reward 大幅下降 范围太大 1.逐项收窄范围 2.增加 curriculum 3.检查分项 reward 8.6
仿真中 DR 鲁棒但真机失败 DR 未覆盖真实不确定性源 1.系统辨识 2.考虑 ASAP delta model 3.检查执行器模型 8.8