两个补丁,救活一个算法
上一章说经验回放和目标网络是补丁。补丁总得有个伤口。这一章把那个伤口摆出来——它有名字,叫致命三角;而它的判例简单到只有 7 个状态、8 个权重,却能把一个奖励全为 0 的问题炸成 10⁶。
先说一件我本来以为会成立的事
写这一章的时候,我本来打算做一个「表格版消融实验」:在第 13 章那个悬崖世界上跑 Q-learning,把经验回放和目标网络分别打开关闭,看差别。
引擎里那两个开关我都实现了,八个种子跑了一遍。结果是这样:
末 100 局均值 8 次里学到最优路径(13 步)的 都不开 -48.6 8 次 只开经验回放 -50.3 8 次 只开目标网络 -49.0 8 次 两个都开 -51.9 8 次
几乎没有区别。四种配置全都稳稳学到最优路径,平均回报差在噪声范围内。
这个「失败的实验」本身就是答案:
表格法里,每个状态各存各的。你更新 Q[s][a],其它格子一个字节都不会动。
所以就算目标在飘、样本高度相关,坏影响也被关在一个格子里,传不出去。
而函数逼近的定义就是参数共享——你更新一个状态,整个函数都会跟着动一点。
补丁是给共享打的,不是给自举打的。
那把伤口摆出来
Sutton 给这个伤口起了个很吓人的名字:致命三角(the deadly triad)。它说的是三样东西凑齐了就可能发散:
| 成分 | 是什么 | 为什么我们非要它不可 |
|---|---|---|
| 自举 | 用估计更新估计(第 11 章) | 不然只能等一局结束,样本效率极低 |
| 离策略 | 走的和学的不是同一个策略(第 12 章) | 不然没法用经验回放,也没法从旧数据学 |
| 函数逼近 | 参数共享(第 14 章) | 不然状态多一点就装不下 |
看第三列。三样我们都想要,而且每一样都有非要不可的理由。这就是为什么这个三角这么烦人——你不能靠「别用它」来躲开。
少任何一样都安全:
- 去掉函数逼近 → 表格法,收敛有保证(上面那个实验)
- 去掉离策略 → on-policy 方法(PPO 这一系),实践中稳得多
- 去掉自举 → 蒙特卡洛,无偏但方差大
判例:Baird 反例
Leemon Baird 1995 年构造的一个例子,只有 7 个状态、8 个权重。它的设计目的就一个:证明这三样凑齐真的会炸,不是理论上的担心。
这个例子有个特别恶劣的地方:
所有转移的奖励都是 0。 所有状态的真实价值都是 0。 一个把所有权重设成 0 的解,就是完美解。
一个笨到只会输出 0 的算法都能做对。
然后挨个切换后面三个配置。对数坐标上的直线,意味着指数爆炸。
它炸得有多干脆
跑 5000 步,权重的最大绝对值:
10 → 79 → 310 → 1200 → 4900 → 1.6e4 → 5.6e4 → 1.4e5 → 4.8e5 → 1.3e6 → 4.5e6
最终 5.68×10⁶,而且完全没有停下来的意思。
真值是 0。
而三个补救措施的效果:
| 配置 | 5000 步后权重最大值 | |
|---|---|---|
| 裸的半梯度 TD | 5.68×10⁶ | ★ 发散 |
| 学习率降到 1/10 | 81.0 | 没炸(但也没收敛到 0) |
| 加目标网络(冻 200 步) | 6.16 | 收住了 |
| 加目标网络(冻 1000 步) | 5.88 | 收住了 |
一个补丁,从 10⁶ 降到 10⁰。这就是目标网络存在的全部理由,摆在这儿了。
为什么会炸
机制不复杂,值得讲清楚。
Baird 的例子里,状态 1–6 的特征是 2·e_i + e_8,状态 7 是 e_7 + 2·e_8。注意所有状态都用到了第 8 个权重——这就是「参数共享」的具体形态。
然后发生的事是这样:
- 算
V(s')的时候用到了共享的 w₈。 - 为了让
V(s)逼近γV(s'),梯度推高了 w₈。 - 但 w₈ 一高,V(s') 也跟着高了——它俩用的是同一个权重。
- 于是目标又变远了,梯度继续推……
这是一个正反馈回路。而离策略那个重要性采样比(这里在实线转移上是 7 倍)给这个回路加了个放大器:更新幅度被放大七倍,回路的增益超过 1,指数爆炸就发生了。
你可能注意到我一直说「半梯度 TD」,不是「梯度 TD」。这个「半」字是问题的核心。
损失是 [r + γV(s';θ) − V(s;θ)]²。θ 在两处都出现了。但标准做法在求梯度时假装目标那一项是常数——只对 V(s;θ) 求导,不对 γV(s';θ) 求导。
所以它不是任何一个函数的真正梯度。它是一个「看起来像梯度」的更新方向,而梯度下降的收敛保证对它不适用。
PyTorch 里那句 with torch.no_grad(): 包住 target 的计算,做的就是这件事——它是这个「半」字的实现。
那为什么不算全梯度?因为算了效果反而更差(叫 residual gradient,收敛但慢得没法用)。这是一个真实存在的、大家都知道但都接受了的理论缺口。
那两个补丁各治什么
把这一章和上一章串起来,两个补丁的分工其实很清楚:
| 补丁 | 治什么 | 机制 |
|---|---|---|
| 目标网络 | 正反馈回路 | 把回路剪断:目标用的是冻住的 θ⁻,推高 θ 不会立刻推高目标。回路的增益被强行压到 0,直到下次同步 |
| 经验回放 | 样本相关性 | 打散数据:连续几帧几乎相同,梯度全朝一个方向;随机抽样把这个方向平均掉,避免局部过拟合和灾难性遗忘 |
注意它们治的是两个不同的病。上面那个 demo 里,目标网络能治住 Baird,因为 Baird 炸的是正反馈;经验回放对 Baird 帮助不大,因为 Baird 的样本本来就是随机的。
说清楚一件事:加了这两个补丁,DQN 依然没有收敛保证。致命三角还在那儿,只是被压住了。
DQN 在实践中会发散,只是概率低了很多。你在训练里可能遇到的具体现象:
- Q 值单调上涨,涨到几百几千,而这个游戏的实际回报最多几十。这是最典型的征兆,值得专门画一条 Q 值曲线盯着。
- 分数练到一半突然崩到 0,再也回不来。
- loss 变 NaN。这是最晚期、也最容易发现的阶段。
标准的应急手段(第 24 章那张表里也有):学习率降一个数量级、梯度裁剪、目标网络同步间隔调大、检查有没有漏 (1 - terminated)。
而如果你反复搞不定,换 PPO——它是 on-policy 的,三角缺一角,稳得多。这也是为什么今天的默认选择是 PPO 而不是 DQN。
补丁之外,也有人正面去修这个理论缺口,值得知道方向:
- Gradient TD 方法(GTD、TDC):构造一个真正的梯度目标(最小化投影贝尔曼误差),在离策略 + 线性逼近下有收敛保证。代价是要维护第二组权重、实现更复杂,实践中用得不多。
- Emphatic TD:给不同状态的更新加权,修正离策略带来的分布偏移。
- 换掉离策略:这才是工业界的实际选择。PPO 走的就是这条路。
值得注意的是:2013 年 DQN 那篇论文没有解决这个理论问题,只是绕过去了,而且绕得非常成功。这在这个领域很典型——工程上的可行常常跑在理论前面很远。
# 目标网络:另存一份,定期同步
target_net = copy.deepcopy(q_net)
for p in target_net.parameters():
p.requires_grad = False # 它不参与梯度,只负责报数
# 算目标的时候用它,而且包在 no_grad 里 —— 这就是那个「半」
with torch.no_grad():
target = r + gamma * target_net(s2).max(1).values * (1 - terminated)
# 每 N 步同步一次
if step % target_update_interval == 0:
target_net.load_state_dict(q_net.state_dict())
SB3 里对应的参数:
DQN("MlpPolicy", env,
target_update_interval=1000, # 目标网络多久同步一次
buffer_size=100_000, # 经验回放多大
max_grad_norm=10, # 梯度裁剪 —— 第三道保险
tau=1.0) # 1.0=硬拷贝;<1 则是软更新(SAC 那种)
那个 tau 值得一提:软更新 θ⁻ ← τθ + (1−τ)θ⁻(τ 取 0.005 之类)是另一种做法——目标网络每步都动一点点,而不是每一万步跳一大下。DDPG 和 SAC 用的是这个,平滑得多。
你在网上看到的「AI 训练失败」的搞笑合集——小人抽搐着飞上天、以物理引擎不允许的姿势穿模、原地高速旋转——其中有相当一部分不是奖励写错了,是训练发散了。
发散之后的 agent 会表现出一种很有辨识度的行为:动作幅度全部顶到极限值。因为价值估计已经变成了天文数字,策略网络被推到饱和区,所有输出都撞在边界上。
怎么和「奖励写错了」区分开?看 Q 值或者 loss 曲线。发散会在数值上留下痕迹(Q 单调上涨、loss 爆炸);奖励写错了不会——那时候一切数值都很正常,只有行为是错的。
后一种情况,正是接下来一整卷的主题。
这一章的一句话
自举 + 离策略 + 函数逼近,三样凑齐就可能发散——一个奖励全为 0 的问题能被炸到 10⁶。经验回放和目标网络不是调参技巧,是在给这个三角拆炸弹。
下一章:既然带 max 的自举这么危险,能不能干脆不学价值?直接把策略参数化,对期望回报求梯度。这条路绕开了 max,也就顺带绕开了「动作必须可枚举」这条限制——连续控制的大门从这里打开。