Retina:one_stage|anchor-based|focal_loss
算法系开篇打算讲一下Retina,详细介绍下其中anchor的作用流程以及Focal Loss的原理和应用。希望对anchor还没有完全搞明白的人看了这篇文章之后可以醍醐灌顶,点赞收藏。

1. Retina简介

​ 众所周知,在网络结构上,Retina并没有什么创新点,它虽是Retina,但是核心讲的还是Focal LOSS。

​ 从网络结构上看,Retina就是标准的resnet+FPN多层金字塔,其中每一个FPN上会出一个子网络进行分类和回归。文章也说了,是因为Focal LOSS的存在,才让网络的性能突飞猛进。

FocalLOSS的公式以及应用之后会说,这篇文章就秉持着逻辑则,从MMDetection的代码中介绍下训练的操作流程和测试的操作流程。

2. 网络及代码架构

在MMdetection中,所有的检测算法都进行了组件化,但是都有相应的detector,比如retina的detector,其中retina的继承关系如下所示.

而retinaDetector中有包含了几个模块,如下图所示。

其中backbone和FPN都是可配置的,无论你是要用Resnet还是mobilenet都可以,FPN也一样。无论是正常的FPN还是Nas-FPN、PAFPN等都是可以随意配置。但是前者的作用都是特征提取,后者都是多尺度训练和inference。其中RetinaHead,当你打开它的代码时,你会发现它的内容及其简单,除了初始化几个层就重写了一个forward_single函数。

def forward_single(self, x):
        """Forward feature of a single scale level.
        Args:
            x (Tensor): Features of a single scale level.
        Returns:
            tuple:
                cls_score (Tensor): Cls scores for a single scale level
                    the channels number is num_anchors * num_classes.
                bbox_pred (Tensor): Box energies / deltas for a single scale
                    level, the channels number is num_anchors * 4.
        """
        cls_feat = x
        reg_feat = x
        for cls_conv in self.cls_convs:
            cls_feat = cls_conv(cls_feat)
        for reg_conv in self.reg_convs:
            reg_feat = reg_conv(reg_feat)
        cls_score = self.retina_cls(cls_feat)
        bbox_pred = self.retina_reg(reg_feat)
        return cls_score, bbox_pred

这是因为绝大部分的内容被写在了AnchorHead中,所以为什么我写这篇文章的时候没有像其他文章一样,讲一下FocalLOSS就草草了事。这篇文章要说的不仅仅是FocalLOSS,还有AnchorHead中Anchor的生成,表示,采样,打标签,以及LOSS的计算等等。我想大多数人都知道Anchor-based就是预先生成很多候选框,网络会预测GT和候选框之间的关系这样一个概念,但是我打赌90%的人仅仅知道这么一个说法,让他写是一个Anchor类完成这个功能是写不出来的。所以我想这篇文章对大多数人是有意义的。

首先理清楚MMDetection中实现一个检测算法的API设计思路。

训练流程

测试流程

其中mmcv中实现了multi_apply函数实现对多层fpn的处理,这也是为什么在mmdetection的代码中随处可见的*single,其实就是对某一层FPN进行操作,然后最终返回结果中为一个数组。

3.Anchor介绍

3.1 Anchor的生成

Anchor就是预先设定的一个候选框,它的目的就是希望网络可以学习得更好,因为目标检测问题中有不同种类的物体会出现在不同的位置并且有着不同的大小,直接学习比较困难(当然现在很多Anchor-free的性能也非常好,但是本文说的是Anchor,就从Anchor-based的由来介绍)。在MMDetection的代码中,我对Anchor进行了可视化。假如我想检测下面这个帅B的人脸位置。

生成的anchor是这样的(scales=8, ratio=[1,2,0.5]):

好吧实在太密了,只可视化其中100个看下,是这样的。

这是在第一层Featuremap上的Anchor可视化结果,但是需要注意的是Anchor在不同的FPN层中大小尺寸不一样,这是因为越是高层的feature_map感受野越大,语义信息更丰富,所以在高层featuremap上的Anchor大小更大,适合对大目标进行预测。

Anchor就是指定的一些候选框,而网络的输出则是搭起Anchor和实际框之间的桥梁,带标签的数据就是让网络去更好地学习,这样在预测的时候就可以根据Anchor和网络输出进行推理。注意:Anchor本身的值是定义好之后就不会变的,具体怎么通过Anchor的值和网络的输出来得到最终的框,参考LOSS函数中回归损失函数即可。

Anchor的原理搞清楚之后,怎么生成Anchor其实只是写法问题,如果一个非专业python用户来写,应该就是for循环套起来了,但是在MMdetection中这段生成Anchor的代码写地着实漂亮。首先,在一张featuremap上不同位置的Anchor是除了位置不同其他都是相同的。假设Anchor的设置为3个尺度,3个比例,那么在一个位置上是得到9个Anchor(也就是参数base_anchors),那么在这个featuremap上的其他位置,也是同样的9个anchor,假设featuremap大小为40x40,则这个featuremap得到的anchor数量为40x40x9。在某一层featuremap上生成anchor的代码如下所示。

def _meshgrid(self, x, y, row_major=True):
        """Generate mesh grid of x and y.
                # meshgrid中的值是所有Anchor相对于左上角的那Anchor中心点的x方向、y方向偏移量。
        Args:
            x (torch.Tensor): Grids of x dimension.
            y (torch.Tensor): Grids of y dimension.
            row_major (bool, optional): Whether to return y grids first.
                Defaults to True.

        Returns:
            tuple[torch.Tensor]: The mesh grids of x and y.
        """
        xx = x.repeat(len(y))
        yy = y.view(-1, 1).repeat(1, len(x)).view(-1)
        if row_major:
            return xx, yy
        else:
            return yy, xx

  def single_level_grid_anchors(self,
                                  base_anchors,
                                  featmap_size,
                                  stride=(16, 16),
                                  device='cuda'):
        """Generate grid anchors of a single level.

        Note:
            This function is usually called by method ``self.grid_anchors``.

        Args:
            base_anchors (torch.Tensor): The base anchors of a feature grid.
            featmap_size (tuple[int]): Size of the feature maps.
            stride (tuple[int], optional): Stride of the feature map in order
                (w, h). Defaults to (16, 16).
            device (str, optional): Device the tensor will be put on.
                Defaults to 'cuda'.

        Returns:
            torch.Tensor: Anchors in the overall feature maps.
        """
        feat_h, feat_w = featmap_size
        # convert Tensor to int, so that we can covert to ONNX correctlly
        feat_h = int(feat_h)
        feat_w = int(feat_w)
        shift_x = torch.arange(0, feat_w, device=device) * stride[0]
        shift_y = torch.arange(0, feat_h, device=device) * stride[1]
                # 生成所有的偏移量
        shift_xx, shift_yy = self._meshgrid(shift_x, shift_y)
        shifts = torch.stack([shift_xx, shift_yy, shift_xx, shift_yy], dim=-1)
        shifts = shifts.type_as(base_anchors)
        # first feat_w elements correspond to the first row of shifts
        # add A anchors (1, A, 4) to K shifts (K, 1, 4) to get
        # shifted anchors (K, A, 4), reshape to (K*A, 4)
                # 偏移量+左上角的Anchor就得到了这个Featuremap上所有的Anchor
        all_anchors = base_anchors[None, :, :] + shifts[:, None, :]
        all_anchors = all_anchors.view(-1, 4)
        # first A rows correspond to A anchors of (0, 0) in feature map,
        # then (0, 1), (0, 2), ...
        return all_anchors

这段代码中没有用一个for循环,代码简洁明了,看完让人直呼过瘾。当然这只是小菜,大盘鸡还在后面嗷嗷待哺。

3.2 Anchor打标签

Anchor的生成我想不难理解,无论是代码还是逻辑,但是说到Anchor打标签这个事,逻辑上略难理解,代码上确是需要一些真功夫了。首先介绍下Anchor为什么需要打标签。

前面说了,在一个featuremap上我们得到的Anchor非常多,每一个anchor都是一个中介,在网络输出和真实GT之间搭起爱的桥梁。但是这桥梁可不是任意的张三和李四都能搭配的。比如常见的以IOU区分正负样本举例,做人脸检测的时候输入一张人脸图片,不同的Anchor与这个人脸之间的IOU值可能是[0,1]之间的任意一个值,你说以IOU为指标的话,如果用一个IOU阈值比如0.5去计算,>0.5则当做正样本去拟合GT,小于0.5则当做背景类。那这显然不合理,你说张三和李四IOU=0.55签上手了,我王五IOU=0.45怎么就不能牵手了,这小伙子不能不讲武德啊。

所以在IOU这块是这么设置的:IOU>pos_thrd为正样本,IOU<neg_thrd为负样本,其他不计算LOSS。这才是只对部分Anchor计算LOSS的原因。我看很多文章说是因为Anchor数量太多才这么做,显然还是有所偏颇。当然,确实也有采样这个步骤,但是这个前因后果还是要理清楚,设置正负样本的阈值和Anchor数量多少没有关系

如果用C++或者是python新手的话,对Anchor打标签的代码又得上for循环:

for _ in 每一层feature_map
    for _ in 每一行
        for _ in 每一列
            for _ in 每一个scale
                for _ in 每一个ratio:
                    ......

显然这么做不符合python优雅的特性。因为所有的anchor表示的值都是相对于原图大小设置的,不同层的featuremap也都是和相同的GT进行IOU的计算。所以Anchor打标签的逻辑中anchors表示为[anchors:shape(num_anchor, 4), ...],列表长度为图片的数量。其中_get_target_single就是对单张图片的anchor进行打标签的过程。

打标签这个事情,MMdetection中特意写了一个组件assigners来实现。这里以max_iou_assigner为例进行说明,除了上述所说的根据IOU来区别正负样本之外,还有一些其他细节。

(1)输入的Anchor组织形式为(num_anchor, 4),GT为(num_gt, 4)。

(2)计算Anchor和GT之间的IOU, shape=(num_anchor, num_gt)

(3)【细节】对GT_bboxes_ignore做处理,若anchor与GT_bboxes_ignore的框IOU大于阈值,则这些anchor不计算LOSS。

这一点在实际的项目中非常重要,因为无论什么计算机视觉项目,总会有一些”惊为天人“的存在。比如行为艺术家贡献了这样的图片在你的数据集里,你是把它分类为人呢还是分类为狮子呢?

(3)对每个Anchor,如果其匹配到的GT框IOU>pos_thrd则回归这个GT框的位置以及拟合它的类别,若0<IOU<neg_thrd则这个作为背景类,预测它的背景类标签,不回归位置。其他的不计LOSS。

(4)【细节】在MMdetection中有这样一段代码:

if self.match_low_quality:
            # Low-quality matching will overwirte the assigned_gt_inds assigned
            # in Step 3. Thus, the assigned gt might not be the best one for
            # prediction.
            # For example, if bbox A has 0.9 and 0.8 iou with GT bbox 1 & 2,
            # bbox 1 will be assigned as the best target for bbox A in step 3.
            # However, if GT bbox 2's gt_argmax_overlaps = A, bbox A's
            # assigned_gt_inds will be overwritten to be bbox B.
            # This might be the reason that it is not used in ROI Heads.
            for i in range(num_gts):
                if gt_max_overlaps[i] >= self.min_pos_iou:
                    if self.gt_max_assign_all:
                        max_iou_inds = overlaps[i, :] == gt_max_overlaps[i]
                        assigned_gt_inds[max_iou_inds] = i + 1
                    else:
                        assigned_gt_inds[gt_argmax_overlaps[i]] = i + 1

我再解释下这个代码背后张三和李四的爱恨纠缠,如下图所示,张三和王五是GT框,李四都是预测框,但是根据IOU规则,可以看到王五牵手了李四还顺便带回家两个路人,而张三苦等多年,使出了闪电五连鞭也没能赢下王五这个不讲武德的年轻人。为了避免这种惨绝人寰的悲剧,特意设了这样一段代码来安慰下张三这样的年轻人,有了这段代码之后,张三就成功牵手了李四,而王五则和两个路人花好月圆。因为这段代码的逻辑,就是给每一个GT框,赋予他IOU最高且>min_pos_iou的box。

分配结果

好了,啰啰嗦嗦一大堆,对于max_iou_assigner中的分配过程应该是说清楚了,代码上我觉得还有一个细节着重强调下,计算anchor和GT的分配的过程关键就下面这两行代码。

# overlaps shape(gt_box_num, anchor_box_num)
max_overlaps, argmax_overlaps = overlaps.max(dim=0)
# max_overlaps shape(anchor_box_num)  
# argmax_overlaps shape(anchor_box_num)
gt_max_overlaps, gt_argmax_overlaps = overlaps.max(dim=1)
# gt_max_overlaps shape(gt_box_num)
# gt_argmax_overlaps shape(gt_box_num)

对于python/torch中的一些涉及指定维度进行操作的API,经常让人搞混,我有个秘诀。像max,min这样的操作,选哪个维度,返回值的shape中就会少这个维度。dim=0的时候返回结果长度是anchor_box_num,那显然max_overlaps表示的是预测框所匹配的最大IOU的值。argmax_overlaps则表示为预测框所匹配到的最大IOU的GT下标。通过这样的理解思路,就很容易搞清楚返回的结果是什么,代码的目的是什么。

好了,根据以上这套左正蹬+下勾拳+刺拳的武林绝学,我想这套形意标签法应该说得清清楚楚了,当然多嘴一句,基于IOU的正负样本分配策略当然不是唯一的方案,事实上这一部分是很多论文的工作出发点,比如CascadeRCNN、AutoAssign等等,不过这部分内容之后也会找时间开个坑捋一捋。

3.3 正负样本采样

好吧,我承认这个模块就是废话,因为我都打算之后讲FocalLOSS了,在Retina里面也不涉及采样的内容。但是!但是在MMdetection的代码结构中,采样是一个检测head的都会有的,如果不做采样,也提供了一个PseudoSampler实现接口上的统一。但是这一块还是有很多工作的,比如OHEM,IoU-balanced sampler,GHM等,这一块也留着之后说吧,在本文中会在FocalLOSS中说明。

4 FocalLOSS

4.1 box的表示

按理说,打完标签之后,是已经可以计算LOSS了,但是直接回归框的位置不是一个明智的选择,因为框的位置变化范围太大,因此这一块通常会设计box的编解码过程。计算LOSS的时候是将网络输出去拟合编码之后的box,预测的时候是将网络的预测解码为相对于原图的box。编解码这个过程在MMdetection中也被单独作为了一个模块。retina也是用得比较常用的模块delta_xywh_bbox_coder。其中框的表示为中心点(x, y)和宽高(w, h),网络需要去拟合的是dx, dy, dw, dh。编码如下公式所示,解码则反推就行了。

在损失函数上,Retina用的就用SmoothL1LOSS去回归box,FocalLOSS计算类别损失。FocalLOSS等会简单介绍下,这块又设计了对box回归的同一些改动,一系列的*IOU_LOSS涌现出来。在 MMdetection的LOSS中也有一些实现。这也是目标检测方向一些人的研究发力点。

4.2 FocalLOSS

这个网上的文章太多太多了,不过既然写这篇文章了我还是说一下,之前说了采样那部分就已经提到了FocalLOSS是对正负样本进行了处理,FocalLOSS的公式如下:

focal loss

作用就是

  1. alpha是指通过超参进行宏观上的loss调控,说句实在话这个就是一个小小的调优方案。
  2. gamma才是重点,这一个多项式的目的是减少容易分类的样本在LOSS中所占的比重,而且是动态调控的。因为一个样本越容易分类,则pt就越接近1,那么这个多项式的值就越小,Loss也就越小,而且负样本(背景类的anchor)绝对大多数都是简单样本啊,FocalLOSS就是这么不仅解决了类别不平衡问题,而且还加强了那些对难分类样本的学习。动态调控这个点非常重要,loss_weight虽然是一个小小的参数,但是无数实验证明了不同类别、不同任务在一起训练的时候loss的均衡是多么的重要。

实在懒得对FocalLOSS再做什么解释,现在都2020年了,搞懂FocalLOSS应该不是多难的事情。而我真正想说的是FocalLOSS提出之后在热力图类的神经网络中的应用,这个确实很有意思。事实上MMdetection中也实现了这个功能,毕竟CornerNet需要用到的。

首先,热力图的GT长这样:

其中最亮的那个点值是1,黑色的为0,亮度最高的那个值在其半径R内呈高斯分布。这类网络的需求就是让网络去预测这样的热力图,其中响应最高的那个点就是我想要找的点,所以在检测中也可以用它去定位检测框或者去定位物体的中心点,关键点检测任务中也可以用它作为关键点进行表示。但是直接去学习一个点时非常困难的,一个是样本的不均衡,一个是定义一个pixel级别的任务对网络来说属实艰辛。然而这,却有了FocalLOSS的用武之地。

先看下代码, 可以看到gaussian_focal_loss中只有为1的那个点当做正样本,其他的都是负样本。

@weighted_loss
def gaussian_focal_loss(pred, gaussian_target, alpha=2.0, gamma=4.0):
    """`Focal Loss <https://arxiv.org/abs/1708.02002>`_ for targets in gaussian
    distribution.
    Args:
        pred (torch.Tensor): The prediction.
        gaussian_target (torch.Tensor): The learning target of the prediction
            in gaussian distribution.
        alpha (float, optional): A balanced form for Focal Loss.
            Defaults to 2.0.
        gamma (float, optional): The gamma for calculating the modulating
            factor. Defaults to 4.0.
    """
    eps = 1e-12
    pos_weights = gaussian_target.eq(1)
    # 只有一个点是正样本,其他都是负样本。
    neg_weights = (1 - gaussian_target).pow(gamma)
    # FocalLOSS的计算方式,作为一个二分类进行处理,这是对正样本的计算
    pos_loss = -(pred + eps).log() * (1 - pred).pow(alpha) * pos_weights
    # 对负样本的计算,neg_weights的值是越靠近最大值的点,其权重越小
    neg_loss = -(1 - pred + eps).log() * pred.pow(alpha) * neg_weights
    return pos_loss + neg_loss

需要明确的是这个二维的FocalLOSS并不是让网络去预测一张和GT一样的热力图,而是逼近一个极端的图:即除了一个点时1其他点都是0,显然这点对网络太难做到,所以实际在很多实验中,预测的时候我可视化发现结果还是一个高斯map,但是其半径一般会更小一些,但是不打紧,只要知道极值点再哪就行了。因为如果网络预测了一张和GT一模一样的热力图,它的loss也不会是0,举个例子,GT为0.9的这个点网络也预测了0.9,那它的loss值为:

可以看到哪怕和GT的值一样,loss也不为0的。这个loss的含义是说在极值点附近的点都是难分类样本,而难分类的程度是距离极值点越近难分类程度越高,其作为背景类别Loss Weight就应该越小。

写在最后

一点总结:从Anchor机制中学到的更应该是一种思想,深度学习的能力很强,但是也不是无所不能,anchor的提出本质上以一种先验的方式降低神经网络的学习成本,这个思想非常重要,网络永远不是万能的,所有算法工程师需要做的其实主要就是两件事:(1)让网络的学习成本更低,提高模型的精度
(2)让网络学到东西更多样化,提高模型的泛化性
Anchor属于第一种,因为它提供了先验框让网络更容易学习,FocalLOSS平衡了正负样本,让网路学习到更多正样本的知识。对网络的输出box进行编码也属于第一种,编码之后的数值分布更集中,方差更小,网络也更好学习。
同时,网络虽然是冷冰冰的,但是学会让网络的输出转化为实际的任务需求是个重要的能力,第一次知道热力图的方式的时候真是感觉惊奇,后来实验发现效果非常好。还有比如人脸识别中直接将网络输出的logites当做人脸的特征进行人脸比对,分割任务中直接将每一个点作为mask进行分类,基于siamese的追踪网络中使用卷积结果来计算不同搜索区域与目标模板的相似度, 等等。
只要是在数据解释、数理统计上合理的东西,神经网络都能进行表达,理论上说,只要有足够的数据,足够的算力,足够的存储空间,神经网络的智慧会超越人们的想法。它就是那个扬言能翘起地球的阿基米德,虽然CV岗灰飞烟灭,计算机视觉落地发展受限的消息铺天盖地。但是我想我会依然坚定走下去,珍惜这个上岸的机会,尊重这个让人惊喜的宝库,人工智能,我坚信它不会是空谈。1000年前的人无法想象人们怎么和远隔千里之外的人即时交流,现在我们一部手机就可以实现。1000年后,自动驾驶的汽车,可观光旅游的太空城,头号玩家般酣畅淋漓的虚拟现实游戏,谁又能说绝对不可能呢。甚至,都不需要1000年这么久呢

动笔之后才发现这样写一篇文章确实比较费神,费力,费心。但是无论如何都会坚持下去,第一篇算法类文章对代码结构上的解释比较多,我想之后可能只会对重要的代码进行解释。其他的应该就是正常paper share,讲一讲思路,方法,实验效果,总结,以及写的一些唠叨废话。希望这篇文章可以帮助到大家,希望大家能够点赞、喜欢、收藏、评论、转发五连鞭,谢谢!