LLM 课程文档
处理数据
并获得增强的文档体验
开始使用
处理数据
继续上一章的示例,以下是如何在 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(Microsoft Research Paraphrase Corpus,微软研究院释义语料库)数据集作为示例,该数据集在 William B. Dolan 和 Chris Brockett 的一篇论文中介绍。该数据集包含 5,801 对句子,并带有一个标签,指示它们是否是释义(即,两个句子是否含义相同)。我们选择它作为本章的示例是因为它是一个小型数据集,因此很容易在其上进行训练实验。
从 Hub 加载数据集
Hub 不仅包含模型;它还包含多种不同语言的多个数据集。您可以在此处浏览数据集,我们建议您在完成本节后尝试加载和处理新的数据集(请参阅此处的通用文档)。但现在,让我们专注于 MRPC 数据集!这是组成 GLUE 基准测试的 10 个数据集之一,GLUE 基准测试是一个学术基准,用于衡量 ML 模型在 10 个不同的文本分类任务中的性能。
🤗 Datasets 库提供了一个非常简单的命令来下载和缓存 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 中。回想一下第 2 章,您可以通过设置 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]
}
我们在第 2 章中讨论了 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 进行预训练,并且除了我们在第 1 章中讨论的掩码语言建模目标之外,它还有一个称为下一句预测的附加目标。此任务的目标是建模句子对之间的关系。
通过下一句预测,模型获得句子对(带有随机掩码的标记),并被要求预测第二个句子是否跟随第一个句子。为了使任务具有非平凡性,句子一半时间来自它们提取的原始文档中的相互跟随,另一半时间两个句子来自两个不同的文档。
一般来说,您无需担心分词输入中是否包含 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
,以及作为列表列表的值)。它也仅在您有足够的 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 任务的预处理函数。