Skip to main content

ferrolearn_linear/
ridge.rs

1//! Ridge regression (L2-regularized linear regression).
2//!
3//! This module provides [`Ridge`], which fits a linear model with L2
4//! regularization using the closed-form solution:
5//!
6//! ```text
7//! w = (X^T X + alpha * I)^{-1} X^T y
8//! ```
9//!
10//! The regularization parameter `alpha` controls the strength of the
11//! L2 penalty, shrinking coefficients toward zero.
12//!
13//! ## REQ status (per `.design/linear/ridge.md`, mirrors `sklearn/linear_model/_ridge.py` @ 1.5.2)
14//!
15//! Mirrors `sklearn.linear_model.Ridge` (`_ridge.py:1016`), default dense path
16//! `solver='auto'`→`'cholesky'` with `fit_intercept` via centering (intercept unpenalized).
17//! coef_/intercept_ match the live sklearn oracle to 1e-8 across alpha∈{0.1,1,10,100}.
18//!
19//! | REQ | Status | Evidence |
20//! |---|---|---|
21//! | REQ-1 (L2 cholesky fit, intercept unpenalized) | SHIPPED | `Fit for Ridge` (centering + `linalg::solve_ridge`). Consumer: `RsRidge` in `ferrolearn-python/src/regressors.rs`. |
22//! | REQ-2 (predict = X·coef + intercept) | SHIPPED | `Predict for FittedRidge`. |
23//! | REQ-3 (fit_intercept incl. false) | SHIPPED | `with_fit_intercept`. |
24//! | REQ-4 (HasCoefficients introspection) | SHIPPED | `HasCoefficients for FittedRidge`. |
25//! | REQ-5 (alpha≥0 validation; alpha=0 → OLS incl. rank-deficient min-norm) | SHIPPED | negative-alpha → `InvalidParameter`; alpha=0 singular falls back `solve_ridge` → `solve_lstsq` (ferray min-norm), mirroring sklearn cholesky→SVD (`_ridge.py:752-756`). Closed #392; test `divergence_ridge_alpha_zero_rank_deficient_min_norm`. |
26//! | REQ-6 (multi-output 2-D Y → 2-D coef_) | SHIPPED | `FittedRidgeMulti<F>` + `Fit<Array2<F>, Array2<F>> for Ridge` (per-target `solve_ridge`, `coefficients()` `(n_features, n_targets)` / `intercepts()` `(n_targets,)`), mirroring sklearn `Ridge` 2-D `coef_` `(n_targets, n_features)` / `intercept_` `(n_targets,)` (`_ridge.py:543`/`:550`). Non-test consumer (R-HONEST-4 — prior NOT-STARTED note predates the multi-output Python binding #29): `RsRidgeMultiOutput in ferrolearn-python/src/regressors.rs` (field `Option<FittedRidgeMulti<f64>>`, transposes `coefficients()` to `(n_targets, n_features)`); `_regressors.py::Ridge.fit` routes the `y.ndim == 2 && y.shape[1] > 1` path to `_RsRidgeMultiOutput`. Verified: `tests/divergence_regressors.py::test_ridge_multioutput_matches_sklearn` (predict/coef ≤ 1e-8), `ferrolearn.Ridge(alpha=a).fit(X, Y_2d)` matches sklearn ≤ 1e-16. |
27//! | REQ-7 (per-target alpha array) | SHIPPED | `Ridge<F>` adds `pub alpha_per_target: Option<Array1<F>>` (default `None`) + `with_alpha_per_target(Array1<F>)` builder. On the multi-output `Fit<Array2, Array2>` path, when `Some(alphas)` it validates `alphas.len() == n_targets` (else `ShapeMismatch`) and each `alphas[k] >= 0` (else `InvalidParameter`), then solves each target column `k` independently with its own penalty via `linalg::solve_ridge` on the SAME centered (fit_intercept) / raw design the scalar path uses — mathematically identical to an independent scalar-`alpha` Ridge per column, mirroring sklearn's array-valued `alpha` (`_ridge.py:701-712`). `None` is byte-identical to the historic scalar `solve_ridge_multi` path. Oracle tests: `ridge_per_target_alpha_matches_sklearn` (alpha `[0.5, 2.0]`, coef col0 `[0.79891892, 1.43891892]`/col1 `[0.78, 0.355]`, intercepts `[-0.75351351, -0.065]`), `ridge_per_target_alpha_equals_independent_scalar_fits`, `ridge_per_target_alpha_length_mismatch_errors`, `ridge_multi_scalar_alpha_unchanged` (regression guard). Closes #385. |
28//! | REQ-8a (dense solver variants auto/cholesky/svd + solver_) | SHIPPED | `pub enum RidgeSolver { #[default] Auto, Cholesky, Svd }` + `Ridge<F>` gains `pub solver: RidgeSolver` (default `Auto`) + `with_solver`; `FittedRidge<F>` gains `solver_` + `pub fn solver()`. The single-output `fit_with_sample_weight` resolves `Auto`→`Cholesky` (`resolve_solver`, `_ridge.py:830`) and dispatches the unconstrained dense solve via `solve_unconstrained`: `Svd` → `linalg::solve_ridge_svd` (`coef = V·diag(sᵢ/(sᵢ²+alpha))·Uᵀy` from the thin SVD via `ferray::linalg::svd`, the analog of sklearn `_solve_svd` `_ridge.py:200-216`); `Cholesky`/`Auto` → the unchanged `linalg::solve_ridge` (byte-identical). All dense solvers yield the IDENTICAL unique solution (strictly convex). `solver_` stores the resolved value. Governs only the single-output unconstrained dense path. Consumer: `Fit<Array2, Array1>::fit for Ridge` (production: `RsRidge::fit` in `ferrolearn-python/src/regressors.rs`; `ridge_cv.rs`). Oracle tests `ridge_solver_svd_matches_sklearn_and_cholesky` (coef `[0.8228070175, 1.3561403509]`, intercept `-0.5768421053`), `ridge_solver_resolution`, `ridge_solver_default_cholesky_unchanged`, `ridge_solver_svd_no_intercept`. Closes #386 (8a). |
29//! | REQ-8b (iterative solver variants lsqr/sparse_cg/sag/saga/lbfgs) | NOT-STARTED | #386 — needs iterative/SGD substrate + RNG. Not represented as `RidgeSolver` variants until the substrate lands. |
30//! | REQ-9 (positive=True) | SHIPPED | `Ridge<F>` adds `pub positive: bool` (default `false`, `_ridge.py:902`/`:911`) + `with_positive(bool)` builder. When `self.positive`, `fit_with_sample_weight` routes the coefficient solve through `solve_nonneg_ridge`, which first computes the UNCONSTRAINED Cholesky optimum (`crate::linalg::solve_ridge`); if that solve succeeds AND all components are `≥ 0` the positivity constraint is INACTIVE and (by strict convexity + KKT) that feasible unconstrained minimizer IS the constrained minimizer, so it is returned EXACTLY as `(w_unc, 0)` (the exact closed-form Cholesky optimum, `n_iter_ = Some(0)`). Otherwise (solve errored, or any component `< 0` → constraint active) it falls through to projected coordinate descent minimizing `0.5·‖A·w−b‖² + 0.5·alpha·‖w‖²` s.t. `w ≥ 0` (`new = max(0, (A[:,j]ᵀr + col_sq[j]·old)/(col_sq[j] + alpha))`, incremental residual update, `max_iter=self.max_iter.unwrap_or(1000)`/`self.tol`). Either way it runs on the SAME centered (and `√w`-rescaled) design `solve_ridge` uses, then recovers `intercept = y_off − x_off·coef` (fit_intercept) / `0` identically. R-DEV-6: sklearn's default positive-Ridge L-BFGS-B (`_solve_lbfgs`, `_ridge.py:300`, objective `0.5·‖Xw−y‖²+0.5·alpha·‖w‖²`, bounds `[(0,inf)]`, dispatched at `_ridge.py:923-928`) runs at `tol=1e-4` and UNDER-CONVERGES on interior optima by ~1e-4; at `tol=1e-12` it reaches the true optimum = ferrolearn's exact Cholesky optimum (to ~1e-11). ferrolearn therefore ships the TRUE non-negative-ridge optimum, matching a CONVERGED sklearn exactly and being at least as precise as the default. `positive=false` (default) is byte-identical to the unconstrained Cholesky path. Oracle tests: `ridge_positive_matches_sklearn` (active constraint, alpha=1, fit_intercept coef `[1.19891304, 0.0]`, intercept `-6.17744565`, all ≥ 0, differs from unconstrained `[0.95708502, -1.85401484]`), `ridge_positive_false_unchanged` (byte-identical guard), `ridge_positive_all_nonneg_equals_unconstrained` (inactive-constraint sanity); divergence suite `divergence_ridge_positive_interior` (interior optimum == converged sklearn tol=1e-12 == exact unconstrained Cholesky). Closes #387, #2131. |
31//! | REQ-10 (max_iter/tol + n_iter_) | SHIPPED | `Ridge<F>` adds `pub max_iter: Option<usize>` (default `None`) and `pub tol: F` (default `1e-4`) with `with_max_iter`/`with_tol` builders. `FittedRidge<F>` adds `n_iter_: Option<usize>` (always `None` for the direct Cholesky solver) with `pub fn n_iter(&self) -> Option<usize>`. Mirrors sklearn ctor `max_iter=None, tol=1e-4` (`_ridge.py:899-900`) and `n_iter_` set at `_ridge.py:994`; `max_iter`/`tol` are no-ops for the direct solver (closed-form normal equations, no iteration) — matching sklearn's direct `cholesky`/`svd` paths which also yield `n_iter_=None`. Test: `ridge_max_iter_tol_niter_defaults_and_builders`. Closes #388. |
32//! | REQ-11 (sample_weight) | SHIPPED | `Ridge::fit_with_sample_weight(x, y, sample_weight: Option<&Array1<F>>)` solves WEIGHTED ridge `min Σᵢ wᵢ(yᵢ−xᵢ·coef)² + alpha·‖coef‖²`: weighted offsets `x_off[j]=Σwᵢx[i,j]/Σwᵢ`, `y_off=Σwᵢyᵢ/Σwᵢ` (fit_intercept), centering, then `√wᵢ` row-rescaling (`_rescale_data`, `_ridge.py:682-688`), `linalg::solve_ridge(&Xs, &ys, alpha)` with the penalty `alpha` UNSCALED (since `Xsᵀ·Xs == Xᵀ·W·X`), `intercept = y_off − x_off·coef`; `fit_intercept=false` skips centering (raw `√w`-rescale, intercept 0). `Fit::fit` delegates `fit_with_sample_weight(x, y, None)` (None byte-identical to the historic centering + `solve_ridge` body; alpha=0 OLS min-norm fallback preserved). Oracle tests `ridge_fit_sample_weight_with_intercept_matches_sklearn` (alpha=1 coef `[0.9233502538, 1.39678511]`, intercept `-0.8033840948`, differs from unweighted `[0.8228070175, 1.3561403509]`), `ridge_fit_sample_weight_no_intercept_matches_sklearn` (alpha=2 coef `[0.7273779983, 1.3737799835]`, intercept 0), `ridge_fit_none_sample_weight_equals_unweighted` (byte-identical guard). Closes #389. |
33//! | REQ-12 (copy_X/random_state) | SHIPPED | `Ridge<F>` adds `pub copy_x: bool` (default `true`) and `pub random_state: Option<u64>` (default `None`) fields with `with_copy_x`/`with_random_state` builders. `copy_x` ABI-only (fit never mutates `x`); `random_state` stored-but-no-op for the deterministic Cholesky solver (only `sag`/`saga` use it, `_ridge.py:898`/`:903`). Test: `ridge_copy_x_random_state_defaults_and_builders`. Closes #390. |
34//! | REQ-13 (ferray substrate) | NOT-STARTED | #391 (alpha=0 fallback already on ferray::linalg::lstsq; coef return tied to #359). |
35//! | REQ-14 (non-finite input rejected) | SHIPPED | Both fit entries reject any NaN/+/-inf in X, y, or `sample_weight` BEFORE centering/solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`_ridge.py:1242`) + `_check_sample_weight` (default `force_all_finite=True`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. The single-output `fit_with_sample_weight` (shared entry `Fit::fit` delegates to) checks X/y/sample_weight; the SEPARATE multi-output arm `Fit<Array2, Array2>::fit` checks X/y independently. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical (the guard never fires on finite input). Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `Ridge().fit` raises `ValueError` for NaN/+inf/-inf in X, NaN/inf in y, and NaN/inf in sample_weight (`tests/divergence_linear_nonfinite_batch2.rs::ridge_*`). Non-test consumer: the existing `Fit::fit` / `RsRidge` consumers. (#2259) |
36//!
37//! acto-critic: core L2 numerics (coef/intercept, alpha scaling, fit_intercept, f32) match the
38//! live oracle; one divergence (#392, alpha=0 rank-deficient min-norm) found and fixed.
39//! Two states only per goal.md R-DEFER-2.
40//!
41//! # Examples
42//!
43//! ```
44//! use ferrolearn_linear::Ridge;
45//! use ferrolearn_core::{Fit, Predict};
46//! use ndarray::{array, Array1, Array2};
47//!
48//! let model = Ridge::<f64>::new();
49//! let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
50//! let y = array![2.0, 4.0, 6.0, 8.0];
51//!
52//! let fitted = model.fit(&x, &y).unwrap();
53//! let preds = fitted.predict(&x).unwrap();
54//! ```
55
56use ferray::linalg::LinalgFloat;
57use ferrolearn_core::error::FerroError;
58use ferrolearn_core::introspection::HasCoefficients;
59use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
60use ferrolearn_core::traits::{Fit, Predict};
61use ndarray::{Array1, Array2, Axis, ScalarOperand};
62use num_traits::{Float, FromPrimitive};
63
64use crate::linalg;
65
66/// Solver used for the dense single-output Ridge coefficient solve.
67///
68/// Mirrors a (dense) subset of scikit-learn's `solver` parameter
69/// (`sklearn/linear_model/_ridge.py:830` `resolve_solver`). Only the
70/// closed-form dense solvers are implemented here; the iterative solvers
71/// (`lsqr`/`sparse_cg`/`sag`/`saga`/`lbfgs`) are tracked separately as
72/// REQ-8b (blocker #386) and are intentionally NOT represented as variants
73/// until their iterative/SGD substrate lands.
74///
75/// Every dense solver returns the IDENTICAL ridge solution — the problem is
76/// strictly convex (`alpha > 0`, or `alpha = 0` with full-rank `X`), so the
77/// minimizer is unique. The variant therefore only governs *how* the unique
78/// solution is computed, not *what* it is.
79///
80/// This governs only the single-output (`Fit<Array2, Array1>`) dense
81/// unconstrained solve. The multi-output (`Fit<Array2, Array2>`) path and the
82/// `positive=true` constrained path are unaffected by this setting.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
84pub enum RidgeSolver {
85    /// `'auto'` — resolve automatically. For the dense path this resolves to
86    /// [`Cholesky`](RidgeSolver::Cholesky) (sklearn `resolve_solver`,
87    /// `_ridge.py:830`: dense, non-positive data → `'cholesky'`).
88    #[default]
89    Auto,
90    /// `'cholesky'` — solve the normal equations `(XᵀX + alpha·I)·w = Xᵀy`
91    /// via a Cholesky factorization (`_solve_cholesky`, `_ridge.py:741`).
92    Cholesky,
93    /// `'svd'` — solve via the singular value decomposition of `X`
94    /// (`_solve_svd`, `_ridge.py:200`). Numerically identical to
95    /// [`Cholesky`](RidgeSolver::Cholesky) on the strictly-convex ridge
96    /// problem (unique minimizer).
97    Svd,
98}
99
100impl RidgeSolver {
101    /// Resolve `Auto` to the concrete dense solver scikit-learn picks for the
102    /// dense, non-positive path: `'cholesky'` (sklearn `resolve_solver`,
103    /// `_ridge.py:830`). `Cholesky`/`Svd` resolve to themselves.
104    #[must_use]
105    fn resolve(self) -> RidgeSolver {
106        match self {
107            RidgeSolver::Auto => RidgeSolver::Cholesky,
108            other => other,
109        }
110    }
111}
112
113/// Ridge regression (L2-regularized least squares).
114///
115/// Adds an L2 penalty to the ordinary least squares objective, which
116/// shrinks coefficients toward zero and can help with multicollinearity.
117///
118/// # Type Parameters
119///
120/// - `F`: The floating-point type (`f32` or `f64`).
121#[derive(Debug, Clone)]
122pub struct Ridge<F> {
123    /// Regularization strength. Larger values specify stronger
124    /// regularization.
125    pub alpha: F,
126    /// Optional per-target L2 penalties for the multi-output
127    /// `Fit<Array2, Array2>` path (sklearn accepts `alpha` as an array of
128    /// shape `(n_targets,)`, validated at
129    /// `sklearn/linear_model/_ridge.py:701-712`). When `Some`, each target
130    /// column `k` is fitted with its own penalty `alpha[k]`, overriding the
131    /// scalar [`alpha`](Self::alpha); this is mathematically identical to
132    /// fitting each target column with an independent scalar-`alpha` Ridge.
133    /// Default `None` (the scalar `alpha` applies to every target).
134    pub alpha_per_target: Option<Array1<F>>,
135    /// Whether to fit an intercept (bias) term.
136    pub fit_intercept: bool,
137    /// Whether `X` may be overwritten during fit (sklearn `copy_X`,
138    /// `_ridge.py:898`). ferrolearn's `fit` never mutates `x` (it reads
139    /// via `.mean_axis()`/`.outer_iter()`), so the observable
140    /// non-mutation contract holds regardless; the field is exposed for
141    /// ABI parity with sklearn. Default `true`, matching sklearn's
142    /// `copy_X=True` default (`_ridge.py:898`).
143    pub copy_x: bool,
144    /// Random seed for the `sag`/`saga` solvers (sklearn `random_state`,
145    /// `_ridge.py:903`). ferrolearn's default solver is deterministic
146    /// Cholesky (`solver='auto'`→`'cholesky'`), so this field is stored
147    /// for ABI parity and has no effect on the computed coefficients.
148    /// Default `None`, matching sklearn's `random_state=None` (`_ridge.py:903`).
149    pub random_state: Option<u64>,
150    /// Maximum number of iterations for iterative solvers (sklearn `max_iter`,
151    /// `_ridge.py:899`). Exposed for sklearn ABI parity; the implemented
152    /// direct Cholesky solver solves the normal equations in closed form with
153    /// no iteration, so this field is stored but has no effect on the computed
154    /// result. When an iterative solver is added (future REQ-8 #386), this
155    /// will control convergence. Default `None`, matching sklearn's default
156    /// (`_ridge.py:899`).
157    pub max_iter: Option<usize>,
158    /// Tolerance for iterative solvers (sklearn `tol`, `_ridge.py:900`).
159    /// Exposed for sklearn ABI parity; the implemented direct Cholesky solver
160    /// solves the normal equations in closed form with no iteration, so this
161    /// field is stored but has no effect on the computed result. When an
162    /// iterative solver is added (future REQ-8 #386), this will control
163    /// convergence. Default `1e-4`, matching sklearn's default (`_ridge.py:900`).
164    pub tol: F,
165    /// When `true`, constrain the fitted coefficients to be non-negative
166    /// (sklearn `positive`, `_ridge.py:902`/`:911`). sklearn solves the
167    /// non-negative ridge QP `min 0.5·‖X·w − y‖² + 0.5·alpha·‖w‖²` subject to
168    /// `w ≥ 0` via the L-BFGS-B solver (`_solve_lbfgs`, `_ridge.py:300`,
169    /// dispatched at `:923-928`); ferrolearn solves the same unique optimum
170    /// with projected coordinate descent. Default `false`, matching sklearn's
171    /// `positive=False` (`_ridge.py:902`); `positive=false` is byte-identical
172    /// to the unconstrained Cholesky path.
173    pub positive: bool,
174    /// Closed-form dense solver for the single-output unconstrained solve
175    /// (sklearn `solver`, `_ridge.py:830` `resolve_solver`). One of
176    /// [`RidgeSolver::Auto`] (default, resolves to `Cholesky` for the dense
177    /// path), [`RidgeSolver::Cholesky`], or [`RidgeSolver::Svd`]. All dense
178    /// solvers yield the IDENTICAL (unique) ridge solution; this only selects
179    /// the factorization used. Governs ONLY the single-output
180    /// (`Fit<Array2, Array1>`) unconstrained path — the multi-output and
181    /// `positive=true` paths ignore it. The iterative solvers
182    /// (`lsqr`/`sparse_cg`/`sag`/`saga`/`lbfgs`) are tracked as REQ-8b (#386).
183    /// Default [`RidgeSolver::Auto`], matching sklearn's `solver='auto'`
184    /// (`_ridge.py:903`).
185    pub solver: RidgeSolver,
186}
187
188impl<F: Float> Ridge<F> {
189    /// Create a new `Ridge` with default settings.
190    ///
191    /// Defaults: `alpha = 1.0`, `fit_intercept = true`, `copy_x = true`,
192    /// `random_state = None`, `max_iter = None`, `tol = 1e-4`,
193    /// `positive = false`, `solver = Auto` — mirroring sklearn's ctor defaults
194    /// (`sklearn/linear_model/_ridge.py:895-903`).
195    #[must_use]
196    pub fn new() -> Self {
197        Self {
198            alpha: F::one(),
199            alpha_per_target: None,
200            fit_intercept: true,
201            copy_x: true,
202            random_state: None,
203            max_iter: None,
204            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
205            positive: false,
206            solver: RidgeSolver::Auto,
207        }
208    }
209
210    /// Set the regularization strength.
211    #[must_use]
212    pub fn with_alpha(mut self, alpha: F) -> Self {
213        self.alpha = alpha;
214        self
215    }
216
217    /// Set per-target L2 penalties for the multi-output
218    /// `Fit<Array2, Array2>` path (sklearn array-valued `alpha`,
219    /// `sklearn/linear_model/_ridge.py:701-712`).
220    ///
221    /// When set, each target column `k` of `Y` is fitted with its own penalty
222    /// `alphas[k]`, overriding the scalar [`alpha`](Self::alpha) on the
223    /// multi-output fit. This is mathematically identical to fitting each
224    /// target column with an independent scalar-`alpha` Ridge. The array
225    /// length must equal the number of target columns and every entry must be
226    /// non-negative (validated at fit time).
227    #[must_use]
228    pub fn with_alpha_per_target(mut self, alphas: Array1<F>) -> Self {
229        self.alpha_per_target = Some(alphas);
230        self
231    }
232
233    /// Set whether to fit an intercept term.
234    #[must_use]
235    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
236        self.fit_intercept = fit_intercept;
237        self
238    }
239
240    /// Set the `copy_X` flag (sklearn `copy_X`, `_ridge.py:898`).
241    ///
242    /// ferrolearn's fit never mutates `x`, so this is exposed for ABI
243    /// parity with sklearn and does not change the computed result.
244    #[must_use]
245    pub fn with_copy_x(mut self, copy_x: bool) -> Self {
246        self.copy_x = copy_x;
247        self
248    }
249
250    /// Set the `random_state` seed (sklearn `random_state`, `_ridge.py:903`).
251    ///
252    /// Only used by sklearn's `sag`/`saga` solvers. ferrolearn's default
253    /// solver is deterministic Cholesky, so this is stored for ABI parity
254    /// and has no effect on the computed coefficients.
255    #[must_use]
256    pub fn with_random_state(mut self, random_state: Option<u64>) -> Self {
257        self.random_state = random_state;
258        self
259    }
260
261    /// Set the maximum number of iterations for iterative solvers (sklearn
262    /// `max_iter`, `_ridge.py:899`).
263    ///
264    /// ferrolearn's direct Cholesky solver solves the normal equations in
265    /// closed form with no iteration, so this is stored for sklearn ABI
266    /// parity and does not affect the computed result. When an iterative
267    /// solver is added (future REQ-8 #386), this will take effect.
268    #[must_use]
269    pub fn with_max_iter(mut self, max_iter: Option<usize>) -> Self {
270        self.max_iter = max_iter;
271        self
272    }
273
274    /// Set the convergence tolerance for iterative solvers (sklearn `tol`,
275    /// `_ridge.py:900`).
276    ///
277    /// ferrolearn's direct Cholesky solver solves the normal equations in
278    /// closed form with no iteration, so this is stored for sklearn ABI
279    /// parity and does not affect the computed result. When an iterative
280    /// solver is added (future REQ-8 #386), this will take effect.
281    #[must_use]
282    pub fn with_tol(mut self, tol: F) -> Self {
283        self.tol = tol;
284        self
285    }
286
287    /// Set whether to constrain the fitted coefficients to be non-negative
288    /// (sklearn `positive`, `_ridge.py:902`/`:911`).
289    ///
290    /// When `true`, ferrolearn solves the non-negative ridge QP
291    /// `min 0.5·‖X·w − y‖² + 0.5·alpha·‖w‖²` subject to `w ≥ 0` via projected
292    /// coordinate descent — the same unique optimum sklearn reaches with its
293    /// L-BFGS-B solver (`_solve_lbfgs`, `_ridge.py:300`). `false` (default)
294    /// uses the unconstrained Cholesky path, byte-identical to today.
295    #[must_use]
296    pub fn with_positive(mut self, positive: bool) -> Self {
297        self.positive = positive;
298        self
299    }
300
301    /// Set the dense closed-form solver for the single-output unconstrained
302    /// solve (sklearn `solver`, `_ridge.py:830`).
303    ///
304    /// [`RidgeSolver::Auto`] (default) resolves to [`RidgeSolver::Cholesky`]
305    /// for the dense path; [`RidgeSolver::Svd`] solves the same unique ridge
306    /// problem via the SVD of `X`. All dense solvers yield the identical
307    /// coefficients (the minimizer is unique); only the factorization differs.
308    /// This governs ONLY the single-output (`Fit<Array2, Array1>`)
309    /// unconstrained path.
310    #[must_use]
311    pub fn with_solver(mut self, solver: RidgeSolver) -> Self {
312        self.solver = solver;
313        self
314    }
315}
316
317impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static> Ridge<F> {
318    /// Fit the Ridge regression model with optional per-sample weights.
319    ///
320    /// Mirrors scikit-learn's `Ridge.fit(X, y, sample_weight=None)`
321    /// (`sklearn/linear_model/_ridge.py`). When `sample_weight` is `Some(w)`,
322    /// this solves the WEIGHTED ridge problem
323    /// `min Σᵢ wᵢ (yᵢ − xᵢ·coef)² + alpha·‖coef‖²`:
324    ///
325    /// - `fit_intercept=true`: offsets are the WEIGHTED means
326    ///   `x_off[j] = Σᵢ wᵢ·x[i,j] / Σwᵢ`, `y_off = Σᵢ wᵢ·yᵢ / Σwᵢ`. `X` and `y`
327    ///   are centered by those offsets, each row is then rescaled by `√wᵢ`
328    ///   (sklearn `_rescale_data`, `_ridge.py:682-688`), the cholesky ridge solve
329    ///   runs on the rescaled design with the penalty `alpha` UNSCALED (because
330    ///   `Xsᵀ·Xs == Xᵀ·W·X`), and `intercept = y_off − x_off·coef`.
331    /// - `fit_intercept=false`: no centering; each row is rescaled by `√wᵢ`, the
332    ///   ridge solve runs on the rescaled `X`/`y`, and `intercept = 0`.
333    ///
334    /// `sample_weight=None` is BYTE-IDENTICAL to [`Fit::fit`] (the unweighted
335    /// centering + `linalg::solve_ridge` path), which delegates here. The
336    /// `alpha=0` → OLS min-norm fallback is preserved (handled inside
337    /// `linalg::solve_ridge`).
338    ///
339    /// # Errors
340    ///
341    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in `x` and
342    /// `y` (or, when provided, `sample_weight`) differ.
343    /// Returns [`FerroError::InvalidParameter`] if `alpha` is negative.
344    /// Returns [`FerroError::InsufficientSamples`] if there are no samples.
345    /// Returns [`FerroError::NumericalInstability`] if the weighted-offset
346    /// denominator (`Σwᵢ`) cannot be formed or a mean cannot be computed.
347    /// Solve the non-negative ridge problem
348    /// `min 0.5·‖A·w − b‖² + 0.5·alpha·‖w‖²` subject to `w ≥ 0` via projected
349    /// coordinate descent.
350    ///
351    /// Mirrors the unique optimum sklearn reaches with its L-BFGS-B solver
352    /// (`_solve_lbfgs`, `sklearn/linear_model/_ridge.py:300`, objective
353    /// `0.5·‖Xw−y‖² + 0.5·alpha·‖w‖²` with bounds `[(0, inf)]`). For a smooth
354    /// strongly-convex QP with box constraints, coordinate descent with
355    /// per-coordinate projection converges to that exact optimum.
356    ///
357    /// Returns `(coef, n_iter)`. A column with `‖A[:,j]‖² + alpha == 0` keeps
358    /// its coordinate at zero (no division by zero).
359    ///
360    /// Inactive-constraint short-circuit (R-DEV-6): we first compute the
361    /// UNCONSTRAINED ridge optimum via the exact Cholesky solve
362    /// (`crate::linalg::solve_ridge`). If that succeeds AND every component is
363    /// `>= 0`, the positivity constraint is inactive, and by strict convexity +
364    /// the KKT conditions a feasible unconstrained minimizer IS the constrained
365    /// minimizer — so we return it as `(w_unc, 0)` (zero iterations, exact). This
366    /// matches a CONVERGED sklearn exactly: sklearn's default positive-Ridge
367    /// L-BFGS-B (`_solve_lbfgs`, `_ridge.py:300`) runs at `tol=1e-4` and
368    /// under-converges on interior optima by ~1e-4, whereas at `tol=1e-12` it
369    /// reaches the same true optimum ferrolearn computes here in closed form.
370    /// Otherwise (the Cholesky solve errored, or any component is `< 0` so the
371    /// constraint is active) we fall through to projected coordinate descent.
372    fn solve_nonneg_ridge(&self, a: &Array2<F>, b: &Array1<F>) -> (Array1<F>, usize) {
373        // Inactive-constraint short-circuit (R-DEV-6): the exact unconstrained
374        // Cholesky optimum, when feasible (all components >= 0), IS the
375        // constrained optimum (strict convexity + KKT). Return it exactly.
376        if let Ok(w_unc) = crate::linalg::solve_ridge(a, b, self.alpha)
377            && w_unc.iter().all(|&v| v >= <F as num_traits::Zero>::zero())
378        {
379            return (w_unc, 0);
380        }
381
382        // Active constraint (or the Cholesky solve failed) — delegate to the
383        // shared projected-coordinate-descent kernel
384        // (`crate::linalg::nonneg_ridge_cd`) so `Ridge` and `RidgeClassifier`
385        // solve the non-negative ridge problem identically.
386        crate::linalg::nonneg_ridge_cd(a, b, self.alpha, self.max_iter.unwrap_or(1000), self.tol)
387    }
388
389    /// Solve the unconstrained dense ridge system on the given (already
390    /// centered / `√w`-rescaled) design, dispatching on the RESOLVED solver.
391    ///
392    /// - [`RidgeSolver::Svd`] → `linalg::solve_ridge_svd` (`_solve_svd`,
393    ///   `_ridge.py:200`).
394    /// - [`RidgeSolver::Cholesky`] / [`RidgeSolver::Auto`] →
395    ///   `linalg::solve_ridge` (Cholesky normal equations, `_ridge.py:741`) —
396    ///   byte-identical to the historic path.
397    ///
398    /// `resolved` is `self.solver.resolve()` (precomputed by the caller). Every
399    /// dense solver returns the same unique minimizer; the choice only affects
400    /// the factorization.
401    fn solve_unconstrained(
402        &self,
403        resolved: RidgeSolver,
404        x: &Array2<F>,
405        y: &Array1<F>,
406    ) -> Result<Array1<F>, FerroError> {
407        match resolved {
408            RidgeSolver::Svd => linalg::solve_ridge_svd(x, y, self.alpha),
409            // `Auto` is resolved to `Cholesky` before this point; matched here
410            // for exhaustiveness with byte-identical behaviour.
411            RidgeSolver::Cholesky | RidgeSolver::Auto => linalg::solve_ridge(x, y, self.alpha),
412        }
413    }
414
415    pub fn fit_with_sample_weight(
416        &self,
417        x: &Array2<F>,
418        y: &Array1<F>,
419        sample_weight: Option<&Array1<F>>,
420    ) -> Result<FittedRidge<F>, FerroError> {
421        let (n_samples, n_features) = x.dim();
422
423        if n_samples != y.len() {
424            return Err(FerroError::ShapeMismatch {
425                expected: vec![n_samples],
426                actual: vec![y.len()],
427                context: "y length must match number of samples in X".into(),
428            });
429        }
430
431        // `<F as num_traits::Zero>::zero()`: the `LinalgFloat` bound pulls
432        // `ferray::Element` (which also defines a `zero`) into scope, so a bare
433        // `F::zero()` is ambiguous between `Element` and `num_traits::Zero`.
434        if self.alpha < <F as num_traits::Zero>::zero() {
435            return Err(FerroError::InvalidParameter {
436                name: "alpha".into(),
437                reason: "must be non-negative".into(),
438            });
439        }
440
441        if n_samples == 0 {
442            return Err(FerroError::InsufficientSamples {
443                required: 1,
444                actual: 0,
445                context: "Ridge requires at least one sample".into(),
446            });
447        }
448
449        if let Some(w) = sample_weight
450            && w.len() != n_samples
451        {
452            return Err(FerroError::ShapeMismatch {
453                expected: vec![n_samples],
454                actual: vec![w.len()],
455                context: "sample_weight length must match number of samples in X".into(),
456            });
457        }
458
459        // Non-finite input validation (#2259). sklearn `Ridge.fit` ->
460        // `self._validate_data(X, y, ...)` (`_ridge.py:1242`) keeps the default
461        // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
462        // X OR y with a `ValueError` BEFORE the solve. sklearn also validates
463        // `sample_weight` via `_check_sample_weight` (default `force_all_finite=
464        // True`), raising on a non-finite weight. `.iter().any(|v| !v.is_finite())`
465        // rejects both NaN and Inf (bounds-safe, no panic, R-CODE-2), matching
466        // the crate idiom (`linear_regression.rs`/`lasso.rs`). The finite path is
467        // byte-identical (the guard never fires on finite input). This is the
468        // shared single-output entry; `Fit::fit` delegates here with `None`.
469        if x.iter().any(|v| !v.is_finite()) {
470            return Err(FerroError::InvalidParameter {
471                name: "X".into(),
472                reason: "Input X contains NaN or infinity.".into(),
473            });
474        }
475        if y.iter().any(|v| !v.is_finite()) {
476            return Err(FerroError::InvalidParameter {
477                name: "y".into(),
478                reason: "Input y contains NaN or infinity.".into(),
479            });
480        }
481        if let Some(w) = sample_weight
482            && w.iter().any(|v| !v.is_finite())
483        {
484            return Err(FerroError::InvalidParameter {
485                name: "sample_weight".into(),
486                reason: "Input sample_weight contains NaN or infinity.".into(),
487            });
488        }
489
490        // Resolve the dense solver once (sklearn `resolve_solver`,
491        // `_ridge.py:830`): `Auto` → `Cholesky` for the dense path. This is the
492        // value stored as the fitted `solver_`. It governs only the
493        // unconstrained dense solve; the `positive` path reports the same
494        // resolved dense value (the `solver` field does not select the
495        // constrained CD solver — see the `solver` field doc).
496        let resolved = self.solver.resolve();
497
498        match sample_weight {
499            None => {
500                // Unweighted path — identical to the original `Fit::fit` body.
501                if self.fit_intercept {
502                    // Center the data to handle the intercept.
503                    let x_mean =
504                        x.mean_axis(Axis(0))
505                            .ok_or_else(|| FerroError::NumericalInstability {
506                                message: "failed to compute column means".into(),
507                            })?;
508                    let y_mean = y.mean().ok_or_else(|| FerroError::NumericalInstability {
509                        message: "failed to compute target mean".into(),
510                    })?;
511
512                    let x_centered = x - &x_mean;
513                    let y_centered = y - y_mean;
514
515                    let (w, n_iter_) = if self.positive {
516                        let (coef, iters) = self.solve_nonneg_ridge(&x_centered, &y_centered);
517                        (coef, Some(iters))
518                    } else {
519                        (
520                            self.solve_unconstrained(resolved, &x_centered, &y_centered)?,
521                            None,
522                        )
523                    };
524                    let intercept = y_mean - x_mean.dot(&w);
525
526                    Ok(FittedRidge {
527                        coefficients: w,
528                        intercept,
529                        n_iter_,
530                        solver_: resolved,
531                    })
532                } else {
533                    let (w, n_iter_) = if self.positive {
534                        let (coef, iters) = self.solve_nonneg_ridge(x, y);
535                        (coef, Some(iters))
536                    } else {
537                        (self.solve_unconstrained(resolved, x, y)?, None)
538                    };
539
540                    Ok(FittedRidge {
541                        coefficients: w,
542                        // Disambiguate `Element::zero` vs `num_traits::Zero::zero`
543                        // (both in scope under the `LinalgFloat` bound).
544                        intercept: <F as num_traits::Zero>::zero(),
545                        n_iter_,
546                        solver_: resolved,
547                    })
548                }
549            }
550            Some(w) => {
551                // Per-row √w factor (sklearn `_rescale_data`, `_ridge.py:682-688`).
552                let w_sqrt = w.mapv(<F as Float>::sqrt);
553
554                if self.fit_intercept {
555                    // WEIGHTED centering: offsets are the weighted means
556                    // x_off[j] = Σ wᵢ x[i,j] / Σ wᵢ, y_off = Σ wᵢ yᵢ / Σ wᵢ
557                    // (sklearn `_preprocess_data` weighted `_average`).
558                    let w_sum = w.sum();
559                    if w_sum <= <F as num_traits::Zero>::zero() {
560                        return Err(FerroError::NumericalInstability {
561                            message: "sum of sample_weight must be positive to center".into(),
562                        });
563                    }
564
565                    let mut x_off = Array1::<F>::zeros(n_features);
566                    for (i, row) in x.outer_iter().enumerate() {
567                        let wi = w[i];
568                        x_off = &x_off + &row.mapv(|v| v * wi);
569                    }
570                    x_off.mapv_inplace(|v| v / w_sum);
571
572                    let y_off = y
573                        .iter()
574                        .zip(w.iter())
575                        .fold(<F as num_traits::Zero>::zero(), |acc, (&yi, &wi)| {
576                            acc + wi * yi
577                        })
578                        / w_sum;
579
580                    // Center, then row-rescale by √w. Penalty `alpha` is UNSCALED:
581                    // (√w·Xc)ᵀ(√w·Xc) == Xcᵀ·W·Xc, so the closed form
582                    // (Xsᵀ·Xs + alpha·I)·coef = Xsᵀ·ys IS the weighted ridge solve.
583                    let x_centered = x - &x_off;
584                    let y_centered = y - y_off;
585                    let x_scaled = &x_centered * &w_sqrt.view().insert_axis(Axis(1));
586                    let y_scaled = &y_centered * &w_sqrt;
587
588                    let (coef, n_iter_) = if self.positive {
589                        let (c, iters) = self.solve_nonneg_ridge(&x_scaled, &y_scaled);
590                        (c, Some(iters))
591                    } else {
592                        (
593                            self.solve_unconstrained(resolved, &x_scaled, &y_scaled)?,
594                            None,
595                        )
596                    };
597                    let intercept = y_off - x_off.dot(&coef);
598
599                    Ok(FittedRidge {
600                        coefficients: coef,
601                        intercept,
602                        n_iter_,
603                        solver_: resolved,
604                    })
605                } else {
606                    // No centering; just √w row-rescaling, intercept 0.
607                    let x_scaled = x * &w_sqrt.view().insert_axis(Axis(1));
608                    let y_scaled = y * &w_sqrt;
609
610                    let (coef, n_iter_) = if self.positive {
611                        let (c, iters) = self.solve_nonneg_ridge(&x_scaled, &y_scaled);
612                        (c, Some(iters))
613                    } else {
614                        (
615                            self.solve_unconstrained(resolved, &x_scaled, &y_scaled)?,
616                            None,
617                        )
618                    };
619
620                    Ok(FittedRidge {
621                        coefficients: coef,
622                        intercept: <F as num_traits::Zero>::zero(),
623                        n_iter_,
624                        solver_: resolved,
625                    })
626                }
627            }
628        }
629    }
630}
631
632impl<F: Float> Default for Ridge<F> {
633    fn default() -> Self {
634        Self::new()
635    }
636}
637
638/// Fitted Ridge regression model.
639///
640/// Stores the learned coefficients and intercept. Implements [`Predict`]
641/// to generate predictions and [`HasCoefficients`] for introspection.
642#[derive(Debug, Clone)]
643pub struct FittedRidge<F> {
644    /// Learned coefficient vector (one per feature).
645    coefficients: Array1<F>,
646    /// Learned intercept (bias) term.
647    intercept: F,
648    /// Number of iterations run by an iterative solver, or `None` for direct
649    /// solvers (sklearn `n_iter_`, `_ridge.py:994`). ferrolearn's Cholesky
650    /// solver is direct (no iteration), so this is always `None` — matching
651    /// sklearn's behaviour when `solver='cholesky'` or `solver='svd'` resolve
652    /// the normal equations in closed form.
653    n_iter_: Option<usize>,
654    /// The RESOLVED dense solver actually used for the coefficient solve
655    /// (sklearn `solver_`, the resolution of `solver='auto'`,
656    /// `_ridge.py:830`/`:994`). `Auto` resolves to [`RidgeSolver::Cholesky`]
657    /// for the dense path; an explicit `Cholesky`/`Svd` resolves to itself.
658    solver_: RidgeSolver,
659}
660
661impl<F> FittedRidge<F> {
662    /// Return the number of iterations run by an iterative solver, or `None`
663    /// for the direct Cholesky solver (sklearn `n_iter_`, `_ridge.py:994`).
664    ///
665    /// ferrolearn implements only the direct Cholesky path, so this is always
666    /// `None`. When an iterative solver is added (future REQ-8 #386), it will
667    /// return `Some(n)`.
668    #[must_use]
669    pub fn n_iter(&self) -> Option<usize> {
670        self.n_iter_
671    }
672
673    /// Return the RESOLVED dense solver used for the coefficient solve
674    /// (sklearn `solver_`, `_ridge.py:830`/`:994`).
675    ///
676    /// [`RidgeSolver::Auto`] resolves to [`RidgeSolver::Cholesky`] for the
677    /// dense path, so a default-`solver` fit reports `Cholesky`; an explicit
678    /// `Svd` reports `Svd`.
679    #[must_use]
680    pub fn solver(&self) -> RidgeSolver {
681        self.solver_
682    }
683}
684
685impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
686    Fit<Array2<F>, Array1<F>> for Ridge<F>
687{
688    type Fitted = FittedRidge<F>;
689    type Error = FerroError;
690
691    /// Fit the Ridge regression model using Cholesky decomposition.
692    ///
693    /// Solves `(X^T X + alpha * I)^{-1} X^T y`.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in
698    /// `x` and `y` differ.
699    /// Returns [`FerroError::InvalidParameter`] if `alpha` is negative.
700    fn fit(&self, x: &Array2<F>, y: &Array1<F>) -> Result<FittedRidge<F>, FerroError> {
701        // Unweighted ridge is the `sample_weight=None` arm of the weighted fit;
702        // delegating keeps the None path byte-identical to the historic body
703        // (centering + `solve_ridge`), mirroring sklearn's single `fit` entry
704        // (`_ridge.py`, `sample_weight=None` default).
705        self.fit_with_sample_weight(x, y, None)
706    }
707}
708
709impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>> for FittedRidge<F> {
710    type Output = Array1<F>;
711    type Error = FerroError;
712
713    /// Predict target values for the given feature matrix.
714    ///
715    /// Computes `X @ coefficients + intercept`.
716    ///
717    /// # Errors
718    ///
719    /// Returns [`FerroError::ShapeMismatch`] if the number of features
720    /// does not match the fitted model.
721    fn predict(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
722        let n_features = x.ncols();
723        if n_features != self.coefficients.len() {
724            return Err(FerroError::ShapeMismatch {
725                expected: vec![self.coefficients.len()],
726                actual: vec![n_features],
727                context: "number of features must match fitted model".into(),
728            });
729        }
730
731        let preds = x.dot(&self.coefficients) + self.intercept;
732        Ok(preds)
733    }
734}
735
736impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F> for FittedRidge<F> {
737    fn coefficients(&self) -> &Array1<F> {
738        &self.coefficients
739    }
740
741    fn intercept(&self) -> F {
742        self.intercept
743    }
744}
745
746/// Fitted multi-output Ridge regression model.
747///
748/// Companion to [`FittedRidge`] for the case where `Y` has multiple
749/// target columns. Stores a `(n_features, n_targets)` coefficient matrix
750/// and a per-target intercept vector. The Cholesky factor of
751/// `X^T X + alpha * I` is computed once during [`Ridge::fit`] and shared
752/// across all targets, so multi-output fitting costs the same `O(p^3)`
753/// factorization as the single-output path.
754#[derive(Debug, Clone)]
755pub struct FittedRidgeMulti<F> {
756    /// Learned coefficients, shape `(n_features, n_targets)`.
757    coefficients: Array2<F>,
758    /// Per-target intercept vector, length `n_targets`. Filled with
759    /// zeros when `fit_intercept = false`.
760    intercepts: Array1<F>,
761}
762
763impl<F: Float> FittedRidgeMulti<F> {
764    /// Borrow the learned coefficient matrix `(n_features, n_targets)`.
765    #[must_use]
766    pub fn coefficients(&self) -> &Array2<F> {
767        &self.coefficients
768    }
769
770    /// Borrow the per-target intercept vector `(n_targets,)`.
771    #[must_use]
772    pub fn intercepts(&self) -> &Array1<F> {
773        &self.intercepts
774    }
775}
776
777impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
778    Fit<Array2<F>, Array2<F>> for Ridge<F>
779{
780    type Fitted = FittedRidgeMulti<F>;
781    type Error = FerroError;
782
783    /// Fit the multi-output Ridge regression model using a single
784    /// shared Cholesky factorization across all `Y` columns.
785    ///
786    /// Solves `(X^T X + alpha * I)^{-1} X^T Y` where `Y` is
787    /// `(n_samples, n_targets)`.
788    ///
789    /// # Errors
790    ///
791    /// Returns [`FerroError::ShapeMismatch`] if the number of samples in
792    /// `x` and `y` differ.
793    /// Returns [`FerroError::InvalidParameter`] if `alpha` is negative.
794    fn fit(&self, x: &Array2<F>, y: &Array2<F>) -> Result<FittedRidgeMulti<F>, FerroError> {
795        let (n_samples, _n_features) = x.dim();
796
797        if n_samples != y.nrows() {
798            return Err(FerroError::ShapeMismatch {
799                expected: vec![n_samples],
800                actual: vec![y.nrows()],
801                context: "y rows must match number of samples in X".into(),
802            });
803        }
804
805        if n_samples == 0 {
806            return Err(FerroError::InsufficientSamples {
807                required: 1,
808                actual: 0,
809                context: "Ridge requires at least one sample".into(),
810            });
811        }
812
813        // Non-finite input validation (#2259) — SEPARATE multi-output arm.
814        // `Fit<Array2, Array2>::fit` does NOT delegate to
815        // `fit_with_sample_weight`, so the same `_validate_data(force_all_finite=
816        // True)` reject-at-fit contract (`_ridge.py:1242`, `multi_output=True`)
817        // is enforced here independently before centering/solve.
818        if x.iter().any(|v| !v.is_finite()) {
819            return Err(FerroError::InvalidParameter {
820                name: "X".into(),
821                reason: "Input X contains NaN or infinity.".into(),
822            });
823        }
824        if y.iter().any(|v| !v.is_finite()) {
825            return Err(FerroError::InvalidParameter {
826                name: "y".into(),
827                reason: "Input y contains NaN or infinity.".into(),
828            });
829        }
830
831        let n_targets = y.ncols();
832
833        // Per-target alpha array (sklearn array-valued `alpha`,
834        // `sklearn/linear_model/_ridge.py:701-712`) — validate the array and
835        // solve each target column independently with its own penalty. When
836        // `None`, fall through to the byte-identical scalar-alpha path below.
837        if let Some(alphas) = &self.alpha_per_target {
838            if alphas.len() != n_targets {
839                return Err(FerroError::ShapeMismatch {
840                    expected: vec![n_targets],
841                    actual: vec![alphas.len()],
842                    context: "alpha array length must equal number of targets".into(),
843                });
844            }
845            for &a in alphas.iter() {
846                if a < <F as num_traits::Zero>::zero() {
847                    return Err(FerroError::InvalidParameter {
848                        name: "alpha".into(),
849                        reason: "must be non-negative".into(),
850                    });
851                }
852            }
853
854            let n_features = x.ncols();
855            let mut coefficients = Array2::<F>::zeros((n_features, n_targets));
856            let mut intercepts = Array1::<F>::zeros(n_targets);
857
858            if self.fit_intercept {
859                // Center once (identical to the scalar path), then solve each
860                // centered target column with its own alpha.
861                let x_mean =
862                    x.mean_axis(Axis(0))
863                        .ok_or_else(|| FerroError::NumericalInstability {
864                            message: "failed to compute column means of X".into(),
865                        })?;
866                let y_mean =
867                    y.mean_axis(Axis(0))
868                        .ok_or_else(|| FerroError::NumericalInstability {
869                            message: "failed to compute column means of Y".into(),
870                        })?;
871
872                let x_centered = x - &x_mean;
873                let y_centered = y - &y_mean;
874
875                for k in 0..n_targets {
876                    let y_col = y_centered.column(k).to_owned();
877                    let w_col = linalg::solve_ridge(&x_centered, &y_col, alphas[k])?;
878                    intercepts[k] = y_mean[k] - x_mean.dot(&w_col);
879                    coefficients.column_mut(k).assign(&w_col);
880                }
881            } else {
882                for k in 0..n_targets {
883                    let y_col = y.column(k).to_owned();
884                    let w_col = linalg::solve_ridge(x, &y_col, alphas[k])?;
885                    coefficients.column_mut(k).assign(&w_col);
886                }
887            }
888
889            return Ok(FittedRidgeMulti {
890                coefficients,
891                intercepts,
892            });
893        }
894
895        if self.alpha < <F as num_traits::Zero>::zero() {
896            return Err(FerroError::InvalidParameter {
897                name: "alpha".into(),
898                reason: "must be non-negative".into(),
899            });
900        }
901
902        if self.fit_intercept {
903            // Center the data to handle the intercept (per-target).
904            let x_mean = x
905                .mean_axis(Axis(0))
906                .ok_or_else(|| FerroError::NumericalInstability {
907                    message: "failed to compute column means of X".into(),
908                })?;
909            let y_mean = y
910                .mean_axis(Axis(0))
911                .ok_or_else(|| FerroError::NumericalInstability {
912                    message: "failed to compute column means of Y".into(),
913                })?;
914
915            let x_centered = x - &x_mean;
916            let y_centered = y - &y_mean;
917
918            let w = linalg::solve_ridge_multi(&x_centered, &y_centered, self.alpha)?;
919            // intercept[k] = y_mean[k] - x_mean · w[:, k]
920            let mut intercepts = Array1::<F>::zeros(n_targets);
921            for k in 0..n_targets {
922                let col = w.column(k);
923                let dot = x_mean.dot(&col);
924                intercepts[k] = y_mean[k] - dot;
925            }
926
927            Ok(FittedRidgeMulti {
928                coefficients: w,
929                intercepts,
930            })
931        } else {
932            let w = linalg::solve_ridge_multi(x, y, self.alpha)?;
933            Ok(FittedRidgeMulti {
934                coefficients: w,
935                intercepts: Array1::<F>::zeros(n_targets),
936            })
937        }
938    }
939}
940
941impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>> for FittedRidgeMulti<F> {
942    type Output = Array2<F>;
943    type Error = FerroError;
944
945    /// Predict target values for the given feature matrix.
946    ///
947    /// Computes `X @ coefficients + intercepts` and returns an
948    /// `(n_samples, n_targets)` array.
949    ///
950    /// # Errors
951    ///
952    /// Returns [`FerroError::ShapeMismatch`] if the number of features
953    /// does not match the fitted model.
954    fn predict(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
955        let n_features = x.ncols();
956        if n_features != self.coefficients.nrows() {
957            return Err(FerroError::ShapeMismatch {
958                expected: vec![self.coefficients.nrows()],
959                actual: vec![n_features],
960                context: "number of features must match fitted model".into(),
961            });
962        }
963
964        let mut preds = x.dot(&self.coefficients);
965        // Broadcast-add per-target intercepts.
966        for (k, &b) in self.intercepts.iter().enumerate() {
967            let mut col = preds.column_mut(k);
968            col.mapv_inplace(|v| v + b);
969        }
970        Ok(preds)
971    }
972}
973
974// Pipeline integration.
975impl<F> PipelineEstimator<F> for Ridge<F>
976where
977    F: Float + FromPrimitive + ScalarOperand + LinalgFloat + Send + Sync + 'static,
978{
979    fn fit_pipeline(
980        &self,
981        x: &Array2<F>,
982        y: &Array1<F>,
983    ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
984        let fitted = self.fit(x, y)?;
985        Ok(Box::new(fitted))
986    }
987}
988
989impl<F> FittedPipelineEstimator<F> for FittedRidge<F>
990where
991    F: Float + ScalarOperand + Send + Sync + 'static,
992{
993    fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
994        self.predict(x)
995    }
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001    use approx::assert_relative_eq;
1002    use ndarray::array;
1003
1004    #[test]
1005    fn test_ridge_no_regularization() {
1006        // With alpha=0, Ridge should behave like OLS.
1007        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1008        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
1009
1010        let model = Ridge::<f64>::new().with_alpha(0.0);
1011        let fitted = model.fit(&x, &y).unwrap();
1012
1013        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-8);
1014        assert_relative_eq!(fitted.intercept(), 1.0, epsilon = 1e-8);
1015    }
1016
1017    #[test]
1018    fn test_ridge_shrinks_coefficients() {
1019        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1020        let y = array![3.0, 5.0, 7.0, 9.0, 11.0];
1021
1022        let model_low = Ridge::<f64>::new().with_alpha(0.01);
1023        let model_high = Ridge::<f64>::new().with_alpha(100.0);
1024
1025        let fitted_low = model_low.fit(&x, &y).unwrap();
1026        let fitted_high = model_high.fit(&x, &y).unwrap();
1027
1028        // Higher alpha should shrink coefficients more.
1029        assert!(fitted_high.coefficients()[0].abs() < fitted_low.coefficients()[0].abs());
1030    }
1031
1032    #[test]
1033    fn test_ridge_no_intercept() {
1034        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1035        let y = array![2.0, 4.0, 6.0, 8.0];
1036
1037        let model = Ridge::<f64>::new()
1038            .with_alpha(0.0)
1039            .with_fit_intercept(false);
1040        let fitted = model.fit(&x, &y).unwrap();
1041
1042        assert_relative_eq!(fitted.coefficients()[0], 2.0, epsilon = 1e-10);
1043        assert_relative_eq!(fitted.intercept(), 0.0, epsilon = 1e-10);
1044    }
1045
1046    #[test]
1047    fn test_ridge_negative_alpha() {
1048        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1049        let y = array![1.0, 2.0, 3.0];
1050
1051        let model = Ridge::<f64>::new().with_alpha(-1.0);
1052        let result = model.fit(&x, &y);
1053        assert!(result.is_err());
1054    }
1055
1056    #[test]
1057    fn test_ridge_shape_mismatch() {
1058        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1059        let y = array![1.0, 2.0];
1060
1061        let model = Ridge::<f64>::new();
1062        let result = model.fit(&x, &y);
1063        assert!(result.is_err());
1064    }
1065
1066    #[test]
1067    fn test_ridge_predict() {
1068        let x =
1069            Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0]).unwrap();
1070        let y = array![1.0, 2.0, 3.0, 6.0];
1071
1072        let model = Ridge::<f64>::new().with_alpha(0.01);
1073        let fitted = model.fit(&x, &y).unwrap();
1074
1075        let preds = fitted.predict(&x).unwrap();
1076        assert_eq!(preds.len(), 4);
1077    }
1078
1079    #[test]
1080    fn test_ridge_pipeline_integration() {
1081        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1082        let y = array![3.0, 5.0, 7.0, 9.0];
1083
1084        let model = Ridge::<f64>::new();
1085        let fitted = model.fit_pipeline(&x, &y).unwrap();
1086        let preds = fitted.predict_pipeline(&x).unwrap();
1087        assert_eq!(preds.len(), 4);
1088    }
1089
1090    #[test]
1091    fn test_ridge_has_coefficients() {
1092        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0]).unwrap();
1093        let y = array![1.0, 2.0, 3.0];
1094
1095        let model = Ridge::<f64>::new();
1096        let fitted = model.fit(&x, &y).unwrap();
1097
1098        assert_eq!(fitted.coefficients().len(), 2);
1099    }
1100
1101    #[test]
1102    fn ridge_fit_sample_weight_with_intercept_matches_sklearn() -> Result<(), FerroError> {
1103        // Live sklearn 1.5.2 oracle (WEIGHTED ridge, alpha=1, fit_intercept=True):
1104        //   cd /tmp && python3 -c "import numpy as np; \
1105        //     from sklearn.linear_model import Ridge; \
1106        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1107        //     y=np.array([3.0,2.5,7.1,6.0,11.2]); w=np.array([1.,4.,1.,1.,3.]); \
1108        //     m=Ridge(alpha=1.0).fit(X,y,sample_weight=w); \
1109        //     print([round(c,10) for c in m.coef_], round(m.intercept_,10))"
1110        //   -> [0.9233502538, 1.39678511] -0.8033840948
1111        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1112            .map_err(|e| FerroError::ShapeMismatch {
1113                expected: vec![5, 2],
1114                actual: vec![],
1115                context: e.to_string(),
1116            })?;
1117        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1118        let w = array![1.0, 4.0, 1.0, 1.0, 3.0];
1119
1120        let model = Ridge::<f64>::new().with_alpha(1.0);
1121        let fitted = model.fit_with_sample_weight(&x, &y, Some(&w))?;
1122
1123        assert_relative_eq!(fitted.coefficients()[0], 0.923_350_253_8, epsilon = 1e-7);
1124        assert_relative_eq!(fitted.coefficients()[1], 1.396_785_11, epsilon = 1e-7);
1125        assert_relative_eq!(fitted.intercept(), -0.803_384_094_8, epsilon = 1e-7);
1126
1127        // Non-tautological: the weighted result MUST differ from the unweighted
1128        // fit (oracle unweighted coef_ [0.8228070175, 1.3561403509]).
1129        let unweighted = model.fit(&x, &y)?;
1130        assert_relative_eq!(
1131            unweighted.coefficients()[0],
1132            0.822_807_017_5,
1133            epsilon = 1e-7
1134        );
1135        assert_relative_eq!(
1136            unweighted.coefficients()[1],
1137            1.356_140_350_9,
1138            epsilon = 1e-7
1139        );
1140        assert!((fitted.coefficients()[0] - unweighted.coefficients()[0]).abs() > 1e-3);
1141        assert!((fitted.intercept() - unweighted.intercept()).abs() > 1e-3);
1142        Ok(())
1143    }
1144
1145    #[test]
1146    fn ridge_fit_sample_weight_no_intercept_matches_sklearn() -> Result<(), FerroError> {
1147        // Live sklearn 1.5.2 oracle (WEIGHTED ridge, alpha=2, fit_intercept=False):
1148        //   cd /tmp && python3 -c "import numpy as np; \
1149        //     from sklearn.linear_model import Ridge; \
1150        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1151        //     y=np.array([3.0,2.5,7.1,6.0,11.2]); w=np.array([1.,4.,1.,1.,3.]); \
1152        //     m=Ridge(alpha=2.0,fit_intercept=False).fit(X,y,sample_weight=w); \
1153        //     print([round(c,10) for c in m.coef_])"
1154        //   -> [0.7273779983, 1.3737799835]
1155        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1156            .map_err(|e| FerroError::ShapeMismatch {
1157                expected: vec![5, 2],
1158                actual: vec![],
1159                context: e.to_string(),
1160            })?;
1161        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1162        let w = array![1.0, 4.0, 1.0, 1.0, 3.0];
1163
1164        let model = Ridge::<f64>::new()
1165            .with_alpha(2.0)
1166            .with_fit_intercept(false);
1167        let fitted = model.fit_with_sample_weight(&x, &y, Some(&w))?;
1168
1169        assert_relative_eq!(fitted.coefficients()[0], 0.727_377_998_3, epsilon = 1e-7);
1170        assert_relative_eq!(fitted.coefficients()[1], 1.373_779_983_5, epsilon = 1e-7);
1171        assert_eq!(fitted.intercept(), 0.0);
1172        Ok(())
1173    }
1174
1175    #[test]
1176    fn ridge_fit_none_sample_weight_equals_unweighted() -> Result<(), FerroError> {
1177        // Regression guard: the `None` path is BYTE-IDENTICAL to `fit`.
1178        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1179            .map_err(|e| FerroError::ShapeMismatch {
1180                expected: vec![5, 2],
1181                actual: vec![],
1182                context: e.to_string(),
1183            })?;
1184        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1185
1186        let model = Ridge::<f64>::new().with_alpha(1.0);
1187        let via_fit = model.fit(&x, &y)?;
1188        let via_none = model.fit_with_sample_weight(&x, &y, None)?;
1189
1190        assert_eq!(
1191            via_fit.coefficients()[0].to_bits(),
1192            via_none.coefficients()[0].to_bits()
1193        );
1194        assert_eq!(
1195            via_fit.coefficients()[1].to_bits(),
1196            via_none.coefficients()[1].to_bits()
1197        );
1198        assert_eq!(
1199            via_fit.intercept().to_bits(),
1200            via_none.intercept().to_bits()
1201        );
1202
1203        // Same for fit_intercept=false.
1204        let model_ni = Ridge::<f64>::new()
1205            .with_alpha(1.0)
1206            .with_fit_intercept(false);
1207        let via_fit_ni = model_ni.fit(&x, &y)?;
1208        let via_none_ni = model_ni.fit_with_sample_weight(&x, &y, None)?;
1209        assert_eq!(
1210            via_fit_ni.coefficients()[0].to_bits(),
1211            via_none_ni.coefficients()[0].to_bits()
1212        );
1213        assert_eq!(
1214            via_fit_ni.intercept().to_bits(),
1215            via_none_ni.intercept().to_bits()
1216        );
1217        Ok(())
1218    }
1219
1220    // -- copy_x / random_state ABI-parity ----------------------------------
1221
1222    #[test]
1223    fn ridge_copy_x_random_state_defaults_and_builders() -> Result<(), FerroError> {
1224        // Defaults mirror sklearn Ridge(copy_X=True, random_state=None)
1225        // (`sklearn/linear_model/_ridge.py:898`/`:903`).
1226        assert!(Ridge::<f64>::new().copy_x, "copy_x default must be true");
1227        assert_eq!(
1228            Ridge::<f64>::new().random_state,
1229            None,
1230            "random_state default must be None"
1231        );
1232
1233        // Builders store the supplied value.
1234        assert!(!Ridge::<f64>::new().with_copy_x(false).copy_x);
1235        assert!(Ridge::<f64>::new().with_copy_x(true).copy_x);
1236        assert_eq!(
1237            Ridge::<f64>::new().with_random_state(Some(42)).random_state,
1238            Some(42)
1239        );
1240        assert_eq!(
1241            Ridge::<f64>::new().with_random_state(None).random_state,
1242            None
1243        );
1244
1245        // No behavior change: fit produces byte-identical coef_/intercept_
1246        // regardless of copy_x or random_state (deterministic Cholesky
1247        // solver is unaffected by either param).
1248        //
1249        // Live sklearn oracle (alpha=1.0, fit_intercept=true):
1250        //   python3 -c "import numpy as np; from sklearn.linear_model import Ridge;
1251        //     X=np.array([[1.,2.],[3.,4.],[5.,6.],[7.,8.],[9.,10.]]);
1252        //     y=np.array([1.,2.,3.,4.,5.]);
1253        //     m=Ridge(alpha=1.0).fit(X,y); print(m.coef_.tolist(), m.intercept_)"
1254        //   -> [0.07692307692307693, 0.4230769230769231] 0.0
1255        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 3., 4., 5., 6., 7., 8., 9., 10.])
1256            .map_err(|e| FerroError::ShapeMismatch {
1257                expected: vec![5, 2],
1258                actual: vec![],
1259                context: e.to_string(),
1260            })?;
1261        let y = array![1., 2., 3., 4., 5.];
1262
1263        let ref_fitted = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1264        let copy_false = Ridge::<f64>::new()
1265            .with_alpha(1.0)
1266            .with_copy_x(false)
1267            .fit(&x, &y)?;
1268        let rs_some = Ridge::<f64>::new()
1269            .with_alpha(1.0)
1270            .with_random_state(Some(42))
1271            .fit(&x, &y)?;
1272
1273        // Byte-identical (deterministic Cholesky, no mutation).
1274        assert_eq!(
1275            ref_fitted.coefficients()[0].to_bits(),
1276            copy_false.coefficients()[0].to_bits()
1277        );
1278        assert_eq!(
1279            ref_fitted.coefficients()[1].to_bits(),
1280            copy_false.coefficients()[1].to_bits()
1281        );
1282        assert_eq!(
1283            ref_fitted.intercept().to_bits(),
1284            copy_false.intercept().to_bits()
1285        );
1286        assert_eq!(
1287            ref_fitted.coefficients()[0].to_bits(),
1288            rs_some.coefficients()[0].to_bits()
1289        );
1290        assert_eq!(
1291            ref_fitted.coefficients()[1].to_bits(),
1292            rs_some.coefficients()[1].to_bits()
1293        );
1294        assert_eq!(
1295            ref_fitted.intercept().to_bits(),
1296            rs_some.intercept().to_bits()
1297        );
1298        Ok(())
1299    }
1300
1301    // -- Multi-output Ridge -------------------------------------------------
1302
1303    #[test]
1304    fn test_ridge_multi_recovers_two_targets_with_zero_alpha() {
1305        // Two synthetic targets sharing the same features:
1306        //   y1 = 2*x1 - 3*x2 + 5
1307        //   y2 = 3*x1 +   x2 - 1
1308        let n = 50;
1309        let x_data: Vec<f64> = (0..n)
1310            .flat_map(|i| {
1311                let i = i as f64;
1312                [i / 10.0, (i / 7.0).sin()]
1313            })
1314            .collect();
1315        let x = Array2::from_shape_vec((n, 2), x_data).unwrap();
1316        let y_data: Vec<f64> = (0..n)
1317            .flat_map(|i| {
1318                let x1 = i as f64 / 10.0;
1319                let x2 = (i as f64 / 7.0).sin();
1320                [2.0 * x1 - 3.0 * x2 + 5.0, 3.0 * x1 + x2 - 1.0]
1321            })
1322            .collect();
1323        let y = Array2::from_shape_vec((n, 2), y_data).unwrap();
1324
1325        let model = Ridge::<f64>::new().with_alpha(1e-8);
1326        let fitted: FittedRidgeMulti<f64> = model.fit(&x, &y).unwrap();
1327
1328        let coef = fitted.coefficients();
1329        assert_eq!(coef.shape(), &[2, 2]);
1330        // Target 0
1331        assert_relative_eq!(coef[[0, 0]], 2.0, epsilon = 1e-4);
1332        assert_relative_eq!(coef[[1, 0]], -3.0, epsilon = 1e-4);
1333        // Target 1
1334        assert_relative_eq!(coef[[0, 1]], 3.0, epsilon = 1e-4);
1335        assert_relative_eq!(coef[[1, 1]], 1.0, epsilon = 1e-4);
1336        // Intercepts
1337        assert_relative_eq!(fitted.intercepts()[0], 5.0, epsilon = 1e-4);
1338        assert_relative_eq!(fitted.intercepts()[1], -1.0, epsilon = 1e-4);
1339    }
1340
1341    #[test]
1342    fn test_ridge_multi_no_intercept() {
1343        // y = X @ B with no bias; verify intercepts come out zero and
1344        // coefficients match the OLS solve.
1345        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
1346        let y =
1347            Array2::from_shape_vec((4, 2), vec![2.0, 4.0, 4.0, 8.0, 6.0, 12.0, 8.0, 16.0]).unwrap();
1348
1349        let model = Ridge::<f64>::new()
1350            .with_alpha(0.0)
1351            .with_fit_intercept(false);
1352        let fitted = model.fit(&x, &y).unwrap();
1353
1354        // y[:, 0] = 2*x, y[:, 1] = 4*x
1355        assert_relative_eq!(fitted.coefficients()[[0, 0]], 2.0, epsilon = 1e-8);
1356        assert_relative_eq!(fitted.coefficients()[[0, 1]], 4.0, epsilon = 1e-8);
1357        assert_relative_eq!(fitted.intercepts()[0], 0.0, epsilon = 1e-12);
1358        assert_relative_eq!(fitted.intercepts()[1], 0.0, epsilon = 1e-12);
1359    }
1360
1361    #[test]
1362    fn test_ridge_multi_shrinks_with_large_alpha() {
1363        // Heavy regularization should pull all targets toward zero
1364        // coefficients (intercepts may still recover the means).
1365        let n = 20;
1366        let x = Array2::from_shape_vec((n, 1), (0..n).map(|i| i as f64).collect()).unwrap();
1367        let y_data: Vec<f64> = (0..n)
1368            .flat_map(|i| [(i as f64) * 10.0, (i as f64) * 5.0])
1369            .collect();
1370        let y = Array2::from_shape_vec((n, 2), y_data).unwrap();
1371
1372        let model = Ridge::<f64>::new()
1373            .with_alpha(1e6)
1374            .with_fit_intercept(false);
1375        let fitted = model.fit(&x, &y).unwrap();
1376
1377        assert!(fitted.coefficients()[[0, 0]].abs() < 1.0);
1378        assert!(fitted.coefficients()[[0, 1]].abs() < 1.0);
1379    }
1380
1381    #[test]
1382    fn test_ridge_multi_predict_round_trips_training_data() {
1383        // Verify Fit→Predict round-trip on the training data: the model
1384        // should reproduce y up to the regularization-induced bias.
1385        let n = 40;
1386        let x_data: Vec<f64> = (0..n)
1387            .flat_map(|i| [(i as f64) / 10.0, ((i as f64) / 3.0).cos()])
1388            .collect();
1389        let x = Array2::from_shape_vec((n, 2), x_data).unwrap();
1390        let y_data: Vec<f64> = (0..n)
1391            .flat_map(|i| {
1392                let x1 = (i as f64) / 10.0;
1393                let x2 = ((i as f64) / 3.0).cos();
1394                [1.5 * x1 + 0.7 * x2 + 0.2, -0.5 * x1 + 2.0 * x2 - 1.0]
1395            })
1396            .collect();
1397        let y = Array2::from_shape_vec((n, 2), y_data).unwrap();
1398
1399        let model = Ridge::<f64>::new().with_alpha(1e-6);
1400        let fitted = model.fit(&x, &y).unwrap();
1401        let y_hat = fitted.predict(&x).unwrap();
1402
1403        assert_eq!(y_hat.shape(), y.shape());
1404        // Maximum element-wise error across both targets.
1405        let max_err = y_hat
1406            .iter()
1407            .zip(y.iter())
1408            .map(|(a, b)| (a - b).abs())
1409            .fold(0.0_f64, f64::max);
1410        assert!(max_err < 1e-3, "max_err = {max_err}");
1411    }
1412
1413    #[test]
1414    fn test_ridge_multi_shape_mismatch() {
1415        // 5-sample X but 3-sample Y — should error.
1416        let x = Array2::from_shape_vec((5, 1), vec![1.0, 2.0, 3.0, 4.0, 5.0]).unwrap();
1417        let y = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
1418        let model = Ridge::<f64>::new();
1419        let err = <Ridge<f64> as Fit<Array2<f64>, Array2<f64>>>::fit(&model, &x, &y).unwrap_err();
1420        matches!(err, FerroError::ShapeMismatch { .. });
1421    }
1422
1423    #[test]
1424    fn test_ridge_multi_single_target_matches_single_output_path() {
1425        // The same data routed through the single-output and the
1426        // multi-output Fit impls should produce coefficients that agree
1427        // to within numerical noise. This pins the parallel paths from
1428        // drifting apart over time.
1429        let n = 30;
1430        let x = Array2::from_shape_vec(
1431            (n, 3),
1432            (0..n)
1433                .flat_map(|i| {
1434                    let i = i as f64;
1435                    [i / 10.0, (i / 5.0).sin(), (i / 11.0).cos()]
1436                })
1437                .collect(),
1438        )
1439        .unwrap();
1440        let y_1d: Array1<f64> = x
1441            .rows()
1442            .into_iter()
1443            .map(|r| 2.0 * r[0] - r[1] + 0.5 * r[2] + 3.0)
1444            .collect();
1445        let y_2d = Array2::from_shape_vec((n, 1), y_1d.to_vec()).unwrap();
1446
1447        let model = Ridge::<f64>::new().with_alpha(0.1);
1448        let single: FittedRidge<f64> = model.fit(&x, &y_1d).unwrap();
1449        let multi: FittedRidgeMulti<f64> = model.fit(&x, &y_2d).unwrap();
1450
1451        // Coefficients agree element-wise.
1452        for j in 0..3 {
1453            assert_relative_eq!(
1454                single.coefficients()[j],
1455                multi.coefficients()[[j, 0]],
1456                epsilon = 1e-10
1457            );
1458        }
1459        // Intercepts agree.
1460        assert_relative_eq!(single.intercept(), multi.intercepts()[0], epsilon = 1e-10);
1461    }
1462
1463    // -- max_iter / tol / n_iter_ ABI-parity (REQ-10) ----------------------
1464
1465    #[test]
1466    fn ridge_max_iter_tol_niter_defaults_and_builders() -> Result<(), FerroError> {
1467        // Defaults mirror sklearn Ridge(max_iter=None, tol=1e-4)
1468        // (`sklearn/linear_model/_ridge.py:899-900`).
1469        assert_eq!(
1470            Ridge::<f64>::new().max_iter,
1471            None,
1472            "max_iter default must be None"
1473        );
1474        assert!(
1475            (Ridge::<f64>::new().tol - 1e-4_f64).abs() < 1e-12,
1476            "tol default must be 1e-4"
1477        );
1478
1479        // Builders store the supplied value.
1480        assert_eq!(
1481            Ridge::<f64>::new().with_max_iter(Some(500)).max_iter,
1482            Some(500)
1483        );
1484        assert_eq!(Ridge::<f64>::new().with_max_iter(None).max_iter, None);
1485        assert!(
1486            (Ridge::<f64>::new().with_tol(1e-6_f64).tol - 1e-6_f64).abs() < 1e-18,
1487            "with_tol must store the supplied value"
1488        );
1489
1490        // n_iter_ is always None for the direct Cholesky solver, matching
1491        // sklearn: Ridge(alpha=1.0).fit(X,y).n_iter_ is None when
1492        // solver='auto' resolves to 'cholesky' (`_ridge.py:994`).
1493        let x = Array2::from_shape_vec((4, 2), vec![1.0, 0.0, 0.0, 1.0, 1.0, 1.0, 2.0, 2.0])
1494            .map_err(|e| FerroError::ShapeMismatch {
1495                expected: vec![4, 2],
1496                actual: vec![],
1497                context: e.to_string(),
1498            })?;
1499        let y = array![1.0, 2.0, 3.0, 6.0];
1500
1501        let fitted = Ridge::<f64>::new().fit(&x, &y)?;
1502        assert_eq!(
1503            fitted.n_iter(),
1504            None,
1505            "n_iter_ must be None for direct Cholesky"
1506        );
1507
1508        // No behavior change: fit produces byte-identical coef_/intercept_
1509        // regardless of max_iter or tol (direct Cholesky is unaffected).
1510        //
1511        // Live sklearn oracle (alpha=1.0, fit_intercept=true):
1512        //   python3 -c "import numpy as np; from sklearn.linear_model import Ridge;
1513        //     X=np.array([[1.,0.],[0.,1.],[1.,1.],[2.,2.]]);
1514        //     y=np.array([1.,2.,3.,6.]);
1515        //     m=Ridge(alpha=1.0).fit(X,y); print(m.coef_.tolist(), m.intercept_)"
1516        //   -> [0.875, 1.375] 0.75
1517        let ref_fitted = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1518        let with_max_iter = Ridge::<f64>::new()
1519            .with_alpha(1.0)
1520            .with_max_iter(Some(500))
1521            .fit(&x, &y)?;
1522        let with_tol = Ridge::<f64>::new()
1523            .with_alpha(1.0)
1524            .with_tol(1e-6_f64)
1525            .fit(&x, &y)?;
1526
1527        // Byte-identical (deterministic Cholesky, max_iter/tol are no-ops).
1528        for j in 0..2 {
1529            assert_eq!(
1530                ref_fitted.coefficients()[j].to_bits(),
1531                with_max_iter.coefficients()[j].to_bits(),
1532                "coef_[{j}] must be byte-identical regardless of max_iter"
1533            );
1534            assert_eq!(
1535                ref_fitted.coefficients()[j].to_bits(),
1536                with_tol.coefficients()[j].to_bits(),
1537                "coef_[{j}] must be byte-identical regardless of tol"
1538            );
1539        }
1540        assert_eq!(
1541            ref_fitted.intercept().to_bits(),
1542            with_max_iter.intercept().to_bits()
1543        );
1544        assert_eq!(
1545            ref_fitted.intercept().to_bits(),
1546            with_tol.intercept().to_bits()
1547        );
1548        assert_eq!(with_max_iter.n_iter(), None);
1549        assert_eq!(with_tol.n_iter(), None);
1550
1551        Ok(())
1552    }
1553
1554    // -- positive=True non-negative ridge (REQ-9) --------------------------
1555
1556    #[test]
1557    fn ridge_positive_matches_sklearn() -> Result<(), FerroError> {
1558        // Live sklearn 1.5.2 oracle (non-negative ridge, alpha=1, positive=True):
1559        //   cd /tmp && python3 -c "import numpy as np; \
1560        //     from sklearn.linear_model import Ridge; \
1561        //     X=np.array([[1.,3.],[2.,1.],[3.,4.],[4.,2.],[5.,5.],[6.,1.],[2.,4.],[5.,2.]]); \
1562        //     y=1.0*X[:,0]-2.0*X[:,1]+np.array([0.1,-0.2,0.15,0.0,-0.1,0.05,0.2,-0.05]); \
1563        //     m=Ridge(alpha=1.0,positive=True).fit(X,y); \
1564        //     print([round(c,8) for c in m.coef_], round(m.intercept_,8))"
1565        //   -> [1.19891304, 0.0] -6.17744565
1566        let x = Array2::from_shape_vec(
1567            (8, 2),
1568            vec![
1569                1., 3., 2., 1., 3., 4., 4., 2., 5., 5., 6., 1., 2., 4., 5., 2.,
1570            ],
1571        )
1572        .map_err(|e| FerroError::ShapeMismatch {
1573            expected: vec![8, 2],
1574            actual: vec![],
1575            context: e.to_string(),
1576        })?;
1577        let raw = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 2.0, 5.0];
1578        let f1 = [3.0, 1.0, 4.0, 2.0, 5.0, 1.0, 4.0, 2.0];
1579        let noise = [0.1, -0.2, 0.15, 0.0, -0.1, 0.05, 0.2, -0.05];
1580        let y: Array1<f64> = (0..8).map(|i| raw[i] - 2.0 * f1[i] + noise[i]).collect();
1581
1582        let model = Ridge::<f64>::new().with_alpha(1.0).with_positive(true);
1583        let fitted = model.fit(&x, &y)?;
1584
1585        assert_relative_eq!(fitted.coefficients()[0], 1.198_913_04, epsilon = 1e-5);
1586        assert_relative_eq!(fitted.coefficients()[1], 0.0, epsilon = 1e-5);
1587        assert_relative_eq!(fitted.intercept(), -6.177_445_65, epsilon = 1e-4);
1588
1589        // All coefficients are non-negative.
1590        for &c in fitted.coefficients().iter() {
1591            assert!(
1592                c >= 0.0,
1593                "coef {c} must be non-negative under positive=True"
1594            );
1595        }
1596
1597        // Non-tautological: the constrained fit MUST differ from the
1598        // unconstrained ridge (oracle unconstrained coef_
1599        // [0.95708502, -1.85401484], intercept -0.23250675 — feature 1
1600        // is strongly negative and clamps to 0).
1601        let unconstrained = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1602        assert_relative_eq!(
1603            unconstrained.coefficients()[0],
1604            0.957_085_02,
1605            epsilon = 1e-5
1606        );
1607        assert_relative_eq!(
1608            unconstrained.coefficients()[1],
1609            -1.854_014_84,
1610            epsilon = 1e-5
1611        );
1612        assert!(
1613            (fitted.coefficients()[1] - unconstrained.coefficients()[1]).abs() > 1e-2,
1614            "positive fit must differ from unconstrained on the clamped coordinate"
1615        );
1616        Ok(())
1617    }
1618
1619    #[test]
1620    fn ridge_positive_false_unchanged() -> Result<(), FerroError> {
1621        // Regression guard: positive=false (default) is BYTE-IDENTICAL to the
1622        // current unconstrained Cholesky `fit`.
1623        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1624            .map_err(|e| FerroError::ShapeMismatch {
1625                expected: vec![5, 2],
1626                actual: vec![],
1627                context: e.to_string(),
1628            })?;
1629        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1630
1631        let model = Ridge::<f64>::new().with_alpha(1.0);
1632        let baseline = model.fit(&x, &y)?;
1633        let explicit_false = Ridge::<f64>::new()
1634            .with_alpha(1.0)
1635            .with_positive(false)
1636            .fit(&x, &y)?;
1637
1638        for j in 0..2 {
1639            assert_eq!(
1640                baseline.coefficients()[j].to_bits(),
1641                explicit_false.coefficients()[j].to_bits(),
1642                "coef_[{j}] must be byte-identical with positive=false"
1643            );
1644        }
1645        assert_eq!(
1646            baseline.intercept().to_bits(),
1647            explicit_false.intercept().to_bits()
1648        );
1649        // positive=false keeps the direct-solver n_iter_ = None contract.
1650        assert_eq!(explicit_false.n_iter(), None);
1651        Ok(())
1652    }
1653
1654    #[test]
1655    fn ridge_positive_all_nonneg_equals_unconstrained() -> Result<(), FerroError> {
1656        // Sanity: data whose unconstrained ridge is already non-negative —
1657        // the box constraint is inactive, so positive=True must reproduce the
1658        // unconstrained coefficients.
1659        //
1660        // Live sklearn 1.5.2 oracle:
1661        //   cd /tmp && python3 -c "import numpy as np; \
1662        //     from sklearn.linear_model import Ridge; \
1663        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1664        //     y=np.array([3.0,2.5,7.1,6.0,11.2]); \
1665        //     u=Ridge(alpha=1.0).fit(X,y); p=Ridge(alpha=1.0,positive=True).fit(X,y); \
1666        //     print([round(c,8) for c in u.coef_], [round(c,8) for c in p.coef_])"
1667        //   -> [0.82280702, 1.35614035] [0.82280702, 1.35614035]
1668        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1669            .map_err(|e| FerroError::ShapeMismatch {
1670                expected: vec![5, 2],
1671                actual: vec![],
1672                context: e.to_string(),
1673            })?;
1674        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1675
1676        let unconstrained = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1677        let positive = Ridge::<f64>::new()
1678            .with_alpha(1.0)
1679            .with_positive(true)
1680            .fit(&x, &y)?;
1681
1682        for j in 0..2 {
1683            assert!(unconstrained.coefficients()[j] >= 0.0);
1684            // CD converges to the unconstrained optimum at the default
1685            // tol=1e-4; sklearn's own L-BFGS-B agrees only to ~1e-6 on this
1686            // fixture (oracle: 0.82280707 vs 0.82280702).
1687            assert_relative_eq!(
1688                positive.coefficients()[j],
1689                unconstrained.coefficients()[j],
1690                epsilon = 1e-4
1691            );
1692        }
1693        assert_relative_eq!(
1694            positive.intercept(),
1695            unconstrained.intercept(),
1696            epsilon = 1e-4
1697        );
1698        Ok(())
1699    }
1700
1701    // -- per-target alpha array (REQ-7) ------------------------------------
1702
1703    /// Build the shared 5x2 fixture `X` and 5x2 `Y` used by the per-target
1704    /// alpha tests.
1705    fn per_target_fixture() -> Result<(Array2<f64>, Array2<f64>), FerroError> {
1706        let shape_err = |e: ndarray::ShapeError| FerroError::ShapeMismatch {
1707            expected: vec![5, 2],
1708            actual: vec![],
1709            context: e.to_string(),
1710        };
1711        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1712            .map_err(shape_err)?;
1713        let y = Array2::from_shape_vec(
1714            (5, 2),
1715            vec![3.0, 1.0, 2.5, 2.0, 7.1, 3.5, 6.0, 4.2, 11.2, 6.0],
1716        )
1717        .map_err(shape_err)?;
1718        Ok((x, y))
1719    }
1720
1721    #[test]
1722    fn ridge_per_target_alpha_matches_sklearn() -> Result<(), FerroError> {
1723        // Live sklearn 1.5.2 oracle (per-target alpha [0.5, 2.0]):
1724        //   cd /tmp && python3 -c "import numpy as np; \
1725        //     from sklearn.linear_model import Ridge; \
1726        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1727        //     Y=np.array([[3.,1.],[2.5,2.],[7.1,3.5],[6.,4.2],[11.2,6.]]); \
1728        //     m=Ridge(alpha=np.array([0.5,2.0])).fit(X,Y); \
1729        //     print(m.coef_.tolist(), m.intercept_.tolist())"
1730        //   -> coef_ (n_targets, n_features) = [[0.79891892, 1.43891892],
1731        //                                       [0.78, 0.355]]
1732        //      intercept_ = [-0.75351351, -0.065]
1733        // ferrolearn stores coefficients as (n_features, n_targets) = the
1734        // TRANSPOSE: column 0 = target 0, column 1 = target 1.
1735        let (x, y) = per_target_fixture()?;
1736
1737        let model = Ridge::<f64>::new().with_alpha_per_target(array![0.5, 2.0]);
1738        let fitted = model.fit(&x, &y)?;
1739
1740        let c0 = fitted.coefficients().column(0).to_owned();
1741        let c1 = fitted.coefficients().column(1).to_owned();
1742        assert_relative_eq!(c0[0], 0.798_918_92, epsilon = 1e-6);
1743        assert_relative_eq!(c0[1], 1.438_918_92, epsilon = 1e-6);
1744        assert_relative_eq!(c1[0], 0.78, epsilon = 1e-6);
1745        assert_relative_eq!(c1[1], 0.355, epsilon = 1e-6);
1746
1747        assert_relative_eq!(fitted.intercepts()[0], -0.753_513_51, epsilon = 1e-6);
1748        assert_relative_eq!(fitted.intercepts()[1], -0.065, epsilon = 1e-6);
1749        Ok(())
1750    }
1751
1752    #[test]
1753    fn ridge_per_target_alpha_equals_independent_scalar_fits() -> Result<(), FerroError> {
1754        // The per-target array path must reproduce, column by column, what an
1755        // independent scalar-alpha Ridge produces on each target column via the
1756        // single-output (1-D) `fit` path.
1757        let (x, y) = per_target_fixture()?;
1758        let alphas = array![0.5, 2.0];
1759
1760        let multi = Ridge::<f64>::new()
1761            .with_alpha_per_target(alphas.clone())
1762            .fit(&x, &y)?;
1763
1764        for k in 0..2 {
1765            let y_col = y.column(k).to_owned();
1766            let scalar = Ridge::<f64>::new().with_alpha(alphas[k]).fit(&x, &y_col)?;
1767            for j in 0..2 {
1768                assert_relative_eq!(
1769                    multi.coefficients()[[j, k]],
1770                    scalar.coefficients()[j],
1771                    epsilon = 1e-9
1772                );
1773            }
1774            assert_relative_eq!(multi.intercepts()[k], scalar.intercept(), epsilon = 1e-9);
1775        }
1776        Ok(())
1777    }
1778
1779    #[test]
1780    fn ridge_per_target_alpha_length_mismatch_errors() -> Result<(), FerroError> {
1781        // alpha array of length 1 with 2 target columns must error.
1782        let (x, y) = per_target_fixture()?;
1783
1784        let model = Ridge::<f64>::new().with_alpha_per_target(array![0.5]);
1785        let result = <Ridge<f64> as Fit<Array2<f64>, Array2<f64>>>::fit(&model, &x, &y);
1786        assert!(matches!(result, Err(FerroError::ShapeMismatch { .. })));
1787        Ok(())
1788    }
1789
1790    #[test]
1791    fn ridge_multi_scalar_alpha_unchanged() -> Result<(), FerroError> {
1792        // Regression guard: with NO per-target array, the multi-output scalar
1793        // path is byte-identical to the historic `solve_ridge_multi` behavior.
1794        //
1795        // Live sklearn 1.5.2 oracle (scalar alpha=1.0):
1796        //   cd /tmp && python3 -c "import numpy as np; \
1797        //     from sklearn.linear_model import Ridge; \
1798        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1799        //     Y=np.array([[3.,1.],[2.5,2.],[7.1,3.5],[6.,4.2],[11.2,6.]]); \
1800        //     m=Ridge(alpha=1.0).fit(X,Y); \
1801        //     print(m.coef_.tolist(), m.intercept_.tolist())"
1802        let (x, y) = per_target_fixture()?;
1803
1804        let baseline = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1805        let explicit_none = Ridge::<f64> {
1806            alpha_per_target: None,
1807            ..Ridge::<f64>::new().with_alpha(1.0)
1808        }
1809        .fit(&x, &y)?;
1810
1811        for j in 0..2 {
1812            for k in 0..2 {
1813                assert_eq!(
1814                    baseline.coefficients()[[j, k]].to_bits(),
1815                    explicit_none.coefficients()[[j, k]].to_bits(),
1816                    "coef_[{j},{k}] must be byte-identical without per-target alpha"
1817                );
1818            }
1819        }
1820        for k in 0..2 {
1821            assert_eq!(
1822                baseline.intercepts()[k].to_bits(),
1823                explicit_none.intercepts()[k].to_bits(),
1824                "intercept_[{k}] must be byte-identical without per-target alpha"
1825            );
1826        }
1827        Ok(())
1828    }
1829
1830    // -- solver variants (auto/cholesky/svd) + solver_ (REQ-8a) ------------
1831
1832    /// Build the shared 5x2 fixture `X` and 1-D `y` used by the solver tests.
1833    fn solver_fixture() -> Result<(Array2<f64>, Array1<f64>), FerroError> {
1834        let x = Array2::from_shape_vec((5, 2), vec![1., 2., 2., 1., 3., 4., 4., 3., 5., 5.])
1835            .map_err(|e| FerroError::ShapeMismatch {
1836                expected: vec![5, 2],
1837                actual: vec![],
1838                context: e.to_string(),
1839            })?;
1840        let y = array![3.0, 2.5, 7.1, 6.0, 11.2];
1841        Ok((x, y))
1842    }
1843
1844    #[test]
1845    fn ridge_solver_svd_matches_sklearn_and_cholesky() -> Result<(), FerroError> {
1846        // Live sklearn 1.5.2 oracle (alpha=1, fit_intercept=True, solver='svd'):
1847        //   cd /tmp && python3 -c "import numpy as np; \
1848        //     from sklearn.linear_model import Ridge; \
1849        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.]]); \
1850        //     y=np.array([3.0,2.5,7.1,6.0,11.2]); \
1851        //     m=Ridge(alpha=1.0,solver='svd').fit(X,y); \
1852        //     print([round(c,10) for c in m.coef_], round(m.intercept_,10))"
1853        //   -> [0.8228070175, 1.3561403509] -0.5768421053
1854        // Every dense solver returns this same unique ridge solution.
1855        let (x, y) = solver_fixture()?;
1856
1857        let svd_fit = Ridge::<f64>::new()
1858            .with_alpha(1.0)
1859            .with_solver(RidgeSolver::Svd)
1860            .fit(&x, &y)?;
1861
1862        assert_relative_eq!(svd_fit.coefficients()[0], 0.822_807_017_5, epsilon = 1e-7);
1863        assert_relative_eq!(svd_fit.coefficients()[1], 1.356_140_350_9, epsilon = 1e-7);
1864        assert_relative_eq!(svd_fit.intercept(), -0.576_842_105_3, epsilon = 1e-7);
1865
1866        // SVD and Cholesky solve the same strictly-convex (unique) problem, so
1867        // they must agree to ~1e-9.
1868        let chol_fit = Ridge::<f64>::new()
1869            .with_alpha(1.0)
1870            .with_solver(RidgeSolver::Cholesky)
1871            .fit(&x, &y)?;
1872        for j in 0..2 {
1873            assert_relative_eq!(
1874                svd_fit.coefficients()[j],
1875                chol_fit.coefficients()[j],
1876                epsilon = 1e-9
1877            );
1878        }
1879        assert_relative_eq!(svd_fit.intercept(), chol_fit.intercept(), epsilon = 1e-9);
1880        Ok(())
1881    }
1882
1883    #[test]
1884    fn ridge_solver_resolution() -> Result<(), FerroError> {
1885        // Default `solver` is Auto; the fitted `solver_` resolves Auto→Cholesky
1886        // for the dense path (sklearn `resolve_solver`, `_ridge.py:830`).
1887        assert_eq!(Ridge::<f64>::new().solver, RidgeSolver::Auto);
1888
1889        let (x, y) = solver_fixture()?;
1890
1891        let auto_fit = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1892        assert_eq!(
1893            auto_fit.solver(),
1894            RidgeSolver::Cholesky,
1895            "Auto must resolve to Cholesky for the dense path"
1896        );
1897
1898        let svd_fit = Ridge::<f64>::new()
1899            .with_alpha(1.0)
1900            .with_solver(RidgeSolver::Svd)
1901            .fit(&x, &y)?;
1902        assert_eq!(svd_fit.solver(), RidgeSolver::Svd);
1903
1904        let chol_fit = Ridge::<f64>::new()
1905            .with_alpha(1.0)
1906            .with_solver(RidgeSolver::Cholesky)
1907            .fit(&x, &y)?;
1908        assert_eq!(chol_fit.solver(), RidgeSolver::Cholesky);
1909        Ok(())
1910    }
1911
1912    #[test]
1913    fn ridge_solver_default_cholesky_unchanged() -> Result<(), FerroError> {
1914        // Regression guard: the default (Auto→Cholesky) coef_/intercept_ are
1915        // BYTE-IDENTICAL to a pre-existing direct `fit` (no `with_solver`).
1916        let (x, y) = solver_fixture()?;
1917
1918        let direct = Ridge::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1919        let explicit_chol = Ridge::<f64>::new()
1920            .with_alpha(1.0)
1921            .with_solver(RidgeSolver::Cholesky)
1922            .fit(&x, &y)?;
1923        let explicit_auto = Ridge::<f64>::new()
1924            .with_alpha(1.0)
1925            .with_solver(RidgeSolver::Auto)
1926            .fit(&x, &y)?;
1927
1928        for j in 0..2 {
1929            assert_eq!(
1930                direct.coefficients()[j].to_bits(),
1931                explicit_chol.coefficients()[j].to_bits(),
1932                "coef_[{j}] must be byte-identical for explicit Cholesky"
1933            );
1934            assert_eq!(
1935                direct.coefficients()[j].to_bits(),
1936                explicit_auto.coefficients()[j].to_bits(),
1937                "coef_[{j}] must be byte-identical for explicit Auto"
1938            );
1939        }
1940        assert_eq!(
1941            direct.intercept().to_bits(),
1942            explicit_chol.intercept().to_bits()
1943        );
1944        assert_eq!(
1945            direct.intercept().to_bits(),
1946            explicit_auto.intercept().to_bits()
1947        );
1948        Ok(())
1949    }
1950
1951    #[test]
1952    fn ridge_solver_svd_no_intercept() -> Result<(), FerroError> {
1953        // With fit_intercept=false, the SVD solver must match the Cholesky
1954        // no-intercept fit (same unique solution) to ~1e-9. sklearn:
1955        //   Ridge(alpha=1.0, fit_intercept=False, solver='svd') ==
1956        //   Ridge(alpha=1.0, fit_intercept=False, solver='cholesky').
1957        let (x, y) = solver_fixture()?;
1958
1959        let svd_fit = Ridge::<f64>::new()
1960            .with_alpha(1.0)
1961            .with_fit_intercept(false)
1962            .with_solver(RidgeSolver::Svd)
1963            .fit(&x, &y)?;
1964        let chol_fit = Ridge::<f64>::new()
1965            .with_alpha(1.0)
1966            .with_fit_intercept(false)
1967            .with_solver(RidgeSolver::Cholesky)
1968            .fit(&x, &y)?;
1969
1970        for j in 0..2 {
1971            assert_relative_eq!(
1972                svd_fit.coefficients()[j],
1973                chol_fit.coefficients()[j],
1974                epsilon = 1e-9
1975            );
1976        }
1977        assert_eq!(svd_fit.intercept(), 0.0);
1978        assert_eq!(chol_fit.intercept(), 0.0);
1979        assert_eq!(svd_fit.solver(), RidgeSolver::Svd);
1980        Ok(())
1981    }
1982}