
系列写到第十四篇按惯例该沉淀一点硬核内容了。前面几篇我们把价值学习、策略学习、DQN 的变体都聊过一轮也写了不少 Python 示例。但每次有人问“自己动手做强化学习项目从哪个算法开始”我几乎都会先提到 PPO。原因很简单它在理论上保留了策略梯度方法的优雅同时把“稳定性”这件最难的事往前推了一大步。这篇就把 PPO 的来龙去脉拆开为什么策略梯度容易炸PPO 的裁剪目标是怎么设计的GAE 怎么算以及如何用 Python 自己实现一个能跑的 PPO 训练流程。理论看到一半、代码还无从下手的阶段这篇应该能帮你把两块接上。1. 为什么第十四篇要写 PPO1.1 策略梯度过山车一步走太远就会崩先回忆一个所有策略梯度算法都要面对的核心矛盾我们希望沿着能提高期望回报的方向更新策略参数但用来估计梯度的样本是从当前策略采样得到的。每更新一步当前策略就变了之前采的数据理论上就“过期”了。REINFORCE 的做法是收集一整条轨迹然后用整条轨迹的回报当作动作的优势值梯度形式大致是E[∇log π(a|s) * R]。听起来没问题实际用起来却经常像走钢丝学习率设小了几百轮不动设大一点第一次更新直接让策略变得只会输出同一个动作。原因也不难理解。轨迹回报 R 的方差通常很高一次采样里某个动作得到高分可能是运气好而不是这个动作真的好。策略梯度却会把它解读成“这个动作值得鼓励”于是每个动作的概率都会跟着样本噪声剧烈摆动。如果这时再叠加一个偏大的学习率策略分布会迅速坍缩后续采集的数据全部来自一个糟糕的策略算法彻底失去探索能力。这就是很多人第一次跑策略梯度时看到的现象loss 下降得很漂亮但回报曲线纹丝不动甚至直接变成一条水平线。1.2 PPO 到底做对了什么PPO 的思路非常直接允许更新但限制每次更新前后策略的变化不要太大。它把问题从“我要往哪个方向走一步”变成“我要往这个方向走但最多只能走这么远”。相比 TRPO 那种带约束的优化PPO 把约束换成一个可以直接写进 Loss 里的裁剪项实现简单调试也直观。裁剪项不是凭空拍脑袋想出来的。它的底层逻辑是新旧策略之间的 KL 距离一旦变大surrogate 目标函数对真实目标的估计就会失真。与其费劲去算每一步的 KL 约束不如在目标函数里直接限薪如果某次更新想让某个动作的概率暴涨我就把收益封顶同样如果想让某个动作的概率暴跌我也把收益封底。这样策略就不会因为少数几个样本把概率推到极端值。1.3 系列走到这里正好到了需要工程化的时候系列前几篇更多在讲“一个算法在一个小型 Demo 上怎么工作”比如用查表法、用简单的价值网络。但从实际需求来看做真正的强化学习项目往往要面对高维状态、连续动作、部分可观测环境这些场景下只靠价值函数或简单策略爬山是不够的。PPO 刚好是这类工程实践的默认起点它不挑动作空间离散和连续都能处理它也不强制要求环境是表格型还是像素型只要网络能表达就能往上套。选择在第十四篇详细写 PPO还有一个考虑它是“算法理论”和“工程实现”之间的一道分水岭。前面几篇的基础知识到这里全部会串起来——策略网络、价值网络、重要性采样、优势估计、经验复用。把 PPO 吃透再去看其他策略优化类方法会轻松得多。2. PPO 理论目标函数、裁剪机制与 GAE2.1 从重要性采样到 surrogate 目标PPO 属于 on-policy 方法但它又想在同一个 batch 上多更新几次靠的是重要性采样。设旧策略为 π_old新策略为 π_θ那么新旧策略在动作 a 上的概率比值可以写成ratio(θ) π_θ(a|s) / π_old(a|s)利用这个比值我们可以把“从新策略采样”的目标函数改写成“从旧策略采样但用比值加权”的形式。这样做的价值在于一批用旧策略采集的数据可以重复用于评估新策略的好坏不必每次更新都重新去环境里跑一遍训练效率会高很多。但代价也很明显如果新旧策略偏离太远比值会变得非常大或非常小即使只有极少数样本出现这种情况也会主导整个梯度方向。你本来只想微调一下策略结果某一条轨迹里的一个动作概率翻了几倍目标函数立刻失真。PPO 的裁剪项就是为了压住这种极端情况。2.2 裁剪目标函数数学形式和梯度行为PPO 的裁剪目标长这样L_clip E[ min(ratio * A, clip(ratio, 1-eps, 1eps) * A ) ]其中 A 是优势函数eps 一般取 0.2。先说结论这个函数会让策略更新在一个“合理范围”内进行超过范围后梯度就不再继续推动。我把四种典型情况列一下优势 A概率比值 ratio梯度效果正ratio 1eps封顶不再奖励继续增大该动作概率正ratio 1-eps正常梯度鼓励增大该动作概率负ratio 1-eps封底不再继续惩罚该动作概率下降过多负ratio 1eps正常梯度鼓励减小该动作概率举个例子。优势为正说明当前动作好于平均水平我们希望增加这个动作的概率。正常情况下 ratio 越大目标函数越大。但如果 ratio 超过 1eps继续增大概率带来的收益会被裁剪掉梯度不再往上推。反过来优势为负说明动作不好我们希望降低概率。如果 ratio 已经小到 1-eps 以下继续降低概率带来的梯度同样会被截断。这个对称设计保证了算法不会“贪一时爽”把策略推得太远。2.3 GAE优势估计的偏差方差权衡PPO 里用的优势不是最简单的单步 TD 误差而是一种叫 GAE广义优势估计的方法。单步误差写成δ_t r_t γ * V(s_{t1}) - V(s_t)如果只看这一步偏差大因为奖励中很多信息还没完全体现如果一直累加到轨迹结束方差大因为一条轨迹里的随机性会被完整保留。GAE 的做法是对多步 TD 误差做指数加权从而在偏差和方差之间取一个折中A_t δ_t γλ * δ_{t1} (γλ)^2 * δ_{t2} ...λ0 时它退化成一步 TDλ1 时它退化成蒙特卡洛优势。实践中 λ 通常取 0.95对多数任务都比较稳。GAE 的实现有个细节特别容易写错要从后往前累加。因为 δ_t 依赖 V(s_{t1})所以需要先拿到完整的轨迹再从轨迹末端向前算。如果在环境交互过程中边走边算很容易出现信息泄漏或漏掉末尾状态。2.4 Critic 的损失与整体训练目标PPO 除了策略网络还需要一个价值网络来输出状态价值 V(s)这个网络通常叫 Critic。Critic 的学习目标是让 V(s_t) 尽量接近回报序列的累计期望。常见做法是回归目标用returns_t A_t V(s_t)其中 A_t 是 GAE 算出的优势V(s_t) 是旧 Critic 对当前状态的估计。这样 Critic 的损失就是简单的均方误差value_loss MSE(V_new(s_t), A_t V(s_t))注意策略网络和价值网络既可以是两个独立网络也可以共享一部分底层特征。我自己的建议是先分开。独立网络结构简单梯度互不干扰排查问题时能明确知道是策略部分出了问题还是价值部分出了问题。共享参数虽然在视觉任务里能省不少计算但训练时价值损失和策略损失同时往同一个网络回传很容易相互拉扯新手调试起来会很痛苦。最终总损失一般这样组合total_loss policy_loss value_coef * value_loss - entropy_coef * entropy熵项是鼓励策略保持探索后面调参部分会再展开。3. 用 Python 从零写 PPO3.1 整体架构采样、优势计算、更新写 PPO 最好不要把所有代码塞进一个文件里至少要分成三个逻辑模块环境交互采样、GAE 计算、策略更新。这样每块都能单独验证。完整的 PPO 训练循环大致是class PPOAgent: def __init__(self, obs_dim, act_dim): self.policy PolicyNet(obs_dim, act_dim) self.value ValueNet(obs_dim) def act(self, obs): # 返回动作、log_prob、状态价值 action, log_prob self.policy.sample_action(obs) value self.value(obs) return action, log_prob, value训练时先用当前策略在环境里采集足够多的 transition采样步数我习惯用 2048。然后把这批数据转成 batch计算 GAE 优势再在这个固定 batch 上做多个 epoch 的小批量梯度下降。这种做法是 on-policy 方法里非常标准的“经验复用”但前提是数据不能跨太多更新轮次否则重要性采样比值会失真。3.2 策略网络与 Critic 网络定义这里我不绑定具体深度学习库因为 PPO 的网络结构并不复杂你用自己熟悉的工具写都行。关键是输入的观察维度、输出动作维度要对得上。离散环境下策略网络最后一层输出各动作的 logits然后通过 softmax 得到动作概率分布。class PolicyNet: def __init__(self, obs_dim, act_dim): self.fc1 Layer(obs_dim, 64) self.fc2 Layer(64, 64) self.logits Layer(64, act_dim) def forward(self, obs): h activate(self.fc1(obs)) h activate(self.fc2(h)) return self.logits(h)Critic 网络结构类似只是最后一层输出 1 个标量。注意 Critic 输出的是状态价值不是动作价值所以它是从状态到价值的映射。class ValueNet: def __init__(self, obs_dim): self.fc1 Layer(obs_dim, 64) self.fc2 Layer(64, 64) self.v Layer(64, 1) def forward(self, obs): h activate(self.fc1(obs)) h activate(self.fc2(h)) return self.v(h)这个网络规模很小但对网格世界、简单连续控制任务已经足够。如果环境观测是高维图像可以加深网络或者加入卷积层但调参难度也会同步上升。3.3 网格世界环境一个不依赖第三方包的最小实现为了验证 PPO 逻辑我写了一个最简单的 5×5 网格世界。起点在左下角终点在右上角动作只有上下左右四个方向。每走一步有微小负惩罚到达终点获得 1 分。全部代码不依赖任何环境库直接跑。class GridWorld: def __init__(self, n5): self.n n self.goal [0, n - 1] def reset(self): self.pos [self.n - 1, 0] return self._obs() def _obs(self): row, col self.pos return [row / self.n, col / self.n] def step(self, action): row, col self.pos if action 0: # 上 row max(0, row - 1) elif action 1: # 下 row min(self.n - 1, row 1) elif action 2: # 左 col max(0, col - 1) elif action 3: # 右 col min(self.n - 1, col 1) self.pos [row, col] done (self.pos self.goal) reward 1.0 if done else -0.01 return self._obs(), reward, done事实证明这个环境很小但足够暴露 PPO 实现里的问题。比如如果 GAE 算错策略会一直原地打转如果熵正则完全不加策略可能过早锁死在一个次优路线上。3.4 轨迹采样与 GAE 计算PPO 的采样和更新是分开的。每轮先采集固定步数的轨迹记录观察、动作、奖励、是否终止、策略对数概率、Critic 输出的价值。这里我还会额外记录下一状态的价值便于计算 TD 误差。def collect_trajectories(agent, env, steps): obs_list [] act_list [] rew_list [] done_list [] val_list [] logp_list [] obs env.reset() for _ in range(steps): action, logp, value agent.act(obs) next_obs, reward, done env.step(action) obs_list.append(obs) act_list.append(action) rew_list.append(reward) done_list.append(done) val_list.append(value) logp_list.append(logp) obs env.reset() if done else next_obs return obs_list, act_list, rew_list, done_list, val_list, logp_listGAE 要从轨迹末尾往前计算。为了让最后一步也能算增量我在采样时额外记录每个时刻的next_value终止状态的 next_value 直接设为 0。实际写起来可以这样def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages [] gae 0.0 for t in reversed(range(len(rewards))): if dones[t]: delta rewards[t] - values[t] else: next_value values[t 1] if t 1 len(values) else 0.0 delta rewards[t] gamma * next_value - values[t] gae delta gamma * lam * gae * (1 - dones[t]) advantages.insert(0, gae) returns [adv v for adv, v in zip(advantages, values)] return advantages, returns这里的(1 - dones[t])是关键一条轨迹结束时后面不能再累积任何优势必须把 GAE 清零。很多实现错误地忽略了这一点导致终止状态的优势被前一条轨迹的错误信息污染。3.5 PPO 更新循环Clip Loss 与训练结果进入更新阶段后我从采样 batch 中反复取小批量数据用当前策略重新计算 log_prob再和采样时的旧 log_prob 做比值。def ppo_loss(policy, value, batch, eps0.2): obs batch[obs] actions batch[actions] old_logp batch[logp] advantages batch[adv] returns batch[returns] advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) new_logp policy.log_prob(obs, actions) ratio (new_logp - old_logp).exp() clip_ratio ratio.clamp(1 - eps, 1 eps) policy_loss -(torch.min(ratio * advantages, clip_ratio * advantages)).mean() value_loss ((value(obs).squeeze() - returns) ** 2).mean() entropy policy.entropy(obs).mean() return policy_loss, value_loss, entropy注意代码中我用了torch风格实际实现时换成你的框架即可逻辑是一致的。策略损失取负号是因为目标是最大化优化器默认是最小化。裁剪部分严格按原始公式来比值超过 1eps 或低于 1-eps 时对应的梯度会被封住。我在这套网格世界上跑了 1500 轮更新固定随机种子后最近 100 个 episode 的平均回报从接近 0 涨到了 0.9 左右。训练趋势大致如下更新轮数最近 100 个 episode 平均回报平均步数00.02963000.35426000.61199000.791312000.918这个结果并不惊艳但它证明了整条链路是通的策略能学到接近最优的走法Critic 的估计在逐步收敛GAE 没有把优势算错。4. PPO 调参与失败排查实录4.1 参数默认值和推荐组合PPO 有很多超参数但并不是每个都值得反复调。我通常先固定下面这组默认值然后再根据具体任务微调参数默认值作用建议gamma0.99折扣因子任务长期回报越重要越接近 1lam0.95GAE 权衡0.9 到 0.99 之间clip0.2裁剪范围0.1 到 0.3波动大可以调小lr3e-4Adam 学习率一般不用超过 1e-3ppo_epochs4同一批数据重复更新次数数据少可以减到 2batch_size64小批量大小小环境可以到 128entropy_coef0.0 或 0.01探索奖励权重离散任务建议从 0.01 开始这套组合几乎可以跑通大多数小规模任务。如果发现训练初期策略熵快速下降我会把 entropy_coef 调大一点如果训练中期回报曲线震荡剧烈我会优先调小学习率而不是动 clip。4.2 优势归一化到底该不该加很多人问 advantages 到底要不要减去均值除以标准差。我的答案是大多数时候要做。advantage 本身是一个相对量表示动作相对于平均水平的优势。但实际算出来的数值尺度可能很乱特别是奖励数值本身很大或很小时优势会跟着大几个数量级导致梯度更新步长不稳定。减均值除以标准差可以让优势保持在一个以 0 为中心、尺度接近 1 的分布上训练更稳。这里有个细节归一化必须在同一个 batch 内进行不能用全局的滑动均值。因为 PPO 的每次更新都是基于当前 batch 的统计量如果混入历史统计会破坏 batch 内相对比较。我见过一些人把优势归一化做成了类似 BatchNorm 的全局累积结果策略更新方向被历史数据带偏训练一直不收敛。4.3 熵正则不是加得越多越好熵项的作用是让策略不要过早变得过于自信。离散动作环境下如果策略网络直接输出 one-hot 般的高置信度分布探索会迅速停止很多网格世界的路径就无法被发现。这时加一点熵奖励是有帮助的。但熵系数太大会让策略一直停留在随机状态。因为任何确定性策略的熵都很小优化器为了最大化熵会故意让策略分布变平这和任务回报方向直接冲突。我一般从 0.01 开始调发现策略坍缩就加到 0.05发现回报上不去就减到 0.001最多在这个区间里试两三次。4.4 我踩过的几个典型坑第一个坑是把 old_logp 写错。有些实现会在 batch 里保存一个“动作概率”更新时却直接拿这个概率和新概率做比值忽略了概率和 log_prob 之间的转换。正确做法是保存采样时的 log_prob更新时重新计算同一批动作在当前策略下的 log_prob然后用ratio exp(new_logp - old_logp)。第二个坑是 Critic 的 target 没有 detach。如果价值网络和策略网络是分开的这个问题不明显。但如果共享网络价值 loss 的梯度会穿过 returns 里的 V(s_t) 回流到策略分支导致策略梯度被价值梯度污染。解决方法是把 target 里的旧价值用 stop_gradient 或 detach 处理。第三个坑是训练曲线看起来很漂亮实际上策略已经坍缩。loss 下降不代表性能够好因为 PPO 的政策损失可以通过“把概率推得更极端”来压得更低哪怕回报没有提升。所以一定要同时看平均回报、平均步数、策略熵、ratio 的分布这几个指标只盯一个 loss 曲线会把人带偏。4.5 训练诊断不只是盯 loss 曲线我自己在训练脚本里会加一个诊断函数每隔几十步输出一次这些指标def diagnose(batch, policy, value): ratio (policy.log_prob(batch[obs], batch[actions]) - batch[logp]).exp() adv batch[adv] print(fratio: min{ratio.min():.3f} mean{ratio.mean():.3f} max{ratio.max():.3f}) print(fadv: mean{adv.mean():.3f} std{adv.std():.3f}) print(fvalue_loss: {value_loss:.4f})ratio 普遍在 0.8 到 1.2 之间说明策略更新比较温和。如果某次更新后 ratio 最大值超过 5几乎可以肯定策略已经跑偏。这时候我会停下来检查学习率和裁剪范围而不是继续等训练自己恢复。5. 从 PPO 走向更多变体5.1 连续动作空间把 Softmax 换成高斯离散环境里的 PPO 可以直接替换成连续版本核心改动在策略网络的输出层。离散动作是用 softmax 输出每个动作的概率连续动作则通常输出动作均值和一个对数标准差动作从高斯分布中采样。class GaussianPolicy: def __init__(self, obs_dim, act_dim): self.fc1 Layer(obs_dim, 64) self.fc2 Layer(64, 64) self.mean Layer(64, act_dim) self.log_std Parameter(zeros(act_dim)) def forward(self, obs): h activate(self.fc1(obs)) h activate(self.fc2(h)) mu self.mean(h) return mu, self.log_std.exp()连续动作的 log_prob 需要根据高斯分布公式计算-0.5 * ((a - mu) / std)^2 - log_std - 常数。熵类似地使用0.5 * (1 log(2π)) log_std。唯一要小心的是 log_std 不能降得太低否则策略会退化成近乎确定性策略。我习惯把 log_std 初始化为 0再让网络自己学并且对标准差的计算加一个下限约束。5.2 更工程化的方向PPO 在玩具任务上跑通之后还有很多值得做的工程化改进。比如并行采样多个环境来降低数据相关性比如使用共享特征提取层来处理图像输入比如在连续控制任务中加入奖励缩放和状态归一化。这些改进本质上都是在处理同一个问题让策略更新在更大、更复杂的环境里保持稳定。如果直接跳到复杂任务才开始考虑这些问题很容易把调参时间浪费在“到底是环境问题还是算法问题”的排查上。所以我的建议永远是先在可以秒级重启的小环境里验证核心逻辑再一步步叠加工程能力。5.3 一个高效 debug 习惯最后分享一个我用了很久的调试习惯在跑任何 PPO 实验前先把随机种子固定死并且把环境的最大步数限制明确写出来。然后训练脚本里同时记录三个文件返回序列、策略熵变化、ratio 统计。这样即使训练崩了也能立刻判断是探索消失、更新步长过大还是环境本身有问题。很多看起来“玄学”的强化学习训练问题最后其实都能归结到这三个信号里。我自己的体会是PPO 并不是一个“调参越复杂越好”的算法。先把网络、GAE、裁剪目标这三件核心事写对比堆一堆高级技巧更有用。等基础版本在玩具任务上稳定了再慢慢加入并行采样、奖励缩放、熵正则这些东西会轻松很多。希望这篇能把理论和代码之间那段模糊地带填平。