去年帮一个团队调工业质检的分类模型,他们基于 ResNet-50 的骨架已经卡在 98.2% 的准确率上两周没动,改了学习率、换了优化器、加了数据增强,曲线就是平的。后来我在每个 bottleneck 里塞了一个二十来行的小模块,训练三轮之后验证集涨到了 99.1%,而参数量只多了 250 万左右。那个小模块就是 SE,全称 Squeeze-and-Excitation,也就是这几年被说烂了的通道注意力机制。但说实话,"注意力机制"这个词现在被用得太泛,很多人听到 SE 第一反应是 Transformer 里那套自注意力,其实两者从动机到计算方式完全不是一回事。这篇东西我想把 SE 从动机、公式、代码、参数量、训练坑、部署坑一路拆到底,顺带把它和 CBAM、CA、ECA、多头自注意力放在一起比一比,方便你在自己的任务里判断该不该用、该怎么用。
1. Squeeze-and-Excitation 究竟在解决卷积的什么短板
1.1 卷积核视角的"通道盲区"
先说一个很多人没细想过的事实:标准卷积在通道维度上是"民主"的。一个 3×3×512 的卷积核在计算输出的时候,会把输入的 512 个通道按同一套空间权重卷积、求和,得到下一层的某个通道。整个过程里,卷积核只学会了"在空间上怎么加权",却没有学会"在通道上怎么加权"。换句话说,512 个输入通道对输出的贡献,是由卷积核的数值隐式决定的,而且这个决定是局部的、静态的——同一组权重不管面对什么输入图像,都给出同样的通道混合比例。
这在 AlexNet 那个年代问题不大,因为那时候大家关心的是"有没有学到边缘和纹理"。但当骨干网络走到 ResNet-50、ResNet-101 这个量级,通道数动辄 2048,通道之间的分工已经非常明确:有的通道专门响应纹理,有的响应颜色,有的响应特定朝向的边缘,还有大量通道在特定输入下几乎不激活。这时候如果网络能根据当前输入动态判断"这 2048 个通道里哪几个是重要的",把资源往重要的通道上倾斜,理论上就能在不增加多少计算量的前提下提升表达能力。
SE 就是干这个的。它的核心假设非常朴素:通道之间存在重要性差异,而且这个差异是输入相关的。同一张特征图,输入一只猫和一辆车,需要被强调的通道应该是不同的。
1.2 从"堆深度"到"重分配"的思路切换
2017 年之前,提升精度的主流思路是往两个方向堆:加深(ResNet 从 18 堆到 152)和加宽(Wide ResNet、ResNeXt 的 group conv)。这两条路都有明确的边际递减:ResNet-152 比 ResNet-50 参数量翻了 2.4 倍,ImageNet top-1 只涨了 2 个点左右,而且训练时间线性增长。
SE 走的是第三条路——不改变特征图的数量,只改变它们的权重。你可以把它想象成一个调音台:原来 512 个音轨全部按固定音量混合输出,现在加了一个自动混音器,先听一遍每个音轨的平均能量,再动态决定每个音轨该放大还是衰减。设备没换,乐手没换,只是混音策略变了。
这也是为什么 SE 的额外参数量可以做到很小。它的参数只跟通道数 C 有关,跟空间尺寸 H×W 完全无关,所以无论你的输入是 224×224 还是 512×512,SE 的开销是一样的。相比之下,如果在空间维度上做注意力(比如 CBAM 的空间分支),开销就跟 H×W 强相关了。
一个常见误解值得提前澄清:SE 不是"给特征图加权",而是"给通道加权"。它输出的是一组长度为 C 的标量,每个标量对应一个通道,然后这组标量被广播乘到整张特征图上。所以 SE 不会改变特征图的空间结构,也不会引入任何位置信息——这是它和后面要讲的 CA、自注意力最本质的区别。
2. Squeeze、Excitation、Scale 三步的数学过程和设计取舍
2.1 Squeeze:把每个通道压成一个数,为什么选全局平均池化
给定输入特征图 U,形状是 C×H×W(这里按通道优先写,实际实现里是 NCHW),Squeeze 这一步做的事极其简单:
z_c = (1 / (H*W)) * Σ_{i=1..H} Σ_{j=1..W} U_c(i, j)也就是对第 c 个通道做全局平均池化,得到一个标量 z_c。C 个通道走完,得到一个长度为 C 的向量 z。
为什么用平均而不是最大?这里的逻辑是:Squeeze 的目的是描述一个通道的"整体活跃程度",而不是"峰值响应"。平均池化对整张空间图的响应做了一次无偏汇总,梯度回传到每个空间位置都是均匀的 1/(H*W),训练非常稳定。最大池化只挑最强的那个位置,梯度只回传到那一个点上,在噪声大的数据集上容易放大异常激活,导致训练震荡。
不过话说回来,这个选择在具体任务里未必总是最优。我做过一个遥感图像的小目标检测实验,图像里目标只占几个像素,全局平均池化会把目标响应和一大片背景平均掉,SE 的收益几乎为零。后来换成 GAP 和 GMP 并联(两个分支各出一个 C 维向量,相加后再送进 Excitation),小目标的召回率才提上来。CBAM 的通道分支用的就是这个并联方案,某种程度上是对纯 GAP 的一个修正。
另一个细节是,Squeeze 的输出维度是 C,跟 batch 无关。这意味着 SE 模块本身的参数量不含 batch 维度,它是逐样本计算的——每个样本算出自己的一组通道权重。这一点在做 batch 推理优化的时候要注意,SE 的 FC 层不能像 BN 那样在推理时把 batch 统计量折叠掉。
2.2 Excitation:瓶颈结构里为什么必须降维,r 该取多少
得到 z 之后,Excitation 用两个全连接层加一个 Sigmoid 把它映射成一组权重:
s = σ( W2 · δ( W1 · z ) )其中 W1 的形状是 (C/r) × C,W2 的形状是 C × (C/r),δ 是 ReLU,σ 是 Sigmoid,r 是降维比。
为什么要降维再用升回来,而不是直接用一个 C×C 的矩阵?原因有三个,我按重要性排一下。
第一是参数控制。一个 C×C 的矩阵在 C=2048 时有 419 万参数,插到 ResNet 的最后一个 stage 里,三个 block 就是 1200 多万,直接把模型撑大三成。而用 r=16 的瓶颈结构,参数量降到 2C²/r = 52 万,只有原来的 1/8。
第二是非线性。降维再升维这个结构天然引入了一层 ReLU,让通道之间的交互不局限于线性组合。如果是单个 C×C 矩阵,那整个 SE 就退化成一次线性变换,表达能力弱很多。
第三是隐式的正则化。瓶颈结构强迫网络用一个低维子空间来描述"哪些通道重要",这本身是一种信息压缩,在训练数据不够大的时候反而有帮助。
r 取多少?原论文在 ImageNet 上做了网格搜索,扫了 4、8、16、32 几档,发现 r=16 在精度和参数量的平衡点上最好。但这个结论有很强的任务依赖性,我自己的经验是:
- ImageNet 这类千类分类,r=16 基本可以闭眼用。
- 细粒度分类(比如区分几十种鸟),通道分工更细,r 建议降到 8,甚至 4。
- 小数据集做迁移学习,r 可以放大到 32,减少过拟合风险。
- 分割和检测里,如果骨架已经很强,r=16 就够了,再小收益不明显。
有一个坑后面会展开讲:r 设得过小时,瓶颈层会成为信息瓶颈。比如 C=64 的浅层网络,r=16 意味着中间层只有 4 个神经元,要把 64 个通道的重要性排序塞进 4 维空间,损失的信息太多,反而掉点。
2.3 Scale:广播乘法的位置决定了梯度怎么走
最后一步是把 s 乘回原特征图:
X_c = s_c · U_c实现上通常写成x * s.view(N, C, 1, 1),靠广播完成。这一步没有任何参数,但它的位置很有讲究。
在 ResNet 的 bottleneck 里,SE 有两种插法:插在残差相加之前,或者插在相加之后。原始 SENet 用的是前者,消融实验显示在残差分支的末端做重标定效果更好。原因可以这样理解:残差相加之后,shortcut 分支的恒等映射也被 SE 的权重影响了,恒等通路被破坏了;而相加之前做,shortcut 仍然是干净的恒等映射,梯度可以无损地回传,SE 只影响主分支的表达。
另一件事是初始化的尺度问题。Sigmoid 输出范围是 0 到 1,如果训练初期 s 普遍在 0.5 附近,等于把整张特征图缩小了一半,会扰动预训练权重的尺度。有几种常见对策:把第二个 FC 的权重初始化得非常小(接近 0),这样 s 接近 0.5 但方差很小;或者把第二个 FC 的 bias 设成 2 到 3,让 sigmoid 输出落在 0.88 到 0.95 之间,接近恒等映射。后者在加载自己随机初始化的 SE 做迁移学习时特别有用,能把训练初期的 loss 尖峰压下去。
3. 一份可以直接跑通的 PyTorch 实现与集成细节
3.1 二十行的最小实现,逐行说明
下面是我在项目里用了好几年的版本,用 1×1 卷积代替 nn.Linear,好处是兼容任意尺寸输入,也方便和卷积网络的其它层统一处理。
import torch import torch.nn as nn class SEBlock(nn.Module): def __init__(self, channels, reduction=16): super().__init__() hidden = max(channels // reduction, 4) # 防止浅层通道数太少 self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Conv2d(channels, hidden, 1, bias=False), nn.ReLU(inplace=True), nn.Conv2d(hidden, channels, 1, bias=True), nn.Sigmoid() ) # 第二个卷积的 bias 设正,让初始输出接近恒等映射 nn.init.zeros_(self.fc[2].weight) nn.init.constant_(self.fc[2].bias, 2.0) def forward(self, x): s = self.fc(self.pool(x)) return x * s几个值得说的点。
hidden = max(channels // reduction, 4)这行是我自己加的保险。很多开源实现直接写channels // reduction,当 channels=32、reduction=16 时中间层只有 2 个神经元,甚至当 channels=16 时会变成 1 甚至 0,直接报错或者退化成常数。加一个下界能避免浅层网络出问题。
用Conv2d(C, hidden, 1)而不是Linear,是因为前者天然接受 NCHW 输入,不需要 view 来 view 去。有些人担心 1×1 卷积比 Linear 慢,实测在 GPU 上差别在 1% 以内,反倒是少了两次 reshape,代码更干净。
nn.init.zeros_(self.fc[2].weight)把升维层的权重清零、bias 设成 2,这样初始化时 sigmoid(2)≈0.88,SE 的输出就是原特征图乘 0.88,非常接近恒等映射。这个技巧能显著改善"在预训练骨架上新加 SE 分支"时的训练稳定性。注意这是我自己项目里的做法,原论文用的是默认初始化,如果你的训练数据量足够大、从头训,用默认的也没问题。
3.2 插进 ResNet Bottleneck 的正确位置
以标准的 Bottleneck 为例(1×1 降维 → 3×3 → 1×1 升维 → 残差相加),SE 应该插在最后那个 1×1 升维卷积之后、残差相加之前:
class SEBottleneck(nn.Module): expansion = 4 def __init__(self, inplanes, planes, stride=1, downsample=None, reduction=16): super().__init__() width = planes self.conv1 = nn.Conv2d(inplanes, width, 1, bias=False) self.bn1 = nn.BatchNorm2d(width) self.conv2 = nn.Conv2d(width, width, 3, stride=stride, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(width) self.conv3 = nn.Conv2d(width, planes * self.expansion, 1, bias=False) self.bn3 = nn.BatchNorm2d(planes * self.expansion) self.relu = nn.ReLU(inplace=True) self.se = SEBlock(planes * self.expansion, reduction) self.downsample = downsample def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.relu(self.bn2(self.conv2(out))) out = self.bn3(self.conv3(out)) out = self.se(out) # 重标定发生在相加之前 if self.downsample is not None: identity = self.downsample(x) out += identity return self.relu(out)注意 SE 作用在planes * expansion上,也就是 bottleneck 的输出通道数,不是中间的 width。这一点经常有人搞错,把 SE 接到 conv2 后面,通道数少了一半,参数量省了但效果也差了。SENet 原文的设定就是作用在 block 输出上。
还有个小顺序问题:self.se(out)放在bn3之后、加 identity 之前。如果放在 ReLU 之后,重标定作用在非负的特征上,理论上也可以,但实测差别不大,跟原论文保持一致就行。
3.3 实测账本:多出来的参数和 FLOPs 到底有多少
拿 ResNet-50 做例子,SE 加在四个 stage 的所有 bottleneck 上,通道数分别是 256、512、1024、2048,block 数分别是 3、4、6、3,r=16:
| Stage | 通道数 C | Block 数 | 单块 SE 参数 2C²/r | 小计 |
|---|---|---|---|---|
| layer1 | 256 | 3 | 8192 | 24,576 |
| layer2 | 512 | 4 | 32,768 | 131,072 |
| layer3 | 1024 | 6 | 131,072 | 786,432 |
| layer4 | 2048 | 3 | 524,288 | 1,572,864 |
| 合计 | — | 16 | — | 约 2.51M |
ResNet-50 原始参数量是 25.6M,加上 SE 变成约 28.1M,增幅 9.8%。FLOPs 方面,SE 的额外计算主要来自两部分:全局平均池化(约 H×W×C 次加法)和两个 1×1 卷积(约 2C²/r 次乘加)。以 layer4 在 7×7 分辨率下为例,一个 block 的 GAP 是 7×7×2048 ≈ 10 万次加法,FC 是 2×2048×2048/16 ≈ 52 万次乘加,合计 62 万。而 layer4 一个 bottleneck 的卷积计算量在 1×1×2048×512×7×7 这个量级,大约是 5000 万次乘加。SE 占比 1% 出头。整网算下来,FLOPs 增加通常在 0.2% 到 0.5% 之间。
这个性价比是 SE 当年能火的核心原因:参数增加约 10%,FLOPs 增加不到 1%,ImageNet top-1 提升 1.5 个点左右(ResNet-50 从 76.1% 到 77.6%)。相比之下,把 ResNet-50 换成 ResNet-101,参数量增加 75%,FLOPs 增加 80%,也只涨 1.7 个点。
4. 训练和部署里那些文档不会写的坑
4.1 小 batch 与 BN 的相互干扰
SE 本身和 BN 没有直接冲突,但两者放在一起会放大小 batch 的问题。原因在于 SE 的权重是逐样本计算的,而 GAP 又对空间维度做平均,当 batch 很小、特征图分辨率又低的时候,GAP 的估计噪声很大,导致同一个通道在不同样本上的权重波动剧烈。再叠加上 BN 在小 batch 下统计量不准,整个 block 的输出方差会明显变大。
我在一个 batch size 只能开到 4 的医学影像项目上踩过这个坑:加了 SE 之后训练 loss 曲线毛刺非常明显,验证精度比不加 SE 还低 0.6 个点。解决方式有两个,一是换用 GroupNorm 或者把 BN 的 momentum 调小(比如从 0.1 调到 0.01),让统计量更平滑;二是把 SE 里的 GAP 换成"带温度的平均",也就是先对空间做一次轻量的 1×1 卷积再加权平均,增加一点可学习参数来稳定估计。第二种做法在 batch 特别小的场景下收益比较明显。
4.2 特征图空间尺寸太小时的收益衰减
GAP 在 7×7 和 1×1 上的行为完全不同。当特征图已经降到 1×1(比如某些分类头之前的全局池化层),SE 就退化成对单个标量的重标定,等价于在通道维度乘一个数,几乎没有额外信息可用。这时候加 SE 的收益为零,只是白白增加参数。
我的经验阈值是:特征图空间尺寸低于 4×4 时,SE 的收益开始明显衰减;低于 2×2 基本没有意义。所以如果你在做高分辨率输入的小目标检测,SE 加在 stride 8 和 stride 16 的层上效果最好,加到 stride 32 那层就要谨慎评估。反过来,如果输入分辨率被压得很低(比如 96×96 的分类任务),最深的 stage 特征图只有 3×3,SE 的收益会比你预期的小很多。
4.3 降维比 r 引发的信息瓶颈
前面提过一句,这里展开。r 的作用是控制瓶颈层的宽度,但它同时控制了"能表达多少种通道重要性模式"。当 C/r 太小时,网络只能用极少的维度来描述通道重要性,遇到需要精细区分的场景就会力不从心。
有个很直观的验证方法:把训练好的 SE 权重 W1 拿出来做 SVD,看前几个奇异值占比。如果前两三个奇异值就占了 90% 以上,说明这个瓶颈已经严重压缩,网络只能表达少数几种"通道重要性模式"。我在一个 200 类细粒度分类任务上做过这个分析,r=16 时前 3 个奇异值占 93%,把 r 降到 4 之后降到 78%,对应的 top-1 提升了 0.9 个点。当然代价是 SE 参数量翻了 4 倍。
对于通道数很小的浅层(C=64 或 128),建议直接把 r 设成 8 甚至 4,或者干脆不用瓶颈,用一个 C×C 的矩阵。小通道数下 C×C 矩阵的参数量本来就不大,C=64 时只有 4096 个参数,完全可以承受。
4.4 迁移学习和权重重初始化的顺序问题
这个坑比较隐蔽。当你拿一个预训练的普通 ResNet-50,往里面插 SE 模块,然后在小数据集上微调时,如果 SE 分支用默认初始化,训练前几百个 step 的 loss 会有个明显尖峰,有时候直接把学习率预热阶段的动力学搞乱。
原因是:预训练权重的特征图尺度是经过精心校准的,插入一个输出期望在 0.5 附近的 SE,等于给每一层都加了一个 0.5 倍的缩放,后面所有层的输入分布都偏了。
三个处理方式,按推荐程度排序:
- 直接用官方预训练的 SE-ResNet 权重。torchvision 里有
se_resnet50这个结构吗?其实没有,但很多第三方库(比如 timm)提供了完整的 SE-ResNet 预训练权重,直接拿来微调最省事。 - 如果必须自己插,把 SE 的第二个卷积权重清零、bias 设成 2.0(前面代码里的做法),让初始化时 SE 接近恒等映射,再配合 500 到 1000 step 的 warmup。
- 前几个 epoch 冻结 SE 分支(把它的学习率设成 0 或者非常小),等主干稳定后再解冻联合训练。
4.5 量化部署时的动态范围问题
SE 里最难量化的是 Sigmoid。它的输出集中在 0 到 1 之间,而且是高度非均匀分布——大多数值挤在 0.3 到 0.7 这个窄区间里。做 INT8 量化的时候,如果按 0 到 1 的满量程去分 256 个桶,实际用到的可能只有中间 100 来个桶,量化误差一下就上来了。
我在 TensorRT 上做过对比测试,整网 INT8 量化的情况下,如果 SE 部分也强制 INT8,ImageNet 上的 top-1 会掉 1.2 到 1.8 个点。把 SE 内部的两个 1×1 卷积和 Sigmoid 保留成 FP16(其余部分仍然 INT8),掉点能控制在 0.3 个点以内,推理速度基本没有损失——因为 SE 的计算量本来就小,保留 FP16 不会成为瓶颈。
另一个做法是把 Sigmoid 换成 Hard-Sigmoid:
hard_sigmoid(x) = clamp(x/6 + 0.5, 0, 1)这样整个 SE 只包含加减乘和 clamp,量化友好度大幅提升。MobileNetV3 用的就是这个方案,它在移动端的量化部署里表现比标准 Sigmoid 稳定得多。代价是表达能力稍有下降,在 ImageNet 这种大任务上大约损失 0.2 个点,但在移动端场景里这是划算的。
5. 通道注意力家族的横向对比与选型判断
5.1 从 SE 到 ECA:一条"减法"的演化路线
SE 之后,通道注意力这个方向基本沿着两条线走:一条是"加更多维度",一条是"减掉冗余计算"。
ECA(Efficient Channel Attention)是减法路线的代表。它指出 SE 的降维操作其实是有害的——降维破坏了通道和权重之间的直接对应关系。ECA 的做法是去掉两个全连接层,用一个长度为 k 的一维卷积直接在 GAP 输出上做跨通道交互:
class ECABlock(nn.Module): def __init__(self, channels, k=3): super().__init__() self.pool = nn.AdaptiveAvgPool2d(1) self.conv = nn.Conv1d(1, 1, kernel_size=k, padding=k // 2, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): y = self.pool(x) # N, C, 1, 1 y = y.squeeze(-1).transpose(-1, -2) # N, 1, C y = self.sigmoid(self.conv(y)) y = y.transpose(-1, -2).unsqueeze(-1) # N, C, 1, 1 return x * yECA 的参数量几乎为零(k 通常取 3 或 5,只有几个参数),效果却能追平甚至略超 SE。我在几个移动端项目里做过替换测试,把 SE 换成 ECA,精度基本持平(差距在 0.1 个点内),参数量少了 250 万,推理延迟在 CPU 上降低约 6%。所以现在做移动端,我基本默认用 ECA 而不是 SE。
5.2 CBAM 与 CA:把空间信息也拉进来
CBAM 在通道注意力之外并联了一个空间注意力分支:先把通道维压成 2 个(一个平均、一个最大),再过一个 7×7 卷积得到一张 H×W 的权重图。它的优势是能定位"哪里重要",在检测和分割任务里比纯通道注意力更有用。代价是额外计算量与 H×W 相关,高分辨率输入下开销不可忽视。
CA(Coordinate Attention)的思路更巧妙:它不做全局平均,而是分别沿水平方向和垂直方向做池化,得到两个一维向量(长度分别是 W 和 H),分别编码后再合并。这样做的好处是把位置信息嵌进了通道注意力里,同时计算量只跟 H+W 有关,比 CBAM 的空间分支省得多。在移动端分割任务上,CA 的表现通常优于 SE 和 CBAM。
| 方法 | 注意力维度 | 额外参数 | 额外计算 | 适合场景 |
|---|---|---|---|---|
| SE | 通道 | 约 2C²/r | 与 C² 相关,占比很小 | 分类骨干、通道数大的深层 |
| ECA | 通道 | 约 k 个 | 几乎为零 | 移动端、轻量模型 |
| CBAM | 通道 + 空间 | 与 C² 和 H×W 都相关 | 中等偏高 | 检测、分割 |
| CA | 通道 + 位置 | 中等 | 与 H+W 相关 | 移动端分割、密集预测 |
| 多头自注意力 | 空间 + 通道 | 与 C² 相关且较大 | 与 H²W² 相关 | 大模型、长程依赖建模 |
5.3 多头自注意力和 SE 到底差在哪
这两个东西经常被混为一谈,因为都叫"注意力",但实际上从问题设定就不一样。
SE 的注意力是逐通道的一维权重,它对所有空间位置一视同仁——如果某个通道被判定为重要,那么整张图上这个通道的所有位置都被同等放大。它建模的是"通道之间的关系"。
多头自注意力作用在序列上,每个位置都会生成自己的 Query、Key、Value,然后计算所有位置两两之间的相似度,得到一张 L×L 的注意力矩阵(L 是序列长度)。它建模的是"任意两个位置之间的关系",是一种内容寻址的全局交互。
从计算复杂度看,SE 是 O(C²),跟空间尺寸无关;自注意力是 O(L²),随序列长度平方增长。这就是为什么 ViT 在高分辨率输入下需要窗口注意力、线性注意力这类变体来降复杂度,而 SE 从来不需要考虑这个问题。
一个实用的判断法:如果你的瓶颈在于"通道之间的分工没有被充分利用",用 SE;如果瓶颈在于"模型需要看到长距离的空间依赖",那得上自注意力。两者不冲突,很多混合架构(比如 CoAtNet 那一类)就是卷积 + SE + 注意力的组合。
5.4 时序任务里的通道注意力变体
SE 的原始设计针对二维特征图,但在时序建模里,把 (C, T) 当作 (C, H) 来用,Squeeze 换成沿时间轴的全局平均,Excitation 照旧,一样能用。我做过一个传感器信号分类的项目,输入是 12 通道的时序信号,窗口长度 512。直接套二维 SE(把时间轴当空间轴)能带来约 1.2 个点的准确率提升,尤其是当某些通道在特定工况下完全无响应时,SE 能自动把它压下去。
这里有个容易忽略的点:时序数据的通道数通常很小(8 到 32),远小于图像里的几百上千,所以 r 一定要设小,或者干脆不要瓶颈。我用的是 C=12、r=2 的配置,中间层还有 6 个神经元,勉强够用。如果照搬图像里的 r=16,中间层就是 0,直接退化成无效模块。
6. 在真实项目里把 SE 用出效果的一些具体经验
6.1 什么任务加 SE 收益最大
按我自己的项目经验排个序,收益从高到低:
多通道传感器数据融合排第一。当输入有多个来源(比如摄像头、雷达、红外)或者多路传感器时,各通道的信噪比差异很大,SE 能自动降权噪声通道,效果通常比图像分类里还明显。我做过一个三路传感器融合的分类任务,加 SE 之后准确率提升 2.3 个点。
细粒度分类排第二。通道分工越细的任务,SE 的价值越大。之前说的那个 200 类细粒度项目,r=4 的 SE 带来了 1.8 个点的提升。
通用图像分类排第三,典型提升 0.5 到 1.5 个点,任务越难(类别越多、类间差异越小)提升越明显。
目标检测和分割要看具体结构。加在 backbone 上通常有小幅提升,加在 FPN 或者检测头上收益不稳定,有时候会掉点,因为检测任务对空间位置敏感,纯通道注意力可能干扰定位精度。这时候应该考虑 CBAM 或者 CA。
已经在用大模型或者强增强的任务收益最小。如果你的骨干已经是 EfficientNet 或者 ConvNeXt 这类本身带注意力设计的架构,再叠一层 SE 经常是负收益——重复的注意力机制会互相干扰。
6.2 推理延迟的真实开销和几条优化经验
纸上说的 FLOPs 增加 0.5%,不代表推理时间只增加 0.5%。SE 里有个 GAP,它是典型的 reduce 操作,在很多推理框架里延迟并不低,因为需要跨线程同步。我在几个平台上实测过 ResNet-50 加 SE 的延迟变化:
| 平台 | 输入分辨率 | 无 SE 延迟 | 有 SE 延迟 | 增幅 |
|---|---|---|---|---|
| 服务器 GPU (FP16) | 224×224 | 3.1 ms | 3.4 ms | 9.7% |
| 桌面 CPU 单线程 | 224×224 | 48 ms | 61 ms | 27% |
| 移动端 NPU | 224×224 | 8.2 ms | 8.9 ms | 8.5% |
CPU 上的增幅明显偏高,主要就是 GAP 和 sigmoid 的开销。优化方向有这么几个:
一是合并 GAP 的实现。有些框架里 AdaptiveAvgPool2d(1) 会走一条通用但慢的路径,换成显式的 mean 操作(比如x.mean(dim=(2,3), keepdim=True))在某些版本上能快 20%。
二是把两个 1×1 卷积合并成一个。如果你不介意放弃非线性,可以把 W2·W1 预乘成一个矩阵,推理时只做一次矩阵乘。代价是失去 ReLU 的非线性,精度会掉一点,但在极端延迟敏感的场景下值得一试。
三是只在部分 stage 加 SE。实测下来,只在 layer3 和 layer4 加 SE,能保住大约 80% 的精度收益,参数量和延迟开销减半。这是个很实用的折中方案。
6.3 几个调参的土办法
最后分享几条我从项目里攒下来的经验,都是文档里不会写的。
看 SE 权重的分布判断模块是否在工作。训练完之后,把所有 batch 的 SE 输出收集起来看直方图。如果所有值都挤在 0.5 附近,说明这个 SE 基本没学到东西,可能是 r 太大或者学习率不合适。如果分布很分散(比如在 0.1 到 0.9 之间),说明它在积极工作。我在一个项目里就是靠这个发现 layer1 的 SE 完全没起作用,直接把那层去掉,省了参数还涨了 0.1 个点。
用 Grad-CAM 对比加 SE 前后的热力图。如果加了 SE 之后热力图反而更分散了,说明通道权重的调整干扰了空间定位,这在检测任务里是危险信号。
微调时给 SE 单独设学习率。SE 是从零初始化的,而主干是预训练的,两者需要的更新幅度不一样。我通常给 SE 分支设 10 倍于主干的学习率,让它快速适应,通常两三个 epoch 就能收敛到合理值。当然那个 bias=2.0 的初始化能让这个需求不那么强烈。
别在 stride 太大的层上用大 r。前面提过空间尺寸的问题,这里补一句:stride 32 那层特征图只有 7×7,GAP 对整张图的表示已经很粗了,此时用 r=16 会导致瓶颈层非常小,表达能力受限。我的做法是在最深层用 r=8,浅层用 r=16,效果比统一 r 好一些。
如果你在复现论文,注意 SE 是加在所有 bottleneck 上还是只加部分。有些实现为了省事只在每个 stage 的第一个 block 加,性能会差 0.3 到 0.5 个点。做对比实验的时候这点差异足以让你得出错误结论。
最后,别迷信 SE。它是一个性价比很高的模块,但不是万能药。当你的模型已经很强、数据量已经很大、任务本身对通道重要性不敏感时,SE 带来的收益可能还不如调一下数据增强策略。我现在的习惯是:先不加任何注意力模块把 baseline 训到一个稳定的水平,再逐个尝试 SE、ECA、CA,用消融实验说话,而不是一上来就堆模块。