1 引言

各位朋友大家好,欢迎来到月来客栈,我是掌柜空字符。

经过前面两篇文章[1][2]的介绍,相信大家对于BERT的原理以及实现过程已经有了比较清晰的理解。同时,我们都知道BERT是一个强大的预训练模型,它可以基于谷歌发布的预训练参数在各个下游任务中进行微调。因此,在本篇文章中,掌柜将会介绍第一个下游微调场景,即如何在文本分类场景中基于BERT预训练模型进行微调。

总的来说,基于BERT的文本分类模型就是在原始的BERT模型后再加上一个分类层即可,类似的结构掌柜在文章This post is all you need(基于Transformer的分类模型)[7]中也介绍过,大家可以去看一下。同时,对于分类层的输入(也就是原始BERT的输出),默认情况下取BERT输出结果中[CLS]位置对于的向量即可,当然也可以修改为其它方式,例如所有位置向量的均值等。因此,对于基于BERT的文本分类模型来说其输入就是BERT的输入,输出则是每个类别对应的logits值。接下来,掌柜首先就来介绍如何构造文本分类的数据集。

以下所有完整示例代码均可从仓库 https://github.com/moon-hotel/BertWithPretrained 中获取!

2 数据预处理

2.1 输入介绍

在构建数据集之前,我们首先需要知道的是模型到底应该接收什么样的输入,然后才能构建出正确的数据形式。在上面我们说到,基于BERT的文本分类模型的输入就等价于BERT模型的输入,同时根据文章[2]的介绍可以知道BERT模型的输入如图1所示:

图 1. BERT模型输入图

由于对于文本分类这个场景来说其输入只有一个序列,所以在构建数据集的时候并不需要构造Segment Embedding的输入,直接默认使用全为0即可(可见文章[9] 第2.2.4节);同时,对于Position Embedding来说在任何场景下都不需要对其指定输入,因为我们在代码实现时已经做了相应默认时的处理(同样见文章[9] 第2.2.4节)。

因此,对于文本分类这个场景来说,只需要构造原始文本对应的Token序列,并在首位分别再加上一个[CLS]符和[SEP]符作为输入即可。

2.2 语料介绍

在这里,我们使用到的数据集是今日头条开放的一个新闻分类数据集[3],一共包含有382688条数据,15个类别。同时掌柜已近将其进行了格式化处理,以7:2:1的比例划分成了训练集、验证集和测试集三个部分。如下所示便是部分示例数据:

其中_!_左边为新闻标题,也就是后面需要用到的分类文本,右边为类别标签。

后台回复“数据集”即可获取网盘链接!

2.3 数据集预览

同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个构建的流程,以便做到心中有数,不会迷路。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图2所示。

图 2. 文本分类数据集处理流程图

如图2所示,第1步需要将原始的数据样本进行分字(tokenize)处理;第2步再根据tokenize后的结果构造一个字典,不过在使用BERT预训练时并不需要我们自己来构造这个字典,直接载入谷歌开源的vocab.txt文件构造字典即可,因为只有vocab.txt中每个字的索引顺序才与开源模型中每个字的Embedding向量一一对应的。第3步则是根据字典将tokenize后的文本序列转换为Token序列,同时在Token序列的首尾分别加上[CLS][SEP]符号,并进行Padding。第4步则是根据第3步处理后的结果生成对应的Padding Mask向量。

最后,在模型训练时只需要将第3步和第4步处理后的结果一起喂给模型即可。

2.4 数据集构建

第1步:定义tokenize

第1步需要完成的就是将输入进来的文本序列tokenize到字符级别。对于中文语料来说就是将每个字和标点符号都给切分开。在这里,我们可以借用transformers包中的BertTokenizer方法来完成,如下所示:

在上述代码中,第2-3行就是根据指定的路径(BERT预训练模型的路径)来载入一个分字模型;第7-8行便是tokenize后的结果。

第2步:建立词表

由于BERT预训练模型中已经有了一个给定的词表(vocab.txt),因此我们并不需要根据自己的语料来建立一个词表。当然,也不能够根据自己的语料来建立词表,因为相同的字在我们自己构建的词表中和vocab.txt中的索引顺序肯定会不一样,而这就会导致后面根据token id 取出来的向量是错误的。

进一步,我们只需要将vocab.txt中的内容读取进来形成一个词表即可,代码如下:

接着便可以定义一个方法来实例化一个词表:

在经过上述代码处理后,我们便能够通过vocab.itos得到一个列表,返回词表中的每一个词;通过vocab.itos[2]返回得到词表中对应索引位置上的词;通过vocab.stoi得到一个字典,返回词表中每个词的索引;通过vocab.stoi['月']返回得到词表中对应词的索引;通过len(vocab)来返回词表的长度。如下便是建立后的词表:

此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建等工作,代码如下:

在上述代码中,vocab_path表示本地词表的路径。max_sen_len表示最大样本长度,当max_sen_len = None时,即以每个batch中最长样本长度为标准,对其它进行padding;当max_sen_len = 'same'时,以整个数据集中最长样本为标准,对其它进行padding;当max_sen_len = 50, 表示以某个固定长度符样本进行padding,多余的截掉。split_sep表示样本与标签之间的分隔符。is_sample_shuffle表示是否打乱数据集。第14-15行为建立词表并取对应特殊字符的索引;第18行中max_position_embeddings为最大样本长度,最大为512。第19-21行则是用来判断传入的最大样本长度。

第3步:转换为Token序列

在得到构建的字典后,便可以通过如下函数来将训练集、验证集和测试集转换成Token序列:

在上述代码中,第6-7行便是用来取得文本和标签;第8行则是首先对序列进行tokenize,然后转换成Token序列并在最前面加上分类标志位[CLS]。第9-11行则是用来对Token序列进行截取,最长为max_position_embeddings个字符即512,并同时在末尾加上[SEP]符号。不过掌柜认为其实不加应该也不会有影响,因为这本来是单个序列的分类。第14行则是用来保存最长序列的长度。在处理完成后,2.2节中的4个样本将会被转换成如下形式:

从上面的输出结果可以看出,101就是[CLS]在词表中的索引位置,102则是[SEP]在词表中的索引;其它非0值就是tokenize后的文本序列转换成的Token序列。同时可以看出,这里的结果是以第3个样本的长度39对其它样本进行padding的,并且padding的Token ID为0。因此,下面我们就来介绍样本的padding处理。

第4步:padding处理与mask

从第3步的输出结果看出,在对原始文本序列tokenize转换为Token ID后还需要对其进行padding处理。对于这一处理过程可以通过如下代码来完成:

在上述代码中,sequences为待padding的序列所构成的列表,其中的每一个元素为一个样本的Token序列;batch_first表示是否将batch_size这个维度放在第1个;max_len表示指定最大序列长度,当max_len = 50时,表示以某个固定长度对样本进行padding多余的截掉,当max_len=None时表示以当前batch中最长样本的长度对其它进行padding。第2-3行用来获取padding的长度;第5-11行则是遍历每一个Token序列,根据max_len来进行padding。

进一步,我们需要定义一个函数来对每个batch的Token序列进行padding处理:

上述代码的作用就是对每个batch的Token序列进行padding处理。

最后,对于每一序列的attention_mask向量,我们只需要判断其是否等于padding_value便可以得到这一结果,可见第5步中的使用示例。

第5步:构造DataLoade与使用示例

经过前面4步的操作,整个数据集的构建就算是已经基本完成了,只需要再构造一个DataLoader迭代器即可,代码如下:

在上述代码中,第5-7行用来得到预处理后的数据并构造对应的DataLoader,其中generate_batch将作为一个参数传入来对每个batch的样本进行处理;第8-9行则判断是否只返回测试集;同理,第10-18行则是用来构造相应的训练集和验证集。在完成类LoadSingleSentenceClassificationDataset所有的编码过程后,便可以通过如下形式进行使用:

执行完上述代码后便可以得到如下所示的结果:

到此,对于整个数据集构建部分的内容就算是介绍完了,接下来我们再来看如何加载预训练模型进行微调。

3 加载预训练模型

在介绍模型微调之前,我们先来看看当我们拿到一个开源的模型参数后怎么读取以及分析。下面掌柜就以huggingface开源的PyTorch训练的bert-base-chinese模型参数[4]为例进行介绍。

3.1 查看模型参数

前一篇文章[5]中,尽管掌柜已经大致介绍了如何通过PyTorch来读取和加载模型参数,但是这里仍旧有必要以bert-base-chinese参数为例再进行一次详细的介绍。通常,对于一个通过PyTorch框架保存的模型参数,我们可以通过如下方式来进行载入:

执行完上述代码后,便可以得到如下输出结果:

从上面的输出结果可以看到,参数pytorch_model.bin被载入后变成了一个有序的字典OrderedDict,并且其中一共有207个参数,其名字分别就是列表中的各个元素。进一步,我们还可以将各个参数的形状打印出来看一看:

同样,我们还可以直接打印出某个参数具体的值。

到此,对于本地的模型参数我们就算是看明白了。不过想要将它迁移到自己所搭建的模型上还要进一步的来分析自己所搭建的模型。

3.2 载入模型配置

在继续往下介绍之前,我们先来看看如何载入本地的BERT配置参数config.json这个文件。通常情况下我们都会定义一个配置类,然将这些参数从config.json载入后来实例化这个配置类,代码如下所示:

在上述代码中,第5-29行是通过指定参数值来实例化BertConfig这个类;第40行的from_json_file()则是通过指定的路径来载入本地的config.json配置文件(如下所示),其中的cls表示未经实例化的BertConfig类,是Python语法中@classmethod的用法。

定义完成后便可以通过如下的方式来载入config.json配置文件。

接下里便可以以访问类成员的方式config.hidden_size来使用这些参数。

3.3 载入并初始化

上一篇文章[2]中,掌柜已经详细地介绍了如何实现整个BERT模型,但是对于如何载入已有参数来初始化网络中的参数还并未介绍。在将本地参数迁移到一个新的模型之前,除了像2.1节那样分析本地参数之外,我们还需要将网络的参数信息也打印出来看一下,以便将两者一一对应上。

在执行完上述代码后,便可以得到如下输出结果:

从上面的输出结果可以发现,BertMolde一共有200个参数,而bert-base-chinese一共有207个参数。这里需要注意的是BertMolde模型中的position_ids这个参数并不是模型中需要训练的参数,只是一个默认的初始值。最后,经分析(两者一一进行对比)后发现bert-base-chinese中除了最后的8个参数以外,其余的199个参数和BertMolde模型中的199个参数一样且顺序也一样。

因此,最后我们可以通过在BertMolde类(Bert.py文件中)中再加入一个如下所示的函数来用bert-base-chinese中的参数初始化BertMolde中的参数:

在上述代码中,第4-5行用来载入本地的bert-base-chinese参数;第6行用来拷贝一份BertModel中的网络参数,这是因为我们无法直接修改里面的值;第7-10行则是根据我们上面的分析,将bert-base-chinese中的参数赋值到state_dict中;第12行是用state_dict中的参数来初始化BertModel中的参数。

最后,我们只需要通过如下方式便可以返回一个通过bert-base-chinese初始化的BERT模型:

当然,如果你需要冻结其中某些层的参数不参与模型训练,那么可以通过类似如下所示的代码来进行设置:

到此,对于整个预训练模型的加载过程就介绍完了,接下来让我们正式进入到基于BERT预训练模型的文本分类场景中。

4 文本分类

4.1 工程结构

为了使得大家对于整个工程有着清晰的认识,掌柜这里先来介绍一下整个项目的目录结构。

图 3. 工程目录结构图

如图3所示,就是我们这个实战项目的工程结构,其中:

  • bert_base_chinese目录里面是原始中文BERT的配置文件和模型参数;

  • cache目录用来保存微调后保存的模型;

  • data里面存放了各类下游任务需要用到的数据集;

  • logs目录用来保存日志;

  • model目录里面是实现的模型代码:

    • BasicBert目录里面是实现整个BERT模型,包括加载预训练参数所用到的代码;
    • DownstreamTasks目录里面实现的是各类基于BERT结构所实现的模型代码,例如BertForSentenceClassification.py中实现的便是基于BERT的文本分类模型,后续我们还会在同级目录中来新建其它场景下基于BERT的模型代码;
  • Tasks目录里面实现的则是model目录中对应下游场景任务的训练代码,例如TaskForSingleSentenceClassification.py实现的则是基于单文本进行分类的训练代码,后续也会添加其它场景下的训练代码;

  • test目录中是各个类的测试文件;

  • utils目录中是一些工具类文件,例如数据集载入和日志打印工具等。

4.2 文本分类前向传播

在介绍完如何将bert-base-chinese中的参数赋值到BERT模型中以及整个工程的目录结构后,接下来我们首先要做的就是实现文本分类的前向传播过程。在图3中的BertForSentenceClassification.py文件中,我们通过定义如下一个类来完成整个前向传播的过程:

在上述代码中,第4行代码分别就是用来指定模型配置、分类的标签数量以及预训练模型的路径;第7-10行代码则是用来定义一个BERT模型,可以看到如果预训练模型的路径存在则会返回一个由bert-base-chinese参数初始化后的BERT模型,否则则会返回一个随机初始化参数的BERT模型;第12行则是定义最后的分类层。

最后,整个前向传播的实现代码如下所示:

在上述代码中,第6-9行返回的就是原始BERT网络的输出,其中pooled_output为BERT第一个位置的向量经过一个全连接层后的结果,第二个参数是BERT中所有位置的向量(具体可以参见文章[2]的第2.6节内容);第10-11行便是用来进行文本分类的分类层;第12-17行则是用来判断返回损失值还是返回logits值。

4.3 模型训练

如图3所示,我们将在Task目录下新建一个名为TaskForSingleSentenceClassification的模块来完成分类模型的微调训练任务。

首先,我们需要定义一个ModelConfig类来对分类模型中的超参数进行管理,代码如下所示:

在上述代码中,第2-23行则是分别用来定义模型中的一些数据集目录、超参数和初始化日志打印类等;第25-29行则是将原始bert_base_chinese配置文件,即config.json中的参数也导入到类ModelConfig中;第31-33行则是将所有的超参数配置情况一同打印到日志文件中方便后续分析,更多关于日志的内容可以参加文章训练模型时如何便捷保存训练日志[8]

最后,我们只需要再定义一个train()函数来完成模型的训练即可,代码如下:

在上述代码中,第2-3行用来初始化一个基于BERT的文本分类模型;第9-19行则是载入相应的数据集;第20-39行则是整个模型的训练过程,完整示例代码可参见[6],掌柜也在代码中进行了详细的注释。

如下便是网络的训练结果:

5 总结

在这篇文章中,掌柜首先从总体上介绍了建立基于BERT模型的文本分类模型的大致思路;然后介绍了模型的输入部分,以及如何从零开始来构建数据集;接着详细介绍了如何分析模型的参数,并将其载入到相应的模型中;最后介绍了如何在BERT网络模型的基础之上来添加一个分类层,来完成最后的文本分类任务。在下一篇文章中,掌柜将会介绍如何在文本蕴含任务,即输入两个句子来进行分类的场景下进行BERT预训练模型的微调。

本次内容就到此结束,感谢您的阅读!如果你觉得上述内容对你有所帮助,欢迎分享至一位你的朋友!若有任何疑问与建议,请添加掌柜微信nulls8或加群进行交流。青山不改,绿水长流,我们月来客栈见

引用

[1] BERT原理与NSL和MLM

[2] BERT模型的分步实现

[3] https://github.com/aceimnorstuvwxz/toutiao-text-classfication-dataset

[4]https://huggingface.co/bert-base-chinese/tree/main

[5] PyTorch模型的保存与迁移

[6] 示例代码 https://github.com/moon-hotel/BertWithPretrained

[7] This post is all you need(基于Transformer的分类模型)

[8] 训练模型时如何便捷保存训练日志

[9] This post is all you need 层层剥开Transformer