主题
DQN 改进:Double / Dueling / PER / Rainbow
原始 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 款游戏上取得了当时最好的成绩,并通过消融实验分析了每一项的贡献。
本节记号:在线网络
Double DQN
Q 值高估问题
DQN 的目标值
即对噪声取最大值会系统性地高估(最大化偏差)。高估本身如果是均匀的并不可怕,但它往往不均匀,会让智能体偏好那些“被高估”的动作,并通过自举在状态之间传播。van Hasselt 等人在 Atari 上观察到 DQN 的价值估计远高于实际回报。
目标值的改写
Double Q-learning(van Hasselt, 2010)的思想是用两个独立的估计器分别负责选择和评估。Double DQN 直接复用 DQN 已有的两个网络:在线网络选动作,目标网络评估:
改动只有一行代码,却能显著减少高估。
Dueling DQN
价值与优势分解
很多状态下,选哪个动作并不重要(例如赛车游戏中前方没有障碍时)。Dueling 架构把网络的输出拆成两路:
- 状态价值流
:这个状态本身有多好; - 优势流
:在这个状态下各动作相对好多少, 。
这样即使某个动作没有被执行,
可辨识性问题
直接令
(另一种形式是减去
优先经验回放(PER)
按 TD 误差采样
均匀回放把所有经验一视同仁,但 TD 误差大的样本说明“预测错得多”,学习价值更高。PER 给每条经验
重要性采样权重修正
非均匀采样改变了数据分布,会给梯度估计带来偏差。用重要性采样权重修正:
多步回报(n-step)
借鉴 n 步 TD,用 n 步回报代替单步目标:
奖励传播更快,尤其在奖励稀疏时有效。严格来说多步目标在异策略下有偏(中间动作来自旧的行为策略),但
分布式 RL(C51)
Bellemare 等人(2017)提出不只学习回报的期望,而是学习回报的完整分布
Noisy Nets
Fortunato 等人(2018)用带可学习噪声参数的线性层代替 ε-贪心:
其中
Rainbow:组合六项改进
Rainbow 把 Double Q-learning、优先回放、Dueling 网络、多步回报、分布式 RL、Noisy Nets 组合在一起。论文的主要发现:
- 组合后的智能体在数据效率和最终性能上都显著超过任何单项改进;
- 消融实验中,去掉优先回放和多步回报对性能的损害最大,去掉分布式 RL 次之;
- 在他们的设置下,去掉 Double 或 Dueling 的影响相对较小(但这与其他组件的组合有关)。
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 基础实现 之上加入 Double、Dueling 和 PER 三项改进,用命令行参数开关(n-step、C51、Noisy Nets 作为练习)。
py
"""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}")1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
实验结果(CartPole-v1,5 万步,2 个随机种子)
我们在本站环境中实际运行了 5 种配置 × 2 个种子(单线程 CPU,每次约 1–3 分钟)。“训练回报”是训练结束前最后 10 个回合(ε=0.05)的平均回报,“贪心评估”是训练结束后用
| 配置 | 种子 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 使用
、 ;使用 PER 时学习率通常要调小(论文中缩小为原来的 1/4)。 - n-step 的选择:
是常用值; 太大时偏差增加。 - 现成实现:CleanRL 提供了 DQN、C51、Rainbow 等的单文件实现;Stable-Baselines3 提供 DQN,其扩展库 SB3-Contrib 提供 QR-DQN。
常见误区
- Double DQN 的 argmax 用了目标网络:那就退化回了原始 DQN。必须用在线网络选动作。
- PER 忘记重要性采样权重:会导致有偏的梯度,训练后期可能不收敛。
- PER 中更新优先级时用了带权重的损失:优先级应基于原始
。 - Dueling 直接
不减均值:可辨识性问题会让训练不稳定。 - 用一两个种子下结论:如上面的实验所示,强化学习的方差很大。
小结
| 改进 | 解决的问题 | 核心改动 |
|---|---|---|
| Double DQN | Q 值高估 | 在线网络选动作、目标网络评估 |
| Dueling DQN | 学习效率 | |
| PER | 样本利用效率 | 按 $ |
| n-step | 奖励传播慢 | n 步回报作为目标 |
| C51 | 只学期望信息不足 | 学习回报分布 |
| Noisy Nets | ε-贪心探索低效 | 可学习的参数噪声 |
练习题
- 在代码中记录训练过程中
的平均值,比较 DQN 与 Double DQN 的高估程度(CartPole 中每步奖励为 1,折扣回报上限为 )。 - 为代码加入 n-step 回报(
),注意回合结束时的截断处理。 - 把每种配置的种子数增加到 5 个,计算均值和标准差,结论是否改变?
- 实现 Noisy Linear 层,替换 ε-贪心。
- 证明对于独立同分布的零均值噪声,
。
参考文献
- 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
- Wang, Z. et al. (2016). Dueling Network Architectures for Deep Reinforcement Learning. ICML. arXiv:1511.06581
- Schaul, T., Quan, J., Antonoglou, I., & Silver, D. (2016). Prioritized Experience Replay. ICLR. arXiv:1511.05952
- Bellemare, M. G., Dabney, W., & Munos, R. (2017). A Distributional Perspective on Reinforcement Learning. ICML. arXiv:1707.06887
- Fortunato, M. et al. (2018). Noisy Networks for Exploration. ICLR. arXiv:1706.10295
- Hessel, M. et al. (2018). Rainbow: Combining Improvements in Deep Reinforcement Learning. AAAI. arXiv:1710.02298
下一步
上一节:DQN | 继续阅读:策略梯度:REINFORCE——换一种思路,直接优化策略。