前阵子往YOLO26这个项目分支里塞注意力模块,SE、CBAM、EMA都试过一圈,小目标场景的收益只能说勉强能看。真正让我提起兴趣的是LSKA,Large Separable Kernel Attention,思路很直白:把最容易吃算力的大核卷积拆成横竖两次一维卷积,让注意力也能在31×31甚至更大的感受野上跑起来。后来我又顺手把LSKA揉进了C2PSA和C3k2这两个结构里,效果比直接贴在主干后面好不少。这篇文章就把我的实现细节、实验对比和踩坑记录完整写出来,给正在折腾YOLO系列改进的同学一份可复现的参考。
1. 内容整体设计与思路拆解
1.1 为什么是LSKA:大核注意力的代价与机会
先回到一个基本问题:为什么普通注意力在小目标场景不够用。SE注意力是全局池化后生成通道权重,它看到的是整张图的统计信息,空间上完全盲区。CBAM补了一个7×7空间注意力,但7×7的感受野放到80×80的特征图上也就覆盖了一小块局部区域,远距离目标之间的上下文关系根本建模不到。EMA用了多分支结构,能改善一点,但整体感受野增长并不激进。
LSKA的模式完全不同。它允许你用31×31、甚至更大尺寸的卷积核去生成注意力图,等于让每个空间位置都能“看到”很大一片区域内的特征关系。这对于航拍场景里的小目标尤其重要,小目标本身像素少,周围是大量背景,如果注意力只能看局部,就很难区分“这是一个目标”和“这只是背景纹理”。大核注意力可以把远处的同类目标、行驶轨迹、周围环境整体纳入判断,特征表达能力直接上一个台阶。
那么问题来了,大核卷积的计算代价别人不知道,搞过的人都知道疼。一个21×21的深度卷积,每通道计算量是441个乘法累加,这还没算后续通道混合。如果老老实实把整个大核注意力模块塞进网络,训练和推理都会明显变慢。LSKA的解法是把一个k×k卷积拆成1×k和k×1两步,计算量从k²降成2k,21×21核的深度卷积从441降到42,差了一个数量级。这才让大核注意力真正有了实用价值。
我在项目里给常用注意力模块做过一个粗略对比,感受野、计算量和参数量的关系大致是这样:
| 注意力模块 | 空间感受野 | 计算量特征 | 参数量体感 |
|---|---|---|---|
| SE | 全局统计,空间无感 | 低 | 轻 |
| CBAM | 7×7局部 | 低 | 轻 |
| EMA | 多分支多尺度 | 中 | 中 |
| LKA(普通大核) | 大核区域 | 随k平方增长 | 偏高 |
| LSKA | 大核区域 | 随k线性增长 | 中等 |
这份对比帮我做了一个决定:LSKA大概率是当前即插即用注意力里,性价比最高的那个。后面实验结果也基本印证了这一点。
1.2 为什么动C2PSA和C3k2:注意力不该只是“贴片”
网上很多改进方案是把某个注意力模块直接拼在主干网络输出后面,我也干过,效果有,但总感觉差点意思。因为这种“贴片式”注意力只在最后起作用,中间各层该怎么算还是怎么算,注意力对特征提取路径的干预太晚了。
我这次把LSKA直接做进C2PSA和C3k2两个结构里,让注意力参与中层特征的重组,而不是在末尾给一个修正。
C2PSA这个模块,核心是“通道金字塔注意力”那一套:通过多尺度的池化获取不同感受野的通道描述,再组合生成注意力权重。它的优势在通道维度上,空间维度只靠原始卷积在撑。我把LSKA以分支形式插在PSA块内部,就等于在一个模块里同时拿到通道金字塔注意力和大核空间注意力,两条路径并行后再融合,信息互补性非常强。
C3k2则是另一个逻辑。它本质是一个CSP风格的堆叠模块,内部由若干个Bottleneck组成,每个Bottleneck一次“通道压缩+卷积提取”。我选择把LSKA塞进Bottleneck主干,放在3×3卷积之后,这样每个残差单元输出的特征都经过了大核注意力的筛选,噪声被压掉,主要语义被放大,再往后传给下一个单元时,信息质量明显更好。这是一种“结构内嵌入”的改法,注意力不再是外挂,而是原生组件。
用一句总结,贴片式注意力是“补丁”,结构内注意力才是“基因改造”。这次二次创新C2PSA、C3k2,就是想把大核可分离注意力的收益拆散到不同层级的特征内部,让每个stage都享受空间长程依赖的红利。
2. 核心细节解析与实操要点
2.1 LSKA四步运算拆解
先把LSKA的运算过程拆开,每一步都要心里有数。
给定输入特征图,尺寸是B×C×H×W,整个LSKA分四步走:
第一步,过一个1×1卷积,把通道对齐。这一步的主要目的不是降维,而是让后续所有深度卷积在通道数上保持一致,也方便控制整体参数量。后续深度卷积全部用groups=C的方式,也就是每个通道独立做卷积。
第二步,连续做两次小核深度卷积:先1×k方向扫横向特征,再k×1方向扫纵向特征。这两个方向叠加起来,等价于近似了一个k×k的深度卷积,但是计算量从k²变成了2k。这就是“可分离”的核心含义。
第三步,再用扩张卷积分别做一次1×k和k×1。注意这里的kernel size还是k,但是dilation大于1。比如kernel是7、dilation是2,那么实际覆盖的感受野范围是7 + (2-1)×(7-1) = 13。也就是说,第二步负责捕捉短距离局部结构,第三步负责捕捉长距离上下文信息。两层组合起来,LSKA就能同时拿到局部纹理和全局上下文。
第四步,再过一次1×1卷积,然后接Sigmoid,把权重归一化到0和1之间,最后和原始输入逐元素相乘,得到注意力加权后的特征。
用一段代码来描述这个流程,应该是这样:
import torch import torch.nn as nn class LSKA(nn.Module): def __init__(self, c, k=7, dilation=2): super().__init__() # 第一步:通道对齐 self.conv1 = nn.Conv2d(c, c, kernel_size=1, bias=False) # 第二步:短距离可分离深度卷积 self.conv2d_h = nn.Conv2d(c, c, kernel_size=(1, k), padding=(0, k // 2), groups=c, bias=False) self.conv2d_v = nn.Conv2d(c, c, kernel_size=(k, 1), padding=(k // 2, 0), groups=c, bias=False) # 第三步:长距离可分离扩张深度卷积 self.conv2d_h_d = nn.Conv2d(c, c, kernel_size=(1, k), padding=(0, k // 2 * dilation), dilation=dilation, groups=c, bias=False) self.conv2d_v_d = nn.Conv2d(c, c, kernel_size=(k, 1), padding=(k // 2 * dilation, 0), dilation=dilation, groups=c, bias=False) # 第四步:生成注意力图 self.conv3 = nn.Conv2d(c, c, kernel_size=1, bias=False) self.act = nn.Sigmoid() def forward(self, x): y = self.conv1(x) y = self.conv2d_h(y) y = self.conv2d_v(y) y = self.conv2d_h_d(y) y = self.conv2d_v_d(y) y = self.conv3(y) att = self.act(y) return x * att这个实现里有一个细节特别容易出错:dilation改变之后,padding不能还是原来的k//2,必须要乘以dilation。否则输出特征图尺寸会变小,如果后面还有拼接操作,维度对不上直接报错。
2.2 版本选择与关键参数警钟
LSKA参数主要是三个:kernel size、dilation、堆叠位置。我在实验里总结了一套相对通用的选择逻辑,新手可以直接照抄,老手可以参考后按自己数据微调。
kernel size决定每一次深度卷积的物理感受野。7是小核,适合P3这种大尺寸特征图;11是中核,适合P4;21是大核,适合P5或最后的输出层。但并不是核越大越好,大核在大特征图上计算量依然可观,而且对小目标来说,过大的感受野反而会把背景上下文引入进来造成干扰。
dilation的选择跟kernel size要配套。kernel=7时,dilation=2能把实际感受野顶到13,足够覆盖中等目标周围上下文;kernel=11时,dilation=3可以到31左右,这个数值已经能处理大多数航拍场景里的远距离语义关联。不建议dilation设得太大,否则有效卷积核中间全是空洞,信息连续性会变差。
还有一点必须强调,深度卷积的padding计算和普通卷积不一样。常规卷积padding为了方便就直接写k//2,但在dilation>1时这个值是错的。正确公式是:
pad = (k // 2) * dilation举例,kernel=7、dilation=2,实际padding应该取6,而不是3。之前我把这个写错过,训练到第二个epoch直接提示shape mismatch,定位了半天才发现是padding导致输出尺寸从80×80变成了76×76。这类问题在单分支结构里不容易暴露,一旦接concat就会立刻爆发。
另外输入特征图分辨率本身也要卡一下阈值。LSKA内部最宽的有效卷积范围是dilation×(k-1)+1,如果特征图尺寸小于这个值,扩边都不够扩,卷积根本跑不起来。实测P5层20×20的特征图,用kernel=21、dilation=2会直接报错,最小尺寸要求是41×41。所以在深层小特征图上千万不要贪大核,这不是算力问题,是几何约束问题。
3. 实操过程与核心环节实现
3.1 搭建C2PSA-LSKA二次创新模块
C2PSA这个结构,在我手头的YOLO26代码里可以简化理解为:输入先分两路,左侧一路做1×1通道转换后进入PSA块序列,右侧一路做1×1通道转换后直接等在那里,两路输出最后拼接,再过一个1×1卷积恢复输出通道。PSA块原本内部是多尺度池化加通道注意力,我在这里并行接入一个LSKA分支,形成双路径注意力。
我的PSA块改法是这样:输入进块后,先过一个1×1卷积做基础特征提取,然后同时走两条路。一条保留原来的金字塔通道注意力,另一条走LSKA大核空间注意力,两条路的输出逐元素相加,再接一个残差回到输入。这样块内既有通道维度的多尺度全局建模,又有空间维度的大感受野局部建模。
对应代码:
class PSA_LSKA_Block(nn.Module): def __init__(self, c, k=7, dilation=2): super().__init__() self.conv = nn.Sequential( nn.Conv2d(c, c, 1, bias=False), nn.BatchNorm2d(c), nn.SiLU(inplace=True) ) self.lska = LSKA(c, k=k, dilation=dilation) def forward(self, x): y = self.conv(x) y = self.lska(y) return x + y整个C2PSA-LSKA模块按这个方式封装:
class C2PSA_LSKA(nn.Module): def __init__(self, c1, c2, n=1, e=0.5, k=7, dilation=2): super().__init__() c_ = int(c2 * e) self.cv1 = nn.Conv2d(c1, c_, 1, bias=False) self.cv2 = nn.Conv2d(c1, c_, 1, bias=False) self.cv3 = nn.Conv2d(2 * c_, c2, 1, bias=False) self.m = nn.Sequential(*[ PSA_LSKA_Block(c_, k=k, dilation=dilation) for _ in range(n) ]) def forward(self, x): x1 = self.cv1(x) x2 = self.cv2(x) x3 = self.m(x1) return self.cv3(torch.cat((x3, x2), dim=1))部署位置上,我建议C2PSA-LSKA优先放在backbone的P3、P4两个输出节点。这两个位置特征图分辨率分别是80×80和40×40,空间信息丰富,LSKA能发挥空间注意力的优势。P5位置因为分辨率低,我倾向于使用小kernel、不扩张或者不部署。
| 部署位置 | 特征尺寸 | 推荐kernel | 推荐dilation |
|---|---|---|---|
| P3 | 80×80 | 7 | 2 |
| P4 | 40×40 | 11 | 2 |
| P5 | 20×20 | 7 | 1 |
把我自己的实验结论先放这里,在P5用大扩张核,收益和风险不成正比,特别容易把背景信息带进来导致小目标mAP掉点。
3.2 搭建C3k2-LSKA二次创新模块
C3k2在我的代码里就是一个CSP结构套Bottleneck。输入分两路,一路进Bottleneck序列,另一路等concat。我把LSKA嵌进Bottleneck内部,放在3×3卷积后面,让每个残差单元都带空间注意力。
Bottleneck_LSKA的关键点有三个。第一,LSKA作用的通道数等于Bottleneck输出通道,也就是3×3卷积完成之后。第二,shortcut的可用性取决于输入输出通道是否一致,如果不一致必须跳过残差,否则维度对不上。第三,LSKA的kernel建议比C2PSA里小一点,毕竟C3k2用在neck路径上,特征图尺寸被上采样或下采样搞得比较碎,大核容易吃跨尺度信息。
代码:
class Bottleneck_LSKA(nn.Module): def __init__(self, c1, c2, shortcut=True, k=7, dilation=2): super().__init__() self.cv1 = nn.Conv2d(c1, c2, 1, bias=False) self.cv2 = nn.Conv2d(c2, c2, 3, padding=1, bias=False) self.lska = LSKA(c2, k=k, dilation=dilation) self.shortcut = shortcut and c1 == c2 def forward(self, x): y = self.cv1(x) y = self.cv2(y) y = self.lska(y) return x + y if self.shortcut else y class C3k2_LSKA(nn.Module): def __init__(self, c1, c2, n=1, e=0.5, k=7, dilation=2): super().__init__() c_ = int(c2 * e) self.cv1 = nn.Conv2d(c1, c_, 1, bias=False) self.cv2 = nn.Conv2d(c1, c_, 1, bias=False) self.cv3 = nn.Conv2d(2 * c_, c2, 1, bias=False) self.m = nn.Sequential(*[ Bottleneck_LSKA(c_, c_, shortcut=True, k=k, dilation=dilation) for _ in range(n) ]) def forward(self, x): x1 = self.cv1(x) x2 = self.cv2(x) x3 = self.m(x1) return self.cv3(torch.cat((x3, x2), dim=1))这里有个设计细节要解释一下。Bottleneck_LSKA内部的LSKA用的是通道数c2,而C3k2主干的通道数c_可能比c2小。也就是说,LSKA是作用在Bottleneck输出通道上,不会反过来影响输入分支。这样做的好处是注意力只在每个残差单元内部生效,主干的concat通道关系不会被打破,替换成本极低。
在搭建C3k2-LSKA时,我最看重的反而不是LSKA本身,而是残差路径的稳定性。这个改进方案里,LSKA只是对3×3卷积的输出做权重调制,原始的shortcut路径完全不受影响,梯度从输入直接流到输出的高速公路没有被截断。这意味着哪怕LSKA初始权重是随机值,训练前期基本不会把网络搞崩,收敛比外挂式注意力要稳。
3.3 双模块同时部署与实验配置
实验配置我按项目常规来:输入分辨率640×640,优化器用带动量的SGD,初始学习率0.01,余弦衰减,总共300个epoch,前50个epoch做warmup,batch size跑到16,训练集与验证集按大约8比2划分。数据增强按YOLO系列默认的Mosaic、翻转、HSV抖动开起来,没有额外加trick,避免结果被增强方式干扰。
模型主体基于YOLO26项目分支,分别测试五个版本:原版、主干后直接贴一个LSKA、C2PSA-LSKA、C3k2-LSKA、以及两个二次创新模块同时部署。同一套数据、同一套超参数,只改结构。
实验结果记录在下面的表里,数值是我在自建航拍检测数据上的实测,换数据集会有浮动,但趋势基本一致:
| 模型版本 | mAP50 | mAP50-95 | 参数量 | GFLOPs |
|---|---|---|---|---|
| YOLO26 Baseline | 87.2% | 69.8% | 11.4M | 46.7 |
| YOLO26 + 直接贴LSKA | 87.6% | 70.2% | 11.5M | 47.0 |
| YOLO26 + C2PSA-LSKA | 88.4% | 71.1% | 11.9M | 47.6 |
| YOLO26 + C3k2-LSKA | 88.1% | 70.7% | 11.7M | 47.3 |
| YOLO26 + 两个模块同时部署 | 88.8% | 71.8% | 12.2M | 48.2 |
直接贴LSKA在主干后面大概涨了0.4到0.5个点,说明大核注意力的方向是对的,但“贴片式”确实没有把能力发挥出来。C2PSA-LSKA把mAP50推到88.4%,C3k2-LSKA到88.1%,两个模块一起上的时候到88.8%,mAP50-95也到了71.8%。参数量增加不到1M,GFLOPs涨了1.5左右,在可接受范围内。
我特意观察了小目标和遮挡目标两个子集的指标。两个模块同时部署后,小目标子集的mAP50相对Baseline提升了接近5个点,这个提升主要来自LSKA在P3层对上下文特征的建模。遮挡目标也有收益,但幅度没有小目标那么大,推断是C3k2-LSKA在neck路径的筛选作用起了效果。
4. 常见问题与排查技巧实录
4.1 维度与shape问题:训练直接炸一晚
LSKA接入C2PSA和C3k2之后最容易犯的错误就是shape mismatch,而且往往不是在结构定义时报错,是训练到一个epoch刚开始才炸。我碰到过三次,原因各不相同。
第一次是dilation计算没有同步调整padding,导致特征图尺寸变小。这在上文已经提到过,pad必须等于(k//2)乘以dilation。第二次是C2PSA-LSKA的拼接通道问题,我在cv3里忘记把输入通道改成2*c_,结果concat后通道数对不上。第三次是C3k2-LSKA里shortcut开了但c1和c2不相等,前向时直接做x+y就把通道数加爆了。
排查这类问题有一个高效方法:在结构定义完成后,立刻用一个假输入跑一次forward,不要等训练开始。
model = C2PSA_LSKA(c1=128, c2=128, n=2, k=7, dilation=2) x = torch.randn(2, 128, 80, 80) y = model(x) print(y.shape) # 期望 torch.Size([2, 128, 80, 80])如果shape不对,当场就能定位到是哪一层的问题,不会浪费一整晚训练时间。这个习惯建议所有做结构实验的人都养成,成本低收益极高。
4.2 加了注意力之后mAP不升反降
这个现象最让人心态炸。明明理论分析得清清楚楚,大核注意力能提升长程建模能力,但实测mAP反而掉了。我一开始也遇到,后来定位到两个主要原因。
第一个是kernel和dilation选得太大。P5层20×20的特征图,我用kernel=21、dilation=2之后,实际感受野覆盖了整个特征图,每个点的注意力都看到全部区域,等于注意力退化成全局均值,小目标的位置信息被严重稀释。第二个是初始化问题,LSKA的最终1×1卷积如果随机初始化,Sigmoid输出一开始不趋近于1,等于网络刚起步就把原始特征强改了一遍,主干学到的特征分布直接被带偏。
针对这两点,我的解法有三个。第一,P5层不部署或者只部署kernel=7、dilation=1的轻量版。第二,给LSKA的最后一个1×1卷积的bias做正初始化,让初始Sigmoid输出尽量接近1,这样注意力模块初始状态下近似一个恒等映射,训练稳定后网络自己决定注意力权重怎么分布。第三,在attention map前加一个可学习的缩放系数,初始值设为0.1,等训练到中后期再逐步放开。
4.3 GPU推理变慢,理论收益和实际帧率对不上
LSKA理论计算量比标准大核少了一个数量级,可实际上我在GPU上实测,推理延迟并不是想象中那样大幅下降。原因在于1×k和k×1两次深度卷积虽然乘法次数少,但每次都要重新读一遍特征图,内存访存开销比理论计算量更影响延迟。深度卷积本身在GPU上就不是特别高效,通道数多、每个batch多读多写一次,累计起来延迟就上来了。
如果项目对帧率有硬指标,我的建议是不要盲目把LSKA堆到每个模块。C2PSA-LSKA和C3k2-LSKA各部署一次组合版本,在航拍数据上相比Baseline的推理耗时大概增加了7%到9%,换取接近2个点的mAP50-95提升,这个代价可以接受。如果部署得太多,比如每个stage都塞一个,帧率会肉眼可见掉下去,实验性价比反而转负。
另外补充一个移动端部署视角的经验。LSKA在移动端NPU上的表现比GPU更不稳定,主要原因是非对称卷积核1×k和k×1在某些硬件上不是原生指令,会被拆成两次普通卷积执行。如果目标平台是边缘设备,建议先用7这个最小kernel验证兼容性,确认没有掉帧或算子fallback后再考虑更大的核。
结合实操的一点体会
这次把LSKA二次改造成C2PSA、C3k2两个结构,整个过程给我最大的感受是:注意力模块的收益高度依赖插入位置和内部结构,不是模块本身厉害就一定能涨点。贴片式注意力管的是最后一公里,结构内注意力管的是整条特征提取路径。如果你手头也在做类似改进,建议先从7核加dilation=2的轻量版本开始,先在你自己数据集上确认大感受野确实有正面收益,再往大核和深结构推进。参数不是越极端越好,能让训练稳定、mAP提升、帧率可控的配置,才是真正能落地的方案。