00216 NLP Course - Processing the data ubuntu
前言
继续上一章的例子,以下是我们在 PyTorch 中如何在一个批次上训练序列分类器的步骤:
1 | import torch |
当然,只在两个句子上进行模型训练不会得到很好的结果。为了获得更好的结果,您需要准备一个更大的数据集。
在本节中,我们将以 MRPC(微软研究院释义语料库)数据集为例,该数据集由 William B. Dolan 和 Chris Brockett 在一篇论文中介绍。这个数据集包含 5,801 对句子,每个句子对都有一个标签,指示它们是否是释义(即,两个句子是否意思相同)。我们选择这个数据集用于本章,因为它是一个小型的数据集,因此易于进行训练实验。
src link: https://huggingface.co/learn/nlp-course/chapter3/2
Operating System: Ubuntu 22.04.4 LTS
参考文档
从 Hub 加载数据集
Hub 不仅仅包含模型;它还有多种不同语言的多个数据集。您可以在这里浏览数据集,并且我们建议您在完成本节内容后尝试加载和处理一个新的数据集(请参阅这里的通用文档)。但现在,让我们专注于 MRPC 数据集!这是组成 GLUE 基准测试的 10 个数据集之一,GLUE 是一个学术基准,用于衡量 ML 模型在 10 个不同文本分类任务上的性能。
🤗 Datasets 库提供了一个非常简单的命令,用于下载和缓存 Hub 上的数据集。我们可以像这样下载 MRPC 数据集:
1 | from datasets import load_dataset |
1 | DatasetDict({ |
如您所见,我们得到了一个 DatasetDict 对象,其中包含训练集、验证集和测试集。每个集合都包含几列(sentence1, sentence2, label 和 idx)和可变数量的行,这些行是每个集合中的元素数量(因此,训练集中有 3,668 对句子,验证集中有 408 对,测试集中有 1,725 对)。
这个命令下载并缓存数据集,默认位置在 ~/.cache/huggingface/datasets。回想一下第 2 章中,您可以通过设置 HF_HOME 环境变量来自定义您的缓存文件夹。
我们可以通过索引访问 raw_datasets 对象中的每一对句子,就像使用字典一样:
1 | raw_train_dataset = raw_datasets["train"] |
1 | {'idx': 0, |
我们可以看到标签已经是整数,所以我们不需要在那里进行任何预处理。要了解哪个整数对应哪个标签,我们可以检查 raw_train_dataset 的特征。这将告诉我们每列的类型:
1 | raw_train_dataset.features |
1 | {'sentence1': Value(dtype='string', id=None), |
在幕后,label 的类型是 ClassLabel,整数到标签名称的映射存储在 names 文件夹中。0 对应于 not_equivalent,1 对应于 equivalent。
预处理数据集
为了预处理数据集,我们需要将文本转换为模型能够理解的数字。正如您在前一章中看到的,这是通过分词器完成的。我们可以向分词器输入一个句子或一个句子列表,因此我们可以直接像这样对每对句子的第一个句子和第二个句子进行分词:
1 | from transformers import AutoTokenizer |
然而,我们无法仅将两个序列传递给模型,并得到预测两个句子是否是释义的结果。我们需要将两个序列作为一对来处理,并应用适当的预处理。幸运的是,分词器也可以接受一对序列,并以我们的 BERT 模型期望的方式准备它们:
1 | inputs = tokenizer("This is the first sentence.", "This is the second one.") |
1 | { |
我们在第 2 章中讨论了 input_ids 和 attention_mask 键,但我们推迟了关于 token_type_ids 的讨论。在这个例子中,这就是告诉模型输入的哪部分是第一句句子,哪部分是第二句句子的信息。
如果我们解码 input_ids 中的 ID 回到单词:
1 | tokenizer.convert_ids_to_tokens(inputs["input_ids"]) |
我们将得到:
1 | ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]'] |
所以我们可以看到,当有两个句子时,模型期望输入的形式为 [CLS] 句子1 [SEP] 句子2 [SEP]。将其与 token_type_ids 对齐,我们得到:
1 | ['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]'] |
正如你所看到的,对应于 [CLS] 句子1 [SEP] 的输入部分都有一个 token 类型 ID 为 0,而对应于 句子2 [SEP] 的其他部分都有一个 token 类型 ID 为 1。
请注意,如果你选择了一个不同的检查点,你的分词输入中不一定会有 token_type_ids(例如,如果你使用的是 DistilBERT 模型,它们就不会被返回)。它们只有在模型知道如何处理它们时才会被返回,因为模型在预训练期间已经见过它们了。
在这里,BERT 使用 token 类型 IDs 进行了预训练,并且在我们在第1章中讨论的掩码语言模型目标的基础上,它还有一个额外的目标,称为下一句预测。这个任务的目标是建模句子对之间的关系。
在下一句预测中,模型被提供了句子对(其中包含随机掩码的标记),并被要求预测第二个句子是否跟随第一个句子。为了使这个任务不平凡,一半的时间里,句子在它们被提取的原始文档中相互跟随,另一半的时间里,两个句子来自两个不同的文档。
一般来说,您不需要担心分词输入中是否包含 token_type_ids:只要您在分词器和模型中使用相同的检查点,一切都会正常进行,因为分词器知道需要向其模型提供什么。
现在我们已经看到分词器如何处理一对句子,我们可以使用它来分词整个数据集:就像在前一章中一样,我们可以通过给分词器第一句列表,然后是第二句列表,来给它一个句子对列表。这也与我们第2章中看到的填充和截断选项兼容。因此,预处理训练数据集的一种方法是:
1 | tokenized_dataset = tokenizer( |
这种方法效果很好,但它有一个缺点,即返回一个字典(包含我们的键,input_ids、attention_mask 和 token_type_ids,以及值为列表的列表)。此外,它只有在您有足够的RAM来存储分词期间整个数据集时才能工作(而 🤗 Datasets 库中的数据集是存储在磁盘上的 Apache Arrow 文件,所以您只保留在内存中加载的样本)。
为了保持数据作为一个数据集,我们将使用 Dataset.map() 方法。如果我们需要比仅仅分词更多的预处理,这也会给我们一些额外的灵活性。map() 方法通过对数据集中的每个元素应用一个函数来工作,所以让我们定义一个函数来分词我们的输入:
1 | def tokenize_function(example): |
这个函数接受一个字典(如我们的数据集中的项目)并返回一个带有键 input_ids、attention_mask 和 token_type_ids 的新字典。注意,如果示例字典包含几个样本(每个键作为一个句子列表),它也能工作,因为如前所述,分词器在句子对列表上工作。这将允许我们在调用 map() 时使用选项 batched=True,这将大大加快分词速度。分词器由 🤗 Tokenizers 库中的 Rust 编写的分词器支持。这个分词器可以非常快,但前提是我们一次性给它提供很多输入。
注意,在我们的分词函数中,我们现在还没有包含填充(padding)参数。这是因为将所有样本填充到最大长度并不是高效的:当我们构建一个批次时进行填充会更好,因为那样我们只需要填充到该批次中的最大长度,而不是整个数据集中的最大长度。当输入长度变化很大时,这可以节省很多时间和处理能力!
以下是我们如何一次性在所有数据集上应用分词函数。我们在调用 map 时使用 batched=True,这样函数就会一次性应用于数据集中的多个元素,而不是分别应用于每个元素。这允许更快的预处理。
1 | tokenized_datasets = raw_datasets.map(tokenize_function, batched=True) |
🤗 Datasets 库应用这种处理的方式是通过向数据集添加新的字段,每个字段对应预处理函数返回的字典中的一个键:
1 | DatasetDict({ |
您甚至可以在使用 map() 应用预处理函数时使用多进程,通过传递一个 num_proc 参数。我们在这里没有这样做,因为 🤗 Tokenizers 库已经使用多个线程来更快地分词我们的样本,但如果您没有使用这个库支持的高速分词器,这可能会加快您的预处理速度。
我们的 tokenize_function 返回一个带有键 input_ids、attention_mask 和 token_type_ids 的字典,因此这三个字段被添加到我们数据集的所有分片中。注意,如果我们的预处理函数为我们在其上应用 map() 的数据集中的现有键返回了一个新值,我们也可以更改现有的字段。
最后我们需要做的是,当我们把元素分批处理时,将所有示例填充到最长元素的长度——我们称之为动态填充的技术。
动态填充
负责在批次内组合样本的函数称为 collate 函数。在构建 DataLoader 时,您可以传递这个参数,默认情况下是一个将样本转换为 PyTorch 张量并连接它们(如果元素是列表、元组或字典,则递归地进行)的函数。在我们的情况下,这将不可能,因为我们拥有的输入不会都是相同的大小。我们故意推迟填充,只为每个批次按需应用它,避免出现带有大量填充的过长输入。这将大大加快训练速度,但请注意,如果您在 TPU 上训练,这可能会导致问题——TPU 更喜欢固定形状,即使这需要额外的填充。
为了实际做到这一点,我们必须定义一个 collate 函数,它将对我们要一起批处理的数据集中的项目应用正确数量的填充。幸运的是,🤗 Transformers 库通过 DataCollatorWithPadding 提供了这样一个函数。在实例化它时,它接收一个分词器(以知道使用哪个填充标记,以及模型是否期望填充在输入的左侧或右侧),并将完成您需要的所有工作:
1 | from transformers import DataCollatorWithPadding |
为了测试这个新工具,让我们从我们的训练集中抓取一些我们想要一起批处理的样本。在这里,我们移除了列 idx、sentence1 和 sentence2,因为它们不会被需要,并且包含字符串(我们不能用字符串创建张量),然后查看批次中每个条目的长度:
1 | samples = tokenized_datasets["train"][:8] |
1 | [50, 59, 47, 67, 59, 50, 62, 32] |
毫不奇怪,我们得到了长度不等的样本,从 32 到 67。动态填充意味着这个批次中的样本应该都被填充到 67,即批次内的最大长度。没有动态填充,所有样本将不得不被填充到整个数据集中的最大长度,或者模型可以接受的最大长度。让我们再次检查我们的 data_collator 是否正确地动态填充了批次:
1 | batch = data_collator(samples) |
1 | {'attention_mask': torch.Size([8, 67]), |
看起来不错!现在我们已经从原始文本转换成了模型可以处理的批次,我们准备好微调模型了!
结语
第二百一十六篇博文写完,开心!!!!
今天,也是充满希望的一天。