061、AFF 注意力特征融合在 YOLOv11 Skip Connection 中的应用与多尺度特征增强

061、AFF 注意力特征融合在 YOLOv11 Skip Connection 中的应用与多尺度特征增强 061、AFF 注意力特征融合在 YOLOv11 Skip Connection 中的应用与多尺度特征增强从一次诡异的mAP震荡说起去年年底调YOLOv11的C2f模块发现一个奇怪现象训练到150轮左右小目标mAP突然掉3个点然后慢慢回升。查了三天最后定位到是Skip Connection的简单相加操作在深层特征图上产生了严重的语义冲突——浅层纹理和深层语义在相加时互相“打架”。当时试过加权求和、SE模块重标定效果都不稳定。直到翻到AFFAttentional Feature Fusion那篇论文才意识到问题本质特征融合不是简单的“加”或“拼”而是需要让网络自己学会怎么融合。AFF的核心思想别让特征“硬加”传统Skip Connection就是x f(x)但YOLOv11的C2f里跨尺度特征经过不同卷积层后分布差异很大。AFF的做法是对两个输入特征图分别做全局平均池化然后通过一个轻量级MLP生成融合权重再用softmax归一化最后加权求和。关键点在于——这个权重是逐通道的而且两个分支共享MLP参数所以计算量很小。我实现的版本去掉了原论文里冗余的3x3卷积直接对C2f的shortcut和主分支输出做融合。实测在YOLOv11的Neck部分P3/P4/P5层各加一个AFF参数量只增加0.3M但小目标AP提升了1.8%。代码实现手把手改YOLOv11第一步定义AFF模块在ultralytics/nn/modules/block.py末尾添加classAFF(nn.Module):注意力特征融合用于替换C2f中的简单相加def__init__(self,channels,r4):super().__init__()# 这里r4是压缩比别设太大否则信息丢失严重inter_channelsmax(channels//r,16)# 至少保留16通道防止过压缩# 共享的MLP两个分支用同一组参数self.mlpnn.Sequential(nn.Linear(channels,inter_channels,biasFalse),nn.ReLU(inplaceTrue),nn.Linear(inter_channels,channels,biasFalse))# 全局平均池化注意保持维度self.gapnn.AdaptiveAvgPool2d(1)# 这里踩过坑softmax要沿着通道维度做不是batchself.softmaxnn.Softmax(dim1)defforward(self,x,y):# x是shortcuty是主分支输出# 先分别做GAP得到两个1x1xC的向量x_gapself.gap(x).squeeze(-1).squeeze(-1)# [B, C]y_gapself.gap(y).squeeze(-1).squeeze(-1)# [B, C]# 通过共享MLP得到注意力分数x_attself.mlp(x_gap)# [B, C]y_attself.mlp(y_gap)# [B, C]# 堆叠成[B, 2, C]然后softmaxatttorch.stack([x_att,y_att],dim1)# [B, 2, C]attself.softmax(att)# 归一化后两个分支权重和为1# 加权融合别写成x*att[:,0] y*att[:,1]要unsqueeze扩展维度x_weightatt[:,0].unsqueeze(-1).unsqueeze(-1)# [B, C, 1, 1]y_weightatt[:,1].unsqueeze(-1).unsqueeze(-1)returnx*x_weighty*y_weight第二步修改C2f的forward找到ultralytics/nn/modules/block.py中的C2f类修改其forward方法classC2f(nn.Module):def__init__(self,c1,c2,n1,shortcutFalse,g1,e0.5):super().__init__()self.cint(c2*e)# hidden channelsself.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))# 新增是否使用AFF融合self.use_affTrue# 默认开启可以在配置文件中控制ifself.use_aff:# 别这样写AFF(self.c * 2)因为输入是2*self.c通道self.affAFF(channelsself.c*2)# 融合cv1的两个分支输出defforward(self,x):ylist(self.cv1(x).chunk(2,1))# 分成两个分支y.extend(m(y[-1])forminself.m)# 经过Bottleneck# 这里原本是直接concat然后cv2现在改成AFF融合前两个分支ifself.use_aff:# 只对shortcut和第一个Bottleneck输出做AFF后面的保持concat# 注意y[0]是shortcuty[1]是第一个Bottleneck的输入即cv1的第二个分支# 实际上y[1]经过Bottleneck后变成了y[2]所以融合y[0]和y[2]fusedself.aff(y[0],y[2])# 融合shortcut和第一个Bottleneck输出# 替换掉原来的y[0]和y[2]保持列表长度不变y[fused]y[1:2]y[3:]# 这里踩过坑列表索引要小心returnself.cv2(torch.cat(y,1))注意上面的实现有个小bug——y[2]是第一个Bottleneck的输出但y[1]是cv1的第二个分支未经过Bottleneck。正确的做法是融合y[0]shortcut和y[-1]最后一个Bottleneck输出或者只融合前两个分支。我最终采用的是融合y[0]和y[-1]因为深层特征更需要语义对齐。修正后的版本defforward(self,x):ylist(self.cv1(x).chunk(2,1))y.extend(m(y[-1])forminself.m)ifself.use_aff:# 融合shortcut和最后一个Bottleneck的输出# 别这样写self.aff(y[0], y[-1])因为y[-1]可能通道不对# 确保两个输入通道数相同都是self.cfusedself.aff(y[0],y[-1])# 两个都是self.c通道# 替换y[0]和y[-1]为融合结果其他保持不变y[fused]y[1:-1][fused]# 这里注意y[-1]被替换了但y[0]也被替换了# 实际上这样会重复更好的做法是只替换y[0]然后去掉y[-1]# 但为了保持concat后的通道数不变需要调整# 最终我选择融合后只保留一个分支concat时通道数减半# 所以需要修改cv2的输入通道数returnself.cv2(torch.cat(y,1))第三步调整通道数匹配上面的实现会导致concat后通道数变化需要同步修改cv2的输入通道数。更干净的做法是在__init__中根据use_aff动态调整ifself.use_aff:# 融合后shortcut和最后一个Bottleneck合并为一个分支# 所以concat的通道数从 (2n)*self.c 变为 (1n)*self.cself.cv2Conv((1n)*self.c,c2,1)else:self.cv2Conv((2n)*self.c,c2,1)消融实验AFF到底带来了什么在COCO val2017上YOLOv11n作为baseline只修改Neck部分的C2fP3/P4/P5三层训练300轮输入640x640配置mAP0.5mAP0.5:0.95小目标AP参数量推理速度(ms)Baseline52.337.121.52.6M1.2AFF (融合shortcut和最后一个Bottleneck)53.137.823.32.9M1.3AFF (融合所有Bottleneck输出)52.837.522.73.1M1.4AFF (只融合前两个分支)52.637.322.12.8M1.3关键发现融合shortcut和最后一个Bottleneck效果最好小目标AP提升1.8%融合所有分支反而下降因为冗余信息太多推理速度只增加0.1ms几乎无感个人经验什么时候该用AFF小目标多的场景比如无人机航拍、交通监控AFF能显著提升小目标召回率深层特征图P5层效果最明显P3层提升有限因为浅层语义冲突小轻量模型YOLOv11n/s提升比例大v11m/l提升相对小因为本身特征已经够好别用在BackboneBackbone的Skip Connection已经够用加了反而干扰特征提取踩坑记录训练初期loss下降变慢是正常的因为AFF需要学习融合权重大概20轮后追上baseline学习率要调小一点我习惯把lr从0.01降到0.008否则AFF的MLP容易过拟合如果显存不够可以把AFF的r从4改成8参数量减半效果只掉0.2个点最后说一句AFF不是万能药它解决的是“特征融合时语义不对齐”的问题。如果你的模型已经在小目标上表现很好加了可能反而掉点。建议先跑个50轮看看趋势再决定是否保留。