基于相似性学习和 Quaterion 的问答系统
现代机器学习中的许多问题都被当作分类任务来处理。有些问题本质上就是分类任务,但有些则被人为地转化为分类任务。当你尝试应用一种不自然地适合你的问题的方法时,你可能会得到过于复杂或笨重的解决方案。在某些情况下,你甚至会获得更差的性能。
想象一下,你接到一个新任务,并决定用老套的分类方法来解决。首先,你需要标注数据。如果任务本身就附带了标注好的数据,那你很幸运;如果没有,你可能需要手动标注。我想你已经知道这有多痛苦了。
假设你设法标注了所有需要的数据并训练了一个模型。它表现良好——干得漂亮!但一天后,你的经理告诉你有一堆新数据,包含新类别,你的模型必须处理它们。你重复了你的流程。两天后,你又一次被联系。你需要再次更新模型,一遍又一遍。这对我来说听起来很繁琐且昂贵,对你来说呢?
自动化客户支持
现在让我们来看一个具体的例子。自动化客户支持有一个紧迫的问题。该服务应该能够在没有任何人工干预的情况下回答用户问题并从文档中检索相关文章。
使用分类方法,你需要构建一个分类模型层级来确定问题的主题。你必须收集和标注一个完整的自定义数据集,其中包含你的私有文档主题来训练模型。然后,每次你的文档中有新主题时,你都必须用额外标注的数据重新训练整个分类器堆栈。我们能否使其更容易?
相似性选项
一种可能的替代方案是相似性学习(Similarity Learning),我们将在本文中讨论它。它建议摆脱类别,转而根据对象之间的相似性做出决策。为了快速实现这一点,我们需要一些中间表示——嵌入(embeddings)。嵌入是高维向量,其中积累了语义信息。
由于嵌入是向量,可以应用一个简单的函数来计算它们之间的相似度分数,例如余弦距离或欧氏距离。因此,对于相似性学习,我们所需要做的就是提供正确的问答对。然后,模型将通过嵌入的相似性来学习区分正确的答案。
如果您想了解更多关于相似性学习及其应用的信息,请查看这篇文章,它可能会有所帮助。
开始构建
在这种情况下,相似性学习方法似乎比分类简单得多,如果您心中有所疑问,让我来消除它们。
由于我没有任何包含详尽常见问题解答(F.A.Q.)的资源可以作为数据集,我从一些流行的云服务提供商的网站上抓取了数据。该数据集仅包含 8.5k 对问答,您可以在此处仔细查看。
有了数据后,我们需要为其获取嵌入。在自然语言处理(NLP)中,将文本表示为嵌入并不是一种新颖的技术。有大量算法和模型可以计算它们。您可能听说过 Word2Vec、GloVe、ELMo、BERT,所有这些模型都可以提供文本嵌入。
然而,最好使用经过语义相似性任务训练的模型来生成嵌入。例如,我们可以在 sentence-transformers 找到此类模型。作者声称 all-mpnet-base-v2
提供最佳质量,但为了本次教程,我们选择 all-MiniLM-L6-v2
,因为它速度快 5 倍且仍然提供良好的结果。
有了这一切,我们就可以测试我们的方法了。我们目前不使用全部数据集,只使用其中一部分。为了衡量模型的性能,我们将使用两个指标——平均倒数排名和precision@1。我们为此实验准备了一个现成的脚本,现在就来运行它吧。
precision@1 | 倒数排名 |
---|---|
0.564 | 0.663 |
这已经是非常不错的质量了,但也许我们可以做得更好?
使用微调改进结果
实际上,我们可以!我们使用的模型具有良好的自然语言理解能力,但它从未见过我们的数据。一种称为微调(fine-tuning)
的方法可能有助于解决这个问题。通过微调,您无需设计特定任务的架构,而是采用在另一任务上预训练的模型,在其顶部应用几层并训练其参数。
听起来不错,但由于相似性学习不像分类那样常见,使用传统工具微调模型可能有些不便。因此,我们将使用 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
需要一些代码 :)
我们先创建一个 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
,它实际上是 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 支持两种相似性表示类型——pair(对)和 group(组)。
group 格式假设所有对象被分成相似对象的组。同一组内的所有对象是相似的,而该组之外的所有其他对象则被认为与它们不相似。
但在 pair 的情况下,我们只能假设明确指定的对象对之间是相似的。
我们可以使用我们数据的任何一种方法,但 pair 方法似乎更直观。
相似性表示的格式决定了可以使用哪种损失函数。例如,ContrastiveLoss 和 MultipleNegativesRankingLoss 与 pair 格式配合使用。
SimilarityPairSample 可以用来表示 pair。让我们看一下它
@dataclass
class SimilarityPairSample:
obj_a: Any
obj_b: Any
score: float = 1.0
subgroup: int = 0
这里可能会有一些问题:score
和 subgroup
是什么?
嗯,score
是衡量期望样本相似性的指标。如果您只需要指定两个样本是否相似,可以分别使用 1.0
和 0.0
。
subgroups
参数需要用于更细粒度地描述负样本是什么。默认情况下,所有 pair 都属于子组零。这意味着我们需要手动指定所有负样本。但在大多数情况下,通过启用不同的子组可以避免这种情况。来自不同子组的所有对象将被视为损失函数中的负样本,因此它提供了一种隐式设置负样本的方式。
有了这些知识,我们现在可以在 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
是训练过程的入口点。
数据集整体评估
截至目前,我们只计算了按批次(batch-wise)的指标。这些指标可能因批次大小而大幅波动,并可能具有误导性。如果能在整个数据集或其大部分上计算指标,可能会更有帮助。原始数据可能占用大量内存,通常无法放入一个批次中。相反,嵌入(embeddings)很可能会占用更少的内存。
这就是 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
来运行它。
epoch | train_precision@1 | train_reciprocal_rank | val_precision@1 | val_reciprocal_rank |
---|---|---|---|---|
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
获得的结果
precision@1 | 倒数排名 |
---|---|
0.577 | 0.675 |
训练后,所有指标都提高了。而且这次训练仅在单个 GPU 上用时 3 分钟!没有出现过拟合,结果稳定增长,尽管我认为仍有改进和实验的空间。
模型服务
正如您可能已经注意到的,Quaterion 框架被拆分为两个独立的库:quaterion
和 quaterion-models。前者包含与训练相关的内容,如损失函数、缓存、pytorch-lightning
依赖等。后者仅包含服务必需的模块:编码器、head 和 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
。
所有即用型代码可以在此处找到。
敬请关注! :)