NLP 课程文档

问答

Hugging Face's logo
加入 Hugging Face 社区

并获得增强型文档体验的访问权限

入门

问答

Ask a Question Open In Colab Open In Studio Lab

是时候看看问答了!这项任务有很多形式,但我们将在这部分重点关注的被称为提取式问答。它涉及对文档提出问题,并将答案识别为文档本身的文本跨度

我们将对 SQuAD 数据集 微调 BERT 模型,该数据集包含由众包工作者对一组维基百科文章提出的问题。这将使我们获得一个能够计算如下预测结果的模型

这实际上展示了使用本节中显示的代码在 Hub 上训练和上传的模型。你可以在这里找到它并重新检查预测结果 here

💡 仅编码器模型(如 BERT)在提取“谁发明了 Transformer 架构?”这样的事实性问题的答案方面往往非常出色,但在面对“为什么天空是蓝色的?”这样的开放式问题时表现不佳。在这些更具挑战性的情况下,通常使用 T5 和 BART 等编码器-解码器模型以与 文本摘要 非常类似的方式合成信息。如果你对这种类型的生成式问答感兴趣,我们建议查看我们基于 ELI5 数据集演示

准备数据

在学术界用于提取式问答的基准测试中,使用最广泛的数据集是 SQuAD,所以我们将在本部分使用它。还有一个更难的 SQuAD v2 基准测试,它包含没有答案的问题。只要你自己的数据集包含上下文列、问题列和答案列,你就可以调整以下步骤。

SQuAD 数据集

像往常一样,由于 `load_dataset()`,我们只需一步即可下载和缓存数据集

from datasets import load_dataset

raw_datasets = load_dataset("squad")

然后我们可以查看此对象以了解有关 SQuAD 数据集的更多信息

raw_datasets
DatasetDict({
    train: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 87599
    })
    validation: Dataset({
        features: ['id', 'title', 'context', 'question', 'answers'],
        num_rows: 10570
    })
})

看起来我们拥有所有需要的 `context`、`question` 和 `answers` 字段,所以让我们打印训练集第一个元素的这些字段

print("Context: ", raw_datasets["train"][0]["context"])
print("Question: ", raw_datasets["train"][0]["question"])
print("Answer: ", raw_datasets["train"][0]["answers"])
Context: 'Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend "Venite Ad Me Omnes". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive (and in a direct line that connects through 3 statues and the Gold Dome), is a simple, modern stone statue of Mary.'
Question: 'To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France?'
Answer: {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}

`context` 和 `question` 字段非常易于使用。`answers` 字段有点棘手,因为它包含一个字典,其中有两个字段都是列表。这是评估期间 `squad` 指标预期的格式;如果使用的是自己的数据,则不必担心将答案放入相同的格式。`text` 字段相当明显,`answer_start` 字段包含上下文内每个答案的起始字符索引。

在训练期间,只有一个可能的答案。我们可以使用 `Dataset.filter()` 方法再次检查这一点

raw_datasets["train"].filter(lambda x: len(x["answers"]["text"]) != 1)
Dataset({
    features: ['id', 'title', 'context', 'question', 'answers'],
    num_rows: 0
})

然而,在评估期间,每个样本可能有多个可能的答案,它们可能相同或不同

print(raw_datasets["validation"][0]["answers"])
print(raw_datasets["validation"][2]["answers"])
{'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}
{'text': ['Santa Clara, California', "Levi's Stadium", "Levi's Stadium in the San Francisco Bay Area at Santa Clara, California."], 'answer_start': [403, 355, 355]}

我们不会深入研究评估脚本,因为它将全部由 🤗 数据集指标完成,但简而言之,一些问题可能有多个可能的答案,并且此脚本将比较预测的答案与所有可接受的答案,并选择最佳分数。例如,如果查看索引为 2 的样本

print(raw_datasets["validation"][2]["context"])
print(raw_datasets["validation"][2]["question"])
'Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi\'s Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50.'
'Where did Super Bowl 50 take place?'

我们可以看到,答案确实可以是我们之前看到的三个可能性之一。

处理训练数据

让我们从预处理训练数据开始。最困难的部分是生成问题的答案的标签,这些标签将是对应于上下文内答案的标记的起始位置和结束位置。

但是,让我们不要操之过急。首先,我们需要使用词标记器将输入中的文本转换为模型可以理解的 ID

from transformers import AutoTokenizer

model_checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

如前所述,我们将微调 BERT 模型,但只要模型具有快速分词器,就可以使用任何其他模型类型。您可以在 这个大表 中查看所有带快速版本的架构,要检查您使用的 tokenizer 对象是否确实由 🤗 Tokenizers 支持,您可以查看它的 is_fast 属性。

tokenizer.is_fast
True

我们可以将问题和上下文一起传递给分词器,它将正确插入特殊标记以形成如下句子

[CLS] question [SEP] context [SEP]

让我们仔细检查一下

context = raw_datasets["train"][0]["context"]
question = raw_datasets["train"][0]["question"]

inputs = tokenizer(question, context)
tokenizer.decode(inputs["input_ids"])
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, '
'the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin '
'Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms '
'upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred '
'Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a '
'replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette '
'Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues '
'and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'

然后,标签将是开始和结束答案的标记的索引,模型的任务是预测输入中每个标记的一个开始和结束 logits,理论标签如下

One-hot encoded labels for question answering.

在这种情况下,上下文并不太长,但数据集中的某些示例具有非常长的上下文,会超过我们设置的最大长度(在本例中为 384)。正如我们在 第 6 章 中探索 question-answering 管道的内部工作时所见,我们将通过从数据集的一个样本创建多个训练特征来处理长上下文,并在它们之间使用滑动窗口。

为了查看使用当前示例如何工作,我们可以将长度限制为 100 并使用 50 个标记的滑动窗口。提醒一下,我们使用

  • max_length 设置最大长度(此处为 100)
  • truncation="only_second" 在问题与其上下文过长时截断上下文(位于第二个位置)
  • stride 设置两个连续块之间的重叠标记数(此处为 50)
  • return_overflowing_tokens=True 让分词器知道我们想要溢出标记
inputs = tokenizer(
    question,
    context,
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
)

for ids in inputs["input_ids"]:
    print(tokenizer.decode(ids))
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basi [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grotto, a Marian place of prayer and reflection. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP]. It is a replica of the grotto at Lourdes, France where the Virgin Mary reputedly appeared to Saint Bernadette Soubirous in 1858. At the end of the main drive ( and in a direct line that connects through 3 statues and the Gold Dome ), is a simple, modern stone statue of Mary. [SEP]'

正如我们所见,我们的示例已拆分为四个输入,每个输入都包含问题和上下文的一部分。请注意,问题的答案(“Bernadette Soubirous”)仅出现在第三个和最后一个输入中,因此通过以这种方式处理长上下文,我们将创建一些训练示例,其中答案未包含在上下文中。对于这些示例,标签将是 start_position = end_position = 0(因此我们预测 [CLS] 标记)。在不幸的情况下,如果答案被截断,我们只会得到它的开头(或结尾),我们也会设置这些标签。对于答案完全位于上下文中的示例,标签将是答案开始的标记索引和答案结束的标记索引。

数据集为我们提供了答案在上下文中的起始字符,通过添加答案的长度,我们可以找到答案在上下文中的结束字符。为了将它们映射到标记索引,我们需要使用我们在 第 6 章 中学习的偏移映射。我们可以让分词器通过传递 return_offsets_mapping=True 来返回这些映射。

inputs = tokenizer(
    question,
    context,
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
    return_offsets_mapping=True,
)
inputs.keys()
dict_keys(['input_ids', 'token_type_ids', 'attention_mask', 'offset_mapping', 'overflow_to_sample_mapping'])

正如我们所见,我们得到了通常的输入 ID、标记类型 ID 和注意力掩码,以及我们需要的偏移映射和一个额外的键 overflow_to_sample_mapping。对应值在同时标记多个文本时对我们很有用(我们应该这样做才能从分词器由 Rust 支持的事实中受益)。由于一个样本可以提供多个特征,因此它将每个特征映射到它来源的示例。因为这里我们只标记了一个示例,所以我们得到一个包含 0 的列表。

inputs["overflow_to_sample_mapping"]
[0, 0, 0, 0]

但是,如果我们标记更多示例,这将变得更有用

inputs = tokenizer(
    raw_datasets["train"][2:6]["question"],
    raw_datasets["train"][2:6]["context"],
    max_length=100,
    truncation="only_second",
    stride=50,
    return_overflowing_tokens=True,
    return_offsets_mapping=True,
)

print(f"The 4 examples gave {len(inputs['input_ids'])} features.")
print(f"Here is where each comes from: {inputs['overflow_to_sample_mapping']}.")
'The 4 examples gave 19 features.'
'Here is where each comes from: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3].'

正如我们所见,前三个示例(在训练集中的索引 2、3 和 4)分别生成了四个特征,最后一个示例(在训练集中的索引 5)生成了 7 个特征。

此信息将有助于将我们获得的每个特征映射到其对应的标签。如前所述,这些标签是

  • (0, 0) 如果答案不在上下文的相应跨度中
  • (start_position, end_position) 如果答案位于上下文的相应跨度中,其中 start_position 是答案开始的标记的索引(在输入 ID 中),end_position 是答案结束的标记的索引(在输入 ID 中)。

为了确定是哪种情况,以及如果相关,标记的位置,我们首先找到输入 ID 中开始和结束上下文的索引。我们可以使用标记类型 ID 来执行此操作,但由于这些 ID 并不一定对所有模型都存在(例如,DistilBERT 不需要它们),因此我们将改用 BatchEncodingsequence_ids() 方法(我们的分词器返回)。

获得这些标记索引后,我们会查看相应的偏移量,它们是两个整数的元组,表示原始上下文中字符的跨度。因此,我们可以检测到此特征中上下文的块是否在答案之后开始或在答案开始之前结束(在这种情况下,标签为 (0, 0))。如果不是这种情况,我们会循环查找答案的第一个和最后一个标记。

answers = raw_datasets["train"][2:6]["answers"]
start_positions = []
end_positions = []

for i, offset in enumerate(inputs["offset_mapping"]):
    sample_idx = inputs["overflow_to_sample_mapping"][i]
    answer = answers[sample_idx]
    start_char = answer["answer_start"][0]
    end_char = answer["answer_start"][0] + len(answer["text"][0])
    sequence_ids = inputs.sequence_ids(i)

    # Find the start and end of the context
    idx = 0
    while sequence_ids[idx] != 1:
        idx += 1
    context_start = idx
    while sequence_ids[idx] == 1:
        idx += 1
    context_end = idx - 1

    # If the answer is not fully inside the context, label is (0, 0)
    if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
        start_positions.append(0)
        end_positions.append(0)
    else:
        # Otherwise it's the start and end token positions
        idx = context_start
        while idx <= context_end and offset[idx][0] <= start_char:
            idx += 1
        start_positions.append(idx - 1)

        idx = context_end
        while idx >= context_start and offset[idx][1] >= end_char:
            idx -= 1
        end_positions.append(idx + 1)

start_positions, end_positions
([83, 51, 19, 0, 0, 64, 27, 0, 34, 0, 0, 0, 67, 34, 0, 0, 0, 0, 0],
 [85, 53, 21, 0, 0, 70, 33, 0, 40, 0, 0, 0, 68, 35, 0, 0, 0, 0, 0])

让我们看一下几个结果,以验证我们的方法是否正确。对于第一个特征,我们找到 (83, 85) 作为标签,因此让我们将理论答案与从 83 到 85(包含)的标记的解码跨度进行比较。

idx = 0
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]

start = start_positions[idx]
end = end_positions[idx]
labeled_answer = tokenizer.decode(inputs["input_ids"][idx][start : end + 1])

print(f"Theoretical answer: {answer}, labels give: {labeled_answer}")
'Theoretical answer: the Main Building, labels give: the Main Building'

因此,这与理论答案匹配!现在让我们检查索引 4,我们在此处将标签设置为 (0, 0),这意味着答案不在该特征的上下文块中。

idx = 4
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]

decoded_example = tokenizer.decode(inputs["input_ids"][idx])
print(f"Theoretical answer: {answer}, decoded example: {decoded_example}")
'Theoretical answer: a Marian place of prayer and reflection, decoded example: [CLS] What is the Grotto at Notre Dame? [SEP] Architecturally, the school has a Catholic character. Atop the Main Building\'s gold dome is a golden statue of the Virgin Mary. Immediately in front of the Main Building and facing it, is a copper statue of Christ with arms upraised with the legend " Venite Ad Me Omnes ". Next to the Main Building is the Basilica of the Sacred Heart. Immediately behind the basilica is the Grot [SEP]'

事实上,我们在上下文中没有看到答案。

✏️ 轮到你了! 当使用 XLNet 架构时,填充应用于左侧,问题和上下文被交换。将我们刚刚看到的所有代码调整为 XLNet 架构(并添加 padding=True)。注意,[CLS] 标记在应用填充后可能不在位置 0。

现在,我们已经逐步了解了如何预处理训练数据,我们可以将其分组在一个函数中,并将该函数应用于整个训练数据集。我们将对每个特征进行填充,使其达到我们设置的最大长度,因为大多数上下文都将很长(相应的样本将拆分为多个特征),因此在此处应用动态填充没有任何实际的好处。

max_length = 384
stride = 128


def preprocess_training_examples(examples):
    questions = [q.strip() for q in examples["question"]]
    inputs = tokenizer(
        questions,
        examples["context"],
        max_length=max_length,
        truncation="only_second",
        stride=stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    offset_mapping = inputs.pop("offset_mapping")
    sample_map = inputs.pop("overflow_to_sample_mapping")
    answers = examples["answers"]
    start_positions = []
    end_positions = []

    for i, offset in enumerate(offset_mapping):
        sample_idx = sample_map[i]
        answer = answers[sample_idx]
        start_char = answer["answer_start"][0]
        end_char = answer["answer_start"][0] + len(answer["text"][0])
        sequence_ids = inputs.sequence_ids(i)

        # Find the start and end of the context
        idx = 0
        while sequence_ids[idx] != 1:
            idx += 1
        context_start = idx
        while sequence_ids[idx] == 1:
            idx += 1
        context_end = idx - 1

        # If the answer is not fully inside the context, label is (0, 0)
        if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
            start_positions.append(0)
            end_positions.append(0)
        else:
            # Otherwise it's the start and end token positions
            idx = context_start
            while idx <= context_end and offset[idx][0] <= start_char:
                idx += 1
            start_positions.append(idx - 1)

            idx = context_end
            while idx >= context_start and offset[idx][1] >= end_char:
                idx -= 1
            end_positions.append(idx + 1)

    inputs["start_positions"] = start_positions
    inputs["end_positions"] = end_positions
    return inputs

注意,我们定义了两个常量来确定使用的最大长度以及滑动窗口的长度,并且我们在分词之前添加了一些清理操作:SQuAD 数据集中的某些问题在开头和结尾处有额外的空格,这些空格没有任何作用(如果你使用像 RoBERTa 这样的模型,它们在分词时会占用空间),因此我们删除了这些额外的空格。

为了将此函数应用于整个训练集,我们使用 Dataset.map() 方法,并使用 batched=True 标志。这是必需的,因为我们正在更改数据集的长度(因为一个示例可以生成多个训练特征)。

train_dataset = raw_datasets["train"].map(
    preprocess_training_examples,
    batched=True,
    remove_columns=raw_datasets["train"].column_names,
)
len(raw_datasets["train"]), len(train_dataset)
(87599, 88729)

正如我们所见,预处理增加了大约 1,000 个特征。现在,我们的训练集已准备好使用——让我们深入研究验证集的预处理!

处理验证数据

预处理验证数据将稍微容易一些,因为我们不需要生成标签(除非我们想计算验证损失,但该数字并不能真正帮助我们理解模型的性能)。真正的乐趣是将模型的预测解释为原始上下文中的跨度。为此,我们只需要存储偏移映射和一些方法来将每个创建的特征与它来源的原始示例匹配。由于原始数据集中有一个 ID 列,因此我们将使用该 ID。

我们在此处添加的唯一内容是对偏移映射进行一些清理。它们将包含问题和上下文的偏移量,但一旦我们进入后处理阶段,我们就无法知道输入 ID 的哪一部分对应于上下文,哪一部分对应于问题(我们使用的 sequence_ids() 方法仅适用于分词器的输出)。因此,我们将对应于问题的偏移量设置为 None

def preprocess_validation_examples(examples):
    questions = [q.strip() for q in examples["question"]]
    inputs = tokenizer(
        questions,
        examples["context"],
        max_length=max_length,
        truncation="only_second",
        stride=stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    sample_map = inputs.pop("overflow_to_sample_mapping")
    example_ids = []

    for i in range(len(inputs["input_ids"])):
        sample_idx = sample_map[i]
        example_ids.append(examples["id"][sample_idx])

        sequence_ids = inputs.sequence_ids(i)
        offset = inputs["offset_mapping"][i]
        inputs["offset_mapping"][i] = [
            o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
        ]

    inputs["example_id"] = example_ids
    return inputs

我们可以像以前一样将此函数应用于整个验证数据集。

validation_dataset = raw_datasets["validation"].map(
    preprocess_validation_examples,
    batched=True,
    remove_columns=raw_datasets["validation"].column_names,
)
len(raw_datasets["validation"]), len(validation_dataset)
(10570, 10822)

在这种情况下,我们只添加了几百个样本,因此看来验证数据集中的上下文稍微短一些。

现在我们已经预处理了所有数据,我们可以进行训练了。

使用 Trainer API 微调模型

此示例的训练代码将与前面部分的代码非常类似——最困难的是编写 compute_metrics() 函数。由于我们对所有样本进行了填充,使其达到我们设置的最大长度,因此没有数据整理器需要定义,所以这个度量计算实际上是我们唯一需要担心的。困难的部分是将模型预测的后处理成原始示例中的文本跨度;一旦我们完成了这一点,🤗 Datasets 库中的度量将为我们完成大部分工作。

后处理

模型会输出答案在输入 ID 中的开始和结束位置的 logits,正如我们在探索question-answering管道时所见。后处理步骤将与我们在那里所做的类似,所以这里快速提醒一下我们采取的步骤。

  • 我们屏蔽了对应于上下文之外的 token 的开始和结束 logits。
  • 然后我们使用 softmax 将开始和结束 logits 转换为概率。
  • 我们通过将对应两个概率的乘积来为每个(start_token, end_token)对分配一个分数。
  • 我们寻找得分最高的对,它会产生一个有效的答案(例如,start_token小于end_token)。

这里我们将略微改变这个过程,因为我们不需要计算实际分数(只需要预测答案)。这意味着我们可以跳过 softmax 步骤。为了更快,我们也不会对所有可能的(start_token, end_token)对进行评分,而只对对应于最高n_best logits 的对进行评分(n_best=20)。由于我们将跳过 softmax,这些分数将是 logits 分数,并将通过将开始和结束 logits 的总和获得(而不是乘积,因为根据规则log(ab)=log(a)+log(b)\log(ab) = \log(a) + \log(b)).

为了演示所有这些,我们需要一些预测。由于我们还没有训练模型,我们将使用 QA 管道的默认模型来在验证集的一部分上生成一些预测。我们可以使用与之前相同的处理函数;因为它依赖于全局常量tokenizer,我们只需要将该对象更改为我们想要临时使用的模型的 tokenizer。

small_eval_set = raw_datasets["validation"].select(range(100))
trained_checkpoint = "distilbert-base-cased-distilled-squad"

tokenizer = AutoTokenizer.from_pretrained(trained_checkpoint)
eval_set = small_eval_set.map(
    preprocess_validation_examples,
    batched=True,
    remove_columns=raw_datasets["validation"].column_names,
)

现在预处理已经完成,我们将 tokenizer 更改回我们最初选择的那个。

tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

然后,我们删除eval_set中模型不期望的列,使用所有的小验证集构建一个批次,并将其传递给模型。如果可以使用 GPU,我们将使用它来加快速度。

import torch
from transformers import AutoModelForQuestionAnswering

eval_set_for_model = eval_set.remove_columns(["example_id", "offset_mapping"])
eval_set_for_model.set_format("torch")

device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
batch = {k: eval_set_for_model[k].to(device) for k in eval_set_for_model.column_names}
trained_model = AutoModelForQuestionAnswering.from_pretrained(trained_checkpoint).to(
    device
)

with torch.no_grad():
    outputs = trained_model(**batch)

由于Trainer 将以 NumPy 数组的形式提供我们预测,因此我们获取开始和结束 logits 并将其转换为该格式。

start_logits = outputs.start_logits.cpu().numpy()
end_logits = outputs.end_logits.cpu().numpy()

现在,我们需要找到small_eval_set中每个示例的预测答案。一个示例可能已在eval_set中被拆分为多个特征,所以第一步是将small_eval_set中的每个示例映射到eval_set中的对应特征。

import collections

example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(eval_set):
    example_to_features[feature["example_id"]].append(idx)

有了它,我们就可以真正开始工作了,通过循环遍历所有示例,并且对于每个示例,遍历所有关联的特征。正如我们之前所说,我们将查看n_best个开始 logits 和结束 logits 的 logits 分数,排除会给出以下情况的位置:

  • 一个不在上下文范围内的答案
  • 一个长度为负数的答案
  • 一个过长的答案(我们将可能性限制在max_answer_length=30

一旦我们为一个示例获得了所有得分可能的答案,我们就选择 logits 分数最高的答案。

import numpy as np

n_best = 20
max_answer_length = 30
predicted_answers = []

for example in small_eval_set:
    example_id = example["id"]
    context = example["context"]
    answers = []

    for feature_index in example_to_features[example_id]:
        start_logit = start_logits[feature_index]
        end_logit = end_logits[feature_index]
        offsets = eval_set["offset_mapping"][feature_index]

        start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
        end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
        for start_index in start_indexes:
            for end_index in end_indexes:
                # Skip answers that are not fully in the context
                if offsets[start_index] is None or offsets[end_index] is None:
                    continue
                # Skip answers with a length that is either < 0 or > max_answer_length.
                if (
                    end_index < start_index
                    or end_index - start_index + 1 > max_answer_length
                ):
                    continue

                answers.append(
                    {
                        "text": context[offsets[start_index][0] : offsets[end_index][1]],
                        "logit_score": start_logit[start_index] + end_logit[end_index],
                    }
                )

    best_answer = max(answers, key=lambda x: x["logit_score"])
    predicted_answers.append({"id": example_id, "prediction_text": best_answer["text"]})

预测答案的最终格式是我们将要使用的度量标准所期望的格式。像往常一样,我们可以借助🤗 Evaluate 库来加载它。

import evaluate

metric = evaluate.load("squad")

此度量标准期望预测答案以我们上面看到的格式(一个带有示例 ID 的键和预测文本的键的字典列表)和理论答案以以下格式(一个带有示例 ID 的键和可能答案的键的字典列表)。

theoretical_answers = [
    {"id": ex["id"], "answers": ex["answers"]} for ex in small_eval_set
]

现在,我们可以通过查看两个列表的第一个元素来检查我们是否得到了合理的结果。

print(predicted_answers[0])
print(theoretical_answers[0])
{'id': '56be4db0acb8001400a502ec', 'prediction_text': 'Denver Broncos'}
{'id': '56be4db0acb8001400a502ec', 'answers': {'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}}

还不错!现在让我们看看度量标准给我们的分数。

metric.compute(predictions=predicted_answers, references=theoretical_answers)
{'exact_match': 83.0, 'f1': 88.25}

同样,考虑到根据其论文在 SQuAD 上微调的 DistilBERT 在整个数据集上获得这两个分数的 79.1 和 86.9,这是一个相当不错的结果。

现在让我们将我们刚刚完成的所有操作放到一个compute_metrics()函数中,我们将在Trainer中使用它。通常,compute_metrics()函数只接收一个包含 logits 和标签的元组eval_preds。这里我们需要更多,因为我们必须在特征数据集中查找偏移量,在示例数据集中查找原始上下文,所以我们将无法使用此函数在训练期间获得常规的评估结果。我们只会在训练结束时使用它来检查结果。

compute_metrics()函数将相同的步骤组合在一起;我们只是在没有找到任何有效答案的情况下添加了一个小检查(在这种情况下,我们预测一个空字符串)。

from tqdm.auto import tqdm


def compute_metrics(start_logits, end_logits, features, examples):
    example_to_features = collections.defaultdict(list)
    for idx, feature in enumerate(features):
        example_to_features[feature["example_id"]].append(idx)

    predicted_answers = []
    for example in tqdm(examples):
        example_id = example["id"]
        context = example["context"]
        answers = []

        # Loop through all features associated with that example
        for feature_index in example_to_features[example_id]:
            start_logit = start_logits[feature_index]
            end_logit = end_logits[feature_index]
            offsets = features[feature_index]["offset_mapping"]

            start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
            end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
            for start_index in start_indexes:
                for end_index in end_indexes:
                    # Skip answers that are not fully in the context
                    if offsets[start_index] is None or offsets[end_index] is None:
                        continue
                    # Skip answers with a length that is either < 0 or > max_answer_length
                    if (
                        end_index < start_index
                        or end_index - start_index + 1 > max_answer_length
                    ):
                        continue

                    answer = {
                        "text": context[offsets[start_index][0] : offsets[end_index][1]],
                        "logit_score": start_logit[start_index] + end_logit[end_index],
                    }
                    answers.append(answer)

        # Select the answer with the best score
        if len(answers) > 0:
            best_answer = max(answers, key=lambda x: x["logit_score"])
            predicted_answers.append(
                {"id": example_id, "prediction_text": best_answer["text"]}
            )
        else:
            predicted_answers.append({"id": example_id, "prediction_text": ""})

    theoretical_answers = [{"id": ex["id"], "answers": ex["answers"]} for ex in examples]
    return metric.compute(predictions=predicted_answers, references=theoretical_answers)

我们可以检查它是否在我们的预测上有效。

compute_metrics(start_logits, end_logits, eval_set, small_eval_set)
{'exact_match': 83.0, 'f1': 88.25}

看起来不错!现在让我们用它来微调我们的模型。

微调模型

我们现在准备训练我们的模型。让我们先创建它,使用像之前一样的AutoModelForQuestionAnswering类。

model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

像往常一样,我们收到一个警告,说一些权重没有被使用(来自预训练头的权重),另一些则被随机初始化(来自问答头的权重)。你现在应该已经习惯了,但这意味着这个模型还没有准备好使用,需要微调——幸好我们马上就要做了!

为了能够将我们的模型推送到 Hub,我们需要登录到 Hugging Face。如果你在笔记本中运行这段代码,你可以使用以下实用函数,它会显示一个小部件,你可以在其中输入你的登录凭据。

from huggingface_hub import notebook_login

notebook_login()

如果你不在笔记本中工作,只需在你的终端中输入以下行。

huggingface-cli login

完成此操作后,我们可以定义我们的TrainingArguments。正如我们在定义用于计算度量标准的函数时所说的那样,我们无法进行常规的评估循环,因为compute_metrics()函数的签名。我们可以编写我们自己的Trainer子类来做到这一点(你可以在问答示例脚本中找到这种方法),但这对于本节来说有点过长。相反,我们只会在训练结束时评估模型,并向你展示如何在“自定义训练循环”中进行常规评估。

这正是Trainer API 显示其局限性和🤗 Accelerate 库闪耀的地方:将类定制到特定用例可能很痛苦,但调整完全公开的训练循环很容易。

让我们看一下我们的TrainingArguments

from transformers import TrainingArguments

args = TrainingArguments(
    "bert-finetuned-squad",
    evaluation_strategy="no",
    save_strategy="epoch",
    learning_rate=2e-5,
    num_train_epochs=3,
    weight_decay=0.01,
    fp16=True,
    push_to_hub=True,
)

我们之前已经见过大多数这些:我们设置了一些超参数(例如学习率、训练的 epoch 数、一些权重衰减)并指示我们希望在每个 epoch 结束时保存模型,跳过评估并将结果上传到 Model Hub。我们还使用fp16=True启用混合精度训练,因为它可以很好地提高最近的 GPU 上的训练速度。

默认情况下,使用的存储库将在你的命名空间中,并以你设置的输出目录命名,因此在我们的例子中它将位于"sgugger/bert-finetuned-squad"中。我们可以通过传递hub_model_id来覆盖它;例如,要将模型推送到huggingface_course组织,我们使用了hub_model_id="huggingface_course/bert-finetuned-squad"(这就是我们在本节开头链接的模型)。

💡如果你使用的输出目录已存在,则它需要是你想要推送到存储库的本地克隆(因此,如果你在定义Trainer时遇到错误,请设置一个新名称)。

最后,我们将所有内容传递给Trainer类并启动训练。

from transformers import Trainer

trainer = Trainer(
    model=model,
    args=args,
    train_dataset=train_dataset,
    eval_dataset=validation_dataset,
    tokenizer=tokenizer,
)
trainer.train()

请注意,在训练过程中,每次模型被保存时(这里,每个 epoch),它都会在后台上传到 Hub。这样,你可以在需要时在另一台机器上恢复训练。整个训练需要一段时间(在 Titan RTX 上需要一个多小时),所以你可以在它进行的时候去喝杯咖啡或重新阅读课程中你发现更具挑战性的部分。还要注意,一旦第一个 epoch 完成,你将看到一些权重上传到 Hub,你可以开始在它的页面上使用你的模型。

训练完成后,我们终于可以评估我们的模型了(并祈祷我们没有把所有计算时间都浪费掉)。Trainerpredict()方法将返回一个元组,其中第一个元素将是模型的预测(这里是一对包含开始和结束 logits)。我们将它发送到我们的compute_metrics()函数。

predictions, _, _ = trainer.predict(validation_dataset)
start_logits, end_logits = predictions
compute_metrics(start_logits, end_logits, validation_dataset, raw_datasets["validation"])
{'exact_match': 81.18259224219489, 'f1': 88.67381321905516}

太棒了!作为比较,BERT 文章中报告的此模型的基线分数为 80.8 和 88.5,所以我们正处于应该在的位置。

最后,我们使用push_to_hub()方法来确保我们上传模型的最新版本。

trainer.push_to_hub(commit_message="Training complete")

这将返回它刚刚执行的提交的 URL,如果你想检查它。

'https://huggingface.co/sgugger/bert-finetuned-squad/commit/9dcee1fbc25946a6ed4bb32efb1bd71d5fa90b68'

Trainer还会用所有评估结果草拟一个模型卡,并将其上传。

在此阶段,你可以在 Model Hub 上使用推理小部件来测试模型并与你的朋友、家人和最喜欢的宠物分享。你已经成功地在问答任务上微调了一个模型——恭喜!

✏️ 轮到你了!尝试另一个模型架构,看看它在这个任务上的表现是否更好!

如果你想更深入地了解训练循环,我们现在将向你展示如何使用🤗 Accelerate 来做同样的事情。

自定义训练循环

现在让我们看看完整的训练循环,这样您就可以轻松地自定义您需要的部分。它看起来很像第 3 章中的训练循环,除了评估循环。由于我们不再受限于Trainer类,因此我们将能够定期评估模型。

准备所有用于训练的东西

首先,我们需要从我们的数据集构建DataLoader。我们将这些数据集的格式设置为"torch",并删除验证集中模型不使用的列。然后,我们可以使用Transformers提供的default_data_collator作为collate_fn并对训练集进行洗牌,但不要对验证集进行洗牌

from torch.utils.data import DataLoader
from transformers import default_data_collator

train_dataset.set_format("torch")
validation_set = validation_dataset.remove_columns(["example_id", "offset_mapping"])
validation_set.set_format("torch")

train_dataloader = DataLoader(
    train_dataset,
    shuffle=True,
    collate_fn=default_data_collator,
    batch_size=8,
)
eval_dataloader = DataLoader(
    validation_set, collate_fn=default_data_collator, batch_size=8
)

接下来,我们将重新实例化我们的模型,以确保我们没有从之前继续微调,而是从BERT预训练模型重新开始

model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)

然后,我们将需要一个优化器。像往常一样,我们使用经典的AdamW,它类似于Adam,但在应用权重衰减的方式上进行了修复

from torch.optim import AdamW

optimizer = AdamW(model.parameters(), lr=2e-5)

一旦我们拥有了所有这些对象,就可以将它们发送到accelerator.prepare()方法。请记住,如果您想在Colab笔记本中使用TPU进行训练,则需要将所有这些代码移到训练函数中,并且不应执行任何实例化Accelerator的单元格。我们可以通过将fp16=True传递给Accelerator来强制混合精度训练(或者,如果您将代码作为脚本执行,只需确保将 🤗 Accelerate config 填写好)。

from accelerate import Accelerator

accelerator = Accelerator(fp16=True)
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
    model, optimizer, train_dataloader, eval_dataloader
)

正如您从前面的部分了解到的那样,我们只能在它经过accelerator.prepare()方法之后使用train_dataloader长度来计算训练步骤数。我们使用与前面部分相同的线性计划

from transformers import get_scheduler

num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch

lr_scheduler = get_scheduler(
    "linear",
    optimizer=optimizer,
    num_warmup_steps=0,
    num_training_steps=num_training_steps,
)

要将我们的模型推送到Hub,我们需要在工作文件夹中创建一个Repository对象。首先登录到Hugging Face Hub,如果您尚未登录。我们将从要给模型提供的模型 ID 中确定存储库名称(您可以随意用自己的选择替换repo_name;它只需要包含您的用户名,这就是函数get_full_repo_name()的作用)。

from huggingface_hub import Repository, get_full_repo_name

model_name = "bert-finetuned-squad-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'sgugger/bert-finetuned-squad-accelerate'

然后,我们可以在本地文件夹中克隆该存储库。如果它已经存在,则该本地文件夹应该是我们正在使用的存储库的克隆

output_dir = "bert-finetuned-squad-accelerate"
repo = Repository(output_dir, clone_from=repo_name)

我们现在可以通过调用repo.push_to_hub()方法上传我们在output_dir中保存的任何内容。这将帮助我们在每个纪元结束时上传中间模型。

训练循环

我们现在准备编写完整的训练循环。在定义进度条以跟踪训练进度之后,循环分为三个部分

  • 训练本身,这是对train_dataloader的经典迭代,通过模型进行正向传递,然后进行反向传递和优化器步骤。
  • 评估,我们收集start_logitsend_logits的所有值,然后将它们转换为NumPy数组。评估循环完成后,我们将所有结果连接起来。请注意,我们需要截断,因为Accelerator可能在末尾添加了一些样本以确保每个进程中的样本数量相同。
  • 保存和上传,我们首先保存模型和分词器,然后调用repo.push_to_hub()。与之前一样,我们使用参数blocking=False告诉 🤗 Hub 库异步推送到后台。这样,训练就可以正常进行,而这个(很长的)指令将在后台执行。

以下是训练循环的完整代码

from tqdm.auto import tqdm
import torch

progress_bar = tqdm(range(num_training_steps))

for epoch in range(num_train_epochs):
    # Training
    model.train()
    for step, batch in enumerate(train_dataloader):
        outputs = model(**batch)
        loss = outputs.loss
        accelerator.backward(loss)

        optimizer.step()
        lr_scheduler.step()
        optimizer.zero_grad()
        progress_bar.update(1)

    # Evaluation
    model.eval()
    start_logits = []
    end_logits = []
    accelerator.print("Evaluation!")
    for batch in tqdm(eval_dataloader):
        with torch.no_grad():
            outputs = model(**batch)

        start_logits.append(accelerator.gather(outputs.start_logits).cpu().numpy())
        end_logits.append(accelerator.gather(outputs.end_logits).cpu().numpy())

    start_logits = np.concatenate(start_logits)
    end_logits = np.concatenate(end_logits)
    start_logits = start_logits[: len(validation_dataset)]
    end_logits = end_logits[: len(validation_dataset)]

    metrics = compute_metrics(
        start_logits, end_logits, validation_dataset, raw_datasets["validation"]
    )
    print(f"epoch {epoch}:", metrics)

    # Save and upload
    accelerator.wait_for_everyone()
    unwrapped_model = accelerator.unwrap_model(model)
    unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
    if accelerator.is_main_process:
        tokenizer.save_pretrained(output_dir)
        repo.push_to_hub(
            commit_message=f"Training in progress epoch {epoch}", blocking=False
        )

如果这是您第一次看到使用 🤗 Accelerate 保存的模型,让我们花点时间检查一下与之相关的三行代码

accelerator.wait_for_everyone()
unwrapped_model = accelerator.unwrap_model(model)
unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)

第一行不言自明:它告诉所有进程在继续之前等待每个人都到达该阶段。这样做是为了确保我们在保存之前每个进程中都有相同的模型。然后,我们获取unwrapped_model,它是我们定义的基本模型。accelerator.prepare()方法更改模型以在分布式训练中工作,因此它将不再具有save_pretrained()方法;accelerator.unwrap_model()方法撤消该步骤。最后,我们调用save_pretrained(),但告诉该方法使用accelerator.save()而不是torch.save()

完成后,您应该拥有一个模型,其产生的结果与使用Trainer训练的结果非常相似。您可以在huggingface-course/bert-finetuned-squad-accelerate中查看我们使用此代码训练的模型。如果您想测试训练循环的任何调整,可以直接通过编辑上面显示的代码来实现它们!

使用微调后的模型

我们已经向您展示了如何使用模型中心中的推理小部件使用我们微调的模型。要在本地pipeline中使用它,您只需要指定模型标识符

from transformers import pipeline

# Replace this with your own checkpoint
model_checkpoint = "huggingface-course/bert-finetuned-squad"
question_answerer = pipeline("question-answering", model=model_checkpoint)

context = """
🤗 Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch and TensorFlow — with a seamless integration
between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back 🤗 Transformers?"
question_answerer(question=question, context=context)
{'score': 0.9979003071784973,
 'start': 78,
 'end': 105,
 'answer': 'Jax, PyTorch and TensorFlow'}

太好了!我们的模型与该管道默认模型的效果一样好!