# 第14章 运动控制案例研究：从盲走到视觉导航的足式机器人

> 原课程: Robots That Learn, UC Berkeley CS294-291, Spring 2026
> 本章基于 Lecture 9: Locomotion Case Studies
> 精读论文:
> - Kumar et al., "RMA: Rapid Motor Adaptation for Legged Robots," RSS 2021
> - Agarwal et al., "Legged Locomotion in Challenging Terrains using Egocentric Vision," CoRL 2022

---

## 一、本章概要

**核心问题**: 足式机器人在真实世界中面临一个根本性困境——仿真中训练好的行走策略，部署到真实环境时由于物理参数偏差（摩擦力、质量分布、电机特性）和地形变化（沙地、草丛、台阶、空隙），性能急剧退化。人类行走时能在几分之一秒内适应地面变化——机器人能否做到同样的事？

**核心贡献**: 本章深度研读两篇里程碑式工作。(1) **RMA (Rapid Motor Adaptation)** 提出了一种 teacher-student 蒸馏范式：先在仿真中用特权信息（精确的摩擦力、质量、地形高度）训练一个"全知"教师策略，再通过监督学习训练一个仅依赖本体感觉历史的"盲"适应模块，在部署时以 10Hz 的低频实时估计环境隐变量，使基础策略能在亚秒级时间内适应未见过的地形——整个过程无需真实世界微调。(2) **Egocentric Locomotion** 将这一范式扩展到视觉领域：用廉价的 scandots（采样点高度）在 Phase 1 作为视觉信息的代理训练 RL 策略，Phase 2 通过监督蒸馏将深度图像映射到相同的隐空间，使小型四足机器人仅凭前置深度相机即可攀爬台阶、跨越空隙、踩踏踏脚石——首次实现端到端的自我中心视觉行走。

**与前后章节的逻辑关系**: 第7章介绍了运动控制的发展视角（发育认知、CPG），第8章介绍了经典控制方法（LQR、MPC），第11章建立了强化学习的数学基础（MDP、策略梯度、PPO）。本章将这些理论基础熔于一炉，展示如何通过 teacher-student 蒸馏、域随机化、异步部署等系统工程手段，将仿真中训练的 RL 策略真正落地到真实机器人的足式运动控制中。本章为第15章（导航案例）和第16章（灵巧操作案例）提供了 sim-to-real 迁移的范本。

**本章路线图**: 第2节建立 RMA 蒸馏的数学框架（从 teacher-student bound 到异步部署的物理直觉）和自我中心视觉运动控制的数学基础；第3节逐段精读两篇论文的方法论；第4节给出完整的 PyTorch 实现；第5节提取关键洞察；第6节提供练习。

---

## 二、核心概念与数学基础

### 2.1 RMA 的 Teacher-Student 蒸馏框架

RMA 解决的问题可以形式化如下：设真实环境的物理参数为 $$\mathbf{e} \in \mathbb{R}^{17}$$（包含机器人质量、质心位置、各关节电机强度、地面摩擦系数、局部地形高度），仿真环境中我们可以访问 $$\mathbf{e}$$ 的真实值，但在真实世界中 $$\mathbf{e}$$ 不可观测。目标是在仿真中训练一个策略，使其在真实世界的未见环境中以亚秒级速度在线适应，且整个过程不需要真实世界数据采集。

**Phase 1: Teacher 训练（特权信息强化学习）**

基础策略 $$\pi$$ 是一个确定性映射：

$$\mathbf{a}_t = \pi(\mathbf{x}_t, \mathbf{a}_{t-1}, \mathbf{z}_t)$$

其中 $$\mathbf{x}_t \in \mathbb{R}^{30}$$ 是本体感觉状态（12 个关节位置 + 12 个关节速度 + 躯干 roll/pitch + 4 个足底接触指示），$$\mathbf{a}_t \in \mathbb{R}^{12}$$ 是目标关节角度（通过 PD 控制器转换为力矩），$$\mathbf{z}_t \in \mathbb{R}^{8}$$ 是环境隐变量（extrinsics vector），由环境因子编码器 $$\mu$$ 从特权信息生成：

$$\boxed{\mathbf{z}_t = \mu(\mathbf{e}_t)}$$

$$\mu$$ 是一个 3 层 MLP（256-128-8），输入 17 维的 $$\mathbf{e}_t$$，输出 8 维的紧致隐编码。这里有一个关键设计选择：**不直接学习 $$\mathbf{e}_t$$ 到动作的映射**，而是学习 $$\mathbf{e}_t$$ 到一个低维隐空间的映射 $$\mathbf{z}_t$$，然后将 $$\mathbf{z}_t$$ 作为基础策略的额外输入。这样做有两个好处：(a) 避免了不同环境参数间的可辨识性问题（多个参数组合可能产生相同的观测效果），(b) 训练优化的是"给定这个隐变量，应该采取什么行动"而非"精确估计环境参数"——这是 RMA 的核心哲学。

$$\pi$$ 和 $$\mu$$ 通过 PPO 在仿真中联合训练。优化目标为最大化期望折扣回报：

$$J(\pi) = \mathbb{E}_{\tau \sim p(\tau \mid \pi)}\left[\sum_{t=0}^{T-1} \gamma^t r_t\right]$$

RMA 的奖励函数源自**生物能量学启发**（bioenergetics-inspired），不依赖任何参考轨迹或预定义步态模式。总奖励为 10 个分量的加权和：

$$r_t = \underbrace{w_1 \cdot \min(v_x^t, 0.35)}_{\text{前进速度}} - w_2 \cdot \underbrace{\|v_y^t\|^2}_{\text{横向漂移}} - w_2 \cdot \underbrace{\|\omega_{\text{yaw}}^t\|^2}_{\text{偏航旋转}}$$
$$- w_3 \cdot \underbrace{\vert \boldsymbol{\tau}^t \cdot (\mathbf{q}^t - \mathbf{q}^{t-1})\vert }_{\text{关节做功}} - w_4 \cdot \underbrace{\|\mathbf{f}^t - \mathbf{f}^{t-1}\|^2}_{\text{地面冲击}} - w_5 \cdot \underbrace{\|\boldsymbol{\tau}^t - \boldsymbol{\tau}^{t-1}\|^2}_{\text{力矩平滑度}}$$
$$- w_6 \cdot \underbrace{\|\mathbf{a}_t\|^2}_{\text{动作幅值}} - w_7 \cdot \underbrace{\|\dot{\mathbf{q}}^t\|^2}_{\text{关节速度}} - w_8 \cdot \underbrace{\|\theta_{\text{roll,pitch}}^t\|^2}_{\text{躯干倾斜}}$$
$$- w_9 \cdot \underbrace{\|v_z^t\|^2}_{\text{垂直加速度}} - w_{10} \cdot \underbrace{\|\text{diag}(\mathbf{g}^t) \cdot \mathbf{v}_f^t\|^2}_{\text{足底滑动}}$$

其中 $$\boldsymbol{\tau}$$ 为关节力矩，$$\mathbf{q}$$ 为关节角度，$$\mathbf{f}$$ 为足底反力，$$\mathbf{g}$$ 为接触指示，$$\mathbf{v}_f$$ 为足端速度。权重向量为 $$(20, 21, 0.002, 0.02, 0.001, 0.07, 0.002, 1.5, 2.0, 0.8)$$。训练采用课程策略：惩罚系数从小逐渐增大，环境参数（质量、摩擦、电机强度）的随机化范围也逐渐扩大，以防止策略早期坍缩为原地不动。

**Phase 2: Adaptation Module 训练（监督蒸馏）**

Phase 1 训练的 $$\pi + \mu$$ 联合策略在仿真中表现出色，但它需要 $$\mathbf{e}_t$$ 作为输入——这是真实世界无法获取的特权信息。Phase 2 的目标是训练一个适应模块 $$\phi$$，仅使用最近 $$k = 50$$ 步（0.5秒）的状态-动作历史来估计 $$\mathbf{z}_t$$：

$$\boxed{\hat{\mathbf{z}}_t = \phi\left(\mathbf{x}_{t-k:t-1}, \mathbf{a}_{t-k:t-1}\right)}$$

$$\phi$$ 的架构是一个 1-D 卷积神经网络，用于捕获时间相关性。其输入通道数、输出通道数、核大小和步长分别为 `[32, 32, 8, 4], [32, 32, 5, 1], [32, 32, 5, 1]`。训练数据通过 on-policy 方式采集：在仿真中运行基础策略 $$\pi$$，其中 $$\mathbf{z}_t$$ 由随机初始化的 $$\phi$$ 生成（而非 ground truth $$\mu(\mathbf{e}_t)$$），将状态-动作历史与实际 $$\mathbf{z}_t$$ 配对，最小化 MSE 损失：

$$\mathcal{L}_{\text{adapt}} = \|\hat{\mathbf{z}}_t - \mathbf{z}_t\|^2 = \|\phi(\mathbf{x}_{t-k:t-1}, \mathbf{a}_{t-k:t-1}) - \mu(\mathbf{e}_t)\|^2$$

这里的关键创新是 **on-policy 数据采集 + 迭代训练**（类似于 DAgger 的思想）。随机初始化的 $$\phi$$ 会产生不准确的 $$\hat{\mathbf{z}}_t$$，导致基础策略输出次优动作、轨迹偏离专家分布——这反而成了训练数据多样性的来源，使 $$\phi$$ 在训练期间就见过分布外（out-of-distribution）状态。这一过程迭代重复直至收敛。

**部署时的异步架构**：一个精妙的设计是基础策略 $$\pi$$ 和适应模块 $$\phi$$ **异步运行**且频率不同。$$\phi$$ 处理 50 步历史（每次推理需遍历整个时间窗口），以 **10 Hz** 运行，每 0.1 秒更新一次 $$\hat{\mathbf{z}}_t$$；$$\pi$$ 以 **100 Hz** 运行，每次推理仅需单步前向传播，使用 $$\phi$$ 最近一次产生的 $$\hat{\mathbf{z}}_t$$ 与当前状态 $$\mathbf{x}_t$$、前一步动作 $$\mathbf{a}_{t-1}$$ 来预测 $$\mathbf{a}_t$$。这种设计在 A1 机器人有限的计算资源上实现了无缝部署。环境隐变量 $$\hat{\mathbf{z}}_t$$ 在真实世界中变化缓慢（地面不会在 0.1 秒内从水泥变为沙地），异步延迟并不损害性能。

### 2.2 自我中心视觉运动控制的数学基础

Agarwal et al. 将 RMA 范式推广到视觉领域，解决了"如何用前置深度相机穿越复杂地形"的问题。其核心挑战是：直接渲染深度图用于 RL 训练会使仿真速度下降一个数量级，需要数十亿样本才能收敛。

**Scandots: 视觉信息的廉价代理**

Phase 1 使用 scandots（扫描采样点）代替深度图。定义 $$\mathbf{m}_t$$ 为在机器人坐标系中预设的 $$(x, y)$$ 位置集合上查询到的地形高度值。$$\mathbf{m}_t$$ 的计算不需要渲染管线——只需在高度场上做点查询，计算代价极低。这使 RL 训练在合理时间内完成。

Phase 1 的观测为 $$\mathbf{o}_t = (\mathbf{m}_t, \mathbf{x}_t, \mathbf{u}_t^{\text{cmd}})$$，其中 $$\mathbf{u}_t^{\text{cmd}} = (v_x^{\text{cmd}}, \omega_z^{\text{cmd}})$$ 是用户指令的线速度和角速度。

**两种 Phase 1 架构**:

- **Monolithic (整体式)**: 先用 MLP 压缩 scandots 为 $$\boldsymbol{\gamma}_t = \text{MLP}(\mathbf{m}_t)$$，然后通过有状态 GRU 直接输出动作: $$\mathbf{a}_t = \text{GRU}_t(\mathbf{x}_t, \boldsymbol{\gamma}_t, \mathbf{u}_t^{\text{cmd}})$$。GRU 内部维护隐状态作为短期视觉记忆。

- **RMA (解耦式)**: 将视觉和本体感觉的处理解耦。scandots 经 GRU 处理为地形几何隐变量 $$\boldsymbol{\gamma}_t = \text{GRU}_t(\mathbf{m}_t)$$，环境参数经 MLP 处理为外因隐变量 $$\mathbf{z}_t = \text{MLP}(\mathbf{e}_t)$$，二者连同本体感觉 $$\mathbf{x}_t$$ 和指令 $$\mathbf{u}_t^{\text{cmd}}$$ 送入前馈 MLP 基础策略: $$\mathbf{a}_t = \text{MLP}(\mathbf{x}_t, \boldsymbol{\gamma}_t, \mathbf{z}_t, \mathbf{u}_t^{\text{cmd}})$$。解耦设计的优势在于模块可替换（例如将深度图替换为 RGB），且各通路可独立监督。

**Phase 2: 深度图蒸馏**

Phase 2 用监督学习将 Phase 1 策略蒸馏为仅使用板载传感器（深度图 $$\mathbf{d}_t$$ 和本体感觉 $$\mathbf{x}_t$$）的策略。

Monolithic 架构中，深度图通过卷积网络预处理后，替代 scandots 送入 GRU:

$$\tilde{\mathbf{d}}_t = \text{ConvNet}(\mathbf{d}_t), \quad \hat{\mathbf{a}}_t = \text{GRU}_t(\mathbf{x}_t, \tilde{\mathbf{d}}_t, \mathbf{u}_t^{\text{cmd}})$$

RMA 架构中，分别训练两个估计器:

$$\hat{\boldsymbol{\gamma}}_t = \text{GRU}_t(\mathbf{x}_t, \mathbf{u}_t^{\text{cmd}}, \tilde{\mathbf{d}}_t), \quad \hat{\mathbf{z}}_t = \text{GRU}_t(\mathbf{x}_t, \mathbf{u}_t^{\text{cmd}})$$

基础策略 $$\pi$$ 的权重从 Phase 1 复制并冻结。训练损失为:

$$\mathcal{L}_{\text{vision}} = \|\hat{\boldsymbol{\gamma}}_t - \boldsymbol{\gamma}_t\|^2, \quad \mathcal{L}_{\text{proprio}} = \|\hat{\mathbf{z}}_t - \mathbf{z}_t\|^2$$

**Theorem 2.1: Teacher-Student 性能上界**

这篇论文的一个独特贡献是对 teacher-student 蒸馏给出了正式的数学保证。设 Phase 1 策略的值函数 $$V^1$$ 满足 $$\forall s,\; \vert V^*(s) - V^1(s)\vert  < \epsilon$$ 且 Phase 2 策略满足 $$\forall s,\; \|\pi^1(s) - \pi^2(f(s))\| < \eta$$，其中 $$f: S \to S'$$ 是从完整状态空间到部分可观测空间的映射。若 $$R, P$$ 满足 Lipschitz 连续性，则对任意状态 $$s$$:

$$\boxed{V^*(s) - V^{\pi^2}(f(s)) < \frac{2\epsilon\gamma + \eta c}{1 - \gamma}}$$

其中 $$c \propto \sum_{s \in S} V^*(s)$$ 是有界常数。这个定理的意义在于：只要 Phase 2 策略对 Phase 1 策略的模仿误差 $$\eta$$ 足够小，且 Phase 1 本身接近最优（误差 $$\epsilon$$），Phase 2 的次优性就被线性控制在 $$\frac{\eta c}{1-\gamma}$$ 以内。实践中，这意味着如果深度图能够从 scandots 中精确重建地形信息（即 $$\eta$$ 很小），那么 Phase 2 策略的表现将与 Phase 1 几乎相同。这一形式化保证了 scandots 作为视觉代理的有效性。

---

## 三、论文精读

### 3.1 Paper 1: RMA — Rapid Motor Adaptation for Legged Robots

**Kumar, Fu, Pathak & Malik, RSS 2021**

#### 3.1.1 动机与问题设定

RMA 启动于一个尖锐的观察：传统足式机器人控制方法（MPC、轨迹优化）需要精确的动力学建模和专家手工调参。基于 RL 的方法虽然在仿真中能学到灵活动作，但从仿真到真实（sim-to-real）的迁移面临三重障碍：(a) 物理机器人与其仿真模型存在系统差异，(b) 真实地形远超仿真中的多样性，(c) 物理引擎无法精确模拟接触力学、可变形表面等复杂物理。

更致命的是，现有适应方法（如 Peng et al., RSS 2020 的 AWR）需要在每个新环境中采集 4-8 分钟的真实数据来优化隐变量——在机器人尚未获得稳定行走策略时采集数据，极可能导致摔倒和硬件损坏。RMA 的答案是：**不仅在仿真中训练行走策略，还要在仿真中训练适应能力本身。**

#### 3.1.2 环境参数与域随机化

RMA 定义环境参数向量 $$\mathbf{e}_t$$ 覆盖以下物理量（训练范围/测试范围）：

| 参数 | 训练范围 | 测试范围 (OOD) |
|------|---------|----------------|
| 摩擦系数 | [0.05, 4.5] | [0.04, 6.0] |
| PD 刚度 $$K_p$$ | [50, 60] | [45, 65] |
| PD 阻尼 $$K_d$$ | [0.4, 0.8] | [0.3, 0.9] |
| 负载 (kg) | [0, 6] | [0, 7] |
| 质心偏移 (cm) | [-0.15, 0.15] | [-0.18, 0.18] |
| 电机强度 | [0.90, 1.10] | [0.88, 1.22] |

关键洞察：测试范围在所有维度上都宽于训练范围（out-of-distribution 评估），这意味着适应模块 $$\phi$$ 的泛化能力不是记忆训练分布的插值，而是学到了从状态-动作历史中推断环境影响的通用映射。

地形生成使用分形噪声（fractal octaves = 2, lacunarity = 2.0, gain = 0.25, z-scale = 0.27），产生高度变化的不平坦地形。**地形难度在训练过程中不设课程**——策略从一开始就面对完整的随机地形，这与许多工作的渐进式课程策略形成对比。

#### 3.1.3 实验结果与关键发现

**仿真结果**（表 II）: RMA 在所有指标上优于基线方法。

| 方法 | 成功率 (%) | TTF | Reward | 距离 (m) |
|------|-----------|-----|--------|----------|
| Robust (域随机化) | 62.4 | 0.80 | 4.62 | 1.13 |
| SysID (直接预测 $$\mathbf{e}_t$$) | 56.5 | 0.74 | 4.82 | 1.17 |
| AWR (真实世界适应) | 41.7 | 0.65 | 4.17 | 0.95 |
| RMA w/o Adapt | 52.1 | 0.75 | 4.72 | 1.15 |
| **RMA** | **73.5** | **0.85** | **5.22** | **1.34** |
| Expert (上界) | 76.2 | 0.86 | 5.23 | 1.35 |

几个重要发现：
1. **预测隐变量 > 预测物理参数**: SysID 直接预测 $$\hat{\mathbf{e}}_t$$ 的表现显著差于 RMA 预测 $$\hat{\mathbf{z}}_t$$。证明精确的物理参数估计既不必要也不可行——学习一个面向控制的隐空间远比系统辨识有效。
2. **适应模块至关重要**: 去掉 $$\phi$$（RMA w/o Adapt）导致成功率从 73.5% 降到 52.1%。没有适应模块的基础策略学到一个过于保守的策略（为了对所有可能环境都鲁棒，牺牲了敏捷性）。
3. **RMA 接近上界**: RMA 的表现与拥有真理值 $$\mathbf{z}_t$$ 的 Expert 仅差 2.7 个百分点，证明 $$\phi$$ 的估计质量极高。

**真实世界室内实验**（图 3）:
- 在记忆海绵床垫上行走: RMA 100% 成功，A1 原厂控制器 100% 成功（但后者缺乏在复杂地形上的泛化能力）
- 在不平坦泡沫垫上: RMA 80% vs A1 控制器 20%，RMA w/o Adapt 0%
- 负载测试: RMA 可承载 12kg（100% 体重），A1 控制器在 8kg 时性能开始退化
- 下 15cm 台阶: RMA 80% 成功，而原厂控制器因不稳定着地而频繁摔倒

**真实世界户外实验**（图 1）:
- 沙地、泥地、土堆: 100% 成功率，无一失败
- 高草丛/灌木丛: 100% 成功率
- 山路下台阶: 70% 成功率（训练中从未见过台阶！）
- 碎石堆/水泥块: 80% 成功率

**适应分析**（图 4）: 作者在一项精心设计的实验中让机器人在脚底裹塑料袋走过油污表面。随着滑动发生，隐变量 $$\hat{\mathbf{z}}$$ 的第 1 和第 5 分量发生显著变化，表明适应模块检测到了滑动事件。适应后的步态力矩幅值增大、步态周期恢复，而 $$\hat{\mathbf{z}}$$ 持续编码"地面仍然滑溜"这一事实——说明适应模块不是简单的瞬时响应器，而是维持了对环境状态的持续性估计。

#### 3.1.4 步态涌现

一个值得强调的是，RMA 的训练**无需任何参考轨迹或预定义步态生成器**。Bioenergetics 启发的奖励函数（最小化关节做功 + 最小化地面冲击）自然地驱动了稳定、高效的步态模式——包括对角小跑（trot）和同侧步（pace）——的涌现。这证明了"从能量最小化原则出发，行走行为可以自发产生"这一核心论点。

### 3.2 Paper 2: Egocentric Locomotion in Challenging Terrains

**Agarwal, Kumar, Malik & Pathak, CoRL 2022**

#### 3.2.1 视觉运动的人类启发设计原则

这篇论文采纳了关于人类视觉行走的三个关键观察作为设计原则：
1. **自我中心视觉流**: 人类在行走时接收的是以自我为中心的前向视觉流，而非俯视的"上帝视角"高程图。行走时我们不会低头看脚下的地面，而是注视前方几步远的地形。
2. **短期视觉记忆**: 人脑维持了一个足够持久的短期记忆，使我们能在脚真正踏到之前看到的那块地面上时仍记得它的特征。从观察到落脚之间有数秒延迟。
3. **试错的步态学习**: 人类幼儿通过成千上万步的试错学会行走——不是执行"预编程的步态"，而是在无数次摔倒中涌现出稳定的运动模式。

这三条原则直接转化为技术选择：(1) 使用前置单目深度相机，不构建度量高程图；(2) 用 GRU 维持短期记忆；(3) 不使用预定义步态先验，让 RL 自行发现最优步态。

#### 3.2.2 为什么不做高程图？

传统视觉行走方法（Miki et al., Science Robotics 2022）的管线是：多帧深度图 $$\to$$ 融合为度量高程图 $$\to$$ 地形分析 + 落脚点规划 $$\to$$ 低层轨迹执行。这条管道有三个脆弱环节：

1. **高程图融合需要位姿估计**: 将不同时刻的深度图融合到统一坐标系需要视觉/惯性里程计提供的相对位姿。噪声和漂移直接污染高程图。
2. **噪声掩盖精细地形**: 踏脚石和空隙在高程图中被大量噪声模糊，导致规划失败。
3. **生物不可解释**: 没有任何证据表明人类会在脑内显式构建度量高程图。

作者通过引入噪声高程图基线（按照 Miki et al. 的噪声模型在训练和测试阶段都注入大量噪声）验证了这一观点。该基线在踏脚石和空隙任务上完全失败（见 Table 1: 平均距离 1.09m vs RMA 18.83m），因为噪声使策略无法可靠地定位石缝位置。

#### 3.2.3 小型机器人的特殊挑战与涌现行为

A1 机器人站立高度仅 40cm（ANYmal C 和 Spot 均为 70cm），髋关节离地高度约 28cm。这个尺寸带来了独特的挑战：
- 上台阶时，机器人的脚会被台阶阻挡（台阶高度接近髋高，腿没有空间抬起）
- 下台阶时，小尺寸使机器人有更大的前倾力矩，容易因重心超出支撑面而跌倒

论文发现了一个引人注目的涌现行为：在没有任何步态先验的情况下，策略学会了大幅度的**髋外展** (hip abduction) 动作——类似螃蟹的横向迈步（图 2c, 2c）。这种动作使机器人在攀爬 25cm 台阶（相当于髋关节高度的 89%）时，可以将腿从身体侧面抬起，绕过台阶阻挡。这个行为在人类设计的步态轨迹生成器中极难通过手工规则实现，但它自然地从 RL 训练和物理约束中涌现出来——这是一个"tabula rasa learning leads to novel solutions"的有力证明。

#### 3.2.4 真实世界结果

**上楼梯**（17cm 高, 30cm 深）：视觉策略 100% 成功完成整段楼梯（13 级），盲策略只能上 2.2 级后摔倒。关键是视觉策略通过 GRU 记忆在接近楼梯顶部时仍能正确估计平台位置——此时楼梯顶部已不在相机视野中。

**下楼梯**: 盲策略实现了 100% 成功率，但学到的是一个"自由落体"策略——在每一级台阶上摔倒再爬起来，导致极高的冲击力和一次实验中的真机后腿脱臼。视觉策略则学到的是受控的步降行为。

**踏脚石和空隙**: 视觉策略分别达到 94% 和 100% 成功率，盲基线 0%。踏脚石任务的 94% 成功率非常惊人，因为该任务要求从自我中心深度图中推断每块石头的位置、尺寸和间距——所有这些信息在被落脚时早已不在相机视野中。

**自然地形**: 在河床岩石、泥泞山路、沙滩（大量滑动和不稳定立足点）等户外场景中，视觉策略展示了令人印象深刻的恢复行为——被树根绊倒后重新站稳，或从岩石裂缝中脱困。

---

## 四、算法与代码实现

### 4.1 完整 RMA Pipeline (PyTorch)

以下实现复现 RMA 的完整 teacher-student 训练与异步部署逻辑。所有维度与原文严格对齐。

```python
"""
RMA: Rapid Motor Adaptation for Legged Robots
Based on Kumar et al., RSS 2021

Architecture dimensions (matching paper Sec. IV-B):
  - State x_t: 30 dims (12 joint pos + 12 joint vel + 2 roll/pitch + 4 foot contacts)
  - Action a_t: 12 dims (target joint positions)
  - Privileged env e_t: 17 dims (mass+COM(3) + motor strength(12) + friction + terrain height)
  - Extrinsics z_t: 8 dims
  - History window k: 50 timesteps (0.5s at 100Hz)
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Normal
import numpy as np

# ============================================================
# Section A: Base Policy π  (Eq. 2 from the paper)
# § III-A: "We implement π as MLPs"
# Input: x_t (30) + a_{t-1} (12) + z_t (8) = 50 dims
# Output: a_t (12 dims, target joint angles)
# Architecture: 3-layer MLP, hidden dim 128
# ============================================================
class BasePolicy(nn.Module):
    """Base policy π(x_t, a_{t-1}, z_t) -> a_t."""
    def __init__(self, state_dim=30, action_dim=12, extrinsics_dim=8, hidden_dim=128):
        super().__init__()
        input_dim = state_dim + action_dim + extrinsics_dim  # 30+12+8 = 50
        self.net = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, action_dim),
        )
        # Orthogonal initialization as common in RL [Schulman et al. 2017]
        self._init_weights()

    def _init_weights(self):
        for m in self.net:
            if isinstance(m, nn.Linear):
                nn.init.orthogonal_(m.weight, gain=0.01)
                nn.init.constant_(m.bias, 0.0)

    def forward(self, x_t, a_prev, z_t):
        """
        Args:
            x_t:   (B, 30) current state
            a_prev:(B, 12) previous action
            z_t:   (B,  8) extrinsics vector
        Returns:
            a_t:   (B, 12) target joint angles
        """
        input_vec = torch.cat([x_t, a_prev, z_t], dim=-1)
        return self.net(input_vec)


# ============================================================
# Section B: Environment Factor Encoder μ  (Eq. 1 from the paper)
# § III-A: "zt = μ(et)" — encodes privileged info into extrinsics
# Input:  e_t (17 dims)
# Output: z_t (8 dims)
# Architecture: 3-layer MLP, hidden dims [256, 128]
# ============================================================
class EnvironmentFactorEncoder(nn.Module):
    """Encodes privileged env params e_t into extrinsics z_t."""
    def __init__(self, env_dim=17, extrinsics_dim=8):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(env_dim, 256),
            nn.ReLU(),
            nn.Linear(256, 128),
            nn.ReLU(),
            nn.Linear(128, extrinsics_dim),
        )

    def forward(self, e_t):
        return self.net(e_t)


# ============================================================
# Section C: Adaptation Module φ  (§ III-B)
# Architecture: 2-layer MLP embed → 3-layer 1-D CNN → Linear
# Input channels / output channels / kernel / stride:
#   [32, 32, 8, 4], [32, 32, 5, 1], [32, 32, 5, 1]
# This is the KEY innovation: estimating z_t from proprioceptive
# history alone, enabling real-world deployment without e_t.
# ============================================================
class AdaptationModule(nn.Module):
    """
    Adaptation module φ: estimates ẑ_t from state-action history.
    Eq. from paper Sec III-B:
        ẑ_t = φ(x_{t-k:t-1}, a_{t-k:t-1})
    """
    def __init__(self, state_dim=30, action_dim=12, extrinsics_dim=8,
                 history_len=50, embed_dim=32):
        super().__init__()
        self.history_len = history_len
        input_dim = state_dim + action_dim  # 42

        # Step 1: Embed each (x, a) pair into 32-dim
        self.embed = nn.Sequential(
            nn.Linear(input_dim, embed_dim),
            nn.ReLU(),
            nn.Linear(embed_dim, embed_dim),
        )

        # Step 2: 1-D CNN over temporal dimension
        # The paper's config: 3 conv layers with channels [32,32,32]
        self.conv = nn.Sequential(
            nn.Conv1d(embed_dim, 32, kernel_size=8, stride=4),
            nn.ReLU(),
            nn.Conv1d(32, 32, kernel_size=5, stride=1),
            nn.ReLU(),
            nn.Conv1d(32, 32, kernel_size=5, stride=1),
            nn.ReLU(),
        )

        # Step 3: Linear projection to extrinsics
        # After 3 conv layers with these params:
        #   L_out = floor((L_in - 8)/4 + 1) -> floor((50-8)/4+1) = 11
        #   L_out = floor((11 - 5)/1 + 1) = 7
        #   L_out = floor((7 - 5)/1 + 1) = 3
        #   flattened: 32 * 3 = 96
        self.fc = nn.Linear(32 * 3, extrinsics_dim)

    def forward(self, state_history, action_history):
        """
        Args:
            state_history:  (B, k, 30)  k=50 timesteps of state
            action_history: (B, k, 12)  k=50 timesteps of action
        Returns:
            z_hat:          (B, 8)  estimated extrinsics vector
        """
        B, k, _ = state_history.shape
        # Concatenate state and action at each timestep
        x = torch.cat([state_history, action_history], dim=-1)  # (B, k, 42)
        x = self.embed(x)            # (B, k, 32)
        x = x.permute(0, 2, 1)       # (B, 32, k) for Conv1d
        x = self.conv(x)             # (B, 32, 3)
        x = x.reshape(B, -1)         # (B, 96)
        return self.fc(x)            # (B, 8)


# ============================================================
# Section D: Full RMA Agent (Phase 1 Training)
# Joint training of π and μ via PPO in simulation
# ============================================================
class RMAPhase1(nn.Module):
    """Phase 1: Base Policy + Environment Factor Encoder, jointly trained via PPO."""
    def __init__(self):
        super().__init__()
        self.base_policy = BasePolicy()
        self.env_encoder = EnvironmentFactorEncoder()

    def forward(self, x_t, a_prev, e_t):
        """
        Full forward pass with privileged information.
        Eq. (1)+(2): z_t = μ(e_t), a_t = π(x_t, a_{t-1}, z_t)
        """
        z_t = self.env_encoder(e_t)
        a_t = self.base_policy(x_t, a_prev, z_t)
        return a_t, z_t


# ============================================================
# Section E: Phase 2 Teacher-Student Training
# Trains φ via supervised learning with on-policy rollouts
# § III-B: Eq. L = MSE(ẑ_t, z_t) with z_t = μ(e_t)
# ============================================================
class RMAPhase2Trainer:
    """
    Phase 2: Trains adaptation module φ to predict z_t from history.
    Uses on-policy data collection (DAgger-style) to ensure
    φ sees diverse, potentially suboptimal trajectories.
    """
    def __init__(self, phase1_model, lr=5e-4):
        self.base_policy = phase1_model.base_policy
        self.env_encoder = phase1_model.env_encoder  # fixed, provides ground truth z_t
        self.adapt_module = AdaptationModule()
        self.optimizer = torch.optim.Adam(self.adapt_module.parameters(), lr=lr)

        # Freeze base_policy and env_encoder
        for p in self.base_policy.parameters():
            p.requires_grad = False
        for p in self.env_encoder.parameters():
            p.requires_grad = False

    def collect_rollout(self, env, num_steps=50):
        """
        Rollout base_policy with ẑ_t from adaptation module.
        Returns (state_history, action_history, z_ground_truth).
        Key: using adaptation module's prediction ẑ_t (not ground truth)
        during rollout ensures on-policy data distribution.
        """
        states, actions = [], []
        x_t = env.reset()
        a_prev = torch.zeros(1, 12)

        state_history = []
        action_history = []

        for _ in range(num_steps):
            with torch.no_grad():
                if len(state_history) >= 50:
                    sh = torch.stack(state_history[-50:], dim=1)
                    ah = torch.stack(action_history[-50:], dim=1)
                    z_hat = self.adapt_module(sh, ah)
                else:
                    # Not enough history yet: use zero extrinsics
                    z_hat = torch.zeros(1, 8)

                a_t = self.base_policy(x_t, a_prev, z_hat)

            x_next, e_t = env.step(a_t)  # env returns privileged info e_t in sim

            state_history.append(x_t)
            action_history.append(a_t)

            x_t = x_next
            a_prev = a_t

        # Now compute ground truth z_t for each timestep using env_encoder
        # (available only in simulation)
        sh = torch.stack(state_history[-50:], dim=0).unsqueeze(0)  # (1, 50, 30)
        ah = torch.stack(action_history[-50:], dim=0).unsqueeze(0)  # (1, 50, 12)

        # Note: in practice, you'd collect e_t at each step
        # Here we use the final e_t as illustration
        z_gt = self.env_encoder(e_t)  # (1, 8)

        return sh, ah, z_gt

    def train_step(self, sh, ah, z_gt):
        """Supervised learning step for φ."""
        z_hat = self.adapt_module(sh, ah)
        loss = F.mse_loss(z_hat, z_gt)

        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()

        return loss.item()


# ============================================================
# Section F: RMA Deployment (Asynchronous)  § III-C
# Base policy at 100Hz, Adaptation module at 10Hz
# The key async design: π uses the most recent ẑ_t from φ
# ============================================================
class RMADeployment:
    """
    Asynchronous deployment on real robot.
    - Base policy π: runs at 100Hz, uses most recent ẑ_t
    - Adaptation module φ: runs at 10Hz, updates ẑ_t from history
    """
    def __init__(self, base_policy, adapt_module, pd_kp=55.0, pd_kd=0.8):
        self.base_policy = base_policy
        self.adapt_module = adapt_module
        self.pd_kp = pd_kp
        self.pd_kd = pd_kd

        self.state_history = []   # Ring buffer of (x_t, a_t) pairs
        self.action_history = []
        self.z_hat = torch.zeros(1, 8)
        self.history_len = 50

    def compute_torque(self, joint_pos_target, joint_pos_current,
                       joint_vel_target, joint_vel_current):
        """
        PD controller converting target joint positions to torques.
        τ = K_p (q̂ - q) + K_d (q̇̂ - q̇)  (with q̇̂ = 0 per paper)
        """
        return self.pd_kp * (joint_pos_target - joint_pos_current) \
               - self.pd_kd * joint_vel_current

    def step_adaptation(self, x_t, a_prev):
        """
        Run at 10Hz: update ẑ_t from recent 50-step history.
        § III-C: "the adaptation policy is slow because it operates
        on the state-action history of 50 time steps, roughly
        updating the extrinsic vector ẑ_t once every 0.1s"
        """
        self.state_history.append(x_t)
        self.action_history.append(a_prev)

        if len(self.state_history) > self.history_len:
            self.state_history.pop(0)
            self.action_history.pop(0)

        if len(self.state_history) == self.history_len:
            sh = torch.stack(self.state_history, dim=0).unsqueeze(0)
            ah = torch.stack(self.action_history, dim=0).unsqueeze(0)
            with torch.no_grad():
                self.z_hat = self.adapt_module(sh, ah)  # (1, 8)

    def step_base_policy(self, x_t, a_prev):
        """
        Run at 100Hz: fast single forward pass using cached ẑ_t.
        § III-C: "The base policy runs at 100 Hz and uses the most
        recent ẑ_t generated by the adaptation module"
        """
        with torch.no_grad():
            a_t = self.base_policy(x_t, a_prev, self.z_hat)
        return a_t


# ============================================================
# Section G: RMA Reward Function
# § III-A: Bioenergetics-inspired reward with 10 terms
# Weights: [20, 21, 0.002, 0.02, 0.001, 0.07, 0.002, 1.5, 2.0, 0.8]
# ============================================================
def compute_rma_reward(v, omega, theta, q, q_prev, tau, tau_prev,
                       f, f_prev, a_t, q_dot, v_z, g, v_f):
    """
    Compute RMA reward at timestep t.
    All inputs are torch tensors of shape (B,).
    Returns scalar reward.
    """
    w = [20.0, 21.0, 0.002, 0.02, 0.001, 0.07, 0.002, 1.5, 2.0, 0.8]

    r_forward = w[0] * torch.minimum(v[:, 0], torch.tensor(0.35))
    r_lateral = -w[1] * (v[:, 1].pow(2) + omega[:, 2].pow(2))
    r_work = -w[2] * torch.abs((tau * (q - q_prev)).sum(dim=-1))
    r_impact = -w[3] * (f - f_prev).pow(2).sum(dim=-1)
    r_smooth = -w[4] * (tau - tau_prev).pow(2).sum(dim=-1)
    r_action = -w[5] * a_t.pow(2).sum(dim=-1)
    r_joint_speed = -w[6] * q_dot.pow(2).sum(dim=-1)
    r_orient = -w[7] * theta[:, :2].pow(2).sum(dim=-1)  # roll, pitch
    r_z_acc = -w[8] * v_z.pow(2)
    r_foot_slip = -w[9] * (g * v_f).pow(2).sum(dim=-1)

    return (r_forward + r_lateral + r_work + r_impact + r_smooth +
            r_action + r_joint_speed + r_orient + r_z_acc + r_foot_slip)
```

### 4.2 Egocentric Locomotion — Phase 2 蒸馏 (PyTorch)

以下实现复现 Egocentric Locomotion 论文 Algorithm 1 的 Phase 2 监督蒸馏过程，以及两种架构（Monolithic 和 RMA）。

```python
"""
Egocentric Locomotion — Phase 2 Supervised Distillation
Based on Agarwal et al., CoRL 2022, Algorithm 1

Two architectures:
  A. Monolithic: depth -> ConvNet -> GRU -> joint angles
  B. RMA: depth -> ConvNet -> GRU(γ̂) + hist -> GRU(ẑ) -> MLP base -> joint angles
"""

import torch
import torch.nn as nn
import torch.nn.functional as F

# ============================================================
# Architecture A: Monolithic Phase 2
# Eq. (6)+(7): d̃_t = ConvNet(d_t), â_t = GRU_t(x_t, d̃_t, u_t^cmd)
# ============================================================
class DepthConvNet(nn.Module):
    """
    Convolutional backbone for depth image preprocessing.
    Input: depth image 58x87 (after paper's preprocessing:
      480x848 -> crop 200px left -> hole fill -> downsample)
    Output: 32-dim latent
    """
    def __init__(self, output_dim=32):
        super().__init__()
        self.conv = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=5, stride=2, padding=2),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(16, 32, kernel_size=3, stride=1, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(32, 32, kernel_size=3, stride=1, padding=1),
            nn.ReLU(),
            nn.AdaptiveAvgPool2d((1, 1)),
        )
        self.fc = nn.Linear(32, output_dim)

    def forward(self, depth):
        """
        Args:
            depth: (B, 1, 58, 87) preprocessed depth image
        Returns:
            d_tilde: (B, 32) compressed depth latent
        """
        x = self.conv(depth)          # (B, 32, 1, 1)
        x = x.flatten(1)              # (B, 32)
        return self.fc(x)             # (B, 32)


class MonolithicPhase2(nn.Module):
    """
    Monolithic architecture for Phase 2.
    Directly maps depth + proprioception to actions via GRU.
    """
    def __init__(self, state_dim=30, action_dim=12, cmd_dim=2,
                 depth_latent_dim=32, gru_hidden_dim=128):
        super().__init__()
        self.depth_conv = DepthConvNet(output_dim=depth_latent_dim)
        input_dim = state_dim + depth_latent_dim + cmd_dim
        self.gru = nn.GRU(input_dim, gru_hidden_dim, batch_first=True)
        self.fc_out = nn.Linear(gru_hidden_dim, action_dim)
        self.hidden_state = None

    def forward(self, x_t, depth_t, cmd_t):
        """
        Args:
            x_t:     (B, state_dim) proprioception
            depth_t: (B, 1, 58, 87) depth image
            cmd_t:   (B, 2) velocity commands (vx, ωz)
        Returns:
            a_t:     (B, 12) target joint angles
        """
        d_tilde = self.depth_conv(depth_t)              # (B, 32)
        gru_input = torch.cat([x_t, d_tilde, cmd_t], dim=-1)  # (B, 30+32+2=64)
        gru_input = gru_input.unsqueeze(1)              # (B, 1, 64)
        gru_out, self.hidden_state = self.gru(gru_input, self.hidden_state)
        return self.fc_out(gru_out.squeeze(1))          # (B, 12)

    def reset_hidden(self, batch_size=1):
        self.hidden_state = torch.zeros(1, batch_size, self.gru.hidden_size)


# ============================================================
# Architecture B: RMA-style Phase 2
# Eq. (8)-(11):
#   d̃_t = ConvNet(d_t)
#   γ̂_t = GRU_t(x_t, u_t^cmd, d̃_t)     [terrain geometry latent]
#   ẑ_t = GRU_t(x_t, u_t^cmd)          [extrinsics latent]
#   a_t = MLP(x_t, u_t^cmd, γ̂_t, ẑ_t)   [base policy, frozen from Phase 1]
# ============================================================
class RMAVisionPhase2(nn.Module):
    """
    RMA Architecture for Phase 2 (Egocentric Vision).
    Estimates both terrain latent γ̂ and extrinsics ẑ from onboard sensing,
    then feeds them into the frozen Phase 1 base policy.
    """
    def __init__(self, state_dim=30, action_dim=12, cmd_dim=2,
                 depth_latent_dim=32, gamma_dim=8, z_dim=8):
        super().__init__()
        self.depth_conv = DepthConvNet(output_dim=depth_latent_dim)

        # Vision GRU: estimates terrain geometry latent γ̂
        # Eq. (9): γ̂_t = GRU_t(x_t, u_t^cmd, d̃_t)
        vision_input_dim = state_dim + cmd_dim + depth_latent_dim
        self.vision_gru = nn.GRU(vision_input_dim, 64, batch_first=True)
        self.gamma_head = nn.Linear(64, gamma_dim)

        # Proprioception GRU: estimates extrinsics ẑ
        # Eq. (10): ẑ_t = GRU_t(x_t, u_t^cmd)
        proprio_input_dim = state_dim + cmd_dim
        self.proprio_gru = nn.GRU(proprio_input_dim, 64, batch_first=True)
        self.z_hat_head = nn.Linear(64, z_dim)

        # Base policy MLP (frozen, copied from Phase 1)
        # Eq. (11): a_t = MLP(x_t, u_t^cmd, γ̂_t, ẑ_t)
        base_input_dim = state_dim + cmd_dim + gamma_dim + z_dim
        self.base_policy = nn.Sequential(
            nn.Linear(base_input_dim, 128),
            nn.ReLU(),
            nn.Linear(128, 128),
            nn.ReLU(),
            nn.Linear(128, action_dim),
        )

        self.vision_hidden = None
        self.proprio_hidden = None

    def forward(self, x_t, depth_t, cmd_t):
        """
        Full Phase 2 forward pass (RMA architecture).
        Returns actions and both latent estimates for training losses.
        """
        B = x_t.shape[0]
        d_tilde = self.depth_conv(depth_t)

        # Vision GRU: estimate γ̂
        vision_input = torch.cat([x_t, cmd_t, d_tilde], dim=-1).unsqueeze(1)
        vision_out, self.vision_hidden = self.vision_gru(vision_input, self.vision_hidden)
        gamma_hat = self.gamma_head(vision_out.squeeze(1))

        # Proprioception GRU: estimate ẑ
        proprio_input = torch.cat([x_t, cmd_t], dim=-1).unsqueeze(1)
        proprio_out, self.proprio_hidden = self.proprio_gru(proprio_input, self.proprio_hidden)
        z_hat = self.z_hat_head(proprio_out.squeeze(1))

        # Frozen base policy
        base_input = torch.cat([x_t, cmd_t, gamma_hat, z_hat], dim=-1)
        a_t = self.base_policy(base_input)

        return a_t, gamma_hat, z_hat

    def reset_hidden(self, batch_size=1):
        self.vision_hidden = torch.zeros(1, batch_size, 64)
        self.proprio_hidden = torch.zeros(1, batch_size, 64)


# ============================================================
# Phase 2 Training Loop (Algorithm 1 from the paper)
# DAgger with BPTT truncated at T=24 timesteps
# ============================================================
def train_phase2_rma(phase1_policy, phase2_model, env, num_iterations=1000,
                     batch_size=256, trunc_len=24, lr=5e-4):
    """
    DAgger training for Phase 2 (RMA architecture).
    § 2.2: "trained using DAgger with truncated BPTT to minimize MSE"
    Loss: L_vision = ||γ̂_t - γ_t||², L_proprio = ||ẑ_t - z_t||²
    """
    optimizer = torch.optim.Adam(phase2_model.parameters(), lr=lr)

    for iteration in range(num_iterations):
        total_loss = 0.0
        optimizer.zero_grad()

        # Reset GRU hidden states
        phase2_model.reset_hidden(batch_size)
        # Phase 1 policy also has GRUs in its RMA variant
        # (reset appropriate stateful modules)

        for t in range(trunc_len):
            # Get observations from simulator
            obs = env.get_observations()  # includes depth, proprioception, scandots
            x_t = obs['proprioception']
            depth_t = obs['depth']
            cmd_t = obs['command']
            mt = obs['scandots']       # privileged in Phase 1
            et = obs['env_params']      # privileged in Phase 1

            # Phase 1 produces ground truth actions and latents
            with torch.no_grad():
                a1_t, gamma_gt, z_gt = phase1_policy(x_t, mt, et, cmd_t)

            # Phase 2 produces estimated actions and latents
            a2_t, gamma_hat, z_hat = phase2_model(x_t, depth_t, cmd_t)

            # Combined loss: action imitation + latent reconstruction
            loss_action = F.mse_loss(a2_t, a1_t)
            loss_gamma = F.mse_loss(gamma_hat, gamma_gt)
            loss_z = F.mse_loss(z_hat, z_gt)
            loss = (loss_action * 1.0 + loss_gamma * 0.1 + loss_z * 0.1)

            total_loss += loss

            # Step simulator with student actions (DAgger: on-policy!)
            env.step(a2_t)

        total_loss.backward()
        # Gradient clipping for stability (common in RL)
        torch.nn.utils.clip_grad_norm_(phase2_model.parameters(), max_norm=1.0)
        optimizer.step()

        # Detach GRU hidden states to truncate BPTT
        phase2_model.vision_hidden = phase2_model.vision_hidden.detach()
        phase2_model.proprio_hidden = phase2_model.proprio_hidden.detach()

        if iteration % 100 == 0:
            print(f"Iter {iteration}: loss = {total_loss.item():.4f}")
```

### 4.3 地形重建损失的关键设计

无论是 RMA 的 Phase 2 ($$\mathcal{L}_{\text{adapt}} = \|\hat{\mathbf{z}}_t - \mathbf{z}_t\|^2$$) 还是 Egocentric Locomotion 的 Phase 2 ($$\mathcal{L}_{\text{vision}} = \|\hat{\boldsymbol{\gamma}}_t - \boldsymbol{\gamma}_t\|^2$$, $$\mathcal{L}_{\text{proprio}} = \|\hat{\mathbf{z}}_t - \mathbf{z}_t\|^2$$)，训练损失的核心思想都是**隐空间对齐**而非显式的地形重建。这里有几个精妙的设计理由：

1. **不预测物理参数**: Phase 2 直接估计隐变量 $$\hat{\mathbf{z}}_t$$ 而非物理参数 $$\hat{\mathbf{e}}_t$$。RMA 的消融实验 (Table II: SysID 56.5% vs RMA 73.5%) 证明了这一点——物理参数可能不可辨识（多个参数组合产生相同效果），但对控制策略而言，知道"应该如何调整行为"比知道"精确的摩擦力是多少"更有用。

2. **不重建高程图**: Egocentric Locomotion 不构建度量高程图，直接让 GRU 从自我中心深度图估计地形几何隐变量 $$\hat{\boldsymbol{\gamma}}_t$$。这避免了高程图融合带来的噪声和位姿估计误差。

3. **On-policy 蒸馏的妙用**: 两个 Phase 2 训练都使用学生策略自己的动作来驱动仿真（而非使用 Phase 1 的动作），确保学生在训练期间看到的是它未来部署时会遇到的状态分布——包含由于不完美适应而产生的非专家轨迹。这是 DAgger 算法在足式机器人中的典型应用。

---

## 五、核心 Takeaway 与延伸阅读

### 5.1 五个核心洞察

**Insight 1: 学会"如何适应"比学会"适应什么"更重要。**
RMA 不去估计精确的物理参数（摩擦力、质量），而是学习一个 8 维紧致隐空间 $$\mathbf{z}_t$$，优化目标是"给定这个隐变量，采取什么动作最佳"而非"精确重建物理参数"。SysID 基线（56.5% vs RMA 73.5%）的惨败证明了这一点：对于控制而言，参数可辨识性不如面向控制的有用表示。

**Insight 2: Teacher-Student 蒸馏是 sim-to-real 的核心范式。**
两篇论文共享同一个基础架构：Phase 1 用特权信息训练全知 teacher，Phase 2 通过 DAgger 式的监督学习蒸馏到仅使用板载传感器的 student。这种范式巧妙地将"在仿真中用不可部署的信息训练最优策略"和"在真实世界中用可获取的信息推断"解耦，由训练承担复杂性，部署则保持轻量。

**Insight 3: 异步部署是低成本机器人的关键使能技术。**
RMA 的适应模块（10Hz, 处理 50 步历史）和基础策略（100Hz, 单步前向传播）的异步设计，使得在 A1 机器人极其有限的计算资源上也能运行完整系统。这背后的物理直觉是：环境参数变化缓慢——摩擦系数不可能在 0.1 秒内剧烈变化——因此低频更新 $$\hat{\mathbf{z}}_t$$ 完全足够。

**Insight 4: 视觉运动控制不需要高程图。**
Agarwal et al. 展示了端到端从自我中心深度到关节力矩的映射——无需度量高程图、无需落脚点规划、无需多帧深度融合——可以直接穿越踏脚石和空隙。高程图基线在这些任务上的完全失败（盲策略 0%，噪声高程图 1.09m vs Egocentric 18.83m）说明：与其构建一个明确的空间表示，不如让网络学会隐式的"视觉-记忆-动作"映射。

**Insight 5: 涌现行为可以超越人类设计。**
A1 机器人攀爬 25cm 台阶时自然产生了大幅度的髋外展 (hip abduction) 动作——一种类似螃蟹的横向迈步策略。这种策略在人类设计的步态轨迹生成器中几乎不可能通过手工规则实现，但它从物理约束和能量最小化的 RL 目标中自然涌现。这证明了 tabula rasa 学习的威力：不预设解的形式，让物理规律和优化目标自行发现最有效的运动模式。

### 5.2 开放问题与研究方向

1. **视觉世界 vs 真实世界的不匹配**: Egocentric Locomotion 在当前范式下，遇到仿真和真实世界视觉不一致时只能"工程回仿真"重新训练。未来的方向是使用真实世界数据持续微调视觉和运动策略。

2. **从盲适应到视觉适应的统一**: RMA 展示了盲本体感觉适应的威力，Egocentric Locomotion 展示了自我中心视觉的价值。一个自然的问题是：能否将二者统一为一个框架，使机器人能在视觉信息和本体感觉之间无缝切换（例如相机被遮挡时回退到盲适应模式）？

3. **更丰富的感知模态**: 两篇论文分别使用了本体感觉和深度视觉。触觉（足底力/力矩）、听觉（电机声音异常）和温度感知如何融入适应框架？

4. **多机器人泛化 (Cross-Embodiment)**: 当前框架为每个机器人平台单独训练。能否学习一个通用的适应模块，使策略在不同形态的足式机器人（两足、四足、六足）之间迁移？

### 5.3 推荐延伸阅读

- **Lee et al., "Learning Quadrupedal Locomotion over Challenging Terrain," Science Robotics, 2020.** 在真实世界中训练四足行走策略的里程碑工作，采用 teacher-student 蒸馏将 MPC 专家策略蒸馏为可部署的神经网络。
- **Miki et al., "Learning Robust Perceptive Locomotion for Quadrupedal Robots in the Wild," Science Robotics, 2022.** 基于高程图的视觉行走方法，使用 ANYmal 机器人在复杂自然地形中实现鲁棒行走。
- **Hoeller et al., "ANYmal Parkour: Learning Agile Navigation for Quadrupedal Robots," Science Robotics, 2024.** 将视觉运动控制推向极限——四足机器人完成跑酷（跳跃、攀爬、穿越窄缝）。
- **Fu et al., "Minimizing Energy Consumption Leads to the Emergence of Gaits in Legged Robots," CoRL 2021.** 系统研究能量最小化奖励如何驱动不同步态的涌现，是 RMA 中 bioenergetics 奖励的理论基础。
- **Peng et al., "Learning Agile Robotic Locomotion Skills by Imitating Animals," RSS 2020.** 通过模仿动物运动数据学习敏捷的四足行走技能，展示了参考轨迹 + RL 微调的范式。

---

## 六、练习与思考

**练习 1 (RMA 隐空间分析)** 假设我们有一个训练好的 RMA 模型。设计一个实验来可视化 8 维隐空间 $$\mathbf{z}$$ 的结构。哪些分量对应摩擦力的变化？哪些编码了负载？哪些编码了地形坡度？提示：在仿真中系统性地改变单个环境参数，观察 $$\mu(\mathbf{e})$$ 各分量的响应。

**练习 2 (适应速度与历史长度)** RMA 的适应模块使用 $$k=50$$ 步（0.5秒）历史。如果我们改变 $$k$$ 的值，适应速度（收敛到正确 $$\hat{\mathbf{z}}$$ 所需的步数）和稳态精度如何变化？对 $$k \in \{10, 25, 50, 100\}$$ 运行仿真实验，绘制 TTF 随 $$k$$ 变化的曲线。讨论短历史（噪声更敏感）与长历史（响应更慢）之间的 trade-off。

**练习 3 (Scandots 到深度的信息损失)** 证明或反证：如果 scandots 的采样密度足够高且相机视场足够宽，从深度图中估计 $$\boldsymbol{\gamma}_t$$ 是无损的（信息论意义下）。如果存在固有的信息损失，哪些地形特征会因自我中心视角而永久丢失？提示：考虑相机盲区（机器人身体正下方的区域）和遮挡。

**练习 4 (奖励权重消融)** RMA 的奖励函数有 10 个分量，每个有权重系数。设计一个消融实验：逐个移除奖励分量（将对应权重设为零），在仿真中训练策略并评估步态质量。哪些奖励分量对稳定步态的涌现是不可或缺的？哪些可以被省略而不明显影响表现？用矩阵形式列出你的消融结果。

**练习 5 (实现异步部署)** 在仿真中实现 RMA 的异步部署逻辑（适应模块 10Hz, 基础策略 100Hz），与同步运行（两者均为 100Hz 或均为 10Hz）进行对比。定量分析：(a) 异步 vs 同步对行走成功率的影响，(b) 计算量差异，(c) 适应模块延迟（从环境变化到 $$\hat{\mathbf{z}}$$ 更新）对恢复行为的影响。

**练习 6 (设计你的适应模块)** 假设你有一个不同于 A1 的机器人平台（例如双足机器人 Cassie 或六足机器人）。RMA 的哪些设计选择可以不加修改地移植？哪些需要针对新平台调整？写出你的适应性设计方案，特别关注：(a) 状态空间和动作空间的维度，(b) 环境参数 $$\mathbf{e}_t$$ 的组成，(c) 奖励函数是否需要修改以鼓励不同形态的步态。

**练习 7 (Theorem 2.1 的边界计算)** 给定 Phase 1 训练后的值函数误差 $$\epsilon = 0.05$$，Phase 2 的模仿误差 $$\eta = 0.1$$，折扣因子 $$\gamma = 0.99$$，且已知 $$\sum_{s} V^*(s) \approx 1000$$。假设 $$L_R = 1.0, L_P = 0.5$$，计算 Theorem 2.1 给出的 Phase 2 策略次优性上界。讨论：这个上界是否足够紧？在实际部署中，什么因素可能导致性能超出这个理论上界？


---

<!-- chapter-nav -->
<div style="display:flex; justify-content:space-between; align-items:center; padding:1em 0;">
  <div><a href="ch13_视觉模仿学习.md">← 第13章 视觉模仿学习</a></div>
  <div><a href="index.md">↑ 目录</a></div>
  <div><a href="ch15_导航案例研究.md">第15章 导航案例研究 →</a></div>
</div>
