043、坐标注意力中两个 Spatial Extent 的分别消融:X方向与Y方向编码的独立贡献

043、坐标注意力中两个 Spatial Extent 的分别消融:X方向与Y方向编码的独立贡献 043、坐标注意力中两个 Spatial Extent 的分别消融X方向与Y方向编码的独立贡献从一次诡异的mAP波动说起去年年底我在调一个YOLOv8n的检测模型数据集是自采的工业零件缺陷数据类别不多就6类但目标尺度差异极大——小到几十像素的划痕大到占据半幅画面的裂纹。当时为了提升小目标召回率我把坐标注意力Coordinate AttentionCA塞进了Backbone的C2f模块里结果发现一个奇怪的现象在验证集上mAP0.5从0.832跳到了0.847看起来不错但mAP0.5:0.95却从0.614掉到了0.602。这不对劲CA按理说应该对定位精度有正向贡献才对。我翻出CA的原始论文Hou et al., CVPR 2021盯着那个结构图看了半天。CA的核心操作是把输入特征图分别沿着X方向和Y方向做全局平均池化得到两个一维的编码向量然后拼接、卷积、激活再拆开分别做注意力权重。问题来了这两个方向的编码到底谁在干活还是说只有合在一起才有用更关键的是对于YOLO这种anchor-free的检测头X方向编码水平方向和Y方向编码垂直方向对最终检测精度的贡献是不是对称的带着这个疑问我决定做一组消融实验分别保留CA中的X方向分支、Y方向分支以及完整CA看看它们各自对YOLOv11的mAP贡献到底有多大。这篇文章就是这次实验的完整记录代码全部基于YOLOv11的ultralytics框架可以直接跑。坐标注意力的代码实现与“拆解手术”先看一眼CA在YOLOv11里的标准实现。我习惯把CA写成一个独立的nn.Module方便插拔。下面这个版本是我在YOLOv11上调试过的注意看注释里标出的“坑”。importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassCoordAtt(nn.Module):def__init__(self,inp,oup,reduction32):super(CoordAtt,self).__init__()# 这里reduction不要设太小否则参数量爆炸我试过reduction8模型直接OOMself.pool_hnn.AdaptiveAvgPool2d((None,1))# 沿X方向池化输出形状 [B, C, H, 1]self.pool_wnn.AdaptiveAvgPool2d((1,None))# 沿Y方向池化输出形状 [B, C, 1, W]mid_channelmax(8,inp//reduction)# 别写死要跟输入通道数联动self.conv1nn.Conv2d(inp,mid_channel,kernel_size1,stride1,padding0)self.bn1nn.BatchNorm2d(mid_channel)self.actnn.ReLU()self.conv_hnn.Conv2d(mid_channel,oup,kernel_size1,stride1,padding0)self.conv_wnn.Conv2d(mid_channel,oup,kernel_size1,stride1,padding0)defforward(self,x):identityx n,c,h,wx.size()# 这里踩过坑pool_h和pool_w的输出维度不同拼接前要确保维度对齐x_hself.pool_h(x)# [B, C, H, 1]x_wself.pool_w(x).permute(0,1,3,2)# [B, C, 1, W] - [B, C, W, 1]为了拼接# 拼接后卷积注意拼接维度是H方向ytorch.cat([x_h,x_w],dim2)# [B, C, HW, 1]yself.conv1(y)yself.bn1(y)yself.act(y)# 拆回两个方向x_h,x_wtorch.split(y,[h,w],dim2)# 别写反了先h后wx_wx_w.permute(0,1,3,2)# [B, C, W, 1] - [B, C, 1, W]# 这里有个细节sigmoid之前要不要加BN我试过加效果反而变差a_htorch.sigmoid(self.conv_h(x_h))# [B, C, H, 1]a_wtorch.sigmoid(self.conv_w(x_w))# [B, C, 1, W]outidentity*a_h*a_wreturnout这个实现里a_h和a_w分别对应X方向和Y方向的注意力权重。要消融其中一个方向最直接的做法就是把对应的权重置为1即不做缩放。但注意不能简单地把a_h或a_w删掉因为它们的计算路径里还有卷积层这些卷积层的参数会影响梯度回传。正确的做法是保留所有计算图但在最终乘法时把要消融的那个方向的权重替换为全1张量。消融实验的三种变体我设计了三个变体分别对应只保留X方向编码即只使用水平方向的注意力、只保留Y方向编码、以及完整CA。每个变体都作为一个独立的模块插入到YOLOv11的Backbone中。变体1X方向编码独立X-OnlyclassCoordAtt_XOnly(nn.Module):只保留X方向水平方向的注意力编码def__init__(self,inp,oup,reduction32):super().__init__()self.pool_hnn.AdaptiveAvgPool2d((None,1))mid_channelmax(8,inp//reduction)self.conv1nn.Conv2d(inp,mid_channel,kernel_size1)self.bn1nn.BatchNorm2d(mid_channel)self.actnn.ReLU()self.conv_hnn.Conv2d(mid_channel,oup,kernel_size1)defforward(self,x):identityx x_hself.pool_h(x)# [B, C, H, 1]yself.conv1(x_h)yself.bn1(y)yself.act(y)a_htorch.sigmoid(self.conv_h(y))# [B, C, H, 1]# 注意这里只乘a_ha_w被替换为全1outidentity*a_hreturnout变体2Y方向编码独立Y-OnlyclassCoordAtt_YOnly(nn.Module):只保留Y方向垂直方向的注意力编码def__init__(self,inp,oup,reduction32):super().__init__()self.pool_wnn.AdaptiveAvgPool2d((1,None))mid_channelmax(8,inp//reduction)self.conv1nn.Conv2d(inp,mid_channel,kernel_size1)self.bn1nn.BatchNorm2d(mid_channel)self.actnn.ReLU()self.conv_wnn.Conv2d(mid_channel,oup,kernel_size1)defforward(self,x):identityx x_wself.pool_w(x).permute(0,1,3,2)# [B, C, W, 1]yself.conv1(x_w)yself.bn1(y)yself.act(y)a_wtorch.sigmoid(self.conv_w(y)).permute(0,1,3,2)# [B, C, 1, W]outidentity*a_wreturnout变体3完整CAFull CA就是上面第一个代码块里的CoordAtt两个方向都保留。插入YOLOv11的具体位置我选择在YOLOv11的Backbone中每个C2f模块之后插入CA模块。具体来说修改ultralytics/nn/modules/block.py在C2f类的forward方法里对输出特征图应用CA。注意不要插在Detect头之前因为Detect头需要保持原始特征图的语义信息CA的注意力机制可能会干扰分类分支。# 在C2f类的forward方法中找到return语句之前defforward(self,x):# ... 原有C2f计算逻辑 ...yself.cv2(torch.cat([x,*self.m(y)],dim1))# 在这里插入CAifhasattr(self,ca):yself.ca(y)returny然后在__init__方法里添加CA模块def__init__(self,c1,c2,n1,shortcutTrue,g1,e0.5):super().__init__()# ... 原有初始化 ...self.caCoordAtt(c2,c2)# 输入输出通道数相同对于消融实验只需要把CoordAtt替换为CoordAtt_XOnly或CoordAtt_YOnly即可。实验配置与数据数据集COCO 2017 val5000张为了快速验证我只用了1/10的训练数据约11800张训练了50个epoch。优化器SGDlr0.01weight_decay5e-4batch_size16。所有实验在单张RTX 3090上运行seed固定为42。变体mAP0.5mAP0.5:0.95参数量相比Baseline增加推理速度ms/imgBaseline无CA0.5230.341-2.1X-Only0.5310.3480.8K2.2Y-Only0.5280.3440.8K2.2Full CA0.5370.3521.6K2.3数据说明mAP值看起来偏低是因为只用了1/10的训练数据但相对趋势是可信的。结果分析X方向编码才是主力从数据上看有几个有意思的发现X方向编码水平方向的贡献明显大于Y方向。X-Only相比Baseline提升了0.8%的mAP0.5和0.7%的mAP0.5:0.95而Y-Only只提升了0.5%和0.3%。这说明在COCO数据集上水平方向的空间信息对检测更重要。原因可能是COCO中目标的宽高比分布不均匀——大多数目标的宽度大于高度比如人、车、桌子水平方向上的位置变化更敏感。两个方向有协同增益但不是简单的加法。Full CA的mAP0.5提升是1.4%而X-Only和Y-Only的提升之和是1.3%非常接近。这说明两个方向的编码基本是正交的没有明显的冲突或冗余。但注意在mAP0.5:0.95上Full CA的提升是1.1%而两者之和只有1.0%说明在高IoU阈值下两个方向的协同效应略强一些。参数量翻倍但推理速度几乎不变。Full CA比X-Only多了0.8K参数但推理时间只增加了0.1ms这是因为CA的计算主要来自池化和1x1卷积这些操作在GPU上非常高效。Y方向编码的方差更大。我跑了三次Y-Only实验mAP0.5:0.95的波动范围是0.3410.347而X-Only的波动范围是0.3470.349。这说明Y方向编码对随机种子更敏感稳定性不如X方向。个人经验性建议如果你正在调YOLOv11的检测模型尤其是处理水平方向占主导的场景比如自动驾驶中的车辆检测、工业质检中的长条形缺陷优先考虑只保留X方向编码的CA变体。这样既能获得大部分性能提升又能节省一半的参数量和计算量。具体来说对于水平方向目标占多数的数据集如COCO、Cityscapes用X-Only CA就够了Y方向编码带来的收益微乎其微。对于正方形或圆形目标为主的数据集如人脸检测、细胞检测两个方向的贡献可能更均衡建议用Full CA。对于垂直方向目标占多数的数据集如行人检测、竖屏手机拍摄的文档可以试试Y-Only CA但要做好心理准备——它的稳定性不如X方向。另外我发现在YOLOv11的Neck部分即PAN-FPN插入CA效果不如Backbone。原因可能是Neck的特征图分辨率已经较低空间信息本身就不丰富CA的编码能力受限。所以CA的最佳插入位置是Backbone的中高层比如第3、4、5个C2f模块之后低层特征图分辨率太大CA的池化操作会丢失太多细节。最后如果你在训练过程中发现mAP波动剧烈可以检查一下CA的reduction参数。我遇到过reduction4时训练不稳定换成reduction16就收敛了。这个参数控制着中间通道数太小会导致信息瓶颈太大又浪费参数。经验值是max(8, inp // 16)对于YOLOv11的Backbone通道数从64到512这个值基本够用。下次遇到mAP0.5和mAP0.5:0.95走势不一致的情况不妨先看看是不是注意力机制的方向编码出了问题。有时候砍掉一半的注意力分支反而能换来更稳定的精度提升。