BERT(Bidirectional Encoder Representations from Transformers) 是一个语言表示模型(language representation model)。它的主要模型结构是trasnformer的encoder堆叠而成,它其实是一个2阶段的框架,分别是pretraining,以及在各个具体任务上进行finetuning。
pretaining阶段需要大量的数据,以及大量的计算机资源,所以google开源了多国的语言预训练模型,我们可以直接在这个基础上进行finetune。
首先我们可以看到BERT 具有两种输出,一个是pooler output,对应的CLS的输出,以及sequence output,对应的是序列中的所有字的最后一层hidden输出。所以BERT主要可以处理两种,一种任务是分类/回归任务(使用的是pooler output),一种是序列任务(sequence output)。
具体做法是:我们学习两个向量,分别是$V_s, V_e$ 他们分别和document的sequence output做dot product,然后经过softmax,得到对应的$s, e$位置。
首先我们从General Language Understanding Evaluation (GLUE) benchmark leaderboard 数据来源日期2020/01/01。
我们可以看到屠榜的模型绝大部分都是BERT based。
另外它的出现,也直接带动了pretrain+finetune的预训练模型的时代。它的预训练模型的使得模型只需要少数的训练数据就可以得到非常好的效果。
此外它是第一个将双向网络和finetuning结合的模型。(虽然finetuning和pretraining在Unifit提出,然后统一下游框架是GPT提出的,但是双向的网络是BERT提出)
模型可以简单的归纳为三个部分,分别是输入层,中间层,以及输出层。这些都和transformer的encoder一致,除了输入层有略微变化
为了使得BERT模型适应下游的任务(比如说分类任务,以及句子关系QA的任务),输入将被改造成CLS+片段A+SEP+(片段B+SEP)
其中
因为trasnformer无法获得字的位置信息,BERT和transformer一样也加入了 绝对位置 position encoding,但是和transformer不同的是,BERT使用的是不是transformer对应的函数型(functional)的encoding方式,而是直接采用类似word embedding的方式(Parametric),直接获得position embedding。
因为我们对输入进行了改造,使得模型可能有多个句子Segment的输入,所以我们也需要加入segment的embedding,例如[CLS], A_1, A_2, A_3,[SEP], B_1, B_2, B_3, [SEP]
对应的segment的输入是[0,0,0,0,1,1,1,1]
, 然后在根据segment id进行embedding_lookup得到segment embedding。 code snippet如下。
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)
for token in tokens_b:
tokens.append(token)
segment_ids.append(1)
tokens.append("[SEP]")
segment_ids.append(1)
输入层为三个embedding相加(position embedding + segment embedding + token embedding)这是为什么?
首先我们简单地假设我们有一个token,我们假设我们的字典大小(vocabulary_size) = 5, 对应的的token_id 是2,这个token所在的位置是第0个位置,我们最大的位置长度为max_position_size = 6,以及我们可以有两种segment,这个token是属于segment = 0的情况。
首先我们分别对三种不同类型的分别进行 embedding lookup的操作,下面的代码中我们,固定了三种类型的embedding matrix,分别是token_embedding,position_embedding,segment_embedding。首先我们要清楚,正常的embedding lookup就是embedding id 进行onehot之后,然后在和embedding matrix 进行矩阵相乘,具体看例子中的 embd_embd_onehot_impl 和 embd_token,这两个的结果是一致的。
我们分别得到了三个类别数据的embedding之后(embd_token, embd_position, embd_sum),再将它们进行相加,得到embd_sum。
其结果跟,将三个类别进行onehot之后的结果concat起来,再进行embedding lookup的结果是一致的。比如下面,我们将token_id_onehot, position_id_onehot, segment_id_onehot 这三个onehot后的结果concat起来得到concat_id_onehot, 与三者的embedding matrix的concat后的结果concat_embedding,进行矩阵相乘,得到的结果 embd_cat。
可以发现 embd_sum == embd_cat。 具体参照下面代码。
import tensorflow as tf
token_id = 2
vocabulary_size = 5
position = 0
max_position_size = 6
segment_id = 0
segment_size = 2
embedding_size = 4
token_embedding = tf.constant([[-3.,-2,-1, 0],[1,2,3,4], [5,6,7,8], [9,10, 11,12], [13,14,15,16]]) #size: (vocabulary_size, embedding_size)
position_embedding = tf.constant([[17., 18, 19, 20], [21,22,23,24], [25,26,27,28], [29,30,31,32], [33,34,35,36], [37,38,39,40]]) #size:(max_position_size, embedding_size)
segment_embedding = tf.constant([[41.,42,43,44], [45,46,47,48]]) #size:(segment_size, embedding_size)
token_id_onehot = tf.one_hot(token_id, vocabulary_size)
position_id_onehot = tf.one_hot(position, max_position_size)
segment_id_onehot = tf.one_hot(segment_id, segment_size)
embd_embd_onehot_impl = tf.matmul([token_id_onehot], token_embedding)
embd_token = tf.nn.embedding_lookup(token_embedding, token_id)
embd_position = tf.nn.embedding_lookup(position_embedding, position)
embd_segment = tf.nn.embedding_lookup(segment_embedding, segment_id)
embd_sum = tf.reduce_sum([embd_token, embd_position, embd_segment], axis=0)
concat_id_onehot = tf.concat([token_id_onehot, position_id_onehot, segment_id_onehot], axis=0)
concat_embedding = tf.concat([token_embedding, position_embedding, segment_embedding], axis=0)
embd_cat = tf.matmul([concat_id_onehot], concat_embedding)
with tf.Session() as sess:
print(sess.run(embd_embd_onehot_impl)) # [[5. 6. 7. 8.]]
print(sess.run(embd_token)) # [5. 6. 7. 8.]
print(sess.run(embd_position)) # [17. 18. 19. 20.]
print(sess.run(embd_segment)) # [41. 42. 43. 44.]
print(sess.run(embd_sum)) # [63. 66. 69. 72.]
print(sess.run(concat_embedding))
'''
[[-3. -2. -1. 0.]
[ 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. 34. 35. 36.]
[37. 38. 39. 40.]
[41. 42. 43. 44.]
[45. 46. 47. 48.]]
[[63. 66. 69. 72.]]
'''
print(sess.run(concat_id_onehot)) # [0. 0. 1. 0. 0. 1. 0. 0. 0. 0. 0. 1. 0.]
print(sess.run(embd_cat)) # [[63. 66. 69. 72.]]
模型的中间层和transformer的encoder一样,都是由self-attention layer + ADD&BatchNorm layer + FFN 层组成的。
模型的每一个输入都对应这一个输出,根据不同的任务我们可以选择不同的输出,主要有两类输出
BERT提出的是一个框架,主要由两个阶段组成。分别是Pre-training以及Fine-Tuning。
语言预训练模型的主要是采用大量的训练预料,然后作无监督学习。
所以BERT采用了双向的语言模型的方式,但是这个如果采用双向的话,就不可以采用预测下一个词的方式了,因为模型会看到要预测的值。所以BERT第一次采用了mask language model(MLM)任务,这就类似于完形填空(Cloze task)。
具体的做法:
我们会随机mask输入的几个词,然后预测这个词。但是这样子做的坏处是因为fine-tuning阶段中并没有MASK token,所以导致了pre-training 和 fine-tuning的不匹配的情况。所以为了减轻这个问题,文章中采用的做法是:对于要MASK 15%的tokens,
for index in cand_indexes:
if len(masked_lms) >= num_to_predict: # 15% of total tokens
break
...
masked_token = None
# 80% of the time, replace with [MASK]
if rng.random() < 0.8:
masked_token = "[MASK]"
else:
# 10% of the time, keep original
if rng.random() < 0.5:
masked_token = tokens[index]
# 10% of the time, replace with random word
else:
masked_token = vocab_words[rng.randint(0, len(vocab_words) - 1)]
output_tokens[index] = masked_token
注意,这边的token的level是采用Byte Pair Encoding (BPE)生成word piece级别的,什么是word piece呢,就是一个subword的编码方式,经过WordpieceTokenizer 之后,将词变为了word piece, 例如:
# input = "unaffable"
# output = ["un", "##aff", "##able"]
这样子的好处是,可以有效的解决OOV的问题,但是mask wordpiece的做法也被后来(ERNIE以及SpanBERT等)证明是不合理的,没有将字的知识考虑进去,会降低精度,于是google在此版的基础上,进行Whole Word Masking(WWM)的模型。需要注意的是,中文的每个字都是一个word piece,所以WWM的方法在中文中,就是MASK一个词组,参考论文。
为什么BERT选择mask掉15%这个比例的词,可以是其他的比例吗? 参考海晨威的回答
随机mask的作用是由完形填空启发的,但是其实换个思路,它和CBOW的作用很像,都是根据上下文去预测中心词,对于15%的mask比例,可以看作 $100/15 \approx 7$ 即 100个词里面mask 15个词,也就是每7个词mask一个,我们可以当作是一个窗口大小为7的CBOW,但是这边的滑动窗口是不重叠的。那从CBOW的滑动窗口角度,10%~20%都是还ok的比例。
为了适配下游任务,使得模型懂得句子之间的关系,BERT加了一个新的训练任务,预测两个句子是不是下一句的关系。
具体来说:50%的概率,句子A和句子B是来自同一个文档的上下句,标记为is_random_next=False
, 50%的概率,句子A和句子B不是同一个文档的上下句,具体的做法就是,采用从其他的文档(document)中,加入新的连续句子(segments)作为句子B。具体参考create_instances_from_document
函数。
首先我们会有一个all_documents存储所有的documents,每个documents是由句子segemnts组成的,每个segment是由单个token组成的。我们首先初始化一个chunk数组,每次都往chunk中添加同一个document中的一个句子,当chunk的长度大于target的长度(此处target的长度一般是max_seq_length
,但是为了匹配下游任务,target的长度可以设置一定比例short_seq_prob
的长度少于max_seq_length
)的时候,随机选择一个某个句子作为分割点,前面的作为句子A,后面的作为句子B。
chunk = Sentence1, Sentence2,..., SentenceN, 我们随机选择选择一个句子作为句子A的结尾,例如2作为句子结尾,则句子A为=Sentence1, Sentence2。我们有50%的几率选择剩下的句子Sentence3,...SentenceN作为句子B,或者50%的几率时的句子B是从其他文档中的另外多个句子。
这时候可能会导致我们的训练样本的总长度len(input_ids)
大于或者小于我们的需要的训练样本长度max_seq_length
。
len(input_ids) > max_seq_length
, 具体的做法是分别删除比较长的一个句子中的头(50%)或尾(50%)的tokendef truncate_seq_pair(tokens_a, tokens_b, max_num_tokens, rng):
#Truncates a pair of sequences to a maximum sequence length.
while True:
total_length = len(tokens_a) + len(tokens_b)
if total_length <= max_num_tokens:
break
trunc_tokens = tokens_a if len(tokens_a) > len(tokens_b) else tokens_b
assert len(trunc_tokens) >= 1
# We want to sometimes truncate from the front and sometimes from the
# back to add more randomness and avoid biases.
if rng.random() < 0.5:
del trunc_tokens[0]
else:
trunc_tokens.pop()
len(input_ids) < max_seq_length
, 采用的做法是补0。```Python
while len(input_ids) < max_seq_length:
input_ids.append(0)
input_mask.append(0)
segment_ids.append(0)
```
根据我们的两个任务,我们预训练模型的输入主要由以下7个特征组成。
input_ids
: 输入的token对应的idinput_mask
: 输入的mask,1代表是正常输入,0代表的是padding的输入segment_ids
: 输入的0:代表句子A或者padding句子,1代表句子Bmasked_lm_positions
:我们mask的token的位置masked_lm_ids
:我们mask的token的对应idmasked_lm_weights
:我们mask的token的权重,1代表是真实mask的,0代表的是padding的masknext_sentence_labels
:句子A和B是否是上下句features = collections.OrderedDict()
features["input_ids"] = create_int_feature(input_ids)
features["input_mask"] = create_int_feature(input_mask)
features["segment_ids"] = create_int_feature(segment_ids)
features["masked_lm_positions"] = create_int_feature(masked_lm_positions)
features["masked_lm_ids"] = create_int_feature(masked_lm_ids)
features["masked_lm_weights"] = create_float_feature(masked_lm_weights)
features["next_sentence_labels"] = create_int_feature([next_sentence_label])
在Fine-Tuning阶段的时候,我们可以简单的plugin任务特定的输入和输出,作为训练。
例如:
CLS representation 被喂到 最后一层作为classification的结果例如 推理任务或者 情感分析任务。
在这个任务中,就不需要MLM任务以及NSP任务所需要的输入了,所以就只有固定输入features(input_ids
, input_mask
, segment_ids
)以及任务特定features
例如分类任务的输入特征:
input_ids
: 输入的token对应的idinput_mask
: 输入的mask,1代表是正常输入,0代表的是padding的输入segment_ids
: 输入的0:代表句子A或者padding句子,1代表句子Blabel_ids
:输入的样本的labelfeatures["input_ids"] = create_int_feature(feature.input_ids)
features["input_mask"] = create_int_feature(feature.input_mask)
features["segment_ids"] = create_int_feature(feature.segment_ids)
features["label_ids"] = create_int_feature([feature.label_id])
源码地址, bert的源码的结构主要为(部分删减):
├── chinese_L-12_H-768_A-12 # 中文的预训练模型
│ ├── bert_config.json # 配置文件
│ ├── bert_model.ckpt.data-00000-of-00001 # 模型文件
│ ├── bert_model.ckpt.index # 模型文件
│ ├── bert_model.ckpt.meta # 模型文件1
│ └── vocab.txt # 模型字典
├── sample_text.txt # 预训练的语料
├── create_pretraining_data.py # 将预训练的语料转换为训练所需数据
├── run_pretraining.py # 进行预训练的脚本
├── modeling.py # 模型结构脚本
├── run_classifier.py # 分类任务脚本
├── run_squad.py # SQuAD QA任务脚本
├── extract_features.py # 提取bert特征脚本,这里的特征指的是模型的所有层的输出特征,不止是最后一层
{
"attention_probs_dropout_prob": 0.1, # attention的分值的dropout
"directionality": "bidi", # 未使用
"hidden_act": "gelu", # FFNN的激活函数,非线性的来源之一(另一个是self-attention)
"hidden_dropout_prob": 0.1, # FFNN的droupout rate
"hidden_size": 768, # embedding hidden的 size
"initializer_range": 0.02, # 初始化的范围
"intermediate_size": 3072, # FFNN的中间层size
"max_position_embeddings": 512, # 最大的位置embedding size,所以我们使用这个预训练模型的话,不能超过512的长度
"num_attention_heads": 12, # attention head 的个数
"num_hidden_layers": 1, # hidden 层数
"pooler_fc_size": 768, # 【CLS】token出来的维度
"pooler_num_attention_heads": 12, #未使用
"pooler_num_fc_layers": 3, #未使用
"pooler_size_per_head": 128, #未使用
"pooler_type": "first_token_transform",
"type_vocab_size": 2, # type_vocab_size 指的是 segment_embedding的 vocabulary size,所以说我们使用这个模型,只能使用两个 segment concat 最多
"vocab_size": 21128 #词汇表的个数
}
这里面我们需要注意的是
为什么要懂得bert的源码?因为你一下子懂得了很多其他基于bert改进模型的源码,例如说Albert,Roberta,XLM等模型的代码。
首先我们在进行模型预训练的时候,我们需要准备训练数据,类似repo中的sample_text.txt。
我们首先来看Pretraining,我们需要准备训练数据,这里只是为了阅读代码,因此我们准备很少的数据就行。它的格式类似于:
当然上面的数据也太少了点,读者可以把这些内容复制个几百次。我们简单的介绍训练数据的格式。每一行代表一个句子。如一个空行代表一个新的文档(document)的开始,一篇文档可以包括多个段落(paragraph),我们可以在一个段落的最后加一个\<eop>表示这个段落的结束(和新段落的开始)。
This is the first sentence.
This is the second sentence and also the end of the paragraph.<eop>
Another paragraph.
Another document starts here.
比如上面的例子,总共有两篇文档,第一篇3个句子,第二篇1个句子。而第一篇的三个句子又分为两个段落,前两个句子是一个段落,最后一个句子又是一个段落。
create_pretraining_data.py
得到训练数据。由于数据会被一次性加载在进内存,进行转化成tfrecord的格式,所以数据不宜过大,只要最后输出存在一个文件夹下即可。如果我们有1000万个文档要训练,那么我们可以把这1000万文档拆分成1万个文件,每个文件中放入1000个文档,从而生成10000个TFRecord文件。
运行脚本如下:
python create_pretraining_data.py \
--input_file=./sample_text.txt \
--output_file=/tmp/tf_examples.tfrecord \
--vocab_file=$BERT_BASE_DIR/vocab.txt \
--do_lower_case=True \
--max_seq_length=128 \
--max_predictions_per_seq=20 \
--masked_lm_prob=0.15 \
--random_seed=12345 \
--dupe_factor=5
max_seq_length Token
序列的最大长度max_predictions_per_seq
最多生成多少个MASKmasked_lm_prob
多少比例的Token变成MASKdupe_factor
一个文档重复多少次首先说一下参数dupe_factor,比如一个句子”it is a good day”,为了充分利用数据,我们可以多次随机的生成MASK,比如第一次可能生成”it is a MASK day”,第二次可能生成”it MASK a good day”。这个参数控制重复的次数。
详细参考解析
create_pretraining_data.py 大体的框架如下:
def main(_):
tokenizer = tokenization.FullTokenizer(
vocab_file=FLAGS.vocab_file, do_lower_case=FLAGS.do_lower_case)
input_files = []
# 省略了文件通配符的处理,我们假设输入的文件已经传入input_files
rng = random.Random(FLAGS.random_seed)
instances = create_training_instances(
input_files, tokenizer, FLAGS.max_seq_length, FLAGS.dupe_factor,
FLAGS.short_seq_prob, FLAGS.masked_lm_prob, FLAGS.max_predictions_per_seq,
rng)
output_files = ....
write_instance_to_example_files(instances, tokenizer, FLAGS.max_seq_length,
FLAGS.max_predictions_per_seq, output_files)
假设我们的max_seq_length=15, masked_lm_prob=0.2, max_predictions_per_seq=2
我们的写入tfrecord的文本的格式就类似如下,因为我们的句子A(7个token)和句子B(6个token)加起来只有13,所以需要padding2个token。max_seq_length* masked_lm_prob=3 > max_predictions_per_seq=2, 所以只mask两个
1. tokens = ["[CLS], "it", "is" "a", "[MASK]", "day", "[SEP]", "I", "apple", "to", "go", "out", "[SEP]", 0,0]
2. input_mask = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0] # 13 个1 ,2 个0 表示最后两个是padding的
3. segment_ids=[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0] # 前7个0表示句子A,后6个表示句子B,最后两个是padding
4. is_random_next=False
5. masked_lm_positions=[4, 8]
#表示Mask后为["[CLS], "it", "is" "a", "[MASK]", "day", "[SEP]", "I", "apple", "to", "go", "out", "[SEP]"] 其中apple是随机替换成的
6.masked_lm_weights=[1, 1] # 表示前2个是真正mask的被mask的,长度=len(masked_lm_positions), 如果有的mask的token是padding,则weigjt = 0。
7. masked_lm_labels=["good", "want"]
run_pretraining.py
进行预训练python run_pretraining.py \
--input_file=/tmp/tf_examples.tfrecord \
--output_dir=/tmp/pretraining_output \
--do_train=True \
--do_eval=True \
--bert_config_file=$BERT_BASE_DIR/bert_config.json \
--init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \
--train_batch_size=32 \
--max_seq_length=128 \
--max_predictions_per_seq=20 \
--num_train_steps=20 \
--num_warmup_steps=10 \
--learning_rate=2e-5
参数都比较容易理解,通常我们需要调整的是num_train_steps、num_warmup_steps和learning_rate
。
def model_fn(features, labels, mode, params):
省略...
model = modeling.BertModel(
config=bert_config,
is_training=is_training,
input_ids=input_ids,
input_mask=input_mask,
token_type_ids=segment_ids,
use_one_hot_embeddings=use_one_hot_embeddings)
(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 # 多任务学习
#其实就是
# 1。 将需要计算mask的词的hidden output 取出,再过一个dense layer(以及layer norm)
# 2。 再通过embedding table相乘,得到对应的字的index(参考XLNET的公式)(+ 每个的bias)
# 3。 计算这几个词的softmax with cross entropy(*weights 再norm)
# output:
# - loss: 归一化的总的cross entropy loss(去掉padding 的mask的loss)
# - per_example_loss: 每个mask位置上的cross entropy loss
# - log_probs: 输出每个位置的的log logits
def get_masked_lm_output(bert_config, input_tensor, output_weights, positions,
label_ids, label_weights):
#得到masked LM的loss和log概率
# 只需要Mask位置的Token的输出。注意我们这边的input_tensor是模型的model.get_sequence_output()
input_tensor = gather_indexes(input_tensor, positions)
with tf.variable_scope("cls/predictions"):
# 在输出之前再加一个非线性变换,这些参数只是用于训练,在Fine-Tuning的时候就不用了。
with tf.variable_scope("transform"):
input_tensor = tf.layers.dense(
input_tensor,
units=bert_config.hidden_size,
activation=modeling.get_activation(bert_config.hidden_act),
kernel_initializer=modeling.create_initializer(
bert_config.initializer_range))
input_tensor = modeling.layer_norm(input_tensor)
# output_weights是复用输入的word Embedding,所以是传入的,
# 这里再多加一个bias。
output_bias = tf.get_variable(
"output_bias",
shape=[bert_config.vocab_size],
initializer=tf.zeros_initializer())
# input_tensor shape(batch size* masked length, hidden size)
# output_wights = (vocabulary size, hidden size)
logits = tf.matmul(input_tensor, output_weights, transpose_b=True)
logits = tf.nn.bias_add(logits, output_bias)
log_probs = tf.nn.log_softmax(logits, axis=-1)
# label_ids的长度是20,表示最大的MASK的Token数
# label_ids里存放的是MASK过的Token的id
label_ids = tf.reshape(label_ids, [-1])
label_weights = tf.reshape(label_weights, [-1])
one_hot_labels = tf.one_hot(
label_ids, depth=bert_config.vocab_size, dtype=tf.float32)
# 但是由于实际MASK的可能不到20,比如只MASK18,那么label_ids有2个0(padding)
# 而label_weights=[1, 1, ...., 0, 0],说明后面两个label_id是padding的,计算loss要去掉。
per_example_loss = -tf.reduce_sum(log_probs * one_hot_labels, axis=[-1])
numerator = tf.reduce_sum(label_weights * per_example_loss)
denominator = tf.reduce_sum(label_weights) + 1e-5
loss = numerator / denominator
return (loss, per_example_loss, log_probs)
# input_tensor : 是模型的model.get_pooled_output()即CLS对应的hidden输出
# 经过一个dense层直接进行2分类。
# Output:
# - loss: batch的平均loss
# - per_example_loss: 每个样本的输出loss
# - log_probs: 每个样本的输出log 概率
def get_next_sentence_output(bert_config, input_tensor, labels):
#预测下一个句子是否相关的loss和log概率
# 简单的2分类,0表示真的下一个句子,1表示随机的。这个分类器的参数在实际的Fine-Tuning
# 会丢弃掉。
with tf.variable_scope("cls/seq_relationship"):
output_weights = tf.get_variable(
"output_weights",
shape=[2, bert_config.hidden_size],
initializer=modeling.create_initializer(bert_config.initializer_range))
output_bias = tf.get_variable(
"output_bias", shape=[2], initializer=tf.zeros_initializer())
logits = tf.matmul(input_tensor, output_weights, transpose_b=True)
logits = tf.nn.bias_add(logits, output_bias)
log_probs = tf.nn.log_softmax(logits, axis=-1)
labels = tf.reshape(labels, [-1])
one_hot_labels = tf.one_hot(labels, depth=2, dtype=tf.float32)
per_example_loss = -tf.reduce_sum(one_hot_labels * log_probs, axis=-1)
loss = tf.reduce_mean(per_example_loss)
return (loss, per_example_loss, log_probs)
google 开源了两种不同大小的模型,分别是$BERT{base}$ 和 $BERT{Large}$。$BERT{base}$(L=12, H=768, A=12, Total Parameters=110M 一亿一千万) and $BERT{Large}$(L=24, H=1024, A=16, Total Parameters=340M 三亿四千万).
首先需要下载中文预训练的模型,chinese_L-12_H-768_A-12, 例如这个代表的是layer=12,hidden size=768, attention head=12。
对于不同的任务,只需要简单将任务的输入数据传入bert模型,然后再进行finetune就行了。
我们以分类任务为例:我们只需要在官方提供的run_classifier.py
脚本上进行少量修改就可以实现一个文本分类的任务。
class DomainClf_Processor(DataProcessor):
#Processor for the domain classification.
# 从文本中读取训练集数据
def get_train_examples(self, data_dir):
#See base class.
return self._create_examples(
self._read_tsv(os.path.join(data_dir, FLAGS.train_file)), "train")
# 从文本中读取验证集数据
def get_dev_examples(self, data_dir):
#See base class.
return self._create_examples(
self._read_tsv(os.path.join(data_dir, FLAGS.dev_file)), "dev")
# 从文本中读取测试集数据
def get_test_examples(self, data_dir):
#See base class.
return self._create_examples(
self._read_tsv(os.path.join(data_dir, FLAGS.test_file)), "test")
# 定义分类的label
def get_labels(self):
#See base class.
return ["alerts", "baike", "calculator", "call", "car_limit", "chat", "cook_book", "fm", "general_command",
"home_command", "master_command", "music", "news", "shopping", "stock", "time", "translator", "video",
"weather"]
# 组装训练的example,我们这是单文本分类所以 使用的是 text-∅ pair
def _create_examples(self, lines, set_type):
#Creates examples for the training and dev sets.
examples = []
for (i, line) in enumerate(lines):
guid = "%s-%s" % (set_type, i)
text_a = tokenization.convert_to_unicode(line[0])
label_txt = line[1].replace("__label__", "")
label = tokenization.convert_to_unicode(label_txt)
examples.append(
InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
return examples
processors = {
"domainclf": DomainClf_Processor,
"cola": ColaProcessor,
...
}
run_classifier.py
以下是一个demo的运行脚本
#!/bin/bash
#description: BERT fine-tuning
export BERT_BASE_DIR=AI_QA/models/RoBERTa-tiny-clue
export DATA_DIR=AI_QA/ALBERT/1-data/command
export TRAINED_CLASSIFIER=./output
export MODEL_NAME=roberta_tiny_clue_command_gpu
export CUDA_VISIBLE_DEVICES=2
python run_classifier_serving_gpu.py \
--task_name=command \
--do_train=false \
--do_eval=false \
--do_predict=true \
--do_export=false \
--do_frozen=true \
--data_dir=$DATA_DIR \
--vocab_file=$BERT_BASE_DIR/vocab.txt \
--test_file=test \
--bert_config_file=$BERT_BASE_DIR/bert_config.json \
--init_checkpoint=$BERT_BASE_DIR/bert_model.ckpt \
--max_seq_length=128 \
--train_batch_size=32 \
--learning_rate=1e-4 \
--num_train_epochs=6.0 \
--output_dir=$TRAINED_CLASSIFIER/$MODEL_NAME
可以看到对于loss的计算,对于整个segment的分类问题,我们直接使用的是model.get_pooled_output()
, 而对于token level的loss计算,我们需要调用模型的model.get_sequence_output()
输出
#Creates a classification model.
model = modeling.BertModel(
config=bert_config,
is_training=is_training,
input_ids=input_ids,
input_mask=input_mask,
token_type_ids=segment_ids,
use_one_hot_embeddings=use_one_hot_embeddings)
# In the demo, we are doing a simple classification task on the entire
# segment.
#
# If you want to use the token-level output, use model.get_sequence_output()
# instead.
output_layer = model.get_pooled_output()
hidden_size = output_layer.shape[-1].value
output_weights = tf.get_variable(
"output_weights", [num_labels, hidden_size],
initializer=tf.truncated_normal_initializer(stddev=0.02))
output_bias = tf.get_variable(
"output_bias", [num_labels], initializer=tf.zeros_initializer())
with tf.variable_scope("loss"):
if is_training:
# I.e., 0.1 dropout
output_layer = tf.nn.dropout(output_layer, keep_prob=0.9)
logits = tf.matmul(output_layer, output_weights, transpose_b=True)
logits = tf.nn.bias_add(logits, output_bias)
probabilities = tf.nn.softmax(logits, axis=-1)
log_probs = tf.nn.log_softmax(logits, axis=-1)
one_hot_labels = tf.one_hot(labels, depth=num_labels, dtype=tf.float32)
per_example_loss = -tf.reduce_sum(one_hot_labels * log_probs, axis=-1)
loss = tf.reduce_mean(per_example_loss)
return (loss, per_example_loss, logits, probabilities)
modeling.py中我们可以看到:
self.sequence_output = self.all_encoder_layers[-1]
# 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))
那么bert是如何进行模型精调的呢?我们在精调的时候能采取和原本下载好的预训练模型不同的configuration吗?以及我们可以创新自己的模型结构么?
相信很多人第一反应就是那肯定不行!我们的预训练就是在原本的模型的基础上,进行的模型参数精调,怎么可以改变模型的结构呢。但是其实我们通过看源码可以得到答案!答案就是可以的,当然这个效果往往没有直接在原本预训练模型配置下精调效果好。但是这也给了大家一些新的思路,比如说我要做一些新的实验,但是初始化还是可以大大提升效果的,或者我们想要训练一个4或者8层的BERT,我们都可以用现有的结构进行初始化。
(total_loss, per_example_loss, logits, probabilities) = create_model(
bert_config, is_training, input_ids, input_mask, segment_ids, label_ids,
num_labels, use_one_hot_embeddings)
tvars
, 然后通过函数 get_assignment_map_from_checkpoint(tvars, init_checkpoint)
获得变量和预训练模型的变量的映射关系 assignment_map
tvars = tf.trainable_variables()
initialized_variable_names = {}
scaffold_fn = None
if init_checkpoint:
(assignment_map, initialized_variable_names
) = modeling.get_assignment_map_from_checkpoint(tvars, init_checkpoint)
tf.train.init_from_checkpoint(init_checkpoint, assignment_map)
我们可以看到,最重要的函数就是get_assignment_map_from_checkpoint(tvars, init_checkpoint)
,可以根据这个函数,知道具体这个映射表是怎么获得的。
常见的tensorflow 的变量名称比如说 `[<tf.Variable 'rnn/gru_cell/gates/kernel:0' shape=(6, 8) dtype=float32_ref>, < tf.Variable 'rnn/gru_cell/gates/bias:0' shape=(8,) dtype=float32_ref>, <tf.Var
iable 'rnn/gru_cell/candidate/kernel:0' shape=(6, 4) dtype=float32_ref>, <tf.V
ariable 'rnn/gru_cell/candidate/bias:0' shape=(4,) dtype=float32_ref>]`
def get_assignment_map_from_checkpoint(tvars, init_checkpoint):
#Compute the union of the current variables and checkpoint variables.
assignment_map = {}
initialized_variable_names = {}
name_to_variable = collections.OrderedDict()
for var in tvars:
name = var.name
m = re.match("^(.*):\\d+$", name)
if m is not None:
name = m.group(1)
name_to_variable[name] = var
init_vars = tf.train.list_variables(init_checkpoint)
assignment_map = collections.OrderedDict()
for x in init_vars:
(name, var) = (x[0], x[1])
if name not in name_to_variable:
continue
assignment_map[name] = name
initialized_variable_names[name] = 1
initialized_variable_names[name + ":0"] = 1
return (assignment_map, initialized_variable_names)
<tf.Variable 'rnn/gru_cell/gates/kernel:0' shape=(6, 8) dtype=float32_ref>
, 可以得到它的name为 'rnn/gru_cell/gates/kernel'
,上面的正则表达式是为了获取变量名称,看不懂可以参考re 链接1 链接2。
得到变量名称后,可以获得一个变量名称到变量的字典name_to_variable
tf.train.init_from_checkpoint(init_checkpoint, assignment_map)
【技术创作101训练营】
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。