更新于 2026年6月29日

10.8 BERT文本分类模型#

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

10.8.1 任务构造原理#

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

因此,对于基于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节 时序数据建模:RNN 适合处理什么样的序列任务」内容。

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

 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 加载预训练模型#

尽管在「第5.3节 PyTorch模型保存与加载:模型复用与权重管理」内容中我们已经详细介绍了如何查看和分析本地的模型参数文件,但是由于BERT模型的参数结构更为复杂,所以我们这里将以bert-base-chinese模型参数为例进行一个简单的介绍。

1. 分析预训练模型参数

根据第5.3节内容的介绍可知,我们可以通过如下方式来查看本地模型中的参数情况:

1 loaded_paras = torch.load('./pytorch_model.bin') 
2 print(type(loaded_paras))
3 print(len(list(loaded_paras.keys())))
4 print(list(loaded_paras.keys()))

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

1 <class 'collections.OrderedDict'>
2 207
3 ['bert.embeddings.word_embeddings.weight',
4 'bert.embeddings.position_embeddings.weight',
5 'bert.embeddings.token_type_embeddings.weight',
6  ...
7 'bert.encoder.layer.11.output.LayerNorm.gamma',
8 'bert.encoder.layer.11.output.LayerNorm.beta']

从上面的输出结果可以看到,参数pytorch_model.bin被载入后变成了一个有序字典OrderedDict,并且其中一共有207个参数,其名字分别就是列表中的各个元素。不过想要将它迁移到我们所搭建的模型上还要进一步的来分析我们自己模型的参数信息。

2. 分析自定义模型参数

此时我们可以通过如下方式来查看模型实例化后的参数情况:

1 bert_model = BertModel(config)
2 print(len(bert_model.state_dict()))
3 print(list(bert_model.state_dict().keys()))

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

1 200
2 ['bert_embeddings.position_ids',
3  'bert_embeddings.word_embeddings.embedding.weight',
4  'bert_embeddings.position_embeddings.embedding.weight',
5 ...   
6  'bert_encoder.bert_layers.11.bert_output.LayerNorm.bias',
7  'bert_pooler.dense.weight','bert_pooler.dense.bias']

从上述结果可以看出,BertModel实例化后中的参数和pytorch_model.bin中的参数数量和名称并不完全一致,但是从名字看能够分清楚两者的对应情况。因此接下来我们需要自己写一个函数来完成参数的解析和赋值。

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

因此,我们可以通过在BertMoldel类中再加入一个如下所示的方法来用bert-base-chinese中的参数初始化BertMolde中的参数:

 1     @classmethod
 2     def from_pretrained(cls, config, pretrained_model_dir=None):
 3         model = cls(config)
 4         model_path = os.path.join(pretrained_model_dir,"pytorch_model.bin")
 5         loaded_paras = torch.load(model_path)
 6         state_dict = deepcopy(model.state_dict())
 7         loaded_names = list(loaded_paras.keys())[:-8]
 8         model_names = list(state_dict.keys())[1:]
 9         for i in range(len(loaded_names)):
10             state_dict[model_names[i]] =loaded_paras[loaded_names[i]]
11         model.load_state_dict(state_dict)
12         return model

在上述代码中,第3行是初始化模型,cls为未实例化的对象,即一个未实例化的BertModel对象。第4~5行用来载入本地的bert-base-chinese参数。第6行用来拷贝一份BertModel中的网络参数,这是因为我们无法直接修改里面的值。第7~10行则是根据我们上面的分析,将 bert-base-chinese中的参数赋值到state_dict中。第11~12行是用state_dict中的参数来初始化BertModel中的参数,并返回整个模型。

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

1 bert = BertModel.from_pretrained(config, pretrained_model_dir)

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

1 for para in bert.parameters():
2     if xxxx:
3         para.requires_grad = False

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

10.8.4 文本分类#

1. 前向传播

在介绍完如何分析和载入本地BERT预训练模型后,接下来我们首先要做的 就是实现文本分类的前向传播过程。在BertForSentenceClassifica tion.py文件中,我们通过定义如下一个类来完成整个前向传播过程:

 1 class BertForSentenceClassification(nn.Module):
 2     def __init__(self, config, bert_model_dir=None):
 3         super(BertForSentenceClassification, self).__init__()
 4         self.num_labels = config.num_labels
 5         if bert_model_dir is not None:
 6             self.bert = BertModel.from_pretrained(config, bert_model_dir)
 7         else:
 8             self.bert = BertModel(config)
 9         self.dropout = nn.Dropout(config.hidden_dropout_prob)
10         self.classifier = nn.Linear(config.hidden_size, self.num_labels)

在上述代码中,第3行代码分别用来指定模型配置和预训练模型的路径。第5~8行代码则是用来实例化一个BERT模型,可以看到如果此时预训练模型的路径存在则会返回一个由bert-base-chinese参数初始化后的BERT模型,否则返回一个随机初始化参数的BERT 模型。第10行是定义最后的分类层。

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

 1     def forward(self, input_ids, attention_mask=None, 
 2         token_type_ids=None, position_ids=None,labels=None):
 3         pooled_output, _ = self.bert(input_ids=input_ids,
 4                     attention_mask,token_type_ids,position_ids)
 5         pooled_output = self.dropout(pooled_output)
 6         logits = self.classifier(pooled_output)
 7         if labels is not None:
 8             loss_fct = nn.CrossEntropyLoss()
 9             loss = loss_fct(logits.view(-1, self.num_labels), labels.view(-1))
10             return loss, logits
11         else:
12             return logits

在上述代码中,第3~4行返回的是原始BERT网络的输出,其中返回的第1结果pooled_output为BERT第1个位置的[CLS]向量经过一个全连接层后的结果,第2个结果是BERT中所有位置的向量。第5~6行便是用来进行文本分类的分类层。第7~12行是根据条件返回损失值或返回logits值。

2. 模型训练

此时,我们在Tasks目录下新建一个名为TaskForSingleSentenceClassification.py的模块来完成分类模型的微调训练任务。首先,需要在其中定义一个ModelConfig类来对分类模型中的超参数进行管理,其中核心部分示例代码如下所示:

 1 class ModelConfig:
 2     def __init__(self):
 3         self.pretrained_model_dir = os.path.join(self.project_dir, "bert_base_chinese")
 4         self.vocab_path = os.path.join(self.pretrained_model_dir, 'vocab.txt')
 5         self.train_file_path = os.path.join(self.dataset_dir, 'toutiao_train.txt')
 6         self.val_file_path = os.path.join(self.dataset_dir, 'toutiao_val.txt')
 7         self.test_file_path = os.path.join(self.dataset_dir, 'toutiao_test.txt')
 8         self.split_sep = '_!_'
 9         self.max_sen_len = None
10         self.num_labels = 15
11         bert_config_path = os.path.join(self.pretrained_model_dir, "config.json")
12         bert_config = BertConfig.from_json_file(bert_config_path)

在上述代码中,第3~4行是用于指定预训练模型的相关路径。第5~7行是指定训练集、验证集和测试集对应的路径。第8~9行分别指定了文本和标签之间的分隔符和样本填充时最大长度的策略,max_sen_len = None表示以每个小批量数据中最长样本为标准进行填充。第10行指定了文本分类的分类数量。第11~12行则是将BERT模型原始的配置文件也导入到ModelConfig类中。

值得一提的是,基于上述配置管理,对于任意基于BERT模型的文本分类任务我们只需要修改数据集路径、样本标签分隔符和样本分类数便可以复用整个模型代码。

最后,我们只需要再定义一个train()函数来完成模型的训练即可,其中核心部分示例代码如下所示:

 1 def train(config):
 2     model = BertForSentenceClassification(config,config.pretrained_model_dir)
 3     bert_tokenize = BertTokenizer.from_pretrained(config.pretrained_model_dir)
 4     data_loader = LoadSingleSentenceClassificationDataset(config.vocab_path,
 5                 bert_tokenize.tokenize,config.batch_size, config.max_sen_len, 
 6                 config.split_sep,  config.max_position_embeddings, 
 7                 config.pad_token_id, config.is_sample_shuffle)
 8     train_iter, test_iter, val_iter = \
 9             data_loader.load_train_val_test_data(config.train_file_path,
10                             config.val_file_path, config.test_file_path)
11     for epoch in range(config.epochs):
12         for idx, (sample, label) in enumerate(train_iter):
13             padding_mask = (sample == data_loader.PAD_IDX).transpose(0, 1)
14             loss, logits = model(input_ids=sample,attention_mask=padding_mask,
15                          token_type_ids=None, position_ids=None, labels=label)
16             acc = (logits.argmax(1) == label).float().mean()
17             logging.info(f"Epoch: {epoch}, Batch[{idx}/{len(train_iter)}], "
18                      f"Train loss :{loss.item():.3f}, Train acc: {acc:.3f}")

在上述代码中,第2行用来初始化一个基于BERT的文本分类模型。第3~10行则是载入相应的数据集。第11~17行则是整个模型的训练过程。

1 Epoch: 0, Batch[0/4186], Train loss :2.862, Train acc: 0.125 
2 Epoch: 0, Batch[10/4186], Train loss :2.084, Train acc: 0.562 
3 Epoch: 0, Batch[20/4186], Train loss :1.136, Train acc: 0.812 
4 Epoch: 0, Batch[30/4186], Train loss :1.000, Train acc: 0.734 
5 Epoch: 0, Batch[4180/4186], Train loss :0.418, Train acc: 0.875 
6 Epoch: 9, Batch[4180/4186], Train loss :0.102, Train acc: 0.984
7 Accurcay on val 0.884
8 Accurcay on test 0.888

在完成模型的训练过程后,便可以将训练过程中持久化的模型用于任务的推理场景中。由于这部分代码在前面章节内容中已多次介绍,这里就不再赘述,各位读者直接阅读源码即可。

10.8.5 小结#

在本节内容中,我们首先介绍了文本分类任务的构建原理;然后详细介绍了整个数据集的构建流程;接着进一步介绍了如何加载预训练模型并将其赋值到以后的BERT模型中;最后介绍了如何基于BERT模型来构建一个文本分类模型以及整个模型的训练过程。到此,对于简单的单文本分类任务就介绍完了。之所以称为单文本分类是因为这种分类场景下模型只接受一个句子,而在文本蕴含任务中则是输入两个句子到模型中判断其蕴含关系,此时在构建样本时只需要在两个句子之间加入分隔符[SEP]即可,具体可直接参见源码实现。

在下一节内容中我们将会介绍如何在问题选择任务中,即给定一个问题和多个选项让模型给出正确的选择,进行BERT预训练模型的微调。

引用#

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

您当前阅读的内容现已出版,点击右侧了解

10章教学课件,400余幅示意插图、40个示例源代码,助力读者轻松迈入深度学习的大门!

查看详情
阅读 --

7.2 时序数据

时序数据建模入门,讲清什么是时序数据、RNN 适合处理哪些序列任务,以及典型应用场景。

5.3 模型的保存与复用

在深度学习中通常训练一个可用的模型都需要耗费极大的成本,因此在模型训练过程中就需要对满足某些条件下的网络权重参数进行保存,然后在实际推理过程中直接载入这些权重参数来完成模型的推理过程。同时,另外一种场景便是模型已经在一批数据上训练完成且完成 …