NLP 课程文档

问答

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

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

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 不需要它们),因此我们将改为使用tokenizer返回的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)

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

处理验证数据

验证数据的预处理会稍微简单一些,因为我们不需要生成标签(除非我们想计算验证损失,但该数字并不能真正帮助我们了解模型的优劣)。真正的乐趣在于将模型的预测解释为原始上下文的跨度。为此,我们只需要存储偏移映射和某种方法来将每个创建的特征与其源自的原始示例匹配。由于原始数据集中有一个 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,因此这些分数将是logit分数,并且将通过将开始和结束logit相加得到(而不是乘积,因为规则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的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}

同样,考虑到根据其论文在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,
)

我们之前见过大多数这些内容:我们设置了一些超参数(例如学习率、训练的轮数和一些权重衰减),并指示我们希望在每个轮结束时保存模型,跳过评估,并将结果上传到模型中心。我们还使用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()

请注意,在训练过程中,每次保存模型(这里,每个轮)时,它都会在后台上传到Hub。这样,如果需要,您就可以在另一台机器上恢复训练。整个训练需要一段时间(在Titan RTX上大约需要一个多小时),因此您可以在此过程中喝杯咖啡或重新阅读课程中您觉得更有挑战性的部分。还要注意,一旦第一个轮结束,您将看到一些权重上传到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 执行相同操作。

自定义训练循环

现在让我们看看完整的训练循环,以便您可以轻松自定义所需的部件。它看起来很像第 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
)

正如您从前面的部分了解到的,只有在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的经典迭代,然后是模型的前向传递,然后是反向传递和优化器步骤。
  • 评估,在其中我们在将它们转换为NumPy数组之前收集start_logitsend_logits的所有值。评估循环完成后,我们将所有结果连接起来。请注意,我们需要进行截断,因为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'}

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