在本章中,我们将学习如何配置一个预训练模型以进行文本分类,并如何微调它以适应任何文本分类的下游任务,例如情感分析、多类分类或多标签分类。我们还将讨论如何处理句子对和回归问题,并提供一个实现示例。我们将使用如 GLUE 等知名数据集以及我们自己的自定义数据集。随后,我们将利用 Trainer 类来处理训练和微调过程中的复杂性。
首先,我们将学习如何使用 Trainer 类微调单句二分类情感分析模型。然后,我们将使用原生 PyTorch 进行情感分类训练,而不使用 Trainer 类。在多类分类和多标签分类中,我们将考虑两个以上的类别。我们将进行七个类的分类微调任务。接下来,我们将训练一个文本回归模型,以预测带有句子对的数值。
本章将涵盖以下主题:
文本分类简介
使用 Trainer 类微调 BERT 模型进行单句二分类
使用原生 PyTorch 训练分类模型
使用自定义数据集微调 BERT 进行多类分类
微调 BERT 进行句子对回归
多标签文本分类
利用 run_glue.py 微调模型
技术要求
我们将使用 Jupyter Notebook 来运行编码练习。你需要 Python 3.6 或更高版本,并确保安装以下包:
sklearn
transformers 4.0+
datasets
文本分类简介
文本分类(也称为文本归类)是一种将文档(如句子、推文、书籍章节、电子邮件内容等)映射到预定义类别列表(类)中的方法。当类别只有两个且标记为正面和负面时,我们使用二分类——更具体地说,就是情感分析。对于两个以上的类别,我们称之为多类分类,其中类别是互斥的;或者是多标签分类,其中类别不是互斥的,这意味着一个文档可以有多个标签。例如,一篇新闻文章的内容可能同时涉及体育和政治。除此之外,我们可能还希望对文档进行评分,范围在 [-1,1] 之间,或在 [1-5] 之间进行排名。这类问题可以通过回归模型来解决,其中输出类型为数值型,而不是类别型。
幸运的是,变换器架构使我们能够高效地解决这些问题。对于句子对任务,如文档相似度或文本蕴含,输入不是单个句子,而是两个句子,如下图所示。我们可以评估两个句子在语义上的相似程度或预测它们是否在语义上相似。另一个句子对任务是文本蕴含,其中问题被定义为多类分类。在这里,两个序列被映射为 GLUE 基准中的包含/矛盾/中立:
微调 BERT 模型进行单句二分类
在本节中,我们将讨论如何微调预训练的 BERT 模型以进行情感分析,使用的将是流行的 IMDb 情感数据集。使用 GPU 可以加快学习过程,但如果没有这样的资源,也可以使用 CPU 进行微调。让我们开始吧。
要了解并保存当前设备,我们可以执行以下代码:
from torch import cuda device = 'cuda' if cuda.is_available() else 'cpu'
我们将在这里使用 DistilBertForSequenceClassification
类,它继承自 DistilBert
类,并在顶部有一个特殊的序列分类头。我们可以利用这个分类头来训练分类模型,默认情况下类别数为 2:
from transformers import ( DistilBertTokenizerFast, DistilBertForSequenceClassification) model_path = 'distilbert-base-uncased' tokenizer = DistilBertTokenizerFast.from_pretrained(model_path) model = DistilBertForSequenceClassification.from_pretrained( model_path, id2label={0: "NEG", 1: "POS"}, label2id={"NEG": 0, "POS": 1})
注意,我们传递了两个参数 id2label
和 label2id
给模型,用于推理时。或者,我们可以实例化一个特定的配置对象并将其传递给模型,如下所示:
config = AutoConfig.from_pretrained(....) SequenceClassification.from_pretrained(.... config=config)
现在,让我们选择一个流行的情感分类数据集,称为 IMDb 数据集。原始数据集包含两个数据集:25,000 个训练样本和 25,000 个测试样本。我们将数据集拆分为测试集和验证集。注意,数据集的前一半样本是正面的,而后一半样本都是负面的。我们可以如下分配样本:
from datasets import load_dataset imdb_train = load_dataset('imdb', split="train") imdb_test = load_dataset('imdb', split="test[:6250]+test[-6250:]") imdb_val = load_dataset('imdb', split="test[6250:12500]+test[-12500:-6250]")
让我们检查数据集的形状:
imdb_train.shape, imdb_test.shape, imdb_val.shape # 输出: ((25000, 2), (12500, 2), (12500, 2))
根据你的计算资源,你可以选择数据集的一小部分。对于较小的部分,你可以运行以下代码,选择 4,000 个样本用于训练,1,000 个用于测试,1,000 个用于验证:
imdb_train = load_dataset('imdb', split="train[:2000]+train[-2000:]") imdb_test = load_dataset('imdb', split="test[:500]+test[-500:]") imdb_val = load_dataset('imdb', split="test[500:1000]+test[-1000:-500]")
现在,我们可以通过 tokenizer 模型将这些数据集进行处理,使其准备好进行训练:
enc_train = imdb_train.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000) enc_test = imdb_test.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000) enc_val = imdb_val.map(lambda e: tokenizer(e['text'], padding=True, truncation=True), batched=True, batch_size=1000)
让我们看看训练集的样子。tokenizer 已经将注意力掩码和输入 ID 添加到数据集中,以便 BERT 模型可以处理它们:
import pandas as pd pd.DataFrame(enc_train)
输出结果如下:
此时,数据集已经准备好进行训练和测试。Trainer
类(对于 TensorFlow 是 TFTrainer
)和 TrainingArguments
类(对于 TensorFlow 是 TFTrainingArguments
)将帮助我们处理大部分训练的复杂性。我们将在 TrainingArguments
类中定义我们的参数集,然后将其传递给 Trainer
对象。
让我们定义每个训练参数的作用:
有关更多信息,请查看 TrainingArguments
的 API 文档或在 Python Notebook 中执行以下代码:
TrainingArguments?
虽然像 LSTM 这样的深度学习架构需要许多 epochs(有时超过 50 个),但对于基于 Transformer 的微调,我们通常会满足于 3 个 epochs,因为迁移学习的原因。大多数情况下,这个数字足够用于微调,因为预训练模型在预训练阶段已经学到了很多关于语言的知识,预训练阶段通常需要约 50 个 epochs。为了确定正确的 epochs 数量,我们需要监控训练和评估的损失。我们将在第 11 章学习如何跟踪训练过程。
对于许多下游任务问题,这将足够,如我们将在这里看到的那样。在训练过程中,我们的模型检查点将每 200 步保存到 ./MyIMDBModel
文件夹下:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./MyIMDBModel', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=32, per_device_eval_batch_size=64, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./logs', logging_steps=200, evaluation_strategy='steps', fp16=cuda.is_available(), load_best_model_at_end=True )
在实例化 Trainer
对象之前,我们将定义 compute_metrics()
方法,这有助于我们根据特定的指标(例如精度、RMSE、皮尔逊相关系数、BLEU 等)监控训练进度。文本分类问题(如情感分类和多类分类)通常通过微平均或宏平均 F1 分数进行评估。宏平均方法对每个类别给予相等的权重,而微平均则对每个文本或每个标记的分类决策给予相等的权重。微平均等于模型正确决策的次数与总决策次数的比率。另一方面,宏平均方法计算每个类别的精度、召回率和 F1 分数的平均值。对于我们的分类问题,宏平均更方便用于评估,因为我们希望对每个标签给予相等的权重,如下所示:
from sklearn.metrics import accuracy_score, precision_recall_fscore_support def compute_metrics(pred): labels = pred.label_ids preds = pred.predictions.argmax(-1) precision, recall, f1, _ = precision_recall_fscore_support(labels, preds, average='macro') acc = accuracy_score(labels, preds) return { 'Accuracy': acc, 'F1': f1, 'Precision': precision, 'Recall': recall }
我们几乎准备好开始训练过程了。现在,让我们实例化 Trainer
对象并启动它。由于 transformers 库的支持,Trainer
类是一个非常强大且优化的工具,用于组织复杂的 PyTorch 和 TensorFlow(TFTrainer 对于 TensorFlow)训练和评估过程:
trainer = Trainer( model=model, args=training_args, train_dataset=enc_train, eval_dataset=enc_val, compute_metrics=compute_metrics )
最后,我们可以开始训练过程:
results = trainer.train()
上述调用将开始记录指标,我们将在第 11 章中详细讨论这些指标。整个 IMDb 数据集包括 25,000 个训练示例。批次大小为 32 时,我们有 25K/32 ≈ 782 步,需要 2,346 步(782 x 3)来完成 3 个 epochs,如下进度条所示:
Trainer
对象在训练结束时会保存验证损失最小的检查点。它选择了第 1,400 步的检查点,因为此步的验证损失最小。让我们在三个数据集(训练集/验证集/测试集)上评估最佳检查点:
q = [trainer.evaluate(eval_dataset=data) for data in [enc_train, enc_val, enc_test]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
输出如下:
干得好!我们已经成功完成了训练/测试阶段,并在宏平均下获得了 92.6 的准确率和 F1 分数。要更详细地监控训练过程,可以使用高级工具如 TensorBoard。这些工具可以解析日志,并让我们跟踪各种指标以进行全面分析。我们已经在 ./logs
文件夹中记录了性能和其他指标。只需在 Python notebook 中运行以下 tensorboard
函数即可,如下所示(我们将在第 11 章详细讨论 TensorBoard 和其他监控工具):
%reload_ext tensorboard %tensorboard --logdir logs
现在,我们将使用模型进行推理,以检查它是否正常工作。让我们定义一个预测函数以简化预测步骤,如下所示:
def get_prediction(text): inputs = tokenizer(text, padding=True, truncation=True, max_length=250, return_tensors="pt").to(device) outputs = model(inputs["input_ids"].to(device), inputs["attention_mask"].to(device)) probs = outputs[0].softmax(1) return probs, probs.argmax()
现在,运行模型进行推理:
text = "I didn't like the movie it bored me " get_prediction(text)[1].item()
我们得到的结果是 0,表示负面情感。我们已经定义了哪个 ID 对应哪个标签。我们可以使用这种映射方案来获取标签。或者,我们可以将所有这些繁琐的步骤直接传递给专用的 API,即 Pipeline,我们已经很熟悉了。在实例化之前,让我们保存最佳模型以备后续推理使用:
model_save_path = "MyBestIMDBModel" trainer.save_model(model_save_path) tokenizer.save_pretrained(model_save_path)
Pipeline API 是使用预训练模型进行推理的一种简单方法。我们从保存位置加载模型并将其传递给 Pipeline API,后者会处理其余部分。我们可以跳过这一步保存过程,直接将内存中的模型和 tokenizer 对象传递给 Pipeline API。如果这样做,你会得到相同的结果。
如下代码所示,当我们执行二分类任务时,需要指定 Pipeline 的任务名称参数为 sentiment-analysis:
from transformers import ( pipeline, DistilBertForSequenceClassification, DistilBertTokenizerFast) model = DistilBertForSequenceClassification.from_pretrained("MyBestIMDBModel") tokenizer = DistilBertTokenizerFast.from_pretrained("MyBestIMDBModel") nlp = pipeline("sentiment-analysis", model=model, tokenizer=tokenizer)
测试模型:
nlp("the movie was very impressive")
输出:
[{'label': 'POS', 'score': 0.9621992707252502}]
再测试一次:
nlp("the text of the picture was very poor")
输出:
[{'label': 'NEG', 'score': 0.9938313961029053}]
Pipeline API 知道如何处理输入,并且以某种方式学习了哪个 ID 对应哪个标签(POS 或 NEG)。它还提供了类别概率。
干得好!我们已经使用 Trainer 类为 IMDb 数据集微调了情感预测模型。在下一节中,我们将使用原生 PyTorch 进行相同的二分类训练,并使用不同的数据集。
使用原生 PyTorch 训练分类模型
Trainer
类非常强大,我们要感谢 Hugging Face 团队提供了这样一个有用的工具。然而,在本节中,我们将从头开始微调预训练模型,以了解其内部工作原理。让我们开始吧。
首先,加载用于微调的模型。我们将选择 DistilBert,因为它是 BERT 的一个小型、快速且廉价的版本:
from transformers import DistilBertForSequenceClassification model = DistilBertForSequenceClassification.from_pretrained('distilbert-base-uncased')
为了微调任何模型,我们需要将其置于训练模式,如下所示:
model.train()
接下来,我们需要加载 tokenizer:
from transformers import DistilBertTokenizerFast tokenizer = DistilBertTokenizerFast.from_pretrained('bert-base-uncased')
由于 Trainer
类为我们组织了整个过程,我们在之前的 IMDb 情感分类练习中没有涉及优化和其他训练设置。现在,我们需要自己实例化优化器。在这里,我们必须选择 AdamW,它是 Adam 算法的一个实现,但修正了权重衰减问题。最近的研究表明,AdamW 在训练损失和验证损失上表现得比 Adam 更好。因此,它在许多 transformer 训练过程中被广泛使用:
from transformers import AdamW optimizer = AdamW(model.parameters(), lr=1e-3)
为了从头设计微调过程,我们必须了解如何实现单步的前向传播和反向传播。我们可以将一个批次的数据通过 transformer 层,得到输出,这称为前向传播。然后,我们必须使用输出和实际标签计算损失,并根据损失更新模型权重,这称为反向传播。
以下代码接收与标签相关联的三句话,进行一次批次的前向传播。最后,模型会自动计算损失:
import torch texts = ["this is a good example", "this is a bad example", "this is a good one"] labels = [1, 0, 1] labels = torch.tensor(labels).unsqueeze(0) encoding = tokenizer(texts, return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'] attention_mask = encoding['attention_mask'] outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs.loss loss.backward() optimizer.step()
输出如下:
SequenceClassifierOutput( [('loss', tensor(0.7178, grad_fn=<NllLossBackward>)), ('logits', tensor([[ 0.0664, -0.0161], [ 0.0738, 0.0665], [ 0.0690, -0.0010]], grad_fn=<AddmmBackward>))])
模型接收 input_ids
和 attention_mask
,这些是由 tokenizer 生成的,并使用实际标签计算损失。我们可以看到输出包括损失和 logits。loss.backward()
计算了张量的梯度,通过输入和标签对模型进行评估。optimizer.step()
执行单步优化,并使用计算出的梯度更新权重,这被称为反向传播。当我们将这些步骤放入一个循环时,我们还将添加 optimizer.zero_grad()
,它清除所有参数的梯度。重要的是在循环开始时调用这个方法,否则我们可能会积累来自多个步骤的梯度。输出中的第二个张量是 logits。在深度学习中,logits 是神经网络的最后一层,包括预测值(实数)。在分类的情况下,logits 需要通过 softmax 函数转换为概率。否则,它们仅仅是用于回归的标准化值。
如果我们要手动计算损失,我们不能将标签传递给模型。因此,模型只会产生 logits,而不会计算损失。在以下示例中,我们手动计算了交叉熵损失:
from torch.nn import functional labels = torch.tensor([1, 0, 1]) outputs = model(input_ids, attention_mask=attention_mask) loss = functional.cross_entropy(outputs.logits, labels) loss.backward() optimizer.step()
输出:
tensor(0.6101, grad_fn=<NllLossBackward>)
通过以上操作,我们了解了如何在前向传播过程中处理批量输入。现在,是时候设计一个循环,以批量的形式遍历整个数据集,从而训练模型多个周期。为此,我们将从设计 Dataset
类开始。它是 torch.Dataset
的一个子类,继承了成员变量和函数,并实现了 __init__()
和 __getitem__()
抽象函数:
from torch.utils.data import Dataset class MyDataset(Dataset): def __init__(self, encodings, labels): self.encodings = encodings self.labels = labels def __getitem__(self, idx): item = {key: torch.tensor(val[idx]) for key, val in self.encodings.items()} item['labels'] = torch.tensor(self.labels[idx]) return item def __len__(self): return len(self.labels)
我们将使用一个名为 Stanford Sentiment Treebank v2 (sst2) 的情感分析数据集来微调模型。我们还将加载 sst2 对应的评估指标,如下所示:
import datasets from datasets import load_dataset sst2 = load_dataset("glue", "sst2") from datasets import load_metric metric = load_metric("glue", "sst2")
我们将提取句子和标签:
texts = sst2['train']['sentence'] labels = sst2['train']['label'] val_texts = sst2['validation']['sentence'] val_labels = sst2['validation']['label']
现在,我们可以通过 tokenizer 处理数据集,并实例化 MyDataset
对象,使 BERT 模型可以使用它们:
train_dataset = MyDataset(tokenizer(texts, truncation=True, padding=True), labels) val_dataset = MyDataset(tokenizer(val_texts, truncation=True, padding=True), val_labels)
让我们实例化 DataLoader
类,它提供了一个接口,通过加载顺序迭代数据样本。这也有助于批处理和内存固定:
from torch.utils.data import DataLoader train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True) val_loader = DataLoader(val_dataset, batch_size=16, shuffle=True)
以下代码检测设备并正确定义 AdamW 优化器:
from transformers import AdamW device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu') model.to(device) optimizer = AdamW(model.parameters(), lr=1e-3)
到目前为止,我们已经了解了如何实现前向传播,这是在一个批次的示例中处理数据的过程。在一个步骤中,每一层从第一层到最后一层都通过批量数据进行处理,经过激活函数后传递给下一层。为了通过多个周期遍历整个数据集,我们设计了两个嵌套循环:外部循环用于 epoch,内部循环用于每个批次的步骤。内部部分由两个块组成,一个是训练,另一个是评估每个 epoch。正如你所注意到的,我们在第一个训练循环中调用了 model.train()
,而在第二个评估块中,我们调用了 model.eval()
。这很重要,因为我们将模型置于训练和推理模式。
我们已经讨论了内部块。请注意,我们通过对应的 metric 对象跟踪模型的性能:
for epoch in range(3): model.train() for batch in train_loader: optimizer.zero_grad() input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) loss = outputs[0] loss.backward() optimizer.step() model.eval() for batch in val_loader: input_ids = batch['input_ids'].to(device) attention_mask = batch['attention_mask'].to(device) labels = batch['labels'].to(device) outputs = model(input_ids, attention_mask=attention_mask, labels=labels) predictions = outputs.logits.argmax(dim=-1) metric.add_batch(predictions=predictions, references=batch["labels"]) eval_metric = metric.compute() print(f"epoch {epoch}: {eval_metric}")
输出:
epoch 0: {'accuracy': 0.9048165137614679} epoch 1: {'accuracy': 0.8944954128440367} epoch 2: {'accuracy': 0.9094036697247706}
干得好!我们已经微调了模型,并获得了约 90.94 的准确率。剩下的过程,例如保存、加载和推理,将类似于我们在 Trainer
类中所做的。
到此为止,我们已经完成了二分类任务。在下一节中,我们将学习如何为非英语语言实现多类分类模型。
使用自定义数据集微调 BERT 进行多类分类
在本节中,我们将微调土耳其语 BERT,即 BERTurk,以执行七类分类的下游任务,数据集来自土耳其报纸,包含七个类别。我们将从获取数据集开始。你也可以在本书的 GitHub 仓库中找到这个数据集,或者从 Kaggle 下载。
首先,在 Python Notebook 中运行以下代码以获取数据:
!wget https://raw.githubusercontent.com/savasy/TurkishTextClassification/master/TTC4900.csv
然后,我们加载数据:
import pandas as pd data = pd.read_csv("TTC4900.csv") data = data.sample(frac=1.0, random_state=42)
接下来,我们将使用 id2label
和 label2id
来组织 ID 和标签,以便模型能够识别每个 ID 对应的标签。我们还将传递标签数量 NUM_LABELS
给模型,以指定 BERT 模型顶部的薄分类头层的大小:
labels = ["teknoloji", "ekonomi", "saglik", "siyaset", "kultur", "spor", "dunya"] NUM_LABELS = len(labels) id2label = {i: l for i, l in enumerate(labels)} label2id = {l: i for i, l in enumerate(labels)} data["labels"] = data.category.map(lambda x: label2id[x.strip()]) data.head()
输出结果如下:
让我们使用 pandas 对象计算并绘制类别数量的饼图:
data.category.value_counts().plot(kind='pie')
如下面的图示所示,数据集的类别分布相对均匀:
以下代码实例化了一个序列分类模型,设置了标签数量(7)、标签 ID 映射以及 BERTurk 模型:
>>> model
输出将是模型的总结,内容较长,此处不一一展示。我们将重点关注最后一层:
(classifier): Linear(in_features=768, out_features=7, bias=True)
你可能注意到,我们没有选择 DistilBert,因为没有为土耳其语预训练的无大写 DistilBert 模型:
from transformers import BertTokenizerFast tokenizer = BertTokenizerFast.from_pretrained("dbmdz/bert-base-turkish-uncased", max_length=512) from transformers import BertForSequenceClassification model = BertForSequenceClassification.from_pretrained( "dbmdz/bert-base-turkish-uncased", num_labels=NUM_LABELS, id2label=id2label, label2id=label2id ) model.to(device)
接下来,让我们准备训练集(50%)、验证集(25%)和测试集(25%),如下所示:
SIZE = data.shape[0] # 句子 train_texts = list(data.text[:SIZE//2]) val_texts = list(data.text[SIZE//2:(3*SIZE)//4]) test_texts = list(data.text[(3*SIZE)//4:]) # 标签 train_labels = list(data.labels[:SIZE//2]) val_labels = list(data.labels[SIZE//2:(3*SIZE)//4]) test_labels = list(data.labels[(3*SIZE)//4:]) # 检查大小 len(train_texts), len(val_texts), len(test_texts) # 输出: (2450, 1225, 1225)
以下代码将三个数据集的句子进行分词,并将其转换为整数(input_ids),然后输入到 BERT 模型中:
train_encodings = tokenizer(train_texts, truncation=True, padding=True) val_encodings = tokenizer(val_texts, truncation=True, padding=True) test_encodings = tokenizer(test_texts, truncation=True, padding=True)
我们已经实现了 MyDataset
类。该类继承自抽象的 Dataset
类,并重写了 __getitem__
和 __len__()
方法,分别用于返回数据项和数据集的大小:
train_dataset = MyDataset(train_encodings, train_labels) val_dataset = MyDataset(val_encodings, val_labels) test_dataset = MyDataset(test_encodings, test_labels)
由于数据集相对较小,我们将批量大小保持为 16。请注意,TrainingArguments
的其他参数与之前的情感分析实验几乎相同:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./TTC4900Model', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=16, per_device_eval_batch_size=32, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./multi-class-logs', logging_steps=50, evaluation_strategy="epoch", eval_steps=50, save_strategy="epoch", fp16=True, load_best_model_at_end=True )
情感分析和文本分类使用相同的评估指标,即宏平均 F1、精确度和召回率。因此,我们不再重新定义 compute_metrics()
函数。下面是实例化 Trainer
对象的代码:
trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=val_dataset, compute_metrics=compute_metrics )
最后,让我们开始训练过程:
trainer.train()
输出结果如下:
要检查训练后的模型,我们需要在三个数据集划分上评估微调后的模型,具体如下。我们最佳的模型在第 300 步时微调完成,损失为 0.28012:
q = [trainer.evaluate(eval_dataset=data) for data in [train_dataset, val_dataset, test_dataset]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
输出结果如下:
分类准确率约为 92.6%,而 F1 宏平均约为 92.5%。在文献中,许多方法已在这个土耳其基准数据集上进行了测试。这些方法主要采用 TF-IDF 和线性分类器、word2vec 嵌入或基于 LSTM 的分类器,最好的 F1 值也仅约为 90.0。与这些方法相比,除了 Transformer 模型外,微调后的 BERT 模型表现更为优越。
与其他实验一样,我们可以通过 TensorBoard 跟踪实验进程:
%load_ext tensorboard %tensorboard --logdir multi-class-logs/
让我们设计一个函数来运行模型进行推断。如果你想看到实际的标签而不是 ID,可以使用模型的配置对象,如下所示的预测函数:
def predict(text): inputs = tokenizer(text, padding=True, truncation=True, max_length=512, return_tensors="pt").to("cuda") outputs = model(**inputs) probs = outputs[0].softmax(1) return (probs, probs.argmax(), model.config.id2label[probs.argmax().item()])
现在,我们准备调用预测函数进行文本分类推断。以下代码对关于足球队的句子进行分类:
text = "Fenerbahçeli futbolcular kısa paslarla hazırlık çalışması yaptılar" predict(text)
输出:
(tensor([[5.6183e-04, 4.9046e-04, 5.1385e-04, 9.94e-04, 3.44e-04, 9.96e-01, 4.061e-04]], device='cuda:0', grad_fn=<SoftmaxBackward>), tensor(5, device='cuda:0'), 'spor')
如我们所见,模型正确地将句子预测为体育(spor)。现在,是时候保存模型并使用 from_pre-trained()
函数重新加载它了。以下是代码:
model_path = "turkish-text-classification-model" trainer.save_model(model_path) tokenizer.save_pretrained(model_path)
现在,我们可以重新加载保存的模型,并使用 pipeline
类进行推断:
model_path = "turkish-text-classification-model" from transformers import (pipeline, BertForSequenceClassification, BertTokenizerFast) model = BertForSequenceClassification.from_pretrained(model_path) tokenizer = BertTokenizerFast.from_pretrained(model_path) nlp = pipeline("sentiment-analysis", model=model, tokenizer=tokenizer)
你可能注意到任务名称是 sentiment-analysis
。这个术语可能会让人困惑,但这个参数最终会返回 TextClassificationPipeline
。让我们运行管道:
nlp("Sinemada hangi filmler oynuyor bugün")
输出:
[{'label': 'kultur', 'score': 0.9930670261383057}]
nlp("Dolar ve Euro bugün yurtiçi piyasalarda yükseldi")
输出:
[{'label': 'ekonomi', 'score': 0.9927696585655212}]
nlp("Bayern Münih ile Barcelona bugün karşı karşıya geliyor. \ Maçı İngiliz hakem James Watts yönetecek!")
输出:
[{'label': 'spor', 'score': 0.9975664019584656}]
模型已经成功预测。
到目前为止,我们已经实现了两个单句任务——情感分析和多类别分类。在下一节中,我们将学习如何处理句子对输入以及如何设计 BERT 的回归模型。
微调 BERT 模型以进行句子对回归
回归模型通常用于分类任务,但在这种情况下,最后一层仅包含一个单元。与通过 softmax 逻辑回归处理不同,它会被归一化。要定义模型并在顶部添加一个单单元头层,有两个选项:直接在 BERT.from_pretrained()
方法中包括 num_labels=1
参数,或通过配置对象传递此信息。最初,需要从预训练模型的配置对象中复制这个配置,如下所示:
from transformers import ( DistilBertConfig, DistilBertTokenizerFast, DistilBertForSequenceClassification) model_path = 'distilbert-base-uncased' config = DistilBertConfig.from_pretrained(model_path, num_labels=1) tokenizer = DistilBertTokenizerFast.from_pretrained(model_path) model = DistilBertForSequenceClassification.from_pretrained( model_path, config=config)
通过 num_labels=1
参数,我们的预训练模型拥有了一个单单元头层。现在,我们准备使用我们的数据集来微调模型。我们将使用语义文本相似度基准(STS-B),这是一个包含句子对的数据集,这些句子对来源于各种内容,如新闻标题。每对句子都被标注了一个从 1 到 5 的相似度评分。我们的任务是微调 BERT 模型以预测这些评分。我们将使用 Pearson/Spearman 相关系数来评估模型,遵循文献中的做法。让我们开始吧。
以下代码加载数据。原始数据被拆分为三部分。然而,测试集没有标签,因此我们可以将验证数据分为两部分,如下所示:
import datasets from datasets import load_dataset stsb_train = load_dataset('glue', 'stsb', split="train") stsb_validation = load_dataset('glue', 'stsb', split="validation") stsb_validation = stsb_validation.shuffle(seed=42) stsb_val = datasets.Dataset.from_dict(stsb_validation[:750]) stsb_test = datasets.Dataset.from_dict(stsb_validation[750:])
让我们通过 Pandas 包装 stsb_train
训练数据,使其更加整洁:
pd.DataFrame(stsb_train)
这就是训练数据的样子:
运行以下代码以检查三个数据集的形状:
stsb_train.shape, stsb_val.shape, stsb_test.shape
输出结果是:
((5749, 4), (750, 4), (750, 4))
运行以下代码以对数据集进行分词:
enc_train = stsb_train.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000) enc_val = stsb_val.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000) enc_test = stsb_test.map(lambda e: tokenizer( e['sentence1'], e['sentence2'], padding=True, truncation=True), batched=True, batch_size=1000)
分词器将两个句子用 [SEP]
分隔符合并,并生成一个句子对的 input_ids
和 attention_mask
,如下所示:
pd.DataFrame(enc_train)
输出结果如下:
与其他实验类似,我们在 TrainingArguments
和 Trainer
类中的配置几乎相同。以下是代码:
from transformers import TrainingArguments, Trainer training_args = TrainingArguments( output_dir='./stsb-model', do_train=True, do_eval=True, num_train_epochs=3, per_device_train_batch_size=32, per_device_eval_batch_size=64, warmup_steps=100, weight_decay=0.01, logging_strategy='steps', logging_dir='./logs', logging_steps=50, evaluation_strategy="steps", save_strategy="epoch", fp16=True, load_best_model_at_end=True )
当前回归任务与之前的分类任务之间的另一个重要区别是 compute_metrics
的设计。在这里,我们的评估指标将基于 Pearson 相关系数和 Spearman 秩相关系数,这符合文献中的常见做法。我们还提供了均方误差(MSE)、均方根误差(RMSE)和平均绝对误差(MAE)指标,这些指标通常用于回归模型:
import numpy as np from scipy.stats import pearsonr from scipy.stats import spearmanr def compute_metrics(pred): preds = np.squeeze(pred.predictions) return { "MSE": ((preds - pred.label_ids) ** 2).mean().item(), "RMSE": np.sqrt(((preds - pred.label_ids) ** 2).mean()).item(), "MAE": np.abs(preds - pred.label_ids).mean().item(), "Pearson": pearsonr(preds, pred.label_ids)[0], "Spearman": spearmanr(preds, pred.label_ids)[0] }现在,让我们实例化 Trainer 对象:
trainer = Trainer( model=model, args=training_args, train_dataset=enc_train, eval_dataset=enc_val, compute_metrics=compute_metrics, tokenizer=tokenizer )
然后,运行训练:
train_result = trainer.train()
输出结果如下:
最佳的验证损失是在第 450 步计算得出的,为 0.544973。让我们对在该步骤获得的最佳检查点模型进行评估,代码如下:
q = [trainer.evaluate(eval_dataset=data) for data in [enc_train, enc_val, enc_test]] pd.DataFrame(q, index=["train", "val", "test"]).iloc[:, :5]
输出结果如下:
皮尔逊和斯皮尔曼相关系数在测试数据集上的得分分别为 87.54 和 87.28。虽然我们没有获得最先进的结果,但根据 GLUE 基准排行榜,我们得到了一个可比的 STS-B 任务结果。
现在我们准备进行模型推理。让我们使用以下两个含义相同的句子,并将其传递给模型:
s1, s2 = "A plane is taking off.", "An air plane is taking off." encoding = tokenizer(s1, s2, return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) outputs = model(input_ids, attention_mask=attention_mask) outputs.logits.item()
输出结果为:4.033723831176758
接下来,我们处理负面的句子对,这意味着这些句子在语义上是不同的:
s1, s2 = "The men are playing soccer.", "A man is riding a motorcycle." encoding = tokenizer("hey how are you there", "hey how are you", return_tensors='pt', padding=True, truncation=True, max_length=512) input_ids = encoding['input_ids'].to(device) attention_mask = encoding['attention_mask'].to(device) outputs = model(input_ids, attention_mask=attention_mask) outputs.logits.item()
输出结果为:2.3579328060150146
最后,我们将模型保存起来,代码如下:
model_path = "sentence-pair-regression-model" trainer.save_model(model_path) tokenizer.save_pretrained(model_path)
干得好!我们已经成功完成了三个任务:情感分析、多类分类和句子对回归。所有这些任务都基于单标签。接下来,我们将学习如何解决多标签分类问题。
多标签文本分类
在本章中,我们已经解决了多类别文本分类问题,其中每个文本只分配一个标签。现在,我们将讨论多标签分类,其中一个文本可以有多个标签。这在自然语言处理应用中很常见,例如新闻分类。例如,一条新闻可能同时与体育和健康相关。下图展示了多标签分类的示例:
现在,我们将深入探讨如何开发一个多标签分类的流程。为此,我们将使用PubMed数据集,该数据集包含约50,000篇研究文章。这些文章被生物医学专家手动标注了MeSH标签,每篇文章根据14个MeSH标签的组合进行描述。
首先,我们需要导入相关库:
import torch import numpy as np import pandas as pd from datasets import Dataset, load_dataset from transformers import (AutoTokenizer, AutoModelForSequenceClassification, TrainingArguments, Trainer)
数据集已经由Hugging Face Hub提供托管。我们从Hub下载数据集,如下所示。为了快速训练,我们仅选择训练数据集的10%。如果你希望获得更好的性能,可以使用整个数据集:
path = "owaiskha9654/PubMed_MultiLabel_Text_Classification_Dataset_MeSH" dataset = load_dataset(path, split="train[:10%]") train_dataset = pd.DataFrame(dataset) text_column = 'abstractText' # 文本字段 label_names = list(train_dataset.columns[6:]) num_labels = len(label_names) print('Number of Labels:', num_labels) train_dataset[[text_column] + label_names]
输出结果如下:
Number of Labels: 14
如下面的表格所示,我们有一个abstractText
字段和14个可能的标签,其中1和0分别表示标签的存在和不存在。
现在,让我们计算标签分布并进行分析,使用以下代码:
train_dataset[label_names].apply(lambda x: sum(x), axis=0).plot(kind="bar", figsize=(10, 6))
这将绘制以下图表:
从不平衡的分布中可以看出,我们有一些较小的标签,如 F、H、I、J、L 和 Z。对整个数据集进行分析时,也会得到类似的分布!
我们需要将标签列转换为一个列表,如下所示:
train_dataset["labels"] = train_dataset.apply( lambda x: x[label_names].to_numpy(), axis=1) train_dataset[[text_column, "labels"]]
现在,我们得到了以下形状(abstractText -> labels),这是后续管道所需要的:
我们将微调 distilbert-base-uncased
模型。由于需要对文本进行标记化,我们首先加载 DistilBert 分词器,如下所示:
model_path = "distilbert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(model_path) def tokenize(batch): return tokenizer(batch[text_column], padding=True, truncation=True)
现在,我们将数据集拆分为三个子集(50% 训练集、25% 验证集和 25% 测试集),并相应地对其进行标记化:
q = train_dataset[[text_column, "labels"]].copy() CUT = int((q.shape)[0] * 0.5) CUT2 = int((q.shape)[0] * 0.75) train_df = q[:CUT] # 训练集 val_df = q[CUT:CUT2] # 验证集 test_df = q[CUT2:] # 测试集 train = Dataset.from_pandas(train_df) # 转换为 Dataset 对象 val = Dataset.from_pandas(val_df) test = Dataset.from_pandas(test_df) train_encoded = train.map(tokenize, batched=True, batch_size=None) val_encoded = val.map(tokenize, batched=True, batch_size=None) test_encoded = test.map(tokenize, batched=True, batch_size=None)
我们现在有三个子集的数据集,这些数据集已经通过分词器进行了编码,并准备好由 Transformer 模型处理。
接下来,我们将定义一个函数来处理 Transformer 模型最后一层的激活(logits),以生成预测向量。如果我们进行的是单标签分类,softmax 将是最合适的公式,因为我们有一个输出。然而,由于许多标签可以是正确的标签且它们不是互斥的,我们将通过 sigmoid 函数独立地传递所有标签,而不是使用 softmax。通过这样做,我们将有机会同时选择所有标签作为预测或没有任何标签。我们通过将 sigmoid 函数的输出通过简单的 >0.5 阈值来做出决定。
现在,我们将定义 compute_metrics()
函数,以在训练期间监控模型性能。以下函数利用 sklearn 库给出标签存在的精确度、召回率和 F1 分数。请注意,在 f1_score()
函数中,我们设置 pos_label=1
,因为我们只关注标签的存在。如果考虑标签的存在和缺失,你可能会得到人为的高结果,并且很难监控模型性能,尤其是对于标签稀疏模式!
让我们定义 compute_metrics
函数:
from sklearn.metrics import (f1_score, precision_score, recall_score) def compute_metrics(eval_pred): y_pred, y_true = eval_pred y_pred = torch.from_numpy(y_pred) y_true = torch.from_numpy(y_true) y_pred = y_pred.sigmoid() > 0.5 y_true = y_true.bool() r = recall_score(y_true, y_pred, average='micro', pos_label=1) p = precision_score(y_true, y_pred, average='micro', pos_label=1) f1 = f1_score(y_true, y_pred, average='micro', pos_label=1) result = {"Recall": r, "Precision": p, "F1": f1} return result
对于单标签多分类任务,我们使用 softmax 激活函数加上交叉熵损失函数。然而,对于多标签模式,我们需要使用不同的激活函数和损失函数。在实现过程中,我们保留原始 Trainer 的所有其他功能,但必须调整损失函数。具体来说,我们将 Trainer 类原始损失函数中的 torch.nn.CrossEntropyLoss()
函数替换为 torch.nn.BCEWithLogitsLoss()
。
如下所示,在 MultilabelTrainer
类定义中,torch.nn.BCEWithLogitsLoss()
函数计算真实标签和原始 logits 之间的损失。损失函数首先通过 sigmoid 函数生成预测,然后计算损失:
class MultilabelTrainer(Trainer): def compute_loss(self, model, inputs, return_outputs=False): labels = inputs.pop("labels") outputs = model(**inputs) logits = outputs.logits loss_fct = torch.nn.BCEWithLogitsLoss() preds_ = logits.view(-1, self.model.config.num_labels) labels_ = labels.float().view(-1, self.model.config.num_labels) loss = loss_fct(preds_, labels_) return (loss, outputs) if return_outputs else loss
此时,Trainer
实例和 TrainingArguments
实例与之前的多分类模式大致相同。以下是参数:
batch_size = 16 num_epoch = 3 args = TrainingArguments( output_dir="/tmp", per_device_train_batch_size=batch_size, per_device_eval_batch_size=batch_size, num_train_epochs=num_epoch, do_train=True, do_eval=True, load_best_model_at_end=True, save_steps=100, eval_steps=100, save_strategy="steps", evaluation_strategy="steps")
我们将加载 DistilBert 模型。注意,我们将模型的输出层大小设置为 14(与 num_labels
一致):
model = AutoModelForSequenceClassification.from_pretrained( model_path, num_labels=num_labels)
好了!我们准备开始训练。开始吧!
multi_trainer = MultilabelTrainer(model, args, train_dataset=train_encoded, eval_dataset=val_encoded, compute_metrics=compute_metrics, tokenizer=tokenizer) multi_trainer.train()
如果一切顺利,我们可以在测试数据集上测试我们的模型,如下所示:
res = multi_trainer.predict(test_encoded) pd.Series(compute_metrics(res[:2])).to_frame()
以下是输出结果:
完成了!我们已经用一个采样的数据集训练了一个多标签分类模型,进行了快速的实验。请注意,使用完整的数据集进行训练会得到更好的F1得分!请你自己试试看吧!接下来,我们将使用一个脚本来简化整个过程。
利用 run_glue.py
进行模型微调
到目前为止,我们已经使用原生PyTorch和Trainer类从头设计了一个微调架构。Hugging Face社区还提供了一个强大的脚本 run_glue.py
,用于GLUE基准测试和类似GLUE的分类下游任务。这个脚本可以处理和组织整个训练/验证过程。如果你想进行快速原型开发,应该使用这个脚本。它可以微调任何在Hugging Face Hub上预训练的模型。我们也可以将自己的数据以任何格式输入给它。
这个脚本可以执行九种不同的GLUE任务。使用这个脚本,我们可以完成到目前为止使用Trainer类所做的所有操作。任务名称可以是以下GLUE任务中的一种:cola、sst2、mrpc、stsb、qqp、mnli、qnli、rte或wnli。
以下是微调模型的脚本示例:
export TASK_NAME="My-Task-Name" python run_glue.py \ --model_name_or_path bert-base-cased \ --task_name $TASK_NAME \ --do_train \ --do_eval \ --max_seq_length 128 \ --per_device_train_batch_size 32 \ --learning_rate 2e-5 \ --num_train_epochs 3 \ --output_dir /tmp/$TASK_NAME/
社区还提供了另一个脚本 run_glue_no_trainer.py
。与原始脚本的主要区别是,这个 no_trainer 脚本给了我们更多的机会来更改优化器的选项或添加我们想要的自定义内容。
总结
在这一章中,我们讨论了如何对任何文本分类下游任务微调预训练模型。我们使用情感分析、多类别分类和句子对分类(具体来说是句子对回归和多标签分类)来微调模型。我们使用了一个知名的IMDb数据集和我们自己的自定义数据集来训练模型。虽然我们利用了Trainer类来应对训练和微调过程中的复杂性,但我们也学习了如何使用原生库从头开始训练,以理解transformers库中的前向传播和反向传播。总结来说,我们讨论并进行了一些微调工作,包括使用Trainer进行单句分类,使用原生PyTorch进行情感分类,单句多类别分类,多标签分类,以及句子对回归的微调。