最近在调一个缺陷检测的小模型,输入分辨率不高,目标又细又碎,主干到 P3 那一层的特征图上看过去基本是一片糊。我一开始以为是数据增强和 anchor 匹配的问题,把 mosaic 关了、把正样本阈值放宽、把 loss 权重来回挪了三遍,mAP 也就上下浮动零点几个点。真正把指标顶上去的那一步,是把主干里 C3 的聚合结果送进一个 CoTAttention 模块做一次上下文重标定,再把改动接进 MMYOLO 的训练管线跑通。这篇就把整件事从头到尾拆一遍:CoTAttention 在算什么、为什么它适合密集小目标、在 MMYOLO 里怎么注册怎么改 backbone、超参要跟着动哪些、以及我踩过的那些“mAP 先掉一个点再慢慢涨回来”的坑。
内容偏实战,前置知识只要求你会写 PyTorch 的 nn.Module、看得懂 MMYOLO 的 YAML 配置、跑过一次tools/train.py。如果你正好在做小目标、遮挡、弱纹理这几类检测任务,又不想整块换主干(换主干意味着重新调 neck 和 head,时间成本太高),那这种“在现有主干里插一个模块”的路子会省很多事。我会给完整代码、完整配置、参数量的估算过程,以及一份排查表,照着抄基本能在一两个小时内跑起来第一版。
1. 主干为什么需要更强的上下文聚合
1.1 三个真实场景暴露的短板
先说清楚问题,不然容易变成“为了加模块而加模块”。
第一个场景是密集小目标。主干到 P3 输出的步长是 8,一个 16×16 像素的目标在特征图上只剩 2×2。这时候单个位置的响应很容易被邻域的强纹理带偏,卷积核的局部感受野又只覆盖到 3×3,网络没有机会去“看一眼周围是什么”。结果就是一排焊点里漏掉中间那个,或者把背景里的一条划痕认成缺陷。
第二个场景是遮挡和截断。目标被挡住一半的时候,可见部分和不可见部分在特征图上是分离的,标准的 3×3 卷积没法建立这两块区域之间的关联。你只能靠堆层数、靠更深的网络去扩大感受野,但那样又会带来梯度和效率的问题。
第三个场景是弱纹理。工业件表面、医疗影像、遥感图里的很多目标,颜色和灰度跟背景几乎一致,区别只在“结构的连续性”上——比如一条边应该直着走,断了就是缺陷。这种判断必须依赖上下文,单点卷积无能为力。
这三个问题的共同点:都不是“分类头不够强”,而是主干在早期阶段没有把空间上下文聚合好,后面再补很费劲。
1.2 CoTAttention 的核心思路:静态上下文加动态注意力
CoTAttention 的名字里有 Contextual Transformer,它最讨巧的地方在于没有推翻卷积,而是让卷积和自注意力各干各的活。
具体拆成三条支路。第一条是静态上下文:对输入做一次 k×k 卷积(默认 3×3),这一步的作用是把邻域信息先编码进来,得到一个“局部上下文图”。第二条是动态上下文:把上一步得到的上下文图和原始输入在通道维拼起来,用两个 1×1 卷积压出一个k*k*C的输出,再 reshape 成每个空间位置上的 k×k 个注意力权重。第三条是 value 支路:1×1 卷积做通道变换。
然后关键的一步是:把动态上下文权重在 k×k 这个邻域维度上做 softmax,再对 value 的邻域特征做加权求和。这样每个位置输出的是“融合了周围 k×k 个邻居、且权重由内容决定”的特征。最后再把静态上下文和动态上下文加起来,作为整体输出的一部分。
用一句人话概括:卷积负责“看邻居”,注意力负责“决定哪个邻居更重要”,两者串起来就是 CoTAttention。整个过程是通道独立的,不像标准自注意力那样需要 C×C 的注意力矩阵,所以参数量和显存都控制得住。
1.3 为什么选它,而不是 SE、CBAM 或大核卷积
这里我给一个自己在实际选型时用的对照表。这些模块我都往主干里插过,结论是这个表:
| 模块 | 增加的核心能力 | 参数量(C=256 时) | 显存增量 | 我的实际体会 |
|---|---|---|---|---|
| SE | 通道重标定 | 约 0.03M | 极小 | 几乎不掉速,但对空间上下文毫无帮助,小目标场景收益常在 0.3 点以内 |
| CBAM | 通道 + 空间注意力 | 约 0.07M | 小 | 空间注意力图只有 1 通道,粒度太粗,密集场景容易误抑制 |
| 大核 depthwise(5/7) | 扩大局部感受野 | 约 0.02M | 小 | 便宜好用,但权重是静态的,遇到内容差异大的邻域没法自适应 |
| CoTAttention | 内容自适应的邻域聚合 | 约 0.95M | 中等 | 小目标提升明显,代价是显存和 FPS |
| 标准自注意力(如 window attn) | 全局建模 | 通常 >2M | 大 | 在 P3 那种 80×80 的特征图上开销劝退,除非做窗口划分 |
选 CoT 的理由很直接:我要的是“邻域内的内容自适应聚合”,而不是“全局依赖”。检测任务里,一个目标通常只跟它身边的几十个像素强相关,把它拉通到全图反而引入噪声,而且部署侧也不友好。
注意:CoT 的参数量集中在 1×1 的 query 生成卷积上(输出维度是
k*k*C),通道数越大增长越快。这是后面要控制插入位置的根本原因,不是拍脑袋决定的。
2. 模块实现:从张量形状开始抠
2.1 四个张量的走向必须先想清楚
写这类模块,我习惯先把形状画在纸上,不然一定会在 reshape 上翻车。CoTAttention 在 forward 里会经历四个关键张量:
static_ctx:形状B,C,H,W,由 k×k 卷积得到,代表局部编码后的上下文。v:形状B,C,H,W,由 1×1 卷积得到,作为待聚合的 value。attn:形状B, k*k*C, H, W,先 reshape 成B,C,k*k,H*W,代表每个通道在每个位置的 k×k 个邻域权重。v_nb:形状B,C,k*k,H*W,是把 value 按 k×k 邻域展开之后的结果。
注意attn和v_nb的维度顺序必须严格对齐,都是(C, k*k, H*W)。只要这两者顺序一致,后面就是一次普通的加权求和加一次沿邻域维的求和,不会出错。我第一版就是attn用了(C, HW, kk)而v_nb用了(C, kk, HW),结果 loss 正常下降但指标死活不动——因为广播之后算出来的是完全无意义的东西,而且不报错。
2.2 一份能直接跑的完整实现
下面这版我按“形状一目了然”的原则写,用F.unfold显式拿到邻域 value。它跟原论文的动态上下文聚合在语义上等价,好处是任何一步的形状你都能在注释里对照上,调试时不会瞎猜。
import torch import torch.nn as nn import torch.nn.functional as F class CoTAttention(nn.Module): """Contextual Transformer Attention,通道独立,不改变分辨率。""" def __init__(self, channels, kernel_size=3, reduction=4, norm_cfg=None): super().__init__() assert kernel_size % 2 == 1, 'kernel_size 必须是奇数,否则 padding 对齐不了' self.channels = channels self.kernel_size = kernel_size self.pad = kernel_size // 2 # 静态上下文:k x k 卷积,把邻域信息先编码进来 self.key_embed = nn.Sequential( nn.Conv2d(channels, channels, kernel_size, padding=self.pad, bias=False), nn.BatchNorm2d(channels), nn.SiLU(inplace=True), ) # value 支路:1x1 通道变换 self.value_embed = nn.Sequential( nn.Conv2d(channels, channels, 1, bias=False), nn.BatchNorm2d(channels), ) # 动态上下文:拼接后压出 k*k*C 个权重 mid = max(channels // reduction, 8) self.query_embed = nn.Sequential( nn.Conv2d(2 * channels, mid, 1, bias=False), nn.BatchNorm2d(mid), nn.SiLU(inplace=True), nn.Conv2d(mid, kernel_size * kernel_size * channels, 1, bias=True), ) def forward(self, x): b, c, h, w = x.shape kk = self.kernel_size * self.kernel_size hw = h * w # 1) 静态上下文 B,C,H,W static_ctx = self.key_embed(x) # 2) value B,C,H,W -> 邻域展开 B,C,kk,HW v = self.value_embed(x) v_nb = F.unfold(v, self.kernel_size, padding=self.pad) # B, C*kk, HW v_nb = v_nb.view(b, c, kk, hw) # 3) 动态上下文权重 B,kk*C,H,W -> B,C,kk,HW attn = self.query_embed(torch.cat([static_ctx, x], dim=1)) attn = attn.view(b, c, kk, hw) # 4) 动态上下文:邻域求和,代表"这一片有什么" dynamic_ctx = v_nb.sum(dim=2).view(b, c, h, w) # 5) 邻域软注意力加权,代表"哪个邻居更该看" attn = attn.softmax(dim=2) out = (attn * v_nb).sum(dim=2).view(b, c, h, w) # 6) 三路融合 return out + static_ctx + dynamic_ctx几个设计选择需要说明。归一化我用了BatchNorm2d而不是原论文里的 LayerNorm,原因是检测任务 batch size 通常不小(8 卡 × 16),BN 更稳,而且 BN 在多数推理引擎里都有成熟算子,导出时不会卡在 LayerNorm 上。如果你因为是极小 batch 才想用 LN,记得把norm_cfg抽成参数,别硬编码。
激活我统一用 SiLU,跟 MMYOLO 里 YOLOv5 系列的默认激活保持一致。混用 ReLU 和 SiLU 在浅层问题不大,但会让 BN 的统计分布在不同 stage 之间差异变大,调 lr 的时候更难伺候。
2.3 怎么塞进 C3:我试过的三种改法
直接往 backbone 里裸插一个 CoT 是最省事的,但效果一般,因为它前面缺少一个通道压缩的过程,输入通道大、计算量高。我的做法是把它挂在 C3 内部。
MMYOLO 的 C3 结构是这样的:cv1把输入压到 hidden 通道,然后过m(一串 Bottleneck),cv2是另一条捷径,最后 cat 之后cv3融合。最自然的插入点是m输出之后、cat 之前,对聚合结果做一次上下文重标定。这样只加一个模块,粒度可控,而且 hidden 通道通常只有输出通道的一半,参数量省一半。
from mmcv.cnn import ConvModule from mmengine.model import BaseModule from mmyolo.models.layers import C3 from mmyolo.models.utils import make_divisible class C3_CoT(C3): """在 C3 的聚合分支末尾插入 CoTAttention。""" def __init__(self, in_channels, out_channels, n=1, shortcut=True, expand_ratio=0.5, norm_cfg=dict(type='BN', momentum=0.03, eps=0.001), act_cfg=dict(type='SiLU', inplace=True), init_cfg=None): super().__init__( in_channels, out_channels, n=n, shortcut=shortcut, expand_ratio=expand_ratio, norm_cfg=norm_cfg, act_cfg=act_cfg, init_cfg=init_cfg) hidden = make_divisible(out_channels * expand_ratio, 8) self.cot = CoTAttention(hidden) def forward(self, x): y1 = self.m(self.cv1(x)) y1 = self.cot(y1) return self.cv3(torch.cat((y1, self.cv2(x)), dim=1))另外两种改法我也试过,简单交代一下取舍。第一种是把 Bottleneck 中间的 3×3 卷积整个换成 CoT,感受野够大,但每个 Bottleneck 都加一个模块,参数量和显存直接翻倍,P3 上会 OOM,放弃。第二种是整个 stage 后面单独挂一个 CoT 做后处理,实现最简单,但梯度回传路径变长,训练前期非常不稳,收敛慢,也放弃了。
2.4 把参数量和算力账算清楚
这部分很多人跳过,结果配置一跑起来发现显存超了。我按kernel_size=3, reduction=4手算一遍,通道数记作 C:
key_embed:3×3 卷积,9C²,加 BN 的2Cvalue_embed:1×1 卷积,C²,加 BN 的2Cquery_embed:第一层2C × C/4 = 0.5C²,第二层C/4 × 9C = 2.25C²,合计2.75C²,加两个 BN 约C/2 × 4
总计约12.75C²量级的权重。用这个公式核对一下不同通道:
| 通道 C | 大概参数量 | 典型出现位置 |
|---|---|---|
| 64 | 约 0.05M | backbone stage2(widen 0.5) |
| 128 | 约 0.21M | backbone stage3(widen 0.5) |
| 256 | 约 0.84M | backbone stage4(widen 0.5) |
| 512 | 约 3.3M | 大模型 stage4(widen 1.0) |
从表里能直接看出结论:在 widen 0.5 的 s 模型上,往 stage4 插一个模块只增加不到 1M 参数,可以接受;但同一套改动搬到 l 或 x 模型上,stage4 的通道是 1024,单模块就是 13M,那就必须重新评估。这就是为什么别人分享的配置你不能无脑套到自己的模型尺度上。
算力方面,卷积部分可以用12.75C²HW粗估 MACs。以 stage4 为例,C=256、HW=20×20=400,得到约 326M MACs,换算成 GFLOPs 大概是 0.65。这个量级跟一个小号的 C3 差不多,不算离谱。但真正的开销不在卷积,在unfold产生的中间张量上,下一节细说。
3. MMYOLO 接入:注册、改主干、改配置
3.1 文件放哪,怎么注册
MMYOLO 基于注册器管理模块,最规范的做法是用custom_imports引入自己的文件,文件里用MODELS.register_module()装饰器注册。目录结构我一般这样组织:
cot_yolo/ ├── __init__.py ├── attention.py # CoTAttention └── backbone.py # C3_CoT + YOLOv5CSPDarknetCoTbackbone.py里的注册写法:
from mmyolo.registry import MODELS from mmyolo.models.backbones import YOLOv5CSPDarknet from .attention import CoTAttention @MODELS.register_module() class C3_CoT(C3): ... @MODELS.register_module() class YOLOv5CSPDarknetCoT(YOLOv5CSPDarknet): ...然后在配置文件的顶层加:
custom_imports = dict( imports=['cot_yolo.backbone'], allow_failed_imports=False, )注意:
allow_failed_imports=False一定要写。默认是 True,导入失败的时候会被静默吞掉,然后报一个“某某类不在注册表里”的错误,你会以为是注册装饰器写错了,实际上是路径不对。这一个参数我至少浪费过半小时。
另外cot_yolo这个目录要么放在项目根目录下能被 Python 找到,要么在运行前把路径塞进PYTHONPATH。我习惯直接放在根目录,跟tools平级,最省心。
3.2 重写 build_stage,而不是从零写 backbone
这是整个接入过程里我认为最关键的一个选择。很多人一上来就自己写一个完整的 backbone 类,把 stem、四个 stage、SPPF 全部重写一遍。我不建议这么干,原因是 MMYOLO 的YOLOv5CSPDarknet里有一堆细节:deepen_factor和widen_factor的取整逻辑(make_divisible)、每个 stage 的 stride 控制、输出通道记录、init_cfg的初始化策略。你手写的时候漏掉任何一个,都会在 neck 那边报通道不匹配,而且报错位置离真正的错误很远。
我的做法是继承,只重写build_stage: