# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
# Maintainer notes (moved from the module docstring; workstream D, 2026-09-14):
# The Python door of `hdbscan/estimator.mojo::hdbscan_fit_host` through
# `bindings/_mojolearn_hdbscan.mojo`. Reference: cuML. The path follows cuML's
# `runner.h:152-234` (brute-force k-NN, Boruvka MST, single linkage, condensed
# tree, excess-of-mass or leaf selection); the lane README lists what is
# mirrored and what is refused.
# Refusals: metric other than euclidean (here and Mojo host);
# cluster_selection_epsilon < 0 or non-finite (here); min_samples < 1 or
# > n_rows, min_cluster_size < 2 or > n_rows, alpha <= 0 or non-finite,
# n_rows < 2, any NaN/inf (Mojo host, DEVIATION 1607). Past 46340 rows the MST
# is built without the dense graph (DEVIATION 1620).
# Held-out points (2026-09-15): prediction data per cuML
# `prediction_data.cu:92-239` (`hdbscan/impl/prediction_data.mojo`);
# approximate_predict per `hdbscan.pyx:1264`, `predict.cuh:220-262`.
# Soft clustering (2026-09-15): `hdbscan.pyx:1180`, `:1114`,
# `soft_clustering.cuh:385-627`; float32 with pinned seams on every column
# (DEVIATION 1616, `hdbscan/impl/detail/soft_clustering.mojo`), where cuML
# promotes four seams to float64. No speed claim.
"""HDBSCAN density-based clustering.
`HDBSCAN` clusters the training data. When it is fitted with
`prediction_data=True`, three functions in this module work on new or
training points under the fitted clustering, following cuML's
`cuml.cluster.hdbscan` functions of the same names:
- `approximate_predict` gives the cluster label and probability of new
points;
- `membership_vector` gives the soft cluster membership of new points;
- `all_points_membership_vectors` gives the soft cluster membership of
every training point.
Each raises `ValueError` for a clusterer fitted without
`prediction_data=True`, as cuML does. Soft membership is computed in
float32 throughout, where cuML uses float64 in some steps.
"""
import warnings
from . import _backend, _serialize
from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, empty
from ._mode import NumericModeMixin
_MODE_CODE = {"fast": 0, "identical": 1, "deterministic": 2}
#: `hierarchy/impl/cluster/detail/connectivities.mojo::DISTANCE_L2_SQRT_EXPANDED`,
#: the one metric `fit_hdbscan` accepts (their RAFT_EXPECTS).
METRIC_L2_SQRT_EXPANDED = 1
#: `hdbscan/impl/detail/select.mojo`'s codes.
SELECTION_EOM = 0
SELECTION_LEAF = 1
_METRICS = {"euclidean": METRIC_L2_SQRT_EXPANDED, "l2": METRIC_L2_SQRT_EXPANDED}
#: The model file (the neighbors and density inference lane, 2026-09-15):
#: what `approximate_predict` reads and nothing else. `raw_data` `<f4`
#: (n, d) as fitted, `core_distances` `<f4`, `labels` `<i4`, the prediction
#: data's `parents` `<i4`, `lambdas` `<f4`, `deaths` `<f4`,
#: `selected_clusters` `<i4`, `index_into_children` `<i4`, `exemplar_idx` `<i4`
#: and `exemplar_label_offsets` `<i4` (the last three and `parents` are what
#: membership_vector and all_points_membership_vectors read), `ints` `<i8`
#: [n_features_in_, n_clusters_, n_outliers_, n_boruvka_rounds_,
#: n_condensed_clusters_, n_edges, min_cluster_size, min_samples (-1 for
#: None), max_cluster_size, allow_single_cluster, n_exemplars], `reals` `<f8`
#: [alpha, cluster_selection_epsilon],
#: `metric` and `cluster_selection_method` as text. A model fitted without
#: prediction_data=True has nothing to save and is refused by name.
_HDBSCAN_FORMAT = "mojolearn-hdbscan-2"
_HDBSCAN_INTS = 11
_SELECTION = {"eom": SELECTION_EOM, "leaf": SELECTION_LEAF}
# cluster_selection_epsilon: cuML's epsilon search (`select.cuh:301-363`), run
# on the host by one function both routes call (DEVIATION 5115).
# probabilities_: cuML `get_probabilities` (DEVIATION 5116).
# prediction_data: cuML's parameter (`hdbscan.pyx:568`).
[docs]
class HDBSCAN(NumericModeMixin):
"""Hierarchical density-based clustering (HDBSCAN).
Follows cuML's `cuml.cluster.HDBSCAN`: builds the mutual reachability
graph from exact nearest neighbors, its minimum spanning tree and the
condensed cluster tree, then selects clusters by excess of mass or by
leaf. Points in no cluster are labeled `-1` (noise). `X` is read as
float32; only the Euclidean metric is supported. A NaN or infinity in
`X`, or fewer than 2 rows, raises an error.
Parameters
----------
min_cluster_size : int, default 5
Smallest group of points that counts as a cluster. Must be between 2
and the number of rows.
min_samples : int or None, default None
Number of neighbors used for the core distance. None means
`min_cluster_size`, as in cuML and scikit-learn. Must be between 1
and the number of rows.
cluster_selection_epsilon : float, default 0.0
A selected cluster born below this distance is merged into its first
ancestor born above it. 0.0 disables merging. Must be finite and
non-negative.
max_cluster_size : int, default 0
Largest cluster the excess-of-mass selection may return. 0 means no
limit.
metric : {'euclidean'}, default 'euclidean'
Distance metric. Any other value raises `ValueError`.
alpha : float, default 1.0
Distance scaling in the robust single linkage. Must be positive and
finite.
cluster_selection_method : {'eom', 'leaf'}, default 'eom'
'eom' selects by excess of mass; 'leaf' selects the leaves of the
condensed tree.
allow_single_cluster : bool, default False
Allow the result to be a single cluster.
prediction_data : bool, default False
Keep the condensed tree and build prediction data during `fit`, so
`approximate_predict`, `membership_vector`,
`all_points_membership_vectors` and `save` can be used. The
clustering itself is the same either way.
Attributes
----------
labels_ : Array of shape (n_samples,), int32
Cluster of each training point, `0 .. n_clusters_ - 1`, or `-1` for
noise. Numbered as cuML numbers them.
probabilities_ : Array of shape (n_samples,), float32
Strength of each point's membership in its cluster, 0 for noise.
core_distances_ : Array of shape (n_samples,), float32
Distance from each point to its `min_samples`-th nearest neighbor,
not counting the point itself.
n_clusters_ : int
Number of clusters found.
n_outliers_ : int
Number of noise points.
n_boruvka_rounds_ : int
Number of Boruvka rounds the minimum spanning tree took.
n_condensed_clusters_ : int
Number of clusters in the condensed tree.
n_features_in_ : int
Number of features seen during `fit`.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> model = mojolearn.HDBSCAN(min_cluster_size=10, prediction_data=True).fit(X)
>>> labels = model.labels_
>>> new_labels, strengths = mojolearn.hdbscan.approximate_predict(model, X[:10])
"""
_BINDING = "_mojolearn_hdbscan"
[docs]
def __init__(
self,
min_cluster_size=5,
min_samples=None,
cluster_selection_epsilon=0.0,
max_cluster_size=0,
metric="euclidean",
alpha=1.0,
cluster_selection_method="eom",
allow_single_cluster=False,
prediction_data=False,
):
self.min_cluster_size = min_cluster_size
self.min_samples = min_samples
self.cluster_selection_epsilon = cluster_selection_epsilon
self.max_cluster_size = max_cluster_size
self.metric = metric
self.alpha = alpha
self.cluster_selection_method = cluster_selection_method
self.allow_single_cluster = allow_single_cluster
self.prediction_data = prediction_data
def _extension(self):
mod = self._bind()
want = getattr(self, "numeric_mode", None) or _backend.default_mode()
fn = getattr(mod, "hdbscan_numeric_mode", None)
if fn is not None:
got = int(fn())
if got != _MODE_CODE.get(want):
raise RuntimeError(
f"mojolearn HDBSCAN: numeric_mode={want!r} was requested but "
f"{mod.__name__} reports compile-time mode code {got}; rebuild "
"it with bash bindings/build_hdbscan.sh"
)
return mod
[docs]
def fit(self, X, y=None):
"""Cluster `X`.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Training data, read as float32.
y : ignored
Present for scikit-learn API compatibility.
Returns
-------
self : HDBSCAN
The fitted estimator.
Raises
------
TypeError
If an integer parameter is not an int, `alpha` or
`cluster_selection_epsilon` is not a number, or `prediction_data`
is not a bool.
ValueError
For an unknown `metric` or `cluster_selection_method`, or a
negative or non-finite `cluster_selection_epsilon`.
"""
x, copied = as_f32_c(X, ndim=2, name="X")
n, d = x.shape
for name in ("min_cluster_size", "max_cluster_size"): # glue: checks two parameter names
v = getattr(self, name)
if isinstance(v, bool) or not isinstance(v, int):
raise TypeError(f"mojolearn HDBSCAN: {name} must be an int, got {type(v).__name__}")
if self.min_samples is None:
min_samples = int(self.min_cluster_size)
elif isinstance(self.min_samples, bool) or not isinstance(self.min_samples, int):
raise TypeError("mojolearn HDBSCAN: min_samples must be an int or None")
else:
min_samples = int(self.min_samples)
if isinstance(self.metric, str):
if self.metric not in _METRICS:
raise ValueError(
f"mojolearn HDBSCAN: metric must be 'euclidean' (cuML's "
f"L2SqrtExpanded, the one fit_hdbscan accepts), got {self.metric!r}"
)
metric = _METRICS[self.metric]
else:
metric = int(self.metric)
if isinstance(self.cluster_selection_method, str):
if self.cluster_selection_method not in _SELECTION:
raise ValueError(
"mojolearn HDBSCAN: cluster_selection_method must be 'eom' or "
f"'leaf', got {self.cluster_selection_method!r}"
)
method = _SELECTION[self.cluster_selection_method]
else:
method = int(self.cluster_selection_method)
for name in ("alpha", "cluster_selection_epsilon"): # glue: checks two parameter names
v = getattr(self, name)
if isinstance(v, bool) or not isinstance(v, (int, float)):
raise TypeError(f"mojolearn HDBSCAN: {name} must be a real number")
cse = float(self.cluster_selection_epsilon)
if not (cse >= 0.0) or cse == float("inf"):
raise ValueError(
"mojolearn HDBSCAN: cluster_selection_epsilon must be a finite "
f"number >= 0, got {self.cluster_selection_epsilon!r} (scikit-learn's range)"
)
if not isinstance(self.prediction_data, bool):
raise TypeError("mojolearn HDBSCAN: prediction_data must be a bool")
want_pd = self.prediction_data
labels = empty((n,), "<i4")
core = empty((n,), "<f4")
info = empty((5 if want_pd else 4,), "<i4")
# ORDER MATCHES bindings/_mojolearn_hdbscan.mojo::hdbscan_fit_binding.
# x, labels_out, core_dists_out, info_out
addrs = [addr_ro(x, name="X"), addr(labels, name="labels_"), addr(core, name="core_distances_"), addr(info, name="info")]
probs = empty((n,), "<f4")
if want_pd:
# tree_parents_out, tree_children_out, tree_lambdas_out,
# tree_sizes_out (2 * n each), inverse_label_map_out (n)
t_par = empty((2 * n,), "<i4")
t_ch = empty((2 * n,), "<i4")
t_lam = empty((2 * n,), "<f4")
t_sz = empty((2 * n,), "<i4")
t_inv = empty((n,), "<i4")
addrs += [addr(t_par, name="tree parents"), addr(t_ch, name="tree children"),
addr(t_lam, name="tree lambdas"), addr(t_sz, name="tree sizes"),
addr(t_inv, name="inverse_label_map")]
# probabilities_out, always LAST (DEVIATION 5116)
addrs.append(addr(probs, name="probabilities_"))
ext = self._extension()
n_clusters = ext.hdbscan_fit(
addrs,
# n, d, min_samples, min_cluster_size, max_cluster_size, alpha,
# allow_single_cluster, cluster_selection_method, cluster_selection_epsilon, metric
[n, d, min_samples, int(self.min_cluster_size), int(self.max_cluster_size), float(self.alpha),
1 if self.allow_single_cluster else 0, method, float(self.cluster_selection_epsilon), metric],
)
self.n_features_in_ = d
self.labels_ = labels
self.core_distances_ = core
self.probabilities_ = probs
self.n_clusters_ = int(n_clusters)
self.n_outliers_ = int(info[1])
self.n_boruvka_rounds_ = int(info[2])
self.n_condensed_clusters_ = int(info[3])
self._prediction_data = None
self._raw_data = None
if want_pd:
self._prediction_data = self._generate_prediction_data(
ext, n, int(info[4]), t_par, t_ch, t_lam, t_sz, t_inv)
# cuML keeps the training matrix as `_raw_data` (hdbscan.pyx:1323);
# a borrowed input is copied so a caller's later write cannot move it.
self._raw_data = x if copied else x.copy()
return self
def _generate_prediction_data(self, ext, n, n_edges, t_par, t_ch, t_lam, t_sz, t_inv):
"""cuML `generate_prediction_data` (`hdbscan.pyx:394-427`,
`prediction_data.cu:92-239`) through the binding's host function."""
n_cond = self.n_condensed_clusters_
n_sel = self.n_clusters_
pd = dict(
n_edges=n_edges,
n_condensed=n_cond,
parents=t_par[:n_edges],
children=t_ch[:n_edges],
lambdas=t_lam[:n_edges],
sizes=t_sz[:n_edges],
inverse_label_map=t_inv[:max(n_sel, 1)],
deaths=empty((n_cond,), "<f4"),
selected_clusters=empty((max(n_sel, 1),), "<i4"),
exemplar_idx=empty((n,), "<i4"),
exemplar_label_offsets=empty((n_sel + 1,), "<i4"),
index_into_children=empty((n_edges + 1,), "<i4"),
)
n_ex = ext.hdbscan_generate_prediction_data(
# labels, parents, children, lambdas, sizes, inverse_label_map,
# deaths_out, selected_clusters_out, exemplar_idx_out,
# exemplar_label_offsets_out, index_into_children_out
[addr_ro(self.labels_, name="labels_"), addr_ro(pd["parents"], name="tree parents"),
addr_ro(pd["children"], name="tree children"), addr_ro(pd["lambdas"], name="tree lambdas"),
addr_ro(pd["sizes"], name="tree sizes"), addr_ro(pd["inverse_label_map"], name="inverse_label_map"),
addr(pd["deaths"], name="deaths"), addr(pd["selected_clusters"], name="selected_clusters"),
addr(pd["exemplar_idx"], name="exemplar_idx"),
addr(pd["exemplar_label_offsets"], name="exemplar_label_offsets"),
addr(pd["index_into_children"], name="index_into_children")],
# n_leaves, n_edges, n_clusters, n_selected
[n, n_edges, n_cond, n_sel],
)
pd["n_exemplars"] = int(n_ex)
pd["exemplar_idx"] = pd["exemplar_idx"][:max(int(n_ex), 1)]
return pd
[docs]
def fit_predict(self, X, y=None):
"""Cluster `X` and return `labels_`.
Returns
-------
labels : Array of shape (n_samples,), int32
"""
return self.fit(X).labels_
[docs]
def save(self, path):
"""Save the fitted model to `path` as an `.npz` file.
The file holds what `approximate_predict`, `membership_vector` and
`all_points_membership_vectors` read, including the training data.
Load it with `load`, or with `mojolearn.host_model(path)` to predict
new points on a machine with no GPU.
Parameters
----------
path : str or path-like
Destination file.
Raises
------
ValueError
If the model is not fitted, was fitted without
`prediction_data=True`, or `metric` or
`cluster_selection_method` is not given by name.
"""
pd = _check_clusterer(self, "HDBSCAN.save")
from .linear_model import _saved_mode
if not isinstance(self.metric, str) or not isinstance(self.cluster_selection_method, str):
raise ValueError("mojolearn HDBSCAN.save: metric and cluster_selection_method must be names")
n_sel = int(self.n_clusters_)
return _serialize.write_npz(path, {
"format": _HDBSCAN_FORMAT,
"estimator": type(self).__name__,
"numeric_mode": _saved_mode(self),
"metric": self.metric,
"cluster_selection_method": self.cluster_selection_method,
"raw_data": self._raw_data,
"core_distances": self.core_distances_,
"labels": self.labels_,
"parents": pd["parents"],
"lambdas": pd["lambdas"],
"deaths": pd["deaths"],
"selected_clusters": pd["selected_clusters"][:max(n_sel, 1)],
"index_into_children": pd["index_into_children"],
"exemplar_idx": pd["exemplar_idx"],
"exemplar_label_offsets": pd["exemplar_label_offsets"],
"ints": Array.from_list([
int(self.n_features_in_), n_sel, int(self.n_outliers_), int(self.n_boruvka_rounds_),
int(self.n_condensed_clusters_), int(pd["n_edges"]), int(self.min_cluster_size),
-1 if self.min_samples is None else int(self.min_samples), int(self.max_cluster_size),
1 if self.allow_single_cluster else 0, int(pd["n_exemplars"]),
], "<i8"),
"reals": Array.from_list([float(self.alpha), float(self.cluster_selection_epsilon)], "<f8"),
})
[docs]
@classmethod
def load(cls, path):
"""Load a model written by `save`.
Arrays keep their saved dtypes. The result works with
`approximate_predict`, `membership_vector` and
`all_points_membership_vectors`.
Parameters
----------
path : str or path-like
File written by `HDBSCAN.save`.
Returns
-------
model : HDBSCAN
Raises
------
ValueError
If the file is of another format or estimator, or is truncated or
inconsistent.
"""
from .linear_model import _check_saved_by, _restore_mode
arrays = _serialize.read_npz(path, _HDBSCAN_FORMAT)
_check_saved_by(arrays, path, cls)
ints = _serialize.exact(arrays, "ints", "<i8")
reals = _serialize.exact(arrays, "reals", "<f8")
if ints.size != _HDBSCAN_INTS or reals.size != 2:
raise ValueError(f"mojolearn: {path!r} holds {ints.size} ints and {reals.size} reals, "
f"{_HDBSCAN_INTS} and 2 are needed")
(d, n_sel, n_out, n_rounds, n_cond, n_edges, mcs, ms, maxcs, single, n_ex) = (
int(ints[i]) for i in range(_HDBSCAN_INTS)) # glue: unpacks the fixed int vector
obj = cls(min_cluster_size=mcs, min_samples=None if ms == -1 else ms,
cluster_selection_epsilon=float(reals[1]), max_cluster_size=maxcs,
metric=_serialize.scalar_str(arrays, "metric"), alpha=float(reals[0]),
cluster_selection_method=_serialize.scalar_str(arrays, "cluster_selection_method"),
allow_single_cluster=bool(single), prediction_data=True)
_restore_mode(obj, arrays)
raw = _serialize.exact(arrays, "raw_data", "<f4")
if raw.ndim != 2 or raw.shape[1] != d or raw.shape[0] < 2:
raise ValueError(f"mojolearn: {path!r} raw_data has shape {tuple(raw.shape)}, ints say {d} features")
n = int(raw.shape[0])
want = dict(core_distances=("<f4", n), labels=("<i4", n), parents=("<i4", n_edges),
lambdas=("<f4", n_edges), deaths=("<f4", n_cond),
selected_clusters=("<i4", max(n_sel, 1)), index_into_children=("<i4", n_edges + 1),
exemplar_idx=("<i4", max(n_ex, 1)), exemplar_label_offsets=("<i4", n_sel + 1))
got = {}
for name, (dtype, size) in want.items(): # glue: checks saved array names
a = _serialize.exact(arrays, name, dtype)
if a.ndim != 1 or a.size != size:
raise ValueError(f"mojolearn: {path!r} {name} holds {a.size} values, {size} are needed")
got[name] = a
obj.n_features_in_ = d
obj.labels_ = got["labels"]
obj.core_distances_ = got["core_distances"]
obj.n_clusters_ = n_sel
obj.n_outliers_ = n_out
obj.n_boruvka_rounds_ = n_rounds
obj.n_condensed_clusters_ = n_cond
obj._raw_data = raw
obj._prediction_data = dict(n_edges=n_edges, n_condensed=n_cond, n_exemplars=n_ex,
parents=got["parents"], lambdas=got["lambdas"],
deaths=got["deaths"], selected_clusters=got["selected_clusters"],
index_into_children=got["index_into_children"],
exemplar_idx=got["exemplar_idx"],
exemplar_label_offsets=got["exemplar_label_offsets"])
return obj
def _check_clusterer(clusterer, where):
"""cuML `_check_clusterer` (`hdbscan.pyx:1098-1110`): a fitted HDBSCAN
with prediction data, else a refusal by name."""
if not isinstance(clusterer, HDBSCAN):
raise TypeError(
f"mojolearn {where}: clusterer must be a mojolearn HDBSCAN, got {type(clusterer).__name__}"
)
if not hasattr(clusterer, "labels_"):
raise ValueError(f"mojolearn {where}: this HDBSCAN instance is not fitted yet; call fit first")
pd = getattr(clusterer, "_prediction_data", None)
if pd is None:
raise ValueError(
f"mojolearn {where}: Prediction data not yet generated. Fit with "
"HDBSCAN(prediction_data=True); cuML refuses the same call the same way "
"(hdbscan.pyx:1104-1108)"
)
return pd
# cuML `approximate_predict` (`hdbscan.pyx:1264`, `predict.cuh:220-262`).
[docs]
def approximate_predict(clusterer, points_to_predict):
"""Predict the cluster of new points under a fitted clustering.
Follows cuML's `approximate_predict`. The labels are those of the
original clustering, not the labels a re-clustering with the new points
would find (hence "approximate"). If the clusterer found no clusters,
a warning is issued and every point is labeled noise.
Parameters
----------
clusterer : HDBSCAN
A model fitted with `prediction_data=True`.
points_to_predict : array-like of shape (n_samples, n_features)
New points, read as float32, with the fitted number of features.
Returns
-------
labels : Array (n_samples,) int32
`-1` where the nearest mutual reachability neighbor is noise or the
point falls outside its cluster's lambda range.
probabilities : Array (n_samples,) float32
Strength of each point's membership in its predicted cluster.
Raises
------
TypeError
If `clusterer` is not a mojolearn `HDBSCAN`.
ValueError
If the clusterer is not fitted, was fitted without
`prediction_data=True`, or the feature count differs from the fit's.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> model = mojolearn.HDBSCAN(prediction_data=True).fit(X)
>>> labels, strengths = mojolearn.hdbscan.approximate_predict(model, X[:10])
"""
pd = _check_clusterer(clusterer, "approximate_predict")
if clusterer.n_clusters_ == 0:
warnings.warn(
"Clusterer does not have any defined clusters, new data will be "
"automatically predicted as outliers."
)
q, _ = as_f32_c(points_to_predict, ndim=2, name="points_to_predict")
nq, d = q.shape
if d != clusterer.n_features_in_:
raise ValueError(
f"mojolearn approximate_predict: points_to_predict has {d} features, "
f"the clusterer was fit on {clusterer.n_features_in_}"
)
# cuML: `clusterer.min_samples or clusterer.min_cluster_size` (hdbscan.pyx:1322)
min_samples = clusterer.min_samples or clusterer.min_cluster_size
labels = empty((nq,), "<i4")
probs = empty((nq,), "<f4")
clusterer._extension().hdbscan_approximate_predict(
# ORDER MATCHES bindings/_mojolearn_hdbscan.mojo::hdbscan_approximate_predict_binding.
[addr_ro(clusterer._raw_data, name="training data"),
addr_ro(clusterer.core_distances_, name="core_distances_"),
addr_ro(clusterer.labels_, name="labels_"),
addr_ro(pd["lambdas"], name="tree lambdas"),
addr_ro(pd["deaths"], name="deaths"),
addr_ro(pd["selected_clusters"], name="selected_clusters"),
addr_ro(pd["index_into_children"], name="index_into_children"),
addr_ro(q, name="points_to_predict"),
addr(labels, name="labels"), addr(probs, name="probabilities")],
# m, d, n_edges, n_clusters (condensed), n_selected, nq, min_samples
[int(clusterer._raw_data.shape[0]), d, pd["n_edges"], pd["n_condensed"],
clusterer.n_clusters_, nq, int(min_samples)],
)
return labels, probs
def _check_batch_size(batch_size, where):
"""cuML's `batch_size must be > 0` (`hdbscan.pyx:1147`, `:1230`)."""
if isinstance(batch_size, bool) or not isinstance(batch_size, int):
raise TypeError(f"mojolearn {where}: batch_size must be an int, got {type(batch_size).__name__}")
if batch_size <= 0:
raise ValueError(f"mojolearn {where}: batch_size must be > 0, got {batch_size}")
def _soft_tree_addrs(pd):
"""parents, lambdas, deaths, selected_clusters, index_into_children,
exemplar_idx, exemplar_label_offsets: the order both soft bindings read."""
return [addr_ro(pd["parents"], name="tree parents"), addr_ro(pd["lambdas"], name="tree lambdas"),
addr_ro(pd["deaths"], name="deaths"), addr_ro(pd["selected_clusters"], name="selected_clusters"),
addr_ro(pd["index_into_children"], name="index_into_children"),
addr_ro(pd["exemplar_idx"], name="exemplar_idx"),
addr_ro(pd["exemplar_label_offsets"], name="exemplar_label_offsets")]
# cuML `membership_vector` (`hdbscan.pyx:1180`, `soft_clustering.cuh:501-627`).
# Float32 on every column (DEVIATION 1616,
# `hdbscan/impl/detail/soft_clustering.mojo`): cuML computes four seams in
# float64.
[docs]
def membership_vector(clusterer, points_to_predict, batch_size=4096):
"""Soft cluster membership of new points.
Follows cuML's `membership_vector`. Row `i` gives, for each selected cluster `j`, the probability that point
`i` is a member of cluster `j`: the distance to the cluster's exemplars
and the merge height in the condensed tree, combined, normalized, and
scaled by the probability that the point is in some cluster, so a row
sums to at most 1 and a point far from every cluster has a row near 0.
Computed in float32 throughout, where cuML uses float64 in some steps.
Where cuML's row overflows to NaN (duplicated points), this row is
finite.
Parameters
----------
clusterer : HDBSCAN
A model fitted with `prediction_data=True`.
points_to_predict : array-like of shape (n_samples, n_features)
New points, read as float32, with the fitted number of features.
batch_size : int, default 4096
Maximum number of points computed per call. Rows are independent, so
it does not change the result. Must be positive.
Returns
-------
membership_vectors : Array (n_samples, n_clusters_) float32
Raises
------
TypeError
If `clusterer` is not a mojolearn `HDBSCAN` or `batch_size` is not an
int.
ValueError
If the clusterer is not fitted, was fitted without
`prediction_data=True`, `batch_size` is not positive, or the feature
count differs from the fit's.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> model = mojolearn.HDBSCAN(prediction_data=True).fit(X)
>>> soft = mojolearn.hdbscan.membership_vector(model, X[:10])
"""
pd = _check_clusterer(clusterer, "membership_vector")
_check_batch_size(batch_size, "membership_vector")
q, _ = as_f32_c(points_to_predict, ndim=2, name="points_to_predict")
nq, d = q.shape
if d != clusterer.n_features_in_:
raise ValueError(
f"mojolearn membership_vector: points_to_predict has {d} features, "
f"the clusterer was fit on {clusterer.n_features_in_}"
)
n_sel = clusterer.n_clusters_
out = empty((nq, n_sel), "<f4")
if n_sel == 0 or nq == 0:
return out
# cuML: `clusterer.min_samples or clusterer.min_cluster_size` (hdbscan.pyx:1238)
min_samples = clusterer.min_samples or clusterer.min_cluster_size
m = int(clusterer._raw_data.shape[0])
tree = _soft_tree_addrs(pd)
ext = clusterer._extension()
q_addr = addr_ro(q, name="points_to_predict")
o_addr = addr(out, name="membership_vectors")
for s in range(0, nq, batch_size): # glue: one Mojo call per user batch
cnt = min(batch_size, nq - s)
ext.hdbscan_membership_vector(
# ORDER MATCHES bindings/_mojolearn_hdbscan.mojo::hdbscan_membership_vector_binding.
[addr_ro(clusterer._raw_data, name="training data"),
addr_ro(clusterer.core_distances_, name="core_distances_"),
addr_ro(clusterer.labels_, name="labels_")] + tree
+ [q_addr + s * d * 4, o_addr + s * n_sel * 4],
# m, d, n_edges, n_clusters (condensed), n_selected, n_exemplars, nq, min_samples
[m, d, pd["n_edges"], pd["n_condensed"], n_sel, pd["n_exemplars"], cnt, int(min_samples)],
)
return out
# cuML `all_points_membership_vectors` (`hdbscan.pyx:1114`,
# `soft_clustering.cuh:385-482`). Float32 on every column (DEVIATION 1616).
[docs]
def all_points_membership_vectors(clusterer, batch_size=4096):
"""Soft cluster membership of every training point.
Follows cuML's `all_points_membership_vectors`, using the fact that the
training points are already in the condensed tree. Each row has the
same meaning as a row of `membership_vector`. Computed in float32
throughout.
Parameters
----------
clusterer : HDBSCAN
A model fitted with `prediction_data=True`.
batch_size : int, default 4096
Maximum number of training rows computed per call. Rows are
independent, so it does not change the result. Must be positive.
Returns
-------
membership_vectors : Array (n_samples, n_clusters_) float32
With no cluster the shape is (n_samples, 0). cuML returns a 1-D
zero vector of length n_samples there instead.
Raises
------
TypeError
If `clusterer` is not a mojolearn `HDBSCAN` or `batch_size` is not an
int.
ValueError
If the clusterer is not fitted, was fitted without
`prediction_data=True`, or `batch_size` is not positive.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> model = mojolearn.HDBSCAN(prediction_data=True).fit(X)
>>> soft = mojolearn.hdbscan.all_points_membership_vectors(model)
"""
pd = _check_clusterer(clusterer, "all_points_membership_vectors")
_check_batch_size(batch_size, "all_points_membership_vectors")
x = clusterer._raw_data
m, d = x.shape
n_sel = clusterer.n_clusters_
out = empty((m, n_sel), "<f4")
if n_sel == 0:
return out
tree = _soft_tree_addrs(pd)
ext = clusterer._extension()
o_addr = addr(out, name="membership_vectors")
for s in range(0, m, batch_size): # glue: one Mojo call per user batch
cnt = min(batch_size, m - s)
ext.hdbscan_all_points_membership_vectors(
# ORDER MATCHES bindings/_mojolearn_hdbscan.mojo::hdbscan_all_points_membership_vectors_binding.
[addr_ro(x, name="training data")] + tree + [o_addr + s * n_sel * 4],
# m, d, n_edges, n_clusters (condensed), n_selected, n_exemplars, row0, count
[int(m), int(d), pd["n_edges"], pd["n_condensed"], n_sel, pd["n_exemplars"], s, cnt],
)
return out
__all__ = ["HDBSCAN", "approximate_predict", "membership_vector", "all_points_membership_vectors"]