1. 从策略梯度到 PPO:为什么我们需要一个更稳的算法
如果你跟着这个系列一路走到第七篇,说明你已经啃过了策略梯度、Actor-Critic 这些基础件。但说实话,基础策略梯度在实际项目里能直接跑出好结果的情况并不多——训练震荡、步长敏感、样本利用率低,这三个问题几乎会同时出现。PPO(Proximal Policy Optimization,近端策略优化)就是冲着这些痛点来的,它现在是深度强化学习里最常用的策略优化算法之一,从游戏 AI 到机器人控制,再到最近很火的 RLHF(基于人类反馈的强化学习),底层都在用它。
这篇文章我会把 PPO 的理论脉络和 Python 实现完整走一遍,重点讲清楚三件事:重要性采样为什么是 PPO 的地基、GAE(广义优势估计)怎么把优势函数算得又稳又准、裁剪目标函数到底在裁剪什么。代码部分我会给出一份可以直接跑的 PyTorch 实现,配合 CartPole 和 LunarLander 两个环境验证。适合已经了解基本强化学习概念、想真正把 PPO 落地到项目里的读者,也适合正在准备面试、需要把算法细节讲明白的朋友。
我自己的经验是,PPO 的论文读三遍不如自己手写一遍。但手写之前,你得先明白每个公式背后的动机,否则调参的时候就是瞎猜。下面从设计思路开始拆。
2. PPO 的整体设计思路与方案选型
2.1 为什么不用朴素策略梯度
朴素策略梯度的更新公式是 $\nabla J = E[\nabla \log \pi(a|s) \cdot G_t]$,其中 $G_t$ 是回报。这个公式的问题在于:每次更新后策略就变了,旧数据立刻失效。也就是说,你采样一批轨迹,只能做一次梯度更新,然后就得扔掉重新采样。样本效率极低,而且步长稍微大一点,策略就会崩掉,因为策略分布变化太剧烈,后续采样到的状态分布和之前完全对不上。
TRPO(Trust Region Policy Optimization)试图解决这个问题,它引入了一个约束:新旧策略之间的 KL 散度不能超过某个阈值 $\delta$。理论上很漂亮,但实现上需要计算 Fisher 信息矩阵和共轭梯度,还要做线搜索,工程复杂度高,跑起来也慢。PPO 的思路是:我不做硬约束,我用一个简单的裁剪操作把策略更新限制在一个合理范围内,效果接近 TRPO,但实现简单得多。
2.2 重要性采样的核心作用
PPO 能复用旧数据的关键在于重要性采样。假设你用旧策略 $\pi_{\theta_{old}}$ 采样了一批数据,现在想用这批数据来估计新策略 $\pi_\theta$ 的期望,就需要乘以一个比率:
$$r_t(\theta) = \frac{\pi_\theta(a_t|s_t)}{\pi_{\theta_{old}}(a_t|s_t)}$$
这个比率就是重要性权重。当 $r_t(\theta) \approx 1$ 时,说明新旧策略在这个动作上的概率差不多,数据可以放心用;当 $r_t(\theta)$ 偏离 1 很远时,说明新旧策略差异大,这个样本的估计就不可靠了。
注意:重要性采样的方差会随着新旧策略差异增大而急剧上升。这就是为什么 PPO 必须限制 $r_t(\theta)$ 的范围,否则训练会发散。
2.3 裁剪目标函数的直觉
PPO 的目标函数是:
$$L^{CLIP}(\theta) = E\left[\min\left(r_t(\theta) A_t, \text{clip}(r_t(\theta), 1-\epsilon, 1+\epsilon) A_t\right)\right]$$
这里的 $\epsilon$ 通常取 0.1 或 0.2。这个 min 加 clip 的组合,效果是:当优势 $A_t > 0$(动作比平均好)时,我不让你把 $r_t$ 涨到超过 $1+\epsilon$;当 $A_t < 0$(动作比平均差)时,我不让你把 $r_t$ 压到低于 $1-\epsilon$。换句话说,策略更新被限制在一个信任区域内,但用的是比 TRPO 简单得多的方式。
为什么用 min?因为我们要的是保守估计。如果 $A_t > 0$,$r_t$ 超过 $1+\epsilon$ 后,clip 后的值不再增长,min 取的是那个被截断的小值,梯度就消失了,策略不会继续往这个方向猛冲。如果 $A_t < 0$,$r_t$ 低于 $1-\epsilon$ 后,clip 后的值不再下降,min 同样取截断值。这个设计非常巧妙,用几行代码就实现了信任区域的效果。
2.4 GAE:优势估计的方差与偏差平衡
优势函数 $A_t = Q(s_t, a_t) - V(s_t)$ 衡量的是"在状态 $s_t$ 下选动作 $a_t$ 比平均水平好多少"。直接用单步 TD 误差 $\delta_t = r_t + \gamma V(s_{t+1}) - V(s_t)$ 作为优势估计,偏差小但方差大;用蒙特卡洛回报减去基线,方差小但偏差大。GAE 用指数加权的方式在两者之间做插值:
$$A_t^{GAE(\gamma, \lambda)} = \sum_{l=0}^{\infty} (\gamma \lambda)^l \delta_{t+l}$$
当 $\lambda = 0$ 时退化为单步 TD,偏差小方差大;当 $\lambda = 1$ 时退化为蒙特卡洛,方差小偏差大。实践中 $\lambda$ 取 0.95 左右,能在两者之间取得不错的平衡。这个参数是 PPO 调参时最值得关注的几个之一。
3. 核心细节解析与实操要点
3.1 网络结构设计
PPO 通常用 Actor-Critic 架构,共享底层特征提取层,然后分出两个头:一个输出动作分布参数(Actor),一个输出状态价值(Critic)。对于离散动作空间,Actor 输出每个动作的 logits,经过 softmax 得到概率;对于连续动作空间,Actor 输出高斯分布的均值和标准差。
import torch import torch.nn as nn import torch.nn.functional as F from torch.distributions import Categorical, Normal class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, continuous=False): super().__init__() self.continuous = continuous self.shared = nn.Sequential( nn.Linear(state_dim, 128), nn.Tanh(), nn.Linear(128, 128), nn.Tanh(), ) self.actor = nn.Linear(128, action_dim) self.critic = nn.Linear(128, 1) if continuous: self.log_std = nn.Parameter(torch.zeros(action_dim)) def forward(self, x): feat = self.shared(x) value = self.critic(feat) if self.continuous: mu = self.actor(feat) std = self.log_std.exp().expand_as(mu) dist = Normal(mu, std) else: logits = self.actor(feat) dist = Categorical(logits=logits) return dist, value共享底层的好处是特征复用,训练更高效;坏处是 Actor 和 Critic 的梯度可能互相干扰。如果发现训练不稳定,可以试试把两个网络完全分开,代价是参数量翻倍。我实测下来,CartPole 这种简单任务共享没问题,LunarLander 上分开跑更稳。
3.2 激活函数的选择
上面代码用的是 Tanh,这是 PPO 原论文和大多数实现的选择。ReLU 在强化学习里容易导致死亡神经元问题,尤其是价值网络输出范围大的时候。Tanh 的输出有界,梯度性质更好。如果你非要用 ReLU,建议加 LayerNorm。
3.3 优势归一化
在计算策略损失之前,对优势做归一化是一个非常重要的技巧:
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)这一步能显著提升训练稳定性。原因在于,优势的绝对尺度会影响梯度大小,而归一化后梯度尺度更一致,学习率更容易调。这个技巧在原始论文里没有强调,但在实践中几乎是标配。
3.4 价值函数裁剪
PPO 论文里还提到了对价值函数也做裁剪:
$$L^{VF} = \max\left((V_\theta - V_{target})^2, (\text{clip}(V_\theta, V_{old}-\epsilon, V_{old}+\epsilon) - V_{target})^2\right)$$
这个做法有争议,有些实现用,有些不用。我的经验是:如果价值网络更新太猛导致训练震荡,加上这个裁剪会有帮助;如果训练本来就稳,加不加区别不大。你可以先不加,遇到问题再试。
3.5 熵正则化
为了鼓励探索,防止策略过早收敛到确定性策略,通常会在损失里加一个熵奖励项:
$$L = L^{CLIP} - c_1 L^{VF} + c_2 H(\pi)$$
$c_2$ 一般取 0.01 左右。熵太大会导致策略一直随机,太小会导致过早收敛。这个参数需要根据任务调整,动作空间大的任务可以适当调大。
4. 完整实操流程与核心环节实现
4.1 环境准备与依赖安装
先确保 Python 环境干净。我推荐用 conda 建一个独立环境:
conda create -n ppo python=3.9 conda activate ppo pip install torch gymnasium numpy matplotlib注意这里用的是gymnasium而不是老的gym,后者已经停止维护了。如果你之前装过gym,建议先卸载,避免 API 冲突。
4.2 经验缓冲区设计
PPO 是 on-policy 算法,每轮采样一批轨迹,更新若干次后就丢弃。缓冲区需要存储:状态、动作、奖励、下一状态、终止标志、动作的对数概率、状态价值。
class RolloutBuffer: def __init__(self): self.states = [] self.actions = [] self.rewards = [] self.next_states = [] self.dones = [] self.log_probs = [] self.values = [] def add(self, state, action, reward, next_state, done, log_prob, value): self.states.append(state) self.actions.append(action) self.rewards.append(reward) self.next_states.append(next_state) self.dones.append(done) self.log_probs.append(log_prob) self.values.append(value) def clear(self): self.__init__() def get_batches(self, batch_size): # 返回打乱后的 mini-batch 迭代器 ...4.3 GAE 计算实现
GAE 的计算需要从后往前遍历,因为每个时刻的优势依赖后续时刻的 TD 误差:
def compute_gae(rewards, values, dones, next_value, gamma=0.99, lam=0.95): advantages = [] gae = 0 values = values + [next_value] for t in reversed(range(len(rewards))): if dones[t]: delta = rewards[t] - values[t] gae = delta else: delta = rewards[t] + gamma * values[t+1] - values[t] gae = delta + gamma * lam * gae advantages.insert(0, gae) returns = [adv + val for adv, val in zip(advantages, values[:-1])] return advantages, returns这里有个细节容易踩坑:当 done 为 True 时,GAE 要重置为当前 delta,不能继续累加后续的。因为终止状态之后没有未来,累加会引入错误信号。我见过不少人在这里写错,导致训练效果差一大截。
4.4 策略更新循环
PPO 的典型更新流程是:采样 N 步,计算 GAE,然后对这批数据做 K 个 epoch 的 mini-batch 更新。K 一般取 3 到 10,batch size 根据总样本数调整。
def update(self, buffer, optimizer, epochs=10, batch_size=64, clip_eps=0.2): states = torch.FloatTensor(np.array(buffer.states)) actions = torch.LongTensor(np.array(buffer.actions)) old_log_probs = torch.FloatTensor(np.array(buffer.log_probs)) advantages, returns = compute_gae(...) advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) for _ in range(epochs): indices = np.random.permutation(len(states)) for start in range(0, len(states), batch_size): idx = indices[start:start+batch_size] dist, values = self.network(states[idx]) new_log_probs = dist.log_prob(actions[idx]) ratio = (new_log_probs - old_log_probs[idx]).exp() surr1 = ratio * advantages[idx] surr2 = torch.clamp(ratio, 1-clip_eps, 1+clip_eps) * advantages[idx] policy_loss = -torch.min(surr1, surr2).mean() value_loss = F.mse_loss(values.squeeze(), returns[idx]) entropy = dist.entropy().mean() loss = policy_loss + 0.5 * value_loss - 0.01 * entropy optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(self.network.parameters(), 0.5) optimizer.step()4.5 关键参数选择与计算过程
几个核心参数的取值逻辑:
| 参数 | 典型值 | 作用 | 调整方向 |
|---|---|---|---|
| clip_eps | 0.1~0.2 | 限制策略更新幅度 | 训练震荡调小,收敛慢调大 |
| gamma | 0.99 | 折扣因子 | 长程任务调大,短程可调小 |
| lam | 0.95 | GAE 平滑系数 | 方差大调小,偏差大调大 |
| lr | 3e-4 | 学习率 | 最敏感的参数,优先调 |
| epochs | 3~10 | 数据复用次数 | 样本效率低调大,过拟合调小 |
| batch_size | 64~256 | mini-batch 大小 | 显存允许下越大越稳 |
学习率是最需要仔细调的。我一般从 3e-4 开始,如果训练曲线震荡就降到 1e-4,如果收敛太慢就升到 1e-3。注意 PPO 对学习率比 DQN 敏感得多,别用 1e-2 这种量级。
4.6 训练循环与日志记录
完整的训练循环大概是这个结构:
for iteration in range(num_iterations): buffer.clear() state, _ = env.reset() for step in range(steps_per_iter): with torch.no_grad(): dist, value = network(torch.FloatTensor(state)) action = dist.sample() log_prob = dist.log_prob(action) next_state, reward, terminated, truncated, _ = env.step(action.item()) done = terminated or truncated buffer.add(state, action.item(), reward, next_state, done, log_prob.item(), value.item()) state = next_state if done: state, _ = env.reset() with torch.no_grad(): _, next_value = network(torch.FloatTensor(state)) update(buffer, optimizer, next_value=next_value.item())日志至少要记录:每轮平均回报、策略损失、价值损失、熵、平均 KL 散度。KL 散度是判断训练是否健康的重要指标,如果它一直很大(比如超过 0.05),说明策略更新太猛,需要调小学习率或 clip_eps。
5. 常见问题与排查技巧实录
5.1 训练曲线震荡不收敛
这是最常见的问题。排查顺序:先看学习率是不是太大,降到 1e-4 试试;再看 clip_eps 是不是太大,降到 0.1;然后检查优势归一化有没有做;最后看 batch_size 是不是太小,增大到 128 或 256。我遇到过好几次都是学习率的问题,PPO 对学习率真的很敏感。
5.2 策略过早收敛到次优解
表现是回报很快涨到一个平台就不动了,熵持续下降接近 0。这时候要增大熵系数 $c_2$,从 0.01 调到 0.05 甚至 0.1。另外检查一下动作采样是不是有问题,比如连续动作空间里 log_std 是不是被压得太小了。
5.3 价值函数损失不下降
如果价值损失一直很大,说明 Critic 学不好。可能的原因:网络容量不够(加宽加层)、学习率不匹配(Actor 和 Critic 可以用不同学习率)、奖励尺度太大(做奖励缩放)。我一般会把奖励除以一个常数,让回报大致落在 [-10, 10] 范围内。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 回报震荡 | 学习率大 / clip_eps 大 | 降 lr 到 1e-4,clip_eps 到 0.1 |
| 收敛慢 | 学习率小 / epochs 少 | 升 lr,增加 epochs |
| 熵快速下降 | 熵系数小 | 增大 c_2 |
| 价值损失大 | 奖励尺度大 / 网络小 | 奖励缩放,加宽网络 |
| KL 散度大 | 更新太猛 | 降 lr,降 clip_eps,减 epochs |
| 训练后期崩溃 | 过拟合旧数据 | 减 epochs,增大 batch_size |
5.5 几个容易忽略的坑
第一个坑是终止状态的处理。Gymnasium 现在返回terminated和truncated两个标志,前者是真正终止(比如杆子倒了),后者是时间截断(比如达到最大步数)。计算 GAE 时,只有terminated才应该重置,truncated不应该。很多人直接用done = terminated or truncated,然后在 GAE 里用 done 判断,这会导致截断时的自举信号丢失。
第二个坑是动作的对数概率计算。连续动作空间里,dist.log_prob(action)返回的是每个维度的对数概率,需要求和才是联合对数概率。如果忘了求和,重要性比率就错了。
第三个坑是随机种子。强化学习对随机性非常敏感,不同种子跑出来的结果可能差很多。建议至少跑 3 个种子取平均,否则你调参调半天可能只是运气好。
第四个坑是梯度裁剪。PPO 里梯度裁剪是标配,但裁剪阈值别设太小,0.5 是常用值。设成 0.1 会导致梯度被过度压制,学习变慢。
6. 从 CartPole 到 LunarLander 的实战验证
6.1 CartPole 上的快速验证
CartPole 是最简单的验证环境,状态 4 维,动作 2 个。用上面的代码,大概 200 到 300 轮迭代就能稳定到 500 分(满分)。如果 500 轮还上不去,肯定是哪里写错了。我建议先用 CartPole 把代码跑通,确认训练曲线正常,再上更复杂的环境。
CartPole 上的推荐参数:lr=3e-4,clip_eps=0.2,gamma=0.99,lam=0.95,epochs=10,batch_size=64,steps_per_iter=2048。这套参数基本不用调就能跑通。
6.2 LunarLander 上的调参过程
LunarLander 状态 8 维,动作 4 个,难度大不少。我一开始用 CartPole 的参数直接跑,结果回报一直在 -100 到 0 之间震荡。后来做了几个调整:学习率降到 1e-4,epochs 降到 5,batch_size 升到 128,熵系数升到 0.02。调整后大概 800 到 1000 轮能稳定到 200 分以上。
这个过程让我意识到,PPO 的参数没有万能配置,必须根据任务调整。状态维度、动作空间大小、奖励尺度都会影响最优参数。我的建议是准备一套基线参数,然后针对具体任务做小范围网格搜索。
6.3 训练监控的关键指标
除了回报,我还会重点看这几个指标:
- 平均 KL 散度:健康范围是 0.005 到 0.02,超过 0.05 说明更新太猛
- 策略熵:应该缓慢下降,如果断崖式下跌说明过早收敛
- 价值估计误差:应该稳步下降,如果反弹说明 Critic 学崩了
- 裁剪比例:被 clip 的样本占比,健康范围 0.1 到 0.3,太高说明更新幅度大
这些指标比单纯的回报更能反映训练状态。回报有随机性,可能一时高一时低,但这些指标的趋势是稳定的。
6.4 性能优化技巧
如果训练太慢,可以试试这几个优化:把环境并行化(用gymnasium.vector或者自己写多进程),把网络放到 GPU 上(小网络 GPU 不一定快,要实测),减少日志记录频率。我实测下来,CartPole 用 CPU 就够了,LunarLander 用 GPU 能快 2 到 3 倍。
另外,torch.no_grad()在采样阶段一定要加,否则会白白构建计算图,显存和速度都会受影响。这个细节很多人会忘。
7. 我对 PPO 实践的一点个人体会
写了这么多,最后分享几个我自己踩坑总结出来的经验。PPO 看起来公式简单,但真正跑好的关键往往在那些论文里一笔带过的地方:优势归一化、梯度裁剪、终止状态处理、随机种子。这些细节决定了你是 500 轮收敛还是 2000 轮还在震荡。
我的建议是,先把 CartPole 跑通,把训练曲线和各项指标都看明白,再上复杂环境。不要一上来就挑战最难的任务,那样你根本分不清是算法问题还是调参问题。另外,多跑几个种子,别被单次结果骗了。强化学习的方差比你想象的大得多。
如果你想把 PPO 用到自己的项目里,先从离散动作空间开始,把流程走通,再扩展到连续动作。连续动作空间里 log_std 的初始化、动作范围的缩放、对数概率的求和,每一个都是坑。慢慢来,别急。