# DQN：深度 Q 网络

> 来源：https://dqn.cn/advanced/dqn ｜ 作者：DQN.CN 编辑部 ｜ 发布：2026-10-29 ｜ 更新：2026-10-30 ｜ 许可：CC BY-NC-SA 4.0（文字）/ MIT（代码）

> **要点速览**
>
> - DQN（Deep Q-Network，深度 Q 网络）用神经网络近似动作价值函数 Q(s,a;θ)，把 Q-learning 扩展到图像等高维状态。
> - DQN 的两项关键技巧是经验回放（从回放池随机采样以打破数据相关性）和目标网络（用延迟更新的参数 θ⁻ 计算 TD 目标以稳定训练）。
> - DQN 的目标值为 y = r + γ max_a' Q(s',a';θ⁻)，损失是 Q(s,a;θ) 与 y 之间的均方误差或 Huber 损失。
> - DQN 只适用于离散动作空间；它的过估计、样本效率等问题催生了 Double DQN、Dueling DQN、PER 和 Rainbow 等改进。

DQN（Deep Q-Network）由 DeepMind 提出，最早发表于 2013 年的 NeurIPS Deep Learning Workshop，完整版本于 2015 年发表在 *Nature*（Mnih et al., *Human-level control through deep reinforcement learning*）。它用卷积神经网络近似动作价值函数 $Q(s, a)$，直接以 Atari 游戏画面为输入，在 49 款游戏上使用同一套网络结构和超参数，很多游戏达到或超过人类专业测试员水平。DQN 的两个关键技巧——**经验回放**和**目标网络**——让“神经网络 + Q-learning”的训练变得稳定，开启了深度强化学习时代。

## 从 Q-learning 到 DQN

表格型 [Q-learning](https://dqn.cn/basics/q-learning-sarsa) 的更新规则是：

$$
Q(s, a) \leftarrow Q(s, a) + \alpha \left[ r + \gamma \max_{a'} Q(s', a') - Q(s, a) \right]
$$

当状态空间很大（例如 Atari 画面有 $210 \times 160$ 个像素）时，无法用表格存储每个状态的 Q 值。DQN 的思路是用参数为 $\theta$ 的神经网络 $Q(s, a; \theta)$ 来近似 $q_*(s, a)$：输入状态，输出每个离散动作的 Q 值。

但直接把 Q-learning 和神经网络结合往往不稳定甚至发散，原因主要有两个：

1. **样本相关性强**：连续采集的样本高度相关，违背了随机梯度下降对独立同分布数据的假设；
2. **目标不断移动**：TD 目标 $r + \gamma \max_{a'} Q(s', a'; \theta)$ 本身依赖正在更新的参数 $\theta$，相当于追逐一个移动的靶子。

DQN 分别用经验回放和目标网络解决这两个问题。

## 关键技巧一：经验回放（Experience Replay）

把智能体与环境交互得到的转移 $(s, a, r, s', \text{done})$ 存入一个固定容量的**回放缓冲区** $\mathcal{D}$。训练时从中**均匀随机采样**一个小批量来更新网络。

好处：

- 打破样本之间的时间相关性；
- 每条经验可以被多次使用，提高样本效率；
- 平滑数据分布的变化。

## 关键技巧二：目标网络（Target Network）

维护一个与在线网络结构相同、参数为 $\theta^-$ 的**目标网络**，用它来计算 TD 目标：

$$
y = r + \gamma (1 - \text{done}) \max_{a'} Q(s', a'; \theta^-)
$$

目标网络的参数每隔 $C$ 步从在线网络复制一次（$\theta^- \leftarrow \theta$），或采用软更新 $\theta^- \leftarrow \tau \theta + (1 - \tau)\theta^-$。这样在一段时间内目标保持固定，训练更稳定。

## 损失函数

DQN 最小化 TD 误差的均方（原论文对误差项做了裁剪，等价于使用 Huber 损失）：

$$
L(\theta) = \mathbb{E}_{(s,a,r,s') \sim \mathcal{D}} \left[ \left( y - Q(s, a; \theta) \right)^2 \right]
$$

## 探索策略：ε-贪心

以概率 $\varepsilon$ 随机选择动作，以概率 $1 - \varepsilon$ 选择 $\arg\max_a Q(s, a; \theta)$。通常 $\varepsilon$ 从 1.0 线性衰减到 0.01～0.1。

## 算法流程

1. 初始化回放缓冲区 $\mathcal{D}$、在线网络 $Q(\cdot;\theta)$，令目标网络 $\theta^- \leftarrow \theta$；
2. 对每一步：
   1. 用 ε-贪心策略选择动作 $a$，执行后得到 $r, s'$；
   2. 把 $(s, a, r, s', \text{done})$ 存入 $\mathcal{D}$；
   3. 从 $\mathcal{D}$ 随机采样小批量，计算目标 $y$；
   4. 对 $L(\theta)$ 做一步梯度下降；
   5. 每 $C$ 步同步目标网络 $\theta^- \leftarrow \theta$。

## PyTorch 实现：CartPole

下面是一个可以直接运行的最小 DQN 实现，使用 Gymnasium 的 `CartPole-v1` 环境（需要 `pip install torch gymnasium`）。

```python
"""DQN：CartPole-v1 上的最小可运行实现（经验回放 + 目标网络软更新）。"""
import random
from collections import deque

import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F

# ---------- 超参数 ----------
GAMMA = 0.99
LR = 5e-4
BATCH_SIZE = 128
BUFFER_SIZE = 50_000
LEARNING_STARTS = 1_000
TAU = 0.005                  # 目标网络软更新系数
EPS_START, EPS_END, EPS_DECAY_STEPS = 1.0, 0.05, 10_000
TOTAL_STEPS = 50_000

random.seed(0); np.random.seed(0); torch.manual_seed(0)
env = gym.make("CartPole-v1")
obs_dim = env.observation_space.shape[0]
n_actions = env.action_space.n
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class QNet(nn.Module):
    def __init__(self, obs_dim, n_actions):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(obs_dim, 128), nn.ReLU(),
            nn.Linear(128, 128), nn.ReLU(),
            nn.Linear(128, n_actions),
        )

    def forward(self, x):
        return self.net(x)

q_net = QNet(obs_dim, n_actions).to(device)
target_net = QNet(obs_dim, n_actions).to(device)
target_net.load_state_dict(q_net.state_dict())
optimizer = torch.optim.Adam(q_net.parameters(), lr=LR)
buffer = deque(maxlen=BUFFER_SIZE)

def epsilon(step):
    frac = min(step / EPS_DECAY_STEPS, 1.0)
    return EPS_START + frac * (EPS_END - EPS_START)

obs, _ = env.reset(seed=0)
env.action_space.seed(0)          # 动作空间的随机数生成器需要单独设置种子
episode_return, returns = 0.0, []

for step in range(TOTAL_STEPS):
    # 1) ε-贪心选择动作
    if random.random() < epsilon(step):
        action = env.action_space.sample()
    else:
        with torch.no_grad():
            q = q_net(torch.as_tensor(obs, dtype=torch.float32, device=device))
            action = int(q.argmax().item())

    # 2) 与环境交互，存入回放缓冲区
    next_obs, reward, terminated, truncated, _ = env.step(action)
    # 注意：只有真正终止（terminated）时才不自举；被截断（truncated）时仍应自举
    buffer.append((obs, action, reward, next_obs, float(terminated)))
    obs = next_obs
    episode_return += reward
    if terminated or truncated:
        returns.append(episode_return)
        obs, _ = env.reset()
        episode_return = 0.0

    # 3) 从缓冲区采样并更新网络
    if step >= LEARNING_STARTS:
        batch = random.sample(buffer, BATCH_SIZE)
        s, a, r, s2, d = map(np.array, zip(*batch))
        s = torch.as_tensor(s, dtype=torch.float32, device=device)
        a = torch.as_tensor(a, dtype=torch.int64, device=device).unsqueeze(1)
        r = torch.as_tensor(r, dtype=torch.float32, device=device)
        s2 = torch.as_tensor(s2, dtype=torch.float32, device=device)
        d = torch.as_tensor(d, dtype=torch.float32, device=device)

        q_sa = q_net(s).gather(1, a).squeeze(1)
        with torch.no_grad():
            target = r + GAMMA * (1 - d) * target_net(s2).max(dim=1).values
        loss = F.smooth_l1_loss(q_sa, target)   # Huber 损失

        optimizer.zero_grad()
        loss.backward()
        nn.utils.clip_grad_norm_(q_net.parameters(), 10.0)
        optimizer.step()

    # 4) 软更新目标网络：θ⁻ ← τθ + (1-τ)θ⁻
    if step >= LEARNING_STARTS:
        with torch.no_grad():
            for p, tp in zip(q_net.parameters(), target_net.parameters()):
                tp.mul_(1 - TAU).add_(TAU * p)

    if step % 5_000 == 0 and returns:
        print(f"step {step:6d} | ε={epsilon(step):.2f} | 最近10回合平均回报 {np.mean(returns[-10:]):.1f}")

env.close()

# ---------- 训练结束：贪心策略评估 10 个回合 ----------
eval_env = gym.make("CartPole-v1")
scores = []
for i in range(10):
    o, _ = eval_env.reset(seed=10_000 + i)
    done, total = False, 0.0
    while not done:
        with torch.no_grad():
            a = int(q_net(torch.as_tensor(o, dtype=torch.float32, device=device)).argmax())
        o, r, te, tr, _ = eval_env.step(a)
        total += r; done = te or tr
    scores.append(total)
print(f"贪心评估（10 回合）平均回报: {np.mean(scores):.1f}")
```
在本站环境中（单线程 CPU，约 1 分钟）实际运行的输出：

```text
step   5000 | ε=0.53 | 最近10回合平均回报 33.9
step  10000 | ε=0.05 | 最近10回合平均回报 118.5
step  15000 | ε=0.05 | 最近10回合平均回报 96.5
step  20000 | ε=0.05 | 最近10回合平均回报 84.4
step  25000 | ε=0.05 | 最近10回合平均回报 96.5
step  30000 | ε=0.05 | 最近10回合平均回报 282.0
step  35000 | ε=0.05 | 最近10回合平均回报 500.0
step  40000 | ε=0.05 | 最近10回合平均回报 271.5
step  45000 | ε=0.05 | 最近10回合平均回报 446.5
贪心评估（10 回合）平均回报: 500.0
```

### 训练的不稳定性：一次真实的调参记录

DQN 在 CartPole 上以**不稳定**著称：回报经常在达到 500 后又突然掉下来（上面的 4 万步处就掉到了 271.5）。我们在 3 个随机种子上比较了两种批量大小（其余超参数相同，训练 5 万步后用贪心策略评估 10 回合）：

| 批量大小 | 种子 0 | 种子 1 | 种子 2 |
| --- | --- | --- | --- |
| 64 | 80.0 | 490.1 | 420.9 |
| 128（最终采用） | 500.0 | 470.9 | 500.0 |

批量 64 时种子 0 在训练末期“崩溃”到 80 分；改为 128 后 3 个种子都稳定在 470 以上。我们还试过把学习率降到 $2.5 \times 10^{-4}$ 或 $10^{-4}$（批量 64、5 万步），在 3 个种子上的评估结果在 93–500 之间，并没有更好。实践中常用的应对方法包括：更大的批量、更小的学习率配合更长的训练、Huber 损失与梯度裁剪、定期评估并**保存表现最好的模型**（而不是训练结束时的模型），以及使用 [Double DQN](https://dqn.cn/advanced/dqn-improvements) 等改进。

### 代码要点

- `gather(1, a)` 取出实际执行动作对应的 Q 值；
- 目标值计算放在 `torch.no_grad()` 中，梯度不会流入目标网络；
- 使用 `terminated` 而不是 `terminated or truncated` 作为终止标志，这是一个常见的坑；
- Huber 损失（`smooth_l1_loss`）和梯度裁剪能提高训练稳定性；
- 这里目标网络采用软更新（$\tau = 0.005$），也可以改成原论文的每 $C$ 步硬同步。

## 常见问题

**Q：DQN 能处理连续动作吗？**
不能直接处理，因为需要对所有动作取 $\max$。连续动作请参考 [DDPG](https://dqn.cn/advanced/ddpg)、[TD3](https://dqn.cn/advanced/td3)、[SAC](https://dqn.cn/advanced/sac)。

**Q：为什么 Q 值会被高估？**
因为 $\max$ 操作会偏向被噪声高估的动作。[Double DQN](https://dqn.cn/advanced/dqn-improvements) 通过把“选动作”和“评估动作”分开来缓解这个问题。

## Atari 版本：预处理与网络结构

原论文在 Atari 上使用的关键设置：

- **预处理**：RGB 画面转灰度并缩放到 84×84；每个动作重复 4 帧（frame skip），取最后两帧的逐像素最大值以消除闪烁；
- **帧堆叠**：把最近 4 帧堆叠成 $4 \times 84 \times 84$ 的输入，以恢复速度等运动信息（近似满足马尔可夫性）；
- **奖励裁剪**：把奖励裁剪到 $\{-1, 0, +1\}$，使同一套超参数适用于所有游戏；
- **网络**：3 个卷积层（32 个 8×8 步长 4、64 个 4×4 步长 2、64 个 3×3 步长 1）+ 512 单元全连接层；
- **超参数**：回放缓冲区 100 万帧，目标网络每 1 万步硬同步，ε 在前 100 万帧从 1 线性衰减到 0.1，共训练 5000 万帧，RMSProp 优化器。

下面的代码用 Gymnasium 的 `AtariPreprocessing` 和 `FrameStackObservation` 搭建了这套预处理，并构建了 Nature DQN 网络。**它只检查形状并随机交互 200 步，不包含训练**——在 Atari 上完整训练 DQN 需要 GPU 和数小时到数天的时间，本站没有进行这样的训练，因此也不报告 Atari 上的分数。把它与上面 CartPole 版本的训练循环组合起来，就是一个完整的 Atari DQN。

```python
"""DQN 的 Atari 设置：标准预处理 + Nature DQN 卷积网络（只检查形状并随机交互几步，不包含训练）。
依赖：pip install "gymnasium[atari]" opencv-python   （ale-py 0.9+ 已自带游戏 ROM）"""
import ale_py
import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn

gym.register_envs(ale_py)

def make_env(env_id="ALE/Breakout-v5"):
    # frameskip=1：跳帧交给 AtariPreprocessing 处理；repeat_action_probability=0 对应原论文设置
    env = gym.make(env_id, frameskip=1, repeat_action_probability=0.0)
    env = gym.wrappers.AtariPreprocessing(
        env,
        noop_max=30,             # 开局随机执行 0~30 个 no-op，增加初始状态多样性
        frame_skip=4,            # 每个动作重复 4 帧
        screen_size=84,          # 缩放到 84×84
        grayscale_obs=True,      # 灰度化
        terminal_on_life_loss=False,
        scale_obs=False,         # 保持 uint8 存储以节省回放内存，送入网络前再 /255
    )
    env = gym.wrappers.FrameStackObservation(env, stack_size=4)   # 堆叠最近 4 帧 → (4, 84, 84)
    return env

class NatureDQN(nn.Module):
    """Mnih et al. (2015) 中的网络结构"""

    def __init__(self, n_actions):
        super().__init__()
        self.net = nn.Sequential(
            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(64 * 7 * 7, 512), nn.ReLU(),
            nn.Linear(512, n_actions),
        )

    def forward(self, x):
        return self.net(x.float() / 255.0)

env = make_env()
obs, _ = env.reset(seed=0)
q_net = NatureDQN(env.action_space.n)
print("观测形状:", np.array(obs).shape, "| 动作数:", env.action_space.n)
print("网络参数量:", sum(p.numel() for p in q_net.parameters()))
total_reward = 0.0
for t in range(200):
    with torch.no_grad():
        q = q_net(torch.as_tensor(np.array(obs))[None])
    obs, r, term, trunc, _ = env.step(env.action_space.sample())
    total_reward += np.sign(r)          # 训练时通常把奖励裁剪到 {-1, 0, +1}
    if term or trunc:
        obs, _ = env.reset()
print("Q 值输出形状:", tuple(q.shape), "| 随机交互 200 步完成")
```
实际运行输出（ale-py 0.12.1）：

```text
观测形状: (4, 84, 84) | 动作数: 4
网络参数量: 1686180
Q 值输出形状: (1, 4) | 随机交互 200 步完成
```

注意 `scale_obs=False`：回放缓冲区中以 `uint8` 存储观测（100 万帧 × 84 × 84 字节约 7 GB，若存 float32 则是 4 倍），送入网络前再除以 255。

## 练习题

1. 把软更新改回原论文的硬更新（每 $C = 500$ 步复制一次参数），比较训练稳定性。
2. 把 Huber 损失换成均方误差、去掉梯度裁剪，观察训练曲线的变化。
3. 去掉经验回放（每步只用最新的一条样本更新），DQN 还能学会 CartPole 吗？
4. 加入“定期评估、保存最佳模型”的逻辑，统计 5 个种子下最佳模型的评估回报。
5. 把 `terminated` 改成 `terminated or truncated` 作为终止标志，分析它对价值估计的影响。

**延伸阅读**

[论文速递：DQN（Nature 2015）解读](https://dqn.cn/papers/2026-10-dqn-nature)

## 参考文献

- Mnih, V. et al. (2015). *Human-level control through deep reinforcement learning*. Nature, 518, 529–533. [doi:10.1038/nature14236](https://doi.org/10.1038/nature14236)
- Mnih, V. et al. (2013). *Playing Atari with Deep Reinforcement Learning*. [arXiv:1312.5602](https://arxiv.org/abs/1312.5602)
- Lin, L.-J. (1992). Self-improving reactive agents based on reinforcement learning, planning and teaching. *Machine Learning*, 8, 293–321.（经验回放的早期提出）
- Machado, M. C. et al. (2018). Revisiting the Arcade Learning Environment: Evaluation Protocols and Open Problems for General Agents. *JAIR*, 61, 523–562.

## 小结与下一步

上一节：[Gymnasium 上手](https://dqn.cn/basics/gymnasium)。DQN = Q-learning + 深度神经网络 + 经验回放 + 目标网络。下一节 [DQN 改进](https://dqn.cn/advanced/dqn-improvements) 将介绍 Double DQN、Dueling DQN、优先经验回放和集大成的 Rainbow。

## 常见问题速查（FAQ）

**问：DQN 为什么需要目标网络？**

答：如果用同一个网络既估计 Q 值又计算目标，每次更新都会改变目标本身，容易振荡甚至发散。目标网络的参数定期（或用软更新缓慢地）从在线网络复制，使目标在一段时间内保持稳定。

**问：经验回放有什么作用？**

答：把转移存入回放池后随机采样，可以打破连续样本之间的相关性，使每条经验被多次利用，提高数据效率并让训练更稳定。

**问：DQN 可以用于连续动作吗？**

答：不能直接使用，因为需要对所有动作求 max。连续动作问题通常使用 DDPG、TD3、SAC 或 PPO。

**问：DQN 在 CartPole 上训练不稳定怎么办？**

答：DQN 对随机种子和超参数比较敏感。可以尝试增大批量、降低学习率、使用软更新、Huber 损失和梯度裁剪，并用多个种子评估。本站实测中 batch 128 时 3 个种子的贪心评估为 500 / 470.9 / 500。

## 如何引用本文

DQN.CN 编辑部. DQN：深度 Q 网络[EB/OL]. DQN.CN, 2026-10-29（更新于 2026-10-30）. https://dqn.cn/advanced/dqn.
