# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Spectral clustering on the GPU. Reference: cuML's `SpectralClustering`.
The estimator is `SpectralClustering`. It is backed by `spectral/`, which
implements cuML 26.08's `ML::SpectralClustering::fit_predict` down through
cuVS's `cluster::spectral::detail::fit_predict` (the kNN connectivity
graph, the normalized graph Laplacian, RAFT's thick-restart Lanczos, and
`cluster/`'s already-implemented k-means) with every closed vendor library along
that path replaced by a named, numbered stand-in (DEVIATIONS 770-781).
**THIS LANE HAS RUN ON ONE APPLE M4 AND NOWHERE ELSE.** Read that before
reading anything else here. `spectral/IDENTICAL_SPECTRAL_CONTRACT.md`
section 10 says it in the lane's own words -- "no cross-vendor result of
any kind (nothing has run anywhere but one M4)" -- and the artifacts agree:
`spectral` is not one of the lanes `tools/e1_bootstrap.sh` phase 8 runs, it
has no card in either leg-11 vendor directory, and
`tools/e3_round_judge.sh` section 7 does not name it. The class docstring
below says what the profile PINS; a pinned convention is not a measured
result, and nothing in this module claims one.
The binding lives in `bindings/_mojolearn_metrics.mojo` alongside the
metrics functions and is loaded through `_metrics_impl._get_binding`.
"""
from . import _backend, _serialize
from ._array import Array
from ._buffer import (
_native, addr, addr_ro, all_finite, as_f32_c, as_f64_c, as_i32_c, empty,
)
from ._expansion_decomp import _Kit, _M
from ._metrics_impl import _get_binding
from .density import _check_queries
from .linear_model import _check_saved_by, _restore_mode, _saved_mode, _shape_of
__all__ = ["SpectralClustering", "SpectralEmbedding", "spectral_embedding"]
#: `SpectralClustering.save`'s format tag (lane/spectral-predict,
#: 2026-09-15): the prediction data of a `prediction_data=True` fit.
_SPECTRAL_FORMAT = "mojolearn-spectral-1"
#: The prediction data a `prediction_data=True` fit keeps.
_PREDICTION_ATTRS = ("_pd_eigenvalues", "_pd_eigenvectors", "_pd_diag", "_pd_centroids", "_fit_X")
_AFFINITIES = ("nearest_neighbors", "precomputed")
#: SpectralEmbedding's affinities: the two above, scikit-learn's 'rbf' and
#: 'precomputed_nearest_neighbors' (lane/algos-decomp, 2026-09-27).
_EMBED_AFFINITIES = _AFFINITIES + ("rbf", "precomputed_nearest_neighbors")
#: SpectralClustering's: the same four (lane/algos-cluster option parity,
#: 2026-09-27); 'rbf' is scikit-learn's default, 'nearest_neighbors' cuML's
#: and this class's.
_CLUSTER_AFFINITIES = _EMBED_AFFINITIES
#: SpectralClustering's label assignments: cuVS's k-means, and scikit-learn's
#: two others on the same embedding (x_cluster/spectral_assign.mojo).
_ASSIGN_LABELS = ("kmeans", "discretize", "cluster_qr")
#: `x_cluster/entries.mojo::ENTRY_SPECTRAL_ASSIGN` and its method codes.
_E_SPECTRAL_ASSIGN = 12
_ASSIGN_CODES = {"discretize": 0, "cluster_qr": 1}
#: cuVS's own struct default for the eigensolver tolerance
#: (`cuvs/preprocessing/spectral_embedding.hpp:59`, `tolerance{1e-5f}`).
DEFAULT_EIGEN_TOL = 1e-5
#: cuVS's own struct default seed (`cuvs/cluster/spectral.hpp`,
#: `raft::random::RngState rng_state{0}`).
DEFAULT_SEED = 0
def _coo_triples(A, who="SpectralClustering"):
"""A precomputed affinity matrix as `(rows, cols, vals, n)` int32/float32.
Same behavior as cuML's `spectral_clustering.pyx:306-312`, which accepts scipy or
cupy sparse in COO/CSR/CSC and calls `sp.coo_matrix(X)` on a dense
input. Converting a dense matrix through COO DROPS EXACT ZEROS, which is
theirs and is also what makes a dense input usable at all.
A sparse input is recognized by DUCK TYPE, not by importing SciPy: any
object with a `tocoo()` whose result carries `shape`, `row`, `col` and
`data` is taken as sparse, and those three arrays are read through the
buffer protocol like every other input (DEVIATION 2489). SciPy, CuPy
(after `.get()`), and anything else shaped like them qualify; nothing
is imported to ask.
"""
tocoo = getattr(A, "tocoo", None)
if callable(tocoo):
coo = tocoo()
shape = tuple(coo.shape)
if len(shape) != 2 or shape[0] != shape[1]:
raise ValueError(
f"mojolearn {who}: a precomputed affinity matrix "
f"must be square, got shape {shape}"
)
rows, _ = as_i32_c(coo.row, ndim=1, name="rows")
cols, _ = as_i32_c(coo.col, ndim=1, name="cols")
vals, _ = as_f32_c(coo.data, ndim=1, name="vals")
return rows, cols, vals, int(shape[0])
shape = _shape_of(A)
if len(shape) != 2 or shape[0] != shape[1]:
raise ValueError(
f"mojolearn {who}: with affinity='precomputed', X "
"must be a square affinity matrix or a sparse matrix with "
f"`tocoo()`, got shape {shape}"
)
# The nonzero test is made in float64, the widest dtype the boundary
# carries, so an entry that is nonzero in a float64 input and rounds to
# 0.0f is KEPT as an explicit zero, exactly as `np.nonzero` on the
# source dtype kept it; the values are narrowed to float32 afterwards,
# one rounding each. -0.0 is a zero, NaN is not (it compares unequal).
dense, _ = as_f64_c(A, ndim=2, name="X")
n = int(shape[0])
# DEVIATION 2489: the scan in Mojo, two calls, count then fill into
# buffers this side allocates. The Python loop this replaced
# (DEVIATION 2373) lives on only as the oracle in
# tests/test_native_nonzero.py, which holds the two to byte equality.
nnz = int(_native("nonzero_f64_count")(addr_ro(dense, name="X"), dense.size)) if dense.size else 0 # cpu-route: CPU-only host binding COO route (dense device entry absent)
rows = empty((nnz,), "<i4")
cols = empty((nnz,), "<i4")
vals = empty((nnz,), "<f4")
if nnz == 0:
# Nothing to write, and an empty Array has no address to hand the
# binding (its pointer helpers refuse a null by design).
return rows, cols, vals, n
wrote = int(_native("nonzero_f64_fill")( # cpu-route: CPU-only host binding COO route (dense device entry absent)
addr_ro(dense, name="X"), n, n,
[addr(rows, name="rows"), addr(cols, name="cols"),
addr(vals, name="vals")],
nnz,
))
if wrote != nnz:
raise RuntimeError(
f"mojolearn {who}: nonzero_f64_fill wrote "
f"{wrote} of {nnz} entries"
)
return rows, cols, vals, n
def _dense_entry(name):
"""The metrics binding's dense-affinity entry `name` (lane
cpu2-l9-neighbors, 2026-10-04: `spectral_fit_predict_dense`,
`spectral_embedding_dense`), or None on a binding without it, the
CPU-only host binding, whose route is the COO scan (`_coo_triples`,
`_DenseCOO`). On the GPU route the dense matrix is uploaded once and its
COO compacted, checked and turned into the Laplacian on the device
(spectral/impl/sparse/linalg/detail/dense_graph.mojo): the same COO, so
the same bits, with no n^2 host scan."""
try:
return getattr(_get_binding(), name)
except (AttributeError, ImportError):
return None
def _dense_square(X, who):
"""A dense precomputed affinity as a C-order float32 square Array (the
dense entries' input), with `_coo_triples`' shape refusal. The nonzero
test is made on the float32 values on the device route: a float64
magnitude that narrows to 0.0f is a zero there."""
shape = _shape_of(X)
if len(shape) != 2 or shape[0] != shape[1]:
raise ValueError(
f"mojolearn {who}: with affinity='precomputed', X "
"must be a square affinity matrix or a sparse matrix with "
f"`tocoo()`, got shape {shape}"
)
return as_f32_c(X, ndim=2, name="X")[0]
#: The dense entries' refusal codes (bindings/_mojolearn_metrics.mojo
#: `spectral_fit_predict_dense_binding`), in the COO route's check order.
_DENSE_EMPTY, _DENSE_NONFINITE, _DENSE_NEGATIVE = -1, -2, -3
# Maintainer notes (moved from the docstring). Reference: cuML 26.08's
# SpectralClustering; pipeline as in cuVS. The identity profile is
# `spectral/IDENTICAL_SPECTRAL_CONTRACT.md` (mojolearn.identical.spectral.fp32.v1).
#
# Sign rule (DEVIATION 770): after the projected eigenproblem is solved and its
# eigenvalues sorted, every column of the ncv x ncv PROJECTED eigenvector matrix
# is negated iff its first nonzero component (x != 0.0, so a leading +-0.0 is
# skipped) in ascending row index is negative; an all-zero column is left
# alone. Applied there and nowhere else: there is no sign pin on the returned
# embedding. Its signs follow from the pinned start vector (DEVIATION 772, a
# splitmix64 hashed uniform that is an exact function of (seed, n)). Adding a
# second rule would be a different profile. RAFT pins neither convention.
#
# Ordering and ties (DEVIATION 778): the projected solver returns eigenvalues
# ascending (cuSOLVER syevd's convention, which RAFT's slicing assumes). This
# path uses which=LA on the NEGATED Laplacian and takes the LAST k, so index
# k-1 is the Laplacian's smallest eigenvalue; the reversed gather puts the
# second-smallest in embedding column 0. Ties break by original index: the
# ascending sort is an insertion sort with the strict comparison
# d[order[j]] > d[key], so equal eigenvalues keep their Jacobi-diagonal order,
# a pure function of the input bits and the pinned sweep schedule. +0.0 and
# -0.0 compare equal, deliberately. LM and SM are refused rather than
# implemented: theirs sorts by magnitude with thrust::sort, which is not
# stable, so implementing it would mean inventing a tie-break.
#
# Degeneracy: same input bits and profile give the same eigenvector bits,
# including inside a degenerate subspace (the Jacobi sweep is a fixed
# sequence). The basis inside a degenerate subspace is NOT stable under
# perturbation: a one-ulp change can rotate it arbitrarily.
#
# Open coverage gaps: the device SIGN_FLIP sabotage bites only on the `hashed`
# fixture (inert on path, ring, hashed_unnorm, blobs; archive/plans/CARD_GAPS.md:78,
# contract section 9); two host instruments also cover the rule.
# spectral.ritz.vectors is recorded AFTER the sign pin, so a whole-column sign
# disagreement between vendors would be re-canonicalized before hashing
# (CARD_GAPS.md, "Three more cards that hash on the far side of a washer").
# Seam J4: the fused ROTATE (DEVIATION 781) lives in a solver shared by the
# device arm and the oracle; the hash-against-a-literal check is owed
# (spectral/NOT_IMPLEMENTED.tsv, "this lane's own gaps").
#
# DEVIATION 780, so nobody re-inflates it: of the five constants once claimed
# as ours (ncv = min(n - k, max(2k+1, 20)), max_iterations = 10 * n_samples,
# the plumbed tolerance, the Jacobi sweep cap, the ncv admissibility bound),
# three were struck on 2026-08-23: they are identical to cuVS 26.08, down to
# the RAFT_EXPECTS message (cuVS 25.08 spells them as literals). Ours: the host
# Jacobi's 60-sweep cap and the ncv admissibility guard.
#
# eigen_tol='auto' refused (DEVIATION 890): cuML 26.08's Python maps 'auto' to
# 0.0 (spectral_clustering.pyx:346-347), passed through as the tolerance; that
# disables the convergence test (res > tol), runs to 10 * n_samples iterations
# and drives an exactly converged problem into DEVIATION 774's
# restart-breakdown refusal. Default 1e-5 is cuVS's struct default
# (spectral_embedding.hpp:59).
# random_state=None means seed 0 (DEVIATION 891): cuVS's struct default is
# rng_state{0}, and the no-seed Lanczos start vector is refused (DEVIATION 772;
# std::random_device is not reproducible).
# n_init=10 is what cluster/detail/spectral.cuh passes (KMeans defaults to 1).
# Normalization: cluster/detail/spectral.cuh:35-36 hard-codes
# norm_laplacian = true and drop_first = false on the clustering path.
# assign_labels 'discretize'/'cluster_qr': x_cluster/spectral_assign.mojo, host
# float64, a Jacobi SVD for LAPACK's (DEVIATION 5119); discretize draws its
# start row from the splitmix64 stream seeded by random_state.
# predict: DEVIATION 2860 (see the notes above `predict`).
[docs]
class SpectralClustering:
"""Spectral clustering on a nearest-neighbor or precomputed affinity graph.
Builds an affinity graph, forms the normalized graph Laplacian, takes its
``n_components`` smallest eigenvectors with a thick-restart Lanczos solver
and clusters the resulting embedding. Defaults follow cuML's
``SpectralClustering``, which differ from scikit-learn's: the default
affinity is ``"nearest_neighbors"`` with ``n_neighbors=10``, and
``n_components`` defaults to ``n_clusters``. The Laplacian is always
normalized and the first (nearly constant) eigenvector is kept, so the
clustering runs on all ``n_components`` columns; ``embedding_`` is
therefore not what :class:`SpectralEmbedding` returns.
Eigenvectors are only defined up to sign, and a basis inside a repeated
eigenvalue is arbitrary. Results are reproducible for the same input and
seed, but a graph with ``c`` connected components has eigenvalue 0 with
multiplicity ``c``, and an embedding with ``n_components <= c`` can change
a lot under tiny input changes.
Parameters
----------
n_clusters : int, default 8
Number of clusters, in ``[1, n_samples]``.
n_components : int or None, default None
Number of eigenvectors; None uses ``n_clusters``. Must satisfy
``1 <= n_components < n_samples``.
random_state : int or None, default None
Seed in ``[0, 2**32)``. None means seed 0, so results are
deterministic (unlike scikit-learn and cuML, where None draws a fresh
seed).
n_neighbors : int, default 10
Neighbors per row in the nearest-neighbor graph; ignored with
``affinity="precomputed"``.
n_init : int, default 10
Number of k-means restarts on the embedding (cuVS's spectral default;
:class:`KMeans` itself defaults to 1).
eigen_tol : float, default 1e-5
Convergence tolerance of the eigensolver; must be a positive float.
The string ``"auto"`` raises ``NotImplementedError``: cuML maps it to
0.0, which disables the convergence test.
affinity : str, default "nearest_neighbors"
``"nearest_neighbors"`` (symmetrized k-NN connectivity of ``X``),
``"precomputed"`` (``X`` is a square affinity matrix, dense or sparse),
``"rbf"`` (``exp(-gamma * ||x_i - x_j||**2)``, scikit-learn's kernel)
or ``"precomputed_nearest_neighbors"`` (k-NN connectivity of a
precomputed distance matrix, symmetrized as ``0.5 * (C + C.T)``). A
callable or any other name raises ``ValueError``.
assign_labels : {"kmeans", "discretize", "cluster_qr"}, default "kmeans"
How labels are assigned from the embedding: k-means, or
scikit-learn's ``"discretize"`` and ``"cluster_qr"``.
eigen_solver : None
Only None is accepted; any other value raises ``NotImplementedError``.
gamma : float or None, default None
RBF kernel coefficient, used only with ``affinity="rbf"``; None means
1.0 (scikit-learn's default). Setting it with another affinity raises
``NotImplementedError``.
degree, coef0, kernel_params : None
Not supported; any value raises ``NotImplementedError``.
n_jobs : None
Not supported; any value raises ``NotImplementedError``.
verbose : bool, default False
Not supported; True raises ``NotImplementedError``.
prediction_data : bool, default False
Keep the eigenpairs, degree scaling and k-means centroids (and, for
``"nearest_neighbors"``, the training rows) so that ``predict`` and
``save`` work. It does not change the fitted labels or embedding.
Supported with ``assign_labels="kmeans"`` and the
``"nearest_neighbors"`` or ``"precomputed"`` affinities only;
otherwise ``NotImplementedError``.
Attributes
----------
labels_ : Array of shape (n_samples,), int32
Cluster label of each training row, in ``[0, n_clusters)``.
embedding_ : Array of shape (n_samples, n_components), float32
The spectral embedding that was clustered.
n_components_ : int
Number of eigenvectors used.
affinity_matrix_ : array
The affinity matrix used by the fit: ``X`` itself for
``"precomputed"``, the dense matrix for ``"rbf"`` and
``"precomputed_nearest_neighbors"``. Not set for
``"nearest_neighbors"``.
n_features_in_ : int
Number of features of ``X``; set for ``"nearest_neighbors"`` and
``"rbf"``.
n_iter_assign_ : int
Iterations of the ``"discretize"`` label assignment; set only for it.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> model = mojolearn.SpectralClustering(n_clusters=4, random_state=0).fit(X)
>>> labels = model.labels_
"""
[docs]
def __init__(
self,
n_clusters=8,
*,
n_components=None,
random_state=None,
n_neighbors=10,
n_init=10,
eigen_tol=DEFAULT_EIGEN_TOL,
affinity="nearest_neighbors",
assign_labels="kmeans",
eigen_solver=None,
gamma=None,
degree=None,
coef0=None,
kernel_params=None,
n_jobs=None,
verbose=False,
prediction_data=False,
):
if affinity not in _CLUSTER_AFFINITIES:
raise ValueError(
f"mojolearn SpectralClustering: affinity={affinity!r} is "
f"refused; it must be one of {list(_CLUSTER_AFFINITIES)}. A "
"callable or another kernel name builds the affinity outside "
"every identity column."
)
if assign_labels not in _ASSIGN_LABELS:
raise ValueError(
f"mojolearn SpectralClustering: assign_labels="
f"{assign_labels!r} must be one of {list(_ASSIGN_LABELS)}"
)
if prediction_data and assign_labels != "kmeans":
raise NotImplementedError(
f"mojolearn SpectralClustering: prediction_data with "
f"assign_labels={assign_labels!r} is refused; the Nystrom "
"predict (DEVIATION 2860) assigns by the k-means centroids")
if eigen_solver is not None:
raise NotImplementedError(
f"mojolearn SpectralClustering: eigen_solver={eigen_solver!r} "
"is refused; there is one solver here, RAFT's thick-restart "
"Lanczos, and it is the one the identity profile pins"
)
if gamma is not None and affinity != "rbf":
raise NotImplementedError(
"mojolearn SpectralClustering: gamma is refused; it "
"parameterizes the RBF affinity, and affinity is not 'rbf'")
if prediction_data and affinity in ("rbf", "precomputed_nearest_neighbors"):
raise NotImplementedError(
f"mojolearn SpectralClustering: prediction_data with affinity={affinity!r} "
"is refused; the Nystrom predict (DEVIATION 2860) carries the "
"'nearest_neighbors' and 'precomputed' affinities only")
for name, value in ( # glue: checks keyword argument values
("degree", degree),
("coef0", coef0),
("kernel_params", kernel_params),
):
if value is not None:
raise NotImplementedError(
f"mojolearn SpectralClustering: {name} is refused; it "
"parameterizes an RBF or polynomial affinity, and "
"affinity is restricted to 'nearest_neighbors' and "
"'precomputed'"
)
if n_jobs is not None:
raise NotImplementedError(
"mojolearn SpectralClustering: n_jobs is refused; the work "
"is on the GPU and there is no host thread pool to size"
)
if verbose:
raise NotImplementedError(
"mojolearn SpectralClustering: verbose is refused; nothing "
"in this path prints. Set MOJOLEARN_IDENTITY_TRACE=<path> "
"for the identity card instead"
)
if isinstance(eigen_tol, str):
raise NotImplementedError(
f"mojolearn SpectralClustering: eigen_tol={eigen_tol!r} is "
"refused (DEVIATION 890). cuML 26.08's Python maps 'auto' to "
"0.0, which is passed straight through as the eigensolver "
"tolerance and disables the convergence test (the loop "
"condition is res > tol), so the solve runs to "
"max_iterations = 10 * n_samples and an exactly converged "
"problem hits the restart-breakdown refusal. Pass a positive "
f"float; {DEFAULT_EIGEN_TOL} is cuVS's own struct default "
"and is this class's default."
)
eigen_tol = float(eigen_tol)
if not (eigen_tol > 0.0) or eigen_tol != eigen_tol:
raise ValueError(
"mojolearn SpectralClustering: eigen_tol must be positive, "
f"got {eigen_tol!r} (DEVIATION 890; see the 'auto' note)"
)
if random_state is None:
seed = DEFAULT_SEED
else:
seed = int(random_state)
if seed < 0 or seed >= 2**32:
raise ValueError(
"mojolearn SpectralClustering: random_state must satisfy "
f"0 <= random_state < 2**32, got {random_state!r} (cuML's "
"check_random_seed refuses the same range)"
)
if int(n_clusters) < 1:
raise ValueError(
"mojolearn SpectralClustering: n_clusters must be at least 1"
)
if int(n_init) < 1:
raise ValueError(
"mojolearn SpectralClustering: n_init must be at least 1"
)
if int(n_neighbors) < 1:
raise ValueError(
"mojolearn SpectralClustering: n_neighbors must be at least 1"
)
if n_components is not None and int(n_components) < 1:
raise ValueError(
"mojolearn SpectralClustering: n_components must be at least "
"1, or None to follow n_clusters"
)
self.n_clusters = int(n_clusters)
self.n_components = n_components
self.random_state = random_state
self.n_neighbors = int(n_neighbors)
self.n_init = int(n_init)
self.eigen_tol = eigen_tol
self.affinity = affinity
#: scikit-learn's SpectralClustering default gamma is 1.0 (its
#: SpectralEmbedding's is 1 / n_features).
self.gamma = (1.0 if gamma is None else float(gamma)) if affinity == "rbf" else None
self.assign_labels = assign_labels
self._seed = seed
self.prediction_data = prediction_data
#: The binding `predict` asks; a host subclass answers the metrics host
#: binding, which ships in the inference wheel.
_BINDING = "_mojolearn_metrics"
def _bind(self, name=None):
return _get_binding(getattr(self, "numeric_mode", None))
def _state_arrays(self, n, k):
return (empty((k,), "<f4"), empty((n, k), "<f4"), empty((n,), "<f4"),
empty((self.n_clusters, k), "<f4"))
def _n_components(self):
"""cuML's rule: `n_components` defaults to `n_clusters` when None
(`spectral_clustering.pyx`)."""
return self.n_clusters if self.n_components is None else int(
self.n_components
)
[docs]
def fit(self, X, y=None):
"""Cluster ``X``.
Parameters
----------
X : array-like of shape (n_samples, n_features) or (n_samples, n_samples)
Training data, or with ``affinity="precomputed"`` a square
nonnegative affinity matrix (dense or sparse); with
``"precomputed_nearest_neighbors"`` a square distance matrix.
Values must be finite.
y : ignored
Returns
-------
self : SpectralClustering
The fitted estimator.
Raises
------
ValueError
If ``X`` holds NaN or infinity, a precomputed affinity has no
nonzero entries, ``n_clusters`` exceeds ``n_samples`` or
``n_components`` is not below ``n_samples``.
TypeError
If ``prediction_data`` is not a bool.
"""
if not isinstance(self.prediction_data, bool):
raise TypeError(
"mojolearn SpectralClustering: prediction_data must be a bool, "
f"got {type(self.prediction_data).__name__}"
)
for name in _PREDICTION_ATTRS: # glue: drops cached attribute names
self.__dict__.pop(name, None)
k = self._n_components()
labels_out = None
state = None
self.__dict__.pop("affinity_matrix_", None)
dense_fn = None
if self.affinity in ("precomputed", "rbf", "precomputed_nearest_neighbors"):
dense_fn = _dense_entry("spectral_fit_predict_dense")
if self.affinity == "precomputed":
self.affinity_matrix_ = X
if dense_fn is not None and not callable(getattr(X, "tocoo", None)):
X = _dense_square(X, "SpectralClustering")
elif self.affinity == "rbf":
# scikit-learn's affinity='rbf' (cluster/_spectral.py:
# pairwise_kernels(X, metric='rbf', gamma=self.gamma)), which
# cuML does not carry: the dense exp(-gamma ||x_i - x_j||^2) by
# the decomp lane's identical cells, SpectralEmbedding's rbf
# route exactly (x_decomp/cells.mojo `sqdist_cell`, `exp_c`),
# then the precomputed route below unchanged.
xm = _M.from_input(X)
mode = _backend.default_mode()
kit = _Kit(mode, _backend.binding("_mojolearn_x_decomp", mode))
aff = kit.ew("exp", kit.ew("scale", kit.sqdist(xm, xm), s=-self.gamma))
self.affinity_matrix_ = aff.out()
self.n_features_in_ = xm.c
X = aff if dense_fn is not None else _DenseCOO(aff)
elif self.affinity == "precomputed_nearest_neighbors":
# scikit-learn's kneighbors_graph of a precomputed distance
# matrix, symmetrized 0.5 (C + C^T): SpectralEmbedding's helper
# (comparisons and the exact values 0, 0.5, 1 only).
aff = SpectralEmbedding._precomputed_knn_affinity(self, X)
self.affinity_matrix_ = aff.out()
X = aff if dense_fn is not None else _DenseCOO(aff)
if dense_fn is not None and (isinstance(X, _M) or isinstance(X, Array)):
# the dense matrix (rbf / kNN affinity host store, or the
# precomputed float32 input) straight to the device entry
if isinstance(X, _M):
n, dense_addr = X.r, X.addr
else:
n, dense_addr = int(X.shape[0]), addr_ro(X, name="X")
self._check_shape(n, k)
labels = empty((n,), "<i4")
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_fit_predict_dense_binding.
params = [n, self.n_clusters, k, self.n_init, self.n_neighbors,
self.eigen_tol, self._seed]
addrs = [dense_addr, addr(labels, name="labels"), addr(embedding, name="embedding")]
if self.prediction_data:
state = self._state_arrays(n, k)
addrs += [addr(a, name="prediction data") for a in state] # glue: addresses of prediction-state buffers
n_out = int(dense_fn(addrs, params))
if n_out == _DENSE_EMPTY:
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has no nonzero entries"
)
if n_out == _DENSE_NONFINITE:
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has a non-finite entry (the implemented path refuses "
"it by name, because sqrt of a non-finite degree is a "
"NaN and no NaN may reach a recorded value)"
)
if n_out == _DENSE_NEGATIVE:
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has a negative entry (refused by name: sqrt of a "
"negative degree is a NaN in cuVS too)"
)
labels_out = labels
elif self.affinity in ("precomputed", "rbf", "precomputed_nearest_neighbors"):
# the COO route: a sparse precomputed input, or a binding without
# the dense entry (the CPU-only host binding)
rows, cols, vals, n = _coo_triples(X)
if vals.size == 0:
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has no nonzero entries"
)
if not all_finite(vals):
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has a non-finite entry (the implemented path refuses "
"it by name, because sqrt of a non-finite degree is a "
"NaN and no NaN may reach a recorded value)"
)
if vals.min() < 0:
raise ValueError(
"mojolearn SpectralClustering: the precomputed affinity "
"matrix has a negative entry (refused by name: sqrt of a "
"negative degree is a NaN in cuVS too)"
)
self._check_shape(n, k)
labels = empty((n,), "<i4")
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_fit_predict_graph_binding.
# n_samples, nnz, n_clusters, n_components, n_init, n_neighbors,
# eigen_tol, seed
params = [
n,
int(vals.shape[0]),
self.n_clusters,
k,
self.n_init,
self.n_neighbors,
self.eigen_tol,
self._seed,
]
if self.prediction_data:
state = self._state_arrays(n, k)
# spectral_fit_predict_graph_state_binding: the same call,
# plus copies of the prediction data.
n_out = int(_get_binding().spectral_fit_predict_graph_state(
[addr_ro(rows, name="rows"), addr_ro(cols, name="cols"),
addr_ro(vals, name="vals"), addr(labels, name="labels"),
addr(embedding, name="embedding")]
+ [addr(a, name="prediction data") for a in state], # glue: addresses of prediction-state buffers
params,
))
else:
n_out = int(
_get_binding().spectral_fit_predict_graph(
addr_ro(rows, name="rows"),
addr_ro(cols, name="cols"),
addr_ro(vals, name="vals"),
addr(labels, name="labels"),
addr(embedding, name="embedding"),
params,
)
)
labels_out = labels
else:
x, self.input_copied_ = as_f32_c(X, ndim=2, name="X")
if not all_finite(x):
raise ValueError(
"mojolearn SpectralClustering: X contains NaN or "
"infinity (the implemented path refuses it by name before any "
"recorded stage)"
)
n = int(x.shape[0])
self._check_shape(n, k)
if self.n_neighbors > n:
raise ValueError(
f"mojolearn SpectralClustering: n_neighbors="
f"{self.n_neighbors} exceeds n_samples={n}"
)
labels = empty((n,), "<i4")
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_fit_predict_dataset_binding.
# n_samples, n_features, n_clusters, n_components, n_init,
# n_neighbors, eigen_tol, seed
params = [
n,
int(x.shape[1]),
self.n_clusters,
k,
self.n_init,
self.n_neighbors,
self.eigen_tol,
self._seed,
]
if self.prediction_data:
state = self._state_arrays(n, k)
n_out = int(_get_binding().spectral_fit_predict_dataset_state(
[addr_ro(x, name="x"), addr(labels, name="labels"),
addr(embedding, name="embedding")]
+ [addr(a, name="prediction data") for a in state], # glue: addresses of prediction-state buffers
params,
))
# A borrowed input is copied so a caller's later write cannot
# move what predict reads.
self._fit_X = x if self.input_copied_ else x.copy()
else:
n_out = int(
_get_binding().spectral_fit_predict_dataset(
addr_ro(x, name="x"),
addr(labels, name="labels"),
addr(embedding, name="embedding"),
params,
)
)
labels_out = labels
self.n_features_in_ = int(x.shape[1])
if n_out != k:
# Cannot fire today: the clustering path hard-codes
# `drop_first = false`, so `n_out == n_components`. The binding
# checks the same thing before it writes anything, and this is
# the reader-facing half of that guard.
raise RuntimeError(
f"mojolearn SpectralClustering: the kernel returned {n_out} "
f"embedding columns where {k} were expected"
)
self.embedding_ = embedding
self.n_components_ = k
self.__dict__.pop("n_iter_assign_", None)
if self.assign_labels != "kmeans":
labels_out = self._assign(embedding, n, k)
self.labels_ = labels_out
if state is not None:
(self._pd_eigenvalues, self._pd_eigenvectors, self._pd_diag,
self._pd_centroids) = state
return self
def _assign(self, embedding, n, k):
"""scikit-learn's discretize / cluster_qr on the fit's embedding, the
x_cluster binding's host code (ENTRY_SPECTRAL_ASSIGN)."""
from . import _expansion_cluster
b = _backend.binding(_expansion_cluster._XCluster._BINDING, getattr(self, "numeric_mode", None))
ip = [n, k, _ASSIGN_CODES[self.assign_labels], int(self._seed), 30, 20]
_f, i, sc = b.x_cluster_call(_E_SPECTRAL_ASSIGN, addr_ro(embedding, name="embedding"),
embedding.size, 0, 0, ip, [0.0])
if self.assign_labels == "discretize":
self.n_iter_assign_ = int(sc[0])
return Array._from_flat(i[0], (n,), "<i4")
def _resolved_neighbors(self, n):
"""`SpectralEmbedding._precomputed_knn_affinity` reads this."""
return self.n_neighbors
def _check_shape(self, n, k):
if self.n_clusters > n:
raise ValueError(
f"mojolearn SpectralClustering: n_clusters={self.n_clusters} "
f"exceeds n_samples={n}"
)
if k >= n:
raise ValueError(
f"mojolearn SpectralClustering: n_components={k} must be "
f"below n_samples={n}; the Lanczos requires "
"1 <= n_components < n_samples and n_samples - n_components "
"> 0 (cuVS's own RAFT_EXPECTS)"
)
[docs]
def fit_predict(self, X, y=None):
"""Fit to ``X`` and return the training labels.
Parameters
----------
X : array-like
As in ``fit``.
y : ignored
Returns
-------
labels : Array of shape (n_samples,), int32
Same as ``labels_``.
"""
return self.fit(X, y=y).labels_
# predict, maintainer notes (DEVIATION 2860). Nystrom extension of Bengio,
# Paiement, Vincent, Delalleau, Le Roux and Ouimet (NIPS 2003), as Fowlkes
# et al. (TPAMI 2004) use it, then the fit's own k-means assignment. For a
# new row x and column c:
# e_c(x) = (1 / mu_c) * sum_i Ktilde(x, x_i) u_c[i] / sqrt(d(x))
# Ktilde(x, x_i) = K(x, x_i) / (sqrt(d(x)) * sqrt(d_i))
# u_c is the fit's unit eigenvector before its degree division,
# mu_c = 1 + theta_c the normalized affinity's eigenvalue (theta_c the Ritz
# value of the negated normalized Laplacian), d_i the fit's degree and
# d(x) = sum_i K(x, x_i); the last division by sqrt(d(x)) is the fit's own
# row scaling. A 'nearest_neighbors' query edge weighs 0.5: the fit's
# symmetrization 0.5 * (a + b) of an edge with no reverse edge. Every fold
# (degree, each projection) runs over training rows in ascending index,
# seeded +0.0, one query at a time, so labels do not depend on the batch
# and the GPU binding and the CPU host binding compute the same bytes.
# Nothing is dropped (the clustering fit keeps the trivial column); a
# column with |mu_c| < 1e-3 is refused by name. How often
# predict(X_train) == labels_ holds is measured in
# bench/results/identity_break/2026-09-15_spectral-predict/.
[docs]
def predict(self, X):
r"""Assign new rows to the fitted clusters.
Neither scikit-learn nor cuML provides ``predict`` for spectral
clustering. This one uses the Nystrom out-of-sample extension (Bengio
et al., NIPS 2003): each new row is projected onto the fitted
eigenvectors through its affinity to the training rows, then assigned
to the nearest fitted k-means centroid (ties go to the lowest
centroid index). A row's label does not depend on the other rows in
the call.
Requires a fit with ``prediction_data=True``.
The affinity of a new row:
- ``"nearest_neighbors"``: its ``n_neighbors`` nearest training rows
(Euclidean), each with weight 0.5. Training degrees are unchanged.
- ``"precomputed"``: ``X`` is the affinity between the new rows and
the training rows, shape ``(n_new, n_train)``, finite and
nonnegative. A sparse matrix is densified.
Training rows passed to ``predict`` are not guaranteed to get their
``labels_``: as a query, a row sees itself with weight 0.5 rather
than 1.0 and has no reverse edges.
Parameters
----------
X : array-like of shape (n_new, n_features) or (n_new, n_train)
New rows, or their affinity to the training rows.
Returns
-------
labels : Array of shape (n_new,), int32
Cluster labels, numbered as in ``labels_``.
Raises
------
ValueError
If the model is not fitted, was fitted without
``prediction_data=True``, ``X`` has the wrong shape or no rows,
or a precomputed affinity is non-finite or negative.
Notes
-----
Every embedding column is kept. If an eigenvalue of the normalized
affinity has magnitude below 1e-3 (the projection would amplify that
column more than 1000x), the prediction is refused with an error
naming the column.
"""
return self._predict_embedding(X)[0]
def _predict_embedding(self, X):
"""`predict`'s labels and the extended embedding (n_new x
n_components float32)."""
if not hasattr(self, "labels_"):
raise ValueError(
"mojolearn SpectralClustering.predict: this instance is not "
"fitted yet; call fit first"
)
if getattr(self, "_pd_eigenvalues", None) is None:
raise ValueError(
"mojolearn SpectralClustering.predict: prediction data was not "
"stored. Fit with SpectralClustering(prediction_data=True), which "
"keeps the eigenpairs, degrees and centroids the Nystrom extension "
"needs (DEVIATION 2860)"
)
if self.affinity not in _AFFINITIES:
raise ValueError(
f"mojolearn SpectralClustering.predict: affinity={self.affinity!r} "
"has no out-of-sample rule; it must be one of "
f"{list(_AFFINITIES)}"
)
k = int(self.n_components_)
n_train = int(self._pd_diag.shape[0])
if self.affinity == "precomputed":
A = X.toarray() if callable(getattr(X, "toarray", None)) else X
shape = _shape_of(A)
if len(shape) != 2 or shape[1] != n_train:
raise ValueError(
"mojolearn SpectralClustering.predict: with affinity="
"'precomputed', X must be the affinity between the new rows "
f"and the {n_train} training rows, shape (n_new, {n_train}); "
f"got shape {shape}"
)
q, _ = as_f32_c(A, ndim=2, name="X")
if q.shape[0] < 1:
raise ValueError("mojolearn SpectralClustering.predict: X has no rows; refused by name")
if not all_finite(q):
raise ValueError(
"mojolearn SpectralClustering.predict: the affinity has a "
"non-finite entry; refused by name"
)
if q.min() < 0:
raise ValueError(
"mojolearn SpectralClustering.predict: the affinity has a "
"negative entry; refused by name, as the fit refuses one"
)
train_addr, n_features, affinity = 0, 0, 1
else:
q = _check_queries(X, self.n_features_in_, "SpectralClustering.predict")
train_addr = addr_ro(self._fit_X, name="training rows")
n_features, affinity = int(self.n_features_in_), 0
nq = int(q.shape[0])
labels = empty((nq,), "<i4")
emb = empty((nq, k), "<f4")
self._bind().spectral_predict(
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::spectral_predict_binding.
[addr_ro(q, name="X"), train_addr,
addr_ro(self._pd_eigenvalues, name="eigenvalues"),
addr_ro(self._pd_eigenvectors, name="eigenvectors"),
addr_ro(self._pd_diag, name="degree scaling"),
addr_ro(self._pd_centroids, name="centroids"),
addr(labels, name="labels"), addr(emb, name="embedding")],
# n_train, n_queries, n_features, n_components, n_clusters,
# n_neighbors, affinity
[n_train, nq, n_features, k, int(self.n_clusters), int(self.n_neighbors), affinity],
)
return labels, emb
# Saved layout: eigenvalues <f4 (k), eigenvectors <f4 (n_train, k), diag <f4
# (n_train), centroids <f4 (n_clusters, k), labels <i4, x <f4 (training rows,
# 'nearest_neighbors' only), affinity, numeric_mode, eigen_tol <f8, meta <i8
# [n_clusters, n_components_, n_neighbors, n_init, seed,
# random_state_is_none, n_train, n_features_in_ (0 for precomputed)]. On a
# CPU-only install predict runs through _mojolearn_metrics_host.spectral_predict.
[docs]
def save(self, path):
"""Save a model fitted with ``prediction_data=True`` to ``path`` (npz).
A model restored with ``SpectralClustering.load`` can ``predict``
new rows, including on a CPU-only install, but cannot refit.
Parameters
----------
path : str or path-like
Destination file.
Raises
------
RuntimeError
If the model is not fitted.
ValueError
If it was fitted without ``prediction_data=True``.
"""
if not hasattr(self, "labels_"):
raise RuntimeError("this estimator is not fitted yet")
if getattr(self, "_pd_eigenvalues", None) is None:
raise ValueError(
"mojolearn SpectralClustering.save: prediction data was not stored; "
"fit with SpectralClustering(prediction_data=True). A model without "
"it can label no new row, so there is nothing a saved file could serve"
)
nn = self.affinity == "nearest_neighbors"
n_train = int(self._pd_diag.shape[0])
arrays = {
"format": _SPECTRAL_FORMAT,
"estimator": type(self).__name__,
"numeric_mode": _saved_mode(self),
"affinity": str(self.affinity),
"eigenvalues": self._pd_eigenvalues,
"eigenvectors": self._pd_eigenvectors,
"diag": self._pd_diag,
"centroids": self._pd_centroids,
"labels": self.labels_,
"eigen_tol": Array.from_list([float(self.eigen_tol)], "<f8"),
"meta": Array.from_list(
[int(self.n_clusters), int(self.n_components_), int(self.n_neighbors),
int(self.n_init), int(self._seed), 1 if self.random_state is None else 0,
n_train, int(self.n_features_in_) if nn else 0],
"<i8",
),
}
if nn:
arrays["x"] = self._fit_X
return _serialize.write_npz(path, arrays)
[docs]
@classmethod
def load(cls, path):
"""Load a model written by ``save``.
The loaded model can ``predict``; it does not refit. Arrays are read
at their saved dtype and never cast.
Parameters
----------
path : str or path-like
File written by ``SpectralClustering.save``.
Returns
-------
model : SpectralClustering
The restored model, with ``labels_`` and ``n_components_`` set.
Raises
------
ValueError
If the file is not a SpectralClustering model or its arrays have
inconsistent shapes.
"""
arrays = _serialize.read_npz(path, _SPECTRAL_FORMAT)
_check_saved_by(arrays, path, cls)
meta = _serialize.exact(arrays, "meta", "<i8")
if meta.size != 8:
raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, 8 are needed")
n_clusters, k, n_neighbors, n_init, seed, seed_none, n_train, nf = (int(v) for v in meta.tolist()) # glue: unpacks the fixed meta vector
tol = _serialize.exact(arrays, "eigen_tol", "<f8")
if tol.size != 1:
raise ValueError(f"mojolearn: {path!r} eigen_tol must hold one value")
affinity = _serialize.scalar_str(arrays, "affinity")
obj = cls(
n_clusters=n_clusters, n_components=k, n_neighbors=n_neighbors, n_init=n_init,
random_state=None if seed_none else seed, eigen_tol=float(tol[0]),
affinity=affinity, prediction_data=True,
)
_restore_mode(obj, arrays)
ev = _serialize.exact(arrays, "eigenvalues", "<f4")
evec = _serialize.exact(arrays, "eigenvectors", "<f4")
dg = _serialize.exact(arrays, "diag", "<f4")
cent = _serialize.exact(arrays, "centroids", "<f4")
labels = _serialize.exact(arrays, "labels", "<i4")
if (ev.size != k or evec.ndim != 2 or tuple(evec.shape) != (n_train, k) or dg.size != n_train
or cent.ndim != 2 or tuple(cent.shape) != (n_clusters, k) or labels.size != n_train):
raise ValueError(
f"mojolearn: {path!r} prediction data shapes do not match n_train={n_train}, "
f"n_components={k}, n_clusters={n_clusters}"
)
obj._fit_X = None
if affinity == "nearest_neighbors":
x = _serialize.exact(arrays, "x", "<f4")
if x.ndim != 2 or tuple(x.shape) != (n_train, nf):
raise ValueError(f"mojolearn: {path!r} x shape {tuple(x.shape)} is not ({n_train}, {nf})")
obj._fit_X = x
obj.n_features_in_ = nf
obj._pd_eigenvalues = ev
obj._pd_eigenvectors = evec
obj._pd_diag = dg
obj._pd_centroids = cent
obj.labels_ = labels
obj.n_components_ = k
return obj
class _DenseCOO:
"""A dense float32 matrix (`_expansion_decomp._M`) as the duck-typed COO
`_coo_triples` reads: row-major order, exact zeros dropped (what
`sp.coo_matrix(dense)` keeps), -0.0 a zero and NaN kept. The scan is the
base binding's `nonzero_f32_count` / `nonzero_f32_fill`
(core/dense_coo.mojo, lane/py-dn-kern 2026-09-28; its CPU route is the
core host binding), which replaced a Python n^2 loop making the same
`v != 0.0` test on the same float32 values (DEVIATION 2489's float32
twin). Pure data movement."""
def __init__(self, m):
n_r, n_c = m.r, m.c
src = m.addr
nnz = int(_native("nonzero_f32_count")(src, n_r * n_c)) if n_r * n_c else 0 # cpu-route: CPU-only host binding COO route (dense device entry absent)
self.shape = (n_r, n_c)
self.row = empty((nnz,), "<i4")
self.col = empty((nnz,), "<i4")
self.data = empty((nnz,), "<f4")
if nnz:
wrote = int(_native("nonzero_f32_fill")( # cpu-route: CPU-only host binding COO route (dense device entry absent)
src, n_r, n_c,
[addr(self.row, name="rows"), addr(self.col, name="cols"), addr(self.data, name="vals")],
nnz))
if wrote != nnz:
raise RuntimeError(f"mojolearn: nonzero_f32_fill wrote {wrote} of {nnz} entries")
def tocoo(self):
return self
# Maintainer notes (moved from the docstring). Reference: cuML's
# manifold.SpectralEmbedding; the same stages SpectralClustering runs before
# its k-means. The dense RBF affinity is the decomp cells (lane/algos-decomp,
# 2026-09-27), as are 'precomputed_nearest_neighbors' and eigen_tol.
# random_state=None means seed 0 (DEVIATION 891). eigen_tol <= 0 disables the
# Lanczos convergence test (DEVIATION 890). eigen_solver names other than None
# are refused: 'arpack', 'lobpcg' and 'amg' are other algorithms, and running
# the Lanczos under their names would be a silent substitution
# (spectral/NOT_IMPLEMENTED.tsv).
[docs]
class SpectralEmbedding:
"""Spectral embedding (Laplacian eigenmaps) for nonlinear dimensionality reduction.
Builds an affinity graph, forms the normalized graph Laplacian and returns
its smallest nontrivial eigenvectors, computed with a thick-restart
Lanczos solver. The first (trivial) eigenvector is dropped. Follows cuML's
``manifold.SpectralEmbedding``. There is no ``transform``: the embedding
covers the fitted rows only, as in scikit-learn.
Parameters
----------
n_components : int, default 2
Dimension of the embedding; at least 1, and the eigenpairs needed
must number fewer than ``n_samples``.
affinity : str, default "nearest_neighbors"
``"nearest_neighbors"`` (symmetrized k-NN connectivity of ``X``),
``"precomputed"`` (``X`` is a square affinity matrix, dense or
sparse), ``"rbf"`` (``exp(-gamma * ||x_i - x_j||**2)``) or
``"precomputed_nearest_neighbors"`` (``X`` is a distance matrix or
sparse distance graph; the ``n_neighbors`` nearest per row,
symmetrized as ``0.5 * (C + C.T)``). A callable or any other name
raises ``ValueError``.
random_state : int or None, default None
Seed in ``[0, 2**32)``. None means seed 0, so results are
deterministic (unlike scikit-learn, where None draws a fresh seed).
n_neighbors : int or None, default None
Neighbors per row; None uses ``max(n_samples // 10, 1)``.
gamma : float or None, default None
Positive RBF coefficient, used only with ``affinity="rbf"``; None
means ``1 / n_features`` (scikit-learn's rule). Setting it with
another affinity raises ``NotImplementedError``.
eigen_solver : None
Only None is accepted; any other value raises ``NotImplementedError``.
eigen_tol : float, "auto" or None, default None
Eigensolver tolerance. ``"auto"`` and None use 1e-5; otherwise a
positive finite float.
n_jobs : int or None, default None
Accepted for compatibility; it does not change the result.
verbose : bool, default False
Accepted for compatibility; nothing is printed.
Attributes
----------
embedding_ : Array of shape (n_samples, n_components), float32
The embedding of the training rows.
affinity_matrix_ : array
The dense affinity matrix built for ``"rbf"`` and
``"precomputed_nearest_neighbors"``.
n_neighbors_ : int
Number of neighbors used, for the nearest-neighbor affinities.
gamma_ : float
The RBF coefficient used, for ``affinity="rbf"``.
n_features_in_ : int
Number of features of ``X``, for ``"nearest_neighbors"`` and ``"rbf"``.
Notes
-----
A precomputed affinity must be nonnegative and finite and must not repeat
a ``(row, col)`` entry; its diagonal is ignored.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> Y = mojolearn.SpectralEmbedding(n_components=2).fit_transform(X)
"""
_drop_first = True
_norm_laplacian = True
[docs]
def __init__(
self,
n_components=2,
*,
affinity="nearest_neighbors",
random_state=None,
n_neighbors=None,
gamma=None,
eigen_solver=None,
eigen_tol=None,
n_jobs=None,
verbose=False,
):
if affinity not in _EMBED_AFFINITIES:
raise ValueError(
f"mojolearn SpectralEmbedding: affinity={affinity!r} is "
f"refused; it must be one of {list(_EMBED_AFFINITIES)}. "
"A callable affinity is Python code that no identity column "
"can run."
)
if gamma is not None and affinity != "rbf":
raise NotImplementedError(
"mojolearn SpectralEmbedding: gamma is refused; it "
"parameterizes an RBF affinity, and affinity is not 'rbf'"
)
if gamma is not None and not float(gamma) > 0:
raise ValueError("mojolearn SpectralEmbedding: gamma must be positive")
if eigen_solver is not None:
raise NotImplementedError(
f"mojolearn SpectralEmbedding: eigen_solver={eigen_solver!r} "
"is refused; there is one solver here, RAFT's thick-restart "
"Lanczos. scikit-learn's 'arpack', 'lobpcg' and 'amg' are "
"other algorithms, and running the Lanczos under their names "
"would be a silent substitution (spectral/NOT_IMPLEMENTED.tsv)"
)
if isinstance(eigen_tol, str):
if eigen_tol != "auto":
raise ValueError(
f"mojolearn SpectralEmbedding: eigen_tol={eigen_tol!r} "
"must be 'auto' or a positive float"
)
elif eigen_tol is not None:
tol = float(eigen_tol)
if not tol > 0.0 or tol != tol or tol == float("inf"):
raise ValueError(
"mojolearn SpectralEmbedding: eigen_tol must be a positive "
f"finite float or 'auto', got {eigen_tol!r} (0 disables the "
"Lanczos convergence test, DEVIATION 890)"
)
# n_jobs sizes scikit-learn's neighbor search; the kNN graph here is
# exact, so the value changes no result. verbose prints nothing.
self.eigen_solver, self.eigen_tol, self.n_jobs, self.verbose = eigen_solver, eigen_tol, n_jobs, verbose
if int(n_components) < 1:
raise ValueError(
"mojolearn SpectralEmbedding: n_components must be at least 1"
)
if n_neighbors is not None and int(n_neighbors) < 1:
raise ValueError(
"mojolearn SpectralEmbedding: n_neighbors must be at least 1, "
"or None for max(n_samples // 10, 1)"
)
if random_state is None:
seed = DEFAULT_SEED
else:
seed = int(random_state)
if seed < 0 or seed >= 2**32:
raise ValueError(
"mojolearn SpectralEmbedding: random_state must satisfy "
f"0 <= random_state < 2**32, got {random_state!r}"
)
self.n_components = int(n_components)
self.affinity = affinity
self.random_state = random_state
self.n_neighbors = None if n_neighbors is None else int(n_neighbors)
self._seed = seed
self.gamma = None if gamma is None else float(gamma)
def _resolved_neighbors(self, n):
return self.n_neighbors if self.n_neighbors is not None else max(n // 10, 1)
[docs]
def fit(self, X, y=None):
"""Compute the embedding of ``X``.
Parameters
----------
X : array-like of shape (n_samples, n_features) or (n_samples, n_samples)
Training data, or a square affinity (``"precomputed"``) or
distance (``"precomputed_nearest_neighbors"``) matrix, dense or
sparse. Values must be finite.
y : ignored
Returns
-------
self : SpectralEmbedding
The fitted estimator.
Raises
------
ValueError
If ``X`` is not finite, a precomputed matrix is not square or has
a negative or repeated entry, or ``n_components`` needs as many
eigenpairs as there are samples.
"""
k = self.n_components
drop_first = bool(self._drop_first)
norm_laplacian = bool(self._norm_laplacian)
# The one piece of arithmetic the reference keeps in Python
# (spectral_embedding.pyx:294): the transform receives this number.
n_lanczos = k + 1 if drop_first else k
dense_fn = None
if self.affinity in ("precomputed", "rbf", "precomputed_nearest_neighbors"):
dense_fn = _dense_entry("spectral_embedding_dense")
if self.affinity == "precomputed_nearest_neighbors":
X = self._precomputed_knn_affinity(X)
if dense_fn is None:
X = _DenseCOO(X)
if self.affinity == "precomputed" and dense_fn is not None and not callable(getattr(X, "tocoo", None)):
X = _dense_square(X, "SpectralEmbedding")
if self.affinity == "rbf":
# scikit-learn's `affinity='rbf'` (manifold/_spectral_embedding.py
# `_get_affinity_matrix`: rbf_kernel(X, gamma), gamma defaulting
# to 1/n_features), which cuML does not carry (lane/algos-decomp,
# 2026-09-27). The dense affinity exp(-gamma ||x_i - x_j||^2) is
# the decomp lane's identical cells (x_decomp/cells.mojo
# `sqdist_cell`, features ascending with one fused multiply-add
# per term, then `exp_c`, the portable expf), and then takes the
# precomputed route below unchanged: the dense matrix through COO,
# exact zeros dropped, the diagonal dropped.
xm = _M.from_input(X)
gamma = self.gamma if self.gamma is not None else 1.0 / xm.c
self.gamma_ = gamma
mode = _backend.default_mode()
kit = _Kit(mode, _backend.binding("_mojolearn_x_decomp", mode))
aff = kit.ew("exp", kit.ew("scale", kit.sqdist(xm, xm), s=-gamma))
self.affinity_matrix_ = aff.out()
self.n_features_in_ = xm.c
X = aff if dense_fn is not None else _DenseCOO(aff)
if dense_fn is not None and (isinstance(X, _M) or isinstance(X, Array)):
# the dense matrix straight to the device entry (see
# `_dense_entry`); the value refusals come from the same scan
if isinstance(X, _M):
n, dense_addr = X.r, X.addr
else:
n, dense_addr = int(X.shape[0]), addr_ro(X, name="X")
self._check_shape(n, n_lanczos)
self.n_neighbors_ = self._resolved_neighbors(n)
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_embedding_dense_binding.
params = [n, n_lanczos, k, int(norm_laplacian), int(drop_first),
self._seed] + self._tol_param()
n_out = int(dense_fn(dense_addr, addr(embedding, name="embedding"), params))
if n_out == _DENSE_EMPTY:
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has no nonzero entries"
)
if n_out == _DENSE_NONFINITE:
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has a non-finite entry"
)
if n_out == _DENSE_NEGATIVE:
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has a negative entry (refused by name: sqrt of a "
"negative degree is a NaN)"
)
elif self.affinity in ("precomputed", "rbf", "precomputed_nearest_neighbors"):
# the COO route: a sparse precomputed input, or a binding without
# the dense entry (the CPU-only host binding)
rows, cols, vals, n = _coo_triples(X, "SpectralEmbedding")
if vals.size == 0:
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has no nonzero entries"
)
# the rbf affinity is exp of a finite value, finite and >= 0 by
# construction (`exp_c` clamps), so its two scans are skipped
if self.affinity in ("rbf", "precomputed_nearest_neighbors"):
pass
elif not all_finite(vals):
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has a non-finite entry"
)
if self.affinity == "precomputed" and vals.min() < 0:
raise ValueError(
"mojolearn SpectralEmbedding: the precomputed affinity "
"matrix has a negative entry (refused by name: sqrt of a "
"negative degree is a NaN)"
)
self._check_shape(n, n_lanczos)
self.n_neighbors_ = self._resolved_neighbors(n)
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_embedding_graph_binding.
params = [n, int(vals.shape[0]), n_lanczos, k,
int(norm_laplacian), int(drop_first), self._seed] + self._tol_param()
n_out = int(_get_binding().spectral_embedding_graph(
addr_ro(rows, name="rows"), addr_ro(cols, name="cols"),
addr_ro(vals, name="vals"), addr(embedding, name="embedding"),
params,
))
else:
x, self.input_copied_ = as_f32_c(X, ndim=2, name="X")
if not all_finite(x):
raise ValueError(
"mojolearn SpectralEmbedding: X contains NaN or infinity"
)
n = int(x.shape[0])
self._check_shape(n, n_lanczos)
n_neighbors = self._resolved_neighbors(n)
if n_neighbors > n:
raise ValueError(
f"mojolearn SpectralEmbedding: n_neighbors={n_neighbors} "
f"exceeds n_samples={n}"
)
self.n_neighbors_ = n_neighbors
embedding = empty((n, k), "<f4")
# ORDER MATCHES bindings/_mojolearn_metrics.mojo::
# spectral_embedding_dataset_binding.
params = [n, int(x.shape[1]), n_lanczos, k, n_neighbors,
int(norm_laplacian), int(drop_first), self._seed] + self._tol_param()
n_out = int(_get_binding().spectral_embedding_dataset(
addr_ro(x, name="x"), addr(embedding, name="embedding"), params,
))
self.n_features_in_ = int(x.shape[1])
if n_out != k:
raise RuntimeError(
f"mojolearn SpectralEmbedding: the kernel returned {n_out} "
f"embedding columns where {k} were expected"
)
self.embedding_ = embedding
return self
def _tol_param(self):
"""The optional trailing `eigen_tol` of the two embedding bindings:
absent for None / 'auto' (the legacy params list, cuVS's 1e-5), else
the float, which the binding rounds to float32 once."""
if self.eigen_tol is None or isinstance(self.eigen_tol, str):
return []
return [float(self.eigen_tol)]
def _precomputed_knn_affinity(self, X):
"""scikit-learn's affinity='precomputed_nearest_neighbors':
NearestNeighbors(metric='precomputed').kneighbors_graph(X,
mode='connectivity'), symmetrized as 0.5 * (C + C^T). X is a distance
matrix: sparse (only the STORED entries are candidates) or dense
(every entry is). Each row keeps its n_neighbors smallest distances,
ties to the lower column (scikit-learn's stable sort of a sorted
CSR row); the query IS the fitted data, so a stored diagonal counts,
as in scikit-learn. Comparisons and the exact values 0, 0.5 and 1
only: no arithmetic."""
tocoo = getattr(X, "tocoo", None)
if callable(tocoo):
coo = tocoo()
n = int(coo.shape[0])
if tuple(coo.shape) != (n, n):
raise ValueError("mojolearn SpectralEmbedding: the precomputed distance graph must be square")
rows, _ = as_i32_c(coo.row, ndim=1, name="rows")
cols, _ = as_i32_c(coo.col, ndim=1, name="cols")
vals, _ = as_f32_c(coo.data, ndim=1, name="data")
if not (rows.shape[0] == cols.shape[0] == vals.shape[0]):
raise ValueError("mojolearn SpectralEmbedding: the precomputed graph's row, col and data differ in length")
nnz = int(vals.shape[0])
dense = None
else:
dense, _ = as_f32_c(X, ndim=2, name="X")
n = int(dense.shape[0])
if tuple(dense.shape) != (n, n):
raise ValueError("mojolearn SpectralEmbedding: the precomputed distance matrix must be square")
nnz = 0
k = self._resolved_neighbors(n)
# core/dense_coo.mojo `knn_affinity_f32` (lane/py-dn-kern, 2026-09-28):
# the per-row (value, column) selection, C and 0.5 (C + C^T) in one
# host entry; it was a Python sort per row and an n^2 loop here.
A = _M.zeros(n, n)
if n == 0:
self.affinity_matrix_ = A.out()
return A
status = empty((3,), "<i4")
sp = dense is None
_native("knn_affinity_f32")(
[0 if sp else addr_ro(dense, name="X"),
addr_ro(rows, name="rows") if sp and nnz else 0,
addr_ro(cols, name="cols") if sp and nnz else 0,
addr_ro(vals, name="data") if sp and nnz else 0,
A.addr, addr(status, name="status")],
[n, k, nnz, 1 if sp else 0])
code, row, count = (int(v) for v in status.tolist()) # glue: unpacks the three-field status
if code == 1:
raise ValueError("mojolearn SpectralEmbedding: a precomputed distance is negative or NaN")
if code == 2:
# `count`: the failing row's candidates, from the binding's status
raise ValueError(f"mojolearn SpectralEmbedding: row {row} of the precomputed graph has "
f"{count} stored distances, fewer than n_neighbors={k}")
self.affinity_matrix_ = A.out()
return A
def _check_shape(self, n, n_lanczos):
if n_lanczos >= n:
raise ValueError(
f"mojolearn SpectralEmbedding: n_components={self.n_components} "
f"needs {n_lanczos} eigenpairs, which must be below "
f"n_samples={n}"
)
[docs]
def spectral_embedding(
A,
*,
n_components=8,
affinity="nearest_neighbors",
random_state=None,
n_neighbors=None,
norm_laplacian=True,
drop_first=True,
):
"""Spectral embedding of a dataset or affinity matrix, as a function.
Equivalent to ``SpectralEmbedding(...).fit_transform(A)``, plus the two
switches the class fixes. Follows cuML's ``manifold.spectral_embedding``;
note that ``n_components`` defaults to 8 here, as in cuML.
Parameters
----------
A : array-like
Data or matrix, interpreted according to ``affinity`` (see
:class:`SpectralEmbedding`).
n_components : int, default 8
Dimension of the embedding.
affinity : str, default "nearest_neighbors"
Affinity, as in :class:`SpectralEmbedding`.
random_state : int or None, default None
Seed; None means seed 0.
n_neighbors : int or None, default None
Neighbors per row; None uses ``max(n_samples // 10, 1)``.
norm_laplacian : bool, default True
Use the symmetric normalized graph Laplacian.
drop_first : bool, default True
Drop the first (trivial) eigenvector.
Returns
-------
embedding : Array of shape (n_samples, n_components), float32
The embedding.
Raises
------
TypeError
If ``norm_laplacian`` or ``drop_first`` is not a bool.
"""
for name, value in (("norm_laplacian", norm_laplacian), ("drop_first", drop_first)): # glue: checks two boolean flags
if not isinstance(value, bool):
raise TypeError(
f"mojolearn spectral_embedding: {name} must be a bool, got "
f"{type(value).__name__}"
)
model = SpectralEmbedding(
n_components=n_components, affinity=affinity,
random_state=random_state, n_neighbors=n_neighbors,
)
model._drop_first = drop_first
model._norm_laplacian = norm_laplacian
return model.fit_transform(A)