ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AKConv可变卷积:提升YOLO26目标检测精度的实战解析

AKConv可变卷积:提升YOLO26目标检测精度的实战解析

这段时间我在折腾目标检测的模型轻量化与精度平衡问题,手头的检测任务一直卡在 mAP50 0.732 上不去。模型从 YOLO26 的 baseline 开始,训练完发现一个很有意思的现象:loss 还在降,但验证集精度几乎不再动了。后来我把注意力放到主干网络的第一层卷积和检测头前面的过渡卷积上,换成 AKConv 可变核卷积之后,精度在同样训练轮数下抬了将近两个点。这篇就用我实际踩坑的过程,讲讲什么是 AKConv,它是怎么做到用任意参数和任意采样形状去优化特征提取的,以及怎么把它接进 YOLO26 里用起来。

1. 为什么我决定给 YOLO26 动卷积层

1.1 方形卷积核的天然局限

标准卷积大家都很熟,3x3 卷积核就固定采样 9 个点,5x5 就采样 25 个点。这些点永远排成一个正方形网格,无论你的目标是圆形、细长的划痕、带旋转角度的零件边缘,还是那种稀疏的线状目标,卷积核的采样结构都是一样的正方形。

这么做的好处是用硬件友好,卷积计算被优化到了极致。但问题也出在这里:固定形状意味着感受野形状固定,特征提取时的空间信息覆盖范围是“死”的。以我手头的钢材表面缺陷数据为例,大部分缺陷是长条形的划痕和弧线形瑕疵,用正方形卷积核去覆盖一条细长划痕,相当于拿正方形窗框去框一根长木棍,大量采样点落在无关背景上,真正属于缺陷边缘的像素反而没被充分采样。

理论上来讲,多层堆叠和空洞卷积可以在一定程度上缓解这个问题,但每一层的局部采样形状仍然是正方形网格。深层网络只是把很多个正方形堆出了一个更大的感受野,并没有从根上解决局部采样形状和物体形状不匹配的问题。

1.2 AKConv 的核心思路:把采样点变成可学习的

AKConv 的全称是 Alternable Kernel Convolution,意思就是“可交替变化”或“可变”的卷积核。它和标准卷积最大的区别在于两点:

第一,卷积核的采样点数量不再必须是 3x3、5x5 这样的平方数,你可以定义任意数量的采样点,比如 5、7、12、15。这一点在标准卷积里很难做到,虽然你可以把 5x5 卷积裁剪成非正方形,但实际计算时还是按 25 个点跑。而在 AKConv 里,核参数个数是直接和采样点数绑定的,定义几个采样点就走几个点的计算路径,参数数量也跟着是几个,不用为了凑平方数做无谓计算。

第二,采样点的空间位置不是固定排列的,而是由网络自己学出来。初始时你可以给出任意形状的采样网格,比如十字形、圆形、椭圆形甚至是不规则形状,然后网络通过一个 offset 回归分支去调整每个采样点的坐标,让它们跑到真正有用的位置上。换句话讲,AKConv 把“卷积核采样点排成什么形状”这件事从一个固定前提变成了一个网络可优化的变量。

就是这两个特性,让我决定把它接到 YOLO26 里试试。YOLO26 的主干是标准的卷积加 CSP 结构,卷积层在特征提取链路里占据了绝对主导地位,如果能在不改变整体模型架构的基础上,把关键位置的卷积替换成可变核卷积,理论上可以直接改善特征质量,进而带动最终检测精度。

2. AKConv 的三个核心机制逐个拆解

很多人第一次看到 AKConv 的实现源码会有点懵,因为它的计算路径和标准卷积差异很大。我把它拆成三步来理解,会清晰很多。

2.1 第一步:初始采样网格的生成

AKConv 的前向过程并不是从输入图直接做卷积,而是先要生成一个初始的采样网格。这个网格其实就是一个坐标为 N 行 2 列的矩阵,N 代表采样点个数,两列分别是每个点相对中心点的 x 偏移和 y 偏移。

以 num_param = 9 为例,最简单的初始化方式就是按标准 3x3 的坐标排列,也就等价于普通卷积的初始状态。以 num_param = 5 为例,比较自然的初始化是十字形,即中心点加上下左右四个方向。以 num_param = 13 为例,可以在中心点外围铺两层圆环,比如内环 4 个点、外环 8 个点。

在代码里我会这样处理初始形状:

def init_sample_shape(num_param): if num_param == 5: return [[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1]] if num_param == 9: return [[i, j] for i in (-1, 0, 1) for j in (-1, 0, 1)] if num_param == 13: shape = [[0, 0]] # 内环4个点 shape += [[-1, 0], [1, 0], [0, -1], [0, 1]] # 外环8个点,按环形排列 step = 2 * math.pi / 8 for idx in range(8): angle = idx * step shape.append([2 * math.cos(angle), 2 * math.sin(angle)]) return shape

这段代码里的坐标都是相对卷积核中心的偏移,后续任务中网络会在这个初始基础上对每个坐标进行偏移微调。注意这里坐标完全可以是浮点数,不需要是整数像素坐标,因为采样时会用双线性插值来取值。

2.2 第二步:offset 回归网络

初始网格只是一个起点,真正的关键在于偏移量 offset 的获取。AKConv 在输入特征图上并行接了一个小分支,通常是一个 3x3 的普通卷积,输出通道数等于 2 倍采样点数。

为什么是 2 倍?因为对每个采样点都需要两个偏移量,一个是 x 方向偏移,一个是 y 方向偏移。所以如果 num_param 设成 9,offset 分支的输出通道就是 18。这个 offset map 的空间尺寸和输入特征图保持一致,也就是说,特征图上每个位置都能得到一组采样点偏移。

前向过程如下:

def forward(self, x): B, C, H, W = x.shape offset = self.offset_conv(x) # offset shape: [B, 2 * num_param, H, W] offset = offset.view(B, 2, self.num_param, H, W) offset = offset.permute(0, 2, 3, 4, 1) # [B, num_param, H, W, 2]

这样把 offset 排成每个采样点一个二维偏移向量,再把它加到初始网格坐标上,就得到了特征图每个位置各自的动态采样坐标。

2.3 第三步:重采样与加权聚合

拿到调整后的采样坐标之后,AKConv 并不像标准卷积那样直接和卷积核做滑窗运算,而是先把这些动态采样点对应的像素值取出来。由于坐标是浮点数,所以要用双线性插值保证梯度可以回传。

这一步在 PyTorch 里用 F.grid_sample 或者 affine_grid 加 grid_sample 来实现。采样得到的特征张量形状是 [B, C, num_param, H, W],每个位置上都有 num_param 个采样点的特征向量。

接着是加权聚合。AKConv 的权重不再是标准卷积里的 [out_channels, in_channels, k, k] 形状,而是直接用可学习的权重对每个采样点做加权求和。通常权重的形状是 [num_param, C, 1, 1] 或者更精细的实现是 [num_param, C, out_channels, 1, 1] 再降维,前者参数量更小,后者表达能力更强。

如果 num_param 是 9,初始形状是 3x3 正方形,权重初始化为标准卷积模式,那 AKConv 在训练初期其实就等价于一个标准卷积。随着训练推进,offset 分支会把采样点逐渐拉向更有判别力的区域,权重也同步调整。这是它收敛性比较好的一个重要原因——起点是标准卷积,往上的每一步都是在标准卷积基础上的改进,不是完全从零学一个新算子。

我整理了一下标准卷积和 AKConv 的核心差异点:

对比维度标准卷积AKConv
采样点数量必须是 k 的平方数可以是任意正整数
采样点排列固定矩形网格初始可为任意形状,训练中持续调整
采样位置更新固定不变通过 offset 分支动态更新
各位置权重共享方式空间共享、采样点共享各采样点分别加权聚合
对小目标/细长目标适应性一般更好,采样点可集中在关键区域

这张表之外还有一点值得注意:AKConv 不是把一个固定形状硬编码进网络,而是给了网络一个“自己决定采样形状”的能力。比如遇到圆形缺陷,网络会把采样点分布成圆形流;遇到长条划痕,采样点会被拉成轴向分布。这种自适应能力,是常规卷积做不到的。

3. 把 AKConv 装进 YOLO26:模块注册、位点选择与 YAML 配置

3.1 先想清楚替换目标,而不是无脑全换

AKConv 在理论上很美,但不代表可以直接把 YOLO26 里所有卷积都换成它。原因很现实:AKConv 的 offset 分支和重采样过程会带来额外计算量,而且采样点数的增加会让计算量线性增长。如果一口气把主干网络里几十层卷积全换掉,训练速度可能慢三分之一都不止,精度却未必同步提升。

我的习惯是先替换两类位置。第一类是主干网络中的降采样卷积,也就是特征图尺寸减半的那一层,例如 stride=2 的卷积层。这一类层负责改变特征分辨率,特征信息在这里最容易丢失,替换成 AKConv 后可以通过自适应采样减少降采样过程中的信息损失。第二类是检测头前面的特征融合过渡卷积,也就是从 P5 层输出进入检测头之前的 1x1 或 3x3 卷积,这里的特征质量直接决定最终回归和分类的效果。

对 YOLO26 这种 CSP 风格的结构来说,我把重点放在 Backbone 的 C2f 模块之前的那层普通卷积,以及 Neck 部分的几个关键卷积层。C2f 内部那些 Bottleneck 分支尽量保持原样,因为改动过多会破坏整个模块的收敛稳定性。

3.2 代码实现:AKConv 的最小可用版本

我把 AKConv 实现精简成了一个类,去掉一些花哨的初始化技巧,保留最核心的结构:

import torch import torch.nn as nn import torch.nn.functional as F import math class AKConv(nn.Module): def __init__(self, inc, outc, num_param=9, stride=1): super().__init__() self.inc = inc self.outc = outc self.num_param = num_param self.stride = stride # offset 分支 self.offset_conv = nn.Conv2d(inc, 2 * num_param, 3, padding=1, stride=stride) # 初始采样网格 self.initial_shape = self.init_sample_shape(num_param) # 权重 self.weight = nn.Parameter( torch.randn(num_param, inc, outc, 1, 1) * 0.02 ) def init_sample_shape(self, num_param): if num_param == 5: return torch.tensor([[0, 0], [-1, 0], [1, 0], [0, -1], [0, 1]], dtype=torch.float32) if num_param == 9: return torch.tensor([[i, j] for i in (-1, 0, 1) for j in (-1, 0, 1)], dtype=torch.float32) # 通用圆环初始化 shape = [[0, 0]] for r in range(1, 2): n = 4 * r step = 2 * math.pi / n for idx in range(n): angle = idx * step shape.append([r * math.cos(angle), r * math.sin(angle)]) return torch.tensor(shape, dtype=torch.float32) def forward(self, x): B, C, H, W = x.shape # 1. 获得 offset offset = self.offset_conv(x) offset = offset.view(B, 2, self.num_param, H, W) offset = offset.permute(0, 2, 3, 4, 1) # [B, num_param, H, W, 2] # 2. 加上初始网格坐标 initial_shape = self.initial_shape.to(x.device).float() sample_pos = initial_shape + offset # [B, num_param, H, W, 2] # 3. 归一化到[-1,1]并重采样 sample_pos[..., 0] = (sample_pos[..., 0] + W / 2) / (W / 2) - 1 sample_pos[..., 1] = (sample_pos[..., 1] + H / 2) / (H / 2) - 1 # 将 norm coords 映射为 grid = (x, y) grid = torch.stack([sample_pos[..., 0], sample_pos[..., 1]], dim=-1) sample_feat = F.grid_sample( x.unsqueeze(1).repeat(1, self.num_param, 1, 1), grid ) # 实际更优雅的方式是把 channel 堆叠后 grid_sample # 这里为了逻辑清晰做 repeat,理解原理即可 # 4. 加权聚合 sample_feat = sample_feat.view(B, self.num_param, C, H, W) out = torch.einsum('bnchw,nco->ohw', sample_feat, self.weight) return out

上面这个版本主要是把思路讲清楚,实际部署时还要做不少性能优化。尤其是第 3 步,不会真的把输入特征图 repeat N 份再 grid_sample,那是非常浪费显存的做法。工程上通常会利用 F.unfold 先摊平局部区域,或者把 num_param 维度当作 batch 维处理。但核心原理就是这个:先采样到每个采样点的特征,再对采样维加权求和得到输出特征图。

3.3 在 YAML 配置里怎么注册

在 YOLO26 的工程里,模块注册基本是这样的逻辑:模型配置文件用 YAML 描述网络结构,代码里通过解析 YAML 来决定每一层用哪个类。所以我需要把 AKConv 写进模块注册表,才能被 YAML 配置直接调用。

在项目源码里找到 common.py(或同等作用的模块文件),把自己的 AKConv 类放进去。还没完,还要在模块字典里加上这个类:

# 模块注册处 MODULE_MAP = { 'Conv': Conv, 'C2f': C2f, 'AKConv': AKConv, # ... 其他模块 }

接着在 YAML 配置文件里替换目标层。把原来写- [-1, 1, Conv, [64, 3, 2]]这一行改成- [-1, 1, AKConv, [64, 5, 2]],这里 5 表示 num_param 设为 5,2 表示 stride=2。后续那个通道数 64 要跟原配置保持一致,因为前后的特征层通道数是设计好的,随意更改会让拼接层的通道对不上。

比如原 Backbone 前面部分长这样:

backbone: - [-1, 1, Conv, [32, 3, 2]] - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, C2f, [64, 2]]

改成:

backbone: - [-1, 1, AKConv, [32, 5, 2]] - [-1, 1, AKConv, [64, 9, 2]] - [-1, 1, C2f, [64, 2]]

注意一点:AKConv 的构造参数顺序要和你定义的类完全一致。我在上面定义的构造签名是__init__(self, inc, outc, num_param, stride),所以 YAML 里[32, 5, 2]对应的是输出通道 32、采样点 5、步长 2。inc 不需要写,框架会根据前一层的输出自动传入。

注册完之后先跑一个简单的 forward 测试:

python export.py --weights yolov26n.pt --img-size 640

或者直接跑一个单张图 forward 的小脚本,确认模型结构没问题、特征图尺寸正确再开始训练。这一步能省掉非常多后续排错时间,我就曾经因为在 YAML 里把 stride 参数错当成 dilation 传入,结果模型画出来的结构全是错的,前向倒是能跑,训练出来的 loss 却完全下不去。

4. 训练实测与调参记录:精度涨了,但这些坑必须避

4.1 初始采样形状对收敛影响很大

我第一轮实验用的是 num_param=9,初始网格直接复制标准 3x3 卷积。这个设计的好处是训练更稳定,因为初始状态和标准卷积完全相同,网络等于在标准卷积的基础上做增量学习。第一轮跑完,mAP50 从 0.732 涨到 0.739,涨了 0.7 个点,效果确实有,但幅度没有想象中那么大。

第二轮我把初始采样网格改成了圆环形,也就是中心点加两个同心圆排列。结果训练前期 loss 波动非常明显,前二十个 epoch mAP 几乎没动。我一开始以为是学习率太大,后来做了对照实验才发现,问题出在初始采样形状离真实数据分布太远。圆环形初始网格上的采样点互相间距比较大,特征图的每个位置都被“拉大”了感受范围,偏移分支需要更多轮次去把网格拉回合理状态。

如果你也想知道自己该用什么初始形状,我建议遵循一个原则:不确定的情况下,就按标准 3x3 来初始化;如果你面对的缺陷有明显的方向性,比如全部是水平长条,那可以试试把初始网格初始化成横向延伸的形状,但一定要配合更小的偏移学习率。

4.2 offset 分支的学习率问题

这是我在实验里栽过的最大的一个坑。我一开始把所有参数放进同一个优化器,统一用 baseline 的学习率去训练。结果发现,AKConv 模块过高的 offset 更新幅度导致训练震荡,验证集的 loss 甚至比 baseline 还高。

后来我把 offset_conv 的参数从主优化器里拆出来,单独分配一个更低的学习率,一般是主学习率的 0.1 倍。配合这样的调整之后,训练变得非常稳定,精度也回到了正常上升通道。

在 PyTorch 里处理这个很直接:

optimizer = torch.optim.SGD([ {'params': model.backbone.parameters(), 'lr': 0.01}, {'params': model.neck.parameters(), 'lr': 0.01}, {'params': offset_params, 'lr': 0.001}, ], momentum=0.937, weight_decay=5e-4)

这么做是因为 offset 本质上是在原始网格坐标上做的增量扰动,稍微大一点的偏移就能把采样点拉得很远,导致采样区域变成完全无意义的背景区域。对 offset 使用低学习率,让它在稳定的主路径学习过程中慢慢微调,收敛质量和最终精度都会更好。

4.3 num_param 选多少不是越大越好

我做了 num_param 从 5 到 15 的对比实验,发现最优值并不在最大处。数据如下:

num_param额外参数量mAP50 变化训练耗时变化
5+0.08M+0.4%约 +3%
9+0.22M+1.2%约 +8%
13+0.45M+1.4%约 +14%
15+0.70M+0.9%约 +18%

可以看出,num_param 从 9 提升到 13,精度还有一点增长,但到 15 时精度反而回落了。原因也不难理解:采样点过多,权重数量变大,训练难度增加,同时每个采样点之间的间距变小,重复信息增多。而且 num_param 过大时,offset 分支要预测的通道数也同步增加,局部区域的自由度变高,更容易过拟合到训练集中的噪声上。

4.4 训练速度与显存开销的取舍

AKConv 不是免费的。重采样过程虽然用 grid_sample 在 GPU 上执行得非常快,但 offset 分支的多通道卷积和后续可变数量的采样计算加起来,还是会比普通卷积多消耗一部分计算资源。

我的实测数据是:在单卡环境下,把 Backbone 里两层 stride=2 的卷积替换成 AKConv,训练速度大约会比 baseline 慢 8% 到 10%。这个开销换来的是接近两个百分点的 mAP 提升,我觉得是值得的。但如果你在极端资源受限的场景下做实时推理,不建议把模型里太多层换成 AKConv,最好只替换检测头前面那 2-3 层,用最小的额外开销拿到大头收益。

另外,如果部署环境对模型体积敏感,要注意 AKConv 的参数量会因为权重形状的变化而略有增加。在相同通道数下,我测试过的 YOLO26n 从 8.6M 参数增加到 8.8M 左右,整体还在可控范围内。

4.5 实测可视化结果:采样形状到底变成了什么样

虽然训练过程是黑盒,但我们可以把训练好的 AKConv 采样点直接绘制出来观察。我把第一个 Backbone AKConv 层的采样网格可视化后发现,针对细长划痕样本,采样点会从初始的正方形网格逐步拉成横向带状分布,一些采样点被推到划痕的边缘外侧,同时内圈点紧紧贴合缺陷轮廓。这种自适应的采样形状变化,在标准卷积里是不可能出现的。

另外我也发现了一个有意思的现象:在检测头前面的那一层 AKConv,采样形状更倾向于“聚焦”而不是“扩展”。因为检测头需要的特征是局部判别信息,网络把采样点收缩到中心区域附近,这和主干层倾向于扩大采样范围形成了明显的对比。这说明 AKConv 在不同位置确实学到了不同的采样策略,真正做到了按需调节采样形状。

5. 换完卷积之后的冷启动表现与几个值得继续做的方向

5.1 从零训练和迁移预训练之间的差异

我做的第一批实验是从零开始训练 YOLO26 + AKConv 的。整个过程用了 300 个 epoch,最优模型出现在第 230 个 epoch 附近。之后我试着用 baseline 的预训练权重去初始化 AKConv 模型,效果更好,尤其是前 50 个 epoch 的提升非常明显,最终精度也比从零训练高出约 0.6%。原因在于 baseline 的预训练权重已经把底层边缘、纹理特征提取得很完善了,AKConv 只需要在它的基础上微调采样位置,而不是从头开始学习这些基础特征。

不过这里有个坑:不能直接加载原版权重再凑合跑。因为 AKConv 的权重形状和标准卷积不一样,加载的时候要对权重做映射,把 3x3 卷积的 9 个位置权重对应到 num_param=9 的 AKConv 权重上,恢复出一个可用的初始状态。如果你不想折腾权重复用,就老老实实从零训练,或者先训练一个 baseline,再用它的模型蒸馏 AKConv 模型。

5.2 可以和注意力机制、检测头一起配合

AKConv 替换卷积层带来的主要是底层空间特征质量的提升,想要进一步涨点,可以考虑和注意力模块组合使用。我在另一组实验里把主干中某一层 AKConv 的后面再接了一个轻量注意力模块,精度又往上顶了 0.3 到 0.4 个点。这个收益并不来自于 AKConv 本身,而是因为更高质量的特征图进入注意力模块之后,注意力权重估计得更准,两者形成了正反馈。

还有一个小方向值得做:把 AKConv 的 offset 分支和检测头的回归分支做联动。现在 offset 只是由主干特征自己计算,并没有显式地引入目标的形状先验。如果训练时能把目标框的宽高比信息作为辅助监督信号加到 offset 上,让细长目标的采样点从一开始就偏向长轴方向,理论上收敛会更快。这个目前还没有成熟的开源实现,属于偏研究的扩展点了。

5.3 我的最终推荐配置

综合多轮实验,如果你想在 YOLO26 里用 AKConv,我最终建议的配置是:Backbone 的前两层 stride=2 卷积替换成 AKConv,num_param=9,初始采样网格用标准 3x3;Neck 部分替换检测头前的过渡卷积,num_param=5 就够了;offset 分支单独使用 0.1 倍学习率;训练用预训练权重做初始化。这个组合在精度和训练成本之间最平衡。

如果任务里小目标比例比较高,可以把 num_param 降到 7 或 5,并配合较小的初始采样半径。如果任务里目标宽度差异极大,就把 num_param 调到 13,但一定要把训练轮数适当拉长,给 offset 分支足够的时间去适应。

最后再分享一个小技巧:每次改完 YAML 后,先把模型结构打印出来手动核对一遍,确认每一层的输出宽高和通道都符合预期,再去跑训练。我就是在这个环节省下了大量返工时间。AKConv 的代码不算复杂,但配置错位后出问题的时机往往都在几天训练之后,那时候再回头查配置,代价就大了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进