本文基于YOLOv13 官方仓库(iMoonLab/yolov13,ultralytics 8.3.63 fork)实测整理,Windows/CPU 全程可跑。标准卷积的参数量永远和 k² 锁死:3×3 就是 9 个采样点,想改只能换奇数尺寸。AKConv(Convolutional Kernel with Arbitrary Sampled Shapes and Arbitrary Size,Zhang et al., 2023, arXiv:2311.11587)把这件事彻底放开——采样点的初始形状任意、数量 N 任意(5 个、10 个都行),每个点再配一个可学习偏移,用双线性重采样取特征、列卷积聚合。本文把它替换进 v13n 的第 1 层下采样卷积,实测仅+1,706 参数。
前言
卷积层改进的"替换式"玩法(层数不变、无顺移)。AKConv 出自 2023 年论文《AKConv: Convolutional Kernel with Arbitrary Sampled Shapes and Arbitrary Size》(官方源码 github.com/CV-ZhangXin/AKConv,官方类名 LDConv),是"采样几何"路线(DCNv2 → 本篇)里最激进的一个:DCNv2 还要在 k² 网格上变形,AKConv 连"网格"本身都不要了。替换点选在 v13n backbone 的层 1(输入 16 通道 @320×320 → 输出 32 通道 @160×160),与普通 stride-2 卷积完全对齐;与 CoordConv/RFAConv/ODConv 同位,四者构成"第一个下采样卷积怎么改"的完整对照组。
专栏目录:YOLOv13改进目录一览
专栏地址:YOLOv13改进专栏——持续更新各方向即插即用改进
一、替换点分析
backbone: - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 - [-1, 1, Conv, [128, 3, 2, 1, 2]] # 1-P2/4 ← 本文替换这一层 ...层数不变,无顺移。层 1 的输入 16 通道、输出 32 通道、stride 2——AKConv 官方实现原生支持 stride(偏移预测卷积与初始采样网格按步长对齐,本文已对 40×40、s=2 验证输出恰为 20×20),可直接替换下采样卷积。
二、AKConv 原理
- 动机:标准卷积核的采样点数与 k² 绑定,且初始形状固定为正方形网格。目标轮廓各不相同,"9 点方格"未必是最优采样拓扑;同时参数量随 k² 增长太快;
- AKConv 的三步:
- 初始采样形状 p_n:N 个采样点的初始位置按"近似方形 + 余数补点"排布(如 N=5 是 2×2 网格再补 1 个点),形状由超参 N 决定、参数量为零(注册为 buffer);
- 可学习偏移:一个 3×3 卷积
p_conv(零初始化)为每个输出位置预测 N 个偏移,实际采样坐标 =规则步进网格 p_0 + p_n + offset; - 双线性重采样 + 列卷积:在 N 个任意坐标上双线性采样特征,把 N 个采样值沿行方向堆叠,交给一个
(N,1)的列卷积聚合(BN+SiLU)——卷积核"读"的是一条由任意位置串起来的采样列;
- stride 的实现:
p_conv按 stride 下采样、初始网格 p_0 按 stride 步进,列卷积以(N,1)步长把堆叠特征压回输出分辨率——s=2 时输出恰为输入一半,可直接替换下采样层; - 偏移分支的梯度缩放:官方对
p_conv挂了_set_lr钩子,把偏移分支的梯度缩放 0.1 倍,防止训练初期偏移抖动(官方原样保留)。
三、实现代码
由官方 CV-ZhangXin/AKConv 等价适配:① 官方einops.rearrange的空间重排以 permute/reshape 等价替换;② 采样坐标显式绑定 offset 的 device/dtype(官方写法固定 CPU 张量,GPU 上会报错);③meshgrid显式传indexing="ij"。已做同权重输出逐位比对(三种配置下 max diff = 0):
import math import torch import torch.nn as nn class AKConv(nn.Module): """AKConv 任意核卷积 (Zhang et al., 2023),官方 CV-ZhangXin/AKConv 等价内嵌。 卷积核的采样点数量与形状任意:每个输出位置先在 N=num_param 个"初始网格 + 可学偏移" 的坐标上双线性重采样特征,再把 N 个采样值沿行方向堆叠,交给 (N,1) 列卷积聚合—— 参数量只随 N 线性增长,而不是 k²。 """ def __init__(self, inc, outc, num_param=5, stride=1, bias=None): super().__init__() self.num_param = num_param self.stride = stride self.conv = nn.Sequential( nn.Conv2d(inc, outc, kernel_size=(num_param, 1), stride=(num_param, 1), bias=bias), nn.BatchNorm2d(outc), nn.SiLU()) # 官方注明:加 BN+SiLU 与 YOLO 的 Conv 对齐 self.p_conv = nn.Conv2d(inc, 2 * num_param, kernel_size=3, padding=1, stride=stride) nn.init.constant_(self.p_conv.weight, 0) # 偏移零初始化,起点即固定初始采样形状 self.p_conv.register_full_backward_hook(self._set_lr) self.register_buffer("p_n", self._get_p_n(N=self.num_param)) @staticmethod def _set_lr(module, grad_input, grad_output): grad_input = (grad_input[i] * 0.1 for i in range(len(grad_input))) grad_output = (grad_output[i] * 0.1 for i in range(len(grad_output))) def forward(self, x): # N is num_param. offset = self.p_conv(x) N = offset.size(1) // 2 # (b, 2N, h, w) 输入坐标系下的 N 个采样点坐标 p = self._get_p(offset, x.dtype) # (b, h, w, 2N) p = p.contiguous().permute(0, 2, 3, 1) q_lt = p.detach().floor() q_rb = q_lt + 1 q_lt = torch.cat([torch.clamp(q_lt[..., :N], 0, x.size(2) - 1), torch.clamp(q_lt[..., N:], 0, x.size(3) - 1)], dim=-1).long() q_rb = torch.cat([torch.clamp(q_rb[..., :N], 0, x.size(2) - 1), torch.clamp(q_rb[..., N:], 0, x.size(3) - 1)], dim=-1).long() q_lb = torch.cat([q_lt[..., :N], q_rb[..., N:]], dim=-1) q_rt = torch.cat([q_rb[..., :N], q_lt[..., N:]], dim=-1) # clip p p = torch.cat([torch.clamp(p[..., :N], 0, x.size(2) - 1), torch.clamp(p[..., N:], 0, x.size(3) - 1)], dim=-1) # bilinear kernel (b, h, w, N) g_lt = (1 + (q_lt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_lt[..., N:].type_as(p) - p[..., N:])) g_rb = (1 - (q_rb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_rb[..., N:].type_as(p) - p[..., N:])) g_lb = (1 + (q_lb[..., :N].type_as(p) - p[..., :N])) * (1 - (q_lb[..., N:].type_as(p) - p[..., N:])) g_rt = (1 - (q_rt[..., :N].type_as(p) - p[..., :N])) * (1 + (q_rt[..., N:].type_as(p) - p[..., N:])) # resampling the features based on the modified coordinates. x_q_lt = self._get_x_q(x, q_lt, N) x_q_rb = self._get_x_q(x, q_rb, N) x_q_lb = self._get_x_q(x, q_lb, N) x_q_rt = self._get_x_q(x, q_rt, N) # bilinear x_offset = g_lt.unsqueeze(dim=1) * x_q_lt + \ g_rb.unsqueeze(dim=1) * x_q_rb + \ g_lb.unsqueeze(dim=1) * x_q_lb + \ g_rt.unsqueeze(dim=1) * x_q_rt x_offset = self._reshape_x_offset(x_offset, self.num_param) out = self.conv(x_offset) return out # generating the initial sampled shapes for the AKConv with different sizes. def _get_p_n(self, N): base_int = round(math.sqrt(self.num_param)) row_number = self.num_param // base_int mod_number = self.num_param % base_int p_n_x, p_n_y = torch.meshgrid( torch.arange(0, row_number), torch.arange(0, base_int), indexing="ij") p_n_x = torch.flatten(p_n_x) p_n_y = torch.flatten(p_n_y) if mod_number > 0: mod_p_n_x, mod_p_n_y = torch.meshgrid( torch.arange(row_number, row_number + 1), torch.arange(0, mod_number), indexing="ij") mod_p_n_x = torch.flatten(mod_p_n_x) mod_p_n_y = torch.flatten(mod_p_n_y) p_n_x, p_n_y = torch.cat((p_n_x, mod_p_n_x)), torch.cat((p_n_y, mod_p_n_y)) p_n = torch.cat([p_n_x, p_n_y], 0) p_n = p_n.view(1, 2 * N, 1, 1) return p_n # no zero-padding def _get_p_0(self, h, w, N, dtype, device): p_0_x, p_0_y = torch.meshgrid( torch.arange(0, h * self.stride, self.stride, device=device), torch.arange(0, w * self.stride, self.stride, device=device), indexing="ij") p_0_x = torch.flatten(p_0_x).view(1, 1, h, w).repeat(1, N, 1, 1) p_0_y = torch.flatten(p_0_y).view(1, 1, h, w).repeat(1, N, 1, 1) p_0 = torch.cat([p_0_x, p_0_y], 1).type(dtype) return p_0 def _get_p(self, offset, dtype): N, h, w = offset.size(1) // 2, offset.size(2), offset.size(3) # (1, 2N, h, w) 初始采样网格:规则步进网格 p_0 + 初始采样形状 p_n + 可学偏移 p_0 = self._get_p_0(h, w, N, dtype, offset.device) p = p_0 + self.p_n.type_as(p_0) + offset return p def _get_x_q(self, x, q, N): b, h, w, _ = q.size() padded_w = x.size(3) c = x.size(1) # (b, c, h*w) x = x.contiguous().view(b, c, -1) # (b, h, w, N) index = q[..., :N] * padded_w + q[..., N:] # offset_x*w + offset_y # (b, c, h*w*N) index = index.contiguous().unsqueeze(dim=1).expand(-1, c, -1, -1, -1).contiguous().view(b, c, -1) x_offset = x.gather(dim=-1, index=index).contiguous().view(b, c, h, w, N) return x_offset # Stacking resampled features in the row direction. @staticmethod def _reshape_x_offset(x_offset, num_param): b, c, h, w, n = x_offset.size() # 等价于官方 rearrange('b c h w n -> b c (h n) w'):把 N 个采样值沿行方向堆叠 return x_offset.permute(0, 1, 2, 4, 3).reshape(b, c, h * n, w).contiguous()注意签名与Conv对齐(c1 自动传入,c2/num_param/stride 来自 yaml),注册用卷积类分支(c2 宽度缩放)。
四、添加步骤(v13 版)
1. 修改ultralytics/nn/modules/conv.py
AKConv类粘贴到 conv.py 末尾。
2. 修改ultralytics/nn/modules/__init__.py
from .conv import (...)里追加AKConv,;__all__里给最后一项"DSConv"补逗号后追加"AKConv"。
3. 修改ultralytics/nn/tasks.py
顶部导入块加AKConv;parse_model()里新增卷积类分支(c2 宽度缩放):
elif m is AKConv: # 卷积类:c1 自动传入,c2 按宽度系数缩放 c1 = ch[f] c2 = args[0] if c2 != nc: c2 = make_divisible(min(c2, max_channels) * width, 8) args = [c1, c2, *args[1:]]插入位置:elif m is FullPAD_Tunnel:之后、else:之前。
五、yaml 模型文件
只改第 1 层,其余与官方 yolov13.yaml 逐行一致(AKConv参数:目标通道、采样点数 N、stride;N=5 为论文示例的"近似方形 + 补点"形状):
# Ultralytics YOLOv13n + AKConv 任意核卷积 🚀 nc: 80 scales: n: [0.50, 0.25, 1024] s: [0.50, 0.50, 1024] l: [1.00, 1.00, 512] x: [1.00, 1.50, 512] backbone: # [from, repeats, module, args] - [-1, 1, Conv, [64, 3, 2]] # 0-P1/2 - [-1, 1, AKConv, [128, 5, 2]] # 1-P2/4 ★AKConv(替换stride-2卷积,层数不变) - [-1, 2, DSC3k2, [256, False, 0.25]] # 2 - [-1, 1, Conv, [256, 3, 2, 1, 4]] # 3-P3/8 - [-1, 2, DSC3k2, [512, False, 0.25]] # 4 - [-1, 1, DSConv, [512, 3, 2]] # 5-P4/16 - [-1, 4, A2C2f, [512, True, 4]] # 6 - [-1, 1, DSConv, [1024, 3, 2]] # 7-P5/32 - [-1, 4, A2C2f, [1024, True, 1]] # 8 head: - [[4, 6, 8], 2, HyperACE, [512, 8, True, True, 0.5, 1, "both"]] # 9 - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 10 - [ 9, 1, DownsampleConv, []] # 11 - [[6, 9], 1, FullPAD_Tunnel, []] # 12 - [[4, 10], 1, FullPAD_Tunnel, []] # 13 - [[8, 11], 1, FullPAD_Tunnel, []] # 14 - [-1, 1, nn.Upsample, [None, 2, "nearest"]] # 15 - [[-1, 12], 1, Concat, [1]] # 16 cat backbone P4 - [-1, 2, DSC3k2, [512, True]] # 17 - [[-1, 9], 1, FullPAD_Tunnel, []] # 18 - [17, 1, nn.Upsample, [None, 2, "nearest"]] # 19 - [[-1, 13], 1, Concat, [1]] # 20 cat backbone P3 - [-1, 2, DSC3k2, [256, True]] # 21 - [10, 1, Conv, [256, 1, 1]] # 22 - [[21, 22], 1, FullPAD_Tunnel, []] # 23 - [-1, 1, Conv, [256, 3, 2]] # 24 - [[-1, 18], 1, Concat, [1]] # 25 cat head P4 - [-1, 2, DSC3k2, [512, True]] # 26 - [[-1, 9], 1, FullPAD_Tunnel, []] # 27 - [26, 1, Conv, [512, 3, 2]] # 28 - [[-1, 14], 1, Concat, [1]] # 29 cat head P5 - [-1, 2, DSC3k2, [1024,True]] # 30 - [[-1, 11], 1, FullPAD_Tunnel, []] # 31 - [[23, 27, 31], 1, Detect, [nc]] # 32 Detect(P3, P4, P5)六、成功运行结果
yolo detect train model=yolov13n-AKConv.yaml data=mydata.yaml epochs=100 imgsz=640 batch=4Python 方式:
from ultralytics import YOLO model = YOLO("yolov13n-AKConv.yaml").load("yolov13n.pt") model.train(data="mydata.yaml", epochs=100, imgsz=640, batch=4)本文实测(YOLOv13 官方仓库,Windows CPU,v13n 结构,替换层 1):
基线 yolov13n: 2,494,151 parameters, 6.5 GFLOPs(层1为 2,368 参数) +AKConv: 2,495,857 parameters, 6.6 GFLOPs(层1为 4,074 参数) 3 epochs completed. Results saved to runs\detect\v13_akconv_smoke✅ 断言全部通过:① 适配版与官方
LDConv(einops 版)同权重输出逐位一致(40×40 s=2、40×40 s=1、39×39 s=1 N=10 三种配置 max diff = 0);② 第 1 层为AKConv,总参数量恰为基线 +1,706;③ 层数不变,整网 640 前向正常、反向梯度可达本层,3 轮冒烟训练正常收敛。
参数量分解(c1=16, c2=32, N=5, s=2):
| 部件 | 参数量 | 说明 |
|---|---|---|
| 列卷积 Conv(16→32, (5,1)) | 2,560 | 聚合 5 个采样点 |
| BN(32) | 64 | |
| p_conv Conv(16→10, 3×3, s2) | 1,450 | 偏移预测(零初始化 + 0.1 梯度缩放) |
| 初始采样形状 p_n | 0 | 固定 buffer,不参与训练 |
| 合计 | 4,074(+1,706) | 原层为 g=2 分组卷积 2,368 |
| 模型 | Params | GFLOPs | 层1参数变化 | 说明 |
|---|---|---|---|---|
| YOLOv13n 基线 | 2,494,151 | 6.5 | 2,368 | 官方 stride-2 分组卷积 |
| +AKConv | 2,495,857 | 6.6 | 4,074(+1,706) | N=5 任意形状采样 |
七、总结
AKConv 的三点记忆锚:采样点数量 N 与形状任意(初始形状零参数,p_n 只是个 buffer)、"重采样 + 堆叠 + 列卷积"三段式(N 个采样值沿行堆叠,(N,1) 卷积一步聚合)、偏移分支梯度缩放 0.1(官方防抖设计)。它与 DCNv2 同走"采样几何"路线互为对照:DCNv2 在 k² 网格上学偏移(参数多、拓扑固定),AKConv 连拓扑都可调(参数随 N 线性);也和 ODConv 篇同位替换层 1——ODConv 换"核权重怎么动态",AKConv 换"采样点放哪里"。
觉得有帮助的话,点赞收藏关注三连支持一下,评论区欢迎交流你的实验结果~
专栏目录:YOLOv13改进目录一览 专栏地址:YOLOv13改进专栏——持续更新各方向即插即用改进