1 引言

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

上一篇文章中掌柜说到,对于BERT技术实现这部分内容将会分为三个大的部分来进行介绍。第一部分主要介绍BERT的网络结构原理以及MLM和NSP这两种任务的具体原理;第二部分将主要介绍如何实现BERT以及BERT预训练模型在下游任务中的使用;第三部分则是介绍如何利用MLM和NSP这两个任务来训练BERT模型(可以是从头开始,也可以是基于开源的BERT预训练模型开始)。第一部分内容在上一篇文章中已经介绍完了,在本篇文章中掌柜将开始详细来介绍第二部分的内容。

以下所有完整实现代码均可从仓库 https://github.com/moon-hotel/BertWithPretrained 中获取!

2 BERT实现

2.1 BERT网络结构回顾

经过上一篇文章[1]的介绍相信大家对于BERT模型的整体结构已经有了一定的了解。如图1所示,本质上来说BERT就是由多个不同的Transformer结构堆叠而来,同时在Embedding部分多加入了一个Segment Embedding。

图 1. BERT网络结构图

进一步,如果将图1所示的网络结构展开,将会得到如图2所示的样子。在接下来的代码实现过程中,掌柜将会以图2中黑色加粗字体所示的部分为一个类进行实现。

图 2. BERT网络模型细节图

2.2 Input Embedding实现

首先,我们先来看看Input Embedding的实现过程。为了复用之前在介绍Transformer实现时所用到的这部分代码,我们直接在这基础上再加一个Segment Embedding即可。

2.2.1 Token Embedding

Token Embedding算是NLP中将文本表示为向量的一个基本操作,其原理就不再赘述,具体实现如下:

在上述代码中,第4行中的padding_idx是用来指定序列中用于padding处理的索引编号,一般来说默认都是0。在指定padding_idx后,如果输入序列中有0,那么对应位置的向量就会全是0。当然,这一步掌柜认为不做也可以,因为在计算自主力权重的时候会通过padding_mask向量来去掉这部分内容,具体可参见[2]。第5行代码便是用给定的方式来初始化参数,当然这几乎不会用到。因为不管是在下游任务中微调,还是继续通过NSL和MLM这两个任务来训练模型参数,我们大多数情况下都会再开源的BERT模型参数上进行,而不是从头再来。

2.2.2 Positional Embedding

对于Positional Embedding来说,其作用便是用来解决自注意力机制不能捕捉到文本序列内部各个位置之间顺序的问题。关于这部分内容原理的介绍,可以参见文章[3]。不同于Transformer中Positional Embedding的实现方式,在BERT中Positional Embedding并没有采用固定的变换公式来计算每个位置上的值,而是采用了类似普通Embedding的方式来为每个位置生成一个向量,然后随着模型一起训练。因此,这一操作就限制了在使用预训练的中文BERT模型时,最大的序列长度只能是512,因为在训练时只初始化了512个位置向量。具体地,其实现代码如下:

从上述代码可以看出,其本质上就是一个普通的Embedding层,只是在这一场景下作者赋予了它另外的含义,即序列中的每一个位置有自己独属的向量表示。同时, 在默认配置中,第16行中的max_position_embeddings值为512。

2.2.3 Segment Embedding

Segment Embedding的原理及目的掌柜在上一篇文章中[1]已经详细介绍过,总结起来就是为了满足下游任务中存在需要两句话同时输入到模型中的场景,即可以看成是对输入的两个序列分别赋予一个位置向量用以区分各自所在的位置。这一点可以和上面的Positional Embedding进行类比。具体地,其实现代码如下:

在上述代码中,type_vocab_size的默认值为2,即只用于区分两个序列的不同位置。

2.2.4 Bert Embeddings

在完成Token、Positional、Segment Embedding这3个部分的代码之后,只需要将每个部分的结果相加即可得到最终的Input Embedding作为模型的输入,如图3所示。

 

图 3. BERT输入图

具体地,其代码实现为:

在上述代码中,config是传入的一个配置类,里面各个类成员就是BERT中对应的模型参数。第12、19、25行代码便是用来分别定义图3中的3部分Embedding。第33行代码是用来生成一个默认的位置id,即[0,1,....,512],在后续可以通过self.position_ids来进行调用。

进一步,其前向传播过程代码为:

在上述代码中,input_ids表示输入序列的原始token id,即根据词表映射后的索引,其形状为[src_len, batch_size]position_ids是位置序列,本质就是[0,1,2,3,...,src_len-1],其形状为[1,src_len]token_type_ids用于不同序列之间的分割,例如[0,0,0,0,1,1,1,1]用于区分前后不同的两个句子,形状为[src_len,batch_size]

同时,第9-10代码表示当模型输入的position_ids为空时,需要根据输入序列的长度来生成一个位置序列(其实这部分输入仅作为内部实现即可,因为它只是[0,1,..,src_len-1]的一串数字。同理,第14行代码表示当模型输入仅包含一个序列(如文本分类)且token_type_ids为空时,那么可以通过15-16行代码来生成一个全0向量。第20-23行代码则是用来将三部分Embeeding的结果相加。

2.3 BertAttention实现

在实现完Input Embedding部分的代码后,下面就可以着手来实现BertEncoder了。如图4所示,整个BertEncoder由多个BertLayer堆叠形成;而BertLayer又是由BertOutput、BertIntermediate和BertAttention这3个部分组成;同时BertAttention是由BertSelfAttention和BertSelfOutput所构成。

图 4. BertEncoder实现结构图

接下来,我们就以图4中从下到上的顺序来依次对每个部分进行实现。

2.4 BertAttention实现

对于BertAttention来说,需要明白的是其核心就是在Transformer中所提出来的self-attention机制,也就是图4中的BertSelfAttention模块;其次再是一个残差连接和标准化操作。对于BertSelfAttention的实现,其代码如下

如上所示所示便是BertSelfAttention的实现代码,其对应的就是GoogleResearch[4]代码中的attention_layer方法。正如前面所说,BertSelfAttention本质上就是Transformer模型中的self-attention模块,具体原理可参见文章[3],这里就不再赘述。

对于BertSelfOutput的实现,其主要就是层Dropout、标准化和残差连接三个操作,代码如下:

接下来就是对BertAttention部分的实现,其由BertSelfAttentionBertSelfOutput这两个类构成,代码如下:

在上述代码中,第8行的hidden_states就是Input Embedding处理后的结果;第9行的attention_mask就是同一个batch中不同长度序列的padding信息,具体可以参加文章[2];第15行就是自注意力机制的输出结果;第21行便是执行BertSelfOutput中的3个操作。

2.5 BertLayer实现

根据图4可知,BertLayer里面还有BertOutputBertIntermediate这两个模块,因此下面先来实现这两个部分。

对于BertIntermediate来说也就是一个普通的全连接层,因此实现起来也非常简单,代码如下:

在上述代码中,第6行用来根据指定参数获取激活函数。

进一步,对于BertOutput来说,其包含有其包含有一个全连接层和残差连接,实现代码如下:

在上述代码中,第8行里hidden_states指的就是BertIntermediate模块的输出,而input_tensor则是BertAttention部分的输出。

在实现完这两个部分的代码后,便可以通过BertAttentionBertIntermediateBertOutput这3个部分来实现组合的BertLayer部分,代码如下:

从上述代码中可以发现,对于BertLayer的实现来说其整体逻辑也并不太复杂,就是根据BertAttentionBertOutputBertIntermediate这三部分构造而来;同时每个部分输出后的维度掌柜也都进行了标注以便大家进行理解。

到此,对于BertLayer部分的实现就介绍完了,下面继续来看如何实现BERT。

2.6 BERT模型实现

根据图2所示可知,BERT主要由Input EmbeddingBertEncoder这两部分构成;而BertEncoder是有多个BertLayer堆叠所形成,因此需要先实现BertEncoder,代码如下:

在上述代码中,第5行便是用来定义多个BertLayer;第18-22行用来循环计算多层BertLayer堆叠后的输出结果。最后,只需要按需将BertEncoder部分的输出结果输入到下游任务即可。

进一步,在将BertEncoder部分的输出结果输入到下游任务前,需要将其进行略微的处理,代码如下:

在上述代码中,第13-14行代码用来取BertEncoder输出的第一个位置([cls]位置),例如在进行文本分类时可以取该位置上的结果进行下一步的分类处理;第15-16行是掌柜自己加入的一个选项,表示取所有位置的平均值,当然我们也可以根据自己的需要在添加下面添加其它的方式;最后,17-19行就是一个普通的全连接层。

紧接着,基于上述所有实现便可以搭建完成整个BERT的主体结构,代码如下:

如上代码所示便是整个BERT部分的实现,可以发现在厘清了整个思路后这部分代码理解起来就相对容易了。第22-24行便是Embedding后的输出结果;第25-26行是整个BERT编码部分的输出;第27-28行便是处理得到整个BERT网络的输出。到此,对于整个BERT主体部分的代码实现就介绍完了。

以上代码的实现均参考自[4] [5] [6],大家有兴趣也可以自行阅读研究。

4 总结

在本篇文章中,掌柜首先和大家一起回顾了BERT的整个网络结构;然后一步一步从Input Embedding、BertAttention、BertLayer再到BertEncoder来详细介绍了整个BERT模型的实现。需要提醒各位读者朋友的是,在阅读本文的过程中最好是结合着每个部分的输出结果(包括形状和意义)来进行理解。在下一篇文章中,掌柜将会介绍如何在现有代码的基础上,实现一个基于BERT的文本分类模型,并同时用开源的预训练参数来对模型进行初始化。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,请添加掌柜微信nulls8或加群进行交流。青山不改,绿水长流,我们月来客栈见

引用

[1]BERT原理与NSL和MLM

[2]This post is all you need(③网络结构与自注意力实现)

[3]This post is all you need(②位置编码与编码解码过程)

[4]Google Research https://github.com/google-research/bert

[5]BERT https://huggingface.co/transformers/model_doc/bert.html#bertmodel

[6] https://github.com/codertimo/BERT-pytorch