☰
DDQN原理与实现:如何解决DQN中的Q值过估计问题
2026/10/23 5:22:11 网站建设 项目流程

训练DQN的过程里,有一个场景我印象特别深:曲线好不容易冲到高分,还没等到高兴,几个回合之后又掉回原点,来回几次之后整个人都麻了。后来才知道,这种“先涨后崩”的曲线背后往往藏着一个很隐蔽的问题——Q值过估计。而DDQN(Double Deep Q-Network)就是专门冲着这个问题来的。我用一段时间完整实现过DDQN,也在多个实验环境里做过和DQN的对照,这篇就把我对它的理解、实现方式和踩坑过程一次聊透。

DDQN做的事情并不复杂:在原有DQN基础上,把“选择动作”和“评估动作”这两件事交给两套不同的参数去完成,从而抑制Q值被系统性高估的问题。如果你已经被DQN的训练震荡、过早收敛到次优策略折磨过,或者想搞明白“为什么我的智能体总是自我感觉良好,实际回报却很拉胯”,这篇笔记应该能从原理到代码再到调试经验,给你一个完整的参考。

1. 过估计问题:我要做DDQN的原因

1.1 一次让我崩溃的训练曲线

先说说我当时遇到的具体情形。在一个常见的小型控制任务里,我按标准的DQN流程搭建了一套方案:经验回放、目标网络、epsilon贪心探索,超参数也是参考开源项目里常见的配置。前两千个回合,平均回报一直接近满分,我当时觉得稳了,就跑去喝茶了。等我再看曲线的时候,平均回报已经跌掉了三分之一,而且还在往下走。

我一开始怀疑是学习率太大、奖励设计有问题,或者回放池里出现了异常数据。排查一圈之后发现,目标网络的更新逻辑看起来也没有问题。真正让我意识到“算法本身不对劲”的,是我把训练后期已经收敛好的Q值打印出来看了一眼。那些动作的Q值比我实际能拿到的累积回报高出将近一倍。也就是说,智能体确实在“自信过头”,但我用的DQN并没有能力识别出这种自信是错的。

标准DQN的目标值公式是这样的:

y = r + gamma * max_a Q_target(s', a)

这里的max操作同时承担了两件事:一是决定下一步要做哪个动作,二是拿这个动作的Q值当作“未来回报的上界”。问题就在于,这两个角色一旦耦合在一起,系统性的过估计就会不断自我强化。这不是一个偶发的数值噪声问题,而是结构性的偏差。

1.2 max操作如何让Q值系统性偏高

为了把这里讲透,我举个特别简单的例子。假设某个状态有两个动作,它们的真实Q值都是0。但是神经网络是用随机初始化开始训练的,它输出的估计值必然有误差——这个误差可能是正,也可能是负。假设现在两个动作的估计值分别是+1和-2,按DQN的max操作,目标值就会取+1。

关键不在于这一步选错了,而在于这个+1会被当作“真实目标值”反传回去更新网络。下次再遇到这个状态,网络就有更大倾向输出一个正偏差的Q值。新产生的回归目标里又包含这个偏高的值,再迭代一轮,偏差再度放大。就这样,一次随机的正噪声进入了系统的Q值预测,变成了目标,而目标又变成了下一次误差的来源。

误差的来源还会因为环境的随机性而加剧。如果环境本身带有随机奖励,或者状态转移有一定噪音,那么同一个状态下的Q估计会随着抽样而抖动。而max操作是一种“专门挑最大值”的算子,它对正噪声天然敏感,对负噪声则直接忽略。时间一长,估计值就会远远偏离真实值,表现出来的症状就是训练前期上升快、后期不稳、容易崩。

1.3 过估计在什么任务里最致命

我后来在几个不同类型的任务里反复验证过,过估计的影响程度差别很大。在动作空间很小的确定性任务里,DQN通常也能跑得不错,因为过估计会被限制在一个很小的范围内,而且所有动作都会被一个差不多的正偏差“轮番照顾”,策略的相对排序没有变化,所以最终表现仍然可用。

但在另外两类任务里,过估计就是大问题。

一类是动作空间很大的任务。动作越多,max操作采到的噪声就越多。想象一下,如果只有一个动作,它的估计误差是±1,那么偏差最多就是1;如果有100个动作,max几乎必然能从一堆随机噪声里挑出一个更大的正值,这个偏差的期望值会随动作数量增长。

另一类是稀疏奖励任务。在这种任务里,智能体大部分时间观察到的都是零奖励或者很小的奖励,一旦偶尔碰见一次正向反馈,Q值会迎来一次罕见的正向更新。由于这类样本太少,它们产生的误差很难被大量反向样本中和,于是“某一次碰到奖励”就会被过估计成“这个动作大概率能带来奖励”,策略也就随之偏移。DDQN并不能彻底解决稀疏奖励本身的问题,但它至少能让更新目标不那么“自我欺骗”,这对训练过程的稳定性帮助非常明显。

2. DDQN的解法思路:把“选动作”和“算价值”拆开

2.1 为什么朴素的单网络方案救不了

我最早想到的修复方式是给DQN加一个“最大Q值惩罚项”,也就是感知到Q值太大时往回调一点。这个思路看起来直接,但做起来非常别扭,因为过估计并不是均匀地分布在整个状态空间里的,热门状态的偏差可能很大,冷门状态的偏差却很小。你用一个全局惩罚去压它,要么压不住,要么把正常状态也压坏了。

也有人说把学习率调低一点,让Q值收敛慢一些,但过估计的根源是max算子带来的正偏差,它不等于梯度噪声。调低学习率只是把所有更新都变小了,偏差的期望依然存在,只不过挪动得更慢而已。换句话说,如果只调参数,等于每天都要小心翼翼地控制误差的扩张速度,而不是削弱误差本身。

所以后来我看到Double Q-Learning的思想时,有眼前一亮的感觉。它解决的问题正是“用一套参数去完成选择和评估”这件事本身。

2.2 原始Double Q-Learning的启发

在传统强化学习里,Double Q-Learning的做法是同时维护两套Q表,记为Q_A和Q_B。每次交互时,先用Q_A选出“看起来最好的动作”,再用Q_B去评估这个动作的价值。这样即使Q_A因为噪声高估了某个动作,Q_B那边仍然保留着一个独立的判断,不会跟着一块儿膨胀。

这种“一个给建议,一个做判断”的机制原理上很清楚,但它原来真的需要两份独立的Q表,每个回合还要随机决定更新哪一份。到了深度强化学习时代,我们不可能随便就让两套大网络各自独立跑一遍,那样训练成本直接翻倍,而且两套网络之间的互动关系很难控制。

DDQN的思路更轻巧:还是保持DQN原来的结构,一个在线网络,一个目标网络。区别只在计算目标值的时候,让在线网络负责选动作,让目标网络负责算Q值。也就是说,两套网络承担了不同的职责,但不需要额外再新建一套网络结构。

2.3 DDQN在DQN框架中的落点

在DQN里,目标值的计算方式是:

y = r + gamma * max_a Q_target(s', a)

DDQN改成两步:

a* = argmax_a Q_online(s', a) y = r + gamma * Q_target(s', a*)

换句话讲,先用在线网络从所有动作里挑出它认为最好的那个,然后不再使用在线网络的输出,而是拿着这个动作去目标网络里查询它对应的Q值。因为目标网络和在线网络的参数不同步,两边的误差分布也有一定独立性,所以“选择”阶段引入的过估计就不会直接污染“评估”阶段的数值。

这个改动小到可能只影响十几行代码,但它的意义在于直接把DQN更新目标里的系统性偏差砍掉了一截。我当时在代码里做这个改动只花了不到二十分钟,但训练结果的变化是肉眼可见的。整个核心原理可以用一句话概括:不要让一个运动员既当裁判员又当运动员。

3. 手写一个可运行的DDQN:架构设计与关键代码

3.1 网络结构:三层全连接只是起跑线

我搭建的DDQN代码主体结构其实和DQN几乎一模一样。它由三块组成:一个用于策略决策的在线网络、一个用于产生目标值的目标网络、以及一个经验回放池。

最常见的网络设计是一个多层感知机,输入维度是状态空间维度,输出维度是动作空间维度,中间夹一到两个隐藏层。我这里以一个中等规模环境为例展示核心逻辑,其中状态维度是8维,动作空间是4维:

def build_network(state_dim, action_dim, hidden_dim=128): import torch.nn as nn return nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) )

你可能注意到我没有在输出层加softmax。这是一个非常容易踩的坑——Q值网络输出的是动作价值的线性估计,不是动作概率,softmax在这里有害无益。动作选择是在训练主循环里通过argmax完成的,不是在网络输出层完成的。

对于像素输入类型的高维状态,可以把全连接层替换成卷积层组,再接入全连接层。原理是一样的,重点仍然在训练目标的构造上,而不是网络有多复杂。

3.2 经验回放池的强度:容量、批次和优先级

经验回放池的核心意义是打破相邻样本之间的时间相关性。如果智能体拿着连续几步的样本去做梯度下降,那么相邻样本高度相似,梯度方向会产生严重的自相关,训练很容易来回震荡。回放池让每一次更新都从不同历史时刻抽样,这就相当于给数据加了随机化。

我常用的实现方式是双端队列,容量一般设在5万到20万之间,批量大小在32到256之间。容量太小,样本多样性不够;容量太大,早期学到的劣质样本会占用大量比例,拖慢收敛。

代码层面我比较推荐用带有随机索引的普通数组实现,比如:

class ReplayBuffer: def __init__(self, capacity): self.buffer = [] self.position = 0 self.capacity = capacity def push(self, transition): if len(self.buffer) < self.capacity: self.buffer.append(transition) else: self.buffer[self.position] = transition self.position = (self.position + 1) % self.capacity def sample(self, batch_size): import random return random.sample(self.buffer, batch_size)

如果你后续打算用优先经验回放,可以直接换成按TD误差做权重抽样的实现。但入门阶段用均匀抽样就够了,把DDQN的机制跑通,再叠加优先级,效果会更容易解读。

3.3 目标网络更新:硬拷贝还是滑动平均

目标网络存在的目的,是给在线网络的更新提供一个相对稳定的“标尺”。如果只有一个网络,每一步都用同一套参数同时完成估计和更新,那么标尺本身也在变,训练就很容易发散。DDQN里目标网络虽然参与Q值计算,但它的参数不参与当前回合的梯度更新。

目标网络的更新有两种主流方式。一种是每隔C步把在线网络的权重直接拷贝到目标网络,这就是经典的硬更新。另一种是每一步做指数滑动平均,通常叫软更新:

theta_target = tau * theta_online + (1 - tau) * theta_target

我个人的经验是,在小型和中等规模环境里,硬更新简单可靠,C取1000到5000步之间通常都能跑通。软更新的tau一般取0.005到0.01,训练会更平滑,但调参不好时也会出现目标网络跟上在线网络太慢、导致目标值阶梯式漂移的问题。

这里有个容易被忽略的点:如果你用了软更新,就不要再用“每隔C步才更新目标网络”的写法,因为这相当于把两种机制混在一起,目标值的更新节奏会变得不可预测。二选一就好。

3.4 训练主循环与关键细节

训练主循环的逻辑顺序是固定的:执行动作、收集样本、存入回放池、从回放池采样、计算目标值、更新在线网络、周期性更新目标网络。

目标值计算是DDQN和DQN唯一的区别所在。我用代码展示一下最核心的部分:

def compute_ddqn_target(online_net, target_net, reward, next_state, done, gamma): # 第一步:用在线网络选择最优动作 next_action = online_net(next_state).argmax(dim=1, keepdim=True) # 第二步:用目标网络评估该动作的Q值 next_q = target_net(next_state).gather(1, next_action).squeeze(1) # 第三步:构造目标 target = reward + (1 - done) * gamma * next_q return target

计算当前预测值时,只需要把在线网络对当前状态的输出用gather取到执行过的动作对应的Q值即可:

pred = online_net(state).gather(1, action) loss = criterion(pred, target.detach())

关于.detach(),我必须多说一句。目标值必须从计算图里脱离出来,否则梯度会沿着目标值一路传回目标网络,导致目标网络也被隐式更新,这相当于破坏了两套参数之间的独立性。

还有一个细节是done的处理。终止状态的下一步没有未来回报,所以gamma * next_q必须用(1 - done)抵消掉。如果忘记这一点,终止状态会被错误地赋予一笔虚构的未来收益,过估计会更严重。

4. 我在训练DDQN时踩过的坑

4.1 用一整个环境回合评估,别只看单步

我早期评估策略很随意,直接看训练过程的平均回报曲线,结果发现曲线高高低低,完全拿不准到底算不算收敛。后来我改成了“每隔固定回合数冻结策略并跑一整轮完整回合”的评估方式,数据一下就干净了不少。

具体做法是,每训练一定的步数就保存当前在线网络的参数,然后用epsilon=0或很小的值去跑若干个完整回合,把总回报平均值记录下来。注意评估时要禁用网络的梯度计算,推理阶段不需要反传,开着梯度只会白白浪费算力,还可能因为显存不足导致奇怪的问题。

评估回合数我一般取5到10个。太少,样本方差太大;太多,评估时间反而超过了训练时间,低频任务里特别不划算。

4.2 目标网络更新频率和步数设置

目标网络更新频率C是个容易出问题的参数。C太小,比如100步,目标网络太靠近在线网络,两套网络的独立性就没了,DDQN抑制过估计的效果大打折扣。C太大,比如5万步,在训练初期目标值长期落后,损失函数会出现明显的跳变。

我在一个环境里曾经把C设成100,结果跑出来的曲线和DQN几乎没有区别。后来把数据拿出来看,发现目标网络和在线网络参数高度接近,相当于DDQN退化成了一份包含目标网络噪声的DQN——这个结果很误导人,一度让我以为DDQN没什么用。

C的合理范围和环境的回合长度相关。如果你的环境一个回合平均200步,那么C取500到2000步比较合理。为什么和回合长度相关?因为过估计的积累主要发生在同一回合内的连续决策中,目标网络至少应该能跨过一个完整回合的更新周期,摸到更高层的回报信号。

4.3 对比DQN时容易犯的“不公平比较”

很多文章会说DDQN比DQN好,但如果你自己动手复现,要特别注意实验条件是否完全一致。如果两者用了不同的随机种子、不同的评估逻辑、甚至不同的回放池容量,对比结果根本不可信。

我自己习惯的做法是:所有对照实验使用同一组随机种子数组,比如五个种子,跑完后取平均值和标准差。每一步评估也使用相同的间隔和相同的评估回合数。你甚至可以每个种子下把初次探索的随机种子固定,只在训练过程中让epsilon贪心机制引入不确定性。

另外,很多DQN实现里其实顺手做了DDQN的改动,只是作者没有明确说。比如有些开源项目在计算标准DQN目标时,已经用了“在线网络选动作、目标网络算Q值”的方式,只是名字仍然写作DQN。这种情况下你拿它和一份真正原始版DQN对比,自然看不出差别。对比之前先看清楚双方的目标值计算代码。

4.4 收敛后突然崩塌的一个隐藏推手

我遇到过程序在中后期突然崩塌的情况,一开始怀疑是过估计死灰复燃。后来反复查证,发现绝大多数时候问题出在学习率和batch大小上。

训练后期,在线网络参数越来越接近稳定解,此时如果学习率依旧保持不变,梯度的绝对量又因为误差降低而变小,整体更新会处于一个微妙平衡。一旦batch里随机采样到一批TD误差特别大的样本,比如回放池里混入了早期的高偏差样本,一轮更新就可能把好不容易学到的最优策略推出“安全区”。

这不是环境bug,也不是算法失效,而是数据和优化过程的天然碰撞。应对办法有三个方向:一是尝试把学习率从3e-4降到1e-4;二是把batch size从64调到128,让样本分布更平滑;三是给梯度做裁剪,限制单次更新步长。我在多次实验里发现,轻度梯度裁剪对DDQN尤其友好,因为抑制过估计的本质就是避免Q值在某个方向上步进过大。

5. 从DDQN出发:后续改进的演进地图

5.1 Dueling DQN:架构上的解耦

理解了DDQN把“选动作”和“算价值”解耦之后,再看Dueling DQN就顺畅得多。Dueling DQN不再修改目标值的计算方式,而是修改网络的结构:把Q值拆成状态价值V(s)和动作优势A(s,a)之和。

这样做的好处是,在某些状态下,无论选择哪个动作,价值差距其实不大。传统网络需要为每个动作都精确拟合Q值,浪费了表示能力;而Dueling结构可以单独学习状态本身的价值,把“这个状态到底值不值得停留”和“在这个状态里哪个动作相对更好”分开建模。

我通常会把Dueling结构和DDQN直接组合,这在主流研究社区里几乎是默认做法了。网络输出层分为两路,一路输出V(s),一路输出每个动作的优势值A(s,a),最后用公式Q(s,a) = V(s) + A(s,a) - mean(A)合并。减去优势均值是为了解决V和A同时偏移引起的不确定性。

5.2 优先经验回放与DDQN的组合

DDQN解决了目标值偏差,优先经验回放解决的是样本利用效率。它在采样时不再均匀取样本,而是更倾向于取TD误差大的样本。因为这些样本意味着在线网络的预测离目标还远,更新收益更高。

如果你把TD误差当作“网络哪里还不懂”的度量,那么优先经验回放的直觉就非常像一个学生拿着错题本复习——专门挑不会的题练。

和DDQN组合时需要注意一个小坑:优先经验回放对TD误差越高的样本给越高的权重,会导致更新分布偏离真实分布,所以需要用重要性采样权重去修正梯度方向。修正系数通常是一个随训练进度从1逐渐降到0的beta参数。如果不做这一步,高频访问的高偏差样本会导致局部过拟合,反而损害最终的收敛结果。

5.3 Rainbow:把六大改进塞进一个框架

DDQN的思路后来被吸收进了一个更大的方案,也就是通常称为Rainbow的算法家族。它把六种经过验证的改进融合在一起:DDQN的目标解耦、优先经验回放的样本加权、Dueling的网络结构、多步回报估计、分布式Q值学习,以及噪声探索机制。

你不需要一开始就全部实现,但每个模块解决的是不同维度的问题,理解它们各自的作用后,你不妨按照自己的场景挑选组合。比如动作空间大但奖励不稀疏的场景,可以优先考虑DDQN加Dueling;如果主要瓶颈是样本效率低、训练太慢,那么多步回报和优先经验回放可能比单纯调参更有用。

有一点我想提醒:多个改进叠加之后,单个模块的贡献会变得很难隔离。想定位问题时,最好的办法是先跑通一个最朴素的DQN基线,再一项一项往上加。所有改进都完成后,你会得到一个比DDQN强大得多的组合版本,但每一步的调试难度也随之上升。不要指望一口气跳到最后一步。

在项目实践里,我一般把DDQN当作一个“稳定性检查工具”:如果某个新想法让DQN变得不稳定,先套上DDQN的目标计算逻辑,再继续排查。这种小细节有时候比试各种花哨的网络结构更管用。希望这篇笔记能帮你少走一些弯路,也期待你在自己的实验里看到一个更平滑的训练曲线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询