【四足强化学习经典算法】(一):RMA——盲视运动控制与快速适应模块

前言

  • 强化学习的价值不应局限于算法本身,更重要的是将其应用于复杂的实际问题。尤其是在机器人领域,面对传统控制方法难以应对的复杂环境与任务,强化学习为机器人自主决策与运动控制提供了新的思路。因此,学习强化学习不能仅停留在算法原理和简单环境的实验上,还需要结合复杂机器人与实际应用场景,进一步理解算法的设计思想与应用价值。

  • 最近,实验室刚好入手了一台宇树四足机器人,正好借此机会,系统学习四足机器人强化学习领域的经典算法。

  • 本系列将围绕经典论文与开源代码展开,重点讲解算法的核心思想、理论原理与具体实现,尝试从理论与代码两个层面加深对相关算法的理解。

  • 关于相关算法的仿真实验与实物部署,我们将放在另一个系列中展开。两个系列并行推进:一个侧重于论文与源码分析 ,另一个侧重于算法复现与实际部署 ,希望通过理论学习与工程实践相结合,逐步建立对四足机器人强化学习的系统认识。(下图为Mjlab部署的go2机器人,我们会在另一个系列探讨如何部署)

  • 本文将从四足机器人强化学习的基础概念入手,介绍本体感知运动控制领域的经典算法 RMA,重点讲解其如何利用历史观测信息估计机器人当前的环境状态与动力学特性,并通过适应模块辅助控制策略应对未知地形、负载变化等复杂情况,从而提升机器人的运动适应能力。

文章目录

    • 前言
    • [1 四足机器人强化学习](#1 四足机器人强化学习)
        • [1-1 四足机器人介绍](#1-1 四足机器人介绍)
        • [1-2 两大分类方法](#1-2 两大分类方法)
        • [1-3 强化学习在四足机器人中的应用](#1-3 强化学习在四足机器人中的应用)
          • [1-3-1 问题建模](#1-3-1 问题建模)
          • [1-3-2 训练步骤](#1-3-2 训练步骤)
          • [1-3-3 sim-to-real](#1-3-3 sim-to-real)
          • [1-3-4 分类](#1-3-4 分类)
        • [1-4 RL Proprioceptive Locomotion](#1-4 RL Proprioceptive Locomotion)
        • [1-5 RL Perceptive Locomotion](#1-5 RL Perceptive Locomotion)
    • [2 RMA](#2 RMA)
        • [2-1 介绍](#2-1 介绍)
          • [2-1-1 论文与思想](#2-1-1 论文与思想)
          • [2-1-2 使用场景](#2-1-2 使用场景)
        • [2-2 架构:基础策略与适应模块](#2-2 架构:基础策略与适应模块)
          • [2-2-1 整体结构](#2-2-1 整体结构)
          • [2-2-2 Base Policy:基础策略](#2-2-2 Base Policy:基础策略)
          • [2-2-3 Adaptation Module:适应模块](#2-2-3 Adaptation Module:适应模块)
        • [2-3 第一阶段:利用特权信息训练基础策略](#2-3 第一阶段:利用特权信息训练基础策略)
          • [2-3-1 特权信息与环境编码](#2-3-1 特权信息与环境编码)
          • [2-3-2 基础策略训练](#2-3-2 基础策略训练)
        • [2-4 第二阶段:利用历史观测训练适应模块](#2-4 第二阶段:利用历史观测训练适应模块)
          • [2-4-1 适应模块训练与构成](#2-4-1 适应模块训练与构成)
          • [2-4-2 适应模块实现](#2-4-2 适应模块实现)
        • [2-5 两个模块协同工作](#2-5 两个模块协同工作)
    • [3 RMA 源码解析](#3 RMA 源码解析)
        • [3-1 官方代码仓库与工程结构](#3-1 官方代码仓库与工程结构)
        • [3-2 基础策略的训练流程](#3-2 基础策略的训练流程)
        • [3-3 特权信息与环境编码的实现](#3-3 特权信息与环境编码的实现)
        • [3-4 适应模块的训练与损失函数](#3-4 适应模块的训练与损失函数)
        • [3-5 策略推理与部署流程](#3-5 策略推理与部署流程)
    • 总结

1 四足机器人强化学习

1-1 四足机器人介绍
  • 四足机器人由机身与四条腿组成,每条腿通常包含三个主动关节:髋关节侧摆、髋关节俯仰与膝关节。以宇树 A1 为例,整机共 18 个自由度,其中 12 个由电机驱动。
  • 关节一般采用位置控制接口:上层控制器输出目标关节角 q ^ \hat q q^,底层驱动器按照 PD(Proportional-Derivative,比例-微分)控制律将其换算为力矩 τ = K p ( q ^ − q ) − K d q ˙ \tau = K_p(\hat q - q) - K_d \dot q τ=Kp(q^−q)−Kdq˙其中:
    • q q q :实际关节角
    • q ˙ \dot q q˙ :关节角速度
    • K p K_p Kp、 K d K_d Kd :比例与微分增益。
    • 目标关节速度通常置零,因此该控制律等价于在关节空间内引入一个弹簧-阻尼系统。
  • 足地接触属于单边约束:脚可以离开地面,但不能穿透地面。接触状态在一个步态周期内反复切换,接触力还必须落在摩擦锥内,否则会出现打滑。这类约束是分段光滑的,难以用单一的连续模型描述。
  • 步态指四条腿的相位关系。常见步态包括对角小跑、同侧小跑、跳跃与溜步。对角小跑在任意时刻有两条腿支撑,稳定性与能耗较为均衡,也是四足强化学习中最常见的训练步态。
  • 机身的稳定性通常由支撑多边形、零力矩点(ZMP,Zero Moment Point)等指标描述。基于模型的控制器会显式利用这些量,而端到端强化学习并不显式计算,稳定性以奖励与终止条件的形式隐式约束。
  • 四足运动控制的困难可以归纳为四点:
    1. 动力学是混杂的:每条腿的接触状态在支撑相与摆动相之间切换,系统在不同阶段服从不同方程
    2. 参数具有不确定性:机身质量、质心位置、地面摩擦、电机出力都会随任务与磨损变化
    3. 感知是受限的:真实机器人无法直接测得地面摩擦与地形高度,只能依靠本体感受器间接推断
    4. 任务要求多样:平地行走、速度跟踪、上下台阶与抗扰恢复对策略提出的要求并不相同
1-2 两大分类方法
  • 现阶段,四足机器人运动控制方法大致可以分为两大类:基于模型的控制方法 和基于学习的控制方法 。
    • 基于模型的控制方法(Model-Based Control) :依赖机器人动力学模型,通过建立数学模型并结合优化算法求解控制指令。经典方法包括模型预测控制(MPC)和全身控制(WBC)等,具有物理意义明确、可解释性较强等优点,但对模型精度与控制器设计有一定要求。

      • MPC 在每个控制周期内对未来一段时域做滚动优化,并只执行第一步。它需要预先给定接触序列与动力学模型,对模型误差与未建模的接触非线性较为敏感。
      • WBC 把任务组织为带优先级的二次规划问题,在满足动力学、摩擦锥与力矩约束的前提下尽可能跟踪任务目标。其性能依赖模型的准确性,参数标定成本较高。
    • 基于学习的控制方法(Learning-Based Control):通过机器学习方法从数据或与环境的交互中学习控制策略,其中强化学习(RL)是目前四足机器人运动控制领域的重要技术路线。该类方法能够通过训练获得复杂的运动控制能力,在复杂地形适应、动态运动等任务中具有较大潜力,但通常需要大量训练数据或仿真交互,并面临 sim-to-real 迁移等挑战。

  • 两类方法的差异可以用一张表概括:
维度 基于模型 基于学习
模型依赖 需要精确的动力学与接触模型 不显式建模,从数据中学习
调参对象 权重、时域、约束 奖励函数、网络结构、随机化范围
计算特点 在线优化,计算量随时域增长 推理为一次前向,训练在离线完成
泛化能力 超出模型假设即失效 依赖训练分布,可由随机化扩展
可解释性 约束与目标明确 策略为黑箱,需借助分析手段
  • 两类方法并不互斥。常见的融合方案是用强化学习训练高层策略、用模型方法完成低层跟踪,或者在仿真中把模型控制器的输出作为示范数据。
1-3 强化学习在四足机器人中的应用
1-3-1 问题建模
  • 四足运动控制可以建模为部分可观测马尔可夫决策过程(POMDP,Partially Observable Markov Decision Process)。智能体在时刻 t t t 获得观测 o t o_t ot,输出动作 a t a_t at,环境转移至下一状态并给出奖励 r t r_t rt。
    • 观测通常由本体感受器提供,包括机身角速度、重力在机体系的投影、关节角、关节角速度与上一步动作。动作一般为目标关节角,经 PD 控制器转换为力矩。
    • 奖励由跟踪项与正则项组成:跟踪项鼓励机器人在指定方向上保持给定速度,正则项约束力矩、动作幅值、关节速度与机身姿态,避免策略学到抖动或高能耗的步态。
1-3-2 训练步骤
  • 训练流程可以拆成五个步骤:
    1. 搭建仿真环境:导入机器人模型,设置接触参数、仿真步长与控制步长
    2. 设计域随机化:对质量、摩擦、电机强度、地形等参数采样,扩大训练分布
    3. 优化策略:用 PPO(Proximal Policy Optimization,近端策略优化)等算法在并行环境中采样并更新网络
    4. 导出策略:将策略网络固化为推理图,连同观测归一化统计量一并导出
    5. 实机部署:在机载计算机上以固定频率运行策略,输出目标关节角
  • 典型任务包括平地行走、速度跟踪、复杂地形运动与抗扰控制。速度跟踪要求策略在给定线速度与角速度指令下保持稳定步态;复杂地形运动要求策略根据地形调整抬腿高度与落足点;抗扰控制要求策略在受到外力后恢复平衡。
  • 然而,在仿真环境中训练出表现良好的策略,并不意味着它能够直接应用于真实机器人。由于仿真与现实之间存在动力学、接触特性和传感器等方面的差异,如何将仿真中学到的控制策略可靠地迁移到真实机器人上,就成为了四足机器人强化学习中的关键问题。
1-3-3 sim-to-real
  • 强化学习通常需要大量环境交互来训练策略,直接在真实四足机器人上进行训练,不仅成本高、效率低,还可能损坏机器人。因此,当前四足机器人强化学习通常采用先在仿真环境中训练,再将策略迁移至真实机器人的方式,这一过程称为 Sim-to-Real。
  • 仿真环境与真实世界之间存在一定的差异,主要包括:
    • 动力学差异:机器人质量、质心位置、关节惯量、电机响应与阻尼等参数难以完全准确地建模。
    • 接触差异:仿真中的足地接触、摩擦与碰撞模型难以完全还原真实接触过程。
    • 传感器差异:真实传感器存在噪声、测量误差与延迟,仿真观测则可能过于理想化。
    • 执行差异:真实电机的控制频率、响应延迟与输出能力存在限制,无法始终精确跟踪策略给出的目标。
  • 为了提高策略在真实机器人上的泛化能力,常见方法包括:
    1. 域随机化(Domain Randomization):在训练过程中随机改变质量、摩擦系数、电机强度与地形等参数,使策略适应更广泛的环境条件。
    2. 系统辨识(System Identification):通过真实机器人实验估计动力学参数,并据此调整仿真模型,使其更接近真实系统。
    3. 鲁棒策略学习(Robust Policy Learning):通过扰动训练、随机外力与多样化环境,提高策略对模型误差和外部干扰的适应能力。
    4. 观测与控制对齐:确保仿真与实机的观测定义、归一化方式、动作含义、控制频率及 PD 参数保持一致,避免部署时出现输入输出不匹配。
  • 总的来说,Sim-to-Real 并不是简单地将训练好的神经网络部署到真实机器人上,而是需要尽可能缩小仿真与现实之间的差距,并保证策略在真实系统中的安全性与稳定性。对于四足机器人而言,这也是强化学习从仿真研究走向实际应用的关键环节。
1-3-4 分类
  • 按照策略输入信息的不同,可以进一步划分为本体感知运动控制(Proprioceptive Locomotion)与感知运动控制(Perceptive Locomotion)。前者只使用机器人自身的状态,后者额外引入外部传感器对环境的观测。
1-4 RL Proprioceptive Locomotion
  • 本体感知运动控制只依赖机器人自身的状态量,不引入相机、激光雷达等外部传感器,硬件成本低、实时性好,是四足强化学习最成熟的分支。
    • 基于多层感知机(MLP,Multilayer Perceptron)的前馈策略(MLP Policy)以关节位置、关节速度、机身姿态、角速度及期望速度等作为输入,经多层网络直接输出关节目标或关节控制量。该结构简单,但单帧观测无法反映运动趋势与环境变化。
    • 基于历史观测的策略(History-based Policy)把连续多帧的本体观测拼接为输入,使策略能够从状态的变化率中推断接触状态与地形起伏。历史窗口越长,可推断的信息越多,但输入维度与训练难度也随之上升。
    • 基于循环神经网络的策略(RNN / GRU / LSTM)通过隐状态保存历史信息,使策略具备时序建模能力。其优点是不需要显式设定窗口长度,缺点是训练稳定性较差,且推理时需要维护隐状态。
    • 基于隐式环境估计的策略(Latent Estimation)从本体观测历史中推断地形、接触条件或动力学变化等难以直接观测的信息,再把隐变量输入策略。代表性工作包括 RMA、DreamWaQ 等,但两者的具体估计目标与训练机制并不相同。
  • 四类方法的对比如下:
方法 输入 时序建模方式 主要局限
MLP Policy 单帧本体观测 无 无法感知趋势与环境变化
History-based 多帧观测拼接 固定窗口 维度随窗口线性增长
RNN / GRU / LSTM 当前观测与隐状态 循环单元 训练稳定性与推理状态维护
Latent Estimation 观测历史 编码器估计隐变量 需要额外的监督信号或联合优化
1-5 RL Perceptive Locomotion
  • 感知运动控制额外引入外部传感器,使策略能够提前获知前方的地形起伏,从而在落足之前完成调整。
    • 地形高度图(Height Map)把机器人周围的地形表示为高度网格,通常以机身为原点建立局部栅格,每个栅格存放该处相对机身的高度。高度图直接给出几何信息,便于策略选择落足点。
    • 深度图与视觉感知(Depth / Vision)利用深度相机或视觉网络提取环境信息,帮助机器人应对台阶、障碍物等复杂环境。其难点在于视觉信号的噪声、光照变化与仿真到真实的视觉差距。
    • 地形特征编码(Terrain Encoding)先由 CNN(Convolutional Neural Network,卷积神经网络)或 MLP 从高度图、深度图中提取低维特征,再与本体观测融合后送入策略。该结构把感知与决策解耦,训练更稳定。
    • 感知与运动联合学习(End-to-End Learning)把感知模块与运动策略连接为单一网络,通过强化学习联合优化整条控制链路。该方案理论上最优,但对数据量与仿真真实度的要求最高。
  • 本体感知与感知运动控制的差异可以概括为:
维度 本体感知 感知运动
输入信息 自身状态 自身状态与外部环境
硬件成本 低 需要相机或雷达
前置信息 无 可提前感知地形
主要风险 只能事后适应 感知噪声与仿真差距
典型方法 RMA、DreamWaQ 高度图策略、视觉策略

2 RMA

2-1 介绍
2-1-1 论文与思想
  • RMA(Rapid Motor Adaptation 快速运动适应) 是四足机器人强化学习领域一篇具有代表性的工作,核心目标是让机器人在无法直接获得完整环境信息的前提下,快速适应地形、动力学等环境变化。
  • RMA 是一种运动适应框架(framework),属于本体感知运动控制:部署阶段只使用机器人自身的本体观测与历史动作,不依赖外部传感器。

RMA 的核心思想在于:如何在不借助外部感知传感器的前提下,利用机器人自身的本体感知历史信息,推断当前环境与动力学特性的变化,并将其作为隐式环境表征输入控制策略,从而使机器人能够快速适应不同地形、负载变化等复杂情况。

  • RMA 的训练过程分为两个阶段。
    • 首先,在仿真环境中训练基础控制策略,使机器人能够在不同环境条件下实现稳定运动;
    • 随后,训练适应模块,使其能够根据机器人近期的本体感知历史信息,估计当前环境与动力学特性的变化,并将估计结果提供给基础策略。
  • 通过这种方式,机器人无需依赖外部环境感知传感器,也能适应不同地形与动力学条件。
2-1-2 使用场景
  • 设想四足机器人已经在普通地面上学会行走,此时遇到以下变化:
    • 地面摩擦力发生变化,脚底更容易打滑
    • 背上突然增加负载,机器人原有的控制策略不再合适
    • 地面变得松软或崎岖,机器人需要调整抬腿高度与落足方式
  • 上述变化在动力学上表现为参数漂移:摩擦系数、机身质量与质心位置、电机出力都偏离了训练时的名义值。固定策略只能在名义参数附近保持性能,一旦偏离过大就会失稳。
  • 若采用域随机化,训练时的参数范围可以覆盖这些变化,从而提高策略对参数漂移的鲁棒性。但策略本身没有显式的环境估计机制,难以针对当前环境的具体特征调整控制行为。RMA 则进一步利用历史本体感知信息估计环境隐编码,并将其作为基础策略的条件输入。
  • RMA 的核心思路是:让机器人不仅学会运动,还能根据运动过程中获得的信息,判断当前环境与动力学发生了什么变化,并及时调整控制策略。
  • 由此,问题转化为一个在线系统辨识问题:如何根据历史状态与动作,快速估计未知的环境与动力学变化,并把估计结果交给运动策略使用。

说人话:传统策略需要在多种环境下训练,容易学到一种折中的控制方式;而 RMA 引入了适应模块,能够根据机器人近期的本体感知信息,判断当前环境与动力学特性,并动态调整控制策略,让机器人在不同环境下都能更好地运动。

2-2 架构:基础策略与适应模块
2-2-1 整体结构
  • RMA 由基础策略与适应模块两部分组成,二者的数据流如下:

#mermaid-svg-4NNfMdSXXhjjkYLt{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4NNfMdSXXhjjkYLt .error-icon{fill:#552222;}#mermaid-svg-4NNfMdSXXhjjkYLt .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4NNfMdSXXhjjkYLt .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4NNfMdSXXhjjkYLt .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4NNfMdSXXhjjkYLt .marker.cross{stroke:#333333;}#mermaid-svg-4NNfMdSXXhjjkYLt svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4NNfMdSXXhjjkYLt p{margin:0;}#mermaid-svg-4NNfMdSXXhjjkYLt .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster-label text{fill:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster-label span{color:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster-label span p{background-color:transparent;}#mermaid-svg-4NNfMdSXXhjjkYLt .label text,#mermaid-svg-4NNfMdSXXhjjkYLt span{fill:#333;color:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt .node rect,#mermaid-svg-4NNfMdSXXhjjkYLt .node circle,#mermaid-svg-4NNfMdSXXhjjkYLt .node ellipse,#mermaid-svg-4NNfMdSXXhjjkYLt .node polygon,#mermaid-svg-4NNfMdSXXhjjkYLt .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-4NNfMdSXXhjjkYLt .rough-node .label text,#mermaid-svg-4NNfMdSXXhjjkYLt .node .label text,#mermaid-svg-4NNfMdSXXhjjkYLt .image-shape .label,#mermaid-svg-4NNfMdSXXhjjkYLt .icon-shape .label{text-anchor:middle;}#mermaid-svg-4NNfMdSXXhjjkYLt .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4NNfMdSXXhjjkYLt .rough-node .label,#mermaid-svg-4NNfMdSXXhjjkYLt .node .label,#mermaid-svg-4NNfMdSXXhjjkYLt .image-shape .label,#mermaid-svg-4NNfMdSXXhjjkYLt .icon-shape .label{text-align:center;}#mermaid-svg-4NNfMdSXXhjjkYLt .node.clickable{cursor:pointer;}#mermaid-svg-4NNfMdSXXhjjkYLt .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-4NNfMdSXXhjjkYLt .arrowheadPath{fill:#333333;}#mermaid-svg-4NNfMdSXXhjjkYLt .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-4NNfMdSXXhjjkYLt .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-4NNfMdSXXhjjkYLt .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4NNfMdSXXhjjkYLt .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-4NNfMdSXXhjjkYLt .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4NNfMdSXXhjjkYLt .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster text{fill:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt .cluster span{color:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4NNfMdSXXhjjkYLt .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-4NNfMdSXXhjjkYLt rect.text{fill:none;stroke-width:0;}#mermaid-svg-4NNfMdSXXhjjkYLt .icon-shape,#mermaid-svg-4NNfMdSXXhjjkYLt .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-4NNfMdSXXhjjkYLt .icon-shape p,#mermaid-svg-4NNfMdSXXhjjkYLt .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-4NNfMdSXXhjjkYLt .icon-shape .label rect,#mermaid-svg-4NNfMdSXXhjjkYLt .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-4NNfMdSXXhjjkYLt .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4NNfMdSXXhjjkYLt .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4NNfMdSXXhjjkYLt :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 历史本体观测与动作
适应模块

Adaptation Module
隐式环境特征

Latent Encoding
当前本体观测
基础策略

Base Policy
期望运动指令(CMS 扩展)
关节控制动作
四足机器人

  • 适应模块以较低频率运行,根据最近一段时间的状态与动作估计隐变量;基础策略以较高频率运行,把当前状态、指令与隐变量映射为关节目标。
2-2-2 Base Policy:基础策略
  • 基础策略通过强化学习训练,学习如何控制机器人行走。
  • 它的输入主要包括:
    • 当前本体观测,例如关节位置、关节速度、机身横滚与俯仰角、足端接触标志
    • 上一步动作,即上一时刻的目标关节角
    • 环境特征编码,即适应模块提供的隐式信息
  • 需要说明的是,论文原文的策略不带速度指令输入;官方仓库的 CMS 版本在此基础上把 4 维速度指令偏差放入观测,用于速度跟踪任务。
  • 论文中基础策略为三层 MLP,输入为 x t ∈ R 30 x_t\in\mathbb R^{30} xt∈R30、 a t − 1 ∈ R 12 a_{t-1}\in\mathbb R^{12} at−1∈R12 与 z t ∈ R 8 z_t\in\mathbb R^8 zt∈R8,隐藏层维度为 128,输出 12 维目标关节角。动作经 PD 控制器换算为力矩,其中 K p = 55 K_p=55 Kp=55、 K d = 0.8 K_d=0.8 Kd=0.8。
    • 它输出关节目标位置等控制动作,使机器人能够根据当前状态和环境条件调整运动。
  • 关键在于:基础策略并不是只学习一套固定的运动方式,而是学习如何根据不同的环境特征采取不同的控制行为。
  • 环境编码 z t z_t zt 相当于在策略输入中增加了一个条件变量,策略由此成为以环境为条件的动作分布。

说人话:基础策略不再是固定地输出动作,而是会根据输入的环境编码 z t z_t zt 来决定怎么运动。当机器人所处的环境发生变化时,适应模块会根据本体感知历史信息更新环境编码,基础策略再根据新的编码调整动作,从而适应不同的地形和动力学条件。

2-2-3 Adaptation Module:适应模块
  • 真实机器人通常无法直接获得仿真中完整的环境参数:机器人既不知道地面摩擦系数,也无法直接测得负载变化对整体动力学的影响。
  • RMA 的解决方法是利用机器人过去一段时间的本体观测与动作,估计一个低维的隐式环境特征向量,通常称为 latent encoding 或 extrinsics。
    • 该向量不一定对应可直接解释的物理参数,而是包含了有助于基础策略调整运动的信息。论文明确指出,估计目标是 z t = μ ( e t ) z_t=\mu(e_t) zt=μ(et),而非 e t e_t et 本身;编码器只保留对行为有影响的部分。
  • 适应模块的输入是长度为 k = 50 k=50 k=50 的历史窗口,对应 100 Hz 控制频率下的 0.5 s: z ^ t = ϕ ( x t − k : t − 1 , a t − k : t − 1 ) \hat z_t=\phi\left(x_{t-k:t-1},a_{t-k:t-1}\right) z^t=ϕ(xt−k:t−1,at−k:t−1)
  • 论文把 ϕ \phi ϕ 实现为一维卷积网络:先用两层 MLP 把每一步的状态与动作嵌入 32 维表示,再用三层一维卷积沿时间维提取相关性,最后线性映射到 8 维。该网络的具体结构见 2-4 节。
  • 因此,适应模块并不直接输出关节控制指令,而是回答一个问题:根据机器人最近的运动表现,当前环境和动力学条件可能是什么样的?

说人话:适应模块会回顾机器人过去 50 帧的本体感知信息,结合这段时间内机器人的运动表现,推测当前环境的特征,并生成一个环境编码,交给基础策略参考,让机器人根据当前情况调整动作。

2-3 第一阶段:利用特权信息训练基础策略
2-3-1 特权信息与环境编码
  • 训练时,仿真环境可以直接提供一些真实机器人不容易直接测得的信息,例如地面摩擦系数、机器人负载、接触特性等。这些信息即为特权信息(Privileged Information),指训练阶段能够从仿真环境中直接获取、但在真实机器人部署时通常无法直接获得或难以准确测量的信息。
    • 论文中的环境因子 e t ∈ R 17 e_t\in\mathbb R^{17} et∈R17 由四部分组成:机身质量与质心位置(3 维)、电机强度(12 维)、地面摩擦(1 维)与局部地形高度(1 维)。
    • RMA 利用这些信息构建环境因子 e t e_t et,帮助基础策略学习不同环境条件下的运动控制规律。
    • 需要注意的是,特权信息主要用于训练阶段,后续适应模块需要根据机器人自身的本体感知历史信息估计相应的环境编码,从而使策略能够应用于真实机器人。
  • 训练中随机化的参数及其采样范围如下,测试时使用更宽的范围:
参数 训练范围 测试范围
摩擦系数 0.05 至 4.5 0.04 至 6.0
比例增益 K p K_p Kp 50 至 60 45 至 65
微分增益 K d K_d Kd 0.4 至 0.8 0.3 至 0.9
负载质量 0 至 6 kg 0 至 7 kg
质心偏移 -0.15 至 0.15 m -0.18 至 0.18 m
电机强度系数 0.90 至 1.10 0.88 至 1.22
  • 表中比例增益与微分增益不参与环境因子的构造,只以随机化的形式改变底层 PD 控制器的行为。

  • 将上述四类分量组成环境特征向量 e t e_t et,再通过编码器得到潜在环境表示:

    z t = μ ( e t ) z_t=\mu(e_t) zt=μ(et)

  • 编码器与基础策略的网络规模由同一份配置决定,prop_encoder 为 256、128、8 的三层结构,与论文描述一致。

python 复制代码
# raisimGymTorch/algo/ppo/module.py
        self.prop_encoder =  nn.Sequential(*[
                                    nn.Linear(input_size - (n_futures+1)*geom_dim - regular_obs_dim -1, 256), self.activation_fn(),
                                    nn.Linear(256, 128), self.activation_fn(),
                                    nn.Linear(128, prop_latent_dim), self.activation_fn(),
                                    ])
        if self.geom_dim > 0:
            self.geom_encoder =  nn.Sequential(*[
                                        nn.Linear(geom_dim, 64), self.activation_fn(),
                                        nn.Linear(64, 16), self.activation_fn(),
                                        nn.Linear(16, geom_latent_dim), self.activation_fn(),
                                        ])

也就是说,第一阶段的环境编码由仿真给出的 e t e_t et 经编码器 μ \mu μ 直接得到,而不是由适应模块估计。

2-3-2 基础策略训练

#mermaid-svg-I1ucaxapNaKIWNSH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-I1ucaxapNaKIWNSH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-I1ucaxapNaKIWNSH .error-icon{fill:#552222;}#mermaid-svg-I1ucaxapNaKIWNSH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-I1ucaxapNaKIWNSH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-I1ucaxapNaKIWNSH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-I1ucaxapNaKIWNSH .marker.cross{stroke:#333333;}#mermaid-svg-I1ucaxapNaKIWNSH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-I1ucaxapNaKIWNSH p{margin:0;}#mermaid-svg-I1ucaxapNaKIWNSH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-I1ucaxapNaKIWNSH .cluster-label text{fill:#333;}#mermaid-svg-I1ucaxapNaKIWNSH .cluster-label span{color:#333;}#mermaid-svg-I1ucaxapNaKIWNSH .cluster-label span p{background-color:transparent;}#mermaid-svg-I1ucaxapNaKIWNSH .label text,#mermaid-svg-I1ucaxapNaKIWNSH span{fill:#333;color:#333;}#mermaid-svg-I1ucaxapNaKIWNSH .node rect,#mermaid-svg-I1ucaxapNaKIWNSH .node circle,#mermaid-svg-I1ucaxapNaKIWNSH .node ellipse,#mermaid-svg-I1ucaxapNaKIWNSH .node polygon,#mermaid-svg-I1ucaxapNaKIWNSH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-I1ucaxapNaKIWNSH .rough-node .label text,#mermaid-svg-I1ucaxapNaKIWNSH .node .label text,#mermaid-svg-I1ucaxapNaKIWNSH .image-shape .label,#mermaid-svg-I1ucaxapNaKIWNSH .icon-shape .label{text-anchor:middle;}#mermaid-svg-I1ucaxapNaKIWNSH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-I1ucaxapNaKIWNSH .rough-node .label,#mermaid-svg-I1ucaxapNaKIWNSH .node .label,#mermaid-svg-I1ucaxapNaKIWNSH .image-shape .label,#mermaid-svg-I1ucaxapNaKIWNSH .icon-shape .label{text-align:center;}#mermaid-svg-I1ucaxapNaKIWNSH .node.clickable{cursor:pointer;}#mermaid-svg-I1ucaxapNaKIWNSH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-I1ucaxapNaKIWNSH .arrowheadPath{fill:#333333;}#mermaid-svg-I1ucaxapNaKIWNSH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-I1ucaxapNaKIWNSH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-I1ucaxapNaKIWNSH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I1ucaxapNaKIWNSH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-I1ucaxapNaKIWNSH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I1ucaxapNaKIWNSH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-I1ucaxapNaKIWNSH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-I1ucaxapNaKIWNSH .cluster text{fill:#333;}#mermaid-svg-I1ucaxapNaKIWNSH .cluster span{color:#333;}#mermaid-svg-I1ucaxapNaKIWNSH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-I1ucaxapNaKIWNSH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-I1ucaxapNaKIWNSH rect.text{fill:none;stroke-width:0;}#mermaid-svg-I1ucaxapNaKIWNSH .icon-shape,#mermaid-svg-I1ucaxapNaKIWNSH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-I1ucaxapNaKIWNSH .icon-shape p,#mermaid-svg-I1ucaxapNaKIWNSH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-I1ucaxapNaKIWNSH .icon-shape .label rect,#mermaid-svg-I1ucaxapNaKIWNSH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-I1ucaxapNaKIWNSH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-I1ucaxapNaKIWNSH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-I1ucaxapNaKIWNSH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 仿真环境因子 e_t

17 维特权信息
环境编码器 μ
环境编码 z_t

8 维
本体观测 x_t

30 维
基础策略 π
动作 a_t

12 维目标关节角
PD 控制器
仿真机器人

  • 基础策略接收本体状态、上一步动作与环境表示,输出动作: a t = π ( x t , a t − 1 , z t ) a_t=\pi(x_t,a_{t-1},z_t) at=π(xt,at−1,zt)其中:
    • x t x_t xt:本体状态,由 12 维关节角、12 维关节角速度、2 维横滚与俯仰角、4 维足端接触标志组成,共 30 维
    • a t − 1 a_{t-1} at−1:上一步动作,即 12 维目标关节角
    • z t z_t zt:环境因子的潜在表示
    • a t a_t at:当前动作,即 12 维目标关节角
  • 训练目标是最大化折扣累计回报:
    J ( π ) = E τ ∼ p ( τ ∣ π ) ∑ t = 0 T − 1 γ t r t J(\pi)=\mathbb E_{\tau\sim p(\tau\mid\pi)}\left\\sum_{t=0}\^{T-1}\\gamma\^t r_t\\right J(π)=Eτ∼p(τ∣π)t=0∑T−1γtrt
  • 策略与编码器端到端联合优化,算法为 PPO,其推导与实现细节见【PPO完全解构】从一次 rollout 到一次权重更新,附带训练曲线判读与策略部署。
  • PPO 的截断代理目标与价值损失为:
    L c l i p ( θ ) = E t min ⁡ ( ρ t ( θ ) A \^ t , c l i p ( ρ t ( θ ) , 1 − ϵ , 1 + ϵ ) A \^ t ) L^{\mathrm{clip}}(\theta)=\mathbb E_t\left\\min\\left(\\rho_t(\\theta)\\hat A_t,\\ \\mathrm{clip}\\left(\\rho_t(\\theta),1-\\epsilon,1+\\epsilon\\right)\\hat A_t\\right)\\right Lclip(θ)=Etmin(ρt(θ)A\^t, clip(ρt(θ),1−ϵ,1+ϵ)A\^t)
    ρ t ( θ ) = π θ ( a t ∣ x t , a t − 1 , z t ) π θ o l d ( a t ∣ x t , a t − 1 , z t ) \rho_t(\theta)=\frac{\pi_\theta(a_t\mid x_t,a_{t-1},z_t)}{\pi_{\theta_{\mathrm{old}}}(a_t\mid x_t,a_{t-1},z_t)} ρt(θ)=πθold(at∣xt,at−1,zt)πθ(at∣xt,at−1,zt)
  • 价值损失同样采用截断形式,以旧价值为基准限制单次更新的幅度:
    L V ( θ v ) = E t max ⁡ ( ( V θ v ( x t ) − R \^ t ) 2 , ( c l i p ( V θ v ( x t ) − V θ v o l d ( x t ) , − ϵ , ϵ ) + V θ v o l d ( x t ) − R \^ t ) 2 ) L^{V}(\theta_v)=\mathbb E_t\left\\max\\left(\\left(V_{\\theta_v}(x_t)-\\hat R_t\\right)\^2,\\ \\left(\\mathrm{clip}\\left(V_{\\theta_v}(x_t)-V_{\\theta_v\^{\\mathrm{old}}}(x_t),-\\epsilon,\\epsilon\\right)+V_{\\theta_v\^{\\mathrm{old}}}(x_t)-\\hat R_t\\right)\^2\\right)\\right LV(θv)=Etmax((Vθv(xt)−R\^t)2, (clip(Vθv(xt)−Vθvold(xt),−ϵ,ϵ)+Vθvold(xt)−R\^t)2)
  • 优势函数由广义优势估计(GAE,Generalized Advantage Estimation)计算:
    A ^ t = ∑ l = 0 T − t − 1 ( γ λ ) l δ t + l , δ t = r t + γ V θ v ( x t + 1 ) − V θ v ( x t ) \hat A_t=\sum_{l=0}^{T-t-1}(\gamma\lambda)^l\delta_{t+l},\qquad \delta_t=r_t+\gamma V_{\theta_v}(x_{t+1})-V_{\theta_v}(x_t) A^t=l=0∑T−t−1(γλ)lδt+l,δt=rt+γVθv(xt+1)−Vθv(xt)
  • 其中 V θ v V_{\theta_v} Vθv 为价值网络, θ v \theta_v θv 为其参数,与策略参数 θ \theta θ 分开更新。
  • 论文的奖励由十项组成,系数取自原文第 III 节:
奖励项 表达式 系数
前进速度 min ⁡ ( v x t , 0.35 ) \min(v_x^t,0.35) min(vxt,0.35) 20
侧向与转向 − ∥ v y t ∥ 2 − ∥ ω y a w t ∥ 2 -\lVert v_y^t\rVert^2-\lVert\omega_{\mathrm{yaw}}^t\rVert^2 −∥vyt∥2−∥ωyawt∥2 21
做功 − ∣ τ t ⋅ ( q t − q t − 1 ) ∣ -\lvert\tau^t\cdot(q^t-q^{t-1})\rvert −∣τt⋅(qt−qt−1)∣ 0.002
地面冲击 − ∥ f t − f t − 1 ∥ 2 -\lVert f^t-f^{t-1}\rVert^2 −∥ft−ft−1∥2 0.02
平滑性 − ∥ τ t − τ t − 1 ∥ 2 -\lVert\tau^t-\tau^{t-1}\rVert^2 −∥τt−τt−1∥2 0.001
动作幅值 − ∥ a t ∥ 2 -\lVert a_t\rVert^2 −∥at∥2 0.07
关节速度 − ∥ q ˙ t ∥ 2 -\lVert\dot q^t\rVert^2 −∥q˙t∥2 0.002
机身姿态 − ∥ θ r o l l , p i t c h t ∥ 2 -\lVert\theta^t_{\mathrm{roll,pitch}}\rVert^2 −∥θroll,pitcht∥2 1.5
竖直加速度 − ∥ v z t ∥ 2 -\lVert v_z^t\rVert^2 −∥vzt∥2 2.0
足端打滑 − ∥ d i a g ( g t ) ⋅ v f , t ∥ 2 -\lVert\mathrm{diag}(g_t)\cdot v_{f,t}\rVert^2 −∥diag(gt)⋅vf,t∥2 0.8
  • 训练时先使用较小的惩罚系数,随训练进程逐步增大,以避免策略在早期为了避免惩罚而停留在原地。
    • 论文不设地形课程,训练全程使用同一分布的随机地形。课程学习(Curriculum Learning)指从较简单的任务开始、随能力提升逐步增加难度的训练策略。
    • 官方 CMS 版本另外加入了一段台阶课程,第 1200 次迭代之后才混入台阶,具体安排见 3-3 节。
  • 上述结构在官方代码中有直接对应的实现。环境侧先确定观测维度:前半部分供参考策略使用,后半部分是当前训练策略的输入。
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      // prior policy input
      obDim_ = 42 + (int)privinfo * (20 + 3 * (int)use_priv_vel + nFoot * (int)use_slope_dots + 1 + 2*(num_g1 -1));
      // trained policy input
      obDim_ += baseDim + (int)privinfo * (20 + 3 * (int)use_priv_vel + nFoot * (int)use_slope_dots + 1 + 2*(num_g1-1));
  • 在默认配置下,本体观测为 42 维,特权信息为 28 维,单份观测共 70 维,环境一次返回 140 维。策略网络通过一次切片只读取后半部分:
python 复制代码
# raisimGymTorch/algo/ppo/module.py
    def forward(self, x):
        # get only the x related to the control policy
        if x.shape[1] > 130:
            # Hacky way to detect where you are (dagger or not)
            # TODO: improve on this!
            x = x[:,x.shape[1]//2:]
        prop_latent = self.prop_encoder(x[:,self.regular_obs_dim:-self.geom_dim*(self.n_futures+1) -1])
        geom_latents = []
        for i in reversed(range(self.n_futures+1)):
            start = -(i+1)*self.geom_dim -1
            end = -i*self.geom_dim -1
            if (end == 0):
                end = None
            geom_latent = self.geom_encoder(x[:,start:end])
            geom_latents.append(geom_latent)
        geom_latents = torch.hstack(geom_latents)
        return self.action_mlp(torch.cat([x[:,:self.regular_obs_dim], prop_latent, geom_latents], 1))
  • 这段前向对应式中的 z t = μ ( e t ) z_t=\mu(e_t) zt=μ(et) 与 a t = π ( x t , a t − 1 , z t ) a_t=\pi(x_t,a_{t-1},z_t) at=π(xt,at−1,zt):
    • prop_encoder 对应环境因子编码器 μ \mu μ 中处理动力学参数的部分,输入为 23 维特权量,输出 8 维
    • geom_encoder 对应地形部分的编码,每个 token 2 维,共 2 个 token,每个输出 1 维
    • action_mlp 对应基础策略 π \pi π,输入为 42 维本体观测、8 维动力学隐编码与 2 维地形隐编码,共 52 维
  • 论文与代码在维度上的差异可以归纳为下表。差异的来源是该仓库采用 CMS 版本的观测组织方式,把地形相关量从环境因子中拆出,交给独立的地形编码器:
符号 含义 论文维度 代码维度 说明
x t x_t xt 本体观测 30 42 代码不含足端接触标志,改用 4 维指令偏差
a t a_t at 动作 12 12 目标关节角,经 tanh 后缩放
e t e_t et 环境因子 17 28 增加滤波速度、坡度采样与地形类型标志(特权信息)
z t z_t zt 隐编码 8 8 加 2 动力学部分 8 维,地形部分拆成两个 1 维 token
2-4 第二阶段:利用历史观测训练适应模块
2-4-1 适应模块训练与构成
  • 第一阶段中,我们可以直接从仿真环境中获取特权信息 e t e_t et,通过编码器将其映射为隐编码 z t z_t zt,并将其作为条件输入提供给基础策略,使策略学会根据不同的环境特征调整控制行为。
    • 问题在于: 真实机器人无法直接获取这些仿真特权信息,那么在实际部署时,应该如何获得基础策略所需的隐编码 z t z_t zt?这正是 RMA 第二阶段需要解决的核心问题。
  • RMA 的办法是利用一段时间内的机器人状态与历史动作,训练一个适应模块来预测环境表示: z ^ t = ϕ ( x t − k : t − 1 , a t − k : t − 1 ) \hat z_t=\phi\left(x_{t-k:t-1},a_{t-k:t-1}\right) z^t=ϕ(xt−k:t−1,at−k:t−1)其中:
    • k k k 表示历史窗口的长度。论文取 k = 50 k=50 k=50,在 100 Hz 控制频率下对应 0.5 s。
  • 训练时,适应模块学习预测第一阶段生成的环境表示 z t z_t zt,通过监督学习最小化预测误差:
    L a d a p t ( ϕ ) = E ∥ z \^ t − z t ∥ 2 2 , z t = μ ( e t ) \mathcal L_{\mathrm{adapt}}(\phi)=\mathbb E\left\\left\\\|\\hat z_t-z_t\\right\\\|_2\^2\\right,\qquad z_t=\mu(e_t) Ladapt(ϕ)=E∥z\^t−zt∥22,zt=μ(et)
  • 数据采集方式是该阶段的关键。若直接用训练好的基础策略配合真实 z t z_t zt 滚动采集数据,得到的样本只覆盖性能良好的轨迹;适应模块在这样的数据上训练,无法应对部署时出现的偏离。
    • 因此论文采用与 DAgger(Dataset Aggregation,数据集聚合)相同的思路:用 ϕ \phi ϕ 当前的预测 z ^ t \hat z_t z^t 驱动基础策略采集轨迹,再把轨迹与真实 z t z_t zt 配对训练 ϕ \phi ϕ,如此迭代。随机初始化带来的探索与不完美的预测共同扩展了数据分布。
    • 部署到真实机器狗时,适应模块根据历史数据估计 z ^ t \hat z_t z^t,再交给基础策略控制机器人。整个过程不需要在每一种新环境下重新训练基础策略。

说人话:可以把 DAgger 理解为一种"边使用、边收集数据、边改进"的训练方法。基础策略负责控制机器狗,适应模块负责估计当前环境。训练时,让适应模块用自己的预测参与控制,再把实际采集到的数据与对应的目标隐编码配对,反过来训练它。这样,适应模块就能逐渐学会处理自己在实际控制中可能遇到的情况。

2-4-2 适应模块实现
  • 代码中的适应模块是一个沿时间维卷积的编码器,实现上把 50 帧观测展平后逐帧投影,再在时间维上做卷积。
  • 沿时间维卷积的编码器(temporal convolutional encoder),是指利用卷积神经网络沿着时间轴对序列数据进行卷积运算,从而提取序列中的时序特征,并将其编码为固定维度的特征表示的神经网络模块。
    • 与普通的全连接网络直接处理输入特征不同,时序卷积通过卷积核在时间轴上滑动,提取相邻时刻之间的局部变化模式;通过多层卷积或扩大感受野,还可以提取更长时间范围内的时序信息。
  • 其核心的部分便是
python 复制代码
nn.Conv1d(32, 32, kernel_size=8, stride=4)
nn.Conv1d(32, 32, kernel_size=5, stride=1)
nn.Conv1d(32, 32, kernel_size=5, stride=1)
  • 这里需要注意,Conv1d 的输入并不是简单的一维时间序列,而是包含多个特征通道的时序数据。
    • 首先,代码将 50 帧历史观测分别通过全连接层,从每帧中提取 32 维特征,再将数据整理为 [batch_size, 32, 50] 的形状,其中 32 表示特征通道数,50 表示时间步数。
    • 随后,卷积核沿时间维度滑动,对连续多个时刻的特征进行卷积运算。
      • 例如,第一层卷积的 kernel_size=8,表示每次处理连续 8 个时刻的特征;stride=4 则表示卷积核每次向后移动 4 个时刻。
    • 后续两层卷积继续融合时序信息,使网络能够从连续的观测历史中提取不同时间范围内的变化模式。最后,将卷积结果展平,通过全连接层映射为固定维度的输出,作为适应模块提取的历史特征表示。

说人话: 可以把 50 帧历史观测理解为一段连续的视频,只不过每一帧记录的是机器人的状态,而不是图像。全连接层先把每一帧转换成 32 维特征,随后卷积核沿时间轴滑动,观察连续几个时刻的特征如何变化,从而学习机器人近期的运动模式,并据此推断当前的环境与动力学特性。

  • 完整代码如下:
python 复制代码
# raisimGymTorch/algo/ppo/module.py
class StateHistoryEncoder(nn.Module):
    def __init__(self, activation_fn, input_size, tsteps, output_size):
        super(StateHistoryEncoder, self).__init__()
        self.activation_fn = activation_fn
        self.tsteps = tsteps
        self.input_shape = input_size*tsteps
        self.output_shape = output_size

        if tsteps == 50:
            self.encoder = nn.Sequential(
            nn.Linear(input_size, 32), self.activation_fn()
            )
            self.conv_layers = nn.Sequential(
                    nn.Conv1d(in_channels = 32, out_channels = 32, kernel_size = 8, stride = 4), nn.LeakyReLU(),
                    nn.Conv1d(in_channels = 32, out_channels = 32, kernel_size = 5, stride = 1), nn.LeakyReLU(),
                    nn.Conv1d(in_channels = 32, out_channels = 32, kernel_size = 5, stride = 1), nn.LeakyReLU(), nn.Flatten())
            self.linear_output = nn.Sequential(
            nn.Linear(32 * 3, output_size), self.activation_fn()
            )

    def forward(self, obs):
        bs = obs.shape[0]
        T = self.tsteps
        projection = self.encoder(obs.reshape([bs * T, -1]))
        output = self.conv_layers(projection.reshape([bs, -1, T]))
        output = self.linear_output(output)
        return output
  • 输入 50 帧、每帧 42 维,经过三层卷积后时间维长度为 3,展平得到 96 维,再线性映射到 10 维,其中前 8 维对应动力学隐编码,后 2 维对应两个地形 token。
  • 监督信号由第一阶段的编码器给出,损失与优化过程如下:
python 复制代码
# raisimGymTorch/algo/ppo/dagger.py
    def _train_step(self):
        self.itr += 1
        self.actor.set_itr(self.itr)
        for epoch in range(self.num_learning_epochs):
            prop_mse = 0
            geom_mse = 0
            loss_counter = 0
            for obs_batch, expert_action_batch in self.storage.mini_batch_generator_inorder(self.num_mini_batches):

                predicted_prop_latent = self.actor.get_history_encoding(obs_batch)
                loss_prop = self.loss_fn(predicted_prop_latent[:,:8], expert_action_batch[:,:8])
                loss_geom = self.loss_fn(predicted_prop_latent[:,8:],
                        expert_action_batch[:,8:])
                loss = loss_geom + loss_prop

                self.optimizer.zero_grad()
                loss.backward()
                self.optimizer.step()
                prop_mse += loss_prop.item()
                geom_mse += loss_geom.item()
                loss_counter += 1

        self.scheduler.step()
        return avg_prop_loss, avg_geom_loss
  • 代码把损失拆成两项分别记录:前 8 维为动力学隐编码的回归误差,后 2 维为地形隐编码的回归误差。两项权重相同,直接相加。
2-5 两个模块协同工作
  • 训练阶段,通常先训练基础策略,再训练适应模块。
  • 部署阶段,两个模块以不同频率异步运行:基础策略以 100 Hz 输出动作,适应模块以 10 Hz 更新隐编码。
    • 记控制周期为 Δ t π = 0.01 \Delta t_\pi=0.01 Δtπ=0.01 s,适应周期为 Δ t ϕ = 0.1 \Delta t_\phi=0.1 Δtϕ=0.1 s,则在 10 个控制步内隐编码保持不变: z ^ t = z ^ t − 1 = ⋯ = z ^ t − 9 , t   m o d   10 = 0 \hat z_t=\hat z_{t-1}=\cdots=\hat z_{t-9},\qquad t\bmod 10=0 z^t=z^t−1=⋯=z^t−9,tmod10=0
  • 异步设计之所以可行,是因为环境因子在真实世界中变化缓慢:摩擦、负载与地形在数十毫秒内基本不变,而关节状态在同样的时间尺度上已经发生了显著变化。把两类量放在同一频率上更新,既浪费计算,也没有必要。
  • 反过来,如果让一个网络直接以状态动作历史为输入输出动作,论文报告了两点问题:步态不自然、仿真性能下降;并且该网络在机载计算机上只能以 10 Hz 运行。异步结构把低频的估计与高频的控制解耦,使基础策略得以维持 100 Hz。

说人话: 将基础策略和适应模块分开是有必要的。适应模块负责分析历史数据、估计环境变化,可以低频运行;基础策略负责实时控制机器人,需要高频输出动作。将两者分开,就能避免复杂的历史信息处理拖慢控制频率,让机器狗既能适应不同环境,又能保持快速、稳定的控制。

  • 代码通过 TorchScript 把两个模块固化到同一张推理图中,部署时只需按顺序调用一次前向:
python 复制代码
# raisimGymTorch/algo/ppo/module.py
    def save_deterministic_graph(self, file_name, example_input, device='cpu'):
        transferred_graph = torch.jit.trace(self.architecture.architecture.to(device), example_input, check_trace=False)
        torch.jit.save(transferred_graph, file_name)
        self.architecture.architecture.to(self.device)
  • 推理侧的调用不区分两个模块的频率,环境每隔若干步提供一次历史,策略每一步都使用最近一次的隐编码:
python 复制代码
# raisimGymTorch/env/envs/rsg_a1_task/evaluate_policy.py
while ep < num_episodes:
    obs = env.observe(False)
    with torch.no_grad():
        action_ll = expert.forward(torch.from_numpy(obs).cpu())
        reward_ll, dones = env.step(action_ll.cpu().detach().numpy())
  • 该脚本在单一环境上以仿真步进的方式运行策略,两条分支的频率关系由环境的控制步长与观测组织方式决定。真实机器人上的 10 Hz 与 100 Hz 分工由上游部署节点实现,官方把这一部分放在独立的仓库 antonilo/vision_locomotion 中。

3 RMA 源码解析

  • 本章以 RMA 原论文为理论主线,结合官方仓库的实际代码分析实现。需要说明的是,该仓库在 RMA 之后被继续用于 CMS 工作,观测维度、地形编码、奖励函数与模仿学习等部分带有后续扩展;文中涉及这些内容时均以代码的实际行为为准,不应直接等同于原论文的全部设计。
3-1 官方代码仓库与工程结构
  • 官方仓库基于 RaiSim 仿真器与 raisimGymTorch 框架,训练全部在 CPU 上完成,GPU 只用于网络更新。
  • 仓库的主要目录与文件如下:
bash 复制代码
rl_locomotion/
├── CMakeLists.txt
├── setup.py
├── data/
│   └── base_policy/              # 预训练底座策略与归一化统计量
├── eval_scripts/
│   └── compute_results.py        # 评测结果汇总
└── raisimGymTorch/
    ├── algo/ppo/
    │   ├── ppo.py                # PPO 实现
    │   ├── dagger.py             # 适应模块的蒸馏训练
    │   ├── module.py             # 网络结构
    │   └── storage.py            # 经验存储与 GAE
    ├── env/
    │   ├── envs/
    │   │   ├── rsg_a1_task/      # 阶段一:特权信息训练
    │   │   │   ├── Environment.hpp
    │   │   │   ├── cfg.yaml
    │   │   │   ├── runner.py
    │   │   │   └── evaluate_policy.py
    │   │   └── dagger_a1/        # 阶段二:适应模块训练
    │   │       ├── Environment.hpp
    │   │       ├── cfg.yaml
    │   │       ├── dagger.py
    │   │       └── evaluate_policy.py
    │   ├── raisim_gym.cpp
    │   ├── RaisimGymEnv.hpp
    │   ├── VectorizedEnvironment.hpp
    │   └── RaisimGymVecEnv.py
    └── helper/
        └── raisim_gym_helper.py
  • 两个环境编译为独立的 Python 扩展模块,由 CMakeLists.txt 统一构建,环境名通过宏注入:
cmake 复制代码
FOREACH(subdir ${SUBDIRS})
    pybind11_add_module(${subdir} raisimGymTorch/env/raisim_gym.cpp raisimGymTorch/env/Yaml.cpp)
    target_link_libraries(${subdir} PRIVATE raisim::raisim ${Dependencies})
    target_include_directories(${subdir} PUBLIC ${EIGEN3_INCLUDE_DIRS} ${RAISIMGYM_ENV_DIR}/${subdir})
    target_compile_options(${subdir} PRIVATE -mtune=native -fPIC -O3 -g -mno-avx2)
    target_compile_definitions(${subdir} PRIVATE "-DRAISIMGYM_TORCH_ENV_NAME=${subdir}")
  • 编译依赖固定版本的 raisimLib,按 README 的要求需要切换到指定提交:
bash 复制代码
git clone https://github.com/antonilo/rl_locomotion.git
cd rl_locomotion
git clone https://github.com/raisimTech/raisimLib.git
cd raisimLib
git checkout f0bb440762c09a9cc93cf6ad3a7f8552c6a4f858
cd ..
python setup.py develop
  • 环境文件修改后必须重新执行 python setup.py develop,否则运行时加载的仍是旧的扩展模块。
  • 两份 cfg.yaml 的主体完全相同,只有阶段二独有的 history_len: 50 与评测间隔不同。关键参数如下:
参数 取值 含义
num_envs 200 并行环境数
num_threads 20 仿真线程数
simulation_dt 0.0025 仿真步长
control_dt 0.01 控制步长,对应 100 Hz
max_time 6.0 单个回合的采样时长,对应 600 个控制步
baseDim 42 本体观测维度
geomDim 2 每个地形 token 的维度
n_futures 1 地形 token 的前瞻数量
history_len 50 阶段二的历史窗口长度
policy_net 128、128 策略隐藏层
pid_coeff 45.0 PD 比例增益的基准值
3-2 基础策略的训练流程
  • 阶段一的目标是联合优化基础策略 π θ \pi_\theta πθ 与环境因子编码器。动作的生成关系为: a t = π θ ( x t , a t − 1 , μ ψ ( e t d y n ) , μ ξ ( e t g e o ) ) a_t=\pi_\theta\left(x_t,a_{t-1},\left\\mu_\\psi\\left(e_t\^{\\mathrm{dyn}}\\right),\\ \\mu_\\xi\\left(e_t\^{\\mathrm{geo}}\\right)\\right\right) at=πθ(xt,at−1,μψ(etdyn), μξ(etgeo))其中:
    • e t d y n e_t^{\mathrm{dyn}} etdyn : 23 维动力学因子
    • e t g e o e_t^{\mathrm{geo}} etgeo :两个 2 维地形 token。代码把每一组 2 维坡度量称为一个 token,按 n_futures + 1 = 2 分成前足组与后足组
      • 前足组为右前、左前足端前方 0.05 m 处相对足下的高度差;
      • 后足组为右后、左后足下相对同侧前足位置的高度差
    • μ ψ \mu_\psi μψ :动力学编码器,输入 23 维,输出 8 维
    • μ ξ \mu_\xi μξ :地形编码器,对每个 2 维 token 单独编码为 1 维
    • 编码结果拼接为 10 维隐编码,即 8 维动力学加 2 维地形,与 42 维本体观测一起送入策略网络。
  • 优化目标是带截断的重要性采样代理目标,即 PPO 的标准形式:
    θ ← θ + η ∇ θ   E t min ⁡ ( ρ t ( θ ) A \^ t , c l i p ( ρ t ( θ ) , 1 − ϵ , 1 + ϵ ) A \^ t ) \theta\leftarrow\theta+\eta\nabla_\theta\,\mathbb E_t\left\\min\\left(\\rho_t(\\theta)\\hat A_t,\\ \\mathrm{clip}\\left(\\rho_t(\\theta),1-\\epsilon,1+\\epsilon\\right)\\hat A_t\\right)\\right θ←θ+η∇θEtmin(ρt(θ)A\^t, clip(ρt(θ),1−ϵ,1+ϵ)A\^t)
  • 编码器与策略共享同一条梯度通路:奖励对策略的梯度会经 a t a_t at 回传到 μ ψ \mu_\psi μψ 与 μ ξ \mu_\xi μξ,因此编码器学到的不是物理参数的还原,而是对控制有用的表示。这一点对应 2-3 节中"端到端联合优化"的表述。
  • 与之配套的代码结构如下。训练入口为 runner.py,脚本先读取配置、构建环境,再按配置选择网络结构。开启特权信息时使用带编码器的 MLPEncode:
python 复制代码
# raisimGymTorch/env/envs/rsg_a1_task/runner.py
    if priv_info:
        if layer_type == 'feedforward':
            init_var = 0.3
            module_type = ppo_module.MLPEncode_wrap
            actor = ppo_module.Actor(module_type(cfg['architecture']['policy_net'],
                                     nn.LeakyReLU,
                                     ob_dim//2,
                                     act_dim,
                                     output_activation_fn,
                                     small_init_flag,
                                     base_obdim = baseDim,
                                     geom_dim = geomDim,
                                     n_futures = n_futures),
                                     ppo_module.MultivariateGaussianDiagonalCovariance(act_dim, init_var),
                                     device_type)

            critic = ppo_module.Critic(module_type(cfg['architecture']['value_net'],
                                                    nn.LeakyReLU,
                                                    ob_dim//2,
                                                    1,
                                                    base_obdim = baseDim,
                                                    geom_dim = geomDim,
                                                    n_futures = n_futures),
                                    device_type)
  • 动作分布为对角高斯,初始标准差 0.3,并在每次迭代后强制不低于 0.2,避免策略过早塌缩到确定性动作。
  • 单次迭代的采样与更新逻辑如下。环境先重置,随后采样 600 步,最后做一次 PPO 更新:
python 复制代码
# raisimGymTorch/env/envs/rsg_a1_task/runner.py
for update in range(500001) if args.loadid is None else range(args.loadid + 1, 500001):
    start = time.time()
    env.reset()
    reward_ll_sum = 0
    forwardX_sum = 0
    penalty_sum = 0
    done_sum = 0
    average_dones = 0.

    if update %  cfg['environment']['eval_every_n'] == 0:
        print("Visualizing and evaluating the current policy")
        actor.save_deterministic_graph(saver.data_dir+"/policy_"+str(update)+'.pt', torch.rand(1, ob_dim).cpu())
        if update %  (1 * cfg['environment']['eval_every_n']) == 0:
            torch.save({
                'actor_architecture_state_dict': actor.architecture.state_dict(),
                'actor_distribution_state_dict': actor.distribution.state_dict(),
                'critic_architecture_state_dict': critic.architecture.state_dict(),
                'optimizer_state_dict': ppo.optimizer.state_dict(),
            }, saver.data_dir+"/full_"+str(update)+'.pt')

    # actual training
    for step in range(n_steps):
        obs = env.observe(not freeze_encoder)
        action = ppo.observe(obs)
        reward, dones = env.step(action)
        unscaled_reward_info = env.get_reward_info()
        forwardX = unscaled_reward_info[:, 0]
        penalty = unscaled_reward_info[:, 1:]
        ppo.step(value_obs=obs, rews=reward, dones=dones, infos=[])
        done_sum = done_sum + sum(dones)
        reward_ll_sum = reward_ll_sum + sum(reward)
        forwardX_sum += np.sum(forwardX)
        penalty_sum += np.sum(penalty, axis=0)

    env.curriculum_callback()

    # take st step to get value obs
    obs = env.observe(not freeze_encoder)
    ppo.update(actor_obs=obs,
               value_obs=obs,
               log_this_iteration=update % 10 == 0,
               update=update)
  • 采样完成后多取一帧观测,作为自举使用的下一状态价值。GAE 由此从后向前递推:
python 复制代码
# raisimGymTorch/algo/ppo/storage.py
    def compute_returns(self, last_values, gamma, lam):
        advantage = 0
        for step in reversed(range(self.num_transitions_per_env)):
            if step == self.num_transitions_per_env - 1:
                next_values = last_values
            else:
                next_values = self.values[step + 1]

            next_is_not_terminal = 1.0 - self.dones[step].float()
            delta = self.rewards[step] + next_is_not_terminal * gamma * next_values - self.values[step]
            advantage = delta + next_is_not_terminal * gamma * lam * advantage
            self.returns[step] = advantage + self.values[step]

        # Compute and normalize the advantages
        self.advantages = self.returns - self.values
        self.advantages = (self.advantages - self.advantages.mean()) / (self.advantages.std() + 1e-8)
  • 优势在批次内做标准化,这是提升 PPO 稳定性的常用做法。折扣因子取 0.997,GAE 参数取 0.95,对应约 3 秒的有效视界。
  • 训练循环还包含一个模仿项,这是该仓库在 CMS 工作中的一个扩展。PPO 在计算损失时读取观测最后一维作为地形标志,并据此在纯强化学习与模仿之间切换权重:
python 复制代码
# raisimGymTorch/algo/ppo/ppo.py
                isSlope = actor_obs_batch[:,-1]
                value_batch = self.critic.evaluate(critic_obs_batch)

                # Surrogate loss
                ratio = torch.exp(actions_log_prob_batch - torch.squeeze(old_actions_log_prob_batch))
                surrogate = -torch.squeeze(advantages_batch) * ratio
                surrogate_clipped = -torch.squeeze(advantages_batch) * torch.clamp(ratio, 1.0 - self.clip_param,
                                                                                   1.0 + self.clip_param)
                surrogate_loss = torch.max(surrogate, surrogate_clipped)

                multiplicative_coeff_rl = self.rl_coeff*(1-isSlope) + isSlope

                rl_loss = (surrogate_loss + self.value_loss_coef * torch.squeeze(value_loss) - self.entropy_coef * entropy_batch)
                rl_loss = (multiplicative_coeff_rl * rl_loss).mean()
                if flat_actions is None:
                    loss = rl_loss
                else:
                    im_loss = (1-self.rl_coeff)*(1-isSlope)*torch.sum(self.imitation_loss(flat_actions, action_mean), dim=1)
                    im_loss = im_loss.mean()
                    loss = rl_loss + im_loss
  • runner.py 中把 rl_coeff 设为 0.3:地形标志为 1 时按纯强化学习更新;标志为 0 时强化学习权重降为 0.3,剩余 0.7 由预训练底座策略的模仿损失承担。该设计不属于 RMA 原文,阅读时应注意区分。
  • 参数更新采用 4 个 epoch 与 4 个小批次,学习率 5e-4,价值损失系数 0.5,熵系数 0,梯度裁剪 0.5。学习率调度器的乘子为 0.9998 n 0.9998^{n} 0.9998n,但训练主循环并未调用 update_scheduler(),因此实际学习率保持常数。
  • 把阶段一的公式逐项映射到代码,可以得到下表。每一行在仓库中都有唯一对应的实现,不存在只写在论文里的符号:
公式中的量 代码位置 具体实现
x t x_t xt Environment.hpp 的 obDouble_ 前 42 维 姿态 2 维、关节角 12 维、关节速度 12 维、上一步动作 12 维、指令偏差 4 维
e t e_t et obDouble_ 后 28 维 28 维特权量,顺序见 3-3 节的表
μ ψ ( e t d y n ) \mu_\psi\left(e_t^{\mathrm{dyn}}\right) μψ(etdyn) MLPEncode.prop_encoder 23 维输入,256、128、8 三层
μ ξ ( e t g e o ) \mu_\xi\left(e_t^{\mathrm{geo}}\right) μξ(etgeo) MLPEncode.geom_encoder 每个 token 2 维输入,输出 1 维,共两个 token
π θ \pi_\theta πθ MLPEncode.action_mlp 52 维输入,128、128 两层,输出 12 维
ρ t ( θ ) \rho_t(\theta) ρt(θ) ppo.py 的 torch.exp(actions_log_prob_batch - old_actions_log_prob_batch) 新旧策略对数概率之差取指数
A ^ t \hat A_t A^t storage.py 的 compute_returns GAE 递推后在批次内标准化
c l i p \mathrm{clip} clip 项 torch.clamp(ratio, 1.0 - clip_param, 1.0 + clip_param) 截断系数 0.2
价值损失 (value_batch - returns_batch).pow(2) 与截断版本取最大 系数 0.5
  • 表中最后一行对应 PPO 的截断价值损失:目标值本身不参与梯度,只有预测值被截断到目标值附近,从而限制单次更新的幅度。
3-3 特权信息与环境编码的实现
  • 环境在构造阶段从 YAML 读取奖励系数、随机化开关与地形参数,随后一次性算出观测维度。环境的特权部分按固定顺序拼接,顺序决定了编码器的切片位置:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      obDouble_.head(obDim_/2) << bodyOrientation_.head(2),
          gc_.tail(12), /// joint angles
          gv_.tail(12),
          act_history[act_history.size() - 1],
          speed_vec,
          (double)includeGRF * grf_bin_obs,
          mass_params,
          motor_strength,
          friction,
          avgXYVel, avgYawVel,
          slope_dots,
          1.-(double)useRef;
  • 特权部分的 28 维由七段组成:
顺序 分量 维度 说明
1 grf_bin_obs 4 足端接触二值量,配置中 includeGRF 为 false 时被整体置零
2 mass_params 3 质量采样值、质心横纵向偏移
3 motor_strength 12 每个关节的出力缩放
4 friction 1 地面摩擦系数
5 avgXYVel、avgYawVel 3 一阶低通后的机体系速度
6 slope_dots 4 足端前方与足下地形的高度差
7 1 - useRef 1 地形类型标志,取 1 表示无参考策略可用
  • 编码器的切片方式与上表一致:前 23 维(第 1 至第 5 段)进入 prop_encoder,第 6 段的 4 维坡度采样按两个 token 进入 geom_encoder,第 7 段标志位不参与编码,只在 PPO 中作为开关使用。
  • 域随机化在每个回合开始时执行一次,训练过程中也会周期性重采样:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
    void randomize_sim_params()
    {
      if (randomize_friction)
      {
        double friction_list[6] = {0.5, 0.7, 0.9, 1.2, 1.5, 1.8};
        friction = friction_list[std::rand() % 6];
        world_->setDefaultMaterial(friction, 0.0, 0.0);
      }

      if (randomize_gains)
      {
        // Resample Gains
        pgain = pid_coeff + Eigen::VectorXd::Random(1)[0] * 5.;
        dgain = 0.6 + Eigen::VectorXd::Random(1)[0] * 0.2;
        Eigen::VectorXd jointPgain(gvDim_), jointDgain(gvDim_);
        jointPgain.setZero();
        jointPgain.tail(nJoints_).setConstant(pgain);
        jointDgain.setZero();
        jointDgain.tail(nJoints_).setConstant(dgain);
        a1_->setPdGains(jointPgain, jointDgain);
      }

      if (randomize_mass)
      {
        // updated mass variation
        auto mass_rand = 0.5 * (1. + Eigen::VectorXd::Random(1)[0]);
        a1_->getMass()[0] = 6.0 + 2.0 * mass_rand;
        Eigen::Vector3d com_vec = Eigen::VectorXd::Random(3);
        com_vec(0) = 0.1 * com_vec(0);
        com_vec(1) = 0.05 * com_vec(1);
        com_vec(2) = 0.0;
        a1_->getLinkCOM()[0] = com_vec;
        a1_->updateMassInfo();
        mass_params << mass_rand, com_vec(0), com_vec(1);
      }

      if (randomize_motor_strength)
      {
        // resample motor strength
        motor_strength = Eigen::VectorXd::Ones(12) - 0.1 * Eigen::VectorXd::Random(12);
      }
    }
  • 该版本的取值范围为:摩擦 0.5 至 1.8,比例增益 40 至 50,微分增益 0.4 至 0.8,机身质量 6 至 8 kg,质心偏移横纵向分别不超过 0.1 m 与 0.05 m,电机强度 0.9 至 1.1。与论文表 I 相比范围更窄,这是 CMS 版本的配置,替换为论文范围只需修改此处。
  • 地形由 RaiSim 的分形高度图生成,参数与论文一致:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
    void add_random_terrain()
    {
      raisim::TerrainProperties terrainProperties;
      terrainProperties.frequency = 10;

      double zscale_val = 0.22;

      terrainProperties.zScale = zscale_val;
      terrainProperties.xSize = 12;
      terrainProperties.ySize = 12;
      terrainProperties.xSamples = 200;
      terrainProperties.ySamples = 200;
      terrainProperties.fractalOctaves = 2;
      terrainProperties.fractalLacunarity = 2.0;
      terrainProperties.fractalGain = 0.25;
      hm_ = world_->addHeightMap(Eigen::VectorXd::Random(1)[0] + 4, Eigen::VectorXd::Random(1)[0], terrainProperties);
  • 除随机地形外,环境还实现了台阶与斜坡生成器。
    • 第 1200 次迭代之前,每 100 次迭代重建一次分形地形,地形类型固定;
    • 此后重建间隔缩短为 50 次,并在分形地形与台阶之间按 40% 与 60% 的比例抽样。
    • 台阶高度按迭代数解锁,每 1000 次迭代增加一档,取值从 0.10 m 逐步升到 0.23 m;向下的台阶上限更低,落在 0.10 m 至 0.17 m 之间。
  • 地形信息以足端前方的坡度探针形式进入观测。探针每 5 个控制步更新一次,在前足前方 0.05 m 处采样高度,与足下位置的高度做差,两点相距 0.05 m,差值即局部坡度:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      for (int k = 0; k < num_g1; k++)
      {
        for (int footIdx_i = 0; footIdx_i < 2; footIdx_i++)
        {
          fx = foot_positions[footIdx_i][0];
          fy = foot_positions[footIdx_i][1];
          position_diff = g1_position[k];

          bxi = fx + position_diff * cosy;
          byi = fy + position_diff * siny;

          bxi_back = fx + (position_diff - 0.05) * cosy;
          byi_back = fy + (position_diff - 0.05) * siny;

          tz = (hm_) ? hm_->getHeight(bxi, byi) : 0;
          tz_back = (hm_) ? hm_->getHeight(bxi_back, byi_back) : 0;
          diff = tz - tz_back;
          if (abs(diff) < slope_th)
          {
            diff = 0;
          }
          slope_dots(latest_position) = diff;
  • 小于阈值 0.08 的高度差被置零,相当于对地形做一次死区处理,避免策略对微小的地面噪声过度反应。
  • 观测归一化并非对整份观测进行。运行统计量只更新后半部分,并对两个地形 token 共享统计量,标志位则不做归一化:
python 复制代码
# raisimGymTorch/env/RaisimGymVecEnv.py
    def update(self, arr):
        batch_mean = np.mean(arr[:,self.mean.shape[1]//2:], axis=0)
        batch_var = np.var(arr[:,self.var.shape[1]//2:], axis=0)
        batch_count = arr.shape[0]
        self.update_from_moments(batch_mean, batch_var, batch_count)

    def update_from_moments(self, batch_mean, batch_var, batch_count):
        half_mean = self.mean[:,self.mean.shape[1]//2:]
        half_var = self.var[:,self.var.shape[1]//2:]
        delta = batch_mean - half_mean
        tot_count = self.count + batch_count

        new_mean = half_mean + delta * batch_count / tot_count
        m_a = half_var * self.count
        m_b = batch_var * batch_count
        m_2 = m_a + m_b + np.square(delta) * (self.count * batch_count / (self.count + batch_count))
        new_var = m_2 / (self.count + batch_count)

        self.mean[:,self.mean.shape[1]//2:] = new_mean
        self.var[:,self.var.shape[1]//2:] = new_var
        # account for the slope information
        self.mean[:,-1] = 0
        self.var[:,-1] = 1
        # duplicate future and present geometry
        self.mean[:,-5:-3] = self.mean[:,-3:-1]
        self.var[:,-5:-3] = self.var[:,-3:-1]
  • 统计量按 count 与 batch_count 做并行合并,效果等同于把所有样本放入一个批次重新计算,但只需保存均值与方差,内存开销与环境数无关。
  • 动作侧,策略输出先经过 tanh,再依次乘以电机强度与 0.4 的动作尺度,最后加上名义关节角:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      act_history.push_back(action_vec.cast<double>());
      auto action = act_history[act_history.size() - 3];

      // mean std normalize action
      pTarget12_ = action;
      pTarget12_ = pTarget12_.cwiseProduct(motor_strength); // adding motor strength perturbation
      pTarget12_ = pTarget12_.cwiseProduct(actionStd_);
      pTarget12_ += actionMean_;

      a1_->setPdTarget(pTarget_, vTarget_);
  • 取 act_history 中倒数第三个动作意味着实际施加的动作存在两个控制步的延迟,对应 20 ms。这一延迟与真实机器人的通信延迟量级相当,训练时引入可以使策略对延迟更鲁棒。
  • 奖励的计算分为跟踪项与惩罚项两部分。跟踪项在 compute_forward_reward 中完成,惩罚项在 step 末尾按系数加权求和:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
    double compute_forward_reward()
    {
      double r = 0.;
      double forward_r = adaptiveForwardVelRewardCoeff_ * std::min(max_speed, bodyLinearVel_[0]);
      // Do not go too fast, but track!
      if (isDown && bodyLinearVel_[0] > (max_speed+0.1))
        forward_r -= adaptiveForwardVelRewardCoeff_ * (bodyLinearVel_[0] - max_speed);
      double angular_r = adaptiveAngularVelRewardCoeff_ * (-std::abs(ang_speed - bodyAngularVel_[2]) + std::abs(ang_speed));
      r += forward_r;
      r += -abs(bodyLinearVel_[1]);
      r += angular_r;
      r += (alive_bonus * 1. / (2. - (double)isSlope));
      return r;
    }
  • 惩罚项与总奖励的合成如下:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      auto cumulative_reward = forwardReward + workReward_ * workRewardCoeff_ +
                               cost_coeff * (footSlipReward_ * footSlipRewardCoeff_ + torqueReward_ * torqueRewardCoeff_ + contactReward_ * contactRewardCoeff_ + deltaContactReward_ * deltaContactRewardCoeff_ + deltaReleaseReward_ * deltaReleaseRewardCoeff_ +
                                             deltaTorqueReward_ * deltaTorqueRewardCoeff_ + actionReward_ * actionRewardCoeff_ + jointSpeedReward_ * jointSpeedRewardCoeff_ +
                                             footClearenceReward_ * footClearenceRewardCoeff_ + upwardReward_ * upwardRewardCoeff_ +
                                             yAccReward_ * yAccRewardCoeff_ + contactDistReward_ * contactDistRewardCoeff_ +
                                             contactChangeReward_ * contactChangeRewardCoeff_ + sidewaysReward_ * sidewaysRewardCoeff_);

      cumulative_reward /= 100;
  • 各系数取自 cfg.yaml,与论文第 III 节的十项奖励并不一一对应,主要差异是代码把若干项关闭并新增了打滑与横移惩罚:
奖励项 配置系数 与论文的对应
前进速度 60 对应前进项,论文系数 20
侧向速度 1 代码中直接计入跟踪项,配置里的 20 只用于日志
转向角速度 35 对应侧向与转向项中的转向部分,论文系数 21
动作幅值 -0.05 对应动作幅值项,论文系数 0.07
关节速度 -0.0007 对应关节速度项,论文系数 0.002
足端打滑 -0.2 对应打滑项,论文系数 0.8
做功 -0.05 对应做功项,论文系数 0.002
侧摆动作 -0.03 论文无独立项
力矩变化 0.0 对应平滑性项,代码中关闭
接触力变化 0.0 对应地面冲击项,代码中关闭
接触力释放 0.0 论文无独立项,代码中关闭
机身姿态 0.0 或 -3.0 仅在低速指令分支启用
  • 终止条件覆盖四类情况:非足端部件触地、机身姿态超限、机身高度过低与回合计时结束:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
    bool isTerminalState(float &terminalReward) final
    {
      terminalReward = float(terminalRewardCoeff_);

      // if the contact body is not feet
      if (itr_number < 100000)
      {
        for (auto &contact : a1_->getContacts())
          if (footIndices_.find(contact.getlocalBodyIndex()) == footIndices_.end())
          {
            return true;
          }
      }

      float term_pitch = 0.2;
      if ((isTest || isSlope || isEval))
        term_pitch = 0.8;
      if (abs(bodyOrientation_[0]) > 0.6 || abs(bodyOrientation_[1]) > term_pitch)
      {
        return true;
      }

      double x = gc_[0];
      double y = gc_[1];
      double z_ht = (hm_) ? hm_->getHeight(x, y) : 0;
      double term_height = 0.24;
      if ((isTest || isSlope || isEval))
        term_height = 0.1;
      if ((gc_[2] - z_ht) < term_height)
      {
        return true;
      }
  • 前 10 万次迭代只允许足端接触地面,用于抑制策略用膝或机身借力的取巧行为;此后该约束解除,允许机器人在复杂地形上以身体接触作为恢复手段。
  • 速度指令每 3 秒重采样一次,采样分两个分支。常规分支给出接近 0.5 m/s 的前进速度与小幅转向;低速分支给出接近零的前进速度,同时提高动作惩罚并施加随机外力:
cpp 复制代码
// raisimGymTorch/env/envs/rsg_a1_task/Environment.hpp
      if (sample_goal_rand_num > 0.6)
      {
        delta_max_speed = -0.5 + 0.15 * (Eigen::VectorXd::Random(1)[0] / 2 + 0.5);
        delta_ang_speed = 0.0 * ((std::rand() % 2) * 2 - 1) * (0.6);
        actionRewardCoeff_ = -1.0;
        upwardRewardCoeff_ = -3.0;
        adaptiveForwardVelRewardCoeff_ = 2.0 * forwardVelRewardCoeff_;
        adaptiveAngularVelRewardCoeff_ = 2.0 * angularVelRewardCoeff_;
      }
      else
      {
        delta_max_speed = -0.05 + 0.1 * Eigen::VectorXd::Random(1)[0];
        delta_ang_speed = 0.4 * Eigen::VectorXd::Random(1)[0];
        actionRewardCoeff_ = 0.;
        adaptiveForwardVelRewardCoeff_ = forwardVelRewardCoeff_;
        adaptiveAngularVelRewardCoeff_ = angularVelRewardCoeff_;
      }
  • 低速分支对应站立与抗扰场景:命令速度接近零时,环境每隔 100 步对机身施加一次随机外力,训练策略在静止状态下抵抗扰动。
  • cost_coeff 的课程更新为 cost_coeff = pow(cost_coeff, cost_decay_fac),配置中初值为 1.0,因此该课程实际不改变系数。若要让系数从较小值缓慢增大,应把初值设为小于 1 的数,并保持衰减因子接近 1。
3-4 适应模块的训练与损失函数
  • 阶段二固定基础策略 π θ \pi_\theta πθ 与编码器 μ \mu μ,只优化历史编码器 ϕ ω \phi_\omega ϕω,优化目标为:
    ω ← ω − η ∇ ω   E ∥ ϕ ω ( x t − k : t − 1 , a t − k : t − 1 ) − μ ( e t ) ∥ 2 2 \omega\leftarrow\omega-\eta\nabla_\omega\,\mathbb E\left\\left\\\|\\phi_\\omega\\left(x_{t-k:t-1},a_{t-k:t-1}\\right)-\\mu\\left(e_t\\right)\\right\\\|_2\^2\\right ω←ω−η∇ωE∥ϕω(xt−k:t−1,at−k:t−1)−μ(et)∥22
  • 与阶段一的区别在于监督信号的来源:阶段一由奖励驱动,梯度经动作回传到编码器;阶段二由奖励之外的教师信号驱动,标签是阶段一编码器的输出,基础策略只作为生成数据的执行器。
    • 以防你不知道,在 DAgger 中,教师(Teacher)负责提供监督标签,学生(Student)则学习模仿教师的输出。
    • 在 RMA 的第二阶段中,可以将第一阶段训练好的环境编码器 μ ( e t ) \mu(e_t) μ(et) 视为教师,其输出的隐编码 z t z_t zt 作为监督标签;适应模块 ϕ \phi ϕ 则是学生,通过历史观测与动作预测 z ^ t \hat z_t z^t,并尽可能使其接近 z t z_t zt。基础策略主要负责根据预测的隐编码执行控制、生成训练数据。
  • 损失按 8 维动力学编码与 2 维地形编码拆成两项,两项等权相加:
    L a d a p t = ∥ z ^ t 0 : 8 − z t 0 : 8 ∥ 2 2 + ∥ z ^ t 8 : 10 − z t 8 : 10 ∥ 2 2 \mathcal L_{\mathrm{adapt}}=\left\|\hat z_t^{0:8}-z_t^{0:8}\right\|_2^2+\left\|\hat z_t^{8:10}-z_t^{8:10}\right\|_2^2 Ladapt= z^t0:8−zt0:8 22+ z^t8:10−zt8:10 22
  • 数据采集遵循 DAgger 的思路:每一步用当前 ϕ ω \phi_\omega ϕω 的估计驱动基础策略,同时用特权信息计算真实 z t z_t zt 存入缓存;一个回合结束后在缓存上做监督更新,下一回合再用更新后的 ϕ ω \phi_\omega ϕω 采集。由此,训练分布随估计器的改进而移动,覆盖了部署时可能出现的偏离轨迹。

说人话: 可以把 DAgger 理解为一个反复迭代的过程:老师先提供正确答案,学生学习模仿;然后让学生自己上场,根据自己的预测执行任务、收集数据,再由老师为这些数据提供正确答案,学生继续学习。如此反复,让学生逐渐适应自己实际执行任务时遇到的情况。

  • 阶段二使用独立的环境 dagger_a1,其观测由 50 帧历史与当前帧组成:历史 50 帧每帧 42 维,当前帧包含本体观测与特权信息共 70 维,总计 2170 维。
cpp 复制代码
// raisimGymTorch/env/envs/dagger_a1/Environment.hpp
      base_obDim_ = baseDim + (int)privinfo * (20 + 3 * (int)use_priv_vel + nFoot * (int)use_slope_dots + 1);
      obDim_ = baseDim * history_len + base_obDim_;
  • 观测的拼接在 observe 中完成,历史片段只取本体观测的 42 维,特权信息只保留当前帧:
cpp 复制代码
// raisimGymTorch/env/envs/dagger_a1/Environment.hpp
    void observe(Eigen::Ref<EigenVec> ob) final
    {
      int lag = 1;
      int vec_size = obs_history.size();
      ob_delay << obs_history[vec_size - lag];
      for (int i = 0; i < history_len; i++)
      {
        ob_concat.segment(baseDim * i, baseDim) << obs_history[vec_size - lag - history_len + i].head(baseDim);
      }
      ob_concat.tail(base_obDim_) << ob_delay;
      ob = ob_concat.cast<float>();
    }
  • 教师信号来自阶段一的策略图。DaggerExpert 加载 TorchScript 模型与归一化统计量,用当前帧的特权部分计算动力学隐编码,并对两个地形 token 分别编码:
python 复制代码
# raisimGymTorch/algo/ppo/dagger.py
class DaggerExpert(nn.Module):
    def __init__(self, loadpth, runid, total_obs_size, T, base_obs_size, nenvs, geomDim = 4, n_futures = 3):
        super(DaggerExpert, self).__init__()
        path = '/'.join([loadpth, 'policy_' + runid + '.pt'])
        self.policy = torch.jit.load(path)
        self.geomDim = geomDim
        self.n_futures = n_futures
        mean_pth = loadpth + "/mean" + runid + ".csv"
        var_pth = loadpth + "/var" + runid + ".csv"
        obs_mean = np.loadtxt(mean_pth, dtype=np.float32)
        obs_var = np.loadtxt(var_pth, dtype=np.float32)
        # cut it
        obs_mean = obs_mean[:,obs_mean.shape[1]//2:]
        obs_var = obs_var[:,obs_var.shape[1]//2:]
        self.mean = self.get_tiled_scales(obs_mean, nenvs, total_obs_size, base_obs_size, T)
        self.var = self.get_tiled_scales(obs_var, nenvs, total_obs_size, base_obs_size, T)
        self.tail_size = total_obs_size - (T + 1) * base_obs_size

    def forward(self, obs):
        obs = obs[:,-self.tail_size:]
        with torch.no_grad():
            prop_latent = self.policy.prop_encoder(obs[:, :-self.geomDim*(self.n_futures+1)-1]) # since there is also ref at the end
            geom_latents = []
            for i in reversed(range(self.n_futures+1)):
                start = -(i+1)*self.geomDim -1
                end = -i*self.geomDim -1
                if (end == 0):
                    end = None
                geom_latent = self.policy.geom_encoder(obs[:,start:end])
                geom_latents.append(geom_latent)
            geom_latents = torch.hstack(geom_latents)
            expert_latent = torch.cat((prop_latent, geom_latents), dim=1)
        return expert_latent
  • get_tiled_scales 把单份 70 维统计量扩展为 2170 维:前 50 帧重复使用本体的 42 维统计量,尾部沿用当前帧的统计量。这样历史与当前帧共享同一套归一化参数,不必为每个时间位置单独估计。
  • 类签名中的默认值为 geomDim=4、n_futures=3,但训练入口在构造时传入配置项,实际取 geomDim=2、n_futures=1;DaggerAgent 的默认值 n_futures=3 同样被入口覆盖为 1。因此几何编码为 2 个 token、共 2 维,历史编码器的输出维度为 10(8 维动力学加 2 维地形),动作 MLP 的输入维度为 52,三者一致。复用该类时应先确认这两个参数是否被覆盖,否则 token 数量与输出维度都会与阶段一的策略图不匹配。
  • 学生策略的 MLP 部分从教师处复制权重后冻结,只训练历史编码器:
python 复制代码
# raisimGymTorch/algo/ppo/dagger.py
class DaggerAgent:
    def __init__(self, expert_policy, prop_latent_encoder, student_mlp,
                 T, base_obs_size, device, n_futures=3):
        expert_policy.to(device)
        prop_latent_encoder.to(device)
        student_mlp.to(device)
        self.expert_policy = expert_policy
        self.prop_latent_encoder = prop_latent_encoder
        self.student_mlp = student_mlp
        self.base_obs_size = base_obs_size
        self.T = T
        self.device = device
        self.mean = expert_policy.mean
        self.var = expert_policy.var
        self.n_futures = n_futures
        self.itr = 0
        self.current_prob = 0
        # copy expert weights for mlp policy
        self.student_mlp.architecture.load_state_dict(self.expert_policy.policy.action_mlp.state_dict())

        for net_i in [self.expert_policy.policy, self.student_mlp]:
            for param in net_i.parameters():
                param.requires_grad = False

    def evaluate(self, obs):
        hlen = self.base_obs_size * self.T
        obdim = self.base_obs_size
        prop_latent = self.get_history_encoding(obs)
        output = torch.cat([obs[:, hlen : hlen + obdim], prop_latent], 1)
        output = self.student_mlp.architecture(output)
        return output

    def get_history_encoding(self, obs):
        hlen = self.base_obs_size * self.T
        raw_obs = obs[:, : hlen]
        prop_latent = self.prop_latent_encoder(raw_obs)
        return prop_latent
  • 采集动作时使用学生策略,即历史编码器当前给出的隐编码;监督标签仍由教师用特权信息计算。这对应 2-4 节所述的 DAgger 式数据采集。
  • 代码中保留了一个教师与学生动作混合的概率 current_prob,每 100 次迭代增加 0.1,但对应的分支已被注释,实际执行的动作始终来自学生。阅读后续版本时需要注意这一处差异。
  • 每次迭代的流程是:采样 600 步并逐步记录(观测,教师隐编码),随后做 1 个 epoch、4 个小批次的回归更新,学习率 5e-3,每 200 次迭代衰减为原来的 0.1。论文报告的学习率为 5e-4,DaggerAgent 的默认值也同为 5e-4,训练入口在构造时把它覆盖为 5e-3。
  • 优化器只包含历史编码器的参数:
python 复制代码
# raisimGymTorch/algo/ppo/dagger.py
        self.optimizer = optim.Adam([*self.actor.prop_latent_encoder.parameters()],
                                    lr=learning_rate)
        self.scheduler = optim.lr_scheduler.StepLR(self.optimizer, step_size=200, gamma=0.1)
  • 训练产物是两张图:历史编码器与冻结的动作 MLP,分别导出为 prop_encoder_*.pt 与 mlp_*.pt。导出时按历史长度把示例输入切成两段,前段送入历史编码器,后段为 42 维本体观测加 10 维隐编码,送入动作 MLP:
python 复制代码
# raisimGymTorch/algo/ppo/dagger.py
    def save_deterministic_graph(self, fname_prop_encoder,
                                 fname_mlp, example_input, device='cpu'):
        hlen = self.base_obs_size * self.T

        prop_encoder_graph = torch.jit.trace(self.prop_latent_encoder.to(device), example_input[:, :hlen])
        torch.jit.save(prop_encoder_graph, fname_prop_encoder)

        mlp_graph = torch.jit.trace(self.student_mlp.architecture.to(device), example_input[:, hlen:])
        torch.jit.save(mlp_graph, fname_mlp)

        self.prop_latent_encoder.to(self.device)
        self.student_mlp.to(self.device)
  • 阶段二的公式与代码对应关系如下:
公式中的量 代码位置 具体实现
x t − k : t − 1 x_{t-k:t-1} xt−k:t−1、 a t − k : t − 1 a_{t-k:t-1} at−k:t−1 dagger_a1/Environment.hpp 的 observe 50 帧历史,每帧取本体观测 42 维
ϕ ω \phi_\omega ϕω StateHistoryEncoder 42 到 32 投影,三层一维卷积,96 到 10
μ ( e t ) \mu(e_t) μ(et) DaggerExpert.forward 复用阶段一策略图的两个编码器
数据采集 dagger.observe 与 dagger.step 学生动作驱动环境,教师隐编码写入缓存
两项损失 _train_step 的 loss_prop 与 loss_geom 均方误差,等权相加
参数更新范围 optim.Adam([*prop_latent_encoder.parameters()]) 只有历史编码器参与优化
学习率调度 StepLR(optimizer, step_size=200, gamma=0.1) 每 200 次迭代衰减为原来的 0.1
  • 两阶段合起来,RMA 的全部可训练参数可以分成三组:阶段一训练基础策略与两个编码器,阶段二只训练历史编码器。部署时保留的是阶段二的编码器与阶段一的动作 MLP,两者拼成一条前向通路。
  • 由于动作 MLP 与教师共享权重,阶段二结束后基础策略的行为保持不变,改变的只是隐编码由特权估计变为历史估计。
3-5 策略推理与部署流程
  • 推理阶段把策略固化为 TorchScript 图。阶段一导出的是完整策略图,其中已包含编码器;阶段二导出的是历史编码器与动作 MLP 两张图。
  • 评测脚本加载模型与归一化统计量后,以单环境模式逐个回合运行。统计量的加载必须先于策略推理,否则输入分布与训练不一致:
python 复制代码
# raisimGymTorch/env/envs/rsg_a1_task/evaluate_policy.py
env = VecEnv(rsg_a1_task.RaisimGymEnv(home_path + "/rsc", dump(cfg['environment'], Dumper=RoundTripDumper)), cfg['environment'])

ob_dim = env.num_obs
act_dim = env.num_acts

env.load_scaling(base_dir, int(runid))
expert = torch.jit.load(os.path.join(base_dir, "policy_{}.pt".format(runid)))
  • load_scaling 读取 mean 与 var 两个 CSV(Comma-Separated Values,逗号分隔值)文件,按 policy_type 决定写入方式:类型 0 与类型 2 用于把底座策略的统计量搬运到后半部分,类型 1 只覆盖后半部分,缺省时整体覆盖。评测脚本调用时不传类型,因此走整体覆盖分支。
  • 回合内的循环记录步数、前进奖励、距离、能耗、平滑度与地面冲击六项指标,回合结束后写入 CSV,由 eval_scripts/compute_results.py 汇总:
python 复制代码
# raisimGymTorch/env/envs/rsg_a1_task/evaluate_policy.py
while ep < num_episodes:
    obs = env.observe(False)
    obs_torch = torch.from_numpy(obs).cpu()
    with torch.no_grad():
        action_ll = expert.forward(torch.from_numpy(obs).cpu())
        reward_ll, dones = env.step(action_ll.cpu().detach().numpy())

    eplen+=1
    metrics += np.array(env.get_reward_info())[:, metric_idxs]
    terminated = np.where(dones == 1)[0].tolist()
  • 真实机器人的部署不在本仓库内。官方把控制器放在另一个仓库中,模型需要复制到其 models 目录,并在启动文件与参数文件中更新模型路径与策略编号。
  • 部署时的频率关系与论文一致:适应模块以 10 Hz 更新隐编码,基础策略以 100 Hz 输出目标关节角。基础策略在两次编码更新之间使用最近一次可用的隐编码;至于两个模块是否由独立线程并行执行、如何同步,取决于具体的部署实现,本仓库不包含这部分代码。
  • 从代码可以确认,推理侧需要一并携带的信息有三类:
    1. 策略图:阶段一为单张图,阶段二为历史编码器与动作 MLP 两张图
    2. 归一化统计量:均值与方差两个 CSV,缺失会导致输入分布偏移
    3. 观测组织方式:历史窗口长度、每帧维度、特权量的排列顺序,三者必须与训练时完全一致
  • 最后一点在实际部署中最容易出错。观测顺序一旦改变,网络仍能输出动作,但隐编码的含义已经不同,表现为步态正常却无法适应地形。

总结

  • 本文从四足机器人强化学习的背景与分类出发,完整梳理了 RMA 的两阶段训练框架:第一阶段借助特权环境因子联合训练编码器与基础策略,第二阶段用历史状态动作回归环境隐编码,部署时以低频估计、高频控制的方式异步运行。
  • 结合官方仓库,本文给出了公式中每个量在代码中的对应位置,包括观测的组织方式、编码器的切片规则、奖励与终止条件的实现、适应模块的蒸馏流程以及推理图的导出方式。
  • 核心要点回顾:
    • 两阶段解耦:环境估计与动作生成分开训练,第一阶段用特权信息学"如何应对",第二阶段用历史学"当前是什么环境"
    • 估计对象是隐编码而非物理参数 : z ^ t \hat z_t z^t 回归的是 μ ( e t ) \mu(e_t) μ(et),编码器只保留对行为有影响的分量
    • DAgger 式数据采集:用当前适应模块驱动基础策略采集轨迹,避免只见过成功轨迹带来的分布偏移
    • 异步频率设计:适应模块 10 Hz、基础策略 100 Hz,利用环境因子变化慢、本体状态变化快的时标差异
    • 工程一致性:观测顺序、归一化统计量、历史窗口长度三者必须在训练与部署之间严格保持一致
  • 如有错误,欢迎指出!
  • 感谢观看!
相关推荐
太理摆烂哥1 小时前
MySQL索引
1024程序员节
爱读书的小胖1 小时前
LeetCode 55 跳跃游戏 C 语言解题分析
c语言·数据结构·算法·leetcode·1024程序员节
Sakura_j_h_1 小时前
Three.js 单文件离线机械臂工作台:爆炸图、透明外壳与剖面
前端·机械臂·webgl·three.js·1024程序员节·esbuild
花间相见1 小时前
【计算基础|MySQL14】MySQL 锁(上):种类与加锁规则
1024程序员节
花间相见1 小时前
【计算基础|MySQL15】MySQL 锁(下):锁全表与死锁
1024程序员节
sdm0704271 小时前
back_insert_iterator-尾插迭代器类
1024程序员节
DisonTangor2 小时前
MOSS-Image-Base 实战:从文生图到 JSON 布局精准控图
人工智能·ai作画·开源·aigc·1024程序员节
TechVoyager_82462 小时前
十六个调试引脚的两副面孔:IT66322 切换芯片的引脚复用与功耗分档
1024程序员节·切换芯片·引脚复用·功耗分档
lunar_coder2 小时前
【C++】如何解决智能指针的析构函数,如何实现shared_ptr的引用计数以及解决shared_ptr的循环引用
c++·1024程序员节