Source code for mojolearn._hierarchy_impl

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Single-linkage agglomerative clustering on the GPU. Reference: cuML's
`AgglomerativeClustering` (`cuml/cpp/src/hierarchy/linkage.cu` down through
cuVS's `cluster/detail/*` to RAFT's Boruvka MST).

The implementation is `hierarchy/` (DEVIATIONS 620-624 and 881); `hierarchy/README.md`,
`hierarchy/NOT_IMPLEMENTED.tsv` are the record.

Line numbers cited here were read in `upstream/cuml-v26.08.00` (265b9da) and
`upstream/cuvs-v26.08.00` (6ba2ce2) on 2026-08-24. The lane elsewhere cites
cuVS at `94c2819` and RAFT at `661a3b8`, the UNTAGGED default-branch checkouts, whose line numbers for the same code differ by a few dozen lines.

This class is not re-exported from `mojolearn/__init__.py` by this file;
whoever owns that file decides the public namespace.
"""

import sys

from . import _backend, _expansion_cluster, _mojolearn_solver, _serialize
from ._array import Array
from ._buffer import addr, addr_ro, all_finite, as_f32_c, empty, frombytes, zeros
from .density import _check_queries
from .linear_model import _check_saved_by, _restore_mode, _saved_mode

#: `AgglomerativeClustering.save`'s format tag
#: (lane/inference-transductive-predict, 2026-09-15).
_AGGLOMERATIVE_FORMAT = "mojolearn-agglomerative-1"

# `cuml/common/distance_type.hpp`, the codes cuML's Python layer passes
# (`agglomerative.pyx:36-43`).
DISTANCE_L2_EXPANDED = 0
DISTANCE_L2_SQRT_EXPANDED = 1
DISTANCE_COSINE_EXPANDED = 2
DISTANCE_L1 = 3

#: `x_cluster/entries.mojo::ENTRY_AGGLO`.
_E_AGGLO = 11

# Only the two the implementation carries. cuML maps "euclidean" and "l2" to
# L2SqrtExpanded; nothing in their table maps to L2Expanded, so no name for
# it is invented here.
_METRICS = {
    "euclidean": DISTANCE_L2_SQRT_EXPANDED,
    "l2": DISTANCE_L2_SQRT_EXPANDED,
}

#: scikit-learn's metric names on the x_cluster route (`x_cluster/agglo.mojo`):
#: -1 euclidean through the squared distance, 1 manhattan, 4 cosine
#: (`bodies.pdist_cell`, DEVIATION 5111), 5 precomputed.
_ALL_METRICS = {"euclidean": -1, "l2": -1, "l1": 1, "manhattan": 1, "cityblock": 1,
                "cosine": 4, "precomputed": 5}

#: `bodies.LINK_*`.
_LINKAGES = {"ward": 0, "complete": 1, "average": 2, "single": 3}

# `cuvs/cluster/agglomerative.hpp::Linkage`, as cuML's Python spells it.
_CONNECTIVITIES = {"pairwise": 0, "knn": 1}

# The dense connectivity matrix is `m * m` of their `int`, so it overflows
# past this (`hierarchy/impl/cluster/detail/connectivities.mojo`).
PAIRWISE_MAX_ROWS = 46340

# The import-time mode guard that stood here is deleted with the one it came
# from; see the note at the top of `_solver_impl.py`. `_backend.select()` is
# what refuses a FAST binary under the identical label, and it degrades one
# unbuilt binding to one broken estimator instead of an unimportable package.


# Maintainer notes (moved from the docstring, 2026-10-12).
#
# Reference: `cuml.cluster.AgglomerativeClustering` and
# `ML::linkage::single_linkage`; the Mojo entry is `hierarchy/impl/linkage.mojo`
# and the host surface is `hierarchy/estimator.mojo`. Two routes: 'single' with
# the euclidean metric, no connectivity matrix and no per-merge distances stays
# on cuML's Boruvka route (`_mojolearn_solver.linkage_fit`); everything else
# takes the x_cluster route (`x_cluster/agglo.mojo`, ENTRY_AGGLO).
#
# Defaults: connectivity 'pairwise' here; cuML's PYTHON default is 'knn'
# (`agglomerative.pyx:123`), their C++ default is pairwise (`linkage.hpp:43-44`)
# and scikit-learn's dense tree is pairwise too. DEVIATION 881: the k-NN graph
# arm is rung 2 and NOT IMPLEMENTED, so 'knn' is refused by name rather than
# downgraded. linkage 'single' here and in cuML (`agglomerative.pyx:124`);
# scikit-learn's default is 'ward'.
#
# c: tunes `k = log(n) + c` for the k-NN graph and cuML forwards it only when
# `use_knn` is true (`linkage.cu:40`, `use_knn ? c : 0`); with 'knn' refused it
# can reach nothing, so any value but 15 raises. n_rows > 46340 on the Boruvka
# route: the dense connectivity matrix is `m * m` of their `int` and overflows.
#
# labels_ on the Boruvka route: roots are labeled by descending position in the
# children array (`extract_flattened_clusters`), cuVS's numbering, not
# scikit-learn's. children_ there is in Boruvka's orientation (`src` is the
# vertex that added the edge); scikit-learn's mergesort on `mst.data` keeps
# scipy's coo order and this sort is the total order
# `(weight_key, min(u,v), max(u,v))` (DEVIATIONS 620 and 621), so rows agree as
# unordered pairs only when the MST is tie-free. n_connected_components_ is the
# literal 1 there (`single_linkage.cuh:301`, the pairwise graph is complete).
# n_boruvka_rounds_ is the identity card's integer stage `linkage.mst.rounds`;
# -1 on a CPU-only install (the host binding runs Kruskal) and on the x_cluster
# route.
#
# IDENTITY: `MOJOLEARN_IDENTITY_TRACE` does nothing on this path. The
# `single_linkage` entry takes no trace object, and the certified eight-stage
# `linkage.*` card is `hierarchy/linkage_main.mojo`'s, which re-runs the
# distance and MST stages beside the fit to record them. This class calls the
# same entry that driver calls; it does not itself emit a card.
#
# predict is DEVIATION 2740 (neither cuML nor scikit-learn has one).
[docs] class AgglomerativeClustering: """Agglomerative hierarchical clustering. Builds a merge tree bottom-up and cuts it into `n_clusters` clusters or at `distance_threshold`. Supports scikit-learn's single, complete, average and Ward linkages, connectivity matrices and per-merge distances. Input is a dense array, converted to float32. Two defaults differ from scikit-learn's and change the result: `linkage` is 'single' (scikit-learn: 'ward') and `connectivity` is 'pairwise', the full dense graph (scikit-learn's None). cuML's ``AgglomerativeClustering`` also defaults to single linkage, but to 'knn' connectivity, which is not available here; pass ``connectivity='pairwise'`` explicitly when moving cuML code. Label integers can differ from scikit-learn's for the same partition: compare partitions (for example with ``adjusted_rand_score``), not the label values. Likewise, `children_` rows can differ in order and orientation from scikit-learn's when merge distances tie. Parameters ---------- n_clusters : int or None, default 2 Number of clusters, between 1 and the number of samples. Must be None when `distance_threshold` is set. metric : str, default 'euclidean' One of 'euclidean', 'l2', 'l1', 'manhattan', 'cityblock', 'cosine' or 'precomputed' (`X` is then a square distance matrix). Any other value, including a callable, raises NotImplementedError. Ward linkage requires 'euclidean' or 'l2'; 'cosine' rejects rows that are all zeros. connectivity : 'pairwise', None, array-like, sparse matrix or callable, default 'pairwise' 'pairwise' and None both mean no constraint (the full dense graph). A matrix of shape (n_samples, n_samples), or a callable that returns one from `X`, restricts merges to its nonzero entries, as in scikit-learn. 'knn' raises NotImplementedError. linkage : {'single', 'complete', 'average', 'ward'}, default 'single' Merge criterion. c : int, default 15 Kept for cuML compatibility, where it tunes the 'knn' graph. Any other value raises NotImplementedError. memory : None Must be None; anything else raises NotImplementedError. compute_full_tree : 'auto' or bool, default 'auto' With a connectivity matrix, whether to build the full tree or stop at `n_clusters`, with scikit-learn's 'auto' rule. Without one the full tree is always built. Must be True or 'auto' when `distance_threshold` is set. distance_threshold : float or None, default None Merge distance at or above which clusters are no longer merged. Exactly one of `n_clusters` and `distance_threshold` must be set. compute_distances : bool, default False Store the merge distances in `distances_` even without `distance_threshold`. prediction_data : bool, default False Keep a copy of the training rows so that `predict` and `save` work. Only single linkage with the 'euclidean' or 'l2' metric can predict. Attributes ---------- labels_ : Array of shape (n_samples,), int32 Cluster of each training row. n_clusters_ : int Number of clusters found. children_ : Array of shape (n_merges, 2), int32 The merges, one per row; values below n_samples are rows, larger values are earlier merges. n_merges is n_samples - 1 for a full tree. distances_ : Array of shape (n_merges,), float32 Merge distances. Set only with `distance_threshold` or ``compute_distances=True``. n_leaves_ : int Number of leaves (n_samples). n_connected_components_ : int Connected components of the connectivity graph. A constrained fit with more than one warns and completes the graph, as scikit-learn does. n_boruvka_rounds_ : int Not a scikit-learn attribute: the number of Boruvka rounds of the single-linkage euclidean fit without a connectivity matrix; -1 on other fits and on a CPU-only install. n_features_in_ : int Number of features seen in `fit`. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32) >>> model = mojolearn.AgglomerativeClustering(n_clusters=4, prediction_data=True).fit(X) >>> labels = model.predict(X[:10]) """ #: The binding `predict` calls. The FIT is `_mojolearn_solver`'s, a #: training-only family; the prediction entry lives in the estimators #: family, whose host binding ships in the inference wheel. _BINDING = "_mojolearn_estimators"
[docs] def __init__(self, n_clusters=2, *, metric="euclidean", connectivity="pairwise", linkage="single", c=15, memory=None, compute_full_tree="auto", distance_threshold=None, compute_distances=False, prediction_data=False): # scikit-learn's linkages (lane/algos-cluster option parity, # 2026-09-27). 'single' with the euclidean metric, no connectivity # matrix and no per-merge distances stays on cuML's Boruvka route # (`_mojolearn_solver.linkage_fit`, bits unchanged); everything # else takes the x_cluster route (`x_cluster/agglo.mojo`). if linkage not in _LINKAGES: raise ValueError( f"linkage must be one of {sorted(_LINKAGES)}, got {linkage!r}") if connectivity is None: # scikit-learn's spelling of "no connectivity constraint", which # is the full dense graph, which is cuML's 'pairwise'. connectivity = "pairwise" if isinstance(connectivity, str): if connectivity not in _CONNECTIVITIES: raise ValueError( "'connectivity' can only be one of {'knn', 'pairwise'}, " "a connectivity matrix or a callable") if connectivity == "knn": raise NotImplementedError( "mojolearn AgglomerativeClustering: connectivity='knn' is " "REFUSED BY NAME. The Linkage::KNN_GRAPH specialization " "(connectivities.cuh:49), the cross-component fix-up its " "forest MST needs (connect_knn_graph, mst.cuh:67 and :131) " "and merge_msts are rung 2 and NOT IMPLEMENTED -- and their host " "overload picks a RANDOM vertex per component from " "std::mt19937(std::random_device()), which would have to be " "pinned first. Use connectivity='pairwise' (cuML's C++ " "default and scikit-learn's dense tree), or pass the k-NN " "graph itself as a connectivity matrix (scikit-learn's " "kneighbors_graph). See hierarchy/NOT_IMPLEMENTED.tsv" ) if metric not in _ALL_METRICS: raise NotImplementedError( f"mojolearn AgglomerativeClustering: metric={metric!r} is " f"refused by name; one of {sorted(_ALL_METRICS)}. A callable " "metric would compute the dissimilarities outside every " "identity column. See x_cluster/NOT_IMPLEMENTED.tsv" ) if linkage == "ward" and metric not in ("euclidean", "l2"): raise ValueError( f"{metric} was provided as metric. Ward can only work with " "euclidean distances (i.e. 'euclidean' and 'l2').") if c != 15: raise NotImplementedError( f"mojolearn AgglomerativeClustering: c={c!r} is refused. It " "tunes k = log(n) + c for the k-NN graph, cuML forwards it " "only when connectivity='knn' (linkage.cu:40, " "'use_knn ? c : 0'), and that arm is refused by name here, " "so any value you pass would reach nothing" ) if memory is not None: raise NotImplementedError( "mojolearn AgglomerativeClustering: memory is refused; it is " "scikit-learn's joblib cache for a host tree build and there " "is no host tree here" ) if compute_full_tree not in ("auto", True, False): raise ValueError( "compute_full_tree must be 'auto', True or False, got " f"{compute_full_tree!r}") if not ((n_clusters is None) ^ (distance_threshold is None)): raise ValueError( "Exactly one of n_clusters and distance_threshold has to be " "set, and the other needs to be None.") if distance_threshold is not None and not compute_full_tree: raise ValueError( "compute_full_tree must be True if distance_threshold is set.") self.n_clusters = n_clusters self.metric = metric self.connectivity = connectivity self.linkage = linkage self.c = 15 self.memory = None self.compute_full_tree = compute_full_tree self.distance_threshold = distance_threshold self.compute_distances = bool(compute_distances) self.prediction_data = prediction_data
def _legacy_route(self): """cuML's single-linkage Boruvka route: the recorded `agglomerative` lanes' bits. Everything else is the x_cluster route.""" return (self.linkage == "single" and self.metric in _METRICS and isinstance(self.connectivity, str) and self.distance_threshold is None and not self.compute_distances) def _bind(self, name=None): return _backend.binding(name or self._BINDING, getattr(self, "numeric_mode", None))
[docs] def fit(self, X, y=None): """Build the merge tree of `X` and cut it into clusters. Parameters ---------- X : array-like of shape (n_samples, n_features) Dense training rows, or a square distance matrix with ``metric='precomputed'``. Converted to float32. Sparse input raises NotImplementedError. y : ignored Present for scikit-learn compatibility. Returns ------- self : AgglomerativeClustering The fitted estimator. Raises ------ TypeError If `prediction_data` is not a bool. NotImplementedError If `X` is sparse. ValueError If there are fewer than 2 rows, `n_clusters` exceeds the number of rows, `X` contains NaN or infinity, a precomputed or connectivity matrix has the wrong shape, or ``metric='cosine'`` meets an all-zero row. With single linkage, the euclidean metric, no connectivity matrix and no merge distances requested, more than 46,340 rows also raises (the dense graph would overflow), except in ``fast`` mode on Apple with at most 64 features. Warns ----- UserWarning If a connectivity matrix has more than one connected component. """ if not isinstance(self.prediction_data, bool): raise TypeError( "mojolearn AgglomerativeClustering: prediction_data must be a " f"bool, got {type(self.prediction_data).__name__}" ) if not self._legacy_route(): return self._fit_x(X) if hasattr(X, "toarray") or hasattr(X, "tocsr"): raise NotImplementedError( "mojolearn AgglomerativeClustering: sparse X is refused; the " "pairwise connectivity arm builds its dense m x m graph from " "a dense float32 matrix" ) x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") n_rows, n_cols = x.shape if n_rows < 2: raise ValueError( f"mojolearn AgglomerativeClustering: n_rows={n_rows} < 2; " "single linkage needs at least two points" ) # FAST on Apple builds the Euclidean MST from Boruvka rounds without # the dense graph (hierarchy/impl/cluster/detail/fast_boruvka.mojo, # taken for n_cols <= 64), so the dense matrix's row cap does not # apply there. fast_mst = ( _backend.requested_mode() == "fast" and sys.platform == "darwin" and n_cols <= 64 ) if n_rows > PAIRWISE_MAX_ROWS and not fast_mst: raise ValueError( f"mojolearn AgglomerativeClustering: n_rows={n_rows} > " f"{PAIRWISE_MAX_ROWS}; the dense connectivity matrix is " "m * m of cuVS's int and overflows past that " "(hierarchy/impl/cluster/detail/connectivities.mojo " "refuses it by name too)" ) k = int(self.n_clusters) if k < 1 or k > n_rows: raise ValueError( f"Expected 1 <= n_clusters <= n_rows ({n_rows}), got " f"n_clusters={k}" ) # Outputs are `_array.Array`s (DEVIATION 2371; they were ndarrays). children = empty((n_rows - 1, 2), "<i4") labels = empty((n_rows,), "<i4") info = zeros((2,), "<i4") _mojolearn_solver.linkage_fit( addr_ro(x, name="X"), addr(children, name="children_"), addr(labels, name="labels_"), addr(info, name="info"), # ORDER MATCHES bindings/_mojolearn_solver.mojo::linkage_fit_binding. # n_rows, n_cols, n_clusters, metric, use_knn [ n_rows, n_cols, k, _METRICS[self.metric], _CONNECTIVITIES[self.connectivity], ], ) self.labels_ = labels self.children_ = children self.n_clusters_ = k self.n_leaves_ = n_rows # -1 on a CPU-only install (the CPU training lane, 2026-09-14): the # host binding runs Kruskal under the device's total order, so there # is no Boruvka pass to count; labels_ and children_ are the same. self.n_boruvka_rounds_ = int(info[0]) self.n_connected_components_ = int(info[1]) self.n_features_in_ = n_cols # 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()) if self.prediction_data else None return self
def _predicts(self): """predict (DEVIATION 2740) is the nearest training row's cluster: single linkage's own criterion under the euclidean metric only.""" return self.linkage == "single" and self.metric in _METRICS def _connectivity_aux(self, X, n): """The connectivity matrix as the binding takes it (lane apple-fast-py2mojo-cluster: `x_cluster/agglo.mojo` keeps its nonzero off-diagonal entries, which Python's edge list did before): (aux, count, edge_mode), mode 1 the dense n x n float32 matrix (count n * n), mode 2 the COO rows, columns and values as float32, concatenated (count nnz).""" conn = self.connectivity if callable(conn): conn = conn(X) tocoo = getattr(conn, "tocoo", None) if callable(tocoo): coo = tocoo() shape = tuple(coo.shape) rows, _ = as_f32_c(coo.row, ndim=1, name="connectivity rows") cols, _ = as_f32_c(coo.col, ndim=1, name="connectivity cols") vals, _ = as_f32_c(coo.data, ndim=1, name="connectivity values") nnz = int(vals.shape[0]) aux = (frombytes(rows.tobytes() + cols.tobytes() + vals.tobytes(), "<f4", (3 * nnz,)) if nnz else None) mode, count = 2, nnz else: dense, _ = as_f32_c(conn, ndim=2, name="connectivity") shape = tuple(dense.shape) aux, mode, count = dense, 1, int(dense.size) if shape != (n, n): raise ValueError( f"Wrong shape for connectivity matrix: {shape} when X has {n} samples") return aux, count, mode def _fit_x(self, X): """The x_cluster route (`x_cluster/agglo.mojo`, ENTRY_AGGLO): every linkage, metric, connectivity matrix and the per-merge distances, with scikit-learn's `_fit` bookkeeping around it.""" import warnings if hasattr(X, "toarray") or hasattr(X, "tocsr"): raise NotImplementedError( "mojolearn AgglomerativeClustering: sparse X is refused; the " "dissimilarities are a dense float32 matrix") x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") n, d = (int(v) for v in x.shape) # glue: the two shape dims if n < 2: raise ValueError( f"mojolearn AgglomerativeClustering: n_rows={n} < 2; a tree " "needs at least two points") if not all_finite(x): raise ValueError("mojolearn AgglomerativeClustering: X contains NaN or infinity") metric = _ALL_METRICS[self.metric] if metric == 5 and n != d: raise ValueError(f"Distance matrix should be square, got matrix of shape {(n, d)}") if metric == 4: # lane cpu2-l9-neighbors: the zero-row test on the device (the # x_neighbors binding's `xn_row_all_zero`, one flag per row from # the bit patterns, +-0.0 a zero and NaN not), then the device # `reduce_stat` max of the flags; was `x.tolist()` and a Python # walk of every value if d == 0: raise ValueError("Cosine affinity cannot be used when X contains zero vectors") zrow = empty((n,), "<i4") self._bind("_mojolearn_x_neighbors").xn_row_all_zero( [addr_ro(x, name="X"), addr(zrow, name="zero rows")], [n, d], []) if int(zrow.max()) > 0: raise ValueError("Cosine affinity cannot be used when X contains zero vectors") if self.n_clusters is not None: k = int(self.n_clusters) if k < 1 or k > n: raise ValueError( f"Cannot extract more clusters than samples: {k} clusters " f"were given for a tree with {n} leaves.") constrained = not isinstance(self.connectivity, str) full = True if constrained: full = self.compute_full_tree if full == "auto": full = (self.distance_threshold is not None or int(self.n_clusters) < max(100, 0.02 * n)) n_merges = n - 1 if full else n - int(self.n_clusters) # the cluster lane's binding, named by its door (a name here would # make this file a whole-surface registry to tools/lane_select.py) b = _backend.binding(_expansion_cluster._XCluster._BINDING, getattr(self, "numeric_mode", None)) # the connectivity filter, the distance-threshold count and the # labels in the binding (x_cluster/agglo.mojo, tree_cut.mojo) aux, n_edges, edge_mode = self._connectivity_aux(x, n) if constrained else (None, -1, 0) thr = self.distance_threshold cut = (1 if thr is not None else int(self.n_clusters)) if full else 0 ip = [n, d, _LINKAGES[self.linkage], metric, n_edges, n_merges, edge_mode, cut, 1 if (full and thr is not None) else 0] fp = [2.0, float(thr) if thr is not None else 0.0] f, i, sc = b.x_cluster_call( _E_AGGLO, addr_ro(x, name="X"), x.size, addr_ro(aux, name="connectivity") if aux is not None else 0, aux.size if aux is not None else 0, ip, fp) children = i[0] self.children_ = Array._from_flat(children, (n_merges, 2), "<i4") self.n_leaves_ = n self.n_connected_components_ = int(sc[0]) if constrained and self.n_connected_components_ > 1: warnings.warn( "the number of connected components of the connectivity matrix " f"is {self.n_connected_components_} > 1. Completing it to avoid " "stopping the tree early.", stacklevel=2) distances = f[0] if self.distance_threshold is not None or self.compute_distances: self.distances_ = Array._from_flat(distances, (n_merges,), "<f4") else: self.__dict__.pop("distances_", None) self.n_clusters_ = (int(sc[1]) if self.distance_threshold is not None and full else int(self.n_clusters)) self.labels_ = Array._from_flat(i[1], (n,), "<i4") self.n_boruvka_rounds_ = -1 self.n_features_in_ = d self._fit_X = None if self.prediction_data and self._predicts(): self._fit_X = x if self.input_copied_ else x.copy() return self
[docs] def fit_predict(self, X, y=None): """Fit the model on `X` and return the training labels. Parameters ---------- X : array-like of shape (n_samples, n_features) Dense training rows, or a square distance matrix with ``metric='precomputed'``. y : ignored Present for scikit-learn compatibility. Returns ------- labels : Array of shape (n_samples,), int32 The fitted `labels_`. """ return self.fit(X).labels_
[docs] def predict(self, X): """Label new rows with the cluster of their nearest training row. Neither scikit-learn nor cuML has this method. For single linkage, the closest cluster to a new row is the one containing its nearest training row (squared euclidean distance); ties go to the lowest cluster label, then the lowest training index. A training row predicts its own fitted label unless an exact duplicate of it was cut into a cluster with a lower label, which happens only when `n_clusters` exceeds the number of distinct rows. Only single linkage with the 'euclidean' or 'l2' metric can predict, and only after a fit with ``prediction_data=True`` (or a model loaded with `load`). Parameters ---------- X : array-like of shape (n_queries, n_features) Rows to label, with the number of features seen in `fit`. Returns ------- labels : Array of shape (n_queries,), int32 Cluster of each row, numbered as `labels_`. Raises ------ ValueError If the model is not fitted or was fitted without ``prediction_data=True``. NotImplementedError If the linkage or metric has no prediction rule. """ # Maintainer notes (moved from the docstring): DEVIATION 2740. The rule # is the linkage's own criterion between the new row and each fitted # cluster; for single linkage that is the cluster of the nearest # training row. THE DISTANCE is DBSCAN's eps accumulator # (`dbscan/impl/neighbors/epsilon_neighborhood.mojo::_eps_acc`, L2 arm): # the squared Euclidean distance summed features ascending, never # rooted, the training row flushed. It is NOT the fit's expanded # `||a||^2 + ||b||^2 - 2 a.b` distance, whose value for a row against # itself need not be 0. One thread per query row, no fold across rows; # the GPU binding and the CPU host binding # (`core/labeled_reference_host_predict.mojo`) compute the same bytes. if not hasattr(self, "labels_"): raise ValueError( "mojolearn AgglomerativeClustering.predict: this instance is not " "fitted yet; call fit first" ) if not self._predicts(): raise NotImplementedError( f"mojolearn AgglomerativeClustering.predict: linkage={self.linkage!r}, " f"metric={self.metric!r} has no predict rule; DEVIATION 2740 is single " "linkage's own criterion (the nearest training row's cluster) under " "the euclidean metric only") if getattr(self, "_fit_X", None) is None: raise ValueError( "mojolearn AgglomerativeClustering.predict: prediction data was not " "stored. Fit with AgglomerativeClustering(prediction_data=True), " "which keeps the training rows this rule needs (DEVIATION 2740)" ) q = _check_queries(X, self.n_features_in_, "AgglomerativeClustering.predict") nq = int(q.shape[0]) n_refs = int(self._fit_X.shape[0]) out = empty((nq,), "<i4") chosen = empty((nq,), "<i4") self._bind(self._BINDING).labeled_reference_predict( # ORDER MATCHES bindings/_mojolearn_estimators.mojo::labeled_reference_predict_binding. # The key of every training row is its label: ties go to the # lowest cluster label, then the lowest training index. [addr_ro(self._fit_X, name="training rows"), addr_ro(self.labels_, name="labels_ (keys)"), addr_ro(self.labels_, name="labels_"), addr_ro(q, name="X"), addr(out, name="labels"), addr(chosen, name="training rows chosen")], # n_refs, n_queries, n_features, metric (0: L2), eps (unused), has_thresh [n_refs, nq, int(self.n_features_in_), 0, 0.0, 0], ) return out
[docs] def save(self, path): """Save the model's prediction data to `path`. Writes an npz file with the training rows, `labels_`, `n_clusters_`, `n_features_in_`, `n_leaves_`, the metric and the numeric mode. `children_` and `n_boruvka_rounds_` are not saved, so the same fit writes the same bytes on every device; a loaded model has neither attribute. A loaded model predicts, including on a CPU-only install. Parameters ---------- path : str or path-like File to write. Raises ------ RuntimeError If the model is not fitted. ValueError If it was fitted without ``prediction_data=True``. NotImplementedError If the linkage or metric has no prediction rule. """ if not hasattr(self, "labels_"): raise RuntimeError("this estimator is not fitted yet") if not self._predicts(): raise NotImplementedError( f"mojolearn AgglomerativeClustering.save: linkage={self.linkage!r}, " f"metric={self.metric!r} has no predict rule to save (DEVIATION 2740)") if getattr(self, "_fit_X", None) is None: raise ValueError( "mojolearn AgglomerativeClustering.save: prediction data was not " "stored; fit with AgglomerativeClustering(prediction_data=True)" ) arrays = { "format": _AGGLOMERATIVE_FORMAT, "estimator": type(self).__name__, "numeric_mode": _saved_mode(self), "metric": str(self.metric), "x": self._fit_X, "labels": self.labels_, "meta": Array.from_list( [int(self.n_clusters_), int(self.n_features_in_), int(self.n_leaves_)], "<i8" ), } return _serialize.write_npz(path, arrays)
[docs] @classmethod def load(cls, path): """Load a model written by `save`. The loaded model can `predict`. Every array is read at its saved dtype and never cast. Parameters ---------- path : str or path-like File written by `save`. Returns ------- model : AgglomerativeClustering The loaded model. Raises ------ ValueError If the file is not a saved AgglomerativeClustering or its arrays are inconsistent. """ arrays = _serialize.read_npz(path, _AGGLOMERATIVE_FORMAT) _check_saved_by(arrays, path, cls) meta = _serialize.exact(arrays, "meta", "<i8") if meta.size != 3: raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, 3 are needed") k, nf, n_leaves = (int(v) for v in meta.tolist()) # glue: unpacks the fixed meta vector obj = cls(n_clusters=k, metric=_serialize.scalar_str(arrays, "metric"), prediction_data=True) _restore_mode(obj, arrays) x = _serialize.exact(arrays, "x", "<f4") labels = _serialize.exact(arrays, "labels", "<i4") if x.ndim != 2 or tuple(x.shape) != (n_leaves, nf): raise ValueError(f"mojolearn: {path!r} x shape {tuple(x.shape)} is not ({n_leaves}, {nf})") if labels.size != n_leaves: raise ValueError(f"mojolearn: {path!r} labels do not match n_leaves_") obj._fit_X = x obj.labels_ = labels obj.n_clusters_ = k obj.n_leaves_ = n_leaves obj.n_features_in_ = nf return obj