016、Head结构改进(一):解耦头设计与分类-回归任务分离
·
从一次深夜调试说起
上周在部署YOLO到边缘设备时遇到一个诡异现象:同一个模型,在COCO数据集上mAP表现不错,但换到我们自己的工业缺陷检测场景,定位框总是“飘”。明明分类置信度很高,框的位置却差几个像素。排查了数据标注、预处理、甚至怀疑是量化误差,最后问题锁定在检测头。
打开YOLOv5的head代码,看到那个经典的耦合头输出——分类和回归共享同一组卷积特征。那一刻突然意识到:分类要的是语义抽象,回归要的是空间敏感,这俩任务用一个特征图硬扛,在复杂场景里容易互相“打架”。
解耦头:为什么要把分类和回归分开?
早期YOLO以及许多one-stage检测器采用耦合头,本质是为了速度。一个分支输出85维向量(4+1+80),简单粗暴。但YOLOv2、v3时代大家就发现,分类和回归的任务特性不同:
- 分类任务:关心“是什么”,需要高层语义特征,对空间位置变化相对鲁棒。
- 回归任务:关心“在哪里”,需要精确的空间坐标,对特征图的空间细节敏感。
让同一组卷积同时学习这两种特征,相当于让一个学生既学数学又学美术,还得用同一套思维方式——不是不行,但天花板有限。
动手改:从耦合头到解耦头
来看原始耦合头的典型实现(简化版):
class CoupledHead(nn.Module):
def __init__(self, in_channels, num_classes):
super().__init__()
self.conv = nn.Conv2d(in_channels, (5 + num_classes) * 3, 1) # 3个anchor
# 输出维度: [batch, (4+1+80)*3, H, W]
def forward(self, x):
return self.conv(x)
改成解耦头后:
class DecoupledHead(nn.Module):
def __init__(self, in_channels, num_classes, width=256):
super().__init__()
# 共享的中间特征提取
self.mid_conv = nn.Sequential(
nn.Conv2d(in_channels, width, 3, padding=1),
nn.BatchNorm2d(width),
nn.SiLU()
)
# 回归分支:输出xywh + objectness
self.reg_convs = nn.Sequential(
nn.Conv2d(width, width, 3, padding=1),
nn.BatchNorm2d(width),
nn.SiLU(),
nn.Conv2d(width, 4 * 3, 1) # 4*3个anchor
)
self.obj_conv = nn.Conv2d(width, 1 * 3, 1) # objectness单独学
# 分类分支:完全独立
self.cls_convs = nn.Sequential(
nn.Conv2d(width, width, 3, padding=1),
nn.BatchNorm2d(width),
nn.SiLU(),
nn.Conv2d(width, num_classes * 3, 1)
)
def forward(self, x):
x = self.mid_conv(x)
reg = self.reg_convs(x)
obj = self.obj_conv(x)
cls = self.cls_convs(x)
# 拼接成兼容格式 [batch, (4+1+80)*3, H, W]
# 注意顺序要和原版对齐,这里踩过坑!
return torch.cat([reg, obj, cls], dim=1)
几个关键点:
- 中间共享层别太深:一般1-2层足够,保持两个分支有一定关联,但别过度共享。
- objectness单独抽出来:很多论文把objectness和回归放一起,但我实验发现分开效果更好——objectness更像“有没有物体”,和“框多准”不是一回事。
- 宽度width要控制:嵌入式部署时,256已经很大了,可以降到128甚至64,精度损失不大。
训练技巧:别急着一起训练
直接替换头就开始端到端训练,可能收敛变慢。建议分两步:
# 第一阶段:冻结主干,只训练新头
for param in model.backbone.parameters():
param.requires_grad = False
# 训几轮让头先学会基本能力
train(head_only=True)
# 第二阶段:整体微调
for param in model.parameters():
param.requires_grad = True
# 这时候学习率要调小,别把刚学好的头冲坏了
train(full_model=True, lr=base_lr*0.1)
部署时的现实问题
解耦头增加了参数量和计算量,这是要还的债。在Jetson Nano上测试:
- 耦合头:8.2ms
- 解耦头(width=256):11.7ms
- 解耦头(width=128):9.1ms
建议:如果应用场景中分类难度大(比如要区分30种相似缺陷),用解耦头;如果只是检测“人/车”这种大类别差异,耦合头够用了,省下的算力可以换更大主干。
个人经验
- 别迷信论文指标:有些论文解耦头涨点明显,那是因为他们在COCO这种80类复杂数据集上测。你的应用如果只有3-5类,可能涨不到1个点,却多了30%计算量。
- objectness分支值得多关注:很多误检不是分类错了,而是objectness乱跳。试试给这个分支加个SE注意力,有时候有奇效。
- 从中间特征图可视化:把分类和回归分支的feature map用Grad-CAM可视化出来,你会看到它们关注区域确实不同——分类看整体,回归看边缘。这个图虽然不能放文章里,但自己看看心里踏实。
更多推荐
所有评论(0)