
强化学习这块我踩过的坑不算少但真正让我从“调参玄学”里爬出来的是认真把 PPO 的每一行损失函数拆开看了一遍。今天这篇不打算写成教科书就按我自己从看懂公式到跑通训练、再到踩坑排查的顺序把 PPO 这个算法从头到尾捋一遍。如果你正在做深度强化学习相关的项目或者被 TRPO、A2C、DDPG 这些名字绕晕过那这篇应该能帮你省下不少翻文档的时间。PPO全称 Proximal Policy Optimization中文一般叫近端策略优化。它属于策略梯度方法里最实用的一档核心目标就一句话让策略每次更新的时候别走太远稳一点别一步跨太大把之前学的东西全毁了。它解决的问题也很明确——传统策略梯度方差大、TRPO 计算二阶导数太重、A2C 又容易更新过猛导致训练崩掉。PPO 用一个裁剪过的替代目标函数把“更新幅度”这件事用一阶方法近似地管住了工程上又好写又好调所以成了工业界和学术界默认的基线算法之一。1. 从策略梯度到 PPO为什么需要“近端”这个约束1.1 策略梯度的直觉与它的致命弱点先说清楚策略梯度到底在干什么。我们有一个策略网络 πθ(a|s)输入状态 s输出动作分布。跑完一轮环境拿到一条轨迹算出每个动作的优势值 A(s,a)然后沿着“让高优势动作概率变大”的方向去更新参数。最朴素的公式就是∇J(θ) E[ ∇log πθ(a|s) * A(s,a) ]这个公式本身没毛病但它有个很要命的问题更新步长完全不受控。你这一批数据里如果恰好有几个优势值特别大的样本梯度就会很大参数一步跨出去策略分布直接变形。下一轮采样的时候新策略可能已经跑到一个完全陌生的区域采出来的数据质量暴跌然后优势估计也跟着崩整个训练就像多米诺骨牌一样倒下去。我最早用 A2C 的时候就遇到过这种情况前 200 轮 reward 涨得好好的突然某一轮开始断崖式下跌怎么调学习率都救不回来。后来才明白这不是学习率的问题是新旧策略之间的分布差异没有被约束。策略梯度理论上是要求用当前策略采样的数据来更新当前策略但你更新完一步之后采样用的策略和更新用的策略就不是同一个了这个偏差会累积。1.2 TRPO 的思路与它的工程代价TRPOTrust Region Policy Optimization就是冲着这个问题去的。它的想法很直接我允许你更新但新旧策略之间的 KL 散度不能超过一个阈值 δ。数学上写成约束优化问题maximize E[ πθ(a|s)/πθ_old(a|s) * A(s,a) ] subject to E[ KL(πθ_old || πθ) ] δ这个约束保证了每次更新后策略不会跑太偏理论上单调性有保证。但问题在于求解这个带约束的优化需要计算 Fisher 信息矩阵和它的逆也就是二阶导数那一套。参数量一大计算量和内存直接爆炸。而且实现起来要用共轭梯度法代码复杂度高调起来也麻烦。我试过在中等规模的网络上跑 TRPO训练速度大概只有 PPO 的三分之一显存占用还高出一截。1.3 PPO 的取舍用裁剪代替约束PPO 的聪明之处在于它不去解那个带约束的优化而是把约束“软化”成一个惩罚项或者裁剪项直接用一阶方法就是普通的梯度下降来优化。最常用的版本是PPO-Clip核心目标函数长这样L_clip(θ) E[ min( r(θ) * A, clip(r(θ), 1-ε, 1ε) * A ) ]其中 r(θ) πθ(a|s) / πθ_old(a|s)就是新旧策略的概率比。ε 是个超参数通常取 0.1 或 0.2。这个 min 和 clip 的组合效果就是当优势 A 为正的时候概率比 r 最多涨到 1ε 就不再给梯度了当 A 为负的时候r 最多降到 1-ε 也不再给梯度。换句话说它把“更新幅度”这件事用概率比这个一阶量给框住了不需要算 KL 的二阶信息也不需要解约束优化。实测下来PPO 在大多数任务上能达到 TRPO 九成以上的效果但训练速度快好几倍代码量少一个数量级。注意ε 不是越大越好。我见过有人为了加快收敛把 ε 设到 0.5结果训练极不稳定。ε 的本质是“信任域半径”设大了就等于没约束退化成普通策略梯度。2. PPO 核心细节拆解那些文档里不会写清楚的地方2.1 优势函数估计GAE 到底怎么算PPO 的效果很大程度上取决于优势函数 A 估得准不准。最常用的是 GAEGeneralized Advantage Estimation它用一个参数 λ 在偏差和方差之间做权衡。具体计算是反向递推的# 假设 rewards 和 values 是长度 T 的数组dones 是终止标志 deltas rewards gamma * values[1:] * (1 - dones) - values[:-1] advantages np.zeros_like(rewards) last_gae 0 for t in reversed(range(T)): last_gae deltas[t] gamma * lambda_ * (1 - dones[t]) * last_gae advantages[t] last_gae returns advantages values[:-1]这里有几个细节值得说。第一dones的处理很关键如果某一步是终止状态那么 bootstrap 项必须置零否则会把下一个 episode 的价值错误地接上来。第二λ 取 0 就是单步 TD 误差偏差大方差小λ 取 1 就是蒙特卡洛回报方差大偏差小。实践中 λ0.95 是个很稳的默认值我基本没怎么改过。第三returns是 advantages 加 values用来做价值网络的回归目标这个别搞混了。2.2 裁剪机制的双重保险min 和 clip 为什么要一起用很多人第一次看 PPO 的公式会疑惑既然已经 clip 了为什么外面还要套一个 min直接写 clip(r, 1-ε, 1ε) * A 不行吗不行。原因在于 clip 函数是单侧的。考虑 A 为正的情况如果 r 已经大于 1εclip 之后变成 1ε梯度为零这没问题。但如果 r 小于 1-ε 呢clip 之后变成 1-ε而 1-ε 是小于 r 的这时候 clip 反而给出了一个正的梯度鼓励策略继续往这个方向走。这显然不对因为 r 太小说明新策略在这个动作上的概率已经比旧策略低很多了不应该再被鼓励。加上 min 之后取的是原始项和裁剪项中较小的那个。当 A 为正且 r 很小时原始项 r*A 比裁剪项 (1-ε)*A 小min 取原始项梯度正常当 A 为正且 r 很大时原始项比裁剪项大min 取裁剪项梯度被截断。这样就实现了双侧的、对称的约束。这个设计很精妙我第一次看懂的时候确实拍了下桌子。2.3 价值网络损失与熵正则两个容易被忽视的配角PPO 的总损失通常由三部分组成L_total L_clip - c1 * L_value c2 * L_entropyL_value是价值网络的均方误差c1一般取 0.5。L_entropy是策略熵用来鼓励探索c2一般取 0.01 或者更小。这两个系数看起来不起眼但实际调的时候影响很大。价值网络如果学得太快优势估计会变得很尖锐策略更新就会很激进学得太慢优势估计噪声大训练效率低。我一般会把价值损失单独监控如果它比策略损失大一个数量级以上就会考虑调低 c1 或者给价值网络单独设一个更小的学习率。熵正则也是任务探索需求高的时候可以适当调大但调太大策略会一直保持随机收敛不了。实操心得我习惯把 clip 比例、价值损失、熵、KL 散度这四个量都打到 TensorBoard 上。KL 散度如果持续大于 0.02 左右说明每次更新步子偏大可以考虑调小 ε 或者减少每批数据的训练轮数。3. 完整实操流程从零搭一个能跑的 PPO3.1 环境与网络结构的选择我以经典的 CartPole 和 LunarLander 为例这两个环境足够简单方便快速验证代码正确性又足够有代表性能看出算法是否真的在工作。网络结构上策略网络和价值网络可以共享底层特征也可以完全分开。共享的话参数少、训练快但两个任务的梯度会互相干扰分开的话更稳但参数多。我的经验是状态空间是低维向量的时候共享底层通常没问题状态是图像的时候强烈建议分开因为卷积层的特征提取对两个任务的偏好差异很大。下面是一个共享底层的简单实现import torch import torch.nn as nn class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, hidden64): super().__init__() self.shared nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh() ) self.actor nn.Linear(hidden, action_dim) self.critic nn.Linear(hidden, 1) def forward(self, x): feat self.shared(x) logits self.actor(feat) value self.critic(feat) return logits, value激活函数用 Tanh 而不是 ReLU是因为策略网络的输出对数值稳定性比较敏感ReLU 的死亡神经元问题在策略梯度里会被放大。这个细节很多教程不会提但实测下来 Tanh 在连续控制任务上确实更稳。3.2 数据采集与批次组织PPO 是 on-policy 算法数据采完一批、更新几轮之后就要丢掉重新采。典型的流程是用当前策略跑 N 个 step比如 2048 或 4096把 (state, action, log_prob, reward, done, value) 存下来。计算 GAE 优势和回报。把数据打乱分成若干个 minibatch。对每个 minibatch计算 PPO 损失反向传播更新参数。重复第 4 步 K 轮比如 10 轮然后回到第 1 步。这里有个容易踩的坑log_prob 必须用采样时的旧策略计算并且要 detach。如果你在更新的时候重新用新策略算 log_prob 去和旧 log_prob 比那概率比就恒等于 1整个裁剪机制就失效了。我见过有人在这里写错训练曲线看起来正常但实际上是退化成普通策略梯度了。# 采样时 with torch.no_grad(): logits, value model(state) dist torch.distributions.Categorical(logitslogits) action dist.sample() log_prob dist.log_prob(action) # 这个要存下来 # 更新时 logits, value model(state) dist torch.distributions.Categorical(logitslogits) new_log_prob dist.log_prob(action) ratio torch.exp(new_log_prob - old_log_prob) # old_log_prob 是存下来的3.3 损失计算与参数更新完整的损失计算大概是这样def ppo_loss(old_log_prob, new_log_prob, advantage, value, old_value, entropy, clip_eps0.2, vf_coef0.5, ent_coef0.01): ratio torch.exp(new_log_prob - old_log_prob) surr1 ratio * advantage surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * advantage policy_loss -torch.min(surr1, surr2).mean() value_loss nn.functional.mse_loss(value, old_value advantage) entropy_loss -entropy.mean() total_loss policy_loss vf_coef * value_loss ent_coef * entropy_loss return total_loss, policy_loss, value_loss, entropy_loss注意优势函数在计算损失之前通常要做标准化也就是减均值除标准差。这一步对训练稳定性影响很大尤其是奖励尺度变化大的任务。我试过在 LunarLander 上不做标准化训练直接不收敛加上之后很快就稳定了。注意优势标准化是在整个 batch 上做的不是每个 minibatch 单独做。如果你在 minibatch 层面做不同 minibatch 的尺度不一致反而会引入噪声。3.4 训练循环与超参数配置下面是我常用的一套超参数在 CartPole、LunarLander、以及几个简单的 MuJoCo 任务上都能跑超参数取值说明学习率3e-4Adam 优化器策略和价值共享采样步数2048每个更新周期采集的 step 数minibatch 大小64太大更新次数少太小噪声大更新轮数 K10同一批数据重复训练的轮数clip ε0.2信任域半径GAE λ0.95偏差方差权衡折扣 γ0.99长期回报折扣价值系数 c10.5价值损失权重熵系数 c20.01探索鼓励权重梯度裁剪0.5防止梯度爆炸学习率 3e-4 是 Adam 在策略梯度里的经典取值我基本没怎么动过。采样步数 2048 是个平衡点太小的话每批数据不够优势估计噪声大太大的话更新频率低样本效率下降。更新轮数 K10 意味着同一批数据被反复用 10 次这是 PPO 样本效率比普通策略梯度高的关键但 K 太大会导致新旧策略差异过大裁剪机制频繁触发反而学不动。4. 常见问题与排查技巧实录4.1 训练不收敛或奖励震荡这是最常见的问题原因可能有很多。我一般按下面的顺序排查现象可能原因排查方法解决方式奖励一直不涨学习率过大或过小打印梯度范数调到 1e-4 到 1e-3 之间试奖励先涨后崩更新步长过大监控 KL 散度调小 ε 或减少 K奖励剧烈震荡优势估计方差大检查 GAE 计算调小 λ 或增大 batch策略熵快速下降熵正则太小打印熵曲线调大 c2价值损失不下降价值网络学习率不匹配单独监控价值损失给价值网络单独设学习率我印象最深的一次是 LunarLander 上奖励在 200 左右反复横跳查了两天才发现是 done 的处理有问题着陆成功和坠毁都算终止但我在计算 GAE 的时候没有区分导致 bootstrap 项把坠毁后的价值错误地传播回来了。改完之后训练曲线立刻平滑了。4.2 概率比异常与裁剪触发频率概率比 r 如果偏离 1 太远说明新旧策略差异过大。正常情况下r 应该在 0.8 到 1.2 之间波动。如果你发现 r 经常跑到 2 以上或者 0.5 以下说明更新太激进了。这时候可以减小学习率减小 K每批数据的更新轮数减小 ε但注意 ε 太小会导致学习缓慢检查优势标准化是否正确我一般会在训练脚本里加一个统计记录每个 batch 里被裁剪的样本比例。如果这个比例长期高于 30%就说明裁剪机制在频繁起作用策略更新被过度限制需要调参了。4.3 不同任务上的适配经验PPO 在离散动作和连续动作上的实现略有不同。离散动作用 Categorical 分布连续动作用 Gaussian 分布输出均值和标准差。连续动作的标准差有两种处理方式一种是作为可学习参数和网络一起训练另一种是用状态相关的函数输出。前者简单后者表达能力强但容易不稳定。我在连续控制任务上一般用可学习的 log_std并且给它设一个下限比如 -20 到 2 之间防止标准差变得过大或过小。标准差过大会导致动作完全随机过小会导致探索不足。这个细节在原始论文里没有强调但在实际项目中非常关键。实操心得如果你的任务奖励尺度很大比如几千几万一定要做奖励缩放。我通常会把奖励除以一个运行中的标准差估计或者直接手动缩放到 [-10, 10] 左右。奖励尺度不统一是训练不稳定的隐形杀手。4.4 并行环境与训练加速单环境采样是 PPO 训练速度的瓶颈。我一般会用 8 到 16 个并行环境同时采样把数据拼成一个 batch 再更新。并行环境可以是多进程也可以是多线程取决于环境本身是否释放 GIL。对于 Gym 环境用SubprocVecEnv多进程通常效果最好。并行环境数不是越多越好。环境数太多的话每个环境采到的步数少episode 可能没跑完就被截断优势估计会受影响。我的经验是每个环境至少采到 128 步以上再汇总这样 GAE 的递推才有足够长度。如果环境数乘以每环境步数等于总采样步数那总采样步数保持在 2048 到 8192 之间比较合适。5. PPO 的变体与扩展方向5.1 从 PPO 到离线强化学习PPO 是 on-policy 的数据用完就丢样本效率是它的短板。如果你手头有一批离线数据想直接拿来训练那就得看 IQL、CQL 这些离线强化学习算法了。它们的核心思路是在策略改进的时候加一个保守项防止策略跑到数据分布之外。PPO 的裁剪机制其实和这个思路有相通之处都是在限制策略更新的幅度只不过一个是在线场景一个是离线场景。5.2 因果强化学习的启发最近因果强化学习Causal RL比较热核心想法是把因果推断的工具嵌入到强化学习流程里区分哪些是动作真正导致的奖励变化哪些是环境本身的混淆因素。这个思路对 PPO 也有启发如果优势函数估计里混入了混淆因素的偏差裁剪机制再稳也没用。我目前还在看这方面的论文实际落地经验不多但感觉在那些环境随机性大、奖励稀疏的任务上因果推断可能会帮上忙。5.3 多智能体与路径规划场景PPO 在多智能体路径规划里也有不少应用比如多 AGV 的调度问题。这种场景下每个 AGV 是一个智能体状态空间包含自身位置、目标位置、其他 AGV 的位置动作是移动方向。PPO 的稳定性在这里很重要因为智能体之间的交互会让奖励信号变得非常嘈杂。我试过在 Gazebo 仿真里跑多 AGV 的 PPO 训练最大的感受是奖励函数设计比算法本身更关键碰撞惩罚、到达奖励、时间惩罚的权重配比直接决定了学出来的策略是激进还是保守。最后再分享一个小技巧如果你在调试 PPO 的时候不确定代码写得对不对可以先在一个极简环境比如只有两个状态的 bandit 问题上跑确认策略能收敛到正确动作再上复杂环境。这个“从最小可复现例子开始”的习惯帮我省下了大量排查时间。PPO 的公式看起来复杂但拆开之后每一步的意图都很清晰理解了裁剪机制背后的“信任域”思想剩下的就是工程细节的打磨了。