处理数据
继续使用来自 上一章 的示例,以下是我们在 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 不仅包含模型;它还包含许多不同语言的多个数据集。你可以 在这里 浏览数据集,我们建议你在完成本节后尝试加载和处理一个新数据集(请参阅 此处 的一般文档)。但现在,让我们关注 MRPC 数据集!这是构成 GLUE 基准 的 10 个数据集之一,它是一个学术基准,用于衡量 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]
对应的输入部分的令牌类型 ID 均为 0
,而与 sentence2 [SEP]
对应的其他部分的令牌类型 ID 均为 1
。
请注意,如果你选择不同的检查点,你的分词输入中不一定会有 token_type_ids
(例如,如果你使用 DistilBERT 模型,它们不会被返回)。它们仅在模型知道如何处理它们时才会被返回,因为它在预训练期间见过它们。
在这里,BERT 使用令牌类型 ID 进行预训练,除了我们在 第一章 中讨论的掩码语言建模目标外,它还有另一个称为“下一句话预测”的目标。这项任务的目标是模拟句子对之间的关系。
在下一句话预测中,模型会获得句子对(包含随机掩码令牌),并被要求预测第二句话是否紧跟第一句话。为了使任务不那么容易,一半情况下句子在它们被提取的原始文档中彼此相邻,另一半情况下两个句子来自两个不同的文档。
通常情况下,您无需担心您的标记化输入中是否包含token_type_ids
:只要您使用相同的检查点进行标记化和建模,一切都会正常,因为标记器知道如何为其模型提供什么。
现在我们已经看到了标记器如何处理一对句子,我们可以用它来标记化整个数据集:就像在上一章中一样,我们可以通过提供第一个句子的列表,然后提供第二个句子的列表,将标记器提供给一对句子的列表。这与我们在第 2 章中看到的填充和截断选项兼容。因此,预处理训练数据集的一种方法是
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()
方法。如果我们需要进行比标记化更多的预处理,这也会给我们额外的灵活性。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
})
})
您甚至可以通过传递num_proc
参数,在使用map()
应用预处理函数时使用多进程。我们在这里没有这样做,因为🤗 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 任务的预处理函数。