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}