Transformers NLP 任务:阅读理解问答

目录

  • [1. 机器阅读理解](#1. 机器阅读理解)
  • [2. pipeline 快速问答](#2. pipeline 快速问答)
  • [3. 数据预处理](#3. 数据预处理)
    • [3.1 加载问答数据集](#3.1 加载问答数据集)
    • [3.2 Tokenizer 如何编码](#3.2 Tokenizer 如何编码)
    • [3.3 单样本对齐函数](#3.3 单样本对齐函数)
    • [3.4 prepare_train_features](#3.4 prepare_train_features)
      • (1)函数目标
      • (2)批量编码
      • [(3)提取 feature 对应的答案](#(3)提取 feature 对应的答案)
      • [(4)字符位置转换为 token 位置](#(4)字符位置转换为 token 位置)
  • [4. 模型训练](#4. 模型训练)
  • [5. 模型测试](#5. 模型测试)

模型预训练:Hugging Face Transformers 基础

我们已经了解了 Hugging Face Transformers 的基础组件,包括 Pipeline、Tokenizer、Model、Datasets、Evaluate、Trainer

本文选择 阅读理解问答 作为 NLP 实战任务,完成一个抽取式问答任务,包括:

原始问答样本

→ tokenizer 编码成模型输入

→ 把答案字符位置转换成 token 位置

→ 问答模型预测 start_logits / end_logits

→ 后处理还原成文本答案

→ 用 EM / F1 评估

→ 保存模型并用 pipeline 推理

Github 地址:HaoYuanxinn/huggingface_transformers-02QA_practice: A step-by-step Hugging Face Transformers tutorial for extractive question answering with SQuAD.


1. 机器阅读理解

机器阅读理解的常见任务形式是给定一段文章 context,再给定一个问题 question,模型需要根据文章内容回答问题,例如:

text 复制代码
context:
Hugging Face is a company based in New York City. It develops tools for building machine learning applications.

question:
Where is Hugging Face based?

answer:
New York City

这种任务可以分成两类。第一类是 抽取式问答 ,答案必须是原文中的一段连续文本;第二类是 生成式问答 ,模型自己生成答案,答案不一定是原文中的连续片段。这里是第一类:抽取式问答 。它和第一部分的基础组件衔接更紧密。它仍然主要使用 BERT、DistilBERT 这类 Encoder 模型,但任务目标从分类 变成了预测答案位置


从文本分类到阅读理解

之前的内容中,我们用文本分类串过基础组件。它输入一句话,输出一个类别,模型输出通常是:

text 复制代码
logits: [batch_size, num_labels]

每条样本输出一个类别分数向量。但是机器阅读理解不一样。输入是 question + context,输出是答案在 context 中的起始位置、结束位置。仍以 New York City 为例,模型需要预测:

start_position = New 这个 token 的位置

end_position = City 这个 token 的位置

所以问答模型输出两个向量:

start_logits: batch_size, sequence_length

end_logits: batch_size, sequence_length

start_logits 表示每个 token 作为答案开始位置的分数,end_logits 表示每个 token 作为答案结束位置的分数


2. pipeline 快速问答

先用已经训练好的问答模型,看一下问答任务最终效果是什么样。

继续使用之前的 VS Code 虚拟环境。创建文件 pipeline_demo.py

python 复制代码
from transformers import pipeline

qa_pipeline = pipeline(
    task="question-answering",
    model="distilbert-base-cased-distilled-squad"
)

context = """
Hugging Face is a company based in New York City.
It develops tools and libraries for building machine learning applications.
The Transformers library provides thousands of pretrained models.
"""

question = "Where is Hugging Face based?"

result = qa_pipeline(
    question=question,
    context=context
)

print(result)
print("answer:", result["answer"])
print("score:", result["score"])
print("start:", result["start"])
print("end:", result["end"])

输出了答案文本、置信度、答案在原始字符串中的起始、结束字符位置。

pipeline 帮我们隐藏了很多细节。

question + context

→ tokenizer 编码

→ model 前向传播

→ 得到 start_logits 和 end_logits

→ 选择最可能的答案起点和终点

→ 根据 offset_mapping 还原成原始文本

→ 输出 answer


3. 数据预处理

3.1 加载问答数据集

使用 SQuAD 格式数据集作为实验数据。SQuAD 是经典的抽取式问答数据集,每条样本包含:

id:样本编号

title:文章标题

context:上下文文章

question:问题

answers:标准答案

新建 load_dataset.py

python 复制代码
from datasets import load_dataset

raw_datasets = load_dataset("squad")

print(raw_datasets)

print("\nexample:")
print(raw_datasets["train"][0])

这里最重要的是 answers 字段。它包含两个内容:

answers"text":答案文本

answers"answer_start":答案在 context 中的起始字符位置

3.2 Tokenizer 如何编码

在文本分类任务中,tokenizer 通常只需要处理一句话;但是在阅读理解任务中,模型需要同时看到两个内容:question与context

要解决三个问题:

  1. tokenizer 会怎样把 question 和 context 拼在一起?
  2. 拼接之后,模型怎么知道哪些 token 属于 question,哪些属于 context?
  3. 如果答案在原文中是字符位置,怎么找到它对应的 token 位置?

对应阅读理解任务中最核心的内容:答案位置对齐

新建文件 tokenizer_demo.py,写最简单的 tokenizer 编码,将question 和 context 作为输入:

python 复制代码
from transformers import AutoTokenizer

checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)

question = "Where is Hugging Face based?"
context = "Hugging Face is a company based in New York City."

encoding = tokenizer(question, context)
print(encoding.keys())

输出:

python 复制代码
dict_keys(['input_ids', 'attention_mask'])

input_ids 是 token 对应的数字编号。模型不能直接处理字符串,所以 tokenizer 会先把文本切成 token,再把 token 转成词表里的 id。attention_mask 用来告诉模型哪些位置是真实 token,哪些位置是 padding 补齐出来的 token。input_ids 是一串数字,不方便观察。我们把它转回 token 看一下。加上:

python 复制代码
tokens = tokenizer.convert_ids_to_tokens(encoding["input_ids"])

for index, token in enumerate(tokens):
    print(index, token)

可以看出,Tokenizer 把它们拼成了一个 token 序列:

text 复制代码
[CLS] question [SEP] context [SEP]

[CLS] 通常放在整个输入开头;[SEP] 用来分隔两个文本,也用来表示输入结束。


sequence_ids

在当前输入中,token 序列由三类内容组成:特殊 token、question 的 token、context 的 token,可以用 sequence_ids() 查看每个 token 属于哪一部分。

添加代码:

python 复制代码
sequence_ids = encoding.sequence_ids()

for index, token in enumerate(tokens):
    print(index, token, sequence_ids[index])

None代表特殊 token,比如 CLSSEP;0是第一个输入,也就是 question;1是 context。阅读理解任务中,答案必须来自 context。所以后面寻找答案开始位置和结束位置时,只能在 sequence_idsindex == 1 的 token 里面找。

offset_mapping

现在我们知道每个 token 属于 question 还是 context。但还没有解决另一个问题,数据集里的answers 字段是字符位置,但一个 token 可能对应多个字符,而模型需要的是 token 下标。

例如原始 context 是:Hugging Face is a company based in New York City.

答案是:New York City。模型训练需要的是 token 下标:

17 New

18 York

19 City

所以我们需要知道每个 token 对应原始字符串中的哪一段字符,这就需要 offset_mapping

重新编码时加上:

python 复制代码
encoding = tokenizer(
    question,
    context,
    return_offsets_mapping=True
)

然后打印 token、sequence_id 和 offset:

python 复制代码
tokens = tokenizer.convert_ids_to_tokens(encoding["input_ids"])
sequence_ids = encoding.sequence_ids()
offset_mapping = encoding["offset_mapping"]

for index, token in enumerate(tokens):
    print(index, token, sequence_ids[index], offset_mapping[index])

这里每一行可以这样理解:

text 复制代码
17 New 1 (35, 38)

表示第 17 个 token 是 New,它属于 context,它对应 context 原文中的第 35 到 38 个字符。

答案 New York City 对应的 token 范围是:

start_position = 17

end_position = 19

这就是 offset_mapping 的作用,它建立了字符位置与token 位置的桥梁

总结一下

1.tokenizer(question, context) 会把问题和上下文拼成一个序列:[CLS] question [SEP] context [SEP]

2.sequence_ids() 可以告诉我们每个 token 属于哪一部分:

None:特殊 token

0:question

1:context

3.模型真正处理的是 token,不是原始字符串。一个单词可能被拆成多个 token,每一个token 对应多个字符,offset_mapping 可以告诉我们每个 token 对应原文中的字符范围。

3.3 单样本对齐函数

新建文件:align_one_example.py

python 复制代码
from transformers import AutoTokenizer

checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)

question = "Where is Hugging Face based?"
context = "Hugging Face is a company based in New York City."
answer_text = "New York City"
answer_start = context.index(answer_text)
answer_end = answer_start + len(answer_text)

这里用 context.index(answer_text) 自动找到答案在原文里的字符起点。

接下来编码:

python 复制代码
encoding = tokenizer(
    question,
    context,
    return_offsets_mapping=True
)
tokens = tokenizer.convert_ids_to_tokens(encoding["input_ids"])
offsets = encoding["offset_mapping"]
sequence_ids = encoding.sequence_ids()

现在要遍历所有 token,找到答案起点和终点。

python 复制代码
start_token = None
end_token = None
for i, offset in enumerate(offsets):
    if sequence_ids[i] != 1:  ## 当前 token 不属于 context
        continue

    start, end = offset

    if start <= answer_start < end:  # 答案的起始字符在当前token内
        start_token = i

    if start < answer_end <= end:  # 答案的结束字符在当前token内
        end_token = i

最后打印结果:

python 复制代码
print("answer_start:", answer_start)
print("answer_end:", answer_end)
print("start_token:", start_token, tokens[start_token])
print("end_token:", end_token, tokens[end_token])
print("answer tokens:", tokens[start_token:end_token + 1])

真实的处理没这么简单,很多时候context 可能很长,如果 question + context 超过最大长度,就必须截断。一条长 context 可能要切成多个片段,这时一条原始样本会变成多个训练特征 feature。

因此真实训练时要用三个参数:

max_length:每个输入片段的最大长度

doc_stride:相邻片段之间保留多少重叠 token

return_overflowing_tokens:把长文本切出来的多个片段全部返回

设置相邻片段的重叠,为了防止答案刚好被切断

3.4 prepare_train_features

构造问答模型的训练输入,新建:train_qa.py

python 复制代码
import torch
from datasets import load_dataset
from transformers import AutoTokenizer

checkpoint = "distilbert-base-cased-distilled-squad"
max_length = 384
doc_stride = 128

tokenizer = AutoTokenizer.from_pretrained(checkpoint)
raw_datasets = load_dataset("squad")

取一小部分数据:

python 复制代码
train_examples = raw_datasets["train"].shuffle(seed=42).select(range(1000))
eval_examples = raw_datasets["validation"].shuffle(seed=42).select(range(200))

(1)函数目标

接下来定义训练数据预处理函数,这个函数的思路是:

原始 examples

tokenizer 编码 question + context

长 context 切成多个 feature

把答案的字符位置转换成 token 位置

生成 start_positions 和 end_positions

原始 SQuAD 样本里,答案是这样保存的:

python 复制代码
answers = {
    "text": ["New York City"],
    "answer_start": [35]
}

答案文本是 "New York City",从 context 的第 35 个字符开始。但是模型训练时不需要字符串答案,而是需要答案开始 token 的下标答案结束 token 的下标 ,所以 prepare_train_features 的任务就是将字符答案位置 转换为 token 答案位置

(2)批量编码

python 复制代码
def prepare_train_features(examples):
    questions = [q.strip() for q in examples["question"]]  ## 去掉问题前后的空格

    tokenized_examples = tokenizer(
        questions,
        examples["context"],
        truncation="only_second",
        max_length=max_length,
        stride=doc_stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length"
    )

truncation="only_second" 表示只截断第二个输入,也就是 context。问题通常很短,文章通常很长,所以应该截断文章。

此时tokenized_examples 的数据格式为:

tokenized_examples = {

"input_ids": [

feature0 的 384 个 token id,

...

],

"attention_mask": [

feature0 的 384 个 0/1,

...

],

"offset_mapping": [

feature0 的 384 个字符位置,

...

],

"overflow_to_sample_mapping": [

0, 0, 1, 2, 2

]

}

继续在函数里:

python 复制代码
    sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
    offset_mapping = tokenized_examples.pop("offset_mapping")

如前文所说,长 context 会被切成多个片段,一条原始样本可能变成多个 feature。所以在处理第 i 个 feature 时,必须知道它来自原始数据中的哪条样本,才能拿到对应的标准答案。这是sample_mapping 的作用

offset_mapping 的作用是把 token 位置和原始字符位置对应起来。

tokenized_examples 的数据格式变为:

tokenized_examples = {

"input_ids": [

feature0 的 384 个 token id,

...

],

"attention_mask": [

feature0 的 384 个 0/1,

...

],

}


(3)提取 feature 对应的答案

tokenizer 已经把原始样本转换成了若干个 feature。接下来要做的事情是:逐个检查每个 feature,看它是否包含答案;如果包含,就计算答案在当前 feature 中的 token 起止位置;如果不包含,就把答案位置标成 [CLS]

逐个 feature 计算 start/end 标签:

python 复制代码
    start_positions = []
    end_positions = []

    for i, offsets in enumerate(offset_mapping):
        input_ids = tokenized_examples["input_ids"][i]
        cls_index = input_ids.index(tokenizer.cls_token_id)
        sequence_ids = tokenized_examples.sequence_ids(i)

enumerate(offset_mapping) 同时包含:i 当前 feature 的编号、offsets 当前 feature 中每个 token 的字符位置映射。对每一个 feature,取出它的 input_ids。并找到 [CLS] 的位置。

继续找到当前 feature 对应的原始答案

python 复制代码
        sample_index = sample_mapping[i]
        answers = examples["answers"][sample_index]

        start_char = answers["answer_start"][0]
        answer_text = answers["text"][0]
        end_char = start_char + len(answer_text)

前面说过,如果长 context 被切成多个片段,一条 example 可能对应多个 feature,所以现在处理第 i 个 feature 时,通过sample_mappingi 找到它原来是哪条 example。

然后从原始样本中取出答案:

python 复制代码
answers = examples["answers"][sample_index]

这里再次得到的是字符位置

start_char:答案开始字符位置

end_char:答案结束字符位置

下一步是真正的对齐

(4)字符位置转换为 token 位置

找到 context 在 token 中的范围

python 复制代码
        token_start_index = 0
        while sequence_ids[token_start_index] != 1:
            token_start_index += 1

        token_end_index = len(input_ids) - 1
        while sequence_ids[token_end_index] != 1:
            token_end_index -= 1

答案只能出现在 context 里,我们先从左往右找第一个 sequence_ids == 1 的 token,它就是 context 的开始;再从右往左找最后一个 sequence_ids == 1 的 token,它就是 context 的结束。后面只在这个范围内找答案。


判断当前片段是否包含答案

python 复制代码
        if not (
            offsets[token_start_index][0] <= start_char
            and offsets[token_end_index][1] >= end_char
        ):
            start_positions.append(cls_index)
            end_positions.append(cls_index)
            continue

offsets = offset_mappingi,它的格式是这样的

(0, 0), # \[CLS

(0, 5), # Where

(6, 8), # is

...

(0, 0), # SEP

(0, 7), # Hugging

(8, 12), # Face

(13, 15), # is

...

]

每一个元素都是一个二元组(start, end),表示当前 token 对应原始文本中的字符范围。

offsets[token_start_index][0] 是当前片段第一个 context token 的起始字符;offsets[token_end_index][1] 是当前片段最后一个 context token 的结束字符。

如果当前片段没有完整答案,就标成:

python 复制代码
start_positions.append(cls_index)
end_positions.append(cls_index)

然后 continue,直接处理下一个 feature,直到真正找到答案的 token 起点和终点

python 复制代码
        while token_start_index < len(offsets) and offsets[token_start_index][0] <= start_char:
            token_start_index += 1
        start_positions.append(token_start_index - 1)
        while offsets[token_end_index][1] >= end_char:
            token_end_index -= 1
        end_positions.append(token_end_index + 1)

最后把标签放回 tokenized_examples

python 复制代码
    tokenized_examples["start_positions"] = start_positions
    tokenized_examples["end_positions"] = end_positions

    return tokenized_examples

最终 tokenized_examples 的数据格式为

tokenized_examples = {

"input_ids": [

384个token id,

...

],

"attention_mask": [

384个0/1,

...

],

"start_positions": [

24,

​ ...

​ ],

​ "end_positions": [

​ 26,

​ ...

​ ]

}

总结一下,函数首先对问题和上下文批量编码,切分为多个特征片段;随后利用样本映射找到每个片段的答案,通过位置映射判断答案是否完整落在当前片段中。

若不包含完整答案,则将答案位置标记为 [CLS];若包含答案,则将答案在原文中的字符级位置转换为当前片段中的 token 级起止位置,最终生成 start_positionsend_positions,作为模型训练标签。

4. 模型训练

函数写完后,继续在 train_qa.py 中添加:

python 复制代码
tokenized_train_dataset = train_examples.map(
    prepare_train_features,
    batched=True,
    remove_columns=train_examples.column_names
)

这里的 batched=True 表示一次处理一批样本。remove_columns=train_examples.column_names 表示删除原始字段。

因为原始字段id、title、context、question、answers不能直接送进模型。模型训练只需要input_ids、attention_mask、start_positions、end_positions

此时打印检查:

python 复制代码
print(tokenized_train_dataset[0].keys())

输出:

text 复制代码
dict_keys(['input_ids', 'attention_mask', 'start_positions', 'end_positions'])

现在数据已经变成模型可以理解的格式了。导入训练相关组件:

python 复制代码
from transformers import (
    AutoModelForQuestionAnswering,
    TrainingArguments,
    Trainer,
    DefaultDataCollator
)
model = AutoModelForQuestionAnswering.from_pretrained(checkpoint)
data_collator = DefaultDataCollator()  ## 数据整理器

TrainingArguments

训练参数:

python 复制代码
training_args = TrainingArguments(
    output_dir="outputs/qa_model",
    learning_rate=2e-5,
    per_device_train_batch_size=8,
    per_device_eval_batch_size=8,
    num_train_epochs=3,
    weight_decay=0.01,
    logging_steps=20,
    evaluation_strategy="epoch",
    save_strategy="epoch",
    fp16=torch.cuda.is_available(),  ## 如果有 GPU,就尝试使用半精度训练
    report_to="none"
)

创建 Trainer 并训练

先处理验证集:

python 复制代码
tokenized_eval_dataset = eval_examples.map(
    prepare_train_features,
    batched=True,
    remove_columns=eval_examples.column_names
)

创建 Trainer:

python 复制代码
trainer = Trainer(
    model=model,
    args=training_args,
    train_dataset=tokenized_train_dataset,
    eval_dataset=tokenized_eval_dataset,
    tokenizer=tokenizer,
    data_collator=data_collator
)

开始训练并保存模型:

python 复制代码
trainer.train()
trainer.save_model("outputs/qa_model/final")
tokenizer.save_pretrained("outputs/qa_model/final")

5.模型测试

5.1 pipeline 推理

新建文件 inference.py,加载本地模型并执行问答

python 复制代码
from transformers import pipeline

model_dir = "outputs/qa_model/final"

qa = pipeline(
    "question-answering",
    model=model_dir,
    tokenizer=model_dir
)

context = """
Hugging Face is a company based in New York City.
It is known for the Transformers library.
"""

question = "What library is Hugging Face known for?"

result = qa(question=question, context=context)
print(result)

输出:

text 复制代码
{'score': 0.718795657157898, 'start': 71, 'end': 91, 'answer': 'Transformers library'}

说明训练、保存、加载、推理已经跑通。


5.2 模型评估

问答任务的常用指标中,EM 表示预测答案和标准答案是否完全一致;F1 看两者之间有多少词重合。

在开展评估之前,需要首先进行后处理,假设 tokenizer 之后得到下面这些 token:

0 CLS

1 Where

2 is

3 Hu

4 ##gging

5 Face

6 based

7 ?

8 SEP

9 Hu

10 ##gging

11 Face

12 is

13 a

14 company

15 based

16 in

17 New

18 York

19 City

20 .

21 SEP

模型会给每个 token 一个 start 分数和一个 end 分数。如果模型认为答案是 New York City,那么理想情况下 start_logits17 最大,end_logits19 最大。于是答案 token 区间就是17 ~ 19。再通过 offset_mapping 找到这些 token 对应原文中的字符位置:

17 New (35, 38)

18 York (39, 43)

19 City (44, 48)

答案在原始 context 中的字符范围是35 ~ 48,最后从原文中切片:

python 复制代码
answer = context[35:48]

得到New York City,这就是后处理的基本思路。

最直观的想法是直接取 argmax,但可能出现非法答案,如答案结束位置不能在开始位置前面、答案不能来自 question、不应包含 [CLS][SEP] 这样的特殊 token......更合理的做法是:

找出分数最高的前 n 个位置,枚举所有 start/end 组合,删除不合法的组合,在剩下的组合中选总分最高的答案

验证集预处理

训练阶段的 prepare_train_features 会生成 start_positionsend_positions,给模型提供监督标签。评估阶段不一样,评估阶段需要保留 offset_mapping,因为后处理要靠它把 token 位置还原成原文中的字符串。

新建文件 evaluate_qa.py

python 复制代码
import collections
import numpy as np
import evaluate
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForQuestionAnswering, TrainingArguments, Trainer, DefaultDataCollator

model_dir = "outputs/qa_model/final"
max_length = 384
doc_stride = 128
n_best_size = 20
max_answer_length = 30

tokenizer = AutoTokenizer.from_pretrained(model_dir)
model = AutoModelForQuestionAnswering.from_pretrained(model_dir)

raw_datasets = load_dataset("squad")
eval_examples = raw_datasets["validation"].shuffle(seed=42).select(range(200))

接下来写验证集预处理函数:

python 复制代码
def prepare_validation_features(examples):
    questions = [q.strip() for q in examples["question"]]

    tokenized_examples = tokenizer(
        questions,
        examples["context"],
        truncation="only_second",
        max_length=max_length,
        stride=doc_stride,
        return_overflowing_tokens=True,
        return_offsets_mapping=True,
        padding="max_length"
    )

    sample_mapping = tokenized_examples.pop("overflow_to_sample_mapping")
    tokenized_examples["example_id"] = []

    for i in range(len(tokenized_examples["input_ids"])):
        sample_index = sample_mapping[i]
        tokenized_examples["example_id"].append(examples["id"][sample_index])

        sequence_ids = tokenized_examples.sequence_ids(i)
        offsets = tokenized_examples["offset_mapping"][i]

        tokenized_examples["offset_mapping"][i] = [
            offset if sequence_ids[k] == 1 else None
            for k, offset in enumerate(offsets)
        ]

    return tokenized_examples

这里新增了 example_id 这个字段,记录当前 feature 对应的原始样本 id。

接下来处理 offset_mapping。sequence_ids 用来判断每个 token 属于哪一部分,问答任务的答案只能来自 context,不能来自 question,也不能来自 [CLS][SEP][PAD]。所以我们把不属于 context 的 token 位置全部设置为 None

处理验证集:

python 复制代码
validation_features = eval_examples.map(
    prepare_validation_features,
    batched=True,
    remove_columns=eval_examples.column_names
)

这里得到的 validation_features 是编码后的 feature。包含:

input_ids

attention_mask

offset_mapping

example_id

评估时,我们需要先让模型在验证集上跑一遍,得到每个 token 的开始分数和结束分数。

python 复制代码
training_args = TrainingArguments(
    output_dir="outputs/qa_eval",
    per_device_eval_batch_size=8,
    report_to="none"
)

data_collator = DefaultDataCollator()

trainer = Trainer(
    model=model,
    args=training_args,
    tokenizer=tokenizer,
    data_collator=data_collator
)

raw_predictions = trainer.predict(validation_features)
start_logits, end_logits = raw_predictions.predictions

print(start_logits.shape)
print(end_logits.shape)

validation_features 输入模型,得到模型输出的两个矩阵 start_logits 和 end_logits

输出

(204, 384)

(204, 384)

验证集被切成了 204 个 feature,模型会给每个 token 一个作为答案开始位置的分数和一个结束位置的分数

后处理预测结果

接下来需要把 start_logitsend_logits 转换成真正的答案文本。

基本思路是:

  1. 找到每条原始样本对应的所有 feature
  2. 在每个 feature 中选出分数最高的若干个 start 位置
  3. 在每个 feature 中选出分数最高的若干个 end 位置
  4. 枚举 start/end 组合
  5. 删除非法答案
  6. 用 offset_mapping 从原始 context 中切出答案文本
  7. 对同一条样本,保留总分最高的答案

非法答案主要包括:start 或 end 不在 context 中、end 在 start 前面、答案长度超过 max_answer_length、start 或 end 位置越界,添加后处理函数:

首先创建一个字典,用来保存每条原始样本对应哪些 feature

python 复制代码
def postprocess_qa_predictions(examples, features, raw_predictions):
    all_start_logits, all_end_logits = raw_predictions

    example_id_to_index = {
        example_id: i for i, example_id in enumerate(examples["id"])
    }

    features_per_example = collections.defaultdict(list)
    for i, feature in enumerate(features):
        example_index = example_id_to_index[feature["example_id"]]
        features_per_example[example_index].append(i)

接下来开始逐条处理原始样本,遍历当前原始样本对应的所有 feature,逐个处理

python 复制代码
    predictions = {}

    for example_index, example in enumerate(examples):
        context = example["context"]
        valid_answers = []

        for feature_index in features_per_example[example_index]:

具体取出当前 feature 的三个东西

start_logit:当前 feature 中每个 token 作为答案开始位置的分数

end_logit:当前 feature 中每个 token 作为答案结束位置的分数

offsets:当前 feature 中每个 token 对应原文的字符范围

保存分数最高的前若干个答案起点和答案终点。枚举所有的答案组合,并排除非法情况

python 复制代码
            start_logit = all_start_logits[feature_index]
            end_logit = all_end_logits[feature_index]
            offsets = features[feature_index]["offset_mapping"]

            start_indexes = np.argsort(start_logit)[-1: -n_best_size - 1: -1].tolist()
            end_indexes = np.argsort(end_logit)[-1: -n_best_size - 1: -1].tolist()

            for start_index in start_indexes:
                for end_index in end_indexes:
                    if start_index >= len(offsets) or end_index >= len(offsets):
                        continue

                    if offsets[start_index] is None or offsets[end_index] is None:
                        continue

                    if end_index < start_index:
                        continue

                    if end_index - start_index + 1 > max_answer_length:
                        continue

通过这些判断之后,当前是一个合法答案区间,下面把 token 位置转换成字符位置,并计算这个候选答案的总分

python 复制代码
                    start_char = offsets[start_index][0]
                    end_char = offsets[end_index][1]
                    text = context[start_char:end_char]
                    score = start_logit[start_index] + end_logit[end_index]

然后保存这个候选答案,最后选分数最高的那个

python 复制代码
                    valid_answers.append({
                        "score": score,
                        "text": text
                    })

        if len(valid_answers) > 0:
            best_answer = max(valid_answers, key=lambda x: x["score"])
            predictions[example["id"]] = best_answer["text"]
        else:
            predictions[example["id"]] = ""

    return predictions

返回所有样本的预测答案。格式为:

复制代码
{
    "56be85543aeaaa14008c9063": "Denver Broncos",
    "56be85543aeaaa14008c9065": "Carolina Panthers",
    "56be85543aeaaa14008c9066": "Santa Clara, California"
}

evaluate.load("squad") 需要的预测格式是:

{

"id": 样本id,

"prediction_text": 预测答案

}

继续添加:

python 复制代码
predictions = postprocess_qa_predictions(
    eval_examples,
    validation_features,
    raw_predictions.predictions
)

formatted_predictions = [
    {
        "id": example_id,
        "prediction_text": prediction_text
    }
    for example_id, prediction_text in predictions.items()
]

references = [
    {
        "id": example["id"],
        "answers": example["answers"]
    }
    for example in eval_examples
]

metric = evaluate.load("squad")
results = metric.compute(
    predictions=formatted_predictions,
    references=references
)

print(results)
复制代码
metric = evaluate.load("squad")

这一步加载 SQuAD 的评估指标。会计算 exact_match、f1

复制代码
results = metric.compute(
    predictions=formatted_predictions,
    references=references
)

最后正式计算指标。把模型预测答案和标准答案一一比较,得到结果

text 复制代码
{'exact_match': 76.0, 'f1': 84.84380470602314}

问答任务的模型输出是每个 token 的起点分数和终点分数。评估时必须先根据 start_logitsend_logits 枚举候选答案,再过滤非法片段,用 offset_mapping 将 token 位置还原为原文中的答案文本。得到预测文本后,才能与标准答案计算 EM 和 F1。

相关推荐
nebula-AI1 小时前
Understand Anything 简要使用文档
人工智能·ai·github·项目管理·知识图谱·dashboard
东方佑1 小时前
WDLM-Turbo:为什么纯实数神经波动力学语言模型可行?
人工智能·语言模型·自然语言处理
暗夜猎手-大魔王1 小时前
转载--Hermes Agent 08 | Agent 的自我进化:nudge、后台审查与轨迹数据
java·前端·人工智能
MicrosoftReactor1 小时前
技术速递|使用 GitHub Copilot CLI 构建 Emoji 列表生成器
ai·github·copilot·cli
weixin_495248401 小时前
AI视频翻译总对不上?字幕配音时间轴是关键
人工智能·音视频
元启数宇1 小时前
扫描图纸PDF JPG怎么转CAD
人工智能·pdf
张彦峰ZYF1 小时前
LangGraph从零构建生产级 AI Agent 平台的递进式学习项目
人工智能·大模型·langgraph
zhangfeng11331 小时前
联邦学习 合并权重 合并权重。导致内存溢出解决办法和类库 mergekit 包依赖版本
人工智能·pytorch·机器学习
宸津-代码粉碎机1 小时前
Spring AI 企业级RAG实战|增量更新+文档去重+定时自动入库生产落地方案
java·大数据·人工智能·后端·python·spring