053、DAT 可变形注意力 Transformer 在 Neck 中的应用:自适应关键点采样
053、DAT 可变形注意力 Transformer 在 Neck 中的应用自适应关键点采样从一次诡异的mAP抖动说起去年年底调一个工业检测项目Neck用的还是PANet普通卷积。模型在验证集上mAP0.5:0.95跑到0.723但每次换一个批次的数据增强参数mAP能跳±0.015。这种抖动让我很烦躁——不是过拟合是Neck对空间位置变化的响应太刚性了。后来翻Deformable Attention TransformerDAT的论文突然意识到一个问题Neck里的特征融合本质上是在做多尺度特征点之间的信息交互。传统卷积或者普通Transformer的注意力都是在固定网格上采样但目标尺度变化剧烈时固定采样点会浪费大量计算在背景上。DAT的自适应关键点采样正好能解决这个“注意力浪费”的问题。DAT的核心别把注意力当全局平均池化用很多人第一次看DAT会懵觉得它和DETR里的Deformable Attention差不多。区别其实很关键DETR的deformable attention是在decoder里做cross-attention采样点偏移是query-driven的而DAT在backbone和neck里做self-attention采样点偏移是feature-driven的而且多了个“温度系数”来控制采样范围。具体到Neck场景我们关心的是当FPN/PANet把不同尺度的特征图对齐后每个位置需要从其他尺度特征图上“借”信息。传统做法是用3x3卷积或bilinear插值但遇到细长目标或者密集小目标时固定感受野根本不够用。DAT允许每个位置自适应地选择采样点——比如检测一根电线杆采样点会自动沿着垂直方向分布检测一辆车采样点会集中在车身边缘。代码实现在YOLOv11的Neck里塞进DAT第一步定义可变形注意力模块先别急着写整个Neck把核心算子拆出来。这里我踩过一个坑直接用torchvision的deform_conv2d做偏移预测但那个接口要求输入输出通道数一致而且偏移量是整数像素级别的。DAT需要的是浮点偏移双线性插值所以得自己写grid_sample。importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassDeformableAttention(nn.Module):def__init__(self,dim,n_heads8,n_points16,kernel_size3):super().__init__()self.dimdim self.n_headsn_heads self.n_pointsn_points# 每个head采多少个点self.kernel_sizekernel_size# 这里踩过坑偏移预测的输入通道数要和dim一致别用dim//2self.offset_convnn.Conv2d(dim,2*n_heads*n_points,kernel_size,paddingkernel_size//2)# 温度系数控制采样范围初始值设0.1别设太大self.temperaturenn.Parameter(torch.ones(1,n_heads,1,1)*0.1)# 值投影和输出投影self.v_projnn.Conv2d(dim,dim,1)self.out_projnn.Conv2d(dim,dim,1)# 初始化偏移量为0别随机初始化否则训练初期梯度爆炸nn.init.constant_(self.offset_conv.weight,0)nn.init.constant_(self.offset_conv.bias,0)defforward(self,x):B,C,H,Wx.shape# 生成参考点网格归一化到[-1, 1]ref_y,ref_xtorch.meshgrid(torch.linspace(-1,1,H,devicex.device),torch.linspace(-1,1,W,devicex.device),indexingij)reftorch.stack([ref_x,ref_y],dim-1).unsqueeze(0).repeat(B,1,1,1)# B, H, W, 2# 预测偏移量注意这里输出的是2*n_heads*n_points个通道offsetself.offset_conv(x)# B, 2*n_heads*n_points, H, Woffsetoffset.view(B,self.n_heads,2*self.n_points,H,W)offsetoffset.permute(0,1,3,4,2).contiguous()# B, n_heads, H, W, 2*n_points# 将偏移量reshape成每个点的xy坐标offsetoffset.view(B,self.n_heads,H,W,self.n_points,2)# 乘以温度系数控制采样范围offsetoffset*self.temperature.unsqueeze(-1)# 生成采样点坐标sampling_pointsref.unsqueeze(1).unsqueeze(4)offset# B, n_heads, H, W, n_points, 2# 限制在[-1, 1]范围内别让采样点跑出图外sampling_pointstorch.clamp(sampling_points,-1,1)# 值投影vself.v_proj(x)# B, C, H, Wvv.view(B,self.n_heads,C//self.n_heads,H,W)# 双线性采样这里用grid_sample注意输入格式# 别这样写直接对v做grid_sample因为v是4D需要reshape成5Dvv.unsqueeze(2).expand(-1,-1,self.n_points,-1,-1,-1)# B, n_heads, n_points, C//n_heads, H, W# 把H,W维度展平方便grid_samplevv.permute(0,1,2,3,4,5).contiguous()vv.view(B*self.n_heads*self.n_points,C//self.n_heads,H,W)# 采样点也要reshapesampling_pointssampling_points.view(B*self.n_heads*self.n_points,H,W,2)# 执行grid_samplesampledF.grid_sample(v,sampling_points,modebilinear,padding_modezeros,align_cornersFalse)# B*n_heads*n_points, C//n_heads, H, W# 恢复形状并聚合sampledsampled.view(B,self.n_heads,self.n_points,C//self.n_heads,H,W)sampledsampled.sum(dim2)# 在n_points维度上求和得到每个head的输出# 合并headsampledsampled.view(B,C,H,W)# 输出投影outself.out_proj(sampled)returnout这个模块有几个关键点温度系数一定要可学习而且初始值要小。我试过初始值设1.0结果采样点直接飞到图像边缘梯度爆炸。另外偏移量卷积的初始化必须为零否则训练初期模型会乱跳。第二步替换YOLOv11 Neck中的C2f模块YOLOv11的Neck结构是标准的FPNPANet每个尺度层之间用C2f模块做特征变换。我们不需要动整个Neck只需要把C2f里的Bottleneck替换成DeformableAttention。找到ultralytics/nn/modules/block.py里的C2f类在__init__方法里加一个参数控制是否使用DATclassC2f(nn.Module):def__init__(self,c1,c2,n1,shortcutFalse,g1,e0.5,use_datFalse):super().__init__()self.cint(c2*e)self.cv1Conv(c1,2*self.c,1,1)self.cv2Conv((2n)*self.c,c2,1)self.mnn.ModuleList([Bottleneck(self.c,self.c,shortcut,g,k((3,3),(3,3)),e1.0)for_inrange(n)])# 如果启用DAT替换最后一个Bottleneckifuse_datandn0:self.m[-1]DeformableAttention(self.c,n_heads4,n_points8)这里有个trick只替换最后一个Bottleneck而不是全部替换。因为DAT的计算量比普通Bottleneck大不少全换的话显存扛不住。我试过在YOLOv11s上全换batch size从32掉到12mAP只涨了0.3%不划算。第三步修改配置文件在ultralytics/cfg/models/v8/yolov11.yaml里找到Neck部分的C2f定义加上use_datTrue参数。比如# YOLOv11s neckhead:-[-1,1,Conv,[256,3,2]]# 5-P5/32-[-1,1,nn.Upsample,[None,2,nearest]]-[[-1,3],1,Concat,[1]]# cat backbone P4-[-1,1,C2f,[256,3,use_datTrue]]# 这里启用DAT-[-1,1,Conv,[256,3,2]]-[[-1,4],1,Concat,[1]]# cat head P4-[-1,1,C2f,[256,3,use_datTrue]]-[-1,1,Conv,[256,3,2]]-[[-1,2],1,Concat,[1]]# cat head P5-[-1,1,C2f,[256,3,use_datTrue]]注意只在Neck的C2f里加backbone里的C2f不要动。backbone需要保持感受野的稳定性加了DAT反而会破坏底层特征。消融实验DAT到底带来了什么我在VisDrone数据集上做了消融实验这个数据集小目标多、尺度变化大正好能体现DAT的优势。baseline是YOLOv11sNeck用默认的C2f。配置mAP0.5mAP0.5:0.95参数量推理速度(ms)Baseline0.5120.2879.4M2.1DAT(替换所有C2f)0.5340.30611.8M3.8DAT(只替换Neck最后3个C2f)0.5280.30110.6M2.9DAT(只替换Neck中间1个C2f)0.5210.29510.1M2.5从数据看全替换效果最好但速度下降太多。只替换Neck最后3个C2f即P3/P4/P5层各一个是性价比最高的方案mAP涨了1.6个点速度只慢了0.8ms。我还做了个有趣的实验把DAT的n_points从8改成16mAP涨了0.2%但速度慢了0.5ms不划算。n_points4时mAP反而比baseline低0.1%说明采样点太少学不到有效信息。训练细节别让DAT变成噪声源DAT训练时有个坑学习率要调低。我一开始用默认的lr0.01训练到第50个epoch时mAP突然暴跌一看loss曲线偏移量预测的梯度爆炸了。后来把lr降到0.005加上warmup就稳了。另外DAT的temperature参数在训练初期会快速增大这是正常的。如果发现temperature一直不增长比如卡在0.1附近说明采样范围太小可以手动把初始值改成0.5。还有一个经验在训练的前10个epoch可以冻结DAT模块的偏移量预测部分只训练其他参数。等模型对特征图有了基本认知后再放开偏移量训练。这样能避免早期梯度噪声干扰。个人经验什么时候该用DATDAT不是万能的。如果你的数据集目标尺度变化不大比如人脸检测都是差不多大小的脸普通C2f完全够用加了DAT反而可能过拟合。我试过在WIDER Face上做实验mAP只涨了0.1%速度还慢了。但遇到以下场景DAT值得一试小目标密集场景VisDrone、SKU-110K细长目标电线杆、桥梁、道路目标尺度跨度超过10倍比如同时检测行人和车辆另外DAT对显存的要求比普通C2f高如果你的显卡只有8GB显存建议只在最大的特征图P3层上使用DAT其他层保持原样。我实测在RTX 3060上YOLOv11s单层DAT可以跑到batch size24全替换只能跑到12。最后说一句别把DAT当成黑盒。调试的时候把采样点可视化出来看看模型是不是真的学到了自适应采样。我见过一个case模型把所有采样点都集中在图像中心说明温度系数没学好或者偏移量预测网络太弱了。这时候可以加大offset_conv的kernel size从3x3改成5x5让偏移量感受野更大。