Skip to main content

ferrolearn_linear/
ridge_classifier.rs

1//! Ridge Classifier.
2//!
3//! This module provides [`RidgeClassifier`], which applies Ridge regression
4//! to classification tasks by converting class labels into a binary indicator
5//! matrix and fitting a multivariate Ridge regression.
6//!
7//! For binary classification, the indicator matrix has a single column
8//! (`{-1, +1}`). For multiclass, it has one column per class (one-hot
9//! encoding). The predicted class is the one with the highest decision
10//! value (`argmax(X @ coef + intercept)`).
11//!
12//! This approach is significantly faster than logistic regression for
13//! large datasets while often achieving competitive accuracy.
14//!
15//! ## REQ status (per `.design/linear/ridge_classifier.md`, mirrors `sklearn/linear_model/_ridge.py` @ 1.5.2)
16//!
17//! Mirrors `sklearn.linear_model.RidgeClassifier` (`_ridge.py:1344`): `LabelBinarizer(pos_label=1,
18//! neg_label=-1)` encoding (`_ridge.py:1300`) + per-class Ridge fit + sign/argmax predict.
19//! coef_/intercept_/decision_function match the live sklearn oracle to 1e-9.
20//!
21//! | REQ | Status | Evidence |
22//! |---|---|---|
23//! | REQ-1 (±1/one-hot encoding + per-class Ridge fit) | SHIPPED | `Fit for RidgeClassifier` (binary {-1,+1}, multiclass one-hot, per-column `linalg::solve_ridge`, centering). Consumer: `RsRidgeClassifier` in `ferrolearn-python`. Mirrors `_ridge.py:1300`. |
24//! | REQ-2 (predict: sign/argmax → original labels) | SHIPPED | binary uses strict `> 0` (mirrors `_base.py:384` `scores > 0`); multiclass argmax; returns `classes[idx]` (original label values). Closed #405 (boundary `>=`→`>`); test `divergence_binary_decision_boundary_strict_gt`. |
25//! | REQ-3 (fit_intercept incl. false) | SHIPPED | centering; matches oracle. |
26//! | REQ-4 (coef_/intercept_/classes_ introspection) | SHIPPED | `HasCoefficients`/`HasClasses`; values match oracle. NOTE: `coef_matrix` is `(n_features, n_targets)`, transposed vs sklearn `coef_` `(n_classes, n_features)` — orientation contract owned by the `ferrolearn-python` binding layer. |
27//! | REQ-5 (alpha≥0 validation; ≥2-class guard) | SHIPPED | negative-alpha → `InvalidParameter`; <2 classes → error. |
28//! | REQ-6a (positive=True) | SHIPPED | `RidgeClassifier<F>` adds `pub positive: bool` (default `false`, `_ridge.py:902`/`:911`) + `with_positive(bool)` builder. When `self.positive`, `fit_with_sample_weight` solves EACH indicator-target column via `crate::linalg::nonneg_ridge_cd` (shared projected-coordinate-descent kernel — `wⱼ = max(0, (A[:,j]ᵀr + col_sq[j]·wⱼ)/(col_sq[j] + alpha))`, `max_iter=self.max_iter.unwrap_or(1000)`/`self.tol`) on the SAME centered/√w-rescaled design `solve_ridge` uses; intercept recovery (`y_off[t] − x_off·coef[:,t]`) UNCHANGED. Mirrors the optimum sklearn reaches with L-BFGS-B for `positive=True` (`_ridge.py:329`, objective `0.5·‖Xw−y‖²+0.5·alpha·‖w‖²`, bounds `[(0,inf)]`). `n_iter_ = Some(worst-case iters over targets)` on the positive path, `None` otherwise. `positive=false` (default) is byte-identical to the unconstrained closed-form path. Oracle tests `ridge_classifier_positive_matches_sklearn` (alpha=1 binary coef `[0.52631579, 0.0]`, intercept `-1.43609023`, all ≥ 0, differs from unconstrained `[0.35294118, -0.23529412]`), `ridge_classifier_positive_false_unchanged` (byte-identical guard). Split from REQ-6 of blocker #393. |
29//! | REQ-6b-i (class_weight) | SHIPPED | `RidgeClassifier<F>` adds `pub class_weight: ClassWeight<F>` (enum `None`/`Balanced`/`Explicit(HashMap<usize,F>)`, default `None`, `_ridge.py:1398`/`:1400`) + `with_class_weight` builder. `fit_with_sample_weight` computes a per-class weight (`Balanced`: `n_samples/(n_classes·count[c])`, `_ridge.py:1402-1404` + `class_weight.py:73`; `Explicit`: `map[c]` else `1.0`, `class_weight.py:77-81`), multiplies it into the user `sample_weight` (or ones) and feeds the EXISTING weighted ridge — mirroring `_ridge.py:1305-1307` (`sample_weight = sample_weight * compute_sample_weight(self.class_weight, y)`). `ClassWeight::None` leaves the unweighted/sample-weighted paths byte-identical. Oracle tests `ridge_classifier_class_weight_balanced_matches_sklearn` (coef `[0.26923077,0.26923077]`, intercept `-2.01923077`), `ridge_classifier_class_weight_dict_matches_sklearn` (`{0:1,1:3}` → `[0.27096774,0.27096774]`, `-2.03225806`), `ridge_classifier_class_weight_none_unchanged` (byte-identical guard), `ridge_classifier_class_weight_explicit_equals_balanced`. Split from REQ-6b of blocker #393. |
30//! | REQ-6b-ii (solver / solver_) | NOT-STARTED | blocker #393 (class_weight done — see REQ-6b-i; positive done — see REQ-6a). No `solver` selection or `solver_` attribute (`_ridge.py:1406-1484`). |
31//! | REQ-7 (max_iter/tol + n_iter_) | SHIPPED | `RidgeClassifier<F>` adds `pub max_iter: Option<usize>` (default `None`) and `pub tol: F` (default `1e-4`) with `with_max_iter`/`with_tol` builders. `FittedRidgeClassifier<F>` adds `n_iter_: Option<usize>` (always `None` for the direct solver) with `pub fn n_iter(&self) -> Option<usize>`. Mirrors sklearn ctor `max_iter=None, tol=1e-4` (`_ridge.py:1520-1521`) and `n_iter_` (`_ridge.py:1464`); `max_iter`/`tol` are no-ops for the direct solver — matching sklearn when the direct path yields `n_iter_=None`. Test: `ridge_classifier_max_iter_tol_niter_defaults_and_builders`. Closes #394. |
32//! | REQ-8 (sample_weight) | SHIPPED | `RidgeClassifier::fit_with_sample_weight(x, y, sample_weight: Option<&Array1<F>>)` forwards weights into the underlying weighted ridge on the indicator matrix `Y`: weighted offsets `x_off[j]=Σwᵢx[i,j]/Σwᵢ`, `y_off[t]=Σwᵢ·Y[i,t]/Σwᵢ` (fit_intercept), centering, then `√wᵢ` row-rescale of `X`/`Y` (sklearn `_rescale_data`, `_ridge.py:682-688`), per-target `linalg::solve_ridge` with `alpha` UNSCALED, `intercept[t]=y_off[t]−Σⱼ x_off[j]·coef[j,t]`; `fit_intercept=false` skips centering (raw `√w`-rescale, intercept 0). `Fit::fit` delegates `fit_with_sample_weight(x, y, None)` (None byte-identical to the historic centering + `solve_ridge` body). Mirrors `RidgeClassifier.fit(X, y, sample_weight=None)` (`_ridge.py:1220`) forwarding through `_prepare_data` (`_ridge.py:1305`) into `_BaseRidge.fit`. Oracle tests `ridge_classifier_sample_weight_matches_sklearn` (alpha=1 binary coef `[0.25333333, 0.36]`, intercept `-1.70666667`, differs from unweighted `[0.31840796, 0.31840796]`), `ridge_classifier_none_sample_weight_equals_unweighted` (byte-identical guard). Closes #395. |
33//! | REQ-9 (RidgeClassifierCV) | SHIPPED | `RidgeClassifierCV<F>`/`FittedRidgeClassifierCV<F>` in the sibling module `ridge_classifier_cv.rs` mirror `class RidgeClassifierCV` (`_ridge.py:2676`): `impl Fit for RidgeClassifierCV` binarizes `y` to a `{-1,+1}` indicator (binary single column / multiclass one-hot, `LabelBinarizer(pos_label=1, neg_label=-1)`, `_ridge.py:1300-1301`); `fn select_alpha_gcv` selects ONE shared `alpha` by leave-one-out GCV over the binarized multi-target problem (closed-form LOO errors `(c / G_inverse_diag)²` summed over all indicator columns + samples, `-squared_errors.mean()`, `_ridge.py:2148-2150` + `_score_without_scorer` `:2211-2218`, sharing `_RidgeGCV` `_ridge.py:1688`); the selected `alpha_` drives a multi-output `Ridge::fit` refit → `coef_`/`intercept_`/`classes_` + `HasClasses`/`HasCoefficients`. That module's own REQ-10 row is SHIPPED. Non-test consumer: crate-root re-export `pub use ridge_classifier_cv::{RidgeClassifierCV, FittedRidgeClassifierCV}` in `lib.rs` (the grandfathered R-DEFER-1/S5 re-export boundary, same path by which sibling CV estimator `RidgeCV` ships). Verification (live sklearn 1.5.2): `cargo test -p ferrolearn-linear --lib ridge_classifier_cv` PASS (7 tests incl. `ridge_classifier_cv_binary_matches_sklearn`, `ridge_classifier_cv_multiclass_matches_sklearn` to <1e-6). NOTE: blocker #396 is the SEPARATE ferray-substrate concern (tracked at REQ-11 of `.design/linear/ridge_classifier.md`), NOT the missing estimator. |
34//! | REQ-10 (ferray substrate) | NOT-STARTED | solve_ridge already on ferray::linalg fallback; coef storage ndarray (tied to #359). |
35//! | REQ-11 (non-finite input rejected) | SHIPPED | `fn fit_with_sample_weight` (the shared entry `Fit::fit` delegates to) rejects any NaN/+/-inf in X or `sample_weight` BEFORE the indicator solve with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` in `RidgeClassifier._prepare_data` (`_ridge.py:1291-1298`) + `_check_sample_weight` (default `force_all_finite=True`, `_ridge.py:1305`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. RidgeClassifier solves the binarized indicator targets DIRECTLY via `linalg::solve_ridge`/`nonneg_ridge_cd` (it does NOT delegate to the #2259-guarded `Ridge::fit`), so the X guard is owned here; the target `y: Array1<usize>` is finite by type (sklearn binarizes the labels), so no `y` check is needed. `.iter().any(|v| !v.is_finite())` catches both NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `RidgeClassifier().fit` raises `ValueError` for NaN/+inf/-inf in X and NaN/inf in sample_weight (`tests/divergence_linear_nonfinite_batch3.rs::ridge_classifier_*`). Non-test consumer: the existing `Fit::fit` / `RsRidgeClassifier` consumers. (#2261) |
36//!
37//! acto-critic: binary + multiclass coef_/intercept_/decision_function match the live oracle to
38//! 1e-9; classes_ returns original label values (no #368-style collapse); one divergence (#405,
39//! binary boundary operator) found and fixed. Two states only per goal.md R-DEFER-2.
40//!
41//! # Examples
42//!
43//! ```
44//! use ferrolearn_linear::ridge_classifier::RidgeClassifier;
45//! use ferrolearn_core::{Fit, Predict};
46//! use ndarray::{array, Array2};
47//!
48//! let x = Array2::from_shape_vec((6, 2), vec![
49//!     1.0, 1.0, 1.0, 2.0, 2.0, 1.0,
50//!     5.0, 5.0, 5.0, 6.0, 6.0, 5.0,
51//! ]).unwrap();
52//! let y = array![0usize, 0, 0, 1, 1, 1];
53//!
54//! let model = RidgeClassifier::<f64>::new();
55//! let fitted = model.fit(&x, &y).unwrap();
56//! let preds = fitted.predict(&x).unwrap();
57//! assert_eq!(preds.len(), 6);
58//! ```
59
60use ferray::linalg::LinalgFloat;
61use ferrolearn_core::error::FerroError;
62use ferrolearn_core::introspection::{HasClasses, HasCoefficients};
63use ferrolearn_core::traits::{Fit, Predict};
64use ndarray::{Array1, Array2, Axis, ScalarOperand};
65use num_traits::{Float, FromPrimitive};
66
67use crate::linalg;
68
69/// Per-class weighting strategy for [`RidgeClassifier`] (sklearn
70/// `class_weight`, `sklearn/linear_model/_ridge.py:1398`).
71///
72/// Mirrors `sklearn.utils.class_weight.compute_class_weight`: the chosen
73/// per-class weight multiplies into any user `sample_weight` BEFORE the
74/// weighted ridge fit (`_ridge.py:1305-1307`,
75/// `sample_weight = sample_weight * compute_sample_weight(self.class_weight, y)`).
76///
77/// - [`ClassWeight::None`] — all classes weight `1.0` (sklearn `None`,
78///   `_ridge.py:1400`).
79/// - [`ClassWeight::Balanced`] — `n_samples / (n_classes * bincount(y))`
80///   (sklearn `'balanced'`, `_ridge.py:1402-1404`,
81///   `class_weight.py:73`).
82/// - [`ClassWeight::Explicit`] — map of class label → weight; classes absent
83///   from the map keep weight `1.0` (sklearn dict, `class_weight.py:77-81`).
84#[derive(Debug, Clone, Default)]
85pub enum ClassWeight<F> {
86    /// All classes have weight `1.0` (sklearn `None`).
87    #[default]
88    None,
89    /// Inversely proportional to class frequency:
90    /// `n_samples / (n_classes * count[c])` (sklearn `'balanced'`).
91    Balanced,
92    /// Explicit per-class weights; classes not present default to `1.0`
93    /// (sklearn dict).
94    Explicit(std::collections::HashMap<usize, F>),
95}
96
97/// Ridge Classifier.
98///
99/// Applies Ridge regression (L2-regularized least squares) to classification
100/// by converting labels to a binary indicator matrix.
101///
102/// # Type Parameters
103///
104/// - `F`: The floating-point type (`f32` or `f64`).
105#[derive(Debug, Clone)]
106pub struct RidgeClassifier<F> {
107    /// Regularization strength. Larger values specify stronger regularization.
108    pub alpha: F,
109    /// Whether to fit an intercept (bias) term.
110    pub fit_intercept: bool,
111    /// Maximum number of iterations for iterative solvers (sklearn `max_iter`,
112    /// `_ridge.py:1520`). Exposed for sklearn ABI parity; ferrolearn implements
113    /// only the direct dense solver, so this field is stored but has no effect
114    /// on the computed result. Default `None`, matching sklearn's default
115    /// (`_ridge.py:1520`).
116    pub max_iter: Option<usize>,
117    /// Convergence tolerance for iterative solvers (sklearn `tol`,
118    /// `_ridge.py:1521`). Exposed for sklearn ABI parity; ferrolearn implements
119    /// only the direct dense solver, so this field is stored but has no effect
120    /// on the computed result. Default `1e-4`, matching sklearn's default
121    /// (`_ridge.py:1521`).
122    pub tol: F,
123    /// When `true`, constrain the coefficients to be non-negative (sklearn
124    /// `positive`, `_ridge.py:902`/`:911`). The per-target indicator-ridge solve
125    /// is routed through projected coordinate descent
126    /// (`crate::linalg::nonneg_ridge_cd`) using `max_iter`/`tol`, mirroring the
127    /// optimum sklearn reaches with its L-BFGS-B solver for `positive=True`
128    /// (`_ridge.py:329`). Default `false`, matching sklearn (`_ridge.py:902`).
129    pub positive: bool,
130    /// Per-class sample weighting (sklearn `class_weight`,
131    /// `_ridge.py:1398`). Reweights samples by class membership before the
132    /// weighted ridge fit, multiplying into any user `sample_weight`
133    /// (`_ridge.py:1305-1307`, mirroring
134    /// `sklearn.utils.class_weight.compute_class_weight`). Default
135    /// [`ClassWeight::None`] (all classes weight `1.0`,
136    /// matching sklearn `class_weight=None`, `_ridge.py:1400`).
137    pub class_weight: ClassWeight<F>,
138}
139
140impl<F: Float> RidgeClassifier<F> {
141    /// Create a new `RidgeClassifier` with default settings.
142    ///
143    /// Defaults: `alpha = 1.0`, `fit_intercept = true`, `max_iter = None`,
144    /// `tol = 1e-4`, `positive = false` — mirroring sklearn's ctor defaults
145    /// (`sklearn/linear_model/_ridge.py:1514-1526`, `positive=False`
146    /// `_ridge.py:902`).
147    #[must_use]
148    pub fn new() -> Self {
149        Self {
150            alpha: F::one(),
151            fit_intercept: true,
152            max_iter: None,
153            tol: F::from(1e-4).unwrap_or_else(F::epsilon),
154            positive: false,
155            class_weight: ClassWeight::None,
156        }
157    }
158
159    /// Set the regularization strength.
160    #[must_use]
161    pub fn with_alpha(mut self, alpha: F) -> Self {
162        self.alpha = alpha;
163        self
164    }
165
166    /// Set whether to fit an intercept term.
167    #[must_use]
168    pub fn with_fit_intercept(mut self, fit_intercept: bool) -> Self {
169        self.fit_intercept = fit_intercept;
170        self
171    }
172
173    /// Set the maximum number of iterations for iterative solvers (sklearn
174    /// `max_iter`, `_ridge.py:1520`).
175    ///
176    /// ferrolearn's direct solver solves in closed form with no iteration, so
177    /// this is stored for sklearn ABI parity and does not affect the computed
178    /// result.
179    #[must_use]
180    pub fn with_max_iter(mut self, max_iter: Option<usize>) -> Self {
181        self.max_iter = max_iter;
182        self
183    }
184
185    /// Set the convergence tolerance for iterative solvers (sklearn `tol`,
186    /// `_ridge.py:1521`).
187    ///
188    /// ferrolearn's direct solver solves in closed form with no iteration, so
189    /// this is stored for sklearn ABI parity and does not affect the computed
190    /// result.
191    #[must_use]
192    pub fn with_tol(mut self, tol: F) -> Self {
193        self.tol = tol;
194        self
195    }
196
197    /// Constrain the fitted coefficients to be non-negative (sklearn
198    /// `positive`, `_ridge.py:902`/`:911`).
199    ///
200    /// When `true`, each indicator-target ridge solve is routed through
201    /// projected coordinate descent (`crate::linalg::nonneg_ridge_cd`) using
202    /// `max_iter`/`tol`, yielding the same optimum sklearn reaches with its
203    /// L-BFGS-B solver for `positive=True` (`_ridge.py:329`). `false` (default)
204    /// is byte-identical to the unconstrained closed-form path.
205    #[must_use]
206    pub fn with_positive(mut self, positive: bool) -> Self {
207        self.positive = positive;
208        self
209    }
210
211    /// Set the per-class sample weighting strategy (sklearn `class_weight`,
212    /// `_ridge.py:1398`).
213    ///
214    /// The selected per-class weight reweights samples by class membership and
215    /// multiplies into any user `sample_weight` before the weighted ridge fit
216    /// (`_ridge.py:1305-1307`, mirroring
217    /// `sklearn.utils.class_weight.compute_class_weight`). [`ClassWeight::None`]
218    /// (default) leaves the unweighted fit byte-identical.
219    #[must_use]
220    pub fn with_class_weight(mut self, class_weight: ClassWeight<F>) -> Self {
221        self.class_weight = class_weight;
222        self
223    }
224}
225
226impl<F: Float> Default for RidgeClassifier<F> {
227    fn default() -> Self {
228        Self::new()
229    }
230}
231
232/// Fitted Ridge Classifier.
233///
234/// Stores the learned coefficient matrix, intercept vector, and class labels.
235#[derive(Debug, Clone)]
236pub struct FittedRidgeClassifier<F> {
237    /// Coefficient matrix, shape `(n_features, n_targets)`.
238    /// For binary, `n_targets = 1`.
239    coef_matrix: Array2<F>,
240    /// Intercept vector, one per target.
241    intercept_vec: Array1<F>,
242    /// For HasCoefficients: first column of coef_matrix.
243    coefficients: Array1<F>,
244    /// For HasCoefficients: first element of intercept_vec.
245    intercept: F,
246    /// Sorted unique class labels.
247    classes: Vec<usize>,
248    /// Whether this is a binary problem.
249    is_binary: bool,
250    /// Number of features.
251    n_features: usize,
252    /// Number of iterations run by an iterative solver, or `None` for the
253    /// direct solver (sklearn `n_iter_`, `_ridge.py:1464`). `None` on the
254    /// unconstrained direct dense path; `Some(iters)` (worst-case over target
255    /// columns) on the `positive=true` projected-coordinate-descent path.
256    n_iter_: Option<usize>,
257}
258
259impl<F: Float> FittedRidgeClassifier<F> {
260    /// Returns the full coefficient matrix, shape `(n_features, n_targets)`.
261    #[must_use]
262    pub fn coef_matrix(&self) -> &Array2<F> {
263        &self.coef_matrix
264    }
265
266    /// Returns the intercept vector.
267    #[must_use]
268    pub fn intercept_vec(&self) -> &Array1<F> {
269        &self.intercept_vec
270    }
271
272    /// Return the number of iterations run by an iterative solver, or `None`
273    /// for the direct solver (sklearn `n_iter_`, `_ridge.py:1464`).
274    ///
275    /// `None` on the unconstrained direct path (matching
276    /// `RidgeClassifier(alpha=1.0).fit(X,y).n_iter_`); `Some(iters)` on the
277    /// `positive=true` projected-coordinate-descent path.
278    #[must_use]
279    pub fn n_iter(&self) -> Option<usize> {
280        self.n_iter_
281    }
282}
283
284impl<F: Float + ndarray::ScalarOperand + Send + Sync + 'static> FittedRidgeClassifier<F> {
285    /// Raw `X @ coef + intercept` per class. Mirrors sklearn
286    /// `RidgeClassifier.decision_function`.
287    ///
288    /// Returns shape `(n_samples, n_classes)`. argmax of each row agrees
289    /// with [`Predict`].
290    ///
291    /// # Errors
292    ///
293    /// Returns [`FerroError::ShapeMismatch`] if the number of features
294    /// does not match the fitted model.
295    pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
296        let n_features = x.ncols();
297        if n_features != self.n_features {
298            return Err(FerroError::ShapeMismatch {
299                expected: vec![self.n_features],
300                actual: vec![n_features],
301                context: "number of features must match fitted model".into(),
302            });
303        }
304        Ok(x.dot(&self.coef_matrix) + &self.intercept_vec)
305    }
306}
307
308impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
309    Fit<Array2<F>, Array1<usize>> for RidgeClassifier<F>
310{
311    type Fitted = FittedRidgeClassifier<F>;
312    type Error = FerroError;
313
314    /// Fit the Ridge Classifier by converting labels to a binary indicator
315    /// matrix and solving multivariate Ridge regression.
316    ///
317    /// Equivalent to [`RidgeClassifier::fit_with_sample_weight`] with
318    /// `sample_weight = None`.
319    ///
320    /// # Errors
321    ///
322    /// - [`FerroError::ShapeMismatch`] — sample count mismatch.
323    /// - [`FerroError::InvalidParameter`] — negative alpha.
324    /// - [`FerroError::InsufficientSamples`] — fewer than 2 classes.
325    fn fit(
326        &self,
327        x: &Array2<F>,
328        y: &Array1<usize>,
329    ) -> Result<FittedRidgeClassifier<F>, FerroError> {
330        // Unweighted fit is the `sample_weight=None` arm of the weighted fit,
331        // mirroring sklearn `RidgeClassifier.fit(X, y, sample_weight=None)`
332        // (`_ridge.py:1220`, default `sample_weight=None`).
333        self.fit_with_sample_weight(x, y, None)
334    }
335}
336
337impl<F: Float + Send + Sync + ScalarOperand + FromPrimitive + LinalgFloat + 'static>
338    RidgeClassifier<F>
339{
340    /// Fit the Ridge Classifier with optional per-sample weights.
341    ///
342    /// Mirrors scikit-learn's `RidgeClassifier.fit(X, y, sample_weight=None)`
343    /// (`sklearn/linear_model/_ridge.py:1220`). The target is encoded as an
344    /// indicator matrix `Y` (binary `{-1, +1}` single column, multiclass
345    /// one-hot) via `LabelBinarizer(pos_label=1, neg_label=-1)`
346    /// (`_ridge.py:1300-1301`), then the underlying weighted ridge is solved on
347    /// `(X, Y)`: `sample_weight` is forwarded into `_BaseRidge.fit` which does
348    /// weighted `_preprocess_data` (weighted offsets) + `_rescale_data` (`√w`
349    /// row-rescale, `_ridge.py:682-688`).
350    ///
351    /// When `sample_weight` is `Some(w)` and `fit_intercept` is `true`, the
352    /// weighted offsets `x_off[j] = Σᵢ wᵢ·x[i,j] / Σwᵢ`,
353    /// `y_off[t] = Σᵢ wᵢ·Y[i,t] / Σwᵢ` center `X`/`Y`, then each row is rescaled
354    /// by `√wᵢ` before the per-target ridge solve, and
355    /// `intercept[t] = y_off[t] − Σⱼ x_off[j]·coef[j,t]`. With `fit_intercept`
356    /// `false` only the `√w` row-rescale is applied and the intercept is `0`.
357    ///
358    /// `sample_weight = None` is BYTE-IDENTICAL to [`Fit::fit`] (the unweighted
359    /// centering + per-target `linalg::solve_ridge` path).
360    ///
361    /// # Errors
362    ///
363    /// - [`FerroError::ShapeMismatch`] — sample count mismatch, or
364    ///   `sample_weight.len() != n_samples`.
365    /// - [`FerroError::InvalidParameter`] — negative alpha.
366    /// - [`FerroError::InsufficientSamples`] — fewer than 2 classes / no samples.
367    pub fn fit_with_sample_weight(
368        &self,
369        x: &Array2<F>,
370        y: &Array1<usize>,
371        sample_weight: Option<&Array1<F>>,
372    ) -> Result<FittedRidgeClassifier<F>, FerroError> {
373        let (n_samples, n_features) = x.dim();
374
375        if n_samples != y.len() {
376            return Err(FerroError::ShapeMismatch {
377                expected: vec![n_samples],
378                actual: vec![y.len()],
379                context: "y length must match number of samples in X".into(),
380            });
381        }
382
383        // `<F as num_traits::Zero>::zero()`: the `LinalgFloat` bound pulls
384        // `ferray::Element` (which also defines `zero`/`one`) into scope, so
385        // bare `F::zero()`/`F::one()` are ambiguous between `Element` and
386        // `num_traits`. Disambiguate to the `num_traits` items used elsewhere.
387        if self.alpha < <F as num_traits::Zero>::zero() {
388            return Err(FerroError::InvalidParameter {
389                name: "alpha".into(),
390                reason: "must be non-negative".into(),
391            });
392        }
393
394        if let Some(w) = sample_weight
395            && w.len() != n_samples
396        {
397            return Err(FerroError::ShapeMismatch {
398                expected: vec![n_samples],
399                actual: vec![w.len()],
400                context: "sample_weight length must match number of samples in X".into(),
401            });
402        }
403
404        // Non-finite input validation, mirroring sklearn's `_validate_data(X, y,
405        // ...)` in `RidgeClassifier._prepare_data` (`_ridge.py:1291-1298`) which
406        // keeps the default `force_all_finite=True`, so `check_array` rejects any
407        // NaN or +/-inf in X with a `ValueError` BEFORE the indicator solve.
408        // RidgeClassifier solves the binarized indicator targets DIRECTLY via
409        // `linalg::solve_ridge`/`nonneg_ridge_cd` (it does NOT delegate to the
410        // #2259-guarded `Ridge::fit`), so X must be checked here. The target `y`
411        // is `Array1<usize>` — finite by type, no `y` check needed (sklearn
412        // binarizes the labels). sklearn also validates `sample_weight` via
413        // `_check_sample_weight` (default `force_all_finite=True`,
414        // `_ridge.py:1305`). `.iter().any(|v| !v.is_finite())` rejects both NaN
415        // and Inf (bounds-safe, no panic, R-CODE-2). The finite path is byte-
416        // identical (the guard never fires on finite input). `Fit::fit` delegates
417        // here with `None`.
418        if x.iter().any(|v| !v.is_finite()) {
419            return Err(FerroError::InvalidParameter {
420                name: "X".into(),
421                reason: "Input X contains NaN or infinity.".into(),
422            });
423        }
424        if let Some(w) = sample_weight
425            && w.iter().any(|v| !v.is_finite())
426        {
427            return Err(FerroError::InvalidParameter {
428                name: "sample_weight".into(),
429                reason: "Input sample_weight contains NaN or infinity.".into(),
430            });
431        }
432
433        let mut classes: Vec<usize> = y.to_vec();
434        classes.sort_unstable();
435        classes.dedup();
436
437        if classes.len() < 2 {
438            return Err(FerroError::InsufficientSamples {
439                required: 2,
440                actual: classes.len(),
441                context: "RidgeClassifier requires at least 2 distinct classes".into(),
442            });
443        }
444
445        if n_samples == 0 {
446            return Err(FerroError::InsufficientSamples {
447                required: 1,
448                actual: 0,
449                context: "RidgeClassifier requires at least one sample".into(),
450            });
451        }
452
453        // Per-class reweighting (sklearn `class_weight`, `_ridge.py:1398`).
454        // `compute_class_weight` (`class_weight.py`) yields a per-class weight;
455        // it then multiplies into the (defaulted-to-ones) user `sample_weight`
456        // (`_ridge.py:1305-1307`,
457        // `sample_weight = sample_weight * compute_sample_weight(...)`). We only
458        // synthesize a weight vector for Balanced/Explicit; `ClassWeight::None`
459        // leaves `sample_weight` untouched so the unweighted fast-path stays
460        // byte-identical.
461        let class_weighted = match &self.class_weight {
462            ClassWeight::None => None,
463            ClassWeight::Balanced => {
464                // w_class[c] = n_samples / (n_classes * count[c]).
465                let n_classes = classes.len();
466                let n_samples_f =
467                    F::from(n_samples).ok_or_else(|| FerroError::NumericalInstability {
468                        message: "failed to convert n_samples to float".into(),
469                    })?;
470                let n_classes_f =
471                    F::from(n_classes).ok_or_else(|| FerroError::NumericalInstability {
472                        message: "failed to convert n_classes to float".into(),
473                    })?;
474                let mut w_class = vec![<F as num_traits::One>::one(); n_classes];
475                for (ci, &c) in classes.iter().enumerate() {
476                    let count = y.iter().filter(|&&label| label == c).count();
477                    let count_f =
478                        F::from(count).ok_or_else(|| FerroError::NumericalInstability {
479                            message: "failed to convert class count to float".into(),
480                        })?;
481                    w_class[ci] = n_samples_f / (n_classes_f * count_f);
482                }
483                Some(w_class)
484            }
485            ClassWeight::Explicit(map) => {
486                // w_class[c] = map[c] if present else 1.0.
487                let mut w_class = Vec::with_capacity(classes.len());
488                for &c in &classes {
489                    w_class.push(
490                        map.get(&c)
491                            .copied()
492                            .unwrap_or(<F as num_traits::One>::one()),
493                    );
494                }
495                Some(w_class)
496            }
497        };
498
499        // Materialize the effective per-sample weight vector when class
500        // weighting is active, multiplying the per-class weight into the
501        // user-supplied base (or ones). Keep it alive for the borrow below.
502        let effective_weight: Option<Array1<F>> = match &class_weighted {
503            None => None,
504            Some(w_class) => {
505                let mut eff = Array1::<F>::zeros(n_samples);
506                for i in 0..n_samples {
507                    let ci = classes.iter().position(|&c| c == y[i]).ok_or_else(|| {
508                        FerroError::NumericalInstability {
509                            message: "class label missing from class set".into(),
510                        }
511                    })?;
512                    let base = match sample_weight {
513                        Some(sw) => sw[i],
514                        None => <F as num_traits::One>::one(),
515                    };
516                    eff[i] = w_class[ci] * base;
517                }
518                Some(eff)
519            }
520        };
521
522        // Route the effective weights into the SAME weighted solve below.
523        // `None` (ClassWeight::None) preserves the original `sample_weight`
524        // (incl. the byte-identical unweighted `None` fast-path).
525        let sample_weight: Option<&Array1<F>> = match &effective_weight {
526            Some(eff) => Some(eff),
527            None => sample_weight,
528        };
529
530        let is_binary = classes.len() == 2;
531
532        // Build indicator matrix Y.
533        let n_targets = if is_binary { 1 } else { classes.len() };
534        let mut y_indicator = Array2::<F>::zeros((n_samples, n_targets));
535
536        if is_binary {
537            // Binary: encode as {-1, +1}.
538            for i in 0..n_samples {
539                y_indicator[[i, 0]] = if y[i] == classes[1] {
540                    <F as num_traits::One>::one()
541                } else {
542                    -<F as num_traits::One>::one()
543                };
544            }
545        } else {
546            // Multiclass: one-hot.
547            for i in 0..n_samples {
548                // `classes` is the sorted-deduped image of `y`, so `y[i]` is
549                // always present; fall back to a typed error rather than panic.
550                let ci = classes.iter().position(|&c| c == y[i]).ok_or_else(|| {
551                    FerroError::NumericalInstability {
552                        message: "class label missing from class set".into(),
553                    }
554                })?;
555                y_indicator[[i, ci]] = <F as num_traits::One>::one();
556            }
557        }
558
559        // Center data if fit_intercept. With sample_weight the offsets are the
560        // weighted means and the rows are √w-rescaled before the solve
561        // (sklearn `_preprocess_data` weighted + `_rescale_data`,
562        // `_ridge.py:682-688`); the penalty `alpha` stays UNSCALED since
563        // (√w·Xc)ᵀ(√w·Xc) == Xcᵀ·W·Xc.
564        let (x_work, y_work, x_off, y_off) = match sample_weight {
565            None => {
566                if self.fit_intercept {
567                    let x_mean =
568                        x.mean_axis(Axis(0))
569                            .ok_or_else(|| FerroError::NumericalInstability {
570                                message: "failed to compute column means".into(),
571                            })?;
572                    let y_mean = y_indicator.mean_axis(Axis(0)).ok_or_else(|| {
573                        FerroError::NumericalInstability {
574                            message: "failed to compute target means".into(),
575                        }
576                    })?;
577                    let x_c = x - &x_mean;
578                    let y_c = &y_indicator - &y_mean;
579                    (x_c, y_c, Some(x_mean), Some(y_mean))
580                } else {
581                    (x.clone(), y_indicator.clone(), None, None)
582                }
583            }
584            Some(w) => {
585                // Per-row √w factor (sklearn `_rescale_data`, `_ridge.py:682-688`).
586                let w_sqrt = w.mapv(<F as Float>::sqrt);
587
588                if self.fit_intercept {
589                    // WEIGHTED offsets: x_off[j] = Σ wᵢ x[i,j] / Σ wᵢ,
590                    // y_off[t] = Σ wᵢ Y[i,t] / Σ wᵢ.
591                    let w_sum = w.sum();
592                    if w_sum <= <F as num_traits::Zero>::zero() {
593                        return Err(FerroError::NumericalInstability {
594                            message: "sum of sample_weight must be positive to center".into(),
595                        });
596                    }
597
598                    let mut x_mean = Array1::<F>::zeros(n_features);
599                    for (i, row) in x.outer_iter().enumerate() {
600                        let wi = w[i];
601                        x_mean = &x_mean + &row.mapv(|v| v * wi);
602                    }
603                    x_mean.mapv_inplace(|v| v / w_sum);
604
605                    let mut y_mean = Array1::<F>::zeros(n_targets);
606                    for (i, row) in y_indicator.outer_iter().enumerate() {
607                        let wi = w[i];
608                        y_mean = &y_mean + &row.mapv(|v| v * wi);
609                    }
610                    y_mean.mapv_inplace(|v| v / w_sum);
611
612                    let x_centered = x - &x_mean;
613                    let y_centered = &y_indicator - &y_mean;
614                    let x_scaled = &x_centered * &w_sqrt.view().insert_axis(Axis(1));
615                    let y_scaled = &y_centered * &w_sqrt.view().insert_axis(Axis(1));
616                    (x_scaled, y_scaled, Some(x_mean), Some(y_mean))
617                } else {
618                    // No centering; just √w row-rescaling, intercept 0.
619                    let x_scaled = x * &w_sqrt.view().insert_axis(Axis(1));
620                    let y_scaled = &y_indicator * &w_sqrt.view().insert_axis(Axis(1));
621                    (x_scaled, y_scaled, None, None)
622                }
623            }
624        };
625
626        // Solve Ridge for each target column. When `self.positive`, the
627        // coefficient solve is constrained to be non-negative via projected
628        // coordinate descent (`crate::linalg::nonneg_ridge_cd`, the same kernel
629        // `Ridge` uses), mirroring sklearn's `positive=True` L-BFGS-B optimum
630        // (`_ridge.py:329`); otherwise the unconstrained closed-form
631        // `linalg::solve_ridge` path is byte-identical to before.
632        let mut coef_matrix = Array2::<F>::zeros((n_features, n_targets));
633        let mut n_iter_ = None;
634        if self.positive {
635            let max_iter = self.max_iter.unwrap_or(1000);
636            let mut max_iters_over_targets = 0usize;
637            for t in 0..n_targets {
638                let y_col = y_work.column(t).to_owned();
639                let (w, iters) =
640                    linalg::nonneg_ridge_cd(&x_work, &y_col, self.alpha, max_iter, self.tol);
641                if iters > max_iters_over_targets {
642                    max_iters_over_targets = iters;
643                }
644                for j in 0..n_features {
645                    coef_matrix[[j, t]] = w[j];
646                }
647            }
648            // The positive path is iterative; report the worst-case iteration
649            // count across target columns (mirrors `Ridge`'s positive `n_iter_`).
650            n_iter_ = Some(max_iters_over_targets);
651        } else {
652            for t in 0..n_targets {
653                let y_col = y_work.column(t).to_owned();
654                let w = linalg::solve_ridge(&x_work, &y_col, self.alpha)?;
655                for j in 0..n_features {
656                    coef_matrix[[j, t]] = w[j];
657                }
658            }
659        }
660
661        // Compute intercepts.
662        let intercept_vec = if let (Some(xm), Some(ym)) = (&x_off, &y_off) {
663            let xm_dot = xm.dot(&coef_matrix);
664            ym - &xm_dot
665        } else {
666            Array1::<F>::zeros(n_targets)
667        };
668
669        let coefficients = coef_matrix.column(0).to_owned();
670        let intercept = intercept_vec[0];
671
672        Ok(FittedRidgeClassifier {
673            coef_matrix,
674            intercept_vec,
675            coefficients,
676            intercept,
677            classes,
678            is_binary,
679            n_features,
680            n_iter_,
681        })
682    }
683}
684
685impl<F: Float + Send + Sync + ScalarOperand + 'static> Predict<Array2<F>>
686    for FittedRidgeClassifier<F>
687{
688    type Output = Array1<usize>;
689    type Error = FerroError;
690
691    /// Predict class labels for the given feature matrix.
692    ///
693    /// Computes `X @ coef_matrix + intercept_vec` and takes `argmax` per row.
694    ///
695    /// # Errors
696    ///
697    /// Returns [`FerroError::ShapeMismatch`] if the number of features
698    /// does not match the fitted model.
699    fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
700        let n_features = x.ncols();
701        if n_features != self.n_features {
702            return Err(FerroError::ShapeMismatch {
703                expected: vec![self.n_features],
704                actual: vec![n_features],
705                context: "number of features must match fitted model".into(),
706            });
707        }
708
709        let n_samples = x.nrows();
710        let mut predictions = Array1::<usize>::zeros(n_samples);
711
712        // Compute decision values: X @ coef_matrix + intercept_vec.
713        let scores = x.dot(&self.coef_matrix) + &self.intercept_vec;
714
715        if self.is_binary {
716            for i in 0..n_samples {
717                // sklearn `LinearClassifierMixin.predict` uses STRICT `scores > 0`
718                // (`sklearn/linear_model/_base.py:384`:
719                // `indices = xp.astype(scores > 0, ...)`), so a decision of
720                // exactly 0 maps to index 0 -> `classes_[0]`.
721                predictions[i] = if scores[[i, 0]] > <F as num_traits::Zero>::zero() {
722                    self.classes[1]
723                } else {
724                    self.classes[0]
725                };
726            }
727        } else {
728            for i in 0..n_samples {
729                let mut best_class = 0;
730                let mut best_score = scores[[i, 0]];
731                for c in 1..self.classes.len() {
732                    if scores[[i, c]] > best_score {
733                        best_score = scores[[i, c]];
734                        best_class = c;
735                    }
736                }
737                predictions[i] = self.classes[best_class];
738            }
739        }
740
741        Ok(predictions)
742    }
743}
744
745impl<F: Float + Send + Sync + ScalarOperand + 'static> HasCoefficients<F>
746    for FittedRidgeClassifier<F>
747{
748    fn coefficients(&self) -> &Array1<F> {
749        &self.coefficients
750    }
751
752    fn intercept(&self) -> F {
753        self.intercept
754    }
755}
756
757impl<F: Float + Send + Sync + ScalarOperand + 'static> HasClasses for FittedRidgeClassifier<F> {
758    fn classes(&self) -> &[usize] {
759        &self.classes
760    }
761
762    fn n_classes(&self) -> usize {
763        self.classes.len()
764    }
765}
766
767#[cfg(test)]
768mod tests {
769    use super::*;
770    use ndarray::array;
771
772    #[test]
773    fn ridge_classifier_sample_weight_matches_sklearn() {
774        // Live sklearn 1.5.2 oracle (R-CHAR-3):
775        //   cd /tmp && python3 -c "import numpy as np; \
776        //     from sklearn.linear_model import RidgeClassifier; \
777        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.],[5.,5.],[1.,1.],[4.,4.]]); \
778        //     y=np.array([0,0,1,1,1,0,1]); w=np.array([1.,3.,1.,1.,2.,1.,1.]); \
779        //     m=RidgeClassifier(alpha=1.0).fit(X,y,sample_weight=w); \
780        //     print([round(c,8) for c in m.coef_[0]], round(m.intercept_[0],8))"
781        //   -> weighted   coef_ [0.25333333, 0.36],       intercept_ -1.70666667
782        //   -> unweighted coef_ [0.31840796, 0.31840796], intercept_ -1.67661692
783        let x = Array2::from_shape_vec(
784            (7, 2),
785            vec![
786                1.0, 2.0, 2.0, 1.0, 3.0, 4.0, 4.0, 3.0, 5.0, 5.0, 1.0, 1.0, 4.0, 4.0,
787            ],
788        )
789        .unwrap();
790        let y = array![0usize, 0, 1, 1, 1, 0, 1];
791        let w = array![1.0, 3.0, 1.0, 1.0, 2.0, 1.0, 1.0];
792
793        let model = RidgeClassifier::<f64>::new().with_alpha(1.0);
794        let fitted = model.fit_with_sample_weight(&x, &y, Some(&w)).unwrap();
795
796        // Binary => single target column 0; coef_matrix is (n_features, n_targets).
797        let coef = fitted.coef_matrix();
798        assert!(
799            (coef[[0, 0]] - 0.253_333_33).abs() < 1e-6,
800            "coef[0]={} expected 0.25333333",
801            coef[[0, 0]]
802        );
803        assert!(
804            (coef[[1, 0]] - 0.36).abs() < 1e-6,
805            "coef[1]={} expected 0.36",
806            coef[[1, 0]]
807        );
808        assert!(
809            (fitted.intercept_vec()[0] - (-1.706_666_67)).abs() < 1e-6,
810            "intercept={} expected -1.70666667",
811            fitted.intercept_vec()[0]
812        );
813
814        // Non-tautological: must differ from the unweighted fit.
815        let unweighted = model.fit(&x, &y).unwrap();
816        let uw = unweighted.coef_matrix();
817        assert!(
818            (uw[[0, 0]] - 0.318_407_96).abs() < 1e-6 && (uw[[1, 0]] - 0.318_407_96).abs() < 1e-6,
819            "unweighted oracle mismatch: [{}, {}]",
820            uw[[0, 0]],
821            uw[[1, 0]]
822        );
823        assert!(
824            (coef[[0, 0]] - uw[[0, 0]]).abs() > 1e-3,
825            "weighted fit must differ from unweighted fit"
826        );
827    }
828
829    #[test]
830    fn ridge_classifier_none_sample_weight_equals_unweighted() {
831        // `fit_with_sample_weight(.., None)` must be byte-identical to `fit`.
832        let x = Array2::from_shape_vec(
833            (7, 2),
834            vec![
835                1.0, 2.0, 2.0, 1.0, 3.0, 4.0, 4.0, 3.0, 5.0, 5.0, 1.0, 1.0, 4.0, 4.0,
836            ],
837        )
838        .unwrap();
839        let y = array![0usize, 0, 1, 1, 1, 0, 1];
840
841        let model = RidgeClassifier::<f64>::new().with_alpha(1.0);
842        let via_fit = model.fit(&x, &y).unwrap();
843        let via_none = model.fit_with_sample_weight(&x, &y, None).unwrap();
844
845        assert_eq!(via_fit.coef_matrix(), via_none.coef_matrix());
846        assert_eq!(via_fit.intercept_vec(), via_none.intercept_vec());
847
848        // Same for fit_intercept=false.
849        let model_ni = RidgeClassifier::<f64>::new()
850            .with_alpha(1.0)
851            .with_fit_intercept(false);
852        let via_fit_ni = model_ni.fit(&x, &y).unwrap();
853        let via_none_ni = model_ni.fit_with_sample_weight(&x, &y, None).unwrap();
854        assert_eq!(via_fit_ni.coef_matrix(), via_none_ni.coef_matrix());
855        assert_eq!(via_fit_ni.intercept_vec(), via_none_ni.intercept_vec());
856    }
857
858    #[test]
859    fn test_default_constructor() {
860        let m = RidgeClassifier::<f64>::new();
861        assert!(m.alpha == 1.0);
862        assert!(m.fit_intercept);
863    }
864
865    #[test]
866    fn test_builder() {
867        let m = RidgeClassifier::<f64>::new()
868            .with_alpha(0.5)
869            .with_fit_intercept(false);
870        assert!(m.alpha == 0.5);
871        assert!(!m.fit_intercept);
872    }
873
874    #[test]
875    fn test_binary_classification() {
876        let x = Array2::from_shape_vec(
877            (8, 2),
878            vec![
879                1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 2.0, 2.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0, 9.0, 9.0,
880            ],
881        )
882        .unwrap();
883        let y = array![0, 0, 0, 0, 1, 1, 1, 1];
884
885        let model = RidgeClassifier::<f64>::new();
886        let fitted = model.fit(&x, &y).unwrap();
887        let preds = fitted.predict(&x).unwrap();
888
889        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
890        assert!(correct >= 6, "expected at least 6 correct, got {correct}");
891    }
892
893    #[test]
894    fn test_multiclass_classification() {
895        let x = Array2::from_shape_vec(
896            (9, 2),
897            vec![
898                0.0, 0.0, 0.5, 0.0, 0.0, 0.5, 10.0, 0.0, 10.5, 0.0, 10.0, 0.5, 0.0, 10.0, 0.5,
899                10.0, 0.0, 10.5,
900            ],
901        )
902        .unwrap();
903        let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
904
905        let model = RidgeClassifier::<f64>::new().with_alpha(0.1);
906        let fitted = model.fit(&x, &y).unwrap();
907
908        assert_eq!(fitted.n_classes(), 3);
909        assert_eq!(fitted.classes(), &[0, 1, 2]);
910
911        let preds = fitted.predict(&x).unwrap();
912        let correct: usize = preds.iter().zip(y.iter()).filter(|(p, a)| p == a).count();
913        assert!(correct >= 7, "expected at least 7 correct, got {correct}");
914    }
915
916    #[test]
917    fn test_shape_mismatch() {
918        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
919        let y = array![0, 1]; // Wrong length
920
921        let model = RidgeClassifier::<f64>::new();
922        assert!(model.fit(&x, &y).is_err());
923    }
924
925    #[test]
926    fn test_negative_alpha() {
927        let x = Array2::from_shape_vec((4, 1), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
928        let y = array![0, 0, 1, 1];
929
930        let model = RidgeClassifier::<f64>::new().with_alpha(-1.0);
931        assert!(model.fit(&x, &y).is_err());
932    }
933
934    #[test]
935    fn test_single_class_error() {
936        let x = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
937        let y = array![0, 0, 0];
938
939        let model = RidgeClassifier::<f64>::new();
940        assert!(model.fit(&x, &y).is_err());
941    }
942
943    #[test]
944    fn test_has_coefficients() {
945        let x = Array2::from_shape_vec(
946            (6, 2),
947            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
948        )
949        .unwrap();
950        let y = array![0, 0, 0, 1, 1, 1];
951
952        let fitted = RidgeClassifier::<f64>::new().fit(&x, &y).unwrap();
953        assert_eq!(fitted.coefficients().len(), 2);
954    }
955
956    #[test]
957    fn test_has_classes() {
958        let x = Array2::from_shape_vec(
959            (6, 2),
960            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
961        )
962        .unwrap();
963        let y = array![0, 0, 0, 1, 1, 1];
964
965        let fitted = RidgeClassifier::<f64>::new().fit(&x, &y).unwrap();
966        assert_eq!(fitted.classes(), &[0, 1]);
967        assert_eq!(fitted.n_classes(), 2);
968    }
969
970    #[test]
971    fn test_predict_feature_mismatch() {
972        let x = Array2::from_shape_vec(
973            (6, 2),
974            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
975        )
976        .unwrap();
977        let y = array![0, 0, 0, 1, 1, 1];
978
979        let fitted = RidgeClassifier::<f64>::new().fit(&x, &y).unwrap();
980
981        let x_bad = Array2::from_shape_vec((3, 1), vec![1.0, 2.0, 3.0]).unwrap();
982        assert!(fitted.predict(&x_bad).is_err());
983    }
984
985    #[test]
986    fn ridge_classifier_max_iter_tol_niter_defaults_and_builders()
987    -> Result<(), Box<dyn std::error::Error>> {
988        // Verify sklearn ABI parity for max_iter/tol/n_iter_ (REQ-7, closes #394).
989        //
990        // Live sklearn 1.5.2 oracle (R-CHAR-3):
991        //   python3 -c "from sklearn.linear_model import RidgeClassifier; \
992        //     import numpy as np; \
993        //     m=RidgeClassifier(); print(m.max_iter, m.tol); \
994        //     X=np.array([[1.,2.],[2.,1.],[3.,4.],[4.,3.]],float); \
995        //     y=np.array([0,0,1,1]); f=m.fit(X,y); print(f.n_iter_)"
996        //   -> None 0.0001
997        //      None
998
999        // Default constructor fields match sklearn defaults.
1000        let m = RidgeClassifier::<f64>::new();
1001        assert_eq!(m.max_iter, None, "default max_iter should be None");
1002        assert!(
1003            (m.tol - 1e-4_f64).abs() < 1e-12,
1004            "default tol should be 1e-4, got {}",
1005            m.tol
1006        );
1007
1008        // Builder round-trips.
1009        let m2 = RidgeClassifier::<f64>::new().with_max_iter(Some(500));
1010        assert_eq!(m2.max_iter, Some(500));
1011
1012        let m3 = RidgeClassifier::<f64>::new().with_tol(1e-6);
1013        assert!((m3.tol - 1e-6_f64).abs() < 1e-15, "got {}", m3.tol);
1014
1015        // n_iter_ is always None for the direct solver (oracle: None).
1016        let x = Array2::from_shape_vec((4, 2), vec![1.0_f64, 2.0, 2.0, 1.0, 3.0, 4.0, 4.0, 3.0])?;
1017        let y = array![0usize, 0, 1, 1];
1018
1019        let fitted = RidgeClassifier::<f64>::new().fit(&x, &y)?;
1020        assert_eq!(
1021            fitted.n_iter(),
1022            None,
1023            "direct solver must report n_iter_=None"
1024        );
1025
1026        // max_iter/tol are no-ops: coef/intercept byte-identical regardless.
1027        let fitted_mi = RidgeClassifier::<f64>::new()
1028            .with_max_iter(Some(500))
1029            .with_tol(1e-6)
1030            .fit(&x, &y)?;
1031        assert_eq!(
1032            fitted.coef_matrix(),
1033            fitted_mi.coef_matrix(),
1034            "max_iter/tol must not change coef for the direct solver"
1035        );
1036        assert_eq!(
1037            fitted.intercept_vec(),
1038            fitted_mi.intercept_vec(),
1039            "max_iter/tol must not change intercept for the direct solver"
1040        );
1041        Ok(())
1042    }
1043
1044    #[test]
1045    fn ridge_classifier_positive_matches_sklearn() -> Result<(), Box<dyn std::error::Error>> {
1046        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1047        //   cd /tmp && python3 -c "import numpy as np; \
1048        //     from sklearn.linear_model import RidgeClassifier; \
1049        //     X=np.array([[1.,5.],[2.,4.],[3.,3.],[4.,2.],[5.,1.],[1.,4.],[5.,2.]]); \
1050        //     y=np.array([0,0,1,1,1,0,1]); \
1051        //     m=RidgeClassifier(alpha=1.0,positive=True).fit(X,y); \
1052        //     print([round(c,8) for c in m.coef_[0]], round(m.intercept_[0],8))"
1053        //   -> positive=True   coef_ [0.52631579, 0.0],         intercept_ -1.43609023
1054        //   -> unconstrained   coef_ [0.35294118, -0.23529412], intercept_ -0.21008403
1055        let x = Array2::from_shape_vec(
1056            (7, 2),
1057            vec![
1058                1.0, 5.0, 2.0, 4.0, 3.0, 3.0, 4.0, 2.0, 5.0, 1.0, 1.0, 4.0, 5.0, 2.0,
1059            ],
1060        )?;
1061        let y = array![0usize, 0, 1, 1, 1, 0, 1];
1062
1063        let model = RidgeClassifier::<f64>::new()
1064            .with_alpha(1.0)
1065            .with_positive(true);
1066        let fitted = model.fit(&x, &y)?;
1067
1068        // Binary => single target column 0; coef_matrix is (n_features, n_targets).
1069        let coef = fitted.coef_matrix();
1070        assert!(
1071            (coef[[0, 0]] - 0.526_315_79).abs() < 1e-5,
1072            "coef[0]={} expected 0.52631579",
1073            coef[[0, 0]]
1074        );
1075        assert!(
1076            (coef[[1, 0]] - 0.0).abs() < 1e-5,
1077            "coef[1]={} expected 0.0",
1078            coef[[1, 0]]
1079        );
1080        assert!(
1081            (fitted.intercept_vec()[0] - (-1.436_090_23)).abs() < 1e-4,
1082            "intercept={} expected -1.43609023",
1083            fitted.intercept_vec()[0]
1084        );
1085
1086        // All coefficients must be non-negative.
1087        for &c in coef.iter() {
1088            assert!(c >= 0.0, "positive=true coefficient {c} must be >= 0");
1089        }
1090
1091        // Non-tautological: must DIFFER from the unconstrained fit
1092        // [0.35294118, -0.23529412] (the negative feature-1 coef clamps to 0).
1093        let unconstrained = RidgeClassifier::<f64>::new().with_alpha(1.0).fit(&x, &y)?;
1094        let uc = unconstrained.coef_matrix();
1095        assert!(
1096            (uc[[0, 0]] - 0.352_941_18).abs() < 1e-5 && (uc[[1, 0]] - (-0.235_294_12)).abs() < 1e-5,
1097            "unconstrained oracle mismatch: [{}, {}]",
1098            uc[[0, 0]],
1099            uc[[1, 0]]
1100        );
1101        assert!(
1102            (coef[[1, 0]] - uc[[1, 0]]).abs() > 1e-2,
1103            "positive fit must differ from unconstrained fit"
1104        );
1105
1106        // The positive (iterative CD) path reports an iteration count.
1107        assert!(
1108            fitted.n_iter().is_some(),
1109            "positive path should report n_iter_"
1110        );
1111        Ok(())
1112    }
1113
1114    #[test]
1115    fn ridge_classifier_positive_false_unchanged() -> Result<(), Box<dyn std::error::Error>> {
1116        // `with_positive(false)` (default) must be byte-identical to plain `fit`.
1117        let x = Array2::from_shape_vec(
1118            (7, 2),
1119            vec![
1120                1.0, 5.0, 2.0, 4.0, 3.0, 3.0, 4.0, 2.0, 5.0, 1.0, 1.0, 4.0, 5.0, 2.0,
1121            ],
1122        )?;
1123        let y = array![0usize, 0, 1, 1, 1, 0, 1];
1124
1125        let baseline = RidgeClassifier::<f64>::new().with_alpha(1.0);
1126        let via_default = baseline.fit(&x, &y)?;
1127        let via_false = baseline.clone().with_positive(false).fit(&x, &y)?;
1128
1129        assert_eq!(via_default.coef_matrix(), via_false.coef_matrix());
1130        assert_eq!(via_default.intercept_vec(), via_false.intercept_vec());
1131        assert_eq!(via_default.n_iter(), via_false.n_iter());
1132        Ok(())
1133    }
1134
1135    #[test]
1136    fn ridge_classifier_class_weight_balanced_matches_sklearn() -> Result<(), FerroError> {
1137        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1138        //   python3 -c "import numpy as np; \
1139        //     from sklearn.linear_model import RidgeClassifier; \
1140        //     X=np.array([[1,2],[2,1],[3,1],[1,3],[2,2],[3,3],[6,5],[5,6]],float); \
1141        //     y=np.array([0,0,0,0,0,0,1,1]); \
1142        //     m=RidgeClassifier(alpha=1.0,class_weight='balanced').fit(X,y); \
1143        //     print([round(c,10) for c in m.coef_[0]], round(m.intercept_[0],10))"
1144        //   -> coef_ [0.2692307692, 0.2692307692], intercept_ -2.0192307692
1145        // balanced per-class weights: class0 = 8/(2*6) = 0.6666666667,
1146        //                             class1 = 8/(2*2) = 2.0.
1147        let x = Array2::from_shape_vec(
1148            (8, 2),
1149            vec![
1150                1.0, 2.0, 2.0, 1.0, 3.0, 1.0, 1.0, 3.0, 2.0, 2.0, 3.0, 3.0, 6.0, 5.0, 5.0, 6.0,
1151            ],
1152        )
1153        .map_err(|e| FerroError::NumericalInstability {
1154            message: e.to_string(),
1155        })?;
1156        let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
1157
1158        let model = RidgeClassifier::<f64>::new()
1159            .with_alpha(1.0)
1160            .with_class_weight(ClassWeight::Balanced);
1161        let fitted = model.fit(&x, &y)?;
1162        let coef = fitted.coef_matrix();
1163        assert!(
1164            (coef[[0, 0]] - 0.269_230_769_2).abs() < 1e-6,
1165            "coef[0]={} expected 0.2692307692",
1166            coef[[0, 0]]
1167        );
1168        assert!(
1169            (coef[[1, 0]] - 0.269_230_769_2).abs() < 1e-6,
1170            "coef[1]={} expected 0.2692307692",
1171            coef[[1, 0]]
1172        );
1173        assert!(
1174            (fitted.intercept_vec()[0] - (-2.019_230_769_2)).abs() < 1e-6,
1175            "intercept={} expected -2.0192307692",
1176            fitted.intercept_vec()[0]
1177        );
1178        Ok(())
1179    }
1180
1181    #[test]
1182    fn ridge_classifier_class_weight_dict_matches_sklearn() -> Result<(), FerroError> {
1183        // Live sklearn 1.5.2 oracle (R-CHAR-3):
1184        //   python3 -c "import numpy as np; \
1185        //     from sklearn.linear_model import RidgeClassifier; \
1186        //     X=np.array([[1,2],[2,1],[3,1],[1,3],[2,2],[3,3],[6,5],[5,6]],float); \
1187        //     y=np.array([0,0,0,0,0,0,1,1]); \
1188        //     m=RidgeClassifier(alpha=1.0,class_weight={0:1.0,1:3.0}).fit(X,y); \
1189        //     print([round(c,10) for c in m.coef_[0]], round(m.intercept_[0],10))"
1190        //   -> coef_ [0.2709677419, 0.2709677419], intercept_ -2.0322580645
1191        use std::collections::HashMap;
1192        let x = Array2::from_shape_vec(
1193            (8, 2),
1194            vec![
1195                1.0, 2.0, 2.0, 1.0, 3.0, 1.0, 1.0, 3.0, 2.0, 2.0, 3.0, 3.0, 6.0, 5.0, 5.0, 6.0,
1196            ],
1197        )
1198        .map_err(|e| FerroError::NumericalInstability {
1199            message: e.to_string(),
1200        })?;
1201        let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
1202
1203        let model = RidgeClassifier::<f64>::new()
1204            .with_alpha(1.0)
1205            .with_class_weight(ClassWeight::Explicit(HashMap::from([
1206                (0usize, 1.0),
1207                (1usize, 3.0),
1208            ])));
1209        let fitted = model.fit(&x, &y)?;
1210        let coef = fitted.coef_matrix();
1211        assert!(
1212            (coef[[0, 0]] - 0.270_967_741_9).abs() < 1e-6,
1213            "coef[0]={} expected 0.2709677419",
1214            coef[[0, 0]]
1215        );
1216        assert!(
1217            (coef[[1, 0]] - 0.270_967_741_9).abs() < 1e-6,
1218            "coef[1]={} expected 0.2709677419",
1219            coef[[1, 0]]
1220        );
1221        assert!(
1222            (fitted.intercept_vec()[0] - (-2.032_258_064_5)).abs() < 1e-6,
1223            "intercept={} expected -2.0322580645",
1224            fitted.intercept_vec()[0]
1225        );
1226        Ok(())
1227    }
1228
1229    #[test]
1230    fn ridge_classifier_class_weight_none_unchanged() -> Result<(), FerroError> {
1231        // Default (ClassWeight::None) must be byte-identical to a plain fit.
1232        // Live sklearn 1.5.2 oracle (R-CHAR-3): RidgeClassifier(alpha=1.0) on
1233        // this data -> coef_ [0.2576687117, 0.2576687117], intercept -1.981595092.
1234        let x = Array2::from_shape_vec(
1235            (8, 2),
1236            vec![
1237                1.0, 2.0, 2.0, 1.0, 3.0, 1.0, 1.0, 3.0, 2.0, 2.0, 3.0, 3.0, 6.0, 5.0, 5.0, 6.0,
1238            ],
1239        )
1240        .map_err(|e| FerroError::NumericalInstability {
1241            message: e.to_string(),
1242        })?;
1243        let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
1244
1245        let model = RidgeClassifier::<f64>::new().with_alpha(1.0);
1246        let plain = model.fit(&x, &y)?;
1247        let none = model
1248            .clone()
1249            .with_class_weight(ClassWeight::None)
1250            .fit(&x, &y)?;
1251
1252        // Byte-identical to the plain fit.
1253        assert_eq!(plain.coef_matrix(), none.coef_matrix());
1254        assert_eq!(plain.intercept_vec(), none.intercept_vec());
1255
1256        // And matches the sklearn None oracle.
1257        let coef = plain.coef_matrix();
1258        assert!(
1259            (coef[[0, 0]] - 0.257_668_711_7).abs() < 1e-6
1260                && (coef[[1, 0]] - 0.257_668_711_7).abs() < 1e-6,
1261            "None oracle mismatch: [{}, {}]",
1262            coef[[0, 0]],
1263            coef[[1, 0]]
1264        );
1265        assert!(
1266            (plain.intercept_vec()[0] - (-1.981_595_092)).abs() < 1e-6,
1267            "intercept={} expected -1.981595092",
1268            plain.intercept_vec()[0]
1269        );
1270        Ok(())
1271    }
1272
1273    #[test]
1274    fn ridge_classifier_class_weight_explicit_equals_balanced() -> Result<(), FerroError> {
1275        // Explicit({0:8/(2*6), 1:8/(2*2)}) must equal Balanced (confirms the
1276        // n_samples/(n_classes*count) formula). Class0=0.6666666667, class1=2.0.
1277        use std::collections::HashMap;
1278        let x = Array2::from_shape_vec(
1279            (8, 2),
1280            vec![
1281                1.0, 2.0, 2.0, 1.0, 3.0, 1.0, 1.0, 3.0, 2.0, 2.0, 3.0, 3.0, 6.0, 5.0, 5.0, 6.0,
1282            ],
1283        )
1284        .map_err(|e| FerroError::NumericalInstability {
1285            message: e.to_string(),
1286        })?;
1287        let y = array![0usize, 0, 0, 0, 0, 0, 1, 1];
1288
1289        let balanced = RidgeClassifier::<f64>::new()
1290            .with_alpha(1.0)
1291            .with_class_weight(ClassWeight::Balanced)
1292            .fit(&x, &y)?;
1293        let explicit = RidgeClassifier::<f64>::new()
1294            .with_alpha(1.0)
1295            .with_class_weight(ClassWeight::Explicit(HashMap::from([
1296                (0usize, 0.666_666_666_7),
1297                (1usize, 2.0),
1298            ])))
1299            .fit(&x, &y)?;
1300
1301        let cb = balanced.coef_matrix();
1302        let ce = explicit.coef_matrix();
1303        assert!(
1304            (cb[[0, 0]] - ce[[0, 0]]).abs() < 1e-9 && (cb[[1, 0]] - ce[[1, 0]]).abs() < 1e-9,
1305            "explicit-balanced coef mismatch: [{}, {}] vs [{}, {}]",
1306            ce[[0, 0]],
1307            ce[[1, 0]],
1308            cb[[0, 0]],
1309            cb[[1, 0]]
1310        );
1311        assert!(
1312            (balanced.intercept_vec()[0] - explicit.intercept_vec()[0]).abs() < 1e-9,
1313            "explicit-balanced intercept mismatch: {} vs {}",
1314            explicit.intercept_vec()[0],
1315            balanced.intercept_vec()[0]
1316        );
1317        Ok(())
1318    }
1319
1320    #[test]
1321    fn test_alpha_zero() {
1322        let x = Array2::from_shape_vec(
1323            (6, 2),
1324            vec![1.0, 1.0, 1.0, 2.0, 2.0, 1.0, 8.0, 8.0, 8.0, 9.0, 9.0, 8.0],
1325        )
1326        .unwrap();
1327        let y = array![0, 0, 0, 1, 1, 1];
1328
1329        let model = RidgeClassifier::<f64>::new().with_alpha(0.0);
1330        let fitted = model.fit(&x, &y).unwrap();
1331        let preds = fitted.predict(&x).unwrap();
1332        assert_eq!(preds.len(), 6);
1333    }
1334}