简介:面向深度学习与强化学习入门者,这是一套基于Python与PyTorch在gym Atari环境中实现DQN及其变体(如DDQN)的课程设计源码包。压缩包共255个文件,大小仅5.05MB,含123个Python源码、45个txt配置与日志及多个md/pdf说明文档,并附带Dockerfile、训练记录GIF和events.out.tfevents事件文件,便于复现完整实验流程。作者在轻薄本上训练时遇到内存不足、程序中途退出的问题,为此多次修改代码和超参数,这些调试经验与调参思路也沉淀在源码注释与笔记中,可帮助读者避开类似坑点,更高效地验证DQN系列算法的收敛效果。已有204人学习下载,适合需要完成强化学习课程设计或初次接触深度强化学习的开发者参考。
1. 在 gym Atari 里跑 DQN:为什么我劝你先别急着上变体
手里有一份环境、一张神经网络、一个经验回放池,就能复现 DQN 吗?能,但跑出来的曲线和论文里的漂亮折线基本是两回事。我这几年在 gym Atari 上折腾 DQN 及其变体,最大的感触是:算法本身只占三成,剩下的全在环境封装、帧预处理、超参数和训练稳定性上。尤其当你从 CartPole 这种简单任务跳到 Pong、Breakout、Space Invaders,原来那套调两下就能收敛的经验瞬间失效。这篇笔记就从一个可复现的基准开始,把 DQN、Double DQN、Dueling DQN 的落地路径、参数边界和踩坑记录完整拆开,适合刚跑通 PyTorch 基础、想在 Atari 上做出真曲线的读者。
2. 先立框架:DQN 在 Atari 上的四个关键设计点
2.1 为什么 Atari 环境需要帧堆叠与灰度预处理
gym 的 Atari 环境通过gym.make("PongNoFrameskip-v4")这类 id 暴露,但原始观测是 (210, 160, 3) 的 RGB 图像,直接用卷积网络吃这个输入,显存和训练时间都不可接受。更重要的是,Atari 游戏里很多信息是时间性的,比如球的运动方向、敌人子弹的速度,单帧图像根本看不出来。所以 DQN 的经典做法是把连续 4 帧灰度图堆叠成一个 (84, 84, 4) 的张量,让网络自己去学运动特征。
常见做法是用gym.Wrapper写一个预处理链,顺序是:灰度化 → 缩放到 84x84 → 帧堆叠。灰度化用亮度公式Y = 0.299R + 0.587G + 0.114B,或者直接调cv2.cvtColor和cv2.resize。缩放不要用最近邻插值,Atari 的画面有大量斜线和细小物体,最近邻会让边缘出现明显锯齿,我一般用cv2.INTER_AREA,它对下采样更平滑,训练出来的策略对小物体更敏感。
import gym import cv2 import numpy as np from collections import deque class AtariPreprocessor(gym.ObservationWrapper): def __init__(self, env, height=84, width=84, n_frames=4): super().__init__(env) self.height = height self.width = width self.n_frames = n_frames self.frames = deque(maxlen=n_frames) self.observation_space = gym.spaces.Box( low=0, high=255, shape=(n_frames, height, width), dtype=np.uint8 ) def _to_gray_resize(self, obs): gray = cv2.cvtColor(obs, cv2.COLOR_RGB2GRAY) resized = cv2.resize(gray, (self.width, self.height), interpolation=cv2.INTER_AREA) return resized def reset(self, **kwargs): obs = self.env.reset(**kwargs) proc = self._to_gray_resize(obs) for _ in range(self.n_frames): self.frames.append(proc) return np.stack(self.frames, axis=0) def step(self, action): obs, reward, done, info = self.env.step(action) proc = self._to_gray_resize(obs) self.frames.append(proc) return np.stack(self.frames, axis=0), reward, done, info这段代码的关键在于deque(maxlen=4)和np.stack的组合:每次 step 后弹掉最旧帧、加入最新帧,保证网络看到的永远是最近 4 帧。注意observation_space的 shape 要写对,否则后续传给网络时会因为维度不一致报错。灰度化和缩放必须在 reset 里先做一轮,否则初始观测是空的。
2.2 经验回放池与目标网络:DQN 稳定性的两根拐杖
DQN 相比传统 Q-Learning 最大的改动不是用了神经网络,而是引入了经验回放和目标网络。经验回放池是个固定容量的环形缓冲区,把 (state, action, reward, next_state, done) 存进去,训练时随机采样一个小批量。这样做的意义是打破样本之间的时间相关性——强化学习的轨迹里相邻帧高度相关,直接按顺序学习会让网络反复在同一个局部区域打转。
目标网络则是另一根拐杖。如果只用同一个 Q 网络计算目标值和当前值,那么每次梯度更新都在移动靶子,损失函数本身也在变,训练很容易震荡甚至发散。DQN 的做法是维护一份参数冻结的旧网络,每隔 C 步把在线网络的参数同步过去。C 在 Atari 上一般取 10000,在 CartPole 这种小任务上取 100 就够。
class ReplayBuffer: def __init__(self, capacity=1000000): self.capacity = capacity self.buffer = deque(maxlen=capacity) def push(self, state, action, reward, next_state, done): self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) state, action, reward, next_state, done = map(np.stack, zip(*batch)) return state, action, reward, next_state, done def __len__(self): return len(self.buffer)参数说明:容量取 100 万是 Atari 的标配,因为 1 个 episode 动不动就是几千步,容量太小会过早覆盖早期经验,导致灾难性遗忘。采样用random.sample而不是顺序拿,保证每个 batch 里的样本来自不同时间段的经验。done标志必须单独存,不能只靠 next_state 判断,否则终止状态会被当成普通状态继续引导 Q 值。
2.3 奖励裁剪与 episode 终止处理
Atari 的原始奖励范围很宽,Pong 里赢一球得 1 分,但有些游戏单步奖励能到十几甚至几十。直接把原始奖励喂给网络,梯度尺度会被极端奖励主导,训练非常不稳。主流做法是把奖励 clip 到 [-1, 1],让网络只学“好坏方向”而忽略幅度。这个技巧看似粗暴,但在 DQN 家族里几乎没被替换过。
还有一个容易踩坑的地方是done。gym 的 done 既表示游戏结束,也表示生命耗尽(life lost)。在 Breakout 这类游戏里,丢一条命并不等于 episode 结束,如果直接把 done 置 True,网络会误以为整局结束,导致后续的 Q 值传播被截断。常见做法是用env.unwrapped.ale.lives()获取当前生命数,当生命数减少时把 done 标记为 False 但把 reward 设为 -1,让网络学会“避免丢命”而不是“整局重来”。
class EpisodicLifeEnv(gym.Wrapper): def __init__(self, env): super().__init__(env) self.lives = 0 def step(self, action): obs, reward, done, info = self.env.step(action) lives = self.env.unwrapped.ale.lives() if lives < self.lives: done = False reward = -1.0 self.lives = lives return obs, reward, done, info def reset(self, **kwargs): obs = self.env.reset(**kwargs) self.lives = self.env.unwrapped.ale.lives() return obs这个包装器的逻辑不复杂,但很多复现项目里没写,结果就是 Breakout 的曲线怎么都拉不起来。注意ale.lives()只在 Atari 环境的 unwrapped 对象上可用,换成其他环境会直接报错。如果你只想快速验证算法,可以跳过这个包装器,但正式实验建议加上。
2.4 DQN 网络结构:从 84x84 输入到动作价值输出
Atari 上的 DQN 网络结构基本是固定的:三层卷积加两层全连接。第一层卷积 32 个 8x8 卷积核,步长 4;第二层 64 个 4x4 卷积核,步长 2;第三层 64 个 3x3 卷积核,步长 1。卷积层之后接一个 512 维的全连接层,最后输出动作空间的维度。这个结构从 2015 年的 Nature DQN 论文沿用至今,几乎不需要改动。
需要留意的是输入维度。我们的预处理把 4 帧堆叠成 (4, 84, 84),PyTorch 的 Conv2d 期望输入是 (batch, channel, height, width),所以网络第一层要写成Conv2d(4, 32, kernel_size=8, stride=4)。如果你用(batch, height, width, channel)这种 TF 风格的布局,PyTorch 会直接报维度错误,这是迁移代码时最常翻车的地方。
import torch import torch.nn as nn class NatureDQN(nn.Module): def __init__(self, n_actions): super().__init__() self.conv = 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(), ) self.fc = nn.Sequential( nn.Linear(64 * 7 * 7, 512), nn.ReLU(), nn.Linear(512, n_actions), ) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) return self.fc(x)这里的64 * 7 * 7是卷积输出特征图的空间尺寸,84x84 经过卷积和池化后正好是 7x7。如果你改了输入尺寸或卷积参数,这个数字要重新算,最稳妥的方法是在网络初始化时打印一层x.shape来确认。网络权重初始化用默认的 Kaiming 就可以,不需要额外花哨的初始化方案。
3. 从 DQN 到 Double DQN 与 Dueling DQN:改哪里、为什么改
3.1 原始 DQN 的过估计问题
原始 DQN 用max_a Q(s', a; θ⁻)计算目标值,这个 max 操作会系统性地高估动作价值。原因在于 Q 函数的近似误差:如果某个动作的真实 Q 值被高估,max 会选择它,而目标值的计算又会进一步放大这个高估。在 Atari 这种动作空间小但噪声大的环境下,过估计会导致策略过于激进,明明应该保守等待时却主动出击。
过估计不是理论上的空谈,我实测过 Pong 场景,原始 DQN 在训练中期会出现 Q 值一路飙升但 episode 奖励不再上升的诡异曲线。这个现象就是过估计的典型症状。解决思路分两条路:一是 Double DQN,二是 Dueling DQN,两者可以叠加使用。
3.2 Double DQN 的解耦思路:用在线网络选动作、目标网络算价值
Double DQN 的核心改动只有一个公式。原始 DQN 的目标是r + γ * max_a Q(s', a; θ⁻),Double DQN 改成先让在线网络选动作:a* = argmax_a Q(s', a; θ),然后用目标网络去算这个动作的价值:r + γ * Q(s', a*; θ⁻)。这样选动作和估值动作由两个网络解耦,高估幅度被显著压低。
实现上只需要在计算 target 的代码里改几行。注意这里有个细节:在线网络选择动作时不需要做梯度计算,用torch.no_grad()包住即可;但目标网络的输出本身就被 no_grad 包着,所以整个 target 计算都是在推理模式下完成的。
def compute_target_dqn(rewards, next_states, done_mask, online_net, target_net, gamma=0.99): with torch.no_grad(): # 原始 DQN: max over target network next_q_target = target_net(next_states).max(dim=1, keepdim=True).values target = rewards + gamma * next_q_target * (1 - done_mask) # Double DQN: argmax from online, value from target next_actions = online_net(next_states).argmax(dim=1, keepdim=True) next_q_target_double = target_net(next_states).gather(1, next_actions) target_double = rewards + gamma * next_q_target_double * (1 - done_mask) return target, target_double参数说明:done_mask是形状为 (batch, 1) 的 0/1 张量,终止状态的目标值不包含未来奖励,直接用(1 - done_mask)乘掉即可。gather是 PyTorch 里按索引取值的标准操作,next_actions必须是 int64 类型。Double DQN 的收益在训练初期不明显,但到中期后能明显看到 Q 值的虚高幅度下降,策略也更稳定。
3.3 Dueling DQN 的结构改动:分离状态价值和动作优势
Dueling DQN 不修改目标值计算,而是修改网络结构。它把 Q 值拆成两部分:状态价值 V(s) 和动作优势 A(s, a)。直观理解是,有些状态下无论选哪个动作差别都不大,这时网络只需要学到一个准确的状态价值;而有些动作选择会带来巨大差异,这时优势分支负责捕捉差异。结构化拆分后,网络的表示能力更强,样本效率也更高。
Dueling 结构在实现上有个反直觉的细节:不能简单地把 V 和 A 相加作为 Q 值,因为这样会导致 V 和 A 的重构不唯一。常见做法是让 A 减去其均值:Q(s,a) = V(s) + A(s,a) - mean_a A(s,a)。这个约束保证优势分支的平均值为 0,状态价值分支能稳定地捕捉全局价值。
class DuelingDQN(nn.Module): def __init__(self, n_actions): super().__init__() self.conv = 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(), ) self.fc_v = nn.Linear(64 * 7 * 7, 512) self.fc_a = nn.Linear(64 * 7 * 7, 512) self.v_out = nn.Linear(512, 1) self.a_out = nn.Linear(512, n_actions) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) v = torch.relu(self.fc_v(x)) a = torch.relu(self.fc_a(x)) v = self.v_out(v) a = self.a_out(a) # 优势分支减去均值,保证可辨识性 q = v + (a - a.mean(dim=1, keepdim=True)) return q这里我把全连接层拆成了两条通路,卷积层共享。实际训练中 Dueling 分支的两条通路如果初始权重一致,可能会在一开始梯度相似,但训练几轮后就会分化。如果你想让 Dueling 的效果更明显,可以给两条通路的激活函数设置不同的 dropout,但不建议在 Atari 上这么做,数据量足够大时网络自己会学会分化。
3.4 三合一的最小改动实现:把变体叠加到同一个训练循环里
实战中很少有人只跑纯 DQN,最常见的做法是把 Double 和 Dueling 叠加,得到 Dueling Double DQN(D3QN)。我把训练循环抽象成一个函数,用布尔开关控制是否启用 Double 和 Dueling,这样能在一套代码里快速做消融实验。训练循环的核心是:采样一个 batch → 计算 Q 值 → 计算 target → 计算 loss → 梯度更新 → 周期性同步目标网络。
def train_step(batch, online_net, target_net, optimizer, gamma=0.99, use_double=True): state, action, reward, next_state, done = batch state = torch.FloatTensor(state).to(device) action = torch.LongTensor(action).unsqueeze(1).to(device) reward = torch.FloatTensor(reward).unsqueeze(1).to(device) next_state = torch.FloatTensor(next_state).to(device) done = torch.FloatTensor(done).unsqueeze(1).to(device) q_values = online_net(state).gather(1, action) with torch.no_grad(): if use_double: next_actions = online_net(next_state).argmax(dim=1, keepdim=True) next_q = target_net(next_state).gather(1, next_actions) else: next_q = target_net(next_state).max(dim=1, keepdim=True).values target = reward + gamma * next_q * (1 - done) loss = nn.functional.smooth_l1_loss(q_values, target) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()注意这里用了smooth_l1_loss(Huber loss)而不是 MSE。原因在于 Atari 的 reward 虽然有 clip,但 early episode 里可能出现少量异常大的目标值,MSE 对离群点的惩罚是平方级的,会拖垮整个梯度;Huber loss 在误差小于 1 时是二次的、大于 1 时是线性的,天然的鲁棒性让训练更稳。这个选择在原始 DQN 论文里就是标配,不建议替换回 MSE。
4. 完整训练管线:从 gym.make 到 checkpoint 保存的最小可跑代码
4.1 环境封装的完整顺序与参数对照
gym 的 Atari 环境包装器顺序是有讲究的。很多人直接gym.make("PongNoFrameskip-v4")就开训,结果发现训练特别慢、曲线特别抖。正确的封装链是:NoFrameskip 环境 → MaxAndSkip 帧跳跃 → EpisodicLife → FireReset → 预处理(灰度、缩放、堆叠)→ RewardClip。其中 FireReset 是专为 Breakout、Space Invaders 这类需要按 FIRE 键才开始游戏的环境准备的,Pong 不需要但加上了也无妨。
def make_atari_env(env_id, seed=0, frame_skip=4, n_frames=4): env = gym.make(env_id) env = gym.wrappers.MaxAndSkipEnv(env, skip=frame_skip) env = EpisodicLifeEnv(env) if "FIRE" in env.unwrapped.get_action_meanings(): env = gym.wrappers.FireResetEnv(env) env = AtariPreprocessor(env, n_frames=n_frames) env = gym.wrappers.ClipRewardEnv(env) env.seed(seed) return env参数说明:frame_skip=4表示每 4 帧执行一次动作,这既节省计算量,也让环境每次返回的观测有足够的运动差异。MaxAndSkipEnv在跳帧时会取这几帧里的最大像素值,而不是只取最后一帧,避免某些环境渲染时闪屏导致观测失真。seed要同时设置到 gym 环境和 numpy,否则每次运行结果不一致,不利于对比实验。
4.2 训练循环:探索率衰减、梯度裁剪与目标网络更新频率
训练循环里最容易被忽略的是梯度裁剪。Atari 的 Q 网络参数大概 200 万到 300 万,梯度范数在训练初期经常超过 10,如果不裁剪 loss 曲线会乱跳。我用nn.utils.clip_grad_norm_把梯度范数限制在 10 以内,这不算激进,但能保证早期不炸。
探索率(epsilon)的衰减策略也值得说。常见的做法是线性衰减加固定下限:前 20 万步从 1.0 衰减到 0.1,之后固定在 0.1。如果你想让训练更快收敛,可以衰减到 0.01,但最终性能可能不如 0.1 稳定。这里没有绝对正确的选择,建议用 0.1 作为默认,除非你明确知道自己的场景需要更多探索。
def train(env, online_net, target_net, optimizer, args): replay = ReplayBuffer(args.buffer_size) obs = env.reset() episode_reward = 0 episode_count = 0 epsilon = args.epsilon_start best_mean_reward = -float("inf") for step in range(1, args.max_steps + 1): # epsilon 线性衰减 if step < args.explore_steps: epsilon = args.epsilon_start + (args.epsilon_end - args.epsilon_start) * (step / args.explore_steps) else: epsilon = args.epsilon_end # epsilon-greedy 选择动作 if random.random() < epsilon: action = env.action_space.sample() else: with torch.no_grad(): state_tensor = torch.FloatTensor(np.expand_dims(obs, 0)).to(device) action = online_net(state_tensor).argmax(dim=1).item() next_obs, reward, done, info = env.step(action) replay.push(obs, action, reward, next_obs, done) obs = next_obs episode_reward += reward if done: obs = env.reset() episode_count += 1 print(f"Episode {episode_count}, Step {step}, Reward {episode_reward:.0f}") episode_reward = 0 if len(replay) > args.batch_size: batch = replay.sample(args.batch_size) loss = train_step(batch, online_net, target_net, optimizer, use_double=args.use_double) # 梯度裁剪 nn.utils.clip_grad_norm_(online_net.parameters(), 10.0) # 目标网络同步 if step % args.target_net_freq == 0: target_net.load_state_dict(online_net.state_dict())要点是episode_reward的累计要在 done 时清空但不要在 life lost 时清空,我们的 EpisodicLifeEnv 已经把 life lost 的 done 改成了 False,所以这里不会混淆。目标网络同步用load_state_dict是硬拷贝,比state_dict的浅拷贝安全,避免后续修改在线网络时连带影响目标网络。
4.3 checkpoint 与评估:一个人跑实验时的后悔药
训练 DQN 动辄几十万步,每步推理一次,在单张 1080Ti 上 Pong 大概要跑 3 到 5 小时。如果中途崩溃或者超参不对,没有 checkpoint 就全白费了。我会把三个东西存下来:在线网络权重、目标网络权重、还有当前的探索率。探索率一定要存,否则恢复训练时 epsilon 会从初始值重新衰减,导致策略突变。
评估也不能只在训练结束做。每 1 万步评估 10 个 episode,取平均奖励作为当前策略的指标。评估时把 epsilon 设为 0,用纯贪心策略,这样能真实反映网络的决策能力。训练曲线和评估曲线要分开记录,因为训练时带探索,奖励天然偏低,混在一起会误导判断。
def save_checkpoint(path, online_net, target_net, optimizer, epsilon, step): torch.save({ "online": online_net.state_dict(), "target": target_net.state_dict(), "optimizer": optimizer.state_dict(), "epsilon": epsilon, "step": step, }, path) def load_checkpoint(path, online_net, target_net, optimizer): ckpt = torch.load(path, map_location=device) online_net.load_state_dict(ckpt["online"]) target_net.load_state_dict(ckpt["target"]) optimizer.load_state_dict(ckpt["optimizer"]) return ckpt["epsilon"], ckpt["step"]这里map_location=device很关键。如果在有 GPU 的机器上训练、在只有 CPU 的机器上恢复,不写 map_location 会直接报错。optimizer的 state 也要存,因为它内部有动量信息和学习率调度状态,不存的话恢复训练的前几步梯度方向会不准。
5. 必踩的坑:Atari 训练中最容易翻车的 5 个细节
5.1 环境版本混用:NoFrameskip 与 v0/v4 的差异
gym 的 Atari 环境 id 后缀有讲究。Pong-v0和Pong-v4内部已经带了帧跳跃和随机起始,而PongNoFrameskip-v4是原始环境。如果你拿着 NoFrameskip 的代码却用普通 v4 的 id 去 make,然后又套了一个 MaxAndSkipEnv,等于跳帧了两次,训练速度直接慢一倍。更隐蔽的问题是普通 v4 环境自带随机性,你没法精确控制 seed,导致复现实验时结果对不上。
我一般只用*NoFrameskip-v4这系列 id,然后把所有包装器显式写在代码里。这样每次实验的环境逻辑完全由自己控制,不会因为 gym 底层版本升级导致行为变化。另外注意gym.make之后要用env.unwrapped拿到 ale 对象,才能调用lives()和get_action_meanings()。
5.2 奖励 clip 的位置错了,曲线永远不对
ClipReward 必须在 EpisodicLife 之后应用。原因是 EpisodicLife 在生命数减少时会把奖励改成 -1,这是算法层面的信号,不应该被裁剪。如果把 ClipReward 放在 EpisodicLife 前面,reward 在进入 EpisodicLife 之前就被压到 [-1, 1] 了,生命数减少时的惩罚信号再改成 -1 就没有区分度了。这个顺序颠倒的后果是网络学不到“避免丢命”的语义,Breakout 的最终得分会明显偏低。
5.3 学习率过大导致 loss 直接 NaN
DQN 的 loss 偶尔出现 NaN 是正常的,但频繁出现就说明有问题。最常见原因是学习率过大。Nature DQN 论文里用的学习率是 0.0001,这个值在 PyTorch 里看起来很小,但在 Atari 上已经不算小。如果用了 Adam,我建议把学习率降到 0.00005,或者改用 RMSProp,原始论文用的是 RMSProp。Adam 的动量机制在 Q 值 target 也在变化时会产生明显过冲,这是很多复现项目里没注意到的。
5.4 帧堆叠里的数据类型不统一
预处理后的观测是np.uint8类型,存入回放池时也保持 uint8,能节省 4 倍内存。但送入网络前必须转成torch.FloatTensor,而且像素值要不要除以 255 得想清楚。原始 DQN 论文没有归一化,直接用 0-255 的整数输入;但很多现代复现会除以 255。两种做法都能收敛,但切换使用会导致训练中断后的 checkpoint 恢复时数据分布突变,曲线掉一截。建议选一种固定下来,推荐不归一化,保持与论文一致,减少不确定性。
5.5 评估时忘记关探索,奖励波动巨大
这是最让我头疼的坑。训练循环里为了探索用了 epsilon-greedy,评估时如果忘了把 epsilon 设为 0,每个 episode 里有 10% 的步在乱动,Pong 这种对动作精度敏感的游戏,评估奖励的方差会非常大,10 个 episode 的均值可能相差 200%。正确做法是在评估函数里接收一个epsilon=0参数,并且在评估前把网络切到 eval 模式。
def evaluate(env, net, n_episodes=10): net.eval() rewards = [] for _ in range(n_episodes): obs = env.reset() episode_reward = 0 done = False while not done: with torch.no_grad(): state_tensor = torch.FloatTensor(np.expand_dims(obs, 0)).to(device) action = net(state_tensor).argmax(dim=1).item() obs, reward, done, info = env.step(action) episode_reward += reward rewards.append(episode_reward) net.train() return np.mean(rewards), np.std(rewards)评估函数里同时返回均值和标准差很有用:如果标准差大于均值,说明策略还不稳定,甚至还没收敛,这时候看到的高均值奖励可能只是运气好。net.eval()和net.train()的切换在 DQN 里影响不大,因为没有 dropout 和 BN,但养成好习惯能避免以后切换到其他网络时踩坑。
6. 变体效果对比与深入思路:一个能复现的进阶验证方案
6.1 用同一套代码跑四组对比实验
我建议你用同一套训练管线跑四组:原始 DQN、Double DQN、Dueling DQN、Dueling Double DQN。每组只改动开关和网络定义,其他超参全部保持一致,这样最后画出来的对比曲线才有说服力。训练步数不用太多,Pong 上 50 万步就能看到明显差异。记录每组的平均评估奖励,画成平滑曲线,x 轴是训练步数,y 轴是 10 个评估 episode 的均值。
为了公平对比,每组实验的 seed 要固定成同一个值。这听起来理所当然,但实际很多人跑对比时只固定了 gym 的 seed,没有固定 numpy 和 torch 的 seed,导致初始权重和探索序列完全不同,曲线差异被噪声淹没。固定 seed 的方法是在训练脚本开头统一设置:
def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)注意torch.backends.cudnn.deterministic = True也要设置,否则 cuDNN 的卷积实现有随机性,在 GPU 上同样的代码两次运行结果也会有微小差异。虽然这个差异不影响最终性能,但对比实验里会让曲线不够干净。
6.2 四组结果怎么读:过估计、收敛速度与最终性能
从我自己跑的 Pong 实验结果看(Pong 是动作空间小、奖励稀疏但信号明确的任务),原始 DQN 大约在 30 万步开始学会回球,但 Q 值曲线一路上涨,明显高于实际 episode 奖励;Double DQN 的 Q 值曲线更贴近真实奖励,收敛速度略慢但最终性能更高;Dueling DQN 在样本效率上有明显优势,同样步数下评估奖励更高,但这个优势在 Pong 上不显著,因为 Pong 的状态价值本身就占了主导,动作优势差异不大;三合一的 Dueling Double DQN 综合了两者优势,但提升幅度并不是简单的相加,在 Breakout 这类需要精细控制动作的游戏里收益更明显。
如果你想验证过估计,可以在训练时记录每一批的q_values.mean()和评估奖励的比值。如果这个比值持续大于 1.5,说明过估计还在;Double DQN 会把比值压到 1.2 以下。这个指标比单纯看 reward 曲线更能定位问题。
6.3 进一步变体:Priority Replay 与 NoisyNet 的接入位置
当你跑完上述四组实验,想继续深入,优先级经验回放(Prioritized Replay)和 NoisyNet 是性价比最高的两个扩展。Priority Replay 改动经验回放池的采样逻辑:TD 误差大的样本被采到的概率更高。实现时需要给每个样本额外存一个 priority 值,并在梯度更新后用当前 TD 误差更新它。这个改动的效果在稀疏奖励的 Atari 游戏里特别明显。
NoisyNet 则把探索机制从 epsilon-greedy 换成在网络的最后一层注入可学习的高斯噪声,探索率随训练自动衰减。接入位置是 Dueling DQN 的a_out层之后,替换掉原来的nn.Linear(512, n_actions)。NoisyNet 的好处是让探索策略更有针对性,但训练不稳定时容易把噪声方差学得过大,需要密切关注 loss。
class NoisyLinear(nn.Module): def __init__(self, in_features, out_features, std_init=0.5): super().__init__() self.in_features = in_features self.out_features = out_features self.weight_mu = nn.Parameter(torch.FloatTensor(out_features, in_features).uniform_(-1/math.sqrt(in_features), 1/math.sqrt(in_features))) self.weight_sigma = nn.Parameter(torch.FloatTensor(out_features, in_features).fill_(std_init / math.sqrt(in_features))) self.bias_mu = nn.Parameter(torch.FloatTensor(out_features).uniform_(-1/math.sqrt(in_features), 1/math.sqrt(in_features))) self.bias_sigma = nn.Parameter(torch.FloatTensor(out_features).fill_(std_init / math.sqrt(in_features))) def forward(self, x): weight = self.weight_mu + self.weight_sigma * torch.randn_like(self.weight_sigma) bias = self.bias_mu + self.bias_sigma * torch.randn_like(self.bias_sigma) return nn.functional.linear(x, weight, bias)这个实现把噪声直接加在权重矩阵上,每次前向传播的噪声都不相同。用 NoisyNet 替换 epsilon-greedy 后,可以直接把探索率固定为 0 不再衰减,因为噪声本身就是探索机制。归一化方面,NoisyLinear 通常放在全连接层之前,输入是已经过 ReLU 的特征向量。
另外一个值得尝试的改动是给回放池的采样加上重要性采样权重纠正,因为 Priority Replay 改变了样本分布,直接学习会用偏的分布估计期望,需要按(N * P(i))^(-beta)纠正。beta 从 0.4 线性增加到 1.0。这一步不做,Priority Replay 在训练后期会引入明显偏差,最终性能反而不如均匀采样。
把这些变体全部组合起来的完整版本就是 Rainbow DQN,它集成了 Double、Dueling、Priority Replay、NoisyNet、多步学习、分布 Q 学习六种改进。官方实现里每个模块的接入顺序都有讲究,我建议不要一上来直接套 Rainbow,分步叠加、每加一个都跑一轮对比,才能知道哪些改进对自己手里的游戏真正有效。
我在跑这些实验时最大的教训是:别一边改网络一边改超参。DQN 对超参敏感,任何结构变化都可能导致原本合适的超参失效,然后你就会陷入“到底是模型有问题还是学习率有问题”的泥潭。先把结构固定,调超参;再固定超参,调结构;最后才谈组合改进。希望这套流程和踩坑记录能帮你把 DQN 及其变体真正跑成一篇漂亮的实验报告,而不是一个黑匣子。
本文还有配套的精品资源,点击获取