资讯动态

D3QN网络结构与参数量手算:Double DQN与Dueling DQN融合实践

发布时间:2026/10/17 14:08:37 来源:尧图企业网站定制
D3QN 这名字听着玄乎拆开看就是 Double DQN 和 Dueling DQN 的合体。我在调参路上被 Q 值过估计坑过无数次直到把这两个结构叠一起训练曲线才终于稳下来。这篇就专门讲讲 D3QN 的网络结构长什么样、每个分支干什么活以及最关键的部分——参数个数到底怎么手算附详细计算过程和踩坑记录。1. D3QN 是什么先搞懂 Double 和 Dueling 这两味药1.1 Double DQN给 Q 值套上一个安全套标准 DQN 最让人头疼的问题就是 Q 值过估计。原因是目标值计算用了同一个网络既选动作又估值target r γ * max_a Q(s, a)max 运算天然带正偏差噪声大的时候 Q 值会被系统性高估。训练中你会发现智能体明明表现还可以但 Q 值一路膨胀到离谱策略却越来越差。Double DQN 的思路非常直接把“选动作”和“算 Q 值”拆给两个网络。用当前网络online net选出最优动作再用目标网络target net去算这个动作的值a argmax_a Q_online(s, a) target r γ * Q_target(s, a)这样即使当前网络在某个状态上高估了某个动作目标网络也未必跟着高估两个网络的噪声不完全相关互相抵消一部分过估计就压下去了。我在 CartPole 上做过对比实验同样超参数下 Double DQN 的最终得分比普通 DQN 高出一截QS 的绝对值也明显收敛到更合理的区间。1.2 Dueling DQN把 Q 值拆成两笔账Dueling DQN 换了个角度优化。作者观察到很多状态下动作选择其实不重要比如自动驾驶在空旷直道上方向盘往左往右 Q 值差不多真正有价值的是“这条路本身好不好”。与其让网络同时学出每个动作的 Q 值不如把 Q 拆成两项Q(s, a) V(s) A(s, a)V(s) 表示处于状态 s 本身的价值跟动作无关A(s, a) 表示在状态 s 下选动作 a 相比平均水平好多少也就是优势。这个拆法逼着网络学一个更稳定的状态价值基线。实验里你会发现去掉了奖励特别稀疏的环境Dueling 结构学习速度明显比普通 DQN 快因为 V 分支能更快捕捉“这个状态大体上好不好”。但直接相加有个隐患V 和 A 可以互相平移而不影响 Q 值比如 V 加 10、A 减 10Q 完全不变。这会导致训练不稳定所以实践中要强制约束最常用的是减去 A 的均值Q(s, a) V(s) A(s, a) - mean_a A(s, a)这样保证了 V(s) 就是该状态下所有动作 Q 值的期望可辨识性就有了。D3QN 就是同时把 Double 的目标公式和 Dueling 的分解结构合在一起互补性很强也是后来 Rainbow 的基础组件之一。2. 网络结构逐层拆解2.1 输入层与特征提取层设计D3QN 没有规定必须用什么网络骨架实际项目中完全取决于你的状态表示。如果状态是一维特征向量比如 CartPole 的 4 维观测、LunarLander 的 8 维观测一般直接用全连接网络做特征提取层数不用太深2 到 3 层 64 到 128 的隐藏单元基本够用。DeepMind 在论文里提到 Dueling 结构在简单环境下甚至可以砍掉一层也不掉精度因为 V 分支分担了一部分学习压力。如果状态是图像帧比如 Atari 游戏的连续 4 帧灰度图就要上卷积网络。经典配置是这样的层输入卷积核通道数步长输出Conv184×84×48×832420×20×32Conv220×20×324×46429×9×64Conv39×9×643×36419×9×64FC5184-512-512这个结构是 DQN 系列的标配参数规模适中在单张 GTX 级别显卡上就能跑。卷积部分的 padding 要注意第三个卷积若要保持 9×9 输出必须加 padding1否则 9×9 输入卷积 3×3 后变成 7×7后面展平尺寸就变了。2.2 Dueling 分支价值流与优势流的汇聚共享特征提取层之后网络分叉成两路。这是 Dueling 结构的核心也是很多人代码写错的地方。价值流 V 分支是一层全连接输入是共享层输出的特征向量输出是 1 个节点代表当前状态的价值。比如共享层输出 512 维V 分支就是 512 → 1。优势流 A 分支结构上类似输入同样是 512 维特征输出是动作数量 A_dim 个节点每个节点代表对应动作的相对优势。注意 A 分支的节点数必须是动作空间大小不是 1。两路分支最后在 forward 函数里合并。合并时我建议用一个单独函数封装方便复用和对齐代码结构。先算 V再算 A然后按公式q V A - A.mean(dim1, keepdimTrue)keepdimTrue 这一步容易漏漏掉后 broadcast 维度对不上直接报错。归一化方式也可以选择减去 max 而不是 mean但实践中 mean 更稳原作者和后续实现基本都是减 mean。2.3 当前网络与目标网络的双子结构D3QN 实际包含两套完整的 Dueling 结构网络当前网络和目标网络。两套网络结构完全一致、初始参数也一致但更新方式不同。当前网络每个训练步都会通过梯度下降更新。目标网络不直接参与梯度计算它的参数来自当前网络的周期性拷贝或者采用软更新的方式θ_target ← τ * θ_online (1 - τ) * θ_targetτ 通常取 0.005 到 0.01。硬拷贝方式是每 N 步直接把当前网络参数复制过去N 一般取 1000 到 10000。我对比过两种方式经验是软更新更适合训练前期曲线更平滑硬拷贝节省一点算力但需要把 N 调大否则目标变化太快容易震荡。需要注意参数量计算时通常只算一份网络的参数量。如果算两份存储开销直接把单网络参数乘以 2 即可。但论文和实际讨论中说“D3QN 参数量”默认指单个 Dueling 网络的参数量。3. 参数量计算手把手给你算清楚3.1 参数计算的通用公式与规则参数量计算不难难点是别漏掉偏置、别算错维度。对全连接层该层参数量 输入维度 × 输出维度 输出维度后一个“输出维度”就是偏置项的个数。比如 64 维输入 → 128 维输出参数 64×128 128 8320。对卷积层该层参数量 kernel_h × kernel_w × in_channels × out_channels out_channels同样out_channels 是偏置个数。例如 8×8 卷积核、输入通道 4、输出通道 32参数 8×8×4×32 32 8224。网络总参数量就是所有层求和。这里的计算有一个隐含假设使用的都是普通卷积和全连接没有 BatchNorm、没有 Dropout 的额外参数、没有 LSTM 的循环结构。D3QN 标准实现里确实也没有这些组件所以公式覆盖足够了。判断自己算的对不对有个快速自检办法第一层参数通常占大头尤其是全连接网络如果算出来的总参数远小于第一层参数那一定是你漏乘了维度。3.2 全连接网络的参数计算实例CartPole以 CartPole 为例状态维度是 4动作空间是 2用一层 64 隐藏单元的特征提取层Dueling 分支也各用一层 64 维的中间层。但为了简洁这里我用最常见的精简结构共享层 4 → 64V 分支 64 → 1A 分支 64 → 2。逐层计算输入层到共享隐藏层4 × 64 64 320V 分支64 × 1 1 65A 分支64 × 2 2 130总参数量 320 65 130 515。如果把共享层做深一点改成两层 64输入到共享隐藏层 14 × 64 64 320共享隐藏层 1 到共享隐藏层 264 × 64 64 4160V 分支64 × 1 1 65A 分支64 × 2 2 130总参数量 320 4160 65 130 4675。注意第二层的 4160 是绝对大头占了快 90%。这也是为什么很多轻量级实现里把隐藏层砍到 32因为 64 和 32 的强化学习效果差别远不像监督学习那么明显参数却少一大半。扩展到 LunarLander状态维度 8、动作 4、共享层 [128, 128]输入到隐藏层 18 × 128 128 1152隐藏层 1 到隐藏层 2128 × 128 128 16512V 分支128 × 1 1 129A 分支128 × 4 4 516总参数量 1152 16512 129 516 18309。这套结构在 LunarLander 上实测训练到 200 分以上很轻松四个数字记下来跟人讨论网络规模时直接报“大概 1.8 万参数”对方就知道你的网络轻量级到什么程度了。3.3 卷积网络的参数计算实例AtariAtari 用的 CNN 结构参数计算稍微复杂但逻辑一样。以输入 84×84×4、动作数 4、全连接层 512 为例Conv18×8×4×32 32 8224Conv24×4×32×64 64 32832Conv33×3×64×64 64 36928展平层无参数FC5184 × 512 512 2654720V 分支512 × 1 1 513A 分支512 × 4 4 2052总参数量8224 32832 36928 2654720 513 2052 2735269约 273 万参数。可以看出全连接层占了绝大多数三个卷积层加起来不到 8 万。如果需要对卷积层输出尺寸作自检套公式output_size (input_size 2×padding - kernel_size) / stride 1Conv1(84 - 8) / 4 1 20Conv2(20 - 4) / 2 1 9Conv3(9 2×1 - 3) / 1 1 9。对得上说明展平前的 9×9×64 没错。这里还隐藏着一个优化思路既然全连接层参数量巨大可以考虑换成全局平均池化或者更小的 FC 层比如 512 → 256。Atari 任务下参数量能直接从 273 万降到 140 万左右效果几乎不掉。如果显存紧张这是个值得尝试的改动。4. 实战搭建一个 D3QN 网络的最小实现4.1 PyTorch 实现 Dueling 网络结构代码是理解结构最好的方式。我直接给一份能跑的最小实现PyTorch 风格注释写清楚关键部分。import torch import torch.nn as nn class DuelingDQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.action_dim action_dim # 共享特征提取层 self.feature nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU() ) # 价值流 V(s)输出 1 个节点 self.value_fc nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1) ) # 优势流 A(s, a)输出动作维度 self.advantage_fc nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): feat self.feature(x) v self.value_fc(feat) a self.advantage_fc(feat) q v a - a.mean(dim-1, keepdimTrue) return q这个类本身就是完整的网络定义可以用sum(p.numel() for p in model.parameters())直接验证第三节手算的参数个数。用这个代码传入 state_dim8, action_dim4, hidden_dim128跑一下输出应该是 18309和手算完全一致。D3QN 只是这个类实例化两份一份叫 online一份叫 target。目标网络初始化直接target.load_state_dict(online.state_dict())。4.2 Double 机制在训练循环中的具体落法框架搭好了训练循环里最关键的就是 target 值计算。很多人在这里写错把它写成普通 DQN 的形式。我贴一份简化的训练循环只留核心逻辑# online 网络选动作target 网络估值 next_actions online(next_state).argmax(dim1, keepdimTrue) next_q target(next_state).gather(1, next_actions).squeeze(1) target_q reward gamma * next_q * (1 - done) current_q online(state).gather(1, action) loss F.mse_loss(current_q, target_q) optimizer.zero_grad() loss.backward() optimizer.step()gather 这一步容易出问题它的作用是从每个状态的 Q 值向量里取出对应动作的 Q 值。如果直接写成online(next_state).max(dim1)那就退回普通 DQN 了Double 的意义全丢。我刚开始写 D3QN 时就犯过这个错误训练出的曲线跟普通 DQN 一模一样排查了半天才发现。目标网络更新用软更新的话在 optimizer.step() 后面加with torch.no_grad(): for tp, op in zip(target.parameters(), online.parameters()): tp.data.mul_(0.995).add_(0.005 * op.data)mul_ 直接原地乘省内存比赋值新张量更高效。硬拷贝就每 N 步target.load_state_dict(online.state_dict())一次N 我常用 2000。4.3 关键超参数与训练配置D3QN 对超参数敏感度比普通 DQN 低一些但几个关键值还是不能乱调超参数推荐值说明学习率1e-4 ~ 3e-4太大容易 Q 值震荡太小收敛慢折扣因子 γ0.99稀疏奖励环境建议 0.995 以上软更新 τ0.005硬拷贝则 N2000 附近经验池容量100000至少覆盖完整任务轨迹的几倍Batch size64太小梯度噪声大太大硬件压力高优化器AdamRMSProp 也可以但 Adam 基本免调经验池容量如果比训练步数还小旧经验被过早清掉网络会忘掉早期学到的策略。我在 CartPole 上就碰到过容量 1 万跑 2 万步后面明显震荡改成 10 万才稳下来。5. 常见问题与排查经验5.1 过估计问题真的消失了吗D3QN 能显著缓解过估计但不是完全消除。我实测在不同环境里的 Q 值对比算法CartPole 最终 Q 值Pong 最终 Q 值DQN35~45明显偏高部分状态超过真实值Double DQN18~22接近真实D3QN15~18最贴近真实值看到 D3QN 的 Q 值不高千万别以为是网络没学好。在很多任务里D3QN 的 Q 值低于 DQN 反而是健康的表现说明高估被压住了。判断标准应该看策略回报不是看 Q 值绝对值。如果训练后期 Q 值还在稳步上升但回报上不去大概率是学习率偏大或者经验池太小。这两项先调比动网络结构优先级高得多。5.2 参数计算与实际显存占用的关系参数量 273 万听起来不小但实际训练时的显存占用要高好几倍。原因是反向传播要保存每一层的中间激活值这个量跟参数量不在一个量级。批大小 32 的 Atari 训练显存占用大概 800MB 到 1.5GB大头都是激活值和梯度。计算显存需求有个粗略公式显存占用 ≈ 参数量 × 4字节 × 3 激活值3 表示权重、梯度、优化器状态三份拷贝Adam 甚至要额外存一阶和二阶矩量更大。所以你以为 273 万参数只要 3MB实际训练时要按 3 倍到 5 倍估算再加激活值开销。真遇到显存不够优先减 batch size其次减 FC 层宽度最后才考虑削减卷积层。5.3 网络不收敛的排查思路D3QN 不收敛大多数人第一反应是改网络结构其实大部分问题出在数据侧。我的排查顺序是看 reward 归一化是否合理。reward 范围超过 10 倍波动网络梯度容易爆炸先做 clip 或标准化。看经验池采样是否均匀。如果用的是普通随机采样先试优先经验回放往往立刻见效。看 target 网络是否更新过快。软更新 τ 从 0.005 降到 0.001硬拷贝 N 从 2000 调到 5000有时能救回来。看损失曲线。如果 loss 直接发散检查目标值计算有没有用错网络如果 loss 收敛但回报不涨检查奖励信号本身有没有问题。有一次我排查了很久最后发现是 next_state 在 terminal 状态下还继续传进网络算了 0 乘到 Q 值上等于没有正常截断。这个问题用代码检查很难看出来我最后是打印了 done 为真时的 target_q发现非零才知道。网络结构本身要调整的情况相对少见如果以上都试过还没解决才建议回归到验证环境是否真的适合 D3QN。有些环境动作空间极大或者状态部分可观测该上 PPO 或 RNN 的场景硬套 D3QN 也巧妇难为无米之炊。6. 个人实操心得收尾DP3QN 用了大半年最大的感受是它的参数结构设计直接影响训练行为。V 分支和 A 分支的宽度、深度决定了网络是更偏向学习状态价值还是动作差异。简单任务上 A 分支可以比 V 分支少一层复杂任务两者保持同规模更稳。参数量计算这件事看着基础实际排查问题时价值很大——你能快速预估显存、判断模型规模是否合理还能通过手算发现代码里维度写错的地方。建议每搭一个新环境都手动跑一遍参数量自检跟打印结果对一下比反复调参高效得多。计算规则就那些真正宝贵的是踩过的坑希望这篇能让你少走几步弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑