DQN深度Q网络实战:原理拆解、PyTorch实现与训练排障
去年冬天我花了差不多两周时间把一个 DQN 智能体从「随机乱按」调到能稳定跑满分中间踩过的坑比教科书上写的内容多得多。DQN全称 Deep Q-Network中文一般叫深度 Q 网络属于强化学习里最经典、也最常被当作入门第一课的价值型算法。它的位置很特殊往前一步是传统的 Q-Learning 查表法往后一步是各种改进变体与更现代的 Actor-Critic 体系。搞懂它等于拿到了进入深度强化学习大门的那把钥匙。这篇文章我打算按「原理为什么这么设计、代码怎么落地、出问题怎么查」三条线来讲。适合两类人一类是刚学完 Q-Learning想弄明白为什么非得塞一个神经网络进去的新手另一类是已经跑过开源实现但训练曲线一直平躺、Q 值一路爆炸、调参全靠玄学的中级玩家。我从状态空间爆炸讲到经验回放池从 TD 误差讲到目标网络最后给一份能直接跑的完整实现和一张排障速查表。所有代码基于通用强化学习环境接口跑在倒立摆这类经典控制任务上逻辑是通的你可以直接套到自己的场景。1. 先把 DQN 这件事讲明白它到底在解决什么问题1.1 从 Q-Learning 说起查表法的天花板在哪要讲 DQN绕不开它的前身 Q-Learning。Q-Learning 的核心思想非常朴素维护一张表行是状态列是动作格子里存的是「在某个状态下做某个动作长期能拿到的累计回报期望」这个值就叫 Q 值。智能体要做的事情就是不断更新这张表最后在每个状态下选 Q 值最大的动作执行。这个思路在状态离散且有限的时候非常好用。比如一个 10×10 的迷宫网格状态总数 100 个每个状态 4 个可选动作整张表也就 400 个格子用 NumPy 开个 100×4 的数组就能存更新一次只改一个元素快得飞起。我最早做路径规划的小 Demo 时就是用这种查表法几百轮就收敛了。问题出在状态一旦连续或者维度一高这张表就存不下了。举个数一张 84×84 的灰度图每个像素 256 种取值状态空间大小是 256 的 7056 次方这个数字大到写出来没意义。哪怕是最简单的倒立摆任务状态是四个连续实数小车位置、速度、杆角度、角速度理论上也是无限多种状态。你不可能为每一种状态都预留一个表格格子因为状态根本数不完。这就是所谓的维度灾难。查表法的本质是「记忆」它没法「泛化」——遇到一个没见过的状态表格里就是空的智能体完全不知道该做什么。而人类不是这么干活的我们见过几次类似的情况就能推断出新的情况该怎么办这种能力靠的是从具体状态中抽象出规律。1.2 为什么非得引入神经网络用函数逼近替代记忆DQN 的关键一步就是把「查表」换成「拟合」。既然不可能为每个状态单独存一个 Q 值那就假设 Q 值是状态的某种函数用一个带参数的模型去逼近这个函数。输入是状态输出是该状态下每个动作对应的 Q 值模型参数通过训练不断调整。为什么选神经网络而不是线性回归或者决策树原因有三个。第一神经网络是万能函数逼近器理论上只要宽度和深度够能拟合任意连续函数表达能力上限高。第二它天然支持端到端的多输出一次前向传播就能算出所有动作的 Q 值做动作选择时直接取 argmax比逐个动作算一遍效率高太多。第三图像、音频这类高维输入神经网络有卷积、池化这些成熟的结构去提取特征线性模型基本无能为力。注意用神经网络替代 Q 表看起来只是换了存储方式实际上引入了一个全新的麻烦——函数逼近会「串味」。更新某一个状态对应的 Q 值会顺带影响到其他相似状态的输出。这在查表法里是绝对不存在的也正是 DQN 训练不稳定的重要根源之一。这个「串味」问题后面会重点讲因为它是理解经验回放和目标网络这两大机制的钥匙。你先记住一个结论DQN 不是简单地把 Q 表换成网络就完事了那样训练大概率会发散。1.3 DQN 的两大护法经验回放与目标网络原始版本的「神经网络 Q-Learning」在实践中基本跑不起来2013 年到 2015 年间有一批工作真正把它做稳其中最关键的两个工程手段就是经验回放Experience Replay和目标网络Target Network。这两个机制后来成了几乎所有 DQN 变体的标配也是面试里问得最多的两个点。经验回放干的事情很直白智能体和环境交互产生的每一条(状态, 动作, 奖励, 下一状态, 是否结束)五元组不立刻拿来训练而是先塞进一个固定容量的池子里训练时从这个池子里随机抽一批出来算梯度。这个操作看起来只是加了个缓存实际上同时解决了两个问题一是打破了连续样本之间的强相关性二是让每条经验可以被重复利用多次样本效率成倍提升。目标网络则是为了解决「自己追自己」的稳定性问题。原始做法里计算目标值时用的 Q 网络和正在更新的 Q 网络是同一个这就导致目标本身在每一步都在动像一个人追着自己的影子跑永远追不上。目标网络的做法是额外维护一份参数它更新得慢专门用来算目标值主网络则专心拟合这些相对稳定的目标。这两个机制听起来都不复杂但它们组合起来的效果是决定性的。我见过不少人自己手写 DQN 时图省事把这两个都去掉结果训练曲线像心电图一样上下乱窜还以为是超参数没调好白白折腾几天。下面几节我会把每个机制的细节和取舍讲清楚。2. 核心原理拆解四个机制背后的「为什么」2.1 Q 值到底在说什么贝尔曼方程的直觉理解Q 值的严格定义是在状态 s 下执行动作 a之后一直按照某个策略行动所能获得的折扣累计回报的期望。写成公式是 Q(s,a) E[r γ·r γ²·r ...]其中 γ 是折扣因子取值在 0 到 1 之间。折扣因子是个很关键的参数它决定了智能体有多「短视」。γ 接近 0 时智能体只看眼前这一步的奖励行为非常贪婪γ 接近 1 时它会考虑很远期的回报但训练难度也随指数上升。倒立摆这种任务γ 设 0.99 是常规操作因为「把杆子撑住」这个目标的回报是持续累积的需要看得远一点。如果是那种一步定生死的场景γ 可以适当调小。这个定义直接导出了强化学习里最重要的等式贝尔曼最优方程Q(s,a) r γ·max Q(s,a)。用大白话翻译当前状态动作的 Q 值等于「这一步拿到的即时奖励」加上「折扣后下一状态最好的那个 Q 值」。这个等式是整个 DQN 训练的目标——网络输出的 Q 值应该尽量满足这个关系。我在理解这一步时用过一个生活类比Q 值就像一家店的估值。今天的估值 今天赚的现金 明天这家店估值的折现。你不会只看今天赚多少也不会完全不顾今天赚多少只看未来两边加权折扣因子就是那个折现率。DQN 的每一次更新本质上就是在修正「估值」和「估值推算出来的结果」之间的差距。2.2 TD 误差与损失函数训练信号从哪来既然贝尔曼方程给出了 Q 值应该满足的关系那么训练目标就明确了让网络预测的 Q 值和方程右边算出来的目标值尽量接近。两者之差有个专门的名字——时序差分误差简称 TD 误差。具体到一次更新从回放池里抽出一个样本主网络对当前状态 s 前向传播取动作 a 对应的那一维输出记为q_pred再用目标网络对下一状态 s 前向传播取所有动作里的最大值乘以折扣因子加上即时奖励得到q_target r γ · max Q_target(s, a)。损失函数就是这两个值的差。这里有几个细节值得展开。第一终止状态要特殊处理。如果这一步之后回合就结束了那就没有「下一状态」可言目标值应该退化成q_target r不能再加折扣项。代码里通常用一个done掩码乘上去q_target r γ * max_q * (1 - done)。漏掉这一步是新手最常犯的错误之一表现是智能体在快结束的时候行为异常因为它误以为后面还有奖励可以拿。第二损失函数用什么。最朴素的是均方误差 MSE但实践中更推荐 Huber 损失也就是平滑 L1PyTorch 里的smooth_l1_loss。原因是 TD 误差偶尔会出现异常大的值尤其是训练初期或奖励尺度大的场景MSE 会对大误差做平方放大梯度直接炸掉Huber 在大误差时退化成线性能有效抑制这种冲击。我在倒立摆任务上对比过用 MSE 时 Q 值偶尔会跳到几百换成 Huber 之后曲线明显平滑。第三要不要对目标值做截断。有些实现会把q_target限制在一个区间内防止它无限增长。这是个治标不治本的做法如果 Q 值真的在系统性膨胀说明奖励尺度设计或者网络结构有问题截断只是把症状藏起来。我的建议是先查奖励设计别急着截断。2.3 经验回放池为什么要打乱时间相关性回放池的作用值得单独拎出来说因为它触及了监督学习和强化学习的一个本质差异。在监督学习里我们假设训练样本是独立同分布的i.i.d.。但强化学习产生的数据完全不是这样智能体连续走过的几十步状态是高度相似的动作也往往有惯性比如一直向左推。如果直接按产生顺序喂给网络网络会在一段时间内反复看到同一类样本梯度方向被这类样本主导之前学到的东西会被快速覆盖这在神经网络训练里叫灾难性遗忘。回放池通过随机采样把这个问题化解了。池子里存的可能是几十轮甚至几百轮之前的数据随机抽取之后一个 batch 内的样本来自不同时间段、不同区域的状态分布近似恢复了独立同分布的性质。同时它还有个副产品一条经验可能被抽中几十次样本利用率大幅提升。在强化学习里和环境交互的成本往往远高于梯度计算这个提升非常值钱。池子的容量是个需要权衡的参数。太小了样本多样性不够起不到打乱的作用太大了存的是很久以前的旧策略产生的经验这些数据对应的行为分布可能已经和当前策略差很远学起来会拖后腿而且内存占用也上来了。倒立摆这种任务池子设 5 万到 10 万足够如果是更复杂的场景一般也就几十万到百万级别。我自己的经验是先用 10 万试如果训练后期明显卡住不动可以把容量调小一点让旧数据淘汰得快些。还有一个实践中的小技巧在训练最开始池子里的样本数少于 batch size 时直接跳过更新等积累够了再开始。否则早期那几个 batch 全是同一条经验复制出来的训练没意义还容易把网络带偏。2.4 目标网络给训练踩一脚刹车前面提到「自己追自己」的问题这里把它讲透。假设没有目标网络那么每次更新时目标值r γ·max Q(s,a)里的 Q 就是主网络本身。更新一次参数之后主网络的输出变了下一批样本算出来的目标值也跟着变。这就像你在射箭靶子每次都在你松手之后移动位置。更麻烦的是这种反馈会形成正反馈循环如果某次更新让某个动作的 Q 值偏大下一步目标值也跟着变大再更新又更大几十轮之后 Q 值就能涨到天文数字训练直接崩掉。这个现象在文献里被称为过高估计偏差。目标网络的做法是复制一份主网络的参数冻结起来专门算目标值每隔一段时间比如 500 步同步一次或者每步按一个小比例软更新。这样一来目标值在一段时间内是静止的主网络有个稳定的追逐对象训练就稳了。同步方式有两种各有适用场景。硬更新是每隔 N 步直接把主网络参数拷过去简单粗暴N 通常设 500 到 2000。软更新是每一步都做一次插值θ_target ← τ · θ_main (1 - τ) · θ_targetτ 一般取 0.001 到 0.01。软更新更平滑但每一步都有计算开销而且 τ 太小会导致目标跟得太慢、学习效率下降。我个人的习惯是起步阶段用软更新τ 设 0.005因为它对超参数不敏感不容易因为同步时机不对而崩。等你对任务足够熟悉了再考虑换成硬更新去压榨最后那点性能。一个容易忽略的坑目标网络的参数绝对不能参与梯度计算。代码里算目标值那一段必须包在torch.no_grad()里否则不仅会浪费显存还会把梯度错误地传导进目标网络让整个机制失效。我最初就漏过这个训练了半小时才发现目标网络也在更新等于白做。2.5 探索与利用epsilon-greedy 到底该怎么衰减Q 值网络给出的是「当前认为最好」的动作但如果从第一步开始就完全按网络输出的 argmax 走智能体会陷入局部最优——它还没试过的动作永远不知道好不好。epsilon-greedy 是最常用的解法以 ε 的概率随机选动作探索以 1-ε 的概率选当前 Q 值最大的动作利用。ε 一开始设成 1.0纯随机随着训练推进逐步衰减到一个小值比如 0.05 或 0.01。衰减策略有几种常见形式。线性衰减最容易理解和调试比如从 1.0 线性降到 0.05用前 10 万步完成。指数衰减是每步乘一个系数前期降得快后期降得慢。还有一种叫分段衰减先保持 1.0 一段时间积累数据再快速降下来。我自己最常用线性衰减因为它的行为最容易预测出了问题也好定位。衰减总步数是个需要跟总训练步数匹配的参数。如果总训练 20 万步衰减设成 10 万步那后半程基本是纯贪心容易过早收敛如果衰减步数设得比总步数还长那训练结束时 ε 还很高等于一直在乱试评估表现会很差。经验值是衰减步数占总训练步数的 30% 到 50%具体看任务复杂度。还有一点常被忽略评估时应该用固定的低 ε 或者干脆 ε0。很多人训练和评估用同一套逻辑结果评估时 ε 还在衰减同一个模型测两次分数差一大截误以为是模型不稳定。评估就是评估要把随机性降到最低才能看出真实水平。3. 手把手实现从零搭一个能跑通的 DQN3.1 环境与依赖准备先把地基打牢实现之前先把依赖装好。核心就三样深度学习框架、数值计算库、环境接口。我下面用 PyTorch 写逻辑换到别的框架上也一样。pip install torch numpy gymnasium这里有个细节值得说环境接口建议用较新的gymnasium而不是老版本的gym。新版把step的返回值从四元组改成了五元组(obs, reward, terminated, truncated, info)把「任务真正结束」和「时间到了被截断」区分开了。这个区分对 DQN 很重要——只有terminated为真时才应该把目标值里的折扣项去掉truncated不算真正的终止。老版本混在一起容易在某些任务上埋下隐蔽的 bug。选定环境是倒立摆平衡任务状态是四维连续向量动作是两个离散选择左推或右推每坚持一步奖励 1杆子倒下或小车跑出边界就结束。它足够简单单机 CPU 十几分钟就能跑出效果但又足够典型能暴露 DQN 的绝大多数问题非常适合当作第一个练手任务。3.2 网络结构设计输入输出约定要清楚网络本身不复杂两层隐藏层、每层 128 个单元激活用 ReLU。关键是要把输入输出的约定固定下来import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, obs_dim, act_dim, hidden128): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, act_dim), ) def forward(self, x): return self.net(x)输入维度是状态向量的长度输出维度是动作数量输出的第 i 个数就代表「选第 i 个动作」的 Q 值。这个「一次前向、多动作输出」的设计是 DQN 的精髓之一它让动作选择变成一次 argmax 操作比给每个动作单独建一个网络效率高得多。有个小取舍值得讨论隐藏层该多宽多深。倒立摆任务 128×2 完全够用用 256×2 也能跑但训练会慢一些用 64×1 就明显欠拟合了。经验法则是输出层维度决定了下限但不要指望靠加宽网络解决所有问题。如果网络已经很宽了还是学不会问题大概率在别处——奖励设计、探索策略、学习率而不是模型容量。再补一个实用细节初始化方式。PyTorch 的nn.Linear默认用 Kaiming 均匀初始化对 ReLU 网络来说是合理的一般不用改。但如果你的任务奖励尺度特别大比如动辄几百上千初始 Q 值相对目标值就太小前期梯度会很大。这时候可以在输出层乘一个小的缩放系数或者干脆把奖励归一化到 [-1, 1] 附近后者更省事。3.3 经验回放池的实现几个容易写错的地方回放池的实现看起来简单但有几个细节直接决定训练效果。import random from collections import deque import numpy as np class ReplayBuffer: def __init__(self, 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)第一个细节用deque(maxlencapacity)而不是普通 list。当样本数超过容量时deque 会自动从头部丢弃最老的样本不需要手动管理而且出入队都是 O(1)。用 list 手动pop(0)的时间复杂度是 O(n)池子大了之后会明显拖慢训练。第二个细节done的存储。这里存的是终止标志取值 0 或 1后面算目标值时用(1 - done)乘上去。注意要和环境的语义对齐——如果你用的是新版接口这里应该只记录terminated不要把truncated也算进去否则超时截断的样本会被当成真终止目标值少算了后续回报智能体会变得短视。第三个细节采样用random.sample而不是random.choices。前者是无放回采样一个 batch 内不会有重复样本后者是有放回可能出现同一条样本被抽好几次。小池子早期这两种差别不大但池子大了之后无放回能保证 batch 内的多样性训练更稳。如果样本量特别大、对采样速度有要求可以换成基于 NumPy 数组的环形缓冲区实现配合预分配的 ndarray采样时用随机索引批量取。不过在几十万级别的池子下deque 的性能完全够用没必要过早优化。3.4 训练主循环把各个部件串起来智能体类把网络、优化器、回放池、超参数全部打包import torch.optim as optim import torch.nn.functional as F class DQNAgent: def __init__(self, obs_dim, act_dim, lr1e-3, gamma0.99, buffer_size100000, batch_size128, tau0.005, grad_clip10.0, devicecpu): self.device torch.device(device) self.q QNetwork(obs_dim, act_dim).to(self.device) self.q_target QNetwork(obs_dim, act_dim).to(self.device) self.q_target.load_state_dict(self.q.state_dict()) self.opt optim.Adam(self.q.parameters(), lrlr) self.buffer ReplayBuffer(buffer_size) self.gamma gamma self.batch_size batch_size self.tau tau self.grad_clip grad_clip self.act_dim act_dim def select_action(self, state, epsilon): if random.random() epsilon: return random.randrange(self.act_dim) with torch.no_grad(): s torch.as_tensor(state, dtypetorch.float32, deviceself.device).unsqueeze(0) return int(self.q(s).argmax(dim1).item()) def update(self): if len(self.buffer) self.batch_size: return None s, a, r, s2, d self.buffer.sample(self.batch_size) s torch.as_tensor(s, dtypetorch.float32, deviceself.device) a torch.as_tensor(a, dtypetorch.int64, deviceself.device) r torch.as_tensor(r, dtypetorch.float32, deviceself.device) s2 torch.as_tensor(s2, dtypetorch.float32, deviceself.device) d torch.as_tensor(d, dtypetorch.float32, deviceself.device) q_pred self.q(s).gather(1, a.unsqueeze(1)).squeeze(1) with torch.no_grad(): max_next_q self.q_target(s2).max(dim1)[0] q_target r self.gamma * max_next_q * (1.0 - d) loss F.smooth_l1_loss(q_pred, q_target) self.opt.zero_grad() loss.backward() nn.utils.clip_grad_norm_(self.q.parameters(), self.grad_clip) self.opt.step() for tp, p in zip(self.q_target.parameters(), self.q.parameters()): tp.data.copy_(self.tau * p.data (1.0 - self.tau) * tp.data) return loss.item()几个地方值得逐行解释。gather(1, a.unsqueeze(1))的作用是从网络输出的所有动作 Q 值里把实际执行的那个动作对应的值挑出来这一步等价于「只对采取过的动作负责」。(1.0 - d)就是前面说的终止掩码。clip_grad_norm_是梯度裁剪把梯度的总范数限制在 10 以内防止某次异常大的 TD 误差把整个网络带飞——这一步在我的实践里救过好几次场强烈建议保留。外层训练循环负责采样、存储、更新、衰减、定期评估def train(env_nameCartPole-v1, total_steps50000, warmup2000, eps_start1.0, eps_end0.05, eps_decay_steps25000, eval_every5000): env gym.make(env_name) eval_env gym.make(env_name) obs_dim env.observation_space.shape[0] act_dim env.action_space.n agent DQNAgent(obs_dim, act_dim) state, _ env.reset() episode_return 0.0 returns_window deque(maxlen20) for step in range(1, total_steps 1): ratio min(1.0, step / eps_decay_steps) epsilon eps_start ratio * (eps_end - eps_start) action agent.select_action(state, epsilon) next_state, reward, term, trunc, _ env.step(action) done float(term) agent.buffer.push(state, action, reward, next_state, done) state next_state episode_return reward if term or trunc: returns_window.append(episode_return) state, _ env.reset() episode_return 0.0 if step warmup: agent.update() if step % eval_every 0: avg evaluate(agent, eval_env, episodes10) recent sum(returns_window) / max(1, len(returns_window)) print(fstep{step:6d} eps{epsilon:.3f} ftrain_ret{recent:6.1f} eval_ret{avg:6.1f}) return agent评估函数单独抽出来用固定的低探索率def evaluate(agent, env, episodes10, epsilon0.02): total 0.0 for _ in range(episodes): state, _ env.reset() done False while not done: action agent.select_action(state, epsilon) state, reward, term, trunc, _ env.step(action) total reward done term or trunc return total / episodes注意warmup这一步的作用前 2000 步只采样不训练让池子里先积累一些有足够多样性的数据避免一开始就用重复样本把网络带偏。这 2000 步差不多相当于二三十个回合对倒立摆来说够了。我在这个配置下实际跑的情况是大概 8000 到 15000 步时训练回报开始明显爬升25000 步左右评估能稳定在 400 分以上到 40000 步基本能碰到 500 分上限。整个过程在普通笔记本的 CPU 上跑不到二十分钟。如果你的曲线和这个差异特别大八成是下面第 4 节里的某个问题。3.5 超参数怎么定从这几个先动手超参数的经验值我先给一张表方便你对照参数常用取值调整方向备注学习率1e-3 或 5e-4曲线震荡就调小Adam 下 1e-3 是稳妥起点折扣因子 γ0.99任务越长视就越大超过 0.995 训练难度陡增batch size64 或 128显存允许就加大越大梯度越稳但更新频率下降回放池容量10 万后期卡顿可减半太小多样性不足太大旧数据拖累目标网络 τ0.005训练不稳就调小软更新比硬更新好调ε 衰减步数总步数 30%~50%收敛太早可拉长太长则后期探索过多梯度裁剪10.0一般不用动有异常大奖励时是救命稻草调参有个优先级问题。我的建议是先固定网络结构只调学习率和 ε 衰减这两者对结果的影响最大。学习率太大会导致 Q 值剧烈震荡太小则训练半天没动静ε 衰减步数和总训练步数不匹配表现会非常明显。等这两个稳定了再去动 batch size 和池子容量这类相对次要的参数。还有一个常被忽视的点别拿单次运行的结果下结论。DQN 的随机性来源很多——网络初始化、回放池采样、探索动作同一个配置跑三次最终分数可能差出两三百。我在做对比实验时至少跑三次取平均不然很容易得出错误结论。如果你只有单次结果那就把评估回合数拉多一点比如测 20 局用平均分而不是最高分。4. 常见问题与排障实录我踩过的那些坑4.1 Q 值一路膨胀到爆炸这是 DQN 最经典的故障表现是打印出来的q_pred均值从十几涨到几百、几千然后突然变成 nan。训练曲线在崩之前往往会有一段虚假的繁荣看起来分数在涨其实是在恶化。原因通常是这几个。第一漏了终止掩码智能体误以为每个回合结束后后面还有无穷无尽的奖励目标值被系统性抬高。第二目标网络没冻结或者跟得太快τ 设成 0.5 这种值等于没有目标网络。第三学习率过大一次更新跨太大步把 Q 值直接推飞。第四损失函数用了纯 MSE 且没有梯度裁剪一个大误差就能把参数带偏。排查顺序我一般是先加 print 看q_pred和q_target的量级正常应该在几十以内检查done掩码那一行写没写对确认torch.no_grad()保住了目标值计算最后才是降学习率和加梯度裁剪。按这个顺序走九成能定位到。4.2 奖励上不去曲线平躺比爆炸更让人抓狂的是「什么都不发生」。跑了几万步分数一直在一二十徘徊和随机策略没区别。先区分是「没学」还是「学了但被探索噪声盖住」。方法很简单关掉探索ε0单独评估一下如果评估分数明显高于训练时的平均分说明策略其实学到了只是训练时探索太强。这时候把 ε 衰减步数砍一半就行。如果评估也上不去重点查三处。一是奖励设计有些任务的奖励稀疏走一百步才给一次分DQN 很难从这种信号里学到东西需要考虑奖励塑形。二是网络输出维度动作数写错了、或者输出层没有覆盖所有动作网络根本没法学。三是数据进网络的格式状态是不是归一化过、维度对不对、dtype 是不是 float32这些低级错误我犯过不止一次调试半天发现是float64传进去导致类型不匹配。还有个隐蔽的原因训练步数根本不够。DQN 不是监督学习几千步就想看到效果不现实倒立摆这种最基础的任务也得一万步以上才起步。我见过有人跑两千步没效果就断言算法有问题这属于预期管理没做好。4.3 训练表现和评估表现对不上训练时平均回报 400评估只有 150这种落差有几个典型成因。最普遍的是评估时的 ε 没固定。如果你的评估函数复用了训练循环里的 ε那评估那一刻的探索强度可能还很高分数自然低。评估应该用固定的低 ε或者干脆 ε0 走贪心。第二个成因是评估环境没有重置或者状态污染。如果训练环境和评估环境是同一个实例里面的随机状态、计数器之类的东西可能互相影响结果不可信。正确做法是单独开一个环境实例专门用于评估。第三个原因是过拟合到训练轨迹。虽然 DQN 不像监督学习那样有明确的训练集测试集之分但如果环境本身是确定性的智能体可能记住了特定轨迹而不是学到通用策略。这种情况下换个随机种子重测分数会掉得很厉害。解法是训练时保证环境有足够的随机性让策略必须泛化。4.4 问题速查表把上面这些整理成一张表出问题时按表顺序排查能省不少时间现象最可能原因快速验证处理办法Q 值膨胀到很大终止掩码缺失打印 q_pred 均值检查(1-done)是否生效Q 值变 nan学习率过大或梯度爆炸看 loss 是否突然飙高降学习率至 5e-4开梯度裁剪分数完全不涨探索未衰减或奖励稀疏用 ε0 单独评估缩短衰减步数考虑奖励塑形分数先涨后崩目标网络更新太快检查 τ 或硬更新间隔τ 降到 0.001~0.005训练高评估低评估时探索未关闭对比两边 ε 取值评估固定 ε0 或 0.02训练初期就发散池子样本不足看 update 是否在 warmup 前触发加 warmup 步数后期完全停滞池子太大旧数据过多看采样样本的回合数容量减半让数据更新鲜结果每次差异巨大随机性太大同配置跑三次多次取平均再判断最后分享一个我觉得最实用的调试技巧把一批样本单独拿出来手动算一遍理想的目标值再和网络输出的预测值对比。这一步能立刻区分出是「目标值算错了」还是「网络拟合能力不够」比盲目调参高效得多。我后来每次重写 DQN 都会先做这个单步验证确认逻辑无误再开始长时间训练。5. 三个常见改进与 DQN 的适用边界5.1 Double DQN治一治过高估计的毛病原始 DQN 里目标值用的是目标网络在所有动作上的最大值。这个max操作有个隐蔽的副作用即使每个动作的 Q 值估计都是无偏的取最大值之后结果也会系统性地偏高这就是所谓的过高估计偏差。动作数越多偏差越明显。Double DQN 的修法很巧妙不再用目标网络自己挑动作而是让主网络挑动作、目标网络给分数。具体实现就是目标值改成r γ · Q_target(s, argmax Q_main(s, a))。代码改动只有两行with torch.no_grad(): next_actions self.q(s2).argmax(dim1, keepdimTrue) max_next_q self.q_target(s2).gather(1, next_actions).squeeze(1) q_target r self.gamma * max_next_q * (1.0 - d)代价是多了一次主网络的前向传播开销很小收益却是实打实的稳定提升。我在倒立摆上对比过加了 Double 之后训练曲线明显更平滑最终分数也更稳定。这个改进几乎没有理由不用我现在写任何基于 DQN 的实现默认都会带上它。5.2 Dueling 与优先经验回放什么场景值得上Dueling 结构把网络拆成两支一支输出状态价值 V(s)一支输出每个动作的优势 A(s,a)最后用Q V (A - mean(A))组合起来。它的直觉是在很多状态下选哪个动作其实差别不大重要的是这个状态本身好不好。把这两部分解耦之后网络能更高效地学习状态价值尤其是在动作选择对结果影响小的场景下。优先经验回放PER则是给池子里的样本加权重TD 误差大的样本被抽中的概率更高因为它们「还没学会」。代价是要维护每个样本的优先级实现复杂度上升而且引入的偏差还需要用重要性采样权重来修正超参数也多了一个。我的建议是分阶段入门阶段先把基础 DQN 跑通别急着上这些。等基础版本稳定了如果发现学习效率明显不足比如同样的步数下分数就是上不去再考虑加 Dueling如果发现样本利用率低、收敛慢再考虑 PER。两个一起上容易出问题因为优先级采样引入的分布偏移会和 Dueling 的训练动态互相影响出问题时很难定位是哪个的锅。5.3 什么时候别用 DQNDQN 有它明确的适用边界硬套到不合适的场景上会非常痛苦。动作空间是连续的时候不要用。DQN 的核心是「对每个动作算一个 Q 值然后取最大」动作连续就意味着无限多个动作这个 max 操作没法做。硬要离散化的话维度一高就组合爆炸而且会损失精度。这种情况应该转向 DDPG、TD3、SAC 这类专门处理连续动作的算法。需要随机策略的场景也不适合。DQN 学出来的是确定性策略给定状态选固定动作在某些需要混合策略才能达到最优的环境里——比如猜拳这种游戏——它会陷入困境。这类问题更适合策略梯度方法。奖励极度稀疏、回合特别长的时候要谨慎。DQN 依赖 TD 误差的信号传播如果奖励要走上千步才出现一次信号传到起点时已经衰减得几乎为零学习会非常慢。这时候要么做奖励塑形要么考虑基于模型的规划方法。反过来说DQN 在动作离散、状态连续或高维、单步决策开销小的场景里非常好用游戏智能体、资源调度、简单的控制任务、推荐系统的粗排策略这些都是它的传统主场。我后来把它用在一个小型的任务分配 Demo 上状态是队列长度和资源占用动作是选哪个执行单元效果比手写的启发式规则稳定不少代码量还更少。5.4 我个人跑下来最实在的几条经验写到这里把散落在各处的经验收一收这几条是我反复验证过、觉得最值得记下来的。第一先把简单环境跑通再上复杂任务。倒立摆虽然简单但它能暴露 DQN 几乎所有的坑——发散、不学习、评估偏差一个都不少。在这个环境上把每个参数的敏感度摸清楚换到新任务上你才有判断力。跳过这一步直接上高维输入的基本都会在调试里迷失。第二训练日志比结果重要。光看最终分数没用要记录每一步的 loss、Q 值均值、ε、回合回报、评估分数。我习惯每 5000 步打一行出问题时回看日志往往能一眼定位到是哪一段开始不对劲的。第三别迷信论文里的超参数。论文的配置是为它那个特定任务调的换个环境大概率不适用。特别是学习率和奖励尺度这两个必须按自己的任务重新校准。我的做法是先固定其他参数只扫学习率从 1e-4 到 1e-2 试四五个值选收敛最快又最稳的那个再调别的。第四验证逻辑比调参更省时间。前面提到的单步验证——手动算一遍目标值和预测值对比——我强烈建议养成习惯。绝大部分「训练不收敛」的问题本质都是代码逻辑写错了而不是超参数不对。花十分钟验证逻辑比花两天调参划算得多。第五保留一个最小可复现版本。每次改动都从那个能跑通的版本分叉出去改坏了随时回滚。我吃过一次亏在训练脚本上直接改结构改到一半发现效果更差想回退的时候已经记不清原来的样子了只能重写。这件事之后我养成了每个实验单独存一份配置的习惯。这套东西跑下来DQN 就不再是那个「看起来简单、一写就崩」的算法了。它背后的每个设计选择都有明确的动机经验回放是为了打破相关性目标网络是为了稳定追逐目标梯度裁剪是兜底探索衰减是权衡短期收益和长期信息。把这几个动机想清楚你在新的场景里遇到新问题时就知道该往哪个方向改而不是对着开源代码反复试错。