🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎
📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃
🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝
📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】 深度学习【DL】
🖍foreword
✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。
如果你对这个系列感兴趣的话,可以关注订阅哟👋
文章目录
无论您是研究人员、分析师还是数据科学家,在某些时候,您都可能需要在海量文档中跋涉才能找到您正在寻找的信息。更糟糕的是,Google 和 Bing 不断提醒您存在更好的搜索方式!例如,如果我们搜索“居里夫人什么时候获得她的第一个诺贝尔奖?” 在 Google 上,我们立即得到“1903”的正确答案,如图 7-1 所示。
图 7-1。谷歌搜索查询和相应的答案片段
在这个例子中,谷歌首先检索了大约 319,000 个与查询相关的文档,然后执行一个额外的处理步骤来提取带有相应段落和网页的答案片段。不难看出为什么这些答案片段很有用。例如,如果我们搜索一个更棘手的问题,例如“哪种吉他调音最好?” 谷歌没有提供答案,相反,我们必须点击搜索引擎返回的网页之一才能自己找到它。1
该技术背后的一般方法称为问答(QA)。QA 有很多种,但最常见的是抽取式 QA,其中涉及的问题的答案可以被识别为文档中的一段文本,其中文档可能是网页、法律合同或新闻文章。首先检索相关文档然后从中提取答案的两阶段过程也是许多现代 QA 系统的基础,包括语义搜索引擎、智能助手和自动化信息提取器。在本章中,我们将应用此过程来解决电子商务网站面临的一个常见问题:帮助消费者回答特定查询以评估产品。我们将看到客户评论可以用作质量检查的丰富且具有挑战性的信息来源,并且在此过程中我们将了解变形金刚如何充当强大的阅读理解可以从文本中提取含义的模型。让我们从充实用例开始。
笔记
本章重点介绍抽取式 QA,但其他形式的 QA 可能更适合您的用例。例如,社区 QA涉及收集用户在Stack Overflow等论坛上生成的问答对,然后使用语义相似性搜索来找到与新问题最接近的匹配答案。还有长篇 QA,旨在为“为什么天空是蓝色的?”等开放式问题生成复杂的段落长度答案。值得注意的是,还可以对表进行 QA,像TAPAS这样的转换器模型甚至可以执行聚合以产生最终答案!
如果您曾经在线购买过产品,您可能会依靠客户评论来帮助您做出决定。这些评论通常可以帮助回答诸如“这把吉他有背带吗?”之类的具体问题。或“我可以在晚上使用这台相机吗?” 仅从产品描述可能很难回答。然而,受欢迎的 产品 可能有成百上千的评论,因此找到相关的评论可能是一个主要的障碍。一种替代方法是在亚马逊等网站提供的社区 QA 平台上发布您的问题,但通常需要几天时间才能得到答案(如果你真的得到答案的话)。如果我们能立即得到答案不是很好吗,就像图 7-1中的 Google 示例一样?让我们看看我们是否可以使用 转换器来做到这一点!
为了构建我们的 QA 系统,我们将使用 SubjQA 数据集2,其中包含 10,000 多条关于产品和服务的英文客户评论,内容涉及六个领域:TripAdvisor、餐厅、电影、书籍、电子产品和杂货店。如图7-2 所示,每条评论都与一个问题相关联,该问题可以使用评论中的一个或多个句子来回答。3
图 7-2。关于产品和相应评论的问题(答案跨度带下划线)
这个数据集的有趣之处在于大多数问题和答案都是主观的;也就是说,它们取决于用户的个人体验。图 7-2中的示例说明了为什么此功能使任务可能比寻找诸如“英国的货币是什么?”之类的事实问题的答案更困难。首先,查询是关于“质量差”的,这是主观的,取决于用户对质量的定义。其次,查询的重要部分根本不会出现在评论中,这意味着无法使用关键字搜索或解释输入问题等快捷方式来回答。这些特性使 SubjQA 成为一个真实的数据集,用于对我们基于评论的 QA 模型进行基准测试,因为用户生成的内容如图 7-2所示类似于我们在野外可能遇到的情况。
笔记
QA 系统通常按它们在响应查询时可以访问的数据域进行分类。封闭域QA 处理关于狭窄主题(例如,单个产品类别)的问题,而开放域QA 处理几乎所有问题(例如,亚马逊的整个产品目录)。通常,与开放域案例相比,封闭域 QA 涉及的文档搜索更少。
首先,让我们从 Hugging Face Hub下载数据集。正如我们在 第 4 章中所做的那样,我们可以使用该get_dataset_config_names()
函数来找出可用的子集:
- from datasets import get_dataset_config_names
-
- domains = get_dataset_config_names("subjqa")
- domains
['books', 'electronics', 'grocery', 'movies', 'restaurants', 'tripadvisor']
对于我们的用例,我们将专注于为电子领域构建一个 QA 系统。要下载electronics
子集,我们只需将此值传递给函数的name
参数load_dataset()
:
- from datasets import load_dataset
-
- subjqa = load_dataset("subjqa", name="electronics")
与 Hub 上的其他问答数据集一样,SubjQA 将每个问题的答案存储为嵌套字典。例如,如果我们检查列中的某一行answers
:
print(subjqa["train"]["answers"][1])
{'text': ['Bass is weak as expected', 'Bass is weak as expected, even with EQ adjusted up'], 'answer_start': [1302, 1302], 'answer_subj_level': [1, 1], 'ans_subj_score': [0.5083333253860474, 0.5083333253860474], 'is_ans_subjective': [True, True]}
我们可以看到答案存储在一个text
字段中,而起始字符索引在answer_start
. 为了更轻松地探索数据集,我们将使用该flatten()
方法展平这些嵌套列,并将每个拆分转换为 Pandas DataFrame
,如下所示:
- import pandas as pd
-
- dfs = {split: dset.to_pandas() for split, dset in subjqa.flatten().items()}
-
- for split, df in dfs.items():
- print(f"Number of questions in {split}: {df['id'].nunique()}")
Number of questions in train: 1295 Number of questions in test: 358 Number of questions in validation: 255
请注意,数据集相对较小,总共只有 1,908 个示例。这模拟了现实世界的场景,因为让领域专家标记抽取的 QA 数据集是劳动密集型且昂贵的。例如,用于法律合同提取 QA 的 CUAD 数据集估计价值 200 万美元,以说明注释其 13,000 个示例所需的法律专业知识!4
SubjQA 数据集中有很多列,但用于构建我们的 QA 系统的最有趣的列如 表 7-1所示。
列名 | 描述 |
---|---|
| 与每个产品关联的亚马逊标准识别码 (ASIN) |
| 问题 |
| 注释者标记的评论中的文本范围 |
| 答案范围的起始字符索引 |
| 客户评价 |
让我们关注这些专栏,并看一些训练示例。我们可以使用该sample()
方法来选择一个随机样本:
- qa_cols = ["title", "question", "answers.text",
- "answers.answer_start", "context"]
- sample_df = dfs["train"][qa_cols].sample(2, random_state=7)
- sample_df
title | question | answers.text | answers.answer_start | context |
---|---|---|---|---|
B005DKZTMG | Does the keyboard lightweight? | [this keyboard is compact] | [215] | I really like this keyboard. I give it 4 stars because it doesn’t have a CAPS LOCK key so I never know if my caps are on. But for the price, it really suffices as a wireless keyboard. I have very large hands and this keyboard is compact, but I have no complaints. |
B00AAIPT76 | How is the battery? | [] | [] | I bought this after the first spare gopro battery I bought wouldn’t hold a charge. I have very realistic expectations of this sort of product, I am skeptical of amazing stories of charge time and battery life but I do expect the batteries to hold a charge for a couple of weeks at least and for the charger to work like a charger. In this I was not disappointed. I am a river rafter and found that the gopro burns through power in a hurry so this purchase solved that issue. the batteries held a charge, on shorter trips the extra two batteries were enough and on longer trips I could use my friends JOOS Orange to recharge them.I just bought a newtrent xtreme powerpak and expect to be able to charge these with that so I will not run out of power again. |
从这些例子中,我们可以做出一些观察。首先,这些问题在语法上不正确,这在电子商务网站的常见问题解答部分中很常见。其次,空answers.text
条目表示无法在评论中找到答案的“无法回答”的问题。最后,我们可以使用答案范围的起始索引和长度来切出评论中与答案相对应的文本范围:
- start_idx = sample_df["answers.answer_start"].iloc[0][0]
- end_idx = start_idx + len(sample_df["answers.text"].iloc[0][0])
- sample_df["context"].iloc[0][start_idx:end_idx]
'this keyboard is compact'
接下来,让我们通过计算以几个常见起始词开头的问题,来了解一下训练集中有哪些类型的问题:
- counts = {}
- question_types = ["What", "How", "Is", "Does", "Do", "Was", "Where", "Why"]
-
- for q in question_types:
- counts[q] = dfs["train"]["question"].str.startswith(q).value_counts()[True]
-
- pd.Series(counts).sort_values().plot.barh()
- plt.title("Frequency of Question Types")
- plt.show()
我们可以看到以“How”、“What”和“Is”开头的问题是最常见的问题,让我们看一些例子:
- for question_type in ["How", "What", "Is"]:
- for question in (
- dfs["train"][dfs["train"].question.str.startswith(question_type)]
- .sample(n=3, random_state=42)['question']):
- print(question)
How is the camera? How do you like the control? How fast is the charger? What is direction? What is the quality of the construction of the bag? What is your impression of the product? Is this how zoom works? Is sound clear? Is it a wireless keyboard?
斯坦福问答数据集
SubjQA的(question, review, [answer sentence])格式通常用于抽取式 QA 数据集,并在斯坦福问答数据集 (SQuAD) 中首创。5这是一个著名的数据集,通常用于测试机器阅读一段文本并回答相关问题的能力。该数据集是通过从 Wikipedia 中抽样数百篇英文文章,将每篇文章分成段落,然后要求众包工作人员为每个段落生成一组问题和答案而创建的。在 SQuAD 的第一个版本中,每个问题的答案都保证存在于相应的段落中。但不久之后,序列模型在提取正确的文本范围和答案方面表现得比人类更好。为了使任务更加困难,SQuAD 2.0 是通过在 SQuAD 1.1 中增加一组与给定段落相关但不能仅从文本中回答的对抗性问题而创建的。6图 7-3显示了撰写本书时的最新技术, 自 2019 年以来,大多数模型的表现都超过了人类。
图 7-3。SQuAD 2.0 基准测试的进展(图片来自 Papers with Code)
然而,这种超人的表现似乎并不反映真正的阅读理解,因为“无法回答”问题的答案通常可以通过反义词等段落中的模式来识别。为解决这些问题,Google 发布了自然问题 (NQ) 数据集,7其中涉及从 Google 搜索用户处获得的寻求事实的问题。NQ 中的答案比 SQuAD 中的答案要长得多,并且提出了更具挑战性的基准。
现在我们已经稍微探索了我们的数据集,让我们深入了解转换器如何从文本中提取答案。
我们的 QA 系统需要做的第一件事是找到一种方法,将潜在答案识别为客户评论中的一段文本。例如,如果我们有一个问题,比如“它防水吗?” 并且评论段落是“This watch is waterproof at 30m depth”,那么模型应该输出“waterproof at 30m”。为此,我们需要了解如何:
构建监督学习问题。
为 QA 任务标记和编码文本。
处理超过模型最大上下文大小的长段落。
让我们先来看看如何界定问题。
从文本中提取答案的最常见方法是将问题构建为跨度分类任务,其中答案跨度的开始和结束标记充当模型需要预测的标签。这个过程如图 7-4 所示。
图 7-4。QA 任务的跨度分类头
由于我们的训练集相对较小,只有 1,295 个示例,因此一个好的策略是从已经在 SQuAD 等大规模 QA 数据集上微调过的语言模型开始。一般来说,这些模型具有很强的阅读理解能力,可以作为构建更准确系统的良好基准。这与前几章中采用的方法有些不同,在前几章中,我们通常从预训练模型开始,然后自己微调特定任务的头部。例如,在 第 2 章中,我们必须微调分类头,因为类的数量与手头的数据集相关。对于抽取式 QA,我们实际上可以从微调模型开始,因为标签的结构在数据集中保持相同。
您可以通过导航到 Hugging Face Hub并在“模型”选项卡上搜索“小队”来找到提取 QA 模型的列表(图 7-5)。
图 7-5。Hugging Face Hub 上的一系列抽取式 QA 模型
如您所见,在撰写本文时,有 350 多个 QA 模型可供选择——那么您应该选择哪一个?一般来说,答案取决于各种因素,例如您的语料库是单语言还是多语言,以及在生产环境中运行模型的限制。表 7-2列出了一些模型,它们为构建提供了良好的基础。
Transformer | 描述 | 参数数量 | F1-score on SQuAD 2.0 |
---|---|---|---|
MiniLM | BERT-base 的精炼版本,可保留 99% 的性能,同时速度提高一倍 | 66M | 79.5 |
RoBERTa-base | RoBERTa 模型比 BERT 模型具有更好的性能,并且可以使用单个 GPU 在大多数 QA 数据集上进行微调 | 125M | 83.0 |
ALBERT-XXL | SQuAD 2.0 上最先进的性能,但计算密集且难以部署 | 235M | 88.1 |
XLM-RoBERTa-large | 100 种语言的多语言模型,具有强大的零样本性能 | 570M | 83.8 |
出于本章的目的,我们将使用微调的 MiniLM 模型,因为它训练速度很快,并且允许我们快速迭代我们将要探索的技术。8 像往常一样,我们首先需要一个标记器来对我们的文本进行编码,所以让我们看看它是如何用于 QA 任务的。
为了对我们的文本进行编码,我们将照常从Hugging Face Hub加载 MiniLM 模型检查点:
- from transformers import AutoTokenizer
-
- model_ckpt = "deepset/minilm-uncased-squad2"
- tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
要查看实际运行的模型,让我们首先尝试从一小段文本中提取答案。在抽取式 QA 任务中,输入作为(问题,上下文)对提供,因此我们将它们都传递给标记器,如下所示:
- question = "How much music can this hold?"
- context = """An MP3 is about 1 MB/minute, so about 6000 hours depending on \
- file size."""
- inputs = tokenizer(question, context, return_tensors="pt")
在这里,我们返回了 PyTorchTensor
对象,因为我们需要它们在模型中运行正向传递。如果我们将标记化的输入视为一个表格:
input_ids | 101 | 2129 | 2172 | 2189 | 2064 | 2023 | ... | 5834 | 2006 | 5371 | 2946 | 1012 | 102 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
token_type_ids | 0 | 0 | 0 | 0 | 0 | 0 | ... | 1 | 1 | 1 | 1 | 1 | 1 |
attention_mask | 1 | 1 | 1 | 1 | 1 | 1 | ... | 1 | 1 | 1 | 1 | 1 | 1 |
我们可以看到熟悉的input_ids
和attention_mask
张量,而token_type_ids
张量表示输入的哪一部分对应于问题和上下文(0 表示问题标记,1 表示上下文标记)。9
要了解标记器如何格式化 QA 任务的输入,让我们解码input_ids
张量:
print(tokenizer.decode(inputs["input_ids"][0]))
[CLS] how much music can this hold? [SEP] an mp3 is about 1 mb / minute, so about 6000 hours depending on file size. [SEP]
[CLS] question tokens [SEP] context tokens [SEP]
其中第一个[SEP]
令牌的位置由 token_type_ids
. 现在我们的文本已被标记化,我们只需要使用 QA 头实例化模型并通过前向传递运行输入:
- import torch
- from transformers import AutoModelForQuestionAnswering
-
- model = AutoModelForQuestionAnswering.from_pretrained(model_ckpt)
-
- with torch.no_grad():
- outputs = model(**inputs)
- print(outputs)
- QuestionAnsweringModelOutput(loss=None, start_logits=tensor([[-0.9862, -4.7750,
- -5.4025, -5.2378, -5.2863, -5.5117, -4.9819, -6.1880,
- -0.9862, 0.2596, -0.2144, -1.7136, 3.7806, 4.8561, -1.0546, -3.9097,
- -1.7374, -4.5944, -1.4278, 3.9949, 5.0390, -0.2018, -3.0193, -4.8549,
- -2.3107, -3.5110, -3.5713, -0.9862]]), end_logits=tensor([[-0.9623,
- -5.4733, -5.0326, -5.1639, -5.4278, -5.5151, -5.1749, -4.6233,
- -0.9623, -3.7855, -0.8715, -3.7745, -3.0161, -1.1780, 0.1758, -2.7365,
- 4.8934, 0.3046, -3.1761, -3.2762, 0.8937, 5.6606, -0.3623, -4.9554,
- -3.2531, -0.0914, 1.6211, -0.9623]]), hidden_states=None,
- attentions=None)
在这里我们可以看到我们得到了一个QuestionAnsweringModelOutput
对象作为 QA 头的输出。如图7-4 所示,QA 头对应于一个线性层,该层从编码器获取隐藏状态并计算开始和结束跨度的 logits。10这意味着我们将 QA 视为令牌分类的一种形式,类似于我们在第 4 章中遇到的命名实体识别。要将输出转换为答案范围,我们首先需要获取开始和结束标记的 logits:
- start_logits = outputs.start_logits
- end_logits = outputs.end_logits
如果我们将这些 logits 的形状与输入 ID 进行比较:
- print(f"Input IDs shape: {inputs.input_ids.size()}")
- print(f"Start logits shape: {start_logits.size()}")
- print(f"End logits shape: {end_logits.size()}")
Input IDs shape: torch.Size([1, 28]) Start logits shape: torch.Size([1, 28]) End logits shape: torch.Size([1, 28])
我们看到每个输入标记有两个 logits(一个开始和结束)。如图7-6 所示,更大的正 logits 对应于更有可能的开始和结束标记的候选者。在这个例子中,我们可以看到模型将最高的起始标记 logits 分配给数字“1”和“6000”,这是有道理的,因为我们的问题是询问数量。同样,我们看到具有最高 logit 的结束标记是“分钟”和“小时”。
图 7-6。开始和结束标记的预测 logits;得分最高的令牌以橙色着色
为了得到最终答案,我们可以计算开始和结束标记 logits 上的 argmax,然后从输入中分割跨度。以下代码执行这些步骤并解码结果,以便我们可以打印结果文本:
- import torch
-
- start_idx = torch.argmax(start_logits)
- end_idx = torch.argmax(end_logits) + 1
- answer_span = inputs["input_ids"][0][start_idx:end_idx]
- answer = tokenizer.decode(answer_span)
- print(f"Question: {question}")
- print(f"Answer: {answer}")
Question: How much music can this hold? Answer: 6000 hours
太好了,它奏效了!在Transformers 中,所有这些预处理和后处理步骤都方便地包装在专用管道中。我们可以通过传递我们的分词器和微调模型来实例化管道,如下所示:
- from transformers import pipeline
-
- pipe = pipeline("question-answering", model=model, tokenizer=tokenizer)
- pipe(question=question, context=context, topk=3)
[{'score': 0.26516005396842957, 'start': 38, 'end': 48, 'answer': '6000 hours'}, {'score': 0.2208300083875656, 'start': 16, 'end': 48, 'answer': '1 MB/minute, so about 6000 hours'}, {'score': 0.10253632068634033, 'start': 16, 'end': 27, 'answer': '1 MB/minute'}]
除了答案之外,管道还返回模型在score
现场的概率估计(通过对 logits 进行 softmax 获得)。当我们想要在单个上下文中比较多个答案时,这很方便。我们还展示了我们可以通过指定topk
参数让模型预测多个答案。有时,可能会有无法回答的问题,例如answers.answer_start
SubjQA 中的空示例。在这些情况下,模型将为令牌分配一个高开始和结束分数[CLS]
,并且管道将此输出映射到一个空字符串:
- pipe(question="Why is there no data?", context=context,
- handle_impossible_answer=True)
{'score':0.9068416357040405,'start':0,'end':0,'answer':''}
阅读理解模型面临的一个微妙之处是上下文通常包含比模型的最大序列长度更多的标记(通常最多几百个标记)。如图 7-7所示,SubjQA 训练集的相当一部分包含不适合 MiniLM 的 512 个标记的上下文大小的问题-上下文对。
图 7-7。SubjQA 训练集中每个问题-上下文对的标记分布
对于其他任务,如文本分类,我们只是在假设令牌嵌入中包含足够信息[CLS]
以生成准确预测的情况下截断长文本。然而,对于 QA,这种策略是有问题的,因为问题的答案可能位于上下文的末尾附近,因此会被截断删除。如图7-8 所示,处理这个问题的标准方法是在输入中应用一个滑动窗口,其中每个窗口都包含一段适合模型上下文的标记。
图 7-8。滑动窗口如何为长文档创建多个问题-上下文对 - 第一个栏对应于问题,而第二个栏是每个窗口中捕获的上下文
在Transformers 中,我们可以 return_overflowing_tokens=True
在分词器中设置启用滑动窗口。滑动窗口的大小由 max_seq_length
参数控制,步幅的大小由 控制 doc_stride
。让我们从我们的训练集中获取第一个示例并定义一个小窗口来说明它是如何工作的:
- example = dfs["train"].iloc[0][["question", "context"]]
- tokenized_example = tokenizer(example["question"], example["context"],
- return_overflowing_tokens=True, max_length=100,
- stride=25)
在这种情况下,我们现在得到一个列表input_ids
,每个窗口一个。让我们检查每个窗口中的令牌数量:
- for idx, window in enumerate(tokenized_example["input_ids"]):
- print(f"Window #{idx} has {len(window)} tokens")
Window #0 has 100 tokens Window #1 has 88 tokens
- for window in tokenized_example["input_ids"]:
- print(f"{tokenizer.decode(window)} \n")
[CLS] how is the bass? [SEP] i have had koss headphones in the past, pro 4aa and qz - 99. the koss portapro is portable and has great bass response. the work great with my android phone and can be " rolled up " to be carried in my motorcycle jacket or computer bag without getting crunched. they are very light and don't feel heavy or bear down on your ears even after listening to music with them on all day. the sound is [SEP] [CLS] how is the bass? [SEP] and don't feel heavy or bear down on your ears even after listening to music with them on all day. the sound is night and day better than any ear - bud could be and are almost as good as the pro 4aa. they are " open air " headphones so you cannot match the bass to the sealed types, but it comes close. for $ 32, you cannot go wrong. [SEP]
在我们简单的答案提取示例中,我们为模型提供了问题和上下文。然而,实际上我们系统的用户只会提供关于产品的问题,因此我们需要某种方式从我们语料库中的所有评论中选择相关段落。一种方法是将给定产品的所有评论连接在一起,并将它们作为一个单一的长上下文提供给模型。虽然简单,但这种方法的缺点是上下文可能会变得非常长,从而为我们的用户查询引入不可接受的延迟。例如,假设平均每个产品有 30 条评论,每条评论需要 100 毫秒来处理。如果我们需要处理所有评论以获得答案,
为了解决这个问题,现代 QA 系统通常基于 检索器-阅读器架构,它有两个主要组件:
负责检索给定查询的相关文档。检索器通常被分类为稀疏或密集。稀疏检索器使用词频将每个文档和查询表示为稀疏向量。11然后通过计算向量的内积来确定查询和文档的相关性。另一方面,密集检索器使用转换器之类的编码器将查询和文档表示为上下文嵌入(密集向量)。这些嵌入对语义进行编码,并允许密集检索器通过了解查询的内容来提高搜索准确性。
负责从检索器提供的文档中提取答案。读者通常是阅读理解模型,尽管在本章末尾我们会看到可以生成自由格式答案的模型示例。
如图7-9 所示,还可以有其他组件对检索器获取的文档或阅读器提取的答案进行后处理。例如,检索到的文档可能需要重新排序以消除可能使读者感到困惑的嘈杂或不相关的文档。同样,当正确答案来自长文档中的各个段落时,通常需要对读者的答案进行后处理。
图 7-9。现代 QA 系统的检索器-阅读器架构
为了构建我们的 QA 系统,我们将使用 由专注于 NLP 的德国公司deepset开发 的Haystack库。Haystack 基于检索器-阅读器架构,抽象了构建这些系统所涉及的大部分复杂性,并与Transformer 紧密集成。除了检索器和读取器之外,在使用 Haystack 构建 QA 管道时还涉及另外两个组件:
一种面向文档的数据库,用于存储在查询时提供给检索器的文档和元数据
管道
结合 QA 系统的所有组件以启用自定义查询流、合并来自多个检索器的文档等
在本节中,我们将了解如何使用这些组件快速构建原型 QA 管道。稍后,我们将研究如何提高其性能。
警告
本章是使用 Haystack 库的 0.9.0 版本编写的。在0.10.0 版本中,重新设计了管道和评估 API,以便更轻松地检查检索器或读取器是否影响性能。要查看本章代码在新 API 中的样子,请查看GitHub 存储库。
在 Haystack 中,有多种文档存储可供选择,每个存储都可以与一组专用的检索器配对。这在表 7-3中进行了说明,其中显示了每个可用文档存储的稀疏(TF-IDF,BM25)和密集(Embedding,DPR)检索器的兼容性。我们将在本章后面解释所有这些首字母缩略词的含义。
In memory | Elasticsearch | FAISS | Milvus | |
---|---|---|---|---|
TF-IDF | Yes | Yes | No | No |
BM25 | No | Yes | No | No |
Embedding | Yes | Yes | Yes | Yes |
DPR | Yes | Yes | Yes | Yes |
由于我们将在本章中探索稀疏和密集检索器,我们将使用ElasticsearchDocumentStore
与两种检索器类型兼容的 。Elasticsearch 是一个搜索引擎,能够处理各种数据类型,包括文本、数字、地理空间、结构化和非结构化。它能够存储大量数据并通过全文搜索功能快速过滤数据,使其特别适合开发 QA 系统。它还具有成为基础架构分析行业标准的优势,因此您的公司很有可能已经拥有可以使用的集群。
要初始化文档存储,我们首先需要下载并安装 Elasticsearch。按照 Elasticsearch 的 指南12,我们可以使用shell 命令tar
获取 Linux 的最新版本wget
并解压:
- url = """https://artifacts.elastic.co/downloads/elasticsearch/\
- elasticsearch-7.9.2-linux-x86_64.tar.gz"""
- !wget -nc -q {url}
- !tar -xzf elasticsearch-7.9.2-linux-x86_64.tar.gz
接下来我们需要启动 Elasticsearch 服务器。由于我们在 Jupyter 笔记本中运行本书中的所有代码,因此我们需要使用 Python 的Popen()
函数来生成一个新进程。当我们这样做的时候,让我们也使用chown
shell 命令在后台运行子进程:
- import os
- from subprocess import Popen, PIPE, STDOUT
-
- # Run Elasticsearch as a background process
- !chown -R daemon:daemon elasticsearch-7.9.2
- es_server = Popen(args=['elasticsearch-7.9.2/bin/elasticsearch'],
- stdout=PIPE, stderr=STDOUT, preexec_fn=lambda: os.setuid(1))
- # Wait until Elasticsearch has started
- !sleep 30
在Popen()
函数中,args
指定我们希望执行的程序,同时stdout=PIPE
为标准输出创建一个新管道stderr=STDOUT
并将错误收集在同一管道中。该 preexec_fn
参数指定我们希望使用的子进程的 ID。默认情况下,Elasticsearch 在本地端口 9200 上运行,因此我们可以通过发送 HTTP 请求来测试连接localhost
:
!curl -X GET "localhost:9200/?pretty"
{ "name" : "96938eee37cd", "cluster_name" : "docker-cluster", "cluster_uuid" : "ABGDdvbbRWmMb9Umz79HbA", "version" : { "number" : "7.9.2", "build_flavor" : "default", "build_type" : "docker", "build_hash" : "d34da0ea4a966c4e49417f2da2f244e3e97b4e6e", "build_date" : "2020-09-23T00:45:33.626720Z", "build_snapshot" : false, "lucene_version" : "8.6.2", "minimum_wire_compatibility_version" : "6.8.0", "minimum_index_compatibility_version" : "6.0.0-beta1" }, "tagline" : "You Know, for Search" }
现在我们的 Elasticsearch 服务器已经启动并运行,接下来要做的是实例化文档存储:
- from haystack.document_store.elasticsearch import ElasticsearchDocumentStore
-
- # Return the document embedding for later use with dense retriever
- document_store = ElasticsearchDocumentStore(return_embedding=True)
默认情况下,ElasticsearchDocumentStore
在 Elasticsearch 上创建两个索引:一个调用document
(你猜对了)存储文档,另一个调用label
存储带注释的答案跨度。现在,我们只document
用 SubjQA 评论填充索引,Haystack 的文档存储需要一个带有text
和meta
键的字典列表,如下所示:
{ "text": "", "meta": { "field_01": " ", "field_02": " ", ... } }
中的字段meta
可用于在检索期间应用过滤器。出于我们的目的,我们将包含 SubjQA 的item_id
和 q_review_id
列,以便我们可以按产品和问题 ID 以及相应的训练拆分进行过滤。然后,我们可以遍历每个示例中的示例,DataFrame
并使用以下方法将它们添加到索引中write_documents()
:
- for split, df in dfs.items():
- # Exclude duplicate reviews
- docs = [{"text": row["context"],
- "meta":{"item_id": row["title"], "question_id": row["id"],
- "split": split}}
- for _,row in df.drop_duplicates(subset="context").iterrows()]
- document_store.write_documents(docs, index="document")
-
- print(f"Loaded {document_store.get_document_count()} documents")
Loaded 1615 documents
太好了,我们已将所有评论加载到索引中!要搜索索引,我们需要一个检索器,所以让我们看看如何为 Elasticsearch 初始化一个。
Elasticsearch 文档存储可以与任何 Haystack 检索器配对,因此让我们从使用基于 BM25(“Best Match 25”的缩写)的稀疏检索器开始。BM25 是经典词频-逆文档频率 (TF-IDF) 算法的改进版本,将问题和上下文表示为可以在 Elasticsearch 上高效搜索的稀疏向量。BM25 分数衡量与搜索查询有关的匹配文本的数量,并通过快速饱和 TF 值和规范化文档长度来改进 TF-IDF,从而使短文档比长文档更受青睐。13
在 Haystack 中,默认使用 BM25 检索器 ElasticsearchRetriever
,所以让我们通过指定我们希望搜索的文档存储来初始化这个类:
- rom haystack.retriever.sparse import ElasticsearchRetriever
-
- es_retriever = ElasticsearchRetriever(document_store=document_store)
接下来,让我们看一个简单的查询训练集中的单个电子产品。对于像我们这样的基于评论的 QA 系统,将查询限制为单个项目很重要,因为否则检索器会获取与用户查询无关的产品评论。例如,询问“相机质量好吗?” 当用户可能会询问特定的笔记本电脑相机时,如果没有产品过滤器,则可能会返回有关手机的评论。就其本身而言,我们数据集中的 ASIN 值有点神秘,但我们可以使用 amazon ASIN等在线工具或简单地将值附加item_id
到www.amazon.com/dp/ URL 来破译它们。以下商品 ID 对应亚马逊的 Fire 平板电脑之一,所以我们使用检索器的retrieve()
询问它是否适合阅读的方法:
- item_id = "B0074BW614"
- query = "Is it good for reading?"
- retrieved_docs = es_retriever.retrieve(
- query=query, top_k=3, filters={"item_id":[item_id], "split":["train"]})
在这里,我们指定了使用参数返回的文档 数量,并对文档字段 中包含的和键top_k
应用了过滤器。的每个元素都是一个 Haystack对象,用于表示文档并包括检索器的查询分数以及其他元数据。让我们看一下检索到的文档之一:item_id
split
meta
retrieved_docs
Document
print(retrieved_docs[0])
{'text': 'This is a gift to myself. I have been a kindle user for 4 years and this is my third one. I never thought I would want a fire for I mainly use it for book reading. I decided to try the fire for when I travel I take my laptop, my phone and my iPod classic. I love my iPod but watching movies on the plane with it can be challenging because it is so small. Laptops battery life is not as good as the Kindle. So the Fire combines for me what I needed all three to do. So far so good.', 'score': 6.243799, 'probability': 0.6857824513476455, 'question': None, 'meta': {'item_id': 'B0074BW614', 'question_id': '868e311275e26dbafe5af70774a300f3', 'split': 'train'}, 'embedding': None, 'id': '252e83e25d52df7311d597dc89eef9f6'}
除了文档的文本之外,我们还可以看到score
Elasticsearch 计算出的与查询的相关性(分数越高意味着匹配越好)。在底层,Elasticsearch 依赖 Lucene进行索引和搜索,因此默认情况下它使用 Lucene 的实用评分功能。您可以在Elasticsearch 文档中找到评分函数背后的细节 ,但简而言之,它首先通过应用布尔测试(文档是否与查询匹配?)过滤候选文档,然后应用基于的相似性度量将文档和查询都表示为向量。
现在我们有了一种检索相关文档的方法,接下来我们需要一种从它们中提取答案的方法。这就是读者进来的地方,所以让我们看看我们如何在 Haystack 中加载我们的 MiniLM 模型。
在 Haystack 中,可以使用两种类型的阅读器从给定的上下文中提取答案:
FARMReader
基于 deepset 的 FARM框架,用于微调和部署转换器。与使用 Transformers 训练的模型兼容, 并且可以直接从 Hugging Face Hub 加载模型。
TransformersReader
基于 Transformers 的 QA 管道。仅适用于运行推理。
尽管两个阅读器都以相同的方式处理模型的权重,但在转换预测以产生答案的方式上存在一些差异:
在Transformers 中,QA 管道在每个段落中使用 softmax 对开始和结束 logits 进行归一化。这意味着仅比较从同一段落中提取的答案之间的答案分数才有意义,其中概率总和为 1。例如,一个段落的答案分数 0.9 不一定比另一段落的分数 0.8 好。在 FARM 中,logits 没有标准化,因此可以更轻松地比较段落间的答案。
有时会两次预测相同的TransformersReader
答案,但分数不同。如果答案位于两个重叠的窗口中,这可能会在较长的上下文中发生。在 FARM 中,这些重复项被删除。
由于我们将在本章后面对阅读器进行微调,因此我们将使用FARMReader
. 与 Transformers 一样,要加载模型,我们只需要在 Hugging Face Hub 上指定 MiniLM 检查点以及一些特定于 QA 的参数:
- from haystack.reader.farm import FARMReader
-
- model_ckpt = "deepset/minilm-uncased-squad2"
- max_seq_length, doc_stride = 384, 128
- reader = FARMReader(model_name_or_path=model_ckpt, progress_bar=False,
- max_seq_len=max_seq_length, doc_stride=doc_stride,
- return_no_answer=True)
笔记
也可以直接在 Transformers 中微调阅读理解模型,然后将其加载TransformersReader
到运行推理中。有关如何进行微调步骤的详细信息,请参阅库 文档中的问答教程。
在FARMReader
中,滑动窗口的行为由我们在分词器中看到的参数max_seq_length
和参数控制。doc_stride
在这里,我们使用了 MiniLM 论文中的值。为了确认,现在让我们在前面的简单示例中测试读者:
print(reader.predict_on_texts(question=question, texts=[context], top_k=1))
{'query': 'How much music can this hold?', 'no_ans_gap': 12.648084878921509, 'answers': [{'answer': '6000 hours', 'score': 10.69961929321289, 'probability': 0.3988136053085327, 'context': 'An MP3 is about 1 MB/minute, so about 6000 hours depending on file size.', 'offset_start': 38, 'offset_end': 48, 'offset_start_in_doc': 38, 'offset_end_in_doc': 48, 'document_id': 'e344757014e804eff50faa3ecf1c9c75'}]}
太好了,阅读器似乎按预期工作——所以接下来,让我们使用 Haystack 的一个管道将我们所有的组件联系在一起。
Haystack 提供了一种Pipeline
抽象,允许我们将检索器、读取器和其他组件组合在一起,形成一个可以为每个用例轻松定制的图形。还有一些预定义的管道类似于 Transformers 中的管道,但专门用于 QA 系统。在我们的例子中,我们有兴趣提取答案,所以我们将使用ExtractiveQAPipeline
,它接受一个检索器-读者对作为它的参数:
- from haystack.pipeline import ExtractiveQAPipeline
-
- pipe = ExtractiveQAPipeline(reader, es_retriever)
每个Pipeline
都有一个run()
方法来指定应该如何执行查询流。对于 ,ExtractiveQAPipeline
我们只需要传递query
、要检索的文档top_k_retriever
数量以及要从这些文档中提取的答案数量 top_k_reader
。在我们的例子中,我们还需要在项目 ID 上指定一个过滤器,这可以使用filters
参数来完成,就像我们之前对检索器所做的那样。让我们再次使用我们关于 Amazon Fire 平板电脑的问题运行一个简单的示例,但这次返回提取的答案:
- n_answers = 3
- preds = pipe.run(query=query, top_k_retriever=3, top_k_reader=n_answers,
- filters={"item_id": [item_id], "split":["train"]})
-
- print(f"Question: {preds['query']} \n")
- for idx in range(n_answers):
- print(f"Answer {idx+1}: {preds['answers'][idx]['answer']}")
- print(f"Review snippet: ...{preds['answers'][idx]['context']}...")
- print("\n\n")
Question: Is it good for reading? Answer 1: I mainly use it for book reading Review snippet: ... is my third one. I never thought I would want a fire for I mainly use it for book reading. I decided to try the fire for when I travel I take my la... Answer 2: the larger screen compared to the Kindle makes for easier reading Review snippet: ...ght enough that I can hold it to read, but the larger screen compared to the Kindle makes for easier reading. I love the color, something I never thou... Answer 3: it is great for reading books when no light is available Review snippet: ...ecoming addicted to hers! Our son LOVES it and it is great for reading books when no light is available. Amazing sound but I suggest good headphones t...
太好了,我们现在有一个用于亚马逊产品评论的端到端 QA 系统!这是一个好的开始,但请注意,第二个和第三个答案更接近问题的实际要求。为了做得更好,我们需要一些指标来量化检索器和读取器的性能。接下来我们来看看。
尽管最近关于 QA 的大部分研究都集中在改进阅读理解模型上,但在实践中,如果检索器一开始就找不到相关文档,那么你的阅读器有多好并不重要!特别是,检索器为整个 QA 系统的性能设置了一个上限,因此确保它做得很好很重要。考虑到这一点,让我们首先介绍一些常用指标来评估检索器,以便我们可以比较稀疏和密集表示的性能。
评估检索器的一个常用指标是召回率,它衡量所有相关文档被检索到的比例。在这种情况下,“相关”仅仅意味着答案是否存在于文本段落中,因此给定一组问题,我们可以通过计算答案出现在由返回的前 k个文档中的次数来计算召回率。猎犬。
使用检索器的内置eval()
方法。这可用于开放域和封闭域 QA,但不适用于像 SubjQA 这样的数据集,其中每个文档都与单个产品配对,我们需要为每个查询按产品 ID 进行过滤。
构建一个Pipeline
将检索器与 EvalRetriever
类结合的自定义。这可以实现自定义指标和查询流。
由于我们需要评估每个产品的召回率,然后汇总所有产品,我们将选择第二种方法。图中的每个节点Pipeline
代表一个类,该类接受一些输入并通过一种run()
方法产生一些输出:
- class PipelineNode:
- def __init__(self):
- self.outgoing_edges = 1
-
- def run(self, **kwargs):
- ...
- return (outputs, "outgoing_edge_name")
这里kwargs
对应于图中前一个节点的输出,在run()
方法中对其进行操作以返回下一个节点的输出元组,以及传出边的名称。唯一的其他要求是包含一个outgoing_edges
属性,该属性指示来自节点的输出数量(在大多数情况下outgoing_edges=1
,除非您在管道中有根据某些标准路由输入的分支)。
在我们的例子中,我们需要一个节点来评估检索器,因此我们将使用EvalRetriever
其run()
方法跟踪哪些文档的答案与基本事实相匹配的类。Pipeline
使用这个类,我们可以通过在代表检索器本身的节点之后添加评估节点来构建图:
- from haystack.pipeline import Pipeline
- from haystack.eval import EvalDocuments
-
- class EvalRetrieverPipeline:
- def __init__(self, retriever):
- self.retriever = retriever
- self.eval_retriever = EvalDocuments()
- pipe = Pipeline()
- pipe.add_node(component=self.retriever, name="ESRetriever",
- inputs=["Query"])
- pipe.add_node(component=self.eval_retriever, name="EvalRetriever",
- inputs=["ESRetriever"])
- self.pipeline = pipe
-
-
- pipe = EvalRetrieverPipeline(es_retriever)
请注意,每个节点都有一个name
和一个 的列表inputs
。在大多数情况下,每个节点都有一条出边,所以我们只需要将前一个节点的名称包含在inputs
.
现在我们有了评估管道,我们需要传递一些查询及其相应的答案。为此,我们会将答案添加到label
文档存储中的专用索引中。Haystack 提供了一个Label
对象,该对象以标准化的方式表示答案范围及其元数据。为了填充label
索引,我们将首先Label
通过循环测试集中的每个问题并提取匹配的答案和其他元数据来创建一个对象列表:
- from haystack import Label
-
- labels = []
- for i, row in dfs["test"].iterrows():
- # Metadata used for filtering in the Retriever
- meta = {"item_id": row["title"], "question_id": row["id"]}
- # Populate labels for questions with answers
- if len(row["answers.text"]):
- for answer in row["answers.text"]:
- label = Label(
- question=row["question"], answer=answer, id=i, origin=row["id"],
- meta=meta, is_correct_answer=True, is_correct_document=True,
- no_answer=False)
- labels.append(label)
- # Populate labels for questions without answers
- else:
- label = Label(
- question=row["question"], answer="", id=i, origin=row["id"],
- meta=meta, is_correct_answer=True, is_correct_document=True,
- no_answer=True)
- labels.append(label)
print(labels[0])
{'id': 'e28f5e62-85e8-41b2-8a34-fbff63b7a466', 'created_at': None, 'updated_at': None, 'question': 'What is the tonal balance of these headphones?', 'answer': 'I have been a headphone fanatic for thirty years', 'is_correct_answer': True, 'is_correct_document': True, 'origin': 'd0781d13200014aa25860e44da9d5ea7', 'document_id': None, 'offset_start_in_doc': None, 'no_answer': False, 'model_id': None, 'meta': {'item_id': 'B00001WRSJ', 'question_id': 'd0781d13200014aa25860e44da9d5ea7'}}
我们可以看到问答对,以及origin
包含唯一问题 ID 的字段,因此我们可以过滤每个问题的文档存储。我们还在该meta
字段中添加了产品 ID,以便我们可以按产品过滤标签。现在我们有了标签,我们可以将它们写入label
Elasticsearch 的索引,如下所示:
- document_store.write_labels(labels, index="label")
- print(f"""Loaded {document_store.get_label_count(index="label")} \
- question-answer pairs""")
Loaded 358 question-answer pairs
接下来,我们需要在我们的问题 ID 和我们可以传递给管道的相应答案之间建立一个映射。要获取所有标签,我们可以使用get_all_labels_aggregated()
文档存储中的方法,该方法将聚合与唯一 ID 关联的所有问答对。这个方法返回一个MultiLabel
对象列表,但在我们的例子中,我们只得到一个元素,因为我们是按问题 ID 过滤的。我们可以建立一个聚合标签列表,如下所示:
- labels_agg = document_store.get_all_labels_aggregated(
- index="label",
- open_domain=True,
- aggregate_by_meta=["item_id"]
- )
- print(len(labels_agg))
330
通过查看这些标签之一,我们可以看到与给定问题相关的所有答案都汇总在一个 multiple_answers
字段中:
print(labels_agg[109])
{'question': 'How does the fan work?', 'multiple_answers': ['the fan is really really good', "the fan itself isn't super loud. There is an adjustable dial to change fan speed"], 'is_correct_answer': True, 'is_correct_document': True, 'origin': '5a9b7616541f700f103d21f8ad41bc4b', 'multiple_document_ids': [None, None], 'multiple_offset_start_in_docs': [None, None], 'no_answer': False, 'model_id': None, 'meta': {'item_id': 'B002MU1ZRS'}}
我们现在拥有评估检索器的所有要素,因此让我们定义一个函数,将与每个产品关联的每个问答对馈送到评估管道并跟踪我们pipe
对象中的正确检索:
- def run_pipeline(pipeline, top_k_retriever=10, top_k_reader=4):
- for l in labels_agg:
- _ = pipeline.pipeline.run(
- query=l.question,
- top_k_retriever=top_k_retriever,
- top_k_reader=top_k_reader,
- top_k_eval_documents=top_k_retriever,
- labels=l,
- filters={"item_id": [l.meta["item_id"]], "split": ["test"]})
- run_pipeline(pipe, top_k_retriever=3)
- print(f"Recall@3: {pipe.eval_retriever.recall:.2f}")
Recall@3: 0.95
太好了,它有效!请注意,我们选择了一个特定的值 top_k_retriever
来指定要检索的文档数。一般来说,增加这个参数会提高召回率,但代价是向读者提供更多文档并减慢端到端管道。为了指导我们决定选择哪个值,我们将创建一个循环多个 k值的函数,并为每个k计算整个测试集的召回率:
- def evaluate_retriever(retriever, topk_values = [1,3,5,10,20]):
- topk_results = {}
-
- for topk in topk_values:
- # Create Pipeline
- p = EvalRetrieverPipeline(retriever)
- # Loop over each question-answers pair in test set
- run_pipeline(p, top_k_retriever=topk)
- # Get metrics
- topk_results[topk] = {"recall": p.eval_retriever.recall}
-
- return pd.DataFrame.from_dict(topk_results, orient="index")
-
-
- es_topk_df = evaluate_retriever(es_retriever)
如果我们绘制结果,我们可以看到随着k的增加召回率如何提高:
- def plot_retriever_eval(dfs, retriever_names):
- fig, ax = plt.subplots()
- for df, retriever_name in zip(dfs, retriever_names):
- df.plot(y="recall", ax=ax, label=retriever_name)
- plt.xticks(df.index)
- plt.ylabel("Top-k Recall")
- plt.xlabel("k")
- plt.show()
-
- plot_retriever_eval([es_topk_df], ["BM25"])
从图中,我们可以看到周围有一个拐点k=5我们得到了几乎完美的回忆 k=10向前。现在让我们看看使用密集向量技术检索文档。
我们已经看到,当我们的稀疏检索器返回时,我们得到了几乎完美的回忆k=10文档,但是我们可以在较小的k值下做得更好吗?这样做的好处是我们可以将更少的文档传递给阅读器,从而减少 QA 管道的整体延迟。像 BM25 这样的稀疏检索器的一个众所周知的限制是,如果用户查询包含与评论不完全匹配的术语,它们可能无法捕获相关文档。一个有前途的替代方案是使用密集嵌入来表示问题和文档,当前的技术状态是一种称为密集通道检索(DPR) 的架构。14 DPR 背后的主要思想是使用两个 BERT 模型作为问题和文章的编码器。如图7-10 所示,这些编码器将输入文本映射为令牌的d维向量表示[CLS]
。
图 7-10。DPR 用于计算文档和查询相关性的双编码器架构
在 Haystack 中,我们可以用与 BM25 类似的方式为 DPR 初始化检索器。除了指定文档存储之外,我们还需要为问题和段落选择 BERT 编码器。这些编码器是通过向他们提出具有相关(正面)段落和不相关(负面)段落的问题来训练的,其目标是了解相关的问题-段落对具有更高的相似性。对于我们的用例,我们将使用在 NQ 语料库上以这种方式微调过的编码器:
- from haystack.retriever.dense import DensePassageRetriever
-
- dpr_retriever = DensePassageRetriever(document_store=document_store,
- query_embedding_model="facebook/dpr-question_encoder-single-nq-base",
- passage_embedding_model="facebook/dpr-ctx_encoder-single-nq-base",
- embed_title=False)
在这里我们还设置embed_title=False
了因为连接文档的标题(即,item_id
)不提供任何额外的信息,因为我们过滤每个产品。一旦我们初始化了密集检索器,下一步就是遍历 Elasticsearch 索引中的所有索引文档,并应用编码器来更新嵌入表示。这可以按如下方式完成:
document_store.update_embeddings(retriever=dpr_retriever)
我们现在准备出发!我们可以以与 BM25 相同的方式评估密集检索器,并比较 top- k召回率:
- dpr_topk_df = evaluate_retriever(dpr_retriever)
- plot_retriever_eval([es_topk_df, dpr_topk_df], ["BM25", "DPR"])
在这里,我们可以看到 DPR 并没有提供超过 BM25 的召回率提升,并且在k=3.
小费
使用 Facebook 的FAISS 库作为文档存储可以加快嵌入的相似性搜索。同样,可以通过对目标域进行微调来提高 DPR 检索器的性能。如果您想了解如何微调 DPR,请查看 Haystack教程。
如果预测和基本事实答案中的字符完全匹配,则给出 EM = 1 的二进制度量,否则 EM = 0。如果没有预期的答案,则如果模型预测任何文本,则 EM = 0。
测量准确率和召回率的调和平均值。
让我们通过从 FARM 导入一些辅助函数并将它们应用于一个简单的示例来看看这些指标是如何工作的:
- from farm.evaluation.squad_evaluation import compute_f1, compute_exact
-
- pred = "about 6000 hours"
- label = "6000 hours"
- print(f"EM: {compute_exact(label, pred)}")
- print(f"F1: {compute_f1(label, pred)}")
EM: 0 F1: 0.8
在后台,这些函数首先通过删除标点符号、修复空格和转换为小写来规范化预测和标签。然后,规范化的字符串被标记为词袋,然后最终在标记级别计算度量。从这个简单的例子中,我们可以看到 EM 是一个比 F 1 -score 更严格的度量:将单个标记添加到预测中会使 EM 为零。另一方面,F 1分数可能无法捕捉到真正不正确的答案。例如,如果我们预测的答案跨度是“大约 6000 美元”,那么我们得到:
- pred = "about 6000 dollars"
- print(f"EM: {compute_exact(label, pred)}")
- print(f"F1: {compute_f1(label, pred)}")
EM: 0 F1: 0.4
因此,仅依赖F 1 -score 会产生误导,跟踪这两个指标是平衡低估 (EM) 和高估 ( F 1 -score) 模型性能之间的权衡的好策略。
现在一般来说,每个问题都有多个有效答案,因此这些指标是针对评估集中的每个问答对计算的,并在所有可能的答案中选择最佳分数。然后通过对每个问答对的各个分数进行平均来获得模型的总体 EM 和F 1分数。
为了评估阅读器,我们将创建一个带有两个节点的新管道:一个阅读器节点和一个用于评估阅读器的节点。我们将使用EvalReader
从读者那里获取预测并计算相应的 EM 和F 1 分数的类。为了与 SQuAD 评估进行比较,我们将使用存储在中的top_1_em
和指标为每个查询获取最佳答案:top_1_f1
EvalAnswers
- from haystack.eval import EvalAnswers
-
- def evaluate_reader(reader):
- score_keys = ['top_1_em', 'top_1_f1']
- eval_reader = EvalAnswers(skip_incorrect_retrieval=False)
- pipe = Pipeline()
- pipe.add_node(component=reader, name="QAReader", inputs=["Query"])
- pipe.add_node(component=eval_reader, name="EvalReader", inputs=["QAReader"])
-
- for l in labels_agg:
- doc = document_store.query(l.question,
- filters={"question_id":[l.origin]})
- _ = pipe.run(query=l.question, documents=doc, labels=l)
-
- return {k:v for k,v in eval_reader.__dict__.items() if k in score_keys}
-
- reader_eval = {}
- reader_eval["Fine-tune on SQuAD"] = evaluate_reader(reader)
请注意,我们指定了skip_incorrect_retrieval=False
. 这是为了确保检索器始终将上下文传递给阅读器(如在 SQuAD 评估中)。现在我们已经通过阅读器运行了每个问题,让我们打印分数:
- def plot_reader_eval(reader_eval):
- fig, ax = plt.subplots()
- df = pd.DataFrame.from_dict(reader_eval)
- df.plot(kind="bar", ylabel="Score", rot=0, ax=ax)
- ax.set_xticklabels(["EM", "F1"])
- plt.legend(loc='upper left')
- plt.show()
-
- plot_reader_eval(reader_eval)
好的,微调后的模型在 SubjQA 上的表现似乎比在 SQuAD 2.0 上的表现要差得多,在 SQuAD 2.0 上,MiniLM 的 EM 和F 1 分数分别为 76.1 和 79.5。性能下降的一个原因是客户评论与生成 SQuAD 2.0 数据集的 Wikipedia 文章完全不同,而且他们使用的语言通常是非正式的。另一个因素可能是我们数据集固有的主观性,其中问题和答案都与维基百科中包含的事实信息不同。让我们看看如何在数据集上微调模型,以通过域适应获得更好的结果。
尽管在 SQuAD 上微调的模型通常可以很好地推广到其他领域,但我们已经看到,对于 SubjQA,我们模型的 EM 和 F 1分数比 SQuAD 差得多。在其他抽取式 QA 数据集中也观察到了这种泛化失败,这被认为是 Transformer 模型特别擅长过度拟合 SQuAD 的证据。15提高阅读器最直接的方法是在 SubjQA 训练集上进一步微调我们的 MiniLM 模型。FARMReader
有一个 为此目的而设计的train()
方法,并期望数据采用 SQuAD JSON 格式,其中所有问答对针对每个项目分组在一起, 如图 7-11 所示。
图 7-11。SQuAD JSON 格式的可视化
这是一种相当复杂的数据格式,所以我们需要一些函数和一些 Pandas 魔法来帮助我们进行转换。我们需要做的第一件事是实现一个函数,该函数可以创建 paragraphs
与每个产品 ID 关联的数组。该数组中的每个元素都包含一个上下文(即评论)和一qas
组问答对。这是一个构建 paragraphs
数组的函数:
- def create_paragraphs(df):
- paragraphs = []
- id2context = dict(zip(df["review_id"], df["context"]))
- for review_id, review in id2context.items():
- qas = []
- # Filter for all question-answer pairs about a specific context
- review_df = df.query(f"review_id == '{review_id}'")
- id2question = dict(zip(review_df["id"], review_df["question"]))
- # Build up the qas array
- for qid, question in id2question.items():
- # Filter for a single question ID
- question_df = df.query(f"id == '{qid}'").to_dict(orient="list")
- ans_start_idxs = question_df["answers.answer_start"][0].tolist()
- ans_text = question_df["answers.text"][0].tolist()
- # Fill answerable questions
- if len(ans_start_idxs):
- answers = [
- {"text": text, "answer_start": answer_start}
- for text, answer_start in zip(ans_text, ans_start_idxs)]
- is_impossible = False
- else:
- answers = []
- is_impossible = True
- # Add question-answer pairs to qas
- qas.append({"question": question, "id": qid,
- "is_impossible": is_impossible, "answers": answers})
- # Add context and question-answer pairs to paragraphs
- paragraphs.append({"qas": qas, "context": review})
- return paragraphs
现在,当我们应用到DataFrame
与单个产品 ID 关联的行时,我们得到 SQuAD 格式:
- product = dfs["train"].query("title == 'B00001P4ZH'")
- create_paragraphs(product)
- [{'qas': [{'question': 'How is the bass?',
- 'id': '2543d296da9766d8d17d040ecc781699',
- 'is_impossible': True,
- 'answers': []}],
- 'context': 'I have had Koss headphones ...',
- 'id': 'd476830bf9282e2b9033e2bb44bbb995',
- 'is_impossible': False,
- 'answers': [{'text': 'Bass is weak as expected', 'answer_start': 1302},
- {'text': 'Bass is weak as expected, even with EQ adjusted up',
- 'answer_start': 1302}]}],
- 'context': 'To anyone who hasn\'t tried all ...'},
- {'qas': [{'question': 'How is the bass?',
- 'id': '455575557886d6dfeea5aa19577e5de4',
- 'is_impossible': False,
- 'answers': [{'text': 'The only fault in the sound is the bass',
- 'answer_start': 650}]}],
- 'context': "I have had many sub-$100 headphones ..."}]
最后一步是将此函数应用于 DataFrame
每个拆分中的每个产品 ID。以下convert_to_squad()
函数执行此技巧并将结果存储在电子-{split}.json 文件中:
- import json
-
- def convert_to_squad(dfs):
- for split, df in dfs.items():
- subjqa_data = {}
- # Create `paragraphs` for each product ID
- groups = (df.groupby("title").apply(create_paragraphs)
- .to_frame(name="paragraphs").reset_index())
- subjqa_data["data"] = groups.to_dict(orient="records")
- # Save the result to disk
- with open(f"electronics-{split}.json", "w+", encoding="utf-8") as f:
- json.dump(subjqa_data, f)
-
- convert_to_squad(dfs)
现在我们有了正确格式的拆分,让我们通过指定训练和开发拆分的位置以及保存微调模型的位置来微调我们的阅读器:
- train_filename = "electronics-train.json"
- dev_filename = "electronics-validation.json"
-
- reader.train(data_dir=".", use_gpu=True, n_epochs=1, batch_size=16,
- train_filename=train_filename, dev_filename=dev_filename)
随着阅读器的微调,现在让我们将其在测试集上的性能与我们的基线模型进行比较:
- reader_eval["Fine-tune on SQuAD + SubjQA"] = evaluate_reader(reader)
- plot_reader_eval(reader_eval)
哇,域适应使我们的 EM 分数提高了六倍,并且使F 1分数增加了一倍多!此时,您可能想知道为什么我们不直接在 SubjQA 训练集上微调预训练的语言模型。一个原因是我们在 SubjQA 中只有 1,295 个训练样例,而 SQuAD 有超过 100,000 个,因此我们可能会遇到过拟合的挑战。不过,让我们看看幼稚微调会产生什么。为了公平比较,我们将使用用于微调 SQuAD 基线的相同语言模型。和以前一样,我们将使用以下内容加载模型FARMReader
:
- minilm_ckpt = "microsoft/MiniLM-L12-H384-uncased"
- minilm_reader = FARMReader(model_name_or_path=minilm_ckpt, progress_bar=False,
- max_seq_len=max_seq_length, doc_stride=doc_stride,
- return_no_answer=True)
接下来,我们微调一个 epoch:
- minilm_reader.train(data_dir=".", use_gpu=True, n_epochs=1, batch_size=16,
- train_filename=train_filename, dev_filename=dev_filename)
并包括对测试集的评估:
- reader_eval["Fine-tune on SubjQA"] = evaluate_reader(minilm_reader)
- plot_reader_eval(reader_eval)
我们可以看到,直接在 SubjQA 上微调语言模型比在 SQuAD 和 SubjQA 上微调性能要差得多。
警告
在处理小型数据集时,最好在评估转换器时使用交叉验证,因为它们容易过度拟合。您可以在FARM 存储库中找到如何使用 SQuAD 格式的数据集执行交叉验证的示例。
现在我们已经了解了如何单独评估阅读器和检索器组件,让我们将它们联系在一起来衡量我们管道的整体性能。为此,我们需要使用 reader 及其 评估的节点来扩充我们的检索器管道。我们已经看到我们在 k=10,所以我们可以修复这个值并评估它对阅读器性能的影响(因为与 SQuAD 风格的评估相比,它现在每个查询都会接收多个上下文):
- # Initialize retriever pipeline
- pipe = EvalRetrieverPipeline(es_retriever)
- # Add nodes for reader
- eval_reader = EvalAnswers()
- pipe.pipeline.add_node(component=reader, name="QAReader",
- inputs=["EvalRetriever"])
- pipe.pipeline.add_node(component=eval_reader, name="EvalReader",
- inputs=["QAReader"])
- # Evaluate!
- run_pipeline(pipe)
- # Extract metrics from reader
- reader_eval["QA Pipeline (top-1)"] = {
- k:v for k,v in eval_reader.__dict__.items()
- if k in ["top_1_em", "top_1_f1"]}
然后,我们可以比较模型的前 1 个 EM 和F 1分数,以预测图 7-12中检索器返回的文档中的答案。
图 7-12。读者的 EM 和F 1分数与整个 QA管道的比较
从这个图中我们可以看到检索器对整体性能的影响。特别是,与匹配问题-上下文对相比,存在整体退化,就像在 SQuAD 风格的评估中所做的那样。这可以通过增加允许读者预测的可能答案的数量来规避。
到目前为止,我们只从上下文中提取了答案范围,但一般来说,答案的零碎部分可能分散在整个文档中,我们希望我们的模型将这些片段合成为一个连贯的答案。让我们看看如何使用生成式 QA 来成功完成这项任务。
将答案提取为文档中文本范围的一种有趣的替代方法是使用预训练的语言模型生成它们。这种方法通常被称为抽象或生成 QA,并且有可能产生更好的措辞答案,从而综合多个段落的证据。虽然不如抽取式 QA 成熟,但这是一个快速发展的研究领域,所以当您阅读本文时,这些方法很可能会在工业中被广泛采用!在本节中,我们将简要介绍当前最先进的技术:检索增强生成 (RAG)。16
RAG 通过将阅读器替换为生成器并使用 DPR 作为检索器,扩展了我们在本章中看到的经典检索器-阅读器架构 。生成器是一个预训练的序列到序列转换器,如 T5 或 BART,它从 DPR 接收文档的潜在向量,然后根据查询和这些文档迭代地生成答案。由于 DPR 和生成器是可微的,整个过程可以端到端微调,如图 7-13 所示。
图 7-13。用于端到端微调检索器和生成器的 RAG 架构(由 Ethan Perez 提供)
为了展示 RAG 的实际效果,我们将使用DPRetriever
前面的 from,因此我们只需要实例化一个生成器。有两种类型的 RAG 模型可供选择:
使用相同的检索到的文档来生成完整的答案。具体来说,来自检索器的前k个文档被馈送到生成器,生成器为每个文档生成一个输出序列,并将结果边缘化以获得最佳答案。
可以使用不同的文档来生成答案中的每个标记。这允许生成器从多个文档中合成证据。
由于 RAG-Token 模型的性能往往优于 RAG-Sequence 模型,因此我们将使用在 NQ 上微调过的令牌模型作为我们的生成器。在 Haystack 中实例化生成器类似于实例化阅读器,但我们不是为上下文中的滑动窗口指定max_seq_length
和doc_stride
参数,而是指定控制文本生成的超参数:
- from haystack.generator.transformers import RAGenerator
-
- generator = RAGenerator(model_name_or_path="facebook/rag-token-nq",
- embed_title=False, num_beams=5)
这里num_beams
指定了在光束搜索中使用的光束数量(文本生成在第 5 章中有详细介绍)。正如我们对 DPR 检索器所做的那样,我们不嵌入文档标题,因为我们的语料库总是按产品 ID 过滤。
接下来要做的是使用 Haystack 将检索器和生成器绑定在一起GenerativeQAPipeline
:
- from haystack.pipeline import GenerativeQAPipeline
-
- pipe = GenerativeQAPipeline(generator=generator, retriever=dpr_retriever)
笔记
在 RAG 中,查询编码器和生成器都是端到端训练的,而上下文编码器是冻结的。在 Haystack 中,
GenerativeQAPipeline
使用来自 的查询编码器RAGenerator
和来自 的上下文编码器DensePassageRetriever
。
现在让我们通过输入一些关于以前的 Amazon Fire 平板电脑的查询来给 RAG 一个旋转。为了简化查询,我们将编写一个简单的函数来获取查询并打印出最佳答案:
- def generate_answers(query, top_k_generator=3):
- preds = pipe.run(query=query, top_k_generator=top_k_generator,
- top_k_retriever=5, filters={"item_id":["B0074BW614"]})
- print(f"Question: {preds['query']} \n")
- for idx in range(top_k_generator):
- print(f"Answer {idx+1}: {preds['answers'][idx]['answer']}")
好的,现在我们准备对其进行测试:
generate_answers(query)
Question: Is it good for reading? Answer 1: the screen is absolutely beautiful Answer 2: the Screen is absolutely beautiful Answer 3: Kindle fire
这个结果对于答案来说并不算太糟糕,但它确实表明问题的主观性质使生成器感到困惑。让我们尝试一些更真实的东西:
generate_answers("What is the main drawback?")
Question: What is the main drawback? Answer 1: the price Answer 2: no flash support Answer 3: the cost
这更明智!为了获得更好的结果,我们可以在 SubjQA 上对 RAG 进行端到端微调;我们将把它作为一个练习,但如果您有兴趣探索它, Transformers 存储库中的脚本 可以帮助您入门。
好吧,那是 QA 的一次旋风之旅,您可能还有更多想要回答的问题(双关语!)。在本章中,我们讨论了 QA 的两种方法(抽取式和生成式),并研究了两种不同的检索算法(BM25 和 DPR)。一路走来,我们看到域适应可以是一种简单的技术,可以显着提高我们的 QA 系统的性能,并且我们研究了一些用于评估此类系统的最常见的指标。尽管我们关注的是封闭域 QA(即电子产品的单个域),但本章中的技术可以很容易地推广到开放域案例;我们建议阅读 Cloudera 出色的 Fast Forward QA 系列,了解其中涉及的内容。
在野外部署 QA 系统可能是一项棘手的工作,我们的经验是,价值的很大一部分来自首先为最终用户提供有用的搜索功能,然后是提取组件。在这方面,除了回答按需用户查询之外,还可以以新颖的方式使用阅读器。例如, Grid Dynamics的研究人员能够使用他们的阅读器自动为客户目录中的每个产品提取一组优缺点。他们还表明,通过创建诸如“什么样的相机?”之类的查询,可以使用阅读器以零镜头方式提取命名实体。鉴于其初期和微妙的故障模式,我们建议仅在其他两种方法用尽后才探索生成 QA。图 7-14说明了解决 QA 问题的这种“需求层次” 。
图 7-14。需求的质量保证层次结构
展望未来,一个令人兴奋的研究领域是多模式 QA,它涉及对文本、表格和图像等多种模式的 QA。正如 MultiModalQA 基准中所述,17 个这样的系统可以让用户回答复杂的问题,这些问题整合了不同模式的信息,例如“用两个手指触摸的著名画作是什么时候完成的?” 实际业务应用的另一个领域是知识图谱上的 QA ,其中图的节点对应于现实世界的实体,它们的关系由边定义。通过将 factoids 编码为 (主语, 谓语,宾语) 三元组,可以使用图表来回答有关缺失元素的问题。有关将转换器与知识图相结合的示例,请参阅Haystack 教程。一个更有希望的方向是自动问题生成,作为一种使用未标记数据或数据增强进行某种形式的无监督/弱监督训练的方法。最近的两个例子包括关于可能回答的问题 (PAQ) 基准和跨语言设置的合成数据增强的论文。18
在本章中,我们已经看到,为了成功地将 QA 模型用于实际用例,我们需要应用一些技巧,例如实现快速检索管道以近乎实时地进行预测。尽管如此,在生产硬件上将 QA 模型应用于少数预先选择的文档可能需要几秒钟的时间。虽然这听起来可能并不多,但想象一下,如果您必须等待几秒钟才能获得 Google 搜索的结果,您的体验会有多么不同——几秒钟的等待时间可以决定由变压器驱动的应用程序的命运。在下一章中,我们将了解一些进一步加速模型预测的方法。