YOLOv1是一個anchor-free的,從YOLOv2開始引入了Anchor,在VOC2007數據集上將mAP提升了10個百分點。YOLOv3也繼續使用了Anchor,本文主要講ultralytics版YOLOv3的Loss部分的計算, 實際上這部分loss和原版差距非常大,並且可以通過arc指定loss的構建方式, 如果想看原版的loss可以在下方release的v6中下載源碼。
Github地址: https://github.com/ultralytics/yolov3
Github release: https://github.com/ultralytics/yolov3/releases
1. Anchor
Faster R-CNN中Anchor的大小和比例是由人手工設計的,可能並不貼合數據集,有可能會給模型性能帶來負面影響。YOLOv2和YOLOv3則是通過聚類算法得到最適合的k個框。聚類距離是通過IoU來定義,IoU越大,邊框距離越近。
Anchor越多,平均IoU會越大,效果越好,但是會帶來計算量上的負擔,下圖是YOLOv2論文中的聚類數量和平均IoU的關系圖,在YOLOv2中選擇了5個anchor作為精度和速度的平衡。
2. 偏移公式
在Faster RCNN中,中心坐標的偏移公式是:
其中\(x_a\)、\(y_a\) 代表中心坐標,\(w_a\)和\(h_a\)代表寬和高,\(t_x\)和\(t_y\)是模型預測的Anchor相對於Ground Truth的偏移量,通過計算得到的x,y就是最終預測框的中心坐標。
而在YOLOv2和YOLOv3中,對偏移量進行了限制,如果不限制偏移量,那么邊框的中心可以在圖像任何位置,可能導致訓練的不穩定。
對照上圖進行理解:
-
\(c_x\)和\(c_y\)分別代表中心點所處區域的左上角坐標。
-
\(p_w\)和\(p_h\)分別代表Anchor的寬和高。
-
\(\sigma(t_x)\)和\(\sigma(t_y)\)分別代表預測框中心點和左上角的距離,\(\sigma\)代表sigmoid函數,將偏移量限制在當前grid中,有利於模型收斂。
-
\(t_w\)和\(t_h\)代表預測的寬高偏移量,Anchor的寬和高乘上指數化后的寬高,對Anchor的長寬進行調整。
-
\(\sigma(t_o)\)是置信度預測值,是當前框有目標的概率乘以bounding box和ground truth的IoU的結果
3. Loss
YOLOv3中有一個參數是ignore_thresh,在ultralytics版版的YOLOv3中對應的是train.py文件中的iou_t
參數(默認為0.225)。
正負樣本是按照以下規則決定的:
-
如果一個預測框與所有的Ground Truth的最大IoU<ignore_thresh時,那這個預測框就是負樣本。
-
如果Ground Truth的中心點落在一個區域中,該區域就負責檢測該物體。將與該物體有最大IoU的預測框作為正樣本(注意這里沒有用到ignore thresh,即使該最大IoU<ignore thresh也不會影響該預測框為正樣本)
在YOLOv3中,Loss分為三個部分:
- 一個是xywh部分帶來的誤差,也就是bbox帶來的loss
- 一個是置信度帶來的誤差,也就是obj帶來的loss
- 最后一個是類別帶來的誤差,也就是class帶來的loss
在代碼中分別對應lbox, lobj, lcls,yolov3中使用的loss公式如下:
其中:
S: 代表grid size, \(S^2\)代表13x13,26x26, 52x52
B: box
\(1_{i,j}^{obj}\): 如果在i,j處的box有目標,其值為1,否則為0
\(1_{i,j}^{noobj}\): 如果在i,j處的box沒有目標,其值為1,否則為0
BCE(binary cross entropy)具體計算公式如下:
以上是論文中yolov3對應的darknet。而pytorch版本的yolov3改動比較大,有較大的改動空間,可以通過參數進行調整。
分成三個部分進行具體分析:
1. lbox部分
在ultralytics版版的YOLOv3中,使用的是GIOU,具體講解見GIOU講解鏈接。
簡單來說是這樣的公式,IoU公式如下:
而GIoU公式如下:
其中\(A_c\)代表兩個框最小閉包區域面積,也就是同時包含了預測框和真實框的最小框的面積。
yolov3中提供了IoU、GIoU、DIoU和CIoU等計算方式,以GIoU為例:
if GIoU: # Generalized IoU https://arxiv.org/pdf/1902.09630.pdf
c_area = cw * ch + 1e-16 # convex area
return iou - (c_area - union) / c_area # GIoU
可以看到代碼和GIoU公式是一致的,再來看一下lbox計算部分:
giou = bbox_iou(pbox.t(), tbox[i],
x1y1x2y2=False, GIoU=True)
lbox += (1.0 - giou).sum() if red == 'sum' else (1.0 - giou).mean()
可以看到box的loss是1-giou的值。
2. lobj部分
lobj代表置信度,即該bounding box中是否含有物體的概率。在yolov3代碼中obj loss可以通過arc來指定,有兩種模式:
如果采用default模式,使用BCEWithLogitsLoss,將obj loss和cls loss分開計算:
BCEobj = nn.BCEWithLogitsLoss(pos_weight=ft([h['obj_pw']]), reduction=red)
if 'default' in arc: # separate obj and cls
lobj += BCEobj(pi[..., 4], tobj) # obj loss
# pi[...,4]對應的是該框中含有目標的置信度,和giou計算BCE
# 相當於將obj loss和cls loss分開計算
如果采用BCE模式,使用的也是BCEWithLogitsLoss, 計算對象是所有的cls loss:
BCE = nn.BCEWithLogitsLoss(reduction=red)
elif 'BCE' in arc: # unified BCE (80 classes)
t = torch.zeros_like(pi[..., 5:]) # targets
if nb:
t[b, a, gj, gi, tcls[i]] = 1.0 # 對應正樣本class置信度設置為1
lobj += BCE(pi[..., 5:], t)#pi[...,5:]對應的是所有的class
3. lcls部分
如果是單類的情況,cls loss=0
如果是多類的情況,也分兩個模式:
如果采用default模式,使用的是BCEWithLogitsLoss計算class loss。
BCEcls = nn.BCEWithLogitsLoss(pos_weight=ft([h['cls_pw']]), reduction=red)
# cls loss 只計算多類之間的loss,單類不進行計算
if 'default' in arc and model.nc > 1:
t = torch.zeros_like(ps[:, 5:]) # targets
t[range(nb), tcls[i]] = 1.0 # 設置對應class為1
lcls += BCEcls(ps[:, 5:], t) # 使用BCE計算分類loss
如果采用CE模式,使用的是CrossEntropy同時計算obj loss和cls loss。
CE = nn.CrossEntropyLoss(reduction=red)
elif 'CE' in arc: # unified CE (1 background + 80 classes)
t = torch.zeros_like(pi[..., 0], dtype=torch.long) # targets
if nb:
t[b, a, gj, gi] = tcls[i] + 1 # 由於cls是從零開始計數的,所以+1
lcls += CE(pi[..., 4:].view(-1, model.nc + 1), t.view(-1))
# 這里將obj loss和cls loss一起計算,使用CrossEntropy Loss
以上三部分總結下來就是下圖:
4. 代碼
ultralytics版版的yolov3的loss已經和論文中提出的部分大相徑庭了,代碼中很多地方地方是來自作者的經驗。另外,這里讀的代碼是2020年2月份左右作者發布的版本,關注這個庫的人會知道,作者更新速度非常快,在筆者寫這篇文章的時候,loss也出現了大幅改動,添加了label smoothing等新的機制,去掉了通過arc來調整loss的機制,簡化了loss部分。
這部分的代碼添加了大量注釋,很多是筆者通過debug得到的結果,理解的時候需要講一下debug的配置:
- 單類數據集class=1
- batch size=2
- 模型是yolov3.cfg
計算loss這部分代碼可以大概上分為兩部分,一部分是正負樣本選取,一部分是loss計算。
1. 正負樣本選取部分
這部分主要工作是在每個yolo層將預設的anchor和ground truth進行匹配,得到正樣本,回顧一下上文中在YOLOv3中正負樣本選取規則:
-
如果一個預測框與所有的Ground Truth的最大IoU<ignore_thresh時,那這個預測框就是負樣本。
-
如果Ground Truth的中心點落在一個區域中,該區域就負責檢測該物體。將與該物體有最大IoU的預測框作為正樣本(注意這里沒有用到ignore thresh,即使該最大IoU<ignore thresh也不會影響該預測框為正樣本)
def build_targets(model, targets):
# targets = [image, class, x, y, w, h]
# 這里的image是一個數字,代表是當前batch的第幾個圖片
# x,y,w,h都進行了歸一化,除以了寬或者高
nt = len(targets)
tcls, tbox, indices, av = [], [], [], []
multi_gpu = type(model) in (nn.parallel.DataParallel,
nn.parallel.DistributedDataParallel)
reject, use_all_anchors = True, True
for i in model.yolo_layers:
# yolov3.cfg中有三個yolo層,這部分用於獲取對應yolo層的grid尺寸和anchor大小
# ng 代表num of grid (13,13) anchor_vec [[x,y],[x,y]]
# 注意這里的anchor_vec: 假如現在是yolo第一個層(downsample rate=32)
# 這一層對應anchor為:[116, 90], [156, 198], [373, 326]
# anchor_vec實際值為以上除以32的結果:[3.6,2.8],[4.875,6.18],[11.6,10.1]
# 原圖 416x416 對應的anchor為 [116, 90]
# 下采樣32倍后 13x13 對應的anchor為 [3.6,2.8]
if multi_gpu:
ng = model.module.module_list[i].ng
anchor_vec = model.module.module_list[i].anchor_vec
else:
ng = model.module_list[i].ng,
anchor_vec = model.module_list[i].anchor_vec
# iou of targets-anchors
# targets中保存的是ground truth
t, a = targets, []
gwh = t[:, 4:6] * ng[0]
if nt: # 如果存在目標
# anchor_vec: shape = [3, 2] 代表3個anchor
# gwh: shape = [2, 2] 代表 2個ground truth
# iou: shape = [3, 2] 代表 3個anchor與對應的兩個ground truth的iou
iou = wh_iou(anchor_vec, gwh) # 計算先驗框和GT的iou
if use_all_anchors:
na = len(anchor_vec) # number of anchors
a = torch.arange(na).view(
(-1, 1)).repeat([1, nt]).view(-1) # 構造 3x2 -> view到6
# a = [0,0,1,1,2,2]
t = targets.repeat([na, 1])
# targets: [image, cls, x, y, w, h]
# 復制3個: shape[2,6] to shape[6,6]
gwh = gwh.repeat([na, 1])
# gwh shape:[6,2]
else: # use best anchor only
iou, a = iou.max(0) # best iou and anchor
# 取iou最大值是darknet的默認做法,返回的a是下角標
# reject anchors below iou_thres (OPTIONAL, increases P, lowers R)
if reject:
# 在這里將所有閾值小於ignore thresh的去掉
j = iou.view(-1) > model.hyp['iou_t']
# iou threshold hyperparameter
t, a, gwh = t[j], a[j], gwh[j]
# Indices
b, c = t[:, :2].long().t() # target image, class
# 取的是targets[image, class, x,y,w,h]中 [image, class]
gxy = t[:, 2:4] * ng[0] # grid x, y
gi, gj = gxy.long().t() # grid x, y indices
# 注意這里通過long將其轉化為整形,代表格子的左上角
indices.append((b, a, gj, gi))
# indice結構體保存內容為:
'''
b: 一個batch中的角標
a: 代表所選中的正樣本的anchor的下角標
gj, gi: 代表所選中的grid的左上角坐標
'''
# Box
gxy -= gxy.floor() # xy
# 現在gxy保存的是偏移量,是需要YOLO進行擬合的對象
tbox.append(torch.cat((gxy, gwh), 1)) # xywh (grids)
# 保存對應偏移量和寬高(對應13x13大小的)
av.append(anchor_vec[a]) # anchor vec
# av 是anchor vec的縮寫,保存的是匹配上的anchor的列表
# Class
tcls.append(c)
# tcls用於保存匹配上的類別列表
if c.shape[0]: # if any targets
assert c.max() < model.nc, 'Model accepts %g classes labeled from 0-%g, however you labelled a class %g. ' \
'See https://github.com/ultralytics/yolov3/wiki/Train-Custom-Data' % (
model.nc, model.nc - 1, c.max())
return tcls, tbox, indices, av
梳理一下在每個YOLO層的匹配流程:
- 將ground truth和anchor進行匹配,得到iou
- 然后有兩個方法匹配:
- 使用yolov3原版的匹配機制,僅僅選擇iou最大的作為正樣本
- 使用ultralytics版版yolov3的默認匹配機制,use_all_anchors=True的時候,選擇所有的匹配對
- 對以上匹配的部分在進行篩選,對應原版yolo中ignore_thresh部分,將以上匹配到的部分中iou<ignore_thresh的部分篩選掉。
- 最后將匹配得到的內容返回到compute_loss函數中。
2. loss計算部分
這部分就是yolov3中核心loss計算,這部分對照上文的講解進行理解。
def compute_loss(p, targets, model):
# p: (bs, anchors, grid, grid, classes + xywh)
# predictions, targets, model
ft = torch.cuda.FloatTensor if p[0].is_cuda else torch.Tensor
lcls, lbox, lobj = ft([0]), ft([0]), ft([0])
tcls, tbox, indices, anchor_vec = build_targets(model, targets)
'''
以yolov3為例,有三個yolo層
tcls: 一個list保存三個tensor,每個tensor中有6(2個gtx3個anchor)個代表類別的數字
tbox: 一個list保存三個tensor,每個tensor形狀[6,4],6(2個gtx3個anchor)個bbox
indices: 一個list保存三個tuple,每個tuple中保存4個tensor:
分別代表 b: 一個batch中的角標
a: 代表所選中的正樣本的anchor的下角標
gj, gi: 代表所選中的grid的左上角坐標
anchor_vec: 一個list保存三個tensor,每個tensor形狀[6,2],
6(2個gtx3個anchor)個anchor,注意大小是相對於13x13feature map的anchor大小
'''
h = model.hyp # hyperparameters
arc = model.arc # # (default, uCE, uBCE) detection architectures
# 具體使用的損失函數是通過arc參數決定的
red = 'sum' # Loss reduction (sum or mean)
# Define criteria
BCEcls = nn.BCEWithLogitsLoss(pos_weight=ft([h['cls_pw']]), reduction=red)
BCEobj = nn.BCEWithLogitsLoss(pos_weight=ft([h['obj_pw']]), reduction=red)
#BCEWithLogitsLoss = sigmoid + BCELoss
BCE = nn.BCEWithLogitsLoss(reduction=red)
CE = nn.CrossEntropyLoss(reduction=red) # weight=model.class_weights
# class label smoothing https://arxiv.org/pdf/1902.04103.pdf eqn 3
# cp, cn = smooth_BCE(eps=0.0)
# 這是最新的版本中提供了label smoothing的功能,只能用在多類問題
if 'F' in arc: # add focal loss
g = h['fl_gamma']
BCEcls, BCEobj, BCE, CE = FocalLoss(BCEcls, g), FocalLoss(
BCEobj, g), FocalLoss(BCE, g), FocalLoss(CE, g)
# focal loss可以用在cls loss或者obj loss
# Compute losses
np, ng = 0, 0 # number grid points, targets
# np這個命名真的迷,建議改一下和numpy縮寫重復
for i, pi in enumerate(p): # layer index, layer predictions
# 在yolov3中,p有三個yolo layer的輸出pi
# 形狀為:(bs, anchors, grid, grid, classes + xywh)
b, a, gj, gi = indices[i] # image, anchor, gridy, gridx
tobj = torch.zeros_like(pi[..., 0])
# tobj = target obj, 形狀為(bs, anchors, grid, grid)
np += tobj.numel() # 返回tobj中元素個數
# Compute losses
nb = len(b)
if nb:
ng += nb # number of targets 用於最后算平均loss
# (bs, anchors, grid, grid, classes + xywh)
ps = pi[b, a, gj, gi] # 即找到了對應目標的classes+xywh,形狀為[6(2x3),6]
# GIoU
pxy = torch.sigmoid(
ps[:, 0:2] # 將x,y進行sigmoid
) # pxy = pxy * s - (s - 1) / 2, s = 1.5 (scale_xy)
pwh = torch.exp(ps[:, 2:4]).clamp(max=1E3) * anchor_vec[i]
# 防止溢出進行clamp操作,乘以13x13feature map對應的anchor
# 這部分和上文中偏移公式是一致的
pbox = torch.cat((pxy, pwh), 1) # predicted box
# pbox: predicted bbox shape:[6, 4]
giou = bbox_iou(pbox.t(), tbox[i], x1y1x2y2=False,
GIoU=True) # giou computation
# 計算giou loss, 形狀為6
lbox += (1.0 - giou).sum() if red == 'sum' else (1.0 - giou).mean()
# bbox loss直接由giou決定
tobj[b, a, gj, gi] = giou.detach().type(tobj.dtype)
# target obj 用giou取代1,代表該點對應置信度
# cls loss 只計算多類之間的loss,單類不進行計算
if 'default' in arc and model.nc > 1:
t = torch.zeros_like(ps[:, 5:]) # targets
t[range(nb), tcls[i]] = 1.0 # 設置對應class為1
lcls += BCEcls(ps[:, 5:], t) # 使用BCE計算分類loss
if 'default' in arc: # separate obj and cls
lobj += BCEobj(pi[..., 4], tobj) # obj loss
# pi[...,4]對應的是該框中含有目標的置信度,和giou計算BCE
# 相當於將obj loss和cls loss分開計算
elif 'BCE' in arc: # unified BCE (80 classes)
t = torch.zeros_like(pi[..., 5:]) # targets
if nb:
t[b, a, gj, gi, tcls[i]] = 1.0 # 對應正樣本class置信度設置為1
lobj += BCE(pi[..., 5:], t)
#pi[...,5:]對應的是所有的class
elif 'CE' in arc: # unified CE (1 background + 80 classes)
t = torch.zeros_like(pi[..., 0], dtype=torch.long) # targets
if nb:
t[b, a, gj, gi] = tcls[i] + 1 # 由於cls是從零開始計數的,所以+1
lcls += CE(pi[..., 4:].view(-1, model.nc + 1), t.view(-1))
# 這里將obj loss和cls loss一起計算,使用CrossEntropy Loss
# 使用對應的權重來平衡,這個參數是作者通過參數搜索(random search)的方法搜索得到的
lbox *= h['giou']
lobj *= h['obj']
lcls *= h['cls']
if red == 'sum':
bs = tobj.shape[0] # batch size
lobj *= 3 / (6300 * bs) * 2
# 6300 = (10 ** 2 + 20 ** 2 + 40 ** 2) * 3
# 輸入為320x320的圖片,則存在6300個anchor
# 3代表3個yolo層, 2是一個超參數,通過實驗獲取
# 如果不想計算的話,可以修改red='mean'
if ng:
lcls *= 3 / ng / model.nc
lbox *= 3 / ng
loss = lbox + lobj + lcls
return loss, torch.cat((lbox, lobj, lcls, loss)).detach()
需要注意的是,三個部分的loss的平衡權重不是按照yolov3原文的設置來做的,是通過超參數進化來搜索得到的,具體請看:【從零開始學習YOLOv3】4. YOLOv3中的參數進化
5. 補充
補充一下BCEWithLogitsLoss的用法,在這之前先看一下BCELoss:
torch.nn.BCELoss
的功能是二分類任務是的交叉熵計算函數,可以認為是CrossEntropy的特例。其分類限定為二分類,y的值必須為{0,1},input應該是概率分布的形式。在使用BCELoss前一般會先加一個sigmoid激活層,常用在自編碼器中。
計算公式:
\(w_n\)是每個類別的loss權重,用於類別不均衡問題。
torch.nn.BCEWithLogitsLoss
的相當於Sigmoid+BCELoss, 即input會經過Sigmoid激活函數,將input變為概率分布的形式。
計算公式: