Source code for mojolearn._expansion_cluster

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""THE CLUSTER LANE'S PUBLIC DOOR.

Owned by the `cluster` expansion lane. `mojolearn/__init__.py` imports this
module and makes every name in `__all__` public as `mojolearn.<name>`; a name
that is already public is refused at import. Put the classes here, or import
them here from the lane's own modules. Two optional hooks route a SAVED model
to the CPU (`mojolearn.host_model`); `_classical_host.py` merges them when it is
first imported, after the package, so both may rely on every module existing:

  CLASSICAL_HOST_BASENAMES     {"_mojolearn_x_cluster": "_mojolearn_x_cluster_host"}
  def classical_host_formats() -> {"mojolearn-<format>-1": {"Estimator": HostEstimator}}
                               import `_classical_host` INSIDE it and subclass
                               its `_HostBound`; never import it at module level
"""
import ctypes

from . import _portable_math as math

from . import _backend, _buffer
from . import _portable_math as _pm
from ._array import Array
from ._mode import NumericModeMixin

__all__ = ["MiniBatchKMeans", "BisectingKMeans", "MeanShift", "OPTICS", "AffinityPropagation", "BayesianGaussianMixture"]

# x_cluster/entries.mojo: the entry numbers of `x_cluster_call`
_E_NEAREST, _E_DISTANCES, _E_MINIBATCH, _E_BISECT, _E_BISECT_PREDICT, _E_MEANSHIFT, _E_OPTICS, _E_AFFINITY, _E_BGMM, _E_BGMM_SCORE, _E_MINIBATCH_PARTIAL = 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10


def _f32(X, name="X", scan=True):
    x, _ = _buffer.as_f32_c(X, ndim=2, name=name)
    if x.shape[0] < 1 or x.shape[1] < 1:
        raise ValueError(f"mojolearn: {name} must be a non-empty 2-D array, got shape {x.shape}")
    # the base binding's native scan (`_buffer.all_finite`); scan=False only
    # when the binding scans X itself (MiniBatchKMeans, MBK_FAST_DEVSCAN)
    if scan and not _buffer.all_finite(x):
        raise ValueError(f"mojolearn: {name} contains NaN or infinity")
    return x


def _aux(*parts):
    """The parts (Arrays or array-likes, None skipped) end to end as one
    float32 Array: byte copies, no Python pass over the values (lane
    pyglue-numeric: the aux blocks were Python lists of every value)."""
    arrs = [_buffer.as_f32_c(p, ndim=None, name="aux")[0] for p in parts if p is not None]  # glue: aux parts as float32 blocks
    total = sum(a.size for a in arrs)   # glue: the few part sizes
    if not total:
        return None
    out = _buffer.empty((total,), "<f4")
    off = 0
    for a in arrs:                      # glue: one copy per part
        if a.size:
            ctypes.memmove(out._addr + 4 * off, _buffer.addr_ro(a, name="aux"), 4 * a.size)
        off += a.size
    return out


def _weights(sample_weight, n):
    """sample_weight as a float32 Array of n values (None stays None)."""
    if sample_weight is None:
        return None
    w = _buffer.as_f32_c(sample_weight, ndim=None, name="sample_weight")[0]
    if w.size != n:
        raise ValueError(f"sample_weight has {w.size} values for {n} samples")
    return w


def _seed(random_state):
    if random_state is None:
        return 0
    if isinstance(random_state, bool) or not isinstance(random_state, int) or random_state < 0:
        raise ValueError("mojolearn: random_state must be None or a non-negative int "
                         "(the lane's seeded host stream; a RandomState object is not taken)")
    return int(random_state) & ((1 << 63) - 1)


def _callable_init(init, X, k, random_state):
    """scikit-learn's callable `init` (`_kmeans.py::_init_centroids`:
    `init(X, n_clusters, random_state=random_state)`): called ONCE on the
    host with the fit's rows, a NumPy RandomState seeded by `random_state`
    when NumPy is present (the int otherwise), and its centers then take
    the array path, so the fit's bits depend only on what it returned.
    scikit-learn's MiniBatchKMeans hands it an `init_size` subsample drawn
    from its own stream; this hands it every row."""
    rs = random_state
    try:
        from ._optional_numpy import require_numpy
        _np = require_numpy('_expansion_cluster')
        X = _np.asarray(X)
        rs = _np.random.RandomState(random_state)
    except (ImportError, AttributeError):
        pass
    return init(X, k, random_state=rs)


class _XCluster(NumericModeMixin):
    """The lane's shared call: `x_cluster_call` on `_mojolearn_x_cluster`
    (the CPU host binding `_mojolearn_x_cluster_host` on a CPU-only install)."""
    _BINDING = "_mojolearn_x_cluster"

    def _call(self, which, x, a, ip, fp=()):
        b = self._bind()
        xa = _buffer.addr_ro(x, name="X") if x is not None else 0
        xn = x.size if x is not None else 0
        aa = _buffer.addr_ro(a, name="aux") if a is not None and a.size else 0
        an = a.size if a is not None else 0
        f, i, s = b.x_cluster_call(which, xa, xn, aa, an, [int(v) for v in ip], [float(v) for v in fp])  # glue: integer and float binding params
        return f, i, s

    def _check_fitted(self, attr):
        if getattr(self, attr, None) is None:
            raise RuntimeError(f"this {type(self).__name__} is not fitted yet")

    def _input_like_fit(self, X):
        x = _f32(X)
        if x.shape[1] != self.n_features_in_:
            raise ValueError(f"mojolearn: X has {x.shape[1]} features, {type(self).__name__} "
                             f"was fitted with {self.n_features_in_}")
        return x

    def _nearest(self, x, centers):
        n, d = x.shape
        f, i, _ = self._call(_E_NEAREST, x, centers, [n, centers.shape[0], d])
        return Array._from_flat(i[0], (n,), "<i4"), f[0]

    def _distances(self, x, centers):
        n, d = x.shape
        k = centers.shape[0]
        f, _, _ = self._call(_E_DISTANCES, x, centers, [n, k, d])
        return Array._from_flat(f[0], (n, k), "<f4")


class _CentersMixin:
    """predict / transform / score against `cluster_centers_` (squared
    euclidean nearest, the lowest index on a tie)."""

    def predict(self, X):
        """Assign each row of `X` to its nearest cluster center.

        The nearest center is by squared euclidean distance; a tie goes to
        the lowest center index.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Rows to label, converted to float32. Must have the number of
            features seen in `fit` and contain no NaN or infinity.

        Returns
        -------
        labels : Array of shape (n_samples,), int32
            Index of the nearest center for each row.

        Raises
        ------
        RuntimeError
            If the model is not fitted.
        ValueError
            If `X` is empty, has the wrong number of features, or contains
            NaN or infinity.
        """
        self._check_fitted("cluster_centers_")
        return self._nearest(self._input_like_fit(X), self.cluster_centers_)[0]

    def transform(self, X):
        """Distance from each row of `X` to every cluster center.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Rows to transform, converted to float32.

        Returns
        -------
        distances : Array of shape (n_samples, n_clusters), float32
            Euclidean distance from each row to each center.

        Raises
        ------
        RuntimeError
            If the model is not fitted.
        """
        self._check_fitted("cluster_centers_")
        return self._distances(self._input_like_fit(X), self.cluster_centers_)

    def fit_predict(self, X, y=None, sample_weight=None):
        """Fit the model on `X` and return the training labels.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training rows, converted to float32.
        y : ignored
            Present for scikit-learn compatibility.
        sample_weight : ignored
            Accepted for scikit-learn compatibility but not passed to the
            fit. Call ``fit(X, sample_weight=...)`` and read `labels_` to
            fit with weights.

        Returns
        -------
        labels : Array of shape (n_samples,), int32
            The fitted `labels_`.
        """
        return self.fit(X).labels_

    def fit_transform(self, X, y=None, sample_weight=None):
        """Fit the model on `X` and return the distances to the centers.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training rows, converted to float32.
        y : ignored
            Present for scikit-learn compatibility.
        sample_weight : ignored
            Accepted for scikit-learn compatibility but not passed to the
            fit.

        Returns
        -------
        distances : Array of shape (n_samples, n_clusters), float32
            Euclidean distance from each training row to each center.
        """
        return self.fit(X).transform(X)

    def score(self, X, y=None, sample_weight=None):
        """Opposite of the inertia of `X` under the fitted centers.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Rows to score, converted to float32.
        y : ignored
            Present for scikit-learn compatibility.
        sample_weight : ignored
            Accepted for scikit-learn compatibility; every row counts once.

        Returns
        -------
        score : float
            Minus the sum of squared distances from each row to its nearest
            center (larger is better).

        Raises
        ------
        RuntimeError
            If the model is not fitted.
        """
        self._check_fitted("cluster_centers_")
        x = self._input_like_fit(X)
        # the float-float fold of the distances, in the binding
        n, d = x.shape
        _, _, sc = self._call(_E_NEAREST, x, self.cluster_centers_, [n, self.cluster_centers_.shape[0], d, 1])
        return -sc[0]


# Reference: scikit-learn `cluster/_kmeans.py` (MiniBatchKMeans),
# `_k_means_minibatch.pyx::update_center_dense`. The batch assignment runs on
# the device; the batch draws, the center update in scikit-learn's order, the
# random reassignment of low-count centers and the early stop (exponentially
# weighted batch inertia, `max_no_improvement`, `tol`) are host code. The batch
# order comes from `random_state` through the lane's splitmix64 stream, not
# NumPy's Mersenne Twister. A callable init is called once on the host and its
# centers take the array path (`_callable_init`); see
# x_cluster/NOT_IMPLEMENTED.tsv.
[docs] class MiniBatchKMeans(_CentersMixin, _XCluster): """Mini-batch k-means clustering. Follows scikit-learn's ``MiniBatchKMeans``: each step assigns a random batch of rows to the nearest center and moves the centers, low-count centers are randomly reassigned, and the fit stops early on the smoothed batch inertia. Batches are drawn from this library's own seeded random stream rather than NumPy's, so results agree with scikit-learn's within a tolerance, not bit for bit. Input is converted to float32. Parameters ---------- n_clusters : int, default 8 Number of clusters. Must be between 1 and the number of samples. init : {'k-means++', 'random'}, array-like or callable, default 'k-means++' How the centers start. 'k-means++' is greedy k-means++, as in scikit-learn. 'random' picks `n_clusters` distinct rows of the init sample, drawn by weight. An array of shape (n_clusters, n_features) gives the starting centers. A callable is called once as ``init(X, n_clusters, random_state=rs)`` with every row of `X` (scikit-learn passes a subsample) and must return such an array; `rs` is a NumPy ``RandomState`` seeded by `random_state` when NumPy is installed, otherwise the int. max_iter : int, default 100 Maximum number of passes over the data. batch_size : int, default 1024 Rows per mini-batch. verbose : int, default 0 Accepted for scikit-learn compatibility; nothing is printed. compute_labels : bool, default True Accepted for scikit-learn compatibility; `labels_` is always computed. random_state : int or None, default None Non-negative seed for the batch draws and initialization. None means 0, so repeated fits give the same result. A NumPy ``RandomState`` is not accepted. tol : float, default 0.0 Early-stop tolerance on center movement, as in scikit-learn. max_no_improvement : int or None, default 10 Stop after this many steps without improvement of the smoothed batch inertia. None disables this early stop. init_size : int or None, default None Number of rows used for initialization. None uses scikit-learn's rule (3 * batch_size, at least 3 * n_clusters, at most n_samples). n_init : int or 'auto', default 'auto' Number of initializations; the best one is kept. 'auto' means 3 for ``init='random'`` and 1 otherwise. reassignment_ratio : float, default 0.01 Controls how often low-count centers are reassigned, as in scikit-learn. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- cluster_centers_ : Array of shape (n_clusters, n_features), float32 Cluster centers. counts_ : Array of shape (n_clusters,), float32 Weighted number of rows assigned to each center over the fit. labels_ : Array of shape (n_samples,), int32 Cluster of each training row. inertia_ : float Sum of squared distances of the training rows to their nearest center. n_steps_ : int Number of mini-batch steps run. n_iter_ : int Number of passes over the data. 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.MiniBatchKMeans(n_clusters=4, random_state=0).fit(X) >>> labels = model.predict(X) """
[docs] def __init__(self, n_clusters=8, *, init="k-means++", max_iter=100, batch_size=1024, verbose=0, compute_labels=True, random_state=None, tol=0.0, max_no_improvement=10, init_size=None, n_init="auto", reassignment_ratio=0.01): self.n_clusters = n_clusters self.init = init self.max_iter = max_iter self.batch_size = batch_size self.verbose = verbose self.compute_labels = compute_labels self.random_state = random_state self.tol = tol self.max_no_improvement = max_no_improvement self.init_size = init_size self.n_init = n_init self.reassignment_ratio = reassignment_ratio
def _devscan(self): """lane/apple-fast-s-linalg MBK_FAST_DEVSCAN (FAST + Apple default, rollback _OFF; x_cluster/minibatch_ptr.mojo): whether the binding scans X for NaN/inf itself (on the device copy), so the host scan is skipped.""" try: return int(self._bind().x_cluster_mbk_devscan()) == 1 except Exception: return False
[docs] def fit(self, X, y=None, sample_weight=None): """Fit the centers on `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, converted to float32. NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. sample_weight : array-like of shape (n_samples,) or None, default None Row weights. As in scikit-learn, they weight the k-means++ potentials, the init scoring and the batch draws. Returns ------- self : MiniBatchKMeans The fitted estimator. Raises ------ ValueError If `n_clusters` is outside [1, n_samples], `init` is an unknown string or an array of the wrong shape, `n_init` is below 1, or `X` contains NaN or infinity. """ devscan = self._devscan() x = _f32(X, scan=not devscan) n, d = x.shape k = int(self.n_clusters) if k < 1 or k > n: raise ValueError(f"n_samples={n} should be >= n_clusters={k}.") weights = _weights(sample_weight, n) init_arr = None if isinstance(self.init, str): if self.init not in ("k-means++", "random"): raise ValueError(f"init must be 'k-means++', 'random' or an array, got {self.init!r}") else: init_arr = _f32(_callable_init(self.init, X, k, self.random_state) if callable(self.init) else self.init, "init") if init_arr.shape != (k, d): raise ValueError(f"The shape of the initial centers {init_arr.shape} does not match " f"the number of clusters {k} and features {d}.") n_init = self.n_init if n_init == "auto": n_init = 3 if (init_arr is None and self.init == "random") else 1 n_init = int(n_init) if n_init < 1: raise ValueError("n_init must be >= 1") mni = -1 if self.max_no_improvement is None else int(self.max_no_improvement) ip = [n, d, k, int(self.max_iter), int(self.batch_size), mni, 0 if self.init_size is None else int(self.init_size), n_init, 1 if init_arr is not None else 0, _seed(self.random_state), 1 if (init_arr is None and self.init == "random") else 0, 1 if weights is not None else 0] a = _aux(init_arr, weights) if devscan: ip.append(1) # ip[12]: the binding owes the NaN/inf scan try: f, i, s = self._call(_E_MINIBATCH, x, a, ip, [float(self.tol), float(self.reassignment_ratio)]) except Exception as e: if devscan and "contains NaN or infinity" in str(e): raise ValueError("mojolearn: X contains NaN or infinity") from None raise self.cluster_centers_ = Array._from_flat(f[0], (k, d), "<f4") self.counts_ = Array._from_flat(f[1], (k,), "<f4") self.labels_ = Array._from_flat(i[0], (n,), "<i4") self.inertia_ = float(s[0]) self.n_steps_ = int(s[1]) self.n_iter_ = int(s[2]) self.n_features_in_ = d return self
[docs] def partial_fit(self, X, y=None, sample_weight=None): """Run one mini-batch step on all of `X`. As in scikit-learn, the first call initializes the centers from `init_size` rows of `X` using `init`. Later calls continue from the current centers (including those of an earlier `fit`); the random stream and the reassignment counter carry over between calls. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows for this step, converted to float32. y : ignored Present for scikit-learn compatibility. sample_weight : array-like of shape (n_samples,) or None, default None Row weights. Returns ------- self : MiniBatchKMeans The updated estimator. `labels_` and `inertia_` describe this batch. Raises ------ ValueError If `X` has a different number of features than earlier calls, or on the first call if `n_clusters` exceeds the number of rows or `init` is invalid. """ x = _f32(X) n, d = x.shape k = int(self.n_clusters) first = getattr(self, "cluster_centers_", None) is None if not first and not hasattr(self, "_partial_state"): # a model from fit(): continue from its centers and counts, as sklearn n0 = min(int(self.batch_size), n) self._partial_batch = n0 self._partial_init = (0, 0) seed = _seed(self.random_state) self._partial_state = ((seed >> 32, seed & 0xFFFFFFFF), 0) if not first and d != self.n_features_in_: raise ValueError(f"X has {d} features, MiniBatchKMeans was fitted with {self.n_features_in_}") aux = [] # glue: the parts of the state block if first: if k < 1 or k > n: raise ValueError(f"n_samples={n} should be >= n_clusters={k}.") batch_eff = min(int(self.batch_size), n) init_size = 3 * batch_eff if self.init_size is None else int(self.init_size) if init_size < k: init_size = 3 * k init_size = min(init_size, n) if isinstance(self.init, str): if self.init not in ("k-means++", "random"): raise ValueError(f"init must be 'k-means++', 'random' or an array, got {self.init!r}") mode = 0 if self.init == "k-means++" else 1 else: ia = _f32(_callable_init(self.init, X, k, self.random_state) if callable(self.init) else self.init, "init") if ia.shape != (k, d): raise ValueError(f"The shape of the initial centers {ia.shape} does not match " f"the number of clusters {k} and features {d}.") aux.append(ia) mode = 2 seed = _seed(self.random_state) state = (seed >> 32, seed & 0xFFFFFFFF) since = 0 self._partial_batch = batch_eff self._partial_init = (mode, init_size) self.n_steps_ = 0 else: mode, init_size = self._partial_init batch_eff = self._partial_batch state, since = self._partial_state aux += [self.cluster_centers_, self.counts_] aux.append(_weights(sample_weight, n)) ip = [n, d, k, 1 if first else 0, mode, init_size, batch_eff, since, state[0], state[1], 1 if sample_weight is not None else 0] a = _aux(*aux) f, i, s = self._call(_E_MINIBATCH_PARTIAL, x, a, ip, [float(self.reassignment_ratio)]) self.cluster_centers_ = Array._from_flat(f[0], (k, d), "<f4") self.counts_ = Array._from_flat(f[1], (k,), "<f4") self.labels_ = Array._from_flat(i[0], (n,), "<i4") self.inertia_ = float(s[0]) self._partial_state = ((int(s[2]), int(s[3])), int(s[1])) self.n_steps_ = getattr(self, "n_steps_", 0) + 1 self.n_features_in_ = d return self
# Reference: scikit-learn `cluster/_bisect_k_means.py`. The 2-means is this # library's KMeans (cuVS's Lloyd, the kmeans identity lanes' pair).
[docs] class BisectingKMeans(_CentersMixin, _XCluster): """Bisecting k-means clustering. Starting from one cluster, the leaf with the highest score is split in two by 2-means, `n_clusters - 1` times. The 2-means step is this library's ``KMeans``, so `tol` uses its centroid-shift rule and the 'random' and 'k-means++' starts are its own; results agree with scikit-learn's within a tolerance. Input is converted to float32. Parameters ---------- n_clusters : int, default 8 Number of clusters. Must be between 1 and the number of samples. init : {'random', 'k-means++'}, default 'random' Start of each 2-means split. Any other value raises NotImplementedError. n_init : int, default 1 Number of 2-means restarts per split; the best is kept. Must be at least 1. random_state : int or None, default None Non-negative seed. None means 0, so repeated fits give the same result. A NumPy ``RandomState`` is not accepted. max_iter : int, default 300 Maximum iterations of each 2-means. verbose : int, default 0 Accepted for scikit-learn compatibility; nothing is printed. tol : float, default 1e-4 Convergence tolerance of each 2-means, on the centroid shift. copy_x : bool, default True Accepted for scikit-learn compatibility; `X` is never modified. algorithm : {'lloyd', 'elkan'}, default 'lloyd' Accepted for scikit-learn compatibility; both run the same Lloyd iterations. bisecting_strategy : {'biggest_inertia', 'largest_cluster'}, default 'biggest_inertia' Which leaf to split next: the one with the largest inertia, or the one with the most rows. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- cluster_centers_ : Array of shape (n_clusters, n_features), float32 Centers of the leaf clusters. labels_ : Array of shape (n_samples,), int32 Cluster of each training row. inertia_ : float Sum of squared distances of the training rows to their cluster center. 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.BisectingKMeans(n_clusters=4, random_state=0).fit(X) >>> labels = model.predict(X) """
[docs] def __init__(self, n_clusters=8, *, init="random", n_init=1, random_state=None, max_iter=300, verbose=0, tol=1e-4, copy_x=True, algorithm="lloyd", bisecting_strategy="biggest_inertia"): self.n_clusters = n_clusters self.init = init self.n_init = n_init self.random_state = random_state self.max_iter = max_iter self.verbose = verbose self.tol = tol self.copy_x = copy_x self.algorithm = algorithm self.bisecting_strategy = bisecting_strategy
[docs] def fit(self, X, y=None, sample_weight=None): """Build the bisection tree on `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, converted to float32. NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. sample_weight : array-like of shape (n_samples,) or None, default None Row weights. Returns ------- self : BisectingKMeans The fitted estimator. Raises ------ NotImplementedError If `init` is not 'random' or 'k-means++'. ValueError If `bisecting_strategy` or `algorithm` is unknown, `n_clusters` is outside [1, n_samples], `n_init` is below 1, or `X` contains NaN or infinity. """ if self.init not in ("random", "k-means++"): raise NotImplementedError(f"mojolearn BisectingKMeans: init={self.init!r} is not implemented; " "'random' or 'k-means++'") if self.bisecting_strategy not in ("biggest_inertia", "largest_cluster"): raise ValueError(f"bisecting_strategy must be 'biggest_inertia' or 'largest_cluster', " f"got {self.bisecting_strategy!r}") if self.algorithm not in ("lloyd", "elkan"): raise ValueError(f"algorithm must be 'lloyd' or 'elkan', got {self.algorithm!r}") x = _f32(X) n, d = x.shape k = int(self.n_clusters) if k < 1 or k > n: raise ValueError(f"n_samples={n} should be >= n_clusters={k}.") if int(self.n_init) < 1: raise ValueError("n_init must be >= 1") ip = [n, d, k, int(self.n_init), 1 if self.init == "random" else 0, int(self.max_iter), _seed(self.random_state), 1 if self.bisecting_strategy == "largest_cluster" else 0] w = None if sample_weight is not None: w = _weights(sample_weight, n) f, i, s = self._call(_E_BISECT, x, w, ip, [float(self.tol)]) self.cluster_centers_ = Array._from_flat(f[0], (k, d), "<f4") m = len(i[1]) // 3 self._tree_centers = Array._from_flat(f[1], (m, d), "<f4") self._tree_nodes = Array._from_flat(i[1], (m, 3), "<i4") self.labels_ = Array._from_flat(i[0], (n,), "<i4") self.inertia_ = float(s[0]) self.n_features_in_ = d return self
[docs] def predict(self, X): """Assign each row of `X` to a leaf cluster. As in scikit-learn, each row descends the bisection tree, going to the nearer child center at each split (the left child on a tie). This can differ from the nearest leaf center. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to label, converted to float32. Returns ------- labels : Array of shape (n_samples,), int32 Leaf cluster of each row. Raises ------ RuntimeError If the model is not fitted. """ self._check_fitted("cluster_centers_") x = self._input_like_fit(X) n, d = x.shape nodes = [int(v) for v in _buffer.flat_bytes(self._tree_nodes).cast("i")] # glue: fitted bisect tree node words _, i, _ = self._call(_E_BISECT_PREDICT, x, self._tree_centers, [n, d] + nodes) return Array._from_flat(i[0], (n,), "<i4")
# Reference: scikit-learn `cluster/_mean_shift.py`. Every seed's shift loop is # one device thread over all rows in order; the default bandwidth is # scikit-learn's `estimate_bandwidth` from the device's exact row order # statistic.
[docs] class MeanShift(_XCluster): """Mean shift clustering with a flat kernel. Follows scikit-learn's ``MeanShift``: each seed is shifted to the mean of the rows within `bandwidth` until it stops moving, centers are merged by intensity (a center within the bandwidth of a stronger one is dropped), and each row is labeled with its nearest center (the lowest index on a tie). Input is converted to float32. Parameters ---------- bandwidth : float or None, default None Radius of the flat kernel; must be positive. None estimates it as scikit-learn's ``estimate_bandwidth`` does (quantile 0.3 over all rows). seeds : array-like of shape (n_seeds, n_features) or None, default None Starting points. None uses every row, or the bins when `bin_seeding` is True. bin_seeding : bool, default False Seed from the rows binned on a grid of size `bandwidth` (rounding half to even), as in scikit-learn. min_bin_freq : int, default 1 With `bin_seeding`, only bins with at least this many rows become seeds. cluster_all : bool, default True If True every row is labeled with its nearest center. If False, rows farther than `bandwidth` from every center get label -1. n_jobs : None Accepted for scikit-learn compatibility; ignored. max_iter : int, default 300 Maximum shifts per seed. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- cluster_centers_ : Array of shape (n_clusters, n_features), float32 Cluster centers. labels_ : Array of shape (n_samples,), int32 Cluster of each training row. bandwidth_ : float Bandwidth used, given or estimated. n_iter_ : int Largest number of shifts over the seeds. 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.MeanShift(bin_seeding=True).fit(X) >>> labels = model.labels_ """
[docs] def __init__(self, *, bandwidth=None, seeds=None, bin_seeding=False, min_bin_freq=1, cluster_all=True, n_jobs=None, max_iter=300): self.bandwidth = bandwidth self.seeds = seeds self.bin_seeding = bin_seeding self.min_bin_freq = min_bin_freq self.cluster_all = cluster_all self.n_jobs = n_jobs self.max_iter = max_iter
[docs] def fit(self, X, y=None): """Find the cluster centers of `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, converted to float32. NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. Returns ------- self : MeanShift The fitted estimator. Raises ------ ValueError If `bandwidth` is not positive, `seeds` has a different number of features than `X`, or `X` contains NaN or infinity. """ x = _f32(X) n, d = x.shape seeds = None if self.seeds is not None: seeds = _f32(self.seeds, "seeds") if seeds.shape[1] != d: raise ValueError(f"seeds have {seeds.shape[1]} features, X has {d}") bw = 0.0 if self.bandwidth is not None: bw = float(self.bandwidth) if not bw > 0: raise ValueError(f"bandwidth needs to be greater than zero or None, got {bw:f}") ip = [n, d, 0 if seeds is None else seeds.shape[0], 1 if self.cluster_all else 0, int(self.max_iter), 1 if self.bin_seeding else 0, int(self.min_bin_freq)] f, i, s = self._call(_E_MEANSHIFT, x, seeds, ip, [bw]) kc = int(s[2]) self.cluster_centers_ = Array._from_flat(f[0], (kc, d), "<f4") self.labels_ = Array._from_flat(i[0], (n,), "<i4") self.bandwidth_ = float(s[0]) self.n_iter_ = int(s[1]) self.n_features_in_ = d return self
[docs] def predict(self, X): """Assign each row of `X` to its nearest cluster center. A tie goes to the lowest center index. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to label, converted to float32. Returns ------- labels : Array of shape (n_samples,), int32 Index of the nearest center for each row. Raises ------ RuntimeError If the model is not fitted. """ self._check_fitted("cluster_centers_") return self._nearest(self._input_like_fit(X), self.cluster_centers_)[0]
[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) Training rows, converted to float32. y : ignored Present for scikit-learn compatibility. Returns ------- labels : Array of shape (n_samples,), int32 The fitted `labels_`. """ return self.fit(X).labels_
# Reference: scikit-learn `cluster/_optics.py`. The n x n distances and the # core distances are the device's; the ordering loop is the reference's # sequential one with the lowest index on a reachability tie; the xi and # dbscan extractions are the reference's.
[docs] class OPTICS(_XCluster): """OPTICS density-based clustering. Follows scikit-learn's ``OPTICS``: it orders the rows by reachability and extracts clusters with the 'xi' or 'dbscan' method. The full n_samples x n_samples distance matrix is computed, so memory grows with the square of the number of rows. Computation is float32 throughout, without scikit-learn's rounding of distances to float precision, so reachability agrees with scikit-learn's within a tolerance. Like scikit-learn's, it has no `predict`. Parameters ---------- min_samples : int > 1 or float in (0, 1], default 5 Neighborhood size for a core point, as a count or as a fraction of the number of samples (at least 2). max_eps : float, default inf Largest neighborhood radius considered. metric : str, default 'minkowski' One of 'minkowski', 'euclidean', 'l2', 'manhattan', 'cityblock', 'l1', 'chebyshev', 'infinity', 'cosine' or 'precomputed'. Any other metric, including a callable, raises NotImplementedError. With 'precomputed', `X` is a square distance matrix. p : float, default 2 Power of the Minkowski metric; must be at least 1 (inf gives Chebyshev). metric_params : dict or None, default None Only the key 'p' is read; it overrides `p`. cluster_method : {'xi', 'dbscan'}, default 'xi' How clusters are extracted from the reachability ordering. eps : float or None, default None Radius for ``cluster_method='dbscan'``; None means `max_eps`. Must not exceed `max_eps`. xi : float in [0, 1], default 0.05 Minimum steepness for ``cluster_method='xi'``. predecessor_correction : bool, default True Apply scikit-learn's predecessor correction in the xi extraction. min_cluster_size : int > 1, float in (0, 1] or None, default None Minimum cluster size for the xi method; None means `min_samples`. algorithm : str, default 'auto' Accepted for scikit-learn compatibility; ignored. leaf_size : int, default 30 Accepted for scikit-learn compatibility; ignored. memory : None Accepted for scikit-learn compatibility; ignored. n_jobs : None Accepted for scikit-learn compatibility; ignored. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- labels_ : Array of shape (n_samples,), int32 Cluster of each row; -1 marks noise. reachability_ : Array of shape (n_samples,), float32 Reachability distance of each row, indexed by row. ordering_ : Array of shape (n_samples,), int32 Cluster ordering of the rows. core_distances_ : Array of shape (n_samples,), float32 Distance at which each row becomes a core point. predecessor_ : Array of shape (n_samples,), int32 Row from which each row was reached; -1 for none. cluster_hierarchy_ : Array of shape (n_clusters, 2), int32 Start and end positions (in `ordering_`) of each cluster. Set only with ``cluster_method='xi'``. n_features_in_ : int Number of features seen in `fit`. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((500, 4), dtype=np.float32) >>> labels = mojolearn.OPTICS(min_samples=10).fit_predict(X) """
[docs] def __init__(self, *, min_samples=5, max_eps=float("inf"), metric="minkowski", p=2, metric_params=None, cluster_method="xi", eps=None, xi=0.05, predecessor_correction=True, min_cluster_size=None, algorithm="auto", leaf_size=30, memory=None, n_jobs=None): self.min_samples = min_samples self.max_eps = max_eps self.metric = metric self.p = p self.metric_params = metric_params self.cluster_method = cluster_method self.eps = eps self.xi = xi self.predecessor_correction = predecessor_correction self.min_cluster_size = min_cluster_size self.algorithm = algorithm self.leaf_size = leaf_size self.memory = memory self.n_jobs = n_jobs
_METRICS = {"euclidean": 0, "l2": 0, "manhattan": 1, "cityblock": 1, "l1": 1, "chebyshev": 2, "infinity": 2, "cosine": 4, "precomputed": 5} def _metric_code(self): """(code, p): euclidean at p=2 keeps the squared-distance route (-1).""" m = self.metric if self.metric_params: p = float(self.metric_params.get("p", self.p)) else: p = float(self.p) if self.p is not None else 2.0 if m == "minkowski": if not p >= 1: raise ValueError(f"p must be >= 1 for minkowski, got {p}") if p == 2: return -1, 2.0 if p == 1: return 1, 1.0 if p == float("inf"): return 2, p return 3, p if m in ("euclidean", "l2"): return -1, 2.0 if m not in self._METRICS: raise NotImplementedError(f"mojolearn OPTICS: metric={m!r} is not implemented; one of " f"{sorted(self._METRICS) + ['minkowski']} (x_cluster/NOT_IMPLEMENTED.tsv)") return self._METRICS[m], p @staticmethod def _size(v, n, name): if isinstance(v, bool) or v is None: raise ValueError(f"{name} must be an int >= 2 or a float in (0, 1]") if isinstance(v, int) or (isinstance(v, float) and v > 1): if int(v) != v or v < 2 or v > n: raise ValueError(f"{name} must be no greater than the number of samples ({n}) and >= 2, got {v}") return int(v) if not 0 < v <= 1: raise ValueError(f"{name} must be in (0, 1], got {v}") return max(2, int(v * n))
[docs] def fit(self, X, y=None): """Compute the reachability ordering of `X` and extract clusters. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, or a square distance matrix with ``metric='precomputed'``. Converted to float32; NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. Returns ------- self : OPTICS The fitted estimator. Raises ------ NotImplementedError If `metric` is not supported. ValueError If `cluster_method`, `min_samples`, `min_cluster_size`, `eps`, `xi` or `p` is out of range, or a precomputed matrix is not square. """ metric, pw = self._metric_code() if self.cluster_method not in ("xi", "dbscan"): raise ValueError(f"cluster_method must be 'xi' or 'dbscan', got {self.cluster_method!r}") x = _f32(X) n, d = x.shape ms = self._size(self.min_samples, n, "min_samples") mcs = ms if self.min_cluster_size is None else self._size(self.min_cluster_size, n, "min_cluster_size") max_eps = float(self.max_eps) eps = max_eps if self.eps is None else float(self.eps) if self.cluster_method == "dbscan" and eps > max_eps: raise ValueError(f"Specify an epsilon smaller than {max_eps}. Got {eps}.") if not 0 <= float(self.xi) <= 1: raise ValueError("xi must be in [0, 1]") if metric == 5 and n != d: raise ValueError(f"metric='precomputed' needs a square distance matrix, got {(n, d)}") ip = [n, d, ms, mcs, 0 if self.cluster_method == "xi" else 1, 1 if self.predecessor_correction else 0, metric] f, i, _ = self._call(_E_OPTICS, x, None, ip, [max_eps, float(self.xi), eps, pw]) self.core_distances_ = Array._from_flat(f[0], (n,), "<f4") self.reachability_ = Array._from_flat(f[1], (n,), "<f4") self.ordering_ = Array._from_flat(i[0], (n,), "<i4") self.predecessor_ = Array._from_flat(i[1], (n,), "<i4") self.labels_ = Array._from_flat(i[2], (n,), "<i4") if self.cluster_method == "xi": self.cluster_hierarchy_ = Array._from_flat(i[3], (len(i[3]) // 2, 2), "<i4") self.n_features_in_ = d return self
[docs] def fit_predict(self, X, y=None): """Fit the model on `X` and return the cluster labels. Parameters ---------- X : array-like of shape (n_samples, n_features) 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_`; -1 marks noise. """ return self.fit(X).labels_
# Reference: scikit-learn `cluster/_affinity_propagation.py`. The # responsibility and availability updates run on the device over the resident # n x n matrices; the convergence window, the exemplar refinement and the # labels are the reference's. The tie noise is the reference's formula with # normals from the lane's seeded stream, not NumPy's.
[docs] class AffinityPropagation(_XCluster): """Affinity propagation clustering. Follows scikit-learn's ``AffinityPropagation``: responsibility and availability messages are exchanged until the set of exemplars is stable for `convergence_iter` iterations. The similarity is minus the squared euclidean distance, or a precomputed matrix. The small noise that breaks ties is drawn from this library's own seeded random stream rather than NumPy's, so results agree with scikit-learn's within a tolerance. Memory grows with the square of the number of rows. Input is converted to float32. Parameters ---------- damping : float in [0.5, 1), default 0.5 Damping of the message updates. max_iter : int, default 200 Maximum number of iterations; must be at least 1. convergence_iter : int, default 15 Number of iterations with no change in the exemplars that stops the fit; must be at least 1. copy : bool, default True Accepted for scikit-learn compatibility; `X` is never modified. preference : float, array-like of shape (n_samples,) or None, default None Preference of each row to be an exemplar. None uses the median of the similarities, as in scikit-learn. affinity : {'euclidean', 'precomputed'}, default 'euclidean' With 'precomputed', `X` is a square similarity matrix. verbose : bool, default False Accepted for scikit-learn compatibility; nothing is printed. random_state : int or None, default None Non-negative seed of the tie-breaking noise. None means 0. A NumPy ``RandomState`` is not accepted. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- cluster_centers_indices_ : Array of shape (n_clusters,), int32 Row indices of the exemplars. cluster_centers_ : Array of shape (n_clusters, n_features), float32 The exemplar rows. Not set with ``affinity='precomputed'``. labels_ : Array of shape (n_samples,), int32 Cluster of each training row. affinity_matrix_ : Array of shape (n_samples, n_samples), float32 The similarity matrix used. n_iter_ : int Number of iterations run. n_features_in_ : int Number of features seen in `fit`. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((500, 4), dtype=np.float32) >>> model = mojolearn.AffinityPropagation(random_state=0).fit(X) >>> labels = model.predict(X) """
[docs] def __init__(self, *, damping=0.5, max_iter=200, convergence_iter=15, copy=True, preference=None, affinity="euclidean", verbose=False, random_state=None): self.damping = damping self.max_iter = max_iter self.convergence_iter = convergence_iter self.copy = copy self.preference = preference self.affinity = affinity self.verbose = verbose self.random_state = random_state
[docs] def fit(self, X, y=None): """Find the exemplars of `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, or a square similarity matrix with ``affinity='precomputed'``. Converted to float32; NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. Returns ------- self : AffinityPropagation The fitted estimator. Raises ------ ValueError If `affinity`, `damping`, `max_iter`, `convergence_iter` or `preference` is invalid, or a precomputed matrix is not square. """ if self.affinity not in ("euclidean", "precomputed"): raise ValueError(f"affinity must be 'euclidean' or 'precomputed', got {self.affinity!r}") if not 0.5 <= float(self.damping) < 1: raise ValueError(f"damping must be in [0.5, 1), got {self.damping}") if int(self.max_iter) < 1 or int(self.convergence_iter) < 1: raise ValueError("max_iter and convergence_iter must be >= 1") x = _f32(X) n, d = x.shape pre = self.affinity == "precomputed" if pre and n != d: raise ValueError(f"The matrix of similarities must be a square array. Got {(n, d)} instead.") pref_arr, mode, scalar = None, 0, 0.0 if self.preference is not None: if isinstance(self.preference, (int, float)): mode, scalar = 1, float(self.preference) else: pref_arr = _f32([list(self.preference)], "preference") if pref_arr.shape[1] != n: raise ValueError("preference must be a scalar or have one value per sample") mode = 2 ip = [n, d, 1 if pre else 0, mode, int(self.max_iter), int(self.convergence_iter), _seed(self.random_state)] f, i, s = self._call(_E_AFFINITY, x, pref_arr, ip, [float(self.damping), scalar]) k = len(i[0]) self.cluster_centers_indices_ = Array._from_flat(i[0], (k,), "<i4") self.labels_ = Array._from_flat(i[1], (n,), "<i4") self.affinity_matrix_ = Array._from_flat(f[0], (n, n), "<f4") # the final availability and responsibility diagonals (a diagnostic the # verifier lanes hash; scikit-learn keeps no such attribute) self._message_diag = Array._from_flat(f[1], (len(f[1]),), "<f4") self.n_iter_ = int(s[0]) self.n_features_in_ = d if not pre: self.cluster_centers_ = Array._from_flat(f[2], (k, d), "<f4") # the entry's exemplar rows return self
[docs] def predict(self, X): """Assign each row of `X` to its nearest exemplar. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to label, converted to float32. Returns ------- labels : Array of shape (n_samples,), int32 Cluster of each row; every row gets -1 when the fit found no exemplars. Raises ------ RuntimeError If the model is not fitted. ValueError If ``affinity='precomputed'``. """ self._check_fitted("labels_") if self.affinity == "precomputed": raise ValueError("Predict method is not supported when affinity='precomputed'.") x = self._input_like_fit(X) if self.cluster_centers_.shape[0] == 0: return _buffer.full((x.shape[0],), -1, "<i4") return self._nearest(x, self.cluster_centers_)[0]
[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) Training rows, or a square similarity matrix with ``affinity='precomputed'``. y : ignored Present for scikit-learn compatibility. Returns ------- labels : Array of shape (n_samples,), int32 The fitted `labels_`. """ return self.fit(X).labels_
# Reference: scikit-learn `mixture/_bayesian_mixture.py` with `_base.py`'s EM # loop. The n-sized work (the Mahalanobis squares, the E-step log-sum-exp, the # M-step moments) runs on the device in float32; the Wishart and # Dirichlet(-process) updates, the d x d Cholesky, digamma and log-gamma and # the lower bound run on the device too, in float-float (about 48 bits; # x_cluster/bgmm_device.mojo), as do the default priors. Every covariance type # runs through full d x d factors (the zeros add nothing).
[docs] class BayesianGaussianMixture(_XCluster): """Variational Bayesian Gaussian mixture. Follows scikit-learn's ``BayesianGaussianMixture``: variational inference with a Dirichlet process or Dirichlet distribution prior on the weights, for every covariance type. The per-row work runs in float32 and the parameter updates and lower bound in higher precision. Initialization draws from this library's own seeded random stream, so results agree with scikit-learn's within a tolerance. Input is converted to float32. Parameters ---------- n_components : int, default 1 Maximum number of mixture components. Must not exceed the number of samples. covariance_type : {'full', 'tied', 'diag', 'spherical'}, default 'full' Form of the component covariances. tol : float, default 1e-3 Convergence threshold on the lower bound gain. reg_covar : float, default 1e-6 Non-negative value added to the covariance diagonals. max_iter : int, default 100 Maximum EM iterations per initialization; must be at least 1. n_init : int, default 1 Number of initializations; the one with the best lower bound is kept. Must be at least 1. init_params : {'kmeans', 'random', 'k-means++', 'random_from_data'}, default 'kmeans' How the responsibilities start. 'kmeans' uses this library's ``KMeans``. weight_concentration_prior_type : {'dirichlet_process', 'dirichlet_distribution'}, default 'dirichlet_process' Prior on the weights. weight_concentration_prior : float or None, default None Weight concentration prior; None means 1 / n_components. mean_precision_prior : float or None, default None Precision prior on the means; None means 1. mean_prior : array-like of shape (n_features,) or None, default None Prior on the means; None means the mean of `X`. degrees_of_freedom_prior : float or None, default None Degrees-of-freedom prior; None means n_features. covariance_prior : float, array-like or None, default None Covariance prior: shape (n_features, n_features) for 'full' and 'tied', (n_features,) for 'diag', a float for 'spherical'. None uses the covariance of `X`, as in scikit-learn. random_state : int or None, default None Non-negative seed. None means 0, so repeated fits give the same result. A NumPy ``RandomState`` is not accepted. warm_start : bool, default False If True, a refit with the same `n_components`, number of features and `covariance_type` resumes from the previous fit's parameters (one run, the lower bound continuing). verbose : int, default 0 Accepted for scikit-learn compatibility; nothing is printed. verbose_interval : int, default 10 Accepted for scikit-learn compatibility; ignored. numeric_mode : str or None, default None Numeric mode for this estimator. None uses the process default (see :func:`mojolearn.set_numeric_mode`). Attributes ---------- weights_ : Array of shape (n_components,), float32 Component weights. means_ : Array of shape (n_components, n_features), float32 Component means. covariances_ : Array, float32 Component covariances, shaped by `covariance_type` as in scikit-learn. precisions_cholesky_ : Array, float32 Cholesky factors of the precision matrices, shaped by `covariance_type`. weight_concentration_ : Array or tuple of two Arrays, float32 Dirichlet parameters of the weights (a pair for the Dirichlet process). weight_concentration_prior_ : float Weight concentration prior used. mean_precision_ : Array of shape (n_components,), float32 Precision of each mean. mean_precision_prior_ : float Mean precision prior used. mean_prior_ : Array of shape (n_features,), float32 Prior on the means used. degrees_of_freedom_ : Array of shape (n_components,) or float Degrees of freedom of each component (a float for 'tied'). degrees_of_freedom_prior_ : float Degrees-of-freedom prior used. covariance_prior_ : Array or float Covariance prior used, shaped by `covariance_type`. lower_bound_ : float Lower bound of the best fit. n_iter_ : int EM iterations of the best fit. converged_ : bool Whether the best fit converged. n_features_in_ : int Number of features seen in `fit`. Examples -------- >>> import numpy as np, mojolearn >>> X = np.random.default_rng(0).random((1000, 4), dtype=np.float32) >>> model = mojolearn.BayesianGaussianMixture(n_components=5, random_state=0).fit(X) >>> proba = model.predict_proba(X) """
[docs] def __init__(self, *, n_components=1, covariance_type="full", tol=1e-3, reg_covar=1e-6, max_iter=100, n_init=1, init_params="kmeans", weight_concentration_prior_type="dirichlet_process", weight_concentration_prior=None, mean_precision_prior=None, mean_prior=None, degrees_of_freedom_prior=None, covariance_prior=None, random_state=None, warm_start=False, verbose=0, verbose_interval=10): self.n_components = n_components self.covariance_type = covariance_type self.tol = tol self.reg_covar = reg_covar self.max_iter = max_iter self.n_init = n_init self.init_params = init_params self.weight_concentration_prior_type = weight_concentration_prior_type self.weight_concentration_prior = weight_concentration_prior self.mean_precision_prior = mean_precision_prior self.mean_prior = mean_prior self.degrees_of_freedom_prior = degrees_of_freedom_prior self.covariance_prior = covariance_prior self.random_state = random_state self.warm_start = warm_start self.verbose = verbose self.verbose_interval = verbose_interval
[docs] def fit(self, X, y=None): """Fit the mixture on `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, converted to float32. NaN or infinity raises ValueError. y : ignored Present for scikit-learn compatibility. Returns ------- self : BayesianGaussianMixture The fitted estimator. Raises ------ ValueError If `covariance_type`, `init_params` or `weight_concentration_prior_type` is unknown, `n_components` exceeds the number of samples, `n_init` or `max_iter` is below 1, or a prior has the wrong shape. """ self._fit(X) return self
[docs] def fit_predict(self, X, y=None): """Fit the mixture on `X` and return the component of each row. Parameters ---------- X : array-like of shape (n_samples, n_features) Training rows, converted to float32. y : ignored Present for scikit-learn compatibility. Returns ------- labels : Array of shape (n_samples,), int32 Most likely component of each training row. """ return self._fit(X)
def _fit(self, X): ctypes = {"full": 0, "tied": 1, "diag": 2, "spherical": 3} if self.covariance_type not in ctypes: raise ValueError(f"covariance_type must be one of {sorted(ctypes)}, got {self.covariance_type!r}") ct = ctypes[self.covariance_type] inits = {"kmeans": 0, "random": 1, "k-means++": 2, "random_from_data": 3} if self.init_params not in inits: raise ValueError(f"init_params must be one of {sorted(inits)}, got {self.init_params!r}") if self.weight_concentration_prior_type not in ("dirichlet_process", "dirichlet_distribution"): raise ValueError("weight_concentration_prior_type must be 'dirichlet_process' or " "'dirichlet_distribution'") x = _f32(X) n, d = x.shape k = int(self.n_components) if k < 1 or n < k: raise ValueError(f"Expected n_samples >= n_components but got n_components = {k}, n_samples = {n}") if int(self.n_init) < 1 or int(self.max_iter) < 1: raise ValueError("n_init and max_iter must be >= 1") # glue: the priors and any warm state as byte copies (`_aux`); the # binding expands a diag or spherical covariance prior to d x d # (`x_cluster/entries.mojo::bgmm_entry`, ip[9] = 2 or 3) parts = [] cov_mode = 0 if self.mean_prior is not None: mp = _buffer.as_f32_c(self.mean_prior, ndim=None, name="mean_prior")[0] if mp.size != d: raise ValueError(f"The parameter 'means' should have the shape of ({d},)") parts.append(mp) if self.covariance_prior is not None: if ct in (0, 1): cp = _f32(self.covariance_prior, "covariance_prior") if cp.shape != (d, d): raise ValueError(f"The parameter '{self.covariance_type} covariance prior' should have the " f"shape of ({d}, {d})") cov_mode = 1 elif ct == 2: cp = _buffer.as_f32_c(self.covariance_prior, ndim=None, name="covariance_prior")[0] if cp.size != d: raise ValueError(f"The parameter 'diag covariance prior' should have the shape of ({d},)") cov_mode = 2 else: cp = _buffer.as_f32_c([float(self.covariance_prior)], ndim=None, name="covariance_prior")[0] cov_mode = 3 parts.append(cp) prev = getattr(self, "_warm", None) warm = bool(self.warm_start) and prev is not None and prev[0] == (k, d, self.covariance_type) if warm: parts.append(self._warm[1]) a = _aux(*parts) none = -1.0 fp = [none if self.weight_concentration_prior is None else float(self.weight_concentration_prior), none if self.mean_precision_prior is None else float(self.mean_precision_prior), none if self.degrees_of_freedom_prior is None else float(self.degrees_of_freedom_prior), float(self.reg_covar), float(self.tol), float(getattr(self, "lower_bound_", 0.0)) if warm else 0.0] ip = [n, d, k, 1 if self.weight_concentration_prior_type == "dirichlet_process" else 0, int(self.max_iter), int(self.n_init), inits[self.init_params], _seed(self.random_state), 1 if self.mean_prior is not None else 0, cov_mode, ct, 1 if warm else 0] f, i, s = self._call(_E_BGMM, x, a, ip, fp) self.weights_ = Array._from_flat(f[0], (k,), "<f4") self.means_ = Array._from_flat(f[1], (k, d), "<f4") self._full_pchol = Array._from_flat(f[3], (k, d, d), "<f4") # f[13], f[14], f[15]: the binding's by-type forms (bgmm_entry) self.covariances_ = self._by_type(f[13], k, d, ct) self.precisions_cholesky_ = self._by_type(f[14], k, d, ct) if self.weight_concentration_prior_type == "dirichlet_process": self.weight_concentration_ = (Array._from_flat(f[4], (k,), "<f4"), Array._from_flat(f[5], (k,), "<f4")) else: self.weight_concentration_ = Array._from_flat(f[4], (k,), "<f4") self.mean_precision_ = Array._from_flat(f[6], (k,), "<f4") self.degrees_of_freedom_ = (float(f[7][0]) if ct == 1 else Array._from_flat(f[7], (k,), "<f4")) self._log_consts = Array._from_flat(f[8], (k,), "<f4") self.mean_prior_ = Array._from_flat(f[9], (d,), "<f4") cpf = f[15] if ct == 2: self.covariance_prior_ = Array._from_flat(cpf, (d,), "<f4") elif ct == 3: self.covariance_prior_ = float(cpf[0]) else: self.covariance_prior_ = Array._from_flat(cpf, (d, d), "<f4") self.lower_bound_ = float(s[0]) self.n_iter_ = int(s[1]) self.converged_ = bool(s[2]) # the state a warm_start refit resumes from (full per-component matrices) self._warm = ((k, d, self.covariance_type), list(f[11]) + list(f[4]) + (list(f[5]) if len(f[5]) else [0.0] * k) + list(f[6]) + list(f[1]) + list(f[7]) + list(f[2]) + list(f[3])) self.weight_concentration_prior_ = float(s[3]) self.mean_precision_prior_ = float(s[4]) self.degrees_of_freedom_prior_ = float(s[5]) self.n_features_in_ = d return Array._from_flat(i[0], (n,), "<i4") @staticmethod def _by_type(flat, k, d, ct): """The binding's by-type values (`bgmm_entry` f[13], f[14]: full k x d x d, tied d x d, diag k x d, spherical k) under the covariance type's shape (as scikit-learn's attributes). Shapes only.""" if ct == 0: return Array._from_flat(flat, (k, d, d), "<f4") if ct == 1: return Array._from_flat(flat, (d, d), "<f4") if ct == 2: return Array._from_flat(flat, (k, d), "<f4") return Array._from_flat(flat, (k,), "<f4") def _score(self, X, flags=0): self._check_fitted("means_") x = self._input_like_fit(X) n, d = x.shape k = self.means_.shape[0] ip = [n, d, k, flags] if flags else [n, d, k] # glue: the model block as byte copies (`_aux`) f, i, sc = self._call(_E_BGMM_SCORE, x, _aux(self.means_, self._full_pchol, self._log_consts), ip) if flags: return f, sc, n, k, i[0] return f[0], f[1], n, k, i[0]
[docs] def predict(self, X): """Most likely component of each row of `X`. A tie goes to the lowest component index. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to label, converted to float32. Returns ------- labels : Array of shape (n_samples,), int32 Component with the largest responsibility for each row. Raises ------ RuntimeError If the model is not fitted. """ # the labels come from the device score (`bodies.argmax_row`: the # first largest log responsibility, the lowest index on a tie) _, _, n, _, labels = self._score(X) return Array._from_flat(labels, (n,), "<i4")
[docs] def predict_proba(self, X): """Responsibility of each component for each row of `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to evaluate, converted to float32. Returns ------- proba : Array of shape (n_samples, n_components), float32 Posterior probability of each component. Raises ------ RuntimeError If the model is not fitted. """ # the exp of every log responsibility in the binding (bodies.exp_cell) f, _, n, k, _ = self._score(X, 1) return Array._from_flat(f[2], (n, k), "<f4")
[docs] def score_samples(self, X): """Log-likelihood of each row of `X` under the mixture. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to evaluate, converted to float32. Returns ------- log_prob : Array of shape (n_samples,), float32 Weighted log probability of each row. Raises ------ RuntimeError If the model is not fitted. """ _, lpn, n, _, _ = self._score(X) return Array._from_flat(lpn, (n,), "<f4")
[docs] def score(self, X, y=None): """Mean log-likelihood of the rows of `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) Rows to evaluate, converted to float32. y : ignored Present for scikit-learn compatibility. Returns ------- score : float Average of `score_samples` over the rows. Raises ------ RuntimeError If the model is not fitted. """ # the float-float fold of log_prob_norm in the binding _, sc, n, _, _ = self._score(X, 2) return sc[0] / n
# ------------------------------------------------ GaussianMixture option parity _GMM_CTYPES = {"full": 0, "tied": 1, "diag": 2, "spherical": 3} _GMM_INITS = {"kmeans": 0, "random": 1, "k-means++": 2, "random_from_data": 3} def _gmm_needs_ext(est): """True when a GaussianMixture fit asks for an option the mixture binding does not carry (it refuses them by name there).""" return (est.covariance_type != "full" or est.init_params in ("k-means++", "random_from_data") or int(est.n_init) != 1 or bool(est.warm_start) or est.weights_init is not None or est.means_init is not None or est.precisions_init is not None) class _GmmExtCall(_XCluster): def __init__(self, numeric_mode=None): self.numeric_mode = numeric_mode def _gmm_ext_fit(est, X): """sklearn GaussianMixture on the cluster lane's mixture driver (plain mode): every covariance type, init, n_init, warm_start and the three *_init arrays.""" if est.covariance_type not in _GMM_CTYPES: raise ValueError(f"covariance_type must be one of {sorted(_GMM_CTYPES)}, got {est.covariance_type!r}") if est.init_params not in _GMM_INITS: raise ValueError(f"init_params must be one of {sorted(_GMM_INITS)}, got {est.init_params!r}") x = _f32(X) n, d = x.shape k = int(est.n_components) if k < 1 or n < k: raise ValueError(f"Expected n_samples >= n_components but got n_components = {k}, n_samples = {n}") ct = _GMM_CTYPES[est.covariance_type] call = _GmmExtCall(getattr(est, "numeric_mode", None)) # glue: the warm state and the init arrays as byte copies (`_aux`); the # binding expands tied, diag and spherical precisions_init to d x d per # component (`x_cluster/entries.mojo::bgmm_entry`, ip[15] = 2, 3, 4) parts = [] prev = getattr(est, "_ext", None) warm = bool(est.warm_start) and prev is not None and prev["key"] == (k, d, est.covariance_type) if warm: parts.append(prev["state"]) flags = [0, 0, 0] if est.weights_init is not None and not warm: w = _buffer.as_f32_c(est.weights_init, ndim=None, name="weights_init")[0] if w.size != k: raise ValueError(f"The parameter 'weights' should have the shape of ({k},)") parts.append(w) flags[0] = 1 if est.means_init is not None and not warm: m = _f32(est.means_init, "means_init") if m.shape != (k, d): raise ValueError(f"The parameter 'means' should have the shape of ({k}, {d})") parts.append(m) flags[1] = 1 if est.precisions_init is not None and not warm: pi = _buffer.as_f32_c(est.precisions_init, ndim=None, name="precisions_init")[0] want = (k * d * d, d * d, k * d, k)[ct] if pi.size != want: raise ValueError(f"The parameter '{est.covariance_type} precision' has {pi.size} values, " f"expected {want}") # the base binding's native scan (`_buffer.all_finite`) if not _buffer.all_finite(pi): raise ValueError("precisions_init must be finite") parts.append(pi) flags[2] = ct + 1 a = _aux(*parts) fp = [-1.0, -1.0, -1.0, float(est.reg_covar), float(est.tol), float(getattr(est, "lower_bound_", 0.0)) if warm else 0.0] ip = [n, d, k, 0, int(est.max_iter), int(est.n_init), _GMM_INITS[est.init_params], _seed(est.random_state), 0, 0, ct, 1 if warm else 0, 1] + flags f, i, s = call._call(_E_BGMM, x, a, ip, fp) est.n_features_in_ = d est.weights_ = Array._from_flat(f[0], (k,), "<f4") est.means_ = Array._from_flat(f[1], (k, d), "<f4") est.covariances_ = BayesianGaussianMixture._by_type(f[13], k, d, ct) est.precisions_cholesky_ = BayesianGaussianMixture._by_type(f[14], k, d, ct) est.log_det_chol_ = Array._from_flat(f[12], (k,), "<f4") # x_cluster bgmm_entry est.n_iter_ = int(s[1]) est.converged_ = bool(s[2]) est.lower_bound_ = float(s[0]) est._ext = {"key": (k, d, est.covariance_type), "call": call, "pchol": Array._from_flat(f[3], (k, d, d), "<f4"), "consts": Array._from_flat(f[8], (k,), "<f4"), "state": (list(f[11]) + list(f[4]) + [0.0] * k + list(f[6]) + list(f[1]) + list(f[7]) + list(f[2]) + list(f[3]))} return est def _gmm_ext_score(est, X): """(log_resp list, score_samples, predict_proba, predict) of an ext fit.""" ext = est._ext x = _f32(X) n, d = x.shape if d != est.n_features_in_: raise ValueError(f"mojolearn GaussianMixture: X has {d} features, the fit had {est.n_features_in_}") k = est.means_.shape[0] # predict_proba's exp and score's fold in the binding (flags 1 | 2); # the fold rides as a fifth item for `_gmm_ext_bic_aic` f, i, sc = ext["call"]._call(_E_BGMM_SCORE, x, _aux(est.means_, ext["pchol"], ext["consts"]), [n, d, k, 3]) return (f[0], Array._from_flat(f[1], (n,), "<f4"), Array._from_flat(f[2], (n, k), "<f4"), Array._from_flat(i[0], (n,), "<i4"), sc[0]) def _gmm_ext_bic_aic(est, X): """(score, bic, aic): sklearn `_n_parameters`, `bic`, `aic` on the ext fit; the mean log-likelihood an ascending float64 fold of score_samples (the binding's float-float fold when it computes it).""" got = _gmm_ext_score(est, X) n = len(got[1]) score = got[4] / n k, d = est.means_.shape cov = {"full": k * d * (d + 1) / 2.0, "diag": k * d, "tied": d * (d + 1) / 2.0, "spherical": k}[est.covariance_type] p = int(cov + k * d + k - 1) return score, -2 * score * n + p * _pm.log(n), -2 * score * n + 2 * p