Skip to content

Ch10 | 模仿学习工程实践:从动作数据到对抗训练

本章定位:Ch09 讨论了如何利用 privileged information 跨越仿真-真机的信息鸿沟。本章讨论一个不同但互补的问题:如何让机器人模仿参考运动——不是只"走到目的地",而是"像人类一样走到目的地"。速度跟踪(Ch04-Ch08)可以让机器人前进、转向和侧移,但它不规定膝盖如何摆动、手臂如何协调、躯干如何倾斜。Motion Imitation 补上了这块拼图。

前置依赖:Ch05(Observation/Action 设计)、Ch07(PPO 训练管线)、Ch09(Privileged Learning)

参考项目:🔧 mjlab tracking(BeyondMimic)· ✅ NVlabs/ProtoMotions · ✅ Open-X-Humanoid/TienKung-Lab


前置自测

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

# 问题 检查目的
1 PPO 的 advantage 估计使用什么方法?GAE 的 λ 参数控制什么? Ch07 PPO 基础
2 两个四元数之间的"旋转距离"如何定义?为什么不能直接做减法? 几何基础——motion imitation 的核心操作
3 指数型奖励 \(r = \exp(-\|e\|^2 / \sigma^2)\) 中 σ 的作用是什么?σ 太小或太大分别有什么问题? Ch06 Reward 设计
4 asymmetric actor-critic 的 actor 和 critic 看到的 observation 有什么不同? Ch09 Privileged Learning
5 .npz 文件格式中,如何存储多个数组?如何读取特定的 key? Python 数据工程基础
6 GAN 中生成器和判别器的对抗训练机制是什么? AMP 的理论基础
7 Domain Randomization(Ch08)和 motion imitation 分别解决策略的什么问题? 理解两者互补性

本章目标

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

  1. 区分 三条模仿学习技术线(显式跟踪 / 对抗风格 / 行为克隆)的适用场景和工程权衡
  2. 处理 动作数据的完整管线:AMASS → SMPL → retarget → .npz / .yaml → mjlab/ProtoMotions
  3. 配置 mjlab 中的 BeyondMimic tracking task,理解 reward 分解和 anchor 机制
  4. 配置 ProtoMotions 中的 AMP/ASE 训练,理解判别器的数据流和稳定性技巧
  5. 实现 BC/DAgger 蒸馏管线,把 teacher tracker 的能力迁移到 deployable student
  6. 诊断 常见失败模式:tracking 漂移、判别器坍塌、retarget 不可达、数据质量问题
  7. 选型 面对新项目时,在 tracking reward / AMP / BC 之间做出合理的工程决策

10.1 算法回顾:三条技术线 ⭐

这一节解决什么问题:用 20% 的篇幅建立全局视角——motion imitation 领域的三条并行技术线,每条解决什么问题、适用什么场景。为后续工程实现做铺垫。

动机:为什么速度命令不够

回顾 Ch04-Ch06:velocity tracking 任务给策略一个期望的线速度和角速度 \((v_x, v_y, \omega_z)\),策略通过关节动作使机器人的基座速度接近命令。这对"让机器人从 A 走到 B"足够了,但它对"怎么走"几乎没有约束——膝盖弯多少、手臂摆多大、躯干前倾多少,都由策略自由决定。

这种自由度在四足机器人上问题不大,因为四条腿的对称结构和接触约束自然限制了"怪异步态"的出现。但在人形机器人上,29 个自由度的全身控制意味着速度命令满足时可能出现各种非自然行为:低头冲刺、手臂乱甩、膝盖内扣。这些行为在速度 reward 看来是"对的"(速度确实跟上了),但在人类观察者看来是"错的"。

Motion Imitation 解决的正是这个问题。它告诉策略"身体的每个关键点应该在哪里、朝什么方向、以什么速度移动"。参考运动(motion clip)提供了一条时间索引的全身状态轨迹,策略的任务是在物理仿真中尽可能接近这条轨迹。

这两种方法的关系类似于音乐中的"自由即兴"和"照谱演奏"。速度跟踪就像给一个音乐家说"弹一首快节奏的曲子"——结果取决于音乐家的个人风格。Motion imitation 就像给他一份乐谱——每个音符的音高、时长和力度都已经规定好了。

如果不做 motion imitation 会怎样

纯速度跟踪训练的人形机器人在真机部署时会出现"uncanny valley"问题:机器人确实能走,但走姿不自然。在实验室环境中这可以接受,但在人机交互场景(服务机器人、物流机器人)中,非自然的运动会降低用户信任度。更重要的是,非自然的运动通常意味着次优的力学效率——人类几百万年进化出的步态是能量效率的局部最优,偏离它通常意味着更高的能耗和更差的稳定性。

三条技术线

Motion imitation 领域在 2018-2025 年间发展出了三条并行的技术路线,理解它们之间的关系是本章的核心。

技术线 A:显式跟踪(Explicit Tracking)

从 DeepMimic (Peng et al., SIGGRAPH 2018) 到 PHC (Luo et al., ICCV 2023) 再到 BeyondMimic (Liao et al., 2025)。核心思路:为参考运动的每一帧构造一个"目标状态",用 reward 惩罚当前状态与目标状态之间的误差。

参考运动帧 t ──→ 目标状态 (root_pos, root_ori, joint_angles, velocities)
                        ↓
                    reward = exp(-α × ‖当前 - 目标‖²)
                        ↓
                    PPO 优化 → 策略学会跟踪参考运动

工程特点:reward 设计透明、可调(每个 body part 独立权重),适合精确复现单条或多条动作。代价是需要精确的 retarget 数据和帧级时间对齐。

技术线 B:对抗式风格约束(Adversarial Style)

从 AMP (Peng et al., SIGGRAPH 2021) 到 ASE (Peng et al., SIGGRAPH 2022) 再到 CALM (Tessler et al., SIGGRAPH 2023) 到 MaskedMimic (Tessler et al., SIGGRAPH Asia 2024)。核心思路:不做帧级别的显式跟踪,而是训练一个判别器(discriminator)来判断策略的运动"看起来"是否像参考数据集。

参考数据集 {(s, s')₁, (s, s')₂, ...} → 判别器训练:"这些是真的"
策略 rollout {(s, s')₁, (s, s')₂, ...}  → 判别器训练:"这些是假的"
                                           ↓
              reward = max[0, 1 - 0.25*(D(s, s') - 1)^2]   (AMP 用 LSGAN,D 为回归输出)
                                           ↓
                              PPO 优化 → 策略产生"像参考"的运动

工程特点:不需要帧级对齐,可以从大量动作数据集中提取"风格"。但判别器训练增加了不稳定性,且"风格像"不保证"动作精确"。

三条线的快速工程对比:

维度 显式跟踪 对抗风格 BC 蒸馏
核心 reward 帧级 MSE 判别器输出(LSGAN 回归值) MSE(student, teacher)
需要的数据 精确 retarget 大致 retarget expert rollout
训练组件 PPO PPO + GAN 纯监督
调试难度 高(判别器不透明)
代表框架 mjlab tracking ProtoMotions AMP Ch09 DistillationRunner

技术线 C:行为克隆蒸馏(Behavioral Cloning Distillation)

BC → DAgger → 大规模蒸馏。核心思路:先用技术线 A 或 B 训练一个 expert teacher,然后用监督学习把 teacher 的行为蒸馏到一个更轻量或输入受限的 student。

这条线与 Ch09 的 teacher-student 蒸馏高度重叠,区别在于:Ch09 的蒸馏跨越的是"信息边界"(privileged → deployable),本章的蒸馏跨越的是"能力边界"(expert tracker → general controller)。两种蒸馏可以叠加——先用 Ch09 方法处理信息边界,再用 Ch10 方法处理能力迁移。

双重解读:监督粒度 vs 信息来源

三条技术线可以从两个完全不同的视角来理解——这是理解 motion imitation 全貌的关键。

视角 A(监督粒度): 显式跟踪是"逐帧监督"(frame-level supervision)——每个时间步都有精确的目标。AMP 是"分布级监督"(distribution-level supervision)——判别器只关心运动的统计特性是否像参考数据,不关心某一帧是否对齐。BC 是"行为级监督"(action-level supervision)——直接模仿 expert 的输出。这三种粒度从细到粗:逐帧 → 分布 → 行为。

视角 B(信息来源): 从信息论角度看,三条线使用不同的信息源来约束策略。显式跟踪的信息来自"参考运动的每一帧状态"——信息量最大但需要精确数据。AMP 的信息来自"参考数据集的联合分布 \(p(s, s')\)"——信息量中等但更鲁棒。BC 的信息来自"expert 的输入-输出映射 \(\pi_{\text{expert}}(a|s)\)"——信息量最少但最容易获取。

这两个视角的工程含义:如果你的数据质量高(精确 retarget、帧率匹配),走视角 A 的"逐帧监督"方向(显式跟踪);如果数据质量有限但量大,走"分布级监督"(AMP);如果你只有一个训练好的 expert 而没有原始动作数据,走"行为级监督"(BC)。

三条线的演进脉络

年份 显式跟踪线 对抗式线 BC 蒸馏线
2018 DeepMimic(单条动作)
2021 AMP(动作数据集→风格 reward)
2022 ASE(latent skill embedding)
2023 PHC(万条动作+渐进网络) CALM(文本条件控制) HOVER 蒸馏
2024 MaskedMimic(motion inpainting) MaskedMimic BC
2025 BeyondMimic(真机 G1) TienKung-Lab(AMP+periodic) VIRAL 大规模蒸馏

这三条线不是互斥的——工业级管线通常组合使用。例如 BeyondMimic 用显式跟踪训练 expert,然后用 diffusion policy(一种 BC 变体)蒸馏出可导航的通用控制器。HOVER 用 AMP 风格的 oracle 训练,然后用 DAgger 蒸馏到 mask-conditioned student。

本质洞察: DeepMimic 和 AMP 的核心区别不是技术细节,而是监督粒度。DeepMimic 说"你的左手在这个时刻应该在这个位置"——逐帧监督。AMP 说"你的运动整体上应该像人类"——分布级监督。前者精确但脆弱(数据错会学错),后者灵活但模糊(可能满足于"大致像"而不精确)。

⚠️ 常见陷阱

🧠 思维陷阱:AMP 总比 DeepMimic 好。 新手想法:"AMP 更新、更灵活,应该总是用 AMP。"实际上,AMP 的判别器训练增加了工程复杂度和不稳定性,而且"风格像"不意味着"动作精确"。如果目标是精确复现动捕数据(如机器人体操表演),DeepMimic 式的显式跟踪更可靠。

💡 概念误区:motion imitation 就是"回放关节角"。 Motion imitation 不是把关节角序列直接写入电机命令。它是在每个时间步构造参考状态,并用 root position / body pose / velocity / regularization 和 termination 共同约束策略——策略必须在物理仿真中通过力的交互来实现参考姿态,不是绕过物理直接设置关节位置。

练习

  1. [分类题] 以下任务分别更适合哪条技术线?说明理由:(a) 精确复现一段太极拳动作 (b) 让人形机器人在任意地形上自然行走 (c) 把一个 teacher tracker 的能力部署到只有 depth camera 的 student
  2. [思考题] AMP 的判别器输入是状态对 \((s_t, s_{t+1})\) 而不是单帧 \(s_t\),为什么?如果只用单帧,判别器会学到什么"shortcuts"?
  3. [跨章综合题] Ch09 的 teacher-student 蒸馏和本章的 BC 蒸馏在技术上有什么共同点和区别?两者可以叠加使用吗?

上一节建立了三条技术线的全局视角。但无论选择哪条线,第一步都相同:准备动作数据。下一节详细介绍从 AMASS 原始数据到框架可用格式的完整管线。

10.2 动作数据格式与管线 ⭐⭐

这一节解决什么问题:动作数据是 motion imitation 的"燃料"。本节讲解 AMASS/SMPL 体系的数据格式、从 SMPL 到机器人关节的 retarget 流程、以及 mjlab 和 ProtoMotions 各自的数据加载方式。

动机:数据质量决定训练上限

Motion imitation 是数据驱动的——你的策略只能学到数据中存在的运动模式。如果 retarget 后的数据在某些关节角处超出机器人限位,策略会被迫学习"妥协动作"(接近但不完全匹配参考),training reward 永远无法达到理论最大值。如果数据的帧率与仿真步频不匹配,插值引入的误差会在快速运动中被放大。

数据管线的每一步都有可能引入错误,而这些错误会在 RL 训练中被放大——因为策略会为了最大化 reward 而"适应"数据错误,产生不自然的补偿动作。

如果数据管线有问题会怎样

常见的数据管线错误和对应症状:

错误类型 症状 排查方法
帧率不匹配 快速动作"慢放"或"快进" 比较 data fps 和 sim fps
关节映射错误 左右镜像、关节交叉 在 MuJoCo 中回放第 0 帧
坐标系不一致 机器人面朝错误方向 检查 root orientation 约定
关节角越界 reward 持续较低且手臂/腿异常 打印 joint_pos_error histogram
全局 vs 局部旋转混淆 body 旋转剧烈扭曲 检查 FK 结果是否合理
接地不良 机器人浮空或下沉 检查 root height offset

AMASS 数据集与 SMPL 体模型

AMASS(Archive of Motion Capture as Surface Shapes, Mahmood et al., ICCV 2019)是当前最大的公开人类动作数据集。它把 15+ 个不同来源的动捕数据统一到 SMPL 体模型的参数空间中,提供超过 40 小时、346 个受试者、11,451 条动作的标准化数据。

SMPL(Skinned Multi-Person Linear model, Loper et al., SIGGRAPH Asia 2015)是一个参数化人体模型:

# SMPL 参数空间
class SMPLParams:
    beta: np.ndarray  # (10,) 体型参数(PCA 系数)
    theta: np.ndarray  # (72,) 关节角(24 joints × 3 axis-angle)
    trans: np.ndarray  # (3,) 全局平移

# SMPL 变体:
# SMPL+H: 增加 MANO 手部关节(159 维 pose)
# SMPL-X: 增加面部表情 + 手部(10475 顶点)

AMASS 中每一帧的数据就是一组 SMPL 参数 \((\beta, \theta_t, \text{trans}_t)\)。整条动作序列就是 \(\{(\theta_t, \text{trans}_t)\}_{t=0}^{T}\) 加上固定的 \(\beta\)(体型在整条动作中不变)。

关键理解:SMPL 的关节角 θ 是 axis-angle 格式。 每个关节 3 个参数,表示绕该轴旋转的角度。这与机器人 URDF 中的关节角定义不同——URDF 通常是 revolute(单轴旋转),而 SMPL 是 ball joint(三轴旋转)。Retarget 的核心工作就是处理这种表示差异。

Retarget 管线:从 SMPL 到机器人关节

Retarget 是 motion imitation 中最容易出错也最难调试的步骤。它的目标是把 SMPL 的 24 关节 axis-angle 表示转换为机器人 URDF 的 N 关节 revolute 角度。这类似于把一首钢琴曲改编为吉他曲——两种乐器的音域不同、表达方式不同,你需要在保持旋律核心的同时适应新乐器的限制。

标准 retarget 流程:

SMPL 动作 (.npz)
    │
    ├─→ 1. 前向运动学 (FK):SMPL params → 关键点 3D 位置
    │
    ├─→ 2. 关键点匹配:SMPL 关键点 → 机器人 body 对应表
    │       (SMPL hip_l → G1 left_hip_yaw_link)
    │
    ├─→ 3. 逆运动学 (IK):从目标关键点位置求解机器人关节角
    │       (使用 Mink 或自定义 IK solver)
    │       约束:关节限位、end-effector 位置、foot contact
    │
    ├─→ 4. 质量验证:在 MuJoCo 中回放,检查穿模/越界/浮空
    │
    └─→ 5. 格式化输出:保存为 .npz (mjlab) 或 .yaml (ProtoMotions)

以下是 retarget 中关键点匹配表的典型示例(SMPL → Unitree G1 29-DoF):

SMPL Joint G1 对应 Link 匹配方式 注意事项
pelvis base_link 根节点对齐 高度 offset 约 0.75m
left_hip left_hip_yaw_link 直接映射 yaw/roll/pitch 三轴
left_knee left_knee_link 直接映射 单轴 revolute
left_ankle left_ankle_roll_link 直接映射 roll/pitch 两轴
left_shoulder left_shoulder_roll_link 直接映射 pitch/roll/yaw 三轴
left_elbow left_elbow_link 直接映射 单轴 revolute
spine1/2/3 torso_link 合并映射 SMPL 3 段脊柱 → G1 1 个 torso

retarget 中最棘手的问题是"自由度不匹配":SMPL 有 24 个 ball joint(每个 3 DoF = 72 DoF),而 G1 只有 23-29 个 revolute joint。很多 SMPL 关节(如脊柱的三段细分)在 G1 上没有对应的驱动关节。解决方案是聚合——把 SMPL 的多段旋转合并为最接近的单关节旋转,或者直接忽略无对应的自由度。

Retarget 的简化 IK 实现

以下展示一个简化的 retarget IK 求解器。实际项目中推荐使用 ProtoMotions 的 motion_retargeting 模块或 Mink 库,但理解底层原理对调试至关重要:

# simple_retarget.py — 简化的 SMPL → Robot IK retarget
import numpy as np
import mujoco
from scipy.spatial.transform import Rotation

def retarget_frame(smpl_joints_3d: np.ndarray,
                   mj_model, mj_data,
                   joint_mapping: dict,
                   max_ik_iters: int = 100) -> np.ndarray:
    """
    对单帧执行 IK retarget。

    Args:
        smpl_joints_3d: (24, 3) SMPL 关键点的 3D 世界坐标
        mj_model: MuJoCo model
        mj_data: MuJoCo data
        joint_mapping: {smpl_joint_idx: mujoco_body_name} 映射
        max_ik_iters: IK 最大迭代次数
    Returns:
        robot_qpos: 机器人关节角(radians)
    """
    # 初始化为站立姿态
    mj_data.qpos[:] = mj_model.key_qpos[0]  # default pose

    for ik_iter in range(max_ik_iters):
        mujoco.mj_forward(mj_model, mj_data)

        total_error = 0.0
        for smpl_idx, body_name in joint_mapping.items():
            body_id = mujoco.mj_name2id(
                mj_model, mujoco.mjtObj.mjOBJ_BODY, body_name
            )
            # 当前 body 位置
            current_pos = mj_data.xpos[body_id].copy()
            # 目标位置(SMPL 关键点)
            target_pos = smpl_joints_3d[smpl_idx]

            error = target_pos - current_pos
            total_error += np.linalg.norm(error)

            # 获取该 body 对应的关节的 Jacobian
            jacp = np.zeros((3, mj_model.nv))
            mujoco.mj_jacBody(mj_model, mj_data, jacp, None, body_id)

            # 阻尼最小二乘 IK 更新
            damping = 0.01
            JtJ = jacp.T @ jacp + damping * np.eye(mj_model.nv)
            dq = np.linalg.solve(JtJ, jacp.T @ error)

            # 更新 qpos(只更新关节角,不更新 root)
            # 注意:free root 在 qpos 中占 7 维(3 pos + 4 quat),在 qvel 中占 6 维,
            # 故跳过 qpos[:7] 与 dq[:6]。下式把 qvel 空间的增量直接加到 qpos,
            # 仅当 root 之后全为 hinge 关节(每个 nq==nv==1)时成立;含 ball joint 时 nq≠nv,需用 mj_integratePos。
            mj_data.qpos[7:] += 0.3 * dq[6:]  # 学习率 0.3

        # 裁剪到关节限位
        for j in range(mj_model.njnt):
            if mj_model.jnt_limited[j]:
                lo, hi = mj_model.jnt_range[j]
                addr = mj_model.jnt_qposadr[j]
                mj_data.qpos[addr] = np.clip(mj_data.qpos[addr], lo, hi)

        if total_error < 0.01:  # 1cm 精度
            break

    return mj_data.qpos[7:].copy()  # 只返回关节角(不含 free-root 的前 7 维 qpos)

这段代码使用了阻尼最小二乘 IK——这是 robotics 中最常用的 IK 方法。阻尼参数 damping=0.01 防止 Jacobian 在奇异点附近的数值不稳定。学习率 0.3 控制每步更新幅度——太大会震荡,太小收敛太慢。

⚠️ 工程提示: 这个简化实现只处理了位置约束。实际的 retarget 还需要处理:(1) 朝向约束(关键 body 不仅要在正确位置,还要面朝正确方向),(2) 足底接触约束(接触帧的脚底高度应该为 0),(3) 对称性约束(左右对称的动作应该产生对称的关节角)。ProtoMotions 和 TienKung-Lab 的 retarget 脚本处理了所有这些约束。

Video → SMPL → Robot:端到端视频动作提取管线

2024-2025 年的前沿工作(KungfuBot, TextOp, HumanPlus)展示了一条完整的管线:从单目视频提取人体动作 → retarget → RL 训练 → 真机部署。这条管线让 motion imitation 不再依赖昂贵的动捕设备。

单目视频
  ├─→ 4D-Humans (HMR2.0) / WHAM / GVHMR → SMPL 参数序列
  ├─→ 物理可行性过滤(KungfuBot 的贡献)
  │     检查:CoM/CoP 稳定性、关节力矩可行性
  ├─→ Retarget:SMPL → G1 关节角
  ├─→ 质量验证:MuJoCo 回放
  └─→ RL 训练:BeyondMimic tracking 或 AMP

这条管线中最关键的步骤是物理可行性过滤——视频提取的 SMPL 参数可能在物理上不可行(例如脚底滑动、质心超出支撑多边形)。KungfuBot(NeurIPS 2025)设计了一个自动过滤器:

# 物理可行性检查的概念代码
def physics_feasibility_check(smpl_sequence):
    """检查 SMPL 动作序列的物理可行性。"""
    issues = []
    for t in range(len(smpl_sequence)):
        # 1. CoM-CoP 稳定性
        com = compute_center_of_mass(smpl_sequence[t])
        cop = compute_support_polygon(smpl_sequence[t])
        if not point_in_polygon(com[:2], cop):
            issues.append(f"Frame {t}: CoM outside support polygon")

        # 2. 足底滑动检测
        if t > 0:
            foot_vel = smpl_sequence[t].foot_pos - smpl_sequence[t-1].foot_pos
            if smpl_sequence[t].foot_contact and np.linalg.norm(foot_vel) > 0.02:
                issues.append(f"Frame {t}: foot skating detected")

    return issues

视频提取方法的选择:

方法 输入 输出 精度 适用场景
4D-Humans (HMR2.0) 单目 RGB SMPL per-frame 室内标准视角
WHAM 单目视频(SLAM 估计相机角速度/轨迹) SMPL + 全局轨迹 户外运动
GVHMR 单目 RGB SMPL + 重力对齐 含重力参考的场景

mjlab 的动作数据格式(BeyondMimic)

mjlab 的 tracking task 使用 .npz 格式存储动作数据。从 CSV(通常来自 retarget 工具的输出)到 .npz 的转换通过 scripts/tracking/csv_to_npz.py 完成:

# 把 retarget 后的 CSV 转为 mjlab 可用的 .npz
# 注意:续行反斜杠必须是行末最后一个字符,不能在其后写内联注释
python scripts/tracking/csv_to_npz.py \
    --input motion_retargeted.csv \
    --output motion.npz \
    --source-fps 30 \
    --target-fps 50 \
    --render
# --target-fps 50 = mjlab 仿真频率;--render 可选:在 MuJoCo 中可视化预览

csv_to_npz.py 做了以下关键操作:

  1. 帧率重采样:从源帧率(通常 30 fps)线性插值到仿真帧率(50 fps)
  2. 四元数归一化:确保 root orientation 四元数长度为 1
  3. 关节角裁剪:把超出 URDF 关节限位的角度裁剪到允许范围
  4. 安全起止帧添加:在动作前后各添加 ~0.5 秒的站立帧(安全过渡)

转换后的 .npz 文件内部结构:

import numpy as np

data = np.load("motion.npz")
print(data.files)
# ['fps', 'root_pos', 'root_quat', 'root_lin_vel', 'root_ang_vel',
#  'joint_pos', 'joint_vel', 'body_pos', 'body_quat', 'body_vel']

# 关键字段说明:
# root_pos:      (T, 3)   - 全局根节点位置 (x, y, z)
# root_quat:     (T, 4)   - 全局根节点朝向 (w, x, y, z) —— MuJoCo wxyz 约定
# root_lin_vel:  (T, 3)   - 根节点线速度(RSI 初始化需要;若数据无此字段,可由 root_pos 按 fps 差分得到)
# root_ang_vel:  (T, 3)   - 根节点角速度(同上)
# joint_pos:     (T, N_j)  - 关节角度 (radians)
# joint_vel:     (T, N_j)  - 关节角速度
# body_pos:      (T, N_b, 3) - 每个 body 的全局位置
# body_quat:     (T, N_b, 4) - 每个 body 的全局朝向

⚠️ 四元数约定陷阱:MuJoCo 使用 (w, x, y, z) 约定,PyTorch3D 的 transforms 同样是 (w, x, y, z);而 scipy Rotation、Isaac/USD 等许多工具使用 (x, y, z, w)。 如果不转换,root orientation 会完全错误——机器人可能面朝下方或侧面。务必逐个工具确认约定。自检方法(仅当初始帧近似无旋转时有效):加载 .npz 后打印 root_quat[0],确认第一个分量(w)接近 1.0。若动作本身带初始 yaw,则应改用可视化回放或比较 heading/up 向量来确认约定。

mjlab 中加载和使用动作数据

mjlab 的 tracking task 通过 WandB Registry 管理动作数据:

# Step 1:上传动作到 WandB Registry
wandb artifact put \
    --name your-org/motions/g1_walk \
    --type motions \
    motion.npz

# Step 2:训练时通过 registry-name 引用
uv run train Mjlab-Tracking-Flat-Unitree-G1 \
    --registry-name your-org/motions/g1_walk \
    --env.scene.num-envs 4096 \
    --agent.max-iterations 20000

也可以直接使用本地文件(不依赖 WandB):

# env_cfg.py 中的动作数据配置
class TrackingEnvCfg:
    motion_file: str = "/path/to/motion.npz"
    # 或者使用 WandB Registry
    # registry_name: str = "your-org/motions/g1_walk"

    # 动作采样配置
    motion_sampling: str = "random"     # "random" | "sequential" | "adaptive"
    reference_state_init: bool = True   # RSI:从参考运动的随机帧初始化
    rsi_start_ratio: float = 0.0       # RSI 起始比例(0 = 只从开头,1 = 任意位置)
    rsi_end_ratio: float = 0.9         # RSI 结束比例(不从最后 10% 开始)

ProtoMotions 的动作数据格式

ProtoMotions 使用 YAML 配置文件管理动作数据,支持更丰富的格式(SMPL/SMPL-X params、joint angles、keypoints):

# ProtoMotions motion config example
motion_file:
  motion_type: "amass_smpl"           # amass_smpl | amass_smplx | joint_angles
  motion_files:
    - path: "data/amass/CMU/01_01.npz"
      weight: 1.0                      # 采样权重
    - path: "data/amass/BMLrub/rub001.npz"
      weight: 0.5
  fps: 30
  fix_heights: true                    # 自动修正脚底接地高度
  skeleton_type: "smpl"                # smpl | smplx | g1 | h1

ProtoMotions 与 mjlab 的关键区别在于 retarget 的时机:

维度 mjlab ProtoMotions
retarget 时机 预处理时(先 retarget 再训练) 训练时(在线 retarget)
数据格式 .npz(已 retarget 的关节角) .npz(SMPL params,原始格式)
机器人切换 需要重新 retarget 只需换 config
retarget 质量控制 可以人工检查每一帧 自动化但难以逐帧检查

本质洞察: mjlab 的"先 retarget 后训练"流程把数据质量控制前置——你可以在 MuJoCo 中逐帧回放检查 retarget 结果,确认没有穿模、越界和浮空问题后再开始训练。ProtoMotions 的"在线 retarget"更灵活(换机器人不需要重新处理数据),但需要更强的自动化质量保障。对于初学者和研究项目,推荐 mjlab 的前置 retarget 流程——它更可控、更容易调试。

数据契约检查:训练前的必过门禁

在任何训练开始之前,先检查 .npz 文件是否满足"数据契约"——必需字段是否齐全、shape 是否自洽、数值是否物理合理。这一步零成本但能避免大量浪费。

# check_motion_contract.py — motion 数据契约检查
# 在任何 tracking 训练之前运行此脚本。
# 数据契约不过关就开始训练,等同于在错误的地基上盖楼。
import numpy as np

def check_motion_contract(npz_path: str) -> list[str]:
    """
    检查 motion.npz 的数据契约,返回问题列表。
    空列表 = 通过所有检查。
    """
    issues = []
    data = np.load(npz_path)

    # ========== 1. 必需 key 检查 ==========
    required = {
        "fps":       "标量",
        "root_pos":  "(T, 3)",
        "root_quat": "(T, 4)",
        "joint_pos": "(T, N_j)",
        "joint_vel": "(T, N_j)",
        "body_pos":  "(T, N_b, 3)",
        "body_quat": "(T, N_b, 4)",
    }
    for key, expected_shape in required.items():
        if key not in data:
            issues.append(f"缺少必需字段 '{key}' (expected shape: {expected_shape})")

    if issues:
        return issues  # 缺 key 不继续,后续检查会 crash

    # ========== 2. 时间维一致性 ==========
    T = data["joint_pos"].shape[0]
    for key in ["root_pos", "root_quat", "joint_pos", "joint_vel",
                "body_pos", "body_quat"]:
        if data[key].shape[0] != T:
            issues.append(
                f"'{key}' 时间维 {data[key].shape[0]} != {T} (joint_pos)"
            )

    # ========== 3. 四元数单位化检查 ==========
    root_quat = data["root_quat"]
    norms = np.linalg.norm(root_quat, axis=-1)
    bad_frames = np.sum(np.abs(norms - 1.0) > 1e-3)
    if bad_frames > 0:
        issues.append(
            f"root_quat: {bad_frames}/{T} 帧非单位四元数 "
            f"(max |norm-1| = {np.max(np.abs(norms-1.0)):.4f})"
        )

    # wxyz 约定检查:第 0 帧的 w 分量应该接近 1(近似无旋转)
    w0 = root_quat[0, 0]
    if abs(w0) < 0.5:
        issues.append(
            f"root_quat[0] 的 w 分量 = {w0:.3f}(预期接近 ±1),"
            f"可能是 xyzw/wxyz 约定混淆"
        )

    # ========== 4. 关节速度与位置的交叉验证 ==========
    fps = float(data["fps"])
    dt = 1.0 / fps
    pos_diff = np.diff(data["joint_pos"], axis=0) / dt
    vel = data["joint_vel"][1:]  # 跳过第 0 帧
    vel_error = np.abs(pos_diff - vel)
    max_vel_error = np.max(vel_error)
    if max_vel_error > 5.0:  # rad/s 的误差阈值
        issues.append(
            f"joint_vel 与 joint_pos 差分不一致: "
            f"max error = {max_vel_error:.2f} rad/s "
            f"(可能是 dt 不匹配或速度字段错误)"
        )

    # ========== 5. 物理合理性 ==========
    # 根节点不应该在地下
    min_z = data["root_pos"][:, 2].min()
    if min_z < -0.1:
        issues.append(f"root_pos z 最小值 = {min_z:.3f}m (在地下)")

    # 关节角不应该有 NaN 或 Inf
    if np.any(np.isnan(data["joint_pos"])):
        issues.append("joint_pos 包含 NaN")
    if np.any(np.isinf(data["joint_pos"])):
        issues.append("joint_pos 包含 Inf")

    # ========== 输出报告 ==========
    if issues:
        print(f"❌ 数据契约检查失败 ({npz_path}): {len(issues)} 个问题")
        for i, issue in enumerate(issues):
            print(f"  [{i+1}] {issue}")
    else:
        print(f"✅ 数据契约通过 ({npz_path}): "
              f"{T} 帧, {fps:.0f} fps, "
              f"{data['joint_pos'].shape[1]} joints, "
              f"{data['body_pos'].shape[1]} bodies")

    return issues


# 使用示例
if __name__ == "__main__":
    import sys
    path = sys.argv[1] if len(sys.argv) > 1 else "motion.npz"
    issues = check_motion_contract(path)
    exit(0 if not issues else 1)
检查项 检查内容 常见失效原因 修复方式
必需 key fps/root_pos/root_quat/joint_pos 等 retarget 工具输出格式变化 修改转换脚本或重新导出
时间维一致 所有字段的 T 相同 截断时只处理了部分字段 统一截断逻辑
四元数单位化 |q| ≈ 1.0 数值累积误差、未归一化 q / np.linalg.norm(q)
wxyz 约定 第 0 帧 w ≈ ±1 xyzw/wxyz 混淆 q_wxyz = q_xyzw[[3,0,1,2]]
vel vs pos 差分 joint_vel ≈ Δjoint_pos/dt fps 不匹配、vel 来自不同源 从 pos 重新计算 vel
物理合理性 无 NaN/Inf、root 不在地下 retarget 溢出、坐标系错误 检查 retarget 工具输出

工程建议:把 check_motion_contract.py 加入项目的 CI/CD 或 Makefile。 每次更新动作数据时自动运行。一次 10 秒的检查可以避免 10 小时的无效训练。

数据质量验证:Retarget 后的必检项

# retarget_verify.py — retarget 后的质量检查
import numpy as np
import mujoco

def verify_retarget(npz_path, mjcf_path):
    """加载 retarget 数据并在 MuJoCo 中验证。"""
    data = np.load(npz_path)
    model = mujoco.MjModel.from_xml_path(mjcf_path)
    mj_data = mujoco.MjData(model)

    issues = []
    T = data["joint_pos"].shape[0]

    for t in range(T):
        # 设置机器人状态为参考帧
        qpos = np.concatenate([
            data["root_pos"][t],
            data["root_quat"][t],
            data["joint_pos"][t],
        ])
        mj_data.qpos[:] = qpos
        mujoco.mj_forward(model, mj_data)

        # 检查 1:关节角是否在限位内
        for j in range(model.njnt):
            if model.jnt_limited[j]:
                lo, hi = model.jnt_range[j]
                q = mj_data.qpos[model.jnt_qposadr[j]]
                if q < lo - 0.01 or q > hi + 0.01:
                    issues.append(f"Frame {t}: joint {j} out of range "
                                  f"({q:.3f} not in [{lo:.3f}, {hi:.3f}])")

        # 检查 2:脚底高度是否合理(不浮空也不穿地)
        for foot_name in ["left_ankle_roll_link", "right_ankle_roll_link"]:
            foot_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY,
                                         foot_name)
            if foot_id < 0:  # 找不到 body 时 mj_name2id 返回 -1,否则 xpos[-1] 会静默读到最后一个 body
                issues.append(f"Frame {t}: body '{foot_name}' 不存在(请核对资产里的 link 名)")
                continue
            foot_z = mj_data.xpos[foot_id, 2]
            if foot_z < -0.02:
                issues.append(f"Frame {t}: {foot_name} below ground "
                              f"(z={foot_z:.3f})")
            if foot_z > 0.15 and t > 10:  # 排除起始帧
                pass  # 可能是跳跃动作,不一定是错误

        # 检查 3:自碰撞(粗略启发:ncon 是所有接触对总数,含脚-地/环境接触,
        # 并不专指自碰撞;严格判定应遍历 mj_data.contact 并按 geom/body 父级过滤同机器人内部 body 对)
        if mj_data.ncon > 20:  # 异常多的接触对
            issues.append(f"Frame {t}: {mj_data.ncon} contacts "
                          f"(possible self-collision)")

    print(f"检查完成: {T} 帧, {len(issues)} 个问题")
    for issue in issues[:20]:  # 只打印前 20 个
        print(f"  {issue}")
    return len(issues) == 0

⚠️ 常见陷阱

⚠️ 编程陷阱:四元数 (w,x,y,z) vs (x,y,z,w) 约定混淆。 MuJoCo 使用 wxyz;PyTorch3D 的 transforms 也是 wxyz(real part first);而 scipy 的 Rotation.as_quat() 默认是 xyzw(scalar-last),可用 as_quat(scalar_first=True) 输出 wxyz。Warp(mjlab 的后端)的 wp.quat 内部是 xyzw。不同库之间传递四元数时必须逐个确认约定,切勿想当然。自检方法:assert abs(np.linalg.norm(quat) - 1.0) < 1e-5 并检查 w 分量是否在预期位置。

⚠️ 编程陷阱:帧率不匹配导致动作变速。 如果源动作是 30 fps,但训练仿真以 50 Hz 逐帧消费(每个仿真 step 前进一帧)且没有按时间戳重采样,动作会被快放到 50/30 = 1.67 倍速度,时长缩短为原来的 30/50 = 0.6 倍——速度类 reward/参考量会随之失真。正确做法是按源 fps/时间戳插值到控制频率。csv_to_npz.py 自动处理了这个问题,但如果你手动准备数据,务必检查。

💡 概念误区:认为"只要 retarget 工具跑通了就没问题"。 Retarget 工具通常在关节限位边界处做 clip——这意味着超出机器人能力的动作帧会被静默截断。training 时策略会尝试跟踪这些被截断的帧,但因为截断引入了不连续(原始动作在这些帧处是连续的),策略会产生抖动。解决方案:retarget 后必须在 MuJoCo 中可视化回放,人工确认动作流畅。

练习

  1. [动手题] 下载 AMASS 数据集中的一条 CMU 动作(如 01_01_poses.npz),用 ProtoMotions 的 retarget 工具转换为 G1 的关节角序列。在 MuJoCo 中回放,记录发现的问题。
  2. [计算题] 一条 30 fps、10 秒的动作数据,转换为 50 fps 后有多少帧?如果 G1 有 23 个关节,.npzjoint_pos 字段的 shape 应该是多少?
  3. [设计题] 如果你需要让 G1 模仿一段篮球投篮动作,但 G1 没有手指关节(不能抓球),retarget 时应该如何处理上肢?手部关键点的匹配权重应该怎么设?

动作数据准备完成后,下一步是选择训练方法。下两节分别讲解两条最重要的技术线:显式跟踪(BeyondMimic)和对抗风格约束(AMP/ASE)的工程实现。

10.3 显式跟踪:BeyondMimic 在 mjlab 中的实现 ⭐⭐⭐

这一节解决什么问题:以 mjlab 的 tracking task(基于 BeyondMimic)为精读对象,展示显式跟踪方法的完整工程实现——从 reward 分解到 anchor 机制到训练配置。

动机:显式跟踪是最直接的 motion imitation 方法

如果你有一段 retarget 好的参考动作,想让机器人精确复现这段动作,显式跟踪是最自然的选择——把每一帧的目标状态和当前状态比较,用 reward 引导策略缩小差距。BeyondMimic(Liao et al., 2025)是这个方向的最新工作,它在 Unitree G1 上实现了跳旋、冲刺和侧空翻等高难度动作。

如果直接用关节角 MSE 作为 reward 会怎样

最简单的 tracking reward 是 \(r = -\sum_j (q_j - q_j^{\text{ref}})^2\)——关节角误差的负平方和。这个 reward 有三个严重问题:

  1. 末端放大效应:髋关节 1° 的误差导致脚尖位移 ~5cm,但踝关节 1° 的误差只导致脚尖位移 ~1cm。扁平的关节角 MSE 给两者相同的惩罚,但物理效果差异巨大
  2. 旋转表示问题:关节角的差值在 revolute joint 上是良定义的,但在 ball joint 或 root orientation(四元数)上需要特殊处理——直接做减法没有几何意义
  3. 全局位置漂移:关节角全部正确但 root 位置偏了,机器人整体在"正确的姿态"但在"错误的位置"——纯关节角 reward 检测不到这个问题

BeyondMimic 的 reward 设计解决了这三个问题。

Reward 分解:BeyondMimic 的五项 reward

mjlab 中 BeyondMimic tracking task 的 reward 由五个分项组成,每项使用指数型 reward \(r_i = \exp(-\alpha_i \cdot e_i)\)

# mjlab tracking task 的 reward 配置
reward_terms = {
    # 1. 全局根节点位置误差(anchor 世界坐标)
    "global_anchor_pos": RewardTermCfg(
        func=mdp.motion_global_anchor_position_error_exp,
        params={"sigma": 0.5},
        weight=2.0,
    ),

    # 2. 全局根节点朝向误差(四元数距离)
    "global_anchor_ori": RewardTermCfg(
        func=mdp.motion_global_anchor_orientation_error_exp,
        params={"sigma": 0.3},
        weight=1.5,
    ),

    # 3. 相对 body pose 误差(关节角)
    "relative_body_pose": RewardTermCfg(
        func=mdp.motion_relative_body_pose_error_exp,
        params={"sigma": 0.5},
        weight=1.5,
    ),

    # 4. Body 速度误差(线速度 + 角速度)
    "body_velocity": RewardTermCfg(
        func=mdp.motion_body_velocity_error_exp,
        params={"sigma": 1.0},
        weight=0.5,
    ),

    # 5. 自碰撞惩罚
    "self_collision": RewardTermCfg(
        func=mdp.self_collision_cost,
        weight=-0.1,
    ),
}

每项 reward 的工程意义:

Reward 项 关注的误差 σ 的含义 典型权重 如果缺失
global_anchor_pos 根节点全局位置 位置容差(米) 2.0 机器人漂移
global_anchor_ori 根节点全局朝向 朝向容差(弧度) 1.5 机器人面朝错方向
relative_body_pose 各关节相对角度 角度容差(弧度) 1.5 姿态不像参考
body_velocity 各 body 速度 速度容差(m/s) 0.5 动作"卡顿"不流畅
self_collision 自身碰撞 -0.1 穿模

σ 调参的工程直觉: σ 控制 reward 的"宽容度"——σ 大意味着"只要大方向对就给分",σ 小意味着"毫米级精确才给分"。训练初期应该用较大的 σ(让策略先学会大致跟踪),后期用较小的 σ(提高精度)。这和 Ch06 讨论的 curriculum learning 思想一致。BeyondMimic 在 mjlab 中默认不做 σ curriculum(使用固定值),但你可以通过 EventManager 实现训练过程中的 σ 退火。

Anchor 机制:BeyondMimic 的关键创新

传统 DeepMimic 要求策略在每个时间步都跟踪参考运动的当前帧——这导致策略只能"沿着参考轨迹走",不能自适应地调整节奏。BeyondMimic 引入了 anchor pose 机制:策略看到的不是"整条参考轨迹",而是"下一个关键姿态"。

# BeyondMimic anchor 机制的实现逻辑
class AnchorPoseObservation:
    """
    策略只看到下一个 anchor pose,而不是完整参考轨迹。
    """
    def __init__(self, motion_data, anchor_interval=10):
        self.motion = motion_data
        self.interval = anchor_interval  # 每 10 帧一个 anchor

    def get_anchor(self, current_frame: int) -> dict:
        """获取当前帧对应的下一个 anchor pose。"""
        # anchor 帧 = 当前帧向上取整到 interval 的倍数
        next_anchor = ((current_frame // self.interval) + 1) * self.interval
        next_anchor = min(next_anchor, len(self.motion) - 1)

        return {
            "anchor_root_pos": self.motion["root_pos"][next_anchor],
            "anchor_root_quat": self.motion["root_quat"][next_anchor],
            "anchor_joint_pos": self.motion["joint_pos"][next_anchor],
        }

Anchor 机制的工程价值:策略不需要精确跟踪每一帧(这在物理仿真中几乎不可能——物理响应有延迟),只需要在每个 anchor 时间点到达正确的姿态。两个 anchor 之间的过渡由策略自由决策——这给了物理仿真系统足够的"呼吸空间"来处理接触力和动力学约束。

Anchor 机制的 observation 设计:

# mjlab tracking task 的 observation 配置(简化)
actor_terms = {
    # ——— 当前机器人状态 ———
    "joint_pos": ObservationTermCfg(func=mdp.joint_pos_rel),
    "joint_vel": ObservationTermCfg(func=mdp.joint_vel_rel),
    "base_ang_vel": ObservationTermCfg(func=mdp.base_ang_vel),
    "projected_gravity": ObservationTermCfg(func=mdp.projected_gravity),
    "last_action": ObservationTermCfg(func=mdp.last_action),

    # ——— 下一个 anchor 的目标状态 ———
    "anchor_root_pos_diff": ObservationTermCfg(
        func=mdp.motion_anchor_root_pos_diff,    # 当前位置 → anchor 位置的差向量
    ),
    "anchor_root_quat_diff": ObservationTermCfg(
        func=mdp.motion_anchor_root_quat_diff,   # 当前朝向 → anchor 朝向的旋转差
    ),
    "anchor_joint_pos_diff": ObservationTermCfg(
        func=mdp.motion_anchor_joint_pos_diff,    # 当前关节角 → anchor 关节角的差
    ),
}

注意 observation 中传入的是"差值"而不是"目标值"——这是一个重要的工程选择。如果传入绝对目标值,策略需要同时理解"我现在在哪"和"目标在哪"再算差值。传入差值直接告诉策略"需要往哪个方向移动多少",减轻了策略的学习负担。

Reference State Initialization (RSI)

# RSI 配置
class RSIConfig:
    enabled: bool = True
    start_ratio: float = 0.0    # 0 = 可以从头开始
    end_ratio: float = 0.9      # 0.9 = 不从最后 10% 开始(避免终止前初始化)

def reference_state_init(env, motion_data, rsi_cfg):
    """Episode reset 时从参考运动的随机帧初始化。"""
    T = len(motion_data["root_pos"])
    # 随机选择初始帧
    start = int(T * rsi_cfg.start_ratio)
    end = int(T * rsi_cfg.end_ratio)
    init_frame = np.random.randint(start, end)

    # 设置机器人状态为该帧的参考状态
    env.robot.set_root_state(
        pos=motion_data["root_pos"][init_frame],
        quat=motion_data["root_quat"][init_frame],
        lin_vel=motion_data["root_lin_vel"][init_frame],
        ang_vel=motion_data["root_ang_vel"][init_frame],
    )
    env.robot.set_joint_state(
        pos=motion_data["joint_pos"][init_frame],
        vel=motion_data["joint_vel"][init_frame],
    )
    return init_frame  # 返回初始帧索引,用于后续 tracking

RSI 解决的核心问题:如果总从开头(frame 0)开始,策略可能只学会了前几帧的跟踪(因为后面还没探索到就已经摔倒了)。RSI 让策略有机会从任意位置开始练习,大大提高了训练效率。

⚠️ RSI 的陷阱:如果参考运动的某些帧的关节角超出机器人限位(retarget 不可达),RSI 会把机器人初始化到一个物理不可行的状态。 MuJoCo 把关节限位当作约束处理(而非简单地把 qpos 裁剪到 range):越界的初始状态会让限位约束在随后几步产生很强的修正力/力矩,可能导致机器人"弹飞"或数值不稳定。解决方案:reset 时主动把关节角投影/裁剪到限位内并 mj_forward 检查,或在 retarget 阶段就确保所有帧都在关节限位内。

完整的 Tracking Reward 实现

以下是 BeyondMimic 风格的 tracking reward 完整实现。理解每一行对调参至关重要:

# tracking_reward.py — BeyondMimic 风格的 tracking reward 实现
import torch

class TrackingReward:
    """
    五项 tracking reward 的完整实现。
    每一项都是指数型 reward: r = exp(-alpha * error)
    """
    def __init__(self, cfg):
        self.cfg = cfg

    def compute(self, robot_state: dict, ref_state: dict) -> dict:
        """
        计算所有 reward 项。

        Args:
            robot_state: 当前机器人状态
                - root_pos: (num_envs, 3)
                - root_quat: (num_envs, 4) wxyz
                - joint_pos: (num_envs, num_joints)
                - body_pos: (num_envs, num_bodies, 3)
                - body_quat: (num_envs, num_bodies, 4)
                - body_lin_vel: (num_envs, num_bodies, 3)
                - body_ang_vel: (num_envs, num_bodies, 3)
            ref_state: 参考动作的目标状态(同结构)
        """
        rewards = {}

        # 1. 全局根节点位置误差
        root_pos_err = torch.norm(
            robot_state["root_pos"] - ref_state["root_pos"], dim=-1
        )
        rewards["global_anchor_pos"] = torch.exp(
            -root_pos_err / self.cfg.sigma_root_pos  # sigma=0.5m
        )

        # 2. 全局根节点朝向误差(四元数距离)
        root_ori_err = quat_distance(
            robot_state["root_quat"], ref_state["root_quat"]
        )
        rewards["global_anchor_ori"] = torch.exp(
            -root_ori_err / self.cfg.sigma_root_ori  # sigma=0.3rad
        )

        # 3. 相对 body pose 误差(逐关节)
        joint_err = torch.norm(
            robot_state["joint_pos"] - ref_state["joint_pos"], dim=-1
        )
        rewards["relative_body_pose"] = torch.exp(
            -joint_err / self.cfg.sigma_body_pose  # sigma=0.5rad
        )

        # 4. Body 速度误差
        lin_vel_err = torch.norm(
            robot_state["body_lin_vel"] - ref_state["body_lin_vel"],
            dim=-1
        ).mean(dim=-1)  # 对所有 body 取平均
        ang_vel_err = torch.norm(
            robot_state["body_ang_vel"] - ref_state["body_ang_vel"],
            dim=-1
        ).mean(dim=-1)
        vel_err = lin_vel_err + 0.1 * ang_vel_err  # 线速度权重 > 角速度
        rewards["body_velocity"] = torch.exp(
            -vel_err / self.cfg.sigma_velocity  # sigma=1.0
        )

        # 5. 自碰撞惩罚
        # 通过 MuJoCo 的 contact 数据判断
        rewards["self_collision"] = -self.cfg.collision_weight * (
            robot_state["self_contact_count"] > 0
        ).float()

        return rewards

    def aggregate(self, rewards: dict) -> torch.Tensor:
        """加权汇总所有 reward 项。"""
        total = (
            self.cfg.w_root_pos * rewards["global_anchor_pos"]
            + self.cfg.w_root_ori * rewards["global_anchor_ori"]
            + self.cfg.w_body_pose * rewards["relative_body_pose"]
            + self.cfg.w_velocity * rewards["body_velocity"]
            + rewards["self_collision"]
        )
        return total

每项 reward 的调参指南:

参数 默认值 如何调 调参信号
sigma_root_pos 0.5 m 机器人漂移严重→减小;不收敛→增大 root_pos_error 均值
sigma_root_ori 0.3 rad 面朝错方向→减小 root_ori_error 均值
sigma_body_pose 0.5 rad 姿态不精确→减小 joint_pos_error 分布
sigma_velocity 1.0 m/s 动作不流畅→减小;抖动→增大 body_vel_error 均值
w_root_pos 2.0 最重要的权重,确保全局位置正确
w_root_ori 1.5 朝向正确比位置稍次要
w_body_pose 1.5 与 root_ori 同等重要
w_velocity 0.5 辅助项,权重最低

完整的 mjlab Tracking 训练流程

从数据准备到训练完成的端到端流程:

# ============================================
# Step 0: 环境准备
# ============================================
pip install mjlab --break-system-packages

# ============================================
# Step 1: 准备动作数据
# ============================================
# 假设你有一个 retarget 好的 CSV 文件
python scripts/tracking/csv_to_npz.py \
    --input g1_walk_retargeted.csv \
    --output g1_walk.npz \
    --source-fps 30 \
    --target-fps 50 \
    --render  # 可视化检查

# ============================================
# Step 2: 上传到 WandB(可选,也可用本地路径)
# ============================================
wandb artifact put \
    --name my-project/motions/g1_walk \
    --type motions \
    g1_walk.npz

# ============================================
# Step 3: 训练 tracking 策略
# ============================================
uv run train Mjlab-Tracking-Flat-Unitree-G1 \
    --registry-name my-project/motions/g1_walk \
    --env.scene.num-envs 4096 \
    --agent.max-iterations 20000 \
    --agent.logger wandb

# ============================================
# Step 4: 评估
# ============================================
uv run play Mjlab-Tracking-Flat-Unitree-G1 \
    --checkpoint ./logs/Mjlab-Tracking-Flat-Unitree-G1/best_model.pt \
    --num-envs 1 \
    --render

训练过程中应该监控的 WandB 指标:

# 在 WandB 中应该创建以下自定义 panel
monitored_metrics = {
    # ---- Reward 分项 ----
    "reward/global_anchor_pos": "应从 ~0.2 上升到 >0.7",
    "reward/global_anchor_ori": "应从 ~0.3 上升到 >0.6",
    "reward/relative_body_pose": "应从 ~0.1 上升到 >0.5",
    "reward/body_velocity": "最难提升,>0.4 就不错",
    "reward/self_collision": "应接近 0(无碰撞)",

    # ---- Tracking 精度 ----
    "metrics/mean_joint_pos_error_deg": "应 <15°",
    "metrics/mean_root_pos_error_m": "应 <0.1m",
    "metrics/mean_root_ori_error_deg": "应 <20°",

    # ---- 训练健康度 ----
    "train/value_loss": "应持续下降",
    "train/mean_episode_length": "应接近动作总帧数",
}

八阶段训练协议:从调试到导出的标准化流程

Motion imitation 训练不应该"一口气跑到底"。以下八个阶段是经过实战验证的标准化流程——每个阶段有明确的配置、指标和退出条件。按顺序完成可以把问题定位在最早的阶段,避免在后期发现"原来是数据就有问题"的痛苦。

这类似于火箭发射的逐级检查——不能跳过"燃料加注检查"直接点火。

阶段 配置 主要指标 退出条件
1. 单帧调试 start 采样、zero agent(不训练) ghost/current 姿态对齐 body 和 anchor 位置可解释
2. 短片段 单条 motion、固定起点 anchor/body error 无数据契约错误
3. 完整片段 uniform 采样(RSI) MPJPE、episode length episode 能走完 80%+ 帧
4. 终止条件 打开关键 termination termination histogram 只有合理终止,无数据 bug 导致的终止
5. Adaptive 采样 失败 bin 采样 entropy、top1 bin 训练集中在真实难点而非随机失败
6. 多 motion 按动作族分层 分 motion 成功率 不是只记住了单条 motion
7. 轻量 DR friction、COM、encoder noise 回退幅度 <15% 风格不因 DR 失真
8. 导出评估 checkpoint + ONNX + 视频 四类评估指标 可复现的完整评估包

阶段 1-2 的目的是排除数据问题。 Zero agent(策略输出全零)下让 MuJoCo 回放参考动作——如果 ghost(参考)和 current(机器人)的姿态不对齐,说明 retarget 数据或 anchor 配置有问题。这两个阶段不需要任何训练,只需要可视化检查。

阶段 3-4 的目的是确认基础训练可行。 单 motion + RSI 是最简单的训练配置。如果在这个配置下 episode length 不能接近动作总帧数,说明 reward 权重或 termination 有问题——不要在此基础上叠加更多复杂性。

阶段 5-6 是从"单条动作"扩展到"多动作库"。 Adaptive 采样把训练资源集中到策略最薄弱的动作片段。但注意:如果在阶段 3 还没收敛就开 adaptive,它会把采样集中到"随机失败"而非"动作难点"——此时所有片段的失败率都高,高失败率不代表动作难。

阶段 7-8 是部署准备。 轻量 DR 让策略对参数变化鲁棒,但不应该太激进——tracking task 的首要目标是"精确模仿"而非"鲁棒走路"。导出时必须同时保存 ONNX 权重、metadata(obs/action 归一化参数)和评估视频。

四类评估指标: 只看 episode reward 不足以判断动作模仿是否成功,至少记录四类指标:

类别 指标 合格范围(G1 行走)
几何误差 MPJPE (mean per-joint position error)、root pos/ori error MPJPE < 5cm、root ori < 15°
速度误差 root velocity error、joint velocity error root vel < 0.3 m/s
接触质量 foot slip distance、unexpected contact count slip < 2cm/step
策略平滑度 action rate (L2)、joint acceleration proxy action_rate < 0.5

⚠️ MPJPE 好不代表模仿成功。 策略可能 MPJPE 很低但脚底一直在滑、动作抖动严重——这些在几何指标上看不出来,必须同时检查接触质量和平滑度。

四元数距离计算:motion imitation 的核心几何操作

tracking reward 中最容易出错的是旋转距离的计算。两个四元数 \(q_1\)\(q_2\) 之间的旋转距离不能直接做减法——因为四元数空间不是欧几里得空间,且 \(q\)\(-q\) 表示同一个旋转(double cover 问题)。

import torch

def quat_distance(q1: torch.Tensor, q2: torch.Tensor) -> torch.Tensor:
    """
    计算两个四元数之间的旋转距离。
    输入: q1, q2 shape (..., 4),wxyz 格式
    输出: 旋转角度 shape (...),单位弧度
    """
    # 处理 double cover:确保 q1·q2 > 0
    dot = (q1 * q2).sum(dim=-1)
    q2 = torch.where(dot.unsqueeze(-1) < 0, -q2, q2)

    # 重新计算 dot(处理后)
    dot = (q1 * q2).sum(dim=-1).clamp(-1.0, 1.0)

    # 旋转角度 = 2 * arccos(|dot|)
    angle = 2.0 * torch.acos(dot.abs())
    return angle

这段代码有三个关键工程细节:

  1. Double cover 处理:如果 q1·q2 < 0,翻转 q2-q2(表示同一旋转但在四元数球面上更近的表示)
  2. clamp(-1, 1):浮点误差可能导致 dot product 略超出 [-1, 1],arccos 对此未定义
  3. abs(dot):取绝对值确保角度总是正的

本质洞察: 四元数距离计算中的 double cover 处理不是"数学上的优雅",而是"工程上的必需"。不处理的话,两个非常接近的旋转可能被计算为 π 弧度(最大距离),因为它们在四元数球面上恰好在对跖点附近。这个 bug 会导致 tracking reward 突然跳变,策略训练不稳定。

⚠️ 常见陷阱

⚠️ 编程陷阱:body 顺序不匹配。 mjlab 中 motion_relative_body_pose_error_exp 期望参考数据的 body 顺序与 MuJoCo 模型中的 body 顺序一致。如果 retarget 工具输出的 body 顺序不同(例如 "left_hip, left_knee, left_ankle, right_hip, ..." vs "left_hip, right_hip, left_knee, right_knee, ..."),每个 body 的误差会和错误的参考值比较。自检方法:在 MuJoCo 中可视化第 0 帧的 retarget 结果,检查所有肢体是否在正确位置。

⚠️ 编程陷阱:velocity reward 中 σ 太小导致"急停急走"。 如果 body_velocity reward 的 σ 设得很小(如 0.1),策略会非常激进地匹配速度——导致关节力矩剧烈波动,真机上不可执行。推荐 σ ≥ 0.5 m/s。

练习

  1. [分析题] BeyondMimic 的五项 reward 中,如果去掉 body_velocity 项(只保留位置和朝向),策略的行为会如何变化?提示:考虑"停在正确位置"vs"以正确速度经过正确位置"的区别。
  2. [编码题] 实现一个 compute_tracking_metrics 函数,输入策略 rollout 和参考动作,输出每个 body 的 position error 和 orientation error 的 histogram。用这个函数比较两个策略(一个有 RSI,一个没有)的跟踪精度。
  3. [跨章综合题] 结合 Ch08(DR)和本节的 tracking reward:如果在 tracking 训练中加入激进的 DR(摩擦 U(0.2, 2.0)),策略的 tracking 精度和鲁棒性会分别如何变化?存在权衡吗?

显式跟踪方法对于精确复现单条动作非常有效。但如果你的目标不是"精确复现"而是"看起来自然"——例如让机器人在任意速度命令下都保持人类步态风格——那么对抗式风格约束(AMP)是更好的选择。下一节讲解 AMP 判别器的工程实现。

10.4 AMP 判别器训练工程 ⭐⭐⭐

这一节解决什么问题:AMP 的判别器训练是模仿学习工程中最容易失败的环节。本节从数据流、网络设计到稳定性技巧,完整讲解如何在 ProtoMotions 和 TienKung-Lab 中配置和调试 AMP。

动机:从"精确跟踪"到"风格约束"

显式跟踪需要帧级别的参考数据对齐——策略在每个时间步都知道"我应该在哪"。但很多场景不需要也不适合帧级跟踪:

  • 速度命令 + 自然步态:你想让机器人以 1.0 m/s 前进,但不指定具体的腿如何摆动——只要"看起来自然"就行
  • 多任务控制:机器人可能需要在行走的同时做其他任务(抓取、搬运),腿部运动需要"像人类"但不需要精确跟踪某条特定动作

AMP 的核心思路:用判别器替代显式的 tracking reward。判别器从参考动作数据集中学习"什么是自然的运动",然后把这个判断作为 reward 信号反馈给策略。

如果不用 AMP 而是直接加 style penalty 会怎样

一种朴素的替代方案是手工设计 style reward(比如惩罚膝盖内扣、奖励对称步态)。这种方法的问题是:

  1. 你需要为每种"不自然"的行为设计一个惩罚项——这需要大量的人工观察和 reward engineering
  2. style penalty 是"告诉策略什么不对",但不告诉它"什么是对的"——策略可能找到一种满足所有惩罚但仍然不自然的运动
  3. 不同机器人的"自然运动"标准不同——四足和人形的 style reward 完全不同

AMP 用数据驱动的方式解决这些问题:判别器从参考数据中自动学习"自然"的标准,不需要人工设计。

AMP 的核心数据流

┌─────────────────────────────────────────────────────────────┐
│                     AMP 训练循环                              │
│                                                              │
│  ┌──────────────┐    rollout     ┌──────────────────────┐    │
│  │   Actor      │──────────────→│  Environment          │    │
│  │   (PPO)      │←── reward ────│  (策略生成的 transitions) │    │
│  └──────────────┘               └───────────┬──────────┘    │
│         ↑                                    │               │
│     task reward                        (s, s') pairs        │
│    + AMP reward                              │               │
│         ↑                                    ▼               │
│  ┌──────────────┐               ┌──────────────────────┐    │
│  │ Discriminator │←── fake ─────│  Replay Buffer        │    │
│  │   D(s, s')    │←── real ─────│  (50% recent rollout   │    │
│  └──────────────┘               │   50% reference data)  │    │
│                                 └──────────────────────┘    │
└─────────────────────────────────────────────────────────────┘

关键理解:AMP 的 reward 有两部分。

\[r_t = w_{\text{task}} \cdot r_{\text{task}} + w_{\text{style}} \cdot r_{\text{style}}\]
  • \(r_{\text{task}}\):标准的 task reward(如速度跟踪、目标位置)
  • \(r_{\text{style}} = \max\!\left[0,\; 1 - 0.25\,\big(D(s_t, s_{t+1}) - 1\big)^2\right]\):来自判别器的风格 reward(AMP 用 LSGAN 形式,\(D\) 为回归输出,对参考数据约为 \(+1\)、对策略数据约为 \(-1\),非概率)

策略同时优化两个目标:完成任务 + 运动风格自然。权重 \(w_{\text{style}}\) 控制"自然度"在整体 reward 中的占比——设得太高,策略为了"好看"而放弃任务性能;设得太低,判别器的信号被任务 reward 淹没。

判别器网络设计

AMP 判别器的输入是相邻两帧的状态对 \((s_t, s_{t+1})\)。状态通常包括:

# AMP 判别器的输入特征(ProtoMotions 默认配置)
discriminator_obs = [
    "joint_pos",          # 所有关节角度
    "joint_vel",          # 所有关节角速度
    "root_height",        # 根节点高度
    "root_rot",           # 根节点朝向(局部坐标系)
    "root_ang_vel",       # 根节点角速度
    "key_body_pos",       # 关键 body 位置(手/脚/头)
]
# 不包含 root_xy_pos(位移不变性)和 root_lin_vel_xy(速度不变性)
# 这确保判别器关注的是"运动风格"而不是"走到哪里"

为什么不包含 root 的 xy 位置和 xy 速度? 因为判别器应该学习的是"运动模式"(步态、摆臂、重心转移),而不是"在哪里运动"或"运动多快"。如果包含了 root xy 位置,判别器会学到"参考数据总是从原点开始"——这不是有用的风格信息。排除 root xy 信息让判别器的输出具有平移不变性速度不变性

# ProtoMotions 中判别器网络定义
class AMPDiscriminator(nn.Module):
    def __init__(self, obs_dim: int, hidden_dims=[1024, 512]):
        super().__init__()
        layers = []
        input_dim = obs_dim * 2  # 两帧拼接
        for h in hidden_dims:
            layers.extend([
                nn.Linear(input_dim, h),
                nn.ReLU(),
            ])
            input_dim = h
        layers.append(nn.Linear(input_dim, 1))
        self.net = nn.Sequential(*layers)

    def forward(self, s_t: torch.Tensor, s_tp1: torch.Tensor) -> torch.Tensor:
        """
        输入两帧状态,输出 LSGAN 回归值(未经 sigmoid,非概率)。
        AMP 训练让它对参考数据回归到 +1、对策略数据回归到 -1。
        输出越接近 +1 → 判别器越认为是"真"的(来自参考数据)。
        """
        x = torch.cat([s_t, s_tp1], dim=-1)
        return self.net(x)

判别器训练:五个稳定性技巧

AMP 判别器的训练容易出现模式坍塌(discriminator 完美区分真假 → reward 信号消失 → 策略不再改进 → discriminator 更容易区分 → 恶性循环)。这类似于考试中的"分数膨胀":如果评分标准太严格(判别器太强),所有学生都拿零分——学生无法从成绩中得知如何改进。以下五个工程技巧确保判别器保持"适度的区分能力":

五个技巧总览:

# 技巧 目的 关键参数
1 Gradient Penalty (R1) 限制判别器梯度幅度 λ_gp = 10
2 更新比例 1:1 防止判别器领先 actor 太多
3 Replay Buffer 50/50 平衡真假数据 buf_size = 100K
4 低学习率 减慢判别器学习速度 disc_lr = 3e-5
5 共享 Normalizer 防止凭归一化差异区分

技巧 1:Gradient Penalty(R1 正则化)

def gradient_penalty(discriminator, real_data, lambda_gp=10.0):
    """R1 gradient penalty,防止判别器过于自信。"""
    real_data.requires_grad_(True)
    logits = discriminator(real_data[:, :obs_dim], real_data[:, obs_dim:])
    grad = torch.autograd.grad(
        logits.sum(), real_data,
        create_graph=True, retain_graph=True,
    )[0]
    penalty = (grad.norm(2, dim=-1) ** 2).mean()
    return lambda_gp * penalty

Gradient penalty 的直觉:它惩罚判别器在真实数据附近的梯度幅度。如果判别器在真实数据点处的梯度很大,说明判别器的 decision boundary 非常"尖锐"——小的扰动就会改变判断。这种尖锐的 boundary 意味着策略很难从判别器的 reward 中获得有用的梯度信息。

技巧 2:判别器和 actor 的更新比例为 1:1

# 在 PPO 训练循环中,每次 actor 更新后更新一次判别器
for epoch in range(ppo_epochs):
    # PPO actor update
    update_actor(actor, batch)

    # Discriminator update (1:1 ratio)
    disc_batch = sample_disc_batch(replay_buffer, reference_data)
    update_discriminator(discriminator, disc_batch)

不同于 WGAN-GP 的多步判别器更新(通常 5:1),AMP 使用 1:1。原因是 RL 中 actor 的分布变化远慢于 GAN 的 generator(PPO 的 clipping 限制了策略变化幅度),判别器不需要过度拟合当前分布。

技巧 3:Replay Buffer 混合 50% recent + 50% reference

class AMPReplayBuffer:
    def __init__(self, capacity=100000):
        self.policy_buffer = deque(maxlen=capacity)
        self.reference_data = None  # 预加载的参考数据

    def sample(self, batch_size):
        half = batch_size // 2
        # 50% 来自策略最近的 rollout("fake" data)
        policy_batch = random.sample(self.policy_buffer, half)
        # 50% 来自参考动作数据集("real" data)
        ref_indices = np.random.randint(0, len(self.reference_data), half)
        ref_batch = self.reference_data[ref_indices]
        return policy_batch, ref_batch

技巧 4:判别器使用更低的学习率

# 典型的 AMP 超参数
actor_lr = 1e-4
discriminator_lr = 3e-5  # 比 actor 低 ~3x

这防止判别器学得太快而 actor 跟不上——如果判别器迅速收敛到完美区分真假,reward 信号会变成一个几乎恒定的低值(策略 rollout 总是被判为"假"),PPO 的 policy gradient 方向不明确。

工程提示: 判别器 LR 和 actor LR 的比例是 AMP 训练中最重要的超参数之一。如果 disc accuracy 一直在 >0.95,首先尝试把 disc_lr 从 3e-5 降到 1e-5。如果仍然太高,加大 gradient penalty(从 10 增到 30)。

技巧 5:Observation normalization 必须共享

# ❌ 错误:actor 和 discriminator 各自维护独立的 normalizer
actor_normalizer = RunningMeanStd(obs_dim)
disc_normalizer = RunningMeanStd(obs_dim)  # 看到不同分布的数据

# ✅ 正确:共享同一个 normalizer
shared_normalizer = RunningMeanStd(obs_dim)
actor.set_normalizer(shared_normalizer)
discriminator.set_normalizer(shared_normalizer)

这是 AMP 训练中最常见的沉默失败模式——Ch09 的 obs normalization 专题中已经提到。如果 actor 和判别器使用不同的 normalizer,判别器可能仅凭归一化差异就能区分 actor rollout 和参考数据,而不是凭运动质量区分。

在 ProtoMotions 中配置 AMP 训练

⚠️ 版本提示(重要):当前 ProtoMotions(ProtoMotions3)的训练入口与配置体系已经从 Hydra/YAML 改为 Python dataclass experiment files。当前官方命令形如: bash python protomotions/train_agent.py \ --experiment-path examples/experiments/amp/mlp.py \ --robot-name g1 --simulator isaacgym \ --motion-file path/to/amass_motionlib.pt \ --experiment-name g1_amp 训练/推理的 motion 输入是打包好的 MotionLib .pt(不是 .npz),推理脚本是 inference_agent.py,参数覆盖用 --overrides,且 resolved_configs.pt 才是配置的 source of truth。本节下面沿用的 python protomotions/train.py +exp=... / eval_agent.py / .npz 写法是较早的 Hydra/PhysAnim 风格,仅用于讲解概念;落地时请以你所用 ProtoMotions 版本的官方文档为准。

为讲解判别器数据流与稳定性技巧,下面仍以(旧式)Hydra config 形式展示 AMP 配置:

# 在 ProtoMotions 中运行 AMP 训练(旧式 Hydra 风格,仅作概念示意)
python protomotions/train.py \
    +exp=amp/flat_terrain \
    +robot=smpl \
    +simulator=isaacgym \
    motion_file=data/amass/CMU/01_01.npz \
    agent.config.disc_lr=3e-5 \
    agent.config.disc_grad_penalty=10.0 \
    agent.config.style_reward_weight=0.5

ProtoMotions 的类继承关系决定了配置的扩展方式:

BaseAgent → PPO → AMP → ASE
                     ↓
                PPO → Mimic/ADD → MaskedMimic

从 PPO 升级到 AMP 只需要: 1. 增加判别器网络配置 2. 增加参考数据加载配置 3. 增加 replay buffer 配置 4. 修改 reward 计算(加入 style reward)

# ProtoMotions AMP 配置关键字段
agent:
  config:
    # ---- 判别器配置 ----
    disc_hidden_dims: [1024, 512]
    disc_lr: 3e-5
    disc_grad_penalty: 10.0
    disc_logit_reg: 0.05
    disc_reward_weight: 0.5

    # ---- Replay Buffer ----
    replay_buffer_size: 100000
    replay_buf_ratio: 0.5       # 50% policy / 50% reference

    # ---- 参考数据 ----
    motion_file: "data/amass/CMU/walking.npz"

ProtoMotions 支持多个后端仿真器(IsaacGym, Isaac Lab, Genesis, MuJoCo),通过 +simulator= 参数切换:

# 使用 Isaac Lab 后端
python protomotions/train.py \
    +exp=amp/flat_terrain \
    +robot=g1 \
    +simulator=isaaclab \
    motion_file=data/retargeted/g1_walk.npz

Isaac Lab 原生的 AMP 集成

除了通过 ProtoMotions 间接使用 Isaac Lab,你也可以在 Isaac Lab 中直接实现 AMP。Isaac Lab 的 manager-based 架构为 AMP 提供了天然的集成点——判别器可以作为一个自定义的 reward term 接入 RewardManager。

以下是在 Isaac Lab 中实现 AMP reward term 的核心代码:

# Isaac Lab 中自定义 AMP reward term
from omni.isaac.lab.managers import RewardTermCfg
import torch.nn as nn

def amp_style_reward(
    env,
    discriminator: nn.Module,
    shared_normalizer,
) -> torch.Tensor:
    """
    AMP 判别器作为 Isaac Lab 的 reward term。
    每个时间步返回 style reward。
    """
    # 获取当前帧和上一帧的 discriminator observation
    # 这些 obs 不包含 root_xy(平移不变性)
    obs_t = env.observation_manager.compute_group("disc_obs")
    obs_prev = env._prev_disc_obs  # 上一帧需要手动缓存

    # 归一化(必须和 actor 共享同一个 normalizer)
    obs_t_norm = shared_normalizer(obs_t)
    obs_prev_norm = shared_normalizer(obs_prev)

    # 判别器前向(LSGAN 回归输出,非概率)
    with torch.no_grad():
        d = discriminator(obs_prev_norm, obs_t_norm)
        # AMP 的 LSGAN style reward:r = max[0, 1 - 0.25*(D - 1)^2]
        reward = torch.clamp(1.0 - 0.25 * (d - 1.0) ** 2, min=0.0)

    return reward.squeeze(-1)


# 在 Isaac Lab 的 RewardsCfg 中注册
@configclass
class RewardsCfg:
    """Reward terms for AMP-augmented velocity tracking."""

    velocity_tracking = RewardTermCfg(
        func=mdp.velocity_tracking_exp_signed,
        weight=1.0,
        params={"command_name": "base_velocity", "std": 0.5},
    )
    amp_style = RewardTermCfg(
        func=amp_style_reward,
        weight=0.5,  # style reward 权重
        params={
            "discriminator": None,        # 在训练脚本中注入
            "shared_normalizer": None,    # 在训练脚本中注入
        },
    )
    # 正则化项仍然保留
    action_rate = RewardTermCfg(
        func=mdp.action_rate_l2, weight=-0.01,
    )

Isaac Lab 原生集成需要额外实现:(1) 判别器的训练循环(在 PPO 更新后执行),(2) replay buffer 管理,(3) 参考数据加载。这些在 ProtoMotions 中已经内置,但在 Isaac Lab 中需要自定义 training script。

以下是 Isaac Lab 中 AMP 训练脚本的骨架:

# train_amp_isaaclab.py — Isaac Lab 原生 AMP 训练骨架
from omni.isaac.lab_tasks.utils import parse_env_cfg
from rsl_rl.runners import OnPolicyRunner

def main():
    # 注意:Isaac Lab 官方环境列表只有 Isaac-Velocity-Rough-G1-v0,并没有 ...-AMP-v0;
    # 下面这个带 AMP 的 task id 需要你以官方环境为基础自行注册(自定义 obs group + 判别器观测)
    env_cfg = parse_env_cfg("Isaac-Velocity-Rough-G1-AMP-v0")
    env = gym.make("Isaac-Velocity-Rough-G1-AMP-v0", cfg=env_cfg)

    # 初始化判别器和训练器
    disc = AMPDiscriminator(obs_dim=disc_obs_dim, hidden_dims=[1024, 512])
    disc_trainer = AMPDiscriminatorTrainer(disc, disc_obs_dim, disc_cfg)

    # 加载参考数据
    ref_data = load_reference_transitions("data/amass/walking.npz")

    # 共享 normalizer
    shared_norm = RunningMeanStd(disc_obs_dim)
    disc_trainer.set_shared_normalizer(shared_norm)

    # 标准 PPO runner(RSL-RL)
    runner = OnPolicyRunner(env, train_cfg, ...)

    for iteration in range(max_iterations):
        # 1. rollout
        rollout = runner.collect_rollout()

        # 2. 计算 AMP reward 并融合
        amp_reward = disc_trainer.compute_disc_reward(
            rollout.prev_obs, rollout.obs
        )
        rollout.rewards += style_weight * amp_reward

        # 3. PPO 更新
        runner.update(rollout)

        # 4. 判别器更新(1:1 比例)
        disc_trainer.add_policy_data(rollout.transitions)
        disc_stats = disc_trainer.update(ref_data)

        # 5. 日志
        if iteration % 50 == 0:
            print(f"Iter {iteration}: disc_acc={disc_stats['disc/accuracy']:.2f}")

Isaac Lab vs ProtoMotions 的选择指南:

场景 推荐方案 理由
快速 AMP/ASE/CALM 对比实验 ProtoMotions 一行配置切换
自定义判别器架构或损失 Isaac Lab 原生 完全控制训练循环
需要与 Isaac Lab 其他 extension 集成 Isaac Lab 原生 避免框架冲突
使用 MaskedMimic ProtoMotions 唯一实现
研究新的 AMP 变体 Isaac Lab 原生 修改灵活

在 TienKung-Lab 中的 AMP + Periodic Reward 融合

TienKung-Lab(Open-X-Humanoid/TienKung-Lab)展示了 AMP 的一个重要工程变体:AMP style reward 与手工 periodic gait reward 的融合。这个组合在全身人形机器人上效果显著——AMP 提供整体风格约束,periodic reward 提供额外的步态节奏约束。

# TienKung-Lab 的融合 reward(概念化)
reward = (
    w_task * r_velocity_tracking          # 速度跟踪
    + w_amp * r_amp_style                  # AMP 风格 reward
    + w_periodic * r_periodic_gait         # 周期步态 reward
    + w_reg * r_regularization             # 正则化
)
# 典型权重:w_task=1.0, w_amp=0.5, w_periodic=0.3, w_reg=0.1

TienKung-Lab 的 retarget 工具 scripts/smplx_to_robot.py 是从 SMPL-X 到自定义人形的现成参考实现——它处理了脊柱聚合、手指忽略和肩部偏移等常见问题。

AMP 判别器训练的完整代码

以下是 AMP 判别器训练循环的完整实现,涵盖所有五个稳定性技巧:

# amp_discriminator_trainer.py — AMP 判别器完整训练循环
import torch
import torch.nn as nn
from collections import deque
import numpy as np

class AMPDiscriminatorTrainer:
    """
    AMP 判别器训练器,集成五个稳定性技巧。
    """
    def __init__(self, disc: nn.Module, obs_dim: int, cfg):
        self.disc = disc
        self.obs_dim = obs_dim
        self.cfg = cfg

        # Replay buffer
        self.policy_buffer = deque(maxlen=cfg.replay_buffer_size)

        # 优化器(技巧 4:低学习率)
        self.optimizer = torch.optim.Adam(
            disc.parameters(), lr=cfg.disc_lr,  # 3e-5
        )

        # 共享的 normalizer(技巧 5)
        self.shared_normalizer = None  # 由外部设置

    def set_shared_normalizer(self, normalizer):
        """设置与 actor 共享的 normalizer。"""
        self.shared_normalizer = normalizer

    def add_policy_data(self, transitions: torch.Tensor):
        """添加策略 rollout 数据到 replay buffer。"""
        # transitions: (N, obs_dim * 2)  — 拼接的 (s_t, s_{t+1})
        for i in range(transitions.shape[0]):
            self.policy_buffer.append(transitions[i].cpu())

    def compute_disc_reward(self, s_t: torch.Tensor,
                            s_tp1: torch.Tensor) -> torch.Tensor:
        """
        计算 AMP style reward。
        策略用这个 reward 来优化"运动自然度"。
        """
        with torch.no_grad():
            # 归一化(使用共享 normalizer)
            if self.shared_normalizer is not None:
                s_t = self.shared_normalizer(s_t)
                s_tp1 = self.shared_normalizer(s_tp1)

            d = self.disc(s_t, s_tp1)
            # AMP 的 LSGAN style reward:r = max[0, 1 - 0.25*(D - 1)^2]
            # D→+1(判别器认为"像参考")时 r→1,远离则衰减并截断到 0
            reward = torch.clamp(1.0 - 0.25 * (d - 1.0) ** 2, min=0.0)
        return reward.squeeze(-1)

    def update(self, reference_data: torch.Tensor) -> dict:
        """
        一步判别器更新。

        Args:
            reference_data: (N, obs_dim * 2) 来自参考动作的 (s, s') 对
        Returns:
            训练统计
        """
        batch_size = self.cfg.disc_batch_size

        # ---- 技巧 3:50% policy + 50% reference ----
        half = batch_size // 2

        # 采样 policy data(fake)
        if len(self.policy_buffer) < half:
            return {"disc/skip": True}
        policy_indices = np.random.choice(
            len(self.policy_buffer), half, replace=False
        )
        fake_batch = torch.stack(
            [self.policy_buffer[i] for i in policy_indices]
        ).to(self.cfg.device)

        # 采样 reference data(real)
        ref_indices = np.random.randint(0, len(reference_data), half)
        real_batch = reference_data[ref_indices].to(self.cfg.device)

        # 拆分为 (s_t, s_{t+1})
        fake_s = fake_batch[:, :self.obs_dim]
        fake_sp = fake_batch[:, self.obs_dim:]
        real_s = real_batch[:, :self.obs_dim]
        real_sp = real_batch[:, self.obs_dim:]

        # 归一化
        if self.shared_normalizer is not None:
            fake_s = self.shared_normalizer(fake_s)
            fake_sp = self.shared_normalizer(fake_sp)
            real_s = self.shared_normalizer(real_s)
            real_sp = self.shared_normalizer(real_sp)

        # ---- 判别器损失(AMP 用 LSGAN 最小二乘,非 BCE 交叉熵)----
        fake_pred = self.disc(fake_s, fake_sp)
        real_pred = self.disc(real_s, real_sp)

        # LSGAN least-squares:参考数据回归到 +1,策略数据回归到 -1
        fake_loss = 0.5 * ((fake_pred + 1.0) ** 2).mean()  # fake → 目标 -1
        real_loss = 0.5 * ((real_pred - 1.0) ** 2).mean()  # real → 目标 +1
        disc_loss = fake_loss + real_loss

        # ---- 技巧 1:Gradient Penalty ----
        real_s.requires_grad_(True)
        real_sp.requires_grad_(True)
        real_pred_gp = self.disc(real_s, real_sp)
        grad = torch.autograd.grad(
            real_pred_gp.sum(), [real_s, real_sp],
            create_graph=True, retain_graph=True,
        )
        grad_penalty = sum((g ** 2).sum(dim=-1).mean() for g in grad)
        total_loss = disc_loss + self.cfg.disc_grad_penalty * grad_penalty

        # ---- 更新 ----
        self.optimizer.zero_grad()
        total_loss.backward()
        torch.nn.utils.clip_grad_norm_(self.disc.parameters(), 1.0)
        self.optimizer.step()

        # 统计
        with torch.no_grad():
            # LSGAN 回归输出以 0 为真假中点(参考→+1,策略→-1)
            fake_acc = (fake_pred < 0).float().mean().item()
            real_acc = (real_pred > 0).float().mean().item()

        return {
            "disc/loss": disc_loss.item(),
            "disc/grad_penalty": grad_penalty.item(),
            "disc/fake_acc": fake_acc,
            "disc/real_acc": real_acc,
            "disc/accuracy": (fake_acc + real_acc) / 2,
        }

关键监控指标和健康范围:

指标 健康范围 不健康的信号 对策
disc/accuracy 0.55 - 0.80 >0.95(判别器太强) 增大 λ_gp,降低 disc_lr
disc/accuracy 0.55 - 0.80 ~0.50(随机猜) 增大 disc_hidden_dims,检查数据格式
disc/grad_penalty <5.0 >50(梯度爆炸) 降低 λ_gp
disc/loss 持续下降后稳定 震荡不收敛 降低 disc_lr

本质洞察: AMP 判别器的理想状态是"稍微好于随机猜测但不完美"——accuracy 在 0.6-0.8 之间。完美的判别器(accuracy=1.0)意味着策略的运动和参考数据"完全不像",style reward 几乎为零,策略收不到有用的梯度。这就是为什么需要 gradient penalty——它刻意削弱判别器,防止它"学得太好"。

在 PPO 训练循环中整合 AMP

以下展示 AMP 如何与 PPO 训练循环整合(技巧 2:1:1 更新比例):

# amp_ppo_integration.py — AMP 与 PPO 的整合
def train_amp_epoch(runner, disc_trainer, reference_data):
    """
    一个 AMP+PPO 训练 epoch。
    技巧 2:每次 PPO 更新后更新一次判别器。
    """
    # ---- Phase 1:Rollout ----
    rollout_data = runner.rollout(num_steps=runner.cfg.num_steps_per_env)

    # 收集 (s_t, s_{t+1}) 对并添加到 replay buffer
    transitions = torch.cat([
        rollout_data.obs[:-1],   # s_t
        rollout_data.obs[1:],    # s_{t+1}
    ], dim=-1)
    disc_trainer.add_policy_data(transitions)

    # ---- Phase 2:计算 AMP reward ----
    with torch.no_grad():
        amp_reward = disc_trainer.compute_disc_reward(
            rollout_data.obs[:-1], rollout_data.obs[1:]
        )
        # 融合 task reward 和 AMP reward
        combined_reward = (
            runner.cfg.task_reward_weight * rollout_data.rewards
            + runner.cfg.style_reward_weight * amp_reward
        )
        rollout_data.rewards = combined_reward

    # ---- Phase 3:PPO 更新 ----
    ppo_stats = runner.update_ppo(rollout_data)

    # ---- Phase 4:判别器更新(1:1 比例)----
    disc_stats = disc_trainer.update(reference_data)

    # ---- 合并统计 ----
    return {**ppo_stats, **disc_stats}

这个整合代码展示了 AMP 训练的核心数据流:rollout → 收集 transitions → 计算 AMP reward → 融合 reward → PPO 更新 → 判别器更新。整个循环在每个 iteration 执行一次。

⚠️ 常见陷阱

⚠️ 编程陷阱:判别器 input 中包含了 root xy 位置。 如果判别器看到 root xy,它会学到"参考数据在特定位置"——这不是风格信息。去掉 root xy 和 root xy 速度后重新训练。

⚠️ 编程陷阱:gradient penalty 的 lambda 太大。 λ_gp > 50 会导致判别器几乎无法学习(梯度被严重惩罚),reward 信号退化为随机噪声。从 λ_gp = 10 开始,如果判别器太强才逐步增大。

🧠 思维陷阱:AMP reward 可以完全替代 task reward。 AMP 的 style reward 只约束"运动风格",不约束"完成任务"。如果没有 task reward,策略会学会非常优雅地站在原地(因为站立也是一种"自然"运动)。AMP reward 是 task reward 的补充,不是替代。

练习

  1. [分析题] AMP 判别器使用 ReLU 而非 LeakyReLU/ELU。如果改用 LeakyReLU,对判别器的梯度流有什么影响?对 AMP reward 的质量有什么影响?
  2. [调参题] 在 ProtoMotions 中运行 AMP 训练,分别设置 disc_reward_weight 为 0.1、0.5 和 2.0。观察 WandB 中 (a) task reward (b) style reward (c) discriminator accuracy 三条曲线的变化。最佳的权重应该让 discriminator accuracy 维持在什么范围?
  3. [跨章综合题] 结合 Ch09 的 obs normalization 讨论:如果 AMP 的 actor 和 discriminator 使用了不同的 normalizer,discriminator 能通过哪些"shortcut"来区分真假?这会导致什么训练症状?

AMP 学到的是"整体风格",但如果你想让策略掌握多种不同的技能(走路、跑步、跳跃)并能在运行时切换,需要 ASE 的潜在技能空间。下一节简要介绍 ASE 的工程实现和 ProtoMotions 中的配置切换。

10.5 ASE 潜在技能空间与 CALM/MaskedMimic ⭐⭐

这一节解决什么问题:从 AMP 的"单一风格"扩展到 ASE 的"多技能切换"、CALM 的"文本条件控制"和 MaskedMimic 的"动作补全"。讲解 ProtoMotions 中 AMP → ASE → CALM → MaskedMimic 的配置切换方式和工程差异。

动机:AMP 只学一种"风格"

AMP 的判别器从参考数据集中学习一种统一的风格。但如果数据集包含走路、跑步和跳跃三种运动,AMP 学到的是三种的"平均风格"——策略可能产生一种"半走半跑"的奇怪运动。ASE(Adversarial Skill Embeddings, Peng et al., SIGGRAPH 2022)解决了这个问题。

如果不用 ASE 而是为每种运动训练独立的 AMP 会怎样

一种替代方案是:为走路训练一个 AMP,为跑步训练另一个 AMP。但这有两个问题:(1) N 种运动需要 N 个独立的判别器和策略,内存和计算成本线性增长;(2) 运动之间的过渡(从走到跑)需要额外的切换逻辑——两个独立策略不知道如何平滑过渡。ASE 用一个共享的 latent space 解决了这两个问题。

ASE 的核心架构

ASE 在 AMP 的基础上增加了一个 latent skill code z

AMP:   (s, s') → D → reward
ASE:   (s, s', z) → D_z → reward,其中 z ~ encoder(motion_clip)

Encoder 从每条动作 clip 中提取一个 latent code z(如 64 维向量),不同的 clip 映射到 latent 空间中的不同位置。策略的输入增加了 z——给定不同的 z,策略会产生不同的运动风格。

# ASE 的核心组件
class SkillEncoder(nn.Module):
    """从 motion clip 的 (s, s') 序列提取 latent skill code z。"""
    def __init__(self, obs_dim: int, latent_dim: int = 64):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(obs_dim * 2, 512),   # 输入是 (s_t, s_{t+1}) 拼接
            nn.ReLU(),
            nn.Linear(512, 256),
            nn.ReLU(),
        )
        self.mu_head = nn.Linear(256, latent_dim)
        self.logvar_head = nn.Linear(256, latent_dim)

    def forward(self, s_t, s_tp1):
        """编码 (s, s') → z(VAE 风格)。"""
        x = torch.cat([s_t, s_tp1], dim=-1)
        h = self.encoder(x)
        mu = self.mu_head(h)
        logvar = self.logvar_head(h)
        # 重参数化采样
        std = torch.exp(0.5 * logvar)
        z = mu + std * torch.randn_like(std)
        return z, mu, logvar


class ASEPolicy(nn.Module):
    """ASE 策略:输入 obs + latent z → action。"""
    def __init__(self, obs_dim: int, latent_dim: int, action_dim: int):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim + latent_dim, 1024),
            nn.ELU(),
            nn.Linear(1024, 512),
            nn.ELU(),
            nn.Linear(512, 256),
            nn.ELU(),
            nn.Linear(256, action_dim),
        )

    def forward(self, obs, z):
        x = torch.cat([obs, z], dim=-1)
        return self.net(x)


class ASEDiscriminator(nn.Module):
    """ASE 判别器:条件化 on z。"""
    def __init__(self, obs_dim: int, latent_dim: int):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim * 2 + latent_dim, 1024),  # (s, s', z)
            nn.ReLU(),
            nn.Linear(1024, 512),
            nn.ReLU(),
            nn.Linear(512, 1),
        )

    def forward(self, s_t, s_tp1, z):
        x = torch.cat([s_t, s_tp1, z], dim=-1)
        return self.net(x)

ASE 的训练流程(与 AMP 的差异)

ASE 在 AMP 基础上增加了三个训练组件:

  1. Encoder 训练:让 encoder 能从 motion clip 中提取有意义的 z
  2. Diversity loss:鼓励不同的 z 产生不同的运动(防止 mode collapse)
  3. Mutual Information reward:策略执行时的 (s, s') 与 z 之间的互信息越高越好
# ASE 训练循环的额外步骤(相对于 AMP)
def ase_training_step(encoder, policy, disc, batch, cfg):
    """一步 ASE 训练,包含 AMP 全部内容 + ASE 特有部分。"""

    # ---- 1. AMP 部分:判别器更新 + style reward(同 10.4 节)----
    # ... (省略,与 AMP 完全相同)

    # ---- 2. ASE 特有:Encoder 更新 ----
    # 从参考 motion clip 采样 (s, s') 对
    ref_s, ref_sp = sample_reference_transitions(batch)
    z, mu, logvar = encoder(ref_s, ref_sp)

    # VAE loss = reconstruction + KL divergence
    # 重建:z 应该让判别器认为 (s, s') 是"真的"
    recon_logits = disc(ref_s, ref_sp, z)
    recon_loss = -recon_logits.mean()  # 最大化判别器对真实数据的输出

    # KL:z 的分布应接近标准正态
    kl_loss = -0.5 * (1 + logvar - mu.pow(2) - logvar.exp()).sum(dim=-1).mean()

    encoder_loss = recon_loss + cfg.kl_weight * kl_loss

    # ---- 3. ASE 特有:Diversity reward ----
    # 采样两个不同的 z,执行后检查 (s, s') 的差异
    z1 = torch.randn(batch_size, cfg.latent_dim, device=cfg.device)
    z2 = torch.randn(batch_size, cfg.latent_dim, device=cfg.device)
    # diversity_reward 鼓励 |z1 - z2| 大时 |(s,s')_z1 - (s,s')_z2| 也大
    # 实现上通常通过 mutual information estimator

    return {
        "ase/encoder_loss": encoder_loss.item(),
        "ase/kl_loss": kl_loss.item(),
    }

在 ProtoMotions 中从 AMP 切换到 ASE

ProtoMotions 的类继承 BaseAgent → PPO → AMP → ASE 意味着从 AMP 到 ASE 只需要修改配置:

# AMP 训练
python protomotions/train.py +exp=amp/flat_terrain \
    +robot=smpl +simulator=isaacgym \
    motion_file=data/amass/CMU/walking.npz

# ASE 训练(只改 +exp)
python protomotions/train.py +exp=ase/flat_terrain \
    +robot=smpl +simulator=isaacgym \
    motion_file=data/amass/CMU/multi_skill.npz  # 多技能数据集

配置差异(ASE 相对 AMP 新增的字段):

# ASE 额外配置
agent:
  config:
    # ---- Skill Encoder ----
    latent_dim: 64
    encoder_hidden_dims: [512, 256]
    kl_weight: 0.01

    # ---- Diversity Loss ----
    diversity_bonus: 0.1    # 鼓励不同 z 产生不同运动
    mi_reward_weight: 0.5   # mutual information reward 权重

CALM:基于 motion-latent 的条件控制(文本是下游扩展)

CALM(Tessler et al., SIGGRAPH 2023)的核心不是文本编码,而是用一个 motion encoder 把动捕序列编码成低维 latent skill code,并在判别器中对该 latent 做条件化——判别器不仅判断"运动是否自然",还判断"运动是否匹配指定技能"。换句话说,CALM 是在 ASE 思路上把"skill 表示"做得更结构化,仍然是 motion → latent → policy 的范式。

重要澄清: CALM 官方方法不以 CLIP/文本编码器为核心。"输入文本 → 选择技能"这类自然语言控制是在 motion-latent 之上的扩展层(通过一个额外的 text-to-latent mapper 把文本指令映射到已有的 skill latent space),更纯粹的文本条件控制在下游工作(PADL/SuperPADL/TextOp)中进一步发展。下面的 CALMTextMapper 仅演示这一扩展接口,不是 CALM 的基础训练组件。

ASE:   z ~ encoder(motion_clip)  →  actor(obs, z)  →  action
CALM:  z ~ motion_encoder(motion_clip)  →  actor(obs, z)  →  action   # 核心:motion-latent 条件
扩展:  z ~ text_to_latent("walk slowly")  →  actor(obs, z)  →  action   # 可选的文本扩展层

作为可选扩展,可以增加一个 text-to-latent mapper 把文本映射到 skill latent space:

class CALMTextMapper(nn.Module):
    """CALM 的文本→latent 映射器。"""
    def __init__(self, clip_dim: int = 512, latent_dim: int = 64):
        super().__init__()
        # CLIP 文本 embedding → ASE latent space
        self.mapper = nn.Sequential(
            nn.Linear(clip_dim, 256),
            nn.ReLU(),
            nn.Linear(256, latent_dim),
        )

    def forward(self, text_embedding: torch.Tensor) -> torch.Tensor:
        """CLIP embedding → latent z。"""
        return self.mapper(text_embedding)

在 ProtoMotions 中运行 CALM:

python protomotions/train.py +exp=calm/flat_terrain \
    motion_file=data/amass/labeled_motions.yaml \
    agent.config.text_encoder="clip"

CALM 的工程前提是需要文本标注的动作数据——每条动作 clip 附带一个文本描述。AMASS 本身不包含文本标注,需要使用 BABEL(Language-grounded AMASS, CVPR 2021)或手动标注。

MaskedMimic:统一的 Motion Inpainting

MaskedMimic(Tessler et al., SIGGRAPH Asia 2024)是 ProtoMotions 中最新也是最强大的方法。它把控制问题重新定义为 masked motion inpainting:给定部分约束(关键帧、文本描述、object interaction),生成满足这些约束的完整全身运动。

MaskedMimic 与前面方法的关键区别:

维度 AMP ASE CALM MaskedMimic
输入条件 无(只约束风格) latent z 文本 → z 任意 mask
训练方式 RL(PPO+GAN) RL RL BC(行为克隆)
技能来源 参考数据集风格 encoder 学到的 z 文本映射的 z expert tracker
控制粒度 分布级 技能级 语言级 关键点级

MaskedMimic 在 ProtoMotions 中的两阶段训练:

# Stage 1:训练全身 tracker(expert,技术线 A)
python protomotions/train.py \
    +exp=full_body_tracker/transformer_flat_terrain \
    +robot=smpl \
    +simulator=isaacgym \
    motion_file=data/amass/all_motions.yaml

# Stage 2:训练 MaskedMimic(BC 蒸馏,从 Stage 1 expert 学习)
python protomotions/train.py \
    +exp=masked_mimic/flat_terrain \
    +robot=smpl \
    agent.config.expert_model_path=logs/stage1/best_model.pt

# 推理:用不同的 mask 实现不同控制模式
python protomotions/eval_agent.py \
    +opt=[masked_mimic/tasks/user_control] \
    checkpoint=logs/stage2/best_model.pt

注意 MaskedMimic 的 Stage 2 是 BC 而非 RL——它从 Stage 1 的 expert tracker 生成的 rollout 数据中学习。这意味着 MaskedMimic 的 Stage 2 训练不需要 reward 设计、不需要 advantage 估计、不需要 PPO 训练循环——只需要标准的监督学习。但代价是 Stage 2 的性能上限受 Stage 1 expert 的限制。

ProtoMotions 的类继承与配置切换总结

ProtoMotions 类继承树(截至 2026-06 核对官方源码):

BaseAgent
├── PPO
│   ├── AMP            (+exp=amp/...)     # +判别器
│   │   └── ASE        (+exp=ase/...)     # +encoder +diversity
│   └── Mimic          (+exp=full_body_tracker/...)  # 显式跟踪
│
└── MaskedMimic        (+exp=masked_mimic/...)
    # 注意:MaskedMimic 直接继承 BaseAgent(不是 Mimic/PPO 的子类);
    # 它在内部加载一个 frozen 的 full-body tracker 作为 expert,用 BC/teacher-student 蒸馏

⚠️ 不要把"加载谁做 teacher"误当成"继承谁":MaskedMimic 用 Mimic 训练出的 tracker 当 expert,但在类层次上 class MaskedMimic(BaseAgent),与 Mimic 是平级关系。

四种方法之间的切换,在旧式 Hydra 版本里靠改 +exp= 参数;在当前 ProtoMotions3 里靠选择不同的 experiment file(--experiment-path .../{amp,ase,mimic,masked_mimic}/...)。无论哪种方式,框架都会自动加载对应的网络架构、训练循环和数据管线——这个设计让实验对比非常方便:用相同的 motion 数据、相同的机器人、相同的仿真器,只切换方法配置就能比较不同方法的效果。

⚠️ 常见陷阱

💡 概念误区:ASE/CALM 的 latent space 可以做任意插值。 虽然 latent space 是连续的,但两个技能之间的插值不保证物理可行。例如"走路"和"跳跃"的 latent 中点可能对应一种"半走半跳"的非自然运动。MaskedMimic 用 motion inpainting 的方式更优雅地解决了技能组合问题——通过指定关键帧约束而非插值 latent。

⚠️ 编程陷阱:CALM 的文本标注质量直接影响 text-to-latent 映射。 如果 BABEL 标注对同一类动作使用了不一致的描述(如同时用 "walking" 和 "strolling"),text mapper 会学到混乱的映射。推荐在训练前对标注做统一化处理(同义词合并、格式标准化)。

⚠️ 编程陷阱:MaskedMimic 的 Stage 2 数据量不足。 BC 蒸馏的数据需要覆盖所有 mask 配置——如果你有 K 种 mask 配置但只从少量 episode 收集数据,某些 mask 配置下的数据可能极少,导致 student 在这些配置下表现很差。ProtoMotions 的解决方案是在数据收集时对 mask 配置做均匀采样。

练习

  1. [概念题] ASE 的 diversity loss 鼓励不同的 z 产生不同的运动。如果没有 diversity loss,所有 z 可能映射到同一种运动(mode collapse)。从 GAN 训练的角度解释为什么会发生这种 collapse。
  2. [配置题] 在 ProtoMotions 中分别运行 AMP 和 ASE 训练(使用相同的参考数据集),比较 (a) 训练时间 (b) 最终 task reward (c) 运动多样性(通过可视化不同的 z 值产生的运动)。
  3. [分析题] MaskedMimic 为什么用 BC 而非 RL 做 Stage 2?如果改用 RL(把 mask-conditioned tracking 作为 reward),训练会遇到什么困难?

10.6 BC/DAgger 蒸馏管线 ⭐⭐

这一节解决什么问题:前面的 tracking 和 AMP 方法训练出的策略可能需要 privileged 信息或特定的参考数据输入。BC/DAgger 蒸馏把这些 expert 策略的能力迁移到更轻量的 student 网络。

动机:Expert Tracker 不能直接部署

BeyondMimic 训练出的 tracker 需要参考动作的 anchor pose 作为输入——部署时谁来提供这个 anchor?AMP 训练出的策略可能依赖 privileged 的接触力信息。要部署到真机,需要把 expert 的能力蒸馏到一个只依赖部署可得信息的 student。

这和 Ch09 的 teacher-student 蒸馏在技术上高度重叠。区别在于:

维度 Ch09 蒸馏 Ch10 蒸馏
蒸馏目标 跨越信息边界(privileged → deployable) 跨越能力边界(expert → general)
Teacher asymmetric AC 的 actor BeyondMimic tracker 或 AMP 策略
Student 输入受限但任务相同 可能执行不同的下游任务
方法 BC / DAgger BC / DAgger / Diffusion Policy

如果直接部署 expert 会怎样

如果尝试直接部署 BeyondMimic expert tracker:(1) anchor pose 来自参考动作数据——真机部署时你需要实时提供参考动作,这限制了机器人只能执行预定义的动作;(2) expert 可能依赖 privileged obs(clean terrain height、contact forces)——真机上没有这些信号。蒸馏到 student 可以同时解决这两个问题。

BeyondMimic 的 Diffusion Policy 蒸馏

BeyondMimic 的蒸馏方法是其最大的创新之一:不是简单的 BC,而是用 guided diffusion policy 从多个 per-skill expert tracker 中蒸馏出一个通用控制器。

Step 1: 为每个技能训练独立的 expert tracker
  walking_expert = train_tracker(walk_motion)
  running_expert = train_tracker(run_motion)
  jumping_expert = train_tracker(jump_motion)

Step 2: 收集多专家 rollout 数据
  dataset = collect_rollouts([walking_expert, running_expert, jumping_expert])

Step 3: 训练 diffusion policy
  diffusion_policy = train_diffusion(dataset)

Step 4: 部署时用 cost-function guidance 实现新任务
  action = diffusion_policy.sample(obs, guidance=cost_fn_navigate)

Diffusion policy 相比纯 BC 的优势:它可以在推理时通过 cost function guidance 实现训练时从未见过的任务(如导航、避障)——不需要为每个新任务重新训练。BeyondMimic 在 Unitree G1 上展示了 zero-shot waypoint navigation、joystick teleop 和 obstacle avoidance。

标准 BC 蒸馏的完整实现

对于不需要 diffusion policy 灵活性的场景,标准 BC 蒸馏仍然是最简单有效的方法:

# motion_imitation_bc.py — 从 tracker expert 蒸馏到 deployable student
import torch
from torch.utils.data import DataLoader, TensorDataset
from pathlib import Path
import numpy as np

def collect_expert_data(env, expert_policy, motion_files, cfg):
    """
    从 expert tracker 收集 (student_obs, expert_action) 对。

    关键:记录的是 student 的 observation(部署可得),
    标签是 expert 的 action(可能基于 privileged obs)。
    """
    all_obs, all_actions = [], []

    for motion_file in motion_files:
        env.load_motion(motion_file)
        obs = env.reset()

        for step in range(cfg.steps_per_motion):
            # Student 只看部署可得的 observation
            student_obs = obs["policy"]

            # Expert 看完整 observation(包含 anchor pose + privileged)
            with torch.no_grad():
                expert_action = expert_policy(obs["expert"])

            all_obs.append(student_obs.cpu())
            all_actions.append(expert_action.cpu())

            obs, _, dones, _ = env.step(expert_action)

            # reset 的环境重新加载新的 motion
            if dones.any():
                obs = env.reset()

    dataset = {
        "obs": torch.cat(all_obs, dim=0),
        "actions": torch.cat(all_actions, dim=0),
    }
    print(f"收集完成: {dataset['obs'].shape[0]} 帧, "
          f"obs_dim={dataset['obs'].shape[1]}, "
          f"act_dim={dataset['actions'].shape[1]}")
    return dataset


def train_student_bc(student, dataset, cfg):
    """
    标准 BC 训练循环,含验证集、early stopping 和学习率调度。
    """
    # 分割训练集和验证集
    n = dataset["obs"].shape[0]
    n_val = int(n * 0.1)
    perm = torch.randperm(n)
    train_obs = dataset["obs"][perm[n_val:]]
    train_act = dataset["actions"][perm[n_val:]]
    val_obs = dataset["obs"][perm[:n_val]]
    val_act = dataset["actions"][perm[:n_val]]

    train_loader = DataLoader(
        TensorDataset(train_obs, train_act),
        batch_size=cfg.batch_size, shuffle=True,
    )
    optimizer = torch.optim.Adam(student.parameters(), lr=cfg.lr)
    scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
        optimizer, T_max=cfg.num_epochs,
    )

    best_val_loss = float("inf")
    patience_counter = 0

    for epoch in range(cfg.num_epochs):
        # ---- 训练 ----
        student.train()
        train_loss = 0
        for obs_b, act_b in train_loader:
            obs_b = obs_b.to(cfg.device)
            act_b = act_b.to(cfg.device)
            pred = student(obs_b)
            loss = torch.nn.functional.mse_loss(pred, act_b)
            optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(student.parameters(), 1.0)
            optimizer.step()
            train_loss += loss.item()

        train_loss /= len(train_loader)
        scheduler.step()

        # ---- 验证 ----
        student.eval()
        with torch.no_grad():
            val_pred = student(val_obs.to(cfg.device))
            val_loss = torch.nn.functional.mse_loss(
                val_pred, val_act.to(cfg.device)
            ).item()

        # ---- Early Stopping ----
        if val_loss < best_val_loss:
            best_val_loss = val_loss
            torch.save(student.state_dict(), "student_best.pt")
            patience_counter = 0
        else:
            patience_counter += 1

        if epoch % 20 == 0:
            print(f"Epoch {epoch}: train={train_loss:.6f}, "
                  f"val={val_loss:.6f}, patience={patience_counter}")

        if patience_counter >= cfg.patience:
            print(f"Early stopping at epoch {epoch}")
            break

    student.load_state_dict(torch.load("student_best.pt"))
    return student

DAgger 蒸馏的完整实现

当纯 BC 的 rollout 评估不合格时(student ≤ 80% expert performance),升级到 DAgger:

# dagger_motion_imitation.py — motion imitation 专用的 DAgger 实现
class MotionImitationDAgger:
    """
    DAgger 训练器,专门用于 motion imitation 场景。
    关键差异:expert 需要参考动作输入,student 不需要。
    """
    def __init__(self, env, expert, student, motion_files, cfg):
        self.env = env
        self.expert = expert       # 冻结的 expert tracker
        self.student = student     # 可训练的 student
        self.motion_files = motion_files
        self.cfg = cfg

        # 数据集
        self.obs_buffer = []
        self.act_buffer = []

        self.optimizer = torch.optim.Adam(
            student.parameters(), lr=cfg.lr
        )

    def collect_round(self, round_idx: int, beta: float):
        """
        一轮 DAgger 数据收集。

        beta: 混合比例
          beta=1.0 → 完全用 expert 执行
          beta=0.0 → 完全用 student 执行
        """
        # 随机选择一条 motion
        motion_file = np.random.choice(self.motion_files)
        self.env.load_motion(motion_file)
        obs = self.env.reset()

        round_obs, round_acts = [], []

        for step in range(self.cfg.steps_per_round):
            student_obs = obs["policy"]

            with torch.no_grad():
                # Expert 用完整 obs(含 anchor + privileged)
                expert_action = self.expert(obs["expert"])
                # Student 只用部署可得 obs
                student_action = self.student(student_obs)

            # 混合执行
            if np.random.random() < beta:
                exec_action = expert_action
            else:
                exec_action = student_action

            # 记录:student obs + expert action
            round_obs.append(student_obs.cpu())
            round_acts.append(expert_action.cpu())

            obs, _, dones, _ = self.env.step(exec_action)
            if dones.any():
                obs = self.env.reset()

        self.obs_buffer.extend(round_obs)
        self.act_buffer.extend(round_acts)

        # 限制总数据量
        max_size = self.cfg.max_dataset_size
        if len(self.obs_buffer) > max_size:
            self.obs_buffer = self.obs_buffer[-max_size:]
            self.act_buffer = self.act_buffer[-max_size:]

    def train_round(self):
        """在当前累积数据集上训练 student。"""
        obs_t = torch.cat(self.obs_buffer, dim=0)
        act_t = torch.cat(self.act_buffer, dim=0)
        loader = DataLoader(
            TensorDataset(obs_t, act_t),
            batch_size=self.cfg.batch_size, shuffle=True,
        )

        total_loss = 0
        for obs_b, act_b in loader:
            obs_b = obs_b.to(self.cfg.device)
            act_b = act_b.to(self.cfg.device)
            pred = self.student(obs_b)
            loss = torch.nn.functional.mse_loss(pred, act_b)
            self.optimizer.zero_grad()
            loss.backward()
            self.optimizer.step()
            total_loss += loss.item()

        return total_loss / len(loader)

    def evaluate(self, num_episodes: int = 50) -> float:
        """评估 student 的 rollout 表现。"""
        total_reward = 0
        for ep in range(num_episodes):
            motion_file = np.random.choice(self.motion_files)
            self.env.load_motion(motion_file)
            obs = self.env.reset()
            ep_reward = 0

            for step in range(self.env.max_episode_length):
                with torch.no_grad():
                    action = self.student(obs["policy"])
                obs, reward, done, _ = self.env.step(action)
                ep_reward += reward.mean().item()
                if done.all():
                    break
            total_reward += ep_reward

        return total_reward / num_episodes

    def run(self):
        """运行完整的 DAgger 流程。"""
        for round_idx in range(self.cfg.num_rounds):
            # Beta 退火
            if round_idx < 3:
                beta = 1.0
            else:
                beta = max(0.0, 1.0 - (round_idx - 3) / self.cfg.num_rounds)

            self.collect_round(round_idx, beta)
            loss = self.train_round()

            # 每 5 轮评估一次
            if round_idx % 5 == 0:
                perf = self.evaluate()
                print(f"Round {round_idx}: beta={beta:.2f}, "
                      f"loss={loss:.6f}, reward={perf:.2f}, "
                      f"dataset={len(self.obs_buffer)}")

        return self.student

Motion Imitation 蒸馏的端到端流程

把本章的所有知识点串联起来,以下是从数据到部署的完整管线:

┌─────────────────────────────────────────────────────┐
│              Motion Imitation 端到端管线              │
│                                                      │
│  1. 数据准备(10.2 节)                              │
│     AMASS .npz → retarget → G1 .npz                 │
│     ↓ 质量验证:MuJoCo 回放                          │
│                                                      │
│  2. Expert 训练(10.3 或 10.4 节)                   │
│     ├── 路线 A:BeyondMimic tracking → expert         │
│     └── 路线 B:AMP style reward → expert             │
│     ↓ 评估:tracking error / style quality            │
│                                                      │
│  3. 蒸馏(10.6 节)                                  │
│     expert + privileged obs                           │
│     → BC 或 DAgger                                   │
│     → student(只需 IMU + 关节编码器)                │
│     ↓ 评估:student ≥ 85% expert reward               │
│                                                      │
│  4. 部署(Ch09 的 ONNX 导出 + Ch23 的真机通信)      │
│     student → ONNX(含 normalizer 烘焙)              │
│     → Jetson Orin → 100 Hz 推理 → 关节命令            │
└─────────────────────────────────────────────────────┘

每个步骤的预估时间(单 GPU, 4096 envs):

步骤 预估时间 瓶颈
Retarget(单条动作) ~10 分钟 IK 求解
BeyondMimic tracking(单条) ~2-6 小时 PPO 收敛
AMP 训练(100 条动作) ~8-24 小时 判别器稳定性
BC 蒸馏 ~30 分钟 数据收集
DAgger 蒸馏(10 轮) ~2-4 小时 交替执行
ONNX 导出 + sim-to-sim 验证 ~10 分钟 手动检查

⚠️ 常见陷阱

⚠️ 编程陷阱:expert 数据只来自一种 motion。 如果 expert tracker 只在走路动作上收集数据,student 只学会走路。要让 student 泛化到多种运动,数据必须覆盖所有目标 motion。

⚠️ 编程陷阱:DAgger 的 beta 退火太快。 如果 beta 在第 3 轮就降到 0,student 还没学好就开始完全自主执行,产生的 state 分布可能离 expert 分布太远——expert 的 action 标签在这些 state 上可能不是最优的。推荐 beta 在 10-20 轮内线性退火。

💡 概念误区:更多 DAgger 轮次一定更好。 DAgger 的每一轮都向数据集中添加新数据。如果 expert 在 student 诱导的某些 state 上表现不稳定(expert 本身在那些 state 没被训练过),DAgger 收集到的标签质量会下降。一般 10-20 轮足够;更多轮数的收益递减。

练习

  1. [设计题] 为 BeyondMimic → deployable student 设计一个 DAgger 蒸馏管线。Student 的 observation 只有 IMU + 关节编码器(无 depth camera),expert 有完整的 anchor pose 输入。DAgger 的每轮数据收集应该怎么安排?beta 退火策略是什么?
  2. [分析题] Diffusion policy 蒸馏和纯 BC 蒸馏在以下场景中哪个更合适?(a) 部署到嵌入式设备(计算受限)(b) 需要在运行时切换任务 (c) 训练数据有限(<100k 帧)
  3. [跨章综合题] 结合 Ch09 的 privileged learning:如果 expert tracker 使用了 asymmetric AC(critic 看 privileged),蒸馏时 student 应该从 expert 的哪个网络收集 action?actor 还是 teacher?为什么?

10.7 选型决策:Tracking vs AMP vs BC ⭐⭐

这一节解决什么问题:面对一个新项目,如何在三种方法之间做出选择?

动机:不是所有项目都需要 AMP

回顾 Ch09 的"不要过度工程化"原则:选择最简单的能解决问题的方案。对于 motion imitation,选型决策取决于三个因素:数据可得性、精度要求和部署约束。

决策流程

开始
│
├── Q1: 你有精确的 retarget 参考动作吗?
│   ├── 否 → Q2: 你有人类动作数据集(可以不精确 retarget)?
│   │   ├── 否 → 不适合 motion imitation,用 velocity tracking + style penalty
│   │   └── 是 → AMP / ASE(不需要帧级对齐)
│   │
│   └── 是 → Q3: 你需要精确复现这段动作吗?
│       ├── 是 → 显式 tracking(BeyondMimic)
│       │         适用:表演、体操、精确操作序列
│       │
│       └── 否 → Q4: 你需要从动作数据中提取"风格"用于其他任务?
│           ├── 是 → AMP(风格约束 + task reward)
│           │         适用:自然步态 + 速度跟踪
│           │
│           └── 否 → 显式 tracking 或 AMP 都可以
│                     按工程复杂度选择(tracking 更简单)
│
├── Q5: 部署时 student 需要泛化到新任务吗?
│   ├── 是 → Diffusion Policy 蒸馏(BeyondMimic 方案)
│   └── 否 → 标准 BC / DAgger 蒸馏

三种方法的综合对比

维度 显式 Tracking AMP BC 蒸馏
数据要求 精确 retarget 大致 retarget 即可 expert rollout
训练复杂度 低(标准 PPO) 中(PPO + GAN) 低(监督学习)
精度 高(帧级跟踪) 中(风格级匹配) 取决于 expert
泛化性 低(跟踪特定动作) 高(学到风格模式) 中(取决于数据)
调试难度 低(reward 可解释) 高(判别器不透明)
推荐入门顺序 第一步 第二步 第三步

这三种方法的关系类似于学习外语的三种方式:显式 tracking 像逐字翻译——精确但僵硬,离开原文就不知道怎么说。AMP 像浸入式学习——你不知道具体的语法规则,但听多了自然就"感觉对"。BC 蒸馏像跟着母语者模仿——你不理解为什么这样说,但照着说通常没问题。

工程实战:三种场景的完整方案设计

场景 1:G1 太极拳表演(精确跟踪)

目标:精确复现一段 60 秒的太极拳动捕数据。要求每个姿态的关节角误差 <10°。

方案设计:
1. 数据:动捕 → SMPL → retarget(IK,关注手臂精度)→ .npz
2. 训练:BeyondMimic tracking,单条动作
   - σ_body_pose = 0.3(高精度要求→小 σ)
   - RSI 开启(从 0-90% 帧随机初始化)
   - 训练时间估计:~4h(单 GPU, 4096 envs)
3. 部署:直接部署 actor(不需要蒸馏——tracking 策略的输入只需 anchor pose)
4. 风险:太极拳有很多缓慢的平衡动作,contact force reward 可能太小
   对策:增大 velocity reward 权重(鼓励"流畅经过"而非"停在原地")

场景 2:G1 自然步态行走(风格约束)

目标:让 G1 在速度命令下自然行走。不指定具体步态,但要"看起来像人"。

方案设计:
1. 数据:AMASS walking 子集(~100 条,已 retarget)
2. 训练:AMP(velocity tracking reward + style reward)
   - disc_reward_weight = 0.5
   - disc_lr = 3e-5, grad_penalty = 10
   - 训练时间估计:~12h(判别器收敛需要更多 iteration)
3. 蒸馏:BC/DAgger → student(只看 IMU + 关节)
4. 部署:student ONNX → Jetson
5. 风险:判别器 mode collapse(accuracy 飙到 >0.95)
   对策:降低 disc_lr,增大 grad_penalty

场景 3:多技能人形机器人(技能库)

目标:G1 能走、跑、跳、转身,运行时通过高层命令切换。

方案设计:
1. 数据:AMASS 多技能子集(走/跑/跳/转身各 20+ 条)
2. 训练路线 A(ProtoMotions):
   ASE(latent skill embedding)→ 不同 z 对应不同技能
   或 MaskedMimic(两阶段:expert tracker → BC 蒸馏)
3. 训练路线 B(Isaac Lab + mjlab):
   为每种技能训练独立 tracker → BeyondMimic diffusion policy 蒸馏
4. 部署:student + 高层 task planner
5. 训练时间估计:路线 A ~24h,路线 B ~48h(多个 expert)
6. 风险:技能过渡时的不稳定
   对策:增加 transition 动作到训练数据(walk-to-run, run-to-walk)

如果选错方案会怎样

选了 tracking 但需要 AMP(数据不够精确): 你的动作数据来自手机视频提取(4D-Humans),retarget 精度有限。如果用显式 tracking,策略会疯狂尝试跟踪这些不精确的参考帧——产生抖动和不自然的补偿动作。改用 AMP 后,判别器只关心"运动整体上像不像",对单帧的不精确更容错。

选了 AMP 但需要 tracking(需要精确复现): 你的客户要求机器人精确复现一段特定的舞蹈。AMP 训练出的策略"整体风格对"但不保证每一帧对齐——观众可能注意到手臂位置偏差了 20cm。改用 BeyondMimic tracking 后,每一帧都有精确的目标,偏差会被 reward 直接惩罚。

练习

  1. [选型题] 你有 100 条 AMASS 动作(已 retarget 到 G1),目标是让 G1 能以自然步态执行速度命令。应该选哪种方法?列出完整的训练配置。
  2. [选型题] 你只有一段 10 秒的太极拳动捕数据,目标是让 G1 精确复现这段动作并部署到真机。应该选哪种方法?完整的管线是什么?
  3. [跨章综合题] 结合 Ch08(DR)和 Ch09(Privileged Learning):如果你要部署场景 2 的 AMP 策略到真机,DR 应该怎么配?privileged obs 应该包含什么?整个 training recipe 的各章对应关系是什么?

本章小结

知识点 核心结论 重要程度
三条技术线 显式跟踪 / 对抗风格 / BC 蒸馏,不互斥可组合
双重解读(监督粒度 vs 信息来源) 逐帧→分布→行为的监督粒度递减 ⭐⭐
AMASS/SMPL 数据管线 SMPL → retarget (IK) → .npz/.yaml → framework ⭐⭐
Retarget IK 求解 阻尼最小二乘 + 关节限位裁剪 + 接地校正 ⭐⭐⭐
Video → Robot 端到端管线 4D-Humans/WHAM → SMPL → 物理可行性过滤 → retarget → RL ⭐⭐
四元数距离计算 必须处理 double cover 和 clamp,核心几何操作 ⭐⭐⭐
BeyondMimic 五项 reward root pos/ori + body pose + velocity + collision ⭐⭐⭐
Tracking reward 的 σ 调参 σ 大=宽容,σ 小=精确;可做 curriculum 退火 ⭐⭐⭐
Anchor 机制 策略看"下一个关键帧"而非完整轨迹,给物理仿真留呼吸空间 ⭐⭐⭐
Reference State Initialization 从参考运动随机帧初始化,大幅提高训练效率 ⭐⭐
AMP 判别器五个稳定性技巧 gradient penalty / 1:1 ratio / replay / low LR / shared normalizer ⭐⭐⭐⭐
AMP 判别器输入设计 排除 root xy 实现平移和速度不变性 ⭐⭐⭐
Isaac Lab 原生 AMP 集成 自定义 reward term + 自定义训练循环 ⭐⭐
ASE latent skill code 不同 z 对应不同技能,配合 diversity loss 防止 mode collapse ⭐⭐
MaskedMimic motion inpainting BC 蒸馏而非 RL,从 expert tracker 学习,mask 控制模式切换 ⭐⭐⭐
ProtoMotions 配置切换 AMP → ASE → CALM → MaskedMimic 只需改 +exp ⭐⭐⭐
BC/DAgger 蒸馏 expert → student 跨越能力边界,含 early stopping + DAgger beta 退火 ⭐⭐
BeyondMimic Diffusion Policy 多 expert 蒸馏 + 推理时 cost-function guidance 实现新任务 ⭐⭐⭐
选型决策树 数据质量 × 精度要求 × 部署约束 ⭐⭐

本章从数据管线(AMASS → retarget → .npz)出发,经过两条核心技术线的工程实现(BeyondMimic 显式跟踪 + AMP 对抗风格),到蒸馏部署(BC/DAgger/Diffusion Policy),构建了一条从"有动作数据"到"机器人能模仿"的完整工程路径。三条技术线不是割裂的——工业级管线通常组合使用。例如:用 BeyondMimic tracking 训练 expert,用 AMP 的风格约束增强自然度,最后用 DAgger 蒸馏到 deployable student。

Ch10 的知识在后续章节中的应用地图:

后续章节 复用的 Ch10 知识 具体应用
Ch11 机器人资产 retarget 管线 URDF/MJCF 模型是 retarget 的输入
Ch15 全身运动 BeyondMimic tracking 完整的人形全身跟踪管线
Ch16 CALM/文本控制 ASE/CALM 配置 从 AMP 扩展到 text-conditioned
Ch21 遥操作 Video → Robot 管线 遥操作数据的 retarget 流程
Ch23 Sim2Real BC/DAgger 蒸馏 从 sim expert 到 real student

下一章(Ch11)将转入机器人建模——从 SolidWorks 到 URDF 到 MJCF/USD 的完整资产管线。这是本章讨论的所有 motion imitation 方法的前置依赖——没有正确的机器人模型,retarget、tracking 和 AMP 都无从谈起。

累积项目

本章需要在你的累积项目中完成以下工作:

  1. 准备至少一条 retarget 到你的机器人的参考动作数据(.npz 格式),并在 MuJoCo 中验证
  2. 在 mjlab 中用 BeyondMimic tracking task 训练一条动作的跟踪策略,记录五项 reward 的收敛曲线
  3. 如果有 ProtoMotions 环境,运行 AMP 训练并对比 tracking reward 和 AMP style reward 的行为差异
  4. 撰写一份动作数据审计报告,包含:数据来源、帧率、关节数、retarget 方法、MuJoCo 回放检查结果
  5. 完成以下 A/B 对比实验:有/无 RSI 的 tracking 性能差异,记录 mean episode length 和 tracking error

实验 Lab:BeyondMimic Tracking A/B 对比

以下是一个可直接执行的实验,验证 RSI 和 σ 调参对 tracking 性能的影响:

# tracking_ablation.py — tracking 策略的 A/B 消融实验
"""
实验设计:
  A 组(baseline): RSI 关闭 + σ_body_pose = 1.0(宽容)
  B 组(full):     RSI 开启 + σ_body_pose = 0.3(严格)

预期:
  B 组的 tracking 精度更高(更小的 joint_pos_error),
  但训练更慢(更严格的 reward 更难优化)。
"""
experiment_configs = {
    "A_baseline": {
        "rsi_enabled": False,
        "sigma_body_pose": 1.0,
        "sigma_root_pos": 0.5,
        "max_iterations": 10000,
    },
    "B_full": {
        "rsi_enabled": True,
        "sigma_body_pose": 0.3,
        "sigma_root_pos": 0.3,
        "max_iterations": 10000,
    },
}

# 对比脚本
def compare_tracking_experiments(log_dir_a, log_dir_b):
    """对比两组实验的 tracking 指标。"""
    # 从 tensorboard/wandb 日志中提取指标
    metrics_to_compare = [
        "reward/global_anchor_pos",    # A 应略高(更宽容)
        "reward/relative_body_pose",   # B 应更高(RSI 帮助探索)
        "metrics/mean_joint_pos_error_deg",  # B 应更低(更严格→更精确)
        "train/mean_episode_length",   # B 应更长(RSI 减少早期摔倒)
    ]
    # ... 从日志中读取并对比

预期观察和分析:

指标 A 组(baseline) B 组(full) 分析
body_pose reward ~0.3-0.5 ~0.5-0.7 B 组更高,因为 RSI 让策略练习了更多帧
joint_error (deg) ~20-30° ~8-15° B 组更低,严格的 σ 驱动更高精度
episode_length ~30% motion ~70% motion B 组更长,RSI 减少了早期摔倒
收敛速度 ~3000 iter ~5000 iter B 组更慢,严格 reward 更难优化
训练时间 ~1.5h ~2.5h B 组更久

如果 B 组反而更差:(1) 检查 retarget 数据是否有越界帧(RSI 会初始化到越界帧 → 弹飞);(2) σ=0.3 对你的动作可能太严格,试 σ=0.5。

快速验证脚本

# verify_ch10_completion.py — 检查累积项目完成度
def verify_ch10():
    """验证 Ch10 累积项目的各项要求。"""
    checks = []

    # Check 1: 动作数据存在且格式正确
    import numpy as np
    data = np.load("motion.npz")
    required_keys = ["root_pos", "root_quat", "joint_pos"]
    for key in required_keys:
        assert key in data.files, f"Missing key: {key}"
    # 四元数单位范数检查(wxyz 约定,w 为第 0 分量)
    assert (np.abs(np.linalg.norm(data["root_quat"], axis=-1) - 1.0) < 0.01).all()
    checks.append("✅ 动作数据格式正确")

    # Check 2: 帧率匹配(对 NpzFile 用 .files 判断比 .get 更稳妥)
    fps = data["fps"].item() if "fps" in data.files else None
    if fps is not None:
        assert fps == 50, f"FPS mismatch: expected 50, got {fps}"
    checks.append("✅ 帧率正确")

    # Check 3: 关节角在限位内
    # (需要加载对应的 MJCF 模型)
    checks.append("⚠️ 关节限位检查需要手动在 MuJoCo 中验证")

    # Check 4: WandB 日志存在
    # checks.append("✅ 训练日志已记录")

    for c in checks:
        print(c)

verify_ch10()

累积项目与前置章节的连接

本章的 tracking reward 设计依赖 Ch06 的 reward engineering 知识——指数型 reward 的 σ 调参、multi-term reward 的权重平衡、curriculum 策略。如果你的 tracking reward 收敛太慢,回到 Ch06 检查 σ 的设置是否合理。

本章的 AMP 判别器中的 observation normalization 共享问题直接关联 Ch09 的 normalization 专题。如果你在 AMP 训练中遇到判别器准确率异常高(>95%)且 style reward 不提升,首先检查 normalizer 是否共享。这是 Ch09 中已经详细讨论的"沉默训练杀手"。

本章的 BC/DAgger 蒸馏管线复用了 Ch09 的完整代码框架。区别在于数据来源(Ch09 从 asymmetric AC 收集,本章从 expert tracker 收集)和 student 的任务(Ch09 是信息降级,本章是能力迁移)。两者可以串联使用。

本章的 retarget 工作产出的 .npz 数据将在 Ch15(全身运动)和 Ch16(文本条件控制)中持续使用。确保你的 retarget 管线文档清晰——后续章节会在此基础上扩展更复杂的动作库。

本章的 video → robot 管线为 Ch21(遥操作)提供了关键的前置能力——遥操作收集的人体动作数据需要经过相同的 retarget 管线才能用于 RL 训练。

实验记录模板

Ch10 累积项目实验记录
━━━━━━━━━━━━━━━━━━━
日期:
机器人:G1 / H1 / Go1(选择一个)
框架:mjlab / ProtoMotions / Isaac Lab
GPU:

动作数据:
  来源:AMASS-CMU / 自采集 / 视频提取
  原始帧率:___ fps
  目标帧率:___ fps
  时长:___ 秒
  retarget 方法:ProtoMotions / TienKung-Lab / 自定义 IK
  MuJoCo 回放检查:通过 / 有问题(描述)

Tracking 实验:
  方法:BeyondMimic
  num_envs:___
  max_iterations:___
  RSI:开启 / 关闭
  五项 reward 最终值:
    root_pos: ___
    root_ori: ___
    body_pose: ___
    velocity: ___
    collision: ___
  mean_joint_error_deg:___
  mean_episode_length:___

AMP 实验(如有):
  方法:AMP / ASE
  disc_reward_weight:___
  disc_accuracy 最终值:___
  style reward 最终值:___
  task reward 最终值:___

结论:
  tracking 精度是否满足需求:是/否
  下一步:蒸馏到 student / 增加 DR / 换 AMP

延伸阅读

资料 难度 推荐原因
Peng et al. 2018, "DeepMimic: Example-Guided Deep Reinforcement Learning of Physics-Based Character Skills" (SIGGRAPH) ⭐⭐ motion imitation 的里程碑,理解 RSI 和 tracking reward
Peng et al. 2021, "AMP: Adversarial Motion Priors for Stylized Physics-Based Character Control" (SIGGRAPH) ⭐⭐⭐ AMP 的完整方法论,判别器设计和训练技巧
Peng et al. 2022, "ASE: Large-Scale Reusable Adversarial Skill Embeddings" (SIGGRAPH) ⭐⭐⭐ 从单一风格到多技能空间
Tessler et al. 2023, "CALM: Conditional Adversarial Latent Models for Directable Virtual Characters" (SIGGRAPH) ⭐⭐ 文本条件控制的工程实现
Tessler et al. 2024, "MaskedMimic: Unified Physics-Based Character Control Through Masked Motion Inpainting" (SIGGRAPH Asia) ⭐⭐⭐ motion inpainting 统一控制,ProtoMotions 最新方法
Luo et al. 2023, "PHC: Perpetual Humanoid Control for Real-time Simulated Avatars" (ICCV) ⭐⭐⭐ 大规模 motion tracking(万条动作),PMCP 渐进网络
Liao et al. 2025, "BeyondMimic: From Motion Tracking to Versatile Humanoid Control via Guided Diffusion" ⭐⭐⭐⭐ 本章精读项目,tracking + diffusion policy 蒸馏
Mahmood et al. 2019, "AMASS: Archive of Motion Capture as Surface Shapes" (ICCV) ⭐⭐ 理解动作数据格式和 SMPL 体模型
Xie et al. 2025, "KungfuBot: Physics-Based Humanoid Whole-Body Control for Learning Highly-Dynamic Skills" (NeurIPS) ⭐⭐⭐ Video → retarget → tracking 的完整管线,物理可行性过滤
ProtoMotions 官方文档 (NVlabs/ProtoMotions) ⭐⭐⭐ AMP/ASE/CALM/MaskedMimic 的统一实现框架
TienKung-Lab 官方文档 (Open-X-Humanoid/TienKung-Lab) ⭐⭐ Isaac Lab + RSL-RL 的 AMP 工程实践,含 retarget 脚本

阅读顺序建议:先读 DeepMimic(理解显式跟踪的基本原理),再读 AMP(理解对抗式风格约束),然后读 BeyondMimic(理解现代 tracking + 蒸馏管线)。PHC 作为大规模 tracking 的参考。ProtoMotions 和 TienKung-Lab 文档作为实践参考贯穿始终。KungfuBot 的 Video → Robot 管线是理解端到端工程流程的最佳案例。

论文精读优先级:如果时间有限,最推荐精读的三篇是 DeepMimic(2018)、AMP(2021)和 BeyondMimic(2025)——它们代表了显式跟踪和对抗风格两条线的起点和当前终点。

🔧 故障排查手册

症状 可能原因 排查步骤 相关章节
tracking reward 一直很低(<0.2) retarget 数据质量差 1. MuJoCo 回放检查穿模/越界 2. 打印 joint_pos_error histogram 3. 检查帧率匹配 本章 10.2
机器人跟踪正确但方向错 四元数 wxyz/xyzw 约定混淆 1. 打印第 0 帧 root_quat 检查 w 分量 2. 确认 retarget 工具的输出约定 本章 10.2
AMP 判别器准确率 >95% 但 style reward 不涨 normalizer 未共享 / gradient penalty 太小 1. 确认 actor 和 disc 共享 normalizer 2. 增大 λ_gp 3. 降低 disc_lr 本章 10.4
AMP 判别器准确率 ~50%(随机猜) 判别器网络太小 / 参考数据太少 1. 增大 disc_hidden_dims 2. 增加参考数据量 3. 检查参考数据格式正确 本章 10.4
策略运动"像人"但不完成任务 style reward 权重太高 1. 降低 disc_reward_weight 2. 增大 task reward 权重 本章 10.4
RSI 后机器人"弹飞" 初始帧关节角越界 1. 检查 retarget 所有帧是否在限位内 2. RSI 后加 mj_forward + clip 本章 10.3
BC 蒸馏 loss 低但 rollout 差 compounding error 1. 增加数据多样性 2. 改用 DAgger 3. 检查 student obs 是否与部署一致 本章 10.6
body 顺序不匹配导致"扭曲" retarget 输出 body 顺序与 MuJoCo 不一致 1. 打印 MuJoCo model 的 body 名列表 2. 对比 .npz 中的 body 顺序 3. 重新排序 本章 10.3
ASE 的所有 z 产生相同运动 diversity loss 太小或缺失 1. 增大 diversity_bonus 2. 检查 encoder 输出的 z 方差 3. 可视化不同 z 本章 10.5
速度 reward 中 σ 太小导致抖动 策略过于激进匹配速度 1. 增大 body_velocity 的 σ 到 ≥0.5 2. 减小 body_velocity 权重 本章 10.3
Isaac Lab AMP 中判别器 loss NaN 输入未归一化或包含 inf 1. 检查 disc_obs 中是否有 inf/nan 值 2. 确认 shared_normalizer 在第一次前向前已初始化 3. 加 gradient clipping 本章 10.4
retarget 后脚底浮空 ~5cm SMPL 和机器人的身高比例差异 1. 检查 retarget 的 height_offset 参数 2. 在 .npz 中手动调整 root_pos 的 z 分量 3. 启用 fix_heights 选项 本章 10.2
DAgger 训练后 student 在新 motion 上失败 expert 数据只覆盖了部分 motion 1. 增加 DAgger 轮次 2. 确保每轮随机选择不同 motion 3. 增大 dataset 最大容量 本章 10.6
ProtoMotions 的 AMP 和 Isaac Lab 原生 AMP 结果差异大 超参数默认值不同 1. 逐项对比 disc_lr、grad_penalty、replay_buffer_size 2. 确认 obs normalization 策略一致 3. 对齐 reward 权重 本章 10.4