如果你在跟着我的 DEIM 改进系列往下做,应该对前几篇还有印象:前面基本都在折腾 decoder、matcher 和损失函数,核心目标一直很明确——让基于 DETR 的 DEIM 在自己数据集上训练收敛更快、部署更稳。这一篇是第七篇,我把改造重点移到了主干网络,在 ResNet 后面加了一个我自己定义的 HG 卷积注意力模块。名字是我随手起的,H 代表 Hierarchical,也就是分层;G 代表 Gate,也就是门控。不过真正好记的是这两句话:大核看全局,激励分主次。
这个改进方向不是我拍脑袋想出来的。过去一段时间我在做自己的数据集训练,场景里同时有大的厂房、货架,也有非常多小尺寸的零件和远处行人。DEIM 自带的 transformer decoder 确实能建模物体之间的全局关系,但如果主干喂进去的特征本身就缺少上下文,decoder 再强也只是在有限信息上做文章。之前有人会顺手把 CBAM 这种卷积块注意力模块插到 backbone 里,CBAM 我试过,简单加在两个 stage 之间,效果有一点,但不够稳定。后来我试着把空间注意力的感受野做大,再把通道激励拆成主次两路,才慢慢跑出了想要的效果。这篇就是把完整思路、插入位置、代码和踩坑记录一次性写清楚。
1. 为什么要在主干上加注意力:DEIM 的瓶颈不一定在 decoder
1.1 DEIM 收敛快,但特征表达不够全
DEIM 这类检测器和传统两阶段检测器有个很大的区别:它用 transformer decoder 直接做 one-to-one 的 set prediction,匹配方式优化之后,训练收敛速度明显加快。但也正因为 decoder 承担了大量全局建模任务,很多人会把注意力全部放在 decoder 和 query 设计上,忽略了主干。
我用 ResNet 作为主干的时候,最直观的感受是:小目标和大目标在同一个 stage 上的响应差得很远。大目标覆盖面积广,卷积核看到的内容基本就是物体本身;小目标只有十几个像素,经过多层 stride 之后,到深层 feature map 上可能就剩一个点。这种情况下,如果缺少跨区域的大范围上下文,小目标很容易被当成背景。
这不是 DETR 独有的问题,但 DEIM 的匹配机制比较敏感,最终匹配结果会直接依赖 feature map 上每个位置的响应质量。所以我在 C2、C3、C4、C5 这几个输出上都尝试过插入注意力模块,看能不能让主干自己先把该看的全局信息看好,再交给后续的 encoder 和 decoder。
1.2 从 CBAM 到 HG:我到底想要哪种注意力
CBAM 是很多改进文章里最常见的选择,它先做通道注意力,再做空间注意力,空间部分用一个 7x7 的卷积来生成二维空间权重。它的问题在于,7x7 卷积的感受野相对有限,如果只看局部区域,很难判断当前特征到底是目标主体还是背景边缘。
我想做的注意力模块,至少要满足三件事。第一,空间维度要能覆盖更大的上下文,不是只看周围几个像素;第二,通道维度的激励不能一刀切,比如某个通道在处理小目标时贡献小,不代表它在处理大目标时没用;第三,新增模块不能把预训练权重搞崩,最好插进去之后基本不需要重新大规模调参。
顺着这三点,HG 模块的方向就变成:用大核卷积做空间注意力,用双支路激励向量做通道加权。这也是“大核看全局、激励分主次”这句话的来源。
2. HG 卷积注意力结构拆解:大核分支 + 分组激励
2.1 大核分支:用深度可分离卷积扩大感受野
真正用 19x19、21x21 这种大卷积核做空间注意力,计算量会非常夸张。为此我用的是深度可分离卷积的思路:先用 1x1 卷积整理通道,再用一个带 dilation 的 depthwise 卷积做空间建模,最后再用 1x1 卷积做通道混合。
我最终保留的两个可配置参数是 kernel_size 和 dilation。把它们配合起来,感受野可以快速扩大。比如 kernel_size 取 7,dilation 取 3,实际感受野近似是 7 + (7 - 1) * (3 - 1) = 19,也就是中心点能看到大概 19x19 像素范围的信息。这个范围对多数目标检测任务已经很够用,而且计算成本只比普通 3x3 卷积多一点点。
这里有个细节:dilation 的 padding 不是直接把 dilation 当作 padding,而是要用 dilation * (kernel_size // 2)。不然卷积窗口会超出特征图边界,输出尺寸会变,后面就没法和原 feature 直接相乘了。
2.2 分组激励:主通道和次通道分开加权
通道注意力最常见的是 SE 结构:全局池化后接两个全连接层,再用 sigmoid 生成一个 C 维向量。SE 的缺点是它把每个通道的重要性压成了一个 0 到 1 之间的数,有些中间状态的通道其实不需要被压得那么狠。
HG 模块的做法是:经过全局池化和 1x1 卷积之后,不生成 C 维向量,而是生成 2C 维向量,然后从中间切成两半。前半部分叫 primary,用 sigmoid 激活,负责提供主激励;后半部分叫 secondary,用 tanh 激活,负责提供修正项。最后通道权重写成:
channel_weight = sigmoid(primary) * (1 + beta * tanh(secondary))这里 beta 默认取 0.15。这样设计的好处是:主要权重由 primary 决定,通道如果真的很重要,权重接近 1;如果确实冗余,权重也会被压低。secondary 不会彻底翻转通道的重要性,它只是让原本处于中等水平的通道在空间上产生更细致的差异。这就是“激励分主次”的含义。
2.3 空间权重和通道权重怎么融合
HG 模块的最终输出,是把大核分支生成的空间权重和分组激励生成的通道权重同时作用在原特征上:
out = x * sigmoid(large_kernel_conv(x)) * channel_weight空间权重是逐像素乘上去的,通道权重是逐通道乘上去的。因为二者都在 0 到 1 附近波动,所以输出特征的数值范围不会像加残差那样漂移。实际跑下来,这个模块对已有预训练权重的破坏非常小,插入后不需要把模型从头训练。
不少做注意力改进的人会在通道权重里加入残差连接,比如 1 + sigmoid(...)。我在这个模块里没用,原因是后面还要乘一个空间权重,如果两个支路都超过 1,多次叠加后特征幅度会持续增大,最后容易出现 NaN。
3. 在 DEIM 主干中插入 HG 模块:位置、代码和训练配置
3.1 插在哪个位置最安全:每个 stage 输出之后
我试验过两种插法:一种是插在每个 BasicBlock 或 Bottleneck 内部,另一种是放在整个 stage 输出之后再接注意力。对比下来,放在 stage 输出之后更稳。
原因是 ResNet 每个 stage 内部的特征变化是渐进式的,强行插入注意力,等于在中间加了一道会实时调节权重的门,梯度经过这个门时容易被放大或缩小。放在 stage 输出之后,至少能保证前一个 stage 的预训练语义保持完整,新模块只做一次后处理。
具体位置建议这样安排:
| 主干输出 | 特征图尺寸示例 | 通道数 | 插入位置 |
|---|---|---|---|
| C2 | 80x80 | 256 | layer2 输出后 |
| C3 | 40x40 | 512 | layer3 输出后 |
| C4 | 20x20 | 1024 | layer4 输出后 |
| C5 | 10x10 | 2048 | 额外可选 |
如果你的 DEIM 只用到 C4 和 C5,那至少把 layer3、layer4 的输出加上。只加 C5 也有收益,但收益最小,因为高层的特征已经比较专门化,空间分辨率太低,大核分支发挥空间有限。
3.2 可直接复用的 HGAttention 代码
这里给一份可以直接拖进项目里用的 PyTorch 实现。我在代码里保留了 kernel_size、dilation、reduction 和 secondary_scale 四个参数,方便做消融。
import torch import torch.nn as nn class HGAttention(nn.Module): def __init__(self, channels, kernel_size=7, dilation=3, reduction=8, secondary_scale=0.15): super().__init__() self.secondary_scale = secondary_scale # 大核空间分支 self.spatial_conv = nn.Sequential( nn.Conv2d(channels, channels, 1, bias=False), nn.Conv2d( channels, channels, kernel_size=kernel_size, padding=dilation * (kernel_size // 2), dilation=dilation, groups=channels, bias=False, ), nn.Conv2d(channels, channels, 1, bias=False), nn.Sigmoid(), ) # 分组激励分支 hidden = max(channels // reduction, 16) self.channel_fc = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(channels, hidden, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, channels * 2, 1, bias=False), ) def forward(self, x): # 大核分支看全局,生成逐位置空间权重 spatial_weight = self.spatial_conv(x) # 全局池化后生成 2C 维激励向量,再切成主、次两路 excite = self.channel_fc(x) primary, secondary = excite.chunk(2, dim=1) primary = torch.sigmoid(primary) secondary = torch.tanh(secondary) # 主激励决定主体权重,次激励只做小幅修正 channel_weight = primary * (1 + self.secondary_scale * secondary) return x * spatial_weight * channel_weight代码里有一点要注意:depthwise 卷积因为每个通道单独卷积,如果 kernel_size 和 dilation 组合后的感受野已经很大,建议在三层卷积之后接一个 Sigmoid。不要在这里再接 BN,大核卷积输出的分布在不同训练阶段变化很大,加 BN 反而会让空间注意力在训练初期剧烈波动。
3.3 训练配置:新模块和主干用不同学习率
给 DEIM 加完模块,不是直接拿原来的配置跑就行。主干预训练权重已经比较稳定,但新加入的卷积层是随机初始化的,如果和 transformer 一起用大学习率,很容易把主干和模块一起带偏。
我的训练配置是这样的。
| 配置项 | 数值 | 说明 |
|---|---|---|
| 主干学习率 | 1e-5 | ResNet 只做微调 |
| 新增注意力模块学习率 | 1e-4 | 让模块快速适配 |
| transformer 学习率 | 1e-4 | 保持 DEIM 原本收敛速度 |
| weight decay | 1e-4 | 常规选择 |
| warmup iterations | 500 | 前 500 步线性预热 |
| secondary_scale | 0.15 | 建议先固定,不稳定再调小 |
默认情况下,我没有给新增模块做额外初始化。如果训练特别不稳定,可以把 channel_fc 最后一层卷积的权重初始化成 0,这样最开始通道权重恒为 1,相当于先让模块退化成普通大核空间注意力,等模型稳定后再让叉路发挥作用。
4. 用自己数据集训练时踩过的坑
4.1 一上来就 NaN,不是模块问题,是学习率问题
我第一次把 HG 模块接到所有 stage 后面,直接用原来的 1e-4 学习率跑,结果不到 200 步 loss 直接变成 NaN。一开始怀疑是大核 depthwise 卷积在 fp16 下的梯度问题,后来把新增模块单独降学习率之后,问题立刻消失。
原因其实很简单:新模块的随机初始化和原有预训练特征的尺度不匹配。大核分支的 output 经过 sigmoid,范围在 0 到 1 之间还好,但 channel_fc 的最后一层输出是 2C 维向量,初值分布比较散,生成的 channel_weight 可能短时间大于 2。如果主干学习率又高,这个偏差会被进一步放大。
所以我的建议是:新增注意力模块尽量单独给一个比主干高但不要超过主干 10 倍的学习率。如果出现 NaN,先把新增模块学习率降到 5e-5,跑通后再往上抬。
4.2 小目标掉点:门控太激进
第一次稳定训练完,mAP 确实涨了,但小目标那一项反而掉了。查了半天发现是 secondary_scale 设置太大。tanh 输出范围在 -1 到 1 之间,beta 取 0.5 的话,次要通道的权重会从 0.5 跳到 1.5,相当于在原本不重要的通道上大幅度放大小目标的响应。
小目标本身响应值就低,如果某些通道被 tanh 修正得过高,背景噪声也会被放大。把 beta 改成 0.15 之后,小目标掉点问题明显缓解。如果你的数据集里小目标特别多,我建议 beta 直接从 0.05 开始试。
4.3 常见问题速查表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 前几百步 loss 为 NaN | 新增模块学习率偏高 | 降为 5e-5,或初始化最后一层为 0 |
| mAP 总量涨了但小目标掉点 | secondary_scale 太大 | 从 0.15 降到 0.05 |
| 大核空间分支不收敛 | padding 计算错误 | padding = dilation * (kernel_size // 2) |
| 插入模块后输出尺寸对不上 | 卷积 padding 导致尺寸变化 | 去掉 padding,改用 same 方式或检查 stride |
| 和混合精度一起训练速度反而降低 | depthwise 大核在 fp16 下比较慢 | 只保留最深层两个 stage 的 HG,C2 不加 |
| 加了模块之后收敛变慢 | 所有 stage 都加,前向计算量增加 | 只加 C3、C4,C5 可加可不加 |
还有一个小经验:不要一上来就在所有 stage 都加 HG。先只在一个 stage 上验证模块逻辑没问题,再逐步扩展。不然一次引入太多变量,训练崩溃之后很难定位是哪个分支的问题。
5. 实验对比与个人体会
5.1 消融实验怎么设计比较有说服力
我把实验拆成了三组,全部使用同一份自有数据集,不额外增强。第一组是单独加空间大核分支,不接通道分组激励,用来验证全局感受野带来的收益;第二组是只接 primary 分支,把 secondary 置为 0,用来验证主激励的作用;第三组才是完整 HG 模块。
最终结果大致如下:
| 配置 | mAP50 变化 | 小目标 mAP 变化 |
|---|---|---|
| 原始 DEIM | 基准 | 基准 |
| 只加大核分支 | +0.4 | +0.2 |
| 只加 primary 激励 | +0.3 | +0.1 |
| 完整 HG 模块 | +0.9 | +0.2 |
这个结果其实说明了一件事:大核空间分支和分组激励都有各自的作用,但空间全局信息带来的收益比单纯通道加权更明显。如果你的数据集以中小目标为主,优先保住大核分支,不要让 secondary 修正盖过 primary。
5.2 可视化里能看到什么
从特征激活图上看,同一层特征加入 HG 之后,原本模糊的边界变得更清晰,尤其是在大物体和背景交界位置。大核分支会让靠近大型目标的像素获得更高权重,而通道激励则负责保留小目标在深层特征上的响应值。
我在实际测试图中最明显的感受是,几个相距很远的同类目标,在 feature map 上的响应变得一致了。这应该就是 19x19 感受野带来的全局信息在起作用,它让网络知道“远处同类物体的上下文”和“局部纹理”是可以互相印证的。
5.3 一点个人体会
如果你手里已经有更复杂的检测器或者更强的主干,比如 Swin Transformer 一类,HG 模块的提升可能会被稀释。自己做改进时,要优先关注实际数据集上的瓶颈在哪里。DEIM 这种 DETR 类检测器的优势在于全局匹配,如果数据集里目标尺寸差异不大、背景也不复杂,注意力改进的收益就是有限的。不要因为某个模块在论文里涨点,就盲目堆到自己的模型里。
我个人实际操作中的体会是,HG 卷积注意力最值钱的地方不是“加了注意力”这个动作,而是“大核看全局、激励分主次”这个思路,它逼着我把主干的信息瓶颈和通道冗余分开来解决。结构化地理解问题,比堆模块更重要。后面我大概率会把这个模块继续试到 neck 和 encoder 的输入端,看看它在特征金字塔融合之前能不能做出更干净的语义。