「HFLLM」3. 微调预训练模型
1 介绍
本章将学习:
- 如何从 Hub 准备大型数据集
- 如何使用高级
Trainer
API 微调模型 - 如何使用自定义训练循环
- 如何利用 🤗 Accelerate 库在任何分布式设置上轻松运行自定义训练循环
2 处理数据
以下是如何在 PyTorch 中的一个批次上训练序列分类器:
1 | import torch |
当然,仅仅用两句话训练模型不会产生非常好的结果。为了获得更好的结果,您需要准备更大的数据集。
在本节中,我们将使用 William B. Dolan 和 Chris Brockett 在一篇论文中介绍的 MRPC (Microsoft Research Paraphrase Corpus) 数据集作为示例。该数据集由 5,801 对句子组成,带有一个标签,指示它们是否是释义(即,如果两个句子的含义相同)。我们在本章中选择它,因为它是一个小数据集,因此很容易对其进行训练。
2.1 从Hub加载数据集
Hub 不仅包含模型;它还拥有许多不同语言的多个数据集。您可以在此处浏览数据集,我们建议您在完成本节后尝试加载和处理新数据集(请参阅此处的一般文档)。但现在,让我们专注于 MRPC 数据集!这是构成 GLUE 基准测试的 10 个数据集之一,GLUE 基准测试是一项学术基准测试,用于衡量 ML 模型在 10 个不同文本分类任务中的性能。
🤗 数据集库提供了一个非常简单的命令,用于在 Hub 上下载和缓存数据集。我们可以像这样下载 MRPC 数据集:
1 | from datasets import load_dataset |
这里在colab中进行测试,运行结果如下:
如你所见,我们得到了一个DatasetDict
对象,其中包含训练集、验证集和测试集。每个都包含几列(sentence1
、sentence2
、label
和idx
)和可变数量的行,即每个集中的元素数(因此,训练集中有 3,668 对句子,验证集中有 408 对,测试集中有 1,725 对)。
此命令默认在*~/.cache/huggingface/datasets*中下载并缓存数据集。回想一下第 2 章,您可以通过设置HF_HOME
环境变量来自定义缓存文件夹。
我们可以通过索引来访问raw_datasets
对象中的每对句子,就像使用字典一样:
1 | raw_train_dataset = raw_datasets["train"] |
结果如下:
我们可以看到标签已经是整数,因此我们不必在那里进行任何预处理。要知道哪个整数对应哪个标签,我们可以检查raw_train_dataset
的特征。这将告诉我们每列的类型:
在后台,label
的类型为ClassLabel
,整数到标签 name 的映射存储在names文件夹中。0
对应于not_equivalent,1
对应于等效
。
2.2 预处理数据集
要预处理数据集,我们需要将文本转换为模型可以理解的数字。正如您在上一章中看到的,这是使用 tokenizer 完成的。我们可以给分词器一个句子或一个句子列表,这样我们就可以直接对每对的所有第一句和所有第二句进行分词,如下所示:
1 | from transformers import AutoTokenizer |
但是,我们不能只将两个序列传递给模型并预测这两个句子是否是释义。我们需要将这两个序列作为一对处理,并应用适当的预处理。幸运的是,分词器还可以采用一对序列,并按照我们的 BERT 模型期望的方式进行准备:
1 | inputs = tokenizer("This is the first sentence.", "This is the second one.") |
之前讨论了input_ids
键和attention_mask
键,但我们推迟了讨论token_type_ids
。在此示例中,这是告诉模型输入的哪一部分是第一句话,哪一部分是第二句话。
如果我们将input_ids
中的 ID 解码回单词:
所以我们看到模型期望输入是当有两个句子[CLS] sentence1 [SEP] sentence2 [SEP]
时的形式。将此与token_type_ids
保持一致,我们可以得到:
1 | ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]'] |
如您所见,对应于[CLS] sentence1 [SEP]
的输入部分的标记类型 ID 均为0
,而对应于句子2 [SEP]
的其他部分的标记类型 ID 均为1
。
请注意,如果您选择其他检查点,则标记化输入中不一定包含token_type_ids
(例如,如果您使用 DistilBERT 模型,则不会返回它们)。只有当模型知道如何处理它们时,才会返回它们,因为它在预训练期间已经看到了它们。
在这里,BERT 使用令牌类型 ID 进行预训练,除了我们在第 1 章中讨论的掩码语言建模目标之外,它还有一个额外的目标,称为下一句预测。此任务的目标是对句子对之间的关系进行建模。
通过下一个句子预测,模型将获得成对的句子(带有随机掩码的标记),并要求预测第二个句子是否在第一个句子之后。为了使任务非同寻常,一半时间句子在提取它们的原始文档中彼此跟随,另一半时间这两个句子来自两个不同的文档。
一般来说,你不需要担心你的分词输入中是否有token_type_ids
:只要你对分词器和模型使用相同的检查点,一切都会好起来的,因为分词器知道要为其模型提供什么。
现在我们已经了解了分词器如何处理一对句子,我们可以使用它来分词我们的整个数据集:就像上一章一样,我们可以通过给分词器第一个句子的列表,然后是第二个句子的列表,给分词器一个句子对的列表。这也与我们在Chapter 2中看到的 padding 和 truncation 选项兼容。因此,预处理训练数据集的一种方法是:
1 | tokenized_dataset = tokenizer( |
这很好用,但它的缺点是返回字典(包含我们的键、input_ids
、attention_mask
和token_type_ids
,以及列表列表中的值)。在分词化过程中,如果您有足够的 RAM 来存储整个数据集,它也只能起作用(而 Datasets 库中的🤗数据集是存储在磁盘上的Apache Arrow文件,因此您只将请求的样本加载到内存中)。
为了将数据保存为数据集,我们将使用Dataset.map()方法。如果我们需要完成更多的预处理而不仅仅是 tokenization,这也为我们提供了一些额外的灵活性。map()
方法的工作原理是在数据集的每个元素上应用一个函数,因此让我们定义一个函数来标记我们的输入:
1 | def tokenize_function(example): |
此函数采用一个字典(就像我们数据集中的项目)并返回一个键为input_ids
、attention_mask
和token_type_ids
的新字典。请注意,如果示例
词典包含多个样本(每个键都是一个句子列表),它也有效,因为分词器
适用于句子对列表,如前所述。这将允许我们在调用map()
时使用选项batched=True
,这将大大加快分词速度。分词器
由 Tokenizers 库中用 Rust 编写的🤗分词器提供支持。这个分词器可以非常快,但前提是我们一次给它很多输入。
请注意,我们现在在 tokenization 函数中省略了padding
参数。这是因为将所有样本填充到最大长度效率不高:最好在构建批次时填充样本,因为这样我们只需要填充到该批次中的最大长度,而不是整个数据集中的最大长度。当输入的长度非常可变时,这可以节省大量时间和处理能力!
以下是我们如何一次在所有数据集上应用分词函数。我们在对map
的调用中使用了batched=True
,因此该函数一次应用于数据集的多个元素,而不是单独应用于每个元素。这允许更快的预处理。
1 | tokenized_datasets = raw_datasets.map(tokenize_function, batched=True) |
运行结果如下:
Datasets 库应用此处理的方式🤗是向数据集添加新字段,每个字段对应预处理函数返回的字典中的每个键:
你甚至可以在使用map()
应用预处理函数时通过传递num_proc
参数来使用 multiprocessing。我们在这里没有这样做,🤗因为 Tokenizers 库已经使用多个线程来更快地对样本进行分词,但如果您没有使用由此库支持的快速分词器,这可能会加快您的预处理速度。
我们的tokenize_function
返回一个键为input_ids
、attention_mask
和token_type_ids
的字典,因此这三个字段将添加到数据集的所有分片中。请注意,如果我们的预处理函数为我们应用map()
的数据集中的现有 key 返回新值,我们也可以更改现有字段。
我们需要做的最后一件事是在将元素批处理在一起时将所有示例填充到最长元素的长度——这种技术我们称之为动态填充。
2.3 动态填充
负责将样本放在一个批次中的函数称为collate 函数。这是您可以在构建DataLoader
时传递的参数,默认是一个函数,该函数只会将样本转换为 PyTorch 张量并连接它们(如果您的元素是列表、元组或字典,则递归)。在我们的例子中,这是不可能的,因为我们的输入不会都具有相同的大小。我们故意推迟了填充,仅在每个批次上根据需要应用它,并避免出现过多填充的过长输入。这将大大加快训练速度,但请注意,如果您在 TPU 上训练,它可能会导致问题——TPU 更喜欢固定的形状,即使这需要额外的填充。
为了在实践中做到这一点,我们必须定义一个 collate 函数,该函数将对我们想要一起批处理的数据集项目应用正确数量的填充。幸运的是,🤗 Transformers 库通过DßataCollatorWithPadding
为我们提供了这样的函数。当你实例化它时,它需要一个分词器(以了解要使用哪个 padding token,以及模型期望 padding 在输入的左侧还是右侧),并且会做你需要的一切:
1 | from transformers import DataCollatorWithPadding |
为了测试这个新方法,让我们从训练集中获取一些样本,我们想一起批处理这些样本。在这里,我们删除了idx
、sentence1
和sentence2
列,因为它们不需要并且包含字符串(而且我们不能用字符串创建张量),并查看批处理中每个条目的长度:
1 | samples = tokenized_datasets["train"][:8] |
结果如下:
毫不奇怪,我们得到的样品长度不一,从 32 到 67 不等。动态填充意味着此批次中的样本都应填充到 67 的长度,即该批次内的最大长度。如果没有动态填充,则必须将所有样本填充到整个数据集中的最大长度,或模型可以接受的最大长度。让我们仔细检查一下我们的data_collator
是否正确地动态填充了 batch:
1 | batch = data_collator(samples) |
看起来不错!现在我们已经从原始文本变成了我们的模型可以处理的批处理,我们准备对其进行微调!
3 使用Trainer API微调模型
🤗 Transformers 提供了一个Trainer
类,可帮助您微调它在数据集上提供的任何预训练模型。完成上一节中的所有数据预处理工作后,您只剩下几个步骤来定义Trainer
。最困难的部分可能是准备环境来运行Trainer.train(),
因为它在 CPU 上运行得非常慢。如果您没有设置 GPU,您可以在Google Colab上访问免费的 GPU 或 TPU。
下面的代码示例假定您已经执行了上一节中的示例。以下是您需要的简短摘要:
1 | from datasets import load_dataset |
3.1 训练
定义Trainer
之前的第一步是定义一个TrainingArguments
类,该类将包含Trainer
将用于训练和评估的所有超参数。您必须提供的唯一参数是保存训练模型的目录,以及沿途的检查点。对于所有其他作,您可以保留默认值,这应该可以很好地进行基本的微调。
1 | from transformers import TrainingArguments |
💡 如果要在训练期间自动将模型上传到中心,请在
TrainingArguments
中传递push_to_hub=True
。我们将在第 4 章中了解更多信息
第二步是定义我们的模型。与上一章一样,我们将使用具有两个标签的AutoModelForSequenceClassification
类:
1 | from transformers import AutoModelForSequenceClassification |
您会注意到,与第 2 章不同,在实例化此预训练模型后,您会收到一条警告。这是因为 BERT 尚未对句子对进行分类进行预训练,因此预训练模型的头部已被丢弃,而是添加了适合序列分类的新头部。警告表示某些权重未使用(与放置的预训练头对应的权重),并且其他一些权重是随机初始化的(新头的权重)。最后,它鼓励您训练模型,这正是我们现在要做的事情。
一旦有了模型,我们就可以定义一个Trainer
,方法是将到目前为止构建的所有对象(模型
、training_args
、训练和验证数据集、data_collator
和分词器
)传递给它:
1 | from transformers import Trainer |
请注意,当您像我们在此处所做的那样传递分词器
时,Trainer
使用的默认data_collator
将是之前定义的DataCollatorWithPadding
,因此您可以在此调用中跳过data_collator=data_collator
行。在第 2 节中向您展示这部分处理仍然很重要!
要在我们的数据集上微调模型,我们只需要调用Trainer
的train()
方法:
这将开始微调(在 GPU 上应该需要几分钟)并每 500 步报告一次训练损失。但是,它不会告诉您模型的性能如何(或差)。这是因为:
- 我们没有告诉
Trainer
在训练期间进行评估,而是将TrainingArguments
中的eval_strategy
设置为“steps”
(每eval_steps
评估一次)或“epoch”
(在每个 epoch 结束时评估)。 - 我们没有为
Trainer
提供compute_metrics()
函数来计算所述评估期间的指标(否则评估只会打印损失,这不是一个非常直观的数字)。
3.2 评估
让我们看看如何构建一个有用的compute_metrics()
函数并在下次训练时使用它。该函数必须采用EvalPrediction
对象(该对象是具有predictions
字段和label_ids
字段的命名元组),并将返回将字符串映射到浮点数的字典(字符串是返回的指标的名称,浮点数是其值)。要从我们的模型中获得一些预测,我们可以使用Trainer.predict()
命令:
1 | predictions = trainer.predict(tokenized_datasets["validation"]) |
predict()
方法的输出是另一个命名元组,其中包含三个字段:predictions
、label_ids
和metrics
。metrics
字段将仅包含所传递数据集的损失,以及一些时间指标(预测总时间和平均值)。完成compute_metrics()
函数并将其传递给Trainer
后,该字段还将包含compute_metrics()
返回的指标。
如您所见,predictions
是一个形状为 408 x 2 的二维数组(408 是我们使用的数据集中的元素数)。这些是我们传递给predict()
的数据集的每个元素的 logits(正如您在上一章中看到的,所有 Transformer 模型都返回 logits)。要将它们转换为我们可以与标签进行比较的预测,我们需要在第二个轴上获取具有最大值的索引:
1 | import numpy as np |
我们现在可以将这些preds
与标签进行比较。为了构建我们的compute_metric()
函数,我们将依赖Evaluate库中的🤗指标。我们可以像加载数据集一样轻松地加载与 MRPC 数据集相关的指标,这次使用evaluate.load()
函数。返回的对象有一个table()
方法,我们可以使用它来执行度量计算:
1 | import evaluate |
您获得的确切结果可能会有所不同,因为模型头的随机初始化可能会改变它实现的指标。在这里,我们可以看到我们的模型在验证集上的准确率为 85.78%,F1 分数为 89.97。这是用于评估 GLUE 基准测试的 MRPC 数据集结果的两个指标。BERT 论文中的表格报告了基本模型的 F1 分数为 88.9。这是我们目前使用有外壳
模型时的无外壳
模型,这解释了更好的结果。
将所有内容打包在一起,我们得到compute_metrics()
函数:
1 | def compute_metrics(eval_preds): |
为了看到它在每个 epoch 结束时报告指标的实际效果,以下是我们如何使用此compute_metrics()
函数定义新的Trainer
:
请注意,我们创建一个新的TrainingArguments
,将其eval_strategy
设置为“epoch”
和一个新模型 — 否则,我们将继续训练我们已经训练过的模型。要启动新的训练运行,我们执行:
1 | trainer.train() |
这一次,它将在每个 epoch 结束时报告训练损失之外的验证损失和指标。同样,由于模型的随机头部初始化,您达到的确切准确率/F1 分数可能与我们发现的略有不同,但它应该在同一个范围内。
Trainer
将在多个 GPU 或 TPU 上开箱即用,并提供许多选项,例如混合精度训练(在训练参数中使用fp16=True
)。我们将在第 10 章中介绍它支持的所有内容。
4 完整的训练过程
现在,我们将了解如何在不使用Trainer
类的情况下获得与上一节相同的结果。同样,我们假设您已经完成了第 2 节中的数据处理。这是一个简短的摘要,涵盖了您需要的一切:
1 | from datasets import load_dataset |
4.1 数据处理
1 | tokenized_datasets = tokenized_datasets.remove_columns(["sentence1", "sentence2", "idx"]) |
- 获取批量数据
1 | for batch in train_dataloader: |
4.2 模型实现
1 | from transformers import AutoModelForSequenceClassification |
4.3 优化器和学习率调度器
1 | from torch.optim import AdamW |
默认使用的学习率调度器只是从最大值 (5e-5) 到 0 的线性衰减。为了正确定义它,我们需要知道我们将采取的训练步骤数,即我们想要运行的 epoch 数乘以训练批次数(即我们的训练数据加载器的长度)。Trainer
默认使用三个 epoch,因此我们将遵循:
1 | from transformers import get_scheduler |
4.4 训练循环
1 | import torch |
添加进度条:
1 | from tqdm.auto import tqdm |
4.5 评估模型
1 | import evaluate |
4.6 使用Accelerate进行加速
1 | from accelerate import Accelerator |