处理数据
继续使用 上一章 中的示例,以下是如何在 PyTorch 中对一个批次训练序列分类器
import torch
from transformers import AdamW, AutoTokenizer, AutoModelForSequenceClassification
# Same as before
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")
# This is new
batch["labels"] = torch.tensor([1, 1])
optimizer = AdamW(model.parameters())
loss = model(**batch).loss
loss.backward()
optimizer.step()
当然,仅仅在两个句子上训练模型不会产生很好的结果。为了获得更好的结果,你需要准备一个更大的数据集。
在本节中,我们将使用 MRPC(微软研究院释义语料库)数据集作为示例,该数据集在 William B. Dolan 和 Chris Brockett 的 论文 中被介绍。该数据集包含 5,801 对句子,并带有指示它们是否为释义的标签(即,两个句子是否意思相同)。我们选择它作为本章的示例,因为它是一个小型数据集,因此易于进行训练实验。
从 Hub 加载数据集
Hub 不仅包含模型;它还包含多种语言的多个数据集。你可以在这里 浏览数据集,我们建议你在完成本节后尝试加载和处理一个新数据集(请参阅此处提供的常规文档 here)。但现在,让我们专注于 MRPC 数据集!这是构成 GLUE 基准 的 10 个数据集之一,GLUE 基准是一个学术基准,用于衡量 ML 模型在 10 种不同文本分类任务上的性能。
🤗 数据集库提供了一个非常简单的命令来下载和缓存 Hub 上的数据集。我们可以像这样下载 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
对象,其中包含训练集、验证集和测试集。这些集合中的每一个都包含多个列(sentence1
、sentence2
、label
和 idx
)和可变数量的行,这些行是每个集合中元素的数量(因此,训练集中有 3,668 对句子,验证集中有 408 对句子,测试集中有 1,725 对句子)。
此命令会下载并缓存数据集,默认情况下位于 ~/.cache/huggingface/datasets 中。回忆第二章,你可以通过设置 HF_HOME
环境变量来定制缓存文件夹。
我们可以通过索引访问 raw_datasets
对象中的每对句子,就像使用字典一样
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
,整数到标签名称的映射存储在 names 文件夹中。0
对应于 not_equivalent
,1
对应于 equivalent
。
✏️ 试一试! 查看训练集的第 15 个元素和验证集的第 87 个元素。它们的标签是什么?
预处理数据集
为了预处理数据集,我们需要将文本转换为模型可以理解的数字。正如你在 上一章 中所看到的,这是通过分词器完成的。我们可以将一个句子或一个句子列表馈送到分词器中,因此我们可以直接将所有第一句话和所有第二句话进行分词,如下所示
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"])
但是,我们不能仅仅将两个序列传递给模型,然后得到关于这两个句子是否为释义的预测。我们需要将两个序列作为一对进行处理,并应用适当的预处理。幸运的是,分词器也可以接受一对序列并以 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
的讨论。在本例中,这就是告诉模型输入的哪一部分是第一句话,哪一部分是第二句话。
✏️ 试一试! 取训练集的第 15 个元素,分别对两个句子进行分词,并作为一对进行分词。两种结果有什么区别?
如果我们将 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
对齐,我们将得到
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
[ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]
如你所见,与 [CLS] sentence1 [SEP]
对应的输入部分都具有 0
的令牌类型 ID,而其他部分,与 sentence2 [SEP]
对应的部分,都具有 1
的令牌类型 ID。
请注意,如果你选择不同的检查点,你的分词输入中可能不会包含 token_type_ids
(例如,如果你使用 DistilBERT 模型,它们不会被返回)。它们只会在模型知道如何处理它们时被返回,因为模型在预训练期间看到了它们。
在这里,BERT 使用令牌类型 ID 进行预训练,除了我们在 第一章 中讨论的掩码语言建模目标之外,它还有一个称为 下一句预测 的额外目标。此任务的目标是对句子对之间的关系进行建模。
使用下一句预测,模型会得到句子对(包含随机掩码令牌),并被要求预测第二个句子是否紧随第一个句子。为了使任务不那么简单,一半情况下句子按它们在提取它们的原始文档中的顺序排列,而另一半情况下,两个句子来自两个不同的文档。
通常,你无需担心你的分词输入中是否包含 token_type_ids
:只要你对分词器和模型使用相同的检查点,一切都将正常,因为分词器知道要提供给模型的内容。
现在我们已经了解了我们的分词器如何处理一对句子,我们可以用它来对整个数据集进行分词:就像在 上一章 中一样,我们可以通过向分词器提供第一个句子的列表,然后提供第二个句子的列表,来为分词器提供一个句子对列表。这与我们在 第二章 中看到的填充和截断选项也兼容。因此,预处理训练数据集的一种方法是
tokenized_dataset = tokenizer(
raw_datasets["train"]["sentence1"],
raw_datasets["train"]["sentence2"],
padding=True,
truncation=True,
)
这很有效,但它有一个缺点,就是返回一个字典(包含我们的键,input_ids
、attention_mask
和 token_type_ids
,以及值为列表的列表)。它也只在你有足够的 RAM 在分词期间存储整个数据集时才能工作(而来自 🤗 Datasets 库的数据集是 Apache Arrow 文件存储在磁盘上,所以你只保留你要求加载到内存中的样本)。
为了将数据保持为数据集,我们将使用 Dataset.map()
方法。如果我们需要进行更多比仅仅分词的预处理,这也能给我们一些额外的灵活性。map()
方法的工作方式是将一个函数应用到数据集的每个元素上,所以让我们定义一个对输入进行分词的函数
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
此函数接受一个字典(类似于我们数据集中的项),并返回一个包含 input_ids
、attention_mask
和 token_type_ids
键的新字典。请注意,如果 example
字典包含多个样本(每个键作为句子列表),它也能正常工作,因为 tokenizer
在句子对列表上工作,正如之前看到的。这将允许我们在调用 map()
时使用 batched=True
选项,这将大大加快分词速度。tokenizer
背后是由 🤗 Tokenizers 库中的 Rust 编写的分词器。这个分词器可以非常快,但前提是我们必须一次给它大量的输入。
请注意,我们现在已经将分词函数中的 padding
参数省略了。这是因为将所有样本填充到最大长度不是高效的:最好在构建批次时填充样本,因为这样我们只需要填充到该批次中的最大长度,而不是整个数据集中的最大长度。当输入的长度变化很大时,这可以节省大量的处理时间和能力!
以下是我们在所有数据集上同时应用分词函数的方式。我们在调用 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_function
返回一个包含 input_ids
、attention_mask
和 token_type_ids
键的字典,因此这些三个字段被添加到我们数据集的所有拆分中。请注意,如果我们的预处理函数返回了我们应用 map()
的数据集中的现有键的新值,我们也可以更改现有字段。
最后,我们需要做的是在将元素一起批处理时,将所有示例填充到最长元素的长度 - 我们称之为 *动态填充* 的技术。
动态填充
负责将样本组合到批次中的函数称为 *合并函数*。它是你在构建 DataLoader
时可以传入的参数,默认函数只会将你的样本转换为 PyTorch 张量并连接它们(如果你的元素是列表、元组或字典,则递归地进行)。在我们这种情况下这是不可能的,因为我们拥有的输入不会都具有相同的尺寸。我们故意推迟了填充,以只在每个批次上根据需要应用它,避免拥有带有大量填充的过长输入。这将大大加快训练速度,但请注意,如果你在 TPU 上进行训练,这会导致问题 - TPU 偏好固定形状,即使这需要额外的填充。
为了在实践中做到这一点,我们必须定义一个合并函数,它将对我们要批处理在一起的数据集中的项应用正确的填充量。幸运的是,🤗 Transformers 库通过 DataCollatorWithPadding
为我们提供了这样的函数。它在实例化时接收一个分词器(以便知道使用哪个填充标记,以及模型是否期望填充在输入的左侧或右侧),并且会完成你需要的所有操作
from transformers import DataCollatorWithPadding
data_collator = DataCollatorWithPadding(tokenizer=tokenizer)
为了测试这个新玩具,让我们从我们的训练集中获取一些我们想要一起批处理的样本。这里,我们删除了 idx
、sentence1
和 sentence2
列,因为它们将不再需要,并且包含字符串(我们无法使用字符串创建张量),并查看批次中每个条目的大小
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。动态填充意味着这个批次中的样本都应该填充到 67 的长度,即批次中的最大长度。如果没有动态填充,所有样本都必须填充到整个数据集中的最大长度,或者模型可以接受的最大长度。让我们仔细检查一下我们的 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])}
看起来不错!现在我们已经从原始文本转换成了模型可以处理的批次,我们就可以微调它了!
✏️ 试一试! 在 GLUE SST-2 数据集上复制预处理。它有点不同,因为它由单个句子组成而不是句子对,但我们做的其他事情应该看起来一样。对于更难的挑战,尝试编写一个可以在任何 GLUE 任务上工作的预处理函数。