先把舞台搭好。2012 年前后,有一个叫 Arcade Learning Environment 的研究平台,把几十款 1970 至 80 年代的 Atari 2600 游戏(打砖块、太空侵略者、乒乓、吃豆人等)包装成统一的强化学习接口:每一帧,智能体看到的是一张 210×160 的游戏画面,能做的是几个手柄动作(上下左右、开火),环境返回的是游戏分数的变化。

这个设定的迷人之处,在于它的通用性。同一套算法、同一个网络结构,不针对任何一款游戏做特别设计,要能玩几十款规则、目标、画面完全不同的游戏。打砖块要把球弹上去,太空侵略者要躲子弹打外星人,赛车要沿赛道开——如果一个智能体只靠看像素和分数就能全部学会,那它学到的就不是某个游戏的技巧,而是某种更一般的“从感知到决策”的能力。

2013 年,Volodymyr Mnih 等人(当时在 DeepMind)发表了《用深度强化学习玩 Atari》,第一次做到了这件事1。论文标题里“直接从高维感官输入学习控制策略”这句话,是对整个领域的宣告:在此之前,强化学习要么用在状态可以手工设计成几个数字的小问题上,要么依赖人类先把原始输入加工成特征。DQN 把这两步合一了——卷积网络负责“看懂画面”,Q-learning 负责“决定怎么动”,端到端一起训练。


DQN 的网络结构,是第三章讲的卷积网络与本卷强化学习的直接嫁接。输入不是单帧,而是把最近 4 帧画面叠在一起(这样网络能感知运动方向和速度——单看一帧无法判断球在往哪飞),经过几层卷积提取空间特征,再接全连接层,最后一层输出每个动作的 Q 值1

注意这个输出设计的巧妙:网络不是输入“状态+动作”输出一个 Q 值,而是输入状态、一次性输出所有动作的 Q 值。这样选动作只需前向传播一次(取输出最大的那个动作),不必对每个动作各跑一遍网络——在动作多、要实时决策的游戏里,这个效率差别很关键。

训练目标,就是上一章那条 Q-learning 更新,被改写成了一个回归问题。对采样到的一条经验 (s,a,r,s)(s,a,r,s’),构造目标值

y=r+γmaxaQ(s,a;θ)y = r + \gamma \max_{a’} Q(s’,a’;\theta)

然后让网络的输出 Q(s,a;θ)Q(s,a;\theta) 去回归这个 yy,损失就是均方误差:

L(θ)=(yQ(s,a;θ))2L(\theta) = \big(, y - Q(s,a;\theta) ,\big)^2

θ\theta 求梯度、反向传播、梯度下降——和第二章训练任何监督网络的流程一模一样。强化学习在这里被巧妙地转化成了一连串监督回归:每一步用“即时奖励 + 下一状态的最优估值”现造一个标签,让网络去拟合。

但正是这个“现造标签”埋着两颗雷。


第一颗雷:样本相关性。强化学习的经验是一条连续的轨迹,相邻几帧画面几乎一样,连续的状态、动作、奖励高度相关。而神经网络的训练(随机梯度下降)有一个隐含假设——每个小批量的样本应该是独立同分布的。如果你按时间顺序、用连续相关的样本去训练,网络会被最近这一小段经历带偏,学了新的忘了旧的,剧烈震荡。

DQN 的解法叫经验回放(experience replay)1。智能体不立即用刚产生的经验去训练,而是把每条 (s,a,r,s)(s,a,r,s’) 存进一个很大的缓冲区(buffer)。训练时,从缓冲区里随机抽取一个小批量来更新网络。随机抽样打散了时间顺序——一个批量里可能混着十分钟前和一秒前的经验、不同游戏阶段的片段——大大降低了样本间的相关性,让训练回到接近独立同分布的假设。

经验回放还附带一个好处:样本复用。一条经验进了缓冲区,会被随机抽中很多次,反复参与训练。在和真实环境交互很昂贵(每一步都要实际玩一帧游戏)的强化学习里,这种数据效率的提升非常实在。这个想法本身不是 DQN 首创(经验回放早在 1990 年代就有人提出),但 DQN 把它和深度网络结合,证明了它是稳定训练的关键一环。


第二颗雷更微妙:目标在动。回到那条损失 L=(yQ(s,a;θ))2L = (y - Q(s,a;\theta))^2,目标 y=r+γmaxaQ(s,a;θ)y = r + \gamma\max_{a’}Q(s’,a’;\theta) 里也含着 θ\theta。也就是说,你每更新一次网络参数 θ\theta,你正在追的那个目标 yy 自己也跟着动了。这就像你想射中一个靶子,可每当你瞄准、扣下扳机的瞬间,靶子就朝你瞄的方向挪一下——很容易陷入正反馈式的震荡或发散。

2015 年发表在《自然》上的 DQN 升级版,给出了第二个关键技巧:目标网络(target network)2。具体做法是维护两份网络:一份是不断更新的“在线网络”Q(;θ)Q(\cdot;\theta),另一份是参数被周期性冻结的“目标网络”Q(;θ)Q(\cdot;\theta^-)。计算 TD 目标时,用冻结的目标网络:

y=r+γmaxaQ(s,a;θ)y = r + \gamma \max_{a’} Q(s’,a’;\theta^-)

在线网络照常每步更新,但目标网络的参数 θ\theta^- 每隔固定步数(比如一万步)才从在线网络复制一次。这样,在两次同步之间,靶子是静止的——网络在追一个不动的目标,训练稳定下来;隔一段时间,把这个目标更新到当前更准的估计上,再静止一段。这个简单的“冻结—追赶—再冻结”节奏,是 DQN 从“能跑通几个游戏”到“稳定玩 49 个游戏达人类水平”的分水岭。

2015 年那篇《自然》论文的标题是《通过深度强化学习达到人类水平的控制》2。同一套网络、同一组超参数,不为任何游戏单独调整,在 49 款 Atari 游戏上测试——智能体在其中过半数的游戏里达到或超过专业人类玩家的水平,有些游戏(如打砖块)它甚至发现了人类没想到的策略:先在墙的一侧凿穿一条通道,把球送到砖块后面,让球在顶部来回弹射、自动清场。


把目标网络和经验回放这两个机制写进代码,比文字更清楚。配套的 code/14_dqn_min.py(纯 numpy,手写单隐层网络与反向传播)在上一章那个 4×4 网格世界上,搭了一个最小可运行的 DQN。状态被编码成长度 16 的 one-hot 向量喂给网络,网络输出 4 个动作的 Q 值。训练循环里两个机制清晰可见:

代码 · 14_dqn_min.py
展开代码 · 14_dqn_min.py
"""
第 14 章配套代码:最小 DQN 思想 demo(经验回放 + 目标网络)。
纯 numpy 实现一个单隐层 Q 网络,在前面同一个 gridworld 上学 Q(s,a)。
重点不是性能,而是把 DQN 的两个关键工程机制写清楚:
  (1) 经验回放 replay buffer:存 (s,a,r,s',done),训练时随机小批量采样,打破时间相关性;
  (2) 目标网络 target net:用周期性冻结的参数算 TD 目标 r + gamma*max Q_target(s'),稳住训练。
Runnable with: numpy only.  python3 14_dqn_min.py
"""
import numpy as np

ROWS, COLS = 4, 4
START, GOAL, TRAP = (0, 0), (3, 3), (1, 3)
WALLS = {(1, 1), (2, 1)}
ACTIONS = [(-1, 0), (1, 0), (0, -1), (0, 1)]


def step(s, a):
    if s in (GOAL, TRAP):
        return s, 0.0, True
    dr, dc = ACTIONS[a]
    ns = (s[0] + dr, s[1] + dc)
    if ns[0] < 0 or ns[0] >= ROWS or ns[1] < 0 or ns[1] >= COLS or ns in WALLS:
        ns = s
    if ns == GOAL:
        return ns, 1.0, True
    if ns == TRAP:
        return ns, -1.0, True
    return ns, -0.04, False


def encode(s):
    """状态 one-hot 编码成长度 16 的向量(作为网络输入)。"""
    v = np.zeros(ROWS * COLS)
    v[s[0] * COLS + s[1]] = 1.0
    return v


class QNet:
    """单隐层 MLP: 16 -> H -> 4,输出每个动作的 Q 值。纯 numpy + 手写反传。"""
    def __init__(self, H=32, seed=0):
        rng = np.random.default_rng(seed)
        self.W1 = rng.normal(0, 0.1, (16, H))
        self.b1 = np.zeros(H)
        self.W2 = rng.normal(0, 0.1, (H, 4))
        self.b2 = np.zeros(4)

    def forward(self, X):
        self.X = X
        self.z1 = X @ self.W1 + self.b1
        self.h = np.maximum(0, self.z1)          # ReLU
        return self.h @ self.W2 + self.b2        # Q 值(线性输出)

    def params(self):
        return [self.W1, self.b1, self.W2, self.b2]

    def copy_from(self, other):
        self.W1, self.b1 = other.W1.copy(), other.b1.copy()
        self.W2, self.b2 = other.W2.copy(), other.b2.copy()

    def train_step(self, X, a_idx, target, lr=0.01):
        """对 Q(s,a) 做一步均方误差回归到 target,只在所选动作 a 上回传。"""
        Q = self.forward(X)
        pred = Q[np.arange(len(X)), a_idx]
        dQ = np.zeros_like(Q)
        dQ[np.arange(len(X)), a_idx] = (pred - target) / len(X)   # MSE 梯度
        dW2 = self.h.T @ dQ
        db2 = dQ.sum(0)
        dh = dQ @ self.W2.T
        dz1 = dh * (self.z1 > 0)
        dW1 = self.X.T @ dz1
        db1 = dz1.sum(0)
        self.W2 -= lr * dW2; self.b2 -= lr * db2
        self.W1 -= lr * dW1; self.b1 -= lr * db1
        return float(np.mean((pred - target) ** 2))


def train(episodes=1500, gamma=0.95, eps=0.2, batch=32,
          target_sync=50, seed=0):
    rng = np.random.default_rng(seed)
    online, target = QNet(seed=seed), QNet(seed=seed)
    target.copy_from(online)
    buffer = []                      # 经验回放缓冲
    states = [(r, c) for r in range(ROWS) for c in range(COLS)
              if (r, c) not in WALLS]
    for ep in range(episodes):
        s = START
        for _ in range(50):
            if rng.random() < eps:
                a = rng.integers(4)
            else:
                a = int(np.argmax(online.forward(encode(s)[None])[0]))
            ns, r, done = step(s, a)
            buffer.append((s, a, r, ns, done))
            if len(buffer) > 5000:
                buffer.pop(0)
            s = ns
            # 从回放缓冲随机采样一个 minibatch 训练
            if len(buffer) >= batch:
                idx = rng.choice(len(buffer), batch, replace=False)
                bs = [buffer[i] for i in idx]
                X = np.array([encode(t[0]) for t in bs])
                A = np.array([t[1] for t in bs])
                R = np.array([t[2] for t in bs])
                NS = np.array([encode(t[3]) for t in bs])
                D = np.array([t[4] for t in bs], dtype=float)
                # TD 目标用【目标网络】算,稳住训练
                Qn = target.forward(NS)
                td_target = R + gamma * np.max(Qn, axis=1) * (1 - D)
                online.train_step(X, A, td_target)
            if done:
                break
        if ep % target_sync == 0:        # 周期性同步目标网络
            target.copy_from(online)
    return online, states


def show(net, states):
    print("=== 最小 DQN (经验回放 + 目标网络) on 4x4 gridworld ===")
    arrow = {0: "↑", 1: "↓", 2: "←", 3: "→"}
    print("\n学到的贪婪策略:")
    for r in range(ROWS):
        row = []
        for c in range(COLS):
            if (r, c) in WALLS:
                row.append(" # ")
            elif (r, c) == GOAL:
                row.append(" G ")
            elif (r, c) == TRAP:
                row.append(" T ")
            else:
                a = int(np.argmax(net.forward(encode((r, c))[None])[0]))
                row.append(f" {arrow[a]} ")
        print("  " + "".join(row))
    print("\n机制要点: replay 打破样本相关性; target net 让 TD 目标不随每步抖动。")


if __name__ == "__main__":
    net, states = train()
    show(net, states)

↓ 下载 14_dqn_min.py

经验回放——每走一步,把 (s,a,r,s,done)(s,a,r,s’,\text{done}) 存进 buffer,然后从缓冲区随机抽 32 条做一个小批量训练,而不是用刚刚那一步:

buffer.append((s, a, r, ns, done))
idx = rng.choice(len(buffer), batch, replace=False)   # 随机抽样去相关
...
td_target = R + gamma * np.max(target.forward(NS), axis=1) * (1 - D)

目标网络——TD 目标用一份独立的 target 网络算,它每隔 50 局才从在线网络同步一次参数:

if ep % target_sync == 0:
    target.copy_from(online)     # 周期性冻结/同步,稳住靶子

训练一千五百局后,网络学出的贪婪策略:

   ↓  ↓  ↓  ←
   ↓  #  ↓  T
   ↓  #  →  ↓
   →  →  →  G

从任何一格出发,沿箭头走都能绕开墙、避开陷阱 T、流向目标 G。这个小程序当然玩不了 Atari——真正的 DQN 网络深得多、缓冲区存上百万条经验、训练上千万帧。但它和那个登上《自然》封面的智能体共享完全相同的骨架:一个神经网络逼近 Q,经验回放喂给它去相关的批量,目标网络给它一个暂时不动的靶子


DQN 之后,研究者很快发现这个基础版本有不少可改进处,于是衍生出一整条改进谱系,几年内把 Atari 上的表现一路推高。

高估偏差。Q-learning 的更新用了 max\max,而 max\max 一个含噪声的估计会系统性地偏高——总是挑出那些“恰好被高估”的动作。2015 年的 Double DQN 用一个简单的拆分缓解这个问题:用在线网络最优动作,用目标网络评估这个动作的价值,把“选”和“评”解耦,显著减小了过度乐观3

价值与优势分离。很多状态下,做哪个动作其实差别不大,真正重要的是“这个状态本身好不好”。2016 年的 Dueling DQN 把网络拆成两个头,分别估计状态价值 V(s)V(s) 和每个动作相对的优势 A(s,a)A(s,a),再合成 Q(s,a)=V(s)+A(s,a)Q(s,a)=V(s)+A(s,a)——让网络能高效地学“这个局面值不值”,而不必为每个动作各学一遍4

不是所有经验一样重要。基础版经验回放是均匀随机抽样,但有些经验(TD 误差大的、出乎意料的)信息量更高。2015 年的优先经验回放(Prioritized Experience Replay)按 TD 误差大小给经验加权,让“意外”的经验被更频繁地回放,在 49 个游戏中的 41 个上超过均匀回放5

学分布而非期望。2017 年的 C51 提出一个更激进的视角:不要只估计回报的期望 QQ,而要估计回报的整个概率分布——同样是平均赢 10 分,“稳定赢 10 分”和“一半时候赢 20、一半输 0”是不同的,分布信息能让学习更稳健6。这开启了分布式强化学习这一支。

2018 年,DeepMind 的 Rainbow 把上述这些改进——Double、Dueling、优先回放、C51、多步回报、噪声网络——全部组合进一个智能体,在 Atari 基准上集大成,验证了这些改进大体是互补而非冲突的7。Rainbow 这个名字本身就是个隐喻:深度强化学习的进步,很多时候不是单个惊艳的新想法,而是一组朴素改进的稳健叠加。


退一步看 DQN 在这部技术史里的位置。它证明的核心命题是:第三章的卷积网络(负责感知)和第十二章的 Q-learning(负责决策)可以端到端地焊接在一起,用反向传播一并训练。 在它之前,“看懂世界”和“在世界里行动”是两个分开的研究领域;DQN 让一个智能体直接从原始像素学到了行动策略,把感知与决策第一次真正打通。

但 DQN 这一支有它的局限。它学的是动作价值 QQ,天然适合离散、有限的动作空间(Atari 手柄就那么几个键)。可现实里有大量连续控制问题——机器人的关节要转多少度、油门踩多深、方向盘打多少角度——动作是连续的实数,无法对所有动作取 max\max。对这类问题,与其费力地学一个 QQ 再从中挑动作,不如直接学一个策略:输入状态,输出动作(或动作的概率分布)。

这就把我们带回上一章末尾提过的另一条线索——策略梯度。它不绕道价值,而是直接对“策略”这个函数做梯度上升。下一章从 1992 年的 REINFORCE 讲起,经过策略梯度定理、actor-critic 架构、信赖域方法,一直讲到 PPO——那个先被用来打游戏、后来又被搬去对齐语言模型(第十一章的 RLHF)的算法。两条线,将在那里第一次交汇。

配套的 manim 动画 assets/manim/ch13_dqn.pyReplayBufferTargetNetwork 两个 Scene)把两个稳定机制演出来:前者展示智能体产生的连续经验(时间上强相关)落入回放池被打散,训练时随机抽一个 minibatch、近似独立——打破相关性;后者展示在线网络不断更新,而一份被冻结的目标网络负责给出 Bellman 目标 y=r+γmaxQ(θ)y=r+\gamma\max Q(\theta^-),在线网络去拟合这个不抖动的目标,每隔若干步才把参数复制过去——把发散的拟合变成稳定的回归。


本质

把神经网络直接塞进 Q-learning 之所以会发散,是因为它同时踩中了两个雷:一是强化学习采到的相邻样本高度相关,违背了梯度下降“样本近似独立”的隐含假设;二是它的回归目标里含着正在被训练的那个网络自己——你在追一个随自己移动的靶子。DQN 的两个机制恰好各拆一个雷:经验回放把经历存进一个池子、训练时随机抽样,把强相关的时间序列打散成近似独立的样本;目标网络冻结一份参数专门用来算目标,让靶子在一段时间内不动。它真正的贡献不是某个新的学习规则,而是让一个早已知道、却一直不稳定的组合(深度网络 + 时序差分)第一次能在一般问题上稳定收敛——并由此证明了一件更大的事:负责“看懂世界”的卷积网络和负责“在世界里行动”的 Q-learning,可以端到端地焊在一起,用同一套反向传播一并训练。感知与决策,第一次被真正打通。


参考文献

  1. Mnih, V., Kavukcuoglu, K., Silver, D., Graves, A., Antonoglou, I., Wierstra, D., & Riedmiller, M. (2013). Playing Atari with Deep Reinforcement Learning. NIPS 2013 Deep Learning Workshop. 首个从像素端到端学控制策略的深度模型;CNN 拟合 Q 值 + 经验回放;7 个 Atari 游戏,6 个超此前方法。arXiv:https://arxiv.org/abs/1312.5602

  2. Mnih, V., Kavukcuoglu, K., Silver, D., Rusu, A. A., Veness, J., Bellemare, M. G., Graves, A., Riedmiller, M., et al. (2015). Human-level control through deep reinforcement learning. Nature, 518, 529–533. 引入目标网络(周期性冻结 θ\theta^- 算 TD 目标)+ 奖励裁剪;同一套网络/超参在 49 个 Atari 游戏过半达人类水平。Nature:https://www.nature.com/articles/nature14236 ;PDF:https://web.stanford.edu/class/psych209/Readings/MnihEtAlHassibis15NatureControlDeepRL.pdf

  3. van Hasselt, H., Guez, A., & Silver, D. (2015/2016). Deep Reinforcement Learning with Double Q-learning. AAAI 2016. 用在线网络选动作、目标网络估值,拆解 max\max 导致的高估偏差。arXiv:https://arxiv.org/abs/1509.06461

  4. Wang, Z., Schaul, T., Hessel, M., van Hasselt, H., Lanctot, M., & de Freitas, N. (2016). Dueling Network Architectures for Deep Reinforcement Learning. ICML 2016. Q(s,a)=V(s)+A(s,a)Q(s,a)=V(s)+A(s,a) 双头分流,提升状态价值估计。arXiv:https://arxiv.org/abs/1511.06581 ;PMLR:https://proceedings.mlr.press/v48/wangf16.pdf

  5. Schaul, T., Quan, J., Antonoglou, I., & Silver, D. (2015). Prioritized Experience Replay. ICLR 2016. 按 TD 误差大小加权采样经验,41/49 游戏胜均匀回放。arXiv:https://arxiv.org/abs/1511.05952

  6. Bellemare, M. G., Dabney, W., & Munos, R. (2017). A Distributional Perspective on Reinforcement Learning. ICML 2017. 学回报的分布而非期望,分布式 Bellman 算子,51 个原子(C51)。arXiv:https://arxiv.org/abs/1707.06887

  7. Hessel, M., Modayil, J., van Hasselt, H., Schaul, T., Ostrovski, G., Dabney, W., Horgan, D., Piot, B., Azar, M., & Silver, D. (2018). Rainbow: Combining Improvements in Deep Reinforcement Learning. AAAI 2018. 合并 Double/Dueling/优先回放/C51/多步/噪声网络,Atari 集大成 SOTA。arXiv:https://arxiv.org/abs/1710.02298

连续轨迹吐出的相邻经验高度相关、违背 SGD 的 i.i.d. 假设;经验回放把它们存进缓冲区、训练时随机抽 minibatch,从本质上把强相关时间序列打散成近似独立样本。
回归目标 y 里含着正在训练的 θ——射靶比喻里靶子随准星同动而发散;冻结一份 θ⁻ 专算 Bellman 目标后靶子静止,在线网络稳定收敛,把发散的拟合变成稳定的回归。