天天看点

Pytorch实现yolov3(train)训练代码详解(二)

上篇我们讲解了如何进行数据预处理,读取数据。接下来我们一起分析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。

Pytorch实现yolov3(train)训练代码详解(二)

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,有些地方可能描述不太好,当然也有理解不到位的,欢迎讨论。

继续阅读