Skip to content

Commit ea178d2

Browse files
authored
fix(lda): address review follow-ups from #456 (#457)
* 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.
1 parent 5ef24eb commit ea178d2

4 files changed

Lines changed: 42 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ All notable changes to this project will be documented in this file.
44
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7-
## [0.6.13]
7+
## [0.6.14]
88
### Added
99
- `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.
1010

11-
## [0.6.12]
11+
## [0.6.13]
1212
### Fixed
1313
- `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.
1414

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
name = "smartcore"
33
description = "Machine Learning in Rust."
44
homepage = "https://smartcorelib.github.io/"
5-
version = "0.6.13"
5+
version = "0.6.14"
66
authors = ["smartcore Developers"]
77
edition = "2024"
88
rust-version = "1.85"

src/decomposition/lda.rs

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
//! * ["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/)
4444
//! * ["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)
4545
//!
46-
//! <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
4746
//! <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
4847
use std::cmp::Ordering;
4948
use std::fmt::Debug;
@@ -63,7 +62,7 @@ use crate::numbers::realnum::RealNumber;
6362
#[derive(Debug)]
6463
pub struct LDA<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> {
6564
// Projection matrix, one column per kept discriminant direction (n_features x n_components).
66-
scalings: X,
65+
projection_matrix: X,
6766
// Generalized eigenvalue of every kept direction, largest first.
6867
eigenvalues: Vec<T>,
6968
// Number of features expected by `transform`.
@@ -72,18 +71,19 @@ pub struct LDA<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> {
7271

7372
impl<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> PartialEq for LDA<T, X> {
7473
fn eq(&self, other: &Self) -> bool {
74+
let tol = T::from(1e-6).unwrap();
7575
if self.n_features != other.n_features
7676
|| self.eigenvalues.len() != other.eigenvalues.len()
7777
|| self
78-
.scalings
78+
.projection_matrix
7979
.iterator(0)
80-
.zip(other.scalings.iterator(0))
81-
.any(|(&a, &b)| (a - b).abs() > T::epsilon())
80+
.zip(other.projection_matrix.iterator(0))
81+
.any(|(&a, &b)| (a - b).abs() > tol)
8282
{
8383
return false;
8484
}
8585
for i in 0..self.eigenvalues.len() {
86-
if (self.eigenvalues[i] - other.eigenvalues[i]).abs() > T::epsilon() {
86+
if (self.eigenvalues[i] - other.eigenvalues[i]).abs() > tol {
8787
return false;
8888
}
8989
}
@@ -276,8 +276,13 @@ impl<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> LDA<T, X> {
276276
let sw_evd = sw.evd(true)?;
277277
let u = sw_evd.V;
278278
let l = sw_evd.d;
279+
let l_max = l
280+
.iter()
281+
.cloned()
282+
.fold(T::zero(), |a, b| if b > a { b } else { a });
283+
let tol = T::from(1e-10).unwrap().max(T::from(1e-4).unwrap() * l_max);
279284
for &li in &l {
280-
if li <= T::epsilon() {
285+
if li <= tol {
281286
return Err(Failed::fit(
282287
"Within-class scatter matrix is singular, provide more samples per class or fewer features",
283288
));
@@ -310,17 +315,17 @@ impl<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> LDA<T, X> {
310315
}
311316
});
312317

313-
let mut scalings = X::zeros(m, n_components);
318+
let mut projection_matrix = X::zeros(m, n_components);
314319
let mut eigenvalues = vec![T::zero(); n_components];
315320
for (col, &src) in order.iter().take(n_components).enumerate() {
316321
eigenvalues[col] = g[src];
317322
for row in 0..m {
318-
scalings.set((row, col), *directions.get((row, src)));
323+
projection_matrix.set((row, col), *directions.get((row, src)));
319324
}
320325
}
321326

322327
Ok(LDA {
323-
scalings,
328+
projection_matrix,
324329
eigenvalues,
325330
n_features: m,
326331
})
@@ -336,12 +341,12 @@ impl<T: Number + RealNumber, X: Array2<T> + EVDDecomposable<T>> LDA<T, X> {
336341
ncols, self.n_features
337342
)));
338343
}
339-
Ok(x.matmul(&self.scalings))
344+
Ok(x.matmul(&self.projection_matrix))
340345
}
341346

342347
/// Get the projection matrix, one column per discriminant direction.
343348
pub fn scalings(&self) -> &X {
344-
&self.scalings
349+
&self.projection_matrix
345350
}
346351
}
347352

@@ -499,6 +504,27 @@ mod tests {
499504
assert!(result.is_err());
500505
}
501506

507+
#[test]
508+
fn mismatched_x_y_is_rejected() {
509+
let (x, _) = three_class_data();
510+
let y_short = vec![0i32; x.shape().0 - 1];
511+
assert!(LDA::fit(&x, &y_short, LDAParameters::default()).is_err());
512+
}
513+
514+
#[test]
515+
fn zero_n_components_is_rejected() {
516+
let (x, y) = three_class_data();
517+
assert!(LDA::fit(&x, &y, LDAParameters::default().with_n_components(0)).is_err());
518+
}
519+
520+
#[test]
521+
fn transform_wrong_features_is_rejected() {
522+
let (x, y) = three_class_data();
523+
let lda = LDA::fit(&x, &y, LDAParameters::default()).unwrap();
524+
let bad = DenseMatrix::<f64>::zeros(3, 2);
525+
assert!(lda.transform(&bad).is_err());
526+
}
527+
502528
#[cfg_attr(
503529
all(target_arch = "wasm32", not(target_os = "wasi")),
504530
wasm_bindgen_test::wasm_bindgen_test

src/decomposition/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
1414
/// LDA is a supervised approach that projects the data onto the directions that best separate the classes.
1515
pub mod lda;
16+
1617
/// PCA is a popular approach for deriving a low-dimensional set of features from a large set of variables.
1718
pub mod pca;
1819
pub mod svd;

0 commit comments

Comments
 (0)