Add LDA for supervised dimensionality reduction (#136) - #456
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #456 +/- ##
===========================================
+ Coverage 43.97% 63.86% +19.88%
===========================================
Files 85 96 +11
Lines 7281 8341 +1060
===========================================
+ Hits 3202 5327 +2125
+ Misses 4079 3014 -1065 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
thanks. please consider adding tests as suggested by the coverage bot |
|
also: v0.6.13 has already been published. |
|
Thanks for this contribution — the implementation is clean and well-structured. Here are my notes from a full diff review. ✅ What works well
🐛 Bugs / Correctness1. Singularity threshold is too tight if li <= T::epsilon() {
2. CHANGELOG version mismatch The CHANGELOG entry is filed under
|
|
Hi @ChrisJr404 — thanks for this contribution, the core math and API shape are solid. I reviewed the full diff and have the following notes (ordered roughly by severity). 🐛 Bugs / Correctness1. Singularity threshold is too tight// current
if li <= T::epsilon() {
let l_max = l.iter().cloned().fold(T::zero(), |a, b| if b > a { b } else { a });
let tol = T::from(1e-10).unwrap().max(T::from(1e-6).unwrap() * l_max);
for &li in &l {
if li <= tol {
return Err(Failed::fit(
"Within-class scatter matrix is singular …",
));
}
}scikit-learn uses 2. CHANGELOG entry is under the wrong version
|
| Missing branch | Suggested test |
|---|---|
n_components = Some(c) validation (c < 1) |
zero_n_components_is_rejected |
n != y.shape() length mismatch |
mismatched_x_y_is_rejected |
transform wrong-feature-count path |
transform_wrong_features_is_rejected |
evd failure / intermediate whitening failure |
hard to trigger directly; partial coverage OK |
Concrete additions you could drop into the existing tests module:
#[test]
fn mismatched_x_y_is_rejected() {
let (x, _) = three_class_data();
let y_short = vec![0i32; x.shape().0 - 1];
assert!(LDA::fit(&x, &y_short, LDAParameters::default()).is_err());
}
#[test]
fn zero_n_components_is_rejected() {
let (x, y) = three_class_data();
assert!(LDA::fit(&x, &y, LDAParameters::default().with_n_components(0)).is_err());
}
#[test]
fn transform_wrong_features_is_rejected() {
let (x, y) = three_class_data();
let lda = LDA::fit(&x, &y, LDAParameters::default()).unwrap();
let bad = DenseMatrix::<f64>::zeros(3, 2); // wrong n_features
assert!(lda.transform(&bad).is_err());
}🔧 Minor / Style
3. PartialEq uses T::epsilon() for floating-point equality
Same issue as point 1: comparing eigenvectors with T::epsilon() will be brittle in practice. Consider using a small fixed tolerance (e.g. T::from(1e-10).unwrap()) or documenting the limitation explicitly, consistent with how approx::relative_eq! is used in the tests.
4. Private field scalings shadows the public getter scalings()
Rust resolves this without ambiguity, but the dual use of the identifier confuses readers. The PCA struct uses a distinct field name (components) from its public accessors. Consider renaming the field to projection_matrix (or scalings_ at minimum):
pub struct LDA<T, X> {
projection_matrix: X, // was `scalings`
eigenvalues: Vec<T>,
n_features: usize,
}5. Missing blank line between pub mod lda and pub mod pca in mod.rs
The doc comment for the pca module is currently visually attached to the lda declaration. A blank line restores the original formatting:
/// LDA is a supervised approach …
pub mod lda;
/// PCA is a popular approach …
pub mod pca;
pub mod svd;6. polyfill.io script tag in doc comment
//! <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
polyfill.io was acquired in 2024 and its CDN has had documented supply-chain concerns. The PCA module already uses MathJax directly without polyfill.io. Recommend removing this line entirely for consistency and safety.
💡 Non-blocking suggestions
explained_variance_ratio_— exposing each eigenvalue divided by the total (analogous to PCA) would make scree plots trivial for users.LDASearchParametersdocs — the default grid only iterates[None]. A short doc note explaining how to construct a non-trivial hyperparameter grid would help users adopting this with the existing grid-search infra.predict(nearest-centroid) — great call scoping that to a follow-up PR; it would complete the supervised story opened in Add Linear Discriminant Analysis (LDA) support #136.
Overall this is a clean first pass. The whitening algebra is correct, the sklearn reference test is the right approach, and the API fits idiomatically into the existing decomposition module. Addressing the CHANGELOG version, singularity threshold, and missing test coverage are the main items before merge.
* fix(lda): address review follow-ups from #456 - Singularity threshold: T::epsilon() → relative tolerance (1e-4 * l_max), matching sklearn - PartialEq: T::epsilon() → fixed 1e-10 tolerance for float comparison - Rename field scalings → projection_matrix to avoid shadowing the public getter - Add missing test coverage: X/y mismatch, zero n_components, wrong transform features - Remove polyfill.io script tag (supply-chain concerns) - Add blank line between lda and pca module declarations in mod.rs - Bump version to 0.6.14 and move CHANGELOG entry accordingly * fix(lda): raise PartialEq tolerance from 1e-10 to 1e-6 for f32 safety 1e-10 rounds to 0.0 in f32 (min positive normal ~1.2e-7), making (a - b).abs() > 0.0 almost always true for distinct values. The 1e-6 floor works correctly for both f32 and f64. Addresses review feedback on #457.
Fixes #136
Checklist
Current behaviour
decompositiononly offers unsupervised reduction (PCA,SVD). There is no way to reduce the features using the class labels, so the directions that best separate the classes are not available.New expected behaviour
Adds
LDA, linear discriminant analysis for supervised dimensionality reduction, next toPCAin the same module.LDA::fit(x, y, params)learns the discriminant directions andtransformprojects onto them, so it plugs into the existingTransformerinterface.Implementation notes:
Sb w = lambda Sw wby whitening with the within-class scatter matrix.Swis factored once with a symmetric eigen decomposition to buildSw^(-1/2), thenSw^(-1/2) Sb Sw^(-1/2)is symmetric and a second symmetric eigen decomposition gives the directions. That keeps everything on the existing symmetricevdpath, no external numeric crates and no unsafe.min(n_classes - 1, n_features)components, matching the number of useful directions.with_n_componentslets you ask for fewer.Failedinstead of producing garbage.LinearDiscriminantAnalysis(solver="eigen")up to the usual per-axis sign, and the known-answer test pins those reference values (compared on magnitudes, the way the PCA tests do).I scoped this to the dimensionality-reduction side to keep it close to
PCA. Happy to follow up with an LDA classifier (predict) in a separate PR if that is wanted.Change logs
Added
decomposition::lda::LDAwithLDAParameters/LDASearchParameters, aTransformerimplementation, ascalingsgetter, doctest and unit tests.