☰
ES-MoE融入YOLOv11:提升复杂背景与小目标检测性能
2026/10/18 7:16:22 网站建设 项目流程

做检测作品改的人基本都遇到过这一幕:模型在干净场景下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_k=1时,每个token最终只走一个专家,其余专家完全不需要计算。这会让模型被迫给不同位置分配不同的处理路径,而不是所有位置都做一个加权平均。如果去掉topk,让所有专家都参与加权,最终结果相当于一个巨大的MLP,模型会退化回固定计算图,专家分工的意义也就没了。

我实测下来,top_k=1的情况最稳定,因为每个token只走一条路径,gate的决策压力最小,梯度也最干净。top_k=2时模型的表达能力会强一点,但计算量几乎翻倍,而且某些token同时走两个专家后,特征容易变得“四不像”。除非你目标场景非常复杂,否则建议从top_k=1开始。

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_experts=4, top_k=1, gate_hidden=64, aux_loss_weight=0.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(inplace=True), ) # 门控:1x1卷积把每个空间位置的C维投影成E维logits self.gate = nn.Sequential( nn.Conv2d(c2, gate_hidden, 1), nn.SiLU(inplace=True), nn.Conv2d(gate_hidden, num_experts, 1), ) # 四个专家,结构不同,全部使用Linear,适配逐token稀疏调用 self.experts = nn.ModuleList([ nn.Sequential( nn.Linear(c2, max(2, c2 // 2)), nn.SiLU(inplace=True), nn.Linear(max(2, c2 // 2), c2), ), nn.Sequential( nn.Linear(c2, c2 * 2), nn.SiLU(inplace=True), 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, a=0.2) if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, a=0.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, keepdim=True) + 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_tuple=True)[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, device=x.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(dim=0) 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, shortcut=True, e=0.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, n=1, shortcut=True, e=0.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((), device=x.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], dim=1))

这里有个很关键的工程注意点: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

模型前向完成后,在训练循环里把辅助损失汇总进总loss:

model.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太小会路由坍缩,太大会干扰主loss
EMA0.001 ~ 0.01路由抖动明显时调大EMA
warmup epochs1 ~ 3前几个epoch不启用aux_loss更稳

路由抖动是我在实验里碰到的一个新问题。由于每个token只走一个专家,相邻epoch之间专家选择可能发生较大变化,导致验证指标波动。这时候把EMA调大一点,能明显平滑曲线。另外,如果你发现某个专家始终没人选,先把aux_weight调高到0.05跑20个epoch,等专家使用率均匀后再降回0.01,可以救回来。

5.2 stage选择:不是越多越好

YOLOv11的主干里,我一开始图省事把四个主stage全换成C3k2_ESMoE,结果小目标AP反而掉了。后来逐步排查才发现,stage1(P2级别)的浅层特征需要的是通用底层特征提取能力,这时候就做专家稀疏选择,会过早丢掉通用性,后面所有层都受影响。

比较稳的做法是:stage1保持普通C3k2,stage2、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权重太小,二是在训练初期没有warmup,gate被检测主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只改backbone,neck和检测头保持原样。

我在实际复现中印象最深的一件事:并非每个stage都能涨点。我一开始把四个主stage全换成ES-MoE,结果小目标AP反而降了;降到只改stage2到stage4之后,P3到P5都能精准分配,坏现象才消失。所以大家在实验时一定要留完整的多配置对比,而不是只盯单点改进。ES-MoE这个方向的价值在于,它把目标检测里“难例为什么难”的问题转移到了路由决策上,值得你在自己的数据集上多跑几组对照,效果会说明一切。

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

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

立即咨询