Bootstrap

一本读懂BERT

目录

一、什么是BERT?

二、BERT安装

三、预训练模型

四、运行Fine-Tuning

五、数据读取源码阅读

(一) DataProcessor

(二) MrpcProcessor

六、分词源码阅读

(一)FullTokenizer

(二) WordpieceTokenizer

七、run_classifier.py的main函数

八、BertModel类

九、自己进行Pretraining

十、性能测试

(一)关于max_seq_len对速度的影响

(二)client_batch_size对速度的影响

(三)num_client 对并发性和速度的影响


一、什么是BERT?

首先我们先看官方的介绍:

BERT is a method of pre-training language representations, meaning that we train a general-purpose "language understanding" model on a large text corpus (like Wikipedia), and then use that model for downstream NLP tasks that we care about (like question answering). BERT outperforms previous methods because it is the first unsuperviseddeeply bidirectional system for pre-training NLP.

划重点:the first unsuperviseddeeply bidirectional system for pre-training NLP.

无监督意味着BERT可以仅使用纯文本语料库进行训练,这是非常重要的特性,因为大量纯文本数据在网络上以多种语言公开。

(上面左图,红色的是ELMo,右二是BERT)

预训练方法可以粗略分为不联系上下文的词袋模型等和联系上下文的方法。其中联系上下文的方法可以进一步分为单向和双向联系上下文两种。诸如NNLM、Skip-Gram、 Glove等词袋模型,是一种单层Shallow模型,无法联系上下文;而LSTM、Transformer为典型的可以联系上下文的深层次网络模型。

BERT是在现有预训练工作的基础上对现有的技术的良好整合与一定的创新。现有的这些模型都是单向或浅双向的。每个单词仅使用左侧(或右侧)的单词进行语境化。例如,在句子中

I  have fallen in love with a girl.

单向表示love仅基于I  have fallen in 但不 基于with a girl。之前有一些模型也有可以联系上下文的,但仅以单层"shallow"的方式。BERT能联系上下文来表示“love” ----- I  have fallen in ... with a girl。是一种深层次、双向的深度神经网络模型。

使用BERT有两个阶段:预训练和微调。

Pre-training 硬件成本相当昂贵(4--16个云TPU需4天),但是每种语言都只需要训练一次(目前的模型主要为英语)。为节省计算资源,谷歌正在发布一些预先培训的模型。 

Fine-tuning 硬件成本相对较低。文中的实践可以在单个云TPU上(最多1小时)或者在GPU(几小时)复现出来。BERT的另一个重要方面是它可以适应许多类型的NLP任务:

  • 句子级别(例如,SST-2)
  • 句子对级别(例如,MultiNLI)
  • 单词级别(例如,NER)
  • 文本阅读(例如,SQuAD)

二、BERT安装

Google提供的BERT代码在这里,可以直接git clone下来。注意运行它需要Tensorflow 1.11及其以上的版本,低版本的Tensorflow不能运行。

三、预训练模型

由于从头开始(from scratch)训练需要巨大的计算资源,因此Google提供了预训练的模型(的checkpoint),目前包括英语、汉语和多语言3类模型:

Uncased的意思是在预处理的时候都变成了小写,而cased是保留大小写。

这么多版本应该如何选择呢?

如果我们处理的问题只包含英文,那么我们应该选择英语的版本(模型大效果好但是参数多训练慢而且需要更多内存/显存)。如果我们只处理中文,那么应该使用中文的版本。如果是其他语言就使用多语言的版本。

四、运行Fine-Tuning

对于大部分情况,不需要重新Pretraining。我们要做的只是根据具体的任务进行Fine-Tuning,因此我们首先介绍Fine-Tuning。这里我们已GLUE的MRPC为例子,我们首先需要下载预训练的模型然后解压,比如作者解压后的位置是:


 
 
  1. /home/chai/data/chinese_L- 12_H- 768_A- 12
  2. # 为了方便我们需要定义环境变量
  3. export BERT_BASE_DIR= /home/chai /data/chinese_L- 12_H- 768_A- 12

环境变量BERT_BASE_DIR是BERT Pretraining的目录,它包含如下内容:


 
 
  1. ~ /data/chinese_L -12_H -768_A -12$ ls -1
  2. bert_config.json
  3. bert_model.ckpt.data -00000- of -00001
  4. bert_model.ckpt.index
  5. bert_model.ckpt.meta
  6. vocab.txt

vocab.txt是模型的词典,这个文件会经常要用到,后面会讲到。

bert_config.json是BERT的配置(超参数),比如网络的层数,通常我们不需要修改,但是也会经常用到。

bert_model.ckpt*,这是预训练好的模型的checkpoint

Fine-Tuning模型的初始值就是来自于这些文件,然后根据不同的任务进行Fine-Tuning。

接下来我们需要下载GLUE数据,这可以使用这个脚本下载,可能需要代理才能下载。

但是大概率下载不下来,能下载的步骤也很麻烦,建议下载网盘的备份版本:
链接:https://pan.baidu.com/s/1-b4I3ocYhiuhu3bpSmCJ_Q
提取码:z6mk

假设下载后的位置是:


 
 
  1. /home/chai/data/glue_data
  2. # 同样为了方便,我们定义如下的环境变量
  3. export GLUE_DIR= /home/chai /data/glue_data

GLUE有很多任务,我们来看其中的MRPC任务。


 
 
  1. chai:~/data/glue_data/MRPC$ head test.tsv
  2. index #1 ID #2 ID #1 String #2 String
  3. 0 1089874 1089925 PCCW 's chief operating officer , Mike Butcher , and Alex Arena , the chief financial officer , will report directly to Mr So . Current Chief Operating Officer Mike Butcher and Group Chief Financial Officer Alex Arena will report to So .
  4. 1 3019446 3019327 The world 's two largest automakers said their U.S. sales declined more than predicted last month as a late summer sales frenzy caused more of an industry backlash than expected . Domestic sales at both GM and No. 2 Ford Motor Co. declined more than predicted as a late summer sales frenzy prompted a larger-than-expected industry backlash .

数据是tsv(tab分割)文件,每行有4个用Tab分割的字段,分别表示index,第一个句子的id,第二个句子的id,第一个句子,第二个句子。也就是输入两个句子,模型判断它们是否同一个意思(Paraphrase)。如果是测试数据,那么第一列就是index(无意义),如果是训练数据,那么第一列就是0或者1,其中0代表不同的意思而1代表相同意思。接下来就可以运行如下命令来进行Fine-Tuning了:


 
 
  1. python run_classifier.py \
  2. --task_name=MRPC \
  3. --do_train=true \
  4. --do_eval=true \
  5. --data_dir=$GLUE_DIR/MRPC \
  6. --vocab_file=$BERT_BASE_DIR/vocab.txt \
  7. --bert_config_file=$BERT_BASE_DIR/bert_config.json \
  8. --init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \
  9. --max_seq_length=128 \
  10. --train_batch_size=8 \
  11. --learning_rate=2e-5 \
  12. --num_train_epochs=3.0 \
  13. --output_dir=/tmp/mrpc_output/

这里简单的解释一下参数的含义,在后面的代码阅读里读者可以更加详细的了解其意义。

  • task_name 任务的名字,这里我们Fine-Tuning MRPC任务
  • do_train 是否训练,这里为True
  • do_eval 是否在训练结束后验证,这里为True
  • data_dir 训练数据目录,配置了环境变量后不需要修改,否则填入绝对路径
  • vocab_file BERT模型的词典
  • bert_config_file BERT模型的配置文件
  • init_checkpoint Fine-Tuning的初始化参数
  • max_seq_length Token序列的最大长度,这里是128
  • train_batch_size batch大小,对于普通8GB的GPU,最大batch大小只能是8,再大就会OOM
  • learning_rate
  • num_train_epochs 训练的epoch次数,根据任务进行调整
  • output_dir 训练得到的模型的存放目录

这里最常见的问题就是内存不够,通常我们的GPU只有8G作用的显存,因此对于小的模型(bert-base),我们最多使用batchsize=8,而如果要使用bert-large,那么batchsize只能设置成1。运行结束后可能得到类似如下的结果:


 
 
  1. ***** Eval results *****
  2. eval_accuracy = 0.845588
  3. eval_loss = 0.505248
  4. global_step = 343
  5. loss = 0.505248

这说明在验证集上的准确率是0.84左右。

五、数据读取源码阅读

(一) DataProcessor

我们首先来看数据是怎么读入的。这是一个抽象基类,定义了get_train_examples、get_dev_examples、get_test_examples和get_labels等4个需要子类实现的方法,另外提供了一个_read_tsv函数用于读取tsv文件。下面我们通过一个实现类MrpcProcessor来了解怎么实现这个抽象基类,如果读者想使用自己的数据,那么就需要自己实现一个新的子类。

(二) MrpcProcessor

对于MRPC任务,这里定义了MrpcProcessor来基础DataProcessor。我们来看其中的get_labels和get_train_examples,其余两个抽象方法是类似的。首先是get_labels,它非常简单,这任务只有两个label。


 
 
  1. def get_labels(self):
  2. return [ "0", "1"]

接下来是get_train_examples:


 
 
  1. def get_train_examples(self, data_dir):
  2. return self._create_examples(
  3. self._read_tsv(os.path.join(data_dir, "train.tsv")), "train")

这个函数首先使用_read_tsv读入训练文件train.tsv,然后使用_create_examples函数把每一行变成一个InputExample对象。


 
 
  1. def _create_examples(self, lines, set_type):
  2. examples = []
  3. for (i, line) in enumerate(lines):
  4. if i == 0:
  5. continue
  6. guid = "%s-%s" % (set_type, i)
  7. text_a = tokenization.convert_to_unicode(line[ 3])
  8. text_b = tokenization.convert_to_unicode(line[ 4])
  9. if set_type == "test":
  10. label = "0"
  11. else:
  12. label = tokenization.convert_to_unicode(line[ 0])
  13. examples.append(
  14. InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))
  15. return examples

代码非常简单,line是一个list,line[3]和line[4]分别代表两个句子,如果是训练集合和验证集合,那么第一列line[0]就是真正的label,而如果是测试集合,label就没有意义,随便赋值成”0”。然后对于所有的字符串都使用tokenization.convert_to_unicode把字符串变成unicode的字符串。这是为了兼容Python2和Python3,因为Python3的str就是unicode,而Python2的str其实是bytearray,Python2却有一个专门的unicode类型。感兴趣的读者可以参考其实现,不感兴趣的可以忽略。

最终构造出一个InputExample对象来,它有4个属性:guid、text_a、text_b和label,guid只是个唯一的id而已。text_a代表第一个句子,text_b代表第二个句子,第二个句子可以为None,label代表分类标签。

六、分词源码阅读

分词是我们需要重点关注的代码,因为如果想要把BERT产品化,我们需要使用Tensorflow Serving,Tensorflow Serving的输入是Tensor,把原始输入变成Tensor一般需要在Client端完成。BERT的分词是Python的代码,如果我们使用其它语言的gRPC Client,那么需要用其它语言实现同样的分词算法,否则预测时会出现问题。

这部分代码需要读者有Unicode的基础知识,了解什么是CodePoint,什么是Unicode Block。Python2和Python3的str有什么区别,Python2的unicode类等价于Python3的str等等。不熟悉的读者可以参考一些资料。

(一)FullTokenizer

BERT里分词主要是由FullTokenizer类来实现的。


 
 
  1. class FullTokenizer(object):
  2. def __init__(self, vocab_file, do_lower_case=True):
  3. self.vocab = load_vocab(vocab_file)
  4. self.basic_tokenizer = BasicTokenizer(do_lower_case=do_lower_case)
  5. self.wordpiece_tokenizer = WordpieceTokenizer(vocab= self.vocab)
  6. def tokenize(self, text):
  7. split_tokens = []
  8. for token in self.basic_tokenizer.tokenize(text):
  9. for sub_token in self.wordpiece_tokenizer.tokenize(token):
  10. split_tokens.append(sub_token)
  11. return split_tokens
  12. def convert_tokens_to_ids(self, tokens):
  13. return convert_tokens_to_ids( self.vocab, tokens)

FullTokenizer的构造函数需要传入参数词典vocab_file和do_lower_case。如果我们自己从头开始训练模型(后面会介绍),那么do_lower_case决定了我们的某些是否区分大小写。如果我们只是Fine-Tuning,那么这个参数需要与模型一致,比如模型是chinese_L-12_H-768_A-12,那么do_lower_case就必须为True。

函数首先调用load_vocab加载词典,建立词到id的映射关系。下面是文件chinese_L-12_H-768_A-12/vocab.txt的部分内容


 
 
  1. the
  2. of
  3. and
  4. in
  5. to

接下来是构造BasicTokenizer和WordpieceTokenizer。前者是根据空格等进行普通的分词,而后者会把前者的结果再细粒度的切分为WordPiece。

tokenize函数实现分词,它先调用BasicTokenizer进行分词,接着调用WordpieceTokenizer把前者的结果再做细粒度切分。下面我们来详细阅读这两个类的代码。我们首先来看BasicTokenizer的tokenize方法。


 
 
  1. def tokenize(self, text):
  2. text = convert_to_unicode(text)
  3. text = self._clean_text(text)
  4. # 这是2018年11月1日为了支持多语言和中文增加的代码。这个代码也可以用于英语模型,因为在
  5. # 英语的训练数据中基本不会出现中文字符(但是某些wiki里偶尔也可能出现中文)。
  6. text = self._tokenize_chinese_chars(text)
  7. orig_tokens = whitespace_tokenize(text)
  8. split_tokens = []
  9. for token in orig_tokens:
  10. if self. do_lower_case:
  11. token = token.lower()
  12. token = self._run_strip_accents(token)
  13. split_tokens.extend( self._run_split_on_punc(token))
  14. output_tokens = whitespace_tokenize( " ".join(split_tokens))
  15. return output_tokens

首先是用convert_to_unicode把输入变成unicode,这个函数前面也介绍过了。接下来是_clean_text函数,它的作用是去除一些无意义的字符。


 
 
  1. def _clean_text(self, text):
  2. """去除一些无意义的字符以及whitespace"""
  3. output = []
  4. for char in text:
  5. cp = ord(char)
  6. if cp == 0 or cp == 0xfffd or _is_control(char):
  7. continue
  8. if _is_whitespace(char):
  9. output.append( " ")
  10. else:
  11. output.append(char)
  12. return "".join(output)

codepoint为0的是无意义的字符,0xfffd(U+FFFD)显示为�,通常用于替换未知的字符。_is_control用于判断一个字符是否是控制字符(control character),所谓的控制字符就是用于控制屏幕的显示,比如\n告诉(控制)屏幕把光标移到下一行的开始。读者可以参考这里


 
 
  1. def _is_control(char):
  2. """检查字符char是否是控制字符"""
  3. # 回车换行和tab理论上是控制字符,但是这里我们把它认为是whitespace而不是控制字符
  4. if char == "\t" or char == "\n" or char == "\r":
  5. return False
  6. cat = unicodedata.category(char)
  7. if cat.startswith( "C"):
  8. return True
  9. return False

这里使用了unicodedata.category这个函数,它返回这个Unicode字符的Category,这里C开头的都被认为是控制字符,读者可以参考这里

接下来是调用_is_whitespace函数,把whitespace变成空格。


 
 
  1. def _is_whitespace(char):
  2. """Checks whether `chars` is a whitespace character."""
  3. # \t, \n, and \r are technically contorl characters but we treat them
  4. # as whitespace since they are generally considered as such.
  5. if char == " " or char == "\t" or char == "\n" or char == "\r":
  6. return True
  7. cat = unicodedata.category(char)
  8. if cat == "Zs":
  9. return True
  10. return False

这里把category为Zs的字符以及空格、tab、换行和回车当成whitespace。然后是_tokenize_chinese_chars,用于切分中文,这里的中文分词很简单,就是切分成一个一个的汉字。也就是在中文字符的前后加上空格,这样后续的分词流程会把没一个字符当成一个词。


 
 
  1. def _tokenize_chinese_chars(self, text):
  2. output = []
  3. for char in text:
  4. cp = ord( char)
  5. if self._is_chinese_char(cp):
  6. output.append( " ")
  7. output.append( char)
  8. output.append( " ")
  9. else:
  10. output.append( char)
  11. return "". join(output)

这里的关键是调用_is_chinese_char函数,这个函数用于判断一个unicode字符是否中文字符。


 
 
  1. def _is_chinese_char(self, cp):
  2. if ((cp >= 0x4E00 and cp <= 0x9FFF) or #
  3. (cp >= 0x3400 and cp <= 0x4DBF) or #
  4. (cp >= 0x20000 and cp <= 0x2A6DF) or #
  5. (cp >= 0x2A700 and cp <= 0x2B73F) or #
  6. (cp >= 0x2B740 and cp <= 0x2B81F) or #
  7. (cp >= 0x2B820 and cp <= 0x2CEAF) or
  8. (cp >= 0xF900 and cp <= 0xFAFF) or #
  9. (cp >= 0x2F800 and cp <= 0x2FA1F)): #
  10. return True
  11. return False

很多网上的判断汉字的正则表达式都只包括4E00-9FA5,但这是不全的,比如  就不再这个范围内。读者可以参考这里

接下来是使用whitespace进行分词,这是通过函数whitespace_tokenize来实现的。它直接调用split函数来实现分词。Python里whitespace包括’\t\n\x0b\x0c\r ‘。然后遍历每一个词,如果需要变成小写,那么先用lower()函数变成小写,接着调用_run_strip_accents函数去除accent。它的代码为:


 
 
  1. def _run_strip_accents(self, text):
  2. text = unicodedata.normalize( "NFD", text)
  3. output = []
  4. for char in text:
  5. cat = unicodedata.category( char)
  6. if cat == "Mn":
  7. continue
  8. output.append( char)
  9. return "". join(output)

它首先调用unicodedata.normalize(“NFD”, text)对text进行归一化。这个函数有什么作用呢?我们先看一下下面的代码:


 
 
  1. >>> s1 = 'café'
  2. >>> s2 = 'cafe\u0301'
  3. >>> s1, s2
  4. ( 'café', 'café')
  5. >>> len(s1), len(s2)
  6. ( 4, 5)
  7. >>> s1 == s2
  8. False

我们”看到”的é其实可以有两种表示方法,一是用一个codepoint直接表示”é”,另外一种是用”e”再加上特殊的codepoint U+0301两个字符来表示。U+0301是COMBINING ACUTE ACCENT,它跟在e之后就变成了”é”。类似的”a\u0301”显示出来就是”á”。注意:这只是打印出来一模一样而已,但是在计算机内部的表示它们完全不同的,前者é是一个codepoint,值为0xe9,而后者是两个codepoint,分别是0x65和0x301。unicodedata.normalize(“NFD”, text)就会把0xe9变成0x65和0x301,比如下面的测试代码。

接下来遍历每一个codepoint,把category为Mn的去掉,比如前面的U+0301,COMBINING ACUTE ACCENT就会被去掉。category为Mn的所有Unicode字符完整列表在这里


 
 
  1. s = unicodedata.normalize( "NFD", "é")
  2. for c in s:
  3. print( "%#x" %(ord( c)))
  4. # 输出为:
  5. 0x65
  6. 0x301

处理完大小写和accent之后得到的Token通过函数_run_split_on_punc再次用标点切分。这个函数会对输入字符串用标点进行切分,返回一个list,list的每一个元素都是一个char。比如输入he’s,则输出是[[h,e], [’],[s]]。代码很简单,这里就不赘述。里面它会调用函数_is_punctuation来判断一个字符是否标点。


 
 
  1. def _is_punctuation(char):
  2. cp = ord(char)
  3. # 我们把ASCII里非字母数字都当成标点。
  4. # 在Unicode的category定义里, "^", "$", and "`" 等都不是标点,但是我们这里都认为是标点。
  5. if ((cp >= 33 and cp <= 47) or (cp >= 58 and cp <= 64) or
  6. (cp >= 91 and cp <= 96) or (cp >= 123 and cp <= 126)):
  7. return True
  8. cat = unicodedata.category(char)
  9. # category是P开头的都是标点,参考https://en.wikipedia.org/wiki/Unicode_character_property
  10. if cat.startswith( "P"):
  11. return True
  12. return False

(二) WordpieceTokenizer

WordpieceTokenizer的作用是把词再切分成更细粒度的WordPiece。关于WordPiece(Byte Pair Encoding)我们之前在机器翻译部分已经介绍过了,它是一种解决OOV问题的方法,如果不管细节,我们把它看成比词更小的基本单位就行。对于中文来说,WordpieceTokenizer什么也不干,因为之前的分词已经是基于字符的了。有兴趣的读者可以参考这个开源项目。一般情况我们不需要自己重新生成WordPiece,使用BERT模型里自带的就行。

WordpieceTokenizer的代码为:


 
 
  1. def tokenize(self, text):
  2. # 把一段文字切分成word piece。这其实是贪心的最大正向匹配算法。
  3. # 比如:
  4. # input = "unaffable"
  5. # output = ["un", "##aff", "##able"]
  6. text = convert_to_unicode( text)
  7. output_tokens = []
  8. for token in whitespace_tokenize( text):
  9. chars = list(token)
  10. if len(chars) > self.max_input_chars_per_word:
  11. output_tokens.append(self.unk_token)
  12. continue
  13. is_bad = False
  14. start = 0
  15. sub_tokens = []
  16. while start < len(chars):
  17. end = len(chars)
  18. cur_substr = None
  19. while start < end:
  20. substr = "". join(chars[start: end])
  21. if start > 0:
  22. substr = "##" + substr
  23. if substr in self.vocab:
  24. cur_substr = substr
  25. break
  26. end -= 1
  27. if cur_substr is None:
  28. is_bad = True
  29. break
  30. sub_tokens.append(cur_substr)
  31. start = end
  32. if is_bad:
  33. output_tokens.append(self.unk_token)
  34. else:
  35. output_tokens.extend(sub_tokens)
  36. return output_tokens

代码有点长,但是很简单,就是贪心的最大正向匹配。其实为了加速,是可以把词典加载到一个Double Array Trie里的。我们用一个例子来看代码的执行过程。比如假设输入是”unaffable”。我们跳到while循环部分,这是start=0,end=len(chars)=9,也就是先看看unaffable在不在词典里,如果在,那么直接作为一个WordPiece,如果不再,那么end-=1,也就是看unaffabl在不在词典里,最终发现”un”在词典里,把un加到结果里。

接着start=2,看affable在不在,不在再看affabl,…,最后发现 ##aff 在词典里。注意:##表示这个词是接着前面的,这样使得WordPiece切分是可逆的——我们可以恢复出“真正”的词。

七、run_classifier.py的main函数

main函数的主要代码为:


 
 
  1. main()
  2. bert_config = modeling.BertConfig.from_json_file(FLAGS.bert_config_file)
  3. task_name = FLAGS.task_name.lower()
  4. processor = processors[task_name]()
  5. label_list = processor.get_labels()
  6. tokenizer = tokenization.FullTokenizer(
  7. vocab_file=FLAGS.vocab_file, do_lower_case=FLAGS.do_lower_case)
  8. run_config = tf.contrib.tpu.RunConfig(
  9. cluster=tpu_cluster_resolver,
  10. master=FLAGS.master,
  11. model_dir=FLAGS.output_dir,
  12. save_checkpoints_steps=FLAGS.save_checkpoints_steps,
  13. tpu_config=tf.contrib.tpu.TPUConfig(
  14. iterations_per_loop=FLAGS.iterations_per_loop,
  15. num_shards=FLAGS.num_tpu_cores,
  16. per_host_input_for_training=is_per_host))
  17. train_examples = None
  18. num_train_steps = None
  19. num_warmup_steps = None
  20. if FLAGS.do_train:
  21. train_examples = processor.get_train_examples(FLAGS.data_dir)
  22. num_train_steps = int(
  23. len(train_examples) / FLAGS.train_batch_size * FLAGS.num_train_epochs)
  24. num_warmup_steps = int(num_train_steps * FLAGS.warmup_proportion)
  25. model_fn = model_fn_builder(
  26. bert_config=bert_config,
  27. num_labels= len(label_list),
  28. init_checkpoint=FLAGS.init_checkpoint,
  29. learning_rate=FLAGS.learning_rate,
  30. num_train_steps=num_train_steps,
  31. num_warmup_steps=num_warmup_steps,
  32. use_tpu=FLAGS.use_tpu,
  33. use_one_hot_embeddings=FLAGS.use_tpu)
  34. # 如果没有TPU,那么会使用GPU或者CPU
  35. estimator = tf.contrib.tpu.TPUEstimator(
  36. use_tpu=FLAGS.use_tpu,
  37. model_fn=model_fn,
  38. config=run_config,
  39. train_batch_size=FLAGS.train_batch_size,
  40. eval_batch_size=FLAGS.eval_batch_size,
  41. predict_batch_size=FLAGS.predict_batch_size)
  42. if FLAGS.do_train:
  43. train_file = os.path. join(FLAGS.output_dir, "train.tf_record")
  44. file_based_convert_examples_to_features(
  45. train_examples, label_list, FLAGS.max_seq_length, tokenizer, train_file)
  46. train_input_fn = file_based_input_fn_builder(
  47. input_file=train_file,
  48. seq_length=FLAGS.max_seq_length,
  49. is_training= True,
  50. drop_remainder= True)
  51. estimator.train(input_fn=train_input_fn, max_steps=num_train_steps)
  52. if FLAGS.do_eval:
  53. eval_examples = processor.get_dev_examples(FLAGS.data_dir)
  54. eval_file = os.path. join(FLAGS.output_dir, "eval.tf_record")
  55. file_based_convert_examples_to_features(
  56. eval_examples, label_list, FLAGS.max_seq_length, tokenizer, eval_file)
  57. # This tells the estimator to run through the entire set.
  58. eval_steps = None
  59. eval_drop_remainder = True if FLAGS.use_tpu else False
  60. eval_input_fn = file_based_input_fn_builder(
  61. input_file=eval_file,
  62. seq_length=FLAGS.max_seq_length,
  63. is_training= False,
  64. drop_remainder=eval_drop_remainder)
  65. result = estimator.evaluate(input_fn=eval_input_fn, steps=eval_steps)
  66. if FLAGS.do_predict:
  67. predict_examples = processor.get_test_examples(FLAGS.data_dir)
  68. predict_file = os.path. join(FLAGS.output_dir, "predict.tf_record")
  69. file_based_convert_examples_to_features(predict_examples, label_list,
  70. FLAGS.max_seq_length, tokenizer, predict_file)
  71. predict_drop_remainder = True if FLAGS.use_tpu else False
  72. predict_input_fn = file_based_input_fn_builder(
  73. input_file=predict_file,
  74. seq_length=FLAGS.max_seq_length,
  75. is_training= False,
  76. drop_remainder=predict_drop_remainder)
  77. result = estimator.predict(input_fn=predict_input_fn)

这里使用的是Tensorflow的Estimator API,这里只介绍训练部分的代码。

首先是通过file_based_convert_examples_to_features函数把输入的tsv文件变成TFRecord文件,便于Tensorflow处理。


 
 
  1. train_file = os.path.join(FLAGS.output_dir, "train.tf_record")
  2. file_based_convert_examples_to_features(
  3. train_examples, label_list, FLAGS.max_seq_length, tokenizer, train_file)
  4. def file_based_convert_examples_to_features(
  5. examples, label_list, max_seq_length, tokenizer, output_file):
  6. writer = tf.python_io.TFRecordWriter(output_file)
  7. for (ex_index, example) in enumerate(examples):
  8. feature = convert_single_example(ex_index, example, label_list,
  9. max_seq_length, tokenizer)
  10. def create_int_feature(values):
  11. f = tf.train.Feature(int64_list=tf.train.Int64List(value=list(values)))
  12. return f
  13. features = collections.OrderedDict()
  14. features[ "input_ids"] = create_int_feature(feature.input_ids)
  15. features[ "input_mask"] = create_int_feature(feature.input_mask)
  16. features[ "segment_ids"] = create_int_feature(feature.segment_ids)
  17. features[ "label_ids"] = create_int_feature([feature.label_id])
  18. tf_example = tf.train.Example(features=tf.train.Features(feature=features))
  19. writer.write(tf_example.SerializeToString())

file_based_convert_examples_to_features函数遍历每一个example(InputExample类的对象)。然后使用convert_single_example函数把每个InputExample对象变成InputFeature。InputFeature就是一个存放特征的对象,它包括input_ids、input_mask、segment_ids和label_id,这4个属性除了label_id是一个int之外,其它都是int的列表,因此使用create_int_feature函数把它变成tf.train.Feature,而label_id需要构造一个只有一个元素的列表,最后构造tf.train.Example对象,然后写到TFRecord文件里。后面Estimator的input_fn会用到它。

这里的最关键是convert_single_example函数,读懂了它就真正明白BERT把输入表示成向量的过程,所以请读者仔细阅读代码和其中的注释。


 
 
  1. def convert_single_example(ex_index, example, label_list, max_seq_length,
  2. tokenizer):
  3. """把一个`InputExample`对象变成`InputFeatures`."""
  4. # label_map把label变成id,这个函数每个example都需要执行一次,其实是可以优化的。
  5. # 只需要在可以再外面执行一次传入即可。
  6. label_map = {}
  7. for (i, label) in enumerate(label_list):
  8. label_map[label] = i
  9. tokens_a = tokenizer.tokenize(example.text_a)
  10. tokens_b = None
  11. if example.text_b:
  12. tokens_b = tokenizer.tokenize(example.text_b)
  13. if tokens_b:
  14. # 如果有b,那么需要保留3个特殊Token[CLS], [SEP]和[SEP]
  15. # 如果两个序列加起来太长,就需要去掉一些。
  16. _truncate_seq_pair(tokens_a, tokens_b, max_seq_length - 3)
  17. else:
  18. # 没有b则只需要保留[CLS]和[SEP]两个特殊字符
  19. # 如果Token太多,就直接截取掉后面的部分。
  20. if len(tokens_a) > max_seq_length - 2:
  21. tokens_a = tokens_a[ 0:(max_seq_length - 2)]
  22. # BERT的约定是:
  23. # (a) 对于两个序列:
  24. # tokens: [CLS] is this jack ##son ##ville ? [SEP] no it is not . [SEP]
  25. # type_ids: 0 0 0 0 0 0 0 0 1 1 1 1 1 1
  26. # (b) 对于一个序列:
  27. # tokens: [CLS] the dog is hairy . [SEP]
  28. # type_ids: 0 0 0 0 0 0 0
  29. #
  30. # 这里"type_ids"用于区分一个Token是来自第一个还是第二个序列
  31. # 对于type=0和type=1,模型会学习出两个Embedding向量。
  32. # 虽然理论上这是不必要的,因为[SEP]隐式的确定了它们的边界。
  33. # 但是实际加上type后,模型能够更加容易的知道这个词属于那个序列。
  34. #
  35. # 对于分类任务,[CLS]对应的向量可以被看成 "sentence vector"
  36. # 注意:一定需要Fine-Tuning之后才有意义
  37. tokens = []
  38. segment_ids = []
  39. tokens.append( "[CLS]")
  40. segment_ids.append( 0)
  41. for token in tokens_a:
  42. tokens.append(token)
  43. segment_ids.append( 0)
  44. tokens.append( "[SEP]")
  45. segment_ids.append( 0)
  46. if tokens_b:
  47. for token in tokens_b:
  48. tokens.append(token)
  49. segment_ids.append( 1)
  50. tokens.append( "[SEP]")
  51. segment_ids.append( 1)
  52. input_ids = tokenizer.convert_tokens_to_ids(tokens)
  53. # mask是1表示是"真正"的Token,0则是Padding出来的。在后面的Attention时会通过tricky的技巧让
  54. # 模型不能attend to这些padding出来的Token上。
  55. input_mask = [ 1] * len(input_ids)
  56. # padding使得序列长度正好等于max_seq_length
  57. while len(input_ids) < max_seq_length:
  58. input_ids.append( 0)
  59. input_mask.append( 0)
  60. segment_ids.append( 0)
  61. label_id = label_map[example.label]
  62. feature = InputFeatures(
  63. input_ids=input_ids,
  64. input_mask=input_mask,
  65. segment_ids=segment_ids,
  66. label_id=label_id)
  67. return feature

如果两个Token序列的长度太长,那么需要去掉一些,这会用到_truncate_seq_pair函数:


 
 
  1. def _truncate_seq_pair(tokens_a, tokens_b, max_length):
  2. while True:
  3. total_length = len(tokens_a) + len(tokens_b)
  4. if total_length <= max_length:
  5. break
  6. if len(tokens_a) > len(tokens_b):
  7. tokens_a.pop()
  8. else:
  9. tokens_b.pop()

这个函数很简单,如果两个序列的长度小于max_length,那么不用truncate,否则在tokens_a和tokens_b中选择长的那个序列来pop掉最后面的那个Token,这样的结果是使得两个Token序列一样长(或者最多a比b多一个Token)。对于Estimator API来说,最重要的是实现model_fn和input_fn。我们先看input_fn,它是由file_based_input_fn_builder构造出来的。代码如下:


 
 
  1. def file_based_input_fn_builder(input_file, seq_length, is_training,
  2. drop_remainder):
  3. name_to_features = {
  4. "input_ids": tf.FixedLenFeature([seq_length], tf.int64),
  5. "input_mask": tf.FixedLenFeature([seq_length], tf.int64),
  6. "segment_ids": tf.FixedLenFeature([seq_length], tf.int64),
  7. "label_ids": tf.FixedLenFeature([], tf.int64),
  8. }
  9. def _decode_record(record, name_to_features):
  10. # 把record decode成TensorFlow example.
  11. example = tf.parse_single_example(record, name_to_features)
  12. # tf.Example只支持tf.int64,但是TPU只支持tf.int32.
  13. # 因此我们把所有的int64变成int32.
  14. for name in list(example.keys()):
  15. t = example[name]
  16. if t.dtype == tf.int64:
  17. t = tf.to_int32(t)
  18. example[name] = t
  19. return example
  20. def input_fn(params):
  21. batch_size = params[ "batch_size"]
  22. # 对于训练来说,我们会重复的读取和shuffling
  23. # 对于验证和测试,我们不需要shuffling和并行读取。
  24. d = tf.data.TFRecordDataset(input_file)
  25. if is_training:
  26. d = d.repeat()
  27. d = d.shuffle(buffer_size= 100)
  28. d = d.apply(
  29. tf.contrib.data.map_and_batch(
  30. lambda record: _decode_record(record, name_to_features),
  31. batch_size=batch_size,
  32. drop_remainder=drop_remainder))
  33. return d
  34. return input_fn

这个函数返回一个函数input_fn。这个input_fn函数首先从文件得到TFRecordDataset,然后根据是否训练来shuffle和重复读取。然后用applay函数对每一个TFRecord进行map_and_batch,调用_decode_record函数对record进行parsing。从而把TFRecord的一条Record变成tf.Example对象,这个对象包括了input_ids等4个用于训练的Tensor。

接下来是model_fn_builder,它用于构造Estimator使用的model_fn。下面是它的主要代码(一些无关的log和TPU相关代码去掉了):


 
 
  1. def model_fn_builder(bert_config, num_labels, init_checkpoint, learning_rate,
  2. num_train_steps, num_warmup_steps, use_tpu,
  3. use_one_hot_embeddings):
  4. # 注意:在model_fn的设计里,features表示输入(特征),而labels表示输出
  5. # 但是这里的实现有点不好,把label也放到了features里。
  6. def model_fn(features, labels, mode, params):
  7. input_ids = features[ "input_ids"]
  8. input_mask = features[ "input_mask"]
  9. segment_ids = features[ "segment_ids"]
  10. label_ids = features[ "label_ids"]
  11. is_training = (mode == tf.estimator.ModeKeys.TRAIN)
  12. # 创建Transformer模型,这是最主要的代码。
  13. (total_loss, per_example_loss, logits, probabilities) = create_model(
  14. bert_config, is_training, input_ids, input_mask, segment_ids, label_ids,
  15. num_labels, use_one_hot_embeddings)
  16. tvars = tf.trainable_variables()
  17. # 从checkpoint恢复参数
  18. if init_checkpoint:
  19. (assignment_map, initialized_variable_names) =
  20. modeling.get_assignment_map_from_checkpoint(tvars, init_checkpoint)
  21. tf.train.init_from_checkpoint(init_checkpoint, assignment_map)
  22. output_spec = None
  23. # 构造训练的spec
  24. if mode == tf.estimator.ModeKeys.TRAIN:
  25. train_op = optimization.create_optimizer(total_loss, learning_rate,
  26. num_train_steps, num_warmup_steps, use_tpu)
  27. output_spec = tf.contrib.tpu.TPUEstimatorSpec(
  28. mode=mode,
  29. loss=total_loss,
  30. train_op=train_op,
  31. scaffold_fn=scaffold_fn)
  32. # 构造eval的spec
  33. elif mode == tf.estimator.ModeKeys.EVAL:
  34. def metric_fn(per_example_loss, label_ids, logits):
  35. predictions = tf.argmax(logits, axis= -1, output_type=tf.int32)
  36. accuracy = tf.metrics.accuracy(label_ids, predictions)
  37. loss = tf.metrics.mean(per_example_loss)
  38. return {
  39. "eval_accuracy": accuracy,
  40. "eval_loss": loss,
  41. }
  42. eval_metrics = (metric_fn, [per_example_loss, label_ids, logits])
  43. output_spec = tf.contrib.tpu.TPUEstimatorSpec(
  44. mode=mode,
  45. loss=total_loss,
  46. eval_metrics=eval_metrics,
  47. scaffold_fn=scaffold_fn)
  48. # 预测的spec
  49. else:
  50. output_spec = tf.contrib.tpu.TPUEstimatorSpec(
  51. mode=mode,
  52. predictions=probabilities,
  53. scaffold_fn=scaffold_fn)
  54. return output_spec
  55. return model_fn

这里的代码都是一些boilerplate代码,没什么可说的,最重要的是调用create_model”真正”的创建Transformer模型。下面我们来看这个函数的代码:


 
 
  1. def create_model(bert_config, is_training, input_ids, input_mask, segment_ids,
  2. labels, num_labels, use_one_hot_embeddings):
  3. model = modeling.BertModel(
  4. config=bert_config,
  5. is_training=is_training,
  6. input_ids=input_ids,
  7. input_mask=input_mask,
  8. token_type_ids=segment_ids,
  9. use_one_hot_embeddings=use_one_hot_embeddings)
  10. # 在这里,我们是用来做分类,因此我们只需要得到[CLS]最后一层的输出。
  11. # 如果需要做序列标注,那么可以使用model.get_sequence_output()
  12. # 默认参数下它返回的output_layer是[8, 768]
  13. output_layer = model.get_pooled_output()
  14. # 默认是768
  15. hidden_size = output_layer.shape[ -1].value
  16. output_weights = tf.get_variable(
  17. "output_weights", [num_labels, hidden_size],
  18. initializer=tf.truncated_normal_initializer(stddev= 0.02))
  19. output_bias = tf.get_variable(
  20. "output_bias", [num_labels], initializer=tf.zeros_initializer())
  21. with tf.variable_scope( "loss"):
  22. if is_training:
  23. # 0.1的概率会dropout
  24. output_layer = tf.nn.dropout(output_layer, keep_prob= 0.9)
  25. # 对[CLS]输出的768的向量再做一个线性变换,输出为label的个数。得到logits
  26. logits = tf.matmul(output_layer, output_weights, transpose_b= True)
  27. logits = tf.nn.bias_add(logits, output_bias)
  28. probabilities = tf.nn.softmax(logits, axis= -1)
  29. log_probs = tf.nn.log_softmax(logits, axis= -1)
  30. one_hot_labels = tf.one_hot(labels, depth=num_labels, dtype=tf.float32)
  31. per_example_loss = -tf.reduce_sum(one_hot_labels * log_probs, axis= -1)
  32. loss = tf.reduce_mean(per_example_loss)
  33. return (loss, per_example_loss, logits, probabilities)

上面代码调用modeling.BertModel得到BERT模型,然后使用它的get_pooled_output方法得到[CLS]最后一层的输出,这是一个768(默认参数下)的向量,然后就是常规的接一个全连接层得到logits,然后softmax得到概率,之后就可以根据真实的分类标签计算loss。我们这时候发现关键的代码是modeling.BertModel。

八、BertModel类

这个类是最终定义模型的地方,代码比较多,我们会按照执行和调用的顺序逐个阅读。因为文字只能线性描述,但是函数的调用关系很复杂,所以建议读者对照源代码来阅读。

我们首先来看这个类的用法,把它当成黑盒。前面的create_model也用到了BertModel,这里我们在详细的介绍一下。下面的代码演示了BertModel的使用方法:


 
 
  1. # 假设输入已经分词并且变成WordPiece的id了 
  2. # 输入是[2, 3],表示batch=2,max_seq_length=3
  3. input_ids = tf.constant([[ 31, 51, 99], [ 15, 5, 0]])
  4. # 第一个例子实际长度为3,第二个例子长度为2
  5. input_mask = tf.constant([[ 1, 1, 1], [ 1, 1, 0]])
  6. # 第一个例子的3个Token中前两个属于句子1,第三个属于句子2
  7. # 而第二个例子的第一个Token属于句子1,第二个属于句子2(第三个是padding)
  8. token_type_ids = tf.constant([[ 0, 0, 1], [ 0, 1, 0]])
  9. # 创建一个BertConfig,词典大小是32000,Transformer的隐单元个数是512
  10. # 8个Transformer block,每个block有6个Attention Head,全连接层的隐单元是1024
  11. config = modeling.BertConfig(vocab_size= 32000, hidden_size= 512,
  12. num_hidden_layers= 8, num_attention_heads= 6, intermediate_size= 1024)
  13. # 创建BertModel
  14. model = modeling.BertModel(config=config, is_training= True,
  15. input_ids=input_ids, input_mask=input_mask, token_type_ids=token_type_ids)
  16. # label_embeddings用于把512的隐单元变换成logits
  17. label_embeddings = tf.get_variable(...)
  18. # 得到[CLS]最后一层输出,把它看成句子的Embedding(Encoding)
  19. pooled_output = model.get_pooled_output()
  20. # 计算logits
  21. logits = tf.matmul(pooled_output, label_embeddings)

接下来我们看一下BertModel的构造函数:


 
 
  1. def __init__(self,
  2. config,
  3. is_training,
  4. input_ids,
  5. input_mask=None,
  6. token_type_ids=None,
  7. use_one_hot_embeddings=True,
  8. scope=None):
  9. # Args:
  10. # config: `BertConfig` 对象
  11. # is_training: bool 表示训练还是eval,是会影响dropout
  12. # input_ids: int32 Tensor shape是[batch_size, seq_length]
  13. # input_mask: (可选) int32 Tensor shape是[batch_size, seq_length]
  14. # token_type_ids: (可选) int32 Tensor shape是[batch_size, seq_length]
  15. # use_one_hot_embeddings: (可选) bool
  16. # 如果True,使用矩阵乘法实现提取词的Embedding;否则用tf.embedding_lookup()
  17. # 对于TPU,使用前者更快,对于GPU和CPU,后者更快。
  18. # scope: (可选) 变量的scope。默认是"bert"
  19. # Raises:
  20. # ValueError: 如果config或者输入tensor的shape有问题就会抛出这个异常
  21. config = copy.deepcopy(config)
  22. if not is_training:
  23. config.hidden_dropout_prob = 0.0
  24. config.attention_probs_dropout_prob = 0.0
  25. input_shape = get_shape_list(input_ids, expected_rank= 2)
  26. batch_size = input_shape[ 0]
  27. seq_length = input_shape[ 1]
  28. if input_mask is None:
  29. input_mask = tf.ones(shape=[batch_size, seq_length], dtype=tf.int32)
  30. if token_type_ids is None:
  31. token_type_ids = tf.zeros(shape=[batch_size, seq_length], dtype=tf.int32)
  32. with tf.variable_scope(scope, default_name= "bert"):
  33. with tf.variable_scope( "embeddings"):
  34. # 词的Embedding lookup
  35. (self.embedding_output, self.embedding_table) = embedding_lookup(
  36. input_ids=input_ids,
  37. vocab_size=config.vocab_size,
  38. embedding_size=config.hidden_size,
  39. initializer_range=config.initializer_range,
  40. word_embedding_name= "word_embeddings",
  41. use_one_hot_embeddings=use_one_hot_embeddings)
  42. # 增加位置embeddings和token type的embeddings,然后是
  43. # layer normalize和dropout。
  44. self.embedding_output = embedding_postprocessor(
  45. input_tensor=self.embedding_output,
  46. use_token_type= True,
  47. token_type_ids=token_type_ids,
  48. token_type_vocab_size=config.type_vocab_size,
  49. token_type_embedding_name= "token_type_embeddings",
  50. use_position_embeddings= True,
  51. position_embedding_name= "position_embeddings",
  52. initializer_range=config.initializer_range,
  53. max_position_embeddings=config.max_position_embeddings,
  54. dropout_prob=config.hidden_dropout_prob)
  55. with tf.variable_scope( "encoder"):
  56. # 把shape为[batch_size, seq_length]的2D mask变成
  57. # shape为[batch_size, seq_length, seq_length]的3D mask
  58. # 以便后向的attention计算,读者可以对比之前的Transformer的代码。
  59. attention_mask = create_attention_mask_from_input_mask(
  60. input_ids, input_mask)
  61. # 多个Transformer模型stack起来。
  62. # all_encoder_layers是一个list,长度为num_hidden_layers(默认12),每一层对应一个值。
  63. # 每一个值都是一个shape为[batch_size, seq_length, hidden_size]的tensor。
  64. self.all_encoder_layers = transformer_model(
  65. input_tensor=self.embedding_output,
  66. attention_mask=attention_mask,
  67. hidden_size=config.hidden_size,
  68. num_hidden_layers=config.num_hidden_layers,
  69. num_attention_heads=config.num_attention_heads,
  70. intermediate_size=config.intermediate_size,
  71. intermediate_act_fn=get_activation(config.hidden_act),
  72. hidden_dropout_prob=config.hidden_dropout_prob,
  73. attention_probs_dropout_prob=config.attention_probs_dropout_prob,
  74. initializer_range=config.initializer_range,
  75. do_return_all_layers= True)
  76. # `sequence_output` 是最后一层的输出,shape是[batch_size, seq_length, hidden_size]
  77. self.sequence_output = self.all_encoder_layers[ -1]
  78. with tf.variable_scope( "pooler"):
  79. # 取最后一层的第一个时刻[CLS]对应的tensor
  80. # 从[batch_size, seq_length, hidden_size]变成[batch_size, hidden_size]
  81. # sequence_output[:, 0:1, :]得到的是[batch_size, 1, hidden_size]
  82. # 我们需要用squeeze把第二维去掉。
  83. first_token_tensor = tf.squeeze(self.sequence_output[:, 0: 1, :], axis= 1)
  84. # 然后再加一个全连接层,输出仍然是[batch_size, hidden_size]
  85. self.pooled_output = tf.layers.dense(
  86. first_token_tensor,
  87. config.hidden_size,
  88. activation=tf.tanh,
  89. kernel_initializer=create_initializer(config.initializer_range))

代码很长,但是其实很简单。首先是对config(BertConfig对象)深度拷贝一份,如果不是训练,那么把dropout都置为零。如果输入的input_mask为None,那么构造一个shape合适值全为1的input_mask,这表示输入都是”真实”的输入,没有padding的内容。如果token_type_ids为None,那么构造一个shape合适并且值全为0的tensor,表示所有Token都属于第一个句子。

然后使用embedding_lookup函数构造词的Embedding,用embedding_postprocessor函数增加位置embeddings和token type的embeddings,然后是layer normalize和dropout。

接着用transformer_model函数构造多个Transformer SubLayer然后stack在一起。得到的all_encoder_layers是一个list,长度为num_hidden_layers(默认12),每一层对应一个值。 每一个值都是一个shape为[batch_size, seq_length, hidden_size]的tensor。

self.sequence_output是最后一层的输出,shape是[batch_size, seq_length, hidden_size]。first_token_tensor是第一个Token([CLS])最后一层的输出,shape是[batch_size, hidden_size]。最后对self.sequence_output再加一个线性变换,得到的tensor仍然是[batch_size, hidden_size]。

embedding_lookup函数用于实现Embedding,它有两种方式:使用tf.nn.embedding_lookup和矩阵乘法(one_hot_embedding=True)。前者适合于CPU与GPU,后者适合于TPU。所谓的one-hot方法是把输入id表示成one-hot的向量,当然输入id序列就变成了one-hot的矩阵,然后乘以Embedding矩阵。而tf.nn.embedding_lookup是直接用id当下标提取Embedding矩阵对应的向量。一般认为tf.nn.embedding_lookup更快一点,但是TPU上似乎不是这样,作者也不太了解原因是什么,猜测可能是TPU的没有快捷的办法提取矩阵的某一行/列?


 
 
  1. def embedding_lookup(input_ids,
  2. vocab_size,
  3. embedding_size=128,
  4. initializer_range=0.02,
  5. word_embedding_name="word_embeddings",
  6. use_one_hot_embeddings=False):
  7. """word embedding
  8. Args:
  9. input_ids: int32 Tensor shape为[batch_size, seq_length],表示WordPiece的id
  10. vocab_size: int 词典大小,需要于vocab.txt一致
  11. embedding_size: int embedding后向量的大小
  12. initializer_range: float 随机初始化的范围
  13. word_embedding_name: string 名字,默认是"word_embeddings"
  14. use_one_hot_embeddings: bool 如果True,使用one-hot方法实现embedding;否则使用
  15. `tf.nn.embedding_lookup()`. TPU适合用One hot方法。
  16. Returns:
  17. float Tensor shape为[batch_size, seq_length, embedding_size]
  18. """
  19. # 这个函数假设输入的shape是[batch_size, seq_length, num_inputs]
  20. # 普通的Embeding一般假设输入是[batch_size, seq_length],
  21. # 增加num_inputs这一维度的目的是为了一次计算更多的Embedding
  22. # 但目前的代码并没有用到,传入的input_ids都是2D的,这增加了代码的阅读难度。
  23. # 如果输入是[batch_size, seq_length],
  24. # 那么我们把它 reshape成[batch_size, seq_length, 1]
  25. if input_ids.shape.ndims == 2:
  26. input_ids = tf.expand_dims(input_ids, axis=[ -1])
  27. # 构造Embedding矩阵,shape是[vocab_size, embedding_size]
  28. embedding_table = tf.get_variable(
  29. name=word_embedding_name,
  30. shape=[vocab_size, embedding_size],
  31. initializer=create_initializer(initializer_range))
  32. if use_one_hot_embeddings:
  33. flat_input_ids = tf.reshape(input_ids, [ -1])
  34. one_hot_input_ids = tf.one_hot(flat_input_ids, depth=vocab_size)
  35. output = tf.matmul(one_hot_input_ids, embedding_table)
  36. else:
  37. output = tf.nn.embedding_lookup(embedding_table, input_ids)
  38. input_shape = get_shape_list(input_ids)
  39. # 把输出从[batch_size, seq_length, num_inputs(这里总是1), embedding_size]
  40. # 变成[batch_size, seq_length, num_inputs*embedding_size]
  41. output = tf.reshape(output,
  42. input_shape[ 0: -1] + [input_shape[ -1] * embedding_size])
  43. return (output, embedding_table)

Embedding本来很简单,使用tf.nn.embedding_lookup就行了。但是为了优化TPU,它还支持使用矩阵乘法来提取词向量。另外为了提高效率,输入的shape除了[batch_size, seq_length]外,它还增加了一个维度变成[batch_size, seq_length, num_inputs]。如果不关心细节,我们把这个函数当成黑盒,那么我们只需要知道它的输入input_ids(可能)是[8, 128],输出是[8, 128, 768]就可以了。

函数embedding_postprocessor的代码如下,需要注意的部分都有注释。


 
 
  1. def embedding_postprocessor(input_tensor,
  2. use_token_type=False,
  3. token_type_ids=None,
  4. token_type_vocab_size=16,
  5. token_type_embedding_name="token_type_embeddings",
  6. use_position_embeddings=True,
  7. position_embedding_name="position_embeddings",
  8. initializer_range=0.02,
  9. max_position_embeddings=512,
  10. dropout_prob=0.1):
  11. """对word embedding之后的tensor进行后处理
  12. Args:
  13. input_tensor: float Tensor shape为[batch_size, seq_length, embedding_size]
  14. use_token_type: bool 是否增加`token_type_ids`的Embedding
  15. token_type_ids: (可选) int32 Tensor shape为[batch_size, seq_length]
  16. 如果`use_token_type`为True则必须有值
  17. token_type_vocab_size: int Token Type的个数,通常是2
  18. token_type_embedding_name: string Token type Embedding的名字
  19. use_position_embeddings: bool 是否使用位置Embedding
  20. position_embedding_name: string,位置embedding的名字
  21. initializer_range: float,初始化范围
  22. max_position_embeddings: int,位置编码的最大长度,可以比最大序列长度大,但是不能比它小。
  23. dropout_prob: float. Dropout 概率
  24. Returns:
  25. float tensor shape和`input_tensor`相同。
  26. """
  27. input_shape = get_shape_list(input_tensor, expected_rank= 3)
  28. batch_size = input_shape[ 0]
  29. seq_length = input_shape[ 1]
  30. width = input_shape[ 2]
  31. if seq_length > max_position_embeddings:
  32. raise ValueError( "The seq length (%d) cannot be greater than "
  33. "`max_position_embeddings` (%d)" %
  34. (seq_length, max_position_embeddings))
  35. output = input_tensor
  36. if use_token_type:
  37. if token_type_ids is None:
  38. raise ValueError( "`token_type_ids` must be specified if"
  39. "`use_token_type` is True.")
  40. token_type_table = tf.get_variable(
  41. name=token_type_embedding_name,
  42. shape=[token_type_vocab_size, width],
  43. initializer=create_initializer(initializer_range))
  44. # 因为Token Type通常很小(2),所以直接用矩阵乘法(one-hot)更快
  45. flat_token_type_ids = tf.reshape(token_type_ids, [ -1])
  46. one_hot_ids = tf.one_hot(flat_token_type_ids, depth=token_type_vocab_size)
  47. token_type_embeddings = tf.matmul(one_hot_ids, token_type_table)
  48. token_type_embeddings = tf.reshape(token_type_embeddings,
  49. [batch_size, seq_length, width])
  50. output += token_type_embeddings
  51. if use_position_embeddings:
  52. full_position_embeddings = tf.get_variable(
  53. name=position_embedding_name,
  54. shape=[max_position_embeddings, width],
  55. initializer=create_initializer(initializer_range))
  56. # 位置Embedding是可以学习的参数,因此我们创建一个[max_position_embeddings, width]的矩阵
  57. # 但实际输入的序列可能并不会到max_position_embeddings(512),为了提高训练速度,
  58. # 我们通过tf.slice取出[0, 1, 2, ..., seq_length-1]的部分,。
  59. if seq_length < max_position_embeddings:
  60. position_embeddings = tf.slice(full_position_embeddings, [ 0, 0],
  61. [seq_length, -1])
  62. else:
  63. position_embeddings = full_position_embeddings
  64. num_dims = len(output.shape.as_list())
  65. # word embedding之后的tensor是[batch_size, seq_length, width]
  66. # 因为位置编码是与输入内容无关,它的shape总是[seq_length, width]
  67. # 我们无法把位置Embedding加到word embedding上
  68. # 因此我们需要扩展位置编码为[1, seq_length, width]
  69. # 然后就能通过broadcasting加上去了。
  70. position_broadcast_shape = []
  71. for _ in range(num_dims - 2):
  72. position_broadcast_shape.append( 1)
  73. position_broadcast_shape.extend([seq_length, width])
  74. # 默认情况下position_broadcast_shape为[1, 128, 768]
  75. position_embeddings = tf.reshape(position_embeddings,
  76. position_broadcast_shape)
  77. # output是[8, 128, 768], position_embeddings是[1, 128, 768]
  78. # 因此可以通过broadcasting相加。
  79. output += position_embeddings
  80. output = layer_norm_and_dropout(output, dropout_prob)
  81. return output

create_attention_mask_from_input_mask函数用于构造Mask矩阵。我们先了解一下它的作用然后再阅读其代码。比如调用它时的两个参数是是:


 
 
  1. input_ids=[
  2. [ 1,2,3,0,0],
  3. [ 1,3,5,6,1]
  4. ]
  5. input_mask=[
  6. [ 1,1,1,0,0],
  7. [ 1,1,1,1,1]
  8. ]

表示这个batch有两个样本,第一个样本长度为3(padding了2个0),第二个样本长度为5。在计算Self-Attention的时候每一个样本都需要一个Attention Mask矩阵,表示每一个时刻可以attend to的范围,1表示可以attend,0表示是padding的(或者在机器翻译的Decoder中不能attend to未来的词)。对于上面的输入,这个函数返回一个shape是[2, 5, 5]的tensor,分别代表两个Attention Mask矩阵。


 
 
  1. [
  2. [1, 1, 1, 0, 0], #它表示第1个词可以attend to 3个词
  3. [ 1, 1, 1, 0, 0], #它表示第2个词可以attend to 3个词
  4. [ 1, 1, 1, 0, 0], #它表示第3个词可以attend to 3个词
  5. [ 1, 1, 1, 0, 0], #无意义,因为输入第4个词是padding的0
  6. [ 1, 1, 1, 0, 0] #无意义,因为输入第5个词是padding的0
  7. ]
  8. [
  9. [1, 1, 1, 1, 1], # 它表示第1个词可以attend to 5个词
  10. [ 1, 1, 1, 1, 1], # 它表示第2个词可以attend to 5个词
  11. [ 1, 1, 1, 1, 1], # 它表示第3个词可以attend to 5个词
  12. [ 1, 1, 1, 1, 1], # 它表示第4个词可以attend to 5个词
  13. [ 1, 1, 1, 1, 1] # 它表示第5个词可以attend to 5个词
  14. ]

了解了它的用途之后下面的代码就很好理解了。


 
 
  1. def create_attention_mask_from_input_mask(from_tensor, to_mask):
  2. """Create 3D attention mask from a 2D tensor mask.
  3. Args:
  4. from_tensor: 2D or 3D Tensor,shape为[batch_size, from_seq_length, ...].
  5. to_mask: int32 Tensor, shape为[batch_size, to_seq_length].
  6. Returns:
  7. float Tensor,shape为[batch_size, from_seq_length, to_seq_length].
  8. """
  9. from_shape = get_shape_list(from_tensor, expected_rank=[ 2, 3])
  10. batch_size = from_shape[ 0]
  11. from_seq_length = from_shape[ 1]
  12. to_shape = get_shape_list(to_mask, expected_rank= 2)
  13. to_seq_length = to_shape[ 1]
  14. to_mask = tf.cast(
  15. tf.reshape(to_mask, [batch_size, 1, to_seq_length]), tf.float32)
  16. # `broadcast_ones` = [batch_size, from_seq_length, 1]
  17. broadcast_ones = tf.ones(
  18. shape=[batch_size, from_seq_length, 1], dtype=tf.float32)
  19. # Here we broadcast along two dimensions to create the mask.
  20. mask = broadcast_ones * to_mask
  21. return mask

比如前面举的例子,broadcast_ones的shape是[2, 5, 1],值全是1,而to_mask是


 
 
  1. to_mask=[
  2. [ 1,1,1,0,0],
  3. [ 1,1,1,1,1]
  4. ]

shape是[2, 5],reshape为[2, 1, 5]。然后broadcast_ones * to_mask就得到[2, 5, 5],正是我们需要的两个Mask矩阵,读者可以验证。注意[batch, A, B]*[batch, B, C]=[batch, A, C],我们可以认为是batch个[A, B]的矩阵乘以batch个[B, C]的矩阵。接下来就是transformer_model函数了,它就是构造Transformer的核心代码。


 
 
  1. def transformer_model(input_tensor,
  2. attention_mask=None,
  3. hidden_size=768,
  4. num_hidden_layers=12,
  5. num_attention_heads=12,
  6. intermediate_size=3072,
  7. intermediate_act_fn=gelu,
  8. hidden_dropout_prob=0.1,
  9. attention_probs_dropout_prob=0.1,
  10. initializer_range=0.02,
  11. do_return_all_layers=False):
  12. """Multi-headed, multi-layer的Transformer,参考"Attention is All You Need".
  13. 这基本上是和原始Transformer encoder相同的代码。
  14. 原始论文为:
  15. https://arxiv.org/abs/1706.03762
  16. Also see:
  17. https://github.com/tensorflow/tensor2tensor/blob/master/tensor2tensor/models/transformer.py
  18. Args:
  19. input_tensor: float Tensor,shape为[batch_size, seq_length, hidden_size]
  20. attention_mask: (可选) int32 Tensor,shape [batch_size, seq_length,
  21. seq_length], 1表示可以attend to,0表示不能。
  22. hidden_size: int. Transformer隐单元个数
  23. num_hidden_layers: int. 有多少个SubLayer
  24. num_attention_heads: int. Transformer Attention Head个数。
  25. intermediate_size: int. 全连接层的隐单元个数
;