Source code for mojolearn.mixture

# 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 `mixture/estimator.mojo` through
# `bindings/_mojolearn_mixture.mojo`. There is no GPU reference: cuML, cuVS and
# RAFT ship no Gaussian mixture at the pinned commits, so scikit-learn's
# `_gaussian_mixture.py` and `_base.py` are the semantics and the oracle, never
# the design source. `mixture/README.md` carries the parameter mapping.
# The default binding implements covariance_type='full', init_params 'kmeans'
# (through the identity-certified k-means) and 'random' (position-mapped Philox
# draws, DEVIATION 1733), n_init=1, warm_start=False; every other option is
# routed to the cluster lane's mixture driver (`_expansion_cluster._gmm_ext_fit`).
# A collapsed component raises (DEVIATION 1723). No speed claim.
"""Gaussian mixture models.

`GaussianMixture` fits a mixture of Gaussians by expectation-maximization,
with scikit-learn's parameter names and meanings.
"""
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}

#: The model file (the neighbors and density inference lane, 2026-09-15):
#: `weights`, `means`, `covariances`, `precisions_cholesky` and
#: `log_det_chol` as flat `<f4` in the order the scoring entries address
#: them, `ints` `<i8` [n_components, n_features, n_iter_, converged_,
#: max_iter, random_state], `reals` `<f8` [lower_bound_, tol, reg_covar],
#: `covariance_type` and `init_params` as text.
_GMM_FORMAT = "mojolearn-gmm-1"

#: Refused by absence on the Mojo side (`GmmParams` has no such field,
#: DEVIATION 1734), so refused by NAME here rather than accepted and
#: ignored, which the contract forbids.
_REFUSED_KNOBS = {
    "n_init": "n_init > 1 picks the best restart by a float comparison of "
              "lower bounds (_base.py:282), a second data-dependent branch "
              "with no identity argument yet (DEVIATION 1734)",
    "warm_start": "warm_start makes the fit depend on the object's history "
                  "rather than on its inputs (DEVIATION 1734)",
    "means_init": "means_init is not a field of GmmParams (mixture/NOT_IMPLEMENTED.tsv)",
    "weights_init": "weights_init is not a field of GmmParams (mixture/NOT_IMPLEMENTED.tsv)",
    "precisions_init": "precisions_init is not a field of GmmParams (mixture/NOT_IMPLEMENTED.tsv)",
}


def _real(v, name):
    if isinstance(v, bool) or not isinstance(v, (int, float)):
        raise TypeError(
            f"mojolearn GaussianMixture: {name} must be a real number, got "
            f"{type(v).__name__}"
        )
    return float(v)


# log_det_chol_ is `_compute_log_det_cholesky`'s answer from the device fold
# (DEVIATION 1726), never re-derived from the diagonal in Python.
# converged_=False: scikit-learn warns, this reports (DEVIATION 1746).
[docs] class GaussianMixture(NumericModeMixin): """Gaussian mixture model fitted by expectation-maximization. Follows scikit-learn's `GaussianMixture`: every parameter has the same name, meaning and default. Iterates until the change in the lower bound is below `tol` or `max_iter` is reached. `X` is read as float32 and the fitted arrays are float32. The default configuration (`covariance_type='full'`, `init_params='kmeans'` or `'random'`, `n_init=1`, no warm start and no initial arrays) runs on mojolearn's main mixture solver. Any other option runs on a second solver; a model fitted that way can score and predict, but `save` and `sample` raise `NotImplementedError`. Parameters ---------- n_components : int, default 1 Number of mixture components. covariance_type : {'full', 'tied', 'diag', 'spherical'}, default 'full' Form of each component's covariance, as in scikit-learn. tol : float, default 1e-3 Convergence threshold on the change in the lower bound. reg_covar : float, default 1e-6 Added to the diagonal of each covariance. max_iter : int, default 100 Maximum number of EM iterations. init_params : {'kmeans', 'random', 'k-means++', 'random_from_data'}, default 'kmeans' How the responsibilities are initialized. random_state : int, default 0 Seed for initialization and for `sample`. n_init : int, default 1 Number of initializations; the best one is kept. warm_start : bool, default False Start from the previous fit's solution when the shape and covariance type match. means_init : array-like of shape (n_components, n_features), default None Initial means. weights_init : array-like of shape (n_components,), default None Initial weights. precisions_init : array-like, default None Initial precisions, shaped by `covariance_type` as in scikit-learn. Attributes ---------- weights_ : Array of shape (n_components,), float32 Weight of each component. means_ : Array of shape (n_components, n_features), float32 Mean of each component. covariances_ : Array, float32 Covariance of each component, shaped by `covariance_type`; for 'full', `(n_components, n_features, n_features)`. Includes `reg_covar` on the diagonal, as scikit-learn's does. precisions_cholesky_ : Array, float32 Cholesky factors of the precision matrices, shaped like `covariances_`. log_det_chol_ : Array of shape (n_components,), float32 Log-determinant of each precision Cholesky factor. n_iter_ : int Number of EM iterations run. converged_ : bool False when the fit stopped at `max_iter` before reaching `tol`. scikit-learn also warns in that case; mojolearn only sets this flag. lower_bound_ : float Lower bound on the log likelihood at the end of the fit. 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) >>> gmm = mojolearn.GaussianMixture(n_components=3).fit(X) >>> labels = gmm.predict(X) >>> proba = gmm.predict_proba(X) >>> X_new, y_new = gmm.sample(100) """ _BINDING = "_mojolearn_mixture"
[docs] def __init__( self, n_components=1, covariance_type="full", tol=1e-3, reg_covar=1e-6, max_iter=100, init_params="kmeans", random_state=0, n_init=1, warm_start=False, means_init=None, weights_init=None, precisions_init=None, ): self.n_components = n_components self.covariance_type = covariance_type self.tol = tol self.reg_covar = reg_covar self.max_iter = max_iter self.init_params = init_params self.random_state = random_state self.n_init = n_init self.warm_start = warm_start self.means_init = means_init self.weights_init = weights_init self.precisions_init = precisions_init
def _extension(self): mod = self._bind() want = getattr(self, "numeric_mode", None) or _backend.default_mode() fn = getattr(mod, "mixture_numeric_mode", None) if fn is not None: got = int(fn()) if got != _MODE_CODE.get(want): raise RuntimeError( f"mojolearn GaussianMixture: numeric_mode={want!r} was requested " f"but {mod.__name__} reports compile-time mode code {got}; " "rebuild it with bash bindings/build_mixture.sh" ) return mod def _refuse_knobs(self): if self.n_init != 1: raise ValueError("mojolearn GaussianMixture: " + _REFUSED_KNOBS["n_init"]) if self.warm_start: raise ValueError("mojolearn GaussianMixture: " + _REFUSED_KNOBS["warm_start"]) for name in ("means_init", "weights_init", "precisions_init"): # glue: checks three parameter names if getattr(self, name) is not None: raise ValueError("mojolearn GaussianMixture: " + _REFUSED_KNOBS[name]) # EM runs one shot on the device (`gaussian_mixture_fit`). Option parity # (2026-09-27, lane/algos-cluster): any option this binding does not carry # runs the same EM on the cluster lane's mixture driver # (`x_cluster/bgmm.mojo`, plain mode; `_expansion_cluster._gmm_ext_fit`). # The default fit is untouched and keeps its recorded bits.
[docs] def fit(self, X, y=None): """Fit the mixture to `X` by expectation-maximization. Runs until `abs(lower_bound - previous) < tol` or `max_iter` iterations. 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 : GaussianMixture The fitted estimator. An unknown `covariance_type` or `init_params`, or a component that collapses during the fit, raises an error naming the problem; the fit never returns a model with a reset component. Raises ------ ValueError For a non-positive `n_components` or initial arrays of the wrong shape. TypeError If `n_components` is not an int or `tol`/`reg_covar` is not a number. """ from ._expansion_cluster import _gmm_needs_ext, _gmm_ext_fit if _gmm_needs_ext(self): return _gmm_ext_fit(self, X) self._ext = None self._refuse_knobs() x, _ = as_f32_c(X, ndim=2, name="X") n, d = x.shape if isinstance(self.n_components, bool) or not isinstance(self.n_components, int): raise TypeError("mojolearn GaussianMixture: n_components must be an int") if not isinstance(self.covariance_type, str) or not isinstance(self.init_params, str): raise TypeError( "mojolearn GaussianMixture: covariance_type and init_params are " "scikit-learn's strings; they are decoded, and refused, by name on " "the Mojo host" ) k = int(self.n_components) if k < 1: raise ValueError(f"mojolearn GaussianMixture: n_components must be positive, got {k}") tol = _real(self.tol, "tol") reg_covar = _real(self.reg_covar, "reg_covar") max_iter = int(self.max_iter) weights = empty((k,), "<f4") means = empty((k * d,), "<f4") covariances = empty((k * d * d,), "<f4") precisions = empty((k * d * d,), "<f4") log_det = empty((k,), "<f4") scalars = empty((3,), "<f8") n_iter = self._extension().gmm_fit( # ORDER MATCHES bindings/_mojolearn_mixture.mojo::gmm_fit_binding. # x, weights_out, means_out, covariances_out, precisions_chol_out, log_det_chol_out, scalars_out [addr_ro(x, name="X"), addr(weights, name="weights_"), addr(means, name="means_"), addr(covariances, name="covariances_"), addr(precisions, name="precisions_cholesky_"), addr(log_det, name="log_det_chol_"), addr(scalars, name="scalars")], # n, d, n_components, covariance_type, tol, reg_covar, max_iter, init_params, random_state [n, d, k, self.covariance_type, tol, reg_covar, max_iter, self.init_params, int(self.random_state)], ) self.n_features_in_ = d self.weights_ = weights self.means_ = means.reshape((k, d)) self.covariances_ = covariances.reshape((k, d, d)) self.precisions_cholesky_ = precisions.reshape((k, d, d)) self.log_det_chol_ = log_det self.n_iter_ = int(n_iter) self.converged_ = bool(int(scalars[1])) self.lower_bound_ = float(scalars[2]) return self
def _model_lists(self, X, out, name): if not hasattr(self, "weights_"): raise ValueError(f"mojolearn GaussianMixture: call fit before {name}") x, _ = as_f32_c(X, ndim=2, name="X") n, d = x.shape if d != self.n_features_in_: raise ValueError( f"mojolearn GaussianMixture: X has {d} features, the fit had {self.n_features_in_}" ) k = self.weights_.shape[0] flat_m = self.means_.reshape((k * d,)) flat_c = self.covariances_.reshape((k * d * d,)) flat_p = self.precisions_cholesky_.reshape((k * d * d,)) # ORDER MATCHES bindings/_mojolearn_mixture.mojo::_rebuild_model. # weights, means, covariances, precisions_chol, log_det_chol, x, out addrs = [addr_ro(self.weights_, name="weights_"), addr_ro(flat_m, name="means_"), addr_ro(flat_c, name="covariances_"), addr_ro(flat_p, name="precisions_cholesky_"), addr_ro(self.log_det_chol_, name="log_det_chol_"), addr_ro(x, name="X"), addr(out, name=name)] # k, d, n_iter, converged, lower_bound, n params = [k, d, self.n_iter_, 1 if self.converged_ else 0, self.lower_bound_, n] # The arrays addressed above stay alive through the caller's frame. return (x, flat_m, flat_c, flat_p), addrs, params, n, k
[docs] def score_samples(self, X): """Return the log likelihood of each row of `X` under the model. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- log_likelihood : Array of shape (n_samples,), float32 """ if getattr(self, "_ext", None) is not None: from ._expansion_cluster import _gmm_ext_score return _gmm_ext_score(self, X)[1] x, _ = as_f32_c(X, ndim=2, name="X") out = empty((x.shape[0],), "<f4") keep, addrs, params, n, k = self._model_lists(x, out, "score_samples") self._extension().gmm_score_samples(addrs, params) return out
[docs] def predict_proba(self, X): """Return the posterior probability of each component for each row. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- proba : Array of shape (n_samples, n_components), float32 """ if getattr(self, "_ext", None) is not None: from ._expansion_cluster import _gmm_ext_score return _gmm_ext_score(self, X)[2] x, _ = as_f32_c(X, ndim=2, name="X") k = self.weights_.shape[0] if hasattr(self, "weights_") else 0 out = empty((x.shape[0] * k,), "<f4") keep, addrs, params, n, k = self._model_lists(x, out, "predict_proba") self._extension().gmm_predict_proba(addrs, params) return out.reshape((n, k))
[docs] def predict(self, X): """Return the most likely component for each row of `X`. A tie goes to the lowest component index. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- labels : Array of shape (n_samples,), int32 """ if getattr(self, "_ext", None) is not None: from ._expansion_cluster import _gmm_ext_score return _gmm_ext_score(self, X)[3] x, _ = as_f32_c(X, ndim=2, name="X") out = empty((x.shape[0],), "<i4") keep, addrs, params, n, k = self._model_lists(x, out, "predict") self._extension().gmm_predict(addrs, params) return out
[docs] def fit_predict(self, X, y=None): """Fit the model, then return `predict(X)`. Returns ------- labels : Array of shape (n_samples,), int32 """ return self.fit(X).predict(X)
[docs] def save(self, path): """Save the fitted model to `path` as an `.npz` file. The file holds every fitted array plus `n_iter_`, `converged_` and `lower_bound_`. Load it with `load`, or with `mojolearn.host_model(path)` to score on a machine with no GPU. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the model is not fitted. NotImplementedError If the model was fitted with options outside the default configuration (see the class description). """ if not hasattr(self, "weights_"): raise RuntimeError("this estimator is not fitted yet") if getattr(self, "_ext", None) is not None: raise NotImplementedError("mojolearn GaussianMixture: save() carries the default fit's model " "format only; a fit with the options routed to x_cluster is not saved " "(x_cluster/NOT_IMPLEMENTED.tsv)") from .linear_model import _saved_mode k, d = self.means_.shape return _serialize.write_npz(path, { "format": _GMM_FORMAT, "estimator": type(self).__name__, "numeric_mode": _saved_mode(self), "covariance_type": str(self.covariance_type), "init_params": str(self.init_params), "weights": self.weights_, "means": self.means_.reshape((k * d,)), "covariances": self.covariances_.reshape((k * d * d,)), "precisions_cholesky": self.precisions_cholesky_.reshape((k * d * d,)), "log_det_chol": self.log_det_chol_, "ints": Array.from_list([k, d, int(self.n_iter_), 1 if self.converged_ else 0, int(self.max_iter), int(self.random_state)], "<i8"), "reals": Array.from_list([float(self.lower_bound_), float(self.tol), float(self.reg_covar)], "<f8"), })
[docs] @classmethod def load(cls, path): """Load a model written by `save`. Arrays keep their saved dtypes. The result supports `score_samples`, `predict_proba`, `predict`, `score`, `bic` and `aic`. Parameters ---------- path : str or path-like File written by `GaussianMixture.save`. Returns ------- model : GaussianMixture 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, _GMM_FORMAT) _check_saved_by(arrays, path, cls) ints = _serialize.exact(arrays, "ints", "<i8") reals = _serialize.exact(arrays, "reals", "<f8") if ints.size != 6 or reals.size != 3: raise ValueError(f"mojolearn: {path!r} holds {ints.size} ints and {reals.size} reals, 6 and 3 are needed") k, d, n_iter, converged, max_iter, seed = (int(ints[i]) for i in range(6)) # glue: unpacks the fixed int vector if k < 1 or d < 1: raise ValueError(f"mojolearn: {path!r} holds an empty model") obj = cls(n_components=k, covariance_type=_serialize.scalar_str(arrays, "covariance_type"), tol=float(reals[1]), reg_covar=float(reals[2]), max_iter=max_iter, init_params=_serialize.scalar_str(arrays, "init_params"), random_state=seed) _restore_mode(obj, arrays) sizes = dict(weights=k, means=k * d, covariances=k * d * d, precisions_cholesky=k * d * d, log_det_chol=k) got = {} for name, size in sizes.items(): # glue: checks saved array names a = _serialize.exact(arrays, name, "<f4") 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.weights_ = got["weights"] obj.means_ = got["means"].reshape((k, d)) obj.covariances_ = got["covariances"].reshape((k, d, d)) obj.precisions_cholesky_ = got["precisions_cholesky"].reshape((k, d, d)) obj.log_det_chol_ = got["log_det_chol"] obj.n_iter_ = n_iter obj.converged_ = bool(converged) obj.lower_bound_ = float(reals[0]) return obj
def _score_bic_aic(self, X): if getattr(self, "_ext", None) is not None: from ._expansion_cluster import _gmm_ext_bic_aic return _gmm_ext_bic_aic(self, X) x, _ = as_f32_c(X, ndim=2, name="X") out = empty((3,), "<f8") keep, addrs, params, n, k = self._model_lists(x, out, "score") self._extension().gmm_score_bic_aic(addrs, params) return float(out[0]), float(out[1]), float(out[2])
[docs] def score(self, X, y=None): """Return the mean log likelihood of the rows of `X`. Parameters ---------- X : array-like of shape (n_samples, n_features) y : ignored Present for scikit-learn API compatibility. Returns ------- score : float """ return self._score_bic_aic(X)[0]
[docs] def bic(self, X): """Return the Bayesian information criterion of the model on `X`. Lower is better. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- bic : float """ return self._score_bic_aic(X)[1]
[docs] def aic(self, X): """Return the Akaike information criterion of the model on `X`. Lower is better. Parameters ---------- X : array-like of shape (n_samples, n_features) Returns ------- aic : float """ return self._score_bic_aic(X)[2]
# Reference: scikit-learn `BaseMixture.sample`. Draws are position-mapped # Philox keyed by `random_state` (DEVIATION 2791, `mixture/checks/sample.mojo`); # the normals go through the fitted `precisions_cholesky_` (DEVIATION 2792).
[docs] def sample(self, n_samples=1): """Draw random rows from the fitted mixture. As in scikit-learn, the number of rows per component is a multinomial draw over `weights_`, and the rows come out grouped by component in ascending order. The draws are keyed by `random_state`, so the same model and `random_state` give the same rows on every call and every GPU vendor; they differ from scikit-learn's draws. `X` is float32 and `y` int32, where scikit-learn returns float64 and int64. Parameters ---------- n_samples : int, default 1 Number of rows to draw. Must be at least 1. Returns ------- X : Array of shape (n_samples, n_features), float32 The drawn rows. y : Array of shape (n_samples,), int32 The component each row was drawn from. Raises ------ ValueError If the model is not fitted or `random_state` is not an int in `[0, 2**64)`. TypeError If `n_samples` is not an int. NotImplementedError If the model was fitted with options outside the default configuration. """ if getattr(self, "_ext", None) is not None: raise NotImplementedError("mojolearn GaussianMixture: sample() is the default fit's; a fit with " "the options routed to x_cluster does not sample (x_cluster/NOT_IMPLEMENTED.tsv)") if not hasattr(self, "weights_"): raise ValueError("mojolearn GaussianMixture: call fit before sample") if isinstance(n_samples, bool) or not isinstance(n_samples, int): raise TypeError("mojolearn GaussianMixture: n_samples must be an int") seed = self.random_state if isinstance(seed, bool) or not isinstance(seed, int) or not 0 <= seed < 2 ** 64: raise ValueError( "mojolearn GaussianMixture: sample needs random_state to be an int in " f"[0, 2**64), got {seed!r}; it is the key of every draw (DEVIATION 2791)" ) n = int(n_samples) k = self.weights_.shape[0] d = self.n_features_in_ flat_m = self.means_.reshape((k * d,)) flat_c = self.covariances_.reshape((k * d * d,)) flat_p = self.precisions_cholesky_.reshape((k * d * d,)) # Never a zero-length buffer: a refused n still addresses real memory. x = empty((max(n, 1) * d,), "<f4") y = empty((max(n, 1),), "<i4") self._extension().gmm_sample( # ORDER MATCHES bindings/_mojolearn_mixture.mojo::gmm_sample_binding. # weights, means, covariances, precisions_chol, log_det_chol, X out, y out [addr_ro(self.weights_, name="weights_"), addr_ro(flat_m, name="means_"), addr_ro(flat_c, name="covariances_"), addr_ro(flat_p, name="precisions_cholesky_"), addr_ro(self.log_det_chol_, name="log_det_chol_"), addr(x, name="X"), addr(y, name="y")], # k, d, n_iter, converged, lower_bound, n (0: no X is read) [k, d, self.n_iter_, 1 if self.converged_ else 0, self.lower_bound_, 0], # n_samples, random_state low 32 bits, random_state high 32 bits [n, seed & 0xFFFFFFFF, seed >> 32], ) return x.reshape((n, d)), y
__all__ = ["GaussianMixture"]