各位朋友大家好,欢迎来到月来客栈,我是掌柜空字符。

想要继续训练BERT的话首先需要弄懂NSP和MLM这两个预训练任务的原理,然后再根据这两个任务来通过自己的语料从头训练一个BERT模型或者是以开源的模型参数为基础进行追加训练。下面,掌柜就分别从MSL和NSP任务的数据构造、模型实现和网络训练等方面来一一进行介绍。

1 引言

经过前面六篇文章的介绍,我们已经清楚了BERT的基本原理[1]、如何从零实现BERT[2]、如何基于BERT预训练模型来完成文本分类任务[3]、文本蕴含任务[4]、问答选择任务(SWAG)[5]以及问题回答任务(SQuAD)[6],算是完成了BERT模型第一部分内容(如何在下游任务中运用预训练BERT)的介绍。在接下来的这篇文章中,掌柜将开始就BERT模型的第二部分内容,即如何利用Mask LM和NSP这两个任务来训练BERT模型进行介绍。通常,你既可以通过MLM和NSP任务来从头训练一个BERT模型,当然也可以在开源预训练模型的基础上再次通过MLM和NSP任务来在特定语料中进行追加训练,以使得模型参数更加符合这一场景。

在文章BERT的基本原理[1]中,掌柜已经就MLM和NSP两个任务的原理做了详细的介绍,所以这里就不再赘述。一句话概括,如图1所示MLM就是随机掩盖掉部分Token让模型来预测,而NSP则是同时输入模型两句话让模型判断后一句话是否真的为前一句话的下一句话,最终通过这两个任务来训练BERT中的权重参数。

图 1. MLM和NSP任务网络结构图

 

2 数据预处理

在正式介绍数据预处理之前,我们还是依照老规矩先通过一张图来大致了解一下整个处理流程,以便做到心中有数不会迷路。

图 2. MLM和NSP任务数据集构造流程图

 

如图2所示便是整个NSP和MLM任务数据集的构建流程。第①②步是根据原始语料来构造NSP任务所需要的输入和标签;第③步则是随机MASK掉部分Token来构造MLM任务的输入,并同时进行padding处理;第④步则是根据第③步处理后的结果来构造MLM任务的标签值,其中[P]表示Padding的含义,这样做的目的是为了忽略那些不需要进行预测的Token在计算损失时的损失值。在大致清楚了整个数据集的构建流程后,我们下面就可以一步一步地来完成数据集的构建了。

同时,为了能够使得整个数据预处理代码具有通用性,同时支持构造不同场景语料下的训练数据集,因此我们需要为每一类不同的数据源定义一个格式化函数来完成标准化的输入。这样即使是换了不同的语料只需要重写一个针对该数据集的格式化函数即可,其余部分的代码都不需要进行改动。

2.1 英文维基百科数据格式化

这里首先以英文维基百科数据wiki2 [7]为例来介绍如何得到格式化后的标准数据。如下所示便是wiki2中的原始文本数据:

在上述示例数据中,每一行都表示一个段落,其由一句话或多句话组成。下面我们需要定义一个函数来对其进行预处理:

在上述代码中,第2-3行用于一次读取所有原始数据,每一行为一个段落;第5-7行用于遍历每一个段落,并进行相应的处理;第6行用于过滤掉段落中只有一个句子的情况,因为后续我们要构造NSP任务所需的数据集所以只有一句话的段落需要去掉;第7行用于将所有字母转换为小写并同时将每句话给分割开;第8行则是将所有的段落给打乱,注意不是句子。

最终,经过read_wiki2函数处理后,我们便能得到一个标准的二维列表,格式形如:

例如上述语料处理后的结果为:

这种格式就是后续代码处理所接受的标准格式。

2.2 中文宋词数据格式化

在介绍完英文数据集的格式化过程后我们再来看一个中文原始数据的格式化过程。如下所示便是我们后续所需要用到的中文宋词数据集:

在上述示例中,每一行表示一首词,句与句之间通过句号进行分割。下面我们同样需要定义一个函数来对其进行预处理并返回指定的标准格式:

在上述代码中,第2-3行用于一次读取所有原始数据,每一行为一首词(段落);第5-9行用于遍历每一个段落,并进行相应的处理;第6-7行用于过滤掉字符乱码的情况;第8-9行用于过滤掉段落中只有一个句子的情况;第10行则是将所有的段落给打乱,注意不是句子。

例如上述语料处理后的结果为:

可以看到, 预处理完成后的结果同上面wiki2数据预处理完后的格式一样。

2.3 构造NSP任务数据

在正式构造NSP任务数据之前,我们需要先定义一个类并对定义相关的类成员变量以方便在其它成员方法中使用,代码如下:

由于后续会有一系列的随机操作,所以上面代码第31行加入了随机状态用于固定随机结果。

紧接着,我们需要定义一个成员函数来封装格式化原始数据集的函数,代码如下:

从上述代码可以看出,该函数的作用就是给出了一个标准化的格式化函数调用方式,可以根据指定的数据集名称返回相应的格式化函数。但是需要注意的是,格式化函数返回的格式需要同read_wiki2()函数返回的样式保持一致。

进一步,我们便可以来定义构造NSP任务数据的处理函数,用来根据给定的连续两句话和对应的段落返回NSP任务中的句子对和标签,具体代码如下:

在上述代码中,第3行用于根据均匀分布产生[0,1)之间的一个随机数作为概率值;第6行则是先从所有段落中随机出一个段落,再从随机出的一个段落中随机出一句话,以此来随机选择下一句话;第8行则是返回构造好的一条NSP任务样本。最后,由于该方法只是功能性的函数没有引用到类中的其它成员,所以通过第1行代码将其申明为了静态方法。

到此,对于NSP任务样本的构造就介绍完了,后续我们只需要调用get_next_sentence_sample()函数即可。

2.4 构造MLM任务数据

为了方便后续构造MLM任务中的数据样本,我们这里需要先定义一个辅助函数,其作用是根据给定的token_ids、候选mask位置以及需要mask的数量来返回被mask后的token_ids和标签label信息,代码如下:

在上述代码中,第1行里token_ids表示经过get_next_sentence_sample()函数处理后的上下句,且已经转换为ids后的结果,candidate_pred_positions表示所有可能被maks掉的候选位置,num_mlm_preds表示根据15%的比例计算出来的需要被mask掉的位置数量;第4-6行为依次遍历每一个候选Token的索引,如果已满足需要被mask的数量则跳出循环;第8-9行则表示将其中$80\%$的Token替换为[MASK](注意,这里其实就是15%里面的$80\%$);第10-14行则是分别保持$10\%$的Token不变以及将另外$10\%$替换为随机Token;第15-16则是对Token进行替换,以及记录下哪些位置上的Token进行了替换;第17-18行则是根据已记录的Token替换信息得到对应的标签信息,其做法便是如果该位置没出现在pred_positions中则表示该位置不是需要被预测的对象,因此在进行损失计算时需要忽略掉这些位置(即为PAD_IDX);而如果其出现在mask的位置,则其标签为原始token_ids对应的id,即正确标签。

例如以下输入:

经过函数replace_masked_tokens()处理后的结果则类似为:

在这之后,我们便可以定义一个函数来构造MLM任务所需要用到的训练数据,代码如下:

在上述代码中,第1行token_ids便是传入的模型输入序列的Token ID(一个样本);第3-6行是用来记录所有可能进行掩盖的Token的索引,并同时排除掉特殊Token;第7行则是将所有候选位置打乱,更利于后续随机抽取;第8行则是用来计算需要进行掩盖的Token的数量,例如原始论文中是15%;第10-11行便是上面介绍到的replace_masked_tokens()功能函数;第12行则是返回最终MLM任务和NSP任务的输入mlm_input_tokens_id和MLM任务的标签mlm_label

2.5 构造整体任务数据

在分别介绍完MLM和NSP两个任务各自的样本构造方法后,下面我们再通过一个方法将两者组合起来便得到了最终整个样本数据的构建,代码如下:

在上述代码中,第1行中的@cache修饰器用于保存或直接载入已预处理完成后的结果,具体原理可以参见文章如何用@修饰器来缓存数据与处理结果?;第4行中的max_len用来记录整个数据集中最长序列的长度,在后续可将其作为padding长度的标准;从第6-7行开始,便是依次遍历每个段落以及段落中的每个句子来构造MLM和NSP任务样本;第8-9行用于构建NSP任务数据样本;第13-18行则是将得到的Token序列转换为token_ids,其中16-17行用于判断序列长度,对于超出部分进行截取。

紧接着,第20-25行则是分别构造segment embedding输入和NSP任务的真实标签;第26-28行则是分别构造得到MLM任务的输入和正确标签;第34行则是将每个构造完成的样本保存到data这个列表中;第35-36行则是返回最终生成的结果。

例如在处理宋词语料时,上述代码便会输出如下类似结果:

如果在构造数据集时不想输出上述结果,只需要将日志等级设置为log_level=logging.INFO即可。

2.6 构造数据集DataLoader

在整个数据预处理结束后,我们便可以构造得到最终模型训练时所需要的DataLoader了。如下代码所示便是训练集、验证集和测试集三部分DataLoader的构建过程:

在上述代码中,第6-7行是根据传入的相关参数来构建一个数据预处理结果的缓存名称,因为不同的参数会处理得到不同的结果,最终缓存后的数据预处理结果将会类似如下所示:

这样在每次载入数据集时如果已经有相应的预处理缓存则直接载入即可。

第8-13行便是用来构造测试集所对应的DataLoader;第14-20行则用于构建训练集所对应的DataLoader,其中如果self.max_sen_lensame,那么在对样本进行padding时会以整个数据中最长样本的长度为标准进行padding,该参数默认情况下为None,即以每个batch中最长的样本为标准进行padding,更多相关内容可以参见文章[3]第2.4节第4步中的介绍;第21-24行则是构造验证集所对应的DataLoader

到此,对于整个BERT模型预训练的数据集就算是构建完成了。

2.7 数据集使用示例

在整个数据集的DataLoader构建完毕后,我们便可以通过如下方式来进行使用:

在上述代码中,第4-10行为wiki2数据集的相关路径,而12-18行则为songci数据集的相关路径,可以根据需要直接进行切换;第20-32行则是其它数据预处理的相关数据。

最后,我们便可通过如下方式来实例化类LoadBertPretrainingDataset并输出相应的结果:

输出结果如下:

3 预训练任务实现

根据文章BERT原理与NSP和MLM可知,BERT的预训练过程包括两个任务:NSP和MLM。同时,为了使得大家能够对这两部分的代码实现有着更加清晰的认识与理解,掌柜将先分别来实现这两个任务,最后再将两者结合到一起来实现BERT的预训练任务。

3.1 NSP任务实现

由于NSP任务实现起来较为简单,所以掌柜这里就直接贴出代码:

上述代码便是整个NSP任务的实现,可以看到其本质上就是一个文本分类任务,仅仅只用取BERT模型最后一层输出的[CLS]做一个分类任务即可。详细介绍可以参见文章基于BERT预训练模型的文本分类任务,这里掌柜就不再赘述了。

3.2 MLM任务实现

相比较于NSP,对于实现MLM任务来说则稍微复杂了一点点。它需要将BERT模型整个最后一层的输出进行一次变换和标准化,然后再做Token级的分类任务来预测被掩盖部分对应的Token值,这个网络结构如图3所示。

图 3. MLM任务网络结构图

 

如图3所示便是构造MLM任务的流程示意。首先取BERT模型最后一层的输出,形状为[src_len,batch_size,hidden_size];然后再经过一次(非)线性变换和标准化,形状同样为[src_len,batch_size,hidden_size];最后再经过一个分类层对每个Token进行分类处理便得到了最后的预测结果,形状为[src_len,batch_size,vocab_size]

此时我们便可以定义类BertForLMTransformHead来完成上述3个步骤:

在上述代码中,第4-8行用来定义相应的(非)线性变换。第8-9行则是用来定义对应的标准化和最后的分类层。第11-12则是用来判断最后分类层中的权重参数是否复用BERT模型Token Embedding中的权重参数,因为MLM任务最后的预测类别就等于Token Embedding中的各个词,所以最后分类层中的权重参数可以复用Token Embedding中的权重参数[11]。

同时,还有一个细节的地方在于,在Token Embedding中词表的形状为[vocab_size,hidden_size],而最后一个分类层权重参数的形状为[hidden_size,vocab_size],因此按道理说这里的权重bert_model_embedding_weights应该要转置后才能复用。不过在第12行代码中我们却直接将其赋值给了最后一层的权重参数,这是为什么呢?

原来在PyTorch的nn.Linear()中,权重参数默认的初始化方式就是

也就是将两个维度进行了交换,所以刚好我们在复用时就不用做其它任何操作了。

紧接着,我们便可以通过如下代码来实现MLM任务:

在上述代码中,第4-7行用于返回得到原始的BERT模型;第9-10行则是取Token Embedding中的权重参数;第12行则是返回得到MLM任务实例化后的类对象;第20-24行为返回得到BERT模型的所有层输出,并只取最后一层;第25行则是完成最后MLM中的分类任务;第27-33行则是根据标签是否为空来返回不同的输出结果。

到此,对于MLM任务的实现掌柜就介绍完了。

3.3 整体实现

经过上面两节内容的介绍,此时再来整体实现NSP和MLM任务那就变得十分容易了。整体实现代码如下所示:

在上述代码中,第12-13行分别用来返回得到实例化后的MLM和NSP任务模型;第22-26行是返回BERT模型的所有输出;第27-32行是分别取BERT模型输出的不同部分来分别进行后续的MLM和NSP任务;第33-43行是根据是否有标签输入来返回不同的输出结果,同时需要注意的是第39行返回的是NSP+MLM两个任务的损失和作为整体模型的损失值;第40-42行是根据条件返回模型不同的结果。

到此对于NSP和MLM任务模型的实现部分就介绍完了。不过掌柜在这里同样要提醒大家的是,在逐行阅读代码的时候最好是将各个变量的维度一起带进去,弄清楚每一步计算后各个变量维度的变化,这样才能更好的理解整个模型。

4 模型训练与微调

在实现完整个模型的前向传播过程后,接下来便可以实现模型训练部分的代码了。

4.1 训练实现

对于整个模型训练部分的代码其实和掌柜在前面几个微调任务中介绍的差不多,只是为了能更加清楚地知道训练模的训练过程型掌柜在这里加入了一些通过Tensorboard可视化的代码。由于这部分代码较长掌柜就分两部分来进行介绍。

在上述代码中,第2行是实例化模型对象;第4-9行是查看本地是否存在相关模型(指对训练过程中保存的模型进行追加训练),这里值得一说的是之所以也要保存last_epoch这个参数是为了同时能够恢复学习率、以及Tensoboard中相关可视化变量在上一次模型保存是时的状态;第11-14行是载入训练模型时所需要用到的数据集;第16-28行是筛选模型中哪些参数需要进行权重衰减(也就是L2正则化),哪些参数不需要进行权重衰减,同时根据筛选条件可知所有biasLayerNorm相关的参数都不需要进行正则化处理;第29行是定义优化器,并且是通过initial_lr来指定的学习率的,因为后续需要用到动态学习率调整的策略;第31-34行是指定动态学习率的调整策略,关于这部分内容的介绍可以参考文章Transformers之自定义学习率动态调整

进一步,实现模型的迭代正反向传播过程,代码如下:

在上述代码中,第5-9行是模型的前向传播过程;第10-13行是反向传播及相关参数的更新过程;第14-16行分别是损失的累计和两个预训练任务准确率的计算;第18-23行是对训练过程中模型的损失、学习率和准确率进行可视化;第26-29行是计算模型在测试集上的准确率,并同时进行可视化处理;第30-34行则是保存在测试集上取得最大准确率时所对应的模型。

到此,对于模型训练部分的内容就介绍完了。同时,模型在训练过程中将会有类似如下所示的输出:

最终,模型的在宋词数据集上损失值和准确率的变化如图4所示。

22042420657

图 4. 模型损失和准确率变化图

 

如图3所示,左边绿色曲线和灰色曲线分别为NSP和MLM这两个任务在训练集上的准确率的变化情况;右边为NSP和MLM两个预训练任务整体损失的变化情况。不过虽然模型看似在训练集上有着不错的训练效果,但是在测试集上却显得不那么尽如人意,如图5所示。

22042420656

图 5. 模型测试集准确率变化图

 

如图5所示便是模型在测试集上的表现情况,其中绿色曲线和灰色曲线分别表示NSP和MLM这两个任务在测试集上的准确率变化情况。从图中可以发现,模型大约在60万次迭代后准确率就没有发生明显地变换了。在调整过多次参数组合后依旧没有得到一个好的效果,掌柜猜测这可能是由于使用的宋词数据集比较特别,不像普通的白话文那样测试集和训练集的句式比较类似。不过后续掌柜也会继续尝试调整模型,并将相应的结果推送到代码仓库中。

4.2 推理实现

在模型训练部分的内容介绍完毕后,下面我们再来看模型推理部分的实现。对于推理部分的实现总体思路为:①将测试样本构造为模型所接受的输入格式;②通过模型前向传播得到预测结果输出;③对模型输出结果进行格式化处理得到最终的预测结果。对于模型预测部分的实现代码如下:

在上述代码中,第3-5行为初始化类LoadBertPretrainingDataset,同时需要说明的是由于是预测场景,所以构造样本时masked_rate可以是任意值;第6-7行则是将传入的测试样本转换为模型所接受的形式,其中第6行masked是用来指定输入的测试样本有没进行mask操作,如果没有则自动按masked_rate的比例进行mask操作,第7行是指定测试样本的语种类型;第9-12行则是载入本地保存好的模型;第15-16行是得到模型前向传播的输出结果;第17行是根据模型的前向传播输出结果来格式化得到最终的输出形式。

最终,可以通过如下方式来完成模型的推理过程,代码如下:

上述代码运行结束后将会看到类似如下所示的结果:

需要注意的是,由于目前模型在测试集上的结果并不理想,所以上述展示的是模型在训练集上的推理结果。

4.3 模型微调

在介绍完整个预训练模型实现过程后,最后一步就是如何将训练得到的模型继续运用在下游任务中。当然,实现这一目的也非常简单,只需要将保存好的模型重新命名为pytorch_model.bin,然后替换掉之前的文件即可。这样就可以想前面介绍的几个下游任务一样对模型进行微调了。

5 总结

在这篇文章中,掌柜首先带着各位客官一起回顾了MLM和NSP任务的基本原理;然后站在全局的角度介绍了整个MLM和NSP任务的数据集构建流程,以便于让各位客官在看后续内容时做到心中有数;接着分别介绍了英文维基百科和中文宋词语料的格式化过程,即如何得到一个统一的标准化格式输出以适应后续的数据集构建;最后详细一步步地介绍了NSP和MLM任务数据集的构建过程,并逐一详细介绍了BERT预训练模型训练部分以及推理部分的代码实现。到此,对于整个BERT部分内容的介绍就结束了。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎点赞分享!若有任何疑问与建议,请留言进行交流。青山不改,绿水长流,我们月来客栈见

引用

[1]BERT原理与NSL和MLM

[2]BERT模型的分步实现

[3]基于BERT预训练模型的文本分类任务

[4]基于BERT预训练模型的文本蕴含任务

[5]基于BERT预训练模型的SWAG问答任务

[6]基于BERT预训练模型的SQuAD回答任务

[7] wiki2地址 https://s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-v1.zip

[8] https://docs.python.org/3.6/library/random.html?highlight=random#random.random

[9] 动手深度学习,李沐

[10]https://github.com/google-research/bert/

[11] https://github.com/google-research/bert/blob/master/run_pretraining.py

[12]示例代码:https://github.com/moon-hotel/BertWithPretrained

[13] This Post Is All You Need(上卷)——层层剥开Transformer