第 07 章:训练管线——多后端适配、超参调优与实验管理
本章定位:前面两章建立了 observation/action 接口(Ch05)和 reward/termination/curriculum 设计(Ch06)——这些都是环境侧的定义。但环境不会自己训练策略,真正驱动学习的是 RL 训练器。本章的核心任务不是推导 PPO 公式(这是 RL 教材的工作),而是理解 环境和 RL 训练器之间的接口边界——observation 怎样路由到 actor 和 critic、timeout 信息怎样影响 value bootstrap、超参数怎样影响训练稳定性、checkpoint 怎样保存和恢复、ONNX 导出的边界在哪里。这些接口细节看起来琐碎,但它们中的任何一个错误都会导致"能跑但结果错"的隐蔽 bug。
前置依赖:Ch05(Observation 与 Action 设计)、Ch06(Reward, Curriculum, Termination)、PPO 基础概念(policy gradient / advantage / GAE / clipped objective)
关键文献:Schulman et al. 2017(PPO)、Huang et al. 2022("The 37 Implementation Details of PPO")、RSL-RL 5.x(Schwarke et al., "RSL-RL: A Learning Library for Robotics Research", arXiv 2509.10771, 2025)
参考项目:✅ unitree_rl_lab(
github.com/unitreerobotics/unitree_rl_lab)· ✅ unitree_rl_mjlab(github.com/unitreerobotics/unitree_rl_mjlab)
前置自测
📋 答不出 ≥ 2 题 → 先回前置章节复习
| 问题 | 检查目的 |
|---|---|
| actor 和 critic 可以看到不同的 observation 吗?为什么要这样设计? | 检查 Ch05 asymmetric AC 理解 |
PPO 的 batch size 由哪些参数决定?num_envs、num_steps_per_env 和 num_mini_batches 三者的关系是什么? |
检查训练数据流理解 |
| ONNX 导出会不会包含 action scaling 和 offset?部署端需要额外做什么? | 检查 Ch05 部署边界理解 |
init_std(policy Gaussian 的标准差)和 reward kernel 的 std 是同一个东西吗? |
检查 Ch05/Ch06 概念区分 |
| 为什么 on-policy 算法收集完一批数据后旧数据就"过期"了? | 检查 on-policy 本质约束理解 |
time_out=True 标记的 episode 结束对 PPO 的 value bootstrap 有什么影响? |
检查 Ch06 termination 理解 |
修改 decimation 后,reward 的 scale_by_dt 会自动补偿吗?PPO 的 gamma 需要调整吗? |
检查 Ch06 dt 缩放与 PPO 耦合 |
本章难度为 ⭐⭐⭐。核心不是背 PPO 公式,而是理解环境、wrapper、runner 和部署之间的接口边界。一个接口错误可能不会报 error,但会导致训练出的策略在部署时完全失效。
本章目标
学完本章后,你应该能够:
- 解释
RslRlVecEnvWrapper的四个核心职责——observation 路由、action clipping、done 合并、timeout bootstrap - 计算 PPO 训练的 batch size、mini batch size,理解 rollout 长度与 advantage 估计质量的关系
- 配置 Go1 velocity 任务的完整训练流程——从 CLI 启动到 TensorBoard/W&B 日志到 checkpoint 恢复
- 使用 超参数-行为映射表,从训练日志症状反推应该调什么参数
- 判断 何时使用 MLP / RNN / CNN,理解每种模型对 obs 接口和部署的不同影响
- 导出 ONNX 模型并验证一致性——哪些部分在 ONNX 内,哪些必须由部署端复现
- 对比 PPO 与 SAC 的适用场景,理解算法选择与并行规模的关系
本章路线图 ⭐
7.1 PPO 的数据流与核心直觉(理论基础)→ 7.2 RslRlVecEnvWrapper 与 OnPolicyRunner 精读(接口边界)→ 7.3 PPO 超参数全解析(调参指南)→ 7.4 自适应 KL 学习率调度(关键稳定器)→ 7.5 网络架构配置(MLP/RNN/CNN)→ 7.6 Obs Normalization(输入预处理)→ 7.7 完整训练流程(从 CLI 到 checkpoint)→ 7.8 训练诊断与 TensorBoard 解读(故障排查)→ 7.9 PPO vs SAC 选型(算法对比)→ 7.10 ONNX 导出与部署接口(部署边界)→ 7.11 unitree_rl_lab vs unitree_rl_mjlab 精读(案例对比)→ 7.12 源码阅读路线(深入探索)。
前置依赖与本章定位 ⭐
本章在全书中的定位是"RL 工程核心三角的第三边"。Ch05 定义了状态空间和动作空间的结构(obs/action),Ch06 定义了价值函数的形状(reward/termination),本章定义了如何在这个形状上做优化(PPO + 超参数 + 网络架构)。改变其中任何一个,训练结果都可能完全不同。
本章假设你已经理解 PPO 的数学公式(clipped surrogate objective、GAE)。如果不熟悉,建议先阅读 Schulman et al. 2017 和 Huang et al. 2022 的 "37 Implementation Details of PPO"。本章不推导公式——而是讲解每个公式中的参数如何在代码中配置,以及代码中的实现细节如何影响训练效果。
7.1 PPO 的数据流与核心直觉 ⭐⭐
这一节解决什么问题:PPO 是怎样消费环境产生的数据的?一次训练 iteration 包含哪些步骤?
On-Policy 的本质约束 ⭐⭐
PPO 是 on-policy 算法。它先用当前策略 \(\pi_{\text{old}}\) 收集一批 rollout 数据,再用这批数据做若干轮优化,优化完后旧数据就过期——因为优化后的策略 \(\pi_{\text{new}} \neq \pi_{\text{old}}\),旧数据是在 \(\pi_{\text{old}}\) 下采集的,不再代表 \(\pi_{\text{new}}\) 的行为分布。这和 off-policy 算法(如 SAC)形成鲜明对比:SAC 可以复用历史数据(通过 replay buffer),而 PPO 每次 update 后必须丢弃旧数据重新收集。
这个约束看起来像浪费(每批数据只用几次就扔掉),但它带来一个重要的好处:训练过程中不需要维护庞大的 replay buffer,且策略更新的方向始终基于当前策略的真实行为分布。对于四足 locomotion 这种接触丰富、状态空间连续、且需要大规模并行环境的任务,on-policy + 大规模并行是一种非常有效的范式——用 4096 个并行环境的短 rollout(24 步)来弥补单次数据被丢弃的"浪费"。
这和制造业中"批量生产 vs 按需定制"的权衡类似:off-policy 像仓储式批量生产(历史数据可复用但需要管理库存),on-policy 像 JIT(Just-In-Time)生产——每次按需采集,不留库存,但需要产线足够快。大规模并行环境就是 PPO 的"高速产线"。
一次训练 Iteration 的三段数据流 ⭐⭐
RSL-RL 的一个 training iteration 可以拆成三段:
第一段:Rollout Collection。Runner 对每个 env 收集 num_steps_per_env 步。mjlab Go1 配置 num_steps_per_env=24。如果 num_envs=4096,一次 collection 的 transition 数是 \(4096 \times 24 = 98304\)。在每步中,runner 调用 wrapper.step(actions) 获取 obs、reward、done、extras,存储到 rollout storage。
第二段:PPO Learning。计算 return(折扣累积 reward)、advantage(GAE 估计)、policy loss(clipped surrogate objective)、value loss(critic 预测误差)和 entropy bonus。Go1 配置 num_mini_batches=4,每个 mini batch 约 24576 transitions;num_learning_epochs=5,即每批 collected data 被重复优化 5 轮。
第三段:Logging and Checkpoint。写训练指标到 TensorBoard/W&B,按 save_interval 保存 checkpoint。
┌─────────────────────────────────────────────────────────────┐
│ 一次训练 Iteration │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌────────────────┐ │
│ │Collection│ → │ Learning │ → │ Log/Checkpoint │ │
│ │ 4096 env │ │ PPO update │ │ TensorBoard │ │
│ │ × 24 步 │ │ 5 epochs │ │ W&B / .pt │ │
│ │ = 98304 │ │ 4 minibatch │ │ │ │
│ │transitions│ │ = 20 updates│ │ │ │
│ └──────────┘ └──────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Batch size 计算公式:
Go1 velocity 的具体数值:\(98304 / 4 = 24576\) per minibatch,\(5 \times 4 = 20\) updates per iteration。
本质洞察:PPO 的 clip 不是"保守更新"——它是在允许数据复用(
num_learning_epochs > 1)的同时维持 on-policy 近似的工程手段。如果num_learning_epochs=1且num_mini_batches=1,clip 几乎不起作用。clip 的价值在于让你用同一批数据做多轮 epoch 更新而不崩溃。
PPO Clipped Objective 的工程含义 ⭐⭐⭐
PPO 的核心 loss 函数:
其中 \(r_t(\theta) = \pi_\theta(a_t|s_t) / \pi_{\text{old}}(a_t|s_t)\) 是新旧策略的概率比值,\(\hat{A}_t\) 是 GAE 估计的 advantage,\(\epsilon\) 是 clip_param(默认 0.2)。
这个公式可以用一个工程类比来理解。clip 就像汽车的电子限速器——它不阻止你踩油门,而是在速度接近安全边界时限制加速。\(r_t(\theta)\) 衡量新旧策略的偏离程度:\(r = 1\) 意味着新旧策略对该动作的概率完全相同,\(r = 1.5\) 意味着新策略比旧策略对该动作概率高 50%。clip 把 \(r\) 限制在 \([0.8, 1.2]\)(当 \(\epsilon=0.2\)),防止单次更新把策略推离采样分布太远。
GAE 的工程实现 ⭐⭐⭐
GAE(Generalized Advantage Estimation)是 PPO 用来估计 advantage 的核心算法。RSL-RL 的实现如下:
# RSL-RL 的 GAE 计算(简化,展示核心逻辑)
def compute_returns(self, last_values, gamma, lam):
"""从后向前计算 GAE advantage。"""
advantage = 0
for step in reversed(range(self.num_steps)):
if step == self.num_steps - 1:
next_values = last_values
else:
next_values = self.values[step + 1]
# 是否是真正的 terminal state?
# 如果是 timeout(非 terminal),需要 bootstrap
next_is_not_terminal = 1.0 - self.dones[step]
# 处理 timeout bootstrap(关键!来自 Ch06 的 time_outs)
# time_out 的 episode 需要 bootstrap:用 critic 估计替代清零
if "time_outs" in self.extras:
next_is_not_terminal += self.extras["time_outs"][step]
# 效果:timeout 的 episode,next_values 不被清零
# TD error
delta = (self.rewards[step]
+ gamma * next_values * next_is_not_terminal
- self.values[step])
# GAE 递推
advantage = delta + gamma * lam * next_is_not_terminal * advantage
self.returns[step] = advantage + self.values[step]
self.advantages = self.returns - self.values
timeout bootstrap 的关键行。注意 next_is_not_terminal += self.extras["time_outs"][step] 这一行——它确保了 Ch06 中 time_out=True 的 truncation 不会清零 value。如果没有这行,所有 episode 结束(包括时间到的正常截断)都会被当作 failure,critic 会系统性地低估长时间稳定行走的价值。
反事实推理:如果删除这行会怎样?所有 done=True 的 state 的 value target 都变为 0。在 20 秒 timeout 的 velocity task 中,策略会学到"episode 尾部的 value 接近 0"——但尾部的 state 可能是非常好的稳定行走状态,真实 value 应该很高。这个系统性低估会通过 GAE 反向传播,导致策略"不愿意进入 episode 后半段"。
Rollout Storage 的数据结构 ⭐⭐
# RSL-RL RolloutStorage 的核心数据(简化)
class RolloutStorage:
def __init__(self, num_envs, num_steps, obs_dim, action_dim):
# 主要 buffer
self.observations = torch.zeros(num_steps, num_envs, obs_dim)
self.critic_obs = torch.zeros(num_steps, num_envs, critic_obs_dim)
self.actions = torch.zeros(num_steps, num_envs, action_dim)
self.rewards = torch.zeros(num_steps, num_envs)
self.dones = torch.zeros(num_steps, num_envs)
self.values = torch.zeros(num_steps, num_envs)
self.log_probs = torch.zeros(num_steps, num_envs)
self.returns = torch.zeros(num_steps, num_envs)
self.advantages = torch.zeros(num_steps, num_envs)
# 显存估算(Go1 velocity, float32)
# obs: 24 * 4096 * 48 * 4 bytes ≈ 18 MB
# critic_obs: 24 * 4096 * 72 * 4 bytes ≈ 27 MB
# actions + rewards + dones + values + log_probs: ~60 MB
# 总计 ~105 MB —— 对现代 GPU 来说不是瓶颈
def mini_batch_generator(self, num_mini_batches):
"""随机打乱后切分 minibatch。"""
batch_size = self.num_envs * self.num_steps
mini_batch_size = batch_size // num_mini_batches
# 随机打乱 index(打破时间相关性)
indices = torch.randperm(batch_size)
for start in range(0, batch_size, mini_batch_size):
idx = indices[start:start + mini_batch_size]
yield {
"obs": self.observations.view(-1, obs_dim)[idx],
"actions": self.actions.view(-1, action_dim)[idx],
# ... 其他字段
}
显存分析。对 Go1 velocity(4096 envs × 24 steps × 48D obs),rollout storage 约 105 MB——远小于模型显存和物理仿真显存。如果遇到 OOM,优先检查 num_envs(减半可以减半物理仿真显存),而不是 num_steps_per_env。
如果不 clip 会怎样?同一批 rollout 被多次 minibatch 更新时,策略可能剧烈偏移,导致后续 minibatch 的 importance weight 不再有效。这就是 TRPO 用 KL 约束解决的问题,PPO 用 clip 更高效地近似了。
GAE 的工程参数 ⭐⭐
GAE(Generalized Advantage Estimation)的两个参数直接影响训练效果:
其中 \(\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)\) 是 TD error。
\(\gamma\)(折扣因子)控制"看多远"。\(\gamma = 0.99\) 意味着 100 步后的 reward 衰减为 \(0.99^{100} \approx 0.37\)——策略关心约 100 步以内的未来。如果 policy dt = 0.02s,100 步 = 2 秒——对四足 locomotion 来说这是一个合理的规划视野。
\(\lambda\)(GAE lambda)控制 bias-variance tradeoff。\(\lambda = 1\) 时 GAE 退化为 Monte Carlo return(无偏但高方差),\(\lambda = 0\) 时退化为单步 TD(低方差但有偏差)。\(\lambda = 0.95\) 是经典折中——在 critic 估计不太准确时(训练初期),更多依赖实际 reward 而非 critic 预测。
反事实推理:如果 \(\gamma = 0.9\)(而非 0.99)?有效视野只有约 10 步(\(0.9^{10} \approx 0.35\))——策略只关心 0.2 秒以内的 reward,可能学到贪婪的短视行为。如果 \(\gamma = 0.999\)?视野扩大到约 1000 步(20 秒)——对于 20 秒 episode 来说几乎是全局最优,但 value function 更难学习(需要准确估计 1000 步后的累积 reward)。
⚠️ 常见陷阱
⚠️ 编程陷阱:num_steps_per_env 设置过小导致 advantage 估计偏差。 如果 num_steps_per_env=4(只有 4 步 rollout),GAE 只能用 4 步的实际 reward + critic 的 bootstrap 来估计 advantage——严重依赖 critic 的准确性。训练初期 critic 很差,advantage 估计几乎是噪声。推荐至少 16-32 步。
💡 概念误区:认为"batch size 越大训练越好"。 太大的 batch size 可能导致每次更新的信息增量太小(梯度方差低但步长也小),收敛变慢。locomotion 的经典 batch size 是 \(\sim\)100k transitions(4096 envs × 24 steps),这已经足够稳定。
练习
- [计算题]
num_envs=2048,num_steps_per_env=32,num_mini_batches=8,num_learning_epochs=5。计算 batch size、mini batch size 和每次 iteration 的总更新次数。 - [分析题] 将
num_learning_epochs从 5 改为 20,可能会出现什么问题?PPO 的 clip 机制能否完全防止这个问题?
上节建立了 PPO 的数据流直觉。但 PPO 并不直接与 env 交互——中间有一个 wrapper 层,负责把 env 的输出转换为 PPO 期望的格式。这个 wrapper 是最容易出 bug 的地方。
7.2 RslRlVecEnvWrapper 与 OnPolicyRunner 精读 ⭐⭐⭐
这一节解决什么问题:环境和 RSL-RL runner 之间的接口边界是什么?wrapper 做了哪些关键转换?
Wrapper 的四个核心职责 ⭐⭐⭐
RslRlVecEnvWrapper(mjlab 和 Isaac Lab 都有等价实现)是 env 和 RSL-RL 之间的唯一桥梁。它做四件事:
职责一:Observation 路由。 env 返回 obs_dict = {"actor": tensor, "critic": tensor}(mjlab)或 {"policy": tensor, "critic": tensor}(Isaac Lab)。wrapper 通过 runner 配置中的 obs_groups 映射把这些 dict 转换为 RSL-RL 期望的 TensorDict 格式。
# wrapper 内部的 observation 路由(简化)
def get_observations(self):
obs_dict = self.env.observation_manager.compute()
# obs_groups 映射:runner 配置中 "actor" -> env 的 "actor" group
# 返回 TensorDict,runner 用 obs_groups key 访问
return obs_dict
职责二:Action clipping。 wrapper 可以在 action 送入 env 之前 clip,防止网络输出超出合理范围。这与 env 内部 ActionManager 的 clip 是两道不同的防线——wrapper clip 限制 raw action,ActionManager clip 限制 processed action。
职责三:Done 合并。 env 分别返回 terminated 和 truncated(bool tensor)。wrapper 合并为 RSL-RL 期望的 dones(long tensor):dones = (terminated | truncated).to(torch.long)。
职责四:Timeout bootstrap 信号。 对于无限时域任务(is_finite_horizon=False),wrapper 把 truncated(且不是 terminated)的信息放入 extras["time_outs"]。RSL-RL 的 GAE 计算在看到 time_outs=True 时不会清零 value——而是用 critic 的估计值做 bootstrap。这正是 Ch06 中讨论的 truncation vs true termination 的工程实现。
# wrapper.step() 核心转换逻辑(两个框架语义一致)
def step(self, actions):
obs_dict, reward, terminated, truncated, extras = self.env.step(actions)
# 职责三:合并 dones
dones = (terminated | truncated).to(torch.long)
# 职责四:timeout 信号
if not self.unwrapped.cfg.is_finite_horizon:
extras["time_outs"] = truncated.to(torch.long)
return obs_dict, reward, dones, extras
OnPolicyRunner 的训练循环 ⭐⭐
OnPolicyRunner(RSL-RL)的 learn() 方法是整个训练的主循环:
# OnPolicyRunner.learn() 简化流程
def learn(self, num_iterations):
for iteration in range(num_iterations):
# === 第一段:Collection ===
with torch.inference_mode():
for step in range(self.num_steps_per_env):
# 1. actor 前向推理
actions = self.actor(obs["actor"])
# 2. critic 前向推理
values = self.critic(obs["critic"])
# 3. env step
obs, rewards, dones, extras = self.wrapper.step(actions)
# 4. 存储到 rollout storage
self.storage.add(obs, actions, rewards, dones, values, log_probs)
# === 第二段:Learning ===
# 计算 GAE advantage(使用 gamma, lam, time_outs)
self.storage.compute_returns(self.critic, gamma, lam)
# PPO 更新
for epoch in range(self.num_learning_epochs):
for batch in self.storage.mini_batch_generator(self.num_mini_batches):
self.ppo.update(batch)
# === 第三段:Logging ===
self.log_metrics(iteration)
if iteration % self.save_interval == 0:
self.save_checkpoint()
MjlabOnPolicyRunner 的三个扩展 ⭐⭐
mjlab 继承 RSL-RL 的 OnPolicyRunner 做了三类关键扩展:
(1) 清理 None optional config。 RSL-RL 5.x 的 RslRlMLPModelCfg 有些 optional 字段默认 None,但 MLPModel 的 __init__ 不接受 None。mjlab 的 runner 在创建 model 前自动清理这些字段。
(2) 保存 common_step_counter 到 checkpoint。 curriculum 恢复依赖 common_step_counter——如果 checkpoint 不保存这个值,从 checkpoint 恢复训练后 curriculum 会从头开始(step=0),导致任务难度骤降。
# mjlab checkpoint 保存扩展
def save(self, path):
# 标准 RSL-RL 保存
super().save(path)
# 额外保存 common_step_counter
torch.save({
"common_step_counter": self.env.common_step_counter,
}, path + "_extras.pt")
(3) 迁移旧 checkpoint key。 RSL-RL 4.x→5.x 的 API 变化导致 checkpoint key 变化(如 actor.0.weight → mlp.0.weight)。mjlab 的 runner 自动检测旧格式并迁移。
双框架 wrapper 差异 ⭐⭐
| 维度 | mjlab | Isaac Lab |
|---|---|---|
| obs group 命名 | actor / critic |
policy / critic |
| wrapper 类 | RslRlVecEnvWrapper |
RslRlVecEnvWrapper (from isaaclab_rl) |
| action clip | 配置在 runner cfg | 配置在 runner cfg |
| curriculum 恢复 | common_step_counter 保存在 extras |
Isaac Lab 2.3+ 也保存 |
| ONNX 导出 | export_policy_as_onnx() from RSL-RL |
同上(共用 RSL-RL) |
⚠️ 常见陷阱
⚠️ 编程陷阱:obs_groups 映射错误导致 critic 丢失 privileged 信息。 如果 obs_groups = {"actor": ("actor",), "critic": ("actor",)}——critic 和 actor 看到相同的 observation,Ch05 中设计的 privileged information 完全浪费。诊断方法:训练启动时打印 actor 和 critic 的 obs dim。如果维度相同,很可能配置错误。
🧠 思维陷阱:认为 wrapper 只是格式转换。 wrapper 做的 timeout bootstrap 信号传递直接影响 value function 的训练 target——搞错等于给 critic 错误的 label。这不是格式问题,是数学问题。
练习
- [源码题] 在 mjlab 中找到
RslRlVecEnvWrapper.step()的实现。确认extras["time_outs"]的设置逻辑。如果is_finite_horizon=True,time_outs 会被设置吗? - [实验题] 在训练前打印 actor obs dim 和 critic obs dim。如果两者相同,说明什么问题?
# 练习 2 起始代码
def check_obs_dims(env, runner_cfg):
"""检查 actor/critic obs 维度是否符合预期。"""
obs_dict = env.reset()
for group_name, obs in obs_dict.items():
print(f" {group_name}: shape={obs.shape}")
# 检查 obs_groups 映射
for runner_key, env_groups in runner_cfg.obs_groups.items():
total_dim = sum(obs_dict[g].shape[1] for g in env_groups)
print(f" Runner '{runner_key}' → env groups {env_groups} → dim={total_dim}")
wrapper 解决了"数据怎么传"的问题。接下来的核心问题是"数据怎么用"——PPO 的每个超参数控制什么,怎么根据训练日志调参。
7.3 PPO 超参数全解析 ⭐⭐⭐
这一节解决什么问题:Go1 的 PPO 配置为什么是这组值?每个参数控制什么?从日志症状如何反推该调哪个参数?
参数总览与默认值对比 ⭐⭐
以下是 legged_gym(历史基准)、Isaac Lab(ANYmal-B rough)和 mjlab(Go1 velocity)的 PPO 超参数对比:
| 参数 | legged_gym | Isaac Lab ANYmal-B | mjlab Go1 | 控制什么 |
|---|---|---|---|---|
clip_param |
0.2 | 0.2 | 0.2 | ratio clip 范围 |
entropy_coef |
0.01 | 0.005 | 0.01 | entropy bonus 系数 |
num_learning_epochs |
5 | 5 | 5 | 数据复用次数 |
num_mini_batches |
4 | 4 | 4 | mini batch 数 |
learning_rate |
1e-3 | 1e-3 | 1e-3 | 初始学习率 |
schedule |
adaptive | adaptive | adaptive | LR 调度方式 |
desired_kl |
0.01 | 0.01 | 0.01 | adaptive 目标 KL |
gamma |
0.99 | 0.99 | 0.99 | 折扣因子 |
lam |
0.95 | 0.95 | 0.95 | GAE lambda |
max_grad_norm |
1.0 | 1.0 | 1.0 | 梯度裁剪阈值 |
value_loss_coef |
1.0 | 1.0 | 1.0 | value loss 权重 |
num_steps_per_env |
24 | 24 | 24 | rollout 长度 |
关键观察:除了 entropy_coef(0.01 vs 0.005),所有参数在三个项目中完全一致。这不是巧合——legged_gym 的超参数经过广泛验证,后续项目都以此为起点。entropy_coef 的差异是独立调优的结果——0.01 鼓励更多探索,0.005 更快收敛到确定性策略。建议从 0.01 开始,如果后期 action std 仍然很高(策略过于随机),减小到 0.005。
双框架 PPO 配置代码 ⭐⭐⭐
# ============= mjlab PPO 配置 =============
from mjlab.rl.config import (
RslRlOnPolicyRunnerCfg,
RslRlPpoAlgorithmCfg,
RslRlMLPModelCfg,
GaussianDistributionCfg,
)
agent_cfg = RslRlOnPolicyRunnerCfg(
# Runner 配置
num_steps_per_env=24,
max_iterations=1500, # rough 用 1500,flat 用 300
save_interval=50,
experiment_name="go1_velocity",
logger="tensorboard", # 或 "wandb"
# obs_groups 路由(Ch05 定义的 group → runner 的 key)
obs_groups={
"actor": ("actor",),
"critic": ("critic",),
},
# PPO 算法配置
algorithm=RslRlPpoAlgorithmCfg(
clip_param=0.2,
desired_kl=0.01,
learning_rate=1e-3,
gamma=0.99,
lam=0.95,
entropy_coef=0.01,
num_learning_epochs=5,
num_mini_batches=4,
value_loss_coef=1.0,
use_clipped_value_loss=True,
max_grad_norm=1.0,
),
# Actor 网络配置(RSL-RL 5.x 新 API)
actor=RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
distribution_cfg=GaussianDistributionCfg(init_std=1.0),
),
# Critic 网络配置(可以与 actor 不同维度)
critic=RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False,
# critic 没有 distribution_cfg——输出 deterministic value
),
)
# ============= Isaac Lab PPO 配置 =============
from isaaclab_rl.rsl_rl import (
RslRlOnPolicyRunnerCfg,
RslRlPpoAlgorithmCfg,
RslRlPpoActorCriticCfg, # Isaac Lab 可能仍使用旧 API
)
@configclass
class AnymalBRoughPPORunnerCfg(RslRlOnPolicyRunnerCfg):
num_steps_per_env = 24
max_iterations = 1500
save_interval = 50
experiment_name = "anymal_b_rough"
algorithm = RslRlPpoAlgorithmCfg(
clip_param=0.2,
desired_kl=0.01,
learning_rate=1e-3,
gamma=0.99,
lam=0.95,
entropy_coef=0.005, # 注意:Isaac Lab 用 0.005
num_learning_epochs=5,
num_mini_batches=4,
value_loss_coef=1.0,
use_clipped_value_loss=True,
max_grad_norm=1.0,
)
policy = RslRlPpoActorCriticCfg(
init_noise_std=1.0,
actor_hidden_dims=[512, 256, 128],
critic_hidden_dims=[512, 256, 128],
activation="elu",
)
两个框架的关键 API 差异:mjlab 使用 RSL-RL 5.x 的分离 actor/critic 配置(RslRlMLPModelCfg),Isaac Lab 可能仍使用旧版的统一 RslRlPpoActorCriticCfg。如果 Isaac Lab 也升级到 RSL-RL 5.x,配置模式会与 mjlab 完全一致。
超参数-行为映射表 ⭐⭐⭐
这是本章最重要的工具——从日志症状反推应该检查哪个配置:
| 超参数 | 调大的效果 | 调小的效果 | 诊断信号 |
|---|---|---|---|
clip_param |
允许更大策略变化 | 更保守的更新 | KL divergence |
desired_kl |
容忍更大分布偏移 | 更频繁降低 LR | adaptive lr 变化 |
learning_rate |
更快但可能不稳定 | 更慢但更稳定 | KL, value loss |
gamma |
更关注长期回报 | 更关注短期回报 | reward 平台期 |
lam |
advantage 接近 MC(高方差低偏差) | 更依赖 critic(低方差高偏差) | advantage 方差 |
entropy_coef |
更多探索 | 更快收敛 | entropy / action std |
num_learning_epochs |
更充分利用数据 | 更接近单次更新 | KL 在 epoch 后期 |
num_mini_batches |
更小 minibatch,更多更新 | 更大 minibatch,更稳定 | 梯度噪声 |
num_steps_per_env |
更长 rollout,GAE 更准 | 更短 rollout,更新更频繁 | advantage 质量 |
max_grad_norm |
更少裁剪 | 更频繁裁剪 | 梯度裁剪触发率 |
使用方法是反向的:先看日志症状,再查对应参数。
| 日志症状 | 首先检查 | 第二检查 |
|---|---|---|
| KL 持续偏高(>0.05) | learning_rate → 降低 |
desired_kl → 减小 |
| KL 持续为零 | 策略未更新 → 检查 reward scale | learning_rate → 增大 |
| entropy 过早塌缩 | entropy_coef → 增大 |
action_rate_l2 penalty 是否过强 |
| value loss 长期很高 | critic obs 是否缺少 privileged | reward 尺度是否合理 |
| reward 上升但策略抖动 | action scale(Ch05) | action_rate_l2 weight(Ch06) |
| reward 不上升 | reward 设计(Ch06) | num_steps_per_env 是否太短 |
| 训练初期 NaN | obs 或 reward 中有 NaN | max_grad_norm → 减小 |
调参优先级 ⭐⭐
从高到低:
- 环境难度(curriculum / command / terrain)——最容易修复
- reward 设计(term / 权重 / \(\sigma\))——影响最大
- PPO 参数(LR / entropy / batch)——通常不是根因
- 网络架构(hidden dims / RNN / CNN)——最后才调
原因是后者更难归因——同时改 reward 和 LR,无法知道哪个修复了问题。
⚠️ 常见陷阱
⚠️ 编程陷阱:只看 reward 曲线改 LR。 正确做法:先看 KL 再改 LR。KL 经常超 desired_kl → adaptive 已经在降 LR,手动再降可能过度。KL 长期过小 → adaptive 在升 LR,说明策略变化太慢。
💡 概念误区:认为 clip_param 控制参数变化幅度。 clip_param 限制的是 policy ratio 在 loss 中的贡献范围,不是直接限制网络参数变化。
🧠 思维陷阱:认为"PPO 参数不对"是训练失败的主因。 90% 的训练失败根因在 reward 设计或 obs/action 配置,而非 PPO 超参数。PPO 的默认参数已经过广泛验证——如果你需要大幅修改它们,先检查环境配置。
练习
- [计算题] 如果
num_envs=8192(而非 4096),其他参数不变,batch size 变为多少?这对训练有什么影响? - [分析题] KL 持续为零说明什么?策略在学习吗?可能的原因是什么?
- [实验题] 将
num_learning_epochs从 5 改为 20,观察 KL 变化。解释为什么 KL 可能变大。
PPO 超参数中最关键的单个机制是 adaptive KL 学习率调度——它是训练稳定性的核心保障。
7.4 自适应 KL 学习率调度 ⭐⭐⭐
这一节解决什么问题:adaptive schedule 怎么工作?为什么它是 locomotion 训练的"最重要稳定器"?
机制详解 ⭐⭐⭐
每次 PPO update 完成后,RSL-RL 计算新旧策略之间的 KL divergence:
然后根据 KL 与 desired_kl 的关系调整 learning rate:
# RSL-RL adaptive KL schedule 核心逻辑
def update_lr(self, mean_kl):
if mean_kl > self.desired_kl * 2.0:
self.learning_rate = max(self.learning_rate / 1.5, 1e-5)
elif mean_kl < self.desired_kl / 2.0:
self.learning_rate = min(self.learning_rate * 1.5, 1e-2)
for param_group in self.optimizer.param_groups:
param_group['lr'] = self.learning_rate
规则解读: - KL > 2 × desired_kl → 策略变化太快 → LR 除以 1.5(降速) - KL < desired_kl / 2 → 策略变化太慢 → LR 乘以 1.5(加速) - KL 在 [desired_kl/2, 2×desired_kl] 之间 → 不调整
这个机制让训练自动适应不同阶段:早期策略变化快(KL 大)→ LR 自动降 → 避免崩溃。后期策略接近收敛(KL 小)→ LR 自动升 → 不浪费计算。
这和自动驾驶中的自适应巡航控制(ACC)非常类似:前方车辆近(KL 大)→ 自动减速(降 LR);前方空旷(KL 小)→ 自动加速(升 LR)。目标不是固定速度,而是维持"安全但高效"的跟车距离——PPO 维持的是"安全但高效"的策略更新幅度。
本质洞察:adaptive KL schedule 是 PPO 在 locomotion 任务中稳定训练的"核心安全网"。如果关闭它(使用 fixed LR),你需要非常小心地选择 LR——太大会在训练初期 KL 爆炸,太小会在后期收敛过慢。adaptive schedule 自动解决了这个问题。
与 Ch06 reward curriculum 的交互。当 reward curriculum 在某个 step 收紧 \(\sigma\)(Ch06),reward landscape 突然变化——策略在下一次 rollout 中看到的 advantage 分布会发生大幅偏移,导致 KL spike。adaptive schedule 此时会自动降低 LR 来吸收这个冲击。这就是为什么推荐 curriculum 的 stage 间隔要足够大——给 adaptive schedule 足够时间恢复。
TensorBoard 中 adaptive LR 的诊断 ⭐⭐
# TensorBoard 中观察 adaptive schedule 的关键字段
Loss/learning_rate # 当前实际 LR
Loss/mean_kl # 平均 KL divergence
# 正常模式:
# 训练初期 LR 下降(策略变化快),中期稳定,后期可能回升
# KL 在 desired_kl 附近波动
# 异常模式:
# LR 持续下降到下限 1e-5 → 策略在剧烈振荡,检查 reward/obs
# LR 持续上升到上限 1e-2 → 策略变化太小,检查 reward gradient
# KL spike → 环境参数突变(curriculum 切换或 DR 太强)
⚠️ 常见陷阱
⚠️ 编程陷阱:手动设置 schedule="fixed" 后忘了精确选择 LR。 fixed schedule 需要你自己平衡初期和后期——这比 adaptive 困难得多。除非有特殊理由,始终使用 schedule="adaptive"。
练习
- [分析题] 如果
desired_kl=0.001(而非 0.01),adaptive schedule 会怎样表现?策略更新速度会如何变化? - [实验题] 运行 200 iteration 训练,在 TensorBoard 中观察
Loss/learning_rate和Loss/mean_kl的关系。记录 LR 调整的时间点和对应的 KL 值。
7.5 网络架构配置 ⭐⭐
这一节解决什么问题:MLP 的层数和宽度怎么选?什么时候需要 RNN 或 CNN?
MLP 架构经验法则 ⭐⭐
locomotion 的标准 actor/critic 架构是三层 MLP [512, 256, 128] + ELU 激活。这不是唯一正确的选择,但它经过了最广泛的验证。
# RSL-RL 5.x MLP 配置
actor = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128), # 三层递减
activation="elu", # ELU 比 ReLU 在 locomotion 中更稳定
obs_normalization=False,
distribution_cfg=GaussianDistributionCfg(init_std=1.0),
)
层数选择:两层通常不够(四足 locomotion 的非线性关系需要至少三层来表达);四层通常多余(增加参数但不增加表达力)。如果任务明显更复杂(如人形全身控制,obs dim > 100),可以增加到 [1024, 512, 256, 128]。
宽度选择:递减是常见模式(512→256→128),物理直觉是"逐步压缩信息"——类似于信号处理中的多级滤波,每一层提取更高阶的特征。但也有项目使用等宽([256, 256, 256])——差异通常很小。
激活函数:ELU 在负值区域有非零梯度(\(\text{ELU}(x) = \alpha(e^x - 1)\) for \(x < 0\)),比 ReLU 的"死神经元"问题更少。TienKung-Lab 和 HOVER 也使用 ELU。
init_std 的物理含义。GaussianDistributionCfg(init_std=1.0) 设置策略输出的 Gaussian 分布的初始标准差。init_std=1.0 意味着训练开始时 raw action 的 68%(1σ)在 [-1, 1] 范围内——这是一个合理的探索范围。如果 init_std 太小(如 0.01),策略几乎不探索,可能卡在初始行为;如果 init_std 太大(如 10.0),初始 action 噪声太大导致机器人剧烈抖动甚至 NaN。训练过程中 action std 会逐渐下降(从 TensorBoard 的 Loss/mean_noise_std 观察),反映策略从"探索"过渡到"确定性行为"。
# RSL-RL 5.x 的 Gaussian distribution 配置
from rsl_rl.utils.config import GaussianDistributionCfg
# 标准配置
dist_cfg = GaussianDistributionCfg(
init_std=1.0, # 初始 action 标准差
# RSL-RL 5.x 移除了旧版的 stochastic, noise_std_type, state_dependent_std
# 这些功能通过新的 distribution_cfg 架构统一管理
)
# 用在 actor config 中
actor = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
distribution_cfg=dist_cfg,
)
RNN 架构(部分可观测场景) ⭐⭐
当 actor observation 不满足 Markov 性(如缺少 base velocity、需要从 history 估计地面属性)时,可以使用 RNN(LSTM/GRU)替代 MLP:
# RSL-RL RNN 配置(假设框架支持)
actor = RslRlRecurrentModelCfg(
hidden_dims=(256,),
rnn_type="lstm",
rnn_hidden_size=256,
rnn_num_layers=1,
# 注意:RNN 需要在推理时维护 hidden state
)
RNN 的部署影响:ONNX 导出需要包含 hidden state 的输入和输出——部署端必须在每步之间保持 hidden state。这比 MLP(无状态推理)复杂得多。MLP 的推理是纯函数(obs → action),RNN 的推理有状态(obs + hidden → action + new_hidden)。
RNN vs History MLP 的工程权衡:
| 维度 | RNN (LSTM) | History MLP |
|---|---|---|
| 理论表达力 | 无限历史 | 固定窗口 |
| 部署复杂度 | 高(维护 hidden state) | 低(无状态) |
| ONNX 导出 | 复杂(额外 I/O) | 简单 |
| 训练稳定性 | 较差(BPTT 梯度问题) | 较好 |
| 实际效果 | 理论更强 | 实践中差异不大 |
对于大多数 locomotion 任务,推荐使用 History MLP(Ch05 的 history_length 配置)而非 RNN。原因是 history_length=3-5(即保留最近 3-5 步的 observation)在实践中足以提供足够的时间信息,且不需要部署端维护状态。只有在确实需要长期记忆(如导航任务中的路径记忆)时才考虑 RNN。
如果必须使用 RNN,注意以下工程细节:
RNN 训练时的 rollout 顺序。MLP 的 minibatch 可以随机打乱所有 transitions(破坏时间顺序没问题)。RNN 的 minibatch 不能打乱——必须保持每个 environment 内的时间顺序。RSL-RL 的 rollout storage 在 RNN 模式下会按 env 为单位切分 minibatch,而非随机打乱。
RNN 的 episode 边界处理。当 episode 结束(done=True)时,hidden state 需要 reset。在训练中,rollout storage 会在 done 的 timestep 自动清零 hidden state。在部署中,需要在 FSM(有限状态机)检测到 episode 重启时手动调用 reset_hidden()。
# RNN episode 边界处理(部署端伪代码)
class RNNDeployController:
def __init__(self, onnx_path, hidden_size):
self.session = ort.InferenceSession(onnx_path)
self.hidden_size = hidden_size
self.reset_hidden()
def reset_hidden(self):
self.h = np.zeros((1, 1, self.hidden_size), dtype=np.float32)
self.c = np.zeros((1, 1, self.hidden_size), dtype=np.float32)
def step(self, obs):
outputs = self.session.run(None, {
"obs": obs.reshape(1, -1),
"h_in": self.h, "c_in": self.c
})
self.h, self.c = outputs[1], outputs[2]
return outputs[0]
def on_fall_detected(self):
"""摔倒后 reset hidden state。"""
self.reset_hidden()
CNN 架构(视觉输入) ⭐⭐
当 observation 包含图像(如 depth camera)时,需要 CNN encoder:
depth_image [64×64] → CNN encoder → latent [64D] → concat with proprio → MLP → action
# CNN encoder 典型结构
Conv2d(1, 32, 3, stride=2) → ELU →
Conv2d(32, 64, 3, stride=2) → ELU →
Conv2d(64, 64, 3, stride=2) → ELU →
Flatten → Linear(64*6*6, 64) → ELU
架构选型决策树 ⭐⭐
observation 包含图像吗?
├── 是 → CNN encoder + MLP
│ 部署需要什么?→ camera pipeline + ONNX with CNN
└── 否 → observation 满足 Markov 性吗?
├── 是 → MLP [512, 256, 128](默认选择)
│ 适用:velocity tracking + contact forces
└── 否 → 有 history_length 配置吗?
├── 是 → MLP with flattened history(首选)
│ history_length=3-5 通常够用
└── 否 → RNN (LSTM)
需要部署端维护 hidden state
⚠️ 常见陷阱
⚠️ 编程陷阱:把网络改大来"修复"训练问题。 如果 [512,256,128] 不收敛,换成 [1024,512,256,128] 通常也不收敛——根因几乎不在网络容量。先检查 reward 和 obs 配置。
练习
- [决策题] observation 包含关节角度(12D)、base velocity(3D)、gravity(3D)、command(3D)、depth image(64×64)。用什么架构?depth 怎样处理?
- [实验题] 只把 critic 的
obs_normalization改True(actor 保持 False),跑 200 iterations,比较 value loss。确认这不影响部署 actor。
7.6 Obs Normalization ⭐⭐
这一节解决什么问题:什么是 running mean/std normalization?什么时候需要?对 ONNX 导出有什么影响?
Running Mean/Std 机制 ⭐⭐
obs_normalization=True 时,RSL-RL 在 model 内部维护 observation 的 running mean 和 std:
# RSL-RL obs normalization 的完整实现逻辑
class EmpiricalNormalization:
"""Welford's online algorithm for running mean/var。
这个类在 RSL-RL 的 MLPModel 内部使用。
它不是 PyTorch Module——不参与梯度计算。
"""
def __init__(self, obs_dim, epsilon=1e-8):
self.running_mean = torch.zeros(obs_dim)
self.running_var = torch.ones(obs_dim)
self.count = torch.tensor(epsilon) # 防止除零
def update(self, obs_batch):
"""用新数据更新 running statistics。"""
batch_mean = obs_batch.mean(dim=0)
batch_var = obs_batch.var(dim=0)
batch_count = obs_batch.shape[0]
# Welford's method for numerical stability
delta = batch_mean - self.running_mean
total_count = self.count + batch_count
self.running_mean += delta * batch_count / total_count
m_a = self.running_var * self.count
m_b = batch_var * batch_count
M2 = m_a + m_b + delta**2 * self.count * batch_count / total_count
self.running_var = M2 / total_count
self.count = total_count
def normalize(self, obs):
"""标准化输入。"""
return (obs - self.running_mean) / (self.running_var.sqrt() + 1e-8)
Running mean 的收敛特性。训练初期(count 小),running mean/var 不准确——前几百个 iteration 的 normalized obs 可能不稳定。这就是为什么有些项目在训练初期关闭 normalization(或使用预收集的 statistics)。
何时使用 vs 不使用:
| 场景 | obs_normalization | 理由 |
|---|---|---|
| velocity baseline(已 term-level scale) | False | 避免双重归一化,简化部署 |
| 量纲差异巨大(力 + 角度 + height scan) | True | 自动平衡不同量纲 |
| 只给 critic 打开 | critic=True, actor=False | 改善 value 估计,不影响部署 |
| 部署简单优先 | False | 避免 normalizer 同步问题 |
| vision 特征 | True(仅 vision 维度) | 像素值分布与物理量差异大 |
只给 critic 打开 normalization 是一个被低估的技巧。它不影响 actor 的 ONNX 导出(actor 输入保持原始 scale),但能让 critic 更好地估计 value——因为 critic obs 通常包含量纲差异很大的 privileged 信息(如接触力和角度混合)。
# mjlab 配置:只给 critic 打开 normalization
actor = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=False, # actor 不归一化 → 部署简单
distribution_cfg=GaussianDistributionCfg(init_std=1.0),
)
critic = RslRlMLPModelCfg(
hidden_dims=(512, 256, 128),
activation="elu",
obs_normalization=True, # critic 归一化 → value 估计更准
)
ONNX 导出的 Normalization 处理 ⭐⭐
如果 obs_normalization=True 且是 actor 的 normalization:
# 验证 ONNX 是否包含 normalizer
import onnx
import onnxruntime as ort
def check_onnx_normalizer(onnx_path):
"""检查 ONNX 模型是否包含 normalization 层。"""
model = onnx.load(onnx_path)
# 检查图中是否有 Sub(减均值)和 Div(除标准差)节点
has_sub = any("Sub" in node.op_type for node in model.graph.node)
has_div = any("Div" in node.op_type for node in model.graph.node)
if has_sub and has_div:
print("✅ ONNX appears to contain normalization (Sub + Div nodes)")
else:
print("⚠️ ONNX may NOT contain normalization!")
print(" If obs_normalization=True, this is a bug.")
print(" 部署端需要手动加载 running mean/std 并预处理。")
# 打印输入信息
for inp in model.graph.input:
print(f"Input: name={inp.name}, shape={[d.dim_value for d in inp.type.tensor_type.shape.dim]}")
# 使用:check_onnx_normalizer("policy.onnx")
⚠️ 常见陷阱
⚠️ 编程陷阱:部署端自己做了一层归一化,但 ONNX 内部也有 normalizer——双重归一化导致输入错误。 自检方法:用相同输入对比 PyTorch 和 ONNX 的输出。
7.7 完整训练流程 ⭐⭐
这一节解决什么问题:从 CLI 启动到 checkpoint 恢复的完整工程流程。
mjlab 训练启动 ⭐⭐
# ============= Step 1: 列出可用任务 =============
uv run list-envs
# 期望输出包含 Mjlab-Velocity-Flat-Unitree-Go1, Mjlab-Velocity-Rough-Unitree-Go1 等
# ============= Step 2: Flat 任务训练(推荐先从 flat 开始) =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 300 \
--agent.logger tensorboard \
--agent.seed 42
# ============= Step 3: 查看训练日志 =============
tensorboard --logdir /tmp/mjlab/logs/
# ============= Step 4: Play 验证 =============
uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
--agent.load-run <run_name> --num-envs 4 --viewer viser
# ============= Step 5: 从 checkpoint 恢复训练 =============
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 600 \
--agent.resume True \
--agent.load-run <run_name> \
--agent.load-checkpoint model_300.pt
# ============= Step 6: Rough 任务训练 =============
uv run train Mjlab-Velocity-Rough-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 1500 \
--agent.logger tensorboard
Isaac Lab 训练启动 ⭐⭐
# Step 1: 列出任务
python scripts/reinforcement_learning/rsl_rl/train.py --help
# Step 2: Flat 训练
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 4096 --max_iterations 300 --seed 42
# Step 3: TensorBoard
tensorboard --logdir logs/rsl_rl/
# Step 4: Play
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0
# Step 5: 恢复训练
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 4096 --max_iterations 600 --resume --load_run <run_name>
train.py 内部执行流 ⭐⭐
1. CLI 解析 → TaskConfig.from_task()
├── 从 registry 读 env_cfg 和 agent_cfg
└── CLI override 应用到 dataclass 字段
2. launch_training()
├── 创建 log directory
├── 设 CUDA_VISIBLE_DEVICES
└── 写 params/env.yaml 和 params/agent.yaml
3. run_train()
├── 构造 ManagerBasedRlEnv(env_cfg)
├── 构造 RslRlVecEnvWrapper(env)
├── 从 registry 找 runner class (MjlabOnPolicyRunner)
├── 构造 runner(wrapper, agent_cfg)
├── 加载 checkpoint (如 resume=True)
└── runner.learn(max_iterations)
├── Collection → Learning → Logging 循环
└── 按 save_interval 保存 checkpoint
W&B 集成 ⭐⭐
# 使用 W&B 代替 TensorBoard
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--agent.logger wandb \
--agent.wandb-project "go1_velocity" \
--agent.wandb-entity "your-team"
W&B 相比 TensorBoard 的优势:(1) 自动记录超参数配置,方便 ablation 对比;(2) 支持 sweep(自动化超参搜索);(3) 多机协作时的集中式日志管理。
W&B Sweep 自动化超参搜索 ⭐⭐
当手动调参效率低下时,可以使用 W&B Sweep 自动搜索最优超参数组合:
# sweep_config.yaml
method: bayes # 贝叶斯优化(比 grid/random 更高效)
metric:
name: Episode_Reward/track_linear_velocity
goal: maximize
parameters:
learning_rate:
min: 1e-4
max: 5e-3
distribution: log_uniform_values
entropy_coef:
values: [0.001, 0.005, 0.01, 0.02]
num_learning_epochs:
values: [3, 5, 8]
clip_param:
values: [0.1, 0.2, 0.3]
# 启动 sweep
wandb sweep sweep_config.yaml
# 输出 sweep_id
# 启动 agent(可以在多台机器上启动多个 agent)
wandb agent <your-entity>/<project>/<sweep_id>
Sweep 的注意事项:(1) 每次 sweep run 应该足够长(至少 200 iteration)才能有意义——太短的 run 无法反映最终收敛行为;(2) 固定 seed 或使用多 seed 取平均,避免随机性干扰结论;(3) sweep 的搜索空间不要太大——先用粗粒度 sweep 确定大致范围,再细粒度搜索。
多 GPU 训练 ⭐⭐
mjlab 通过 torchrunx 支持多 GPU 训练,Isaac Lab 通过 torchrun 支持。
# ============= mjlab 多 GPU 训练 =============
# 使用 torchrunx(mjlab 内置支持)
uv run train Mjlab-Velocity-Rough-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 1500 \
--distributed True \
--num-gpus 2
# ============= Isaac Lab 多 GPU 训练 =============
# 使用 torchrun
torchrun --nproc_per_node=2 scripts/reinforcement_learning/rsl_rl/train.py \
--task Isaac-Velocity-Rough-Anymal-C-v0 \
--num_envs 4096 --max_iterations 1500 \
--distributed
多 GPU 的工程注意事项:
| 维度 | 单 GPU | 多 GPU |
|---|---|---|
| num_envs | 4096 | 4096 per GPU → 总 8192 |
| batch size | 98304 | 196608(翻倍) |
| gradient sync | 无 | all-reduce 每个 minibatch |
| wall-clock 加速 | — | ~1.6-1.8× for 2 GPU |
| 调参影响 | — | 可能需要调整 LR(线性缩放规则) |
加速效率不是线性 2× 的原因:(1) all-reduce 通信开销;(2) minibatch 更大后每次更新的梯度方差更小,可能需要更大 LR 来补偿——这就是"线性缩放规则"(linear scaling rule):batch size 翻倍时 LR 也应翻倍。但在 PPO 中,adaptive KL schedule 会自动调 LR,所以不需要手动翻倍——让 adaptive 自己适应即可。
Checkpoint 格式详解 ⭐⭐
# checkpoint 包含的内容
checkpoint = {
# 模型权重
"model_state_dict": actor_critic.state_dict(),
# 优化器状态(恢复训练时需要)
"optimizer_state_dict": optimizer.state_dict(),
# 训练进度
"iter": current_iteration,
# obs normalizer 状态(如果打开了 normalization)
"obs_norm_mean": obs_normalizer.running_mean,
"obs_norm_var": obs_normalizer.running_var,
"obs_norm_count": obs_normalizer.count,
}
# mjlab 额外保存
extras = {
"common_step_counter": env.common_step_counter,
# curriculum 恢复依赖此值
}
从 checkpoint 恢复时的验证步骤:
# 恢复训练后的验证脚本
def verify_resume(env, runner, expected_iteration):
"""验证恢复训练是否正确。"""
print(f"Expected iteration: {expected_iteration}")
print(f"Current iteration: {runner.current_learning_iteration}")
assert runner.current_learning_iteration == expected_iteration, \
"Iteration mismatch after resume!"
print(f"Common step counter: {env.common_step_counter}")
if env.common_step_counter == 0:
print("⚠️ common_step_counter is 0 — curriculum may have reset!")
# 验证 LR
lr = runner.optimizer.param_groups[0]['lr']
print(f"Current learning rate: {lr:.6f}")
# 验证 obs normalizer(如果使用)
if hasattr(runner, 'obs_normalizer') and runner.obs_normalizer is not None:
mean = runner.obs_normalizer.running_mean
print(f"Obs normalizer mean range: [{mean.min():.3f}, {mean.max():.3f}]")
⚠️ 常见陷阱
⚠️ 编程陷阱:恢复训练时忘了恢复 curriculum state。 如果 checkpoint 不保存 common_step_counter,恢复后 step-driven curriculum 会从 stage 0 重新开始——任务难度骤降,策略可能"退化"。mjlab 的 MjlabOnPolicyRunner 已处理此问题,但自定义 runner 需要手动保存。
练习
- [实验题] 在 mjlab 中完整执行 Step 1-5。记录训练 300 iteration 的 wall-clock time、final tracking reward 和 steps/s。
- [编程题] 写一个脚本,在训练后自动从最新 checkpoint 导出 ONNX 并运行一致性验证(复用 Ch05 的
onnx_verify.py)。
7.8 训练诊断与 TensorBoard 解读 ⭐⭐⭐
这一节解决什么问题:怎么从训练日志判断训练是否健康?常见失败模式的诊断方法。
TensorBoard 字段速查 ⭐⭐
# PPO 核心指标
Loss/mean_reward # 平均 episode reward
Loss/mean_surrogate_loss # actor loss(应稳定下降或波动在小范围)
Loss/mean_value_loss # critic loss(应稳定下降)
Loss/mean_entropy # 策略 entropy(应缓慢下降,不应骤降)
Loss/mean_kl # KL divergence(应在 desired_kl 附近波动)
Loss/learning_rate # 当前 LR(adaptive schedule 的输出)
Loss/mean_noise_std # action Gaussian 的 std(应从 init_std 缓慢下降)
# 环境指标(来自 RewardManager / TerminationManager)
Episode_Reward/* # 各 reward term 的 episode 均值
Episode_Termination/* # 各 termination condition 的触发次数
Curriculum/* # curriculum state(地形 level、命令 stage)
Metrics/* # 任务级 metrics(tracking error、slip velocity 等)
指标联合阅读法 ⭐⭐⭐
训练曲线不能单独看。一条 reward 曲线上升不代表策略变好——可能是策略学会了站着不动(episode 变长导致 reward 累积增加,但运动能力没有提升)。
| 指标组合 | 正常模式 | 异常信号 | 可能原因 |
|---|---|---|---|
| reward ↑ + episode_length ↑ | 学会避免摔倒 | episode_length ↑ 但 tracking ↓ | 学会站着不动 |
| reward ↑ + KL 平稳 | 更新幅度可控 | KL spike 后 reward ↓ | 单次更新过猛 |
| entropy 缓降 + reward ↑ | 探索→确定策略 | entropy 骤降 + reward 平台 | 过早收敛 |
| value_loss ↓ + reward ↑ | critic 越来越准 | value_loss ↑ + reward ↑ | critic 跟不上变化 |
| action_rate 平稳 + tracking ↑ | 平滑且准确 | action_rate 低但 tracking 差 | action scale 太小 |
常见失败模式诊断 ⭐⭐⭐
失败模式 1:策略收敛到站立不动。
- 症状:total reward 上升但 tracking reward 接近零
- 根因:penalty 总量 > tracking 贡献,或 action scale 太小
- 诊断:检查 Episode_Reward/track_* 和所有 penalty 项的绝对值
- 修复:增大 tracking weight 或减小 penalty weight(Ch06)
# 诊断脚本:检查 standing-still 问题
def diagnose_standing_still(env, runner, num_steps=200):
"""检查策略是否收敛到站立不动。"""
policy = runner.get_inference_policy()
obs = env.reset()["actor"]
base_velocities = []
tracking_rewards = []
for _ in range(num_steps):
with torch.no_grad():
actions = policy(obs)
obs_dict, rewards, _, _, extras = env.step(actions)
obs = obs_dict["actor"]
# 记录 base velocity
base_vel = env.scene.robot.data.root_lin_vel_b[:, :2]
base_velocities.append(base_vel.norm(dim=1).mean().item())
avg_speed = sum(base_velocities) / len(base_velocities)
print(f"Average base speed: {avg_speed:.3f} m/s")
if avg_speed < 0.1:
print("⚠️ Policy converged to standing still!")
print(" 1. 检查 tracking weight 是否足够大")
print(" 2. 检查 penalty 总量是否压过 tracking")
print(" 3. 检查 action scale 是否太小")
print(" 4. 检查 command 是否在 actor obs 中")
失败模式 2:KL 持续爆炸后训练崩溃。
- 症状:Loss/mean_kl 持续 >0.1,Loss/learning_rate 降到下限
- 根因:reward 尺度突变(curriculum 切换)、obs 中有 NaN、LR 初始值太高
- 诊断:检查 curriculum 切换时间点是否与 KL spike 对应
- 修复:延后 curriculum stage 或减小初始 LR
失败模式 3:NaN 传播链。 - 症状:loss 变为 NaN,训练停止 - 根因:obs NaN → 网络输出 NaN → loss NaN - 诊断链路:
# NaN 传播链诊断脚本
def diagnose_nan_chain(env, runner, max_steps=1000):
"""逐步检测 NaN 传播路径。"""
policy = runner.get_inference_policy()
obs = env.reset()["actor"]
for step in range(max_steps):
# 检查 obs NaN
if torch.isnan(obs).any():
nan_dims = torch.isnan(obs).any(dim=0).nonzero().squeeze()
print(f"Step {step}: NaN in obs at dims {nan_dims.tolist()}")
# 找到哪些 env 有 NaN
nan_envs = torch.isnan(obs).any(dim=1).nonzero().squeeze()
print(f" Affected envs: {nan_envs[:5].tolist()}")
return "obs_nan", step
with torch.no_grad():
actions = policy(obs)
# 检查 action NaN
if torch.isnan(actions).any():
print(f"Step {step}: NaN in actions (network output)")
return "action_nan", step
obs_dict, rewards, terminated, truncated, extras = env.step(actions)
obs = obs_dict["actor"]
# 检查 reward NaN
if torch.isnan(rewards).any():
nan_envs = torch.isnan(rewards).nonzero().squeeze()
print(f"Step {step}: NaN in rewards at envs {nan_envs[:5].tolist()}")
return "reward_nan", step
print(f"No NaN detected in {max_steps} steps ✅")
return None, max_steps
失败模式 4:Critic 持续高 loss。
- 症状:Loss/mean_value_loss 不下降或很高
- 根因:critic obs 缺少 privileged 信息,或 reward 尺度太大
- 诊断代码:
# 诊断 critic obs 维度
def check_critic_setup(env, runner_cfg):
"""验证 critic 是否接收了 privileged observation。"""
obs_dict = env.reset()
actor_dim = obs_dict.get("actor", obs_dict.get("policy")).shape[1]
critic_dim = obs_dict["critic"].shape[1]
print(f"Actor obs dim: {actor_dim}")
print(f"Critic obs dim: {critic_dim}")
print(f"Privileged dim: {critic_dim - actor_dim}")
if critic_dim == actor_dim:
print("⚠️ Critic and actor have SAME obs dim!")
print(" This means critic has NO privileged information.")
print(" Check obs_groups mapping in runner config.")
print(f" Current obs_groups: {runner_cfg.obs_groups}")
elif critic_dim < actor_dim:
print("🔴 Critic has FEWER dims than actor — this is almost certainly wrong!")
else:
print(f"✅ Critic has {critic_dim - actor_dim} extra privileged dims")
失败模式 5:训练初期 reward 震荡不收敛。 - 症状:reward 在前 200 iteration 剧烈震荡 - 根因通常不在 PPO 参数——而在 reward 设计(Ch06) - 诊断:检查 tracking 和 penalty 是否在交替主导 advantage 方向
# 诊断 reward 震荡
def diagnose_reward_oscillation(tensorboard_logdir):
"""分析 reward 分项的时间序列模式。"""
print("检查以下模式:")
print(" 1. tracking ↑ → penalty ↑ → tracking ↓ → penalty ↓ → ...")
print(" → tracking 和 penalty 量级太接近,增大 tracking weight")
print(" 2. reward spike → KL spike → LR drop → slow recovery")
print(" → curriculum 切换太突然,增大 stage 间隔")
print(" 3. 所有分项都在震荡")
print(" → 可能是 learning_rate 太大,让 adaptive 降低")
完整训练诊断 callback ⭐⭐⭐
以下 callback 可以集成到训练循环中,自动检测常见问题:
# training_monitor.py — 集成到训练循环的自动诊断
import torch
class TrainingMonitor:
"""在训练过程中自动检测常见问题。"""
def __init__(self, env, runner_cfg, check_interval=50):
self.env = env
self.runner_cfg = runner_cfg
self.check_interval = check_interval
self.kl_history = []
self.reward_history = []
def on_iteration_end(self, iteration, metrics):
"""每个 iteration 结束后调用。"""
# 记录历史
kl = metrics.get("mean_kl", 0)
reward = metrics.get("mean_reward", 0)
self.kl_history.append(kl)
self.reward_history.append(reward)
# 定期检查
if iteration % self.check_interval == 0 and iteration > 0:
self._check_kl_health(iteration)
self._check_reward_progress(iteration)
self._check_obs_dims()
def _check_kl_health(self, iteration):
"""检查 KL 是否异常。"""
recent_kl = self.kl_history[-self.check_interval:]
avg_kl = sum(recent_kl) / len(recent_kl)
max_kl = max(recent_kl)
if max_kl > 0.1:
print(f"[iter {iteration}] ⚠️ KL spike detected: max={max_kl:.4f}")
print(f" Check: curriculum 切换?reward 突变?LR 太高?")
if avg_kl < 1e-5:
print(f"[iter {iteration}] ⚠️ KL near zero: avg={avg_kl:.6f}")
print(f" Check: 策略未更新?reward scale 太小?")
def _check_reward_progress(self, iteration):
"""检查 reward 是否在进步。"""
if len(self.reward_history) < 2 * self.check_interval:
return
recent = self.reward_history[-self.check_interval:]
previous = self.reward_history[-2*self.check_interval:-self.check_interval]
recent_avg = sum(recent) / len(recent)
previous_avg = sum(previous) / len(previous)
if recent_avg < previous_avg * 0.9:
print(f"[iter {iteration}] ⚠️ Reward regression: "
f"{previous_avg:.2f} → {recent_avg:.2f}")
elif abs(recent_avg - previous_avg) < 0.01 * abs(previous_avg):
print(f"[iter {iteration}] 📊 Reward plateau: {recent_avg:.2f}")
def _check_obs_dims(self):
"""检查 actor/critic obs 维度。"""
obs = self.env.reset()
actor_dim = obs.get("actor", obs.get("policy")).shape[1]
critic_dim = obs["critic"].shape[1]
if actor_dim == critic_dim:
print("⚠️ Actor and critic obs dims are equal — "
"privileged info may be missing!")
# 使用方法:在训练循环中集成
# monitor = TrainingMonitor(env, runner_cfg)
# for iteration in range(max_iterations):
# ... training code ...
# monitor.on_iteration_end(iteration, metrics)
⚠️ 常见陷阱
🧠 思维陷阱:认为"reward 曲线高 = 行为好"。 这正是 reward hacking(Ch06)。PPO 只会忠实地优化你给它的目标——如果 reward 函数有漏洞,PPO 会高效地利用它。
练习
- [诊断题] 训练 500 iteration 后,total reward 高(38.0)但 play video 中机器人在跳跃前进而非正常走路。TensorBoard 中
Episode_Reward/track_linear_velocity= 0.85(高),Episode_Reward/undesired_contact= -0.01(低)。问题在哪?应该调什么? - [编程题] 写一个训练 callback,在每 50 个 iteration 自动检查:(a) KL 是否超过 3×desired_kl (b) reward 是否有 NaN (c) actor obs dim vs critic obs dim。打印警告信息。
7.9 PPO vs SAC 选型 ⭐⭐
这一节解决什么问题:什么时候 PPO 是正确选择?什么时候应该考虑 SAC?
选型决策框架 ⭐⭐
| 维度 | PPO | SAC |
|---|---|---|
| 算法类型 | on-policy | off-policy |
| 数据复用 | 差(每批用完即弃) | 好(replay buffer) |
| GPU 并行利用 | 极好(4096+ envs) | 中等(replay 采样瓶颈) |
| 调参难度 | 低(经典参数通用) | 中等(alpha, buffer size) |
| 典型训练时间 | 10-60 min(4096 envs + GPU) | 数小时(受限于 buffer) |
| 适合场景 | locomotion, 大规模并行 | manipulation, 有限并行 |
| 社区支持 | 极好(RSL-RL 生态) | 好(SB3, RL Games) |
核心工程差异:PPO 需要大量并行环境来弥补数据浪费(4096+ envs 是 locomotion 标准),SAC 需要大容量 replay buffer 来存储历史数据(通常 100 万容量)。在 GPU 并行仿真场景下,PPO 的"高速产线 + JIT 生产"模式通常比 SAC 的"仓储 + 按需取用"模式更高效。
量化对比:假设 GPU 每秒产出 200 万 transitions。PPO 每次更新消耗 ~100k transitions(耗时 ~0.1s 含 collection + learning),每秒可做 ~10 次更新。SAC 每次更新从 buffer 采样 256 transitions(更新耗时 ~0.01s),每秒可做 ~100 次更新——但每次更新只用极少数据。在 locomotion 的大 state space 中,PPO 的"大 batch 低频更新"通常比 SAC 的"小 batch 高频更新"更稳定。
何时考虑 SAC ⭐⭐
- 有限并行:如果只能运行 <100 个并行环境(如高保真仿真或真机 fine-tuning),PPO 的 batch 太小,SAC 的数据复用更有价值
- Manipulation:桌面操作任务的 contact dynamics 通常需要更精细的探索,SAC 的 entropy regularization 天然鼓励多模态探索
- 连续学习:如果需要从旧策略继续学习(如 sim-to-real fine-tuning),SAC 的 replay buffer 可以保留旧经验
将 SAC 接入 mjlab 的工程考量 ⭐⭐
如果决定在 mjlab 环境中使用 SAC(通过 Stable Baselines3 或 RL Games),需要适配以下接口:
# SAC wrapper 适配要点(伪代码)
class SACCompatWrapper:
"""把 mjlab env 适配为 SAC 兼容的 Gym 接口。"""
def __init__(self, mjlab_env):
self.env = mjlab_env
# SAC 不区分 actor/critic obs
# 通常使用 actor obs(部署可得的信号)
self.observation_space = gym.spaces.Box(
low=-np.inf, high=np.inf,
shape=(actor_obs_dim,), dtype=np.float32
)
self.action_space = gym.spaces.Box(
low=-1.0, high=1.0,
shape=(action_dim,), dtype=np.float32
)
# SAC 需要 replay buffer
# 注意:replay buffer 消耗显存——仅 obs 数组就约 100 万 × 48D × 4B ≈ 192 MB;
# 完整 transition 还需存 next_obs、action、reward、done,整个 buffer 通常数百 MB 到数 GB
def step(self, action):
obs_dict, reward, terminated, truncated, extras = self.env.step(action)
# SAC 通常需要区分 terminal 和 non-terminal
# 与 PPO 不同:SAC 的 done 语义直接影响 Q-target
done = terminated # 只有 true termination 才是 done
# truncation 通过 info dict 传递
info = {"TimeLimit.truncated": truncated.cpu().numpy()}
return obs_dict["actor"].cpu().numpy(), reward.cpu().numpy(), \
done.cpu().numpy(), truncated.cpu().numpy(), info
def reset(self):
obs_dict = self.env.reset()
return obs_dict["actor"].cpu().numpy(), {}
关键差异:SAC 的 done 语义与 PPO 不同——SAC 通常只在 true termination 时设 done=True(因为 Q-learning 的 target 需要精确的 terminal/non-terminal 区分),truncation 通过 info dict 传递。而 PPO/RSL-RL 合并 terminated 和 truncated 为 dones,用 extras["time_outs"] 区分。这个语义差异是切换算法时最容易出错的地方。
其他算法简述 ⭐⭐
PPO-Lagrangian / 约束 RL。当任务有硬安全约束(如"关节力矩不超过 X Nm"、"底盘倾斜不超过 Y 度")时,标准 PPO 只能通过 penalty reward 间接处理。PPO-Lagrangian 引入 Lagrange 乘子来直接优化约束——乘子自动调节 penalty weight,不需要手动调。Isaac Lab 2.3 的 DexSuite 中已有实现。
Diffusion Policy。在 manipulation 任务中,动作分布可能是多模态的(如"从左边抓"和"从右边抓"都是有效策略)。Gaussian policy(PPO/SAC)只能表示单模态分布,Diffusion Policy 通过去噪过程生成动作,天然支持多模态。但推理时需要多步去噪——延迟是部署瓶颈。对 locomotion(需要 50 Hz 实时控制)来说,Diffusion Policy 的推理延迟通常不可接受。
算法选型决策树 ⭐⭐
你的任务是什么?
├── Locomotion(四足/人形行走)
│ ├── 可以用 4096+ 并行环境? → PPO(默认且最优选择)
│ └── 真机 fine-tuning? → 先 PPO sim 训练,再考虑 SAC/CMA-ES fine-tune
├── Manipulation(抓取/放置)
│ ├── 动作空间是单模态? → SAC(exploration + replay buffer)
│ ├── 动作空间是多模态? → Diffusion Policy(如果延迟可接受)
│ └── 有硬安全约束? → PPO-Lagrangian / SafePO
├── 高速运动(翻滚/跳跃/运动技巧)
│ └── PPO + AMP(Adversarial Motion Priors)
└── 不确定
└── 先用 PPO(最快验证)→ 如果 PPO 不够再切换
本质洞察:算法选择不是"哪个算法更好"的问题——而是"哪个算法的假设最匹配你的任务特征"。PPO 假设可以大量并行采样,SAC 假设可以高效复用历史数据,Diffusion Policy 假设动作分布是多模态的。选错算法不是"不能用"——而是"需要更多调参工作来弥补假设不匹配"。
跨算法公平对比方法 ⭐⭐
如果你需要在论文或报告中对比多个算法,以下是确保公平性的工程步骤:
# 公平对比框架
class FairComparison:
"""管理多算法公平对比实验。"""
def __init__(self, task_name, seeds=(42, 123, 456)):
self.task = task_name
self.seeds = seeds
self.budget = None # 统一的计算预算
def set_compute_budget(self, total_transitions):
"""用总 transition 数作为统一计算预算。"""
self.budget = total_transitions
# PPO: total_transitions / (num_envs * steps_per_env) = iterations
# SAC: total_transitions / env_interactions_per_update = updates
def ppo_config(self):
"""PPO 配置(使用推荐默认值)。"""
iters = self.budget // (4096 * 24)
return {"algorithm": "PPO", "num_envs": 4096,
"num_steps_per_env": 24, "max_iterations": iters}
def sac_config(self):
"""SAC 配置(需要等量调优工作)。"""
# SAC 每次 update 消耗 1 个 env step + 256 个 replay sample
updates = self.budget // 256
return {"algorithm": "SAC", "num_envs": 256,
"replay_buffer_size": 1_000_000, "total_updates": updates}
def run_all(self):
"""运行所有配置 × 所有 seed。"""
for seed in self.seeds:
for config in [self.ppo_config(), self.sac_config()]:
print(f"Running {config['algorithm']} seed={seed}...")
# run_experiment(self.task, config, seed)
# 使用
comparison = FairComparison("Mjlab-Velocity-Flat-Unitree-Go1")
comparison.set_compute_budget(total_transitions=50_000_000)
comparison.run_all()
⚠️ 常见陷阱
💡 概念误区:认为"sample efficiency 高 = 训练更快"。 sample efficiency 衡量的是"用了多少样本",wall-clock time 衡量的是"花了多少时间"。在 GPU 并行场景下,PPO 的样本效率低但每秒产出样本极多,总训练时间可能比 SAC 更短。
🧠 思维陷阱:认为"PPO 能用就永远不需要换算法"。 PPO 在无约束、连续控制、大规模并行场景下表现优秀。但在硬安全约束、离散-连续混合动作、有限数据(如真机 fine-tuning)或多模态动作分布场景中,其他算法可能有本质优势。
练习
- [计算题] GPU 每秒产出 200 万 transitions。PPO 用 98304 transitions/update(耗时 0.1s)。SAC 每次更新用 256 transitions(耗时 0.01s,但 buffer 更新耗时 0.05s)。一小时内两者分别做了多少次更新?
- [设计题] 如果要把 SAC 接入 mjlab velocity task,wrapper 需要做哪些修改?(提示:考虑 replay buffer 接口、dones 语义、observation routing。)
- [分析题] PPO-Lagrangian 和 penalty-based reward(直接在 reward 中加 penalty term)有什么区别?在什么情况下 Lagrangian 方法更好?(提示:考虑 penalty weight 的手动调优 vs 自动调优。)
7.10 ONNX 导出与部署接口 ⭐⭐⭐
这一节解决什么问题:ONNX 导出包含什么?部署端需要额外做什么?
导出流程 ⭐⭐
# ONNX 导出(两个框架共用 RSL-RL 的导出逻辑)
from rsl_rl.utils import export_policy_as_onnx
# 在训练结束后或 play 时调用
export_policy_as_onnx(
actor_model, # actor 网络(必须是 eval mode)
path="policy.onnx",
obs_dim=48, # actor observation 维度
filename="policy",
)
完整的导出和验证脚本:
# export_and_verify.py
import torch
import onnxruntime as ort
import numpy as np
def export_and_verify(runner, obs_dim, output_dir):
"""导出 ONNX 并验证一致性。"""
import os
os.makedirs(output_dir, exist_ok=True)
# 1. 获取 actor 模型并设为 eval mode
actor = runner.alg.actor
actor.eval()
# 2. 导出 ONNX
onnx_path = os.path.join(output_dir, "policy.onnx")
dummy_input = torch.randn(1, obs_dim, device=next(actor.parameters()).device)
torch.onnx.export(
actor,
dummy_input,
onnx_path,
opset_version=11,
input_names=["obs"],
output_names=["actions"],
dynamic_axes={"obs": {0: "batch"}, "actions": {0: "batch"}},
)
print(f"✅ ONNX exported to {onnx_path}")
# 3. 验证一致性
session = ort.InferenceSession(onnx_path)
input_name = session.get_inputs()[0].name
max_diff = 0.0
for _ in range(20):
test_obs = torch.randn(1, obs_dim, device=next(actor.parameters()).device)
with torch.no_grad():
pt_out = actor(test_obs).cpu().numpy()
onnx_out = session.run(None, {input_name: test_obs.cpu().numpy()})[0]
diff = np.abs(pt_out - onnx_out).max()
max_diff = max(max_diff, diff)
status = "✅ PASS" if max_diff < 1e-5 else "🔴 FAIL"
print(f"ONNX verification: {status} (max diff: {max_diff:.2e})")
# 4. 打印模型信息
import onnx
model = onnx.load(onnx_path)
print(f"\nModel info:")
print(f" Inputs: {[inp.name for inp in model.graph.input]}")
print(f" Outputs: {[out.name for out in model.graph.output]}")
print(f" Nodes: {len(model.graph.node)}")
# 5. 检查是否包含 normalizer
has_normalizer = any("Sub" in n.op_type or "norm" in n.name.lower()
for n in model.graph.node)
if has_normalizer:
print(" ⚠️ Contains normalization layers — 部署端不要再归一化!")
return max_diff < 1e-5
# 使用:export_and_verify(runner, obs_dim=48, output_dir="./onnx_export/")
ONNX 的边界 ⭐⭐⭐
| 组件 | 在 ONNX 内部 | 在 ONNX 外部 |
|---|---|---|
| Actor MLP 权重 | ✓ | |
| Obs normalizer(如打开) | ✓ | |
| Action scale / offset | ✓(部署端复现) | |
| Action clip | ✓(部署端复现) | |
| Observation 拼接顺序 | ✓(部署端按顺序拼) | |
| Observation noise | N/A(部署不加噪声) | |
| Critic 网络 | N/A(部署不需要) | |
| RNN hidden state | ✓(如使用 RNN) | ✓(部署端维护) |
部署端必须复现的四件事:
1. 按正确顺序拼接 observation(参考 Ch05 部署边界文档)
→ 顺序错误不会报错,但行为完全错误
2. 把 ONNX 输出的 raw action 乘以 action scale + action offset
→ raw_action * scale + default_joint_pos
3. 对 processed action 做 clip(关节限位)
→ np.clip(processed, joint_lower, joint_upper)
4. 按正确的控制频率调用(policy dt = physics_dt × decimation)
→ 如果训练时 50 Hz,部署也必须 50 Hz
RNN ONNX 导出的特殊处理 ⭐⭐
如果 actor 使用 LSTM/GRU,ONNX 需要额外的 hidden state 输入和输出:
# RNN ONNX 导出(伪代码)
def export_rnn_onnx(actor, obs_dim, hidden_size, num_layers):
"""导出 RNN actor 的 ONNX。"""
# RNN 需要 3 个输入:obs, h0, c0
dummy_obs = torch.randn(1, obs_dim)
dummy_h = torch.zeros(num_layers, 1, hidden_size)
dummy_c = torch.zeros(num_layers, 1, hidden_size)
torch.onnx.export(
actor,
(dummy_obs, (dummy_h, dummy_c)),
"policy_rnn.onnx",
input_names=["obs", "hidden_state", "cell_state"],
output_names=["actions", "hidden_state_out", "cell_state_out"],
)
部署端的 RNN 推理循环:
# RNN 部署端推理
class RNNDeploymentController:
def __init__(self, onnx_path, hidden_size, num_layers):
self.session = ort.InferenceSession(onnx_path)
# 初始化 hidden state(在 episode 开始时 reset)
self.h = np.zeros((num_layers, 1, hidden_size), dtype=np.float32)
self.c = np.zeros((num_layers, 1, hidden_size), dtype=np.float32)
def step(self, obs):
"""一步推理,维护 hidden state。"""
outputs = self.session.run(None, {
"obs": obs,
"hidden_state": self.h,
"cell_state": self.c,
})
actions = outputs[0]
self.h = outputs[1] # 更新 hidden state
self.c = outputs[2] # 更新 cell state
return actions
def reset_hidden(self):
"""episode 开始时 reset hidden state。"""
self.h[:] = 0
self.c[:] = 0
ONNX 推理性能分析 ⭐⭐
部署端的推理延迟直接影响控制频率。以下是典型的推理延迟:
| 模型 | obs_dim | hidden_dims | 硬件 | 推理延迟 |
|---|---|---|---|---|
| MLP [512,256,128] | 48 | — | Jetson Orin NX | ~0.2 ms |
| MLP [512,256,128] | 48 | — | RTX 4090 | ~0.05 ms |
| LSTM + MLP | 48 + hidden | 256 | Jetson Orin NX | ~0.5 ms |
| CNN + MLP | 48 + 64×64 | 256 | Jetson Orin NX | ~2.0 ms |
50 Hz 控制频率要求每步推理 < 20 ms——MLP 和 LSTM 都远在安全范围内。CNN 可能在低端硬件上是瓶颈——需要优化(如 TensorRT 量化)。
# 推理延迟测试
import time
def benchmark_onnx_inference(onnx_path, obs_dim, num_runs=1000):
"""测试 ONNX 推理延迟。"""
session = ort.InferenceSession(onnx_path)
input_name = session.get_inputs()[0].name
dummy_obs = np.random.randn(1, obs_dim).astype(np.float32)
# Warmup
for _ in range(100):
session.run(None, {input_name: dummy_obs})
# Benchmark
times = []
for _ in range(num_runs):
start = time.perf_counter()
session.run(None, {input_name: dummy_obs})
times.append(time.perf_counter() - start)
avg_ms = np.mean(times) * 1000
p95_ms = np.percentile(times, 95) * 1000
p99_ms = np.percentile(times, 99) * 1000
print(f"ONNX Inference Benchmark ({num_runs} runs):")
print(f" Average: {avg_ms:.3f} ms")
print(f" P95: {p95_ms:.3f} ms")
print(f" P99: {p99_ms:.3f} ms")
if p99_ms > 20:
print(f" ⚠️ P99 > 20ms — may not meet 50 Hz control rate!")
else:
print(f" ✅ Well within 50 Hz budget (20 ms)")
⚠️ 常见陷阱
⚠️ 编程陷阱:ONNX 导出时网络处于 training mode。 model.train() 和 model.eval() 对 BatchNorm/Dropout 有影响。导出前务必调用 model.eval()。RSL-RL 的导出函数已处理此问题,但自定义导出需要注意。
练习
- [实验题] 在 mjlab 中训练 Go1 velocity flat 到 300 iteration,导出 ONNX,运行一致性验证。记录 max diff。
- [设计题] 如果 actor 使用了 RNN(LSTM),ONNX 的输入和输出分别应该包含什么?部署端需要如何处理 hidden state?
7.11 unitree_rl_lab vs unitree_rl_mjlab 精读 ⭐⭐
这一节解决什么问题:同一机器人在两个框架中的超参数对齐和配置差异。
概览 ⭐⭐
| 维度 | unitree_rl_lab | unitree_rl_mjlab |
|---|---|---|
| 框架 | Isaac Lab | mjlab |
| 物理后端 | PhysX (GPU) | MuJoCo Warp (GPU) |
| 机器人 | Go1, Go2, H1, G1 | Go1, Go2, G1 |
| RL 后端 | RSL-RL | RSL-RL |
| 部署支持 | ONNX + Unitree SDK2 | ONNX + Unitree SDK2 |
| 部署仓库 | unitree_rl_lab 含 C++ 推理代码 |
unitree_rl_mjlab 含等价代码 |
两个仓库的部署管线几乎完全相同——ONNX 推理 + Unitree SDK2 + FSM(有限状态机)。差异主要在训练阶段的 env 配置和 reward 调优。
超参数对齐分析 ⭐⭐
对于 Go1 velocity task,两个仓库的 PPO 超参数应该非常接近(因为都基于 legged_gym 的经典值)。差异可能出现在:
-
reward weights:不同物理引擎(PhysX vs MuJoCo)的接触动力学不同,最优 reward 权重也不同。特别是 foot_slip 和 contact 相关 terms 可能需要不同的 weight。
-
action scale:不同引擎的 PD 控制器实现可能有微小差异,导致同样的 action scale 在两个引擎中产生不同幅度的关节运动。
-
terrain 配置:Isaac Lab 使用 heightfield-based terrain,mjlab 使用 MuJoCo 的 heightfield asset——两者的离散化精度不同。
跨框架超参数迁移方法:
# 跨框架超参数对比脚本
def compare_configs(mjlab_cfg_path, isaaclab_cfg_path):
"""对比两个框架的训练配置。"""
import yaml
with open(mjlab_cfg_path) as f:
mj_cfg = yaml.safe_load(f)
with open(isaaclab_cfg_path) as f:
il_cfg = yaml.safe_load(f)
# PPO 超参数对比
ppo_keys = ['clip_param', 'learning_rate', 'gamma', 'lam',
'entropy_coef', 'num_learning_epochs', 'num_mini_batches',
'desired_kl', 'max_grad_norm']
print("PPO Hyperparameter Comparison:")
print("=" * 50)
for key in ppo_keys:
mj_val = mj_cfg.get('algorithm', {}).get(key, 'N/A')
il_val = il_cfg.get('algorithm', {}).get(key, 'N/A')
match = "✅" if mj_val == il_val else "⚠️"
print(f" {match} {key:>25s}: mjlab={mj_val}, IL={il_val}")
# Reward weight 对比
print("\nReward Weight Comparison:")
mj_rewards = mj_cfg.get('rewards', {})
il_rewards = il_cfg.get('rewards', {})
all_keys = set(list(mj_rewards.keys()) + list(il_rewards.keys()))
for key in sorted(all_keys):
mj_w = mj_rewards.get(key, {}).get('weight', 'N/A')
il_w = il_rewards.get(key, {}).get('weight', 'N/A')
print(f" {key:>30s}: mjlab={mj_w}, IL={il_w}")
部署时的 joint ordering 陷阱 ⭐⭐⭐
unitree_rl_lab 的 issues 中有多个关于 joint ordering mismatch 的 bug 报告。MJCF 和 USD 中关节的排列顺序可能不同——训练时 action[0] 对应 FL_hip,部署时 action[0] 如果对应 FR_hip,所有关节目标就错位了。
# 打印和验证 joint ordering
def verify_joint_ordering(env, expected_order=None):
"""打印 action manager 的 joint 顺序并验证。"""
am = env.action_manager
print("Action Manager Joint Ordering:")
print("=" * 50)
for term_name, term in am.active_terms.items():
print(f"\n Term: {term_name}")
print(f" Type: {type(term).__name__}")
print(f" Action dim: {term.action_dim}")
# 获取 joint names(具体属性名取决于框架版本)
if hasattr(term, '_joint_names'):
names = term._joint_names
for i, name in enumerate(names):
print(f" action[{i}] → {name}")
elif hasattr(term, 'cfg') and hasattr(term.cfg, 'joint_names'):
print(f" Joint pattern: {term.cfg.joint_names}")
# 与期望顺序对比
if expected_order is not None:
print(f"\nExpected order: {expected_order}")
# ... 对比逻辑
# Go1 的标准 joint 顺序(12 joints)
GO1_JOINT_ORDER = [
"FL_hip_joint", "FL_thigh_joint", "FL_calf_joint", # 左前
"FR_hip_joint", "FR_thigh_joint", "FR_calf_joint", # 右前
"RL_hip_joint", "RL_thigh_joint", "RL_calf_joint", # 左后
"RR_hip_joint", "RR_thigh_joint", "RR_calf_joint", # 右后
]
部署端的完整推理循环 ⭐⭐⭐
以下是部署端(真机上)的推理循环伪代码,展示 ONNX 推理如何与 Unitree SDK2 交互:
# 部署端推理循环(Python 伪代码,实际通常用 C++)
import onnxruntime as ort
import numpy as np
class DeploymentController:
"""ONNX-based deployment controller for Unitree Go1."""
def __init__(self, onnx_path, action_scale, default_joint_pos):
# 1. 加载 ONNX 模型
self.session = ort.InferenceSession(onnx_path)
self.input_name = self.session.get_inputs()[0].name
# 2. 部署端需要知道的参数(不在 ONNX 内!)
self.action_scale = np.array(action_scale) # [12]
self.default_joint_pos = np.array(default_joint_pos) # [12]
self.joint_limits_lower = np.array([...]) # [12]
self.joint_limits_upper = np.array([...]) # [12]
# 3. 上一步的 action(用于 obs 中的 last_action)
self.last_action = np.zeros(12)
# 4. 控制频率参数
self.control_dt = 0.02 # 50 Hz
def get_observation(self, robot_state):
"""从机器人传感器构建 observation 向量。"""
# 严格按 Ch05 部署边界文档的顺序拼接!
obs = np.concatenate([
robot_state.imu_angular_velocity, # base_ang_vel [3]
robot_state.projected_gravity, # projected_gravity [3]
robot_state.joint_positions - self.default_joint_pos, # joint_pos_rel [12]
robot_state.joint_velocities, # joint_vel [12]
self.last_action, # last_action [12]
self.current_command, # command [3]
# 注意:不包含 base_lin_vel(部署不可得,见 Ch05)
])
return obs.reshape(1, -1).astype(np.float32)
def step(self, robot_state):
"""一步推理:obs → ONNX → raw action → processed action → 发送到电机。"""
# 1. 构建 observation
obs = self.get_observation(robot_state)
# 2. ONNX 推理(得到 raw action)
raw_action = self.session.run(None, {self.input_name: obs})[0][0]
# 3. 后处理(ONNX 外部!)
processed_action = raw_action * self.action_scale + self.default_joint_pos
# 4. Clip 到关节限位
processed_action = np.clip(
processed_action,
self.joint_limits_lower,
self.joint_limits_upper
)
# 5. 发送到电机
# send_joint_positions(processed_action)
# 6. 更新 last_action
self.last_action = raw_action
return processed_action
这段代码展示了部署端需要复现的全部逻辑——任何一步遗漏或错误都会导致策略在真机上表现完全不同于仿真。这就是为什么 Ch05 中强调部署边界文档的重要性。
CycloneDDS 版本兼容性问题 ⭐⭐
unitree_rl_lab 和 unitree_rl_mjlab 的 issues 中报告了 CycloneDDS 版本不兼容导致的通信失败。Unitree SDK2 依赖特定版本的 CycloneDDS(通常 0.10.x),如果系统中安装了更新版本(如 0.11.x),SDK 可能无法正确发现机器人。
# 检查 CycloneDDS 版本
pip show cyclonedds
# 如果版本不兼容,降级到已知工作版本
pip install cyclonedds==0.10.5
部署前完整验证脚本 ⭐⭐⭐
以下脚本在部署到真机之前执行全部验证——涵盖 ONNX 一致性、joint ordering、action scale、控制频率等关键接口:
# pre_deploy_verify.py — 部署前一键验证
import sys
import numpy as np
def run_all_checks(onnx_path, env, runner):
"""部署前的完整验证流程。"""
checks_passed = 0
checks_total = 0
# Check 1: ONNX 存在性
checks_total += 1
import os
if os.path.exists(onnx_path):
print(f"✅ [1/7] ONNX file exists: {onnx_path}")
checks_passed += 1
else:
print(f"🔴 [1/7] ONNX file NOT found: {onnx_path}")
# Check 2: ONNX 一致性
checks_total += 1
import onnxruntime as ort
import torch
session = ort.InferenceSession(onnx_path)
actor = runner.alg.actor
actor.eval()
obs_dim = session.get_inputs()[0].shape[1]
test_obs = torch.randn(1, obs_dim)
with torch.no_grad():
pt_out = actor(test_obs).cpu().numpy()
onnx_out = session.run(None, {session.get_inputs()[0].name: test_obs.numpy()})[0]
diff = np.abs(pt_out - onnx_out).max()
if diff < 1e-5:
print(f"✅ [2/7] ONNX consistency: max_diff={diff:.2e}")
checks_passed += 1
else:
print(f"🔴 [2/7] ONNX inconsistency: max_diff={diff:.2e}")
# Check 3: Actor obs dim 正确
checks_total += 1
env_obs = env.reset()
actor_dim = env_obs.get("actor", env_obs.get("policy")).shape[1]
if actor_dim == obs_dim:
print(f"✅ [3/7] Actor obs dim matches ONNX input: {obs_dim}")
checks_passed += 1
else:
print(f"🔴 [3/7] Dim mismatch: env={actor_dim}, ONNX={obs_dim}")
# Check 4-7: joint ordering, action scale, etc.
print(f"📝 [4/7] Joint ordering — verify manually with print_joint_order()")
print(f"📝 [5/7] Action scale/offset — verify from deployment config")
print(f"📝 [6/7] Control frequency — confirm policy_dt matches real-time loop")
print(f"📝 [7/7] Play video — visual sanity check")
print(f"\n{'='*40}")
print(f"Automated checks: {checks_passed}/{checks_total} passed")
print(f"Manual checks: 4 remaining — complete before deploying!")
return checks_passed == checks_total
# 使用:run_all_checks("policy.onnx", env, runner)
⚠️ 常见陷阱
⚠️ 编程陷阱:从 Isaac Lab 训练的 ONNX 直接在 mjlab 部署(或反过来),忘了检查 joint ordering。 两个框架的 URDF/MJCF 解析可能产生不同的 joint 排序。必须验证 action dim → joint name 的映射完全一致。
7.12 源码阅读路线 ⭐⭐
路线 A:mjlab 训练主链
scripts/train.py→ CLI 入口、TaskConfig 加载src/mjlab/rl/runners.py→MjlabOnPolicyRunner的三个扩展src/mjlab/rl/vecenv_wrapper.py→ wrapper 的四个核心职责src/mjlab/rl/config.py→RslRlOnPolicyRunnerCfg、RslRlPpoAlgorithmCfg、RslRlMLPModelCfg- RSL-RL:
rsl_rl/runners/on_policy_runner.py→learn()主循环
路线 A 的核心阅读目标:理解从 CLI 启动到 PPO update 的完整调用链。每一步的输入和输出是什么?数据在哪里转换格式?
# 验证训练数据流的诊断脚本
def trace_training_dataflow(env, wrapper, runner, num_steps=3):
"""逐步追踪训练数据在各层之间的变换。"""
print("=== Data Flow Trace ===")
# Step 1: env.reset() → obs_dict
obs_dict = env.reset()
print(f"\n1. env.reset() output:")
for key, val in obs_dict.items():
print(f" obs_dict['{key}']: shape={val.shape}, "
f"dtype={val.dtype}, device={val.device}")
# Step 2: wrapper 观察 obs_dict 转换
print(f"\n2. Wrapper obs_groups mapping:")
for runner_key, env_groups in runner.cfg.obs_groups.items():
dims = [obs_dict[g].shape[1] for g in env_groups]
print(f" runner['{runner_key}'] ← env{list(env_groups)} → dim={sum(dims)}")
# Step 3: actor 前向推理
import torch
actor_obs = obs_dict.get("actor", obs_dict.get("policy"))
with torch.inference_mode():
actions = runner.alg.actor(actor_obs)
print(f"\n3. Actor inference:")
print(f" input: shape={actor_obs.shape}")
print(f" output: shape={actions.shape}, "
f"range=[{actions.min():.3f}, {actions.max():.3f}]")
# Step 4: wrapper.step() → 各输出
obs_dict, rewards, dones, extras = wrapper.step(actions)
print(f"\n4. wrapper.step() output:")
print(f" rewards: shape={rewards.shape}, mean={rewards.mean():.4f}")
print(f" dones: shape={dones.shape}, sum={dones.sum().item():.0f}")
if "time_outs" in extras:
print(f" time_outs: sum={extras['time_outs'].sum().item():.0f}")
else:
print(f" time_outs: NOT present (finite horizon?)")
路线 B:Isaac Lab 对等路径
scripts/reinforcement_learning/rsl_rl/train.py→ Isaac Lab 的训练入口source/isaaclab_rl/rsl_rl/→ wrapper 和 runner- 对比 Isaac Lab 和 mjlab 的 wrapper 差异(重点:obs_groups 命名
policyvsactor)
Isaac Lab 的 manager 调用顺序与 mjlab 完全镜像——这是设计使然。两个框架共享 manager-based 架构的语义契约,差异仅在 config 模式和 tensor 访问路径。
路线 C:RSL-RL 内部
rsl_rl/algorithms/ppo.py→ clipped objective、GAE、adaptive KLrsl_rl/modules/actor_critic.py→ MLP model、Gaussian distributionrsl_rl/storage/rollout_storage.py→ transition 存储和 minibatch 切分
RSL-RL 的 PPO 实现整合了 Huang et al. 2022 的"37 个实现细节"中的多项优化,包括:advantage normalization(每个 minibatch 内标准化 advantage)、value function clipping(限制 value 预测的变化幅度)、以及 proper timeout handling(不对 truncated episode bootstrap 清零)。
PPO update 的核心代码路径(简化,展示关键步骤):
# rsl_rl/algorithms/ppo.py 核心更新逻辑(简化)
class PPO:
def update(self, batch):
# 1. Actor 前向:获取新策略下的 log_prob 和 entropy
new_log_probs = self.actor.evaluate(batch["obs"], batch["actions"])
entropy = self.actor.get_entropy()
# 2. 计算 policy ratio
ratio = torch.exp(new_log_probs - batch["old_log_probs"])
# 3. Advantage normalization(实现细节 #7 from Huang 2022)
advantages = batch["advantages"]
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# 4. Clipped surrogate loss
surr1 = ratio * advantages
surr2 = torch.clamp(ratio, 1.0 - self.clip_param,
1.0 + self.clip_param) * advantages
policy_loss = -torch.min(surr1, surr2).mean()
# 5. Value loss(可选 clipped)
new_values = self.critic(batch["critic_obs"])
if self.use_clipped_value_loss:
value_clipped = batch["values"] + torch.clamp(
new_values - batch["values"],
-self.clip_param, self.clip_param
)
value_loss = torch.max(
(new_values - batch["returns"]) ** 2,
(value_clipped - batch["returns"]) ** 2,
).mean()
else:
value_loss = ((new_values - batch["returns"]) ** 2).mean()
# 6. 总 loss = policy + value + entropy
loss = (policy_loss
+ self.value_loss_coef * value_loss
- self.entropy_coef * entropy.mean())
# 7. 反向传播 + 梯度裁剪
self.optimizer.zero_grad()
loss.backward()
torch.nn.utils.clip_grad_norm_(
self.parameters(), self.max_grad_norm
)
self.optimizer.step()
# 8. 计算 KL 用于 adaptive LR
with torch.no_grad():
kl = (batch["old_log_probs"] - new_log_probs).mean()
return {
"surrogate_loss": policy_loss.item(),
"value_loss": value_loss.item(),
"entropy": entropy.mean().item(),
"kl": kl.item(),
}
路线 D:unitree_rl_lab 部署链
unitree_rl_lab/deploy/→ C++ ONNX 推理代码unitree_rl_lab/deploy/fsm.py→ 有限状态机设计- 对比
unitree_rl_mjlab的等价实现
阅读原则:先 runner config → 再 wrapper → 再 RSL-RL 算法内部。这个顺序与 Ch05/Ch06 一致——从高层接口到低层实现。
7.13 实验管理最佳实践 ⭐⭐
这一节解决什么问题:如何系统地管理大量训练实验?seed 怎么管理?如何确保结果可复现?
Seed 管理 ⭐⭐
RL 训练对随机 seed 非常敏感——同一配置不同 seed 可能产生显著不同的结果。正确的实验方法是多 seed 取平均。
# 多 seed 训练脚本
for SEED in 42 123 456 789 1024; do
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 \
--agent.max-iterations 300 \
--agent.seed $SEED \
--agent.run-name "flat_seed${SEED}" \
--agent.logger tensorboard
done
seed 控制的范围:RSL-RL 的 seed 控制以下随机源:(1) PyTorch 网络初始化;(2) minibatch 随机打乱;(3) action noise 采样。但它不控制环境内部的随机化(如 terrain 生成、domain randomization、command 采样)——这些由 env 的 seed 控制。确保两者都被固定。
实验日志模板 ⭐⭐
# Experiment Log Template
## Metadata
- Task: ________________
- Framework: mjlab / Isaac Lab
- GPU: ________________
- Date: ________________
- Commit hash: ________________
## Configuration
- num_envs: ____
- max_iterations: ____
- seed: ____
- Key overrides: ________________
## Results (at iteration ____)
| Metric | Value |
|--------|-------|
| tracking_reward | ____ |
| total_return | ____ |
| mean_kl | ____ |
| mean_entropy | ____ |
| fell_over_pct | ____ |
| wall_clock_time | ____ min |
| steps_per_second | ____ |
## Observations
- 行为描述: ________________
- 异常信号: ________________
- 与 baseline 对比: ________________
## Decision
- [ ] 作为新 baseline
- [ ] 需要进一步调参
- [ ] 废弃(原因: ________________)
实验对比方法论 ⭐⭐
公平对比的三个原则:
原则一:控制计算预算。 比较 PPO 和 SAC 时,不要用"同样的 iteration 数"——因为每次 iteration 的计算量可能不同。用"同样的 wall-clock time"或"同样的 env transitions 数"。
原则二:多 seed 统计。 单个 seed 的对比没有统计意义。至少 3 个 seed(推荐 5 个),报告 mean ± std。如果 std 大于 mean 差异的一半,结论可能不可靠。
原则三:超参数调优程度可比。 如果 PPO 用了精心调优的参数但 SAC 用了默认参数,对比不公平。两者都应该经过等量的调优工作(如同样的 sweep 轮数)。
这和医学临床试验的"双盲对照"设计有类比——不控制混淆因子(seed、计算量、调优程度)的对比实验,结论不可靠。
训练结果的自动化分析脚本 ⭐⭐
# analyze_experiment.py
import os
import json
import numpy as np
def analyze_training_runs(log_dir, metric="Episode_Reward/track_linear_velocity"):
"""分析多个 training run 的结果。"""
results = {}
for run_name in os.listdir(log_dir):
run_path = os.path.join(log_dir, run_name)
if not os.path.isdir(run_path):
continue
# 读取 params
params = {} # 默认值,避免文件缺失时 UnboundLocalError
params_path = os.path.join(run_path, "params", "agent.yaml")
if os.path.exists(params_path):
with open(params_path) as f:
import yaml
params = yaml.safe_load(f)
# 读取最终 metric(从 TensorBoard 或 summary)
# ... 实际实现需要 TensorBoard event reader
results[run_name] = {
"params": params,
# "final_metric": ...,
}
print(f"Analyzed {len(results)} runs in {log_dir}")
return results
def compare_runs(results, group_key="seed"):
"""按 group_key 分组对比结果。"""
groups = {}
for name, data in results.items():
key = data["params"].get(group_key, "default")
groups.setdefault(key, []).append(data)
for group, runs in groups.items():
metrics = [r.get("final_metric", 0) for r in runs]
if metrics:
print(f" {group}: mean={np.mean(metrics):.3f} "
f"± {np.std(metrics):.3f} (n={len(metrics)})")
本章小结
| 知识点 | 核心要点 | 难度 |
|---|---|---|
| On-policy 本质 | 数据用完即弃,靠大规模并行弥补 | ⭐⭐ |
| Wrapper 四职责 | obs 路由、action clip、done 合并、timeout bootstrap | ⭐⭐⭐ |
| Batch size 计算 | envs × steps_per_env / mini_batches | ⭐⭐ |
| Adaptive KL | 最重要的单个稳定器,自动调 LR | ⭐⭐⭐ |
| 超参数映射表 | 从日志症状反推参数 | ⭐⭐⭐ |
| 调参优先级 | 环境 → reward → PPO → 网络 | ⭐⭐ |
| MLP vs RNN vs CNN | 默认 MLP,RNN 需维护 hidden state | ⭐⭐ |
| ONNX 边界 | MLP 在内,scale/offset/clip 在外 | ⭐⭐⭐ |
| PPO vs SAC | PPO 适合大规模并行,SAC 适合有限并行 | ⭐⭐ |
| Joint ordering | 跨框架部署的头号陷阱 | ⭐⭐⭐ |
本章建立了一个关键认知:PPO 超参数几乎不是训练失败的根因——90% 的问题在 reward 设计或 obs/action 配置。PPO 的经典参数(clip=0.2, gamma=0.99, lam=0.95, lr=1e-3 adaptive)已经过数千个项目验证。如果你需要大幅修改它们,先回头检查 Ch05 和 Ch06。
本章覆盖了三个认知模式的转变:
从"调 PPO 参数修复训练"到"先查环境再查算法"。 调参优先级应该是:环境难度 → reward 设计 → PPO 参数 → 网络架构。这个顺序反映了根因出现的频率——90% 的训练失败根因在前两项。如果你发现自己在大幅修改 clip_param 或 entropy_coef,几乎可以确定真正的问题在 reward 或 obs 中。
从"单一曲线判断训练质量"到"多指标联合诊断"。 reward 曲线上升不代表行为正确(可能是 reward hacking),KL 为零不代表收敛(可能是策略未更新),entropy 下降不代表过拟合(可能是正常的探索→利用过渡)。正确的诊断需要同时看 reward + KL + entropy + value_loss + termination + play video。
从"训练完就部署"到"验证每个接口边界"。 训练成功只是起点——部署需要验证 ONNX 一致性、observation 拼接顺序、action scale/offset、joint ordering、控制频率。任何一个接口错误都会导致"仿真中表现完美但真机上完全失效"。这个教训在 unitree_rl_lab 的 issues 中反复出现。
累积项目:本章新增模块
本章为累积项目新增"训练管线与超参调优"模块。你现在应该能够:
- 在 mjlab 和 Isaac Lab 中完整执行 velocity task 训练(flat + rough)
- 从 TensorBoard 的 Loss/* 指标诊断训练健康状况
- 使用 adaptive KL schedule 自动稳定训练
- 导出 ONNX 并验证一致性
- 理解跨框架部署时 joint ordering 和 action scale 的陷阱
训练管线完整验证 checklist ⭐⭐
在开始 Ch08 之前,逐项验证:
[ ] 1. 列出所有可用任务(uv run list-envs)
[ ] 2. Zero agent 验证环境物理(uv run play ... --agent zero)
[ ] 3. Random agent 验证 reward 范围(uv run play ... --agent random)
[ ] 4. 小规模 smoke test(64 envs × 2 iter,无报错)
[ ] 5. 中等规模训练(1024 envs × 200 iter,tracking reward 上升)
[ ] 6. 完整训练(4096 envs × 300 iter flat / 1500 iter rough)
[ ] 7. TensorBoard 检查:
[ ] 7a. Loss/mean_reward 持续上升
[ ] 7b. Loss/mean_kl 在 desired_kl 附近波动
[ ] 7c. Loss/mean_entropy 缓慢下降(不是骤降)
[ ] 7d. Loss/learning_rate 在合理范围内变化
[ ] 7e. Episode_Termination/time_out 占比 > 50%
[ ] 8. Play 视频验证行为合理
[ ] 9. ONNX 导出 + 一致性验证(max diff < 1e-5)
[ ] 10. 从 checkpoint 恢复训练,确认:
[ ] 10a. iteration 计数正确
[ ] 10b. common_step_counter 正确(curriculum 不回退)
[ ] 10c. learning_rate 恢复到之前的值
完整验证命令序列:
# ============= mjlab 完整验证 =============
# Step 1
uv run list-envs | grep Velocity
# Step 2-3
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent zero --num-envs 4 --viewer viser
uv run play Mjlab-Velocity-Flat-Unitree-Go1 --agent random --num-envs 4 --viewer viser
# Step 4
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 64 --agent.max-iterations 2
# Step 5
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 1024 --agent.max-iterations 200 \
--agent.logger tensorboard --agent.seed 42 \
--agent.run-name ch07_smoke
# Step 6
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 --agent.max-iterations 300 \
--agent.logger tensorboard --agent.seed 42 \
--agent.run-name ch07_flat
# Step 7
tensorboard --logdir /tmp/mjlab/logs/
# Step 8
uv run play Mjlab-Velocity-Flat-Unitree-Go1 \
--agent.load-run ch07_flat --num-envs 4 --viewer viser
# Step 10: resume 验证
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 --agent.max-iterations 400 \
--agent.resume True --agent.load-run ch07_flat
# ============= Isaac Lab 等价验证 =============
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 64 --max_iterations 2
python scripts/reinforcement_learning/rsl_rl/train.py --task Isaac-Velocity-Flat-Anymal-C-v0 \
--num_envs 4096 --max_iterations 300 --seed 42
python scripts/reinforcement_learning/rsl_rl/play.py --task Isaac-Velocity-Flat-Anymal-C-v0
与前置章节的连接 ⭐
本章在 RL 工程核心三角中的位置:
Ch05: Obs/Action Ch06: Reward/Term/Curriculum
(状态空间 + 动作空间) (价值函数形状)
↘ ↙
Ch07: PPO 训练管线
(在价值函数形状上做优化)
↓
Ch08: Domain Randomization
(扩大训练分布覆盖)
Ch05 → Ch07 的耦合点:obs dim 决定网络输入维度、obs_groups routing 决定 actor/critic 信息分配、action scale/offset 在 ONNX 外部需要部署端复现。
Ch06 → Ch07 的耦合点:reward scale 影响 value function 的数值范围(进而影响 value loss 和 LR 的合理值)、termination 的 time_out 标记直接影响 GAE 的 bootstrap 逻辑、curriculum 的 stage 切换可能导致 KL spike。
Ch07 → Ch08 的预告:Domain Randomization 改变环境参数分布,会影响 reward 的期望值和方差(Ch06),进而影响 PPO 的 advantage 估计质量(本章)。DR 太强时 reward 方差暴增,可能导致 value loss 不收敛——这时需要增大 num_steps_per_env 或减小 learning_rate。
📋 PPO 训练配置审查 Checklist
使用方法:在完成新任务的 PPO 配置或修改现有配置后,逐项检查。
A. 数据流审查
- [ ] obs_groups 映射正确:actor → 部署可得 obs,critic → 含 privileged obs
- [ ] actor obs dim < critic obs dim(否则 privileged 信息可能缺失)
- [ ] wrapper 正确传递
extras["time_outs"] - [ ]
is_finite_horizon设置正确
B. 超参数审查
- [ ]
schedule = "adaptive"(除非有特殊理由用 fixed) - [ ]
desired_kl = 0.01(经典值,通常不需要改) - [ ]
clip_param = 0.2(同上) - [ ]
num_steps_per_env ≥ 16(太短 advantage 估计不准) - [ ]
num_learning_epochs ≤ 10(太多过拟合 on-policy data) - [ ]
entropy_coef在 [0.001, 0.05] 范围内
C. 网络审查
- [ ] actor 和 critic 使用 ELU 激活
- [ ] hidden_dims 为三层递减(如 [512, 256, 128])
- [ ]
init_std = 1.0(标准起点) - [ ] obs_normalization 设置合理(默认 False,特殊场景可打开 critic only)
D. 部署审查
- [ ] ONNX 导出后 max diff < 1e-5
- [ ] 部署端 observation 拼接顺序与训练一致
- [ ] 部署端 action scale/offset 与训练一致
- [ ] 部署端控制频率与训练时 policy dt 一致
- [ ] joint ordering 在训练框架和部署框架之间验证一致
下一章(Ch08)将在本章的训练管线基础上进入 Domain Randomization——如何通过随机化环境参数来提升策略的鲁棒性。DR 的随机化参数会影响 reward 的期望值和方差(Ch06),进而影响 PPO 的 advantage 估计质量(本章)。
延伸阅读
| 资料 | 难度 | 内容 |
|---|---|---|
| Schulman et al. 2017, "Proximal Policy Optimization Algorithms" | ⭐⭐ | PPO 原论文 |
| Huang et al. 2022, "The 37 Implementation Details of PPO" | ⭐⭐⭐ | 37 个实现细节 |
| RSL-RL 5.x (Schwarke et al., "RSL-RL: A Learning Library for Robotics Research", arXiv 2509.10771, 2025) | ⭐⭐ | RSL-RL 架构和 API |
| Haarnoja et al. 2018, "Soft Actor-Critic" | ⭐⭐⭐ | SAC 原论文 |
| Rudin et al. 2022, "Learning to Walk in Minutes" | ⭐⭐ | 大规模并行训练 |
| CleanRL (github.com/vwxyzjn/cleanrl) | ⭐⭐ | 各种 RL 算法单文件实现 |
| unitree_rl_lab (github.com/unitreerobotics/unitree_rl_lab) | ⭐⭐ | Go1 部署参考 |
🔧 故障排查手册
| 症状 | 可能原因 | 排查步骤 | 相关小节 |
|---|---|---|---|
| reward 不上升 | reward 设计或 obs 配置错误 | 1.跑 random agent 检查 reward 分项 2.检查 command 是否在 obs 中 | 7.8, Ch05, Ch06 |
| KL spike 后崩溃 | LR 过大或 curriculum 突变 | 1.检查 curriculum 切换时间点 2.降低初始 LR 3.延后 curriculum stage | 7.4, Ch06 |
| loss 变 NaN | obs/reward NaN 传播 | 1.在 wrapper.step() 后检测 NaN 2.用 mjlab 的 --enable-nan-guard | 7.8 |
| value loss 不降 | critic obs 缺少 privileged | 1.打印 actor/critic obs dim 2.检查 obs_groups 映射 | 7.2, Ch05 |
| entropy 过早塌缩 | entropy_coef 太小或 action_rate 太强 | 1.增大 entropy_coef 2.检查 action_rate weight | 7.3, Ch06 |
| entropy 不降 | entropy_coef 太大或 reward 信号弱 | 1.减小 entropy_coef 2.检查 reward gradient(Ch06 σ 设置) | 7.3, Ch06 |
| 恢复训练后 curriculum 回退 | checkpoint 不含 common_step_counter | 1.检查 runner 是否保存 extras 2.使用 MjlabOnPolicyRunner | 7.7 |
| ONNX 输出不一致 | model.eval() 未调用或 normalizer 遗漏 | 1.运行 onnx_verify.py 2.检查 normalizer 是否在图中 | 7.10 |
| 跨框架部署失败 | joint ordering mismatch | 1.打印两框架的 joint names 2.逐一对比顺序 | 7.11 |
| action 幅度过大导致 NaN | action scale 与 PD gain 耦合 | 1.用 random agent 检查 processed action 范围 2.调 action scale(Ch05) | Ch05 |
| 多 GPU 训练比单 GPU 更慢 | 通信开销或 batch size 过小 | 1.确认 num_envs per GPU ≥ 2048 2.检查 NCCL 配置 | 7.7 |
| checkpoint 加载后 key error | RSL-RL API 版本不匹配 | 1.检查 RSL-RL 版本 2.使用 MjlabOnPolicyRunner 自动迁移 | 7.2 |
| 训练速度(steps/s)突然下降 | GPU 显存溢出导致交换 | 1.减小 num_envs 2.检查 nvidia-smi 显存使用 | 7.7 |
附录:RSL-RL 5.x API 迁移指南
RSL-RL 从 3.x 到 5.x 经历了两次重大 API 变化。如果你阅读旧代码(如 legged_gym 或早期 Isaac Lab),需要理解以下迁移路径:
3.x → 4.x 关键变化
| 旧 API (3.x) | 新 API (4.x+) | 说明 |
|---|---|---|
policy 统一配置 |
分离 actor + critic |
允许不同网络架构 |
init_noise_std 参数 |
GaussianDistributionCfg(init_std=...) |
分布配置独立 |
actor.0.weight checkpoint key |
mlp.0.weight |
模型结构命名变化 |
4.x → 5.x 关键变化
| 旧 API (4.x) | 新 API (5.x) | 说明 |
|---|---|---|
RslRlPpoActorCriticCfg |
RslRlMLPModelCfg × 2 |
完全分离的模型配置 |
stochastic flag |
移除(通过 distribution_cfg 控制) | 简化 API |
noise_std_type, state_dependent_std |
移除 | 统一为 GaussianDistributionCfg |
| 单 GPU 训练 | 原生多 GPU 支持(torchrunx) | 架构改进 |
自动迁移脚本(mjlab 的 MjlabOnPolicyRunner 已内置):
# 旧 checkpoint 迁移逻辑(在 MjlabOnPolicyRunner 中)
def _migrate_checkpoint(self, state_dict):
"""自动迁移旧版 RSL-RL checkpoint key。"""
new_state_dict = {}
for key, value in state_dict.items():
# 4.x → 5.x: actor.N.weight → mlp.N.weight
new_key = key
if key.startswith("actor."):
new_key = key.replace("actor.", "mlp.", 1)
elif key.startswith("critic."):
new_key = key.replace("critic.", "mlp.", 1)
new_state_dict[new_key] = value
if new_key != key:
print(f" Migrated: {key} → {new_key}")
return new_state_dict
跨版本兼容性建议:
- 在教材代码中固定 RSL-RL 版本(如 rsl-rl-lib>=5.3.0,<6.0.0)
- 使用 mjlab 的 MjlabOnPolicyRunner(自动处理迁移)而非直接继承 RSL-RL 基类
- 如果必须使用旧版 RSL-RL,在 Isaac Lab 2.3 的 isaaclab_rl/rsl_rl/ 中有兼容层
附录:训练性能基准参考
以下基准帮助你判断自己的训练是否正常运行:
| 任务 | 框架 | GPU | num_envs | steps/s | 300 iter 耗时 |
|---|---|---|---|---|---|
| Go1 flat | mjlab | RTX 4090 | 4096 | ~90k | ~3 min |
| Go1 rough | mjlab | RTX 4090 | 4096 | ~80k | ~15 min (1500 iter) |
| ANYmal-C flat | Isaac Lab | RTX 4090 | 4096 | ~85k | ~3 min |
| Go1 flat | mjlab | RTX 3090 | 4096 | ~60k | ~5 min |
| Go1 flat | mjlab | RTX 4090 | 2048 | ~55k | ~5 min |
如果你的 steps/s 显著低于上表:
1. 检查 GPU 利用率(nvidia-smi)—— 如果 <50%,可能是 CPU 瓶颈
2. 检查 num_envs —— 太少(<1024)会导致 GPU 利用率低
3. 检查是否启用了不必要的渲染(--headless 或关闭 viewer)
4. 检查 Python profiler 是否有热点(如 CPU-side observation 计算)
# 测量训练性能
uv run train Mjlab-Velocity-Flat-Unitree-Go1 \
--env.scene.num-envs 4096 --agent.max-iterations 10 \
--agent.logger tensorboard
# 看 TensorBoard 中的 "Perf/total_fps" 或终端输出的 steps/s