作者: Matthew Carrigan 和 Merve Noyan
创建日期 13/01/2022
最后修改日期 13/01/2022
描述: 使用 Keras 和 Hugging Face Transformers 实现问答。
问答是常见的自然语言处理(NLP)任务,有几种不同的变体。在某些变体中,任务是多项选择:每个问题都提供了一系列可能的答案,模型只需返回对这些选项的概率分布。一种更具挑战性的问答变体,也更适用于实际任务,是未提供选项的情况。在这种情况下,模型会获得一个输入文档(称为上下文)和一个关于该文档的问题,它必须从文档中提取包含答案的文本片段(span)。在这种情况下,模型计算的不是答案的概率分布,而是文档文本中代表答案开始和结束的两个概率分布。这种变体被称为“抽取式问答”。
抽取式问答是一个非常具有挑战性的自然语言处理任务,当问题和答案是自然语言时,从头开始训练这样的模型所需的数据集规模巨大得令人望而却步。因此,问答(就像几乎所有自然语言处理任务一样)从强大的预训练基础模型中受益匪浅——从强大的预训练语言模型开始可以将达到给定准确率所需的数据集规模降低多个数量级,使您能够以令人惊讶的合理数据集达到非常强大的性能。
然而,从预训练模型开始会增加一些困难——您从哪里获取模型?如何确保您的输入数据与原始模型以相同的方式进行预处理和分词?如何修改模型以添加一个与您感兴趣的任务相匹配的输出头部?
在此示例中,我们将向您展示如何从 Hugging Face 🤗Transformers 库加载模型以应对此挑战。我们还将从 🤗Datasets 库加载一个基准问答数据集——这是另一个开源仓库,包含从自然语言处理到视觉等多种模态的广泛数据集。但请注意,这两个库并非必须一起使用。如果您想使用自己的数据训练来自 🤗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
,包含用于训练集、验证集和测试集的键。我们可以看到训练集、验证集和测试集都有一个用于上下文、问题和这些问题答案的列。要访问实际元素,您需要先选择一个分割(split),然后提供索引。正如我们上面提到的,答案由其在文本中的开始位置和完整文本(它是上下文的一个子字符串)表示。让我们看看一个训练示例是什么样的。
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 VRAM 可供挥霍的读者。
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()
辅助方法,旨在帮助您处理无法轻松转换为数组的数据,例如序列长度可变或数据过大无法全部加载到内存中时。此方法在底层 🤗 Dataset 周围包装了一个 tf.data.Dataset
,从底层数据集中流式传输样本并动态分批,从而最大限度地减少因不必要的填充而浪费的内存和计算。如果您的用例需要此功能,请参阅有关 to_tf_dataset 和 data collator 的文档以获取示例。如果不需要,请随意按照此示例直接转换为字典!
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。通过推送此模型,您将获得:
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