"""UMAP algorithm."""
# Author: Hugues Van Assel <vanasselhugues@gmail.com>
#
# License: BSD 3-Clause License
from typing import Dict, Optional, Union, Type
import torch
import numpy as np
from torchdr.affinity import UMAPAffinity
from torchdr.affinity.knn_normalized import _log_P_UMAP
from torchdr.neighbor_embedding.base import NegativeSamplingNeighborEmbedding
from torchdr.distance import pairwise_distances_indexed, FaissConfig
from torchdr.utils import binary_search
from scipy.optimize import curve_fit
# from umap/umap/umap_.py
def find_ab_params(spread, min_dist):
"""Fit a, b params as in UMAP.
Fit (a, b) for the differentiable curve used in lower
dimensional fuzzy simplicial complex construction. We want the
smooth curve (from a pre-defined family with simple gradient) that
best matches an offset exponential decay.
"""
def curve(x, a, b):
return 1.0 / (1.0 + a * x ** (2 * b))
xv = np.linspace(0, spread * 3, 300)
yv = np.zeros(xv.shape)
yv[xv < min_dist] = 1.0
yv[xv >= min_dist] = np.exp(-(xv[xv >= min_dist] - min_dist) / spread)
params, covar = curve_fit(curve, xv, yv)
return params[0].item(), params[1].item()
[docs]
class UMAP(NegativeSamplingNeighborEmbedding):
r"""UMAP introduced in :cite:`mcinnes2018umap` and further studied in :cite:`damrich2021umap`.
It uses a :class:`~torchdr.UMAPAffinity` as input affinity :math:`\mathbf{P}`
and output affinity :math:`Q_{ij} = (1 + a \| \mathbf{z}_i - \mathbf{z}_j \|^{2b})^{-1}` where :math:`a, b` are fitted from ``min_dist`` and ``spread``.
The loss function is defined as:
.. math::
-\sum_{ij} P_{ij} \log Q_{ij} + \sum_{i,j \in \mathrm{Neg}(i)} \log (1 - Q_{ij})
where :math:`\mathrm{Neg}(i)` is the set of negatives samples for point :math:`i`.
Note
----
This implementation supports multi-GPU training when launched with ``torchrun``.
Set ``distributed='auto'`` (default) to automatically detect and use multiple GPUs.
It also supports the shared non-parametric transform path implemented in
:class:`NegativeSamplingNeighborEmbedding`.
Parameters
----------
n_neighbors : float, optional
Number of nearest neighbors.
n_components : int, optional
Dimension of the embedding space.
min_dist : float, optional
Minimum distance between points in the embedding space.
spread : float, optional
The effective scale of the embedded points. Used to configure the UMAPAffinityOut.
a : float, optional
Parameter for the Student t-distribution.
b : float, optional
Parameter for the Student t-distribution.
lr : float, optional
Learning rate for the algorithm, by default 1e-1.
optimizer : str or torch.optim.Optimizer, optional
Name of an optimizer from torch.optim or an optimizer class.
Default is "SGD".
optimizer_kwargs : dict or 'auto', optional
Additional keyword arguments for the optimizer. Default is 'auto'.
which sets appropriate momentum values for SGD based on early exaggeration phase.
scheduler : str or torch.optim.lr_scheduler.LRScheduler, optional
Name of a scheduler from torch.optim.lr_scheduler or a scheduler class.
Default is "LinearLR".
scheduler_kwargs : dict, 'auto', or None, optional
Additional keyword arguments for the scheduler. Default is 'auto', which
corresponds to a linear decay from the learning rate to 0 for `LinearLR`.
init : {'normal', 'pca'} or torch.Tensor of shape (n_samples, output_dim), optional
Initialization for the embedding Z, default 'pca'.
init_scaling : float, optional
Scaling factor for the initialization, by default 1e-4.
min_grad_norm : float, optional
Precision threshold at which the algorithm stops, by default 1e-7.
max_iter : int, optional
Number of maximum iterations for the descent algorithm. by default 2000.
device : str, optional
Device to use, by default "auto".
backend : {"keops", "faiss", None} or FaissConfig, optional
Which backend to use for handling sparsity and memory efficiency.
Can be:
- "keops": Use KeOps for memory-efficient symbolic computations
- "faiss": Use FAISS for fast k-NN computations with default settings
- None: Use standard PyTorch operations
- FaissConfig object: Use FAISS with custom configuration
Default is "faiss".
verbose : bool, optional
Verbosity, by default False.
random_state : float, optional
Random seed for reproducibility, by default None.
max_iter_affinity : int, optional
Number of maximum iterations for the input affinity computation.
metric : {'euclidean', 'manhattan'}, optional
Metric to use for the input affinity, by default 'sqeuclidean'.
n_negatives : int, optional
Number of negative samples for the noise-contrastive loss, by default 10.
check_interval : int, optional
Check interval for the algorithm, by default 50.
exclude_neighbors_from_negative_sampling : bool, optional
Whether to exclude nearest neighbors from negative sampling.
Default is False.
discard_NNs : bool, optional
Deprecated alias for ``exclude_neighbors_from_negative_sampling``.
compile : bool, optional
Whether to compile the algorithm using torch.compile. Default is False.
distributed : bool or 'auto', optional
Whether to use distributed computation across multiple GPUs.
- "auto": Automatically detect if running with torchrun (default)
- True: Force distributed mode (requires torchrun)
- False: Disable distributed mode
Default is "auto".
""" # noqa: E501
def __init__(
self,
n_neighbors: float = 30,
n_components: int = 2,
min_dist: float = 0.1,
spread: float = 1.0,
a: Optional[float] = None,
b: Optional[float] = None,
lr: float = 1e0,
optimizer: Union[str, Type[torch.optim.Optimizer]] = "SGD",
optimizer_kwargs: Union[Dict, str] = None,
scheduler: Optional[
Union[str, Type[torch.optim.lr_scheduler.LRScheduler]]
] = "LinearLR",
scheduler_kwargs: Union[Dict, str, None] = "auto",
init: str = "pca",
init_scaling: float = 1e-4,
min_grad_norm: float = 1e-7,
max_iter: int = 1000,
device: str = "auto",
backend: Union[str, FaissConfig, None] = "faiss",
verbose: bool = False,
random_state: Optional[float] = None,
max_iter_affinity: int = 100,
metric: str = "sqeuclidean",
negative_sample_rate: int = 5,
check_interval: int = 50,
exclude_neighbors_from_negative_sampling: Optional[bool] = None,
discard_NNs: Optional[bool] = None,
compile: bool = False,
distributed: Union[bool, str] = "auto",
**kwargs,
):
self.n_neighbors = n_neighbors
self.min_dist = min_dist
self.spread = spread
self.metric = metric
self.max_iter_affinity = max_iter_affinity
self.negative_sample_rate = negative_sample_rate
self.sparsity = True
self._use_closed_form_gradients = True
self._eps = 1e-3
if a is None or b is None:
a, b = find_ab_params(self.spread, self.min_dist)
self._a = a
self._b = b
self.n_negatives = int(self.negative_sample_rate * self.n_neighbors)
affinity_in = UMAPAffinity(
n_neighbors=n_neighbors,
metric=metric,
max_iter=max_iter_affinity,
device=device,
backend=backend,
verbose=verbose,
sparsity=self.sparsity,
compile=compile,
distributed=distributed,
)
super().__init__(
affinity_in=affinity_in,
n_components=n_components,
optimizer=optimizer,
optimizer_kwargs=optimizer_kwargs,
min_grad_norm=min_grad_norm,
max_iter=max_iter,
lr=lr,
scheduler=scheduler,
scheduler_kwargs=scheduler_kwargs,
init=init,
init_scaling=init_scaling,
device=device,
backend=backend,
verbose=verbose,
random_state=random_state,
check_interval=check_interval,
exclude_neighbors_from_negative_sampling=exclude_neighbors_from_negative_sampling,
discard_NNs=discard_NNs,
compile=compile,
n_negatives=self.n_negatives,
distributed=distributed,
**kwargs,
)
[docs]
def on_affinity_computation_end(self):
super().on_affinity_computation_end()
# Remove small affinity edges
A_max = self.affinity_in_.max()
threshold = A_max / self.max_iter
small_affinity_edges = self.affinity_in_ <= threshold
if self.verbose:
kept_pct = (~small_affinity_edges).float().mean().item() * 100
self.logger.info(f"Keeping {kept_pct:.1f}% of affinity edges.")
self.affinity_in_.add_(1e-3).reciprocal_().mul_(A_max)
self.affinity_in_.masked_fill_(
small_affinity_edges, float("inf")
) # avoid updating these edges
self.register_buffer("epochs_per_sample", self.affinity_in_, persistent=False)
self.register_buffer(
"epoch_of_next_sample", self.epochs_per_sample.clone(), persistent=False
)
def _compute_attractive_gradients(self):
D = pairwise_distances_indexed(
self.embedding_,
query_indices=self.chunk_indices_,
key_indices=self.NN_indices_,
metric="sqeuclidean",
)
positive_edges = D > 0
D_ = 1 + self._a * D**self._b
D.pow_(self._b - 1)
D.mul_(2 * self._a * self._b).div_(D_)
D.masked_fill_(~positive_edges, 0) # prevent infinities when b < 1
# UMAP keeps a per-edge counter (epoch_of_next_sample) so that stronger edges
# (higher affinity → smaller epochs_per_sample) get updated more often.
mask_affinity_in = self.epoch_of_next_sample <= self.n_iter_ + 1
self.register_buffer("mask_affinity_in_", mask_affinity_in, persistent=False)
self.epoch_of_next_sample[self.mask_affinity_in_] += self.epochs_per_sample[
self.mask_affinity_in_
]
D.masked_fill_(~self.mask_affinity_in_, 0)
diff = (
self.embedding_[self.chunk_indices_].unsqueeze(1)
- self.embedding_[self.NN_indices_]
)
grad = torch.einsum("ijk,ij->ik", diff, D)
grad.clamp_(-4, 4) # clamp as in umap repo
return grad
def _compute_repulsive_gradients(self):
D = pairwise_distances_indexed(
self.embedding_,
query_indices=self.chunk_indices_,
key_indices=self.neg_indices_,
metric="sqeuclidean",
)
D_ = 1 + self._a * D**self._b
D.add_(self._eps)
D.mul_(D_)
D.reciprocal_().mul_(-2 * self._b)
# Filter to keep 'negative_sample_rate' negative edges per positive edge.
neg_counts = (self.mask_affinity_in_.sum(dim=1) * self.negative_sample_rate).to(
torch.long
)
col_idx = torch.arange(self.n_negatives, device=self.embedding_.device)
filtered_edges = col_idx[None, :].ge(neg_counts[:, None])
D.masked_fill_(filtered_edges, 0)
diff = (
self.embedding_[self.chunk_indices_].unsqueeze(1)
- self.embedding_[self.neg_indices_]
)
grad = torch.einsum("ijk,ij->ik", diff, D)
grad.clamp_(-4, 4) # clamp as in umap repo
return grad
# --- Non-parametric transform ---
def _compute_bipartite_affinity(self, C, indices):
"""Build the UMAP bipartite affinity used during transform.
This is the UMAP-specific hook for the shared non-parametric transform
pipeline in :class:`NegativeSamplingNeighborEmbedding`. It mirrors the
unsymmetrized UMAP neighbor graph construction on the bipartite graph
from new points to the fitted training set.
"""
# umap-learn reduces local_connectivity by one for transform. With
# UMAP's default local_connectivity=1 this means rho=0, unlike fit-time
# graph construction. Taking the nearest distance here would force one
# affinity to 1 for every query and make every point look like an exact
# match during initialization.
rho = torch.zeros(C.shape[0], dtype=C.dtype, device=C.device)
log_n_neighbors = torch.log2(
torch.tensor(self.n_neighbors, dtype=C.dtype, device=C.device)
)
def marginal_gap(eps):
# Match smooth_knn_dist's bipartite transform convention: the
# closest edge participates in the graph but is omitted from the
# bandwidth calibration sum.
log_marg = _log_P_UMAP(C[:, 1:], rho, eps).logsumexp(1)
return log_marg.exp().reshape(-1) - log_n_neighbors
eps = binary_search(
f=marginal_gap,
n=C.shape[0],
max_iter=self.max_iter_affinity,
dtype=C.dtype,
device=C.device,
)
return _log_P_UMAP(C, rho, eps).exp()
def _make_transform_epochs_per_sample(self, affinity, n_epochs):
"""Convert transform edge strengths into UMAP's epoch schedule.
This keeps the transform path aligned with UMAP's usual edge-sampling
logic while still using TorchDR's vectorized, mask-based optimizer.
"""
epochs_per_sample = torch.full_like(affinity, float("inf"))
if n_epochs <= 0:
return epochs_per_sample
max_affinity = affinity.max()
if max_affinity <= 0:
return epochs_per_sample
threshold = max_affinity / float(n_epochs)
active_edges = affinity >= threshold
eps = torch.finfo(affinity.dtype).tiny
epochs_per_sample[active_edges] = max_affinity / affinity[active_edges].clamp(
min=eps
)
return epochs_per_sample
def _initialize_transform_embedding(
self, affinity, nn_indices, train_emb, neighbor_distances=None
):
"""Match UMAP's transform initialization when exact matches exist.
The default weighted-average initialization from the base class is kept,
except that rows containing a zero-distance neighbor are snapped to the
corresponding training embedding exactly, as in ``umap-learn``.
"""
embedding_new = super()._initialize_transform_embedding(
affinity,
nn_indices,
train_emb,
neighbor_distances=neighbor_distances,
)
if neighbor_distances is None:
return embedding_new
exact_match = neighbor_distances == 0
if exact_match.any():
exact_rows = exact_match.any(dim=1)
exact_cols = exact_match.to(torch.int64).argmax(dim=1)
embedding_new[exact_rows] = train_emb[
nn_indices[exact_rows, exact_cols[exact_rows]].long()
]
return embedding_new
def _enter_transform(self, embedding_new, train_emb, affinity, nn_indices):
"""Set up UMAP edge-sampling state for transform.
Reuses the same edge-sampling schedule as fit, but on the bipartite
graph between new points and the frozen training embedding. The actual
optimization still runs through TorchDR's vectorized mask-based update
path rather than ``umap-learn``'s edge-wise CPU loop.
"""
epochs_per_sample = self._make_transform_epochs_per_sample(
affinity, self._get_max_iter_transform()
)
saved = super()._enter_transform(embedding_new, train_emb, affinity, nn_indices)
# Save UMAP-specific state
for attr in ("epochs_per_sample", "epoch_of_next_sample", "mask_affinity_in_"):
saved[attr] = (hasattr(self, attr), getattr(self, attr, None))
self.epochs_per_sample = epochs_per_sample
self.epoch_of_next_sample = epochs_per_sample.clone()
return saved