可变形注意力详解:从DETR加速到DAT与DCNv3的机制实现
发布时间:2026/10/1 13:04:23来源:尧图网络
可变形注意力Deformable Attention这个概念我第一次认真啃是在2020年复现DETR的那段时间。当时DETR要在整张特征图上做密集注意力500个epoch才收敛到一个能看的精度一个实验排期就是好几天调一次参数的成本高得离谱。后来Deformable DETR出来把训练轮数压到50精度反而更高核心改动就是这个可变形注意力——它不再对整张图做地毯式计算而是只在少数几个由网络自己学出来的位置上采样算注意力。这篇文章我会把它的机制、实现细节、参数量级以及后来衍生出的DAT、DCNv3这些变体讲清楚适合已经懂基础Transformer、想把注意力用在检测分割这类视觉任务、或者想自己改造注意力结构的同学不需要你事先懂可变形卷积。1. 传统注意力的三个硬伤原版Transformer搬到视觉为什么水土不服先把背景讲透不然你很难理解可变形注意力每个设计取舍背后的理由。标准自注意力在一个长度为N的序列上计算每个元素和所有其他元素的相似度复杂度是O(N²·C)N是序列长度C是通道维度。在自然语言里一句话也就几十到几百个token这个代价能接受。但视觉任务里一张800×1333的图做16倍下采样得到的是50×84个特征点再加上多尺度特征金字塔特征点数量轻松上十万这时候O(N²)就完全没法用了。可变形注意力的所有设计本质上都在回答同一个问题能不能只算一小部分“值得算”的位置。1.1 密集注意力把算力花在了“废话”上CNN做检测的时候有个很重要的经验一张图里真正决定输出结果的往往只是少数几个关键位置比如目标的边缘、角点、纹理突变处。背景大部分是平坦的、重复的算不算对结果影响不大。但标准注意力不管这些它对每个query都去和全图所有key算相似度大部分计算其实是浪费在无关背景上的。这就像你要找一栋楼里的某个人标准注意力的做法是把整栋楼每一户都敲一遍门而可变形注意力的做法是先估计这个人大概在哪几个房间只敲那几个门。更麻烦的是密集注意力的权重分布常常是“糊”的。早期DETR的可视化就显示很多head的注意力图接近均匀分布没有明显的聚焦区域说明模型本身也没能学到清晰的空间对应关系。这种“糊”不仅浪费算力还让优化变得困难——梯度分散在大量弱相关的连接上参数更新效率低。可变形注意力通过强制模型只选K个位置把注意力权重集中在少数采样点上反而逼着网络去学“该看哪里”注意力图的可解释性也明显提升。这里有个容易被忽略的点稀疏不等于信息丢失。CNN的3×3卷积本身就是稀疏的它只看9个邻居但堆叠几十层后照样能获得全局感受野。可变形注意力的采样点虽然少但采样位置是可学习的、数据驱动的理论上比固定网格的卷积更灵活。实测下来K取4到8个采样点就能覆盖绝大多数任务的精度需求再往上加收益很小这个数量级后面我会专门算一笔账。1.2 缺少空间归纳偏置收敛慢是必然Transformer是为序列设计的它天然不知道“相邻的两个像素关系更近”这件事位置信息完全靠位置编码硬塞进去。而视觉任务有很强的二维空间结构平移不变性、局部相关性、多尺度层次性。这种结构和Transformer的假设不匹配导致模型得从大量数据里重新学出这些本该免费获得的先验收敛慢就是必然结果。Deformable DETR论文里给过一个很直观的对比原始DETR要500个epochDeformable DETR只要50个epoch精度还能从38.8 AP提到43.3 AP左右。这十倍的收敛加速根源就在可变形注意力把“从参考点出发、在邻域采几个点”这种局部性先验直接写进了结构里。参考点给了空间锚定偏移量给了局部搜索范围这两样加起来相当于把卷积的局部性用注意力的形式表达了出来同时保留了注意力加权聚合的灵活性。还有一点DETR收敛慢跟注意力在训练初期“无所适从”有关。训练开始时参数是随机的注意力权重没有明显的空间倾向模型相当于随机看全图梯度信号信噪比很低。可变形注意力在初始化时把偏移量设成0采样点就落在参考点上注意力权重初始接近均匀这个状态下它退化成一个带权重的局部聚合行为接近卷积训练非常稳之后再慢慢学出偏移。这个初始化技巧是整套方法能work的关键之一我在第3节会展开讲。2. 可变形注意力的核心机制拆解2.1 参考点、采样偏移、注意力权重这三件套把公式摊开看可变形注意力对单个query z_q的输出是$$\text{DeformAttn}(z_q, p_q, x) \sum_{m1}^{M} W_m \Big[ \sum_{k1}^{K} A_{mqk} \cdot Wm x(p_q \Delta p{mqk}) \Big]$$这里M是注意力头数K是每个query的采样点数量x是特征图p_q是参考点Δp_mqk是采样偏移A_mqk是注意力权重W_m和W_m分别是输出和输入的投影矩阵W_m在所有采样点之间共享。三个关键量各有各的职责。参考点p_q是“从哪里出发”它决定了这个query关注的区域中心。在DETR类结构里参考点通常来自query自身的坐标或者解码器里的位置编码投影在backbone类的应用里参考点一般就是特征图上的规则网格点。采样偏移Δp_mqk是“往哪儿看”它由query特征经一个线性层预测出来是可学习的、每个采样点独立的二维向量。注意力权重A_mqk是“看多重”每个query在它的K个采样点上有K个权重经过softmax或sigmoid归一化保证权重之和为1跨层级求和的情况另说。这里有个设计上的讲究偏移量预测的是相对参考点的偏移而不是绝对坐标。为什么用相对量因为相对量跟特征图尺寸解耦模型在不同的分辨率、不同的尺度上都能复用同一套偏移预测逻辑泛化性更好。而且相对偏移天然有界配合后面要讲的归一化处理数值上更稳定。如果直接预测绝对坐标模型得自己去学各尺度的坐标范围训练难度陡增。采样点的特征值x(p_q Δp_mqk)并不是某个整数网格位置上的值因为Δp通常是小数。实际实现里用的是双线性插值先找到最近的四个整点按距离加权求和得到该位置的插值特征。双线性插值关于坐标是可导的所以偏移量能正常回传梯度这一点是可变形注意力能端到端训练的前提。很多人第一次看公式会忽略这个细节以为采样是个不可导的离散操作其实不是。注意偏移量和权重都从同一个query特征z_q预测这意味着每个query有自己独立的一组采样参数。这正是它比卷积灵活的地方——卷积核在所有位置共享而可变形注意力的“核”是逐查询动态生成的。2.2 从单尺度到多尺度MSDeformAttn怎么融合特征金字塔单尺度的可变形注意力只管一层特征图但检测任务对多尺度非常敏感小目标需要高分辨率特征大目标需要低分辨率特征。Deformable DETR把单尺度版本扩展成了多尺度可变形注意力MSDeformAttn公式变成$$\text{MSDeformAttn}(z_q, \hat{p}q, {x^l}) \sum{m1}^{M} W_m \Big[ \sum_{l1}^{L} \sum_{k1}^{K} A_{mlqk} \cdot W_m x^l(\phi_l(\hat{p}q) \Delta p{mlqk}) \Big]$$多出来的是l下标代表特征层级L是层数。具体做法是参考点先归一化到[0,1]区间然后φ_l把归一化坐标重新缩放回第l层的实际坐标每层各采样K个点。这样做的妙处在于一个query可以同时从多个尺度上收集信息而且每个尺度上的采样位置都通过各自学出来的偏移独立决定不需要像FPN那样做显式的特征融合。为什么这个设计比“先FPN融合再做注意力”更好因为FPN式的融合是位置无关的——所有query在同一空间位置上看到的是同一份融合特征。而多尺度可变形注意力是位置相关的每个query可以根据自己的需要决定从哪一层、哪个位置取多少信息。用一个类比FPN像把不同分辨率的照片叠在一起看而多尺度可变形注意力像给每个query配了一副可调焦的眼镜它能自己决定看哪张照片的哪个角落。实现上各层的采样点坐标要做边界检查超出特征图范围的采样点要么被clip到边界要么更常见的做法在双线性插值的掩码里被置零。这个细节如果不处理训练后期偏移量学大之后容易出现越界访问轻则精度掉点重则直接报错。mmcv的MultiScaleDeformableAttention在CUDA核函数里统一处理了越界用到了im2col_step的分块策略来省显存这套实现被后续大量工作沿用。关于采样点数的分配Deformable DETR的默认配置是num_heads8、num_levels4、num_points4也就是每个head在每层采4个点一共4×416个采样点每个head独立。注意这里的K是每层每头的采样数不是总数。很多人第一次读代码会把这个搞混以为K是全局总数结果算复杂度时对不上账。我第2.3节会用这个配置具体算一遍。2.3 把复杂度这笔账算清楚标准注意力的计算量大致是O(N_q·N_k·C)N_q是query数量N_k是key数量在自注意力里两者相等。可变形注意力的计算量是O(N_q·M·L·K·C)M是头数L是层数K是每层每头采样点数。两个一比关键的差别在于N_k被换成了M·L·K。拿一个具体配置算假设特征图来自4个尺度总特征点数约20000通道C256头数M8。标准注意力如果在这20000个点上做全局计算每个query要算20000次相似度总计算量级是N_q×20000×256而可变形注意力每个query只算8×4×4128个采样点计算量直接砍到约1/156。即使考虑标准注意力实际不会真的做全图会受显存限制做局部窗口可变形注意力的这个稀疏度依然有明显优势。当然账不能只算FLOPs。可变形注意力有个隐性成本双线性插值和动态偏移预测带来的访存开销比规则卷积大GPU利用率通常不如标准卷积高。实际测下来同样FLOPs下可变形注意力的wall-clock时间可能比理论值高20%到50%。但即便如此它相比全局注意力的总账还是划算的因为砍掉的FLOPs量太大。选型的时候如果你的输入分辨率不高、特征点数不多比如几千全局注意力未必慢一旦特征点上万可变形注意力的优势就非常明显了。再补一句参数量的账偏移预测层和权重预测层的参数量大致是3×M×L×K×C偏移2个分量加权重1个分量乘头数、层数、点数以C256、M8、L4、K4算大概3×8×4×4×256≈100万参数。这相对于backbone动辄几千万的参数量是很小的属于“结构轻、收益大”的改动这也是它被大量工作采用的原因之一。3. 手写一个可变形注意力模块从公式到能跑的代码3.1 偏移与权重预测头的设计自己实现的时候第一步是把query特征映射到偏移和权重两个输出上。结构上就是一个线性层输入维度是C输出维度是3×M×L×K前2×M×L×K个分量是每个采样点的xy偏移后M×L×K个分量是注意力权重logits形式后面归一化。为什么合在一个线性层里因为偏移和权重共享同一份query语义分开预测反而割裂了信息合在一起还能省一次矩阵乘实现更简洁。import torch import torch.nn as nn class DeformAttnHead(nn.Module): def __init__(self, d_model256, n_heads8, n_levels4, n_points4): super().__init__() self.d_model d_model self.n_heads n_heads self.n_levels n_levels self.n_points n_points # 2个偏移分量 1个权重分量 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) self.value_proj nn.Linear(d_model, d_model) self.output_proj nn.Linear(d_model, d_model) # 关键初始化偏移层置零权重层小值初始化 nn.init.constant_(self.sampling_offsets.weight, 0.) nn.init.constant_(self.sampling_offsets.bias, 0.) nn.init.xavier_uniform_(self.attention_weights.weight) nn.init.constant_(self.attention_weights.bias, 0.)偏移层的bias初始化为0意味着训练开始时所有采样点都落在参考点上网络从一个稳定状态出发。这个细节看起来小但去掉之后收敛速度会明显变慢甚至出现训练初期的loss震荡。权重层用xavier初始化、bias置零是为了让初始的权重分布接近均匀避免一开始就强烈偏向某个采样点。参考点的生成有两种常见方式。如果用在检测头里参考点来自query的位置嵌入或坐标回归如果用在backbone里参考点就是特征图上的规则网格。规则网格的生成可以用torch.meshgrid注意要把它归一化到[0,1]再送去采样因为后面的坐标缩放需要统一的基准。3.2 双线性采样与梯度回传采样这一步是整个模块最核心也最容易写错的地方。给定一批浮点坐标要在这批坐标上取特征图的插值值。原理是对每个坐标(x, y)取floor得到左上角整点算出四个角点的权重然后加权求和。关键点是所有操作都要用PyTorch的张量运算表达不能出现离散的索引赋值否则梯度断掉。def bilinear_sample(feat, points, spatial_shapes): feat: [B, C, H, W] 单层特征图 points: [B, N, 2] 归一化坐标(x, y)范围[0,1] B, C, H, W feat.shape # 反归一化到像素坐标 x points[..., 0] * (W - 1) y points[..., 1] * (H - 1) x0 torch.floor(x).long() y0 torch.floor(y).long() x1 x0 1 y1 y0 1 # 边界裁剪 x0c x0.clamp(0, W - 1) x1c x1.clamp(0, W - 1) y0c y0.clamp(0, H - 1) y1c y1.clamp(0, H - 1) # 四个角的插值权重 wx1 (x1 - x).clamp(0, 1) wx0 1 - wx1 wy1 (y1 - y).clamp(0, 1) wy0 1 - wy1 def gather(yy, xx): idx (yy * W xx).reshape(B, -1) flat feat.reshape(B, C, -1) return torch.gather(flat, 2, idx.unsqueeze(1).expand(-1, C, -1)).reshape(B, C, -1) v00 gather(y0c, x0c) v01 gather(y0c, x1c) v10 gather(y1c, x0c) v11 gather(y1c, x1c) w00 (wy0 * wx0).reshape(B, 1, -1) w01 (wy0 * wx1).reshape(B, 1, -1) w10 (wy1 * wx0).reshape(B, 1, -1) w11 (wy1 * wx1).reshape(B, 1, -1) return v00 * w00 v01 * w01 v10 * w10 v11 * w11这段代码里torch.gather负责按索引取值clamp负责把越界坐标压回边界。由于x0、y0这些是从可导的坐标算出来的floor本身不可导但x1-x、y1-y这些权重项是可导的整体对偏移量的梯度能正常回传只是梯度会经过权重项而不是索引项。这就是双线性插值“近似可导”的实现方式实际训练完全够用。提示如果你用mmcv直接调用MultiScaleDeformableAttention是更省事的选择它用CUDA写了专门的核函数速度和显存都优化过。自己写Python版本主要是为了理解机制生产环境别硬扛。3.3 初始化与训练配置里那些容易被忽略的细节除了前面说的偏移层置零还有几个配置点值得单独拎出来。一是偏移量的数值范围要控制。原始预测出来的偏移是任意实数直接加在归一化参考点上可能把采样点甩到很远的越界位置。常见做法是把偏移乘一个缩放因子或者用tanh约束到[-1,1]再乘一个最大偏移半径。Deformable DETR的实现里偏移实际上是在归一化坐标系下按层级缩放处理的各层按自己的尺寸换算。二是注意力权重的归一化维度。当有多个尺度时权重到底是每个尺度内独立softmax还是跨所有尺度一起softmax不同实现有不同选择。跨尺度一起归一化能让网络在层与层之间做取舍小目标特征和大目标特征二选一更符合“动态选尺度”的直觉但梯度耦合更强。每层独立归一化则更稳各层信息都被强制利用一部分。实测下来检测任务里跨尺度归一化略好分割任务里两者差别不大可以用配置项切换。三是学习率的设置。可变形注意力新增的偏移和权重预测层因为初始化特殊不需要单独降学习率跟随主干即可。但如果你是从预训练backbone迁移新增模块的初始学习率可以略高一点比如主干的1到2倍让它学得快一些。warmup阶段建议留够2000步以上避免偏移量在训练初期被一个大梯度带偏。四是dropout和梯度裁剪。偏移预测对异常值敏感梯度爆炸时偏移量会突然变得很大导致采样点全部越界、特征全变0、loss直接炸。训练大模型时建议把梯度裁剪阈值设在0.1到1.0之间具体看模型规模。这个不是可变形注意力特有的问题但它对这个问题更敏感所以更要注意。4. 可变形注意力的几条主流拓展路线4.1 DAT把可变形注意力塞进BackboneDeformable DETR的可变形注意力主要用在解码器里特征还是靠CNN或Swin提。DATDeformable Attention Transformer的想法更激进直接把可变形注意力当成backbone里的基本模块替代标准注意力。它的结构是几层局部窗口注意力加一层可变形注意力交替排列。局部窗口负责高效建模近邻关系可变形注意力层负责给模型提供长距离、数据驱动的全局建模能力。DAT里有个值得学的设计参考点不是固定网格而是通过一个轻量的偏移网络从特征预测的。做法是先对特征做一次平均池化得到粗粒度的全局描述子用它预测一组参考点再把参考点上的特征拿去算可变形注意力。这样参考点本身也是数据驱动的能自适应地聚焦到信息量大的区域比固定网格更聪明。实测在ImageNet上DAT用类似Swin的参数量能做到略高的精度且在密集预测任务上优势更明显因为它保留了可变形注意力对空间细节的敏感度。不过DAT也不是没有代价。可变形注意力层在小特征图上还行一旦到高分辨率阶段比如stride 4采样点的双线性插值开销就上来了。所以DAT的设计是分层级的浅层用局部注意力深层才用可变形注意力兼顾效率和建模能力。这个取舍你要根据自己任务的输入分辨率来权衡分辨率越高越要往深层放可变形层。4.2 DCNv3与InternImage大核时代的可变形算子如果说DAT是把可变形注意力当注意力用那DCNv3InternImage的核心算子就是把它拉回卷积的框架里重新设计。DCNv3的出发点是既然可变形卷积和可变形注意力本质上是同一类“动态采样加权聚合”操作能不能设计一个统一算子既能吃到超大卷积核的甜头又能保持可变形采样的灵活性。DCNv3的公式大概是这样$$y(p_0) \sum_{g1}^{G} \sum_{k1}^{K} w_g \cdot m_{gk} \cdot x_g(p_0 p_k \Delta p_{gk})$$跟DCNv2比它引入了分组机制G个组每组有独立的采样偏移Δp_gk但组间共享投影权重w_g深度可分离的思路。这个设计的妙处在于它把DCNv2里“每个采样点独立权重”带来的参数量和计算量压下去了同时又保留了多组偏移带来的表达多样性。G组偏移意味着算子能在同一位置同时关注G个不同的空间模式这比单组的采样能力强不少。InternImage用DCNv3堆出了能和ViT掰手腕的backbone而且在大核比如7×7、甚至更大配置下比标准卷积强得多。为什么大核对可变形算子特别重要因为大核的标准卷积参数量是K²倍增长而可变形算子在大核下虽然采样点变多但每个点的贡献由动态偏移决定参数效率更高。用大白话讲标准大核是“把所有邻居都硬编码进权重”可变形大核是“让网络自己挑几个关键邻居看”后者参数利用率明显更高。4.3 视频、3D与多任务的变体可变形注意力的思路天然适配时序和三维数据。视频任务里相邻帧之间有运动标准注意力对每个位置在时空上做全连接计算量大且容易受冗余帧干扰。把可变形思想扩展到时序让采样偏移在时间维度上也可学习模型就能自动“跟”着目标运动去采样而不是每个像素都算一遍。这类做法在视频超分、视频目标检测里都有人做过思路是一致的用动态偏移替代固定时空网格。3D点云场景里可变形注意力的价值更直观。点云本来就没有规则网格标准注意力只能在采样子集上做可变形注意力则可以直接在原始坐标上预测采样位置用最近邻或插值取值绕开了体素化的信息损失。这类工作里参考点通常来自点的几何坐标偏移量则建模局部几何形变两者结合能较好地处理点云的非均匀分布。多任务场景里也有它的身影。比如在检测分割统一模型里共享的backbone用可变形注意力不同任务的头再各自接小型的可变形模块既省参数又能让每个任务保留自己的空间感知偏好。我实际做过的一个实验是在同一个模型里让检测头偏向小采样点、分割头偏向大采样点结果两个任务的精度都比用统一配置高一点说明采样点数量本身也是个可以按任务调的旋钮。5. 踩坑实录与调参心得5.1 训练不稳定、loss炸掉的几种典型场景我遇到最多的问题是训练跑到中期突然loss飙升、梯度变NaN。追下来十有八九是偏移量学“飞”了——某些采样点被甩到特征图外很远的位置双线性插值取到0那一部分输出直接塌掉梯度反向传播时又把偏移层带崩。解决思路是双保险一是对偏移量做范围约束用tanh乘一个上限或者按层尺寸做clip二是加梯度裁剪阈值别设太大0.1到1.0之间试。第二个坑是显存。可变形注意力自己写的Python版本中间会产生大量形状为[B, N, M, L, K, C]的中间张量显存占用比看起来大得多。我早期用batch size 8跑不动排查后发现是采样后没及时reshape、中间变量没释放。用mmcv的CUDA实现能省不少它内部用了im2col_step分块显存占用和batch解耦。自己写的话记得把采样和聚合分开做别一口气全展开。第三个坑是偏移量初始化没做对导致训练初期就发散。有一次我把偏移层bias忘了置零结果第一个epoch的loss比预期高一截后面虽然能收敛但最终精度低了大概0.5个点。这个细节真的很小但影响确实存在我现在的做法是把初始化写进模块的__init__里确保每次实例化都生效不靠配置文件。5.2 采样点数、分组数、学习率之间怎么配合采样点数K不是越大越好。K4时每个query看4个点计算省、速度快适合backbone这种层次深、调用次数多的场景K8到16时单个query的表达能力强适合检测头这种“一次定结果”的位置。我试过在检测头把K从4加到8小目标AP涨了约0.8但推理时间增加了15%左右。加到16收益就很小了基本不划算属于典型的收益递减。分组数GDCNv3类的选择逻辑类似G越大能同时建模的空间模式越多但每组都要独立预测偏移参数量和计算量线性增长。经验值是G取4到16小模型取小值大模型取大值。G和K之间还有个交互G×K是总的采样配置数这个乘积决定了算子的表达上限。比如G8、K93×3核就已经能覆盖相当丰富的模式再往上堆性价比不高。学习率方面可变形注意力模块本身对学习率不特别敏感但对warmup长度敏感。我的习惯是warmup至少2000步总训练epoch在12到36之间时前两个epoch算warmup。如果是从头训练大backbonewarmup可以拉到5000步。学习率衰减用cosine比较稳step衰减在可变形模块上偶尔会出现精度台阶式下降不太好控。还有一个容易被忽略的点偏移量和权重的梯度尺度不一样。偏移量是二维坐标量纲是像素级权重是无量纲的logits。如果两个预测头用同一个学习率、同一个权重衰减偏移量那边相对更容易过冲。有个小技巧是给偏移预测层单独设一个略小的权重衰减或者给它的梯度乘一个0.5到1.0的缩放系数稳定性会好一些。我实测这个改动让训练后期的loss曲线平滑了不少。# 训练配置片段参考 optimizer torch.optim.AdamW([ {params: backbone.parameters(), lr: 1e-4, weight_decay: 0.05}, # 偏移层稍小权衰、稍高学习率 {params: deform_attn.sampling_offsets.parameters(), lr: 2e-4, weight_decay: 0.01}, {params: deform_attn.attention_weights.parameters(), lr: 1e-4, weight_decay: 0.05}, ], betas(0.9, 0.999)) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_maxtotal_steps, eta_min1e-6) # 梯度裁剪不能省 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm0.5)最后分享一个我在调试时可变形注意力最有用的一个手段把采样点坐标可视化到原图上。做法是把每层的参考点和偏移点画出来看看网络到底在“看”哪里。有次我发现所有采样点都挤在参考点附近几乎没偏移追下来是偏移缩放因子设得太小网络学到的最优偏移超出表示范围被限制了。把缩放因子调大后模型精度立刻有提升。这个方法比盯着loss曲线猜问题高效得多强烈建议你在调试阶段加上。这个方向后续还能往下挖的地方不少比如把可变形采样和线性注意力结合在超长序列上找平衡点或者把采样点从规则预测改成基于内容相似度的动态聚类都不失为有潜力的探索方向。
网站建设高端定制企业官网