代码示例 / 自然语言处理 / Question Answering with Hugging Face Transformers

Question Answering with Hugging Face Transformers

作者: Matthew Carrigan 和 Merve Noyan
创建日期: 13/01/2022
最后修改: 13/01/2022

在 Colab 中查看 GitHub 源代码

描述: 使用 Keras 和 Hugging Face Transformers 实现问题回答。


问题回答简介

问题回答是一个常见的 NLP 任务,有几种变体。在某些变体中,任务是多项选择: 每个问题提供一系列可能的答案,模型只需返回选项的概率分布。问题回答的另一种更具挑战性的变体,更适用于现实任务,是在未提供选项的情况下进行的。在这种情况下,模型得到一个输入文档——称为上下文——以及一个关于该文档的问题,它必须提取文档中包含答案的文本范围。在这种情况下,模型并不是计算答案的概率分布,而是计算文档文本中标记的两个概率分布,分别表示包含答案的范围的开始和结束。该变体称为“提取式问题回答”。

提取式问题回答是一个非常具有挑战性的 NLP 任务,要求从头开始训练这样的模型所需的数据集规模巨大。因此,问题回答(和几乎所有 NLP 任务一样)对此非常有利于从强大的预训练基础模型开始——从强大的预训练语言模型开始,可以将达到特定精度所需的数据集规模减少几个数量级,使您能够以令人惊讶的合理数据集达到非常强的性能。

不过,从预训练模型开始增加了困难——您从哪里获取模型?您如何确保输入数据经过预处理和分词与原始模型相同?您如何修改模型以添加与您的兴趣任务匹配的输出头?

在本例中,我们将向您展示如何从 Hugging Face 🤗Transformers 库加载模型以应对这一挑战。我们还将从 🤗Datasets 库加载基准问题回答数据集——这是另一个开源库,其中包含许多模态下的各种数据集,从 NLP 到视觉等。请注意,这些库之间并没有必须彼此结合使用的要求。如果您想在自己的数据上训练一个 🤗Transformers 模型,或者您想从 🤗 Datasets 加载数据并使用它训练自己完全不相关的模型,当然是可以的(并且非常鼓励!)

安装要求

!pip install git+https://github.com/huggingface/transformers.git
!pip install datasets
!pip install huggingface-hub

加载数据集

我们将使用 🤗 Datasets 库下载 SQUAD 问题回答数据集,使用 load_dataset()

from datasets import load_dataset

datasets = load_dataset("squad")

datasets 对象本身是一个 DatasetDict,其中包含一个用于训练、验证和测试集的键。我们可以看到,训练、验证和测试集中都有上下文、问题和这些问题的答案的列。要访问一个实际元素,您需要先选择一个划分,然后给出一个索引。我们可以看到,答案通过其在文本中的起始位置及其完整文本表示,即前面提到的上下文的子串。让我们看看一个单独的训练示例是什么样的。

print(datasets["train"][0])
{'id': '5733be284776f41900661182', 'title': 'University_of_Notre_Dame', 'context': '在建筑风格上,这所学校具有天主教特征。主楼金色圆顶上方是圣母玛利亚的金色雕像。主楼正前方是一个里程碑性的铜像,耶稣基督手臂高举,铭文为“Venite Ad Me Omnes”。主楼旁边是圣心大教堂。大教堂的正后方是洞穴,这是一个玛利亚的祷告与反思的地方。它是法国卢尔德的洞穴的复制品,传说圣母玛利亚在1858年出现在圣女伯尔纳黛特·苏比露斯面前。在主道的尽头(并在连接着三座雕像和金色圆顶的直线上),是一座简单、现代的玛利亚石雕像。', 'question': '圣母玛利亚在1858年在法国卢尔德显现给了谁?', 'answers': {'text': ['圣女伯尔纳黛特·苏比露斯'], 'answer_start': [515]}}

预处理训练数据

在将这些文本输入到我们的模型之前,我们需要对它们进行预处理。这个过程是由一个 🤗 Transformers 的 Tokenizer 完成的,它将(正如名称所示)对输入进行分词(包括将标记转换为预训练词汇表中的相应 ID),并将其放入模型期望的格式中,以及生成模型所需的其他输入。

为了做到这一点,我们使用 AutoTokenizer.from_pretrained 方法实例化我们的 tokenizer,这将确保:

  • 我们得到一个与我们想要使用的模型架构相对应的分词器。
  • 我们下载在预训练这个特定检查点时使用的词汇表。

该词汇表将被缓存,因此在下次运行该单元时不会再次下载。

from_pretrained() 方法需要一个模型的名称。如果你不确定选择哪个模型,不要惊慌!可供选择的模型列表可能令人困惑,但通常有一个简单的权衡:较大的模型速度较慢,消耗更多内存,但通常在微调后会略微提高最终准确率。在这个例子中,我们选择了(相对)轻量的 "distilbert",这是著名的 BERT 语言模型的一个较小、蒸馏的版本。然而,如果你绝对需要在重要任务中获得尽可能高的准确率,并且有 GPU 内存(和空闲时间)来处理它,你可能会更倾向于使用更大的模型,比如 "roberta-large"。在 🤗 Transformers 中还存在比 "roberta" 更新和更大的模型,但我们将寻找和训练它们的任务留给那些特别受虐狂或有 40GB VRAM 可随意使用的读者。

from transformers import AutoTokenizer

model_checkpoint = "distilbert-base-cased"

tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
正在下载:   0%|          | 0.00/29.0 [00:00<?, ?B/s]

正在下载:   0%|          | 0.00/411 [00:00<?, ?B/s]

正在下载:   0%|          | 0.00/208k [00:00<?, ?B/s]

正在下载:   0%|          | 0.00/426k [00:00<?, ?B/s]

根据你选择的模型,你将在上面的单元返回的字典中看到不同的键。对于我们在这里要做的事情,它们并不是很重要(只需知道它们是我们稍后将实例化的模型所需的),但如果你感兴趣,可以在这个教程中了解更多信息。

在问答的预处理中特别需要解决的一个问题是如何处理非常长的文档。我们通常在其他任务中截断它们,当它们超过模型的最大句子长度时,但在这里,删除部分上下文可能会导致我们失去正在寻找的答案。为了解决这个问题,我们将允许我们数据集中一个(长)示例提供多个输入特征,每个特征的长度短于模型的最大长度(或我们设置的超参数)。此外,以防答案位于我们分割长上下文的地方,我们允许生成的特征之间有一些重叠,由超参数 doc_stride 控制。

如果我们简单地用固定大小(max_length)进行截断,我们将失去信息。我们希望避免截断问题,而只是截断上下文,以确保任务仍然可以解决。为此,我们将 truncation 设置为 "only_second",这样每对中的第二个序列(上下文)就只会被截断。为了获得受最大长度限制的特征列表,我们需要将 return_overflowing_tokens 设置为 True,并将 doc_stride 传递给 stride。要查看原始上下文的哪个特征包含答案,我们可以返回 "offset_mapping"

max_length = 384  # 特征的最大长度(问题和上下文)
doc_stride = (
    128  # 分割时上下文两个部分之间的授权重叠
)
# 这是必须的。

在没有可能答案的情况下(答案位于由长上下文给出的示例提供的其他特征中),我们将起始位置和结束位置的 cls 索引都设置为此。我们也可以简单地将这些示例从训练集中丢弃,如果标志 allow_impossible_answersFalse。由于预处理本身就已经复杂得足够,我们对这一部分保持简单。

def prepare_train_features(examples):
    # 使用截断和填充对我们的示例进行标记化,但使用步幅保留溢出部分。这导致一个示例在上下文较长时可能生成多个特征,
    # 每个特征的上下文都与前一个特征的上下文有一点重叠。
    examples["question"] = [q.lstrip() for q in examples["question"]]
    examples["context"] = [c.lstrip() for c in examples["context"]]
    tokenized_examples = tokenizer(
        examples["question"],
        examples["context"],
        truncation="only_second",
        max_length=max_length,
        stride=doc_stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length",
    )

    # 由于一个示例可能会在上下文较长时生成多个特征,因此我们需要从特征到其对应示例的映射。这个键正好为我们提供了这一点。
    sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
    # 偏移映射将为我们提供从标记到原始上下文中的字符位置的映射。这将帮助我们计算开始位置和结束位置。
    offset_mapping = tokenized_examples.pop("offset_mapping")

    # 让我们对这些示例进行标记!
    tokenized_examples["start_positions"] = []
    tokenized_examples["end_positions"] = []

    for i, offsets in enumerate(offset_mapping):
        # 我们将用CLS标记的索引标记不可能的答案。
        input_ids = tokenized_examples["input_ids"][i]
        cls_index = input_ids.index(tokenizer.cls_token_id)

        # 获取与该示例相对应的序列(以了解上下文和问题是什么)。
        sequence_ids = tokenized_examples.sequence_ids(i)

        # 一个示例可以给出多个跨度,这是包含此文本跨度的示例的索引。
        sample_index = sample_mapping[i]
        answers = examples["answers"][sample_index]
        # 如果没有给出答案,将cls_index作为答案。
        if len(answers["answer_start"]) == 0:
            tokenized_examples["start_positions"].append(cls_index)
            tokenized_examples["end_positions"].append(cls_index)
        else:
            # 文本中答案的起始/结束字符索引。
            start_char = answers["answer_start"][0]
            end_char = start_char + len(answers["text"][0])

            # 当前跨度在文本中的起始标记索引。
            token_start_index = 0
            while sequence_ids[token_start_index] != 1:
                token_start_index += 1

            # 当前跨度在文本中的结束标记索引。
            token_end_index = len(input_ids) - 1
            while sequence_ids[token_end_index] != 1:
                token_end_index -= 1

            # 检测答案是否在跨度之外(在这种情况下,该特征标记为CLS索引)。
            if not (
                offsets[token_start_index][0] <= start_char
                and offsets[token_end_index][1] >= end_char
            ):
                tokenized_examples["start_positions"].append(cls_index)
                tokenized_examples["end_positions"].append(cls_index)
            else:
                # 否则,将token_start_index和token_end_index移动到答案的两个端点。
                # 注意:如果答案是最后一个词(边缘情况),我们可以在最后一个偏移量之后去。
                while (
                    token_start_index < len(offsets)
                    and offsets[token_start_index][0] <= start_char
                ):
                    token_start_index += 1
                tokenized_examples["start_positions"].append(token_start_index - 1)
                while offsets[token_end_index][1] >= end_char:
                    token_end_index -= 1
                tokenized_examples["end_positions"].append(token_end_index + 1)

    return tokenized_examples

要将此函数应用于我们数据集中的所有句子(或句子对),我们只需使用 Dataset 对象的 map() 方法,该方法将对所有元素应用该函数。

我们将使用 batched=True 来一起批量编码文本。这是为了充分利用我们之前加载的快速分词器的全部好处,该分词器将使用多线程并发处理批量中的文本。我们还使用 remove_columns 参数来删除在应用分词之前存在的列 - 这确保唯一剩下的特征是我们实际想要传递给模型的特征。

tokenized_datasets = datasets.map(
    prepare_train_features,
    batched=True,
    remove_columns=datasets["train"].column_names,
    num_proc=3,
)

更棒的是,🤗 Datasets 库会自动缓存结果,以避免下次运行笔记本时花费时间在此步骤上。🤗 Datasets 库通常足够智能,可以检测您传递给 map 的函数是否已更改(因此需要不使用缓存数据)。例如,它会正确检测到如果您在第一单元中更改了任务并重新运行笔记本。🤗 Datasets 在使用缓存文件时会提醒您,您可以在调用 map() 时传递 load_from_cache_file=False 来不使用缓存文件并强制重新应用预处理。

由于我们所有的数据都被填充或截断到相同的长度,并且数据量也不大,我们现在可以简单地将其转换为一个准备训练的 numpy 数组字典。

尽管我们在这里不会使用它,但 🤗 Datasets 有一个 to_tf_dataset() 辅助方法,旨在帮助您在数据不能轻松转换为数组时,例如当数据具有可变序列长度或太大而无法放入内存时。此方法将一个 tf.data.Dataset 包装在底层的 🤗 Dataset 周围,从底层数据集中流式传输样本并实时进行批处理,从而最小化由于不必要的填充而浪费的内存和计算。如果您的用例需要,请查看 docs 中关于 to_tf_dataset 和数据收集器的示例。如果不需要,请随意按照此示例简单地转换为字典!

train_set = tokenized_datasets["train"].with_format("numpy")[
    :
]  # 将整个数据集加载为 numpy 数组字典
validation_set = tokenized_datasets["validation"].with_format("numpy")[:]

微调模型

这可真是很多工作!但现在我们的数据已准备好,一切将顺利进行。首先,我们下载预训练模型并对其进行微调。由于我们的任务是问答,因此我们使用 TFAutoModelForQuestionAnswering 类。与分词器一样,from_pretrained() 方法将为我们下载并缓存模型:

from transformers import TFAutoModelForQuestionAnswering

model = TFAutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
正在下载:   0%|          | 0.00/338M [00:00<?, ?B/s]

某些来自于 distilbert-base-cased 的模型检查点的层在初始化 TFDistilBertForQuestionAnswering 时未被使用: ['vocab_transform', 'activation_13', 'vocab_projector', 'vocab_layer_norm']
- 如果您是从另一个任务或架构的模型检查点初始化 TFDistilBertForQuestionAnswering,这是可以预期的(例如,从 BertForPreTraining 模型初始化 BertForSequenceClassification 模型)。
- 如果您是从您希望完全相同的模型检查点初始化 TFDistilBertForQuestionAnswering,这就不是预期的(从一个 BertForSequenceClassification 模型初始化另一个 BertForSequenceClassification 模型)。
某些 TFDistilBertForQuestionAnswering 的层未从 distilbert-base-cased 的模型检查点初始化并且是新初始化的: ['dropout_19', 'qa_outputs']
您可能应该在下游任务上训练此模型,以便能够用于预测和推理。

这个警告告诉我们,我们丢弃了一些权重,并重新初始化了一些其他权重。别担心!这是完全正常的。回想一下,像 BERT 和 Distilbert 这样的模型是基于语言建模任务进行训练的,但我们加载模型为 TFAutoModelForQuestionAnswering,这意味着我们希望模型执行一个问答任务。这个变化需要移除并替换最终输出层或“头”,以适应新任务。from_pretrained 方法将为我们处理这一切,警告只是提醒我们进行了一些模型调整,并且模型在新初始化的层在某些数据上进行微调之前不会生成有用的预测。

接下来,我们可以创建一个优化器并指定一个损失函数。通常您可以得到 使用学习率衰减和分离权重衰减可以略微提高性能,但在本示例中,标准的 Adam 优化器就可以很好地工作。请注意,然而,当微调预训练的变换器模型时,通常希望使用较低的学习率!我们发现最佳结果的学习率范围是在1e-5到1e-4之间,而在默认的Adam学习率1e-3下,训练可能会完全发散。

import tensorflow as tf
from tensorflow import keras

optimizer = keras.optimizers.Adam(learning_rate=5e-5)

现在我们只需编译和拟合模型。出于便利考虑,所有 🤗 Transformers 模型都带有一个默认损失,匹配它们的输出头,尽管您当然可以使用自己的损失函数。由于内置损失在向前传递过程中内部计算,因此使用时可能会发现某些Keras指标表现不佳或给出意外输出。这是 🤗 Transformers 中一个非常活跃的开发领域,因此希望我们很快会对此问题有一个好的解决方案!

不过现在,我们使用内置损失而不使用任何指标。要获取内置损失,只需在 compile 中省略 loss 参数。

# 可选地取消注释下一行以进行 float16 训练
keras.mixed_precision.set_global_policy("mixed_float16")

model.compile(optimizer=optimizer)
INFO:tensorflow:混合精度兼容性检查 (mixed_float16): OK
您的 GPU 在 dtype 策略 mixed_float16 下可能运行得很快,因为它的计算能力至少为 7.0。您的 GPU: Tesla V100-SXM2-16GB, 计算能力 7.0

compile() 中未指定损失 - 将使用模型的内部损失计算作为损失。别慌 - 这是在 Transformers 中训练 TensorFlow 模型的常见方法!请确保您的标签作为键传递到输入字典中,以便在向前传递期间可以访问它们。要禁用此行为,请传递损失参数,或者如果您不希望模型计算损失,则显式传递 loss=None。

现在我们可以训练模型了。注意我们没有传递单独的标签 - 标签作为输入字典中的键,使它们在向前传递期间对模型可见,以便计算内置损失。

model.fit(train_set, validation_data=validation_set, epochs=1)
2773/2773 [==============================] - 1205s 431ms/step - loss: 1.5360 - val_loss: 1.1816

<keras.callbacks.History at 0x7f0b104fab90>

我们完成了!让我们试一试,从 keras.io 首页中使用一些文本:

context = """Keras是一个为人类设计的API,而不是机器。Keras遵循最佳实践,以减少认知负担:它提供一致且简单的API,最小化常见用例所需的用户操作数量,并提供清晰且可操作的错误消息。它还拥有广泛的文档和开发者指南。"""
question = "什么是Keras?"

inputs = tokenizer([context], [question], return_tensors="np")
outputs = model(inputs)
start_position = tf.argmax(outputs.start_logits, axis=1)
end_position = tf.argmax(outputs.end_logits, axis=1)
print(int(start_position), int(end_position[0]))
26 30

看来我们的模型认为答案是从标记1到12(包含)的跨度。没有奖品来猜测那些标记是什么!

answer = inputs["input_ids"][0, int(start_position) : int(end_position) + 1]
print(answer)
[ 8080   111  3014 20480  1116]

现在我们可以使用 tokenizer.decode() 方法将这些标记ID转换回文本:

print(tokenizer.decode(answer))
一致且简单的API

这就是全部!请记住,这个示例旨在快速运行,而不是最先进的,所训练的模型肯定会犯错误。如果您使用一个更大的模型作为训练基础,并花时间适当地调整超参数,您会发现可以获得更好的损失(以及相应更准确的答案)。

最后,您可以将模型推送到 HuggingFace Hub。通过推送这个模型,您将拥有:

  • 为您生成的漂亮模型卡,包含超参数和模型训练的指标,
  • 用于推理调用的 web API,
  • 在模型页面上的小部件,使其他人可以测试您的模型。 这个模型目前托管在 这里 我们为您准备了一个单独整洁的用户界面 这里
model.push_to_hub("transformers-qa", organization="keras-io")
tokenizer.push_to_hub("transformers-qa", organization="keras-io")

如果您有非Transformers基础的Keras模型,您也可以推送它们。 push_to_hub_keras。你可以使用 from_pretrained_keras 来轻松加载。

from huggingface_hub.keras_mixin import push_to_hub_keras

push_to_hub_keras(
    model=model, repo_url="https://huggingface.co/your-username/your-awesome-model"
)
from_pretrained_keras("your-username/your-awesome-model") # 加载你的模型