LLM 课程文档

问答

Hugging Face's logo
加入 Hugging Face 社区

并获得增强的文档体验

开始使用

问答

Ask a Question Open In Colab Open In Studio Lab

是时候了解问答了!这项任务有多种形式,但我们将在本节中重点介绍的是抽取式问答。这包括提出关于文档的问题,并将答案识别为文档本身的文本片段

我们将在 SQuAD 数据集上微调 BERT 模型,该数据集由众包工作者在一组维基百科文章上提出的问题组成。这将为我们提供一个能够计算如下预测的模型

这实际上展示了使用本节中显示的代码训练并上传到 Hub 的模型。您可以在这里找到它并再次检查预测。

💡 像 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
    })
})

看起来我们拥有 contextquestionanswers 字段所需的一切,所以让我们打印出训练集第一个元素的这些字段

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]}

contextquestion 字段使用起来非常简单。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]}

我们不会深入研究评估脚本,因为它将由 🤗 Datasets 指标为我们封装所有内容,但简而言之,一些问题有几个可能的答案,此脚本将比较预测的答案与所有可接受的答案并取最佳分数。例如,如果我们查看索引 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]'

标签将是答案起始和结束词元的索引,模型将负责预测输入中每个词元的一个起始和结束逻辑,理论标签如下

One-hot encoded labels for question answering.

在这种情况下,上下文不会太长,但数据集中有些示例的上下文非常长,会超出我们设置的最大长度(本例中为 384)。正如我们在第六章探索 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] 词元)。在不幸的情况下,如果答案已被截断,以至于我们只剩下答案的开始(或结束)部分,我们也会设置这些标签。对于答案完全包含在上下文中的示例,标签将是答案开始的词元索引和答案结束的词元索引。

数据集为我们提供了答案在上下文中的起始字符,通过加上答案的长度,我们可以找到答案在上下文中的结束字符。为了将这些映射到词元索引,我们需要使用我们在第六章中学习的偏移映射。我们可以通过传入 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 这样的模型,在分词时会占用空间),所以我们删除了这些多余的空格。

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

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,正如我们在探索问答管道时所看到的。后处理步骤将与我们之前所做的类似,所以这里快速回顾一下我们采取的行动

  • 我们掩盖了上下文之外的词元所对应的起始和结束 logits。
  • 然后,我们使用 softmax 将起始和结束 logits 转换为概率。
  • 我们通过取对应两个概率的乘积,为每个 (start_token, end_token) 对分配一个分数。
  • 我们寻找得分最高的有效答案对(例如,start_token 小于 end_token)。

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

为了演示所有这些,我们需要一些预测。由于我们还没有训练模型,我们将使用 QA 管道的默认模型,在验证集的一小部分上生成一些预测。我们可以使用与之前相同的处理函数;因为它依赖于全局常量 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 = 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 的 logit 分数,排除以下情况的位置:

  • 答案不在上下文中
  • 答案长度为负数
  • 答案过长(我们将可能性限制为 max_answer_length=30

一旦我们为一个示例获得了所有评分的可能答案,我们只需选择 logit 分数最佳的那个

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}

同样,考虑到 DistilBERT 在 SQuAD 上微调后,根据其论文,在整个数据集上获得了 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,
)

我们之前已经见过大部分这些:我们设置了一些超参数(如学习率、训练时期数和一些权重衰减),并表明我们希望在每个时期结束时保存模型,跳过评估,并将结果上传到模型中心。我们还启用了混合精度训练,设置 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 还会起草一份包含所有评估结果的模型卡并上传。

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

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

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

自定义训练循环

现在让我们看看完整的训练循环,以便您可以轻松自定义所需的部件。它将与第三章中的训练循环非常相似,但评估循环除外。我们将能够定期评估模型,因为我们不再受 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 notebook 中在 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
)

正如您应该从上一节中了解到的,我们只能在 train_dataloader 经过 accelerator.prepare() 方法后才使用其长度来计算训练步骤的数量。我们使用与上一节相同的线性调度

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 中的任何内容。这将帮助我们在每个 epoch 结束时上传中间模型。

训练循环

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

  • 训练本身,这是经典的 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'}

太棒了!我们的模型与此管道的默认模型一样好用!

< > 在 GitHub 上更新