Note:强化学习(六)

0 阅读16分钟

Note:强化学习(六)

2026 | ming


index_6.jpg

十五. 近端策略优化 PPO

PPO,全称 Proximal Policy Optimization(近端策略优化),你可能在不少论文和开源项目中都见过它。它并不是一个从石头缝里蹦出来的全新算法,而是站在 A2C(Advantage Actor-Critic)的肩膀上,做了一次非常优雅的约束。本质上,PPO 依然属于 Actor-Critic 家族,它的核心革新在于:给策略更新加上一个“信任域”,让智能体在学习时步子不要迈得太大,从而避免因一次鲁莽的参数更新而导致整个训练崩溃。

这种“求稳”的设计哲学,让 PPO 在训练过程中展现出惊人的稳定性,也使得它一跃成为现代强化学习中最流行、最可靠的算法之一。不论你是要训练一个走路的机器人,还是微调一个大语言模型让它更符合人类偏好,PPO 几乎都是首选。

在 PPO 出现之前,其实有一个算法已经提出了类似的约束思想,叫做 TRPO(Trust Region Policy Optimization)。TRPO 的目标非常明确:在每次更新策略时,都保证新策略与旧策略之间的差异被约束在一个“信任域”内,通常是用 KL 散度来衡量。但它的实现方式非常复杂——你需要用一个共轭梯度法去近似 Fisher 信息矩阵,再做一个线搜索,计算代价极高,代码也很容易出错。

PPO 的作者踩在 TRPO 的肩膀上,问了一个直击灵魂的问题:我们能不能只用一阶优化(比如随机梯度下降),就达到和 TRPO 类似的效果? 答案就是 PPO。它仅用几行代码的改动,就实现了比 TRPO 更稳定、甚至更好的效果,计算开销却小得多。如今,TRPO 基本上只存在于教科书里,而 PPO 成为了工程实践中的事实标准。

15.1 从 A2C 到 PPO

在 Actor-Critic 中,我们通常有两个核心模块:

  • Actor:策略函数 πθ(as)\pi_\theta(a \mid s),负责给出在状态 ss 下采取动作 aa 的概率;
  • Critic:价值函数 Vϕ(s)V_\phi(s),负责评估状态的长期价值。

策略梯度的基本思想可以写成:

θJ(θ)=Et[θlogπθ(atst)A^t]\nabla_\theta J(\theta) = \mathbb{E}_t \left[ \nabla_\theta \log \pi_\theta(a_t \mid s_t)\hat{A}_t \right]

这里的 A^t\hat{A}_t 是优势函数估计。它衡量的是:在状态 sts_t 下,动作 ata_t 相比平均水平到底好多少。

如果 A^t>0\hat{A}_t > 0,说明这个动作比预期更好,我们希望提高它的概率;如果 A^t<0\hat{A}_t < 0,说明这个动作比预期更差,我们希望降低它的概率。这个逻辑非常自然。

但是问题在于:普通策略梯度只告诉我们更新方向,却不关心更新幅度是否过大。

假设某个动作在一次采样中刚好带来了很高回报,策略网络可能会迅速提高这个动作的概率。可是这个回报也许只是偶然的,或者只在某些状态下有效。如果更新太激进,策略分布会发生明显漂移,下一轮采样的数据分布也会被改变,训练就容易变得震荡。

所以 PPO 的核心思想可以概括为一句话:让好动作更可能出现,让坏动作更少出现,但不要一次性改得太离谱。

这就是“近端”的含义:新的策略应该靠近旧策略,在一个相对安全的范围内更新。

15.2 新旧策略概率比

PPO 并不是直接最大化

logπθ(atst)A^t\log \pi_\theta(a_t \mid s_t)\hat{A}_t

而是引入了一个非常重要的量:新旧策略概率比

设采样数据来自旧策略 πθold\pi_{\theta_{\text{old}}},当前正在优化的新策略是 πθ\pi_\theta,那么定义:

rt(θ)=πθ(atst)πθold(atst)r_t(\theta) = \frac{ \pi_\theta(a_t \mid s_t) }{ \pi_{\theta_{\text{old}}}(a_t \mid s_t) }

这个比值表示:对于同一个已采样动作 ata_t,新策略相比旧策略有多倾向于选择它。

  • 如果 rt(θ)=1r_t(\theta)=1,说明新旧策略对该动作的概率一样;
  • 如果 rt(θ)>1r_t(\theta)>1,说明新策略提高了该动作的概率;
  • 如果 rt(θ)<1r_t(\theta)<1,说明新策略降低了该动作的概率。

于是,策略优化目标可以写成:

LPG(θ)=Et[rt(θ)A^t]L^{\text{PG}}(\theta) = \mathbb{E}_t \left[ r_t(\theta)\hat{A}_t \right]

这个式子非常关键。它把“旧策略采样得到的数据”转化成了“新策略应该如何改进”的优化目标。

直观地看:

  • A^t>0\hat{A}_t>0 时,我们希望增大 rt(θ)r_t(\theta),也就是提高好动作的概率;
  • A^t<0\hat{A}_t<0 时,我们希望减小 rt(θ)r_t(\theta),也就是降低坏动作的概率。

如果只看到这里,PPO 似乎只是把普通策略梯度换了一种写法。但真正的重点还在后面:PPO 会限制这个概率比的变化范围。

假设某个动作的优势函数为正:A^t>0\hat{A}_t > 0,按照策略梯度的逻辑,我们确实应该提高这个动作的概率。也就是说,我们希望:rt(θ)>1r_t(\theta) > 1,但是,如果新策略把这个动作的概率提高得太多,比如从 0.050.05 提高到 0.800.80,那么:

rt(θ)=0.800.05=16r_t(\theta) = \frac{0.80}{0.05} = 16

这意味着策略已经发生了非常剧烈的变化。即使这个动作在当前样本中表现很好,我们也很难保证它在整体状态分布下仍然稳定有效。反过来,如果某个动作的优势函数为负:A^t<0\hat{A}_t < 0, 我们希望降低它的概率,也就是让:rt(θ)<1r_t(\theta) < 1, 但如果降低得太狠,比如从 0.500.50 直接压到 0.0010.001,策略也可能过早放弃某些仍然有探索价值的动作。所以 PPO 的想法很朴素:策略当然要更新,但更新幅度应该被限制在一个合理区间内。

通常这个区间写成:[1ϵ, 1+ϵ][1-\epsilon,\ 1+\epsilon], 其中 ϵ\epsilon 是一个较小的超参数,常见取值如 0.10.10.20.20.30.3。如果 ϵ=0.2\epsilon=0.2,那么 PPO 希望概率比大致控制在:[0.8, 1.2][0.8,\ 1.2], 这并不是说策略完全不能超过这个范围,而是说:一旦超过这个范围,PPO 的目标函数就不再继续奖励这种过度更新。

15.3 裁剪目标函数

PPO 最经典的目标函数是裁剪目标函数,也叫 clipped surrogate objective:

LCLIP(θ)=Et[min(rt(θ)A^t, clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{\text{CLIP}}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta)\hat{A}_t,\ \text{clip}\left(r_t(\theta),1-\epsilon,1+\epsilon\right)\hat{A}_t \right) \right]

其中,

clip(rt(θ),1ϵ,1+ϵ)\text{clip}\left(r_t(\theta),1-\epsilon,1+\epsilon\right)

表示把 rt(θ)r_t(\theta) 限制在 [1ϵ,1+ϵ][1-\epsilon,1+\epsilon] 之间:

clip(rt,1ϵ,1+ϵ)={1ϵ,rt<1ϵrt,1ϵrt1+ϵ1+ϵ,rt>1+ϵ\text{clip}(r_t,1-\epsilon,1+\epsilon) = \begin{cases} 1-\epsilon, & r_t < 1-\epsilon \\ r_t, & 1-\epsilon \le r_t \le 1+\epsilon \\ 1+\epsilon, & r_t > 1+\epsilon \end{cases}

这个公式一开始看起来有点绕,但它的直觉非常清楚:PPO 会在“原始目标”和“裁剪目标”之间取更保守的那个。

这里的 min\min 很重要。它让 PPO 形成一种偏保守的优化机制:只要新策略更新得过于激进,目标函数就不再继续鼓励这种变化。

为了真正理解 PPO,最好把优势函数分成两种情况来看。

情况一:优势函数为正

A^t>0\hat{A}_t > 0 说明动作 ata_t 比预期更好,我们希望提高它的概率。此时,如果 rt(θ)r_t(\theta)11 增大到 1.11.1,目标函数会变大,这是合理的。但如果 rt(θ)r_t(\theta) 继续增大到 1.51.5,说明新策略已经把这个动作的概率提高得太多。PPO 不希望继续奖励这种过度增长,于是裁剪项会把它限制为:1+ϵ1+\epsilon , 此时目标函数相当于:

min(rt(θ)A^t, (1+ϵ)A^t)\min \left( r_t(\theta)\hat{A}_t,\ (1+\epsilon)\hat{A}_t \right)

因为 A^t>0\hat{A}_t>0,当 rt(θ)>1+ϵr_t(\theta)>1+\epsilon 时,有:

rt(θ)A^t>(1+ϵ)A^tr_t(\theta)\hat{A}_t > (1+\epsilon)\hat{A}_t

所以 min\min 会选择裁剪后的项:

(1+ϵ)A^t(1+\epsilon)\hat{A}_t

也就是说,好动作的概率可以提高,但提高到一定程度后,PPO 就不再给额外奖励。

情况二:优势函数为负

A^t<0\hat{A}_t < 0 说明动作 ata_t 比预期更差,我们希望降低它的概率。此时,如果 rt(θ)r_t(\theta)11 降到 0.90.9,目标函数会改善,这是合理的。但如果 rt(θ)r_t(\theta) 继续降到 0.30.3,说明新策略几乎把这个动作彻底压下去了。PPO 同样不希望这种变化太极端。此时裁剪项会把概率比限制为:1ϵ1-\epsilon , 目标函数变为:

min(rt(θ)A^t, (1ϵ)A^t)\min \left( r_t(\theta)\hat{A}_t,\ (1-\epsilon)\hat{A}_t \right)

注意这里 A^t<0\hat{A}_t<0。当 rt(θ)<1ϵr_t(\theta)<1-\epsilon 时,原始项反而会变得“没那么小”:

rt(θ)A^t>(1ϵ)A^tr_t(\theta)\hat{A}_t > (1-\epsilon)\hat{A}_t

于是 min\min 会选择更保守的裁剪项:

(1ϵ)A^t(1-\epsilon)\hat{A}_t

也就是说,坏动作的概率可以降低,但降低到一定程度后,PPO 也不会继续奖励这种过度压制。

这就是 PPO 的精髓:它不是禁止策略更新,而是限制那些“看起来收益很大,但其实可能破坏稳定性”的过度更新。

15.4 优势函数

PPO 本身主要解决“策略如何稳定更新”的问题。但在实际使用中,PPO 通常会配合 GAE 来计算优势函数。前面我们已经介绍过 GAE。这里就不再详细讲解了,但是注意,在得到优势函数后,我们通常还会构造价值函数的回归目标:

R^t=A^t+Vϕ(st)\hat{R}_t = \hat{A}_t + V_\phi(s_t)

这里的 R^t\hat{R}_t 可以理解成给 Critic 学习的目标回报。Actor 使用 A^t\hat{A}_t 来判断动作好坏,Critic 使用 R^t\hat{R}_t 来学习状态价值。在实践中,我们还常常会对优势函数做标准化:

A^tA^tμA^σA^+ε\hat{A}_t \leftarrow \frac{ \hat{A}_t-\mu_{\hat{A}} }{ \sigma_{\hat{A}}+\varepsilon }

这样做不是改变优势函数的相对排序,而是让梯度尺度更稳定。对于 PPO 这种要进行多轮小批量更新的算法来说,优势标准化通常非常有帮助。

15.5 PPO完整损失函数

PPO 虽然最核心的是策略裁剪目标,但完整训练时通常包含三部分:

  1. 策略损失;
  2. 价值函数损失;
  3. 熵正则化项。

①. 策略目标

策略部分就是前面介绍的裁剪目标:

LCLIP(θ)=Et[min(rt(θ)A^t, clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{\text{CLIP}}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta)\hat{A}_t,\ \text{clip}\left(r_t(\theta),1-\epsilon,1+\epsilon\right)\hat{A}_t \right) \right]

因为深度学习框架通常默认最小化 loss,所以实现时会写成负号形式:

Lpolicy(θ)=LCLIP(θ)L^{\text{policy}}(\theta) = - L^{\text{CLIP}}(\theta)

也就是说,最大化 PPO 的策略目标,等价于最小化它的相反数。

②. 价值函数损失

Critic 的任务是让 Vϕ(st)V_\phi(s_t) 接近目标回报 R^t\hat{R}_t。最常见的价值函数损失是均方误差:

Lvalue(ϕ)=Et[(Vϕ(st)R^t)2]L^{\text{value}}(\phi) = \mathbb{E}_t \left[ \left( V_\phi(s_t)-\hat{R}_t \right)^2 \right]

如果策略网络和价值网络共享底层特征,那么这个损失也会影响共享网络的表示学习。一般来说,价值函数太弱会导致优势估计质量下降,进而影响策略更新;但价值函数损失权重太大,也可能让网络过度服务于价值预测,而削弱策略学习。

③. 熵正则化

为了鼓励探索,PPO 通常也会加入熵奖励:

H(πθ(st))=aπθ(ast)logπθ(ast)\mathcal{H} \left( \pi_\theta(\cdot \mid s_t) \right) = - \sum_a \pi_\theta(a \mid s_t) \log \pi_\theta(a \mid s_t)

对于离散动作空间,熵越大,说明策略越随机,探索越充分;熵越小,说明策略越确定,越倾向于选择少数动作。

熵项通常写成:

Lentropy(θ)=Et[H(πθ(st))]L^{\text{entropy}}(\theta) = \mathbb{E}_t \left[ \mathcal{H} \left( \pi_\theta(\cdot \mid s_t) \right) \right]

在总损失中,我们希望最大化熵,所以在最小化形式下需要减去熵项。

④. 总损失函数

综合起来,PPO 的总损失通常写成:

L(θ,ϕ)=LCLIP(θ)+cvLvalue(ϕ)ceLentropy(θ)L(\theta,\phi) = - L^{\text{CLIP}}(\theta) + c_v L^{\text{value}}(\phi) - c_e L^{\text{entropy}}(\theta)

其中:

  • cvc_v 是价值函数损失系数;
  • cec_e 是熵正则化系数;
  • θ\theta 表示策略网络参数;
  • ϕ\phi 表示价值网络参数。

如果 Actor 和 Critic 共享一部分网络参数,那么 θ\thetaϕ\phi 并不一定完全独立。更常见的做法是:共享一个 feature extractor,然后分别接 actor head 和 critic head。直观地说,这个总损失在同时做三件事:

  • 让策略朝着优势函数指示的方向更新;
  • 让价值函数更准确地预测回报;
  • 保留一定随机性,避免策略过早塌缩。

15.6 PPO训练流程

总体流程如图15.1所示:

c19.jpg

第一步:用当前策略采样数据

使用当前策略 πθold\pi_{\theta_{\text{old}}} 与环境交互,收集一段轨迹:

(st,at,rt,st+1,dt)(s_t,a_t,r_t,s_{t+1},d_t)

其中 dtd_t 表示 episode 是否结束。同时,需要记录旧策略下动作的 log probability:

logπθold(atst)\log \pi_{\theta_{\text{old}}}(a_t \mid s_t)

这个量非常重要,因为后面计算概率比时需要用到它:

rt(θ)=exp(logπθ(atst)logπθold(atst))r_t(\theta) = \exp \left( \log \pi_\theta(a_t \mid s_t) - \log \pi_{\theta_{\text{old}}}(a_t \mid s_t) \right)

用 log probability 来算比值比直接算概率更稳定,尤其是在动作概率很小的时候。

第二步:计算价值、优势和回报目标

使用 Critic 得到状态价值:

Vϕ(st)V_\phi(s_t)

然后用 GAE 计算优势函数:

A^t=l=0Tt1(γλ)lδt+l\hat{A}_t = \sum_{l=0}^{T-t-1} (\gamma \lambda)^l \delta_{t+l}

并构造价值函数目标:

R^t=A^t+Vϕ(st)\hat{R}_t = \hat{A}_t + V_\phi(s_t)

通常还会对 A^t\hat{A}_t 做标准化,以稳定训练。

第三步:固定旧策略数据,多轮优化新策略

这是 PPO 与更简单的 A2C 类算法相比非常重要的一点。A2C 通常是采样一批数据,然后更新一次或少数几次。而 PPO 会在同一批采样数据上进行多轮 epoch 的小批量优化。每次优化时,旧策略的 log probability 保持不变,新策略的 log probability 会随着参数更新而变化。于是 PPO 可以反复计算:

rt(θ)=exp(logπθ(atst)logπθold(atst))r_t(\theta) = \exp \left( \log \pi_\theta(a_t \mid s_t) - \log \pi_{\theta_{\text{old}}}(a_t \mid s_t) \right)

再代入裁剪目标:

LCLIP(θ)=Et[min(rt(θ)A^t, clip(rt(θ),1ϵ,1+ϵ)A^t)]L^{\text{CLIP}}(\theta) = \mathbb{E}_t \left[ \min \left( r_t(\theta)\hat{A}_t,\ \text{clip}\left(r_t(\theta),1-\epsilon,1+\epsilon\right)\hat{A}_t \right) \right]

这也是 PPO 高效的原因之一:它不会只用一次 rollout 数据就丢掉,而是会在“不过度偏离旧策略”的前提下,多次利用这批数据。

不过,这里也有边界。PPO 不能无限重复使用同一批数据。因为随着新策略不断更新,数据逐渐变得“不像是当前策略采出来的”。裁剪机制可以缓解这个问题,但不能完全消除这个问题。因此 PPO 通常只对一批数据训练若干个 epoch,而不是训练到完全收敛。

十六. 现代PPO算法模板

这一章我们来整理一份现代 PPO 的完整训练模板。前面的章节里我们已经讲过 GAE、Actor-Critic 共享网络、PPO 裁剪目标这些组件——这里不再从头推导,而是直接用代码把它们拼成一个可以直接跑起来、并且训练比较稳定的 pipeline。

代码中 GAE 模块和 ActorCriticNet 网络沿用 14.1、14.2 小节的实现,这里只展示上层的 PPO 主逻辑。我们会先介绍滑动平均辅助类,然后逐步拆解 PPO 核心流程。

先引入必要库:

import time
from typing import Dict, Tuple, Optional
import torch
import torch.nn.functional as F
from torch.distributions import Categorical
from tqdm import tqdm
from collections import deque
from numbers import Number
import torch.nn as nn
import gymnasium as gym
from gymnasium.wrappers.vector import NumpyToTorch

16.1 滑动平均

先来看一个辅助工具类,固定容量滑动平均

class FixedSizeMovingAverage:
    """固定容量的滑动平均值计算器"""

    def __init__(self, capacity: int):
        if not isinstance(capacity, int) or capacity <= 0:
            raise ValueError("容量必须是正整数")
        self._deque = deque(maxlen=capacity)

    def add(self, value: Number) -> None:
        """
        添加一个数值,如果序列已满,自动丢弃最旧元素。
        """
        if not isinstance(value, Number):
            raise TypeError(f"只允许添加数值类型,收到 {type(value).__name__}")
        self._deque.append(value)

    def mean(self) -> float:
        """返回当前序列中所有元素的平均值,序列为空时返回 0.0。"""
        if not self._deque:
            return 0.0
        returns = sum(self._deque) / len(self._deque)
        return round(returns, 2)

这个东西的作用很简单:在训练循环中,我们每隔一段时间评估一次策略,并用滑动平均来平滑评估回报曲线,方便在 tqdm 进度条里观察趋势。它不是 PPO 必需的理论组件,但能让实验过程更友好。

16.2 PPO类初始化

下面我们把 PPO 类里的每个函数拆开,每一小节就是PPO类中的一些函数的代码,逐行加上详细的中文注释。即使你对 PPO 的实现细节还不太熟,跟着注释走一遍,也能看清整个算法如何从“采样一批数据”到“用 clip 小心翼翼地更新多轮”的全过程。

class PPO:
    """
    PPO(Proximal Policy Optimization)完整训练器。

    同时负责采样、训练、评估与学习率调度。传入的 train_envs 是向量化环境,
    可以并行采样多个 trajectory。
    """

    def __init__(
        self,
        train_envs,                   # 向量化训练环境(如 gym.vector.AsyncVectorEnv)
        eval_env,                     # 单环境评估环境
        state_dim: int,               # 状态向量维度
        action_dim: int,              # 离散动作的总数
        hidden_size: int = 256,       # 共享网络的隐藏层尺寸
        lr: float = 3e-4,             # 初始学习率
        gamma: float = 0.99,          # 折扣因子
        gae_lambda: float = 0.95,     # GAE λ 参数,控制偏差-方差的折中
        value_loss_coef: float = 0.5, # 价值损失在总损失中的权重
        entropy_coef: float = 0.01,   # 熵正则化系数(越大越鼓励探索)
        max_grad_norm: float = 0.5,   # 梯度裁剪的最大范数
        rollout_steps: int = 128,     # 每次采样多少步(单环境步数)
        update_epochs: int = 4,       # 同一批数据重复训练多少个 epoch
        num_minibatches: int = 4,     # 将批量数据拆成多少个小批次
        policy_clip_coef: float = 0.2,# PPO 策略损失中的 clip 范围 ε
        value_clip_coef: float = 0.2, # 价值损失中的 clip 范围(如果启用)
        use_clipped_value_loss: bool = True,  # 是否使用 clipped value loss
        device: torch.device = torch.device("cpu"),
        reward_window_size: int = 20, # 计算滑动平均奖励的窗口大小
        reward_scale: float = 80.0,   # 奖励缩放系数(简单的 scale,不是标准化)
    ):
        self.train_envs = train_envs
        self.eval_env = eval_env
        self.num_envs = train_envs.num_envs   # 并行环境数量 N
        self.device = device

        # 训练相关的批大小
        self.rollout_steps = rollout_steps        # 每个环境采样的步数 T
        self.update_epochs = update_epochs
        self.num_minibatches = num_minibatches
        self.batch_size = self.num_envs * self.rollout_steps   # 总样本量 = N×T
        self.minibatch_size = self.batch_size // self.num_minibatches

        # PPO 损失的超参数
        self.policy_clip_coef = policy_clip_coef
        self.value_clip_coef = value_clip_coef
        self.use_clipped_value_loss = use_clipped_value_loss
        self.value_loss_coef = value_loss_coef
        self.entropy_coef = entropy_coef
        self.max_grad_norm = max_grad_norm

        # 学习率调度相关
        self.initial_lr = lr
        self.reward_scale = reward_scale
        self.reward_window_size = reward_window_size
        self.global_step = 0   # 全局环境交互步数计数器

        # ---------- 核心组件 ----------
        # 共享权重的 Actor-Critic 网络
        self.actor_critic = ActorCriticNet(state_dim, action_dim, hidden_size).to(device)

        # Adam 优化器,eps=1e-5 是 RL 中常见的数值稳定性设置
        self.optimizer = torch.optim.Adam(
            self.actor_critic.parameters(),
            lr=lr,
            eps=1e-5,
            weight_decay=0,
        )

        # 广义优势估计器
        self.gae = GAE(gamma, gae_lambda)

        # 保存当前观测,保证连续调用 collect_rollouts 时环境状态接续
        initial_observation, _ = self.train_envs.reset()
        self.current_observation = self._to_tensor(initial_observation)

16.3 PPO类中的辅助函数

    def _to_tensor(self, data, dtype=None) -> torch.Tensor:
        """
        将环境返回的 numpy 数组或普通张量统一转移到 self.device 上,
        并可选转换 dtype。这对向量化环境特别重要,因为环境 step 返回的
        reward、done 等通常是 numpy,需要先转为张量。
        """
        if torch.is_tensor(data):
            tensor = data.to(self.device)
        else:
            tensor = torch.as_tensor(data, device=self.device)

        if dtype is not None:
            tensor = tensor.to(dtype=dtype)

        return tensor

    def normalize_rewards(self, rewards: torch.Tensor) -> torch.Tensor:
        """
        简单的奖励缩放:除以一个固定常数 reward_scale。
        这不是逐 batch 的标准化,而是针对特定环境的先验缩放。
        如果环境本身的 reward 量级已经合理(例如 CartPole 的 ±1),
        可以把 reward_scale 设为 1.0,相当于不做任何变换。
        """
        return rewards / self.reward_scale

    def update_learning_rate(self, total_steps: int) -> None:
        """
        线性衰减学习率,从初始值逐渐降至初始值的 5%(由 lower bound 控制)。
        这样做是为了训练初期大步探索、后期精细收敛。
        total_steps 是预定的总训练步数,progress 由 self.global_step 决定。
        """
        progress = self.global_step / total_steps
        new_lr = self.initial_lr * (1.0 - progress)
        # 保留 5% 的初始学习率作为下限,防止后期更新停滞
        new_lr = max(new_lr, self.initial_lr * 0.05)

        for param_group in self.optimizer.param_groups:
            param_group["lr"] = new_lr

16.4 采样

这一段负责与环境交互,收集一个完整 batch 的轨迹数据,并利用 GAE 计算优势。

    def collect_rollouts(self, n_steps: int) -> Dict[str, torch.Tensor]:
        """
        使用当前策略与环境交互 n_steps 步(每个并行环境独立走 n_steps),
        得到形状为 (num_envs, n_steps, ...) 的观测、动作、奖励等。
        最后利用 GAE 一次性算出所有时间步的优势 A_t。

        返回字典中包含了模型更新所需的全部数据:
          - observations:  状态 s_t
          - actions:       实际采取的动作 a_t
          - old_log_probs: 旧策略(采集时策略)的 log π_old(a_t|s_t)
          - advantages:    标准化后的优势 A_t
          - returns:       R_t = A_t + V(s_t),作为价值网络的目标
          - old_values:    旧价值 V_old(s_t),供 clipped value loss 使用
        """
        self.actor_critic.eval()  # 采样时切换到评估模式,不启用 dropout / batchnorm

        observations = []
        actions = []
        values = []
        rewards = []
        dones = []
        old_log_probs = []

        with torch.no_grad():   # 采样不计算梯度,节省显存和计算
            for _ in range(n_steps):
                observation = self.current_observation
                observations.append(observation)

                # 从当前策略中采样动作,并同时拿到 log_prob、熵和 V(s)
                action, log_prob, _, value = self.actor_critic.get_action_and_value(
                    observation
                )

                actions.append(action)
                old_log_probs.append(log_prob)
                values.append(value.squeeze(-1))  # (num_envs,)

                # 环境交互:动作先转到 CPU 再变成 numpy
                next_observation, reward, terminated, truncated, _ = (
                    self.train_envs.step(action.int().cpu().numpy())
                )

                next_observation = self._to_tensor(next_observation)
                reward = self._to_tensor(reward, dtype=torch.float32)
                terminated = self._to_tensor(terminated)
                truncated = self._to_tensor(truncated)

                # 在 PPO 中,terminated(自然结束)和 truncated(时间截断)都视为 episode 结束
                done = torch.logical_or(terminated, truncated).float()

                rewards.append(reward)
                dones.append(done)

                # 更新当前观测,下一次循环继续推进
                self.current_observation = next_observation

        # 将列表堆叠成张量,形状为 (num_envs, n_steps, ...) 或 (num_envs, n_steps)
        observations = torch.stack(observations, dim=1)
        actions = torch.stack(actions, dim=1)
        values = torch.stack(values, dim=1)
        rewards = torch.stack(rewards, dim=1)
        dones = torch.stack(dones, dim=1)
        old_log_probs = torch.stack(old_log_probs, dim=1)

        # 奖励缩放
        rewards = self.normalize_rewards(rewards)

        # 为 GAE 提供最后一个状态的价值作为 bootstrap
        with torch.no_grad():
            next_value = self.actor_critic(self.current_observation)[1].squeeze(-1)

        # values_with_bootstrap 多出一列,用于计算 GAE 中最后一步的 δ
        values_with_bootstrap = torch.cat(
            [values, next_value.unsqueeze(1)],
            dim=1,
        )

        # 计算优势 A_t
        advantages = self.gae(dones, rewards, values_with_bootstrap)

        # 价值函数的目标 R_t = A_t + V(s_t)
        returns = advantages + values

        # 优势标准化:均值为 0,标准差为 1,加上微小常数防止除零
        advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)

        return {
            "observations": observations,
            "actions": actions,
            "old_log_probs": old_log_probs,
            "advantages": advantages,
            "returns": returns,
            "old_values": values,
        }

16.5 核心更新函数

这是 PPO 的灵魂所在。它拿 collect_rollouts 返回的数据,在多个 epoch 里反复打乱、划分成 minibatch,然后对策略损失和价值损失进行 clip,最后合并熵正则化项。

    def update(
        self,
        rollout_data: Dict[str, torch.Tensor],
    ) -> Tuple[float, float, float, float]:
        """
        使用一批 rollout 数据对 Actor-Critic 网络执行多次 epoch 的小批量更新。

        返回四个损失的训练期间平均值:
          - policy_loss
          - value_loss
          - entropy_loss
          - total_loss
        """
        self.actor_critic.train()  # 切换到训练模式

        observations = rollout_data["observations"]
        actions = rollout_data["actions"]
        old_log_probs = rollout_data["old_log_probs"]
        advantages = rollout_data["advantages"]
        returns = rollout_data["returns"]
        old_values = rollout_data["old_values"]

        # 将 (num_envs, rollout_steps, ...) 展平成 (batch_size, ...)
        # 用 reshape((-1,) + observations.shape[2:]) 可以兼容图像等三维状态
        batch_observations = observations.reshape((-1,) + observations.shape[2:])
        batch_actions = actions.reshape(-1)
        batch_old_log_probs = old_log_probs.reshape(-1)
        batch_advantages = advantages.reshape(-1)
        batch_returns = returns.reshape(-1)
        batch_old_values = old_values.reshape(-1)

        # 记录所有 minibatch 的损失和,最后求平均
        policy_loss_sum = 0.0
        value_loss_sum = 0.0
        entropy_loss_sum = 0.0
        total_loss_sum = 0.0

        for _ in range(self.update_epochs):
            # 每个 epoch 随机打乱 batch 样本的顺序,避免小批次之间的固定偏差
            shuffled_indices = torch.randperm(self.batch_size, device=self.device)

            for start_idx in range(0, self.batch_size, self.minibatch_size):
                end_idx = start_idx + self.minibatch_size
                mini_batch_indices = shuffled_indices[start_idx:end_idx]

                # 取出当前 minibatch 的数据
                mini_batch_observations = batch_observations[mini_batch_indices]
                mini_batch_actions = batch_actions[mini_batch_indices]
                mini_batch_old_log_probs = batch_old_log_probs[mini_batch_indices]
                mini_batch_advantages = batch_advantages[mini_batch_indices]
                mini_batch_returns = batch_returns[mini_batch_indices]
                mini_batch_old_values = batch_old_values[mini_batch_indices]

                # 用当前策略重新评估这批 (s,a)
                _, new_log_probs, entropies, new_values = (
                    self.actor_critic.get_action_and_value(
                        mini_batch_observations,
                        mini_batch_actions,
                    )
                )
                new_values = new_values.squeeze(-1)

                # ================================================================
                # 1. 策略损失(Policy Loss)—— PPO 的核心 clipped surrogate 目标
                #    目标是最大化 L^CLIP(θ),等价于最小化 -L^CLIP(θ)。
                # ================================================================
                # 重要性采样比率 r(θ) = π_θ(a|s) / π_old(a|s)
                log_ratio = new_log_probs - mini_batch_old_log_probs
                # 将 log_ratio 限制在 exp(15) 以下,防止数值溢出
                log_ratio = torch.clamp(log_ratio, max=15.0)
                ratio = log_ratio.exp()

                # 未裁剪的 surrogate 目标:A * r(θ)
                unclipped_policy_loss = mini_batch_advantages * ratio
                # 裁剪后的目标:A * clip(r(θ), 1-ε, 1+ε)
                clipped_policy_loss = mini_batch_advantages * torch.clamp(
                    ratio,
                    1.0 - self.policy_clip_coef,
                    1.0 + self.policy_clip_coef,
                )

                # 对每个样本取 min,相当于在 “保守” 与 “激进” 之间选择更悲观的更新
                # 注意这里梯度只会传过 min 中实际更小的那个部分
                policy_loss = -torch.min(
                    unclipped_policy_loss,
                    clipped_policy_loss,
                ).mean()

                # ================================================================
                # 2. 价值损失(Value Loss)
                #    可选用 clipped value loss,防止价值函数一次更新幅度过大。
                #    基本思想:让新价值 V_new 不要偏离旧价值 V_old 超过 ±value_clip_coef。
                # ================================================================
                if self.use_clipped_value_loss:
                    value_loss_unclipped = (new_values - mini_batch_returns) ** 2

                    clipped_values = mini_batch_old_values + torch.clamp(
                        new_values - mini_batch_old_values,
                        -self.value_clip_coef,
                        self.value_clip_coef,
                    )
                    value_loss_clipped = (clipped_values - mini_batch_returns) ** 2

                    # 取 max 意味着对两种损失取更保守(更大)的那个
                    value_loss = torch.max(
                        value_loss_unclipped,
                        value_loss_clipped,
                    ).mean()
                else:
                    value_loss = F.mse_loss(new_values, mini_batch_returns)

                # ================================================================
                # 3. 熵正则化(Entropy Regularization)
                #    熵越大策略随机性越强。我们鼓励策略保持一定探索能力,
                #    因此从总损失中减去熵(等价于最大化熵)。
                #    这里 entropy_loss 是负值,因为 -coef * entropy。
                # ================================================================
                entropy_loss = -self.entropy_coef * entropies.mean()

                # ================================================================
                # 4. 总损失并反向传播
                # ================================================================
                total_loss = (
                    policy_loss
                    + self.value_loss_coef * value_loss
                    + entropy_loss
                )

                self.optimizer.zero_grad()
                total_loss.backward()

                # 梯度裁剪:防止极少数样本产生过大的梯度,提升训练平稳性
                torch.nn.utils.clip_grad_norm_(
                    self.actor_critic.parameters(),
                    self.max_grad_norm,
                )
                self.optimizer.step()

                # 累加损失,用于最后汇报平均值
                policy_loss_sum += policy_loss.item()
                value_loss_sum += value_loss.item()
                entropy_loss_sum += entropy_loss.item()
                total_loss_sum += total_loss.item()

        # 总共进行了 update_epochs * num_minibatches 次梯度更新
        num_updates = self.update_epochs * self.num_minibatches

        return (
            policy_loss_sum / num_updates,
            value_loss_sum / num_updates,
            entropy_loss_sum / num_updates,
            total_loss_sum / num_updates,
        )

16.6 评估函数

与训练环境分离,用固定策略跑若干完整 episode,直观判断当前性能。

    def evaluate(
        self,
        eval_env=None,
        episodes: int = 5,
        deterministic: bool = True,
    ) -> Tuple[float, float]:
        """
        在评估环境上运行若干完整 episode,计算平均回报和平均 episode 长度。

        deterministic=True 时采用 argmax 动作(观察确定性策略表现);
        为 False 时按照策略分布采样动作(观察随机策略表现)。
        """
        if eval_env is None:
            eval_env = self.eval_env

        self.actor_critic.eval()
        episode_rewards = []
        episode_lengths = []

        with torch.no_grad():
            for _ in range(episodes):
                observation, _ = eval_env.reset()
                observation = self._to_tensor(observation)

                done = False
                total_reward = 0.0
                episode_length = 0

                while not done:
                    # 单环境的一维状态需要补充 batch 维度
                    if observation.dim() == 1:
                        network_input = observation.unsqueeze(0)
                    else:
                        network_input = observation

                    logits, _ = self.actor_critic(network_input)

                    if deterministic:
                        # argmax 选择概率最大的动作
                        action = torch.argmax(logits, dim=-1)
                    else:
                        # 从策略分布中采样,注意参数名必须是 logits
                        action_dist = Categorical(logits=logits)
                        action = action_dist.sample().view(-1)

                    next_observation, reward, terminated, truncated, _ = eval_env.step(
                        action.cpu()
                    )

                    next_observation = self._to_tensor(next_observation)
                    reward = self._to_tensor(reward, dtype=torch.float32)
                    terminated = self._to_tensor(terminated)
                    truncated = self._to_tensor(truncated)

                    done = bool((terminated | truncated).item())
                    total_reward += reward.item()
                    episode_length += 1
                    observation = next_observation

                episode_rewards.append(total_reward)
                episode_lengths.append(episode_length)

        mean_reward = torch.tensor(episode_rewards, dtype=torch.float32).mean().item()
        mean_length = torch.tensor(episode_lengths, dtype=torch.float32).mean().item()

        return mean_reward, mean_length

16.7 主训练循环

最后是整个流程的调度者,它反复执行 “采样 → 更新”,期间定期评估并调整学习率。

    def train(
        self,
        total_steps: int,
        eval_interval: int = 10_000,
    ):
        """
        PPO 主训练循环。

        参数:
          total_steps: 总环境交互步数(不是梯度更新次数)。
          eval_interval: 每隔多少步进行一次完整评估。

        返回:
          reward_history: 每次评估的 episode 平均回报列表。
          length_history: 每次评估的 episode 平均长度列表。
          total_time: 总训练耗时(秒)。
        """
        reward_history = []
        length_history = []
        moving_average_reward = FixedSizeMovingAverage(self.reward_window_size)

        # 一次性采样 rollout_steps * num_envs 步的总步数
        rollout_batch_steps = self.rollout_steps * self.num_envs
        if eval_interval < rollout_batch_steps:
            raise ValueError(
                f"eval_interval 必须大于或等于一次 rollout 的总步数:{rollout_batch_steps}"
            )

        # 令 next_eval_step 初始等于 0,进入 while 后立刻先评估一次初始策略
        next_eval_step = self.global_step
        start_time = time.time()

        with tqdm(total=total_steps, desc="Training PPO") as progress_bar:
            while self.global_step < total_steps:
                # 到达评估节点 → 评估当前策略
                if self.global_step >= next_eval_step:
                    mean_reward, mean_length = self.evaluate(self.eval_env)
                    reward_history.append(mean_reward)
                    length_history.append(mean_length)
                    moving_average_reward.add(mean_reward)

                    progress_bar.set_postfix(
                        {
                            "reward": f"{moving_average_reward.mean():.1f}",
                            "len": f"{mean_length:.1f}",
                        }
                    )
                    next_eval_step += eval_interval

                # 1. 使用当前策略采样 rollout_steps 步
                rollout_data = self.collect_rollouts(self.rollout_steps)
                self.global_step += rollout_batch_steps   # 更新全局步数

                # 2. 根据当前进度调整学习率
                self.update_learning_rate(total_steps)

                # 3. 用这批数据对网络做多轮小批量更新
                self.update(rollout_data)

                progress_bar.update(rollout_batch_steps)

        total_time = time.time() - start_time

        return reward_history, length_history, total_time

16.8 LunarLander-v3 环境测试

# 如下为测试代码,与14.6小节几乎相同
if __name__ == "__main__":
    envs = gym.make_vec(
        "LunarLander-v3",
        continuous=False,
        gravity=-10.0,
        enable_wind=True,
        wind_power=10.0,
        turbulence_power=1.0,
        num_envs=8,
        vectorization_mode="async",
    )
    envs = NumpyToTorch(envs)

    eval_env = gym.make_vec(
        "LunarLander-v3",
        continuous=False,
        gravity=-10.0,
        enable_wind=True,
        wind_power=10.0,
        turbulence_power=1.0,
        num_envs=1,
    )
    eval_env = NumpyToTorch(eval_env)

    agent = PPO(
        envs,
        eval_env,
        8,
        4,
        lr=3e-4,
        gamma=0.99,
        rollout_steps=512,
        entropy_coef=0.01,
        update_epochs=5,
        num_minibatches=8,
        use_clipped_value_loss=True,
        reward_scale=80,
    )
    
    rewards,lengths,total_time = agent.train(600000, eval_interval=4096)
    envs.close()
    eval_env.close()

通过把rewards曲线绘制出来后,如下图所示,可以直观地看到,PPO的训练过程非常平稳,与A2C相比几乎看不到剧烈的奖励抖动,并且收敛速度也明显更快,其实不止这一个环境,PPO在大量基准任务上都表现出远超A2C的鲁棒性和收敛速度,也让它成为现代深度强化学习中最常被采用的基础算法之一,广泛用于机器人控制、游戏AI以及大语言模型的对齐训练等前沿领域。

c20.jpg


END~

e6.jpg