1. 从一张特征图说起:为什么需要空间与通道双注意力
做视觉任务的朋友大概率都遇到过这种情况:模型在训练集上表现不错,一换到真实场景就拉胯,尤其是目标尺度变化大、背景杂乱、光照不均的时候。我最早做分类任务时,明明骨干网络已经换到比较深的版本了,精度就是卡在一个瓶颈上不去。后来分析热力图才发现,模型把大量注意力放在了背景纹理上,真正有判别力的区域反而响应很弱。
这就是注意力机制要解决的核心问题:让网络学会“看哪里”和“看什么”。通道注意力回答的是“哪些特征通道更重要”,空间注意力回答的是“特征图的哪些位置更重要”。两者结合,就构成了空间和通道双注意力模块的基本思路。
我这次复现的模块,核心结构并不复杂:先做通道维度的注意力加权,再做空间维度的注意力加权,最后把两路结果融合。但真正动手写的时候,细节坑不少——池化方式的选择、卷积核大小的取舍、激活函数的搭配、残差连接的加法还是乘法,每一步都会影响最终效果。下面我把整个复现过程拆开讲,包括设计动机、代码实现、参数调试和实测对比。
提示:本文假设读者已经具备PyTorch基础,了解卷积神经网络的基本结构。如果你刚接触深度学习,建议先跑通一个简单的分类网络再来看这篇。
2. 模块整体设计:先通道后空间,还是并行双路
2.1 两种主流串联方式的取舍
空间和通道注意力的组合方式,常见的有两种:串联和并行。串联就是先算通道注意力,加权后再算空间注意力,再加权一次;并行则是两路同时计算,最后融合。我最初选的是串联,因为逻辑上更直观——先决定“哪些通道重要”,再决定“哪些位置重要”。
但实测下来发现一个问题:串联结构中,通道注意力会先改变特征分布,空间注意力再基于改变后的特征去计算,两者之间存在耦合。如果通道注意力过度抑制了某些通道,空间注意力可能会丢失一部分位置信息。后来我改成并行结构,两路独立计算后再相加融合,反而更稳定。
不过并行结构也有代价:参数量和计算量会略增。对于轻量级模型,串联更划算;对于精度优先的场景,并行更稳。我最终的方案是折中——通道注意力用较少的参数,空间注意力用较大的卷积核,两者并行后做逐元素相加。
2.2 通道注意力的设计逻辑
通道注意力的核心思想是:每个通道代表一种特征响应,不同通道对不同语义的敏感度不同。比如有些通道对边缘敏感,有些对纹理敏感,有些对颜色敏感。通道注意力就是给每个通道学一个权重,重要的通道权重高,不重要的权重低。
实现上,最经典的做法是全局平均池化加两层全连接。全局平均池化把每个通道的空间信息压缩成一个标量,然后通过一个降维再升维的瓶颈结构,学出通道间的非线性关系。这里有个关键参数:降维比例。我试过4、8、16三个值,发现8在大多数任务上表现最均衡。比例太小,参数量大且容易过拟合;比例太大,通道间关系学不充分。
激活函数我用的是Sigmoid,因为需要把权重映射到0到1之间。但注意,Sigmoid在两端梯度很小,如果通道数很多,深层网络里可能会出现梯度消失。我的做法是在全连接层后加BatchNorm,再接Sigmoid,这样梯度更稳。
2.3 空间注意力的设计逻辑
空间注意力关注的是“特征图的哪些位置更重要”。和通道注意力不同,空间注意力是在每个位置上算一个权重,所有通道共享。实现上,通常先沿通道维度做最大池化和平均池化,得到两张单通道的特征图,然后拼接后用一个卷积层学出空间权重。
这里有个细节:最大池化和平均池化要不要都用?我试过只用平均池化,发现对显著目标的响应不够强;只用最大池化,又容易受噪声影响。两者拼接后卷积,效果最好。卷积核大小我选了7×7,因为感受野够大,能覆盖中等尺度的目标。如果目标普遍很小,可以降到3×3;如果目标很大,可以升到11×11。
注意:空间注意力的卷积层不要加激活函数,直接输出后接Sigmoid。加了ReLU会把负值截断,导致部分位置的权重恒为零,反而丢失信息。
3. 代码逐段拆解:从张量维度到参数初始化
3.1 通道注意力模块的完整实现
先看通道注意力部分的代码。输入张量的形状是(batch, channels, height, width),输出是(batch, channels, 1, 1)的权重向量。
import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction=8): super(ChannelAttention, self).__init__() self.avg_pool = nn.AdaptiveAvgPool2d(1) self.max_pool = nn.AdaptiveMaxPool2d(1) self.fc = nn.Sequential( nn.Linear(channels, channels // reduction, bias=False), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels, bias=False), nn.BatchNorm1d(channels), nn.Sigmoid() ) def forward(self, x): b, c, _, _ = x.size() avg_out = self.fc(self.avg_pool(x).view(b, c)) max_out = self.fc(self.max_pool(x).view(b, c)) out = avg_out + max_out return out.view(b, c, 1, 1)这段代码里,我同时用了平均池化和最大池化,两者结果相加后过Sigmoid。为什么用两个池化?平均池化保留整体信息,最大池化保留显著特征,两者互补。BatchNorm加在全连接之后,是为了稳定训练初期的梯度分布。注意全连接层的bias都设成了False,因为后面有BatchNorm,bias会被抵消,加了反而浪费参数。
3.2 空间注意力模块的完整实现
空间注意力模块的输入同样是(batch, channels, height, width),输出是(batch, 1, height, width)的权重图。
class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super(SpatialAttention, self).__init__() assert kernel_size in (3, 7, 11), 'kernel size must be 3, 7 or 11' padding = kernel_size // 2 self.conv = nn.Conv2d(2, 1, kernel_size, padding=padding, bias=False) self.sigmoid = nn.Sigmoid() def forward(self, x): avg_out = torch.mean(x, dim=1, keepdim=True) max_out, _ = torch.max(x, dim=1, keepdim=True) concat = torch.cat([avg_out, max_out], dim=1) out = self.conv(concat) return self.sigmoid(out)这里沿通道维度分别做平均和最大,得到两张单通道图,拼接后用一个卷积层融合。卷积核大小我默认用7,padding设为3保持尺寸不变。注意卷积层没有bias,因为后面接Sigmoid,bias的作用会被Sigmoid的非线性吸收,去掉可以减少参数量。
3.3 双注意力融合模块的组装
把通道和空间注意力组装起来,我采用的是并行结构:
class DualAttention(nn.Module): def __init__(self, channels, reduction=8, kernel_size=7): super(DualAttention, self).__init__() self.channel_att = ChannelAttention(channels, reduction) self.spatial_att = SpatialAttention(kernel_size) def forward(self, x): ca = self.channel_att(x) sa = self.spatial_att(x) out = x * ca + x * sa return out融合方式我用了相加而不是相乘。相乘会让两个注意力都强的地方才保留,容易过度抑制;相加则保留了两路各自的判断,更平滑。实测下来,相加的收敛速度比相乘快约15%的epoch。
3.4 参数初始化与训练稳定性
注意力模块的参数初始化很关键。全连接层我用的是Kaiming初始化,卷积层用默认初始化。但注意,Sigmoid前的BatchNorm的weight初始化为1,bias为0,这样初始阶段Sigmoid的输入接近0,输出接近0.5,相当于对原始特征做近似恒等映射,不会在训练初期破坏预训练权重。
def init_weights(m): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') elif isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') model.apply(init_weights)提示:如果你是在预训练骨干网络上加注意力模块,建议先把骨干网络冻结几个epoch,只训练注意力部分,等损失稳定后再解冻全网络微调。这样能避免注意力模块的随机初始权重把预训练特征带偏。
4. 实测对比:插入位置、池化方式与超参影响
4.1 插入位置对精度的影响
我把双注意力模块分别插在骨干网络的四个位置做了对比实验:浅层(第一组卷积后)、中层(第二组卷积后)、深层(第三组卷积后)、以及每个残差块后都插。数据集用的是自己构造的一个中等规模分类任务,约5万张图,20个类别。
| 插入位置 | 参数量增量 | 验证集精度 | 训练收敛epoch |
|---|---|---|---|
| 浅层 | +0.8% | +1.2% | 42 |
| 中层 | +0.8% | +2.1% | 38 |
| 深层 | +0.8% | +1.6% | 40 |
| 每块都插 | +3.2% | +2.3% | 35 |
结论很明确:中层插入性价比最高,精度提升明显且参数量增加少。每块都插虽然精度略高,但参数量和计算量增加太多,推理速度下降约20%。深层插入效果不如中层,因为深层特征已经比较抽象,空间信息损失较多,空间注意力的作用被削弱。
4.2 池化方式的消融实验
通道注意力里我用了平均池化和最大池化双路,空间注意力里也用了双路。为了验证双路是否必要,我做了消融:
| 通道池化 | 空间池化 | 验证集精度 |
|---|---|---|
| 仅平均 | 仅平均 | 基准 |
| 仅平均 | 双路 | +0.9% |
| 双路 | 仅平均 | +1.1% |
| 双路 | 双路 | +1.8% |
双路池化确实比单路好,但提升幅度不是线性的。如果算力紧张,可以只保留通道双路,空间用平均池化,精度损失约0.7%,但计算量减少约30%。
4.3 降维比例与卷积核大小的调参经验
降维比例我试了4、8、16、32。比例8在精度和参数量之间最平衡。比例4参数量翻倍但精度只提升0.3%;比例16参数量减半但精度下降0.5%;比例32精度下降1.2%,明显欠拟合。
空间卷积核大小试了3、7、11。7最好,3的感受野太小,对中等目标响应不足;11的感受野太大,容易把背景也纳入权重,反而引入噪声。如果任务里目标普遍很小(比如小于32×32像素),3反而更好。
注意:这些超参没有绝对最优值,跟你的数据集、骨干网络、输入分辨率都有关。建议先固定其他参数,只调一个,观察验证集精度的变化趋势,再决定最终值。
5. 踩过的坑与排查过程
5.1 损失不下降:注意力权重全为零
第一次跑的时候,训练了10个epoch,损失几乎不动。我把注意力权重打印出来看,发现Sigmoid输出全是0.5左右,相当于没有学到任何东西。排查后发现两个问题:一是全连接层的初始化用了默认的均匀分布,导致Sigmoid输入太小;二是学习率设成了0.1,对注意力模块来说太大,梯度直接爆炸。
修复方案:全连接层改用Kaiming初始化,学习率降到0.01,并在注意力模块前加了一个LayerNorm。改完后第3个epoch损失就开始明显下降。
5.2 验证集精度震荡:BatchNorm的坑
训练过程中验证集精度上下震荡超过3%,很不稳定。排查后发现是BatchNorm在作怪:注意力模块里的BatchNorm对batch size很敏感,我用的batch size是16,太小,统计量估计不准。改成GroupNorm后震荡消失。
# 把BatchNorm1d换成GroupNorm nn.GroupNorm(num_groups=4, num_channels=channels)GroupNorm对batch size不敏感,小batch下更稳。如果你的batch size大于64,BatchNorm也可以用,但建议把momentum调小到0.01。
5.3 推理速度下降:卷积核的代价
加了注意力模块后,推理速度从每张图12ms涨到18ms,涨了50%。分析后发现空间注意力的7×7卷积是瓶颈。优化方案:把7×7卷积拆成两个3×3卷积串联,感受野不变,但参数量和计算量都下降。
self.conv = nn.Sequential( nn.Conv2d(2, 2, 3, padding=1, bias=False), nn.Conv2d(2, 1, 3, padding=1, bias=False) )改完后推理速度回到14ms,精度只掉了0.2%。这个技巧在部署到边缘设备时特别有用。
5.4 梯度消失:Sigmoid的饱和区
深层网络里,Sigmoid在输入绝对值大于5时梯度接近零。我加了注意力模块后,发现浅层梯度正常,深层梯度几乎为零。解决方案是在Sigmoid前加一个缩放因子,把输入限制在-3到3之间。
self.sigmoid = nn.Sigmoid() # 前向时 out = self.sigmoid(out * 0.5)缩放0.5后,梯度消失问题明显缓解,深层网络也能正常更新。
6. 不同任务上的适配建议
6.1 分类任务:中层插入加并行融合
分类任务对全局语义要求高,通道注意力的作用更明显。建议插在中层,通道和空间并行,降维比例8,空间卷积核7。如果类别数很多(超过100),可以把降维比例调到4,增强通道间的区分能力。
6.2 检测任务:多尺度插入加串联融合
检测任务里目标尺度变化大,建议在FPN的每个尺度上都插注意力模块,但用串联结构,先通道后空间。因为检测对位置精度要求高,串联能让空间注意力在通道筛选后的特征上更精准地定位。降维比例用8,空间卷积核用3,因为检测目标通常不会特别大。
6.3 分割任务:浅层插入加空间优先
分割任务需要保留空间细节,建议在浅层就插入注意力模块,并且空间注意力的权重可以适当放大。融合方式用相加,但空间注意力的输出乘以1.2的系数,让位置信息更突出。降维比例用16,减少通道注意力的干扰。
提示:这些建议是基于我自己的实验经验,不同数据集上可能需要微调。建议先用小规模数据跑一轮消融,确定最优配置后再上全量数据。
7. 一些实用的训练技巧
注意力模块对学习率很敏感。我的经验是:如果是从头训练,学习率用骨干网络的一半;如果是在预训练模型上加,学习率先用0.001,等损失稳定后再逐步升到0.01。用余弦退火调度比阶梯下降更稳,因为注意力权重的变化是连续的,阶梯下降容易在切换点产生震荡。
权重衰减建议设成1e-4,太大容易把注意力权重压到零,太小又容易过拟合。如果发现注意力权重分布过于集中(大部分接近0或1),说明权重衰减太小,可以适当加大。
还有一个技巧:在注意力模块后加一个Dropout,概率0.1,能防止注意力权重过拟合到训练集的特定模式。我试过0.1、0.2、0.3,0.1最好,0.3会欠拟合。
self.dropout = nn.Dropout2d(0.1) # 前向时 out = self.dropout(x * ca + x * sa)Dropout2d对整个通道做drop,比普通Dropout更适合注意力模块,因为注意力权重是空间相关的,逐元素drop会破坏空间一致性。
最后说一个我踩过的坑:不要在注意力模块里用ReLU作为最终激活。ReLU会把负权重截断为零,导致部分通道或位置永远得不到激活。用Sigmoid或Softmax,保证权重在0到1之间连续变化。如果非要用ReLU,至少加一个残差连接,让原始特征能绕过注意力模块。
整个复现过程大概花了我两周时间,从最初的结构设计到最终的稳定版本,中间改了十几版。最大的体会是:注意力模块本身不复杂,但细节决定成败。池化方式、卷积核大小、初始化、归一化层、学习率,每一个都会影响最终效果。建议你在复现时先跑通一个最小版本,确认梯度能正常回传,再逐步加细节。不要一上来就把所有技巧都用上,那样出了问题很难定位。