上篇我们讲解了如何进行数据预处理,读取数据。接下来我们一起分析yolov3训练过程与training procedure。想真正读懂这个部分,要对inference部分有所了解。
数据加载
def train(
cfg,
data_cfg,
img_size=416,
resume=False,
epochs=273, # 500200 batches at bs 64, dataset length 117263
batch_size=16,
accumulate=1,
multi_scale=False,
freeze_backbone=False,
transfer=False # Transfer learning (train only YOLO layers)
):
init_seeds()
weights = 'weights' + os.sep #python是跨平台的。在Windows上,文件的路径分隔符是'\',在Linux上是'/'。
latest = weights + 'latest.pt'
best = weights + 'best.pt'
device = torch_utils.select_device()
# Configure run
train_path = parse_data_cfg(data_cfg)['train'] #data_cfg='data/coco.data' train=../coco/trainvalno5k.txt
# Initialize model
model = Darknet(cfg, img_size).to(device)
# Optimizer
optimizer = optim.SGD(model.parameters(), lr=hyp['lr0'], momentum=hyp['momentum'], weight_decay=hyp['weight_decay'])
cutoff = -1 # backbone reaches to cutoff layer
start_epoch = 0
best_loss = float('inf')
nf = int(model.module_defs[model.yolo_layers[0] - 1]['filters']) # yolo layer size (i.e. 255)
if resume: # Load previously saved model
if transfer: # Transfer learning
chkpt = torch.load(weights + 'yolov3.pt', map_location=device)
model.load_state_dict({k: v for k, v in chkpt['model'].items() if v.numel() > 1 and v.shape[0] != 255},
strict=False)
for p in model.parameters():
p.requires_grad = True if p.shape[0] == nf else False
else: # resume from latest.pt
chkpt = torch.load(latest, map_location=device) # load checkpoint
model.load_state_dict(chkpt['model'])
start_epoch = chkpt['epoch'] + 1
if chkpt['optimizer'] is not None:
optimizer.load_state_dict(chkpt['optimizer'])
best_loss = chkpt['best_loss']
del chkpt
else: # Initialize model with backbone (optional)
if '-tiny.cfg' in cfg:
cutoff = load_darknet_weights(model, weights + 'yolov3-tiny.conv.15')
else:
cutoff = load_darknet_weights(model, weights + 'darknet53.conv.74')
lf = lambda x: 1 - 10 ** (hyp['lrf'] * (1 - x / epochs)) # inv exp ramp to lr0 * 1e-2
scheduler = optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lf, last_epoch=start_epoch - 1)
dataset = LoadImagesAndLabels(train_path, img_size=img_size, augment=True)
# dataset.__getitem__(0)
# Initialize distributed training
if torch.cuda.device_count() > 1:
dist.init_process_group(backend=opt.backend, init_method=opt.dist_url, world_size=opt.world_size, rank=opt.rank)
model = torch.nn.parallel.DistributedDataParallel(model)
sampler = torch.utils.data.distributed.DistributedSampler(dataset)
else:
sampler = None
# Dataloader
dataloader = DataLoader(dataset,
batch_size=batch_size,
num_workers=opt.num_workers,
shuffle=True,
pin_memory=True,
collate_fn=dataset.collate_fn,
sampler=sampler)
这一段代码实现了上一篇讲的数据加载过程,以及路径配置和模式选择,具体我不赘述,如果想了解的话可以在评论问我。
train以及loss
for epoch in range(start_epoch, epochs):
model.train()
print(('\n%8s%12s' + '%10s' * 7) % ('Epoch', 'Batch', 'xy', 'wh', 'conf', 'cls', 'total', 'nTargets', 'time'))
# Update scheduler
scheduler.step()
# Freeze backbone at epoch 0, unfreeze at epoch 1
if freeze_backbone and epoch < 2:
for name, p in model.named_parameters():
if int(name.split('.')[1]) < cutoff: # if layer < 75
p.requires_grad = False if epoch == 0 else True
mloss = torch.zeros(5).to(device) # mean losses
for i, (imgs, targets, _, _) in enumerate(dataloader):
imgs = imgs.to(device)
targets = targets.to(device)
nt = len(targets)
# if nt == 0: # if no targets continue
# continue
# Plot images with bounding boxes
if epoch == 0 and i == 0:
plot_images(imgs=imgs, targets=targets, fname='train_batch0.jpg')
# SGD burn-in
if epoch == 0 and i <= n_burnin:
lr = hyp['lr0'] * (i / n_burnin) ** 4
for x in optimizer.param_groups:
x['lr'] = lr
# Run model
pred = model(imgs)
# Compute loss
loss, loss_items = compute_loss(pred, targets, model)
train的过程:
1:设置epoch参数,它决定了所有数据所需要训练的轮数。
2:进入epoch的for循环后,讲model设置为train,然后for i, (imgs, targets, _, _) in enumerate(dataloader):获取数据预处理后的数据和labels,这里要注意数据和labels都resize成416*416了(与txt中的不同)。
3:将取出的数据imgs传入model中,model就是yolov3的darknet,它有3个yolo层,每个yolo层都会输出一个特征映射图(dimention如(bs, 3, 13, 13, 85))bs=batch_size,3指每一个像素点存在3种anchor,13*13是它的维度,85=xywh+conf+classes。
class YOLOLayer(nn.Module):#x = module[0](x, img_size)
def __init__(self, anchors, nc, img_size, yolo_layer, cfg):
super(YOLOLayer, self).__init__()
self.anchors = torch.Tensor(anchors)
self.na = len(anchors) # number of anchors (3)
self.nc = nc # number of classes (80)
self.img_size = 0
if ONNX_EXPORT: # grids must be computed in __init__
stride = [32, 16, 8][yolo_layer] # stride of this layer
if cfg.endswith('yolov3-tiny.cfg'):
stride *= 2
ng = (int(img_size[0] / stride), int(img_size[1] / stride)) # number grid points
create_grids(self, max(img_size), ng)
def forward(self, p, img_size, var=None):
if ONNX_EXPORT:
bs = 1 # batch size
else:
bs, nx, ny = p.shape[0], p.shape[-2], p.shape[-1]
if self.img_size != img_size:
create_grids(self, img_size, (nx, ny), p.device)
# p.view(bs, 255, 13, 13) -- > (bs, 3, 13, 13, 85) # (bs, anchors, grid, grid, classes + xywh)
p = p.view(bs, self.na, self.nc + 5, self.nx, self.ny).permute(0, 1, 3, 4, 2).contiguous() # prediction
if self.training:
return p
4:loss, loss_items = compute_loss(pred, targets, model),三个yolo层的输出最终与labels产生的targets运算得到loss。
5:用设置好的optimizer对loss进行BP。
6:保存最终的参数。
loss的构成:
首先,我从yolov3的算法思想讲起,让大家对整体思路有所了解,再具体到代码层面,这样大家可以感受代码复现算法的过程,从而真正理解yolov3。这对大家再相应背景下训练自己的数据也好,改造yolov3也好,都有直接的帮助。
yolov3算法思想:首先设计darknet,darknet是resnet的变形,在imagenet数据集上进行训练。然后去除最后的全连接,并在模型上增加了三个维度13,26,52的输出,即yolo层(为了增加小模型的检测精度),再用coco数据集进行微调。coco数据集的标签包括了class与ground truth(xywh)。loss由四个部分组成lxy,lwh,lcls,lconf组成(代码中会解释每个成分)。
这里就有个重要的疑问了,一个尺度的feature map有三个anchors,那么对于某个ground truth框,究竟是哪个anchor负责匹配它呢?和YOLOv1一样,对于训练图片中的ground truth,若其中心点落在某个cell内,那么该cell内的3个anchor box负责预测它,具体是哪个anchor box预测它,需要在训练中确定,即由那个与ground truth的IOU最大的anchor box预测它,而剩余的2个anchor box不与该ground truth匹配。YOLOv3需要假定每个cell至多含有一个grounth truth,而在实际上基本不会出现多于1个的情况。与ground truth匹配的anchor box计算坐标误差、置信度误差(此时target为1)以及分类误差,而其它的anchor box只计算置信度误差(此时target为0)。
def compute_loss(p, targets, model): # predictions, targets, model
ft = torch.cuda.FloatTensor if p[0].is_cuda else torch.Tensor
lxy, lwh, lcls, lconf = ft([0]), ft([0]), ft([0]), ft([0])
txy, twh, tcls, indices = build_targets(model, targets)#在13 26 52维度中找到大于iou阈值最适合的anchor box 作为targets
#txy[维度(0:2),(x,y)] twh[维度(0:2),(w,h)] indices=[0,anchor索引,gi,gj]
# Define criteria
MSE = nn.MSELoss()
CE = nn.CrossEntropyLoss()
BCE = nn.BCEWithLogitsLoss()
# Compute losses
h = model.hyp # hyperparameters
bs = p[0].shape[0] # batch size
k = h['k'] * bs # loss gain
for i, pi0 in enumerate(p): # layer i predictions, i
b, a, gj, gi = indices[i] # image, anchor, gridx, gridy
tconf = torch.zeros_like(pi0[..., 0]) # conf
# Compute losses
if len(b): # number of targets
pi = pi0[b, a, gj, gi] # predictions closest to anchors 找到p中与targets对应的数据lxy
tconf[b, a, gj, gi] = 1 # conf
# pi[..., 2:4] = torch.sigmoid(pi[..., 2:4]) # wh power loss (uncomment)
lxy += (k * h['xy']) * MSE(torch.sigmoid(pi[..., 0:2]), txy[i]) # xy loss
lwh += (k * h['wh']) * MSE(pi[..., 2:4], twh[i]) # wh yolo loss
lcls += (k * h['cls']) * CE(pi[..., 5:], tcls[i]) # class_conf loss
# pos_weight = ft([gp[i] / min(gp) * 4.])
# BCE = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
lconf += (k * h['conf']) * BCE(pi0[..., 4], tconf) # obj_conf loss
loss = lxy + lwh + lconf + lcls
return loss, torch.cat((lxy, lwh, lconf, lcls, loss)).detach()
下面我们具体看看代码。
txy, twh, tcls, indices = build_targets(model, targets),build_targets函数返回了我们需要参数,我们看看这个函数具体再操作什么。
def build_targets(model, targets):
# targets = [image, class, x(归一后的中心), y, w(归一后的宽), h] [ 0.00000, 20.00000, 0.72913, 0.48770, 0.13595, 0.08381]
iou_thres = model.hyp['iou_t'] # hyperparameter
if type(model) in (nn.parallel.DataParallel, nn.parallel.DistributedDataParallel):
model = model.module
nt = len(targets)
txy, twh, tcls, indices = [], [], [], []
for i in model.yolo_layers:
layer = model.module_list[i][0]
#layer->YOLOLayer()
# iou of targets-anchors
t, a = targets, []
gwh = targets[:, 4:6] * layer.ng #layer.ng就是yolo层输出维度13 26 52,gwh将原来的wh还原到13*13的图上
if nt:
iou = [wh_iou(x, gwh) for x in layer.anchor_vec] #anchor_vec是anchor box的wh
iou, a = torch.stack(iou, 0).max(0) # best iou and anchor找到每一层与label->wh,iou最大的anchor,a是anchor的索引
# reject below threshold ious (OPTIONAL, increases P, lowers R)
reject = True
if reject:
j = iou > iou_thres
t, a, gwh = targets[j], a[j], gwh[j]
# Indices targets = [image, class, x(归一后的中心), y, w(归一后的宽), h]
b, c = t[:, :2].long().t() # target image, class
gxy = t[:, 2:4] * layer.ng
gi, gj = gxy.long().t() # grid_i, grid_j
indices.append((b, a, gj, gi))
# XY coordinates
txy.append(gxy - gxy.floor())#在yolov3里是Gx,Gy减去grid cell左上角坐标Cx,Cy
# Width and height
twh.append(torch.log(gwh / layer.anchor_vec[a])) # wh yolo method
# twh.append((gwh / layer.anchor_vec[a]) ** (1 / 3) / 2) # wh power method
# Class
tcls.append(c)
if c.shape[0]:
assert c.max() <= layer.nc, 'Target classes exceed model classes'
return txy, twh, tcls, indices
build_targets(model, targets)需要两个参数,model是模型,targets是从labels读取resize后的标签参数,具体的targets = [image, class, x(归一后的中心), y, w(归一后的宽), h],iou_thres是我们需要设置的超参数,作用我们后面会讲。
for i in model.yolo_layers:我们知道有3个yolo层,i是层数的索引,targets[:, 4:6]是ground truth的宽和高,所以gwh将原来的wh还原到13*13的特征图上。
iou = [wh_iou(x, gwh) for x in layer.anchor_vec],anchor_vec是anchor box的wh,我们求出每一层三个anchor box与ground truth的iou,然后找到iou最大的anchor box,用a来记录它的索引,删去小于iou_thres的anchor。用b记录imges,c记录calss类别,gxy记录对应feature map的中心点,gi,gj记录哪个格子负责这个ground truth。
txy=gxy - gxy.floor(),在yolov3里是Gx,Gy减去grid cell左上角坐标Cx,Cy得到txy。
twh=torch.log(gwh / layer.anchor_vec[a])对应上面公式反推过来的。txy,twh是我们loss的标签,它是真正坐标的offset,所以我们bp优化得到的ixy还需要做相应的变换才能得到真正的box。
tcls=c,c就是calss类别。这样我们就理解build_targets返回的标签参数意义了。
for i, pi0 in enumerate(p): # layer i predictions, i
b, a, gj, gi = indices[i] # image, anchor, gridx, gridy
tconf = torch.zeros_like(pi0[..., 0]) # conf
# Compute losses
if len(b): # number of targets
pi = pi0[b, a, gj, gi] # predictions closest to anchors 找到p中与targets对应的数据lxy
tconf[b, a, gj, gi] = 1 # conf
# pi[..., 2:4] = torch.sigmoid(pi[..., 2:4]) # wh power loss (uncomment)
lxy += (k * h['xy']) * MSE(torch.sigmoid(pi[..., 0:2]), txy[i]) # xy loss
lwh += (k * h['wh']) * MSE(pi[..., 2:4], twh[i]) # wh yolo loss
lcls += (k * h['cls']) * CE(pi[..., 5:], tcls[i]) # class_conf loss
# pos_weight = ft([gp[i] / min(gp) * 4.])
# BCE = nn.BCEWithLogitsLoss(pos_weight=pos_weight)
lconf += (k * h['conf']) * BCE(pi0[..., 4], tconf) # obj_conf loss
loss = lxy + lwh + lconf + lcls
for i, pi0 in enumerate( p ):p是我们yolo层返回的特征图,i=0时,p=[[bs,3,13,13,85],[bs,3,26,26,85],[bs,3,52,52,85]].
b, a, gj, gi = indices[i],从返回的标签中读取最佳anchor,与格子坐标,因为我们知道每个ground truth只对应一个anchor box,这些索引就是为了找到那个anchor box。
pi = pi0[b, a, gj, gi],pi0=(b,3,13,13,85)找到pi0中对应ground truth的数据。
tconf[b, a, gj, gi] = 1 ,将对应位置的confience设置为1,其余都为0。
lxy += (k * h[‘xy’]) * MSE(torch.sigmoid(pi[…, 0:2]), txy[i]),与ground truth匹配的anchor box计算坐标误差、置信度误差(此时target为1)以及分类误差,而其它的anchor box只计算置信度误差(此时target为0)。这里pi只记录了与ground truth匹配的anchor box的信息。sigmoid是将xy限制在0-1,因为中心坐标落在格子内。MSE就是均方差。
lwh += (k * h[‘wh’]) * MSE(pi[…, 2:4], twh[i]) 如上。
lcls += (k * h[‘cls’]) * CE(pi[…, 5:], tcls[i]):只计算与ground truth匹配的anchor box的分类误差。CE是交叉熵。
lconf += (k * h[‘conf’]) * BCE(pi0[…, 4], tconf)。tconf把对应ground truth的置信度设置为1,没有与ground truth对应都设置为0.
这样我们就求得了loss,有些地方可能描述不太好,当然也有理解不到位的,欢迎讨论。