到目前为止,我们已经介绍了如何使用预训练的BERT模型。现在,我们将学习如何针对下游任务微调预训练的BERT模型。需要注意的是,微调并非需要我们从头开始训练BERT模型,而是使用预训练的BERT模型,并根据任务需要更新模型的权重。
在本节中,我们将学习如何为以下任务微调预训练的BERT模型。
- 文本分类任务
- 自然语言推理任务
- 问答任务
- 命名实体识别任务
1、 文本分类任务
首先,我们学习如何为文本分类任务微调预训练的BERT模型。比如我们要进行情感分析,目标是对一个句子是积极(正面情绪)还是消极(负面情绪)进行分类。假设我们有一个包含句子及其标签的数据集。
以句子I love Paris为例,我们首先对句子进行标记,在句首添加[CLS],在句尾添加[SEP]。然后,将这些标记输入预训练的BERT模型,并得到所有标记的嵌入。
接下来,我们只取[CLS]的嵌入,也就是 R C L S R_{CLS} RCLS,忽略所有其他标记的嵌入,因为[CLS]标记的嵌入包含整个句子的总特征。我们将 R C L S R_{CLS} RCLS送入一个分类器(使用softmax激活函数的前馈网络层),并训练分类器进行情感分析。
但这与我们在本节一开始看到的情况有什么不同呢?微调预训练的BERT模型与使用预训练的BERT模型作为特征提取器有何不同呢?
在前面,我们了解到,在提取句子的嵌入 R C L S R_{CLS} RCLS后,我们将 R C L S R_{CLS} RCLS送入一个分类器并训练其进行分类。同样,在微调过程中,我们将嵌入 R C L S R_{CLS} RCLS送入一个分类器,并训练它进行分类。
不同的是,对预训练的BERT模型进行微调时,模型的权重与分类器一同更新。但使用预训练的BERT模型作为特征提取器时,我们只更新分类器的权重,而不更新模型的权重。
在微调期间,可以通过以下两种方式调整权重。
- 与分类器层一起更新预训练的BERT模型的权重。
- 仅更新分类器层的权重,不更新预训练的BERT模型的权重。这类似于使用预训练的BERT模型作为特征提取器的情况。
下图显示了如何针对文本分类任务对预训练的BERT模型进行微调。
从图中可以看到,我们将标记送入预训练的BERT模型,得到所有标记的嵌入。然后将[CLS]标记的嵌入送入使用softmax激活函数的前馈网络层进行分类。
下面,我们将针对情感分析任务对预训练的BERT模型进行微调,以深入了解微调的工作原理。
1.1 导入依赖库
我们以使用IMDB数据集的情感分析任务为例来微调预训练的BERT模型。IMDB数据集由电影评论和情感标签(正面/负面)组成。
首先,安装必要的库。
!pip install nlp
!pip install Transformers
然后,导入必要的模块。
from transformers import BertForSequenceClassification, BertTokenizerFast,
Trainer, TrainingArguments
from nlp import load_dataset
import torch
import numpy as np
1.2 加载模型和数据集
使用nlp库下载并加载数据集。然后,检查数据类型。
from datasets import load_dataset
dataset = load_dataset("ethos", "binary", split='train')
type(dataset)
输出如下。
nlp.arrow_dataset.Dataset
接下来,将数据集分成训练集和测试集。
dataset = dataset.train_test_split(test_size=0.3)
打印数据集的内容。
print(dataset)
DatasetDict({
train: Dataset({
features: ['text', 'label'],
num_rows: 798
})
test: Dataset({
features: ['text', 'label'],
num_rows: 200
})
})
现在,创建训练集和测试集。
train_set = dataset['train']
test_set = dataset['test']
接下来,下载并加载预训练的BERT模型。在这个例子中,我们使用预训练的bert-base-uncased模型。由于要进行序列分类,因此我们使用BertForSequence-Classification类。
model = BertForSequenceClassification.from_pretrained('bert-base-uncased')
然后,下载并加载用于预训练bert-base-uncased模型的词元分析器。
可以看到,我们使用了BertTokenizerFast类创建词元分析器,而不是使用BertTokenizer。与BertTokenizer相比,BertTokenizerFast类有很多优点。我们将在后面了解这方面的内容。
tokenizer = BertTokenizerFast.from_pretrained('bert-base-uncased')
现在,我们已经加载了数据集和模型,可以开始对数据集进行预处理了。
1.3 预处理数据集
我们仍然以句子I love Paris为例,使用词元分析器对数据集进行快速预处理。
首先,对例句进行标记,在句首添加[CLS]标记,在句尾添加[SEP]标记,如下所示。
tokens = [ [CLS], I, love, Paris, [SEP] ]
接下来,将标记映射到唯一的输入ID(标记ID)。假设输入ID如下所示。
input_ids = [101, 1045, 2293, 3000, 102]
然后,添加分段ID(标记类型ID)。假设输入中有两个句子,分段ID可以用来区分这两个句子。第1句中的所有标记被映射为0,第2句中的所有标记被映射为1。在这里,我们只有一个句子,因此所有的标记都会被映射为0,如下所示。
token_type_ids = [0, 0, 0, 0, 0]
现在创建注意力掩码。我们知道注意力掩码是用来区分实际标记和[PAD]标记的,它把所有实际标记映射为1,把[PAD]标记映射为0。假设标记长度为5,因为标记列表已经有5个标记,所以不必添加[PAD]标记。在本例中,注意力掩码如下所示。
attention_mask = [1, 1, 1, 1, 1]
不过,我们无须手动执行上述所有步骤,词元分析器会为我们完成这些步骤。我们只需将例句传递给词元分析器,如下所示。
tokenizer('I love Paris')
上面的代码将返回以下内容。可以看到,输入句已被标记,并被映射到input_ids、token_type_ids和attention_mask。
{
'input_ids': [101, 1045, 2293, 3000, 102],
'token_type_ids': [0, 0, 0, 0, 0],
'attention_mask': [1, 1, 1, 1, 1]
}
通过词元分析器,还可以输入任意数量的句子,并动态地进行补长或填充。要实现动态补长或填充,需要将padding设置为True,同时设置最大序列长度。假设输入3个句子,并将最大序列长度max_length设置为5,如下所示。
tokenizer(['I love Paris', 'birds fly', 'snow fall'], padding = True,max_length = 5)
上面的代码将返回以下内容。可以看到,所有的句子都被映射到input_ids、token_type_ids和attention_mask。第2句和第3句只有两个标记,加上[CLS]和[SEP]后,有4个标记。由于将padding设置为True,并将max_length设置为5,因此在第2句和第3句中添加了一个额外的[PAD]标记。这就是在第2句和第3句的注意力掩码中出现0的原因。
{
'input_ids': [[101, 1045, 2293, 3000, 102], [101, 5055, 4875, 102, 0],
[101, 4586, 2991, 102, 0]],
'token_type_ids': [[0, 0, 0, 0, 0], [1, 1, 1, 1, 1], [0, 0, 0, 0, 0]], 'attention_mask': [[1, 1, 1, 1, 1], [1, 1, 1, 1, 0], [1, 1, 1, 1, 0]]
}
有了词元分析器,我们可以轻松地预处理数据集。我们定义了一个名为preprocess的函数来处理数据集,如下所示。
def preprocess(data):
return tokenizer(data['text'], padding = True, truncation = True)
使用preprocess函数对训练集和测试集进行预处理。
train_set = train_set.map(preprocess, batched = True,
batch_size = len(train_set))
test_set = test_set.map(preprocess, batched = True, batch_size = len(test_set))
接下来,使用set_format函数,选择数据集中需要的列及其对应的格式,如下所示。
train_set.set_format('torch',
columns = ['input_ids', 'attention_mask', 'label'])
test_set.set_format('Torch',
columns = ['input_ids', 'attention_mask', 'label'])
1.4 训练模型
首先,定义批量大小和迭代次数。
batch_size = 8
epochs = 2
然后,确定预热步骤和权重衰减。
warmup_steps = 500
weight_decay = 0.01
接着,设置训练参数。
training_args = TrainingArguments(
output_dir = './results',
num_train_epochs = epochs,
per_device_train_batch_size = batch_size,
per_device_eval_batch_size = batch_size,
warmup_steps = warmup_steps,
weight_decay = weight_decay,
evaluate_during_training = True,
logging_dir = './logs',
)
最后,定义训练函数。
trainer = Trainer(
model = model,
args = training_args,
train_dataset = train_set,
eval_dataset = test_set
)
现在,开始训练模型。
trainer.train()
训练结束后,可以使用evaluate函数来评估模型。
trainer.evaluate()
以上代码的输出如下。
以这种方式,我们就可以针对文本分类任务对预训练的BERT模型进行微调。
2、自然语言推理任务
现在,我们学习如何为自然语言推理任务微调BERT模型。在自然语言推理任务中,模型的目标是确定在给定前提下,一个假设是必然的(真)、矛盾的(假),还是未定的(中性)。
在下图所示的样本数据集中,有几个前提和假设,并有标签表明假设是真、是假,还是中性。
模型的目标是确定一个句子对(前提−假设对)是真、是假,还是中性。我们以一个前提−假设对为例来了解如何做到这一点。
- 前提:He is playing(他在玩)
- 假设:He is sleeping(他在睡觉)
首先,对句子对进行标记,在第1句的开头添加[CLS]标记,在每句的结尾添加[SEP]标记,如下所示。
tokens = [ [CLS], He, is, playing, [SEP], He, is, sleeping, [SEP] ]
现在,将这些标记送入预训练的BERT模型,得到每个标记的嵌入。我们已经知道[CLS]标记的特征就是整个句子对的特征。
因此,将[CLS]标记的特征 R C L S R_{CLS} RCLS送入分类器(使用softmax激活函数的前馈网络层)。分类器将返回该句子对是真、是假,以及是中性的概率,如图所示。在最初的迭代中,结果会不准确,但经过多次迭代后,结果会逐渐准确。
3、 问答任务
在本节中,我们将学习如何为问答任务微调BERT模型。在问答任务中,针对一个问题,模型会返回一个答案。
我们的目标是让模型针对给定问题返回正确的答案。BERT模型的输入是一个问题和一个段落,也就是说,需要向BERT输入一个问题和一个含有答案的段落。BERT必须从该段落中提取答案。因此,从本质上讲,BERT必须返回包含答案的文本段。让我们通过下面这个问题−段落对示例来理解。
- 问题:“什么是免疫系统?”
- 段落:“免疫系统是一个由生物体内许多生物结构和过程组成的系统,它能保护人们免受疾病的侵害。为了正常运作,免疫系统必须检测各种各样的制剂,即所谓的病原体,从病毒到寄生虫,并将它们与有机体自身的健康组织区分开来。”
BERT必须从该段落中提取出一个答案,也就是包含答案的文本段。因此,它应该返回如下信息。
答案:“一个由生物体内许多生物结构和过程组成的系统,它能保护人们免受疾病的侵害。”
如何微调BERT模型来完成这项任务?要做到这一点,模型必须了解给定段落中包含答案的文本段的起始索引和结束索引。以“什么是免疫系统”这个问题为例,如果模型理解这个问题的答案是从索引5(“一”)开始,在索引39(“害”)结束,那么可以得到如下答案。
段落:“免疫系统是一个由生物体内许多生物结构和过程组成的系统,它能保护人们免受疾病的侵害。为了正常运作,免疫系统必须检测各种各样的制剂,即所谓的病原体,从病毒到寄生虫,并将它们与有机体自身的健康组织区分开来。”
如何找到包含答案的文本段的起始索引和结束索引呢?我们如果能够得到该段落中每个标记是答案的起始标记和结束标记的概率,那么就可以很容易地提取答案。但如何才能实现这一点?这里需要引入两个向量,称为起始向量S和结束向量E。起始向量和结束向量的值将通过训练习得。
首先,计算该段落中每个标记是答案的起始标记的概率。
为了计算这个概率,对于每个标记 i i i,计算标记特征 R i R_i Ri和起始向量S之间的点积。然后,将softmax函数应用于点积 S ⋅ R i S·R_i S⋅Ri,得到概率。计算公式如下所示。
p i = e S ⋅ R i ∑ j e S ⋅ R i p_i = \frac{e^{S·R_i}}{\sum_je^{S·R_i}} pi=∑jeS⋅RieS⋅Ri
接下来,选择其中具有最高概率的标记,并将其索引值作为起始索引。
以同样的方式,计算该段落中每个标记是答案的结束标记的概率。为了计算这个概率,为每个标记 i i i计算标记特征 R i R_i Ri和结束向量E之间的点积。然后,将softmax函数应用于点积 E ⋅ R i E·R_i E⋅Ri,得到概率。计算公式如下所示。
p i = e E ⋅ R i ∑ j e E ⋅ R i p_i = \frac{e^{E·R_i}}{\sum_je^{E·R_i}} pi=∑jeE⋅RieE⋅Ri
接着,选择其中具有最高概率的标记,并将其索引值作为结束索引。现在,我们就可以使用起始索引和结束索引选择包含答案的文本段了。
如图所示,首先对问题−段落对进行标记,然后将这些标记送入预训练的BERT模型。该模型返回所有标记的嵌入(标记特征)。 R i R_i Ri是问题中的标记的嵌入, R i 、 R_i^、 Ri、是段落中的标记的嵌入。
在得到嵌入后,开始分别计算嵌入与起始向量和结束向量的点积,并使用softmax函数获得段落中每个标记是起始词和结束词的概率。
上图展现了如何计算段落中每个标记是起始词和结束词的概率。下面,我们使用概率最高的起始索引和结束索引来选择包含答案的文本段。为了更好地理解这一点,下面我们将学习如何在问答任务中使用微调后的BERT模型。
3.1 用微调后的BERT模型执行问答任务
首先,导入必要的库模块。
from transformers import BertForQuestionAnswering, BertTokenizer
然后,下载并加载该模型。我们使用的是bert-large-uncased-whole-word-masking-fine-tuned-squad模型,该模型基于斯坦福问答数据集(SQuAD)微调而得。
model = BertForQuestionAnswering.from_pretrained('bert-large-uncased-whole-word-masking-fine-tuned-squad')
接下来,下载并加载词元分析器。
tokenizer = BertTokenizer.from_pretrained('bert-large-uncased-whole-word-masking-fine-tuned-squad')
3.2 对模型输入进行预处理
首先,定义BERT的输入。输入的问题和段落文本如下所示。
question = "What is the immune system?"
paragraph = "The immune system is a system of many biological structures
and processes within an organism that protects against disease. To function
properly, an immune system must detect a wide variety of agents, known as
pathogens, from viruses to parasitic worms, and distinguish them from the
organism's own healthy tissue."
接着,在问题的开头添加[CLS]标记,在问题和段落的结尾添加[SEP]标记。
question = '[CLS]' + question + '[SEP]'
paragraph = paragraph + '[SEP]'
然后,标记问题和段落。
question_tokens = tokenizer.tokenize(question)
paragraph_tokens = tokenizer.tokenize(paragraph)
合并问题标记和段落标记,并将其转换为input_ids。
tokens = question_tokens + paragraph_tokens
input_ids = tokenizer.convert_tokens_to_ids(tokens)
设置segment_ids。对于问题的所有标记,将segment_ids设置为0;对于段落的所有标记,将segment_ids设置为1。
segment_ids = [0] * len(question_tokens)
segment_ids = [1] * len(paragraph_tokens)
把input_ids和segment_ids转换成张量。
input_ids = torch.tensor([input_ids])
segment_ids = torch.tensor([segment_ids])
我们已经处理了输入,现在将它送入模型以获得答案。
3.3 获得答案
把input_ids和segment_ids送入模型。模型将返回所有标记的起始分数和结束分数。
start_scores, end_scores = model(input_ids, token_type_ids = segment_ids)
这时,我们需要选择start_index和end_index,前者是具有最高起始分数的标记的索引,后者是具有最高结束分数的标记的索引。
start_index = torch.argmax(start_scores)
end_index = torch.argmax(end_scores)
然后,打印起始索引和结束索引之间的文本段作为答案。
print(' '.join(tokens[start_index:end_index+1]))
以上代码的输出如下。
a system of many biological structures and processes within an organism
that protects against disease
4、 命名实体识别任务
在命名实体识别任务中,我们的目标是将命名实体划分到预设的类别中。例如在句子Jeremy lives in Paris中,Jeremy应被归类为人,而Paris应被归类为地点。
现在,让我们看看如何微调预训练的BERT模型以执行命名实体识别任务。首先,对句子进行标记,在句首添加[CLS]标记,在句尾添加[SEP]标记。然后,将这些标记送入预训练的BERT模型,获得每个标记的特征。接下来,将这些标记特征送入一个分类器(使用softmax激活函数的前馈网络层)。最后,分类器返回每个命名实体所对应的类别,如图所示。