☰
ACNet详解:非对称卷积块如何训练提点、推理无损融合
2026/10/17 20:30:55 网站建设 项目流程

如果你跟我一样喜欢把训练好的卷积核可视化出来看,应该会发现一个规律:普通的3x3卷积核,权重的重心几乎总是落在最中间那行和中间那列,形成一个或明或暗的十字。这个现象在早期视觉模型里非常常见,但很少有人会认真追问一句:如果一开始就把这个“骨架”强化出来,模型会不会更强?ACNet这篇工作干的就是这件事。它提出了一种叫Asymmetric Convolution Block(非对称卷积块)的插件式结构:训练时在标准方形卷积旁边并行走两个非对称卷积分支,推理时再无损融合回一个3x3卷积。也就是说,部署时你的网络结构和原始ResNet一模一样,参数、FLOPs一点都不多,但准确率通常能提升1个百分点上下。

下面我会从原理、实现、融合验证到适用范围,把ACNet完整复盘一遍。这篇文章更适合已经会写PyTorch模型、想给现有网络做低成本涨点、又不想动推理结构的人。纯新手也能看,遇到基础概念我会停下来解释一下。

1. 从“卷积核骨架”说起:一个被很多人看见但没人动手的问题

1.1 训练好的方形卷积核,为什么总带着一个“十字阴影”

你随便拿一个训练好的VGG或者ResNet,把第一层3x3卷积核的权重画成灰度图,会看到一个非常明显的现象:中间一行和中间一列的权重绝对值普遍偏大,四个角上的权重相对偏小。如果把多张卷积核热力图叠在一起看,就是一个典型的十字形“骨架”。这个现象在ACNet原论文里被叫做Kernel Skeletons,也就是核骨架。

为什么会出现这种现象?可以从两个角度理解。从数据侧看,自然图像里的结构和纹理以水平、垂直方向为主,比如建筑物的边缘、物体的轮廓、文字笔画,都包含大量正交方向的梯度信息。卷积核在做特征提取时,中间行和中间列恰好覆盖了每个像素的左右邻居和上下邻居,这两条线承担了大部分“沿方向组合信息”的任务。从梯度侧看,反向传播过程中,位于中心行列的权重因为总是参与到更多有效特征的计算里,所以更容易获得较大的梯度,逐步训练后就形成了这种十字占优的稳定分布。

这其实揭示了一个长期被忽视的事实:方形卷积核虽然名义上是KxK个参数,真正的表达能力高度集中在中间行、中间列以及它们的交叉点附近。四个角上的权重当然也有用,但重要性和中心区域完全不在一个量级。既然如此,与其寄希望于梯度自组织地形成骨架,为什么不直接设计一种训练方式,让它更明确、更高效地把骨架强化出来?这就是ACNet的出发点。

1.2 直接改核结构不可取,需要一种“软强化”

这里可能有人会想:那简单,训练的时候把中间十字区域的权重乘以一个放大系数,或者直接把边缘位置置零,不就能强化骨架了吗?实际做过的朋友应该能意识到,问题没有这么简单。

直接放大十字区域数值,相当于在训练中强行改变梯度分布,容易导致卷积核在其他位置学不到必要的局部细节,模型会很“脆”。直接把四角置零就更不可行了,因为3x3核的四角虽然在统计意义上权重偏小,但它们依然承载了部分对角方向的信息,在目标发生旋转、形变、遮挡时,四角特征恰恰是一种补充。强行砍掉这个自由度,反而削弱了模型的泛化能力。

ACNet的做法不是“限制”,而是“引导”。它在标准方形卷积旁额外放置两个非对称卷积分支,相当于给主卷积核配了两个长期训练的助手。助手只负责中间行、中间列方向的特征提取,主卷积核仍然保留完整KxK的学习空间。训练阶段,三个分支并行输出并叠加,梯度会一起流回主核和辅助核。由于辅助核的存在,中心十字区域的梯度被更频繁、更集中地触发,主核在训练中自然把更多容量分配给骨架。推理阶段,再把三条分支融合回来,不改变部署图。

1.3 插件式设计为什么对工程如此友好

ACNet最讨喜的一点是它不改变推理时的网络结构。训练时网络里确实多出两个卷积分支,但那是训练期的临时脚手架;融合之后,模型里的每一个可学习模块还是普普通通的KxK卷积,框架和硬件的优化路径完全没有变化。

这对工程落地意义很大。很多优化技巧在训练时效果很好,但部署时要么需要自定义算子,要么增加显存或延时,导致上线困难。ACB则完全没有这种负担,融合后的卷积和标准卷积在底层算子层面没有任何区别,该用TensorRT、OpenVINO还是其他推理引擎都用,不需要任何额外适配。你唯一要做的是在导出模型前跑一遍权重融合脚本。

2. ACB的原理拆解:三条分支如何训练,又如何“变回”一个卷积

2.1 结构定义:主分支加两个非对称助手

一个典型的非对称卷积块(ACBlock)由三个分支组成。主分支就是常规的KxK卷积(论文里通常讨论K=3的情况),后面跟一个BatchNorm。垂直分支是Kx1卷积,它的卷积核高度为K、宽度为1,负责捕捉上下方向的邻居信息。水平分支是1xK卷积,宽度为K、高度为1,负责捕捉左右方向的邻居信息。后面也都跟着各自的BatchNorm。

从感受野的角度看,三个分支的感受野其实是一样的。Kx1卷积配合适当padding后,对输入特征图上某个位置来说,它聚合的是该位置上下K/2个像素的信息;1xK卷积聚合的则是左右K/2个像素的信息。KxK卷积聚合的是一个方形邻域。所以这三个分支叠加,本质上是在同一个感受野范围内,对水平、垂直、方形三种采样策略做了加权组合。

前向计算的时候,输入x分别经过三个分支,得到三张特征图,然后逐元素相加作为最终输出。需要注意的是,有的论文版本里辅助分支后面还会接ReLU,有的实现里不接。按照原论文,辅助分支是否激活需要结合具体基线结构判断,最简单的做法是参考你所要替换的卷积层前后的激活逻辑。

2.2 训练与推理解耦的数学基础:卷积的线性可加性

为什么训练时要分成三条分支,推理时却能合并?这个问题的核心是卷积运算是线性的,卷积核之间满足可加性。用公式表达就是:

y = W_main * x + W_h * x + W_v * x = (W_main + W_h + W_v) * x

这里的加法并不是张量胡乱相加,而是把三个不同形状的卷积核先对齐到同一个KxK尺寸,再在对应位置做元素级累加。Kx1核和1xK核需要经过补零操作,放到KxK核的特定位置上,这样合并出来的新卷积核才和原来的三路输出完全一致。

补零的对齐方式从语义上很好理解:对于1xK卷积核,它本质上是只处理中心行像素左右邻域信息的核,所以在KxK核里,它应当被放在中心行,也就是第K//2行的位置;对于Kx1核,它只处理中心列像素上下邻域信息,所以应当被放在中心列。这样一来,合并后的卷积核中心十字区域由三部分叠加,四个角的位置仍然只有主分支在贡献参数。

这种“训练时多分支、推理时单分支”的设计范式,后来在结构重参数化领域被发扬光大。ACNet是这套思路里非常早期、也非常清晰的一个代表作。

2.3 训练参数与推理成本的真实对比

训练和推理的开销要分开看。

阶段参数量单样本计算量说明
训练期约3倍标准卷积(KxK + 1xK + Kx1,含BN)约3倍主要是反向传播需要同时维护三条分支的梯度
推理期与标准卷积完全相同与标准卷积完全相同融合后只有一个KxK卷积核

训练期多出来的成本在绝大多数情况下是可以接受的。GPU训练时的算力相对充裕,而且ACB带来的准确率提升往往能让模型提前收敛或者更好地泛化,省下来的调参时间远大于多出来的训练时间。而推理期的零额外成本,恰恰是它区别于一般多分支结构最大的优势。如果你在做边缘端部署,算力按毫瓦计,你就知道“推理结构完全不变”这句话有多值钱。

3. 从零写一个可部署的ACNet:PyTorch实现全流程

3.1 ACBlock的核心代码

先给一个最基础、最干净的实现。这里我刻意把辅助分支是否加ReLU的开关留出来,看具体任务需要。

import torch import torch.nn as nn import torch.nn.functional as F class ACBlock(nn.Module): def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1, dilation=1, groups=1, use_bn=True, deploy=False, use_aux_act=False): super().__init__() self.kernel_size = kernel_size self.stride = stride self.padding = padding self.dilation = dilation self.groups = groups self.deploy = deploy if deploy: # 推理模式下只有一个合并后的常规卷积 self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, dilation, groups, bias=True) else: # 主分支:KxK self.conv_main = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, dilation, groups, bias=False) # 垂直分支:Kx1 self.conv_ver = nn.Conv2d(in_channels, out_channels, (kernel_size, 1), stride, (padding, 0), dilation, groups, bias=False) # 水平分支:1xK self.conv_hor = nn.Conv2d(in_channels, out_channels, (1, kernel_size), stride, (0, padding), dilation, groups, bias=False) self.bn_main = nn.BatchNorm2d(out_channels) if use_bn else nn.Identity() self.bn_ver = nn.BatchNorm2d(out_channels) if use_bn else nn.Identity() self.bn_hor = nn.BatchNorm2d(out_channels) if use_bn else nn.Identity() self.act = nn.ReLU(inplace=True) if use_aux_act else nn.Identity() def forward(self, x): if self.deploy: return self.conv(x) y = self.bn_main(self.conv_main(x)) y_ver = self.act(self.bn_ver(self.conv_ver(x))) y_hor = self.act(self.bn_hor(self.conv_hor(x))) return y + y_ver + y_hor

这里最需要注意的是stride和padding的配合。如果主卷积的stride=2,那么辅助分支也必须保持相同的stride,否则三个分支输出的空间尺寸会对不上。padding方向也要跟着核形状走:Kx1核的padding只在高度方向,是(padding, 0);1xK核的padding只在宽度方向,是(0, padding)。

3.2 把ACBlock塞进ResNet的BasicBlock

现在演示怎么把它用在ResNet-18/ResNet-34里的BasicBlock。常见的ResNet实现中,BasicBlock包含两个3x3卷积,第一个带下采样。我们可以只替换第一个3x3卷积,第二个保持普通卷积,这样可以控制实验变量,只验证ACB带来的收益。

class BasicBlockWithAC(nn.Module): expansion = 1 def __init__(self, in_channels, out_channels, stride=1): super().__init__() # 第一个3x3卷积使用ACBlock self.conv1 = ACBlock(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, use_bn=True) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self.shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(identity) out = self.relu(out) return out

这里我额外加了一个bn1,因为ACBlock内部已经带了BN,再加一层会重复。实际使用时可以把ACBlock里的BN当作普通卷积后面的第一个BN,外面的bn1就不需要了,直接把conv1的输出接到ReLU。如果你习惯了标准ResNet的写法,调整一下就好。

3.3 权重融合脚本与等价性验证

融合分两步。第一步要把每个分支内部的BatchNorm参数折叠进卷积权重和偏置;第二步再把两条辅助分支补齐到主核尺寸后,与主分支权重相加。

def fuse_bn_into_conv(conv, bn): w = conv.weight.detach() b = torch.zeros(conv.out_channels, device=w.device) if conv.bias is None else conv.bias.detach() running_mean = bn.running_mean running_var = bn.running_var gamma = bn.weight.detach() beta = bn.bias.detach() eps = bn.eps scale = gamma / torch.sqrt(running_var + eps) w_fused = w * scale.reshape(-1, 1, 1, 1) b_fused = (b - running_mean) * scale + beta return w_fused, b_fused def merge_ac_block(ac_block): main_w, main_b = fuse_bn_into_conv(ac_block.conv_main, ac_block.bn_main) ver_w, ver_b = fuse_bn_into_conv(ac_block.conv_ver, ac_block.bn_ver) hor_w, hor_b = fuse_bn_into_conv(ac_block.conv_hor, ac_block.bn_hor) k = main_w.shape[-1] fused_w = main_w.clone() fused_b = main_b + ver_b + hor_b # 垂直分支 Kx1 -> KxK 中心列 fused_w[:, :, :, k // 2] += ver_w[:, :, :, 0] # 水平分支 1xK -> KxK 中心行 fused_w[:, :, k // 2, :] += hor_w[:, :, 0, :] return fused_w, fused_b

需要强调的是,这个脚本已经默认了3x3卷积且stride=1、dilation=1。如果你的ACBlock用了不同kernel size,比如5x5,那么中心行索引还是k//2,逻辑不变;但如果stride不为1,融合时还要考虑特征图对齐问题,建议直接用标准配置。

融合之后的验证比较简单。构造一个相同的输入,分别跑融合前的ACBlock和融合后的普通卷积层,比较输出的最大绝对误差。

torch.manual_seed(0) ac = ACBlock(3, 8, kernel_size=3, padding=1, use_bn=True).eval() fused_w, fused_b = merge_ac_block(ac) conv_deploy = nn.Conv2d(3, 8, kernel_size=3, padding=1) conv_deploy.weight.data = fused_w conv_deploy.bias.data = fused_b conv_deploy.eval() x = torch.randn(4, 3, 32, 32) with torch.no_grad(): y_ac = ac(x) y_deploy = conv_deploy(x) max_err = (y_ac - y_deploy).abs().max().item() print("max abs error:", max_err) # 通常小于1e-5,说明融合正确

如果误差出现在1e-5量级以下,说明折叠过程没问题。如果误差到了1e-2或更大,别急着怀疑代码,先检查BN的running_mean和running_var是不是在eval模式下被固定了。经常有人忘记调用.eval(),导致BN统计量还在更新,融合自然对不上。

4. 训练与部署的实操要点:这几个坑我踩过,你提前避开

4.1 融合时BN必须每条分支各自先折叠,顺序不能乱

这个是在实际项目中非常容易踩的坑。ACBlock里三条分支各带一个BN,它们的running_mean、running_var、weight、bias都是独立统计的。融合时,必须先对每条分支分别做“BN折叠进卷积”的操作,得到三条分支各自融合后的卷积权重,然后再把权重相加。

有人图省事,先把三条分支的卷积权重加总,再套用一个BN。这在数学上是错的,因为三条分支经过的是不同的BN,统计量完全不一样,先加总再折叠等于用三条分支的平均统计量去近似三条独立归一化的结果,两个模型的输出会明显不一致。验证阶段很容易发现,误差通常在1e-1量级。

从工程角度看,正确顺序其实也更好维护。fuse_bn_into_conv是一个通用函数,对主分支和辅助分支完全复用,代码更整洁。后面如果某个分支不用BN,那张量处理逻辑调整一下即可。

4.2 融合前后不要只看权重,要直接对比输出

我见过不少朋友做完融合后,只看一下融合完的权重和主分支权重是不是“差不多”就完事了。实际上你真正要验证的是:给定同样的输入,融合前模型和融合后模型的输出是否完全一致。

这里的关键是“输出完全一致”而不是“权重接近”。因为BN折叠本身会引入一项对偏置的修正,辅助分支补零的位置也会影响最终权重,这些细节只看权重很难一眼看出问题。最稳妥的办法就是写一个小的对比脚本,构造随机输入或者真实样本,分别跑融合前和融合后的模型,统计最大绝对误差。

我之前在某个项目里遇到过一种情况:融合时忘记处理辅助分支的bias,因为conv_ver和conv_hor构造时bias=False,主分支bias也不存在,三条分支折叠后都有bias,这个bias必须相加。如果漏加,单看权重完全正常,但输出误差会稳定在偏置量附近,肉眼很难发现。所以验证脚本里一定要把bias的对比也写上。

4.3 训练超参:大部分情况不需要大改

用ACB做涨点实验,比较理想的做法是保持基线模型的全部训练配置不变,只把3x3卷积替换成ACBlock。优化器、学习率、batch size、epoch数目、数据增强都先保持一致,先看替换本身带来多少收益。绝大多数情况下,ACB的收益是稳的,不需要额外调整超参就能观察到。

如果发现训练震荡明显,可以先把辅助分支的ReLU去掉,让两个分支只做线性变换和BN归一化,降低训练初期的不稳定性。另外BN在小batch下本身就容易震荡,ACBlock里每条分支都有BN,会让这个问题被放大,这时候不要把batch size压得太小,尽量保持在32以上,或者使用sync BN。

关于训练时长,如果原来的模型训练充分、已经收敛得很平,ACB带来的提升一般空间有限;反过来说,如果原来训练不充分,ACB的辅助分支能让模型更快地学到骨架特征,提升会更明显。所以实践里ACB经常被用在训练资源有限、模型性能还没压榨干净的项目里。

5. 实验效果观察:ACB在哪些场景真的很值,哪些场景只是折腾

5.1 公开基准与相对提升

原论文在ImageNet、CIFAR系列这些常见公开数据集上都做了验证。以ImageNet上的ResNet-50为例,替换部分3x3卷积为ACB后,Top-1准确率相对基线大约提升了0.7到1.5个百分点之间,具体数值会随训练策略、版本实现有所浮动。在CIFAR-10/100这类小规模数据集上,收益往往更明显,多个ResNet变体都能看到稳定的涨点。

如果你做过分类任务的调参,应该知道在ImageNet这个量级上,0.5个百分点以上的提升就已经是很可观的改进了,很多结构改动连0.1个点都稳定不了。ACNet能做到这个程度,而且推理成本完全不变,性价比相当高。除了图像分类,检测、分割任务里也有不少工作直接在主干的3x3卷积上套ACB来获得基础涨点。

5.2 为什么小模型和短训练收益更大

ACB的收益呈现出明显的“模型越小、收益越大”的趋势。原因其实不复杂:大模型往往本身就有很强的拟合能力,通道数多、层数深,即使不显式强化骨架,它也能靠巨大的参数冗余在训练中找到合适的特征表达,ACB带来的结构性先验属于锦上添花。而小模型参数少,学习容量有限,普通方形卷积很容易把有限的容量浪费在冗余的方向组合上,ACB相当于提前告诉网络“中间十字区域更值得投入”,从而更高效地利用参数。

同样,训练数据少、训练轮次短时,ACB的收益也会放大。这是因为辅助分支相当于一种隐性的结构正则,在模型还没充分收敛的时候,它已经按一个合理的先验把骨架区域的基础特征学出来了。用一句话概括:ACB本质上是在给卷积核引入一个“先验形状”,当你手里的数据和算力都不够充裕时,这类先验的价值最大。

5.3 什么时候别用ACB

ACB不是万能的,有几个场景我不建议硬套。

场景问题说明处理建议
深度可分离卷积结构大量使用逐通道卷积+逐点卷积,如果强行套ACB,融合逻辑和分组卷积的兼容性会变复杂,收益被简化结构吃掉优先考虑在逐点卷积或最后融合阶段做文章
1x1卷积层骨架概念只对K>1的卷积核有意义,1x1卷积没有“中间行/列”可强化不适用
训练资源极其紧张ACB在训练期约3倍计算量,如果单次训练都要跑很久,成本收益比不好看可以先在小规模子集上验证收益
网络已经采用重参数化结构如RepVGG这类本身就多分支训练、单分支推理的模型,再叠一层ACB收益边际递减明显先做对比实验再决定

还有一个容易被忽视的边界:ACB在分类骨干上可能很有效,但如果你直接在检测或分割任务的FPN层、head层里到处乱插ACB,反而可能因为任务特性不同而效果不明显。建议优先替换网络前中段的卷积,而不是所有位置无脑替换。

6. ACNet之后:它如何开启结构重参数化的解题思路

6.1 从ACB到RepVGG:结构重参数化家族

ACNet经常被追溯为结构重参数化的早期代表。后来的RepVGG把“多分支训练、单分支推理”的思路又往前推进了一步,它在训练时使用3x3卷积加1x1卷积分支加恒等映射三条分支,推理时全部融合回3x3卷积。相比ACNet,RepVGG进一步证明了:训练期和推理期的结构可以完全不一样,推理时的简洁结构不一定需要从训练时就固定地学习。

如果把两者放在一起对比,会发现核心公式惊人一致,都是利用卷积线性可加性把几个分支融合成一个卷积。区别在于ACNet关注的是“核骨架先验”,希望用异形卷积强化中心十字;RepVGG关注的是“多分支带来的隐式集成”,希望训练时通过多路径组合提升表征能力。你的项目里如果需要做轻量化部署,可能RepVGG的收益更大;如果只是想在现有ResNet结构上低成本提点,ACB更容易上手。

再往后还有Diverse Branch Block这类工作,把不同尺度、不同核形状的分支都纳入融合范围。它们本质上都是ACNet思路的延伸,只是把辅助分支设计得更多样、更复杂。

6.2 ACB思想的适用边界

ACB的本质是给卷积核加了一个结构先验,这不是一个可以无限叠加的万能组件。当模型本身就极深极宽,或者已经用了其他类型的结构先验(比如注意力机制、动态卷积),ACB的边际收益会快速递减。

另外要注意,ACB虽然能让模型准确率提升,但它并不改变模型对数据增强的依赖。如果你训练时用了很强的数据增强(比如AutoAugment、RandAugment),ACB的额外正则效果就会被部分抵消。所以实验设计时最好固定一套数据增强策略,再单独看ACB的作用。

6.3 这篇工作真正让我受启发的点

说实话,ACNet的代码量很小,数学原理也不难,但它传达了一个很重要的设计态度:训练阶段和推理阶段可以不是同一个模型,推理时的性能关键不在于训练时结构有多漂亮,而在于融合后真正跑的那个算子有多高效。

这种思路在之后几年被反复验证,从重参数化到剪枝蒸馏,再到各种权重合并技术,都在走“训练花哨、部署简洁”的路线。如果你要做模型上线,训练侧的算力相对宽裕,部署侧的延迟和参数量才是真正的瓶颈,那就不妨把训练和推理结构彻底拆开思考。ACB是一个很小的切入点,但它把这条路走通了,而且走得非常干净。

最后一个实操建议

我自己做实验的时候,习惯把ACBlock、融合脚本和验证脚本封装成一个小工具类,在换任何新骨干网络时都顺手先跑一次对比。操作流程很简单:挑一个中等规模的3x3卷积层,替换成ACBlock,保持其他一切不变,训练同样的epoch,对比融合前的验证精度和融合后的部署精度。往往一次实验就能判断这个场景适不适合用ACB。

如果你手上正好有一个分类或检测模型,训练了好几个星期都提不了点,不妨把几层3x3卷替换成ACBlock再试一次。就算最终只提升0.3到0.5个百分点,考虑到推理成本一点没变,这笔买卖也是稳赚的。唯一要记得的,就是训练完成后一定用融合脚本导出,别带着三个分支去部署。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询