Skip to main content

sklears_decomposition/
nmf.rs

1//! Non-negative Matrix Factorization (NMF) implementation.
2//!
3//! NMF factorizes a non-negative matrix X into two non-negative matrices W and H
4//! such that X ≈ WH. This is useful for dimensionality reduction and feature
5//! extraction when the data is inherently non-negative.
6
7use scirs2_core::ndarray::Array2;
8use scirs2_core::random::rngs::StdRng;
9use scirs2_core::random::{thread_rng, RngExt, SeedableRng};
10#[cfg(feature = "serde")]
11use serde::{Deserialize, Serialize};
12use sklears_core::{
13    error::{Result, SklearsError},
14    traits::{Fit, Transform, Untrained},
15};
16
17/// NMF algorithm variants
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
20pub enum NMFSolver {
21    /// Multiplicative Update algorithm
22    MultiplicativeUpdate,
23    /// Coordinate Descent algorithm
24    #[default]
25    CoordinateDescent,
26    /// Alternating Least Squares algorithm
27    ALS,
28    /// Semi-NMF for mixed-sign data
29    SemiNMF,
30    /// Online NMF for streaming data
31    OnlineNMF,
32}
33
34/// Initialization methods for NMF
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
37pub enum NMFInit {
38    /// Random initialization
39    #[default]
40    Random,
41    /// Non-negative Double Singular Value Decomposition
42    Nndsvd,
43    /// NNDSVD with zeros filled with average
44    NndsvdA,
45    /// NNDSVD with zeros filled with small random values
46    NndsvdAr,
47}
48
49/// Regularization type for NMF
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub enum NMFRegularization {
53    /// No regularization
54    #[default]
55    None,
56    /// L1 regularization
57    L1,
58    /// L2 regularization
59    L2,
60    /// Both L1 and L2 regularization
61    Both,
62}
63
64/// Non-negative Matrix Factorization transformer
65#[derive(Debug, Clone)]
66pub struct NMF<State = Untrained> {
67    /// Number of components/topics
68    pub n_components: usize,
69    /// Initialization method
70    pub init: NMFInit,
71    /// Solver algorithm
72    pub solver: NMFSolver,
73    /// Regularization type
74    pub regularization: NMFRegularization,
75    /// L1 regularization strength
76    pub alpha_w: f64,
77    /// L2 regularization strength
78    pub alpha_h: f64,
79    /// L1 ratio for elastic net regularization
80    pub l1_ratio: f64,
81    /// Maximum number of iterations
82    pub max_iter: usize,
83    /// Tolerance for convergence
84    pub tol: f64,
85    /// Random state for reproducibility
86    pub random_state: Option<u64>,
87
88    /// Trained state
89    state: State,
90}
91
92/// Trained NMF state
93#[derive(Debug, Clone)]
94#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
95pub struct TrainedNMF {
96    /// Components matrix (H) - shape (n_components, n_features)
97    pub components: Array2<f64>,
98    /// Basis matrix (W) - shape (n_samples, n_components)
99    pub w_matrix: Array2<f64>,
100    /// Number of features in training data
101    pub n_features_in: usize,
102    /// Number of components
103    pub n_components: usize,
104    /// Number of iterations performed
105    pub n_iter: usize,
106    /// Final reconstruction error
107    pub reconstruction_err: f64,
108}
109
110impl NMF<Untrained> {
111    /// Create a new NMF transformer
112    pub fn new(n_components: usize) -> Self {
113        Self {
114            n_components,
115            init: NMFInit::Random,
116            solver: NMFSolver::CoordinateDescent,
117            regularization: NMFRegularization::None,
118            alpha_w: 0.0,
119            alpha_h: 0.0,
120            l1_ratio: 0.0,
121            max_iter: 200,
122            tol: 1e-4,
123            random_state: None,
124            state: Untrained,
125        }
126    }
127
128    /// Set the initialization method
129    pub fn init(mut self, init: NMFInit) -> Self {
130        self.init = init;
131        self
132    }
133
134    /// Set the solver algorithm
135    pub fn solver(mut self, solver: NMFSolver) -> Self {
136        self.solver = solver;
137        self
138    }
139
140    /// Set regularization parameters
141    pub fn regularization(mut self, regularization: NMFRegularization, alpha: f64) -> Self {
142        self.regularization = regularization;
143        match regularization {
144            NMFRegularization::L1 => {
145                self.alpha_w = alpha;
146                self.alpha_h = alpha;
147            }
148            NMFRegularization::L2 => {
149                self.alpha_w = alpha;
150                self.alpha_h = alpha;
151            }
152            NMFRegularization::Both => {
153                self.alpha_w = alpha;
154                self.alpha_h = alpha;
155            }
156            NMFRegularization::None => {}
157        }
158        self
159    }
160
161    /// Set L1 ratio for elastic net
162    pub fn l1_ratio(mut self, l1_ratio: f64) -> Self {
163        self.l1_ratio = l1_ratio;
164        self
165    }
166
167    /// Set maximum iterations
168    pub fn max_iter(mut self, max_iter: usize) -> Self {
169        self.max_iter = max_iter;
170        self
171    }
172
173    /// Set tolerance
174    pub fn tol(mut self, tol: f64) -> Self {
175        self.tol = tol;
176        self
177    }
178
179    /// Set random state
180    pub fn random_state(mut self, random_state: u64) -> Self {
181        self.random_state = Some(random_state);
182        self
183    }
184}
185
186impl Fit<Array2<f64>, ()> for NMF<Untrained> {
187    type Fitted = NMF<TrainedNMF>;
188
189    fn fit(self, x: &Array2<f64>, _y: &()) -> Result<Self::Fitted> {
190        let (n_samples, n_features) = x.dim();
191
192        // Check for non-negative data (except for Semi-NMF which allows mixed-sign data)
193        if self.solver != NMFSolver::SemiNMF {
194            for &val in x.iter() {
195                if val < 0.0 {
196                    return Err(SklearsError::InvalidInput(
197                        "NMF requires non-negative data (use Semi-NMF for mixed-sign data)"
198                            .to_string(),
199                    ));
200                }
201            }
202        }
203
204        if self.n_components > n_features.min(n_samples) {
205            return Err(SklearsError::InvalidInput(
206                "n_components cannot be larger than min(n_samples, n_features)".to_string(),
207            ));
208        }
209
210        // Initialize random number generator with optional seed for reproducibility
211        let mut rng = if let Some(seed) = self.random_state {
212            StdRng::seed_from_u64(seed)
213        } else {
214            StdRng::from_rng(&mut thread_rng())
215        };
216
217        // Initialize W and H matrices
218        let (mut w, mut h) = self.initialize_matrices(x, &mut rng)?;
219
220        // Run NMF algorithm
221        let (n_iter, reconstruction_err) = match self.solver {
222            NMFSolver::MultiplicativeUpdate => self.multiplicative_update(x, &mut w, &mut h)?,
223            NMFSolver::CoordinateDescent => self.coordinate_descent(x, &mut w, &mut h)?,
224            NMFSolver::ALS => self.alternating_least_squares(x, &mut w, &mut h)?,
225            NMFSolver::SemiNMF => self.semi_nmf_update(x, &mut w, &mut h)?,
226            NMFSolver::OnlineNMF => self.online_nmf_update(x, &mut w, &mut h)?,
227        };
228
229        Ok(NMF {
230            n_components: self.n_components,
231            init: self.init,
232            solver: self.solver,
233            regularization: self.regularization,
234            alpha_w: self.alpha_w,
235            alpha_h: self.alpha_h,
236            l1_ratio: self.l1_ratio,
237            max_iter: self.max_iter,
238            tol: self.tol,
239            random_state: self.random_state,
240            state: TrainedNMF {
241                components: h,
242                w_matrix: w,
243                n_features_in: n_features,
244                n_components: self.n_components,
245                n_iter,
246                reconstruction_err,
247            },
248        })
249    }
250}
251
252impl Transform<Array2<f64>, Array2<f64>> for NMF<TrainedNMF> {
253    fn transform(&self, x: &Array2<f64>) -> Result<Array2<f64>> {
254        let (n_samples, n_features) = x.dim();
255
256        if n_features != self.state.n_features_in {
257            return Err(SklearsError::FeatureMismatch {
258                expected: self.state.n_features_in,
259                actual: n_features,
260            });
261        }
262
263        // Check for non-negative data (except for Semi-NMF which allows mixed-sign data)
264        if self.solver != NMFSolver::SemiNMF {
265            for &val in x.iter() {
266                if val < 0.0 {
267                    return Err(SklearsError::InvalidInput(
268                        "NMF requires non-negative data (use Semi-NMF for mixed-sign data)"
269                            .to_string(),
270                    ));
271                }
272            }
273        }
274
275        // Solve for W given fixed H: X ≈ WH, so W = argmin_W ||X - WH||_F^2 s.t. W >= 0
276        // where H is the learned components matrix
277        let mut w = Array2::from_elem((n_samples, self.state.n_components), 0.1);
278
279        // Use coordinate descent to solve for non-negative W
280        for _ in 0..100 {
281            // Fixed number of iterations for transform
282            for k in 0..self.state.n_components {
283                // Compute residual for component k
284                let mut residual = x.clone();
285                for j in 0..self.state.n_components {
286                    if j != k {
287                        let wj = w.column(j);
288                        let hj = self.state.components.row(j);
289                        for i in 0..residual.nrows() {
290                            for l in 0..residual.ncols() {
291                                residual[[i, l]] -= wj[i] * hj[l];
292                            }
293                        }
294                    }
295                }
296
297                // Update W[:,k]
298                let hk = self.state.components.row(k);
299                let hk_norm_sq = hk.mapv(|x| x * x).sum();
300
301                if hk_norm_sq > 1e-12 {
302                    for i in 0..w.nrows() {
303                        let numerator = residual.row(i).dot(&hk);
304                        w[[i, k]] = (numerator / hk_norm_sq).max(0.0);
305                    }
306                }
307            }
308        }
309
310        Ok(w)
311    }
312}
313
314impl NMF<Untrained> {
315    /// Initialize W and H matrices
316    fn initialize_matrices(
317        &self,
318        x: &Array2<f64>,
319        rng: &mut impl RngExt,
320    ) -> Result<(Array2<f64>, Array2<f64>)> {
321        let (n_samples, n_features) = x.dim();
322
323        match self.init {
324            NMFInit::Random => {
325                let w = self.random_init((n_samples, self.n_components), rng);
326                let h = self.random_init((self.n_components, n_features), rng);
327                Ok((w, h))
328            }
329            NMFInit::Nndsvd | NMFInit::NndsvdA | NMFInit::NndsvdAr => self.nndsvd_init(x, rng),
330        }
331    }
332
333    /// Random initialization
334    fn random_init(&self, shape: (usize, usize), rng: &mut impl RngExt) -> Array2<f64> {
335        let (rows, cols) = shape;
336        let mut matrix = Array2::zeros((rows, cols));
337
338        for i in 0..rows {
339            for j in 0..cols {
340                matrix[[i, j]] = rng.random::<f64>();
341            }
342        }
343
344        matrix
345    }
346
347    /// NNDSVD initialization (simplified version)
348    fn nndsvd_init(
349        &self,
350        x: &Array2<f64>,
351        rng: &mut impl RngExt,
352    ) -> Result<(Array2<f64>, Array2<f64>)> {
353        let (n_samples, n_features) = x.dim();
354
355        // For simplicity, we'll use a basic SVD approximation
356        // In a full implementation, we would use proper SVD decomposition
357
358        let avg = x.mean().unwrap_or(0.1);
359        let mut w = Array2::from_elem((n_samples, self.n_components), avg.sqrt());
360        let mut h = Array2::from_elem((self.n_components, n_features), avg.sqrt());
361
362        // Add some randomness based on the initialization variant
363        match self.init {
364            NMFInit::NndsvdA => {
365                // Fill zeros with average
366                for i in 0..n_samples {
367                    for j in 0..self.n_components {
368                        if w[[i, j]] == 0.0 {
369                            w[[i, j]] = avg;
370                        }
371                    }
372                }
373                for i in 0..self.n_components {
374                    for j in 0..n_features {
375                        if h[[i, j]] == 0.0 {
376                            h[[i, j]] = avg;
377                        }
378                    }
379                }
380            }
381            NMFInit::NndsvdAr => {
382                // Fill zeros with small random values
383                for i in 0..n_samples {
384                    for j in 0..self.n_components {
385                        if w[[i, j]] == 0.0 {
386                            w[[i, j]] = avg * rng.random::<f64>() * 0.01;
387                        }
388                    }
389                }
390                for i in 0..self.n_components {
391                    for j in 0..n_features {
392                        if h[[i, j]] == 0.0 {
393                            h[[i, j]] = avg * rng.random::<f64>() * 0.01;
394                        }
395                    }
396                }
397            }
398            _ => {}
399        }
400
401        Ok((w, h))
402    }
403
404    /// Multiplicative update algorithm with regularization support
405    fn multiplicative_update(
406        &self,
407        x: &Array2<f64>,
408        w: &mut Array2<f64>,
409        h: &mut Array2<f64>,
410    ) -> Result<(usize, f64)> {
411        let mut prev_error = f64::INFINITY;
412        let mut n_iter = 0;
413
414        for iter in 0..self.max_iter {
415            n_iter = iter + 1;
416
417            // Update H with regularization: H := H ⊙ (W^T X) / (W^T W H + α_h * R_h)
418            self.update_h_multiplicative_regularized(x, w, h)?;
419
420            // Update W with regularization: W := W ⊙ (X H^T) / (W H H^T + α_w * R_w)
421            self.update_w_multiplicative_regularized(x, w, h)?;
422
423            // Calculate reconstruction error including regularization
424            let reconstruction = w.dot(h);
425            let mut error = self.frobenius_norm_squared(&(x - &reconstruction));
426
427            // Add regularization terms to error
428            error += self.compute_regularization_penalty(w, h);
429
430            // Check convergence
431            if (prev_error - error).abs() < self.tol {
432                break;
433            }
434
435            prev_error = error;
436        }
437
438        Ok((n_iter, prev_error))
439    }
440
441    /// Update H matrix with multiplicative update and regularization
442    fn update_h_multiplicative_regularized(
443        &self,
444        x: &Array2<f64>,
445        w: &Array2<f64>,
446        h: &mut Array2<f64>,
447    ) -> Result<()> {
448        let numerator_h = w.t().dot(x);
449        let base_denominator_h = w.t().dot(w).dot(h);
450
451        for i in 0..h.nrows() {
452            for j in 0..h.ncols() {
453                let mut denominator = base_denominator_h[[i, j]];
454
455                // Add regularization terms to denominator
456                match self.regularization {
457                    NMFRegularization::L1 => {
458                        denominator += self.alpha_h;
459                    }
460                    NMFRegularization::L2 => {
461                        denominator += 2.0 * self.alpha_h * h[[i, j]];
462                    }
463                    NMFRegularization::Both => {
464                        // Elastic net: L1 + L2
465                        denominator += self.l1_ratio * self.alpha_h
466                            + (1.0 - self.l1_ratio) * 2.0 * self.alpha_h * h[[i, j]];
467                    }
468                    NMFRegularization::None => {}
469                }
470
471                if denominator > 1e-12 {
472                    h[[i, j]] *= numerator_h[[i, j]] / denominator;
473                    h[[i, j]] = h[[i, j]].max(1e-12); // Ensure non-negativity
474                }
475            }
476        }
477        Ok(())
478    }
479
480    /// Update W matrix with multiplicative update and regularization
481    fn update_w_multiplicative_regularized(
482        &self,
483        x: &Array2<f64>,
484        w: &mut Array2<f64>,
485        h: &Array2<f64>,
486    ) -> Result<()> {
487        let numerator_w = x.dot(&h.t());
488        let base_denominator_w = w.dot(h).dot(&h.t());
489
490        for i in 0..w.nrows() {
491            for j in 0..w.ncols() {
492                let mut denominator = base_denominator_w[[i, j]];
493
494                // Add regularization terms to denominator
495                match self.regularization {
496                    NMFRegularization::L1 => {
497                        denominator += self.alpha_w;
498                    }
499                    NMFRegularization::L2 => {
500                        denominator += 2.0 * self.alpha_w * w[[i, j]];
501                    }
502                    NMFRegularization::Both => {
503                        // Elastic net: L1 + L2
504                        denominator += self.l1_ratio * self.alpha_w
505                            + (1.0 - self.l1_ratio) * 2.0 * self.alpha_w * w[[i, j]];
506                    }
507                    NMFRegularization::None => {}
508                }
509
510                if denominator > 1e-12 {
511                    w[[i, j]] *= numerator_w[[i, j]] / denominator;
512                    w[[i, j]] = w[[i, j]].max(1e-12); // Ensure non-negativity
513                }
514            }
515        }
516        Ok(())
517    }
518
519    /// Compute regularization penalty for the objective function
520    fn compute_regularization_penalty(&self, w: &Array2<f64>, h: &Array2<f64>) -> f64 {
521        let mut penalty = 0.0;
522
523        match self.regularization {
524            NMFRegularization::L1 => {
525                // L1 penalty: α_w * ||W||_1 + α_h * ||H||_1
526                penalty += self.alpha_w * w.mapv(|x| x.abs()).sum();
527                penalty += self.alpha_h * h.mapv(|x| x.abs()).sum();
528            }
529            NMFRegularization::L2 => {
530                // L2 penalty: α_w * ||W||_F^2 + α_h * ||H||_F^2
531                penalty += self.alpha_w * w.mapv(|x| x * x).sum();
532                penalty += self.alpha_h * h.mapv(|x| x * x).sum();
533            }
534            NMFRegularization::Both => {
535                // Elastic net penalty
536                let l1_penalty = w.mapv(|x| x.abs()).sum() + h.mapv(|x| x.abs()).sum();
537                let l2_penalty = w.mapv(|x| x * x).sum() + h.mapv(|x| x * x).sum();
538                penalty += self.l1_ratio * (self.alpha_w + self.alpha_h) * l1_penalty
539                    + (1.0 - self.l1_ratio) * (self.alpha_w + self.alpha_h) * l2_penalty;
540            }
541            NMFRegularization::None => {}
542        }
543
544        penalty
545    }
546
547    /// Alternating Least Squares (ALS) algorithm for NMF
548    ///
549    /// This algorithm alternates between solving least squares problems for W and H
550    /// while enforcing non-negativity constraints using projected gradient methods.
551    fn alternating_least_squares(
552        &self,
553        x: &Array2<f64>,
554        w: &mut Array2<f64>,
555        h: &mut Array2<f64>,
556    ) -> Result<(usize, f64)> {
557        let mut prev_error = f64::INFINITY;
558        let mut n_iter = 0;
559
560        for iter in 0..self.max_iter {
561            n_iter = iter + 1;
562
563            // Update H using least squares: min_H ||X - WH||_F^2 s.t. H >= 0
564            self.update_h_als(x, w, h)?;
565
566            // Update W using least squares: min_W ||X - WH||_F^2 s.t. W >= 0
567            self.update_w_als(x, w, h)?;
568
569            // Calculate reconstruction error
570            let reconstruction = w.dot(h);
571            let error = self.frobenius_norm_squared(&(x - &reconstruction));
572
573            // Check convergence
574            if (prev_error - error).abs() < self.tol {
575                break;
576            }
577
578            prev_error = error;
579        }
580
581        Ok((n_iter, prev_error))
582    }
583
584    /// Update H matrix using projected least squares
585    ///
586    /// Solves: min_H ||X - WH||_F^2 s.t. H >= 0
587    /// This is equivalent to solving: min_H ||W^T(X - WH)||_F^2 s.t. H >= 0
588    /// Which gives us the normal equations: (W^T W) H = W^T X with non-negativity constraints
589    fn update_h_als(&self, x: &Array2<f64>, w: &Array2<f64>, h: &mut Array2<f64>) -> Result<()> {
590        // Compute W^T W (Gram matrix)
591        let wtw = w.t().dot(w);
592
593        // Compute W^T X (right-hand side)
594        let wtx = w.t().dot(x);
595
596        // Solve (W^T W) H = W^T X for each column of H using projected gradient descent
597        for j in 0..h.ncols() {
598            let mut h_col = h.column(j).to_owned();
599            let rhs = wtx.column(j);
600
601            // Projected gradient descent for this column
602            let step_size = 0.01; // Fixed step size - could be adaptive
603            for _gd_iter in 0..50 {
604                // Inner iterations for gradient descent
605                // Compute gradient: (W^T W) h_col - rhs
606                let gradient = wtw.dot(&h_col) - rhs;
607
608                // Gradient descent step
609                h_col = &h_col - step_size * &gradient;
610
611                // Project onto non-negative orthant
612                h_col.mapv_inplace(|x| x.max(0.0));
613            }
614
615            // Update the column in H
616            h.column_mut(j).assign(&h_col);
617        }
618
619        Ok(())
620    }
621
622    /// Update W matrix using projected least squares
623    ///
624    /// Solves: min_W ||X - WH||_F^2 s.t. W >= 0
625    /// This is equivalent to solving: min_W ||(X - WH)H^T||_F^2 s.t. W >= 0
626    /// Which gives us the normal equations: W (H H^T) = X H^T with non-negativity constraints
627    fn update_w_als(&self, x: &Array2<f64>, w: &mut Array2<f64>, h: &Array2<f64>) -> Result<()> {
628        // Compute H H^T (Gram matrix)
629        let hht = h.dot(&h.t());
630
631        // Compute X H^T (right-hand side)
632        let xht = x.dot(&h.t());
633
634        // Solve W (H H^T) = X H^T for each row of W using projected gradient descent
635        for i in 0..w.nrows() {
636            let mut w_row = w.row(i).to_owned();
637            let rhs = xht.row(i);
638
639            // Projected gradient descent for this row
640            let step_size = 0.01; // Fixed step size - could be adaptive
641            for _gd_iter in 0..50 {
642                // Inner iterations for gradient descent
643                // Compute gradient: w_row (H H^T) - rhs
644                let gradient = w_row.dot(&hht) - rhs;
645
646                // Gradient descent step
647                w_row = &w_row - step_size * &gradient;
648
649                // Project onto non-negative orthant
650                w_row.mapv_inplace(|x| x.max(0.0));
651            }
652
653            // Update the row in W
654            w.row_mut(i).assign(&w_row);
655        }
656
657        Ok(())
658    }
659
660    /// Coordinate descent algorithm with L1 regularization support
661    fn coordinate_descent(
662        &self,
663        x: &Array2<f64>,
664        w: &mut Array2<f64>,
665        h: &mut Array2<f64>,
666    ) -> Result<(usize, f64)> {
667        let mut prev_error = f64::INFINITY;
668        let mut n_iter = 0;
669
670        for iter in 0..self.max_iter {
671            n_iter = iter + 1;
672
673            // Update W column by column with regularization
674            for k in 0..self.n_components {
675                // Compute residual
676                let mut residual = x.clone();
677                for j in 0..self.n_components {
678                    if j != k {
679                        let wj = w.column(j);
680                        let hj = h.row(j);
681                        for i in 0..residual.nrows() {
682                            for l in 0..residual.ncols() {
683                                residual[[i, l]] -= wj[i] * hj[l];
684                            }
685                        }
686                    }
687                }
688
689                // Update W[:,k] with regularization
690                let hk = h.row(k);
691                let hk_norm_sq = hk.mapv(|x| x * x).sum();
692
693                if hk_norm_sq > 1e-12 {
694                    for i in 0..w.nrows() {
695                        let numerator = residual.row(i).dot(&hk);
696                        let raw_update = numerator / hk_norm_sq;
697
698                        // Apply regularization (soft thresholding for L1)
699                        w[[i, k]] = self.apply_soft_thresholding_w(raw_update);
700                    }
701                }
702
703                // Update H[k,:] with regularization
704                let wk = w.column(k);
705                let wk_norm_sq = wk.mapv(|x| x * x).sum();
706
707                if wk_norm_sq > 1e-12 {
708                    for j in 0..h.ncols() {
709                        let numerator = wk.dot(&residual.column(j));
710                        let raw_update = numerator / wk_norm_sq;
711
712                        // Apply regularization (soft thresholding for L1)
713                        h[[k, j]] = self.apply_soft_thresholding_h(raw_update);
714                    }
715                }
716            }
717
718            // Calculate reconstruction error including regularization
719            let reconstruction = w.dot(h);
720            let mut error = self.frobenius_norm_squared(&(x - &reconstruction));
721            error += self.compute_regularization_penalty(w, h);
722
723            // Check convergence
724            if (prev_error - error).abs() < self.tol {
725                break;
726            }
727
728            prev_error = error;
729        }
730
731        Ok((n_iter, prev_error))
732    }
733
734    /// Apply soft thresholding for L1 regularization on W matrix elements
735    fn apply_soft_thresholding_w(&self, value: f64) -> f64 {
736        match self.regularization {
737            NMFRegularization::L1 => {
738                // Soft thresholding: sign(x) * max(0, |x| - α)
739                let threshold = self.alpha_w;
740                if value > threshold {
741                    value - threshold
742                } else if value < -threshold {
743                    value + threshold
744                } else {
745                    0.0
746                }
747            }
748            NMFRegularization::L2 => {
749                // L2 regularization: x / (1 + α)
750                value / (1.0 + self.alpha_w)
751            }
752            NMFRegularization::Both => {
753                // Elastic net: combine L1 and L2
754                let l1_threshold = self.l1_ratio * self.alpha_w;
755                let l2_factor = 1.0 + (1.0 - self.l1_ratio) * self.alpha_w;
756
757                let soft_thresholded = if value > l1_threshold {
758                    value - l1_threshold
759                } else if value < -l1_threshold {
760                    value + l1_threshold
761                } else {
762                    0.0
763                };
764
765                soft_thresholded / l2_factor
766            }
767            NMFRegularization::None => value,
768        }
769        .max(0.0) // Ensure non-negativity
770    }
771
772    /// Apply soft thresholding for L1 regularization on H matrix elements
773    fn apply_soft_thresholding_h(&self, value: f64) -> f64 {
774        match self.regularization {
775            NMFRegularization::L1 => {
776                // Soft thresholding: sign(x) * max(0, |x| - α)
777                let threshold = self.alpha_h;
778                if value > threshold {
779                    value - threshold
780                } else if value < -threshold {
781                    value + threshold
782                } else {
783                    0.0
784                }
785            }
786            NMFRegularization::L2 => {
787                // L2 regularization: x / (1 + α)
788                value / (1.0 + self.alpha_h)
789            }
790            NMFRegularization::Both => {
791                // Elastic net: combine L1 and L2
792                let l1_threshold = self.l1_ratio * self.alpha_h;
793                let l2_factor = 1.0 + (1.0 - self.l1_ratio) * self.alpha_h;
794
795                let soft_thresholded = if value > l1_threshold {
796                    value - l1_threshold
797                } else if value < -l1_threshold {
798                    value + l1_threshold
799                } else {
800                    0.0
801                };
802
803                soft_thresholded / l2_factor
804            }
805            NMFRegularization::None => value,
806        }
807        .max(0.0) // Ensure non-negativity
808    }
809
810    /// Compute Frobenius norm squared
811    fn frobenius_norm_squared(&self, matrix: &Array2<f64>) -> f64 {
812        matrix.mapv(|x| x * x).sum()
813    }
814
815    /// Semi-NMF algorithm for mixed-sign data
816    ///
817    /// Semi-NMF allows the data matrix X to have negative values but enforces
818    /// non-negativity constraints on the factor matrices W and H.
819    /// The algorithm factorizes X ≈ WH where W >= 0 and H >= 0, but X can contain negative values.
820    fn semi_nmf_update(
821        &self,
822        x: &Array2<f64>,
823        w: &mut Array2<f64>,
824        h: &mut Array2<f64>,
825    ) -> Result<(usize, f64)> {
826        let mut prev_error = f64::INFINITY;
827        let mut n_iter = 0;
828
829        for iter in 0..self.max_iter {
830            n_iter = iter + 1;
831
832            // Update H: min_H ||X - WH||_F^2 s.t. H >= 0
833            self.update_h_semi_nmf(x, w, h)?;
834
835            // Update W: min_W ||X - WH||_F^2 s.t. W >= 0
836            self.update_w_semi_nmf(x, w, h)?;
837
838            // Compute reconstruction error
839            let reconstruction = w.dot(h);
840            let error = (x - &reconstruction).mapv(|x| x * x).sum().sqrt();
841
842            // Check convergence
843            if (prev_error - error).abs() < self.tol {
844                break;
845            }
846            prev_error = error;
847        }
848
849        Ok((n_iter, prev_error))
850    }
851
852    /// Update H matrix for Semi-NMF using multiplicative update rule
853    fn update_h_semi_nmf(
854        &self,
855        x: &Array2<f64>,
856        w: &Array2<f64>,
857        h: &mut Array2<f64>,
858    ) -> Result<()> {
859        let wt = w.t();
860        let wt_x = wt.dot(x);
861        let wt_w = wt.dot(w);
862        let wt_w_h = wt_w.dot(h);
863
864        // Separate positive and negative parts of W^T X
865        let mut wt_x_pos = Array2::zeros(wt_x.dim());
866        let mut wt_x_neg = Array2::zeros(wt_x.dim());
867
868        for i in 0..wt_x.nrows() {
869            for j in 0..wt_x.ncols() {
870                if wt_x[[i, j]] >= 0.0 {
871                    wt_x_pos[[i, j]] = wt_x[[i, j]];
872                } else {
873                    wt_x_neg[[i, j]] = -wt_x[[i, j]];
874                }
875            }
876        }
877
878        // Multiplicative update rule for Semi-NMF: H_ij = H_ij * (W^T X)^+_ij / ((W^T W H + (W^T X)^-)_ij + eps)
879        for i in 0..h.nrows() {
880            for j in 0..h.ncols() {
881                let numerator = wt_x_pos[[i, j]];
882                let denominator = wt_w_h[[i, j]] + wt_x_neg[[i, j]] + 1e-10;
883                h[[i, j]] *= numerator / denominator;
884                h[[i, j]] = h[[i, j]].max(0.0); // Ensure non-negativity
885            }
886        }
887
888        Ok(())
889    }
890
891    /// Update W matrix for Semi-NMF using multiplicative update rule
892    fn update_w_semi_nmf(
893        &self,
894        x: &Array2<f64>,
895        w: &mut Array2<f64>,
896        h: &Array2<f64>,
897    ) -> Result<()> {
898        let ht = h.t();
899        let x_ht = x.dot(&ht);
900        let h_ht = h.dot(&ht);
901        let w_h_ht = w.dot(&h_ht);
902
903        // Separate positive and negative parts of X H^T
904        let mut x_ht_pos = Array2::zeros(x_ht.dim());
905        let mut x_ht_neg = Array2::zeros(x_ht.dim());
906
907        for i in 0..x_ht.nrows() {
908            for j in 0..x_ht.ncols() {
909                if x_ht[[i, j]] >= 0.0 {
910                    x_ht_pos[[i, j]] = x_ht[[i, j]];
911                } else {
912                    x_ht_neg[[i, j]] = -x_ht[[i, j]];
913                }
914            }
915        }
916
917        // Multiplicative update rule for Semi-NMF: W_ij = W_ij * (X H^T)^+_ij / ((W H H^T + (X H^T)^-)_ij + eps)
918        for i in 0..w.nrows() {
919            for j in 0..w.ncols() {
920                let numerator = x_ht_pos[[i, j]];
921                let denominator = w_h_ht[[i, j]] + x_ht_neg[[i, j]] + 1e-10;
922                w[[i, j]] *= numerator / denominator;
923                w[[i, j]] = w[[i, j]].max(0.0); // Ensure non-negativity
924            }
925        }
926
927        Ok(())
928    }
929
930    /// Online NMF algorithm for streaming data
931    ///
932    /// Online NMF processes data incrementally, updating the factorization as new data arrives.
933    /// This is useful for streaming applications where data cannot be stored in memory.
934    /// The algorithm uses stochastic gradient descent with momentum.
935    fn online_nmf_update(
936        &self,
937        x: &Array2<f64>,
938        w: &mut Array2<f64>,
939        h: &mut Array2<f64>,
940    ) -> Result<(usize, f64)> {
941        let (n_samples, _n_features) = x.dim();
942        let batch_size = (n_samples / 10).max(1); // Process in mini-batches
943        let learning_rate = 0.01;
944        let momentum = 0.9;
945
946        // Initialize momentum terms
947        let mut w_momentum = Array2::zeros(w.dim());
948        let mut h_momentum = Array2::zeros(h.dim());
949
950        let mut total_error = 0.0;
951        let mut n_iter = 0;
952
953        // Process data in mini-batches
954        for batch_start in (0..n_samples).step_by(batch_size) {
955            let batch_end = (batch_start + batch_size).min(n_samples);
956            let batch_indices: Vec<usize> = (batch_start..batch_end).collect();
957            let x_batch = x.select(scirs2_core::ndarray::Axis(0), &batch_indices);
958
959            n_iter += 1;
960
961            // Update H for this batch using online learning
962            self.update_h_online(&x_batch, w, h, &mut h_momentum, learning_rate, momentum)?;
963
964            // Update W for this batch using online learning
965            self.update_w_online(&x_batch, w, h, &mut w_momentum, learning_rate, momentum)?;
966
967            // Compute batch reconstruction error
968            let w_batch = w.select(scirs2_core::ndarray::Axis(0), &batch_indices);
969            let reconstruction = w_batch.dot(h);
970            let batch_error = (&x_batch - &reconstruction).mapv(|x| x * x).sum();
971            total_error += batch_error;
972        }
973
974        let avg_error = (total_error / n_samples as f64).sqrt();
975        Ok((n_iter, avg_error))
976    }
977
978    /// Update H matrix for Online NMF using stochastic gradient descent
979    fn update_h_online(
980        &self,
981        x_batch: &Array2<f64>,
982        w: &Array2<f64>,
983        h: &mut Array2<f64>,
984        h_momentum: &mut Array2<f64>,
985        learning_rate: f64,
986        momentum_coeff: f64,
987    ) -> Result<()> {
988        let (batch_size, _) = x_batch.dim();
989        let _wt = w.t();
990
991        // Compute gradients for H: ∇H = W^T(WH - X)
992        let wh = w.dot(h);
993        let wh_batch = wh.slice(scirs2_core::ndarray::s![0..batch_size, ..]);
994        let residual = &wh_batch.to_owned() - x_batch;
995        let w_batch = w.slice(scirs2_core::ndarray::s![0..batch_size, ..]);
996        let grad_h = w_batch.t().dot(&residual);
997
998        // Update H with momentum: H = H - lr * grad + momentum * prev_momentum
999        for i in 0..h.nrows() {
1000            for j in 0..h.ncols() {
1001                // Apply momentum
1002                h_momentum[[i, j]] =
1003                    momentum_coeff * h_momentum[[i, j]] - learning_rate * grad_h[[i, j]];
1004
1005                // Update H
1006                h[[i, j]] += h_momentum[[i, j]];
1007
1008                // Ensure non-negativity
1009                h[[i, j]] = h[[i, j]].max(0.0);
1010            }
1011        }
1012
1013        Ok(())
1014    }
1015
1016    /// Update W matrix for Online NMF using stochastic gradient descent
1017    fn update_w_online(
1018        &self,
1019        x_batch: &Array2<f64>,
1020        w: &mut Array2<f64>,
1021        h: &Array2<f64>,
1022        w_momentum: &mut Array2<f64>,
1023        learning_rate: f64,
1024        momentum_coeff: f64,
1025    ) -> Result<()> {
1026        let (batch_size, _) = x_batch.dim();
1027
1028        // Compute gradients for W: ∇W = (WH - X)H^T
1029        let wh = w.dot(h);
1030        let wh_batch = wh.slice(scirs2_core::ndarray::s![0..batch_size, ..]);
1031        let residual = &wh_batch.to_owned() - x_batch;
1032        let grad_w_batch = residual.dot(&h.t());
1033
1034        // Update W with momentum for the current batch
1035        for i in 0..batch_size {
1036            let global_i = i; // In real streaming, this would be mapped to global indices
1037            if global_i < w.nrows() {
1038                for j in 0..w.ncols() {
1039                    // Apply momentum
1040                    w_momentum[[global_i, j]] = momentum_coeff * w_momentum[[global_i, j]]
1041                        - learning_rate * grad_w_batch[[i, j]];
1042
1043                    // Update W
1044                    w[[global_i, j]] += w_momentum[[global_i, j]];
1045
1046                    // Ensure non-negativity
1047                    w[[global_i, j]] = w[[global_i, j]].max(0.0);
1048                }
1049            }
1050        }
1051
1052        Ok(())
1053    }
1054}
1055
1056impl NMF<TrainedNMF> {
1057    /// Get the components matrix (W)
1058    pub fn components(&self) -> Array2<f64> {
1059        self.state.components.t().to_owned()
1060    }
1061
1062    /// Get the number of iterations performed
1063    pub fn n_iter(&self) -> usize {
1064        self.state.n_iter
1065    }
1066
1067    /// Get the reconstruction error
1068    pub fn reconstruction_err(&self) -> f64 {
1069        self.state.reconstruction_err
1070    }
1071
1072    /// Inverse transform (reconstruct data from components)
1073    pub fn inverse_transform(&self, h: &Array2<f64>) -> Result<Array2<f64>> {
1074        let (_n_samples, n_components) = h.dim();
1075
1076        if n_components != self.state.n_components {
1077            return Err(SklearsError::FeatureMismatch {
1078                expected: self.state.n_components,
1079                actual: n_components,
1080            });
1081        }
1082
1083        // Reconstruct: X ≈ W * H (where h is n_samples x n_components, H is n_components x n_features)
1084        let reconstructed = h.dot(&self.state.components);
1085        Ok(reconstructed)
1086    }
1087}
1088
1089impl Default for NMF<Untrained> {
1090    fn default() -> Self {
1091        Self::new(2)
1092    }
1093}
1094
1095#[allow(non_snake_case)]
1096#[cfg(test)]
1097mod tests {
1098    use super::*;
1099    use approx::assert_abs_diff_eq;
1100    use scirs2_core::ndarray::array;
1101
1102    #[test]
1103    fn test_nmf_creation() {
1104        let nmf = NMF::new(3)
1105            .init(NMFInit::Random)
1106            .solver(NMFSolver::MultiplicativeUpdate)
1107            .max_iter(100)
1108            .tol(1e-6)
1109            .random_state(42);
1110
1111        assert_eq!(nmf.n_components, 3);
1112        assert_eq!(nmf.init, NMFInit::Random);
1113        assert_eq!(nmf.solver, NMFSolver::MultiplicativeUpdate);
1114        assert_eq!(nmf.max_iter, 100);
1115        assert_abs_diff_eq!(nmf.tol, 1e-6, epsilon = 1e-10);
1116    }
1117
1118    #[test]
1119    fn test_nmf_fit_transform() {
1120        // Create non-negative data
1121        let x = array![
1122            [1.0, 2.0, 3.0],
1123            [2.0, 4.0, 6.0],
1124            [1.0, 3.0, 5.0],
1125            [2.0, 5.0, 8.0],
1126        ];
1127
1128        let nmf = NMF::new(2).random_state(42);
1129        let trained_nmf = nmf.fit(&x, &()).expect("model fitting should succeed");
1130        let h = trained_nmf
1131            .transform(&x)
1132            .expect("transformation should succeed");
1133
1134        assert_eq!(h.dim(), (4, 2));
1135        assert_eq!(trained_nmf.state.n_features_in, 3);
1136        assert_eq!(trained_nmf.state.n_components, 2);
1137
1138        // Check that all values in H are non-negative
1139        for &val in h.iter() {
1140            assert!(val >= 0.0);
1141        }
1142    }
1143
1144    #[test]
1145    fn test_nmf_inverse_transform() {
1146        let x = array![[1.0, 2.0], [2.0, 4.0], [3.0, 6.0],];
1147
1148        let nmf = NMF::new(2).random_state(123);
1149        let trained_nmf = nmf.fit(&x, &()).expect("model fitting should succeed");
1150        let h = trained_nmf
1151            .transform(&x)
1152            .expect("transformation should succeed");
1153        let x_reconstructed = trained_nmf
1154            .inverse_transform(&h)
1155            .expect("operation should succeed");
1156
1157        assert_eq!(x_reconstructed.dim(), x.dim());
1158
1159        // Check that all reconstructed values are non-negative
1160        for &val in x_reconstructed.iter() {
1161            assert!(val >= 0.0);
1162        }
1163    }
1164
1165    #[test]
1166    fn test_nmf_different_solvers() {
1167        let x = array![[1.0, 2.0, 3.0], [2.0, 3.0, 4.0], [3.0, 4.0, 5.0],];
1168
1169        let solvers = vec![
1170            NMFSolver::MultiplicativeUpdate,
1171            NMFSolver::CoordinateDescent,
1172            NMFSolver::ALS,
1173        ];
1174
1175        for solver in solvers {
1176            let nmf = NMF::new(2).solver(solver).random_state(42);
1177
1178            let trained_nmf = nmf.fit(&x, &()).expect("model fitting should succeed");
1179            let h = trained_nmf
1180                .transform(&x)
1181                .expect("transformation should succeed");
1182
1183            assert_eq!(h.dim(), (3, 2));
1184
1185            // Check non-negativity
1186            for &val in h.iter() {
1187                assert!(val >= 0.0);
1188            }
1189        }
1190    }
1191
1192    #[test]
1193    fn test_nmf_different_inits() {
1194        let x = array![[1.0, 2.0], [2.0, 4.0], [3.0, 6.0],];
1195
1196        let inits = vec![
1197            NMFInit::Random,
1198            NMFInit::Nndsvd,
1199            NMFInit::NndsvdA,
1200            NMFInit::NndsvdAr,
1201        ];
1202
1203        for init in inits {
1204            let nmf = NMF::new(2).init(init).random_state(42);
1205
1206            let trained_nmf = nmf.fit(&x, &()).expect("model fitting should succeed");
1207            let h = trained_nmf
1208                .transform(&x)
1209                .expect("transformation should succeed");
1210
1211            assert_eq!(h.dim(), (3, 2));
1212        }
1213    }
1214
1215    #[test]
1216    fn test_nmf_negative_data_error() {
1217        let x = array![
1218            [1.0, -2.0], // Contains negative value
1219            [2.0, 4.0],
1220        ];
1221
1222        let nmf = NMF::new(2);
1223        let result = nmf.fit(&x, &());
1224        assert!(result.is_err());
1225    }
1226
1227    #[test]
1228    fn test_nmf_too_many_components() {
1229        let x = array![[1.0, 2.0], [2.0, 4.0],];
1230
1231        let nmf = NMF::new(5); // More components than min(n_samples, n_features)
1232        let result = nmf.fit(&x, &());
1233        assert!(result.is_err());
1234    }
1235
1236    #[test]
1237    fn test_nmf_components_access() {
1238        let x = array![[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [1.0, 3.0, 5.0],];
1239
1240        let nmf = NMF::new(2).random_state(42);
1241        let trained_nmf = nmf.fit(&x, &()).expect("model fitting should succeed");
1242
1243        let components = trained_nmf.components();
1244        assert_eq!(components.dim(), (3, 2));
1245
1246        // Check that all components are non-negative
1247        for &val in components.iter() {
1248            assert!(val >= 0.0);
1249        }
1250    }
1251
1252    #[test]
1253    fn test_sparse_nmf_l1_regularization() {
1254        let x = array![
1255            [1.0, 2.0, 0.0, 3.0],
1256            [0.0, 4.0, 5.0, 1.0],
1257            [2.0, 0.0, 6.0, 2.0],
1258            [3.0, 1.0, 0.0, 4.0],
1259        ];
1260
1261        // Compare NMF without regularization vs with L1 regularization
1262        let nmf_no_reg = NMF::new(2)
1263            .regularization(NMFRegularization::None, 0.0)
1264            .solver(NMFSolver::CoordinateDescent)
1265            .max_iter(100)
1266            .random_state(42);
1267
1268        let nmf_l1 = NMF::new(2)
1269            .regularization(NMFRegularization::L1, 0.5) // Higher regularization
1270            .solver(NMFSolver::CoordinateDescent)
1271            .max_iter(100)
1272            .random_state(42);
1273
1274        let trained_no_reg = nmf_no_reg
1275            .fit(&x, &())
1276            .expect("model fitting should succeed");
1277        let trained_l1 = nmf_l1.fit(&x, &()).expect("model fitting should succeed");
1278
1279        let h_no_reg = trained_no_reg
1280            .transform(&x)
1281            .expect("transformation should succeed");
1282        let h_l1 = trained_l1
1283            .transform(&x)
1284            .expect("transformation should succeed");
1285
1286        // Check that results are non-negative
1287        for &val in h_no_reg.iter() {
1288            assert!(val >= 0.0);
1289        }
1290        for &val in h_l1.iter() {
1291            assert!(val >= 0.0);
1292        }
1293
1294        // Count number of small values (closer to zero)
1295        let threshold = 0.1; // More reasonable threshold
1296        let sparse_count_no_reg = h_no_reg.iter().filter(|&&val| val < threshold).count();
1297        let sparse_count_l1 = h_l1.iter().filter(|&&val| val < threshold).count();
1298
1299        // L1 regularization should tend to produce more small values (sparsity)
1300        // If not more sparse elements, then at least smaller values on average
1301        let mean_no_reg = h_no_reg
1302            .mean()
1303            .expect("array should have elements for mean computation");
1304        let mean_l1 = h_l1
1305            .mean()
1306            .expect("array should have elements for mean computation");
1307
1308        // L1 regularization should generally produce smaller coefficients (shrinkage)
1309        assert!(
1310            sparse_count_l1 >= sparse_count_no_reg || mean_l1 <= mean_no_reg,
1311            "L1 regularization should induce sparsity or smaller coefficients. No reg: sparse={}, mean={:.3}, L1: sparse={}, mean={:.3}",
1312            sparse_count_no_reg, mean_no_reg, sparse_count_l1, mean_l1
1313        );
1314    }
1315
1316    #[test]
1317    fn test_nmf_l2_regularization() {
1318        let x = array![[1.0, 2.0, 3.0], [2.0, 4.0, 6.0], [1.0, 3.0, 5.0],];
1319
1320        let nmf_l2 = NMF::new(2)
1321            .regularization(NMFRegularization::L2, 0.01)
1322            .solver(NMFSolver::MultiplicativeUpdate)
1323            .random_state(42);
1324
1325        let trained_l2 = nmf_l2.fit(&x, &()).expect("model fitting should succeed");
1326        let h_l2 = trained_l2
1327            .transform(&x)
1328            .expect("transformation should succeed");
1329
1330        // Check that results are non-negative
1331        for &val in h_l2.iter() {
1332            assert!(val >= 0.0);
1333        }
1334
1335        assert_eq!(h_l2.dim(), (3, 2));
1336    }
1337
1338    #[test]
1339    fn test_nmf_elastic_net_regularization() {
1340        let x = array![
1341            [1.0, 2.0, 3.0, 0.0],
1342            [0.0, 4.0, 1.0, 2.0],
1343            [2.0, 0.0, 5.0, 1.0],
1344            [1.0, 3.0, 0.0, 4.0],
1345        ];
1346
1347        let nmf_elastic = NMF::new(2)
1348            .regularization(NMFRegularization::Both, 0.05)
1349            .l1_ratio(0.5) // Equal mix of L1 and L2
1350            .solver(NMFSolver::CoordinateDescent)
1351            .max_iter(50)
1352            .random_state(42);
1353
1354        let trained_elastic = nmf_elastic
1355            .fit(&x, &())
1356            .expect("model fitting should succeed");
1357        let h_elastic = trained_elastic
1358            .transform(&x)
1359            .expect("transformation should succeed");
1360
1361        // Check that results are non-negative
1362        for &val in h_elastic.iter() {
1363            assert!(val >= 0.0);
1364        }
1365
1366        assert_eq!(h_elastic.dim(), (4, 2));
1367    }
1368
1369    #[test]
1370    fn test_nmf_regularization_comparison() {
1371        let x = array![[2.0, 1.0, 0.0], [1.0, 3.0, 1.0], [0.0, 2.0, 2.0],];
1372
1373        // Compare unregularized vs L1 regularized
1374        let nmf_none = NMF::new(2)
1375            .regularization(NMFRegularization::None, 0.0)
1376            .random_state(42);
1377
1378        let nmf_l1 = NMF::new(2)
1379            .regularization(NMFRegularization::L1, 0.1)
1380            .random_state(42);
1381
1382        let trained_none = nmf_none.fit(&x, &()).expect("model fitting should succeed");
1383        let trained_l1 = nmf_l1.fit(&x, &()).expect("model fitting should succeed");
1384
1385        let h_none = trained_none
1386            .transform(&x)
1387            .expect("transformation should succeed");
1388        let h_l1 = trained_l1
1389            .transform(&x)
1390            .expect("transformation should succeed");
1391
1392        // Both should be non-negative
1393        for &val in h_none.iter() {
1394            assert!(val >= 0.0);
1395        }
1396        for &val in h_l1.iter() {
1397            assert!(val >= 0.0);
1398        }
1399
1400        // L1 regularized should generally have smaller values (shrinkage effect)
1401        let l1_sum = h_l1.sum();
1402        let none_sum = h_none.sum();
1403
1404        // L1 regularization typically reduces the magnitude of coefficients
1405        assert!(l1_sum <= none_sum || (l1_sum - none_sum).abs() < 1e-2);
1406    }
1407
1408    #[test]
1409    fn test_nmf_regularization_parameters() {
1410        let nmf = NMF::new(2)
1411            .regularization(NMFRegularization::L1, 0.05)
1412            .l1_ratio(0.7);
1413
1414        assert_eq!(nmf.regularization, NMFRegularization::L1);
1415        assert_eq!(nmf.alpha_w, 0.05);
1416        assert_eq!(nmf.alpha_h, 0.05);
1417        assert_eq!(nmf.l1_ratio, 0.7);
1418    }
1419}