# DQN 改进：Double / Dueling / PER / Rainbow

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

> **要点速览**
>
> - Double DQN 用在线网络选择动作、用目标网络评估动作，缓解 max 操作带来的 Q 值过估计。
> - Dueling DQN 把 Q 值分解为状态价值 V(s) 和优势 A(s,a) 两个分支，适合许多动作价值相近的状态。
> - 优先经验回放（PER）按 TD 误差大小优先采样“意外”的经验，并用重要性采样权重修正偏差。
> - Rainbow 组合了 Double、Dueling、PER、多步回报、分布式 RL（C51）和 Noisy Nets 六项改进，在 Atari 上显著优于单项改进。

原始 [DQN](https://dqn.cn/advanced/dqn) 之后，研究者针对它的 Q 值高估、样本利用效率低、网络结构不够高效等问题提出了一系列改进。Double DQN（van Hasselt et al., 2016）把动作选择与评估分离以缓解高估；Dueling DQN（Wang et al., 2016）把 Q 值拆成状态价值与优势函数；优先经验回放 PER（Schaul et al., 2016）按 TD 误差大小优先采样。Rainbow（Hessel et al., 2018）把包括这些在内的六项改进组合在一起，在 Atari 57 款游戏上取得了当时最好的成绩，并通过消融实验分析了每一项的贡献。

本节记号：在线网络 $Q(s, a; \theta)$，目标网络 $Q(s, a; \theta^-)$，回放缓冲区 $\mathcal{D}$，TD 误差 $\delta$。

## Double DQN

### Q 值高估问题

DQN 的目标值 $y = r + \gamma \max_{a'} Q(s', a'; \theta^-)$ 中，同一个网络既用来**选**动作（$\arg\max$），又用来**评估**这个动作的价值。若估计带有噪声 $Q(s', a') = q_*(s', a') + \epsilon_{a'}$，则

$$
\mathbb{E}\left[\max_{a'} Q(s', a')\right] \ge \max_{a'} \mathbb{E}\left[Q(s', a')\right] = \max_{a'} q_*(s', a')
$$

即对噪声取最大值会系统性地**高估**（最大化偏差）。高估本身如果是均匀的并不可怕，但它往往不均匀，会让智能体偏好那些“被高估”的动作，并通过自举在状态之间传播。van Hasselt 等人在 Atari 上观察到 DQN 的价值估计远高于实际回报。

### 目标值的改写

Double Q-learning（van Hasselt, 2010）的思想是用两个独立的估计器分别负责选择和评估。Double DQN 直接复用 DQN 已有的两个网络：**在线网络选动作，目标网络评估**：

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

改动只有一行代码，却能显著减少高估。

## Dueling DQN

### 价值与优势分解

很多状态下，选哪个动作并不重要（例如赛车游戏中前方没有障碍时）。Dueling 架构把网络的输出拆成两路：

- **状态价值流** $V(s; \theta, \beta)$：这个状态本身有多好；
- **优势流** $A(s, a; \theta, \alpha)$：在这个状态下各动作相对好多少，$A(s, a) = Q(s, a) - V(s)$。

这样即使某个动作没有被执行，$V(s)$ 的更新也会影响该状态所有动作的 Q 值，学习效率更高。

### 可辨识性问题

直接令 $Q = V + A$ 时，$V$ 和 $A$ 不唯一（$V$ 加一个常数、$A$ 减同一个常数，$Q$ 不变）。论文采用减去均值的形式：

$$
Q(s, a) = V(s) + \left(A(s, a) - \frac{1}{|\mathcal{A}|} \sum_{a'} A(s, a')\right)
$$

（另一种形式是减去 $\max_{a'} A(s, a')$，论文发现减均值更稳定。）Dueling 只改变网络结构，可以与任何 DQN 变体组合。

## 优先经验回放（PER）

### 按 TD 误差采样

均匀回放把所有经验一视同仁，但 TD 误差大的样本说明“预测错得多”，学习价值更高。PER 给每条经验 $i$ 赋予优先级 $p_i = |\delta_i| + \epsilon$，按如下概率采样：

$$
P(i) = \frac{p_i^\alpha}{\sum_k p_k^\alpha}
$$

$\alpha$ 控制优先程度（$\alpha = 0$ 退化为均匀采样）。新样本以当前最大优先级插入，保证至少被采样一次。为了在 $O(\log N)$ 时间内按优先级采样和更新，通常使用**求和树**（sum tree）数据结构。

### 重要性采样权重修正

非均匀采样改变了数据分布，会给梯度估计带来偏差。用重要性采样权重修正：

$$
w_i = \left(\frac{1}{N} \cdot \frac{1}{P(i)}\right)^\beta \Big/ \max_j w_j
$$

$\beta$ 从初始值（如 0.4）线性退火到 1，在训练末期完全修正偏差。损失变为 $\frac{1}{B} \sum_i w_i \cdot \ell(\delta_i)$。

## 多步回报（n-step）

借鉴 [n 步 TD](https://dqn.cn/basics/td-learning)，用 n 步回报代替单步目标：

$$
y^{(n)} = \sum_{k=0}^{n-1} \gamma^k r_{t+k+1} + \gamma^n \max_{a'} Q(s_{t+n}, a'; \theta^-)
$$

奖励传播更快，尤其在奖励稀疏时有效。严格来说多步目标在异策略下有偏（中间动作来自旧的行为策略），但 $n$ 较小（Rainbow 用 $n = 3$）时实践效果很好。

## 分布式 RL（C51）

Bellemare 等人（2017）提出不只学习回报的期望，而是学习回报的**完整分布** $Z(s, a)$，满足分布式贝尔曼方程 $Z(s, a) \overset{D}{=} R + \gamma Z(S', A')$。C51 用 51 个固定支撑点 $z_i \in [V_{\min}, V_{\max}]$ 上的类别分布表示 $Z$，目标分布投影回支撑点后用交叉熵训练。学习分布提供了更丰富的训练信号，在 Atari 上带来了显著提升。

## Noisy Nets

Fortunato 等人（2018）用带可学习噪声参数的线性层代替 ε-贪心：

$$
y = (\mu^w + \sigma^w \odot \varepsilon^w)\, x + (\mu^b + \sigma^b \odot \varepsilon^b)
$$

其中 $\varepsilon$ 是随机噪声，$\mu, \sigma$ 是可学习参数。网络可以自己学会在哪些状态多探索、哪些状态少探索，在需要深度探索的游戏中效果明显。

## Rainbow：组合六项改进

Rainbow 把 **Double Q-learning、优先回放、Dueling 网络、多步回报、分布式 RL、Noisy Nets** 组合在一起。论文的主要发现：

- 组合后的智能体在数据效率和最终性能上都显著超过任何单项改进；
- 消融实验中，去掉**优先回放**和**多步回报**对性能的损害最大，去掉分布式 RL 次之；
- 在他们的设置下，去掉 Double 或 Dueling 的影响相对较小（但这与其他组件的组合有关）。

```mermaid
flowchart LR
  DQN --> Double[Double DQN]
  DQN --> Dueling[Dueling]
  DQN --> PER[优先回放]
  DQN --> NStep[n-step]
  DQN --> C51[分布式 C51]
  DQN --> Noisy[Noisy Nets]
  Double & Dueling & PER & NStep & C51 & Noisy --> Rainbow
```

## 代码实现

下面在 [DQN 基础实现](https://dqn.cn/advanced/dqn) 之上加入 Double、Dueling 和 PER 三项改进，用命令行参数开关（n-step、C51、Noisy Nets 作为练习）。

```python
"""DQN 改进：Double DQN / Dueling DQN / 优先经验回放（PER），CartPole-v1。
用法：python dqn_variants.py --double --dueling --per --seed 0
"""
import argparse
import random

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

parser = argparse.ArgumentParser()
parser.add_argument("--double", action="store_true")
parser.add_argument("--dueling", action="store_true")
parser.add_argument("--per", action="store_true")
parser.add_argument("--seed", type=int, default=0)
parser.add_argument("--steps", type=int, default=50_000)
args = parser.parse_args()

GAMMA, LR, BATCH, BUFFER, START, TAU = 0.99, 5e-4, 64, 50_000, 1_000, 0.005
EPS_START, EPS_END, EPS_DECAY = 1.0, 0.05, 10_000
PER_ALPHA, PER_BETA0, PER_EPS = 0.6, 0.4, 1e-6

random.seed(args.seed); np.random.seed(args.seed); torch.manual_seed(args.seed)
env = gym.make("CartPole-v1")
obs_dim, n_actions = env.observation_space.shape[0], env.action_space.n

class QNet(nn.Module):
    def __init__(self, dueling):
        super().__init__()
        self.dueling = dueling
        self.body = nn.Sequential(nn.Linear(obs_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU())
        if dueling:
            self.value = nn.Linear(128, 1)              # V(s)
            self.adv = nn.Linear(128, n_actions)        # A(s, a)
        else:
            self.head = nn.Linear(128, n_actions)

    def forward(self, x):
        h = self.body(x)
        if not self.dueling:
            return self.head(h)
        a = self.adv(h)
        return self.value(h) + a - a.mean(dim=1, keepdim=True)   # Q = V + A - mean(A)

class SumTree:
    """优先经验回放用的求和树：O(log N) 按优先级采样与更新。"""

    def __init__(self, capacity):
        self.capacity = capacity
        self.tree = np.zeros(2 * capacity)
        self.data = [None] * capacity
        self.ptr, self.size = 0, 0

    def add(self, priority, item):
        self.data[self.ptr] = item
        self.update(self.ptr, priority)
        self.ptr = (self.ptr + 1) % self.capacity
        self.size = min(self.size + 1, self.capacity)

    def update(self, idx, priority):
        i = idx + self.capacity
        self.tree[i] = priority
        i //= 2
        while i >= 1:
            self.tree[i] = self.tree[2 * i] + self.tree[2 * i + 1]
            i //= 2

    def find(self, value):
        i = 1
        while i < self.capacity:
            left = 2 * i
            if value <= self.tree[left]:
                i = left
            else:
                value -= self.tree[left]
                i = left + 1
        return i - self.capacity

    def total(self):
        return self.tree[1]

q_net, target_net = QNet(args.dueling), QNet(args.dueling)
target_net.load_state_dict(q_net.state_dict())
opt = torch.optim.Adam(q_net.parameters(), lr=LR)
tree = SumTree(BUFFER)
max_priority = 1.0

obs, _ = env.reset(seed=args.seed)
ep_ret, returns = 0.0, []
for step in range(args.steps):
    eps = EPS_START + min(step / EPS_DECAY, 1.0) * (EPS_END - EPS_START)
    if random.random() < eps:
        action = env.action_space.sample()
    else:
        with torch.no_grad():
            action = int(q_net(torch.as_tensor(obs, dtype=torch.float32)[None]).argmax())
    next_obs, r, term, trunc, _ = env.step(action)
    # 新样本用当前最大优先级加入，保证至少被采样一次
    tree.add(max_priority if args.per else 1.0, (obs, action, r, next_obs, float(term)))
    obs, ep_ret = next_obs, ep_ret + r
    if term or trunc:
        returns.append(ep_ret)
        obs, _ = env.reset()
        ep_ret = 0.0

    if step >= START:
        # ---- 采样：均匀 或 按优先级 ----
        if args.per:
            seg = tree.total() / BATCH
            idxs = [tree.find(random.uniform(seg * i, seg * (i + 1))) for i in range(BATCH)]
            probs = np.array([tree.tree[i + BUFFER] for i in idxs]) / tree.total()
            beta = PER_BETA0 + (1.0 - PER_BETA0) * step / args.steps   # β 线性退火到 1
            w = (tree.size * probs) ** (-beta)
            w = torch.as_tensor(w / w.max(), dtype=torch.float32)       # 重要性采样权重
        else:
            idxs = np.random.randint(0, tree.size, size=BATCH)
            w = torch.ones(BATCH)
        s, a, r_, s2, d = map(np.array, zip(*[tree.data[i] for i in idxs]))
        s, s2 = torch.as_tensor(s, dtype=torch.float32), torch.as_tensor(s2, dtype=torch.float32)
        a = torch.as_tensor(a).long().unsqueeze(1)
        r_, d = torch.as_tensor(r_, dtype=torch.float32), torch.as_tensor(d, dtype=torch.float32)

        q_sa = q_net(s).gather(1, a).squeeze(1)
        with torch.no_grad():
            if args.double:   # 在线网络选动作，目标网络评估
                a2 = q_net(s2).argmax(dim=1, keepdim=True)
                q_next = target_net(s2).gather(1, a2).squeeze(1)
            else:
                q_next = target_net(s2).max(dim=1).values
            target = r_ + GAMMA * (1 - d) * q_next
        td = target - q_sa
        loss = (w * F.smooth_l1_loss(q_sa, target, reduction="none")).mean()
        opt.zero_grad(); loss.backward()
        nn.utils.clip_grad_norm_(q_net.parameters(), 10.0)
        opt.step()

        if args.per:
            new_p = (td.abs().detach().numpy() + PER_EPS) ** PER_ALPHA
            for i, p in zip(idxs, new_p):
                tree.update(i, p)
            max_priority = max(max_priority, new_p.max())

        with torch.no_grad():
            for p, tp in zip(q_net.parameters(), target_net.parameters()):
                tp.mul_(1 - TAU).add_(TAU * p)

def evaluate(n=10):
    e = gym.make("CartPole-v1"); total = 0.0
    for i in range(n):
        o, _ = e.reset(seed=10_000 + i); done = False
        while not done:
            with torch.no_grad():
                o, r, te, tr, _ = e.step(int(q_net(torch.as_tensor(o, dtype=torch.float32)[None]).argmax()))
            total += r; done = te or tr
    return total / n

name = "+".join([k for k in ["double", "dueling", "per"] if getattr(args, k)]) or "vanilla"
print(f"{name:20s} seed={args.seed} 最后10回合训练回报 {np.mean(returns[-10:]):6.1f} | 贪心评估(10回合) {evaluate():6.1f}")
```
### 实验结果（CartPole-v1，5 万步，2 个随机种子）

我们在本站环境中实际运行了 5 种配置 × 2 个种子（单线程 CPU，每次约 1–3 分钟）。“训练回报”是训练结束前最后 10 个回合（ε=0.05）的平均回报，“贪心评估”是训练结束后用 $\arg\max$ 策略在 10 个新回合上的平均回报：

| 配置 | 种子 0：训练 / 贪心评估 | 种子 1：训练 / 贪心评估 |
| --- | --- | --- |
| DQN（基线） | 363.3 / 500.0 | 500.0 / 500.0 |
| + Double | 252.3 / 500.0 | 420.5 / 500.0 |
| + Dueling | 238.7 / 257.1 | 257.3 / 500.0 |
| + PER | 137.4 / 500.0 | 473.8 / 282.9 |
| Double + Dueling + PER | 453.1 / 500.0 | 117.8 / 154.4 |

**如实解读**：在 CartPole 这种简单任务、仅 2 个种子、5 万步的预算下，各种改进与基线之间**没有表现出稳定的差异**，种子之间的波动比配置之间的差异还大，组合版本在种子 1 上反而最差。这并不说明这些改进无效——它们是针对 Atari 这类高维、奖励稀疏、训练上千万帧的任务设计的，论文中的结论来自 57 款游戏、多个种子的大规模实验。在小任务上评估算法改进，需要更多的种子（通常 ≥ 5–10 个）和更长的训练，并报告置信区间。这本身就是一个很好的教训。

## 实践技巧

- **优先尝试 Double DQN**：几乎零成本，几乎总是有益或无害。
- **PER 的超参数**：论文中比例式 PER 使用 $\alpha = 0.6$、$\beta_0 = 0.4$；使用 PER 时学习率通常要调小（论文中缩小为原来的 1/4）。
- **n-step 的选择**：$n = 3$ 是常用值；$n$ 太大时偏差增加。
- **现成实现**：[CleanRL](https://docs.cleanrl.dev/) 提供了 DQN、C51、Rainbow 等的单文件实现；[Stable-Baselines3](https://stable-baselines3.readthedocs.io/) 提供 DQN，其扩展库 SB3-Contrib 提供 QR-DQN。

## 常见误区

- **Double DQN 的 argmax 用了目标网络**：那就退化回了原始 DQN。必须用**在线网络**选动作。
- **PER 忘记重要性采样权重**：会导致有偏的梯度，训练后期可能不收敛。
- **PER 中更新优先级时用了带权重的损失**：优先级应基于原始 $|\delta|$。
- **Dueling 直接 $V + A$ 不减均值**：可辨识性问题会让训练不稳定。
- **用一两个种子下结论**：如上面的实验所示，强化学习的方差很大。

## 小结

| 改进 | 解决的问题 | 核心改动 |
| --- | --- | --- |
| Double DQN | Q 值高估 | 在线网络选动作、目标网络评估 |
| Dueling DQN | 学习效率 | $Q = V + (A - \text{mean}\,A)$ |
| PER | 样本利用效率 | 按 $|\delta|^\alpha$ 采样 + IS 权重 |
| n-step | 奖励传播慢 | n 步回报作为目标 |
| C51 | 只学期望信息不足 | 学习回报分布 |
| Noisy Nets | ε-贪心探索低效 | 可学习的参数噪声 |

## 练习题

1. 在代码中记录训练过程中 $\max_a Q(s_0, a)$ 的平均值，比较 DQN 与 Double DQN 的高估程度（CartPole 中每步奖励为 1，折扣回报上限为 $1/(1-\gamma) = 100$）。
2. 为代码加入 n-step 回报（$n = 3$），注意回合结束时的截断处理。
3. 把每种配置的种子数增加到 5 个，计算均值和标准差，结论是否改变？
4. 实现 Noisy Linear 层，替换 ε-贪心。
5. 证明对于独立同分布的零均值噪声，$\mathbb{E}[\max_i (q + \epsilon_i)] \ge q$。

## 参考文献

- van Hasselt, H. (2010). Double Q-learning. *NeurIPS*.
- van Hasselt, H., Guez, A., & Silver, D. (2016). Deep Reinforcement Learning with Double Q-learning. *AAAI*. [arXiv:1509.06461](https://arxiv.org/abs/1509.06461)
- Wang, Z. et al. (2016). Dueling Network Architectures for Deep Reinforcement Learning. *ICML*. [arXiv:1511.06581](https://arxiv.org/abs/1511.06581)
- Schaul, T., Quan, J., Antonoglou, I., & Silver, D. (2016). Prioritized Experience Replay. *ICLR*. [arXiv:1511.05952](https://arxiv.org/abs/1511.05952)
- Bellemare, M. G., Dabney, W., & Munos, R. (2017). A Distributional Perspective on Reinforcement Learning. *ICML*. [arXiv:1707.06887](https://arxiv.org/abs/1707.06887)
- Fortunato, M. et al. (2018). Noisy Networks for Exploration. *ICLR*. [arXiv:1706.10295](https://arxiv.org/abs/1706.10295)
- Hessel, M. et al. (2018). Rainbow: Combining Improvements in Deep Reinforcement Learning. *AAAI*. [arXiv:1710.02298](https://arxiv.org/abs/1710.02298)

## 下一步

上一节：[DQN](https://dqn.cn/advanced/dqn)　｜　继续阅读：[策略梯度：REINFORCE](https://dqn.cn/advanced/reinforce)——换一种思路，直接优化策略。

## 常见问题速查（FAQ）

**问：Double DQN 为什么能减少过估计？**

答：标准 DQN 用同一组估计值既选动作又评估动作，噪声会使 max 系统性偏高；Double DQN 把选择（在线网络）和评估（目标网络）解耦，降低了这种偏差。

**问：这些改进在小环境上一定有效吗？**

答：不一定。本站在 CartPole 上对 5 种配置各做了 2 个种子的实验，没有观察到稳定的提升，随机种子带来的差异往往大于改进本身。这些改进的优势主要在 Atari 等更难的基准上体现。

**问：Rainbow 中哪些改进最重要？**

答：Rainbow 论文的消融实验显示，去掉优先经验回放或多步回报对性能影响最大，分布式 RL 也很重要。

## 如何引用本文

DQN.CN 编辑部. DQN 改进：Double / Dueling / PER / Rainbow[EB/OL]. DQN.CN, 2026-10-29（更新于 2026-10-30）. https://dqn.cn/advanced/dqn-improvements.
