经过「第4讲 BERT 文本分类实战教程:从 [CLS] 微调到 38 万今日头条 15 类新闻分类」内容的介绍,相信大家对于如何利用BERT预训练模型来进行单文本分类的整体流程已经有了较为清晰的认识。当然BERT的能力显然远不止于此,因此在这节内容中,我们将会介绍第2个下游任务的微调场景,即如何基于BERT预训练模型来完成文本蕴含(文本对的分类)任务。
5.1 任务构造原理#
所谓文本对分类指的就是同时给模型输入两句话,然后让模型来判断两句话之间的关系,所以本质上也就变成了一个文本分类任务。同时,鉴于第1个任务场景用到的是中文语料,因此在第2个场景中我们将会用英文语料进行示例。这样便于大家两者都能掌握。
总的来说,基于BERT的文本蕴含任务同第4节中介绍的单文本分类任务本质上没有任何不同,最终都是对一个文本序列进行分类。只是按照BERT模型的思想,文本对分类任务在数据集的构建过程中需要通过Segment Embedding来区分前后两个不同的序列,并且两个句子之间需要通过一个[SEP]符号来进行分割,因此本节内容的核心就在于如何构建数据集。
总结起来,文本对的分类任务除了在模型输入上发生了变换,其它地方均与单文本分类任务一样,同样也是取最后一层的[CLS]向量进行分类。接下来,首先就来介绍如何构造文本分类的数据集。
5.2 数据预处理#
5.2.1 输入介绍#
由于在文本对分类任务这个场景中模型的输入包含两个序列,因此在构建数据集的时候不仅仅需要进行Token Embedding操作,同时还要对两个序列进行Segment Embedding操作。对于Position Embedding来说在任何场景下都不需要对其指定输入,因为我们在代码实现时已经做了相应默认时的处理。
因此,对于文本对分类这个场景来说构建模型所需的训练集需要完成两个步骤:①需要构造原始文本对应的Token序列,然后在最前面加上一个[CLS]符,两个序列之间以及整个序列的末尾分别再加上一个[SEP]符;②根据两个序列各自的长度再构建一个类似 [0,0,0,...,1,1,1,...] 的 token_type_ids 向量。最后将两者均作为模型的输入即可。
5.2.2 语料介绍#
在这里,我们使用到的是论文中所提到的MNLI(The Multi-Genre Natural Language Inference Corpus, 多类型自然语言推理数据库)自然语言推断任务数据集[3]。也就是给定前提(premise)语句和假设(hypothesis)语句,任务是预测前提语句是否包含假设(蕴含, entailment),与假设矛盾(矛盾,contradiction)或者两者都不(中立,neutral)。
如下所示便是原始示例数据中的两个样本:
1 {"annotator_labels": ["entailment", "neutral", "entailment", "neutral",
2 "entailment"],"genre": "oup","gold_label": "entailment", "pairID": "82890e",
3 "promptID": "82890", "sentence1": " From Home Work to Modern Manufacture",
4 "sentence1_binary_parse": "( From ( ( Home Work ) ( to ( Modern Manufacture ) ) ) )" ...",
5 "sentence1_parse": "(ROOT (PP (IN From) (NP (NP (NNP Home) (NNP Work)) (PP (TO to) (NP (NNP Modern)...",
6 "sentence2": "Modern manufacturing has changed over time.",
7 "sentence2_binary_parse": "( ( Modern manufacturing ) ( ( has ( ....",
8 "sentence2_parse": "(ROOT (S (NP (NNP Modern) (NN manufacturing)) (VP ..." }
9 {"annotator_labels": ["neutral", "neutral", "entailment", "neutral",
10 "neutral"],"genre":"nineeleven","gold_label": "neutral", "pairID": "16525n",
11 "promptID": "16525", "sentence1": "They were promptly executed.",
12 "sentence1_binary_parse": "( They ( ( were ( promptly executed ) ) . ) )",
13 "sentence1_parse": "(ROOT (S (NP (PRP They)) (VP (VBD were) (VP (ADVP (RB not) (RB only)) (VBD earned) ...",
14 "sentence2": "They were executed immediately upon capture.",
15 "sentence2_binary_parse": "( They ( ( were ( ( executed immediately apparent (nor why the item is ...",
16 "sentence2_parse": "(ROOT (S (NP (PRP They)) (VP (VBD were) (VP (VBN called) (NP (NP (DT a) (JJ floppy))..."}由于该数据集同时也可用于其它任务中,因此除了我们需要的前提和假设两个句子和标签之外,还有每个句子的语法解析结构等等。在这里,下载完成数据后只需要执行项目中的format.py脚本即可将原始数据划分成训练集、验证集和测试集。格式化后的数据形式如下所示:
1 From Home Work to Modern Manufacture_!_Modern manufacturing has changed overtime._!_1
2 They were promptly executed._!_They were executed immediately upon capture._!_2下一步,我们只需要在格式化后的数据上进行数据集的构建即可。
5.2.3 数据集预览#
同样,在正式介绍如何构建数据集之前我们先通过一张图来了解一下整个构建流程,以便做到心中有数。假如我们现在有两个样本构成了一个batch,那么其整个数据的处理过程则如图5-1所示。
如图5-1所示,第1步需要将原始的数据样本进行分词(tokenize)处理;第2步再根据tokenize后的结果构造一个字典,不过在使用BERT预训练时并不需要我们自己来构造这个字典,直接使用相应开源模型中的vocab.txt文件构造字典即可,因为只有vocab.txt中每个字的索引顺序才与开源模型中每个字的Embedding向量一一对应的。第3步则是根据字典将tokenize后的文本序列转换为Token id序列,同时在Token id序列的起始位置加上[CLS],在两个序列之间以及整个序列的末尾加上[SEP]符号,并进行Padding。第4、5步则是根据第3步处理后的结果分别生成对应的token types ids和attention mask向量。
最后,在模型训练时只需要将第3、4和5步处理后的结果一起喂给模型进行训练即可。
5.2.4 数据集构建#
第1步:定义tokenize
第1步需要完成的就是将输入进来的文本序列tokenize到单词级别,对于英文语料简单来说就是将每个单词和标点符号分开。不过具体的处理方式使用到的是一种叫做 WordPiece 的处理方式。在这里,我们可以借用transformers包中的 BertTokenizer 方法来完成,示例如下:
1 from transformers import BertTokenizer
2 model_name = '../bert_base_uncased_english'
3 tokenizer = BertTokenizer.from_pretrained(model_name)
4 r = tokenizer.tokenize("From Home Work to Modern Manufacture. Modern manufacturing has changed over time.")
5 print(r)
6 ['from', 'home', 'work', 'to', 'modern', 'manufacture', '.', 'modern', 'manufacturing', 'has', 'changed', 'over', 'time', '.']在上述代码中,第2~3行就是根据指定的路径(BERT预训练模型的路径)来载入一个tokenize模型;第6行便是tokenize后的结果。
同时,对于一些新词WordPiece也会将其拆分成合理的部分:
1 r = tokenizer.tokenize("huggingface")
2 print(r)
3 ['hugging', '##face']第2步:建立词表
由于BERT预训练模型中已经有了一个给定的词表(vocab.txt),因此我们并不需要根据自己的语料来建立一个词表。当然,也不能够根据自己的语料来建立词表,因为相同的字在我们自己构建的词表中和vocab.txt中的索引顺序肯定会不一样,而这就会导致后面根据token id 取出来的向量是错误的。
进一步,我们只需要将vocab.txt中的内容读取进来形成一个词表即可。这部分代码同「第4.2.2节」中的相同,所以在这里就不在赘述。
接着便可以定义一个方法来实例化一个词表:
1 def build_vocab(vocab_path):
2 return Vocab(vocab_path)
3
4 if __name__ == '__main__':
5 vocab = build_vocab()在经过上述代码处理后,我们便能够通过 vocab.itos 得到一个列表,返回词表中的每一个词;通过 vocab.itos[2] 返回得到词表中对应索引位置上的词;通过 vocab.stoi 得到一个字典,返回词表中每个词的索引;通过 vocab.stoi['good'] 返回得到词表中对应词的索引;通过len(vocab)来返回词表的长度。如下便是建立后的词表:
1 {'[PAD]': 0, '[unused0]': 1, '[unused1]': 2, '[unused2]': 3, ..., '[CLS]': 101, '[SEP]': 102,...,2106, 'such': 2107, 'being': 2108, 'used': 2109, 'state':2110, 'people': 2111, 'part': 2112, 'know': 2113, 'against': 2114, 'your': 2115, 'many': 2116, 'second': 2117, 'university': 2118, 'both': 2119, 'national': 2120, '##er': 2121, 'these': 2122, 'don': 2123, 'known': 2124, 'off': 2125, 'way': 2126, 'until': 2127, 're': 2128, 'how': 2129, ...}此时,我们就需要定义一个类,并在类的初始化过程中根据训练语料完成字典的构建等工作,代码如下:
1 class LoadPairSentenceClassificationDataset(LoadSingleSentenceClassificationDataset):
2 def __init__(self, kwargs):
3 super(LoadPairSentenceClassificationDataset, self).__init__(kwargs)
4 pass由于在第4节中的单文本分类场景中已近实现了数据集构建整个流程的代码,所以我们在这里只需要继承 LoadSingleSentenceClassificationDataset 这个类,然后再重写里面的 data_process() 和 generate_batch() 方法即可,其它地方不用修改。
第3步:转换为Token序列
在得到构建的字典后,便可以通过如下方法来将训练集、验证集和测试集转换成Token序列:
1 def data_process(self, filepath):
2 raw_iter = open(filepath).readlines()
3 data = []
4 max_len = 0
5 for raw in tqdm(raw_iter, ncols=80):
6 line = raw.rstrip("\n").split(self.split_sep)
7 s1, s2, l = line[0], line[1], line[2]
8 token1 = [self.vocab[token] for token in self.tokenizer(s1)]
9 token2 = [self.vocab[token] for token in self.tokenizer(s2)]
10 tmp = [self.CLS_IDX] + token1 + [self.SEP_IDX] + token2
11 if len(tmp) > self.max_position_embeddings - 1:
12 tmp = tmp[:self.max_position_embeddings - 1]
13 tmp += [self.SEP_IDX]
14 seg1 = [0] (len(token1) + 2)
15 seg2 = [1] (len(tmp) - len(seg1))
16 segs = torch.tensor(seg1 + seg2, dtype=torch.long)
17 tensor_ = torch.tensor(tmp, dtype=torch.long)
18 l = torch.tensor(int(l), dtype=torch.long)
19 max_len = max(max_len, tensor_.size(0))
20 data.append((tensor_, segs, l))
21 return data, max_len在上述代码中,第6~7行便是用来取得文本和标签;第8~9行是分别对两个序列s1和s2转换为词表中对应的Token;第10~13行则是将两个序列拼接起来,并在序列的开始加上[CLS]符号,在两个序列之间及末位加上[SEP]符号;第14~16行则是构造得到Segment Embedding的输入向量,其中第14行里2 表示[CLS]和中间的[SEP]这两个字符;第17~20行则是整合得到对应的样本数据,包括Token Embedding的输入、Segment Embedding的输入以及每个样本对应的标签。
在处理完成后,第5.2节中的2个样本将会被转换成如下形式:
1 tensor([[ 101, 2013, 2188, 2147, 2000, 2715, 9922, 102, 2715, 5814,
2 2038, 2904, 2058, 2051, 1012, 102],
3 [ 101, 2027, 2020, 13364, 6472, 1012, 102, 2027, 2020, 6472,
4 3202, 2588, 5425, 1012, 102, 0]])
5 torch.Size([2, 16])
6 tensor([[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
7 [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0]])从上面的输出结果可以看出,101就是[CLS]在词表中的索引位置,102则是[SEP]在词表中的索引;其它非0值就是tokenize后的文本序列转换成的Token序列。同时可以看出,这里的结果是以第1个样本的长度16对第2个样本进行padding的,并且padding的Token ID为0。可以发现,除了对原始的文本的Token序列进行Padding外,还需要对Segment Embedding的输入进行Padding。
因此,下面我们就来介绍样本的padding处理。
第4步:padding处理与mask
从第3步的输出结果看出,在对原始文本序列tokenize转换为Token id后还需要对其进行padding处理。对于这一处理过程我们在前面第4.2.4节中已经介绍过了,这里就不再赘述。
进一步,我们需要定义一个方法来对每个batch的Token序列进行padding处理:
1 def generate_batch(self, data_batch):
2 batch_sentence, batch_seg, batch_label = [], [], []
3 for (sen, seg, label) in data_batch: # 对一个batch中每个样本进行处理。
4 batch_sentence.append(sen)
5 batch_seg.append((seg))
6 batch_label.append(label)
7 batch_sentence = pad_sequence(batch_sentence, # [batch_size,max_len]
8 padding_value=self.PAD_IDX,
9 batch_first=False,
10 max_len=self.max_sen_len) #[max_len,batch_size]
11 batch_seg = pad_sequence(batch_seg, # [batch_size,max_len]
12 padding_value=self.PAD_IDX,
13 batch_first=False,
14 max_len=self.max_sen_len)# [max_len,batch_size]
15 batch_label = torch.tensor(batch_label, dtype=torch.long)
16 return batch_sentence, batch_seg, batch_label上述代码的作用就是对每个batch的Token Embedding输入序列以及Segment Embedding输入进行padding处理并同时返回标签。
最后,对于每一序列的attention_mask向量,我们只需要判断其是否等于padding_value便可以得到这一结果,具体可见下面的使用示例。
第5步:构造DataLoade与使用示例
经过前面4步的处理,整个数据集的构建就算是已经基本完成了,只需要再构造一个DataLoader迭代器即可。由于这部分代码同第4.2.4节中的相同,这里就不再赘述。
在完成类 LoadPairSentenceClassificationDataset 所有的编码过程后,便可以通过如下形式进行使用:
1 from Tasks.TaskForPairSentenceClassification import ModelConfig
2 from utils.data_helpers import LoadPairSentenceClassificationDataset
3 from transformers import BertTokenizer
4
5 if __name__ == '__main__':
6 config = ModelConfig()
7 tokenizer = BertTokenizer.from_pretrained(config.pretrained_model_dir)
8 load_dataset = LoadPairSentenceClassificationDataset(
9 vocab_path=config.vocab_path,
10 tokenizer=tokenizer.tokenize,
11 batch_size=config.batch_size,
12 max_sen_len=config.max_sen_len,
13 split_sep=config.split_sep,
14 max_position_embeddings=config.max_position_embeddings,
15 pad_index=config.pad_token_id,
16 is_sample_shuffle=config.is_sample_shuffle)
17
18 train_iter, test_iter, val_iter = \
19 load_dataset.load_train_val_test_data(config.train_file_path,
20 config.val_file_path,
21 config.test_file_path)
22 for sample, seg, label in train_iter:
23 print(sample.shape) # [seq_len,batch_size]
24 print(sample.transpose(0, 1)) # [batch_size,seq_len]
25 padding_mask = (sample == load_dataset.PAD_IDX).transpose(0, 1)
26 print(padding_mask.shape)
27 print(label.shape)
28 print(seg.shape) # [seq_len,batch_size]
29 print(label) # [batch_size,]
30 print(seg.transpose(0, 1))
31 break执行完上述代码后便可以得到如下所示的结果:
1 torch.Size([16, 2])
2 tensor([[ 101, 2013, 2188, 2147, 2000, 2715, 9922, 102, 2715, 5814,
3 2038, 2904, 2058, 2051, 1012, 102],
4 [ 101, 2027, 2020, 13364, 6472, 1012, 102, 2027, 2020, 6472,
5 3202, 2588, 5425, 1012, 102, 0]])
6 torch.Size([2, 16])
7 torch.Size([2])
8 torch.Size([16, 2])
9 tensor([1, 2])
10 tensor([[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1],
11 [0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0]])5.3 文本蕴含#
5.3.1 前向传播#
如同我们在第5.1节内容中介绍的那样,文本对(文本蕴含)分类任务本质上还是一个序列分类的问题,因此这里直接使用 DownstreamTasks 目录中的 BertForSentenceClassification.py 模块即可。同时,由于这个模块的内容在第4.1.1节中已经介绍过了,所以在这里就不再赘述。
5.3.2 模型训练#
进一步,对于模型的训练过程来说,我们需要在Tasks目录(如图2-5所示)下新建一个名为 TaskForPairSentenceClassification.py 的模块来完成文本对分类模型的微调训练任务。
首先,我们需要定义一个ModelConfig类来对分类模型中的超参数进行管理,代码如下所示:
1 class ModelConfig:
2 def __init__(self):
3 self.project_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4 self.dataset_dir = os.path.join(self.project_dir, 'data', 'PairSentenceClassification')
5 self.pretrained_model_dir = os.path.join(self.project_dir, "bert_base_uncased_english")
6 self.vocab_path=os.path.join(self.pretrained_model_dir, 'vocab.txt')
7 self.device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')
8 self.train_file_path = os.path.join(self.dataset_dir, 'train.txt')
9 self.val_file_path = os.path.join(self.dataset_dir, 'val.txt')
10 self.test_file_path = os.path.join(self.dataset_dir, 'test.txt')
11 self.model_save_dir = os.path.join(self.project_dir, 'cache')
12 self.logs_save_dir = os.path.join(self.project_dir, 'logs')
13 self.split_sep = '_!_'
14 self.is_sample_shuffle = True
15 self.batch_size = 16
16 self.max_sen_len = None
17 self.num_labels = 3
18 self.epochs = 10
19 self.model_val_per_epoch = 2
20 logger_init(log_file_name='pair', log_level=logging.INFO,
21 log_dir=self.logs_save_dir)
22 if not os.path.exists(self.model_save_dir):
23 os.makedirs(self.model_save_dir)
24
25 config_path = os.path.join(self.pretrained_model_dir, "config.json")
26 bert_config = BertConfig.from_json_file(config_path)
27 for key, value in bert_config.__dict__.items():
28 self.__dict__[key] = value
29 logging.info(" ### 将当前配置打印到日志文件中 ")
30 for key, value in self.__dict__.items():
31 logging.info(f"### {key} = {value}")在上述代码中,第2~23行则是分别用来定义模型中的一些数据集目录、超参数和初始化日志打印类等;第25~28行则是将原始 bert_base_uncased_english 配置文件,即 config.json 中的参数也导入到类 ModelConfig 中;第31~33行则是将所有的超参数配置情况一同打印到日志文件中。
最后,我们只需要再定义一个 train() 函数来完成模型的训练即可。由于这部分代码同4.4.2节中的大同小异,所以这里就不再赘述,大家可以直接阅读项目中的源码。
5.3.3 模型推理#
文本对分类模型中的推理部分同4.4.3单文本分类模型中的内容几乎一样,所以在这里也不再赘述,大家可以直接参考项目源码。
到此,对于第2个基于BERT预训练模型的文本蕴含任务就介绍完了。在下一节内容中,我们将会介绍如何在问题选择任务(即输入一个问题和四个选项让模型选择其中最合理的一个答案)场景下进行BERT预训练模型的微调。