YOLOv5-4.0-loss.py 源代码导读(损失函数)

YOLOv5介绍

YOLOv5为兼顾速度与性能的目标检测算法。笔者将在近期更新一系列YOLOv5的代码导读博客。YOLOv5为2021.1.5日发布的4.0版本。
YOLOv5开源项目github网址
源代码导读汇总网址
本博客导读的代码为utils文件夹下的loss.py,取自2020。5.10更新版本。

在介绍损失函数的代码实现之前,首先介绍所使用的BCE loss: Binary Cross Entropy loss 二分类交叉熵损失函数和Focal loss的定义及对应的公式。

Binary Cross Entropy loss 二分类交叉熵损失函数(BCE)

交叉熵损失函数用于衡量样本分类的准确性,其表达形式如下图所示:
YOLOv5-4.0-loss.py 源代码导读(损失函数)
其中y尖代表模型预测第i个样本是某一类的概率,yi代表标签,因此对标签为0或1有不同的计算方式。当用于多分类问题时,其实相当于是扩充了标签本身维数,对将多个类的二进制表示写在一起构成了一个one hot向量。

为了解决类别不均衡的问题,引入alpha参数作为一个因子,对每一类的loss值做一个加权。

为了让BCEloss更具有泛化性,Kaiming He et.al 提出了Focal loss,其表达式如下所示:
YOLOv5-4.0-loss.py 源代码导读(损失函数)
通过引入另一个超参gamma,将概率较高的正样本和概率较低的负样本的loss值显著降低了,因此难样本会对损失函数值有较大的贡献度,使得模型提高对较难样本进行区分

2020年NIPS《Generalized Focal loss》中,提出了QFocal loss,如下所示:
YOLOv5-4.0-loss.py 源代码导读(损失函数)
该式将每一张图片对应的概率看做是图片对应的质量,从0-1进行取值,标签中的0(不属于该类)和1(属于该类)即代表了质量的两个极端。实践中,beta取2为最佳。

loss.py

该文件给出了YOLOv5 模型中使用到的全部损失函数的定义,并提供了基于ground truth 和模型预测输出之间计算损失函数的接口。

以下为该文件必须导入的模块,其中bbox_iou和is_parallel函数来自于其他文件的定义。详细解读可以参考笔者其他博客。

import torch #pytorch 模块
import torch.nn as nn

from utils.general import bbox_iou   # bbox_iou函数详细解析见general.py文件中 该函数为返回一个box对一组box的iou值
from utils.torch_utils import is_parallel # 判断模型是否并行处理

以下为标签平滑处理部分代码

# BCE: Binary Cross Entropy 二分类交叉熵损失函数 用于多类别多分类问题
def smooth_BCE(eps=0.1):  # https://github.com/ultralytics/yolov3/issues/238#issuecomment-598028441最开始提出这个改进的网址
    # return positive, negative label smoothing BCE targets
    # 标签平滑操作 两个值分别代表正样本和负样本的标签取值
    # 这样做的目的是为了后续的的 BCE loss
    return 1.0 - 0.5 * eps, 0.5 * eps

正负样本加权的BCE loss定义

class BCEBlurWithLogitsLoss(nn.Module): #blur 意为模糊 据下行原版注释是减少了错失标签带来的影响
    # BCEwithLogitLoss() with reduced missing label effects.
    def __init__(self, alpha=0.05):
        super(BCEBlurWithLogitsLoss, self).__init__()
        # 这里BCEWithLogitsLoss 是输入的每一个元素带入sigmoid函数之后 再同标签计算BCE loss
        self.loss_fcn = nn.BCEWithLogitsLoss(reduction='none')  # must be nn.BCEWithLogitsLoss()
        self.alpha = alpha #这个应该算是一个模糊系数吧 默认为0.05

    def forward(self, pred, true):
        # 得到了预测值和标签值得BCE loss 注:预测是经过sigmoid函数处理再计算BCE的
        loss = self.loss_fcn(pred, true)
        # 将预测值进行sigmoid处理 数学意义为每一位对应类别出现的概率
        pred = torch.sigmoid(pred)  # prob from logits
        # 假定missing的标签用一行0进行补齐,则相减之后missing的样本概率不受影响,正常样本样本概率为绝对值较小的负数
        dx = pred - true  # reduce only missing label effects
        """
        torch.exp()函数就是求e的多少次方  输入tensor每一个元素经过计算之后返回对应的tensor
        根据下式 对于正常的较大概率的样本 dx对应值为绝对值较小一个负数 假设为-0.12,则-1为-1.12除0.05 为-22.4,
        -22.4 指数化之后为一个很小很小的正数,1-该正数之后得到的值较大 再在loss中乘上之后影响微乎其微
        而对于missing的样本 dx对应为一个稍大的正数 如0.3 减去1之后为-0.7 除以0.05 为 -14
        -14相比-22.4值为指数级增大,因此对应的alpha_factor相比正常样本显著减小 在loss中较小考虑
        """
        # Q:有一个问题,为什么yolov5要对missing的样本有这样的容忍度,而不是选择直接屏蔽掉对应样本呢?
        alpha_factor = 1 - torch.exp((dx - 1) / (self.alpha + 1e-4))
        loss *= alpha_factor 
        return loss.mean() # 这个mean的意义应该为对一批batch中的每一个样本得到的BCE loss求均值作为返回值

Focal loss定义

class FocalLoss(nn.Module): # 这里定义了FocalLoss
    # Wraps focal loss around existing loss_fcn(), i.e. criteria = FocalLoss(nn.BCEWithLogitsLoss(), gamma=1.5)
    def __init__(self, loss_fcn, gamma=1.5, alpha=0.25):
        super(FocalLoss, self).__init__()
        self.loss_fcn = loss_fcn  # must be nn.BCEWithLogitsLoss() 这里的loss_fcn基础定义为多分类交叉熵损失函数
        self.gamma = gamma  # Focal loss中的gamma参数 用于削弱简单样本对loss的贡献程度
        self.alpha = alpha  # Focal loss中的alpha参数 用于平衡正负样本个数不均衡的问题
        self.reduction = loss_fcn.reduction
        self.loss_fcn.reduction = 'none'  # 需要将Focal loss应用于每一个样本之中

    def forward(self, pred, true):
        loss = self.loss_fcn(pred, true) # 这里的loss代表正常的BCE loss结果
        
        # TF implementation https://github.com/tensorflow/addons/blob/v0.7.1/tensorflow_addons/losses/focal_loss.py
        # 通过sigmoid函数返回得到的概率 即Focal loss 中的y'
        pred_prob = torch.sigmoid(pred) 
        # 这里对p_t属于正样本还是负样本进行了判别,正样本对应true=1,即Focal loss中的大括号
        # 正样本时 返回pred_prob为是正样本的概率y',负样本时为1-y'
        p_t = true * pred_prob + (1 - true) * (1 - pred_prob)
        # 这里同样对alpha_factor进行了属于正样本还是负样本的判别,即Focal loss中的
        alpha_factor = true * self.alpha + (1 - true) * (1 - self.alpha)
        # 这里代表Focal loss中的指数项
        # 正样本对应(1-y')的gamma次方 负样本度对应y'的gamma次方
        modulating_factor = (1.0 - p_t) ** self.gamma
        # 返回最终的loss大tensor
        loss *= alpha_factor * modulating_factor
        # 以下几个判断代表返回loss的均值/和/本体了
        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        else:  # 'none'
            return loss

QFocal loss的定义

class QFocalLoss(nn.Module): #来自NIPS2020的Generalized Focal loss论文 除了modulate参数定义变化之外 其余定义同Focal loss相同
    # Wraps Quality focal loss around existing loss_fcn(), i.e. criteria = FocalLoss(nn.BCEWithLogitsLoss(), gamma=1.5)
    def __init__(self, loss_fcn, gamma=1.5, alpha=0.25):
        super(QFocalLoss, self).__init__()
        self.loss_fcn = loss_fcn  # must be nn.BCEWithLogitsLoss()
        self.gamma = gamma
        self.alpha = alpha
        self.reduction = loss_fcn.reduction
        self.loss_fcn.reduction = 'none'  # required to apply FL to each element

    def forward(self, pred, true):
        loss = self.loss_fcn(pred, true)

        pred_prob = torch.sigmoid(pred)  
        # 对alpha参数 对正负样本进行区分
        alpha_factor = true * self.alpha + (1 - true) * (1 - self.alpha)
        # 对比一下正常的Focal loss           (1.0 - p_t) ** self.gamma
        # Focal loss对样本预先进行了正和负的区分,而QFocal loss无视这样的区分,
        # 将pred_prob看做是样本质量 负为0 正为1 直接进行做差乘方来实现泛化
        modulating_factor = torch.abs(true - pred_prob) ** self.gamma # 这里的平方幂数为实验验证得来
        loss *= alpha_factor * modulating_factor

        if self.reduction == 'mean':
            return loss.mean()
        elif self.reduction == 'sum':
            return loss.sum()
        else:  # 'none'
            return loss

以下为Compute _loss类 为方便观看,按照类中定义的方法进行区分。
该部分为类别的初始化

class ComputeLoss:
    # Compute losses
    def __init__(self, model, autobalance=False):
        super(ComputeLoss, self).__init__()
        device = next(model.parameters()).device  # 获取模型对应的设备型号
        h = model.hyp  # 超参数hyperparameters

        # Define criteria
        # 定义评价标准 cls代表类别的BCE loss obj的BCElos为判断第i个网格中的第j个box是否负责对应的object
        # 这里的pos_weight为对应的参数 在模型训练的yaml文件中可以调整
        BCEcls = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([h['cls_pw']], device=device))
        BCEobj = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([h['obj_pw']], device=device))

        # Class label smoothing https://arxiv.org/pdf/1902.04103.pdf eqn 3
        # 这里进行标签平滑处理 cp代表positive的标签值 cn代表negative的标签值
        self.cp, self.cn = smooth_BCE(eps=h.get('label_smoothing', 0.0))  # positive, negative BCE targets

        # Focal loss定义
        g = h['fl_gamma']  # 根据yaml文件获取focal loss的gamma参数
        if g > 0:
            BCEcls, BCEobj = FocalLoss(BCEcls, g), FocalLoss(BCEobj, g)
        
        # 这里的det 返回的是模型的model属性的最后一项 应该返回的是包含模型检测器的那部分 不确定
        det = model.module.model[-1] if is_parallel(model) else model.model[-1]  # Detect() module
        # self.balance和self.ssi的定义需要看模型定义的主干  det.nl 对应的是检测器输出的类别个数 P3-P7对应的是什么部分需要看模型的构建
        self.balance = {3: [4.0, 1.0, 0.4]}.get(det.nl, [4.0, 1.0, 0.25, 0.06, .02])  # P3-P7       
        self.ssi = list(det.stride).index(16) if autobalance else 0  # stride 16 index
        
        # self.BCEcls和self.BCEobj对应的是loss self.gr变量不知道 self.hyp为从yaml文件中提取到的各种超参数 autobalance不确定 猜测和anchor有关
        self.BCEcls, self.BCEobj, self.gr, self.hyp, self.autobalance = BCEcls, BCEobj, model.gr, h, autobalance
        # setattr函数为给实例对象添加属性 这里把na nc nl 和 anchor属性全部私有化
        for k in 'na', 'nc', 'nl', 'anchors':
            setattr(self, k, getattr(det, k))

调用方法 通过模型预测和标签返回loss的值和各个loss合并后的tensor

    def __call__(self, p, targets):  # predictions, targets, model
        device = targets.device  # 确定运行的设备
        # 初始化 分类 bbx回归 和obj三种损失函数值
        lcls, lbox, lobj = torch.zeros(1, device=device), torch.zeros(1, device=device), torch.zeros(1, device=device)
        # 返回目标对应的 分类结果 bbx结果 索引 和对应的anchor 见下文build_targets函数
        tcls, tbox, indices, anchors = self.build_targets(p, targets)  # targets

        # 以下for循环内 为计算loss
        # 对不同的尺度对应的层进行遍历
        for i, pi in enumerate(p):  # i: layer index, pi: layer predictions
            # b:image索引 a:anchor的索引 gj/gi:网格的纵坐标和横坐标
            b, a, gj, gi = indices[i]  
            # 初始化target object tensor中的元素全部为0
            tobj = torch.zeros_like(pi[..., 0], device=device)  
            # n为返回的target数目
            n = b.shape[0] 
            # 当图像中含有的target数目不为0的时候 即对一张图片的标签不为0的时候
            if n:
                # ps为同targets一致的预测子集
                ps = pi[b, a, gj, gi]  

                # 计算bounding box的回归损失函数
                pxy = ps[:, :2].sigmoid() * 2. - 0.5
                pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i]
                # 得到了预测的所有bounding box
                pbox = torch.cat((pxy, pwh), 1)  
                # bbx_iou(prediction, target) 计算预测框和标签框的bbx损失值 此处强制使用CIoU loss 如果要进行更换 需改这个地方
                iou = bbox_iou(pbox.T, tbox[i], x1y1x2y2=False, CIoU=True)  # 该函数详细解析见general.py 源代码解析
                lbox += (1.0 - iou).mean()  # 对iou中每一个box对应的iou都取了平均值 计算的是所有box的平均iou loss

                # 计算anchor是否存在Object的损失函数值
                tobj[b, a, gj, gi] = (1.0 - self.gr) + self.gr * iou.detach().clamp(0).type(tobj.dtype)  # 用iou值来当比例

                # 计算Classification相关的loss
                if self.nc > 1:  # cls loss (只有在类别多于1的时候才会进行计算)
                    # 以下计算中cn和cp分别代表经过标签平滑处理之后的negative样本和positive样本对应的标签值
                    # t为标签中target所在grid对应类别 的one hot 向量格式
                    t = torch.full_like(ps[:, 5:], self.cn, device=device)  
                    t[range(n), tcls[i]] = self.cp
                    lcls += self.BCEcls(ps[:, 5:], t)  # 计算多分类的Focal loss
                    
            # 计算lobj loss 这里是指bbx中有物体的置信度
            obji = self.BCEobj(pi[..., 4], tobj)
            lobj += obji * self.balance[i]  # 得到obj loss
            # 作者添加了自动平衡功能 加入了每个框是否具有物体的置信度大小带来的影响
            if self.autobalance:
                self.balance[i] = self.balance[i] * 0.9999 + 0.0001 / obji.detach().item()
        
        # self.ssi参数在这里用于元素的平衡
        if self.autobalance:
            self.balance = [x / self.balance[self.ssi] for x in self.balance]
            
        # 以下是根据超参里面的参数 对各个损失函数进行平衡
        lbox *= self.hyp['box']
        lobj *= self.hyp['obj']
        lcls *= self.hyp['cls']
        bs = tobj.shape[0]  # bs代表batchsize

        loss = lbox + lobj + lcls
        return loss * bs, torch.cat((lbox, lobj, lcls, loss)).detach() # 返回一个batch的总损失函数值 和把各个loss cat一起的大tensor

将标签转换为便于后续计算loss的格式,整理target相关信息。

    def build_targets(self, p, targets): #将预测的格式 转化为便于计算loss的target格式
        
        # 为compute_loss类构建目标 输入的targets为(image,class,x,y,w,h) 应该为一维向量 代表第几张图片(在一个batch中的次序)中每一个bbx及其类别
        # na变量代表number of anchors, nt变量代表number of targets
        na, nt = self.na, targets.shape[0]
        # 初始化四个列表 代表targets的类别 bbx 索引 和 anchor位置
        tcls, tbox, indices, anch = [], [], [], []
        # 归一化为空间网格增益 即为7个1 最终代表检测的7种不同属性  
        gain = torch.ones(7, device=targets.device) 
        
        # torch.arange()函数为等间隔生成一段序列 如 torch.arange(3)的结果为[0,1,2] tensor.float()为将结果转换为浮点数
        # tensor.view(na,1)此处为将一维tensor的行转换为列且升维为二维tensor
        # tensor.repeat(1,nt)此处为每一行重复第一个元素重复nt次 因此最终的ai尺寸为[na,nt]
        ai = torch.arange(na, device=targets.device).float().view(na, 1).repeat(1, nt)  # same as .repeat_interleave(nt)
        
        # tensor.repeat(na,1,1)将原始一维向量在第1/2/3个维度上分别重复 na,1,1次 ai[:,:,None]将原二维tensor ai扩充至三维 且第三维为1
        # tensor.cat(tensor1,tensor2,2) 将两个三维tensor在第三个维度上面拼接到了一起 
        # tensor1[1*na,nt*1,1]和tensor2[na,nt,1]的张量的第三维cat一起之后 为[na,nt,2]
        targets = torch.cat((targets.repeat(na, 1, 1), ai[:, :, None]), 2)  # append anchor indices

        g = 0.5  # bias 偏置
        # offsets 偏移量 应该是对应anchor的偏移量
        off = torch.tensor([[0, 0],
                            [1, 0], [0, 1], [-1, 0], [0, -1],  # j,k,l,m
                            # [1, 1], [1, -1], [-1, 1], [-1, -1],  # jk,jm,lk,lm
                            ], device=targets.device).float() * g  # offsets
        
        # 这里的nl 应该代表 number of label标签的数量
        # for 对每一个标签进行迭代
        for i in range(self.nl):
            # 调取对应索引的anchor
            anchors = self.anchors[i]
            # p指prediction 模型的预测输出 这里可以看到P至少为五维 有一个[i]的索引 
            # gain[2:6] 返回一个一维向量 代表p第i的元素的第四维度数目和第三维度数目 repeat double 
            gain[2:6] = torch.tensor(p[i].shape)[[3, 2, 3, 2]]  # xyxy gain

            # 匹配targets到对应的anchors
            t = targets * gain
            if nt: # 当存在target时
                # Matches
                r = t[:, :, 4:6] / anchors[:, None]  # 获取wh的比例
                j = torch.max(r, 1. / r).max(2)[0] < self.hyp['anchor_t']  # 与设定的anchor_t超参进行比较
                # j = wh_iou(anchors, t[:, 4:6]) > model.hyp['iou_t']  # iou(3,n)=wh_iou(anchors(3,2), gwh(n,2))
                t = t[j]  # 过滤 获得匹配成功的目标

                # Offsets
                # 获取每一个匹配成功的目标对应的偏移量 
                # 因为YOLO核心在于通过预测偏移量得到bbx 所以此步用于对偏移量进行处理 使后续根据偏移量得到对应bbx
                gxy = t[:, 2:4]  # 获取网格的xy坐标值
                gxi = gain[[2, 3]] - gxy  # 翻转
                j, k = ((gxy % 1. < g) & (gxy > 1.)).T
                l, m = ((gxi % 1. < g) & (gxi > 1.)).T
                j = torch.stack((torch.ones_like(j), j, k, l, m))
                t = t.repeat((5, 1, 1))[j]
                offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j]
            else: #不存在target时返回对应空值
                t = targets[0]
                offsets = 0

            # Define
            b, c = t[:, :2].long().T  # b,c代一个target对应的image的序号和对应的class
            gxy = t[:, 2:4]  # 网格的 xy 坐标
            gwh = t[:, 4:6]  # 网格的 wh 坐标
            gij = (gxy - offsets).long()
            gi, gj = gij.T  # 网格 xy 索引

            # 将上述得到的target的信息添加到对应list中
            a = t[:, 6].long()  # anchor 索引
            indices.append((b, a, gj.clamp_(0, gain[3] - 1), gi.clamp_(0, gain[2] - 1)))  # target对应的image, anchor, grid 索引
            tbox.append(torch.cat((gxy - gij, gwh), 1))  # target对应的box
            anch.append(anchors[a])  # target对应的anchors
            tcls.append(c)  # target对应的类别class

        return tcls, tbox, indices, anch

限于作者水平有限,有不准确不清晰支出,欢迎读者提示指正!

上一篇:浅析卷积神经网络在计算机视觉中的应用(附YOLOv5实验总结)


下一篇:YOLOv5安装以及环境配置