“速通” PPO

Proximal Policy Optimization

终于到了这几年 NLP 领域中比较火热的 RL 算法之一了

On-Policy 算法中，采集数据用的策略和训练的策略是相同的，这样的问题是数据用一次后就得丢弃，然后再重新采集数据，训练速度很慢。

PPO 背后的直觉

PPO 的理念是通过限制每个训练周期对策略的更改来提高策略的训练稳定性：避免剧烈的策略更新。

这出于两个原因：

根据这个领域的经验，训练中较小的策略更新更有可能收敛到最优解。

策略更新中，过大的 step 可能导致 “跌下悬崖”（得到 bad policy），并需要很长时间恢复，甚至永远无法回归原始水平。

Clipped Surrogate Objective Function

回顾：策略目标函数

我们的目标是通过采取梯度上升（或者梯度下降的负函数）来推动 agent 选择那些能带来更高奖励的行为，并避免那些可能带来负面效果的动作。

Copy L PG(θ)=Et[log⁡πθ(at∣st)∗At]L^{PG}(\theta) = \mathbb{E}_t \left[ \log \pi_\theta(a_t | s_t) * A_t \right]LPG(θ)=Et​[logπθ​(at​∣st​)∗At​]

log⁡πθ(at∣st)\log \pi_\theta(a_t | s_t)logπθ​(at​∣st​)：在状态 st s_tst​ 下选择动作 at a_tat​ 的对数概率，意味着我们在当前策略中采取这个动作的概率有多大。

At A_tAt​：优势函数（Advantage），如果 A>0 A > 0A>0，说明这个动作比当前状态下其他可能的动作更好；反之，则较差。
然而，经典的 PG 方法存在一个问题：策略更新步长的选择至关重要。

如果步长太小，训练过程会非常慢；

如果步长太大，训练中的波动性太大，可能导致训练不稳定。

于是，PPO 提出了一个新方案，Clipped Surrogate Objective Function，它通过裁剪策略变化的范围，确保策略更新不会太激进，从而保持训练过程的稳定性。

这个新的目标函数如下：

Copy L CLIP(θ)=E^t[min⁡(rt(θ)At^,clip(rt(θ),1−ϵ,1+ϵ)At^)]L^{CLIP}(\theta) = \hat{\mathbb{E}}_t \left[ \min \left( r_t(\theta) \hat{A_t}, \text{clip}\left( r_t(\theta), 1 - \epsilon, 1 + \epsilon \right) \hat{A_t} \right) \right]LCLIP(θ)=E^t​[min(rt​(θ)At​^​,clip(rt​(θ),1−ϵ,1+ϵ)At​^​)]

The Ratio Function

其中，关键的部分是比率函数 rt(θ)r_t(\theta)rt​(θ)，它表示当前策略与之前策略之间的动作概率比率：

Copy rt(θ)=πθ(at∣st)π θ old(at∣st)r_t(\theta) = \frac{\pi_\theta(a_t | s_t)}{\pi_{\theta_{\text{old}}}(a_t | s_t)}rt​(θ)=πθold​​(at​∣st​)πθ​(at​∣st​)​

比率反映了当前策略与旧策略的偏差程度：

如果 r t(θ)∈(0,1)r_{t(\theta)}\in (0, 1)rt(θ)​∈(0,1) ，则说明在当前策略下，选择该动作的概率变小。

如果 rt(θ)>1 r_t(\theta) > 1rt​(θ)>1，说明在当前策略下，动作 $a_t$ 比之前更有可能被选择。

未裁剪部分

公式中的 unclipped 部分为：

Copy L CPI(θ)=E^t[πθ(at∣st)π θ old(at∣st)A^t]=E^t[rt(θ)A^t]L^{CPI}(\theta) = \hat{\mathbb{E}}_t \left[ \frac{\pi_\theta(a_t | s_t)}{\pi_{\theta_{\text{old}}}(a_t | s_t)} \hat{A}_t \right] = \hat{\mathbb{E}}_t \left[ r_t(\theta) \hat{A}_t \right]LCPI(θ)=E^t​[πθold​​(at​∣st​)πθ​(at​∣st​)​A^t​]=E^t​[rt​(θ)A^t​]

在未截断的目标函数中， rt(θ)\ r_t(\theta) rt​(θ) 直接乘以优势值 A^t\hat{A}_tA^t​，如果动作 at a_tat​ 在当前策略下比在旧策略下更加优（即优势值 A^t>0\hat{A}_t > 0A^t​>0），那么我们会推崇该动作，反之则会削弱它的影响。这是标准的策略梯度优化方向。

但如前面所提到的，没有约束的策略更新可能会导致训练不稳定。如果比率 rt(θ)r_t(\theta)rt​(θ) 远大于 1，策略更新会过大，进而导致训练过程中难以收敛。

这时，PPO 引入了截断策略，裁剪比率的范围。

裁剪部分

Copy L CLIP(θ)=E^t[min⁡(rt(θ)A^t,clip(rt(θ),1−ϵ,1+ϵ)A^t)]L^{CLIP}(\theta) = \hat{\mathbb{E}}_t \left[ \min \left( r_t(\theta) \hat{A}_t, \text{clip}\left( r_t(\theta), 1 - \epsilon, 1 + \epsilon \right) \hat{A}_t \right) \right]LCLIP(θ)=E^t​[min(rt​(θ)A^t​,clip(rt​(θ),1−ϵ,1+ϵ)A^t​)]

在这里，我们看到 min⁡\minmin 操作的引入。当比率 rt(θ)r_t(\theta)rt​(θ) 超过了设定的阈值 [1−ϵ,1+ϵ][1 - \epsilon, 1 + \epsilon][1−ϵ,1+ϵ] 时，裁剪操作会将比率限制在这个范围内，从而防止策略更新过大。

裁剪比率函数为：

Copy clip(rt(θ),1−ϵ,1+ϵ)\text{clip}\left( r_t(\theta), 1 - \epsilon, 1 + \epsilon \right)clip(rt​(θ),1−ϵ,1+ϵ)

这意味着如果比率 rt(θ)r_t(\theta)rt​(θ) 超出设定的区间（原始论文中 ϵ=0.2\epsilon = 0.2ϵ=0.2），它会被截断在 [0.8,1.2][0.8, 1.2][0.8,1.2] 之间，从而保证策略更新的稳定性。我们取截断后的值和未截断值之间的最小值，这保证了最终的目标函数不会过分乐观，而是趋向于一个更加保守的估计。

可视化

首先记住，我们取裁剪目标和未裁剪目标之间的最小值。

情况 1 和 2：比例在范围内

在这两种情况下，都没有剪裁，策略会根据 At A_tAt​ 的正负进行相应更新。这是 PPO 的理想状态，一切都按照预期进行。

情况 1：At>0 A_t > 0At​>0 且 pt(θ)∈[1−ϵ,1+ϵ]p_t(\theta) \in [1 - \epsilon, 1 + \epsilon]pt​(θ)∈[1−ϵ,1+ϵ]

优势函数 At A_tAt​ 为正，意味着这个动作比预期更好。

pt(θ)p_t(\theta)pt​(θ) 处于这个范围内，说明策略变化不大，我们想要鼓励这个动作，因此不进行剪裁。

结果：目标函数为正，梯度更新会推动策略进一步偏向执行这个动作。

情况 2：At<0 A_t < 0At​<0 且 pt(θ)∈[1−ϵ,1+ϵ]p_t(\theta) \in [1 - \epsilon, 1 + \epsilon]pt​(θ)∈[1−ϵ,1+ϵ]

优势函数为负，意味着这个动作比预期更差。

同样，由于比例在范围内，不进行剪裁。我们希望减少该动作的执行。

结果：目标函数为负，梯度更新会使策略远离执行这个动作。

情况 3 和 4：比例低于范围

这里比例表明当前策略比旧策略低估了这个动作的概率。会发生什么呢？

情况 3：At>0 A_t > 0At​>0 且 pt(θ)<1−ϵ p_t(\theta) < 1 - \epsilonpt​(θ)<1−ϵ

动作很好（优势函数为正），但新策略认为这个动作的概率较低。

我们不进行剪裁，因为我们想要 增加 这个优秀动作的概率，允许梯度强烈推动更新。

结果：目标函数为正，梯度鼓励这个动作。

情况 4：At<0 A_t < 0At​<0 且 pt(θ)<1−ϵ p_t(\theta) < 1 - \epsilonpt​(θ)<1−ϵ

动作很差（优势函数为负），策略已经在减少这个动作的概率。

然而，我们进行剪裁，因为概率已经低于 1−ϵ 1 - \epsilon1−ϵ，继续降低可能会过度惩罚，导致训练不稳定。

结果：目标函数被剪裁，梯度不会再更新，该动作概率保持在下限。

情况 5 和 6：比例高于范围

在这里，策略对动作过于自信，这意味着新策略让这个动作的执行概率过高。

情况 5：At>0 A_t > 0At​>0 且 pt(θ)>1+ϵ p_t(\theta) > 1 + \epsilonpt​(θ)>1+ϵ

动作很好（优势函数为正），但新策略过高估计了它的执行概率。

我们进行剪裁，因为我们不希望策略过度偏向这个动作。即使 At A_tAt​ 为正，我们也需要限制策略的更新步幅。

结果：目标函数被剪裁，梯度不更新，我们限制了策略的变化幅度。

情况 6：At<0 A_t < 0At​<0 且 pt(θ)>1+ϵ p_t(\theta) > 1 + \epsilonpt​(θ)>1+ϵ

动作不好，但策略却让它的执行概率变得更高。这显然不是我们想要的。

此时，比例已经超出范围，我们不进行剪裁。目标函数为负，梯度强烈推策略远离这个差的动作。

结果：目标函数为负，梯度会使策略远离这个动作。

为什么在剪裁的情况下梯度为 0？

原因在于，当比值 rt(θ)r_t(\theta)rt​(θ) 被剪裁到 1−ϵ 1 - ϵ1−ϵ 或 1+ϵ 1 + ϵ1+ϵ 时，导数不再是比值 rt(θ)r_t(\theta)rt​(θ) 乘以优势 At A_tAt​ 的导数，而是 (1−ϵ)At(1 - ϵ)A_t(1−ϵ)At​ 或 (1+ϵ)At(1 + ϵ)A_t(1+ϵ)At​ 的导数，而这两个表达式的导数为 0。

总结

总结一下，PPO 的目标是通过 Clipped Surrogate Objective 限制当前策略与旧策略之间的变化范围。我们移除了让概率比值超出 [1−ϵ,1+ϵ][1 - ϵ, 1 + ϵ][1−ϵ,1+ϵ] 区间的激励，因为一旦比值超出该区间，梯度就会变为 0，策略更新就停止。

在 PPO 更新过程中，我们只在两种情况下更新策略：

当比值 rt(θ)r_t(\theta)rt​(θ) 落在 [1−ϵ,1+ϵ][1 - ϵ, 1 + ϵ][1−ϵ,1+ϵ] 区间内时。

比值在区间外，但优势函数引导比值靠近该区间。

最后复习一下，PPO 的 Clipped Surrogate Objective Loss 是由三部分组成：

Clipped Surrogate Objective function：限制策略更新的变化范围。

Value Loss Function：用来最小化值函数的均方误差。

Entropy Bonus：用于保持足够的探索，以防止策略过早陷入局部最优。

这三部分结合以确保 PPO 既能稳定地更新策略，又能保持足够的探索性。

代码实现

现在让我们从代码角度深入理解 PPO 的实现。聚焦在 cleanrl 中 ppo.py最关键的部分，用简洁的方式解释其工作原理。

1. 策略网络与值网络结构

Copy
class Agent(nn.Module):
def __init__(self, envs):
super().__init__()
# 评论家网络:将状态映射为价值(用神经网络估计状态的好坏程度)
self.critic = nn.Sequential(
layer_init(nn.Linear(np.array(envs.single_observation_space.shape).prod(), 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 1), std=1.0), # 最后一层的初始化对学习稳定性很重要
)
# 演员网络:将状态映射为动作概率(输出策略的神经网络)
self.actor = nn.Sequential(
layer_init(nn.Linear(np.array(envs.single_observation_space.shape).prod(), 64)),
nn.Tanh(),
layer_init(nn.Linear(64, 64)),
nn.Tanh(),
layer_init(nn.Linear(64, envs.single_action_space.n), std=0.01), # 较小的标准差确保初始策略近似均匀分布
)

这是一个典型的双网络架构：

actor (策略网络) 输出动作的概率分布

critic (值网络) 预测状态价值

两个网络都采用简单的两层 MLP 结构 (64-64)

使用正交初始化 (orthogonal initialization) 来帮助训练稳定性

2. GAE (广义优势估计) 的实现

Copy
# GAE计算:反向递推计算优势函数和回报值
with torch.no_grad():
next_value = agent.get_value(next_obs).reshape(1, -1)
advantages = torch.zeros_like(rewards).to(device)
lastgaelam = 0
for t in reversed(range(args.num_steps)):
# GAE(广义优势估计)的优雅实现
# 可以理解为时序差分误差的指数加权和
delta = rewards[t] + args.gamma * nextvalues * nextnonterminal - values[t]
advantages[t] = lastgaelam = delta + args.gamma * args.gae_lambda * nextnonterminal * lastgaelam
returns = advantages + values # 回报值 = 优势函数 + 价值估计

这段代码展示了 GAE 的递归计算过程：

从后向前计算 TD 误差 (delta)

用指数加权的方式累积这些 TD 误差

gamma 和 lambda 超参数控制着价值估计的偏差 - 方差权衡

3. PPO 的核心损失函数计算

Copy
# PPO的核心:在防止策略变化过大的同时改进策略
_, newlogprob, entropy, newvalue = agent.get_action_and_value(b_obs[mb_inds], b_actions.long()[mb_inds])
ratio = (newlogprob - b_logprobs[mb_inds]).exp() # 重要性采样比率

# 著名的PPO-Clip目标函数
pg_loss1 = -mb_advantages * ratio
pg_loss2 = -mb_advantages * torch.clamp(ratio, 1 - args.clip_coef, 1 + args.clip_coef)
pg_loss = torch.max(pg_loss1, pg_loss2).mean() # 悲观式(最坏情况)策略损失

# 价值函数损失同样使用截断来保持接近旧预测
if args.clip_vloss:
v_loss_unclipped = (newvalue - b_returns[mb_inds]) ** 2
v_clipped = b_values[mb_inds] + torch.clamp(
newvalue - b_values[mb_inds],
-args.clip_coef,
args.clip_coef,
)
v_loss_clipped = (v_clipped - b_returns[mb_inds]) ** 2
v_loss = 0.5 * torch.max(v_loss_unclipped, v_loss_clipped).mean()

# 综合损失函数:结合策略损失、价值损失和用于探索的熵奖励项
loss = pg_loss - args.ent_coef * entropy_loss + v_loss * args.vf_coef

这里实现了前面提到的三个关键组件：

Clipped Surrogate Objective 用 max 操作实现截断

值函数损失同样使用了截断机制 (这是 OpenAI 的实现特色)

熵奖励项用于鼓励探索

前段时间平台的 ipfs 崩了因而搁置许久，这篇发出来后 Huggingface DeepRL 系列正式完结～