基于相似性学习和 Quaterion 的问答系统
现代机器学习中的许多问题都被视为分类任务。有些任务本身就是分类任务,但有些则是被人为地转化为分类任务。当你尝试应用一种不自然地适合你问题的方法时,你就有可能提出过于复杂或笨重的解决方案。在某些情况下,你甚至会得到更差的性能。
想象一下你接到了一项新任务,并决定用一种老式的分类方法来解决它。首先,你需要标记数据。如果它和任务一起送到你手上,那你很幸运,但如果没有,你可能需要手动标记它。我想你已经很熟悉这可能会有多痛苦了。
假设你以某种方式标记了所有所需数据并训练了一个模型。它表现出色——干得好!但一天后,你的经理告诉你有一堆带有新类别的新数据,你的模型必须处理这些数据。你重复你的流程。然后,两天后,你又一次被联系。你需要再次、再次、再次更新模型。对我来说,这听起来很繁琐和昂贵,对你来说不是吗?
自动化客户支持
现在让我们看一个具体的例子。自动化客户支持是一个紧迫的问题。该服务应该能够回答用户问题并从文档中检索相关文章,而无需任何人工干预。
使用分类方法,你需要建立一个分类模型层次结构来确定问题的主题。你必须收集和标记一份完整的自定义数据集,其中包含你的私人文档主题来训练它。然后,每次文档中出现新主题时,你都必须用额外的标记数据重新训练整个分类器堆栈。我们能让它更容易吗?
相似性选项
一种可能的替代方案是相似性学习,我们将在本文中讨论它。它建议放弃类别,转而根据对象之间的相似性做出决策。为了快速实现这一点,我们需要一些中间表示——嵌入。嵌入是高维向量,其中积累了语义信息。
由于嵌入是向量,我们可以应用一个简单的函数来计算它们之间的相似性分数,例如余弦或欧几里德距离。因此,通过相似性学习,我们所需要做的就是提供正确问题和答案的对。然后,模型将通过嵌入的相似性学习区分正确的答案。
如果您想了解更多关于相似性学习及其应用的信息,请查阅这篇文章,它可能会有所帮助。
让我们开始构建
在这种情况下,相似性学习方法似乎比分类简单得多,如果你有任何疑问,请允许我为你消除它们。
由于我没有任何包含详尽常见问题解答的资源可以作为数据集,我从流行的云提供商网站上抓取了数据。该数据集仅包含 8.5k 对问题和答案,你可以在这里仔细查看它。
有了数据之后,我们需要为其获取嵌入。在自然语言处理中,将文本表示为嵌入并非一项新颖的技术。有大量的算法和模型可以计算它们。你可能听说过 Word2Vec、GloVe、ELMo、BERT,所有这些模型都可以提供文本嵌入。
然而,最好使用针对语义相似性任务训练的模型来生成嵌入。例如,我们可以在sentence-transformers找到此类模型。作者声称all-mpnet-base-v2提供最佳质量,但为了本教程,让我们选择all-MiniLM-L6-v2,因为它速度快 5 倍,并且仍然能提供良好的结果。
有了这些,我们就可以测试我们的方法了。我们目前不会使用所有数据集,而只使用其中一部分。为了衡量模型的性能,我们将使用两个指标——平均倒数排名和精确度@1。我们有一个现成的脚本用于此实验,现在就让我们启动它。
| 精确度@1 | 倒数排名 |
|---|---|
| 0.564 | 0.663 |
这已经是非常不错的质量了,但也许我们可以做得更好?
通过微调改进结果
确实可以!我们使用的模型具有良好的自然语言理解能力,但它从未见过我们的数据。一种称为“微调”的方法可能有助于解决这个问题。通过微调,你无需设计特定于任务的架构,而是采用在另一个任务上预训练的模型,在其顶部应用几层并训练其参数。
听起来不错,但由于相似性学习不像分类那样普遍,因此使用传统工具微调模型可能会有些不便。为此,我们将使用Quaterion——一个用于微调相似性学习模型的框架。让我们看看如何用它来训练模型。
首先,创建我们的项目并将其命名为 faq。
教程中未涵盖的所有项目依赖项、实用脚本都可以在仓库中找到。
配置训练
Quaterion 中的主要实体是 TrainableModel。这个类使模型的构建过程快速便捷。
TrainableModel 是 pytorch_lightning.LightningModule 的封装。
Lightning 处理所有训练过程的复杂性,如训练循环、设备管理等,并省去了用户手动实现所有这些例行程序的必要性。此外,Lightning 的模块化值得一提。它改进了职责分离,使代码更具可读性、健壮性且易于编写。所有这些特性使 Pytorch Lightning 成为 Quaterion 完美的训练后端。
要使用 TrainableModel,你需要让你的模型类继承自它。就像你在纯 pytorch_lightning 中使用 LightningModule 一样。强制性方法包括 configure_loss、configure_encoders、configure_head 和 configure_optimizers。
所提及的大多数方法都相当容易实现,你可能只需要导入几个包即可完成。但 configure_encoders 需要一些代码 :)
让我们暂时创建一个带有模型模板和 configure_encoders 占位符的 model.py 文件。
from typing import Union, Dict, Optional
from torch.optim import Adam
from quaterion import TrainableModel
from quaterion.loss import MultipleNegativesRankingLoss, SimilarityLoss
from quaterion_models.encoders import Encoder
from quaterion_models.heads import EncoderHead
from quaterion_models.heads.skip_connection_head import SkipConnectionHead
class FAQModel(TrainableModel):
def __init__(self, lr=10e-5, *args, **kwargs):
self.lr = lr
super().__init__(*args, **kwargs)
def configure_optimizers(self):
return Adam(self.model.parameters(), lr=self.lr)
def configure_loss(self) -> SimilarityLoss:
return MultipleNegativesRankingLoss(symmetric=True)
def configure_encoders(self) -> Union[Encoder, Dict[str, Encoder]]:
... # ToDo
def configure_head(self, input_embedding_size: int) -> EncoderHead:
return SkipConnectionHead(input_embedding_size)
configure_optimizers是 Lightning 提供的方法。细心的你可能已经注意到了神秘的self.model,它实际上是一个 SimilarityModel 实例。我们稍后会介绍它。configure_loss是训练过程中使用的损失函数。你可以选择 Quaterion 中现成的实现。然而,由于 Quaterion 的目的不是涵盖所有可能的损失或相似性学习的其他实体和特性,而是提供一个方便的框架来构建和使用此类模型,因此可能没有所需的损失。在这种情况下,可以使用 PytorchMetricLearningWrapper 从 pytorch-metric-learning 库中引入所需的损失,该库拥有丰富的损失集合。你也可以自己实现自定义损失。configure_head- 通过 Quaterion 构建的模型是编码器和顶层 - head 的组合。与损失一样,也提供了一些 head 的实现。它们可以在 quaterion_models.heads 中找到。
在我们的例子中,我们使用MultipleNegativesRankingLoss。这种损失尤其适用于训练检索任务。它假设我们只传递正对(相似对象),并将所有其他对象视为负例。
MultipleNegativesRankingLoss 内部使用余弦来测量距离,但它是一个可配置参数。Quaterion 也提供了其他距离的实现。你可以在 quaterion.distances 中找到可用的距离。
现在我们可以回到 configure_encoders 了:)
配置编码器
编码器任务是将对象转换为嵌入。它们通常利用一些预训练模型,在我们的例子中是来自 sentence-transformers 的 all-MiniLM-L6-v2。为了在 Quaterion 中使用它,我们需要创建一个继承自 Encoder 类的包装器。
让我们在 encoder.py 中创建我们的编码器
import os
from torch import Tensor, nn
from sentence_transformers.models import Transformer, Pooling
from quaterion_models.encoders import Encoder
from quaterion_models.types import TensorInterchange, CollateFnType
class FAQEncoder(Encoder):
def __init__(self, transformer, pooling):
super().__init__()
self.transformer = transformer
self.pooling = pooling
self.encoder = nn.Sequential(self.transformer, self.pooling)
@property
def trainable(self) -> bool:
# Defines if we want to train encoder itself, or head layer only
return False
@property
def embedding_size(self) -> int:
return self.transformer.get_word_embedding_dimension()
def forward(self, batch: TensorInterchange) -> Tensor:
return self.encoder(batch)["sentence_embedding"]
def get_collate_fn(self) -> CollateFnType:
return self.transformer.tokenize
@staticmethod
def _transformer_path(path: str):
return os.path.join(path, "transformer")
@staticmethod
def _pooling_path(path: str):
return os.path.join(path, "pooling")
def save(self, output_path: str):
transformer_path = self._transformer_path(output_path)
os.makedirs(transformer_path, exist_ok=True)
pooling_path = self._pooling_path(output_path)
os.makedirs(pooling_path, exist_ok=True)
self.transformer.save(transformer_path)
self.pooling.save(pooling_path)
@classmethod
def load(cls, input_path: str) -> Encoder:
transformer = Transformer.load(cls._transformer_path(input_path))
pooling = Pooling.load(cls._pooling_path(input_path))
return cls(transformer=transformer, pooling=pooling)
正如你所注意到的,实现的方法比我们之前讨论的要多。现在让我们逐一介绍它们!
在
__init__中,我们注册了我们的预训练层,这与你在 torch.nn.Module 的派生类中操作的方式类似。trainable定义了当前Encoder层在训练期间是否应该更新。如果trainable=False,那么所有层都将被冻结。embedding_size是编码器输出的大小,它对于正确的head配置是必需的。get_collate_fn比较棘手。在这里,你应该返回一个方法,它将一批原始数据准备成适合编码器输入的格式。如果get_collate_fn未被覆盖,那么将使用 default_collate。
其余方法被认为是自解释的。
由于我们的编码器已准备就绪,我们现在可以填充 configure_encoders。只需将以下代码插入到 model.py 中即可。
...
from sentence_transformers import SentenceTransformer
from sentence_transformers.models import Transformer, Pooling
from faq.encoder import FAQEncoder
class FAQModel(TrainableModel):
...
def configure_encoders(self) -> Union[Encoder, Dict[str, Encoder]]:
pre_trained_model = SentenceTransformer("all-MiniLM-L6-v2")
transformer: Transformer = pre_trained_model[0]
pooling: Pooling = pre_trained_model[1]
encoder = FAQEncoder(transformer, pooling)
return encoder
数据准备
好的,我们有了原始数据和一个可训练的模型。但是我们还不知道如何将这些数据提供给我们的模型。
目前,Quaterion 支持两种相似性表示类型——对和组。
组格式假设所有对象都分成相似对象的组。一个组中的所有对象都是相似的,而该组之外的所有其他对象都被认为与它们不相似。
但在成对的情况下,我们只能假设明确指定的对象对之间存在相似性。
我们可以将这两种方法中的任何一种应用于我们的数据,但成对的方法似乎更直观。
相似性表示的格式决定了可以使用哪种损失。例如,ContrastiveLoss 和 MultipleNegativesRankingLoss 适用于对格式。
SimilarityPairSample 可用于表示对。让我们来看看它。
@dataclass
class SimilarityPairSample:
obj_a: Any
obj_b: Any
score: float = 1.0
subgroup: int = 0
这里可能有一些问题:score 和 subgroup 是什么?
嗯,score 是预期样本相似度的一个衡量标准。如果你只需要指定两个样本是否相似,你可以分别使用 1.0 和 0.0。
subgroups 参数用于更细粒度地描述可能的负例。默认情况下,所有对都属于零子组。这意味着我们需要手动指定所有负例。但在大多数情况下,我们可以通过启用不同的子组来避免这种情况。来自不同子组的所有对象将被视为损失中的负例,从而提供了一种隐式设置负例的方式。
有了这些知识,我们现在可以在 dataset.py 中创建我们的 Dataset 类来为我们的模型提供数据。
import json
from typing import List, Dict
from torch.utils.data import Dataset
from quaterion.dataset.similarity_samples import SimilarityPairSample
class FAQDataset(Dataset):
"""Dataset class to process .jsonl files with FAQ from popular cloud providers."""
def __init__(self, dataset_path):
self.dataset: List[Dict[str, str]] = self.read_dataset(dataset_path)
def __getitem__(self, index) -> SimilarityPairSample:
line = self.dataset[index]
question = line["question"]
# All questions have a unique subgroup
# Meaning that all other answers are considered negative pairs
subgroup = hash(question)
return SimilarityPairSample(
obj_a=question,
obj_b=line["answer"],
score=1,
subgroup=subgroup
)
def __len__(self):
return len(self.dataset)
@staticmethod
def read_dataset(dataset_path) -> List[Dict[str, str]]:
"""Read jsonl-file into a memory."""
with open(dataset_path, "r") as fd:
return [json.loads(json_line) for json_line in fd]
我们为每个问题分配了一个唯一的子组,因此所有具有不同问题的其他对象都将被视为负例。
评估指标
我们还没有给模型添加任何指标。为此,Quaterion 提供了 configure_metrics。我们只需要覆盖它并附上感兴趣的指标。
Quaterion 实现了一些流行的检索指标,例如 *precision @ k* 或 *mean reciprocal rank*。它们可以在 quaterion.eval 包中找到。但是只有少数几个指标,通常假设用户会自己创建所需的指标或从其他库中获取。你可能需要继承自 PairMetric 或 GroupMetric 来实现一个新的指标。
在 configure_metrics 中,我们需要返回一个 AttachedMetric 列表。它们只是指标实例的包装器,有助于更轻松地记录指标。在底层,日志记录由 pytorch-lightning 处理。你可以根据需要配置它——将所需的参数作为关键字参数传递给 AttachedMetric。有关更多信息,请访问 日志记录文档页面。
让我们为 FAQModel 添加上述指标。将此代码添加到 model.py 中。
...
from quaterion.eval.pair import RetrievalPrecision, RetrievalReciprocalRank
from quaterion.eval.attached_metric import AttachedMetric
class FAQModel(TrainableModel):
def __init__(self, lr=10e-5, *args, **kwargs):
self.lr = lr
super().__init__(*args, **kwargs)
...
def configure_metrics(self):
return [
AttachedMetric(
"RetrievalPrecision",
RetrievalPrecision(k=1),
prog_bar=True,
on_epoch=True,
),
AttachedMetric(
"RetrievalReciprocalRank",
RetrievalReciprocalRank(),
prog_bar=True,
on_epoch=True
),
]
使用缓存进行快速训练
对于不可训练的编码器,Quaterion 还有一个锦上添花的功能。如果编码器被冻结,它们是确定性的,并且在每个 epoch 中对相同的输入数据发出完全相同的嵌入。这提供了一种避免重复计算并减少训练时间的方法。为此,Quaterion 具有缓存功能。
训练开始前,缓存运行一个 epoch,使用冻结的编码器预先计算所有嵌入,然后将它们存储在您选择的设备上(目前是 CPU 或 GPU)。您所需要做的就是定义哪些编码器是可训练的或不可训练的,并设置缓存设置。就这样:其余的 Quaterion 都会为您处理。
要配置缓存,您需要在 TrainableModel 中重写 configure_cache 方法。此方法应返回一个 CacheConfig 实例。
让我们为我们的模型添加缓存
...
from quaterion.train.cache import CacheConfig, CacheType
...
class FAQModel(TrainableModel):
...
def configure_caches(self) -> Optional[CacheConfig]:
return CacheConfig(CacheType.AUTO)
...
CacheType 决定了缓存将如何在内存中存储。
培训
现在我们需要将所有代码组合到 train.py 中并启动训练过程。
import torch
import pytorch_lightning as pl
from quaterion import Quaterion
from quaterion.dataset import PairsSimilarityDataLoader
from faq.dataset import FAQDataset
def train(model, train_dataset_path, val_dataset_path, params):
use_gpu = params.get("cuda", torch.cuda.is_available())
trainer = pl.Trainer(
min_epochs=params.get("min_epochs", 1),
max_epochs=params.get("max_epochs", 500),
auto_select_gpus=use_gpu,
log_every_n_steps=params.get("log_every_n_steps", 1),
gpus=int(use_gpu),
)
train_dataset = FAQDataset(train_dataset_path)
val_dataset = FAQDataset(val_dataset_path)
train_dataloader = PairsSimilarityDataLoader(
train_dataset, batch_size=1024
)
val_dataloader = PairsSimilarityDataLoader(
val_dataset, batch_size=1024
)
Quaterion.fit(model, trainer, train_dataloader, val_dataloader)
if __name__ == "__main__":
import os
from pytorch_lightning import seed_everything
from faq.model import FAQModel
from faq.config import DATA_DIR, ROOT_DIR
seed_everything(42, workers=True)
faq_model = FAQModel()
train_path = os.path.join(
DATA_DIR,
"train_cloud_faq_dataset.jsonl"
)
val_path = os.path.join(
DATA_DIR,
"val_cloud_faq_dataset.jsonl"
)
train(faq_model, train_path, val_path, {})
faq_model.save_servable(os.path.join(ROOT_DIR, "servable"))
这里有几个未曾见过的类,PairsSimilarityDataLoader,它是 SimilarityPairSample 对象的原生数据加载器,而 Quaterion 是训练过程的入口点。
数据集级评估
到目前为止,我们只计算了批次级指标。这些指标会根据批次大小而波动很大,可能会产生误导。如果我们能在整个数据集或其大部分上计算一个指标,那可能会很有帮助。原始数据可能会消耗大量的内存,通常我们无法将其放入一个批次中。相反,嵌入很可能会消耗更少的内存。
这就是 Evaluator 登场的地方。首先,拥有 SimilaritySample 数据集后,Evaluator 通过 SimilarityModel 对其进行编码并计算相应的标签。之后,它会计算一个指标值,该值可能比批次级指标更具代表性。
然而,你仍然可能会发现评估变得太慢,或者内存中没有足够的空间。瓶颈可能在于计算检索指标所需的平方距离矩阵。你可以通过计算尺寸减小的矩形矩阵来缓解这个瓶颈。Evaluator 接受带有样本大小的 sampler 来只选择指定数量的嵌入。如果未指定样本大小,则在所有嵌入上执行评估。
少说废话!让我们将评估器添加到我们的代码中,并完成 train.py。
...
from quaterion.eval.evaluator import Evaluator
from quaterion.eval.pair import RetrievalReciprocalRank, RetrievalPrecision
from quaterion.eval.samplers.pair_sampler import PairSampler
...
def train(model, train_dataset_path, val_dataset_path, params):
...
metrics = {
"rrk": RetrievalReciprocalRank(),
"rp@1": RetrievalPrecision(k=1)
}
sampler = PairSampler()
evaluator = Evaluator(metrics, sampler)
results = Quaterion.evaluate(evaluator, val_dataset, model.model)
print(f"results: {results}")
训练结果
现在我们可以训练我们的模型了,我通过 python3 -m faq.train 进行训练。
| 周期 | 训练精度@1 | 训练倒数排名 | 验证精度@1 | 验证倒数排名 |
|---|---|---|---|---|
| 0 | 0.650 | 0.732 | 0.659 | 0.741 |
| 100 | 0.665 | 0.746 | 0.673 | 0.754 |
| 200 | 0.677 | 0.757 | 0.682 | 0.763 |
| 300 | 0.686 | 0.765 | 0.688 | 0.768 |
| 400 | 0.695 | 0.772 | 0.694 | 0.773 |
| 500 | 0.701 | 0.778 | 0.700 | 0.777 |
使用 Evaluator 获得的结果
| 精确度@1 | 倒数排名 |
|---|---|
| 0.577 | 0.675 |
训练后所有指标都得到了提升。这次训练仅在单个 GPU 上进行了 3 分钟!没有过拟合,结果稳步增长,尽管我认为仍有改进和实验的空间。
模型服务
正如你可能已经注意到的,Quaterion 框架分为两个独立的库:quaterion 和 quaterion-models。前者包含与训练相关的东西,如损失、缓存、pytorch-lightning 依赖项等。而后者仅包含用于服务的必要模块:编码器、头部和 SimilarityModel 本身。
这种分离的原因是
- 在生产环境中你需要操作的实体数量更少
- 减少内存占用
将训练依赖项与服务环境隔离至关重要,因为训练步骤通常更复杂。训练依赖项很快就会失控,显著减慢部署和服务时间并增加不必要的资源使用。
train.py 的最后一行 — faq_model.save_servable(...) — 以一种消除所有 Quaterion 依赖项并仅存储在生产环境中运行模型最必要数据的方式保存了编码器和模型。
在 serve.py 中,我们加载并编码所有答案,然后查找与我们感兴趣的问题最接近的向量。
import os
import json
import torch
from quaterion_models.model import SimilarityModel
from quaterion.distances import Distance
from faq.config import DATA_DIR, ROOT_DIR
if __name__ == "__main__":
device = "cuda:0" if torch.cuda.is_available() else "cpu"
model = SimilarityModel.load(os.path.join(ROOT_DIR, "servable"))
model.to(device)
dataset_path = os.path.join(DATA_DIR, "val_cloud_faq_dataset.jsonl")
with open(dataset_path) as fd:
answers = [json.loads(json_line)["answer"] for json_line in fd]
# everything is ready, let's encode our answers
answer_embeddings = model.encode(answers, to_numpy=False)
# Some prepared questions and answers to ensure that our model works as intended
questions = [
"what is the pricing of aws lambda functions powered by aws graviton2 processors?",
"can i run a cluster or job for a long time?",
"what is the dell open manage system administrator suite (omsa)?",
"what are the differences between the event streams standard and event streams enterprise plans?",
]
ground_truth_answers = [
"aws lambda functions powered by aws graviton2 processors are 20% cheaper compared to x86-based lambda functions",
"yes, you can run a cluster for as long as is required",
"omsa enables you to perform certain hardware configuration tasks and to monitor the hardware directly via the operating system",
"to find out more information about the different event streams plans, see choosing your plan",
]
# encode our questions and find the closest to them answer embeddings
question_embeddings = model.encode(questions, to_numpy=False)
distance = Distance.get_by_name(Distance.COSINE)
question_answers_distances = distance.distance_matrix(
question_embeddings, answer_embeddings
)
answers_indices = question_answers_distances.min(dim=1)[1]
for q_ind, a_ind in enumerate(answers_indices):
print("Q:", questions[q_ind])
print("A:", answers[a_ind], end="\n\n")
assert (
answers[a_ind] == ground_truth_answers[q_ind]
), f"<{answers[a_ind]}> != <{ground_truth_answers[q_ind]}>"
我们将答案嵌入集合存储在内存中,并直接在 Python 中执行搜索。出于生产目的,最好使用某种向量搜索引擎,例如 Qdrant。它提供了持久性、速度提升和许多其他功能。
到目前为止,我们已经使用 Quaterion 完成了整个训练过程,为模型服务做好了准备,甚至今天还应用了一个训练好的模型。
感谢您的时间和关注!我希望您喜欢这个大型教程,并将 Quaterion 用于您的相似性学习项目。
所有可使用的代码都可以在这里找到。
敬请关注!:)
