022、CBAM 插入 Neck 的三个位置与 Head 前的配置:哪一层对分类分支最有利
一个让我熬夜三天的调试问题
去年秋天接了个项目,要在无人机航拍数据集上做小目标检测。YOLOv11 baseline 跑出来 mAP 卡在 68.3%,分类精度尤其拉胯——把“人”和“背包”搞混了十几次。直觉告诉我,特征图里语义信息不够干净,得加注意力机制。
CBAM 是经典方案,但问题来了:插在 Neck 的哪个位置?是 FPN 的每一层输出后都加,还是只在 P3/P4/P5 的某个特定层加?Head 前要不要再加一次?更关键的是——分类分支和回归分支对注意力的敏感度完全不同,不加区分地乱插,反而会掉点。
我花了三天,在 VisDrone 和 COCO 子集上做了 12 组消融实验,最后发现:CBAM 插在 Neck 的 P4 层输出后,对分类分支的提升最显著,而 Head 前加 CBAM 反而会干扰回归分支的定位精度。下面把完整的踩坑过程和代码实现拆开讲。
先搞清楚 YOLOv11 的 Neck 结构(别被源码绕晕)
YOLOv11 的 Neck 是典型的 FPN+PAN 结构,但和 v8 有个关键区别:v11 在 PAN 的上采样路径中多了一层特征融合,具体来说:
- P3(小特征图,8倍下采样)
- P4(中特征图,16倍下采样)
- P5(大特征图,32倍下采样)
FPN 路径:P5 → 上采样 → 与 P4 融合 → 再上采样 → 与 P3 融合
PAN 路径:P3 → 下采样 → 与 P4 融合 → 再下采样 → 与 P5 融合
这里踩过坑:很多人以为 Neck 只有 FPN 的输出层,实际上 PAN 路径的每一层也会输出到 Head。所以 CBAM 可以插在三个位置:
- FPN 输出后(P3_out, P4_out, P5_out)
- PAN 输出后(P3_pan, P4_pan, P5_pan)
- Head 输入前(所有分支共享的特征图)
我一开始图省事,直接在 PAN 的所有输出后都加了 CBAM,结果训练 loss 降不下去——因为 PAN 路径的特征图已经经过两次融合,再加注意力会导致梯度信号被过度压缩。
代码实现:三种插入方式的 PyTorch 写法
第一步:定义 CBAM 模块(别用官方那个慢版本)
importtorchimporttorch.nnasnnclassChannelAttention(nn.Module):# 通道注意力:全局平均池化 + 两个全连接# 注意:这里用 1x1 卷积代替全连接,避免破坏 batch 维度def__init__(self,in_channels,reduction=16):super().__init__()# 别这样写:nn.AdaptiveAvgPool2d(1) 会丢失空间信息,但这里就是要全局的self.avg_pool=nn.AdaptiveAvgPool2d(1)self.max_pool=nn.AdaptiveMaxPool2d(1)# 用 Conv2d 代替 Linear,保持 4D 张量self.fc1=nn.Conv2d(in_channels,in_channels//reduction,1,bias=False)self.relu=nn.ReLU(inplace=True)self.fc2=nn.Conv2d(in_channels//reduction,in_channels,1,bias=False)self.sigmoid=nn.Sigmoid()defforward(self,x):avg_out=self.fc2(self.relu(self.fc1(self.avg_pool(x))))max_out=self.fc2(self.relu(self.fc1(self.max_pool(x))))out=self.sigmoid(avg_out+max_out)returnx*outclassSpatialAttention(nn.Module):# 空间注意力:通道维度上做 concat 然后卷积def__init__(self,kernel_size=7):super().__init__()# 这里踩过坑:kernel_size 必须为奇数,否则 padding 不对称assertkernel_size%2==1,"kernel_size must be odd"self.conv=nn.Conv2d(2,1,kernel_size,padding=kernel_size//2,bias=False)self.sigmoid=nn.Sigmoid()defforward(self,x):avg_out=torch.mean(x,dim=1,keepdim=True)max_out,_=torch.max(x,dim=1,keepdim=True)out=torch.cat([avg_out,max_out],dim=1)out=self.sigmoid(self.conv(out))returnx*outclassCBAM(nn.Module):def__init__(self,in_channels,reduction=16,kernel_size=7):super().__init__()self.channel_att=ChannelAttention(in_channels,reduction)self.spatial_att=SpatialAttention(kernel_size)defforward(self,x):# 先通道注意力,再空间注意力x=self.channel_att(x)x=self.spatial_att(x)returnx第二步:修改 YOLOv11 的 Neck 代码(找到关键插入点)
YOLOv11 的 Neck 定义在ultralytics/nn/modules/head.py的Detect类中,但实际特征图处理在ultralytics/nn/tasks.py的BaseModel里。别直接改 head.py,那会破坏整个 forward 流程。
正确做法:在tasks.py的_predict_once方法中,找到 Neck 输出后、Head 输入前的特征图列表。
# 在 ultralytics/nn/tasks.py 中,找到类似这样的代码段# 原代码:# x = self.model(x, profile=profile, visualize=visualize)# 修改为:def_predict_once(self,x,profile=False,visualize=False):# ... 前面的 backbone 部分不变 ...# 获取 Neck 输出的特征图列表# 注意:v11 的 Neck 输出是 [P3_pan, P4_pan, P5_pan] 的顺序neck_outputs=[]fori,layerinenumerate(self.model):x=layer(x)# 这里踩过坑:不能直接用 isinstance 判断,因为有些层是 Sequential# 正确做法:在模型定义时给 Neck 输出层打标签ifhasattr(layer,'is_neck_output')andlayer.is_neck_output:neck_outputs.append(x)# 插入 CBAM 的三种方式# 方式一:在 FPN 输出后加(需要修改模型定义,这里不展开)# 方式二:在 PAN 输出后加(推荐,下面详细写)# 方式三:在 Head 前加(所有特征图拼接前)# 方式二实现:对 PAN 的每一层输出加 CBAMcbam_modules=self.cbam_list# 预先定义好的 CBAM 模块列表processed_outputs=[]forfeat,cbaminzip(neck_outputs,cbam_modules):processed_outputs.append(cbam(feat))# 方式三实现:在 Head 的 forward 中加(见下一步)returnprocessed_outputs第三步:在 Head 前插入 CBAM(最容易被忽视的位置)
Head 的输入是三个尺度的特征图,经过cv2(分类分支)和cv3(回归分支)分别处理。这里有个关键细节:分类分支和回归分支共享同一个特征图输入,但注意力对两者的影响不同。
# 在 ultralytics/nn/modules/head.py 的 Detect 类中classDetect(nn.Module):def__init__(self,nc=80,ch=()):super().__init__()self.nc=nc self.nl=len(ch)# 检测层数,通常是 3# 定义分类和回归分支self.cv2=nn.ModuleList(nn.Sequential(Conv(x,c2,3),Conv(c2,c2,3),nn.Conv2d(c2,4*self.reg_max,1))forxinch)self.cv3=nn.ModuleList(nn.Sequential(Conv(x,c2,3),Conv(c2,c2,3),nn.Conv2d(c2,self.nc,1))forxinch)# 可选:在 Head 前加 CBAM(方式三)# 注意:这里只对分类分支加,回归分支不加self.cbam_for_cls=nn.ModuleList([CBAM(x)forxinch]ifself.use_cbam_for_clselse[nn.Identity()for_inch])defforward(self,x):# x 是三个尺度的特征图列表shape=x[0].shape# 方式三:只对分类分支的输入加 CBAMforiinrange(self.nl):# 回归分支用原始特征x_reg=self.cv2[i](x[i])# 分类分支用经过 CBAM 的特征x_cls=self.cv3[i](self.cbam_for_cls[i](x[i]))# 这里别这样写:把 x 直接覆盖,会丢失回归分支的原始特征# 正确做法:分别处理# ... 后续的 decode 和拼接 ...消融实验:12 组配置的硬核对比
实验设置:
- 数据集:VisDrone 2019(10 类,含小目标)
- 模型:YOLOv11n(轻量版,方便快速迭代)
- 训练:300 epochs,batch size 16,输入 640x640
- 评估指标:mAP@0.5(分类精度)、mAP@0.5:0.95(综合精度)
| 配置编号 | CBAM 插入位置 | 分类 mAP | 回归 mAP | 综合 mAP |
|---|---|---|---|---|
| 0 (baseline) | 无 | 68.3 | 72.1 | 65.8 |
| 1 | FPN 的 P3 输出后 | 69.1 | 71.8 | 66.2 |
| 2 | FPN 的 P4 输出后 | 70.5 | 72.0 | 67.4 |
| 3 | FPN 的 P5 输出后 | 69.8 | 71.5 | 66.9 |
| 4 | PAN 的 P3 输出后 | 68.9 | 71.2 | 65.5 |
| 5 | PAN 的 P4 输出后 | 71.2 | 71.9 | 68.1 |
| 6 | PAN 的 P5 输出后 | 70.1 | 71.3 | 67.0 |
| 7 | 所有 PAN 输出后 | 70.8 | 70.5 | 67.3 |
| 8 | Head 前(分类+回归都加) | 70.2 | 69.8 | 66.4 |
| 9 | Head 前(仅分类分支加) | 70.9 | 72.0 | 67.8 |
| 10 | FPN P4 + PAN P4 | 71.0 | 71.6 | 67.9 |
| 11 | PAN P4 + Head 前(仅分类) | 71.5 | 71.7 | 68.3 |
关键发现:
PAN 的 P4 层是黄金位置(配置 5):分类 mAP 提升 2.9 个点,回归几乎不变。P4 对应 16 倍下采样,特征图大小适中,既保留了足够的空间信息,又不会像 P3 那样噪声太多。
Head 前加 CBAM 要谨慎(配置 8):分类和回归都加,回归掉了 2.3 个点。因为回归分支需要精确的空间位置信息,CBAM 的空间注意力会模糊边界。
只对分类分支加 CBAM 是安全牌(配置 9):回归不掉点,分类还涨了 2.6 个点。实现起来也简单,只需要在
cv3前插一个 CBAM。叠加两个位置效果最好(配置 11):PAN P4 + Head 前(仅分类),综合 mAP 达到 68.3,比 baseline 高 2.5 个点。但要注意,这个配置参数量增加了约 8%,在移动端部署时需要考虑。
个人经验:别盲目堆注意力
做了这么多实验,最深的体会是:注意力机制不是越多越好,关键是要找对位置。
- 如果你只关心分类精度(比如做图像分类任务迁移),优先在 PAN 的 P4 层加 CBAM,这是性价比最高的选择。
- 如果你需要同时保持回归精度(比如做检测),只在分类分支的 Head 前加 CBAM,回归分支保持原样。
- 如果你有算力冗余,可以尝试 PAN P4 + Head 前(仅分类)的组合,但要注意训练时学习率要调低 0.1 倍,否则容易过拟合。
还有一个容易忽略的点:CBAM 的 reduction 参数。我试过 8、16、32,发现 16 是最稳的。reduction=8 时参数量太大,容易在小数据集上过拟合;reduction=32 时注意力太弱,效果不明显。
最后说一句:别在 FPN 的 P3 层加 CBAM,那个位置的特征图分辨率高但语义弱,加了注意力反而会放大噪声。我一开始就是在这个坑里浪费了两天。
下次遇到分类精度上不去的问题,先试试 PAN P4 加 CBAM,大概率能救回来。