008、SE注意力机制改进:在YOLOv8中集成Squeeze-and-Excitation模块的代码实现
008、SE注意力机制改进:在YOLOv8中集成Squeeze-and-Excitation模块的代码实现
从一次模型部署的翻车说起
去年有个项目,要在边缘设备上跑目标检测,客户要求模型大小控制在5MB以内,mAP还不能低于0.75。我试了YOLOv8n,精度差一点;换YOLOv8s,模型超了。正愁着,突然想到SE模块——这玩意儿参数量极小,但能通过通道注意力让模型“学会”关注重要特征。结果一跑,YOLOv8n加SE后mAP从0.72跳到0.78,模型只大了0.3MB。今天就把这个“白嫖”精度的技巧拆开揉碎讲清楚。
SE模块到底在干什么
Squeeze-and-Excitation,说白了就是给每个通道打分。打个比方,你有一堆特征图,有的通道包含关键信息(比如人的轮廓),有的全是背景噪声。SE模块先通过全局平均池化把每个通道压缩成一个数值(Squeeze),再用两个全连接层学习通道间的依赖关系,最后用Sigmoid输出0到1的权重(Excitation),乘回原特征图。
这里有个关键点:SE的压缩比r(reduction ratio)默认是16,意味着中间层的神经元数量是输入通道数的1/16。别小看这个参数,后面会讲它怎么坑人。
在YOLOv8中插入SE的三种姿势
姿势一:替换C2f中的Bottleneck(推荐)
YOLOv8的骨干网络核心是C2f模块,里面包含多个Bottleneck。最直接的做法是在每个Bottleneck的3x3卷积后面加SE。打开ultralytics/nn/modules.py,找到Bottleneck类:
classBottleneck(nn.Module):def__init__(self,c1,c2,shortcut=True,g=1,k=(3,3),e=0.5):super().__init__()c_=int(c2*e)# hidden channelsself.cv1=Conv(c1,c_,k[0],1)self.cv2=Conv(c_,c2,k[1],1,g=g)self.add=shortcutandc1==c2# 这里踩过坑:SE要加在cv2之后,shortcut之前self.se=SEModule(c2,reduction=16)# 新增defforward(self,x):returnx+self.se(self.cv2(self.cv1(x)))ifself.addelseself.se(self.cv2(self.cv1(x)))注意看,我把SE加在了cv2输出之后、shortcut相加之前。别这样写:self.se(self.cv2(self.cv1(x))) + x——虽然结果一样,但语义上SE应该作用在特征提取分支上,而不是残差连接上。
姿势二:在C2f的输出端加SE(轻量方案)
如果模型已经很大了,不想在每个Bottleneck里加SE(参数量会翻倍),可以在C2f模块整体输出后加一个SE。找到C2f类的forward:
classC2f(nn.Module):defforward(self,x):y=list(self.cv1(x).chunk(2,1))y.extend([m(y[-1])forminself.m])# 这里别这样写:直接return self.cv2(torch.cat(y, 1))out=self.cv2(torch.cat(y,1))returnself.se(out)ifhasattr(self,'se')elseout这种方式参数量极小,但效果不如姿势一。适合模型已经接近边缘设备极限的场景。
姿势三:在Neck的每个卷积后加SE(暴力方案)
YOLOv8的Neck(Head部分)用了SPPF和多个卷积。如果你追求极致精度,可以在Neck的每个Conv模块后加SE。但说实话,我一般不推荐——参数量涨得厉害,而且Neck部分的特征已经比较高层了,SE带来的收益有限。
定义SEModule的代码细节
在ultralytics/nn/modules.py顶部添加:
classSEModule(nn.Module):def__init__(self,channels,reduction=16):super().__init__()# 这里踩过坑:reduction不能小于1,否则中间层通道数为0self.avg_pool=nn.AdaptiveAvgPool2d(1)self.fc=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.avg_pool(x).view(b,c)y=self.fc(y).view(b,c,1,1)returnx*y.expand_as(x)注意bias=False——全连接层不加偏置,因为SE模块的核心是学习通道间的缩放关系,偏置会引入不必要的偏移。另外inplace=True能省点显存,但如果你用torch.jit导出模型,建议改成inplace=False,否则可能报错。
修改配置文件
在ultralytics/cfg/models/v8/下新建yolov8n-se.yaml:
# 只改backbone部分,其他保持原样backbone:-[-1,1,Conv,[64,3,2]]# 0-P1/2-[-1,1,Conv,[128,3,2]]# 1-P2/4-[-1,3,C2f,[128,True]]# 2-[-1,1,Conv,[256,3,2]]# 3-P3/8-[-1,6,C2f,[256,True]]# 4-[-1,1,Conv,[512,3,2]]# 5-P4/16-[-1,6,C2f,[512,True]]# 6-[-1,1,Conv,[1024,3,2]]# 7-P5/32-[-1,3,C2f,[1024,True]]# 8-[-1,1,SPPF,[1024,5]]# 9等等,这里有个问题:配置文件里没法直接指定每个Bottleneck加SE。所以我们需要在代码层面做修改。更优雅的方式是写一个C2f_SE类,继承C2f并重写__init__:
classC2f_SE(C2f):def__init__(self,c1,c2,n=1,shortcut=False,g=1,e=0.5):super().__init__(c1,c2,n,shortcut,g,e)# 替换原有的Bottleneck为带SE的版本self.m=nn.ModuleList([Bottleneck_SE(self.c,self.c,shortcut,g,k=(3,3),e=1.0)for_inrange(n)])然后在配置文件中把C2f替换成C2f_SE。但这样改起来比较麻烦,我一般直接在parse_model函数里做手脚——检测到配置中的C2f就自动替换成带SE的版本。
训练时的坑与调参
学习率要调低
加了SE模块后,模型对学习率更敏感。原来YOLOv8n用lr=0.01,加了SE后建议降到0.008。我试过0.01直接训,loss震荡得厉害,最后mAP反而低了0.02。
压缩比r不是越小越好
有人觉得r=4比r=16好,因为中间层神经元更多。实际测试下来,r=8在COCO上比r=16高0.3个点,但参数量多了20%。对于边缘设备,r=16是性价比最高的选择。别这样写:reduction=2——中间层通道数直接减半,参数量翻倍,收益却微乎其微。
位置选择有讲究
我在YOLOv8n上做了消融实验:
- 只在backbone加SE:mAP提升0.8%
- 只在neck加SE:mAP提升0.3%
- backbone+neck都加:mAP提升1.1%,但参数量涨了15%
结论:backbone的低层特征更需要通道注意力,neck部分加SE性价比不高。
部署时的注意事项
ONNX导出要小心
SE模块里的AdaptiveAvgPool2d在ONNX导出时可能会被拆成多个op,导致推理速度变慢。建议用torch.onnx.export时加上opset_version=12,或者手动把AdaptiveAvgPool2d替换成AvgPool2d(前提是你知道输入尺寸)。
TensorRT加速
SE模块的两个全连接层在TensorRT里会被优化成矩阵乘,速度几乎没损失。但注意Sigmoid在FP16模式下可能精度不够,建议用nn.Hardsigmoid替代——精度几乎一样,推理更快。
个人经验总结
SE模块是我在YOLOv8上用得最多的注意力机制,没有之一。它不像CBAM那样需要两个分支,也不像ECA那样需要一维卷积,简单粗暴但有效。如果你在调参时发现模型精度卡在某个点不动了,加个SE往往能再往上跳0.5-1个点。
但记住:SE不是万能的。对于小目标检测任务,SE的效果不如CA(Coordinate Attention),因为SE只关注通道维度,忽略了空间位置信息。另外,如果你的数据集本身通道间相关性就很强(比如遥感图像),SE的收益会大打折扣。
最后说一句:别在YOLOv8s以上的模型里加SE——参数量涨得太多,不如直接换大模型。YOLOv8n和YOLOv8m才是SE的最佳搭档。
