代码示例 / 自然语言处理 / 使用 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 优化器可以正常工作。但是请注意,当微调预训练的转换器模型时,您通常需要使用较低的学习率!我们发现最佳结果是在 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