前阵子在一个机器人控制项目里,朋友突然抛来一个问题:PPO 明明是拿着旧策略采样的数据,还能在同批数据上更新好几轮,这不就是 off-policy 吗?说实话,这个问题刚接触强化学习时我也想过,而且一度被各种博客绕晕。后来为了把 on-policy / off-policy 的边界彻底搞明白,我啃了不少原始论文,也亲手改过多种训练框架,才慢慢建立起一套自己的判断标准。这篇文章就把 PPO 和 on-policy / off-policy 一起梳理一遍:从定义到公式,从伪代码到调参经验,最后汇总一些我踩过的坑。无论你是刚入门 RL、还在做课程实验,还是已经上过项目、被训练稳定性折磨过,这篇笔记应该都能给你一点参考。
先说结论:PPO 是 on-policy 算法,但它在实现上确实“借用”了一些 off-policy 的手段,比如重要性采样、多轮 epoch 更新。很多人会把“用了历史数据”直接等价于 off-policy,这个误解不澄清,后面的原理和代码都会拧巴。下面我按自己的理解,一层层把它拆开讲。
1. on-policy 与 off-policy:先把这个基础盘清楚
1.1 行为策略与目标策略:两个角色一张牌桌
几乎所有讲 on/off-policy 的资料,最后都会落到两个词上:行为策略(behavior policy)和目标策略(target policy)。行为策略负责在环境里采集数据,目标策略是你真正想要优化的策略。两者的关系,直接定义了算法属于哪个阵营。
如果行为策略和目标策略是同一个策略,就是 on-policy;如果它们不是同一个策略,就是 off-policy。听上去很简单,但很多人会忽略一个微妙点:这里的“同一个策略”并不是指“同一份网络权重”那么机械,而是指在采样时刻与优化时刻,策略的分布是否一致。
拿 DQN 举个例子。DQN 在环境里跑的时候用 ε-greedy 策略,是为了保持探索;但真正优化的目标是取最大 Q 值的确定性策略。行为策略和目标策略明显不同,所以 DQN 是 off-policy。SAC、DDPG、TD3 也都属于这一类,它们通常有一个可以存几十万条经验的大回放池,不同时期的策略采到的数据可以混在一起训练。
反观 A2C / A3C 这类算法,采样和更新用的是同一个策略,拿到一批 rollout 后,更新一下网络,然后把数据直接丢掉,再用新策略采样。这就是标准 on-policy。PPO 在设计目标上属于这个阵营,但为什么它能在一批旧数据上更新多个 epoch?这就要提到重要性采样了。
1.2 为什么 PPO 能用旧数据却依然是 on-policy
真正让新人混淆的是 PPO 的 rollout buffer。它看起来和 DQN 的经验回放池都叫 buffer,都存 transition,但生命周期完全不一样。DQN 的回放池可以跨几千步、甚至跨好几十个策略版本,PPO 的 rollout buffer 只保留当前策略版本刚刚采到的一批数据,用完就扔。数据来源越单一,策略更新时面对的分布偏移就越小。
那 PPO 为什么敢在这批旧数据上反复用?关键在于重要性采样。假设旧策略是 π_old,新策略是 π_θ,我们想用 π_old 采到的样本来估计 π_θ 下的期望,就要给每个样本乘以一个比例:
r_t(θ) = π_θ(a_t|s_t) / π_old(a_t|s_t)
理论上,只要这个比例算得准,旧数据可以当作近似新数据来用。但这个“只要”后面藏着巨大的方差风险。一旦 π_θ 偏离 π_old 太远,比例值可能变得非常大,尤其是当 π_old 对某个动作给的概率极低、而新策略又把它抬得很高时,单个样本的梯度会像一颗炸弹一样,直接盖过其它所有样本。我在早期实验里见过 policy loss 从 0.05 跳到几百,基本就是这一下炸的。
PPO 的应对方式很直接:把比例裁到一个可控范围,不让任何单个样本拥有无限大的梯度。所以你可以说 PPO 用了一点 off-policy 的思想,但它的数据生命周期、更新范围和风险控制方式,本质上还是 on-policy 那套。理解了这一层,后面再去看裁剪目标函数就非常顺。
2. PPO 的三大核心细节:重要性采样、裁剪目标与 GAE
2.1 重要性采样到底在计算什么
PPO 的目标函数最常写成下面这一行:
L^CLIP(θ) = E_t[ min( r_t(θ) A_t, clip(r_t(θ), 1-ε, 1+ε) A_t ) ]
其中 clip 把比例 r_t 限制在 [1-ε, 1+ε] 之间。ε 默认 0.2,通常取 0.1 到 0.3 之间。
先看 A_t > 0 的情况。它代表在状态 s_t 下,动作 a_t 的实际回报高于平均水平,我们希望增加它出现的概率。增加概率就会让 r_t 变大,但 min 和 clip 的双重限制意味着 r_t 最多涨到 1+ε。也就是说,策略更新被明确上了一道天花板。如果 A_t < 0,我们希望压低这个动作的概率,r_t 会变小,但下限被卡在 1-ε。这样一来,优势再悲观,单步更新也不会干出“直接把概率压到零”这种激进操作。
这里有个容易被忽略的点:裁剪并不是对梯度做了简单截断,而是改变了目标函数的形状,形成一种“信任区域”。当 r_t 超过边界时,对应那部分样本的梯度会被置为 0。所以实际训练中,你会看到一部分样本在起作用,另一部分已经被 clip 掉,统计被 clip 的比例(clip fraction)就成了判断训练健康度的重要指标。
2.2 裁剪目标:一句“别走太远”是怎么变成数学的
别看这行公式简单,它取代的是一整套繁琐的 TRPO 流程。TRPO 用 KL 散度约束新旧策略的差异,理论上漂亮,但每次迭代要估计 Fisher 信息矩阵、做二阶优化,代码实现重,调试起来更是心累。PPO 把约束近似成一行 clip,计算量大幅下降,实测在很多任务上能达到和 TRPO 差不多的效果。
我自己的体验是:PPO 不是最优的收敛速度,但它很皮实。同样的代码换个随机 seed、换个环境配置,通常不会突然暴毙。对于要长期维护的训练系统来说,这种稳定性比微小的性能差异更重要。所以工业项目里 PPO 成了默认选择。
有一点要特别提醒:clip 参数 ε 并不是越大越好。ε 拉大,相当于放宽了信任区域,策略一步可以迈得更远,早期训练可能看起来更快,但崩的概率也更高。我习惯先用 0.2 跑通,再视情况往 0.1 或 0.3 调。调 ε 之前,先确认优势估计和奖励尺度没问题,否则你只是在给一个不稳的系统打补丁。
2.3 优势估计 GAE:在偏差和方差之间拧旋钮
优势估计直接决定 policy 往哪个方向推。蒙特卡洛回报无偏,但需要跑完整个片段,方差大;一步 TD 方差小,但偏差有可能较大。GAE 给了一个旋钮让你在两者之间滑动。
具体公式:
δ_t = r_t + γ V(s_{t+1}) - V(s_t)
A_t = δ_t + γλ δ_{t+1} + (γλ)^2 δ_{t+2} + ... + (γλ)^{T-1-t} δ_{T-1}
当 λ=0 时,GAE 变成一步 TD,方差小偏差大;当 λ=1 时,它退化成蒙特卡洛,无偏但方差大。默认 λ=0.95 通常不错,但我实际调参时会在 0.9 到 0.99 之间尝试。任务里的奖励越稀疏,λ 往往越要拉高,让优势估计吸收更多未来信息。
说句题外话,GAE 的质量非常依赖价值网络。因为每个 δ_t 里都带着 V(s) 和 V(s_{t+1}),如果 critic 学歪了,优势估计也会一起歪。这也是为什么很多人强调 PPO 的 value loss 不能太大,也不建议一开始就把 value 的更新数设得和 policy 一样多。
3. 实操手记:一个能跑实验的 PPO 是怎么搭出来的
3.1 网络结构、rollout buffer 与 worker 数量
先说结论:Actor 和 Critic 可以共享主干,也可以分开。对于图像输入的任务,共享卷积主干往往能利用通用特征;对于低维向量输入的任务,我推荐分开。共享主干时,两个头会产生梯度干扰,policy loss 和 value loss 需要互相迁就;分开之后,代码多写几行,但调试清爽得多。
然后是 rollout buffer 的组织。PPO 的典型做法是并行开 N 个环境,每个环境跑 T 步,拼成一个 N*T 的 rollout buffer。例如 N=8、T=1024,就是 8192 个 transition。数据收集完之后,把它打乱成 mini-batch,在同一个 buffer 上做多轮梯度更新。
有人问为什么不用更大的 T?T 太长意味着每个 worker 的轨迹穿越更多时间步,数据相关性变大;T 太短又会让一次更新能用的样本不足。实际项目中我常用 512 到 2048 之间,具体要看任务。物理仿真类任务,T 取 1024 左右比较稳。
并行环境的数量也不是越多越好。我见过有人开 64 个环境,结果数据同步和显存开销反而把收益吃掉了。我的经验是,普通工作站开到 8 到 16 个 worker,性价比最高。如果环境本身非常慢,优先做向量化优化,而不是靠堆 worker 硬扛。
3.2 GAE 和 PPO loss 的代码实现:核心就两段
下面给一段靠近实际训练循环的 PyTorch 伪代码。重点看两部分:一个是 GAE 怎么算,一个是 PPO loss 怎么套 clip。
def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = torch.zeros_like(rewards) gae = 0.0 next_value = 0.0 for t in reversed(range(len(rewards))): delta = rewards[t] + gamma * (1 - dones[t]) * next_value - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages[t] = gae next_value = values[t] returns = advantages + values return advantages, returns def ppo_loss(new_logp, old_logp, advantages, values, returns, eps=0.2): ratio = (new_logp - old_logp).exp() surr1 = ratio * advantages surr2 = ratio.clamp(1 - eps, 1 + eps) * advantages policy_loss = -torch.min(surr1, surr2).mean() value_loss = F.mse_loss(values, returns) entropy = -new_logp.mean() total = policy_loss + 0.5 * value_loss - 0.01 * entropy return total这个写法里,ratio 用 log 概率相减再取 exp,而不是直接用概率相除,数值稳定性会好很多。原因很简单:两个训练过程中算出来的概率可能都很小,小数相除在 float32 下容易溢出,而对数相减天然把除法变成了减法。现实工程代码里还要做两个补充操作:一是对 advantage 做 z-score 归一化,让策略梯度在不同奖励尺度下保持可比;二是在训练循环里把 rollout buffer 打乱,然后分成 mini-batch 循环更新。为了突出核心逻辑,这两步我在这里先省略了,但真跑实验的时候一个都不能少。
更新循环一般是 4 到 10 个 epoch。epoch 太多会让重要性采样的有效性下降,clip fraction 会持续偏高;epoch 太少又浪费了已经采到的数据。我习惯先设 4,观察 policy loss 和 clip fraction 的曲线后再加。
3.3 超参表与我的调参顺序
下面这张表是我在做某个连续控制任务时常用的一套起点,可以直接抄作业,再根据任务微调:
| 参数 | 常用值 | 作用与注意事项 |
|---|---|---|
| γ | 0.99 | 折扣因子,控制奖励的远视程度,稀疏任务可以调高到 0.995 |
| λ | 0.95 | GAE 的偏差方差平衡旋钮,奖励越稀疏越倾向调大 |
| ε | 0.2 | clip 范围,训练抖动大就调小,收敛太慢可尝试调大 |
| learning rate | 3e-4 | Adam 默认起步值,显存或 batch 大时可以适当降低 |
| entropy coef | 0.01 | 熵正则,防止策略过早坍缩,探索不够时调大 |
| value coef | 0.5 | value loss 的权重,注意不要让它压过 policy loss |
| 更新 epoch | 4~10 | 数据复用轮数,太大容易过拟合旧数据 |
| worker 数 | 8~16 | 并行环境数量,兼顾采集速度和资源占用 |
| advantage 归一化 | True | 强烈建议开启,否则梯度绝对值不稳定 |
调参顺序我建议这样:先确认 reward 尺度,让 value loss 稳定下降,最后才动 clip / lr。不要一上来就调 ε。很多人习惯先动学习率,但如果你连优势估计的尺度都没管好,改学习率只是换一种崩溃方式。我通常先跑一个不加熵正则、不对 advantage 归一化的版本,亲眼看到它怎么崩,再一步一步把稳定性补回去。这个过程看起来繁琐,却能帮你建立对算法每个部件的直觉。
4. 训练现场实录:常见问题与排查经验
4.1 训练崩溃和策略突变:先查这三样
训练崩溃是 on-policy 算法最常见的问题。现象一般是:前几轮还正常,某次更新后 reward 突然断崖式下跌,重新初始化也没用。重点排查三件事:优势估计有没有归一化、clip fraction 是不是过高、entropy 是不是早就坍缩了。
优势估计如果不做归一化,绝对值大的优势样本会主导梯度,让策略冲向某个动作;clip fraction 持续超过 30%,说明每步更新策略偏离太多;entropy 如果掉到接近 0,说明策略已经早早确定化,后续几乎没有探索能力。这三种情况我都单独遇到过,有一次还是三个一起爆,最后只能重开实验。
还有一种情况特别容易忽略:value network 过拟合导致价值估计自嗨。因为 GAE 是基于当前 value 算的,如果 value 疯狂波动,优势估计也会跟着疯狂波动。这时候我会给 value loss 单独设置一个 clip,限制它对 targets 的变化幅度,或者在更新循环里让 value 的 epoch 少于 policy 的 epoch。这个操作在原始论文里不常提,但工程上非常实用。
4.2 on-policy 的样本效率瓶颈:算法身份决定的事
on-policy 最大的短板就是样本效率。PPO 在低维控制任务上还算友好,但任务一旦变成稀疏奖励,它就会像无头苍蝇。如果项目里环境步进成本非常高,比如真实机器人,我建议直接考虑 off-policy 算法。反过来,如果环境可以低成本并行,比如模拟器,PPO 往往更省心。
想要在 PPO 框架内提升效率,我常用的手段是:把环境向量化数量尽量开到 8 到 16;把 GAE 的 λ 适当调大;对奖励做尺度归一化。注意,“调大 buffer”不代表效率高,on-policy 数据本来就是一次性资源,开再大的 buffer 也只会延迟更新,并不会像 DQN 回放池那样带来数据复用收益。
我还在一个简单的网格环境里做过对照实验:同样的任务,PPO 需要约 20 万步才能达到一定奖励,SAC 只需要约 8 万步。差距在复杂任务里会更大。所以当你面对一个全新任务时,先想清楚自己是“能无限采样”还是“采样一次很贵”,这个选择比任何算法细节都重要。
4.3 把 PPO 当 off-policy 用:那是我踩得最痛的一脚
我见过有人直接把 PPO 和 off-policy 经验回放混着用,做法是把几十个 epoch 的 transition 全部存进同一个 buffer,再随机采样训练。这个做法的理论问题在于,PPO 的目标函数只假设新旧策略相差不大,数据来自太早的策略版本时,importance ratio 会越来越偏。结果通常是一段时间后 reward 曲线突然崩掉,而且无法复现。
如果真的想复用历史数据,至少要正确计算 importance ratio,并做好方差削减与约束。但与其在 PPO 上强行造 off-policy 能力,不如直接换算法。我自己的项目经验是:样本效率优先选 SAC / TD3,稳定性和调参友好度优先选 PPO。两者没有优劣,只是数据生命周期不同,适合的场景也不同。
提示:PPO 的 rollout buffer 只服务当前一次更新,跨策略版本的数据不要混进去。如果发现自己在不断延长 buffer 想“榨干”样本,说明你可能选错了算法方向。
最后说一点个人体会。我跑 RL 实验有个固定习惯:每次训练之前先画一条 reward 曲线,并且把 on-policy / off-policy 这个分类写在实验记录第一行。这听起来很机械,但确实帮我避免了很多“看似在调参、其实在瞎按”的时间浪费。PPO 成为默认选择不是因为它什么都最好,而是因为它稳。搞清楚它为什么稳,比背一堆公式更有用。希望这篇笔记能帮你少走一点弯路。