听诊器的发明,让医生第一次能听见心肺的内部声音。 Transformer 的诞生,则让机器第一次真正理解了语言的上下文。对医疗人来说,这不只是技术的突破,它意味着我们可以重新思考如何让 AI 在临床、科研和患者服务中发挥作用。
作为医疗从业者,我们每天都要处理庞杂的信息:病历、科研文献……而 Transformer 正是当下所有大语言模型(LLM)的核心。理解它,等于理解了 ChatGPT、医学辅助诊断系统、自动科研写作工具的根基。
所以,接下来我希望带你一步步走入 Transformer 学习课程,用通俗的方式来理解复杂的技术。
需要课程代码以及问题咨询通过留言 📮 在路上的蟹老板(pqjrkwem@gmail.com)
课程简介
本课程将教你如何使用 Transformaer 生态系统的库进行自然语言处理(NLP)。
涵盖的库
- Transformers - 核心模型库
- Datasets - 数据集处理
- Tokenizers - 文本分词
- Accelerate - 训练加速
- Hugging Face Hub - 模型共享平台
课程结构
第 1-4 章:Transformers 基础
介绍 Transformers 库的主要概念。完成后你将:
- 了解 Transformer 模型的工作原理
- 使用 Transformer 模型
- 微调一个预训练模型
- 分享模型和标记器
第 5-8 章:拥有一个自己的模型
- Datasets 库
- Tokenizers 库
- 主要的 NLP 任务
- 构建和分享你的模型
学习要求
具备:
良好的 Python 知识, 要是熟悉 深度学习和机器学习就更好了, 对于 tensorflow 或者 pytorch 熟悉更更更加分
第三章:微调预训练模型
在第二章中,我们探索了如何使用 Tokenizer 和预训练模型进行预测。本章将介绍如何使用自己的数据集微调预训练模型。
你将学习到:
- 如何从模型中心(Hub)加载大型数据集
- 如何使用 Trainer API 微调模型
- 如何自定义训练过程
- 如何利用 Accelerate 在分布式设备上运行训练
第三章:微调预训练模型
章节概述
在第二章我们探索了如何使用 Tokenizer 和预训练模型进行预测。那么如何使用自己的数据集微调预训练模型呢?本章将解决这个问题!你将学到:
- 如何从模型中心(Hub)加载大型数据集
- 如何使用高级的 Trainer API 微调一个模型
- 如何使用自定义训练过程
- 如何利用 Accelerate 库在所有分布式设备上轻松运行自定义训练过程
3.1 处理数据
课程示例
在这一小节你将学习如何使用模型中心(Hub)加载大型数据集。下面是用模型中心的数据在 PyTorch 上训练句子分类器的一个例子:
import torch
from torch.optim import AdamW
from transformers import AutoTokenizer, AutoModelForSequenceClassification
# 和之前一样
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"This course is amazing!",
]
batch = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
# 新增部分
batch["labels"] = torch.tensor([1, 1])
optimizer = AdamW(model.parameters())
loss = model(**batch).loss
loss.backward()
optimizer.step()
然而仅用两句话训练模型不会产生很好的效果,你需要准备一个更大的数据集才能得到更好的训练结果。
在本节中,我们以 MRPC(微软研究院释义语料库)数据集为例,该数据集由威廉·多兰和克里斯·布罗克特在一篇文章中发布,由 5801 对句子组成,每个句子对带有一个标签来指示它们是否为同义(即两个句子的意思相同)。在本章选择该数据集的原因是它的数据体量小,容易对其进行训练。
从模型中心(Hub)加载数据集
模型中心(Hub)不仅仅包含模型,还有许多别的语言的数据集。你可以访问 huggingface.co/datasets 进行浏览。我们建议你在完成本节的学习后阅读一下加载和处理新的数据集相关文档,这会让你对 Hugging Face 的数据集理解更加清晰。
现在让我们使用 MRPC 数据集中的 GLUE 基准测试数据集作为我们训练所使用的数据集,它是构成 MRPC 数据集的 10 个数据集之一,作为一个用于衡量机器学习模型在 10 个不同文本分类任务中性能的学术基准。
Datasets 库提供了一条非常便捷的命令,可以在模型中心(Hub)上下载和缓存数据集。你可以以下代码下载 MRPC 数据集:
⚠️ 警告: 确保你已经运行 pip install datasets 安装了 datasets。然后,再继续下面的加载 MRPC 数据集和打印出来查看其内容。
from datasets import load_dataset
raw_datasets = load_dataset("glue", "mrpc")
raw_datasets
输出结果:
DatasetDict({
train: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 3668
})
validation: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 408
})
test: Dataset({
features: ['sentence1', 'sentence2', 'label', 'idx'],
num_rows: 1725
})
})
现在我们获得了一个 DatasetDict 对象,这个对象包含训练集、验证集和测试集。每一个集合都包含 4 个列(sentence1、sentence2、label 和 idx)以及一个代表行数的变量(每个集合中的行的个数)。运行结果显示该训练集中有 3668 对句子,验证集中有 408 对,测试集中有 1725 对。
默认情况下,该命令会下载数据集并缓存到 ~/.cache/huggingface/datasets。回想在第 2 章中我们学到过,可以通过设置 HF_HOME 环境变量来自定义缓存的文件夹。
我们可以访问该数据集中的每一个 raw_train_dataset 对象,例如使用字典:
raw_train_dataset = raw_datasets["train"]
raw_train_dataset[0]
输出结果:
{
"idx": 0,
"label": 1,
"sentence1": 'Amrozi accused his brother , whom he called " the witness " , of deliberately distorting his evidence .',
"sentence2": 'Referring to him as only " the witness " , Amrozi accused his brother of deliberately distorting his evidence .',
}
现在可以看到标签已经是整数了,因此不需要对标签做任何预处理。如果想要知道不同数字对应标签的实际含义,我们可以查看 raw_train_dataset 的 features。这告诉我们每列的类型:
raw_train_dataset.features
输出结果:
{
"sentence1": Value(dtype="string", id=None),
"sentence2": Value(dtype="string", id=None),
"label": ClassLabel(
num_classes=2,
names=["not_equivalent", "equivalent"],
names_file=None,
id=None
),
"idx": Value(dtype="int32", id=None),
}
上面的例子中的 label 是一种 ClassLabel,也就是使用整数建立起类别标签的映射关系。0 对应于 not_equivalent(非同义),1 对应于 equivalent(同义)。
✏️ 试试看! 查看训练集的第 15 行元素和验证集的 87 行元素。他们的标签是什么?
预处理数据集
为了预处理数据集,我们需要将文本转换为模型能够理解的数字。在第二章我们已经学习过,这是通过一个 Tokenizer 完成的。我们可以向 Tokenizer 输入一个句子或一个句子列表。以下代码表示对每对句子中的所有第一句和所有第二句进行 tokenize:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
tokenized_sentences_1 = tokenizer(raw_datasets["train"]["sentence1"])
tokenized_sentences_2 = tokenizer(raw_datasets["train"]["sentence2"])
不过在将两句话传递给模型,预测这两句话是否是同义之前,我们需要给这两句话依次进行适当的预处理。Tokenizer 不仅仅可以输入单个句子,还可以输入一组句子,并按照 BERT 模型所需要的输入进行处理:
inputs = tokenizer("This is the first sentence.", "This is the second one.")
inputs
输出结果:
{
"input_ids": [
101, 2023, 2003, 1996, 2034, 6251, 1012, 102,
2023, 2003, 1996, 2117, 2028, 1012, 102,
],
"token_type_ids": [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
"attention_mask": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
}
我们在第二章讨论了 input_ids 和 attention_mask,但尚未讨论 token_type_ids。在本例中,token_type_ids 的作用就是告诉模型输入的哪一部分是第一句,哪一部分是第二句。
✏️ 试试看! 选取训练集中的第 15 个元素,将两句话分别进行 tokenization。结果和上方的例子有什么不同?
如果将 input_ids 中的 id 转换回文字:
tokenizer.convert_ids_to_tokens(inputs["input_ids"])
将得到:
[ "[CLS]", "this", "is", "the", "first", "sentence", ".", "[SEP]", "this", "is", "the", "second", "one", ".", "[SEP]",]
所以我们看到模型需要输入的形式是 [CLS] sentence1 [SEP] sentence2 [SEP]。所以当有两句话的时候,token_type_ids 的值是:
输入 tokens: [CLS] this is the first sentence . [SEP] this is the second one . [SEP]
token_type_ids: [ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
现在输入中 [CLS] sentence1 [SEP] 它们的 token_type_ids 均为 0,而其他部分例如 sentence2 [SEP],所有的 token_type_ids 均为 1。
请注意,如果选择其他的 checkpoint,不一定具有 token_type_ids,比如,DistilBERT 模型就不会返回。只有当 tokenizer 在预训练期间使用过这一层,也就是模型在构建时需要它们时,才会返回 token_type_ids。
在这里,BERT 使用了带有 token_type_ids 的预训练 tokenizer,除了我们在第一章中讨论的掩码语言建模,还有一个额外的应用类型称为"下一句预测"。这个任务的目标是对句子对之间的关系进行建模。
在下一句预测任务中,会给模型输入成对的句子(带有随机遮罩的 token),并要求预测第二个句子是否紧跟第一个句子。为了使任务具有挑战性,提高模型的泛化能力,数据集中一有一半句子对中的句子在原始文档中顺序排列,另一半句子对中的两个句子来自两个不同的文档。
一般来说无需要担心在你的输入中是否需要有 token_type_ids。只要你使用相同的 checkpoint 的 Tokenizer 和模型,Tokenizer 就会知道向模型提供什么,一切都会顺利进行。
现在我们已经了解了 Tokenizer 如何处理一对句子,我们可以用它来处理整个数据集:就像在第二章中一样,我们可以给 Tokenizer 提供一对句子,第一个参数是它第一个句子的列表,第二个参数是第二个句子的列表。这也与我们在第二章中看到的填充和截断选项兼容。因此预处理训练数据集的一种方法是:
tokenized_dataset = tokenizer(
raw_datasets["train"]["sentence1"],
raw_datasets["train"]["sentence2"],
padding=True,
truncation=True,
)
这种方法虽然有效,但有一个缺点是它返回的是一个字典(字典的键是 input_ids、attention_mask 和 token_type_ids,字典的值是键所对应值的列表)。这意味着在转换过程中要有足够的内存来存储整个数据集才不会出错。不过来自 Datasets 库中的数据集是以 Apache Arrow 格式存储在磁盘上的,因此你只需将接下来要用的数据加载在内存中,而不是加载整个数据集,这对内存容量的需求比较友好。
我们将使用 Dataset.map() 方法将数据保存为 dataset 格式,如果我们需要做更多的预处理而不仅仅是 tokenization 它还支持了一些额外的自定义的方法。map() 方法的工作原理是使用一个函数处理数据集的每个元素。让我们定义一个对输入进行 tokenize 的函数:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
该函数接收一个字典(与 dataset 的项类似)并返回一个包含 input_ids、attention_mask 和 token_type_ids 键的新字典。请注意,如果 example 字典所对应的值包含多个句子(每个键作为一个句子列表),那么它依然可以运行,就像前面的例子一样,tokenizer 可以处理成对的句子列表,这样的话我们可以在调用 map() 时使用该选项 batched=True,这将显著加快处理的速度。tokenizer 来自 Tokenizers 库,由 Rust 编写而成。当一次给它很多输入时,这个 tokenizer 可以处理地非常快。
请注意,我们暂时在 tokenize_function 中省略了 padding 参数。这是因为将所有的样本填充到最大长度有些浪费。一个更好的做法是:在构建 batch 的时候填充。这样我们只需要填充到每个 batch 中的最大长度,而不是整个数据集的最大长度。当输入长度不稳定时,这可以节省大量时间和处理能力!
下面是我们如何使用一次性 tokenize_function 处理整个数据集。我们在调用 map 时使用了 batched=True,这样函数就可以同时处理数据集的多个元素,而不是分别处理每个元素,这样可以更快进行预处理。
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
tokenized_datasets
Datasets 库进行这种处理的方式是向数据集添加新的字段,每个字段对应预处理函数返回的字典中的每个键:
DatasetDict({
train: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label',
'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 3668
})
validation: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label',
'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 408
})
test: Dataset({
features: ['attention_mask', 'idx', 'input_ids', 'label',
'sentence1', 'sentence2', 'token_type_ids'],
num_rows: 1725
})
})
在使用预处理函数 map() 时,甚至可以通过传递 num_proc 参数并行处理。我们在这里没有这样做,因为在这个例子中 Tokenizers 库已经使用多线程来更快地对样本 tokenize,但是如果没有使用该库支持的快速 tokenizer,使用 num_proc 可能会加快预处理。
我们的 tokenize_function 返回包含 input_ids、attention_mask 和 token_type_ids 键的字典,这三个字段被添加到数据集的三个集合里(训练集、验证集和测试集)。请注意,如果预处理函数 map() 为现有键返回一个新值,我们可以通过使用 map() 函数返回的新值修改现有的字段。
我们最后需要做的是将所有示例填充到该 batch 中最长元素的长度,这种技术被称为动态填充。
动态填充
负责在批处理中将数据整理为一个 batch 的函数称为 collate 函数。这是一个可以在构建 DataLoader 时传递的一个参数,默认是一个将你的数据集转换为 PyTorch 张量并将它们拼接起来的函数(如果你的元素是列表、元组或字典,则会使用递归进行拼接)。这在本例子中下是不可行的,因为我们的输入的大小可能是不相同的。我们特意推迟了填充的时间,只在每个 batch 上进行必要的填充,以避免出现有大量填充的过长输入。这将大大加快训练速度,但请注意,如果你在 TPU 上训练,需要注意一个问题——TPU 喜欢固定的形状,即使这需要额外填充很多无用的 token。
为了解决句子长度不统一的问题,我们必须定义一个 collate 函数,该函数会将每个 batch 句子填充到正确的长度。幸运的是, Transformers 库通过 DataCollatorWithPadding 为我们提供了这样一个函数。当你实例化它时,它需要一个 tokenizer(用来知道使用哪种填充 token 以及模型期望在输入的左边填充还是右边填充),然后它会自动完成所有需要的操作:
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
为了测试这个新东西,让我们从我们的训练集中抽取几个样本,在这里,我们删除列 idx、sentence1 和 sentence2,因为不需要它们,而且删除包含字符串的列(我们不能用字符串创建张量),然后查看一个 batch 中每个条目的长度:
samples = tokenized_datasets["train"][:8]
samples = {k: v for k, v in samples.items() if k not in ["idx", "sentence1", "sentence2"]}
[len(x) for x in samples["input_ids"]]
输出结果:
[50, 59, 47, 67, 59, 50, 62, 32]
不出所料,我们得到了不同长度的样本,从 32 到 67。动态填充意味着这个 batch 都应该填充到长度为 67,这是这个 batch 中的最大长度。如果没有动态填充,所有的样本都必须填充到整个数据集中的最大长度,或者模型可以接受的最大长度。让我们再次检查 data_collator 是否正确地动态填充了这批样本:
batch = data_collator(samples)
{k: v.shape for k, v in batch.items()}
输出结果:
{
"attention_mask": torch.Size([8, 67]),
"input_ids": torch.Size([8, 67]),
"token_type_ids": torch.Size([8, 67]),
"labels": torch.Size([8]),
}
看起来不错!现在,我们已经从原始文本转化为了模型可以处理的数据,我们准备好对其进行微调。
3.2 使用 Trainer API 微调模型
Transformers 提供了一个 Trainer 类,可以帮助你在数据集上微调任何预训练模型。在上一节中完成所有数据预处理工作后,你只需完成几个步骤来定义 Trainer。最困难的部分可能是准备运行 Trainer.train() 所需的环境,因为在 CPU 上运行速度非常慢。如果你没有设置 GPU,可以使用 Google Colab 上获得免费的 GPU 或 TPU。
下面的示例假设你已经执行了上一节中的示例。下面是在开始学习这一节之前你需要运行的代码:
from datasets import load_dataset
from transformers import AutoTokenizer, DataCollatorWithPadding
raw_datasets = load_dataset("glue", "mrpc")
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
Training
在我们定义 Trainer 之前第一步要定义一个 TrainingArguments 类,它包含 Trainer 在训练和评估中使用的所有超参数。你只需要提供的参数是一个用于保存训练后的模型以及训练过程中的 checkpoint 的目录。对于其余的参数你可以保留默认值,这对于简单的微调应该效果就很好了。
from transformers import TrainingArguments
training_args = TrainingArguments("test-trainer")
💡 提示: 如果你想在训练期间自动将模型上传到 Hub,请将 push_to_hub=True 添加到 TrainingArguments 之中。我们将在第四章中详细介绍这部分。
第二步是定义我们的模型。与前一章一样,我们将使用 AutoModelForSequenceClassification 类,它有两个参数:
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
你会注意到,和第二章不同的是,在实例化此预训练模型后会收到警告。这是因为 BERT 没有在句子对分类方面进行过预训练,所以预训练模型的 head 已经被丢弃,而是添加了一个适合句子序列分类的新头部。这些警告表明一些权重没有使用(对应于被放弃的预训练头的权重),而有些权重被随机初始化(对应于新 head 的权重)。
一旦有了我们的模型,我们就可以定义一个 Trainer 把到目前为止构建的所有对象——model、training_args、训练和验证数据集、data_collator 和 tokenizer 传递给 Trainer:
from transformers import Trainer
trainer = Trainer(
model,
training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
)
请注意,当你在这里传递 tokenizer 时,Trainer 默认使用的 data_collator 是之前预定义的 DataCollatorWithPadding。所以你可以在本例中可以跳过 data_collator=data_collator 一行。在第 2 节中向你展示这部分处理过程仍然很重要!
要在我们的数据集上微调模型,我们只需调用 Trainer 的 train() 方法:
trainer.train()
开始微调(在 GPU 上应该需要几分钟),每 500 步报告一次训练损失。然而它不会告诉你模型的性能(或质量)如何。这是因为:
- 我们没有告诉 Trainer 在训练过程中进行评估,比如将 evaluation_strategy 设置为 "steps"(在每个 eval_steps 步骤评估一次)或 "epoch"(在每个 epoch 结束时评估)。
- 我们没有为 Trainer 提供一个 compute_metrics() 函数来计算上述评估过程的指标(否则评估将只会输出 loss,但这不是一个非常直观的数字)。
评估
让我们看看如何构建一个有用的 compute_metrics() 函数,并在下次训练时使用它。该函数必须接收一个 EvalPrediction 对象(它是一个带有 predictions 和 label_ids 字段的参数元组),并将返回一个字符串映射到浮点数的字典(字符串是返回的指标名称,而浮点数是其值)。为了从我们的模型中获得预测结果,可以使用 Trainer.predict() 命令:
predictions = trainer.predict(tokenized_datasets["validation"])
print(predictions.predictions.shape, predictions.label_ids.shape)
输出结果:
(408, 2) (408,)
predict() 方法的输出另一个带有三个字段的命名元组:predictions、label_ids 和 metrics。metrics 字段将只包含所传递的数据集的损失,以及一些时间指标(总共花费的时间和平均预测时间)。当我们定义了自己的 compute_metrics() 函数并将其传递给 Trainer,该字段还将包含 compute_metrics() 返回的结果。predictions 是一个二维数组,形状为 408 × 2(408 是我们使用的数据集中的元素数量),这是我们传递给 predict() 的数据集中每个元素的 logits(正如在前一章中看到的,所有 Transformer 模型都返回 logits)。为了将它们转化为可以与我们的标签进行比较的预测值,我们需要找出第二个维度上取值最大的索引:
import numpy as np
preds = np.argmax(predictions.predictions, axis=-1)
我们现在可以将这些 preds 与标签进行比较。为了构建我们的 compute_metric() 函数,我们将使用 Evaluate 库中的指标。我们可以像加载数据集一样轻松地加载与 MRPC 数据集关联的指标,这次是使用 evaluate.load() 函数。返回的对象有一个 compute() 方法,我们可以用它来进行指标的计算:
import evaluate
metric = evaluate.load("glue", "mrpc")
metric.compute(predictions=preds, references=predictions.label_ids)
输出结果:
{'accuracy': 0.8578431372549019, 'f1': 0.8996539792387542}
你得到的确切结果可能会有所不同,因为模型头部的随机初始化可能会改变其指标。在这里,我们可以看到我们的模型在验证集上的准确率为 85.78%,F1 分数为 89.97。这是用于评估 MRPC 数据集在 GLUE 基准测试中的结果的两个指标。在 BERT 论文中的表格中,基础模型的 F1 分数为 88.9。那是 uncased 模型,而我们现在正在使用 cased 模型,这解释了为什么我们得到了更好的结果。
最后把所有东西打包在一起,我们就得到了 compute_metrics() 函数:
def compute_metrics(eval_preds):
metric = evaluate.load("glue", "mrpc")
logits, labels = eval_preds
predictions = np.argmax(logits, axis=-1)
return metric.compute(predictions=predictions, references=labels)
为了查看模型在每个训练周期结束时的好坏,下面是我们如何使用 compute_metrics() 函数定义一个新的 Trainer:
training_args = TrainingArguments("test-trainer", evaluation_strategy="epoch")
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
trainer = Trainer(
model,
training_args,
train_dataset=tokenized_datasets["train"],
eval_dataset=tokenized_datasets["validation"],
data_collator=data_collator,
tokenizer=tokenizer,
compute_metrics=compute_metrics,
)
请注意,我们设置了一个新的 TrainingArguments,其 evaluation_strategy 设置为 "epoch" 并且创建了一个新模型。如果不创建新的模型就直接训练,就只会继续训练我们已经训练过的模型。为了启动新的训练,我们执行:
trainer.train()
这一次,它将在每个 epoch 结束时在训练损失的基础上报告验证损失和指标。同样,由于模型的随机头部初始化,达到的准确率/F1 分数可能与我们发现的略有不同,这是由于模型头部的随机初始化造成的,但应该相差不多。
Trainer 可以在多个 GPU 或 TPU 上运行,并提供许多选项,例如混合精度训练(在训练的参数中使用 fp16=True)。我们将在第十章讨论它支持的所有内容。
使用 Trainer API 微调的介绍到此结束。在第七章中会给出一个对大多数常见的 NLP 任务进行训练的例子,但现在让我们看看如何在 PyTorch 中做相同的操作。
✏️ 试试看! 使用你在第 2 节中学到的数据处理过程,在 GLUE SST-2 数据集上对模型进行微调。
3.3 一个完整的训练
现在,我们将了解如何在不使用 Trainer 类的情况下实现与上一节相同的结果。同样,我们假设你已经完成了第 2 节中的数据处理。下面对第 2 节内容的一个简短总结,涵盖了你需要在本节之前运行的所有内容:
from datasets import load_dataset
from transformers import AutoTokenizer, DataCollatorWithPadding
raw_datasets = load_dataset("glue", "mrpc")
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
tokenized_datasets = raw_datasets.map(tokenize_function, batched=True)
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
训练前的准备
在正式开始编写我们的训练循环之前,我们需要定义一些对象。首先是我们将用于迭代 batch 的数据加载器。但在定义这些数据加载器之前,我们需要对我们的 tokenized_datasets 进行一些后处理,以自己实现一些 Trainer 自动为我们处理的内容。具体来说,我们需要:
- 删除与模型不需要的列(如 sentence1 和 sentence2 列)
- 将列名 label 重命名为 labels(因为模型默认的输入是 labels)
- 设置数据集的格式,使其返回 PyTorch 张量而不是列表
针对上面的每个步骤,我们的 tokenized_datasets 都有一个方法:
tokenized_datasets = tokenized_datasets.remove_columns(["sentence1", "sentence2", "idx"])
tokenized_datasets = tokenized_datasets.rename_column("label", "labels")
tokenized_datasets.set_format("torch")
tokenized_datasets["train"].column_names
然后,我们可以检查结果中是否只有模型能够接受的列:
["attention_mask", "input_ids", "labels", "token_type_ids"]
至此,我们可以轻松定义数据加载器:
from torch.utils.data import DataLoader
train_dataloader = DataLoader(
tokenized_datasets["train"], shuffle=True, batch_size=8, collate_fn=data_collator
)
eval_dataloader = DataLoader(
tokenized_datasets["validation"], batch_size=8, collate_fn=data_collator
)
为了快速检验数据处理中没有错误,我们可以这样检验其中的一个 batch:
for batch in train_dataloader:
break
{k: v.shape for k, v in batch.items()}
输出结果:
{
'attention_mask': torch.Size([8, 65]),
'input_ids': torch.Size([8, 65]),
'labels': torch.Size([8]),
'token_type_ids': torch.Size([8, 65])
}
请注意,这里的形状可能与你略有不同,因为我们为训练数据加载器设置了 shuffle=True,并且模型会将句子填充到 batch 中的最大长度。
现在我们已经完全完成了数据预处理(对于任何 ML 从业者来说都是一个令人满意但难以实现的目标),让我们将注意力转向模型。我们会像在上一节中所做的那样实例化它:
from transformers import AutoModelForSequenceClassification
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
为了确保训练过程中一切顺利,我们将 batch 传递给这个模型:
outputs = model(**batch)
print(outputs.loss, outputs.logits.shape)
输出结果:
tensor(0.5441, grad_fn=<NllLossBackward>) torch.Size([8, 2])
当我们输入 labels 时, Transformers 模型都将返回这个 batch 的 loss,我们还得到了 logits(batch 中的每个输入有两个输出,所以张量大小为 8 x 2)。
我们几乎准备好编写我们的训练循环了!我们只是缺少两件事:优化器和学习率调度器。由于我们试图手动实现 Trainer 的功能,我们将使用相同的优化器和学习率调度器。Trainer 使用的优化器是 AdamW,它与 Adam 相同,但加入了权重衰减正则化的一点变化(参见 Ilya Loshchilov 和 Frank Hutter 的 "Decoupled Weight Decay Regularization"):
from torch.optim import AdamW
optimizer = AdamW(model.parameters(), lr=5e-5)
最后,默认使用的学习率调度器只是从最大值(5e-5)到 0 的线性衰减。为了定义它,我们需要知道我们训练的次数,即所有数据训练的次数(epochs)乘以的 batch 的数量(即我们训练数据加载器的长度)。Trainer 默认情况下使用三个 epochs,因此我们定义训练过程如下:
from transformers import get_scheduler
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
print(num_training_steps)
输出结果:
1377
训练循环
最后一件事:如果我们可以访问 GPU,我们将希望使用 GPU(在 CPU 上,训练可能需要几个小时而不是几分钟)。为此,我们定义了一个 device,它在 GPU 可用的情况下指向 GPU,最后我们将把我们的模型和 batch 放在 device 上:
import torch
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
model.to(device)
device
输出结果:
device(type='cuda')
我们现在准备好训练了!为了知道训练何时结束,我们使用 tqdm 库,在训练步骤数上添加了一个进度条:
from tqdm.auto import tqdm
progress_bar = tqdm(range(num_training_steps))
model.train()
for epoch in range(num_epochs):
for batch in train_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
你可以看到训练循环的核心与介绍中的非常相似。我们没有要求在训练的过程中进行检验,所以这个训练循环不会告诉我们任何关于模型目前的状态。我们需要为此添加一个评估循环。
评估循环
正如我们之前所做的那样,我们将使用 Evaluate 库提供的指标。我们已经了解了 metric.compute() 方法,当我们使用 add_batch() 方法进行预测循环时,实际上该指标可以为我们累积所有 batch 的结果。一旦我们累积了所有 batch,我们就可以使用 metric.compute() 评估得到的结果。以下是如何在评估循环中实现所有这些的方法:
import evaluate
metric = evaluate.load("glue", "mrpc")
model.eval()
for batch in eval_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
with torch.no_grad():
outputs = model(**batch)
logits = outputs.logits
predictions = torch.argmax(logits, dim=-1)
metric.add_batch(predictions=predictions, references=batch["labels"])
metric.compute()
输出结果:
{'accuracy': 0.8431372549019608, 'f1': 0.8907849829351535}
同样,由于模型头部初始化和数据打乱的随机性,你的结果会略有不同,但应该相差不多。
✏️ 试试看! 修改之前的训练循环以在 SST-2 数据集上微调你的模型。
使用 Accelerate 加速你的训练循环
我们之前定义的训练循环在单个 CPU 或 GPU 上运行良好。通过使用 Accelerate 库,只需进行一些调整,我们就可以在多个 GPU 或 TPU 上启用分布式训练。从创建训练和验证数据加载器开始,我们的手动训练循环如下所示:
from torch.optim import AdamW
from transformers import AutoModelForSequenceClassification, get_scheduler
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
optimizer = AdamW(model.parameters(), lr=3e-5)
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
model.to(device)
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
progress_bar = tqdm(range(num_training_steps))
model.train()
for epoch in range(num_epochs):
for batch in train_dataloader:
batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
loss.backward()
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
以下是更改的部分:
+ from accelerate import Accelerator
from transformers import AutoModelForSequenceClassification, get_scheduler
+ accelerator = Accelerator()
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
optimizer = AdamW(model.parameters(), lr=3e-5)
- device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
- model.to(device)
+ train_dataloader, eval_dataloader, model, optimizer = accelerator.prepare(
+ train_dataloader, eval_dataloader, model, optimizer
+ )
num_epochs = 3
num_training_steps = num_epochs * len(train_dataloader)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps
)
progress_bar = tqdm(range(num_training_steps))
model.train()
for epoch in range(num_epochs):
for batch in train_dataloader:
- batch = {k: v.to(device) for k, v in batch.items()}
outputs = model(**batch)
loss = outputs.loss
- loss.backward()
+ accelerator.backward(loss)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
要添加的第一行是导入 Accelerator。第二行实例化一个 Accelerator 对象,它将查看环境并初始化适当的分布式设置。 Accelerate 为你处理数据在设备间的数据传递,因此你可以删除将模型放在设备上的那行代码(或者,如果你愿意,可使用 accelerator.device 代替 device)。
然后大部分工作会在将数据加载器、模型和优化器发送到的 accelerator.prepare() 中完成。这将会把这些对象包装在适当的容器中,以确保你的分布式训练按预期工作。要进行的其余更改是删除将 batch 放在 device 的那行代码(同样,如果你想保留它,你可以将其更改为使用 accelerator.device)并将 loss.backward() 替换为 accelerator.backward(loss)。
⚠️ 提示: 为了使云端 TPU 提供的加速中发挥最大的效益,我们建议使用 tokenizer 的 padding=max_length 和 max_length 参数将你的样本填充到固定长度。
如果你想复制并粘贴来直接运行,以下是 Accelerate 的完整训练循环:
from accelerate import Accelerator
from torch.optim import AdamW
from transformers import AutoModelForSequenceClassification, get_scheduler
accelerator = Accelerator()
model = AutoModelForSequenceClassification.from_pretrained(checkpoint, num_labels=2)
optimizer = AdamW(model.parameters(), lr=3e-5)
train_dl, eval_dl, model, optimizer = accelerator.prepare(
train_dataloader, eval_dataloader, model, optimizer
)
num_epochs = 3
num_training_steps = num_epochs * len(train_dl)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
progress_bar = tqdm(range(num_training_steps))
model.train()
for epoch in range(num_epochs):
for batch in train_dl:
outputs = model(**batch)
loss = outputs.loss
accelerator.backward(loss)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
把这个放在 train.py 文件中,可以让它在任何类型的分布式设置上运行。要在分布式设置中试用它,请运行以下命令:
accelerate config
这将询问你几个配置的问题并将你的回答保存到此命令使用的配置文件中:
accelerate launch train.py
这将启动分布式训练。如果你想在 Notebook 中尝试此操作(例如,在 Colab 上使用 TPU 进行测试),只需将代码粘贴到一个 training_function() 函数中,并在最后一个单元格中运行:
from accelerate import notebook_launcher
notebook_launcher(training_function)
你可以在 Accelerate repo 找到更多的示例。
3.4 微调,检查!
这是非常令人高兴的!在前两章中,你了解了模型和 Tokenizer,现在你知道如何针对你自己的数据对它们进行微调。回顾一下,在本章中,你:
- 了解了 Hub 中的数据集
- 学习了如何加载和预处理数据集,包括使用动态填充和数据整理器
- 实现你自己的模型微调和评估
- 实现了一个较为底层的训练循环
- 使用 Accelerate 轻松调整你的训练循环,使其适用于多个 GPU 或 TPU
3.5 章末小测验
问题 1
"emotion" 数据集包含带有情绪标记的 Twitter 消息。请在 Hub 中进行搜索并读取数据集的数据卡片。判断哪一个基本情感不在这个数据集中?
- ❌ Joy(欢乐)
- ❌ Love(爱)
- ✅ Confusion(困惑)
- ❌ Surprise(惊喜)
问题 2
在 Hub 中搜索 ar_sarcasm 数据集,该数据集支持哪个任务?
- ✅ 情绪分类
- ❌ 机器翻译
- ❌ 命名实体识别
- ❌ 回答问题
问题 3
当输入一对句子时 BERT 模型会需要进行怎么样的预处理?
- ❌ 句子 1 的 token 序列 [SEP] 句子 2 的 token 序列
- ❌ [CLS] 句子 1 的 token 序列 句子 2 的 token 序列
- ✅ [CLS] 句子 1 的 token 序列 [SEP] 句子 2 的 token 序列 [SEP]
- ❌ [CLS] 句子 1 的 token 序列 [SEP] 句子 2 的 token 序列
问题 4
Dataset.map() 方法的好处是什么?
- ✅ 该函数执行后的结果被缓存,重新执行代码时不会花费多余时间
- ✅ 它可以进行并行化处理,比在数据集的每个元素上依次使用函数进行处理更快
- ✅ 它不会将整个数据集加载到内存中,而是在处理一个元素后立即保存结果
问题 5
什么是动态填充?
- ❌ 就是将每个批处理的输入填充到整个数据集中的最大长度
- ✅ 这是当你在创建 batch 时将输入填充到该 batch 内句子的最大长度
- ❌ 当你将每个句子填充到与数据集中的前一个句子相同数量的 token 时
问题 6
collate 函数的用途是什么?
- ❌ 它确保数据集中的所有序列具有相同的长度
- ✅ 它把所有的样本地放在一个 batch 里
- ❌ 它预处理整个数据集
- ❌ 它截断数据集中的序列
问题 7
当你用一个预先训练过的语言模型(例如 bert-base-uncased)实例化一个 AutoModelForXxx 类,这个类与它所被训练的任务不匹配时会发生什么?
- ❌ 什么都没有,但会出现一个警告
- ✅ 丢弃预训练模型的头部,取而代之的是一个适合该任务的新头部
- ❌ 丢弃预先训练好的模型头部
- ❌ 没有,因为模型仍然可以针对不同的任务进行微调
问题 8
TrainingArguments 的用途是什么?
- ✅ 它包含了所有用于训练和评估的超参数
- ❌ 它指定模型的大小
- ❌ 它只包含用于评估的超参数
问题 9
为什么要使用 Accelerate 库?
- ❌ 它可以对更快地访问的模型
- ❌ 它提供了一个高级 API,因此我不必实现自己的训练循环
- ✅ 它使我们的训练循环运行在分布式架构上
- ❌ 它提供了更多的优化功能