Skip to main content

ferrolearn_decomp/
nmf.rs

1//! Non-negative Matrix Factorization (NMF).
2//!
3//! [`NMF`] decomposes a non-negative matrix `X` into two non-negative
4//! factors `W` and `H` such that `X ~ W * H`, where:
5//! - `X` has shape `(n_samples, n_features)`
6//! - `W` has shape `(n_samples, n_components)`
7//! - `H` has shape `(n_components, n_features)`
8//!
9//! # Algorithm
10//!
11//! Two solvers are supported:
12//!
13//! - **Multiplicative Update** (Lee & Seung, 2001): iteratively update `W` and
14//!   `H` using multiplicative rules that guarantee non-negativity.
15//! - **Coordinate Descent**: iteratively solve for each element of `W` and `H`
16//!   using closed-form coordinate-wise updates.
17//!
18//! # Initialization
19//!
20//! - **Random**: initialize `W` and `H` with random non-negative values.
21//! - **NNDSVD**: Non-Negative Double SVD, initializes `W` and `H` from a
22//!   truncated SVD of `X`, setting negative entries to zero.
23//!
24//! # Examples
25//!
26//! ```
27//! use ferrolearn_decomp::NMF;
28//! use ferrolearn_core::traits::{Fit, Transform};
29//! use ndarray::array;
30//!
31//! let nmf = NMF::<f64>::new(2);
32//! let x = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
33//! let fitted = nmf.fit(&x, &()).unwrap();
34//! let projected = fitted.transform(&x).unwrap();
35//! assert_eq!(projected.ncols(), 2);
36//! ```
37//!
38//! ## REQ status
39//!
40//! Design: `.design/decomp/nmf.md`. Tracking: #1608. Each REQ is BINARY — SHIPPED
41//! (impl + non-test consumer + tests + green verification) or NOT-STARTED (concrete
42//! open blocker). Non-test consumers: crate re-export (`lib.rs:97`), the PyO3
43//! `_RsNMF` binding (`ferrolearn-python/src/extras.rs:1116`, registered `lib.rs:75`),
44//! `PipelineTransformer`. Oracle = live sklearn 1.5.2 (`_nmf.py`, `class NMF`), run
45//! from `/tmp` (R-CHAR-3). ferrolearn's ctor still DEFAULTS to MU + Random init;
46//! exact component VALUES on the RANDOM/MU path are a carve-out (numpy RNG vs Rust
47//! RNG; NMF identifiable only up to permutation/scaling). BUT the DETERMINISTIC
48//! `init='nndsvd', solver='cd'` path is now BIT-EXACT to sklearn (#2398/#2394/#2395/
49//! #2396/#2397): the real SVD-based NNDSVD init (`init_nndsvd` via
50//! `ferray::linalg::svd_lapack` + `svd_flip_u_based`), the violation-ratio CD
51//! convergence (`solve_coordinate_descent`/`update_cd_sweep`), and the CD transform
52//! W-solve all reproduce sklearn to ~1e-9 (`tests/divergence_nmf_cd_nndsvd_2393.rs`).
53//!
54//! | REQ | Scope | Status | Evidence / Blocker |
55//! |---|---|---|---|
56//! | 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) |
57//! | REQ-2 | Non-negativity of `components_` (H) + transform (W) | SHIPPED | MU multiplicative + CD clamp; `test_nmf_components_non_negative`/`_transform_non_negative` |
58//! | REQ-3 | Both solvers (MU/CD) × both inits (Random/NNDSVD) run | SHIPPED | `fit` dispatch (`:657-670`); 4-combo tests |
59//! | REQ-4 | Reconstruction QUALITY (`‖X−WH‖` small/decreasing — "did NMF work") | SHIPPED | `reconstruction_error` (`:247`) monotone-decreasing + small residual; `test_nmf_*` |
60//! | 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 |
61//! | 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 |
62//! | 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 |
63//! | REQ-8 | `beta_loss` (kullback-leibler/itakura-saito) + `_gamma` | NOT-STARTED | sklearn `_nmf.py:89,:919`; ferrolearn Frobenius-only — blocker #1612 |
64//! | 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) |
65//! | REQ-10 | `inverse_transform` = `W·H` | SHIPPED | `nmf.rs:229` (`= _nmf.py:1238`); exact algebra + col-mismatch `ShapeMismatch` |
66//! | 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` |
67//! | 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 |
68//! | REQ-13 | `n_components=None` default | NOT-STARTED | sklearn `_nmf.py:914` → min(n,p); ferrolearn requires explicit usize — blocker #1614 |
69//! | REQ-14 | `alpha_W`/`alpha_H`/`l1_ratio` regularization | NOT-STARTED | sklearn `_nmf.py:921-923,:1275` — blocker #1615 |
70//! | REQ-15 | `shuffle` (CD) + fitted attrs `n_components_`/`n_features_in_` | NOT-STARTED | sklearn `_nmf.py:924` — blocker #1616 |
71//! | REQ-16 | ferray substrate | NOT-STARTED | `ndarray` + `rand` + hand-rolled Jacobi — blocker #1617 |
72//!
73//! Count: **9 SHIPPED (REQ-1,2,3,4,9,10,11,12 + REQ-6/7 cd+nndsvd algorithm) / 7
74//! NOT-STARTED (REQ-8,13,14,15,16 + REQ-6 nndsvda-default/nndsvdar/custom + REQ-7
75//! cd-ctor-default)**. REQ-5 is SHIPPED on the deterministic cd+nndsvd path,
76//! NOT-STARTED on random/MU (carve-out #1609).
77
78use ferrolearn_core::error::FerroError;
79use ferrolearn_core::pipeline::{FittedPipelineTransformer, PipelineTransformer};
80use ferrolearn_core::traits::{Fit, Transform};
81use ndarray::{Array1, Array2};
82use num_traits::Float;
83use rand::SeedableRng;
84use rand_distr::{Distribution, Uniform};
85use std::any::TypeId;
86
87/// Reject non-finite input the way sklearn's `_validate_data` does.
88///
89/// sklearn runs `check_array` with the default `force_all_finite=True` at the
90/// top of `NMF.fit`/`transform` (`_nmf.py:1652`), raising
91/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
92/// (`sklearn/utils/validation.py:147-154`) BEFORE `check_non_negative`
93/// (`_nmf.py:1706`) and the factorization. The finiteness check therefore wins
94/// even when a negative value is also present. NaN AND infinity both rejected.
95/// The message names "NaN" and "infinity" to mirror sklearn. Never panics
96/// (R-CODE-2).
97fn reject_non_finite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
98    if x.iter().any(|v| !v.is_finite()) {
99        return Err(FerroError::InvalidParameter {
100            name: "X".into(),
101            reason: "Input X contains NaN or infinity.".into(),
102        });
103    }
104    Ok(())
105}
106
107// ---------------------------------------------------------------------------
108// Configuration enums
109// ---------------------------------------------------------------------------
110
111/// The solver algorithm for NMF.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum NMFSolver {
114    /// Multiplicative update rules (Lee & Seung, 2001).
115    MultiplicativeUpdate,
116    /// Coordinate descent.
117    CoordinateDescent,
118}
119
120/// The initialization strategy for NMF.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum NMFInit {
123    /// Random non-negative initialization.
124    Random,
125    /// Non-Negative Double SVD initialization.
126    Nndsvd,
127}
128
129// ---------------------------------------------------------------------------
130// NMF (unfitted)
131// ---------------------------------------------------------------------------
132
133/// Non-negative Matrix Factorization configuration.
134///
135/// Holds hyperparameters for the NMF decomposition. Calling [`Fit::fit`]
136/// computes the factorization and returns a [`FittedNMF`] that can
137/// project new data via [`Transform::transform`].
138#[derive(Debug, Clone)]
139pub struct NMF<F> {
140    /// Number of components to extract.
141    n_components: usize,
142    /// Maximum number of iterations for the solver.
143    max_iter: usize,
144    /// Convergence tolerance for the solver.
145    tol: f64,
146    /// The solver algorithm to use.
147    solver: NMFSolver,
148    /// The initialization strategy.
149    init: NMFInit,
150    /// Optional random seed for reproducibility.
151    random_state: Option<u64>,
152    _marker: std::marker::PhantomData<F>,
153}
154
155impl<F: Float + Send + Sync + 'static> NMF<F> {
156    /// Create a new `NMF` that extracts `n_components` components.
157    ///
158    /// Defaults: `max_iter=200`, `tol=1e-4`, solver=`MultiplicativeUpdate`,
159    /// init=`Random`, no random seed.
160    #[must_use]
161    pub fn new(n_components: usize) -> Self {
162        Self {
163            n_components,
164            max_iter: 200,
165            tol: 1e-4,
166            solver: NMFSolver::MultiplicativeUpdate,
167            init: NMFInit::Random,
168            random_state: None,
169            _marker: std::marker::PhantomData,
170        }
171    }
172
173    /// Set the maximum number of iterations.
174    #[must_use]
175    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
176        self.max_iter = max_iter;
177        self
178    }
179
180    /// Set the convergence tolerance.
181    #[must_use]
182    pub fn with_tol(mut self, tol: f64) -> Self {
183        self.tol = tol;
184        self
185    }
186
187    /// Set the solver algorithm.
188    #[must_use]
189    pub fn with_solver(mut self, solver: NMFSolver) -> Self {
190        self.solver = solver;
191        self
192    }
193
194    /// Set the initialization strategy.
195    #[must_use]
196    pub fn with_init(mut self, init: NMFInit) -> Self {
197        self.init = init;
198        self
199    }
200
201    /// Set the random seed for reproducible results.
202    #[must_use]
203    pub fn with_random_state(mut self, seed: u64) -> Self {
204        self.random_state = Some(seed);
205        self
206    }
207
208    /// Return the configured number of components.
209    #[must_use]
210    pub fn n_components(&self) -> usize {
211        self.n_components
212    }
213
214    /// Return the configured maximum iterations.
215    #[must_use]
216    pub fn max_iter(&self) -> usize {
217        self.max_iter
218    }
219
220    /// Return the configured tolerance.
221    #[must_use]
222    pub fn tol(&self) -> f64 {
223        self.tol
224    }
225
226    /// Return the configured solver.
227    #[must_use]
228    pub fn solver(&self) -> NMFSolver {
229        self.solver
230    }
231
232    /// Return the configured initialization strategy.
233    #[must_use]
234    pub fn init(&self) -> NMFInit {
235        self.init
236    }
237
238    /// Return the configured random state, if any.
239    #[must_use]
240    pub fn random_state(&self) -> Option<u64> {
241        self.random_state
242    }
243}
244
245// ---------------------------------------------------------------------------
246// FittedNMF
247// ---------------------------------------------------------------------------
248
249/// A fitted NMF model holding the learned components and reconstruction error.
250///
251/// Created by calling [`Fit::fit`] on an [`NMF`]. Implements
252/// [`Transform<Array2<F>>`] to project new data onto the learned components.
253#[derive(Debug, Clone)]
254pub struct FittedNMF<F> {
255    /// Learned component matrix H, shape `(n_components, n_features)`.
256    components_: Array2<F>,
257    /// The Frobenius norm of the reconstruction error at convergence.
258    reconstruction_err_: F,
259    /// Number of iterations performed.
260    n_iter_: usize,
261}
262
263impl<F: Float + Send + Sync + 'static> FittedNMF<F> {
264    /// Learned components (H matrix), shape `(n_components, n_features)`.
265    #[must_use]
266    pub fn components(&self) -> &Array2<F> {
267        &self.components_
268    }
269
270    /// Frobenius norm of the reconstruction error `||X - W*H||_F`.
271    #[must_use]
272    pub fn reconstruction_err(&self) -> F {
273        self.reconstruction_err_
274    }
275
276    /// Number of iterations performed during fitting.
277    #[must_use]
278    pub fn n_iter(&self) -> usize {
279        self.n_iter_
280    }
281
282    /// Reconstruct the original feature space from the latent representation.
283    /// Mirrors sklearn `NMF.inverse_transform`. Returns `W @ H` where `W`
284    /// is the input transformed matrix and `H = self.components_`.
285    ///
286    /// # Errors
287    ///
288    /// Returns [`FerroError::ShapeMismatch`] if `w.ncols()` does not equal
289    /// the number of components.
290    pub fn inverse_transform(&self, w: &Array2<F>) -> Result<Array2<F>, FerroError> {
291        let n_components = self.components_.nrows();
292        if w.ncols() != n_components {
293            return Err(FerroError::ShapeMismatch {
294                expected: vec![w.nrows(), n_components],
295                actual: vec![w.nrows(), w.ncols()],
296                context: "FittedNMF::inverse_transform".into(),
297            });
298        }
299        Ok(w.dot(&self.components_))
300    }
301}
302
303// ---------------------------------------------------------------------------
304// Internal helpers
305// ---------------------------------------------------------------------------
306
307/// Compute the Frobenius norm of `X - W * H`.
308fn reconstruction_error<F: Float + 'static>(x: &Array2<F>, w: &Array2<F>, h: &Array2<F>) -> F {
309    let wh = w.dot(h);
310    let mut err = F::zero();
311    for (a, b) in x.iter().zip(wh.iter()) {
312        let diff = *a - *b;
313        err = err + diff * diff;
314    }
315    err.sqrt()
316}
317
318/// Small epsilon to prevent division by zero.
319fn eps<F: Float>() -> F {
320    F::from(1e-12).unwrap_or_else(F::epsilon)
321}
322
323/// Initialize W and H with random non-negative values.
324fn init_random<F: Float>(
325    n_samples: usize,
326    n_features: usize,
327    n_components: usize,
328    seed: u64,
329) -> (Array2<F>, Array2<F>) {
330    let mut rng: rand::rngs::StdRng = SeedableRng::seed_from_u64(seed);
331    let uniform = Uniform::new(0.0f64, 1.0f64).unwrap();
332
333    let mut w = Array2::<F>::zeros((n_samples, n_components));
334    for elem in &mut w {
335        *elem = F::from(uniform.sample(&mut rng)).unwrap_or_else(F::zero) + eps::<F>();
336    }
337
338    let mut h = Array2::<F>::zeros((n_components, n_features));
339    for elem in &mut h {
340        *elem = F::from(uniform.sample(&mut rng)).unwrap_or_else(F::zero) + eps::<F>();
341    }
342
343    (w, h)
344}
345
346/// Bridge an `ndarray::Array2<f64>` into a `ferray` 2-D array.
347fn ndarray_to_ferray_f64(a: &Array2<f64>) -> Result<ferray::Array<f64, ferray::Ix2>, FerroError> {
348    let (m, n) = a.dim();
349    let data: Vec<f64> = a.iter().copied().collect();
350    ferray::Array::<f64, ferray::Ix2>::from_vec(ferray::Ix2::new([m, n]), data).map_err(|e| {
351        FerroError::NumericalInstability {
352            message: format!("ferray array construction failed: {e}"),
353        }
354    })
355}
356
357/// Bridge an `ndarray::Array2<f32>` into a `ferray` 2-D array.
358fn ndarray_to_ferray_f32(a: &Array2<f32>) -> Result<ferray::Array<f32, ferray::Ix2>, FerroError> {
359    let (m, n) = a.dim();
360    let data: Vec<f32> = a.iter().copied().collect();
361    ferray::Array::<f32, ferray::Ix2>::from_vec(ferray::Ix2::new([m, n]), data).map_err(|e| {
362        FerroError::NumericalInstability {
363            message: format!("ferray array construction failed: {e}"),
364        }
365    })
366}
367
368/// Full thin SVD of `a` (`m × n`) returning `(U, s, Vt)` via
369/// `ferray::linalg::svd_lapack` (LAPACK `gesdd`). `U` is `(m, k)`, `s` length
370/// `k`, `Vt` is `(k, n)` with `k = min(m, n)`. This is the SAME driver
371/// `scipy.linalg.svd` calls and the SAME helper `pca.rs` uses, so the spectrum
372/// and singular vectors are bit-identical to scipy/sklearn (up to per-vector
373/// sign, fixed by [`svd_flip_u_based`]).
374#[allow(
375    clippy::type_complexity,
376    reason = "(U, s, Vt) is the standard thin-SVD triple, not worth a named struct"
377)]
378fn svd_full_f64(a: &Array2<f64>) -> Result<(Array2<f64>, Array1<f64>, Array2<f64>), FerroError> {
379    let fa = ndarray_to_ferray_f64(a)?;
380    let (u, s, vt) =
381        ferray::linalg::svd_lapack(&fa, false).map_err(|e| FerroError::NumericalInstability {
382            message: format!("ferray svd_lapack (gesdd) failed: {e}"),
383        })?;
384    Ok((u.into_ndarray(), s.into_ndarray(), vt.into_ndarray()))
385}
386
387/// f32 analogue of [`svd_full_f64`].
388#[allow(
389    clippy::type_complexity,
390    reason = "(U, s, Vt) is the standard thin-SVD triple, not worth a named struct"
391)]
392fn svd_full_f32(a: &Array2<f32>) -> Result<(Array2<f32>, Array1<f32>, Array2<f32>), FerroError> {
393    let fa = ndarray_to_ferray_f32(a)?;
394    let (u, s, vt) =
395        ferray::linalg::svd_lapack(&fa, false).map_err(|e| FerroError::NumericalInstability {
396            message: format!("ferray svd_lapack (gesdd) failed: {e}"),
397        })?;
398    Ok((u.into_ndarray(), s.into_ndarray(), vt.into_ndarray()))
399}
400
401/// sklearn's `svd_flip(U, Vt, u_based_decision=True)`
402/// (`sklearn/utils/extmath.py`): for each column `k` of `U`, find the entry with
403/// the largest absolute value (numpy `argmax` — first index on ties via strict
404/// `>`); if that entry is negative, negate column `k` of `U` and row `k` of
405/// `Vt`. This is the sign convention `randomized_svd` applies, so the
406/// deterministic top-`k` full SVD reproduces sklearn's `_initialize_nmf`
407/// `U`/`Vt` exactly.
408fn svd_flip_u_based<F: Float>(u: &mut Array2<F>, vt: &mut Array2<F>) {
409    let (m, k) = u.dim();
410    let n = vt.ncols();
411    for col in 0..k {
412        let mut max_abs = F::neg_infinity();
413        let mut max_row = 0usize;
414        for row in 0..m {
415            let av = u[[row, col]].abs();
416            if av > max_abs {
417                max_abs = av;
418                max_row = row;
419            }
420        }
421        if u[[max_row, col]] < F::zero() {
422            for row in 0..m {
423                u[[row, col]] = -u[[row, col]];
424            }
425            for j in 0..n {
426                vt[[col, j]] = -vt[[col, j]];
427            }
428        }
429    }
430}
431
432/// Compute the top-`n_components` `(U, S, Vt)` of `X` (sign-corrected by
433/// [`svd_flip_u_based`]) for the NNDSVD init. f64/f32 route through
434/// `ferray::linalg::svd_lapack` (LAPACK `gesdd`, sklearn's `gesdd` driver);
435/// exotic `F` falls back to a Jacobi eigendecomposition of `XᵀX` reconstructing
436/// `U = X·V/s`.
437#[allow(
438    clippy::type_complexity,
439    reason = "(U, s, Vt) is the standard thin-SVD triple, not worth a named struct"
440)]
441fn nndsvd_svd<F: Float + Send + Sync + 'static>(
442    x: &Array2<F>,
443    n_components: usize,
444) -> Result<(Array2<F>, Array1<F>, Array2<F>), FerroError> {
445    let (n_samples, n_features) = x.dim();
446    let k = n_components;
447
448    // f64/f32 fast path through ferray's LAPACK gesdd SVD (sklearn's driver).
449    let full = if TypeId::of::<F>() == TypeId::of::<f64>() {
450        // SAFETY: TypeId proves F == f64; the cast/transmute is between identical
451        // types, the same pattern PCA's `svd_dispatch` uses.
452        let x_f64: &Array2<f64> = unsafe { &*(std::ptr::from_ref(x).cast::<Array2<f64>>()) };
453        let (u, s, vt) = svd_full_f64(x_f64)?;
454        let u_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f64>, Array2<F>>(&u) };
455        let s_f: Array1<F> = unsafe { std::mem::transmute_copy::<Array1<f64>, Array1<F>>(&s) };
456        let vt_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f64>, Array2<F>>(&vt) };
457        std::mem::forget(u);
458        std::mem::forget(s);
459        std::mem::forget(vt);
460        Some((u_f, s_f, vt_f))
461    } else if TypeId::of::<F>() == TypeId::of::<f32>() {
462        // SAFETY: TypeId proves F == f32; the cast/transmute is between identical
463        // types, the same pattern PCA's `svd_dispatch` uses.
464        let x_f32: &Array2<f32> = unsafe { &*(std::ptr::from_ref(x).cast::<Array2<f32>>()) };
465        let (u, s, vt) = svd_full_f32(x_f32)?;
466        let u_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f32>, Array2<F>>(&u) };
467        let s_f: Array1<F> = unsafe { std::mem::transmute_copy::<Array1<f32>, Array1<F>>(&s) };
468        let vt_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f32>, Array2<F>>(&vt) };
469        std::mem::forget(u);
470        std::mem::forget(s);
471        std::mem::forget(vt);
472        Some((u_f, s_f, vt_f))
473    } else {
474        None
475    };
476
477    let (mut u, s, mut vt) = match full {
478        Some((u_full, s_full, vt_full)) => {
479            // Truncate the thin SVD to the leading k triplets.
480            let u_t = u_full.slice(ndarray::s![.., ..k]).to_owned();
481            let s_t = s_full.slice(ndarray::s![..k]).to_owned();
482            let vt_t = vt_full.slice(ndarray::s![..k, ..]).to_owned();
483            (u_t, s_t, vt_t)
484        }
485        None => {
486            // Exotic-F fallback: V/S from Jacobi(XᵀX), U = X·V/s.
487            let max_iter = n_features * n_features * 100 + 1000;
488            let xtx = x.t().dot(x);
489            let (eigenvalues, eigenvectors) = jacobi_eigen_symmetric(&xtx, max_iter)?;
490            let mut indices: Vec<usize> = (0..n_features).collect();
491            indices.sort_by(|&a, &b| {
492                eigenvalues[b]
493                    .partial_cmp(&eigenvalues[a])
494                    .unwrap_or(std::cmp::Ordering::Equal)
495            });
496            let mut s_t = Array1::<F>::zeros(k);
497            let mut vt_t = Array2::<F>::zeros((k, n_features));
498            for (row, &idx) in indices.iter().take(k).enumerate() {
499                let sv = eigenvalues[idx].max(F::zero()).sqrt();
500                s_t[row] = sv;
501                for j in 0..n_features {
502                    vt_t[[row, j]] = eigenvectors[[j, idx]];
503                }
504            }
505            // U = X · Vᵀᵀ / s = X · V / s; columns where s ~ 0 stay zero.
506            let mut u_t = Array2::<F>::zeros((n_samples, k));
507            for row in 0..k {
508                let sv = s_t[row];
509                if sv <= eps::<F>() {
510                    continue;
511                }
512                for i in 0..n_samples {
513                    let mut acc = F::zero();
514                    for j in 0..n_features {
515                        acc = acc + x[[i, j]] * vt_t[[row, j]];
516                    }
517                    u_t[[i, row]] = acc / sv;
518                }
519            }
520            (u_t, s_t, vt_t)
521        }
522    };
523
524    svd_flip_u_based(&mut u, &mut vt);
525    Ok((u, s, vt))
526}
527
528/// NNDSVD initialization (Boutsidis & Gallopoulos, 2008), matching sklearn's
529/// `_initialize_nmf(X, n_components, init="nndsvd")`
530/// (`sklearn/decomposition/_nmf.py:320-358`).
531///
532/// `U, S, Vt = svd(X)` (top `n_components`, sign-corrected like
533/// `randomized_svd`). The leading triplet seeds `W[:,0] = sqrt(S[0])·|U[:,0]|`,
534/// `H[0,:] = sqrt(S[0])·|Vt[0,:]|`. For each later component the positive and
535/// negative parts of `U[:,j]`/`Vt[j,:]` are split, the larger-norm pair chosen,
536/// and scaled by `lbd = sqrt(S[j]·sigma)`. Entries below machine `eps` are
537/// zeroed (plain `nndsvd`: zeros stay). Deterministic — no RNG.
538fn init_nndsvd<F: Float + Send + Sync + 'static>(
539    x: &Array2<F>,
540    n_components: usize,
541) -> Result<(Array2<F>, Array2<F>), FerroError> {
542    let (n_samples, n_features) = x.dim();
543    let (u, s, vt) = nndsvd_svd(x, n_components)?;
544
545    // sklearn uses `eps = np.finfo(X.dtype).eps`. Match per concrete F.
546    let machine_eps = if TypeId::of::<F>() == TypeId::of::<f32>() {
547        F::from(f32::EPSILON).unwrap_or_else(F::epsilon)
548    } else {
549        F::from(f64::EPSILON).unwrap_or_else(F::epsilon)
550    };
551
552    let mut w = Array2::<F>::zeros((n_samples, n_components));
553    let mut h = Array2::<F>::zeros((n_components, n_features));
554
555    // Leading singular triplet is non-negative — use as-is.
556    let sqrt_s0 = s[0].max(F::zero()).sqrt();
557    for i in 0..n_samples {
558        w[[i, 0]] = sqrt_s0 * u[[i, 0]].abs();
559    }
560    for j in 0..n_features {
561        h[[0, j]] = sqrt_s0 * vt[[0, j]].abs();
562    }
563
564    for comp in 1..n_components {
565        // Positive / negative parts of U[:,comp] and Vt[comp,:].
566        let mut xp_nrm_sq = F::zero();
567        let mut xn_nrm_sq = F::zero();
568        for i in 0..n_samples {
569            let val = u[[i, comp]];
570            if val > F::zero() {
571                xp_nrm_sq = xp_nrm_sq + val * val;
572            } else {
573                xn_nrm_sq = xn_nrm_sq + val * val;
574            }
575        }
576        let mut yp_nrm_sq = F::zero();
577        let mut yn_nrm_sq = F::zero();
578        for j in 0..n_features {
579            let val = vt[[comp, j]];
580            if val > F::zero() {
581                yp_nrm_sq = yp_nrm_sq + val * val;
582            } else {
583                yn_nrm_sq = yn_nrm_sq + val * val;
584            }
585        }
586        let x_p_nrm = xp_nrm_sq.sqrt();
587        let y_p_nrm = yp_nrm_sq.sqrt();
588        let x_n_nrm = xn_nrm_sq.sqrt();
589        let y_n_nrm = yn_nrm_sq.sqrt();
590
591        let m_p = x_p_nrm * y_p_nrm;
592        let m_n = x_n_nrm * y_n_nrm;
593
594        // `use_positive`, `nrm_u`, `nrm_v`, `sigma`.
595        let (use_positive, nrm_u, nrm_v, sigma) = if m_p > m_n {
596            (true, x_p_nrm, y_p_nrm, m_p)
597        } else {
598            (false, x_n_nrm, y_n_nrm, m_n)
599        };
600
601        let lbd = (s[comp] * sigma).max(F::zero()).sqrt();
602
603        // W[:,comp] = lbd * (selected part of U[:,comp]) / nrm_u.
604        for i in 0..n_samples {
605            let val = u[[i, comp]];
606            let part = if use_positive {
607                if val > F::zero() { val } else { F::zero() }
608            } else if val < F::zero() {
609                -val
610            } else {
611                F::zero()
612            };
613            w[[i, comp]] = if nrm_u > F::zero() {
614                lbd * (part / nrm_u)
615            } else {
616                F::zero()
617            };
618        }
619        // H[comp,:] = lbd * (selected part of Vt[comp,:]) / nrm_v.
620        for j in 0..n_features {
621            let val = vt[[comp, j]];
622            let part = if use_positive {
623                if val > F::zero() { val } else { F::zero() }
624            } else if val < F::zero() {
625                -val
626            } else {
627                F::zero()
628            };
629            h[[comp, j]] = if nrm_v > F::zero() {
630                lbd * (part / nrm_v)
631            } else {
632                F::zero()
633            };
634        }
635    }
636
637    // sklearn: `W[W < eps] = 0; H[H < eps] = 0` (plain nndsvd: zeros stay).
638    for val in &mut w {
639        if *val < machine_eps {
640            *val = F::zero();
641        }
642    }
643    for val in &mut h {
644        if *val < machine_eps {
645            *val = F::zero();
646        }
647    }
648
649    Ok((w, h))
650}
651
652/// Jacobi eigendecomposition for symmetric matrices.
653///
654/// Returns `(eigenvalues, eigenvectors)` where column `i` of `eigenvectors`
655/// is the eigenvector for `eigenvalues[i]`. Eigenvalues are NOT sorted.
656fn jacobi_eigen_symmetric<F: Float + Send + Sync + 'static>(
657    a: &Array2<F>,
658    max_iter: usize,
659) -> Result<(Array1<F>, Array2<F>), FerroError> {
660    let n = a.nrows();
661    if n == 0 {
662        return Ok((Array1::zeros(0), Array2::zeros((0, 0))));
663    }
664    if n == 1 {
665        let eigenvalues = Array1::from_vec(vec![a[[0, 0]]]);
666        let eigenvectors = Array2::from_shape_vec((1, 1), vec![F::one()]).unwrap();
667        return Ok((eigenvalues, eigenvectors));
668    }
669
670    let mut mat = a.to_owned();
671    let mut v = Array2::<F>::zeros((n, n));
672    for i in 0..n {
673        v[[i, i]] = F::one();
674    }
675
676    let tol = F::from(1e-12).unwrap_or_else(F::epsilon);
677
678    for _iteration in 0..max_iter {
679        let mut max_off = F::zero();
680        let mut p = 0;
681        let mut q = 1;
682        for i in 0..n {
683            for j in (i + 1)..n {
684                let val = mat[[i, j]].abs();
685                if val > max_off {
686                    max_off = val;
687                    p = i;
688                    q = j;
689                }
690            }
691        }
692
693        if max_off < tol {
694            let eigenvalues = Array1::from_shape_fn(n, |i| mat[[i, i]]);
695            return Ok((eigenvalues, v));
696        }
697
698        let app = mat[[p, p]];
699        let aqq = mat[[q, q]];
700        let apq = mat[[p, q]];
701
702        let theta = if (app - aqq).abs() < tol {
703            F::from(std::f64::consts::FRAC_PI_4).unwrap_or_else(F::one)
704        } else {
705            let tau = (aqq - app) / (F::from(2.0).unwrap() * apq);
706            let t = if tau >= F::zero() {
707                F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
708            } else {
709                -F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
710            };
711            t.atan()
712        };
713
714        let c = theta.cos();
715        let s = theta.sin();
716
717        let mut new_mat = mat.clone();
718        for i in 0..n {
719            if i != p && i != q {
720                let mip = mat[[i, p]];
721                let miq = mat[[i, q]];
722                new_mat[[i, p]] = c * mip - s * miq;
723                new_mat[[p, i]] = new_mat[[i, p]];
724                new_mat[[i, q]] = s * mip + c * miq;
725                new_mat[[q, i]] = new_mat[[i, q]];
726            }
727        }
728
729        new_mat[[p, p]] = c * c * app - F::from(2.0).unwrap() * s * c * apq + s * s * aqq;
730        new_mat[[q, q]] = s * s * app + F::from(2.0).unwrap() * s * c * apq + c * c * aqq;
731        new_mat[[p, q]] = F::zero();
732        new_mat[[q, p]] = F::zero();
733
734        mat = new_mat;
735
736        for i in 0..n {
737            let vip = v[[i, p]];
738            let viq = v[[i, q]];
739            v[[i, p]] = c * vip - s * viq;
740            v[[i, q]] = s * vip + c * viq;
741        }
742    }
743
744    Err(FerroError::ConvergenceFailure {
745        iterations: max_iter,
746        message: "Jacobi eigendecomposition did not converge in NMF NNDSVD init".into(),
747    })
748}
749
750/// Multiplicative update solver (Lee & Seung, 2001).
751///
752/// Update rules:
753///   W <- W * (X H^T) / (W H H^T + eps)
754///   H <- H * (W^T X) / (W^T W H + eps)
755fn solve_multiplicative_update<F: Float + 'static>(
756    x: &Array2<F>,
757    w: &mut Array2<F>,
758    h: &mut Array2<F>,
759    max_iter: usize,
760    tol: f64,
761) -> usize {
762    let tol_f = F::from(tol).unwrap_or_else(F::epsilon);
763    let epsilon = eps::<F>();
764    let mut prev_err = reconstruction_error(x, w, h);
765
766    for iteration in 0..max_iter {
767        // Update H: H <- H * (W^T X) / (W^T W H + eps)
768        let wt = w.t();
769        let numerator_h = wt.dot(x);
770        let denominator_h = wt.dot(&*w).dot(&*h);
771
772        for (h_val, (num, den)) in h
773            .iter_mut()
774            .zip(numerator_h.iter().zip(denominator_h.iter()))
775        {
776            *h_val = *h_val * (*num / (*den + epsilon));
777        }
778
779        // Update W: W <- W * (X H^T) / (W H H^T + eps)
780        let ht = h.t();
781        let numerator_w = x.dot(&ht);
782        let denominator_w = w.dot(&*h).dot(&ht);
783
784        for (w_val, (num, den)) in w
785            .iter_mut()
786            .zip(numerator_w.iter().zip(denominator_w.iter()))
787        {
788            *w_val = *w_val * (*num / (*den + epsilon));
789        }
790
791        // Check convergence.
792        let err = reconstruction_error(x, w, h);
793        if (prev_err - err).abs() < tol_f {
794            return iteration + 1;
795        }
796        prev_err = err;
797    }
798
799    max_iter
800}
801
802/// One cyclic coordinate-descent sweep over the columns of `w`, with `ht`
803/// playing the role of `Ht` (`= H.T`). Mirrors sklearn's Cython
804/// `_update_cdnmf_fast` (`sklearn/decomposition/_cdnmf_fast.pyx`):
805///
806/// ```text
807/// HHt = Ht.T @ Ht ; XHt = X @ Ht
808/// for t in 0..n_components:
809///     for i in 0..n_samples:
810///         grad = -XHt[i,t] + sum_r HHt[t,r] * W[i,r]    # W updated in place
811///         pg   = grad if W[i,t] != 0 else min(0, grad)  # projected gradient
812///         violation += |pg|
813///         if HHt[t,t] != 0: W[i,t] = max(W[i,t] - grad / HHt[t,t], 0)
814/// ```
815///
816/// Returns the accumulated projected-gradient `violation`. `w` is mutated in
817/// place so later coordinates in the sweep see the already-updated entries.
818/// To update `H`, call with `X.T`, `Ht`, `W` (the symmetry sklearn exploits in
819/// `_fit_coordinate_descent`).
820fn update_cd_sweep<F: Float + 'static>(x: &Array2<F>, w: &mut Array2<F>, ht: &Array2<F>) -> F {
821    let n_components = ht.ncols();
822    let n_samples = w.nrows();
823
824    // HHt = Ht.T @ Ht  (n_components × n_components)
825    let hht = ht.t().dot(ht);
826    // XHt = X @ Ht      (n_samples × n_components)
827    let xht = x.dot(ht);
828
829    let mut violation = F::zero();
830    for t in 0..n_components {
831        let hess = hht[[t, t]];
832        for i in 0..n_samples {
833            // grad = GW[i,t] where GW = W @ HHt - XHt
834            let mut grad = -xht[[i, t]];
835            for r in 0..n_components {
836                grad = grad + hht[[t, r]] * w[[i, r]];
837            }
838            // projected gradient
839            let pg = if w[[i, t]] == F::zero() {
840                grad.min(F::zero())
841            } else {
842                grad
843            };
844            violation = violation + pg.abs();
845            if hess != F::zero() {
846                w[[i, t]] = (w[[i, t]] - grad / hess).max(F::zero());
847            }
848        }
849    }
850    violation
851}
852
853/// Coordinate-descent solver, matching sklearn's `_fit_coordinate_descent`
854/// (`sklearn/decomposition/_nmf.py:410-527`).
855///
856/// Each iteration updates `W` (via [`update_cd_sweep`] over `Ht = H.T`) and, if
857/// `update_h`, `H` (via the `X.T`/`Ht`/`W` symmetry), accumulating the total
858/// projected-gradient `violation`. The stopping rule is the VIOLATION RATIO
859/// `violation / violation_init <= tol` (`_nmf.py:513-525`), NOT a
860/// reconstruction-error delta. `H` is carried as its transpose `ht` (`= H.T`)
861/// throughout — the `C`-order layout sklearn keeps (`_nmf.py:495`). Returns the
862/// 1-based iteration count `n_iter` sklearn stores as `n_iter_`.
863fn solve_coordinate_descent<F: Float + 'static>(
864    x: &Array2<F>,
865    w: &mut Array2<F>,
866    h: &mut Array2<F>,
867    max_iter: usize,
868    tol: f64,
869    update_h: bool,
870) -> usize {
871    let tol_f = F::from(tol).unwrap_or_else(F::epsilon);
872
873    // Work on Ht = H.T (n_features × n_components), as sklearn does.
874    let mut ht = h.t().to_owned();
875    let xt = x.t().to_owned();
876
877    let mut violation_init = F::zero();
878    let mut n_iter = if max_iter == 0 { 0 } else { 1 };
879
880    for iteration in 1..=max_iter {
881        n_iter = iteration;
882        let mut violation = F::zero();
883
884        // Update W (Ht plays the role of Ht).
885        violation = violation + update_cd_sweep(x, w, &ht);
886
887        // Update H (symmetry: X.T, Ht, W).
888        if update_h {
889            violation = violation + update_cd_sweep(&xt, &mut ht, w);
890        }
891
892        if iteration == 1 {
893            violation_init = violation;
894        }
895        if violation_init == F::zero() {
896            break;
897        }
898        if violation / violation_init <= tol_f {
899            break;
900        }
901    }
902
903    // Write Ht.T back into H.
904    if update_h {
905        for k in 0..h.nrows() {
906            for j in 0..h.ncols() {
907                h[[k, j]] = ht[[j, k]];
908            }
909        }
910    }
911
912    n_iter
913}
914
915// ---------------------------------------------------------------------------
916// Trait implementations
917// ---------------------------------------------------------------------------
918
919impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for NMF<F> {
920    type Fitted = FittedNMF<F>;
921    type Error = FerroError;
922
923    /// Fit the NMF model by decomposing `X ~ W * H`.
924    ///
925    /// # Errors
926    ///
927    /// - [`FerroError::InvalidParameter`] if `n_components` is zero or exceeds
928    ///   the minimum of `n_samples` and `n_features`.
929    /// - [`FerroError::InvalidParameter`] if any entry of `X` is negative.
930    /// - [`FerroError::InsufficientSamples`] if there are zero samples.
931    /// - [`FerroError::ConvergenceFailure`] if NNDSVD initialization fails.
932    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedNMF<F>, FerroError> {
933        let (n_samples, n_features) = x.dim();
934
935        if self.n_components == 0 {
936            return Err(FerroError::InvalidParameter {
937                name: "n_components".into(),
938                reason: "must be at least 1".into(),
939            });
940        }
941        if n_samples == 0 {
942            return Err(FerroError::InsufficientSamples {
943                required: 1,
944                actual: 0,
945                context: "NMF::fit".into(),
946            });
947        }
948        if self.n_components > n_samples.min(n_features) {
949            return Err(FerroError::InvalidParameter {
950                name: "n_components".into(),
951                reason: format!(
952                    "n_components ({}) exceeds min(n_samples, n_features) = {}",
953                    self.n_components,
954                    n_samples.min(n_features)
955                ),
956            });
957        }
958
959        // Finiteness FIRST: sklearn `NMF.fit_transform` runs `_validate_data`
960        // (`_nmf.py:1652`) — default `force_all_finite=True` — BEFORE
961        // `check_non_negative` (`_nmf.py:1706`), so a NaN/Inf raises the
962        // finiteness `ValueError` even when a negative value is also present
963        // (`utils/validation.py:147-154`). Mirror that ordering here: reject
964        // non-finite, then check non-negativity. NaN AND infinity both rejected
965        // — replaces the prior silent-garbage `Ok` (#2288).
966        reject_non_finite(x)?;
967
968        // Check non-negativity.
969        for &val in x {
970            if val < F::zero() {
971                return Err(FerroError::InvalidParameter {
972                    name: "X".into(),
973                    reason: "NMF requires all entries in X to be non-negative".into(),
974                });
975            }
976        }
977
978        let seed = self.random_state.unwrap_or(0);
979
980        // Initialize W and H.
981        let (mut w, mut h) = match self.init {
982            NMFInit::Random => init_random(n_samples, n_features, self.n_components, seed),
983            NMFInit::Nndsvd => init_nndsvd(x, self.n_components)?,
984        };
985
986        // Solve.
987        let n_iter = match self.solver {
988            NMFSolver::MultiplicativeUpdate => {
989                solve_multiplicative_update(x, &mut w, &mut h, self.max_iter, self.tol)
990            }
991            NMFSolver::CoordinateDescent => {
992                solve_coordinate_descent(x, &mut w, &mut h, self.max_iter, self.tol, true)
993            }
994        };
995
996        let reconstruction_err = reconstruction_error(x, &w, &h);
997
998        Ok(FittedNMF {
999            components_: h,
1000            reconstruction_err_: reconstruction_err,
1001            n_iter_: n_iter,
1002        })
1003    }
1004}
1005
1006impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedNMF<F> {
1007    type Output = Array2<F>;
1008    type Error = FerroError;
1009
1010    /// Project data onto the learned NMF components.
1011    ///
1012    /// Mirrors sklearn `NMF.transform` (`sklearn/decomposition/_nmf.py:1213`):
1013    /// `W = _fit_transform(X, H=self.components_, update_H=False)[0]` — solve the
1014    /// non-negative least squares `min_{W>=0} ||X - W·H||²` for the FIXED fitted
1015    /// `H` via the same coordinate descent. With `solver="cd"`, `W` is
1016    /// initialised to ZEROS (`_nmf.py:1254`) and only `W` is updated
1017    /// (`update_H=False`), so `transform` re-solves `W` rather than returning the
1018    /// fitted `W` — both reach the convex NNLS optimum but, like sklearn itself,
1019    /// the re-solve has its own (looser) violation-ratio stop, so `transform(X)`
1020    /// differs slightly (~2e-5 here) from `fit_transform`'s co-optimised `W`.
1021    ///
1022    /// Uses `max_iter = 200`, `tol = 1e-4` (sklearn's `NMF` defaults).
1023    ///
1024    /// # Errors
1025    ///
1026    /// - [`FerroError::ShapeMismatch`] if the number of columns does not
1027    ///   match the number of features seen during fitting.
1028    /// - [`FerroError::InvalidParameter`] if any entry of `X` is negative.
1029    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1030        let n_features = self.components_.ncols();
1031        if x.ncols() != n_features {
1032            return Err(FerroError::ShapeMismatch {
1033                expected: vec![x.nrows(), n_features],
1034                actual: vec![x.nrows(), x.ncols()],
1035                context: "FittedNMF::transform".into(),
1036            });
1037        }
1038
1039        // Finiteness FIRST (before the non-negativity check): sklearn
1040        // `NMF.transform` likewise runs `_validate_data(force_all_finite=True)`
1041        // before `check_non_negative`, so a NaN/Inf raises the finiteness
1042        // `ValueError` (`utils/validation.py:147-154`) BEFORE the projection.
1043        // NaN AND infinity both rejected (#2289).
1044        reject_non_finite(x)?;
1045
1046        // Check non-negativity.
1047        for &val in x {
1048            if val < F::zero() {
1049                return Err(FerroError::InvalidParameter {
1050                    name: "X".into(),
1051                    reason: "NMF requires all entries in X to be non-negative".into(),
1052                });
1053            }
1054        }
1055
1056        let n_samples = x.nrows();
1057        let n_components = self.components_.nrows();
1058
1059        // sklearn `_check_w_h` inits W to zeros for the `cd` solver when
1060        // `update_H=False` (`_nmf.py:1253-1254`); H stays the fitted components_.
1061        let mut w = Array2::<F>::zeros((n_samples, n_components));
1062        let mut h = self.components_.clone();
1063
1064        // Solve W with H fixed (update_H=false) via the violation-ratio CD.
1065        solve_coordinate_descent(x, &mut w, &mut h, 200, 1e-4, false);
1066
1067        Ok(w)
1068    }
1069}
1070
1071// ---------------------------------------------------------------------------
1072// Pipeline integration (generic)
1073// ---------------------------------------------------------------------------
1074
1075impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for NMF<F> {
1076    /// Fit NMF using the pipeline interface.
1077    ///
1078    /// The `y` argument is ignored; NMF is unsupervised.
1079    ///
1080    /// # Errors
1081    ///
1082    /// Propagates errors from [`Fit::fit`].
1083    fn fit_pipeline(
1084        &self,
1085        x: &Array2<F>,
1086        _y: &Array1<F>,
1087    ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
1088        let fitted = self.fit(x, &())?;
1089        Ok(Box::new(fitted))
1090    }
1091}
1092
1093impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for FittedNMF<F> {
1094    /// Transform data using the pipeline interface.
1095    ///
1096    /// # Errors
1097    ///
1098    /// Propagates errors from [`Transform::transform`].
1099    fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
1100        self.transform(x)
1101    }
1102}
1103
1104// ---------------------------------------------------------------------------
1105// Tests
1106// ---------------------------------------------------------------------------
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111    use approx::assert_abs_diff_eq;
1112    use ndarray::array;
1113
1114    /// Helper: create a small non-negative dataset.
1115    fn small_dataset() -> Array2<f64> {
1116        array![
1117            [1.0, 2.0, 3.0],
1118            [4.0, 5.0, 6.0],
1119            [7.0, 8.0, 9.0],
1120            [10.0, 11.0, 12.0],
1121        ]
1122    }
1123
1124    /// Helper: create a larger non-negative dataset.
1125    fn medium_dataset() -> Array2<f64> {
1126        array![
1127            [5.0, 3.0, 0.0, 1.0],
1128            [4.0, 0.0, 0.0, 1.0],
1129            [1.0, 1.0, 0.0, 5.0],
1130            [1.0, 0.0, 0.0, 4.0],
1131            [0.0, 1.0, 5.0, 4.0],
1132            [0.0, 0.0, 4.0, 3.0],
1133        ]
1134    }
1135
1136    #[test]
1137    fn test_nmf_basic_fit() {
1138        let nmf = NMF::<f64>::new(2).with_random_state(42);
1139        let x = small_dataset();
1140        let fitted = nmf.fit(&x, &()).unwrap();
1141        assert_eq!(fitted.components().dim(), (2, 3));
1142    }
1143
1144    #[test]
1145    fn test_nmf_components_non_negative() {
1146        let nmf = NMF::<f64>::new(2).with_random_state(42);
1147        let x = small_dataset();
1148        let fitted = nmf.fit(&x, &()).unwrap();
1149        for &val in fitted.components() {
1150            assert!(
1151                val >= 0.0,
1152                "component value should be non-negative, got {val}"
1153            );
1154        }
1155    }
1156
1157    #[test]
1158    fn test_nmf_transform_dimensions() {
1159        let nmf = NMF::<f64>::new(2).with_random_state(42);
1160        let x = small_dataset();
1161        let fitted = nmf.fit(&x, &()).unwrap();
1162        let projected = fitted.transform(&x).unwrap();
1163        assert_eq!(projected.dim(), (4, 2));
1164    }
1165
1166    #[test]
1167    fn test_nmf_transform_non_negative() {
1168        let nmf = NMF::<f64>::new(2).with_random_state(42);
1169        let x = small_dataset();
1170        let fitted = nmf.fit(&x, &()).unwrap();
1171        let projected = fitted.transform(&x).unwrap();
1172        for &val in &projected {
1173            assert!(val >= 0.0, "W value should be non-negative, got {val}");
1174        }
1175    }
1176
1177    #[test]
1178    fn test_nmf_reconstruction_error_decreases() {
1179        let nmf_few = NMF::<f64>::new(2).with_random_state(42).with_max_iter(10);
1180        let nmf_many = NMF::<f64>::new(2).with_random_state(42).with_max_iter(200);
1181        let x = small_dataset();
1182        let fitted_few = nmf_few.fit(&x, &()).unwrap();
1183        let fitted_many = nmf_many.fit(&x, &()).unwrap();
1184        assert!(
1185            fitted_many.reconstruction_err() <= fitted_few.reconstruction_err() + 1e-6,
1186            "more iterations should reduce error: few={}, many={}",
1187            fitted_few.reconstruction_err(),
1188            fitted_many.reconstruction_err()
1189        );
1190    }
1191
1192    #[test]
1193    fn test_nmf_reconstruction_error_positive() {
1194        let nmf = NMF::<f64>::new(2).with_random_state(42);
1195        let x = small_dataset();
1196        let fitted = nmf.fit(&x, &()).unwrap();
1197        assert!(fitted.reconstruction_err() >= 0.0);
1198    }
1199
1200    #[test]
1201    fn test_nmf_coordinate_descent_solver() {
1202        let nmf = NMF::<f64>::new(2)
1203            .with_solver(NMFSolver::CoordinateDescent)
1204            .with_random_state(42);
1205        let x = medium_dataset();
1206        let fitted = nmf.fit(&x, &()).unwrap();
1207        assert_eq!(fitted.components().dim(), (2, 4));
1208        for &val in fitted.components() {
1209            assert!(val >= 0.0, "CD component should be non-negative, got {val}");
1210        }
1211    }
1212
1213    #[test]
1214    fn test_nmf_nndsvd_init() {
1215        let nmf = NMF::<f64>::new(2)
1216            .with_init(NMFInit::Nndsvd)
1217            .with_random_state(42);
1218        let x = medium_dataset();
1219        let fitted = nmf.fit(&x, &()).unwrap();
1220        assert_eq!(fitted.components().dim(), (2, 4));
1221        for &val in fitted.components() {
1222            assert!(
1223                val >= 0.0,
1224                "NNDSVD component should be non-negative, got {val}"
1225            );
1226        }
1227    }
1228
1229    #[test]
1230    fn test_nmf_cd_with_nndsvd() {
1231        let nmf = NMF::<f64>::new(2)
1232            .with_solver(NMFSolver::CoordinateDescent)
1233            .with_init(NMFInit::Nndsvd)
1234            .with_random_state(42);
1235        let x = medium_dataset();
1236        let fitted = nmf.fit(&x, &()).unwrap();
1237        assert_eq!(fitted.components().dim(), (2, 4));
1238    }
1239
1240    #[test]
1241    fn test_nmf_invalid_n_components_zero() {
1242        let nmf = NMF::<f64>::new(0);
1243        let x = small_dataset();
1244        assert!(nmf.fit(&x, &()).is_err());
1245    }
1246
1247    #[test]
1248    fn test_nmf_invalid_n_components_too_large() {
1249        let nmf = NMF::<f64>::new(10);
1250        let x = small_dataset(); // 4x3
1251        assert!(nmf.fit(&x, &()).is_err());
1252    }
1253
1254    #[test]
1255    fn test_nmf_negative_input_rejected() {
1256        let nmf = NMF::<f64>::new(1);
1257        let x = array![[1.0, -2.0], [3.0, 4.0]];
1258        assert!(nmf.fit(&x, &()).is_err());
1259    }
1260
1261    #[test]
1262    fn test_nmf_transform_shape_mismatch() {
1263        let nmf = NMF::<f64>::new(2).with_random_state(42);
1264        let x = small_dataset();
1265        let fitted = nmf.fit(&x, &()).unwrap();
1266        let x_bad = array![[1.0, 2.0]]; // wrong number of features
1267        assert!(fitted.transform(&x_bad).is_err());
1268    }
1269
1270    #[test]
1271    fn test_nmf_transform_negative_rejected() {
1272        let nmf = NMF::<f64>::new(2).with_random_state(42);
1273        let x = small_dataset();
1274        let fitted = nmf.fit(&x, &()).unwrap();
1275        let x_neg = array![[1.0, -2.0, 3.0]];
1276        assert!(fitted.transform(&x_neg).is_err());
1277    }
1278
1279    #[test]
1280    fn test_nmf_reproducibility() {
1281        let nmf1 = NMF::<f64>::new(2).with_random_state(42);
1282        let nmf2 = NMF::<f64>::new(2).with_random_state(42);
1283        let x = small_dataset();
1284        let fitted1 = nmf1.fit(&x, &()).unwrap();
1285        let fitted2 = nmf2.fit(&x, &()).unwrap();
1286        for (a, b) in fitted1.components().iter().zip(fitted2.components().iter()) {
1287            assert_abs_diff_eq!(a, b, epsilon = 1e-10);
1288        }
1289    }
1290
1291    #[test]
1292    fn test_nmf_single_component() {
1293        let nmf = NMF::<f64>::new(1).with_random_state(42);
1294        let x = small_dataset();
1295        let fitted = nmf.fit(&x, &()).unwrap();
1296        assert_eq!(fitted.components().nrows(), 1);
1297        let projected = fitted.transform(&x).unwrap();
1298        assert_eq!(projected.ncols(), 1);
1299    }
1300
1301    #[test]
1302    fn test_nmf_n_iter_positive() {
1303        let nmf = NMF::<f64>::new(2).with_random_state(42);
1304        let x = small_dataset();
1305        let fitted = nmf.fit(&x, &()).unwrap();
1306        assert!(fitted.n_iter() > 0);
1307    }
1308
1309    #[test]
1310    fn test_nmf_getters() {
1311        let nmf = NMF::<f64>::new(3)
1312            .with_max_iter(100)
1313            .with_tol(1e-5)
1314            .with_solver(NMFSolver::CoordinateDescent)
1315            .with_init(NMFInit::Nndsvd)
1316            .with_random_state(99);
1317        assert_eq!(nmf.n_components(), 3);
1318        assert_eq!(nmf.max_iter(), 100);
1319        assert_abs_diff_eq!(nmf.tol(), 1e-5);
1320        assert_eq!(nmf.solver(), NMFSolver::CoordinateDescent);
1321        assert_eq!(nmf.init(), NMFInit::Nndsvd);
1322        assert_eq!(nmf.random_state(), Some(99));
1323    }
1324
1325    #[test]
1326    fn test_nmf_f32() {
1327        let nmf = NMF::<f32>::new(1).with_random_state(42);
1328        let x: Array2<f32> = array![[1.0f32, 2.0], [3.0, 4.0], [5.0, 6.0]];
1329        let fitted = nmf.fit(&x, &()).unwrap();
1330        let projected = fitted.transform(&x).unwrap();
1331        assert_eq!(projected.ncols(), 1);
1332    }
1333
1334    #[test]
1335    fn test_nmf_zero_entries() {
1336        let nmf = NMF::<f64>::new(2).with_random_state(42);
1337        let x = array![[0.0, 0.0, 1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]];
1338        let fitted = nmf.fit(&x, &()).unwrap();
1339        assert_eq!(fitted.components().dim(), (2, 3));
1340    }
1341
1342    #[test]
1343    fn test_nmf_pipeline_integration() {
1344        use ferrolearn_core::pipeline::{FittedPipelineEstimator, Pipeline, PipelineEstimator};
1345        use ferrolearn_core::traits::Predict;
1346
1347        struct SumEstimator;
1348
1349        impl PipelineEstimator<f64> for SumEstimator {
1350            fn fit_pipeline(
1351                &self,
1352                _x: &Array2<f64>,
1353                _y: &Array1<f64>,
1354            ) -> Result<Box<dyn FittedPipelineEstimator<f64>>, FerroError> {
1355                Ok(Box::new(FittedSumEstimator))
1356            }
1357        }
1358
1359        struct FittedSumEstimator;
1360
1361        impl FittedPipelineEstimator<f64> for FittedSumEstimator {
1362            fn predict_pipeline(&self, x: &Array2<f64>) -> Result<Array1<f64>, FerroError> {
1363                let sums: Vec<f64> = x.rows().into_iter().map(|r| r.sum()).collect();
1364                Ok(Array1::from_vec(sums))
1365            }
1366        }
1367
1368        let pipeline = Pipeline::new()
1369            .transform_step("nmf", Box::new(NMF::<f64>::new(2).with_random_state(42)))
1370            .estimator_step("sum", Box::new(SumEstimator));
1371
1372        let x = small_dataset();
1373        let y = Array1::from_vec(vec![0.0, 1.0, 2.0, 3.0]);
1374
1375        let fitted = pipeline.fit(&x, &y).unwrap();
1376        let preds = fitted.predict(&x).unwrap();
1377        assert_eq!(preds.len(), 4);
1378    }
1379
1380    #[test]
1381    fn test_nmf_medium_dataset_mu() {
1382        let nmf = NMF::<f64>::new(3)
1383            .with_solver(NMFSolver::MultiplicativeUpdate)
1384            .with_random_state(42)
1385            .with_max_iter(500);
1386        let x = medium_dataset();
1387        let fitted = nmf.fit(&x, &()).unwrap();
1388        assert_eq!(fitted.components().dim(), (3, 4));
1389        // Reconstruction error should be reasonable.
1390        assert!(
1391            fitted.reconstruction_err() < 10.0,
1392            "reconstruction error too large: {}",
1393            fitted.reconstruction_err()
1394        );
1395    }
1396
1397    #[test]
1398    fn test_nmf_insufficient_samples() {
1399        let nmf = NMF::<f64>::new(1);
1400        let x = Array2::<f64>::zeros((0, 3));
1401        assert!(nmf.fit(&x, &()).is_err());
1402    }
1403
1404    /// NNDSVD init (isolated) matches sklearn's `_initialize_nmf(X, 3,
1405    /// init='nndsvd')` element-wise. Expected `H`/`W` are the LIVE sklearn 1.5.2
1406    /// oracle (`_initialize_nmf`, run from /tmp, R-CHAR-3) on the deterministic
1407    /// `(RandomState(0).rand(12,6)*5).round(3)` fixture — the SVD pos/neg split
1408    /// + `lbd = sqrt(S[j]*sigma)` scaling, NOT copied from ferrolearn.
1409    #[test]
1410    fn test_nmf_nndsvd_init_matches_sklearn() {
1411        let x: Array2<f64> = array![
1412            [2.744, 3.576, 3.014, 2.724, 2.118, 3.229],
1413            [2.188, 4.459, 4.818, 1.917, 3.959, 2.644],
1414            [2.84, 4.628, 0.355, 0.436, 0.101, 4.163],
1415            [3.891, 4.35, 4.893, 3.996, 2.307, 3.903],
1416            [0.591, 3.2, 0.717, 4.723, 2.609, 2.073],
1417            [1.323, 3.871, 2.281, 2.842, 0.094, 3.088],
1418            [3.06, 3.085, 4.719, 3.409, 1.798, 2.185],
1419            [3.488, 0.301, 3.334, 3.353, 1.052, 0.645],
1420            [1.577, 1.819, 2.851, 2.193, 4.942, 0.51],
1421            [1.044, 0.807, 3.266, 1.266, 2.332, 1.222],
1422            [0.795, 0.552, 3.282, 0.691, 0.983, 1.844],
1423            [4.105, 0.486, 4.19, 0.48, 4.882, 2.343],
1424        ];
1425        let result = init_nndsvd(&x, 3);
1426        assert!(
1427            result.is_ok(),
1428            "init_nndsvd should succeed on the 12x6 fixture"
1429        );
1430        let (w, h) = match result {
1431            Ok(pair) => pair,
1432            Err(_) => return,
1433        };
1434        assert_eq!(h.dim(), (3, 6));
1435        assert_eq!(w.dim(), (12, 3));
1436        // sklearn 1.5.2 oracle: _initialize_nmf(X, 3, init='nndsvd').
1437        let sk_h: [[f64; 6]; 3] = [
1438            [
1439                1.7741112747292909,
1440                2.0242809154746326,
1441                2.3973352370908354,
1442                1.7673562411021053,
1443                1.7161976904877514,
1444                1.750048077389621,
1445            ],
1446            [
1447                0.0,
1448                1.5897115406013913,
1449                0.0,
1450                0.41349076122979694,
1451                0.0,
1452                1.0036389906737029,
1453            ],
1454            [
1455                0.0,
1456                0.00561171974406682,
1457                0.0,
1458                1.6761257358883248,
1459                0.3407178035232749,
1460                0.0,
1461            ],
1462        ];
1463        for (k, row) in sk_h.iter().enumerate() {
1464            for (j, &expected) in row.iter().enumerate() {
1465                assert_abs_diff_eq!(h[[k, j]], expected, epsilon = 1e-9);
1466            }
1467        }
1468        // W column 0 (leading triplet: sqrt(S[0])*|U[:,0]|).
1469        let sk_w_col0_head = [1.5111518021294372, 1.7749072337282812, 1.0616211988216013];
1470        for (i, &expected) in sk_w_col0_head.iter().enumerate() {
1471            assert_abs_diff_eq!(w[[i, 0]], expected, epsilon = 1e-9);
1472        }
1473    }
1474
1475    #[test]
1476    fn test_nmf_more_components_lower_error() {
1477        let nmf1 = NMF::<f64>::new(1).with_random_state(42).with_max_iter(300);
1478        let nmf2 = NMF::<f64>::new(2).with_random_state(42).with_max_iter(300);
1479        let x = medium_dataset();
1480        let fitted1 = nmf1.fit(&x, &()).unwrap();
1481        let fitted2 = nmf2.fit(&x, &()).unwrap();
1482        assert!(
1483            fitted2.reconstruction_err() <= fitted1.reconstruction_err() + 1e-6,
1484            "more components should reduce error: 1comp={}, 2comp={}",
1485            fitted1.reconstruction_err(),
1486            fitted2.reconstruction_err()
1487        );
1488    }
1489}