转战pytorch——实现自己的任务(4)

前言

在前面的三章中,我们分别介绍了pytorch的组件,详细解读了官方的代码示例,了解python的运行过程,并利用最新的自然语言处理模型Albert实现了一次文本分类。但是,如果我们需要构建属于自己的数据处理、模型以及评估该怎么办呢?本文将会在接下来介绍如何从头开始实现一个自己的任务。所有的代码我都会整理到github上。

1. 模型的构建

pytorch的模型构建比较简单,之前也已经介绍过,只需要在初始化层中定义属于自己的模型,但是,pytorch官方提供的模块是较为基础的。我们需要在文件夹里添加一个custom_model文件用于存放我们的模型,这时候,我们就可以发挥我们自己的想象力,搭建属于我们自己的模型了。

一般的模块,例如在第一章中介绍的那些,我们都可以在pytorch自带的模块中获得。但是有很多时候,我们需要更为高级的模块时,pytorch那些模块就显得不够用了。例如,最简单的attention模型,当然,现在都是transformer了,但是并不是所有的任务transformer都胜任,例如在文本分类模型里,lstm也显示出一定的竞争力。

实际上,在构建模型方面,有两个比较好的库,一个是集成化高的fast.ai,在我看来,它比Keras集成度更加高级,因为它将整个学习过程集成在了learner里,learner不仅仅可以进行数据的处理、模型的训练,还可以对模型训练过程中各种情况进行一个打印分析,如果是一个初学者,使用这个是非常容易上手的。

另一个是随着elmo火起来的Allennlp。它虽然没有fast.ai封装的那么高级,但是它除了提供很多常见的模型外,还提供了很多的模块,用以构建我们的模型,比如一个在原装的pytorch里不存在,但是在Kreas里有的,且非常实用的Timedistribute模块,在Allennlp里就有,否则,只能自己手写一个了。

1.1 例子attention的实现

这里我们以一个attention的实现作为例子,看一下pytorch自己实现的层长什么样子,由于已经有一些基础了,我们就不再赘述attention的具体实现,详情可以参见《attention机制》、《attention 可视化》、《bilstm+attention》、《Self-attention》。

2. 修改processor和InputExample

processor的作用就是从文本里读取样本到内存里。它的具体细节如下,可以看到其核心在于_create_examples,这块是我们需要自己写的,我们将一行的文本变成我们的格式化输入样例InputExample,另一个就是使用自带的_read_tsv或者_read_csv文件读取我们想要的文本,这个已经自带了,因此也不用担心。第三个要注意的是InputExample的定义,这里是一个文本对,你也可以做成文本序列或者其他你需要的形式。

class WnliProcessor(DataProcessor):
    """Processor for the WNLI data set (GLUE version)."""

    def get_train_examples(self, data_dir):
        """See base class."""
        return self._create_examples(
            self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")

    def get_dev_examples(self, data_dir):
        """See base class."""
        return self._create_examples(
            self._read_tsv(os.path.join(data_dir, "dev.tsv")), "dev")

    def get_labels(self):
        """See base class."""
        return ["0", "1"]

    def _create_examples(self, lines, set_type):
        """Creates examples for the training and dev sets."""
        examples = []
        for (i, line) in enumerate(lines):
            if i == 0:
                continue
            guid = "%s-%s" % (set_type, line[0])
            text_a = line[1]
            text_b = line[2]
            label = line[-1]
            examples.append(
                InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
        return examples

3. 增加任务索引

增加任务索引则能更好的使用整个框架,只需要把自己的任务定义一个名称,然后规定该任务的任务类型,以及类别标签数目,就可以在后面不用再管你这个任务的特殊性了。这里的任务名称目前仅仅是区分读入数据时处理不同,你也可以应用到其他领域。

4.将样例转换为特征

刚刚已经将文本读取为一个个结构化的样例存储在内存中,接下来就是在这个文本中抽取特征。一些常规操作比如分词等,都可以在此处进行。而现在流行的BERT模型,则是进行Token操作。另一个需要注意的,就是和上面一样,我们还是需要定义一个输入特征类,其实它没有什么其他的用处,只是为了更好的存储输入的特征。

def glue_convert_examples_to_features(examples, tokenizer,
                                      max_seq_length=512,
                                      task=None,
                                      label_list=None,
                                      output_mode=None):
    if task is not None:
        processor = glue_processors[task]()
        if label_list is None:
            label_list = processor.get_labels()
            logger.info("Using label list %s for task %s" % (label_list, task))
        if output_mode is None:
            output_mode = glue_output_modes[task]
            logger.info("Using output mode %s for task %s" % (output_mode, task))

    label_map = {label: i for i, label in enumerate(label_list)}

    features = []
    for (ex_index, example) in enumerate(examples):
        if ex_index % 10000 == 0:
            logger.info("Writing example %d" % (ex_index))

        """
        在这里编写逻辑代码,将文本转换为我们需要的输入特征。这里具体的可以参见原文,由于代码过长,因此我们不进一步的进行展示。
        """

        features.append(
            InputFeatures(input_ids=input_ids,
                          attention_mask=attention_mask,
                          token_type_ids=token_type_ids,
                          label=label_id,
                          input_len=input_len))
    return features

5. 将数据统一存放于Dataset中

做到上述过程,就已经把一个个样例以结构化的形式存储到内存之中了,那么只需要在我们需要的时候,一个个提取出来就行了,而这里为了方便对接Dataset和Data_loader两个类,我们还需要在主函数中的load_and_cache_examples(args, task, tokenizer, data_type='train')方法里进行一定的修改,将我们每个样例的不同的部分先合并为一个统一的整体。打个比方,原来我们对于车辆都是以组成一辆车的材料在一起的方式存储的,但是现在为了流水线操作,我们必须将轮胎先都放在一起,车架都放在一起,等等,然后在组装时,我们才能够更快的组装,这里的load_and_cache_examples(args, task, tokenizer, data_type='train')就起到了分类归总各个配件的操作。

def load_and_cache_examples(args, task, tokenizer, data_type='train'):
    if args.local_rank not in [-1, 0] and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache

    """
    此部分代码省略,主要功能就是读取数据并进行缓存,最后得出各个特征为下一步提供处理的材料。
    """
    if args.local_rank == 0 and not evaluate:
        torch.distributed.barrier()  # Make sure only the first process in distributed training process the dataset, and the others will use the cache
    # Convert to Tensors and build dataset
    all_input_ids = torch.tensor([f.input_ids for f in features], dtype=torch.long)
    all_attention_mask = torch.tensor([f.attention_mask for f in features], dtype=torch.long)
    all_token_type_ids = torch.tensor([f.token_type_ids for f in features], dtype=torch.long)
    all_lens = torch.tensor([f.input_len for f in features], dtype=torch.long)
    if output_mode == "classification":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.long)
    elif output_mode == "regression":
        all_labels = torch.tensor([f.label for f in features], dtype=torch.float)
    dataset = TensorDataset(all_input_ids, all_attention_mask, all_token_type_ids, all_lens, all_labels)
    return dataset

核心就是上述的代码,因为我们之前的样本都还没有进行张量化,在这里将他们一同进行张量化后,才能让Data_loader进行采样抽取出样本。

6. 调整data_loader

在训练过程的一开始,就向我们介绍了采样的过程,首先进行一个随机采样器RandomSampler,此处的步骤就是打乱数据集。然后使用DataLoader进行数据加载,这里需要注意的就是collate_fn,这个函数需要你自己编写,以适应我们模型对于输入的需求。

def train(args, train_dataset, model, tokenizer):
    """ Train the model """
    args.train_batch_size = args.per_gpu_train_batch_size * max(1, args.n_gpu)
    train_sampler = RandomSampler(train_dataset) if args.local_rank == -1 else DistributedSampler(train_dataset)
    train_dataloader = DataLoader(train_dataset, sampler=train_sampler, batch_size=args.train_batch_size,
                                  collate_fn=collate_fn)
"""
后面的代码先不看
"""

从下面的代码中可以看到,这里就是对于每个batach进行一个统一的格式化,保证每个批次的长度一致,并且返回值其实就是我们的模型想要的样子。

def collate_fn(batch):
    """
    batch should be a list of (sequence, target, length) tuples...
    Returns a padded tensor of sequences sorted from longest to shortest,
    """
    all_input_ids, all_attention_mask, all_token_type_ids, all_lens, all_labels = map(torch.stack, zip(*batch))
    max_len = max(all_lens).item()
    all_input_ids = all_input_ids[:, :max_len]
    all_attention_mask = all_attention_mask[:, :max_len]
    all_token_type_ids = all_token_type_ids[:, :max_len]
    return all_input_ids, all_attention_mask, all_token_type_ids, all_labels

7. 调整模型输入

下面是训练过程中最重要的代码,用以将数据作为输入给我们的模型,可以看到其实就是以字典的形式是最合适的,因为这样有我们需要的特征放进去也行,我们不需要的特征也可以放进去,并不影响最后的结果。这里将labels也放进去,为了进行损失的计算。

        for step, batch in enumerate(train_dataloader):
            model.train()
            batch = tuple(t.to(args.device) for t in batch)
            inputs = {'input_ids': batch[0], 'attention_mask': batch[1], 'labels': batch[3], 'token_type_ids': batch[2]}
            outputs = model(**inputs)

8. 训练过程中的loss的各种情况与解析

当我们将以上所有步骤都完成之后,只需要将我们的数据转换为可以读取的文件即可,可以说把中间很多拿不准的过程都交给整个框架了,而框架的可靠性相对比较好,其扩展性也比较强,当你进行一个改动时,你可以只改动其中的某一部分,而不是全体,这样就可以记住很多东西,也减轻了我们对于全局的把握压力。但是,对于一个神经网络训练的过程中,loss会出现的种种情况。针对各种现象,正好看到了一篇非常好的文章《loss不下降原因解析》,详细介绍了各个情况,这里简单列举一下:

  • train loss 不断下降,test loss不断下降,说明网络仍在学习
  • train loss 不断下降,test loss趋于不变,说明网络过拟合
  • train loss 趋于不变,test loss不断下降,说明数据集100%有问题
  • train loss 趋于不变,test loss趋于不变,说明学习遇到瓶颈,需要减小学习率或批量数目
  • train loss 不断上升,test loss不断上升,说明网络结构设计不当,训练超参数设置不当,数据集经过清洗等问题

当然,也存在一种情况,你给了输入的数据,给了输出的标签,但是模型怎么都学不到东西。这时候,应该考虑数据和标签之间是否有关系?例如,一个文章的类型是随便标的2类,电脑基本上是识别不出来了,因此就挑选类别大类进行识别,而且无法改进。

如果有同学知道,loss都在不断的下降,但是预测结果一直不变是什么情况,也可以留言或者私信告诉我。

9.其他的一些pytorch小技巧

9.1 查看张量情况

pytorch也有很多有利的调试的库帮助我们更好的进行编程,一个有用的库就是TorchSnooper,它可以跟踪一个函数内的所有张量的形状并自动打印出来,详情可以参见《pytorch调试利器》,但是需要补充的是,它只能追踪所有的数据变量的信息,对于模型处于哪个设备,情况如何并不清楚,因此,在出现问题时,还需要额外检测一下模型是否也在预定情况下运行。

9.2 查看非张量的list形状

需要查看list的形状时,我们可以使用(np.array(list)).shape将列表转换为np的形式查看其情况,并且使用exit()可以随时退出程序运行。不过在此仍然进行单元测试,而并不是整体测试,因为后者的测试成本过高。(比如,你是在数据加载中出现问题,那这时候,直接运行服务器的话占用成本太大。)

10. 小结

我们这里简单的总结一下,如果我们需要做一个模型,需要修改的部分有哪些。

  1. 搭建自己的模型(custom_model)
  2. 搭建自己的processor(glue)和 InputExample (util)
  3. 增加任务索引(glue)
  4. 搭建自己的convert_examples_to_features(gule) 和InputFeatures(gule)
  5. 构建加载过程(load_and_cache_examples)
  6. 构建自己的data_loader回调函数(glue)
  7. 调整模型的输入(train/evaluate/test)
  8. 创建训练文件

只需要按照我们说的这10个步骤,我们就能够搭建出属于自己的程序,而且,尽管这8个过程相较于一个toy来说,是非常复杂的,但是每完成一步都是扎扎实实的一步,从而有条不紊的构建一个系统级的系统。

发布了232 篇原创文章 · 获赞 547 · 访问量 51万+

猜你喜欢

转载自blog.csdn.net/qq_35082030/article/details/104543191