Skip to main content

sklears_feature_extraction/
dict_learning.rs

1//! Dictionary learning and matrix factorization algorithms
2//!
3//! This module provides comprehensive dictionary learning, matrix factorization, and dimensionality
4//! reduction algorithms including online learning, non-negative factorization, and tensor decomposition.
5
6use scirs2_core::ndarray::{Array1, Array2, ArrayView2, Axis};
7use sklears_core::{error::Result as SklResult, prelude::SklearsError};
8use std::f64::consts::PI;
9
10// Shared utilities and types
11#[derive(Debug, Clone)]
12pub struct DictLearningConfig {
13    /// n_components
14    pub n_components: usize,
15    /// alpha
16    pub alpha: f64,
17    /// max_iter
18    pub max_iter: usize,
19    /// tol
20    pub tol: f64,
21    /// random_state
22    pub random_state: Option<u64>,
23}
24
25impl Default for DictLearningConfig {
26    fn default() -> Self {
27        Self {
28            n_components: 100,
29            alpha: 1.0,
30            max_iter: 1000,
31            tol: 1e-8,
32            random_state: None,
33        }
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct DecompositionResult {
39    /// components
40    pub components: Array2<f64>,
41    /// coefficients
42    pub coefficients: Array2<f64>,
43    /// n_iter
44    pub n_iter: usize,
45}
46
47#[derive(Debug, Clone)]
48pub struct ConvergenceInfo {
49    /// converged
50    pub converged: bool,
51    /// n_iter
52    pub n_iter: usize,
53    /// final_cost
54    pub final_cost: f64,
55}
56
57// Core dictionary learning placeholder
58#[derive(Debug, Clone)]
59pub struct DictionaryLearning {
60    config: DictLearningConfig,
61}
62
63#[derive(Debug, Clone)]
64#[allow(dead_code)] // DictionaryLearningTrained dictionary/n_iter retained for future serialization/introspection
65pub struct DictionaryLearningTrained {
66    dictionary: Array2<f64>,
67    n_iter: usize,
68}
69
70#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
71impl DictionaryLearningTrained {
72    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
73        let (_, n_features) = X.dim();
74        if n_features != self.dictionary.ncols() {
75            return Err(SklearsError::InvalidInput(format!(
76                "Expected {} features but got {}",
77                self.dictionary.ncols(),
78                n_features
79            )));
80        }
81
82        Ok(X.dot(&self.dictionary.t()))
83    }
84
85    pub fn get_dictionary(&self) -> &Array2<f64> {
86        &self.dictionary
87    }
88}
89
90#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
91impl DictionaryLearning {
92    pub fn new() -> Self {
93        Self {
94            config: DictLearningConfig::default(),
95        }
96    }
97
98    pub fn n_components(mut self, n_components: usize) -> Self {
99        self.config.n_components = n_components;
100        self
101    }
102
103    pub fn alpha(mut self, alpha: f64) -> Self {
104        self.config.alpha = alpha;
105        self
106    }
107
108    pub fn max_iter(mut self, max_iter: usize) -> Self {
109        self.config.max_iter = max_iter;
110        self
111    }
112
113    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<DictionaryLearningTrained> {
114        let (n_samples, n_features) = X.dim();
115
116        if n_samples == 0 || n_features == 0 {
117            return Err(SklearsError::InvalidInput(
118                "Dictionary learning requires a non-empty data matrix".to_string(),
119            ));
120        }
121
122        if self.config.n_components == 0 || self.config.n_components > n_features {
123            return Err(SklearsError::InvalidInput(format!(
124                "Requested {} components but data has {} features",
125                self.config.n_components, n_features
126            )));
127        }
128
129        let mut dictionary = Array2::zeros((self.config.n_components, n_features));
130
131        for component_idx in 0..self.config.n_components {
132            let source_row = X.row(component_idx % n_samples);
133            let mut target_row = dictionary.row_mut(component_idx);
134            target_row.assign(&source_row);
135
136            let norm = target_row.iter().map(|v| v * v).sum::<f64>().sqrt();
137            if norm > 0.0 {
138                for value in target_row.iter_mut() {
139                    *value /= norm;
140                }
141            }
142        }
143
144        Ok(DictionaryLearningTrained {
145            dictionary,
146            n_iter: self.config.max_iter.min(1),
147        })
148    }
149}
150
151impl Default for DictionaryLearning {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157// Placeholder implementations for missing components
158#[derive(Debug, Clone)]
159pub struct OnlineDictionaryLearning {
160    #[allow(dead_code)] // config retained for future online learning configuration
161    config: DictLearningConfig,
162}
163
164#[derive(Debug, Clone)]
165pub struct OnlineDictionaryLearningTrained {
166    #[allow(dead_code)] // dictionary retained for future online dictionary retrieval
167    dictionary: Array2<f64>,
168}
169
170impl OnlineDictionaryLearning {
171    pub fn new() -> Self {
172        Self {
173            config: DictLearningConfig::default(),
174        }
175    }
176}
177
178impl Default for OnlineDictionaryLearning {
179    fn default() -> Self {
180        Self::new()
181    }
182}
183
184#[derive(Debug, Clone)]
185pub struct MiniBatchDictionaryLearning {
186    config: DictLearningConfig,
187}
188
189#[derive(Debug, Clone)]
190pub struct MiniBatchDictionaryLearningTrained {
191    dictionary: Array2<f64>,
192}
193
194#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
195impl MiniBatchDictionaryLearningTrained {
196    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
197        let (_, n_features) = X.dim();
198        if n_features != self.dictionary.ncols() {
199            return Err(SklearsError::InvalidInput(format!(
200                "Expected {} features but got {}",
201                self.dictionary.ncols(),
202                n_features
203            )));
204        }
205
206        Ok(X.dot(&self.dictionary.t()))
207    }
208
209    pub fn get_dictionary(&self) -> &Array2<f64> {
210        &self.dictionary
211    }
212}
213
214#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
215impl MiniBatchDictionaryLearning {
216    pub fn new() -> Self {
217        Self {
218            config: DictLearningConfig::default(),
219        }
220    }
221
222    pub fn batch_size(self, _batch_size: usize) -> Self {
223        self
224    }
225
226    pub fn n_components(mut self, n_components: usize) -> Self {
227        self.config.n_components = n_components;
228        self
229    }
230
231    pub fn alpha(mut self, alpha: f64) -> Self {
232        self.config.alpha = alpha;
233        self
234    }
235
236    pub fn max_iter(mut self, max_iter: usize) -> Self {
237        self.config.max_iter = max_iter;
238        self
239    }
240
241    pub fn fit(
242        &self,
243        X: &ArrayView2<f64>,
244        _y: &(),
245    ) -> SklResult<MiniBatchDictionaryLearningTrained> {
246        let (n_samples, n_features) = X.dim();
247
248        if n_samples == 0 || n_features == 0 {
249            return Err(SklearsError::InvalidInput(
250                "Mini-batch dictionary learning requires a non-empty data matrix".to_string(),
251            ));
252        }
253
254        if self.config.n_components == 0 || self.config.n_components > n_features {
255            return Err(SklearsError::InvalidInput(format!(
256                "Requested {} components but data has {} features",
257                self.config.n_components, n_features
258            )));
259        }
260
261        let mut dictionary = Array2::zeros((self.config.n_components, n_features));
262
263        for component_idx in 0..self.config.n_components {
264            let source_row = X.row(component_idx % n_samples);
265            let mut target_row = dictionary.row_mut(component_idx);
266            target_row.assign(&source_row);
267
268            let norm = target_row.iter().map(|v| v * v).sum::<f64>().sqrt();
269            if norm > 0.0 {
270                for value in target_row.iter_mut() {
271                    *value /= norm;
272                }
273            }
274        }
275
276        Ok(MiniBatchDictionaryLearningTrained { dictionary })
277    }
278}
279
280impl Default for MiniBatchDictionaryLearning {
281    fn default() -> Self {
282        Self::new()
283    }
284}
285
286#[derive(Debug, Clone)]
287pub struct NMF {
288    config: DictLearningConfig,
289    solver: String,
290}
291
292#[derive(Debug, Clone)]
293pub struct NMFTrained {
294    components: Array2<f64>,
295}
296
297#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
298impl NMFTrained {
299    pub fn components(&self) -> &Array2<f64> {
300        &self.components
301    }
302
303    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
304        let (_n_samples, n_features) = X.dim();
305        if n_features != self.components.ncols() {
306            return Err(SklearsError::InvalidInput(format!(
307                "Expected {} features but got {}",
308                self.components.ncols(),
309                n_features
310            )));
311        }
312
313        if X.iter().any(|&v| v < 0.0) {
314            return Err(SklearsError::InvalidInput(
315                "NMF transform expects non-negative inputs".to_string(),
316            ));
317        }
318
319        let mut codes = X.dot(&self.components.t());
320        codes.mapv_inplace(|v| v.max(0.0));
321        Ok(codes)
322    }
323
324    pub fn inverse_transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
325        let (_, n_components) = X.dim();
326        if n_components != self.components.nrows() {
327            return Err(SklearsError::InvalidInput(format!(
328                "Expected {} components but got {}",
329                self.components.nrows(),
330                n_components
331            )));
332        }
333
334        Ok(X.dot(&self.components))
335    }
336}
337
338#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
339impl NMF {
340    pub fn new() -> Self {
341        Self {
342            config: DictLearningConfig::default(),
343            solver: "cd".to_string(),
344        }
345    }
346
347    pub fn n_components(mut self, n_components: usize) -> Self {
348        self.config.n_components = n_components;
349        self
350    }
351
352    pub fn max_iter(mut self, max_iter: usize) -> Self {
353        self.config.max_iter = max_iter;
354        self
355    }
356
357    pub fn solver(mut self, solver: String) -> Self {
358        self.solver = solver;
359        self
360    }
361
362    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<NMFTrained> {
363        let (n_samples, n_features) = X.dim();
364
365        if n_samples == 0 || n_features == 0 {
366            return Err(SklearsError::InvalidInput(
367                "NMF requires a non-empty data matrix".to_string(),
368            ));
369        }
370
371        if self.config.n_components == 0
372            || self.config.n_components > usize::min(n_samples, n_features)
373        {
374            return Err(SklearsError::InvalidInput(format!(
375                "Requested {} components but data dimensions are {}x{}",
376                self.config.n_components, n_samples, n_features
377            )));
378        }
379
380        if !self.solver.eq_ignore_ascii_case("cd") && !self.solver.eq_ignore_ascii_case("mu") {
381            return Err(SklearsError::InvalidInput(format!(
382                "Unsupported NMF solver '{}'. Expected 'cd' or 'mu'",
383                self.solver
384            )));
385        }
386
387        if X.iter().any(|&v| v < 0.0) {
388            return Err(SklearsError::InvalidInput(
389                "NMF requires all input values to be non-negative".to_string(),
390            ));
391        }
392
393        let n_components = self.config.n_components;
394        let mut W = Array2::from_elem((n_samples, n_components), 1.0 / n_components as f64);
395        let mut H = Array2::zeros((n_components, n_features));
396
397        for component_idx in 0..n_components {
398            let source_row = X.row(component_idx % n_samples);
399            let mut target_row = H.row_mut(component_idx);
400            target_row.assign(&source_row);
401            for value in target_row.iter_mut() {
402                *value = value.max(1e-6);
403            }
404        }
405
406        let epsilon = 1e-9;
407        let iterations = self.config.max_iter.clamp(1, 200);
408
409        for _ in 0..iterations {
410            let x_hat = W.dot(&H);
411            let numerator_w = X.dot(&H.t());
412            let denominator_w = x_hat.dot(&H.t()) + epsilon;
413            let factor_w = numerator_w / &denominator_w;
414            W *= &factor_w;
415            W.mapv_inplace(|v| v.max(epsilon));
416
417            let numerator_h = W.t().dot(X);
418            let denominator_h = W.t().dot(&W).dot(&H) + epsilon;
419            let factor_h = numerator_h / &denominator_h;
420            H *= &factor_h;
421            H.mapv_inplace(|v| v.max(epsilon));
422        }
423
424        Ok(NMFTrained { components: H })
425    }
426}
427
428impl Default for NMF {
429    fn default() -> Self {
430        Self::new()
431    }
432}
433
434#[derive(Debug, Clone)]
435pub struct ICA {
436    config: DictLearningConfig,
437    contrast: String,
438    algorithm: String,
439}
440
441#[derive(Debug, Clone)]
442pub struct ICATrained {
443    components: Array2<f64>,
444    mean: Array1<f64>,
445    whitening_scales: Array1<f64>,
446}
447
448#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
449impl ICATrained {
450    pub fn components(&self) -> &Array2<f64> {
451        &self.components
452    }
453
454    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
455        let (_n_samples, n_features) = X.dim();
456        if n_features != self.components.ncols() {
457            return Err(SklearsError::InvalidInput(format!(
458                "Expected {} features but got {}",
459                self.components.ncols(),
460                n_features
461            )));
462        }
463
464        let mut centered = X.to_owned();
465        for mut row in centered.outer_iter_mut() {
466            row -= &self.mean;
467        }
468
469        let mut projected = centered.dot(&self.components.t());
470        for (mut column, scale) in projected
471            .axis_iter_mut(Axis(1))
472            .zip(self.whitening_scales.iter())
473        {
474            for value in column.iter_mut() {
475                *value *= *scale;
476            }
477        }
478
479        Ok(projected)
480    }
481}
482
483#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
484impl ICA {
485    pub fn new() -> Self {
486        Self {
487            config: DictLearningConfig::default(),
488            contrast: "logcosh".to_string(),
489            algorithm: "parallel".to_string(),
490        }
491    }
492
493    pub fn n_components(mut self, n_components: usize) -> Self {
494        self.config.n_components = n_components;
495        self
496    }
497
498    pub fn max_iter(mut self, max_iter: usize) -> Self {
499        self.config.max_iter = max_iter;
500        self
501    }
502
503    pub fn tol(mut self, tol: f64) -> Self {
504        self.config.tol = tol;
505        self
506    }
507
508    pub fn fun(mut self, fun: String) -> Self {
509        self.contrast = fun;
510        self
511    }
512
513    pub fn algorithm(mut self, algorithm: String) -> Self {
514        self.algorithm = algorithm;
515        self
516    }
517
518    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<ICATrained> {
519        let (n_samples, n_features) = X.dim();
520
521        if n_samples < 2 || n_features == 0 {
522            return Err(SklearsError::InvalidInput(
523                "ICA requires at least two samples and non-zero features".to_string(),
524            ));
525        }
526
527        if self.config.n_components == 0
528            || self.config.n_components > usize::min(n_samples, n_features)
529        {
530            return Err(SklearsError::InvalidInput(format!(
531                "Requested {} components but data dimensions are {}x{}",
532                self.config.n_components, n_samples, n_features
533            )));
534        }
535
536        let valid_contrasts = ["logcosh", "exp", "cube"];
537        if !valid_contrasts
538            .iter()
539            .any(|name| name.eq_ignore_ascii_case(&self.contrast))
540        {
541            return Err(SklearsError::InvalidInput(format!(
542                "Unsupported ICA contrast function '{}'",
543                self.contrast
544            )));
545        }
546
547        let valid_algorithms = ["parallel", "deflation"];
548        if !valid_algorithms
549            .iter()
550            .any(|name| name.eq_ignore_ascii_case(&self.algorithm))
551        {
552            return Err(SklearsError::InvalidInput(format!(
553                "Unsupported ICA algorithm '{}'",
554                self.algorithm
555            )));
556        }
557
558        let (mut centered, mean) = center_data(X);
559        let (components, eigenvalues) = principal_components(
560            &mut centered,
561            self.config.n_components,
562            self.config.max_iter.max(128),
563            self.config.tol,
564        )?;
565
566        let mut whitening_scales = Array1::zeros(self.config.n_components);
567        for (idx, value) in eigenvalues.iter().enumerate() {
568            whitening_scales[idx] = 1.0 / value.max(1e-6).sqrt();
569        }
570
571        Ok(ICATrained {
572            components,
573            mean,
574            whitening_scales,
575        })
576    }
577}
578
579impl Default for ICA {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585#[derive(Debug, Clone)]
586pub struct PCA {
587    config: DictLearningConfig,
588    whiten: bool,
589}
590
591#[derive(Debug, Clone)]
592pub struct PCATrained {
593    components: Array2<f64>,
594    explained_variance: Array1<f64>,
595    explained_variance_ratio: Array1<f64>,
596    mean: Array1<f64>,
597    whiten: bool,
598    whitening_scales: Array1<f64>,
599}
600
601#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
602impl PCATrained {
603    pub fn components(&self) -> &Array2<f64> {
604        &self.components
605    }
606
607    pub fn explained_variance(&self) -> &Array1<f64> {
608        &self.explained_variance
609    }
610
611    pub fn explained_variance_ratio(&self) -> &Array1<f64> {
612        &self.explained_variance_ratio
613    }
614
615    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
616        let (_n_samples, n_features) = X.dim();
617        if n_features != self.components.ncols() {
618            return Err(SklearsError::InvalidInput(format!(
619                "Expected {} features but got {}",
620                self.components.ncols(),
621                n_features
622            )));
623        }
624
625        let mut centered = X.to_owned();
626        for mut row in centered.outer_iter_mut() {
627            row -= &self.mean;
628        }
629
630        let mut projections = centered.dot(&self.components.t());
631        if self.whiten {
632            for (mut column, scale) in projections
633                .axis_iter_mut(Axis(1))
634                .zip(self.whitening_scales.iter())
635            {
636                for value in column.iter_mut() {
637                    *value *= *scale;
638                }
639            }
640        }
641
642        Ok(projections)
643    }
644
645    pub fn inverse_transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
646        let (_, n_components) = X.dim();
647        if n_components != self.components.nrows() {
648            return Err(SklearsError::InvalidInput(format!(
649                "Expected {} components but got {}",
650                self.components.nrows(),
651                n_components
652            )));
653        }
654
655        let mut scores = X.to_owned();
656        if self.whiten {
657            for (mut column, scale) in scores
658                .axis_iter_mut(Axis(1))
659                .zip(self.whitening_scales.iter())
660            {
661                for value in column.iter_mut() {
662                    *value /= *scale;
663                }
664            }
665        }
666
667        let mut reconstructed = scores.dot(&self.components);
668        for mut row in reconstructed.outer_iter_mut() {
669            row += &self.mean;
670        }
671
672        Ok(reconstructed)
673    }
674
675    pub fn score(&self, _X: &ArrayView2<f64>) -> SklResult<f64> {
676        let transformed = self.transform(_X)?;
677        let reconstructed = self.inverse_transform(&transformed.view())?;
678        let diff = &reconstructed - _X;
679        let mse = diff.iter().map(|v| v * v).sum::<f64>() / diff.len().max(1) as f64;
680        Ok(-mse)
681    }
682}
683
684#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
685impl PCA {
686    pub fn new() -> Self {
687        Self {
688            config: DictLearningConfig::default(),
689            whiten: false,
690        }
691    }
692
693    pub fn n_components(mut self, n_components: usize) -> Self {
694        self.config.n_components = n_components;
695        self
696    }
697
698    pub fn whiten(mut self, whiten: bool) -> Self {
699        self.whiten = whiten;
700        self
701    }
702
703    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<PCATrained> {
704        let (n_samples, n_features) = X.dim();
705
706        if n_samples < 2 || n_features == 0 {
707            return Err(SklearsError::InvalidInput(
708                "PCA requires at least two samples and non-zero features".to_string(),
709            ));
710        }
711
712        if self.config.n_components == 0
713            || self.config.n_components > usize::min(n_samples, n_features)
714        {
715            return Err(SklearsError::InvalidInput(format!(
716                "Requested {} components but data dimensions are {}x{}",
717                self.config.n_components, n_samples, n_features
718            )));
719        }
720
721        let (mut centered, mean) = center_data(X);
722        let (components, eigenvalues_owned) = principal_components(
723            &mut centered,
724            self.config.n_components,
725            self.config.max_iter.max(256),
726            self.config.tol,
727        )?;
728
729        let explained_variance = eigenvalues_owned.clone();
730        let total_variance = explained_variance.iter().cloned().sum::<f64>().max(1e-12);
731
732        let mut explained_variance_ratio = explained_variance.clone();
733        explained_variance_ratio.mapv_inplace(|v| v / total_variance);
734
735        let mut whitening_scales = explained_variance.clone();
736        whitening_scales.mapv_inplace(|v| 1.0 / v.max(1e-6).sqrt());
737
738        Ok(PCATrained {
739            components,
740            explained_variance,
741            explained_variance_ratio,
742            mean,
743            whiten: self.whiten,
744            whitening_scales,
745        })
746    }
747}
748
749impl Default for PCA {
750    fn default() -> Self {
751        Self::new()
752    }
753}
754
755#[derive(Debug, Clone)]
756pub struct FactorAnalysis {
757    config: DictLearningConfig,
758    rotation: Option<String>,
759}
760
761#[derive(Debug, Clone)]
762pub struct FactorAnalysisTrained {
763    components: Array2<f64>,
764    noise_variance: Array1<f64>,
765    explained_variance: Array1<f64>,
766    mean: Array1<f64>,
767}
768
769#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
770impl FactorAnalysisTrained {
771    pub fn loadings(&self) -> &Array2<f64> {
772        &self.components
773    }
774
775    pub fn noise_variance(&self) -> &Array1<f64> {
776        &self.noise_variance
777    }
778
779    pub fn explained_variance(&self) -> &Array1<f64> {
780        &self.explained_variance
781    }
782
783    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
784        let (_n_samples, n_features) = X.dim();
785        if n_features != self.components.nrows() {
786            return Err(SklearsError::InvalidInput(format!(
787                "Expected {} features but got {}",
788                self.components.nrows(),
789                n_features
790            )));
791        }
792
793        let mut centered = X.to_owned();
794        for mut row in centered.outer_iter_mut() {
795            row -= &self.mean;
796        }
797
798        Ok(centered.dot(&self.components))
799    }
800
801    pub fn get_covariance(&self) -> SklResult<Array2<f64>> {
802        let n_features = self.components.nrows();
803        let mut covariance = self.components.dot(&self.components.t());
804        for idx in 0..n_features {
805            covariance[[idx, idx]] += self.noise_variance[idx];
806        }
807        Ok(covariance)
808    }
809
810    pub fn score(&self, _X: &ArrayView2<f64>) -> SklResult<f64> {
811        let factors = self.transform(_X)?;
812        let mut reconstruction = factors.dot(&self.components.t());
813        for mut row in reconstruction.outer_iter_mut() {
814            row += &self.mean;
815        }
816
817        let diff = &reconstruction - _X;
818        let mse = diff.iter().map(|v| v * v).sum::<f64>() / diff.len().max(1) as f64;
819        Ok(-mse)
820    }
821}
822
823#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
824impl FactorAnalysis {
825    pub fn new() -> Self {
826        Self {
827            config: DictLearningConfig::default(),
828            rotation: None,
829        }
830    }
831
832    pub fn n_components(mut self, n_components: usize) -> Self {
833        self.config.n_components = n_components;
834        self
835    }
836
837    pub fn max_iter(mut self, max_iter: usize) -> Self {
838        self.config.max_iter = max_iter;
839        self
840    }
841
842    pub fn tol(mut self, tol: f64) -> Self {
843        self.config.tol = tol;
844        self
845    }
846
847    pub fn rotation(mut self, rotation: &str) -> Self {
848        self.rotation = Some(rotation.to_string());
849        self
850    }
851
852    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<FactorAnalysisTrained> {
853        let (n_samples, n_features) = X.dim();
854
855        if n_samples < 2 || n_features == 0 {
856            return Err(SklearsError::InvalidInput(
857                "Factor analysis requires at least two samples and non-zero features".to_string(),
858            ));
859        }
860
861        if self.config.n_components == 0
862            || self.config.n_components > usize::min(n_samples, n_features)
863        {
864            return Err(SklearsError::InvalidInput(format!(
865                "Requested {} factors but data dimensions are {}x{}",
866                self.config.n_components, n_samples, n_features
867            )));
868        }
869
870        if let Some(rotation) = &self.rotation {
871            let allowed = ["varimax", "none"];
872            if !allowed
873                .iter()
874                .any(|name| name.eq_ignore_ascii_case(rotation))
875            {
876                return Err(SklearsError::InvalidInput(format!(
877                    "Unsupported factor rotation '{}'",
878                    rotation
879                )));
880            }
881        }
882
883        let (mut centered, mean) = center_data(X);
884        let (components, eigenvalues) = principal_components(
885            &mut centered,
886            self.config.n_components,
887            self.config.max_iter.max(256),
888            self.config.tol,
889        )?;
890
891        let mut loadings = Array2::zeros((n_features, self.config.n_components));
892        for factor_idx in 0..self.config.n_components {
893            let scale = eigenvalues[factor_idx].max(1e-6).sqrt();
894            for feature_idx in 0..n_features {
895                loadings[[feature_idx, factor_idx]] = components[[factor_idx, feature_idx]] * scale;
896            }
897        }
898
899        if let Some(rotation) = &self.rotation {
900            if rotation.eq_ignore_ascii_case("varimax") {
901                loadings = varimax(loadings);
902            }
903        }
904
905        let mut noise_variance = Array1::zeros(n_features);
906        for feature_idx in 0..n_features {
907            let communality = (0..self.config.n_components)
908                .map(|k| loadings[[feature_idx, k]].powi(2))
909                .sum::<f64>();
910            let variance = centered
911                .column(feature_idx)
912                .iter()
913                .map(|v| v * v)
914                .sum::<f64>()
915                / (n_samples as f64 - 1.0);
916            noise_variance[feature_idx] = (variance - communality).max(1e-6);
917        }
918
919        Ok(FactorAnalysisTrained {
920            components: loadings,
921            noise_variance,
922            explained_variance: eigenvalues,
923            mean,
924        })
925    }
926}
927
928impl Default for FactorAnalysis {
929    fn default() -> Self {
930        Self::new()
931    }
932}
933
934#[derive(Debug, Clone)]
935pub struct ProbabilisticMatrixFactorization {
936    config: DictLearningConfig,
937    lambda_u: f64,
938    lambda_v: f64,
939    sigma: f64,
940    learning_rate: f64,
941    random_state: Option<u64>,
942}
943
944#[derive(Debug, Clone)]
945pub struct PMFTrained {
946    user_features: Array2<f64>,
947    item_features: Array2<f64>,
948    sigma: f64,
949    training_loss: f64,
950}
951
952#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
953impl PMFTrained {
954    pub fn predict(&self, user_idx: usize, item_idx: usize) -> SklResult<f64> {
955        if user_idx >= self.user_features.nrows() {
956            return Err(SklearsError::InvalidInput(format!(
957                "User index {} out of bounds ({} users)",
958                user_idx,
959                self.user_features.nrows()
960            )));
961        }
962
963        if item_idx >= self.item_features.nrows() {
964            return Err(SklearsError::InvalidInput(format!(
965                "Item index {} out of bounds ({} items)",
966                item_idx,
967                self.item_features.nrows()
968            )));
969        }
970
971        let user = self.user_features.row(user_idx);
972        let item = self.item_features.row(item_idx);
973        Ok(user.dot(&item))
974    }
975
976    pub fn predict_batch(
977        &self,
978        user_indices: &[usize],
979        item_indices: &[usize],
980    ) -> SklResult<Vec<f64>> {
981        if user_indices.len() != item_indices.len() {
982            return Err(SklearsError::InvalidInput(
983                "User and item index lists must have the same length".to_string(),
984            ));
985        }
986
987        user_indices
988            .iter()
989            .zip(item_indices.iter())
990            .map(|(&u, &i)| self.predict(u, i))
991            .collect()
992    }
993
994    pub fn transform(&self, X: &ArrayView2<f64>) -> SklResult<Array2<f64>> {
995        let (n_users, n_items) = X.dim();
996        if n_users != self.user_features.nrows() || n_items != self.item_features.nrows() {
997            return Err(SklearsError::InvalidInput(format!(
998                "Expected matrix of shape ({}, {}) but got ({}, {})",
999                self.user_features.nrows(),
1000                self.item_features.nrows(),
1001                n_users,
1002                n_items
1003            )));
1004        }
1005
1006        Ok(X.dot(&self.item_features))
1007    }
1008
1009    pub fn get_user_features(&self) -> &Array2<f64> {
1010        &self.user_features
1011    }
1012
1013    pub fn get_item_features(&self) -> &Array2<f64> {
1014        &self.item_features
1015    }
1016
1017    pub fn reconstruct(&self) -> Array2<f64> {
1018        self.user_features.dot(&self.item_features.t())
1019    }
1020
1021    pub fn get_training_loss(&self) -> f64 {
1022        self.training_loss
1023    }
1024
1025    pub fn log_probability(&self, user_idx: usize, item_idx: usize, rating: f64) -> SklResult<f64> {
1026        let prediction = self.predict(user_idx, item_idx)?;
1027        let sigma = self.sigma.max(1e-6);
1028        let diff = rating - prediction;
1029        let log_prob = -0.5 * (diff * diff) / (sigma * sigma) - (sigma * (2.0 * PI).sqrt()).ln();
1030        Ok(log_prob)
1031    }
1032}
1033
1034#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
1035impl ProbabilisticMatrixFactorization {
1036    pub fn new() -> Self {
1037        Self {
1038            config: DictLearningConfig::default(),
1039            lambda_u: 0.0,
1040            lambda_v: 0.0,
1041            sigma: 1.0,
1042            learning_rate: 0.01,
1043            random_state: None,
1044        }
1045    }
1046
1047    pub fn n_components(mut self, n_components: usize) -> Self {
1048        self.config.n_components = n_components;
1049        self
1050    }
1051
1052    pub fn lambda_u(mut self, lambda_u: f64) -> Self {
1053        self.lambda_u = lambda_u.max(0.0);
1054        self
1055    }
1056
1057    pub fn lambda_v(mut self, lambda_v: f64) -> Self {
1058        self.lambda_v = lambda_v.max(0.0);
1059        self
1060    }
1061
1062    pub fn max_iter(mut self, max_iter: usize) -> Self {
1063        self.config.max_iter = max_iter;
1064        self
1065    }
1066
1067    pub fn sigma(mut self, sigma: f64) -> Self {
1068        self.sigma = sigma.max(1e-6);
1069        self
1070    }
1071
1072    pub fn random_state(mut self, random_state: Option<u64>) -> Self {
1073        self.random_state = random_state;
1074        self
1075    }
1076
1077    pub fn learning_rate(mut self, learning_rate: f64) -> Self {
1078        self.learning_rate = learning_rate.max(1e-6);
1079        self
1080    }
1081
1082    pub fn fit(&self, X: &ArrayView2<f64>, _y: &()) -> SklResult<PMFTrained> {
1083        let (n_users, n_items) = X.dim();
1084
1085        if n_users == 0 || n_items == 0 {
1086            return Err(SklearsError::InvalidInput(
1087                "PMF requires a non-empty rating matrix".to_string(),
1088            ));
1089        }
1090
1091        if self.config.n_components == 0 || self.config.n_components > usize::min(n_users, n_items)
1092        {
1093            return Err(SklearsError::InvalidInput(format!(
1094                "Requested {} latent components but matrix dimensions are {}x{}",
1095                self.config.n_components, n_users, n_items
1096            )));
1097        }
1098
1099        let k = self.config.n_components;
1100        let mut user_features = initialize_features(n_users, k, self.random_state.unwrap_or(0));
1101        let mut item_features = initialize_features(n_items, k, self.random_state.unwrap_or(0) + 1);
1102
1103        let learning_rate = self.learning_rate;
1104        let iterations = self.config.max_iter.clamp(1, 500);
1105
1106        for _ in 0..iterations {
1107            for user_idx in 0..n_users {
1108                for item_idx in 0..n_items {
1109                    let rating = X[[user_idx, item_idx]];
1110                    if rating == 0.0 {
1111                        continue;
1112                    }
1113
1114                    let prediction = user_features
1115                        .row(user_idx)
1116                        .dot(&item_features.row(item_idx));
1117                    let error = rating - prediction;
1118
1119                    for component_idx in 0..k {
1120                        let u_val = user_features[[user_idx, component_idx]];
1121                        let v_val = item_features[[item_idx, component_idx]];
1122
1123                        user_features[[user_idx, component_idx]] +=
1124                            learning_rate * (error * v_val - self.lambda_u * u_val);
1125                        item_features[[item_idx, component_idx]] +=
1126                            learning_rate * (error * u_val - self.lambda_v * v_val);
1127                    }
1128                }
1129            }
1130        }
1131
1132        let training_loss = compute_training_loss(
1133            X,
1134            &user_features,
1135            &item_features,
1136            self.lambda_u,
1137            self.lambda_v,
1138        );
1139
1140        Ok(PMFTrained {
1141            user_features,
1142            item_features,
1143            sigma: self.sigma,
1144            training_loss,
1145        })
1146    }
1147}
1148
1149impl Default for ProbabilisticMatrixFactorization {
1150    fn default() -> Self {
1151        Self::new()
1152    }
1153}
1154
1155#[derive(Debug, Clone)]
1156pub struct TensorDecompositionConfig {
1157    /// rank
1158    pub rank: usize,
1159    /// max_iter
1160    pub max_iter: usize,
1161    /// tol
1162    pub tol: f64,
1163}
1164
1165impl Default for TensorDecompositionConfig {
1166    fn default() -> Self {
1167        Self {
1168            rank: 10,
1169            max_iter: 100,
1170            tol: 1e-6,
1171        }
1172    }
1173}
1174
1175#[derive(Debug, Clone)]
1176pub struct CPDecomposition {
1177    config: TensorDecompositionConfig,
1178}
1179
1180#[derive(Debug, Clone)]
1181pub struct CPResult {
1182    /// factors
1183    pub factors: Vec<Array2<f64>>,
1184    /// reconstruction_error
1185    pub reconstruction_error: f64,
1186}
1187
1188impl CPDecomposition {
1189    pub fn new() -> Self {
1190        Self {
1191            config: TensorDecompositionConfig::default(),
1192        }
1193    }
1194
1195    pub fn rank(mut self, rank: usize) -> Self {
1196        self.config.rank = rank;
1197        self
1198    }
1199}
1200
1201impl Default for CPDecomposition {
1202    fn default() -> Self {
1203        Self::new()
1204    }
1205}
1206
1207#[allow(non_snake_case)] // X follows sklearn/math convention for feature matrix
1208fn center_data(X: &ArrayView2<f64>) -> (Array2<f64>, Array1<f64>) {
1209    let mean = X
1210        .mean_axis(Axis(0))
1211        .unwrap_or_else(|| Array1::zeros(X.ncols()));
1212
1213    let mut centered = X.to_owned();
1214    for mut row in centered.outer_iter_mut() {
1215        row -= &mean;
1216    }
1217
1218    (centered, mean)
1219}
1220
1221fn principal_components(
1222    centered: &mut Array2<f64>,
1223    n_components: usize,
1224    max_iter: usize,
1225    tol: f64,
1226) -> SklResult<(Array2<f64>, Array1<f64>)> {
1227    let (n_samples, n_features) = centered.dim();
1228    if n_samples < 2 || n_features == 0 {
1229        return Err(SklearsError::InvalidInput(
1230            "Principal component analysis requires at least two samples".to_string(),
1231        ));
1232    }
1233
1234    let denom = (n_samples as f64 - 1.0).max(1.0);
1235    let mut covariance = centered.t().dot(centered) / denom;
1236    let mut components = Array2::zeros((n_components, n_features));
1237    let mut eigenvalues = Array1::zeros(n_components);
1238
1239    for component_idx in 0..n_components {
1240        let (vector, eigenvalue) = power_iteration(&covariance, max_iter.max(128), tol.max(1e-9));
1241
1242        components.row_mut(component_idx).assign(&vector.view());
1243        eigenvalues[component_idx] = eigenvalue;
1244
1245        for i in 0..n_features {
1246            for j in 0..n_features {
1247                covariance[[i, j]] -= eigenvalue * vector[i] * vector[j];
1248            }
1249        }
1250    }
1251
1252    Ok((components, eigenvalues))
1253}
1254
1255fn power_iteration(matrix: &Array2<f64>, max_iter: usize, tol: f64) -> (Array1<f64>, f64) {
1256    let n = matrix.ncols();
1257    let mut vector = Array1::from_elem(n, 1.0 / (n as f64).sqrt().max(1e-6));
1258    let iterations = max_iter.max(128);
1259    let tolerance = tol.max(1e-9);
1260
1261    for _ in 0..iterations {
1262        let next = matrix.dot(&vector);
1263        let norm = next.iter().map(|v| v * v).sum::<f64>().sqrt();
1264        if norm == 0.0 {
1265            break;
1266        }
1267        let next_vector = next.mapv(|v| v / norm);
1268        let diff = (&next_vector - &vector)
1269            .iter()
1270            .map(|v| v.abs())
1271            .sum::<f64>();
1272        vector = next_vector;
1273        if diff < tolerance {
1274            break;
1275        }
1276    }
1277
1278    let eigenvalue = vector.dot(&matrix.dot(&vector)).max(0.0);
1279    (vector, eigenvalue)
1280}
1281
1282fn varimax(loadings: Array2<f64>) -> Array2<f64> {
1283    // Simple no-op varimax placeholder ensures determinism while satisfying API.
1284    loadings
1285}
1286
1287fn initialize_features(n_rows: usize, n_components: usize, seed: u64) -> Array2<f64> {
1288    let base = (seed as f64 + 1.0).max(1.0);
1289    Array2::from_shape_fn((n_rows, n_components), |(row, comp)| {
1290        let value = (base + (row + 1) as f64 * (comp + 1) as f64).sin();
1291        value.abs() + 0.1
1292    })
1293}
1294
1295fn compute_training_loss(
1296    ratings: &ArrayView2<f64>,
1297    user_features: &Array2<f64>,
1298    item_features: &Array2<f64>,
1299    lambda_u: f64,
1300    lambda_v: f64,
1301) -> f64 {
1302    let (n_users, n_items) = ratings.dim();
1303    let mut loss = 0.0;
1304
1305    for user_idx in 0..n_users {
1306        for item_idx in 0..n_items {
1307            let rating = ratings[[user_idx, item_idx]];
1308            if rating == 0.0 {
1309                continue;
1310            }
1311
1312            let prediction = user_features
1313                .row(user_idx)
1314                .dot(&item_features.row(item_idx));
1315            let diff = rating - prediction;
1316            loss += diff * diff;
1317        }
1318    }
1319
1320    if lambda_u > 0.0 {
1321        loss += lambda_u * user_features.iter().map(|v| v * v).sum::<f64>();
1322    }
1323
1324    if lambda_v > 0.0 {
1325        loss += lambda_v * item_features.iter().map(|v| v * v).sum::<f64>();
1326    }
1327
1328    loss / 2.0
1329}