最近一直在看预训练模型,发现大部分模型的源代码基本上都是在Google官方发布的BERT源码的基础上进行修改的(但是全都是TF1.x😷,这点我要吐槽了,按道理TF2.x出来之后,Google在大力推广TF2.x,然而连Google自己发布的ELECTRA、Adapter-BERT、ALBERT等等源代码都是import tensorflow.compat.v1 as tf😷,excuse me?)。所以还是回头再仔细看了一遍原来BERT的源代码。不过,整体阅读下来,感觉还是非常顺畅的,不得不说代码写的真的好。所以这篇文章主要是记录一下自己看BERT源代码的过程。
BERT整体代码结构
BERT原理我在这里就不多啰嗦了,网上一大堆,当然更加推荐的是取看原始论文。首先来看看BERT的代码文件与整体结构。如下:
1 | ├── README.md |
- create_pretraining_data.py:用来创建训练实例;
- extract_features.py:提取出预训练的特征;
- modeling.py:BERT的核心建模文件,模型主体部分;
- modeling_test.py:对modeling.py文件进行unittest测试;
- optimization.py:自定义的优化器;
- optimization_test.py:对optimization.py文件的unittest测试;
- predicting_movie_reviews_with_bert_on_tf_hub.ipynb:通过调用tfhub来使用BERT进行预测;
- run_classifier.py:在多种数据集上(譬如:MRPC、XNLI、MNLI、COLA)来进行BERT模型的finetune;
- run_classifier_with_tfhub.py:通过调用tfhub来进行finetune;
- run_pretraining.py:通过MLM与NSP任务来对模型进行预训练;
- run_squad.py:在squad数据集上进行finetune;
- tokenization.py:对原始数据进行清洗、分词等操作;
- tokenization_test.py:对 tokenization.py文件进行unittest测试。
以上就是各文件的大致简介,下面将对核心代码文件进行走读,tnesor的维度以及重要注释我均已在代码里写明~
核心代码文件走读
modeling.py
modeling.py文件是BERT模型的实现。首先来看BertConfig类,如下:
1 | class BertConfig(object): |
这个主要是BERT模型的配置文件,注释其实很详细了,但是需要特别说明的是type_vocab_size
,这个表示的是segment id,默认是2,代码里没有修改,但是在bert_config.json文件里有非常详细的解释。
看完BertConfig类之后就是BERT模型了,但是由于BERT模型整体非常复杂,我们先来看看它的其实的component。首先来看token embedding部分,如下:
1 | # embedding_lookup用来获取token embedding |
其中每个tensor的维度我都标注的非常清楚了,看懂代码应该没有什么问题。通过embedding_lookup
函数,我们就得到了token embedding以及embedding_table,其中embedding_table就是词向量表,如果我们不使用finetune的方式,那么我们也可以将训练好的embedding_table给抽出来,然后采用feasture based的方式来进行下游任务的训练。
除了token embedding之后,BERT中还有segment embedding与positional embedding,最终的embedding是这三个embedding相加得到的结果。具体实现代码如下:
1 | # embedding_postprocessor用来将token embedding、segment embedding以及positional embedding进行相加, |
这里需要注意一下,在BERT中,embedding_size=hidden_size。得到embedding之后我们就需要将输入输入到transformer中了,在BERT当中,transformer由12个self-attention layer堆叠而成。所以,首先来看看self-attention layer的实现,如下:
1 | def attention_layer(from_tensor, |
这是标准的self-attention层的实现,我觉得有很多地方可以借鉴,譬如:让padding的位置经过softmax的值无限接近于0,以及在做QK.T的计算的时候,我们一开始就把输入给它reshape层2维的,即:[batch_size,seq_length,width_size]
转化到[batch_size*seq_length,num_attention_heads*size_per_head]
,从而加快训练,这个其实我之前都没想过,只有在看开源代码的时候才能知道。定义了sefl-attention layer之后,我们来看transfomer的实现,如下:
1 | def transformer_model(input_tensor, |
现在,我们将embedding以及transfomer给串起来,得到完整的BERT模型。如下:
1 | class BertModel(object): |
获取最顶层的[CLS]token的tensor用于训练NSP任务,如果下游任务是分类任务的话,我们最终也是在这个的基础上,来介入softmax或者其他的结构来做;此外,获取最顶层self-ateention layer的输出结果,用来训练MLM任务。
tokenization.py
tokenizaton.py文件用来对原始文本进行分词、词干化、小写、去除空格等等操作,并将原始文本向量化。在这里,需要特别提一下的话就是分词这块,BERT使用了两种分词,首先对原始文本进行粗粒度的分词,然后在此基础上,进行wordpiece分词,得到更加细粒度的分词结果。具体代码如下:
首先是粗粒度的分词,代码如下:
1 | class BasicTokenizer(object): |
然后是wordpiece分词,具体代码如下:
1 | ''' |
将这两分词进行结合,得到细粒度的分词结果,如下:
1 | class FullTokenizer(object): |
create_pretraining_data.py
这部分主要是在tokenization.py的基础上,创建训练实例。我们来看BERT中是怎么实现随机MASK操作的。代码如下:
1 | def create_masked_lm_predictions(tokens, masked_lm_prob, |
然后我们可以输入命令,如下:
1 | python create_pretraining_data.py \ |
得到结果如下:
1 | I0704 18:10:11.231426 4486237632 create_pretraining_data.py:160] *** Example *** |
我们可以看到,输出结果有:
- input_ids:padding之后的tokens;
- input_mask:对input_ids进行mask得到的结果;
- segment_ids:0表示的是第一个句子,1表示第二个句子,后面的0表示padding
- masked_lm_positions:表示被随机MASK掉的token在instance中的位置;
- masked_lm_ids:表示被随机MASK掉的token在词汇表中的编码;
- masked_lm_weigths:表示被随机MASK掉的token的序列,其中1表示MASK掉的token是原始的文本token,0表示MASK的是padding之后的token。
当然了,输入的文本也有要求的:一行表示一个句子,不同文章之间要隔一个空行。
run_pretraining.py
这部分是对BERT模型进行预训练。一般我们这部分都是不用管的,直接加载已经训练好的BERT模型权重就可以了(直接训练个人基本上不太可能,太耗钱了,Google都是用多块TPU训练了好几天。。。)大致看一下它的代码结构吧~
由于在预训练阶段,BERT是使用MLM任务与NSP任务来进行预训练的,所以先来看一下这两个任务吧~
1 | # 定义MLM任务 |
对于MLM任务,输入的是最顶层self-attention层的输出结果,维度是:[batch_size,seq_length,hidden_size]
,但是我们需要注意的是,在MLM中,我们其实只计算被随机MASK掉的token的loss,这也是BERT需要大量的训练数据以及收敛慢的原因,我们最终需要计算的是:[batch_size*max_pred_pre_seq,hidden_size]
,其中max_pred_pre_seq
表示一个句子最多被MASK的数目。对于MLM loss的计算,我们是minimize (-log MLE)。
对于NSP任务,输入的是最顶层的[CLS]token的tensor,维度是:[batch_size,hidden_size]
,然后使用softmax进行二分类。关于NSP loss,仍然与MLM loss一样,是minimize (-log MLE)。
定义完两个任务之后,我们就需要定义模型,来完成训练过程,如下:
1 | # 定义模型 |
run_classifier.py
如果我们想使用BERT在自己的任务上,我们需要修改的就是run_classifier.py文件。具体代码如下:
首先是对数据处理,对于BERT来说,Google官方实现的代码中,输入是from_tensor_slices,所以首先需要对数据集进行处理,如下:
1 | class DataProcessor(object): |
我们只需要继承这个类,重写这几个函数即可。数据处理完之后,来看看怎么接入下游任务,以分类任务为例,代码如下:
1 | def create_model(bert_config, is_training, input_ids, input_mask, segment_ids, |
接入下游任务,得到整个模型的loss之后,然后就开始finetune,如下:
1 |
|
之后运行main函数就可以完成整个训练了。
整个BERT模型的代码就看完啦,读下来的感受就是:非常的舒爽,不得不说,Google写的代码质量还是非常好的,虽然我擅长的是tensorflow2.x,但是tensorflow1.x的代码读起来还是没有什么障碍的。之后最好在自己的任务上使用BERT来看看效果~除此之外,如非必要,以后关于各种BERT的变体模型就不会解读它的源代码了,基本上都是照搬BERT的源码实现,然后在预训练任务上或者BERT模型主体部分的实现上进行一些改动,整体上大同小异~
over~☕️