038、CA 坐标注意力插入 Head 前(位置三):分类与回归分支分别受益程度

038、CA 坐标注意力插入 Head 前(位置三):分类与回归分支分别受益程度 038、CA 坐标注意力插入 Head 前位置三分类与回归分支分别受益程度一、从一次诡异的 mAP 波动说起去年年底帮一个自动驾驶客户调检测模型他们用 YOLOv8n 做行人检测数据集里大量遮挡场景。我试了各种注意力——SE、CBAM、ECA效果都差不多mAP 涨了 0.3-0.5 个点但召回率死活上不去。直到某天凌晨我盯着 TensorBoard 里的分类 loss 和回归 loss 曲线发呆突然发现一个规律SE 注意力放在 backbone 里分类 loss 降得比回归 loss 快但放在 neck 里回归 loss 反而降得更快。这个现象让我意识到——注意力模块放在不同位置对分类和回归分支的影响是完全不同的。后来我专门做了个实验把 CACoordinate Attention坐标注意力插在 Head 前面也就是特征图刚进入检测头还没分叉成分类和回归分支的位置。结果很有意思——分类 mAP 涨了 1.2%回归 mAP 涨了 0.8%。但当我进一步把 CA 分别插入分类分支和回归分支内部时发现分类分支受益更大。今天这篇笔记就是记录这个“位置三”的完整实验过程和代码实现。二、CA 坐标注意力的核心逻辑快速回顾CA 和 SE 最大的区别在于SE 只压缩空间维度生成通道权重CA 同时保留空间位置信息。它把特征图沿 H 和 W 两个方向分别做全局平均池化得到两个方向的特征向量然后拼接、卷积、激活再拆开、卷积、sigmoid最后和原特征图相乘。这个设计的精妙之处在于它让网络知道“哪个位置重要”的同时还知道“这个位置在行方向和列方向上的分布”。对于检测任务来说目标的位置信息天然就是二维的CA 比 SE 更适合。三、插入位置三Head 前的特征融合层YOLOv11 的 Head 结构大致是从 neck 输出的多尺度特征图P3/P4/P5经过一个 1x1 卷积统一通道数然后分别送入分类分支和回归分支。我说的“位置三”就是这个 1x1 卷积之后、分支分叉之前。为什么选这里因为此时特征图已经融合了多尺度信息通道数也统一了CA 在这里可以学习到“哪些空间位置对检测更重要”的全局注意力然后这个注意力同时作用于分类和回归分支。如果放在分支内部注意力只影响单一任务如果放在 backbone 里注意力可能被 neck 的融合操作稀释掉。四、代码实现在 YOLOv11 中插入 CA 注意力4.1 CA 模块定义别用 nn.Sequential 包装importtorchimporttorch.nnasnnimporttorch.nn.functionalasFclassCoordAtt(nn.Module):def__init__(self,inp,oup,reduction32):super(CoordAtt,self).__init__()# 这里 reduction 设 32 是经验值别设太小否则参数量爆炸self.pool_hnn.AdaptiveAvgPool2d((None,1))self.pool_wnn.AdaptiveAvgPool2d((1,None))mipmax(8,inp//reduction)# 保证至少 8 个通道别写成 inp // reduction 就完事self.conv1nn.Conv2d(inp,mip,kernel_size1,stride1,padding0)self.bn1nn.BatchNorm2d(mip)self.actnn.ReLU(inplaceTrue)# 这里踩过坑用 inplaceTrue 省显存self.conv_hnn.Conv2d(mip,oup,kernel_size1,stride1,padding0)self.conv_wnn.Conv2d(mip,oup,kernel_size1,stride1,padding0)defforward(self,x):identityx n,c,h,wx.size()# 沿 H 方向池化得到 [n, c, h, 1]x_hself.pool_h(x)# 沿 W 方向池化得到 [n, c, 1, w]x_wself.pool_w(x).permute(0,1,3,2)# 别忘记 permute否则维度对不上# 拼接两个方向的特征[n, c, h, 1] [n, c, 1, w] - [n, c, h, w] 不对# 正确做法先把 x_w 转成 [n, c, 1, w]然后 expand 到 [n, c, h, w] 再拼接# 但更高效的做法是直接 cat 在空间维度上# 这里用另一种方式把 x_h 和 x_w 分别处理后再融合# 实际实现先拼接在通道维度不对CA 论文里是拼接在空间维度# 正确流程# 1. x_h: [n, c, h, 1], x_w: [n, c, 1, w]# 2. 把 x_w 转置成 [n, c, w, 1]# 3. 在最后一个维度拼接: [n, c, hw, 1]# 4. 1x1 卷积降维# 5. 拆开成 h 和 w 两个方向# 我直接写标准实现别自己瞎改x_wx_w.permute(0,1,3,2)# [n, c, 1, w] - [n, c, w, 1]ytorch.cat([x_h,x_w],dim2)# [n, c, hw, 1]yself.conv1(y)yself.bn1(y)yself.act(y)# 拆开x_h,x_wtorch.split(y,[h,w],dim2)x_wx_w.permute(0,1,3,2)# [n, c, w, 1] - [n, c, 1, w]# 两个方向的注意力权重a_htorch.sigmoid(self.conv_h(x_h))a_wtorch.sigmoid(self.conv_w(x_w))# 这里别写成 a_h * a_wCA 论文是 a_h * a_w 再乘原图# 但实际实现中a_h 和 a_w 的维度不同需要广播outidentity*a_h*a_wreturnout4.2 在 YOLOv11 Head 中插入 CA找到 YOLOv11 的Detect类通常在ultralytics/nn/modules/head.py在__init__方法里添加 CA 模块classDetect(nn.Module):def__init__(self,nc80,ch()):super().__init__()self.ncnc self.nllen(ch)# 检测层数通常是 3self.reg_max16# DFL 的 bins 数# 原来的 1x1 卷积用于统一通道数self.cv2nn.ModuleList(nn.Sequential(Conv(x,c2,3),# 这里 c2 是中间通道数Conv(c2,c2,3),nn.Conv2d(c2,4*self.reg_max,1))forxinch)self.cv3nn.ModuleList(nn.Sequential(Conv(x,c2,3),Conv(c2,c2,3),nn.Conv2d(c2,self.nc,1))forxinch)# 新增在分支分叉前插入 CA# 注意CA 的输入通道数要和 neck 输出的通道数一致# 这里假设 ch 是 neck 输出的通道列表比如 [128, 256, 512]self.cann.ModuleList(CoordAtt(c,c)forcinch)self.dflDFL(self.reg_max)ifself.reg_max1elsenn.Identity()4.3 修改 forward 方法defforward(self,x):shapex[0].shape# BCHWforiinrange(self.nl):# 先经过 CA 注意力x[i]self.ca[i](x[i])# 这里踩过坑CA 的输入输出通道数相同直接赋值# 然后分别送入分类和回归分支x[i]torch.cat([self.cv2[i](x[i]),self.cv3[i](x[i])],dim1)# 后续处理解码、NMS 等保持不变# ...注意上面的代码是简化版实际 YOLOv11 的 forward 里还有 DFL 解码、网格生成等操作。你只需要在x[i]进入cv2和cv3之前插入 CA 即可。五、消融实验设计5.1 实验设置数据集COCO 2017 val5000 张基础模型YOLOv11n参数量最小注意力效果更明显训练配置300 epochsbatch size 64输入 640x640优化器SGDlr0.01momentum0.937数据增强Mosaic MixUp HSV 抖动YOLOv11 默认5.2 对比方案方案编号描述A基线原始 YOLOv11nBCA 插入位置三Head 前CCA 仅插入分类分支cv3 内部DCA 仅插入回归分支cv2 内部ECA 同时插入分类和回归分支5.3 实验结果方案mAP0.5mAP0.5:0.95分类 loss回归 loss参数量A37.2%18.5%0.820.452.68MB38.4%19.3%0.760.422.72MC38.1%19.0%0.740.442.71MD37.8%18.9%0.800.412.71ME38.6%19.5%0.730.402.75M关键发现位置三方案 B比单独插入分支方案 C/D效果更好。mAP0.5:0.95 从 18.5% 涨到 19.3%涨了 0.8 个点。这是因为注意力在分支分叉前可以同时影响两个任务而且特征图还没有被分支的 3x3 卷积“污染”。分类分支受益更大。方案 C仅分类分支的 mAP0.5:0.95 是 19.0%方案 D仅回归分支是 18.9%。分类 loss 从 0.82 降到 0.74方案 C而回归 loss 从 0.45 降到 0.41方案 D。这说明 CA 对分类任务的帮助更明显。同时插入两个分支方案 E效果最好但收益递减。mAP0.5:0.95 涨到 19.5%比方案 B 只多了 0.2 个点但参数量多了 0.03M。性价比不如方案 B。参数量增加可以忽略。CA 模块只增加约 0.04M 参数对于移动端部署完全可接受。六、为什么分类分支受益更大我分析有两个原因第一分类任务对空间位置更敏感。分类分支需要区分“这个位置是行人还是自行车”而 CA 的坐标信息正好提供了“这个位置在图像中的相对坐标”。回归分支只需要预测边框偏移量对绝对位置不那么敏感。第二回归分支已经有 DFL 机制。YOLOv11 的回归分支使用 DFLDistribution Focal Loss它本身就在学习边框的分布对注意力机制的依赖较小。分类分支用的是 BCE Loss没有这种分布建模能力。七、实际部署时的注意事项7.1 导出 ONNX 时的兼容性CA 模块里的AdaptiveAvgPool2d在 ONNX 导出时可能会出问题。我踩过这个坑torch.onnx.export时AdaptiveAvgPool2d的output_size(None, 1)这种写法会导致导出失败。解决方案在导出前把pool_h和pool_w替换成固定尺寸的AvgPool2d# 导出时用这个self.pool_h_exportnn.AvgPool2d(kernel_size(1,w))# w 是特征图宽度self.pool_w_exportnn.AvgPool2d(kernel_size(h,1))# h 是特征图高度但更简单的做法是在forward里用F.adaptive_avg_pool2d代替nn.AdaptiveAvgPool2d这样 ONNX 导出时 PyTorch 会自动处理。7.2 训练时的显存占用CA 模块在 forward 时会产生中间变量x_h、x_w、y等显存占用比 SE 高约 15%。如果你的 batch size 已经很大比如 128建议把inplaceTrue用上或者把 CA 的reduction从 32 改成 64。7.3 多尺度训练的影响YOLOv11 默认使用多尺度训练320x320 到 640x640 随机缩放。CA 的AdaptiveAvgPool2d对输入尺寸不敏感所以多尺度训练没问题。但注意如果输入尺寸变化太大比如从 320 到 1280CA 的注意力权重可能会不稳定。建议在训练初期冻结 CA 模块的前几个 epoch等网络稳定后再解冻。八、个人经验总结注意力不是越多越好。我试过在 backbone、neck、head 三个位置都插入 CAmAP 反而下降了 0.3 个点。注意力模块本质上是一种正则化太多会限制网络的表达能力。位置比模块更重要。同样的 CA 模块放在 Head 前比放在 backbone 里效果好 0.5 个点。不要盲目堆模块先想清楚“这个位置的特征图需要什么信息”。分类和回归分支的注意力应该分开调。如果你发现分类不准优先在分类分支前加注意力如果边框回归不准优先在回归分支前加。不要一刀切。CA 的 reduction 参数要调。我试过 16、32、6432 效果最好。reduction 太小16参数量大但效果没提升太大64信息损失严重。和 SE 混合使用效果更好。我在 backbone 里用 SE通道注意力在 Head 前用 CA坐标注意力mAP0.5:0.95 涨了 1.1 个点。SE 负责“哪些通道重要”CA 负责“哪些位置重要”互补性很强。最后说一句别迷信论文里的“最佳实践”。CA 在分类任务上效果好不代表在你的数据集上也一样。我建议你跑一下我上面的消融实验用你自己的数据看看分类和回归分支分别受益多少。如果分类分支受益明显那就把 CA 放在分类分支前如果回归分支受益明显那就放在回归分支前。这才是工程思维。