NLP Course documentation

抽取式问答问答

Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

抽取式问答问答

Open In Colab Open In Studio Lab

现在我们来看看问答这个任务!这个任务有很多种类型,但我们在本节将要关注的是称为 抽取式(extractive) 问题回答的形式。会有一些问题和文档,其中答案就在文档段落之内。

我们将使用 SQuAD 数据集 微调一个 BERT 模型,其中包括群众工作者对一组维基百科文章提出的问题。以下是一个小的测试样例:

本节使用的代码已经上传到了 Hub。你可以在 这里 找到它并尝试用它进行预测。

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

准备数据

作为抽取式问题回答的学术基准最常用的数据集是 SQuAD ,所以我们在这里将使用它。还有一个更难的 SQuAD v2 基准,其中包含一些没有答案的问题。你也可以使用自己的数据集,只要你自己的数据集包含了 Context 列、问题列和答案列,应该也能够适用下面的步骤。

SQuAD 数据集

像往常一样,我们可以使用 load_dataset() 在一行中下载和缓存数据集:

from datasets import load_dataset

raw_datasets = load_dataset("squad")

我们可以查看这个 raw_datasets 对象来了解关于 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 字段包含了 Context 中每个答案开始的索引。

在训练过程中,只有一个可能的答案。我们也可以使用 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 metric 帮我们完成,但简单来说,一些问题可能有多个可能的答案,而该评估代码将把预测的答案与所有可接受的答案进行比较,并选择最佳分数。例如,让我们看一下索引为 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?'

我们可以看到,答案的确可能是我们之前看到的三个可能选择 ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'] 的之一。

处理训练数据

我们从预处理训练数据开始。最困难的部分将是生成问题答案的位置,即找到 Context 中对应答案 token 的起始和结束位置。

但我们不要急于求成。首先,我们需要使用 tokenizer 将输入中的文本转换为模型可以理解的 ID:

from transformers import AutoTokenizer

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

如前所述,我们将对 BERT 模型进行微调,但你可以使用任何其他模型类型,只要它实现了快速 tokenizer 即可。你可以在 支持快速 tokenizer 的框架 表中看到所有带有快速版本的架构,要检查你正在使用的 tokenizer 对象是否真的是由🤗 Tokenizers 支持的,你可以查看它的 is_fast 属性:

tokenizer.is_fast
True

我们可以将 question 和 context 一起传递给我们的 tokenizer 它会正确插入特殊 tokens 形成如下句子:

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

需要预测的是答案起始和结束 token 的索引,模型的任务是为输入中的每个标记预测一个起始和结束的 logit 值,理论上的预测的结果如下所示:

One-hot encoded labels for question answering.

在做个例子中,Context 没有很长,但是数据集中的一些示例的 Context 会很长,会超过我们设置的最大长度(本例中为 384)。正如我们在 第六章 中所看到的,当我们探索 question-answering 管道的内部结构时,我们会通过将一个样本的较长的 Context 划分成多个片段,并在这些片段之间使用滑动窗口,来处理较长的 Context。

要了解在这个过程中对当前的训练样本进行了哪些处理,我们可以将长度限制为 100,并使用长度为 50 的 token 窗口。我们将设置以下的参数:

  • max_length 来设置最大长度 (这里为 100)
  • truncation="only_second" 在问题和 Context 过长时截断 Context(Context 位于第二个位置,第一个是 Question)
  • stride 设置两个连续块之间的重叠 tokens 数 (这里为 50)
  • return_overflowing_tokens=True 告诉 tokenizer 我们想要保留超过长度的 tokens
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]'

如我们所见,示例文本被拆分成四个输入,每个输入都包含问题和 Context 的一部分。请注意,问题的答案 (“Bernadette Soubirous”) 仅出现在第三个和最后一个片段中,因此通过以这种方式处理较长的 Context 时,我们可能创建一些 Context 中不包含答案的训练样本。我们把这些样本的标签设置为 start_position = end_position = 0 (这样的话,实际上我们的答案指向了 [CLS] tokens)。如果答案被截断,那么只在这一部分预测答案的起始(或结束)的token 的索引。对于答案完全在 Context 中的示例,标签将是答案起始的 token 的索引和答案结束的 token 的索引。

数据集为我们提供了 Context 中答案的起始的位置索引,加上答案的长度,我们可以找到 Context 中的结束索引。要将它们映射到 tokens 索引,我们将需要使用我们在 第六章 中学到的偏移映射。我们可以通过使用 return_offsets_mapping=True,让我们的 tokenizer 返回偏移后的映射:

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

如我们所见,我们得到了 inputs ID、tokens 类型 ID 和注意力掩码,以及我们所需的偏移映射和一个额外的 overflow_to_sample_mapping 。当我们同时对多个文本并行 tokenize 时,为了从支持 Rust 中受益,这个键的值对我们很有用。由于一个长的样本可以切分为多个短的样本,它保存了这些短的样本是来自于哪个长的样本。因为这里我们只对一个样本进行了 tokenize,所以我们得到一个由 0 组成的列表:

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

但是,如果我们对更多的示例进行 tokenize ,它会变得更加有用:

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 处) 每条数据被拆分为4个样本,最后一条数据(在训练集中的索引 5 处) 拆分为了5个样本。

这些信息将有助于将我们拆分后的文本块映射到其相应的标签。如前所述,这些标签的规则是:

  • 如果答案不在相应上下文的范围内,则为 (0, 0)
  • 如果答案在相应上下文的范围内,则为 (start_position, end_position) ,其中 start_position 是答案起始处的 token 索引(在 inputs ID 中), end_position 是答案结束处的 token 索引(在 inputs ID 中)

为了确定这两种情况中的哪一种,并且如果是第二种,则需要确定 token 的位置,我们首先找到在输入 ID 中起始和结束上下文的索引。我们首先找到拆分后的每一个部分在 Context 起始和结束的索引,可以使用 token 类型 ID 来完成此操作,但由于并非所有模型都支持这样的操作(如DistilBERT),因此可以使用 tokenizersequence_ids() 函数返回的 BatchEncoding 对象。

有了这些 tokens 的索引之后,我们就可以计算相应的偏移量了,它们是两个整数的元组,表示原始 Context 中的字符范围。因此,我们可以检测每个分块中的 Context 块是在答案之后起始还是在答案起始之前结束(在这种情况下,标签是 (0, 0) )。如果答案就在 Context 里,我们就循环查找答案的第一个和最后一个 token:

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)

    # 找到上下文的起始和结束
    idx = 0
    while sequence_ids[idx] != 1:
        idx += 1
    context_start = idx
    while sequence_ids[idx] == 1:
        idx += 1
    context_end = idx - 1

    # 如果答案不完全在上下文内,标签为(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:
        # 否则,它就是起始和结束 token 的位置
        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(包括 85)的 tokens 解码的结果进行比较:

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

确实,我们在 Context 中没有看到答案。

✏️ 轮你来了! 在使用 XLNet 架构时,如果截取后的文本长度没有达到设定的最大长度,需要在左侧进行填充,并且需要交互问题和 Context 的顺序。尝试将我们刚刚看到的所有代码调整为 XLNet 架构(并添加 padding=True )。请注意,因为是在左侧填充的,所以填充后的 [CLS] tokens 可能不在索引为 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)

        # 找到上下文的起始和结束
        idx = 0
        while sequence_ids[idx] != 1:
            idx += 1
        context_start = idx
        while sequence_ids[idx] == 1:
            idx += 1
        context_end = idx - 1

        # 如果答案不完全在上下文内,标签为(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:
            # 否则,它就是起始和结束 tokens 的位置
            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

请注意,我们定义了两个常量来确定所使用的最大长度以及滑动窗口的长度,并且在之前 tokenize 之前对数据进行了一些清洗:SQuAD 数据集中的一些问题在开头和结尾有额外的空格,这些空格没有任何意义(如果你使用像 RoBERTa 这样的模型,它们会占用 tokenize 的长度),因此我们去掉了这些额外的空格。

要使用该函数处理整个训练集,我们可以使用 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 个样本。我们的训练集现在已经准备好使用了——让我们深入研究一下验证集的预处理!

处理验证数据

验证集的预处理会更加容易,因为我们不需要生成标签(除非我们想计算验证损失,但那个数字并不能真正帮助我们了解模型的好坏,如果要评估模型更好的方式使用我们之前提到的squad 指标)。真正的挑战在于将模型的预测转化为为原始 Context 的片段。为此,我们只需要存储偏移映射并且找到一种方法来将每个分割后的样本与分割前的原始片段匹配起来。由于原始数据集中有一个 ID 列,我们可以使用ID来代表原始的片段。

我们唯一需要做的是对偏移映射进行一些微小修改。偏移映射包含问题和 Context 的偏移量(问题的偏移量是0,Context 是1),但当我们进入后处理阶段,我们将无法知道 inputs ID 的哪个部分对应于 Context,哪个部分是问题(我们使用的 sequence_ids() 方法仅可用于 tokenizer 的输出)。因此,我们将将与问题对应的偏移设置为 None Context 对应的偏移量保持不变:

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)

从最终的结果来看,我们只添加了几百个样本,因此验证数据集中的 Context 似乎要短一些。

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

使用 Trainer API 微调模型

这个例子的训练代码与前面的部分非常相似,最困难的部分是编写 compute_metrics() 评估指标函数。由于我们将所有样本填充到了我们设置的最大长度,所以没有需要定义的数据整理器,因此我们唯一需要担心的事情是如何计算评估指标。比较困难的部分将是将模型预测的结果还原到原始示例中的文本片段;一旦我们完成了这一步骤,🤗 Datasets 库中的 metric 就可以帮助我们做大部分工作。

后处理

模型将输出答案在 inputs ID 中起始和结束位置的 logit,正如我们在探索 question-answering pipeline 时看到的那样。后处理步骤与我们在那里所做的很相似,所以这里简单回顾一下我们所采取的操作:

  • 我们屏蔽了除了 Context 之外的 tokens 对应的起始和结束 logit。
  • 然后,我们使用 softmax 将起始和结束 logits 转换为概率。
  • 我们通过将两个概率对应的乘积来为每个 (start_token, end_token) 对计算一个分数。
  • 我们寻找具有最大分数且产生有效答案(例如, start_token 小于 end_token )的对。

这次我们将稍微改变这个流程,因为我们不需要计算实际分数(只需要预测的答案的文本)。这意味着我们可以跳过 softmax 步骤(因为 softmax 并不会改变分数大小的排序)。为了加快计算速度,我们也不会为所有可能的 (start_token, end_token) 对计算分数,而只会计算与最高的 n_best 对应的 logit 分数(其中 n_best=20 )。由于我们将跳过 softmax,这些分数将是 logit 分数,而且是起始和结束对数概率的和(而不是乘积,因为对数运算规则: ($\log(ab) = \log(a) + \log(b))$。

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

为了测试这些代码,我们需要一些预测结果。由于我们还没有训练模型,我们将使用 QA pipeline 的默认模型在验证集的一小部分上生成一些预测结果。我们可以使用与之前相同的处理函数;因为它依赖于全局常量 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

tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)

然后我们移除 eval_set 中模型不需要的列,构建一个包含所有小型验证集数据的 batch,并将其传递给模型。如果有可用的 GPU,我们将使用 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)

为了便于实验,让我们将这些输出转换为 NumPy 数组:

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 个起始 logit 和结束 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:
                # 跳过不完全在上下文中的答案
                if offsets[start_index] is None or offsets[end_index] is None:
                    continue
                # 跳过长度为负数或大于 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 和可能答案的字典列表)的参考答案:

该评估指标需要一个由样本 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 的论文 所述,DistilBERT 在 SQuAD 上微调后整体数据集的得分为 79.1 和 86.9,相比之下我们取得的结果相当不错。

现在,让我们将刚才所做的放入 compute_metrics() 函数中,就可以在 Trainer 中使用它了。通常, compute_metrics() 函数只接收一个包含 logits 和带预测标签组成的 eval_preds 元组。但是在这里,我们需要更多的信息才能评估结果,因为我们需要在分割后的数据集中查找偏移量,并在原始数据集中查找原始 Context,因此我们无法在训练过程中使用此函数来获取常规的评估结果。我们只会在训练结束时使用它来检查训练的结果。 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 = []

        # 循环遍历与该示例相关联的所有特征
        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:
                    # 跳过不完全位于上下文中的答案
                    if offsets[start_index] is None or offsets[end_index] is None:
                        continue
                    # 跳过长度小于 0 或大于 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)

        # 选择得分最高的答案
        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。如果你在 Notebook 中运行此代码,则可以使用以下的函数执行此操作,该函数会显示一个小部件,你可以在其中输入登录凭据进行登陆:

from huggingface_hub import notebook_login

notebook_login()

如果你不在 Notebook 中工作,只需在终端中输入以下行:

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 进行训练,你需要将所有这些代码移到一个训练函数中,不要在 Colab Notebook 的单元格中直接实例化 Accelerator 对象。这是因为在 TPU 环境下,直接在单元格中实例化可能会导致资源分配和初始化的问题。此外我们还可以通过向 Accelerator 传递 fp16=True 来强制使用混合精度训练(或者,如果你想要将代码作为脚本执行,只需确保填写正确的🤗 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 中的所有内容。这将帮助我们在每个时期结束时上传中间模型。

训练循环

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

  • 训练本身,即对 train_dataloader 进行迭代,模型前向传播、反向传播和优化器更新。

  • 评估,我们将遍历整个评估数据集,同时收集 start_logitsend_logits 中的所有值。完成评估循环后,我们会将所有结果汇总到一起。需要注意的是,由于 Accelerator 可能会在最后添加一些额外的样本,以确保每个进程中的样本数量相同,因此我们需要对这些数据进行截断,以防止多余样本影响最终结果。

  • 保存和上传,首先保存模型和 Tokenizer,然后调用 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):
    # 训练
    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)

    # 评估
    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)

    # 保存和上传
    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

# 将其替换为你自己的 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'}

很棒!我们的模型与 pipeline 的默认模型一样有效!

< > Update on GitHub