策略梯度需要回答:这次动作,比策略在当前状态的平均表现好多少?只看即时奖励容易漏掉延迟反馈;等完整回合结束再算回报,又可能带来很大的随机波动。

GAE(Generalized Advantage Estimation,广义优势估计)把多个时间尺度的反馈组合起来。本文先说明估计对象,再推导公式,最后处理最容易写错的两件事:下一状态价值是否参与自举,下一条样本的优势是否能够继续传播。

先固定策略、折扣与价值快照

假设环境满足 Markov 性,同一批轨迹由固定策略 π 采样。奖励 r_t 是执行 a_t、到达 s_(t+1) 时收到的奖励;γ∈[0,1] 是折扣。γ=1 时须有有限回合或其他保证回报有限的条件。

定义 Q^π_γ(s,a) 为先执行 a、之后按 π 行动的期望折扣回报,V^π_γ(s) 为按 π 选择当前动作后的平均价值。真实优势是:

1
2
A^π_γ(s,a) = Q^π_γ(s,a) − V^π_γ(s)
δ_t = r_t + γ V(s_(t+1)) − V(s_t)

V 是本批数据采集时的 Critic 快照,不假设它已经等于真实价值。δ 是一步 TD 残差,既不是原始奖励,也不是动作概率。若 V 恰好准确,固定 s_t、a_t 后对环境转移取期望,Bellman 关系给出 E[δ_t | s_t,a_t]=A^π_γ(s_t,a_t)。单条样本仍可能偏离这个期望。

Schulman 等人的 GAE 原始论文第 3 节给出了多步残差的加权估计和 λ 的偏差—方差折中。本文固定折扣价值来讨论;原论文还从未折扣目标分析 γ 引入的梯度偏差,两种目标不能混为一谈。

多步残差为什么等于回报减基线

先不考虑回合边界,把两步残差展开:

1
2
3
4
δ_t + γ δ_(t+1)
= r_t + γ V(s_(t+1)) − V(s_t)
+ γ r_(t+1) + γ² V(s_(t+2)) − γ V(s_(t+1))
= r_t + γ r_(t+1) + γ² V(s_(t+2)) − V(s_t)

中间价值相消。相同的抵消持续 k 步,得到多步优势估计 A^(k):

1
2
A_t^(k) = Σ[l=0..k−1] γ^l δ_(t+l)
= Σ[l=0..k−1] γ^l r_(t+l) + γ^k V(s_(t+k)) − V(s_t)

它用前 k 步实际奖励,加上末端价值估计,再减去起点基线。步数越短,依赖末端 Critic 越多;步数越长,接纳的随机奖励越多。这里的望远镜等式要求相邻转移连接同一条轨迹,不能把重置后的初始状态接进去。

有限片段的混合权重不能漏掉尾项

设从 t 到当前片段末端还有 L 条转移。对 0≤λ<1,将前 L−1 个多步估计分别赋权 (1−λ)λ^(k−1),**最后一个赋权 λ^(L−1)**:

1
2
3
A_t = (1−λ) Σ[k=1..L−1] λ^(k−1) A_t^(k)
+ λ^(L−1) A_t^(L)
= Σ[l=0..L−1] (γλ)^l δ_(t+l)

权重和为 1。固定 δ_(t+l),它在所有 k≥l+1 的估计中出现;对应权重之和为 λ^l,再乘上 γ^l 就得到右式。L=1 时只有最后一项,权重为 1。λ=1 直接取最长的多步估计,不把无限级数中形式上的“0 乘无穷”用于计算。

若每个有限项都乘 (1−λ) 而没有补上尾项,权重和只有 1−λ^L,所得数值会错误缩小。定义片段外 A=0 后,残差和可以写成:

1
2
A_t = δ_t + γλ A_(t+1)
价值回归目标 T_t = A_t + V(s_t)

逆序计算只需 O(L) 时间。T 是供 Critic 拟合的 λ-return 目标;除特定端点外,不等于完整 Monte Carlo 回报。Actor 使用优势时将它视为固定学习信号,不能让策略损失沿 A 再反传进 Critic。可回看反向传播与自动微分区分计算图和冻结数值。

三步轨迹怎样逆序手算

设 γ=0.9、λ=0.8,奖励为 [1,0,2],当前状态价值为 [1,1.5,0.5],最后一步真正终止。前两步下一状态价值分别为 1.5、0.5,终止后的自举价值为 0。

t r_t V(s_t) 自举价值 δ_t 逆序 A_t T_t=A_t+V(s_t)
2 2 0.5 0 1.5 1.5 2
1 0 1.5 0.5 −1.05 −1.05+0.72×1.5=0.03 1.53
0 1 1 1.5 1.35 1.35+0.72×0.03=1.3716 2.3716

在 t=0,三个多步优势分别为 1.35、0.405、1.62;混合权重是 0.2、0.16、0.64。独立算得 0.2×1.35+0.16×0.405+0.64×1.62=1.3716,和逆序算法一致。

GAE 的两道门:截断保留下一状态价值,但阻断重置后优势;真正终止同时关闭两道门
原创示意:相同的一步奖励,自举门 b 与轨迹门 c 控制不同的信息。打开完整图

终止与截断需要两道门

Gymnasium 官方时间限制说明区分了任务本身的终止和外部截断:真正终止不自举,外部截断通常保留下一状态价值。下面的两门设计是本文针对拼接采样片段的实现约定。

1
2
3
4
b_t = 0(真正终止),否则 1
c_t = 1(数组下一条仍是同一轨迹的后继),否则 0
δ_t = r_t + γ b_t V(next_state_t) − V(state_t)
A_t = δ_t + γλ c_t A_(t+1)
情形 b:自举 c:传播下一条优势 所需的下一状态
普通连续转移 1 1 实际后继状态
任务真正终止 0 0 价值被忽略
外部时间上限后重置 1 0 重置前最后观察
采样器用完步数,环境未结束 1 0 当前片段的实际后继

例如 r=1、V(s)=2、V(next)=3、γ=0.9。真正终止得到 δ=−1;外部截断得到 δ=1.7。两者都不能接入新回合的优势。把 done 同时用于两道门会漏掉截断的自举;只用 terminated 控制两道门则可能串入下一回合。

如果时间限制就是任务定义的一部分,剩余时间属于状态,达到期限可能是真正终止;不要只凭“超时”一词判断。向量环境与自动重置包装器还须核对具体 API:next_value 必须来自该转移实际到达的最后观察,不能取重置观察。本文不实现这些包装器。

λ 怎样暴露偏差与方差

λ=0 得到一步 TD;λ=1 时,完整终止回合中的中间价值全部相消,得到实际折扣回报减起点基线。但截断片段仍有末端自举,不能宣称“λ=1 就没有 Critic 误差”。

设 e(s)=V(s)−V^π_γ(s)。对固定长度 L、尚未终止的连续片段,将近似价值与准确价值得到的残差和相减:

1
2
3
误差 = −e(s_t)
+ γ(1−λ) Σ[j=1..L−1] (γλ)^(j−1) e(s_(t+j))
+ γ(γλ)^(L−1) e(s_(t+L))

这是逐项整理价值系数所得的路径等式。完整终止回合的末端误差项为零。λ=1 消去中间误差,但仍有起点基线;在固定状态下,与动作无关的基线不改变期望策略梯度,因为 Σ_a π(a|s)∇logπ(a|s)=∇Σ_aπ(a|s)=0。这不代表优势数值本身没有误差,或有限样本梯度毫无波动。

再构造一个能够完全枚举的小实验:第一步奖励 0;第二步真正终止,奖励以相同概率为 0 或 2。γ=0.9,准确的起点价值是 0.9,中间价值是 1。设第一步两个动作通向相同分布,因此真实优势为 0。另取错误中间价值 2,保持起点价值 0.9 不变。

λ 中间 V=1 时 E[A_0] 方差 中间 V=2 时 E[A_0] 方差
0 0 0 0.9 0
0.5 0 0.2025 0.45 0.2025
1 0 0.81 0 0.81

此例中,提高 λ 减轻错误末端价值带来的偏差,同时引入更多终局奖励波动。这是两条路径的精确矩,不是训练成绩;真实环境的残差可能相关,方差不保证随 λ 单调增加,也不存在所有任务统一最优的 λ。

完整标准库实验

Python 3,无第三方依赖。每条转移显式保存 value、next_value 和三种边界标志。cut 表示采样片段在此结束;数组末端自动关闭传播。输入应按单条轨迹的时间顺序拼接,切换轨迹时必须标记边界;数值连续性检查只能抓住部分错误,不能代替轨迹 ID。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
from dataclasses import dataclass
import json
import math


@dataclass(frozen=True)
class Step:
reward: float
value: float
next_value: float
terminated: bool = False
truncated: bool = False
cut: bool = False


def finite_number(x):
return (type(x) in (int, float) and math.isfinite(x))


def gae(steps, gamma=0.9, lam=0.8):
if not finite_number(gamma) or not 0 <= gamma <= 1:
raise ValueError("gamma must be finite and in [0,1]")
if not finite_number(lam) or not 0 <= lam <= 1:
raise ValueError("lambda must be finite and in [0,1]")
if not isinstance(steps, (list, tuple)) or not steps:
raise ValueError("steps must be a nonempty list or tuple")
for t, step in enumerate(steps):
if not isinstance(step, Step):
raise ValueError("each transition must be a Step")
if not all(finite_number(x) for x in
(step.reward, step.value, step.next_value)):
raise ValueError("reward and values must be finite numbers")
if not all(type(x) is bool for x in
(step.terminated, step.truncated, step.cut)):
raise ValueError("boundary flags must be bool")
for t in range(len(steps) - 1):
step = steps[t]
if not (step.terminated or step.truncated or step.cut):
if not math.isclose(step.next_value, steps[t + 1].value,
rel_tol=1e-12, abs_tol=1e-12):
raise ValueError("inconsistent adjacent value snapshots")
advantages = [0.0] * len(steps)
targets = [0.0] * len(steps)
carry = 0.0
for t in range(len(steps) - 1, -1, -1):
step = steps[t]
bootstrap = 0.0 if step.terminated else step.next_value
delta = step.reward + gamma * bootstrap - step.value
connected = (t + 1 < len(steps) and not
(step.terminated or step.truncated or step.cut))
carry = delta + gamma * lam * (carry if connected else 0.0)
target = carry + step.value
if not math.isfinite(carry) or not math.isfinite(target):
raise ValueError("computed output overflowed")
advantages[t], targets[t] = carry, target
return advantages, targets


def moments(lam, middle_value):
samples = []
for terminal_reward in (0.0, 2.0):
steps = [Step(0.0, 0.9, middle_value),
Step(terminal_reward, middle_value, 0.0, terminated=True)]
samples.append(gae(steps, lam=lam)[0][0])
mean = sum(samples) / 2
variance = sum((x - mean) ** 2 for x in samples) / 2
return {"mean": mean, "variance": variance}


if __name__ == "__main__":
trajectory = [Step(1, 1, 1.5), Step(0, 1.5, 0.5),
Step(2, 0.5, 0, terminated=True)]
advantages, targets = gae(trajectory)
print(json.dumps({
"advantages": advantages, "targets": targets,
"boundary": {
"terminated": gae([Step(1, 2, 3, terminated=True)])[0][0],
"truncated": gae([Step(1, 2, 3, truncated=True)])[0][0],
"collector_cut": gae([Step(1, 2, 3, cut=True)])[0][0]},
"moments": [{"lambda": lam, "exact_critic": moments(lam, 1),
"wrong_critic": moments(lam, 2)}
for lam in (0.0, 0.5, 1.0)]}, indent=2))

运行后,advantages 约为 [1.3716,0.03,1.5],targets 约为 [2.3716,1.53,2],边界结果分别为 −1、1.7、1.7;moments 与上表一致,浮点末位可能不同。输入校验与逆序递推共 O(N) 时间,返回两组长度 N 的数组占 O(N) 空间,递推状态仅占 O(1)。

项目 tests/verify-gae-article.cjs 直接提取并运行这段代码,另用原始奖励构造所有 k 步目标,按有限混合权重独立计算参考答案;还检查随机轨迹、λ 端点、回合隔离、末端自举和非法输入。它验证的是估计器,不是完整 Actor-Critic 或 PPO 训练系统。

接入训练前的错误清单

检查点 常见失败
采样策略 不记录行为策略,用过期轨迹直接套当前策略梯度
Critic 快照 先更新 V,再混用旧优势与新基线
拼接片段 重置后没有断开传播,上一局吸收下一局奖励
末端价值 截断时置零,或用重置后的观察自举
优势与价值目标 标准化优势后再加 V,改变了 Critic 的回归目标
批次标准化 单样本或零方差时除零;误认为它保证无偏
并行环境 把不同环境的相邻数组元素当成时间后继
λ 端点 忽略有限混合尾项;把 λ=1 的截断数据称为完整回报

先保存原始 A 和 T,再根据训练约定单独处理 Actor 使用的优势。PPO 的概率比、裁剪目标和更新停止条件属于后续策略优化问题;GAE 本身没有限制策略更新幅度,也不把旧经验自动变成可永久重放的数据。

自检:外部截断后重置,能同时保留下一状态价值与下一条优势吗?

只保留重置前最后观察的价值。下一条若来自新回合,必须关闭优势传播。自举门是“未来价值是否存在”,轨迹门是“这条数组后继是否属于同一段经验”。

建议沿策略梯度与 Actor-Critic → 本文 → PPO 原始论文阅读,先跑通估计与边界,再研究策略更新;并与 DQN 的经验回放与目标网络对照,两类方法共享 TD 思想,但采样和优化约定不同。