043、Transformer Encoder 在 YOLO 中的应用:Self-Attention 替换跨阶段连接的实验
从一次诡异的mAP震荡说起
上个月调YOLOv5的CSPDarknet结构,发现一个怪现象:在VisDrone数据集上,把C3模块里的Bottleneck换成Transformer Encoder后,mAP@0.5从0.52掉到0.48,但mAP@0.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后通道数一致y=self.cv1(x)# 1x1卷积降维y=list(self.m(y.chunk(2,dim=1)))# 分成两半,一半走Bottlenecky[1]=self.cv2(y[1])# 另一半走恒等映射returnself.cv3(torch.cat(y,dim=1))# 拼接后1x1卷积恢复通道注意那个chunk(2, dim=1),它把通道数对半切。如果替换成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):y=self.cv1(x)y1,y2=y.chunk(2,dim=1)y1=self.transformer_encoder(y1)# 这里y1只有一半通道y2=self.cv2(y2)returnself.cv3(torch.cat([y1,y2],dim=1))问题在于self.transformer_encoder的输入通道数只有原始的一半,但它的内部参数(比如QKV投影矩阵)是按照完整通道初始化的。这样训练时梯度会从两个分支分别回流,在cv1处叠加,导致梯度范数突然增大。
正确的做法是调整通道数:
classC3WithTransformer(nn.Module):def__init__(self,c1,c2,n=1,shortcut=True,g=1,e=0.5):super().__init__()c_=int(c2*e)# 中间通道数self.cv1=Conv(c1,c_,1,1)self.cv2=Conv(c1,c_,1,1)# 注意这里输入是c1,不是c_# 这里踩过坑:transformer的hidden_dim必须和c_一致self.transformer=TransformerEncoder(d_model=c_,nhead=4,num_layers=2,dim_feedforward=c_*2)self.cv3=Conv(c_*2,c2,1)defforward(self,x):# 两个分支独立处理,避免梯度冲突y1=self.cv1(x)y1=self.transformer(y1)y2=self.cv2(x)# 直接从原始输入取,不经过splitreturnself.cv3(torch.cat([y1,y2],dim=1))关键改动:两个分支的1x1卷积都从原始输入x取数据,而不是先split再处理。这样梯度流完全独立,不会互相干扰。
训练技巧:学习率要单独调
替换后训练时发现,Transformer分支的收敛速度比CNN分支慢很多。用同样的学习率(0.01),CNN分支的loss在10个epoch内降到0.5,Transformer分支还在1.2徘徊。
解决方案是给Transformer分支单独设置学习率:
# 在优化器里分组optimizer=torch.optim.SGD([{'params':model.backbone.parameters(),'lr':0.01},{'params':model.transformer_branch.parameters(),'lr':0.005},# 减半{'params':model.head.parameters(),'lr':0.01}],momentum=0.937,weight_decay=5e-4)另外,Transformer的LayerNorm在训练初期会导致梯度震荡。我加了一个warmup策略:前5个epoch让Transformer分支的学习率从0线性增加到0.005,同时冻结LayerNorm的gamma和beta参数(不更新)。
实验结果:小目标涨点,大目标掉点
在VisDrone上跑了100个epoch,对比原始YOLOv5s:
| 指标 | 原始YOLOv5s | 替换后 |
|---|---|---|
| mAP@0.5 | 0.523 | 0.541 |
| mAP@0.5:0.95 | 0.312 | 0.328 |
| 小目标AP | 0.187 | 0.214 |
| 大目标AP | 0.612 | 0.589 |
小目标涨了2.7个点,大目标掉了2.3个点。分析原因是Transformer的全局注意力让模型更关注上下文信息,对小目标(比如远处的行人)有帮助,但大目标(比如车辆)的局部细节被平滑掉了。
个人经验建议
别在backbone里用Transformer:YOLO的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.benchmark,Transformer的动态计算图会让cuDNN的自动调优失效,反而更慢。
这个方案最终在边缘设备(Jetson Orin)上跑了28 FPS,比原始YOLOv5s慢了5 FPS,但mAP涨了1.8个点。如果对速度不敏感,可以试试把C3模块的Bottleneck数量从3减到1,同时把Transformer层数从2加到4,这样精度还能再涨0.5个点。