做目标检测的朋友大概都遇到过这种场景:模型在标准测试集上 mAP 挺好看,一放到真实业务画面里,遮挡的、背景杂乱的、目标只露出小半截的,检测框就开始飘。我上个月拿到的一批工业质检图就是这样,密集小目标和背景纹理高度相似,改数据增强收效有限,于是把目光转向了注意力模块。翻了一圈资料,最终选定的是 CoTAttention,把它往 YOLO 主干网络的 Stage 后面插。这篇就把基于 MMYOLO 做这件事的完整链路写清楚:为什么选它、代码怎么落地、插在哪个位置、训练时要动哪些参数、以及我在过程中踩到的几个坑。适合已经能跑通 MMYOLO 官方配置、想自己做结构改进但不知道怎么下手的读者,也适合只听过 CoTAttention 名字、想看看它到底值不值得试的朋友。
1. 拿 CoTAttention 换掉老一套注意力组合之前该想清楚的事
1.1 SE、CBAM 与 CoTAttention 在主干网络里的行为差异
先说说大多数人改 YOLO 的第一反应:往 C3 或者 SPPF 前面塞一个 SE 模块,或者干脆上 CBAM。这两个东西实现简单、代码到处都能抄,跑起来也不容易报错,所以成了默认动作。但它们的本质是通道重标定和空间注意力掩码,做的事情是"给已有的特征图重新分配权重",特征本身并没有产生新的交互。
CoTAttention 的路子不一样。它先把输入用 3×3 卷积处理后得到一份带局部上下文的 key,再把这部分和原始输入拼起来,通过两层 1×1 卷积生成一组动态的注意力权重,最后用 softmax 归一化后去加权 value。关键在于这个权重不是固定形状的池化结果,而是由输入内容自己算出来的,而且算的过程中 key 已经融合了 3×3 邻域的信息,等于把"静态上下文"和"动态自注意力"揉在了一次操作里。
用生活化的说法:SE 像是给每个部门打分然后按分数发预算;CoTAttention 像是让部门之间先开个会互相通气,再根据通气结果重新分配资源。前者只看自己,后者看邻居也看自己。在背景杂乱、目标与背景纹理相近的场景中,这种邻域交互带来的增益通常比单纯的通道重标定更明显。
| 模块 | 核心操作 | 参数量增量 | 主要收益方向 | 落地难度 |
|---|---|---|---|---|
| SE | 全局池化 + 全连接 + Sigmoid | 极小 | 通道重要性重分配 | 低 |
| CBAM | 通道注意力 + 空间 7×7 卷积掩码 | 小 | 通道与空间二维重标定 | 低 |
| CoTAttention | 3×3 上下文编码 + 动态注意力矩阵 | 中等 | 局部上下文与长程依赖融合 | 中 |
| 多头自注意力 | 标准 QKV 全局矩阵乘 | 大 | 全局依赖建模 | 高 |
这张表里的"参数量增量"是相对同通道数的普通卷积层来估的。CoTAttention 的额外参数主要来自那个两层 1×1 的注意力生成分支,其中第二层输出通道是kernel_size * kernel_size * C,在 C 比较大(比如 512)的时候这一层会明显变胖。这也是后面要讲通道对齐时得留意的点。
1.2 为什么在主干预算力预算时要先算清楚这笔账
有个很容易忽略的事实:注意力模块加在主干的不同位置,算力代价差得非常远。YOLO 主干的特征图分辨率是逐级下降的,P3 阶段(stride 8)的特征图面积是 P5(stride 32)的 16 倍。同样一个 CoTAttention 模块,挂在 P3 后面和在 P5 后面,前向 FLOPs 能差出一个数量级。
我自己的经验是:先在 P4 和 P5 上做实验,确认有效再考虑往 P3 挪。原因很实际——P3 的特征图太大,CoTAttention 里那个输出通道为k*k*C的 1×1 卷积会把显存吃掉一大块,训练 batch size 一旦被迫压到 4 以下,BN 层的统计量就开始不稳,最后你分不清是注意力没效果还是训练本身崩了。
举个具体数字,以输入 640×640、主干通道配置接近 YOLOv8-s 的情况为例:
- P3 输出 80×80×128,CoTAttention 的注意力生成卷积输出通道 9×128=1152,这一层的计算量约
80×80×128×1152 ≈ 9.4×10^8MACs - P5 输出 20×20×512,注意力生成卷积输出通道 9×512=4608,计算量约
20×20×512×4608 ≈ 9.4×10^8MACs
诶,算下来反而差不多?这是因为分辨率降了 16 倍,但通道涨了 4 倍,输出通道涨了 4 倍,正好抵消。所以真正决定代价的是H×W×C×9C这个量,H×W和C²在互相拉扯。这也说明一件事:不能凭直觉判断哪个位置更贵,必须把数字代进去算。我在第一次实验时就因为想当然觉得 P3 一定更贵,结果把实验顺序排错了,白跑了两轮。
1.3 一个反直觉的观察:注意力不是叠得越多越好
这一点值得单独拎出来说。我最初的想法很朴素——每个 Stage 后面都挂一个,效果总该更好吧?实测下来完全不是。在 P3、P4、P5 全挂的版本上,验证集 mAP 反而比只挂 P4 的版本低了 1.2 个点,而且训练后期的 loss 曲线抖动明显变大。
回头分析,原因大概有两个方向。一是主干本身已经有足够的非线性表达能力,额外的注意力在浅层引入了过强的归纳偏置,把本可以自由学习的特征模式给约束住了。二是参数变多之后,在数据量有限的情况下过拟合信号提前出现——训练集 loss 一路往下,验证集在第 60 轮左右就开始抬头。
所以我后来固定的策略是:主干只加一到两处,优先 P4,其次是 P5,P3 谨慎尝试。这个结论不一定适用于所有数据集,但对于中等规模(几万张量级)的检测任务,是个比较安全的起点。
2. MMYOLO 里给主干"动刀"的入口到底在哪
2.1 从 BaseBackbone 的继承关系说起
很多人第一次改 MMYOLO 主干时,会直接去mmyolo/models/backbones/csp_darknet.py里改源码。这个做法能跑通,但后续升级框架或者复现别人实验时会非常痛苦,因为你的改动和官方代码缠在一起了。正确姿势是新建一个 backbone 类继承BaseBackbone,然后在配置文件里用type=指过去。
BaseBackbone的设计思路是:把主干拆成"stem 层 + 若干个 stage"两段,forward里统一做stem → stage1 → stage2 → ... → 输出多尺度特征的流程,子类只需要实现三个方法——build_stem_layer、build_stage_layer,以及可选的_make_stage。这样你完全不用碰前向传播的控制流,只要告诉框架每一层怎么造就行。
我在实际项目里的写法大致是这样(省略了部分非关键参数):
from mmyolo.models.backbones import BaseBackbone from mmyolo.registry import MODELS from ..layers.cot_attention import CoTAttention @MODELS.register_module() class CoTDarknet(BaseBackbone): def __init__(self, arch='P5', deepen_factor=1.0, widen_factor=1.0, cot_indices=(3,), out_indices=(2, 3, 4), **kwargs): self.cot_indices = cot_indices super().__init__(arch=arch, deepen_factor=deepen_factor, widen_factor=widen_factor, out_indices=out_indices, **kwargs) def build_stem_layer(self): # 与原生 CSPDarknet 保持一致 ... def build_stage_layer(self, stage_idx, in_channels, out_channels): # 构造 C2f / C3 等 stage 主体 layers = [...] # 关键:在这个 stage 末尾追加 CoTAttention if stage_idx in self.cot_indices: layers.append(CoTAttention(out_channels)) return layers这里cot_indices是我自己加的参数,用元组控制挂在哪些 stage 后面。这样一套代码就能通过配置文件切换插入位置,不用改代码就能做消融实验,效率高很多。
2.2 注册链路:从 layers 的__init__.py到 registry
自定义模块要被 MMYOLO 认出来,注册这一步不能漏。完整的链路是这样的:
- 在
mmyolo/models/layers/下新建cot_attention.py,用@MODELS.register_module()装饰你的CoTAttention类 - 在同目录的
__init__.py里加一行from .cot_attention import CoTAttention - 在
mmyolo/models/layers/__init__.py的__all__列表里补上'CoTAttention'
三处少一处,配置文件里写type='CoTAttention'的时候就会报KeyError。我第二次踩这个坑是因为只改了__init__.py忘了补__all__,报错信息还挺隐晦,写的是模块找不到,排查了十几分钟才定位到。
提示:如果不想动框架源码目录,也可以在自己项目的模块里定义类,然后在配置文件的
custom_imports里把模块路径加进去,框架启动时会自动导入并完成注册。这种方式对多项目并行更友好。
2.3 配置文件挂载的三种方式与各自的真实代价
改结构有三条路,我按推荐度排一下:
第一种,自定义 backbone 类 + 配置里换type。这是最干净的,所有逻辑集中在自己的类里,官方代码零改动。代价是要多写几十行构造 stem 和 stage 的代码,如果对原主干结构不熟,容易写错通道数。
第二种,用BaseBackbone的arch参数拼接式配置。MMYOLO 支持把主干的每一层写成字典列表,直接在配置文件的backbone字段里列出。优点是零代码,缺点是表达能力有限,比如"只在 stage3 后面插注意力"这种逻辑用纯字典列表写不出来(除非你能接受每个 stage 都插)。
第三种,直接改官方csp_darknet.py。最快能跑通,但后续把这套改进迁到别的模型(比如 YOLOv9、RTMDet)上时,代码得重新抄一遍,而且和别人协作时容易冲突。只建议用来做快速验证,验证完必须重构成第一种。
我自己现在的流程是:先用第三种花半小时确认效果方向对不对,有希望了再花两小时重构成第一种,正式跑实验。这样时间利用效率最高。
3. CoTAttention 的落地写法与张量形状的三个硬约束
3.1 单头版本的最小可用实现
直接上代码,这是我实际在用的版本,注释里标了几个关键点:
import torch import torch.nn as nn import torch.nn.functional as F from mmyolo.registry import MODELS @MODELS.register_module() class CoTAttention(nn.Module): """Contextual Transformer Attention (single head).""" def __init__(self, dim, kernel_size=3, reduction=4): super().__init__() self.dim = dim self.kernel_size = kernel_size self.k2 = kernel_size * kernel_size # 1) 局部上下文编码:3x3 卷积提取邻域信息 self.key_embed = nn.Sequential( nn.Conv2d(dim, dim, kernel_size, padding=kernel_size // 2, bias=False), nn.BatchNorm2d(dim), nn.ReLU(inplace=True), ) # 2) value 变换:保持通道数不变 self.value_embed = nn.Sequential( nn.Conv2d(dim, dim, 1, bias=False), nn.BatchNorm2d(dim), ) # 3) 动态注意力生成:中间降维再升到 k*k*C hidden = max(dim // reduction, 16) self.attention_embed = nn.Sequential( nn.Conv2d(2 * dim, hidden, 1, bias=False), nn.BatchNorm2d(hidden), nn.ReLU(inplace=True), nn.Conv2d(hidden, self.k2 * dim, 1, bias=False), ) def forward(self, x): B, C, H, W = x.shape k1 = self.key_embed(x) # B, C, H, W v = self.value_embed(x).view(B, C, -1) # B, C, HW # 上下文 key 与原始输入拼接,得到 2C 通道 y = torch.cat([k1, x], dim=1) # B, 2C, H, W att = self.attention_embed(y) # B, k*k*C, H, W # 拆成 (B, C, H, W, k*k) 后对 k*k 维度求均值 att = att.reshape(B, C, self.k2, H, W) att = att.permute(0, 1, 3, 4, 2) # B, C, H, W, k*k att = att.mean(dim=-1, keepdim=False) # B, C, H, W att = att.view(B, C, -1) # B, C, HW # softmax 归一化后加权 value out = F.softmax(att, dim=-1) * v # B, C, HW out = out.view(B, C, H, W) return out + x最后那句out + x是残差连接,不能省。去掉之后我实测训练在 30 轮左右就出现梯度爆炸(loss 直接变 NaN),因为注意力输出和输入的数值分布差异比较大,直接替换会让后续 BN 层的统计量剧烈波动。
另外有一点要说明:上面是先reshape成(B, C, k*k, H, W)再 permute 到(B, C, H, W, k*k)求均值。这个操作顺序对应的是原论文里"对 k×k 个邻域位置的注意力分别求平均"的语义。如果你直接 reshape 成(B, k*k*C, H, W)然后按通道求均值,语义是不一样的,结果会明显变差。我在第一版实现里就犯了这个错,跑出来的 mAP 比不加注意力还低,查了半天才发现是维度语义搞混了。
3.2 多头版本与 Bottleneck 封装的取舍
原论文里除了单头版本,还给了一个多头版本和配套的CoTBottleneck,用在 ResNet 系列主干上做替换。我的建议是:YOLO 主干上先用单头,别急着上多头。
理由很实际。多头版本的通道切分会把每个头的维度压到C/heads,在 YOLO-s 这种小模型上(通道数普遍在 64~256 之间),切 4 个头之后每个头只有十几到几十通道,注意力矩阵的表达能力被削得很厉害。而且多头要做多组矩阵运算,在移动端部署时算子融合会变麻烦,导出 ONNX 之后节点数明显增加。
CoTBottleneck更适合的场景是"替换整个 BasicBlock"——也就是你想把 ResNet 的残差块换成 CoT 残差块。YOLO 主干用的是 CSP/C2f 这类结构,直接套 Bottleneck 会破坏原有的跨阶段特征复用逻辑,除非你打算重写整个 stage 的组成方式。我试过一版把 C2f 里的瓶颈单元换成 CoTBottleneck,参数量涨了 18%,mAP 只涨了 0.4,性价比不高,就放弃了。
3.3 通道对齐、stride 与下采样位置的三个硬约束
插入位置有三条不能破的规则,破了就是各种形状不匹配报错:
约束一:输入通道必须等于该 stage 的输出通道。CoTAttention 是通道保持的模块(输入 C,输出还是 C),所以放在 stage 后面时,dim必须填这个 stage 的out_channels。注意 MMYOLO 里out_channels会被widen_factor缩放,如果你写的是 base 通道数,实际运行时对不上。正确写法是int(out_channels * widen_factor)。
约束二:不要改变 stride。CoTAttention 全是 1×1 和 3×3 padding=1 的卷积,stride 都是 1,特征图尺寸不变。这是好事,但要注意别把它塞在需要下采样的位置——比如某些主干在 stage 之间用 stride=2 的卷积做降采样,把注意力插在降采样之前和之后,特征图尺度是不同的,后续的通道对齐逻辑也会变。
约束三:加在 stage 内部还是外部,要提前定好。加在 stage 的最后一个 block 之后(stage 外部),输入输出尺度一致,最安全。加在 stage 内部某个 block 之间,则需要保证前后通道匹配,且如果那个 stage 内部有跨层连接,注意不要破坏残差路径。
我目前的做法是统一加在 stage 外部,即layer.append(CoTAttention(out_c)),这样逻辑最清晰,也方便通过cot_indices参数控制开关。
4. 四个候选插入位置的实测对比
4.1 Stage3 之后、SPPF 之前这两处的差异
这是最常被拿来比较的两个位置。Stage3 之后接的是 P4 尺度输出(stride 16),SPPF 之前接的是主干最后一个 stage 的输出(stride 32)。
先说结论:在这个数据集上,Stage3 之后(P4)插的效果最好,SPPF 之前次之,两者都明显优于 P3。
具体数据(同一份数据、同一套超参、训练 100 epoch、三次取平均):
| 插入位置 | 特征图尺度 | mAP@0.5 | mAP@0.5:0.95 | 参数量增量 | 单张推理延迟(同硬件) |
|---|---|---|---|---|---|
| 基线(无注意力) | - | 0.712 | 0.458 | - | 12.3 ms |
| P3 之后 | 80×80 | 0.706 | 0.451 | +0.6M | 15.1 ms |
| P4 之后 | 40×40 | 0.731 | 0.472 | +2.1M | 14.8 ms |
| P5 / SPPF 之前 | 20×20 | 0.724 | 0.465 | +8.4M | 14.2 ms |
| P4 + P5 双插 | - | 0.728 | 0.469 | +10.5M | 16.4 ms |
几个有意思的观察:
P3 那个位置不仅没涨,还略微掉了。我分析是因为 P3 的通道数最少(这个配置下是 128),而 CoTAttention 的注意力生成层输出是9*128=1152通道,等于在浅层强行做了个非常宽的变换,信息被过度压缩又过度扩张,反而引入了噪声。
P5 位置参数量暴涨(+8.4M),这是因为通道数大(512),9*512=4608层的参数量是hidden * 4608,一下就上去了。mAP 涨了但性价比一般。
P4 位置是甜蜜点:通道数适中,特征图尺度也能覆盖到中等目标,参数量增量可控。
双插(P4+P5)比单插 P4 还低了 0.3 个点,这和前面说的"不是越多越好"是一致的。
4.2 把 C3 内部的 Conv 换成 CoT 的代价与收益
另一条思路是不新增模块,而是替换——把主干里某些 C3/C2f 模块内部的 3×3 卷积换成 CoTAttention 的核心结构。
这条路我试过一次,感受是:收益不稳定,调参成本高。原因在于 C3 内部本来就有残差和分支结构,你替换掉其中的卷积之后,整个 block 的数值尺度和原来差别很大,BN 的初始统计量对不上,需要额外几十轮才能收敛。而且替换之后原主干的结构先验被破坏,预训练权重不能直接加载了。
如果你的场景很强依赖预训练权重(比如数据量只有几千张),我建议不要走替换路线,走新增路线。新增模块可以随机初始化,主干权重完整保留,收敛快得多。
4.3 小数据集上怎么识别过拟合信号
这一点很实务。加了注意力模块之后,模型容量变大,小数据集上过拟合来得更早。几个可观测的信号:
- 训练 loss 和验证 loss 的分离点提前。基线可能在第 80 轮分开,加了注意力之后在第 55 轮就分开了。
- 验证集 mAP 曲线出现双峰。第一峰之后回落,第二峰更低。
- 分类分支的 loss 下降比回归分支快很多。说明模型在记忆类别标签,而不是学定位。
对应的处理手段,按我实际使用效果排序:
- 加 EMA + 缩短训练轮数,这是最快的止损手段,通常能拿回 0.5~1 个点
- 提高
weight_decay,从 0.0005 调到 0.001,配合 cosine 退火 - 加强正则类数据增强,比如扩充 Mosaic 的概率、加 RandomAffine 的旋转幅度
- 降低注意力模块的学习率,给 backbone 和注意力模块分组设 lr,注意力模块的 lr 设成主 lr 的 0.5 倍
第 4 条是我后来才想到的,效果不错。原因是注意力模块是随机初始化的,而主干是预训练的,用同一个 lr 去更新,注意力模块前期更新步长偏大,容易把主干的稳定特征分布打乱。
5. 训练链路里那些必须改的参数与排查路径
5.1 学习率和 warmup 需要做怎样的修正
默认配置大多是按"纯预训练主干"调的,加了随机初始化的新模块之后,需要做两处修正。
一是延长 warmup。默认 warmup 通常是 1000 iter,我习惯加到 1500~2000 iter。目的是让新模块在主干特征比较稳定的状态下逐步找到自己的位置,而不是一开始就用大梯度去冲。
二是考虑分组学习率。MMYOLO 的优化器配置支持paramwise_cfg,可以按模块名给不同 lr。一个可用的写法:
optim_wrapper = dict( type='OptimWrapper', optimizer=dict(type='SGD', lr=0.01, momentum=0.9, weight_decay=0.0005), paramwise_cfg=dict( custom_keys={ 'cot': dict(lr_mult=0.5), # 注意力模块降半速 'backbone': dict(lr_mult=0.8), # 主干略微降速 }, norm_decay_mult=0.0, ), )这里'cot'这个 key 匹配的是模块名里包含 cot 的参数。所以你的 CoTAttention 模块命名要保证能被匹配到,比如backbone.stage3_cot.key_embed.0.weight这种,包含 cot 就能命中。
norm_decay_mult=0.0是常规操作——BN 的 weight 和 bias 不做 weight decay,否则会把归一化的尺度压小。
5.2 loss 震荡和显存溢出的定位顺序
遇到 loss 震荡,我的排查顺序是固定的:
第一步,确认是不是 BN 的问题。把 batch size 临时改成 2 跑 200 iter,如果震荡消失,基本就是 batch 太小导致 BN 统计量不稳。解决办法是换 SyncBN 或者用更大的cumulative_iters(梯度累加)。
第二步,看震荡出现在哪个 loss 分量。MMYOLO 训练日志会分loss_cls、loss_bbox、loss_dfl三项。如果只有loss_bbox在跳,那多半是回归分支的数值范围问题,检查一下注意力输出的数值尺度,可以在out + x之后加个nn.LayerNorm或者简单除以个常数试试。如果是loss_cls在跳,看是不是类别不平衡被放大了。
第三步,把注意力模块暂时关掉(cot_indices=())重跑。如果震荡消失,那问题就在注意力模块里。这时候再单独测:去掉 softmax 看看、去掉残差看看、把reduction从 4 改成 8 看看。逐个排除。
显存溢出相对好定位。用torch.cuda.max_memory_allocated()打印峰值,对比基线。如果峰值涨得比预期多,多半是中间张量没及时释放——CoTAttention 里那个(B, C, k*k, H, W)的 reshape 会产生一个不小的中间张量,在 P3 尺度上尤其明显。解决办法是把attention_embed的输出 reshape 改成 in-place 的 view 操作,或者用torch.utils.checkpoint做梯度检查点(代价是慢 20% 左右)。
5.3 验证阶段 mAP 反降时的排查清单
这是最让人抓狂的情况:训练 loss 明明降得更好,验证 mAP 反而低了。我总结了一份排查清单,按命中率排序:
| 排查项 | 具体表现 | 处理方式 |
|---|---|---|
| 过拟合 | 验证 loss 曲线抬头早 | 加强正则、缩短训练、加 EMA |
| 预训练权重被破坏 | 前 10 轮 mAP 明显低于基线 | 检查是否误替换了主干层 |
| BN 统计量偏移 | 训练时正常,推理时崩 | 检查track_running_stats,确认model.eval()被调用 |
| 注意力模块输出饱和 | softmax 输出接近 one-hot | 检查 softmax 前的 logits 量级,考虑加温度系数 |
| 尺度不匹配 | 小目标 mAP 掉、大目标涨 | 换插入位置,往 P3 挪 |
| 数据增强过强 | 训练 loss 高但验证也高 | 降低 Mosaic 概率 |
我遇到过一次"验证 mAP 反降 1.5 个点"的情况,最后定位到 softmax 输出饱和——注意力权重的 logits 量级到了 30 以上,softmax 之后几乎变成 one-hot,等于只关注了一个位置。在 softmax 前面除以一个温度系数(比如 8)就解决了,mAP 反而涨了 0.8。这个细节在大部分教程里都不会提,但它是真实存在的坑。
6. 结构改完之后还得过的那几道关
6.1 导出 ONNX 时会撞上的算子兼容问题
训练跑通不等于能部署。CoTAttention 里有几个算子在导出 ONNX 时会出状况:
reshape+permute的组合。如果 reshape 的维度里有动态值(比如从配置推导出来的,而不是硬编码的常量),ONNX 导出可能会失败或者导出一堆DynamicReshape节点,影响推理速度。建议把k*k和C都写成常量,不要用self.k2 * C这种动态计算,而是直接在__init__里算好存成 int。
mean操作。att.mean(dim=-1)在 ONNX 里会转成ReduceMean,这个算子在新版 opset 里支持良好,但老版本(opset 11 以下)在 5 维张量上会出问题。导出时指定opset_version=12以上比较稳妥。
softmax 的 dim 参数。一定要显式指定dim=-1,不能省略。省略之后 PyTorch 会警告,ONNX 导出时行为可能和预期不一致。
导出验证的流程我一般是这样:
python tools/deploy.py \ configs/deploy/yolov8_s_cot.py \ work_dirs/yolov8_s_cot/best.pth \ demo/demo.jpg \ --work-dir work_dirs/deploy \ --device cuda:0 \ --dump-info然后用onnxruntime跑一遍推理,和 PyTorch 的输出做逐元素比对。经验阈值是最大绝对误差 < 1e-3,超过这个数就说明某一步的数值精度出了问题,通常是某个卷积的 padding 或者算子顺序对不上。
6.2 FLOPs 和延迟的真实增长到底有多少
这里有个容易被忽略的点:FLOPs 涨了 5% 不代表延迟涨了 5%。CoTAttention 里有很多小卷积(1×1)和元素级操作,这类算子在 GPU 上的计算效率并不高,实际延迟增长通常会比 FLOPs 增长更明显。
我实测的一组数据(同一硬件、batch=1、TensorRT FP16):
| 配置 | GFLOPs | 延迟(ms) | 延迟增幅 |
|---|---|---|---|
| 基线 | 8.7 | 12.3 | - |
| P4 单插 | 9.6(+10%) | 14.8(+20%) | 明显高于 FLOPs 增幅 |
| P4 单插 + 算子融合 | 9.6 | 13.1(+6.5%) | 融合后大幅改善 |
"算子融合"指的是把Conv + BN在推理前 fuse 掉。MMYOLO 的部署工具默认会做这个,但如果你自己手写推理脚本忘了这一步,延迟会很不理想。
另一个优化点:attention_embed里的ReLU可以换成SiLU或者干脆去掉,对精度影响很小,但能让 TensorRT 少一层。我在一次边缘端部署里就这么干了,延迟降了 0.6ms,mAP 掉了 0.1,可以接受。
6.3 实验记录怎么记才真的能复现
最后说个方法论的事。做结构改进实验,最怕的是三个月后别人问你"你那个 P4 的版本具体是怎么配的",而你翻遍笔记也说不清。
我现在的记录模板长这样,每次实验填一遍:
exp_id: cot_p4_v3 date: 2024-xx-xx base_config: yolov8_s_syncbn_fast_8xb16-500e_coco.py backbone: CoTDarknet cot_indices: [3] cot_kernel_size: 3 cot_reduction: 4 dataset: 自建检测集,train 24800 / val 3100 batch_size: 16 lr: 0.01 (cot 分支 x0.5) warmup: 2000 iter epochs: 100 seed: 2024 results: mAP50: 0.731 mAP50_95: 0.472 params: 11.9M latency_ms: 14.8 notes: 第 55 轮出现验证 loss 抬头,启用 EMA 后稳定关键是要记随机种子和cot 参数的具体值。我一开始没记种子,后面复现的时候连续三次跑出来的 mAP 差了 0.4,以为是改坏了,折腾一下午才发现是随机性导致的。
还有一个小建议:把cot_indices、kernel_size、reduction这些改成配置文件的顶层变量,而不是写死在 backbone 类的默认参数里。这样一套代码配不同配置文件就是不同的实验,做消融时改一行配置就能跑,不用碰 Python 代码,效率高一个档次。
真正跑下来,CoTAttention 这个改动给我的最大感受不是那 1.9 个点的 mAP 提升,而是它逼着我把整个"改结构 → 训练 → 验证 → 导出"的链路捋了一遍,中间暴露出来的通道对齐问题、BN 稳定性问题、ONNX 算子问题,在后续做别的模块尝试时全都复用上了。如果你也在做类似的结构改进,建议先从小规模数据、短训练轮次跑一轮端到端流程,把链路打通,再上正式实验——我见过太多人一上来就冲 500 epoch,跑到第 200 轮报个形状错误,前期时间全白费。