6. 策略训练
上一章的 trot 是我们编程让 Pupper 走;这一章则让它通过强化学习自己寻找动作策略。我们会把 MuJoCo 仿真封装成 Gymnasium 环境,再用 Stable-Baselines3 的 PPO 训练一条能够接收 (vx, vy, wz) 速度命令的低层控制策略。
本章目标
- 能把 MuJoCo 仿真封装成标准
gymnasium.Env - 能解释基础模式的 45 维观测和 gait 模式的 50 维观测
- 能理解 12 维关节位置残差动作和 PD 位置控制
- 能读懂 7 项基础奖励以及可选的 gait 接触奖励
- 能通过 YAML 配置运行、续训和评估 PPO 策略
- 能根据仓库中的真实训练报告判断策略已经学会了什么、还没有学会什么
- 能理解把步态从"能走"打磨到"走得好"的一整套机制:奖励经济学与课程训练、动作滤波、步态条件化和摆动整形
前置阅读
- 第 4–5 章
- 强化学习 · 绪论
- 强化学习 · 策略梯度
- 项目 · DDPG Reacher-v5(熟悉连续动作训练)
6.1 任务定义
策略每个控制周期接收目标速度:
| 指令 | 含义 | 训练范围 |
|---|---|---|
vx | 机身坐标系前后速度 | [-0.75, 0.75] m/s |
vy | 机身坐标系横向速度 | [-0.5, 0.5] m/s |
wz | 绕竖直轴的角速度 | [-2.0, 2.0] rad/s |
PPO 输出 12 个归一化关节位置残差。环境把残差加到默认站姿上,再由 MuJoCo 的 position actuator 和 PD 参数产生关节力矩。
基础模式只指定机身应该怎么移动,不强制四只脚使用某一种步态。打开 gait.enabled 后,策略还会收到 walk、trot 或 pace 的步态标识与周期相位,并通过额外的足端接触奖励学习目标接触顺序。
6.2 Gymnasium 环境
环境定义在 6.rl_pupper/pupper_env.py,机器人模型复用课程公共资源 assets/mjcfs/pupper_v3.xml。关键控制参数如下:
DT_PHYSICS = 0.004 # MuJoCo 物理步长:250 Hz
DT_CONTROL = 0.02 # 策略控制周期:50 Hz
KP, KD = 5.0, 0.25
ACTION_SCALE = 0.5
MAX_STEPS = 1000 # 20 秒一个 episode
环境遵循 Gymnasium 的五元组接口:
obs, info = env.reset(seed=0)
obs, reward, terminated, truncated, info = env.step(action)
其中:
terminated=True表示机身过低或倾斜超过阈值,机器人已经跌倒;truncated=True表示回合达到 1000 个控制步;info保存每个已缩放奖励项,gait 模式还会保存目标/实际接触和接触匹配率。
6.2.1 Reset 随机化
每次 reset 会:
- 把机身放在
(0, 0, 0.28); - 随机采样
[-π, π]的 yaw; - 在默认站姿附近给 12 个关节加入
±0.05 rad噪声; - 清空速度、上一动作和足端腾空计时;
- 随机采样一组新的
(vx, vy, wz)命令。
当前实现没有质量、摩擦、PD 增益随机化,也没有外力 kick、观测噪声或动作延迟。它首先解决平地仿真里的速度跟踪,尚不是可直接部署到真机的 sim2real 环境。
6.2.2 终止条件
满足任一条件就提前终止:
body_z < 0.10
up_in_body_z < cos(0.52)
第二个条件等价于机身倾斜约 30° 以上。提前终止既限制危险姿态,也让 PPO 不必继续收集已经摔倒后的无效数据。
6.3 Observation 与 Action
6.3.1 基础模式:45 维观测
基础观测是单帧状态,不做历史帧堆叠:
| 顺序 | 内容 | 维度 |
|---|---|---|
| 1 | 机身坐标系角速度 | 3 |
| 2 | 机身坐标系重力方向 | 3 |
| 3 | 目标命令 (vx, vy, wz) | 3 |
| 4 | 关节角相对默认站姿的偏差 | 12 |
| 5 | 关节速度 | 12 |
| 6 | 上一步动作 | 12 |
| 合计 | 45 |
核心拼接逻辑是:
obs = np.concatenate([
ang_vel, # 3
gravity, # 3
self.cmd, # 3
joint_angles - DEFAULT_POSE, # 12
joint_vel, # 12
self.last_action, # 12
]).astype(np.float32)
重力方向比世界坐标系四元数更适合做姿态特征,因为它不依赖绝对 yaw。上一动作让策略知道自己刚刚发出了什么命令,有助于学习更平滑的动作变化。
当前观测没有直接提供机身线速度。策略需要从关节状态、姿态变化以及多步交互中间接推断速度,这也是低速跟踪仍会超调的原因之一(6.9 的进阶版补上了这一项)。
6.3.2 Gait 模式:50 维观测
gait.enabled: true 时,在原 45 维末尾追加:
walk/trot/pace三维 one-hot:3 维;sin(phase), cos(phase):2 维。
使用正余弦表达周期相位,可以避免 phase 从 1 跳回 0 时产生数值不连续。
6.3.3 12 维 PD 残差动作
动作空间为:
spaces.Box(-1.0, 1.0, shape=(12,), dtype=np.float32)
动作先转换成默认站姿附近的目标角,再裁剪到关节限位:
motor_target = np.clip(
DEFAULT_POSE + 0.5 * action,
JOINT_LOWERS,
JOINT_UPPERS,
)
残差动作让策略从能够站立的姿态开始探索,比直接输出任意目标关节角更稳定。物理仿真每 4 ms 前进一步,每个策略动作保持 5 个物理步。
6.4 奖励设计
当前基础环境使用 7 项奖励:
REWARD_WEIGHTS = {
"tracking_lin_vel": 1.5,
"tracking_ang_vel": 0.8,
"orientation": -5.0,
"torques": -2e-4,
"action_rate": -0.01,
"feet_air_time": 0.2,
"termination": -100.0,
}
奖励项函数和权重遵循同一符号约定:tracking 项返回越大越好的正值,惩罚项返回非负幅值并乘负权重。
| 奖励项 | 含义 |
|---|---|
tracking_lin_vel | 跟踪机身坐标系下的 (vx, vy) |
tracking_ang_vel | 跟踪绕竖直轴的 wz |
orientation | 惩罚机身竖直方向偏离世界竖直方向 |
torques | 惩罚 12 个关节的平方力矩和 |
action_rate | 惩罚当前动作与上一动作的平方差 |
feet_air_time | 非静止命令下,在足端重新落地时奖励足够长的腾空时间 |
termination | 跌倒时施加一次终止惩罚 |
6.4.1 速度跟踪
线速度奖励为:
角速度奖励使用相同形式:

6.4.2 稳定、能耗和动作平滑
姿态项使用机身竖直轴在世界坐标系水平面的投影平方和;力矩项读取 data.qfrc_actuator;动作变化项计算 ||a_t-a_{t-1}||²。三者分别限制身体倾斜、电机输出和高频抖动。
足端接触目前通过 foot site 高度近似判断,而不是遍历完整的 MuJoCo contact pair:
foot_z = site_z - FOOT_RADIUS
contact = foot_z < 1e-3
6.4.3 总奖励
所有已缩放项先求和、乘控制周期,再裁剪为非负数:
reward = np.clip(sum(scaled.values()) * 0.02, 0.0, 10000.0)
因此 episode return 不会出现负值。跌倒惩罚会压低终止步的奖励,同时 terminated=True 结束当前回合。
6.4.4 Gait 接触奖励
gait 模式会根据当前 gait、周期相位、相位偏移和 duty factor 计算四只脚的期望接触状态,再计算:
contact_match = mean(actual_contact == expected_contact)
r_gait_contact = contact_reward_weight * contact_match
默认迁移配置使用权重 0.5;从零训练的 gait 对照配置将权重提高到 4.0。
6.5 三种训练配置
配置文件位于 6.rl_pupper/configs/:
| 配置 | 观测 | 训练方式 | 预算 | 用途 |
|---|---|---|---|---|
train.yaml | 45 | 从零训练 | 20M | 默认基础策略 |
train_no_gait.yaml | 45 | 从零训练 | 20M | 公平对照 A |
train_gait.yaml | 50 | 从零训练 | 20M | 公平对照 B |
finetune_gait.yaml | 50 | 从 45 维 19M checkpoint 迁移 | 5M | 快速验证 gait 链路 |
默认 train.yaml 的主要参数是:
run:
seed: 0
total_timesteps: 20000000
n_envs: 8
device: cpu
tensorboard: false
output_dir: outputs
environment:
max_steps: 1000
ppo:
n_steps: 2048
batch_size: 256
n_epochs: 4
learning_rate: 0.0003
gamma: 0.97
gae_lambda: 0.95
clip_range: 0.2
ent_coef: 0.01
net_arch: [256, 256]
checkpoint:
every_steps: 1000000
name_prefix: pupper_ppo
final_name: pupper_ppo
resume_from 用于同一观测结构的断点续训;transfer_from 专门用于把 45 维策略迁移到 50 维 gait 策略。迁移时保留输入层前 45 列权重,将新增 5 列权重初始化为零。两者不能同时设置。
6.6 安装与训练
下面的命令都从 codes/practices/quadruped/cs123 目录执行。
6.6.1 安装依赖
cd codes/practices/quadruped/cs123
uv sync
父目录的 pyproject.toml 已声明 Gymnasium、MuJoCo、Stable-Baselines3、PyYAML、ImageIO、Matplotlib、Pytest 和 TensorBoard 等依赖。
6.6.2 短训练
第一次先用较小预算确认整个流程能运行:
uv run python 6.rl_pupper/train.py \
--timesteps 100000 \
--n-envs 4
6.6.3 默认训练
uv run python 6.rl_pupper/train.py
默认读取 6.rl_pupper/configs/train.yaml,最终模型保存到:
6.rl_pupper/outputs/pupper_ppo.zip
每 1M 环境步还会保存一个中间 checkpoint。
命令行参数可以覆盖 YAML 中 run 下的字段:
uv run python 6.rl_pupper/train.py \
--timesteps 20000000 \
--n-envs 16 \
--tensorboard \
--device cpu
MuJoCo 仿真仍在 CPU 上运行。Apple Silicon 可以尝试 --device mps 加速 PPO 网络更新,但不会把 MuJoCo 环境搬到 GPU;没有 CUDA 时,SB3 的 auto 默认回退 CPU,不会自动选择 MPS。
6.6.4 训练设备参考
这个实验包含两类计算:并行 MuJoCo 环境由 CPU 执行,device 只决定 PPO 神经网络使用 CPU、CUDA 还是 MPS。由于当前策略网络只有两层 [256, 256],增加 GPU 并不一定比增加 CPU 并行环境更有效。
下面的配置适合作为起点,实际速度仍取决于 CPU 单核性能、可用核心数、系统负载和 PyTorch 后端:
| 设备场景 | 建议 device | 建议 n_envs | 适合用途 |
|---|---|---|---|
| 普通笔记本,4–8 个逻辑核心 | cpu | 2–4 | 100k–1M 短训练和流程验证 |
| 桌面级多核 CPU,8–16 个逻辑核心 | cpu | 4–8 | 基础实验和较长训练 |
| 高核心数工作站 | cpu | 8–16 | 20M 正式训练与多组对照 |
| Apple Silicon | 先用 cpu,再实测 mps | 4–16 | MPS 只加速策略更新,MuJoCo 仍受 CPU 限制 |
| CUDA 工作站 | cuda | 按 CPU 核心数设置 | GPU 加速策略更新,环境吞吐仍主要取决于 CPU |
仓库报告所用机器是 Apple M3 Ultra:28 个逻辑 CPU 核心、96 GiB 统一内存、60 核 GPU。实际训练采用 device=cpu 和 16 个 SubprocVecEnv,完成约 20M 步用时 49 分钟。这个结果可作为高端多核设备的参考,不代表其他机器的固定耗时。
选择 n_envs 时应从较小值逐步增加;当继续增加环境数却不再提高每秒环境步数时,说明 CPU 调度或内存带宽已经成为瓶颈。CUDA/MPS 是否更快也应以相同配置下的实际吞吐为准。
6.6.5 TensorBoard
启用日志后,在另一个终端运行:
uv run tensorboard --logdir 6.rl_pupper/outputs/tb
重点观察:
rollout/ep_rew_mean:总回报是否持续改善;rollout/ep_len_mean:策略能否逐渐完成更长回合;train/explained_variance:value function 是否学到有效估计;train/std:探索标准差是否持续增大。
6.6.6 断点续训
uv run python 6.rl_pupper/train.py \
--timesteps 20000000 \
--checkpoint 6.rl_pupper/outputs/pupper_ppo.zip
6.6.7 Gait 迁移与公平对照
# 从 19M 基础策略迁移并微调 5M
uv run python 6.rl_pupper/train.py \
--config 6.rl_pupper/configs/finetune_gait.yaml
# 两组从零训练的公平对照
uv run python 6.rl_pupper/train.py \
--config 6.rl_pupper/configs/train_no_gait.yaml
uv run python 6.rl_pupper/train.py \
--config 6.rl_pupper/configs/train_gait.yaml
6.7 评估与输出
基础 45 维策略训练完成后运行:
uv run python 6.rl_pupper/evaluate.py
默认加载 6.rl_pupper/outputs/pupper_ppo.zip,生成:
6.rl_pupper/outputs/demo.gif
6.rl_pupper/outputs/velocity_tracking.webp
也可以显式指定 checkpoint 和输出目录:
uv run python 6.rl_pupper/evaluate.py \
--checkpoint 6.rl_pupper/reports/raw/checkpoints/pupper_ppo_19000000_steps.zip \
--out 6.rl_pupper/outputs/eval_19m
demo.gif 依次测试:
- 站立 2 秒;
vx=0.5 m/s前进 3 秒;wz=0.5 rad/s左转 2.5 秒;vx=-0.3 m/s后退 2.5 秒;- 再次站立 2 秒。
速度图分别在 vx=0.2/0.4/0.6 m/s 下运行 6 秒,绘制目标速度与实际速度。
Linux 无头环境渲染失败时,可以设置 EGL 后端:
MUJOCO_GL=egl uv run python 6.rl_pupper/evaluate.py
当前 evaluate.py 构造的是基础 45 维环境,不能直接加载 50 维 gait checkpoint。gait 接触评估及其图表保存在 reports/gait_finetune/ 中。
6.8 当前真实训练结果
仓库已经保存了一次 Apple M3 Ultra CPU 训练报告。以下数据是特定机器和随机种子下的实验记录,不应当当作所有机器都能复现的固定速度。
6.8.1 20M 基础训练
- 16 个 CPU
SubprocVecEnv - 实际完成 20,021,248 步
- 用时约 49 分钟
ep_rew_mean:2.61 → 24.58,峰值 25.49ep_len_mean:258 → 830,峰值 884,尚未稳定达到 1000- 最佳 checkpoint:19M
- 三档前向速度平均 MAE:0.085 m/s
- 固定速度评估没有摔倒,但完整命令切换演示在
t=4.16 s发生过一次终止

下图展示最佳 19M checkpoint 对 0.2/0.4/0.6 m/s 三档前向速度命令的响应。中高速命令已经能被跟踪,低速段的波动和超调仍然明显。

最终策略的命令切换效果如下。GIF 依次包含站立、前进、左转、后退和再次站立;它可以完成主要动作,但切换过程尚未完全稳定。

完整记录、训练曲线、checkpoint 对比和原始 CSV 见 6.rl_pupper/reports/README.md。
这次实验说明策略已经学会稳定前进、后退、转向和粗粒度变速,但低速命令仍然明显超调,命令切换也没有完全稳定。报告没有对 vy 横移能力做系统评估,因此不能仅凭这组结果宣称已经完成全方向速度跟踪。
6.8.2 5M Gait 迁移微调
迁移实验从 19M 的 45 维策略出发,扩展到 50 维后训练 5M 步。最佳 4.5M checkpoint 的结果是:
- 三种 gait 平均速度 MAE:0.076 m/s;
- 四足接触匹配率:0.485;
- 评估重置次数:0。
速度与稳定性得到保留,但 0.485 的接触匹配接近未按相位区分触地的基准水平。当前实验没有成功形成标准 walk、trot 和 pace,只能证明 gait 输入、相位、奖励、迁移和评估链路已经跑通。(6.9.4 的进阶版把接触奖励权重提高到 4.0 并配合摆动整形后,trot 匹配率可达 85.6%。)
目标接触时序与实测接触的差异可以直接从下图看出:左侧是规则的目标周期,右侧实测触地频率更高,也没有稳定复现三种 gait 的相位关系。


完整结果见 reports/gait_finetune/README.md。
6.9 进阶:从"能走"到"走得好"
上面的最小实验证明了策略"能走",但把它的演示和第 5 章手写 trot 放在一起看,差距一目了然:站立时原地踏步、行走时机身弹跳、步频快而碎、几乎看不到抬腿,命令切换时还可能摔倒。针对这些问题的优化实现在 6.rl_pupper_v2 到 6.rl_pupper_v2_5(v1 目录保持原样作为基线),本节按机制归纳其设计原理。
6.9.1 闭环观测:补上机身线速度
最小实验的头号局限是观测里没有机身线速度,策略只能从关节状态间接推断自己走多快,速度控制本质上是开环的。进阶版在观测末尾追加机身坐标系线速度 3 维(45 → 48 维),跟踪变成闭环,低速超调随之消失。
追加在末尾而不是插在中间,是为了让旧 checkpoint 可以按"新增输入列权重置零"的方式迁移到新结构(transfer_from 机制),不浪费已训练的参数。注意线速度在仿真中可以直接读取,部署到真机需要状态估计器提供等价量,或改用帧堆叠让策略自行估计。
6.9.2 奖励经济学:为什么策略会"站着不动"
给最小实验直接补上竖直速度、横滚俯仰角速度、关节加速度、足端打滑等平滑惩罚,然后从零训练——得到的往往不是更平滑的步态,而是一只一动不动的机器狗。这是本章最值得理解的一个现象,原因藏在奖励的"经济学"里:
- 总奖励下限被裁剪为 0(见 6.4.3),尝试迈步时惩罚超过收益会被截断成 0;
- 而站着不动时,指数型跟踪奖励仍会发放"残余分红"——以
为例,命令 0.5 m/s 下原地不动仍能拿到 的线速度跟踪分; - 于是"站桩保本"的期望收益高于"学步期交税摔跤",策略理性地选择冻结。
进阶版用三件事拆掉这个局部最优:
dont_wait惩罚:命令非零、四足全触地且机身不动时按步罚分,把"站桩抗命"的净收益打到 0 以下;- 收紧跟踪 sigma(0.25 → 0.15):原地不动的残余分红从 37% 降到 19%,而误差 0.1 以内的良好跟踪几乎不受影响;
- 两阶段课程:先用削弱的平滑惩罚 bootstrap 出行走(
bootstrap_walk.yaml),再恢复全额惩罚打磨步态(smooth_finetune.yaml)。惩罚只负责修剪已经存在的行为,而不是阻止行为出现。
配套的还有两类扰动:开局随机初速度(策略从第一步就必须迈步,消除"发现行走"的探索鸿沟)和训练时的随机 kick(站桩扛不住踢,必须学会迈步恢复),以及熵系数在阶段内线性衰减(前期高熵保探索、末期低熵保平滑)。
奖励表在 7 项基础上扩展到 17 项,新增项各司其职:
| 新增项 | 治什么 |
|---|---|
lin_vel_z / ang_vel_xy | 机身弹跳、横滚俯仰摇摆 |
joint_acceleration / foot_slip | 关节抖动、足端拖地打滑 |
stand_still / stand_still_joint_velocity | 零命令时原地踏步 |
abduction_angle | 四腿外八劈叉 |
feet_stance_time | 三腿跛行:feet_air_time 只奖励抬起来的腿,钉死不抬的腿零成本,策略会把一条腿当锚拖行;对连续触地超时的脚按步罚分补上这个漏洞 |
foot_clearance | 看不到抬腿:贴地掠过是平地上的能量最优解,摆动相足端高度在奖励里一分钱不值;给它一个目标高度(3.5 cm,对齐第 5 章的 step_height),真实地形部署也依赖这一项 |
dont_wait | 站桩抗命(见上) |
6.9.3 动作低通滤波:手写步态平滑的等价先验
手写步态"天然平滑"的本质是动作频带受限——摆线轨迹是低频周期函数。而神经网络每 20 ms 输出一次新目标,高频分量靠惩罚项压不干净。进阶版把同样的频带先验直接加给 RL:策略输出先过一阶 EMA 滤波(α=0.5,50 Hz 下截止约 5.5 Hz)再进 PD 伺服,保留步态基频与摆动相、滤掉高频抖动。
关键是训练和评估都带滤波,策略学会穿过滤波器工作,而不是事后补救。这也是真机部署的标准做法。
6.9.4 步态节律与摆动整形
消除抖动之后,与手写步态的观感差距主要剩三点,各有对应机制:
- 节律不规整:涌现步态没有固定周期和相位。用 6.4.4 的 gait 条件化给策略加"节拍器"——只训练 trot 一种步态、接触奖励权重提到 4.0(最小实验的 0.5 验证了不足以改变时序)。
- 步频太快:腾空时间目标 0.1 s 等于给 5 Hz 小碎步发许可证,提高到 0.25 s 后短促点地会在触地时被扣分,步频落向 trot 时钟的 2 Hz。
- 速度与步幅的物理边界:步幅 × 步频 = 速度,Pupper 步幅上限约 10 cm,0.5 m/s 命令下步频物理上必须 4 Hz 以上——第 5 章手写演示看起来从容,一半原因是它只以 0.04–0.1 m/s 踱步。因此评估新增了与手写演示同速档的 0.15 m/s 巡航演示,同速对比才公平。
最后一步是稳定性打磨:摆动变大变慢后平均支撑腿减少、机身晃动回升,行为定型后把 lin_vel_z、ang_vel_xy 惩罚恢复满额、提高速度跟踪权重、低熵收尾即可(纯配置微调,见 stability_polish.yaml)。
6.9.5 最终效果
同一命令序列演示下,最终版(6.rl_pupper_v2_5)与最小实验基线对比:
| 指标 | 基线(v1) | 最终版 |
|---|---|---|
| 前进速度波动(cmd 0.5 m/s) | ±0.190 | ±0.067 |
| 机身高度波动 | ±0.0217 m | ±0.0050 m |
| 站立偏航晃动 | ±0.543 rad/s | ±0.349 rad/s |
| trot 接触匹配率 | —(无节律约束) | 85.6% |
| 巡航步频(0.15 m/s) | — | 2.6 Hz(trot 时钟 2 Hz) |
| 四脚触地占空比 | 一腿 92% 钉地 | 四腿均衡(54–66%) |
| 演示全程摔倒 | 1 次 | 0 次 |


各机制的消融实验如下,每个目录只引入一组改动:
| 目录 | 引入的机制 |
|---|---|
6.rl_pupper_v2 | 线速度观测、17 项奖励、命令重采样与扰动、两阶段课程 |
6.rl_pupper_v2_1 | 钉腿超时惩罚(治三腿跛行) |
6.rl_pupper_v2_2 | 动作低通滤波(治高频抖动) |
6.rl_pupper_v2_3 | trot 步态条件化(治节律不齐) |
6.rl_pupper_v2_4 | 摆动整形与巡航演示(治小碎步、不抬腿) |
6.rl_pupper_v2_5 | 稳定性打磨(纯配置微调) |
每个目录的 reports/README.md 都包含该级的训练配置、checkpoint 对比和与上一级的逐项对照表。
6.10 局限与下一步
进阶版解决了最小实验的多数局限,剩余的开放问题:
- 固定 2 Hz 的 trot 时钟对高速命令物理上不可满足,高速跟踪偏保守;改进方向是让步态周期随命令速度缩放,或改用残差 RL——在第 5 章开环轨迹上学习残差,节律由手写轨迹保证、平衡由 RL 负责;
- 机身线速度观测依赖仿真直读,真机部署需要状态估计器或帧堆叠方案;
- 域随机化仅有初速度与 kick 扰动,观测噪声、动作延迟、摩擦/质量随机化尚未加入,也没有真机部署;
- 足端接触仍使用 site 高度近似。
参考资料
- Stable-Baselines3 PPO 文档
- Gymnasium Env API
- Margolis et al., “Rapid Locomotion via Reinforcement Learning”
- Rudin et al., “Learning to Walk in Minutes Using Massively Parallel Deep Reinforcement Learning”