本文不讲Bert原理,拟进行Bert源码分析和应用
Bert源码分析
组织结构
这是Bert Github地址,打开后会看到这样的结构:
下面我将逐个分析上诉图片中加框的文件,其他的文件不是源码,不用分析.
modeling.py
该文件是整个Bert模型源码,包含两个类:
- BertConfig:Bert配置类
- BertModel:Bert模型类
- embedding_lookup:用来返回函数token embedding词向量
- embedding_postprocessor:得到token embedding+segment embedding+position embedding
- create_attention_mask_from_input_mask得到mask,用来attention该attention的部分
- transformer_model和attention_layer:Transform的ender部分,也就是self-attention,不解释了,看太多遍了.
注意上面的顺序,不是乱写的,是按照BertModel调用顺序组织的.
BertConfig
1 | class BertConfig(object): |
BertModel
现在进入正题,开始分析Bert模型源码
1 | class BertModel(object): |
参数说明:
config
:一个BertConfig
实例is_training
:bool
类型,是否是训练流程,用类控制是否dropoutinput_ids
:输入Tensor
,shape
是[batch_size, seq_length]
.input_mask
:shape
是[batch_size, seq_length]
无需细讲token_type_ids
:shape
是[batch_size, seq_length]
,bert
中就是0或1use_one_hot_embeddings
:在embedding_lookup
返回词向量的时候使用,详细见embedding_lookup
函数
embedding_lookup
为了得到进入模型的词向量(token embedding)
1 | def embedding_lookup(input_ids,vocab_size,embedding_size=128,initializer_range=0.02,word_embedding_name="word_embeddings",use_one_hot_embeddings=False): |
参数说明:
- input_ids:[batch_size, seq_length]
- vocab_size:词典大小
- initializer_range:初始化参数
- word_embedding_name:不解释
- use_one_hot_embeddings:是否使用one_hot方式初始化(为啥我感觉这里是True还是False结果得到的结果是一样的?????)如下代码.
return:token embedding:[batch_size, seq_length, embedding_size].和embedding_table(不解释)
1 | import tensorflow as tf |
embedding_postprocessor
bert模型的输入向量有三个,embedding_lookup得到的是token embedding 我们还需要segment embedding和position embedding,这三者的维度是完全相同的(废话不相同怎么加啊。。。)本部分代码会将这三个embeddig加起来并dropout
1 | def embedding_postprocessor(input_tensor, |
参数说明:
- input_tensor:token embedding[batch_size, seq_length, embedding_size]
- use_token_type是否使用segment embedding
- token_type_ids:[batch_size, seq_length],这两个参数其实就是控制生成segment embedding的,上诉代码中的
output += token_type_embeddings
就是得到token embedding+segment embedding- use_position_embeddings:是否使用位置信息
- max_position_embeddings:序列最大长度
注:- 本部分代码中的
width
其实就是词向量维度(换个embedding_size
能死啊。。。)- 可以看出位置信息跟Transform的固定方式不一样,它是训练出来的.
output += position_embeddings
就得到了三者的想加结果
return :token embedding+segment embedding+position_embeddings
create_attention_mask_from_input_mask
目的是将本来shape为[batch_size, seq_length]转为[batch_size, seq_length,seq_length],为什么要这样的维度呢?因为…..算了麻烦不写了,去我的另一篇Transform中看吧
1 | def create_attention_mask_from_input_mask(from_tensor, to_mask): |
参数说明:
- from_tensor:[batch_size, seq_length].
- to_mask:[batch_size, seq_length]
注:Transform
中的mask
和平常用的不太一样,这里的mask
是为了在计算attention
的时候”看不到不应该看到的内容”,计算方式为该看到的mask
为0,不该看到的mask
为一个负的很大的数字,然后两者相加(平常使用mask
是看到的为1,看不到的为0,然后两者做点乘),这样在计算softmax
的时候那些负数的attention
会非常非常小,也就基本看不到了.
transformer_model
这一部分是
Transform
部分,但是只有encoder
部分,从BertModel
中的with tf.variable_scope("encoder"):
这一部分也可以看出来
1 | def transformer_model(input_tensor, |
参数说明:
- input_tensor:token embedding+segment embedding+position embedding [batch_size, seq_length, embedding_size]
- attention_mask:[batch_size, seq_length,seq_length]
- hidden_size:不解释
- num_hidden_layers:多少个
ecncoder block
- num_attention_heads:多少个
head
- intermediate_size:
feed forward
隐藏层维度- intermediate_act_fn:
feed forward
激活函数
其他的不解释了
return [batch_size, seq_length, hidden_size],
attention_layer
其实就是
self-attention
,但是在计算的时候全都转换为了二维矩阵,按注释的意思是避免反复reshape,因为reshape在CPU/GPU上易于实现,但是在TPU上不易实现,这样可以加速训练.
1 | def attention_layer(from_tensor, |
参数说明:
- from_tensor在Transform中被转为二维[batch_size*seq_length, embedding_size]
- to_shape:传过来的参数跟from_tensor一毛一样,在这里没什么卵用其实,因为q和k的length是一样的
- attention_mask:[batch_size, seq_length,seq_length]
- num_attention_heads:head数量
- size_per_head:每一个head维度,代码中是用总维度除以head数量得到的:attention_head_size = int(hidden_size / num_attention_heads)
return: return :[batch_size, from_seq_length,num_attention_heads * size_per_head].
激活函数
1 | def gelu(x): |
这个激活函数很有特色,其实这个公式就是$x \times \Phi(x)$,后一项是正态函数,也就是说,gelu中后面那一大堆其实近似等于$\int_{-\infty}^{x}\frac{1}{\sqrt(2\pi)}e^{-\frac{x^2}{2}}dx$,至于咋来的这个近似值,还不清楚。
测试函数:
1
2
3
4
5
6
7
8
9
10
11
12 from scipy import stats
import math
a = stats.norm.cdf(2, 0, 1)
def gelu(x):
return 0.5 * (1.0 + math.tanh((math.sqrt(2 / math.pi) * (x + 0.044715 * math.pow(x, 3)))))
print(a)
print(gelu(2))
#结果:
#0.9772498680518208
#0.9772988470438875
总结一:
看完模型感觉真特么简单这模型,似乎除了self-attention就啥都没有了,但是先别着急,一般情况下模型是重点,但是对于Bert而言,模型却仅仅是开始,真正的创新点还在下面.
create_pretraining_data.py
这部分代码用来生成训练样本,我们从
main
函数开始看起,首先进入tokenization.py
def main
1 | def main(_): |
class TrainingInstance
单个训练样本类,看
__init__
就能看出来,没什么其他东西
1 | class TrainingInstance(object): |
def create_training_instances
这个函数是重中之重,用来生成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33 def create_training_instances(input_files, tokenizer, max_seq_length,
dupe_factor, short_seq_prob, masked_lm_prob,
max_predictions_per_seq, rng):
all_documents = [[]]#外层是文档,内层是文档中的每个句子
for input_file in input_files:
with tf.gfile.GFile(input_file, "r") as reader:
while True:
line = tokenization.convert_to_unicode(reader.readline())
if not line:
break
line = line.strip()
if not line:# 空行表示文档分割
all_documents.append([])
tokens = tokenizer.tokenize(line)
if tokens:
all_documents[-1].append(tokens)
all_documents = [x for x in all_documents if x]
rng.shuffle(all_documents)
vocab_words = list(tokenizer.vocab.keys())
instances = []
for _ in range(dupe_factor):
for document_index in range(len(all_documents)):
instances.extend(
create_instances_from_document(all_documents,
document_index,
max_seq_length,
short_seq_prob,
masked_lm_prob,
max_predictions_per_seq,
vocab_words,
rng))
rng.shuffle(instances)
return instances参数说明:
dupe_factor:每一个句子用几次:因为如果一个句子只用一次的话那么mask的位置就是固定的,这样我们把每个句子在训练中都多用几次,而且没次的mask位置都不相同,就可以防止某些词永远看不到
short_seq_prob:长度小于“max_seq_length”的样本比例。因为在fine-tune过程里面输入的target_seq_length是可变的(小于等于max_seq_length),那么为了防止过拟合也需要在pre-train的过程当中构造一些短的样本
max_predictions_per_seq:一个句子里最多有多少个[MASK]标记
masked_lm_prob:多少比例的Token被MASK掉
rng:随机率
def create_instances_from_document
一个文档中抽取训练样本,重中之重
1 | def create_instances_from_document(all_documents, document_index, max_seq_length, short_seq_prob, |
def create_masked_lm_predictions
真正的mask在这里实现
1 | def create_masked_lm_predictions(tokens, masked_lm_prob, max_predictions_per_seq, vocab_words, rng): |
代码流程是这样的:首先嫁给你一个句子随机打乱,并确定一个句子的15%是多少个token,设num_to_predict,然后对于[0,是多少个token,设num_to_predict]token,以80%的概率替换为[mask],10%的概率替换,10%的概率保持,这样就做到了对于15%的toke80% [mask],10%替换,10%保持。而预测的不是那15%的80(标注问题),而是全部15%。为什么要mask呢?你想啊,我们的目的是得到这样一个模型:输入一个句子,输出一个能够尽可能表示该句子的向量(用最容易理解的语言就是我们不知道输入的是什么玩意,但是我们需要知道输出的向量是什么),如果不mask直接训练那不就相当于用1来推导1?而如果我们mask一部分就意味着并不知道输入(至少不知道全部),至于为什么要把15不全部mask,我觉得这个解释很不错,但是过于专业化:
- 如果把 100% 的输入替换为 [MASK]:模型会偏向为 [MASK] 输入建模,而不会学习到 non-masked 输入的表征。
- 如果把 90% 的输入替换为 [MASK]、10% 的输入替换为随机 token:模型会偏向认为 non-masked 输入是错的。
- 如果把 90% 的输入替换为 [MASK]、维持 10% 的输入不变:模型会偏向直接复制 non-masked 输入的上下文无关表征。
所以,为了使模型可以学习到相对有效的上下文相关表征,需要以 1:1 的比例使用两种策略处理 non-masked 输入。论文提及,随机替换的输入只占整体的 1.5%,似乎不会对最终效果有影响(模型有足够的容错余量)。
通俗点说就是全部mask的话就意味着用mask来预测真正的单词,学习的仅仅是mask(而且mask的每个词都不一样,学到的mask表示也不一样,很显然不合理),加入10%的替换就意味着用错的词预测对的词,而10%保持不变意味着用1来推导1,因此后两个10%的作用其实是为了学到没有mask的部分。
或者还有一种解释方式: 因为每次都是要学习这15%的token,其他的学不到(认识到这一点很重要)倘若某一个词在训练模型的时候被mask了,而微调的时候出现了咋办?因此不管怎样,都必须让模型好歹”认识一下”这个词.tokenization.py
按照create_pretraining_data.py
中main
的调用顺序,先看FullTokenizer
类
FullTokenizer
1 | class FullTokenizer(object): |
在
__init__
中可以看到,又得先分析BasicTokenizer
类和WordpieceTokenizer
类(哎呀真烦,最后在回来做超链接吧),除此之外就是调用了几个小函数,load_vocab
它的输入参数是bert模型的词典,返回的是一个OrdereDict
:{词:词号}.其他的不说了,没啥意思。
class BasicTokenizer
目的是根据空格,标点进行普通的分词,最后返回的是关于词的列表,对于中文而言是关于字的列表。
1 | class BasicTokenizer(object): |
class WordpieceTokenizer
这个才是重点,跑test的时候出现的那些##都是从这里拿来的,其实就是把未登录词在词表中匹配相应的前缀.
1 | class WordpieceTokenizer(object): |
tokenize说明: 使用贪心的最大正向匹配算法
eg:input = “unaffable” output = [“un”, “##aff”, “##able”],首先看”unaffable”在不在词表中,在的话就当做一个词,也就是WordPiece,不在的话在看”unaffabl”在不在,也就是while
中的end-=1
,最终发现”un”在词表中,算是一个WordPiece,然后start=2,也就是代码中的start=end
,看”##affable”在不在词表中,在看”##affabl”(##表示接着前面),最终返回[“un”, “##aff”, “##able”].注意,这样切分是可逆的,也就是可以根据词表重载”攒回”原词,以此便解决了oov问题.