"""Base classes for affinity matrices."""
# Author: Hugues Van Assel <vanasselhugues@gmail.com>
#
# License: BSD 3-Clause License
from abc import ABC
from typing import Union, Any
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchdr.utils import (
to_torch,
bool_arg,
set_logger,
DistributedContext,
)
from torchdr.distance import (
pairwise_distances,
FaissConfig,
FaissPlanConfig,
)
from torchdr.distance.faiss import input_sharded_pairwise_distances_faiss
from torchdr.distance.faiss_plan import _resolve_faiss_plan
from torchdr.distributed.input_contract import gather_shard_layout
import torch.distributed as dist
[docs]
class Affinity(nn.Module, ABC):
r"""Base class for affinity matrices.
Parameters
----------
metric : str, optional
Distance metric for pairwise distances. Default is "sqeuclidean".
zero_diag : bool, optional
Whether to set the diagonal to zero. Default is True.
device : str, optional
Device for computation. ``"auto"`` uses the input data's device.
Default is "auto".
backend : {"keops", "faiss", None}, FaissConfig, or FaissPlanConfig, optional
Backend for handling sparsity and memory efficiency. A ``FaissPlanConfig``
expresses high-level intent (e.g. ``mode="exact"``); the resolved plan is
stored as ``faiss_plan_`` after computation.
Default is None (standard PyTorch).
verbose : bool, optional
Verbosity. Default is False.
compile : bool, optional
Whether to compile the affinity computation. Default is False.
_pre_processed : bool, optional
If True, skips ``to_torch`` conversion (inputs are already tensors
on the correct device). Default is False.
"""
def __init__(
self,
metric: str = "sqeuclidean",
zero_diag: bool = True,
device: str = "auto",
backend: Union[str, FaissConfig, FaissPlanConfig, None] = None,
verbose: bool = False,
random_state: float = None,
compile: bool = False,
_pre_processed: bool = False,
):
super().__init__()
self.log = {}
self.metric = metric
self.zero_diag = bool_arg(zero_diag)
self.device = device if device is not None else "auto"
self.backend = backend
self.verbose = bool_arg(verbose)
self.random_state = random_state
self.compile = compile
self._pre_processed = _pre_processed
self.logger = set_logger(self.__class__.__name__, self.verbose)
# --- Public API ---
def __call__(self, X: Union[torch.Tensor, np.ndarray], **kwargs):
r"""Compute the affinity matrix from the input data.
Parameters
----------
X : torch.Tensor or np.ndarray of shape (n_samples, n_features)
Input data.
Returns
-------
affinity_matrix : torch.Tensor or pykeops.torch.LazyTensor
The computed affinity matrix.
"""
if not self._pre_processed:
X = to_torch(X)
return self._compute_affinity(X, **kwargs)
# --- Core computation (must be implemented by subclasses) ---
def _compute_affinity(self, X: torch.Tensor):
r"""Compute the affinity matrix. Must be overridden by subclasses."""
raise NotImplementedError(
"[TorchDR] ERROR : `_compute_affinity` method is not implemented."
)
# --- Distance computation ---
def _resolve_plan_backend(self, X):
r"""Resolve and expose diagnostics for a ``FaissPlanConfig`` backend.
If ``self.backend`` is a :class:`~torchdr.distance.FaissPlanConfig`,
resolve it into an immutable execution plan (stored as
``self.faiss_plan_`` and printed on rank 0 when ``verbose=True``) and
return the original high-level configuration for ``pairwise_distances``
to dispatch. Otherwise return ``self.backend`` unchanged.
"""
backend = self.backend
if not isinstance(backend, FaissPlanConfig):
return backend
plan, _ = _resolve_faiss_plan(
backend,
n_samples=self._get_n_samples(X),
n_features=self._get_n_features(X),
distributed_ctx=getattr(self, "dist_ctx", None),
)
self.faiss_plan_ = plan
if self.verbose and getattr(self, "rank", 0) == 0:
self.logger.info(f"Resolved FAISS execution plan: {plan}")
return backend
def _distance_matrix(
self, X: torch.Tensor, k: int = None, return_indices: bool = False
):
r"""Compute the pairwise distance matrix.
Parameters
----------
X : torch.Tensor of shape (n_samples, n_features)
Input data.
k : int, optional
Number of nearest neighbors. Default is None (full matrix).
return_indices : bool, optional
Whether to return k-NN indices. Default is False.
Returns
-------
C : torch.Tensor or pykeops.torch.LazyTensor
The pairwise distance matrix.
"""
return pairwise_distances(
X=X,
metric=self.metric,
backend=self._resolve_plan_backend(X),
exclude_diag=self.zero_diag,
k=k,
return_indices=return_indices,
device=self.device,
)
# --- Utilities ---
def _get_compute_device(self, X):
"""Return the target device (from ``self.device`` or inferred from X)."""
if self.device != "auto":
return self.device
if isinstance(X, DataLoader):
from torchdr.distance.faiss import get_dataloader_metadata
metadata = get_dataloader_metadata(X)
if metadata is not None and "device" in metadata:
return metadata["device"]
for batch in X:
if isinstance(batch, (list, tuple)):
batch = batch[0]
return batch.device
return torch.device("cpu")
return X.device
def _get_n_samples(self, X):
"""Return the number of samples in the input."""
if isinstance(X, DataLoader):
return len(X.dataset)
return X.shape[0]
def _get_n_features(self, X):
"""Return the feature dimension, or None if unknown (e.g. DataLoader)."""
if isinstance(X, DataLoader):
from torchdr.distance.faiss import get_dataloader_metadata
metadata = get_dataloader_metadata(X)
if metadata is not None and "n_features" in metadata:
return metadata["n_features"]
return None
return X.shape[1]
def _get_dtype(self, X):
"""Return the dtype of the input."""
if isinstance(X, DataLoader):
from torchdr.distance.faiss import get_dataloader_metadata
metadata = get_dataloader_metadata(X)
if metadata is not None:
return metadata["dtype"]
for batch in X:
if isinstance(batch, (list, tuple)):
batch = batch[0]
return batch.dtype
raise ValueError("[TorchDR] DataLoader is empty, cannot determine dtype.")
return X.dtype
# --- Memory management ---
[docs]
def clear_memory(self):
"""Clear non-persistent buffers to free memory."""
if hasattr(self, "_non_persistent_buffers_set"):
for name in list(self._non_persistent_buffers_set):
if hasattr(self, name):
delattr(self, name)
if torch.cuda.is_available():
torch.cuda.empty_cache()
[docs]
class LogAffinity(Affinity):
r"""Base class for affinity matrices in log domain.
Subclasses must implement :meth:`_compute_log_affinity`.
Parameters
----------
metric : str, optional
Distance metric for pairwise distances. Default is "sqeuclidean".
device : str, optional
Device for computation. ``"auto"`` uses the input data's device.
Default is "auto".
backend : {"keops", "faiss", None}, FaissConfig, or FaissPlanConfig, optional
Backend for handling sparsity and memory efficiency. A ``FaissPlanConfig``
expresses high-level intent (e.g. ``mode="exact"``); the resolved plan is
stored as ``faiss_plan_`` after computation.
Default is None (standard PyTorch).
verbose : bool, optional
Verbosity. Default is False.
compile : bool, optional
Whether to compile the affinity computation. Default is False.
_pre_processed : bool, optional
If True, skips ``to_torch`` conversion. Default is False.
"""
def __init__(
self,
metric: str = "sqeuclidean",
zero_diag: bool = True,
device: str = "auto",
backend: Union[str, FaissConfig, FaissPlanConfig, None] = None,
verbose: bool = False,
random_state: float = None,
compile: bool = False,
_pre_processed: bool = False,
):
super().__init__(
metric=metric,
zero_diag=zero_diag,
device=device,
backend=backend,
verbose=verbose,
random_state=random_state,
compile=compile,
_pre_processed=_pre_processed,
)
def __call__(
self,
X: Union[torch.Tensor, np.ndarray],
log: bool = False,
**kwargs: Any,
):
r"""Compute the affinity matrix (or its log) from the input data.
Parameters
----------
X : torch.Tensor or np.ndarray of shape (n_samples, n_features)
Input data.
log : bool, optional
If True, returns the log affinity. Otherwise, exponentiates it.
Returns
-------
affinity_matrix : torch.Tensor or pykeops.torch.LazyTensor
The affinity matrix (or log affinity if ``log=True``).
"""
if not self._pre_processed:
X = to_torch(X)
log_affinity = self._compute_log_affinity(X, **kwargs)
if log:
return log_affinity
else:
return log_affinity.exp()
def _compute_log_affinity(self, X: torch.Tensor, **kwargs):
r"""Compute the log affinity matrix. Must be overridden by subclasses."""
raise NotImplementedError(
"[TorchDR] ERROR : `_compute_log_affinity` method is not implemented."
)
class SparseAffinity(Affinity):
r"""Base class for sparse affinity matrices.
Returns the affinity matrix in rectangular format (n_samples, k) with
the corresponding k-NN indices when sparsity is enabled. Otherwise,
returns the full (n_samples, n_samples) matrix.
**Distributed training:** When ``distributed='auto'`` (default) and
launched with ``torchrun``, each GPU processes a chunk of the dataset
in parallel. Requires ``sparsity=True`` and ``backend="faiss"``.
Subclasses must implement :meth:`_compute_sparse_affinity`.
Parameters
----------
metric : str, optional
Distance metric for pairwise distances. Default is "sqeuclidean".
zero_diag : bool, optional
Whether to set the diagonal to zero. Default is True.
device : str, optional
Device for computation. ``"auto"`` uses the input data's device.
Default is "auto".
backend : {"keops", "faiss", None}, FaissConfig, or FaissPlanConfig, optional
Backend for handling sparsity and memory efficiency. A ``FaissPlanConfig``
expresses high-level intent (e.g. ``mode="exact"``); the resolved plan is
stored as ``faiss_plan_`` after computation.
Default is None (standard PyTorch).
verbose : bool, optional
Verbosity. Default is False.
compile : bool, optional
Whether to compile the affinity computation. Default is False.
sparsity : bool or 'auto', optional
Whether to use sparse (rectangular) format. Default is True.
distributed : bool or 'auto', optional
Whether to use distributed multi-GPU computation.
``"auto"`` detects ``torchrun`` automatically. Default is "auto".
input_layout : {'replicated', 'sharded'}, optional
Whether every rank receives the full input or one contiguous row shard.
Sharded input currently requires exact Flat FAISS search. Default is
"replicated".
_pre_processed : bool, optional
If True, skips ``to_torch`` conversion. Default is False.
"""
def __init__(
self,
metric: str = "sqeuclidean",
zero_diag: bool = True,
device: str = "auto",
backend: Union[str, FaissConfig, FaissPlanConfig, None] = None,
verbose: bool = False,
compile: bool = False,
sparsity: bool = True,
distributed: Union[bool, str] = "auto",
random_state: float = None,
input_layout: str = "replicated",
_pre_processed: bool = False,
):
# --- Distributed setup ---
if distributed == "auto":
self.distributed = dist.is_initialized()
else:
self.distributed = bool(distributed)
if self.distributed:
if not dist.is_initialized():
raise RuntimeError(
"[TorchDR] distributed=True requires launching with "
"torchrun. "
"Example: torchrun --nproc_per_node=4 your_script.py"
)
self.dist_ctx = DistributedContext()
self.rank = self.dist_ctx.rank
self.world_size = self.dist_ctx.world_size
self.is_multi_gpu = self.world_size > 1
if device == "cpu":
raise ValueError(
"[TorchDR] Distributed mode requires GPU (device cannot be 'cpu')"
)
device = torch.device(f"cuda:{self.dist_ctx.local_rank}")
# Force sparsity and FAISS backend for distributed mode
self._sparsity_forced = not sparsity
if self._sparsity_forced:
sparsity = True
self._backend_forced = backend not in [
"faiss",
None,
] and not isinstance(backend, (FaissConfig, FaissPlanConfig))
if self._backend_forced:
self._original_backend = backend
backend = "faiss"
else:
self.dist_ctx = None
self.rank = 0
self.world_size = 1
self.is_multi_gpu = False
super().__init__(
metric=metric,
zero_diag=zero_diag,
device=device,
backend=backend,
verbose=verbose,
random_state=random_state,
compile=compile,
_pre_processed=_pre_processed,
)
self.sparsity = sparsity
if self.distributed and self.verbose:
if self._sparsity_forced:
self.logger.warning(
"Distributed mode requires sparsity=True, enabling sparsity."
)
if self._backend_forced:
self.logger.warning(
f"Distributed mode requires FAISS backend, "
f"switching from '{self._original_backend}' to 'faiss'."
)
if self.is_multi_gpu:
self.logger.info(
f"Distributed mode enabled: rank {self.rank}/{self.world_size}"
)
# --- Input layout (replicated vs row-sharded) ---
if input_layout not in ("replicated", "sharded"):
raise ValueError(
f"[TorchDR] input_layout must be 'replicated' or 'sharded', got "
f"{input_layout!r}."
)
self.input_layout = input_layout
self._shard_layout_ = None
if input_layout == "sharded":
self._resolve_sharded_faiss_config()
def _resolve_sharded_faiss_config(self):
"""Validate and cache the exact Flat config used by the sharded path.
Input-sharding builds a per-rank Flat index over each shard and merges
the global top-k, so it composes only with exact ``Flat`` search. A high-
level plan must explicitly request the matching sharded index topology;
replicated intent is contradictory when the caller supplies row shards.
"""
backend = self.backend
if isinstance(backend, FaissPlanConfig):
if backend.distribution != "shard":
raise ValueError(
"[TorchDR] input_layout='sharded' requires "
"FaissPlanConfig(distribution='shard'); replicated index "
"intent contradicts a row-sharded input."
)
_, resolved = _resolve_faiss_plan(backend)
elif isinstance(backend, FaissConfig):
resolved = backend
elif backend in ("faiss", None):
resolved = FaissConfig()
else:
raise ValueError(
"[TorchDR] input_layout='sharded' requires backend='faiss' or "
"an exact-Flat FaissConfig; an explicitly selected non-FAISS "
f"backend cannot be used, got {backend!r}."
)
if resolved.index_type != "Flat":
raise NotImplementedError(
"[TorchDR] input_layout='sharded' currently supports only "
f"exact 'Flat' search, got index_type={resolved.index_type!r}."
" An approximate sharded index is a separate follow-up."
)
self._sharded_faiss_config_ = resolved
def _resolve_shard_layout(self, X):
"""Gather (once per call) and cache this input's rank-major shard layout.
Also records the chunk bounds that the distributed sparse symmetrization
and the estimator's gradient partition read back, derived here from the
true shard offsets rather than the balanced split. Single-process (no
group or one rank) short-circuits to the whole input as a single shard.
"""
layout = self._shard_layout_
if layout is None:
layout = gather_shard_layout(X, self.dist_ctx if self.distributed else None)
self._shard_layout_ = layout
self.n_global_ = layout.global_count
self.chunk_start_ = layout.local_offset
self.chunk_end_ = layout.local_offset + layout.local_count
self.chunk_size_ = layout.local_count
return layout
# --- Sparsity property ---
@property
def sparsity(self):
"""Return the sparsity setting."""
return self._sparsity
@sparsity.setter
def sparsity(self, value):
"""Set the sparsity setting."""
self._sparsity = bool_arg(value)
# --- Public API ---
def __call__(
self,
X: Union[torch.Tensor, np.ndarray],
return_indices: bool = True,
**kwargs,
):
r"""Compute the sparse affinity matrix from the input data.
Parameters
----------
X : torch.Tensor or np.ndarray of shape (n_samples, n_features)
Input data.
return_indices : bool, optional
Whether to return k-NN indices. Default is True.
Returns
-------
affinity_matrix : torch.Tensor
The computed affinity matrix.
indices : torch.Tensor or None
k-NN indices if ``return_indices=True`` and sparsity is enabled.
"""
if not self._pre_processed:
X = to_torch(X)
if self.input_layout == "sharded":
# Drop any layout cached by a previous call so this fit re-derives it
# from the shards actually passed here.
self._shard_layout_ = None
return self._compute_sparse_affinity(X, return_indices, **kwargs)
# --- Core computation (must be implemented by subclasses) ---
def _compute_sparse_affinity(
self, X: torch.Tensor, return_indices: bool = True, **kwargs
):
r"""Compute the sparse affinity matrix. Must be overridden."""
raise NotImplementedError(
"[TorchDR] ERROR : `_compute_sparse_affinity` method is not implemented."
)
# --- Distance computation ---
def _distance_matrix(
self, X: torch.Tensor, k: int = None, return_indices: bool = False
):
"""Compute pairwise distances, passing distributed context if active.
Parameters
----------
X : torch.Tensor
Input data.
k : int, optional
Number of nearest neighbors.
return_indices : bool, default=False
Whether to return k-NN indices.
Returns
-------
distances : torch.Tensor
Distance matrix.
indices : torch.Tensor, optional
Indices if ``return_indices=True``.
"""
# Row-sharded input: each rank holds a distinct contiguous shard and the
# search returns *global* neighbor ids over the reconstructed dataset.
# The dispatcher's replicated-input contract does not apply here, so this
# path calls the input-sharded kernel directly and records the chunk
# bounds from the true shard offsets.
if self.input_layout == "sharded":
layout = self._resolve_shard_layout(X)
if isinstance(self.backend, FaissPlanConfig):
self.faiss_plan_, _ = _resolve_faiss_plan(
self.backend,
n_samples=layout.global_count,
n_features=X.shape[1],
distributed_ctx=self.dist_ctx if self.distributed else None,
max_indexed_rows=max(layout.counts),
)
if self.verbose and self.rank == 0:
self.logger.info(
f"Resolved FAISS execution plan: {self.faiss_plan_}"
)
distances, indices = input_sharded_pairwise_distances_faiss(
X,
k=k,
metric=self.metric,
exclude_diag=self.zero_diag,
config=self._sharded_faiss_config_,
device=self.device,
distributed_ctx=self.dist_ctx if self.distributed else None,
)
return (distances, indices) if return_indices else distances
backend = self._resolve_plan_backend(X)
result = pairwise_distances(
X=X,
metric=self.metric,
backend=backend,
exclude_diag=self.zero_diag,
k=k,
return_indices=return_indices,
device=self.device,
distributed_ctx=self.dist_ctx if self.distributed else None,
)
# Store chunk bounds for downstream use (e.g. distributed symmetrization)
if self.distributed and self.dist_ctx is not None:
chunk_start, chunk_end = self.dist_ctx.compute_chunk_bounds(
self._get_n_samples(X)
)
self.chunk_start_ = chunk_start
self.chunk_end_ = chunk_end
self.chunk_size_ = chunk_end - chunk_start
return result
class SparseLogAffinity(SparseAffinity, LogAffinity):
r"""Base class for sparse log affinity matrices.
Combines :class:`SparseAffinity` (sparse format, distributed support)
with :class:`LogAffinity` (log-domain computation).
Subclasses must implement :meth:`_compute_sparse_log_affinity`.
Parameters
----------
metric : str, optional
Distance metric for pairwise distances. Default is "sqeuclidean".
zero_diag : bool, optional
Whether to set the diagonal to zero. Default is True.
device : str, optional
Device for computation. ``"auto"`` uses the input data's device.
Default is "auto".
backend : {"keops", "faiss", None}, FaissConfig, or FaissPlanConfig, optional
Backend for handling sparsity and memory efficiency. A ``FaissPlanConfig``
expresses high-level intent (e.g. ``mode="exact"``); the resolved plan is
stored as ``faiss_plan_`` after computation.
Default is None (standard PyTorch).
verbose : bool, optional
Verbosity. Default is False.
compile : bool, optional
Whether to compile the affinity computation. Default is False.
sparsity : bool or 'auto', optional
Whether to use sparse (rectangular) format. Default is True.
distributed : bool or 'auto', optional
Whether to use distributed multi-GPU computation.
``"auto"`` detects ``torchrun`` automatically. Default is "auto".
_pre_processed : bool, optional
If True, skips ``to_torch`` conversion. Default is False.
"""
def __call__(
self,
X: Union[torch.Tensor, np.ndarray],
log: bool = False,
return_indices: bool = True,
**kwargs,
):
r"""Compute the sparse (log) affinity matrix from the input data.
Parameters
----------
X : torch.Tensor or np.ndarray of shape (n_samples, n_features)
Input data.
log : bool, optional
If True, returns the log affinity. Otherwise, exponentiates it.
return_indices : bool, optional
Whether to return k-NN indices. Default is True.
Returns
-------
affinity_matrix : torch.Tensor
The affinity matrix (or log affinity if ``log=True``).
indices : torch.Tensor or None
k-NN indices if ``return_indices=True`` and sparsity is enabled.
"""
if not self._pre_processed:
X = to_torch(X)
if return_indices:
log_affinity, indices = self._compute_sparse_log_affinity(
X, return_indices, **kwargs
)
affinity_to_return = log_affinity if log else log_affinity.exp()
return (affinity_to_return, indices)
else:
log_affinity = self._compute_sparse_log_affinity(
X, return_indices, **kwargs
)
affinity_to_return = log_affinity if log else log_affinity.exp()
return affinity_to_return
def _compute_sparse_log_affinity(
self, X: torch.Tensor, return_indices: bool = False, **kwargs
):
r"""Compute the sparse log affinity matrix. Must be overridden."""
raise NotImplementedError(
"[TorchDR] ERROR : `_compute_sparse_log_affinity` method is "
"not implemented."
)