【阅读笔记】BERT 介绍和代码解读

最近玩了玩 BERT,做了一些实验,感觉还挺有意思的,写点东西记录一下,我会从粗到细,从简单到复杂,一层一层的说明白 BERT 的每一步。

BERT 的预训练

BERT 模型的预训练会从数据集抽取两句话,其中 B 句有 50% 的概率是 A 句的下一句,然后将这两句话转化输入表征,再随机遮掩(mask 掉)输入序列中 15% 的词,并要求 Transformer 完成预测这些被遮掩的词和预测 B 句是否是 A 句的这两个任务。
对于 Mask 预测任务,首先整个序列会随机 Mask 掉 15% 的词,这里的 Mask 不只是简单地用 [MASK] 符号代替某些词,这是因为这会引起预训练与微调两阶段不太匹配。所以在确定需要 Mask 掉的词后,80% 的情况下会直接替代为 [MASK] ,10% 的情况会替代为其它任意的词,最后 10% 的情况会保留原词。
对于二分类任务,在抽取一个序列(A+B)中,B 有 50% 的概率是 A 的下一句。如果是的话就会生成标注 [IsNext],不是的话就会生成标注[NotNext]。
之所以选择这两个任务是因为: Mask 预测任务如果可以很好地完成,说明句子内的语义已经充分理解了; 是否为连续的句子预测任务如果可以很好地完成,说明句子整体的语义也已经充分理解了。

BERT 的应用

最后预训练完模型,就要尝试把它们应用到各种 NLP 任务中,并进行简单的微调。不同的任务在微调上有一些差别,但 BERT 已经强大到能为大多数 NLP 任务提供高效的信息抽取功能。对于分类问题而言,例如预测 A/B 句是不是问答对、预测单句是不是语法正确等,它们可以直接利用特殊符 [CLS] 所输出的向量C\bm{C},即P=softmax(CW)P=softmax(\bm{C}\cdot \bm{W}),新任务只需要训练权重矩阵W\bm{W}就可以了。

对于其它序列标注或生成任务,我们也可以使用 BERT 对应的输出信息作出预测,例如每一个时间步输出一个标注或词等。下图展示了 BERT 在 4 种任务中的微调方法,它们都只添加了一个额外的输出层。在下图中,Tok 表示不同的词、E 表示输入的嵌入向量、T_i 表示第 i 个词在经过 BERT 处理后输出的上下文向量。

BERT 的整体流程

通过阅读google放出的代码,我们来了解一下细节。
BERT 模型的总体流程如下

Created with Raphaël 2.2.0input_idstoken_type_idsword_embeddingstoken_type_embeddingsposition_embeddingsembedding_outputTransformerall_encoder_layer

输入的是 input_ids 和 token_type_ids 分别代表输入句子的 term 和 句子是 A 句子还是 B 句子。
先从总体流程级别看这个过程:
通过 word2vec 或者 one-hot 得到 input_ids 对应的 word_embeddings (One hot is better for TPUs),通过 token_type_table(训练得到的句子的 embedding) 得到 token_type_ids 对应的 token_type_embeddings(optional,这个其实就是 Segement Embeddings),通过 full_position_embeddings(训练得到的位置的 embedding) 截取序列长度的部分得到 position_embeddings(optional),把三个相加得到 embedding_output。
【阅读笔记】BERT 介绍和代码解读
再把 embedding_output 放入到 transformer_model 得到 all_encoder_layers。transformer_model 采用最经典的 Attention Is All You Need 里提出的 Transformer 模型,主要的想法是 Self attention 和 Multi-Head Attention。
【阅读笔记】BERT 介绍和代码解读
之后用 sequence_output = all_encoder_layers[-1] 做 term 级别的东西,或者用 [CLS] 处的输出 first_token_tensor = tf.squeeze(sequence_output[:, 0:1, :], axis=1) 加一个**函数为 tanh 的全连接作为输出 pooled_output 做句子级别的东西。

通过代码理解细节

我会在 tensor 后面表上维度方便理解:
从 input_ids ([batch, max_sequence_length]) 到 embedding_output([batch, max_sequence_length, hidden_size]) 很容易理解,就是一个查表相加的过程(embedding_lookup,适合 cpu 和 gpu;或者 onehot 与 embedding_table 相乘,适合 tpu)。
再将embedding_output ([batch_size, max_sequence_length, hidden_size]) 放到 transformer_model 中得到 all_encoder_layers (num_hidden_layers*[batch_size, max_sequence_length, hidden_size]),transformer_model 的所有层的结果,然后如前所述 sequence_output = all_encoder_layers[-1] ([batch, max_sequence_length, hidden_size]) 做 term 级别的东西,或者用 [CLS] 处的输出 first_token_tensor= tf.squeeze(sequence_output[:, 0:1, :], axis=1)([batch_size,hidden_size]) 加一个**函数为 tanh 的全连接作为输出 pooled_output 做句子级别的东西。
【阅读笔记】BERT 介绍和代码解读
需要仔细理解的过程是 transformer_model 的过程,transformer_model 的输入是 embedding_output([batch_size, max_sequence_length, hidden_size]) ,为了减少 representation 在 2D 和 3D 之间的变换过程,所以在处理过程中保持 2D 的状态(这是因为在 GPU/CPU 上 reshape 比较方便,但在 TPU 上并不方便),reshape embedding_output 为 prev_output([batch_size*max_sequence_length, hidden_size]),然后对其进行 num_hidden_layers 个 transformer block 得到 all_encoder_layers(num_hidden_layers*[batch, max_sequence_length, hidden_size])。每个 block 分为两个小层,并且把上个 block 的输出 prev_output([batch_size*max_sequence_length, hidden_size]) 作为 layer_input([batch*max_sequence_length, hidden_size]) ,先经过 attention_layer 得到 attention_head,再把如果有多个 attention_heads 再把他们 concat 起来得到 attention_output(在 bert 的情况下看起来只会有一个 attention_heads,可能在一般情况下会有其他序列的 attention),再把 attention_output 用一个全链接投影到 hidden_size 维上,加上 dropout 之后,和 layer_input ([batch*max_sequence_length, hidden_size]) 相加(相当于一个 shortcut),最后进行 layer_norm。第二小层是过一个**函为 gelu 的全链接,得倒 intermediate_output ([batch*max_sequence_length, intermediate_size]) ,再投影回 layer_output ([batch*max_sequence_length, hidden_size]) ,dropout 后再加上 attention_output ([batch*max_sequence_length, hidden_size]) (相当于一个 shortcut),最后进行 layer_norm 得到 block 的输出。
【阅读笔记】BERT 介绍和代码解读
【阅读笔记】BERT 介绍和代码解读
【阅读笔记】BERT 介绍和代码解读
attention_layer 体现了 multi-headed attentionself-attention(from_tensor 和 to_tensor 相同都是 layer_input([batch*max_sequence_length, hidden_size])),先从 from_tensor_2D([batch*max_sequence_length, hidden_size]) 投影(全连接)得到 query_layer([batch*max_sequence_length, num_atention_heads*size_per_head]),从to_tensor_2D([batch*max_sequence_length, hidden_size]) 投影(全连接)得到 key_layer([batch*max_sequence_length, num_atention_heads*size_per_head]) 和 value_layer([batch*max_sequence_length, num_atention_heads*size_per_head]),再经过若干次 reshape 和运算得到结果 context_layer([batch*max_sequence_length, num_atention_heads*size_per_head]),也就是attention_head([batch*max_sequence_length, num_atention_heads*size_per_head])。 这个过程中的 rehape 是为了保证乘法和 softmax 的进行,具体过程需要看代码,语言表述会更乱,没有直接看代码简洁,核心思想就是用 query 和 key 相乘得到 attention_scores([batch, num_atention_heads, max_sequence_length, max_sequence_length]),在对其进行缩放(乘1(sizeperhead)\frac{1}{\sqrt(size_{per- head})},这是为了让值变小点,利于进行 softmax 后梯度反向传播),在进行 softmax,dropout 后与 value 相乘 的到最终的结果 context_layer

理解的 trick

看完上面的也许你还是一头雾水,想要真正理解还是得自己一行一行的看代码,我下面说一下我看代码过程中感觉方便理解的点。其实比较费脑子的地方是对 Transformer 模型的理解,在 Transformerattention layer 又是其中比较难懂的地方。对于 Transformer 模型的理解,我们先要知道它的创新之处在于没有采取大热的 RNN/LSTM/GRU 的结构,而是使用 attention layer 和全连接层。上句话的核心意思就是不是用 RNN 类似的结构,而是独立考虑每一个状态(也就是 tensor 最后一个维度的 hidden_size),也就是说所有的全连接、shortcut 都是针对这些状态。而整个句子不同 term 的关系是通过 attention layer 体现的。 attention layer 可以这么理解:来了一个从 from_tensor 得到 query,然后算和从 to_tensor 得到 key 相关,用所得结果就是不同位置得 to_tensor 对某一位置的贡献,也就是说 softmax值与 value 相乘得倒 context_layer。如果理解了 attention layer,就明白了他如何突破了 LSTM 对前后关系长度的限制,它是一个全局的相关。而且可以由多个 attention concat 起来,每一个学习一处前后关系。
最后再说很基础的 tensorflow 的知识:

  • tf.matmul之间的两个 tensor 维度必须是 2D 以上并且维度相同,前面的维度都相当于 batch,最后两个维度是矩阵,对其进行乘法运算,还有一些 transpose 参数来确定前后两个 tensor 的最后两维是否进行转秩操作。
  • tf.nn.softmax是对某个 axis 上的值进行 softmax,默认是对最后一个维度进行。

希望在看完我的理解后能够更容易理解 berttransformer model。也希望有什么不对的地方有缘人也可帮我指正。
过段时间我也会把我写的一些关于 bert 得程序放在我的 github 上。