数据集划分
虽然此次竞赛已经帮我们划分好了训练集和验证集,但这里还是对数据集的划分以及交叉验证进行一些说明。
Baseline搭建好之后,通常会需要将训练数据划分为训练集和验证集, 训练集用于训练模型,验证集用来调整参数。验证集的划分有如下的几种方式。
Hold-out
Hold-out也叫留出法,是最简单的一种划分方式。即随机的将训练数据按照一定比例划分为训练集和验证集from glob import globimport 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 randomrandom.seed(0)from glob import glob import numpy as npdef 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 KFoldkfold = 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)# 优化器使用SGDself.optimizer = SGD(self.model.parameters(), lr=config.lr, momentum=config.momentum, weight_decay=config.weights_decay, nesterov=True)# 学习率调整策略使用Warmup + Cosineself.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 resultsdef 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 resdef 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折交叉往返验证。