Source code for mojolearn._gp_impl

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""Exact dense Gaussian process regression on the GPU.

PRIVATE MODULE. `GaussianProcessRegressor` and the four kernel classes
(`RBF`, `Matern`, `ConstantKernel`, `WhiteKernel`) are re-exported from
`mojolearn/__init__.py`.

EXPOSED 2026-09-01, AND THE HISTORY OF THE WITHHOLDING IS PART OF THIS
FILE'S CONTRACT. This estimator sat in `__init__.py`'s `_NOT_YET` -- the
only entry ever placed there for a reason other than a missing surface --
because its IDENTICAL card was believed to diverge Apple against AMD on 8
of 3,494 stages. That reading was WITHDRAWN at `9835094e` (2026-09-01):
the eight differing lines are the SABOTAGED half of one `GP_SAB_STD_EXP`
clean-then-sabotaged pair, an arm that exists precisely because a device
`exp` is a vendor's choice in its last bit, and `check_gp_sabotages`
writes both halves of every pair into the same card. The SHIPPED path is
byte-identical Apple M4 against AMD MI325X on the other 3,486 lines. See
`bench/results/e1/GP_CROSS_VENDOR_DIVERGENCE.md` (corrected in place) and
`gaussian_process/README.md`. With the sole blocker withdrawn, the
estimator is exposed.

**THERE IS NO REFERENCE GAUSSIAN PROCESS.** cuML, cuVS and RAFT implement
none at the pinned commits, so this lane adds the capability per `ok-to-add-capability`.
scikit-learn's `sklearn/gaussian_process/_gpr.py` is the SEMANTICS
reference and the oracle, never the design source; every name this surface
shares with scikit-learn means what scikit-learn means by it.

THE BINDING THIS FILE CALLS IS `_mojolearn_gp`, AND ITS ABI WAS
RENEGOTIATED ONCE, ON MEASUREMENT. This file landed (22a5b550) with the
binding OWED and a contract at each call site in which every buffer
address was its own positional argument -- ten arguments for `gpr_fit`,
thirteen for `gpr_predict`. The binding was written to that spelling
verbatim (8c449b70) and the first `bash bindings/build_gp.sh` FAILED AT
def_function ELABORATION (2026-09-01, log /tmp/build_gp_fast.log),
exactly as `bindings/_mojolearn.mojo`'s header predicts above roughly
nine arguments. The maintainer renegotiated BOTH sides the same day to
the tree's own fold -- the one `knn_search` took when its ten arguments
hit the same wall: each entry point takes exactly TWO Python lists,
`addrs` (every buffer address, order written in the binding docstring
and at the call site below in the same words) and `params` (the scalar
list 22a5b550 spelled, unchanged to the word). EVERY ARRAY WHOSE ADDRESS
GOES INTO `addrs` MUST BE BOUND TO A LOCAL for the duration of the call:
an address inside a list keeps nothing alive (`_buffer.py`). The rest of
the contract is unchanged: function names, and that the kernel spec MUST
be rebuilt through `gaussian_process/checks/kernels.mojo`'s constructors
so their refusals stay reachable. The three-tier build is still RUN
OWED.

WHAT THE KERNEL CLASSES ARE. A covariance function here is a POSTFIX node
list over five flat arrays (`GPKernelSpec`, DEVIATION 1756), which is
exactly what a binding can hand over without a graph representation. The
four classes below and their `+` / `*` operators build that list and
nothing else: they hold no bounds, no `theta` and no gradient, because
hyperparameter optimization is not implemented (DEVIATION 1761) and the
pieces that exist only to serve it are absent rather than present and
unused. HYPERPARAMETERS ARE NOT VALIDATED IN PYTHON: a NaN noise level, a
non-positive length scale, a Matern `nu` outside the three closed forms
each go down to the Mojo constructors unjudged, so those refusals
(DEVIATIONS 1765, 1768) stay reachable from this surface -- the same rule
that keeps `SVR`'s epsilon refusals reachable.

THE LANE'S STANDING: gated on the Apple M4 in both tiers, eleven checks
each, nine sabotage arms driven at run time; the IDENTICAL card
byte-identical Apple M4 against AMD MI325X on every shipped-path line
(at `9835094e`'s reading of the 2026-08-28 cards). The third column came
later: the identity harness's 136-lane record at 4048e1b51
(bench/results/identity_break/2026-09-14_136-lanes/diff.three-columns.txt)
reads IDENTICAL x3 on all 36 gp training cells and 36 infer cells across
the Apple M4, an NVIDIA H100 and an AMD MI325X under IDENTICAL. The gp SPEED ladder is UNRUN:
exposure is a correctness claim, not a
speed claim.
"""

from . import _portable_math as math
import numbers

from . import _backend
from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, empty

#: `gaussian_process/gp_optim_items.mojo`'s stop words (GP_STOP_*).
_OPT_STOPS = ("running", "pgtol", "no-descent", "line-search", "ftol", "max-iter", "nonfinite-start")
from ._mode import NumericModeMixin
from .linear_model import _flatten, _r2_sums, _round_f32, _shape_of
from .preprocessing import StandardScaler

#: `checks/numerics.mojo` codes, duplicated from `_backend._MODE_CODE` on
#: purpose, for `_arima_impl.py`'s reason: the read-back must not share a
#: table with the thing it checks.
_MODE_CODE = {"fast": 0, "identical": 1, "deterministic": 2}

# The GP_K_* node kinds, mirroring gaussian_process/checks/kernels.mojo.
# A silent renumbering on either side is a WRONG KERNEL, not a failure,
# which is why the surface test plants a fixture whose answer moves if the
# kind codes move.
_K_CONST = 0
_K_WHITE = 1
_K_RBF = 2
_K_MATERN = 3
_K_SUM = 4
_K_PROD = 5


def _as_length_scale(length_scale, what):
    """A list of floats from a scalar or a sequence. CONVERSION ONLY, no
    judgement: positivity, finiteness and the isotropic-or-ARD length rule
    are `gp_kernel_rbf` / `gp_kernel_matern` / `gp_validate_kernel`'s
    refusals (kernels.mojo), and a copy of them here would make the named
    Mojo refusal unreachable from Python and therefore untestable."""
    if not _shape_of(length_scale):
        return [float(length_scale)]
    ls = [float(v) for v in _flatten(length_scale)]  # glue: converts the length-scale argument
    if len(ls) == 0:
        raise ValueError(
            f"mojolearn {what}: length_scale is an empty sequence; pass a "
            "scalar (isotropic) or n_features values (ARD)"
        )
    return ls


#: FLT_MIN, the smallest normal binary32; `_ftz` flushes anything below it.
_FLT_MIN = 1.1754943508222875e-38


def _gp_py2mojo(ext):
    """True when the binding `ext` applies normalize_y's un-normalization
    itself (lane apple-fast-py2mojo-cluster: the mean, std, covariance and
    sample_y draws, `gaussian_process/unnorm.mojo`). A
    `-D MOJOLEARN_PY2MOJO_cluster_OFF` build answers 0 and the Python loops
    below run (the A/B switch)."""
    fn = getattr(ext, "gp_py2mojo", None)
    return fn is not None and int(fn()) == 1


def _gpr_ptr(ext):
    """True when the binding fits and predicts from the caller's memory on
    the device (`gpr_fit_ptr` / `gpr_predict_ptr`,
    `gaussian_process/gpr_resident.mojo`, lane fam2-kernel-gp): IDENTICAL
    GPU builds. The host binding and a `-D MOJOLEARN_IDN_GPR_PTR_OFF` build
    answer no and `gpr_fit` / `gpr_predict` run with the same lists."""
    fn = getattr(ext, "gp_idn_caps", None)
    return fn is not None and (int(fn()) & 2) == 2


def _ftz(x):
    """`checks/numerics.mojo::ftz` on a binary32 value held in a Python
    float: a subnormal becomes a zero of the same sign."""
    if x != 0.0 and abs(x) < _FLT_MIN:
        return math.copysign(0.0, x)
    return x

#: scikit-learn's default hyperparameter bounds, `kernels.py` (every leaf).
_DEFAULT_BOUNDS = (1e-5, 1e5)


def _as_bounds(bounds, n, what):
    """"fixed" or a list of `n` `(lo, hi)` float pairs. A single pair applies
    to every entry of an ARD length scale, scikit-learn's rule. Each bound
    must be finite and positive with `lo <= hi` (theta is its log;
    DEVIATION 2880)."""
    if isinstance(bounds, str):
        if bounds != "fixed":
            raise ValueError(f"mojolearn {what}: bounds must be a (low, high) pair or 'fixed', got {bounds!r}")
        return "fixed"
    shape = _shape_of(bounds)
    if shape == (2,):
        pairs = [tuple(float(v) for v in _flatten(bounds))] * n  # glue: converts the bounds argument
    elif shape == (n, 2):
        flat = [float(v) for v in _flatten(bounds)]  # glue: converts the bounds argument
        pairs = list(zip(flat[0::2], flat[1::2]))
    else:
        raise ValueError(
            f"mojolearn {what}: bounds must be 'fixed', a (low, high) pair, or {n} pairs; got shape {shape}")
    for lo, hi in pairs:  # glue: validates each hyperparameter bound pair
        if not (math.isfinite(lo) and math.isfinite(hi) and 0.0 < lo <= hi):
            raise ValueError(
                f"mojolearn {what}: every bound must be finite and positive with low <= high, got "
                f"({lo!r}, {hi!r}); theta is the natural log of a hyperparameter (DEVIATION 2880)")
    return pairs


# Maintainer notes: the four classes build a postfix node list (`a b +` /
# `a b *`) over GPKernelSpec's flat arrays; nothing is distributed or
# reassociated (DEVIATION 1756). Hyperparameters per DEVIATION 2880: a user's
# kernel computes `theta` with the owned host math helper; the fit uses the
# same pinned logarithm arithmetic through its Mojo binding, and a fitted
# `kernel_` reports the optimizer's own theta.
class Kernel:
    """Base class of the Gaussian process kernels.

    Kernels combine with ``+`` and ``*``: ``k1 + k2`` is the sum and
    ``k1 * k2`` the product of two kernels, as in scikit-learn's ``Sum`` and
    ``Product``. Only mojolearn kernels combine; write a scaled kernel as
    ``ConstantKernel(2.0) * RBF(...)`` rather than ``2.0 * RBF(...)``
    (combining a kernel with anything else raises ``TypeError``).

    The hyperparameter view follows scikit-learn: `theta` is the natural log
    of every free hyperparameter, left operand first, with one entry per
    feature for an anisotropic length scale; `bounds` is the log of each
    entry's ``(low, high)`` pair; `n_dims` is their count. A kernel built
    with bounds ``"fixed"`` contributes no entries.
    """

    def __add__(self, other):
        return _Combined(self, other, _K_SUM, "+")

    def __mul__(self, other):
        return _Combined(self, other, _K_PROD, "*")

    def _nodes(self):
        """Postfix list of `(kind, param, length_scales)` tuples."""
        raise NotImplementedError

    def _hyper(self):
        """Postfix list, one per node: `None` for an operator or a fixed leaf,
        else `(values, bounds)` for the leaf's free entries."""
        raise NotImplementedError

    def _replace(self, values):
        """A copy with the free entries replaced by `values` (consumed from
        the front of the list, which is mutated)."""
        raise NotImplementedError

    def _free_flags(self):
        return [0 if h is None else 1 for h in self._hyper()]  # glue: one flag per postfix node

    def _free_values(self):
        return [v for h in self._hyper() if h is not None for v in h[0]]  # glue: collects free kernel hyperparameters (n_dims entries)

    def _free_bounds(self):
        return [b for h in self._hyper() if h is not None for b in h[1]]  # glue: collects bounds of free kernel hyperparameters (n_dims entries)

    def _with_free_values(self, values, theta=None):
        out = self._replace(list(values))
        if theta is not None:
            out._theta_fit = [float(t) for t in theta]  # glue: copies the fitted theta, kernel hyperparameters (n_dims entries)
        return out

    @property
    def n_dims(self):
        """Number of free hyperparameters (the length of `theta`)."""
        return len(self._free_values())

    @property
    def theta(self):
        """Natural log of the free hyperparameters, as a list of floats.

        On a fitted ``kernel_`` this is the optimizer's own theta.
        """
        fitted = getattr(self, "_theta_fit", None)
        if fitted is not None:
            return list(fitted)
        return [math.log(v) for v in self._free_values()]  # glue: sklearn theta API, log of free kernel hyperparameters (n_dims entries)

    @property
    def bounds(self):
        """Natural log of each free hyperparameter's ``(low, high)`` bounds,
        as a list of ``[low, high]`` pairs."""
        return [[math.log(lo), math.log(hi)] for lo, hi in self._free_bounds()]  # glue: sklearn bounds API, log of kernel hyperparameters (n_dims entries) bounds

    def __repr__(self):
        return self._name()


# Reference: scikit-learn `kernels.py:1187`.
[docs] class ConstantKernel(Kernel): """Constant kernel: ``k(x, y) = constant_value`` for every pair of points. Follows scikit-learn's ``ConstantKernel``. Multiply it with another kernel to scale that kernel's magnitude. Parameters ---------- constant_value : float, default 1.0 The constant. constant_value_bounds : pair of float or "fixed", default (1e-5, 1e5) Bounds for the optimizer, or ``"fixed"`` to keep the value fixed. Both bounds must be finite and positive with ``low <= high``; otherwise ``ValueError`` is raised. Examples -------- >>> import mojolearn >>> kernel = mojolearn.ConstantKernel(2.0) * mojolearn.RBF(1.0) """
[docs] def __init__(self, constant_value=1.0, constant_value_bounds=_DEFAULT_BOUNDS): self.constant_value = float(constant_value) self.constant_value_bounds = _as_bounds(constant_value_bounds, 1, "ConstantKernel")
def _nodes(self): return [(_K_CONST, self.constant_value, [])] def _hyper(self): b = self.constant_value_bounds return [None if b == "fixed" else ([self.constant_value], b)] def _replace(self, values): v = values.pop(0) if self.constant_value_bounds != "fixed" else self.constant_value return ConstantKernel(v, self.constant_value_bounds) def _name(self): return f"ConstantKernel({self.constant_value!r})"
# Reference: scikit-learn `kernels.py:1325`; diagonal-only rule is DEVIATION 1762.
[docs] class WhiteKernel(Kernel): """White noise kernel: adds ``noise_level`` to the training diagonal. Follows scikit-learn's ``WhiteKernel``: the noise appears only on the diagonal of the training covariance, and contributes zero to the covariance between training and query points. Add it to another kernel to model observation noise. Parameters ---------- noise_level : float, default 1.0 The noise variance. noise_level_bounds : pair of float or "fixed", default (1e-5, 1e5) Bounds for the optimizer, or ``"fixed"`` to keep the value fixed. Both bounds must be finite and positive with ``low <= high``; otherwise ``ValueError`` is raised. Examples -------- >>> import mojolearn >>> kernel = mojolearn.RBF(1.0) + mojolearn.WhiteKernel(0.1) """
[docs] def __init__(self, noise_level=1.0, noise_level_bounds=_DEFAULT_BOUNDS): self.noise_level = float(noise_level) self.noise_level_bounds = _as_bounds(noise_level_bounds, 1, "WhiteKernel")
def _nodes(self): return [(_K_WHITE, self.noise_level, [])] def _hyper(self): b = self.noise_level_bounds return [None if b == "fixed" else ([self.noise_level], b)] def _replace(self, values): v = values.pop(0) if self.noise_level_bounds != "fixed" else self.noise_level return WhiteKernel(v, self.noise_level_bounds) def _name(self): return f"WhiteKernel({self.noise_level!r})"
# Reference: scikit-learn `kernels.py:1448`.
[docs] class RBF(Kernel): """Radial basis function (squared exponential) kernel. Follows scikit-learn's ``RBF``: ``k(x, y) = exp(-0.5 * ||(x - y) / length_scale||^2)``. Parameters ---------- length_scale : float or sequence of float, default 1.0 A scalar gives an isotropic kernel; a sequence of ``n_features`` values gives one length scale per feature (anisotropic). Any other length is refused when the model is fitted. Values must be positive and finite; they are checked at fit time. length_scale_bounds : pair of float, sequence of pairs, or "fixed", default (1e-5, 1e5) Bounds for the optimizer: one pair for every length scale, one pair per length scale, or ``"fixed"``. Both bounds must be finite and positive with ``low <= high``; otherwise ``ValueError`` is raised. Examples -------- >>> import mojolearn >>> kernel = mojolearn.RBF(length_scale=[1.0, 2.0, 0.5]) """
[docs] def __init__(self, length_scale=1.0, length_scale_bounds=_DEFAULT_BOUNDS): self.length_scale = _as_length_scale(length_scale, "RBF") self.length_scale_bounds = _as_bounds(length_scale_bounds, len(self.length_scale), "RBF")
def _nodes(self): return [(_K_RBF, 0.0, list(self.length_scale))] def _hyper(self): b = self.length_scale_bounds return [None if b == "fixed" else (list(self.length_scale), b)] def _replace(self, values): ls = list(self.length_scale) if self.length_scale_bounds != "fixed": ls = [values.pop(0) for _ in ls] # glue: consumes this leaf's length scales return RBF(ls, self.length_scale_bounds) def _name(self): return f"RBF({self.length_scale!r})"
# Reference: scikit-learn `kernels.py:1601`. `nu` is not checked here so # that `gp_kernel_matern`'s refusal (DEVIATION 1765) stays reachable.
[docs] class Matern(Kernel): """Matern kernel with ``nu`` in {0.5, 1.5, 2.5}. Follows scikit-learn's ``Matern``, with the same argument order. Only the three closed forms are supported: ``nu=0.5`` (absolute exponential), ``1.5`` (once differentiable) and ``2.5`` (twice differentiable). Any other ``nu``, including ``inf``, is refused with an error when the model is fitted; use `RBF` for the ``nu=inf`` limit. Parameters ---------- length_scale : float or sequence of float, default 1.0 A scalar gives an isotropic kernel; a sequence of ``n_features`` values gives one length scale per feature (anisotropic). Values must be positive and finite; they are checked at fit time. length_scale_bounds : pair of float, sequence of pairs, or "fixed", default (1e-5, 1e5) Bounds for the optimizer: one pair for every length scale, one pair per length scale, or ``"fixed"``. Both bounds must be finite and positive with ``low <= high``; otherwise ``ValueError`` is raised. nu : float, default 1.5 Smoothness: 0.5, 1.5 or 2.5. Examples -------- >>> import mojolearn >>> kernel = mojolearn.Matern(length_scale=1.0, nu=2.5) """
[docs] def __init__(self, length_scale=1.0, length_scale_bounds=_DEFAULT_BOUNDS, nu=1.5): self.length_scale = _as_length_scale(length_scale, "Matern") self.length_scale_bounds = _as_bounds(length_scale_bounds, len(self.length_scale), "Matern") self.nu = float(nu)
def _nodes(self): return [(_K_MATERN, self.nu, list(self.length_scale))] def _hyper(self): b = self.length_scale_bounds return [None if b == "fixed" else (list(self.length_scale), b)] def _replace(self, values): ls = list(self.length_scale) if self.length_scale_bounds != "fixed": ls = [values.pop(0) for _ in ls] # glue: consumes this leaf's length scales return Matern(ls, self.length_scale_bounds, nu=self.nu) def _name(self): return f"Matern({self.length_scale!r}, nu={self.nu!r})"
class _Combined(Kernel): def __init__(self, a, b, op, sym): if not isinstance(a, Kernel) or not isinstance(b, Kernel): raise TypeError( "mojolearn gaussian process kernels compose only with each " "other; sklearn's magnitude-scaling shorthand " "`2.0 * RBF(...)` is spelled ConstantKernel(2.0) * RBF(...) " "here, so the constant that runs is one you constructed" ) self.a, self.b, self.op, self.sym = a, b, op, sym def _nodes(self): return self.a._nodes() + self.b._nodes() + [(self.op, 0.0, [])] def _hyper(self): return self.a._hyper() + self.b._hyper() + [None] def _replace(self, values): return _Combined(self.a._replace(values), self.b._replace(values), self.op, self.sym) def _name(self): return f"({self.a._name()} {self.sym} {self.b._name()})" class _SavedKernel(Kernel): """A kernel read back from a saved model: its postfix `(kind, param, length_scales)` nodes exactly as `_nodes()` produced them at save time, so `_kernel_arrays()` hands the binding the same four arrays the fit handed it (the neighbors and density inference lane, 2026-09-15).""" def __init__(self, nodes): self.__nodes = [(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.__nodes] # glue: copies the kernel's postfix nodes def _hyper(self): # A saved kernel carries no bounds: every hyperparameter is fixed. return [None for _ in self.__nodes] # glue: one None per postfix node def _replace(self, values): return _SavedKernel(self.__nodes) def _name(self): return f"_SavedKernel({len(self.__nodes)} nodes)" #: The model file (the neighbors and density inference lane, 2026-09-15): #: `X_train` `<f4` (n, d), `L` `<f4` (n, n), `alpha` `<f4` (n) as fitted; the #: kernel's postfix spec as `kinds` `<i4`, `params` `<f8`, `ls_len` `<i4` and the #: `ls` table `<f8` (the Python floats `_nodes()` held, so the binary32 arrays #: predict builds are the fit's); `ints` `<i8` [n_features_in_, info_, #: normalize_y_, n_ls]; `reals` `<f8` [alpha, _y_train_mean, _y_train_std, #: log_marginal_likelihood_value_]. `mojolearn.host_model(path)` predicts the #: mean and std from it on a CPU through the inference-only gp binding, with #: normalize_y's scale-back in `predict` as on the GPU. _GP_FORMAT = "mojolearn-gp-1" # Maintainer notes (moved from the docstring): # - The scikit-learn surface over `gaussian_process/estimator.mojo` # (DEVIATIONS 1750-1771 are the lane's; 1772 is this surface's). No # reference GP exists in cuML/cuVS/RAFT; scikit-learn `_gpr.py` is the # semantics oracle only. # - House rule: a parameter accepted and ignored is a wrong answer waiting # for a caller, so each parameter is honored or refused by name. # - alpha IS the Cholesky profile's jitter; there is no second jitter knob # (DEVIATION 1751). Passed through UNCLAMPED so the Mojo refusals stay # reachable: NaN, negative and +inf refused by name (DEVIATION 1768); # under identical only +0.0 and 2**-20 are accepted (DEVIATION 1637). # - DEVIATION 1772: the default alpha is 2**-20, not sklearn's 1e-10. (a) # Every kernel matrix this lane builds has a unit diagonal, and in float32 # 1.0 + 1e-10 rounds to exactly 1.0 (the gap above 1.0 is 2**-23), so # sklearn's default ridge would be a bitwise no-op here, measured by # `check_duplicate_inputs_need_the_ridge` (DEVIATION 1752). (b) Under # identical the Cholesky profile accepts exactly +0.0 and 2**-20. 2**-20 is # the smallest float32 ridge on a unit diagonal that does anything and is # `gaussian_process/estimator.mojo::gp_profile_alpha()`'s value. # - optimizer: 'fmin_l_bfgs_b' uses the identical gradient (DEVIATION 2880) # and a projected L-BFGS whose every rule is pinned (DEVIATION 2881, # gaussian_process/gp_optim_items.mojo, one binding call on the device). # None stays the default so a fit that ran before runs the same bits. # Restarts are Philox keyed by random_state (DEVIATION 2881). # - normalize_y: sklearn `_gpr.py:275-285`; the two folds are # StandardScaler's pinned float32 reductions in this estimator's tier; the # un-normalization is `std * mean + y_mean` and `sqrt(var * std**2)`, each # one correctly rounded binary32 operation, flushed like `ftz`. # - Non-finite X/y/length scales are refused on the Mojo host naming the flat # index (DEVIATION 1768), before any launch. # - A failed Cholesky returns LAPACK info instead of raising (DEVIATION 1634); # predict refuses it in Mojo, log_marginal_likelihood here with the Mojo # entry's own sentences. # - return_cov / sample_y: DEVIATION 2793. GP classification (Laplace, Newton # iteration) is `_gpc_impl.py` (DEVIATION 2830 closes DEVIATION 1766). # - Cross-vendor record: the IDENTICAL card is byte-identical Apple M4 vs AMD # MI325X on every shipped-path line (the 8-line divergence reading was # withdrawn at 9835094e: those lines are the sabotaged half of a # GP_SAB_STD_EXP pair). The identity harness's 136-lane record at # 4048e1b51 reads IDENTICAL x3 on every gp training and infer cell across # Apple M4, H100 and MI325X.
[docs] class GaussianProcessRegressor(NumericModeMixin): """Exact Gaussian process regression on the GPU. Follows scikit-learn's ``GaussianProcessRegressor``: ``fit`` builds the kernel matrix ``K`` of the training points, adds ``alpha`` to its diagonal, factors it by Cholesky and solves for the dual coefficients; ``predict`` returns the posterior mean and, on request, the standard deviation or the full covariance. X is dense float32 in C order (other inputs are converted; sparse input is not accepted) and y is 1-D: one target per model. Differences from scikit-learn that change results: - The default ``alpha`` is ``2**-20``, not ``1e-10``. Kernel matrices have a unit diagonal, and in float32 ``1.0 + 1e-10`` is exactly ``1.0``, so scikit-learn's default would add nothing. - The default ``optimizer`` is ``None``: the kernel you pass is used as is. Pass ``optimizer="fmin_l_bfgs_b"`` (scikit-learn's default) to fit the hyperparameters by maximizing the log marginal likelihood. - A failed Cholesky factorization does not raise. ``fit`` returns normally and records LAPACK's code in ``info_``; check that it is 0. A nonzero ``info_`` means this kernel and ridge do not describe the data (common causes: duplicate training rows, a length scale far larger than the data's spread, ``alpha=0``). ``predict`` and ``log_marginal_likelihood`` then raise an error. Under ``numeric_mode="identical"`` (the default), fit, predict and sample_y give the same bits on NVIDIA and AMD GPUs. Parameters ---------- kernel : Kernel or None, default None A combination of `RBF`, `Matern`, `ConstantKernel` and `WhiteKernel` with ``+`` and ``*``. None means ``ConstantKernel(1.0, "fixed") * RBF(1.0, "fixed")``, scikit-learn's default. Other kernel types, including scikit-learn kernel objects, raise ``TypeError``. alpha : float, default 2**-20 Value added to the diagonal of the training kernel matrix (a ridge). NaN, negative and infinite values are refused at fit time. Under ``numeric_mode="identical"`` only ``0.0`` and ``2**-20`` are accepted. Not to be confused with the fitted ``alpha_``. optimizer : None or "fmin_l_bfgs_b", default None None fits the kernel as passed. ``"fmin_l_bfgs_b"`` maximizes the log marginal likelihood over the kernel's free hyperparameters (those whose bounds are not ``"fixed"``) with a bounded L-BFGS that runs on the device. A callable raises ``NotImplementedError``; any other value raises ``ValueError``. n_restarts_optimizer : int, default 0 Number of extra optimizer runs from log-uniform random starting points within the bounds. The run with the best likelihood wins; a tie goes to the earlier run. normalize_y : bool, default False Center and scale y by its mean and standard deviation before the fit, and scale the predictions back, as in scikit-learn. A zero standard deviation scales by one. copy_X_train : bool, default True Only True is accepted: X is always copied to float32 memory. False raises ``NotImplementedError``. n_targets : None, default None Only None is accepted; multi-target models are not supported. random_state : int or None, default None Seed for the optimizer's restart points, an int in ``[0, 2**64)``. Required when ``optimizer`` is set and ``n_restarts_optimizer > 0``. `sample_y` takes its own ``random_state``. numeric_mode : str or None, default None The numeric mode for this estimator; None uses the process default. See the numeric modes guide. Attributes ---------- X_train_ : Array of shape (n_train, n_features), float32 The training inputs. y_train_ : Array of shape (n_train,), float32 The training targets, normalized when ``normalize_y=True`` (as in scikit-learn). kernel_ : Kernel The fitted kernel: the kernel you passed when ``optimizer=None``, otherwise a copy with the optimized hyperparameters. L_ : Array of shape (n_train, n_train), float32 Lower Cholesky factor of ``K + alpha * I``. alpha_ : Array of shape (n_train,), float32 Dual coefficients ``(K + alpha * I)^-1 y`` (scikit-learn's ``alpha_``; not the ``alpha`` ridge parameter). log_marginal_likelihood_value_ : float Log marginal likelihood of the fitted kernel. Meaningful only when ``info_ == 0``. info_ : int LAPACK's Cholesky code. 0 means ``L_`` is a valid factor; ``k > 0`` means the leading minor of order ``k`` was not positive definite. nb_ : int The Cholesky panel width used. n_features_in_ : int Number of features seen during fit. input_copied_ : bool Whether fit had to copy X to reach float32 C order. clamped_ : Array of shape (n_query,), int32 Set by ``predict(X, return_std=True)``: a per-point flag marking variances that were clamped to zero. n_clamped_ : int Set by ``predict(X, return_std=True)``: how many variances were clamped to zero. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((200, 3), dtype=np.float32) >>> y = np.sin(X.sum(axis=1)).astype(np.float32) >>> kernel = (mojolearn.ConstantKernel(1.0) * mojolearn.RBF([1.0, 1.0, 1.0]) ... + mojolearn.WhiteKernel(0.01)) >>> model = mojolearn.GaussianProcessRegressor(kernel, optimizer="fmin_l_bfgs_b").fit(X, y) >>> ok = model.info_ == 0 >>> mean, std = model.predict(X[:5], return_std=True) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "regressor" _BINDING = "_mojolearn_gp"
[docs] def __init__( self, kernel=None, *, alpha=2.0 ** -20, optimizer=None, n_restarts_optimizer=0, normalize_y=False, copy_X_train=True, n_targets=None, random_state=None, ): if kernel is None: # sklearn's documented default, `_gpr.py:229-231`, bounds # "fixed" as there, so the default kernel is never optimized. kernel = ConstantKernel(1.0, "fixed") * RBF(1.0, "fixed") if not isinstance(kernel, Kernel): raise TypeError( "mojolearn GaussianProcessRegressor: kernel must be a " "composition of mojolearn's RBF, Matern, ConstantKernel and " "WhiteKernel (a scikit-learn kernel object carries bounds " "and a theta this implementation deliberately has no use for; " "DEVIATION 1761)" ) if callable(optimizer): raise NotImplementedError( "mojolearn GaussianProcessRegressor: a callable optimizer is " "refused; it would run caller code on host floats whose " "arithmetic no column can pin. optimizer='fmin_l_bfgs_b' runs " "the identical bounded quasi-Newton optimizer (DEVIATION 2881)" ) if optimizer is None or (isinstance(optimizer, str) and optimizer.lower() == "none"): optimizer = None elif optimizer != "fmin_l_bfgs_b": raise ValueError( f"mojolearn GaussianProcessRegressor: optimizer={optimizer!r} is " "not one of None and 'fmin_l_bfgs_b'" ) if isinstance(n_restarts_optimizer, bool) or not isinstance(n_restarts_optimizer, numbers.Integral) \ or int(n_restarts_optimizer) < 0: raise ValueError( "mojolearn GaussianProcessRegressor: n_restarts_optimizer must " f"be a non-negative int, got {n_restarts_optimizer!r}" ) n_restarts_optimizer = int(n_restarts_optimizer) if not isinstance(normalize_y, (bool, int)) or normalize_y not in (0, 1): raise TypeError( "mojolearn GaussianProcessRegressor: normalize_y must be a bool, " f"got {normalize_y!r}" ) if not copy_X_train: raise NotImplementedError( "mojolearn GaussianProcessRegressor: copy_X_train=False is " "refused. sklearn's False stores a REFERENCE to X; this " "surface always copies -- X crosses to float32 C-order host " "memory and to the device -- so False has nothing it could " "mean here, and accepting it would promise a memory saving " "that cannot be delivered" ) if n_targets is not None: raise NotImplementedError( "mojolearn GaussianProcessRegressor: n_targets is refused; " "it shapes sample_y's draws from the unfitted prior, which is " "not implemented. y is single-target here" ) if random_state is not None and ( isinstance(random_state, bool) or not isinstance(random_state, numbers.Integral) or not 0 <= int(random_state) < 2 ** 64): raise ValueError( "mojolearn GaussianProcessRegressor: random_state must be None " f"or an int in [0, 2**64), got {random_state!r}; it keys the " "restart draws (DEVIATION 2881). sample_y takes its own " "random_state argument (DEVIATION 2793)" ) if optimizer is not None and n_restarts_optimizer > 0 and random_state is None: raise ValueError( "mojolearn GaussianProcessRegressor: n_restarts_optimizer > 0 " "needs random_state to be an int; the restart starting points " "are position-mapped Philox keyed by it (DEVIATION 2881), and " "scikit-learn's None (the global NumPy generator) has no " "identical counterpart" ) self.kernel = kernel # CONVERSION, NOT POLICY (the SVR epsilon rule): a non-number still # raises here, and every value that IS a number goes down unclamped # so gp_validate_alpha's refusals -- NaN, negative, +inf, and the # identical tier's two-value pin -- stay reachable from Python. self.alpha = float(alpha) self.optimizer = optimizer self.n_restarts_optimizer = n_restarts_optimizer self.normalize_y = bool(normalize_y) self.copy_X_train = True self.n_targets = None self.random_state = None if random_state is None else int(random_state)
# -- the binding, and the tier it really is ----------------------------- def _extension(self): """The `_mojolearn_gp` binding for THIS estimator's tier, with the binary's own compile-time answer cross-checked against it -- `_arima_impl.py`'s pattern, for its reason: a wrong-arm measurement that is correctly labelled by accident is the failure the three-tier design exists to prevent.""" 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( "mojolearn GaussianProcessRegressor: numeric_mode=" f"{want!r} was requested but {mod.__name__} reports " f"compile-time mode code {got}; the binary and the " "directory it sits in disagree, rebuild it with " "bash bindings/build_gp.sh" ) return mod def _kernel_arrays(self, kernel=None): """The postfix spec as four flat arrays, the shape a binding can take (DEVIATION 1756: GPKernelSpec is five parallel host lists; the offsets are the one list NOT sent, because the binding MUST rebuild the spec through gp_kernel_const/white/rbf/matern and gp_kernel_sum/prod -- which recompute them -- so that every constructor refusal stays reachable from this surface). The kernel is the FITTED `kernel_` once there is one, else `kernel`.""" if kernel is None: kernel = getattr(self, "kernel_", None) or self.kernel nodes = kernel._nodes() # `Array.from_list(..., '<f4')` rounds each Python float to binary32 # exactly as `np.array(dtype=np.float32)` did (DEVIATION 2376). 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 n_ls = len(table) # Never a zero-length buffer: mirror estimator.mojo's # _length_scale_table -- one unused 1.0 stands in, and n_ls says so. ls = Array.from_list(table if table else [1.0], "<f4") return kinds, params, ls_len, ls, n_ls # -- fit ---------------------------------------------------------------- # Reference: sklearn `_gpr.py:349-367`: `K = kernel(X); K[diag] += alpha; # L = cholesky(K); alpha_ = cho_solve(L, y)` plus the log marginal # likelihood, in one device call (`gpr_fit_host`). DEVIATION 1634: a # failed factorization is reported in `info_`, not raised.
[docs] def fit(self, X, y): """Fit the Gaussian process to training data. With an optimizer, the kernel hyperparameters are fitted first. Then ``K + alpha * I`` is factored and the dual coefficients and log marginal likelihood are computed on the device. Parameters ---------- X : array-like of shape (n_samples, n_features) Training inputs, converted to float32. y : array-like of shape (n_samples,) Training targets. A 2-D y raises ``ValueError``. Returns ------- self : GaussianProcessRegressor The fitted model. Check ``info_ == 0``: a failed Cholesky factorization is reported there rather than raised. """ x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") n_rows, n_cols = x.shape shape = _shape_of(y) if len(shape) != 1: raise ValueError( "mojolearn GaussianProcessRegressor: y must be 1-D, got " f"{len(shape)}-D shape {shape}; multi-target GP fits are not " "implemented (the n_targets refusal in __init__ is this same " "boundary)" ) # No finiteness check here: check the class table -- non-finite # cells are refused BY NAME on the Mojo host (DEVIATION 1768), and # a duplicate check here would make that refusal unreachable. targets, _ = as_f32_c(y, ndim=1, name="y") if targets.shape[0] != n_rows: raise ValueError( f"mojolearn GaussianProcessRegressor: y has {targets.shape[0]} " f"entries, X has {n_rows} rows" ) if self.normalize_y: # sklearn `_gpr.py:275-280`. StandardScaler's pinned folds in # this estimator's tier; for 1-D y scikit-learn's # `_handle_zeros_in_scale` takes its scalar arm (`std == 0 -> 1`), # StandardScaler's exact-zero rule. column = targets.reshape((n_rows, 1)) scaler = StandardScaler(numeric_mode=self.numeric_mode_used()).fit(column) y_mean = float(scaler.mean_[0]) y_std = float(scaler.scale_[0]) targets = scaler.transform(column).reshape((n_rows,)) else: y_mean, y_std = 0.0, 1.0 kernel_ = self.kernel self._optimizer_runs = [] if self.optimizer is not None and self.kernel.n_dims > 0: kernel_ = self._optimize(x, targets, n_rows, n_cols) kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays(kernel_) # EVERY SIZE IS A FUNCTION OF (n_rows, n_cols) ALONE; nothing here # is a worst-case buffer (contrast SVR's DEVIATION 873 note). l_out = empty((n_rows * n_rows,), "<f4") dual = empty((n_rows,), "<f4") # info, nb, logdet, ydotalpha, lml -- in that order. scalars = empty((5,), "<f8") # TWO LISTS, NOT FOURTEEN ARGUMENTS (the module header's ABI note: # the positional spelling failed def_function elaboration, measured # 2026-09-01). Every array addressed below is bound to a local in # this frame -- x, targets, kinds, kparams, ls_len, ls, l_out, # dual, scalars -- which is what keeps the addresses alive. ext = self._extension() info = (ext.gpr_fit_ptr if _gpr_ptr(ext) else ext.gpr_fit)( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_fit_binding. # x, y, kinds, kparams, ls_len, ls, l_out, dual_out, scalars_out [ addr_ro(x, name="x"), addr_ro(targets, name="targets"), 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(dual, name="dual"), addr(scalars, name="scalars"), ], # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_fit_binding. # n_train, n_features, n_nodes, n_ls, alpha [n_rows, n_cols, int(kinds.shape[0]), n_ls, self.alpha], ) self.X_train_ = x self.y_train_ = targets self._y_train_mean = y_mean self._y_train_std = y_std self.normalize_y_ = bool(self.normalize_y) self.kernel_ = kernel_ self.n_features_in_ = n_cols self.L_ = l_out.reshape((n_rows, n_rows)) self.alpha_ = dual self.info_ = int(info) self.nb_ = int(scalars[1]) self.log_marginal_likelihood_value_ = float(scalars[4]) self._logdet = float(scalars[2]) self._ydotalpha = float(scalars[3]) return self
# -- the optimizer (DEVIATIONS 2880, 2881) ------------------------------- def _lml_grad(self, kernel, x, targets, n_rows, n_cols): """`(info, lml, grad)` at `kernel` through `gpr_lml_grad`: the float32 likelihood and gradient over the kernel's free entries, widened.""" kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays(kernel) free = Array.from_list(kernel._free_flags(), "<i4") n_free = kernel.n_dims grad = empty((max(n_free, 1),), "<f8") scalars = empty((2,), "<f8") info = self._extension().gpr_lml_grad( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_lml_grad_binding. # x, y, kinds, kparams, ls_len, ls, free, grad_out, scalars_out [addr_ro(x, name="x"), addr_ro(targets, name="targets"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), addr_ro(free, name="free"), addr(grad, name="grad"), addr(scalars, name="scalars")], # n_train, n_features, n_nodes, n_ls, alpha [n_rows, n_cols, int(kinds.shape[0]), n_ls, self.alpha], ) return int(info), float(scalars[1]), [float(grad[i]) for i in range(n_free)] # glue: reads the gradient of kernel hyperparameters (n_dims entries) def _optimize(self, x, targets, n_rows, n_cols): """scikit-learn `_gpr.py:299-341`: optimize from the kernel's theta, then from `n_restarts_optimizer` log-uniform starts; the smallest negative likelihood wins and a tie goes to the earlier run (`np.argmin`). ONE binding call (`gpr_optimize`) runs every start on the device (DEVIATION 2881, `gaussian_process/gp_optim_items.mojo`); Python only hands the kernel over and reads the answer. Returns the fitted kernel, whose `theta` is the winning run's. Every run is recorded in `_optimizer_runs` as `(n_iter, n_eval, stop, -f)`.""" base = self.kernel kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays(base) free = Array.from_list(base._free_flags(), "<i4") n_theta = base.n_dims bounds = Array.from_list([float(v) for b in base._free_bounds() for v in (b[0], b[1])], "<f4") # glue: packs bounds of free kernel hyperparameters (n_dims entries) n_runs = 1 + int(self.n_restarts_optimizer) theta_out = empty((n_theta,), "<f8") values_out = empty((n_theta,), "<f8") runs_out = empty((n_runs * 4,), "<f8") # random_state may be None only with no restarts (checked in __init__); # the kernel then draws no restart point and ignores the seed. seed = 0 if self.random_state is None else int(self.random_state) self._extension().gpr_optimize( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_optimize_binding. # x, y, kinds, kparams, ls_len, ls, free, bounds, theta_out, # values_out, runs_out [addr_ro(x, name="x"), addr_ro(targets, name="targets"), addr_ro(kinds, name="kinds"), addr_ro(kparams, name="kparams"), addr_ro(ls_len, name="ls_len"), addr_ro(ls, name="ls"), addr_ro(free, name="free"), addr_ro(bounds, name="bounds"), addr(theta_out, name="theta_out"), addr(values_out, name="values_out"), addr(runs_out, name="runs_out")], # n_train, n_features, n_nodes, n_ls, alpha, n_restarts, seed_lo, seed_hi [n_rows, n_cols, int(kinds.shape[0]), n_ls, self.alpha, n_runs - 1, seed & 0xFFFFFFFF, (seed >> 32) & 0xFFFFFFFF], ) for r in range(n_runs): # glue: one record per optimizer restart n_iter, n_eval, stop, f = (float(runs_out[r * 4 + j]) for j in range(4)) # glue: four fields per optimizer run self._optimizer_runs.append((int(n_iter), int(n_eval), _OPT_STOPS[int(stop)], -f)) theta = [float(theta_out[i]) for i in range(n_theta)] # glue: reads the fitted theta, kernel hyperparameters (n_dims entries) params = [float(values_out[i]) for i in range(n_theta)] # glue: reads fitted kernel hyperparameters (n_dims entries) return base._with_free_values(params, theta=theta) # -- predict ------------------------------------------------------------ # Reference: sklearn `_gpr.py:446-500`. Variance clamp and flags: # DEVIATION 1760. A fit with `info_ != 0` is refused in Mojo # (`gpr_predict_host`, DEVIATION 1634); `info_` is passed down so that # refusal stays reachable. return_cov: DEVIATION 2793.
[docs] def predict(self, X, return_std=False, return_cov=False): """Predict with the posterior of the fitted Gaussian process. Parameters ---------- X : array-like of shape (n_query, n_features) Query points, converted to float32. return_std : bool, default False Also return the predictive standard deviation. Negative variances from rounding are clamped to zero; ``clamped_`` and ``n_clamped_`` record which points were clamped. return_cov : bool, default False Also return the full predictive covariance ``k(X, X) - V^T V``, exactly symmetric and not clamped. At most one of ``return_std`` and ``return_cov`` may be True (``RuntimeError`` otherwise). Returns ------- mean : Array of shape (n_query,), float32 std : Array of shape (n_query,), float32 Only with ``return_std=True``. cov : Array of shape (n_query, n_query), float32 Only with ``return_cov=True``. Raises ------ ValueError If the model is not fitted (predicting from the unfitted prior is not supported), or X has a different number of features than the training data. Notes ----- A model whose fit failed (``info_ != 0``) refuses to predict. With ``normalize_y=True`` the mean, standard deviation and covariance are scaled back to the units of y. """ if return_cov and return_std: raise RuntimeError( "mojolearn GaussianProcessRegressor: at most one of return_std " "or return_cov can be requested (scikit-learn's rule)" ) if return_cov: return self._predict_cov(X) if not hasattr(self, "alpha_"): raise ValueError( "mojolearn GaussianProcessRegressor: call fit() first (the " "unfitted-prior arm of sklearn's predict is not implemented)" ) q, _ = as_f32_c(X, ndim=2, name="X") if q.shape[1] != self.n_features_in_: raise ValueError( f"mojolearn GaussianProcessRegressor: X has {q.shape[1]} " f"features, fit saw {self.n_features_in_}" ) n_star = q.shape[0] n_train = self.X_train_.shape[0] kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays() mean = empty((n_star,), "<f4") var = empty((n_star,), "<f4") std = empty((n_star,), "<f4") clamped = empty((n_star,), "<i4") # Kept in locals so the arrays outlive the call; the Mojo side # borrows these addresses and owns nothing, and an address inside # the list below keeps nothing alive on its own (_buffer.py). Two # lists, not fifteen arguments -- the module header's ABI note. xt = self.X_train_ lf = self.L_ dual = self.alpha_ ext = self._extension() norm = bool(getattr(self, "normalize_y_", False)) # the binding un-normalizes (every build; the Python fallback was # removed by lane fam2-kernel-gp) in_mojo = norm tail = [1, float(self._y_train_std), float(self._y_train_mean)] if in_mojo else [] n_clamped = (ext.gpr_predict_ptr if _gpr_ptr(ext) else ext.gpr_predict)( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_predict_binding. # xtrain, l, dual, xstar, kinds, kparams, ls_len, ls, # mean_out, var_out, std_out, clamped_out [ addr_ro(xt, name="xt"), addr_ro(lf, name="lf"), addr_ro(dual, name="dual"), addr_ro(q, name="q"), 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"), addr(var, name="var"), addr(std, name="std"), addr(clamped, name="clamped"), ], # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_predict_binding. # n_train, n_features, n_star, n_nodes, n_ls, return_std, info [n_train, self.n_features_in_, n_star, int(kinds.shape[0]), n_ls, 1 if return_std else 0, self.info_] + tail, ) if return_std: self.clamped_ = clamped self.n_clamped_ = int(n_clamped) return mean, std return mean
def _predict_cov(self, X): """`predict(X, return_cov=True)`, `_gpr.py:495-506`: `(mean, cov)`, float32 `(n,)` and `(n, n)`. `cov = k(X, X) - V^T V` in the steps `sample_y` takes before its factorization (DEVIATION 2793: `gpr_predict_cov_host`, `gpr_host_predict_cov`), exactly symmetric, not clamped; under `normalize_y` the mean is un-normalized as `predict`'s and each covariance cell is scaled by `std**2`, one rounding each, on the host.""" if not hasattr(self, "alpha_"): raise ValueError( "mojolearn GaussianProcessRegressor: call fit() first (the " "unfitted-prior arm of sklearn's predict is not implemented)" ) q, _ = as_f32_c(X, ndim=2, name="X") if q.shape[1] != self.n_features_in_: raise ValueError( f"mojolearn GaussianProcessRegressor: X has {q.shape[1]} " f"features, fit saw {self.n_features_in_}" ) n_star = q.shape[0] n_train = self.X_train_.shape[0] kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays() ext = self._extension() if not callable(getattr(ext, "gpr_predict_cov", None)): raise ImportError( f"mojolearn GaussianProcessRegressor: {ext.__name__} does not " "export gpr_predict_cov, so return_cov=True cannot run on it; " "rebuild the GP binding" ) mean = empty((max(n_star, 1),), "<f4") cov = empty((max(n_star * n_star, 1),), "<f4") norm = bool(getattr(self, "normalize_y_", False)) # the binding un-normalizes (every build; the Python fallback was # removed by lane fam2-kernel-gp) in_mojo = norm tail = [1, float(self._y_train_std), float(self._y_train_mean)] if in_mojo else [] xt = self.X_train_ lf = self.L_ dual = self.alpha_ ext.gpr_predict_cov( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_predict_cov_binding. # xtrain, l, dual, xstar, kinds, kparams, ls_len, ls, mean_out, cov_out [ addr_ro(xt, name="xt"), addr_ro(lf, name="lf"), addr_ro(dual, name="dual"), addr_ro(q, name="q"), 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"), addr(cov, name="cov"), ], # n_train, n_features, n_star, n_nodes, n_ls, info # (+ normalize_y, y_std, y_mean: the binding un-normalizes) [n_train, self.n_features_in_, n_star, int(kinds.shape[0]), n_ls, self.info_] + tail, ) return mean[:n_star], cov[:n_star * n_star].reshape((n_star, n_star)) # -- saved models ----------------------------------------------------------- # Format: `_GP_FORMAT`, layout documented above it.
[docs] def save(self, path): """Save the fitted model to ``path`` as an ``.npz`` file. The file holds everything `predict` reads: the training inputs, the Cholesky factor, the dual coefficients, the fitted kernel and the numeric mode. Raises ``RuntimeError`` if the model is not fitted. Parameters ---------- path : str or path-like Destination file. """ if not hasattr(self, "alpha_"): raise RuntimeError("this estimator is not fitted yet") from . import _serialize from .linear_model import _saved_mode nodes = self.kernel_._nodes() table = [float(v) for _, _, ls in nodes for v in ls] # glue: packs the kernel length-scale hyperparameters n = int(self.X_train_.shape[0]) return _serialize.write_npz(path, { "format": _GP_FORMAT, "estimator": type(self).__name__, "numeric_mode": _saved_mode(self), "X_train": self.X_train_, "L": self.L_.reshape((n * n,)), "alpha": self.alpha_, "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], "<f8"), # 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 "ls": Array.from_list(table if table else [1.0], "<f8"), "ints": Array.from_list([int(self.n_features_in_), int(self.info_), 1 if getattr(self, "normalize_y_", False) else 0, len(table)], "<i8"), "reals": Array.from_list([float(self.alpha), float(getattr(self, "_y_train_mean", 0.0)), float(getattr(self, "_y_train_std", 1.0)), float(self.log_marginal_likelihood_value_)], "<f8"), })
[docs] @classmethod def load(cls, path): """Load a model written by `save`. The loaded model predicts the mean and standard deviation. 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 ------- GaussianProcessRegressor """ from . import _serialize from .linear_model import _check_saved_by, _restore_mode arrays = _serialize.read_npz(path, _GP_FORMAT) _check_saved_by(arrays, path, cls) ints = _serialize.exact(arrays, "ints", "<i8") reals = _serialize.exact(arrays, "reals", "<f8") if ints.size != 4 or reals.size != 4: raise ValueError(f"mojolearn: {path!r} holds {ints.size} ints and {reals.size} reals, 4 and 4 are needed") d, info, norm, n_ls = (int(ints[i]) for i in range(4)) # glue: four saved meta fields x = _serialize.exact(arrays, "X_train", "<f4") if x.ndim != 2 or x.shape[1] != d or x.shape[0] < 1: raise ValueError(f"mojolearn: {path!r} X_train has shape {tuple(x.shape)}, ints say {d} features") n = int(x.shape[0]) l_flat = _serialize.exact(arrays, "L", "<f4") dual = _serialize.exact(arrays, "alpha", "<f4") if l_flat.size != n * n or dual.ndim != 1 or dual.size != n: raise ValueError(f"mojolearn: {path!r} L or alpha does not match {n} training rows") kinds = _serialize.exact(arrays, "kinds", "<i4") params = _serialize.exact(arrays, "params", "<f8") ls_len = _serialize.exact(arrays, "ls_len", "<i4") ls = _serialize.exact(arrays, "ls", "<f8") if not (kinds.size == params.size == ls_len.size) or kinds.size < 1: raise ValueError(f"mojolearn: {path!r} carries a malformed kernel spec") lens = [int(ls_len[i]) for i in range(ls_len.size)] # glue: saved length-scale count per node if sum(lens) != n_ls or ls.size != max(n_ls, 1): # glue: checks the saved kernel node lengths raise ValueError(f"mojolearn: {path!r} length scale table does not match its node lengths") nodes, off = [], 0 for i in range(kinds.size): # glue: rebuilds the kernel's postfix nodes nodes.append((int(kinds[i]), float(params[i]), [float(ls[off + j]) for j in range(lens[i])])) # glue: rebuilds the kernel's postfix nodes off += lens[i] obj = cls(kernel=_SavedKernel(nodes), alpha=float(reals[0]), normalize_y=bool(norm)) _restore_mode(obj, arrays) obj.X_train_ = x obj.kernel_ = obj.kernel obj.n_features_in_ = d obj.L_ = l_flat.reshape((n, n)) obj.alpha_ = dual obj.info_ = info obj.normalize_y_ = bool(norm) obj._y_train_mean = float(reals[1]) obj._y_train_std = float(reals[2]) obj.log_marginal_likelihood_value_ = float(reals[3]) return obj
# -- the rest of sklearn's surface, honored or refused by name ---------- # Reference: sklearn `_gpr.py:575`; theta arm per DEVIATION 2880; a # failed factorization answers -inf as `_gpr.py:593` does.
[docs] def log_marginal_likelihood(self, theta=None, eval_gradient=False, clone_kernel=True): """Log marginal likelihood of the training data. Parameters ---------- theta : sequence of float or None, default None None returns the value computed during fit, and raises ``RuntimeError`` if that fit's factorization failed. Otherwise, the log of the free hyperparameters of ``kernel_`` (one entry per ``kernel_.n_dims``; ``ValueError`` on a length mismatch): the likelihood is evaluated at ``exp(theta)`` in float32. A factorization that fails at that theta gives ``-inf`` and a zero gradient, as in scikit-learn. eval_gradient : bool, default False Also return the gradient with respect to theta. Requires a theta (``ValueError`` otherwise). clone_kernel : bool, default True Accepted for scikit-learn compatibility; ``kernel_`` is never modified. Returns ------- log_likelihood : float gradient : list of float Only with ``eval_gradient=True``. """ if not hasattr(self, "alpha_"): raise ValueError( "mojolearn GaussianProcessRegressor: call fit() first" ) if theta is not None: if eval_gradient not in (True, False): raise TypeError("mojolearn GaussianProcessRegressor: eval_gradient must be a bool") kern = self.kernel_ theta = [float(t) for t in _flatten(theta)] if _shape_of(theta) else [float(theta)] # glue: converts the theta argument, kernel hyperparameters (n_dims entries) if len(theta) != kern.n_dims: raise ValueError( f"mojolearn GaussianProcessRegressor: theta has {len(theta)} entries, " f"kernel_ has {kern.n_dims} free hyperparameters") ext = self._extension() params = [float(v) for v in ext.gp_theta_params(theta)] # glue: reads the binding's kernel hyperparameters (n_dims entries) n_rows, n_cols = self.X_train_.shape info, lml, grad = self._lml_grad(kern._with_free_values(params), self.X_train_, self.y_train_, n_rows, n_cols) if info != 0: lml, grad = -math.inf, [0.0] * len(theta) return (lml, grad) if eval_gradient else lml if eval_gradient: raise ValueError( "mojolearn GaussianProcessRegressor: eval_gradient=True needs a theta, " "scikit-learn's rule (_gpr.py:582)" ) if self.info_ != 0: # gpr_log_marginal_likelihood's refusal, restated: this is # the one Mojo guard the binding cannot serve (fit already # returned), so its sentences live here too. DEVIATION 1634. raise RuntimeError( "mojolearn GaussianProcessRegressor: the factorization " f"failed (info={self.info_}), so the leading minor of order " f"{self.info_} of K + alpha*I was not positive definite and " "there is no marginal likelihood to report. For a Gaussian " "process that is a RESULT and not a bug: this kernel and " "this ridge do not describe these points. The usual causes " "are duplicate or near-duplicate training rows, a length " "scale far larger than the spread of the data, and alpha=0. " "sklearn's optimizer answers -inf here (_gpr.py:593) " "because it is comparing candidates; there is no optimizer " "in this lane (DEVIATION 1761), so this refuses instead. " "DEVIATION 1634" ) return self.log_marginal_likelihood_value_
# DEVIATION 2793 (`gaussian_process/checks/sample_y.mojo`) names the # stream, the jitter and the order: the covariance `k(X, X) - V^T V` is # factored by the identical Cholesky with its pinned 2^-20 jitter, the # normals are position-mapped Philox keyed by random_state (DEVIATION # 2791's construction, tag "GPSY"), and the product is the identical GEMM.
[docs] def sample_y(self, X, n_samples=1, random_state=0): """Draw samples from the posterior at ``X``. Each sample is the predictive mean plus a factor of the predictive covariance times standard normals, scaled back to the units of y under ``normalize_y``, as in scikit-learn. The covariance is factored with a ``2**-20`` jitter on its diagonal. The same model, ``X``, ``n_samples`` and ``random_state`` give the same values on every call; they are not scikit-learn's values. A covariance that cannot be factored raises an error. Parameters ---------- X : array-like of shape (n_query, n_features) Query points, converted to float32. n_samples : int, default 1 Number of joint draws. random_state : int, default 0 Seed, an int in ``[0, 2**64)``. None and a NumPy ``RandomState`` raise ``ValueError``. Returns ------- Array of shape (n_query, n_samples), float32 Column ``s`` is one joint draw over the query points. Raises ------ ValueError If the model is not fitted (sampling the unfitted prior is not supported). """ if not hasattr(self, "alpha_"): raise ValueError( "mojolearn GaussianProcessRegressor: call fit() first (sampling " "the unfitted prior is not implemented)" ) if isinstance(n_samples, bool) or not isinstance(n_samples, numbers.Integral): raise TypeError("mojolearn GaussianProcessRegressor: n_samples must be an int") seed = random_state if (isinstance(seed, bool) or not isinstance(seed, numbers.Integral) or not 0 <= int(seed) < 2 ** 64): raise ValueError( "mojolearn GaussianProcessRegressor: sample_y needs random_state to " f"be an int in [0, 2**64), got {seed!r}; it is the key of every " "draw (DEVIATION 2793)" ) seed = int(seed) n = int(n_samples) q, _ = as_f32_c(X, ndim=2, name="X") if q.shape[1] != self.n_features_in_: raise ValueError( f"mojolearn GaussianProcessRegressor: X has {q.shape[1]} " f"features, fit saw {self.n_features_in_}" ) n_star = q.shape[0] n_train = self.X_train_.shape[0] kinds, kparams, ls_len, ls, n_ls = self._kernel_arrays() # Never a zero-length buffer: a refused n still addresses real memory. out = empty((max(n_star * n, 1),), "<f4") xt = self.X_train_ lf = self.L_ dual = self.alpha_ ext = self._extension() norm = bool(getattr(self, "normalize_y_", False)) # the binding un-normalizes (every build; the Python fallback was # removed by lane fam2-kernel-gp) in_mojo = norm tail = [1, float(self._y_train_std), float(self._y_train_mean)] if in_mojo else [] ext.gpr_sample_y( # ORDER MATCHES bindings/_mojolearn_gp.mojo::gpr_sample_y_binding. # xtrain, l, dual, xstar, kinds, kparams, ls_len, ls, y_out [ addr_ro(xt, name="xt"), addr_ro(lf, name="lf"), addr_ro(dual, name="dual"), addr_ro(q, name="q"), 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="y_out"), ], # n_train, n_features, n_star, n_nodes, n_ls, info, n_samples, # random_state low 32 bits, random_state high 32 bits [n_train, self.n_features_in_, n_star, int(kinds.shape[0]), n_ls, self.info_, n, seed & 0xFFFFFFFF, seed >> 32] + tail, ) return out.reshape((n_star, n))
# Host float64 summary of float32 predictions, outside the identity # claim (the SVR.score rule).
[docs] def score(self, X, y): """Coefficient of determination R^2 of the predictions. Uses scikit-learn's definition, accumulated in float64 from the float32 predictions. Parameters ---------- X : array-like of shape (n_samples, n_features) Test inputs. y : array-like of shape (n_samples,) True targets. Returns ------- float """ pred = self.predict(X) shape = _shape_of(y) if len(shape) != 1 or shape[0] != pred.shape[0]: raise ValueError( "mojolearn GaussianProcessRegressor: y must be 1-D with one " f"entry per row of X, got shape {shape} against " f"{pred.shape[0]} predictions" ) # Sequential Python float64 (`linear_model._r2_sums`, DEVIATION # 2365): a host reduction outside the identity claim. ss_res, ss_tot = _r2_sums(pred, y) if ss_tot == 0.0: return 1.0 if ss_res == 0.0 else 0.0 return 1.0 - ss_res / ss_tot