最近在折腾YOLO26改进,104这个编号是我实验笔记里自己排的序号,前面试过不少模块方案,大部分结局就是"涨了一点点但说不上质变"。直到我把注意力从Backbone转向Neck,决定动特征金字塔网络(FPN)的结构,把CTrans接进去做跨尺度特征融合,这一版的提升才算有了区分度。CTrans这个名字,熟悉医学图像分割的朋友应该不陌生,它本质是把CNN的局部特征提取能力和Transformer的全局建模能力做成一个可插拔模块。搜了一圈公开资料,没看到有人直接把它接进YOLO26的Neck,所以这篇就当是我的首发尝试记录。如果你也在做检测模型Neck层面的改进,想知道Transformer到底该以什么姿势进入金字塔结构,这篇应该能帮你省下不少试错时间。
1. 为什么盯着特征金字塔改:YOLO26 Neck的"局部视野"困境
1.1 基线Neck的工作方式与性能瓶颈
YOLO系列发展到第26代,主干和检测头的结构一直在变,但Neck部分的思路延续性很强,本质上还是一个带自顶向下路径和横向连接的特征金字塔,加上自底向上的融合层。这个过程里,不同尺度的特征通过上采样、对齐、拼接或者相加来合并,再用若干卷积块做局部细化。
这套设计的优点是简单、稳定、推理开销小,但问题也恰恰出在这里。金字塔融合的本质是"把不同分辨率的特征搬到同一个尺度上",搬运之后的信息交互仍然局限在卷积核的感受野里。比如P5的语义信息要往下传给P3,中间要经过多次上采样和局部卷积,每次传播都带着信息损耗,长距离的依赖关系很难完整保留。
我做了一个很粗糙的消融实验:在基线模型上把Neck的融合次数增加一倍,mAP只动了不到半个点。这说明瓶颈根本不是"融合得不够多",而是每次融合能看到的上下文范围太有限。检测模型里有很多难样本——被遮挡的目标、和背景纹理接近的目标、小尺寸目标——这些恰恰需要的是全局线索,而不是更大范围的局部卷积。
1.2 全局上下文到底有什么用
说一个适合理解全局上下文的场景:一张图里有一个人蹲在树丛后面,只露出半截肩膀。单纯看局部特征,这个区域和树丛的纹理高度相似,分类器很容易判错。但如果模型能看到整幅图的语义分布——前面是公园、附近有长椅、人的肢体结构在遮挡边缘有连续性——判断结果就会完全不一样。
传统FPN对这种需求的支撑是间接的。它靠逐层传递把深层语义"扩散"到浅层,理论上P3特征里应该已经含有全局信息,但实际经过多次卷积和上采样之后,这种信息已经被稀释得非常严重。注意力机制(特别是自注意力)的优势在于,它让任意位置的特征可以直接和全图任意位置交互,路径长度恒为1,不存在逐层衰减的问题。
1.3 为什么选择CTrans而不是直接堆Transformer
直接堆一个纯Transformer进Neck,最直观的问题是计算量。Transformer对特征图做全局注意力,复杂度是序列长度的平方。如果把它接到YOLO26的P3层——通常是原图1/8分辨率,一张训练图上可能有上万甚至几万个token——显存和训练时间都顶不住。而且纯Transformer对局部纹理细节的敏感度不如卷积,检测任务里小目标非常依赖局部梯度信息,不能为了全局视野丢掉局部细节。
CTrans这种"CNN+Transformer"双分支结构就恰好卡在这个点上:局部分支继续用卷积守住空间细节,全局分支把特征压缩到较小维度做注意力交互,最后再把两边融合起来。局部不丢,全局能补,复杂度还可以通过压缩维度来控制。这个思路在分割任务上已经验证得比较充分,我当时想的是同一套逻辑放到目标检测的金字塔结构里理论上也能成立,剩下的就是怎么接、接在哪的问题。
2. CTrans模块的拆解:局部钉子户与全局视野的配合
2.1 整体结构:双分支加残差
我实现的CTransBlock不是完整照搬某个现成版本,而是按"可插拔、轻量化、方便做消融"的原则重新梳理过的,整体分三条路:一条局部卷积分支、一条全局Transformer分支、一个融合输出加残差。
输入特征进来后先分两路。局部分支用深度可分离卷积加1x1卷积做,保证感知局部结构的同时参数很少。全局分支先通过一个1x1卷积把通道压到原通道的四分之一左右,然后展平成序列送进几个Transformer编码层做自注意力,注意力做完再恢复成特征图并扩回原通道。融合阶段把两条分支的结果沿通道拼起来,过1x1卷积融合,最后和输入做残差相加。
用四分之一通道这个设计,不是拍脑袋定的。注意力模块的FLOPs主要花在QKV投影和注意力矩阵计算上,通道压到四分之一之后,整个全局分支的开销大概只占模块总开销的三成左右。这个比例在训练实测中比较均衡,既能保证Transformer有足够的表达能力,又不至于让训练速度断崖式下跌。
2.2 局部特征分支的卷积选型
局部分支我用了一个深度可分离卷积加一个1x1卷积的组合,中间带BatchNorm和SiLU激活。深度可分离卷积先按通道分别做3x3空间卷积,再用1x1卷积做通道交互,参数大约是普通3x3卷积的九分之一。这个设计并不是为了省参数而省参数,而是保证局部分支的职责足够"纯粹"——它只需要捕捉边缘、纹理、小目标的局部结构,不需要承担太大范围的语义抽象。
这里有个容易被忽视的细节:局部分支的BatchNorm应该保持独立,而不能和全局分支共用统计量。因为局部分支处理的是高分辨率、方差较大的浅层特征,全局分支处理的是压缩后的深层语义特征,两者的特征分布差异很大。强行共用BN统计量会导致训练早期统计量来回震荡,收敛不稳定。
2.3 全局分支的Transformer编码器
全局分支先把特征图展平成序列,每个位置对应一个空间位置的token。我用的编码器就是标准套路:LayerNorm加多头自注意力,再接LayerNorm加前馈网络,中间用残差串起来。前馈网络的扩展倍数我设成了2倍而不是常见的4倍,因为在Neck这种本身已经被卷积处理过的特征上,过大的FFN容量很容易过拟合,对提升没有帮助。
自注意力的实现要注意输入顺序的逻辑。你拿到的特征图展平后,序列顺序其实就是从左到右、从上到下的空间扫描顺序,这个顺序本身对位置信息没有感知。所以我在编码器前面补了一个可学习的位置编码,否则Transformer对于"左上角和右下角是不是同一个点"完全没有概念,全局交互就真的变成了无序的全局混合。
2.4 融合层为什么用1x1卷积而不是直接相加
很多类似模块在融合时喜欢简单地把局部特征和全局特征逐元素相加。我试过这种加法,效果能用,但总感觉两个分支各自的特性会被互相稀释。改成通道拼接加1x1卷积之后,模型有主动权决定每一维通道分别从局部分支和全局分支取多少比例的信息,表达能力明显更好,实验指标也比加法版本高。
另外一个必须注意的约定是归一化层。局部分支用BatchNorm,全局分支内部用LayerNorm,这两套统计机制本身不冲突,因为它们各管各的。但融合之后的输出层不能再接一个没有充分预热的BN,我实际是把融合结果再过了一次LayerNorm再接残差。这样做的原因在于,融合层的输入统计分布取决于两条分支的组合模式,在训练过程中变化很剧烈,BN在这种场景下不够稳,LayerNorm对所有样本使用同样的归一化方式,反而更适合作为融合之后的稳定器。
3. 插进FPN的正确姿势:接入点选择与通道配比
3.1 三个接入位置的对比
把CTrans接进特征金字塔,位置不是随便选的。我当时列了三种方案,每个都做过小规模对比实验。
第一种,把Neck里原有的C2f/C3类融合块直接替换成CTransBlock。优点是改动最小,缺点是Neck里这样的融合块有好几个,全部替换后计算量直接翻倍,而且Transformer块的全局交互会重复计算,大量信息冗余。
第二种,在金字塔每个尺度的融合结果后面并联一个CTrans分支,浅层P3、中层P4、深层P5各放一个。这种做法是覆盖面最全的,但P3特征是图像分辨率的1/8,token数量巨大,全局注意力在P3上的开销完全失控,单卡显存直接报警。
第三种,把CTrans放在自顶向下融合结束之后、自底向上融合开始之前的过渡位置,让深层语义特征先完成一次全局交互,再向浅层传播。这个位置能最大程度发挥全局建模的作用,因为此时特征图已经带着金字塔各层的语义信息,全局交互等于在做一次"跨层信息梳理"。
三种方案我都跑过短周期的实验,效果排序是三大于二大于一,而性价比差距更明显。三方案的训练时间和基线只差不到百分之十五,二方案在同等效果的条件下却要多花近一倍的显存。
3.2 我采用的接入方案:深层主干加浅层旁路
最终我采用的是第三种方案的变体:在P5对应的深层特征上完整走一次CTrans,在P4和P3上用轻量旁路方式做局部增强,不引入全局注意力。P5特征分辨率低,通常只有原图的1/32,token数量可控,适合做完整的全局交互;P3和P4分辨率较高,但它们的全局信息可以通过深层特征传播下来,再加上局部卷积的细化就足够。
这样安排还有一个好处:深层特征经过自注意力梳理后,语义更干净,再由自底向上的路径逐步融合回浅层时,全局信息是"被带着走"的,而不是每个尺度都重新算一遍。我在代码里的做法是,把CTransBlock作为Neck的一个子模块实例化一次,只对P5调用,P3和P4的增强交给原有的卷积路径完成。
3.3 通道数与计算量的配比逻辑
通道数设计上,我遵循一个原则:Transformer分支的隐层维度不要超过输入通道的四分之一,编码器深度控制在两层,注意力头数四到六个。以P5为例,如果输入通道是1024,那么全局分支的token维度压到256,注意力矩阵是256x256的,计算量在一个很舒服的范围。
模型配置文件里我额外加了几个可调参数:ctrans_depth控制编码器层数,ctrans_heads控制注意力头数,ctrans_enc_ratio控制压缩比例。这样后续做消融实验很方便,不用改代码。我强烈建议你做改进模块时也保留这种"配置化"的思路,所有超参数都暴露到配置文件里,而不是写死在模块里,否则跑对比实验时会浪费大量时间在改代码重跑上。
# neck部分配置示意,实际字段以你自己的代码结构为准 neck: type: CTransFPN ctrans_depth: 2 ctrans_heads: 4 ctrans_enc_ratio: 0.25 ctrans_apply_scales: [P5]4. 代码落地:从模块定义到训练配置的完整链路
4.1 CTransBlock核心实现
我用PyTorch风格写了一份可以直接复用的核心实现。注意这份代码只包含了模块本身,挂载位置需要你自己根据Neck的结构调整。如果你用的是类似nn.Module的框架,把下面这段代码放进模型定义文件里就能用:
import torch import torch.nn as nn import torch.nn.functional as F class CTransEncoderLayer(nn.Module): def __init__(self, dim, heads=4, expand=2, dropout=0.0): super().__init__() self.norm1 = nn.LayerNorm(dim) self.attn = nn.MultiheadAttention( dim, heads, dropout=dropout, batch_first=True ) self.norm2 = nn.LayerNorm(dim) self.ffn = nn.Sequential( nn.Linear(dim, dim * expand), nn.GELU(), nn.Linear(dim * expand, dim), ) def forward(self, x): x = x + self.attn(self.norm1(x), self.norm1(x), self.norm1(x))[0] x = x + self.ffn(self.norm2(x)) return x class CTransBlock(nn.Module): def __init__(self, c_in, enc_ratio=0.25, depth=2, heads=4): super().__init__() enc_dim = max(32, int(c_in * enc_ratio)) # 局部分支:深度可分离卷积 self.local = nn.Sequential( nn.Conv2d(c_in, c_in, 3, 1, 1, groups=c_in, bias=False), nn.Conv2d(c_in, c_in, 1, 1, bias=False), nn.BatchNorm2d(c_in), nn.SiLU(inplace=True), ) # 全局分支:通道压缩 + Transformer编码 self.reduce = nn.Conv2d(c_in, enc_dim, 1, bias=False) self.pos_embed = nn.Parameter(torch.randn(1, 0, enc_dim) * 0.02) self.encoder = nn.ModuleList([ CTransEncoderLayer(enc_dim, heads=heads) for _ in range(depth) ]) self.recover = nn.Conv2d(enc_dim, c_in, 1, bias=False) # 融合 self.merge = nn.Conv2d(c_in * 2, c_in, 1, bias=False) self.fuse_norm = nn.LayerNorm(c_in) def forward(self, x): identity = x local_out = self.local(x) b, c, h, w = x.shape token = self.reduce(x).flatten(2).transpose(1, 2) # B, L, D if self.pos_embed.shape[1] != token.shape[1]: self._reset_pos_embed(token.shape[1]) token = token + self.pos_embed for layer in self.encoder: token = layer(token) global_out = token.transpose(1, 2).reshape(b, -1, h, w) global_out = self.recover(global_out) fused = torch.cat([local_out, global_out], dim=1) fused = self.merge(fused) fused = fused.permute(0, 2, 3, 1) fused = self.fuse_norm(fused).permute(0, 3, 1, 2) return fused + identity def _reset_pos_embed(self, length): self.pos_embed = nn.Parameter( torch.randn(1, length, self.pos_embed.shape[-1]) * 0.02 ).to(self.pos_embed.device)4.2 位置编码这个坑:动态长度问题
上面代码里我留下了一段动态重置位置编码的逻辑,这是我在实际跑实验时踩到的第一个坑。原因很简单:训练时输入图像的尺寸往往不是固定的,就算你固定了训练尺寸,模型在不同阶段也可能遇到不同分辨率的输入。位置编码的序列长度必须和当前token数一致,否则会直接报维度错误或者产生不对齐的偏差。
最省事的做法是固定训练分辨率,并把位置编码初始化为一个足够大的长度,比如2048,然后运行时截取。如果你希望模型能适配多尺度推理,那就用我上面这种动态重置逻辑,或者干脆改用没有位置编码的注意力变体。但说实话,对检测任务来说,直接固定训练尺寸、保持位置编码语义稳定,效果比动态重置更稳,也更好复现。
4.3 挂载进Neck的forward
挂载的方式取决于你的Neck代码结构。我这边是在Neck的forward里拿到P5特征之后、进入自底向上融合之前加的调用,核心逻辑只有两行:
# 伪代码示意,按你的Neck实现调整 p5 = self.c_trans_block(p5) # CTransBlock实例这里有一个我自己验证过的顺序问题:CTrans应该放在"自顶向下融合结束之后"而不是之前。如果放在融合之前,P5特征还只包含来自Backbone的信息,金字塔层级之间的信息没有先交互,CTrans能梳理的内容就少了一块。放在融合之后,输入CTrans的特征已经包含了FPN传递下来的多尺度信息,全局注意力的起点更高,效果自然更好。
4.4 训练超参和AMP的坑
训练层面,我把Transformer相关参数单独划分了一个参数组,学习率设成主干的十分之一。原因是Transformer对学习率非常敏感,用主干的学习率直接训练,前期loss容易震荡,甚至会出现注意力权重某个头突然失效的情况。单独给低学习率之后,整个训练过程明显平顺。
混合精度训练也必须留意。LayerNorm和softmax在fp16下容易出现精度不足的问题,尤其是当特征值分布方差较大时,softmax会退化成近乎one-hot的形式。我的做法是检查训练日志里有没有出现NaN的loss,如果有,把LayerNorm的计算强制回退到fp32。很多框架支持在LayerNorm层上加一个fp32的标志,务必把这个开关打开。
5. 实验对比与踩坑记录:提升没有想象中那么快,但确实稳
5.1 实验条件与结果对比
我在自己整理的一份目标检测数据集上做了对比实验,数据以车辆和小目标为主,训练和验证严格隔离,保证结论的趋势可信。评价指标看mAP50和mAP50:95,以及参数量和推理速度的变化。以下数据只反映我自己数据上的相对趋势,不同数据集上具体数值会有浮动,但相对关系应该具备参考价值:
| 模型变体 | mAP50 | mAP50:95 | 参数量 | 推理FPS |
|---|---|---|---|---|
| YOLO26基线 | 52.3 | 34.1 | 28.6M | 214 |
| 基线+CTrans(P5全局) | 54.6 | 36.8 | 31.2M | 183 |
| 基线+CTrans(P3/P4/P5全接) | 54.9 | 37.0 | 34.7M | 141 |
| 基线+等参数纯CNN替换 | 53.1 | 35.0 | 30.4M | 201 |
看到结果第一个感受是:提升是有的,mAP50涨了大约2.3个点,而且mAP50:95的涨幅比例更明显,说明CTrans对定位质量的帮助比对分类概率更显著。第二个感受是,全尺度接CTrans的收益比只接P5并没有质的提升,却付出了将近一半的推理速度代价,这个性价比直接否定了"接得越多越好"的直觉。
5.2 踩坑一:注意力权重发散
第一次跑完整训练时,loss曲线在epoch 20左右突然出现一次尖峰,然后精度明显下滑。查了半天,问题出在自注意力的缩放因子和初始化上。我在实现里用了标准的多头注意力,内部缩放因子是1除以根号dim,理论上没问题,但因为我压缩后的维度只有64,这个缩放值很小,再加上投影矩阵用默认初始化,训练初期注意力分布就会非常极端,容易在某一个epoch被离群样本带偏。
解决方式有两个,我都试了。一个是把QKV投影改成零初始化,这样训练初期注意力等于恒等映射,模块对主干特征的影响从零开始逐渐变大,训练非常稳定。另一个是给注意力矩阵加温度系数,类似让softmax更平滑。实测下来零初始化效果更好,也更简单,推荐优先尝试。
5.3 踩坑二:BN分支与LN分支的统计冲突
局部分支的BatchNorm和融合后的LayerNorm在长时间训练后出现了一个隐蔽的问题:某个epoch的验证精度突然抖动,然后恢复,过几个epoch又抖一次。定位后发现原因是局部分支的BN统计量在训练后期还在缓慢漂移,而融合后的LayerNorm是对整条通道做归一化,归一化结果会被这种漂移影响。
这个坑的根治办法是让局部分支也放弃BatchNorm,改用GroupNorm或者干脆在CTransBlock内部全部使用LayerNorm风格的归一化。我最终选择了给局部分支换用GroupNorm,分组数量设为通道数除以8。本地分支和全局分支完全解耦各自的归一化方式之后,验证精度曲线的抖动消失了。如果你用更大的batch size,BN漂移问题可能不明显,但小batch训练时一定要考虑这一点。
5.4 踩坑三:大分辨率特征图的内存爆炸
刚开始做全尺度接入实验时,P3层特征(原图1/8分辨率)进入CTrans后,显存占用直接飙到显存上限,训练完全跑不起来。即使只接P4和P5,如果输入分辨率偏大,P4的token数量也相当可观。自注意力的中间矩阵是token数的平方级存储,这个数学规律躲不掉。
最终方案就是把全局交互限制在P5,P4最多做一次局部增强,绝不把自注意力用在原图1/8级别的特征上。如果你确实需要在中等尺度也保留全局交互,可以考虑窗口注意力或者轴注意力,把全局交互拆成行和列两次局部交互,复杂度从平方级降到线性级,但这属于另一套更复杂的改造了。
6. 什么场景下值得用CTrans改进FPN:别盲目堆模块
如果让我给出一个明确的使用判断标准,我会这样问:你的任务里,目标间的上下文关系重要吗?如果你的数据集大量包含遮挡目标、小目标、背景干扰强的场景,比如遥感图像里的密集目标、自动驾驶场景里的遮挡行人,那CTrans这种全局建模模块能提供的帮助非常明显,值得加。如果你的场景是简单背景下的常规目标检测,目标清晰、尺度均匀,那么花在CTrans上的开销大概率换不来同等比例的精度提升,不如把预算花在更强的数据增强上。
我的建议是,优先试"仅P5接CTrans"的轻量方案,如果提升不明显,再考虑加深编码器层数,而不是一开始就全尺度铺开。我在实际对比中就发现,编码器层数从一层加到两层,mAP提升明显;从两层加到四层,提升就很小了,但训练时间几乎翻倍。这可能是Neck特征本身的抽象程度已经比较高,继续加深Transformer带来的边际收益有限。
最后再分享一个小技巧。做完CTrans的改进之后,我又试过把它搬到Backbone顶层替换最后的卷积层,发现效果远不如放在Neck里稳定。原因可能是Backbone顶层本身就承担着分类语义的抽象职责,直接叠加全局注意力和原有的分类分支会互相竞争学习目标。而Neck负责的是多尺度融合,这个阶段引入全局上下文正好补上了金字塔结构最大的短板。所以做模型改进,模块本身重要,放对位置更重要——这大概是我这次实验最值钱的一条经验。