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_{τ ~ π_θ} [ Σ_{t=0}^{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) Π_{t=0}^{T-1} π_θ(a_t|s_t) p(s_{t+1}|s_t, a_t)注意这里 p(s_0) 是初始状态分布,p(s_{t+1}|s_t, a_t) 是环境的状态转移概率,它们都不依赖于策略参数 θ。所以当我们对 log π_θ(τ) 求梯度的时候,这两个部分就自然消掉了,只剩下一串策略的对数概率:
∇_θ log π_θ(τ) = Σ_{t=0}^{T-1} ∇_θ log π_θ(a_t|s_t)代回 J(\theta) 的梯度表达式,可以得到:
∇_θ J(θ) = E_{τ ~ π_θ} [ Σ_{t=0}^{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 的核心思想与局限性
TRPO(Trust 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 Sampling:PPO 能重复使用数据的理论基础
在正式推导 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_{t+1}) - V(s_t)。这种方法方差小,但偏差很大——因为 V 函数本身估计不准,会把误差一直带下去。
5.2 GAE 公式推导与参数含义
GAE(Generalized Advantage Estimation)就是为了在方差和偏差之间找一个平衡。它定义了一组带权重的时间差分误差之和:
Â_t^{GAE(γ,λ)} = Σ_{l=0}^{T-t-1} (γλ)^l δ_{t+l}其中 δ_t = r_t + γV(s_{t+1}) - V(s_t) 是 TD 误差。把公式展开来看:
Â_t = δ_t + (γλ)δ_{t+1} + (γλ)^2 δ_{t+2} + ...当 λ=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 size | 2048 | 按任务复杂度调整 |
| 学习率 | 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 什么时候不该用 PPO
PPO 不是万能的。如果你的环境采样成本极高,比如真实机器人、真实驾驶测试,那么 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, gamma=0.99, lam=0.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, epsilon=0.2, epochs=10): 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_norm=0.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% 时间,剩下的才是参数优化。希望这篇文章能帮你少走一些我当年走过的弯路。