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

DQN:让一个网络去猜整张表

2013 年那篇七页的论文,让「深度强化学习」这个词存在了。但它做的事,比大多数人以为的更朴素——也更琐碎。

DQN五个零件回归问题

把 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) 第三次出现了。它是这本书里被漏掉最多次的一行。

✎ 2013 之后:Rainbow 那六个改进

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⁶,而且还在涨。