监督分类是机器学习中最广泛使用的训练目标之一,但并非每个任务都可以定义为监督分类。例如,
- 您的类别可能会快速变化 — 例如,可能会随时间添加新类别,
- 您可能没有每个可能类别的样本,
- 在训练期间可能无法枚举所有可能的类别,
- 您可能有一个本质上不同的任务,例如搜索或检索。
所有这些问题都可以通过相似性学习有效地解决。
注意:如果您不熟悉相似性学习概念,请查看 awesome-metric-learning 仓库,了解优秀的资源和用例示例。
然而,相似性学习也有其自身的困难,例如
- 通常需要更大的批量大小,
- 更复杂的损失函数,
- 训练和推理之间不断变化的架构。
Quaterion 是一个微调框架,旨在解决相似性学习中的此类问题。它使用 PyTorch Lightning 作为后端,其宣传口号是“花更多时间在研究上,减少工程工作。” 这对 Quaterion 同样适用,它包括
- 可训练和可服务模型类,
- 带注释的内置损失函数,以及当您需要更多时,对 pytorch-metric-learning 的封装,
- 样本、数据集和数据加载器类,以便更容易地处理相似性学习数据,
- 一种缓存机制,用于更快的迭代和更小的内存占用。
深入了解 Quaterion
让我们分解一些重要的模块
TrainableModel:pl.LightNingModule的子类,具有附加的钩子方法,如configure_encoders、configure_head、configure_metrics等,用于定义训练和评估所需的对象——请参阅下文以了解更多信息。SimilarityModel:仅用于推理的导出方法,可在推理期间提高代码传输和降低依赖性。实际上,Quaterion 由两个包组成quaterion_models:推理所需的包。quaterion:定义训练所需对象并依赖于quaterion_models的包。
Encoder和EncoderHead:构成SimilarityModel的两个对象。在大多数情况下,您可以使用冻结的预训练编码器(例如,torchvision中的 ResNets,或transformers中的语言模型),并在其顶部堆叠可训练的EncoderHead。quaterion_models提供了几种现成的EncoderHead实现,但您也可以通过子类化父类或在SequentialHead中轻松列出 PyTorch 模块来创建自己的实现。
Quaterion 还有其他对象,例如距离函数、评估指标、评估器、方便的数据集和数据加载器类,但它们大多不言自明。因此,为简洁起见,本文将不详细解释它们。但是,您可以随时查看 文档 以了解更多信息。
本教程的重点是使用 Quaterion 逐步解决相似性学习问题。这也将帮助我们更好地理解上述对象如何在实际项目中协同工作。让我们开始逐步介绍代码的一些重要部分。
如果您正在寻找完整的源代码,可以在 Quaterion 仓库的 examples 目录中找到它。
数据集
在本教程中,我们将使用 Stanford Cars 数据集。

Stanford Cars 数据集
它包含来自 196 个类别的 16185 张汽车图像,并分为训练和测试子集,几乎是 50-50% 的划分。然而,为了让事情变得更有趣,我们将首先合并训练和测试子集,然后再次将其分成两部分,这样 196 个类别中的一半将放入训练集,另一半将放入测试集。这将使我们能够使用训练阶段从未见过的新类别样本来测试我们的模型,这是监督分类无法实现但相似性学习可以实现的目标。
在以下代码中,借用自 data.py
get_datasets()函数执行上述拆分任务。get_dataloaders()函数从训练和测试数据集中创建GroupSimilarityDataLoader实例。- 数据集是常规的 PyTorch 数据集,发出
SimilarityGroupSample实例。
注意:目前,Quaterion 有两种数据类型来表示数据集中的样本。要了解有关 SimilarityPairSample 的更多信息,请查看 NLP 教程
import numpy as np
import os
import tqdm
from torch.utils.data import Dataset, Subset
from torchvision import datasets, transforms
from typing import Callable
from pytorch_lightning import seed_everything
from quaterion.dataset import (
GroupSimilarityDataLoader,
SimilarityGroupSample,
)
# set seed to deterministically sample train and test categories later on
seed_everything(seed=42)
# dataset will be downloaded to this directory under local directory
dataset_path = os.path.join(".", "torchvision", "datasets")
def get_datasets(input_size: int):
# Use Mean and std values for the ImageNet dataset as the base model was pretrained on it.
# taken from https://www.geeksforgeeks.org/how-to-normalize-images-in-pytorch/
mean = [0.485, 0.456, 0.406]
std = [0.229, 0.224, 0.225]
# create train and test transforms
transform = transforms.Compose(
[
transforms.Resize((input_size, input_size)),
transforms.ToTensor(),
transforms.Normalize(mean, std),
]
)
# we need to merge train and test splits into a full dataset first,
# and then we will split it to two subsets again with each one composed of distinct labels.
full_dataset = datasets.StanfordCars(
root=dataset_path, split="train", download=True
) + datasets.StanfordCars(root=dataset_path, split="test", download=True)
# full_dataset contains examples from 196 categories labeled with an integer from 0 to 195
# randomly sample half of it to be used for training
train_categories = np.random.choice(a=196, size=196 // 2, replace=False)
# get a list of labels for all samples in the dataset
labels_list = np.array([label for _, label in tqdm.tqdm(full_dataset)])
# get a mask for indices where label is included in train_categories
labels_mask = np.isin(labels_list, train_categories)
# get a list of indices to be used as train samples
train_indices = np.argwhere(labels_mask).squeeze()
# others will be used as test samples
test_indices = np.argwhere(np.logical_not(labels_mask)).squeeze()
# now that we have distinct indices for train and test sets, we can use `Subset` to create new datasets
# from `full_dataset`, which contain only the samples at given indices.
# finally, we apply transformations created above.
train_dataset = CarsDataset(
Subset(full_dataset, train_indices), transform=transform
)
test_dataset = CarsDataset(
Subset(full_dataset, test_indices), transform=transform
)
return train_dataset, test_dataset
def get_dataloaders(
batch_size: int,
input_size: int,
shuffle: bool = False,
):
train_dataset, test_dataset = get_datasets(input_size)
train_dataloader = GroupSimilarityDataLoader(
train_dataset, batch_size=batch_size, shuffle=shuffle
)
test_dataloader = GroupSimilarityDataLoader(
test_dataset, batch_size=batch_size, shuffle=False
)
return train_dataloader, test_dataloader
class CarsDataset(Dataset):
def __init__(self, dataset: Dataset, transform: Callable):
self._dataset = dataset
self._transform = transform
def __len__(self) -> int:
return len(self._dataset)
def __getitem__(self, index) -> SimilarityGroupSample:
image, label = self._dataset[index]
image = self._transform(image)
return SimilarityGroupSample(obj=image, group=label)
可训练模型
现在是时候回顾 Quaterion 最令人兴奋的构建块之一了:TrainableModel。它是您希望配置用于训练的模型的基类,它提供了几个以 configure_ 开头的钩子方法,用于设置训练阶段的各个方面,就像其自己的基类 pl.LightningModule 一样。它对于使用 Quaterion 进行微调至关重要,因此我们将分解 models.py 中的这段基本代码,并单独回顾每个方法。让我们从导入开始
import torch
import torchvision
from quaterion_models.encoders import Encoder
from quaterion_models.heads import EncoderHead, SkipConnectionHead
from torch import nn
from typing import Dict, Union, Optional, List
from quaterion import TrainableModel
from quaterion.eval.attached_metric import AttachedMetric
from quaterion.eval.group import RetrievalRPrecision
from quaterion.loss import SimilarityLoss, TripletLoss
from quaterion.train.cache import CacheConfig, CacheType
from .encoders import CarsEncoder
在以下代码片段中,我们子类化 TrainableModel。您可以使用 __init__() 存储一些属性,以便稍后在各种 configure_* 方法中使用。然而,更有趣的部分在 configure_encoders() 方法中。我们需要从此方法返回一个 Encoder 实例(或一个以 Encoder 实例作为值的字典)。在我们的例子中,它是一个 CarsEncoders 实例,我们很快就会回顾。现在请注意它是如何使用预训练的 ResNet152 模型创建的,其分类层被替换为恒等函数。
class Model(TrainableModel):
def __init__(self, lr: float, mining: str):
self._lr = lr
self._mining = mining
super().__init__()
def configure_encoders(self) -> Union[Encoder, Dict[str, Encoder]]:
pre_trained_encoder = torchvision.models.resnet152(pretrained=True)
pre_trained_encoder.fc = nn.Identity()
return CarsEncoder(pre_trained_encoder)
在 Quaterion 中,SimilarityModel 由一个或多个 Encoder 和一个 EncoderHead 组成。quaterion_models 有 几种 EncoderHead 实现,它们具有统一的 API,例如可配置的 dropout 值。您可以使用其中之一,或者创建自己的 EncoderHead 子类。在任何一种情况下,您都需要从 configure_head 返回其一个实例。在此示例中,我们将使用 SkipConnectionHead,它轻量且更耐过拟合。
def configure_head(self, input_embedding_size) -> EncoderHead:
return SkipConnectionHead(input_embedding_size, dropout=0.1)
Quaterion 实现了 一些流行的相似性学习损失函数,所有这些函数都子类化 GroupLoss 或 PairwiseLoss。在此示例中,我们将使用 TripletLoss,它是 GroupLoss 的子类。通常,GroupLoss 的子类用于其中样本分配有某个组(或标签)的数据集。在我们的示例中,标签是汽车的品牌。这些数据集应发出 SimilarityGroupSample。其他替代方案是 PairwiseLoss 的实现,它们消耗 SimilarityPairSample - 成对对象,其相似性是单独指定的。要查看后者的示例,您可能需要查看 NLP 教程
def configure_loss(self) -> SimilarityLoss:
return TripletLoss(mining=self._mining, margin=0.5)
configure_optimizers() 对 PyTorch Lightning 用户来说可能很熟悉,但该方法内部使用了新颖的 self.model。它是 SimilarityModel 的一个实例,由 Quaterion 从 configure_encoders() 和 configure_head() 的返回值自动创建。
def configure_optimizers(self):
optimizer = torch.optim.Adam(self.model.parameters(), self._lr)
return optimizer
Quaterion 中的缓存用于避免在每个 epoch 中计算冻结的预训练 Encoder 的输出。配置后,输出将计算一次并缓存在首选设备中以供以后直接使用。它既提供了显著的速度提升,又减少了内存占用。然而,它非常通用,并且有几个可调的旋钮。为了充分发挥其潜力,建议您查看 缓存教程。为了使本文内容独立,您需要从 configure_caches() 返回一个 CacheConfig 实例,以指定与缓存相关的首选项,例如
CacheType,即是在 CPU 还是 GPU 上存储缓存,save_dir,即在哪里持久化缓存以供后续运行,batch_size,即仅在创建缓存时使用的批量大小 - 实际训练期间使用的批量大小可能不同。
def configure_caches(self) -> Optional[CacheConfig]:
return CacheConfig(
cache_type=CacheType.AUTO, save_dir="./cache_dir", batch_size=32
)
我们刚刚配置了 TrainableModel 的训练相关设置。然而,评估是机器学习实验不可或缺的一部分,您可以通过从 configure_metrics() 返回一个或多个 AttachedMetric 实例来配置评估指标。Quaterion 内置了多种 组 和 成对 评估指标。
def configure_metrics(self) -> Union[AttachedMetric, List[AttachedMetric]]:
return AttachedMetric(
"rrp",
metric=RetrievalRPrecision(),
prog_bar=True,
on_epoch=True,
on_step=False,
)
编码器
如前所述,SimilarityModel 由一个或多个 Encoder 和一个 EncoderHead 组成。即使我们冻结预训练的 Encoder 实例,EncoderHead 仍然是可训练的,并且具有足够的参数来适应手头的新任务。建议您尽可能将 trainable 属性设置为 False,因为它能让您受益于上述缓存机制。另一个重要属性是 embedding_size,它将作为 input_embedding_size 传递给 TrainableModel.configure_head(),以便正确初始化头部层。让我们看看在以下代码中 Encoder 是如何实现的,该代码借用自 encoders.py
import os
import torch
import torch.nn as nn
from quaterion_models.encoders import Encoder
class CarsEncoder(Encoder):
def __init__(self, encoder_model: nn.Module):
super().__init__()
self._encoder = encoder_model
self._embedding_size = 2048 # last dimension from the ResNet model
@property
def trainable(self) -> bool:
return False
@property
def embedding_size(self) -> int:
return self._embedding_size
Encoder 是一个常规的 torch.nn.Module 子类,我们需要在 forward 方法中实现前向传播逻辑。根据您创建子模块的方式,此方法可能更复杂;但是,在此示例中,我们只是通过预训练的 ResNet152 骨干网络传递输入
def forward(self, images):
embeddings = self._encoder.forward(images)
return embeddings
机器学习开发的一个重要步骤是正确保存和加载模型。Quaterion 允许您使用 TrainableModel.save_servable() 保存您的 SimilarityModel,并使用 SimilarityModel.load() 恢复它。为了能够使用这两个方法,您需要在 Encoder 中实现 save() 和 load() 方法。此外,将 Encoder 的子类定义在 __main__ 命名空间之外也很重要,即在与主入口点不同的文件中。否则,它可能无法正确恢复。
def save(self, output_path: str):
os.makedirs(output_path, exist_ok=True)
torch.save(self._encoder, os.path.join(output_path, "encoder.pth"))
@classmethod
def load(cls, input_path):
encoder_model = torch.load(os.path.join(input_path, "encoder.pth"))
return CarsEncoder(encoder_model)
培训
实现所有基本对象后,将它们整合在一起并使用 Quaterion.fit() 方法运行训练循环变得轻而易举。它需要
- 一个
TrainableModel, - 一个
pl.Trainer, - 一个用于训练数据的
SimilarityDataLoader, - 以及可选的,另一个用于评估数据的
SimilarityDataLoader。
我们需要导入一些对象来准备所有这些
import os
import pytorch_lightning as pl
import torch
from pytorch_lightning.callbacks import EarlyStopping, ModelSummary
from quaterion import Quaterion
from .data import get_dataloaders
from .models import Model
以下代码片段中的 train() 函数需要几个超参数值作为参数。它们可以在 config.py 中定义,或者从命令行传递。然而,为了简洁起见,这部分代码被省略了。相反,让我们关注所有构建块是如何初始化并传递给 Quaterion.fit() 的,后者负责运行整个循环。当训练循环完成时,您可以简单地调用 TrainableModel.save_servable() 来保存 SimilarityModel 实例的当前状态
def train(
lr: float,
mining: str,
batch_size: int,
epochs: int,
input_size: int,
shuffle: bool,
save_dir: str,
):
model = Model(
lr=lr,
mining=mining,
)
train_dataloader, val_dataloader = get_dataloaders(
batch_size=batch_size, input_size=input_size, shuffle=shuffle
)
early_stopping = EarlyStopping(
monitor="validation_loss",
patience=50,
)
trainer = pl.Trainer(
gpus=1 if torch.cuda.is_available() else 0,
max_epochs=epochs,
callbacks=[early_stopping, ModelSummary(max_depth=3)],
enable_checkpointing=False,
log_every_n_steps=1,
)
Quaterion.fit(
trainable_model=model,
trainer=trainer,
train_dataloader=train_dataloader,
val_dataloader=val_dataloader,
)
model.save_servable(save_dir)
评估
让我们看看我们通过这些简单步骤取得了什么成就。evaluate.py 有两个函数来评估基线模型和调优后的相似性模型。为了简洁起见,我们将只回顾后者。除了轻松恢复 SimilarityModel 之外,这段代码片段还展示了如何使用 Evaluator 通过给定的评估指标来评估 SimilarityModel 在给定数据集上的性能。

检索的原始模型和调优模型的比较
数据集的完整评估通常呈指数增长,因此您可能希望对采样子集进行部分评估。在这种情况下,您可以使用 采样器 来限制评估。与用于训练的 Quaterion.fit() 类似,Quaterion.evaluate() 运行完整的评估循环。它将以下内容作为参数
- 使用给定评估指标和
Sampler创建的Evaluator实例, - 要评估的
SimilarityModel, - 以及评估数据集。
def eval_tuned_encoder(dataset, device):
print("Evaluating tuned encoder...")
tuned_cars_model = SimilarityModel.load(
os.path.join(os.path.dirname(__file__), "cars_encoders")
).to(device)
tuned_cars_model.eval()
result = Quaterion.evaluate(
evaluator=Evaluator(
metrics=RetrievalRPrecision(),
sampler=GroupSampler(sample_size=1000, device=device, log_progress=True),
),
model=tuned_cars_model,
dataset=dataset,
)
print(result)
结论
在本教程中,我们训练了一个相似性模型,用于从训练阶段未见过的新类别中搜索相似的汽车。然后,我们通过检索 R-Precision 指标在测试数据集上对其进行了评估。基础模型得分 0.1207,而我们的调优模型得分 0.2540,是前者的两倍。这些分数如下图所示

基础模型和调优模型的指标
