NLP 课程文档

处理数据

Hugging Face's logo
加入 Hugging Face 社区

并获得增强型文档体验

开始

处理数据

Ask a Question Open In Colab Open In Studio Lab

继续使用 上一章 中的示例,以下是如何在 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 对象,其中包含训练集、验证集和测试集。这些集合中的每一个都包含多个列(sentence1sentence2labelidx)和可变数量的行,这些行是每个集合中元素的数量(因此,训练集中有 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_datasetfeatures。这将告诉我们每列的类型

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_equivalent1 对应于 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_idsattention_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_idsattention_masktoken_type_ids,以及值为列表的列表)。它也只在你有足够的 RAM 在分词期间存储整个数据集时才能工作(而来自 🤗 Datasets 库的数据集是 Apache Arrow 文件存储在磁盘上,所以你只保留你要求加载到内存中的样本)。

为了将数据保持为数据集,我们将使用 Dataset.map() 方法。如果我们需要进行更多比仅仅分词的预处理,这也能给我们一些额外的灵活性。map() 方法的工作方式是将一个函数应用到数据集的每个元素上,所以让我们定义一个对输入进行分词的函数

def tokenize_function(example):
    return tokenizer(example["sentence1"], example["sentence2"], truncation=True)

此函数接受一个字典(类似于我们数据集中的项),并返回一个包含 input_idsattention_masktoken_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_idsattention_masktoken_type_ids 键的字典,因此这些三个字段被添加到我们数据集的所有拆分中。请注意,如果我们的预处理函数返回了我们应用 map() 的数据集中的现有键的新值,我们也可以更改现有字段。

最后,我们需要做的是在将元素一起批处理时,将所有示例填充到最长元素的长度 - 我们称之为 *动态填充* 的技术。

动态填充

负责将样本组合到批次中的函数称为 *合并函数*。它是你在构建 DataLoader 时可以传入的参数,默认函数只会将你的样本转换为 PyTorch 张量并连接它们(如果你的元素是列表、元组或字典,则递归地进行)。在我们这种情况下这是不可能的,因为我们拥有的输入不会都具有相同的尺寸。我们故意推迟了填充,以只在每个批次上根据需要应用它,避免拥有带有大量填充的过长输入。这将大大加快训练速度,但请注意,如果你在 TPU 上进行训练,这会导致问题 - TPU 偏好固定形状,即使这需要额外的填充。

为了在实践中做到这一点,我们必须定义一个合并函数,它将对我们要批处理在一起的数据集中的项应用正确的填充量。幸运的是,🤗 Transformers 库通过 DataCollatorWithPadding 为我们提供了这样的函数。它在实例化时接收一个分词器(以便知道使用哪个填充标记,以及模型是否期望填充在输入的左侧或右侧),并且会完成你需要的所有操作

from transformers import DataCollatorWithPadding

data_collator = DataCollatorWithPadding(tokenizer=tokenizer)

为了测试这个新玩具,让我们从我们的训练集中获取一些我们想要一起批处理的样本。这里,我们删除了 idxsentence1sentence2 列,因为它们将不再需要,并且包含字符串(我们无法使用字符串创建张量),并查看批次中每个条目的大小

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 任务上工作的预处理函数。