【NLP】BERT模型解析记录

1.bert简单介绍

BERT(Bidirectional Encoder Representations from Transformers)是谷歌在2018年10月份的论文《Pre-training of Deep Bidirectional Transformers for Language Understanding》中提出的一个预训练模型框架,发布后对NLP领域产生了深远影响,各种基于bert的模型如雨后春笋般涌出。

在此对bert模型做一个简单的记录用于后期学习参考,文中会标注相关出处,如遇未注明或出现错误,请告知。如遇侵权,请告知删除~

bert模型分为pre-trainingfine-tuning两个阶段。

1.1. pre-training阶段

在预训练阶段,bert构造了两个训练任务Mask LMNext Sentence Prediction
Mask LM
Mask LM是谷歌提出的一种学习句子内部关系的一种trick,该trick的灵感来源于完形填空(跟word2vecCBOW模型类似),基本思想是随机遮掩(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的效果。

基于bert的下游任务
  • 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模型输入

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 embeddingposition embedding

token embeddingsegment embeddingposition 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)。

你可能感兴趣的:(【NLP】BERT模型解析记录)