文章目录
微调模型
数据处理
下面是我们如何在PyTorch中训练一个批处理的序列分类器:
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(微软研究释义语料库)数据集作为示例,该数据集由 William B. Dolan 和 Chris Brockett 在一篇论文中介绍。数据集由 5801 对句子组成,有一个标签表明它们是否是释义(即,如果两个句子意思相同)。我们在本章中选择它是因为它是一个小数据集,所以很容易在它上面进行训练实验。
载入数据集
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 中。可以通过设置 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 的特征。这将告诉我们每一列的类型:
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。
预处理数据集
为了预处理数据集,我们需要将文本转换为模型可以理解的数字。正如您在前一章中看到的,这是通过标记器完成的。我们可以给标记器提供一个句子或一个句子列表,这样我们就可以直接标记每对的所有第一个句子和所有第二个句子,就像这样:
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。在这个例子中,它告诉模型输入的哪一部分是第一个句子,哪一部分是第二个句子。
如果我们将 input_ids中 的 id 解码回 words :
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] 相对应的输入部分的 token 类型ID都为 0,而与 sentence2 [SEP] 相对应的其他部分的令牌类型ID都为 1。
注意,如果选择不同的检查点,则在标记化的输入中不一定有 token_type_ids (例如,如果使用蒸馏器模型,则不会返回它们)。只有当模型知道如何处理它们时,它们才会被返回,因为它在预训练期间已经看到了它们。
在这里,BERT 使用 token 类型ID进行预训练,并且在掩码语言建模目标之上,它有一个额外的目标,称为下一个句子预测。这个任务的目标是为句子对之间的关系建模。
对于下一个句子预测,向模型提供成对的句子(带有随机屏蔽的 token),并要求它预测第二个句子是否紧跟着第一个句子。为了使任务不琐碎,在一半的时间里,句子在它们被提取的原始文档中彼此相连,另一半的时间里,两个句子来自两个不同的文档。
一般来说,您不需要担心在您的标记化输入中是否存在 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来存储整个数据集在标记化期间(而数据集库中的数据集是存储在磁盘上的Apache Arrow文件,所以你只保留你要求在内存中加载的样本)。
为了将数据保持为数据集,我们将使用 dataset .map方法。如果我们需要做更多的预处理,而不仅仅是标记化,这也为我们提供了一些额外的灵活性。map 方法通过对数据集的每个元素应用一个函数来工作,所以让我们定义一个函数来标记我们的输入:
def tokenize_function(example):
return tokenizer(example["sentence1"], example["sentence2"], truncation=True)
这个函数接受一个字典(就像我们的数据集的项),并返回一个带有键 input_ids、attention_mask 和 token_type_ids 的新字典。请注意,如果示例字典包含多个示例(每个键作为一个句子列表),它也可以工作,因为标记器可以处理成对的句子列表,如前所述。这将允许我们在调用 map 时使用 batched=True 选项,这将大大加快标记化的速度。该标记器由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 函数返回一个字典,其中包含键 input_ids、attention_mask 和 token_type_ids ,因此这三个字段被添加到我们数据集的所有分割中。注意,如果预处理函数为应用 map的数据集中的现有键返回一个新值,我们也可以更改现有字段。
我们需要做的最后一件事是在批处理元素时将所有示例填充到最长元素的长度---我们将这种技术称为动态填充。
动态填充
负责将一批样品放在一起的函数称为"整理函数"。这是一个可以在构建 DataLoader 时传递的参数,默认值是一个函数,它只会将样本转换为PyTorch张量并将它们连接起来(如果元素是列表、元组或字典,则递归)。这在我们的例子中是不可能的,因为我们的输入并不都是相同的大小。我们故意推迟填充,只在每批需要时应用它,避免过多的填充输入。这将大大加快训练速度,但请注意,如果你在TPU上训练,它可能会导致问题- TPU更喜欢固定的形状,即使需要额外的填充。
为了在实践中做到这一点,我们必须定义一个 collate 函数,该函数将对我们想要批处理的数据集的项应用正确的填充量。幸运的是,Transformers 库通过 DataCollatorWithPadding 为我们提供了这样一个函数。当你实例化它时,它需要一个 tokenizer(知道要使用哪个填充 token,以及模型是否期望填充在输入的左侧或右侧),并且会做你需要的一切:
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])}