Skip to content

5.3 Actor-Critic 架构

上一节我们推导出了策略梯度定理,看到了 REINFORCE 算法的优雅和它的致命伤——高方差。训练曲线上的锯齿不是小瑕疵,而是策略梯度方法的根本性挑战。

解决高方差有一条清晰的路线:给梯度估计减去一个"基准线"。这个基准线的最佳人选,恰好是第 3 章学过的价值函数 V(s)V(s)。而这个"策略网络 + 价值网络"的组合,就是 RL 中最重要的架构——Actor-Critic。

第一步:引入基线——从"拿了多少分"到"比平均好了多少"

先回到 REINFORCE 的梯度公式:

θJθlogπθ(atst)Gt\nabla_\theta J \approx \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot G_t

这里的 GtG_t 是从时刻 tt 到结束的累积回报。问题在于 GtG_t 波动太大——运气好的时候 Gt=10G_t = 10,运气差的时候 Gt=2G_t = 2,明明是同一个动作。

一个直接的想法是:与其用绝对回报,不如用相对回报——"这次比平时好了多少":

θJθlogπθ(atst)(Gtb(st))\nabla_\theta J \approx \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot (G_t - b(s_t))

其中 b(st)b(s_t) 就是基线(Baseline)。认识一下新出现的角色:

符号角色大白话
GtG_t实际回报"这趟跑了多少分"
b(st)b(s_t)基线"在状态 sts_t,正常情况下能跑多少分"
Gtb(st)G_t - b(s_t)相对回报"比平时好了还是差了"

为什么减去基线能降方差?想象你在赌场玩老虎机,某次赢了 100 块。如果你不知道这台机器的平均赢率,你可能会觉得"选对了!"(Gt=100G_t = 100,很大)。但如果你知道这台机器平均每次赢 80 块,你就会冷静下来——"只比平均多了 20 块而已"(Gtb=20G_t - b = 20,小得多)。基线减掉了"运气"带来的波动,让梯度信号只反映"真正因为动作选择带来的差异"。

数学上可以证明一件关键的事:只要 b(st)b(s_t) 不依赖于动作 aa,引入基线不会改变梯度的期望方向(仍然是正确的梯度方向),但能显著降低方差。这是一个非常罕见的"免费的午餐"——不付出任何代价就改善了训练。

第二步:最好的基线是 V(s)V(s)

基线可以是任何不依赖动作的东西——常数、移动平均、随便什么函数。但最好的基线是什么?

如果你回头看第 3 章对状态价值函数的定义——Vπ(s)V^\pi(s) 是"从状态 ss 出发,遵循策略 π\pi,期望能拿多少分"——你会发现这不正是基线想要的"正常情况下能跑多少分"吗?

把基线替换为 V(s)V(s)

θJθlogπθ(atst)(GtV(st))\nabla_\theta J \approx \nabla_\theta \log \pi_\theta(a_t|s_t) \cdot (G_t - V(s_t))

这里 GtV(st)G_t - V(s_t) 有一个专门的名字——优势函数(Advantage Function)A(s,a)A(s, a)

A(s,a)=GtV(s)A(s, a) = G_t - V(s)

优势函数回答的是一个极其实际的问题:"在这个状态下,选择动作 aa 比随便按策略执行好了多少?"

  • A>0A > 0:这个动作比平均水平好 → 增加它的概率
  • A<0A < 0:这个动作比平均水平差 → 降低它的概率
  • A0A \approx 0:这个动作中规中矩 → 概率基本不变

第三步:用 TD Error 替代 GtG_t——不用等 episode 结束

还有最后一步优化。GtG_t 有一个工程上的麻烦:它需要跑完整个 episode 才能计算。在赌博机这种一步结束的场景无所谓,但在 CartPole(可能撑几百步)或者围棋(几百步)中,你必须等到游戏结束才能更新策略。

还记得第 3 章的 TD Error 吗?

δ=r+γV(s)V(s)\delta = r + \gamma V(s') - V(s)

TD Error 只依赖一步转移——当前状态 ss、动作 aa、即时奖励 rr、下一状态 ss'。不需要等到 episode 结束。而且,TD Error 本身就是优势函数的一种估计:

A(s,a)δ=r+γV(s)V(s)A(s,a) \approx \delta = r + \gamma V(s') - V(s)

直觉上很清楚:TD Error 说的是"我刚才拿到奖励 rr,然后到了状态 ss'——这和 Critic 对当前状态的评估 V(s)V(s) 相比,好了多少?"如果 r+γV(s)r + \gamma V(s')V(s)V(s) 大,说明这一步走得好;如果小,说明走得差。

把 REINFORCE 和 Actor-Critic 放在一起对比:

REINFORCEActor-Critic
动作评估GtG_t(整条轨迹的回报)δ\delta(一步的 TD Error)
需要跑完 episode 吗否,每走一步就能更新
方差高(整条轨迹的随机性)低(只有一步的随机性)
偏差无偏有偏(用估计值更新估计值)
类比跑完马拉松再看总分每跑一公里就调整配速

Actor-Critic 用一步的信息就更新策略,代价是引入了偏差——因为 Critic 自己的估计 V(s)V(s') 可能不准。但实践中,用一点偏差换来大幅降低的方差,几乎总是划算的。

Actor-Critic:两个网络,各司其职

把上面三步整合起来,就得到了强化学习中最经典的架构。Actor 负责选择动作,Critic 负责评估动作的好坏,两者通过优势函数 A(s,a)A(s,a) 协作:

Actor-Critic 数据流

  状态 s

    ├──→ Actor(策略网络)
    │      π(a|s) → 选动作 a
    │                  │
    │              执行动作 a
    │                  │
    │                  ▼
    │              环境 → 返回 r, s'
    │                  │
    ├──→ Critic(价值网络)  │
    │      V(s)  ──────────┤
    │      V(s') ──────────┤
    │                      │
    │      δ = r + γV(s') - V(s)
    │            │
    │            ▼
    │      Actor 更新:θ ← θ + α·∇log π(a|s)·δ
    │      Critic 更新:V(s) ← V(s) + α·δ

    └──→ 下一步,重复以上过程

两个网络共享同一个输入(状态 ss),但各做各的事:

网络角色输入输出学习目标
Actor(演员)选择动作状态 ss动作概率 π(as)\pi(a|s)最大化累积奖励
Critic(评论家)评估局面状态 ss价值估计 V(s)V(s)准确预测未来回报

如果你仔细看 Critic 的更新规则,V(s)V(s)+αδV(s) \leftarrow V(s) + \alpha \cdot \delta——这不就是第 3 章的 TD Learning 吗?Critic 本质上就是第 3 章价值函数 V(s)V(s) 的神经网络实现,它独立地学习"每个状态值多少分"。Actor 则是策略 π(as)\pi(a|s) 的神经网络实现,它根据 Critic 提供的评估来调整自己的行为。

两个函数逼近器(第 3 章从表格到深度学习的过渡)协同工作——Critic 帮 Actor 判断"这个动作比平均好多少",Actor 根据判断调整策略,然后新的策略又产生新的数据让 Critic 学得更好。这就是 Actor-Critic 名字的由来。

用 PyTorch 实现 Actor-Critic

Actor-Critic 的代码比 REINFORCE 多了一个 Critic 网络,但结构依然清晰:

python
import torch
import torch.nn as nn
import torch.optim as optim
import gymnasium as gym
import numpy as np

# ==========================================
# 1. Actor-Critic 网络(共享特征提取层)
# ==========================================
class ActorCritic(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        # 共享的特征提取层
        self.shared = nn.Sequential(
            nn.Linear(state_dim, 128),
            nn.ReLU(),
        )
        # Actor 头:输出动作概率
        self.actor = nn.Sequential(
            nn.Linear(128, action_dim),
            nn.Softmax(dim=-1)
        )
        # Critic 头:输出状态价值
        self.critic = nn.Linear(128, 1)

    def forward(self, x):
        features = self.shared(x)
        action_probs = self.actor(features)
        state_value = self.critic(features)
        return action_probs, state_value

# ==========================================
# 2. 训练循环(每步更新,不需要等 episode 结束)
# ==========================================
env = gym.make("CartPole-v1")
model = ActorCritic(state_dim=4, action_dim=2)
optimizer = optim.Adam(model.parameters(), lr=1e-3)
gamma = 0.99

reward_history = []

for episode in range(500):
    state, _ = env.reset()
    total_reward = 0

    while True:
        state_t = torch.FloatTensor(state)

        # Actor 选动作,Critic 评估状态
        probs, value = model(state_t)
        dist = torch.distributions.Categorical(probs)
        action = dist.sample()
        log_prob = dist.log_prob(action)

        # 执行动作
        next_state, reward, terminated, truncated, _ = env.step(action.item())
        done = terminated or truncated
        total_reward += reward

        # Critic 评估下一个状态
        with torch.no_grad():
            _, next_value = model(torch.FloatTensor(next_state))
            next_value = 0 if done else next_value

        # TD Error = 优势估计
        td_target = reward + gamma * next_value
        td_error = td_target - value

        # Actor 损失:策略梯度 × 优势
        actor_loss = -log_prob * td_error.detach()

        # Critic 损失:让 V(s) 接近 TD Target
        critic_loss = td_error.pow(2)

        # 总损失
        loss = actor_loss + critic_loss

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        state = next_state
        if done:
            break

    reward_history.append(total_reward)
    if (episode + 1) % 50 == 0:
        avg = np.mean(reward_history[-50:])
        print(f"Episode {episode+1} | Avg Reward: {avg:.1f}")

和 REINFORCE 的代码相比,关键区别是:多了一个 Critic 网络(输出 V(s)V(s)),用 TD Error(td_target - value)替代了 GtG_t,Critic 有自己的损失函数(MSE),而且不需要跑完 episode 才更新。

CartPole 上的 Actor-Critic 训练曲线

Actor-Critic 在 CartPole 上的训练曲线

 500 ┤
     │                              ━━━━━━━━━━━━━━━
 400 ┤                         ━━━━
     │                    ━━━━
 300 ┤              ━━━━━
     │         ━━━━
 200 ┤    ━━━━
     │ ━━
 100 ┤╱
     └────────────────────────────────────────────
     0    50   100  150  200  250  300  350  400  450  500
                    Episode

 对比 REINFORCE 的典型曲线(更多锯齿、更慢收敛)

Actor-Critic 在 CartPole 上通常在 200-300 个 episode 内就能稳定到 500 分(满分),而 REINFORCE 可能需要 500+ episode 且曲线锯齿明显。这就是"用偏差换方差"的收益——每一步都有更稳定的梯度信号,策略更新不再被运气牵着走。

Actor-Critic 的后续演进

Actor-Critic 不是终点,而是一个骨架。后续章节中你会看到它的各种变体:

章节变体关键改进
第 6 章 PPOPPO-Clip限制策略更新幅度,防止"步子迈太大"
第 6 章 GAE广义优势估计多步 TD Error 的指数加权和,精确控制偏差-方差权衡
第 8 章 DPO隐式 Actor-Critic用偏好数据替代 Critic,去掉 on-policy 的限制
第 8 章 GRPO去掉 Critic用组内均值替代 V(s)V(s),省掉一个网络

所有的变体都共享同一个骨架:一个负责选择的网络 + 一个负责评估的信号。变化的只是"评估信号怎么来"和"选择网络怎么更新"。

思考题:既然 Actor-Critic 比 REINFORCE 好,为什么不用纯 Critic(只用 V)?

因为只有 Critic 没办法直接输出策略。Critic 学的是 V(s)V(s)Q(s,a)Q(s,a),从中推导策略需要用 argmaxaQ(s,a)\arg\max_a Q(s,a)——但在连续动作空间中,这个 argmax\arg\max 不存在解析解(你不可能对无限多个连续值逐一比较)。

Actor 的价值在于:它直接输出动作概率,天然适用于连续动作空间。这就是为什么需要两个网络——Critic 负责"评价",Actor 负责"选择",缺一不可。

思考题:Actor-Critic 的"偏差"从哪来?它有害吗?

偏差来自 Critic 的自举(Bootstrapping)——Critic 用自己的估计 V(s)V(s') 来更新 V(s)V(s)。如果 V(s)V(s') 本身就不准确,误差会传播回来。这就像你用一把不准的尺子去校准另一把尺子——误差会累积。

但这种偏差不一定是坏事。适度的偏差可以换来更低的方差,整体上可能比无偏但高方差的 REINFORCE 收敛更快。第 6 章的 GAE 就是在精确控制这个"偏差-方差权衡"——用参数 λ\lambda 在纯 TD(高偏差低方差)和纯 MC(无偏高方差)之间平滑插值。

现在让我们回到代码,用实验亲眼看到基线的效果——基线实验与总结


Built for reusable bilingual course delivery