From c037d145980d94cef5babd6fb71425dbddb5ac0c Mon Sep 17 00:00:00 2001
From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com>
Date: Tue, 25 Aug 2026 07:05:01 -0400
Subject: [PATCH] Add LDA for supervised dimensionality reduction (#136)
---
CHANGELOG.md | 4 +
src/decomposition/lda.rs | 518 +++++++++++++++++++++++++++++++++++++++
src/decomposition/mod.rs | 2 +
3 files changed, 524 insertions(+)
create mode 100644 src/decomposition/lda.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3990e24f..3b5a8690 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.6.13]
+### Added
+- `decomposition/lda.rs`: `LDA`, linear discriminant analysis for supervised dimensionality reduction (#136). It projects the data onto the directions that best separate the classes, keeping `min(n_classes - 1, n_features)` components by default, and implements the `Transformer` interface next to `PCA`. Directions match scikit-learn's `LinearDiscriminantAnalysis(solver="eigen")` up to sign.
+
## [0.6.12]
### Fixed
- `xgboost/xgb_regressor.rs`: `XGRegressor::fit` no longer panics when `subsample` is less than 1.0 on a small dataset (#444). The sample for each tree now keeps a minimum of one row, as scikit-learn does for its own `subsample` parameter. Sample sizes of one row or more are unchanged.
diff --git a/src/decomposition/lda.rs b/src/decomposition/lda.rs
new file mode 100644
index 00000000..4c07f1b1
--- /dev/null
+++ b/src/decomposition/lda.rs
@@ -0,0 +1,518 @@
+//! # Linear Discriminant Analysis
+//!
+//! Linear discriminant analysis (LDA) finds the linear combinations of the features that best separate two or more classes.
+//! Unlike [PCA](../pca/index.html), which is unsupervised and keeps the directions of largest variance, LDA is supervised: it uses the class
+//! labels \\(y\\) to look for the directions that maximise the ratio of between-class scatter to within-class scatter (the Rayleigh quotient).
+//! The result is a projection of the data onto at most \\(C - 1\\) axes, where \\(C\\) is the number of classes, that pulls the classes apart.
+//!
+//! LDA is often used to reduce the number of features before a classification step and for data visualization.
+//! Each discriminant direction \\(w\\) is a generalized eigenvector of the pair \\((S_B, S_W)\\), the between-class and within-class scatter matrices:
+//!
+//! \\[S_B w = \lambda S_W w\\]
+//!
+//! The directions are kept in descending order of \\(\lambda\\), so the first component separates the classes the most.
+//!
+//! LDA assumes the classes share the same covariance. It needs the within-class scatter matrix to be invertible, so keep the number of
+//! samples per class larger than the number of features.
+//!
+//! Example:
+//! ```
+//! use smartcore::linalg::basic::matrix::DenseMatrix;
+//! use smartcore::decomposition::lda::*;
+//!
+//! // Three well separated classes, three samples each
+//! let x = DenseMatrix::from_2d_array(&[
+//! &[4.0, 2.0, 0.6],
+//! &[4.2, 2.1, 0.5],
+//! &[3.9, 1.9, 0.7],
+//! &[6.0, 3.0, 4.5],
+//! &[6.2, 2.9, 4.6],
+//! &[5.8, 3.1, 4.4],
+//! &[7.5, 3.6, 6.1],
+//! &[7.7, 3.5, 6.0],
+//! &[7.3, 3.7, 6.2],
+//! ]).unwrap();
+//! let y = vec![0, 0, 0, 1, 1, 1, 2, 2, 2];
+//!
+//! let lda = LDA::fit(&x, &y, LDAParameters::default()).unwrap(); // keep C - 1 = 2 components
+//!
+//! let projected = lda.transform(&x).unwrap();
+//! ```
+//!
+//! ## References:
+//! * ["An Introduction to Statistical Learning", James G., Witten D., Hastie T., Tibshirani R., 4.4 Linear Discriminant Analysis](http://faculty.marshall.usc.edu/gareth-james/ISL/)
+//! * ["Pattern Classification", Duda R.O., Hart P.E., Stork D.G., 2nd ed., 3.8.3 Multiple Discriminant Analysis](https://www.wiley.com/en-us/Pattern+Classification%2C+2nd+Edition-p-9780471056690)
+//!
+//!
+//!
+use std::cmp::Ordering;
+use std::fmt::Debug;
+
+#[cfg(feature = "serde")]
+use serde::{Deserialize, Serialize};
+
+use crate::api::Transformer;
+use crate::error::Failed;
+use crate::linalg::basic::arrays::{Array1, Array2};
+use crate::linalg::traits::evd::EVDDecomposable;
+use crate::numbers::basenum::Number;
+use crate::numbers::realnum::RealNumber;
+
+/// Linear discriminant analysis
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Debug)]
+pub struct LDA + EVDDecomposable> {
+ // Projection matrix, one column per kept discriminant direction (n_features x n_components).
+ scalings: X,
+ // Generalized eigenvalue of every kept direction, largest first.
+ eigenvalues: Vec,
+ // Number of features expected by `transform`.
+ n_features: usize,
+}
+
+impl + EVDDecomposable> PartialEq for LDA {
+ fn eq(&self, other: &Self) -> bool {
+ if self.n_features != other.n_features
+ || self.eigenvalues.len() != other.eigenvalues.len()
+ || self
+ .scalings
+ .iterator(0)
+ .zip(other.scalings.iterator(0))
+ .any(|(&a, &b)| (a - b).abs() > T::epsilon())
+ {
+ return false;
+ }
+ for i in 0..self.eigenvalues.len() {
+ if (self.eigenvalues[i] - other.eigenvalues[i]).abs() > T::epsilon() {
+ return false;
+ }
+ }
+ true
+ }
+}
+
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Debug, Clone, Default)]
+/// LDA parameters
+#[must_use]
+pub struct LDAParameters {
+ #[cfg_attr(feature = "serde", serde(default))]
+ /// Number of discriminant directions to keep. When `None` the largest useful number,
+ /// `min(n_classes - 1, n_features)`, is used.
+ pub n_components: Option,
+}
+
+impl LDAParameters {
+ /// Number of discriminant directions to keep.
+ pub fn with_n_components(mut self, n_components: usize) -> Self {
+ self.n_components = Some(n_components);
+ self
+ }
+}
+
+/// LDA grid search parameters
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
+#[derive(Debug, Clone)]
+#[must_use]
+pub struct LDASearchParameters {
+ #[cfg_attr(feature = "serde", serde(default))]
+ /// Number of discriminant directions to keep.
+ pub n_components: Vec