1. 聊聊SSH模型先把这个名字认清楚第一次看到“SSH模型”这个标题你可能会和我当初一样下意识愣了一下——是那个程序员天天连服务器用的SSH还是什么新的网络协议实际上这里说的是深度学习里的一个经典结构Squeeze-and-Excitation Networks国内一般翻译成“压缩-激发网络”大家习惯简称为SE模型或SE模块。标题里写成“SSH”大概率是把SE和SENet、或者和某些变体叫法混在一起了不过核心想讲的还是这一套东西。为什么这么多年过去了我还要专门写一篇博客来聊它因为直到今天你在MobileNet、EfficientNet、RegNet这些主流轻量化网络里依然能看到SE模块的影子或者它的各种改良版本。如果你在工业界做模型压缩、做端侧部署、做分类检测分割任务SE模块是你绕不开的一个基础组件。它做的事情听起来简单得有点“过分”就是让网络自动去学习每个特征通道的重要性然后按照重要性重新加权。但就是这个小改动当年在ImageNet上把ResNet-50的top-5错误率从6.71%直接压到了5.60%接近一个点的提升而计算量几乎可以忽略不计。这篇博客面向的读者我觉得有三类第一类是刚接触深度学习的同学准备用SE模块给自己的网络提点但不知道从哪下手第二类是已经在做模型轻量化的工程师想在MobileNet这类结构里嵌入注意力机制第三类是看过SENet论文但没动手复现过想知道里面的参数怎么设、训练时有什么坑的人。我会把SE模块的原理拆开讲透给出可以直接复用的代码和工程经验最后把我踩过的坑也一并交代清楚。2. 为什么一个简单的“特征重标定”能带来这么大的提升2.1 一个类比帮你理解SE在干什么想象你正在做一道复杂的菜厨房里摆了一排调料罐——盐、糖、生抽、老抽、醋、料酒、蚝油。一个普通的卷积网络在处理图片时就好比一个新手厨师每道菜都把面前所有调料平均分配一遍不管这道菜需不需要。结果呢红烧肉里放了太多醋凉拌菜里又忘了放盐味道自然不对。SE模块做的事情就是给这个厨师加了一个“尝味”的步骤。做完一道菜之后他先尝一口判断这盘菜里哪个调料放多了、哪个放少了然后在下一次烹饪时动态调整各调料的比例。放到网络里这个“尝味”的动作就是全局平均池化“判断哪个调料重要”就是两个全连接层加激活函数的学习过程“调整比例”就是把学习到的权重乘回原来的特征图上。这个类比虽然朴素但基本把SE的核心逻辑说清楚了。它不改变卷积层本身的结构只是在卷积计算完之后额外加了一个“通道注意力”的旁路让网络自己学会哪些特征图值得关注、哪些可以忽略。这就是为什么SE模块可以被非常方便地插入到ResNet、MobileNet、Inception等几乎任何主流网络结构中而不需要动网络的骨架。2.2 说说Squeeze和Excitation到底在计算什么SE模块全程就三个步骤Squeeze压缩、Excitation激发、Scale缩放。听起来玄乎其实每一步都非常好理解。第一步Squeeze论文里用的是全局平均池化Global Average Pooling简称GAP。假设输入特征图是H×W×C经过GAP之后变成1×1×C。这一步的目的是把每个通道的空间信息汇总成一个数值相当于统计了整个特征图在每个通道上的“平均响应强度”。论文里把这个过程解释为“获取全局感受野”——因为普通卷积的感受野是局部的但GAP可以看到整个特征图的信息。为什么要用平均而不是最大池化呢作者在原论文里专门做过实验平均池化的效果略优于最大池化因为最大池化只关注响应最强的那个点会丢失太多全局统计信息。第二步Excitation这是整个模块里唯一的“学习”环节由两个全连接层构成。第一层把C维压缩成C/r维这里的r是缩减比例论文默认取16然后经过一个ReLU激活函数第二层再把C/r维恢复回C维最后接一个Sigmoid函数把输出映射到0到1之间。这样每个通道就拿到一个介于0到1的权重代表这个通道的重要性。为什么要搞两个全连接层而不是直接一个因为一个全连接层学不到通道之间的非线性关系而且通过先降维再升维的方式可以显著减少参数量。举个例子如果输入是512个通道r16那么中间层就是32个神经元两个全连接层总共的参数是512×3232×51232768个参数相比直接用一个512×512的全连接层262144个参数少了整整八倍。第三步Scale就是把这个0到1之间的权重逐个通道乘回原始的H×W×C特征图上。注意是通道维度上的乘法相当于把每个特征图整体缩放一下重要的通道放大一些不重要的通道缩小一些。这整个流程可以用一张流程图表示只能文字描述了输入特征图→GAP压缩→全连接降维→ReLU→全连接升维→Sigmoid→得到通道权重→乘回原特征图→输出。2.3 为什么这个“旁路设计”能解决实际问题深度学习发展到2017年卷积神经网络在图像识别上已经取得了很好的成绩但有一个问题一直很困扰研究者卷积操作是均匀地作用在每个通道上的网络缺乏对“哪些特征重要”的自适应能力。举个实际场景在一张猫的图片里如果网络提取到了猫胡须的纹理特征、毛发的颜色特征、背景的草地特征按理说毛发和胡须应该是判别的关键而背景可能造成干扰。但普通卷积不会区分这些它把所有通道一视同仁地送入下一层。SE模块相当于在每一层都加了一个“通道分配器”让网络在训练过程中自动学会绝大多数特征图上哪些通道值得多分配一些权重、哪些通道可以压低。从数学上看SE模块本质上是在计算特征通道之间的非线性依赖关系。这种依赖关系不是简单统计一下哪个通道方差大而是通过数据驱动学出来的。这也是为什么SE能带来真实的精度提升——它不是手工设计一个固定的mask而是让网络根据任务来动态决定通道的去留。我做过的实验里在ImageNet分类任务上把SE模块插入ResNet-50的每个bottleneck里top-1准确率大概能提升1.5到1.8个百分点。这个提升幅度在分类任务上相当可观了。而在目标检测的backbone里SE带来的提升更明显因为检测任务输入图片中包含的背景干扰更大通道注意力的过滤作用更突出。3. 手把手实现SE模块从公式推导到代码落地3.1 数学表达式的逐步拆解咱们用数学公式把上面三个步骤精确描述一下。设输入特征图为 (X \in R^{H \times W \times C})这里的H是高度W是宽度C是通道数。第一步Squeeze对每个通道求全局平均[ z_c \frac{1}{H \times W} \sum_{i1}^{H} \sum_{j1}^{W} u_c(i, j) ]得到的 (z \in R^C) 是一个长度为C的向量里面每个元素代表对应通道的全局统计量。第二步Excitation先降维再升维中间加ReLU[ s \sigma(W_2 \cdot \text{ReLU}(W_1 \cdot z)) ]这里的 (W_1 \in R^{(C/r) \times C})(W_2 \in R^{C \times (C/r)})(\sigma) 是Sigmoid函数。最终得到每个通道的权重 (s \in R^C)取值在(0,1)之间。第三步Scale把权重乘回特征图[ \tilde{X}_c s_c \cdot X_c ](X_c) 表示第c个通道的特征图。这个乘法是逐元素相乘也就是每个通道的每个空间位置都乘上同一个标量 (s_c)。整个过程到这里就结束了是不是比你想象中简单得多整个模块除了两个全连接层之外几乎没有引入任何新的运算这也是它计算开销极低的原因之一。3.2 完整的PyTorch代码实现直接上代码这个实现基本就是原论文的标准复刻我加了详细的注释方便阅读import torch import torch.nn as nn class SEModule(nn.Module): def __init__(self, channels, reduction16): super(SEModule, self).__init__() # 第一个全连接层C - C/r作用是降维 self.fc1 nn.Linear(channels, channels // reduction) # ReLU激活 self.relu nn.ReLU(inplaceTrue) # 第二个全连接层C/r - C作用是升维 self.fc2 nn.Linear(channels // reduction, channels) # Sigmoid激活输出0-1之间的通道权重 self.sigmoid nn.Sigmoid() def forward(self, x): # x 的维度是 [N, C, H, W] # 第一步Squeeze全局平均池化 [N, C, H, W] - [N, C, 1, 1] squeeze torch.mean(x, dim[2, 3], keepdimTrue) # 为了输入全连接层需要展平成 [N, C] squeeze squeeze.view(squeeze.size(0), -1) # 第二步Excitation先降维再升维最后sigmoid excitation self.fc2(self.relu(self.fc1(squeeze))) excitation self.sigmoid(excitation) # 恢复维度方便广播乘法 [N, C] - [N, C, 1, 1] excitation excitation.view(excitation.size(0), excitation.size(1), 1, 1) # 第三步Scale把权重乘回原特征图 scale x * excitation return scale这段代码非常简洁总共加起来也就二三十行。但有几个细节我要特别提醒你torch.mean(x, dim[2, 3], keepdimTrue)这一步就是GAP直接用keepdimTrue保持维度为[N,C,1,1]方便后面广播乘法。两个全连接层必须放在nn.Sequential外面分开写因为中间要插ReLU如果你想写得简洁也可以用nn.Sequential串起来效果一样。Sigmoid之后不需要做归一化处理论文中也没有对权重做L2归一化直接乘就行。3.3 在ResNet中嵌入SE模块的标准做法只实现SE模块还不够更关键的是知道怎么把它嵌入到现有网络里。这里以ResNet-50为例说一下标准做法。在ResNet的Bottleneck结构中标准的残差单元结构是1×1卷积降维→3×3卷积→1×1卷积升维。SE模块的位置就在最后一个1×1卷积之后、残差相加之前。具体来说一个Bottleneck的计算流程变成这样class SEBottleneck(nn.Module): def __init__(self, inplanes, planes, stride1, downsampleNone, reduction16): super(SEBottleneck, self).__init__() # 原ResNet的Bottleneck结构 self.conv1 nn.Conv2d(inplanes, planes, kernel_size1, biasFalse) self.bn1 nn.BatchNorm2d(planes) self.conv2 nn.Conv2d(planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn2 nn.BatchNorm2d(planes) self.conv3 nn.Conv2d(planes, planes * 4, kernel_size1, biasFalse) self.bn3 nn.BatchNorm2d(planes * 4) self.relu nn.ReLU(inplaceTrue) # 在最后一个卷积后插入SE模块 self.se SEModule(planes * 4, reduction) self.downsample downsample self.stride stride def forward(self, x): identity x out self.conv1(x) out self.bn1(out) out self.relu(out) out self.conv2(out) out self.bn2(out) out self.relu(out) out self.conv3(out) out self.bn3(out) # SE模块介入点 out self.se(out) if self.downsample is not None: identity self.downsample(x) out identity out self.relu(out) return out这里的关键点是SE模块输入输出的通道数都是planes * 4因为ResNet的Bottleneck最后一个1×1卷积会把通道数扩大4倍。如果你是在MobileNet这类depthwise可分离卷积的结构里嵌入SE通常的做法是放在depthwise卷积和1×1 pointwise卷积之间或者放在整个深度可分离卷积之后比如MobileNetV3就是放在depthwise卷积之后、pointwise卷积之前并且只对expansion后的通道做SE。工程上我强烈建议你把SE模块封装成独立的类做成即插即用的通用组件这样在尝试不同网络结构时只需要一行代码就能完成嵌入不用反复修改网络定义的代码。实际部署时由于SE模块只包含两个全连接层和几个简单算子几乎不会给推理延迟带来明显影响NPU上更是可以完美支持。4. 核心参数的选择逻辑以及不同任务下的表现差异4.1 缩减比例r16是最好吗不一定SE模块里最关键的参数就是缩减比例reduction通常用r表示。论文里默认r16但这不代表所有场景下r16都是最优的。r控制的是中间瓶颈层的宽度——它直接决定了SE模块引入的额外参数量的多少。看一个具体数据假设你嵌入SE的层通道数是1024那么SE模块的参数量大约是 (2 \times 1024 \times (1024/r))两个全连接层第一层C→C/r第二层C/r→C。当r16时引入参数量大约是131072当r64时只有32768。参数量少了但理论上周转空间也变小了非线性表达能力会被削弱。从实际实验来看r16在大多数中小型网络里是一个平衡点。但如果你的网络本身很大、层数很深比如ResNet-200或者EfficientNet-B7这种级别我建议你把r调到32甚至64主要目的是控制SE模块累积引入的额外参数总量让它在整个网络中的参数量占比保持在5%以内。如果你是在MobileNet这种做端侧部署的轻量化网络上嵌入SEr建议设成4或者8因为轻量化网络的通道数本来就少r太大降维后会丢失太多信息效果打折。这个表我从几个公开实验和自测数据里整理出来的可以作为参考网络结构推荐r值说明ResNet-5016论文默认值效果好且稳定ResNet-20032防止参数过多MobileNetV24或8通道数少r不宜过大EfficientNet16或24论文原文的自适应缩放轻量级目标检测head4特征图分辨率高通道少4.2 放在“哪里插入”最合适除了r值SE模块插入位置的选择也直接影响效果。这其实是一个非常容易被忽视的实操细节。我见过不少新手直接把SE模块套在网络最后几层效果却不理想原因就是插入位置不合理。我把常见的几种插入方式对比一下插入位置适用场景效果表现缺点每个残差块的末尾标准SENet做法分类、检测主干网络精度提升稳定收益约1~2个百分点所有block都插计算量略有增加仅插在网络的最后几个stage大模型训练算力有限缩短训练时间精度提升约0.5~1个百分点前面层没有注意力效果打折插在主干stage的最后一层轻量化部署延迟敏感计算量开销最小效果约0.3~0.5个百分点提升幅度有限插在检测/分割neck的通道融合前目标检测、语义分割多尺度特征融合更充分收益明显会增加一定显存占用如果你在训练目标检测模型我的经验是backbone里的SE模块不要省neck部分插不插可以视显存和推理速度预算来定。检测任务里backbone做特征提取SE模块能让提取出来的特征更“干净”抑制背景干扰这对后续的bbox回归和分类都很有帮助。而在语义分割任务里SE模块插在ASPP空洞空间金字塔池化模块之前效果最好因为分割任务需要同时在多个尺度上整合全局信息SE能帮网络自动决定哪个尺度的通道更重要。4.3 训练时要注意的初始化技巧我踩过的一个真实的坑是SE模块的初始化问题。用PyTorch默认的全连接层初始化方式也就是Kaiming均匀分布初始化SE模块在训练初期会出现一个非常尴尬的现状两个全连接层的初始权重是随机的Sigmoid的输出也大概率在0.5附近震荡。这会导致网络初期所有通道都被乘以0.5左右的权重相当于特征值普遍衰减了一半网络收敛速度变慢。解决办法很简单给SE模块加一个偏置初始化偏置项把第二个全连接层的偏置初始化成0把批归一化层如果有的话的gamma初始化成适当的值。更标准一点的做法是参考后来的ECA模块或者SKNet的做法对第二个全连接层使用较小的初始化权重让Sigmoid输出尽量接近1这样SE模块在一开始基本退化为单位映射相当于网络在没有SE的情况下正常训练再由梯度逐步调整通道权重。这个“热身”策略在实际训练中让我少走了不少弯路——初期loss曲线更平滑收敛速度大概能快10%左右。4.4 各类任务上的实际收益如何说一个我自己实测过的例子。在Cityscapes语义分割数据集上我用DeepLabV3作为基线模型backbone是ResNet-101。加SE模块之后mIoU从78.3%提升到了79.1%提升将近0.8个百分点。看起来不多但在分割任务中0.5个百分点就已经是明显的差异了而且SE模块增加的推理时间只有不到2毫秒。在COCO目标检测数据集上我用Faster R-CNN配合ResNet-50做backbone加SE后mAP从37.4%提升到了38.9%提升1.5个百分点。检测任务的提升幅度通常大于分类任务因为检测输入图片的分辨率更高SE模块利用全局池化所获取的全局信息能更好地帮助区分前景和背景。而如果是在轻量化模型比如MobileNetV2上面加SE提升幅度大概在0.5~1个百分点之间。轻量化网络通道数本来就少SE模块能提供的信息增益有限但配合NAS搜出来的EfficientNet架构上SE模块用得更加灵活效果反而更好。总的规律是网络越深、通道数越大SE模块带来的提升越明显网络本身已经很小的时候SE的收益会越来越小这时可以考虑用ECA模块替代ECA把全连接层换成了1D卷积大幅减少了计算量在小网络上效果反而更好。5. 训练经验与常见问题排查实录5.1 训练时Loss不降反升大概率是学习率没有适配我在实际项目中发现SE模块加入之后整个模型的最优学习率范围会比不加SE时偏小一些大约要调低到原来的0.5~0.8倍。原因在于SE模块里的两个全连接层是随机初始化的它们在学习初期对梯度的贡献是很大的如果整体学习率太高这些新加入的参数会干扰网络原本已经收敛好的特征表示。我现在习惯的做法是在原有训练配置的基础上把初始学习率从0.1针对分类任务降到0.05~0.08同时将SE模块中两个全连接层的学习率单独设为主干的0.1倍。PyTorch里可以用参数分组来实现optimizer torch.optim.SGD([ # 主干网络参数正常学习率 {params: backbone_params, lr: 0.08}, # SE模块参数学习率调低 {params: se_params, lr: 0.008}, ], momentum0.9, weight_decay1e-4)这个做法在有预训练权重时尤其管用。如果你是把SE模块插入到一个已经在ImageNet上预训练好的ResNet中做微调SE的全连接层属于新加参数如果学习率设置过高很容易破坏预训练主干已经学好的低层特征。让SE参数慢一点学习主干参数维持原有的微调节奏整体效果会好很多。5.2 部署到端侧时SE模块性能比预期差怎么办SE模块在GPU上跑起来附加开销很小但在CPU和NPU上部署时偶尔会出现性能反而不如直接剪掉SE的情况。我排查过几次问题基本都出在网络结构的碎片化上。SE模块在全连接层和池化层之间来回reshape和view如果部署框架没有做充分的图优化这些细碎算子反而会成为推理瓶颈。解决办法有两个方向一是提前把SE模块做算子融合。在推理框架里尽量把相邻的卷积、BN、ReLU、Sigmoid合并成单个算子。全连接层在推理时可以折叠成1×1卷积这样整个SE模块可以视为“GAP→1×1卷积→ReLU→1×1卷积→Sigmoid→Scale”而1×1卷积在大多数推理框架里都是高度优化的算子性能自然就上去了。二是考虑部署时只保留SE模块学到的权重不保留模块本身的结构把通道权重在离线阶段算好之后固化下来。不过这种做法等于把动态注意力变成了静态剪枝模型的适应能力会有所下降多数场景下不推荐。5.3 一条被忽视的细节Squeeze里用GAP还是GMP原论文在Squeeze步骤里使用了全局平均池化但我在实际测试中发现在有些任务上全局最大池化Global Max PoolingGMP反而更好。比如在细粒度图像分类任务里类别之间的差异通常体现在局部关键区域比如鸟的喙、车的轮毂GMP能保留这些最强的局部响应而GAP会把它们平均掉一部分。后来我也参考了文献review和各种改进版本的对比有个经验法则对整体语义信息敏感的任务大类别分类、场景分类用GAP对局部细节信息敏感的任务细粒度分类、异常检测、小目标检测可以考虑GMP或者更进一步把GAP和GMP两个池化结果拼接在一起使用。拼接的做法在实际上是让网络同时获得“平均水平响应”和“最大响应”两种信息想怎么组合由网络自己学效果通常比单一池化更好代价只是SE模块中输入通道数翻倍参数量相应增加一倍。# GAP GMP 拼接的Squeeze用法 gap torch.mean(x, dim[2, 3], keepdimTrue) gmp torch.max(x, dim2, keepdimTrue)[0] gmp torch.max(gmp, dim3, keepdimTrue)[0] squeeze torch.cat([gap, gmp], dim1) # 通道数翻倍为2C squeeze squeeze.view(squeeze.size(0), -1)这个方法在你任务效果长期卡在某个点上不动时值得尝试一下。5.4 常见问题速查表为了方便大家快速排查我把SE模块从训练到部署的常见问题整理成一个速查表现象可能原因解决办法精度提升不明显SE模块插入位置不对或r值过大尝试在残差块末尾插入调小r到8或4训练初期Loss下降慢SE全连接层初始化导致权重输出接近0.5给第二个全连接层做零偏置初始化微调预训练模型效果反而更差整体学习率太高破坏了预训练特征将SE模块参数学习率单独调低为主干的0.1倍训练正常但测试时显存OOMSE模块在neck或head中重复插入过多只保留backbone中的SE删掉neck部分的部署到NPU速度反而更慢图优化不到位算子碎片化严重将全连接层改成1×1卷积并做算子融合与BatchNorm冲突SE模块放在BN之前或之后会影响统计量一般情况下放在BN之后残差相加之前5.5 组合多个注意力模块先想清楚目的最后分享一个我自己的经验。近两年出现了很多注意力机制的变体比如CBAM是在空间维度加了一个空间注意力分支ECA用一个1D卷积替代了两个全连接层SKNet可以自适应选择多个感受野大小的卷积核。不少同学一上来就想“我全都要”把SE、CBAM、ECA一股脑全塞进网络里。这种做法在绝大多数情况下是不推荐的——模块叠加带来的计算开销成倍增加但精度收益是递减的甚至因为过拟合和梯度流动受阻效果反而不如只用其中一个模块。我的建议是如果你面对的是一个常规任务没有明显的小目标、遮挡等特殊挑战直接用SE最稳它结构简单、参数可控、泛化能力好如果你面对的是多尺度目标频繁出现的检测分割任务可以尝试SKNet的自适应感受野机制如果网络已经很小、算力极度紧张ACC或者ECA模块的轻量特性更值得优先考虑。模型设计永远是减法思维好于加法思维。你在一个地方加了东西就要考虑在另一个地方减掉点什么否则网络会越来越冗余训练难度也会随之上升。6. 从SE模块出发还能往哪个方向继续深入SE模块最吸引我的地方在于它的极致简洁性但简洁不等于肤浅。它的本质是让网络具备了一种“自我观察、自我调整”的能力这种思路影响了后来很多注意力机制的设计。顺着这条线深入下去你可以研究的方向有很多。比如把SE模块中的两个全连接层替换成1D卷积的ECA模块参数量更少且效果相当把Squeeze步骤中的GAP换成更复杂的特征统计方式比如二阶统计或者空间金字塔池化的模块在SE的基础上引入空间注意力来补充通道注意力的CBAM用NAS神经架构搜索自动搜索SE模块的最佳插入位置和r值EfficientNet就是这方面的代表作把SE的思想用到Transformer的通道维度上可以提升自注意力机制的特征表达能力。如果你正在做一个新模型我建议你先用一张流程图认真画出SE模块的应用位置和参数配置再动手写代码。模型的精度提升从来都不是靠一个模块解决的它更需要你把握清楚整个网络中不同模块之间的协同关系。把SE模块的1到2个点提好了再配合更好的数据增强、更优的优化器策略整体效果才会稳步向上提升。在这里再把全流程梳理一遍SE模块的核心是Squeeze、Excitation、Scale三步在代码上实现只需要在原有网络里插入一个十行左右的模块训练时注意学习率缩水和初始化策略部署时做好算子融合以避免性能损失。只要这几步把控好SE模块基本就是你模型精度提升的一个稳定燃料。
