From 39b8648e0ceba751e01b4d8d0b1f256627bee1b1 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Fri, 21 Aug 2026 22:21:42 +0200 Subject: [PATCH 01/12] centering trick & fast mean --- sklearn/linear_model/_base.py | 35 ++++++++++++++++++++++++----- sklearn/linear_model/_ridge.py | 40 ++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/sklearn/linear_model/_base.py b/sklearn/linear_model/_base.py index 3acb42f35d894..66f2e7e7bd10a 100644 --- a/sklearn/linear_model/_base.py +++ b/sklearn/linear_model/_base.py @@ -26,6 +26,7 @@ _asarray_with_order, _average, _expit, + _is_numpy_namespace, check_same_namespace, get_namespace, get_namespace_and_device, @@ -119,6 +120,8 @@ def _preprocess_data( sample_weight=None, check_input=True, rescale_with_sw=True, + center_X=True, + fast_mean_X=False, ): """Common data preprocessing for fitting linear models. @@ -132,9 +135,9 @@ def _preprocess_data( Then, if `fit_intercept=True` this preprocessing centers both `X` and `y` as follows: - - if `X` is dense, center the data and + - if `X` is dense and `center_X=True`, center the data and store the mean vector in `X_offset`. - - if `X` is sparse, store the mean in `X_offset` + - if `X` is sparse, or `center_X=False`, store the mean in `X_offset` without centering `X`. The centering is expected to be handled by the linear solver where appropriate. - in either case, always center `y` and store the mean in `y_offset`. @@ -147,12 +150,27 @@ def _preprocess_data( If `rescale_with_sw` is True, then X and y are rescaled with the square root of sample weights. + `center_X=False` lets a caller with a dense `X` skip the `O(n_samples * + n_features)` centering pass (e.g. because its solver can apply the + centering algebraically from `X_offset` instead of on a materialized + centered copy of `X`). It has no effect on sparse `X`, which is never + centered in place regardless. + + `fast_mean_X=True` computes `X_offset` (for dense, unweighted, numpy `X`) + as a single BLAS gemv (`(1 / n_samples) @ X`) instead of `X.mean(axis=0)`. + This is faster but sums in a different order, so it is opt-in and meant + only for callers whose downstream numerics are insensitive to the tiny + (~1e-16 relative) resulting difference in `X_offset` -- e.g. it is not + used by default because it can change tie-breaking in combinatorial + solvers such as LARS, or avoid an overflow-to-inf that some code + (knowingly or not) relies on for extreme-magnitude `X`. + Returns ------- X_out : {ndarray, sparse matrix} of shape (n_samples, n_features) If copy=True a copy of the input X is triggered, otherwise operations are inplace. - If input X is dense, then X_out is centered. + If input X is dense and `center_X=True`, then X_out is centered. y_out : {ndarray, sparse matrix} of shape (n_samples,) or (n_samples, n_targets) Centered copy of y. X_offset : ndarray of shape (n_features,) @@ -190,10 +208,15 @@ def _preprocess_data( if X_is_sparse: X_offset, X_var = mean_variance_axis(X, axis=0, weights=sample_weight) else: - X_offset = _average(X, axis=0, weights=sample_weight, xp=xp) + if fast_mean_X and sample_weight is None and _is_numpy_namespace(xp): + ones_over_n = xp.full(n_samples, 1.0 / n_samples, dtype=X.dtype) + X_offset = ones_over_n @ X + else: + X_offset = _average(X, axis=0, weights=sample_weight, xp=xp) + X_offset = xp.astype(X_offset, X.dtype, copy=False) - X_offset = xp.astype(X_offset, X.dtype, copy=False) - X -= X_offset + if center_X: + X -= X_offset y_offset = _average(y, axis=0, weights=sample_weight, xp=xp) y -= y_offset diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 1eb149d2d4390..fceee3e53dce4 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -212,14 +212,24 @@ def _solve_lsqr( return coefs, n_iter -def _solve_cholesky(X, y, alpha): +def _solve_cholesky(X, y, alpha, X_offset=None): # w = inv(X^t X + alpha*Id) * X.T y + # + # If X_offset is given, X is assumed to *not* be centered (unlike y, which + # is always assumed centered) and the Gram matrix is corrected + # algebraically instead of materializing a centered copy of X: + # Xc.T @ Xc = X.T @ X - n_samples * outer(X_offset, X_offset) + # Xy needs no such correction: Xc.T @ yc = X.T @ yc - X_offset * yc.sum(0), + # and yc.sum(0) is 0 because yc is centered. n_features = X.shape[1] n_targets = y.shape[1] A = safe_sparse_dot(X.T, X, dense_output=True) Xy = safe_sparse_dot(X.T, y, dense_output=True) + if X_offset is not None: + A -= X.shape[0] * np.outer(X_offset, X_offset) + one_alpha = np.array_equal(alpha, len(alpha) * [alpha[0]]) if one_alpha: @@ -755,8 +765,14 @@ def _ridge_regression( solver = "svd" else: try: - coef = _solve_cholesky(X, y, alpha) + coef = _solve_cholesky(X, y, alpha, X_offset=X_offset) except linalg.LinAlgError: + if X_offset is not None: + # X was left uncentered to avoid materializing a copy on + # the (common) successful path; the svd fallback below + # needs centered X, so pay for that copy now, on this + # rare failure path. + X = X - X_offset # use SVD solver if matrix is singular solver = "svd" @@ -960,6 +976,20 @@ def fit(self, X, y, sample_weight=None): if sample_weight is not None: sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) + # Dense X with n_features <= n_samples, no sample weights, and a solver + # that resolves to "cholesky" hits _solve_cholesky's primal branch, + # which can apply centering algebraically from X_offset instead of on + # a materialized centered copy of X. This avoids an O(n_samples * + # n_features) pass over X for what is the most common Ridge shape. + use_no_center_cholesky = ( + self.fit_intercept + and not sparse.issparse(X) + and sample_weight is None + and not self.positive + and solver in ("auto", "cholesky") + and X.shape[0] >= X.shape[1] + ) + # when X is sparse we only remove offset from y X, y, X_offset, y_offset, X_scale, _ = _preprocess_data( X, @@ -968,6 +998,8 @@ def fit(self, X, y, sample_weight=None): copy=self.copy_X, sample_weight=sample_weight, rescale_with_sw=False, + center_X=not use_no_center_cholesky, + fast_mean_X=use_no_center_cholesky, ) if solver == "sag" and sparse.issparse(X) and self.fit_intercept: @@ -993,6 +1025,10 @@ def fit(self, X, y, sample_weight=None): if sparse.issparse(X) and self.fit_intercept: # required to fit intercept with sparse_cg and lbfgs solver params = {"X_offset": X_offset, "X_scale": X_scale} + elif use_no_center_cholesky: + # X was left uncentered; _solve_cholesky applies the + # correction algebraically from X_offset. + params = {"X_offset": X_offset} else: # for dense matrices or when intercept is set to 0 params = {} From ffc4331fa268336322048189d36382d4510a7fde Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Fri, 21 Aug 2026 22:32:34 +0200 Subject: [PATCH 02/12] fast assert finite --- sklearn/utils/validation.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/sklearn/utils/validation.py b/sklearn/utils/validation.py index f05aee4de107d..93383d5f3ef42 100644 --- a/sklearn/utils/validation.py +++ b/sklearn/utils/validation.py @@ -130,7 +130,24 @@ def _assert_all_finite( # Cython implementation to prevent false positives and provide a detailed # error message. with np.errstate(over="ignore"): - first_pass_isfinite = xp.isfinite(xp.sum(X)) + if ( + _is_numpy_namespace(xp) + and X.ndim == 2 + and X.dtype.kind == "f" + and (X.flags["C_CONTIGUOUS"] or X.flags["F_CONTIGUOUS"]) + ): + # A full xp.sum(X) reduction is a generic, direction-agnostic sum. + # A BLAS gemv against a ones vector is faster, but only when its + # direction matches the array's memory layout (row sums for + # C-contiguous, column sums for F-contiguous) -- the "wrong" + # direction can be slower than the plain sum it replaces. + if X.flags["C_CONTIGUOUS"]: + partial_sums = X @ np.ones(X.shape[1], dtype=X.dtype) + else: + partial_sums = np.ones(X.shape[0], dtype=X.dtype) @ X + first_pass_isfinite = xp.isfinite(np.sum(partial_sums)) + else: + first_pass_isfinite = xp.isfinite(xp.sum(X)) if first_pass_isfinite: return From 0714fb9afbf8cdec3355d5c2e14c0cc082e681c7 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Fri, 21 Aug 2026 22:52:27 +0200 Subject: [PATCH 03/12] FIX Ridge: don't skip centering when array API dispatch forces solver to svd use_no_center_cholesky matched on solver in ("auto", "cholesky") but didn't check the array namespace. With array API dispatch to a non-numpy namespace, solver="auto" silently resolves to "svd" instead of "cholesky" (see resolve_solver), which needs X to actually be centered. This left X uncentered while running svd, causing test_cross_val_predict_array_api_compliance[...-Ridge] failures on array_api_strict and torch in CI. --- sklearn/linear_model/_ridge.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index fceee3e53dce4..da67f566bb953 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -981,12 +981,17 @@ def fit(self, X, y, sample_weight=None): # which can apply centering algebraically from X_offset instead of on # a materialized centered copy of X. This avoids an O(n_samples * # n_features) pass over X for what is the most common Ridge shape. + # Array API dispatch to a non-numpy namespace always resolves "auto" + # (and forbids explicit "cholesky") to "svd" instead (see + # resolve_solver), which needs X to actually be centered, so this + # fast path must not engage there. use_no_center_cholesky = ( self.fit_intercept and not sparse.issparse(X) and sample_weight is None and not self.positive and solver in ("auto", "cholesky") + and _is_numpy_namespace(xp) and X.shape[0] >= X.shape[1] ) From fe354e2c96266f3323248ee404c9670869c794b8 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Mon, 24 Aug 2026 09:51:22 +0200 Subject: [PATCH 04/12] iter: quite ugly... --- sklearn/linear_model/_ridge.py | 44 ++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index da67f566bb953..1fbd1dc934f15 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -48,6 +48,7 @@ get_namespace, get_namespace_and_device, move_to, + supported_float_dtypes, ) from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.extmath import row_norms, safe_sparse_dot @@ -976,6 +977,8 @@ def fit(self, X, y, sample_weight=None): if sample_weight is not None: sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) + X_is_sparse = sparse.issparse(X) + # Dense X with n_features <= n_samples, no sample weights, and a solver # that resolves to "cholesky" hits _solve_cholesky's primal branch, # which can apply centering algebraically from X_offset instead of on @@ -987,7 +990,7 @@ def fit(self, X, y, sample_weight=None): # fast path must not engage there. use_no_center_cholesky = ( self.fit_intercept - and not sparse.issparse(X) + and not X_is_sparse and sample_weight is None and not self.positive and solver in ("auto", "cholesky") @@ -995,12 +998,49 @@ def fit(self, X, y, sample_weight=None): and X.shape[0] >= X.shape[1] ) + # `Ridge.fit`'s own `validate_data` call already dtype-checked (to + # one of `supported_float_dtypes`), shape-checked and finite-checked + # X and y, so _preprocess_data's own check_array pass over them is + # redundant when X is already one of those dtypes: no dtype, shape or + # finiteness fact can have changed in between, and dense arrays have + # no accept_sparse-driven format conversion to perform. This can't be + # assumed unconditionally though: + # - RidgeClassifier reaches here through `_prepare_data`, whose own + # `validate_data` call does *not* request a float dtype, so X can + # still be e.g. int64 -- in which case check_array's dtype + # coercion is the only place that ever converts it, and skipping + # it left `y -= y_offset` trying to write float into an int64 y. + # - For sparse X, check_input=True also converts non-csr/csc formats + # such as coo (which some solvers' accept_sparse legitimately + # allows through unconverted) into a format `mean_variance_axis` + # requires -- skipping that breaks e.g. + # `Ridge(solver="lsqr").fit(X_coo, y)` with `fit_intercept=True`. + # Scoped to the numpy namespace like the rest of this fast path: + # the dtype/shape/finite guarantee from `validate_data` should hold + # equally for other array-API namespaces, but that isn't verified + # here, and this optimization only targets the CPU/numpy case. + check_input = ( + X_is_sparse + or not _is_numpy_namespace(xp) + or X.dtype not in supported_float_dtypes(xp) + ) + # when X is sparse we only remove offset from y X, y, X_offset, y_offset, X_scale, _ = _preprocess_data( X, y, fit_intercept=self.fit_intercept, - copy=self.copy_X, + # On the no-center fast path X is never mutated (no `X -= + # X_offset`, and `_solve_cholesky` only reads from X), so the + # defensive copy that `copy_X` exists for is unneeded there, + # regardless of what the user passed: skip it. Elsewhere, X *is* + # mutated in place below (`X -= X_offset`) and `validate_data` + # commonly returns the caller's own array unchanged (it only + # copies when actually needed, e.g. to fix up writeability), so + # `copy_X` must still be honored there to avoid silently + # mutating the caller's array when they asked not to. + copy=False if use_no_center_cholesky else self.copy_X, + check_input=check_input, sample_weight=sample_weight, rescale_with_sw=False, center_X=not use_no_center_cholesky, From 88b0aa8d2bdc74132849e93f59ff0b14accaa6bb Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Mon, 24 Aug 2026 10:02:45 +0200 Subject: [PATCH 05/12] prettier --- sklearn/linear_model/_ridge.py | 55 ++++++++++++++-------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 1fbd1dc934f15..24452e458d0d6 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -48,7 +48,6 @@ get_namespace, get_namespace_and_device, move_to, - supported_float_dtypes, ) from sklearn.utils._param_validation import Interval, StrOptions, validate_params from sklearn.utils.extmath import row_norms, safe_sparse_dot @@ -392,9 +391,15 @@ def func(w): def _get_valid_accept_sparse(is_X_sparse, solver): if is_X_sparse and solver in ["auto", "sag", "saga"]: + # sag/saga's Cython solver needs actual CSR structure to run. return "csr" else: - return ["csr", "csc", "coo"] + # Every other sparse-capable code path (_solve_sparse_cg, _solve_lsqr, + # _preprocess_data's mean_variance_axis, ...) only ever goes through + # generic scipy sparse ops or explicitly requires csr/csc, so there is + # no need to accept (and thus convert downstream) other formats such + # as coo here. + return ["csr", "csc"] @validate_params( @@ -998,34 +1003,13 @@ def fit(self, X, y, sample_weight=None): and X.shape[0] >= X.shape[1] ) - # `Ridge.fit`'s own `validate_data` call already dtype-checked (to - # one of `supported_float_dtypes`), shape-checked and finite-checked - # X and y, so _preprocess_data's own check_array pass over them is - # redundant when X is already one of those dtypes: no dtype, shape or - # finiteness fact can have changed in between, and dense arrays have - # no accept_sparse-driven format conversion to perform. This can't be - # assumed unconditionally though: - # - RidgeClassifier reaches here through `_prepare_data`, whose own - # `validate_data` call does *not* request a float dtype, so X can - # still be e.g. int64 -- in which case check_array's dtype - # coercion is the only place that ever converts it, and skipping - # it left `y -= y_offset` trying to write float into an int64 y. - # - For sparse X, check_input=True also converts non-csr/csc formats - # such as coo (which some solvers' accept_sparse legitimately - # allows through unconverted) into a format `mean_variance_axis` - # requires -- skipping that breaks e.g. - # `Ridge(solver="lsqr").fit(X_coo, y)` with `fit_intercept=True`. - # Scoped to the numpy namespace like the rest of this fast path: - # the dtype/shape/finite guarantee from `validate_data` should hold - # equally for other array-API namespaces, but that isn't verified - # here, and this optimization only targets the CPU/numpy case. - check_input = ( - X_is_sparse - or not _is_numpy_namespace(xp) - or X.dtype not in supported_float_dtypes(xp) - ) - - # when X is sparse we only remove offset from y + # Both `Ridge.fit` and `RidgeClassifier._prepare_data` already run + # `validate_data` with a matching `dtype`/`accept_sparse` contract + # (the latter via `_get_valid_accept_sparse`, same as above), so by + # the time X and y reach here they're already a supported float + # dtype and, if sparse, already csr/csc -- check_input=False below + # trusts that instead of having _preprocess_data redo it. + # (when X is sparse we only remove the offset from y) X, y, X_offset, y_offset, X_scale, _ = _preprocess_data( X, y, @@ -1040,14 +1024,14 @@ def fit(self, X, y, sample_weight=None): # `copy_X` must still be honored there to avoid silently # mutating the caller's array when they asked not to. copy=False if use_no_center_cholesky else self.copy_X, - check_input=check_input, + check_input=False, sample_weight=sample_weight, rescale_with_sw=False, center_X=not use_no_center_cholesky, fast_mean_X=use_no_center_cholesky, ) - if solver == "sag" and sparse.issparse(X) and self.fit_intercept: + if solver == "sag" and X_is_sparse and self.fit_intercept: self.coef_, self.n_iter_, self.intercept_, self.solver_ = _ridge_regression( X, y, @@ -1067,7 +1051,7 @@ def fit(self, X, y, sample_weight=None): self.intercept_ += y_offset else: - if sparse.issparse(X) and self.fit_intercept: + if X_is_sparse and self.fit_intercept: # required to fit intercept with sparse_cg and lbfgs solver params = {"X_offset": X_offset, "X_scale": X_scale} elif use_no_center_cholesky: @@ -1409,6 +1393,11 @@ def _prepare_data(self, X, y, sample_weight, solver): X, y, accept_sparse=accept_sparse, + # X (not y: dtype here only ever applies to X, never to the + # class labels) needs to already be a supported float dtype by + # the time it reaches `_BaseRidge.fit`, which trusts this + # validation and skips its own for dense X. + dtype=[xp.float64, xp.float32], multi_output=True, y_numeric=False, force_writeable=True, From c7dc619ed0c5ea563774dad2ca638ed4caf3fe89 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Mon, 24 Aug 2026 10:26:01 +0200 Subject: [PATCH 06/12] iter --- sklearn/linear_model/_ridge.py | 38 ++++++++++++---------------------- 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 24452e458d0d6..e03319779e0c3 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -389,17 +389,16 @@ def func(w): return coefs -def _get_valid_accept_sparse(is_X_sparse, solver): +def _get_valid_accept_sparse(is_X_sparse, solver, fit_intercept=False): if is_X_sparse and solver in ["auto", "sag", "saga"]: # sag/saga's Cython solver needs actual CSR structure to run. return "csr" - else: - # Every other sparse-capable code path (_solve_sparse_cg, _solve_lsqr, - # _preprocess_data's mean_variance_axis, ...) only ever goes through - # generic scipy sparse ops or explicitly requires csr/csc, so there is - # no need to accept (and thus convert downstream) other formats such - # as coo here. + elif is_X_sparse and fit_intercept: + # when `fit_intercept=True`, `mean_variance_axis` will be called on X + # and it requires csr/csc format return ["csr", "csc"] + else: + return ["csr", "csc", "coo"] @validate_params( @@ -1003,26 +1002,11 @@ def fit(self, X, y, sample_weight=None): and X.shape[0] >= X.shape[1] ) - # Both `Ridge.fit` and `RidgeClassifier._prepare_data` already run - # `validate_data` with a matching `dtype`/`accept_sparse` contract - # (the latter via `_get_valid_accept_sparse`, same as above), so by - # the time X and y reach here they're already a supported float - # dtype and, if sparse, already csr/csc -- check_input=False below - # trusts that instead of having _preprocess_data redo it. - # (when X is sparse we only remove the offset from y) X, y, X_offset, y_offset, X_scale, _ = _preprocess_data( X, y, fit_intercept=self.fit_intercept, - # On the no-center fast path X is never mutated (no `X -= - # X_offset`, and `_solve_cholesky` only reads from X), so the - # defensive copy that `copy_X` exists for is unneeded there, - # regardless of what the user passed: skip it. Elsewhere, X *is* - # mutated in place below (`X -= X_offset`) and `validate_data` - # commonly returns the caller's own array unchanged (it only - # copies when actually needed, e.g. to fix up writeability), so - # `copy_X` must still be honored there to avoid silently - # mutating the caller's array when they asked not to. + # When `use_no_center_cholesky=True`, X is never mutated: copy=False if use_no_center_cholesky else self.copy_X, check_input=False, sample_weight=sample_weight, @@ -1311,7 +1295,9 @@ def fit(self, X, y, sample_weight=None): self : object Fitted estimator. """ - _accept_sparse = _get_valid_accept_sparse(sparse.issparse(X), self.solver) + _accept_sparse = _get_valid_accept_sparse( + sparse.issparse(X), self.solver, self.fit_intercept + ) xp, _, device = get_namespace_and_device(X) y, sample_weight = move_to(y, sample_weight, xp=xp, device=device) @@ -1385,7 +1371,9 @@ def _prepare_data(self, X, y, sample_weight, solver): Y : ndarray of shape (n_samples, n_classes) The binarized version of `y`. """ - accept_sparse = _get_valid_accept_sparse(sparse.issparse(X), solver) + accept_sparse = _get_valid_accept_sparse( + sparse.issparse(X), solver, self.fit_intercept + ) xp, _, device = get_namespace_and_device(X) sample_weight = move_to(sample_weight, xp=xp, device=device) X, y = validate_data( From 2fcb2fb17759becebe68f779e735a1400b8baaf5 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Mon, 24 Aug 2026 11:13:04 +0200 Subject: [PATCH 07/12] remove low-impact optims --- sklearn/linear_model/_base.py | 19 ++----------------- sklearn/linear_model/_ridge.py | 1 - sklearn/utils/validation.py | 19 +------------------ 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/sklearn/linear_model/_base.py b/sklearn/linear_model/_base.py index 66f2e7e7bd10a..648d567075729 100644 --- a/sklearn/linear_model/_base.py +++ b/sklearn/linear_model/_base.py @@ -26,7 +26,6 @@ _asarray_with_order, _average, _expit, - _is_numpy_namespace, check_same_namespace, get_namespace, get_namespace_and_device, @@ -121,7 +120,6 @@ def _preprocess_data( check_input=True, rescale_with_sw=True, center_X=True, - fast_mean_X=False, ): """Common data preprocessing for fitting linear models. @@ -156,15 +154,6 @@ def _preprocess_data( centered copy of `X`). It has no effect on sparse `X`, which is never centered in place regardless. - `fast_mean_X=True` computes `X_offset` (for dense, unweighted, numpy `X`) - as a single BLAS gemv (`(1 / n_samples) @ X`) instead of `X.mean(axis=0)`. - This is faster but sums in a different order, so it is opt-in and meant - only for callers whose downstream numerics are insensitive to the tiny - (~1e-16 relative) resulting difference in `X_offset` -- e.g. it is not - used by default because it can change tie-breaking in combinatorial - solvers such as LARS, or avoid an overflow-to-inf that some code - (knowingly or not) relies on for extreme-magnitude `X`. - Returns ------- X_out : {ndarray, sparse matrix} of shape (n_samples, n_features) @@ -208,12 +197,8 @@ def _preprocess_data( if X_is_sparse: X_offset, X_var = mean_variance_axis(X, axis=0, weights=sample_weight) else: - if fast_mean_X and sample_weight is None and _is_numpy_namespace(xp): - ones_over_n = xp.full(n_samples, 1.0 / n_samples, dtype=X.dtype) - X_offset = ones_over_n @ X - else: - X_offset = _average(X, axis=0, weights=sample_weight, xp=xp) - X_offset = xp.astype(X_offset, X.dtype, copy=False) + X_offset = _average(X, axis=0, weights=sample_weight, xp=xp) + X_offset = xp.astype(X_offset, X.dtype, copy=False) if center_X: X -= X_offset diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index e03319779e0c3..8955255db939f 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -1012,7 +1012,6 @@ def fit(self, X, y, sample_weight=None): sample_weight=sample_weight, rescale_with_sw=False, center_X=not use_no_center_cholesky, - fast_mean_X=use_no_center_cholesky, ) if solver == "sag" and X_is_sparse and self.fit_intercept: diff --git a/sklearn/utils/validation.py b/sklearn/utils/validation.py index 93383d5f3ef42..f05aee4de107d 100644 --- a/sklearn/utils/validation.py +++ b/sklearn/utils/validation.py @@ -130,24 +130,7 @@ def _assert_all_finite( # Cython implementation to prevent false positives and provide a detailed # error message. with np.errstate(over="ignore"): - if ( - _is_numpy_namespace(xp) - and X.ndim == 2 - and X.dtype.kind == "f" - and (X.flags["C_CONTIGUOUS"] or X.flags["F_CONTIGUOUS"]) - ): - # A full xp.sum(X) reduction is a generic, direction-agnostic sum. - # A BLAS gemv against a ones vector is faster, but only when its - # direction matches the array's memory layout (row sums for - # C-contiguous, column sums for F-contiguous) -- the "wrong" - # direction can be slower than the plain sum it replaces. - if X.flags["C_CONTIGUOUS"]: - partial_sums = X @ np.ones(X.shape[1], dtype=X.dtype) - else: - partial_sums = np.ones(X.shape[0], dtype=X.dtype) @ X - first_pass_isfinite = xp.isfinite(np.sum(partial_sums)) - else: - first_pass_isfinite = xp.isfinite(xp.sum(X)) + first_pass_isfinite = xp.isfinite(xp.sum(X)) if first_pass_isfinite: return From dbc32bb9ab7a3334bf33e138b3036f04fcc206e2 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Tue, 25 Aug 2026 18:31:49 +0200 Subject: [PATCH 08/12] iter comments --- sklearn/linear_model/_base.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sklearn/linear_model/_base.py b/sklearn/linear_model/_base.py index 648d567075729..22ae8b8bde8f8 100644 --- a/sklearn/linear_model/_base.py +++ b/sklearn/linear_model/_base.py @@ -149,9 +149,7 @@ def _preprocess_data( sample weights. `center_X=False` lets a caller with a dense `X` skip the `O(n_samples * - n_features)` centering pass (e.g. because its solver can apply the - centering algebraically from `X_offset` instead of on a materialized - centered copy of `X`). It has no effect on sparse `X`, which is never + n_features)` centering pass. It has no effect on sparse `X`, which is never centered in place regardless. Returns From 2053577a29717d900d1baa7c187a4a3a9fc08124 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Tue, 25 Aug 2026 18:53:43 +0200 Subject: [PATCH 09/12] cleanup --- sklearn/linear_model/_ridge.py | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 8ec61899b1db1..a798f69023858 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -639,7 +639,6 @@ def _ridge_regression( random_state=None, return_n_iter=False, return_intercept=False, - return_solver=False, X_scale=None, X_offset=None, check_input=True, @@ -850,7 +849,7 @@ def _ridge_regression( else: res = coef - return (*res, solver) if return_solver else res + return res def resolve_solver(solver, positive, return_intercept, is_sparse, xp): @@ -982,23 +981,17 @@ def fit(self, X, y, sample_weight=None): sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) X_is_sparse = sparse.issparse(X) + self.solver_ = resolve_solver( + solver, self.positive, return_intercept=False, is_sparse=X_is_sparse, xp=xp + ) - # Dense X with n_features <= n_samples, no sample weights, and a solver - # that resolves to "cholesky" hits _solve_cholesky's primal branch, - # which can apply centering algebraically from X_offset instead of on - # a materialized centered copy of X. This avoids an O(n_samples * - # n_features) pass over X for what is the most common Ridge shape. - # Array API dispatch to a non-numpy namespace always resolves "auto" - # (and forbids explicit "cholesky") to "svd" instead (see - # resolve_solver), which needs X to actually be centered, so this - # fast path must not engage there. + # X with n_features <= n_samples, no sample weights, and a + # resolved "cholesky" solver hits _solve_cholesky's primal branch, + # which can apply an algebraically-centering optimization. use_no_center_cholesky = ( self.fit_intercept - and not X_is_sparse and sample_weight is None - and not self.positive - and solver in ("auto", "cholesky") - and _is_numpy_namespace(xp) + and self.solver_ == "cholesky" and X.shape[0] >= X.shape[1] ) @@ -1015,7 +1008,7 @@ def fit(self, X, y, sample_weight=None): ) if solver == "sag" and X_is_sparse and self.fit_intercept: - self.coef_, self.n_iter_, self.intercept_, self.solver_ = _ridge_regression( + self.coef_, self.n_iter_, self.intercept_ = _ridge_regression( X, y, alpha=self.alpha, @@ -1027,7 +1020,6 @@ def fit(self, X, y, sample_weight=None): random_state=self.random_state, return_n_iter=True, return_intercept=True, - return_solver=True, check_input=False, ) # add the offset which was subtracted by _preprocess_data @@ -1045,19 +1037,18 @@ def fit(self, X, y, sample_weight=None): # for dense matrices or when intercept is set to 0 params = {} - self.coef_, self.n_iter_, self.solver_ = _ridge_regression( + self.coef_, self.n_iter_ = _ridge_regression( X, y, alpha=self.alpha, sample_weight=sample_weight, max_iter=self.max_iter, tol=self.tol, - solver=solver, + solver=self.solver_, positive=self.positive, random_state=self.random_state, return_n_iter=True, return_intercept=False, - return_solver=True, check_input=False, fit_intercept=self.fit_intercept, **params, From 8b6462735b920dd9aa1800c65cba66f83a841175 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Wed, 26 Aug 2026 18:54:22 +0200 Subject: [PATCH 10/12] more cleanup --- sklearn/linear_model/_ridge.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index a798f69023858..142e6092a2233 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -773,9 +773,7 @@ def _ridge_regression( except linalg.LinAlgError: if X_offset is not None: # X was left uncentered to avoid materializing a copy on - # the (common) successful path; the svd fallback below - # needs centered X, so pay for that copy now, on this - # rare failure path. + # the svd fallback needs centered X: X = X - X_offset # use SVD solver if matrix is singular solver = "svd" @@ -999,8 +997,13 @@ def fit(self, X, y, sample_weight=None): X, y, fit_intercept=self.fit_intercept, - # When `use_no_center_cholesky=True`, X is never mutated: - copy=False if use_no_center_cholesky else self.copy_X, + copy=( + self.copy_X + # If `use_no_center_cholesky` or X is sparse, + # X is never mutated + and not use_no_center_cholesky + and not X_is_sparse + ), check_input=False, sample_weight=sample_weight, rescale_with_sw=False, From 29a3ee3a777583db4e1b09d4ad0103ff630a917c Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Mon, 7 Sep 2026 16:15:36 +0200 Subject: [PATCH 11/12] inline comment --- sklearn/linear_model/_ridge.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 142e6092a2233..55899a93fad8a 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -1004,6 +1004,8 @@ def fit(self, X, y, sample_weight=None): and not use_no_center_cholesky and not X_is_sparse ), + # X and y were already validated by `validate_data` in the + # public `fit` method of the subclass (e.g. `Ridge.fit`). check_input=False, sample_weight=sample_weight, rescale_with_sw=False, From 33d02cb6d4ba2f326707b744250498b9bb653623 Mon Sep 17 00:00:00 2001 From: Arthur Lacote Date: Wed, 9 Sep 2026 08:54:36 +0200 Subject: [PATCH 12/12] revert return_solver --- sklearn/linear_model/_ridge.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/sklearn/linear_model/_ridge.py b/sklearn/linear_model/_ridge.py index 55899a93fad8a..3b06b17d73a22 100644 --- a/sklearn/linear_model/_ridge.py +++ b/sklearn/linear_model/_ridge.py @@ -639,6 +639,7 @@ def _ridge_regression( random_state=None, return_n_iter=False, return_intercept=False, + return_solver=False, X_scale=None, X_offset=None, check_input=True, @@ -847,7 +848,7 @@ def _ridge_regression( else: res = coef - return res + return (*res, solver) if return_solver else res def resolve_solver(solver, positive, return_intercept, is_sparse, xp): @@ -979,7 +980,7 @@ def fit(self, X, y, sample_weight=None): sample_weight = _check_sample_weight(sample_weight, X, dtype=X.dtype) X_is_sparse = sparse.issparse(X) - self.solver_ = resolve_solver( + solver = resolve_solver( solver, self.positive, return_intercept=False, is_sparse=X_is_sparse, xp=xp ) @@ -989,7 +990,7 @@ def fit(self, X, y, sample_weight=None): use_no_center_cholesky = ( self.fit_intercept and sample_weight is None - and self.solver_ == "cholesky" + and solver == "cholesky" and X.shape[0] >= X.shape[1] ) @@ -1013,7 +1014,7 @@ def fit(self, X, y, sample_weight=None): ) if solver == "sag" and X_is_sparse and self.fit_intercept: - self.coef_, self.n_iter_, self.intercept_ = _ridge_regression( + self.coef_, self.n_iter_, self.intercept_, self.solver_ = _ridge_regression( X, y, alpha=self.alpha, @@ -1025,6 +1026,7 @@ def fit(self, X, y, sample_weight=None): random_state=self.random_state, return_n_iter=True, return_intercept=True, + return_solver=True, check_input=False, ) # add the offset which was subtracted by _preprocess_data @@ -1042,18 +1044,19 @@ def fit(self, X, y, sample_weight=None): # for dense matrices or when intercept is set to 0 params = {} - self.coef_, self.n_iter_ = _ridge_regression( + self.coef_, self.n_iter_, self.solver_ = _ridge_regression( X, y, alpha=self.alpha, sample_weight=sample_weight, max_iter=self.max_iter, tol=self.tol, - solver=self.solver_, + solver=solver, positive=self.positive, random_state=self.random_state, return_n_iter=True, return_intercept=False, + return_solver=True, check_input=False, fit_intercept=self.fit_intercept, **params,