这篇文章尝试从代码实现的角度,系统梳理这个四足极限跑酷项目的整体结构、数据流动、训练过程、奖励函数与域随机化设计。与其把它理解为一个"单纯用深度图控制四足机器人"的项目,不如更准确地说:它是一套先利用仿真特权信息训练强教师策略,再逐步蒸馏到可部署视觉策略的两阶段框架。
项目的主线可以压缩成一句话:训练时,完整的 753 维观测会被拆成多路,分别经过 scan_encoder / priv_encoder / history_encoder / depth_encoder / estimator 等模块,最后被压缩到一个 114 维动作决策输入;部署时,则只保留可感知、可估计的那部分信息,由 depth_actor 输出最终的 12 维动作。
1. 项目总览:从 753 维训练观测到 114 维动作输入
在训练阶段,环境完整观测的拼接顺序是:
obs = proprio(53) + scan(132) + priv_explicit(9) + priv_latent_raw(29) + history(10x53=530) = 753
其中:
proprio(53):本体感觉,包括机身角速度、姿态相关量、yaw 误差、速度命令、关节位置、关节速度、上一时刻动作、足端接触等。scan(132):机器人前方地形高度采样,也就是所谓的地形扫描特权信息。priv_explicit(9):显式特权信息。当前代码中这 9 维里真正有信息的主要是前 3 维机身线速度,后 6 维基本是占位。priv_latent_raw(29):隐藏参数真值,包括质量参数、摩擦系数和电机强度等域随机化变量。history(10x53):最近 10 帧本体感觉历史。
虽然训练时观测是 753 维,但 actor 真正决策时并不直接使用全部原始维度,而是将不同来源的信息编码成低维表示,最终拼成:
114 = proprio(53) + terrain_latent(32) + priv_explicit(9) + latent(20)
部署阶段更进一步,不再输入 753 维完整观测,而是直接构造这 114 维输入。
2. 模块关系:每个编码器到底在做什么
整个项目最容易混淆的地方,是不同 encoder 的职责边界。实际上它们分工非常明确。
2.1 scan_encoder:把地形扫描压成低维地形特征
scan_encoder 接收 obs_scan(132),输出 scan_latent(32)。
它的作用是把高维地形高度采样压缩成策略可用的低维地形表示。这个模块没有显式监督标签,而是作为 actor 的一部分,通过 PPO 端到端学习。换句话说,网络会自动找到"怎样的 32 维地形表示最有利于产生高回报动作"。
2.2 priv_encoder:把隐藏参数真值编码成适应 latent
priv_encoder 接收 priv_latent_raw(29),输出 priv_latent(20)。
这里的 29 维并不是地形,而是仿真中的隐藏参数真值,包括:
- 质量参数 4 维
- 摩擦系数 1 维
- 电机强度 12 维
- 电机强度 12 维
这个模块也不是监督学习,而是把这些原始隐藏参数编码成策略可用的 20 维低维表示。它一方面通过 PPO 梯度更新,另一方面还会受到 priv_reg_loss 的额外约束。
2.3 history_encoder:用历史本体感觉恢复隐藏信息
history_encoder 接收最近 10 帧 proprio(10x53),输出 hist_latent(20)。
它学的不是地形,也不是动作,而是试图从历史运动信息中恢复出与 priv_latent 对齐的低维隐藏参数表示。可以把它理解为"部署时用来代替隐藏真值的一条适应分支"。
需要特别强调的是:
history_encoder 的目标不是拟合 priv_latent_raw(29),而是拟合经过 priv_encoder 压缩后的 priv_latent(20)。
2.4 estimator:从本体感觉预测显式特权信息
estimator 接收 proprio(53),输出 priv_explicit_est(9)。
它是标准监督学习模块,利用 obs 中环境直接提供的 priv_explicit_gt(9) 作为标签,学习从本体感觉估计显式特权量。
这里有一个实现细节非常重要:虽然写成了 9 维,但当前代码里真正有物理意义的主要是前 3 维线速度,后 6 维基本是零占位。因此从语义上看,这一路本质上更接近"线速度估计器"。
2.5 depth_encoder:从深度图提取视觉地形特征与 yaw
depth_encoder 接收一张 58x87 的深度图和一个被抹去 yaw 相关量的 proprio(53),输出:
depth_latent(32):视觉版地形特征pred_yaw(2):预测出来的 yaw 相关量
它的作用是用深度视觉去替代原本依赖扫描得到的 scan_latent,同时补回被屏蔽掉的 yaw 信息。
2.6 actor 与 depth_actor:同一个主干,两种地形特征来源
actor 是教师策略,depth_actor 是学生策略。二者在结构上并没有本质区别,depth_actor 本身就是 actor 的拷贝。它们的唯一区别在于输入中的 32 维地形特征来自哪里:
actor使用scan_encoder(obs_scan)得到的scan_latentdepth_actor使用depth_encoder(depth)得到的depth_latent
因此,depth_actor 并不是"多了一个视觉 head 的 actor",而是同一个 actor 主干,只是地形特征来源从扫描换成了视觉。
3. 数据流动:训练与部署时信息是怎么传的
3.1 第一阶段:Teacher RL
第一阶段的核心目标,是在拥有特权信息的条件下先训练出一个强教师策略。
数据流大致如下:
obs_scan(132)经过scan_encoder得到scan_latent(32)proprio(53)经过estimator得到priv_explicit_est(9)priv_latent_raw(29)经过priv_encoder得到priv_latent(20)- 最近 10 帧
proprio经过history_encoder得到hist_latent(20)
之后 actor 接收:
proprio(53) + scan_latent(32) + priv_explicit_est(9) + latent(20)
这里最后这个 latent(20) 并不是同时放入 priv_latent 和 hist_latent 两份,而是同一个 20 维槽位在两者之间切换:
- 大多数时候是
priv_latent - 做 adaptation / DAgger 更新时会切换成
hist_latent
这一点很重要。它说明 actor 不是同时吃两个 20 维适应向量,而是在训练过程中逐步从"依赖真值隐变量"过渡到"依赖历史估计隐变量"。
3.2 第二阶段:Vision Distillation
第二阶段的目标,是用视觉分支取代扫描分支。
具体流程如下:
- 输入一张
58x87深度图 - 再输入一个被 mask 掉 yaw 相关维度的
proprio(53) depth_encoder输出depth_latent(32)和pred_yaw(2)- 再把
pred_yaw(2)回填到被 mask 的 yaw 位置 - 然后构造学生输入:
proprio(53) + depth_latent(32) + priv_explicit_gt(9) + hist_latent(20)
然后把它送进 depth_actor,输出学生动作。
这里有两个非常关键的实现细节:
第一,第二阶段训练时,depth_actor 用的是 priv_explicit_gt,而不是 estimator 的预测值。这意味着第二阶段存在一个轻微的 train-deploy mismatch:训练时学生还在"偷看"显式特权真值,而部署时必须改成 estimator 输出。
第二,代码里虽然保留了"让 depth_latent 直接拟合 scan_latent"的 latent 对齐接口,但在当前主流程里这一项实际上没有启用。真正生效的是:
depth_actor_loss = || action_teacher_mean - action_student_mean ||yaw_loss = || yaw_teacher - yaw_student ||
因此第二阶段本质上做的不是显式 latent 回归,而是:
- 用视觉特征替代扫描特征
- 用行为蒸馏让学生模仿教师动作
- 用 yaw 监督帮助视觉分支恢复方向相关信息
3.3 第三阶段:Deployment / JIT
部署时真正运行的是:
depth_encoder提供depth_latentestimator提供priv_explicit_esthistory_encoder提供hist_latentdepth_actor输出最终 12 维动作
所以部署态真正依赖的是:
proprio + depth_latent + priv_explicit_est + hist_latent
这一点非常能体现整个项目的设计思想:训练时充分利用仿真里的特权信息,部署时则尽可能只依赖真实可得的感知和历史状态。
4. 梯度更新:每个模块到底怎么学
4.1 第一阶段:强化学习与辅助监督共同进行
第一阶段并不是单纯的 PPO,而是"PPO 主目标 + 多个辅助学习目标"共同作用。
estimator 是最标准的监督学习模块。它用 proprio(53) 预测 priv_explicit_gt(9),损失是预测值和真值之间的均方误差。
history_encoder 主要通过单独的 update_dagger() 更新,其目标是让 hist_latent(20) 拟合 priv_latent(20)。这一步从损失形式上看是监督学习,但因为训练样本来自当前策略不断 rollout 出来的新状态,所以代码里采用了 DAgger 这个名字。这里的 DAgger 指 Dataset Aggregation,中文常译为"数据聚合式模仿学习"。它和普通监督学习的区别不在于 loss,而在于训练数据不是固定离线数据,而是随着当前策略不断重新收集和聚合的。
actor / critic / scan_encoder 主要通过 PPO 更新。具体包括:
- 策略截断损失
- 价值函数损失
- 熵正则项
priv_encoder 稍微特殊。它不直接拟合某个真值标签,而是把 priv_latent_raw(29) 编码成策略可用的 priv_latent(20)。它一方面会受到 PPO 梯度更新,另一方面还会受到 priv_reg_loss 的共同塑形。
这里必须把 priv_reg_loss 说清楚,因为这是整个项目里很容易被理解反的地方。
priv_reg_loss 发生在 PPO 的更新过程中,本质上是一个 latent 对齐正则。代码实现时,hist_latent 被 detach() 成常量,梯度只会回到 priv_latent 这一支,也就是说:
- 它不是"把 history_encoder 拉向 priv_encoder"
- 而是"固定 hist_latent,把 priv_latent 往 hist_latent 的方向拉近"
这样做的目的是缩小训练时 privileged 分支和未来部署时 history 分支之间的表示差距,避免 actor 只适应"带真值隐藏参数"的输入分布,而在切换到历史估计 latent 时性能骤降。
4.2 第二阶段:行为蒸馏 + yaw 监督
第二阶段开始时,会先复制教师 actor,初始化出 depth_actor。之后,教师 actor 在这一阶段只是作为固定老师使用:
- 它负责产生教师动作
- 不参与反向传播
- 不更新参数
学生侧则由 depth_actor + depth_encoder 组成。训练目标有两个:
depth_actor_loss = || action_teacher_mean - action_student_mean ||yaw_loss = || yaw_teacher - yaw_student ||
这里的 depth_actor_loss 比较的是 teacher 和 student 的确定性动作输出,而不是 PPO 中从高斯分布里 sample 出来的随机动作。teacher 和 student 看到的是同一时刻、同一环境状态,但两者的地形信息来源不同:
- teacher 用真实扫描特征
scan_latent - student 用视觉替代特征
depth_latent
这就是典型的行为蒸馏。
最终第二阶段真正被联合更新的是:
depth_actordepth_encoder
而不是教师 actor。
5. 奖励函数:这个项目到底在鼓励什么
从代码实现上看,奖励项虽然很多,但完全可以归纳成四类。
5.1 任务跟踪类奖励
这一类奖励直接决定机器人是否在"完成跑酷任务"。
最核心的两个项是:
tracking_goal_veltracking_yaw
tracking_goal_vel 鼓励机器人沿着当前目标点方向移动。
tracking_yaw 鼓励机器人让自身朝向对准目标方向。
这两项构成了最直接的任务驱动力:既要朝目标走,也要朝向目标。
5.2 机体稳定类奖励
这一类奖励负责限制身体姿态过于激烈、避免翻车。
主要包括:
lin_vel_z:惩罚 z 向速度过大,抑制过激跳动ang_vel_xy:惩罚滚转和俯仰角速度过大orientation:约束身体姿态保持合理
它们的作用是告诉机器人:可以高速跑、可以跳跃,但不能把身体搞成失控状态。
5.3 能耗与动作平滑类奖励
如果没有这类正则,策略很容易学出"能跑但很暴力"的动作模式。
主要包括:
torques:惩罚扭矩过大delta_torques:惩罚相邻时刻扭矩变化过快action_rate:惩罚动作变化过快dof_acc:惩罚关节加速度过大hip_pos、dof_error:鼓励关节不要偏离默认姿态太远
这一类奖励的本质,是让机器人不仅要跑得快,还要跑得像一个真正可控、可执行的机器人。
5.4 跑酷安全与地形交互类奖励
跑酷任务对接触安全要求更高,因此代码里还加入了几项专门和地形交互有关的惩罚。
主要包括:
collision:惩罚身体不该碰撞的部位发生接触feet_stumble:惩罚脚撞到近似垂直障碍feet_edge:惩罚脚踩在地形边缘
这些奖励的目标很明确:机器人不仅要过去,而且要"安全地过去"。
如果把整个奖励系统压缩成一句话,它鼓励的是:
- 朝目标方向稳定前进
- 保持合理朝向和姿态
- 用平滑、低代价的动作完成运动
- 避免危险碰撞和错误落脚
6. 域随机化:项目里到底随机了什么
本项目中的域随机化主要可以归纳成四类:动力学参数随机化、外部扰动随机化、执行器随机化和视觉相关随机化。除此之外,代码里还保留了一些可选的鲁棒性增强项,但当前默认配置下没有全部启用。
6.1 动力学参数随机化
这是最核心的一类随机化,主要包括:
- 摩擦系数随机化
- 机体质量随机化
- 质心偏移随机化
摩擦系数随机化让不同环境实例落在不同摩擦条件下,从而提升策略对接触条件变化的适应能力。
机体质量随机化和质心偏移随机化,则用于模拟真实机器人质量建模误差、安装件变化、电池与传感器负载差异等问题。
这三项共同作用的结果,是让策略不要过拟合某一个精确动力学模型。
6.2 外部扰动随机化
项目中启用了随机推搡机制。实现上并不是施加真实的外力脉冲,而是每隔一段时间直接随机修改机器人 base 的水平速度。
虽然这种实现方式比较简化,但目的很明确:让机器人在训练中反复遭遇扰动,并学会从扰动中恢复。对跑酷任务来说,这一点尤其重要,因为真实世界中的落地误差、碰撞反弹和地形细节偏差,都可以近似理解为某种外部扰动。
6.3 执行器随机化
项目还对电机强度做了随机化,范围是 [0.8, 1.2]。在控制实现里,这会影响 PD 控制中的刚度与阻尼通道,相当于让不同环境中的电机"强一点或弱一点"。
这项设计非常合理,因为现实中的执行器性能不可能永远和仿真标称值完全一致。通过训练时引入这一随机化,可以显著降低策略对理想执行器模型的依赖。
6.4 视觉相关随机化
对于视觉学生策略来说,传感器本身也不能过于理想。项目中对相机参数做了轻量级随机化,主要包括:
- 相机俯仰角随机化
- 水平视场角随机化
- 可选的深度噪声
这类随机化的意义在于:现实相机的安装角度、视场和成像噪声都不可能与仿真完全一致。通过在训练中轻微扰动这些参数,可以减少视觉策略对某一套理想相机配置的过拟合。
6.5 尚未默认启用的可选随机化
除了已经启用的项,代码里还保留了:
- 初始状态随机化
- 动作延迟随机化
- 观测噪声
- 地形测量噪声
这些更像是"鲁棒性工具箱"。当前主配置没有全部打开,但从工程角度看,它们为进一步增强 sim-to-real 泛化提供了扩展空间。
7. 这个项目最值得注意的实现细节
最后总结几条最容易混淆、但也最值得读代码时记住的点。
第一,history_encoder 学的不是地形,而是隐藏参数的低维适应表示。
第二,depth_actor 不是一个全新网络,而是教师 actor 的结构拷贝,区别只在于地形特征来源从扫描切换成了视觉。
第三,第二阶段并没有真正启用 scan_latent -> depth_latent 的显式 latent 对齐损失,真正生效的是动作蒸馏和 yaw 监督。
第四,训练与部署之间存在一个小的不一致:第二阶段蒸馏时学生仍然使用 priv_explicit_gt,而部署时则必须使用 priv_explicit_est 。
8. 总结
从代码角度看,这个项目的设计并不是"直接把深度图喂进 actor"这么简单,而是一套很清晰的逐步替换流程:
- 第一阶段先借助仿真特权信息训练强教师策略
- 同时训练 estimator 和 history adaptation 分支
- 第二阶段再用视觉特征去替代扫描特征,用行为蒸馏训练学生策略
- 最终部署时只保留真实可得的信息:
proprio + depth + history
这套框架最有价值的地方,在于它非常务实地利用了仿真的优势:第一阶段先利用地形特权信息、显式特权信息和隐式特权信息训练出强教师策略,同时让历史编码器学习如何用历史本体信息去逼近隐式特权表示;第二阶段再用深度图提取的视觉地形特征替代原来的地形扫描特征,并结合本体信息、显式信息和历史信息训练学生策略,最终逼近真实部署条件。
对高动态四足跑酷这种任务来说,这种"先在仿真里把问题学明白,再向现实约束收缩"的思路,往往比一开始就强行追求纯端到端部署输入,更容易做出真正可用的系统。