1. 程式人生 > 其它 >Bert模型實現垃圾郵件分類

Bert模型實現垃圾郵件分類

近日,對近些年在NLP領域很火的BERT模型進行了學習,並進行實踐。今天在這裡做一下筆記。

本篇部落格包含下列內容:

BERT模型簡介

  概覽

  BERT模型結構

BERT專案學習及程式碼走讀

  專案基本特性介紹

  程式碼走讀&要點歸納

基於BERT模型實現垃圾郵件分類

  TREC06語料庫

  基準模型介紹

  BERT遷移模型實現

一.BERT模型簡介

1.概覽

  BERT模型的全稱是Bidirectional Encoder Representations from Transformer,即Transformer模型的雙向編碼器。只看名稱可能很難看出門道,簡單點講,BERT模型就是一個Word2Vec的進化版,使用詞向量對自然語言進行表示,但其模型深度極大,引數也特別的多。以Bert_BASE模型來舉例,其包含12個隱藏層,每個隱層維度為768,每層又包含12個attention head,總共有110M個引數,模型引數檔案在硬碟上就佔據400MB的空間。

BERT是一個預訓練模型,即通過半監督學習的方式,在海量的語料庫上學習出單詞的良好特徵表示。其在11個經典NLP任務中都展現出了最佳的效能。Bert模型一共有4個特徵:

  ①預訓練:是一個預先訓練好的語言模型,所有未來的開發者都可以直接繼承使用。

  ②深度:是一個很深的模型,Bert_BASE的層數是12,Bert_LARGE的層數是24。

  ③雙向Transformer:BERT是在基於Attention原理的Transformer模型上發展而來,通過丟棄 Transformer 中的 Decoder 模組(僅保留Encoder),BERT 具有雙向編碼能力和強大的特徵提取能力。

  ④自然語言理解:其半監督學習方式,更強調模型對自然語言的理解能力,而不是語言生成。

2.BERT模型結構

  BERT模型的結構圖如上所示。以Bert_BASE模型為例:其輸入為符合化之後的向量,通過Embedding(嵌入)層,完成一些基本的預處理工作,之後就是由12個隱藏層組成的Transformer模型結構,最後的Pooling(池化)層,完成降維,輸出最終結果。

預訓練工作:

  Bert模型的預訓練工作包含2個任務:即掩碼語言模型任務和句子對匹配檢驗任務。

掩碼語言模型任務:在訓練過程中,從輸入句子中遮蔽一些詞,然後根據上下文來嘗試將這些詞進行復原(類似於英語考試的完形填空)。在半監督學習的過程當中,會有15%的詞彙被隨機遮蔽,其中的80%直接替換為[MASK],10%替換成其他詞語,另外10%保持原詞彙不變。

句子對匹配檢驗任務:句子A和句子B一起輸入到BERT模型,由BERT模型來判斷句子 B 是否是後面的句子 A(True/False)。訓練資料是從平行語料中隨機抽取兩個連續的句子生成的,50%的樣本保留抽取的兩個句子(True),其餘50%樣本的第二個句子從語料庫中隨機抽取(False) .

上述2個任務都是使用維基百科作為訓練語料庫。

二.BERT專案學習及程式碼走讀

1.專案基本特性介紹

  這裡以GitHub上的bert-master專案為例(https://github.com/google-research/bert),對BERT模型的原始碼進行學習,瞭解其流程,掌握要點。

  bert專案是Google Research最早開源的一個BERT模型專案。第一眼看過去,這個專案還真的挺複雜的,沒有直接能執行的demo不說(只有程式碼,沒有示例資料),預訓練基準模型也需要另外下載。這裡我下載了兩個預訓練模型存放在bert-master專案中的models資料夾內。基準模型下載地址可以在README.md檔案中找到。

英文BERT模型:uncased_L-12_H-768_A-12

https://storage.googleapis.com/bert_models/2018_10_18/uncased_L-12_H-768_A-12.zip

中文BERT模型:chinese_L-12_H-768_A-12

https://storage.googleapis.com/bert_models/2018_11_03/chinese_L-12_H-768_A-12.zip

  通過閱讀README檔案,發現BERT模型的main函式在run_classifier.py程式碼檔案中,但其使用也較為複雜,需要在執行run_classifier.py程式碼時傳入大量的引數,README檔案中示例如下:

為了簡單使用,儘量少的輸入引數,製作shell指令碼/bat檔案,來方便的執行程式碼。不再對 BERT_BASE_DIR 和 GLUE_DIR 環境變數進行設定,直接將對應的資料位置、基準模型位置的相對路徑進行填入即可。

run.sh

run.bat

2.程式碼走讀&要點歸納

run_classifier.py

1.樣本類

  包含InputExample()類和PaddingInputExample()類。規定了BERT模型輸入樣本的格式和內容,其中PaddingInputExample()類是在樣本數量不足的情況下,通常是訓練的最後一個batch,填入多個空樣本,將樣本數量填充至batch_size。

2.資料預處理類

包含DataProcessor()類以及繼承該類的各個子類。DataProcessor()類不提供具體的資料預處理方法,需要各子類來編寫完成具體的資料預處理方法。程式碼中自帶Xnliprocessor(),MnliProcessor(),MrpcProcessor(),ColaProcessor()四個資料預處理子類,分別對應4種不同的公共資料集。仿照這4種資料預處理子類,我們可以編寫自己的資料預處理類,來對自己的資料集進行處理,以適配BERT模型,這些內容會在第三部分具體講解。

3. convert_single_example()函式

  將一個樣本(字串型別),經過符號化等一系列操作,轉換成神經網路可以使用的InputFeature(List向量型別)。其中InputFeature包含5部分內容:

  input_ids: 即各word的位置序列(在詞彙表中的位置) 如[101,123,4342,5423,632,732,....,0]

  input_mask: 即word掩碼,1為真貨,0為序列填充 如[1,1,1,1,1,1,1,...,0]

  segment_ids: 即2個句子的標註序列, 如[0,0,0,0,0,0,1,1,1,1,1,1]。對於 segment_ids 如2個句子後還不到最大長度,則後面用0填充。

  label_id: 即標籤的位置,在 label_list 中的位置。

  is_real_example: 是否為真正樣本的標記,取值為布林值。

  該樣本轉換函式主要通過tokenization.py程式碼中的FullTokenizer()類來完成上述功能。

4. file_based_convert_examples_to_features()函式

將一組樣本(由InputFeatures類組成),轉化為 tf.train.Example類,並將轉換好的樣本內容寫入檔案output_file,包含train.tf_record,eval.tf_record,predict.tf_record 三類檔案,與執行程式時的do_train、do_eval、do_predict引數相關聯。需要呼叫上方的convert_single_example()函式。

5. file_based_input_fn_builder()函式

該函式是一個樣本迭代器建構函式,最終返回input_fn()函式,該input_fn函式類似一個迭代器,從 train.tf_record / eval.tf_record / predict.tf_record檔案中讀取資料,按照 batch_size 進行資料的 shuffle(亂序),之後轉資料型別為tf.int32,再返回轉換後資料。

6. model_fn_builder()函式

該函式是一個模型函式的生成函式,其返回內容model_fn會作為引數傳入tf.contrib.tpu.TPUEstimator類中,在初始化該類時加以使用。model_fn_builder()函式的主體內容即model_fn,它通過呼叫create_model函式,使用modeling.py程式碼中的BertModel類來建立深度學習網路模型,並完成載入基準BERT模型(init_checkpoint引數),定義optimizer,設定網路訓練步驟等操作,最終返回的output_spec為tf.contrib.tpu.TPUEstimatorSpec類物件。

tokenization.py

1. FullTokenizer()類:

  該類為符號化類,在run_classifier.py中的convert_single_example()方法中加以使用。主要功能由tokenize()函式進行實現,將字串轉換為由詞彙表(vocab.txt)中所包含詞彙組成的List。核心為兩層巢狀的for迴圈。外層for迴圈分割單詞,漢字,標點符號,組成一個list;內層for迴圈嘗試將詞彙表中不存在的單詞分割成多個子串,例如:”unaffable”→[“un”, ”##aff”, ”##able”],可以在保留詞根的同時,擴充了詞彙的延展性。實在分割不出來的未知詞彙用[UNK]代替。

2.符號化支援類:

  包括BasicTokenizer類和WordpieceTokenizer類,這兩個類負責完成FullTokenizer()類中的具體功能。其中,BasicTokenizer類完成的工作包括去除口音詞彙,去除標點符號,判斷中文字元,去除間隔符等。WordpieceTokenizer類主要完成最大子字串搜尋功能。

modeling.py

  在這個程式碼檔案中,BertModel類來完成整個模型的建立,架構工作。其結構分為嵌入層,編碼層(隱藏層),池化層。其這幾個層是並列關係,名稱空間結構關係如下圖:

其中,嵌入層的embedding_lookup ()函式,輸出最基本的詞向量 embeddings,其shape 為 [batch_size,seq_length,hidden_size];編碼層embedding_postprocessor 函式,將三個embeddings進行加和,最終輸出shape 為[batch_size,seq_length,hidden_size]的tensor;池化層在進行降維操作時,僅取seq_length 維(即第二個維度)的第一個embedding(即第一個token),池化後, 作為輸出的變數self.pooled_output的shape為[batch_size,hidden_size]。

需要特別注意的是,transformer_model()函式實現了上文中提到的attention模型。其源論文” Attention is All You Need”可以在https://arxiv.org/abs/1706.03762檢視。

三.基於Bert模型實現垃圾郵件分類

  在上一期的部落格中,使用SVM來完成垃圾郵件分類工作,本期使用BERT來構造遷移模型,以實現垃圾郵件的分類。

1.TREC06語料庫

  本次實踐工作依然使用2006 TREC Public Spam Corpora 語料庫,包含2組資料集,即中文資料集trec06c和英文資料集trec06p。這篇部落格中以中文資料集trec06c資料集作範例。

  在該資料集中,每個郵件以GBK編碼單獨儲存在一個檔案內,儲存了原始郵件的所有資料,包括髮送方郵箱、接收方郵箱地址、郵件傳送時間等。郵件示例如下:

資料集中共包含21766個正樣本,42854個負樣本,我們根據其樣本索引檔案(full/index)進行預處理工作,使其正負樣本達到1:1的均衡比例,最終得到的索引檔案中包含21766個正樣本以及21766個負樣本。

2.基準模型介紹

  這裡使用的基準模型是chinese_L-12_H-768_A-12模型,其具體配置引數資訊如下:

attention_probs_dropout_prob

0.1

directionality

“bidi”

hidden_act

"gelu"

hidden_dropout_prob

0.1

hidden_size

768

initializer_range

0.02

intermediate_size

3072

max_position_embeddings

512

num_attention_heads

12

num_hidden_layers

12

pooler_fc_size

768

pooler_num_attention_heads

12

pooler_num_fc_layers

3

pooler_size_per_head

128

pooler_type

"first_token_transform"

type_vocab_size

2

vocab_size

21128

  可以看到,BERT模型的詞彙表數量是21128,相比上篇部落格中的SVM模型(詞彙數量95963)要少很多。其原因是Bert模型以單個漢字為基礎單位,而SVM模型是以詞彙(片語)為基礎單位。

3.BERT遷移模型實現

BERT模型並不能夠直接判斷郵件是否為垃圾郵件,其模型輸出也是一個長度為hidden_size的詞向量。因此需要使用BERT作為基礎模型,然後加入相應的全連線層和啟用函式,完成遷移模型,以對郵件進行分類。基於BERT預訓練模型已具備自然語言理解能力的情況下,對模型引數進行訓練和微調後,即可實現對垃圾郵件進行分類的功能。

使用Bert-master專案製作遷移模型,主要的工作是需要將自己的trec06c資料轉換成Bert模型所需要的格式。通過前面對bert-master專案程式碼進行閱讀,發現其資料預處理部分位於run_classifier.py程式碼中的DataProcessor()類附近,DataProcessor()類為其父類,我們需要編寫一個數據預處理子類來對trec06c資料進行處理、轉換。資料預處理、轉換的流程圖如下:

首先讀取索引檔案,獲得索引列表;第二步根據索引列表,讀取每一個郵件檔案;對每一封郵件進行處理,包括移除頭部資訊獲取正文、字元解碼、字串拼接等操作,將一個郵件檔案轉換成為一個字串,並形成Content_List;第四步是對每一個字串文字內容進行封裝,將其轉換為InputExample類的物件,並形成Example_List,這是所有樣本的集合;最後根據8:1:1的比例,劃分訓練集(train set)、驗證集(dev set)和測試集(test set)。根據上述流程完成CN_trec06c_Processor(DataProcessor)類,其程式碼如下:

  1 # TODO ===============自己的dataProcessor trec06c 資料 原始email,散裝資料=====================
  2 # 需要繼承 DataProcessor,並重新裡面的幾個資料預處理函式。
  3 class CN_trec06c_Processor(DataProcessor):    
  4   def __init__(self):
  5     # 定義一些超引數
  6     self.MAX_EMAIL_LENGTH = 400   #最長單個郵件長度
  7   def get_email_file(self,base_path,path_list):
  8     email_str_list = []
  9     for i in range(len(path_list)):
 10       with open(base_path + path_list[i][1:],'r',encoding='gbk') as fin:
 11         words = ""
 12         begin_tag = 0
 13         wrong_tag = 0
 14         while(True):
 15           if wrong_tag > 20 or len(words)>self.MAX_EMAIL_LENGTH:
 16             break
 17           try:
 18             line = fin.readline()
 19             wrong_tag = 0
 20           except:
 21             wrong_tag += 1
 22             continue
 23           if (not line):
 24             break
 25           if(begin_tag == 0):
 26             if(line=='\n'):
 27               begin_tag = 1
 28             continue
 29           else:
 30             words += line.strip() + ' '
 31             if len(words)>self.MAX_EMAIL_LENGTH:
 32               break
 33         if len(words)>=10:   # 語句最短長度
 34           email_str_list.append(words)
 35     return email_str_list
 36   def get_all_examples(self):
 37     trec06Path = "../../02_SVM_analysis/data/"  # trec06c資料位置
 38     path_list_spam = []
 39     with open(trec06Path+'CN_index_spam','r',encoding='utf-8') as fin:
 40       for line in fin.readlines():
 41         path_list_spam.append(line.strip())
 42     path_list_ham = []
 43     with open(trec06Path+'CN_index_ham','r',encoding='utf-8') as fin:
 44       for line in fin.readlines():
 45         path_list_ham.append(line.strip())
 46     # 是否對原始資料長度作裁剪  共 21766 個 正例  21766個 負例
 47     path_list_spam = path_list_spam[:100]  #這裡僅取100個樣本進行本機測試
 48     path_list_ham = path_list_ham[:100]
 49     spam_email_list = self.get_email_file(trec06Path[:-6],path_list_spam)
 50     ham_email_list = self.get_email_file(trec06Path[:-6],path_list_ham)
 51     print("*****************====================*****************")
 52     print("正例樣本數量: ",len(ham_email_list))
 53     print("反例樣本數量: ",len(spam_email_list))
 54     with open('model_spam_tuning/CN_trec06c/train_sample_stat.txt','w',encoding='utf-8') as fout:
 55       fout.write("正例樣本數量: " + str(len(ham_email_list)) + '\n')
 56       fout.write("反例樣本數量: " + str(len(spam_email_list)) + '\n')
 57     print("*****************====================*****************")
 58     examples = []
 59     for i in range(len(spam_email_list)):
 60       guid = "train-%d" % (i)  # 從 0 開始
 61       # TODO 下方,tokenization.convert_to_unicode() 函式,將byte類資料 decode成為'utf-8'
 62       text_a = tokenization.convert_to_unicode(str(spam_email_list[i]))
 63       label = '0' # 轉int類 是後續的操作,此處仍舊是str 垃圾郵件label為0
 64       examples.append(
 65           InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
 66     for i in range(len(ham_email_list)):
 67       guid = "train-%d" % (i+len(spam_email_list))  # 從 垃圾郵件長度 開始向後續
 68       # TODO 下方,tokenization.convert_to_unicode() 函式,將byte類資料 decode成為'utf-8'
 69       text_a = tokenization.convert_to_unicode(str(ham_email_list[i]))
 70       label = '1' # 轉int類 是後續的操作,此處仍舊是str 正常郵件label為1
 71       examples.append(
 72           InputExample(guid=guid, text_a=text_a, text_b=None, label=label))
 73     # TODO 還要進行切分 測試集,訓練集,驗證集
 74     return examples
 75 
 76   def get_train_examples(self, data_dir):
 77     # train_data_path = os.path.join(data_dir, "cn_train_tiny_tiny.csv") # 訓練集 資料檔名稱,可以在這裡改
 78     examples = self.get_all_examples()
 79     ex_new = []
 80     for i in range(len(examples)):
 81       if i%10 != 1 and i%10 != 2: # 8:1:1 切分訓練集,驗證集,測試集
 82         ex_new.append(examples[i])
 83     return ex_new
 84 
 85   def get_dev_examples(self, data_dir):
 86     """Gets a collection of `InputExample`s for the dev set."""
 87     examples = self.get_all_examples()
 88     ex_new = []
 89     for i in range(len(examples)):
 90       if i%10 == 1:  # 8:1:1 切分訓練集,驗證集,測試集
 91         ex_new.append(examples[i])
 92     return ex_new
 93 
 94   def get_test_examples(self, data_dir):
 95     """Gets a collection of `InputExample`s for prediction."""
 96     test_set_txt = []
 97     test_set_label = []
 98     examples = self.get_all_examples()
 99     ex_new = []
100     for i in range(len(examples)):
101       if i%10 == 2:  # 8:1:1 切分訓練集,驗證集,測試集
102         ex_new.append(examples[i])
103         test_set_txt.append(examples[i].text_a)
104         test_set_label.append(examples[i].label)
105     with open('model_spam_tuning/CN_trec06c/test_origin.txt','w',encoding='utf-8') as fout:
106       for i in range(len(test_set_label)):
107         fout.write(test_set_label[i]+'\t'+test_set_txt[i]+'\n')
108     return ex_new
109 
110   def get_labels(self):
111     """Gets the list of labels for this data set."""
112     return ['0','1']
113 # TODO ===============自己的dataProcessor trec06c 資料=====================

  其中,get_email_file()函式讀取路徑List中所有郵件檔案的內容,並完成預處理、篩選工作,返回郵件內容List;get_all_examples()函式分別讀取垃圾郵件路徑索引List以及正常郵件路徑索引List,呼叫get_email_file()函式獲取所有郵件的內容,將格式化為BERT模型所需要的格式,即InputExample()類,包括guid,text_a,text_b,label四個屬性。後面get_train_examples(),get_dev_examples(),get_test_examples()三個函式對所有的郵件樣本進行劃分,分別得到訓練集,驗證集,測試集。需要注意的是,這三個函式的名稱不能隨意更動,它們是對父類DataProcessor()中同名方法的具體實現。最後一個函式get_labels()返回標籤列表,分幾類有返回幾種標籤,需要注意的是這裡的標籤仍然是字串型別。

  編寫完成資料預處理類之後,我們需要給Bert模型新增相應的處理任務,將CN_trec06c_Processor類新增到下方main()函式的processors字典變數中,如下圖。需要注意字典的key值必須全部用小寫字母。

這時,基於BERT的遷移模型已經構建完畢,使用前面編寫好的run.sh(Linux)或run.bat(windows)即可執行run_classifier.py指令碼開始訓練。由於bert-master專案會將所有的資料樣本讀入視訊記憶體進行訓練,因此在我本機環境下難以執行(GTX 860、2G視訊記憶體),僅能執行100個樣本。因此,借用一個朋友的雲伺服器(GTX 1080Ti、10G視訊記憶體)開展實驗。硬體配置&環境配置版本如下:

在超引數配置為下表的情況下,訓練了25個epochs,耗時約3個小時。

下圖為訓練時的輸出,可以看出雲伺服器每秒可以完成120多個樣本的訓練。

最終得到的結果在模型資料夾的eval_result.txt檔案中,最終模型在驗證集上的分類準確率達到了99.347%,比之前的SVM模型要準確不少。

而該BERT遷移模型對於測試集的分類結果則在predict_results.tsv檔案中。

  對於英文郵件建立BERT遷移模型的方法與中文類似,需要完成一個英文郵件的資料預處理類,並將其新增到main()函式的processors字典變數中。程式碼就不羅列了,最終得到的英文垃圾郵件分類準確率為98.81% 。