ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

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 减 10,Q 完全不变。这会导致训练不稳定,所以实践中要强制约束,最常用的是减去 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×32
Conv220×20×324×46429×9×64
Conv39×9×643×36419×9×64
FC5184-512-512

这个结构是 DQN 系列的标配,参数规模适中,在单张 GTX 级别显卡上就能跑。卷积部分的 padding 要注意,第三个卷积若要保持 9×9 输出必须加 padding=1,否则 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(dim=1, keepdim=True)

keepdim=True 这一步容易漏,漏掉后 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 → 64,V 分支 64 → 1,A 分支 64 → 2。

逐层计算:

  1. 输入层到共享隐藏层:4 × 64 + 64 = 320
  2. V 分支:64 × 1 + 1 = 65
  3. A 分支:64 × 2 + 2 = 130

总参数量 = 320 + 65 + 130 = 515。

如果把共享层做深一点,改成两层 64:

  1. 输入到共享隐藏层 1:4 × 64 + 64 = 320
  2. 共享隐藏层 1 到共享隐藏层 2:64 × 64 + 64 = 4160
  3. V 分支:64 × 1 + 1 = 65
  4. A 分支:64 × 2 + 2 = 130

总参数量 = 320 + 4160 + 65 + 130 = 4675。

注意第二层的 4160 是绝对大头,占了快 90%。这也是为什么很多轻量级实现里把隐藏层砍到 32,因为 64 和 32 的强化学习效果差别远不像监督学习那么明显,参数却少一大半。

扩展到 LunarLander,状态维度 8、动作 4、共享层 [128, 128]:

  1. 输入到隐藏层 1:8 × 128 + 128 = 1152
  2. 隐藏层 1 到隐藏层 2:128 × 128 + 128 = 16512
  3. V 分支:128 × 1 + 1 = 129
  4. A 分支:128 × 4 + 4 = 516

总参数量 = 1152 + 16512 + 129 + 516 = 18309。

这套结构在 LunarLander 上实测训练到 200 分以上很轻松,四个数字记下来,跟人讨论网络规模时直接报“大概 1.8 万参数”,对方就知道你的网络轻量级到什么程度了。

3.3 卷积网络的参数计算实例(Atari)

Atari 用的 CNN 结构参数计算稍微复杂,但逻辑一样。以输入 84×84×4、动作数 4、全连接层 512 为例:

  1. Conv1:8×8×4×32 + 32 = 8224
  2. Conv2:4×4×32×64 + 64 = 32832
  3. Conv3:3×3×64×64 + 64 = 36928
  4. 展平层无参数
  5. FC:5184 × 512 + 512 = 2654720
  6. V 分支:512 × 1 + 1 = 513
  7. A 分支:512 × 4 + 4 = 2052

总参数量:

8224 + 32832 + 36928 + 2654720 + 513 + 2052 = 2735269

约 273 万参数。可以看出全连接层占了绝大多数,三个卷积层加起来不到 8 万。如果需要对卷积层输出尺寸作自检,套公式:

output_size = (input_size + 2×padding - kernel_size) / stride + 1

Conv1:(84 - 8) / 4 + 1 = 20;Conv2:(20 - 4) / 2 + 1 = 9;Conv3:(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_dim=128): 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, keepdim=True) return q

这个类本身就是完整的网络定义,可以用sum(p.numel() for p in model.parameters())直接验证第三节手算的参数个数。用这个代码传入 state_dim=8, action_dim=4, hidden_dim=128,跑一下输出应该是 18309,和手算完全一致。

D3QN 只是这个类实例化两份,一份叫 online,一份叫 target。目标网络初始化直接target.load_state_dict(online.state_dict())。

4.2 Double 机制在训练循环中的具体落法

框架搭好了,训练循环里最关键的就是 target 值计算。很多人在这里写错,把它写成普通 DQN 的形式。我贴一份简化的训练循环,只留核心逻辑:

# online 网络选动作,target 网络估值 next_actions = online(next_state).argmax(dim=1, keepdim=True) 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(dim=1)那就退回普通 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硬拷贝则 N=2000 附近
经验池容量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 不收敛,大多数人第一反应是改网络结构,其实大部分问题出在数据侧。我的排查顺序是:

  1. 看 reward 归一化是否合理。reward 范围超过 10 倍波动,网络梯度容易爆炸,先做 clip 或标准化。
  2. 看经验池采样是否均匀。如果用的是普通随机采样,先试优先经验回放,往往立刻见效。
  3. 看 target 网络是否更新过快。软更新 τ 从 0.005 降到 0.001,硬拷贝 N 从 2000 调到 5000,有时能救回来。
  4. 看损失曲线。如果 loss 直接发散,检查目标值计算有没有用错网络;如果 loss 收敛但回报不涨,检查奖励信号本身有没有问题。

有一次我排查了很久,最后发现是 next_state 在 terminal 状态下还继续传进网络算了 0 乘到 Q 值上,等于没有正常截断。这个问题用代码检查很难看出来,我最后是打印了 done 为真时的 target_q,发现非零才知道。

网络结构本身要调整的情况相对少见,如果以上都试过还没解决,才建议回归到验证环境是否真的适合 D3QN。有些环境动作空间极大或者状态部分可观测,该上 PPO 或 RNN 的场景,硬套 D3QN 也巧妇难为无米之炊。

6. 个人实操心得收尾

DP3QN 用了大半年,最大的感受是它的参数结构设计直接影响训练行为。V 分支和 A 分支的宽度、深度,决定了网络是更偏向学习状态价值还是动作差异。简单任务上 A 分支可以比 V 分支少一层,复杂任务两者保持同规模更稳。参数量计算这件事,看着基础,实际排查问题时价值很大——你能快速预估显存、判断模型规模是否合理,还能通过手算发现代码里维度写错的地方。建议每搭一个新环境,都手动跑一遍参数量自检,跟打印结果对一下,比反复调参高效得多。计算规则就那些,真正宝贵的是踩过的坑,希望这篇能让你少走几步弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进