LLM 课程文档
问答
并获得增强的文档体验
开始
问答
现在来看看问答!这项任务有很多形式,但我们将在本节中重点关注的是抽取式问答。这包括对文档提出问题,并在文档本身中将答案识别为文本跨度。
我们将在 SQuAD 数据集上微调 BERT 模型,该数据集由众包工作者在一组维基百科文章上提出的问题组成。这将使我们获得一个能够计算如下预测的模型
这实际上是在展示使用本节中展示的代码训练并上传到 Hub 的模型。您可以在这里找到它并仔细检查预测。
💡 像 BERT 这样仅编码器的模型通常擅长提取事实性问题的答案,例如“谁发明了 Transformer 架构?”,但在被问到开放式问题,如“天空为什么是蓝色的?”时表现不佳。在这些更具挑战性的情况下,通常使用像 T5 和 BART 这样的编码器-解码器模型来综合信息,这与文本摘要非常相似。如果您对这种生成式问答感兴趣,我们建议您查看我们基于 ELI5 数据集的演示。
准备数据
作为抽取式问答的学术基准,使用最多的数据集是 SQuAD,所以我们将在此处使用它。还有一个更难的 SQuAD v2 基准,其中包括没有答案的问题。只要您自己的数据集包含上下文列、问题列和答案列,您就应该能够适应以下步骤。
SQuAD 数据集
和往常一样,得益于 load_dataset()
,我们只需一步即可下载并缓存数据集
from datasets import load_dataset
raw_datasets = load_dataset("squad")
然后我们可以查看此对象,以了解有关 SQuAD 数据集的更多信息
raw_datasets
DatasetDict({
train: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 87599
})
validation: Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 10570
})
})
看起来我们拥有了 context
、question
和 answers
字段所需的一切,所以让我们为训练集的第一个元素打印这些字段
print("Context: ", raw_datasets["train"][0]["context"])
print("Question: ", raw_datasets["train"][0]["question"])
print("Answer: ", raw_datasets["train"][0]["answers"])
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?'
Answer: {'text': ['Saint Bernadette Soubirous'], 'answer_start': [515]}
context
和 question
字段非常容易使用。answers
字段有点棘手,因为它包含一个字典,其中有两个字段都是列表。这是 squad
指标在评估期间期望的格式;如果您使用自己的数据,则不必担心将答案放入相同的格式。text
字段非常明显,而 answer_start
字段包含上下文中每个答案的起始字符索引。
在训练期间,只有一个可能的答案。我们可以通过使用 Dataset.filter()
方法来仔细检查这一点
raw_datasets["train"].filter(lambda x: len(x["answers"]["text"]) != 1)
Dataset({
features: ['id', 'title', 'context', 'question', 'answers'],
num_rows: 0
})
但是,对于评估,每个样本都有几个可能的答案,这些答案可能相同或不同
print(raw_datasets["validation"][0]["answers"])
print(raw_datasets["validation"][2]["answers"])
{'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}
{'text': ['Santa Clara, California', "Levi's Stadium", "Levi's Stadium in the San Francisco Bay Area at Santa Clara, California."], 'answer_start': [403, 355, 355]}
我们不会深入研究评估脚本,因为它将全部由 🤗 Datasets 指标为我们包装,但简短的版本是一些问题有几个可能的答案,此脚本会将预测的答案与所有可接受的答案进行比较,并取最佳分数。例如,如果我们看一下索引为 2 的样本
print(raw_datasets["validation"][2]["context"])
print(raw_datasets["validation"][2]["question"])
'Super Bowl 50 was an American football game to determine the champion of the National Football League (NFL) for the 2015 season. The American Football Conference (AFC) champion Denver Broncos defeated the National Football Conference (NFC) champion Carolina Panthers 24–10 to earn their third Super Bowl title. The game was played on February 7, 2016, at Levi\'s Stadium in the San Francisco Bay Area at Santa Clara, California. As this was the 50th Super Bowl, the league emphasized the "golden anniversary" with various gold-themed initiatives, as well as temporarily suspending the tradition of naming each Super Bowl game with Roman numerals (under which the game would have been known as "Super Bowl L"), so that the logo could prominently feature the Arabic numerals 50.'
'Where did Super Bowl 50 take place?'
我们可以看到,答案确实可以是我们在之前看到的三个可能性之一。
处理训练数据
让我们从预处理训练数据开始。困难的部分将是为问题的答案生成标签,这将是上下文中与答案对应的 token 的开始和结束位置。
但我们不要超前。首先,我们需要使用分词器将输入中的文本转换为模型可以理解的 ID
from transformers import AutoTokenizer
model_checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
如前所述,我们将微调 BERT 模型,但您可以使用任何其他模型类型,只要它实现了快速分词器。您可以在此大表中查看所有带有快速版本的架构,要检查您正在使用的 tokenizer
对象是否确实由 🤗 Tokenizers 支持,您可以查看其 is_fast
属性
tokenizer.is_fast
True
我们可以将问题和上下文一起传递给我们的分词器,它将正确插入特殊 token 以形成这样的句子
[CLS] question [SEP] context [SEP]
让我们仔细检查一下
context = raw_datasets["train"][0]["context"]
question = raw_datasets["train"][0]["question"]
inputs = tokenizer(question, context)
tokenizer.decode(inputs["input_ids"])
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] 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. [SEP]'
然后,标签将是开始和结束答案的 token 的索引,并且模型的任务是预测输入中每个 token 的一个开始和结束 logit,理论标签如下
在这种情况下,上下文不是太长,但是数据集中的某些示例具有非常长的上下文,这些上下文将超过我们设置的最大长度(在本例中为 384)。正如我们在第 6 章中探讨问答管道的内部结构时看到的那样,我们将通过从数据集的一个样本创建几个训练特征,并在它们之间使用滑动窗口来处理长上下文。
为了了解如何使用当前示例工作,我们可以将长度限制为 100,并使用 50 个 token 的滑动窗口。作为提醒,我们使用
max_length
设置最大长度(此处为 100)truncation="only_second"
在问题及其上下文太长时截断上下文(在第二个位置)stride
设置两个连续块之间重叠 token 的数量(此处为 50)return_overflowing_tokens=True
让分词器知道我们想要溢出的 token
inputs = tokenizer(
question,
context,
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
)
for ids in inputs["input_ids"]:
print(tokenizer.decode(ids))
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] 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 basi [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] 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 [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP] 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 [SEP]'
'[CLS] To whom did the Virgin Mary allegedly appear in 1858 in Lourdes France? [SEP]. 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. [SEP]'
正如我们所看到的,我们的示例已拆分为四个输入,每个输入都包含问题和上下文的某一部分。请注意,问题“Bernadette Soubirous”的答案仅出现在第三个和最后一个输入中,因此通过以这种方式处理长上下文,我们将创建一些训练示例,其中答案不包含在上下文中。对于这些示例,标签将是 start_position = end_position = 0
(因此我们预测 [CLS]
token)。在答案被截断以至于我们只有答案的开头(或结尾)的不幸情况下,我们也将设置这些标签。对于答案完全在上下文中的示例,标签将是答案开始的 token 的索引和答案结束的 token 的索引。
数据集为我们提供了上下文中答案的起始字符,通过添加答案的长度,我们可以在上下文中找到结束字符。为了将这些映射到 token 索引,我们将需要使用我们在第 6 章中研究的偏移映射。我们可以通过传递 return_offsets_mapping=True
让我们的分词器返回这些
inputs = tokenizer(
question,
context,
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
inputs.keys()
dict_keys(['input_ids', 'token_type_ids', 'attention_mask', 'offset_mapping', 'overflow_to_sample_mapping'])
正如我们所看到的,我们得到了通常的输入 ID、token 类型 ID 和注意力掩码,以及我们需要的偏移映射和一个额外的键 overflow_to_sample_mapping
。当我们同时对多个文本进行分词时(我们应该这样做以受益于我们的分词器由 Rust 支持的事实),相应的值将对我们有用。由于一个样本可以提供多个特征,因此它将每个特征映射到它源自的示例。因为这里我们只分词了一个示例,所以我们得到一个 0 的列表
inputs["overflow_to_sample_mapping"]
[0, 0, 0, 0]
但是,如果我们分词更多示例,这将变得更有用
inputs = tokenizer(
raw_datasets["train"][2:6]["question"],
raw_datasets["train"][2:6]["context"],
max_length=100,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
print(f"The 4 examples gave {len(inputs['input_ids'])} features.")
print(f"Here is where each comes from: {inputs['overflow_to_sample_mapping']}.")
'The 4 examples gave 19 features.'
'Here is where each comes from: [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3].'
正如我们所看到的,前三个示例(在训练集中的索引 2、3 和 4 处)每个都提供了四个特征,而最后一个示例(在训练集中的索引 5 处)提供了 7 个特征。
此信息将有助于将我们获得的每个特征映射到其对应的标签。如前所述,这些标签是
- (0, 0),如果答案不在上下文的相应跨度内
- (start_position, end_position),如果答案在上下文的相应跨度内,其中 start_position 是答案开始时 token(在输入 ID 中)的索引,end_position 是答案结束时 token(在输入 ID 中)的索引
为了确定是哪种情况,以及(如果相关)token 的位置,我们首先找到在输入 ID 中开始和结束上下文的索引。我们可以使用 token 类型 ID 来做到这一点,但由于这些 token 类型 ID 不一定存在于所有模型中(例如,DistilBERT 不需要它们),我们将改为使用我们的分词器返回的 BatchEncoding
的 sequence_ids()
方法。
一旦我们有了这些 token 索引,我们就会查看相应的偏移量,这些偏移量是表示原始上下文中字符跨度的两个整数的元组。因此,我们可以检测到此特征中上下文的块是否在答案之后开始或在答案开始之前结束(在这种情况下,标签为 (0, 0))。如果不是这种情况,我们循环查找答案的第一个和最后一个 token
answers = raw_datasets["train"][2:6]["answers"]
start_positions = []
end_positions = []
for i, offset in enumerate(inputs["offset_mapping"]):
sample_idx = inputs["overflow_to_sample_mapping"][i]
answer = answers[sample_idx]
start_char = answer["answer_start"][0]
end_char = answer["answer_start"][0] + len(answer["text"][0])
sequence_ids = inputs.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
start_positions, end_positions
([83, 51, 19, 0, 0, 64, 27, 0, 34, 0, 0, 0, 67, 34, 0, 0, 0, 0, 0],
[85, 53, 21, 0, 0, 70, 33, 0, 40, 0, 0, 0, 68, 35, 0, 0, 0, 0, 0])
让我们看一下一些结果,以验证我们的方法是否正确。对于第一个特征,我们发现 (83, 85) 作为标签,因此让我们将理论答案与从 83 到 85(包括 85)的已解码 token 跨度进行比较
idx = 0
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]
start = start_positions[idx]
end = end_positions[idx]
labeled_answer = tokenizer.decode(inputs["input_ids"][idx][start : end + 1])
print(f"Theoretical answer: {answer}, labels give: {labeled_answer}")
'Theoretical answer: the Main Building, labels give: the Main Building'
所以这是一个匹配!现在让我们检查索引 4,我们在其中将标签设置为 (0, 0),这意味着答案不在该特征的上下文块中
idx = 4
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]
decoded_example = tokenizer.decode(inputs["input_ids"][idx])
print(f"Theoretical answer: {answer}, decoded example: {decoded_example}")
'Theoretical answer: a Marian place of prayer and reflection, decoded example: [CLS] What is the Grotto at Notre Dame? [SEP] 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 Grot [SEP]'
的确,我们在上下文中看不到答案。
✏️ 轮到你了! 当使用 XLNet 架构时,填充应用于左侧,问题和上下文被切换。将我们刚刚看到的所有代码适应于 XLNet 架构(并添加 padding=True
)。请注意,应用填充后,[CLS]
token 可能不在 0 位置。
现在我们已经逐步了解了如何预处理我们的训练数据,我们可以将其分组到一个函数中,我们将在整个训练数据集上应用该函数。我们将每个特征填充到我们设置的最大长度,因为大多数上下文都很长(并且相应的样本将被拆分为多个特征),因此在此处应用动态填充没有真正的好处
max_length = 384
stride = 128
def preprocess_training_examples(examples):
questions = [q.strip() for q in examples["question"]]
inputs = tokenizer(
questions,
examples["context"],
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
offset_mapping = inputs.pop("offset_mapping")
sample_map = inputs.pop("overflow_to_sample_mapping")
answers = examples["answers"]
start_positions = []
end_positions = []
for i, offset in enumerate(offset_mapping):
sample_idx = sample_map[i]
answer = answers[sample_idx]
start_char = answer["answer_start"][0]
end_char = answer["answer_start"][0] + len(answer["text"][0])
sequence_ids = inputs.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
inputs["start_positions"] = start_positions
inputs["end_positions"] = end_positions
return inputs
请注意,我们定义了两个常量来确定使用的最大长度以及滑动窗口的长度,并且我们在分词之前添加了一点点清理:SQuAD 数据集中的某些问题在开头和结尾处有额外的空格,这些空格没有任何添加(如果您使用像 RoBERTa 这样的模型进行分词,则会占用空间),因此我们删除了这些额外的空格。
为了将此函数应用于整个训练集,我们使用带有 batched=True
标志的 Dataset.map()
方法。这在这里是必要的,因为我们正在更改数据集的长度(因为一个示例可以提供多个训练特征)
train_dataset = raw_datasets["train"].map(
preprocess_training_examples,
batched=True,
remove_columns=raw_datasets["train"].column_names,
)
len(raw_datasets["train"]), len(train_dataset)
(87599, 88729)
正如我们所看到的,预处理大约添加了 1,000 个特征。我们的训练集现在可以使用了——让我们深入研究验证集的预处理!
处理验证数据
预处理验证数据会稍微容易一些,因为我们不需要生成标签(除非我们想计算验证损失,但该数字实际上无助于我们了解模型的优劣)。真正的乐趣是将模型的预测解释为原始上下文的跨度。为此,我们只需要存储偏移映射和某种方式来将每个创建的特征与其来自的原始示例匹配。由于原始数据集中有一个 ID 列,我们将使用该 ID。
我们在这里唯一要添加的是对偏移映射进行少量清理。它们将包含问题和上下文的偏移量,但是一旦我们进入后处理阶段,我们将无法知道输入 ID 的哪一部分对应于上下文,哪一部分是问题(我们使用的 sequence_ids()
方法仅适用于分词器的输出)。因此,我们将对应于问题的偏移量设置为 None
def preprocess_validation_examples(examples):
questions = [q.strip() for q in examples["question"]]
inputs = tokenizer(
questions,
examples["context"],
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
)
sample_map = inputs.pop("overflow_to_sample_mapping")
example_ids = []
for i in range(len(inputs["input_ids"])):
sample_idx = sample_map[i]
example_ids.append(examples["id"][sample_idx])
sequence_ids = inputs.sequence_ids(i)
offset = inputs["offset_mapping"][i]
inputs["offset_mapping"][i] = [
o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
]
inputs["example_id"] = example_ids
return inputs
我们可以像以前一样将此函数应用于整个验证数据集
validation_dataset = raw_datasets["validation"].map(
preprocess_validation_examples,
batched=True,
remove_columns=raw_datasets["validation"].column_names,
)
len(raw_datasets["validation"]), len(validation_dataset)
(10570, 10822)
在这种情况下,我们只添加了数百个样本,因此验证数据集中的上下文似乎有点短。
现在我们已经预处理了所有数据,我们可以开始训练了。
使用 Trainer API 微调模型
此示例的训练代码看起来与前面章节中的代码非常相似——最困难的事情是编写 compute_metrics()
函数。由于我们将所有样本填充到我们设置的最大长度,因此没有要定义的数据整理器,因此此指标计算实际上是我们唯一需要担心的事情。困难的部分是将模型预测后处理为原始示例中的文本跨度;一旦我们完成此操作,来自 🤗 Datasets 库的指标将为我们完成大部分工作。
后处理
正如我们在探索question-answering
管道时看到的那样,模型将输出输入 ID 中答案的开始和结束位置的 logits。后处理步骤将类似于我们在那里所做的,所以这里快速回顾一下我们采取的行动
- 我们屏蔽了与上下文中 token 之外的 token 相对应的开始和结束 logits。
- 然后,我们使用 softmax 将开始和结束 logits 转换为概率。
- 我们通过取相应两个概率的乘积,将分数归因于每个
(start_token, end_token)
对。 - 我们寻找产生有效答案(例如,
start_token
低于end_token
)的最大分数对。
在这里,我们将稍微更改此过程,因为我们不需要计算实际分数(只需预测的答案)。这意味着我们可以跳过 softmax 步骤。为了加快速度,我们也不会对所有可能的 (start_token, end_token)
对进行评分,而只对与最高的 n_best
logits 相对应的对进行评分(其中 n_best=20
)。由于我们将跳过 softmax,这些分数将是 logit 分数,并将通过取开始和结束 logits 的总和来获得(而不是乘积,因为规则).
为了演示所有这些,我们将需要某种预测。由于我们尚未训练我们的模型,我们将使用 QA 管道的默认模型在验证集的一小部分上生成一些预测。我们可以使用与之前相同的处理函数;因为它依赖于全局常量 tokenizer
,我们只需将该对象临时更改为我们要使用的模型的 tokenizer
small_eval_set = raw_datasets["validation"].select(range(100))
trained_checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(trained_checkpoint)
eval_set = small_eval_set.map(
preprocess_validation_examples,
batched=True,
remove_columns=raw_datasets["validation"].column_names,
)
现在预处理已完成,我们将 tokenizer 更改回我们最初选择的 tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
然后,我们删除模型不期望的 eval_set
的列,使用所有这些小验证集构建一个批次,并通过模型传递它。如果有 GPU 可用,我们使用它来加快速度
import torch
from transformers import AutoModelForQuestionAnswering
eval_set_for_model = eval_set.remove_columns(["example_id", "offset_mapping"])
eval_set_for_model.set_format("torch")
device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu")
batch = {k: eval_set_for_model[k].to(device) for k in eval_set_for_model.column_names}
trained_model = AutoModelForQuestionAnswering.from_pretrained(trained_checkpoint).to(
device
)
with torch.no_grad():
outputs = trained_model(**batch)
由于 Trainer
会将预测作为 NumPy 数组提供给我们,因此我们获取开始和结束 logits 并将其转换为该格式
start_logits = outputs.start_logits.cpu().numpy() end_logits = outputs.end_logits.cpu().numpy()
现在,我们需要找到 small_eval_set
中每个示例的预测答案。eval_set
中的一个示例可能已拆分为多个特征,因此第一步是将 small_eval_set
中的每个示例映射到 eval_set
中的相应特征
import collections
example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(eval_set):
example_to_features[feature["example_id"]].append(idx)
有了这个,我们就可以真正开始工作了,方法是循环遍历所有示例,并针对每个示例,遍历所有关联的特征。正如我们之前所说,我们将查看 n_best
开始 logits 和结束 logits 的 logit 分数,排除给出以下位置
- 不在上下文中的答案
- 长度为负的答案
- 太长的答案(我们将可能性限制为
max_answer_length=30
)
一旦我们获得了一个示例的所有评分的可能答案,我们就选择具有最佳 logit 分数的答案
import numpy as np
n_best = 20
max_answer_length = 30
predicted_answers = []
for example in small_eval_set:
example_id = example["id"]
context = example["context"]
answers = []
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = eval_set["offset_mapping"][feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
# Skip answers that are not fully in the context
if offsets[start_index] is None or offsets[end_index] is None:
continue
# Skip answers with a length that is either < 0 or > max_answer_length.
if (
end_index < start_index
or end_index - start_index + 1 > max_answer_length
):
continue
answers.append(
{
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
}
)
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({"id": example_id, "prediction_text": best_answer["text"]})
预测答案的最终格式将是我们将使用的指标所期望的格式。和往常一样,我们可以借助 🤗 Evaluate 库加载它
import evaluate
metric = evaluate.load("squad")
此指标期望预测的答案采用我们上面看到的格式(字典列表,其中一个键用于示例的 ID,一个键用于预测的文本),理论答案采用以下格式(字典列表,其中一个键用于示例的 ID,一个键用于可能的答案)
theoretical_answers = [
{"id": ex["id"], "answers": ex["answers"]} for ex in small_eval_set
]
我们现在可以通过查看两个列表的第一个元素来检查我们是否获得了合理的结果
print(predicted_answers[0])
print(theoretical_answers[0])
{'id': '56be4db0acb8001400a502ec', 'prediction_text': 'Denver Broncos'}
{'id': '56be4db0acb8001400a502ec', 'answers': {'text': ['Denver Broncos', 'Denver Broncos', 'Denver Broncos'], 'answer_start': [177, 177, 177]}}
还不错!现在让我们看看指标给我们的分数
metric.compute(predictions=predicted_answers, references=theoretical_answers)
{'exact_match': 83.0, 'f1': 88.25}
同样,考虑到根据其论文,在整个数据集上微调的 DistilBERT 在这些分数上获得了 79.1 和 86.9,这已经相当不错了。
现在,让我们将我们刚刚完成的所有内容放入一个 compute_metrics()
函数中,我们将在 Trainer
中使用它。通常,compute_metrics()
函数仅接收一个元组 eval_preds
,其中包含 logits 和标签。在这里,我们将需要更多,因为我们必须在特征数据集中查找偏移量,并在示例数据集中查找原始上下文,因此我们将无法使用此函数在训练期间获得常规评估结果。我们只会在训练结束时使用它来检查结果。
compute_metrics()
函数将之前的步骤分组在一起;我们只是添加了一个小的检查,以防我们没有提出任何有效的答案(在这种情况下,我们预测一个空字符串)。
from tqdm.auto import tqdm
def compute_metrics(start_logits, end_logits, features, examples):
example_to_features = collections.defaultdict(list)
for idx, feature in enumerate(features):
example_to_features[feature["example_id"]].append(idx)
predicted_answers = []
for example in tqdm(examples):
example_id = example["id"]
context = example["context"]
answers = []
# Loop through all features associated with that example
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = features[feature_index]["offset_mapping"]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
# Skip answers that are not fully in the context
if offsets[start_index] is None or offsets[end_index] is None:
continue
# Skip answers with a length that is either < 0 or > max_answer_length
if (
end_index < start_index
or end_index - start_index + 1 > max_answer_length
):
continue
answer = {
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
}
answers.append(answer)
# Select the answer with the best score
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append(
{"id": example_id, "prediction_text": best_answer["text"]}
)
else:
predicted_answers.append({"id": example_id, "prediction_text": ""})
theoretical_answers = [{"id": ex["id"], "answers": ex["answers"]} for ex in examples]
return metric.compute(predictions=predicted_answers, references=theoretical_answers)
我们可以检查它是否适用于我们的预测
compute_metrics(start_logits, end_logits, eval_set, small_eval_set)
{'exact_match': 83.0, 'f1': 88.25}
看起来不错!现在让我们使用它来微调我们的模型。
微调模型
我们现在准备好训练我们的模型了。让我们首先创建它,像以前一样使用 AutoModelForQuestionAnswering
类
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
和往常一样,我们会收到警告,指出某些权重未使用(来自预训练头的权重),而另一些权重是随机初始化的(用于问答头的权重)。您现在应该已经习惯了这一点,但这意味着此模型尚未准备好立即使用,需要进行微调——幸好我们即将这样做!
为了能够将我们的模型推送到 Hub,我们需要登录 Hugging Face。如果您在笔记本中运行此代码,则可以使用以下实用程序函数执行此操作,该函数会显示一个您可以在其中输入登录凭据的 widget
from huggingface_hub import notebook_login
notebook_login()
如果您不在笔记本中工作,只需在终端中键入以下行
huggingface-cli login
完成此操作后,我们可以定义我们的 TrainingArguments
。正如我们在定义计算指标的函数时所说,由于 compute_metrics()
函数的签名,我们将无法进行常规的评估循环。我们可以编写自己的 Trainer
子类来实现这一点(您可以在问题解答示例脚本中找到这种方法),但这对于本节来说有点太长了。相反,我们在这里只在训练结束时评估模型,并在下面的“自定义训练循环”中向您展示如何进行常规评估。
这确实是 Trainer
API 显示其局限性以及 🤗 Accelerate 库大放异彩的地方:为特定用例自定义类可能很痛苦,但调整完全公开的训练循环却很容易。
让我们看一下我们的 TrainingArguments
from transformers import TrainingArguments
args = TrainingArguments(
"bert-finetuned-squad",
evaluation_strategy="no",
save_strategy="epoch",
learning_rate=2e-5,
num_train_epochs=3,
weight_decay=0.01,
fp16=True,
push_to_hub=True,
)
我们之前已经见过其中大部分:我们设置了一些超参数(例如学习率、我们训练的 epoch 数量以及一些权重衰减),并表明我们希望在每个 epoch 结束时保存模型、跳过评估并将我们的结果上传到 Model Hub。我们还使用 fp16=True
启用了混合精度训练,因为它可以在最新的 GPU 上很好地加速训练。
默认情况下,使用的存储库将在您的命名空间中,并以您设置的输出目录命名,因此在我们的例子中,它将位于 "sgugger/bert-finetuned-squad"
中。我们可以通过传递 hub_model_id
来覆盖此设置;例如,为了将模型推送到 huggingface_course
组织,我们使用了 hub_model_id="huggingface_course/bert-finetuned-squad"
(这是我们在本节开头链接到的模型)。
💡 如果您正在使用的输出目录存在,则它需要是您要推送到的存储库的本地克隆(因此,如果您在定义 Trainer
时遇到错误,请设置一个新名称)。
最后,我们只需将所有内容传递给 Trainer
类并启动训练
from transformers import Trainer
trainer = Trainer(
model=model,
args=args,
train_dataset=train_dataset,
eval_dataset=validation_dataset,
tokenizer=tokenizer,
)
trainer.train()
请注意,在训练发生时,每次保存模型(此处为每个 epoch)时,它都会在后台上传到 Hub。这样,您就可以在必要时在另一台机器上恢复训练。整个训练需要一段时间(在 Titan RTX 上略多于一小时),因此您可以喝杯咖啡或在训练进行时重读课程中您认为更具挑战性的部分。另请注意,一旦第一个 epoch 完成,您将看到一些权重上传到 Hub,并且您可以开始在其页面上使用您的模型。
训练完成后,我们终于可以评估我们的模型了(并祈祷我们没有把所有的计算时间都浪费掉)。Trainer
的 predict()
方法将返回一个元组,其中第一个元素将是模型的预测(这里是一对包含起始和结束 logits 的值)。我们将其发送到我们的 compute_metrics()
函数
predictions, _, _ = trainer.predict(validation_dataset)
start_logits, end_logits = predictions
compute_metrics(start_logits, end_logits, validation_dataset, raw_datasets["validation"])
{'exact_match': 81.18259224219489, 'f1': 88.67381321905516}
太棒了!作为比较,BERT 文章中报告的此模型基线分数为 80.8 和 88.5,因此我们正处于我们应该在的位置。
最后,我们使用 push_to_hub()
方法以确保我们上传模型的最新版本
trainer.push_to_hub(commit_message="Training complete")
如果您想检查它,这将返回它刚刚执行的提交的 URL
'https://huggingface.co/sgugger/bert-finetuned-squad/commit/9dcee1fbc25946a6ed4bb32efb1bd71d5fa90b68'
Trainer
还会起草包含所有评估结果的模型卡,并将其上传。
在这个阶段,您可以使用 Model Hub 上的推理小部件来测试模型,并与您的朋友、家人和心爱的宠物分享。您已成功在问题解答任务上微调了一个模型 — 恭喜!
✏️ 轮到你了! 尝试另一种模型架构,看看它在此任务上是否表现更好!
如果您想更深入地研究训练循环,我们现在将向您展示如何使用 🤗 Accelerate 做同样的事情。
自定义训练循环
现在让我们看一下完整的训练循环,以便您可以轻松自定义您需要的部分。它看起来很像第 3 章中的训练循环,但评估循环除外。由于我们不再受 Trainer
类的约束,我们将能够定期评估模型。
为训练准备一切
首先,我们需要从我们的数据集中构建 DataLoader
。我们将这些数据集的格式设置为 "torch"
,并删除验证集中模型未使用的列。然后,我们可以使用 Transformers 提供的 default_data_collator
作为 collate_fn
,并打乱训练集,但不打乱验证集
from torch.utils.data import DataLoader
from transformers import default_data_collator
train_dataset.set_format("torch")
validation_set = validation_dataset.remove_columns(["example_id", "offset_mapping"])
validation_set.set_format("torch")
train_dataloader = DataLoader(
train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=8,
)
eval_dataloader = DataLoader(
validation_set, collate_fn=default_data_collator, batch_size=8
)
接下来,我们重新实例化我们的模型,以确保我们不是从之前的微调继续,而是从 BERT 预训练模型重新开始
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
然后我们将需要一个优化器。像往常一样,我们使用经典的 AdamW
,它类似于 Adam,但在权重衰减的应用方式上有所改进
from torch.optim import AdamW
optimizer = AdamW(model.parameters(), lr=2e-5)
一旦我们拥有所有这些对象,我们就可以将它们发送到 accelerator.prepare()
方法。请记住,如果您想在 Colab 笔记本中的 TPU 上进行训练,您需要将所有这些代码移动到一个训练函数中,并且不应执行任何实例化 Accelerator
的单元格。我们可以通过将 fp16=True
传递给 Accelerator
来强制进行混合精度训练(或者,如果您将代码作为脚本执行,只需确保适当地填写 🤗 Accelerate config
)。
from accelerate import Accelerator
accelerator = Accelerator(fp16=True)
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
model, optimizer, train_dataloader, eval_dataloader
)
正如您应该从前面的章节中了解到的那样,我们只能在 train_dataloader
通过 accelerator.prepare()
方法后,才能使用其长度来计算训练步骤的数量。我们使用与前面章节中相同的线性计划
from transformers import get_scheduler
num_train_epochs = 3
num_update_steps_per_epoch = len(train_dataloader)
num_training_steps = num_train_epochs * num_update_steps_per_epoch
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
为了将我们的模型推送到 Hub,我们需要在工作文件夹中创建一个 Repository
对象。首先登录 Hugging Face Hub,如果您尚未登录。我们将从我们想要给模型的模型 ID 中确定存储库名称(您可以随意用您自己的选择替换 repo_name
;它只需要包含您的用户名,这就是 get_full_repo_name()
函数的作用)
from huggingface_hub import Repository, get_full_repo_name
model_name = "bert-finetuned-squad-accelerate"
repo_name = get_full_repo_name(model_name)
repo_name
'sgugger/bert-finetuned-squad-accelerate'
然后我们可以在本地文件夹中克隆该存储库。如果它已经存在,则此本地文件夹应该是我们正在使用的存储库的克隆
output_dir = "bert-finetuned-squad-accelerate"
repo = Repository(output_dir, clone_from=repo_name)
我们现在可以通过调用 repo.push_to_hub()
方法上传我们在 output_dir
中保存的任何内容。这将帮助我们在每个 epoch 结束时上传中间模型。
训练循环
我们现在准备好编写完整的训练循环。在定义进度条以跟踪训练进度后,循环包含三个部分
- 训练本身,这是对
train_dataloader
的经典迭代,通过模型的前向传递,然后是反向传递和优化器步骤。 - 评估,在评估中,我们在将
start_logits
和end_logits
的所有值转换为 NumPy 数组之前收集它们。一旦评估循环完成,我们将连接所有结果。请注意,我们需要截断,因为Accelerator
可能在末尾添加了一些样本,以确保我们在每个进程中都有相同数量的示例。 - 保存和上传,我们首先保存模型和 tokenizer,然后调用
repo.push_to_hub()
。正如我们之前所做的那样,我们使用参数blocking=False
来告诉 🤗 Hub 库在异步进程中推送。这样,训练会正常继续,并且此(长时间)指令在后台执行。
这是训练循环的完整代码
from tqdm.auto import tqdm
import torch
progress_bar = tqdm(range(num_training_steps))
for epoch in range(num_train_epochs):
# Training
model.train()
for step, batch in enumerate(train_dataloader):
outputs = model(**batch)
loss = outputs.loss
accelerator.backward(loss)
optimizer.step()
lr_scheduler.step()
optimizer.zero_grad()
progress_bar.update(1)
# Evaluation
model.eval()
start_logits = []
end_logits = []
accelerator.print("Evaluation!")
for batch in tqdm(eval_dataloader):
with torch.no_grad():
outputs = model(**batch)
start_logits.append(accelerator.gather(outputs.start_logits).cpu().numpy())
end_logits.append(accelerator.gather(outputs.end_logits).cpu().numpy())
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
start_logits = start_logits[: len(validation_dataset)]
end_logits = end_logits[: len(validation_dataset)]
metrics = compute_metrics(
start_logits, end_logits, validation_dataset, raw_datasets["validation"]
)
print(f"epoch {epoch}:", metrics)
# Save and upload
accelerator.wait_for_everyone()
unwrapped_model = accelerator.unwrap_model(model)
unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
if accelerator.is_main_process:
tokenizer.save_pretrained(output_dir)
repo.push_to_hub(
commit_message=f"Training in progress epoch {epoch}", blocking=False
)
如果您是第一次看到使用 🤗 Accelerate 保存的模型,让我们花一点时间检查一下与之相关的三行代码
accelerator.wait_for_everyone() unwrapped_model = accelerator.unwrap_model(model) unwrapped_model.save_pretrained(output_dir, save_function=accelerator.save)
第一行是不言自明的:它告诉所有进程等待,直到每个人都处于该阶段才能继续。这是为了确保我们在保存之前在每个进程中都有相同的模型。然后我们获取 unwrapped_model
,这是我们定义的基础模型。accelerator.prepare()
方法更改模型以在分布式训练中工作,因此它不再具有 save_pretrained()
方法;accelerator.unwrap_model()
方法会撤消该步骤。最后,我们调用 save_pretrained()
,但告诉该方法使用 accelerator.save()
而不是 torch.save()
。
完成此操作后,您应该拥有一个模型,该模型产生的结果与使用 Trainer
训练的模型非常相似。您可以在huggingface-course/bert-finetuned-squad-accelerate中查看我们使用此代码训练的模型。如果您想测试对训练循环的任何调整,您可以直接通过编辑上面显示的代码来实现它们!
使用微调后的模型
我们已经向您展示了如何使用我们在 Model Hub 上使用推理小部件微调的模型。要在本地的 pipeline
中使用它,您只需指定模型标识符
from transformers import pipeline
# Replace this with your own checkpoint
model_checkpoint = "huggingface-course/bert-finetuned-squad"
question_answerer = pipeline("question-answering", model=model_checkpoint)
context = """
🤗 Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch and TensorFlow — with a seamless integration
between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back 🤗 Transformers?"
question_answerer(question=question, context=context)
{'score': 0.9979003071784973,
'start': 78,
'end': 105,
'answer': 'Jax, PyTorch and TensorFlow'}
太棒了!我们的模型与此 pipeline 的默认模型一样有效!
< > 在 GitHub 上更新