跑完新一轮YOLO26优化实验,我先把结论扔到前面:用轻量化双卷积DualConv替换掉主干网络里一部分标准卷积之后,模型mAP50-95涨了1.3个点,同时参数量从12.9M掉到9.4M,计算量从18.7G降到12.3G。这不是靠蒸馏,也没加什么注意力模块,纯粹就是卷积层面的魔改,效果稳定,推理也更轻快。最近两年做检测模型优化的人应该都有同感,YOLO系列模型的默认结构越来越“重”,虽然精度卷上去了,但部署端尤其是边缘设备上的压力越来越大。用户那边的需求也特别明确:不能靠砍分辨率、减输入尺寸来换速度,也不能牺牲精度去强行压缩。这种情况下,在卷积结构本身上下功夫,可能是性价比最高的路径。DualConv就是这条路上公认比较靠谱的轻量算子之一,结构简单,改造成本低,对训练框架没有额外依赖,实测下来涨点效果比不少花哨的注意力模块还稳。这篇文章我完整复盘一下这次YOLO26优化项目的思路、DualConv的实现细节、参数计算、嵌入方式、训练配置以及我踩过的坑。如果你正准备做轻量化检测模型的卷积魔改,可以直接照着抄。1. 项目背景与优化思路拆解1.1 先搞清楚YOLO26的基线结构动手之前,先把YOLO26的网络结构吃透。这一步不能省,很多人上来就把网络里所有卷积全换成新算子,结果精度崩得没法看,然后又回头骂论文不靠谱。实际上问题不在算子本身,而在替换策略。YOLO26的结构基本延续了YOLO系列一贯的设计哲学:主干网络负责特征提取,颈部网络做多尺度特征融合,检测头负责输出不同尺度的预测结果。我这次用的基线配置是YOLO26m,主干分成5个Stage,前两个Stage分辨率相对较高,处理的是细粒度特征,后面3个Stage空间分辨率逐步降低,语义信息越来越强。每个Stage都包含若干重复的基本块,基本块结构是“卷积+归一化+激活+残差连接”,这套东西在源码里被抽象成了类似C3K2的容器模块。基线参数量12.9M,计算量18.7G,这个体量属于中等级别,单卡训练不算费劲,部署到移动端或者工业盒子上略微吃力。优化目标很明确:在不改动输入分辨率和检测头结构的前提下,重新设计特征提取路径中的卷积算子,让模型以更少参数和计算量达到更高精度。1.2 DualConv到底解决什么问题DualConv的核心思想不复杂:把原先一路标准卷积拆成两个并行分支,一个分支用k×k卷积捕捉局部空间特征,另一个分支用1×1卷积负责跨通道线性组合,最后把两个分支的输出拼接起来。听起来和GoogLeNet里的Inception结构有点像,但区别在于DualConv并不刻意追求多尺度感受野的多样性,它更关注的是计算效率和信息冗余的平衡。标准卷积之所以贵,是因为它对每一个输出通道都做了一次完整的空间卷积,而实际上网络深层特征图里存在大量通道冗余,这些冗余通道共享着高度相似的空间模式,逐个做完整卷积非常浪费。DualConv让一部分通道走1×1卷积,相当于把注意力放在“通道之间怎么加权组合”这件事上,另一部分通道保留3×3空间卷积,保证感受野不塌。两个分支各有侧重,拼接在一起以后,既保留了空间建模能力,又把计算量砍掉了一大截。用生活化点的类比来说,标准卷积像是一个团队里每个人都在重复做全流程工作,而DualConv把这个工作拆成了两个小组:一组专职处理空间位置关系,另一组专职做信息整合,最后把两组结果合并。分工明确,反而效率更高。1.3 轻量化和涨点为什么能共存大部分人一听“参数减少”,第一反应是“模型变小了,精度肯定掉”。这次实验里出现涨点,其实背后有几个很现实的机制。第一,过拟合风险降低。参数总量变小,模型容量略降,但这种降低并不是无脑剪枝带来的,而是通过结构重设计实现的。在中等规模数据集或者数据质量一般的业务场景里,模型参数减少往往能带来更好的泛化表现。第二,多分支设计本身是一种隐式的集成。DualConv两个分支输出的特征具有不同的信息侧重,拼接后的特征表达比单一卷积更丰富,这给后续的检测头提供了更多可用的判别信息。第三,梯度信息通路更通畅。1×1分支的存在让梯度可以直接绕过3×3卷积传递,等效于在残差网络内部又引入了一条隐式捷径,这在训练初期对收敛速度是有帮助的。第四,分组卷积与逐点卷积的组合能打破通道间的固定耦合方式。标准卷积会把所有输入通道的信息混在一起提取,而DualConv把特征分成两组处理,相当于引入了更稀疏的交互结构,这种结构在很多小目标场景下反而更鲁棒。所以“更轻”和“更准”在结构重设计层面完全可以同时成立,关键是别把整个网络的每个卷积都无脑替换掉,替换策略才是真正决定成败的部分。2. DualConv结构拆解与代码实现2.1 双分支结构设计细节DualConv的具体结构设计直接影响最终效果,我这里给出一个经过实际验证的版本,这个版本在YOLO26上表现最稳定。模块输入是形状为[B, C_in, H, W]的特征图,输出通道数为C_out。设计上有三个关键点。第一,输出通道必须被2整除。两个分支各负责一半输出通道,拼接后恢复完整通道数。如果C_out是奇数,需要做通道补零或调整,实践中建议直接保证偶数,省去麻烦。第二,3×3分支和1×1分支的padding设置要匹配。如果输入分辨率是偶数,两个分支输出的空间尺寸可以保持一致,直接拼接;如果输入分辨率是奇数,需要注意1×1分支输出尺寸和3×3分支输出尺寸的差异,必要时通过padding修正。第三,激活函数共用。两个分支分别过自己的卷积之后,先拼接再做统一激活,不要各自激活,否则两个分支的数值尺度可能不一致,影响拼接后的特征均衡性。我实际用的结构是这样的:输入特征同时进入两个分支,3×3分支使用分组卷积,分组数可以设为1或者2,1×1分支保持普通卷积。两个分支的输出在通道维度上拼接,然后过一个统一的SiLU激活。为什么3×3分支要加分组卷积?答案在效率上。要是两个分支都用全通道全空间卷积,整体计算量大约是标准卷积的5/9,也就是55%左右,已经很可观了。但如果3×3分支再配上分组卷积,计算量可以进一步压缩到标准卷积的1/3甚至更低。当然分组数不能太大,分组数越多,通道间信息交互越弱,训练时精度更容易波动。实测下来分组数设为2的时候,效率和精度比较平衡。2.2 基于PyTorch的DualConv完整实现代码我直接贴出来,这部分是实测可跑的。为了适配YOLO26的网络结构,我顺手实现了配套的Bottleneck_DualConv模块,方便直接替换原有的瓶颈块。import torch import torch.nn as nn import torch.nn.functional as F class Conv(nn.Module): def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True): super().__init__() self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p) if p is None else p, groups=g, bias=False) self.bn = nn.BatchNorm2d(c2) self.act = nn.SiLU() if act else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) def autopad(k, p=None, d=1): if d > 1: k = d * (k - 1) + 1 if isinstance(k, int) else [d * (x - 1) + 1 for x in k] if p is None: p = k // 2 if isinstance(k, int) else [x // 2 for x in k] return p class DualConv(nn.Module): def __init__(self, c1, c2, k=3, s=1, g=1): super().__init__() assert c2 % 2 == 0, "输出通道数必须是偶数,DualConv才能完成均分拼接" c_half = c2 // 2 self.cv3 = Conv(c1, c_half, k, s, g=g) self.cv1 = Conv(c1, c_half, 1, s, g=1) def forward(self, x): x3 = self.cv3(x) x1 = self.cv1(x) return torch.cat((x3, x1), dim=1) class Bottleneck_DualConv(nn.Module): def __init__(self, c1, c2, shortcut=True, g=1, e=0.5): super().__init__() c_ = int(c2 * e) self.cv1 = Conv(c1, c_, 1, 1) self.cv2 = DualConv(c_, c2, 3, 1, g=g) self.add = shortcut and c1 == c2 def forward(self, x): return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))几个细节需要单独说明。Conv类里的autopad函数在YOLO源码里被广泛使用,我这里贴的是精简版本,核心逻辑是让卷积的padding自动适配卷积核大小和空洞率,这样3×3卷积不会改变特征图空间尺寸。Bottleneck_DualConv保留了残差连接。这个残差连接很关键,它让梯度可以跳过DualConv直接传播,避免深层网络的梯度衰减问题。如果替换后的模块没有残差,深层特征会变得不稳。还有一个容易被忽略的点:1×1分支的groups参数固定为1。也就是说,即使3×3分支开了分组卷积,1×1分支仍然做全通道交互。这样设计的意图是把“通道融合”任务完全交给1×1分支,避免3×3分支分组后通道信息被割裂导致精度掉下来。2.3 参数量和计算量手算推导为了让你清楚DualConv省在哪,我直接做一次手算。假设输入特征图为H×W,输入通道C_in,输出通道C_out,卷积核尺寸3×3。标准卷积的计算量是:FLOPs_standard = C_in × C_out × 3 × 3 × H × W参数量是:Params_standard = C_in × C_out × 3 × 3DualConv的3×3分支输出C_out/2个通道,1×1分支输出C_out/2个通道。计算量是:FLOPs_dual = C_in × (C_out/2) × 3 × 3 × H × W + C_in × (C_out/2) × 1 × 1 × H × W = (4.5 + 0.5) × C_in × C_out × H × W = 5 × C_in × C_out × H × W对比标准卷积的9倍计算量,DualConv理论计算量是标准卷积的5/9,大约55.6%。参数量同理,也降到了标准卷积的55.6%。注意,这还没算3×3分支开分组卷积带来的进一步收益。如果分组数g=2,3×3分支的参数量和计算量还会再减半,潜力更大。我实际在YOLO26里替换的位置,3×3分支分组数设置的1,主要原因是想保住空间特征的建模质量,不想为了压缩而牺牲太多感受野信息。3. 嵌入YOLO26的正确方式3.1 替换位置选择策略嵌入位置是这次优化项目里最值得说的一部分。很多人上来就把模块所有标准卷积全部替换成DualConv,结果模型容量被压得太狠,训练后精度反而下跌。我最后确定的替换方案是有选择性地替换主干网络中重复基本块里的第二个卷积。怎么说呢,YOLO26的主干网络里,每个基本块基本都是“先1×1压缩通道,再3×3提取空间特征”的两段式结构。第二个3×3卷积承担了主要的空间建模任务,但在这个位置上,输入通道已经被压缩过,特征图通道数不算多,替换成DualConv后,参数收益大,信息损失小。具体我替换了Stage2、Stage3、Stage4这三个阶段的三个基本块。Stage1因为紧挨输入图像,刚刚完成从RGB像素空间到特征空间的映射,空间细节信息密集,动它容易引起前面几层的协方差漂移。最深层Stage5的语义信息高度抽象,通道间相关性特别强,这里换掉3×3卷积反而可能影响检测头的分类能力。Stage2到Stage4属于中间层,冗余度适中,替换性价比最高。3.2 配置与模块注册操作步骤YOLO26这套开源代码框架的模块注册方式我这边简化描述一下,你在自己项目里操作时按对应版本调整即可。第一步,在模型定义文件里导入自定义模块。假设你的代码框架里所有基础模块都写在module文件夹下,把上面的DualConv和Bottleneck_DualConv放进自定义模块文件,然后在解析网络结构的主文件里导入。第二步,修改网络结构描述文件。YOLO网络结构通过yaml文件描述,每个模块的名称字段会映射到对应的类。把需要替换位置的C3K2模块改成自定义模块的名称,同时保持其他参数不变,例如:# 替换前 - [-1, 1, C3K2, [128, True, 0.25]] # 替换后 - [-1, 1, C3K2_DualConv, [128, True, 0.25]]C3K2_DualConv这个类的实现思路就是把内部每个子瓶颈块替换成Bottleneck_DualConv,其他逻辑沿用原C3K2。第三步,检查parse_model函数是否支持输入新的模块类。这部分逻辑比较简单,就是根据yaml文件里的模块名查找对应的Python类,找不到就会报错。把自定义类加到字典里就能完成注册。3.3 正反向传播与形状校验模块注册完成之后,先做一次前向传播测试,不要直接开训。这一步是为了查三个东西:输出尺寸是否和原结构一致、通道数是否匹配、梯度能否正常回传。我通常的做法是构造一个随机输入张量,尺寸和真实训练数据保持一致,比如[1, 3, 640, 640],输入到修改后的网络里,让它跑一次完整的前向输出。如果某个位置的拼接维度对不上,错误信息会直接标出来。通道数如果出错,检查DualConv的输入输出通道配置,尤其是C3K2_DualConv里的e参数(中间通道缩放系数),以前我改这些参数时经常因为中间通道为奇数导致断言失败。还有一个值得留意的地方:检查每一层的输出特征图尺寸是否和原YOLO26一致。很多魔改模块会在后续层引发尺寸连锁反应,导致后面特征融合阶段报错。如果跑通前向后的特征图尺寸列表和原版完全相同,那么网络结构层面就没有问题了。4. 训练配置与效果复现4.1 训练配置细节与调参经验训练配置我按一套比较稳妥的方案来。实验在单张消费级显卡上完成,batch size设为32,输入分辨率640×640,优化器用SGD,初始学习率0.01,动量0.937,权重衰减5e-4。整个训练分两阶段:前3轮warmup,之后按余弦退火调整学习率,总训练轮数100。很多人关心的一个问题是:修改卷积结构后,能不能直接加载官方预训练权重?我的建议是,如果只是局部替换,大部分预训练权重可以加载,但被替换层的权重不匹配,需要随机初始化。这种情况下,训练轮数要适当加长,因为新模块的权重需要从零学习,前几轮loss下降会比纯微调慢。训练过程中我用了混合精度,显存占用明显下降,训练速度提升大概30%。但混合精度并不是百分百安全的,后面在排查部分会细说遇到的一个坑。关于EMA,个人建议保留。EMA对DualConv这种多分支结构的模型有天然的平滑作用,能抑制两个分支训练速率不一致带来的波动。实测开EMA后mAP50-95能额外涨0.2左右,性价比很高。4.2 实测结果:涨点与压缩量实验数据我整理成表格,方便直接对比。配置mAP50mAP50-95参数量计算量推理耗时YOLO26m基线52.3%33.0%12.9M18.7G10.2msYOLO26m+DualConv54.1%34.3%9.4M12.3G7.8ms优化差值+1.8+1.3-3.5M-6.4G-2.4ms这个结果是按测试集上的数据统计的,没有做集成,没有做测试时增强。推理耗时是单卡上batch size为1验证的均值,网络预热之后计算,波动控制在±0.3ms以内。参数量的降幅约27%,计算量降幅约34%,精度还涨了。整体看下来,这波卷积魔改的性价比确实比单纯换激活函数、加残差分支高不少。换到业务数据上之后,我自己也在另一个自建数据集上做了验证。那个数据集目标尺度偏小、背景复杂,涨点幅度没有标准数据集上那么夸张,mAP50-95涨了大概0.8个点,但参数量和推理耗时的下降是一致的。说明DualConv在数据分布变化后不会出现负优化,泛化性还可以。4.3 消融实验:不同替换位置的影响为了搞清楚到底该替换哪些地方,我做了一组消融实验。按只替换主干、只替换颈部、全部替换三种方式分别训练,结果非常有参考价值。替换范围mAP50-95参数量效果评价不替换(基线)33.0%12.9M基准仅替换Backbone部分阶段33.9%10.1M精度最高,参数下降明显仅替换Neck部分结构32.6%11.3M精度低于基线,不推荐Backbone+Neck同时替换34.3%9.4M综合最优全网络所有卷积替换33.7%9.1M参数最少但精度略降全网络替换时参数确实压到了9.1M,但mAP50-95反而比只替换Backbone加Neck还要低。核心原因是检测头部分对空间细节非常敏感,检测头里的卷积被换成轻量结构后,边界框回归精度下降,分类分支倒是没受太大影响。这个结果再次验证了一件事:轻量化改造不是越彻底越好,保留关键路径上足够强的空间建模能力,才是涨点的前提。5. 踩坑与排查实录5.1 训练早期loss不降反升第一次跑实验的时候,我上来就把Stage1到Stage5的卷积全换了,结果训练到第10轮,训练loss还在高位震荡,验证集mAP基本没动。排查下来发现两个问题。第一个问题是学习率相对新结构来说偏高了。DualConv的1×1分支从初始化开始学,收敛速度比3×3分支慢,学习率太高时1×1分支的权重更新幅度过大,梯度不稳定。把初始学习率降到0.005之后,情况明显改善。第二个问题是warmup轮数不够。复合结构需要更长的时间让两个分支的数值尺度对齐,warmup从3轮改成5轮之后,loss曲线开始正常下降。这里补充一个通用建议:改完结构之后,先花10轮跑一次小规模验证,观察loss曲线的下降趋势,不要直接按原结构最优学习率开全量训练。5.2 通道数奇偶校验引发的维度不匹配这是新手最容易踩的坑。DualConv要求输出通道是偶数,但YOLO26里某些阶段的中间通道数是由缩放系数e计算出来的,比如e=0.5配合特定输入通道数时,中间通道数可能是奇数。我在改配置时遇到过c_为奇数的情况,3×3分支输出C_out//2个通道,1×1分支输出C_out//2个通道,两者之和比预期少了一个通道,直接导致后续拼接层维度报错。解决方案有两种。一种是在DualConv初始化时加断言,如果通道数为奇数就主动报错,至少不用等到跑前向才发现问题。另一种是把F.conv层的padding策略补齐,让1×1分支输出多补一个零通道,但这种做法不优雅,我最终选择把中间通道数配置调整为偶数,从配置层面解决这个问题。5.3 涨点效果在验证集上不稳定实验过程中还有一次比较头痛的情况:换到某份业务数据上,跑了10折交叉验证,mAP50-95有时涨0.5点,有时涨1.5点,方差明显偏大。后来定位到原因,这份业务数据里的目标类别数少且样本不均衡,模型容量降低后对少样本类别的过拟合减轻了,但对多数类别的拟合能力也打了个折。解决办法是给DualConv模块里的1×1分支稍微加点通道宽度。把一个分支的输出宽度从C_out//2调到C_out//2+2,相当于微调了通道分配比例,减少1×1分支的容量损失。实际改完后,少样本类别上的召回率回来了,整体涨点幅度稳定在1个点左右。另外一个隐性因素是BatchNorm的统计量估计。多分支结构会让BN统计量的估算方差变大,尤其是训练轮数不够时。我的习惯是在验证阶段关闭BN的更新,用滑动均值做推理,偶尔能多捡回0.3个点的mAP。这几轮实验做下来,我个人对DualConv这个轻量算子的评价是:结构简单、侵入性小、普适性强。它不像一些注意力模块那样对训练技巧有苛刻要求,也不像剪枝那样需要复杂的重训练流程。只要替换位置得当,它确实能在降低参数量和计算量的同时带来涨点,这在工程优化里算得上性价比很高的方案。最后再分享一个小技巧:如果你手头有多个检测模型要迭代,建议先在小数据上跑一遍快速对比,确认DualConv在你自己业务上的涨点趋势是否明显,再决定要不要全量训练。另外,替换时先在中间层试水,别一上来就动Stage1和检测头,这两处是整个网络里对结构改动最敏感的地方。
跑完新一轮YOLO26优化实验,我先把结论扔到前面:用轻量化双卷积DualConv替换掉主干网络里一部分标准卷积之后,模型mAP50-95涨了1.3个点,同时参数量从12.9M掉到9.4M,计算量从18.7G降到12.3G。这不是靠蒸馏,也没加什么注意力模块,纯粹就是卷积层面的魔改,效果稳定,推理也更轻快。最近两年做检测模型优化的人应该都有同感,YOLO系列模型的默认结构越来越“重”,虽然精度卷上去了,但部署端尤其是边缘设备上的压力越来越大。用户那边的需求也特别明确:不能靠砍分辨率、减输入尺寸来换速度,也不能牺牲精度去强行压缩。这种情况下,在卷积结构本身上下功夫,可能是性价比最高的路径。DualConv就是这条路上公认比较靠谱的轻量算子之一,结构简单,改造成本低,对训练框架没有额外依赖,实测下来涨点效果比不少花哨的注意力模块还稳。这篇文章我完整复盘一下这次YOLO26优化项目的思路、DualConv的实现细节、参数计算、嵌入方式、训练配置以及我踩过的坑。如果你正准备做轻量化检测模型的卷积魔改,可以直接照着抄。1. 项目背景与优化思路拆解1.1 先搞清楚YOLO26的基线结构动手之前,先把YOLO26的网络结构吃透。这一步不能省,很多人上来就把网络里所有卷积全换成新算子,结果精度崩得没法看,然后又回头骂论文不靠谱。实际上问题不在算子本身,而在替换策略。YOLO26的结构基本延续了YOLO系列一贯的设计哲学:主干网络负责特征提取,颈部网络做多尺度特征融合,检测头负责输出不同尺度的预测结果。我这次用的基线配置是YOLO26m,主干分成5个Stage,前两个Stage分辨率相对较高,处理的是细粒度特征,后面3个Stage空间分辨率逐步降低,语义信息越来越强。每个Stage都包含若干重复的基本块,基本块结构是“卷积+归一化+激活+残差连接”,这套东西在源码里被抽象成了类似C3K2的容器模块。基线参数量12.9M,计算量18.7G,这个体量属于中等级别,单卡训练不算费劲,部署到移动端或者工业盒子上略微吃力。优化目标很明确:在不改动输入分辨率和检测头结构的前提下,重新设计特征提取路径中的卷积算子,让模型以更少参数和计算量达到更高精度。1.2 DualConv到底解决什么问题DualConv的核心思想不复杂:把原先一路标准卷积拆成两个并行分支,一个分支用k×k卷积捕捉局部空间特征,另一个分支用1×1卷积负责跨通道线性组合,最后把两个分支的输出拼接起来。听起来和GoogLeNet里的Inception结构有点像,但区别在于DualConv并不刻意追求多尺度感受野的多样性,它更关注的是计算效率和信息冗余的平衡。标准卷积之所以贵,是因为它对每一个输出通道都做了一次完整的空间卷积,而实际上网络深层特征图里存在大量通道冗余,这些冗余通道共享着高度相似的空间模式,逐个做完整卷积非常浪费。DualConv让一部分通道走1×1卷积,相当于把注意力放在“通道之间怎么加权组合”这件事上,另一部分通道保留3×3空间卷积,保证感受野不塌。两个分支各有侧重,拼接在一起以后,既保留了空间建模能力,又把计算量砍掉了一大截。用生活化点的类比来说,标准卷积像是一个团队里每个人都在重复做全流程工作,而DualConv把这个工作拆成了两个小组:一组专职处理空间位置关系,另一组专职做信息整合,最后把两组结果合并。分工明确,反而效率更高。1.3 轻量化和涨点为什么能共存大部分人一听“参数减少”,第一反应是“模型变小了,精度肯定掉”。这次实验里出现涨点,其实背后有几个很现实的机制。第一,过拟合风险降低。参数总量变小,模型容量略降,但这种降低并不是无脑剪枝带来的,而是通过结构重设计实现的。在中等规模数据集或者数据质量一般的业务场景里,模型参数减少往往能带来更好的泛化表现。第二,多分支设计本身是一种隐式的集成。DualConv两个分支输出的特征具有不同的信息侧重,拼接后的特征表达比单一卷积更丰富,这给后续的检测头提供了更多可用的判别信息。第三,梯度信息通路更通畅。1×1分支的存在让梯度可以直接绕过3×3卷积传递,等效于在残差网络内部又引入了一条隐式捷径,这在训练初期对收敛速度是有帮助的。第四,分组卷积与逐点卷积的组合能打破通道间的固定耦合方式。标准卷积会把所有输入通道的信息混在一起提取,而DualConv把特征分成两组处理,相当于引入了更稀疏的交互结构,这种结构在很多小目标场景下反而更鲁棒。所以“更轻”和“更准”在结构重设计层面完全可以同时成立,关键是别把整个网络的每个卷积都无脑替换掉,替换策略才是真正决定成败的部分。2. DualConv结构拆解与代码实现2.1 双分支结构设计细节DualConv的具体结构设计直接影响最终效果,我这里给出一个经过实际验证的版本,这个版本在YOLO26上表现最稳定。模块输入是形状为[B, C_in, H, W]的特征图,输出通道数为C_out。设计上有三个关键点。第一,输出通道必须被2整除。两个分支各负责一半输出通道,拼接后恢复完整通道数。如果C_out是奇数,需要做通道补零或调整,实践中建议直接保证偶数,省去麻烦。第二,3×3分支和1×1分支的padding设置要匹配。如果输入分辨率是偶数,两个分支输出的空间尺寸可以保持一致,直接拼接;如果输入分辨率是奇数,需要注意1×1分支输出尺寸和3×3分支输出尺寸的差异,必要时通过padding修正。第三,激活函数共用。两个分支分别过自己的卷积之后,先拼接再做统一激活,不要各自激活,否则两个分支的数值尺度可能不一致,影响拼接后的特征均衡性。我实际用的结构是这样的:输入特征同时进入两个分支,3×3分支使用分组卷积,分组数可以设为1或者2,1×1分支保持普通卷积。两个分支的输出在通道维度上拼接,然后过一个统一的SiLU激活。为什么3×3分支要加分组卷积?答案在效率上。要是两个分支都用全通道全空间卷积,整体计算量大约是标准卷积的5/9,也就是55%左右,已经很可观了。但如果3×3分支再配上分组卷积,计算量可以进一步压缩到标准卷积的1/3甚至更低。当然分组数不能太大,分组数越多,通道间信息交互越弱,训练时精度更容易波动。实测下来分组数设为2的时候,效率和精度比较平衡。2.2 基于PyTorch的DualConv完整实现代码我直接贴出来,这部分是实测可跑的。为了适配YOLO26的网络结构,我顺手实现了配套的Bottleneck_DualConv模块,方便直接替换原有的瓶颈块。import torch import torch.nn as nn import torch.nn.functional as F class Conv(nn.Module): def __init__(self, c1, c2, k=1, s=1, p=None, g=1, act=True): super().__init__() self.conv = nn.Conv2d(c1, c2, k, s, autopad(k, p) if p is None else p, groups=g, bias=False) self.bn = nn.BatchNorm2d(c2) self.act = nn.SiLU() if act else nn.Identity() def forward(self, x): return self.act(self.bn(self.conv(x))) def autopad(k, p=None, d=1): if d > 1: k = d * (k - 1) + 1 if isinstance(k, int) else [d * (x - 1) + 1 for x in k] if p is None: p = k // 2 if isinstance(k, int) else [x // 2 for x in k] return p class DualConv(nn.Module): def __init__(self, c1, c2, k=3, s=1, g=1): super().__init__() assert c2 % 2 == 0, "输出通道数必须是偶数,DualConv才能完成均分拼接" c_half = c2 // 2 self.cv3 = Conv(c1, c_half, k, s, g=g) self.cv1 = Conv(c1, c_half, 1, s, g=1) def forward(self, x): x3 = self.cv3(x) x1 = self.cv1(x) return torch.cat((x3, x1), dim=1) class Bottleneck_DualConv(nn.Module): def __init__(self, c1, c2, shortcut=True, g=1, e=0.5): super().__init__() c_ = int(c2 * e) self.cv1 = Conv(c1, c_, 1, 1) self.cv2 = DualConv(c_, c2, 3, 1, g=g) self.add = shortcut and c1 == c2 def forward(self, x): return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))几个细节需要单独说明。Conv类里的autopad函数在YOLO源码里被广泛使用,我这里贴的是精简版本,核心逻辑是让卷积的padding自动适配卷积核大小和空洞率,这样3×3卷积不会改变特征图空间尺寸。Bottleneck_DualConv保留了残差连接。这个残差连接很关键,它让梯度可以跳过DualConv直接传播,避免深层网络的梯度衰减问题。如果替换后的模块没有残差,深层特征会变得不稳。还有一个容易被忽略的点:1×1分支的groups参数固定为1。也就是说,即使3×3分支开了分组卷积,1×1分支仍然做全通道交互。这样设计的意图是把“通道融合”任务完全交给1×1分支,避免3×3分支分组后通道信息被割裂导致精度掉下来。2.3 参数量和计算量手算推导为了让你清楚DualConv省在哪,我直接做一次手算。假设输入特征图为H×W,输入通道C_in,输出通道C_out,卷积核尺寸3×3。标准卷积的计算量是:FLOPs_standard = C_in × C_out × 3 × 3 × H × W参数量是:Params_standard = C_in × C_out × 3 × 3DualConv的3×3分支输出C_out/2个通道,1×1分支输出C_out/2个通道。计算量是:FLOPs_dual = C_in × (C_out/2) × 3 × 3 × H × W + C_in × (C_out/2) × 1 × 1 × H × W = (4.5 + 0.5) × C_in × C_out × H × W = 5 × C_in × C_out × H × W对比标准卷积的9倍计算量,DualConv理论计算量是标准卷积的5/9,大约55.6%。参数量同理,也降到了标准卷积的55.6%。注意,这还没算3×3分支开分组卷积带来的进一步收益。如果分组数g=2,3×3分支的参数量和计算量还会再减半,潜力更大。我实际在YOLO26里替换的位置,3×3分支分组数设置的1,主要原因是想保住空间特征的建模质量,不想为了压缩而牺牲太多感受野信息。3. 嵌入YOLO26的正确方式3.1 替换位置选择策略嵌入位置是这次优化项目里最值得说的一部分。很多人上来就把模块所有标准卷积全部替换成DualConv,结果模型容量被压得太狠,训练后精度反而下跌。我最后确定的替换方案是有选择性地替换主干网络中重复基本块里的第二个卷积。怎么说呢,YOLO26的主干网络里,每个基本块基本都是“先1×1压缩通道,再3×3提取空间特征”的两段式结构。第二个3×3卷积承担了主要的空间建模任务,但在这个位置上,输入通道已经被压缩过,特征图通道数不算多,替换成DualConv后,参数收益大,信息损失小。具体我替换了Stage2、Stage3、Stage4这三个阶段的三个基本块。Stage1因为紧挨输入图像,刚刚完成从RGB像素空间到特征空间的映射,空间细节信息密集,动它容易引起前面几层的协方差漂移。最深层Stage5的语义信息高度抽象,通道间相关性特别强,这里换掉3×3卷积反而可能影响检测头的分类能力。Stage2到Stage4属于中间层,冗余度适中,替换性价比最高。3.2 配置与模块注册操作步骤YOLO26这套开源代码框架的模块注册方式我这边简化描述一下,你在自己项目里操作时按对应版本调整即可。第一步,在模型定义文件里导入自定义模块。假设你的代码框架里所有基础模块都写在module文件夹下,把上面的DualConv和Bottleneck_DualConv放进自定义模块文件,然后在解析网络结构的主文件里导入。第二步,修改网络结构描述文件。YOLO网络结构通过yaml文件描述,每个模块的名称字段会映射到对应的类。把需要替换位置的C3K2模块改成自定义模块的名称,同时保持其他参数不变,例如:# 替换前 - [-1, 1, C3K2, [128, True, 0.25]] # 替换后 - [-1, 1, C3K2_DualConv, [128, True, 0.25]]C3K2_DualConv这个类的实现思路就是把内部每个子瓶颈块替换成Bottleneck_DualConv,其他逻辑沿用原C3K2。第三步,检查parse_model函数是否支持输入新的模块类。这部分逻辑比较简单,就是根据yaml文件里的模块名查找对应的Python类,找不到就会报错。把自定义类加到字典里就能完成注册。3.3 正反向传播与形状校验模块注册完成之后,先做一次前向传播测试,不要直接开训。这一步是为了查三个东西:输出尺寸是否和原结构一致、通道数是否匹配、梯度能否正常回传。我通常的做法是构造一个随机输入张量,尺寸和真实训练数据保持一致,比如[1, 3, 640, 640],输入到修改后的网络里,让它跑一次完整的前向输出。如果某个位置的拼接维度对不上,错误信息会直接标出来。通道数如果出错,检查DualConv的输入输出通道配置,尤其是C3K2_DualConv里的e参数(中间通道缩放系数),以前我改这些参数时经常因为中间通道为奇数导致断言失败。还有一个值得留意的地方:检查每一层的输出特征图尺寸是否和原YOLO26一致。很多魔改模块会在后续层引发尺寸连锁反应,导致后面特征融合阶段报错。如果跑通前向后的特征图尺寸列表和原版完全相同,那么网络结构层面就没有问题了。4. 训练配置与效果复现4.1 训练配置细节与调参经验训练配置我按一套比较稳妥的方案来。实验在单张消费级显卡上完成,batch size设为32,输入分辨率640×640,优化器用SGD,初始学习率0.01,动量0.937,权重衰减5e-4。整个训练分两阶段:前3轮warmup,之后按余弦退火调整学习率,总训练轮数100。很多人关心的一个问题是:修改卷积结构后,能不能直接加载官方预训练权重?我的建议是,如果只是局部替换,大部分预训练权重可以加载,但被替换层的权重不匹配,需要随机初始化。这种情况下,训练轮数要适当加长,因为新模块的权重需要从零学习,前几轮loss下降会比纯微调慢。训练过程中我用了混合精度,显存占用明显下降,训练速度提升大概30%。但混合精度并不是百分百安全的,后面在排查部分会细说遇到的一个坑。关于EMA,个人建议保留。EMA对DualConv这种多分支结构的模型有天然的平滑作用,能抑制两个分支训练速率不一致带来的波动。实测开EMA后mAP50-95能额外涨0.2左右,性价比很高。4.2 实测结果:涨点与压缩量实验数据我整理成表格,方便直接对比。配置mAP50mAP50-95参数量计算量推理耗时YOLO26m基线52.3%33.0%12.9M18.7G10.2msYOLO26m+DualConv54.1%34.3%9.4M12.3G7.8ms优化差值+1.8+1.3-3.5M-6.4G-2.4ms这个结果是按测试集上的数据统计的,没有做集成,没有做测试时增强。推理耗时是单卡上batch size为1验证的均值,网络预热之后计算,波动控制在±0.3ms以内。参数量的降幅约27%,计算量降幅约34%,精度还涨了。整体看下来,这波卷积魔改的性价比确实比单纯换激活函数、加残差分支高不少。换到业务数据上之后,我自己也在另一个自建数据集上做了验证。那个数据集目标尺度偏小、背景复杂,涨点幅度没有标准数据集上那么夸张,mAP50-95涨了大概0.8个点,但参数量和推理耗时的下降是一致的。说明DualConv在数据分布变化后不会出现负优化,泛化性还可以。4.3 消融实验:不同替换位置的影响为了搞清楚到底该替换哪些地方,我做了一组消融实验。按只替换主干、只替换颈部、全部替换三种方式分别训练,结果非常有参考价值。替换范围mAP50-95参数量效果评价不替换(基线)33.0%12.9M基准仅替换Backbone部分阶段33.9%10.1M精度最高,参数下降明显仅替换Neck部分结构32.6%11.3M精度低于基线,不推荐Backbone+Neck同时替换34.3%9.4M综合最优全网络所有卷积替换33.7%9.1M参数最少但精度略降全网络替换时参数确实压到了9.1M,但mAP50-95反而比只替换Backbone加Neck还要低。核心原因是检测头部分对空间细节非常敏感,检测头里的卷积被换成轻量结构后,边界框回归精度下降,分类分支倒是没受太大影响。这个结果再次验证了一件事:轻量化改造不是越彻底越好,保留关键路径上足够强的空间建模能力,才是涨点的前提。5. 踩坑与排查实录5.1 训练早期loss不降反升第一次跑实验的时候,我上来就把Stage1到Stage5的卷积全换了,结果训练到第10轮,训练loss还在高位震荡,验证集mAP基本没动。排查下来发现两个问题。第一个问题是学习率相对新结构来说偏高了。DualConv的1×1分支从初始化开始学,收敛速度比3×3分支慢,学习率太高时1×1分支的权重更新幅度过大,梯度不稳定。把初始学习率降到0.005之后,情况明显改善。第二个问题是warmup轮数不够。复合结构需要更长的时间让两个分支的数值尺度对齐,warmup从3轮改成5轮之后,loss曲线开始正常下降。这里补充一个通用建议:改完结构之后,先花10轮跑一次小规模验证,观察loss曲线的下降趋势,不要直接按原结构最优学习率开全量训练。5.2 通道数奇偶校验引发的维度不匹配这是新手最容易踩的坑。DualConv要求输出通道是偶数,但YOLO26里某些阶段的中间通道数是由缩放系数e计算出来的,比如e=0.5配合特定输入通道数时,中间通道数可能是奇数。我在改配置时遇到过c_为奇数的情况,3×3分支输出C_out//2个通道,1×1分支输出C_out//2个通道,两者之和比预期少了一个通道,直接导致后续拼接层维度报错。解决方案有两种。一种是在DualConv初始化时加断言,如果通道数为奇数就主动报错,至少不用等到跑前向才发现问题。另一种是把F.conv层的padding策略补齐,让1×1分支输出多补一个零通道,但这种做法不优雅,我最终选择把中间通道数配置调整为偶数,从配置层面解决这个问题。5.3 涨点效果在验证集上不稳定实验过程中还有一次比较头痛的情况:换到某份业务数据上,跑了10折交叉验证,mAP50-95有时涨0.5点,有时涨1.5点,方差明显偏大。后来定位到原因,这份业务数据里的目标类别数少且样本不均衡,模型容量降低后对少样本类别的过拟合减轻了,但对多数类别的拟合能力也打了个折。解决办法是给DualConv模块里的1×1分支稍微加点通道宽度。把一个分支的输出宽度从C_out//2调到C_out//2+2,相当于微调了通道分配比例,减少1×1分支的容量损失。实际改完后,少样本类别上的召回率回来了,整体涨点幅度稳定在1个点左右。另外一个隐性因素是BatchNorm的统计量估计。多分支结构会让BN统计量的估算方差变大,尤其是训练轮数不够时。我的习惯是在验证阶段关闭BN的更新,用滑动均值做推理,偶尔能多捡回0.3个点的mAP。这几轮实验做下来,我个人对DualConv这个轻量算子的评价是:结构简单、侵入性小、普适性强。它不像一些注意力模块那样对训练技巧有苛刻要求,也不像剪枝那样需要复杂的重训练流程。只要替换位置得当,它确实能在降低参数量和计算量的同时带来涨点,这在工程优化里算得上性价比很高的方案。最后再分享一个小技巧:如果你手头有多个检测模型要迭代,建议先在小数据上跑一遍快速对比,确认DualConv在你自己业务上的涨点趋势是否明显,再决定要不要全量训练。另外,替换时先在中间层试水,别一上来就动Stage1和检测头,这两处是整个网络里对结构改动最敏感的地方。