Skip to main content

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}