Deformable DETR:可变形注意力如何解决目标检测训练与小目标难题
2026/9/18 9:46:06 网站建设 项目流程

Transformer 进目标检测这条线,从 DETR 到 Deformable DETR,中间隔着一道特别现实的坎——训练成本。2020 年 DETR 出来的时候,圈子里几乎所有人都在夸它的框架干净:没有 anchor、没有 NMS、不需要手工设计先验,一个端到端的集合预测就把检测做完了。但真上手去复现的人会很快发现,官方配置里写的是 500 个 epoch,八卡跑起来差不多要一周,而且小目标的 AP 低得让人怀疑人生。

Deformable DETR 做的事情,本质上是给注意力机制装了一个"取景器":不让每个查询去看全图,而是只让它盯着自己关心的那 K 个采样点。这个改动把训练轮数从 500 压到了 50,小目标的表现也翻了身。这篇文章我会从注意力机制本身的计算账讲起,拆开 DETR 的两个瓶颈,再钻到可变形注意力的公式和实现细节里,最后给一套能真正跑起来的训练配置、排查链路和部署取舍。适合已经写过 CNN 检测器、想搞清楚 Transformer 检测这条线到底该怎么用的读者;如果你只是想了解大意,前面两节也能看懂。

1. 一个反直觉的起点:Transformer 在检测任务里最先被质疑的三件事

1.1 丢掉卷积的归纳偏置,为什么反而在检测上成立

卷积网络最大的优势是它自带先验:局部连接、权重共享、平移等变。这意味着模型不需要从数据里学"相邻像素关系更紧密"这件事,样本效率天然就高。Transformer 几乎没有这种先验,位置关系得靠位置编码硬塞进去,所以早期在分类任务上,ViT 必须先在超大规模数据上预训练,直接在小数据集上训是训不动的。

那为什么放到检测上,这套逻辑就变了?我的理解是,检测任务里的"先验"本来就有很大一部分是人工塞进去的,也就是 anchor。三个尺度、九种宽高比、几十个 anchor 点,这些超参本质上是在用人力去描述"目标可能长什么样"。DETR 把这部分手工先验拿掉,换成"每个查询是一个槽位,负责描述一个目标"这种更弱的约束,然后把描述目标这件事交给数据去学。代价是需要更多数据,收益是框架干净得多——这一点在后来的 DAB-DETR、DINO 上被进一步验证,它们把查询显式地解释成四维 anchor 之后,收敛速度又上了一个台阶。

另一个容易被忽略的点是:自注意力其实是一种"数据相关的动态卷积"。卷积核的权重在推理时是固定的,而注意力的权重是每次前向都根据输入内容重新算出来的。检测场景里,目标的大小、位置、遮挡关系变化极大,这种动态聚合能力确实比固定卷积核更合适。所以与其说 Transformer 丢掉了归纳偏置,不如说它把"局部性"换成了"内容相关性"。

1.2 自注意力的计算账:O(N²) 的代价换来的是全图视野

把账算清楚,后面很多设计选择就自动能解释通了。标准自注意力的核心是softmax(QK^T / sqrt(d_k)) V,其中 Q、K、V 的形状都是(N, d),N 是序列长度。QK^T这一步就是N × d × N次乘加,复杂度是 O(N²d);后面的加权求和又是 O(N²d);而生成 Q、K、V 的三个线性投影加起来是 O(Nd²)。

关键就在于这两个量谁更大。当 N 远大于 d 的时候,N²d 会迅速吃掉一切。拿 DETR 的典型输入算一下:800×1333 的图,走 stride 32 的骨干网络,特征图是 25×42 约 1050 个位置。这时候 N²d 大约是 1050²×256,也就是 2.8 亿量级的操作,而投影部分是 3×1050×256² 约 2 亿,两者还在一个数量级上。但如果换成 stride 16 的特征图,N 变成 4200,注意力的开销直接变成原来的 16 倍,而投影只涨 4 倍,差距就拉开了。

这解释了 DETR 那个看起来很奇怪的设计——它不但只用单尺度特征,还在骨干网络的 C5 后面额外加了一个空洞卷积模块,把有效步长从 32 拉到 32 且保持分辨率不涨。目的很直接:把 N 摁住。但代价也明摆着,一个 32×32 像素的目标,在这张特征图上只占一个格子,全局注意力再怎么聪明也没法从一个格子里恢复出细节。

内存的问题比算力更致命。注意力图QK^T的形状是(N, N),每个头都要存一份。N 取 10000 的时候,单头单样本就是 1 亿个浮点数,fp32 下 400MB。这就是为什么早期 Transformer 检测模型的分辨率上不去,也是 Deformable Attention 要解决的核心痛点。

1.3 位置编码与多头:两个最容易被当成"超参"糊弄过去的设计

位置编码在 Transformer 检测里不是可有可无的装饰。自注意力本身是置换不变的,你把输入序列打乱,输出只是跟着打乱,内容完全一样。检测任务要的恰恰是空间位置,所以位置信息必须显式注入。DETR 用的是固定的正弦位置编码,加在编码器输入上;解码器的对象查询则是一组可学习的嵌入向量。Deformable DETR 沿用了这套,但多了个新东西:因为要区分不同层级的特征,它额外给每个层级加了一个可学习的level_embed,直接加到 value 上,让模型知道"这一份特征来自哪个尺度"。

多头注意力则常常被当成调参项随便设成 8。它的实际作用是给模型多个独立的子空间,每个子空间可以关注不同的模式。在 Deformable DETR 里,多头还有一个更具体的作用——因为每个头只采 K 个点,如果所有头的采样位置重合,那就等于浪费。所以官方实现里专门设计了一个初始化技巧,让 8 个头一开始就朝不同方向看。这个技巧很多人根本不知道,但它对前几个 epoch 的收敛速度影响相当明显,我在第 3 节会贴出具体代码。

2. DETR 的骨架好看,但训练要 500 个 epoch 这件事绕不过去

2.1 二分图匹配损失:把检测改写成集合预测的那一步

DETR 最漂亮的地方在于它把检测输出定义成了一个集合,然后直接用集合之间的匹配来解决"哪个预测对应哪个真值"这个问题。具体做法是:模型固定输出 N 个预测(N 通常取 100),真值有 M 个,构造一个 N×M 的代价矩阵,用匈牙利算法求一个最小代价的一对一匹配。代价函数是分类概率的对数、边界框 L1 距离和 GIoU 三项的加权和。

匹配完成之后,损失就只在这 M 个匹配对上加:分类用交叉熵,框回归用 L1 加 GIoU。没被匹配上的预测,分类目标就是"空"这一类。这套机制直接消掉了重复框的问题——因为一对一匹配本身不允许两个查询同时负责同一个真值,所以 NMS 这个后处理彻底不需要了。

代价矩阵的具体形式是cost = -log p(c_i) + L1(box) + GIoU(box),损失的权重分别是分类 1、L1 为 5、GIoU 为 2。这里有个细节值得注意:匹配用的代价和反向传播用的损失不是同一个函数,匹配时用的是概率的对数,而训练损失里的分类项是交叉熵。这种"匹配和训练分离"的做法在很多后续工作里被沿用,因为它让匹配过程可以更灵活,不必对可导性有要求。

2.2 收敛慢的根因:训练初期注意力权重几乎是均匀的

500 个 epoch 这件事,官方论文里其实给过分析。他们可视化了不同训练阶段解码器的交叉注意力图,发现早期阶段注意力几乎均匀地铺在整个图像上,随着训练推进才逐渐聚焦到目标的头部、腿部这些有判别力的区域。也就是说,模型需要几十个 epoch 才能学会"该看哪里"。

为什么会这样?根因在于注意力的计算方式。softmax的输入是查询和键的点积,训练初期这些投影矩阵都是小随机数初始化的,点积的数值范围很窄,经过 softmax 之后分布就接近均匀。要让分布变尖锐,需要投影矩阵的数值增长好几个数量级,这个过程靠梯度慢慢磨,自然快不了。

还有一个更隐蔽的问题:匈牙利匹配在训练早期是不稳定的。参数每更新一步,代价矩阵就变了,匹配结果也跟着变,某个查询这一轮负责左边的车,下一轮可能被分配去负责右边的人。监督信号这样来回横跳,模型很难稳定下来。DINO 后来提出的去噪训练(contrastive denoising)就是专门治这个病的——直接构造带噪的查询并让它们去重建真值,给模型一个稳定的监督信号,把收敛压到 12 个 epoch。

2.3 小目标漏检的物理原因:分辨率和感受野的矛盾

小目标的问题,说到底是物理层面的信息缺失,不是损失函数能救的。前面算过,800×1333 的输入在 stride 32 的特征图上只有 25×42 个位置,一个 32 像素见方的目标就占一个格子。而这个格子的特征,是骨干网络连续下采样五次之后的产物,里面已经混进了周围大片背景的信息。全局注意力再怎么加,也只是在这个已经糊掉的表示上做加权平均,恢复不出原本的细节。

更要命的是检测头的注意力本身。DETR 的解码器要给每个对象查询生成一个框,而这个框通常是相对于整张图的归一化坐标。当目标的真实尺寸只有图像的 2% 时,模型需要输出的坐标精度非常高,但注意力在均匀分布的状态下根本提供不了这种定位精度。这也是为什么 DETR 的 AP_S 在当时明显落后于 Faster R-CNN + FPN——后者通过 P2、P3 这些高分辨率层级,天然保留了小目标的细节。

3. 稀疏采样这一步怎么走通的:Deformable Attention 的机制拆解

3.1 从"看全图"到"只看 K 个点":公式里每一项在干什么

Deformable Attention 的核心思想可以用一句话概括:不是所有位置都值得看,让模型自己学着挑几个点就够了。多尺度版本的完整公式长这样:

MSDeformAttn(z_q, p_q, {x^l}) = sum_m W_m [ sum_l sum_k A_mlqk · W'_m · x^l(p_q + Δp_mlqk) ]

这里的符号需要逐个交代清楚。z_q是查询 q 的特征向量,p_q是它对应的参考点坐标,{x^l}是 L 个层级的特征图。下标 m 是注意力头的编号,l 是层级的编号,k 是采样点的编号,通常是 4 个。

Δp_mlqk是第 m 个头、第 l 层、第 k 个采样点相对于参考点的偏移量。A_mlqk是对应的注意力权重,它被约束成在所有层级和所有采样点上归一化,也就是sum_l sum_k A_mlqk = 1x^l(p)表示在特征图第 l 层上的双线性插值取值,因为p_q + Δp_mlqk一般是小数坐标。W'_m是 value 的投影矩阵,W_m是输出的投影矩阵。

和标准注意力的区别关键在复杂度上。标准注意力里,每个查询要和所有 N_k 个键做点积,开销是N_q × N_k。而这里每个查询只和K × L = 16个点打交道,开销是N_q × 16,跟特征图大小完全脱钩。当查询数N_q就是特征图的像素数时,整体复杂度对图像分辨率近似线性——这就是它能上高分辨率特征图的根本原因。

3.2 参考点、偏移量与权重:三个量分别怎么来

这三个量的生成方式,是整个模块里最需要掰扯清楚的部分。

参考点p_q在编码器和解码器里含义不同。在编码器里,查询就是特征图上的每个位置,参考点就是那个位置归一化到 [0,1] 的坐标,官方实现里还会加上半个像素的偏移,让坐标落在像素中心而不是角点上。在解码器里,参考点是预测框的中心坐标——注意,是"框"而不是"点"。这就带来一个顺序问题:解码器必须先算出框,才能算参考点,然后才能做可变形注意力。所以解码器层的结构是:自注意力 → 框回归头预测框 → 取出框中心作为参考点 → 可变形交叉注意力,这个顺序不能乱。

偏移量和权重是通过对查询特征做线性投影得到的:

# 偏移量:每个头、每个层级、每个采样点需要 2 个分量(x, y) self.sampling_offsets = nn.Linear(d_model, n_heads * n_levels * n_points * 2) # 权重:每个头、每个层级、每个采样点一个标量 self.attention_weights = nn.Linear(d_model, n_heads * n_levels * n_points) # 权重在 (层级 × 采样点) 这个维度上做 softmax attention_weights = attention_weights.softmax(-1) # 偏移量除以该层级的 (W, H),换算到归一化坐标系 offset_normalizer = torch.stack([spatial_shapes[:, 1], spatial_shapes[:, 0]], -1) sampling_locations = reference_points + sampling_offsets / offset_normalizer

这里有个容易踩的坑:偏移量没有经过 sigmoid。原版实现是把线性输出的原始值直接除以层级的宽高,得到一个以归一化坐标为单位、但范围不受限的偏移。好处是模型可以学到很大的偏移量,配合双线性插值的边界处理,采样点跑到图外也不会崩。后来的 mmdetection 版本加了 sigmoid 限制范围,训练更稳但灵活性下降,两者各有取舍。

初始化的技巧在这里,很值得抄:

constant_(self.sampling_offsets.weight.data, 0.) # 让 n_heads 个头的初始方向均匀分布在圆周上 thetas = torch.arange(n_heads, dtype=torch.float32) * (2.0 * math.pi / n_heads) grid_init = torch.stack([thetas.cos(), thetas.sin()], -1) grid_init = (grid_init / grid_init.abs().max(-1, keepdim=True)[0]) grid_init = grid_init.view(n_heads, 1, 1, 2).repeat(1, n_levels, n_points, 1) for i in range(n_points): grid_init[:, :, i, :] *= i + 1 # 同一头内的 K 个点沿同一方向拉开距离 with torch.no_grad(): self.sampling_offsets.bias = nn.Parameter(grid_init.view(-1))

把权重置零、偏置设成这个"圆周分布",意味着训练刚开始时,8 个头各自朝 8 个不同方向采样,同一头内部的 4 个点沿着该方向由近及远排列。这个初始化让模型一上来就有"多方向、多尺度"的观察能力,比全零初始化收敛快不少。我在自己搭的小数据集上对比过,去掉这个初始化,前 5 个 epoch 的 loss 下降明显更慢。

3.3 多尺度版本如何把 FPN 的活接过来

Deformable DETR 用的是 4 个层级的特征。前三个来自 ResNet 的 C3、C4、C5,步长分别是 8、16、32,第四个是在 C5 上再做一个步长 2 的 3×3 卷积得到的,有效步长 64。每个层级的通道数先用 1×1 卷积统一投影到 256 维。

然后是最关键的排布方式:把这 4 个层级的特征在空间维度上展平,再沿着序列维度拼接成一条长序列,一起送进编码器。同时记录两个辅助张量——spatial_shapes保存每层的 (H, W),level_start_index保存每层在长序列里的起始位置。有了这两个东西,可变形注意力在做双线性插值时就能准确定位到某个层级上的某个位置。

这套设计实际上是把 FPN 的功能吃掉了。FPN 的做法是把高层语义特征上采样后和低层特征相加,是一种手工设计的融合路径;而这里不做任何融合,直接让注意力去跨层级采样,融合过程完全交给学习。好处是省掉了 FPN 那一堆额外的卷积层,坏处是模型必须自己学会"什么时候该看高层、什么时候该看低层"。他们加的那个level_embed就是给模型的一个提示,告诉它当前 value 来自哪个尺度。

从实测效果看,这套设计对小目标的提升非常直接。论文里的数字大致是:ResNet-50 骨干、50 个 epoch 的配置下,整体 AP 能到 43~44 左右,已经超过了训 500 个 epoch 的 DETR;叠加迭代式框回归能到 45 出头,再上两阶段变体能到 46 左右。具体数字不同版本会有浮动,以官方配置为准,但趋势是稳的。

3.4 感受野和计算量的账:为什么它敢上高分辨率

算一下复杂度。可变形注意力里,每个查询需要做的操作是:生成偏移量和权重(两次线性投影,O(d²))、K×L 个点的双线性插值(每个点是常数开销)、然后是对 K×L 个采样值做加权求和。所以单查询的开销是 O(d² + KL·d),和特征图大小无关。总的复杂度是 O(N_q · (d² + KL·d)),对特征图空间尺寸完全线性。

内存方面更明显。标准注意力要存(N_q, N_k)的注意力图,可变形注意力只需要存(N_q, M, L, K)的采样坐标和权重,K 取 4、L 取 4、M 取 8 的话,每个查询只有 128 个数。N_q 取 10 万(对应较高的分辨率),也只是 1280 万个浮点数,几十 MB 的事。

当然,稀疏采样也有代价。感受野变成了"学习出来的",不再是理论上全图可达。如果某个目标需要跨越大半个图像才能找到关联,可变形注意力就得靠多层堆叠来传递信息——6 层编码器、每层 4 层级的采样,理论上能够覆盖很大范围,但实际能不能学到,取决于数据和训练。这也是为什么 Deformable DETR 在需要长距离关系推理的任务上,比不过全局注意力,但在通用检测这种局部特征为主的任务上反而赢了。

4. 把训练脚本跑通:环境、编译与配置里的具体数字

4.1 可变形注意力算子的编译和那些反复出现的报错

原版的可变形注意力是用 CUDA 写的自定义算子,推理前必须先编译。流程本身很简单:

cd models/ops sh ./make.sh # 验证是否编译成功并正确注册 python test.py # 全部输出 True 才算过

真正折磨人的是编译失败的排查。下面这张表是我和同事踩过的总结,按出现频率排序:

报错信息根因处理方式
undefined symbol: _ZN2at...PyTorch 和扩展的 C++ ABI 不一致setup.py 里加-D_GLIBCXX_USE_CXX11_ABI=0,或与新 ABI 保持一致
nvcc fatal: Unsupported gpu architecture显卡架构超出 nvcc 支持范围export TORCH_CUDA_ARCH_LIST="7.0;7.5;8.0;8.6",只编译需要的架构
identifier "AT_CHECK" is undefinedPyTorch 版本较新,旧 API 被移除AT_CHECK全局替换成TORCH_CHECK
error: 'xxx' is not a member of 'at::cuda'CUDA 版本和 PyTorch 编译时用的 CUDA 不一致torch.version.cudanvcc --version必须对齐
编译过了但 forward 输出全 0输入张量不连续,或spatial_shapes在 CPU 上输入全部.contiguous(),辅助张量统一放到同一 device
半精度下报类型错误原版 kernel 只支持 float32要么改 kernel,要么在推理时切回 fp32

从效率和可维护性考虑,我现在的做法是:如果只是做实验,直接编译官方算子,性能最好;如果是要长期维护或者要导出部署,就换成纯 PyTorch 的 grid_sample 实现,慢一些但省心得多。这个回退实现在 4.4 节后面会给。

4.2 数据准备:COCO 格式里几个不太显眼的坑

数据这块本身不难,但有几个坑几乎人人都会踩一次。标准目录结构是这样的:

data/coco/ ├── annotations/ │ ├── instances_train2017.json │ └── instances_val2017.json ├── train2017/ └── val2017/

第一个坑是类别数的"91 和 80"。COCO 的 JSON 里类别 id 是 1 到 90 之间的不连续整数,但实际有效类别只有 80 个。很多官方代码在构建类别映射时用的是最大 id 加一,也就是 91,然后靠一个映射表把有效 id 压到 0~79。如果你自己清洗过数据集,直接把 id 重排了,那这个映射就会错位——表现是模型训练完全正常,但验证时所有类别的 AP 都是零。

第二个坑是验证速度。COCO 的完整评估要跑几千张图,单卡跑一次十几分钟。调试阶段我一般会在配置文件里把验证集换成一个几百张的子集,等配置确认没问题了再跑全量。省下来的时间相当可观。

第三个坑是坐标归一化。DETR 系列要求框坐标在送入损失函数之前是归一化到 [0,1] 的,而 COCO 标注里的框是绝对像素坐标加上左上角宽高格式。官方代码里有一个NormalizedXYWH的变换链负责转换。如果你自己写数据加载器,一定要注意这里的顺序:先转成中心点加宽高,再归一化,顺序反了结果会错得很隐蔽。

4.3 50 个 epoch 的训练配置:学习率、损失权重和梯度裁剪

配置这块我直接给一份能用的对照表:

配置项取值说明
epochs50第 40 个 epoch 时学习率降为原来的 0.1
优化器AdamW骨干网络用 SGD 也可以,但 AdamW 更省心
基础学习率2e-4骨干网络单独用 2e-5,低十倍
权重衰减1e-4偏置项和归一化层参数不加权重衰减
单卡 batch size28 卡合计 16,显存 24G 够用
梯度裁剪max norm 0.1这个值很关键,不要漏
分类损失系数2用的是 focal loss,alpha 取 0.25
L1 损失系数5用于框回归
GIoU 损失系数2用于框回归
对象查询数300比 DETR 的 100 多,因为匹配更稀疏

两个地方需要特别注意。梯度裁剪的阈值 0.1 看起来很小,但这是官方配置,实测确实需要——Deformable DETR 在训练早期梯度范数会突然冲高,不裁剪的话很容易出现 loss 变成 NaN。另一个是分类损失用的是 sigmoid 加 focal loss,而不是 softmax 加交叉熵。这意味着每个查询对每个类别独立做二分类,匹配时用的代价函数也相应换成了 focal loss 的形式。这个改动是论文里明确提到的,很多复现版本漏掉了,结果就是收敛变慢。

4.4 迭代式框回归和两阶段变体:什么时候值得开

这两个是 Deformable DETR 提升 AP 的两个主要增量,但都不是默认必须要开的。

迭代式框回归的思路是让解码器的每一层都在前一层预测框的基础上做一次精修。第 i 层的框是这样来的:b_i = sigmoid(Δb_i + inverse_sigmoid(b_{i-1})),也就是先把上一层的框反 sigmoid 变换到实数域,加上一个预测出的偏移量,再 sigmoid 回来。这里有个关键细节——参考点必须 detach,也就是梯度不能通过参考点回传到上一层。如果不 detach,梯度会沿着层间路径反传,训练会非常不稳定。这个坑我在第一次自己实现的时候踩过,loss 会莫名其妙地跳。

两阶段变体则是让编码器的输出先接一个检测头,产生一批粗略的候选框,取分数最高的若干个作为解码器的参考点和查询初始化。因为参考点是从编码器特征里直接预测出来的,天然带有位置信息,比随机初始化的查询要好得多,小目标的收益尤其明显。代价是多了一个阶段的计算量,训练速度会慢一到两成。

我的建议是:验证框架是否跑通的时候先只开迭代式框回归,等基线稳定了再上两阶段。如果数据集里小目标占比较高,两阶段几乎是必开的。

5. 实测踩坑记录:不收敛、漏检、显存爆掉分别怎么排查

5.1 loss 卡住不动时的完整排查链路

遇到训练不收敛,最忌讳的是漫无目的地调学习率。我现在的排查顺序是固定的,从最便宜的开始。

第一步,把损失拆成四个分量分别打印:分类损失、L1 损失、GIoU 损失、匹配数。分类损失一直不下降,问题在分类头或者标签分配;L1 和 GIoU 不动,问题在框回归或者坐标格式;匹配数一直是 0,那基本可以确定是数据加载出了问题。

第二步,检查匹配数。正常情况下,匹配数应该等于这个 batch 里真值的总数。如果明显偏小,说明很多真值没被匹配上,原因通常是类别映射错位(回想 4.2 节那个 91 和 80 的坑)或者坐标归一化错了。如果匹配数一直是 0,那必然是数据问题,不用再怀疑模型。

第三步,检查输入归一化。Deformable DETR 沿用了 ImageNet 的均值和方差做归一化,如果你换成自己的数据集统计值,要确保训练和推理一致。这一步错了的表现是 loss 在下降但验证 AP 上不去。

第四步,看梯度范数。如果梯度范数长期在几十以上,说明学习率偏高或者需要更强的裁剪;如果长期接近零,那可能是梯度在某处断掉了,检查一下参考点的 detach 是不是加错了位置。

第五步,确认数据加载器真的读到了标注。这一步听起来很蠢,但我遇到过两次——一次是路径配错了导致加载了空标注,另一次是缓存文件过期。打印一下每个 batch 的框数量分布,能省下大量时间。

5.2 小目标召回上不去,我试过的四件事

小目标是个系统工程,没有单点解决方案。按性价比排序,我实际试过的做法有这些。

提高输入分辨率是最直接的一招。把短边从 800 提到 1000 甚至 1200,小目标 AP 通常能有明显提升,因为目标在特征图上的占格数直接翻了。代价是显存和耗时都按平方增长,24G 卡上 1200 的分辨率基本是上限。

多尺度训练是第二招。训练时随机在几个尺寸之间切换,让模型对不同尺度的目标都见过。这一招不需要额外显存,只是让训练时的尺寸抖动起来,收益稳定但幅度不如直接提分辨率。

第三招是调整特征层级。默认是从 C3 开始的,如果目标特别小,可以考虑再加一层 stride 4 的特征,或者把 C3 的通道数提上去。加层级的代价是序列长度增加,训练变慢。

第四招是开两阶段变体。前面说过,两阶段的参考点是从编码器特征里预测出来的,小目标本来就容易在编码器输出上留下响应,所以参考点更容易落到小目标上。我的实测里,单阶段切到两阶段,小目标 AP 大概能涨 2 个点左右,是所有招里收益最明显的。

5.3 部署阶段:算子导出和量化时不得不做的取舍

训练跑通不难,难的是部署。核心问题在于那个自定义 CUDA 算子——ONNX、TensorRT、各种推理引擎都不认识它,必须替换成等价的标准算子组合。

好在可变形注意力的计算过程可以拆解成几步标准操作:算采样坐标、按层级做双线性插值、再按权重加权求和。双线性插值正好对应grid_sample,加权求和就是普通矩阵乘法,两者在主流推理框架里都有。下面是一个简化版的纯 PyTorch 回退实现,思路和导出时用的完全一致:

import torch import torch.nn.functional as F def deform_attn_forward(value, spatial_shapes, sampling_locations, attention_weights, n_points): """ value: (bs, sum(H*W), n_heads, head_dim) spatial_shapes: List[(h, w)],每个层级的特征图尺寸 sampling_locations: (bs, n_q, n_heads, n_levels, n_points, 2),值域 [0, 1] attention_weights: (bs, n_q, n_heads, n_levels, n_points) """ bs, n_q, n_heads, n_levels, _, _ = sampling_locations.shape head_dim = value.shape[-1] split_sizes = [h * w for h, w in spatial_shapes] value_list = value.split(split_sizes, dim=1) sampled_levels = [] for lvl, (h, w) in enumerate(spatial_shapes): v = value_list[lvl].permute(0, 2, 1).reshape(bs * n_heads, head_dim, h, w) # 归一化坐标 [0,1] 映射到 grid_sample 要求的 [-1,1] grid = sampling_locations[:, :, :, lvl] * 2 - 1 grid = grid.permute(0, 2, 1, 3, 4).reshape(bs * n_heads, n_q, n_points, 2) out = F.grid_sample(v, grid, mode="bilinear", padding_mode="zeros", align_corners=False) sampled_levels.append(out.view(bs, n_heads, head_dim, n_q, n_points)) sampled = torch.stack(sampled_levels, dim=4) # (bs, heads, dim, n_q, levels, points) weights = attention_weights.permute(0, 2, 1, 3, 4) # (bs, heads, n_q, levels, points) weights = weights.unsqueeze(2) # 广播到 head_dim 维度 out = (sampled * weights).sum(dim=(4, 5)) # 层级和采样点维度求和 out = out.permute(0, 3, 1, 2).reshape(bs, n_q, n_heads * head_dim) return out

这段代码的正确性可以用官方算子做对拍验证:随机生成输入,两个实现的结果差应该在一两个浮点误差范围内。导出 ONNX 的时候要注意grid_sample需要 opset 16 及以上,低版本不支持align_corners=False这个组合。

量化的取舍更麻烦。原版 kernel 只支持 fp32,而且双线性插值对量化误差很敏感——采样坐标稍微偏一点,取到的就是完全不同的特征。我的经验是,可变形注意力这部分尽量保持 fp32,只对骨干网络做量化,这样精度损失可控。整体速度提升会打折,但比为了全量化把 AP 掉好几个点要划算。

6. 放在变体谱系里看:Deformable Attention 与相邻工作的关系

6.1 和可变形卷积、稀疏注意力、FPN 的对照

不少人第一次看到 Deformable Attention 会觉得眼熟,因为它的思路和可变形卷积(Deformable Conv)非常像。两者的共同点都是"让网络自己学采样位置",但粒度不同:可变形卷积是在卷积核的固定网格上加偏移,采样点数等于卷积核大小,是局部的;可变形注意力是在参考点周围学偏移,采样点数由 K 控制,而且权重也是学出来的,聚合方式是加权的而不是卷积核加权。可以说它是可变形卷积在注意力框架里的一个推广。

方案采样方式复杂度对分辨率训练轮数小目标表现
Faster R-CNN + FPN密集 anchor与 anchor 数相关数十轮
DETR全局稠密注意力平方约 500 轮较差
Deformable DETR稀疏可学习采样近似线性约 50 轮
DAB-DETR / DINO四维 anchor 加去噪同可变形注意力12 到 36 轮更好

和稀疏注意力(比如各种局部窗口注意力)的区别也值得说一句。Swin 那种做法是固定窗口,位置是硬的,优点是实现简单、硬件友好;可变形注意力的采样位置是软的、学出来的,表达能力强但要求自定义算子。工程上选哪个,很大程度取决于你能不能接受底层算子。

和 FPN 的关系前面提过,它其实是把多尺度融合这件事从手工设计变成了学习。这也是为什么后来的检测器普遍不再单独强调 FPN——多尺度特征还在,但融合方式换了。

6.2 ViT、Swin、DINO 这条线上的位置

如果把视觉 Transformer 画成一棵树,Deformable DETR 属于"编码器-解码器检测框架"这一支,和纯骨干网络改进的 ViT、Swin 不在同一个分支上,但两者是能组合的。Deformable DETR 用的骨干网络是 ResNet,后面有人换成 Swin,AP 又能涨几个点。这说明它解决的问题(注意力的计算效率和收敛速度)和骨干网络解决的问题(特征提取能力)是正交的。

DINO 这条线则是沿着 Deformable DETR 往前走:保留了可变形注意力作为底层算子,在上面加了对比去噪训练、混合查询选择、以及基于 look-forward-twice 的框更新策略。从工程角度看,如果你今天要做一个新的检测项目,直接从 DINO 或它的后续版本起步,比从 Deformable DETR 起步要省事——但前提是你理解可变形注意力这一层,否则出了问题时根本不知道从哪查。

6.3 如果今天重新选型,我会怎么决定

回到最实际的问题:一个新项目该选什么。我自己的判断标准大致是这样几条。

如果数据集不大,比如几千张图以内,我会优先考虑带强预训练权重的方案,因为 DETR 系列的收敛依赖数据量。这种情况下用 DINO 系列配大规模的预训练骨干,能省下大量训练时间。

如果数据里有大量小目标,比如遥感、工业质检这类场景,那么多尺度可变形注意力基本是必选项,同时要把输入分辨率提上去,并且开两阶段。单纯堆骨干网络的容量收益很小。

如果部署环境受限,比如只能跑在通用推理引擎上,那要提前评估自定义算子的替换成本。前面给的 grid_sample 回退方案能用,但速度会掉,需要提前做性能预算。

最后说个我自己的体会。刚开始接触 Deformable DETR 的时候,我最想不通的是为什么它的初始化要写得那么绕——又是余弦正弦又是按序号放大。后来自己从零实现了一遍,把那个初始化换成全零,看着前几个 epoch 的 loss 曲线慢慢爬,才真正明白那几行代码值多少钱。有些设计在论文里只有半句话,但它背后是作者试过无数次才留下来的,动手复现一遍比读十遍论文管用。

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

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

立即咨询