近日,对近些年在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% 。