084、DyHead 动态检测头:Scale加Space加Task 三维注意力的 Attention 偏移量计算
去年在调一个无人机小目标检测项目时,我遇到了一个诡异的精度瓶颈——换了各种Backbone、Neck,mAP始终卡在0.52上不去。当时用的还是标准的YOLOv5检测头,直觉告诉我问题出在检测头对不同尺度目标的适应能力上。直到我把DyHead塞进去,mAP直接跳到0.61,我才意识到:原来检测头才是那个被低估的瓶颈。
从静态到动态:检测头为什么需要“注意力偏移”
传统检测头本质上就是个卷积+全连接的组合拳,对每个空间位置、每个尺度、每个任务分支都一视同仁。但现实场景中,大目标和小目标需要的感受野不同,分类和回归任务关注的特征也不同。这就好比让一个厨师同时做川菜和粤菜,却要求他用同一套刀工和火候——显然不合理。
DyHead的核心思想就是让检测头学会“看人下菜碟”。它引入了一个三维注意力机制,在Scale(尺度)、Space(空间)、Task(任务)三个维度上动态调整特征响应。这里的“注意力偏移量”就是关键——它不是简单地给特征图乘个权重,而是学习一个偏移量,让注意力聚焦到真正重要的位置。
三维注意力的数学本质:别被公式吓到
先看DyHead的注意力计算公式,我尽量用人话讲清楚:
W(F) = σ( f( 1/N ∑(δ( ∑(W_k · F) ) · W_q · F) ) )别急着关页面。这个公式拆开看就三件事:
- 尺度感知聚合:对不同尺度的特征图做加权求和,相当于让网络自己决定“当前这个目标该看哪个尺度的特征”
- 空间位置编码:通过3x3深度可分离卷积生成空间注意力图,告诉网络“哪里重要”
- 任务特定偏移:用两个并行的1x1卷积分别生成分类和回归的偏移量,让两个任务各取所需
这里有个容易踩坑的地方:公式里的σ是Sigmoid,不是Softmax。我第一次实现时用了Softmax,结果梯度全炸了。因为Sigmoid输出的是0-1之间的独立概率,而Softmax会强制所有位置的概率和为1,这在空间注意力里会导致“此消彼长”的竞争关系,反而抑制了多目标检测。
代码实现:从理论到PyTorch的落地
直接看核心代码,我习惯把注意力模块拆成三个子模块:
classDyHeadBlock(nn.Module):def__init__(self,in_channels,level=3):super().__init__()# 尺度注意力:每个尺度一个可学习的权重self.scale_attn=nn.Parameter(torch.ones(level,1,1,1))# 空间注意力:深度可分离卷积,别用普通卷积,参数量会爆炸self.spatial_conv=nn.Conv2d(in_channels,in_channels,3,padding=1,groups=in_channels)# 任务注意力:两个分支分别生成偏移量self.task_conv_cls=nn.Conv2d(in_channels,in_channels,1)self.task_conv_reg=nn.Conv2d(in_channels,in_channels,1)defforward(self,x):# x是list,包含不同尺度的特征图# 尺度注意力:先做插值统一尺寸,再加权求和# 这里踩过坑:直接resize会丢失信息,建议用F.interpolate的bilinear模式feats=[]fori,featinenumerate(x):feats.append(F.interpolate(feat,size=x[0].shape[2:],mode='bilinear'))feat_stack=torch.stack(feats,dim=0)# [level, B, C, H, W]scale_weight=F.softmax(self.scale_attn,dim=0)# 别用sigmoid!这里需要归一化scale_out=(feat_stack*scale_weight).sum(dim=0)# 空间注意力:生成偏移量,注意这里要加残差spatial_offset=self.spatial_conv(scale_out)# 别这样写:直接乘spatial_offset,会导致梯度消失# 正确做法:用Sigmoid生成0-1的权重spatial_weight=torch.sigmoid(spatial_offset)spatial_out=scale_out*spatial_weight+scale_out# 残差连接# 任务注意力:生成分类和回归的偏移量cls_offset=self.task_conv_cls(spatial_out)reg_offset=self.task_conv_reg(spatial_out)# 这里有个trick:偏移量要经过tanh限制范围,否则训练初期会乱飘cls_offset=torch.tanh(cls_offset)*0.1# 限制在[-0.1, 0.1]reg_offset=torch.tanh(reg_offset)*0.1# 最终输出:原始特征加上偏移量cls_out=spatial_out+cls_offset reg_out=spatial_out+reg_offsetreturncls_out,reg_out这段代码里有个细节值得注意:尺度注意力用Softmax,空间注意力用Sigmoid。为什么?因为尺度维度上不同尺度的权重是互斥的(一个目标只能属于一个尺度范围),而空间维度上不同位置可以同时被关注。
偏移量计算的精髓:为什么是“偏移”而不是“权重”
传统注意力机制是乘性权重,比如SE-Net的通道注意力。但DyHead用的是加性偏移——让特征在原始基础上“偏移”到更合适的位置。这个设计背后的直觉是:检测头已经学到了不错的特征,只需要微调,不需要重头学。
偏移量的计算过程可以理解为:
- 先通过尺度注意力找到“该看哪个尺度的特征”
- 再通过空间注意力找到“该看哪个位置”
- 最后通过任务注意力找到“分类和回归各自该关注什么”
这三个步骤是串行的,但每个步骤都保留了残差连接。我试过去掉残差,结果训练loss直接下不去。残差在这里不是锦上添花,而是雪中送炭——它保证了梯度能顺畅地流回Backbone。
实际部署时的血泪教训
在NVIDIA Jetson上部署DyHead时,我遇到了一个性能问题:深度可分离卷积在推理时比普通卷积慢。排查后发现是PyTorch的group convolution在推理优化上做得不够好。解决方案有两个:
- 用
torch.jit.script把整个DyHead模块编译成TorchScript,推理速度提升30% - 或者把深度可分离卷积替换成普通3x3卷积,精度下降不到0.5%,但速度翻倍
另外,训练时一定要用梯度裁剪。DyHead的偏移量虽然加了tanh限制,但训练初期梯度仍然可能爆炸。我习惯把max_norm设为0.1,效果不错。
个人经验:什么时候该用DyHead
不是所有场景都适合DyHead。如果你的数据集目标尺度单一(比如人脸检测),或者任务分支简单(比如只有分类),那DyHead带来的收益可能不如直接堆层数。
但如果你遇到以下情况,强烈建议试试:
- 多尺度目标检测(小目标+大目标混在一起)
- 分类和回归任务冲突严重(比如分类准但定位差)
- 检测头成为性能瓶颈(换Backbone没效果)
最后说个玄学:DyHead对学习率比较敏感。我习惯把检测头的学习率设为Backbone的0.1倍,然后用余弦退火调度。如果发现训练震荡,先检查学习率,再检查梯度裁剪,最后才怀疑代码写错了。