基于相似性学习和四元数的问答系统
现代机器学习中的许多问题被视为分类任务。有些是设计上的分类任务,但其他一些则是人工转化而成的。当你尝试应用一种与您的问题不自然贴合的方法时,您可能会面临过于复杂或笨重的解决方案。在某些情况下,您甚至可能会得到更糟糕的性能。
想象一下,你获得了一个新任务,并决定用旧的分类方法来解决它。首先,你需要标记的数据。如果它随任务一起提供,那你很幸运,但如果没有,你可能需要手动标记。我想你已经知道这可能有多痛苦。
假设你以某种方式标记了所有必需的数据并训练了一个模型。 它显示出良好的性能 - 干得好! 但一天后,你的经理告诉你关于一堆包含新类别的新数据,你的模型必须处理这些数据。 你重复你的流程。 然后,两天后,你又一次被联系。 你需要再次更新模型,一次又一次,反复如此。 听起来对我来说很乏味且昂贵,对你来说不也是吗?
自动化客户支持
现在让我们看看具体的例子。自动化客户支持存在一个紧迫的问题。 该服务应该能够在没有任何人工干预的情况下回答用户问题并检索相关的文档文章。
使用分类方法,您需要构建分类模型的层次结构以确定问题的主题。 您必须收集和标记整套自定义数据集以训练您的私人文档主题。 然后,每当您的文档中有新主题时,您必须使用额外标记的数据重新训练整堆分类器。 我们能否简化这个过程?
相似性选项
一种可能的替代方案是相似性学习,我们将在本文中讨论它。 它建议摆脱类别,并基于对象之间的相似性来做出决策。 要快速做到这一点,我们需要一些中间表示 - 嵌入。 嵌入是具有语义信息的高维向量。
由于嵌入是向量,可以应用一个简单的函数来计算它们之间的相似度分数,例如余弦或欧几里得距离。 因此,在相似度学习中,我们只需提供正确的问题和答案对。 然后,模型将通过嵌入的相似性来学习区分正确答案。
如果你想了解更多关于相似性学习和应用的内容,可以查看这篇文章,这可能会是一个资产。
让我们来构建
相似性学习的方法在这种情况下似乎比分类简单得多,如果你有一些疑虑,让我来消除它们。
由于我没有任何全面的常见问题解答资源可以作为数据集,因此我从一些流行云服务提供商的网站上进行了爬取。 该数据集仅包含 8.5k 对问题和答案,您可以在 这里 详细查看。
一旦我们拥有数据,我们需要为其获取嵌入。 在自然语言处理领域,将文本表示为嵌入并不是一种新技术。 有很多算法和模型可以计算它们。 你可能听说过 Word2Vec、GloVe、ELMo、BERT,所有这些模型都可以提供文本嵌入。
然而,使用经过语义相似性任务训练的模型来生成嵌入效果更好。 例如,我们可以在 句子转换器 找到这样的模型。 作者声称 all-mpnet-base-v2 提供了最佳质量,但我们在本教程中选择 all-MiniLM-L6-v2,因为它速度快 5 倍,且仍然提供良好的结果。
拥有这些,我们可以测试我们的方法。我们现在不会使用完整的数据集,而只会使用其中的一部分。为了衡量模型的性能,我们将使用两种指标 - 平均倒数排名 和 精准度@1。 我们有一个现成的脚本 用于这个实验,现在就让我们启动它。
| 准确率@1 | 倒数排名 |
|---|---|
| 0.564 | 0.663 |
这已经相当不错了,但我们可以做得更好吗?
通过微调改善结果
实际上,我们可以!我们使用的模型具有良好的自然语言理解能力,但它从未见过我们的数据。一个称为 fine-tuning 的方法可能有助于克服这个问题。通过微调,你不需要设计一个针对特定任务的架构,而是可以在另一个任务上预训练的模型上添加几层,然后训练其参数。
听起来不错,但由于相似性学习不像分类那样普遍,使用传统工具微调模型可能会有些不便。 因此,我们将使用 四元数 - 一个用于微调相似性学习模型的框架。 让我们看看我们如何使用它来训练模型
首先,创建我们的项目并将其命名为 faq。
所有项目依赖项,教程中未涵盖的实用脚本可以在仓库中找到。
配置训练
Quaterion中的主要实体是 可训练模型。此类使模型构建过程快速且便捷。
TrainableModel 是一个关于 pytorch_lightning.LightningModule 的封装。
闪电 处理所有训练过程的复杂性,如训练循环、设备管理等,免去了用户手动实现所有这些常规工作的必要性。 另外,Lightning 的模块化也值得一提。 它改善了职责的分离,使代码更易读、健壮且易于编写。 所有这些特点使 Pytorch Lightning 成为 Quaterion 的完美训练后端。
要使用 TrainableModel,您需要从它继承您的模型类。 以您在纯 pytorch_lightning 中使用 LightningModule 的方式。 必须的方法是 configure_loss、configure_encoders、configure_head、 configure_optimizers。
提到的大部分方法都很容易实现,你可能只需要几个导入就可以做到。但 configure_encoders 需要一些代码:)
让我们创建一个 model.py,其中包含模型的模板和一个 configure_encoders 的占位符。
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,它实际上是一个相似性模型 实例。我们会在后面讨论它。configure_loss是一个在训练期间使用的损失函数。您可以选择来自 Quaterion 的现成实现。但是,由于 Quaterion 的目的是不覆盖所有可能的损失,或相似性学习的其他实体和特征,而是提供一个方便的框架来构建和使用这样的模型,因此可能没有所需的损失。在这种情况下,可以使用 PytorchMetricLearningWrapper 从 pytorch-metric-learning 库中获取所需的损失,该库拥有丰富的损失集合。您也可以自行实现自定义损失。configure_head- 通过Quaterion构建的模型是编码器和顶层(head)的组合。 与损失类似,提供了一些头部实现。它们可以在 quaterion_models.heads 找到。
在我们的示例中,我们使用 多重负面排名损失。这个损失函数对于训练检索任务特别有效。它假设我们仅传递正对(相似对象),并将所有其他对象视为负例。
MultipleNegativesRankingLoss 在后台使用余弦距离来度量距离,但这是一个可配置的参数。
Quaterion 还提供了其他距离的实现。你可以在 quaterion.distances 找到可用的距离。
现在我们可以回到 configure_encoders :)
配置编码器
编码器任务是将对象转换为嵌入。
它们通常利用一些预训练模型,在我们的例子中是来自 sentence-transformers 的 all-MiniLM-L6-v2。
为了在 Quaterion 中使用它,我们需要创建一个继承自 编码器 类的包装器。
让我们在 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 适用于成对格式。
相似对样本 可以用来表示对。让我们来看看它:
@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 的列表。它们只是度量实例的包装器,并帮助更轻松地记录度量。在后台,logging 是由 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 在不可训练编码器方面多了一层优势。 如果编码器被冻结,它们是确定性的,并在每个周期对相同输入数据产生精确的嵌入。 这提供了一种避免重复计算和减少训练时间的方法。 为此,Quaterion 具有缓存功能。
在训练开始之前,缓存运行一个周期以预先计算所有嵌入,使用冻结的编码器,然后将它们存储在你选择的设备上(当前为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)
...
缓存类型 决定了缓存将如何存储在内存中。
训练
现在我们需要将所有代码合并在一起并在 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
和 四元数模型。
前者包含与训练相关的内容,如损失、缓存、pytorch-lightning 依赖等。
而后者仅包含服务所需的模块:编码器、头部和 SimilarityModel 本身。
分离的原因是:
- 在生产环境中操作所需实体的数量较少
- 减少内存占用
将训练依赖与服务环境隔离是至关重要的,因为训练步骤通常更为复杂。
训练依赖快速失控,显著延缓了部署和服务的时间,并增加了不必要的资源使用。
在 train.py 的最后一行 - faq_model.save_servable(...) 以一种消除所有四元数依赖的方式保存编码器和模型,只存储在生产中运行模型所需的最必要的数据。
在 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。
所有可用的代码可以在 这里 找到。
敬请期待!:)

