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

ES-MoE融入YOLOv11:提升复杂背景与小目标检测性能

ES-MoE融入YOLOv11:提升复杂背景与小目标检测性能 ★ FEATURED ARTICLE
做检测作品改的人基本都遇到过这一幕模型在干净场景下AP很高一换到有树林、乱石、广告牌这些“脏”背景mAP立刻掉一截或者小目标的AP惨淡到可以忽略密集人群里漏检一个接一个。你可能会觉得是网络不够深、通道不够宽于是去堆FLOPs但结果往往是训练时间翻倍涨点有限。真正的问题在于——无论输入图像里的目标是简单还是复杂、是大还是小网络主干都在用一套完全一样的计算图去处理每一个位置。YOLOv11在复杂背景、小目标或密集目标场景中表现不稳定的一个直接原因就在这里算力分配太“均匀”了。这几天我主要在做的工作是把 YOLO-Master 里 ES-MoE 的高效稀疏专家混合模块搬进 YOLOv11 的 C3k2 结构里核心目的是让网络在不同特征位置自适应选择不同的特征增强路径用来提升复杂背景、小目标和密集目标场景的特征表达能力。这篇文章把整个过程的设计思路、代码实现、训练调参和踩坑记录都整理出来适合两类人一类是正在做目标检测方向改进和实验设计的研究者另一类是希望在不更换backbone的前提下提升工程模型鲁棒性的开发者。1. 固定算力分配复杂背景下表现起伏的根因1.1 YOLOv11的C3k2在实际推理时做了什么YOLOv11沿用了C3k2作为主干的基本计算单元。一个标准的C3k2块包含若干Bottleneck每个Bottleneck内部通常是1×1 Conv降维、3×3 Conv做特征交互然后和一个shortcut相加。这个设计本身是高效的无论是在P5尺度还是P3尺度它都保持了比较稳定的计算开销。但问题也出在这套结构的高度“统一”上。每个Bottleneck对特征图上的每一个位置都执行完全相同的运算不管这个区域是目标边缘还是大片背景不管是小目标还是十几米外的车辆残影卷积核都花相同的算力来做同样的事。这种情况我一般叫它“固定计算图”——网络结构定下来之后不同输入在不同位置上享受的处理流程就完全一致了。这会带来一个隐患一个3×3卷积核在局部窗口内只有9个采样点想同时承担“提取目标高响应”和“抑制背景纹理”两个任务参数就会趋向中间态——对前景不够敏感对背景也没有能力压制。YOLO的C3k2虽然能通过加深Bottleneck数量来缓解但在复杂背景下这种缓解是很有限的因为到了深层特征背景和目标已经混在了同一个空间位置里普通卷积很难在同一个位置同时完成“分离”和“增强”两件事。1.2 三个高频失效场景逼近同一瓶颈看几个具体场景就清楚了。第一个是复杂背景。草木、水面、密集纹理这类背景局部特征和高频噪声混在一起。目标一旦和背景颜色接近比如迷彩装备、枯叶里的虫子C3k2里的3×3卷积会在局部窗口内同时看到目标和背景的两类模式最后学出来的卷积核往往是“都有一点响应”等于两头都没做好。第二个是小目标。小目标的有效token数量太少在深层特征图里可能只剩几个像素。常规卷积在做特征聚合时这些少量强响应对输出的贡献会被周围大量背景token稀释。更麻烦的是如果门控或者注意力机制是全局算的小目标的响应几乎淹没在统计量里深层骨干对它几乎“视而不见”。第三个是密集目标。相邻目标边界重叠每个位置可能同时要表达两个目标的上下文。普通3×3卷积只有9个采样点想让它在同一个感受野里既分离两个重叠目标又保持各自的细节实在有点强人所难。这三种场景看起来完全不同但本质都指向同一件事主干缺少一条“按需分配计算”的路径。ES-MoE的思路正是针对这一点来的——先判断当前特征需要什么样的处理再调用对应专家完成增强而不是所有位置走同一流程。2. ES-MoE的三个核心设计专家划分、稀疏门控与负载均衡2.1 专家划分为什么四个方向就够我在实际操作里默认配置是4个专家。不是说专家越多越好而是4个方向已经能覆盖目标检测里最常见的特征增强诉求。下面是我在YOLOv11里默认用的划分方式专家结构方向主要负责Expert 0Bottleneck压缩型降维去噪提炼主要语义Expert 1扩展交互型捕捉细节之间的组合关系Expert 2高频锐化型小目标、边缘的高响应增强Expert 3身份映射型保留原始特征防止过度重构专家内部我尽量不引入卷积而是用Linear层。原因是ES-MoE的路由是逐token做的专家如果带3×3卷积token选择和计算分离会很麻烦部署时也不太好优化。局部信息在进入ESMoE模块之前已经由一个共享的轻量卷积处理过一遍这样既保留了卷积的空间表达能力又让门控和专家拿到相对独立的梯度。为什么是4而不是8或16我自己的实验感受是在YOLOv11主干里专家数量一多gate很难训练前期大部分时间都花在“探索到底该走哪条路”上收敛速度明显变慢。专家太少又起不到分工效果。4个是一个平衡点后续你可以再往6个、8个方向试但最好保证每个专家都有自己的结构差异而不是复制堆叠。2.2 稀疏门控如何保证“只选该选的”ES-MoE里的“稀疏”主要体现在门控上。整个流程是共享卷积提取局部线索1×1卷积生成每个空间位置对应各专家的logits经过softmax得到概率再用top-k只保留少数专家。比如top_k1时每个token最终只走一个专家其余专家完全不需要计算。这会让模型被迫给不同位置分配不同的处理路径而不是所有位置都做一个加权平均。如果去掉topk让所有专家都参与加权最终结果相当于一个巨大的MLP模型会退化回固定计算图专家分工的意义也就没了。我实测下来top_k1的情况最稳定因为每个token只走一条路径gate的决策压力最小梯度也最干净。top_k2时模型的表达能力会强一点但计算量几乎翻倍而且某些token同时走两个专家后特征容易变得“四不像”。除非你目标场景非常复杂否则建议从top_k1开始。2.3 负载均衡辅助损失防止路由坍缩MoE结构有一个经典问题叫路由坍缩训练初期gate很容易把所有token都丢到某一个专家上其他专家完全不更新长此以往模型只是“练了其中一个专家”其他专家形同虚设。解决方式是加一个负载均衡辅助损失形式可以写成aux_loss E * sum_i ( f_i * P_i )其中E是专家数f_i是专家i实际接收的token占比P_i是gate输出中专家i的平均概率。这个loss鼓励不同专家的使用率尽可能均匀但不会强制它们完全一样只是让老是被冷落的专家也能拿到梯度。这个辅助损失在ESMoE模块里直接算出来然后并入YOLOv11总loss。我一般把权重设置在0.01左右。权重太小起不到均衡作用权重太大又会干扰检测主loss导致AP整体下降。2.4 动态路由位置信息与尺度先验只看内容特征做路由还不够稳。原版思路里有个“动态”的概念我在落地时理解成路由决策应该参考当前特征所在的尺度位置。我的处理方法是gate输出分支的1×1卷积可以额外拼接当前stage的下采样倍数或输入尺寸归一化信息。这样做等于给网络一个粗粒度的“尺度偏好”在P3层更多token会走高频锐化型专家因为小目标主要分布在这些层到了P5层identity和bottleneck型专家的选择比例会明显上升因为该尺度特征更多承担语义层面的融合。其实这和检测头的FPN设计逻辑是一致的不同层负责不同尺度的目标。既然检测头都这样分工了特征增强层自然也应该有类似分工。加了这一路先验之后我在小目标场景里的稳定性改善比只做内容路由时要明显很多。3. 在YOLOv11的C3k2中嵌入ES-MoE核心代码与替换流程3.1 ESMoE模块实现先给出完整的ESMoE模块代码。下面这个版本我做了简化方便你看清逻辑训练时带辅助损失推理时可以直接把辅助损失那一段去掉不影响主路径。# es_moe.py import torch import torch.nn as nn class ESMoE(nn.Module): def __init__(self, c1, c2, num_experts4, top_k1, gate_hidden64, aux_loss_weight0.01): super().__init__() assert c1 c2, 当前实现里输入输出通道保持一致更方便 self.c1 c1 self.c2 c2 self.num_experts num_experts self.top_k min(top_k, num_experts) self.aux_loss_weight aux_loss_weight # 先用轻量卷积提取局部上下文再做逐token路由 self.shared_conv nn.Sequential( nn.Conv2d(c1, c2, 3, 1, 1), nn.GroupNorm(min(8, c2 // 8), c2), nn.SiLU(inplaceTrue), ) # 门控1x1卷积把每个空间位置的C维投影成E维logits self.gate nn.Sequential( nn.Conv2d(c2, gate_hidden, 1), nn.SiLU(inplaceTrue), nn.Conv2d(gate_hidden, num_experts, 1), ) # 四个专家结构不同全部使用Linear适配逐token稀疏调用 self.experts nn.ModuleList([ nn.Sequential( nn.Linear(c2, max(2, c2 // 2)), nn.SiLU(inplaceTrue), nn.Linear(max(2, c2 // 2), c2), ), nn.Sequential( nn.Linear(c2, c2 * 2), nn.SiLU(inplaceTrue), nn.Linear(c2 * 2, c2), ), nn.Sequential( nn.Linear(c2, c2), nn.GELU(), nn.Linear(c2, c2), ), nn.Identity(), ]) self._init_weights() def _init_weights(self): for m in self.modules(): if isinstance(m, nn.Linear): nn.init.kaiming_normal_(m.weight, a0.2) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, a0.2) if m.bias is not None: nn.init.zeros_(m.bias) def forward(self, x): B, C, H, W x.shape N H * W # 局部上下文卷积 z self.shared_conv(x) # [B, C, H, W] gate_logits self.gate(z) # [B, E, H, W] # 转成逐token形式 z_flat z.permute(0, 2, 3, 1).reshape(B * N, C) # [BN, C] gate_logits gate_logits.reshape(B, self.num_experts, N) gate_logits gate_logits.permute(0, 2, 1).reshape(B * N, self.num_experts) gate_weights torch.softmax(gate_logits, dim-1) # [BN, E] # top-k 稀疏选择 topk_w, topk_idx torch.topk(gate_weights, self.top_k, dim-1) topk_w topk_w / (topk_w.sum(dim-1, keepdimTrue) 1e-6) out_flat torch.zeros_like(z_flat) for k in range(self.top_k): idx_k topk_idx[:, k] # [BN] w_k topk_w[:, k] # [BN] for e in range(self.num_experts): mask (idx_k e) if mask.any(): token_idx mask.nonzero(as_tupleTrue)[0] selected z_flat[token_idx] expert_out self.experts[e](selected) out_flat[token_idx] out_flat[token_idx] \ expert_out * w_k[token_idx].unsqueeze(-1) # 恢复空间维度 out out_flat.view(B, H, W, C).permute(0, 3, 1, 2).contiguous() # 辅助损失鼓励专家使用率均匀 count_selected torch.zeros(self.num_experts, devicex.device) for e in range(self.num_experts): count_selected[e] (topk_idx e).any(dim-1).sum().float() f_i count_selected / (B * N) mean_prob gate_weights.mean(dim0) aux_loss self.num_experts * (f_i * mean_prob).sum() return out, aux_loss这里面有两个细节值得注意。第一个是gate用的1×1卷积输出空间尺寸和输入一样所以每个像素点都有自己的专家选择而不是整张图共享一个路由结果这一点对空间密集目标非常重要。第二个是专家全部用Linear因为我们对token做了稀疏选择无法直接对4D特征图做批量卷积用Linear后可以很干净地实现只在被选中的token上做计算。3.2 把ESMoE装进C3k2得到ESMoE模块后下一步就是做Bottleneck替换。我保留YOLOv11原来C3k2的整体融合结构只是把其中的Bottleneck变成带ESMoE的版本。# c3k2_esmoe.py import torch import torch.nn as nn from .es_moe import ESMoE # 这里Conv直接用你YOLO环境里的标准卷积块 class Bottleneck_ESMoE(nn.Module): def __init__(self, c1, c2, shortcutTrue, e0.5): super().__init__() c_ int(c2 * e) self.cv1 Conv(c1, c_, 1, 1) self.m ESMoE(c_, c_) self.cv2 Conv(c_, c2, 3, 1, 1) self.add shortcut and c1 c2 def forward(self, x): y, aux self.m(self.cv1(x)) y self.cv2(y) return x y if self.add else y, aux class C3k2_ESMoE(nn.Module): def __init__(self, c1, c2, n1, shortcutTrue, e0.5): super().__init__() c_ int(c2 * e) self.cv1 Conv(c1, c_, 1, 1) self.cv2 Conv(c1, c_, 1, 1) self.m nn.ModuleList( Bottleneck_ESMoE(c_, c_, shortcut, e) for _ in range(n) ) self.cv3 Conv(2 * c_, c2, 1) self.aux_loss None def forward(self, x): aux torch.zeros((), devicex.device) y1 self.cv1(x) y2 self.cv2(x) for b in self.m: y1, tmp_aux b(y1) aux aux tmp_aux self.aux_loss aux return self.cv3(torch.cat([y1, y2], dim1))这里有个很关键的工程注意点C3k2_ESMoE的forward里不再像普通模块那样只返回out而是把内部所有ESMoE的aux_loss累加并保存到self.aux_loss。这样在训练循环里只需要遍历模型中的所有C3k2_ESMoE实例就能拿到当前batch的辅助损失总和。3.3 在检测框架里注册新模块如果你用的是现成YOLO训练框架需要把新类加进模块注册表并在解析模型配置时让C3k2_ESMoE参与通道计算。通常是在任务文件里的parse_model分支中追加类似逻辑elif m in {C3k2_ESMoE, }: c2 make_divisible(c2 * 0.5, 8) args [c1, c2, *args[1:]]这行代码的作用是告诉解析器C3k2_ESMoE是主干里的一种带通道变换的模块第一个卷积会先把通道数从c1压缩到c2而不是直接把c2当输出通道。不同的框架处理细节可能略有差异但思路一致——让解析器知道这个模块和普通C3k2一样需要通道推导。3.4 训练时把aux_loss接到总loss模型前向完成后在训练循环里把辅助损失汇总进总lossmodel.train() moe_aux 0.0 for m in model.modules(): if hasattr(m, aux_loss) and isinstance(m, C3k2_ESMoE): moe_aux moe_aux m.aux_loss total_loss base_loss moe_aux_weight * moe_aux注意要在当前batch的forward结束后立即读取aux_loss下一次forward会被覆盖。如果你的框架支持hook也可以用forward hook在每个ESMoE模块后面自动捕获aux_loss逻辑上更干净。4. 场景收益拆解复杂背景、小目标与密集目标分别怎么涨点4.1 复杂背景背景走“压制路径”目标走“增强路径”加了ES-MoE之后最直观的变化是背景和目标开始“分路处理”。当gate判断某个token更多是背景纹理时它大概率会走identity或bottleneck压缩型专家这两个方向不会引入额外的高频响应而当gate判断一个token更像是目标边缘时会优先走高频锐化型专家把这个位置的响应进一步顶上去。这相当于把原来“目标与背景共用一套卷积核”的问题转换成了“让网络自己决定如何分配专家”。我自己在带草地、水面、树干纹理的场景上做了对比最明显的现象是误检下降。以前的模型容易把背景里的高频纹理当成目标候选加入ES-MoE之后这类纹理token大多被gate分配到了更低增益的路径不会产生不必要的强响应。4.2 小目标token级路由不再让少量前景像素被“平均掉”小目标场景的改善来自两个层面。第一共享卷积之后再路由减少了全局统计量对局部强响应的稀释。原来一个只有几个像素的目标其响应在整张特征图的pooling或统计里几乎不起作用但ES-MoE是在每个token单独做决策的一个小目标如果落在“高频锐化专家”覆盖范围它就能获得完整的一条增强路径而不是被周围大片背景“平均”掉。第二尺度先验让P3、P4层更偏爱高频专家。小目标在浅层特征里保留的细节最多浅层的gate如果更倾向于把小目标token导向高频专家目标的边缘和角点响应就会更强对后续检测头更友好。4.3 密集目标硬路由天然具备“分离”效果密集目标最怕的就是类内特征同化两个人挨在一起网络最终学到的是“一个模糊的人脸响应”而不是两个独立目标。ES-MoE的硬路由在这里有一个很有意思的效果——相邻token即使视觉上很像也可能因为局部上下文差异被gate分配到不同的专家一个走identity保留原始边缘一个走扩展交互型专家去探索更大的感受野这就在特征层面提前做了一次“差分”。当然这不能完全替代NMS和检测头的逻辑但它确实缓解了检测头面对重叠目标时特征过于相似的问题。我在密集小目标的场景里看到漏检数下降很大一部分原因就是相邻目标在特征增强阶段被故意“岔开”了。4.4 预期收益与成本对照场景未改进时的主要问题ES-MoE的主要收益参考幅度复杂背景背景高频纹理引误检背景与目标分路处理、误检降1~3个点的AP提升小目标少量前景响应被稀释token级路由、高频专家增强子集会高出2~4个点密集目标相邻目标特征同化硬路由制造特征差分漏检率下降比较明显这个表是相对参考具体数字依赖数据集和训练配置别把它当成固定收益。我的经验是ES-MoE在困难子集上的改善通常比整体mAP更明显因为它的机制本质上就是解决“难例”的分配问题。如果你后续还要做论文实验建议单独统计困难子集的AP而不是只看整体数字。5. 训练ES-MoE的调参经验aux_loss权重、stage选择与超参表5.1 超参推荐下面是我调试下来比较稳妥的一组配置适合YOLOv11主干超参数推荐值说明num_experts4默认即可结构差异更重要top_k1稳定计算量小gate_hidden64gate容量足够moe_aux_weight0.01 ~ 0.02太小会路由坍缩太大会干扰主lossEMA0.001 ~ 0.01路由抖动明显时调大EMAwarmup epochs1 ~ 3前几个epoch不启用aux_loss更稳路由抖动是我在实验里碰到的一个新问题。由于每个token只走一个专家相邻epoch之间专家选择可能发生较大变化导致验证指标波动。这时候把EMA调大一点能明显平滑曲线。另外如果你发现某个专家始终没人选先把aux_weight调高到0.05跑20个epoch等专家使用率均匀后再降回0.01可以救回来。5.2 stage选择不是越多越好YOLOv11的主干里我一开始图省事把四个主stage全换成C3k2_ESMoE结果小目标AP反而掉了。后来逐步排查才发现stage1P2级别的浅层特征需要的是通用底层特征提取能力这时候就做专家稀疏选择会过早丢掉通用性后面所有层都受影响。比较稳的做法是stage1保持普通C3k2stage2、stage3、stage4用C3k2_ESMoE而且每个stage不要全替换至少留一个普通Bottleneck作为兜底。这样既能在关键尺度有专家分工又不会让主干变得过度稀疏。5.3 训练细节门槛在数据增强ES-MoE对训练数据的多样性要求比普通卷积更高。因为专家分工需要依赖gate对“不同特征模式”的分类能力如果训练集背景都比较单一gate会学出一个非常固定的选择策略换到新场景就不work。所以我把Mosaic增强概率调高了同时在训练后期保留一部分混合增强让gate见到的背景类型足够多。如果你用的是公开数据集建议额外加一些裁剪采样或者随机遮挡让小目标的位置和背景组合更多样。6. 复现过程中的踩坑记录与排查思路6.1 路由坍缩所有token都走identity专家这是我最早遇到、也是最严重的坑。现象是训练过程中gate几乎把所有token分配到identity专家其余三个专家完全拿不到梯度。排查后发现原因有两个一是aux_loss权重太小二是在训练初期没有warmupgate被检测主loss带偏。我的对策是在前2个epoch把aux_weight临时加到0.05并给gate最后一层的bias做均匀初始化让每个专家在训练前期都有差不多的初始概率。坚持20个epoch之后专家使用率会逐渐拉开再把权重降回0.01。6.2 专家内部不要用BN专家内部如果用BN会出现一个很隐蔽的问题因为稀疏路由每个专家实际接收的token数量是不确定的BN的batch统计量会变得非常不稳定甚至出现某些专家在一个batch里只有几个token、统计量几乎无意义的极端情况。我把所有专家内部的BN都换成了GroupNorm或者干脆只使用Linear层统计稳定性会好很多。这一点在MoE结构里几乎是通病不只是ES-MoE会遇到。6.3 混合精度下gate logits容易饱和AMP训练时gate的logits经过softmax后经常出现某一项接近1、其余接近0的极端分布导致topk结果非常敏感微小的梯度波动就会让专家选择发生跳变。我的处理办法是把gate的计算保持在float32精度下不参与AMP的自动降精度。方法很简单在forward里对gate_logits做.float()计算算完topk后再转回输入dtype。对内存影响不大但训练稳定性提升明显。6.4 推理阶段必须去掉aux_loss相关逻辑虽然aux_loss是附加的不影响主路径输出但如果你导出模型时不够小心可能会导致一些框架把辅助损失分支也导出进去增加额外的计算和显存。建议在train模式之外用torch.no_grad()包住forward并且不读取aux_loss导出时直接把ESMoE里的gate和专家保留不需要辅助损失相关的变量。6.5 验证阶段要固定EMA和gate权重YOLO训练通常带EMA但EMA对MoE的gate权重也有影响。我在早期实验中发现验证指标波动大一半原因是EMA没固定好。如果EMA参数设置得过小gate权重跟随当前batch的梯度来回晃动专家选择在验证时不稳定。建议EMA权重设置得比普通卷积偏大一点大概0.01附近验证时务必使用EMA权重不要直接读原始模型参数。这个细节不处理好AP曲线会像过山车。6.6 与检测头之间的配合最后提醒一句ES-MoE放在backbone里效果最好但如果把类似的MoE逻辑加到neck或者检测头前面容易和FPN的多尺度融合逻辑起冲突。因为neck本身就要把不同尺度特征拉到一个语义空间里如果之前已做过分路径增强融合时可能反而丢失专家分工带来的特征差异。我的建议是除非你有非常明确的反复实验支撑否则ES-MoE只改backboneneck和检测头保持原样。我在实际复现中印象最深的一件事并非每个stage都能涨点。我一开始把四个主stage全换成ES-MoE结果小目标AP反而降了降到只改stage2到stage4之后P3到P5都能精准分配坏现象才消失。所以大家在实验时一定要留完整的多配置对比而不是只盯单点改进。ES-MoE这个方向的价值在于它把目标检测里“难例为什么难”的问题转移到了路由决策上值得你在自己的数据集上多跑几组对照效果会说明一切。
阅读完成 · 觉得有帮助?
咨询建站