代码示例 / 自然语言处理 / 使用 Hugging Face Transformers 进行问答

使用 Hugging Face Transformers 进行问答

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

ⓘ 此示例使用 Keras 2

在 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 库通过 load_dataset() 下载 SQUAD 问答数据集。

from datasets import load_dataset

datasets = load_dataset("squad")

datasets 对象本身是一个 DatasetDict,它包含一个用于训练集、验证集和测试集的键。我们可以看到训练集、验证集和测试集都包含一个用于上下文、问题和这些问题答案的列。要访问实际的元素,您需要首先选择一个拆分,然后给出索引。我们可以看到答案由其在文本中的起始位置及其完整文本指示,如上所述,它时上下文的子字符串。让我们看看一个训练示例是什么样子的。

print(datasets["train"][0])
{'id': '5733be284776f41900661182', 'title': 'University_of_Notre_Dame', '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?', 'answers': {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}}

预处理训练数据

在将这些文本馈送到我们的模型之前,我们需要对其进行预处理。这是通过 🤗 Transformers 的 Tokenizer 完成的,它将(顾名思义)对输入进行标记化(包括将标记转换为其在预训练词汇表中的相应 ID)并将其置于模型期望的格式中,以及生成模型所需的其它输入。

要完成所有这些操作,我们使用 AutoTokenizer.from_pretrained 方法实例化我们的标记器,这将确保

  • 我们获得一个与我们想要使用的模型架构相对应的标记器。
  • 我们下载了预训练此特定检查点时使用的词汇表。

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

from_pretrained() 方法需要模型的名称。如果您不确定选择哪个模型,请不要惊慌!可供选择的模型列表可能令人眼花缭乱,但通常有一个简单的权衡:更大的模型速度较慢且消耗更多内存,但在微调后通常会产生略微更好的最终准确率。在本例中,我们选择了(相对)轻量级的 "distilbert",它是著名的 BERT 语言模型的一个较小、蒸馏后的版本。但是,如果您确实需要为一项重要任务获得最高的准确率,并且您有 GPU 内存(和空闲时间)来处理它,您可能更喜欢使用更大的模型,例如 "roberta-large"。在 🤗Transformers 中存在比 "roberta" 更新且更大的模型,但我们将查找和训练它们的这项任务留给那些特别喜欢受虐或有 40GB 显存可以随意使用的读者作为练习。

from transformers import AutoTokenizer

model_checkpoint = "distilbert-base-cased"

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

Downloading:   0%|          | 0.00/411 [00:00<?, ?B/s]

Downloading:   0%|          | 0.00/208k [00:00<?, ?B/s]

Downloading:   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  # The maximum length of a feature (question and context)
doc_stride = (
    128  # The authorized overlap between two part of the context when splitting
)
# it is needed.

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

def prepare_train_features(examples):
    # Tokenize our examples with truncation and padding, but keep the overflows using a
    # stride. This results in one example possible giving several features when a context is long,
    # each of those features having a context that overlaps a bit the context of the previous
    # feature.
    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",
    )

    # Since one example might give us several features if it has a long context, we need a
    # map from a feature to its corresponding example. This key gives us just that.
    sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
    # The offset mappings will give us a map from token to character position in the original
    # context. This will help us compute the start_positions and end_positions.
    offset_mapping = tokenized_examples.pop("offset_mapping")

    # Let's label those examples!
    tokenized_examples["start_positions"] = []
    tokenized_examples["end_positions"] = []

    for i, offsets in enumerate(offset_mapping):
        # We will label impossible answers with the index of the CLS token.
        input_ids = tokenized_examples["input_ids"][i]
        cls_index = input_ids.index(tokenizer.cls_token_id)

        # Grab the sequence corresponding to that example (to know what is the context and what
        # is the question).
        sequence_ids = tokenized_examples.sequence_ids(i)

        # One example can give several spans, this is the index of the example containing this
        # span of text.
        sample_index = sample_mapping[i]
        answers = examples["answers"][sample_index]
        # If no answers are given, set the cls_index as answer.
        if len(answers["answer_start"]) == 0:
            tokenized_examples["start_positions"].append(cls_index)
            tokenized_examples["end_positions"].append(cls_index)
        else:
            # Start/end character index of the answer in the text.
            start_char = answers["answer_start"][0]
            end_char = start_char + len(answers["text"][0])

            # Start token index of the current span in the text.
            token_start_index = 0
            while sequence_ids[token_start_index] != 1:
                token_start_index += 1

            # End token index of the current span in the text.
            token_end_index = len(input_ids) - 1
            while sequence_ids[token_end_index] != 1:
                token_end_index -= 1

            # Detect if the answer is out of the span (in which case this feature is labeled with the
            # CLS index).
            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:
                # Otherwise move the token_start_index and token_end_index to the two ends of the
                # answer.
                # Note: we could go after the last offset if the answer is the last word (edge
                # case).
                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 周围,从底层数据集中流式传输样本并动态地将它们批处理,从而最大限度地减少不必要填充造成的内存和计算浪费。如果您的用例需要它,请参阅 文档 中关于 to_tf_dataset 和数据整理器的示例。如果不是,请随时遵循此示例并简单地转换为字典!

train_set = tokenized_datasets["train"].with_format("numpy")[
    :
]  # Load the whole dataset as a dict of numpy arrays
validation_set = tokenized_datasets["validation"].with_format("numpy")[:]

微调模型

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

from transformers import TFAutoModelForQuestionAnswering

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

Some layers from the model checkpoint at distilbert-base-cased were not used when initializing TFDistilBertForQuestionAnswering: ['vocab_transform', 'activation_13', 'vocab_projector', 'vocab_layer_norm']
- This IS expected if you are initializing TFDistilBertForQuestionAnswering from the checkpoint of a model trained on another task or with another architecture (e.g. initializing a BertForSequenceClassification model from a BertForPreTraining model).
- This IS NOT expected if you are initializing TFDistilBertForQuestionAnswering from the checkpoint of a model that you expect to be exactly identical (initializing a BertForSequenceClassification model from a BertForSequenceClassification model).
Some layers of TFDistilBertForQuestionAnswering were not initialized from the model checkpoint at distilbert-base-cased and are newly initialized: ['dropout_19', 'qa_outputs']
You should probably TRAIN this model on a down-stream task to be able to use it for predictions and inference.

警告告诉我们我们正在丢弃一些权重并重新初始化其他一些权重。不要惊慌!这绝对是正常的。回想一下,像 BERT 和 Distilbert 这样的模型是在**语言建模**任务上训练的,但我们将模型加载为 TFAutoModelForQuestionAnswering,这意味着我们希望模型执行**问答**任务。此更改需要删除最终输出层或“头部”并替换为适合新任务的新头部。from_pretrained 方法将为我们处理所有这些,警告只是为了提醒我们已经执行了一些模型手术,并且在对一些数据进行微调之前,模型不会生成有用的预测。

接下来,我们可以创建一个优化器并指定损失函数。通常,通过使用学习率衰减和解耦权重衰减可以获得略微更好的性能,但出于本示例的目的,标准的 Adam 优化器可以正常工作。但是请注意,在微调预训练的 Transformer 模型时,通常需要使用较低的学习率!我们发现最佳结果是在 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 参数即可。

# Optionally uncomment the next line for float16 training
keras.mixed_precision.set_global_policy("mixed_float16")

model.compile(optimizer=optimizer)
INFO:tensorflow:Mixed precision compatibility check (mixed_float16): OK
Your GPU will likely run quickly with dtype policy mixed_float16 as it has compute capability of at least 7.0. Your GPU: Tesla V100-SXM2-16GB, compute capability 7.0

No loss specified in compile() - the model's internal loss computation will be used as the loss. Don't panic - this is a common way to train TensorFlow models in Transformers! Please ensure your labels are passed as keys in the input dict so that they are accessible to the model during the forward pass. To disable this behaviour, please pass a loss argument, or explicitly pass loss=None if you do not want your model to compute a loss.

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

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 is an API designed for human beings, not machines. Keras follows best
practices for reducing cognitive load: it offers consistent & simple APIs, it minimizes
the number of user actions required for common use cases, and it provides clear &
actionable error messages. It also has extensive documentation and developer guides. """
question = "What is 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))
consistent & simple APIs

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

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

  • 为您生成的不错的模型卡,其中包含模型训练的超参数和指标,
  • 用于推理调用的 Web API,
  • 模型页面中的一个小部件,使其他人能够测试您的模型。此模型当前托管在 此处,我们为您准备了一个单独的简洁 UI 此处
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://hugging-face.cn/your-username/your-awesome-model"
)
from_pretrained_keras("your-username/your-awesome-model") # load your model