Source code for mojolearn.linear_model

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
"""GPU linear models. Reference: cuML's `LinearRegression(algorithm='eig')`,
`Ridge(solver='eig')` and `LogisticRegression(solver='qn')`.

NUMPY-FREE SINCE DEVIATION 2360 (branch numpy-free-0.7). Inputs cross the
boundary through `_buffer.as_f32_c` and friends, outputs are `_array.Array`,
and the centering of an intercept fit -- the column sums, the subtraction
and the sqrt-weight rescale -- runs in the estimators binding
(`lm_col_sums`, `lm_center`, `lm_scale_rows`; on the device on a GPU
install, lane hr-small-passes 2026-10-02). The column sums are exact,
rounded once to float64, so they do not depend on any summation order.
What is still Python here is named at each site: label encoding
(permitted, O(rows)).
"""

import array
from . import _portable_math as math

from . import _backend, _mojolearn_estimators, _serialize
from ._array import Array
from ._buffer import (
    addr, addr_ro, all_finite, as_f32_c, as_f64_c, empty, view, zeros,
)
from ._labels import (
    argmax_rows, classes_from_member, classes_member, decode_labels,
    encode_labels, flatten_labels, sorted_classes, threshold_codes,
)
from ._mode import NumericModeMixin

#: The model file formats (the classical host inference lane, 2026-09-13).
#: `save` writes exactly what `predict` reads, raw bytes and exact dtypes,
#: through `_serialize.write_npz`, so equal models give equal file hashes
#: on every machine; `load` refuses a cast (`_serialize.exact`). The
#: intercept travels as `<f8` because it is a Python float computed with
#: `math.fsum` at fit time and `predict` hands `float(self.intercept_)` to
#: the binding, so the file carries the exact value predict uses.
_LINEAR_FORMAT = "mojolearn-linear-1"
_LOGISTIC_FORMAT = "mojolearn-logistic-1"


def _saved_mode(est):
    """The tier `predict` would run on now, persisted as GradientBoosting
    persists it, so a loaded model never silently changes tier."""
    mode = getattr(est, "numeric_mode", None) or _backend.default_mode()
    if not isinstance(mode, str) or mode.strip().lower() not in (
        "fast", "deterministic", "identical"
    ):
        raise ValueError(f"mojolearn: cannot save invalid numeric_mode {mode!r}")
    return mode.strip().lower()


def _restore_mode(obj, arrays):
    mode = _serialize.scalar_str(arrays, "numeric_mode")
    if mode not in ("fast", "deterministic", "identical"):
        raise ValueError(f"mojolearn: invalid saved numeric_mode {mode!r}")
    obj.numeric_mode = mode


def _check_saved_by(arrays, path, cls):
    """The file's `estimator` must be `cls` or a base of it, so the host
    subclasses of `_classical_host.py` load the plain class's file."""
    saved_as = _serialize.scalar_str(arrays, "estimator")
    if saved_as not in (c.__name__ for c in cls.__mro__):  # glue: walks the class hierarchy names
        raise ValueError(
            f"mojolearn: {path!r} was saved by {saved_as}, not {cls.__name__}"
        )


def _save_linear(est, path, extra=None):
    """`LinearRegression.save` and `Ridge.save`: `coef_` `<f4`, the
    intercept `<f8`, `meta` `<i8` [n_features_in_, fit_intercept]."""
    if not hasattr(est, "coef_"):
        raise RuntimeError("this estimator is not fitted yet")
    arrays = {
        "format": _LINEAR_FORMAT,
        "estimator": type(est).__name__,
        "numeric_mode": _saved_mode(est),
        "coef": est.coef_,
        "intercept": Array.from_list([float(est.intercept_)], "<f8"),
        "meta": Array.from_list(
            [int(est.n_features_in_), 1 if est.fit_intercept else 0], "<i8"
        ),
    }
    if extra:
        arrays.update(extra)
    return _serialize.write_npz(path, arrays)


def _load_linear(cls, path, arrays=None, **kwargs):
    # Ridge has already read the archive to validate its alpha member. Reuse
    # that exact decoded mapping instead of reopening and materializing every
    # member a second time. LinearRegression still enters through the normal
    # one-read path.
    if arrays is None:
        arrays = _serialize.read_npz(path, _LINEAR_FORMAT)
    _check_saved_by(arrays, path, cls)
    meta = _serialize.exact(arrays, "meta", "<i8")
    if meta.size != 2:
        raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, 2 are needed")
    obj = cls(fit_intercept=bool(int(meta[1])), **kwargs)
    _restore_mode(obj, arrays)
    obj.coef_ = _serialize.exact(arrays, "coef", "<f4")
    obj.n_features_in_ = int(meta[0])
    if obj.coef_.ndim != 1 or obj.coef_.size != obj.n_features_in_:
        raise ValueError(f"mojolearn: {path!r} coef does not match n_features_in_")
    intercept = _serialize.exact(arrays, "intercept", "<f8")
    if intercept.size != 1:
        raise ValueError(f"mojolearn: {path!r} intercept must hold one value")
    obj.intercept_ = float(intercept[0])
    return obj, arrays


# ---------------------------------------------------------------------------
# Host helpers (DEVIATION 2360). Small on purpose; every one says what it
# costs.
# ---------------------------------------------------------------------------


def _round_f32(v):
    """`float(np.float32(v))`: ONE round-to-nearest-even of a Python float
    (binary64) to binary32 and back. `array.array('f')` stores through the C
    `(float)` cast, which is that rounding; an out-of-range value becomes
    +-inf exactly as NumPy's `astype(float32)` makes it."""
    return array.array("f", (float(v),))[0]


def _shape_of(obj):
    """The shape of an array-like WITHOUT converting it: `.shape` when the
    object has one (ndarray, Array, memoryview), the nesting of a list or
    tuple otherwise. Used only to raise the estimator's own message before
    `_buffer` would raise its generic one."""
    shape = getattr(obj, "shape", None)
    if shape is not None:
        return tuple(shape)
    if isinstance(obj, (list, tuple)):
        if obj and isinstance(obj[0], (list, tuple)):
            return (len(obj), len(obj[0]))
        return (len(obj),)
    if hasattr(obj, "__len__"):
        return (len(obj),)
    return ()


def _labels_1d(y):
    """`y` as a Python list of scalars, plus its shape. Labels may be ints,
    floats or strings, so this never goes through a float buffer; `tolist()`
    (ndarray, Array, array.array) or plain iteration. O(rows), which is the
    one Python loop the contract permits on labels."""
    shape = _shape_of(y)
    if len(shape) != 1:
        return None, shape
    return flatten_labels(y), shape


# The `classes_` ORDER RULE and the label <-> code maps live in
# `_labels.py` (DEVIATION 2340): `sorted_classes`, `decode_labels`. Every
# classifier in the package orders `classes_` through that one spelling;
# DEVIATION 2364 is this file's adoption of it (a Python list, int64 /
# float64 Arrays out of `predict` for int / float labels, a list otherwise).


def _flatten(obj):
    """Every leaf of a nested list/tuple (or anything with `tolist()`) as
    one flat Python list; a scalar is a one-element list."""
    if hasattr(obj, "tolist"):
        obj = obj.tolist()
    if isinstance(obj, (list, tuple)):
        out = []
        for v in obj:  # cpu-route: flattens a user Python list y, the explicit input step
            out.extend(_flatten(v))
        return out
    return [obj]


#: struct-format codes of the integer buffer types.
_INT_FORMATS = frozenset("bBhHiIlLqQnN")


def _buffer_format(obj):
    """The struct format code of a buffer-protocol object, byte-order prefix
    stripped; None when `obj` exports no buffer (a list, a scalar)."""
    if isinstance(obj, Array):
        return {"<f4": "f", "<f8": "d", "<i4": "i", "<i8": "q", "<u4": "I",
                "<u1": "B", "<f2": "e"}.get(obj.dtype)
    try:
        with view(obj) as buf:
            return buf.format.lstrip("<>=@!|")
    except TypeError:
        return None


def _is_integer_labels(y):
    """`np.issubdtype(y.dtype, np.integer)`: True for a buffer whose format
    is an integer code (bools are NOT integers here, as in NumPy), or a
    list/tuple whose every leaf is a Python int (bools excluded)."""
    fmt = _buffer_format(y)
    if fmt is not None:
        return fmt in _INT_FORMATS
    leaves = _flatten(y)
    return bool(leaves) and all(type(v) is int for v in leaves)  # cpu-route: kind test of a user Python list y, the explicit input step


def _dtype_name(y):
    """What to print for `y`'s dtype in a refusal: the buffer format code
    when it has one, the Python type name otherwise."""
    fmt = _buffer_format(y)
    if fmt is not None:
        return f"buffer format {fmt!r}"
    return f"type {type(y).__name__}"


def _accuracy_host(pred, y):
    """The fraction of rows where `pred == y`: the exact match count of the
    x_metrics binding's grouped sums (`_expansion_metrics.accuracy_fraction`,
    on the device on a GPU install) over the row count. Lane pyglue-sweep
    (Oct 3): this was a Python count over O(rows) labels."""
    from ._expansion_metrics import accuracy_fraction
    if len(_shape_of(y)) != 1 or _shape_of(y)[0] != len(pred):
        raise ValueError(
            "mojolearn: y must be 1-D with one entry per row of X"
        )
    return accuracy_fraction(y, pred)


def _target_1d(y, n_rows, ndim_msg, length_msg):
    """A 1-D C-contiguous float32 target of length `n_rows`, with the
    estimator's own two messages raised before `_buffer`'s generic ones."""
    if len(_shape_of(y)) != 1:
        raise ValueError(ndim_msg)
    t, _ = as_f32_c(y, ndim=1, name="y")
    if t.shape[0] != n_rows:
        raise ValueError(length_msg)
    return t


def _r2_sums(pred, y):
    """`(SS_res, SS_tot)` for scikit-learn's R^2 from float32 predictions
    and the target cast once to float32: the x_metrics binding's grouped
    sums (`_expansion_metrics`'s `reg_term` and pinned-sum stages, on the
    device on a GPU install, the same words on the host column), the
    target mean first, then both sums of squares, binary64 out. Lane
    pyglue-sweep (Oct 3): these were sequential Python float64 loops over
    the rows (DEVIATION 2365); the bits of `score()` move. Callers apply
    their own convention for `SS_tot == 0`."""
    from ._expansion_metrics import _Reg, _r2_sums_of
    t, _ = as_f32_c(y, ndim=1, name="y")
    p, _ = as_f32_c(pred, ndim=1, name="predictions")
    r = _Reg(t, p, None, "uniform_average", "score", variance_ok=True)
    # lane cpu2-l7-metrics: one program, the y mean rounded on the device
    return _r2_sums_of(r, None)


def _r2_host(pred, y):
    """`1 - SS_res / SS_tot`, and 0.0 for a constant target -- the linear
    models' NumPy-era convention (`if denom else 0.0`). See `_r2_sums`."""
    ss_res, ss_tot = _r2_sums(pred, y)
    return 1.0 - ss_res / ss_tot if ss_tot else 0.0


# The centering of LinearRegression and Ridge (lane hr-small-passes,
# 2026-10-02) runs in the estimators binding: `lm_col_sums`, `lm_center`
# and `lm_scale_rows`, on the device on a GPU install
# (glm/impl/center_device.mojo) and over the same items on the CPU column
# (glm/host/center_host.mojo), so every tier writes the same words. `b` is
# the estimator's `_bind("_mojolearn_estimators")`.


def _col_sums(b, x, rows, cols):
    """The column sums of a C-contiguous float32 `[rows, cols]` buffer: each
    the EXACT sum rounded once to float64 (round to nearest even), so the
    blocked device fold and the host pass agree on every vendor
    (glm/impl/center_items.mojo). Returns a float64 `Array` of `cols`."""
    out = empty((cols,), "<f8")
    b.lm_col_sums(addr_ro(x, name="X"), addr(out, name="column sums"),
                  [int(rows), int(cols)])
    return out


def _means(b, x, rows, cols, total=None):
    """(float64 means, float32 means) of the columns of a `[rows, cols]`
    buffer, as two `Array`s: `_col_sums` finished in Mojo
    (`lm_means_finish`, glm/impl/lm_finish.mojo): sum / rows (one binary64
    division), and with a `total` then `* rows / total`; the float32 means
    are one round-to-nearest-even of the float64 ones. A 1-D vector is a
    `[rows, 1]` matrix here."""
    sums = _col_sums(b, x, rows, cols)
    m64, m32 = empty((cols,), "<f8"), empty((cols,), "<f4")
    b.lm_means_finish(addr(sums, name="column sums"), addr(m64, name="means"),
                      addr(m32, name="float32 means"),
                      [int(cols), int(rows), 0 if total is None else 1],
                      0.0 if total is None else float(total))
    return m64, m32


def _weight_total(b, weights):
    """`sum(w)`: the exact sum rounded once to float64 (`_col_sums` over a
    `[rows, 1]` view)."""
    total = float(_col_sums(b, weights, weights.shape[0], 1)[0])
    if total <= 0.0:
        raise ValueError(
            "mojolearn: sample_weight sums to zero, so the weighted mean "
            "cuML's preProcessData forms is a division by zero"
        )
    return total


def _column_means(b, x, weights):
    """Column means in float64, narrowed to float32 -- weighted when
    `weights` is not None. Returns a float32 `Array`.

    Unweighted: `_means`. Weighted it is cuML's
    `raft::stats::weightedMean`, `sum_i w_i x_ij / sum_i w_i`, in THIS
    ORDER: (1) `wx_ij = fl32(x_ij * w_i)` (`lm_scale_rows`); (2) the
    column means of `wx`; (3) `mean_j * rows / total` in float64, `total`
    the weights' `_weight_total`; (4) one narrowing to float32 (steps 2-4
    are `lm_means_finish`). Theirs divides by the SUM OF THE WEIGHTS and
    not by the row count, so a uniform weight of 2 leaves the mean
    unchanged.
    """
    rows, cols = x.shape
    if weights is None:
        return _means(b, x, rows, cols)[1]
    total = _weight_total(b, weights)
    wx = _helper_output(x.shape, rows * cols)
    b.lm_scale_rows(addr_ro(x, name="X"), addr_ro(weights, name="sample_weight"),
                    addr(wx, name="weighted X"), [int(rows), int(cols)])
    return _means(b, wx, rows, cols, total)[1]


def _classical_xy_means(b, x, y):
    """C02 exact X/y sum streams in one native invocation; default OFF.
    NOT COMPILED — NOT TESTED — IDENTITY NOT VERIFIED — QUALITY NOT VERIFIED — NOT MEASURED.
    """
    flag = _optional_export(b, "lm_classical_stats")
    if flag is None or not bool(flag()):
        return None
    rows, cols = x.shape
    sx, sy = empty((cols,), "<f8"), empty((1,), "<f8")
    b.lm_col_sums_pair(addr_ro(x, name="X"), addr_ro(y, name="y"),
                       addr(sx, name="X sums"), addr(sy, name="y sum"), [rows, cols])
    xm64, xm32 = empty((cols,), "<f8"), empty((cols,), "<f4")
    ym64, ym32 = empty((1,), "<f8"), empty((1,), "<f4")
    b.lm_means_finish(addr(sx, name="X sums"), addr(xm64, name="X means"),
                      addr(xm32, name="X means32"), [cols, rows, 0], 0.0)
    b.lm_means_finish(addr(sy, name="y sum"), addr(ym64, name="y mean"),
                      addr(ym32, name="y mean32"), [1, rows, 0], 0.0)
    return xm32, float(ym64[0]), ym32


def _vector_mean(b, v, weights):
    """(float64 mean, float32 mean `Array` of one) of the target, weighted
    when `weights` is not None: the 1-D half of `_column_means`."""
    n = v.shape[0]
    if weights is None:
        m64, m32 = _means(b, v, n, 1)
    else:
        total = _weight_total(b, weights)
        wy = _helper_output(v.shape, n)
        b.lm_scale_rows(addr_ro(v, name="y"), addr_ro(weights, name="sample_weight"),
                        addr(wy, name="weighted y"), [int(n), 1])
        m64, m32 = _means(b, wy, n, 1, total)
    return float(m64[0]), m32


def _intercept(b, x_mean, coef, cols, y_mean):
    """`y_mean - math.fsum(x_mean_j * coef_j)` (binary64 products of the
    float32 words, exactly summed and rounded once) in Mojo
    (`lm_intercept`, glm/impl/lm_finish.mojo)."""
    return float(b.lm_intercept(addr_ro(x_mean, name="x mean"), addr_ro(coef, name="coef_"),
                                [int(cols)], float(y_mean)))


def _dims(x):
    """`(rows, cols)` of a 2-D Array, or `(rows, 1)` of a 1-D one: a vector
    is a `[rows, 1]` matrix to every helper in this file."""
    return (x.shape[0], x.shape[1]) if x.ndim == 2 else (x.shape[0], 1)


def _helper_output(shape, size):
    """DEVIATION 2632: the float32 destination of an op that writes EVERY
    element (`lm_center`, `lm_scale_rows`), taken from
    `_buffer._output_store`'s uninitialized raw allocation instead of
    `empty`, whose zero fill cost 105 to 121 ms of a 4,000,000 x 11
    LinearRegression fit on the H100 pod. No byte of the result comes from
    the allocation, so no bit moves."""
    from ._buffer import _output_store
    return Array._owned(_output_store("f", size), tuple(shape), "<f4", "C")


def _center(b, x, mu32):
    """`x - mu` in float32 per column (`lm_center`): one binary32
    subtraction per cell, operands and result flushed to signed zero when
    subnormal; `x` is `[rows, cols]` or a vector with `cols == 1`, `mu32` a
    float32 `Array` of `cols` means."""
    rows, cols = _dims(x)
    mean = mu32
    out = _helper_output(x.shape, rows * cols)
    b.lm_center(addr_ro(x, name="X"), addr_ro(mean, name="column means"),
                addr(out, name="centered X"), [int(rows), int(cols)])
    return out


def _shift(b, v, mu32):
    """The 1-D `_center`: `fl32(v_i - mu)`, `mu32` the float32 mean as a
    one-element `Array` (`_vector_mean`'s second result)."""
    return _center(b, v, mu32)


def _sqrt_weights(b, weights):
    """`fl32(sqrt(w_i))` per row of the float32 `weights`, in the binding
    (`py2mojo_rows` ROWS_SQRT_F32: `portable_sqrtf`, on the device on a GPU
    install, the same words on the host column). Lane pyglue-sweep (Oct 3):
    this was a Python comprehension over the rows."""
    n = int(weights.shape[0])
    out = empty((n,), "<f4")
    if n:
        b.py2mojo_rows(_ROWS_SQRT_F32, addr_ro(weights, name="sample_weight"),
                       addr(out, name="sqrt weights"), [n, 1])
    return out


def _scale_rows(b, x, root):
    """Row `i` of `x` times `root[i]` in float32 (`lm_scale_rows`): one
    binary32 multiplication per cell, operands and result flushed when
    subnormal; `root` a float32 Array of `rows` values."""
    rows, cols = _dims(x)
    w = root
    out = _helper_output(x.shape, rows * cols)
    b.lm_scale_rows(addr_ro(x, name="X"), addr_ro(w, name="sqrt weights"),
                    addr(out, name="scaled X"), [int(rows), int(cols)])
    return out


def _check_sample_weight(sample_weight, n_rows, estimator):
    """Validate `sample_weight` and return it as a C-order float32 Array,
    or None when there are no weights.

    cuML validates nothing here: `olsFit` takes a raw pointer and trusts it.
    These three checks are OURS and they are input validation, which is the
    one kind of refusal that is correct rather than a gap -- a length
    mismatch would read past the end of the array on the device, and a
    negative weight makes `sqrt(w)` a NaN that silently poisons the whole
    fit. Non-finite weights are refused for the same reason.
    """
    if sample_weight is None:
        return None
    shape = _shape_of(sample_weight)
    if len(shape) != 1:
        raise ValueError(
            f"mojolearn {estimator}: sample_weight must be 1-D, got "
            f"{len(shape)} dimensions"
        )
    w, _ = as_f32_c(sample_weight, ndim=1, name="sample_weight")
    if w.shape[0] != n_rows:
        raise ValueError(
            f"mojolearn {estimator}: sample_weight has {w.shape[0]} entries "
            f"but X has {n_rows} rows"
        )
    if not all_finite(w):
        raise ValueError(
            f"mojolearn {estimator}: sample_weight contains a non-finite "
            "value; sqrt of it would poison every row of the fit"
        )
    if w.min() < 0.0:
        raise ValueError(
            f"mojolearn {estimator}: sample_weight has a negative entry; "
            "a weighted least squares scales rows by sqrt(w) (ols.cuh:100) "
            "and sqrt of a negative is a NaN"
        )
    return w


def _ols_tsqr_on(rows, cols):
    """Whether `LinearRegression.fit` takes the blocked TSQR: a tall design
    (rows >= cols + 1, cols + 1 <= 512) and MOJOLEARN_LINALG_TSQR not 0."""
    from ._expansion_decomp import _tsqr_lstsq_on
    return _tsqr_lstsq_on(rows, cols, 1)


def _ols_normal_eq_default(b):
    """Whether this binding's build routes LinearRegression.fit to the
    equilibrated normal equations instead of the TSQR (lane
    apple-fast-olsne: FAST on Apple, the comptime OLS_FAST_NORMAL_EQ read
    back through `ols_normal_eq_default`; False on a binding without it)."""
    q = _optional_export(b, "ols_normal_eq_default")
    return bool(q()) if q is not None else False


def _linear_gram_fit(b, x, y, rows, cols, alpha, fit_intercept):
    """MOJOLEARN_CLASSICAL_LINEAR_GRAM_SOLVE (lane/classical-structural):
    `(coef, x_mean, y_mean)` from the binding's resident centered-Gram fit
    (glm/impl/gram_solve.mojo: X and y up once, the blocked centered Gram and
    cross, the IDENTICAL Cholesky), or None when the build lacks the route or
    the binding reports status 1 (the Gram did not factor or a pivot failed
    the trust gate): the caller's incumbent route then runs. Glue only: the
    binding computes every number."""
    q = _optional_export(b, "linear_gram_solve_default")
    fn = _optional_export(b, "linear_gram_fit")
    if q is None or fn is None or not bool(q()):
        return None
    coef = empty((cols,), "<f4")
    mu = empty((cols,), "<f4")
    ymean = empty((1,), "<f8")
    status = int(fn(addr_ro(x, name="X"), addr_ro(y, name="y"),
                    addr(coef, name="coef_"), addr(mu, name="column means"),
                    addr(ymean, name="y mean"),
                    [int(rows), int(cols), float(alpha), 1 if fit_intercept else 0]))
    if status != 0:
        return None
    if fit_intercept:
        return coef, mu, float(ymean.tolist()[0])
    return coef, zeros((cols,), "<f4"), 0.0


def _optional_export(b, name):
    """`name` from binding `b`, or None when it does not export it. A host
    binding's stand-in raises ImportError (by name) for a missing export
    rather than AttributeError, so a plain getattr default never applies on a
    CPU-only install, and LinearRegression.fit raised there."""
    try:
        return getattr(b, name, None)
    except (ImportError, AttributeError):
        return None


def _ols_tsqr(x, y, rows, cols, mode):
    """coef_ (float32, cols) of min ||x w - y|| (x and y already centered and
    weighted as `fit` prepares them) through the blocked TSQR of [x | y]
    (x_decomp/tsqr_core.mojo, lane neural-pass140): R_aug = [[R, Q^T y],
    [0, rho]] in one pass over the rows, then the minimum-norm solution from
    the SVD of the small R (`_expansion_decomp._tsqr_lstsq_core`). No Gram
    matrix, so the condition number is not squared. A singular value at or
    below cols * eps32 * s_max is dropped (the dependent directions of a
    rank-deficient design, a constant column centered to zero among them,
    get no weight: the minimum-norm solution). The columns are equilibrated
    first as the normal equations route does (DEVIATION 2620, lane
    apple-fast-tsqr): R's column j is scaled by the exact power of two that
    rule picks for ||x e_j||^2 before the SVD and coef_ is scaled by it
    after, so a column's units do not move the cutoff (istella's columns
    span seven orders of magnitude; unequilibrated, real directions fell
    under it). DEVIATION 2621 still differs: the cutoff is on singular
    values, not on squared ones."""
    from ._expansion_decomp import _F32_EPS, _Kit, _mode, _tsqr_lstsq_core
    k = _Kit(_mode(mode))
    X, _, _, _ = _tsqr_lstsq_core(k, x, y, rows, cols, 1, _F32_EPS * cols, equilibrate=True)
    return X.out((cols,))
# TOMBSTONE: MOJOLEARN_OLS_FAST_DEVICE_CENTER (DROPPED-semantics) deleted 2026-10-03 by dd83da010; code recoverable at dd83da010^.
# Restore: git apply experiments/removed/MOJOLEARN_OLS_FAST_DEVICE_CENTER.patch; record in docs/TOMBSTONES.md.


def _ols_tsqr_centered(x, y, rows, cols, mode):
    """`(coef_, column means, y mean)` of the unweighted fit with an
    intercept in ONE binding entry (lane idn-dense-linalg,
    `x_decomp_ols_tsqr_r`): X and y cross to the device once; the exact
    column sums, the means, the centering and the blocked TSQR of
    [X - mu | y - mean] read the resident buffers, then `_ols_tsqr`'s solve
    on the small R. The same words as `_column_means` + `_center` +
    `_ols_tsqr`, which crossed X four times. None when the binding does not
    route here (an older binding or a FAST build): the caller keeps that
    sequence. The IDENTICAL off arm (MOJOLEARN_IDN_OLS_ONE_ENTRY_OFF) was
    deleted 2026-10-08 (grid ge123e6f9: 2.97x/1.74x istella, 6.17x/3.83x
    taxi slower on NV/AMD); recoverable at main 42d1e42c6."""
    from ._expansion_decomp import _F32_EPS, _Kit, _M, _mode, _tsqr_lstsq_core
    k = _Kit(_mode(mode))
    flags = _optional_export(k._raw(), "x_decomp_idn_flags")
    if flags is None or not int(flags()) & 1:
        return None
    n = cols + 1
    Ra = _M.zeros(n, n)
    mu = empty((cols,), "<f4")
    ymean = empty((1,), "<f8")
    # ORDER MATCHES x_decomp/api.mojo ols_tsqr_r_py: (a, b, r_out, mu, ymean), (m, d)
    k.b.x_decomp_ols_tsqr_r(addr_ro(x, name="X"), addr_ro(y, name="y"), Ra.addr,
                            addr(mu, name="column means"), addr(ymean, name="y mean"),
                            [int(rows), int(cols)])
    # TOMBSTONE: MOJOLEARN_IDN_OLS_R_TRISOLVE (loser: noise, ols taxi/istella avg ~0.98x, bits changed) deleted 2026-10-11 by c14beeda5; code recoverable at c14beeda5^.
    # Restore: git apply experiments/removed/MOJOLEARN_IDN_OLS_R_TRISOLVE.patch
    X, _, _, _ = _tsqr_lstsq_core(k, x, y, rows, cols, 1, _F32_EPS * cols, equilibrate=True, Ra=Ra)
    return X.out((cols,)), mu, float(ymean.tolist()[0])


# Maintainer notes (moved from the docstring, 2026-10-12):
# - Default route (lane neural-pass140): the blocked TSQR of [X | y] and the SVD
#   of its small R (`_ols_tsqr`: no Gram matrix, the condition number not
#   squared) for every tall design (rows >= cols + 1) with at most 511 features.
#   MOJOLEARN_LINALG_TSQR=0, FAST Apple builds (`_ols_normal_eq_default`) and any
#   design the TSQR does not take keep the normal-equations solver below.
# - Normal equations: the eigendecomposition solver (cuML `algorithm='eig'`,
#   `lstsqEig`, RAFT), which forms X.T @ X and squares the condition number.
#   cuML's SVD and QR solvers (`lstsqSvdJacobi`, `lstsqSvdQR`, `lstsqQR`) are not
#   written (glm/NOT_IMPLEMENTED.tsv). On the tall route the Gram matrix is
#   equilibrated by exact power-of-two column scales and its pseudo-inverse keeps
#   eigenvalues above n_features * eps32 * max (DEVIATIONS 2620, 2621), so a
#   column's units do not change the model's rank. The wide route (n_features >
#   n) is w = X.T (X X.T)^+ y through the Gram of the rows, n x n, nonsingular at
#   full row rank (DEVIATION 550, glm/impl/linalg/detail/lstsq_min_norm.mojo),
#   with row scales and an n * eps32 * max cutoff (DEVIATION 2622); it squares
#   the condition number exactly as the tall route does; a true SVD or LQ route
#   would be better and is not written.
# - n_features == 1: cuML switches to its SVD solver because its eigensolver does
#   not take one column (linear_regression.pyx:390-394); the device Jacobi does
#   not share that limitation (DEVIATION 551).
# - Sample weights are a host-side rescale through the estimators binding:
#   `olsFit` takes sqrt of the weights, multiplies row i of X and entry i of y by
#   it, solves, and undoes the scaling (ols.cuh:99-110, 129-141). Both halves are
#   implemented on the device in `glm/impl/ols.mojo::ols_fit_weighted`, but
#   `bindings/_mojolearn_estimators.mojo::ols_fit_binding` takes a fixed `params`
#   of length 2 and cannot hand a weight pointer across, so this class applies
#   the same two operations (`py2mojo_rows` ROWS_SQRT_F32 for the roots,
#   `lm_scale_rows` for the rows; on the device on a GPU install) and calls the
#   unweighted entry. The root is the correctly rounded float32 sqrt
#   (`portable_sqrtf`) and the row multiply one float32 rounding of an exact
#   float64 product, the same operations the device kernels perform; the routes
#   agree bit for bit except on denormals (the device flushes).
#   `check_ols_sample_weight_host_rescale_matches_device` in
#   glm/checks/ols_check.mojo gates it. Weighted fits with an intercept use
#   weighted column means, as cuML does (`raft::stats::weightedMean`,
#   preprocess.cuh:95-97, 110-112).
# - The intercept is a reimplementation, no reference file: cuML wraps the solver
#   in `preProcessData` / `postProcessData` (preprocess.cuh:98-176); the
#   implemented `ols_fit` refuses `fit_intercept` by name (glm/impl/ols.mojo).
#   Column means and the y mean are float64 from exact column sums (`lm_col_sums`,
#   rounded once), subtracted in float32 (`lm_center`), both on the device on a
#   GPU install, and the intercept is mean(y) - sum(mu_X * coef) with an exactly
#   rounded fsum (no BLAS dot: a platform-dependent host reduction, E2's first
#   finding). cuML's sums accumulate in float32, so coef_ can differ from cuML's
#   in the last bits on ill-conditioned data.
# - Honored/refused rows were measured by tools/e2u_matrix_fit.py.
[docs] class LinearRegression(NumericModeMixin): """Ordinary least squares linear regression. Fits ``y ~ X @ coef_ + intercept_`` by minimizing the sum of squared residuals, the same model as scikit-learn's ``LinearRegression``. Tall designs (more rows than features, up to 511 features) are solved by a QR factorization of ``[X | y]`` followed by an SVD of its small triangular factor, so the condition number of ``X`` is not squared. Other designs, and fits in ``fast`` mode on Apple GPUs, solve the normal equations through an eigendecomposition of the (equilibrated) Gram matrix; that squares the condition number, so prefer well conditioned designs there. A design with more features than rows gets the minimum-norm least-squares solution. Rank-deficient designs are handled by a pseudo-inverse whose cutoff does not depend on a column's units. ``X`` is read as float32 (a C-contiguous float32 array is used without a copy). ``y`` must be one-dimensional: a single target. With ``fit_intercept=True`` the data are centered on their (weighted) means before the solve, as in scikit-learn and cuML. Parameters ---------- fit_intercept : bool, default True Whether to fit an intercept. When False the data are assumed to be centered and ``intercept_`` is 0.0. numeric_mode : {'identical', 'fast'} or None, default None The numeric mode for this estimator; None uses the process default (see ``mojolearn.set_numeric_mode``). ``'fast'`` is for Apple GPUs. Attributes ---------- coef_ : array of shape (n_features,), float32 The fitted coefficients. intercept_ : float The fitted intercept; 0.0 when ``fit_intercept`` is False. n_features_in_ : int The number of features seen in ``fit``. input_copied_ : bool Whether ``fit`` had to copy ``X`` to float32 C order. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = X @ np.arange(8, dtype=np.float32) + 1.0 >>> model = mojolearn.LinearRegression().fit(X, y) >>> pred = model.predict(X) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "regressor" #: This family's binding, for `NumericModeMixin._bind`. _BINDING = "_mojolearn_estimators"
[docs] def __init__(self, *, fit_intercept=True): self.fit_intercept = fit_intercept
[docs] def fit(self, X, y, sample_weight=None): """Fit the linear model. Parameters ---------- X : array-like of shape (n_samples, n_features) Training data, read as float32. y : array-like of shape (n_samples,) Target values. A 2-D ``y`` raises ``ValueError``. sample_weight : array-like of shape (n_samples,), default None Non-negative, finite per-row weights. A weighted fit minimizes ``sum(w_i * r_i**2)``, and its intercept uses weighted means. A weight array of the wrong shape or length, or with a negative or non-finite entry, raises ``ValueError``. Returns ------- self : LinearRegression The fitted estimator. """ x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape target = _target_1d( y, rows, "mojolearn LinearRegression currently requires one target", "mojolearn LinearRegression X and y lengths differ", ) weights = _check_sample_weight(sample_weight, rows, "LinearRegression") b = self._bind("_mojolearn_estimators") fast_ne = _ols_normal_eq_default(b) normal_eq = not _ols_tsqr_on(rows, cols) or fast_ne resident = _optional_export(b, "ols_fit_resident") if fast_ne and weights is None and resident is not None: # lane apple-fast-olsne: FAST Apple builds only (the binding's # compiled OLS_FAST_NORMAL_EQ): the normal equations with X and y # uploaded once. Every other build keeps main's route below. self.coef_ = empty((cols,), "<f4") mu = empty((cols,), "<f4") ymean = empty((1,), "<f8") resident(addr_ro(x, name="X"), addr_ro(target, name="y"), addr(self.coef_, name="coef_"), addr(mu, name="column means"), addr(ymean, name="y mean"), [rows, cols, 1 if self.fit_intercept else 0]) if self.fit_intercept: self._x_mean = mu self._y_mean = float(ymean.tolist()[0]) else: self._x_mean = zeros((cols,), "<f4") self._y_mean = 0.0 self._set_intercept(cols) return self # SPLIT 2026-10-11 (grid g50ebe26a5, arm linear_gram_solve=off vs today's defaults, one run per arm): OLS leaves the # resident centered-Gram route (MOJOLEARN_CLASSICAL_LINEAR_GRAM_SOLVE) for the centered TSQR below. OLS taxi NV L40S # 13.567 -> 15.669 ms, AMD MI325X 31.501 -> 15.829 ms (avg 0.762); istella NV 657.0 -> 504.0 ms, AMD 775.0 -> 447.0 ms # (avg 0.665); v1193 / b0048, hash c8495868 / cc3f6b24 on both vendors. Ridge keeps the Gram route (off: taxi 5.76x slower). if self.fit_intercept and weights is None and not normal_eq: # lane idn-dense-linalg: center + TSQR in one entry, X up once got = _ols_tsqr_centered(x, target, rows, cols, getattr(self, "numeric_mode", None)) if got is not None: self.coef_, self._x_mean, self._y_mean = got self._set_intercept(cols) return self if self.fit_intercept: # float64 column means -> float32, then a float32 subtraction. # The means come from exact column sums rounded once to float64 # (`lm_col_sums`, lane hr-small-passes); the 1-D target mean is # the same op over a [rows, 1] view. Neither goes through BLAS or libm. The dot # below is the one place a BLAS call would have slipped in, so # it is an exactly-rounded fsum instead. # # WITH WEIGHTS THE MEANS ARE WEIGHTED, which is cuML's # preProcessData (`raft::stats::weightedMean`, sum(w*x)/sum(w), # preprocess.cuh:95-97 and 110-112) and NOT the unweighted mean # applied to weighted rows. The two differ, and using the wrong # one puts the intercept in the wrong place without moving any # coefficient enough to notice. b = self._bind("_mojolearn_estimators") paired = _classical_xy_means(b, x, target) if weights is None else None if paired is None: mu32 = _column_means(b, x, weights) self._y_mean, y32 = _vector_mean(b, target, weights) else: mu32, self._y_mean, y32 = paired self._x_mean = mu32 # NumPy narrowed the Python-float y mean to float32 BEFORE the # float32 subtract (value-based / weak-scalar casting); the # same order here (`lm_means_finish`' float32 mean) so the # centered bits are the same bits. work_x = _center(b, x, mu32) work_y = _shift(b, target, y32) else: work_x, work_y = x, target self._x_mean = zeros((cols,), "<f4") self._y_mean = 0.0 if weights is not None: # `olsFit`, ols.cuh:99-110, on the host. See SAMPLE WEIGHTS in # the class docstring for why this is here and not in the Mojo # layer, and for the bit-for-bit claim the gate checks. b = self._bind("_mojolearn_estimators") root = _sqrt_weights(b, weights) work_x = _scale_rows(b, work_x, root) work_y = _scale_rows(b, work_y, root) if not normal_eq: # lane neural-pass140: the blocked TSQR of [X | y] and the SVD of # its small R (_ols_tsqr); MOJOLEARN_LINALG_TSQR=0 (and FAST on # Apple, `_ols_normal_eq_default`) keeps the normal equations below self.coef_ = _ols_tsqr(work_x, work_y, rows, cols, getattr(self, "numeric_mode", None)) else: self.coef_ = empty((cols,), "<f4") self._bind("_mojolearn_estimators").ols_fit( addr_ro(work_x, name="X"), addr_ro(work_y, name="y"), addr(self.coef_, name="coef_"), [rows, cols], ) self._set_intercept(cols) return self
def _set_intercept(self, cols): if self.fit_intercept: self.intercept_ = _intercept(self._bind("_mojolearn_estimators"), self._x_mean, self.coef_, cols, self._y_mean) else: self.intercept_ = 0.0 self.n_features_in_ = cols
[docs] def predict(self, X): """Predict with the fitted linear model. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- y_pred : array of shape (n_samples,), float32 ``X @ coef_ + intercept_``. Raises ------ ValueError If the model is not fitted, or ``X`` has a different number of features than the training data. """ if not hasattr(self, "coef_"): raise ValueError("mojolearn LinearRegression: call fit before predict") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError("mojolearn LinearRegression feature count differs from fit") out = empty((x.shape[0],), "<f4") self._bind("_mojolearn_estimators").ols_predict( addr_ro(x, name="X"), addr_ro(self.coef_, name="coef_"), addr(out, name="predictions"), [x.shape[0], x.shape[1], float(self.intercept_)], ) return out
# The R^2 sums run in the x_metrics binding (`_r2_sums`; formerly a # sequential float64 host reduction, DEVIATION 2365).
[docs] def score(self, X, y): """Return the coefficient of determination R^2 of the prediction. Parameters ---------- X : array-like of shape (n_samples, n_features) Test samples. y : array-like of shape (n_samples,) True target values. Returns ------- score : float ``1 - SS_res / SS_tot``, computed in float64. A constant ``y`` (``SS_tot == 0``) scores 0.0. """ return _r2_host(self.predict(X), y)
# The file holds what `predict` reads and nothing else (the classical host # inference lane, 2026-09-13): `coef_` <f4, the intercept <f8, `meta` <i8 # [n_features_in_, fit_intercept] and the numeric mode.
[docs] def save(self, path): """Save the fitted model to ``path`` as an ``.npz`` file. The file stores ``coef_``, the intercept (as float64), the feature count, ``fit_intercept`` and the numeric mode. Equal models give byte-identical files. ``mojolearn.host_model(path)`` can predict from the file on a machine without a GPU. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the model is not fitted. """ return _save_linear(self, path)
[docs] @classmethod def load(cls, path): """Load a model written by ``save``. The loaded model predicts with the saved coefficients, intercept and numeric mode. It carries no training-time means, so it is for prediction, not for continuing a fit. Parameters ---------- path : str or path-like A file written by ``LinearRegression.save``. Returns ------- model : LinearRegression Raises ------ ValueError If the file was saved by a different estimator or its fields do not match. """ obj, _ = _load_linear(cls, path) return obj
# Maintainer notes (moved from the docstring, 2026-10-12): # Reference: cuml/python/cuml/linear_model/ridge.pyx and # cuml/cpp/src/glm/ridge.cuh::ridgeFit (DEVIATION 545; the Mojo implementation # is glm/impl/ridge.mojo, whose design note is worth reading: their `eig` solver # is an SVD through the eigendecomposition of X.T @ X followed by `ridgeSolve`, # NOT "OLS with alpha added", and so is ours). Rows measured by # tools/e2u_matrix_fit.py: # alpha honored, non-negative (ridge.pyx:296); 0.0 is least squares # through the ridge program # fit_intercept True centers X and y on the host exactly as LinearRegression # does (DEVIATION 517); the implemented `ridge_fit` sees a # centered design with fit_intercept=False, ridge.cuh:247's # `intercept = 0` arm # solver 'eig' only; cuML's 'auto' maps to 'eig' (ridge.pyx:304); # 'svd' (ridgeSVD -> cuSOLVER gesvd, glm/NOT_IMPLEMENTED.tsv) and # 'cd' (cuml/solvers/cd.pyx) refused by name # normalize refused: preProcessData's meanvar arm (preprocess.cuh:76-108) # sample_weight refused: ridge.cuh:197-208 / 220-231, not implemented # n_features==1 refused: ridge.cuh:210 forces ridgeSVD for one column # (ridge.pyx:355 warns and switches); raised by name by the Mojo # layer (alpha > 0; `linear_gram_fit` defers to it) # y 2-D refused, one target only
[docs] class Ridge(NumericModeMixin): """Linear least squares with l2 regularization. Minimizes ``||y - X w||^2 + alpha * ||w||^2``, the same objective and the same minimizer as scikit-learn's ``Ridge``. The solve is cuML's ``solver='eig'``: an eigendecomposition of ``X.T @ X``, which squares the condition number of ``X`` (see ``LinearRegression``). The intercept is not penalized: with ``fit_intercept=True`` the data are centered on their means before the solve. ``X`` is read as float32 (a C-contiguous float32 array is used without a copy). ``y`` must be one-dimensional: a single target. ``sample_weight`` is not supported, and a design with a single feature and ``alpha > 0`` is refused with an error. Parameters ---------- alpha : float, default 1.0 Regularization strength, non-negative. 0.0 gives ordinary least squares. A negative value raises ``ValueError``. solver : {'auto', 'eig'}, default 'auto' ``'auto'`` means ``'eig'``, the one solver implemented. ``'svd'`` and ``'cd'`` raise ``NotImplementedError``; any other value raises ``TypeError``. fit_intercept : bool, default True Whether to fit an intercept. When False ``intercept_`` is 0.0. normalize : bool, default False Only False is supported; True raises ``NotImplementedError``. numeric_mode : {'identical', 'fast'} or None, default None The numeric mode for this estimator; None uses the process default (see ``mojolearn.set_numeric_mode``). ``'fast'`` is for Apple GPUs. Attributes ---------- coef_ : array of shape (n_features,), float32 The fitted coefficients. intercept_ : float The fitted intercept; 0.0 when ``fit_intercept`` is False. solver_ : str The solver used, always ``'eig'``. n_features_in_ : int The number of features seen in ``fit``. input_copied_ : bool Whether ``fit`` had to copy ``X`` to float32 C order. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = X @ np.arange(8, dtype=np.float32) + 1.0 >>> model = mojolearn.Ridge(alpha=0.5).fit(X, y) >>> pred = model.predict(X) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "regressor" #: This family's binding, for `NumericModeMixin._bind`. _BINDING = "_mojolearn_estimators"
[docs] def __init__(self, *, alpha=1.0, solver="auto", fit_intercept=True, normalize=False): if alpha < 0.0: raise ValueError(f"alpha must be non-negative, got {alpha}") if solver not in ("auto", "eig", "svd", "cd"): raise TypeError(f"solver {solver!r} is not supported") if solver in ("svd", "cd"): raise NotImplementedError( f"mojolearn Ridge: solver={solver!r} is not implemented " "(ridgeSVD is raft::linalg::svdQR -> cuSOLVER gesvd; 'cd' " "is cuml/solvers/cd.pyx); solver='eig' (cuML's 'auto') is " "the implemented arm. See glm/NOT_IMPLEMENTED.tsv" ) if normalize: raise NotImplementedError( "mojolearn Ridge: normalize is not implemented (preprocess.cuh:" "76-108, the meanvar arm of preProcessData; glm/NOT_IMPLEMENTED.tsv)" ) self.alpha = alpha self.solver = solver self.fit_intercept = fit_intercept self.normalize = normalize
[docs] def fit(self, X, y, sample_weight=None): """Fit the ridge model. Parameters ---------- X : array-like of shape (n_samples, n_features) Training data, read as float32. y : array-like of shape (n_samples,) Target values. A 2-D ``y`` raises ``ValueError``. sample_weight : None Must be None; weights raise ``NotImplementedError``. Returns ------- self : Ridge The fitted estimator. """ if sample_weight is not None: raise NotImplementedError( "mojolearn Ridge: sample_weight is not implemented " "(ridge.cuh:197-208; glm/NOT_IMPLEMENTED.tsv)" ) self.solver_ = "eig" x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape target = _target_1d( y, rows, "mojolearn Ridge currently requires one target", "mojolearn Ridge X and y lengths differ", ) b = self._bind("_mojolearn_estimators") q = getattr(b, "ridge_resident_default", None) resident = getattr(b, "ridge_fit_resident", None) use_resident = q is not None and resident is not None and bool(q()) # MOJOLEARN_CLASSICAL_LINEAR_GRAM_SOLVE: the resident centered-Gram # fit; None when the build lacks it or the Gram is not trusted gram = _linear_gram_fit(b, x, target, rows, cols, float(self.alpha), self.fit_intercept) if gram is not None: self.coef_, self._x_mean, self._y_mean = gram use_resident = True elif use_resident: # lane apple-fast-ridgespeed: FAST Apple builds (default unless # -D MOJOLEARN_RIDGE_RESIDENT_OFF): X and y uploaded once, the same # column sums, center and ridgeEig on the resident buffers (the # same words as the route below). self.coef_ = empty((cols,), "<f4") mu = empty((cols,), "<f4") ymean = empty((1,), "<f8") resident(addr_ro(x, name="X"), addr_ro(target, name="y"), addr(self.coef_, name="coef_"), addr(mu, name="column means"), addr(ymean, name="y mean"), [rows, cols, float(self.alpha), 1 if self.fit_intercept else 0]) if self.fit_intercept: self._x_mean = mu self._y_mean = float(ymean.tolist()[0]) else: self._x_mean = zeros((cols,), "<f4") self._y_mean = 0.0 elif self.fit_intercept: # The same centering as LinearRegression, for the same # reasons; read that class's fit. paired = _classical_xy_means(b, x, target) if paired is None: mu32 = _column_means(b, x, None) self._y_mean, y32 = _vector_mean(b, target, None) else: mu32, self._y_mean, y32 = paired self._x_mean = mu32 work_x = _center(b, x, mu32) work_y = _shift(b, target, y32) else: work_x, work_y = x, target self._x_mean = zeros((cols,), "<f4") self._y_mean = 0.0 if not use_resident: self.coef_ = empty((cols,), "<f4") b.ridge_fit( addr_ro(work_x, name="X"), addr_ro(work_y, name="y"), addr(self.coef_, name="coef_"), [rows, cols, float(self.alpha)], ) if self.fit_intercept: self.intercept_ = _intercept(b, self._x_mean, self.coef_, cols, self._y_mean) else: self.intercept_ = 0.0 self.n_features_in_ = cols return self
[docs] def predict(self, X): """Predict with the fitted ridge model. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- y_pred : array of shape (n_samples,), float32 ``X @ coef_ + intercept_``. Raises ------ ValueError If the model is not fitted, or ``X`` has a different number of features than the training data. """ if not hasattr(self, "coef_"): raise ValueError("mojolearn Ridge: call fit before predict") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError("mojolearn Ridge feature count differs from fit") out = empty((x.shape[0],), "<f4") # The same gemv + intercept epilogue OLS predicts with # (`gemmPredict` in the reference serves both, `base.pyx:134`). self._bind("_mojolearn_estimators").ols_predict( addr_ro(x, name="X"), addr_ro(self.coef_, name="coef_"), addr(out, name="predictions"), [x.shape[0], x.shape[1], float(self.intercept_)], ) return out
# The R^2 sums run in the x_metrics binding (`_r2_sums`; formerly a # sequential float64 host reduction, DEVIATION 2365).
[docs] def score(self, X, y): """Return the coefficient of determination R^2 of the prediction. Parameters ---------- X : array-like of shape (n_samples, n_features) Test samples. y : array-like of shape (n_samples,) True target values. Returns ------- score : float ``1 - SS_res / SS_tot``, computed in float64. A constant ``y`` (``SS_tot == 0``) scores 0.0. """ return _r2_host(self.predict(X), y)
# LinearRegression and Ridge share one file format because they share one # predict; Ridge adds `alpha` <f8.
[docs] def save(self, path): """Save the fitted model to ``path`` as an ``.npz`` file. The file stores what ``LinearRegression.save`` stores plus ``alpha``. ``mojolearn.host_model(path)`` can predict from the file on a machine without a GPU. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the model is not fitted. """ return _save_linear( self, path, {"alpha": Array.from_list([float(self.alpha)], "<f8")} )
[docs] @classmethod def load(cls, path): """Load a model written by ``save``. The loaded model predicts with the saved coefficients, intercept, ``alpha`` and numeric mode; ``solver_`` is ``'eig'``. Parameters ---------- path : str or path-like A file written by ``Ridge.save``. Returns ------- model : Ridge Raises ------ ValueError If the file was saved by a different estimator or its fields do not match. """ arrays = _serialize.read_npz(path, _LINEAR_FORMAT) alpha = _serialize.exact(arrays, "alpha", "<f8") if alpha.size != 1: raise ValueError(f"mojolearn: {path!r} alpha must hold one value") obj, _ = _load_linear(cls, path, arrays=arrays, alpha=float(alpha[0])) obj.solver_ = "eig" return obj
# cuML's `qn_params.loss` ids (cuml/linear_model/qn.h); the Python door maps # 'sigmoid' to QN_LOSS_LOGISTIC and 'softmax' to QN_LOSS_SOFTMAX. _QN_OPT_RETCODE = {0: "OPT_SUCCESS", 1: "OPT_NUMERIC_ERROR", 2: "OPT_LS_FAILED", 3: "OPT_MAX_ITERS_REACHED", 4: "OPT_INVALID_ARGS"} #: `glm/impl/linear_model/qn.mojo`'s ids, the 14th `qn_fit` field. _QN_LOSS_LOGISTIC = 0 _QN_LOSS_SQUARED = 1 _QN_LOSS_SOFTMAX = 2 _QN_LOSS_SVC_L1 = 3 _QN_LOSS_SVC_L2 = 4 _QN_LOSS_SVR_L1 = 5 _QN_LOSS_SVR_L2 = 6 _QN_LOSS_ABS = 7 def _coef_from_w(w, cols, n_targets, fit_intercept): """`coef_` and `intercept_` from the fitted `W` block. One target: the first `cols` entries and the last (a slice of an Array COPIES, the _array contract, so both are detached from `_w`). C targets: cuML's column-major `w[c + C*j]`, the bias column at `j == cols`.""" if n_targets == 1: coef = w[:cols].reshape((1, cols)) intercept = w[cols:cols + 1] if fit_intercept else zeros((1,), "<f4") return coef, intercept # `w[c + C*j]` is the (c, j) entry of a column-major [C, n_param] block: # one relabeled view and one C-order copy (`Array._as_c`), then two # slices, instead of a Python loop over the parameters (lane # pyglue-sweep, Oct 3). n_param = cols + (1 if fit_intercept else 0) full = Array._view_of(w, (n_targets, n_param), "F")._as_c() coef = full[:, :cols] intercept = full[:, cols] if fit_intercept else zeros((n_targets,), "<f4") return coef, intercept #: lane/apple-fast-py2mojo-linear: `py2mojo_rows` modes (core/py2mojo_rows.mojo) #: and the `py2mojo_linear_flags` bit that routes them _ROWS_LOG, _ROWS_SGD_PROBA, _ROWS_LRCV_PROBA, _ROWS_SQRT_F32 = 1, 2, 3, 5 _PY2MOJO_ROWS = 2 def _py2mojo_flags(binding): fn = getattr(binding, "py2mojo_linear_flags", None) return int(fn()) if fn is not None else 0 # Maintainer notes (moved from the docstring, 2026-10-12): # Reference: cuml/python/cuml/linear_model/logistic_regression.py, # cuml/python/cuml/solvers/qn.pyx and cuml/cpp/src/glm/qn/ (DEVIATIONS 546-549; # the Mojo implementation is glm/impl/qn/*.mojo, one file per theirs). # penalty_normalized=True: cuML divides the penalty by n so its minimizer is # scikit-learn's LogisticRegression(C) minimizer. OWL-QN with a projected Armijo # line search when l1 != 0 (qn_solvers.cuh:420-445, DEVIATION 552, # glm/impl/qn/qn_solvers.mojo::min_owlqn). Convergence: # qn_util.cuh::check_convergence. Rows measured by tools/e2u_matrix_fit.py: # penalty 'l2' is l2 = 1/C (logistic_regression.py:640); None is qn.cuh:61 # tol grad_tol = tol, change_tol = tol * 0.01, ftol = change_tol * 0.1 # (qn.pyx:504-506, qn_util.cuh:88-98) # fit_intercept the bias is a solver parameter (GLMDims, glm_base.cuh:96), not # penalized (glm_regularizer.cuh:45); no host centering here # max_iter reaching it is a warning in the reference; here n_iter_ == max_iter # with retcode_ == 3 # class_weight / sample_weight refused: class_weight becomes a sample_weight # in the reference (logistic_regression.py:400-436); # GLMBase::add_sample_weights and the weighted getLossAndDZ arm are # not implemented # l1_ratio required with elasticnet (logistic_regression.py:310-316) # > 2 classes since lane/logistic-multiclass (2026-09-14): QN_LOSS_SOFTMAX, # glm/impl/qn/glm_softmax.mojo, DEVIATIONS 705-711, gated by # `pixi run check-glm-multinomial`; predict_proba is the softmax in # float64 (`qn_softmax`, the rule of DEVIATION 549); predict's tie rule # is `_labels.argmax_rows`, the positional rule of `softmax_row_max`. # l1/elasticnet with > 2 classes refused: OWL-QN on the softmax # objective runs but has no identity gate (CONTRIBUTING.md # "Non-default paths") # warm_start absent: cuML's QN has it, LogisticRegression does not expose it # The l1 arm is a different solver: OWL-QN keeps the L-BFGS history; the # objective carries l1 * ||w||_1 in its value, the direction is built from a # pseudo-gradient, and every step is projected back into its starting orthant, # which produces exact zeros; the identity claim on this arm covers the sparsity # pattern too. The intercept is not l1-penalized (`pg_limit = D * C`, # qn_solvers.cuh:447). # coef_/intercept_ for C > 2 come from cuML's column-major W block w[c + C*j]. # classes_ is a Python list (DEVIATION 2364). predict_proba goes through # `identical_exp64` (DEVIATION 549; cuML computes it in float32 on the device and # stores float64). # IDENTITY: under identical every reduction in the objective, the gradient and the # solver's dot products is a pinned fold, exp/log are the portable pair, and the # line search's one host multiply-add is an fma, so the accepted steps, the # L-BFGS history, the iteration count and the coefficients are a function of the # inputs alone; the card (`qn.iterNNNN.*`, `qn.n_iter`, `qn.coef`) records them.
[docs] class LogisticRegression(NumericModeMixin): """Logistic regression classifier, solved by a quasi-Newton method. Minimizes ``mean_i logloss_i + penalty(w) / (C * n_samples)``, which has the same minimizer as scikit-learn's ``LogisticRegression(C=C)``. Two classes use the logistic loss; more than two use the multinomial (softmax) loss. The solver is L-BFGS (history of 5) with a backtracking Armijo line search, or OWL-QN when the penalty has an l1 part; OWL-QN produces coefficients that are exactly zero. The intercept is a solver parameter and is never penalized. This is cuML's ``solver='qn'``. A fit stops when ``max|grad| <= tol * max(loss, tol)``, when the objective changes by less than ``tol * 0.01 * max(loss, tol)`` over 10 iterations, or at ``max_iter`` iterations (``retcode_`` 3). In ``identical`` mode the iteration count and the coefficients are the same on NVIDIA and AMD GPUs. ``X`` is read as float32. ``y`` is a 1-D array of labels of any sortable type; ``classes_`` is the sorted unique labels. Parameters ---------- penalty : {'l2', 'l1', 'elasticnet', None}, default 'l2' The regularization term. ``'l1'`` and ``'elasticnet'`` use OWL-QN and are available for two classes only; with more classes ``fit`` raises ``NotImplementedError``. Any other value raises ``ValueError``. tol : float, default 1e-4 Convergence tolerance (see above). C : float, default 1.0 Inverse regularization strength, > 0; otherwise ``ValueError``. fit_intercept : bool, default True Whether to fit an (unpenalized) intercept. class_weight : None Must be None; anything else raises ``NotImplementedError``. max_iter : int, default 1000 Maximum number of L-BFGS iterations. scikit-learn's default is 100. linesearch_max_iter : int, default 50 Maximum number of line-search steps per iteration. l1_ratio : float or None, default None The l1 share of the elastic-net penalty, in [0, 1]: the l1 strength is ``l1_ratio / C`` and the l2 strength ``(1 - l1_ratio) / C``. Required with ``penalty='elasticnet'`` (``ValueError`` if missing or out of range) and ignored (stored as None) for every other penalty. solver : {'qn'}, default 'qn' Only ``'qn'`` is supported; any other value raises ``ValueError``. numeric_mode : {'identical', 'fast'} or None, default None The numeric mode for this estimator; None uses the process default (see ``mojolearn.set_numeric_mode``). ``'fast'`` is for Apple GPUs. Attributes ---------- classes_ : list The sorted class labels. coef_ : array of shape (1, n_features) or (n_classes, n_features), float32 Coefficients: one row for two classes, one row per class (in ``classes_`` order) for more. intercept_ : array of shape (1,) or (n_classes,), float32 Intercepts; zeros when ``fit_intercept`` is False. n_iter_ : array of shape (1,), int64 The number of solver iterations run. objective_ : float The final value of the minimized objective. retcode_ : int The solver's return code: 0 converged, 1 numeric error, 2 line search failed, 3 maximum iterations reached, 4 invalid arguments. n_features_in_ : int The number of features seen in ``fit``. input_copied_ : bool Whether ``fit`` had to copy ``X`` to float32 C order. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = (X[:, 0] + X[:, 1] > 1.0).astype(np.int64) >>> clf = mojolearn.LogisticRegression(C=10.0).fit(X, y) >>> proba = clf.predict_proba(X) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "classifier" #: This family's binding, for `NumericModeMixin._bind`. _BINDING = "_mojolearn_estimators"
[docs] def __init__(self, *, penalty="l2", tol=1e-4, C=1.0, fit_intercept=True, class_weight=None, max_iter=1000, linesearch_max_iter=50, l1_ratio=None, solver="qn"): if penalty not in ("l1", "l2", "elasticnet", None): raise ValueError(f"`penalty` {penalty!r} not supported.") if solver != "qn": raise ValueError( "Only quasi-newton `qn` solver is supported, not %s" % solver) if class_weight is not None: raise NotImplementedError( "mojolearn LogisticRegression: class_weight is not implemented " "(it becomes a sample_weight upstream, logistic_regression.py" ":400-436, and sample_weight is not implemented; glm/NOT_IMPLEMENTED.tsv)" ) if C <= 0: raise ValueError(f"C must be positive, got {C}") self.penalty = penalty self.tol = tol self.C = C self.fit_intercept = fit_intercept self.class_weight = None self.max_iter = max_iter self.linesearch_max_iter = linesearch_max_iter # `logistic_regression.py:310-316`, copied including the two # messages: `l1_ratio` is REQUIRED for elasticnet and IGNORED # (set to None) for every other penalty. self.l1_ratio = None if penalty == "elasticnet": if l1_ratio is None: raise ValueError( "l1_ratio has to be specified for loss='elasticnet'" ) if l1_ratio < 0.0 or l1_ratio > 1.0: raise ValueError( "l1_ratio value has to be between 0.0 and 1.0" ) self.l1_ratio = l1_ratio self.solver = solver # QN(...) defaults the Python door does not expose self.lbfgs_memory = 5 self.penalty_normalized = True
def _get_qn_params(self): """`_get_qn_params`, logistic_regression.py:632-649, all four arms. Returns `(l1_strength, l2_strength)`. `qn_fit` then divides both by `n` when `penalty_normalized` (qn.cuh:54-59), so what the solver sees is `(1/C)/n`, and `l1 != 0` is what selects OWL-QN there. """ if self.penalty is None: return 0.0, 0.0 if self.penalty == "l1": return 1.0 / self.C, 0.0 if self.penalty == "l2": return 0.0, 1.0 / self.C strength = 1.0 / self.C return self.l1_ratio * strength, (1.0 - self.l1_ratio) * strength
[docs] def fit(self, X, y, sample_weight=None): """Fit the classifier. Parameters ---------- X : array-like of shape (n_samples, n_features) Training data, read as float32. y : array-like of shape (n_samples,) Class labels, at least two distinct values. A 2-D ``y``, a single class or a length mismatch raises ``ValueError``. sample_weight : None Must be None; weights raise ``NotImplementedError``. Returns ------- self : LogisticRegression The fitted estimator. """ if sample_weight is not None: raise NotImplementedError( "mojolearn LogisticRegression: sample_weight is not implemented " "(GLMBase::add_sample_weights, glm_base.cuh:115; " "glm/NOT_IMPLEMENTED.tsv)" ) x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape if len(_shape_of(y)) != 1: raise ValueError("mojolearn LogisticRegression requires a 1-D y") # LabelEncoder: sorted unique classes -> 0..k-1 # (logistic_regression.py:383), under the package-wide ORDER RULE # (DEVIATION 2340) through `encode_labels`: the native encoder for a # numeric buffer (DEVIATION 2500, as the forests take it), the # `sorted_classes` routine for everything else. Same classes, same # codes (`tests/test_labels_native.py`). self.classes_, codes = encode_labels(y) if len(codes) != rows: raise ValueError("mojolearn LogisticRegression X and y lengths differ") n_classes = len(self.classes_) if n_classes < 2: raise ValueError("mojolearn LogisticRegression: y has one class") # `qn.cuh:106`: one target for two classes (the logistic loss, code # 1 is `classes_[1]`, the class the solver maps to +1), C targets # for C > 2 (the softmax loss, code c is `classes_[c]`). n_targets = 1 if n_classes == 2 else n_classes loss = _QN_LOSS_LOGISTIC if n_targets == 1 else _QN_LOSS_SOFTMAX # Label encoding, the permitted O(rows) Python loop. y_enc = codes.astype("<f4") # dense codes < 2**24: exact l1, l2 = self._get_qn_params() if n_targets > 1 and l1 != 0.0: raise NotImplementedError( f"mojolearn LogisticRegression: penalty={self.penalty!r} with " f"{n_classes} classes selects OWL-QN on the softmax objective, " "which runs but has no identity gate (glm/checks/" "multinomial_check.mojo gates l2 and no penalty); refused by " "name until it is gated (CONTRIBUTING.md (Non-default paths))" ) n_param = cols + (1 if self.fit_intercept else 0) w = zeros((n_param * n_targets,), "<f4") info = zeros((2,), "<f4") n_iter = self._bind("_mojolearn_estimators").qn_fit( addr_ro(x, name="X"), addr_ro(y_enc, name="y"), addr(w, name="coef_"), addr(info, name="info"), [rows, cols, n_classes, float(l1), float(l2), float(self.tol), float(self.tol * 0.01), int(self.max_iter), int(self.linesearch_max_iter), int(self.lbfgs_memory), 1 if self.fit_intercept else 0, 1 if self.penalty_normalized else 0, 0, loss], ) self._w = w self.coef_, self.intercept_ = _coef_from_w(w, cols, n_targets, self.fit_intercept) self.n_iter_ = Array.from_list([int(n_iter)], "<i8") self.objective_ = float(info[0]) self.retcode_ = int(info[1]) self.n_features_in_ = cols return self
[docs] def decision_function(self, X): """Return the linear scores ``X @ coef_.T + intercept_``. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- scores : array of shape (n_samples,) or (n_samples, n_classes), float32 For two classes, one score per row (positive means ``classes_[1]``); for more, one score per class. Raises ------ ValueError If the model is not fitted, or ``X`` has a different number of features than the training data. """ if not hasattr(self, "_w"): raise ValueError("mojolearn LogisticRegression: call fit first") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError("mojolearn LogisticRegression feature count differs from fit") n_targets = self._n_targets() if n_targets == 1: out = empty((x.shape[0],), "<f4") self._bind("_mojolearn_estimators").qn_decision_function( addr_ro(x, name="X"), addr_ro(self._w, name="coef_"), addr(out, name="scores"), [x.shape[0], x.shape[1], 1 if self.fit_intercept else 0], ) return out # C > 2: `(n, C)` float32, `scores[i, c]` the class-c logit, the # 4-field call of the same entry (lane/logistic-multiclass). out = empty((x.shape[0], n_targets), "<f4") self._bind("_mojolearn_estimators").qn_decision_function( addr_ro(x, name="X"), addr_ro(self._w, name="coef_"), addr(out, name="scores"), [x.shape[0], x.shape[1], 1 if self.fit_intercept else 0, n_targets], ) return out
def _n_targets(self): """`qn.cuh:106`: 1 for two classes, C for C > 2.""" n_classes = len(self.classes_) return 1 if n_classes == 2 else n_classes # `qn_predict`: `z > 0 ? 1 : 0` (qn.cuh:276), mapped to classes_ # (DEVIATION 2364 for the return type). C > 2: the first maximum wins a # tie (`_labels.argmax_rows`; the rule the device's `softmax_row_max` # applies to a tie and to +0.0 against -0.0, and cuML's argmax over C).
[docs] def predict(self, X): """Predict class labels. For two classes a row is ``classes_[1]`` when its decision score is positive and ``classes_[0]`` otherwise. For more classes it is the class with the highest score; a tie goes to the class that comes first in ``classes_``. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- labels : array of shape (n_samples,) or list An int64 array for integer labels, a float64 array for float labels, and a Python list for any other label type. """ if self._n_targets() == 1: x, _ = as_f32_c(X, ndim=2, name="X") codes = empty((x.shape[0],), "<i8") binding = self._bind("_mojolearn_estimators") native = getattr(binding, "qn_predict_binary", None) if native is None: raise RuntimeError( "mojolearn LogisticRegression: the estimators binding has no " "qn_predict_binary; rebuild it") native( addr_ro(x, name="X"), addr_ro(self._w, name="coef_"), addr(codes, name="codes"), [x.shape[0], x.shape[1], 1 if self.fit_intercept else 0], ) return decode_labels(self.classes_, codes) # lane fam2-linear: the row argmax runs on the device beside the # scores (`qn_predict_multiclass`); a binding without the entry (a # CPU-only install, or a build with MOJOLEARN_QN_DEV_ARGMAX_OFF) # keeps the host scan, the same first-maximum rule. binding = self._bind("_mojolearn_estimators") native = getattr(binding, "qn_predict_multiclass", None) if native is not None: if not hasattr(self, "_w"): raise ValueError("mojolearn LogisticRegression: call fit first") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError("mojolearn LogisticRegression feature count differs from fit") codes = empty((x.shape[0],), "<i8") if x.shape[0]: native( addr_ro(x, name="X"), addr_ro(self._w, name="coef_"), addr(codes, name="codes"), [x.shape[0], x.shape[1], 1 if self.fit_intercept else 0, self._n_targets()], ) return decode_labels(self.classes_, codes) scores = self.decision_function(X) return decode_labels(self.classes_, argmax_rows(scores))
[docs] def predict_proba(self, X): """Return class probabilities. The sigmoid of the decision score for two classes, the softmax of each row's scores for more, computed in float64. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- proba : array of shape (n_samples, n_classes), float64 Column ``k`` is the probability of ``classes_[k]``. """ scores = self.decision_function(X) n_targets = self._n_targets() if n_targets == 1: out = empty((scores.shape[0], 2), "<f8") self._bind("_mojolearn_estimators").qn_sigmoid( addr_ro(scores, name="scores"), addr(out, name="proba"), [scores.shape[0]]) return out # C > 2: the softmax of each row in float64 on the host through # `identical_exp64` (`qn_softmax_host`, the rule of DEVIATION 549). out = empty((scores.shape[0], n_targets), "<f8") self._bind("_mojolearn_estimators").qn_softmax( addr_ro(scores, name="scores"), addr(out, name="proba"), [scores.shape[0], n_targets]) return out
# `py2mojo_rows` ROWS_LOG in the estimators binding, on the device on a GPU # install; -inf at an exact zero as NumPy gave.
[docs] def predict_log_proba(self, X): """Return the natural log of ``predict_proba(X)``. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- log_proba : array of shape (n_samples, n_classes), float64 ``log(p)`` per entry; ``-inf`` where a probability is exactly 0. """ proba = self.predict_proba(X) binding = self._bind("_mojolearn_estimators") # lane/apple-fast-py2mojo-linear: `_log_or_inf` of every cell in the # binding (core/py2mojo_rows.mojo ROWS_LOG, the same log). Lane # pyglue-sweep (Oct 3): the Python per-cell arm for a binary without # the door is gone; such a binary refuses by name. if not _py2mojo_flags(binding) & _PY2MOJO_ROWS: raise RuntimeError( "mojolearn: this estimators binding predates py2mojo_rows; " "rebuild it (pixi run build)" ) out = empty(proba.shape, "<f8") if proba.size: binding.py2mojo_rows(_ROWS_LOG, addr_ro(proba, name="proba"), addr(out, name="log proba"), [proba.size, 1]) return out
# The match count runs in the x_metrics binding (`_accuracy_host`).
[docs] def score(self, X, y): """Return the mean accuracy on the given data. Parameters ---------- X : array-like of shape (n_samples, n_features) Test samples. y : array-like of shape (n_samples,) True labels. Returns ------- score : float The fraction of rows where ``predict(X) == y``. """ return _accuracy_host(self.predict(X), y)
# The file: `_w` as fitted (the n_targets * (n_features + fit_intercept) # float32 block inference reads; coef_ and intercept_ are its copies and are # rebuilt by `load`), `classes_` as `_labels.classes_member` (its length is # the class count, so a C > 2 model needs no extra field), the feature count, # fit_intercept and the numeric mode (classical host inference lane, # 2026-09-13; C > 2 since lane/logistic-multiclass, 2026-09-14).
[docs] def save(self, path): """Save the fitted model to ``path`` as an ``.npz`` file. The file stores the fitted coefficients and intercepts, the class labels, the feature count, ``fit_intercept`` and the numeric mode. ``mojolearn.host_model(path)`` can predict from the file on a machine without a GPU. Parameters ---------- path : str or path-like Destination file. Raises ------ RuntimeError If the model is not fitted. """ if not hasattr(self, "_w"): raise RuntimeError("this estimator is not fitted yet") arrays = { "format": _LOGISTIC_FORMAT, "estimator": type(self).__name__, "numeric_mode": _saved_mode(self), "w": self._w, "classes": classes_member(self.classes_), "meta": Array.from_list( [int(self.n_features_in_), 1 if self.fit_intercept else 0], "<i8" ), } return _serialize.write_npz(path, arrays)
[docs] @classmethod def load(cls, path): """Load a model written by ``save``. The loaded model predicts with the saved coefficients, classes and numeric mode. It has no ``n_iter_``, ``objective_`` or ``retcode_``. Parameters ---------- path : str or path-like A file written by ``LogisticRegression.save``. Returns ------- model : LogisticRegression Raises ------ ValueError If the file was saved by a different estimator or its fields do not match. """ arrays = _serialize.read_npz(path, _LOGISTIC_FORMAT) _check_saved_by(arrays, path, cls) meta = _serialize.exact(arrays, "meta", "<i8") if meta.size != 2: raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, 2 are needed") obj = cls(fit_intercept=bool(int(meta[1]))) _restore_mode(obj, arrays) cols = int(meta[0]) obj.classes_ = classes_from_member(arrays["classes"]) if len(obj.classes_) < 2: raise ValueError(f"mojolearn: {path!r} must carry at least two classes") n_targets = obj._n_targets() w = _serialize.exact(arrays, "w", "<f4") if w.ndim != 1 or w.size != (cols + (1 if obj.fit_intercept else 0)) * n_targets: raise ValueError(f"mojolearn: {path!r} w does not match n_features_in_, fit_intercept and the class count") obj._w = w obj.coef_, obj.intercept_ = _coef_from_w(w, cols, n_targets, obj.fit_intercept) obj.n_features_in_ = cols return obj
def _qn_fit_one_target(est, x, y_enc, n_classes, loss, l1, l2, grad_tol, change_tol, svr_eps=0.0): """One `qn_fit` call on a one-target loss (the 15-field form): the fitted `(n_features + fit_intercept,)` float32 block, `num_iters`, the objective and the OPT_RETCODE. `est` supplies the solver fields.""" rows, cols = x.shape w = zeros((cols + (1 if est.fit_intercept else 0),), "<f4") info = zeros((2,), "<f4") n_iter = est._bind("_mojolearn_estimators").qn_fit( addr_ro(x, name="X"), addr_ro(y_enc, name="y"), addr(w, name="coef_"), addr(info, name="info"), [rows, cols, n_classes, float(l1), float(l2), float(grad_tol), float(change_tol), int(est.max_iter), int(est.linesearch_max_iter), int(est.lbfgs_memory), 1 if est.fit_intercept else 0, 1 if est.penalty_normalized else 0, 0, int(loss), float(svr_eps)], ) return w, int(n_iter), float(info[0]), int(info[1]) def _qn_scores(est, X, w, n_targets=1): """`qn_decision_function` on a fitted block: `(n,)` float32 for one target, `(n, n_targets)` for the column-major one-vs-rest block.""" x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != est.n_features_in_: raise ValueError(f"mojolearn {type(est).__name__} feature count differs from fit") params = [x.shape[0], x.shape[1], 1 if est.fit_intercept else 0] shape = (x.shape[0],) if n_targets > 1: params.append(n_targets) shape = (x.shape[0], n_targets) out = empty(shape, "<f4") est._bind("_mojolearn_estimators").qn_decision_function( addr_ro(x, name="X"), addr_ro(w, name="coef_"), addr(out, name="scores"), params, ) return out def _check_qn_solver_fields(name, tol, max_iter, linesearch_max_iter, lbfgs_memory): if not tol > 0: raise ValueError(f"mojolearn {name}: tol must be positive, got {tol}") for field, value in (("max_iter", max_iter), # glue: three solver argument checks ("linesearch_max_iter", linesearch_max_iter), ("lbfgs_memory", lbfgs_memory)): if int(value) != value or value < 1: raise ValueError(f"mojolearn {name}: {field} must be a positive integer, got {value}") # Maintainer notes (moved from the docstring, 2026-10-12): # Reference: cuml.solvers.QN with loss='l2' (squared) and loss='l1' (absolute), # cuml/cpp/src/glm/qn/glm_linear.cuh; the Mojo implementation is # glm/impl/qn/glm_linear.mojo (DEVIATION 707). change_tol = delta if given else # tol * 0.01 (qn.pyx:504-506). sample_weight: glm/NOT_IMPLEMENTED.tsv.
[docs] class QNRegressor(NumericModeMixin): """Linear regression on the squared or absolute loss, solved by a quasi-Newton method. Minimizes ``mean_i loss(y_i, x_i @ w + b) + l1_strength * ||w||_1 + (l2_strength / 2) * ||w||^2``, where the loss is ``(z - y)**2 / 2`` (``'squared_error'``) or ``|z - y|`` (``'absolute_error'``). With ``penalty_normalized=True`` both strengths are divided by the number of samples. The solver is the L-BFGS / OWL-QN solver of ``LogisticRegression``: a nonzero ``l1_strength`` selects OWL-QN. The intercept is a solver parameter and is not penalized. This is cuML's ``QN`` solver with ``loss='l2'`` or ``loss='l1'``. ``X`` is read as float32; ``y`` must be one-dimensional. Out-of-range parameter values raise ``ValueError``. Parameters ---------- loss : {'squared_error', 'absolute_error'}, default 'squared_error' The data-fit loss; any other value raises ``ValueError``. fit_intercept : bool, default True Whether to fit an (unpenalized) intercept. l1_strength : float, default 0.0 Strength of the l1 penalty, non-negative. l2_strength : float, default 0.0 Strength of the l2 penalty, non-negative. max_iter : int, default 1000 Maximum number of solver iterations, a positive integer. tol : float, default 1e-4 Gradient tolerance, > 0. delta : float or None, default None Tolerance on the change of the objective, non-negative; None means ``tol * 0.01``. linesearch_max_iter : int, default 50 Maximum number of line-search steps per iteration, a positive integer. lbfgs_memory : int, default 5 Number of past updates kept by L-BFGS, a positive integer. warm_start : bool, default False Only False is supported (the solver always starts from zero); True raises ``NotImplementedError``. penalty_normalized : bool, default True Whether to divide both penalty strengths by the number of samples. numeric_mode : {'identical', 'fast'} or None, default None The numeric mode for this estimator; None uses the process default (see ``mojolearn.set_numeric_mode``). ``'fast'`` is for Apple GPUs. Attributes ---------- coef_ : array of shape (n_features,), float32 The fitted coefficients. intercept_ : float The fitted intercept; 0.0 when ``fit_intercept`` is False. n_iter_ : array of shape (1,), int64 The number of solver iterations run. objective_ : float The final value of the minimized objective. retcode_ : int The solver's return code, as on ``LogisticRegression`` (0 means converged). n_features_in_ : int The number of features seen in ``fit``. input_copied_ : bool Whether ``fit`` had to copy ``X`` to float32 C order. Examples -------- >>> import numpy as np, mojolearn >>> rng = np.random.default_rng(0) >>> X = rng.random((1000, 8), dtype=np.float32) >>> y = X @ np.arange(8, dtype=np.float32) + 1.0 >>> model = mojolearn.QNRegressor(loss="absolute_error").fit(X, y) >>> pred = model.predict(X) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "regressor" _BINDING = "_mojolearn_estimators" _LOSSES = {"squared_error": _QN_LOSS_SQUARED, "absolute_error": _QN_LOSS_ABS}
[docs] def __init__(self, *, loss="squared_error", fit_intercept=True, l1_strength=0.0, l2_strength=0.0, max_iter=1000, tol=1e-4, delta=None, linesearch_max_iter=50, lbfgs_memory=5, warm_start=False, penalty_normalized=True): if loss not in self._LOSSES: raise ValueError( f"Expected loss to be one of {list(self._LOSSES)}, got {loss!r}") if warm_start: raise NotImplementedError( "mojolearn QNRegressor: warm_start is not implemented (the " "solver starts from w0 = 0)") if l1_strength < 0 or l2_strength < 0: raise ValueError( "mojolearn QNRegressor: l1_strength and l2_strength must be " f"non-negative, got {l1_strength} and {l2_strength}") if delta is not None and delta < 0: raise ValueError(f"mojolearn QNRegressor: delta must be non-negative, got {delta}") _check_qn_solver_fields("QNRegressor", tol, max_iter, linesearch_max_iter, lbfgs_memory) self.loss = loss self.fit_intercept = fit_intercept self.l1_strength = l1_strength self.l2_strength = l2_strength self.max_iter = max_iter self.tol = tol self.delta = delta self.linesearch_max_iter = linesearch_max_iter self.lbfgs_memory = lbfgs_memory self.warm_start = False self.penalty_normalized = penalty_normalized
[docs] def fit(self, X, y, sample_weight=None): """Fit the model. Parameters ---------- X : array-like of shape (n_samples, n_features) Training data, read as float32. y : array-like of shape (n_samples,) Target values. A 2-D ``y`` or a length mismatch raises ``ValueError``. sample_weight : None Must be None; weights raise ``NotImplementedError``. Returns ------- self : QNRegressor The fitted estimator. """ if sample_weight is not None: raise NotImplementedError( "mojolearn QNRegressor: sample_weight is not implemented " "(GLMBase::add_sample_weights, glm_base.cuh:115; " "glm/NOT_IMPLEMENTED.tsv)") x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape t = _target_1d(y, rows, "mojolearn QNRegressor requires a 1-D y", "mojolearn QNRegressor X and y lengths differ") change_tol = self.delta if self.delta is not None else self.tol * 0.01 w, n_iter, self.objective_, self.retcode_ = _qn_fit_one_target( self, x, t, 1, self._LOSSES[self.loss], self.l1_strength, self.l2_strength, self.tol, change_tol) self._w = w self.coef_ = w[:cols] self.intercept_ = float(w[cols]) if self.fit_intercept else 0.0 self.n_iter_ = Array.from_list([n_iter], "<i8") self.n_features_in_ = cols return self
[docs] def predict(self, X): """Predict with the fitted model. Parameters ---------- X : array-like of shape (n_samples, n_features) Samples, read as float32. Returns ------- y_pred : array of shape (n_samples,), float32 ``X @ coef_ + intercept_``. Raises ------ ValueError If the model is not fitted, or ``X`` has a different number of features than the training data. """ if not hasattr(self, "_w"): raise ValueError("mojolearn QNRegressor: call fit before predict") return _qn_scores(self, X, self._w)
[docs] def score(self, X, y): """Return the coefficient of determination R^2 of the prediction. Parameters ---------- X : array-like of shape (n_samples, n_features) Test samples. y : array-like of shape (n_samples,) True target values. Returns ------- score : float ``1 - SS_res / SS_tot``, computed in float64. A constant ``y`` scores 0.0. """ return _r2_host(self.predict(X), y)