Source code for mojolearn.svm

# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
# Maintainer notes (moved from the module docstring, 2026-10-12):
# Reference for the linear pair: cuML's LinearSVC and LinearSVR
# (cuml/svm/linear_svc.py, linear_svr.py, linear.pyx, and cpp/src/svm/linear.cu,
# which fills qn_params and calls qnFit); the losses are
# cpp/src/glm/qn/glm_svm.cuh, here glm/impl/qn/glm_svm.mojo (DEVIATIONS 708 and
# 714). DEVIATION 714 stands on this surface: the squared hinge's gradient is
# the derivative of its value, 2 (z - s), so LinearSVC(loss='squared_hinge')
# minimizes the objective it documents. The objective's penalty_normalized =
# true is linear.cu:151-176; tol -> grad_tol = tol, change_tol = 0.1 * tol is
# linear.pyx:150-151.
"""Support vector machines.

``SVC`` and ``SVR`` are kernel machines trained by SMO. ``LinearSVC`` and
``LinearSVR`` are linear machines trained by the quasi-Newton solver of
``LogisticRegression`` (L-BFGS, or OWL-QN for an l1 penalty) on the hinge
family of losses. The linear machines minimize::

    mean_i loss(y_i, x_i @ w + b) + penalty(w) / (C * n_samples)

where ``penalty`` is the l1 norm or half the squared l2 norm: scikit-learn's
``LinearSVC`` / ``LinearSVR`` objective divided by ``C * n_samples``. The
intercept is never penalized. ``tol`` is the gradient tolerance; the
objective-change tolerance is ``0.1 * tol``. This follows cuML's
``LinearSVC`` and ``LinearSVR``.
"""

from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, empty, frombytes, zeros
from ._labels import argmax_rows, decode_labels, encode_labels, sorted_classes, threshold_codes
from ._mode import NumericModeMixin
from ._svm_impl import SVC, SVR
from .linear_model import (
    _QN_LOSS_SVC_L1, _QN_LOSS_SVC_L2, _QN_LOSS_SVR_L1, _QN_LOSS_SVR_L2,
    _accuracy_host, _check_qn_solver_fields, _labels_1d, _qn_fit_one_target,
    _qn_scores, _r2_host, _target_1d, _py2mojo_flags, _PY2MOJO_ROWS,
)

__all__ = ["SVC", "SVR", "LinearSVC", "LinearSVR"]


class _LinearSVMBase(NumericModeMixin):
    """The fields and refusals the two linear machines share."""

    _BINDING = "_mojolearn_estimators"
    _LOSSES = {}

    def _init_common(self, penalty, loss, C, fit_intercept, penalized_intercept,
                     tol, max_iter, linesearch_max_iter, lbfgs_memory):
        name = type(self).__name__
        if penalty not in ("l1", "l2"):
            raise ValueError(f"Expected penalty to be one of ['l1', 'l2'], got {penalty!r}")
        if loss not in self._LOSSES:
            raise ValueError(f"Expected loss to be one of {list(self._LOSSES)}, got {loss!r}")
        if not C > 0:
            raise ValueError(f"mojolearn {name}: C must be positive, got {C}")
        if penalized_intercept:
            raise NotImplementedError(
                f"mojolearn {name}: penalized_intercept=True is not implemented "
                "(the reference appends a column of ones to X and fits without "
                "a bias, linear.cu:150-163); the intercept is never penalized here")
        _check_qn_solver_fields(name, tol, max_iter, linesearch_max_iter, lbfgs_memory)
        self.penalty = penalty
        self.loss = loss
        self.C = C
        self.fit_intercept = fit_intercept
        self.penalized_intercept = False
        self.tol = tol
        self.max_iter = max_iter
        self.linesearch_max_iter = linesearch_max_iter
        self.lbfgs_memory = lbfgs_memory
        self.penalty_normalized = True

    def _strengths(self):
        """`linear.cu:151,174-175`: `iC = 1 / C` on the chosen penalty."""
        inv_c = 1.0 / self.C
        return (inv_c, 0.0) if self.penalty == "l1" else (0.0, inv_c)

    def _refuse_sample_weight(self, sample_weight):
        if sample_weight is not None:
            raise NotImplementedError(
                f"mojolearn {type(self).__name__}: sample_weight is not implemented "
                "(GLMBase::add_sample_weights, glm_base.cuh:115; "
                "glm/NOT_IMPLEMENTED.tsv)")


[docs] class LinearSVC(_LinearSVMBase): """Linear support vector classification. Minimizes the hinge or squared hinge loss plus an l2 or l1 penalty (see the ``mojolearn.svm`` module description for the objective). Two classes fit one machine; more classes fit one machine per class against the rest, in class order. The intercept is never penalized. ``X`` is read as float32. ``y`` is a 1-D array of labels; ``classes_`` is the sorted unique labels. Invalid parameter values raise ``ValueError``. Parameters ---------- penalty : {'l2', 'l1'}, default 'l2' The regularization term; ``'l1'`` uses OWL-QN and gives exact zeros. loss : {'squared_hinge', 'hinge'}, default 'squared_hinge' The data-fit loss. C : float, default 1.0 Inverse regularization strength, > 0. fit_intercept : bool, default True Whether to fit an (unpenalized) intercept. penalized_intercept : bool, default False Only False is supported; True raises ``NotImplementedError``. class_weight : None Must be None; anything else raises ``NotImplementedError``. tol : float, default 1e-4 Gradient tolerance, > 0. max_iter : int, default 1000 Maximum number of solver iterations per machine. linesearch_max_iter : int, default 100 Maximum number of line-search steps per iteration. lbfgs_memory : int, default 5 Number of past updates kept by L-BFGS. multi_class : {'ovr'}, default 'ovr' One-vs-rest, the only supported value. 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 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 largest iteration count over the machines. objective_ : float or list of float The final objective value (one per machine when there are more than two classes). retcode_ : int or list of int The solver's return code, as on ``LogisticRegression`` (one per machine when there are more than two classes). 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.LinearSVC(C=10.0).fit(X, y) >>> labels = clf.predict(X) """ #: scikit-learn's estimator kind: `cross_val_score` stratifies a #: classifier's default folds, as scikit-learn's does. _estimator_type = "classifier" _LOSSES = {"hinge": _QN_LOSS_SVC_L1, "squared_hinge": _QN_LOSS_SVC_L2}
[docs] def __init__(self, *, penalty="l2", loss="squared_hinge", C=1.0, fit_intercept=True, penalized_intercept=False, class_weight=None, tol=1e-4, max_iter=1000, linesearch_max_iter=100, lbfgs_memory=5, multi_class="ovr"): if multi_class != "ovr": raise ValueError( f"mojolearn LinearSVC: multi_class must be 'ovr', got {multi_class!r}") if class_weight is not None: raise NotImplementedError( "mojolearn LinearSVC: class_weight is not implemented (it becomes " "a sample_weight in the reference, and sample_weight is not " "implemented; glm/NOT_IMPLEMENTED.tsv)") self._init_common(penalty, loss, C, fit_intercept, penalized_intercept, tol, max_iter, linesearch_max_iter, lbfgs_memory) self.class_weight = None self.multi_class = multi_class
[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 : LinearSVC The fitted estimator. """ self._refuse_sample_weight(sample_weight) x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape labels, _ = _labels_1d(y) if labels is None: raise ValueError("mojolearn LinearSVC requires a 1-D y") if len(labels) != rows: raise ValueError("mojolearn LinearSVC X and y lengths differ") # lane py-shared: the native encoder (`sorted_classes` is its definition) self.classes_, codes = encode_labels(labels) n_classes = len(self.classes_) if n_classes < 2: raise ValueError("mojolearn LinearSVC: y has one class") l1, l2 = self._strengths() # `OvrSelector` (linear.cu:50-54): y is 1 on the selected class and 0 # elsewhere; two classes select class 1 and fit once. selected = [1] if n_classes == 2 else list(range(n_classes)) blocks, n_iter, objectives, retcodes = [], 0, [], [] ovr = getattr(self._bind("_mojolearn_estimators"), "qn_fit_ovr", None) if ovr is not None and codes.dtype == "<i4": # lane fam2-linear: X and the class codes cross the bus once; # the binding builds each 0/1 target on the device and runs the # same per-class fits (a binding without the entry keeps the # per-class calls below) n_fits = len(selected) n_coefs = cols + (1 if self.fit_intercept else 0) w_all = zeros((n_fits, n_coefs), "<f4") info = zeros((n_fits, 3), "<f4") codes_c = codes._as_c() ovr( addr_ro(x, name="X"), addr_ro(codes_c, name="codes"), addr(w_all, name="coef_"), addr(info, name="info"), [rows, cols, selected[0], n_fits, float(l1), float(l2), float(self.tol), float(0.1 * self.tol), 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, int(self._LOSSES[self.loss])]) for k, (fx, rc, it) in enumerate(info.tolist()): # glue: unpacks one result row per fit blocks.append(w_all[k]) n_iter = max(n_iter, int(it)) objectives.append(float(fx)) retcodes.append(int(rc)) selected = [] for cls in selected: # glue: one binary fit per selected class # the 0/1 target through the core helpers (equal_elements, cast_elements) y_enc = (codes == cls).astype("<f4") w, k, fx, rc = _qn_fit_one_target( self, x, y_enc, 2, self._LOSSES[self.loss], l1, l2, self.tol, 0.1 * self.tol) blocks.append(w) n_iter = max(n_iter, k) objectives.append(fx) retcodes.append(rc) n_targets = len(blocks) n_coefs = cols + (1 if self.fit_intercept else 0) # the (C, n_coefs) block, joined as bytes; its column-major copy (one # Mojo strided copy) is the `w[c + C*j]` block `qn_decision_function` reads W = frombytes(b"".join(b.tobytes() for b in blocks), "<f4", (n_targets, n_coefs)) # glue: joins one coefficient block per target self._w = Array._view_of(W._as_order("F"), (n_targets * n_coefs,), "C") self.coef_ = W[:, :cols] self.intercept_ = W[:, cols] if self.fit_intercept else zeros((n_targets,), "<f4") self.n_iter_ = Array.from_list([n_iter], "<i8") self.objective_ = objectives[0] if n_targets == 1 else objectives self.retcode_ = retcodes[0] if n_targets == 1 else retcodes 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 LinearSVC: call fit first") n_classes = len(self.classes_) return _qn_scores(self, X, self._w, 1 if n_classes == 2 else n_classes)
[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. """ b = self._bind("_mojolearn_estimators") if hasattr(self, "classes_") else None if (b is not None and len(self.classes_) == 2 and _py2mojo_flags(b) & _PY2MOJO_ROWS): # lane/apple-fast-py2mojo-linear: the `z > 0` codes in the binding # (`qn_predict_binary`, the same decision function and threshold) if not hasattr(self, "_w"): raise ValueError("mojolearn LinearSVC: call fit first") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError(f"mojolearn {type(self).__name__} feature count differs from fit") codes = empty((x.shape[0],), "<i8") if x.shape[0]: b.qn_predict_binary( 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) multi = getattr(b, "qn_predict_multiclass", None) if b is not None else None if multi is not None and len(self.classes_) > 2: # lane fam2-linear: the row argmax on the device beside the scores if not hasattr(self, "_w"): raise ValueError("mojolearn LinearSVC: call fit first") x, _ = as_f32_c(X, ndim=2, name="X") if x.shape[1] != self.n_features_in_: raise ValueError(f"mojolearn {type(self).__name__} feature count differs from fit") codes = empty((x.shape[0],), "<i8") if x.shape[0]: multi( 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, len(self.classes_)]) return decode_labels(self.classes_, codes) scores = self.decision_function(X) if len(self.classes_) == 2: return decode_labels(self.classes_, threshold_codes(scores)) return decode_labels(self.classes_, argmax_rows(scores))
[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)
[docs] class LinearSVR(_LinearSVMBase): """Linear support vector regression. Minimizes the epsilon-insensitive or squared epsilon-insensitive loss plus an l1 or l2 penalty (see the ``mojolearn.svm`` module description for the objective). The intercept is never penalized. The default penalty is ``'l1'`` (cuML's default), which uses OWL-QN and gives exact zeros. scikit-learn's ``LinearSVR`` is l2 only: pass ``penalty='l2'`` for its objective. ``X`` is read as float32; ``y`` must be one-dimensional. Invalid parameter values raise ``ValueError``. Parameters ---------- epsilon : float, default 0.0 Width of the insensitive zone, finite and >= 0. penalty : {'l1', 'l2'}, default 'l1' The regularization term. loss : {'epsilon_insensitive', 'squared_epsilon_insensitive'}, default 'epsilon_insensitive' The data-fit loss. C : float, default 1.0 Inverse regularization strength, > 0. fit_intercept : bool, default True Whether to fit an (unpenalized) intercept. penalized_intercept : bool, default False Only False is supported; True raises ``NotImplementedError``. tol : float, default 1e-4 Gradient tolerance, > 0. max_iter : int, default 1000 Maximum number of solver iterations. linesearch_max_iter : int, default 100 Maximum number of line-search steps per iteration. lbfgs_memory : int, default 5 Number of past updates kept by L-BFGS. 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``. 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.LinearSVR(penalty="l2", epsilon=0.1).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" _LOSSES = {"epsilon_insensitive": _QN_LOSS_SVR_L1, "squared_epsilon_insensitive": _QN_LOSS_SVR_L2}
[docs] def __init__(self, *, epsilon=0.0, penalty="l1", loss="epsilon_insensitive", C=1.0, fit_intercept=True, penalized_intercept=False, tol=1e-4, max_iter=1000, linesearch_max_iter=100, lbfgs_memory=5): if not (0.0 <= epsilon < float("inf")): raise ValueError( f"mojolearn LinearSVR: epsilon must be finite and non-negative, got {epsilon}") self._init_common(penalty, loss, C, fit_intercept, penalized_intercept, tol, max_iter, linesearch_max_iter, lbfgs_memory) self.epsilon = epsilon
[docs] def fit(self, X, y, sample_weight=None): """Fit the regressor. 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 : LinearSVR The fitted estimator. """ self._refuse_sample_weight(sample_weight) x, self.input_copied_ = as_f32_c(X, ndim=2, name="X") rows, cols = x.shape t = _target_1d(y, rows, "mojolearn LinearSVR requires a 1-D y", "mojolearn LinearSVR X and y lengths differ") l1, l2 = self._strengths() w, n_iter, self.objective_, self.retcode_ = _qn_fit_one_target( self, x, t, 1, self._LOSSES[self.loss], l1, l2, self.tol, 0.1 * self.tol, svr_eps=self.epsilon) 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 LinearSVR: 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)