Skip to main content

ferrolearn_linear/
ridge_classifier_cv.rs

1//! Ridge classifier with built-in cross-validation for alpha selection.
2//!
3//! This module provides [`RidgeClassifierCV`], the cross-validated variant of
4//! [`crate::RidgeClassifier`]. It mirrors scikit-learn's
5//! `class RidgeClassifierCV` (`sklearn/linear_model/_ridge.py:2676`): the target
6//! is binarized with a `LabelBinarizer(pos_label=1, neg_label=-1)` (binary →
7//! single `{-1, +1}` column, multiclass → one-hot `{-1, +1}` columns), and a
8//! SHARED regularization strength `alpha` is selected by efficient leave-one-out
9//! Generalized Cross-Validation over the binarized multi-target problem
10//! (`_RidgeGCV`, `_ridge.py:1688`). For `scoring=None` (the default), sklearn
11//! scores each candidate by `-squared_errors.mean()` where
12//! `squared_errors = (c / G_inverse_diag) ** 2` is shape `(n_samples, n_y)`
13//! (`_ridge.py:2148-2150` + `_score_without_scorer`, `_ridge.py:2211-2218`):
14//! the closed-form LOO errors are summed over BOTH samples and indicator
15//! columns, sharing a single matrix decomposition across all `alphas`. The
16//! single chosen `alpha_` then drives a final multi-output Ridge refit on the
17//! indicator matrix, recovering `coef_`/`intercept_` exactly as
18//! [`crate::RidgeClassifier`] does; prediction is the binary sign / multiclass
19//! argmax of the decision function.
20//!
21//! The GCV closed form (centering for `fit_intercept`, the per-alpha
22//! `G_inverse_diag`/`c` computation, intercept-dimension cancellation) is
23//! REPLICATED from [`crate::ridge_cv`]'s verified 1-D `_RidgeGCV` path, extended
24//! to accumulate the squared LOO errors over every indicator-target column
25//! against the SAME shared hat matrix (the hat-matrix diagonal depends only on
26//! `X + alpha`, not on the target).
27//!
28//! ## REQ status
29//!
30//! Two states only (SHIPPED / NOT-STARTED), per goal.md R-DEFER-2.
31//!
32//! See `.design/linear/ridge_classifier.md` for the full requirements table.
33//!
34//! | REQ | Status | Evidence |
35//! |---|---|---|
36//! | REQ-10 (`RidgeClassifierCV`) | SHIPPED | this module: shared-alpha LOO-GCV over the binarized indicator targets (`_ridge.py:2676` + `_RidgeGCV`, `_ridge.py:1688`), final multi-output Ridge refit. |
37//! | REQ-11a (store_cv_results/cv_results_) | SHIPPED | #2248. `RidgeClassifierCV<F>` adds `pub store_cv_results: bool` (default `false`, mirroring sklearn's documented default, `_ridge.py:2547`/`:2727`; the ctor `store_cv_results=None` sentinel resolves to `False`, `_ridge.py:2349-2350`) + `with_store_cv_results` builder + getter. `fn gcv_scores_svd`/`fn gcv_scores_eigen in ridge_classifier_cv.rs` retain the un-summed per-sample-per-target squared LOO errors `(c / G_inverse_diag)²` (the SAME terms the alpha-selection sums) into an `Array3<F>` shaped `(n_samples, n_targets, n_alphas)` — alpha axis = input `alphas` order, target axis = indicator columns — mirroring sklearn `cv_results_` (`squared_errors.ravel()`, `_ridge.py:2152`; reshape to `(n_samples, n_y, n_alphas)`, `_ridge.py:2199-2204`). `FittedRidgeClassifierCV<F>` stores `cv_results_: Option<Array3<F>>` + `pub fn cv_results(&self) -> Option<&Array3<F>>` (`None` when `store_cv_results=false`). The selection sum is byte-identical regardless of the flag, so `alpha_`/`coef_`/`predict` are unchanged. Non-test consumer: crate-root re-export `pub use ridge_classifier_cv::{RidgeClassifierCV, FittedRidgeClassifierCV}` in `lib.rs`. Verification (live sklearn 1.5.2, R-CHAR-3): binary `store_cv_results=True` on `oracle_x`, `y=[0,0,0,0,0,1,1,1]`, `alphas=[0.1,1,10]` → `cv_results_` shape `(8,1,3)` values `[[0.01905,0.014503,0.000155],…,[0.595118,0.52245,0.111111]]`; 3-class `y=[0,0,1,1,2,2,1,0]` → `(8,3,3)` per-target; non-monotone `alphas=[10,0.1,1]` → axis-0 ↔ alpha 10. Tests `ridge_classifier_cv_store_cv_results_binary_matches_sklearn`, `…_multiclass_…`, `…_none_default`, `…_alpha_axis_order` PASS. |
38//! | REQ-11b (scoring/cv/class_weight/store_cv_values ctor params) | NOT-STARTED | #2248. sklearn's `RidgeClassifierCV` ctor also exposes `scoring` (when set, `cv_results_` stores predictions/scores not squared errors, `_ridge.py:2153-2156`/`:2211-2218`), `cv` (actual k-fold instead of the GCV path), `class_weight` (`_ridge.py:2812`/`:2836`), and the DEPRECATED `store_cv_values` alias (`_ridge.py:2826`/`:2333-2348`). ferrolearn carries only `alphas`/`fit_intercept`/`store_cv_results`; these remain unimplemented. |
39//!
40//! ## Documented precision caveat — eigen-path alpha selection at a degenerate
41//! fp-tie (#2253)
42//!
43//! On a DEGENERATE eigen-path fixture where the closed-form leave-one-out
44//! squared errors are mathematically equal across every candidate `alpha` — a
45//! near-perfect-fit point such as `RidgeClassifierCV(alphas=[0.1,1,10])` on the
46//! 2-sample / 2-feature `X=[[1,2],[6,5]]`, `y=[0,1]` (`n_samples (2) <=
47//! n_features (2)` → the `gcv_scores_eigen` Gram path) — ferrolearn's selected
48//! `alpha_` can diverge from scikit-learn's by the floating-point ORDER of the
49//! eigendecomposition, NOT by any formula difference.
50//!
51//! The GCV formula here is byte-for-byte structurally identical to sklearn's
52//! `_solve_eigen_gram` (`_ridge.py:1914-1933`): `w = 1/(eigvals+alpha)`,
53//! intercept-dim regularization cancelled (`_ridge.py:1928`), `c = Q·(w⊙Qᵀy)`
54//! (`_ridge.py:1930`), `G_inverse_diag = Σ_j w_j Q[:,j]²` (`_ridge.py:1931`),
55//! `squared_errors = (c/G_inverse_diag)²` (`_ridge.py:2149`), `score =
56//! -squared_errors.mean()` (`_ridge.py:2148`/`:2216`), strict `> best_score`
57//! update keeping the first/smallest-index alpha on a tie (`_ridge.py:2185`).
58//! The DIVERGENCE is upstream of that formula: at the degenerate Gram matrix
59//! `K = [[9.5,-7.5],[-7.5,9.5]]` the symmetric eigenvector entry is exactly
60//! `±1/√2`, but ferray's `eigh` (`ferray-linalg/src/decomp/eigen.rs`) rounds it
61//! to `0x3fe6a09e667f3bcd = 0.707106781186547_6` while scipy's LAPACK rounds to
62//! `0x3fe6a09e667f3bcc = 0.707106781186547_5` (a 1-ULP difference). With
63//! ferray's value the scalar-accumulated `(c/G_inverse_diag)²` collapses to
64//! bit-exact `4.0` for ALL three alphas (a TRUE tie → first-wins keeps
65//! `alpha_=0.1`), whereas scipy's LAPACK rounding leaves a ~2e-15 excess on the
66//! smaller-alpha means (`4.000000000000002, 4.000000000000002, 4.0`) so
67//! sklearn's strict-max picks `alpha_=10.0`.
68//!
69//! The mathematically-exact LOO squared error at this degenerate 2-point
70//! perfect-fit is the SAME constant for every alpha — sklearn's choice rests
71//! entirely on a 2e-15 fp-noise spread its LAPACK eigendecomposition happens to
72//! retain and ours happens to erase. No formula change reproduces scipy's exact
73//! spread without bit-replicating LAPACK's eigendecomposition, so this is the
74//! R-DEV-1 "documented tolerances where sklearn is NOT deterministic" boundary
75//! (analogous to the f32-ABI / RNG-order caveats), NOT a fixable divergence. The
76//! NON-degenerate eigen/SVD-path `alpha_`/`cv_results_` stay bit-exact (the
77//! `ridge_classifier_cv_*_matches_sklearn` + `…_store_cv_results_*` oracle tests
78//! and the 1-D `ridge_cv` GCV pins remain green); the caveat is STRICTLY the
79//! degenerate fp-tie. The pin
80//! `divergence_ridge_classifier_cv_alpha_select_n2_eigen`
81//! (`tests/divergence_ridge_classifier_cv_alpha_tie.rs`) stays `#[ignore]` with
82//! this rationale.
83//! # Examples
84//!
85//! ```
86//! use ferrolearn_linear::RidgeClassifierCV;
87//! use ferrolearn_core::{Fit, Predict};
88//! use ndarray::{array, Array2};
89//!
90//! let x = Array2::from_shape_vec((6, 2), vec![
91//!     1.0, 1.0, 1.0, 2.0, 2.0, 1.0,
92//!     5.0, 5.0, 5.0, 6.0, 6.0, 5.0,
93//! ]).unwrap();
94//! let y = array![0usize, 0, 0, 1, 1, 1];
95//!
96//! let model = RidgeClassifierCV::<f64>::new();
97//! let fitted = model.fit(&x, &y).unwrap();
98//! let preds = fitted.predict(&x).unwrap();
99//! assert_eq!(preds.len(), 6);
100//! ```
101
102use ferray::linalg::LinalgFloat;
103use ferray::{Array as FerrayArray, Ix2};
104use ferrolearn_core::error::FerroError;
105use ferrolearn_core::introspection::{HasClasses, HasCoefficients};
106use ferrolearn_core::traits::{Fit, Predict};
107use ndarray::{Array1, Array2, Array3, Axis, ScalarOperand};
108use num_traits::{Float, FromPrimitive};
109
110use crate::Ridge;
111
112/// Ridge classifier with built-in cross-validated alpha selection.
113///
114/// Selects a single shared regularization strength `alpha` from a candidate
115/// grid by leave-one-out Generalized Cross-Validation over the binarized
116/// indicator targets, then refits a multi-output Ridge at the chosen alpha.
117/// Mirrors scikit-learn's `RidgeClassifierCV` (`sklearn/linear_model/_ridge.py:2676`).
118///
119/// # Type Parameters
120///
121/// - `F`: The floating-point type (`f32` or `f64`).
122#[derive(Debug, Clone)]
123pub struct RidgeClassifierCV<F> {
124    /// Candidate regularization strengths to evaluate (sklearn `alphas`,
125    /// default `(0.1, 1.0, 10.0)`, `_ridge.py:2688`).
126    pub alphas: Vec<F>,
127    /// Whether to fit an intercept (bias) term (sklearn `fit_intercept`,
128    /// default `True`, `_ridge.py:2698`).
129    pub fit_intercept: bool,
130    /// Whether to retain the per-sample cross-validation results
131    /// (`cv_results_`). Mirrors sklearn `store_cv_results` (documented default
132    /// `False`, `_ridge.py:2547`/`:2727`; the ctor sentinel `None` resolves to
133    /// `False`, `_ridge.py:2349-2350`). When `true`, the fitted model exposes the
134    /// per-sample-per-target-per-alpha squared leave-one-out errors via
135    /// [`FittedRidgeClassifierCV::cv_results`].
136    pub store_cv_results: bool,
137}
138
139impl<F: Float + FromPrimitive> RidgeClassifierCV<F> {
140    /// Create a new `RidgeClassifierCV` with default settings.
141    ///
142    /// Defaults: `alphas = [0.1, 1.0, 10.0]` and `fit_intercept = true`,
143    /// mirroring sklearn's ctor defaults (`sklearn/linear_model/_ridge.py:2688`,
144    /// `:2698`).
145    #[must_use]
146    pub fn new() -> Self {
147        // `F::from(_)` returns `Option`; fall back to `one` (never hit for
148        // f32/f64 literals) rather than unwrap in library code (R-CODE-2).
149        let one = <F as num_traits::One>::one();
150        let p1 = F::from(0.1).unwrap_or(one);
151        let ten = F::from(10.0).unwrap_or(one);
152        Self {
153            alphas: vec![p1, one, ten],
154            fit_intercept: true,
155            store_cv_results: false,
156        }
157    }
158
159    /// Set the candidate regularization strengths (sklearn `alphas`,
160    /// `_ridge.py:2688`).
161    ///
162    /// Each value must be non-negative.
163    #[must_use]
164    pub fn with_alphas(mut self, alphas: Vec<F>) -> Self {
165        self.alphas = alphas;
166        self
167    }
168
169    /// Set whether to fit an intercept term (sklearn `fit_intercept`,
170    /// `_ridge.py:2698`).
171    #[must_use]
172    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
173        self.fit_intercept = fit_intercept;
174        self
175    }
176
177    /// Set whether to retain the cross-validation results (sklearn
178    /// `store_cv_results`, default `False`, `_ridge.py:2547`/`:2727`).
179    ///
180    /// When `true`, the fitted model's [`FittedRidgeClassifierCV::cv_results`]
181    /// returns the per-sample-per-target-per-alpha squared leave-one-out errors;
182    /// when `false` (the default) it returns `None`.
183    #[must_use]
184    pub fn with_store_cv_results(mut self, store_cv_results: bool) -> Self {
185        self.store_cv_results = store_cv_results;
186        self
187    }
188}
189
190impl<F: Float + FromPrimitive> Default for RidgeClassifierCV<F> {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196/// Fitted Ridge classifier with cross-validated alpha.
197///
198/// Stores the selected shared alpha, the per-class coefficient matrix, the
199/// per-class intercepts, and the sorted class labels. Implements [`Predict`],
200/// [`HasCoefficients`], and [`HasClasses`] for introspection.
201#[derive(Debug, Clone)]
202pub struct FittedRidgeClassifierCV<F> {
203    /// The shared alpha that achieved the lowest mean squared LOO error.
204    alpha_: F,
205    /// Coefficient matrix, shape `(n_classes_or_1, n_features)` matching
206    /// sklearn `coef_`. Binary problems store a single `(1, n_features)` row.
207    coefficients: Array2<F>,
208    /// First coefficient row materialized as a 1-D vector for the
209    /// [`HasCoefficients`] (1-D) contract used across the crate.
210    coefficients_row0: Array1<F>,
211    /// Per-class intercept vector, length `n_classes_or_1`.
212    intercepts: Array1<F>,
213    /// Sorted unique class labels.
214    classes: Vec<usize>,
215    /// Whether this is a binary problem (single decision column).
216    is_binary: bool,
217    /// Number of features (for the predict-time shape check).
218    n_features: usize,
219    /// Per-sample cross-validation results, shape `(n_samples, n_targets,
220    /// n_alphas)`, populated only when `store_cv_results` was set. `Some` holds
221    /// the squared leave-one-out errors `(c / G_inverse_diag)²` (sklearn
222    /// `cv_results_`, `_ridge.py:2149`/`:2199-2204`); `None` when
223    /// `store_cv_results` is `false`.
224    cv_results_: Option<Array3<F>>,
225}
226
227impl<F: Float> FittedRidgeClassifierCV<F> {
228    /// Returns the shared alpha selected by cross-validation (sklearn
229    /// `alpha_`, `_ridge.py:2766`).
230    #[must_use]
231    pub fn alpha_(&self) -> F {
232        self.alpha_
233    }
234
235    /// Alias for [`alpha_`](Self::alpha_) — the selected shared alpha.
236    #[must_use]
237    pub fn best_alpha(&self) -> F {
238        self.alpha_
239    }
240
241    /// Returns the per-class coefficient matrix, shape
242    /// `(n_classes_or_1, n_features)` (sklearn `coef_`, `_ridge.py:2757`).
243    #[must_use]
244    pub fn coefficients(&self) -> &Array2<F> {
245        &self.coefficients
246    }
247
248    /// Returns the per-class intercept vector, length `n_classes_or_1`
249    /// (sklearn `intercept_`, `_ridge.py:2762`).
250    #[must_use]
251    pub fn intercepts(&self) -> &Array1<F> {
252        &self.intercepts
253    }
254
255    /// Returns the sorted unique class labels (sklearn `classes_`,
256    /// `_ridge.py:2774`).
257    #[must_use]
258    pub fn classes(&self) -> &[usize] {
259        &self.classes
260    }
261
262    /// Returns the per-sample cross-validation results, shape
263    /// `(n_samples, n_targets, n_alphas)`, or `None` when `store_cv_results`
264    /// was not set.
265    ///
266    /// The values are the per-sample-per-target-per-alpha SQUARED leave-one-out
267    /// errors `(c / G_inverse_diag)²` that the GCV minimizes — the same
268    /// per-sample terms the alpha selection sums (sklearn `cv_results_`,
269    /// `_ridge.py:2149` `squared_errors`, reshaped to `(n_samples, n_y,
270    /// n_alphas)` at `_ridge.py:2199-2204`). The alpha axis follows the input
271    /// `alphas` order; the target axis follows the indicator columns (the sorted
272    /// `classes_`; a single column for binary problems).
273    #[must_use]
274    pub fn cv_results(&self) -> Option<&Array3<F>> {
275        self.cv_results_.as_ref()
276    }
277}
278
279impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
280    Fit<Array2<F>, Array1<usize>> for RidgeClassifierCV<F>
281{
282    type Fitted = FittedRidgeClassifierCV<F>;
283    type Error = FerroError;
284
285    /// Fit the `RidgeClassifierCV` model.
286    ///
287    /// Binarizes `y` into a `{-1, +1}` indicator matrix (binary → single
288    /// column, multiclass → one-hot), selects a single shared `alpha` by
289    /// leave-one-out Generalized Cross-Validation over that multi-target
290    /// problem (mirroring sklearn's `_RidgeGCV` path on the binarized `Y`,
291    /// `_ridge.py:2876-2881`; `scoring=None` → `-squared_errors.mean()`,
292    /// `_ridge.py:2148-2150`/`:2211-2218`), then refits a multi-output Ridge at
293    /// the chosen alpha.
294    ///
295    /// # Errors
296    ///
297    /// - [`FerroError::ShapeMismatch`] if `x` and `y` have different numbers of
298    ///   samples.
299    /// - [`FerroError::InvalidParameter`] if `x` contains a non-finite value
300    ///   (NaN/±Inf), or if `alphas` is empty or contains a non-positive value
301    ///   (`alpha <= 0`; the GCV path is undefined at `alpha = 0`).
302    /// - [`FerroError::InsufficientSamples`] if there are no samples or fewer
303    ///   than two distinct classes.
304    fn fit(
305        &self,
306        x: &Array2<F>,
307        y: &Array1<usize>,
308    ) -> Result<FittedRidgeClassifierCV<F>, FerroError> {
309        let (n_samples, n_features) = x.dim();
310
311        if n_samples != y.len() {
312            return Err(FerroError::ShapeMismatch {
313                expected: vec![n_samples],
314                actual: vec![y.len()],
315                context: "y length must match number of samples in X".into(),
316            });
317        }
318
319        // sklearn `RidgeClassifierCV.fit` -> `_BaseRidge._prepare_data` calls
320        // `self._validate_data(X, y, ..., force_all_finite=True[default])`
321        // (`_ridge.py:1291`), so any NaN/+/-inf in X raises a `ValueError`
322        // BEFORE any decomposition. Fire this up front so BOTH the wide/eigen
323        // (`n_samples <= n_features`, today `Ok(NaN)`) and the SVD
324        // (`n_samples > n_features`, today an incidental linalg-convergence
325        // error) paths get the same clean validation rejection. `y` is class
326        // labels (`usize`, always finite by type), so only X is checked —
327        // mirroring the sibling pattern in `multi_task_lasso.rs`. (#2246)
328        if x.iter().any(|v| !v.is_finite()) {
329            return Err(FerroError::InvalidParameter {
330                name: "X".into(),
331                reason: "Input X contains NaN or infinity.".into(),
332            });
333        }
334
335        if self.alphas.is_empty() {
336            return Err(FerroError::InvalidParameter {
337                name: "alphas".into(),
338                reason: "must contain at least one candidate".into(),
339            });
340        }
341
342        for &a in &self.alphas {
343            // `<F as num_traits::Zero>::zero()`: the `LinalgFloat` bound pulls
344            // `ferray::Element` (also defining `zero`) into scope, making a bare
345            // `F::zero()` ambiguous. Disambiguate to `num_traits::Zero`.
346            //
347            // Strictly positive (`a <= 0` rejected, not just `a < 0`): on the
348            // GCV path (ferrolearn's only path, `cv is None`) sklearn validates
349            // each alpha with `Interval(Real, 0, None, closed="neither")`
350            // (`_ridge.py:2259`) / `include_boundaries="neither"`
351            // (`_ridge.py:2354-2360`), raising `ValueError("alphas[i] == 0.0,
352            // must be > 0.0")` because "_RidgeGCV does not work for alpha = 0"
353            // (`_ridge.py:2354`). (#2247)
354            if a <= <F as num_traits::Zero>::zero() {
355                return Err(FerroError::InvalidParameter {
356                    name: "alphas".into(),
357                    reason: "alphas must be > 0.0".into(),
358                });
359            }
360        }
361
362        if n_samples == 0 {
363            return Err(FerroError::InsufficientSamples {
364                required: 1,
365                actual: 0,
366                context: "RidgeClassifierCV requires at least one sample".into(),
367            });
368        }
369
370        // Sorted unique class labels (mirrors sklearn's `LabelBinarizer.classes_`).
371        let mut classes: Vec<usize> = y.to_vec();
372        classes.sort_unstable();
373        classes.dedup();
374
375        if classes.len() < 2 {
376            return Err(FerroError::InsufficientSamples {
377                required: 2,
378                actual: classes.len(),
379                context: "RidgeClassifierCV requires at least 2 distinct classes".into(),
380            });
381        }
382
383        let is_binary = classes.len() == 2;
384
385        // Build the `{-1, +1}` indicator matrix `Y` (mirrors
386        // `LabelBinarizer(pos_label=1, neg_label=-1)`, `_ridge.py:1300-1301`).
387        // Binary → single column (+1 for class index 1, -1 for class 0);
388        // multiclass → one-hot `{-1, +1}` (+1 on the active class, -1 elsewhere).
389        let n_targets = if is_binary { 1 } else { classes.len() };
390        let one = <F as num_traits::One>::one();
391        let neg_one = -one;
392        let mut y_indicator = Array2::<F>::from_elem((n_samples, n_targets), neg_one);
393
394        if is_binary {
395            for i in 0..n_samples {
396                if y[i] == classes[1] {
397                    y_indicator[[i, 0]] = one;
398                }
399            }
400        } else {
401            for i in 0..n_samples {
402                // `classes` is the sorted-deduped image of `y`, so `y[i]` is
403                // always present; fall back to a typed error rather than panic.
404                let ci = classes.iter().position(|&c| c == y[i]).ok_or_else(|| {
405                    FerroError::NumericalInstability {
406                        message: "class label missing from class set".into(),
407                    }
408                })?;
409                y_indicator[[i, ci]] = one;
410            }
411        }
412
413        // SHARED-ALPHA leave-one-out GCV over the binarized multi-target
414        // problem. The hat-matrix diagonal depends only on `X + alpha`, so a
415        // single decomposition is reused across both alphas AND target columns;
416        // the per-alpha score sums the squared LOO errors over every column and
417        // sample (sklearn `-squared_errors.mean()` with `squared_errors` shape
418        // `(n_samples, n_y)`, `_ridge.py:2148-2150`/`:2211-2218`).
419        let (alpha_, cv_results_) = self.select_alpha_gcv(x, &y_indicator)?;
420
421        // Final refit: multi-output Ridge at the selected alpha on the indicator
422        // matrix (sklearn refits `coef_ = dual_coef_.T @ X` + `_set_intercept`,
423        // `_ridge.py:2191-2197`; an equivalent direct Ridge refit reproduces the
424        // same centering/intercept handling RidgeClassifier uses).
425        let final_model = Ridge::<F>::new()
426            .with_alpha(alpha_)
427            .with_fit_intercept(self.fit_intercept);
428        let fitted_multi = final_model.fit(x, &y_indicator)?;
429
430        // `FittedRidgeMulti` stores `(n_features, n_targets)`; transpose to the
431        // sklearn `coef_` orientation `(n_classes_or_1, n_features)`.
432        let coef_ft = fitted_multi.coefficients();
433        let coefficients = coef_ft.t().to_owned();
434        let coefficients_row0 = coefficients.row(0).to_owned();
435        let intercepts = fitted_multi.intercepts().clone();
436
437        Ok(FittedRidgeClassifierCV {
438            alpha_,
439            coefficients,
440            coefficients_row0,
441            intercepts,
442            classes,
443            is_binary,
444            n_features,
445            cv_results_,
446        })
447    }
448}
449
450impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
451    RidgeClassifierCV<F>
452{
453    /// Shared-alpha leave-one-out Generalized Cross-Validation over the
454    /// binarized multi-target indicator `Y`, mirroring sklearn `_RidgeGCV.fit`
455    /// (`_ridge.py:2059`) on the binarized targets.
456    ///
457    /// Centers `X`/`Y` when `fit_intercept` (uniform weights → `sqrt_sw = 1`;
458    /// sklearn `_preprocess_data`, `_ridge.py:2106`), decomposes once via the
459    /// shape-appropriate mode (sklearn `_check_gcv_mode`, `_ridge.py:1569`: SVD
460    /// of the design when `n_samples > n_features`, else eigendecomposition of
461    /// the Gram `X·Xᵀ`), then for each alpha sums the squared closed-form LOO
462    /// errors `(c / G_inverse_diag)²` over EVERY indicator column and sample and
463    /// picks the alpha minimising that total (equivalently the mean — `n_y` is
464    /// constant across alphas; sklearn `-squared_errors.mean()`,
465    /// `_ridge.py:2148-2150`/`:2211-2218`). Ties → the first (smallest-index)
466    /// alpha, matching sklearn's strict `alpha_score > best_score` update
467    /// (`_ridge.py:2185`).
468    ///
469    /// When `self.store_cv_results`, ALSO returns the per-sample-per-target
470    /// squared LOO errors as an `Array3<F>` shaped `(n_samples, n_targets,
471    /// n_alphas)` (alpha axis = input `alphas` order, target axis = indicator
472    /// columns) — the un-summed terms the alpha selection sums (sklearn
473    /// `cv_results_`, `_ridge.py:2149`/`:2199-2204`). The selection math itself
474    /// is unchanged, so `alpha_` stays bit-exact regardless of the flag.
475    #[allow(
476        clippy::type_complexity,
477        reason = "GCV returns the selected alpha plus the optional retained cv_results_ buffer in one pass to avoid recomputing the decomposition"
478    )]
479    fn select_alpha_gcv(
480        &self,
481        x: &Array2<F>,
482        y: &Array2<F>,
483    ) -> Result<(F, Option<Array3<F>>), FerroError> {
484        let (n_samples, n_features) = x.dim();
485
486        // Center X and Y per column (sklearn `_preprocess_data`,
487        // `_ridge.py:2106`); with uniform weights the centered design has zero
488        // column means and the square-root sample weights are all 1.
489        let (x_c, y_c) = if self.fit_intercept {
490            let x_mean = x
491                .mean_axis(Axis(0))
492                .ok_or_else(|| FerroError::NumericalInstability {
493                    message: "RidgeClassifierCV GCV: failed to compute X column means".into(),
494                })?;
495            let y_mean = y
496                .mean_axis(Axis(0))
497                .ok_or_else(|| FerroError::NumericalInstability {
498                    message: "RidgeClassifierCV GCV: failed to compute Y column means".into(),
499                })?;
500            (x - &x_mean, y - &y_mean)
501        } else {
502            (x.to_owned(), y.to_owned())
503        };
504
505        // Per-alpha total squared LOO error (summed over samples AND columns).
506        // When `store_cv_results`, the score functions ALSO return the
507        // un-summed per-sample-per-target squared LOO errors as
508        // `(n_samples, n_targets, n_alphas)`.
509        let (scores, cv_results) = if n_samples > n_features {
510            self.gcv_scores_svd(&x_c, &y_c)?
511        } else {
512            self.gcv_scores_eigen(&x_c, &y_c)?
513        };
514
515        let mut best_alpha = self.alphas[0];
516        let mut best_err = F::infinity();
517        for (&alpha, &total_sq_err) in self.alphas.iter().zip(scores.iter()) {
518            if total_sq_err < best_err {
519                best_err = total_sq_err;
520                best_alpha = alpha;
521            }
522        }
523
524        Ok((best_alpha, cv_results))
525    }
526
527    /// SVD-mode shared-alpha GCV totals, used when `n_samples > n_features`
528    /// (sklearn `_svd_decompose_design_matrix` `_ridge.py:2025` +
529    /// `_solve_svd_design_matrix` `_ridge.py:2039`). Returns the total squared
530    /// LOO error (summed over every indicator column and sample) for each
531    /// candidate alpha (lower is better).
532    ///
533    /// REPLICATES `crate::ridge_cv`'s verified 1-D SVD path, extended to
534    /// accumulate over the columns of `Y` against the SAME `U`/singular values.
535    ///
536    /// When `self.store_cv_results`, the returned `Option<Array3<F>>` holds the
537    /// un-summed per-sample-per-target squared LOO errors shaped `(n_samples,
538    /// n_targets, n_alphas)` (sklearn `cv_results_`, `_ridge.py:2149`/
539    /// `:2199-2204`). The summation that selects `alpha_` is byte-identical
540    /// whether or not retention is enabled.
541    #[allow(
542        clippy::type_complexity,
543        reason = "returns the per-alpha totals plus the optional retained cv_results_ buffer computed in the same loop"
544    )]
545    fn gcv_scores_svd(
546        &self,
547        x_c: &Array2<F>,
548        y_c: &Array2<F>,
549    ) -> Result<(Vec<F>, Option<Array3<F>>), FerroError> {
550        let n_samples = x_c.nrows();
551        let n_targets = y_c.ncols();
552        let one = <F as num_traits::One>::one();
553
554        // Build the (possibly intercept-augmented) design matrix. With uniform
555        // weights `sqrt_sw = 1`, so the appended intercept column is all ones
556        // (sklearn `_svd_decompose_design_matrix`, `_ridge.py:2032`).
557        let n_cols = if self.fit_intercept {
558            x_c.ncols() + 1
559        } else {
560            x_c.ncols()
561        };
562        let mut design = Array2::<F>::zeros((n_samples, n_cols));
563        design.slice_mut(ndarray::s![.., ..x_c.ncols()]).assign(x_c);
564        if self.fit_intercept {
565            design.column_mut(x_c.ncols()).fill(one);
566        }
567
568        // Thin SVD: `U` is `(n_samples, k)`, singular values length `k`
569        // (sklearn `linalg.svd(X, full_matrices=0)`, `_ridge.py:2034`).
570        let (u, singvals) = svd_u_s(&design)?;
571        let k = singvals.len();
572
573        let singvals_sq: Vec<F> = (0..k).map(|j| singvals[j] * singvals[j]).collect();
574
575        // UT_Y[j, t] = Σ_i U[i,j] · Y[i,t] (sklearn `_ridge.py:2036`, per column).
576        let mut ut_y = Array2::<F>::zeros((k, n_targets));
577        for j in 0..k {
578            for t in 0..n_targets {
579                let mut acc = <F as num_traits::Zero>::zero();
580                for i in 0..n_samples {
581                    acc += u[(i, j)] * y_c[[i, t]];
582                }
583                ut_y[[j, t]] = acc;
584            }
585        }
586
587        // Intercept dimension: the column of U most aligned with the normalized
588        // sqrt_sw (uniform → ones/√n) (sklearn `_find_smallest_angle`,
589        // `_ridge.py:1579`).
590        let intercept_dim = if self.fit_intercept {
591            Some(find_intercept_dim(&u, n_samples, k))
592        } else {
593            None
594        };
595
596        // Optional cv_results_ retention buffer, shaped (n_samples, n_targets,
597        // n_alphas); the alpha axis follows the input `alphas` enumeration order
598        // (sklearn `cv_results_`, `_ridge.py:2199-2204`).
599        let mut cv_results = if self.store_cv_results {
600            Some(Array3::<F>::zeros((
601                n_samples,
602                n_targets,
603                self.alphas.len(),
604            )))
605        } else {
606            None
607        };
608
609        let mut out = Vec::with_capacity(self.alphas.len());
610        for (a_idx, &alpha) in self.alphas.iter().enumerate() {
611            let inv_alpha = one / alpha;
612            // w_j = (singvals_sq_j + alpha)^-1 - alpha^-1 (sklearn :2045).
613            let mut w: Vec<F> = singvals_sq
614                .iter()
615                .map(|&s2| one / (s2 + alpha) - inv_alpha)
616                .collect();
617            if let Some(d) = intercept_dim {
618                // Cancel regularization for the intercept (sklearn :2051).
619                w[d] = -inv_alpha;
620            }
621
622            // For each sample i: G_inverse_diag_i = Σ_j w_j U[i,j]² + alpha^-1
623            // (sklearn :2053, shared across columns). Per column t:
624            // c[i,t] = Σ_j U[i,j]·w_j·UT_Y[j,t] + alpha^-1·Y[i,t] (sklearn :2052).
625            // squared_error[i,t] = (c[i,t] / G_inverse_diag_i)² (sklearn :2149).
626            let mut total = <F as num_traits::Zero>::zero();
627            for i in 0..n_samples {
628                let mut g_i = <F as num_traits::Zero>::zero();
629                for j in 0..k {
630                    let uij = u[(i, j)];
631                    g_i += w[j] * uij * uij;
632                }
633                g_i += inv_alpha;
634                for t in 0..n_targets {
635                    let mut c_it = <F as num_traits::Zero>::zero();
636                    for j in 0..k {
637                        c_it += u[(i, j)] * (w[j] * ut_y[[j, t]]);
638                    }
639                    c_it += inv_alpha * y_c[[i, t]];
640                    let looe = c_it / g_i;
641                    let sq_err = looe * looe;
642                    total += sq_err;
643                    // Retain the un-summed per-sample-per-target squared LOO
644                    // error (`cv_results_[:, t, a]`, sklearn `_ridge.py:2152`
645                    // ravel + `:2199-2204` reshape).
646                    if let Some(buf) = cv_results.as_mut() {
647                        buf[[i, t, a_idx]] = sq_err;
648                    }
649                }
650            }
651            out.push(total);
652        }
653        Ok((out, cv_results))
654    }
655
656    /// Eigen-mode shared-alpha GCV totals, used when
657    /// `n_samples <= n_features` (sklearn `_eigen_decompose_gram`
658    /// `_ridge.py:1900` then `_solve_eigen_gram` `_ridge.py:1914`). Returns the
659    /// total squared LOO error (summed over every indicator column and sample)
660    /// for each candidate alpha (lower is better).
661    ///
662    /// REPLICATES `crate::ridge_cv`'s verified 1-D eigen path, extended to
663    /// accumulate over the columns of `Y` against the SAME `Q`/eigenvalues.
664    ///
665    /// When `self.store_cv_results`, the returned `Option<Array3<F>>` holds the
666    /// un-summed per-sample-per-target squared LOO errors shaped `(n_samples,
667    /// n_targets, n_alphas)` (sklearn `cv_results_`, `_ridge.py:2149`/
668    /// `:2199-2204`). The summation that selects `alpha_` is byte-identical
669    /// whether or not retention is enabled.
670    #[allow(
671        clippy::type_complexity,
672        reason = "returns the per-alpha totals plus the optional retained cv_results_ buffer computed in the same loop"
673    )]
674    fn gcv_scores_eigen(
675        &self,
676        x_c: &Array2<F>,
677        y_c: &Array2<F>,
678    ) -> Result<(Vec<F>, Option<Array3<F>>), FerroError> {
679        let n_samples = x_c.nrows();
680        let n_targets = y_c.ncols();
681        let one = <F as num_traits::One>::one();
682
683        // Gram matrix K = X·Xᵀ on the centered design (sklearn dense
684        // `_compute_gram` → `X X^T`, `_ridge.py:1799`).
685        let mut k_mat = x_c.dot(&x_c.t());
686        if self.fit_intercept {
687            // Add outer(sqrt_sw, sqrt_sw): uniform weights → the all-ones rank-1
688            // matrix, emulating centering with the intercept eigenvector
689            // (sklearn `_eigen_decompose_gram`, `_ridge.py:1909`).
690            for i in 0..n_samples {
691                for j in 0..n_samples {
692                    k_mat[(i, j)] += one;
693                }
694            }
695        }
696
697        // Eigendecomposition K = Q diag(eigvals) Qᵀ (sklearn `linalg.eigh`,
698        // `_ridge.py:1910`).
699        let (eigvals, q) = eigh_sym(&k_mat)?;
700        let m = eigvals.len();
701
702        // QT_Y[j, t] = Σ_i Q[i,j] · Y[i,t] (sklearn :1911, per column).
703        let mut qt_y = Array2::<F>::zeros((m, n_targets));
704        for j in 0..m {
705            for t in 0..n_targets {
706                let mut acc = <F as num_traits::Zero>::zero();
707                for i in 0..n_samples {
708                    acc += q[(i, j)] * y_c[[i, t]];
709                }
710                qt_y[[j, t]] = acc;
711            }
712        }
713
714        // Intercept eigenvector: the column of Q most aligned with the
715        // normalized sqrt_sw (uniform → ones/√n) (sklearn :1926-1927).
716        let intercept_dim = if self.fit_intercept {
717            Some(find_intercept_dim(&q, n_samples, m))
718        } else {
719            None
720        };
721
722        // Optional cv_results_ retention buffer, shaped (n_samples, n_targets,
723        // n_alphas); the alpha axis follows the input `alphas` enumeration order
724        // (sklearn `cv_results_`, `_ridge.py:2199-2204`).
725        let mut cv_results = if self.store_cv_results {
726            Some(Array3::<F>::zeros((
727                n_samples,
728                n_targets,
729                self.alphas.len(),
730            )))
731        } else {
732            None
733        };
734
735        let mut out = Vec::with_capacity(self.alphas.len());
736        for (a_idx, &alpha) in self.alphas.iter().enumerate() {
737            // w_j = 1 / (eigvals_j + alpha) (sklearn :1919).
738            let mut w: Vec<F> = eigvals.iter().map(|&ev| one / (ev + alpha)).collect();
739            if let Some(d) = intercept_dim {
740                // Cancel regularization for the intercept (sklearn :1928).
741                w[d] = <F as num_traits::Zero>::zero();
742            }
743
744            // G_inverse_diag_i = Σ_j w_j Q[i,j]² (shared across columns,
745            // sklearn :1931). c[i,t] = Σ_j Q[i,j]·w_j·QT_Y[j,t] (sklearn :1930).
746            let mut total = <F as num_traits::Zero>::zero();
747            for i in 0..n_samples {
748                let mut g_i = <F as num_traits::Zero>::zero();
749                for j in 0..m {
750                    let qij = q[(i, j)];
751                    g_i += w[j] * qij * qij;
752                }
753                for t in 0..n_targets {
754                    let mut c_it = <F as num_traits::Zero>::zero();
755                    for j in 0..m {
756                        c_it += q[(i, j)] * (w[j] * qt_y[[j, t]]);
757                    }
758                    let looe = c_it / g_i;
759                    let sq_err = looe * looe;
760                    total += sq_err;
761                    // Retain the un-summed per-sample-per-target squared LOO
762                    // error (`cv_results_[:, t, a]`, sklearn `_ridge.py:2152`
763                    // ravel + `:2199-2204` reshape).
764                    if let Some(buf) = cv_results.as_mut() {
765                        buf[[i, t, a_idx]] = sq_err;
766                    }
767                }
768            }
769            out.push(total);
770        }
771        Ok((out, cv_results))
772    }
773}
774
775/// Find the column index of an orthonormal factor (`U` or `Q`, both
776/// `(n_samples, k)`) most aligned with the normalized uniform-weight vector
777/// `ones/√n`. Mirrors sklearn `_find_smallest_angle` (`_ridge.py:1579`): the
778/// query and columns are unit vectors, so the most-aligned column maximises
779/// `|query · column|`; with `query = ones/√n` the per-column dot product is
780/// proportional to the column sum, so `|column-sum|` is the discriminant.
781fn find_intercept_dim<F: Float + std::ops::AddAssign + 'static>(
782    u: &Array2<F>,
783    n_samples: usize,
784    k: usize,
785) -> usize {
786    let mut best_idx = 0usize;
787    let mut best_abs = F::neg_infinity();
788    for j in 0..k {
789        let mut col_sum = <F as num_traits::Zero>::zero();
790        for i in 0..n_samples {
791            col_sum += u[(i, j)];
792        }
793        let a = col_sum.abs();
794        if a > best_abs {
795            best_abs = a;
796            best_idx = j;
797        }
798    }
799    best_idx
800}
801
802/// Thin SVD via the ferray substrate, returning `(U, S)` as ndarray types.
803///
804/// Bridges `ndarray → ferray` for the decomposition and back (R-SUBSTRATE-4),
805/// routing through [`ferray::linalg::svd`] (`ferray-linalg/src/decomp/svd.rs`).
806fn svd_u_s<F: LinalgFloat>(a: &Array2<F>) -> Result<(Array2<F>, Array1<F>), FerroError> {
807    let (rows, cols) = a.dim();
808    let flat: Vec<F> = a.iter().copied().collect();
809    let fa = FerrayArray::<F, Ix2>::from_vec(Ix2::new([rows, cols]), flat).map_err(|e| {
810        FerroError::NumericalInstability {
811            message: format!("RidgeClassifierCV GCV: failed to build design matrix for SVD: {e}"),
812        }
813    })?;
814    let (u, s, _vt) =
815        ferray::linalg::svd(&fa, false).map_err(|e| FerroError::NumericalInstability {
816            message: format!("RidgeClassifierCV GCV: SVD failed: {e}"),
817        })?;
818    let u_nd = ferray_to_ndarray2(&u)?;
819    let s_nd = ferray_to_ndarray1(&s)?;
820    Ok((u_nd, s_nd))
821}
822
823/// Symmetric eigendecomposition via the ferray substrate, returning
824/// `(eigvals, Q)` as ndarray types (ascending eigenvalues).
825///
826/// Bridges `ndarray → ferray` and back (R-SUBSTRATE-4), routing through
827/// [`ferray::linalg::eigh`] (`ferray-linalg/src/decomp/eigen.rs`).
828fn eigh_sym<F: LinalgFloat>(a: &Array2<F>) -> Result<(Array1<F>, Array2<F>), FerroError> {
829    let (rows, cols) = a.dim();
830    let flat: Vec<F> = a.iter().copied().collect();
831    let fa = FerrayArray::<F, Ix2>::from_vec(Ix2::new([rows, cols]), flat).map_err(|e| {
832        FerroError::NumericalInstability {
833            message: format!("RidgeClassifierCV GCV: failed to build Gram matrix for eigh: {e}"),
834        }
835    })?;
836    let (vals, q) = ferray::linalg::eigh(&fa).map_err(|e| FerroError::NumericalInstability {
837        message: format!("RidgeClassifierCV GCV: eigendecomposition failed: {e}"),
838    })?;
839    let vals_nd = ferray_to_ndarray1(&vals)?;
840    let q_nd = ferray_to_ndarray2(&q)?;
841    Ok((vals_nd, q_nd))
842}
843
844/// Bridge a ferray 2-D array back to `ndarray::Array2` (R-SUBSTRATE-4).
845fn ferray_to_ndarray2<F: LinalgFloat>(a: &FerrayArray<F, Ix2>) -> Result<Array2<F>, FerroError> {
846    let shape = a.shape();
847    let (rows, cols) = (shape[0], shape[1]);
848    let nd = a.clone().into_ndarray();
849    let flat: Vec<F> = nd.iter().copied().collect();
850    Array2::from_shape_vec((rows, cols), flat).map_err(|e| FerroError::NumericalInstability {
851        message: format!("RidgeClassifierCV GCV: ferray→ndarray (2-D) bridge failed: {e}"),
852    })
853}
854
855/// Bridge a ferray 1-D array back to `ndarray::Array1` (R-SUBSTRATE-4).
856fn ferray_to_ndarray1<F: LinalgFloat>(
857    a: &FerrayArray<F, ferray::Ix1>,
858) -> Result<Array1<F>, FerroError> {
859    let nd = a.clone().into_ndarray();
860    let flat: Vec<F> = nd.iter().copied().collect();
861    Ok(Array1::from_vec(flat))
862}
863
864impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
865    for FittedRidgeClassifierCV<F>
866{
867    type Output = Array1<usize>;
868    type Error = FerroError;
869
870    /// Predict class labels for the given feature matrix.
871    ///
872    /// Computes the decision `X · coefficientsᵀ + intercepts`: binary takes the
873    /// strict-sign rule (`classes[1]` if `decision > 0` else `classes[0]`,
874    /// mirroring `LinearClassifierMixin.predict`, `_base.py:384` `scores > 0`);
875    /// multiclass takes the argmax over class columns → `classes[idx]`.
876    ///
877    /// # Errors
878    ///
879    /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
880    /// match the fitted model.
881    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
882        let n_features = x.ncols();
883        if n_features != self.n_features {
884            return Err(FerroError::ShapeMismatch {
885                expected: vec![self.n_features],
886                actual: vec![n_features],
887                context: "number of features must match fitted model".into(),
888            });
889        }
890
891        let n_samples = x.nrows();
892        let mut predictions = Array1::<usize>::zeros(n_samples);
893
894        // Decision scores: X · coefficientsᵀ + intercepts, shape
895        // `(n_samples, n_classes_or_1)`.
896        let scores = x.dot(&self.coefficients.t()) + &self.intercepts;
897
898        if self.is_binary {
899            for i in 0..n_samples {
900                // Strict `> 0` (sklearn `_base.py:384`): an exact-0 decision maps
901                // to index 0 → `classes[0]`.
902                predictions[i] = if scores[[i, 0]] > <F as num_traits::Zero>::zero() {
903                    self.classes[1]
904                } else {
905                    self.classes[0]
906                };
907            }
908        } else {
909            for i in 0..n_samples {
910                let mut best_class = 0;
911                let mut best_score = scores[[i, 0]];
912                for c in 1..self.classes.len() {
913                    if scores[[i, c]] > best_score {
914                        best_score = scores[[i, c]];
915                        best_class = c;
916                    }
917                }
918                predictions[i] = self.classes[best_class];
919            }
920        }
921
922        Ok(predictions)
923    }
924}
925
926impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
927    for FittedRidgeClassifierCV<F>
928{
929    /// Returns the first coefficient row as a flat vector (the binary decision
930    /// vector / first class for multiclass), matching the `HasCoefficients`
931    /// 1-D contract used across the crate.
932    fn coefficients(&self) -> &Array1<F> {
933        &self.coefficients_row0
934    }
935
936    fn intercept(&self) -> F {
937        self.intercepts[0]
938    }
939}
940
941impl<F: Float + Send + Sync + ScalarOperand + 'static> HasClasses for FittedRidgeClassifierCV<F> {
942    fn classes(&self) -> &[usize] {
943        &self.classes
944    }
945
946    fn n_classes(&self) -> usize {
947        self.classes.len()
948    }
949}
950
951#[cfg(test)]
952mod tests {
953    use super::*;
954    use ndarray::array;
955
956    /// Shared 8×2 design used by the oracle tests.
957    fn oracle_x() -> Array2<f64> {
958        Array2::from_shape_vec(
959            (8, 2),
960            vec![
961                1.0, 2.0, 2.0, 1.0, 3.0, 1.0, 1.0, 3.0, 2.0, 2.0, 6.0, 5.0, 5.0, 6.0, 7.0, 7.0,
962            ],
963        )
964        .unwrap()
965    }
966
967    #[test]
968    fn ridge_classifier_cv_binary_matches_sklearn() -> Result<(), FerroError> {
969        // Live sklearn 1.5.2 oracle (R-CHAR-3):
970        //   python3 -c "import numpy as np; \
971        //     from sklearn.linear_model import RidgeClassifierCV; \
972        //     X=np.array([[1,2],[2,1],[3,1],[1,3],[2,2],[6,5],[5,6],[7,7]],float); \
973        //     y=np.array([0,0,0,0,0,1,1,1]); \
974        //     m=RidgeClassifierCV(alphas=[0.1,1.0,10.0]).fit(X,y); \
975        //     print(m.alpha_, m.coef_.tolist(), m.intercept_.tolist(), m.predict(X).tolist())"
976        //   -> alpha_ 10.0
977        //      coef_ [[0.1974921630094044, 0.1974921630094044]]
978        //      intercept_ [-1.5830721003134798]
979        //      predict [0, 0, 0, 0, 0, 1, 1, 1]
980        let x = oracle_x();
981        let y = array![0usize, 0, 0, 0, 0, 1, 1, 1];
982
983        let model = RidgeClassifierCV::<f64>::new().with_alphas(vec![0.1, 1.0, 10.0]);
984        let fitted = model.fit(&x, &y)?;
985
986        assert!(
987            (fitted.alpha_() - 10.0).abs() < 1e-12,
988            "alpha_={} expected 10.0",
989            fitted.alpha_()
990        );
991
992        let coef = fitted.coefficients();
993        assert_eq!(coef.shape(), &[1, 2], "binary coef_ must be (1, 2)");
994        assert!(
995            (coef[[0, 0]] - 0.197_492_163_0).abs() < 1e-6,
996            "coef[0,0]={} expected 0.197492163",
997            coef[[0, 0]]
998        );
999        assert!(
1000            (coef[[0, 1]] - 0.197_492_163_0).abs() < 1e-6,
1001            "coef[0,1]={} expected 0.197492163",
1002            coef[[0, 1]]
1003        );
1004
1005        assert!(
1006            (fitted.intercepts()[0] - (-1.583_072_100_3)).abs() < 1e-6,
1007            "intercept={} expected -1.5830721003",
1008            fitted.intercepts()[0]
1009        );
1010
1011        let preds = fitted.predict(&x)?;
1012        assert_eq!(preds.to_vec(), vec![0, 0, 0, 0, 0, 1, 1, 1]);
1013        Ok(())
1014    }
1015
1016    #[test]
1017    fn ridge_classifier_cv_multiclass_matches_sklearn() -> Result<(), FerroError> {
1018        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1019        //   python3 -c "import numpy as np; \
1020        //     from sklearn.linear_model import RidgeClassifierCV; \
1021        //     X=np.array([[1,2],[2,1],[3,1],[1,3],[2,2],[6,5],[5,6],[7,7]],float); \
1022        //     y=np.array([0,0,1,1,2,2,1,0]); \
1023        //     m=RidgeClassifierCV(alphas=[0.1,1.0,10.0]).fit(X,y); \
1024        //     print(m.alpha_, m.coef_.tolist(), m.predict(X).tolist())"
1025        //   -> alpha_ 10.0
1026        //      coef_ [[-0.0031348, -0.0031348],
1027        //             [-0.07817398, 0.04682602],
1028        //             [0.08130878, -0.04369122]]
1029        //      predict [1, 0, 0, 1, 1, 0, 1, 0]
1030        let x = oracle_x();
1031        let y = array![0usize, 0, 1, 1, 2, 2, 1, 0];
1032
1033        let model = RidgeClassifierCV::<f64>::new().with_alphas(vec![0.1, 1.0, 10.0]);
1034        let fitted = model.fit(&x, &y)?;
1035
1036        assert!(
1037            (fitted.alpha_() - 10.0).abs() < 1e-12,
1038            "alpha_={} expected 10.0",
1039            fitted.alpha_()
1040        );
1041
1042        let coef = fitted.coefficients();
1043        assert_eq!(coef.shape(), &[3, 2], "multiclass coef_ must be (3, 2)");
1044
1045        let expected = [
1046            [-0.003_134_8, -0.003_134_8],
1047            [-0.078_173_98, 0.046_826_02],
1048            [0.081_308_78, -0.043_691_22],
1049        ];
1050        for r in 0..3 {
1051            for c in 0..2 {
1052                assert!(
1053                    (coef[[r, c]] - expected[r][c]).abs() < 1e-6,
1054                    "coef[{r},{c}]={} expected {}",
1055                    coef[[r, c]],
1056                    expected[r][c]
1057                );
1058            }
1059        }
1060
1061        let preds = fitted.predict(&x)?;
1062        assert_eq!(preds.to_vec(), vec![1, 0, 0, 1, 1, 0, 1, 0]);
1063        Ok(())
1064    }
1065
1066    #[test]
1067    fn ridge_classifier_cv_single_class_errors() {
1068        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 1.0, 3.0, 1.0]).unwrap();
1069        let y = array![0usize, 0, 0];
1070
1071        let model = RidgeClassifierCV::<f64>::new();
1072        assert!(
1073            model.fit(&x, &y).is_err(),
1074            "single-class input must error (>= 2-class guard)"
1075        );
1076    }
1077
1078    #[test]
1079    fn ridge_classifier_cv_selects_from_alphas() -> Result<(), FerroError> {
1080        // The selected alpha_ must always be one of the provided alphas, and
1081        // the prediction must be sensible (recovers the well-separated labels).
1082        let x = oracle_x();
1083        let y = array![0usize, 0, 0, 0, 0, 1, 1, 1];
1084        let alphas = vec![0.01, 0.1, 1.0, 10.0, 100.0];
1085
1086        let model = RidgeClassifierCV::<f64>::new().with_alphas(alphas.clone());
1087        let fitted = model.fit(&x, &y)?;
1088
1089        assert!(
1090            alphas.iter().any(|&a| (a - fitted.alpha_()).abs() < 1e-12),
1091            "selected alpha_={} is not one of the candidates",
1092            fitted.alpha_()
1093        );
1094
1095        let preds = fitted.predict(&x)?;
1096        let correct = preds.iter().zip(y.iter()).filter(|(p, t)| p == t).count();
1097        assert!(correct >= 7, "expected >= 7 correct, got {correct}");
1098        Ok(())
1099    }
1100
1101    #[test]
1102    fn ridge_classifier_cv_default_builder() {
1103        let m = RidgeClassifierCV::<f64>::new();
1104        assert_eq!(m.alphas.len(), 3);
1105        assert!(m.fit_intercept);
1106    }
1107
1108    #[test]
1109    fn ridge_classifier_cv_shape_mismatch() {
1110        let x = Array2::from_shape_vec((3, 2), vec![1.0, 2.0, 2.0, 1.0, 3.0, 1.0]).unwrap();
1111        let y = array![0usize, 1];
1112
1113        let model = RidgeClassifierCV::<f64>::new();
1114        assert!(model.fit(&x, &y).is_err());
1115    }
1116
1117    #[test]
1118    fn ridge_classifier_cv_empty_alphas_error() {
1119        let x =
1120            Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 2.0, 1.0, 6.0, 5.0, 5.0, 6.0]).unwrap();
1121        let y = array![0usize, 0, 1, 1];
1122
1123        let model = RidgeClassifierCV::<f64>::new().with_alphas(vec![]);
1124        assert!(model.fit(&x, &y).is_err());
1125    }
1126}