去年冬天,我花了差不多两周时间,把一个 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 损失(也就是平滑 L1,PyTorch 里的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. 手把手实现:从零搭一个能跑通的 DQN
3.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, hidden=128): 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(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)第一个细节:用deque(maxlen=capacity)而不是普通 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, lr=1e-3, gamma=0.99, buffer_size=100000, batch_size=128, tau=0.005, grad_clip=10.0, device="cpu"): 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(), lr=lr) 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, dtype=torch.float32, device=self.device).unsqueeze(0) return int(self.q(s).argmax(dim=1).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, dtype=torch.float32, device=self.device) a = torch.as_tensor(a, dtype=torch.int64, device=self.device) r = torch.as_tensor(r, dtype=torch.float32, device=self.device) s2 = torch.as_tensor(s2, dtype=torch.float32, device=self.device) d = torch.as_tensor(d, dtype=torch.float32, device=self.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(dim=1)[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_name="CartPole-v1", total_steps=50000, warmup=2000, eps_start=1.0, eps_end=0.05, eps_decay_steps=25000, eval_every=5000): 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(maxlen=20) 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, episodes=10) recent = sum(returns_window) / max(1, len(returns_window)) print(f"step={step:6d} eps={epsilon:.3f} " f"train_ret={recent:6.1f} eval_ret={avg:6.1f}") return agent评估函数单独抽出来,用固定的低探索率:
def evaluate(agent, env, episodes=10, epsilon=0.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 size | 64 或 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(dim=1, keepdim=True) 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 什么时候别用 DQN
DQN 有它明确的适用边界,硬套到不合适的场景上会非常痛苦。
动作空间是连续的时候不要用。DQN 的核心是「对每个动作算一个 Q 值然后取最大」,动作连续就意味着无限多个动作,这个 max 操作没法做。硬要离散化的话,维度一高就组合爆炸,而且会损失精度。这种情况应该转向 DDPG、TD3、SAC 这类专门处理连续动作的算法。
需要随机策略的场景也不适合。DQN 学出来的是确定性策略(给定状态选固定动作),在某些需要混合策略才能达到最优的环境里——比如猜拳这种游戏——它会陷入困境。这类问题更适合策略梯度方法。
奖励极度稀疏、回合特别长的时候要谨慎。DQN 依赖 TD 误差的信号传播,如果奖励要走上千步才出现一次,信号传到起点时已经衰减得几乎为零,学习会非常慢。这时候要么做奖励塑形,要么考虑基于模型的规划方法。
反过来说,DQN 在动作离散、状态连续或高维、单步决策开销小的场景里非常好用:游戏智能体、资源调度、简单的控制任务、推荐系统的粗排策略,这些都是它的传统主场。我后来把它用在一个小型的任务分配 Demo 上,状态是队列长度和资源占用,动作是选哪个执行单元,效果比手写的启发式规则稳定不少,代码量还更少。
5.4 我个人跑下来最实在的几条经验
写到这里,把散落在各处的经验收一收,这几条是我反复验证过、觉得最值得记下来的。
第一,先把简单环境跑通再上复杂任务。倒立摆虽然简单,但它能暴露 DQN 几乎所有的坑——发散、不学习、评估偏差,一个都不少。在这个环境上把每个参数的敏感度摸清楚,换到新任务上你才有判断力。跳过这一步直接上高维输入的,基本都会在调试里迷失。
第二,训练日志比结果重要。光看最终分数没用,要记录每一步的 loss、Q 值均值、ε、回合回报、评估分数。我习惯每 5000 步打一行,出问题时回看日志,往往能一眼定位到是哪一段开始不对劲的。
第三,别迷信论文里的超参数。论文的配置是为它那个特定任务调的,换个环境大概率不适用。特别是学习率和奖励尺度,这两个必须按自己的任务重新校准。我的做法是先固定其他参数,只扫学习率,从 1e-4 到 1e-2 试四五个值,选收敛最快又最稳的那个,再调别的。
第四,验证逻辑比调参更省时间。前面提到的单步验证——手动算一遍目标值和预测值对比——我强烈建议养成习惯。绝大部分「训练不收敛」的问题,本质都是代码逻辑写错了,而不是超参数不对。花十分钟验证逻辑,比花两天调参划算得多。
第五,保留一个最小可复现版本。每次改动都从那个能跑通的版本分叉出去,改坏了随时回滚。我吃过一次亏,在训练脚本上直接改结构,改到一半发现效果更差,想回退的时候已经记不清原来的样子了,只能重写。这件事之后我养成了每个实验单独存一份配置的习惯。
这套东西跑下来,DQN 就不再是那个「看起来简单、一写就崩」的算法了。它背后的每个设计选择都有明确的动机:经验回放是为了打破相关性,目标网络是为了稳定追逐目标,梯度裁剪是兜底,探索衰减是权衡短期收益和长期信息。把这几个动机想清楚,你在新的场景里遇到新问题时,就知道该往哪个方向改,而不是对着开源代码反复试错。