卷 IV · 换脑CH 17深度 17/24

策略梯度:不学价值,直接学策略

第 9 章留了一句话:策略比价值先定下来,价值只是中间品。这一章顺着那句话走到底——干脆跳过价值,直接推着策略往好的方向挪。这条路会打开一扇 DQN 永远打不开的门。

REINFORCE策略梯度定理基线与方差

换一个思路

前面所有算法的路线都是:学 Q → 取 argmax → 得到策略

这条路有三个卡点,而且都不小:

  • 那个 argmax 要求动作可枚举。连续动作(关节力矩、方向盘角度)直接出局。
  • 它只能给出确定性策略。可有些问题的最优策略本来就是随机的(石头剪刀布,或者任何对手会摸清你套路的场合)。
  • Q 值差一点点,argmax 就跳变。0.51 和 0.49 之间挪一下,策略整个换了——这让训练很不稳。

策略梯度的做法是:把策略本身参数化,然后直接对「期望回报」求梯度。

π(a|s; θ)  ——  一个网络:状态进去,各动作的概率出来

目标:max J(θ) = E[ 这一局的总回报 ]
       θ
做法:θ ← θ + α ∇J(θ)

没有 Q,没有 max,没有表。就是普通的梯度上升。

但那个梯度怎么求

这里有个真实的困难,值得停一下:J(θ) 是「按这个策略走一局,期望能拿多少」——而这个期望依赖于环境的转移概率 P,你不知道 P。

怎么对一个你写不出来的东西求梯度?

答案是策略梯度定理,它的结论漂亮得有点不像话:

∇J(θ) = E [ G · ∇ log π(a|s; θ) ]
             t         θ

P 消失了。

为什么能消失?因为环境的转移概率不依赖于 θ——你怎么调策略,物理规律都不变。求导的时候它就成了常数,被约掉了。剩下的部分全是你能算的:G 是实际拿到的回报,∇log π 是你自己网络的梯度。

然后用第 10 章那一招:期望算不出来?采样求平均。

◆ REINFORCE:把那个式子念成人话
θ ← θ + α · G · ∇ log π(a|s)
                       θ

逐项翻译:

  • ∇ log π(a|s)「把动作 a 的概率调高」的方向。这纯粹是个方向,和好坏无关。
  • G这一局实际拿了多少分。它是个标量,充当缩放系数。

两个一乘:G 是正的大数 → 大幅提高这个动作的概率;G 是负数 → 反向,降低它的概率。

整句话就是:「这局分高?那就把这局里做过的每一件事都变得更可能发生。」

朴素到近乎天真,但它是对的——而且它是后面 A2C、TRPO、PPO、以及 ChatGPT 那个 RLHF 的共同祖先

跑一下

▶ 动手 · 在「减了基线」和「没减基线」之间切

看两个数:末 100 局平均回报(越接近最优 0.705 越好),和梯度信号的标准差(越小越稳)。

它的毛病:方差大到离谱

那个天真的做法有个直接后果。

注意 G整局的总回报。这意味着:这一局里的每一个动作,共享同一个缩放系数。

假设一局走了三十步,其中第 12 步是个好动作,第 25 步是个烂动作,但这局总分不错。REINFORCE 会把这两个动作的概率一起调高。

它靠什么区分好坏?靠统计——跑很多很多局,好动作平均出现在高分局里的次数多一点。信号淹在噪声里,只能靠海量样本捞出来。

更糟的一层:如果所有回报都是正的

假设某个环境里,回报总是 +10 到 +100 之间。

那么 G 永远是正数,于是每一个被采样到的动作,概率都在被调高——包括那些糟糕的动作。

它靠什么学?靠「好动作被调得多一点」这种相对差异。这能奏效,但效率极低,而且噪声巨大

那个救命的小改动:减基线

解法简单得让人怀疑它有没有用:从 G 里减掉一个基线 b。

θ ← θ + α · (G - b) · ∇ log π(a|s)

b 可以是「历史平均回报」,也可以是一个学出来的 V(s)。关键性质是:

◆ 减基线不改变期望,只降低方差

数学上:只要 b 不依赖于动作 a,那么 E[b · ∇log π] = 0所以减掉它之后,梯度的期望完全不变——估计仍然是无偏的。

但方差降了,而且降得很实在。因为现在的含义变了:

从「这局拿了多少分」变成了「这局比平均好多少」。

比平均好 → 正数 → 调高概率。比平均差 → 负数 → 调低概率

终于有负反馈了。而在此之前,所有动作都只在被往上推。

引擎跑出来的数字:梯度信号的标准差从 0.527 降到 0.433,末 100 局平均回报从 0.640 提到 0.696——而理论最优是 0.705。它几乎学到了最优。

那个基线,就是 Critic

现在把基线从「历史平均」升级成「一个学出来的 V(s)」,会发生一件事:

G - V(s)   ≈   A(s,a)      ← 这个叫「优势」(advantage)

「在状态 s 做动作 a,比这个状态的平均水平好多少。」

这一下就精确了。不再是「这一局好不好」,而是「这一步好不好」——第 10 章末尾那个信用分配问题,在这里得到了正面回答。

于是结构变成了两个网络:

  • Actor π(a|s;θ):负责做动作
  • Critic V(s;w):负责回答「这个状态平均能拿多少」

这就是 Actor-Critic。而回头看第 9 章那张表——它就是策略迭代:Critic 在做评估,Actor 在做改进,只不过两边都改成了「用梯度挪一点点」。

✎ GAE:优势估计的那个滑块

优势 A 怎么估?这里又回到了第 10、11 章那条谱线:

1 步:  A ≈ r + γV(s') - V(s)              ← 偏差大,方差小
n 步:  A ≈ r + …… + γⁿV(s⁽ⁿ⁾) - V(s)
整局:  A ≈ G - V(s)                       ← 无偏,方差大

GAE(广义优势估计)把所有 n 按 λ 的幂加权平均,一个参数 gae_lambda 就在这条谱线上滑动。

这就是你在 PPO 配置里看到的那个 gae_lambda=0.95它和 γ 是两个不同的旋钮,一个常见的混淆点:

  • γ:agent真正在乎多远的未来(这是问题定义的一部分)
  • λ:你愿意用多长的实际轨迹去估优势(这是估计方法的选择)

调 γ 是在改问题,调 λ 只是在改你怎么估计。前者要慎重,后者可以放心试。

◆ 那扇 DQN 打不开的门

策略梯度最重要的能力,是它从头到尾没有出现过 max

所以策略网络可以直接输出连续值——比如输出一个高斯分布的均值和方差,然后从里面采样一个力矩:

class GaussianPolicy(nn.Module):
    def forward(self, state):
        mu = self.mu_net(state)            # 均值:4 个关节各一个力矩
        std = self.log_std.exp()           # 标准差:控制探索的幅度
        return Normal(mu, std)             # 一个连续分布

dist = policy(state)
action = dist.sample()                     # 采样一个连续动作
logp = dist.log_prob(action).sum()         # ← 这就是那个 log π

机器人、走路、开车、机械臂——所有连续控制,全在这条路上。

顺带,那个 std 值得注意:它就是探索。训练初期它大(动作随机),后期自动变小(动作确定)。第 3、4 章那个 ε 在这里被吸收进了策略本身——不再是外挂的,是策略的一部分。

⚠ 策略梯度的两个真实痛处

① 它是 on-policy 的,样本用完就扔。

那个梯度公式里的期望,是在当前策略下的期望。策略一更新,之前收集的数据就不再代表它了。

所以不能用经验回放,样本效率天生比 DQN 低一个数量级。这是第 13 章那张表里那一行的来源,也是 PPO 要用「多个环境并行采样」的原因。

② 一步迈太大就崩,而且崩了回不来。

这个比第一点更要命。策略更新过头 → 走到很差的区域 → 采到的全是烂数据 → 用烂数据算出的梯度更烂 → 正反馈,一路崩到底

监督学习没有这个问题:数据集是固定的,学坏了下一轮还是同一批数据,能救回来。强化学习里,你的数据是你自己采的——策略崩了,数据源也就毁了。

这个问题催生了 TRPO 和 PPO。下一章就在解它。

⌗ 换成真机:REINFORCE 三十行

它值得敲一遍,因为它是所有现代策略梯度方法的最小骨架:

import torch, gymnasium as gym
from torch.distributions import Categorical

env = gym.make("CartPole-v1")
policy = torch.nn.Sequential(
    torch.nn.Linear(4, 64), torch.nn.ReLU(),
    torch.nn.Linear(64, 2))                    # 2 个动作的 logits
opt = torch.optim.Adam(policy.parameters(), lr=1e-2)

for ep in range(1000):
    s, _ = env.reset()
    logps, rewards = [], []
    while True:
        dist = Categorical(logits=policy(torch.tensor(s)))
        a = dist.sample()
        logps.append(dist.log_prob(a))         # ← log π(a|s)
        s, r, term, trunc, _ = env.step(a.item())
        rewards.append(r)
        if term or trunc: break

    # 倒着算回报
    G, returns = 0, []
    for r in reversed(rewards):
        G = r + 0.99 * G
        returns.insert(0, G)
    returns = torch.tensor(returns)
    returns = (returns - returns.mean()) / (returns.std() + 1e-8)   # ← 减基线

    loss = -(torch.stack(logps) * returns).sum()   # 负号:梯度上升写成下降
    opt.zero_grad(); loss.backward(); opt.step()

那句 (returns - mean) / std 就是减基线的最省事版本(顺便还归一化了尺度)。去掉它试试——你会看到收敛慢一大截,而且不稳。

那个负号也值得注意:PyTorch 的优化器只会做梯度下降,所以要最大化 J 就得最小化 −J。这是策略梯度代码里最常见的一处困惑来源。

↩ 回到那个视频

那个学走路的小人,用的几乎肯定是这条路上的算法(PPO 或 SAC)。原因就是上面那个 max它的动作是四个连续的力矩,DQN 那一系根本处理不了。

而你在训练早期看到的抽搐,现在有了更准确的解释:那是高斯策略的标准差还很大,每一帧的力矩都在大范围随机采样。

随着训练推进,std 自动收缩,动作变得平滑、连贯、可预测。「从抽搐到走路」这个过程,在参数上就是那个 std 从大变小的过程。

这一章的一句话

不学价值直接学策略,绕开了 max,也就绕开了「动作必须可枚举」。代价是方差极大——而治它的那个基线,正好长成了 Critic。

下一章:解掉「一步迈太大就崩」那个问题。答案简单到能用十几行写完,却稳到从机器人一路用到了 ChatGPT——相信这批数据,但只相信一步之内。