Retrieval backends¶
pytorch_metric_learning.evaluation provides nearest-neighbor search,
threshold matching and selection, and k-means clustering for
AccuracyCalculator and application code. These
operate on embedding tensors the caller has already computed; the
evaluation recipe shows an ordinary
PyTorch extraction loop.
from pytorch_metric_learning.evaluation import (
CustomKNN,
FaissKMeans,
FaissKNN,
MatchFinder,
)
| Backend | Dependency | Default for |
|---|---|---|
CustomKNN |
PyTorch | AccuracyCalculator k-NN metrics |
MatchFinder |
PyTorch | None; construct it explicitly |
FaissKNN |
faiss extra |
None; pass it as knn_func |
FaissKMeans |
faiss extra |
None; pass it as kmeans_func for NMI and AMI |
Importing pytorch_metric_learning.evaluation does not import Faiss or
scikit-learn. Constructing a Faiss backend without the extra raises
ModuleNotFoundError naming pytorch-metric-learning[faiss].
k-NN function contract¶
A knn_func is called as knn_func(query, k, reference, ref_includes_query)
and returns (distances, indices), each shaped (len(query), k), on the
query's device. Rows are ordered from nearest to farthest. When
ref_includes_query is true, the backend searches for k + 1 neighbors and
removes each query's own index i; if row i does not appear in a row's
results, the last neighbor is removed instead. This assumes the query is the
first len(query) rows of reference, which
AccuracyCalculator validates.
Every index must address a row of the reference passed to that call.
AccuracyCalculator raises ValueError for indices of another shape or
outside the reference set. It calls knn_func under torch.no_grad() with the
number of neighbors its selected metrics need.
CustomKNN¶
evaluation.CustomKNN(distance, batch_size=None)
- distance: A distance. Similarities such as
CosineSimilarityselect the largest values; distances select the smallest. Normalization follows the distance'snormalize_embeddingssetting. - batch_size: If set, compute
(batch_size, len(reference))distance blocks and keep each block's topk. The result equals the unbatched result except for the order of exactly tied values; peak memory falls from the full(len(query), len(reference))matrix to one block plus the outputs.
Distances have the dtype the distance returns, which is float32 for float16 queries
with an unnormalized distance; indices are torch.long. Tied values are
ordered by torch.topk, which does not guarantee index order. The search runs
under torch.no_grad(), so its outputs carry no autograd graph even when the
inputs require gradients. The backend retains no per-call result tensors after
results are discarded, including after a failed search. AccuracyCalculator releases
the k-NN outputs before returning its metrics. With k=None, all neighbors are
materialized during evaluation, so peak output storage is still quadratic when
query and reference counts grow together.
AccuracyCalculator defaults to
CustomKNN(LpDistance(normalize_embeddings=False, power=2), batch_size=256):
exact squared Euclidean search on the input device without normalization.
Normalize embeddings before evaluation when cosine geometry is intended.
import torch
from pytorch_metric_learning.distances import LpDistance
from pytorch_metric_learning.evaluation import CustomKNN
query = torch.tensor([[0.0, 0.0], [3.0, 0.0]])
reference = torch.tensor([[0.0, 1.0], [3.0, 0.5], [9.0, 9.0]])
knn = CustomKNN(LpDistance(normalize_embeddings=False, power=2))
distances, indices = knn(query, 1, reference)
assert indices.tolist() == [[0], [1]]
assert distances.tolist() == [[1.0], [0.25]]
MatchFinder¶
evaluation.MatchFinder(distance, threshold=None)
A pair matches when its distance is at most threshold, or when its similarity
is at least threshold for a distance with is_inverted=True. A threshold
passed to a method overrides the constructor value; omitting both raises
ValueError. Computations run under torch.no_grad() on the inputs' device.
get_match_matrix(query_emb, ref_emb=None, threshold=None)returns a boolean(len(query_emb), len(ref_emb))tensor.get_match_indices(query_emb, ref_emb=None, threshold=None)returns atorch.longtensor shaped(num_matches, 2); each row is a(query index, reference index)pair of the match matrix, in row-major order.is_match(x, y, threshold=None)compares rowiofxwith rowiofyand returns a boolean tensor shaped(len(x),).
For get_match_matrix and get_match_indices, ref_emb=None compares
query_emb with itself, so the diagonal matches whenever a row matches itself.
All three methods honor the configured distance normalization and power, including
cosine similarity and squared Euclidean distance. is_match uses
distance.pairwise, so it computes only
corresponding pairs, without materializing a full matrix.
import torch
from pytorch_metric_learning.distances import CosineSimilarity
from pytorch_metric_learning.evaluation import MatchFinder
query = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
reference = torch.tensor([[1.0, 0.1], [1.0, 1.0], [-1.0, 0.0]])
finder = MatchFinder(CosineSimilarity(), threshold=0.9)
matches = finder.get_match_matrix(query, reference)
assert matches.tolist() == [[True, False, False], [False, False, False]]
assert finder.get_match_indices(query, reference).tolist() == [[0, 0]]
assert finder.is_match(query, reference[:2]).tolist() == [True, False]
Threshold selection¶
select_threshold chooses the threshold for a match/no-match decision from
validation scores and labels. Model inference, dataset splits, and pair
construction stay in application code.
evaluation.select_threshold(
scores: ArrayLike,
labels: ArrayLike,
metric: Callable[[MatchCounts], Tensor],
*,
is_inverted: bool,
) -> ThresholdSelection
- scores: A one-dimensional floating-point tensor or NumPy array of finite distances or similarities, one per validation pair.
- labels: A boolean tensor or array of the same length,
Truewhere the pair should match. Both values must be present. - metric: The quantity to maximize. It receives a
MatchCountsand returns one value per candidate threshold, NaN where undefined. - is_inverted:
Truewhen larger scores mean more alike, as for similarities; passdistance.is_inverted.
Invalid inputs, a metric that returns another shape, and a metric that is NaN
for every candidate raise ValueError. The function runs on CPU copies, keeps
no autograd graph, and returns Python floats.
class ThresholdSelection(NamedTuple):
threshold: float
value: float
threshold follows the MatchFinder rule, so the validation
pairs at or beyond it get exactly the decisions counted in value, the metric at
that threshold. Candidates are every distinct way to separate the sorted scores:
- Between two neighboring scores, the threshold is their midpoint, which leaves the same room on both sides. If the scores are adjacent floating-point values, it is the weaker score of the matching side.
- If every pair should match, it is the weakest score. New pairs beyond it do not match.
- If no pair should match, it is
inffor similarities and-inffor distances.
When several candidates tie, the one with the fewest matches wins.
class MatchCounts(NamedTuple):
true_positives: Tensor
false_positives: Tensor
true_negatives: Tensor
false_negatives: Tensor
Each field is a one-dimensional float64 CPU tensor with one count per candidate
threshold, strictest first. The built-in metrics take a MatchCounts:
evaluation.match_accuracy(counts: MatchCounts) -> Tensor
evaluation.match_balanced_accuracy(counts: MatchCounts) -> Tensor
evaluation.match_f1(counts: MatchCounts) -> Tensor
match_accuracy counts correct decisions, so it favors the majority on
imbalanced validation pairs. match_balanced_accuracy is the mean of recall and
specificity. match_f1 is 2 * tp / (2 * tp + fp + fn).
For verification, score labeled pairs with distance.pairwise and pass the
threshold to MatchFinder:
import torch
from pytorch_metric_learning.distances import CosineSimilarity
from pytorch_metric_learning.evaluation import MatchFinder, match_f1, select_threshold
distance = CosineSimilarity()
first = torch.tensor([[1.0, 0.0], [1.0, 0.0], [1.0, 0.0], [1.0, 0.0]])
second = torch.tensor([[1.0, 0.1], [1.0, 0.3], [0.2, 1.0], [-1.0, 0.0]])
is_same = torch.tensor([True, True, False, False])
selection = select_threshold(
distance.pairwise(first, second),
is_same,
match_f1,
is_inverted=distance.is_inverted,
)
finder = MatchFinder(distance, threshold=selection.threshold)
assert selection.value == 1.0
assert finder.is_match(first, second).tolist() == is_same.tolist()
For one-shot classification, score each query by its nearest reference and label it by whether that neighbor has the right class. A query from an unknown class is never correct, so the threshold learns to reject it:
from pytorch_metric_learning.evaluation import CustomKNN
gallery = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
gallery_labels = torch.tensor([0, 1])
query = torch.tensor([[1.0, 0.1], [0.1, 1.0], [1.0, 1.0], [-1.0, -0.5]])
query_labels = torch.tensor([0, 1, 2, 3])
similarities, neighbors = CustomKNN(distance)(query, 1, gallery)
correct = gallery_labels[neighbors[:, 0]] == query_labels
selection = select_threshold(
similarities[:, 0], correct, match_f1, is_inverted=distance.is_inverted
)
accepted = similarities[:, 0] >= selection.threshold
assert accepted.tolist() == [True, True, False, False]
A custom metric can express a constraint. This one maximizes recall among thresholds with a false match rate of at most 1%:
def recall_at_low_false_match_rate(counts):
recall = counts.true_positives / (counts.true_positives + counts.false_negatives)
false_match_rate = counts.false_positives / (
counts.false_positives + counts.true_negatives
)
return torch.where(false_match_rate <= 0.01, recall, torch.nan)
selection = select_threshold(
similarities[:, 0],
correct,
recall_at_low_false_match_rate,
is_inverted=True,
)
assert selection.value == 1.0
Logging¶
Retrieval and accuracy calculation use ordinary Python module loggers under
pytorch_metric_learning. Applications configure handlers and levels:
import logging
logging.basicConfig()
logging.getLogger("pytorch_metric_learning").setLevel(logging.INFO)
FaissKNN¶
evaluation.FaissKNN(reset_before=True, reset_after=True, index_init_fn=None, gpus=None)
- reset_before: Build a new index from
index_init_fn(dimension)on every call, addingreferencebefore searching. IfFalse, the call searches the existing index, and areferencepassed to the call is appended to it first. - reset_after: Discard the index after every call.
- index_init_fn: Callable returning a Faiss index for a dimension. Defaults to
faiss.IndexFlatL2, whose distances are squared Euclidean. - gpus: A non-empty list or tuple of Faiss GPU indices for the search.
Without it, CUDA queries use all GPUs visible to Faiss. A value that is not a
list or tuple raises
TypeError; an empty one raisesValueError.
Faiss receives float32 inputs, and results return to the query's device. An
input stays on its GPU when the index is one GPU index on that same device, and
the call runs in that device's context on its current PyTorch stream; otherwise, including for sharded multi-GPU indices, Faiss receives a CPU copy.
A GPU search requires a GPU build of Faiss, one that provides
faiss.GpuIndex, with at least one visible GPU, and a CUDA query or gpus.
The faiss-cpu package never searches on a GPU, even when Faiss reports one.
A GPU search runs only when k is at most 2,048, the limit in Faiss's
DeviceDefs.cuh; otherwise it logs a warning and uses the CPU index.
With reset_before=False, the index accumulates: each call that passes
reference appends those rows, and their indices continue after the rows already
present, so repeated calls search duplicated rows. Search a persistent index by
calling it without reference, as below. AccuracyCalculator always passes its
reference set, so give it a FaissKNN with reset_before=True, the default;
indices past its reference set raise ValueError.
To keep a persistent index, disable both resets:
import os
import tempfile
import torch
from pytorch_metric_learning.evaluation import FaissKNN
reference, more_reference, query = (
torch.randn(50, 8),
torch.randn(20, 8),
torch.randn(5, 8),
)
knn = FaissKNN(reset_before=False, reset_after=False)
knn.train(reference) # new index containing reference
knn.add(more_reference) # append rows; indices continue at 50
distances, indices = knn(query, 10)
assert indices.shape == (5, 10)
with tempfile.TemporaryDirectory() as directory:
path = os.path.join(directory, "reference.index")
knn.save(path)
restored = FaissKNN(reset_before=False, reset_after=False)
restored.load(path)
assert torch.equal(restored(query, 10)[1], indices)
FaissKMeans¶
evaluation.FaissKMeans(**kwargs)
Keyword arguments go to faiss.Kmeans. Calling kmeans(x, num_clusters) trains
on a float32 CPU copy of x and returns one torch.long cluster index per row
on x's device.
macOS OpenMP runtimes¶
The macOS faiss-cpu and PyTorch wheels each bundle an OpenMP runtime, and
loading both in one process can abort with OMP: Error #15 or crash in
multithreaded Faiss calls. Setting both KMP_DUPLICATE_LIB_OK=TRUE and
OMP_NUM_THREADS=1 before importing either library lets searches and k-means
complete, including alongside UMAP in the same process; Intel documents the
duplicate-runtime setting as unsupported. Prefer
CustomKNN on macOS, or install Faiss and PyTorch from one distribution that
shares a single OpenMP runtime. The library does not change the environment.