廣告行業(yè)中那些趣事系列8:詳解BERT中分類器源碼

摘要:BERT是近幾年NLP領(lǐng)域中具有里程碑意義的存在。因?yàn)樾Ч煤蛻?yīng)用范圍廣所以被廣泛應(yīng)用于科學(xué)研究和工程項(xiàng)目中。廣告系列中前幾篇文章有從理論的方面講過BERT的原理,也有從實(shí)戰(zhàn)的方面講過使用BERT構(gòu)建分類模型。本篇從源碼的角度從整體到局部分析BERT模型中分類器部分的源碼。
目錄
01 整體模塊劃分
02 數(shù)據(jù)處理模塊
03 特征處理模塊
04 模型構(gòu)建模塊
05 模型運(yùn)行模塊
06 其他模塊
總結(jié)

整體模塊劃分
對于機(jī)器學(xué)習(xí)工程師來說,會(huì)調(diào)包跑程序應(yīng)該是萬里長征的第一步。這一步主要是幫助我們迅速將模型應(yīng)用到實(shí)際業(yè)務(wù)中,并且提升自信心,但這還遠(yuǎn)遠(yuǎn)不夠。要想根據(jù)不同的業(yè)務(wù)場景更好的使用模型,我們需要深層次的理解模型,讀點(diǎn)源碼才能走的更遠(yuǎn)。
本篇解讀的是BERT開源項(xiàng)目中分類器部分的源碼,從最開始的數(shù)據(jù)輸入到模型運(yùn)行整個(gè)流程主要可以分成數(shù)據(jù)處理模塊、特征處理模塊、模型構(gòu)建模塊和模型運(yùn)行模塊。具體如下圖所示:

圖1 BERT分類器整體模塊劃分
因?yàn)樵鷳B(tài)BERT預(yù)訓(xùn)練模型動(dòng)輒幾百兆甚至上千兆的大小,模型訓(xùn)練速度非常慢,對于BERT模型線上化非常不友好,所以使用目前比較火的BERT最新派生產(chǎn)品ALBERT來完成BERT線上化服務(wù)。ALBERT使用參數(shù)減少技術(shù)來降低內(nèi)存消耗從而最終達(dá)到提高BERT的訓(xùn)練速度,并且在主要基準(zhǔn)測試中均名列前茅,可謂跑的快,還跑的好。本篇解讀的BERT源碼也是基于ALBERT開源項(xiàng)目。
項(xiàng)目開源的github工程:https://github.com/wilsonlsm006/albert_zh
主要解讀分類器部分的源碼,代碼及注釋在run_classifier.py文件,歡迎小伙伴們fork。

數(shù)據(jù)處理模塊
數(shù)據(jù)處理模塊主要負(fù)責(zé)數(shù)據(jù)讀入和預(yù)處理功能。
數(shù)據(jù)處理主要由數(shù)據(jù)處理器DataProcessor來完成。根據(jù)不同的任務(wù)會(huì)有不同的數(shù)據(jù)處理器子類,這里的不同表現(xiàn)在數(shù)據(jù)讀入方式和數(shù)據(jù)預(yù)處理方面。
1. 數(shù)據(jù)讀入方式
實(shí)際項(xiàng)目中數(shù)據(jù)讀入的方式多種多樣,比如csv、tsv、txt等。比如有的項(xiàng)目是需要讀取csv文件,而有的則需要tsv或者txt格式。我們可以構(gòu)建自定義的數(shù)據(jù)處理器來完成不同的項(xiàng)目需求。
2. 數(shù)據(jù)預(yù)處理
數(shù)據(jù)預(yù)處理是根據(jù)不同的NLP任務(wù)來完成不同的操作,比如單句分類任務(wù)我們需要的是text_a和label格式。而句子相似關(guān)系判斷任務(wù)需要的是text_a,text_b,label格式。其他任務(wù)也是類似的,根據(jù)不同的NLP任務(wù)來完成數(shù)據(jù)預(yù)處理操作。
通過一個(gè)類圖來講解源碼中的數(shù)據(jù)處理器:

圖2 數(shù)據(jù)處理器類圖
對應(yīng)到項(xiàng)目源碼中,我們有一個(gè)DataProcessor父類。父類中有五個(gè)方法,分別是讀取tsv文件、獲得訓(xùn)練集、獲得驗(yàn)證集、獲得測試集和獲得標(biāo)簽。這里可根據(jù)業(yè)務(wù)需求增刪改獲取文件類型的函數(shù),比如讀取csv可以添加get_csv(input_file)等等。
class DataProcessor(object):"""Base class for data converters for sequence classification data sets."""def get_train_examples(self, data_dir):"""Gets a collection of `InputExample`s for the train set."""raise NotImplementedError()def get_dev_examples(self, data_dir):"""Gets a collection of `InputExample`s for the dev set."""raise NotImplementedError()def get_test_examples(self, data_dir):"""Gets a collection of `InputExample`s for prediction."""raise NotImplementedError()def get_labels(self):"""Gets the list of labels for this data set."""raise NotImplementedError()def _read_tsv(cls, input_file, quotechar=None):"""Reads a tab separated value file."""with tf.gfile.Open(input_file, "r") as f:reader = csv.reader(f, delimiter="\t", quotechar=quotechar)lines = []for line in reader:lines.append(line)return lines
下面兩個(gè)子類,分別是處理句子關(guān)系判斷任務(wù)的SentencePairClassificationProcessor數(shù)據(jù)處理器和LCQMCPairClassificationProcessor分類的數(shù)據(jù)處理器。前面文章有講過如果需要做單句分類的任務(wù)我們可以在這里添加一個(gè)SentenceClassifierProcess進(jìn)行定制化開發(fā)。
對應(yīng)到項(xiàng)目源碼中,因?yàn)槲覀兪蔷渥雨P(guān)系判斷任務(wù),其實(shí)就是判斷兩句話是不是有關(guān)系,這里我們得到的最終數(shù)據(jù)格式是列表類型,具體數(shù)據(jù)格式如下:
[(guid,text_a,text_b,label),(guid,text_a,text_b,label),....]
其中guid作為唯一識(shí)別text_a和text_b句子對的標(biāo)志,可以理解為該條樣例的唯一id;
text_a和text_b是需要判斷的兩個(gè)句子;
label字段就是標(biāo)簽,如果兩句話相似則置為1,否則為0。
上面四個(gè)字段guid和text_a是必須的。text_b是可選的,如果為空則變成單句分類任務(wù),不為空則是句子關(guān)系判斷任務(wù)。label在訓(xùn)練集和驗(yàn)證集是必須的,在測試集中可以不提供。
具體代碼在SentencePairClassificationProcessor子類的_create_examples函數(shù):
def _create_examples(self, lines, set_type):"""Creates examples for the training and dev sets."""examples = []print("length of lines:", len(lines))for (i, line) in enumerate(lines):# print('#i:',i,line)if i == 0:continueguid = "%s-%s" % (set_type, i)try:label = tokenization.convert_to_unicode(line[2])text_a = tokenization.convert_to_unicode(line[0])text_b = tokenization.convert_to_unicode(line[1])examples.append(InputExample(guid=guid, text_a=text_a, text_b=text_b, label=label))except Exception:print('###error.i:', i, line)return examples

特征處理模塊
特征處理模塊主要的功能是將數(shù)據(jù)處理模塊得到的數(shù)據(jù)轉(zhuǎn)化成特征并持久化到TFRecord文件中,由file_based_convert_examples_to_features函數(shù)完成。
"""將數(shù)據(jù)處理模塊得到的數(shù)據(jù)轉(zhuǎn)化成TFRecord文件input:examples:數(shù)據(jù)格式為[(guid,text_a,text_b,label),(guid,text_a,text_b,label),....]label_list:標(biāo)簽列表max_seq_length:允許的句子最大長度tokenizer:分詞器output_file:TFRecord文件存儲(chǔ)路徑output:持久化到TFRecord格式文件"""def file_based_convert_examples_to_features(examples,label_list,max_seq_length,tokenizer, output_file):
1. 預(yù)處理數(shù)據(jù)轉(zhuǎn)化成特征
數(shù)據(jù)轉(zhuǎn)化成特征的操作主要由函數(shù)convert_single_example完成。傳統(tǒng)的機(jī)器學(xué)習(xí)需要從數(shù)據(jù)中抽取特征,NLP任務(wù)是對文本進(jìn)行分詞等操作獲取特征。BERT模型中默認(rèn)每個(gè)字字就是一個(gè)詞。
"""將預(yù)處理數(shù)據(jù)加工成模型需要的特征input:ex_index:數(shù)據(jù)條數(shù)索引example:數(shù)據(jù)格式為[(guid,text_a,text_b,label),(guid,text_a,text_b,label),....]label_list:標(biāo)簽列表max_seq_length:允許的句子最大長度,這里如果輸入句子長度不足則補(bǔ)0tokenizer:分詞器output: feature = InputFeatures(input_ids=input_ids:token embedding:表示詞向量,第一個(gè)詞是CLS,分隔詞有SEP,是單詞本身input_mask=input_mask:position embedding:為了令transformer感知詞與詞之間的位置關(guān)系segment_ids=segment_ids:segment embedding:text_a與text_b的句子關(guān)系label_id=label_id:標(biāo)簽is_real_example=True)"""def convert_single_example(ex_index, example,label_list, max_seq_length,tokenizer):....feature = InputFeatures(input_ids=input_ids,input_mask=input_mask,segment_ids=segment_ids,label_id=label_id,is_real_example=True)return feature
論文中BERT模型的輸入轉(zhuǎn)化成特征如下圖所示:

圖3 句子輸入轉(zhuǎn)化成三層Embedding
這里需要注意下對text_a和text_b的預(yù)處理操作。首先會(huì)進(jìn)行標(biāo)記化將text_a和text_b轉(zhuǎn)化成tokens_a和tokens_b。如果tokens_b存在,那么tokens_a和tokens_b的長度就不能超過max_seq_length-3,因?yàn)樾枰尤?/span>cls,sep,seq三個(gè)符號(hào);如果tokens_b不存在,那么tokens_a的長度不能超過 max_seq_length -2 ,因?yàn)樾枰尤?/span> cls 和 sep符號(hào)。
這里通過一條具體的數(shù)據(jù)轉(zhuǎn)化成特征說明上述流程?,F(xiàn)在我們的example中有一條數(shù)據(jù),分別有三個(gè)字段:
text_a: 這種圖片是用什么軟件制作的?
text_b: 這種圖片制作是用什么軟件呢?
label: 1
經(jīng)過分詞之后,我們會(huì)得到:
tokens: [CLS] 這 種 圖 片 是 用 什 么 軟 件 制 作 的 ? [SEP] 這 種 圖 片 制 作 是 用 什 么 軟 件 呢 ? [SEP]
其中[CLS]是模型額外增加的開始標(biāo)志,說明這是句首位置。[SEP]代表分隔符,我們會(huì)將兩句話拼接成一句話,通過分隔符來識(shí)別。第二句話拼接完成后也會(huì)加上一個(gè)分隔符。這里需要注意的是BERT對于中文分詞是以每個(gè)字進(jìn)行切分,并不是我們通常理解的按照中文實(shí)際的詞進(jìn)行切分。
經(jīng)過特征提取之后變成了:
input_ids:101 6821 4905 1745 4275 3221 4500 784 720 6763 816 1169 868 46388043 102 6821 4905 1745 4275 1169 868 3221 4500 784 720 6763 816 1450 8043 1020 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
input_mask:1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0
segment_ids:0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 00 0 0 0 0 0 0 0 0 0 0 0 0 0
label_id:1
這里詳細(xì)說下我們真正給模型輸入的特征是什么。
input_ids代表詞向量編碼。NLP任務(wù)中我們會(huì)將文本轉(zhuǎn)化成詞向量的表征形式提供給模型。通過BERT源碼中的tokenizer將句子拆分成字,并且將字映射成id。比如上面例子中第一句話有14個(gè)字,第二句話也有14個(gè)字,再加上一個(gè)開始標(biāo)志和兩個(gè)分隔符,一種有31個(gè)字。而上面例子中的input_ids列表中前31個(gè)位置都有每個(gè)字映射的id,并且相同字的映射的id也是一樣的。其他則通過添加0進(jìn)行填充;
input_mask代表位置編碼。為了transformer感知詞與詞之間的位置關(guān)系,源碼中會(huì)將當(dāng)前位置有字的設(shè)置為1,其他用0進(jìn)行填充;
segment_ids代表句子關(guān)系編碼。如果是句子關(guān)系判斷任務(wù)則會(huì)將text_b位置對應(yīng)的句子關(guān)系編碼置為1。這里需要注意,只要是句子關(guān)系判斷任務(wù),不管兩句話到底有沒有關(guān)系,即標(biāo)簽是否為1都會(huì)將text_b位置對應(yīng)的句子關(guān)系編碼置為1;
label_id就代表兩句話是不是有關(guān)系。如果有關(guān)系則標(biāo)簽置為1,否則為0。
2. 特征存儲(chǔ)在TFRecord格式文件
當(dāng)我們進(jìn)行模型訓(xùn)練的時(shí)候,會(huì)將全部訓(xùn)練數(shù)據(jù)加載到內(nèi)存中。對于小規(guī)模數(shù)據(jù)集來說沒有問題,但是遇到大規(guī)模數(shù)據(jù)集時(shí)我們的內(nèi)存并不能加載全部的數(shù)據(jù),所以涉及到分批加載數(shù)據(jù)。Tensorflow給開發(fā)者提供了TFRecord格式文件。TFRecord內(nèi)部采用二進(jìn)制編碼,加載快,對大型數(shù)據(jù)轉(zhuǎn)換友好。
小結(jié)下,特征處理模塊主要將預(yù)處理得到的數(shù)據(jù)轉(zhuǎn)化成特征并存儲(chǔ)到TFRecord格式文件。BERT會(huì)將句子輸入轉(zhuǎn)化成三層Embedding編碼,第一層是詞編碼,主要表示詞本身;第二層編碼是位置編碼,主要為了transformer感知詞與詞之間的位置關(guān)系;第三層編碼則表示句與句之間關(guān)系。通過這三層編碼我們就得到了模型的特征輸入。為了方便大數(shù)據(jù)集下模型訓(xùn)練加載數(shù)據(jù),我們將特征持久化到TFRecord格式文件。

模型構(gòu)建模塊
模型構(gòu)建模塊主要分成模型構(gòu)建和模型標(biāo)準(zhǔn)輸入。
1. 模型構(gòu)建
通過函數(shù)model_fn_builder來構(gòu)建自定義模型估計(jì)器。
"""自定義模型估計(jì)器(model_fn_builder)input:bert_config:bert相關(guān)的配置num_labels:標(biāo)簽的數(shù)量init_checkpoint:預(yù)訓(xùn)練模型learning_rate:學(xué)習(xí)率num_train_steps:模型訓(xùn)練輪數(shù) = (訓(xùn)練集總數(shù)/batch_size)*epochsnum_warmup_steps:線性地增加學(xué)習(xí)率,num_warmup_steps = num_train_steps * warmup_proportionuse_tpu:是否使用TPUoutput:構(gòu)建好的模型"""def model_fn_builder(bert_config, num_labels, init_checkpoint, learning_rate,num_train_steps, num_warmup_steps, use_tpu,use_one_hot_embeddings):"""Returns `model_fn` closure for TPUEstimator."""......return model_fn
這里模型構(gòu)建主要有create_model函數(shù)完成,主要完成兩件事:第一是調(diào)用modeling.py中的BertModel類創(chuàng)建模型;第二是計(jì)算交叉熵?fù)p失loss。交叉熵的值越小,兩個(gè)概率分布就越接近。
"""創(chuàng)建模型,主要完成兩件事:第一件事是調(diào)用modeling.py中國的BertModel類創(chuàng)建模型;第二件事事計(jì)算交叉熵?fù)p失loss。交叉熵的值越小,兩個(gè)概率分布就越接近。"""def create_model(bert_config, is_training, input_ids, input_mask, segment_ids,labels, num_labels, use_one_hot_embeddings):"""Creates a classification model."""# 建立一個(gè)BERT分類模型(create_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)......return (loss, per_example_loss, logits, probabilities)
2. 模型標(biāo)準(zhǔn)輸入
因?yàn)樵错?xiàng)目是基于Tensorflow框架開發(fā),所以需要將前面得到的特征轉(zhuǎn)化成標(biāo)準(zhǔn)的Tensorflow模型輸入格式。這塊主要由函數(shù)file_based_input_fn_builder來完成。通過輸入文件的不同可以完成訓(xùn)練集、驗(yàn)證集和測試集的輸入。
"""模型標(biāo)準(zhǔn)輸入從TFRecord格式文件中讀取特征并轉(zhuǎn)化成TensorFlow標(biāo)準(zhǔn)的數(shù)據(jù)輸入格式input:input_file:input_file=train_file:輸入文件,可以是訓(xùn)練集、驗(yàn)證集和預(yù)測集seq_length=FLAGS.max_seq_length:句子最大長度is_training=True:是否訓(xùn)練標(biāo)志drop_remainder=True:表示在少于batch_size元素的情況下是否應(yīng)刪除最后一批 ; 默認(rèn)是不刪除。output:TensorFlow標(biāo)準(zhǔn)的格式輸入"""def file_based_input_fn_builder(input_file, seq_length, is_training,drop_remainder):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),}......return input_fn
這里需要注意的是is_training字段,對于訓(xùn)練數(shù)據(jù),需要大量的并行讀寫和打亂順序;而對于驗(yàn)證數(shù)據(jù),我們不希望打亂數(shù)據(jù),是否并行也不關(guān)心。
小結(jié)下,模型構(gòu)建模塊主要由模型構(gòu)建和模型標(biāo)準(zhǔn)輸入兩部分。模型構(gòu)建負(fù)責(zé)創(chuàng)建和配置BERT模型。模型標(biāo)準(zhǔn)輸入則讀取TFRecord格式文件并轉(zhuǎn)化成標(biāo)準(zhǔn)的模型輸入,根據(jù)輸入文件的不同完成訓(xùn)練集、驗(yàn)證集和測試集的標(biāo)準(zhǔn)輸入。

模型運(yùn)行模塊
上面模型構(gòu)建好了之后即可運(yùn)行模型。Tensorflow中模型運(yùn)行需要構(gòu)建一個(gè)Estimator對象。主要通過源碼中tf.contrib.tpu.TPUEstimator()來構(gòu)建。
"""Estimator對象包裝由model_fn指定的模型input:給定輸入和其他一些參數(shù)use_tpu:是否使用TPUmodel_fn:前面構(gòu)建好的模型config:模型運(yùn)行相關(guān)的配置train_batch_size:訓(xùn)練batch大小eval_batch_size:驗(yàn)證batch大小predict_batch_size:預(yù)測batch大小output:需要進(jìn)行訓(xùn)練、計(jì)算,或預(yù)測的操作"""estimator = tf.contrib.tpu.TPUEstimator(use_tpu=FLAGS.use_tpu,model_fn=model_fn,config=run_config,train_batch_size=FLAGS.train_batch_size,eval_batch_size=FLAGS.eval_batch_size,predict_batch_size=FLAGS.predict_batch_size)
1. 模型訓(xùn)練
模型訓(xùn)練通過estimator.train即可完成:
if FLAGS.do_train:train_input_fn = file_based_input_fn_builder(input_file=train_file,seq_length=FLAGS.max_seq_length,is_training=True,drop_remainder=True)....estimator.train(input_fn=train_input_fn, max_steps=num_train_steps)
2. 模型驗(yàn)證
模型驗(yàn)證通過estimator.evaluate即可完成:
if FLAGS.do_eval:= file_based_input_fn_builder(input_file=eval_file,seq_length=FLAGS.max_seq_length,is_training=False,drop_remainder=eval_drop_remainder)....result = estimator.evaluate(input_fn=eval_input_fn, steps=eval_steps, checkpoint_path=filename)
3. 模型預(yù)測
模型預(yù)測通過estimator.predict即可完成:
if FLAGS.do_predict:predict_input_fn = file_based_input_fn_builder(input_file=predict_file,seq_length=FLAGS.max_seq_length,is_training=False,drop_remainder=predict_drop_remainder)....result = estimator.predict(input_fn=predict_input_fn)

其他模塊
1. tf日志模塊
import tensorflow as tf# 日志的顯示等級(jí)tf.logging.set_verbosity(tf.logging.INFO)# 打印提示日志tf.logging.info("***** Runningtraining *****")# 打印傳參日志tf.logging.info(" Num examples = %d", len(train_examples))
2. 外部傳參模塊
import tensorflow as tfflags = tf.flagsFLAGS = flags.FLAGSflags.DEFINE_string("data_dir", None,"The input data dir. Should contain the .tsv files (or other datafiles) ""for thetask.")# 設(shè)置哪些參數(shù)是必須要傳入的flags.mark_flag_as_required("data_dir")
總結(jié)本篇主要講解BERT中分類器部分的源碼。整體來看主要分成數(shù)據(jù)處理模塊、特征處理模塊、模型構(gòu)建模塊和模型運(yùn)行模塊。數(shù)據(jù)處理模塊主要負(fù)責(zé)數(shù)據(jù)讀入和預(yù)處理工作;特征處理模塊負(fù)責(zé)將預(yù)處理后的數(shù)據(jù)轉(zhuǎn)化成特征并持久化到TFRecord格式文件中;模型構(gòu)建模塊主要負(fù)責(zé)構(gòu)建BERT模型和模型標(biāo)準(zhǔn)輸入數(shù)據(jù)準(zhǔn)備;模型運(yùn)行模塊主要負(fù)責(zé)模型訓(xùn)練、驗(yàn)證和預(yù)測。通過整體到局部的方式我們可以對BERT中的分類器源碼有深入的了解。后面可以根據(jù)實(shí)際的業(yè)務(wù)需求對分類器進(jìn)行二次開發(fā)。
