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}