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}