LLM 课程文档

数据处理

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

数据处理

Ask a Question Open In Colab Open In Studio Lab

接着上一章的例子,下面是我们如何在一个批次上训练序列分类器:

import torch
from torch.optim import AdamW
from transformers import 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 基准是用于衡量机器学习模型在 10 个不同文本分类任务上性能的学术基准。

🤗 Datasets 库提供了一个非常简单的命令来从 Hub 下载和缓存数据集。我们可以像这样下载 MRPC 数据集:

💡 更多资源:有关更多数据集加载技术和示例,请查阅 🤗 Datasets 文档

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"])

💡 深入探讨:有关更高级的分词技术以及理解不同分词器如何工作,请探索 🤗 Tokenizers 文档操作手册中的分词指南

然而,我们不能仅仅将两个序列传递给模型,然后预测这两个句子是否是释义。我们需要将这两个序列作为一对来处理,并应用适当的预处理。幸运的是,分词器也可以接收一对序列,并按照我们的 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]` 的部分都具有 `0` 的标记类型 ID,而其他部分(对应于 `sentence2 [SEP]`)都具有 `1` 的标记类型 ID。

请注意,如果你选择不同的检查点,你的分词输入中不一定会包含 `token_type_ids`(例如,如果你使用 DistilBERT 模型,它们就不会返回)。它们仅在模型知道如何处理它们时才返回,因为模型在预训练期间见过它们。

在这里,BERT 是用 token type 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` 参数。这是因为将所有样本填充到最大长度效率不高:最好在构建批次时填充样本,因为这样我们只需要填充到该批次中的最大长度,而不是整个数据集中的最大长度。当输入长度变化很大时,这可以节省大量时间和处理能力!

📚 性能提示:在 🤗 Datasets 性能指南中了解更多高效数据处理技术。

下面是我们如何一次性将分词函数应用于所有数据集。我们在 `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 更喜欢固定的形状,即使这需要额外的填充。

🚀 优化指南:有关优化训练性能的更多详细信息,包括填充策略和 TPU 考虑事项,请参阅 🤗 Transformers 性能文档

为了实际操作,我们必须定义一个整理函数,它将为我们想要批量处理的数据集项应用正确的填充量。幸运的是,🤗 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 任务的预处理函数。

📖 更多练习:查看 🤗 Transformers 示例中的这些动手示例。

太棒了!现在我们已经使用 🤗 Datasets 库的最新最佳实践对数据进行了预处理,我们已准备好使用现代 Trainer API 训练我们的模型。下一节将向你展示如何利用 Hugging Face 生态系统中可用的最新功能和优化来有效地微调模型。

本节测验

测试你对数据处理概念的理解

1. 使用 Dataset.map() 并设置 batched=True 的主要优点是什么?

2. 为什么我们使用动态填充,而不是将所有序列填充到数据集中的最大长度?

3. 在 BERT 分词中,token_type_ids 字段代表什么?

4. 当使用 load_dataset('glue', 'mrpc') 加载数据集时,第二个参数指定了什么?

5. 在训练前移除“sentence1”和“sentence2”等列的目的是什么?

💡 要点:

  • 使用 `Dataset.map()` 并设置 `batched=True` 以显著加快预处理速度
  • 使用 `DataCollatorWithPadding` 进行动态填充比固定长度填充更高效
  • 始终对数据进行预处理,以使其符合模型预期(数值张量,正确的列名)
  • 🤗 Datasets 库提供了强大的工具,可实现高效的大规模数据处理
< > 在 GitHub 上更新