主题
DQN:深度 Q 网络
DQN(Deep Q-Network)由 DeepMind 提出,最早发表于 2013 年的 NeurIPS Deep Learning Workshop,完整版本于 2015 年发表在 Nature(Mnih et al., Human-level control through deep reinforcement learning)。它用卷积神经网络近似动作价值函数
从 Q-learning 到 DQN
表格型 Q-learning 的更新规则是:
当状态空间很大(例如 Atari 画面有
但直接把 Q-learning 和神经网络结合往往不稳定甚至发散,原因主要有两个:
- 样本相关性强:连续采集的样本高度相关,违背了随机梯度下降对独立同分布数据的假设;
- 目标不断移动:TD 目标
本身依赖正在更新的参数 ,相当于追逐一个移动的靶子。
DQN 分别用经验回放和目标网络解决这两个问题。
关键技巧一:经验回放(Experience Replay)
把智能体与环境交互得到的转移
好处:
- 打破样本之间的时间相关性;
- 每条经验可以被多次使用,提高样本效率;
- 平滑数据分布的变化。
关键技巧二:目标网络(Target Network)
维护一个与在线网络结构相同、参数为
目标网络的参数每隔
损失函数
DQN 最小化 TD 误差的均方(原论文对误差项做了裁剪,等价于使用 Huber 损失):
探索策略:ε-贪心
以概率
算法流程
- 初始化回放缓冲区
、在线网络 ,令目标网络 ; - 对每一步:
- 用 ε-贪心策略选择动作
,执行后得到 ; - 把
存入 ; - 从
随机采样小批量,计算目标 ; - 对
做一步梯度下降; - 每
步同步目标网络 。
- 用 ε-贪心策略选择动作
PyTorch 实现:CartPole
下面是一个可以直接运行的最小 DQN 实现,使用 Gymnasium 的 CartPole-v1 环境(需要 pip install torch gymnasium)。
py
"""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}")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
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
在本站环境中(单线程 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.01
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
训练的不稳定性:一次真实的调参记录
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 以上。我们还试过把学习率降到
代码要点
gather(1, a)取出实际执行动作对应的 Q 值;- 目标值计算放在
torch.no_grad()中,梯度不会流入目标网络; - 使用
terminated而不是terminated or truncated作为终止标志,这是一个常见的坑; - Huber 损失(
smooth_l1_loss)和梯度裁剪能提高训练稳定性; - 这里目标网络采用软更新(
),也可以改成原论文的每 步硬同步。
常见问题
Q:DQN 能处理连续动作吗? 不能直接处理,因为需要对所有动作取
Q:为什么 Q 值会被高估? 因为
Atari 版本:预处理与网络结构
原论文在 Atari 上使用的关键设置:
- 预处理:RGB 画面转灰度并缩放到 84×84;每个动作重复 4 帧(frame skip),取最后两帧的逐像素最大值以消除闪烁;
- 帧堆叠:把最近 4 帧堆叠成
的输入,以恢复速度等运动信息(近似满足马尔可夫性); - 奖励裁剪:把奖励裁剪到
,使同一套超参数适用于所有游戏; - 网络: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。
py
"""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 步完成")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
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
实际运行输出(ale-py 0.12.1):
text
观测形状: (4, 84, 84) | 动作数: 4
网络参数量: 1686180
Q 值输出形状: (1, 4) | 随机交互 200 步完成1
2
3
2
3
注意 scale_obs=False:回放缓冲区中以 uint8 存储观测(100 万帧 × 84 × 84 字节约 7 GB,若存 float32 则是 4 倍),送入网络前再除以 255。
练习题
- 把软更新改回原论文的硬更新(每
步复制一次参数),比较训练稳定性。 - 把 Huber 损失换成均方误差、去掉梯度裁剪,观察训练曲线的变化。
- 去掉经验回放(每步只用最新的一条样本更新),DQN 还能学会 CartPole 吗?
- 加入“定期评估、保存最佳模型”的逻辑,统计 5 个种子下最佳模型的评估回报。
- 把
terminated改成terminated or truncated作为终止标志,分析它对价值估计的影响。
参考文献
- Mnih, V. et al. (2015). Human-level control through deep reinforcement learning. Nature, 518, 529–533. doi:10.1038/nature14236
- Mnih, V. et al. (2013). Playing Atari with Deep Reinforcement Learning. arXiv: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 上手。DQN = Q-learning + 深度神经网络 + 经验回放 + 目标网络。下一节 DQN 改进 将介绍 Double DQN、Dueling DQN、优先经验回放和集大成的 Rainbow。