【NLP】改变NLP格局的利器-BERT(模型和代码解析)

在计算机视觉领域,ImageNet和ResNet的出现奠定了图像算法大规模应用的基础;在语音领域也有DeepSpeech、DeepSpeech2这类工业级解决方案;而NLP领域由于domain更多更深,则呈现的是长时间的分裂格局,没有出现一个够格的Pre-train模型来实现大一统。

2018年的10月,Google发布论文《Pre-training of Deep Bidirectional Transformers for Language Understanding》,文中提出的BERT模型成功在 11 项 NLP 任务中取得SOTA的结果,宛如一道惊雷传遍了各大Research的论坛,各大自媒体小编们奔走相告,拉开了广大NLPer填坑的序幕。时隔一年半,BERT已经成功在搜索、推荐、智能客服、语音编解码等应用场景遍地开花。那么,我们就结合Jacob Devlin放出的源码来对BERT一探究竟,欢迎各位评论指正。

  1. BERT模型

1.1 背景介绍

文章开头指出了Language model pre-training在很多NLP任务上都是行之有效的办法,同时介绍了以往惯用的两种套路:

  • Feature based Approaches

用大量的语料结合Language model的方法来学习词的表示,然后将词的表示应用到特定任务的网络结构的word embedding层。类似的方法有:ELMo

  • Pre-training and Fine-Tuning

同样是使用大量语料训练Language model得到预训练模型,和上述方法不同点在于,对于一个特定任务还是使用该Language model模型进行finetune,这样做的好处是可以减少需要学习的权重的过程,对于一个新任务可以更快的查看效果并迭代。类似的方法有:OpenAI GPT, 文中的BERT模型也是使用这种方法。

1.2 模型结构

BERT模型的base model使用Transformer,具体的介绍可以参照我之前的一篇介绍 换一种方式进行机器翻译-Transformer ,同时BERT还结合 Masked LMNext Sentence Prediction 两种方法分别捕捉单词和句子之间的语义关系,是这篇文章主要的创新点。

同时,文章的附录也给出了BERT和OpenAI GPT以及ELMo在模型对比:

  1. 相比于OpenAI GPT的单向传导, BERT结合了双向context,信息更丰富。由于language model只使用encoder部分,所以不需要做太多改动。

  2. ELMo是采用的双向LSTM模型,将获取的双向单词表示进行拼接,然后传入下一层。

1.3 Embedding

估计很多人想问,如果pre-train模型是language model模型的话,对于问答之类的特定任务,如何用transformer来表示文档呢,BERT是使用embedding来解决这个问题的:

如上图所示,BERT共有三类embedding:

  1. Token Embeddings: 单词的embedding表示;

  2. Segment Embeddings : 句子级别的分割embedding,用来解决上面的问题,比如第一个句子的segment id都是0,第二句都是1。

  3. Position Embeddings: Transformer中提出,用于加入时序信息,不同于原版中Transformer使用的是sin和cos函数,这里使用的是参数进行学习。

最终是将这三种embeddings全部加起来,输入到网络层。

1.4 Masked LM

简单来说,这一步就是在训练的过程中随机的把 每个句子中的15%的单词进行mask,损失函数也只对mask过的词作用 。同时,由于mask只有在training过程中使用,在测试集中并没有,所以为了尽可能和测试集处理接近,所有被mask住的词中分三种方式处理:

  • 10%的概率用随机词来替换

  • 10%的概率不进行替换

  • 80%的概率用[mask]标识来替换

做这部分的动机是作者认为现有的语言模型的做法无非两种,一种是从左到右的预测;一种是分别训练从左到右和从右到左两个模型,把两个模型训练的embedding拼接起来。然而,作者感觉之前的bidirectional做法没有灵魂,只是将简单的将左右的embedding拼起来, 缺少了一种模型的整体性(矛头直指OpenAI GPT和ELMo) 。而且他们认为,在实际应用中 每个单词的向量表示其实反应的是它周围context的信息 ,如果只是用左边的context或者右边的context来表示,亦或是用将两种训练的context人工拼接起来,距离具体的task应用都差点意思。所以想出了这么一个简单有效的trick。

这里我也大胆揣测一下mask的意义 ,了解word2vec的同学们都知道,这个东西看起来很像是CBOW方法,即用单词的context的表示来预测当前词, 而在CBOW中,预测当前词的context信息中是没有加入当前词的信息的 ,为此,我也特地去确认了一下源码:

if (cbow) {  //train the cbow architecture
      // in -> hidden
      cw = 0;
      for (a = b; a < window * 2 + 1 - b; a++) if (a != window) {
        c = sentence_position - window + a;
        if (c = sentence_length) continue;
        last_word = sen[c];
        if (last_word == -1) continue;
        for (c = 0; c < layer1_size; c++) neu1[c] += syn0[c + last_word * layer1_size];
        cw++;
      }
...

如上所示, 当a != window的时候才计算context信息 (建议大家看一下word2vec源码,受益良多)。

话说回来,Masked LM的做法是将当前词替换为了上述介绍的三种处理方式。个人感觉是因为对于多层较复杂的神经网络结构中,这种方式相比于不使用当前词信息要好实现的多,而且结果不错。

而损失函数只计算被mask的单词,这个地方作者的代码也得也很细:

# sequence_tensor: sequence_output
# position: masked positions
def gather_indexes(sequence_tensor, positions):
  """Gathers the vectors at the specific positions over a minibatch."""
  sequence_shape = modeling.get_shape_list(sequence_tensor, expected_rank=3)
  batch_size = sequence_shape[0]
  seq_length = sequence_shape[1]
  width = sequence_shape[2]

  flat_offsets = tf.reshape(
      tf.range(0, batch_size, dtype=tf.int32) * seq_length, [-1, 1])
  flat_positions = tf.reshape(positions + flat_offsets, [-1])
  flat_sequence_tensor = tf.reshape(sequence_tensor,
                                    [batch_size * seq_length, width])
  output_tensor = tf.gather(flat_sequence_tensor, flat_positions)
  return output_tensor

经过Transformer结构,可以获取到每个词的隐层表示sequence_output,shape:(batch, seq_length, hidden_size)。同时我们也有masked positions作为输入,shape:(batch_size, max_predictions_per_seq)。

上面的代码是得到只背masked位置的单词,以batch为3、seq_length为10为例:作者先取了batch方向上的flat_offsets,然后将positions加上flat_offsets并reshape为一维,这样就获得了在batch和step序列两个维度上的绝对索引值,然后通过索引值去sequence_output变量上索引。

flat_offsets = [[0],[10],[20]]
positions = [[0,1],[2,3],[3,4]]
flat_positions = [[0,1,12,13,23,24]]

如上所述,masked positions是固定尺寸的:

name_to_features = {
        "input_ids":
            tf.FixedLenFeature([max_seq_length], tf.int64),
        "input_mask":
            tf.FixedLenFeature([max_seq_length], tf.int64),
        "segment_ids":
            tf.FixedLenFeature([max_seq_length], tf.int64),
        "masked_lm_positions":
            tf.FixedLenFeature([max_predictions_per_seq], tf.int64),
        "masked_lm_ids":
            tf.FixedLenFeature([max_predictions_per_seq], tf.int64),
        "masked_lm_weights":
            tf.FixedLenFeature([max_predictions_per_seq], tf.float32),
        "next_sentence_labels":
            tf.FixedLenFeature([1], tf.int64),
    }

每个句子有长有短,补齐的方式是添0,同时label也要添加0。

while len(masked_lm_positions) < max_predictions_per_seq:
    masked_lm_positions.append(0)
    masked_lm_ids.append(0)
    masked_lm_weights.append(0.0)

然后将masked word对应的隐变量经过Softmax来预测该位置对应的groundtruth,这里作者也提供了不同masked word进行weighted loss,但是在预处理的代码中是简单的将所有的masked_lm_weights 设置为1。

1.5 Next Sentence Prediction

对于QA和NLI任务来说,更多的是要理解两个句子之间的联系,所以文章还使用了NSP的策略,即给定两个句子x和y,来预测y是否是x的下一句。这里文章给的注释是NSP的准确率达到了97-98%, 同时文章在后面训练的地方也说明最好是使用document-level的预料进行训练,效果会好一些,所以文章用了BooksCorpus和English Wikipedia而不是Billion Word Benchmark。

由于encoder的输出的shape是(batch_size, seq_length, hidden_size),对于NSP文章中是取第一个单词也就是[CLS]对应的隐变量通过Softmax来预测:

# 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))

在得到 Masked LM 和 Next Sentence Prediction 各自的loss之后,BERT是将两个loss直接加起来作为整个模型的loss:

(masked_lm_loss,
masked_lm_example_loss, masked_lm_log_probs) = get_masked_lm_output(
         bert_config, model.get_sequence_output(), model.get_embedding_table(),
         masked_lm_positions, masked_lm_ids, masked_lm_weights)

(next_sentence_loss, next_sentence_example_loss,
next_sentence_log_probs) = get_next_sentence_output(
         bert_config, model.get_pooled_output(), next_sentence_labels)

total_loss = masked_lm_loss + next_sentence_loss

2.实验结果

2.1 在各项任务上的对比:

在和ELMo和OpenAI GPT正面对比下, BERT在GLUE下的指标非常好看。

而在官方release的代码中,BERT给出了上述BASE和LARGE模型之间的区别:

https://github.com/google-research/bert/ github.com

  • BERT-Base, Uncased : 12-layer, 768-hidden, 12-heads, 110M parameters

  • BERT-Large, Uncased : 24-layer, 1024-hidden, 16-heads, 340M parameters

Uncased是将所有单词转为小写字母,同时文章还提出,适当增加hidden_size可以有效提升效果,但是增加到1000左右基本上就不怎么变化了。BERT-Base的模型设置是为了和OpenAI GPT对比。

2.2 Masked LM和NSP对比:

这里对比了只用Masked LM和 Masked LM + NSP,只有Masked LM结果也好于类似OpenAI GPT网络的结果,前两行结果和证明NSP在NLI相关任务上提升较大。

3. 优缺点:

3.1 优点:

  • 经过预训练+微调的Bert模型在11项相关的NLP任务中得到SOTA结果,可以说是NLP中的ResNet,并且作者十分慷慨的放出了他们 fairly expensive 的模型(four days on 4 to 16 Cloud TPUs)。嗯,金钱的味道真美妙。

  • 采用Transformer作为base model, inference速度可以并行加速。

  • 实用性强,在工业界中也渗透到了方方面面的任务,不断开枝散叶,如阿里的推荐BERT4Rec,滴滴的语音编解码MPC等等。

3.2 缺点:

  • 只有15%被masked的词进入了损失函数,而且加入了多种随机因素,需要大量的数据作为支撑,可能会收敛较慢。

4. 总结:

  • 偏实验性的文章,思路简单,没有太多网络模型上的矫揉造作,简单粗暴却有效。同时带来的成本是需要依托大量的算力在大量的数据上来进行反复实验,所以此类文章多出见于大厂,对于工业界的应用往往也是可以无缝衔接。

  • 作者将Transfomer重写了一份,代码写的很好,特别是注释非常工整。

  • 后续的方向,笔者不才,看到文中的Masked LM和word2vec中的CBOW有异曲同工之处,所以想着是否可以和Skip-gram也结合一下。也请各位看官发表高论于评论之中。

如果文章对您有用的话,麻烦点个赞支持一下。

原文链接:

https://zhuanlan.zhihu.com/p/104501321

本文由作者授权Jerry的算法和NLP原创发布于公众号平台,点击’阅读原文’直达原文链接,欢迎投稿,算法、AI、NLP 均可。

扫码添加微信,一定要备注研究 方向+地点+学校+昵称 (如机器学习+上海+上交+汤姆),只有备注正确才可以加群噢。

  • 每日一题算法交流群

  • AI竞赛群

  • CV交流群

 ▲长按加群