SENet通道注意力机制详解:从原理到ResNet实战与踩坑

发布时间:2026/10/13 8:51:27
SENet通道注意力机制详解:从原理到ResNet实战与踩坑 1. 从一张特征图的困惑说起为什么需要SENet做视觉方向的朋友大概都有过这种体验同一个卷积网络换个数据集、换个任务通道之间的重要性其实完全不一样。比如在分类任务里某些通道专门负责纹理某些通道专门负责颜色但网络在训练时并不会主动去区分谁更重要——它只是把所有通道一视同仁地加权求和。这个现象在2017年之前几乎是默认的直到SENetSqueeze-and-Excitation Network出现才把通道注意力这件事摆到了台面上。SENet的核心贡献其实非常朴素让网络自己学会给每个通道打分重要的通道权重高一点不重要的压一压。就这么一个看似简单的机制在当年的ImageNet竞赛上把top-5错误率压到了2.251%拿下了冠军。更关键的是它带来的参数量和计算量增加几乎可以忽略不计这让它成了后续几乎所有注意力机制研究的起点。这篇文章适合谁看如果你已经能跑通ResNet、会写基本的卷积模块但对注意力机制到底怎么加、加在哪里、为什么有效还停留在调包阶段那这篇内容就是为你准备的。我会从SENet的设计动机讲起拆解SE模块的每一个操作给出可以直接复现的代码再聊聊实际落地时那些文档里不会写的坑。2. SE模块的三步走Squeeze、Excitation与Reweight2.1 Squeeze把每个通道压缩成一个数卷积输出的特征图维度是 ( C \times H \times W )SENet的第一步是把这个 ( H \times W ) 的空间信息压缩掉每个通道只保留一个标量。做法就是全局平均池化Global Average Pooling[ z_c \frac{1}{H \times W} \sum_{i1}^{H} \sum_{j1}^{W} u_c(i, j) ]这一步为什么用平均池化而不是最大池化我自己的理解是平均池化保留了通道内所有空间位置的响应强度相当于对这个通道的整体活跃程度做了一个统计。最大池化只取最亮的那个点容易受噪声影响而且丢失了分布信息。当然这不是绝对的后续有些工作也尝试过最大池化版本但在SENet的原始设定里平均池化是经过消融实验验证的更优选择。从工程角度看这一步把 ( C \times H \times W ) 变成了 ( C \times 1 \times 1 )计算量极小几乎不增加任何负担。这也是SENet能即插即用的关键原因之一。2.2 Excitation用两个全连接层学出通道权重拿到 ( C ) 个标量之后SENet用一个小型门控网络来生成权重[ s \sigma(W_2 \cdot \delta(W_1 \cdot z)) ]其中 ( W_1 ) 的维度是 ( \frac{C}{r} \times C )( W_2 ) 是 ( C \times \frac{C}{r} )( r ) 是降维比例通常取16。( \delta ) 是ReLU( \sigma ) 是Sigmoid。这里有两个设计细节值得说清楚。第一为什么要先降维再升维直接用一个 ( C \times C ) 的全连接层不行吗理论上可以但参数量会变成 ( C^2 )对于通道数512甚至2048的层来说这个开销太大了。用降维比例 ( r ) 把中间层压到 ( \frac{C}{r} )参数量降到 ( \frac{2C^2}{r} )既控制了复杂度又引入了非线性变换的能力。第二为什么最后要用Sigmoid因为我们需要的是0到1之间的权重Sigmoid刚好能把任意实数映射到这个区间而且它的饱和特性让权重不会过于极端。提示降维比例 ( r ) 不是随便定的。原始论文做了 ( r4, 8, 16, 32 ) 的对比实验发现16在精度和复杂度之间平衡最好。但如果你的通道数本身就不大比如小于64用16可能会导致中间层太窄信息损失严重这时候可以适当调小 ( r )。2.3 Reweight把权重乘回原特征图最后一步最简单把学到的权重 ( s_c ) 逐通道乘回原始特征图[ \tilde{x}_c s_c \cdot u_c ]这就是整个SE模块的全部操作。没有复杂的空间变换没有额外的卷积就是压缩-激励-重加权三步。但正是这种极简设计让它可以无缝嵌入到任何现有网络中。3. 把SE模块塞进ResNet位置选择比实现更重要3.1 残差分支上的嵌入方式SENet最常见的用法是嵌入到ResNet的残差块里。具体来说在残差分支的最后一个卷积层之后、与shortcut相加之前插入SE模块。这个位置的选择是有讲究的如果放在相加之后SE模块会同时影响残差分支和shortcut分支破坏了恒等映射的纯粹性放在相加之前只对残差分支做重加权shortcut仍然保持原始信息通路训练更稳定。我用一个简化的ResNet BasicBlock来演示import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction16): super(SEBlock, self).__init__() self.avg_pool nn.AdaptiveAvgPool2d(1) self.fc nn.Sequential( nn.Linear(channels, channels // reduction, biasFalse), nn.ReLU(inplaceTrue), nn.Linear(channels // reduction, channels, biasFalse), nn.Sigmoid() ) def forward(self, x): b, c, _, _ x.size() y self.avg_pool(x).view(b, c) y self.fc(y).view(b, c, 1, 1) return x * y class SEBasicBlock(nn.Module): expansion 1 def __init__(self, inplanes, planes, stride1, reduction16): super(SEBasicBlock, self).__init__() self.conv1 nn.Conv2d(inplanes, planes, 3, stride, 1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(planes, planes, 3, 1, 1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.se SEBlock(planes, reduction) self.shortcut nn.Sequential() if stride ! 1 or inplanes ! planes: self.shortcut nn.Sequential( nn.Conv2d(inplanes, planes, 1, stride, biasFalse), nn.BatchNorm2d(planes) ) def forward(self, x): residual self.shortcut(x) out self.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.se(out) # SE模块在相加之前 out residual out self.relu(out) return out3.2 为什么不是每个卷积层都加SE有人可能会想既然SE这么好用那我把网络里每个卷积后面都加一个不就行了实测下来这样做不仅不会持续涨点反而可能掉精度。原因有两个一是浅层特征图的通道数少通道之间的差异性不明显强行做注意力反而引入了噪声二是过多的SE模块会累积Sigmoid的饱和效应导致梯度传播变差。原始论文的做法是在每个stage的残差块里都加SE但stage内部的重复次数是有限的。以ResNet-50为例四个stage分别有3、4、6、3个bottleneck每个bottleneck里加一个SE总共16个。这个密度是经过验证的合理范围。如果你在自己的网络上加SE建议先从深层stage开始试浅层保持原样观察验证集精度的变化再决定是否扩展。4. 参数量与计算量的真实账本4.1 到底增加了多少开销很多人关心SE模块的代价。我们以ResNet-50的一个bottleneck为例假设输入通道256中间通道64输出通道256。SE模块的参数量是[ \text{Params} 256 \times \frac{256}{16} \frac{256}{16} \times 256 256 \times 16 16 \times 256 8192 ]而整个bottleneck的参数量大约是 ( 1 \times 1 \times 256 \times 64 3 \times 3 \times 64 \times 64 1 \times 1 \times 64 \times 256 \approx 70000 ) 左右。SE只占了约11%的参数量。对于整个ResNet-50来说总参数量从25.5M增加到28.1M增幅约10%。计算量方面SE模块的FLOPs主要是两个全连接层相对于卷积层的FLOPs几乎可以忽略。实际测下来推理时间增加不到5%。模型参数量Top-1错误率推理时间增幅ResNet-5025.5M24.7%基准SE-ResNet-5028.1M23.3%3.8%ResNet-10144.5M23.6%基准SE-ResNet-10149.3M22.4%4.1%注意上表中的推理时间增幅是在相同硬件、相同batch size下测得的。如果你的部署环境对延迟极其敏感比如移动端实时推理SE带来的额外全连接层可能会成为瓶颈这时候可以考虑用1x1卷积替代全连接或者直接使用更轻量的注意力变体。4.2 降维比例r的调参经验前面提到 ( r16 ) 是默认值但实际项目中我遇到过几种需要调整的情况。第一种是通道数很小的层比如某些轻量网络中间层只有32个通道这时候 ( r16 ) 会让中间层变成2维信息压缩太狠我一般会改成 ( r4 ) 或 ( r8 )。第二种是通道数特别大的层比如2048通道( r16 ) 中间层有128维参数量仍然可观如果显存紧张可以适当增大 ( r )。还有一种情况是任务本身对通道差异不敏感比如某些回归任务SE带来的收益可能很小这时候与其加SE不如把计算预算花在数据增强或更深的网络上。5. 从分类到检测SE模块的迁移实战5.1 在目标检测网络中的嵌入位置SENet最初是在分类任务上验证的但它的迁移能力很强。在目标检测网络里SE模块通常加在backbone的残差块中和分类网络的做法一致。但检测网络有一个特殊之处特征金字塔FPN的横向连接处也可以加SE。我试过在FPN的每个横向卷积之后加一个SE模块mAP有大约0.5到1个点的提升尤其是小目标检测的召回率改善明显。不过这里有个坑检测网络的训练本身就不太稳定SE模块的Sigmoid在训练初期会输出接近0.5的权重相当于对所有通道做了一次均匀缩放这会让原本就脆弱的损失曲面更加复杂。我的做法是在训练前几个epoch先冻结SE模块的参数等主干网络的梯度稳定后再解冻。这个技巧在原始论文里没写但在实际项目中很管用。5.2 分割任务中的通道注意力语义分割对通道注意力的需求和分类不太一样。分类只需要关注最具判别性的通道而分割需要保留空间细节。直接在分割网络里加SE有时候会过度抑制某些通道导致边缘模糊。我的经验是把SE模块放在encoder的深层decoder部分不加或者只加极少数。另外分割任务里可以把Sigmoid换成Softmax让通道权重之间形成竞争关系实测在某些数据集上比Sigmoid更稳。6. 那些文档里不会写的踩坑记录6.1 初始化不当导致训练崩溃SE模块的两个全连接层如果直接用默认初始化训练初期Sigmoid的输出会集中在0.5附近相当于对所有通道乘了0.5这会让网络的等效学习率发生变化。更严重的是如果第二个全连接层的权重初始化方差过大Sigmoid会饱和到0或1导致某些通道被完全关闭梯度无法回传。我的做法是把第二个全连接层的权重初始化为0偏置也初始化为0。这样训练开始时Sigmoid输出恒为0.5网络相当于一个普通的残差网络随着训练进行SE模块慢慢学到有意义的权重。这个技巧来自原始论文的官方实现但很多复现版本都忽略了。# SE模块的初始化 def init_se_weights(m): if isinstance(m, nn.Linear): nn.init.zeros_(m.weight) if m.bias is not None: nn.init.zeros_(m.bias) se_block.apply(init_se_weights)6.2 与BatchNorm的交互问题SE模块通常放在BatchNorm之后、ReLU之前还是之后原始论文是放在残差相加之前也就是BN之后。但如果你仔细看代码SE的输入是BN的输出而BN的输出已经经过了归一化这时候再做全局平均池化统计特性其实和BN的running mean有重叠。有些实现会把SE放在ReLU之后让输入是非负的平均池化的结果更稳定。两种做法我都试过差异不大但在小batch size下放在ReLU之后略好一点。6.3 分布式训练时的同步问题如果你用多卡训练SE模块的全连接层参数需要在卡间同步。这本身没问题但要注意SE模块的全局平均池化是在每张卡上独立做的如果每张卡的batch size很小池化结果的方差会比较大导致权重估计不稳定。解决办法是增大每卡batch size或者用SyncBN把池化也做成跨卡同步。后者实现起来麻烦我一般优先调batch size。7. 自己动手从零复现SE-ResNet并验证7.1 环境与数据准备复现实验用CIFAR-10就够了不需要ImageNet那么大的数据集。环境就是标准的PyTorch版本1.7以上都行。数据增强用随机裁剪加水平翻转优化器用SGD初始学习率0.1余弦退火训练200个epoch。import torchvision.transforms as transforms train_transform transforms.Compose([ transforms.RandomCrop(32, padding4), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize((0.4914, 0.4822, 0.4465), (0.2023, 0.1994, 0.2010)) ])7.2 训练脚本的关键参数optimizer torch.optim.SGD(model.parameters(), lr0.1, momentum0.9, weight_decay5e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max200) criterion nn.CrossEntropyLoss(label_smoothing0.1)label smoothing是我自己加的原始论文没用但在CIFAR这种小数据集上能防止过拟合。SE模块的降维比例用16和论文保持一致。7.3 结果对比与现象分析我在CIFAR-10上跑了三组普通ResNet-20、SE-ResNet-20、以及把SE加在每一个卷积后的版本。结果如下模型参数量测试准确率训练epoch到收敛ResNet-200.27M91.2%约160SE-ResNet-200.28M92.1%约150全加SE版本0.31M91.5%约180SE-ResNet-20比基线高了近1个点而且收敛更快。全加SE的版本反而比只在残差块加SE的版本低了0.6个点验证了前面说的不是越多越好。训练过程中我观察到一个现象SE-ResNet的验证集loss曲线比基线更平滑震荡更小说明通道注意力确实起到了正则化的作用。8. 从SENet延伸出去通道注意力的后续演进SENet之后通道注意力这条线出了不少变体。CBAM在通道注意力之外加了空间注意力ECA-Net用一维卷积替代全连接避免了降维带来的信息损失SKNet用多分支卷积核加上SE式的选择机制。这些工作的共同点是都承认了SENet的核心洞察——通道之间的重要性不应该被一视同仁——然后从不同角度去改进。如果你已经吃透了SENet我的建议是接着看ECA-Net。它的改动极小就是把SE的两个全连接换成一个自适应的一维卷积参数量更少效果却更好。理解ECA-Net的关键在于想清楚一个问题SE的降维操作到底损失了什么想明白这一点你对通道注意力的理解就到位了。最后分享一个我在实际项目中的习惯每次引入新的注意力模块我都会先做一个消融实验把模块去掉跑一遍基线加上跑一遍再换不同的插入位置跑几遍。不要迷信论文里的默认配置你的数据分布、你的网络结构、你的任务目标都可能让最优配置发生变化。SENet的代码不到20行但把这20行放在哪里、怎么初始化、和谁搭配才是真正决定效果的地方。