Bert 源码阅读

预训练

create_pretraining_data.py

生成预训练用的数据集

create_training_instances

读取输入文件,生成训练用样本

输入文件格式: 1.一行一个句子,不是一整个段落或者任意长度的文本。因为需要用到句子的边界用来进行NSP任务 2.文档之间的空行 作为文档之间的边界。因为NSP 任务不会跨文档

all_documents 表示分词后的文档集合,是一个三层的列表,

第一层是document,表示一篇文章; 第二层是segment,表示文章中的一个句子。 其中,一个document包含多个segment。 一个document是一个两层的列表,其中的每个segment是一个列表。

[ [[I,Love,play games],[Me,Too]], [[Today is a good day], [Yes]] ]

上面表示两个document,每个document有两个segment。

dupe_factor 表示 遍历数据集(all_documents)的次数,类似于epoch。

然后,从all_documents中生成instance。最后,将instance 写入到tfrecord文件中。

create_instances_from_document

从文档中生成多个样本,每个样本总长度固定,有少量长度较短,且包含两个segment(可能包含多个句子)。

short_seq_prob 表示以一定的概率使用短序列,即序列的长度 在(2,max_num_tokens)中随机采样 这样做的目的是尽量减小预训练和微调任务之间的差异。

对于每个document,依次遍历每个句子(segment),加入到current_chunk中,同时记录current_chunk的长度。 如果current_chunk 的长度大于target_seq_length 或者是document的最后一个句子,那么从current_chunk中选择前k个segment作为句子a,k是随机生成的。 然后生成句子b。 如果current_chunk只有一个句子或者当前随机数小于0.5,那么就随机选择b。b的长度是target_seq_length减去句子A的长度。即保证A和B加起来长度为target_seq_length。 随机生成时,则从all_documents中随机选择一个document。并从选中的文档中随机选择一个句子作为开始,并从该句子开始,依次选取后面的句子加入到句子b中,直到句子b的长度大于序列的最大长度。另外,由于句子b是随机生成的,所以current_chunk中句子a后面的句子并没有利用,所以将i回退到句子a的后面一个句子处。 如果使用句子a后面的句子作为句子b,那么句子a和句子b构成一个句子对。

如果句子a和句子b的长度之和大于max_num_tokens,那么每次选择一个较长的句子从前或者从后删除衣一个字符,直到满足最大长度的限制。

然后,根据句子a和b生成tokens和segment_ids数组。segment_ids表示当前token属于句子a还是b。tokens的句子a前面加入[CLS],句子a后面加[SEP],句子b后面加[SEP]。

并调用create_masked_lm_predictions生成masked_lm_positions和masked_lm_labels 。 即被mask的字符的位置和该位置的label(即被mask的字符是谁),构成一个Instance。instance 包括被mask后的序列,对应的segment_id序列,被mask的token在序列中的下标和label。

然后,再从上次遍历到的句子后面开始接着遍历,生成新的instance,直到遍历完document中的所有segment。

create_masked_lm_predictions

对句子进行mask,最后返回处理后的序列和被mask的token在序列中的下标和对应的实际token作为label。

遍历句子中的每个token, 生成候选token的下标。

如果是单独的一个token,则将下标加入候选列表中;如果是一个sub token(##开头),且执行Whole Word Masking(WWM)时,则将下标加入到候选列表中的最后一个元素中,表示和最后一个元素属于同一个token,需要作为一个整体进行mask。

比如候选下标列表如下: [[1],[2],[3,4]]

表示1,2是各自的token,3,4属于一个token。

然后,将候选token的下标列表打散,遍历候选列表,如果当前选中被mask的token数大于阈值,则跳出循环 如果加入当前下标集合后超过了单个序列(句子对)最大预估数量(被mask的token数量)的话则跳过这个word。

如果当前token中已经有subword被mask过,则跳过当前token。

剩下是合法候选的可以被mask的token。

对于每个token或者说token中的subword,80%的概率替换成[MASK],10%的概率不被替换,10%的概率随机替换。(原因是啥)。 然后,在序列中将该token更新为被mask后的值。 最后,将被mask的token的位置和label按照位置进行排序,并写入masked_lm_positions 和masked_lm_labels 返回。同时返回mask处理后的序列。

write_instance_to_example_files

最后,将instance 写入到文件中。

在写入文件时,进行如下操作: 1.将token序列通过词典转换为id序列; 2.进行padding操作,如果序列长度没有达到最大长度,则进行padding,包括token序列、mask序列和segment_id序列 3.生成被mask的token的下标序列、label序列、权重向量,其中label也通过词典转换为对应的id,每个被mask的token的权重都先置为0。 4.生成NSP任务的label。

modeling.py

BertConfig

hidden_size : embedding 大小

num_hidden_layers : Transformer 中encoder(attention)层数

intermediate_size : FFN 的隐层神经元个数

max_position_embeddings: 位置embedding的个数

type_vocab_size: 句子类型(第一句还是第二句)的个数,应该是2

BertModel

输入input_ids维度为[batch_size, seq_length],输出是[batch_size, seq_length,hidden_size]

首先获取输入序列的embedding,embedding_output 的维度是[batch_size, seq_length, emb_size], 然后加上token type embedding 和position embedding,这样输入embedding就有三部分组成,分别是token的embedding、token type的embedding和position embedding。

token type 表示token来自第一个句子还是第二个句子。每个token 都有一个type的embedding。相应的,每个token也都有一个position embedding。position embedding 和transformer中不同,这里使用一个可以学习的embedding_table来表示position的embedding, 设置一个position的最大长度max_position_embeddings,保证该值大于序列最大长度seq_length。序列中的每个元素的position分别是[0,1,…,seq_length-1] 最后,再对embedding进行layer norm 和dropout。

layer norm 是对batch 中的每个序列进行的,即相当于每个序列内部进行各自的归一化。

第二步,生成mask 矩阵,用于计算注意力权重。

create_attention_mask_from_input_mask

大意是 input_mask是[batch_size, to_seq_length], 由于self-attention中,序列中每个元素和其他所有元素(query 和 key)都会有一个attention score,所以对应的attention mask矩阵维度就是[batch_size, from_seq_length,to_seq_length], 那么把input_mask 由[batch_size, to_seq_length] 先变成[ batch_size, 1, to_seq_length],然后再沿第2个维度进行broadcast 即可得到维度为[batch_size, from_seq_length,to_seq_length]的attention mask。

第三步,经过若干层transformer,对输入进行编码

输入维度为[batch_size, seq_length,hidden_size],输出是[batch_size, seq_length,hidden_size],输出是一个列表,包含了每个encoder层的输出。

默认有12层encoder,12个multi-head,embedding 大小为768维,中间层大小3072,激活函数是gelu。隐层的dropout比例和attention score dropout的比例都是0.1。

每一层encoder的结构如下: 经过self-attention层之后,经过 线性映射、dropout、residual和layer norm 后,接一个FFN。

FFN 在代码中只有一个隐层,激活函数为intermediate_act_fn,隐层神经元个数为intermediate_size。然后接输出层,依然是 线性映射、dropout、residual和layer norm 四个步骤。输出层的神经元个数为hidden_size。

此时,得到了该层encoder 的输出, 并作为下一层encoder的输入,同时,将该层的输出记录到列表中。 最后,将所有层的输出返回。

self-attention

encoder中 如果from_tensor和to_tensor 是相同的,则是self-attention。 from_tensor经过线性映射后变成query(Q),to_tensor经过线性映射后变成key(K)、value(V),,Q/K/V的维度为[batch_size, seq_length, size_per_head]。然后Q、K 经过dot product 和scale,最后经过softmax 得到attention得分, 在scaled 之后 ,softmax 之前,需要对attention score 矩阵进行mask,保证被padding的部分的得分为0。

mask的逻辑是 对于参与attention计算的部分,mask为0,而被mask的位置,mask 值为-10000.0 而attention_mask的初始值中,需要attend 的值为1, mask部分为0, 所以进行了如下转换:

      adder = (1.0 - tf.cast(attention_mask, tf.float32)) * -10000.0

然后: attention_scores += adder

即attend的部分attention score不变,而被mask的部分被减去了-10000.0,这样在经过softmax时,被mask的部分得分就是0。 然后,对attention score矩阵进行dropout。

最后,使用attention 得分对value 进行加权,得到encoder层的输出

第四步,是pooling层 该层是对序列的三维输出进行pooling,得到二维输出,这个操作对segment-level或者segment-pair-level的分类任务是必须的,因为此时每个序列都必须有一个固定维度的表示。

transformer的输出维度是[batch_size, seq_length, hidden_size] pooling层是将transformer的输出 转换为[batch_size,hidden_size],在实现时比较简单粗暴,直接取第一个token的embedding 作为pooling层的输出,然后经过一个非线性层,激活函数为tanh。

整个bert模型有四个输出:

outputs = { “embedding_output”: model.get_embedding_output(), “sequence_output”: model.get_sequence_output(), “pooled_output”: model.get_pooled_output(), “all_encoder_layers”: model.get_all_encoder_layers(), }

embedding_output 是每个序列的embedding表示(token embedding+token type embedding+position embedding),维度是[batch_size, seq_length, hidden_size] sequence_output 是最后一层encoder层的输出,维度是[batch_size, seq_length, hidden_size] pooled_output 是pooling层的输出,维度是[batch_size,hidden_size]。 all_encoder_layers 是每一层encoder的输出,每一层的输出维度和sequence_output相同。

run_pretraining.py

基于bert 使用masked language modeling(MLM)分类和Next Sentence Prediction (NSP)两个任务进行预训练。

预训练的输入文件就是create_pretraining_data.py 生成的文件。 主要有两个函数: 1.model_fn_builder

负责构建模型,计算两个任务的损失。将两个任务的损失相加作为最终的损失函数。 如果是train模式,则还需要使用优化器对模型进行优化 如果是eval模式,则需要对测试集或验证集进行评估,并返回相应的评估指标。 评估时,两个任务分别计算准确率和损失。

get_masked_lm_output

获取 MLM 任务的输出,计算MLM损失

参数中,input_tensor 是bert的输出,维度为[batch_size, seq_length,emb_size] positions的维度是[batch_size,max_predictions_per_seq],表示被mask的token在序列中的位置。 首先根据positions从input_tensor中获取被masked的位置上的embedding输出。

如何根据position 拿到对应位置的输出呢?

a.获取每个序列在整体中的偏置(seq_id * seq_length ),记为flat_offsets。 生成序列[0,1,2,3,4,…,batch_size-1],然后乘以seq_length ,得到 [0, 1seq_length,2seq_length,…,(batch_size-1)seq_length],维度是[batch_size, 1] b.然后将positions和flat_offsets相加得到每个被masked的token在整体中的偏置,并reshape成长度为[batch_sizemax_predictions_per_seq]的列表,记为flat_positions c.同时,将bert的输出reshape成[batch_sizeseq_length,emb_size] d.最后,使用gather得到被masked的token的embedding,输出维度为[batch_sizemax_predictions_per_seq, emb_size]

然后,接一个隐层和layer norm ,最终输出还是[batch_size*max_predictions_per_seq, emb_size]。

最后,基于softmax计算每个位置上每个token的log概率,并计算损失。

在计算token的概率分布时,和一般的Word2vec不一样,这里并没有做负采样,而是计算整个vocabulary词表中的token的概率。 计算token的概率分布时,先通过当前token的模型输出和词表中的每个token的embedding计算内积,最后再加上每个token各自的bias,即得到当前被mask的位置的token的logits。最后经过softmax和log,得到log后的概率分布。 概率分布矩阵维度[batch_size*max_predictions_per_seq,vocab_size]。

最后,考虑每个被mask的token的权重,计算交叉熵损失。

get_next_sentence_output

计算NSP 损失,其实是一个二分类交叉熵损失。

这个任务比较简单,bert pooling层的输出作为句子的表示。使用一个二分类的softmax计算句子对中的第二个句子是下一个句子和不是下一个句子的概率。然后计算交叉熵损失

2.input_fn_builder 流式输入 segment_ids 表示句子是第一个句子还是第二个句子

微调

run_classifier.py

使用四个数据集在bert预训练模型基础上进行句子分类任务。

其实,可以先不要关注processor内部的逻辑。先关注整体流程。

构建模型结构 create_model
使用bert_config构建bert模型,这里只是一个句子分类任务,所以使用bert的pooled_output,即序列中第一个token的输出,先对pooled_output进行dropout,后面直接接一个wx+b, 然后使用softmax得到每个类的预估概率,并计算交叉熵损失

数据处理

file_based_convert_examples_to_features

先将数据集中的每个样本转换成features(一个kv结构),然后转换成tf.train.Example对象,并写入到文件中

convert_single_example

将数据集中的每个样本转换成InputFeatures对象

InputFeatures定义如下:

class InputFeatures(object):
  """A single set of features of data."""

  def __init__(self,
               input_ids,
               input_mask,
               segment_ids,
               label_id,
               is_real_example=True):
    self.input_ids = input_ids
    self.input_mask = input_mask
    self.segment_ids = segment_ids
    self.label_id = label_id
    self.is_real_example = is_real_example

首先,如果当前样本是PaddingInputExample,则使用默认值填充出来一个InputFeatures对象。 PaddingInputExample 应该是为了将一个batch凑够batch_size而使用的对象,类似于一个占位符。

遍历每一个样本,先对每个样本中的text_a和text_b(如果存在的话)进行分词,当存在句子对时, 如果句子对长度大于限制,则对句子对进行截取,直到总长度满足限制。

如果有tokens_b,考虑[CLS], [SEP], [SEP]这三个符号,保证截取后的句对长度最大为max_seq_length - 3。 如果没有tokens_b,那么只截取tokens_a,保证tokens_a最大长度为max_seq_length - 2。

截取逻辑: 每次从更长的那个句子中去掉最后一个字符,直到满足长度限制。如果是单个句子且长度超过限制,则直接截取到最大长度限制即可。

然后,将样本中的相关信息存储到feature对象中。

具体地,根据分词后的序列,生成tokens、segment_ids序列。然后将tokens根据字典转换为id序列input_ids。

tokens 第一个字符是CLS,segment_ids第一个值为0,表示来自第一个句子。 第一个句子最后一个字符是SEP, 对应的segment_ids中的元素为0. 如果有tokens_b,那么在tokens_b的最后也加上一个SEP,同时segment_ids 也加上一个1,表示来自第二个句子。

同时,生成mask向量input_mask,并对input_ids、input_mask、segment_ids进行padding(全部补0),并存储到feature对象中。

最后,将feature对象写入到tfrecord中。

下面这段注释很重要 The convention in BERT is: (a) For sequence pairs: tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP] type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1 (b) For single sequences: tokens: [CLS] the dog is hairy . [SEP] type_ids: 0 0 0 0 0 0 0

Where “type_ids” are used to indicate whether this is the first sequence or the second sequence. The embedding vectors for type=0 and type=1 were learned during pre-training and are added to the wordpiece embedding vector (and position vector). This is not strictly necessary since the [SEP] token unambiguously separates the sequences, but it makes it easier for the model to learn the concept of sequences.

For classification tasks, the first vector (corresponding to [CLS]) is used as the “sentence vector”. Note that this only makes sense because the entire model is fine-tuned.

对应下面这段代码:


 tokens = []
  segment_ids = []
  tokens.append("[CLS]")
  segment_ids.append(0)
  for token in tokens_a:
    tokens.append(token)
    segment_ids.append(0)
  tokens.append("[SEP]")
  segment_ids.append(0)

  if tokens_b:
    for token in tokens_b:
      tokens.append(token)
      segment_ids.append(1)
    tokens.append("[SEP]")
    segment_ids.append(1)

构建模型输入:

file_based_input_fn_builder 流式解析输入文件,传递给Estimator

模型的输入格式包括如下key:

 name_to_features = {
      "input_ids": tf.FixedLenFeature([seq_length], tf.int64),
      "input_mask": tf.FixedLenFeature([seq_length], tf.int64),
      "segment_ids": tf.FixedLenFeature([seq_length], tf.int64),
      "label_ids": tf.FixedLenFeature([], tf.int64),
      "is_real_example": tf.FixedLenFeature([], tf.int64),
  }

下面介绍一下四个数据集,分别对应一个processor。

ColaProcessor MnliProcessor MrpcProcessor XnliProcessor

1.XnliProcessor

XNLI数据集是Facebook AI研究院(FAIR)和纽约大学研究团队的一项合作研究成果,它提供了一种用于评估跨语句表征的数据集。 跨语言语句理解(XLU,cross-lingual Language Understanding)主要使用一种语言去训练模型,并在其它语言上评估模型。尽管一些 XLU 研究在跨语种文档分类上给出了较好的结果,但目前 XLU 对自然语言推理(NLI,Natural Language Inference)等一些更难以理解的任务依然缺少基准测试数据集。大规模 NLI,即文本蕴含识别(RTE,Recognizing Textual Entailment),业已成为评估语言理解的实际测试平台。在 RTE 中,系统读取两句话,并判定两者间的关系是否为“蕴含”(Entailment)、“矛盾”(Contradict)或“中性”(Neutral)。

数据集中的label 如下:”contradiction”, “entailment”, “neutral”

格式是前两个字段是一个句子对,第三个字段是label

2.MnliProcessor

MultiNLI dataset 和 XNLI 数据集格式相同,也是做RTE任务的一个数据集。

3.MrpcProcessor

MRPC 数据集,Microsoft Research Paraphrase Corpus(MRPC)语料库是释义识别的数据集,其中系统旨在识别两个语句是否相互为释义句。或者说判断两个给定句子,是否具有相同的语义,属于句子对的文本二分类任务; 样本是一个句子对,label 是0,1

4.ColaProcessor

纽约大学发布的有关语法的数据集,该任务主要是对一个给定句子,判定其是否语法正确,因此CoLA属于单个句子的文本二分类任务; 样本是一个句子,label 是0或1

参考: http://www.xuwei.io/2018/11/30/%E6%96%87%E6%9C%AC%E5%88%86%E7%B1%BB-glue%E6%95%B0%E6%8D%AE%E9%9B%86%E4%BB%8B%E7%BB%8D/

MRPC

下载数据集: 使用https://gist.github.com/W4ngatang/60c2bdb54d156a41194446737ce03e2e 这里的代码并没有下载成功。

参考《https://www.jianshu.com/p/3d0bb34c488a》 这里的方法下载成功

extract_features.py

这个文件是从bert 预训练的模型中获取token的embedding。

具体来说,即给定一个样本,使用预训练模型预测 该样本,可以得到该样本每个token对应的所有层的embedding。

首先,读取文件,转换为如下格式

      InputExample(unique_id=unique_id, text_a=text_a, text_b=text_b))

然后,将上述格式的example转换为feature对象。 处理过程和run_classifier.py中一样,对句子先分词,然后保证句子长度小于最大长度限制,然后生成input_ids/input_mask/input_type_ids/input_type_ids向量,并存储到InputFeatures中。

模型输入: InputFeatures( unique_id=example.unique_id, tokens=tokens, input_ids=input_ids, input_mask=input_mask, input_type_ids=input_type_ids)

然后获取样本每一层的输出,并记录下来,最后写入到文件中。

运行方法如下:

Sentence A and Sentence B are separated by the ||| delimiter for sentence pair tasks like question answering and entailment. For single sentence inputs, put one sentence per line and DON’T use the delimiter. echo ‘Who was Jim Henson ? ||| Jim Henson was a puppeteer’ > /tmp/input.txt

python extract_features.py
–input_file=/tmp/input.txt
–output_file=/tmp/output.jsonl
–vocab_file=$BERT_BASE_DIR/vocab.txt
–bert_config_file=$BERT_BASE_DIR/bert_config.json
–init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt
–layers=-1,-2,-3,-4
–max_seq_length=128
–batch_size=8

辅助类

tokenization.py

FullTokenizer

vocab : token 到index 的映射 inv_vocab : index 到token的映射

综合上面两种基础分词器进行分词 BasicTokenizer 划分出每个token,WordpieceTokenizer 对每个token 划分成多个sub-token

最终,分词结果就是sub token 粒度的。

BasicTokenizer

这个分词器 完成如下功能: 1.去除无意义的字符,一些控制字符,将空白字符(\r\t\n)替换成空格 该功能由_clean_text 函数完成

2.在中文字符前后加入空格 由_tokenize_chinese_chars 函数完成

3.使用空格进行分词,同时,根据do_lower_case标示决定是否将大写字符变成小写

4.最后,去除accent,并使用标点进行切分

accent 这个参考文档中解释比较详细

_run_split_on_punc 根据标点进一步切分。所有标点都作为单独的token。

WordpieceTokenizer

该分词器的输入是BasicTokenizer的输出结果

对于BasicTokenizer 切分出的每个单词, max_input_chars_per_word限制 word的最大长度,超过该限制则置为未知单词UNK

使用wordpiece算法进行char粒度的切分,是一种贪心的最大正向匹配算法

对于每个之前分出来的token,start 从0开始,end 从最后一个char开始,end 从后向前遍历,如果start到end之间的token在词汇表中,则该sub token 加入到结果列表中, 然后 start 从end 开始 ,end 还是从最后一个字符开始遍历。此时,start 不再是0, 所以subtoken 前面都加上##。

参考: http://fancyerii.github.io/2019/03/09/bert-codes/#fulltokenizer

optimization.py

create_optimizer

学习率使用了decay,具体计算如下:

global_step = min(global_step, decay_steps) decayed_learning_rate = (learning_rate - end_learning_rate) * (1 - global_step / decay_steps) ^ (power) + end_learning_rate

如果num_warmup_steps有值,则对lr进行预热。 具体逻辑是:

如果 global_step 小于num_warmup_steps 则使用预热阶段的lr。

预热阶段的lr=global_steps_float / warmup_steps_float * init_lr

即预热阶段 lr 随着step 增长从 init_lr 开始线性提高

然后,使用AdamWeightDecayOptimizer优化器进行优化。

AdamWeightDecayOptimizer

带有L2 weight decay 的Adam 优化器

adam计算如下:

\(m_t = \beta_1 * m_{t-1} + (1-\beta_1)*g \\ v_t = \beta_2 * v_{t-1} + (1-\beta_2)*g^2 \\ update = \frac{m_t}{\sqrt(v_t)+\epsilon}\) L2 decay计算如下:

\[update = update + weight\_decay\_rate * param\_t \\ update\_with\_lr = learning\_rate * update \\ param_{t+1} = param_t - update\_with\_lr\]

其中,l2 decay 时排除了LayerNorm 和bias 相关的参数。

对于adam优化器的权重衰减需要补充相关知识

参考:https://arxiv.org/pdf/1711.05101.pdf https://zhuanlan.zhihu.com/p/40814046

run_squad.py

没有看

Stanford Question Answering Dataset (SQuAD) 是一个阅读理解数据集,每个样本包含一个问题和答案,也有可能问题无法回答

create_model

拿到bert模型的sequence_output, 然后reshape成[batch_size * seq_length, hidden_size]

计算在每个位置上计算一个二分类,(wX+b),得到[batch_size, seq_length, 2] 的输出 然后转置成[2,batch_size, seq_length],然后拆成两个[batch_size, seq_length]的矩阵,

分别作为start_logits 和 end_logits 返回

然后,每个样本都有一个真实的start_position 和end_position, 分别和上面的logit计算交叉熵损失。

最后,取两个位置上的平均值作为最终损失,并进行优化。

看起来,像是预测 答案的开始位置和结束位置

read_squad_examples 从文件中读取样本Example convert_examples_to_features 将样本转换为特征(Feature)供模型使用

orig_to_tok_index:每个token 在整个序列中的位置

all_doc_tokens: 整个序列的sub token

tok_to_orig_index : 每个sub token 对应的token 在序列中的位置

由于每个token 会被划分成sub token,所以可以根据orig_to_tok_index 找到每个token的位置

宁雨 /
Published under (CC) BY-NC-SA in categories MachineLearning  tagged with
comments powered by Disqus