查看原文
其他

8万字+60幅图一网打尽BERT模型

空字符 月来客栈
2024-09-09

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

近8万字、60余幅示例图、5个下游微调场景、从零实现NSP和MLM预训练任务,带你步步走进BERT。本文为精简版,不包含各个下游任务的代码实现介绍,完整版可公众号后台回复BERT获取最新版PDF下载链接,所有代码可从此处获取:https://github.com/moon-hotel/BertWithPretrained

转发本文至朋友圈,公众号回复“送书”,本周三晚抽送三本《动手学深度学习》。

以下为全文目录

  • 1BERT原理与预训练任务
    • 1.3.1 BERT网络结构
    • 1.3.2 Input Embedding
    • 1.3.3 BertEncoder
    • 1.3.4 MLM与NSP
    • 1.2.1 面临问题
    • 1.2.2 解决思路
    • 1.1  引言
    • 1.2 动机
    • 1.3 技术实现
  • 2 BERT 实现
    • 2.2.1 Token Embedding
    • 2.2.2 Positional Embedding
    • 2.2.3 Segment Embedding
    • 2.2.4 Bert Embeddings
    • 2.1 BERT网络结构回顾
    • 2.2 Input Embedding实现
    • 2.3 BertEncoder实现
    • 2.4 BertAttention实现
    • 2.5 BertLayer实现
    • 2.6 BERT模型实现
  • 3 基于BERT预训练模型的文本分类任务
    • 3.1 模型原理
    • 3.2 数据预处理
  • 4 基于BERT预训练模型的文本蕴含任务
    • 4.2.1 输入介绍
    • 4.2.2 语料介绍
    • 4.2.3 数据集预览
    • 4.1 模型原理
    • 4.2 数据预处理
  • 5 基于BERT预训练模型的SWAG问答任务
    • 5.2.1 构建原理
    • 5.2.2 语料介绍
    • 5.2.3 数据集预览
    • 5.1 模型原理
    • 5.2 模型构建
  • 6 基于BERT预训练模型的SQuAD问答任务
    • 6.3.1 语料介绍
    • 6.3.2 数据集预览
    • 6.2.1 构建原理
    • 6.2.2 输入重构
    • 6.1 模型原理
    • 6.2 模型构建
    • 6.3 数据集构建
  • 7 基于BERT预训练模型的命名体识别任务
    • 7.2.1 构建原理
    • 7.2.2 语料介绍
    • 7.2.3 数据集预览
    • 7.1 模型原理
    • 7.2 模型构建
  • 8 基于BERT预训练模型的NSP和MLM任务
    • 8.4.1 训练实现
    • 8.4.2 推理实现
    • 8.4.3 模型微调
    • 8.3.1 NSP任务实现
    • 8.3.2 MLM任务实现
    • 8.3.3 整体实现
    • 8.2.1 英文维基百科数据格式化
    • 8.2.2 中文宋词数据格式化
    • 8.2.3 构造NSP任务数据
    • 8.2.4 构造MLM任务数据
    • 8.2.5 构造整体任务数据
    • 8.2.6 构造数据集DataLoader
    • 8.2.7 数据集使用示例
    • 8.1 模型原理
    • 8.2 数据预处理
    • 8.3 预训练任务实现
    • 8.4 模型训练与微调
  • 9 内容预览
  • 10 引用

1 BERT原理与预训练任务

1.1  引言

经过前面一系列文章[4]的介绍我们总算是对于Transformer有了清晰的认知。不过说起Transformer模型,其实在它发表之初并没有引起太大的反响,直到它的后继者BERT[1]的出现才使得大家再次回过头来仔细研究Transformer。

4万字=50幅图一网打尽Transformer(点击跳转)

因此,在接下来这个系列的文章中,掌柜将主要从BERT的基本原理、BERT模型的实现、BERT预训练模型在下游任务中的运用、Mask LM和NSP的实现与运用这几个方面来详细介绍Bert。总的来说就是先介绍如何在下游任务中运用预训练的BERT模型,然后再介绍如何利用Mask LM和NSP这两个任务来训练BERT模型。

关于Bert所取得的成就这里就不再细说,用论文里面作者的描述来说就是:BERT不仅在概念上很简单,并且从经验上来看它也非常强大,以至于直接刷新了11项NLP记录。

BERT is conceptually simple and empirically powerful. It obtains new state-of-the-art re- sults on eleven natural language processing tasks.

作者之所以说BERT在概念上很简单,掌柜猜测这是因为BERT本质上是根据Transformer中Encoder堆叠而来所以说它简单;而作者说BERT从经验上来看非常强大,是因为BERT在训练过程中所基于的Mask LM和NSP这两个任务所以说其强大。

由于BERT是基于多层的Transformer堆叠而来,因此在整篇论文中关于BERT网络结构的细节之处作者并没有提及。同时,由于论文配了一张极具迷惑性的网络结构图,使得在不看源码的基础上你几乎很难弄清整个网络结构的细节之处。

BERT’s model architec- ture is a multi-layer bidirectional Transformer en- coder based on the original implementation de- scribed in Vaswani et al. (2017) and released in the tensor2tensor library.

图 1-1. BERT网络结构图

如图1-1所示就是论文中所展示的BERT网络结构图。看完论文后真的不知道作者为什么要画这么一个结构图,难道就是为了凸显“bidirectional ”?一眼看去,对于同一层的Trm来说它到底代表什么?是类似于time step的展开,还是每个Trm都有着不同的权重?这些你都不清楚,当然论文也没有介绍。不过在看完这部分的源码实现后你就会发现,其实真正的BERT网络结构大体上应该是如图1-2所示的模样。

图 1-2. BERT网络结构图

虽然从图1-2来看BERT的网络结构确实是不太复杂,但是其中的Transformer模块相较于原始Transformer[2]中的实现依旧有着不小的差别,不过这并不影响从整体上来认识BERT。

希望通过这一系列的篇文章能够让大家对BERT的原理与运用有一个比较清楚的认识与理解。下面,就让我们正式走进对于这篇论文的解读中来。公众号后台回复“论文”即可获得下载链接!

1.2 动机

1.2.1 面临问题

在论文的介绍部分作者谈到,预训练语言模型(Language model pre-training)对于下游很多自然语言处理任务都有着显著的改善。但是作者继续说到,现有预训练模型的网络结构限制了模型本身的表达能力,其中最主要的限制就是没有采用双向编码的方法来对输入进行编码。

Language model pre-training has been shown to be effective for improving many natural language processing tasks. We argue that current techniques restrict the power of the pre-trained representations, espe- cially for the fine-tuning approaches. The ma jor limitation is that standard language models are unidirectional.

例如在 OpenAI GPT中,它使用了从左到右(left-to-right)的网络架构,这就使得模型在编码过程中只能够看到当前只看之前的信息。

For example, in OpenAI GPT, the authors use a left-to- right architecture, where every token can only at- tend to previous tokens in the self-attention layers of the Transformer.

图 1-3. 不同方向编码示意图

如图1-3所示,对于这句样本来说,无论是采用left-to-right还是right-to-left的方法,模型在对“it”进行编码时都不能够很好的捕捉到其具体的指代信息。这就像我们人在看这句话时一样,在没有看到“tired”这个词之前我们是无法判断“it”具体所指代的事物(例如把“tired”换成“wide”,则“it”指代的就是“street”)。如果采用双向编码的方式则从理论上来说就能够很好的避免这个问题,如图1-4所示。

图 1-4. 注意力机制可视化图[3]

在图1-4中,橙色线条表示“it”应该将注意力集中在哪些位置上,颜色越深则表示注意力权重越大。通过这幅图可以发现,模型在对"it"进行编码时,将大部分注意力都集中在了”The animal“上,而这也符合我们预期的结果。

1.2.2 解决思路

在论文中,作者提出了采用BERT(Bidirectional Encoder Representations from Transformers)这一网络结构来实现模型的双向编码学习能力。同时,为了使得模型能够有效的学习到双向编码的能力,BERT在训练过程中使用了基于掩盖的语言模型(Masked Language Model, MLM),即随机对输入序列中的某些位置进行遮蔽,然后通过模型来对其进行预测。

In this paper, we improve the fine-tuning based approaches by proposing BERT: Bidirectional Encoder Representations from Transformers. BERT alleviates the previously mentioned unidi- rectionality constraint by using a “masked lan- guage model” (MLM) pre-training objective.

作者继续谈到,由于MLM预测任务能够使得模型编码得到的结果同时包含上下文的语境信息,因此有利于训练得到更深的BERT网络模型。除此之外,在训练BERT的过程中作者还加入了下句预测任务(Next Sentence Prediction, NSP),即同时输入两句话到模型中,然后预测第2句话是不是第1句话的下一句话。

1.3 技术实现

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

1.3.1 BERT网络结构

正如图1-2所示,BERT网络结构整体上就是由多层的Transformer Encoder堆叠所形成。关于Transformer部分的具体解读可以参见文章[4],这里就不再赘述。

BERT’s model architec- ture is a multi-layer bidirectional Transformer en- coder based on the original implementation de- scribed in Vaswani et al. (2017) [2].

当然,如果是将图1-2中的结果再细致化一点便能够得到如图1-5所示的网络结构图。

图 1-5. BERT网络模型细节图

如图1-5所示便是一个详细版的BERT网络结构图,可以发现上半部分与之前的Transformer Encoder差不多,只不过在Input部分多了一个Segment Embedding。下面就开始分别对其中的各个部分进行介绍。

1.3.2 Input Embedding

如图1-6所示,在BERT中Input Embedding主要包含三个部分:Token Embedding、Positional Embedding和Segment Embedding。虽然前面两种Embedding在Transformer中已经介绍过,但是这里需要注意的是BERT中的Positional Embedding对于每个时刻的位置并不是采用公式计算出来的,其原理也是类似普通的词嵌入一样为每一个位置初始化了一个向量,然后随着网络一起训练。

图 1-6. Input Embedding结构图

当然,最值得注意的一点就是BERT开源的预训练模型最大只支持512个字符的长度,这是因为其在训练过程中(位置)词表的最大长度只有512。

To speed up pretraing in our experiments, we pre-train the model with sequence length of 128 for 90% of the steps. Then, we train the rest 10% of the steps of sequence of 512 to learn the positional embeddings.

除此之外,第三部分则是BERT中引入的Segment Embedding。由于BERT的主要目的是构建一个通用的预训练模型,因此难免需要兼顾到各种NLP任务场景下的输入。因此Segment Embedding的作用便是便是用来区分输入序列中的不同序列,其本质就是通过一个普通的词嵌入来区分每一个序列所处的位置。例如在NSP任务中,那么对于任意一个序列的每一位置都将用同一个向量来进行表示,即此时Segment词表的长度为2。

最后,再将这三部分Embedding后的结果相加(并进行标准化)便得到了最终的Input Embedding部分的输出,如图1-7所示。

图 1-7. BERT输入图

如图1-7所示,最上面的Input表示原始的输入序列,其中第一个字符”[CLS]“是一个特殊的分类标志,如果下游任务是做文本分类的话,那么在BERT的输出结果中可以只取"[CLS]"对应的向量进行分类即可;而其中的"[SEP]"字符则是用来作为将两句话分开的标志。Segment Embedding层则同样是用来区分两句话所在的不同位置,对于每句话来说其内部各自的位置都是一样的,当然如果原始输入就只有一句话,那么Segment Embedding层中对应的每个Token的位置向量都是一样的。最后,Positional Embedding则是用来标识句子中每个Token各自所在的位置,使得模型能够捕捉到文本”有序“这一特性。具体细节之处见后续代码实现。

Sentence pairs are packed together into a single sequence. We differentiate the sentences in two ways. First, we separate them with a special token ([SEP]). Second, we add a learned embed- ding to every token indicating whether it belongs to sentence A or sentence B.

1.3.3 BertEncoder

如图1-8所示便是Bert Encoder的结构示意图,其整体由多个BertLayer(也就是论文中所指代的Transformer blocks)所构成。

图 1-8. BertEncoder结构图

具体的,在论文中作者分别用来表示BertLayer的层数,即BertEncoder是由个BertLayer所构成;用来表示模型的维度;用来表示多头注意力中多头的个数。同时,在论文中作者分别就,这两种尺寸的BERT模型进行了实验对比。由于这部分类似的内容在Transformer中已经进行了详细介绍,所以这里就不再赘述,细节之处见后续代码实现。

1.3.4 MLM与NSP

为了能够更好训练BERT网络,论文作者在BERT的训练过程中引入两个任务,MLM和NSP。对于MLM任务来说,其做法是随机掩盖掉输入序列中的Token(即用"[MASK]"替换掉原有的Token),然后在BERT的输出结果中取对应掩盖位置上的向量进行真实值预测。

In order to train a deep bidirectional representation, we simply mask some percentage of the input tokens at random, and then predict those masked tokens. In this case, the final hidden vectors corresponding to the mask tokens are fed into an output softmax over the vocabulary, as in a standard LM. In all of our experiments, we mask 15% of all WordPiece tokens in each sequence at random.

接着作者提到,虽然MLM的这种做法能够得到一个很好的预训练模型,但是仍旧存在不足之处。由于在fine-tuning时,由于输入序列中并不存在”[MASK]“这样的Token,因此这将导致pre-training和fine-tuning之间存在不匹配不一致的问题(GAP)。

Although this allows us to obtain a bidirec- tional pre-trained model, a downside is that we are creating a mismatch between pre-training and fine-tuning, since the [MASK] token does not ap- pear during fine-tuning.

为了解决这一问题,作者在原始MLM的基础了做了部分改动,即先选定的Token,然后将其中的替换为"[MASK]"、随机替换为其它Token、剩下的不变。最后取这的Token对应的输出做分类来预测其真实值。

The training data generator chooses 15% of the token positions at random for prediction. If the i-th token is chosen, we replace the i-th token with (1) the [MASK] token 80% of the time (2) a random token 10% of the time (3) the unchanged i-th token 10% of the time.

最后,作者还给出了一个不同掩盖策略下的对比结果,如图1-9所示。

图 1-9. 不同MASK策略对比结果图

由于很多下游任务需要依赖于分析两句话之间的关系来进行建模,例如问题回答等。为了使得模型能够具备有这样的能力,作者在论文中又提出了二分类的下句预测任务。

Many important downstream tasks such as Ques- tion Answering (QA) and Natural Language Infer- ence (NLI) are based on understanding the rela- tionship between two sentences. In order to train a model that understands sentence relationships, we pre-train for a binarized next sen- tence prediction task.

具体地,对于每个样本来说都是由A和B两句话构成,其中的情况B确实为A的下一句话(标签为IsNext),另外的的情况是B为语料中其它的随机句子(标签为NotNext),然后模型来预测B是否为A的下一句话,MLM和NSP任务的整体网络结果·结构图如图1-10所示。

Specifically, when choosing the sentences A and B for each pretraining example, 50% of the time B is the actual next sentence that follows A (labeled as IsNext), and 50% of the time it is a random sentence from the corpus (labeled as NotNext).

图 1-10. ML和NSP任务网络结构图

2 BERT 实现

2.1 BERT网络结构回顾

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

图 2-1. BERT网络结构图

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

图 2-2. BERT网络模型细节图

2.2 Input Embedding实现

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

2.2.1 Token Embedding

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

 1 class TokenEmbedding(nn.Module):
 2     def __init__(self, vocab_size, hidden_size, pad_token_id=0, initializer_range=0.02):
 3         super(TokenEmbedding, self).__init__()
 4         self.embedding = nn.Embedding(vocab_size, hidden_size, padding_idx=pad_token_id)
 5         self._reset_parameters(initializer_range)
 6 
 7     def forward(self, input_ids):
 8         """
 9         :param input_ids: shape : [input_ids_len, batch_size]
10         :return: shape: [input_ids_len, batch_size, hidden_size]
11         """

12         return self.embedding(input_ids)
13 
14     def _reset_parameters(self, initializer_range):
15         for p in self.parameters():
16             if p.dim() > 1:
17                 normal_(p, mean=0.0, std=initializer_range)

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

2.2.2 Positional Embedding

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

 1 class PositionalEmbedding(nn.Module):
 2      """
 3      位置编码。
 4       *** 注意:Bert中的位置编码完全不同于Transformer中的位置编码,
 5                 前者本质上也是一个普通的Embedding层,而后者是通过公式计算得到,
 6                 而这也是为什么Bert只能接受长度为512字符的原因,因为位置编码的最大size为512 ***
 7       # Since the position embedding table is a learned variable, we create it
 8       # using a (long) sequence length `max_position_embeddings`. The actual
 9       # sequence length might be shorter than this, for faster training of
10       # tasks that do not have long sequences.
11                                                  ————————  GoogleResearch
12     https://github.com/google-research/bert/blob/eedf5716ce1268e56f0a50264a88cafad334ac61/modeling.py
13     """

14     def __init__(self, hidden_size, max_position_embeddings=512, initializer_range=0.02):
15         super(PositionalEmbedding, self).__init__()
16         self.embedding = nn.Embedding(max_position_embeddings, hidden_size)
17 
18     def forward(self, position_ids):
19         """
20         :param position_ids: [1,position_ids_len]
21         :return: [position_ids_len, 1, hidden_size]
22         """

23         return self.embedding(position_ids).transpose(01)

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

2.2.3 Segment Embedding

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

 1 class SegmentEmbedding(nn.Module):
 2     def __init__(self, type_vocab_size, hidden_size, initializer_range=0.02):
 3         super(SegmentEmbedding, self).__init__()
 4         self.embedding = nn.Embedding(type_vocab_size, hidden_size)
 5 
 6     def forward(self, token_type_ids):
 7         """
 8 
 9         :param token_type_ids:  shape: [token_type_ids_len, batch_size]
10         :return: shape: [token_type_ids_len, batch_size, hidden_size]
11         """

12         return self.embedding(token_type_ids)

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

2.2.4 Bert Embeddings

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

图 2-3. BERT输入图

具体地,其代码实现为:

 1 class BertEmbeddings(nn.Module):
 2     """
 3     BERT Embedding which is consisted with under features
 4         1. TokenEmbedding : normal embedding matrix
 5         2. PositionalEmbedding : normal embedding matrix
 6         2. SegmentEmbedding : adding sentence segment info, (sent_A:1, sent_B:2)
 7         sum of all these features are output of BERTEmbedding
 8     """

 9 
10     def __init__(self, config):
11         super().__init__()
12         self.word_embeddings = TokenEmbedding(
13             vocab_size=config.vocab_size,
14             hidden_size=config.hidden_size,
15             pad_token_id=config.pad_token_id,
16             initializer_range=config.initializer_range)
17         # return shape [src_len,batch_size,hidden_size]
18 
19         self.position_embeddings = PositionalEmbedding(
20             max_position_embeddings=config.max_position_embeddings,
21             hidden_size=config.hidden_size,
22             initializer_range=config.initializer_range)
23         # return shape [src_len,1,hidden_size]
24 
25         self.token_type_embeddings = SegmentEmbedding(
26             type_vocab_size=config.type_vocab_size,
27             hidden_size=config.hidden_size,
28             initializer_range=config.initializer_range)
29         # return shape  [src_len,batch_size,hidden_size]
30 
31         self.LayerNorm = nn.LayerNorm(config.hidden_size)
32         self.dropout = nn.Dropout(config.hidden_dropout_prob)
33         self.register_buffer("position_ids",
34                              torch.arange(config.max_position_embeddings).expand((1-1)))
35         # shape: [1, max_position_embeddings]

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

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

 1     def forward(self,
 2                 input_ids=None,
 3                 position_ids=None,
 4                 token_type_ids=None):

 5         src_len = input_ids.size(0)
 6         token_embedding = self.word_embeddings(input_ids)
 7         # shape:[src_len,batch_size,hidden_size]
 8 
 9         if position_ids is None:
10             position_ids = self.position_ids[:, :src_len]  # [1,src_len]
11         positional_embedding = self.position_embeddings(position_ids)
12         # [src_len, 1, hidden_size]
13 
14         if token_type_ids is None:
15             token_type_ids = torch.zeros_like(input_ids,
16                                               device=self.position_ids.device)# [src_len, batch_size]
17         segment_embedding = self.token_type_embeddings(token_type_ids)
18         # [src_len,batch_size,hidden_size]
19 
20         embeddings = token_embedding + positional_embedding + segment_embedding
21         #[src_len,batch_size,hidden_size] + [src_len,1,hidden_size]+[src_len,batch_size,hidden_size]
22         embeddings = self.LayerNorm(embeddings)  # [src_len, batch_size, hidden_size]
23         embeddings = self.dropout(embeddings)
24         return embeddings

在上述代码中,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 BertEncoder实现

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

图 2-4. BertEncoder实现结构图

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

2.4 BertAttention实现

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

 1 class BertSelfAttention(nn.Module):
 2     """
 3     实现多头注意力机制,对应的是GoogleResearch代码中的attention_layer方法
 4     https://github.com/google-     research/bert/blob/eedf5716ce1268e56f0a50264a88cafad334ac61/modeling.py#L558
 5     """

 6     def __init__(self, config):
 7         super(BertSelfAttention, self).__init__()
 8         self.multi_head_attention = MyMultiheadAttention(embed_dim=config.hidden_size,
 9                                                          num_heads=config.num_attention_heads,
10                                                          dropout=config.attention_probs_dropout_prob)
11 
12     def forward(self, query, key, value, attn_mask=None, key_padding_mask=None):
13         """
14         :param query: # [tgt_len, batch_size, hidden_size], tgt_len 表示目标序列的长度
15         :param key:  #  [src_len, batch_size, hidden_size], src_len 表示源序列的长度
16         :param value: # [src_len, batch_size, hidden_size], src_len 表示源序列的长度
17         :param key_padding_mask: [batch_size, src_len], src_len 表示源序列的长度
18         :return:
19         attn_output: [tgt_len, batch_size, hidden_size]
20         attn_output_weights: # [batch_size, tgt_len, src_len]
21         """

22         return self.multi_head_attention(
23           query, key, value, attn_mask=attn_mask, key_padding_mask=key_padding_mask)

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

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

 1 class BertSelfOutput(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=1e-12)
 5         self.dropout = nn.Dropout(config.hidden_dropout_prob)
 6 
 7     def forward(self, hidden_states, input_tensor):
 8         """
 9         :param hidden_states: [src_len, batch_size, hidden_size]
10         :param input_tensor: [src_len, batch_size, hidden_size]
11         :return: [src_len, batch_size, hidden_size]
12         """

13         hidden_states = self.dropout(hidden_states)
14         hidden_states = self.LayerNorm(hidden_states + input_tensor)
15         return hidden_states

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

 1 class BertAttention(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.self = BertSelfAttention(config)
 5         self.output = BertSelfOutput(config)
 6 
 7     def forward(self,
 8                 hidden_states,
 9                 attention_mask=None):

10         """
11         :param hidden_states: [src_len, batch_size, hidden_size]
12         :param attention_mask: [batch_size, src_len]
13         :return: [src_len, batch_size, hidden_size]
14         """

15         self_outputs = self.self(hidden_states,
16                                  hidden_states,
17                                  hidden_states,
18                                  attn_mask=None,
19                                  key_padding_mask=attention_mask)
20         # self_outputs[0] shape: [src_len, batch_size, hidden_size]
21         attention_output = self.output(self_outputs[0], hidden_states)
22         return attention_output

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

2.5 BertLayer实现

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

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

 1 class BertIntermediate(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.dense = nn.Linear(config.hidden_size, config.intermediate_size)
 5         if isinstance(config.hidden_act, str):
 6             self.intermediate_act_fn = get_activation(config.hidden_act)
 7         else:
 8             self.intermediate_act_fn = config.hidden_act
 9 
10     def forward(self, hidden_states):
11         """
12         # :param hidden_states: [src_len, batch_size, hidden_size]
13         :return: [src_len, batch_size, intermediate_size]
14         """

15         hidden_states = self.dense(hidden_states)  # [src_len, batch_size, intermediate_size]
16         if self.intermediate_act_fn is None:
17             hidden_states = hidden_states
18         else:
19             hidden_states = self.intermediate_act_fn(hidden_states)
20         return hidden_states

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

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

 1 class BertOutput(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.dense = nn.Linear(config.intermediate_size, config.hidden_size)
 5         self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=1e-12)
 6         self.dropout = nn.Dropout(config.hidden_dropout_prob)
 7 
 8     def forward(self, hidden_states, input_tensor):
 9         """
10         :param hidden_states: [src_len, batch_size, intermediate_size]
11         :param input_tensor: [src_len, batch_size, hidden_size]
12         :return: [src_len, batch_size, hidden_size]
13         """

14         hidden_states = self.dense(hidden_states)  # [src_len, batch_size, hidden_size]
15         hidden_states = self.dropout(hidden_states)
16         hidden_states = self.LayerNorm(hidden_states + input_tensor)
17         return hidden_states

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

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

 1 class BertLayer(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.bert_attention = BertAttention(config)
 5         self.bert_intermediate = BertIntermediate(config)
 6         self.bert_output = BertOutput(config)
 7 
 8     def forward(self,
 9                 hidden_states,
10                 attention_mask=None):

11         """
12         :param hidden_states: [src_len, batch_size, hidden_size]
13         :param attention_mask: [batch_size, src_len]
14         :return: [src_len, batch_size, hidden_size]
15         """

16         attention_output = self.bert_attention(hidden_states, attention_mask)
17         # [src_len, batch_size, hidden_size]
18         intermediate_output = self.bert_intermediate(attention_output)
19         # [src_len, batch_size, intermediate_size]
20         layer_output = self.bert_output(intermediate_output, attention_output)
21         # [src_len, batch_size, hidden_size]
22         return layer_output

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

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

2.6 BERT模型实现

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

 1 class BertEncoder(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.config = config
 5         self.bert_layers = nn.ModuleList([BertLayer(config) 
                                             for _ in range(config.num_hidden_layers)])
 6 
 7     def forward(
 8             self,
 9             hidden_states,
10             attention_mask=None):

11         """
12         :param hidden_states: [src_len, batch_size, hidden_size]
13         :param attention_mask: [batch_size, src_len]
14         :return:
15         """

16         all_encoder_layers = []
17         layer_output = hidden_states
18         for i, layer_module in enumerate(self.bert_layers):
19             layer_output = layer_module(layer_output,
20                                         attention_mask)
21             #  [src_len, batch_size, hidden_size]
22             all_encoder_layers.append(layer_output)
23         return all_encoder_layers

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

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

 1 class BertPooler(nn.Module):
 2     def __init__(self, config):
 3         super().__init__()
 4         self.dense = nn.Linear(config.hidden_size, config.hidden_size)
 5         self.activation = nn.Tanh()
 6         self.config = config
 7 
 8     def forward(self, hidden_states):
 9         """
10         :param hidden_states:  [src_len, batch_size, hidden_size]
11         :return: [batch_size, hidden_size]
12         """

13         if self.config.pooler_type == "first_token_transform":
14             token_tensor = hidden_states[0, :].reshape(-1, self.config.hidden_size)
15         elif self.config.pooler_type == "all_token_average":
16             token_tensor = torch.mean(hidden_states, dim=0)
17         pooled_output = self.dense(token_tensor)  # [batch_size, hidden_size]
18         pooled_output = self.activation(pooled_output)
19         return pooled_output

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

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

 1 class BertModel(nn.Module):
 2 
 3     def __init__(self, config):
 4         super().__init__()
 5         self.bert_embeddings = BertEmbeddings(config)
 6         self.bert_encoder = BertEncoder(config)
 7         self.bert_pooler = BertPooler(config)
 8         self.config = config
 9 
10     def forward(self,
11                 input_ids=None,
12                 attention_mask=None,
13                 token_type_ids=None,
14                 position_ids=None):

15         """
16         :param input_ids:  [src_len, batch_size]
17         :param attention_mask: [batch_size, src_len]
18         :param token_type_ids: [batch_size, src_len]
19         :param position_ids: [1,src_len]
20         :return:
21         """

22         embedding_output = self.bert_embeddings(input_ids=input_ids,
23                                                 position_ids=position_ids,
24                                                 token_type_ids=token_type_ids)
25         all_encoder_outputs = self.bert_encoder(embedding_output,
26                                                 attention_mask=attention_mask)
27         sequence_output = all_encoder_outputs[-1]  # 取最后一层
28         pooled_output = self.bert_pooler(sequence_output)# [batch_size, hidden_size]
29         # 默认是最后一层的first token 即[cls]位置经dense + tanh 后的结果
30         return pooled_output, all_encoder_outputs

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

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

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

3.1 模型原理

总的来说,基于BERT的文本分类模型就是在原始的BERT模型后再加上一个分类层即可,类似的结构掌柜在文章[4]基于Transformer的分类模型中也介绍过,大家可以去看一下。同时,对于分类层的输入(也就是原始BERT的输出),默认情况下取BERT输出结果中[CLS]位置对于的向量即可,当然也可以修改为其它方式,例如所有位置向量的均值等。因此,对于基于BERT的文本分类模型来说其输入就是BERT的输入,输出则是每个类别对应的logits值。接下来,掌柜首先就来介绍如何构造文本分类的数据集。

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

3.2 数据预处理

在构建数据集之前,我们首先需要知道的是模型到底应该接收什么样的输入,然后才能构建出正确的数据形式。在上面我们说到,基于BERT的文本分类模型的输入就等价于BERT模型的输入,同时根据文章第2节内容的介绍可以知道BERT模型的输入如图3-1所示:

图 3-1. BERT模型输入图

由于对于文本分类这个场景来说其输入只有一个序列,所以在构建数据集的时候并不需要构造Segment Embedding的输入,直接默认使用全为0即可(可见文章[4] 第2.2.4节);同时,对于Position Embedding来说在任何场景下都不需要对其指定输入,因为我们在代码实现时已经做了相应默认时的处理(同样见文章[4] 第2.2.4节)。

因此,对于文本分类这个场景来说,只需要构造原始文本对应的Token序列,并在首位分别再加上一个[CLS]符和[SEP]符作为输入即可。

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

4.1 模型原理

总的来说,基于BERT的文本蕴含(文本对分类)任务同上一节中介绍的单文本分类任务本质上没有任何不同,最终都是对一个文本序列进行分类。只是按照BERT模型的思想,文本对分类任务在数据集的构建过程中需要通过Segment Embedding来区分前后两个不同的序列,因此本篇文章的核心内容就在于如何构建数据集。总结起来,文本对的分类任务除了在模型输入上发生了变换,其它地方均与单文本分类任务一样。接下来,掌柜首先就来介绍如何构造文本分类的数据集。

4.2 数据预处理

4.2.1 输入介绍

在构建数据集之前,我们首先需要知道的是模型到底应该接收什么样的输入,然后才能构建出正确的数据形式。根据第2节内容的介绍可以知道BERT模型的输入如图4-1所示:

图 4-1. BERT模型输入图

由于在文本对分类任务这个场景中模型的输入包含两个序列,因此在构建数据集的时候不仅仅需要进行Token Embedding操作,同时还要对两个序列进行Segment Embedding操作。对于Position Embedding来说在任何场景下都不需要对其指定输入,因为我们在代码实现时已经做了相应默认时的处理(同样见文章[4]第2.2.4节)。

因此,对于文本对分类这个场景来说:①需要构造原始文本对应的Token序列,然后在最前面加上一个[CLS]符,两个序列之间和末尾分别再加上一个[SEP]符;②根据两个序列各自的长度再构建一个类似[0,0,0,...,1,1,1,...]token_type_ids向量。最后将两者均作为模型的输入即可。

4.2.2 语料介绍

在这里,我们使用到的是论文中所提到的MNLI(The Multi-Genre Natural Language Inference Corpus, 多类型自然语言推理数据库)自然语言推断任务数据集[9]。也就是给定前提(premise)语句和假设(hypothesis)语句,任务是预测前提语句是否包含假设(蕴含, entailment),与假设矛盾(矛盾,contradiction)或者两者都不(中立,neutral)。

如下所示便是部分原始示例数据:

1 {"annotator_labels": ["entailment""neutral""entailment""neutral""entailment"], "genre""oup""gold_label""entailment""pairID""82890e""promptID""82890""sentence1"" From Home Work to Modern Manufacture""sentence1_binary_parse""( From ( ( Home Work ) ( to ( Modern Manufacture ) ) ) )""sentence1_parse""(ROOT (PP (IN From) (NP (NP (NNP Home) (NNP Work)) (PP (TO to) (NP (NNP Modern) (NNP Manufacture))))))""sentence2""Modern manufacturing has changed over time.""sentence2_binary_parse""( ( Modern manufacturing ) ( ( has ( changed ( over time ) ) ) . ) )""sentence2_parse""(ROOT (S (NP (NNP Modern) (NN manufacturing)) (VP (VBZ has) (VP (VBN changed) (PP (IN over) (NP (NN time))))) (. .)))" }
2 {"annotator_labels": ["neutral""neutral""entailment""neutral""neutral"], "genre""nineeleven""gold_label""neutral""pairID""16525n""promptID""16525""sentence1""They were promptly executed.""sentence1_binary_parse""( They ( ( were ( promptly executed ) ) . ) )""sentence1_parse""(ROOT (S (NP (PRP They)) (VP (VBD were) (VP (ADVP (RB promptly)) (VBN executed))) (. .)))""sentence2""They were executed immediately upon capture.""sentence2_binary_parse""( They ( ( were ( ( executed immediately ) ( upon capture ) ) ) . ) )""sentence2_parse""(ROOT (S (NP (PRP They)) (VP (VBD were) (VP (VBN executed) (ADVP (RB immediately)) (PP (IN upon) (NP (NN capture))))) (. .)))"}

由于该数据集同时也可用于其它任务中,因此除了我们需要的前提和假设两个句子和标签之外,还有每个句子的语法解析结构等等。在这里,下载完成数据后只需要执行项目中的format.py脚本即可将原始数据划分成训练集、验证集和测试集。格式化后的数据形式如下所示:

1 From Home Work to Modern Manufacture_!_Modern manufacturing has changed over time._!_1
2 They were promptly executed._!_They were executed immediately upon capture._!_2

后台回复“数据集”即可获取网盘链接!

4.2.3 数据集预览

同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个构建的流程。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图4-2所示。

图 4-2. 文本对分类数据集处理流程图

如图4-2所示,第1步需要将原始的数据样本进行分词(tokenize)处理;第2步再根据tokenize后的结果构造一个字典,不过在使用BERT预训练时并不需要我们自己来构造这个字典,直接使用相应开源模型中的vocab.txt文件构造字典即可,因为只有vocab.txt中每个字的索引顺序才与开源模型中每个字的Embedding向量一一对应的。第3步则是根据字典将tokenize后的文本序列转换为Token Id序列,同时分在Token Id序列的起始位置加上[CLS],在两个序列之间以及末尾加上[SEP]符号,并进行Padding。第4、5步则是根据第3步处理后的结果分别生成对应的token types ids和attention  mask向量。

最后,在模型训练时只需要将第3、4和5步处理后的结果一起喂给模型即可。

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

5.1 模型原理

所谓问答选择指的就是同时给模型输入一个问题和若干选项的答案,最后需要模型从给定的选项中选择一个最符合问题逻辑的答案。可问题在于我们应该怎么来构建这个模型呢?

通常来说,在NLP领域的很多场景中模型最后所做的基本上都是一个分类任务,虽然表面上看起来不是。例如:文本蕴含任务其实就是将两个序列拼接在一起,然后预测其所属的类别;基于神经网络的序列生成模型(翻译、文本生成等)本质就是预测词表中下一个最有可能出现的词,此时的分类类别就是词表的大小。因此,从本质上来说本文介绍的问答选择任务以及在下一篇文章中将要介绍的问题回答任务其实都是一个分类任务,而关键的地方就在于如何构建模型的输入和输出。

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

5.2 模型构建

5.2.1 构建原理

正如前面所说,对于问答选择这个任务场景来说其本质上依旧可以归结为分类任务,只是关键在于如何构建这个任务以及整个数据集。对于问答选择这个场景来说,其整体原理如图5-1所示。

图 5-1. 问答选择原理图

如图5-1所示,是一个基于BERT预训练模型的四选一问答选择模型的原理图。从图5-1中可以看出,原始数据的形式是一个问题和四个选项,模型需要做的就是从四个选项中给出最合理的一个,于是也就变成了一个四分类任务。同时,构建模型输入的方式就是将原始问题和每一个答案都拼接起来构成一个序列中间用[SEP]符号隔开,然后再分别输入到BERT模型中进行特征提取得到四个特征向量形状为[4,hidden_size],最后再经过一个分类层进行分类处理得到预测选项。值得一提的是,通常情况下这里的四个特征都是直接取每个序列经BERT编码后的[CLS]向量。

到此,对于问答选择整个模型的原理我们算是清楚了,下面首先来看如何构造数据集。

5.2.2 语料介绍

在这里,我们使用到的也是论文中所提到的SWAG(The Situations With Adversarial Generations )数据集[10] [11],即给定一个情景(一个问题或一句描述),任务是模型从给定的四个选项中预测最有可能的一个。

如下所示便是部分原始示例数据:

1 ,video-id,fold-ind,startphrase,sent1,sent2,gold-source,ending0,ending1,ending2,ending3,label
2 0,anetv_NttjvRpSdsI,19391,The people are in robes. They,The people are in robes.,They,gold,are wearing colorful costumes.,are doing karate moves on the floor.,shake hands on their hips.,do a flip to the bag.,0
3 1,lsmdc3057_ROBIN_HOOD-27684,16344,She smirks at someone and rides off. He,She smirks at someone and rides off.,He,gold,smiles and falls heavily.,wears a bashful smile.,kneels down behind her.,gives him a playful glance.,1

如上所示数据集中一共有12个字段包含两个样本,我们这里需要用到的就是sent1,ending0,ending1,ending2,ending3,label这6个字段。例如对于第一个样本来说,其形式如下:

1 The people are in robes. They
2   A) wearing colorful costumes.# 正确选项
3   B) are doing karate moves on the floor.
4   C) shake hands on their hips.  
5   D) do a flip to the bag.

同时,由于该数据集已经做了训练集、验证集和测试集(没有标签)的划分,所以后续我们也就不需要来手动划分了。

后台回复“数据集”即可获取网盘链接!

5.2.3 数据集预览

同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个大致构建的流程。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图5-2所示。

图 5-2. 问答选择数据集构建流程图

如图5-2所示,首先对于原始数据的每个样本(一个问题和四个选项),需要将问题同每个选项拼接在一起构造成为四个序列并添加上对应的分类符[CLS]和分隔符[SEP],即图中的第①步重构样本。紧接着需要将第①步构造得到的序列转换得到Token id并进行padding处理,此时便得到了一个形状为[batch_size,num_choice,seq_len]的三维矩阵,即图5-2中第2步处理完成后形状为[2,4,19]的结果。同时,在第②步中还要根据每个序列构造得到相应的attention_mask向量和token_types_ids向量(图5-2中未画出),并且两者的形状也是[batch_size,num_choice,seq_len]

其次是将第②步处理后的结果变形成[batch_size*num_choice,seq_len]的二维形式,因为BERT模型接收输入形式便是一个二维的矩阵。在经过BERT模型进行特征提取后,将会得到一个形状为[batch_size*num_choice,hidden_size]的二维矩阵,最后再乘上一个形状为[hidden_size,1]的矩阵并变形成[batch_size,num_choice]即可完成整个分类任务。

6 基于BERT预训练模型的SQuAD问答任务

6.1 模型原理

所谓问题回答指的就是同时给模型输入一个问题和一段描述,最后需要模型从给定的描述中预测出答案所在的位置(text span)。例如:

1 描述:苏轼是北宋著名的文学家与政治家,眉州眉山人。
2 问题:苏轼是哪里人?
3 标签:眉州眉山人

对于这样一个问题问答任务我们应该怎么来构建这个模型呢?

在做这个任务之前首先需要明白的就是:①最终问题的答案一定是在给定的描述中;②问题的答案一定是一段连续的字符。例如对于上面的描述,如果给出问题“苏轼生活在什么年代他是哪里人?”,那么模型并不会给出“北宋”和“眉州眉山人”这两个分离的答案,最好的情况下便是给出“北宋著名的文学家与政治家,眉州眉山人”这一个答案。

在有了这两个限制条件后,对于这类问答任务的本质也就变成了需要让模型预测得到答案在描述中的起始位置(start position)以及它的结束位置(end position)。所以,问题最终又变成了如何在BERT模型的基础上再构建一个分类器来对BERT最后一层输出的每个Token进行分类,判断它们是否属于start position或者是end position。

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

6.2 模型构建

6.2.1 构建原理

正如上面所说,对于问题回答这个任务场景来说其本质上依旧可以归结为分类任务,只是关键在于如何构建这个任务以及整个数据集。对于问题回答这个场景来说,其整体原理如图6-1所示。

图 6-1. 问题回答原理图

如图6-1所示,是一个基于BERT预训练模型的问题回答模型的原理图。从图6-1中可以看出,构建模型输入的方式就是将原始问题和上下文描述拼接成一个序列中间用[SEP]符号隔开,然后再分别输入到BERT模型中进行特征提取。在BERT编码完成后,再取最后一层的输出对每个Token进行分类即可得到start position和end position的预测输出。

值得注意的是在问题回答场景中是将问题放在上下文描述前面的,即Sentence A为问题Sentence B为描述。而在上一个问题选择任务场景中是将答案放在描述之后。这是因为在SWAG这一推理数据集中,每个选项其实都可以看作是问题(描述)的下半句,两者具有强烈的先后顺序算是一种逻辑推理,因此将选项放在了描述了后面。在问题回答这一场景中,论文中将问题放在描述前面掌柜猜测是因为:①两者并没有强烈的先后顺序;②问题相对较短放到前面可能好处理一点。所以基于这样的考虑,在问答任务中将问题放在了描述前面。不过后续大家依旧可以尝试交换一下顺序看看效果。

到此,对于问题回答模型的原理我们算是大致清楚了,下面首先来看如何构造数据集。

6.2.2 输入重构

在正式介绍如何构建数据集之前我们先来看这么一个问题。如果上下文过长怎么办?是直接采取截断处理吗?如果问题答案恰巧是在被截断的部分里呢,还能直接截断吗?显然,认真想一想截断这种做法在这里肯定是不行的,因此在论文中作者也采用了另外一种方法来解决这一问题,那就是滑动窗口。

在问题回答这个任务场景中(其它场景也可以参考借鉴),当原始的上下文过长超过给定长度或者是512个字符时,可以采取滑动窗口的方法来构造整个模型的输入序列,如图6-2所示。

图 6-2. 问题回答滑动窗口训练时处理流程

在图6-2所示的这一场景中,第①步需要做的是根据指定最大长度和滑动窗口大小将原始样本进行滑动窗口处理并得到多个子样本。不过这里需要注意的是,sentence A即问题部分不参与滑动处理。同时,图6-2中样本右边的3列数字分别表示每个子样本的起始结束索引和原始样本对应的ID。紧接着第②步便是将所有原始样本滑动处理后的结果作为训练集来训练模型。

总的来说训练这一过程并没有太大的问题,因为每个子样本也都有其对应的标签值,因此和普通的训练过程并没有什么本质上的差异。在问题回答这一任务中,最关键的地方在于如何在推理过程中也使用滑动窗口。当然,一种最直观的做法就是直接取起始位置预测概率值加结束位置预测概率值最大的子样本对应的结果,作为整个原始样本对应的预测结果。不过下面掌柜将来介绍另外一种效果更好的处理方式(这也是论文中所采取的方式),其整个处理流程如图6-3所示。

图 6-3. 问题回答滑动窗口推理时处理流程(一)

如图6-3所示,在推理过程中第①步要做的仍旧是需要根据指定最大长度和滑动窗口大小将原始样本进行滑动窗口处理。接着第②步便是根据BERT分类的输出取前K个概率值最大的结果。在图6-3中这里的K值为4,因此对于每个子样本来说其start position和end position分别都有4个候选结果。例如,第②步中第1行的7:0.41,10:02,9:0.12,2:01表示函数就是对于第1个子样本来说,start position为索引7的概率值为0.41,其它同理。

这样对于每一个子样本来说,在分别得到start position和end position的K个候选值后便可以通过组合来得到更多的候选预测结果,然后再根据一些规则来选择最终原始样本对应的预测输出。

根据图6-3中样本重构后的结果可以看出:(1)最终的索引预测结果肯定是大于8的,因为答案只可能在上下文中出现;(2)在进行结果组合的过程中,起始索引肯定是小于等于结束索引的。因此,根据这两个条件在经过步骤③的处理后,便可以得到进一步的筛选结果。例如,对于第1个子样本来说,start position中7和2是不满足条件(1)的,所以可以直接去掉;同时,为了满足第(2)个条件所以在end position中8,6,7均需要去掉。

进一步,将第③步处理后的结果在每个子样本内部进行组合,并按照start position加end position值的大小进行排序,便可以得到如图6-4所示的结果。

图 6-4. 问题回答滑动窗口推理时处理流程(二)

如图6-4所示表示根据概率和排序后的结果。例如第1列9,13,0.65的含义便是最终原始样本预测结果为9,13的概率值为0.65。因此,最终该原始样本对应的预测值便可以取9和13。

6.3 数据集构建

6.3.1 语料介绍

由于没有找到类似的高质量中文数据集,所以在这里掌柜使用到的也是论文中所提到的SQuAD(The Stanford Question Answering Dataset 1.1 )数据集[12],即给定一个问题和描述需要模型从描述中找出答案的起止位置。(找不到数据集的客官也可以找掌柜要)

从2.1节的介绍来看,在问题回答这个任务场景中模型的整体原理并不复杂,只不过需要通过滑动窗口来重构输入,所以后续我们在预处理数据集的时候还需要费点功夫。同时,SQuAD数据集本身的结构也略显复杂,所以这也相应加大了整个数据集构建的工作难度。如果不太想了解SQuAD数据集的构建流程也可以直接跳到对应预处理完成后的内容。下面掌柜就带着各位客官来一起梳理一下整个流程。

图 6-5. SQuAD数据集结构图(一)

如图6-5所示便是数据集SQuAD1.1的结构形式。它整体由一个json格式的数据组成,其中的数据部分就在字段“data”中。可以看到data中存放的是一个列表,而列表中的每个元素可以看成是一篇文章,并以字典进行的存储。进一步,对于每一篇文章来说,其结构如下:

图 6-6. SQuAD数据集结构图(二)

如图6-6所示,对于data中的每一篇文章来说,由“title”和“paragraphs”这两个字段组成。可以看到paragraphs是一个列表,其中的每一个元素为一个字典,可以看做是每一篇文章的其中一个段落,即后续需要使用到的上下文描述context。对于每一个段落来说,其包含有一段描述(“context”字段)和若干个问题与答案对(“qas”字段),其结构如下:

图 6-7. SQuAD数据集结构图(三)

如图6-7所示,对于每个段落对应的问题组qas来说,其每一个元素都是答案(“answers”字段)、问题(“question”字段)和ID(“id”字段)的字典形式。同时,这里的“answer_start”只是答案在context中字符层面的索引,而不是每个单词在context中的位置,所以后面还需要进行转换。而我们所需要完成的便是从数据集中提取出对应的context、question、start pos、end pos以及id信息。

到此,对于数据集SQuAD1.1的基本信息就介绍完了。下面掌柜就开始来一步步介绍如何构建数据集。

6.3.2 数据集预览

在正式介绍如何构建数据集之前我们同样先通过一张图来了解一下整个大致构建的流程。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图6-8所示。由于英文样例普遍较长画图不太方便,所以这里就以中文进行了示例,不过两者原理都一样。

图 6-8. 问题回答模型数据集构造流程图

如图6-8所示,首先对于原始数据中的上下文按照指定的最大长度和滑动窗口大小进行滑动处理,然后再将问题同上下文拼接在一起构造成为一个序列并添加上对应的分类符[CLS]和分隔符[SEP],即图中的第①步重构样本。紧接着需要将第①步构造得到的序列转换得到Token id并进行padding处理,此时便得到了一个形状为[batch_size,seq_len]的二维矩阵,即图6-8中第②步处理完成后形状为[7,18]的结果。同时,在第②步中还要根据每个序列构造得到相应的attention_mask向量和token_types_ids向量(图中未画出),并且两者的形状也是[batch_size,seq_len]

最后,将第②步处理后的结果输入到BERT模型中,在经过BERT特征提取后将会得到一个形状为[batch_size,seq_len,hidden_size]的三维矩阵,最后再乘上一个形状为[hidden_size,2]的矩阵并变形成[batch_size,seq_len,2]的形状,即是对每个Token进行分类。

7 基于BERT预训练模型的命名体识别任务

7.1 模型原理

所谓命名体指的是给模型输入一句文本,最后需要模型将其中的实体(例如人名、地名、组织等等)标记出来。

例如:

1 句子:涂伊说,如果有机会他想去黄州赤壁看一看!
2 标签:['B-PER''I-PER''O''O''O''O''O''O''O''O''O''O''B-LOC''I-LOC''B-LOC''I-LOC''O''O''O''O']
3 实体:涂伊(人名)、黄州(地名)、赤壁(地名)

对于这样一个形式的任务我们应该怎么来构建模型呢?

通常来讲,对于任意一个NLP任务来说模型最后所要完成的基本上都是一个分类任务,尽管表面上看起来可能不太像。根据给出的标签来看,对于原始句子中的每个字符来说其都有一个对应的类别标签,因此对于NER任务来说只需要对原始句子里的每个字符进行分类即可,然后再将预测后的结果进行后处理便能够得到句子从存在的相应实体。

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

7.2 模型构建

7.2.1 构建原理

正如上面所说,对于命名体识别这个任务场景来说其本质上依旧可以归结为分类任务,只是关键在于如何构建这个任务以及整个数据集。对于这个任务场景来说,其整体原理如图7-1所示。

图 7-1. 命名体识别原理图

如图7-1所示便是一个基于BERT预训练模型的NER任务原理图。从图7-1中可以看出原始数据输入为一个句子,我们只需要在句子的首尾分别加上[CLS][SEP],然后输入到模型当中进行特征提取并最终通过一个分类层对输出的每个Token进行分类即可,最后只需要对各个Token的预测结果进行后处理便能够实现整个NER任务。

到此,对于问答选择整个模型的原理我们算是清楚了,下面首先来看如何构造数据集。

7.2.2 语料介绍

在这里,我们使用到的是一个中文命名体识别数据集[13],如下所示便是原始数据的存储形式:

 1 涂 B-PER
 2 伊 I-PER
 3 说 O
 4 , O
 5 如 O
 6 果 O
 7 有 O
 8 机 O
 9 会 O
10 他 O
11 想 O
12 去 O
13 黄 B-LOC
14 州 I-LOC
15 赤 B-LOC
16 壁 I-LOC
17 看 O
18 一 O
19 看 O
20 !O

其中每一行包含一个字符和其对应的所属类别,B-表示该类实体的开始标志,I-表示该类实体的延续标志。例如对于13-16行来说其对应了“黄州”和“赤壁”这两个实体。同时,对于这个数据集来说,其一共包含有3类实体(人名、地名和组织),因此其对应的分类总数便为7,如下所示:

1 {'O': 0, 'B-ORG': 1, 'B-LOC': 2, 'B-PER': 3, 'I-ORG': 4, 'I-LOC': 5, 'I-PER': 6}

接下里,便可以根据需要来构造模型训练时所需要的数据集了。

7.2.3 数据集预览

同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个大致构建的流程。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图7-2所示。

图 7-2. 数据集构造流程图

如图7-2所示,首先我们需要将原始的语料格式化成一个一个的句子;然后再将其转换成对应的Token ID,并在首尾分别加上[CLS][SEP],同时对于标签来说因为首尾加入的这两个Token不需要进行预测所以在对应位置上需要标识出来以便在计算损失的时候对其忽略;最后,在输入模型之前在对其进行Padding处理和构造得到相应的Padding Mask向量。

8 基于BERT预训练模型的NSP和MLM任务

8.1 模型原理

通常,你既可以通过MLM和NSP任务来从头训练一个BERT模型,当然也可以在开源预训练模型的基础上再次通过MLM和NSP任务来在特定语料中进行追加训练,以使得模型参数更加符合这一场景。

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

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

8.2 数据预处理

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

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

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

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

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

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

1 The development of [UNK] powder , based on [UNK] or [UNK] , by the French inventor Paul [UNK] in 1884 was a further step allowing smaller charges of propellant with longer barrels . The guns of the pre @-@ [UNK] battleships of the 1890s tended to be smaller in calibre compared to the ships of the 1880s , most often 12 in ( 305 mm ) , but progressively grew in length of barrel , making use of improved [UNK] to gain greater muzzle velocity .
  
2    = = = [UNK] of armament = = =
    
3  The nature of the projectiles also changed during the ironclad period . Initially , the best armor @-@ piercing [UNK] was a solid cast @-@ iron shot . Later , shot of [UNK] iron , a harder iron alloy , gave better armor @-@ piercing qualities . Eventually the armor @-@ piercing shell was developed .

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

1 def read_wiki2(filepath=None):
2     with open(filepath, 'r'as f:
3         lines = f.readlines()  # 一次读取所有行,每一行为一个段落
4     paragraphs = []
5     for line in tqdm(lines, ncols=80, desc=" ## 正在读取原始数据"):
6         if len(line.split(' . ')) >= 2:
7             paragraphs.append(line.strip().lower().split(' . '))
8     random.shuffle(paragraphs)  # 将所有段落打乱
9     return paragraphs

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

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

1 [ [sentence 1, sentence 2, ...], [sentence 1, sentence 2,...],...,[] ]

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

1 [['the development of [unk] powder , based on [unk] or [unk] , by the french inventor paul [unk] in 1884 was a further step allowing smaller charges of propellant with longer barrels''the guns of the pre @-@ [unk] battleships of the 1890s tended to be smaller in calibre compared to the ships of the 1880s , most often 12 in ( 305 mm ) , but progressively grew in length of barrel , making use of improved [unk] to gain greater muzzle velocity .'], ['the nature of the projectiles also changed during the ironclad period''initially , the best armor @-@ piercing [unk] was a solid cast @-@ iron shot .'],[],[]...[]]

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

8.2.2 中文宋词数据格式化

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

1 红酥手,黄縢酒,满城春色宫墙柳。东风恶,欢情薄。一怀愁绪,几年离索。错错错。春如旧,人空瘦,泪痕红鲛绡透。桃花落。闲池阁。山盟虽在,锦书难托。莫莫莫。
2 十年生死两茫茫。不思量。自难忘。千里孤坟,无处话凄凉。纵使相逢应不识,尘满面,鬓如霜。夜来幽梦忽还乡。小轩窗。正梳妆。相顾无言,惟有泪千行。料得年年断肠处,明月夜,短松冈。

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

 1 def read_songci(filepath=None):
 2     with open(filepath, 'r'as f:
 3         lines = f.readlines()  # 一次读取所有行,每一行为一首词
 4     paragraphs = []
 5     for line in tqdm(lines, ncols=80, desc=" ## 正在读取原始数据"):
 6         if "□" in line or "……" in line:
 7             continue
 8         if len(line.split('。')) >= 2:
 9             paragraphs.append(line.strip().split('。')[:-1])
10     random.shuffle(paragraphs)  # 将所有段落打乱
11     return paragraphs

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

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

1 [['五花心里看抛球''香腮红嫩柳烟稠'], ['若论风流,无过圆社,拐蹬蹑搭齐全''门庭富贵,曾到御帘前''灌口二郎为首,赵皇上、下脚流传''人都道、齐云一社,三锦独争先''花前''并月下,全身绣带,偷侧双肩''更高而不远,一搭打秋千''球落处、圆光拐,双佩剑、侧蹑相连''高人处,翻身佶料,天下总呼圆'],[],[]....[]]

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

8.2.3 构造NSP任务数据

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

 1 class LoadBertPretrainingDataset(object):
 2     def __init__(self,
 3                  vocab_path='./vocab.txt',
 4                  tokenizer=None,
 5                  batch_size=32,
 6                  max_sen_len=None,
 7                  max_position_embeddings=512,
 8                  pad_index=0,
 9                  is_sample_shuffle=True,
10                  random_state=2021,
11                  data_name='wiki2',
12                  masked_rate=0.15,
13                  masked_token_rate=0.8,
14                  masked_token_unchanged_rate=0.5):

15         self.tokenizer = tokenizer
16         self.vocab = build_vocab(vocab_path)
17         self.PAD_IDX = pad_index
18         self.SEP_IDX = self.vocab['[SEP]']
19         self.CLS_IDX = self.vocab['[CLS]']
20         self.MASK_IDS = self.vocab['[MASK]']
21         self.batch_size = batch_size
22         self.max_sen_len = max_sen_len
23         self.max_position_embeddings = max_position_embeddings
24         self.pad_index = pad_index
25         self.is_sample_shuffle = is_sample_shuffle
26         self.data_name = data_name
27         self.masked_rate = masked_rate
28         self.masked_token_rate = masked_token_rate
29         self.masked_token_unchanged_rate = masked_token_unchanged_rate
30         self.random_state = random_state
31         random.seed(random_state)

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

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

 1     def get_format_data(self, filepath):
 2         if self.data_name == 'wiki2':
 3             return read_wiki2(filepath)
 4         elif self.data_name == 'songci':
 5             return read_songci(filepath)
 6         elif self.data_name == 'custom':
 7             return read_custom(filepath)
 8         else:
 9             raise ValueError(f"数据 {self.data_name} 不存在对应的格式化函数,"
10                              f"请参考函数 read_wiki(filepath) 实现对应的格式化函数!")

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

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

1     @staticmethod
2     def get_next_sentence_sample(sentence, next_sentence, paragraphs):
3         if random.random() < 0.5:
4             is_next = True
5         else:
6             next_sentence = random.choice(random.choice(paragraphs))
7             is_next = False
8         return sentence, next_sentence, is_next

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

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

8.2.4 构造MLM任务数据

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

 1     def replace_masked_tokens(self, token_ids, candidate_pred_positions, num_mlm_preds):
 2         pred_positions = []
 3         mlm_input_tokens_id = [token_id for token_id in token_ids]
 4         for mlm_pred_position in candidate_pred_positions:
 5             if len(pred_positions) >= num_mlm_preds:
 6                 break  # 如果已经mask的数量大于等于num_mlm_preds则停止mask
 7             masked_token_id = None
 8             if random.random() < self.masked_token_rate:  # 0.8
 9                 masked_token_id = self.MASK_IDS
10             else:
11                 if random.random() < self.masked_token_unchanged_rate:  # 0.5 # 10%的时间:保持词不变
12                     masked_token_id = token_ids[mlm_pred_position]    
13                 else:# 10%的时间:用随机词替换该词
14                     masked_token_id = random.randint(0, len(self.vocab.stoi))
15             mlm_input_tokens_id[mlm_pred_position] = masked_token_id
16             pred_positions.append(mlm_pred_position)  # 保留被mask位置的索引信息
17         mlm_label = [self.PAD_IDX if idx not in pred_positions
18                      else token_ids[idx] for idx in range(len(token_ids))]
19         return mlm_input_tokens_id, mlm_label

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

例如以下输入:

1 token_ids = [101103148952243103310029200026241031,....]
2 candidate_pred_positions = [2,8,5,9,7,3...]
3 num_mlm_preds = 5

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

1 mlm_input_tokens_id = [101103110322431033100292000103,  1031, ...]
2 mlm_label = [ 0,   0,   4895,  0,    0,    0,    0,   2624,  0,...]

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

 1     def get_masked_sample(self, token_ids):
 2         candidate_pred_positions = []  # 候选预测位置的索引
 3         for i, ids in enumerate(token_ids):
 4             if ids in [self.CLS_IDX, self.SEP_IDX]:
 5                 continue
 6             candidate_pred_positions.append(i)
 7         random.shuffle(candidate_pred_positions)  
 8         num_mlm_preds = max(1, round(len(token_ids) * self.masked_rate))
 9         logging.debug(f" ## Mask数量为: {num_mlm_preds}")
10         mlm_input_tokens_id, mlm_label = self.replace_masked_tokens(
11             token_ids, candidate_pred_positions, num_mlm_preds)
12         return mlm_input_tokens_id, mlm_label

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

8.2.5 构造整体任务数据

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

 1     @cache
 2     def data_process(self, filepath, postfix='cache'):
 3         paragraphs = self.get_format_data(filepath)
 4         data,max_len = [],0
 5         desc = f" ## 正在构造NSP和MLM样本({filepath.split('.')[1]})"
 6         for paragraph in tqdm(paragraphs, ncols=80, desc=desc):  # 遍历每个
 7             for i in range(len(paragraph) - 1):  # 遍历一个段落中的每一句话
 8                 sentence, next_sentence, is_next = self.get_next_sentence_sample(
 9                     paragraph[i], paragraph[i + 1], paragraphs)  # 构造NSP样本
10                 logging.debug(f" ## 当前句文本:{sentence}")
11                 logging.debug(f" ## 下一句文本:{next_sentence}")
12                 logging.debug(f" ## 下一句标签:{is_next}")
13                 token_a_ids = [self.vocab[token] for token in self.tokenizer(sentence)]
14                 token_b_ids = [self.vocab[token] for token in self.tokenizer(next_sentence)]
15                 token_ids = [self.CLS_IDX] + token_a_ids + [self.SEP_IDX] + token_b_ids
16                 if len(token_ids) > self.max_position_embeddings - 1:
17                     token_ids = token_ids[:self.max_position_embeddings - 1
18                 token_ids += [self.SEP_IDX]
19                 logging.debug(f" ## Mask之前词元结果:{[self.vocab.itos[t] for t in token_ids]}")
20                 seg1 = [0] * (len(token_a_ids) + 2)  # 2 表示[CLS]和中间的[SEP]这两个字符
21                 seg2 = [1] * (len(token_ids) - len(seg1))
22                 segs = torch.tensor(seg1 + seg2, dtype=torch.long)
23                 logging.debug(f" ## Mask之前token ids:{token_ids}")
24                 logging.debug(f" ##      segment ids:{segs.tolist()},序列长度为 {len(segs)}")
25                 nsp_lable = torch.tensor(int(is_next), dtype=torch.long)
26                 mlm_input_tokens_id, mlm_label = self.get_masked_sample(token_ids)
27                 token_ids = torch.tensor(mlm_input_tokens_id, dtype=torch.long)
28                 mlm_label = torch.tensor(mlm_label, dtype=torch.long)
29                 max_len = max(max_len, token_ids.size(0))
30                 logging.debug(f" ## Mask之后token ids:{token_ids.tolist()}")
31                 logging.debug(f" ## Mask之后词元结果:{[self.vocab.itos[t] for t in token_ids]}")
32                 logging.debug(f" ## Mask之后label ids:{mlm_label.tolist()}")
33                 logging.debug(f" ## 当前样本构造结束================== \n\n")
34                 data.append([token_ids, segs, nsp_lable, mlm_label])
35         all_data = {'data': data, 'max_len': max_len}
36         return all_data

在上述代码中,第1行中的@cache修饰器用于保存或直接载入已预处理完成后的结果,具体原理可以参见文章[15];第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行则是返回最终生成的结果。

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

 1 [2022-01-17 20:27:38] - DEBUG:  ## 当前句文本:风住尘香花已尽,日晚倦梳头
 2 [2022-01-17 20:27:38] - DEBUG:  ## 下一句文本:锦书欲寄鸿难托
 3 [2022-01-17 20:27:38] - DEBUG:  ## 下一句标签:False
 4 [2022-01-17 20:27:38] - DEBUG:  ## Mask之前词元结果:['[CLS]', '风', '住', '尘', '香', '花', '已', '尽', ',', '日', '晚', '倦', '梳', '头', '[SEP]', '锦', '书', '欲', '寄', '鸿', '难', '托', '[SEP]']
 5 [2022-01-17 20:27:38] - DEBUG:  ## Mask之前token ids:[101, 7599, 857, 2212, 7676, 5709, 2347, 2226, 8024, 3189, 3241, 958, 3463, 1928, 102, 7239, 741, 3617, 2164, 7896, 7410, 2805, 102]
 6 [2022-01-17 20:27:38] - DEBUG:  ##      segment ids:[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],序列长度为 23
 7 [2022-01-17 20:27:38] - DEBUG:  ## Mask数量为: 3
 8 [2022-01-17 20:27:38] - DEBUG:  ## Mask之后token ids:[101, 7599, 857, 2212, 103, 5709, 2347, 103, 8024, 3189, 3241, 103, 3463, 1928, 102, 7239, 741, 3617, 2164, 7896, 7410, 2805, 102]
 9 [2022-01-17 20:27:38] - DEBUG:  ## Mask之后词元结果:['[CLS]', '风', '住', '尘', '[MASK]', '花', '已', '[MASK]', ',', '日', '晚', '[MASK]', '梳', '头', '[SEP]', '锦', '书', '欲', '寄', '鸿', '难', '托', '[SEP]']
10 [2022-01-17 20:27:38] - DEBUG:  ## Mask之后label ids:[0, 0, 0, 0, 7676, 0, 0, 2226, 0, 0, 0, 958, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
11 [2022-01-17 20:27:38] - DEBUG:  ## 当前样本构造结束================== 

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

8.2.6 构造数据集DataLoader

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

 1     def load_train_val_test_data(self,
 2                                  train_file_path=None,
 3                                  val_file_path=None,
 4                                  test_file_path=None,
 5                                  only_test=False):

 6         postfix = f"_ml{self.max_sen_len}_rs{self.random_state}_mr{str(self.masked_rate)[2:]}" \
 7                f"_mtr{str(self.masked_token_rate)[2:]}_mtur{str(self.masked_token_unchanged_rate)[2:]}"
 8         test_data = self.data_process(filepath=test_file_path,
 9                                       postfix='test' + postfix)['data']
10         test_iter = DataLoader(test_data, batch_size=self.batch_size,
11                                shuffle=False, collate_fn=self.generate_batch)
12         if only_test:
13             return test_iter
14         data = self.data_process(filepath=train_file_path, postfix='train' + postfix)
15         train_data, max_len = data['data'], data['max_len']
16         if self.max_sen_len == 'same':
17             self.max_sen_len = max_len
18         train_iter = DataLoader(train_data, batch_size=self.batch_size,
19                                 shuffle=self.is_sample_shuffle,
20                                 collate_fn=self.generate_batch)
21         val_data = self.data_process(filepath=val_file_path, postfix='val' + postfix)['data']
22         val_iter = DataLoader(val_data, batch_size=self.batch_size,
23                               shuffle=False,
24                               collate_fn=self.generate_batch)
25         return train_iter, test_iter, val_iter

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

1 songci_test_mlNone_rs2021_mr15_mtr8_mtur5.pt

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

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

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

8.2.7 数据集使用示例

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

 1 class ModelConfig:
 2     def __init__(self):
 3         self.project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 4         # ========== wiki2 数据集相关配置
 5         # self.dataset_dir = os.path.join(self.project_dir, 'data', 'WikiText')
 6         # self.pretrained_model_dir = os.path.join(self.project_dir, "bert_base_uncased_english")
 7         # self.train_file_path = os.path.join(self.dataset_dir, 'wiki.train.tokens')
 8         # self.val_file_path = os.path.join(self.dataset_dir, 'wiki.valid.tokens')
 9         # self.test_file_path = os.path.join(self.dataset_dir, 'wiki.test.tokens')
10         # self.data_name = 'wiki2' 
11         
12         # ========== songci 数据集相关配置
13         self.dataset_dir = os.path.join(self.project_dir, 'data''SongCi')
14         self.pretrained_model_dir = os.path.join(self.project_dir, "bert_base_chinese")
15         self.train_file_path = os.path.join(self.dataset_dir, 'songci.train.txt')
16         self.val_file_path = os.path.join(self.dataset_dir, 'songci.valid.txt')
17         self.test_file_path = os.path.join(self.dataset_dir, 'songci.test.txt')
18         self.data_name = 'songci'
19         
20         self.vocab_path = os.path.join(self.pretrained_model_dir, 'vocab.txt')
21         self.model_save_dir = os.path.join(self.project_dir, 'cache')
22         self.logs_save_dir = os.path.join(self.project_dir, 'logs')
23         self.is_sample_shuffle = True
24         self.batch_size = 16
25         self.max_sen_len = None
26         self.max_position_embeddings = 512
27         self.pad_index = 0
28         self.is_sample_shuffle = True
29         self.random_state = 2021
30         self.masked_rate = 0.15
31         self.masked_token_rate = 0.8
32         self.masked_token_unchanged_rate = 0.5
33         ......

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

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

 1 if __name__ == '__main__':
 2     model_config = ModelConfig()
 3     data_loader = LoadBertPretrainingDataset(
 4         vocab_path=model_config.vocab_path,
 5         tokenizer=BertTokenizer.from_pretrained(
 6             model_config.pretrained_model_dir).tokenize,
 7         batch_size=model_config.batch_size,
 8         max_sen_len=model_config.max_sen_len,
 9         max_position_embeddings=model_config.max_position_embeddings,
10         pad_index=model_config.pad_index,
11         is_sample_shuffle=model_config.is_sample_shuffle,
12         random_state=model_config.random_state,
13         data_name=model_config.data_name,
14         masked_rate=model_config.masked_rate,
15         masked_token_rate=model_config.masked_token_rate,
16         masked_token_unchanged_rate=model_config.masked_token_unchanged_rate)
17 
18     test_iter = data_loader.load_train_val_test_data(test_file_path=model_config.test_file_path,
19                                                      only_test=True)
20     for b_token_ids, b_segs, b_mask, b_mlm_label, b_nsp_label in test_iter:
21         print(b_token_ids.shape)  # [src_len,batch_size]
22         print(b_segs.shape)  # [src_len,batch_size]
23         print(b_mask.shape)  # [batch_size,src_len]
24         print(b_mlm_label.shape)  # [src_len,batch_size]
25         print(b_nsp_label.shape)  # [batch_size]
26         break

输出结果如下:

1 [2022-01-17 21:01:07] - INFO: 缓存文件 ~/BertWithPretrained/data/SongCi/songci_test_ mlNone_rs2021_mr15_mtr8_mtur5.pt 存在,直接载入缓存文件!
2 [2022-01-17 21:01:08] - INFO: ## 成功返回测试集,一共包含样本6249个
3 torch.Size([4216])
4 torch.Size([4216])
5 torch.Size([1642])
6 torch.Size([4216])
7 torch.Size([16])

8.3 预训练任务实现

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

8.3.1 NSP任务实现

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

 1 class BertForNextSentencePrediction(nn.Module):
 2 
 3     def __init__(self, config, bert_pretrained_model_dir=None):
 4         super(BertForNextSentencePrediction, self).__init__()
 5         if bert_pretrained_model_dir is not None:
 6             self.bert = BertModel.from_pretrained(config, bert_pretrained_model_dir)
 7         else:
 8             self.bert = BertModel(config)
 9         self.classifier = nn.Linear(config.hidden_size, 2)
10 
11     def forward(self,
12                 input_ids,  # [src_len, batch_size]
13                 attention_mask=None,  # [batch_size, src_len] mask掉padding部分的内容
14                 token_type_ids=None,  # [src_len, batch_size] 如果输入模型的只有一个序列,那么这个参数也不用传值
15                 position_ids=None,
16                 next_sentence_labels=None):
  # [batch_size,]
17         pooled_output, _ = self.bert(
18             input_ids=input_ids,
19             attention_mask=attention_mask,
20             token_type_ids=token_type_ids,
21             position_ids=position_ids)
22         # pooled_output: [batch_size, hidden_size]
23         seq_relationship_score = self.classifier(pooled_output)
24         # seq_relationship_score: [batch_size, 2]
25         if next_sentence_labels is not None:
26             loss_fct = nn.CrossEntropyLoss()
27             loss = loss_fct(seq_relationship_score.view(-12), next_sentence_labels.view(-1))
28             return loss
29         else:
30             return seq_relationship_score

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

8.3.2 MLM任务实现

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

图 8-3. MLM任务网络结构图

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

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

 1 class BertForLMTransformHead(nn.Module):
 2     def __init__(self, config, bert_model_embedding_weights=None):
 3         super(BertForLMTransformHead, self).__init__()
 4         self.dense = nn.Linear(config.hidden_size, config.hidden_size)
 5         if isinstance(config.hidden_act, str):
 6             self.transform_act_fn = get_activation(config.hidden_act)
 7         else:
 8             self.transform_act_fn = config.hidden_act
 9         self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=1e-12)
10         self.decoder = nn.Linear(config.hidden_size, config.vocab_size)
11         if bert_model_embedding_weights is not None:
12             self.decoder.weight = nn.Parameter(bert_model_embedding_weights)
13         # [hidden_size, vocab_size]
14         self.decoder.bias = nn.Parameter(torch.zeros(config.vocab_size))
15     def forward(self, hidden_states):
16         hidden_states = self.dense(hidden_states)  # [src_len, batch_size, hidden_size]
17         hidden_states = self.transform_act_fn(hidden_states)  # [src_len, batch_size, hidden_size]
18         hidden_states = self.LayerNorm(hidden_states)  # [src_len, batch_size, hidden_size]
19         hidden_states = self.decoder(hidden_states)
20         return hidden_states # [src_len, batch_size, vocab_size]

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

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

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

1 self.weight = Parameter(torch.Tensor(out_features, in_features))

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

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

 1 class BertForMaskedLM(nn.Module):
 2     def __init__(self, config, bert_pretrained_model_dir=None):
 3         super(BertForMaskedLM, self).__init__()
 4         if bert_pretrained_model_dir is not None:
 5             self.bert = BertModel.from_pretrained(config, bert_pretrained_model_dir)
 6         else:
 7             self.bert = BertModel(config)
 8         weights = None
 9         if config.use_embedding_weight:
10             weights = self.bert.bert_embeddings.word_embeddings.embedding.weight
11             logging.info(f"## 使用token embedding中的权重矩阵作为输出层的权重!{weights.shape}")
12         self.classifier = BertForLMTransformHead(config, weights)
13         self.config = config
14     def forward(self,
15                 input_ids,  # [src_len, batch_size]
16                 attention_mask=None,  # [batch_size, src_len] mask掉padding部分的内容
17                 token_type_ids=None,  # [src_len, batch_size]
18                 position_ids=None,
19                 masked_lm_labels=None):
  # [src_len,batch_size]
20         _, all_encoder_outputs = self.bert(
21             input_ids=input_ids,attention_mask=attention_mask,
22             token_type_ids=token_type_ids,position_ids=position_ids)
23         sequence_output = all_encoder_outputs[-1]  # 取Bert最后一层的输出
24         # sequence_output: [src_len, batch_size, hidden_size]
25         prediction_scores = self.classifier(sequence_output)
26         # prediction_scores: [src_len, batch_size, vocab_size]
27         if masked_lm_labels is not None:
28             loss_fct = nn.CrossEntropyLoss(ignore_index=-1)
29             masked_lm_loss = loss_fct(prediction_scores.reshape(-1, self.config.vocab_size),
30                                       masked_lm_labels.reshape(-1))
31             return masked_lm_loss
32         else:
33             return prediction_scores  # [src_len, batch_size, vocab_size]

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

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

8.3.3 整体实现

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

 1 class BertForPretrainingModel(nn.Module):
 2     def __init__(self, config, bert_pretrained_model_dir=None):
 3         super(BertForPretrainingModel, self).__init__()
 4         if bert_pretrained_model_dir is not None:
 5             self.bert = BertModel.from_pretrained(config, bert_pretrained_model_dir)
 6         else:  # 如果没有指定预训练模型路径,则随机初始化整个网络权重
 7             self.bert = BertModel(config)
 8         weights = None
 9         if 'use_embedding_weight' in config.__dict__ and config.use_embedding_weight:
10             weights = self.bert.bert_embeddings.word_embeddings.embedding.weight
11             logging.info(f"## 使用token embedding中的权重矩阵作为输出层的权重!{weights.shape}")
12         self.mlm_prediction = BertForLMTransformHead(config, weights)
13         self.nsp_prediction = nn.Linear(config.hidden_size, 2)
14         self.config = config
15 
16     def forward(self, input_ids,  # [src_len, batch_size]
17                 attention_mask=None,  # [batch_size, src_len] mask掉padding部分的内容
18                 token_type_ids=None,  # [src_len, batch_size]
19                 position_ids=None,
20                 masked_lm_labels=None,  # [src_len,batch_size]
21                 next_sentence_labels=None):
  # [batch_size]
22         pooled_output, all_encoder_outputs = self.bert(
23             input_ids=input_ids,
24             attention_mask=attention_mask,
25             token_type_ids=token_type_ids,
26             position_ids=position_ids)
27         sequence_output = all_encoder_outputs[-1]  # 取Bert最后一层的输出
28         # sequence_output: [src_len, batch_size, hidden_size]
29         mlm_prediction_logits = self.mlm_prediction(sequence_output)
30         # mlm_prediction_logits: [src_len, batch_size, vocab_size]
31         nsp_pred_logits = self.nsp_prediction(pooled_output)
32         # nsp_pred_logits: [batch_size, 2]
33         if masked_lm_labels is not None and next_sentence_labels is not None:
34             loss_fct = nn.CrossEntropyLoss(ignore_index=-1)
35             mlm_loss = loss_fct(mlm_prediction_logits.reshape(-1, self.config.vocab_size),
36                                 masked_lm_labels.reshape(-1))
37             nsp_loss = loss_fct(nsp_pred_logits.reshape(-12),
38                                 next_sentence_labels.reshape(-1))
39             total_loss = mlm_loss + nsp_loss
40             return total_loss, mlm_prediction_logits, nsp_pred_logits
41         else:
42             return mlm_prediction_logits, nsp_pred_logits
43         # [src_len, batch_size, vocab_size], [batch_size, 2]

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

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

8.4 模型训练与微调

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

8.4.1 训练实现

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

 1 def train(config):
 2     model = BertForPretrainingModel(config,config.pretrained_model_dir)
 3     last_epoch = -1
 4     if os.path.exists(config.model_save_path):
 5         checkpoint = torch.load(config.model_save_path)
 6         last_epoch = checkpoint['last_epoch']
 7         loaded_paras = checkpoint['model_state_dict']
 8         model.load_state_dict(loaded_paras)
 9         logging.info("## 成功载入已有模型,进行追加训练......")
10     model.train()
11     bert_tokenize = BertTokenizer.from_pretrained(config.pretrained_model_dir).tokenize
12     data_loader = LoadBertPretrainingDataset(vocab_path=config.vocab_path, ...)
13     train_iter, test_iter, val_iter = \
14         data_loader.load_train_val_test_data(test_file_path=config.test_file_path, ...)
15       
16     no_decay = ["bias""LayerNorm.weight"]
17     optimizer_grouped_parameters = [
18         {
19             "params": [p for n, p in model.named_parameters() if not any(nd in n for nd in no_decay)],
20             "weight_decay": config.weight_decay,
21             "initial_lr": config.learning_rate
22         },
23         {
24             "params": [p for n, p in model.named_parameters() if any(nd in n for nd in no_decay)],
25             "weight_decay"0.0,
26             "initial_lr": config.learning_rate
27         },
28     ]      
29     optimizer = AdamW([{"params": model.parameters(),
30                         "initial_lr": config.learning_rate}])
31     scheduler = get_polynomial_decay_schedule_with_warmup(optimizer,
32                                                           config.num_warmup_steps,
33                                                           config.num_train_steps,
34                                                           last_epoch=last_epoch)

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

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

 1     for epoch in range(config.epochs):
 2         losses = 0
 3         for idx, (b_token_ids, b_segs, b_mask, b_mlm_label, b_nsp_label) in enumerate(train_iter):
 4           #......
 5             loss, mlm_logits, nsp_logits = model(input_ids=b_token_ids,
 6                                                  attention_mask=b_mask,
 7                                                  token_type_ids=b_segs,
 8                                                  masked_lm_labels=b_mlm_label,
 9                                                  next_sentence_labels=b_nsp_label)
10             optimizer.zero_grad()
11             loss.backward()
12             optimizer.step()
13             scheduler.step()
14             losses += loss.item()
15             mlm_acc, _, _, nsp_acc, _, _ = accuracy(mlm_logits, nsp_logits, b_mlm_label,
16                                                     b_nsp_label, data_loader.PAD_IDX)
17             if idx % 20 == 0:
18                 config.writer.add_scalar('Training/Loss', loss.item(), scheduler.last_epoch)
19                 config.writer.add_scalar('Training/Learning Rate'
20                                          scheduler.get_last_lr()[0], scheduler.last_epoch)
21                 config.writer.add_scalars(main_tag='Training/Accuracy',
22                                           tag_scalar_dict={'NSP': nsp_acc,'MLM': mlm_acc},
23                                           global_step=scheduler.last_epoch)
24         train_loss = losses / len(train_iter)
25         if (epoch + 1) % config.model_val_per_epoch == 0:
26             mlm_acc, nsp_acc = evaluate(config, val_iter, model, data_loader.PAD_IDX)
27             config.writer.add_scalars(main_tag='Testing/Accuracy',
28                                       tag_scalar_dict={'NSP': nsp_acc,'MLM': mlm_acc},
29                                       global_step=scheduler.last_epoch)
30             if mlm_acc > max_acc:
31                 max_acc = mlm_acc
32                 state_dict = model.state_dict()
33             torch.save({'last_epoch': scheduler.last_epoch,'model_state_dict': state_dict},
34                          config.model_save_path)

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

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

1 - INFO: Epoch: [1/120], Batch[ 0/7836], Train loss : 1.897, Train mlm acc: 0.0, nsp acc: 0.537
2 - INFO: Epoch: [1/120], Batch[20/7836], Train loss : 1.786, Train mlm acc: 0.0, nsp acc: 0.562
3 - INFO: Epoch: [1/120], Batch[40/7836], Train loss : 1.812, Train mlm acc: 0.0, nsp acc: 0.489
4           ......

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

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

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

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

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

8.4.2 推理实现

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

 1 def inference(config, sentences=None, masked=False, language='en'):
 2     bert_tokenize = BertTokenizer.from_pretrained(config.pretrained_model_dir).tokenize
 3     data_loader = LoadBertPretrainingDataset(vocab_path=config.vocab_path,
 4                                              ....
 5                                              masked_rate=0.15)  # 15% Mask掉
 6     token_ids, pred_idx, mask = data_loader.make_inference_samples(sentences,masked=masked,
 7                                                                    language=language)
 8     model = BertForPretrainingModel(config,config.pretrained_model_dir)
 9     if os.path.exists(config.model_save_path):
10         checkpoint = torch.load(config.model_save_path)
11         loaded_paras = checkpoint['model_state_dict']
12         model.load_state_dict(loaded_paras)
13         logging.info("## 成功载入已有模型进行推理......")
14     with torch.no_grad():
15         mlm_logits, _ = model(input_ids=token_ids,
16                               attention_mask=mask)
17     pretty_print(token_ids, mlm_logits, pred_idx,data_loader.vocab.itos, sentences, language)

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

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

 1 if __name__ == '__main__':
 2     config = ModelConfig()
 3     train(config)
 4     sentences_1 = ["I no longer love her, true, but perhaps I love her.",
 5                    "Love is so short and oblivion so long."]
 6 
 7     sentences_2 = ["我住长江头,君住长江尾。",
 8                    "日日思君不见君,共饮长江水。",
 9                    "此水几时休,此恨何时已。",
10                    "只愿君心似我心,定不负相思意。"]
11     inference(config, sentences_2, masked=False, language='zh')

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

1 - INFO: ## 成功载入已有模型进行推理……
2 - INFO:  ### 原始:我住长江头,君住长江尾。
3 - INFO:   ## 掩盖:我住长江头,[MASK]住长[MASK]尾。
4 - INFO:   ## 预测:我住长江头,君住长河尾。  
5 - INFO: ====================
6 - INFO:  ### 原始:日日思君不见君,共饮长江水。
7 - INFO:   ## 掩盖:日日思君不[MASK]君,共[MASK]长江水。
8 - INFO:   ## 预测:日日思君不见君,共饮长江水。
9 #   ......

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

8.4.3 模型微调

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

9 内容预览



10 引用

[1]BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding.

[2]Vaswani A, Shazeer N, Parmar N, et al. Attention is all you need

[3]http://jalammar.github.io/illustrated-transformer/

[4]This post is all you need (上)——层层剥开Transformer

[5]The Illustrated BERT, ELMo, and co. (How NLP Cracked Transfer Learning) https://jalammar.github.io/illustrated-bert/

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

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

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

[9]https://cims.nyu.edu/~sbowman/multinli/

[10]https://rowanzellers.com/swag/

[11]https://github.com/rowanz/swagaf/tree/master/data

[12]https://rajpurkar.github.io/SQuAD-explorer/

[13] https://github.com/zjy-ucas/ChineseNER

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

[15]如何用@修饰器来缓存数据与处理结果?

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

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

[18] 自定义学习率动态调整与代码实现

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

本次内容就到此结束,感谢您的阅读!青山不改,绿水长流,我们月来客栈见!

继续滑动看下一个
月来客栈
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存