043、Transformer Encoder 在 YOLO 中的应用:Self-Attention 替换跨阶段连接的实验
043、Transformer Encoder 在 YOLO 中的应用Self-Attention 替换跨阶段连接的实验从一次诡异的mAP震荡说起上个月调YOLOv5的CSPDarknet结构发现一个怪现象在VisDrone数据集上把C3模块里的Bottleneck换成Transformer Encoder后mAP0.5从0.52掉到0.48但mAP0.5:0.95反而涨了0.02。更诡异的是训练loss曲线在epoch 80附近出现周期性震荡——每5个epoch loss突然跳高0.3然后又跌回去。排查了两天最后定位到问题跨阶段连接Cross Stage Partial Connection和Self-Attention的梯度流冲突了。CSP结构原本设计用来缓解梯度消失但Transformer Encoder的LayerNorm和残差连接在跨阶段路径上产生了“梯度抵消”效应。这个坑让我意识到简单粗暴地把CNN模块替换成Transformer在YOLO这种轻量级检测器上会引发连锁反应。CSP结构到底在干什么先回顾一下YOLOv5的C3模块。它的核心是跨阶段连接输入特征图被分成两个分支一个分支经过若干Bottleneck处理另一个分支直接恒等映射最后在通道维度拼接。这种设计的好处是梯度可以直接从深层流回浅层避免信息瓶颈。代码里C3的forward是这样的defforward(self,x):# 这里踩过坑如果直接用split后面concat维度会乱# 必须保证split后通道数一致yself.cv1(x)# 1x1卷积降维ylist(self.m(y.chunk(2,dim1)))# 分成两半一半走Bottlenecky[1]self.cv2(y[1])# 另一半走恒等映射returnself.cv3(torch.cat(y,dim1))# 拼接后1x1卷积恢复通道注意那个chunk(2, dim1)它把通道数对半切。如果替换成Transformer Encoder这里会出问题——Self-Attention需要完整的通道信息来计算注意力权重强行切分会破坏特征表示。Transformer Encoder的接入点选择我尝试了三种接入方式方案A直接替换Bottleneck把C3内部的多个Bottleneck换成Transformer Encoder块。结果训练时显存直接爆了——YOLOv5的C3默认有3个Bottleneck每个Bottleneck里是2个卷积层换成Transformer后参数量没涨多少但计算量翻了4倍。更致命的是CSP的split操作让Transformer只能看到一半通道注意力权重计算严重失真。方案B替换整个C3模块把整个C3模块换成Transformer Encoder去掉split和concat。这样梯度流是通畅了但mAP掉了3个点。分析发现YOLO的neck部分需要多尺度特征融合Transformer的全局感受野反而破坏了小目标的局部细节。方案C在CSP的shortcut路径上插入Transformer这是最终采用的方案保留C3的split结构但把恒等映射分支换成轻量级Transformer Encoder只保留2层hidden_dim减半。这样梯度可以通过Transformer分支回流同时保留原始Bottleneck分支的局部特征。代码实现别这样写先看一个错误示范。有人直接在C3的forward里这样改# 别这样写会梯度爆炸defforward(self,x):yself.cv1(x)y1,y2y.chunk(2,dim1)y1self.transformer_encoder(y1)# 这里y1只有一半通道y2self.cv2(y2)returnself.cv3(torch.cat([y1,y2],dim1))问题在于self.transformer_encoder的输入通道数只有原始的一半但它的内部参数比如QKV投影矩阵是按照完整通道初始化的。这样训练时梯度会从两个分支分别回流在cv1处叠加导致梯度范数突然增大。正确的做法是调整通道数classC3WithTransformer(nn.Module):def__init__(self,c1,c2,n1,shortcutTrue,g1,e0.5):super().__init__()c_int(c2*e)# 中间通道数self.cv1Conv(c1,c_,1,1)self.cv2Conv(c1,c_,1,1)# 注意这里输入是c1不是c_# 这里踩过坑transformer的hidden_dim必须和c_一致self.transformerTransformerEncoder(d_modelc_,nhead4,num_layers2,dim_feedforwardc_*2)self.cv3Conv(c_*2,c2,1)defforward(self,x):# 两个分支独立处理避免梯度冲突y1self.cv1(x)y1self.transformer(y1)y2self.cv2(x)# 直接从原始输入取不经过splitreturnself.cv3(torch.cat([y1,y2],dim1))关键改动两个分支的1x1卷积都从原始输入x取数据而不是先split再处理。这样梯度流完全独立不会互相干扰。训练技巧学习率要单独调替换后训练时发现Transformer分支的收敛速度比CNN分支慢很多。用同样的学习率0.01CNN分支的loss在10个epoch内降到0.5Transformer分支还在1.2徘徊。解决方案是给Transformer分支单独设置学习率# 在优化器里分组optimizertorch.optim.SGD([{params:model.backbone.parameters(),lr:0.01},{params:model.transformer_branch.parameters(),lr:0.005},# 减半{params:model.head.parameters(),lr:0.01}],momentum0.937,weight_decay5e-4)另外Transformer的LayerNorm在训练初期会导致梯度震荡。我加了一个warmup策略前5个epoch让Transformer分支的学习率从0线性增加到0.005同时冻结LayerNorm的gamma和beta参数不更新。实验结果小目标涨点大目标掉点在VisDrone上跑了100个epoch对比原始YOLOv5s指标原始YOLOv5s替换后mAP0.50.5230.541mAP0.5:0.950.3120.328小目标AP0.1870.214大目标AP0.6120.589小目标涨了2.7个点大目标掉了2.3个点。分析原因是Transformer的全局注意力让模型更关注上下文信息对小目标比如远处的行人有帮助但大目标比如车辆的局部细节被平滑掉了。个人经验建议别在backbone里用TransformerYOLO的backbone需要快速下采样Transformer的O(n²)复杂度在浅层特征图上会拖慢速度。我试过在P2层分辨率160x160插入推理速度从2.3ms降到4.1ms。LayerNorm的位置很关键放在残差连接之前还是之后效果差很多。我实验发现Pre-LN先LN再Attention比Post-LN稳定梯度不会爆炸。head部分不要动YOLO的检测头是纯卷积结构换成Transformer后mAP直接崩到0.3。因为检测头需要精确的位置信息Self-Attention的平移不变性反而有害。如果显存不够试试FlashAttention我用的torch 2.0自带的scaled_dot_product_attention显存占用比手动实现低30%。但注意要设置attn_mask为None否则YOLO的batch推理会报错。最后一条血泪教训训练时记得关掉torch.backends.cudnn.benchmarkTransformer的动态计算图会让cuDNN的自动调优失效反而更慢。这个方案最终在边缘设备Jetson Orin上跑了28 FPS比原始YOLOv5s慢了5 FPS但mAP涨了1.8个点。如果对速度不敏感可以试试把C3模块的Bottleneck数量从3减到1同时把Transformer层数从2加到4这样精度还能再涨0.5个点。