#|YOLOv6 | 模型结构与训练策略详细解析

如有错误,恳请指出。
美团的yolov6发布已经2个多月,现在把他的改进和知识点稍微总结一下,用这篇博客记录。github地址:https://github.com/meituan/YOLOv6
下面主要分为模型与训练策略两个方面去介绍,最后再进行总结。

文章目录

  • 1. 网络结构改进
    • 1.1 EfficientRep Backbone
      • SimSPPF
      • RepConv
      • RepBlock
    • 1.2 Rep-PAN
    • 1.3 Decoupled Head
  • 2. 训练策略改进
    • 2.1 Anchor-free 无锚范式
    • 2.2 SimOTA 标签分配策略
    • 2.3 SIoU 边界框回归损失
  • 3. 实验结果与总结

1. 网络结构改进 1.1 EfficientRep Backbone 参考了重参数结构化的实现,对多卷积分支的拓扑结构进行融合。其实yolov5中也有使用到,yolov5中对3x3卷积和bn层进行了融合。
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

接下来就是看yolov6的网络结构部分,可以看见这里提出了3个模块:RepConv,RepBlock,SimSPPF,由于这里没有论文直接看是什么东西,所以这里分别来看源码。
SimSPPF
其实吧,通过源码分析,yolov6的代码很大部分是套用yolov5的代码结构的。所谓的SimSPPFSimConv本质上只是激活函数使用了ReLU而不是SiLU
  • SimSPPF类
class SimSPPF(nn.Module): '''Simplified SPPF with ReLU activation''' def __init__(self, in_channels, out_channels, kernel_size=5): super().__init__() c_ = in_channels // 2# hidden channels self.cv1 = SimConv(in_channels, c_, 1, 1) self.cv2 = SimConv(c_ * 4, out_channels, 1, 1) self.m = nn.MaxPool2d(kernel_size=kernel_size, stride=1, padding=kernel_size // 2)def forward(self, x): x = self.cv1(x) with warnings.catch_warnings(): warnings.simplefilter('ignore') y1 = self.m(x) y2 = self.m(y1) return self.cv2(torch.cat([x, y1, y2, self.m(y2)], 1))

  • 原yolov5的SPPF类
class SPPF(nn.Module): # Spatial Pyramid Pooling - Fast (SPPF) layer for YOLOv5 by Glenn Jocher def __init__(self, c1, c2, k=5):# equivalent to SPP(k=(5, 9, 13)) super().__init__() c_ = c1 // 2# hidden channels self.cv1 = Conv(c1, c_, 1, 1) self.cv2 = Conv(c_ * 4, c2, 1, 1) self.m = nn.MaxPool2d(kernel_size=k, stride=1, padding=k // 2)def forward(self, x): x = self.cv1(x) with warnings.catch_warnings(): warnings.simplefilter('ignore')# suppress torch 1.9.0 max_pool2d() warning y1 = self.m(x) y2 = self.m(y1) return self.cv2(torch.cat((x, y1, y2, self.m(y2)), 1))

RepConv
我最早接触参数重结构化这个词是看见了大佬丁霄汉发表的几篇论文:RepVGG,RepMLP,RepLKNet,这些构建新backbone的论文无一例外的全部使用了参数重结构化的思想。
RepVGG将3x3,1x1,identity分支的残差结果利用数学计算方法等价为一个3x3的卷积结构,实现训练与推断过程的解耦;RepMLP将局部的CNN先验信息加进了全连接层,使得其与MLP相结合等等。这里需要注意,重结构化层MLP结构也不是说变成Linear层,而是简化为1x1的卷积。(后续有机会把这几篇文章介绍一下,或者直接看大佬的知乎:https://www.zhihu.com/people/ding-xiao-yi-93/posts)
这里源码中是改写了RepVGG的代码,原理如下图所示:
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

核心的思想是为了解耦训练过程与推理过程。训练过程中花的时间可以长点,缩短推理时间。在推理过程中,重结构化的网络拓扑结构会变得简单,加快推理速度。
相关的参数重结构化的技巧已经使用得比较的广泛,详细见参考资料3.
RepBlock
这个就没什么好说的了,就是多个RepConv的堆叠,不过其中 RepBlock 的第一个 RepConv 会做 channel 维度的变换和对齐。
  • RepBlock代码:
class RepBlock(nn.Module): ''' RepBlock is a stage block with rep-style basic block ''' def __init__(self, in_channels, out_channels, n=1, block=RepVGGBlock): super().__init__() self.conv1 = block(in_channels, out_channels) self.block = nn.Sequential(*(block(out_channels, out_channels) for _ in range(n - 1))) if n > 1 else Nonedef forward(self, x): x = self.conv1(x) if self.block is not None: x = self.block(x) return x

1.2 Rep-PAN 【#|YOLOv6 | 模型结构与训练策略详细解析】结构示意图如下所示:
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

这里的1x1 Conv其实是1x1卷积的作为通道降维操作,Upsample是使用了反卷积nn.ConvTranspose2d(其实也是卷积的一种);3x3 Conv为了减半尺寸,RepBlock与backbone中的类似。
直接看源码就比较清晰了:
class RepPANNeck(nn.Module): """RepPANNeck Module EfficientRep is the default backbone of this model. RepPANNeck has the balance of feature fusion ability and hardware efficiency. """def __init__( self, channels_list=None, num_repeats=None, block=RepVGGBlock, ): super().__init__()assert channels_list is not None assert num_repeats is not Noneself.Rep_p4 = RepBlock( in_channels=channels_list[3] + channels_list[5], out_channels=channels_list[5], n=num_repeats[5], block=block )self.Rep_p3 = RepBlock( in_channels=channels_list[2] + channels_list[6], out_channels=channels_list[6], n=num_repeats[6], block=block )self.Rep_n3 = RepBlock( in_channels=channels_list[6] + channels_list[7], out_channels=channels_list[8], n=num_repeats[7], block=block )self.Rep_n4 = RepBlock( in_channels=channels_list[5] + channels_list[9], out_channels=channels_list[10], n=num_repeats[8], block=block )self.reduce_layer0 = SimConv( in_channels=channels_list[4], out_channels=channels_list[5], kernel_size=1, stride=1 )self.upsample0 = Transpose( in_channels=channels_list[5], out_channels=channels_list[5], )self.reduce_layer1 = SimConv( in_channels=channels_list[5], out_channels=channels_list[6], kernel_size=1, stride=1 )self.upsample1 = Transpose( in_channels=channels_list[6], out_channels=channels_list[6] )self.downsample2 = SimConv( in_channels=channels_list[6], out_channels=channels_list[7], kernel_size=3, stride=2 )self.downsample1 = SimConv( in_channels=channels_list[8], out_channels=channels_list[9], kernel_size=3, stride=2 )def forward(self, input):(x2, x1, x0) = inputfpn_out0 = self.reduce_layer0(x0) upsample_feat0 = self.upsample0(fpn_out0) f_concat_layer0 = torch.cat([upsample_feat0, x1], 1) f_out0 = self.Rep_p4(f_concat_layer0)fpn_out1 = self.reduce_layer1(f_out0) upsample_feat1 = self.upsample1(fpn_out1) f_concat_layer1 = torch.cat([upsample_feat1, x2], 1) pan_out2 = self.Rep_p3(f_concat_layer1)down_feat1 = self.downsample2(pan_out2) p_concat_layer1 = torch.cat([down_feat1, fpn_out1], 1) pan_out1 = self.Rep_n3(p_concat_layer1)down_feat0 = self.downsample1(pan_out1) p_concat_layer2 = torch.cat([down_feat0, fpn_out0], 1) pan_out0 = self.Rep_n4(p_concat_layer2)outputs = [pan_out2, pan_out1, pan_out0]return

1.3 Decoupled Head
  • 结构示意图:
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

咋一看可能也看不懂,其实其意思就是将解耦进行到底。为每一层的预测特征层都配置不一样的回归卷积,分类卷积,置信度卷积。按照一般的FPN结构是3层,那么现在就是每一层都会有其独立的卷积输出。整个detect head不共享任何的结构,参数完全是独立的。
  • 源码如下:
class Detect(nn.Module): '''Efficient Decoupled Head With hardware-aware degisn, the decoupled head is optimized with hybridchannels methods. ''' def __init__(self, num_classes=80, anchors=1, num_layers=3, inplace=True, head_layers=None):# detection layer super().__init__() assert head_layers is not None self.nc = num_classes# number of classes self.no = num_classes + 5# number of outputs per anchor self.nl = num_layers# number of detection layers if isinstance(anchors, (list, tuple)): self.na = len(anchors[0]) // 2 else: self.na = anchors self.anchors = anchors self.grid = [torch.zeros(1)] * num_layers self.prior_prob = 1e-2 self.inplace = inplace stride = [8, 16, 32]# strides computed during build self.stride = torch.tensor(stride)# Init decouple head self.cls_convs = nn.ModuleList() self.reg_convs = nn.ModuleList() self.cls_preds = nn.ModuleList() self.reg_preds = nn.ModuleList() self.obj_preds = nn.ModuleList() self.stems = nn.ModuleList()# Efficient decoupled head layers for i in range(num_layers): idx = i*6 self.stems.append(head_layers[idx]) self.cls_convs.append(head_layers[idx+1]) self.reg_convs.append(head_layers[idx+2]) self.cls_preds.append(head_layers[idx+3]) self.reg_preds.append(head_layers[idx+4]) self.obj_preds.append(head_layers[idx+5])def initialize_biases(self): for conv in self.cls_preds: b = conv.bias.view(self.na, -1) b.data.fill_(-math.log((1 - self.prior_prob) / self.prior_prob)) conv.bias = torch.nn.Parameter(b.view(-1), requires_grad=True) for conv in self.obj_preds: b = conv.bias.view(self.na, -1) b.data.fill_(-math.log((1 - self.prior_prob) / self.prior_prob)) conv.bias = torch.nn.Parameter(b.view(-1), requires_grad=True)def forward(self, x): z = [] for i in range(self.nl): x[i] = self.stems[i](x[i]) cls_x = x[i] reg_x = x[i] cls_feat = self.cls_convs[i](cls_x) cls_output = self.cls_preds[i](cls_feat) reg_feat = self.reg_convs[i](reg_x) reg_output = self.reg_preds[i](reg_feat) obj_output = self.obj_preds[i](reg_feat) if self.training: x[i] = torch.cat([reg_output, obj_output, cls_output], 1) bs, _, ny, nx = x[i].shape x[i] = x[i].view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous() else: y = torch.cat([reg_output, obj_output.sigmoid(), cls_output.sigmoid()], 1) bs, _, ny, nx = y.shape y = y.view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous() if self.grid[i].shape[2:4] != y.shape[2:4]: d = self.stride.device yv, xv = torch.meshgrid([torch.arange(ny).to(d), torch.arange(nx).to(d)]) self.grid[i] = torch.stack((xv, yv), 2).view(1, self.na, ny, nx, 2).float() if self.inplace: y[..., 0:2] = (y[..., 0:2] + self.grid[i]) * self.stride[i]# xy y[..., 2:4] = torch.exp(y[..., 2:4]) * self.stride[i] # wh else: xy = (y[..., 0:2] + self.grid[i]) * self.stride[i]# xy wh = torch.exp(y[..., 2:4]) * self.stride[i]# wh y = torch.cat((xy, wh, y[..., 4:]), -1) z.append(y.view(bs, -1, self.no))# Train: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25] # Eval:0: [1, 19200+4800+1200, 25] #1: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25] return x if self.training else (torch.cat(z, 1), x)def build_effidehead_layer(channels_list, num_anchors, num_classes): head_layers = nn.Sequential( # stem0 Conv( in_channels=channels_list[6], out_channels=channels_list[6], kernel_size=1, stride=1 ), # cls_conv0 Conv( in_channels=channels_list[6], out_channels=channels_list[6], kernel_size=3, stride=1 ), # reg_conv0 Conv( in_channels=channels_list[6], out_channels=channels_list[6], kernel_size=3, stride=1 ), # cls_pred0 nn.Conv2d( in_channels=channels_list[6], out_channels=num_classes * num_anchors, kernel_size=1 ), # reg_pred0 nn.Conv2d( in_channels=channels_list[6], out_channels=4 * num_anchors, kernel_size=1 ), # obj_pred0 nn.Conv2d( in_channels=channels_list[6], out_channels=1 * num_anchors, kernel_size=1 ), # stem1 Conv( in_channels=channels_list[8], out_channels=channels_list[8], kernel_size=1, stride=1 ), # cls_conv1 Conv( in_channels=channels_list[8], out_channels=channels_list[8], kernel_size=3, stride=1 ), # reg_conv1 Conv( in_channels=channels_list[8], out_channels=channels_list[8], kernel_size=3, stride=1 ), # cls_pred1 nn.Conv2d( in_channels=channels_list[8], out_channels=num_classes * num_anchors, kernel_size=1 ), # reg_pred1 nn.Conv2d( in_channels=channels_list[8], out_channels=4 * num_anchors, kernel_size=1 ), # obj_pred1 nn.Conv2d( in_channels=channels_list[8], out_channels=1 * num_anchors, kernel_size=1 ), # stem2 Conv( in_channels=channels_list[10], out_channels=channels_list[10], kernel_size=1, stride=1 ), # cls_conv2 Conv( in_channels=channels_list[10], out_channels=channels_list[10], kernel_size=3, stride=1 ), # reg_conv2 Conv( in_channels=channels_list[10], out_channels=channels_list[10], kernel_size=3, stride=1 ), # cls_pred2 nn.Conv2d( in_channels=channels_list[10], out_channels=num_classes * num_anchors, kernel_size=1 ), # reg_pred2 nn.Conv2d( in_channels=channels_list[10], out_channels=4 * num_anchors, kernel_size=1 ), # obj_pred2 nn.Conv2d( in_channels=channels_list[10], out_channels=1 * num_anchors, kernel_size=1 ) ) return head_layers

yolov5的detect head我之前是写过博客记录的,见:Yolov5-6.0系列 | yolov5的模型网络构建。
补充:注意美团这里yolov6的forward过程:
if self.grid[i].shape[2:4] != y.shape[2:4]: d = self.stride.device yv, xv = torch.meshgrid([torch.arange(ny).to(d), torch.arange(nx).to(d)]) self.grid[i] = torch.stack((xv, yv), 2).view(1, self.na, ny, nx, 2).float() if self.inplace: y[..., 0:2] = (y[..., 0:2] + self.grid[i]) * self.stride[i]# xy y[..., 2:4] = torch.exp(y[..., 2:4]) * self.stride[i] # wh else: xy = (y[..., 0:2] + self.grid[i]) * self.stride[i]# xy wh = torch.exp(y[..., 2:4]) * self.stride[i]# wh y = torch.cat((xy, wh, y[..., 4:]), -1)

其还是沿用了yolov3的偏移设置
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

并没有使用yolov5所提出的消除网格的方法,所以还会存在一些极端点网络无法处理。所以有理由详细使用yolov5的网格敏感度消除的方法会使得yolov6的性能进一步的提升。
if self.inplace: y[..., 0:2] = (y[..., 0:2] * 2 + self.grid[i]) * self.stride[i]# xy y[..., 2:4] = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i]# wh

在head这一部分,除了是完全参数独立之外,其实没有过太多的改进。训练和推理形式都是类似的,返回和推理的shape也是和yolov5类似的。
# Train: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25] # Eval:0: [1, 19200+4800+1200, 25] #1: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25] return x if self.training else (torch.cat(z, 1), x)

总结:
综上所述,将参数重结构化(3x3,1x1,残差分支合并为一个3x3卷积)方法使用在yolov5的框架中,改进了backbone与neck部分,方便部署。然后在head上使用了参数全独立的解耦。不过,缺点是甚至没有借鉴yolov5的网格敏感度消除,网络可能会出现极端点无法预测的情况。
2. 训练策略改进 2.1 Anchor-free 无锚范式 这个东西很早的时候就出现了,之前也写过一些列anchor-free算法的介绍:FCOS,CenterNet,ASFF,SAPD等等。具体参考专栏:目标检测论文专栏
由于 Anchor-based检测器需要在训练之前进行聚类分析以确定最佳 Anchor 集合,这会一定程度提高检测器的复杂度,而anchor-free就不需要任何anchor的手工或者是聚类的设置,直接是类似语义分割的想法在每个特征点上直接预测4个回归目标域k个分类目标。
不过不同算法的分配情况不同。
2.2 SimOTA 标签分配策略 YOLOv5 的标签分配策略是基于 Shape 匹配,并通过跨网格匹配策略增加正样本数量,从而使得网络快速收敛,但是该方法属于静态分配方法,并不会随着网络训练的过程而调整。
YOLOv6 引入了 SimOTA 算法动态分配正样本,进一步提高检测精度。在之前的文章已经对 SimOTA 标签分配策略进行了详细介绍,见:YoloX | SimOTA标签匹配策略
2.3 SIoU 边界框回归损失 近年来,常用的边界框回归损失包括IoU、GIoU、CIoU、DIoU loss等等,这些损失函数通过考虑预测框与目标框之前的重叠程度、中心点距离、纵横比等因素来衡量两者之间的差距,从而指导网络最小化损失以提升回归精度,但是这些方法都没有考虑到预测框与目标框之间方向的匹配性。
关于IoU、GIoU、CIoU、DIoU loss的具体介绍,之前的博客已有介绍,见:YOLOv4中的tricks概念总结——Bag of freebies
对于SIoU损失,其结果形式搞得相当的复杂,其具体包含了四个部分:角度损失(Angle cost)、距离损失(Distance cost)、形状损失(Shape cost)、IoU损失(IoU cost)
详细见:目标检测–边框回归损失函数SIoU原理详解及代码实现,这里作简要的摘抄。
  • 角度损失
    #|YOLOv6 | 模型结构与训练策略详细解析
    文章图片
  • 距离损失
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

其中:
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

  • 形状损失
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

  • IoU损失
    #|YOLOv6 | 模型结构与训练策略详细解析
    文章图片

  • SIoU损失
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

3. 实验结果与总结
  • 消融实验
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

  • 效果对比
#|YOLOv6 | 模型结构与训练策略详细解析
文章图片

yolov6的实验对比更加偏向于轻量级模型的对比。
对yolov6的提出,我主要从两个方面去做总结,一个是模型上的改进,另外一个是训练策略上的改进。
但是可以看见,无论是模型上的改进还是训练策略上的改进并没有提出一些比较新颖的见解。对于网络结构上的改进,主要是利用了重结构化的思想改进了yolov5的框架,同时将head进行参数的完全独立不共享(这样做可能会有效,但是感觉增加了很大的参数量)。而参数重结构化可以使得推理过程的速度加快,改变网络的拓扑结构。
对于训练策略上,没有消除网格的敏感度,可能会导致特殊值无法处理的情况。使用了一个比较新颖的Iou损失函数。但是,无论是模型还是训练策略,更感觉是一个大杂烩。特别是完全的参数独立的解耦头,只提升了0.2%,对于我们个人的配置有点怀疑其有效性,根绝投入和收获不成比例。
不过,yolov6的成功,更说明了参数重结构化的可行性与有效性。也侧面反映出yolov5的成功,影响深远。
参考资料:
1. YOLOv6:又快又准的目标检测框架开源啦
2. YOLOv6 github地址
3. 深度学习中的重参数机制总结和实现
4. Yolov5-6.0系列 | yolov5的模型网络构建
5. YoloX | SimOTA标签匹配策略
6. YOLOv4中的tricks概念总结——Bag of freebies
7. 目标检测–边框回归损失函数SIoU原理详解及代码实现

    推荐阅读