NLP 模型应用之二:BERT

引入

BERT 是谷歌在 2018 年 10 月发布的自然语言处理模型,它在十一项自然语言任务中打破记录,在有些任务中有显著提高,并超越了人类水平,被誉为开启了 NLP 的新时代。虽然,在之后又出现了大量新算法,这两年 BERT 仍然是各大比赛以及产品中的主流算法。论文地址:https://arxiv.org/pdf/1810.04805.pdf

BERT 全称为 Bidirectional Encoder Representations from Transformers,从名字可以看出,它基于 Transformer 基础模型。在 BERT 之前,ELMo 模型已经开始用预测训练方法从无监督数据中提取与上下文相关的词义;而 GPT 模型用 Pretrain/Fine-tune 方法,延用了预训练模型的结构和参数,但由于它是单向模型,主要用于据前文估计后文。而 BERT 使用了双向模型,遮蔽句中部分单词,训练句间关系等方法,提出了一套完整的解决方案,在模型结构不变的情况下,适配各种各样的 NLP 任务。

模型规模

BERT 通过前期对大量的无标签数据的预训练 pretain,显著地提高了后期在少量数据有标签任务上的表现 fine-tune。在 33 亿单词(BooksCorpus 800M words,English Wikipedia 2,500M words)的无标注语料库上做预训练,BERT 最终发布了 BASE 和 LARGE 两个版本的模型,官方也发布了中文模型。

BERT_BASE (L=12, H=768, A=12, Total Param-eters=110M) 

BERT_LARGE (L=24, H=1024,A=16, Total Parameters=340M)

BERT_CHINESE(L=12, H=768, A=12, Total Param-eters=110M)

其中 L 为 Transformer layer+ feed forward 层数,H 为隐藏层的维度,A 为注意力头数。

原理

BERT 是一个多层、双向,且只有 Encoding编码部分的 Transformer 模型,先使用大量无标签数据训练,然后针对具体任务,加入最后一层,然后微调模型 fine-tune。从而解决分类、推理、问答、命名实体识别等多种问题。

前篇讲到迁移学习的两种主流方法:第一种方法是用训练好的模型作为特征提取器;第二种方法是延用之前训练出的模型整体结构。因此,在预训练时,就要把接口给留出来,比如怎么支持分类,怎么判断前后关系……,设计模型时难度较高,这也是 BERT 模型的关键技术。

BERT 的底层使用 Transformer 模型,改进了训练方法,更好地利用无监督数据,把一段话中词的之间关系(Attention)用参数描述出来。其中包含了词义、位置的先后关系、句间关系(Segment)。

预训练包括两个任务:第一个任务是屏蔽语言模型(后面详述);第二个任务是将上下句作为训练样本,用模型判断两句是否相关。两个任务各有一个损失函数值 loss,将两个损失加起来作为总的损失进行优化。

遮蔽语言模型(训练句中词的关系)

屏蔽语言模型 masked language model(Masked LM),它随机抠掉句中 15% 的单词,其中 80% 替换成 [MASK],10% 替换成随机词,另外 10% 只做替换标记,但不替换内容,让模型根据上下文猜测该单词。由于 BERT 是双向模型,它不仅能从前文中寻找线索,也能从后文中寻找线索,MLM 极大地扩展了模型的适用场景,如解决完型填空之类的问题。

下一句预测(训练句间关系)

下一句预测 next Sentence Prediction (NSP),用于训练模型识别句子之间的关系。将训练样本作为上下句,有 50% 样本,下句和上句存在真实的连续关系的,另外 50% 样本,下句和上句无关,用模型训练判断两句是否相关,从而将无标签数据变为有标签数据。

具体实现

BERT 设计同一结构解决不同问题,pretain 与 fine-tune 时模型结构几乎不变,从而利用少量数据 fine-tune 增量训练,生成高质量的模型。

首先,BERT 定义了几种特殊字符:'[PAD]': 0, 句子不够长时填补的空白字符 '[CLS]': 1, 位于句首,为分类任务预留,可存储两句间的关系 '[SEP]': 2, 标记句尾和句间位置 '[MASK]': 3,随机遮蔽 例如随机取两个句子,组装在一起: [CLS]+ 句 1+[SEP]+ 句 2+[SEP];句中 15% 的词被替换;不够长的句子补 [PAD]。如下图所示:

图片来自论文

输入数据由三部分组成:词的具体含义 (token),分段信息 (segment),位置信息 (position)。这一结构在 fine-tune 时即可支持双句输入,也可支持单句输入。Pretain 训练好的模型参数和结构,用于初始化针对特定目的训练 fine-tune。

代码分析

论文中官方发布的代码地址 https://github.com/google-research/bert,由 Tensorflow 实现。

如果使用 pytorch,推荐 https://github.com/graykode/nlp-tutorial,它是一个自然语言处理教程,由 Pytorch 实现。其中包括从 Word2Vec、TextCNN 到 Transformer 多个模型的演进。其主要优点是代码非常简单。比如 BERT 实现在 nlp-tutorial/5-2.BERT/BERT_Torch.py 文件中,只有 200 多行代码,其中一半以上和前篇 Transformer 相同,同时比 Transformer 翻译任务减少了 Decoder 部分,因此只需要考虑不到一半的基础逻辑。

也可参考 https://github.com/huggingface/transformers,它的下载量仅次于 google 官方发布的 TensorFlow 版本。其中除了 BERT 还包括 GPT2、CTRL、ROBERTA 等多个基于 transformer 模型 NLP 工具的实现,它同时提供 BERT 和 Pytorch 代码。其中 BERT 的 Pytorch 实现包括 1500 行代码,例程相对完整,对于问答、分类、句间关系等问题均有具体实现的类及调用方法。

下面列出了解决问答的实例(在程序的最后部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class BertForQuestionAnswering(BertPreTrainedModel):
def __init__(self, config):
super(BertForQuestionAnswering, self).__init__(config)
self.num_labels = config.num_labels
self.bert = BertModel(config)
self.qa_outputs = nn.Linear(config.hidden_size, config.num_labels)
self.init_weights()

@add_start_docstrings_to_callable(BERT_INPUTS_DOCSTRING)
def forward(self, input_ids=None,
attention_mask=None,  token_type_ids=None,
position_ids=None,  head_mask=None,
inputs_embeds=None, start_positions=None,
end_positions=None,):

outputs = self.bert(input_ids,
attention_mask=attention_mask,  token_type_ids=token_type_ids,
position_ids=position_ids,  head_mask=head_mask,
inputs_embeds=inputs_embeds,)

sequence_output = outputs[0]
logits = self.qa_outputs(sequence_output)
start_logits, end_logits = logits.split(1, dim=-1)
start_logits = start_logits.squeeze(-1)
end_logits = end_logits.squeeze(-1)
outputs = (start_logits, end_logits,) + outputs[2:]

if start_positions is not None and end_positions is not None:
# If we are on multi-GPU, split add a dimension
if len(start_positions.size()) > 1:
start_positions = start_positions.squeeze(-1)
if len(end_positions.size()) > 1:
end_positions = end_positions.squeeze(-1)
# sometimes the start/end positions are outside our model inputs, we ignore these terms

ignored_index = start_logits.size(1)
start_positions.clamp_(0, ignored_index)
end_positions.clamp_(0, ignored_index)
loss_fct = CrossEntropyLoss(ignore_index=ignored_index)
start_loss = loss_fct(start_logits, start_positions)
end_loss = loss_fct(end_logits, end_positions)
total_loss = (start_loss + end_loss) / 2
outputs = (total_loss,) + outputs
return outputs # (loss), start_logits, end_logits, (hidden_states), (attentions)

问答给出两部分数据,第一部分是问题,第二部分是包含答案的段落,目标是找到答案在段落中的开始和结束位置。上例重写了其父类的初始化 init 和前向传播 forward 两个函数,在整个网络结构的最后加入了一个全连接层来计算位置;其核心是用预测的位置与实际位置的差异计算误差函数。

程序中也示例了该类的调用方法:

1
2
3
4
5
6
7
8
9
10
11
12
import torch

tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertForQuestionAnswering.from_pretrained('bert-large-uncased-whole-word-masking-finetuned-squad')
question, text = "Who was Jim Henson?", "Jim Henson was a nice puppet"

input_ids = tokenizer.encode(question, text)
token_type_ids = [0 if i <= input_ids.index(102) else 1 for i in range(len(input_ids))]
start_scores, end_scores = model(torch.tensor([input_ids]), token_type_ids=torch.tensor([token_type_ids]))
all_tokens = tokenizer.convert_ids_to_tokens(input_ids)
answer = ' '.join(all_tokens[torch.argmax(start_scores) : torch.argmax(end_scores)+1])
assert answer == "a nice puppet"

由于 Pytorch,TensorFlow 已经提供了大量的工具,很多“高深”的模型,站在工具的基础上看并不困难。