Skip to main content

ferrolearn_linear/
lib.rs

1//! # ferrolearn-linear
2//!
3//! Linear models for the ferrolearn machine learning framework.
4//!
5//! This crate provides implementations of the most common linear models
6//! for both regression and classification tasks:
7//!
8//! - **[`LinearRegression`]** — Ordinary Least Squares via QR decomposition
9//! - **[`Ridge`]** — L2-regularized regression via Cholesky decomposition
10//! - **[`RidgeCV`]** — Ridge with built-in cross-validated alpha selection
11//! - **[`Lasso`]** — L1-regularized regression via coordinate descent
12//! - **[`LassoCV`]** — Lasso with built-in cross-validated alpha selection
13//! - **[`ElasticNet`]** — Combined L1/L2 regularization via coordinate descent
14//! - **[`ElasticNetCV`]** — ElasticNet with cross-validated (alpha, l1_ratio) selection
15//! - **[`BayesianRidge`]** — Bayesian Ridge with automatic regularization tuning
16//! - **[`HuberRegressor`]** — Robust regression via IRLS with Huber loss
17//! - **[`LogisticRegression`]** — Binary and multiclass classification via L-BFGS
18//!
19//! All models implement the [`ferrolearn_core::Fit`] and [`ferrolearn_core::Predict`]
20//! traits, and produce fitted types that implement [`ferrolearn_core::introspection::HasCoefficients`].
21//!
22//! # Design
23//!
24//! Each model follows the compile-time safety pattern:
25//!
26//! - The unfitted struct (e.g., `LinearRegression<F>`) holds hyperparameters
27//!   and implements [`Fit`](ferrolearn_core::Fit).
28//! - Calling `fit()` produces a new fitted type (e.g., `FittedLinearRegression<F>`)
29//!   that implements [`ferrolearn_core::Predict`].
30//! - Calling `predict()` on an unfitted model is a compile-time error.
31//!
32//! # Pipeline Integration
33//!
34//! All models implement [`PipelineEstimator`](ferrolearn_core::pipeline::PipelineEstimator)
35//! for `f64`, allowing them to be used as the final step in a
36//! [`Pipeline`](ferrolearn_core::pipeline::Pipeline).
37//!
38//! # Float Generics
39//!
40//! All models are generic over `F: num_traits::Float + Send + Sync + 'static`,
41//! supporting both `f32` and `f64`.
42//!
43//! # `## REQ status`
44//!
45//! Binary (R-DEFER-2) for the crate-root RE-EXPORT BOUNDARY (this file is the public-API
46//! surface, not an estimator). Mirrors `sklearn/linear_model/__init__.py` `__all__`
47//! (`:48-98`) + the `score` mixins `sklearn/base.py` `ClassifierMixin.score` (`:738-764`,
48//! accuracy) / `RegressorMixin.score` (`:805-849`, R²). Design doc: `.design/linear/lib.md`.
49//! Per-estimator REQs live in the sibling modules' own routed docs. Score traits
50//! (`ClassifierScore`/`RegressorScore`) are pre-existing crate-root `pub trait`s re-exported
51//! via the meta-crate (`ferrolearn::linear`) — grandfathered public API (goal.md S5); honest
52//! underclaim (R-HONEST-3): no production `.score()` caller yet. `sample_weight` is
53//! supported via the `Option<&Array1<F>>` last param on both traits (REQ-6, #1106).
54//!
55//! | REQ | Status | Evidence |
56//! |---|---|---|
57//! | REQ-1 (re-export boundary) | SHIPPED | the `pub use` block re-exports every implemented linear/svm/discriminant_analysis/isotonic estimator at the crate root, mirroring sklearn `linear_model.__all__` (`__init__.py:48-98`), broadened per goal.md scope §2. Consumers: meta-crate `pub use ferrolearn_linear as linear` + PyO3 shim `ferrolearn-python/src/{regressors,classifiers,extras}.rs`. |
58//! | REQ-2 (`ClassifierScore::score` == mean accuracy) | SHIPPED | `ClassifierScore` blanket impl body `mean_accuracy` (`correct / n`) mirrors `ClassifierMixin.score` → `accuracy_score` (`base.py:738-764`); critic-verified vs live oracle (`accuracy_score([0,1,2,1],[0,1,1,1])=0.75`). Consumer: grandfathered crate/meta re-export (S5). Underclaim: no production `.score()` caller; single-label (`Output=Array1<usize>`). |
59//! | REQ-3 (`RegressorScore::score` == in-regime R²) | SHIPPED | `RegressorScore` blanket impl body `r2_score` = `1 − ss_res/ss_tot` mirrors `RegressorMixin.score` → `metrics.r2_score` (`base.py:805-849`); matches live oracle `r2_score([3.,5.,2.,7.],[2.5,5.,2.,8.])=0.9152542372881356` (`r2_in_regime_matches_oracle`). Consumer: grandfathered re-export (S5). |
60//! | REQ-4 (constant-y R² edge parity) | SHIPPED | FIXED #1104. `r2_score` now returns `0.0` (was `neg_infinity()`) when `ss_tot==0 ∧ ss_res!=0`, matching `metrics.r2_score` (`_regression.py:891`); zero-residual stays `1.0`. Green: `divergence_r2_constant_ytrue_nonzero_residual_returns_zero` + `r2_constant_ytrue_zero_residual_returns_one`. |
61//! | REQ-5 (`log_proba` behind predict_log_proba) | SHIPPED | FIXED #1105. `log_proba` is now the unclamped `p.ln()`, matching sklearn `predict_log_proba = np.log(predict_proba)` (`discriminant_analysis.py:1059`); `0.0`→`-inf`. Consumers: `logistic_regression.rs`/`logistic_regression_cv.rs`/`qda.rs` `predict_log_proba`. Green: `divergence_log_proba_zero_clamps_instead_of_neg_inf`. |
62//! | REQ-6 (sample_weight on score) | SHIPPED | FIXED #1106. Both score traits now take `sample_weight: Option<&Array1<F>>` as the LAST param, matching sklearn `score(self, X, y, sample_weight=None)` (`base.py:738`,`:805`). `ClassifierScore::score` → `weighted_accuracy` (`Σ wᵢ·[predᵢ==yᵢ] / Σ wᵢ`, the `accuracy_score(..., sample_weight=w)` analog); `RegressorScore::score` → `weighted_r2_score` (`1 − Σwᵢ(yᵢ−predᵢ)² / Σwᵢ(yᵢ−ȳ_w)²`, ȳ_w = `Σwᵢyᵢ/Σwᵢ`, the `r2_score(..., sample_weight=w)` analog). `None` is byte-identical to `mean_accuracy`/`r2_score`. Oracle-verified (live sklearn 1.5.2): `weighted_accuracy([0,1,0,0,1],[0,1,1,0,1],[1,2,3,1,1])=0.625`, `weighted_r2_score([1.1,1.9,3.2,3.7,5.1],[1,2,3,4,5],[1,2,3,1,1])=0.9770114942528736` in `tests/divergence_lib.rs` (`classifier_score_weighted_matches_sklearn`, `regressor_score_weighted_matches_sklearn`, `score_none_sample_weight_equals_unweighted`). |
63//! | REQ-substrate (ferray) | NOT-STARTED | open prereq blocker #1107. Helpers + score traits run on `ndarray::{Array1,Array2}` + `num_traits::Float`, not `ferray-core` arrays (R-SUBSTRATE-1). |
64
65pub mod ard;
66pub mod bayesian_ridge;
67pub mod elastic_net;
68pub mod elastic_net_cv;
69pub mod glm;
70pub mod huber_regressor;
71pub mod isotonic;
72pub mod lars;
73pub mod lasso;
74pub mod lasso_cv;
75pub mod lda;
76mod linalg;
77pub mod linear_regression;
78pub mod linear_svc;
79pub mod linear_svr;
80pub mod logistic_regression;
81pub mod logistic_regression_cv;
82pub mod multi_task_elastic_net;
83pub mod multi_task_elastic_net_cv;
84pub mod multi_task_lasso;
85pub mod multi_task_lasso_cv;
86pub mod nu_svm;
87pub mod omp;
88pub mod one_class_svm;
89mod optim;
90pub mod qda;
91pub mod quantile_regressor;
92pub mod ransac;
93pub mod ridge;
94pub mod ridge_classifier;
95pub mod ridge_classifier_cv;
96pub mod ridge_cv;
97pub mod sgd;
98pub mod svm;
99
100// Re-export the main types at the crate root.
101pub use ard::{ARDRegression, FittedARDRegression};
102pub use bayesian_ridge::{BayesianRidge, FittedBayesianRidge};
103pub use elastic_net::{ElasticNet, FittedElasticNet};
104pub use elastic_net_cv::{ElasticNetCV, FittedElasticNetCV};
105pub use glm::{
106    FittedGLMRegressor, GLMFamily, GLMRegressor, GammaRegressor, PoissonRegressor, TweedieRegressor,
107};
108pub use huber_regressor::{FittedHuberRegressor, HuberRegressor};
109pub use isotonic::{FittedIsotonicRegression, IsotonicRegression};
110pub use lars::{FittedLars, FittedLassoLars, Lars, LassoLars};
111pub use lasso::{FittedLasso, Lasso};
112pub use lasso_cv::{FittedLassoCV, LassoCV};
113pub use lda::{FittedLDA, LDA};
114pub use linear_regression::{FittedLinearRegression, LinearRegression};
115pub use linear_svc::{FittedLinearSVC, LinearSVC, LinearSVCLoss};
116pub use linear_svr::{FittedLinearSVR, LinearSVR, LinearSVRLoss};
117pub use logistic_regression::{FittedLogisticRegression, LogisticRegression};
118pub use logistic_regression_cv::{FittedLogisticRegressionCV, LogisticRegressionCV};
119pub use multi_task_elastic_net::{FittedMultiTaskElasticNet, MultiTaskElasticNet};
120pub use multi_task_elastic_net_cv::{FittedMultiTaskElasticNetCV, MultiTaskElasticNetCV};
121pub use multi_task_lasso::{FittedMultiTaskLasso, MultiTaskLasso};
122pub use multi_task_lasso_cv::{FittedMultiTaskLassoCV, MultiTaskLassoCV};
123pub use nu_svm::{FittedNuSVC, FittedNuSVR, NuSVC, NuSVR};
124pub use omp::{FittedOMP, OrthogonalMatchingPursuit};
125pub use one_class_svm::{FittedOneClassSVM, OneClassSVM};
126pub use qda::{FittedQDA, QDA};
127pub use quantile_regressor::{FittedQuantileRegressor, QuantileRegressor};
128pub use ransac::{FittedRANSACRegressor, MinSamples, RANSACRegressor, RansacLoss};
129pub use ridge::{FittedRidge, FittedRidgeMulti, Ridge};
130pub use ridge_classifier::{FittedRidgeClassifier, RidgeClassifier};
131pub use ridge_classifier_cv::{FittedRidgeClassifierCV, RidgeClassifierCV};
132pub use ridge_cv::{FittedRidgeCV, RidgeCV};
133pub use sgd::{
134    FittedSGDClassifier, FittedSGDOneClassSVM, FittedSGDRegressor, SGDClassifier, SGDOneClassSVM,
135    SGDRegressor,
136};
137pub use svm::{
138    FittedSVC, FittedSVR, Kernel, LinearKernel, PolynomialKernel, RbfKernel, SVC, SVR,
139    SigmoidKernel,
140};
141
142use ferrolearn_core::error::FerroError;
143use ferrolearn_core::traits::Predict;
144use ndarray::{Array1, Array2};
145use num_traits::Float;
146
147/// Mean-accuracy `score(x, y)` exposed on every fitted classifier in this
148/// crate via a blanket impl over [`Predict<Array2<F>, Output=Array1<usize>>`].
149///
150/// Users just `use ferrolearn_linear::ClassifierScore;` to call
151/// `fitted.score(&x, &y)` and get the same result as sklearn's
152/// `ClassifierMixin.score`.
153pub trait ClassifierScore<F: Float> {
154    /// (Optionally weighted) mean accuracy on the given test data and labels.
155    ///
156    /// Mirrors sklearn `ClassifierMixin.score(self, X, y, sample_weight=None)`
157    /// (`base.py:738`) → `accuracy_score(y, predict(X), sample_weight=...)`.
158    /// Passing `None` for `sample_weight` reproduces the unweighted
159    /// `correct / n` accuracy.
160    ///
161    /// # Errors
162    ///
163    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()`, or if a
164    /// non-`None` `sample_weight` has a length other than `y.len()`, or any
165    /// error forwarded from the inner `predict`.
166    fn score(
167        &self,
168        x: &Array2<F>,
169        y: &Array1<usize>,
170        sample_weight: Option<&Array1<F>>,
171    ) -> Result<F, FerroError>;
172}
173
174impl<T, F> ClassifierScore<F> for T
175where
176    T: Predict<Array2<F>, Output = Array1<usize>, Error = FerroError>,
177    F: Float,
178{
179    fn score(
180        &self,
181        x: &Array2<F>,
182        y: &Array1<usize>,
183        sample_weight: Option<&Array1<F>>,
184    ) -> Result<F, FerroError> {
185        if x.nrows() != y.len() {
186            return Err(FerroError::ShapeMismatch {
187                expected: vec![x.nrows()],
188                actual: vec![y.len()],
189                context: "y length must match number of samples in X".into(),
190            });
191        }
192        let preds = self.predict(x)?;
193        weighted_accuracy(&preds, y, sample_weight)
194    }
195}
196
197/// R² `score(x, y)` exposed on every fitted regressor in this crate via
198/// a blanket impl over [`Predict<Array2<F>, Output=Array1<F>>`].
199///
200/// Users just `use ferrolearn_linear::RegressorScore;` to call
201/// `fitted.score(&x, &y)`.
202pub trait RegressorScore<F: Float> {
203    /// (Optionally weighted) R² coefficient of determination on the given test
204    /// data and targets.
205    ///
206    /// Mirrors sklearn `RegressorMixin.score(self, X, y, sample_weight=None)`
207    /// (`base.py:805`) → `r2_score(y, predict(X), sample_weight=...)`. Passing
208    /// `None` for `sample_weight` reproduces the unweighted `1 − SSres/SStot`.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`FerroError::ShapeMismatch`] if `x.nrows() != y.len()`, or if a
213    /// non-`None` `sample_weight` has a length other than `y.len()`, or any
214    /// error forwarded from the inner `predict`.
215    fn score(
216        &self,
217        x: &Array2<F>,
218        y: &Array1<F>,
219        sample_weight: Option<&Array1<F>>,
220    ) -> Result<F, FerroError>;
221}
222
223impl<T, F> RegressorScore<F> for T
224where
225    T: Predict<Array2<F>, Output = Array1<F>, Error = FerroError>,
226    F: Float,
227{
228    fn score(
229        &self,
230        x: &Array2<F>,
231        y: &Array1<F>,
232        sample_weight: Option<&Array1<F>>,
233    ) -> Result<F, FerroError> {
234        if x.nrows() != y.len() {
235            return Err(FerroError::ShapeMismatch {
236                expected: vec![x.nrows()],
237                actual: vec![y.len()],
238                context: "y length must match number of samples in X".into(),
239            });
240        }
241        let preds = self.predict(x)?;
242        weighted_r2_score(&preds, y, sample_weight)
243    }
244}
245
246/// Mean accuracy: `(sum(predictions == targets)) / n`.
247///
248/// Used as the body of every classifier `score(&self, x, y)` method in
249/// this crate to mirror sklearn's `ClassifierMixin.score`.
250pub(crate) fn mean_accuracy<F: Float>(predictions: &Array1<usize>, targets: &Array1<usize>) -> F {
251    let n = targets.len();
252    if n == 0 {
253        return F::zero();
254    }
255    let correct = predictions
256        .iter()
257        .zip(targets.iter())
258        .filter(|(p, t)| p == t)
259        .count();
260    F::from(correct).map_or(F::zero(), |c| c / F::from(n).unwrap_or(F::one()))
261}
262
263/// (Optionally) weighted mean accuracy:
264/// `sum_i(w_i · [pred_i == y_i]) / sum_i(w_i)`.
265///
266/// Mirrors sklearn `ClassifierMixin.score` → `accuracy_score(y, pred,
267/// sample_weight=...)` (`base.py:738-764`; `metrics._classification.accuracy_score`
268/// forwards `sample_weight` into `_weighted_sum`). When `sample_weight` is
269/// `None`, the result is byte-identical to [`mean_accuracy`] (`correct / n`).
270///
271/// # Errors
272///
273/// Returns [`FerroError::ShapeMismatch`] if a non-`None` `sample_weight` has a
274/// length other than `targets.len()`.
275pub(crate) fn weighted_accuracy<F: Float>(
276    predictions: &Array1<usize>,
277    targets: &Array1<usize>,
278    sample_weight: Option<&Array1<F>>,
279) -> Result<F, FerroError> {
280    let Some(w) = sample_weight else {
281        return Ok(mean_accuracy(predictions, targets));
282    };
283    let n = targets.len();
284    if w.len() != n {
285        return Err(FerroError::ShapeMismatch {
286            expected: vec![n],
287            actual: vec![w.len()],
288            context: "sample_weight length must match number of samples".into(),
289        });
290    }
291    if n == 0 {
292        return Ok(F::zero());
293    }
294    let mut num = F::zero();
295    let mut den = F::zero();
296    for i in 0..n {
297        den = den + w[i];
298        if predictions[i] == targets[i] {
299            num = num + w[i];
300        }
301    }
302    Ok(num / den)
303}
304
305/// R² coefficient of determination: `1 - SSres / SStot`. Used as the
306/// body of every regressor `score(&self, x, y)` method to mirror
307/// sklearn's `RegressorMixin.score`. Constant-y returns `1.0` if
308/// predictions are also constant-perfect (zero residual), else `0.0`
309/// when the residual is non-zero — matching `sklearn.metrics.r2_score`
310/// (`_regression.py:891`: `output_scores[nonzero_numerator &
311/// ~nonzero_denominator] = 0.0`).
312pub(crate) fn r2_score<F: Float>(y_pred: &Array1<F>, y_true: &Array1<F>) -> F {
313    let n = y_true.len();
314    if n == 0 {
315        return F::zero();
316    }
317    let mean = y_true.iter().copied().fold(F::zero(), |a, b| a + b) / F::from(n).unwrap();
318    let mut ss_res = F::zero();
319    let mut ss_tot = F::zero();
320    for i in 0..n {
321        let r = y_true[i] - y_pred[i];
322        let t = y_true[i] - mean;
323        ss_res = ss_res + r * r;
324        ss_tot = ss_tot + t * t;
325    }
326    if ss_tot == F::zero() {
327        if ss_res == F::zero() {
328            F::one()
329        } else {
330            F::zero()
331        }
332    } else {
333        F::one() - ss_res / ss_tot
334    }
335}
336
337/// (Optionally) weighted R² coefficient of determination:
338/// `1 − SSres_w / SStot_w`, where `SSres_w = Σ w_i·(y_i − pred_i)²`,
339/// `SStot_w = Σ w_i·(y_i − ȳ_w)²`, and `ȳ_w = Σ w_i·y_i / Σ w_i`.
340///
341/// Mirrors sklearn `RegressorMixin.score` → `r2_score(y, pred,
342/// sample_weight=...)` (`base.py:805-849`; `metrics._regression.r2_score`
343/// weights both numerator and denominator by `sample_weight`). When
344/// `sample_weight` is `None`, the result is byte-identical to [`r2_score`].
345/// The constant-`y` edge (`SStot_w == 0`) matches the unweighted convention:
346/// `1.0` if the (weighted) residual is also `0`, else `0.0`.
347///
348/// # Errors
349///
350/// Returns [`FerroError::ShapeMismatch`] if a non-`None` `sample_weight` has a
351/// length other than `y_true.len()`.
352pub(crate) fn weighted_r2_score<F: Float>(
353    y_pred: &Array1<F>,
354    y_true: &Array1<F>,
355    sample_weight: Option<&Array1<F>>,
356) -> Result<F, FerroError> {
357    let Some(w) = sample_weight else {
358        return Ok(r2_score(y_pred, y_true));
359    };
360    let n = y_true.len();
361    if w.len() != n {
362        return Err(FerroError::ShapeMismatch {
363            expected: vec![n],
364            actual: vec![w.len()],
365            context: "sample_weight length must match number of samples".into(),
366        });
367    }
368    if n == 0 {
369        return Ok(F::zero());
370    }
371    let mut w_sum = F::zero();
372    let mut wy_sum = F::zero();
373    for i in 0..n {
374        w_sum = w_sum + w[i];
375        wy_sum = wy_sum + w[i] * y_true[i];
376    }
377    let mean = wy_sum / w_sum;
378    let mut ss_res = F::zero();
379    let mut ss_tot = F::zero();
380    for i in 0..n {
381        let r = y_true[i] - y_pred[i];
382        let t = y_true[i] - mean;
383        ss_res = ss_res + w[i] * r * r;
384        ss_tot = ss_tot + w[i] * t * t;
385    }
386    if ss_tot == F::zero() {
387        if ss_res == F::zero() {
388            Ok(F::one())
389        } else {
390            Ok(F::zero())
391        }
392    } else {
393        Ok(F::one() - ss_res / ss_tot)
394    }
395}
396
397/// Element-wise natural log of a probability matrix, used as the body of every
398/// classifier `predict_log_proba` method in this crate. Unclamped, mirroring
399/// scikit-learn `predict_log_proba = np.log(predict_proba)`
400/// (`sklearn/discriminant_analysis.py:1059`: `return np.log(probas_)`): a `0.0`
401/// probability maps to `-inf`. Inputs are always in `[0, 1]`, so the result is
402/// either finite or `-inf` (never `NaN`).
403pub(crate) fn log_proba<F: Float>(proba: &Array2<F>) -> Array2<F> {
404    proba.mapv(|p| p.ln())
405}