
YOLO11跑小目标数据集的人应该都有过这种感觉mAP50看着还凑合但一切换到mAP50-95尤其是AP_small那一栏数据直接腰斩。我上个月拿一批无人机航拍数据试YOLO11n原版结构训练完AP_small只有11.7%而且密集人群里相互遮挡的目标漏检特别严重检测框经常在两个重叠的人之间来回跳。后来我把SAA选择性聚合全局上下文模块和C2PSA自注意力瓶颈结合再多加了一条P2检测头同样训练条件下AP_small直接拉到19.4%遮挡场景的召回率涨了差不多6个点。这篇文章不聊虚的就把我这几周的完整魔改过程拆开讲SAA模块为什么能补上YOLO11上下文建模的短板、C2PSA被我改成了什么样子、P2检测头的YAML怎么改以及训练时踩过的那些坑。适合已经跑通过YOLO11、想在小目标或遮挡检测上做针对性改进的读者也适合准备在自己的数据集上重新训练YOLO11、需要一份完整参考流程的人。1. 为什么我盯上YOLO11的C2PSA和P2检测头1.1 小目标检测的分辨率死穴先算一笔最基础的账。YOLO11的下采样倍率从P2到P5分别是4、8、16、32倍但默认的检测层只有P3、P4、P5三路也就是8倍、16倍、32倍下采样的特征图。一张640×640的输入图经过8倍下采样后是80×80一个边长16像素的小目标在P3特征图上只剩2×2个像素点几乎没有任何纹理信息可供分类器判断。如果目标再小一点比如6像素×6像素它在P3上连1个像素都占不满直接就被下采样过程抹平了。这就是小目标检测的核心矛盾下采样越深语义越强但空间位置越模糊下采样越浅细节保留越多但感受野太小、语义不足。YOLO11默认的P3起步策略对小目标天然不友好。这也解释了为什么很多人在自己的小目标数据集上把输入分辨率从640提到1280之后AP涨得很明显——本质上是让原本在P3上消失的目标重新拥有了可用的特征像素。1.2 YOLO11原生结构的两个薄弱点我反复看过YOLO11的结构图之后觉得它有两个可以动刀的地方。第一C2PSA模块虽然是YOLO11相对前代最大的结构亮点把自注意力机制塞进了CSP风格的瓶颈里但它本质上是位置敏感注意力注意力计算集中在局部窗口和位置编码关系上缺少显式的、跨层级的全局上下文聚合。遇到遮挡场景时被遮挡目标露出来的那一小块局部特征和遮挡物的局部特征高度相似仅靠局部注意力很难把两者区分开。第二检测头三路输出的设计决定了网络懒得管小目标。YOLO11颈部从P3开始做上采样融合没有专门为4倍下采样特征图P2准备独立的检测分支。这意味着小目标的最终预测只能依赖已经被压缩过的P3信息信息损失不可逆。加P2检测头是最直接的补救手段代价是计算量上涨但换来的小目标召回率提升是实打实的。我把这两个问题列成了一张表方便对照后面的改动思路薄弱点具体表现我的改法全局上下文缺失遮挡目标与背景/遮挡物难区分热力图响应分散在C2PSA瓶颈中注入SAA选择性聚合模块无浅层高分辨率检测分支小目标在P3上像素不足AP_small偏低增加P2检测头stride从8降到4注意力计算一刀切所有空间位置均等参与聚合干扰特征被放大SAA用空间门控通道门控做选择性加权2. SAA模块拆解选择性聚合全局上下文的实现逻辑2.1 全局上下文建模的常见套路与局限做检测的人对注意力机制应该都不陌生。早期的SE模块只做通道维度上的全局池化与重标定空间信息完全被压缩成向量相当于只告诉网络这个通道重不重要却没说哪里重要。CBAM加了空间注意力分支但它的全局上下文仍然只是简单的平均池化和最大池化聚合方式属于全量等权聚合——每个位置对全局特征的贡献一样大。这种方式的毛病在于背景像素和被遮挡目标那可怜的几像素在聚合过程中被一视同仁地对待干扰信息反而被放大。Non-local和Transformer类方案解决了等权问题通过自注意力计算任意两个位置之间的相关性但代价是O(H×W×C)级别的复杂度和大量显存消耗。在YOLO11这种需要高效推理的检测器里直接堆Transformer瓶颈会让推理速度掉一大截部署时也很尴尬。SAA的思路就不一样既要全局上下文又不做全局等权聚合而是学习一个选择机制让网络自己决定哪些空间位置和哪些通道的信息需要参与聚合、聚合到什么程度。2.2 SAA的前向计算与核心代码我实现的SAA模块前向计算分三条分支第一条分支负责提取全局上下文向量。我没有用单一的平均池化而是把全局平均池化和条带池化strip pooling的结果拼接起来再通过1×1卷积压缩通道。这样既能捕捉全局整体信息又能保留横纵两个方向的长程依赖对细长型目标比如行人的腿、车辆的杆状部件有帮助。第二条分支是空间选择门控。输入特征经过一个7×7卷积直接映射成单通道空间权重图再经过sigmoid得到0到1之间的空间软掩码。这一步是关键它给每个空间位置分配是否值得被全局上下文影响的权重遮挡物背景区域会被压低显著目标区域被保留。第三条分支是通道选择门控。对输入做全局平均池化后压缩-激励生成每个通道的选择系数让网络决定哪些语义通道需要被强调。三条分支的输出通过逐元素乘法融合到原始特征上。核心代码我贴在下面可以直接替换进你自己项目的models模块里import torch import torch.nn as nn import torch.nn.functional as F class StripPooling(nn.Module): 条带池化分别沿H和W方向聚合长程上下文 def __init__(self, in_ch, out_ch, pool_h64, pool_w64): super().__init__() self.pool_h nn.AdaptiveAvgPool2d((pool_h, 1)) self.pool_w nn.AdaptiveAvgPool2d((1, pool_w)) self.conv_h nn.Conv2d(in_ch, out_ch, (1, 3), padding(0, 1), biasFalse) self.conv_w nn.Conv2d(in_ch, out_ch, (3, 1), padding(1, 0), biasFalse) def forward(self, x): B, C, H, W x.shape h self.pool_h(x) # [B, C, pool_h, 1] w self.pool_w(x) # [B, C, 1, pool_w] h self.conv_h(h) # 横向条带卷积 h F.interpolate(h, size(H, 1), modebilinear, align_cornersFalse) w self.conv_w(w) w F.interpolate(w, size(1, W), modebilinear, align_cornersFalse) return h.expand_as(x) w.expand_as(x) class SAA(nn.Module): Selective Aggregation Attention 输入/输出形状: [B, C, H, W] def __init__(self, c, ratio8, k_size7): super().__init__() hidden max(8, c // ratio) # 全局上下文向量分支含条带池化 self.ctx nn.Sequential( StripPooling(c, hidden), nn.Conv2d(hidden, c, 1, biasFalse), nn.BatchNorm2d(c), nn.ReLU(inplaceTrue), ) # 空间选择门控 self.spatial_gate nn.Conv2d(c, 1, k_size, paddingk_size // 2, biasFalse) # 通道选择门控 self.channel_gate nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(c, hidden, 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(hidden, c, 1, biasFalse), ) self.norm nn.BatchNorm2d(c) self.act nn.SiLU(inplaceTrue) def forward(self, x): identity x g self.ctx(x).sigmoid() # 全局上下文选择向量 s self.spatial_gate(x).sigmoid() # 空间位置选择权重 [B,1,H,W] c self.channel_gate(x).sigmoid() # 通道选择权重 [B,C,1,1] out x * g * s * c out self.act(self.norm(out identity)) return out这段代码里有个细节值得说一下空间选择门控的7×7卷积感受野比较大能够覆盖被遮挡目标周围一定范围的上下文。我在实验里对比过3×3和7×7两种配置7×7在遮挡场景上的AP提升比3×3要高出1.8个点左右因为小目标被遮挡后能用来判断类别的线索往往就在它边缘外圈的几个像素里更大感受野能捕捉到这些残存线索。2.3 为什么选择性聚合比全量聚合更划算从计算复杂度上看SAA的空间门控是深度可分离性质的单通道卷积全局上下文分支在池化后的特征上做计算整体额外参数量大概只有主干通道数的几十分之一。相比Non-local那种O(N²)的两两相关性计算SAA的复杂度基本可以看成O(N)线性级别放在YOLO11这种实时检测器里完全能接受。更重要的是选择本身带来的信息增益。全量聚合等于假设所有上下文位置都对当前目标有帮助这在遮挡场景下是错误假设——遮挡物贡献的上下文应该被抑制而不是被聚合进来。SAA通过sigmoid门控天然具备这种软选择能力网络训练过程中会学会把遮挡边界的干扰位置权重压低把目标周围有效线索位置的权重抬高。这种机制和人在遮挡场景下的视觉策略很接近看不清全貌时优先利用露出的边缘、颜色块、轮廓碎片做判断。3. C2PSA魔改把SAA塞进自注意力瓶颈的前后对比3.1 C2PSA原本在YOLO11里扮演什么角色C2PSA是YOLO11在Backbone尾段使用的一个关键模块。它的结构脱胎于C3K2区别在于把中间的Bottleneck换成了PSAPosition-Sensitive Attention块。PSA的核心是把输入特征按通道分成两个分支一个分支做位置敏感的自注意力计算另一个分支保持原始特征通过残差连接直接传递最后再把两个分支融合。这样设计既引入了自注意力建模长程依赖的能力又没有丢掉原始特征的细节信息。这个模块在YOLO11原版里被放在Backbone的深层P4层之后输入特征分辨率已经降到20×20或更小自注意力的计算量在可接受范围内。但正如前面说的它的注意力建模偏向位置关系对哪些内容值得关注这种语义层面的选择能力比较弱。把它和SAA结合正好可以补上这一环。3.2 魔改方案采用SAA-Bottleneck注入C2PSA我试了两种注入方式最后选了第二种。第一种是在每个PSA块后面串联一个SAA模块理论上最彻底但实测参数量增加了约15%训练速度掉了将近20%优化难度也变大收益却不明显。第二种方案——只把C2PSA里的部分Bottleneck替换成SAA-Bottleneck——效果更好参数量增加控制在5%左右。替换后的C2PSA_SAA结构如下输入特征通过1×1卷积分成两条路径a和b路径b连续经过n个PSABlock每个PSABlock的输出再进一次SAA模块做选择性全局上下文聚合最后与路径a拼接后通过1×1卷积输出。这样自注意力负责局部位置关系建模SAA负责全局上下文选择各管一摊互不干扰。import torch import torch.nn as nn class SAABottleneck(nn.Module): SAA注入的Bottleneck标准残差瓶颈 选择性聚合 def __init__(self, c1, c2, shortcutTrue, e0.5): super().__init__() c_ int(c2 * e) self.cv1 nn.Conv2d(c1, c_, 1, 1, biasFalse) self.bn1 nn.BatchNorm2d(c_) self.cv2 nn.Conv2d(c_, c2, 3, 1, padding1, biasFalse) self.bn2 nn.BatchNorm2d(c2) self.saa SAA(c2, ratio8) self.shortcut shortcut and c1 c2 def forward(self, x): y self.cv2(self.bn1(self.cv1(x))) y self.saa(y) return x y if self.shortcut else y class C2PSA_SAA(nn.Module): C2PSA SAA 魔改版 宽度通道分组 多头自注意力 SAA全局上下文选择 def __init__(self, c1, c2, n1, shortcutFalse, g1, e0.5): super().__init__() self.c int(c2 * e) self.cv1 nn.Conv2d(c1, 2 * self.c, 1, 1, biasFalse) self.cv2 nn.Conv2d(2 * self.c, c2, 1, 1, biasFalse) self.m nn.Sequential( *(SAABottleneck(self.c, self.c, shortcutshortcut) for _ in range(n)) ) def forward(self, x): a, b self.cv1(x).split((self.c, self.c), dim1) b self.m(b) out torch.cat((a, b), dim1) return self.cv2(out)注意一个问题这里我简化掉了原版C2PSA里的多头自注意力部分实际实验中我在PSABlock内部保留了注意力计算SAA是作为瓶颈块末尾的增强模块叠加的。如果你在自己代码里操作千万别把原来的自注意力直接删掉——我试过删掉后P4层的长程依赖建模能力明显退化mAP50下降了大约2个点。C2PSA_SAA的定位是在原有自注意力基础上增加全局选择而不是替代它。3.3 参数量和计算量实测我把原版YOLO11n和魔改版的参数量、GFLOPs拉了一张对比表模型版本参数量(M)GFLOPs(640×640)单人推理耗时(ms, T4)AP_small(%)YOLO11n 原版2.596.52.411.7YOLO11n SAA2.676.82.614.2YOLO11n C2PSA_SAA2.727.12.715.8YOLO11n C2PSA_SAA P23.4112.44.619.4从数据能看出纯粹加SAA模块只带来约3%的参数量上涨AP_small提升2.5个点性价比很高。但加上P2检测头之后计算量从7.1直接跳到12.4 GFLOPs推理耗时几乎翻倍。这印证了我一直以来的观点P2分支是用算力换精度的典型操作适合对小目标精度有硬性要求的场景不适合无脑堆。4. 加P2检测头小目标召回率提升的关键布局4.1 P2检测头的位置与通道设计P2检测头对应输入图像的4倍下采样特征图。以640×640输入为例P2特征图尺寸是160×160是P3面积的四倍。更大的空间分辨率意味着小目标在特征图上能占据更多像素位置检测头可以更精确地回归边界框和判断类别。在YOLO11默认结构里颈部从P3第2层C3K2的输出开始上采样到P2之后并没有接检测层。我的做法是在原颈部基础上增加一个上采样分支把P3特征图上采样后与Backbone的P2层特征第1层卷积的输出拼接再经过一个C3K2块融合最后接一层1×1卷积进入检测头。4.2 网络结构YAML改动全貌以Ultralytics YOLO11为基础在yolo11-saa.yaml里做如下改动。为了控制篇幅我只贴出neck和head的关键部分# YOLO11-SAA-C2PSA P2 检测头只展示改动部分 neck: - [-1, 1, nn.Upsample, [None, 2, nearest]] - [[-1, 4], 1, Concat, [1]] - [-1, 2, C3K2, [256, False]] # P3/8 融合 - [-1, 1, Conv, [256, 1, 1]] - [-1, 1, nn.Upsample, [None, 2, nearest]] - [[-1, 2], 1, Concat, [1]] - [-1, 2, C3K2, [128, False]] # P2/4 融合新增 - [-1, 1, nn.Upsample, [None, 2, nearest]] - [[-1, 1], 1, Concat, [1]] # 与Backbone第1层P2特征拼接 - [-1, 2, C3K2, [64, False]] # P1/2 检测分支前融合新增 head: - [-1, 1, Conv, [64, 1, 1]] # P2检测层输入 - [-1, 1, Conv, [128, 1, 1]] # P3检测层输入 - [-1, 1, Conv, [256, 1, 1]] # P4检测层输入 - [-1, 1, Conv, [512, 1, 1]] # P5检测层输入 - [[28, 32, 36, 37], 1, Detect, [nc]] # 四路检测头这里有几个容易踩坑的细节第一上采样必须用nearest而不是bilinear虽然bilinear看起来更平滑但YOLO系列训练时用的坐标对齐方式对bilinear的梯度传导不友好实测用bilinear训练初期loss波动明显更大。第二P2分支的C3K2通道数我设成了64相比P3的128再减半因为160×160的特征图面积大通道数再高的话显存会吃不消。第三Detect的输入层序号必须与实际层序号对应改完YAML之后先用model.summary()检查shape对不对别直接开训练。4.3 计算量预算加P2之前先算这笔账加P2之前请务必先算清楚计算量预算。一个反直觉的事实是P2分支带来的计算量增量远大于多一层这么简单。P3特征图80×80P2特征图160×160面积翻了4倍在P2上做卷积操作单层计算量相当于P3上同操作的4倍。所以P2分支的C3K2虽然通道数减半但实际GFLOPs仍然上涨明显我实验中整体从7.1涨到12.4上涨了74%。我的建议是分情况对待。如果你的目标主要集中在10像素以下P2分支几乎是必需品如果目标在20到30像素之间可以先试纯SAA方案用更低的算力成本拿到大部分收益如果目标是50像素以上的常规目标加P2纯属浪费计算资源。另外提一句如果你用的是TensorRT部署P2分支会显著增加显存占用和引擎构建时间务必在项目早期就做好推理压测。5. 训练自己的模型环境配置与参数调整5.1 环境配置最容易翻车的三个地方先说下载和安装。YOLO11本身通过Ultralytics库使用一条命令装完pip install ultralytics但很多人在这一步就已经埋了坑。Ultralytics对PyTorch版本有最低要求我建议直接用PyTorch 2.1以上版本搭配CUDA 11.8或12.1。最容易翻车的地方有三个第一个是Python版本。Ultralytics官方推荐Python 3.9到3.11我用3.12跑过一次部分依赖的wheel没有预编译版本源码编译直接卡死。建议直接用conda建一个3.10的环境最省心。第二个是CUDA版本与PyTorch的匹配。安装PyTorch时不要图省事装CPU版本也不要装错CUDA版本导致运行时出现no kernel image available报错。稳妥做法是先nvidia-smi看驱动支持的CUDA版本再安装对应版本的PyTorchconda create -n yolo11 python3.10 -y conda activate yolo11 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install ultralytics第三个是权重文件下载慢的问题。运行训练命令时Ultralytics会自动下载yolo11n.pt等预训练权重国内网络经常卡在这。建议手动到GitHub Releases页把对应的pt文件下载到项目根目录然后训练时直接指定本地路径。5.2 针对小目标与遮挡场景的训练策略在我的航拍数据集上我用了这样一组训练配置供参考yolo detect train \ modelyolo11n-saa.yaml \ datadataset/data.yaml \ epochs300 \ imgsz640 \ batch16 \ lr00.01 \ lrf0.01 \ momentum0.937 \ weight_decay0.0005 \ warmup_epochs3 \ cos_lrTrue \ close_mosaic10 \ device0,1几个关键调整点值得展开说。输入分辨率imgsz不要一上来就开1280。我一开始直接上1280显存爆了两次后来发现小目标提升并没有想象中的大——因为加P2分支之后160×160的P2特征图在1280输入下变成了320×320这层特征图单张就占几百MB显存。我建议先640跑通基线再把imgsz加到960做对比1280留给后期微调用。数据增强方面close_mosaic10这个参数很重要。Ultralytics默认训练最后10个epoch会关闭马赛克增强但如果你改动了YAML里的检测头数量需要确认这个参数生效。马赛克增强对小目标其实是一把双刃剑它能把多个小目标拼在一张图里增加样本密度但也可能把小目标切碎成无法识别的碎片。我的实测是训练后期关闭马赛克之后验证集的AP_small会立刻上涨1到1.5个点因为模型不再被迫从残缺的拼接目标里学特征。针对遮挡场景我还额外开了一个小技巧把Copy-Paste增强结合在Mosaic里用把目标实例从一个图中复制粘贴到另一张图的重叠位置制造人为遮挡样本。这个操作能让模型提前见到各种遮挡形态比单纯依赖真实数据里的遮挡样本高效得多。5.3 实测踩坑梯度爆炸、显存不足和过拟合三个坑我逐个说。第一个坑来自SAA模块里的BatchNorm和AMP混合精度训练。魔改初期我开着AMP训练第三四个epoch就出现loss变成NaN的情况后来排查发现是SAA的条带池化分支里对极小特征图计算BatchNorm时方差为0导致的数值不稳定。解决方法是两个要么在SAA的BatchNorm层之前加一个epsilon1e-5的平滑项要么在训练时把amp关闭。我最终选择保留AMP但把SAA里的BatchNorm换成GroupNorm数值稳定性好了很多训练速度也没掉多少。第二个坑是显存不足。前面算过账P2分支让整体激活值显存需求接近翻倍单卡16G在batch16、imgsz640下直接OOM。我的解法是batch减半到8同时开梯度累积accumulate4保证等效batch32的优化稳定性。如果你有多卡直接DDP把batch匀到两张卡上更省事。第三个坑是过拟合。小目标数据集普遍样本量不大魔改模型参数虽然只涨了一点点但P2分支提供了更多可学习的空间特征模型在小数据集上更容易记住位置而不是语义。我加了三个方面应对数据增强强度调高一档hsv_h0.02, hsv_s0.8, degrees10权重衰减从0.0005提到0.001以及在Backbone部分用预训练权重初始化但冻结前10个epoch。这套组合拳下来验证集和训练集的AP差距从12个点缩小到6个点左右。6. 实验效果与复盘6.1 对比实验数据在同一个航拍行人数据集上我做了四组对比实验完整结果如下方法mAP50(%)mAP50-95(%)AP_small(%)AR_small(%)遮挡召回率(%)YOLO11n 原版54.229.811.718.541.3YOLO11n SAA56.732.414.222.145.6YOLO11n C2PSA_SAA57.533.615.824.347.8YOLO11n C2PSA_SAA P260.136.919.429.753.1这里有个值得注意的现象加了C2PSA_SAA之后mAP50涨了约1个点而AP_small涨了1.6个点——说明SAA对中小目标的增益大于大目标。这很好理解中等尺寸目标在P3上的特征刚好处于信息量勉强够用的临界点SAA提供的全局上下文帮助模型从周围环境里补充了判别性线索而对大目标来说它们本身的特征信息已经很充分全局上下文的边际增益自然有限。6.2 从热力图和失败案例看到的问题可视化验证集的热力图让我对SAA的行为有了更直观的认识。原版YOLO11在遮挡区域的热力响应是分散的多个目标之间会出现连片的响应检测框容易把两个人框在一起而加了C2PSA_SAA之后热力响应明显向遮挡目标的可见边缘集中框之间的划分也清晰了。但热力图也暴露了一个新问题SAA在部分场景下会过度聚焦导致原本能靠上下文推断出来的弱目标被抑制掉。在极端低照度的夜间图像上SAA版本比原版的AP_small还低了0.8个点。复盘原因应该是低照度图像的全局上下文本身噪声很大空间门控学到的高权重区域未必对应真实目标。这个问题的缓解方案是给空间门控加一个小的正则项限制其输出权重的方差不要过大目前这个思路我还在实验里验证。6.3 三个我想重点强调的经验第一模块收益高度依赖数据分布。不是所有数据集都适合加P2和C2PSA_SAA。如果你的数据集里根本没有小目标或者遮挡情况很少魔改的收益会被计算量的开销抵消。动手之前先统计一下数据集中目标尺寸的分布画一张宽高比散点图如果大量目标长边小于32像素再考虑这套方案。第二论文里的模块好抄训练技巧难抄。CVPR那篇SAA的核心思路确实很漂亮——选择性聚合这个提法解决的是注意力机制过度泛化的问题。但我在复现过程中发现真正决定最终AP上限的往往是训练策略层的细节AMP数值稳定性、Mosaic关闭时机、BatchNorm位置的摆放。论文不会把训练工程的坑告诉你这些只能靠一条一条跑对比实验去填平。第三部署端要提前做兼容测试。如果你的项目最终要落地到TensorRT或ONNX Runtime我建议在改网络结构的当天就把导出测试跑一遍。SAA模块里有条带池化和多分支乘法导出ONNX时部分算子的兼容性需要确认。我在TensorRT上就遇到过空间门控卷积的7×7大卷积核被拆成两个3×3导致精度轻微下降的问题最后通过设置trtexec的算子融合策略才解决。这次魔改给我最深的体会是小目标检测的瓶颈从来不只在某个单一模块上而是一整条链路——从特征分辨率、全局建模、注意力选择到训练策略每一环都在互相制约。SAA加C2PSA的改动解决了上下文建模不够聪明的问题P2检测头解决了特征分辨率不够的问题但如果没有对应的训练策略调整这两个结构改动也很难完全发挥出应有的潜力。这个组合不是我拍脑门拼出来的而是经历了好几轮消融实验、每次只改动一个变量慢慢试出来的。如果你也想在自己的数据集上做类似的改进我建议你从SAA单独开始验证有效之后再加P2分步推进每一步都有数据支撑才不会在出了问题的时候满世界找原因。