Expand description
Non-negative Matrix Factorization (NMF).
NMF decomposes a non-negative matrix X into two non-negative
factors W and H such that X ~ W * H, where:
Xhas shape(n_samples, n_features)Whas shape(n_samples, n_components)Hhas shape(n_components, n_features)
§Algorithm
Two solvers are supported:
- Multiplicative Update (Lee & Seung, 2001): iteratively update
WandHusing multiplicative rules that guarantee non-negativity. - Coordinate Descent: iteratively solve for each element of
WandHusing closed-form coordinate-wise updates.
§Initialization
- Random: initialize
WandHwith random non-negative values. - NNDSVD: Non-Negative Double SVD, initializes
WandHfrom a truncated SVD ofX, setting negative entries to zero.
§Examples
use ferrolearn_decomp::NMF;
use ferrolearn_core::traits::{Fit, Transform};
use ndarray::array;
let nmf = NMF::<f64>::new(2);
let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
let fitted = nmf.fit(&x, &()).unwrap();
let projected = fitted.transform(&x).unwrap();
assert_eq!(projected.ncols(), 2);§REQ status
Design: .design/decomp/nmf.md. Tracking: #1608. Each REQ is BINARY — SHIPPED
(impl + non-test consumer + tests + green verification) or NOT-STARTED (concrete
open blocker). Non-test consumers: crate re-export (lib.rs:97), the PyO3
_RsNMF binding (ferrolearn-python/src/extras.rs:1116, registered lib.rs:75),
PipelineTransformer. Oracle = live sklearn 1.5.2 (_nmf.py, class NMF), run
from /tmp (R-CHAR-3). ferrolearn’s ctor still DEFAULTS to MU + Random init;
exact component VALUES on the RANDOM/MU path are a carve-out (numpy RNG vs Rust
RNG; NMF identifiable only up to permutation/scaling). BUT the DETERMINISTIC
init='nndsvd', solver='cd' path is now BIT-EXACT to sklearn (#2398/#2394/#2395/
#2396/#2397): the real SVD-based NNDSVD init (init_nndsvd via
ferray::linalg::svd_lapack + svd_flip_u_based), the violation-ratio CD
convergence (solve_coordinate_descent/update_cd_sweep), and the CD transform
W-solve all reproduce sklearn to ~1e-9 (tests/divergence_nmf_cd_nndsvd_2393.rs).
| REQ | Scope | Status | Evidence / Blocker |
|---|---|---|---|
| REQ-1 | Structural: components_ shape (n_components,n_features), transform W shape, finite + decreasing reconstruction_err_, n_iter_, seed-determinism | SHIPPED (scoped) | fit (nmf.rs:617); green-guards + in-module tests. STRUCTURAL, NOT values (REQ-5) |
| REQ-2 | Non-negativity of components_ (H) + transform (W) | SHIPPED | MU multiplicative + CD clamp; test_nmf_components_non_negative/_transform_non_negative |
| REQ-3 | Both solvers (MU/CD) × both inits (Random/NNDSVD) run | SHIPPED | fit dispatch (:657-670); 4-combo tests |
| REQ-4 | Reconstruction QUALITY (‖X−WH‖ small/decreasing — “did NMF work”) | SHIPPED | reconstruction_error (:247) monotone-decreasing + small residual; test_nmf_* |
| REQ-5 | EXACT components_ value parity | SHIPPED (deterministic cd+nndsvd path) / NOT-STARTED (random/MU) | The DETERMINISTIC init='nndsvd', solver='cd' components_ is bit-exact to sklearn — divergence_components_cd_nndsvd (tests/divergence_nmf_cd_nndsvd_2393.rs) matches components_[0] to 1e-6 (was #2394). The random-init/MU path stays a CARVE-OUT (numpy vs Rust RNG, perm/scale) — blocker #1609 |
| REQ-6 | real SVD init='nndsvd' (+ nndsvda/nndsvdar/custom, nndsvda default) | SHIPPED (nndsvd) / NOT-STARTED (nndsvda default + nndsvdar/custom) | init_nndsvd (nmf.rs symbol init_nndsvd) now does the REAL SVD-based NNDSVD = sklearn _initialize_nmf (_nmf.py:320-358): svd_lapack (LAPACK gesdd, the SAME driver randomized_svd/scipy use) → svd_flip_u_based (extmath.py svd_flip(u_based_decision=True)) → leading-triplet sqrt(S[0])·|U|/sqrt(S[0])·|Vt| + per-component pos/neg-part split lbd=sqrt(S[j]·sigma) → <eps-zero. test_nmf_nndsvd_init_matches_sklearn matches sklearn _initialize_nmf(X,3,'nndsvd') W/H to 1e-9. STILL NOT-STARTED: the nndsvda/nndsvdar zero-fill + custom + init=None→nndsvda default — blocker #1610 |
| REQ-7 | solver='cd' matching _fit_coordinate_descent (+ cd DEFAULT) | SHIPPED (cd algorithm) / NOT-STARTED (cd ctor DEFAULT) | solve_coordinate_descent/update_cd_sweep now match sklearn’s Cython _update_cdnmf_fast (Gram-based per-coordinate W[i,t]=max(0,W[i,t]−grad/hess), in-place sweep) + the VIOLATION-RATIO stop violation/violation_init<=tol (_nmf.py:500-525), reproducing sklearn n_iter_ (divergence_n_iter_cd_nndsvd → 151) + reconstruction_err_ (divergence_reconstruction_err_cd_nndsvd → 5.5136, 1e-6). STILL NOT-STARTED: NMF::new still defaults to MultiplicativeUpdate not cd — blocker #1611 |
| REQ-8 | beta_loss (kullback-leibler/itakura-saito) + _gamma | NOT-STARTED | sklearn _nmf.py:89,:919; ferrolearn Frobenius-only — blocker #1612 |
| REQ-9 | transform NNLS-W VALUE | SHIPPED | transform (impl Transform for FittedNMF) now solves W via the CD with H fixed = _fit_transform(X, H=components_, update_H=False) (_nmf.py:1213): W=zeros (_nmf.py:1254), violation-ratio CD. divergence_transform_w_cd_nndsvd matches sklearn m.transform(X)[0] to 1e-6 (was #2397) |
| REQ-10 | inverse_transform = W·H | SHIPPED | nmf.rs:229 (= _nmf.py:1238); exact algebra + col-mismatch ShapeMismatch |
| REQ-11 | Error/parameter contracts (incl. NON-FINITE rejection, finiteness-before-nonnegative) | SHIPPED (scoped) | fit/transform guards. FLAG: sklearn raises InvalidParameterError, accepts n_components=None, doesn’t pre-reject >min(n,p). NON-FINITE: fit+transform call reject_non_finite (nmf.rs symbol reject_non_finite) BEFORE the non-negativity check and factorization, returning InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."} = sklearn _validate_data(force_all_finite=True) (_nmf.py:1652) which runs BEFORE check_non_negative (_nmf.py:1706), so a NaN+negative input rejects for finiteness first (utils/validation.py:147-154). tests/divergence_nonfinite.rs::divergence_nmf_fit_nan_/_fit_nan_and_negative_finiteness_fires_first match the live sklearn 1.5.2 oracle. Was #2288/#2289, fixed. Consumer: re-export lib.rs + NMF fit/transform |
| REQ-12 | PyO3 _RsNMF binding (thin n_components ctor + fit + transform) | SHIPPED (scoped) | extras.rs:1116, registered lib.rs:75; NO params/getters/inverse_transform |
| REQ-13 | n_components=None default | NOT-STARTED | sklearn _nmf.py:914 → min(n,p); ferrolearn requires explicit usize — blocker #1614 |
| REQ-14 | alpha_W/alpha_H/l1_ratio regularization | NOT-STARTED | sklearn _nmf.py:921-923,:1275 — blocker #1615 |
| REQ-15 | shuffle (CD) + fitted attrs n_components_/n_features_in_ | NOT-STARTED | sklearn _nmf.py:924 — blocker #1616 |
| REQ-16 | ferray substrate | NOT-STARTED | ndarray + rand + hand-rolled Jacobi — blocker #1617 |
Count: 9 SHIPPED (REQ-1,2,3,4,9,10,11,12 + REQ-6/7 cd+nndsvd algorithm) / 7 NOT-STARTED (REQ-8,13,14,15,16 + REQ-6 nndsvda-default/nndsvdar/custom + REQ-7 cd-ctor-default). REQ-5 is SHIPPED on the deterministic cd+nndsvd path, NOT-STARTED on random/MU (carve-out #1609).
Structs§
- FittedNMF
- A fitted NMF model holding the learned components and reconstruction error.
- NMF
- Non-negative Matrix Factorization configuration.