026、YOLOv8改进实战:SE注意力机制原理与C2f_SE模块代码实现
从一次模型部署翻车说起
去年有个项目,要在Jetson Nano上跑YOLOv8n做工业缺陷检测。模型在服务器上mAP能到0.78,一上板子直接掉到0.62。排查了三天,发现是特征图通道间的响应差异太大——某些通道几乎全是噪声,某些通道又过度激活。当时第一反应就是加SE模块,结果改了C2f结构后,mAP回升到0.74,推理速度只慢了2ms。今天就把这个坑填上,手把手把SE塞进YOLOv8的C2f里。
SE注意力机制到底在干什么
SENet(Squeeze-and-Excitation Networks)的核心逻辑其实特别朴素:让网络自己学会给每个通道打分。打个比方,你让模型检测小目标,浅层特征图里有些通道专门响应边缘,有些通道响应纹理。SE模块就是给这些通道分配权重——有用的通道权重拉高,没用的压下去。
具体分三步走:
Squeeze(压缩):对每个通道做全局平均池化,把H×W的特征图压成1×1的标量。这一步相当于统计每个通道的"全局响应强度"。代码里就是nn.AdaptiveAvgPool2d(1),没什么花头。
Excitation(激励):接两个全连接层。第一个FC把通道数压缩到原来的1/r(r是缩减率,默认16),用ReLU激活;第二个FC恢复原始通道数,用Sigmoid输出0到1之间的权重。这里有个细节——第一个FC降维是为了减少参数量,第二个FC升维是为了恢复通道数。别想着省掉降维层,直接全连接映射到原始通道数,参数量爆炸不说,效果反而变差。
Scale(缩放):把Sigmoid输出的权重逐通道乘回原始特征图。这一步就是"注意力"的体现——权重高的通道保留甚至放大,权重低的通道被抑制。
为什么SE要往C2f里塞
YOLOv8的C2f模块是跨阶段局部网络的变体,它把输入特征图分成两路:一路直接传递,另一路经过多个Bottleneck提取特征。问题在于,C2f内部的特征融合是简单的拼接加卷积,没有考虑通道间的差异化重要性。SE模块正好补上这个短板——在特征融合前或融合后加入通道注意力,让模型自动聚焦关键通道。
实际调试中发现,SE放在C2f的输出端效果最稳。放在中间Bottleneck里反而容易破坏梯度流,尤其训练初期loss下降特别慢。
手写C2f_SE模块代码
先看原始的C2f结构。YOLOv8的C2f继承自ultralytics的C2f类,核心是__init__里定义了一个cv1(1x1卷积降维)、cv2(1x1卷积输出)、以及m(Bottleneck列表)。前向传播时,输入经过cv1后分成两路:一路直接到cv2,另一路经过m中的Bottleneck后再到cv2。
我们要改的就是在cv2之前插入SE模块。直接上代码:
importtorchimporttorch.nnasnnclassSEBlock(nn.Module):def__init__(self,channels,reduction=16):super().__init__()# 这里reduction别设太小,8以下参数量暴增,效果提升有限self.squeeze=nn.AdaptiveAvgPool2d(1)self.excitation=nn.Sequential(nn.Linear(channels,channels//reduction,bias=False),nn.ReLU(inplace=True),nn.Linear(channels//reduction,channels,bias=False),nn.Sigmoid())defforward(self,x):b,c,_,_=x.size()y=self.squeeze(x).view(b,c)# 别忘记view,不然Linear吃不了4D张量y=self.excitation(y).view(b,c,1,1)returnx*y.expand_as(x)# expand_as自动广播,但显式写出来更清晰然后定义C2f_SE:
classC2f_SE(nn.Module):def__init__(self,c1,c2,n=1,shortcut=False,g=1,e=0.5):super().__init__()self.c=int(c2*e)# 隐藏层通道数self.cv1=Conv(c1,2*self.c,1,1)self.cv2=Conv((2+n)*self.c,c2,1)# 注意拼接后的通道数计算self.m=nn.ModuleList([Bottleneck(self.c,self.c,shortcut,g,k=3,e=1.0)for_inrange(n)])# 在cv2前插入SE,这里踩过坑:SE放在cv2之后会破坏输出通道数对齐self.se=SEBlock((2+n)*self.c)defforward(self,x):y=list(self.cv1(x).chunk(2,1))# 分成两路,每路通道数为self.cy.extend(m(y[-1])forminself.m)# 把Bottleneck的输出追加到列表# 这里别用torch.cat直接拼,先过SE再cat会丢失通道权重信息y=self.se(torch.cat(y,1))# 先cat再SE,确保所有通道都被加权returnself.cv2(y)关键细节:SE模块的输入通道数是(2+n)*self.c,因为拼接了2个直接传递的通道和n个Bottleneck的输出。如果你把SE放在cv2之后,那SE的输入通道数就是c2,但cv2已经完成了通道变换,SE的加权效果会大打折扣。
如何替换YOLOv8的C2f
找到ultralytics/nn/modules/block.py,把上面的C2f_SE类加进去。然后在ultralytics/nn/tasks.py的parse_model函数里,找到C2f的映射,加一条:
ifmin(C2f,C2f_SE):# 注意这里要同时保留原C2fc1,c2=ch[f],args[0]args=[c1,c2,*args[1:]]然后在模型配置文件(比如yolov8n.yaml)里,把C2f替换成C2f_SE。比如:
backbone:-[-1,1,Conv,[64,3,2]]-[-1,1,Conv,[128,3,2]]-[-1,3,C2f_SE,[128,True]]# 这里改掉-[-1,1,Conv,[256,3,2]]-[-1,6,C2f_SE,[256,True]]# 后面类似注意:替换后记得调整学习率。SE模块的Sigmoid输出初始值接近0.5,相当于一开始所有通道权重都差不多。如果学习率太大,SE的权重会剧烈震荡,导致训练不稳定。建议把初始学习率降到原来的0.8倍,或者给SE模块单独设一个较小的学习率。
训练时的一个坑
SE模块的Sigmoid输出在训练初期会接近0.5,这没问题。但如果你用了nn.BatchNorm2d,且BN的affine参数为True(默认就是),那么BN的缩放因子会与SE的权重产生耦合。具体表现是:训练到一半,SE的权重突然全部趋近于1,相当于SE失效了。
解决办法:在SE的excitation里,把第二个Linear的bias设为True(默认就是False),让Sigmoid有一个可学习的偏置。或者更简单——在SE之前加一个LayerNorm,但会增加推理开销。我一般选择把SE的Sigmoid换成HardSigmoid,收敛更稳:
self.excitation=nn.Sequential(nn.Linear(channels,channels//reduction,bias=False),nn.ReLU(inplace=True),nn.Linear(channels//reduction,channels,bias=False),nn.Hardsigmoid()# 换成HardSigmoid,梯度更平滑)推理速度实测
在RTX 3060上,YOLOv8n原模型推理一张640x640图片耗时2.8ms,替换三个C2f_SE后耗时3.1ms,增加了约10%。但mAP从0.372提升到0.389(COCO val2017)。如果你用TensorRT部署,SE模块的Sigmoid会被优化成近似计算,速度损失可以控制在5%以内。
个人建议:只在backbone的最后两个C2f和head的第一个C2f上加SE,不要全换。浅层特征图分辨率大,SE的全局池化会丢失空间信息,加了反而掉点。深层特征图通道数多,SE的参数量占比小,性价比最高。
什么时候别用SE
SE不是万能药。如果你的数据集目标尺度特别单一(比如固定大小的车牌检测),SE带来的提升微乎其微。另外,如果你已经在用CBAM或ECA,就别叠SE了——注意力机制叠多了,梯度流会乱,训练loss下不去。我见过有人把SE、CBAM、CA全塞进C2f,结果mAP比原版还低0.03。
最后说一句:SE的reduction参数别死磕16。小模型(n/s系列)用8,大模型(l/x系列)用16或32。这个经验值是我在三个不同项目里试出来的,你可以直接拿去用。