1.bert简单介绍
BERT(Bidirectional Encoder Representations from Transformers)是谷歌在2018年10月份的论文《Pre-training of Deep Bidirectional Transformers for Language Understanding》中提出的一个预训练模型框架,发布后对NLP领域产生了深远影响,各种基于bert的模型如雨后春笋般涌出。
在此对bert模型做一个简单的记录用于后期学习参考,文中会标注相关出处,如遇未注明或出现错误,请告知。如遇侵权,请告知删除~
bert模型分为pre-training
和fine-tuning
两个阶段。
1.1. pre-training阶段
在预训练阶段,bert构造了两个训练任务Mask LM
和Next Sentence Prediction
。
Mask LM
Mask LM
是谷歌提出的一种学习句子内部关系的一种trick,该trick的灵感来源于完形填空(跟word2vec
的CBOW
模型类似),基本思想是随机遮掩(mask)句子中一些词,并利用上下文信息对其进行预测。
mask的具体做法是随机对每个句子中15%的词语按如下规则进行
# 80%的时间采用[mask]
my dog is hairy ===> my dog is [MASK]
# 10%的时间随机取一个词来代替mask的词
my dog is hairy ===> my dog is apple
# 10%的时间保持不变
my dog is hairy ===> my dog is hairy
使用该trick后可以使得模型对上下文信息具有完全意义上的双向表征。
Q:模型在进行[mask]
的时候为什么要以一定的概率保持不变呢?
A:如果100%的概率都用[MASK]来取代被选中的词,那么在fine tuning的时候模型可能会有一些没见过的词。
Q:那么为什么要以一定的概率使用随机词呢?
A:这是因为Transformer要保持对每个输入token分布式的表征,否则Transformer很可能会记住这个[MASK]
就是"hairy"。至于使用随机词带来的负面影响,文章中说了,所有其他的token(即非"hairy"的token)共享15%*10% = 1.5%的概率,其影响是可以忽略不计的。
然而,该trick具有以下两个缺点:
1)pre-training和fine-tuning阶段不一致,该trick在fine-tuning阶段是不可见的,只有在pre-training阶段才是用该trick。
2) 模型收敛速度慢。在每个batch中只有15%的token被预测,因此需要在pre-training阶段花费更多的训练次数。
Next Sentence Prediction
在自动问答(QA)、自然语言理解(NLI)等任务中,需要弄清上下两句话之间的关系,为了模型理解这种关系,需要训练Next Sentence Prediction。
构造训练语料方式:50%时间下一句是真正的下一句,50%时间下一句是语料中随机的一句话。
在pre-training阶段,模型的损失函数是Mask LM和Next Sentence Prediction最大似然概率均值之和。
1.2. fine-tuning阶段
完成预训练之后的bert模型就具备了fine-tuning的能力,论文中将bert应用于11项NLP任务中,在当时均取得了STOA的效果。
- MNLI(Multi-Genre Natural Language Inference):输入句子对,预测第二个句子相对于第一个句子是entailment, contradiction, or neutral三种类型中的哪一种。
- QQP(Quora Question Pairs):输入句子对,二分类任务,判断两个问句在语义上是否等价。
- QNLI(Question Natural Language Inference):输入句子对,二分类任务,正样本包含答案,负样本不包含。
- STS-B(Semantic Textual Similarity Benchmark):输入句子对,计算两个句子之间的语义相似度得分(1-5分)。
- MRPC(Microsoft Research Paraphrase Corpus):输入句子对,判断两个句子在语义上是否等价。
- RTE(Recognizing Textual Entailment):输入句子对,是否是蕴含关系,与MNLI类似,只是样本数量小得多。
- SWAG(Situations With Adversarial Generations):给定一个句子,判断候选的四个句子那个是输入句子的续写。
- SST-2(Stanford Sentiment Treebank):电影评论的情感分析。
- CoLA(Corpus of Linguistic Acceptability):判断一个句子是否在语义上是可接受的。
- SQuAD(Standford Question Answering Dataset):知识问答,给定一个问题和一段包含答案的文本,找出该文本中答案的所在的范围。
- CoNLL 2003 Named Entity Recognition:命名实体识别。
在fine-tuning阶段,根据下游任务的性质,可选择不同的bert输出特征作为下游任务的输入。bert模型的输出主要有model.get_pooling_out()
和model.get_sequence_out()
model.get_pooling_out()
输出的是每个句子开头位置[CLS]的向量表示,也可以简单理解为该句子所属类别的向量表示,其shape=[batch size, hidden size]
model.get_sequence_out()
输出的是整个句子每个token的向量表示,需要注意的是该向量表示也包括了[CLS],其shape=[batch_size, seq_length, hidden_size]
2.模型结构
2.1. bert输入
bert的输入由以下三部分组成
单词embedding(token embeddings),这个就是我们之前一直提到的单词embedding,表示当前词的embedding。
句子embedding(segmentation embeddings ),表示当前词所在句子的index embedding,因为bert的输入是由两个句子构成的,那么每个句子有个整体的embedding项对应给每个单词。
位置信息embedding(position embeddings),表示当前词所在位置的index embedding,这是因为NLP中单词顺序是很重要的特征,需要在这里对位置信息进行编码。
把单词对应的三个embedding叠加,就形成了Bert的输入。bert输入的三个embedding都是通过学习得到的。
2.2.bert结构
对比OpenAI GPT(Generative pre-trained transformer),BERT是双向的Transformer block连接;就像单向RNN和双向RNN的区别,直觉上来讲效果会好一些。
对比ELMo,虽然都是“双向”,但目标函数其实是不同的。ELMo是分别以和作为目标函数,独立训练处两个representation然后拼接,而BERT则是以作为目标函数训练LM。
原文请参考:https://zhuanlan.zhihu.com/p/46652512
通过阅读bert源码,可以很清晰地得知其模型结构,bert模型实现部分主要在modeling.py文件中。
embedding_lookup
函数实现的是token embedding
embedding_postprocessor
函数实现的是segment embedding
和position embedding
将token embedding
、segment embedding
和position embedding
相加就可以得到bert模型的输入(也即源码中的self.embedding_output
),然后将self.embedding_output
输入到12层transformer(只有encoder)中,即可得到bert的model.get_sequence_out()
输出。
而model.get_pooling_out()
则是在model.get_sequence_out()
中取出第一个token
(也即[CLS])对应的向量表示。
然为了在下游的分类任务中能够得到相同维度的representation,因此会经过一个dense层将[CLS]的representation转换为固定维度(hidden_size),所以model.get_pooling_out()
的维度是[batch size, hidden size]
如需要详细的bert源码解读,可参考https://zhuanlan.zhihu.com/p/69106080 (PART I),也可关注笔者公众号【NLPer笔记簿】,后台回复bert
即可获取bert源码解读完整版。
with tf.variable_scope(scope, default_name="bert", reuse=tf.AUTO_REUSE):
with tf.variable_scope("embeddings"):
# Perform embedding lookup on the word ids.
(self.embedding_output, self.embedding_table) = embedding_lookup(
input_ids=input_ids,
vocab_size=config.vocab_size,
embedding_size=config.hidden_size,
initializer_range=config.initializer_range,
word_embedding_name="word_embeddings",
use_one_hot_embeddings=use_one_hot_embeddings)
# Add positional embeddings and token type embeddings, then layer
# normalize and perform dropout.
self.embedding_output = embedding_postprocessor(
input_tensor=self.embedding_output,
use_token_type=True,
token_type_ids=token_type_ids,
token_type_vocab_size=config.type_vocab_size,
token_type_embedding_name="token_type_embeddings",
use_position_embeddings=True,
position_embedding_name="position_embeddings",
initializer_range=config.initializer_range,
max_position_embeddings=config.max_position_embeddings,
dropout_prob=config.hidden_dropout_prob)
with tf.variable_scope("encoder"):
# This converts a 2D mask of shape [batch_size, seq_length] to a 3D
# mask of shape [batch_size, seq_length, seq_length] which is used
# for the attention scores.
attention_mask = create_attention_mask_from_input_mask(
input_ids, input_mask)
# Run the stacked transformer.
# `sequence_output` shape = [batch_size, seq_length, hidden_size].
self.all_encoder_layers = transformer_model(
input_tensor=self.embedding_output,
attention_mask=attention_mask,
hidden_size=config.hidden_size,
num_hidden_layers=config.num_hidden_layers,
num_attention_heads=config.num_attention_heads,
intermediate_size=config.intermediate_size,
intermediate_act_fn=get_activation(config.hidden_act),
hidden_dropout_prob=config.hidden_dropout_prob,
attention_probs_dropout_prob=config.attention_probs_dropout_prob,
initializer_range=config.initializer_range,
do_return_all_layers=True)
self.sequence_output = self.all_encoder_layers[-1]
# The "pooler" converts the encoded sequence tensor of shape
# [batch_size, seq_length, hidden_size] to a tensor of shape
# [batch_size, hidden_size]. This is necessary for segment-level
# (or segment-pair-level) classification tasks where we need a fixed
# dimensional representation of the segment.
with tf.variable_scope("pooler"):
# We "pool" the model by simply taking the hidden state corresponding
# to the first token. We assume that this has been pre-trained
first_token_tensor = tf.squeeze(self.sequence_output[:, 0:1, :], axis=1)
self.pooled_output = tf.layers.dense(
first_token_tensor,
config.hidden_size,
activation=tf.tanh,
kernel_initializer=create_initializer(config.initializer_range))
2.3.bert输出
其实bert输出已经在模型结构章节介绍过了,bert输出主要有model.get_sequence_out()
和model.get_pooling_out()
两种输出,其shape分别为[batch_size, seq_length, hidden_size]和[batch_size, hidden_size]。
model.get_sequence_out()
输出主要用于特征提取再处理的序列任务,而model.get_pooling_out()
输出可直接接softmax进行分类(当然需要外加一层dense层将hidden_size转换为num_tag)。