Source code for braintools.optim._regression

# Copyright 2026 BrainX Ecosystem Limited. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ==============================================================================

"""Closed-form and proximal linear regressions (readout training).

Pure functions that fit ``Y ~ X @ coef + intercept``:

- :func:`linear_regression`: ordinary least squares (``lstsq``);
- :func:`ridge_regression`: L2-penalised least squares, solved through the SVD;
- :func:`lasso_regression` and :func:`elastic_net_regression`: L1 and L1+L2
  penalised least squares, solved with FISTA (with adaptive restart) inside a
  ``lax.while_loop``.

All of them return a :class:`RegressionResult` and can be wrapped in
``jax.jit`` and ``jax.vmap``. The objectives are scaled exactly as in
scikit-learn's ``LinearRegression``, ``Ridge``, ``Lasso`` and ``ElasticNet``.
"""

import numbers
from typing import Any, NamedTuple

import brainunit as u
import jax.numpy as jnp
from jax import lax

from braintools._misc import set_module_as

__all__ = [
    'RegressionResult',
    'linear_regression',
    'ridge_regression',
    'lasso_regression',
    'elastic_net_regression',
]


[docs] class RegressionResult(NamedTuple): """Fitted coefficients of a linear regression ``Y ~ X @ coef + intercept``. Attributes ---------- coef : jax.Array or brainunit.Quantity Coefficients, shape ``(n_features,)`` for a 1-D target and ``(n_features, n_targets)`` for a 2-D target (the transpose of scikit-learn's ``coef_``, so that ``X @ coef`` is the prediction). In the unit of ``Y`` divided by the unit of ``X``. intercept : jax.Array or brainunit.Quantity Intercept, a scalar for a 1-D target and shape ``(n_targets,)`` for a 2-D target; zero when ``fit_intercept=False``. In the unit of ``Y``. """ coef: Any intercept: Any
# --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- def _prepare(X, Y, fit_intercept): """Validate shapes, split units and centre the data. Returns ``(Xc, Yc, x_mean, y_mean, squeeze, x_unit, y_unit)`` with ``Yc`` always 2-D. """ X, x_unit = u.split_mantissa_unit(X) Y, y_unit = u.split_mantissa_unit(Y) X = jnp.asarray(X) Y = jnp.asarray(Y) if X.ndim != 2: raise ValueError(f'X must be a 2-D array (n_samples, n_features), got shape {X.shape}.') if Y.ndim not in (1, 2): raise ValueError(f'Y must be a 1-D or 2-D array, got shape {Y.shape}.') if Y.shape[0] != X.shape[0]: raise ValueError( f'X and Y must have the same number of samples, got {X.shape[0]} and {Y.shape[0]}.' ) if X.shape[0] == 0: raise ValueError('X must contain at least one sample.') dtype = jnp.result_type(X.dtype, Y.dtype, jnp.float32) X = X.astype(dtype) Y = Y.astype(dtype) squeeze = Y.ndim == 1 if squeeze: Y = Y[:, None] if fit_intercept: x_mean = jnp.mean(X, axis=0) y_mean = jnp.mean(Y, axis=0) X = X - x_mean Y = Y - y_mean else: x_mean = jnp.zeros(X.shape[1], dtype) y_mean = jnp.zeros(Y.shape[1], dtype) return X, Y, x_mean, y_mean, squeeze, x_unit, y_unit def _finish(coef, x_mean, y_mean, fit_intercept, squeeze, x_unit, y_unit): """Compute the intercept, restore the target shape and attach units.""" if fit_intercept: intercept = y_mean - x_mean @ coef else: intercept = jnp.zeros_like(y_mean) if squeeze: coef = coef[:, 0] intercept = intercept[0] coef = u.maybe_decimal(u.Quantity(coef, unit=y_unit / x_unit)) intercept = u.maybe_decimal(u.Quantity(intercept, unit=y_unit)) return RegressionResult(coef, intercept) def _penalty(value, unit, name): """Return the mantissa of a penalty strength expressed in ``unit``. A plain number is accepted only when ``unit`` is dimensionless; otherwise ``value`` must be a Quantity of the dimension of ``unit``. """ if isinstance(value, u.Quantity): if not u.get_dim(value) == u.get_dim(unit): raise u.UnitMismatchError( f'{name} must have the unit {unit} (the penalty must match the units of ' f'the data), got {value.unit}.' ) return value.to_decimal(unit) if not unit.is_unitless: raise u.UnitMismatchError( f'{name} must be a Quantity with the unit {unit} when X or Y carry units, ' f'got the plain number {value!r}.' ) if isinstance(value, numbers.Real) and value < 0: raise ValueError(f'{name} must be >= 0, got {value}.') return value def _rcond(X): return jnp.finfo(X.dtype).eps * max(X.shape) def _check_iter(max_iter, tol): if not isinstance(max_iter, numbers.Integral) or max_iter < 1: raise ValueError(f'max_iter must be a positive integer, got {max_iter!r}.') if isinstance(tol, numbers.Real) and tol < 0: raise ValueError(f'tol must be >= 0, got {tol}.') def _default_tol(tol, dtype): """Resolve ``tol=None``: 1e-8 in float64, 1e-7 (about the rounding floor) in float32.""" if tol is not None: return tol return 1e-8 if dtype == jnp.float64 else 1e-7 def _soft_threshold(x, thresh): return jnp.sign(x) * jnp.maximum(jnp.abs(x) - thresh, 0.) def _fista(X, Y, l1, l2, max_iter, tol): """Minimise ``1/(2n) ||Y - X W||^2 + l1 ||W||_1 + l2/2 ||W||^2`` with FISTA. Uses the gradient-based adaptive restart of O'Donoghue and Candes, which restores linear convergence on strongly convex problems. Stopping rule: with ``L`` the Lipschitz constant of the smooth part and ``W_{k+1} = prox(Z_k - grad(Z_k) / L)``, the gradient mapping ``G_k = L (Z_k - W_{k+1})`` is an element of ``grad(Z_k) + d(l1 ||.||_1)(W_{k+1})`` and is zero exactly at a minimiser, so ``max|G_k|`` is a first-order optimality (KKT) residual in the units of the gradient. The loop stops when, for every target column ``j``, ``max_i |G_k|_ij <= tol * max_i |X^T Y|_ij / n``; the right-hand side is the gradient at ``W = 0`` (for the lasso, the smallest ``alpha`` with an all-zero solution), so the test is invariant to rescaling ``X`` and ``Y``. A negative or NaN penalty returns NaN. """ n = X.shape[0] dtype = X.dtype l1 = jnp.asarray(l1, dtype) l2 = jnp.asarray(l2, dtype) # `not (x >= 0)` also catches NaN; traced penalties cannot be checked eagerly. invalid = jnp.logical_not(jnp.logical_and(l1 >= 0, l2 >= 0)) smax = jnp.linalg.norm(X, ord=2) lip = smax ** 2 / n + l2 # All-zero X with l2 == 0: any step works (the minimiser is W = 0). lip = jnp.where(lip > 0, lip, jnp.ones_like(lip)) threshold = jnp.asarray(_default_tol(tol, dtype), dtype) * jnp.max(jnp.abs(X.T @ Y), axis=0) / n def body(carry): k, W, Z, t, _ = carry grad = X.T @ (X @ Z - Y) / n + l2 * Z W_new = _soft_threshold(Z - grad / lip, l1 / lip) restart = jnp.vdot(Z - W_new, W_new - W) > 0 t_new = (1. + jnp.sqrt(1. + 4. * t * t)) / 2. beta = jnp.where(restart, 0., (t - 1.) / t_new) t_new = jnp.where(restart, jnp.ones_like(t_new), t_new) Z_new = W_new + beta * (W_new - W) residual = lip * jnp.max(jnp.abs(Z - W_new), axis=0) done = jnp.all(residual <= threshold) return k + 1, W_new, Z_new, t_new, done def cond(carry): k, _, _, _, done = carry return jnp.logical_and(k < max_iter, jnp.logical_not(done)) W0 = jnp.zeros((X.shape[1], Y.shape[1]), dtype) init = (jnp.asarray(0), W0, W0, jnp.asarray(1., dtype), invalid) _, W, _, _, _ = lax.while_loop(cond, body, init) return jnp.where(invalid, jnp.nan, W) # --------------------------------------------------------------------------- # public API # --------------------------------------------------------------------------- @set_module_as('braintools.optim') def linear_regression(X, Y, *, fit_intercept: bool = True) -> RegressionResult: r"""Fit an ordinary least-squares linear regression. Minimises :math:`\|Y - X W - b\|_F^2` with :func:`jax.numpy.linalg.lstsq`. When ``X`` is rank deficient (for example more features than samples, or a constant feature with ``fit_intercept=True``) the minimum-norm solution is returned, so constant features get a zero coefficient. Parameters ---------- X : array_like or brainunit.Quantity Design matrix, shape ``(n_samples, n_features)``. Y : array_like or brainunit.Quantity Targets, shape ``(n_samples,)`` or ``(n_samples, n_targets)``. fit_intercept : bool, default True Fit an unpenalised intercept by centring ``X`` and ``Y`` first. Returns ------- RegressionResult ``coef`` of shape ``(n_features,)`` or ``(n_features, n_targets)`` (in the unit of ``Y`` over the unit of ``X``) and ``intercept`` (in the unit of ``Y``). Raises ------ ValueError If ``X`` is not 2-D, ``Y`` is not 1-D or 2-D, or the sample counts differ. See Also -------- ridge_regression : L2-penalised least squares. RLS : the same solution computed online, one batch at a time. Notes ----- Matches ``sklearn.linear_model.LinearRegression``. The function is pure and supports ``jax.jit``, ``jax.vmap`` and ``jax.grad`` (gradients with respect to ``X`` require distinct non-zero singular values of the centred ``X``; gradients with respect to ``Y`` always exist). Examples -------- .. code-block:: python >>> import jax.numpy as jnp >>> import braintools >>> X = jnp.array([[0.], [1.], [2.], [3.]]) >>> Y = 2. * X[:, 0] + 1. >>> res = braintools.optim.linear_regression(X, Y) >>> print(jnp.round(res.coef, 4), round(float(res.intercept), 4)) [2.] 1.0 """ Xc, Yc, xm, ym, squeeze, xu, yu = _prepare(X, Y, fit_intercept) coef = jnp.linalg.lstsq(Xc, Yc, rcond=_rcond(Xc))[0] return _finish(coef, xm, ym, fit_intercept, squeeze, xu, yu) @set_module_as('braintools.optim') def ridge_regression(X, Y, alpha=1.0, *, fit_intercept: bool = True) -> RegressionResult: r"""Fit a ridge (L2-penalised least-squares) regression in closed form. Minimises .. math:: \|Y - X W - b\|_F^2 + \alpha \|W\|_F^2, the objective of ``sklearn.linear_model.Ridge``; the intercept :math:`b` is not penalised. The solution is computed from the thin SVD :math:`X_c = U S V^\top` of the centred design matrix as :math:`W = V \operatorname{diag}(s / (s^2 + \alpha)) U^\top Y_c`, which works for any shape (including more features than samples) and reduces to the minimum-norm least-squares solution at ``alpha=0``. Parameters ---------- X : array_like or brainunit.Quantity Design matrix, shape ``(n_samples, n_features)``. Y : array_like or brainunit.Quantity Targets, shape ``(n_samples,)`` or ``(n_samples, n_targets)``. alpha : float or brainunit.Quantity, default 1.0 Non-negative L2 penalty. When ``X`` carries a unit, ``alpha`` must be a Quantity in that unit squared. fit_intercept : bool, default True Fit an unpenalised intercept by centring ``X`` and ``Y`` first. Returns ------- RegressionResult ``coef`` of shape ``(n_features,)`` or ``(n_features, n_targets)`` and ``intercept``. Raises ------ ValueError If the shapes are invalid or a concrete ``alpha`` is negative. brainunit.UnitMismatchError If ``alpha`` does not have the unit of ``X`` squared. See Also -------- linear_regression : the ``alpha=0`` case. lasso_regression, elastic_net_regression : L1 and L1+L2 penalties. RLS : online ridge regression with ``P0 = I / alpha``. Notes ----- Pure; supports ``jax.jit``, ``jax.vmap`` (for example over a grid of ``alpha``) and ``jax.grad``. Gradients with respect to ``Y`` and ``alpha`` always exist; gradients with respect to ``X`` go through the SVD and need distinct non-zero singular values of the centred ``X``. A negative ``alpha`` that is only known at run time (traced under ``jax.jit`` or ``jax.vmap``) cannot raise, so it returns NaN coefficients and intercept instead. Examples -------- .. code-block:: python >>> import jax >>> import jax.numpy as jnp >>> import braintools >>> X = jax.random.normal(jax.random.PRNGKey(0), (50, 3)) >>> Y = X @ jnp.array([1., -2., 0.5]) + 3. >>> res = braintools.optim.ridge_regression(X, Y, alpha=1e-6) >>> print(jnp.round(res.coef, 3), round(float(res.intercept), 3)) [ 1. -2. 0.5] 3.0 >>> >>> # vmap over a grid of penalties >>> alphas = jnp.array([0.1, 1., 10.]) >>> coefs = jax.vmap(lambda a: braintools.optim.ridge_regression(X, Y, a).coef)(alphas) >>> coefs.shape (3, 3) """ Xc, Yc, xm, ym, squeeze, xu, yu = _prepare(X, Y, fit_intercept) alpha = jnp.asarray(_penalty(alpha, xu * xu, 'alpha'), Xc.dtype) U, s, Vt = jnp.linalg.svd(Xc, full_matrices=False) keep = s > _rcond(Xc) * s[0] s_safe = jnp.where(keep, s, 1.) d = jnp.where(keep, s_safe / (s_safe ** 2 + alpha), 0.) coef = Vt.T @ (d[:, None] * (U.T @ Yc)) # A traced alpha cannot be validated eagerly: a negative (or NaN) one gives NaN. coef = jnp.where(alpha >= 0, coef, jnp.nan) return _finish(coef, xm, ym, fit_intercept, squeeze, xu, yu) @set_module_as('braintools.optim') def elastic_net_regression( X, Y, alpha=1.0, l1_ratio=0.5, *, fit_intercept: bool = True, max_iter: int = 10000, tol: float | None = None, ) -> RegressionResult: r"""Fit an elastic-net (L1 + L2 penalised) linear regression with FISTA. Minimises the objective of ``sklearn.linear_model.ElasticNet``, .. math:: \frac{1}{2 n} \|Y - X W - b\|_F^2 + \alpha \rho \|W\|_1 + \frac{\alpha (1 - \rho)}{2} \|W\|_F^2, with :math:`\rho` = ``l1_ratio`` and :math:`n` the number of samples, by FISTA [1]_ with gradient-based adaptive restart [2]_ in a ``lax.while_loop``. The intercept is not penalised. Each target column is an independent problem (as in scikit-learn, not a multi-task penalty). Parameters ---------- X : array_like Design matrix, shape ``(n_samples, n_features)``. Y : array_like Targets, shape ``(n_samples,)`` or ``(n_samples, n_targets)``. alpha : float, default 1.0 Non-negative overall penalty strength. l1_ratio : float, default 0.5 Mixing parameter :math:`\rho \in [0, 1]`: 1 is the lasso, 0 is ridge (with the ``1/(2n)`` data-term scaling). fit_intercept : bool, default True Fit an unpenalised intercept by centring ``X`` and ``Y`` first. max_iter : int, default 10000 Maximum number of FISTA iterations (static). tol : float, optional Relative tolerance on the first-order optimality residual (see Notes). Default ``1e-8`` for float64 data and ``1e-7`` (close to the rounding floor) for float32 data. ``tol=0`` runs all ``max_iter`` iterations unless an exact minimiser is reached. Returns ------- RegressionResult ``coef`` of shape ``(n_features,)`` or ``(n_features, n_targets)`` and ``intercept``. Raises ------ ValueError If the shapes are invalid, a concrete ``alpha`` is negative, a concrete ``l1_ratio`` is outside ``[0, 1]``, or ``max_iter``/``tol`` are invalid. brainunit.UnitMismatchError If ``X`` or ``Y`` carry physical units: the L1 and L2 terms then have different units, so the objective is not defined. Pass mantissas. See Also -------- lasso_regression : the ``l1_ratio=1`` case. ridge_regression : closed-form L2 penalty. Notes ----- Pure; supports ``jax.jit`` and ``jax.vmap`` (a batched ``while_loop`` runs until every member has converged). The iteration count is data dependent, so reverse-mode differentiation (``jax.grad``) is not supported; forward mode (``jax.jvp``) works but differentiates the iterations, not the solution map. Stopping rule. Let :math:`L = \sigma_{\max}(X_c)^2 / n + \alpha (1 - \rho)` be the Lipschitz constant of the smooth part, :math:`Z_k` the extrapolated point and :math:`W_{k+1}` the proximal-gradient step from it. The gradient mapping :math:`G_k = L (Z_k - W_{k+1})` lies in :math:`\nabla f(Z_k) + \alpha \rho\, \partial \|W_{k+1}\|_1` and vanishes exactly at a minimiser, so it measures the violation of the optimality (KKT) conditions in the units of the gradient. The loop stops once, for every target column :math:`j`, .. math:: \max_i |G_k|_{ij} \le \mathrm{tol} \cdot \max_i |X_c^\top Y_c|_{ij} / n, i.e. the residual is small relative to the gradient at :math:`W = 0`. The test is invariant to rescaling ``X`` and ``Y``, and unlike a test on the step :math:`|W_{k+1} - Z_k|` it does not stop early when the curvature is small (more features than samples, ill-conditioned ``X``). The coefficient error is roughly the residual divided by the smallest curvature on the active set, so for very ill-conditioned problems lower ``tol`` (and raise ``max_iter``). A penalty that is only known at run time (traced under ``jax.jit`` or ``jax.vmap``) cannot raise: a negative ``alpha`` (or an ``l1_ratio`` outside ``[0, 1]`` that makes a penalty negative) returns NaN coefficients and intercept, without iterating. References ---------- .. [1] A. Beck and M. Teboulle, "A fast iterative shrinkage-thresholding algorithm for linear inverse problems," SIAM J. Imaging Sci. 2(1), 183-202, 2009. .. [2] B. O'Donoghue and E. Candes, "Adaptive restart for accelerated gradient schemes," Found. Comput. Math. 15, 715-732, 2015. Examples -------- .. code-block:: python >>> import jax >>> import jax.numpy as jnp >>> import braintools >>> X = jax.random.normal(jax.random.PRNGKey(0), (100, 5)) >>> Y = X @ jnp.array([3., 0., 0., -2., 0.]) >>> res = braintools.optim.elastic_net_regression(X, Y, alpha=0.1, l1_ratio=0.7) >>> print(jnp.round(res.coef, 2)) [ 2.79 -0. 0. -1.88 0. ] """ _check_iter(max_iter, tol) if isinstance(l1_ratio, numbers.Real) and not 0. <= float(l1_ratio) <= 1.: raise ValueError(f'l1_ratio must be in [0, 1], got {l1_ratio}.') Xc, Yc, xm, ym, squeeze, xu, yu = _prepare(X, Y, fit_intercept) if not (xu.is_unitless and yu.is_unitless): raise u.UnitMismatchError( 'elastic_net_regression does not support X or Y with physical units: the L1 ' 'and L2 penalties would have different units. Pass the mantissas instead.' ) alpha = _penalty(alpha, xu, 'alpha') coef = _fista(Xc, Yc, alpha * l1_ratio, alpha * (1. - l1_ratio), max_iter, tol) return _finish(coef, xm, ym, fit_intercept, squeeze, xu, yu) @set_module_as('braintools.optim') def lasso_regression( X, Y, alpha=1.0, *, fit_intercept: bool = True, max_iter: int = 10000, tol: float | None = None, ) -> RegressionResult: r"""Fit a lasso (L1-penalised) linear regression with FISTA. Minimises the objective of ``sklearn.linear_model.Lasso``, .. math:: \frac{1}{2 n} \|Y - X W - b\|_F^2 + \alpha \|W\|_1, with :math:`n` the number of samples, by FISTA [1]_ with gradient-based adaptive restart [2]_ in a ``lax.while_loop``. The intercept is not penalised, and each target column is an independent problem. Parameters ---------- X : array_like or brainunit.Quantity Design matrix, shape ``(n_samples, n_features)``. Y : array_like or brainunit.Quantity Targets, shape ``(n_samples,)`` or ``(n_samples, n_targets)``. alpha : float or brainunit.Quantity, default 1.0 Non-negative L1 penalty. When ``X`` or ``Y`` carry units, ``alpha`` must be a Quantity in the unit of ``X`` times the unit of ``Y``. fit_intercept : bool, default True Fit an unpenalised intercept by centring ``X`` and ``Y`` first. max_iter : int, default 10000 Maximum number of FISTA iterations (static). tol : float, optional Relative tolerance on the first-order optimality residual (the stopping rule is described in :func:`elastic_net_regression`). Default ``1e-8`` for float64 data and ``1e-7`` (close to the rounding floor) for float32 data. ``tol=0`` runs all ``max_iter`` iterations unless an exact minimiser is reached. Returns ------- RegressionResult ``coef`` of shape ``(n_features,)`` or ``(n_features, n_targets)`` and ``intercept``. Raises ------ ValueError If the shapes are invalid, a concrete ``alpha`` is negative, or ``max_iter``/``tol`` are invalid. brainunit.UnitMismatchError If ``alpha`` does not have the unit of ``X`` times the unit of ``Y``. See Also -------- elastic_net_regression : mixed L1 and L2 penalties. ridge_regression : closed-form L2 penalty. Notes ----- Pure; supports ``jax.jit`` and ``jax.vmap``. The iteration count is data dependent, so reverse-mode differentiation (``jax.grad``) is not supported. For large ``alpha`` (above ``max|X_c^T Y_c| / n``) every coefficient is exactly zero. The loop stops when, for every target column, the largest entry of the proximal gradient mapping :math:`L (Z_k - W_{k+1})` (a first-order optimality residual, zero exactly at the minimiser) is at most ``tol * max|X_c^T Y_c| / n``; see :func:`elastic_net_regression`. A negative ``alpha`` that is only known at run time (traced under ``jax.jit`` or ``jax.vmap``) cannot raise, so it returns NaN coefficients and intercept. References ---------- .. [1] A. Beck and M. Teboulle, "A fast iterative shrinkage-thresholding algorithm for linear inverse problems," SIAM J. Imaging Sci. 2(1), 183-202, 2009. .. [2] B. O'Donoghue and E. Candes, "Adaptive restart for accelerated gradient schemes," Found. Comput. Math. 15, 715-732, 2015. Examples -------- .. code-block:: python >>> import jax >>> import jax.numpy as jnp >>> import braintools >>> X = jax.random.normal(jax.random.PRNGKey(0), (100, 5)) >>> Y = X @ jnp.array([3., 0., 0., -2., 0.]) + 1. >>> res = braintools.optim.lasso_regression(X, Y, alpha=0.1) >>> print(jnp.round(res.coef, 2), round(float(res.intercept), 2)) [ 2.86 -0. 0. -1.9 0. ] 0.98 """ _check_iter(max_iter, tol) Xc, Yc, xm, ym, squeeze, xu, yu = _prepare(X, Y, fit_intercept) alpha = _penalty(alpha, xu * yu, 'alpha') coef = _fista(Xc, Yc, alpha, 0., max_iter, tol) return _finish(coef, xm, ym, fit_intercept, squeeze, xu, yu)