强化学习算法:PPO
paperreading
本文字数:8.2k 字 | 阅读时长 ≈ 34 min

强化学习算法:PPO

paperreading
本文字数:8.2k 字 | 阅读时长 ≈ 34 min

强化学习专项:1. 基本概念 · 2. DQN · 3. Policy Gradient · 4. PPO(本文) · 5. 如何理解强化学习

1. 什么是 PPO?

PPO(Proximal Policy Optimization,近端策略优化) 是一种基于策略的 on-policy 强化学习算法。on-policy 表示训练数据由当前策略采集;策略完成更新后,需要重新收集数据,不能像 DQN 一样长期使用 replay buffer 中的旧样本。

PPO 通常采用 Actor-Critic 结构:

PPO 仍然沿用 Policy Gradient 的基本思想:没有人提供正确动作,策略只能执行动作并观察结果。如果动作带来的结果比当前状态下通常能取得的结果更好,就提高它的概率;反之则降低它的概率。

前面的 REINFORCE 直接使用折扣回报 \(U_t\) 调整动作概率,主要有两个问题:

PPO 分别处理这两个问题:Critic 估计状态价值,GAE 根据 reward 和价值预测计算优势 \(\hat{A}_t\);clip 再限制一次训练中策略改变得过多。

一次 PPO 更新分成“收集数据”和“优化网络”两个阶段:

使用当前策略收集一批 rollout
              ↓
Critic 预测状态价值,计算 TD 误差和 GAE
              ↓
Actor 根据优势调整动作概率
Critic 回归新的价值目标
              ↓
通过 clip 限制 Actor 的更新幅度
              ↓
丢弃旧 rollout,使用新策略重新采集

这里的 rollout 是使用当前策略连续收集的一段交互数据。它可以包含多个完整 episode,也可能停在某个尚未结束的 episode 中。后面的公式都在说明这些交互数据怎样变成 Actor 和 Critic 的训练信号。

2. 优势函数与 GAE

这一章要构造两个训练信号:Actor 需要知道实际动作比预期好多少,Critic 需要一个可以回归的价值目标。两者都从状态价值和 rollout 中的 reward 开始计算。

2.1 什么是优势?

优势函数用于衡量动作 \(a_t\) 相对于状态 \(s_t\) 下平均表现的好坏:

\[ A_\pi(s_t,a_t) =Q_\pi(s_t,a_t)-V_\pi(s_t) \]

\(Q_\pi(s,a)\) 表示在状态 \(s\) 先执行动作 \(a\),之后按照策略 \(\pi\) 行动时的期望折扣回报;\(V_\pi(s)\) 则没有预先指定当前动作,它表示从状态 \(s\) 出发,始终按照策略 \(\pi\) 行动时的期望折扣回报。

下标 \(\pi\) 用来强调价值取决于后续采用的策略:即使起点相同,策略不同,\(Q_\pi\) 和 \(V_\pi\) 也可能不同。

真实的 \(Q_\pi\) 和 \(V_\pi\) 无法直接得到,因此 PPO 使用 Critic 近似状态价值:

\[ V_\phi(s_t)\approx V_\pi(s_t) \]

这里的“平均表现”不是整个 batch 的平均回报,而是从同一状态 \(s_t\) 出发、按照当前策略选择动作时的期望回报 \(V_\pi(s_t)\)。因此,\(A_\pi(s_t,a_t)>0\) 表示动作 \(a_t\) 比当前策略在这个状态下通常会做出的选择更好。

2.2 从优势函数到单步 TD 误差

接下来需要估计这个优势。根据 Bellman 关系,动作价值可以写成一步奖励与下一状态价值的期望:

\[ Q_\pi(s_t,a_t) =\mathbb{E} \left[ r_t+\gamma(1-d_t)V_\pi(s_{t+1}) \mid s_t,a_t \right] \]

将它代入 \(A_\pi=Q_\pi-V_\pi\):

\[ A_\pi(s_t,a_t) =\mathbb{E} \left[ r_t+\gamma(1-d_t)V_\pi(s_{t+1}) -V_\pi(s_t) \mid s_t,a_t \right] \]

这个式子已经给出了优势的计算方向,但真实的期望和 \(V_\pi\) 都无法直接得到。实际训练会使用 rollout 中采样到的一条 transition \((s_t,a_t,r_t,s_{t+1},d_t)\),并用 Critic 的预测 \(V_{\phi_{\mathrm{old}}}\) 近似 \(V_\pi\)。由此得到的括号内数值被记作 \(\delta_t\):

\[ \delta_t =r_t+\gamma(1-d_t)V_{\phi_{\mathrm{old}}}(s_{t+1}) -V_{\phi_{\mathrm{old}}}(s_t) \]

因此,\(\delta_t\) 不是突然引入的新目标,而是 \(A_\pi=Q_\pi-V_\pi\) 经过 Bellman 展开后,用一次实际交互和 Critic 预测计算出的单步 TD(Temporal Difference,时序差分)误差。它衡量“当前 reward 加下一状态价值”与“Critic 原预测”之间的差距。如果 Critic 能准确表示 \(V_\pi\),那么:

\[ A_\pi(s_t,a_t) =\mathbb{E} \left[ \delta_t\mid s_t,a_t \right] \]

单个 \(\delta_t\) 只是这个期望的一次采样,不一定恰好等于真实优势。\(\delta_t>0\) 表示这一步的实际结果比 Critic 原来的预期好,\(\delta_t<0\) 则表示比预期差。\(d_t=1\) 表示执行动作后真正终止,此时下一状态没有未来价值。

下标 \(\mathrm{old}\) 表示这些价值是在收集当前 rollout 时计算并保存的。它不要求额外创建一个“旧 Critic 网络”;在优化开始前缓存 \(V(s_t)\) 和 \(V(s_{t+1})\),并在本轮优化中保持这些数值不变即可。

2.3 GAE 怎样组合多个 TD 误差?

单个 \(\delta_t\) 只使用一步 transition,容易受到当前 reward、下一状态和 Critic 预测误差的影响。PPO 不直接把它当成最终优势,而是继续参考后面若干步的 TD 误差。

PPO 通常使用 GAE(Generalized Advantage Estimation,广义优势估计)组合多个时间步的 TD 误差:

核心公式:GAE 优势估计

\[ \hat{A}_t =\delta_t +\gamma\lambda(1-e_t)\hat{A}_{t+1} \]

这个公式比前面的 TD 误差多了 \(\lambda\) 和 \(e_t\),它们分别控制“看多远”和“在哪里停止”:

之所以已经有 \(\gamma\) 还需要 \(\lambda\),是因为两者控制的不是同一件事:\(\gamma\) 决定未来奖励在任务目标中有多重要,\(\lambda\) 决定计算当前优势时要使用多少后续 TD 误差。\(\lambda=0\) 时 \(\hat{A}_t=\delta_t\),只看当前一步;\(\lambda\) 越接近 1,考虑的后续信息越多,但估计的波动通常也会更大。

\(e_t\) 和前面的 \(d_t\) 也略有不同:\(d_t\) 表示环境真正终止,用于判断下一状态是否还有价值;\(e_t\) 表示当前 episode 是否已经结束,用于防止 GAE 递推到下一个 episode。如果只是达到环境的时间上限,那么 \(d_t=0\)、\(e_t=1\);如果没有这种截断,通常可以直接认为 \(d_t=e_t\)。

把 GAE 和前面的 TD 误差对应起来

可以把 \(\delta_t\) 理解为第 \(t\) 步的“意外程度”:执行动作后得到的结果,比 Critic 原来预计的好多少。rollout 中每一次状态转移都会产生一个 TD 误差。为方便观察,先假设下面三个时间步属于同一个 episode,中间没有终止:

\[ \begin{aligned} \delta_t &=r_t+\gamma V_{\phi_{\mathrm{old}}}(s_{t+1}) -V_{\phi_{\mathrm{old}}}(s_t) \\ \delta_{t+1} &=r_{t+1}+\gamma V_{\phi_{\mathrm{old}}}(s_{t+2}) -V_{\phi_{\mathrm{old}}}(s_{t+1}) \\ \delta_{t+2} &=r_{t+2}+\gamma V_{\phi_{\mathrm{old}}}(s_{t+3}) -V_{\phi_{\mathrm{old}}}(s_{t+2}) \end{aligned} \]

如果只用 \(\delta_t\),动作 \(a_t\) 只能收到第一条 transition 提供的评价。GAE 则从后向前递推,把后续 transition 暴露出来的误差信息也传回时间步 \(t\):

\[ \hat{A}_t =\delta_t +\gamma\lambda\delta_{t+1} +(\gamma\lambda)^2\delta_{t+2} +\cdots \]

因此,距离当前动作越远的 TD 误差,权重越小。这里组合的不是几份不同的 loss,而是同一条 rollout 上连续多个时间步的 TD 误差,最后得到动作 \(a_t\) 的一个优势估计 \(\hat{A}_t\)。

当 \(\lambda=1\) 时,可以更直接地看出它和回报的关系。下面只展开从 \(t\) 到 \(t+2\) 的三个时间步,并假设 episode 此时还没有终止,因此用 \(V(s_{t+3})\) 估计后续尚未观察到的回报。为简化符号,将 \(V_{\phi_{\mathrm{old}}}\) 暂时写成 \(V\)。把上面的三个 TD 误差代入 GAE:

\[ \begin{aligned} \hat{A}_t ={}&\left[r_t+\gamma V(s_{t+1})-V(s_t)\right] \\ &+\gamma\left[r_{t+1}+\gamma V(s_{t+2})-V(s_{t+1})\right] \\ &+\gamma^2\left[r_{t+2}+\gamma V(s_{t+3})-V(s_{t+2})\right] \\ ={}&r_t+\gamma r_{t+1}+\gamma^2r_{t+2} +\gamma^3V(s_{t+3})-V(s_t) \end{aligned} \]

可以看到,中间的 \(V(s_{t+1})\) 和 \(V(s_{t+2})\) 相互抵消了。剩下的内容就是:

从时间步 t 开始观察到的多步回报
+ 最后一个状态的剩余价值
- Critic 原来对状态 s_t 的预测

这正是在估计“实际结果比原来的状态价值好多少”,也就是优势。如果 episode 已经真正终止,最后一个状态的价值会被设为 0。

实际 PPO 通常令 \(0<\lambda<1\),不让很远的 TD 误差完整传回来。这样得到的是短期 TD 估计和长期回报估计之间的折中:\(\lambda=0\) 时只使用 \(\delta_t\);\(\lambda\) 越接近 1,使用的后续信息越多。

如果某一步已经结束,\((1-e_t)\) 会令递推在这里停止,不会把下一个 episode 的 TD 误差接进来。

2.4 从优势得到 Critic 的价值目标

得到优势估计后,Critic 的价值目标为:

\[ V_t^{\mathrm{target}} =\hat{A}_t+V_{\phi_{\mathrm{old}}}(s_t) \]

这个式子来自优势的定义 \(A=Q-V\)。把它移项,就得到 \(Q=V+A\)。因此可以把 \(\hat{A}_t\) 看成对旧价值预测的一次修正:

对当前采样到的动作来说,\(V_{\phi_{\mathrm{old}}}(s_t)+\hat{A}_t\) 估计的是这次交互从 \(s_t\) 出发能够取得的回报。动作本身由当前策略采样,因此对许多样本取平均后,它可以作为 \(V_\pi(s_t)\) 的学习目标。

rollout 不会直接返回 \(\delta_t\)、\(\hat{A}_t\) 或 \(V_t^{\mathrm{target}}\)。它只保存 reward、结束标记,以及旧 Critic 对当前状态和下一状态的价值预测。收集结束后,PPO 才按照下面的顺序计算:

r_t、V_old(s_t)、V_old(s_{t+1})、结束标记
                       ↓
                 单步 TD 误差 δ_t
                       ↓
          从 episode 末尾向前递推 GAE
                       ↓
                    Â_t
                       ↓
          V_target = V_old(s_t) + Â_t

使用后文实际运行的第一个 episode 说明这个过程。第 13 步是最后一步,环境返回 reward 1,旧 Critic 预测 \(V(s_{13})=-0.1391\)。终止后下一状态价值为 0,因此:

\[ \delta_{13}=1-(-0.1391)=1.1391 \]

因为 episode 已经结束,GAE 不再向后递推:

\[ \hat{A}_{13}=\delta_{13}=1.1391 \]

第 12 步保存的数值为 \(r_{12}=1\)、\(V(s_{12})=-0.1224\)、\(V(s_{13})=-0.1391\),所以:

\[ \delta_{12} =1+0.99\times(-0.1391)-(-0.1224) =0.9847 \]

此时已经知道 \(\hat{A}_{13}\),因此可以继续向前计算:

\[ \hat{A}_{12} =0.9847+0.99\times0.95\times1.1391 =2.0561 \]

于是第 12 步的价值目标为:

\[ V_{12}^{\mathrm{target}} =-0.1224+2.0561 =1.9337 \]

按照相同方式继续向前递推,就能得到 rollout 中每个时间步的 \(\hat{A}_t\) 和 \(V_t^{\mathrm{target}}\)。这些 target 不是环境提供的 ground truth,而是 PPO 根据实际 reward、结束标记和旧 Critic 的预测构造出来的。

有了 prediction 和 target,Critic 就可以通过平方误差进行普通的价值回归:

\[ L^{\mathrm{value}}(\phi) =\frac{1}{2} \mathbb{E}_t \left[ \left( V_\phi(s_t)-V_t^{\mathrm{target}} \right)^2 \right] \]

\(V_\phi(s_t)\) 是当前 Critic 的 prediction,\(V_t^{\mathrm{target}}\) 是刚才构造并固定下来的 target,\(\mathbb{E}_t\) 表示对 batch 中各个时间步的损失取平均。它本质上仍然是一次普通的平方误差回归。

训练 Actor 前,通常还会对一个 batch 内的优势做标准化:

\[ \widetilde{A}_t =\frac{\hat{A}_t-\mu_A}{\sigma_A+\varepsilon} \]

其中,\(\mu_A\) 和 \(\sigma_A\) 是当前 batch 中优势的均值和标准差,\(\varepsilon\) 是防止除以 0 的极小值。标准化只改变 Actor 各样本的相对权重,减小不同 batch 之间的尺度波动。Critic 的 \(V_t^{\mathrm{target}}\) 必须使用标准化前的 \(\hat{A}_t\) 构造,否则它学习的就不再是原始回报尺度。

3. PPO 的裁剪目标

第二章已经得到了 Actor 使用的优势 \(\hat{A}_t\) 和 Critic 使用的 \(V_t^{\mathrm{target}}\)。接下来只剩一个问题:Actor 应该根据优势更新动作概率,但一次不能改变得太多。

3.1 为什么需要新旧策略概率比?

在基础 Policy Gradient 中,Actor 根据 \(\hat{A}_t\log\pi_\theta(a_t\mid s_t)\) 调整动作概率。如果每批数据只更新一次,到这里就已经可以训练。但 PPO 希望对同一批 rollout 更新多轮,以提高数据利用率。

rollout 由更新前的策略 \(\pi_{\theta_{\mathrm{old}}}\) 采集。第一轮反向传播后,Actor 参数发生变化,当前策略变成 \(\pi_\theta\),但正在训练的数据仍然来自旧策略。为了继续使用这批数据,PPO 比较新旧策略对同一个实际动作 \(a_t\) 给出的概率:

\[ \begin{aligned} \rho_t(\theta) &= \frac{\pi_\theta(a_t\mid s_t)} {\pi_{\theta_{\mathrm{old}}}(a_t\mid s_t)} \\ &= \exp \left( \log\pi_\theta(a_t\mid s_t) -\log\pi_{\theta_{\mathrm{old}}}(a_t\mid s_t) \right) \end{aligned} \]

这个概率比并没有改变 Policy Gradient 的基本方向。在更新刚开始、\(\theta=\theta_{\mathrm{old}}\) 时,\(\rho_t=1\),而它的梯度为:

\[ \left.\nabla_\theta\rho_t(\theta) \right|_{\theta=\theta_{\mathrm{old}}} =\left.\nabla_\theta\log\pi_\theta(a_t\mid s_t) \right|_{\theta=\theta_{\mathrm{old}}} \]

因此,在旧策略附近最大化 \(\rho_t\hat{A}_t\),与 Policy Gradient 使用 \(\hat{A}_t\log\pi_\theta(a_t\mid s_t)\) 具有相同的局部更新方向;同时,\(\rho_t\) 还能直接告诉我们动作概率已经变成原来的多少倍。

代码中也不一定需要复制一份旧 Actor。收集 rollout 时保存每个实际动作的 old_log_prob,之后更新网络并重新计算 new_log_prob,两者相减再取指数,就能得到同一个概率比。在当前 rollout 的多轮优化结束前,保存的旧 log probability 始终不变。

把概率比与优势相乘,就能表达策略希望的更新方向:

如果没有限制,直接最大化 \(\mathbb{E}_t[\rho_t\hat{A}_t]\) 就会不断推动概率变化。但 rollout 只反映旧策略附近的行为,策略离开得太远以后,这批数据对新策略的代表性会越来越差。

3.2 clip 怎样限制策略更新?

如果直接在同一批 rollout 上多次更新,当前策略可能迅速偏离采集数据时的旧策略。PPO 使用裁剪目标限制这种变化:

核心公式:PPO 裁剪目标

\[ L^{\mathrm{CLIP}}(\theta) =\mathbb{E}_t \left[ \min \left( \rho_t(\theta)\hat{A}_t,\, \operatorname{clip} \left( \rho_t(\theta), 1-\epsilon_{\mathrm{clip}}, 1+\epsilon_{\mathrm{clip}} \right) \hat{A}_t \right) \right] \]

\(\epsilon_{\mathrm{clip}}\) 是裁剪系数,通常取 0.1 或 0.2。裁剪的作用可以概括为:

优势估计 策略希望怎样更新 裁剪限制
\(\hat{A}_t>0\) 提高动作 \(a_t\) 的概率 \(\rho_t(\theta)>1+\epsilon_{\mathrm{clip}}\) 后,不再奖励更大的增幅
\(\hat{A}_t<0\) 降低动作 \(a_t\) 的概率 \(\rho_t(\theta)<1-\epsilon_{\mathrm{clip}}\) 后,不再奖励更大的降幅

例如令 \(\epsilon_{\mathrm{clip}}=0.2\)、\(\hat{A}_t=2\)。如果动作概率已经提高到原来的 1.4 倍,未裁剪目标为 \(1.4\times2=2.8\),裁剪后最多只按 \(1.2\times2=2.4\) 计算。继续增大概率不会再带来更高的目标值。

clip 并不是把所有概率比强制限制在区间内。它只是在策略已经沿正确方向改变得足够多时,不再鼓励更大的更新;如果策略朝错误方向变化,目标函数仍然会进行惩罚。

由于优化器执行最小化,Actor 实际使用的策略损失为:

\[ L^{\mathrm{policy}}(\theta) =-L^{\mathrm{CLIP}}(\theta) \]

3.3 熵奖励与总损失

为了避免策略过早变得过于确定,训练中通常还会加入熵奖励。这里的 \(H(\pi_\theta)\) 表示策略输出的动作分布有多随机。对于离散动作空间:

\[ H\bigl(\pi_\theta(\cdot\mid s)\bigr) =-\sum_a \pi_\theta(a\mid s) \log\pi_\theta(a\mid s) \]

如果两个动作的概率为 \([0.5,0.5]\),策略还愿意尝试两种动作,熵约为 0.693;如果概率变成 \([0.99,0.01]\),策略几乎只选第一个动作,熵约为 0.056。因此:

训练初期的优势估计还不准确。如果策略过早变成 \([0.99,0.01]\),之后就很难再采样到第二个动作,也可能因此错过更好的选择。代码会计算 batch 中各状态的平均策略熵,并将它加入总损失:

训练公式:代码中实际优化的总损失

\[ L(\theta,\phi) =L^{\mathrm{policy}}(\theta) +c_vL^{\mathrm{value}}(\phi) -c_eH(\pi_\theta) \]

其中,\(c_v\) 是价值损失系数,\(c_e\) 是熵奖励系数。因为优化器执行梯度下降,总损失中使用 \(-c_eH(\pi_\theta)\):熵越大,这一项越小,从而鼓励策略保留一定的随机性。\(c_e\) 决定这种鼓励有多强;它通常取较小的值,避免探索项压过真正的策略目标。

4. PPO 的训练流程

PPO 会不断交替执行两个阶段:收集 rollout 时网络参数保持不变,只与环境交互;进入优化阶段后暂时不再采集数据,固定这批 rollout,对 Actor-Critic 更新若干轮。这样,“旧策略”始终指采集当前 rollout 时的策略。

图中的 rollout 先用于计算 GAE 优势。之后同一批数据分成两条训练路径:Actor 根据新旧策略的概率比和 clip 计算策略损失,Critic 根据价值目标计算价值损失;完成若干轮更新后,再使用新策略收集下一批 rollout。

算法流程

  1. 初始化 Actor \(\pi_\theta(a\mid s)\) 和 Critic \(V_\phi(s)\);

  2. 使用当前策略与环境交互,收集一批 rollout:

    \[ (s_t,a_t,r_t,s_{t+1},d_t,e_t),\quad \log\pi_{\theta_{\mathrm{old}}}(a_t\mid s_t),\quad V_{\phi_{\mathrm{old}}}(s_t),\quad V_{\phi_{\mathrm{old}}}(s_{t+1}) \]

  3. 根据奖励和旧 Critic 的价值预测计算 TD 误差、GAE \(\hat{A}_t\) 和价值目标 \(V_t^{\mathrm{target}}\);

  4. 对优势估计进行标准化,并将 rollout 随机划分为多个 mini-batch;

  5. 对同一批数据优化 \(K\) 轮,计算裁剪策略损失、价值损失和熵奖励;

  6. 完成优化后丢弃当前 rollout,使用更新后的策略重新收集数据;

  7. 重复以上过程,直到训练结束。

mini-batch 是从当前 rollout 中随机取出的一小批时间步,\(K\) 表示把整份 rollout 重复训练多少轮。例如后文代码收集 1024 步,每个 mini-batch 包含 256 步,那么一轮会产生 4 次参数更新;训练 4 轮,总共会更新 16 次。每轮开始前重新打乱数据,避免总以相同顺序训练。

PPO 可以这样重复利用当前 rollout,但这些数据仍然来自更新前不久的策略。完成 \(K\) 轮后必须丢弃它们,不能像 DQN 一样长期使用 replay buffer 中的任意历史数据,因此 PPO 仍然属于 on-policy 算法。

这里收集的是固定数量的时间步,而不是固定数量的完整 episode。同一个 episode 内,上一条数据的 \(s_{t+1}\) 会成为下一条数据的 \(s_t\);如果 episode 提前结束,环境会立即重置,并继续收集,直到凑满 rollout_steps 步。rollout 最后也可能停在某个尚未结束的 episode 中。

算法特点

5. CartPole 代码实现

下面使用 PPO 解决 CartPole。代码完整包含 rollout 收集、GAE、价值目标、优势标准化、多轮 mini-batch 优化,以及策略损失、价值损失和熵奖励。

5.1 代码和前文如何对应?

代码没有另外创建 old Actor 或 old Critic,而是在收集 rollout 时缓存更新前的输出。这些缓存值在同一批数据的 4 轮优化中保持不变:

前文中的量 代码中的变量 来源
\(\log\pi_{\theta_{\mathrm{old}}}(a_t\mid s_t)\) rollout_log_probs 收集动作时由 Actor 计算并保存
\(V_{\phi_{\mathrm{old}}}(s_t)\) rollout_values 收集数据时由 Critic 计算并保存
\(V_{\phi_{\mathrm{old}}}(s_{t+1})\) rollout_next_values 环境返回下一状态后由 Critic 计算并保存
\(d_t\)、\(e_t\) terminated、rollout_episode_ends 前者决定是否把下一状态价值置为 0,后者决定是否切断 GAE
\(\delta_t\) td_error reward、当前价值和下一状态价值共同计算
\(\hat{A}_t\) advantages 从 rollout 末尾向前递推 GAE
\(V_t^{\mathrm{target}}\) value_targets advantages + rollout_values
\(\rho_t(\theta)\) probability_ratios 新 log probability 与缓存的旧 log probability 之差取指数
\(L^{\mathrm{policy}}\)、\(L^{\mathrm{value}}\)、\(H\) policy_loss、value_loss、entropy 分别训练 Actor、Critic,并维持一定探索性

因此,代码执行的正是第 4 节的流程:先完整收集一批数据和旧网络输出,再固定这些数据,多轮更新当前 Actor-Critic。

5.2 一次实际训练是怎样执行的?

下面固定随机种子,实际执行第一个 rollout 以及随后的优化。数值用于展示计算过程,不同设备和依赖版本可能略有差异。

运行配置 数值
随机种子 0
rollout 长度 1024
折扣因子 \(\gamma\) 0.99
GAE 参数 \(\lambda\) 0.95
clip 范围 \([0.8,1.2]\)
mini-batch 大小 256
优化轮数 4
价值损失系数 \(c_v\) 0.5
熵奖励系数 \(c_e\) 0.01

1. Actor 采样动作,Critic 同时评价状态

第一次交互产生的数据为:

量 数值
\(s_0\) \([0.0137,-0.0230,-0.0459,-0.0483]\)
动作概率 \((a=0,a=1)\) \((0.5470,0.4530)\)
采样动作 \(a_0\) 0
旧 log probability -0.6033
\(V_{\mathrm{old}}(s_0)\) -0.2862
\(r_0\) 1
\(V_{\mathrm{old}}(s_1)\) -0.2623

后续时间步重复相同过程。收集 rollout 时网络不更新,只保存交互结果、旧 log probability 和旧价值预测。

2. 固定长度的 rollout 可以跨越多个 episode

第一个 episode 有 14 步。环境随后重置并继续采样,直到累计 1024 步:其中包含 49 个完整 episode,以及新 episode 的前 2 步。rollout 是固定长度的数据段,不要求恰好停在 episode 边界;\(e_t\) 会阻止 GAE 跨越两个 episode。

3. 从后向前计算 TD 误差、GAE 和价值目标

每一步先根据 reward 和 Critic 的价值预测计算单步 TD 误差:

\[ \delta_t =r_t+\gamma(1-d_t)V_{\mathrm{old}}(s_{t+1}) -V_{\mathrm{old}}(s_t) \]

它表示“当前 reward 加下一状态价值”与 Critic 原预测之间的差距。为了让表格更紧凑,下面记:

\[ V_{\mathrm{next},t} =(1-d_t)V_{\mathrm{old}}(s_{t+1}) \]

因此,第 0 步的 TD 误差为:

\[ \delta_0 =1+0.99\times(-0.2623)-(-0.2862) =1.0266 \]

得到每一步的 \(\delta_t\) 后,再从 episode 末尾按照 \(\hat{A}_t=\delta_t+\gamma\lambda(1-e_t)\hat{A}_{t+1}\) 反向递推。本例中 \(\gamma\lambda=0.99\times0.95=0.9405\)。下面集中列出第一个 episode 的计算结果;终止时 \(d_t=1\),所以 \(V_{\mathrm{next},t}=0\):

\(t\) \(V_{\mathrm{old}}(s_t)\) \(V_{\mathrm{next},t}\) \(\delta_t\) \(\hat{A}_t\)
13 -0.1391 0.0000 1.1391 1.1391
12 -0.1224 -0.1391 0.9847 2.0561
11 -0.1501 -0.1224 1.0288 2.9626
10 -0.1777 -0.1501 1.0291 3.8154
9 -0.2055 -0.1777 1.0296 4.6180
8 -0.1839 -0.2055 0.9805 5.3237
7 -0.1624 -0.1839 0.9803 5.9872
6 -0.1879 -0.1624 1.0271 6.6581
5 -0.2133 -0.1879 1.0274 7.2893
4 -0.1892 -0.2133 0.9779 7.8335
3 -0.2136 -0.1892 1.0263 8.3937
2 -0.2380 -0.2136 1.0266 8.9209
1 -0.2623 -0.2380 1.0267 9.4168
0 -0.2862 -0.2623 1.0266 9.8830

因此,第 0 步不是直接把 \(\delta_0=1.0266\) 当成优势,而是继续使用已经计算出的 \(\hat{A}_1\):

\[ \begin{aligned} \hat{A}_0 &=\delta_0+\gamma\lambda\hat{A}_1 \\ &=1.0266+0.9405\times9.4168 \\ &=9.8830 \end{aligned} \]

有了优势以后,Critic 在 \(s_0\) 上的价值目标为:

\[ V_0^{\mathrm{target}} =\hat{A}_0+V_{\phi_{\mathrm{old}}}(s_0) =9.8830-0.2862 =9.5968 \]

最后一步的价值目标则是:

\[ V_{13}^{\mathrm{target}} =1.1391-0.1391 =1.0000 \]

因为第 13 步之后 episode 已经终止,所以它的价值目标只剩当前 reward 1。

4. 标准化优势,再划分 mini-batch

第 0 步的标准化优势为:

\[ \widetilde{A}_0 =\frac{9.8830-8.4141}{4.1191} =0.3566 \]

相关数值集中如下:

rollout 时间步 \(\mu_A\) \(\sigma_A\) \(\widetilde{A}_0\) 每轮 mini-batch 总更新次数
1024 8.4141 4.1191 0.3566 4 16

Actor 使用标准化优势;Critic 的 value_targets 在标准化前已经算好,仍然保持原始回报尺度。

5. 第一次更新前,概率比必然接近 1

刚进入第一轮第一个 mini-batch 时,Actor 还没有更新,所以:

\[ \log\pi_\theta(a_t\mid s_t) =\log\pi_{\theta_{\mathrm{old}}}(a_t\mid s_t) \]

因此所有 \(\rho_t(\theta)\) 都约等于 1,裁剪暂时不会生效。本次第一个 mini-batch 得到:

损失项 数值
policy_loss -0.0248
value_loss 44.4443
entropy 0.6886
total_loss 22.1905

这几个数是 256 个样本组成的 mini-batch 的统计结果。下面取这批数据中的第一个样本(rollout 下标 375),分别计算价值损失和熵:

该样本的量 数值
Critic 当前预测 \(V_\phi(s_{375})\) -0.3325
已计算并固定的价值目标 \(V_{375}^{\mathrm{target}}\) 10.3435
Actor 的动作 0 概率 0.5438
Actor 的动作 1 概率 0.4562

价值损失:先逐个计算预测与目标的平方误差,再求平均并乘以 \(1/2\)。 用表中保留四位小数的数值计算:

\[ \begin{aligned} \text{预测误差} &= -0.3325 - 10.3435 = -10.6760 \\ \text{平方误差} &= (-10.6760) \times (-10.6760) \approx 113.9770 \end{aligned} \]

程序使用未舍入的数值计算,该样本的平方误差约为 113.9764。第一批 256 个样本的平方误差总和为 22755.4746,因此代码中的 value_loss = 0.5 * F.mse_loss(...) 得到:

\[ L^{\mathrm{value}} =\frac{1}{2}\times\frac{22755.4746}{256} \approx44.4443 \]

熵:先用一个状态下所有动作的概率计算,再对这批状态求平均。 CartPole 有两个动作,记它们的概率为 \(p_0,p_1\),则 \(H=-p_0\log p_0-p_1\log p_1\)。上面这个样本的熵为:

\[ H_{375} =-0.5438\log(0.5438) -0.4562\log(0.4562) \approx0.6893 \]

这里使用自然对数,计算的是完整动作分布,而不只是实际采样动作的概率。256 个状态的熵总和为 176.2860,因此 action_distribution.entropy().mean() 得到:

\[ H=\frac{176.2860}{256}\approx0.6886 \]

表中数值经过四舍五入,程序使用未舍入的数值计算。最后把各项组合成总损失:

\[ L^{\mathrm{total}} \approx -0.0248+0.5\times44.4443-0.01\times0.6886 \approx22.1905 \]

这里的 \(0.5\) 是总损失中的权重 \(c_v\),与价值损失定义里的 \(1/2\) 是两个不同的系数。

value_loss 较大是因为初始 Critic 预测接近 0,而价值目标已经达到数个到十几个单位,并不表示环境表现突然变差。

6. 多轮更新后,clip 开始发挥作用

每完成一轮 mini-batch 优化,当前策略都会变化,但缓存的旧 log probability 不变。因此,概率比逐渐偏离 1:

优化轮数 最小概率比 最大概率比 超出 \([0.8,1.2]\) 的样本比例
1 0.9199 1.0949 0.0%
2 0.8286 1.2031 0.1%
3 0.7482 1.2985 10.8%
4 0.6874 1.3705 20.7%

概率比仍可超出 \([0.8,1.2]\),因为 clip 限制的是目标函数提供的更新收益,并不强行截断网络输出。4 轮后两条实际数据的变化如下:

时间步 标准化优势 实际动作 旧 log probability 新 log probability 概率比
28 0.3464 1 -0.8006 -0.6787 1.1296
6 -0.4263 0 -0.6070 -0.8418 0.7907

正优势提高实际动作概率,负优势降低实际动作概率。完成 4 轮优化后丢弃当前 rollout,再用新策略收集 1024 步数据,开始下一轮 on-policy 更新。

5.3 完整代码

from collections import deque

import gymnasium as gym
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.distributions import Categorical


class ActorCritic(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        self.actor = nn.Sequential(
            nn.Linear(state_dim, 64), nn.Tanh(),
            nn.Linear(64, 64), nn.Tanh(),
            nn.Linear(64, action_dim),
        )
        self.critic = nn.Sequential(
            nn.Linear(state_dim, 64), nn.Tanh(),
            nn.Linear(64, 64), nn.Tanh(),
            nn.Linear(64, 1),
        )

    def get_action_distribution(self, states):
        action_logits = self.actor(states)
        return Categorical(logits=action_logits)

    def get_value(self, states):
        return self.critic(states).squeeze(-1)


def main():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    env = gym.make("CartPole-v1")

    random_seed = 0
    torch.manual_seed(random_seed)
    state, _ = env.reset(seed=random_seed)
    env.action_space.seed(random_seed)

    state_dim = env.observation_space.shape[0]
    action_dim = env.action_space.n

    num_updates = 200
    rollout_steps = 1024
    minibatch_size = 256
    update_epochs = 4
    learning_rate = 3e-4
    gamma = 0.99
    gae_lambda = 0.95
    clip_epsilon = 0.2
    value_loss_coefficient = 0.5
    entropy_coefficient = 0.01
    max_gradient_norm = 0.5

    actor_critic = ActorCritic(state_dim, action_dim).to(device)
    optimizer = torch.optim.Adam(
        actor_critic.parameters(),
        lr=learning_rate,
        eps=1e-5,
    )

    rollout_states = torch.zeros(
        (rollout_steps, state_dim), dtype=torch.float32, device=device
    )
    rollout_actions = torch.zeros(
        rollout_steps, dtype=torch.long, device=device
    )
    rollout_log_probs = torch.zeros(
        rollout_steps, dtype=torch.float32, device=device
    )
    rollout_rewards = torch.zeros(
        rollout_steps, dtype=torch.float32, device=device
    )
    rollout_episode_ends = torch.zeros(
        rollout_steps, dtype=torch.float32, device=device
    )
    rollout_values = torch.zeros(
        rollout_steps, dtype=torch.float32, device=device
    )
    rollout_next_values = torch.zeros(
        rollout_steps, dtype=torch.float32, device=device
    )

    episode_score = 0.0
    recent_scores = deque(maxlen=20)
    environment_steps = 0

    for update in range(num_updates):
        # 使用当前策略收集一批 rollout
        for step in range(rollout_steps):
            state_tensor = torch.as_tensor(
                state, dtype=torch.float32, device=device
            )

            with torch.no_grad():
                action_distribution = (
                    actor_critic.get_action_distribution(state_tensor)
                )
                action = action_distribution.sample()
                log_prob = action_distribution.log_prob(action)
                value = actor_critic.get_value(state_tensor)

            next_state, reward, terminated, truncated, _ = env.step(
                action.item()
            )
            episode_ended = terminated or truncated

            with torch.no_grad():
                next_state_tensor = torch.as_tensor(
                    next_state, dtype=torch.float32, device=device
                )
                next_value = actor_critic.get_value(next_state_tensor)
                if terminated:
                    next_value = torch.zeros((), device=device)

            rollout_states[step] = state_tensor
            rollout_actions[step] = action
            rollout_log_probs[step] = log_prob
            rollout_rewards[step] = reward
            rollout_episode_ends[step] = float(episode_ended)
            rollout_values[step] = value
            rollout_next_values[step] = next_value

            state = next_state
            episode_score += reward
            environment_steps += 1

            if episode_ended:
                recent_scores.append(episode_score)
                episode_score = 0.0
                state, _ = env.reset()

        # 从后向前计算 GAE 和 Critic 的价值目标
        advantages = torch.zeros_like(rollout_rewards)
        next_advantage = torch.zeros((), device=device)

        for step in reversed(range(rollout_steps)):
            td_error = (
                rollout_rewards[step]
                + gamma * rollout_next_values[step]
                - rollout_values[step]
            )
            trace_continues = 1.0 - rollout_episode_ends[step]
            next_advantage = (
                td_error
                + gamma
                * gae_lambda
                * trace_continues
                * next_advantage
            )
            advantages[step] = next_advantage

        value_targets = advantages + rollout_values
        normalized_advantages = (
            advantages - advantages.mean()
        ) / (advantages.std(unbiased=False) + 1e-8)

        # 对同一批 rollout 进行多轮 mini-batch 优化
        for _ in range(update_epochs):
            shuffled_indices = torch.randperm(
                rollout_steps, device=device
            )

            for start in range(0, rollout_steps, minibatch_size):
                minibatch_indices = shuffled_indices[
                    start:start + minibatch_size
                ]

                minibatch_states = rollout_states[minibatch_indices]
                minibatch_actions = rollout_actions[minibatch_indices]
                minibatch_old_log_probs = rollout_log_probs[
                    minibatch_indices
                ]
                minibatch_advantages = normalized_advantages[
                    minibatch_indices
                ]
                minibatch_value_targets = value_targets[
                    minibatch_indices
                ]

                action_distribution = (
                    actor_critic.get_action_distribution(minibatch_states)
                )
                new_log_probs = action_distribution.log_prob(
                    minibatch_actions
                )
                entropy = action_distribution.entropy().mean()

                probability_ratios = torch.exp(
                    new_log_probs - minibatch_old_log_probs
                )
                unclipped_objective = (
                    probability_ratios * minibatch_advantages
                )
                clipped_objective = torch.clamp(
                    probability_ratios,
                    1.0 - clip_epsilon,
                    1.0 + clip_epsilon,
                ) * minibatch_advantages
                policy_loss = -torch.min(
                    unclipped_objective,
                    clipped_objective,
                ).mean()

                predicted_values = actor_critic.get_value(
                    minibatch_states
                )
                value_loss = 0.5 * F.mse_loss(
                    predicted_values,
                    minibatch_value_targets,
                )

                total_loss = (
                    policy_loss
                    + value_loss_coefficient * value_loss
                    - entropy_coefficient * entropy
                )

                optimizer.zero_grad()
                total_loss.backward()
                torch.nn.utils.clip_grad_norm_(
                    actor_critic.parameters(),
                    max_gradient_norm,
                )
                optimizer.step()

        if update % 10 == 0:
            mean_score = (
                sum(recent_scores) / len(recent_scores)
                if recent_scores else 0.0
            )
            print(
                f"Update {update:3d} | "
                f"Steps {environment_steps:7d} | "
                f"Mean score {mean_score:6.1f} | "
                f"Loss {total_loss.item():7.4f}"
            )

    env.close()


if __name__ == "__main__":
    main()

代码区分了 Gymnasium 中的 terminated 和 truncated:真正终止时,下一状态价值设为 0;因时间上限截断时,仍使用 Critic 对下一状态的预测,补上尚未观察到的未来回报,这就是 bootstrap。两种情况都会通过 rollout_episode_ends 切断 GAE,避免递推到下一个 episode。

代码中的 clip_grad_norm_ 是另一项常见的稳定训练措施:当一次反向传播得到的梯度过大时,将整体梯度范数限制在 max_gradient_norm 以内。它和 PPO 的 clip 不是一回事:前者限制梯度数值,后者限制新旧策略概率比对目标函数的影响。

完整代码可以压缩成一条主线:

收集 1024 步并缓存旧网络输出
              ↓
计算 TD 误差、GAE 和价值目标
              ↓
标准化优势并随机划分 mini-batch
              ↓
固定旧数据,优化 Actor-Critic 4 轮
              ↓
丢弃 rollout,使用新策略重新采集
Sep 06, 2026