ferrolearn_linear/lda.rs
1//! Linear Discriminant Analysis (LDA).
2//!
3//! LDA is both a supervised dimensionality-reduction technique and a linear
4//! classifier. This module mirrors scikit-learn's **default** `solver="svd"`
5//! path (`sklearn/discriminant_analysis.py:487-559`, commit 156ef14): rather
6//! than forming a covariance and solving the classical `Sw⁻¹·Sb` Fisher
7//! eigenproblem, it whitens the within-class data with two SVDs, derives the
8//! whitened projection `scalings_` and the weighted overall mean `xbar_`, then
9//! forms the **affine** classifier `coef_`/`intercept_` (whose `intercept_`
10//! embeds `log(priors_)`).
11//!
12//! The [`Solver::Lsqr`] least-squares path (`_solve_lstsq`,
13//! `discriminant_analysis.py:365-419`) is also available (`LDA::with_solver`):
14//! it forms the prior-weighted within-class covariance `covariance_` and solves
15//! `coef_ = lstsq(covariance_, means_.T).T`, supporting covariance
16//! [`Shrinkage`] (`None`/`Auto` Ledoit-Wolf/`Fixed`); it does NOT do
17//! dimensionality reduction (no `transform`). The [`Solver::Eigen`]
18//! generalized-eigenvalue path (`_solve_eigen`,
19//! `discriminant_analysis.py:421-485`) is also available (`LDA::with_solver`):
20//! it forms the within-class scatter `Sw = covariance_` and total scatter
21//! `St = cov(X)`, then solves the generalized symmetric-definite eigenproblem
22//! `eigh(Sb, Sw)` (with `Sb = St - Sw`) — reduced to a STANDARD symmetric
23//! eigenproblem via the Cholesky factor of `Sw` (`Sw = L·Lᵀ`,
24//! `M = L⁻¹·Sb·L⁻ᵀ`, `eigh(M)`, `evecs = L⁻ᵀ·W`) since ferray exposes only the
25//! standard solver. It supports `shrinkage` and dimensionality reduction
26//! (`transform` is the un-centered `X @ scalings_`, since eigen has no `xbar_`).
27//!
28//! # Algorithm (`_solve_svd`, `discriminant_analysis.py:487-559`)
29//!
30//! With `n = n_samples`, `c = n_classes`:
31//! 1. `priors_`: empirical `n_k / n` when the constructor `priors` is `None`
32//! (sklearn's default), else the provided `priors` used VERBATIM
33//! (`discriminant_analysis.py:601-605`).
34//! 2. `means_` = per-class mean; `xbar_ = priors_ @ means_`.
35//! 3. `Xc` = each sample minus its class mean (stacked); `std = std(Xc, axis=0)`
36//! (population, `ddof=0`), zeros replaced by `1`.
37//! 4. `Xw = sqrt(1/(n-c)) · (Xc / std)`; thin SVD `Xw = U·diag(S)·Vt`;
38//! `rank = Σ(S > tol)`; `scalings = (Vt[:rank]/std).T / S[:rank]`.
39//! 5. Between-class scaled centers `Xb = (sqrt(n·priors_·1/(c-1)) ⊙
40//! (means_-xbar_).T).T @ scalings`; thin SVD `Xb = U2·diag(S2)·Vt2`;
41//! `explained_variance_ratio_ = (S2²/ΣS2²)[:max_components]`;
42//! `rank2 = Σ(S2 > tol·S2[0])`; `scalings_ = scalings @ Vt2.T[:, :rank2]`.
43//! 6. `coef = (means_-xbar_) @ scalings_`;
44//! `intercept_ = -½·Σ(coef²) + log(priors_)`;
45//! `coef_ = coef @ scalings_.T`; `intercept_ -= xbar_ @ coef_.T`.
46//!
47//! Inference (the `LinearClassifierMixin`, `discriminant_analysis.py:739`):
48//! - `transform(X) = ((X - xbar_) @ scalings_)[:, :max_components]`
49//! (`discriminant_analysis.py:684-689`).
50//! - `decision_function(X) = X @ coef_.T + intercept_`
51//! (`discriminant_analysis.py:739`).
52//! - `predict(X)` = `classes_[argmax(decision_function)]`.
53//! - `predict_proba(X)` = `softmax(decision_function)`
54//! (`discriminant_analysis.py:706-711`).
55//!
56//! The number of discriminant directions is at most `min(n_classes - 1,
57//! n_features)`.
58//!
59//! ## REQ status (per `.design/linear/lda.md`, mirrors `sklearn/discriminant_analysis.py` @ 1.5.2)
60//!
61//! | REQ | Status | Evidence |
62//! |---|---|---|
63//! | REQ-1 (svd fit + decision_function parity) | SHIPPED | `_solve_svd` in `fn fit` (`fn svd_s_vt` → `ferray::linalg::svd`) builds `coef_`/`intercept_`/`xbar_`/`scalings_` (`discriminant_analysis.py:556-559`); `fn decision_function` = `X @ coef_.T + intercept_` (`:739`). Consumer: `Predict for FittedLDA` + crate-root `pub use`. Test `lda_decision_function_parity` <1e-6 vs live oracle. #588. |
64//! | REQ-2 (predict argmax) | SHIPPED | `Predict::predict` = `classes_[argmax(decision_function)]`; the affine decision carries `log(priors_)` via `intercept_`. Test `lda_imbalanced_priors_predict` (prior shifts the boundary, label-for-label vs live oracle). #589. |
65//! | REQ-3 (predict_proba prior-aware) | SHIPPED | `FittedLDA::predict_proba` = `softmax(decision_function)` (`discriminant_analysis.py:711`); rows sum to 1. Test `lda_imbalanced_priors_predict` proba block <1e-6 vs live oracle. #590 (partial: multiclass softmax; binary `expit` collapse pends #600). |
66//! | REQ-5 (transform parity) | SHIPPED | `fn transform` = `((X - xbar_) @ scalings_)[:, :max_components]` (`discriminant_analysis.py:684-689`). Test `lda_transform_parity` <1e-6 (per-column sign) vs live oracle. #592. |
67//! | REQ-6 (n_components bound) | SHIPPED | `fn fit` computes `max_components = min(n_classes-1, n_features)`, defaults `None` to it, errors `Some(0)`/`Some(k>max)` (`discriminant_analysis.py:614-625`). Tests `test_lda_default_n_components`, `test_lda_error_zero_n_components`, `test_lda_error_n_components_too_large`. |
68//! | REQ-7 (priors: None=empirical + provided) | SHIPPED | `fn fit` resolves `priors_`: empirical `n_k/n` when `priors` is `None` (`discriminant_analysis.py:601-603`), else the provided `LDA::with_priors` array, now VALIDATED like sklearn LDA (`:607-612`, unlike QDA): R-DEV-4 length check (`p.len() != n_classes` → `ShapeMismatch`, sklearn would mis-index `:540,557`); negative entries → `InvalidParameter` (`:607-608`, `raise ValueError("priors must be non-negative")`); renormalized `p / p.sum()` with an `eprintln!` warning (the crate's warning channel, cf. qda.rs) when `|Σ-1| > 1e-5` (`:610-612`). The resolved priors flow into `xbar_ = priors_ @ means_` (`:517`), the between-class scaling `sqrt(n·priors_·fac)` (`:540`), and `intercept_ += log(priors_)` (`:557`). `FittedLDA::priors` exposes `priors_`. Consumer: the resolved `priors` is read by `fn fit` (xbar_/scaling/intercept_); `Predict for FittedLDA` consumes the prior-shifted decision. Tests: `lda_imbalanced_priors_predict` (empirical `[0.9091,0.0909]` flips the label), `lda_provided_priors` (`with_priors([0.9,0.1])` `predict_proba` <1e-6 vs live oracle; empirical default differs), `lda_priors_negative_rejected` (`[-0.1,1.1]` → `Err`), `lda_priors_renormalized` (`[0.5,0.6]` → `priors_=[0.4545…,0.5454…]`, `predict_proba` <1e-6 vs the live oracle which renormalizes internally). #593, #603. |
69//! | REQ-8 (coef_/intercept_/xbar_) | SHIPPED | `FittedLDA::{coef, intercept, xbar}` accessors expose the `_solve_svd` arrays (`discriminant_analysis.py:556-559,517`). Consumer: `fn decision_function` reads `coef_`/`intercept_`; `fn transform` reads `xbar_`. Verified via `lda_decision_function_parity` (decision = `X@coef_.T+intercept_`) + `lda_transform_parity` (uses `xbar_`). |
70//! | REQ-13 (explained_variance_ratio_) | SHIPPED | `fn fit` sets `explained_variance_ratio_ = (S2²/ΣS2²)[:max_components]` from the SECOND (between-class) SVD (`discriminant_analysis.py:550-552`). Test `test_lda_explained_variance_ratio_oracle` <1e-9 vs live `L().explained_variance_ratio_`. #599. |
71//! | REQ-4 (predict_log_proba smallest_normal floor) | SHIPPED | `FittedLDA::predict_log_proba` mirrors sklearn exactly (`discriminant_analysis.py:713-737`): `predict_proba` then `prediction[prediction == 0.0] += smallest_normal` (`F::min_positive_value()` = numpy `finfo.smallest_normal`, `:729-736`) before `log`, so nonzero probas keep their true `ln` and exact zeros become `log(MIN_POSITIVE)` (not `-inf`). Consumer: shares `FittedLDA::predict_proba` (the `Predict` path). Test `lda_predict_log_proba` (overlapping 3-class, all-finite log-probas) <1e-6 vs live `LinearDiscriminantAnalysis().predict_log_proba`. #591. |
72//! | REQ-9 (lsqr solver) | SHIPPED | `Solver::Lsqr` (`LDA::with_solver`) dispatches `fn fit` to `fn solve_lstsq` (sklearn `_solve_lstsq`, `discriminant_analysis.py:365-419`): `covariance_ = Σ_k priors_[k] · cov(X_k)` (`_class_cov` `:128-172`, ALWAYS populated for lsqr, `:413`); `coef_ = lstsq(covariance_, means_.T)[0].T` (`:416`) via `fn lstsq_multi` → `ferray::linalg::lstsq` (multi-RHS, `ferray-linalg/src/solve.rs:208`, R-SUBSTRATE-4 bridge); `intercept_ = -½·diag(means_ @ coef_.T) + log(priors_)` (`:417-418`). No `scalings_`/`xbar_`/`explained_variance_ratio_` / `transform` (sklearn raises for lsqr `transform`, `:676-679`; `max_components=0` ⇒ empty projection). Binary collapse `coef_[1]-coef_[0]` deferred to #600 (coef_ stays `(n_classes, n_features)`, matching the svd path). Consumer: `fn fit` reads `self.solver` and dispatches; `Predict`/`predict_proba` for `FittedLDA` consume the lsqr `coef_`/`intercept_`. Test `lda_lsqr_solver` (collapsed `coef_[1]-coef_[0]` = `[14.7368…, 14.7368…]`, predict/predict_proba) <1e-6 vs live `LinearDiscriminantAnalysis(solver='lsqr').fit(X,y)`. #595. |
73//! | REQ-10 (eigen solver) | SHIPPED | `Solver::Eigen` (`LDA::with_solver`) dispatches `fn fit` to `fn solve_eigen` (sklearn `_solve_eigen`, `discriminant_analysis.py:421-485`): `Sw = covariance_ = Σ_k priors_[k]·cov(X_k)` (`_class_cov` `:467-471`); `St = _cov(WHOLE X, shrinkage)` (total scatter, `:472`); `Sb = St - Sw` (`:473`); the GENERALIZED symmetric-definite eigenproblem `eigh(Sb, Sw)` (`:475`) reduced to STANDARD form via the Cholesky factor of `Sw` (ferray has `eigh`/`cholesky` but no generalized solver): `Sw = L·Lᵀ` (`fn cholesky_lower` → `ferray::linalg::cholesky`, `ferray-linalg/src/decomp/cholesky.rs:22`), `M = L⁻¹·Sb·L⁻ᵀ` (`fn matrix_inverse` → `ferray::linalg::inv`, `ferray-linalg/src/solve.rs:367`) SYMMETRIZED `M = (M+Mᵀ)/2`, `(evals, W) = eigh(M)` (`fn eigh_sym` → `ferray::linalg::eigh`, ascending, `ferray-linalg/src/decomp/eigen.rs:105`), generalized `evecs = L⁻ᵀ·W` sorted DESCENDING by eigenvalue (`:479`); `explained_variance_ratio_ = sort(evals/Σevals)[::-1][:max_components]` (`:476-478`); `scalings_ = evecs` (ALL columns, `:481`); `coef_ = (means_@evecs)@evecs.T` (`:482`, SIGN/ORDER-INVARIANT so it matches sklearn despite the eigenvector ambiguity); `intercept_ = -½·diag(means_@coef_.T) + log(priors_)` (`:483-485`). Supports `shrinkage` (like lsqr); `transform` is the un-centered `X @ scalings_[:, :max_components]` (eigen has NO `xbar_`, `:687`). Consumer: `fn fit` reads `self.solver` and dispatches; `Predict`/`predict_proba` for `FittedLDA` consume the eigen `coef_`/`intercept_`; `Transform` consumes `scalings_`. Tests `lda_eigen_solver` (collapsed `coef_[1]-coef_[0]` = `[14.7368…, 14.7368…]`, `explained_variance_ratio_` = `[1.0]`, predict/predict_proba) and `lda_eigen_shrinkage` (`Fixed(0.5)` collapsed coef = `[12.043…, 12.043…]`) <1e-6 vs live `LinearDiscriminantAnalysis(solver='eigen').fit(X,y)`. #596. |
74//! | REQ-11 (shrinkage None/auto/float) | SHIPPED | `Shrinkage::{None, Auto, Fixed(F)}` (`LDA::with_shrinkage`) drives `fn cov_shrunk` (sklearn `_cov`, `discriminant_analysis.py:36-93`) inside `fn solve_lstsq`: `None` → maximum-likelihood empirical covariance (`fn empirical_covariance`, `np.cov(...,bias=1)`, `:76-77`); `Fixed(s)` → `(1-s)·emp + s·(trace(emp)/p)·I` (`shrunk_covariance`, `covariance/_shrunk_covariance.py:153-156`), validated `0 ≤ s ≤ 1` (`Interval(Real,0,1,closed=both)`, `:339`) else `InvalidParameter`; `Auto` → analytical Ledoit-Wolf (`fn ledoit_wolf_shrinkage`, transcribed from `covariance/_shrunk_covariance.py:365-401` unblocked case) on StandardScaler-standardized data then rescaled (`_cov` `:70-75`). `Solver::Svd` + non-`None` shrinkage → `InvalidParameter("shrinkage not supported with svd solver")` mirroring sklearn `NotImplementedError` (`:628-629`). Consumer: `fn fit`/`fn solve_lstsq` read `self.shrinkage`. Tests `lda_shrinkage_fixed` (`Fixed(0.5)` coef = `[12.043…, 12.043…]`), `lda_shrinkage_auto` (`Auto` coef = `[11.3706…, 11.3706…]`, validates the Ledoit-Wolf transcription), `lda_svd_shrinkage_rejected` (svd+shrinkage → `Err`) <1e-6 vs the live oracle. #597. |
75//! | REQ-12 (store_covariance / covariance_) | SHIPPED | `LDA::with_store_covariance` sets the flag (sklearn default `false`, `discriminant_analysis.py:353`); when `true`, `fn fit` computes the shared within-class covariance `covariance_ = Σ_k priors_[k] · cov(X_k)` (`:509-510`, `_class_cov` `:128-172`) with the maximum-likelihood (`bias=1`, ÷`n_k`) per-class empirical covariance (`empirical_covariance`, `np.cov(...,bias=1)`), stored on `FittedLDA::covariance` (`None` when the flag is unset, matching sklearn). Consumer: `fn fit` reads `self.store_covariance`/`priors`/`means` and populates the field; `FittedLDA::covariance` exposes it. Test `lda_store_covariance` matches the live oracle `LinearDiscriminantAnalysis(store_covariance=True).fit(X,y).covariance_` to 1e-9 and asserts `None` for the default/`false` path. #598. |
76//! | REQ-14 (binary decision_function shape `(n,)`) | NOT-STARTED | open prereq blocker #600. `fn decision_function` always returns `(n, n_classes)`; sklearn collapses binary to `(n,)` (`discriminant_analysis.py:651-657,739`). Binding-ABI layer (parallel to QDA #581). |
77//! | REQ-15 (tol parameter) | SHIPPED | `LDA::with_tol` sets the svd-solver rank threshold (sklearn default `1e-4`, `discriminant_analysis.py:354,362`); `fn fit` reads `self.tol` into BOTH rank cutoffs `rank = Σ(S > tol)` (`:532`) and `rank2 = Σ(S2 > tol·S2[0])` (`:554`), REPLACING the prior hardcoded `1e-4`. Default `1e-4` ⇒ byte-identical to prior behavior (all existing svd-fit oracle tests stay green). Consumer: `fn fit` reads `self.tol` in both rank thresholds. Test `lda_tol_param` (field default `1e-4` + `with_tol` plumb-through). #601. |
78//! | REQ-16 (ferray array-type substrate) | NOT-STARTED | open prereq blocker #602. The two SVDs run on `ferray::linalg::svd`; the owned array type is still `ndarray` (crate-wide deferral, cf. qda.rs REQ-12 #585). |
79//! | REQ-17 (non-finite input rejected) | SHIPPED | The shared `fn fit` entry rejects any NaN/+/-inf in X BEFORE the solver dispatch (svd/lsqr/eigen) with `FerroError::InvalidParameter`, mirroring sklearn's `_validate_data(force_all_finite=True)` (`discriminant_analysis.py:589`) → `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`. `y` is `Array1<usize>` (integer labels), finite by type; LDA's `fit` takes no `sample_weight`, so X is the only runtime check. All three solvers dispatch downstream of the guard, so it covers every solver path. `.iter().any(|v| !v.is_finite())` catches NaN and Inf; the finite path is byte-identical. Verified vs the live sklearn 1.5.2 oracle (R-CHAR-3): `LinearDiscriminantAnalysis().fit` raises `ValueError` for NaN/+inf/-inf in X (`tests/divergence_linear_nonfinite_batch4.rs::lda_*`). Non-test consumer: the existing `Fit for LDA` consumers (`Predict for FittedLDA`, crate-root `pub use`). (#2263) |
80//!
81//! # Examples
82//!
83//! ```
84//! use ferrolearn_linear::lda::LDA;
85//! use ferrolearn_core::{Fit, Predict};
86//! use ndarray::{array, Array1, Array2};
87//!
88//! let lda = LDA::new(Some(1));
89//! let x = Array2::from_shape_vec(
90//! (6, 2),
91//! vec![1.0, 1.0, 1.5, 1.2, 1.2, 0.8, 5.0, 5.0, 5.5, 4.8, 4.8, 5.2],
92//! ).unwrap();
93//! let y = array![0usize, 0, 0, 1, 1, 1];
94//! let fitted = lda.fit(&x, &y).unwrap();
95//! let preds = fitted.predict(&x).unwrap();
96//! assert_eq!(preds.len(), 6);
97//! ```
98
99use ferray::linalg::{LinalgFloat, cholesky, eigh, inv, svd};
100use ferray::{Array as FerrayArray, Ix2 as FerrayIx2};
101use ferrolearn_core::error::FerroError;
102use ferrolearn_core::introspection::HasClasses;
103use ferrolearn_core::pipeline::{FittedPipelineEstimator, PipelineEstimator};
104use ferrolearn_core::traits::{Fit, Predict, Transform};
105use ndarray::{Array1, Array2, ScalarOperand};
106use num_traits::{Float, NumCast};
107
108// ---------------------------------------------------------------------------
109// Solver / Shrinkage enums
110// ---------------------------------------------------------------------------
111
112/// LDA solver selector (sklearn's `solver`, `discriminant_analysis.py:204-216`,
113/// `_parameter_constraints` `StrOptions({svd, lsqr, eigen})` `:338`).
114///
115/// - [`Solver::Svd`] (default) — the singular-value-decomposition path
116/// (`_solve_svd`, `discriminant_analysis.py:487-559`); supports `transform`
117/// (dimensionality reduction) but NOT `shrinkage`.
118/// - [`Solver::Lsqr`] — the least-squares path (`_solve_lstsq`,
119/// `discriminant_analysis.py:365-419`): `coef_ = lstsq(covariance_,
120/// means_.T).T`, `intercept_ = -½·diag(means_ @ coef_.T) + log(priors_)`.
121/// Supports `shrinkage`; does NOT support `transform` (sklearn raises
122/// `NotImplementedError`, `:676-679`).
123/// - [`Solver::Eigen`] — the generalized-eigenvalue path
124/// (`_solve_eigen`, `discriminant_analysis.py:421-485`): forms `Sw`/`St`,
125/// solves the generalized `eigh(Sb, Sw)` (reduced to a STANDARD symmetric
126/// eigenproblem via the Cholesky factor of `Sw`), and yields `scalings_`,
127/// `coef_`, `intercept_`, `explained_variance_ratio_`. Supports `shrinkage`
128/// and `transform` (the un-centered `X @ scalings_`).
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
130pub enum Solver {
131 /// Singular-value-decomposition solver (sklearn default).
132 #[default]
133 Svd,
134 /// Least-squares solver (`_solve_lstsq`).
135 Lsqr,
136 /// Generalized-eigenvalue solver (`_solve_eigen`), via Cholesky reduction.
137 Eigen,
138}
139
140/// LDA covariance-shrinkage selector (sklearn's `shrinkage`,
141/// `discriminant_analysis.py:218-225`, `_parameter_constraints`
142/// `[StrOptions({auto}), Interval(Real, 0, 1, closed=both), None]` `:339`).
143///
144/// Drives the per-class covariance estimate `_cov`
145/// (`discriminant_analysis.py:36-93`) inside the `lsqr`/`eigen` solvers
146/// (sklearn note `:225`: shrinkage works only with the `lsqr` and `eigen`
147/// solvers):
148/// - [`Shrinkage::None`] — no shrinkage; the maximum-likelihood empirical
149/// covariance (`np.cov(..., bias=1)`, `:76-77`).
150/// - [`Shrinkage::Auto`] — automatic Ledoit-Wolf shrinkage (`:70-75`):
151/// standardize features, run the Ledoit-Wolf lemma, then rescale.
152/// - [`Shrinkage::Fixed`]`(s)` — fixed shrinkage `s ∈ [0, 1]`
153/// (`shrunk_covariance`, `_shrunk_covariance.py:111-158`):
154/// `(1 - s)·emp_cov + s·(trace(emp_cov)/p)·I`.
155///
156/// # Type Parameters
157///
158/// - `F`: the floating-point scalar type (`f32` or `f64`).
159#[derive(Debug, Clone, Copy, PartialEq, Default)]
160pub enum Shrinkage<F> {
161 /// No shrinkage (sklearn `None`/`'empirical'`). Default.
162 #[default]
163 None,
164 /// Automatic Ledoit-Wolf shrinkage (sklearn `'auto'`).
165 Auto,
166 /// Fixed shrinkage coefficient `s ∈ [0, 1]` (sklearn `float`).
167 Fixed(F),
168}
169
170// ---------------------------------------------------------------------------
171// LDA (unfitted)
172// ---------------------------------------------------------------------------
173
174/// Linear Discriminant Analysis configuration.
175///
176/// Holds hyperparameters. Calling [`Fit::fit`] runs sklearn's default
177/// `solver="svd"` path (`discriminant_analysis.py:487-559`) and returns a
178/// [`FittedLDA`].
179///
180/// Use [`LDA::with_priors`] to supply class priors (sklearn's `priors`,
181/// `discriminant_analysis.py:359`); the default `None` infers the empirical
182/// priors `n_k / n` at fit time.
183///
184/// # Type Parameters
185///
186/// - `F`: The floating-point scalar type (`f32` or `f64`).
187#[derive(Debug, Clone)]
188pub struct LDA<F> {
189 /// Number of discriminant components to retain.
190 ///
191 /// If `None`, defaults to `min(n_classes - 1, n_features)` at fit time.
192 n_components: Option<usize>,
193
194 /// Class prior probabilities (sklearn's `priors`,
195 /// `discriminant_analysis.py:351,359`).
196 ///
197 /// `None` (sklearn's default) ⇒ the empirical priors `n_k / n` are inferred
198 /// from the training data at fit time. `Some(p)` ⇒ `p` is used VERBATIM as
199 /// `priors_` (matching sklearn `:605`, `self.priors_ = xp.asarray(self.priors)`).
200 priors: Option<Array1<F>>,
201
202 /// Solver selector (sklearn's `solver`, default `"svd"`,
203 /// `discriminant_analysis.py:204,349`). See [`Solver`].
204 solver: Solver,
205
206 /// Covariance-shrinkage selector (sklearn's `shrinkage`, default `None`,
207 /// `discriminant_analysis.py:218,350`). See [`Shrinkage`]. Only honored by
208 /// the `lsqr` solver here; combined with `Solver::Svd` it is rejected at fit
209 /// (sklearn `NotImplementedError`, `:628-629`).
210 shrinkage: Shrinkage<F>,
211
212 /// Whether to compute and store the shared within-class covariance matrix
213 /// `covariance_` during fit (sklearn's `store_covariance`, default `false`,
214 /// `discriminant_analysis.py:353,361`). When `true`, the svd-solver `fit`
215 /// computes `covariance_ = Σ_k priors_[k] · cov(X_k)` (`:509-510`,
216 /// `_class_cov` `:128-172`).
217 store_covariance: bool,
218
219 /// Singular-value rank threshold used by the svd-solver (sklearn's `tol`,
220 /// default `1e-4`, `discriminant_analysis.py:354,362`). It drives the two
221 /// rank cutoffs `rank = Σ(S > tol)` (`:532`) and
222 /// `rank2 = Σ(S2 > tol·S2[0])` (`:554`).
223 tol: F,
224
225 _marker: std::marker::PhantomData<F>,
226}
227
228impl<F: Float + Send + Sync + 'static> LDA<F> {
229 /// Create a new `LDA`.
230 ///
231 /// - `n_components`: number of discriminant directions to retain.
232 /// Pass `None` to use `min(n_classes - 1, n_features)`.
233 #[must_use]
234 pub fn new(n_components: Option<usize>) -> Self {
235 Self {
236 n_components,
237 priors: None,
238 solver: Solver::Svd,
239 shrinkage: Shrinkage::None,
240 store_covariance: false,
241 // sklearn default `tol=1e-4` (`discriminant_analysis.py:354`).
242 // `1e-4` is exactly representable in f32/f64; the fallback to
243 // `F::epsilon()` is unreachable for those but keeps `new`
244 // panic-free for any conforming `Float`.
245 tol: F::from(1e-4).unwrap_or_else(F::epsilon),
246 _marker: std::marker::PhantomData,
247 }
248 }
249
250 /// Return the configured number of components (may be `None`).
251 #[must_use]
252 pub fn n_components(&self) -> Option<usize> {
253 self.n_components
254 }
255
256 /// Set the class prior probabilities (sklearn's `priors`,
257 /// `discriminant_analysis.py:351,359`).
258 ///
259 /// The provided vector is used VERBATIM as `priors_` (sklearn does not
260 /// normalize it here when it already sums to 1, `:605`). Its length must
261 /// equal the number of classes seen at fit time, or [`Fit::fit`] returns
262 /// [`FerroError::ShapeMismatch`]. Pass nothing (the `None` default) to infer
263 /// the empirical priors `n_k / n` from the training data (`:601-603`).
264 #[must_use]
265 pub fn with_priors(mut self, priors: Array1<F>) -> Self {
266 self.priors = Some(priors);
267 self
268 }
269
270 /// Return the configured class priors (`None` ⇒ empirical at fit time).
271 /// Mirrors sklearn's constructor `priors` (`discriminant_analysis.py:359`).
272 #[must_use]
273 pub fn priors(&self) -> Option<&Array1<F>> {
274 self.priors.as_ref()
275 }
276
277 /// Set the solver (sklearn's `solver`, `discriminant_analysis.py:204,349`).
278 /// Default [`Solver::Svd`]. See [`Solver`].
279 ///
280 /// [`Solver::Lsqr`] enables the least-squares path (and `shrinkage`);
281 /// [`Solver::Eigen`] enables the generalized-eigenvalue path (also supports
282 /// `shrinkage` and `transform`).
283 #[must_use]
284 pub fn with_solver(mut self, solver: Solver) -> Self {
285 self.solver = solver;
286 self
287 }
288
289 /// Return the configured solver. Mirrors sklearn's constructor `solver`
290 /// (`discriminant_analysis.py:349`, default `"svd"`).
291 #[must_use]
292 pub fn solver(&self) -> Solver {
293 self.solver
294 }
295
296 /// Set the covariance shrinkage (sklearn's `shrinkage`,
297 /// `discriminant_analysis.py:218,350`). Default [`Shrinkage::None`]. See
298 /// [`Shrinkage`].
299 ///
300 /// Honored only by [`Solver::Lsqr`] here (sklearn note `:225`: shrinkage
301 /// works only with the `lsqr`/`eigen` solvers). Combined with
302 /// [`Solver::Svd`], [`Fit::fit`] returns a [`FerroError`] mirroring sklearn's
303 /// `NotImplementedError("shrinkage not supported with 'svd' solver.")`
304 /// (`:628-629`). [`Shrinkage::Fixed`]`(s)` requires `0 <= s <= 1` (sklearn
305 /// `Interval(Real, 0, 1, closed="both")`, `:339`), else [`Fit::fit`] returns
306 /// [`FerroError::InvalidParameter`].
307 #[must_use]
308 pub fn with_shrinkage(mut self, shrinkage: Shrinkage<F>) -> Self {
309 self.shrinkage = shrinkage;
310 self
311 }
312
313 /// Return the configured covariance shrinkage. Mirrors sklearn's constructor
314 /// `shrinkage` (`discriminant_analysis.py:350`, default `None`).
315 #[must_use]
316 pub fn shrinkage(&self) -> Shrinkage<F> {
317 self.shrinkage
318 }
319
320 /// Set whether to compute and store the shared within-class covariance
321 /// matrix `covariance_` during fit (sklearn's `store_covariance`,
322 /// `discriminant_analysis.py:353,361`). Default `false`.
323 ///
324 /// When `true`, [`Fit::fit`] computes `covariance_ = Σ_k priors_[k] ·
325 /// cov(X_k)` (`:509-510`, `_class_cov` `:128-172`) and
326 /// [`FittedLDA::covariance`] returns `Some`. When `false` it returns `None`
327 /// (matching sklearn, where the attribute only exists when the flag is set).
328 #[must_use]
329 pub fn with_store_covariance(mut self, store_covariance: bool) -> Self {
330 self.store_covariance = store_covariance;
331 self
332 }
333
334 /// Return whether `covariance_` will be stored during fit (sklearn's
335 /// `store_covariance`, `discriminant_analysis.py:353`).
336 #[must_use]
337 pub fn store_covariance(&self) -> bool {
338 self.store_covariance
339 }
340
341 /// Set the singular-value rank threshold `tol` used by the svd-solver
342 /// (sklearn's `tol`, `discriminant_analysis.py:354,362`). Default `1e-4`.
343 ///
344 /// It drives the two rank cutoffs `rank = Σ(S > tol)` (`:532`) and
345 /// `rank2 = Σ(S2 > tol·S2[0])` (`:554`).
346 #[must_use]
347 pub fn with_tol(mut self, tol: F) -> Self {
348 self.tol = tol;
349 self
350 }
351
352 /// Return the configured svd-solver rank threshold `tol`. Mirrors sklearn's
353 /// constructor `tol` (`discriminant_analysis.py:354`, default `1e-4`).
354 #[must_use]
355 pub fn tol(&self) -> F {
356 self.tol
357 }
358}
359
360impl<F: Float + Send + Sync + 'static> Default for LDA<F> {
361 fn default() -> Self {
362 Self::new(None)
363 }
364}
365
366// ---------------------------------------------------------------------------
367// FittedLDA
368// ---------------------------------------------------------------------------
369
370/// A fitted LDA model (sklearn's `svd` solver).
371///
372/// Created by calling [`Fit::fit`] on an [`LDA`]. Implements:
373/// - [`Transform<Array2<F>>`] — project data via `(X - xbar_) @ scalings_`.
374/// - [`Predict<Array2<F>>`] — classify by argmax of the affine
375/// `decision_function`.
376#[derive(Debug, Clone)]
377pub struct FittedLDA<F> {
378 /// Whitened projection matrix `scalings_`, shape `(n_features, rank2)`.
379 /// Mirrors sklearn's `scalings_` (`discriminant_analysis.py:555`).
380 scalings: Array2<F>,
381
382 /// Per-class means in the ORIGINAL feature space, shape
383 /// `(n_classes, n_features)`. Mirrors sklearn's `means_`
384 /// (`discriminant_analysis.py:508`).
385 means: Array2<F>,
386
387 /// Weighted overall mean `xbar_ = priors_ @ means_`, length `n_features`.
388 /// Mirrors sklearn's `xbar_` (`discriminant_analysis.py:517`).
389 xbar: Array1<F>,
390
391 /// Resolved class priors `priors_`, length `n_classes`. Empirical `n_k / n`
392 /// when the constructor `priors` was `None`, else the provided `priors`
393 /// verbatim. Mirrors sklearn's `priors_` (`discriminant_analysis.py:601-605`).
394 priors: Array1<F>,
395
396 /// Affine classifier coefficients `coef_`, shape `(n_classes, n_features)`.
397 /// Mirrors sklearn's `coef_` (`discriminant_analysis.py:558`). (Binary
398 /// collapse to `(1, n_features)` pends #600.)
399 coef: Array2<F>,
400
401 /// Affine classifier intercepts `intercept_`, length `n_classes` (embeds
402 /// `log(priors_)`). Mirrors sklearn's `intercept_`
403 /// (`discriminant_analysis.py:557,559`).
404 intercept: Array1<F>,
405
406 /// Ratio of explained variance per discriminant direction, length
407 /// `max_components`. Mirrors sklearn's `explained_variance_ratio_`
408 /// (`discriminant_analysis.py:550-552`).
409 explained_variance_ratio: Array1<F>,
410
411 /// Shared within-class covariance matrix `covariance_`, shape
412 /// `(n_features, n_features)`, present only when the model was configured
413 /// with [`LDA::with_store_covariance`]`(true)`. Mirrors sklearn's
414 /// `covariance_` (`discriminant_analysis.py:509-510`, `_class_cov`
415 /// `:128-172`): `Σ_k priors_[k] · cov(X_k)`. `None` otherwise (matching
416 /// sklearn, where the attribute only exists when `store_covariance=True`).
417 covariance: Option<Array2<F>>,
418
419 /// Class labels corresponding to rows of `means`/`coef`.
420 classes: Vec<usize>,
421
422 /// Number of components to keep on `transform` output (sklearn's
423 /// `_max_components`, `discriminant_analysis.py:619/625`).
424 max_components: usize,
425
426 /// Number of features seen during fitting.
427 n_features: usize,
428}
429
430impl<F: Float + Send + Sync + 'static> FittedLDA<F> {
431 /// Whitened projection (`scalings_`) matrix, shape `(n_features, rank2)`.
432 /// Mirrors sklearn's `scalings_` (`discriminant_analysis.py:555`).
433 #[must_use]
434 pub fn scalings(&self) -> &Array2<F> {
435 &self.scalings
436 }
437
438 /// Per-class means in the original feature space, shape
439 /// `(n_classes, n_features)`. Mirrors sklearn's `means_`
440 /// (`discriminant_analysis.py:508`).
441 #[must_use]
442 pub fn means(&self) -> &Array2<F> {
443 &self.means
444 }
445
446 /// Weighted overall mean `xbar_`, length `n_features`. Mirrors sklearn's
447 /// `xbar_` (`discriminant_analysis.py:517`).
448 #[must_use]
449 pub fn xbar(&self) -> &Array1<F> {
450 &self.xbar
451 }
452
453 /// Resolved class priors `priors_`, length `n_classes` (empirical `n_k / n`
454 /// when the constructor `priors` was `None`, else the provided `priors`
455 /// verbatim). Mirrors sklearn's `priors_` (`discriminant_analysis.py:601-605`).
456 #[must_use]
457 pub fn priors(&self) -> &Array1<F> {
458 &self.priors
459 }
460
461 /// Affine classifier coefficients `coef_`, shape `(n_classes, n_features)`.
462 /// Mirrors sklearn's `coef_` (`discriminant_analysis.py:558`).
463 #[must_use]
464 pub fn coef(&self) -> &Array2<F> {
465 &self.coef
466 }
467
468 /// Affine classifier intercepts `intercept_`, length `n_classes`. Mirrors
469 /// sklearn's `intercept_` (`discriminant_analysis.py:557,559`).
470 #[must_use]
471 pub fn intercept(&self) -> &Array1<F> {
472 &self.intercept
473 }
474
475 /// Explained-variance ratio per discriminant direction. Mirrors sklearn's
476 /// `explained_variance_ratio_` (`discriminant_analysis.py:550-552`).
477 #[must_use]
478 pub fn explained_variance_ratio(&self) -> &Array1<F> {
479 &self.explained_variance_ratio
480 }
481
482 /// Shared within-class covariance matrix `covariance_`, shape
483 /// `(n_features, n_features)`. Mirrors sklearn's `covariance_`
484 /// (`discriminant_analysis.py:509-510`, `_class_cov` `:128-172`):
485 /// `Σ_k priors_[k] · cov(X_k)` where `cov(X_k)` is the maximum-likelihood
486 /// empirical covariance of class `k`'s samples (`np.cov(..., bias=1)`,
487 /// normalized by `n_k`, via `empirical_covariance`,
488 /// `covariance/_empirical_covariance.py:109`).
489 ///
490 /// Returns `Some` only when the model was configured with
491 /// [`LDA::with_store_covariance`]`(true)`; `None` otherwise — matching
492 /// sklearn, where the `covariance_` attribute only exists when
493 /// `store_covariance=True`.
494 #[must_use]
495 pub fn covariance(&self) -> Option<&Array2<F>> {
496 self.covariance.as_ref()
497 }
498
499 /// Sorted class labels as seen during fitting.
500 #[must_use]
501 pub fn classes(&self) -> &[usize] {
502 &self.classes
503 }
504
505 /// Per-class discriminant scores. Mirrors sklearn
506 /// `LinearDiscriminantAnalysis.decision_function` (the `LinearClassifierMixin`,
507 /// `discriminant_analysis.py:739`): the affine map `X @ coef_.T + intercept_`.
508 ///
509 /// Returns shape `(n_samples, n_classes)`. (Binary collapse to `(n,)` pends
510 /// REQ-14/#600.) argmax of each row agrees with [`Predict`].
511 ///
512 /// # Errors
513 ///
514 /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
515 /// match the fitted model.
516 pub fn decision_function(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
517 if x.ncols() != self.n_features {
518 return Err(FerroError::ShapeMismatch {
519 expected: vec![x.nrows(), self.n_features],
520 actual: vec![x.nrows(), x.ncols()],
521 context: "FittedLDA::decision_function".into(),
522 });
523 }
524 // X @ coef_.T + intercept_ (coef_ is (n_classes, n_features)).
525 let mut out = x.dot(&self.coef.t());
526 let n_classes = self.intercept.len();
527 for mut row in out.rows_mut() {
528 for c in 0..n_classes {
529 row[c] = row[c] + self.intercept[c];
530 }
531 }
532 Ok(out)
533 }
534
535 /// Predict per-class probabilities. Mirrors sklearn
536 /// `LinearDiscriminantAnalysis.predict_proba` (`discriminant_analysis.py:706-711`):
537 /// the multiclass `softmax(decision_function)` (the row-max-shifted softmax
538 /// of `sklearn.utils.extmath.softmax`, `extmath.py:949-985`).
539 ///
540 /// Returns shape `(n_samples, n_classes)`; rows sum to 1. (The binary
541 /// `[1-expit(d), expit(d)]` collapse pends REQ-14/#600; the multiclass
542 /// softmax here is correct for `n_classes >= 2` because `coef_`/`intercept_`
543 /// are not yet collapsed to the binary single-row form.)
544 ///
545 /// # Errors
546 ///
547 /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
548 /// match the model.
549 pub fn predict_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
550 let decision = self.decision_function(x)?;
551 let n_samples = decision.nrows();
552 let n_classes = decision.ncols();
553 let mut proba = Array2::<F>::zeros((n_samples, n_classes));
554 for i in 0..n_samples {
555 let max_l = (0..n_classes)
556 .map(|c| decision[[i, c]])
557 .fold(F::neg_infinity(), |a, b| if b > a { b } else { a });
558 let mut sum_exp = F::zero();
559 for c in 0..n_classes {
560 let e = (decision[[i, c]] - max_l).exp();
561 proba[[i, c]] = e;
562 sum_exp = sum_exp + e;
563 }
564 for c in 0..n_classes {
565 proba[[i, c]] = proba[[i, c]] / sum_exp;
566 }
567 }
568 Ok(proba)
569 }
570
571 /// Element-wise log of [`predict_proba`](Self::predict_proba). Mirrors
572 /// sklearn `predict_log_proba` exactly (`discriminant_analysis.py:713-737`):
573 /// entries that are EXACTLY `0.0` are bumped by the dtype's
574 /// `smallest_normal` (`f32`/`f64::MIN_POSITIVE`) before taking `log`
575 /// (`:729-736`), so `log(0)` becomes `log(MIN_POSITIVE)` rather than `-inf`;
576 /// every nonzero probability keeps its true `ln`.
577 ///
578 /// # Errors
579 ///
580 /// Forwards any error from [`predict_proba`](Self::predict_proba).
581 pub fn predict_log_proba(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
582 let proba = self.predict_proba(x)?;
583 // sklearn: prediction[prediction == 0.0] += smallest_normal; log(prediction).
584 // `F::min_positive_value()` is numpy's `finfo(dtype).smallest_normal`
585 // (`f64::MIN_POSITIVE` ≈ 2.2250738585072014e-308).
586 let smallest_normal = F::min_positive_value();
587 Ok(proba.mapv(|p| {
588 if p == F::zero() {
589 (p + smallest_normal).ln()
590 } else {
591 p.ln()
592 }
593 }))
594 }
595}
596
597// ---------------------------------------------------------------------------
598// Internal helpers
599// ---------------------------------------------------------------------------
600
601/// Convert a `usize` count to `F` without panicking. Returns
602/// [`FerroError::NumericalInstability`] if the value is not representable.
603#[inline]
604fn usize_to_f<F: Float>(v: usize) -> Result<F, FerroError> {
605 F::from(v).ok_or_else(|| FerroError::NumericalInstability {
606 message: format!("could not represent count {v} as the float type"),
607 })
608}
609
610/// `0.5` as `F`, built panic-free from `1 / (1 + 1)` (exact for binary floats).
611#[inline]
612fn half<F: Float>() -> F {
613 F::one() / (F::one() + F::one())
614}
615
616/// Singular values `S` and right singular vectors transposed `Vt` of the thin
617/// SVD `A = U·diag(S)·Vt` (`full_matrices=False`), on the ferray substrate
618/// (`ferray::linalg::svd`, the analog of `scipy.linalg.svd(X,
619/// full_matrices=False)`, `discriminant_analysis.py:530,545`). Mirrors the
620/// bridging pattern in `qda.rs::svd_s_vt` / `bayesian_ridge.rs::svd_thin`
621/// (R-SUBSTRATE-4): the caller keeps its `ndarray` signature and the
622/// ndarray↔ferray conversion happens here.
623///
624/// Returns `(S, Vt)` with `S` of length `k = min(m, n)` (descending) and `Vt`
625/// of shape `(k, n)`.
626///
627/// # Errors
628///
629/// Returns [`FerroError::NumericalInstability`] if the ferray array build or
630/// the SVD itself fails.
631fn svd_s_vt<F: LinalgFloat>(a: &Array2<F>) -> Result<(Array1<F>, Array2<F>), FerroError> {
632 let (m, n) = a.dim();
633 let a_flat: Vec<F> = a.iter().copied().collect();
634 let fa =
635 FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([m, n]), a_flat).map_err(|e| {
636 FerroError::NumericalInstability {
637 message: format!("ferray svd: failed to build matrix: {e}"),
638 }
639 })?;
640 let (_u, s, vt) = svd(&fa, false).map_err(|e| FerroError::NumericalInstability {
641 message: format!("ferray svd failed: {e}"),
642 })?;
643 let s_nd = Array1::from_vec(s.iter().copied().collect());
644 let vt_shape = vt.shape();
645 let vt_nd = Array2::from_shape_vec((vt_shape[0], vt_shape[1]), vt.iter().copied().collect())
646 .map_err(|e| FerroError::NumericalInstability {
647 message: format!("ferray svd: Vt shape conversion failed: {e}"),
648 })?;
649 Ok((s_nd, vt_nd))
650}
651
652/// Maximum-likelihood empirical covariance of `xg` (rows = samples), centered on
653/// the per-column mean and normalized by `n` (NOT `n-1`). Mirrors sklearn's
654/// `empirical_covariance` / `np.cov(..., bias=1)` (`discriminant_analysis.py:77`,
655/// `covariance/_empirical_covariance.py:109`).
656///
657/// Returns the `(p, p)` covariance and the per-column means (length `p`).
658fn empirical_covariance<F: Float>(xg: &Array2<F>) -> Result<(Array2<F>, Array1<F>), FerroError> {
659 let (n, p) = xg.dim();
660 let nf = usize_to_f::<F>(n)?;
661 let mut mean = Array1::<F>::zeros(p);
662 for i in 0..n {
663 for j in 0..p {
664 mean[j] = mean[j] + xg[[i, j]];
665 }
666 }
667 for j in 0..p {
668 mean[j] = mean[j] / nf;
669 }
670 let mut cov = Array2::<F>::zeros((p, p));
671 for a in 0..p {
672 for b in 0..p {
673 let mut acc = F::zero();
674 for i in 0..n {
675 acc = acc + (xg[[i, a]] - mean[a]) * (xg[[i, b]] - mean[b]);
676 }
677 cov[[a, b]] = acc / nf;
678 }
679 }
680 Ok((cov, mean))
681}
682
683/// Ledoit-Wolf analytical shrinkage coefficient of `x` (rows = samples), the
684/// transcription of sklearn's `ledoit_wolf_shrinkage`
685/// (`covariance/_shrunk_covariance.py:299-402`) for the unblocked case
686/// (`block_size=1000 >> n_features`, so `n_splits = 0` and the blocked loops
687/// collapse to the single tail term `beta_ = Σ(X²ᵀ·X²)`, `delta_ =
688/// Σ((Xᵀ·X)²)`). `x` is assumed already centered (`assume_centered=True`, the
689/// caller centers in [`cov_shrunk`]).
690///
691/// Formula (`:365-401`): with `X² = x⊙x`, `emp_cov_trace = Σ_i X²[i,:]/n`,
692/// `mu = Σ(emp_cov_trace)/p`, `beta_ = Σ(X²ᵀ·X²)`, `delta_ = Σ((Xᵀ·X)²)/n²`,
693/// `beta = (1/(p·n))·(beta_/n − delta_)`,
694/// `delta = (delta_ − 2·mu·Σ(emp_cov_trace) + p·mu²)/p`,
695/// `beta = min(beta, delta)`, `shrinkage = 0 if beta==0 else beta/delta`.
696fn ledoit_wolf_shrinkage<F: Float>(x: &Array2<F>) -> Result<F, FerroError> {
697 let (n, p) = x.dim();
698 // sklearn `:345-346`: for a single feature the result is shrinkage-invariant.
699 if p == 1 {
700 return Ok(F::zero());
701 }
702 let nf = usize_to_f::<F>(n)?;
703 let pf = usize_to_f::<F>(p)?;
704
705 // emp_cov_trace[j] = Σ_i x[i,j]² / n (:365-366)
706 let mut emp_cov_trace = Array1::<F>::zeros(p);
707 for j in 0..p {
708 let mut acc = F::zero();
709 for i in 0..n {
710 acc = acc + x[[i, j]] * x[[i, j]];
711 }
712 emp_cov_trace[j] = acc / nf;
713 }
714 // mu = Σ(emp_cov_trace) / p (:367)
715 let mut trace_sum = F::zero();
716 for j in 0..p {
717 trace_sum = trace_sum + emp_cov_trace[j];
718 }
719 let mu = trace_sum / pf;
720
721 // beta_ = Σ over (a,b) of (X²ᵀ·X²)[a,b] = Σ_{a,b} Σ_i x[i,a]²·x[i,b]² (:388-390)
722 // delta_ = Σ over (a,b) of ((Xᵀ·X)[a,b])² (:384-386)
723 let mut beta_acc = F::zero();
724 let mut delta_acc = F::zero();
725 for a in 0..p {
726 for b in 0..p {
727 let mut g_ab = F::zero(); // (Xᵀ·X)[a,b] = Σ_i x[i,a]·x[i,b]
728 let mut h_ab = F::zero(); // (X²ᵀ·X²)[a,b] = Σ_i x[i,a]²·x[i,b]²
729 for i in 0..n {
730 let xa = x[[i, a]];
731 let xb = x[[i, b]];
732 g_ab = g_ab + xa * xb;
733 h_ab = h_ab + (xa * xa) * (xb * xb);
734 }
735 beta_acc = beta_acc + h_ab;
736 delta_acc = delta_acc + g_ab * g_ab;
737 }
738 }
739 // delta_ /= n² (:387)
740 let delta_ = delta_acc / (nf * nf);
741 // beta = (1/(p·n)) · (beta_/n − delta_) (:392)
742 let beta = (F::one() / (pf * nf)) * (beta_acc / nf - delta_);
743 // delta = (delta_ − 2·mu·Σ(emp_cov_trace) + p·mu²) / p (:394-395)
744 let two = F::one() + F::one();
745 let mut delta = delta_ - two * mu * trace_sum + pf * mu * mu;
746 delta = delta / pf;
747 // beta = min(beta, delta) (:399)
748 let beta = if beta < delta { beta } else { delta };
749 // shrinkage = 0 if beta==0 else beta/delta (:401)
750 if beta == F::zero() {
751 Ok(F::zero())
752 } else {
753 Ok(beta / delta)
754 }
755}
756
757/// Per-class covariance estimate with optional shrinkage — sklearn's `_cov`
758/// (`discriminant_analysis.py:36-93`) for `covariance_estimator=None`:
759/// - [`Shrinkage::None`] → empirical maximum-likelihood covariance (`:76-77`).
760/// - [`Shrinkage::Fixed`]`(s)` → `shrunk_covariance(emp_cov, s)`
761/// (`:78-79`, `_shrunk_covariance.py:153-156`):
762/// `(1 − s)·emp_cov + s·(trace(emp_cov)/p)·I`.
763/// - [`Shrinkage::Auto`] → Ledoit-Wolf on the StandardScaler-standardized data,
764/// then rescaled (`:70-75`): standardize `Xs = (X − mean)/scale` (population
765/// std, `ddof=0`, zeros → 1), `s = ledoit_wolf(Xs)`, then `cov[a,b] =
766/// scale[a]·s[a,b]·scale[b]`.
767fn cov_shrunk<F: Float>(xg: &Array2<F>, shrinkage: Shrinkage<F>) -> Result<Array2<F>, FerroError> {
768 let (n, p) = xg.dim();
769 match shrinkage {
770 Shrinkage::None => {
771 let (cov, _mean) = empirical_covariance(xg)?;
772 Ok(cov)
773 }
774 Shrinkage::Fixed(s) => {
775 let (emp, _mean) = empirical_covariance(xg)?;
776 let pf = usize_to_f::<F>(p)?;
777 let mut trace = F::zero();
778 for j in 0..p {
779 trace = trace + emp[[j, j]];
780 }
781 let mu = trace / pf;
782 let mut out = Array2::<F>::zeros((p, p));
783 for a in 0..p {
784 for b in 0..p {
785 let diag = if a == b { mu } else { F::zero() };
786 out[[a, b]] = (F::one() - s) * emp[[a, b]] + s * diag;
787 }
788 }
789 Ok(out)
790 }
791 Shrinkage::Auto => {
792 // StandardScaler: center + divide by POPULATION std (ddof=0); zeros
793 // replaced by 1.0 (sklearn StandardScaler `_handle_zeros_in_scale`).
794 let nf = usize_to_f::<F>(n)?;
795 let mut mean = Array1::<F>::zeros(p);
796 for i in 0..n {
797 for j in 0..p {
798 mean[j] = mean[j] + xg[[i, j]];
799 }
800 }
801 for j in 0..p {
802 mean[j] = mean[j] / nf;
803 }
804 let mut scale = Array1::<F>::zeros(p);
805 for j in 0..p {
806 let mut var = F::zero();
807 for i in 0..n {
808 let d = xg[[i, j]] - mean[j];
809 var = var + d * d;
810 }
811 var = var / nf;
812 let sd = var.sqrt();
813 scale[j] = if sd == F::zero() { F::one() } else { sd };
814 }
815 let mut xs = Array2::<F>::zeros((n, p));
816 for i in 0..n {
817 for j in 0..p {
818 xs[[i, j]] = (xg[[i, j]] - mean[j]) / scale[j];
819 }
820 }
821 // ledoit_wolf re-centers; Xs already has ~0 mean, but follow sklearn
822 // exactly and re-center (assume_centered=False, `:357-358`).
823 let mut xs_mean = Array1::<F>::zeros(p);
824 for i in 0..n {
825 for j in 0..p {
826 xs_mean[j] = xs_mean[j] + xs[[i, j]];
827 }
828 }
829 for j in 0..p {
830 xs_mean[j] = xs_mean[j] / nf;
831 }
832 let mut xc = Array2::<F>::zeros((n, p));
833 for i in 0..n {
834 for j in 0..p {
835 xc[[i, j]] = xs[[i, j]] - xs_mean[j];
836 }
837 }
838 // shrinkage coefficient from the (centered) standardized data.
839 let shr = ledoit_wolf_shrinkage(&xc)?;
840 // emp_cov of the standardized data = Xcᵀ·Xc / n, then shrink:
841 // (1 − shr)·emp + shr·(trace(emp)/p)·I (`_shrunk_covariance.py`).
842 let (emp, _m) = empirical_covariance(&xs)?;
843 let pf = usize_to_f::<F>(p)?;
844 let mut trace = F::zero();
845 for j in 0..p {
846 trace = trace + emp[[j, j]];
847 }
848 let mu = trace / pf;
849 // rescale: cov[a,b] = scale[a] · shrunk[a,b] · scale[b] (`:75`).
850 let mut out = Array2::<F>::zeros((p, p));
851 for a in 0..p {
852 for b in 0..p {
853 let diag = if a == b { mu } else { F::zero() };
854 let shrunk = (F::one() - shr) * emp[[a, b]] + shr * diag;
855 out[[a, b]] = scale[a] * shrunk * scale[b];
856 }
857 }
858 Ok(out)
859 }
860 }
861}
862
863/// Solve the multi-RHS least-squares problem `A @ x = b` (with `b` having
864/// `nrhs` columns) through [`ferray::linalg::lstsq`]
865/// (`ferray-linalg/src/solve.rs:208`, the LAPACK-`gelsd`-equivalent single-SVD
866/// min-norm solver), bridging ndarray↔ferray at this boundary (R-SUBSTRATE-4),
867/// mirroring the bridge in `linalg.rs::solve_lstsq`. Returns the `(n, nrhs)`
868/// solution. Used by `_solve_lstsq` to compute `coef_ = lstsq(covariance_,
869/// means_.T)[0].T` (`discriminant_analysis.py:416`).
870///
871/// `rcond` is `Some(F::epsilon())`, pinning the singular-value cutoff to scipy's
872/// `cond=eps` default (matching `linalg.lstsq` `:416`), as in `solve_lstsq`.
873fn lstsq_multi<F: LinalgFloat>(a: &Array2<F>, b: &Array2<F>) -> Result<Array2<F>, FerroError> {
874 let (m, n) = a.dim();
875 let (bm, nrhs) = b.dim();
876 if bm != m {
877 return Err(FerroError::ShapeMismatch {
878 expected: vec![m, nrhs],
879 actual: vec![bm, nrhs],
880 context: "LDA lsqr: covariance/means row mismatch".into(),
881 });
882 }
883 let a_flat: Vec<F> = a.iter().copied().collect();
884 let fa =
885 FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([m, n]), a_flat).map_err(|e| {
886 FerroError::NumericalInstability {
887 message: format!("ferray lstsq: failed to build matrix A: {e}"),
888 }
889 })?;
890 let b_flat: Vec<F> = b.iter().copied().collect();
891 let fb = FerrayArray::<F, ferray::IxDyn>::from_vec(ferray::IxDyn::new(&[bm, nrhs]), b_flat)
892 .map_err(|e| FerroError::NumericalInstability {
893 message: format!("ferray lstsq: failed to build RHS B: {e}"),
894 })?;
895 let (sol, _residuals, _rank, _singular) = ferray::linalg::lstsq(&fa, &fb, Some(F::epsilon()))
896 .map_err(|e| FerroError::NumericalInstability {
897 message: format!("ferray lstsq solve failed: {e}"),
898 })?;
899 let sol_shape = sol.shape();
900 let out = Array2::from_shape_vec((sol_shape[0], sol_shape[1]), sol.iter().copied().collect())
901 .map_err(|e| FerroError::NumericalInstability {
902 message: format!("ferray lstsq: solution shape conversion failed: {e}"),
903 })?;
904 Ok(out)
905}
906
907/// Lower-triangular Cholesky factor `L` of the symmetric-positive-definite `a`
908/// (`a = L·Lᵀ`), on the ferray substrate ([`ferray::linalg::cholesky`],
909/// `ferray-linalg/src/decomp/cholesky.rs:22`, the analog of
910/// `scipy.linalg.cholesky(..., lower=True)`), bridging ndarray↔ferray at this
911/// boundary (R-SUBSTRATE-4) exactly as [`svd_s_vt`]/[`lstsq_multi`] do. Used by
912/// the generalized-eigen reduction in `_solve_eigen`
913/// (`discriminant_analysis.py:475`, `linalg.eigh(Sb, Sw)`).
914///
915/// # Errors
916///
917/// Returns [`FerroError::NumericalInstability`] if the ferray build or the
918/// factorization fails (e.g. `Sw` is not positive definite).
919fn cholesky_lower<F: LinalgFloat>(a: &Array2<F>) -> Result<Array2<F>, FerroError> {
920 let (m, n) = a.dim();
921 let a_flat: Vec<F> = a.iter().copied().collect();
922 let fa =
923 FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([m, n]), a_flat).map_err(|e| {
924 FerroError::NumericalInstability {
925 message: format!("ferray cholesky: failed to build matrix: {e}"),
926 }
927 })?;
928 let l = cholesky(&fa).map_err(|e| FerroError::NumericalInstability {
929 message: format!("ferray cholesky failed (Sw not positive definite?): {e}"),
930 })?;
931 let shape = l.shape();
932 Array2::from_shape_vec((shape[0], shape[1]), l.iter().copied().collect()).map_err(|e| {
933 FerroError::NumericalInstability {
934 message: format!("ferray cholesky: shape conversion failed: {e}"),
935 }
936 })
937}
938
939/// Inverse of the square matrix `a`, on the ferray substrate
940/// ([`ferray::linalg::inv`], `ferray-linalg/src/solve.rs:367`, the analog of
941/// `numpy.linalg.inv`), bridging ndarray↔ferray at this boundary
942/// (R-SUBSTRATE-4). Used to form `L⁻¹` for the generalized-eigen reduction in
943/// `_solve_eigen`.
944///
945/// # Errors
946///
947/// Returns [`FerroError::NumericalInstability`] if the ferray build or the
948/// inversion fails (singular matrix).
949fn matrix_inverse<F: LinalgFloat>(a: &Array2<F>) -> Result<Array2<F>, FerroError> {
950 let (m, n) = a.dim();
951 let a_flat: Vec<F> = a.iter().copied().collect();
952 let fa =
953 FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([m, n]), a_flat).map_err(|e| {
954 FerroError::NumericalInstability {
955 message: format!("ferray inv: failed to build matrix: {e}"),
956 }
957 })?;
958 let ai = inv(&fa).map_err(|e| FerroError::NumericalInstability {
959 message: format!("ferray inv failed (singular matrix?): {e}"),
960 })?;
961 let shape = ai.shape();
962 Array2::from_shape_vec((shape[0], shape[1]), ai.iter().copied().collect()).map_err(|e| {
963 FerroError::NumericalInstability {
964 message: format!("ferray inv: shape conversion failed: {e}"),
965 }
966 })
967}
968
969/// Eigenvalues and eigenvectors of the symmetric matrix `a`, on the ferray
970/// substrate ([`ferray::linalg::eigh`], `ferray-linalg/src/decomp/eigen.rs:105`,
971/// the analog of `scipy.linalg.eigh` for the STANDARD symmetric problem),
972/// bridging ndarray↔ferray at this boundary (R-SUBSTRATE-4). Returns
973/// `(evals, evecs)` with eigenvalues in ASCENDING order (ferray/LAPACK
974/// convention) and eigenvectors as columns of `evecs`. The generalized
975/// `eigh(Sb, Sw)` of `_solve_eigen` (`discriminant_analysis.py:475`) is reduced
976/// to this standard form via the Cholesky factor of `Sw` (see `solve_eigen`).
977///
978/// # Errors
979///
980/// Returns [`FerroError::NumericalInstability`] if the ferray build or the
981/// eigendecomposition fails.
982fn eigh_sym<F: LinalgFloat>(a: &Array2<F>) -> Result<(Array1<F>, Array2<F>), FerroError> {
983 let (m, n) = a.dim();
984 let a_flat: Vec<F> = a.iter().copied().collect();
985 let fa =
986 FerrayArray::<F, FerrayIx2>::from_vec(FerrayIx2::new([m, n]), a_flat).map_err(|e| {
987 FerroError::NumericalInstability {
988 message: format!("ferray eigh: failed to build matrix: {e}"),
989 }
990 })?;
991 let (vals, vecs) = eigh(&fa).map_err(|e| FerroError::NumericalInstability {
992 message: format!("ferray eigh failed: {e}"),
993 })?;
994 let vals_nd = Array1::from_vec(vals.iter().copied().collect());
995 let vecs_shape = vecs.shape();
996 let vecs_nd = Array2::from_shape_vec(
997 (vecs_shape[0], vecs_shape[1]),
998 vecs.iter().copied().collect(),
999 )
1000 .map_err(|e| FerroError::NumericalInstability {
1001 message: format!("ferray eigh: eigenvector shape conversion failed: {e}"),
1002 })?;
1003 Ok((vals_nd, vecs_nd))
1004}
1005
1006// ---------------------------------------------------------------------------
1007// Fit (sklearn _solve_svd)
1008// ---------------------------------------------------------------------------
1009
1010impl<F: LinalgFloat + ScalarOperand> Fit<Array2<F>, Array1<usize>> for LDA<F> {
1011 type Fitted = FittedLDA<F>;
1012 type Error = FerroError;
1013
1014 /// Fit the LDA model via sklearn's default `solver="svd"` path
1015 /// (`discriminant_analysis.py:487-559`): two SVDs whiten the within-class
1016 /// data and project onto the between-class subspace, yielding `scalings_`,
1017 /// `xbar_`, `coef_`, `intercept_` (embedding `log(priors_)`), and
1018 /// `explained_variance_ratio_`.
1019 ///
1020 /// # Errors
1021 ///
1022 /// - [`FerroError::InsufficientSamples`] if fewer than 2 samples / classes.
1023 /// - [`FerroError::InvalidParameter`] if `n_components` is zero or exceeds
1024 /// `min(n_classes - 1, n_features)`.
1025 /// - [`FerroError::ShapeMismatch`] if `x` and `y` have different row counts.
1026 /// - [`FerroError::NumericalInstability`] if an SVD fails.
1027 #[allow(
1028 clippy::needless_range_loop,
1029 reason = "explicit index loops mirror sklearn's broadcasting per-column/per-class"
1030 )]
1031 fn fit(&self, x: &Array2<F>, y: &Array1<usize>) -> Result<FittedLDA<F>, FerroError> {
1032 let (n_samples, n_features) = x.dim();
1033
1034 if n_samples != y.len() {
1035 return Err(FerroError::ShapeMismatch {
1036 expected: vec![n_samples],
1037 actual: vec![y.len()],
1038 context: "LDA: y length must match number of rows in X".into(),
1039 });
1040 }
1041 if n_samples < 2 {
1042 return Err(FerroError::InsufficientSamples {
1043 required: 2,
1044 actual: n_samples,
1045 context: "LDA requires at least 2 samples".into(),
1046 });
1047 }
1048
1049 // Sorted unique classes (sklearn `classes_ = unique_labels(y)`, :592).
1050 let mut classes: Vec<usize> = y.to_vec();
1051 classes.sort_unstable();
1052 classes.dedup();
1053 let n_classes = classes.len();
1054
1055 if n_classes < 2 {
1056 return Err(FerroError::InsufficientSamples {
1057 required: 2,
1058 actual: n_classes,
1059 context: "LDA requires at least 2 distinct classes".into(),
1060 });
1061 }
1062 // sklearn rejects n_samples == n_classes (:596-599).
1063 if n_samples == n_classes {
1064 return Err(FerroError::InsufficientSamples {
1065 required: n_classes + 1,
1066 actual: n_samples,
1067 context: "LDA: number of samples must exceed number of classes".into(),
1068 });
1069 }
1070
1071 // Non-finite input validation (#2263). sklearn `LinearDiscriminantAnalysis.fit`
1072 // -> `self._validate_data(X, y, ensure_min_samples=2, ...)`
1073 // (`discriminant_analysis.py:589`) keeps the default
1074 // `force_all_finite=True`, so `check_array` rejects any NaN or +/-inf in
1075 // X with a `ValueError("Input X contains NaN.")` / `"... contains
1076 // infinity ..."` BEFORE the solver dispatch (svd/lsqr/eigen). `y` is
1077 // `Array1<usize>` here (integer class labels), finite by construction, so
1078 // only X needs the runtime check; LDA's `fit` takes no `sample_weight`.
1079 // `.iter().any(|v| !v.is_finite())` rejects both NaN and Inf (bounds-safe,
1080 // no panic, R-CODE-2). This is the shared fit entry — all three solvers
1081 // (svd default, lsqr, eigen) dispatch downstream of it, so the guard
1082 // covers every solver. The finite path is byte-identical.
1083 if x.iter().any(|v| !v.is_finite()) {
1084 return Err(FerroError::InvalidParameter {
1085 name: "X".into(),
1086 reason: "Input X contains NaN or infinity.".into(),
1087 });
1088 }
1089
1090 // _max_components (sklearn :614-625).
1091 let max_components = (n_classes - 1).min(n_features);
1092 let user_max = match self.n_components {
1093 None => max_components,
1094 Some(0) => {
1095 return Err(FerroError::InvalidParameter {
1096 name: "n_components".into(),
1097 reason: "must be at least 1".into(),
1098 });
1099 }
1100 Some(k) if k > max_components => {
1101 return Err(FerroError::InvalidParameter {
1102 name: "n_components".into(),
1103 reason: format!(
1104 "n_components ({k}) cannot be larger than min(n_features, n_classes - 1) = {max_components}"
1105 ),
1106 });
1107 }
1108 Some(k) => k,
1109 };
1110
1111 let n_f = usize_to_f::<F>(n_samples)?;
1112
1113 // --- per-class means_ and class indices (sklearn `_class_means`) ------
1114 let mut means = Array2::<F>::zeros((n_classes, n_features));
1115 let mut class_indices: Vec<Vec<usize>> = vec![Vec::new(); n_classes];
1116 let mut class_pos = std::collections::HashMap::new();
1117 for (idx, &cls) in classes.iter().enumerate() {
1118 class_pos.insert(cls, idx);
1119 }
1120 for (i, &label) in y.iter().enumerate() {
1121 if let Some(&idx) = class_pos.get(&label) {
1122 class_indices[idx].push(i);
1123 }
1124 }
1125 for (idx, indices) in class_indices.iter().enumerate() {
1126 if indices.is_empty() {
1127 return Err(FerroError::InsufficientSamples {
1128 required: 1,
1129 actual: 0,
1130 context: format!("LDA: class {} has no samples", classes[idx]),
1131 });
1132 }
1133 let cnt_f = usize_to_f::<F>(indices.len())?;
1134 for &i in indices {
1135 for j in 0..n_features {
1136 means[[idx, j]] += x[[i, j]];
1137 }
1138 }
1139 for j in 0..n_features {
1140 means[[idx, j]] /= cnt_f;
1141 }
1142 }
1143
1144 // --- priors_ (sklearn :601-605) --------------------------------------
1145 // `priors=None` (default) ⇒ empirical `n_k / n` inferred from the data
1146 // (`:601-603`). `Some(p)` ⇒ `p` used VERBATIM (`:605`,
1147 // `self.priors_ = xp.asarray(self.priors)`). sklearn would mis-index a
1148 // wrong-length array, so reject it up front (R-DEV-4 length check).
1149 let priors = match &self.priors {
1150 None => {
1151 let mut priors = Array1::<F>::zeros(n_classes);
1152 for idx in 0..n_classes {
1153 priors[idx] = usize_to_f::<F>(class_indices[idx].len())? / n_f;
1154 }
1155 priors
1156 }
1157 Some(p) => {
1158 if p.len() != n_classes {
1159 return Err(FerroError::ShapeMismatch {
1160 expected: vec![n_classes],
1161 actual: vec![p.len()],
1162 context: "LDA: priors length must match number of classes".into(),
1163 });
1164 }
1165 let mut p = p.clone();
1166 // sklearn rejects negative priors (:607-608,
1167 // `if xp.any(self.priors_ < 0): raise ValueError("priors must
1168 // be non-negative")`).
1169 if p.iter().any(|&v| v < <F as num_traits::Zero>::zero()) {
1170 return Err(FerroError::InvalidParameter {
1171 name: "priors".into(),
1172 reason: "priors must be non-negative".into(),
1173 });
1174 }
1175 // sklearn renormalizes (with a UserWarning) when the priors do
1176 // not sum to 1 (:610-612, `if xp.abs(xp.sum(self.priors_) - 1.0)
1177 // > 1e-5: warnings.warn(...); self.priors_ = self.priors_ /
1178 // self.priors_.sum()`). FerroError has no warning channel; the
1179 // crate emits warnings via `eprintln!` (cf. qda.rs collinearity
1180 // warning, `discriminant_analysis.py:947`). The observable
1181 // contract is the renormalized `priors_`.
1182 let s = p.sum();
1183 let tol_sum = F::from(1e-5).unwrap_or_else(F::epsilon);
1184 if (s - <F as num_traits::One>::one()).abs() > tol_sum {
1185 eprintln!("The priors do not sum to 1. Renormalizing");
1186 for v in p.iter_mut() {
1187 *v /= s;
1188 }
1189 }
1190 p
1191 }
1192 };
1193
1194 // --- solver dispatch (sklearn :627-650) -------------------------------
1195 // `lsqr` and `eigen` need only means_/priors_/class_indices; resolve
1196 // them above this point, then branch. `svd` falls through to the
1197 // existing two-SVD path below (BYTE-IDENTICAL — `shrinkage` must be
1198 // `None` for svd, sklearn `NotImplementedError` `:628-629`).
1199 match self.solver {
1200 Solver::Lsqr => {
1201 return self.solve_lstsq(
1202 x,
1203 &classes,
1204 &class_indices,
1205 &means,
1206 &priors,
1207 user_max,
1208 n_features,
1209 );
1210 }
1211 Solver::Eigen => {
1212 // The generalized-eigenvalue solver (sklearn `_solve_eigen`,
1213 // `discriminant_analysis.py:421-485`): generalized `eigh(Sb, Sw)`
1214 // reduced to a standard symmetric eigenproblem via the Cholesky
1215 // factor of `Sw`. Supports `shrinkage` (like lsqr). See
1216 // [`LDA::solve_eigen`].
1217 return self.solve_eigen(
1218 x,
1219 &classes,
1220 &class_indices,
1221 &means,
1222 &priors,
1223 user_max,
1224 n_features,
1225 );
1226 }
1227 Solver::Svd => {
1228 // sklearn: svd + shrinkage != None → NotImplementedError
1229 // ("shrinkage not supported with 'svd' solver.", `:628-629`).
1230 if !matches!(self.shrinkage, Shrinkage::None) {
1231 return Err(FerroError::InvalidParameter {
1232 name: "shrinkage".into(),
1233 reason: "shrinkage not supported with svd solver".into(),
1234 });
1235 }
1236 }
1237 }
1238
1239 // --- xbar_ = priors_ @ means_ (sklearn :517) -------------------------
1240 let mut xbar = Array1::<F>::zeros(n_features);
1241 for j in 0..n_features {
1242 let mut acc = <F as num_traits::Zero>::zero();
1243 for idx in 0..n_classes {
1244 acc += priors[idx] * means[[idx, j]];
1245 }
1246 xbar[j] = acc;
1247 }
1248
1249 // --- covariance_ (sklearn :509-510, `_class_cov` :128-172) -----------
1250 // When `store_covariance` is set, compute the shared within-class
1251 // covariance `Σ_k priors_[k] · cov(X_k)`, where `cov(X_k)` is the
1252 // MAXIMUM-LIKELIHOOD empirical covariance of class k's samples —
1253 // `empirical_covariance` calls `np.cov(Xg.T, bias=1)`
1254 // (`covariance/_empirical_covariance.py:109`), i.e. centered on the
1255 // class mean and normalized by `n_k` (NOT `n_k - 1`). Verified against
1256 // the live oracle: class-0 of the dispatch fixture yields the documented
1257 // `[[0.4296875, …], …]` only under the `bias=1` (÷n_k) normalization.
1258 let covariance = if self.store_covariance {
1259 let mut cov = Array2::<F>::zeros((n_features, n_features));
1260 for (idx, indices) in class_indices.iter().enumerate() {
1261 let nk = usize_to_f::<F>(indices.len())?;
1262 let prior_k = priors[idx];
1263 // cov(X_k)[a, b] = (1/n_k) Σ_i (x_ia - μ_ka)(x_ib - μ_kb).
1264 for a in 0..n_features {
1265 for b in 0..n_features {
1266 let mut acc = <F as num_traits::Zero>::zero();
1267 for &i in indices {
1268 acc += (x[[i, a]] - means[[idx, a]]) * (x[[i, b]] - means[[idx, b]]);
1269 }
1270 cov[[a, b]] += prior_k * (acc / nk);
1271 }
1272 }
1273 }
1274 Some(cov)
1275 } else {
1276 None
1277 };
1278
1279 // --- Xc = each sample minus its class mean (stacked; sklearn :512-519) -
1280 let mut xc = Array2::<F>::zeros((n_samples, n_features));
1281 for (idx, indices) in class_indices.iter().enumerate() {
1282 for &i in indices {
1283 for j in 0..n_features {
1284 xc[[i, j]] = x[[i, j]] - means[[idx, j]];
1285 }
1286 }
1287 }
1288
1289 // --- std = population std of Xc per column (ddof=0; sklearn :522-524) --
1290 // numpy std: sqrt(mean((Xc - mean(Xc))^2)). Xc columns already have ~0
1291 // mean by construction, but follow numpy exactly (subtract the column
1292 // mean) for ULP fidelity.
1293 let mut std = Array1::<F>::zeros(n_features);
1294 for j in 0..n_features {
1295 let mut col_mean = <F as num_traits::Zero>::zero();
1296 for i in 0..n_samples {
1297 col_mean += xc[[i, j]];
1298 }
1299 col_mean /= n_f;
1300 let mut var = <F as num_traits::Zero>::zero();
1301 for i in 0..n_samples {
1302 let d = xc[[i, j]] - col_mean;
1303 var += d * d;
1304 }
1305 var /= n_f;
1306 let s = var.sqrt();
1307 std[j] = if s == <F as num_traits::Zero>::zero() {
1308 <F as num_traits::One>::one()
1309 } else {
1310 s
1311 };
1312 }
1313
1314 // --- Xw = sqrt(1/(n-c)) * (Xc / std) (sklearn :525-528) --------------
1315 let denom = usize_to_f::<F>(n_samples - n_classes)?;
1316 let fac_sqrt = (<F as num_traits::One>::one() / denom).sqrt();
1317 let mut xw = Array2::<F>::zeros((n_samples, n_features));
1318 for i in 0..n_samples {
1319 for j in 0..n_features {
1320 xw[[i, j]] = fac_sqrt * (xc[[i, j]] / std[j]);
1321 }
1322 }
1323
1324 // --- first SVD: within whitening (sklearn :530-534) -------------------
1325 // sklearn's svd-solver rank threshold `tol` (constructor default `1e-4`,
1326 // `discriminant_analysis.py:354,362`), now configurable via
1327 // `LDA::with_tol`. Default `1e-4` ⇒ byte-identical to the prior hardcode.
1328 let tol = self.tol;
1329 let (s1, vt1) = svd_s_vt::<F>(&xw)?;
1330 let rank1 = s1.iter().filter(|&&v| v > tol).count();
1331 if rank1 == 0 {
1332 return Err(FerroError::NumericalInstability {
1333 message: "LDA: within-class scatter has rank 0 (all features constant)".into(),
1334 });
1335 }
1336 // scalings = (Vt[:rank]/std).T / S[:rank] -> (n_features, rank1)
1337 let mut scalings1 = Array2::<F>::zeros((n_features, rank1));
1338 for k in 0..rank1 {
1339 let sk = s1[k];
1340 for j in 0..n_features {
1341 scalings1[[j, k]] = (vt1[[k, j]] / std[j]) / sk;
1342 }
1343 }
1344
1345 // --- between-class scaled centers (sklearn :535-541) ------------------
1346 // Xb[i] = sqrt(n * priors_[i] * fac2) * (means_[i] - xbar_) then @ scalings.
1347 let fac2 = if n_classes == 1 {
1348 <F as num_traits::One>::one()
1349 } else {
1350 <F as num_traits::One>::one() / usize_to_f::<F>(n_classes - 1)?
1351 };
1352 let mut xb_centers = Array2::<F>::zeros((n_classes, n_features));
1353 for idx in 0..n_classes {
1354 let w = (n_f * priors[idx] * fac2).sqrt();
1355 for j in 0..n_features {
1356 xb_centers[[idx, j]] = w * (means[[idx, j]] - xbar[j]);
1357 }
1358 }
1359 let xb = xb_centers.dot(&scalings1); // (n_classes, rank1)
1360
1361 // --- second SVD: between-class projection (sklearn :545-555) ----------
1362 let (s2, vt2) = svd_s_vt::<F>(&xb)?;
1363
1364 // explained_variance_ratio_ = (S2^2 / sum(S2^2))[:max_components] (:550-552)
1365 let mut sum_sq = <F as num_traits::Zero>::zero();
1366 for &v in s2.iter() {
1367 sum_sq += v * v;
1368 }
1369 let evr_len = user_max.min(s2.len());
1370 let mut explained_variance_ratio = Array1::<F>::zeros(evr_len);
1371 for k in 0..evr_len {
1372 explained_variance_ratio[k] = if sum_sq > <F as num_traits::Zero>::zero() {
1373 (s2[k] * s2[k]) / sum_sq
1374 } else {
1375 <F as num_traits::Zero>::zero()
1376 };
1377 }
1378
1379 // rank2 = sum(S2 > tol * S2[0]) (sklearn :554)
1380 let s2_0 = if s2.is_empty() {
1381 <F as num_traits::Zero>::zero()
1382 } else {
1383 s2[0]
1384 };
1385 let rank2 = s2.iter().filter(|&&v| v > tol * s2_0).count();
1386 if rank2 == 0 {
1387 return Err(FerroError::NumericalInstability {
1388 message: "LDA: between-class scatter has rank 0 (classes coincide)".into(),
1389 });
1390 }
1391
1392 // scalings_ = scalings @ Vt2.T[:, :rank2] -> (n_features, rank2)
1393 // Vt2 is (k2, rank1); Vt2.T is (rank1, k2); take first rank2 columns.
1394 let mut scalings = Array2::<F>::zeros((n_features, rank2));
1395 for j in 0..n_features {
1396 for c in 0..rank2 {
1397 let mut acc = <F as num_traits::Zero>::zero();
1398 for k in 0..rank1 {
1399 // Vt2.T[k, c] = Vt2[c, k]
1400 acc += scalings1[[j, k]] * vt2[[c, k]];
1401 }
1402 scalings[[j, c]] = acc;
1403 }
1404 }
1405
1406 // --- coef_ / intercept_ (sklearn :556-559) ---------------------------
1407 // coef = (means_ - xbar_) @ scalings_ (n_classes, rank2)
1408 let mut centered_means = Array2::<F>::zeros((n_classes, n_features));
1409 for idx in 0..n_classes {
1410 for j in 0..n_features {
1411 centered_means[[idx, j]] = means[[idx, j]] - xbar[j];
1412 }
1413 }
1414 let coef_lowrank = centered_means.dot(&scalings); // (n_classes, rank2)
1415
1416 // intercept_ = -0.5 * sum(coef^2, axis=1) + log(priors_)
1417 let neg_half = -half::<F>();
1418 let mut intercept = Array1::<F>::zeros(n_classes);
1419 for idx in 0..n_classes {
1420 let mut sq = <F as num_traits::Zero>::zero();
1421 for c in 0..rank2 {
1422 sq += coef_lowrank[[idx, c]] * coef_lowrank[[idx, c]];
1423 }
1424 intercept[idx] = neg_half * sq + priors[idx].ln();
1425 }
1426
1427 // coef_ = coef @ scalings_.T (n_classes, n_features)
1428 let coef = coef_lowrank.dot(&scalings.t());
1429
1430 // intercept_ -= xbar_ @ coef_.T (subtract per class)
1431 for idx in 0..n_classes {
1432 let mut dot = <F as num_traits::Zero>::zero();
1433 for j in 0..n_features {
1434 dot += xbar[j] * coef[[idx, j]];
1435 }
1436 intercept[idx] -= dot;
1437 }
1438
1439 Ok(FittedLDA {
1440 scalings,
1441 means,
1442 xbar,
1443 priors,
1444 coef,
1445 intercept,
1446 explained_variance_ratio,
1447 covariance,
1448 classes,
1449 max_components: user_max,
1450 n_features,
1451 })
1452 }
1453}
1454
1455// ---------------------------------------------------------------------------
1456// Fit (sklearn _solve_lstsq) — the lsqr solver
1457// ---------------------------------------------------------------------------
1458
1459impl<F: LinalgFloat + ScalarOperand> LDA<F> {
1460 /// The least-squares solver (sklearn's `_solve_lstsq`,
1461 /// `discriminant_analysis.py:365-419`), dispatched from [`Fit::fit`] when
1462 /// [`Solver::Lsqr`] is selected.
1463 ///
1464 /// Computes (`:412-418`):
1465 /// - `covariance_ = Σ_k priors_[k] · cov(X_k)` where `cov(X_k)` applies the
1466 /// configured [`Shrinkage`] to class `k`'s empirical covariance
1467 /// (`_class_cov` `:128-172`, `_cov` `:36-93`).
1468 /// - `coef_ = lstsq(covariance_, means_.T)[0].T` (`:416`), via
1469 /// [`ferray::linalg::lstsq`] (multi-RHS).
1470 /// - `intercept_ = -½·diag(means_ @ coef_.T) + log(priors_)` (`:417-418`).
1471 ///
1472 /// Unlike sklearn's `svd` solver, the lsqr `coef_` is the FULL-space
1473 /// discriminant `(n_classes, n_features)` — NO `scalings_`/`xbar_`/
1474 /// `explained_variance_ratio_` and NO `transform` (sklearn raises
1475 /// `NotImplementedError` for `transform` under lsqr, `:676-679`); here
1476 /// [`Transform`] returns an error because `scalings_` is the zero matrix /
1477 /// `xbar_` is zero, and `transform` slices to `max_components` of a meaningless
1478 /// projection — we instead document that `transform` is unsupported by
1479 /// recording `max_components = 0` so the projection is empty (mirroring
1480 /// sklearn's "dimensionality reduction is not supported" for lsqr).
1481 ///
1482 /// `covariance_` is ALWAYS populated for lsqr (sklearn `:413`, the attribute
1483 /// is set regardless of `store_covariance`), exposed via
1484 /// [`FittedLDA::covariance`].
1485 ///
1486 /// `decision_function`/`predict`/`predict_proba` work identically to the svd
1487 /// path because they consume `coef_`/`intercept_` only.
1488 ///
1489 /// NOTE: the binary-collapse of `coef_`/`intercept_` to a single row
1490 /// (sklearn `:651-657`) is NOT applied here, matching the existing svd path
1491 /// (open prereq blocker #600); `coef_` stays `(n_classes, n_features)`.
1492 ///
1493 /// # Errors
1494 ///
1495 /// - [`FerroError::InvalidParameter`] if [`Shrinkage::Fixed`]`(s)` has
1496 /// `s ∉ [0, 1]` (sklearn `Interval(Real, 0, 1, closed="both")`, `:339`).
1497 /// - [`FerroError::NumericalInstability`] if the least-squares solve fails.
1498 #[allow(
1499 clippy::too_many_arguments,
1500 reason = "the lsqr solver consumes the same pre-resolved fit state (classes/indices/means/priors/dims) the svd path computes; threading them avoids recomputation"
1501 )]
1502 fn solve_lstsq(
1503 &self,
1504 x: &Array2<F>,
1505 classes: &[usize],
1506 class_indices: &[Vec<usize>],
1507 means: &Array2<F>,
1508 priors: &Array1<F>,
1509 _user_max: usize,
1510 n_features: usize,
1511 ) -> Result<FittedLDA<F>, FerroError> {
1512 let n_classes = classes.len();
1513
1514 // Validate Fixed(s): sklearn Interval(Real, 0, 1, closed="both") (:339).
1515 if let Shrinkage::Fixed(s) = self.shrinkage
1516 && (s < <F as num_traits::Zero>::zero() || s > <F as num_traits::One>::one())
1517 {
1518 return Err(FerroError::InvalidParameter {
1519 name: "shrinkage".into(),
1520 reason: "shrinkage float must be in [0, 1]".into(),
1521 });
1522 }
1523
1524 // covariance_ = Σ_k priors_[k] · cov(X_k) (sklearn _class_cov :167-172).
1525 let mut covariance = Array2::<F>::zeros((n_features, n_features));
1526 for (idx, indices) in class_indices.iter().enumerate() {
1527 // Gather class-k rows.
1528 let nk = indices.len();
1529 let mut xg = Array2::<F>::zeros((nk, n_features));
1530 for (r, &i) in indices.iter().enumerate() {
1531 for j in 0..n_features {
1532 xg[[r, j]] = x[[i, j]];
1533 }
1534 }
1535 let cov_k = cov_shrunk(&xg, self.shrinkage)?;
1536 let prior_k = priors[idx];
1537 for a in 0..n_features {
1538 for b in 0..n_features {
1539 covariance[[a, b]] += prior_k * cov_k[[a, b]];
1540 }
1541 }
1542 }
1543
1544 // coef_ = lstsq(covariance_, means_.T)[0].T (sklearn :416).
1545 // Solve `covariance_ @ X = means_.T` for X of shape (n_features,
1546 // n_classes); X = lstsq(...)[0]; coef_ = X.T = (n_classes, n_features).
1547 let means_t = means.t().to_owned(); // (n_features, n_classes)
1548 let sol = lstsq_multi(&covariance, &means_t)?; // (n_features, n_classes)
1549 let coef = sol.t().to_owned(); // (n_classes, n_features)
1550
1551 // intercept_ = -0.5 * diag(means_ @ coef_.T) + log(priors_) (:417-418).
1552 // diag(means_ @ coef_.T)[k] = Σ_j means_[k,j] · coef_[k,j].
1553 let neg_half = -half::<F>();
1554 let mut intercept = Array1::<F>::zeros(n_classes);
1555 for k in 0..n_classes {
1556 let mut dot = <F as num_traits::Zero>::zero();
1557 for j in 0..n_features {
1558 dot += means[[k, j]] * coef[[k, j]];
1559 }
1560 intercept[k] = neg_half * dot + priors[k].ln();
1561 }
1562
1563 // lsqr does NOT support dimensionality reduction (sklearn :372-373,
1564 // :676-679): no scalings_/xbar_/explained_variance_ratio_. Set them to
1565 // empty/zero and `max_components = 0` so `transform` yields a `(n, 0)`
1566 // projection (the lsqr "no transform" contract).
1567 Ok(FittedLDA {
1568 scalings: Array2::<F>::zeros((n_features, 0)),
1569 means: means.to_owned(),
1570 xbar: Array1::<F>::zeros(n_features),
1571 priors: priors.to_owned(),
1572 coef,
1573 intercept,
1574 explained_variance_ratio: Array1::<F>::zeros(0),
1575 covariance: Some(covariance),
1576 classes: classes.to_vec(),
1577 max_components: 0,
1578 n_features,
1579 })
1580 }
1581}
1582
1583// ---------------------------------------------------------------------------
1584// Fit (sklearn _solve_eigen) — the eigen solver
1585// ---------------------------------------------------------------------------
1586
1587impl<F: LinalgFloat + ScalarOperand> LDA<F> {
1588 /// The generalized-eigenvalue solver (sklearn's `_solve_eigen`,
1589 /// `discriminant_analysis.py:421-485`), dispatched from [`Fit::fit`] when
1590 /// [`Solver::Eigen`] is selected.
1591 ///
1592 /// Computes (`:466-485`):
1593 /// - `Sw = Σ_k priors_[k] · cov(X_k)` — the within-class scatter (the
1594 /// shrinkage-aware `_class_cov`, `:467-471`), stored as `covariance_`.
1595 /// - `St = cov(WHOLE X)` — the total scatter of all of `X` (the SAME `_cov`
1596 /// with the configured shrinkage applied to the full centered `X`, `:472`).
1597 /// - `Sb = St - Sw` — the between-class scatter (`:473`).
1598 /// - the GENERALIZED symmetric-definite eigenproblem `eigh(Sb, Sw)` (`:475`),
1599 /// sorted by DESCENDING eigenvalue (`:479`,
1600 /// `evecs = evecs[:, argsort(evals)[::-1]]`).
1601 /// - `explained_variance_ratio_ = sort(evals / Σ evals)[::-1][:max_components]`
1602 /// (`:476-478`).
1603 /// - `scalings_ = evecs` (`:481`), `coef_ = (means_ @ evecs) @ evecs.T`
1604 /// (`:482`), `intercept_ = -½·diag(means_ @ coef_.T) + log(priors_)`
1605 /// (`:483-485`).
1606 ///
1607 /// # The Cholesky reduction
1608 ///
1609 /// ferray exposes the STANDARD symmetric eigensolver
1610 /// ([`ferray::linalg::eigh`]) and [`ferray::linalg::cholesky`], not a
1611 /// generalized solver, so the generalized problem `Sb·v = λ·Sw·v` (with `Sw`
1612 /// SPD) is reduced to standard form: let `Sw = L·Lᵀ` (Cholesky); then
1613 /// `M = L⁻¹·Sb·L⁻ᵀ` is symmetric and `eigh(M)` gives the same eigenvalues
1614 /// `λ`, with generalized eigenvectors `v = L⁻ᵀ·w` (`w` the standard
1615 /// eigenvectors of `M`). `M` is symmetrized (`M = (M + Mᵀ)/2`) to kill
1616 /// rounding asymmetry before [`eigh`]. Because `coef_ = (means_@evecs)@evecsᵀ`
1617 /// is invariant to the per-column SIGN and ORDER of `evecs`, `coef_`/
1618 /// `intercept_` (hence `predict`/`predict_proba`/`decision_function`) match
1619 /// sklearn exactly regardless of the eigenvector sign/order ambiguity. The
1620 /// explained-variance ratio is sorted by eigenvalue, so it is order-stable.
1621 ///
1622 /// `scalings_` is `(n_features, n_features)` (sklearn keeps ALL columns for
1623 /// the eigen solver, `:481`); `transform` slices to `[:, :max_components]`.
1624 /// Eigen has NO `xbar_` (sklearn `_solve_eigen` does not set it, `:466-485`),
1625 /// so `transform` is the un-centered `X @ scalings_` (`:687`).
1626 ///
1627 /// # Errors
1628 ///
1629 /// - [`FerroError::InvalidParameter`] if [`Shrinkage::Fixed`]`(s)` has
1630 /// `s ∉ [0, 1]` (sklearn `Interval(Real, 0, 1, closed="both")`, `:339`).
1631 /// - [`FerroError::NumericalInstability`] if the Cholesky factorization,
1632 /// inversion, or eigendecomposition fails.
1633 #[allow(
1634 clippy::too_many_arguments,
1635 reason = "the eigen solver consumes the same pre-resolved fit state (X/classes/indices/means/priors/dims) the svd path computes; threading them avoids recomputation"
1636 )]
1637 #[allow(
1638 clippy::needless_range_loop,
1639 reason = "explicit index loops mirror sklearn's matrix arithmetic per-row/per-column"
1640 )]
1641 fn solve_eigen(
1642 &self,
1643 x: &Array2<F>,
1644 classes: &[usize],
1645 class_indices: &[Vec<usize>],
1646 means: &Array2<F>,
1647 priors: &Array1<F>,
1648 max_components: usize,
1649 n_features: usize,
1650 ) -> Result<FittedLDA<F>, FerroError> {
1651 let n_classes = classes.len();
1652
1653 // Validate Fixed(s): sklearn Interval(Real, 0, 1, closed="both") (:339).
1654 if let Shrinkage::Fixed(s) = self.shrinkage
1655 && (s < <F as num_traits::Zero>::zero() || s > <F as num_traits::One>::one())
1656 {
1657 return Err(FerroError::InvalidParameter {
1658 name: "shrinkage".into(),
1659 reason: "shrinkage float must be in [0, 1]".into(),
1660 });
1661 }
1662
1663 // Sw = Σ_k priors_[k] · cov(X_k) — within-class scatter (covariance_,
1664 // sklearn :467-471, `_class_cov` :128-172, `_cov` :36-93 shrinkage-aware).
1665 let mut sw = Array2::<F>::zeros((n_features, n_features));
1666 for (idx, indices) in class_indices.iter().enumerate() {
1667 let nk = indices.len();
1668 let mut xg = Array2::<F>::zeros((nk, n_features));
1669 for (r, &i) in indices.iter().enumerate() {
1670 for j in 0..n_features {
1671 xg[[r, j]] = x[[i, j]];
1672 }
1673 }
1674 let cov_k = cov_shrunk(&xg, self.shrinkage)?;
1675 let prior_k = priors[idx];
1676 for a in 0..n_features {
1677 for b in 0..n_features {
1678 sw[[a, b]] += prior_k * cov_k[[a, b]];
1679 }
1680 }
1681 }
1682
1683 // St = _cov(WHOLE X, shrinkage) — total scatter of all of X (sklearn
1684 // :472). Same `_cov` (shrinkage applied to the FULL centered X).
1685 let st = cov_shrunk(&x.to_owned(), self.shrinkage)?;
1686
1687 // Sb = St - Sw — between-class scatter (sklearn :473).
1688 let mut sb = Array2::<F>::zeros((n_features, n_features));
1689 for a in 0..n_features {
1690 for b in 0..n_features {
1691 sb[[a, b]] = st[[a, b]] - sw[[a, b]];
1692 }
1693 }
1694
1695 // --- generalized eigh(Sb, Sw) via Cholesky reduction (sklearn :475) ---
1696 // Sw = L·Lᵀ; M = L⁻¹·Sb·L⁻ᵀ (symmetric); (evals, W) = eigh(M);
1697 // generalized eigenvectors evecs = L⁻ᵀ·W.
1698 let l = cholesky_lower(&sw)?; // lower-triangular, Sw = L·Lᵀ
1699 let l_inv = matrix_inverse(&l)?; // L⁻¹ (n_features, n_features)
1700 // M = L⁻¹ · Sb · L⁻ᵀ
1701 let l_inv_t = l_inv.t().to_owned();
1702 let m = l_inv.dot(&sb).dot(&l_inv_t);
1703 // Symmetrize M = (M + Mᵀ)/2 to kill rounding asymmetry before eigh.
1704 let mut m_sym = Array2::<F>::zeros((n_features, n_features));
1705 let half_f = half::<F>();
1706 for a in 0..n_features {
1707 for b in 0..n_features {
1708 m_sym[[a, b]] = (m[[a, b]] + m[[b, a]]) * half_f;
1709 }
1710 }
1711 // eigh(M) — ascending eigenvalues, eigenvectors as columns.
1712 let (evals_asc, w) = eigh_sym(&m_sym)?;
1713 // Generalized eigenvectors: evecs = L⁻ᵀ · W (n_features, n_features).
1714 let evecs_asc = l_inv_t.dot(&w);
1715
1716 // --- sort by DESCENDING eigenvalue (sklearn :479) ---------------------
1717 // argsort(evals)[::-1]: indices ordered by descending eigenvalue.
1718 let n = evals_asc.len();
1719 let mut order: Vec<usize> = (0..n).collect();
1720 // evals_asc is ascending; reverse it for descending order.
1721 order.reverse();
1722 let mut evals_desc = Array1::<F>::zeros(n);
1723 let mut evecs = Array2::<F>::zeros((n_features, n));
1724 for (new_c, &old_c) in order.iter().enumerate() {
1725 evals_desc[new_c] = evals_asc[old_c];
1726 for j in 0..n_features {
1727 evecs[[j, new_c]] = evecs_asc[[j, old_c]];
1728 }
1729 }
1730
1731 // --- explained_variance_ratio_ (sklearn :476-478) --------------------
1732 // sort(evals / sum(evals))[::-1][:max_components]. The ratio is over ALL
1733 // eigenvalues, sorted descending, then truncated to max_components.
1734 let mut sum_evals = <F as num_traits::Zero>::zero();
1735 for &v in evals_asc.iter() {
1736 sum_evals += v;
1737 }
1738 let evr_len = max_components.min(n);
1739 let mut explained_variance_ratio = Array1::<F>::zeros(evr_len);
1740 for k in 0..evr_len {
1741 // evals_desc is already the descending sort of evals; dividing by the
1742 // (sign-stable) sum preserves the sort order sklearn applies.
1743 explained_variance_ratio[k] = if sum_evals != <F as num_traits::Zero>::zero() {
1744 evals_desc[k] / sum_evals
1745 } else {
1746 <F as num_traits::Zero>::zero()
1747 };
1748 }
1749
1750 // --- scalings_ / coef_ / intercept_ (sklearn :481-485) ---------------
1751 // scalings_ = evecs (ALL columns; sklearn keeps the full (n_features,
1752 // n_features) for the eigen solver, :481).
1753 let scalings = evecs.clone();
1754 // coef_ = (means_ @ evecs) @ evecs.T (n_classes, n_features).
1755 // This is invariant to the per-column sign/order of evecs, so it matches
1756 // sklearn exactly despite the eigenvector sign/order ambiguity.
1757 let coef = means.dot(&evecs).dot(&evecs.t());
1758 // intercept_ = -0.5 * diag(means_ @ coef_.T) + log(priors_) (:483-485).
1759 // diag(means_ @ coef_.T)[k] = Σ_j means_[k,j] · coef_[k,j].
1760 let neg_half = -half::<F>();
1761 let mut intercept = Array1::<F>::zeros(n_classes);
1762 for k in 0..n_classes {
1763 let mut dot = <F as num_traits::Zero>::zero();
1764 for j in 0..n_features {
1765 dot += means[[k, j]] * coef[[k, j]];
1766 }
1767 intercept[k] = neg_half * dot + priors[k].ln();
1768 }
1769
1770 Ok(FittedLDA {
1771 scalings,
1772 means: means.to_owned(),
1773 // Eigen has NO xbar_ (sklearn `_solve_eigen` never sets it,
1774 // :466-485); `transform` is the un-centered `X @ scalings_` (:687).
1775 xbar: Array1::<F>::zeros(n_features),
1776 priors: priors.to_owned(),
1777 coef,
1778 intercept,
1779 explained_variance_ratio,
1780 // covariance_ = Sw, ALWAYS populated for eigen (sklearn :467-469,
1781 // the attribute is set regardless of store_covariance).
1782 covariance: Some(sw),
1783 classes: classes.to_vec(),
1784 max_components,
1785 n_features,
1786 })
1787 }
1788}
1789
1790// ---------------------------------------------------------------------------
1791// Transform (sklearn svd transform)
1792// ---------------------------------------------------------------------------
1793
1794impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedLDA<F> {
1795 type Output = Array2<F>;
1796 type Error = FerroError;
1797
1798 /// Project `x` onto the discriminant axes: `((X - xbar_) @ scalings_)[:, :n]`
1799 /// where `n = max_components`. Mirrors sklearn's svd-solver `transform`
1800 /// (`discriminant_analysis.py:684-685,689`).
1801 ///
1802 /// # Errors
1803 ///
1804 /// Returns [`FerroError::ShapeMismatch`] if `x.ncols()` does not match the
1805 /// number of features seen during fitting.
1806 fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1807 if x.ncols() != self.n_features {
1808 return Err(FerroError::ShapeMismatch {
1809 expected: vec![x.nrows(), self.n_features],
1810 actual: vec![x.nrows(), x.ncols()],
1811 context: "FittedLDA::transform".into(),
1812 });
1813 }
1814 // (X - xbar_) @ scalings_
1815 let mut xc = x.to_owned();
1816 for mut row in xc.rows_mut() {
1817 for j in 0..self.n_features {
1818 row[j] = row[j] - self.xbar[j];
1819 }
1820 }
1821 let projected = xc.dot(&self.scalings);
1822 // Slice to [:, :max_components] (sklearn :689).
1823 let keep = self.max_components.min(projected.ncols());
1824 Ok(projected.slice(ndarray::s![.., ..keep]).to_owned())
1825 }
1826}
1827
1828// ---------------------------------------------------------------------------
1829// Predict (argmax of the affine decision_function)
1830// ---------------------------------------------------------------------------
1831
1832impl<F: Float + Send + Sync + 'static> Predict<Array2<F>> for FittedLDA<F> {
1833 type Output = Array1<usize>;
1834 type Error = FerroError;
1835
1836 /// Classify samples by argmax of the affine `decision_function`
1837 /// (`classes_[argmax(X @ coef_.T + intercept_)]`), mirroring sklearn's
1838 /// `predict` (the `LinearClassifierMixin`, `discriminant_analysis.py:739`).
1839 /// The argmax follows numpy's first-max-wins tie-breaking.
1840 ///
1841 /// # Errors
1842 ///
1843 /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
1844 /// match the model.
1845 fn predict(&self, x: &Array2<F>) -> Result<Array1<usize>, FerroError> {
1846 let decision = self.decision_function(x)?;
1847 let n_samples = decision.nrows();
1848 let n_classes = decision.ncols();
1849 let mut predictions = Array1::<usize>::zeros(n_samples);
1850 for i in 0..n_samples {
1851 let mut best_idx = 0usize;
1852 let mut best = decision[[i, 0]];
1853 for c in 1..n_classes {
1854 let v = decision[[i, c]];
1855 // numpy argmax: strictly-greater wins; ties keep first index.
1856 if v > best {
1857 best = v;
1858 best_idx = c;
1859 }
1860 }
1861 predictions[i] = self.classes[best_idx];
1862 }
1863 Ok(predictions)
1864 }
1865}
1866
1867// ---------------------------------------------------------------------------
1868// Introspection
1869// ---------------------------------------------------------------------------
1870
1871impl<F: Float + Send + Sync + 'static> HasClasses for FittedLDA<F> {
1872 fn classes(&self) -> &[usize] {
1873 &self.classes
1874 }
1875
1876 fn n_classes(&self) -> usize {
1877 self.classes.len()
1878 }
1879}
1880
1881// ---------------------------------------------------------------------------
1882// Pipeline integration (generic)
1883// ---------------------------------------------------------------------------
1884
1885impl<F: LinalgFloat + ScalarOperand> PipelineEstimator<F> for LDA<F> {
1886 /// Fit LDA using the pipeline interface.
1887 ///
1888 /// # Errors
1889 ///
1890 /// Propagates errors from [`Fit::fit`].
1891 fn fit_pipeline(
1892 &self,
1893 x: &Array2<F>,
1894 y: &Array1<F>,
1895 ) -> Result<Box<dyn FittedPipelineEstimator<F>>, FerroError> {
1896 let y_usize: Array1<usize> = y.mapv(|v| v.to_usize().unwrap_or(0));
1897 let fitted = self.fit(x, &y_usize)?;
1898 Ok(Box::new(FittedLDAPipeline(fitted)))
1899 }
1900}
1901
1902/// Wrapper for pipeline integration that converts predictions to float.
1903struct FittedLDAPipeline<F>(FittedLDA<F>);
1904
1905impl<F: Float + Send + Sync + 'static> FittedPipelineEstimator<F> for FittedLDAPipeline<F> {
1906 /// Predict via the pipeline interface, returning float class labels.
1907 fn predict_pipeline(&self, x: &Array2<F>) -> Result<Array1<F>, FerroError> {
1908 let preds = self.0.predict(x)?;
1909 Ok(preds.mapv(|v| NumCast::from(v).unwrap_or_else(F::nan)))
1910 }
1911}
1912
1913// ---------------------------------------------------------------------------
1914// Tests
1915// ---------------------------------------------------------------------------
1916
1917#[cfg(test)]
1918mod tests {
1919 use super::*;
1920 use approx::assert_abs_diff_eq;
1921 use ndarray::{Array2, array};
1922
1923 // ------------------------------------------------------------------
1924 // Helpers
1925 // ------------------------------------------------------------------
1926
1927 fn linearly_separable_2d() -> (Array2<f64>, Array1<usize>) {
1928 // Two well-separated Gaussian clusters.
1929 let x = Array2::from_shape_vec(
1930 (8, 2),
1931 vec![
1932 1.0, 1.0, 1.5, 1.2, 0.8, 0.9, 1.1, 1.3, // class 0
1933 6.0, 6.0, 6.2, 5.8, 5.9, 6.1, 6.3, 5.7, // class 1
1934 ],
1935 )
1936 .unwrap();
1937 let y = array![0, 0, 0, 0, 1, 1, 1, 1];
1938 (x, y)
1939 }
1940
1941 fn three_class_data() -> (Array2<f64>, Array1<usize>) {
1942 let x = Array2::from_shape_vec(
1943 (9, 2),
1944 vec![
1945 0.0, 0.0, 0.5, 0.1, 0.1, 0.5, // class 0
1946 5.0, 0.0, 5.2, 0.3, 4.8, 0.1, // class 1
1947 0.0, 5.0, 0.1, 5.2, 0.3, 4.8, // class 2
1948 ],
1949 )
1950 .unwrap();
1951 let y = array![0, 0, 0, 1, 1, 1, 2, 2, 2];
1952 (x, y)
1953 }
1954
1955 // ------------------------------------------------------------------
1956
1957 #[test]
1958 fn test_lda_fit_returns_fitted() {
1959 let (x, y) = linearly_separable_2d();
1960 let lda = LDA::<f64>::new(Some(1));
1961 let fitted = lda.fit(&x, &y).unwrap();
1962 // scalings_ is (n_features, rank2); for a binary 2-feature set rank2 = 1.
1963 assert_eq!(fitted.scalings().ncols(), 1);
1964 assert_eq!(fitted.scalings().nrows(), 2);
1965 }
1966
1967 #[test]
1968 fn test_lda_default_n_components() {
1969 // With 2 classes the default n_components = min(1, n_features) = 1.
1970 let (x, y) = linearly_separable_2d();
1971 let lda = LDA::<f64>::default();
1972 let fitted = lda.fit(&x, &y).unwrap();
1973 // transform output is truncated to max_components = 1.
1974 assert_eq!(fitted.transform(&x).unwrap().ncols(), 1);
1975 }
1976
1977 #[test]
1978 fn test_lda_transform_shape() {
1979 let (x, y) = linearly_separable_2d();
1980 let lda = LDA::<f64>::new(Some(1));
1981 let fitted = lda.fit(&x, &y).unwrap();
1982 let proj = fitted.transform(&x).unwrap();
1983 assert_eq!(proj.dim(), (8, 1));
1984 }
1985
1986 #[test]
1987 fn test_lda_predict_accuracy_binary() {
1988 let (x, y) = linearly_separable_2d();
1989 let lda = LDA::<f64>::new(Some(1));
1990 let fitted = lda.fit(&x, &y).unwrap();
1991 let preds = fitted.predict(&x).unwrap();
1992 let correct = preds.iter().zip(y.iter()).filter(|(p, a)| *p == *a).count();
1993 assert_eq!(correct, 8, "All 8 samples should be classified correctly");
1994 }
1995
1996 #[test]
1997 fn test_lda_predict_three_classes() {
1998 let (x, y) = three_class_data();
1999 let lda = LDA::<f64>::new(Some(2));
2000 let fitted = lda.fit(&x, &y).unwrap();
2001 let preds = fitted.predict(&x).unwrap();
2002 let correct = preds.iter().zip(y.iter()).filter(|(p, a)| *p == *a).count();
2003 assert!(correct >= 7, "Expected at least 7/9 correct, got {correct}");
2004 }
2005
2006 #[test]
2007 fn test_lda_explained_variance_ratio_positive() {
2008 let (x, y) = linearly_separable_2d();
2009 let lda = LDA::<f64>::new(Some(1));
2010 let fitted = lda.fit(&x, &y).unwrap();
2011 for &v in fitted.explained_variance_ratio() {
2012 assert!(v >= 0.0);
2013 }
2014 }
2015
2016 #[test]
2017 fn test_lda_explained_variance_ratio_le_1() {
2018 let (x, y) = three_class_data();
2019 let lda = LDA::<f64>::new(Some(2));
2020 let fitted = lda.fit(&x, &y).unwrap();
2021 let total: f64 = fitted.explained_variance_ratio().iter().sum();
2022 assert!(total <= 1.0 + 1e-9, "total={total}");
2023 }
2024
2025 /// R-CHAR-3 oracle pin for `explained_variance_ratio_` (REQ-13). Expected
2026 /// values are the live sklearn 1.5.2
2027 /// `LinearDiscriminantAnalysis().fit(X,y).explained_variance_ratio_` on the
2028 /// 3-class / 2-feature balanced set (same data as `divergence_lda_fit.rs`):
2029 /// ```text
2030 /// python3 -c "import numpy as np; \
2031 /// from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as L; \
2032 /// X=np.array([[0.,0.],[1.,.5],[.5,1.],[1.,1.],[4.,4.],[5.,4.5],[4.5,5.],[5.,5.],\
2033 /// [0.,5.],[1.,6.],[.5,5.5],[1.,5.]]); \
2034 /// y=np.array([0,0,0,0,1,1,1,1,2,2,2,2]); \
2035 /// print(repr(L().fit(X,y).explained_variance_ratio_.tolist()))"
2036 /// # [0.6428683117561941, 0.3571316882438059]
2037 /// ```
2038 #[test]
2039 fn test_lda_explained_variance_ratio_oracle() {
2040 const SK_EVR: [f64; 2] = [0.6428683117561941, 0.3571316882438059];
2041 let x = Array2::from_shape_vec(
2042 (12, 2),
2043 vec![
2044 0.0, 0.0, 1.0, 0.5, 0.5, 1.0, 1.0, 1.0, 4.0, 4.0, 5.0, 4.5, 4.5, 5.0, 5.0, 5.0,
2045 0.0, 5.0, 1.0, 6.0, 0.5, 5.5, 1.0, 5.0,
2046 ],
2047 )
2048 .unwrap();
2049 let y = array![0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2];
2050 let fitted = LDA::<f64>::new(Some(2)).fit(&x, &y).unwrap();
2051 let evr = fitted.explained_variance_ratio();
2052 assert_eq!(evr.len(), 2);
2053 for k in 0..2 {
2054 assert_abs_diff_eq!(evr[k], SK_EVR[k], epsilon = 1e-9);
2055 }
2056 }
2057
2058 /// R-CHAR-3 oracle pin for `coef_`/`intercept_`/`xbar_` (REQ-8). Live
2059 /// sklearn 1.5.2 attributes on the same 3-class / 2-feature set:
2060 /// ```text
2061 /// python3 -c "... ; m=L().fit(X,y); \
2062 /// print(repr(m.coef_.tolist())); print(repr(m.intercept_.tolist())); \
2063 /// print(repr(m.xbar_.tolist()))"
2064 /// # coef_ [[2.2582417582417564, -14.02747252747253],
2065 /// # [13.335164835164827, -2.950549450549442],
2066 /// # [-15.593406593406584, 16.978021978021978]]
2067 /// # intercept_ [25.208393205837393, -32.94545294800878, -56.65081009086592]
2068 /// # xbar_ [1.958333333333333, 3.541666666666666]
2069 /// ```
2070 #[test]
2071 fn test_lda_coef_intercept_xbar_oracle() {
2072 const SK_COEF: [[f64; 2]; 3] = [
2073 [2.2582417582417564, -14.02747252747253],
2074 [13.335164835164827, -2.950549450549442],
2075 [-15.593406593406584, 16.978021978021978],
2076 ];
2077 const SK_INTERCEPT: [f64; 3] = [25.208393205837393, -32.94545294800878, -56.65081009086592];
2078 const SK_XBAR: [f64; 2] = [1.958333333333333, 3.541666666666666];
2079 let x = Array2::from_shape_vec(
2080 (12, 2),
2081 vec![
2082 0.0, 0.0, 1.0, 0.5, 0.5, 1.0, 1.0, 1.0, 4.0, 4.0, 5.0, 4.5, 4.5, 5.0, 5.0, 5.0,
2083 0.0, 5.0, 1.0, 6.0, 0.5, 5.5, 1.0, 5.0,
2084 ],
2085 )
2086 .unwrap();
2087 let y = array![0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2];
2088 let fitted = LDA::<f64>::new(Some(2)).fit(&x, &y).unwrap();
2089 for i in 0..3 {
2090 for (j, &expected) in SK_COEF[i].iter().enumerate() {
2091 assert_abs_diff_eq!(fitted.coef()[[i, j]], expected, epsilon = 1e-9);
2092 }
2093 assert_abs_diff_eq!(fitted.intercept()[i], SK_INTERCEPT[i], epsilon = 1e-9);
2094 }
2095 for (j, &expected) in SK_XBAR.iter().enumerate() {
2096 assert_abs_diff_eq!(fitted.xbar()[j], expected, epsilon = 1e-12);
2097 }
2098 }
2099
2100 #[test]
2101 fn test_lda_classes_accessor() {
2102 let (x, y) = linearly_separable_2d();
2103 let lda = LDA::<f64>::new(Some(1));
2104 let fitted = lda.fit(&x, &y).unwrap();
2105 assert_eq!(fitted.classes(), &[0usize, 1]);
2106 }
2107
2108 #[test]
2109 fn test_lda_means_shape() {
2110 // means_ is now in the ORIGINAL feature space (n_classes, n_features).
2111 let (x, y) = three_class_data();
2112 let lda = LDA::<f64>::new(Some(2));
2113 let fitted = lda.fit(&x, &y).unwrap();
2114 assert_eq!(fitted.means().dim(), (3, 2));
2115 }
2116
2117 #[test]
2118 fn test_lda_transform_shape_mismatch() {
2119 let (x, y) = linearly_separable_2d();
2120 let lda = LDA::<f64>::new(Some(1));
2121 let fitted = lda.fit(&x, &y).unwrap();
2122 let x_bad = Array2::<f64>::zeros((3, 5));
2123 assert!(fitted.transform(&x_bad).is_err());
2124 }
2125
2126 #[test]
2127 fn test_lda_predict_shape_mismatch() {
2128 let (x, y) = linearly_separable_2d();
2129 let lda = LDA::<f64>::new(Some(1));
2130 let fitted = lda.fit(&x, &y).unwrap();
2131 let x_bad = Array2::<f64>::zeros((3, 5));
2132 assert!(fitted.predict(&x_bad).is_err());
2133 }
2134
2135 #[test]
2136 fn test_lda_error_zero_n_components() {
2137 let (x, y) = linearly_separable_2d();
2138 let lda = LDA::<f64>::new(Some(0));
2139 assert!(lda.fit(&x, &y).is_err());
2140 }
2141
2142 #[test]
2143 fn test_lda_error_n_components_too_large() {
2144 let (x, y) = linearly_separable_2d(); // 2 classes → max 1 component
2145 let lda = LDA::<f64>::new(Some(5));
2146 assert!(lda.fit(&x, &y).is_err());
2147 }
2148
2149 #[test]
2150 fn test_lda_error_single_class() {
2151 let x =
2152 Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]).unwrap();
2153 let y = array![0usize, 0, 0, 0];
2154 let lda = LDA::<f64>::new(None);
2155 assert!(lda.fit(&x, &y).is_err());
2156 }
2157
2158 #[test]
2159 fn test_lda_error_shape_mismatch_fit() {
2160 let x = Array2::<f64>::zeros((4, 2));
2161 let y = array![0usize, 1]; // wrong length
2162 let lda = LDA::<f64>::new(None);
2163 assert!(lda.fit(&x, &y).is_err());
2164 }
2165
2166 #[test]
2167 fn test_lda_error_insufficient_samples() {
2168 let x = Array2::<f64>::zeros((1, 2));
2169 let y = array![0usize];
2170 let lda = LDA::<f64>::new(None);
2171 assert!(lda.fit(&x, &y).is_err());
2172 }
2173
2174 #[test]
2175 fn test_lda_scalings_accessor() {
2176 let (x, y) = linearly_separable_2d();
2177 let lda = LDA::<f64>::new(Some(1));
2178 let fitted = lda.fit(&x, &y).unwrap();
2179 assert_eq!(fitted.scalings().nrows(), 2);
2180 }
2181
2182 #[test]
2183 fn test_lda_pipeline_estimator() {
2184 use ferrolearn_core::pipeline::PipelineEstimator;
2185
2186 let (x, y_usize) = linearly_separable_2d();
2187 let y_f64 = y_usize.mapv(|v| v as f64);
2188 let lda = LDA::<f64>::new(Some(1));
2189 let fitted = lda.fit_pipeline(&x, &y_f64).unwrap();
2190 let preds = fitted.predict_pipeline(&x).unwrap();
2191 assert_eq!(preds.len(), 8);
2192 }
2193
2194 #[test]
2195 fn test_lda_n_components_getter() {
2196 let lda = LDA::<f64>::new(Some(2));
2197 assert_eq!(lda.n_components(), Some(2));
2198 let lda_none = LDA::<f64>::new(None);
2199 assert_eq!(lda_none.n_components(), None);
2200 }
2201
2202 #[test]
2203 fn test_lda_priors_builder_default_none() {
2204 // Default (sklearn `priors=None`, discriminant_analysis.py:359).
2205 let lda = LDA::<f64>::new(None);
2206 assert!(lda.priors().is_none());
2207 // with_priors stores the vector verbatim.
2208 let lda = lda.with_priors(array![0.7, 0.3]);
2209 let p = lda.priors().cloned().unwrap_or_default();
2210 assert_eq!(p.len(), 2);
2211 assert_abs_diff_eq!(p[0], 0.7, epsilon = 1e-12);
2212 assert_abs_diff_eq!(p[1], 0.3, epsilon = 1e-12);
2213 }
2214
2215 /// Re-oracled (was `test_lda_transform_then_predict_consistent`, which
2216 /// asserted the OLD nearest-centroid algorithm: `predict ==
2217 /// argmin ‖transform(x) - projected_mean‖`). The SVD solver's `predict` is
2218 /// the argmax of the affine `decision_function = X @ coef_.T + intercept_`
2219 /// (`discriminant_analysis.py:739`), NOT nearest-centroid in projected
2220 /// space, so this now checks the new contract.
2221 #[test]
2222 fn test_lda_predict_matches_decision_argmax() {
2223 let (x, y) = linearly_separable_2d();
2224 let lda = LDA::<f64>::new(Some(1));
2225 let fitted = lda.fit(&x, &y).unwrap();
2226 let dec = fitted.decision_function(&x).unwrap();
2227 let preds = fitted.predict(&x).unwrap();
2228 let n_samples = dec.nrows();
2229 let n_classes = dec.ncols();
2230 for i in 0..n_samples {
2231 let mut best = 0usize;
2232 let mut best_v = dec[[i, 0]];
2233 for c in 1..n_classes {
2234 if dec[[i, c]] > best_v {
2235 best_v = dec[[i, c]];
2236 best = c;
2237 }
2238 }
2239 assert_eq!(preds[i], fitted.classes()[best]);
2240 }
2241 }
2242
2243 #[test]
2244 fn test_lda_projected_class_separation() {
2245 let (x, y) = linearly_separable_2d();
2246 let lda = LDA::<f64>::new(Some(1));
2247 let fitted = lda.fit(&x, &y).unwrap();
2248 let projected = fitted.transform(&x).unwrap();
2249
2250 // Means of class 0 and class 1 in projected space should be far apart.
2251 let mean0: f64 = projected
2252 .rows()
2253 .into_iter()
2254 .zip(y.iter())
2255 .filter(|&(_, label)| *label == 0)
2256 .map(|(row, _)| row[0])
2257 .sum::<f64>()
2258 / 4.0;
2259 let mean1: f64 = projected
2260 .rows()
2261 .into_iter()
2262 .zip(y.iter())
2263 .filter(|&(_, label)| *label == 1)
2264 .map(|(row, _)| row[0])
2265 .sum::<f64>()
2266 / 4.0;
2267
2268 assert!(
2269 (mean0 - mean1).abs() > 0.5,
2270 "Projected means should differ, got {mean0} vs {mean1}"
2271 );
2272 }
2273
2274 #[test]
2275 fn test_lda_transform_known_data() {
2276 // With perfectly separated 1-D data the centered/whitened transform
2277 // should still place the two classes on opposite sides.
2278 let x = Array2::from_shape_vec((4, 1), vec![-2.0, -1.0, 1.0, 2.0]).unwrap();
2279 let y = array![0usize, 0, 1, 1];
2280 let lda = LDA::<f64>::new(Some(1));
2281 let fitted = lda.fit(&x, &y).unwrap();
2282 let proj = fitted.transform(&x).unwrap();
2283 let sign0 = proj[[0, 0]].signum();
2284 let sign1 = proj[[2, 0]].signum();
2285 assert_ne!(
2286 sign0 as i32, sign1 as i32,
2287 "Classes should be on opposite sides"
2288 );
2289 }
2290
2291 #[test]
2292 fn test_lda_predict_proba_rows_sum_to_one() {
2293 let (x, y) = three_class_data();
2294 let lda = LDA::<f64>::new(Some(2));
2295 let fitted = lda.fit(&x, &y).unwrap();
2296 let proba = fitted.predict_proba(&x).unwrap();
2297 assert_eq!(proba.dim(), (9, 3));
2298 for row in proba.rows() {
2299 let s: f64 = row.iter().sum();
2300 assert_abs_diff_eq!(s, 1.0, epsilon = 1e-12);
2301 }
2302 }
2303}