今天是23-04-28,周五,因为要放五一节,就回家了。回家还是很chill的,就是效率没有在学校里面高。预计这个五一节就把这个专栏完成得差不多了吧,后续打算再开个专栏,去写WebServer,正好我的课设也准备交这个上去。
今天刚回到家,就看到卧室桌上一个很熟悉的本子,哈哈哈哈这不是我高中记单词的本子吗,怎么跑出来了,估计是被我妈整理房间的时候给翻出来了吧。那时候的字还很青涩(虽然现在也差不多)好多回忆瞬间就涌上心头了。泪目~
OK,言归正传,今天准备谈一下YOLOv5的损失函数loss.py
。这个文件代码量不多,但我觉得对于理解整个YOLO网络是如何运作的尤为重要,而且难度也不小,而且也非常重要。
在准备写这篇博客之前,我又去补了一下知识点,可以看下博主的这两篇:【PyTorch 理论】交叉熵损失函数的理解和【PyTorch】两种常用的交叉熵损失函数BCELoss和BCEWithLogitsLoss。
损失函数总结:
交叉熵损失函数: L = − [ y log y ^ + ( 1 − y ) log ( 1 − y ^ ) ] \mathrm{L}=-[\mathrm{y} \log \hat{\mathrm{y}}+(1-\mathrm{y}) \log (1-\hat{\mathrm{y}})] L=−[ylogy^+(1−y)log(1−y^)]
预测输出越接近真是样本标签,损失函数L越小。
BCELoss和BCEWithLogitsLoss是一组常用的二元交叉熵损失函数,常用于二分类问题。区别在于BCELoss的输入需要先进行Sigmoid处理,而BCEWithLogitsLoss则是将Sigmoid和BCELoss合成一步,也就是说BCEWithLogitsLoss函数内部自动先对output进行Sigmoid处理,再对output和target进行BCELoss计算。
导航:YOLOv5-6.x源码分析 全流程记录
import torch
import torch.nn as nn
from utils.metrics import bbox_iou
from utils.torch_utils import is_parallel
def smooth_BCE(eps=0.1): # https://github.com/ultralytics/yolov3/issues/238#issuecomment-598028441
# return positive, negative label smoothing BCE targets
return 1.0 - 0.5 * eps, 0.5 * eps
这段代码是标签平滑的策略(trick),目的是防止过拟合。
该函数将原本的正负样本1和0修改为1.0 - 0.5 * eps
,和0.5 * eps
class BCEBlurWithLogitsLoss(nn.Module):
# BCEwithLogitLoss() with reduced missing label effects.
def __init__(self, alpha=0.05):
super().__init__()
self.loss_fcn = nn.BCEWithLogitsLoss(reduction='none') # must be nn.BCEWithLogitsLoss()
self.alpha = alpha
def forward(self, pred, true):
loss = self.loss_fcn(pred, true)
pred = torch.sigmoid(pred) # prob from logits
# dx = [-1, 1] 当pred=1 true=0时(网络预测说这里有个obj但是gt说这里没有), dx=1 => alpha_factor=0 => loss=0
# 这种就是检测成正样本了但是检测错了(false positive)或者missing label的情况 这种情况不应该过多的惩罚->loss=0
dx = pred - true # reduce only missing label effects
# 如果采样绝对值的话 会减轻pred和gt差异过大而造成的影响
# dx = (pred - true).abs() # reduce missing label and false label effects
alpha_factor = 1 - torch.exp((dx - 1) / (self.alpha + 1e-4))
loss *= alpha_factor
return loss.mean()
这段代码是BCE函数的一个替代,可以直接在ComputeLoss类中的__init__
中代替传统的BCE函数
class FocalLoss(nn.Module):
# 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().__init__()
self.loss_fcn = loss_fcn # must be nn.BCEWithLogitsLoss()
self.gamma = gamma # 参数gamma 用于削弱简单样本对loss的贡献程度
self.alpha = alpha # 参数alpha 用于平衡正负样本个数不均衡的问题
self.reduction = loss_fcn.reduction
# focalloss中的BCE函数的reduction='None' BCE不使用Sum或者Mean
self.loss_fcn.reduction = 'none' # required to apply FL to each element
def forward(self, pred, true):
loss = self.loss_fcn(pred, true) # 正常BCE的loss: loss = -log(p_t)
# p_t = torch.exp(-loss)
# loss *= self.alpha * (1.000001 - p_t) ** self.gamma # non-zero power for gradient stability
# TF implementation https://github.com/tensorflow/addons/blob/v0.7.1/tensorflow_addons/losses/focal_loss.py
pred_prob = torch.sigmoid(pred) # prob from logits
p_t = true * pred_prob + (1 - true) * (1 - pred_prob)
alpha_factor = true * self.alpha + (1 - true) * (1 - self.alpha)
modulating_factor = (1.0 - p_t) ** 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
这个损失函数的主要思路是:希望那些hard examples对损失的贡献变大,使网络更倾向于从这些样本上学习。防止由于easy examples过多,主导整个损失函数。
优点:
同样在ComputeLoss中用来代替原本的BCEcls和BCEobj
class QFocalLoss(nn.Module):
# 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().__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) # prob from logits
alpha_factor = true * self.alpha + (1 - true) * (1 - self.alpha)
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
用来代替FocalLoss,可以直接在__init__
中替换
class ComputeLoss:
sort_obj_iou = False
# Compute losses
def __init__(self, model, autobalance=False):
device = next(model.parameters()).device # get model device
h = model.hyp # hyperparameters
# Define criteria
# 定义分类损失和置信度损失
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=0,代表不用focal loss
g = h['fl_gamma'] # focal loss gamma
if g > 0:
# g > 0, 将分类损失和置信度损失(BCE)都换成focalloss损失函数
BCEcls, BCEobj = FocalLoss(BCEcls, g), FocalLoss(BCEobj, g)
# BCEcls, BCEobj = QFocalLoss(BCEcls, g), QFocalLoss(BCEobj, g)
# 返回的是模型的检测头 Detector 3个 分别对应产生三个输出feature map
m = de_parallel(model).model[-1] # Detect() module
# balance用来设置三个feature map对应输出的置信度损失系数(平衡三个feature map的置信度损失)
self.balance = {3: [4.0, 1.0, 0.4]}.get(m.nl, [4.0, 1.0, 0.25, 0.06, 0.02]) # P3-P7
# 三个预测头的下采样率m.stride: [8, 16, 32] .index(16): 求出下采样率stride=16的索引
# 这个参数会用来自动计算更新3个feature map的置信度损失系数self.balance
self.ssi = list(m.stride).index(16) if autobalance else 0 # stride 16 index
# self.gr: 计算真实框的置信度标准的iou ratio self.autobalance: 是否自动更新各feature map的置信度损失平衡系数 默认False
self.BCEcls, self.BCEobj, self.gr, self.hyp, self.autobalance = BCEcls, BCEobj, 1.0, h, autobalance
self.na = m.na # number of anchors 3个
self.nc = m.nc # number of classes 类别数
self.nl = m.nl # number of layers
self.anchors = m.anchors
self.device = device
这部分就是定义了一些后面要使用的变量。
def build_targets(self, p, targets):
# p: 网络输出;targets:GT框;
# Build targets for compute_loss(), input targets(image,class,x,y,w,h)
na, nt = self.na, targets.shape[0] # number of anchors, targets
tcls, tbox, indices, anch = [], [], [], [] # 初始化
# gain是为了后面将targets=[na,nt,7]中的归一化了的xywh映射到相对feature map尺度上
# 7: image_index+class+xywh+anchor_index
gain = torch.ones(7, device=self.device) # normalized to gridspace gain
# anchor索引,后面有用,用来表示当前bbox和当前层的哪个anchor匹配
ai = torch.arange(na, device=self.device).float().view(na, 1).repeat(1, nt) # same as .repeat_interleave(nt)
# 先repeat targets和当前层anchor个数一样,相当于每个bbox变成了3个,然后和3个anchor单独匹配
targets = torch.cat((targets.repeat(na, 1, 1), ai[..., None]), 2) # append anchor indices
# 这两个变量是用来扩展正样本的 因为预测框预测到target有可能不止当前的格子预测到了
# 可能周围的格子也预测到了高质量的样本 我们也要把这部分的预测信息加入正样本中
# 设置网络中心偏移量
g = 0.5 # bias 用来衡量target中心点离哪个格子近
# 附近4个网格
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=self.device).float() * g # offsets
# 对每个检测层进行处理
for i in range(self.nl): # 三个尺度的预测特征图输出分支
# 当前feature map对应的三个anchor尺寸
anchors, shape = self.anchors[i], p[i].shape
# [1, 1, 1, 1, 1, 1, 1] -> [1, 1, 112, 112, 112,112, 1]=image_index+class+xywh+anchor_index
gain[2:6] = torch.tensor(shape)[[3, 2, 3, 2]] # xyxy gain
# Match targets to anchors
t = targets * gain # shape(3,n,7)
if nt: # 开始匹配
# Matches
r = t[..., 4:6] / anchors[:, None] # wh ratio
# 筛选条件 GT与anchor的宽比或高比超过一定的阈值 就当作负样本
# 筛选出宽比w1/w2 w2/w1 高比h1/h2 h2/h1中最大的那个
# .max(2)返回宽比 高比两者中较大的一个值和它的索引 [0]返回较大的一个值
# j: [3, 63] False: 当前anchor是当前gt的负样本 True: 当前anchor是当前gt的正样本
j = torch.max(r, 1 / r).max(2)[0] < self.hyp['anchor_t'] # compare
# 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] # filter
# Offsets 筛选当前格子周围格子 找到2个离target中心最近的两个格子 可能周围的格子也预测到了高质量的样本 我们也要把这部分的预测信息加入正样本中
# 除了target所在的当前格子外, 还有2个格子对目标进行检测(计算损失) 也就是说一个目标需要3个格子去预测(计算损失)
# 首先当前格子是其中1个 再从当前格子的上下左右四个格子中选择2个 用这三个格子去预测这个目标(计算损失)
# feature map上的原点在左上角 向右为x轴正坐标 向下为y轴正坐标
gxy = t[:, 2:4] # grid xy
gxi = gain[[2, 3]] - gxy # inverse
# 筛选中心坐标 距离当前grid_cell的左、上方偏移小于g=0.5 且 中心坐标必须大于1(坐标不能在边上 此时就没有4个格子了)
# j: [126] bool 如果是True表示当前target中心点所在的格子的左边格子也对该target进行回归(后续进行计算损失)
# k: [126] bool 如果是True表示当前target中心点所在的格子的上边格子也对该target进行回归(后续进行计算损失)
j, k = ((gxy % 1 < g) & (gxy > 1)).T
# 筛选中心坐标 距离当前grid_cell的右、下方偏移小于g=0.5 且 中心坐标必须大于1(坐标不能在边上 此时就没有4个格子了)
# l: [126] bool 如果是True表示当前target中心点所在的格子的右边格子也对该target进行回归(后续进行计算损失)
# m: [126] bool 如果是True表示当前target中心点所在的格子的下边格子也对该target进行回归(后续进行计算损失)
l, m = ((gxi % 1 < g) & (gxi > 1)).T
# j: [5, 126] torch.ones_like(j): 当前格子, 不需要筛选全是True j, k, l, m: 左上右下格子的筛选结果
j = torch.stack((torch.ones_like(j), j, k, l, m))
# 得到筛选后所有格子的正样本 格子数<=3*126 都不在边上等号成立
# t: [126, 7] -> 复制5份target[5, 126, 7] 分别对应当前格子和左上右下格子5个格子
# j: [5, 126] + t: [5, 126, 7] => t: [378, 7] 理论上是小于等于3倍的126 当且仅当没有边界的格子等号成立
t = t.repeat((5, 1, 1))[j]
# 添加偏移量
offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j]
else:
t = targets[0]
offsets = 0
# Define
bc, gxy, gwh, a = t.chunk(4, 1) # (image, class), grid xy, grid wh, anchors
a, (b, c) = a.long().view(-1), bc.long().T # anchors, image, class
gij = (gxy - offsets).long() # 预测真实框的网格所在的左上角坐标(有左上右下的网格)
gi, gj = gij.T # grid indices
# Append
# gj: 网格的左上角y坐标 gi: 网格的左上角x坐标
indices.append((b, a, gj.clamp_(0, shape[2] - 1), gi.clamp_(0, shape[3] - 1))) # image, anchor, grid
tbox.append(torch.cat((gxy - gij, gwh), 1)) # box
anch.append(anchors[a]) # anchors
tcls.append(c) # class
return tcls, tbox, indices, anch
这段代码主要是处理当前批次的所有图片的targets,将预测的格式转化为便于计算loss的target格式。筛选条件是比较GT和anchor的宽比和高比,大于一定的阈值就是负样本,反之正样本。
作用:用于网络训练时计算loss所需要的目标框,即正样本。
筛选到的正样本信息(image_index, anchor_index, gridy, gridx),传入__call__函数,通过这个信息去筛选pred每个grid预测得到的信息,保留对应grid_cell上的正样本。通过build_targets筛选的GT中的正样本和pred筛选出的对应位置的预测样本进行计算损失。
为什么原图上归一化的框特征图的大小就是特征图上的坐标了呢?
即这行代码:t = targets*gain
,具体可看这篇博文博客
对于下采样32倍的特征图来说,每一个格子对应着原图上( h / 32 , w / 32 ) (h/32,w/32)(h/32,w/32)的大小,其中h,w是原图的高和宽
def __call__(self, p, targets): # predictions, targets
'''
:params targets: 数据增强后的真实框 [num_object, batch_index+class+xywh] 我这里的数据是[35,6]
:params loss * bs: 整个batch的总损失 进行反向传播
:params torch.cat((lbox, lobj, lcls, loss)).detach(): 回归损失、置信度损失、分类损失和总损失 这个参数只用来可视化参数或保存信息
'''
# 初始化三种损失
lcls = torch.zeros(1, device=self.device) # class loss
lbox = torch.zeros(1, device=self.device) # box loss
lobj = torch.zeros(1, device=self.device) # object loss
# tcls: 表示这个target所属的class index
# tbox: xywh 其中xy为这个target对当前grid_cell左上角的偏移量
# indices: b: 表示这个target属于的image index
# a: 表示这个target使用的anchor index
# gj: 经过筛选后确定某个target在某个网格中进行预测(计算损失) gj表示这个网格的左上角y坐标
# gi: 表示这个网格的左上角x坐标
# anch: 表示这个target所使用anchor的尺度(相对于这个feature map) 注意可能一个target会使用大小不同anchor进行计算
tcls, tbox, indices, anchors = self.build_targets(p, targets) # targets
# Losses
for i, pi in enumerate(p): # layer index, layer predictions
b, a, gj, gi = indices[i] # image, anchor, gridy, gridx
tobj = torch.zeros(pi.shape[:4], dtype=pi.dtype, device=self.device) # target obj
n = b.shape[0] # number of targets
if n:
# pxy, pwh, _, pcls = pi[b, a, gj, gi].tensor_split((2, 4, 5), dim=1) # faster, requires torch 1.8.0
pxy, pwh, _, pcls = pi[b, a, gj, gi].split((2, 2, 1, self.nc), 1) # target-subset of predictions
# Regression
pxy = pxy.sigmoid() * 2 - 0.5
pwh = (pwh.sigmoid() * 2) ** 2 * anchors[i]
pbox = torch.cat((pxy, pwh), 1) # predicted box
iou = bbox_iou(pbox, tbox[i], CIoU=True).squeeze() # iou(prediction, target)
lbox += (1.0 - iou).mean() # iou loss
# Objectness 只计算所有正样本的回归损失
iou = iou.detach().clamp(0).type(tobj.dtype)
if self.sort_obj_iou:
j = iou.argsort()
# 排序之后 如果同一个grid出现两个gt 那么我们经过排序之后每个grid中的score_iou都能保证是最大的
# (小的会被覆盖 因为同一个grid坐标肯定相同)那么从时间顺序的话, 最后1个总是和最大的IOU去计算LOSS, 梯度传播
b, a, gj, gi, iou = b[j], a[j], gj[j], gi[j], iou[j]
if self.gr < 1:
iou = (1.0 - self.gr) + self.gr * iou
# 预测信息有置信度 但是真实框信息是没有置信度的 所以需要我们人为的给一个标准置信度
# self.gr是iou ratio [0, 1] self.gr越大置信度越接近iou self.gr越小置信度越接近1(人为加大训练难度)
tobj[b, a, gj, gi] = iou # iou ratio
# Classification 只计算所有正样本的分类损失
if self.nc > 1: # cls loss (only if multiple classes)
t = torch.full_like(pcls, self.cn, device=self.device) # targets
t[range(n), tcls[i]] = self.cp
lcls += self.BCEcls(pcls, t) # BCE
# Append targets to text file
# with open('targets.txt', 'a') as file:
# [file.write('%11.5g ' * 4 % tuple(x) + '\n') for x in torch.cat((txy[i], twh[i]), 1)]
obji = self.BCEobj(pi[..., 4], tobj)
# 每个feature map的置信度损失权重不同 要乘以相应的权重系数self.balance[i]
# 一般来说,检测小物体的难度大一点,所以会增加大特征图的损失系数,让模型更加侧重小物体的检测
lobj += obji * self.balance[i] # obj loss
if self.autobalance:
self.balance[i] = self.balance[i] * 0.9999 + 0.0001 / obji.detach().item()
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] # batch size
return (lbox + lobj + lcls) * bs, torch.cat((lbox, lobj, lcls)).detach()
分别计算了三类损失,在train.py中调用返回
loss, loss_items = compute_loss(pred, targets.to(device))
又学了一遍loss,这次算是真的学通了!!!
分类损失采用的是nn.BCEWithLogitsLoss,即二分类损失,你没听错,就是用的二分类损失,比如现在有4个分类:猫、狗、猪、鸡,当前标签真值为猪,那么计算损失的时候,targets就是[0, 0, 1, 0]。
按照640乘640分辨率,3个输出层来算的话,P3是80乘80个格子,P4是40乘40,P5是20乘20,一共有8400个格子,并不是每一个格子上的输出都要去做分类损失计算的,只有负责预测对应物体的格子才需要做分类损失计算(边框损失计算也是一样)。至于哪些格子才会负责去预测对应的物体,这个逻辑下面再说。
置信度损失就是跟IOU挂钩的。
置信度损失同样也是BCEWithLogitsLoss,不过置信度是每一个格子都要做损失计算的,因为最终在使用的时候我们首先就是由置信度阈值来判断对应格子的输出是不是可信的。置信度的真值并不是固定的,如果该格子负责预测对应的物体,那么置信度真值就是预测边框与标签边框的IOU。如果不负责预测任何物体,那真值就是0。
样本分配是在网络最后输出的三个不同下采样倍数的特征图上逐层进行的:
接下来就是anchor在模型中的应用了。这就涉及到了yolo系列目标框回归的过程了。
边框损失由预测边框与标签边框的IOU来定,IOU越大,损失自然越小,IOU如果是1,损失就是0,IOU如果是0,损失就越大,上限定为1,所以边框损失就是1-IOU。
如图所示,我们检测到的不是框,而是偏移量。得到bx,by,bw,bh就是最终的检测结果。
其中,tx、ty、tw、th为模型预测输出,bx、by、bw、bh为最终预测目标边框中心点,宽高。
关于anchor,看这里:YOLO v2。anchor主要是可以加速训练(从直接预测位置变为预测偏移量)
过程总结
iou = bbox_iou(pbox, tbox[i], CIoU=True).squeeze() # iou(prediction, target)
lbox += (1.0 - iou).mean() # iou loss
MMDetection移植yolov5——(二)前向推理
附一张流程图,写的太好了!还就那个醍醐灌顶!!!
这个文件我觉得是整个YOLOv5源码中最难的一个了,太难理解了,尤其是我的pytorch还不是很熟,各种花里胡哨的矩阵操作看的我太痛苦了。尽管写这篇博客的时候看了大量的其他博客,但还是难以理解,我太难了TnT
之前一直觉得置信度损失和bbox损失很像,一直云里雾里的,问了下gpt,感觉还是讲的挺透彻的。
References
CSDN 西西弗Sisyphus: 目标检测 YOLOv5 - Sample Assignment
CSDN 满船清梦压星河HK:【YOLOV5-5.x 源码解读】loss.py
CSDN 小哈蒙德:YOLO-V3-SPP 训练时正样本筛选源码解析之build_targets
CSDN guikunchen:yolov5 代码解读 损失函数 loss.py
CSDN gorgeous(๑><๑)【代码解读】超详细,YOLOV5之build_targets函数解读。
B站 薛定谔的AI yolo v5 解读,训练,复现