代码示例 / 自然语言处理 / 使用 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_answersFalse,我们也可以直接从训练集中丢弃这些示例。由于预处理已经足够复杂,为了简单起见,我们在此部分保持简单。

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() 辅助方法,旨在帮助您处理数据不易转换为数组的情况,例如当数据具有可变序列长度或太大而无法放入内存时。此方法在底层 🤗 Dataset 周围包装一个 tf.data.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 范围内的值获得的,并且训练可能会在 1e-3 的默认 Adam 学习率下完全发散。

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")

如果您有非基于 Transformer 的 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