DQN:让一个网络去猜整张表
2013 年那篇七页的论文,让「深度强化学习」这个词存在了。但它做的事,比大多数人以为的更朴素——也更琐碎。
把 Q-learning 改成回归问题
第 12 章那个更新式:
Q(s,a) ← Q(s,a) + α [ r + γ max Q(s',a') - Q(s,a) ]
把 Q 换成一个带参数的网络 Q(s,a;θ),这一行就变成了一个再普通不过的监督学习问题:
预测值:Q(s,a;θ)
目标值:r + γ max Q(s',a';θ)
a'
损失: L(θ) = [ 目标 - 预测 ]²
然后拿梯度下降去降这个损失。就这样。
你会注意到一件很怪的事:目标值里也有 θ。这个「目标随着你训练在动」的问题,正是下一章的主角。
五个零件
只有一个是深度学习
这是这一章最想说的话。
五个零件里,只有第一个(卷积网络)是「深度学习」的贡献。其余四个——ε 退火、经验回放、目标网络、奖励裁剪——全都是为了让强化学习和神经网络能凑在一起工作而打的补丁。
换句话说:把 CNN 接到 Q-learning 上,这个想法本身并不难想,难的是让它不炸。而在 2013 年之前,很多人试过,都炸了。
DQN 那篇论文真正的分量,是找齐了这一套配方,并且证明它不用改一个超参就能跑 49 个不同的游戏。
逐个说说那四个补丁
经验回放:打断样本之间的相关性
直接用连续采集的样本训练,会出问题。原因很具体:
监督学习假定训练样本是独立同分布的(i.i.d.)——你从数据集里随机抽 batch。但强化学习采到的样本是一条轨迹:第 100 帧和第 101 帧几乎一模一样。
连续拿一百个几乎相同的样本去做梯度下降,会发生什么?网络在这一百步里被朝同一个方向猛推,把之前学的东西挤掉。这叫灾难性遗忘。
解法:把走过的 (s,a,r,s') 存进一个大缓冲区(DQN 用了一百万条),训练时从里面随机抽一批。
好处不止一个:
- 打断了相关性,重新逼近 i.i.d.
- 每条经验被反复使用——样本效率大幅提升,这在采样贵的场景里是决定性的
- 缓冲区里混着新旧策略的数据,训练分布更平稳
而它能成立的唯一原因,是第 12 章说的那件事:Q-learning 是离策略的。它学的是最优策略,不在乎这条经验当初是谁走出来的。
PPO 就不能这么做(它是 on-policy 的),所以它的样本效率天生比 DQN 低——这是第 13 章那张表里那一行的来源。
目标网络:别追一个自己在推的东西
看那个损失:
L(θ) = [ r + γ max Q(s',a';θ) - Q(s,a;θ) ]²
↑ ↑
目标里有 θ 预测里也有 θ
你在用梯度下降去让预测逼近目标,可你每走一步,目标自己也动了。
一个常见的比喻是「追着自己的影子跑」。更准确的说法是:你在推着一堵墙往前走,同时试图撞上它。
DQN 的解法简单粗暴:另存一份权重 θ⁻,专门用来算目标,每一万步才从 θ 同步一次。在这一万步里,目标是钉死不动的,问题退化成了一个老老实实的监督学习。
下一章会给你一个判例,让你看到不打这个补丁的后果有多具体。
奖励裁剪:让一套超参能跑 49 个游戏
打砖块每块 1 分,吃豆人每颗豆 10 分,有些游戏一次几百分。如果直接用原始分数,梯度的尺度会差好几个数量级——同一个学习率不可能同时适配。
DQN 的做法是把所有奖励裁到 −1、0、+1 三档:正的就是 +1,负的就是 −1。
这是个很实用的技巧,但它有明确的代价,而且这个代价直接关系到本书卷 V 的主题:
裁剪之后,「吃一颗豆子(10 分)」和「吃一个大力丸(50 分)」变成了完全一样的 +1。
agent 再也分不出哪个更值钱。它会学出一种「什么分都要,一视同仁」的行为。
在有些游戏里这没关系,在有些游戏里这是致命的——比如那些「忍住不吃小的,等大的」才能拿高分的游戏。
这是这本书里第一次出现「为了让训练跑起来而修改奖励函数」。它管用,但它改变了你要求 agent 做的事。
后来的改进(比如 Rainbow 里的分布式 DQN、以及 PopArt 那种自适应归一化)都在试图既保留尺度不变性,又不丢掉奖励的相对大小。
顺带看看那个网络长什么样
DQN 的网络结构简单得让人意外——按今天的标准,它小得像个玩具:
import torch.nn as nn
class DQN(nn.Module):
def __init__(self, n_actions):
super().__init__()
self.net = nn.Sequential(
# 输入:4 帧堆叠的 84x84 灰度图
nn.Conv2d(4, 32, kernel_size=8, stride=4), nn.ReLU(),
nn.Conv2d(32, 64, kernel_size=4, stride=2), nn.ReLU(),
nn.Conv2d(64, 64, kernel_size=3, stride=1), nn.ReLU(),
nn.Flatten(),
nn.Linear(3136, 512), nn.ReLU(),
nn.Linear(512, n_actions), # ← 每个动作一个 Q 值
)
def forward(self, x):
return self.net(x / 255.0) # 像素归一化到 [0,1]
三个卷积层加两个全连接,大约 170 万个参数。
注意最后一层:输出维度是动作数,一次前向就把所有动作的 Q 值都算出来了。这是个很关键的设计——如果做成「输入 (s,a) 输出一个数」,那么每选一次动作就要跑 18 次前向。
而这个设计也顺带钉死了 DQN 的边界:动作必须是离散且可枚举的。连续动作空间上,你没法让最后一层「每个动作一个输出」。
for step in range(total_steps):
# 1. 用 ε-greedy 走一步,存进缓冲区
a = random_action() if rand() < eps(step) else q_net(s).argmax()
s2, r, terminated, truncated, _ = env.step(a)
buffer.push(s, a, clip(r, -1, 1), s2, terminated)
s = s2 if not (terminated or truncated) else env.reset()[0]
# 2. 从缓冲区随机抽一批来训练
if len(buffer) > 50_000: # 先攒够再开始
batch = buffer.sample(32)
with torch.no_grad():
target = batch.r + gamma * target_net(batch.s2).max(1).values \
* (1 - batch.terminated) # ← 终止就没有未来
pred = q_net(batch.s).gather(1, batch.a)
loss = F.smooth_l1_loss(pred, target)
optimizer.zero_grad(); loss.backward(); optimizer.step()
# 3. 每隔一万步,把目标网络同步一次
if step % 10_000 == 0:
target_net.load_state_dict(q_net.state_dict())
二十行。五个零件全在里面:ε 退火在第 1 步,奖励裁剪在 clip,经验回放在 buffer.sample,目标网络在第 2、3 步,卷积网络在 q_net 里。
那句 (1 - batch.terminated) 第三次出现了。它是这本书里被漏掉最多次的一行。
DQN 之后几年出了一批改进,2017 年 DeepMind 把其中六个叠在一起,叫 Rainbow。值得知道名字,因为你在论文和代码里会反复遇到:
- Double DQN:治第 12 章那个最大化偏差。改两行,效果扎实。
- 优先经验回放:TD 误差大的样本多抽几次——「让它多练错得多的地方」。
- Dueling 网络:把 Q 拆成 V(s) + A(s,a)。因为很多状态下「做什么都差不多」,分开学更省样本。
- 多步回报:用 n 步 TD 而不是 1 步(第 11 章那条谱线)。
- 分布式 DQN(C51):不学「期望是多少」,学整个回报分布长什么样。信息量大得多。
- NoisyNet:把探索做进网络权重里,替掉 ε-greedy。
Rainbow 的消融实验里,贡献最大的是优先经验回报和多步回报。这是个有意思的信号:提升最多的两项,改的都是「用哪些数据、怎么算目标」,不是网络结构。
pip install "stable-baselines3[extra]" "gymnasium[atari,accept-rom-license]"
from stable_baselines3 import DQN
model = DQN("CnnPolicy", "ALE/Breakout-v5",
buffer_size=100_000, # 经验回放(100 万太吃内存,这里减小)
learning_starts=50_000, # 先攒够再学
target_update_interval=1000,# 目标网络同步间隔
exploration_fraction=0.1, # ε 退火用掉总步数的 10%
train_freq=4) # 每走 4 步训练一次
model.learn(total_timesteps=10_000_000)
一个务实的提醒:不要拿 Atari 入门。DQN 在 Breakout 上要跑一千万步,单卡好几个小时到一天,而且中间很长一段时间曲线是平的——你根本分不清是「还没学会」还是「写错了」。
先在 CartPole-v1 上跑 DQN("MlpPolicy", ...),五万步、几分钟就能收敛。跑通了、看懂了每个参数在干什么,再上 Atari。
DQN 打 Atari 的视频你多半也看过——尤其是打砖块那一段,它学会了在墙上打个洞,把球送到砖块后面去反复弹。那个片段当年震撼了很多人。
值得澄清一件事:「挖洞」这个策略不是它「想」出来的。它是几千万帧里随机撞出来的一次高分,然后价值函数把那条路径的分数抬了上去,于是策略往那儿收敛。
这是这本书主线的又一个例子:那个行为不是谁写的,是从分数里长出来的。而它长成什么样,完全取决于「什么样的结果分高」。
顺带:DQN 打不过的游戏也很有名。《蒙特祖玛的复仇》那个游戏 DQN 得 0 分——因为它要先拿钥匙、再开门,中间几百步一分都没有。第 21 章会正面处理这个问题。
这一章的一句话
DQN 的五个零件里只有一个是深度学习,其余四个都是让强化学习和神经网络能凑在一起工作的补丁。而那篇论文真正的贡献,是找齐了这套配方。
下一章:把其中最关键的那个补丁拆下来。你会看到一个奖励全程为 0 的问题——所有状态的真实价值都是 0——而一个正确实现的算法把权重推到了 5.68×10⁶,而且还在涨。