DQN 深度 Q 网络 本节摘要:2013 年,Mnih 用一个 Q 学习网络吃下原始像素,在七款 Atari 游戏上击败所有经典 RL 智能体;2015 年扩展到 49 款游戏、登上 Nature,点燃了深度 RL 时代。DQN = Q 学习 + 三个让函数逼近稳定的工程技巧:经验回放(去相关)、目标网络(冻结自举目标)、奖励裁剪(归一化梯度幅度)。本节讲透这三个技巧为何不可或缺——它们要驯服的是「致命三要素」(函数逼近 + 自举 + off-policy,三者合起来没有收敛保证)。你会在一个手写的单隐层 MLP 上跑通完整 DQN,看到它与 Atari 版算法在数学上完全同构,只是规模不同。
本节摘要:2013 年,Mnih 用一个 Q 学习网络吃下原始像素,在七款 Atari 游戏上击败所有经典 RL 智能体;2015 年扩展到 49 款游戏、登上 Nature,点燃了深度 RL 时代。DQN = Q 学习 + 三个让函数逼近稳定的工程技巧:经验回放(去相关)、目标网络(冻结自举目标)、奖励裁剪(归一化梯度幅度)。本节讲透这三个技巧为何不可或缺——它们要驯服的是「致命三要素」(函数逼近 + 自举 + off-policy,三者合起来没有收敛保证)。你会在一个手写的单隐层 MLP 上跑通完整 DQN,看到它与 Atari 版算法在数学上完全同构,只是规模不同。最后讲清 Double DQN、Dueling、Rainbow 等改进——它们都是叠在这三件套之上的几百分点提升。
对应原课程:Phase 9 · Lesson 05 ·
dqn(原英文phases/09-reinforcement-learning/05-dqn/docs/en.md)。
阅读完本节,你应当能够:
L(θ) = E[(r + γ max Q(s',·;θ⁻) - Q(s,a;θ))²],区分在线网 θ 与目标网 θ⁻。表格 Q 学习需要为每个(状态, 动作)对存一个 Q 值。棋盘约 10⁴³ 个状态;一帧 Atari 是 210×160×3 = 100800 个特征。表格 RL 在几千个状态时就死掉,遑论几十亿。
修复办法事后看很显然:用一个神经网络 Q(s, a; θ) 替换 Q 表。但「事后看显然」花了几十年。朴素函数逼近 + Q 学习在致命三要素下发散——函数逼近 + 自举 + off-policy 学习,三者合起来没有收敛保证。Mnih et al. (2013, 2015) 找到了三个工程技巧让训练稳定:
DQN 在 Atari 上是第一次:单一架构 + 单一超参集,从原始像素解决几十个控制问题。之后所有的「深度 RL」——DDQN、Rainbow、Dueling、Distributional、R2D2、Agent57——都叠在这个三件套之上。
L(θ) = E_{(s,a,r,s')~D} [ (r + γ max_{a'} Q(s', a'; θ⁻) - Q(s, a; θ))² ]
θ = 在线网络,每步用梯度下降更新。θ⁻ = 目标网络,周期性从 θ 拷贝(约每 10000 步)。D = 存历史转移的回放池。经验回放:约 10⁶ 转移的环形缓冲。每次训练步均匀随机采样一个 minibatch。这打断了时间相关性(相邻帧几乎相同),让网络能从稀有的高奖励转移里学很多次,并去相关连续的梯度更新。没有它,神经网络 + on-policy TD 在 Atari 上发散。
目标网络:在贝尔曼方程两边用同一个 Q(·; θ),目标每次更新都动——「追自己的尾巴」。修复:留第二份网 Q(·; θ⁻),权重冻结。每 C 步把 θ → θ⁻ 拷一次。这把回归目标稳住数千个梯度步。软更新 θ⁻ ← τ θ + (1-τ) θ⁻(DDPG、SAC 用)是更平滑的变体。
奖励裁剪:Atari 奖励幅度从 1 到 1000+ 不等。裁到 {-1, 0, +1} 阻止单一游戏主导梯度。奖励幅度有意义时它不对;但 Atari 只在乎符号,所以无所谓。
Double DQN(Hasselt 2016)修复最大化偏差:用在线网选动作,目标网估值。
target = r + γ Q(s', argmax_{a'} Q(s', a'; θ); θ^-)
直接替换,普遍更好。默认就该开。
Rainbow(2017)的其他改进:优先回放(按 TD 误差大小采样)、Dueling 架构(分开 V(s) 与优势头)、Noisy 网(学到的探索)、n 步回报、分布式 Q(C51/QR-DQN)、多步自举。每个加几个百分点,收益大致可加。
💡 「致命三要素」是为什么深度 RL 比监督学习难得多的根源。监督学习里,目标
y是固定的;DQN 里,目标r + γ max Q(s',·;θ⁻)自己也在变——而且跟着你正在训练的网一起变。目标网络的作用就是把「移动的靶」临时钉住几千步,让 SGD 有机会收敛到一个局部解。
这里的代码是纯标准库、不用 numpy——我们手写一个单隐层 MLP,跑在一个微型连续 GridWorld 上,每次训练步只需微秒级。算法与大规模 Atari DQN 完全一致。
class ReplayBuffer: def __init__(self, capacity): self.buf = [] self.capacity = capacity def push(self, s, a, r, s_next, done): if len(self.buf) == self.capacity: self.buf.pop(0) self.buf.append((s, a, r, s_next, done)) def sample(self, batch, rng): return rng.sample(self.buf, batch)
Atari 用约 50000 容量;我们的玩具环境 5000 足够。
class QNet: def __init__(self, n_in, n_hidden, n_actions, rng): self.W1 = [[rng.gauss(0, 0.3) for _ in range(n_in)] for _ in range(n_hidden)] self.b1 = [0.0] * n_hidden self.W2 = [[rng.gauss(0, 0.3) for _ in range(n_hidden)] for _ in range(n_actions)] self.b2 = [0.0] * n_actions def forward(self, x): h = [max(0.0, sum(w * xi for w, xi in zip(row, x)) + b) for row, b in zip(self.W1, self.b1)] q = [sum(w * hi for w, hi in zip(row, h)) + b for row, b in zip(self.W2, self.b2)] return q, h
前向传播:线性 → ReLU → 线性。这就是整个网。
def train_step(online, target, batch, gamma, lr): grads = zeros_like(online) for s, a, r, s_next, done in batch: q, h = online.forward(s) if done: y = r else: q_next, _ = target.forward(s_next) y = r + gamma * max(q_next) td_error = q[a] - y accumulate_grads(grads, online, s, h, a, td_error) apply_sgd(online, grads, lr / len(batch))
形状是第 04 节的 Q 学习,只有两处不同:(a) 我们对一个可微的 Q(·; θ) 做反传,而非索引一张表;(b) 目标用的是 Q(·; θ⁻)。
for episode in range(N): s = env.reset() while not done: a = epsilon_greedy(online, s, epsilon) s_next, r, done = env.step(s, a) buffer.push(s, a, r, s_next, done) if len(buffer) >= batch: train_step(online, target, buffer.sample(batch), gamma, lr) if steps % sync_every == 0: target = copy(online) s = s_next
在我们的微型 GridWorld(16 维 one-hot 状态)上,约 500 回合学到近最优策略。Atari 上,把它放大到 2 亿帧并加一个 CNN 特征提取器。
2026 年 DQN 已很少是最先进,但仍是参考性的 off-policy 算法:
| 任务 | 首选方法 | 为什么不用 DQN |
|---|---|---|
| 离散动作 Atari 类 | Rainbow DQN 或 Muesli | 同框架、更多技巧 |
| 连续控制 | SAC / TD3(第 07 节) | DQN 没有策略网 |
| on-policy / 高吞吐 | PPO(第 08 节) | 无回放池;更易扩展 |
| 离线 RL | CQL / IQL / Decision Transformer | 保守 Q 目标,无自举爆炸 |
| 大离散动作空间(推荐) | 带 action embedding 的 DQN,或 IMPALA | 可以,装饰很重要 |
| LLM RL | PPO / GRPO | 序列级而非步级;损失不同 |
经验教训仍然流转。回放与目标网络出现在 SAC、TD3、DDPG、SAC-X、AlphaZero 的自博弈缓冲、以及每个离线 RL 方法里。奖励裁剪在 PPO 里以「优势归一化」的形式延续。这套架构就是蓝图。
生产里你不会手写 DQN——stable-baselines3 的 DQN 算法或 CleanRL 的单文件 dqn.py 是标准选择。两者都默认带目标网、回放池、ε 衰减。读 CleanRL 那个单文件是理解 DQN 的最佳途径:它把所有部件压在一个不到 300 行的文件里,与我们的骨架一一对应。值得做的事:把本节的玩具实现与 CleanRL 对照阅读,看每个 trick 在真实代码里长什么样。
本节产出一个可复用 skill(位于原课程 outputs/skill-dqn-trainer.md)。骨架:
--- name: dqn-trainer description: 为离散动作 RL 任务产出 DQN 训练配置(缓冲、目标网同步、ε 调度、奖励裁剪)。 version: 1.0.0 phase: 9 lesson: 5 tags: [rl, dqn, deep-rl] --- 给定一个离散动作环境(观测形状、动作数、视野、奖励尺度),输出: 1. 网络。架构(MLP / CNN / Transformer)、特征维、深度。 2. 回放池。容量、minibatch 大小、预热大小。 3. 目标网络。同步策略(每 C 步硬同步,或软更新 τ)。 4. 探索。ε 起 / 止 / 调度长度。 5. 损失。Huber vs MSE、梯度裁剪值、奖励裁剪规则。 6. Double DQN。默认开,除非有明确理由关。 拒绝交付没有目标网络、没有回放池、或 ε 全程=1 的 DQN。 拒绝连续动作任务(改走 SAC / TD3)。 标记任何奖励范围 > 步均值 10 倍的,需裁剪或尺度归一化。
code/main.py,画出每回合回报曲线。多少回合后滚动均值超过 -10?argmax a'、目标网估值。在带噪声奖励的 GridWorld 上,1000 回合后,对比有/无 Double DQN 时 Q(s_0, best_a) 相对真值 V*(s_0) 的偏差。max 向上偏。生产里永远用 Double DQN。| 术语 | 俗称 | 实际含义 |
|---|---|---|
| DQN | 「深度 Q 学习」 | 用神经网络做 Q 函数 + 回放池 + 目标网 |
| 经验回放 | 「打散的转移」 | 每次梯度步均匀采样的环形缓冲;去相关数据 |
| 目标网络 | 「冻结的自举」 | 周期性拷贝的 Q,用于贝尔曼目标;稳住训练 |
| 致命三要素 | 「RL 为何发散」 | 函数逼近 + 自举 + off-policy = 无收敛保证 |
| Double DQN | 「最大化偏差的修复」 | 在线网选动作,目标网估值 |
| Dueling DQN | 「V 头与 A 头」 | 分解 Q = V + A - mean(A);同输出,梯度流更好 |
| Rainbow | 「全家桶」 | DDQN + PER + Dueling + n 步 + Noisy + 分布式 |
| PER | 「优先回放」 | 按 TD 误差幅度比例采样转移 |
L(θ) = E[(r + γ max Q(s',·;θ⁻) - Q(s,a;θ))²],在线网 θ 更新、目标网 θ⁻ 周期冻结。max 的高估偏差。下一节,我们换个思路——不再估价值,而是直接参数化策略算期望回报的梯度往上爬。REINFORCE 是 PPO、GRPO 与所有 LLM RL 循环的源头。