10.8 BERT文本分类模型#

经过前面两节内容的介绍,我们对于BERT模型的原理及其实现过程已经有了比较清晰的理解。同时,由于BERT是一个强大的预训练模型,因此我们可以直接基于谷歌发布的预训练参数将模型迁移到各个下游任务中进行微调学习。在这节内容中,我们将开始介绍第1个基于BERT预训练模型的下游任务文本分类。

10.8.1 任务构造原理#

整体来看基于BERT的文本分类模型就是在原始BERT模型的基础之上添加了一个新的分类层。 同时,对于分类层的输入,即原始BERT模型的输出,默认情况下可以取BERT输出结果中[CLS]位置对应的向量,当然也可以修改为其它方式,例如取所有位置向量的均值等,详见10.7.3节内容。

因此,对于基于BERT的文本分类模型来说其输入为一个单句,输出则是每个类别对应的概率值。接下来,我们首先来介绍如何构造文本分类任务中所使用到的数据集。以下完整示例代码可参见Code/Chapter10/C04_BERT文件。

10.8.2 数据预处理#

在构建数据集之前,我们首先需要知道模型应该接收什么样的输入,然后才能构建得到正确的样本形式。由于在文本分类这个任务场景中输入模型的只有一个序列,所以在构建数据集时并不需要构造句子编码这一层,直接默认使用全为0即可。同时,对于位置编码来说在任何场景下都不需要显示地指定输入,因为我们在代码实现时已经做了相应的默认处理逻辑。因此,对于文本分类来说只需要构造原始文本对应的索引序列, 并在首尾分别再加上一个[CLS]符和[SEP]符作为输入即可。

1. 语料介绍

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

1 千万不要乱申请网贷否则后果很严重_!_4
2 10年前的今天纪念 5.12 汶川大地震 10 周年_!_11
3 怎么看待杨毅在一 NBA 直播比赛中说詹姆斯的球场统治力已经超过乔丹伯德和科比?_!_3 
4 戴安娜王妃的车祸有什么谜团?_!_2

在上述示例中,_!_左边为新闻标题,即后续需要用到的分类文本,右边为类别标签。

2. 定义Tokenizer

在构建数据集之前需要完成的便是将文本序列切分到字符级别,对于中文语料来说主要是将每个字和标点符号都分割开。在这里我们可以借用 transformers包中的BertTokenizer模块来完成,示例代码如下所示:

1 from transformers import BertTokenizer
2 model_config = ModelConfig()
3 tokenize = BertTokenizer.from_pretrained(model_config.pretrained_model_dir)
4 print(tokenize.tokenizer("10年前的今天,纪念 5.12 汶川大地震 10 周年"))
5 # ['10', '年', '前', '的', '今', '天', ',', '纪', '念', '5', 
6 # '.', '12', '汶', '川', '大', '地', '震', '10', '周', '年']

在上述代码中,第1行是导入BertTokenizer模块。第3行是实例化一个BertTokenizer类对象用于中文序列的分割。第5~6行便是切分后的示例结果。

3. 建立词表

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

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

 1 class Vocab:
 2     UNK = '[UNK]'
 3     def __init__(self, vocab_path):
 4         self.stoi = {}
 5         self.itos = []
 6         with open(vocab_path, 'r', encoding='utf-8') as f:
 7             for i, word in enumerate(f):
 8                 w = word.strip('\n')
 9                 self.stoi[w] = i
10                 self.itos.append(w)
11 
12     def __getitem__(self, token):
13         return self.stoi.get(token, self.stoi.get(Vocab.UNK))
14 
15     def __len__(self):
16         return len(self.itos)

在上述代码中,第6~10行便是根据原始文件vocab.txt来构建整个词表。第12~13行是实现将类Vocab的实例化对象作为字典一样的方式进行使用,方便取对应字符的索引。第15~16行则是实现可以通过对实例化对象执行len()方法来获取长度。

此时,我们需要定义一个类,并在类的初始化过程中根据训练语料完成词表的构建等相关工作,示例代码如下所示:

 1 class LoadSingleSentenceClassificationDataset:
 2     def __init__(self,vocab_path='./vocab.txt', tokenizer=None,
 3             batch_size=32,max_sen_len=None,split_sep='\n', pad_index=0,
 4             max_position_embeddings=512,is_sample_shuffle=True):
 5         self.tokenizer = tokenizer
 6         self.vocab = build_vocab(vocab_path)
 7         self.PAD_IDX = pad_index
 8         self.SEP_IDX = self.vocab['[SEP]']
 9         self.CLS_IDX = self.vocab['[CLS]']
10         self.batch_size = batch_size
11         self.split_sep = split_sep
12         self.max_position_embeddings = max_position_embeddings
13         if isinstance(max_sen_len, int) and max_sen_len > max_position_embeddings:
14             max_sen_len = max_position_embeddings
15         self.max_sen_len = max_sen_len
16         self.is_sample_shuffle = is_sample_shuffle

在上述代码中,第2行vocab_path表示本地词表的路径。第3行max_sen_len表示最大样本长度,当max_sen_len = None时,即以每个小批量样本最长样本的长度为标准对其它样本进行填充;当max_sen_len = 'same'时,表示以整个数据集中最长样本为标准对其它样本进行填充;当 max_sen_len = 50时,表示以某个固定长度对样本进行填充,而多余的则截断。split_sep表示样本与标签之间的分隔符。第4行is_sample_shuffle表示是否打乱训练集中的样本。第5~9行是建立词表并取对应特殊字符的索引。第12行中max_position_embeddings为最大样本长度,最大为 512。第13~14行则是用来判断传入的最大样本长度。

4. 转换为索引序列

在完成词表构建后接着可以通过如下方法来分别将训练集、验证集和测试集中的原始文本转换成词表索引序列,同时返回所有样本中最长样本的长度,示例代码如下所示:

 1     def data_process(self, filepath, postfix='cache'):
 2         raw_iter = open(filepath, encoding="utf8").readlines()
 3         data, max_len = [], 0
 4         for raw in tqdm(raw_iter, ncols=80):
 5             line = raw.rstrip("\n").split(self.split_sep)
 6             s, l = line[0], line[1]
 7             tmp = [self.CLS_IDX] + [self.vocab[token] for token in self.tokenizer(s)]
 8             if len(tmp) > self.max_position_embeddings - 1:
 9                 tmp = tmp[:self.max_position_embeddings - 1]
10             tmp += [self.SEP_IDX]
11             tensor_ = torch.tensor(tmp, dtype=torch.long)
12             l = torch.tensor(int(l), dtype=torch.long)
13             max_len = max(max_len, tensor_.size(0))
14             data.append((tensor_, l))
15         return data, max_len

在上述代码中,第5~6行是分别用来获取对应的文本和标签。第7行是首先对序列进行切分,然后转换成索引序列并在最前面加上分类标志位[CLS]。第 8~10行是用来对索引序列进行截取,最长为max_position_embeddings个字符, 默认为 512,并同时在末尾加上[SEP]符号。第11~12是分别将样本索引序列和标签转换为张量。第13行是用来保存最长序列的长度。

在处理完成后,语料介绍中的4个样本将会被转换成类似如下形式:

1 tensor([[101, 1283,  674,  679, 6206,  744, 4509, 6435, 5381,..,   0, 0  ],
2         [101, 8108, 2399, 1184, 4638,  791, 2399, 8024, 5279,..,   0, 0  ],
3         [101, 2582,  720, 4692, 2521, 3342, 3675, 1762,  671,..,8043, 102],
4         [101, 2785, 2128, 2025, 4374, 1964, 4638, 6756, 1730,..,   0, 0  ]])
5 torch.Size([39, 4])

从上面输出结果可以看出,101就是[CLS]在词表中的索引位置,102则是[SEP]在词表中的索引,其它非0值是文本序列转换成的索引后的结果。同时可以看出,这里的结果是以第 3 个样本的长度39对其它样本进行的填充,并且填充的值为0。

5. 构造迭代器

经过前面4步的处理整个数据集的构建就算是基本完成了,下一步需要再实现一个辅助函数来对每个小批量中的样本进行填充,然后再构造一个DataLoader迭代器即可,示例代码如下所示:

1     def generate_batch(self, data_batch):
2         batch_sens, batch_label = [], []
3         for (sen, label) in data_batch: 
4             batch_sens.append(sen)
5             batch_label.append(label)
6         batch_sens = pad_sequence(batch_sens, False, self.max_sen_len, self.PAD_IDX)
7         batch_label = torch.tensor(batch_label, dtype=torch.long)
8         return batch_sens, batch_label

在上述代码中,第3~5行是取小批量中的每个样本和标签。第6行是对小批量中的样本进行填充或截断处理,关于pad_sequence可参见7.2.4节内容。

最后,构建数据集迭代器,示例代码如下所示:

 1     def load_train_val_test_data(self, train_file_path=None,val_file_path=None,
 2                                  test_file_path=None,only_test=False):
 3         test_data, _ = self.data_process(file_path=test_file_path)
 4         test_iter = DataLoader(test_data, batch_size=self.batch_size,
 5                                shuffle=False, collate_fn=self.generate_batch)
 6         if only_test:
 7             return test_iter
 8         train_data, max_sen_len = self.data_process(file_path=train_file_path)  
 9         if self.max_sen_len == 'same':
10             self.max_sen_len = max_sen_len
11         val_data, _ = self.data_process(file_path=val_file_path)
12         train_iter = DataLoader(train_data, batch_size=self.batch_size,
13                      shuffle=self.is_sample_shuffle, collate_fn=self.generate_batch)
14         val_iter = DataLoader(val_data, batch_size=self.batch_size,
15                      shuffle=False, collate_fn=self.generate_batch)
16         return train_iter, test_iter, val_iter

在上述代码中,第3~7行是构造得到测试集对应的迭代器,并根据条件判断是否仅返回测试集。第6~15行是构建得到训练集和验证集对应的迭代器,其中第9行用于判断是否使用所有样本中的最大长度对其它样本进行填充。

在完成类LoadSingleSentenceClassificationDataset所有的编码过程后,便可以通过如下形式进行使用:

 1 if __name__ == '__main__':
 2     model_config = ModelConfig()
 3     load_dataset = LoadSingleSentenceClassificationDataset(
 4         vocab_path=model_config.vocab_path,
 5         tokenizer=BertTokenizer.from_pretrained(model_config.pretrained_model_dir).tokenize,
 6         batch_size=model_config.batch_size,max_sen_len=model_config.max_sen_len,
 7         split_sep=model_config.split_sep,pad_index=model_config.pad_token_id
 8         max_position_embeddings=model_config.max_position_embeddings)
 9     train_iter, test_iter, val_iter = \
10         load_dataset.load_train_val_test_data(model_config.train_file_path,
11                     model_config.val_file_path,model_config.test_file_path)
12     for sample, label in train_iter:
13         padding_mask = (sample == load_dataset.PAD_IDX).transpose(0, 1)
14         print(sample.shape)
15         print(padding_mask)

在上述代码中,第2行是实例化一个配置类对象。第3~8行是根据传入参数实例化一个数据集构建类对象。第9~11行则是根据原始数据路径分别返回训练集、验证集和测试集对应的迭代器。第13行构造每个小批量样本对应的掩码向量。

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

10.8.3 加载预训练模型#

55