ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从策略梯度到PPO:核心机制、代码实现与调参实战

从策略梯度到PPO:核心机制、代码实现与调参实战 强化学习这条路上PPOProximal Policy Optimization近端策略优化几乎是绕不开的一座山。不管你是做游戏AI、机器人控制、推荐系统还是最近火起来的大模型对齐训练PPO都稳稳地坐在核心算法的位置上。但说实话我见过太多人第一次啃PPO时的状态公式看了一遍觉得懂了代码跑了一遍发现loss不降调了几天参数最后放弃治疗。问题不在于PPO本身有多难而在于大多数资料一上来就甩公式跳过了“为什么需要PPO”这个最关键的前置问题。这篇文章我打算换个讲法从策略梯度为什么会崩、TRPO为什么难用讲起把PPO的设计动机、核心机制、代码实现和调参经验串成一条线让你不只是“看懂”而是能真正跑起来、调得动。1. 从策略梯度到PPO为什么需要“近端”这个约束1.1 朴素策略梯度的致命缺陷策略梯度的核心思想很直白如果某个动作带来了高回报就增大它被选中的概率反之则减小。用数学表达就是最大化期望回报 $J(\theta) E_{\tau \sim \pi_\theta}[R(\tau)]$然后对参数求梯度做上升。这个思路本身没问题但实际操作中有一个非常要命的特性——步长极其敏感。你可以这样理解策略梯度是在“策略空间”里走一步但策略空间不是平坦的。参数 $\theta$ 稍微变一点点输出动作的概率分布可能发生剧烈变化。如果这一步迈大了新策略可能跑到一个完全陌生的区域采集到的数据分布和旧策略差异巨大梯度估计就彻底失准了。更糟糕的是一旦策略变差后续采集的数据更差形成恶性循环loss直接爆炸再也拉不回来。我在早期用REINFORCE做CartPole的时候就遇到过这个情况学习率设成0.01前几百个episode看着还行突然某一轮回报断崖式下跌之后就再也没恢复过。当时以为是网络结构问题换了好几版都没用后来才意识到是步长太大导致策略崩溃。1.2 TRPO的解决方案与工程困境TRPOTrust Region Policy Optimization就是冲着这个问题去的。它的思路是每一步更新时限制新旧策略之间的KL散度不超过一个阈值 $\delta$。这样就能保证新策略不会跑得太远数据分布不会偏移太多。数学上它解决了一个带约束的优化问题用共轭梯度加线搜索来求解理论上很漂亮。但工程上TRPO的麻烦程度让人头疼。它需要计算Fisher信息矩阵向量积、做共轭梯度迭代、还要线搜索保证约束满足。实现复杂不说计算开销也大而且和 dropout、参数共享这些常见技巧配合起来很别扭。我试过在Atari环境上跑TRPO光调试约束满足就花了两天训练速度还比预期慢不少。1.3 PPO的取舍用裁剪替代硬约束PPO的思路非常务实既然TRPO的硬约束难搞那我用一个更简单的方式来近似这个约束。具体来说PPO提出了两种方案最终裁剪Clip方案成为主流。它的核心操作是在目标函数里做文章。原来的策略梯度目标是 $L E[\frac{\pi_\theta(a|s)}{\pi_{old}(a|s)} \cdot A]$其中比值 $r_t(\theta) \frac{\pi_\theta}{\pi_{old}}$ 衡量新旧策略对同一动作的偏好差异。PPO把这个比值裁剪到 $[1-\epsilon, 1\epsilon]$ 区间内目标函数变成$$L^{CLIP} E[\min(r_t \cdot A, \text{clip}(r_t, 1-\epsilon, 1\epsilon) \cdot A)]$$这个 $\min$ 加 $\text{clip}$ 的组合很巧妙。当优势 $A 0$动作好时如果比值超过 $1\epsilon$梯度就截断不再鼓励继续增大这个动作的概率当 $A 0$动作差时如果比值低于 $1-\epsilon$同样截断不再鼓励继续减小。这样就把策略更新限制在了一个“信任区域”内但实现上只需要几行代码。这里有个容易混淆的点裁剪的是“比值”不是“概率”。很多人第一次看公式会以为在裁剪动作概率其实不是。比值是相对量衡量的是新旧策略的差异程度这才是“近端”的真正含义。2. Actor-Critic架构PPO的骨架怎么搭2.1 为什么PPO必须用Actor-CriticPPO属于Actor-Critic家族这不是随便选的。纯策略梯度方法用蒙特卡洛采样估计回报方差极大需要大量样本才能稳定。而Actor-Critic引入一个价值网络Critic来估计状态价值 $V(s)$用TD误差或者GAE来计算优势函数方差显著降低。Actor负责输出动作概率分布Critic负责评估当前状态值多少钱。两者共享或者不共享底层特征都可以实践中是否共享要根据任务来定。Atari这种视觉输入任务通常共享卷积层而一些低维状态任务分开建网络反而更稳定。2.2 优势函数估计GAE的来龙去脉优势函数 $A(s,a) Q(s,a) - V(s)$ 衡量的是“在状态s下选动作a比平均水平好多少”。直接估计Q值需要另一个网络不够优雅。PPO通常用GAEGeneralized Advantage Estimation来计算优势。GAE的核心是一个衰减参数 $\lambda$。当 $\lambda0$ 时优势估计退化为单步TD误差 $r \gamma V(s) - V(s)$偏差大但方差小当 $\lambda1$ 时退化为蒙特卡洛回报减去基线方差大但偏差小。GAE通过指数加权平均在两者之间取平衡$$\hat{A}t^{GAE} \sum{l0}^{\infty} (\gamma\lambda)^l \delta_{tl}$$其中 $\delta_t r_t \gamma V(s_{t1}) - V(s_t)$ 是TD误差。实践中 $\lambda$ 通常取0.95$\gamma$ 取0.99这组值在大多数任务上表现稳健。我自己的经验是GAE的 $\lambda$ 比学习率更值得先调。在一些稀疏奖励任务里把 $\lambda$ 从0.95降到0.9训练稳定性会有明显提升因为降低了方差。而在奖励密集的任务里$\lambda$ 可以适当调高到0.97甚至0.98让优势估计更准确。2.3 重要性采样PPO能复用数据的关键PPO是on-policy算法理论上只能用当前策略采集的数据。但实际训练中如果每采集一批数据就更新一次然后扔掉样本效率太低了。PPO的做法是用当前策略采集一批数据然后对这批数据进行多轮epoch更新。这就用到了重要性采样。重要性采样的公式是 $E_{x \sim p}[f(x)] E_{x \sim q}[\frac{p(x)}{q(x)} f(x)]$其中 $p$ 是目标分布$q$ 是采样分布。在PPO里$p$ 是新策略$q$ 是采集数据时的旧策略比值 $r_t(\theta)$ 就是重要性权重。但重要性采样有个前提两个分布不能差太远否则权重方差会爆炸。这正是PPO要裁剪比值的原因——裁剪就是在控制新旧策略的差异保证重要性采样的有效性。所以你看裁剪和重要性采样是一体两面的理解了这层关系PPO的设计逻辑就通了大半。3. 手把手实现PPO从网络结构到训练循环3.1 网络结构设计的关键决策先确定网络输出什么。Actor网络输出动作分布的参数离散动作输出每个动作的logits连续动作输出高斯分布的均值和标准差。Critic网络输出一个标量即状态价值 $V(s)$。标准差的处理有个细节连续动作任务中标准差可以是固定的、也可以作为可学习参数。我建议把标准差作为独立于状态的可学习参数而不是让网络根据状态输出。原因是让网络输出标准差容易导致训练初期方差过大或过小不稳定。独立参数初始化成-0.5即标准差约0.6通常是个不错的起点。import torch import torch.nn as nn import torch.nn.functional as F class ActorCritic(nn.Module): def __init__(self, state_dim, action_dim, continuousFalse): super().__init__() self.continuous continuous self.shared nn.Sequential( nn.Linear(state_dim, 64), nn.Tanh(), nn.Linear(64, 64), nn.Tanh() ) self.actor_mean nn.Linear(64, action_dim) self.critic nn.Linear(64, 1) if continuous: self.log_std nn.Parameter(torch.ones(action_dim) * -0.5) def forward(self, x): feat self.shared(x) if self.continuous: mean self.actor_mean(feat) std self.log_std.exp().expand_as(mean) return mean, std, self.critic(feat) else: logits self.actor_mean(feat) return logits, self.critic(feat)激活函数用Tanh而不是ReLU这在PPO里几乎是默认选择。原因是ReLU在负半轴梯度为零容易导致部分神经元“死亡”而PPO对策略更新的稳定性要求高Tanh的平滑性更友好。3.2 数据采集与缓冲区管理PPO的数据采集流程是用当前策略跑若干个环境步或者若干个完整episode把 $(s, a, r, s, done, log_prob, value)$ 存进缓冲区。这里有个关键点必须存储旧策略的log概率因为后续计算重要性比值时需要它。缓冲区大小rollout length是个重要超参数。太小了每批数据更新几次就没了样本效率低太大了旧策略和新策略差异过大重要性采样失效。实践中rollout length通常设为2048或4096步对于简单任务可以小一些复杂任务需要更大。class RolloutBuffer: def __init__(self): self.states [] self.actions [] self.rewards [] self.dones [] self.log_probs [] self.values [] def store(self, s, a, r, d, lp, v): self.states.append(s) self.actions.append(a) self.rewards.append(r) self.dones.append(d) self.log_probs.append(lp) self.values.append(v) def clear(self): self.__init__()3.3 GAE计算与优势归一化数据采集完后需要反向计算GAE。这里有个容易出bug的地方done标志的处理。当 $doneTrue$ 时下一个状态的TD误差中的 $V(s)$ 应该置零因为episode已经结束没有后续回报了。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages [] gae 0 values values [0] # 最后加一个0作为终止状态的V for t in reversed(range(len(rewards))): if dones[t]: delta rewards[t] - values[t] gae delta else: delta rewards[t] gamma * values[t1] - 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计算完优势后一定要做归一化。优势的尺度直接影响梯度大小如果不归一化不同任务、不同训练阶段的优势量级差异巨大学习率根本没法统一设置。归一化就是减去均值除以标准差一行代码的事但不做的话训练很容易崩。3.4 裁剪损失与完整更新流程PPO的损失由三部分组成策略损失裁剪后的、价值损失、熵正则项。def ppo_update(model, optimizer, buffer, clip_eps0.2, epochs10, batch_size64, vf_coef0.5, ent_coef0.01): advantages, returns compute_gae( buffer.rewards, buffer.values, buffer.dones) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) states torch.FloatTensor(buffer.states) actions torch.FloatTensor(buffer.actions) old_log_probs torch.FloatTensor(buffer.log_probs) returns torch.FloatTensor(returns) advantages torch.FloatTensor(advantages) dataset_size len(states) for _ in range(epochs): indices torch.randperm(dataset_size) for start in range(0, dataset_size, batch_size): idx indices[start:startbatch_size] # 前向传播计算新策略的log_prob和value # ...根据离散/连续分别处理 ratio (new_log_probs - old_log_probs[idx]).exp() surr1 ratio * advantages[idx] surr2 torch.clamp(ratio, 1-clip_eps, 1clip_eps) * advantages[idx] policy_loss -torch.min(surr1, surr2).mean() value_loss F.mse_loss(new_values, returns[idx]) entropy_loss -entropy.mean() loss policy_loss vf_coef * value_loss ent_coef * entropy_loss optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_(model.parameters(), 0.5) optimizer.step()几个关键参数的经验值clip_eps0.2是原论文的默认值大多数任务够用epochs10表示每批数据更新10轮太多会过拟合旧数据vf_coef0.5控制价值损失权重ent_coef0.01是熵正则系数鼓励探索。梯度裁剪阈值0.5是标配防止梯度爆炸。4. 调参实战那些文档里不会写的坑4.1 学习率与裁剪范围的联动关系很多人把学习率和clip_eps当成独立参数调其实它们是有联动关系的。学习率决定了每次参数更新的幅度clip_eps决定了允许的策略变化范围。如果学习率很大但clip_eps很小梯度会被频繁裁剪实际更新幅度被压制训练变慢反过来学习率小但clip_eps大裁剪几乎不生效PPO退化成普通策略梯度稳定性优势就没了。我的经验是先固定clip_eps0.2然后调学习率。学习率从3e-4开始试如果训练曲线震荡厉害就降到1e-4如果收敛太慢就升到1e-3。确定学习率后再微调clip_eps通常在0.1到0.3之间。对于连续控制任务clip_eps可以稍小一些0.1-0.15因为连续动作空间对策略变化更敏感。4.2 训练不稳定的排查链路PPO训练不稳定是家常便饭但排查要有章法。我一般按这个顺序查第一步看价值损失。如果价值损失持续增大不收敛说明Critic学不好可能是学习率太大或者网络容量不够。这时候先单独把价值网络的学习率降下来试试。第二步看策略熵。如果熵快速降到接近零说明策略过早收敛到确定性动作探索不足。这时候增大ent_coef或者检查奖励设计是否有问题。第三步看KL散度。虽然PPO不显式约束KL但可以监控它。如果KL散度在每次更新后飙升说明新旧策略差异过大需要减小学习率或clip_eps。第四步看优势的分布。如果优势的绝对值普遍很大说明价值估计不准GAE的 $\lambda$ 可能需要调小。有个隐蔽的坑如果环境有episode长度限制比如CartPole最多500步在截断处不能简单地把done设为True。截断和真正终止是两回事截断时 $V(s)$ 不应该置零否则价值估计会有偏差。这个问题在Gymnasium的新API里用truncated标志区分老版本Gym没有这个区分需要自己处理。4.3 离散与连续动作空间的实现差异离散动作和连续动作在PPO里的处理差别不小主要体现在三个方面对比项离散动作连续动作策略输出每个动作的logits高斯分布的均值和标准差概率计算Softmax后取对数高斯分布对数概率密度熵计算$-\sum p \log p$高斯分布微分熵探索方式熵正则 采样标准差控制 采样常见问题动作概率塌缩标准差崩溃或爆炸连续动作里标准差崩溃是个高频问题。训练一段时间后标准差变得极小策略几乎确定性输出探索完全停止。解决办法有两个一是给标准差设下限比如最小0.05二是在损失里加一个对标准差的惩罚项防止它太小。# 连续动作的log_prob计算 def evaluate_continuous(mean, std, actions): dist torch.distributions.Normal(mean, std) log_probs dist.log_prob(actions).sum(dim-1) entropy dist.entropy().sum(dim-1) return log_probs, entropy4.4 并行环境与批量采样的工程优化单环境采集数据太慢PPO通常配合多个并行环境使用。做法是开N个环境实例每个环境跑一步把N个 $(s,a,r,s,done)$ 拼成一个batch。这样采集效率提升N倍而且不同环境的状态多样性更好梯度估计更准。但并行环境有个坑每个环境的episode结束时间不同。当某个环境done了需要重置它但其他环境还在继续。这时候GAE计算要特别小心每个环境的优势计算是独立的不能跨环境混在一起。实现上通常把每个环境的数据分别存储计算完GAE后再拼起来。class VecEnv: def __init__(self, env_fns): self.envs [fn() for fn in env_fns] self.num_envs len(self.envs) def step(self, actions): results [env.step(a) for env, a in zip(self.envs, actions)] # 对done的环境自动重置 for i, (_, _, done, _) in enumerate(results): if done: self.envs[i].reset() return results并行环境数量一般设为4到16太多的话每个环境分到的更新频率降低反而影响学习。CPU核心数够的话用8个环境是个比较稳妥的选择。5. PPO的变体与适用边界5.1 裁剪之外的替代方案PPO原论文其实提了两种方案除了裁剪还有自适应KL惩罚。后者的思路是设一个目标KL值如果实际KL超过目标就增大惩罚系数低于目标就减小。这个方案在某些任务上表现更好但多了一个需要调的超参数实践中用的人少。另外还有双裁剪PPOPPO with dual-clip针对负优势情况做额外裁剪防止比值过大时梯度爆炸。这个在奖励稀疏或者负奖励较多的任务里有帮助。5.2 PPO在离线强化学习中的局限PPO是on-policy算法这意味着它不能直接用在离线数据上。有人尝试把PPO改造成离线版本但效果通常不如专门设计的离线算法如IQL、CQL。原因是离线数据分布固定PPO的重要性采样比值容易失控裁剪机制也救不回来。如果你的数据是离线采集的建议直接上离线RL算法别硬套PPO。5.3 什么任务不适合PPOPPO不是万能的。以下几类任务要慎重奖励极度稀疏的任务比如只有最终成功才给奖励PPO的探索效率不够可能需要配合HERHindsight Experience Replay或者内在奖励。动作空间维度极高的任务比如连续控制上百个关节PPO的方差会很大可能需要分层强化学习或者模型基方法。需要长期规划的任务PPO的GAE本质上是短视的对于需要几十步以上推理的任务效果有限。反过来PPO在以下场景表现很好离散动作的游戏AI、中等维度的连续控制、需要快速迭代的实验性项目。它的实现简单、调参相对容易、社区资源丰富这些优势让它成为强化学习入门的首选算法之一。6. 我踩过的几个真实坑与对应解法6.1 观测归一化没做导致训练完全不动有一次在MuJoCo环境上跑PPO训练了几十万步回报一直是初始水平。查了半天网络结构、学习率、GAE参数都没问题。最后发现是观测值没有归一化——不同维度的观测值范围差异巨大有的在0.001量级有的在100量级网络根本学不动。加上RunningMeanStd归一化后训练立刻正常了。这个坑的教训是任何输入网络的数据都要检查量级。状态、奖励、优势全都要归一化。奖励归一化可以用running mean/std优势归一化用batch内的mean/std。6.2 并行环境重置逻辑写错导致价值估计偏差并行环境里当某个环境done后需要重置但重置后的第一个状态不应该和上一个episode的终止状态混在一起计算GAE。我最初的实现是把所有环境的数据按时间步拼成一个大数组结果done环境的重置状态被当成了上一个episode的延续价值估计完全错了。修复方法是给每个环境维护独立的数据流计算完GAE后再合并。6.3 熵系数设太大导致策略永远随机熵正则系数ent_coef设成0.1默认值的10倍时策略的熵一直很高动作接近均匀随机回报上不去。原因是熵奖励太大策略宁愿保持随机也不愿收敛到高回报动作。把ent_coef降到0.01后正常。这个参数的合理范围很窄0.001到0.02之间超过0.05基本就会出问题。6.4 价值函数学习率没单独设置Actor和Critic共用一个优化器时学习率是统一的。但Critic通常需要比Actor更大的学习率才能学好价值估计。我的做法是给Critic单独设一个优化器学习率是Actor的2到3倍。这个技巧在价值估计困难的任务里效果明显。actor_optimizer torch.optim.Adam(actor.parameters(), lr3e-4) critic_optimizer torch.optim.Adam(critic.parameters(), lr1e-3)6.5 梯度裁剪阈值设太大等于没裁梯度裁剪阈值设成10的时候几乎不会触发裁剪梯度爆炸照样发生。设成0.5是经过大量实验验证的稳妥值。如果发现训练仍然不稳定可以降到0.1试试但太低会拖慢学习速度。这个参数和网络深度有关浅层网络可以稍大深层网络要小一些。7. 从PPO出发的进阶路线PPO吃透之后往几个方向走都有路。想做更稳定的策略优化可以看TRPO的原始论文和ACKTR想做离线RLIQL和CQL是当前的主流选择想结合模型预测MBPO和Dreamer系列值得研究想做大模型对齐PPO的变体如DPO、GRPO是当前的热点方向。但不管往哪走PPO里学到的核心概念——重要性采样、优势估计、信任区域、裁剪机制——都是通用的。这些概念理解透了看其他算法论文会快很多。我自己从PPO入手后来看SAC、TD3、IQL的论文时发现很多设计思路是相通的迁移成本比想象中低。最后分享一个学习路径上的建议不要只跑通一个环境就觉得自己会PPO了。至少要在离散CartPole或Atari、连续MuJoCo的HalfCheetah或Hopper、稀疏奖励Fetch机械臂任务三类环境上各跑一遍感受不同场景下PPO的表现差异和调参重点。这个过程会逼着你理解每个超参数的实际作用比看十篇论文都管用。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进