Source code for mojolearn._gpc_impl

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Gaussian process classification: the Laplace approximation.

PRIVATE MODULE. `GaussianProcessClassifier` is re-exported from
`mojolearn/__init__.py`; `HostGaussianProcessClassifier` is what
`mojolearn.host_model` returns for a saved classifier.

THE REFERENCE is scikit-learn 1.9.0 `sklearn/gaussian_process/_gpc.py`:
`_BinaryGaussianProcessClassifierLaplace` (`_gpc.py:36-513`, Rasmussen and
Williams Algorithms 3.1 and 3.2 with the logistic likelihood) and
`GaussianProcessClassifier` (`_gpc.py:516` on), which wraps the binary
estimator in `OneVsRestClassifier` past two classes. Every name here means
what scikit-learn means by it.

WHERE THE ARITHMETIC IS. One binary Laplace fit per binding call,
`gpc_fit` and `gpc_predict` on `_mojolearn_gp` (the GPU binding,
`gaussian_process/classifier.mojo`) or on `_mojolearn_gp_host` (the CPU host
binding, `gaussian_process/host/gpc_oracle.mojo`); the per-row steps of
both are `gaussian_process/host/gpc_steps.mojo`, which carries DEVIATIONS
2830 (the stop rule), 2831 (the pinned float32 orders) and 2832 (the
float64 probability and its erf). This file holds the class encoding, the
one-vs-rest composition (DEVIATION 2833) and the saved model.

DEVIATION 2833, THE ONE-VS-REST COMPOSITION, in plain Python float64 (every
value it reads is a float64 or an exact widening of a float32, and CPython
float arithmetic is IEEE double with no contraction, so it is the same on
every host):
  - column k of a K-class fit is the binary target `y == classes_[k]`, the
    `LabelBinarizer` column `OneVsRestClassifier` fits (`multiclass.py`);
  - `predict_proba` divides each row by its sum where the sum is not zero
    (`multiclass.py:559-560`), the sum taken over k ASCENDING from +0.0
    (NumPy's reduction order may differ in the last bits);
  - `predict` is the first k whose unnormalized class-k probability is
    strictly largest, which is NumPy's argmax tie rule that
    `OneVsRestClassifier.predict` reproduces (`multiclass.py:499-508`); a
    binary model predicts `classes_[1]` where the float32 latent mean is
    strictly positive (`_gpc.py:287-290`);
  - `log_marginal_likelihood_value_` is the mean of the K binary values
    (`_gpc.py:216-222`), summed ascending from +0.0 then divided by K.

WHAT IS HONORED AND WHAT IS REFUSED, one line per parameter:

    kernel                honored   RBF, Matern (nu in {0.5, 1.5, 2.5}),
                                    ConstantKernel and WhiteKernel via + and *;
                                    None is scikit-learn's default
                                    ConstantKernel(1.0) * RBF(1.0)
    optimizer             refused   anything but None (DEVIATION 1761). THE
                                    DEFAULT IS None, NOT 'fmin_l_bfgs_b', so
                                    the fitted kernel_ is the kernel passed
    n_restarts_optimizer  refused   anything but 0; it restarts the optimizer
    max_iter_predict      honored   at least 1; the Newton loop's cap
                                    (DEVIATION 2830 says how it stops early)
    warm_start            refused   True: it starts the Newton loop from the
                                    previous fit's latent values, so a second
                                    fit would depend on the first
    copy_X_train          refused   False: X always crosses to float32 memory
    random_state          refused   anything but None; only the refused
                                    optimizer restarts draw random numbers
    multi_class           honored   'one_vs_rest'; 'one_vs_one' is refused BY
                                    NAME (not implemented, and it has no
                                    predict_proba in the reference either)
    n_jobs                refused   anything but None; the binary fits run
                                    one after another on one device

THE INFERENCE BOUNDARY.
`fit` on a CPU-only install refuses unless the internal verifier's
`reference_training()` is active. `predict`, `predict_proba`,
`latent_mean_and_variance` and `load` are public on the CPU:
`GaussianProcessClassifier.load(path)` routes `_mojolearn_gp` to
`_mojolearn_gp_host` on a CPU-only install, and `mojolearn.host_model(path)`
returns `HostGaussianProcessClassifier`, bound to the host binding on any
box.
"""

from . import _backend, _serialize
from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, as_i32_c, empty, frombytes
from ._gp_impl import _MODE_CODE, ConstantKernel, Kernel, RBF
from ._labels import (
    classes_from_member,
    classes_member,
    decode_labels,
    encode_labels,
)
from ._mode import NumericModeMixin

#: The saved-model format tag.
_GPC_FORMAT = "mojolearn.gpc.v1"
_ESTIMATOR = "GaussianProcessClassifier"
_NAME = "mojolearn GaussianProcessClassifier"


def _gpc_all(ext):
    """True when the binding fits and predicts every class in one device
    session (`gpc_fit_all` / `gpc_predict_all`,
    `gaussian_process/gpc_ovr.mojo`, lane fam2-kernel-gp): IDENTICAL GPU
    builds. The host binding and a `-D MOJOLEARN_IDN_GPC_OVR_OFF` build
    answer no and the per-class binding calls below run; either way the
    binding, never Python, forms targets, codes and probabilities."""
    fn = getattr(ext, "gp_idn_caps", None)
    return fn is not None and (int(fn()) & 1) == 1


def _ovr_combine(ext, cols, n_star):
    """DEVIATION 2833 on the binding (`gpc_ovr_combine`, the device on a GPU
    install): the k unnormalized class-1 probability columns normalized per
    row and the first strictly largest column per row. Returns the
    (n_star, k) float64 probabilities and the n_star int32 codes."""
    k = len(cols)
    proba = empty((n_star, k), "<f8")
    codes = empty((n_star,), "<i4")
    ext.gpc_ovr_combine(
        # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpc_ovr_combine_binding.
        [addr(proba, name="proba_out"), addr(codes, name="codes_out")]
        + [addr_ro(c, name=f"class_{i}") for i, c in enumerate(cols)],  # glue: one class column address per class
        [int(n_star)],
    )
    return proba, codes


def _stacked(blocks, dtype, shape):
    """The fits' same-shape buffers as one (len(blocks),) + shape Array, joined
    as bytes (no per-element Python)."""
    raw = b"".join(a.tobytes() for a in blocks)  # glue: joins one buffer per class fit
    return frombytes(raw, dtype, (len(blocks),) + tuple(shape))


def _kernel_arrays(kernel):
    """The postfix spec as the four flat arrays `_gp_impl.py` sends
    (DEVIATION 1756), plus the length-scale count."""
    nodes = kernel._nodes()
    kinds = Array.from_list([int(k) for k, _, _ in nodes], "<i4")  # glue: packs the kernel's postfix nodes
    params = Array.from_list([float(p) for _, p, _ in nodes], "<f4")  # glue: packs the kernel's postfix nodes
    ls_len = Array.from_list([len(ls) for _, _, ls in nodes], "<i4")  # glue: packs the kernel's postfix nodes
    table = [v for _, _, ls in nodes for v in ls]  # glue: packs the kernel length-scale hyperparameters
    ls = Array.from_list(table if table else [1.0], "<f4")
    return kinds, params, ls_len, ls, len(table)


class _SavedKernel(Kernel):
    """A kernel read back from a model file: the postfix nodes exactly as
    they were saved (float32 values round-trip through Python floats
    exactly), so a loaded model sends the binding the bytes the fit sent."""

    def __init__(self, nodes):
        self._node_list = [(int(k), float(p), [float(v) for v in ls]) for k, p, ls in nodes]  # glue: copies the kernel's postfix nodes

    def _nodes(self):
        return [(k, p, list(ls)) for k, p, ls in self._node_list]  # glue: copies the kernel's postfix nodes

    def _name(self):
        return f"SavedKernel({len(self._node_list)} postfix nodes)"


class _BinaryLaplace:
    """One binary Laplace fit, the state `_BinaryGaussianProcessClassifierLaplace`
    keeps (`_gpc.py:263-265`): the 0/1 targets `y_train_`, `pi_`, `W_sr_`,
    `L_` (the lower factor of `B = I + W_sr K W_sr`), the likelihood, and the
    Newton iteration count and panel width that ran."""

    def __init__(self, y_train, L, pi, W_sr, lml, n_iter, nb):
        self.y_train_ = y_train
        self.L_ = L
        self.pi_ = pi
        self.W_sr_ = W_sr
        self.log_marginal_likelihood_value_ = lml
        self.n_iter_ = n_iter
        self.nb_ = nb


# Maintainer notes: reference is scikit-learn 1.9.0 `_gpc.py` (binary
# Laplace `_gpc.py:36-513`, R&W Algorithms 3.1/3.2, logistic likelihood;
# OneVsRest wrapper `_gpc.py:516` on). Stop rule DEVIATION 2830, pinned
# float32 orders 2831, float64 probability and erf 2832, one-vs-rest
# composition 2833 (see the module docstring). optimizer refused per
# DEVIATION 1761: an optimizer's iteration count is data dependent and its
# line search, gradient fold and tolerance are not pinned for this estimator.
[docs] class GaussianProcessClassifier(NumericModeMixin): """Gaussian process classification by the Laplace approximation. Follows scikit-learn's ``GaussianProcessClassifier``: a latent Gaussian process with a logistic likelihood, fitted by Newton iteration (Rasmussen and Williams, Algorithms 3.1 and 3.2). Two classes give one binary model; more classes give one binary model per class against the rest, with probabilities normalized per row as in scikit-learn's one-vs-rest wrapper. X is dense float32 in C order (other inputs are converted). Differences from scikit-learn that change results: - There is no hyperparameter optimizer. ``optimizer`` must be None (the default here; scikit-learn's is ``"fmin_l_bfgs_b"``), so ``kernel_`` is the kernel you pass. - Every fit starts from zero latent values; ``warm_start=True`` is refused. Parameters ---------- kernel : Kernel or None, default None A combination of `RBF`, `Matern`, `ConstantKernel` and `WhiteKernel` with ``+`` and ``*``. None means ``ConstantKernel(1.0) * RBF(1.0)``. Other kernel types raise ``TypeError``. optimizer : None, default None Only None (or ``"none"``) is accepted; anything else raises ``NotImplementedError``. n_restarts_optimizer : int, default 0 Only 0 is accepted (``NotImplementedError`` otherwise). max_iter_predict : int, default 100 Maximum number of Newton iterations per binary fit; the iteration can stop earlier on convergence. Must be an int (``TypeError``) of at least 1 (``ValueError``). warm_start : bool, default False Only False is accepted (``NotImplementedError`` otherwise). copy_X_train : bool, default True Only True is accepted: X is always copied to float32 memory. random_state : None, default None Only None is accepted; without an optimizer nothing is random. multi_class : "one_vs_rest", default "one_vs_rest" ``"one_vs_one"`` raises ``NotImplementedError``; any other value raises ``ValueError``. n_jobs : None, default None Only None is accepted; the binary fits run on one device. numeric_mode : str or None, default None The numeric mode for this estimator; None uses the process default. See the numeric modes guide. Attributes ---------- classes_ : list The class labels, in sorted order. n_classes_ : int Number of classes. X_train_ : Array of shape (n_train, n_features), float32 The training inputs. estimators_ : list The binary fits: one for two classes, one per class past two. Each has ``y_train_``, ``L_``, ``pi_``, ``W_sr_``, ``log_marginal_likelihood_value_`` and ``n_iter_``. y_train_, L_, pi_, W_sr_ : Array Two classes only: the 0/1 targets, the lower Cholesky factor of ``I + W_sr K W_sr``, the training probabilities and the square root of the likelihood Hessian of the single binary fit. log_marginal_likelihood_value_ : float The binary fit's log marginal likelihood, or the mean over the binary fits past two classes. n_iter_ : int or list of int Newton iterations run: an int for two classes, one int per class past two. kernel_ : Kernel The kernel passed in. n_features_in_ : int Number of features seen during fit. input_copied_ : bool Whether fit had to copy X to reach float32 C order. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((200, 4), dtype=np.float32) >>> y = (X[:, 0] + X[:, 1] > 1.0).astype(np.int32) >>> model = mojolearn.GaussianProcessClassifier(mojolearn.RBF(0.5)).fit(X, y) >>> proba = model.predict_proba(X[:5]) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "classifier" _BINDING = "_mojolearn_gp"
[docs] def __init__( self, kernel=None, *, optimizer=None, n_restarts_optimizer=0, max_iter_predict=100, warm_start=False, copy_X_train=True, random_state=None, multi_class="one_vs_rest", n_jobs=None, ): if kernel is None: # scikit-learn's default, `_gpc.py:186-189`, bounds "fixed" # there; there is no optimizer here, so the two coincide. kernel = ConstantKernel(1.0) * RBF(1.0) if not isinstance(kernel, Kernel): raise TypeError( f"{_NAME}: kernel must be a composition of mojolearn's RBF, " "Matern, ConstantKernel and WhiteKernel (DEVIATION 1761)" ) if not (optimizer is None or (isinstance(optimizer, str) and optimizer.lower() == "none")): raise NotImplementedError( f"{_NAME}: optimizer={optimizer!r} is refused; only None runs " "(DEVIATION 1761). An optimizer's iteration count is data " "dependent and its line search, gradient fold and tolerance " "are not pinned. scikit-learn's default is 'fmin_l_bfgs_b', " "so this default DIFFERS from theirs and the fitted kernel_ " "is the kernel you passed" ) if isinstance(n_restarts_optimizer, bool) or int(n_restarts_optimizer) != 0: raise NotImplementedError( f"{_NAME}: n_restarts_optimizer={n_restarts_optimizer!r} is " "refused; it restarts the optimizer, which is refused " "(DEVIATION 1761)" ) if isinstance(max_iter_predict, bool) or not isinstance(max_iter_predict, int): raise TypeError( f"{_NAME}: max_iter_predict must be an int, got {max_iter_predict!r}" ) if max_iter_predict < 1: raise ValueError( f"{_NAME}: max_iter_predict must be at least 1, got {max_iter_predict}" ) if warm_start: raise NotImplementedError( f"{_NAME}: warm_start=True is refused. It starts the Newton " "loop from the previous fit's latent values (_gpc.py:454-459), " "so a second fit would depend on the first; every fit here " "starts from zero" ) if not copy_X_train: raise NotImplementedError( f"{_NAME}: copy_X_train=False is refused; X always crosses to " "float32 C-order memory, so there is no reference to keep" ) if random_state is not None: raise NotImplementedError( f"{_NAME}: random_state is refused; only the optimizer's " "restarts draw random numbers in the reference, and the " "optimizer is refused" ) if multi_class == "one_vs_one": raise NotImplementedError( f"{_NAME}: multi_class='one_vs_one' is refused BY NAME; it is " "not implemented (and the reference gives it no " "predict_proba). 'one_vs_rest' runs (DEVIATION 2833)" ) if multi_class != "one_vs_rest": raise ValueError( f"{_NAME}: multi_class must be 'one_vs_rest' or 'one_vs_one', " f"got {multi_class!r}" ) if n_jobs is not None: raise NotImplementedError( f"{_NAME}: n_jobs={n_jobs!r} is refused; the one-vs-rest " "binary fits run one after another on one device" ) self.kernel = kernel self.optimizer = None self.n_restarts_optimizer = 0 self.max_iter_predict = int(max_iter_predict) self.warm_start = False self.copy_X_train = True self.random_state = None self.multi_class = "one_vs_rest" self.n_jobs = None
# -- the binding ---------------------------------------------------------- def _extension(self): """`_mojolearn_gp` (or the host binding) with its compile-time tier cross-checked against the requested one, `_gp_impl.py`'s rule.""" mod = self._bind() want = getattr(self, "numeric_mode", None) or _backend.default_mode() fn = getattr(mod, "gp_numeric_mode", None) if fn is not None: got = int(fn()) if got != _MODE_CODE.get(want): raise RuntimeError( f"{_NAME}: numeric_mode={want!r} was requested but " f"{mod.__name__} reports compile-time mode code {got}; " "rebuild it with bash bindings/build_gp.sh" ) return mod # -- fit ------------------------------------------------------------------ # Reference: sklearn `_gpc.py:164-226`.
[docs] def fit(self, X, y): """Fit the classifier. Encodes the classes, then runs one binary Laplace fit for two classes or one per class against the rest. Parameters ---------- X : array-like of shape (n_samples, n_features) Training inputs, converted to float32. y : array-like of shape (n_samples,) Class labels; at least two distinct classes (``ValueError`` otherwise). Returns ------- self : GaussianProcessClassifier """ x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") n_rows, n_cols = x.shape classes, codes = encode_labels(y) if codes.size != n_rows: raise ValueError(f"{_NAME}: y has {codes.size} entries, X has {n_rows} rows") n_classes = len(classes) if n_classes == 1: raise ValueError( f"{_NAME} requires 2 or more distinct classes; got 1 class " f"(only class {classes[0]!r} is present)" ) kinds, kparams, ls_len, ls, n_ls = _kernel_arrays(self.kernel) ext = self._extension() columns = [1] if n_classes == 2 else list(range(n_classes)) codes32 = Array.from_list(codes, "<i4") if _gpc_all(ext): fits = self._fit_all(ext, x, codes32, kinds, kparams, ls_len, ls, n_ls, columns) else: fits = [] for k in columns: # glue: one binding call per one-vs-rest class fits.append(self._fit_binary(ext, x, codes32, kinds, kparams, ls_len, ls, n_ls, k=k)) self._set_fitted(x, classes, fits) return self
def _fit_all(self, ext, x, codes32, kinds, kparams, ls_len, ls, n_ls, columns): """Every binary fit in one binding call (`gpc_fit_all`): X uploaded and its kernel matrix computed once, the class targets built on the device. Returns the `_BinaryLaplace` list `_fit_binary` would.""" n_rows, n_cols = x.shape codes, _ = as_i32_c(codes32, ndim=1, name="class codes") # Per fit: targets, factor, pi, W_sr and (lml, n_iter, nb). Every # array addressed below stays bound in `outs` for the call. outs = [] addrs = [ # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpc_fit_all_binding. addr_ro(x, name="x"), addr_ro(codes, name="codes"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), ] for _k in columns: # glue: allocates one output set per binary fit y01 = empty((n_rows,), "<f4") l_out = empty((n_rows * n_rows,), "<f4") pi = empty((n_rows,), "<f4") wsr = empty((n_rows,), "<f4") scalars = empty((3,), "<f8") outs.append((y01, l_out, pi, wsr, scalars)) addrs += [ addr(y01, name="y_out"), addr(l_out, name="l_out"), addr(pi, name="pi_out"), addr(wsr, name="wsr_out"), addr(scalars, name="scalars_out"), ] ext.gpc_fit_all( addrs, # n_train, n_features, n_nodes, n_ls, max_iter_predict, n_fits, then the classes [n_rows, n_cols, int(kinds.shape[0]), n_ls, self.max_iter_predict, len(columns)] + [int(k) for k in columns], # glue: the class of each binary fit ) return [ _BinaryLaplace(y01, l_out.reshape((n_rows, n_rows)), pi, wsr, float(scalars[0]), int(scalars[1]), int(scalars[2])) for y01, l_out, pi, wsr, scalars in outs # glue: wraps each fit's outputs ] def _fit_binary(self, ext, x, y01, kinds, kparams, ls_len, ls, n_ls, k=None): """With `k`, `y01` is the int32 class codes and the targets are `code == k`: the binding builds them and writes them back for the model (every build: the Python fallback loop was removed by lane fam2-kernel-gp, a GPU route does no Python arithmetic).""" n_rows, n_cols = x.shape extra_addrs, extra_params, codes = [], [], None if k is not None: codes, _ = as_i32_c(y01, ndim=1, name="class codes") y01 = empty((n_rows,), "<f4") extra_addrs, extra_params = [addr(y01, name="y_out")], [int(k)] l_out = empty((n_rows * n_rows,), "<f4") pi = empty((n_rows,), "<f4") wsr = empty((n_rows,), "<f4") # lml, n_iter, nb, in that order. scalars = empty((3,), "<f8") # Every array addressed below is bound to a local of this frame. n_iter = ext.gpc_fit( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpc_fit_binding. [ addr_ro(x, name="x"), addr_ro(codes if codes is not None else y01, name="y"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), addr(l_out, name="l_out"), addr(pi, name="pi_out"), addr(wsr, name="wsr_out"), addr(scalars, name="scalars_out"), ] + extra_addrs, # n_train, n_features, n_nodes, n_ls, max_iter_predict (+ k) [n_rows, n_cols, int(kinds.shape[0]), n_ls, self.max_iter_predict] + extra_params, ) return _BinaryLaplace(y01, l_out.reshape((n_rows, n_rows)), pi, wsr, float(scalars[0]), int(n_iter), int(scalars[2])) def _set_fitted(self, x, classes, fits): self.X_train_ = x self.n_features_in_ = int(x.shape[1]) self.classes_ = list(classes) self.n_classes_ = len(classes) self.estimators_ = fits self.kernel_ = self.kernel if self.n_classes_ == 2: only = fits[0] self.y_train_ = only.y_train_ self.L_ = only.L_ self.pi_ = only.pi_ self.W_sr_ = only.W_sr_ self.n_iter_ = only.n_iter_ self.log_marginal_likelihood_value_ = only.log_marginal_likelihood_value_ else: self.n_iter_ = [e.n_iter_ for e in fits] # glue: iteration counts per class # the per-class values summed in storage order by the native # reduction (lane py-runtime-b: was a Python accumulation) lml = Array.from_list([float(e.log_marginal_likelihood_value_) for e in fits], "<f8") # glue: one saved scalar per class self.log_marginal_likelihood_value_ = float(lml.sum()) / len(fits) # glue: Array.sum is the native reduce_stat over the k class values # -- prediction ------------------------------------------------------------- def _query(self, X): if not hasattr(self, "estimators_"): raise ValueError(f"{_NAME}: call fit() or load() first") q, _ = as_f32_c(X, ndim=2, name="X") if q.shape[1] != self.n_features_in_: raise ValueError( f"{_NAME}: X has {q.shape[1]} features, fit saw {self.n_features_in_}" ) return q def _latent(self, ext, est, q, want_proba, out_kind=0): """(mean, var, proba) of one binary fit at the query rows; var and proba are written only with `want_proba`. `out_kind` 1 / 2 (lane apple-fast-py2mojo-cluster): a fourth item, the int64 class codes of `predict` / the float64 `[1 - p, p]` rows of `predict_proba`, from the binding (`gaussian_process/unnorm.mojo::gpc_binary_out`).""" n_star = int(q.shape[0]) n_train, n_features = self.X_train_.shape kinds, kparams, ls_len, ls, n_ls = _kernel_arrays(self.kernel) mean = empty((n_star,), "<f4") var = empty((n_star,), "<f4") proba = empty((n_star,), "<f8") out = None if out_kind == 1: out = empty((n_star,), "<i8") elif out_kind == 2: out = empty((n_star, 2), "<f8") xt = self.X_train_ y01 = est.y_train_ pi = est.pi_ wsr = est.W_sr_ lf = est.L_ ext.gpc_predict( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpc_predict_binding. [ addr_ro(xt, name="xtrain"), addr_ro(y01, name="y"), addr_ro(pi, name="pi"), addr_ro(wsr, name="wsr"), addr_ro(lf, name="l"), addr_ro(q, name="xstar"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), addr(mean, name="mean_out"), addr(var, name="var_out"), addr(proba, name="proba_out"), ] + ([addr(out, name="out")] if out is not None else []), # n_train, n_features, n_star, n_nodes, n_ls, want_proba (+ out_kind) [int(n_train), int(n_features), n_star, int(kinds.shape[0]), n_ls, 1 if want_proba else 0] + ([out_kind] if out is not None else []), ) if out is not None: return mean, var, proba, out return mean, var, proba # Reference: sklearn `_gpc.py:410-441`, binary only as `_gpc.py:873-878`.
[docs] def latent_mean_and_variance(self, X): """Mean and variance of the latent function at ``X``. Binary models only, as in scikit-learn; a model with more than two classes raises ``ValueError``. Parameters ---------- X : array-like of shape (n_query, n_features) Query points, converted to float32. Returns ------- mean : Array of shape (n_query,), float32 var : Array of shape (n_query,), float32 """ q = self._query(X) if self.n_classes_ > 2: raise ValueError( "Returning the mean and variance of the latent function f is " "only supported for binary classification, received " f"{self.n_classes_} classes." ) mean, var, _ = self._latent(self._extension(), self.estimators_[0], q, True) return mean, var
# Reference: sklearn `_gpc.py:292-329` and `multiclass.py:523-562` # (DEVIATIONS 2832 and 2833).
[docs] def predict_proba(self, X): """Class probabilities for ``X``. Past two classes, each row of the per-class probabilities is divided by its sum, as in scikit-learn's one-vs-rest wrapper. Parameters ---------- X : array-like of shape (n_query, n_features) Query points, converted to float32. Returns ------- Array of shape (n_query, n_classes), float64 Columns follow ``classes_``. """ q = self._query(X) ext = self._extension() if _gpc_all(ext): return self._predict_all(ext, q, 2) if self.n_classes_ == 2: # `[1 - p, p]` from the binding (`gpc_binary_out`), every build return self._latent(ext, self.estimators_[0], q, True, 2)[3] cols = [self._latent(ext, e, q, True)[2] for e in self.estimators_] # glue: one latent column per class proba, _ = _ovr_combine(ext, cols, int(q.shape[0])) return proba
def _predict_all(self, ext, q, out_kind): """`gpc_predict_all`: every binary fit against one cross kernel in one device session. `out_kind` 1 returns the int64 class codes of `predict`, 2 the float64 `predict_proba` rows.""" n_star = int(q.shape[0]) n_train, n_features = self.X_train_.shape kinds, kparams, ls_len, ls, n_ls = _kernel_arrays(self.kernel) fits = self.estimators_ k = len(fits) if out_kind == 1: out = empty((n_star,), "<i8") else: out = empty((n_star, max(k, 2)), "<f8") xt = self.X_train_ addrs = [ # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpc_predict_all_binding. addr_ro(xt, name="xtrain"), addr_ro(q, name="xstar"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), addr(out, name="out"), ] for est in fits: # glue: four model addresses per binary fit addrs += [ addr_ro(est.y_train_, name="y"), addr_ro(est.pi_, name="pi"), addr_ro(est.W_sr_, name="wsr"), addr_ro(est.L_, name="l"), ] ext.gpc_predict_all( addrs, # n_train, n_features, n_star, n_nodes, n_ls, out_kind, k [int(n_train), int(n_features), n_star, int(kinds.shape[0]), n_ls, int(out_kind), k], ) return out # Reference: sklearn `_gpc.py:287-290`; multiclass argmax per DEVIATION 2833.
[docs] def predict(self, X): """Predict class labels for ``X``. With two classes, ``classes_[1]`` where the latent mean is positive and ``classes_[0]`` elsewhere. Past two, the class with the largest one-vs-rest probability; a tie goes to the first such class. Parameters ---------- X : array-like of shape (n_query, n_features) Query points, converted to float32. Returns ------- labels : Array or list of shape (n_query,) Labels drawn from ``classes_``: an int64 or float64 Array for numeric classes, a list for string or bool classes. """ q = self._query(X) ext = self._extension() if _gpc_all(ext): return decode_labels(self.classes_, self._predict_all(ext, q, 1)) if self.n_classes_ == 2: # `mean > 0` codes from the binding (`gpc_binary_out`), every build return decode_labels(self.classes_, self._latent(ext, self.estimators_[0], q, False, 1)[3]) else: cols = [self._latent(ext, e, q, True)[2] for e in self.estimators_] # glue: one latent column per class _, codes32 = _ovr_combine(ext, cols, int(q.shape[0])) codes = codes32.astype("<i8") return decode_labels(self.classes_, codes)
# Reference: sklearn `_gpc.py:279-353`, the `theta is None` arm only.
[docs] def log_marginal_likelihood(self, theta=None, eval_gradient=False, clone_kernel=True): """Log marginal likelihood of the fitted model. Only ``theta=None`` is supported: it returns ``log_marginal_likelihood_value_``. A theta raises ``NotImplementedError`` and ``eval_gradient=True`` raises ``ValueError``. Parameters ---------- theta : None, default None eval_gradient : bool, default False clone_kernel : bool, default True Accepted for scikit-learn compatibility; ignored. Returns ------- float """ if theta is not None: raise NotImplementedError( f"{_NAME}: log_marginal_likelihood(theta) at other " "hyperparameters is refused; it serves the optimizer, which is " "refused (DEVIATION 1761)" ) if eval_gradient: raise ValueError("Gradient can only be evaluated for theta!=None") if not hasattr(self, "estimators_"): raise ValueError(f"{_NAME}: call fit() first") return self.log_marginal_likelihood_value_
# A host summary outside the identity claim.
[docs] def score(self, X, y): """Mean accuracy of `predict` on ``X`` against ``y``. Parameters ---------- X : array-like of shape (n_samples, n_features) Test inputs. y : array-like of shape (n_samples,) True labels. Returns ------- float """ from ._expansion_metrics import accuracy_fraction from ._metrics_impl import _shape_of pred = self.predict(X) n_truth = int(_shape_of(y)[0]) if _shape_of(y) else 0 if n_truth != len(pred): raise ValueError(f"{_NAME}: y has {n_truth} entries for {len(pred)} rows") # the x_metrics binding's grouped match count over the row count return accuracy_fraction(y, pred)
# -- save and load ----------------------------------------------------------
[docs] def save(self, path): """Save the fitted model to ``path`` as an ``.npz`` file. The file holds everything prediction reads, byte for byte: the kernel, the float32 training inputs, the classes, the numeric mode and each binary fit's state. Raises ``RuntimeError`` if the model is not fitted. Parameters ---------- path : str or path-like Destination file. """ if not hasattr(self, "estimators_"): raise RuntimeError(f"{_NAME}: call fit before save") mode = getattr(self, "numeric_mode", None) or _backend.default_mode() if not isinstance(mode, str) or mode.strip().lower() not in _MODE_CODE: raise ValueError(f"{_NAME}: cannot save invalid numeric_mode {mode!r}") kinds, kparams, ls_len, ls, n_ls = _kernel_arrays(self.kernel) n_train, n_features = self.X_train_.shape fits = self.estimators_ arrays = { "format": _GPC_FORMAT, "estimator": _ESTIMATOR, "numeric_mode": mode.strip().lower(), "classes": classes_member(self.classes_), "x": self.X_train_, "kinds": kinds, "kparams": kparams, "ls_len": ls_len, "ls": ls, # n_train, n_features, n_estimators, max_iter_predict, n_ls, n_classes "meta": Array.from_list([int(n_train), int(n_features), len(fits), int(self.max_iter_predict), int(n_ls), int(self.n_classes_)], "<i8"), "y": _stacked([e.y_train_ for e in fits], "<f4", (n_train,)), # glue: one buffer per class fit "L": _stacked([e.L_ for e in fits], "<f4", (n_train * n_train,)), # glue: one buffer per class fit "pi": _stacked([e.pi_ for e in fits], "<f4", (n_train,)), # glue: one buffer per class fit "wsr": _stacked([e.W_sr_ for e in fits], "<f4", (n_train,)), # glue: one buffer per class fit "lml": Array.from_list([float(e.log_marginal_likelihood_value_) for e in fits], "<f8"), # glue: saved per-class fit scalars "n_iter": Array.from_list([int(e.n_iter_) for e in fits], "<i8"), # glue: saved per-class fit scalars "nb": Array.from_list([int(e.nb_) for e in fits], "<i8"), # glue: saved per-class fit scalars } return _serialize.write_npz(path, arrays)
[docs] @classmethod def load(cls, path): """Load a model written by `save`. The loaded model supports `predict`, `predict_proba` and `latent_mean_and_variance`, including on a CPU-only install. Every array is read at its saved dtype and never cast; a file saved by a different estimator or with inconsistent contents raises ``ValueError``. Parameters ---------- path : str or path-like File written by `save`. Returns ------- GaussianProcessClassifier """ arrays = _serialize.read_npz(path, _GPC_FORMAT) saved_as = _serialize.scalar_str(arrays, "estimator") if saved_as != _ESTIMATOR: raise ValueError(f"mojolearn: {path!r} was saved by {saved_as}, not {_ESTIMATOR}") mode = _serialize.scalar_str(arrays, "numeric_mode") if mode not in _MODE_CODE: raise ValueError(f"mojolearn: invalid saved numeric_mode {mode!r}") meta = _serialize.exact(arrays, "meta", "<i8").tolist() if len(meta) != 6: raise ValueError(f"mojolearn: {path!r} meta holds {len(meta)} fields, 6 are needed") n_train, n_features, n_est, max_iter, n_ls, n_classes = (int(v) for v in meta) # glue: six saved meta fields kinds = _serialize.exact(arrays, "kinds", "<i4").tolist() kparams = _serialize.exact(arrays, "kparams", "<f4").tolist() ls_len = _serialize.exact(arrays, "ls_len", "<i4").tolist() table = _serialize.exact(arrays, "ls", "<f4").tolist() if len(kparams) != len(kinds) or len(ls_len) != len(kinds) or sum(ls_len) != n_ls: # glue: kernel node count check raise ValueError(f"mojolearn: {path!r} kernel arrays disagree with each other") nodes, off = [], 0 for k, p, ln in zip(kinds, kparams, ls_len): # glue: rebuilds the kernel's postfix nodes nodes.append((k, p, table[off:off + ln])) off += ln obj = cls(kernel=_SavedKernel(nodes), max_iter_predict=max_iter) obj.numeric_mode = mode x = _serialize.exact(arrays, "x", "<f4") if tuple(x.shape) != (n_train, n_features): raise ValueError(f"mojolearn: {path!r} x has shape {tuple(x.shape)}, meta says " f"({n_train}, {n_features})") classes = classes_from_member(arrays["classes"]) want_est = 1 if n_classes == 2 else n_classes if len(classes) != n_classes or n_classes < 2 or n_est != want_est: raise ValueError(f"mojolearn: {path!r} holds {len(classes)} classes and {n_est} " f"binary fits for n_classes {n_classes}") def rows(name, dtype, width): a = _serialize.exact(arrays, name, dtype) if tuple(a.shape) != ((n_est, width) if width else (n_est,)): raise ValueError(f"mojolearn: {path!r} {name} has shape {tuple(a.shape)}") return a ys = rows("y", "<f4", n_train) ls_ = rows("L", "<f4", n_train * n_train) pis = rows("pi", "<f4", n_train) wsrs = rows("wsr", "<f4", n_train) lmls = rows("lml", "<f8", 0) iters = rows("n_iter", "<i8", 0) nbs = rows("nb", "<i8", 0) fits = [] for e in range(n_est): # glue: one loaded fit per class # row e of each saved block, copied by memoryview runs (no per-element Python) fits.append(_BinaryLaplace( ys[e], ls_[e].reshape((n_train, n_train)), pis[e], wsrs[e], float(lmls[e]), int(iters[e]), int(nbs[e]), )) obj._set_fitted(x, classes, fits) if n_est > 1: obj.log_marginal_likelihood_value_ = obj.log_marginal_likelihood_value_ return obj
__all__ = ["GaussianProcessClassifier"]