资讯详情 深度强化学习入门:DQN在Atari上的完整实现与避坑指南
📅 2026/10/25 8:02:17
简介面向深度学习与强化学习入门者这是一套基于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, height84, width84, n_frames4): super().__init__(env) self.height height self.width width self.n_frames n_frames self.frames deque(maxlenn_frames) self.observation_space gym.spaces.Box( low0, high255, shape(n_frames, height, width), dtypenp.uint8 ) def _to_gray_resize(self, obs): gray cv2.cvtColor(obs, cv2.COLOR_RGB2GRAY) resized cv2.resize(gray, (self.width, self.height), interpolationcv2.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, axis0) 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, axis0), reward, done, info这段代码的关键在于deque(maxlen4)和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, capacity1000000): self.capacity capacity self.buffer deque(maxlencapacity) 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_size8, stride4)。如果你用(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_size8, stride4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size4, stride2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size3, stride1), 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, gamma0.99): with torch.no_grad(): # 原始 DQN: max over target network next_q_target target_net(next_states).max(dim1, keepdimTrue).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(dim1, keepdimTrue) 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_size8, stride4), nn.ReLU(), nn.Conv2d(32, 64, kernel_size4, stride2), nn.ReLU(), nn.Conv2d(64, 64, kernel_size3, stride1), 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(dim1, keepdimTrue)) return q这里我把全连接层拆成了两条通路卷积层共享。实际训练中 Dueling 分支的两条通路如果初始权重一致可能会在一开始梯度相似但训练几轮后就会分化。如果你想让 Dueling 的效果更明显可以给两条通路的激活函数设置不同的 dropout但不建议在 Atari 上这么做数据量足够大时网络自己会学会分化。3.4 三合一的最小改动实现把变体叠加到同一个训练循环里实战中很少有人只跑纯 DQN最常见的做法是把 Double 和 Dueling 叠加得到 Dueling Double DQND3QN。我把训练循环抽象成一个函数用布尔开关控制是否启用 Double 和 Dueling这样能在一套代码里快速做消融实验。训练循环的核心是采样一个 batch → 计算 Q 值 → 计算 target → 计算 loss → 梯度更新 → 周期性同步目标网络。def train_step(batch, online_net, target_net, optimizer, gamma0.99, use_doubleTrue): 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(dim1, keepdimTrue) next_q target_net(next_state).gather(1, next_actions) else: next_q target_net(next_state).max(dim1, keepdimTrue).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_lossHuber 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, seed0, frame_skip4, n_frames4): env gym.make(env_id) env gym.wrappers.MaxAndSkipEnv(env, skipframe_skip) env EpisodicLifeEnv(env) if FIRE in env.unwrapped.get_action_meanings(): env gym.wrappers.FireResetEnv(env) env AtariPreprocessor(env, n_framesn_frames) env gym.wrappers.ClipRewardEnv(env) env.seed(seed) return env参数说明frame_skip4表示每 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(dim1).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(fEpisode {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_doubleargs.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_locationdevice) 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_locationdevice很关键。如果在有 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 直接 NaNDQN 的 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%。正确做法是在评估函数里接收一个epsilon0参数并且在评估前把网络切到 eval 模式。def evaluate(env, net, n_episodes10): 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(dim1).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_init0.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 及其变体真正跑成一篇漂亮的实验报告而不是一个黑匣子。本文还有配套的精品资源点击获取