
简介基于 Python 在 gym Atari 环境中实现 DQN 及其变体的课程设计资源包面向深度学习初学者、强化学习课程设计者及需快速上手 PyTorch 的开发者解决从框架阅读、算法补全到训练验证的完整实践问题。压缩包共 255 个文件以 Python 源码为核心123 个 py配合约 45 个 txt 说明文档、MD 笔记、Dockerfile、PyTorch 训练日志及运行效果 GIF/PNG 截图整体仅 5.05MB便于下载与本地调试。内容涵盖 DQN、DDQN 等变体实现提供训练过程记录与可视化结果可辅助对比不同算法在 Atari 环境中的表现同时附有 docker 配置便于复现运行环境。已有 204 人学习下载适合正在完成强化学习课程设计、或希望参考完整工程结构以缩短排错周期的学习者。1. 为什么都拿 gym Atari 当 DQN 的练手场把标题里的每个词拆开说大多数 DQN 教程卡住的点根本不在算法而在 gym Atari 环境的版本耦合——这是很多人第一次跑 deep Q-Learning 时没预料到的。标题里「gym Atari 环境 DQN 算法及其变体」的组合恰恰是强化学习入门的一线标准组合环境免费、状态是图像、奖励稀疏却有规律能完整体验「卷积网络看屏幕学打游戏」的完整闭环。这篇文章会把环境搭到能跑通的最小状态再从经典 DQN 推到 Double、Dueling 和优先经验回放最后落到一组能直接对着排查的避坑清单。适合刚把 Python 玩熟、准备跨进强化学习的读者也适合被「loss 不降」折腾了一整晚的熟手回炉。2. 先搭对环境gym 与 Atari ROM 的版本坑和最小安装清单2.1 gym 还是 gymnasium两个名字背后的同一个 Atari 生态打开搜索引擎gym 的第一个结果是 OpenAI Gym第二个结果可能是 gymnasium。这俩不是竞争关系而是同一个生态的换壳延续OpenAI 在 2021 年后把维护权交给 Farama 基金会项目改名为 Gymnasium接口绝大部分保持一致Atari 环境也从原来的 atari-py 迁移到了 ale-pyArcade Learning Environment 的 Python 绑定。对做 DQN 的人来说最大的变化只有两个。第一原来的env.step(action)返回 4 个值现在是obs, reward, terminated, truncated, info5 个值多出来的 truncated 表示「是否因超时结束」和 terminated是否因游戏失败结束分开后代码逻辑更干净但老代码直接拿 4 个返回值会当场报not enough values to unpack。第二安装方式变了pip install gym[atari]这类老写法在 gymnasium 时代已经失效得用下面这套组合。还有一个绕不开的坑Atari 环境的 ROM 文件有版权。ale-py 把 ROM 从库里拆了出来你需要单独装一个带 license 的 ROM 包或者自己放 ROM 文件进指定目录。这是整套环境最容易卡住的一步大部分人第一次import gym没报错、gym.make(PongNoFrameskip-v4)就报No such ROM问题就出在这。ROM 包装好后不需要手动操作文件ale-py 会自动把它解压到正确位置。2.2 最小可运行安装从 Python 虚拟环境到 ale-py 的推荐组合我先给结论本机开发用 Python 3.9 到 3.11 之间最稳配合gymnasium[atari, accept-rom-license]作为环境层不要直接装老版 gym。老版 gym 不是不能用而是它的维护基本冻结后续所有变体代码都要为truncated参数做兼容没有这个必要。用 venv 建独立环境避免 base 环境里的其他第三方库把依赖搅乱。# 创建并激活虚拟环境 python3 -m venv dqn_env source dqn_env/bin/activate # Windows 下改为 dqn_env\Scripts\activate # 升级 pip 和基础工具 pip install --upgrade pip setuptools wheel # 安装 gymnasium 的 Atari 全家桶含 ROM license 包 pip install gymnasium[atari] pip install gymnasium[accept-rom-license] # 深度学习框架CPU 版就够跑通代码GPU 版按需装 pip install torch torchvision逻辑说明gymnasium[atari]会拉取 gymnasium 本体和 ale-pygymnasium[accept-rom-license]额外带上 ROM 包两个 extra 都要写才能一次装齐。torch换成 CPU 版能降低很多入门阻力DQN 在 Atari 上一个 step 的推理量很小CPU 也能跑只是训练速度会慢 3 到 5 倍。装完后先做一次导入检查确认发行版之间的 hidden import 没有互相污染。# check_env.py import gymnasium as gym print(gymnasium 版本:, gym.__version__) env gym.make(PongNoFrameskip-v4, render_modehuman) print(观测空间:, env.observation_space) # Box(210, 160, 3) print(动作空间:, env.action_space) # Discrete(6) obs, info env.reset() print(单帧 obs 形状:, obs.shape) env.close()参数说明PongNoFrameskip-v4是 DQN 论文里最常用的环境之一NoFrameskip表示不做帧跳过保证每一步都是原子操作写赝代码时心智负担小。render_modehuman会弹窗显示画面在服务器上会报错改成rgb_array即可。动作空间是 6 个离散动作对应球拍的上下移动和静止实际训练时通常只取其中的 3 个有效动作来加速收敛。2.3 验证环境可用的第一个脚本观察空间、动作空间与渲染装好环境后先别急着写网络先跑一个随机策略脚本把「环境能不能通」和「obs 到底是什么」两步验证做掉。这样后面算法出问题时你能确定锅在逻辑、不在环境。# random_run.py import gymnasium as gym env gym.make(PongNoFrameskip-v4, render_modergb_array) obs, _ env.reset() for step in range(200): action env.action_space.sample() # 随机采样一个动作 obs, reward, terminated, truncated, info env.step(action) if terminated or truncated: obs, _ env.reset() if step % 50 0: # 打印 obs 的统计量确认不是全黑或全白 print(fstep{step}, obs.mean{obs.mean():.2f}, fobs.max{obs.max()}, reward{reward}) env.close()逻辑说明随机策略不要求智能体学任何东西只验证环境步进、reset 逻辑和 reward 返回是否正常。obs.mean()如果稳定在某个非极端值附近说明画面内容正常如果全黑或全白大概率是渲染模式不对或 ROM 本身有问题。这一步跑通后环境层面的坑基本就排除了。3. 从零写经典 DQN网络结构、经验回放、目标网络三件套3.1 为什么 DQN 必须同时有经验回放和目标网络先立住理论DQN 的全称是 Deep Q-Learning Network核心思想是用神经网络拟合 Q 函数Q(s, a)——给定状态s和动作a输出未来的累计折扣奖励期望。和传统 Q 表相比神经网络的输入是连续的高维图像输出是每个动作的 Q 值向量。看起来只是把表格换成了网络但直接这么干会出两个大问题。第一个问题是样本相关性。强化学习的数据是序列产生的相邻状态几乎一样用这样的数据连续更新网络梯度会偏向刚看到的那个小片段训练抖得像心电图。DQN 的解决办法是经验回放所有(state, action, reward, next_state, done)装进一个大小固定的缓冲区每次更新从中随机抽一个 batch破坏相关性。第二个问题是目标不稳定。Q 学习的目标值r gamma * max_a Q(s, a)本身用到当前网络的输出等于用正在跳的秤量自己的体重。DQN 的做法是单独维护一个参数延迟更新的目标网络目标值的计算走目标网络训练网络去逼近它目标网络再每隔若干步把训练网络的参数复制过来。这样目标值在两次更新之间是相对固定的训练才算得上稳定。所以三个件是配套关系网络提供拟合能力回放提供独立同分布的数据目标网络提供不漂移的监督信号。三个缺一个论文里的效果都复现不出来这不是玄学是数值稳定性问题。3.2 预处理与网络定义为什么 Atari 画面要先做裁剪和灰度直接拿 210×160×3 的 RGB 图像训练参数规模大但收益甚微。DQN 论文里做了一套固定预处理被后续几乎所有 Atari 工作沿用灰度化然后缩放并裁剪到 84×84再连续堆叠 4 帧作为状态输入。灰度让通道从 3 降到 1裁剪去掉了上下两端的计分板4 帧堆叠是为了让网络感知运动方向——单帧画面看不出球往哪飞4 帧连着看就有了时间信息。常见做法是用gymnasium.wrappers里的包装器组合不用自己写每帧循环。import gymnasium as gym from gymnasium.wrappers import AtariPreprocessing, FrameStack def make_atari_env(env_idPongNoFrameskip-v4, seed42): env gym.make(env_id, render_modergb_array) env AtariPreprocessing( env, noop_max30, # 每回合开始随机跳过 0~30 步打散初始帧 frame_skip4, # 每步重复执行动作 4 帧加速游戏节奏 screen_size84, # 缩放为 84x84 灰度图 terminal_on_life_lossFalse, # 是否把丢一条命视为终止 ) env FrameStack(env, num_stack4) # 堆叠 4 帧作为状态 return env逻辑说明AtariPreprocessing把颜色、尺寸、帧跳过全部处理掉FrameStack在通道维度堆叠历史帧最终状态形状是(4, 84, 84)和下面的网络输入对齐。terminal_on_life_loss这个参数对训练效果影响很大Pong 这类游戏一次对局有多条命把丢命当终止会让网络更快学会「活着」但也会让价值估计被截断。经典论文的做法是在训练时不截断False评估时截断True。参数说明frame_skip4是 Atari 官方推荐值动作延迟 4 帧相当于把时间分辨率降低了但游戏速度和奖励积累都变快网络收敛更快。noop_max30打散状态多样性避免每局开始时画面完全相同导致过拟合。这两个值不建议乱改改小了训练变慢改大了动作失去精度。网络结构是 DQN 的另一半。Atari 输入是 84×84 的灰度帧堆叠正好适合卷积网络提取空间特征后面接全连接层输出每个动作的 Q 值。import torch import torch.nn as nn import torch.nn.functional as F class DQN(nn.Module): def __init__(self, n_actions): super(DQN, self).__init__() self.conv1 nn.Conv2d(4, 32, kernel_size8, stride4) # 4 帧输入 self.conv2 nn.Conv2d(32, 64, kernel_size4, stride2) self.conv3 nn.Conv2d(64, 64, kernel_size3, stride1) self.fc1 nn.Linear(64 * 7 * 7, 512) self.fc2 nn.Linear(512, n_actions) def forward(self, x): # 输入 x 形状: (batch, 4, 84, 84)先归一化再进卷积 x x.float() / 255.0 x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) x torch.relu(self.conv3(x)) x x.view(x.size(0), -1) # 拉平特征图 x torch.relu(self.fc1(x)) return self.fc2(x)逻辑说明输入 4 帧灰度图第一层卷积用 8×8 大核和 stride4感受野大、计算量小适合捕捉画面里的大物体第二层 4×4 核、第三层 3×3 核逐步细化。经过三层卷积和两层全连接输出维度等于动作空间大小每个值代表对应动作的 Q 值估计。输入先除以 255 归一化避免网络一开始就在大数值输入上震荡。注意这里nn.Conv2d的第一参数是 4对应FrameStack堆叠的 4 帧不少人在这里顺手写成 3RGB 通道数前向传播不会报错但输入语义完全错了训练结果必然翻车。3.3 训练主循环epsilon 贪心、损失计算与参数更新的最小实现经典 DQN 的完整训练循环包含三件事用 epsilon 贪心策略采集数据存入回放缓冲区从缓冲区随机采样 batch 计算 TD 误差反向传播更新训练网络同时周期性把训练网络参数复制给目标网络。下面这段是去掉了日志和模型保存的主循环骨架。import random import numpy as np import torch.optim as optim from collections import deque # 经验回放缓冲区 replay_buffer deque(maxlen100_000) # 两个网络online 负责动作选择target 负责目标值计算 online_net DQN(n_actionsenv.action_space.n).to(device) target_net DQN(n_actionsenv.action_space.n).to(device) target_net.load_state_dict(online_net.state_dict()) target_net.eval() # 目标网络不训练只做推理 optimizer optim.Adam(online_net.parameters(), lr1e-4) gamma 0.99 # 折扣因子 batch_size 32 epsilon 1.0 # 初始探索率 epsilon_min 0.1 epsilon_decay 0.995 # 每步衰减步数越多探索越少 target_update_freq 1000 # 每 1000 步同步一次目标网络 for step in range(1, 2_000_000 1): # 1. epsilon 贪心选择动作 if random.random() epsilon: action env.action_space.sample() else: with torch.no_grad(): state_t torch.FloatTensor(np.array(state)).unsqueeze(0).to(device) q_values online_net(state_t) action q_values.argmax().item() next_state, reward, terminated, truncated, _ env.step(action) done terminated or truncated # 2. 存入回放缓冲区 replay_buffer.append((state, action, reward, next_state, done)) state next_state epsilon max(epsilon_min, epsilon * epsilon_decay) if done: state, _ env.reset() # 3. 采样 batch 并更新网络 if len(replay_buffer) batch_size: continue batch random.sample(replay_buffer, batch_size) states, actions, rewards, next_states, dones zip(*batch) states torch.FloatTensor(np.array(states)).to(device) actions torch.LongTensor(actions).unsqueeze(1).to(device) rewards torch.FloatTensor(rewards).to(device) next_states torch.FloatTensor(np.array(next_states)).to(device) dones torch.FloatTensor(dones).to(device) # 目标值: r gamma * max_a Q_target(s, a) with torch.no_grad(): next_q target_net(next_states).max(1)[0] targets rewards gamma * next_q * (1 - dones) # 预测值: Q_online(s, a) q_pred online_net(states).gather(1, actions).squeeze(1) loss F.smooth_l1_loss(q_pred, targets) # Huber 损失对异常值更稳 optimizer.zero_grad() loss.backward() optimizer.step() # 4. 周期性同步目标网络 if step % target_update_freq 0: target_net.load_state_dict(online_net.state_dict())逻辑说明动作选择阶段epsilon控制探索与利用的平衡初始值 1.0 表示完全随机随后逐步衰减到 0.1 保持少量探索。gather(1, actions)从 Q 值矩阵里取出所选动作对应的预测值目标值用target_net计算且包在no_grad里阻断梯度流向目标网络。参数说明replay_buffer大小 100k 是 Atari 场景的折中值论文里用的是 1M但内存受限时可以缩减到 100k效果会下降但要能跑通优先。target_update_freq1000意味着每隔 1000 步硬同步一次参数这个值过小会导致目标漂移过大会让学习变慢。F.smooth_l1_loss相比 MSE 对大误差的惩罚更温和能防止单条高奖励样本把网络权重拉飞。epsilon_decay0.995是经验值总步数 200 万的情况下大约在 10 万步后探索率就逼近下限适合 Pong 这类简单环境更复杂的游戏需要更慢的衰减。4. 三个必做变体Double DQN、Dueling DQN 与优先经验回放怎么改4.1 Double DQN一条 argmax 的改动解决价值高估经典 DQN 的目标值计算是r gamma * max_a Q_target(s, a)max操作天然带着正偏差——噪声会让某个动作的 Q 值被高估取最大值时高估概率更高。这个偏差会随着训练被网络记住最终导致学到过于乐观的价值估计策略反而变差。Double DQN 的思路一句话就能说清用在线网络选择动作用目标网络评估该动作的价值把「选」和「评」分开。改动只在损失计算部分其他代码完全不动。# Double DQN 的目标值计算 with torch.no_grad(): # 用 online 网络选最优动作 next_actions online_net(next_states).argmax(1, keepdimTrue) # 用 target 网络评估该动作的 Q 值 next_q target_net(next_states).gather(1, next_actions).squeeze(1) targets rewards gamma * next_q * (1 - dones)逻辑说明经典写法里max(1)[0]是「选动作」和「评价值」在同一条网络上完成天然放大了高估。改成先由online_net在next_states上选出argmax动作再让target_net查这个动作的 Q 值两个网络相互制约高估被显著抑制。这个变体是整个 DQN 家族里性价比最高的一个改动量约 3 行Pong 训练的奖励曲线明显更平滑后期不掉点。注意argmax(1, keepdimTrue)产生的是形状(batch, 1)的索引张量后面gather才能正确取数。漏掉keepdim会得到一维张量gather直接报维度错误。这也是我每次复现 DQN 变体时最先怀疑的报错点。4.2 Dueling DQN把「状态好坏」和「动作选择」拆开的网络改造Dueling DQN 的动机很直觉很多游戏画面里某个状态好不好跟选哪个动作关系不大比如 Pong 里球还在对方半场时上下移动的选择对当前状态价值影响很小。把「这个画面值多少钱」状态价值 V和「在这个画面上选某个动作比平均好多少」优势函数 A拆开建模能加快学习速度尤其在动作空间大的环境里。实现上只需要替换网络结构损失计算和训练循环全部沿用。class DuelingDQN(nn.Module): def __init__(self, n_actions): super(DuelingDQN, self).__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(), ) # 状态价值分支 V(s) self.fc_v nn.Sequential( nn.Linear(64 * 7 * 7, 512), nn.ReLU(), nn.Linear(512, 1), ) # 优势分支 A(s, a) self.fc_a nn.Sequential( nn.Linear(64 * 7 * 7, 512), nn.ReLU(), nn.Linear(512, n_actions), ) def forward(self, x): x x.float() / 255.0 feat self.conv(x) feat feat.view(feat.size(0), -1) v self.fc_v(feat) a self.fc_a(feat) # 优势减去均值保证可辨识性 q v a - a.mean(dim1, keepdimTrue) return q逻辑说明网络末尾分裂成两个分支fc_v输出一个标量表示状态价值fc_a输出每个动作的优势值。合并时减去优势均值a.mean(dim1, keepdimTrue)是数学上的可辨识性约束——如果不强制优势均值为 0V 和 A 的组合有无穷多组解网络更新会不稳定。4.3 优先经验回放让高频关键样本被更大概率抽到经验回放是均匀随机采样但样本的重要性天然不同。比如游戏刚开始的帧和即将撞到球拍的最后一帧前者大量重复、学到的东西少后者是决定胜负的关键帧。优先经验回放PER的思路是给每条经验打一个优先级TD 误差大说明这条经验和当前策略的预期差距大需要被更频繁地学习。实现分三步存储时保存 TD 误差采样时按优先级概率抽取更新后修正优先级同时用重要性采样权重补偿分布偏差。# PER 的经验条带优先级 class PrioritizedBuffer: def __init__(self, capacity, alpha0.6): self.capacity capacity self.alpha alpha # alpha 控制优先级影响程度 self.buffer [] self.priorities np.zeros(capacity) def push(self, state, action, reward, next_state, done, td_error): # 新样本的优先级用当前最大优先级保证一定会被抽到 priority max(self.priorities.max(), 1.0) if self.buffer else 1.0 if len(self.buffer) self.capacity: self.buffer.append((state, action, reward, next_state, done)) else: # 覆盖最老的一条 self.buffer.pop(0) self.buffer.append((state, action, reward, next_state, done)) self.priorities[len(self.buffer) - 1] priority ** self.alpha def sample(self, batch_size, beta0.4): probs self.priorities[:len(self.buffer)] probs probs / probs.sum() indices np.random.choice(len(self.buffer), batch_size, pprobs) batch [self.buffer[i] for i in indices] # 重要性采样权重beta 从 0.4 逐步升到 1.0 weights (1.0 / (probs[indices] * len(self.buffer))) ** beta weights weights / weights.max() return batch, weights, indices def update_priority(self, indices, td_errors): for idx, td in zip(indices, td_errors): self.priorities[idx] (abs(td) 1e-6) ** self.alpha逻辑说明alpha0.6是论文推荐值表示优先级对采样概率的影响程度越大越偏向高优先级样本alpha0退化为均匀采样。probs self.priorities[:len(self.buffer)]用当前有效长度进行归一化因为 numpy 数组的初始 0 值不能进入概率分布计算。update_priority里的1e-6是防零保护防止优先级为 0 的样本永远不被采样。使用 PER 后损失计算需要额外乘上重要性权重否则采样分布偏移会导致网络估计产生偏差。具体是每个样本的损失乘上对应的weights再求均值。这是 PER 和普通回放唯一的损失差异忘记做这一步会让训练曲线乱跳。到这里你已经集齐了 DQN 后时代的三件套。实际工程里Double DQN 和 Dueling DQN 可以无缝叠加PER 和它们也能共存组合后的网络依然只需要一个在线网络、一个目标网络和一条损失函数。5. DQN 训练避坑指南五个让 loss 不降或环境卡死的真实场景5.1 报错No such ROMROM 版权分离后的第一道坎环境用gym.make(PongNoFrameskip-v4)时直接抛FileNotFoundError: No such ROM连环境都建不起来。原因很简单pip install gymnasium[atari]只装了 ale-py 本体没有 ROM 文件。解决方法是补装带 license 的 ROM 包pip install gymnasium[accept-rom-license]如果装完还是报找不到多半是装到了别的 Python 环境里。用pip show ale-py查看安装路径再检查当前 python 解释器是不是同一个虚拟环境。我犯过最蠢的错误是用 vscode 打开项目时Python 解释器自动选了全局环境于是终端里明明装了 ROMvscode 里执行却一直报错。在 vscode 右下角把解释器切回dqn_env问题立刻消失。5.2 loss 不降反升奖励缩放和梯度爆炸的排查顺序训练到几万步时 loss 纹丝不动甚至越train越高这是 DQN 新手最常见的困惑。先说结论loss 不降不一定是坏事DQN 的 loss 本身就不像监督学习的分类 loss 那样单调下降因为目标值每 1000 步才更新一次更新瞬间 loss 会跳变。但持续上升就需要排查了。按顺序查三件事。第一reward 是否做缩放Atari 环境的原始奖励是 ±1Q 值范围很小如果某个游戏奖励很大几千分TD 目标也会变成上千梯度爆炸。把 reward 除以 10 或做 clip。第二学习率是否过大DQN 用 1e-4 是实验验证过的不要轻易调大。第三target_net是否真的处于eval模式且包裹在no_grad里——如果目标网络计算图被保留显存会持续增长反向传播也会把梯度混进目标网络。5.3 训练几万步后突然「遗忘」回放缓冲区被陈旧样本污染训练曲线先上升后暴跌像学了又忘。这是回放缓冲区容量太大且目标网络更新太慢导致的典型症状缓冲区里积累了太多早期低质量样本在线网络早已进步抽到老样本时 TD 误差巨大梯度把网络往回拽。解决方法是缩小缓冲区或增大目标网络更新频率。我通常的做法是把target_update_freq从 1000 降到 500replay_buffer容量保持 100k 不变。如果还是遗忘给经验打年龄标记deque(maxlenN)里超过一定年龄的样本在采样时乘一个衰减系数。不过这个方案在纯 DQN 里属于过度设计先把更新频率调快试试八成能稳住。5.4 服务器上渲染报错没有显示器的环境怎么跑在无头 Linux 服务器上跑训练gym.make(env_name, render_modehuman)会直接报ValueError: Cannot render to human in a headless environment。很多人卡在这一步就放弃训练了其实训练本身根本不需要渲染画面。把render_mode改成rgb_array或干脆删掉这个参数Atari 环境会自动以无渲染模式运行速度反而更快。需要录视频时再加render_modergb_array配合RecordVideo包装器在评估时保存 mp4。5.5 训练结果每次都不一样的「玄学」随机种子与确定性设置同样的代码、同样的超参两次训练出来的效果天差地别这不全是玄学是强化学习里随机性太强环境随机性、网络初始化、epsilon 探索、回放采样都是随机源。排查时先确认所有随机源是否被固定。import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) env.reset(seedseed) # gymnasium 在 reset 时接收 seed 参数env.reset(seedseed)是 gymnasium 的接口会自动为环境随机数生成器设置种子。注意要在make_atari_env里把 seed 透传进去否则每次env.reset都会重新触发初始随机化流程noop_max就是其中一个随机源。设有种子之后两次训练的前期曲线才具备可比性否则你根本分不清超参改动的作用还是运气的作用。6. 结果验证与扩散实践把训练过的策略录下来、量化下来训练曲线只是过程指标真正能说服人的是策略在你的评估协议下蹂躏游戏的表现。我每次跑完一次训练都会做三件事录制一段视频、跑一次固定步数的评估、把超参和行为记录进一张卡片。视频录制用 gymnasium 的RecordVideo包装器评估时强制贪心关闭探索用训练好的在线网络选择动作from gymnasium.wrappers import RecordVideo eval_env make_atari_env(PongNoFrameskip-v4) eval_env RecordVideo(eval_env, video_folder./videos, episode_triggerlambda t: True) obs, _ eval_env.reset() for _ in range(5000): with torch.no_grad(): state_t torch.FloatTensor(np.array(obs)).unsqueeze(0).to(device) action online_net(state_t).argmax().item() obs, _, terminated, truncated, _ eval_env.step(action) if terminated or truncated: obs, _ eval_env.reset() eval_env.close()评估时每局初始的noop_max随机跳过帧会导致每局起点不同同一策略会有分数浮动。我一般跑 5 局取平均得分记录在超参卡上和训练 baseline 做对比。另一件不可省的事是保存模型权重和状态字典训练几百万步的权重是血泪经验换来的成果没有后悔药可吃每 10 万步存一次 checkpoint 是正常习惯。拿到一个能跑的 Atari 智能体之后横向迁移到 SpaceInvaders、Breakout 等其他环境只需要改环境名和动作空间大小。真正值钱的是你形成的那张超参卡——什么环境配什么frame_skip、epsilon_decay和target_update_freq这是你的个人手册。我自己的习惯是每跑完一次实验顺手在代码仓库里更新一遍 README 上的超参记录表包含 seed、buffer 大小、卷积层通道数、最终平均分下次回看时能直接定位当时的思路省掉大量重复验证时间。这两百多万步的训练不是为了复现一篇论文的数字而是让你亲手感知到这三件事经验回放是稳定性的底座目标网络是震荡的刹车变体是收敛速度的放大器。希望帮到你。本文还有配套的精品资源点击获取