写在前面:我缺的不是下一条公式,而是公式在代码里的位置
前面的笔记已经分别整理过 MDP、Observation、Action 和 GAE。但单独看这些概念时,我仍然经常有一种割裂感:
- 我知道 是折扣因子,却不知道它在哪一段循环里真正参与计算。
- 我见过 ,却不知道 rollout buffer 为什么必须先存 、
done和旧策略的log_prob。 - 我知道 PPO 有 clip,却容易把它与 action clip、torque clip 混在一起。
- 我看到
num_steps_per_env = 24、num_mini_batches = 4,却没有立刻想到它们怎样改变一轮更新的数据形状。
这篇不再按“概念百科”的方式写,而是沿着一条真实的数据流往下走:
legged_gym产生交互数据,rsl_rl保存这批数据、计算 GAE,再用 PPO 更新 actor 和 critic。公式不是悬在代码上方的装饰,而是每个张量为什么要存在的理由。
文中五个实验均在浏览器本地运行,不加载第三方绘图库。可以调参数、暂停动画;系统开启“减少动态效果”时会自动显示静态状态。
0. 先给出整篇的一句话结论
一次 PPO iteration 可以压缩成下面六步:
N 个并行机器人各走 T 步 |
一轮训练:环境步与优化步不是同一个循环
在旧版配套代码里,这条主循环就在 OnPolicyRunner.learn():先循环 num_steps_per_env 次调用 alg.act() 和 env.step(),再调用 compute_returns() 与 update()。所以读代码时,最好的入口不是 PPO loss,而是 runner。
1. 先固定版本:不要把两代 rsl_rl API 混在一起
legged_gym 是基于 Isaac Gym 的经典项目。它的官方 README 明确要求安装 rsl_rl v1.0.2。而今天的 rsl_rl 已经发展到 5.x,模型、storage 和 observation 接口都经过重构。
本文采用两条清楚分开的版本线:
| 用途 | 固定版本 | 为什么这样选 |
|---|---|---|
| 正文逐行对照 | legged_gym@8fa29ac + rsl_rl v1.0.2@2ad79cf |
这是 legged_gym 官方指定的配套 API,调用链能够直接闭合 |
| 现代实现对照 | rsl_rl v5.5.0@89869d8 |
2026-08-27 的官方最新稳定版,用于说明概念未变、代码结构怎样变化 |
这篇的目标不是证明某个版本更好,而是先用最容易闭环的经典代码理解 PPO;理解之后,再看 5.x 的抽象就不会只剩下类名。
2. 两个仓库各自负责什么
先把边界划清:
| 仓库 | 主要责任 | 数学上的位置 |
|---|---|---|
legged_gym |
并行环境、物理步进、observation、action 到 torque、reward、done/reset、随机化、课程学习 | 定义 、 如何作用于环境、、 与转移 |
rsl_rl |
actor/critic、动作分布、rollout storage、GAE、PPO loss、mini-batch、优化器、日志 | 学习 与 |
它们之间真正的接口很窄:
对于 个并行环境,典型张量形状是:
legged_gym 不负责计算 PPO ratio;rsl_rl 也不知道某个 reward 是速度跟踪还是抬脚奖励。两边只通过这些张量合作。
2.1 legged_gym 把 MDP 落到代码
在 legged_robot.py 中,可以按下面顺序找:
step(actions):接收 policy 动作,执行多个 physics substep。_compute_torques(actions):把动作解释为位置、速度或力矩控制目标。post_physics_step():刷新状态、检查终止、计算 reward 与下一帧 observation。compute_reward():把所有启用的 reward term 按 scale 求和。compute_observations():拼 actor 能看到的输入并按配置加入噪声。reset_idx():只重置已经终止的环境,并可更新 terrain/command curriculum。
所以一行 env.step(actions) 背后,实际包含控制、动力学、接触、奖励、终止和重置。
2.2 rsl_rl 把一次 iteration 拆成采样与学习
从真正的命令入口开始,调用链是:train.py 调用 task_registry.make_env() 与 make_alg_runner(),registry 再把 LeggedRobotCfgPPO 转成字典并构造 OnPolicyRunner。因此两个仓库是在 runner 创建处接上的,不是靠隐式的全局状态互相调用。
经典 runner 的关键顺序可写成不依赖具体 API 的伪代码:
for update in range(max_iterations): |
对应源码:OnPolicyRunner.learn()。
这里有两个重要边界:
- rollout 时使用
torch.inference_mode(),因为采样阶段不需要为每一步保留反向传播图; - update 使用刚收集的一批旧策略数据,更新结束后清空 storage,再用新策略重新采样。
3. Actor–Critic:一个决定动作,一个估计当前位置
3.1 Actor 输出的是动作分布,不只是一个动作值
经典 ActorCritic 的 actor MLP 输出均值 ,再配合一个可学习标准差向量 形成对角 Gaussian:
训练时采样:
部署时通常直接使用均值:
对应源码:ActorCritic.update_distribution()、act() 与 act_inference()。
Gaussian 探索如何经过 action clip、scale 与 PD 变成 torque
这张图把四个经常混淆的量拆开了:
- 与 属于 policy 分布;
- sample 出来的 是 policy 的原始随机变量,rollout 保存的
log_prob也是在这个动作上计算; - 环境先把 截到允许范围得到 ;
action_scale、默认关节角、PD gain 与 torque limit 属于环境控制接口。
legged_gym 的位置控制可以写成:
最后还有:
对应 _compute_torques()。这也是为什么“代码考虑得更多”:策略公式只写 ,而工程实现还必须处理单位、缩放、控制模式、PD、饱和与 decimation。
3.2 Critic 的服务对象是 advantage,不是电机
Critic 输出一个标量:
它回答的是:“从现在开始,按当前策略走下去,大概还能拿多少累计回报?”
Critic 不直接输出 action,也不进入 PD 控制器。它的主要用途是构造 TD error、advantage 和 value target,帮助 actor 判断刚才采到的动作相对预期是好还是坏。
如果环境提供 privileged observation,旧版 runner 会让 actor 使用普通 observation,critic 使用 privileged observation;没有 privileged observation 时二者输入相同。对应 OnPolicyRunner.__init__() 与 learn()。
4. Rollout buffer:为什么必须保存这些张量
令:
- :并行环境数;
- :每个环境每轮采样步数,即
num_steps_per_env; - :actor observation 维度;
- :critic observation 维度;
- :动作维度;
- :一轮 rollout 的 transition 数量。
经典 RolloutStorage 会分配:
| 数据 | 典型形状 | 后面服务于什么 |
|---|---|---|
observations |
重算新策略的动作分布 | |
privileged_observations |
重算 critic value | |
actions |
在新旧策略下评估同一批动作 | |
rewards、dones |
TD、GAE、终止 mask | |
values |
baseline 与 value clipping 的旧值 | |
returns、advantages |
critic target 与 policy update 权重 | |
actions_log_prob |
计算 PPO ratio 的旧 log-prob | |
mu、sigma |
估计新旧 Gaussian 的 KL |
基础配置给出 、、、。因此一个典型 rollout 中:
observations [24, 4096, 235] |
这是基础类的尺寸示例,具体机器人配置可以覆盖 observation 与 action 维数;真正应该记住的是形状之间的关系。
最关键的问题是:为什么需要旧 log_prob?
因为 PPO 更新时参数已经变成 ,但采样动作来自更新前的 。必须同时知道同一动作在新旧策略下的概率密度,才能判断更新幅度:
连续动作的 是概率密度,不是“选中动作的百分比”。代码用 log-prob 相减再 exp,数值上也比先计算很多很小的密度再相除更稳定。
对于 维独立 Gaussian,代码先把各动作维的 log-prob 相加,得到联合动作的 log-prob:
所以 PPO ratio 比较的是整组关节动作,不是任选一个关节单独比较。
4.1 真正终止与 timeout 不完全相同
最严谨的想法是给它们两个不同的 mask:
其中 控制“能否从边界状态 bootstrap”, 控制“GAE 能否继续穿过边界传播”:
| 边界 | 含义 | ||
|---|---|---|---|
| 普通 transition | 1 | 1 | bootstrap,并向后继续递推 |
| 真正 terminal(摔倒等) | 0 | 0 | 状态价值为零,递推也停止 |
| timeout / 时间上限截断 | 1 | 0 | 用截断前状态 bootstrap,但不能串到 reset 后的新 episode |
从语义上说,timeout 应使用 reset 前截断状态的 value,而不是 reset 后新 episode 的 observation。v1.0.2 的具体实现更早:PPO.process_env_step() 直接把 rollout 时已保存的 transition.values 乘 补进 reward,随后 storage 仍通过 done 停止 GAE;它在代码结构上把 的效果提前并入了 。对应 timeout bootstrap 与 storage 写入。这是经典兼容版的历史写法,不要把它误读成“可以连接 reset 后的新状态”。
5. 从 reward 到 advantage:TD 与 GAE 服务于“动作比预期好多少”
5.1 Reward 是环境给的,advantage 是算法算的
legged_gym.compute_reward() 会把所有非零 reward scale 对应的函数结果加起来:
例如速度跟踪、姿态、力矩、动作变化、碰撞等。PPO 并不知道每一项的物理含义,它只接收最终 。
先忽略边界、令 ,单步 TD error 是:
它在问:
实际拿到的 reward 加上下一状态的预计价值,是否高于 critic 对当前状态原来的预计?
5.2 GAE 把未来几步的 TD error 加权回来
有限 rollout 中,GAE 用反向递推实现:
展开后可以看到未来第 步 TD error 的权重:
Critic 的回归目标则是:
这里的 是尚未标准化的原始 advantage:实现先用它构造 return target,再单独标准化 policy update 所用的 advantage。不能反过来拿均值为 0 的标准化 advantage 构造 critic target。
对应 RolloutStorage.compute_returns()。
γ 与 λ 怎样决定一次 TD error 能影响多远
这个实验帮助区分两个参数:
- 决定环境回报本身如何折扣,也进入 TD bootstrap;
- 只控制多个 TD error 混合的时间跨度,是 GAE 的 bias–variance 旋钮。
时几乎只看一步 TD,偏差较大但方差较小; 时看得更远,通常偏差更小但方差更大。两种边界都会阻止 GAE 穿进 reset 后的新 episode;差别在于 timeout 仍可对边界状态做一次 bootstrap,真正 terminal 不可以。
5.3 为什么要归一化 advantage
旧版实现会对整批 rollout 做:
它不改变“相对好坏”的排序,但让一次 update 的梯度尺度更可控。需要注意:advantage normalization、observation normalization 和 reward normalization 是三件不同的事,不能只看到 normalization 就当作同一层处理。
6. PPO ratio:把“这个动作好不好”变成“该增大还是减小概率”
Advantage 的符号给出方向:
- :这个动作比 critic 的基线预期更好,希望提高它的概率密度;
- :这个动作比预期差,希望降低它的概率密度。
Ratio 表示新策略相对旧策略改变了多少:
- :新策略更偏爱这个已采样动作;
- :新策略降低了这个动作的概率密度;
- :对这个样本而言,新旧策略没有变化。
如果只最大化 ,网络可能为了少数样本一步走得太远。PPO 的 clipped surrogate objective 是:
代码做梯度下降,因此等价地计算负号后的最大值。对应 PPO.update() 中 ratio 与 surrogate loss。
Clip 裁的是概率比带来的收益,不是把 action 截成 ±ε
正负 advantage 的平台方向并不相同:
| Advantage | 希望 ratio 往哪里走 | 哪一侧不再继续奖励 |
|---|---|---|
| 往 走,提高好动作概率 | 的右侧 | |
| 往 走,降低坏动作概率 | 的左侧 |
所以只画一条“对称截断曲线”会掩盖 min 与 advantage 符号共同产生的非对称效果。
6.1 三种 clip 必须分开
| 名称 | 发生位置 | 目的 |
|---|---|---|
| PPO ratio clip | policy loss | 限制同一批旧数据驱动策略继续获益的范围 |
| action clip | policy 输出进入环境之前 | 防止归一化动作超过约定范围 |
| torque clip | PD/torque 计算之后 | 遵守执行器物理上限 |
PPO clip 并没有直接改写发送给机器人的 action。它只改变训练时的目标函数。
7. Critic、Entropy、KL:PPO 不只包含一条 policy loss
7.1 Value loss 让 critic 学会预测 return target
最基本的 value loss 是:
启用 value clipping 时,先限制新 value 相对 rollout 时旧 value 的变化:
再取未裁剪与裁剪误差中的较大者:
对应 value clipping 实现。这里取较大的误差,是为了避免 critic 通过一次过大的更新获得过于乐观的“改善”。
7.2 Entropy 让动作分布不要过早塌缩
对角 Gaussian 的 entropy 会对动作维求和:
较大时探索更广,entropy 较高; 过早变得很小时,策略容易只重复一小片动作。经典实现中的总损失是:
对应 loss 组合与梯度裁剪。
7.3 Adaptive KL 是油门,不是另一种 clip
对于对角 Gaussian,代码估计旧策略到新策略的 KL divergence。当平均 KL 太大时把 learning rate 除以 ,太小时乘以 ,并限制在 。
可以这样记:
- ratio clip 改的是 objective 的形状;
- adaptive KL 改的是 optimizer 下一步走多快;
- gradient clipping 限制的是一次反向传播得到的总梯度范数。
8. Mini-batch 与多个 epoch:一批 on-policy 数据为什么还能重复用
rollout 收集完以后,feedforward storage 把前两维展平:
再随机打乱并分为 个 mini-batch:
每个 epoch 遍历这些 mini-batch,总 optimizer step 数为:
其中 是 num_learning_epochs。对应 mini_batch_generator()。
这里再抠一个版本细节:v1.0.2 在进入 epoch 循环前只调用一次 randperm,因此同一轮 update 的多个 epoch 复用同一组随机分块;有些后续实现会每个 epoch 重新打乱。理解 PPO 不依赖哪一种选择,但复现代码时不能凭印象把它们写成同一个实现。
N × T 的 rollout 如何被打乱、切块并重复利用
用 legged_gym 基础配置举例:
num_envs = 4096 |
则:
每个 mini-batch 约有:
一轮 iteration 做 次 optimizer step。每个 transition 在这一轮里被看约 5 次,然后整批丢弃。
这仍然叫 on-policy,是因为数据只被当前这轮的旧策略采样并短暂复用;PPO 用 ratio clip 和 KL 调度限制它在多次复用中偏离旧策略太远。它并不会像 replay buffer 那样长期混用很多历史策略的数据。
上面的展平只对应 feedforward policy。使用 recurrent policy 时,不能把 timestep 当作独立样本任意打散;经典实现会按 episode boundary 分割并补齐轨迹,同时传递 mask 与初始 hidden state,再走单独的 recurrent mini-batch generator。
9. 把 legged_gym 配置项逐个放回公式
官方基础配置集中在 LeggedRobotCfgPPO。下面不只抄默认值,而是说明它在训练链路里服务什么。
| 配置 | 公式/代码位置 | 调大时最直接的变化 | 先看什么指标 |
|---|---|---|---|
num_steps_per_env |
rollout 的 | 每轮数据更多、GAE 序列更长、显存增加 | collection time、FPS、显存 |
num_mini_batches |
单个 batch 更小、optimizer step 更多 | update time、KL 波动 | |
num_learning_epochs |
同批数据复用 次 | 拟合更充分,也更容易偏离旧策略 | KL、clip fraction |
clip_param |
surrogate/value 可获益区间变宽 | KL、surrogate loss | |
gamma |
TD 与 return 的 | 更看重远期 reward | value loss、episode return |
lam |
GAE 的 | advantage 更依赖远期 TD | advantage 方差、value loss |
learning_rate |
optimizer 步长 | 参数更新更快 | KL、loss 尖峰 |
desired_kl |
adaptive schedule 的 | 允许的新旧分布距离目标改变 | learning rate、KL |
entropy_coef |
更鼓励探索、动作噪声衰减更慢 | mean noise std、reward | |
value_loss_coef |
critic 梯度在总损失中占比更高 | value loss、explained variance | |
max_grad_norm |
上限 | 允许更大的单步梯度 | grad norm、NaN |
use_clipped_value_loss |
是否限制 critic 相对旧预测跳变 | value loss |
这些参数不是互相独立的旋钮。例如增大 epoch、learning rate 与 clip range 都可能提高策略偏移;看到 KL 变大时,不能只盯着其中一个。
还要区分“库构造函数默认值”和“项目实际传入值”:PPO.__init__() 自己的默认 gamma=.998、epoch 1、mini-batch 1、固定学习率,并不是 legged_gym 的训练配置。真正跑项目时以上表 LeggedRobotCfgPPO 覆盖后的参数为准。
10. 为什么这套结构特别适合四足机器人
10.1 GPU 并行环境让 on-policy 数据不再那么昂贵
四足接触任务的单条 trajectory 噪声很大,但 Isaac Gym 可以同时运行成千上万个环境。一次短 rollout 就能得到 个 transition,使 PPO 在不长期保存旧经验的情况下仍有很大的 batch。
10.2 对角 Gaussian 很适合连续关节动作
actor 直接输出每个动作维的均值,标准差控制探索;这与连续的关节位置偏移、速度目标或 torque 自然对应。动作维的 log-prob 与 entropy 求和后,每个 transition 得到一个联合动作评分。
10.3 Clipped update 能缓和接触动力学与 reward 的高噪声
脚落地、打滑、碰撞和 reset 都会让回报曲面不平滑。PPO 不能消除这些非平稳性,但限制同一批数据驱动策略走得过远,通常比完全不约束的 policy gradient 更容易稳定训练。
不过 PPO 不能修复坏的任务定义:
- observation 缺失关键状态,PPO 不会凭空补出来;
- action scale 或 PD gain 不合理,PPO 只会在错误接口里挣扎;
- reward 有漏洞,PPO 会认真利用漏洞;
- timeout 与 terminal 处理错,GAE 会得到系统性偏差;
- sim-to-real 接口不一致,训练 reward 再高也不能保证部署成功。
11. 从训练现象反推是哪一段出了问题
| 现象 | 优先检查的 PPO 环节 | 还要回查的环境环节 |
|---|---|---|
| KL 突然很高、learning rate 连续下降 | epoch、LR、clip、advantage scale | reward 是否突然跳变、reset 是否异常 |
| action std 很快接近 0 | entropy、初始化 std | observation 是否足够、reward 是否只偏爱一个僵硬解 |
| value loss 爆炸 | return/GAE、value coefficient、bootstrap | reward scale、timeout/done |
| loss 看起来正常但 reward 不升 | actor 得到的 advantage 是否有信息 | observation、action、reward 设计 |
| reward 上升但机器人动作很难看 | PPO 可能正在正确优化错误目标 | reward hacking、动作/力矩惩罚、课程学习 |
| 训练好、部署差 | 不是先调 PPO clip | 观测可获得性、延迟、噪声、随机化、控制频率 |
更完整的曲线排查可以继续看:强化学习训练曲线诊断。
12. rsl_rl v5.5.0 中哪些名字变了,哪些公式没变
现代 rsl_rl 仍然是“rollout → GAE → PPO update”,但抽象层已经不同:
| 经典 v1.0.2 | v5.5.0 | 概念是否改变 |
|---|---|---|
一个 ActorCritic 容纳两张网络 |
PPO.actor 与 PPO.critic 是独立 model |
Actor/critic 数学角色不变 |
observations + privileged_observations |
完整 TensorDict + obs_groups |
非对称输入思想不变 |
ActorCriticRecurrent |
actor/critic 可分别选择 RNNModel |
recurrent 的能力更可组合 |
| storage 保存固定字段 | storage 保存 observation TensorDict 与标准 PPO 字段 | rollout 目的不变 |
| PPO 内手写 Gaussian KL | distribution 对象提供 KL | adaptive KL 逻辑不变 |
现代版的关键代码入口:
v5.5.0 还特别调整了 observation normalizer 的更新时间:整轮 PPO 优化期间统计量保持固定,优化结束后再用整个 rollout 更新并在多 GPU 间同步。这个细节提醒我:代码里常见的工程设计,往往不是新公式,而是在保护公式成立时所依赖的数据分布与时序。
名字相同也可能不是同一机制:经典 legged_gym 配置中的 normalization 主要是人工设定的 observation 分量缩放、噪声与 clip;v5.5.0 的 EmpiricalNormalization 才维护 running mean/variance。阅读配置时不能把二者直接画等号。
如果直接使用今天的 rsl_rl,应以它的官方配置文档与固定 release 为准,不要照搬本文 v1.0.2 的类名;但 GAE、ratio、clipped objective、value target 与 entropy 的理解可以直接迁移。
13. 最容易混淆的八件事
- PPO ratio clip 不是 action clip。 前者裁训练收益,后者裁发给环境的动作。
- Critic 不控制机器人。 它训练期提供 baseline 和 value target,部署通常只保留 actor。
reward不等于return。 Reward 是一步分数;return/GAE 把未来信息带回来。done不一定都该把 bootstrap 置零。 真终止与时间截断要区分。- Policy loss 不要求像监督学习 loss 那样单调下降。 数据分布每轮都在变。
- 多 epoch 不等于离线 replay。 同一批数据只短暂复用,更新后就丢弃。
- PPO 稳定不等于机器人一定安全。 Action、PD、torque limit 和真机保护属于另一层。
- Privileged critic 不代表部署 actor 偷看真值。 特权信息只帮助训练 value;actor 输入必须满足部署可获得性。
14. 下次读代码时,我会按这个顺序找
1. runner.learn() |
如果能顺着这七步把一个 transition 从 追到 optimizer step,再从更新后的 policy 追回下一轮 ,PPO 就不再只是几条分散的公式。
15. 总结:每条公式到底服务于哪个场景
| 公式 | 在训练里解决什么问题 | 对应代码位置 |
|---|---|---|
| 连续动作与探索 | actor distribution | |
| 把 policy action 变成可执行控制 | legged_gym._compute_torques() |
|
| 判断一步结果是否超出预期 | storage GAE 递推 | |
| 在偏差与方差之间传播信用 | compute_returns() |
|
| 衡量新旧策略对同一动作的变化 | PPO update | |
| 避免同批旧数据继续推动过大更新 | surrogate loss | |
| 训练 critic 预测未来回报 | value loss | |
| 防止探索过早消失 | entropy bonus | |
| 用分布距离调节学习率 | adaptive schedule | |
| 把并行采样变成训练 batch | rollout + generator |
最后把整篇压缩成一句话:
legged_gym决定机器人经历什么,rsl_rl决定怎样从这些经历更新策略;reward 先经 critic 与 GAE 变成 advantage,再经新旧策略 ratio 与 PPO clip 变成一次受约束的梯度更新。