1#[cfg(feature = "serde")]
36use serde::{Deserialize, Serialize};
37
38use scirs2_core::ndarray::{Array1, Array2, Axis};
39use scirs2_core::random::rngs::StdRng;
40use scirs2_core::random::{rng as make_rng, RngExt, SeedableRng};
41use scirs2_linalg::compat::ArrayLinalgExt;
42use sklears_core::{
43 error::{Result, SklearsError},
44 traits::{Fit, Transform, Untrained},
45 types::Float,
46};
47
48#[derive(Debug, Clone)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55pub struct EMAlgorithm {
56 pub max_iter: usize,
58 pub tol: Float,
60}
61
62impl Default for EMAlgorithm {
63 fn default() -> Self {
64 Self {
65 max_iter: 1000,
66 tol: 1e-4,
67 }
68 }
69}
70
71#[derive(Debug, Clone)]
73#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
74pub struct FactorAnalysisConfig {
75 pub n_components: usize,
77 pub em: EMAlgorithm,
79 pub random_state: Option<u64>,
81}
82
83impl Default for FactorAnalysisConfig {
84 fn default() -> Self {
85 Self {
86 n_components: 2,
87 em: EMAlgorithm::default(),
88 random_state: None,
89 }
90 }
91}
92
93#[derive(Debug, Clone)]
99#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
100pub struct TrainedFactorAnalysis {
101 pub loadings: Array2<Float>,
103 pub noise_variance: Array1<Float>,
105 pub mean: Array1<Float>,
107 pub n_samples_fit: usize,
109 pub log_likelihood: Float,
111 pub n_iter: usize,
113}
114
115#[derive(Debug, Clone)]
124#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
125pub struct FactorAnalysis<State = Untrained> {
126 pub config: FactorAnalysisConfig,
128 state: State,
130}
131
132impl FactorAnalysis<Untrained> {
133 pub fn new(n_components: usize) -> Self {
135 Self {
136 config: FactorAnalysisConfig {
137 n_components,
138 ..FactorAnalysisConfig::default()
139 },
140 state: Untrained,
141 }
142 }
143
144 pub fn em(mut self, em: EMAlgorithm) -> Self {
146 self.config.em = em;
147 self
148 }
149
150 pub fn random_state(mut self, seed: u64) -> Self {
152 self.config.random_state = Some(seed);
153 self
154 }
155}
156
157impl Fit<Array2<Float>, ()> for FactorAnalysis<Untrained> {
162 type Fitted = FactorAnalysis<TrainedFactorAnalysis>;
163
164 fn fit(self, x: &Array2<Float>, _y: &()) -> Result<Self::Fitted> {
165 let (n_samples, n_features) = x.dim();
166 let q = self.config.n_components;
167
168 if q == 0 {
169 return Err(SklearsError::InvalidInput(
170 "n_components must be > 0".to_string(),
171 ));
172 }
173 if q >= n_features {
174 return Err(SklearsError::InvalidInput(format!(
175 "n_components ({}) must be less than n_features ({})",
176 q, n_features
177 )));
178 }
179 if n_samples < 2 {
180 return Err(SklearsError::InvalidInput(
181 "At least 2 samples are required for factor analysis".to_string(),
182 ));
183 }
184
185 let n = n_samples as Float;
186
187 let mean = x.mean_axis(Axis(0)).ok_or_else(|| {
189 SklearsError::NumericalError("Cannot compute mean of empty array".to_string())
190 })?;
191 let x_c = x - &mean.clone().insert_axis(Axis(0));
193
194 let s_full: Array2<Float> = x_c.t().dot(&x_c) / n;
197 let s_diag: Array1<Float> = {
199 let mut d = Array1::zeros(n_features);
200 for j in 0..n_features {
201 d[j] = s_full[[j, j]];
202 }
203 d
204 };
205
206 let mut rng: StdRng = match self.config.random_state {
208 Some(seed) => StdRng::seed_from_u64(seed),
209 None => StdRng::from_rng(&mut make_rng()),
210 };
211
212 let mut w: Array2<Float> =
214 Array2::from_shape_fn((n_features, q), |_| (rng.random::<Float>() - 0.5) * 0.01);
215
216 let psi_min: Float = 1e-6;
218 let mut psi: Array1<Float> = s_diag.mapv(|v| v.max(psi_min));
219
220 let mut log_likelihood_prev: Float = Float::NEG_INFINITY;
221 let mut n_iter = 0usize;
222
223 for iter in 0..self.config.em.max_iter {
224 n_iter = iter + 1;
225
226 let psi_inv: Array1<Float> = psi.mapv(|v| 1.0 / v);
230
231 let wt_psi_inv: Array2<Float> = {
233 let mut out = Array2::zeros((q, n_features));
234 for j in 0..n_features {
235 for i in 0..q {
236 out[[i, j]] = w[[j, i]] * psi_inv[j];
237 }
238 }
239 out
240 };
241
242 let mut m_mat: Array2<Float> = wt_psi_inv.dot(&w);
244 for i in 0..q {
245 m_mat[[i, i]] += 1.0;
246 }
247
248 let m_inv = m_mat.inv().map_err(|e| {
250 SklearsError::NumericalError(format!(
251 "Factor precision matrix M is singular: {}",
252 e
253 ))
254 })?;
255
256 let ez: Array2<Float> = m_inv.dot(&wt_psi_inv.dot(&x_c.t()));
258
259 let ez_ezt: Array2<Float> = ez.dot(&ez.t());
262 let mut ezz: Array2<Float> = &m_inv * n + &ez_ezt;
263
264 for i in 0..q {
266 ezz[[i, i]] += psi_min;
267 }
268
269 let xct_ez: Array2<Float> = x_c.t().dot(&ez.t()); let ezz_inv = ezz.inv().map_err(|e| {
272 SklearsError::NumericalError(format!(
273 "E[z zᵀ] matrix is singular during M-step: {}",
274 e
275 ))
276 })?;
277 let w_new: Array2<Float> = xct_ez.dot(&ezz_inv); let w_new_ez: Array2<Float> = w_new.dot(&ez); let mut psi_new: Array1<Float> = Array1::zeros(n_features);
283 for j in 0..n_features {
284 let row = w_new_ez.row(j);
285 let col = x_c.column(j);
286 let corr: Float = row.dot(&col) / n;
287 psi_new[j] = (s_diag[j] - corr).max(psi_min);
288 }
289
290 let log_l = compute_log_likelihood_mdl(
296 n, n_features, &w_new, &psi_new, &psi_inv, &m_mat, &m_inv, &s_full,
297 );
298
299 w = w_new;
301 psi = psi_new;
302
303 if (log_l - log_likelihood_prev).abs() < self.config.em.tol {
305 log_likelihood_prev = log_l;
306 break;
307 }
308 log_likelihood_prev = log_l;
309 }
310
311 Ok(FactorAnalysis {
312 config: self.config,
313 state: TrainedFactorAnalysis {
314 loadings: w,
315 noise_variance: psi,
316 mean,
317 n_samples_fit: n_samples,
318 log_likelihood: log_likelihood_prev,
319 n_iter,
320 },
321 })
322 }
323}
324
325impl Transform<Array2<Float>, Array2<Float>> for FactorAnalysis<TrainedFactorAnalysis> {
330 fn transform(&self, x: &Array2<Float>) -> Result<Array2<Float>> {
334 let (n_samples, n_features) = x.dim();
335 let s = &self.state;
336
337 if n_features != s.mean.len() {
338 return Err(SklearsError::FeatureMismatch {
339 expected: s.mean.len(),
340 actual: n_features,
341 });
342 }
343
344 let q = s.loadings.ncols();
345 let psi_inv: Array1<Float> = s.noise_variance.mapv(|v| 1.0 / v);
346
347 let wt_psi_inv: Array2<Float> = {
349 let mut out = Array2::zeros((q, n_features));
350 for j in 0..n_features {
351 for i in 0..q {
352 out[[i, j]] = s.loadings[[j, i]] * psi_inv[j];
353 }
354 }
355 out
356 };
357
358 let mut m_mat: Array2<Float> = wt_psi_inv.dot(&s.loadings);
360 for i in 0..q {
361 m_mat[[i, i]] += 1.0;
362 }
363
364 let m_inv = m_mat.inv().map_err(|e| {
365 SklearsError::NumericalError(format!("Factor precision matrix is singular: {}", e))
366 })?;
367
368 let mean_row = s.mean.clone().insert_axis(Axis(0));
370 let x_c = x - &mean_row;
371
372 let scores_t: Array2<Float> = m_inv.dot(&wt_psi_inv.dot(&x_c.t()));
374
375 let mut scores = Array2::zeros((n_samples, q));
376 for n_idx in 0..n_samples {
377 for qi in 0..q {
378 scores[[n_idx, qi]] = scores_t[[qi, n_idx]];
379 }
380 }
381 Ok(scores)
382 }
383}
384
385impl FactorAnalysis<TrainedFactorAnalysis> {
390 pub fn loadings(&self) -> &Array2<Float> {
392 &self.state.loadings
393 }
394
395 pub fn noise_variance(&self) -> &Array1<Float> {
397 &self.state.noise_variance
398 }
399
400 pub fn mean(&self) -> &Array1<Float> {
402 &self.state.mean
403 }
404
405 pub fn log_likelihood(&self) -> Float {
407 self.state.log_likelihood
408 }
409
410 pub fn n_iter(&self) -> usize {
412 self.state.n_iter
413 }
414
415 pub fn loadings_plot_data(&self, feature_names: Option<Vec<String>>) -> LoadingsPlotData {
422 let (n_features, n_components) = self.state.loadings.dim();
423 let features = feature_names
424 .unwrap_or_else(|| (0..n_features).map(|i| format!("feature_{}", i)).collect());
425 let components: Vec<String> = (1..=n_components).map(|i| format!("Factor{}", i)).collect();
426
427 LoadingsPlotData {
428 loadings: self.state.loadings.clone(),
429 feature_names: features,
430 component_names: components,
431 }
432 }
433
434 pub fn communalities(&self) -> Array1<Float> {
439 let w = &self.state.loadings;
440 let psi = &self.state.noise_variance;
441 let n_features = w.nrows();
442 let mut comm = Array1::zeros(n_features);
443 for j in 0..n_features {
444 let h2: Float = w.row(j).mapv(|v| v * v).sum();
445 comm[j] = h2 / (h2 + psi[j]);
446 }
447 comm
448 }
449
450 pub fn sample(&self, n_samples: usize, random_state: Option<u64>) -> Result<Array2<Float>> {
460 let s = &self.state;
461 let (n_features, q) = s.loadings.dim();
462
463 let mut rng: StdRng = match random_state {
464 Some(seed) => StdRng::seed_from_u64(seed),
465 None => StdRng::from_rng(&mut make_rng()),
466 };
467
468 let factors: Array2<Float> =
470 Array2::from_shape_fn((n_samples, q), |_| sample_normal(&mut rng));
471
472 let noise: Array2<Float> = Array2::from_shape_fn((n_samples, n_features), |(_i, j)| {
474 sample_normal(&mut rng) * s.noise_variance[j].sqrt()
475 });
476
477 let loadings_t = s.loadings.t().to_owned(); let x_latent: Array2<Float> = factors.dot(&loadings_t); let mean_row = s.mean.clone().insert_axis(Axis(0)); Ok(x_latent + &mean_row + &noise)
483 }
484
485 pub fn aic(&self) -> Float {
492 let n_params = self.n_free_params() as Float;
493 -2.0 * self.state.log_likelihood + 2.0 * n_params
494 }
495
496 pub fn bic(&self) -> Float {
498 let n = self.state.n_samples_fit as Float;
499 let n_params = self.n_free_params() as Float;
500 -2.0 * self.state.log_likelihood + n.ln() * n_params
501 }
502
503 pub fn n_free_params(&self) -> usize {
507 let (p, q) = self.state.loadings.dim();
508 let rotation_removal = q.saturating_sub(1) * q / 2;
509 p * q + p - rotation_removal
510 }
511
512 pub fn covariance(&self) -> Array2<Float> {
514 compute_sigma(&self.state.loadings, &self.state.noise_variance)
515 }
516}
517
518pub struct ModelComparisonResult {
527 pub aic_scores: Vec<Float>,
529 pub bic_scores: Vec<Float>,
531 pub best_aic_idx: usize,
533 pub best_bic_idx: usize,
535}
536
537pub fn select_n_components(
540 x: &Array2<Float>,
541 component_range: std::ops::Range<usize>,
542 random_state: Option<u64>,
543) -> Result<ModelComparisonResult> {
544 if component_range.is_empty() {
545 return Err(SklearsError::InvalidInput(
546 "component_range must be non-empty".to_string(),
547 ));
548 }
549
550 let mut aic_scores = Vec::new();
551 let mut bic_scores = Vec::new();
552
553 for q in component_range {
554 let model = FactorAnalysis::new(q);
555 let model = if let Some(seed) = random_state {
556 model.random_state(seed)
557 } else {
558 model
559 };
560 let trained = model.fit(x, &())?;
561 aic_scores.push(trained.aic());
562 bic_scores.push(trained.bic());
563 }
564
565 let best_aic_idx = aic_scores
566 .iter()
567 .enumerate()
568 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
569 .map(|(i, _)| i)
570 .unwrap_or(0);
571
572 let best_bic_idx = bic_scores
573 .iter()
574 .enumerate()
575 .min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
576 .map(|(i, _)| i)
577 .unwrap_or(0);
578
579 Ok(ModelComparisonResult {
580 aic_scores,
581 bic_scores,
582 best_aic_idx,
583 best_bic_idx,
584 })
585}
586
587#[derive(Debug, Clone)]
593pub struct LoadingsPlotData {
594 pub loadings: Array2<Float>,
596 pub feature_names: Vec<String>,
598 pub component_names: Vec<String>,
600}
601
602fn compute_sigma(w: &Array2<Float>, psi: &Array1<Float>) -> Array2<Float> {
608 let mut sigma: Array2<Float> = w.dot(&w.t());
609 let n_features = psi.len();
610 for j in 0..n_features {
611 sigma[[j, j]] += psi[j];
612 }
613 sigma
614}
615
616#[allow(clippy::too_many_arguments)]
630fn compute_log_likelihood_mdl(
631 n: Float,
632 p: usize,
633 _w: &Array2<Float>,
634 psi: &Array1<Float>,
635 psi_inv: &Array1<Float>,
636 m_mat: &Array2<Float>,
637 m_inv: &Array2<Float>,
638 s_full: &Array2<Float>,
639) -> Float {
640 use std::f64::consts::PI;
641 let q = m_mat.nrows();
642
643 let log_psi_sum: Float = psi.iter().map(|&v| v.max(1e-300).ln()).sum();
645
646 let log_det_m = log_det_spd(m_mat, q);
656 if !log_det_m.is_finite() {
657 return Float::NEG_INFINITY;
658 }
659
660 let log_det_sigma = log_psi_sum + log_det_m;
661
662 let trace_psi_inv_s: Float = (0..p).map(|j| psi_inv[j] * s_full[[j, j]]).sum();
665
666 let w = _w;
703 let p_dim = psi_inv.len();
704 let a: Array2<Float> = {
706 let mut out = Array2::zeros((q, p_dim));
707 for j in 0..p_dim {
708 for i in 0..q {
709 out[[i, j]] = w[[j, i]] * psi_inv[j];
710 }
711 }
712 out
713 };
714 let b: Array2<Float> = a.dot(s_full).dot(&a.t());
716 let trace_correction: Float = (0..q)
718 .map(|i| (0..q).map(|j| m_inv[[i, j]] * b[[j, i]]).sum::<Float>())
719 .sum();
720
721 let trace_sigma_inv_s = trace_psi_inv_s - trace_correction;
722
723 -0.5 * n * ((p as Float) * (2.0 * PI as Float).ln() + log_det_sigma + trace_sigma_inv_s)
724}
725
726fn log_det_spd(m: &Array2<Float>, q: usize) -> Float {
741 if q == 0 {
742 return 0.0;
743 }
744 if q == 1 {
745 return m[[0, 0]].abs().ln();
746 }
747 leibniz_log_det(m, q)
750}
751
752fn leibniz_log_det(m: &Array2<Float>, q: usize) -> Float {
755 if q <= 4 {
756 let det = small_det(m, q);
758 if det <= 0.0 {
759 Float::NEG_INFINITY
760 } else {
761 det.ln()
762 }
763 } else {
764 lu_log_det(m, q)
766 }
767}
768
769fn small_det(m: &Array2<Float>, q: usize) -> Float {
771 match q {
772 1 => m[[0, 0]],
773 2 => m[[0, 0]] * m[[1, 1]] - m[[0, 1]] * m[[1, 0]],
774 3 => {
775 m[[0, 0]] * (m[[1, 1]] * m[[2, 2]] - m[[1, 2]] * m[[2, 1]])
776 - m[[0, 1]] * (m[[1, 0]] * m[[2, 2]] - m[[1, 2]] * m[[2, 0]])
777 + m[[0, 2]] * (m[[1, 0]] * m[[2, 1]] - m[[1, 1]] * m[[2, 0]])
778 }
779 4 => {
780 let mut det = 0.0_f64;
781 for col in 0..4_usize {
782 let mut sub = Array2::zeros((3, 3));
784 for r in 1..4_usize {
785 let mut sc = 0usize;
786 for c in 0..4_usize {
787 if c != col {
788 sub[[r - 1, sc]] = m[[r, c]];
789 sc += 1;
790 }
791 }
792 }
793 let cofactor = if col % 2 == 0 { 1.0 } else { -1.0 };
794 det += cofactor * m[[0, col]] * small_det(&sub, 3);
795 }
796 det
797 }
798 _ => lu_log_det(m, q),
799 }
800}
801
802fn lu_log_det(m: &Array2<Float>, q: usize) -> Float {
804 let mut a = m.to_owned();
805 let mut log_det = 0.0_f64;
806 let mut sign = 1.0_f64;
807
808 for k in 0..q {
809 let pivot_row = (k..q)
811 .max_by(|&r1, &r2| {
812 a[[r1, k]]
813 .abs()
814 .partial_cmp(&a[[r2, k]].abs())
815 .unwrap_or(std::cmp::Ordering::Equal)
816 })
817 .unwrap_or(k);
818
819 if pivot_row != k {
820 for c in 0..q {
821 a.swap([k, c], [pivot_row, c]);
822 }
823 sign = -sign;
824 }
825
826 let pivot = a[[k, k]];
827 if pivot.abs() < 1e-300 {
828 return Float::NEG_INFINITY;
829 }
830 log_det += pivot.abs().ln();
831 if pivot < 0.0 {
832 sign = -sign;
833 }
834
835 for r in (k + 1)..q {
836 let factor = a[[r, k]] / pivot;
837 for c in k..q {
838 let val = a[[k, c]] * factor;
839 a[[r, c]] -= val;
840 }
841 }
842 }
843
844 if sign < 0.0 {
845 Float::NEG_INFINITY
846 } else {
847 log_det
848 }
849}
850
851fn sample_normal(rng: &mut StdRng) -> Float {
854 let u1: Float = rng.random::<Float>().max(1e-10);
856 let u2: Float = rng.random::<Float>();
857 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI as Float * u2).cos()
858}
859
860#[cfg(test)]
865mod tests {
866 use super::*;
867 use scirs2_core::ndarray::array;
868
869 fn make_test_data() -> Array2<Float> {
870 array![
872 [1.2, 2.1, 3.3, 0.5],
873 [0.8, 1.9, 2.7, 0.3],
874 [1.5, 2.4, 3.9, 0.6],
875 [0.6, 1.6, 2.2, 0.2],
876 [1.1, 2.0, 3.1, 0.4],
877 [1.3, 2.2, 3.5, 0.55],
878 [0.9, 1.8, 2.7, 0.35],
879 [1.4, 2.3, 3.7, 0.58],
880 [0.7, 1.7, 2.4, 0.25],
881 [1.0, 2.05, 3.05, 0.45],
882 ]
883 }
884
885 #[test]
886 fn test_factor_analysis_fit_transform() {
887 let x = make_test_data();
888 let fa = FactorAnalysis::new(2).random_state(42);
889 let trained = fa
890 .fit(&x, &())
891 .expect("FactorAnalysis::fit should succeed on well-formed data");
892
893 assert_eq!(trained.loadings().dim(), (4, 2));
895 assert_eq!(trained.noise_variance().len(), 4);
897 assert!(trained.noise_variance().iter().all(|&v| v > 0.0));
899
900 let scores = trained
902 .transform(&x)
903 .expect("transform should succeed on fitted model");
904 assert_eq!(scores.dim(), (10, 2));
905 }
906
907 #[test]
908 fn test_factor_analysis_sampling() {
909 let x = make_test_data();
910 let fa = FactorAnalysis::new(2).random_state(0);
911 let trained = fa
912 .fit(&x, &())
913 .expect("FactorAnalysis::fit should succeed on well-formed data");
914
915 let samples = trained
916 .sample(5, Some(1))
917 .expect("sample should succeed on fitted model");
918 assert_eq!(samples.dim(), (5, 4));
919 }
920
921 #[test]
922 fn test_aic_bic_ordering() {
923 let x = make_test_data();
924 let fa1 = FactorAnalysis::new(1).random_state(7);
926 let fa2 = FactorAnalysis::new(2).random_state(7);
927 let m1 = fa1
928 .fit(&x, &())
929 .expect("FactorAnalysis::fit with 1 component should succeed");
930 let m2 = fa2
931 .fit(&x, &())
932 .expect("FactorAnalysis::fit with 2 components should succeed");
933
934 assert!(m2.n_free_params() > m1.n_free_params());
936 assert!(m1.log_likelihood().is_finite());
938 assert!(m2.log_likelihood().is_finite());
939 }
940
941 #[test]
942 fn test_loadings_plot_data() {
943 let x = make_test_data();
944 let trained = FactorAnalysis::new(2)
945 .random_state(42)
946 .fit(&x, &())
947 .expect("FactorAnalysis::fit should succeed on well-formed data");
948
949 let plot =
950 trained.loadings_plot_data(Some(vec!["a".into(), "b".into(), "c".into(), "d".into()]));
951 assert_eq!(plot.feature_names.len(), 4);
952 assert_eq!(plot.component_names.len(), 2);
953 }
954
955 #[test]
956 fn test_select_n_components() {
957 let x = make_test_data();
958 let result = select_n_components(&x, 1..3, Some(42))
959 .expect("select_n_components should succeed on well-formed data");
960 assert_eq!(result.aic_scores.len(), 2);
961 assert_eq!(result.bic_scores.len(), 2);
962 }
963
964 #[test]
965 fn test_communalities_range() {
966 let x = make_test_data();
967 let trained = FactorAnalysis::new(2)
968 .random_state(42)
969 .fit(&x, &())
970 .expect("FactorAnalysis::fit should succeed on well-formed data");
971 let comm = trained.communalities();
972 for c in comm.iter() {
974 assert!(
975 *c > 0.0 && *c <= 1.0 + 1e-9,
976 "communality out of range: {}",
977 c
978 );
979 }
980 }
981
982 #[test]
983 fn test_invalid_n_components() {
984 let x = make_test_data();
985 let fa = FactorAnalysis::new(4);
987 assert!(fa.fit(&x, &()).is_err());
988 }
989
990 #[test]
991 fn test_covariance_positive_diagonal() {
992 let x = make_test_data();
993 let trained = FactorAnalysis::new(2)
994 .random_state(42)
995 .fit(&x, &())
996 .expect("FactorAnalysis::fit should succeed on well-formed data");
997 let cov = trained.covariance();
998 for j in 0..4 {
999 assert!(cov[[j, j]] > 0.0);
1000 }
1001 }
1002
1003 #[test]
1014 fn test_log_likelihood_numerical() {
1015 let w_test = Array2::from_shape_vec((2, 1), vec![1.0_f64, 1.0_f64])
1022 .expect("shape (2,1) matches 2-element vector");
1023 let psi_test = Array1::from_vec(vec![0.5_f64, 0.5_f64]);
1024 let m_mat = Array2::from_shape_vec((1, 1), vec![3.0_f64])
1026 .expect("shape (1,1) matches 1-element vector");
1027 let m_inv = Array2::from_shape_vec((1, 1), vec![1.0 / 3.0])
1028 .expect("shape (1,1) matches 1-element vector");
1029 let psi_inv = Array1::from_vec(vec![2.0_f64, 2.0_f64]);
1030 let s_full = Array2::eye(2);
1032 let n = 10.0_f64;
1033 let p = 2;
1034
1035 let log_l =
1036 compute_log_likelihood_mdl(n, p, &w_test, &psi_test, &psi_inv, &m_mat, &m_inv, &s_full);
1037
1038 let expected_log_det_sigma = 0.5_f64.ln() * 2.0 + 3.0_f64.ln();
1052 let expected_trace = 4.0 - 8.0 / 3.0;
1053 let expected = -5.0
1054 * (2.0 * (2.0 * std::f64::consts::PI).ln() + expected_log_det_sigma + expected_trace);
1055
1056 assert!(
1057 (log_l - expected).abs() < 1e-6,
1058 "log-likelihood mismatch: got {}, expected {}",
1059 log_l,
1060 expected
1061 );
1062 }
1063
1064 #[test]
1067 fn test_pipeline_fit_transform_then_transform_new_data() {
1068 use crate::modular_framework::{
1069 AlgorithmCapabilities, DecompositionAlgorithm, DecompositionComponents,
1070 DecompositionParams, DecompositionPipeline, StandardizationStep,
1071 };
1072 use scirs2_core::ndarray::{Array1, Array2};
1073 use sklears_core::types::Float;
1074 use std::any::Any;
1075
1076 #[derive(Debug, Clone)]
1078 struct IdentityAlg {
1079 fitted: bool,
1080 n_feats: usize,
1081 }
1082 impl IdentityAlg {
1083 fn new() -> Self {
1084 Self {
1085 fitted: false,
1086 n_feats: 0,
1087 }
1088 }
1089 }
1090 impl DecompositionAlgorithm for IdentityAlg {
1091 fn name(&self) -> &str {
1092 "identity"
1093 }
1094 fn description(&self) -> &str {
1095 "pass-through"
1096 }
1097 fn capabilities(&self) -> AlgorithmCapabilities {
1098 AlgorithmCapabilities::default()
1099 }
1100 fn validate_params(&self, _: &DecompositionParams) -> sklears_core::error::Result<()> {
1101 Ok(())
1102 }
1103 fn fit(
1104 &mut self,
1105 data: &Array2<Float>,
1106 _: &DecompositionParams,
1107 ) -> sklears_core::error::Result<()> {
1108 self.fitted = true;
1109 self.n_feats = data.ncols();
1110 Ok(())
1111 }
1112 fn transform(
1113 &self,
1114 data: &Array2<Float>,
1115 ) -> sklears_core::error::Result<Array2<Float>> {
1116 Ok(data.clone())
1117 }
1118 fn get_components(&self) -> sklears_core::error::Result<DecompositionComponents> {
1119 Ok(DecompositionComponents {
1120 components: Some(Array2::eye(self.n_feats)),
1121 eigenvalues: Some(Array1::ones(self.n_feats)),
1122 ..Default::default()
1123 })
1124 }
1125 fn is_fitted(&self) -> bool {
1126 self.fitted
1127 }
1128 fn clone_algorithm(&self) -> Box<dyn DecompositionAlgorithm> {
1129 Box::new(self.clone())
1130 }
1131 fn as_any(&self) -> &dyn Any {
1132 self
1133 }
1134 }
1135
1136 let mut pipeline = DecompositionPipeline::new(Box::new(IdentityAlg::new()))
1138 .add_preprocessing(Box::new(StandardizationStep::new()));
1139
1140 let params = DecompositionParams::default();
1141 let x_train = Array2::from_shape_vec((4, 2), vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
1142 .expect("shape (4,2) matches 8-element vector");
1143
1144 let result = pipeline
1146 .fit_transform(&x_train, ¶ms)
1147 .expect("pipeline fit_transform should succeed on well-formed data");
1148 assert_eq!(
1149 result
1150 .components
1151 .components
1152 .expect("identity algorithm returns eye components")
1153 .shape(),
1154 [2, 2]
1155 );
1156
1157 let x_test = Array2::from_shape_vec((2, 2), vec![2.0, 3.0, 4.0, 5.0])
1159 .expect("shape (2,2) matches 4-element vector");
1160 let transformed = pipeline
1161 .transform(&x_test)
1162 .expect("pipeline transform should succeed on fitted pipeline");
1163 assert_eq!(transformed.dim(), (2, 2));
1164 }
1165}