ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PPO算法详解:从策略梯度到近端策略优化,打造稳定强化学习

PPO算法详解:从策略梯度到近端策略优化,打造稳定强化学习 1. 为什么说 PPO 是强化学习里的“万金油”先聊聊我自己的使用经历。去年我做过一个机械臂抓取的项目仿真环境里用 DQN 调了半天样本效率低到让人崩溃后来换了 DDPG又因为超参数太敏感稍微调一下学习率就整个崩掉。最后换到 PPO才算真正把任务跑通了。这大概也是 PPO 这几年成为主流选择的核心原因——它不像 DQN 那样只适合离散动作也不像 DDPG 那样对超参数极度挑剔而是以一种“稳中求进”的方式把策略梯度方法的可靠性提升到了一个新的高度。PPO 的全称是 Proximal Policy Optimization中文一般叫近端策略优化。OpenAI 在 2017 年提出它之后很快就成了强化学习领域最常用的 baseline 算法之一后来更是成为 ChatGPT 训练中 RLHF 阶段的核心算法。这个算法的核心诉求就一句话在每一轮更新时让新策略不要偏离旧策略太远同时又要保证策略在往更好的方向改进。围绕这个诉求PPO 给出了两种实现方式一种是带 KL 惩罚项的 adaptive KL penalty另一种是更常用的 clipped surrogate objective也就是裁剪版的目标函数。后面这种写法因为实现简单、效果稳定几乎成了 PPO 的代名词。我接下来讲的推导会先从策略梯度方法的基础开始把 PPO 为什么要做“近端约束”这件事讲透然后把裁剪目标的数学推导一步步展开。文章里会有不少公式但我尽量让每一步的来龙去脉都清楚毕竟只有真正理解了推导过程遇到问题的时候才知道该往哪个方向排查。这篇文章适合三种人看一是刚入门强化学习、想系统理解 PPO 但被各种公式劝退的初学者二是已经在用 Stable-Baselines3 之类的库调 PPO但不太清楚内部原理的工程派三是需要自己实现算法、或者要改算法发论文的研究生。不管你是哪一类我都建议你拿出一张纸跟着推导自己写一遍效果比光看不练好太多。2. 从策略梯度说起PPO 要解决的到底是什么问题2.1 强化学习的核心目标和表示方法先建立一个统一的认识。强化学习的场景一般用一个叫 MDP 的框架来描述它有五个要素状态集合、动作集合、状态转移概率、奖励函数以及折扣因子。这个框架的好处在于它把“智能体在环境中通过试错学习”这件事抽象成了一个数学问题我们想要找到一条策略让智能体在和环境交互的过程中累积的折扣回报期望最大化。为了让这个问题可以用梯度方法求解我们要把策略参数化。最常见的做法是用一个神经网络来表示策略输入是状态输出是动作的概率分布。比如在连续控制任务里输出通常是一个高斯分布的均值和方差在离散动作空间里输出是各个动作的 softmax 概率。我们把策略网络的参数记作 \theta策略本身记作 \pi_\theta(a|s)表示在状态 s 下选择动作 a 的概率。有了参数化的策略接下来要定义我们的优化目标。用 J(\theta) 表示策略 \pi_\theta 的期望回报J(\theta) E_{τ ~ π_θ} [ Σ_{t0}^{T} γ^t r_t ]其中 τ 是一条完整的轨迹也就是状态、动作、奖励的序列。我们的目标就是找到一个 \theta 使得 J(\theta) 最大。这个过程之所以叫策略梯度方法就是因为我们直接对 J(\theta) 求关于 \theta 的梯度然后用梯度上升来更新参数。2.2 策略梯度定理的推导REINFORCE 到通用形式直接对 J(\theta) 求梯度并不容易因为期望里面依赖策略 π_θ 的分布而这个分布又和参数 θ 耦合在一起。但有一个人尽皆知的技巧叫做 score function trick也就是把对分布求梯度转化成对分布的对数求梯度∇_θ π_θ(τ) π_θ(τ) ∇_θ log π_θ(τ)这个等式很好验证左边对 π_θ(τ) 求梯度右边是 π_θ(τ) 乘以 log π_θ(τ) 的梯度两边其实是一样的。这样做的妙处在于把“对概率分布求梯度”转换成了“对 log 概率求梯度”而后者是可以直接通过神经网络的反向传播计算的。我们把轨迹 τ 的分布展开写出来。对于一条长度为 T 的轨迹它的概率是π_θ(τ) p(s_0) Π_{t0}^{T-1} π_θ(a_t|s_t) p(s_{t1}|s_t, a_t)注意这里 p(s_0) 是初始状态分布p(s_{t1}|s_t, a_t) 是环境的状态转移概率它们都不依赖于策略参数 θ。所以当我们对 log π_θ(τ) 求梯度的时候这两个部分就自然消掉了只剩下一串策略的对数概率∇_θ log π_θ(τ) Σ_{t0}^{T-1} ∇_θ log π_θ(a_t|s_t)代回 J(\theta) 的梯度表达式可以得到∇_θ J(θ) E_{τ ~ π_θ} [ Σ_{t0}^{T-1} ∇_θ log π_θ(a_t|s_t) · R(τ) ]其中 R(τ) 是整条轨迹的累积回报。这就是最基本的 REINFORCE 算法的更新公式。思路很直接让智能体跑完一整条轨迹如果这条轨迹整体回报高就增加这条轨迹上所有动作的概率如果回报低就降低这些动作的概率。但 REINFORCE 有一个致命问题方差极大。因为同一个动作在不同轨迹中带来的回报差异可能非常大导致梯度方向噪声很重。一个改进是用某个时刻之后的累积回报 G_t 代替整条轨迹的回报 R(τ)这样至少梯度只和当前时刻之后的回报相关方差会小一些。再进一步我们引入 baseline一般用状态价值函数 V(s) 来作为 baseline于是得到∇_θ J(θ) E [ Σ_t ∇_θ log π_θ(a_t|s_t) · (G_t - V(s_t)) ]这里的 G_t - V(s_t) 其实就是在估计一个叫做优势函数的东西。简单理解就是这个动作比平均水平的“好”或者“坏”好多少坏多少。2.3 为什么策略梯度方法的更新步长如此难调有了策略梯度的基本形式下面就到了关键问题每次用梯度做更新的时候步长应该选多大如果步长太小学习速度会非常慢可能跑几百万步还没有明显效果。如果步长太大策略在参数空间中跑得太远策略分布一下子变化过大很容易导致性能崩塌——这在强化学习里叫 performance collapse。为什么这么容易崩塌因为策略梯度是 on-policy 的也就是说我们用当前策略收集的数据来更新当前策略一旦策略变化太大之前收集的数据就不再能代表新策略下的分布了梯度估计就失真了。拿打游戏来类比你用当前的操作习惯打了一百局总结出一些经验如果让你严格按着这个经验去改操作改一点点是合理的但如果直接把操作风格彻底换掉你下一百局的体验可能完全不适用旧经验了。策略梯度也是这样我们希望每次更新都在一个“可靠区间”内既不浪费样本效率又能保证更新方向是有效的。传统方法就是靠调学习率来控制步长。但学习率是个全局量对不同的参数、不同的状态同一个学习率的“实际步长”是完全不同的。这个问题在参数空间和分布空间之间存在一个很大的鸿沟参数的微小变化可能引发策略分布的剧烈变化反之亦然。所以学术界开始思考能不能不做参数空间的限制而是直接限制策略分布的差异这就是 TRPO 和 PPO 的出发点。3. 从 TRPO 到 PPO置信域思路的演变3.1 TRPO 的核心思想与局限性TRPOTrust Region Policy Optimization是 PPO 的直接前身。它提出来一个目标函数叫做 surrogate objective核心想法是这样我们要找一个新的策略让它的期望回报比旧策略更高与此同时新旧策略之间的差异不能太大。TRPO 把这个差异用 KL 散度来衡量然后把它作为一个硬约束。形式上可以写成maximize_θ E_{s,a ~ π_θ_old} [ (π_θ(a|s) / π_θ_old(a|s)) · A(s, a) ] subject to E_{s ~ π_θ_old} [ KL(π_θ_old(·|s) || π_θ(·|s)) ] ≤ δ这个约束的直观含义是更新后的策略在每个状态下和旧策略的平均 KL 散度不能超过一个阈值 δ。这样设计的好处是它保证了更新是在一个“可信赖”的范围内进行的。理论上有证明说只要满足这个 KL 约束新策略的真实性能就有一个下界保证不会比旧策略差太多这就是所谓的“单调改进保证”。TRPO 的问题也很明显——实现复杂、计算量大。硬约束需要用共轭梯度法来近似求解还要计算 Fisher 信息矩阵和它的一些复杂运算。工程实现上非常繁琐而且对噪声很敏感跑起来又慢又难调。我当时第一次尝试复现 TRPO 的时候光是处理 Fisher 向量乘积那一堆代码就花了好几天最后还是没能完全调稳。3.2 PPO 为什么要换一种表达方式PPO 的初衷就是要在保留 TRPO 的稳定性的前提下把算法简化到一阶优化就能解决的程度。论文作者给出的方案简单到让人怀疑直接把 KL 散度约束从“硬约束”挪到“软惩罚”里面。PPO 的第一个版本是这样在目标函数后面加上一个 KL 散度惩罚项惩罚系数 β 是动态调整的。如果当前策略和旧策略的 KL 散度超过阈值就增大 β让惩罚变重如果 KL 散度太小说明更新太保守就减小 β让更新更激进。这个方法叫 adaptive KL penalty它确实有效但依然需要调 β 的调度策略。更彻底的简化是 PPO 的第二个版本也就是我们常说的 PPO-Clip。它连 KL 散度都不用算了直接对目标函数的每一项做截断。这个思路非常巧妙后面我会详细推导它为什么能起到和 KL 约束类似的作用。但先绕开一个小问题为什么我们不直接用策略的概率比做约束而是要绕这么一大圈其实这个问题也可以反过来想——你当然可以给概率比加上约束比如限制 \pi_θ / \pi_θ_old 的范围在 [0.8, 1.2] 之间这样也能限制策略更新幅度。但这样做在梯度计算上不太自然因为对概率比做硬截断会使得梯度在某些地方直接变成 0优化行为很不平滑。而 PPO-Clip 的截断方式是在目标函数层面操作它保留了一种“当概率比超出范围时该项梯度为 0但目标值并没有被强制拉回来”的性质对应的优化动态是经过理论分析的。4. PPO 目标函数的完整推导一步一步算给你看4.1 Importance SamplingPPO 能重复使用数据的理论基础在正式推导 PPO 目标函数之前有必要先补一个重要的数学工具——重要性采样Importance Sampling。为什么要提它因为它能解释 PPO 更新中的一个关键机制为什么我们用旧策略收集的数据也能用于更新新策略。假设我们的目标是计算 f(x) 在分布 p(x) 下的期望但我们手里只有从分布 q(x) 采样得到的数据。这时候可以用一个校正因子把期望改写一下E_{x~p}[f(x)] E_{x~q}[ (p(x)/q(x)) · f(x) ]这个公式的正确性很容易验证把左边的期望展开成积分形式上下乘以 q(x)然后你会发现它就变成了右边。这里的 p(x)/q(x) 就是重要性权重。在 PPO 里我们用旧策略 π_θ_old 来收集轨迹然后要用这些数据来评估新策略 π_θ 的优势期望。按道理来说期望应该是在新策略的分布下计算的但因为新策略还没确定没法用它来做采样。所以我们就用重要性采样把对新策略的期望改写成对旧策略的期望E_{(s,a) ~ π_θ}[A(s,a)] E_{(s,a) ~ π_θ_old}[ (π_θ(a|s)/π_θ_old(a|s)) · A(s,a) ]这里的概率比值 r_t(θ) 就是后面整个推导的核心对象r_t(θ) π_θ(a_t|s_t) / π_θ_old(a_t|s_t)理解了这个比值PPO 的一切都围绕它展开。当 r_t(θ) 1 的时候说明新策略选择这个动作的概率比旧策略更高当 r_t(θ) 1 的时候说明新策略选择这个动作的概率降低了当 r_t(θ) ≈ 1 的时候说明新旧策略在这个状态下基本一致。4.2 从策略梯度到 surrogate objective 的推导现在我们正式来构建 PPO 的优化目标。最基本的策略梯度目标可以写成L^{PG}(θ) E_t [ r_t(θ) · A_t ]这里的下标 t 表示时间步A_t 是优势估计。对这个目标求关于 θ 的梯度在 θ θ_old 这一点附近r_t(θ_old) 1所以梯度变成∇_θ L^{PG}(θ) |_{θθ_old} E_t [ ∇_θ log π_θ(a_t|s_t) · A_t ]这正是标准策略梯度公式。也就是说在旧策略这一点上surrogate objective 的梯度和真实策略梯度的期望是一致的所以我们可以安全地用这个目标来做优化。但问题依然存在这个目标函数对步长没有约束直接做梯度上升走太远就会崩。PPO 论文里用了一个非常巧妙的办法来解决这个问题——对目标函数加一个 clip 操作。基本形式如下L^{CLIP}(θ) E_t [ min( r_t(θ) · A_t, clip(r_t(θ), 1-ε, 1ε) · A_t ) ]这里 ε 是一个超参数论文里一般取 0.2。min 操作的意思是如果 r_t(θ) · A_t 的值大于裁剪后的值就取裁剪后的值如果小于就取原始值。但这个 min 操作在不同符号的优势项下面行为是完全不同的下面我们分情况讨论。4.3 当优势为正时clip 如何防止过度贪心先考虑 A_t 0 的情况。也就是说在这个状态下采取这个动作比平均水平要好我们希望增加它的概率。对于正的优势r_t(θ) · A_t 是 r_t(θ) 的增函数。我们希望让 r_t(θ) 尽量大因为这意味着新策略在这个好动作上的概率显著提高了。但如果我们不加限制r_t(θ) 可能会冲到很大策略更新就变得非常激进。这时来看 clip 部分。A_t 0 时clip(r_t, 1-ε, 1ε) · A_t 会变成一个分段的函数当 r_t 1-ε 时值是 (1-ε) · A_t比原始值 r_t · A_t 要大当 r_t 在 [1-ε, 1ε] 内时值等于 r_t · A_t当 r_t 1ε 时值是 (1ε) · A_t比原始值 r_t · A_t 要小。再套上 min 操作后你会发现在 r_t 1ε 的区间上取的是原始值 r_t · A_t在 r_t 1ε 的区间上取的是裁剪后的值 (1ε) · A_t。换句话说当 r_t 超过 1ε 之后目标函数就不再增长了它的图像是一条水平线。这意味着就算新策略对该动作的概率极高梯度也会变成 0优化算法不会再朝“继续增加概率”的方向走了。这个设计的直观价值是当优势是正的时候我们允许适度增加该动作的概率但并不允许无限增加。这就好比你考试考好了可以获得奖励但奖励的额度设了一个上限再往上就没有额外激励了。4.4 当优势为负时clip 如何防止“摆烂”现在考虑 A_t 0 的情况。这个动作比平均水平差我们希望降低它的概率。对于负的优势r_t(θ) · A_t 是 r_t(θ) 的减函数。也就是说 r_t 越小目标函数值反而越大因为负数乘以小正数结果更接近 0看起来比大的负数“大”。我们需要的是让 r_t 变小也就是降低坏动作的概率。但同样不能无限降低。再看 clip 部分的特征。A_t 0 时当 r_t 1-ε 时clip 的值是 (1-ε) · A_t它比原始值 r_t · A_t 更小当 r_t 在 [1-ε, 1ε] 内时值等于 r_t · A_t当 r_t 1ε 时clip 的值是 (1ε) · A_t比原始值 r_t · A_t 更大。套上 min 操作后情况正好和正优势相反在 r_t 1-ε 的区间上取的是原始值 r_t · A_t当 r_t 降到 1-ε 以下时取的是裁剪后值 (1-ε) · A_t它是一条水平线。也就是说当 r_t 小于 1-ε 之后目标函数值停止增加梯度也会变成 0优化算法不再继续压低这个坏动作的概率。这个机制防止了另一种极端情况如果一个动作是坏的我们希望压低它的概率但也不希望压到 0因为可能只是这个状态下的偶然结果并不是真正的坏动作。Clip 操作相当于给“惩罚”设了一个底线。现实中我试过把 ε 调到 0.5结果是策略非常保守几乎不敢尝试新的动作后面我会专门讲调参的坑。4.5 两种形式统一起来看PPO-Clip 到底在做什么把正负优势两个情况合在一起我们可以总结 PPO-Clip 的行为本质当 r_t(θ) 在 [1-ε, 1ε] 区间内时目标函数和普通的 surrogate objective 一致梯度正常传播当 r_t(θ) 超出区间右侧时如果优势为正梯度截断为 0如果优势为负梯度保持原始值但值处于水平段方向会被其他项纠正当 r_t(θ) 低于区间左侧时如果优势为负梯度截断为 0如果优势为正保持原始值但被水平线封顶。这样设计的效果是无论优势正负只要概率比超出安全区间相关的梯度贡献就会被抑制从而整个更新过程被限制在了一个“近端”范围内。这和 TRPO 用 KL 散度硬约束的效果非常接近但不需要计算任何二阶信息实现起来极其简单。理论层面还有一个重要性质。PPO 论文证明了L^{CLIP}(θ) 是真实目标函数的一个下界估计。换句话说我们用这个裁剪后的目标去优化即使它估计得不够准它也不会高估新策略的真实性能这就避免了“自以为变好了实际崩了”的假象。这一点在实际训练里至关重要因为强化学习本来就充满了噪声一个会高估性能的目标函数会给你带来灾难性的误判。5. 优势函数估计GAE 是 PPO 的另一根支柱5.1 为什么不能用总回报直接算优势PPO 目标函数里反复出现的 A_t是优势函数在时刻 t 的估计值。优势的定义是 A(s,a) Q(s,a) - V(s)意思是这个动作相比当前状态平均水平的增益。如果 Q 值和 V 值都估计得准那优势的正负就能很好地指导策略更新方向。但在实际实现中我们很少直接估计 Q(s,a)而是用轨迹上的回报来近似。最简单的优势估计是蒙特卡洛方法也就是用一步到位的折扣累积回报 G_t 减去基线 V(s_t)。问题在于G_t 的方差非常大尤其当任务的时间跨度长、环境噪声大的时候靠一条轨迹的回报来估计优势几乎等于拿噪声当下雨信号。还有一种极端是用 TD 误差的做法比如 GAE 里当 λ0 时优势就是一步的 TD 误差 δ_t r_t γV(s_{t1}) - V(s_t)。这种方法方差小但偏差很大——因为 V 函数本身估计不准会把误差一直带下去。5.2 GAE 公式推导与参数含义GAEGeneralized Advantage Estimation就是为了在方差和偏差之间找一个平衡。它定义了一组带权重的时间差分误差之和Â_t^{GAE(γ,λ)} Σ_{l0}^{T-t-1} (γλ)^l δ_{tl}其中 δ_t r_t γV(s_{t1}) - V(s_t) 是 TD 误差。把公式展开来看Â_t δ_t (γλ)δ_{t1} (γλ)^2 δ_{t2} ...当 λ0 时只剩第一项就是一步 TD 估计偏差大方差小。当 λ1 时等于把后面所有时间步的 TD 误差都加进来在无限长时间下退化成蒙特卡洛估计方差大偏差小。λ 取中间值就能兼顾两头。PPO 论文里通常取 λ0.95 或者 0.99需要根据任务的稀疏奖励情况来微调。GAE 在 PPO 里的位置非常关键。理论上来讲即使不用 GAE、用普通的回报做优势估计PPO 也能工作但训练方差会大很多收敛速度会明显变慢。我在实际项目里对比过用 GAE 之后在 MuJoCo 的 HalfCheetah 任务中收敛步数大概快了近一倍而且曲线平滑得多。5.3 价值网络的训练目标PPO 的 Actor-Critic 架构里价值网络 V_φ(s) 需要拟合状态价值函数。训练价值网络通常用的是均方误差损失L^{VF}(φ) E_t [ (V_φ(s_t) - V_t^target)^2 ]这里的 V_t^target 一般用 GAE 时的 TD 目标来算即 V_t^target A_t V_φ_old(s_t)。值得注意的一点是在 GAE 的计算中我们需要一个“旧价值网络”的输出作为 baseline而不能用当前更新的价值网络来算。为什么因为如果用当前网络来评估每个梯度更新后 V 都会变之前算好的优势就失去了意义整个训练目标就变成了一个移动靶很难收敛。还有一个细节是价值函数损失的剪裁。PPO 论文提出价值函数的更新也需要加一个 clip防止价值函数更新过快。这个 clip 方式和策略的 clip 有点类似如果当前价值输出和旧价值输出差异超过一定范围就取裁剪后的值。我在实现中一般会加上这个能明显缓解价值网络和策略网络之间的失衡问题。6. 完整训练流程与工程实现解析6.1 从采样到更新的标准循环PPO 的完整训练流程可以概括为以下几个步骤。第一次看可能会觉得有点绕但只要理解“采样—更新—再采样”这个循环后面就顺了。第一步用当前策略 π_θ_old 在环境中采样收集一批轨迹数据。这批数据包括每个时间步的状态、动作、奖励、以及下一步状态还有对应的动作对数概率。第二步根据采样的轨迹计算出每个时间步的回报 G_t 和优势估计 A_t。这里的计算依赖价值网络 V_φ所以需要在采样的时候顺便记录价值网络的输出。第三步把优势值标准化。这里有个容易被忽略的细节直接把所有优势的均值和方差归一化能让训练稳定不少。原因是最开始优势的尺度可能因环境而异不统一的尺度会导致策略更新的实际幅度不可控。第四步重复多轮优化。PPO 的一个核心特性就是 off-policy 的程度有限但存在也就是说我们可以在同一批数据上做多轮梯度更新。论文里一般建议做 3 到 10 个小 epoch。每轮更新时重新计算 r_t(θ)根据裁剪目标求梯度更新策略网络同时用价值损失更新价值网络。第五步更新完成后清空旧数据重新用新策略采样。这个采样效率和更新次数的权衡就是 PPO 最需要调的部分。6.2 Actor-Critic 网络架构与损失函数的组合方式PPO 的标准架构是 Actor-Critic其中 Actor 输出策略分布Critic 输出状态价值。在连续动作空间里Actor 通常输出高斯分布的均值和标准差在离散动作空间里Actor 输出各动作的 logits。网络的具体结构根据任务的不同而不同。对于像素输入一般用 CNN 处理图像得到特征向量后再分别接两个 head对于低维状态输入通常用两层 64 或 256 的 MLP 就能工作得不错。我自己习惯用两层 256 的网络作为默认配置然后在不同任务上做少量调整一般都能达到不错的性能。总损失函数是策略损失、价值损失和熵奖励的加权和L^{total}(θ) L^{CLIP}(θ) - c1 · L^{VF}(φ) c2 · Entropy(π_θ)这里要特别说下熵奖励。熵奖励的目的是鼓励探索它会给策略分布一个“保持随机性”的激励。如果策略太早收敛到某个确定性动作熵会很低加上熵奖励就能避免这种情况。但熵系数也不能太大我之前遇到过一个情况熵系数设到 0.05结果策略一直处于随机探索的状态根本学不会精细控制。在 PPO 实现里熵系数一般取很小的值比如 0.001 或者 0.01。6.3 关键超参数解析与推荐配置PPO 的超参数不少但真正影响训练质量的主要就这几个裁剪系数 ε 默认取 0.2。这个值的含义是我们允许策略在一个更新中单个动作的概率比不超出 20% 的变化范围。用更大的值会让策略更新更激进但稳定性下降更小的值则更保守。一般来说如果训练曲线震荡得很厉害可以把 ε 调小一点如果学习太慢可以略微调大。GAE 里的 λ 控制的是优势估计中的偏差方差权衡。对于稀疏奖励任务我建议用比较大的 λ比如 0.99因为稀疏奖励的信号本来就少需要尽量保留远期信息对于密集奖励任务0.95 甚至 0.9 就够了可以减小方差。每个 batch 的更新轮数也就是 update epochs也是一个关键参数。论文里建议 3但实践中根据数据规模可能需要调整。如果你的策略更新太快导致崩了可以减少 epochs反之如果数据利用率太低可以加一点。我自己一般用 10 作为默认值在小规模任务上效果不错但大规模任务要适当减少。学习率默认是 3e-4这是 OpenAI 的 baseline 里的经典推荐。但我强烈建议你根据自己的任务做小范围搜索。学习率太大PPO 也扛不住一样会崩学习率太小收敛慢到你怀疑人生。6.4 我自己常用的 PPO 训练配置参考下面是我在实际项目中验证过的一组比较稳的配置适合中等难度的连续控制任务比如 MuJoCo 或者 PyBullet 里的机器人控制超参数推荐值说明裁剪系数 ε0.2默认值通常就够用GAE λ0.95密集奖励任务可以降到 0.9折扣因子 γ0.99长周期任务可以调到 0.999更新轮数10大 batch 时可以降低batch size2048按任务复杂度调整学习率3e-4可以用线性衰减熵系数0.0-0.01需要探索时再开价值损失系数0.5默认值比较保险这套配置不是万能药但它给了我一个很好的起点。大多数情况下我只需要调整 batch size 和更新轮数就能适配不同的任务。7. 训练中遇到的常见问题与排查技巧7.1 策略突然崩掉裁剪机制也没能完全兜住很多人以为用了 PPO 就一定不会崩这是最大的误解。PPL 的裁剪只是把一个更新内的变化限制住了但如果你在一个 batch 上做太多次更新相当于把多个“小步子”累积成了一个大跨越一样可能冲出安全区域。我在一个倒立摆任务上踩过这个坑当时把 update epochs 设成了 20跑了几千步之后策略突然完全退化不管怎么重置都学不回来。后来排查发现问题不在裁剪而是太久地在同一批数据上重复优化即便每个 epoch 的单步变化不大累积变化早就超出合理范围了。解决方法是把 update epochs 降回 5同时在每个 epoch 之间重新计算一下 KL 散度如果 KL 散度超过某个阈值就提前终止这一轮更新。其实这就是 PPO 论文里提到的 early stopping 策略。实操上我建议设置一个 KL 阈值比如 0.01 到 0.05当超过这个值时直接 break能有效防止过度更新。7.2 奖励一直不涨通常是优势估计出了问题如果训练曲线保持一条平线奖励死活不涨很多人第一反应是改网络结构或者加学习率但我的经验是先去检查优势估计。最常见的坑是 GAE 的计算顺序错了。GAE 是从时间末尾往前推的因为后面的 TD 误差会影响前面的优势估计。如果你从前往后算得到的 advantage 根本不对。我见过不少开源代码在这个细节上出错甚至跑出来的结果看起来还行但一旦换任务就原形毕露。另一个常见问题是价值网络和策略网络的更新失衡。Critic 更新太快价值估计噪声大导致 GAE 的优势信号里混入大量噪声Critic 太弱baseline 不准优势信号又成了有偏的。这时候可以调大价值损失系数或者用单独的优化器来分别控制两个网络的更新节奏。我个人喜欢给价值网络单独设一个更低的学习率大约是策略网络的一半。还有一点连老手都可能忽略数据标准化。PPO 对状态输入的尺度非常敏感。如果状态特征之间的量纲差异很大比如一个是位置0.01 级别一个是速度100 级别梯度更新很容易被大尺度特征主导小尺度特征学不到任何有效信息。我一般会对状态做 RunningMeanStd 归一化也就是在训练过程中持续统计状态的均值和方差把输入标准化到零均值单位方差。这一步往往比调整学习率的收益大得多。7.3 训练过程震荡严重KL 散度是最好的体检指标训练曲线像锯齿一样上下乱跳这是 PPO 中非常常见的情况。每当遇到这种情况我都会先查看 KL 散度。KL 散度在这里的意义是每个 epoch 更新后新旧策略分布的差异有多大。如果在一次更新后 KL 散度飙升到 0.1 甚至更高说明策略变化太大应该降低学习率或者调小裁剪系数。如果 KL 散度几乎为 0说明策略几乎没有更新可能梯度消失或者学习率太小。有一个在线的策略是我在多个项目里验证过的在训练时记录每一轮的 mean KL如果超过 0.02 就自动降低学习率 × 0.5如果连续多轮 KL 很小就适当增大学习率 × 1.1。这套简单规则虽然不是最优的但能帮你快速定位问题让训练曲线平滑不少。还有一个跟初始化有关系的原因。如果策略网络的输出层初始化不当初始策略分布可能过于集中在某个动作上导致 KL 散度天然就很大。解决方法是在策略网络输出层用较小的初始化权重让初始策略分布接近均匀分布。在 PyTorch 里就是对输出的线性层做正交初始化并设置较小的缩放系数。7.4 样本效率太低on-policy 算法的天花板与缓解PPO 的本质是 on-policy 算法这就决定了它的样本效率不会太高。如果你发现 PPO 在一个真实机器人或者昂贵的仿真环境里需要上百万步才能学会任务不要觉得是代码写错了这是算法特性决定的。缓解方法有几种。第一是并行采样。用一个 vectorized environment 同时跑多个环境每个环境独立采样然后把数据汇总到一起做更新。这能大幅提高数据吞吐量。第二是用更有效的优势估计。比如对稀疏奖励任务可以尝试用更强的 reward shaping人为设计一些中间奖励信号来引导策略。第三是考虑混合方法比如先用 PPO 再切换到 SAC或者反过来在一些任务上效果不错。另一个实用技巧是给 GAE 用更小的 λ 来降低方差。在奖励比较密集的任务里λ0.9 的收敛速度有时候反而比 λ0.99 更快因为信号密度足够高不需要借助远期信息来补充。8. 到底什么时候选 PPO什么时候选别的算法8.1 PPO 的优势区间和适用场景从工程角度看PPO 最强大的地方在于它的鲁棒性和通用性而不是它在特定任务上的极限性能。对于离散动作空间比如 Atari 游戏、棋类游戏PPO 虽然不如 DQN 系列某些算法那么专注但胜在一个算法通吃。对连续动作空间比如机器人控制、自动驾驶仿真PPO 和 SAC 是两大主流但 PPU 更容易调稳。如果你的任务有一个可并行的仿真环境PPO 几乎是首选因为并行采样可以弥补 on-policy 的样本效率问题。很多大规模 RL 训练框架也是以 PPO 为核心算法因为它实现简单、稳定可靠集群部署也容易。还有一个重要场景是 RLHF也就是人类反馈强化学习。ChatGPT 这类模型在做 RLHF 时就是用的 PPO原因很简单PPO 可以在不偏离旧策略太远的前提下更新语言模型这对防止模型输出“崩坏”至关重要。语言模型动辄百亿参数如果一个更新就把策略完全带偏后果不堪设想。8.2 什么时候不该用 PPOPPO 不是万能的。如果你的环境采样成本极高比如真实机器人、真实驾驶测试那么 on-policy 算法天然就不适合。这种情况下更推荐 SAC 或 TD3 这类 off-policy 算法它们能更好地利用历史数据样本效率高得多。如果任务对精度和最终性能要求极高PPO 往往在后期收敛不够精细。我做过一个需要精确控制的四旋翼任务PPO 能学会基本悬停但最终位置误差总是降不下来换成 SAC 之后精度明显提升。这类需要高精度控制的场景PPO 可以拿来预训练但后续微调更适合 SAC。8.3 PPO 的后续改进方向PPO 论文发表以来出现了很多改进版本。有些是通用技巧比如在实现里加 GAE 的 λ 退火、学习率退火、奖励标准化、梯度裁剪有些是针对特定任务的变体比如在机器人领域加上了分布矫正项和双 Critic 架构还有些是理论层面的深化比如用更严格的信赖域约束来替代简单的裁剪。我自己比较关注的一个方向是自适应调节裁剪系数 ε而不是固定用 0.2。具体思路是在训练过程中监测 KL 散度如果 KL 散度持续很小就调大 ε 让更新更激进如果 KL 散度太大就调小 ε 让更新更保守。这样能在一个很宽的范围内自动取到一个相对合理的平衡点。9. 从零手写一个极简 PPO核心代码拆解讲了这么多理论不写点代码总觉得差点意思。下面给出一份极简但能跑通的 PPO 核心代码用 PyTorch 实现去掉所有工程性封装只保留算法本身。我会贴出关键部分并注释每一行代码的作用。import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import numpy as np class PPONetwork(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.fc1 nn.Linear(state_dim, 128) self.fc2 nn.Linear(128, 128) self.actor_mean nn.Linear(128, action_dim) self.actor_logstd nn.Parameter(torch.zeros(action_dim)) self.critic nn.Linear(128, 1) def forward(self, x): x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return x def policy(self, state): x self.forward(state) mean self.actor_mean(x) logstd self.actor_logstd.expand_as(mean) return mean, logstd def value(self, state): x self.forward(state) return self.critic(x)这里用了一个独立的 logstd 参数来代表高斯分布的 log 标准差。用参数化标准差并参与训练这是 PPO 的标准做法。注意 actor_logstd 是一个 Parameter而不是通过网络层生成这样可以让网络学到一个合适的探索噪声幅度。接着是优势估计和裁剪损失的实现def compute_gae(rewards, values, dones, gamma0.99, lam0.95): T len(rewards) advantages torch.zeros_like(rewards) last_gae 0 for t in reversed(range(T)): next_value 0 if dones[t] else values[t 1] delta rewards[t] gamma * next_value - values[t] last_gae delta gamma * lam * (1 - dones[t]) * last_gae advantages[t] last_gae returns advantages values return advantages, returns这段代码最关键的地方是 reversed 循环从最后一步往前递归计算 GAE。dones 的作用是标记一个 episode 是否结束。如果结束了next_value 就是 0因为之后没有回报了。注意看这里的数学表达实际上 GAE 递推等价于前面写的公式展开。然后是 PPO 策略更新的核心部分def ppo_update(policy, optimizer, states, actions, old_log_probs, advantages, returns, epsilon0.2, epochs10): for epoch in range(epochs): mean, logstd policy.policy(states) dist torch.distributions.Normal(mean, logstd.exp()) log_probs dist.log_prob(actions).sum(dim-1) ratio (log_probs - old_log_probs).exp() surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - epsilon, 1 epsilon) * advantages policy_loss -torch.min(surr1, surr2).mean() value_loss F.mse_loss(policy.value(states).squeeze(), returns) entropy dist.entropy().mean() total_loss policy_loss 0.5 * value_loss - 0.01 * entropy optimizer.zero_grad() total_loss.backward() nn.utils.clip_grad_norm_(policy.parameters(), max_norm0.5) optimizer.step()有几处需要注意。surr1 是原始的目标项surr2 是裁剪后的目标项对两者取 min 就实现了前面推导的裁剪目标。ratio 用的是 log_prob 差值再 exp是数值上更稳定的做法。梯度裁剪这一行非常关键它防止单次更新的梯度范数过大是 PPO 实际训练中防止崩掉的重要保险。如果你是第一次接触这些代码建议先跑一个最简单的环境验证一下比如 CartPole。把这段代码封装成采样循环跑几千步看看奖励有没有稳定上升。跑通了之后再往里面加 Normalization、running mean、更大的网络逐步扩展成一个完整的训练框架。10. 结合我这几年的实战体会聊聊 PPO 的未来和陷阱从我的个人经验来说PPO 最吸引人的地方不在于它的理论有多漂亮而在于它“够用且稳定”。很多复杂的强化学习算法在论文里效果很好但一到真实场景就原形毕露——不是超参数极度敏感就是实现起来充满隐藏的细节。PPO 的裁剪目标和 GAE 的组合让我在多个项目里都能以较少的调参成本获得不错的性能。但我也想说PPO 不是一个“傻瓜式”算法。不要以为加了裁剪就可以随意调大学习率、随意增加更新轮数。我见过很多人拿着 PPO 代码跑不收敛最后发现是学习率设成了 0.01或者是优势函数计算时没加终端状态处理。这些细节不会在论文里明说却决定了训练成败。如果你要自己实现一遍 PPO我的建议是先别急着复制粘贴大库的代码而是从极简版本开始亲手把 GAE、裁剪目标、价值函数损失这几个模块一个一个写出来同时配合 TensorBoard 记录 KL 散度、新旧策略的 ratio 分布、优势的均值和方差。这个过程会帮助你建立对算法极其扎实的直觉。最后再分享一个我自己调试时的习惯每当模型训练出问题我永远不会先去调网络结构或改超参数而是检查三件事——奖励信号是否正确、优势计算是否正确、以及 KL 散度是否异常。这三个地方占了 PPO 调试的 80% 时间剩下的才是参数优化。希望这篇文章能帮你少走一些我当年走过的弯路。
返回列表