DataWhale街景字符编码识别项目模型训练与验证

49次阅读

共计 6490 个字符,预计需要花费 17 分钟才能阅读完成。

数据集划分

虽然此次竞赛已经帮我们划分好了训练集和验证集,但这里还是对数据集的划分以及交叉验证进行一些说明。

Baseline 搭建好之后,通常会需要将训练数据划分为训练集和验证集,训练集用于训练模型,验证集用来调整参数。验证集的划分有如下的几种方式。

  • Hold-out
    Hold-out 也叫留出法,是最简单的一种划分方式。即随机的将训练数据按照一定比例划分为训练集和验证集

    
    from glob import glob
    import random
    # 保证每次划分的结果一致
    random.seed(0)
    img_paths = glob('/content/data/mchar_train/'+'*.png')
    
    random.shuffle(img_paths)
    
    train_cnt = int(len(img_paths) * 0.8)
    train_imgs, val_imgs = img_paths[:train_cnt], img_paths[train_cnt:]
  • K-Fold
    K-Fold 将训练数据划分为 K 份,将其中的 K - 1 份作为训练集,剩下的作为验证集,依次循环 K 次,训练得到 K 个不同的模型,最后模型的精度由 K 个模型的精度取平均得到。

    超参数 K 可以根据数据集的大小来设置,数据集比较大,可以适当的设小一些,甚至可以直接设为 1,此时等同于 Hold-out;数据集比较小,可以适当的设大一些,甚至可以直接设为 1,等同于留一法。

    import random
    random.seed(0)
    from glob import glob 
    import numpy as  np
    def k_fold(data, k=10):
    """
    Params:
        data(list or numpy.ndarray): data need split to trainset and val_set
        
        k(integer): 
    """
        fold = len(data) // k
        fold = [i for i in range(0, len(data), fold)]
        fold[-1] = len(data)
    
        idxes = list(range(len(data)))
        random.shuffle(idxes)
        if isinstance(data, list):
            for f in range(len(fold)-1):
                temp = idxes[fold[0]: fold[f]]
                temp.extend(idxes[fold[f+1]: fold[-1]])
                yield temp, idxes[fold[f]: fold[f+1]]
        elif isinstance(data, np.ndarray):
            for f in range(len(fold)-1):
                yield np.concatenate([idxes[fold[0]: fold[f]], idxes[fold[f+1]: fold[-1]]], 0), \
                    idxes[fold[f]: fold[f+1]]
            

    当然更方便的做法是直接调用 sklearn 中的 kfold 函数

    
    from sklearn.model_selection import KFold
    
    kfold = KFold(3, shuffle=True, random_state=0)
    
    arr = np.random.randint(0, 20, (10, 4))
    # 返回生成器
    res = kfold.split(arr)
  • bootstrap 自助采样
    通过有放回的采样方式得到新的训练集和验证集,每次的训练集和验证集都是有区别的。这种划分方式一般适用于数据量较小的情况。

如下图所示,分别代表了 3 种划分方式。本次项目中,数据量较大,且官方已经帮我们划分好了,个人觉得直接利用划分好的训练集训练,验证集调参就好了,不用交叉验证了,差别应该不大。

模型训练

模型训练专门定义了一个类,叫做 Trainer,上一篇已经展示了它的所有代码,这里只是对它主要的部分进行一些说明。

  • 构建数据集

# 训练集的加载器
self.train_loader = DataLoader(DigitsDataset(data_dir['train_data'], data_dir['train_label']), batch_size=config.batch_size, \
                    num_workers=8, pin_memory=True, drop_last=True)

# 测试集的加载器
self.val_loader = DataLoader(DigitsDataset(data_dir['val_data'], data_dir['val_label'], aug=False), batch_size=config.batch_size,\
                    num_workers=8, pin_memory=True, drop_last=False)
  • 定义优化器和损失函数
# 损失函数使用标签平滑的交叉熵损失函数
self.criterion = LabelSmoothEntropy().to(self.device)

# 优化器使用 SGD
self.optimizer = SGD(self.model.parameters(), lr=config.lr, momentum=config.momentum, weight_decay=config.weights_decay, nesterov=True)

# 学习率调整策略使用 Warmup + Cosine

self.lr_scheduler = CosineAnnealingWarmRestarts(self.optimizer, 10, 2, eta_min=10e-4)
  • 执行训练
    下面是训练一个 epoch 的代码

def train_epoch(self, epoch):
    total_loss = 0
    corrects = 0
    tbar = tqdm(self.train_loader)
    # 每次都需要将模型调回训练模式,非常重要
    self.model.train()
    for i, (img, label) in enumerate(tbar):
      """
      img(tensor): shape [N, C, H, W]
      label(tensor): shape [N, 5], 第一列表示第一个数字
      """
      img = img.to(self.device)
      label = label.to(self.device)
      self.optimizer.zero_grad()
      
      # pred(tensor): shape [N, 11]
      pred = self.model(img)
      
      loss = self.criterion(pred[0], label[:, 0]) + \
          self.criterion(pred[1], label[:, 1]) + \
          self.criterion(pred[2], label[:, 2]) + \
          self.criterion(pred[3], label[:, 3]) + \
          self.criterion(pred[4], label[:, 4])
      total_loss += loss.item()
      loss.backward()
      self.optimizer.step()
      temp = t.stack([\
            pred[0].argmax(1) == label[:, 0], \
            pred[1].argmax(1) == label[:, 1], \
            pred[2].argmax(1) == label[:, 2], \
            pred[3].argmax(1) == label[:, 3], \
            pred[4].argmax(1) == label[:, 4]\
        ], dim=1)
      
      # 只有 5 个数字全部预测正确才算正确
      corrects += t.all(temp, dim=1).sum().item()
      tbar.set_description('loss: %.3f, acc: %.3f'%(loss/(i+1), corrects*100/((i + 1) * config.batch_size)))
      if (i + 1) % config.print_interval == 0:
        self.lr_scheduler.step()
        
  • 模型保存与加载
    Pytorch 中模型默认以字典形式保存为 pth 文件,也可以很方便的保存自己需要的额外参数,比如当前模型的精度、误差、优化器参数以及网络相关配置等等。
def save_model(self, save_path, save_opt=False, save_config=False):
    """
    Params:
        save_path(string): 模型保存路径
        
        save_opt(bool): 是否保存优化器相关参数,可用于之后恢复训练状态
        
        save_config(bool): 是否保存网络配置参数,可用于后期查看网络参数
        
    """
    dicts = {}
    dicts['model'] = self.model.state_dict()
    if save_opt:
        dicts['opt'] = self.optimizer.state_dict()
    
    if save_config:
        dicts['config'] = {s: config.__getattribute__(s) for s in dir(config) if not s.startswith('_')}
    
    t.save(dicts, save_path)

def load_model(self, load_path, save_opt=False, save_config=False):

    dicts = t.load(load_path)

    self.model.load_state_dict(dicts['model'])

    if save_opt:
        self.optimizer.load_state_dict(dicts['opt'])
    # 如果保存了网络配置参数,需要将保存的参数值设置为 config 的相应属性
    if save_config:
        for k, v in dicts['config'].items():
            config.__setattr__(k, v)

模型验证

模型验证过程中,使用 t.no_grad 可以大大节省内存。

def eval(self):

    """
    model.eval(): 它会影响 BatchNorm 以及 Dropout 这样的网络的前向传播过程。t.no_grad():它会停用反向传播过程,从而大大的节省内存。(在模型验证过程中,推荐使用)"""
    self.model.eval()
    corrects = 0
    with t.no_grad():
    tbar = tqdm(self.val_loader)
    for i, (img, label) in enumerate(tbar):
        img = img.to(self.device)
        label = label.to(self.device)
        pred = self.model(img)
    
        temp = t.stack([pred[0].argmax(1) == label[:, 0], \
            pred[1].argmax(1) == label[:, 1], \
            pred[2].argmax(1) == label[:, 2], \
            pred[3].argmax(1) == label[:, 3], \
            pred[4].argmax(1) == label[:, 4]\
        ], dim=1)
    
        corrects += t.all(temp, dim=1).sum().item()
        tbar.set_description('Val Acc: %.2f'%(corrects * 100 /((i+1)*config.batch_size)))
    self.model.train()
    return corrects / (len(self.val_loader) * config.batch_size)

模型预测并生成结果

def predicts(model_path):
    test_loader = DataLoader(DigitsDataset(data_dir['val_data'], None, aug=False), batch_size=config.batch_size, shuffle=False,\
                        num_workers=8, pin_memory=True, drop_last=False)
    results = []
    model = DigitsMobilenet(config.class_num).cuda()
    model.load_state_dict(t.load(model_path)['model'])
    print('Load model from %s successfully'%model_path)
    
    tbar = tqdm(test_loader)
    
    model.eval()
    with t.no_grad():
        for i, (img, img_names) in enumerate(tbar):
        img = img.cuda()
        pred = model(img)
    
        results += [[name, code] for name, code in zip(img_names, parse2class(pred))]
    
    # result.sort(key=results)
    results = sorted(results, key=lambda x: x[0])
    
    write2csv(results)
    return results

def parse2class(prediction):
    """
    将预测的类别解析为字符串
    Params:
        prediction(tuple of tensor):  分别对应
    
    
    """
    ch1, ch2, ch3, ch4, ch5 = prediction
    
    char_list = [str(i) for i in range(10)]
    char_list.append('')
    
    
    ch1, ch2, ch3, ch4, ch5 = ch1.argmax(1), ch2.argmax(1), ch3.argmax(1), ch4.argmax(1), ch5.argmax(1)
    
    ch1, ch2, ch3, ch4, ch5 = [char_list[i.item()] for i in ch1], [char_list[i.item()] for i in ch2], \
                    [char_list[i.item()] for i in ch3], [char_list[i.item()] for i in ch4], \
                    [char_list[i.item()] for i in ch5]
    
    res = [c1+c2+c3+c4+c5 for c1, c2, c3, c4, c5 in zip(ch1, ch2, ch3, ch4, ch5)]             
    return res

def write2csv(results):
    """
    将结果写入 csv 文件
    
    Params:
        results(list):
    
    """df = pd.DataFrame(results, columns=['file_name','file_code'])
    
    df.file_name = df.file_name.apply(lambda x: x.split('/')[-1])
    
    save_name = '/content/drive/My Drive/Data/Datawhale-DigitsRecognition/results.csv'
    df.to_csv(save_name, sep=',', index=None)
    print('Results.saved to %s'%save_name)

总结

  • 在全局池化层后加上一个 bn 层可以在一定程度上防止过拟合。验证集的上的精度更接近训练集的精度。
  • 使用 Label Smooth 后,模型可以更快收敛。
  • 使用更大尺寸的输出(从 64 * 128 变为 128 * 256)后,网络的精度有明显的提升。原因可能是因为我们使用的从 ImageNet 预训练的模型,预训练模型是使用 224 * 224 大小作为输入的,因此可以结合数据集的特点,尽可能调整输入大小,使其接近预训练模型的输入,这样通常会有更好的效果。

模型训练的后期,训练集上的精度已经接近 100%,但验证集上的精度几乎不再上升,基本上处于 70% 左右的水平。还是存在一定的过拟合效应。这种过拟合效应也有可能是由于测试集和训练集之间的分布不一致所导致,因此下一步可以尝试重新划分训练集和测试集训练。有条件的可以进行 K 折交叉往返验证。

正文完
 0