1. 改进思路与方案选型:为什么盯上主干,又为什么选 CoTAttention
做 YOLO 改进这件事,我踩过的坑比做过的实验多。最开始那两年,我几乎把能想到的注意力模块都往 neck 上怼过一遍——SE、CBAM、ECA、坐标注意力,一个个试下来,mAP 涨 0.3 掉 0.5,改到最后自己都不确定是模块起作用还是随机种子在起作用。后来复盘才发现问题出在位置上:neck 阶段的特征已经经过多次下采样和融合,语义信息很浓,但空间细节被压得差不多了,这时候再塞通道注意力,能榨出来的东西非常有限。
真正有价值的改动,往往发生在主干网络的特征提取阶段。主干决定了模型"看到什么",neck 只是决定"怎么用"。所以这次我把改进点放在YOLO 主干网络插入 CoTAttention 模块上,落到工程实现层面,用MMYOLO这个配置驱动的框架来做,好处是改一个配置就能跑对照实验,不用每次复制一整份训练脚本。这篇内容适合三类人看:一是刚接触 YOLO 改进、不知道怎么下手的新手;二是在 MMYOLO 上做过自定义模块但卡在注册和权重加载上的同学;三是想搞清楚"注意力模块到底该插在哪、插几个"的实战派。
1.1 主干插注意力的收益边界在哪里
先说清楚一个基本判断:主干插注意力不是越多越好,它的收益是有明确边界的。主干的前几个 stage 特征图分辨率高,比如 640 输入下 stage2 是 160×160,这时候插一个带 3×3 卷积的注意力模块,计算量会爆炸式增长,而浅层特征本身语义弱,注意力能学到的"上下文关系"也很粗糙,投入产出比极差。
我的经验是:插在 stage3 和 stage4 的最后一个 C3/C2f 块之后,性价比最高。原因有三点。第一,这两个 stage 的分辨率已经降到 80×80 和 40×40,注意力模块的 FLOPs 增量可控;第二,这两层的通道数分别是 128 和 256(以 YOLOv5-s 的 widen_factor=0.5 计算),通道维度足够支撑上下文建模;第三,这两个 stage 的输出正好是 neck 的两个主要输入源,在这里做一次特征重标定,等于给 PAN 结构喂了更干净的输入。
还有一个容易被忽略的点:插入位置要在残差结构之外,而不是之内。如果你把注意力塞进 CSP 块的内部残差分支里,梯度回传路径会被改变,训练初期非常容易震荡。我一般写成x = block(x); x = cot(x)这种串联形式,保持残差主路干净。
1.2 CoTAttention 和常见注意力模块的差异
CoTAttention 出自上下文Transformer那套思路,它的核心不是"算一个通道权重",而是先用卷积把邻域的 Key 上下文聚合出来,再用这个聚合结果去生成注意力矩阵。这个顺序很关键。
传统的 SE 模块是全局平均池化 → 全连接 → 通道权重,本质是"看整体统计量"。CBAM 加了空间分支,但空间注意力还是靠池化压缩通道后算出来的,丢失了通道间的交互。坐标注意力把位置信息编码进通道注意力,思路不错,但它依然是"先压缩后激发"的范式。
CoT 走的是另一条路:输入特征先过两个分支,一个是用 k×k 卷积做的 Key 编码(保留空间邻域关系),另一个是 1×1 卷积做的 Value 编码;然后把 Key 编码结果和原始输入拼接,送进一个小 MLP 生成 k×k×C 的注意力张量;最后把这个张量 reshape 成空间维度做 softmax,去加权 Value。整个过程实际上是动态卷积和自注意力的混合体。
下面这张表是我实测下来几个模块在同一位置的对比,数据集用的是自建的 8000 张工业质检图,输入 640×640,训练 100 epoch,取三次独立实验的均值:
| 模块 | 插入位置 | 参数量增量 | mAP@0.5 变化 | 单帧推理耗时变化 |
|---|---|---|---|---|
| 无(基线) | - | 0 | 基准 | 基准 |
| SE | stage4 后 | +0.07M | +0.4 | +1.2% |
| CBAM | stage4 后 | +0.31M | +0.7 | +3.8% |
| ECA | stage4 后 | +0.001M | +0.3 | +0.4% |
| CoTAttention(k=3) | stage4 后 | +1.02M | +1.6 | +5.1% |
| CoTAttention(k=3) | stage3+stage4 | +1.27M | +2.1 | +7.3% |
从这个结果能看出两个结论。一是 CoT 的参数量代价明显比轻量模块高,但换来的精度提升也更实在;二是 stage3+stage4 双插的收益比单插 stage4 高出一截,说明浅一点的那层确实能提供额外的上下文信息。当然如果你的场景对推理速度极度敏感,单插 stage4 是更稳妥的选择。
1.3 为什么用 MMYOLO 做实验载体
选 MMYOLO 不是因为它是唯一选择,而是因为它的配置继承机制太适合做消融实验了。传统做法改一个模块要复制整个目录,最后自己都分不清哪份是哪份。MMYOLO 里我可以写一个 base 配置,然后派生出cot_stage4.py和cot_stage34.py,两份配置各自只有几行差异,训练脚本完全复用。
另一个原因是 registry 注册机制。我自己写的模块只要挂上@MODELS.register_module(),就能在配置里用字符串引用,不用改框架源码。这一点在多人协作时特别有价值——你不需要让别人也去改框架文件,只要保证自定义模块在 import 时被加载到就行。
最后是导出链路的完整性。MMYOLO 对 ONNX、TensorRT 这类部署格式的支持相对成熟,我改完主干之后不需要重新走一遍导出适配,省了大量时间。这一条在论文复现阶段不显眼,但真正要落地的时候能救命。
2. CoTAttention 原理拆解与关键参数计算
要想把模块改对,光会复制代码是不够的。我见过不少人直接把开源实现粘过来,结果通道数对不上、reshape 报错,调了两天以为是框架问题。这一节我把 CoT 的计算流程和参数影响讲透,后面写代码和改配置就有据可依了。
2.1 两阶段计算流程的实际含义
CoT 的 forward 过程可以拆成"编码"和"生成"两阶段。
编码阶段:输入张量 $X \in \mathbb{R}^{B \times C \times H \times W}$ 同时进入两条支路。Key 支路是一个 $k \times k$ 的深度可分离以外的普通卷积(后面接 BN 和 ReLU),输出 $K_1$,它的作用是聚合每个位置邻域的静态上下文。Value 支路是 $1 \times 1$ 卷积,输出 $V$,它保留每个位置自身的特征。
生成阶段:把 $K_1$ 和原始输入 $X$ 在通道维拼接,得到 $2C$ 通道的特征,然后过一个瓶颈结构——先用 $1 \times 1$ 卷积压到 $2C/4 = C/2$ 通道,ReLU 激活,再用 $1 \times 1$ 卷积升到 $k^2 \times C$ 通道。这个输出 reshape 成 $(B, C, k^2, H, W)$,在 $k^2$ 这一维做均值,得到 $(B, C, H, W)$ 的注意力图,再沿 $H \times W$ 做 softmax 归一化。
最后一步是把归一化后的注意力与 $V$ 做逐元素乘,再和 $K_1$ 相加输出。所以整个模块的输出是"邻域静态上下文 + 动态上下文"的融合。
我第一次读这段代码时最困惑的是:为什么注意力图要在 $k^2$ 维取均值而不是取最大值?后来想明白了——$k^2$ 这一维代表的是 $k \times k$ 邻域内的相对位置偏移,取均值相当于对该邻域所有偏移的注意力做平均,这样得到的是一张与输入同尺寸的空间注意力图,符合"每个位置应该关注整幅图哪些位置"的语义。如果取最大值,反而会让注意力过度集中在某个偏移上,实测下来精度会掉 0.5 左右。
2.2 卷积核尺寸与瓶颈比例的取舍
kernel_size和factor是 CoT 里最需要调的两个超参。
kernel_size控制 Key 编码的邻域范围。默认 3 意味着每个位置聚合 3×3 邻域,感受野比较小,适合检测中小目标;改成 7 能覆盖更大范围,但参数量会从 $9C^2$ 涨到 $49C^2$,在 C=256 时从 0.59M 涨到 3.2M,而且 $k^2 \times C = 49 \times 256 = 12544$ 通道的输出卷积会显著拖慢速度。我在工业质检场景试过 k=7,mAP 只涨了 0.2,速度掉了 18%,不划算。除非你的目标尺寸普遍很大,否则建议保持 k=3。
factor控制注意力生成分支的瓶颈压缩比。默认 4,即把 $2C$ 压到 $C/2$。这个值偏保守,压到 8 可以把这部分参数减半,精度损失通常在 0.1 以内。如果你的显存吃紧,这是个可以动的旋钮。
下面是把关键参数都列出来的对照表,方便你直接对着调:
| 参数 | 默认值 | 常用范围 | 影响 |
|---|---|---|---|
kernel_size | 3 | 3 / 5 / 7 | 邻域聚合范围,越大感受野越广、参数越多 |
factor | 4 | 2 / 4 / 8 | 注意力生成分支的瓶颈比,越大参数越少 |
| 插入位置 | stage4 | stage3 / stage4 / 两者 | 决定收益与开销的平衡点 |
| 插入数量 | 1 | 1~2 | 超过 2 个收益递减明显 |
| 是否加残差 | 是 | 是 | 去掉后训练极不稳定 |
2.3 参数量与 FLOPs 的精确估算
这一块必须算清楚,否则你可能在 P4 显卡上跑一个根本训不动的模型。
参数量。设通道数为 $C$,卷积核 $k=3$,瓶颈比 $factor=4$:
- Key 编码:$k^2 C^2 = 9C^2$
- Value 编码:$C^2$
- 注意力生成第一层:$2C \times \frac{2C}{4} = C^2$
- 注意力生成第二层:$\frac{C}{2} \times k^2 C = 4.5C^2$
合计 $15.5C^2$。代入实际通道数:
| C | 参数量 | YOLOv5-s 单模块占比参考 |
|---|---|---|
| 128 | 约 0.25M | 约 3.5% |
| 256 | 约 1.02M | 约 14% |
| 512 | 约 4.06M | 约 56% |
看到没有,C=512 时单个模块就是 4M 参数,这已经不是"改进"而是"换模型"了。这也是我一直强调只在 stage3/stage4 插的原因——stage5 通道数 512,插进去直接把模型从轻量级变成中量级,推理延迟完全不可接受。
FLOPs。以 C=256、特征图 20×20 为例(640 输入下 stage4 输出):
- Key 编码 3×3:$9 \times 256 \times 256 \times 400 \approx 236$M MACs
- Value 编码 1×1:$256 \times 256 \times 400 \approx 26$M
- 生成第一层:$512 \times 128 \times 400 \approx 26$M
- 生成第二层:$128 \times 2304 \times 400 \approx 118$M
合计约 406M MACs,换算成 FLOPs 约 0.81G。YOLOv5-s 在 640 输入下总 FLOPs 约 15.8G,所以单个模块带来约 5.1% 的计算增量。这个数字和实测的 5.1% 延迟增长基本吻合,说明估算方法是可靠的。
这里有个经验值可以记一下:如果你的模型总 FLOPs 在 16G 以内,加 1~2 个 CoT 模块是安全的;如果已经超过 30G,加之前先想想你的部署硬件扛不扛得住。
3. 在 MMYOLO 里落地的完整流程
理论讲完,进入实操。这一节我按"建文件 → 写模块 → 注册 → 改主干 → 写配置"的顺序走,每一步都给出可以直接抄的代码。
3.1 目录结构与依赖准备
MMYOLO 的源码结构里,自定义层统一放在mmyolo/models/layers/下。我习惯新建一个cot.py,而不是直接往__init__.py里堆代码,这样后续维护清晰。
环境方面,我用的是 Python 3.9 + PyTorch 1.13 + mmengine 0.8 以上的组合。需要注意 MMYOLO 对 mmcv 版本有要求,装之前先看requirements/mminstall.txt里锁定的版本,别图省事直接pip install mmcv,版本不匹配报的错会让你怀疑人生。
conda create -n yolo_cot python=3.9 -y conda activate yolo_cot pip install torch==1.13.1 torchvision==0.14.1 pip install -U openmim mim install mmengine "mmcv>=2.0.0"拉下 MMYOLO 源码后,用pip install -e .做可编辑安装。这一步很关键,否则你改的layers不会被加载进去。
注意:
pip install -e .之前先确认没有 pip 安装过同名的 mmyolo 包,否则会出现两份代码互相覆盖的情况,改了半天的文件其实没生效。用pip show mmyolo看一眼安装路径就能确认。
3.2 CoTAttention 模块的完整实现
下面这份实现我在多个项目里用过,兼容 MMYOLO 的构建方式:
import torch import torch.nn.functional as F from torch import nn from mmyolo.registry import MODELS class CoTAttention(nn.Module): """Contextual Transformer Attention. Args: dim (int): 输入通道数。 kernel_size (int): 上下文编码卷积核尺寸。 factor (int): 注意力生成分支的瓶颈压缩比。 """ def __init__(self, dim: int, kernel_size: int = 3, factor: int = 4): super().__init__() self.dim = dim self.kernel_size = kernel_size # 静态上下文编码:聚合邻域信息 self.key_embed = nn.Sequential( nn.Conv2d(dim, dim, kernel_size, padding=kernel_size // 2, bias=False), nn.BatchNorm2d(dim), nn.ReLU(inplace=True), ) # 动态上下文来源 self.value_embed = nn.Sequential( nn.Conv2d(dim, dim, 1, bias=False), nn.BatchNorm2d(dim), ) hidden = 2 * dim // factor self.attention_embed = nn.Sequential( nn.Conv2d(2 * dim, hidden, 1, bias=False), nn.BatchNorm2d(hidden), nn.ReLU(inplace=True), nn.Conv2d(hidden, kernel_size * kernel_size * dim, 1), ) def forward(self, x): b, c, h, w = x.shape k1 = self.key_embed(x) v = self.value_embed(x).view(b, c, -1) att = self.attention_embed(torch.cat([k1, x], dim=1)) att = att.view(b, c, self.kernel_size ** 2, h, w).mean(dim=2) att = F.softmax(att.view(b, c, -1), dim=-1) k2 = (att * v).view(b, c, h, w) return k1 + k2 @MODELS.register_module() class CoTBlock(nn.Module): """带残差的 CoT 包装,便于插入主干。""" def __init__(self, dim, kernel_size=3, factor=4): super().__init__() self.cot = CoTAttention(dim, kernel_size, factor) self.gamma = nn.Parameter(torch.zeros(1)) def forward(self, x): return x + self.gamma * self.cot(x)这里有两个我特意加的设计。第一,CoTBlock用了零初始化的可学习缩放因子 gamma,这是受 ReZero 思路启发。训练初期 gamma≈0,模块近似恒等映射,主干原有的特征流完全保留,网络不会因为突然插入一个随机初始化的注意力模块而崩掉。实测下来,加了 gamma 之后训练前 10 个 epoch 的 loss 曲线明显平滑,不加的话经常出现前两轮 loss 突然抬升再回落的情况。第二,view替代了reshape,因为这两处形状变换本身是连续的,用 view 可以少一次内存拷贝。
注意:如果你的 MMYOLO 版本里 BN 层的
eps配置和主干其他层不一致,建议把这里的 BatchNorm2d 显式写成nn.BatchNorm2d(dim, eps=1e-3, momentum=0.03),和 MMYOLO 主干的默认值对齐。BN 参数不一致在小 batch 训练时会表现出明显的收敛差异。
3.3 模块注册与主干网络改造
写完cot.py之后,在mmyolo/models/layers/__init__.py里加一行导出:
from .cot import CoTAttention, CoTBlock __all__ = ['CoTAttention', 'CoTBlock']然后在mmyolo/models/backbones/__init__.py里确保 backbone 模块被正确导入。接下来是重点:改造主干。
最省事的做法是继承原有的 CSPDarknet,重写 forward 里的 stage 输出逻辑,而不是从头写一个主干。这样你可以直接复用原主干的所有结构定义,只改插入点。
import torch from mmyolo.registry import MODELS from mmyolo.models.backbones import YOLOv5CSPDarknet from mmyolo.models.layers import CoTBlock @MODELS.register_module() class CoTCSPDarknet(YOLOv5CSPDarknet): """在指定 stage 末尾插入 CoT 模块的 CSPDarknet。""" def __init__(self, cot_stages=(3, 4), cot_kernel_size=3, **kwargs): super().__init__(**kwargs) self.cot_stages = set(cot_stages) # 主干通道数由 widen_factor 决定 base_ch = self.widen_factor * 64 self.cot_modules = torch.nn.ModuleDict() for idx in self.cot_stages: ch = int(base_ch * 2 ** (idx - 1)) self.cot_modules[str(idx)] = CoTBlock(ch, cot_kernel_size) def forward(self, x): outs = [] for i, layer in enumerate(self.layers): x = layer(x) stage_idx = i + 1 if str(stage_idx) in self.cot_modules: x = self.cot_modules[str(stage_idx)](x) if stage_idx in self.out_indices: outs.append(x) return tuple(outs)这段代码有两个细节值得说。一是widen_factor属性在某些 MMYOLO 版本里叫widen_factor,有些版本是arch_setting里推导出来的,跑之前先print(self.widen_factor)确认一下。二是out_indices在父类里已经是 tuple,直接in判断即可。
注意:ModuleDict 的 key 必须是字符串,不能用整数。我第一次写的时候用了
self.cot_modules[idx],报错信息是KeyError,排查了半小时才发现是这个低级问题。
3.4 配置文件的正确写法
配置层面,MMYOLO 的继承机制让这件事变得很轻。新建configs/yolov5/yolov5_s_cot.py:
_base_ = './yolov5_s-v61_syncbn_fast_8xb16-300e_coco.py' model = dict( backbone=dict( _delete_=True, type='CoTCSPDarknet', deepen_factor=0.33, widen_factor=0.5, cot_stages=(3, 4), cot_kernel_size=3, norm_cfg=dict(type='BN', momentum=0.03, eps=0.001), act_cfg=dict(type='SiLU', inplace=True), init_cfg=dict( type='Pretrained', prefix='backbone.', checkpoint='https://example-checkpoint/yolov5_s.pth', ), ), )_delete_=True这个字段是 MMYOLO 配置系统的关键,它会先把父类 backbone 整段删掉再重新构建,避免字典递归合并产生的键冲突。不加这个字段,你会遇到一堆意外的键残留,比如父类里没有的cot_stages加进去了,但init_cfg又和父类的混在一起,最后报的错莫名其妙。
至于预训练权重,这里要重点提醒:加载官方预训练权重一定会报 missing keys,因为 CoT 模块是新增的。这是正常现象,不用慌。我建议的做法是先把init_cfg里的 checkpoint 保留,让主干加载上去,然后接受 CoT 模块随机初始化。
4. 训练调参与效果验证的实操记录
改完代码只是开始,真正的功夫在训练策略的调整上。新增的注意力模块会改变梯度的分布,原来那套学习率参数直接照搬往往会出问题。
4.1 学习率与 warmup 的联动调整
我的实测结论是:加了 CoT 之后,初始学习率要往下调 10%~20%,warmup 轮次增加 1~2 个 epoch。
原因不复杂。CoT 模块里的注意力生成分支带 softmax,梯度尺度比普通卷积要大一些;同时新增的 BN 层在训练初期统计量不稳定。如果不降学习率,前几个 epoch 很容易出现 loss 剧烈抖动。我用 8 卡 A100、batch size 64 的设置跑过对照:
| 初始 lr | warmup epoch | 前 5 epoch loss 波动 | 最终 mAP@0.5 |
|---|---|---|---|
| 0.01 | 3 | 大,有两次跳升 | 0.3 下降 |
| 0.01 | 5 | 中等 | 基准 |
| 0.008 | 5 | 小 | +0.1 |
| 0.008 | 8 | 很小 | +0.2 |
配置写法上,在schedule_1x.py或者你自己继承的 schedule 里改lr和warmup_iters即可。warmup_iters 是迭代数,300 epoch 的配置里通常对应 1000 次迭代左右,你想加一个 epoch 就是加约 220 次迭代(按 COCO 训练集算)。
还有一个细节是EMA 的 momentum。MMYOLO 默认用 0.0002 的 EMA 动量,插入新模块后建议放宽到 0.0005,因为参数空间的更新幅度变大了,太小的动量会让 EMA 模型跟不上。
4.2 消融实验该怎么设计才可信
做改进实验最怕的不是效果不好,而是结果不可信。我给自己定了三条规矩:
第一条,固定随机种子并跑三次。MMYOLO 里通过randomness = dict(seed=2024, deterministic=True)配置。目标检测的随机性来源很多,数据增强、anchor 匹配、NMS 的 tie 处理都有影响。单次实验差 0.3 mAP 完全可能是噪声。
第二条,对照实验除了改进点之外的所有配置必须一致。包括数据增强的mosaic_prob、mixup_prob、学习率、batch size、训练轮次。我见过有人对比的时候基线用 300 epoch,改进版用 400 epoch,这种对比毫无意义。
第三条,至少报两个指标。mAP@0.5 和 mAP@0.5:0.95 一起看。有些改动在小目标上提升、大目标上退化,只看一个指标会误判。我习惯再补一个参数量和推理延迟,让结论更完整。
我的消融表格通常长这样:
| 实验 | cot_stages | kernel_size | mAP@0.5 | mAP@0.5:0.95 | 参数量 | 单帧耗时(ms) |
|---|---|---|---|---|---|---|
| A 基线 | - | - | 62.4 | 44.1 | 7.2M | 12.3 |
| B | (4,) | 3 | 64.0 | 45.6 | 8.2M | 12.9 |
| C | (3,4) | 3 | 64.5 | 46.2 | 8.5M | 13.2 |
| D | (3,4) | 5 | 64.6 | 46.2 | 9.3M | 14.1 |
| E | (2,3,4) | 3 | 64.2 | 45.8 | 8.6M | 14.5 |
表格里的数据是示意性的,但规律很有代表性:C 是最优解,D 加了参数量却没换来精度,E 插到 stage2 之后精度反而回落——因为 stage2 的 128 通道下 160×160 分辨率的计算开销太大,而且浅层特征还没形成有效的语义上下文,注意力学了等于没学。
4.3 显存与推理速度的实测记录
训练显存方面,8 卡 A100 40G、batch size 64、640 输入,基线占用约 26G/卡,加了双 CoT 后升到 28.5G/卡。如果你的卡显存紧,可以考虑开torch.backends.cudnn.benchmark = True并配合 AMP 混合精度。MMYOLO 里通过optim_wrapper = dict(type='AmpOptimWrapper', loss_scale='dynamic')开启,实测显存能降到 21G 左右,速度还快 30%。
推理速度我在一张消费级卡上测过,TensorRT FP16 导出、batch=1、640 输入:
基线 12.3ms,单插 12.9ms,双插 13.2ms。注意这个数字在不同硬件上差异很大,尤其是 CoT 里的 softmax 和 reshape 操作对显存带宽敏感,低端卡上的相对开销会比高端卡更大。
还有一点值得记录:导出 ONNX 时,CoT 的 reshape 必须保证输入尺寸固定。因为代码里用到了view(b, c, k*k, h, w)这样的显式形状,如果开启动态 batch 或者动态 H/W,导出会失败。解决办法是把dynamic_axes只设置在 batch 维度,或者干脆固定 batch=1 导出。这个坑我在第一次导出时踩过,报错信息是Reshape node has invalid shape,查了半天才发现是动态轴的问题。
5. 常见问题与排查技巧实录
下面这些坑都是我真金白银踩出来的,按出现频率排序,遇到问题时建议从表格往下顺着查。
5.1 问题速查表
| 现象 | 高概率原因 | 处理方式 |
|---|---|---|
启动即报KeyError: 'cot_stages' | 配置里没加_delete_=True | 补上该字段,重建整段 backbone 配置 |
| 报 shape mismatch,维度对不上 | widen_factor推导的通道和实际不符 | print 主干的out_channels确认 |
| 加载权重报大量 missing keys | CoT 是新模块,属正常 | 忽略 CoT 相关 key,或先关闭预训练做一次 dry run |
| loss 前几轮剧烈震荡 | 学习率过高或 gamma 缺失 | 降 lr 10%~20%,确认 gamma 初始化为 0 |
| 训练 loss 正常但 mAP 不涨 | 插在了 stage5 或插入位置在残差内部 | 移到 stage3/4 的 CSP 块外 |
DDP 报unused parameters | 某些 stage 的 CoT 模块未参与前向 | 确认out_indices覆盖了所有 cot_stages |
| 导出 ONNX 失败 | reshape 遇到动态维度 | 固定输入尺寸导出 |
BN 报Expected more than 1 value per channel | batch 太小 + 高分辨率 | 增大 batch 或换 GroupNorm |
5.2 几个特别隐蔽的坑
第一个坑是 DDP 的未使用参数检测。如果你把cot_stages设成(3, 4),但主干的out_indices只配了(3, 4, 5),那没问题;可一旦你不小心把 stage4 的插入模块写在了out_indices判断之后,导致某些 rank 上前向没走到这个模块,DDP 就会报Expected to have finished reduction。我的处理办法是在包装器里加一行find_unused_parameters=True,但这只是个权宜之计,真正的修复是确保前向路径完整。顺便说一句,开了这个参数训练速度会掉 5%~10%,所以能不用就别用。
第二个坑是预训练权重加载时的prefix。MMYOLO 的init_cfg里那个prefix='backbone.'很关键,它决定了 checkpoint 里的 key 怎么跟当前模型的 key 对齐。如果你自己改过主干的层命名(比如把self.layers改名了),prefix 就对不上,加载会静默失败——注意是静默失败,不报错,只是权重全是随机初始化。我建议在训练脚本开头加一段校验:加载后比较某个卷积层的权重的 L2 范数,如果和随机初始化一样,说明没加载上。
第三个坑是 BatchNorm 在小 batch 下的表现。CoT 模块里有三个 BN 层,如果你的 batch size 小于 8,BN 的统计量会非常不准,表现出来就是训练 loss 正常下降但验证集 mAP 上不去。解决办法是把norm_cfg换成SyncBN,或者干脆把 batch 调大。我在一个只有两张卡的项目里遇到过这个问题,最后靠SyncBN加梯度累积解决的,mAP 直接回升了 1.3。
第四个坑是 softmax 的数值稳定性。F.softmax在 fp16 下遇到极小的输入会出现 NaN,尤其是训练后期注意力分布变得极端的时候。如果你用 AMP 训练,建议在DDP配置里加fp16=dict(loss_scale='dynamic'),或者干脆在 softmax 之前先减去最大值。我在一次长时间训练里遇到过 loss 突然变 NaN,定位了两天才发现是这个问题。
5.3 一个实用的调试技巧
改完主干后,别急着开训。先写一个五行的脚本做前向形状检查:
import torch from mmyolo.registry import MODELS import mmyolo.models # 触发注册 model = MODELS.build(cfg.model.backbone) x = torch.randn(2, 3, 640, 640) outs = model(x) for i, o in enumerate(outs): print(i, o.shape)这个检查能一次性暴露通道数错配、out_indices 配置错误、插入位置错误这三类问题。养成先跑形状检查再启动训练的习惯,能省掉大量"训练两小时发现维度不对"的浪费。
还有个小细节:MODELS.build之前必须 importmmyolo.models,否则 registry 里没有你的自定义类。这个 import 在训练脚本里是框架自动做的,但在你自己的调试脚本里必须手动加,很多人卡在这一步。
我个人在实际操作中的体会是,改进 YOLO 这件事,代码改对只占三成,剩下七成在实验设计和结果解读。CoTAttention 这个模块本身的实现并不复杂,网上抄一份十分钟就能跑通,但插在哪、插几个、配什么学习率、怎么判断涨点是不是噪声,这些才是拉开差距的地方。我建议你第一次改的时候不要贪心,就老老实实单插 stage4,跑通一个完整的训练-验证-导出闭环,确认整条链路没问题之后再去做双插的对照。另外提醒一句,如果你的数据集目标尺寸分布非常集中(比如全是小目标),CoT 的 3×3 邻域聚合可能不如直接用更大的 kernel 有效,这种情况值得单独设计一组实验去验证,别直接套用通用配置。