资讯动态

ACNet详解:非对称卷积块如何训练提点、推理无损融合

发布时间:2026/10/17 20:31:07 来源:尧图企业网站定制
如果你跟我一样喜欢把训练好的卷积核可视化出来看应该会发现一个规律普通的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卷积论文里通常讨论K3的情况后面跟一个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. 从零写一个可部署的ACNetPyTorch实现全流程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_size3, stride1, padding1, dilation1, groups1, use_bnTrue, deployFalse, use_aux_actFalse): 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, biasTrue) else: # 主分支KxK self.conv_main nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, dilation, groups, biasFalse) # 垂直分支Kx1 self.conv_ver nn.Conv2d(in_channels, out_channels, (kernel_size, 1), stride, (padding, 0), dilation, groups, biasFalse) # 水平分支1xK self.conv_hor nn.Conv2d(in_channels, out_channels, (1, kernel_size), stride, (0, padding), dilation, groups, biasFalse) 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(inplaceTrue) 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的配合。如果主卷积的stride2那么辅助分支也必须保持相同的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, stride1): super().__init__() # 第一个3x3卷积使用ACBlock self.conv1 ACBlock(in_channels, out_channels, kernel_size3, stridestride, padding1, use_bnTrue) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, stride1, padding1, biasFalse) 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_size1, stridestride, biasFalse), 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, devicew.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卷积且stride1、dilation1。如果你的ACBlock用了不同kernel size比如5x5那么中心行索引还是k//2逻辑不变但如果stride不为1融合时还要考虑特征图对齐问题建议直接用标准配置。融合之后的验证比较简单。构造一个相同的输入分别跑融合前的ACBlock和融合后的普通卷积层比较输出的最大绝对误差。torch.manual_seed(0) ac ACBlock(3, 8, kernel_size3, padding1, use_bnTrue).eval() fused_w, fused_b merge_ac_block(ac) conv_deploy nn.Conv2d(3, 8, kernel_size3, padding1) 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构造时biasFalse主分支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 什么时候别用ACBACB不是万能的有几个场景我不建议硬套。场景问题说明处理建议深度可分离卷积结构大量使用逐通道卷积逐点卷积如果强行套ACB融合逻辑和分组卷积的兼容性会变复杂收益被简化结构吃掉优先考虑在逐点卷积或最后融合阶段做文章1x1卷积层骨架概念只对K1的卷积核有意义1x1卷积没有“中间行/列”可强化不适用训练资源极其紧张ACB在训练期约3倍计算量如果单次训练都要跑很久成本收益比不好看可以先在小规模子集上验证收益网络已经采用重参数化结构如RepVGG这类本身就多分支训练、单分支推理的模型再叠一层ACB收益边际递减明显先做对比实验再决定还有一个容易被忽视的边界ACB在分类骨干上可能很有效但如果你直接在检测或分割任务的FPN层、head层里到处乱插ACB反而可能因为任务特性不同而效果不明显。建议优先替换网络前中段的卷积而不是所有位置无脑替换。6. ACNet之后它如何开启结构重参数化的解题思路6.1 从ACB到RepVGG结构重参数化家族ACNet经常被追溯为结构重参数化的早期代表。后来的RepVGG把“多分支训练、单分支推理”的思路又往前推进了一步它在训练时使用3x3卷积加1x1卷积分支加恒等映射三条分支推理时全部融合回3x3卷积。相比ACNetRepVGG进一步证明了训练期和推理期的结构可以完全不一样推理时的简洁结构不一定需要从训练时就固定地学习。如果把两者放在一起对比会发现核心公式惊人一致都是利用卷积线性可加性把几个分支融合成一个卷积。区别在于ACNet关注的是“核骨架先验”希望用异形卷积强化中心十字RepVGG关注的是“多分支带来的隐式集成”希望训练时通过多路径组合提升表征能力。你的项目里如果需要做轻量化部署可能RepVGG的收益更大如果只是想在现有ResNet结构上低成本提点ACB更容易上手。再往后还有Diverse Branch Block这类工作把不同尺度、不同核形状的分支都纳入融合范围。它们本质上都是ACNet思路的延伸只是把辅助分支设计得更多样、更复杂。6.2 ACB思想的适用边界ACB的本质是给卷积核加了一个结构先验这不是一个可以无限叠加的万能组件。当模型本身就极深极宽或者已经用了其他类型的结构先验比如注意力机制、动态卷积ACB的边际收益会快速递减。另外要注意ACB虽然能让模型准确率提升但它并不改变模型对数据增强的依赖。如果你训练时用了很强的数据增强比如AutoAugment、RandAugmentACB的额外正则效果就会被部分抵消。所以实验设计时最好固定一套数据增强策略再单独看ACB的作用。6.3 这篇工作真正让我受启发的点说实话ACNet的代码量很小数学原理也不难但它传达了一个很重要的设计态度训练阶段和推理阶段可以不是同一个模型推理时的性能关键不在于训练时结构有多漂亮而在于融合后真正跑的那个算子有多高效。这种思路在之后几年被反复验证从重参数化到剪枝蒸馏再到各种权重合并技术都在走“训练花哨、部署简洁”的路线。如果你要做模型上线训练侧的算力相对宽裕部署侧的延迟和参数量才是真正的瓶颈那就不妨把训练和推理结构彻底拆开思考。ACB是一个很小的切入点但它把这条路走通了而且走得非常干净。最后一个实操建议我自己做实验的时候习惯把ACBlock、融合脚本和验证脚本封装成一个小工具类在换任何新骨干网络时都顺手先跑一次对比。操作流程很简单挑一个中等规模的3x3卷积层替换成ACBlock保持其他一切不变训练同样的epoch对比融合前的验证精度和融合后的部署精度。往往一次实验就能判断这个场景适不适合用ACB。如果你手上正好有一个分类或检测模型训练了好几个星期都提不了点不妨把几层3x3卷替换成ACBlock再试一次。就算最终只提升0.3到0.5个百分点考虑到推理成本一点没变这笔买卖也是稳赚的。唯一要记得的就是训练完成后一定用融合脚本导出别带着三个分支去部署。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑