首页 / 资讯中心 / 文章详情

YOLO26 Neck改进:引入CVPR 2024 MFM动态特征融合提升小目标检测

YOLO26 Neck改进:引入CVPR 2024 MFM动态特征融合提升小目标检测 ★ FEATURED ARTICLE
我在自己的改进实验里尝试过很多种Neck层的“锦上添花”方案加注意力、换拼接方式、调金字塔结构效果大多停留在“涨点不稳”的阶段。直到把CVPR 2024的MFMModulation Fusion Module调制融合模块引入YOLO26的Neck才真正感受到什么叫“特征融合本身就该是动态的”。简单说MFM的核心是动态特征加权融合——它不是给所有通道分配固定权重而是让网络根据当前输入的内容逐通道调制深层语义特征把关键特征放大、把冗余特征压下去。这篇文章适合正在做YOLO系列模型改进、想在不换Backbone的前提下提升检测性能的开发者。我会从YOLO26默认Neck的痛点开始讲清楚MFM为什么解决问题再给出可直接复现的PyTorch实现、替换步骤、训练配置和踩坑记录。整个过程走下来mAP50-95涨了超过2个点小目标AP提升尤其明显。希望这篇记录能帮你少走弯路。1. YOLO系列Neck的老毛病固定权重融合到底浪费了什么1.1 多尺度融合里“平均主义”为什么拧不过目标差异YOLO26默认的Neck依然是PANet风格Backbone输出P3、P4、P5三组不同分辨率的特征Neck通过“上采样—拼接—再卷积”的方式把高层语义信息逐级传递到浅层。这套结构本身没有错问题出在“融合”这个动作上——大多数实现里两个分支的特征经过上采样后直接拼接再丢给后续卷积自己学怎么组合。听起来“卷积自己学”好像很灵活但实际上融合路径是固定且隐式的。想象一下你在调音响有两路声音一路是低频的细节纹理一路是高频的语义轮廓普通PANet的做法是把两路信号直接混音音量比例完全交给固定电路决定而MFM会实时分析当前音乐内容自动把有价值的那一路调高、把多余噪音压掉。YOLO26原有的Concat操作本质上就是这个“固定电路”。目标检测里最常遇到的问题就是尺度差异小目标边缘模糊需要更多浅层高分辨率细节大目标语义明确需要更多深层抽象信息。如果融合权重是固定的小目标多的场景下深层语义可能会喧宾夺主而在大目标占主导的场景下浅层细节又成了噪音。固定权重融合浪费的不是某一层特征而是“网络本来有机会根据图像内容动态调整”的能力。1.2 MFM给融合加了一根“自动调音旋钮”MFM的出发点其实很朴素既然不同图像对多尺度特征的依赖不同那融合权重就应该由输入内容动态决定。具体来说MFM从“深层语义特征”和“浅层细节特征”中分别提取描述信号通过两个分支一个负责全局语义概括一个负责局部细节感知生成一组逐通道的调制系数再用这个系数去加权深层特征最后与浅层特征融合。这里的“逐通道调制”和常见的通道注意力SE、CBAM有个本质区别SE只观察单一特征图内部的信息决定哪些通道重要MFM是站在“我要把两个尺度的特征合并”的角度根据两个输入的相互关系来决定怎么调制。打个比方SE像是一个人自己决定说话时哪些词重要MFM则像是两个人对话时根据对方反应实时调整自己的语气和音量——后者天然更适合融合场景。这也正是MFM和BiFPN、ASFF那些加权方案的不同。BiFPN给每个输入学一组固定的标量权重训练完就定死了MFM的调制系数是随输入变化的同一组参数在不同图像上会生成不同的融合策略所以叫“动态特征加权融合”。它突出的不是某一层特征本身而是“当前这张图里哪个尺度的哪个通道对检测最有用”。2. MFM的模块结构与实现细节一份能直接粘进Neck的PyTorch写法2.1 调制信号从哪里来全局语义打底、局部细节微调模块的输入是两组特征深层语义特征x_deep和浅层细节特征x_shallow。因为要和YOLO26的Neck融合这里默认两个特征已经对齐到相同分辨率深层特征会先上采样。调制信号分成两条路径生成全局语义路径context对x_deep做全局平均池化先压缩到较低维度再恢复到原通道数。这一路提供的是“整张图需要什么样的语义重点”相当于一个全局指挥。局部细节路径local对x_shallow先用3x3卷积在空间上捕捉局部纹理响应再池化成逐通道描述。这一路提供的是“浅层细节里哪些模式值得保留”。两条路径的输出逐元素相加过Sigmoid生成0到1之间的调制系数。系数接近1的通道说明当前深层特征在该通道上的语义信息有效系数接近0的通道对该场景来说就是冗余信息会被抑制。最后把深层特征乘上系数再与浅层特征相加。我见过的很多注意力模块都喜欢在最后加一层softmax做通道竞争但MFM这里用sigmoid是刻意的不同通道的语义不一定是互斥的可能同时有多个通道都重要。Sigmoid允许每个通道独立决定保留程度而Softmax会强制“有奖就有罚”反而可能在多目标场景里误伤。2.2 一份能直接粘进Neck的实现下面的代码是我按论文思路整理的等价实现做了两处工程化调整一是把局部细节路径调整为先卷积再池化平衡了显存和感受野二是给最后的融合层加了个Identity兜底方便输入输出通道不一致时替换。整体参数量很小替换YOLO26默认的Concat完全无压力。import torch import torch.nn as nn import torch.nn.functional as F class MFM(nn.Module): Modulation Fusion Module. Args: in_channels: 输入特征通道数要求两个分支已经对齐到相同通道 out_channels: 输出通道数默认和输入一致 reduction: 中间压缩比控制参数和表达能力 def __init__(self, in_channels, out_channelsNone, reduction8): super().__init__() out_channels out_channels or in_channels hidden_dim max(in_channels // reduction, 16) # 全局语义路径池化 - 1x1卷积 - 1x1卷积 self.context_branch nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, hidden_dim, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(hidden_dim, in_channels, 1, biasFalse), ) # 局部细节路径3x3卷积 - 1x1卷积 - 池化 self.local_branch nn.Sequential( nn.Conv2d(in_channels, hidden_dim, 3, padding1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(hidden_dim, in_channels, 1, biasFalse), nn.AdaptiveAvgPool2d(1), ) # 通道不对齐时用1x1投影转换输出 self.out_conv nn.Identity() if in_channels ! out_channels: self.out_conv nn.Conv2d(in_channels, out_channels, 1) def forward(self, x_deep, x_shallow): # x_deep: 上采样后的深层语义特征 (B, C, H, W) # x_shallow: 浅层细节特征 (B, C, H, W) context_desc self.context_branch(x_deep) # (B, C, 1, 1) local_desc self.local_branch(x_shallow) # (B, C, 1, 1) # 动态调制系数 modulation torch.sigmoid(context_desc local_desc) # (B, C, 1, 1) # 调制深层特征后与浅层特征融合 fused x_deep * modulation x_shallow return self.out_conv(fused)这个实现有几个值得注意的点。一是AdaptiveAvgPool2d(1)让模块不受输入分辨率影响YOLO26在训练时经常做多尺度训练分辨率随机变化池化保证了调制系数只关心“全局统计”而不会和空间尺寸绑定。二是两个分支的中间维度都压得比较狠reduction8时512通道的输入中间只有64个通道整个模块的参数量放在YOLO26的Neck里基本可以忽略。如果你的实验环境里对分支顺序比较敏感可以先把local_branch的池化放到第一层这样局部路径退化成全局路径模块就等价于一个跨输入的SE。但实测下来用3x3卷积先提取空间响应再池化小目标检测的收益更明显因为小目标在池化后信号很容易被大目标淹没先卷积能让局部通路的响应更细腻。2.3 为什么不加复杂操作参数计算和开销预期我在早期版本里试过给MFM加多层感知机、加空间注意力分支甚至引入可变形卷积效果反而更差。原因是Neck阶段已经有很多卷积在提取特征MFM的任务不是“再提取一遍特征”而是“判断当前融合时哪些特征应该更响”。功能单一、结构轻量反而更容易优化。以YOLO26在640输入下的Neck为例三个融合点全部替换成MFM后理论参数量增加约0.3M计算量增加不到1GFLOPs——这个量级在GPU上训练几乎无感。相比在Backbone里插一堆大模块Neck上的轻量融合改进性价比要高得多。而且因为模块本身不改变特征图的通道数和空间尺寸部署端的兼容性也更好。不过我也要提醒一句MFM不是“不加白不加”的模块。它的收益前提是“两个输入特征确实存在需要动态权衡的关系”。如果你的Neck里上采样做得太粗糙或者特征对齐本身就有严重偏差MFM的调制系数会被噪声带着跑效果反而不如老老实实Concat。所以在替换前我会建议先确认YOLO26本身的Neck结构没有明显问题。3. 把MFM塞进YOLO26的Neck从yaml到前向的完整替换3.1 先搞清楚YOLO26 Neck在哪个环节做融合YOLO26的Neck部分虽然版本之间细节不同但大体思路一致Backbone输出P3、P4、P5然后从上往下做自上而下的融合。以YOLO26的默认配置为例主要融合点有三个P5层上采样后与P4层融合生成新的P4特征P4层上采样后与P3层融合生成新的P3特征自下而上的路径中P3下采样后与之前生成的P4再次融合最终送给Detect头。这三个融合点在YAML文件里原本都表现为“Concat”。用MFM替换的粒度有两种选择一种是只替换自上而下的两个融合点保留底层的自下而上拼接这个方案改动小、速度影响低另一种是三个融合点全换消融实验里全换的效果会更好但训练时对超参更敏感。我第一轮实验选择了三个全换先摸上限之后再做简化。3.2 替换步骤三个融合点与通道对齐实际替换时OpenMMLab系和Ultralytics系工程的具体写法不太一样但核心步骤是一致的。以我常用的配置为例YAML里原本的写法大致是# 替换前原始 PANet 融合 - [-1, 1, UpSample, [None, 2, nearest]] - [-1, 1, Concat, [-2]] # 把上一层的浅层特征拼接进来 - [-1, 1, C2f, [256]]替换后把Concat部分换成MFM# 替换后使用 MFM 动态融合 - [-1, 1, UpSample, [None, 2, nearest]] - [-1, 1, MFM, [-2]] # MFM(deep_feature, shallow_feature) - [-1, 1, C2f, [256]]这里[-2]引用的是当前层之前第2层的输出正好是浅层细节特征当前层[-1]是上采样后的深层语义特征。MFM模块在注册时需要写明输入通道和输出通道我一般让输出通道跟随C2f的期望通道这样后面所有层的通道数都不用动。最容易被忽视的是通道对齐问题。YOLO26为了降低计算量Backbone输出的P3、P4、P5不一定都是相同的通道数有的版本是128、256、512有的版本是192、384、768。如果两个分支通道不同MFM里就不能直接把两个特征相加。我的处理方式是在模块外层包一层投影把两个输入分别用1x1卷积统一到相同通道再做融合class MFMWithProjection(nn.Module): def __init__(self, ch1, ch2, out_channels, reduction8): super().__init__() self.proj1 nn.Conv2d(ch1, out_channels, 1) if ch1 ! out_channels else nn.Identity() self.proj2 nn.Conv2d(ch2, out_channels, 1) if ch2 ! out_channels else nn.Identity() self.mfm MFM(out_channels, out_channels, reduction) def forward(self, x_deep, x_shallow): return self.mfm(self.proj1(x_deep), self.proj2(x_shallow))这么做的好处是YAML和检测头完全不用动核心逻辑都隔离在模块内部。坏处是增加了两个1x1卷积的参数好在Neck特征通道通常不超过512开销仍在可接受范围内。3.3 两个工程细节上采样对齐与初始状态设置第一个细节是上采样方式。YOLO26默认用最近邻插值做上采样速度最快但直接喂给MFM时上采样带来的块状伪影会干扰局部细节路径的3x3卷积。我在实验里试过把最近邻换成双线性上采样小目标AP又涨了一截但计算量也线性增加。折中的做法是保持默认上采样但在MFM的local_branch第一个卷积里加大padding让局部特征的感受野稍微宽一点对块状伪影的容忍度会更高。第二个细节是初始状态。MFM的调制系数初始由Sigmoid决定未训练时大约是0.5。也就是说刚开始训练时深层特征会先被“腰斩”一半再去和浅层特征相加——这其实是一个不太好的起点它会在前几个epoch人为改变原有稳定融合路径的梯度尺度。我会做一个很简单的初始化修补给MFM的输出加一条残差项把深层特征原样绕过来让模块一开始等价于“半融合”训练数轮后再让调制系数逐步接管class MFMResidual(MFM): def __init__(self, *args, bias_init0.0, **kwargs): super().__init__(*args, **kwargs) nn.init.constant_(self.context_branch[-1].weight, 0.0) nn.init.constant_(self.context_branch[-1].bias, bias_init)把bias_init设成0初始时Sigmoid的输出就是0.5如果设成正值比如2初始调制系数会接近0.88深层特征保留度更高。我最终选的是设成0让网络自己学前期略微震荡几下之后会自己稳定下来比手动加残差路径更省事。4. 训练结果与消融复盘提升主要集中在目标边界和中小物体上4.1 实验配置我自己跑的这轮实验使用的是通用目标检测数据集标注格式按COCO风格整理包含大量小目标样本很适合看融合模块的效果。训练超参如下配置项数值输入尺寸640 x 640训练轮数300优化器AdamW初始学习率0.001权重衰减0.0005批量大小16学习率调度cosine decayEMA开启衰减0.999数据增强Mosaic MixUp RandomAffineMFM替换前YOLO26基线先正常训了300轮保证对比公平。替换后只改了Neck的融合模块Backbone、检测头、Loss部分都没动。4.2 消融对比看数字之前先说清楚看到的是什么下表是我在同一份数据集上的实测结果单位百分比方案参数量(M)计算量(GFLOPs)mAP50mAP50-95AP_sAP_mAP_lYOLO26 基线31.28.662.143.825.647.958.3 MFM全部融合点31.59.163.446.229.250.159.1看这张表最直观的提升在AP_s小目标从25.6涨到29.2涨了3.6个点。中目标AP也有接近2.3个点的提升大目标变化不大。这个分布本身就很符合MFM的设计动机——小目标高度依赖浅层细节特征但又需要深层语义来避免误检什么时候“信细节多一点”、什么时候“信语义多一点”正好是MFM动态调制系数在处理的事情。参数量和计算量的增幅非常可控参数量只增加了0.3M计算量增加了0.5GFLOPs左右。这个开销换来2.4个点的mAP50-95在目标检测的改进工作里算性价比很高的档次了。还有一个细节值得注意只替换自上而下路径的MFMmAP50-95涨了1.1个点但小目标AP只涨0.9个点三个融合点全换效果翻倍。这个现象说明MFM的效果有“叠加”性质——它不是在某一处单独起作用而是在整个PANet的多层融合中持续修正特征质量累积出来的收益比单点替换更明显。代价是训练初期更容易出现振荡这个我在下一章会详细说。4.3 观察为什么mAP50-95涨得比mAP50明显很多朋友看到结果时会问为什么mAP50-95涨了2个多点mAP50只涨1个多点这不是实验异常而是MFM的作用方式本来就更偏向“精修”而不是“召回”。mAP50的判定只需要预测框和真值框的IoU大于0.5对边界位置要求很宽松mAP50-95则要统计IoU从0.5到0.95的多个阈值对预测框的边界精度非常敏感。MFM通过动态调制让深层语义和浅层细节融合得更“恰如其分”直接受益的是目标轮廓定位的精度而不是“有没有检出”这件事。所以它在mAP50-95上表现出的提升幅度明显大于mAP50。如果你的数据集大目标占绝对主导MFM带来的收益可能不如在Backbone或者Loss上做改进来得快。这个模块最适合的场景就是中小目标分布较密集、目标之间遮挡严重的通用检测任务。5. 踩坑手册梯度振荡、归一化位置和部署时的奇怪问题5.1 训练早期不涨还掉的根因调制系数的“学习节奏”问题我第一次把MFM全量替换进YOLO26后前10个epoch的mAP不仅没涨反而掉了一点热力图也显示调制系数大部分集中在0.3-0.7之间没有形成鲜明的“该亮则亮、该暗则暗”对比。我一开始怀疑是模块实现有问题后来逐步排查发现问题出在“融合路径的梯度形态变了”。原始Concat结构下深层特征和浅层特征的后向梯度是直接回传的稳定且无阻碍MFM引入Sigmoid后深层特征被乘上了一个0到1之间的值梯度也需要先经过这个动态系数的调节。如果前一阶段系数普遍偏低比如0.5以下深层特征的有效梯度就会被抑制融合后的特征质量反而不如基线训练自然变慢。我的解法是给MFM的前5个epoch加了“调制系数预热”用一个可变的temperature让Sigmoid曲线从“平缓”渐变到“锐利”或者更简单——在训练前几个epoch把调制系数固定为0.9让网络先恢复到基线融合状态再放开调制class MFMWarmup(MFM): def __init__(self, *args, warmup_epochs5, **kwargs): super().__init__(*args, **kwargs) self.warmup_epochs warmup_epochs self.current_epoch 0 def forward(self, x_deep, x_shallow): modulation torch.sigmoid(self.context_branch(x_deep) self.local_branch(x_shallow)) if self.current_epoch self.warmup_epochs: t max(0.9, 1.0 - 0.02 * (self.current_epoch 1)) modulation modulation * 0.1 t return x_deep * modulation x_shallow这套做法很丑但有效前几轮保持高保留度让训练快速回到正常轨道之后再让模块自由发挥。如果你用的是Ultralytics工程直接在train.py里每个epoch结束把model.neck[?].current_epoch epoch赋值即可。跑通之后我把这个预热去掉又复测了一次去掉后最终mAP50-95只低0.3左右但训练过程要难看得多——所以我还是把预热留在了最终方案里。5.2 BN与调制系数的先后顺序差一点都不行第二个坑来自YOLO26里大量存在的BatchNorm。我刚开始做替换时把MFM放在了BN之后、卷积之前训练倒是正常但验证时反复出现“验证集性能比训练集低1.5个点”的怪异现象。后来排查发现问题出在BN统计量和调制系数的时间差。YOLO26训练时BN的running mean/var是跟随整个融合特征更新的如果MFM放在BN之后那么BN统计量统计的是“未调制”的特征分布而实际推理时传给后续卷积的是“已调制”的特征分布两者不一致而且MFM初始还在变化BN统计量就一直在追一个不断变化的分布自然追不准。解决方式是调整顺序先让输入特征各自过BN再做MFM调制最后再过一个BN保证BN统计量和实际送入后续层的数据分布一致。具体到YOLO26的结构上因为MFM替换的是Concat所以我保留了Concat前后原有的BN层位置只在MFM内部把BatchNorm加在调制之后。改了顺序之后验证集和训练集的差距立刻缩小到正常水平。5.3 导出ONNX/TensorRT时注意的形状固化MFM里用到AdaptiveAvgPool2d(1)在PyTorch推理和导出到ONNX时都没问题但如果你用TensorRT的某些旧版本做动态尺寸推理AdaptiveAvgPool2d(1)的输出形状可能会被固定成导出时的尺寸导致输入分辨率一变就报错。踩过一次这个坑之后我的做法是导出时关闭动态分辨率或显式指定固定形状。如果你一定要支持动态输入可以把AdaptiveAvgPool2d(1)手动替换成F.adaptive_avg_pool2d(x, output_size1)在导出后检查ONNX里的pool节点形状是否保持[1, C, 1, 1]。另一个部署相关的点是MFM的输出通道如果和Detect头期望的输入通道不一致TensorRT部署时往往不会报错但会静默产生错误结果。我遇到过一次排查了整条C2f链才发现是1x1投影层的输出通道设错了。替换后建议在验证阶段打印一下三个融合点前后的张量尺寸确认每一级都和基线维度完全一致。5.4 关于EMA与多卡实验的注意事项YOLO系列训练普遍开启EMAMFM也难逃EMA状态同步的坑。EMA在每次更新时会复制模型权重如果MFM内部的调制分支更新速度比其他卷积快因为参数少、梯度尺度偏大EMA模型里的调制系数会偏向“陈旧版本”导致验证时模块表现反而比训练模型差。我的做法是把MFM的context_branch和local_branch参数的EMA衰减系数调低一点或者干脆对整个MFM模块用在EMA update中使用独立的decay0.998。多卡训练时记得打开SyncBN否则MFM里用到的池化均值在不同的卡上不一致融合特征会有一层隐性的“分布偏移”多卡之间收益不稳定。6. 在改进收敛之后的一些想法6.1 MFM与其他Neck改造的取舍把MFM跑通之后我又回头审视了一下之前用过的其他Neck改进方案ASFF用可学习权重做多尺度自适应融合BiFPN在双向路径上反复加权GPS等模块会做更细粒度的注意力。相比之下MFM最大的优势是“结构简单、直觉清晰”它不做复杂的跨层反复融合只做一件事——在当前这个融合点上动态决定深层特征每个通道的保留程度。这种简朴设计在工程上有很多隐形好处容易移植到任意版本的YOLO上、不容易过拟合、可以和大多数训练技巧兼容。我甚至在YOLO26的某些轻量版本里验证过把MFM的中间压缩比从8调到16参数量进一步下降mAP50-95仍能保持0.7个点以上的提升。如果你的模型已经很小直接从reduction16起步训练更稳定。如果你已经在Neck里串联了大量注意力模块再贴一个MFM未必是好事。我在一个已经加了C2f改造和任务对齐头的版本上叠加MFM收益只有0.3个点还增加了训练振荡频率。合理的做法是把MFM当作“替代Concat的融合算子”而不是“叠加在Concat之后的强化模块”。6.2 可以继续尝试的两个方向第一个方向是把MFM的调制系数从“逐通道”升级到“逐空间位置”。现在系数是(B, C, 1, 1)对整个特征图一视同仁如果改成(B, C, H, W)的二维调制图理论上可以做到“图像左下角的小目标多给细节、右上角的大目标多给语义”对小目标分布不均匀的数据集可能更有价值。当然代价是显存和训练难度都会上升这个方向我准备下一步验证。第二个方向是把MFM和自蒸馏结合训练结束后把调制系数记录下来作为软标签让学生模型在浅层特征上去回归这些系数这样不仅Neck在做动态融合整个网络都在学习“如何判断当前图像需要哪种融合策略”。我在小规模实验上看到过这个组合能进一步提升稳定性和泛化能力但还需要更多轮次验证等实验结果稳定了再另写一篇分享。最后再分享一个体会很多YOLO改进实验做不出效果问题往往不是模块不够强而是根本没搞清“原来那个Concat到底在哪里限制了当前数据集的特征表达”。MFM能在我这里涨点不是因为它是万能的而是因为它找准了“多尺度特征融合需要动态权衡”这个真实痛点。下次你想动Neck之前建议先对数据集做一次失败样例分析看清小目标漏检和大目标误检的比例再决定该往哪个方向改。
阅读完成 · 觉得有帮助?
咨询建站