Skip to main content

sklears_decomposition/
factor_analysis.rs

1//! Factor Analysis implementation
2//!
3//! This module provides comprehensive factor analysis capabilities:
4//! - Classical factor analysis via EM algorithm (X = W*F + ε)
5//! - Factor visualization (loadings heatmap data)
6//! - Posterior sampling / generative sampling inference
7//! - Model comparison via AIC/BIC information criteria
8//!
9//! ## Model
10//!
11//! ```text
12//! X = μ + W * F + ε
13//! F ~ N(0, I_q)          (latent factors, q = n_components)
14//! ε ~ N(0, Ψ)            (noise, Ψ = diag(ψ₁, …, ψ_p))
15//! ```
16//!
17//! The marginal distribution is `X ~ N(μ, W*Wᵀ + Ψ)`.
18//!
19//! ## EM Algorithm
20//!
21//! **E-step**: compute posterior statistics of the latent factors
22//! ```text
23//! M   = Wᵀ Ψ⁻¹ W + I_q           (q × q)
24//! Ez  = M⁻¹ Wᵀ Ψ⁻¹ (X − μ)ᵀ     (q × n)
25//! Ezz = n M⁻¹ + Ez Ezᵀ           (q × q)  (not stored; used inline)
26//! ```
27//!
28//! **M-step**: closed-form maximisation
29//! ```text
30//! W_new  = (X − μ)ᵀ Ezᵀ (Ezz)⁻¹
31//! Ψ_new  = diag(S) − diag(W_new Ez (X − μ) / n)   (per-feature)
32//! ```
33//! where S = (X − μ)ᵀ (X − μ) / n is the sample covariance matrix diagonal.
34
35#[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// ────────────────────────────────────────────────────────────────────────────
49// Public re-exports / placeholder compatibility types
50// ────────────────────────────────────────────────────────────────────────────
51
52/// EM algorithm configuration for factor analysis
53#[derive(Debug, Clone)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55pub struct EMAlgorithm {
56    /// Maximum number of EM iterations
57    pub max_iter: usize,
58    /// Convergence tolerance on log-likelihood improvement
59    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/// Configuration for `FactorAnalysis`
72#[derive(Debug, Clone)]
73#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
74pub struct FactorAnalysisConfig {
75    /// Number of latent factors to extract
76    pub n_components: usize,
77    /// EM algorithm settings
78    pub em: EMAlgorithm,
79    /// Optional random seed for reproducibility
80    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// ────────────────────────────────────────────────────────────────────────────
94// Trained state
95// ────────────────────────────────────────────────────────────────────────────
96
97/// Fitted factor analysis state stored inside `FactorAnalysis<TrainedFactorAnalysis>`.
98#[derive(Debug, Clone)]
99#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
100pub struct TrainedFactorAnalysis {
101    /// Loading matrix W — shape (n_features, n_components)
102    pub loadings: Array2<Float>,
103    /// Diagonal noise variances Ψ — shape (n_features,)
104    pub noise_variance: Array1<Float>,
105    /// Per-feature training mean μ — shape (n_features,)
106    pub mean: Array1<Float>,
107    /// Number of samples seen during fit
108    pub n_samples_fit: usize,
109    /// Final log-likelihood
110    pub log_likelihood: Float,
111    /// Number of EM iterations run
112    pub n_iter: usize,
113}
114
115// ────────────────────────────────────────────────────────────────────────────
116// Primary struct
117// ────────────────────────────────────────────────────────────────────────────
118
119/// Factor Analysis transformer using the EM algorithm.
120///
121/// Implements the classical factor analysis model:
122/// `X = μ + W * F + ε` with F ~ N(0, I) and ε ~ N(0, Ψ) where Ψ is diagonal.
123#[derive(Debug, Clone)]
124#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
125pub struct FactorAnalysis<State = Untrained> {
126    /// Model configuration
127    pub config: FactorAnalysisConfig,
128    /// Trained/untrained state (uses phantom type pattern)
129    state: State,
130}
131
132impl FactorAnalysis<Untrained> {
133    /// Create a new factor analysis model with the given number of components
134    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    /// Set EM algorithm configuration
145    pub fn em(mut self, em: EMAlgorithm) -> Self {
146        self.config.em = em;
147        self
148    }
149
150    /// Set random seed for reproducibility
151    pub fn random_state(mut self, seed: u64) -> Self {
152        self.config.random_state = Some(seed);
153        self
154    }
155}
156
157// ────────────────────────────────────────────────────────────────────────────
158// Fit implementation (EM algorithm)
159// ────────────────────────────────────────────────────────────────────────────
160
161impl 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        // ── Compute mean and center data ──────────────────────────────────
188        let mean = x.mean_axis(Axis(0)).ok_or_else(|| {
189            SklearsError::NumericalError("Cannot compute mean of empty array".to_string())
190        })?;
191        // x_c: (n_samples, n_features)  centred observations
192        let x_c = x - &mean.clone().insert_axis(Axis(0));
193
194        // ── Full sample covariance S (p × p) — computed once ─────────────
195        // S = X_cᵀ X_c / n
196        let s_full: Array2<Float> = x_c.t().dot(&x_c) / n;
197        // Also cache the diagonal for the M-step Ψ update
198        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        // ── Initialise W and Ψ ───────────────────────────────────────────
207        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        // W: (n_features, q)  — small random initialisation
213        let mut w: Array2<Float> =
214            Array2::from_shape_fn((n_features, q), |_| (rng.random::<Float>() - 0.5) * 0.01);
215
216        // Ψ: (n_features,) — initialise to sample variances (clipped)
217        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            // ── E-step ───────────────────────────────────────────────────
227            //   M  = Wᵀ Ψ⁻¹ W + I_q        (q × q)
228            //   Ez = M⁻¹ Wᵀ Ψ⁻¹ X_cᵀ      (q × n)
229            let psi_inv: Array1<Float> = psi.mapv(|v| 1.0 / v);
230
231            // Wᵀ Ψ⁻¹: (q × p), where each column j is scaled by psi_inv[j]
232            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            // M = Wᵀ Ψ⁻¹ W + I_q  (q × q)
243            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            // M⁻¹  (q × q)
249            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            // Ez = M⁻¹ Wᵀ Ψ⁻¹ X_cᵀ  (q × n)
257            let ez: Array2<Float> = m_inv.dot(&wt_psi_inv.dot(&x_c.t()));
258
259            // ── M-step ───────────────────────────────────────────────────
260            //  Ezz = n * M⁻¹ + Ez Ezᵀ   (q × q)
261            let ez_ezt: Array2<Float> = ez.dot(&ez.t());
262            let mut ezz: Array2<Float> = &m_inv * n + &ez_ezt;
263
264            // Regularise for numerical stability
265            for i in 0..q {
266                ezz[[i, i]] += psi_min;
267            }
268
269            // W_new = X_cᵀ Ezᵀ Ezz⁻¹    (p × q)
270            let xct_ez: Array2<Float> = x_c.t().dot(&ez.t()); // (p, q)
271            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); // (p, q)
278
279            // Ψ_new[j] = S[j,j] − (W_new Ez X_c)[j,j] / n
280            // W_new Ez: (p × n), then dot with x_c columns for per-feature correlation
281            let w_new_ez: Array2<Float> = w_new.dot(&ez); // (p, n)
282            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            // ── Log-likelihood using matrix determinant lemma ────────────
291            // log|Σ| = Σⱼ log(ψⱼ) + log|M|      (M = Wᵀ Ψ⁻¹ W + I_q)
292            // trace(Σ⁻¹ S) uses the Woodbury identity:
293            //   Σ⁻¹ = Ψ⁻¹ − Ψ⁻¹ W M⁻¹ Wᵀ Ψ⁻¹
294            // so trace(Σ⁻¹ S) = trace(Ψ⁻¹ S) − trace(M⁻¹ Wᵀ Ψ⁻¹ S Ψ⁻¹ W)
295            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            // Update parameters
300            w = w_new;
301            psi = psi_new;
302
303            // Convergence check
304            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
325// ────────────────────────────────────────────────────────────────────────────
326// Transform (compute factor scores)
327// ────────────────────────────────────────────────────────────────────────────
328
329impl Transform<Array2<Float>, Array2<Float>> for FactorAnalysis<TrainedFactorAnalysis> {
330    /// Compute posterior mean factor scores for each observation.
331    ///
332    /// Returns matrix of shape `(n_samples, n_components)`.
333    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        // Wᵀ Ψ⁻¹  (q × p)
348        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        // M = Wᵀ Ψ⁻¹ W + I_q
359        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        // Centre data
369        let mean_row = s.mean.clone().insert_axis(Axis(0));
370        let x_c = x - &mean_row;
371
372        // Scores = (M⁻¹ Wᵀ Ψ⁻¹ X_cᵀ)ᵀ  →  (n_samples, q)
373        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
385// ────────────────────────────────────────────────────────────────────────────
386// Accessors on the trained model
387// ────────────────────────────────────────────────────────────────────────────
388
389impl FactorAnalysis<TrainedFactorAnalysis> {
390    /// Return the loading matrix W — shape `(n_features, n_components)`.
391    pub fn loadings(&self) -> &Array2<Float> {
392        &self.state.loadings
393    }
394
395    /// Return the diagonal noise variances Ψ — shape `(n_features,)`.
396    pub fn noise_variance(&self) -> &Array1<Float> {
397        &self.state.noise_variance
398    }
399
400    /// Return the per-feature training mean.
401    pub fn mean(&self) -> &Array1<Float> {
402        &self.state.mean
403    }
404
405    /// Return the log-likelihood at convergence.
406    pub fn log_likelihood(&self) -> Float {
407        self.state.log_likelihood
408    }
409
410    /// Return the number of EM iterations run.
411    pub fn n_iter(&self) -> usize {
412        self.state.n_iter
413    }
414
415    // ── Factor Visualization ─────────────────────────────────────────────
416
417    /// Return loading heatmap data suitable for visualisation.
418    ///
419    /// Returns a `LoadingsPlotData` struct containing the loading matrix,
420    /// feature labels, and component labels.
421    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    /// Return a scree-plot-like DataFrame with communalities (proportion of
435    /// each feature's variance explained by all factors).
436    ///
437    /// `communality[j] = Σ_k W[j,k]² / (Σ_k W[j,k]² + ψ_j)`
438    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    // ── Sampling / Generative Inference ──────────────────────────────────
451
452    /// Draw `n_samples` new observations from the fitted factor model.
453    ///
454    /// ```text
455    /// F_new ~ N(0, I_q)
456    /// ε_new ~ N(0, Ψ)
457    /// X_new = μ + W F_new + ε_new
458    /// ```
459    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        // Draw factors F_new: (n_samples, q) from N(0, I)
469        let factors: Array2<Float> =
470            Array2::from_shape_fn((n_samples, q), |_| sample_normal(&mut rng));
471
472        // Draw noise ε: (n_samples, n_features) from N(0, diag(Ψ))
473        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        // X_new = μ + F_new Wᵀ + ε
478        let loadings_t = s.loadings.t().to_owned(); // (q, p)
479        let x_latent: Array2<Float> = factors.dot(&loadings_t); // (n, p)
480        let mean_row = s.mean.clone().insert_axis(Axis(0)); // (1, p)
481
482        Ok(x_latent + &mean_row + &noise)
483    }
484
485    // ── Model Comparison (Information Criteria) ───────────────────────────
486
487    /// Return AIC for this model: `AIC = -2 log L + 2 * n_params`.
488    ///
489    /// `n_params = p*q + p - q*(q-1)/2`
490    /// (loadings + noise variances minus rotation indeterminacy)
491    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    /// Return BIC for this model: `BIC = -2 log L + log(n) * n_params`.
497    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    /// Number of free parameters (accounting for rotation indeterminacy).
504    ///
505    /// `n_params = p*q + p - q*(q-1)/2`
506    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    /// Reconstruct the full model covariance matrix Σ = W Wᵀ + Ψ.
513    pub fn covariance(&self) -> Array2<Float> {
514        compute_sigma(&self.state.loadings, &self.state.noise_variance)
515    }
516}
517
518// ────────────────────────────────────────────────────────────────────────────
519// Model comparison helper
520// ────────────────────────────────────────────────────────────────────────────
521
522/// Compare multiple trained factor analysis models by AIC / BIC.
523///
524/// Returns the index of the model with the lowest AIC and the lowest BIC,
525/// along with per-model scores.
526pub struct ModelComparisonResult {
527    /// AIC for each model
528    pub aic_scores: Vec<Float>,
529    /// BIC for each model
530    pub bic_scores: Vec<Float>,
531    /// Index of model with lowest AIC
532    pub best_aic_idx: usize,
533    /// Index of model with lowest BIC
534    pub best_bic_idx: usize,
535}
536
537/// Run `FactorAnalysis` for a range of `n_components` values and return
538/// the comparison result with AIC and BIC for each.
539pub 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// ────────────────────────────────────────────────────────────────────────────
588// Visualization data structs
589// ────────────────────────────────────────────────────────────────────────────
590
591/// Data for rendering a factor loadings heatmap.
592#[derive(Debug, Clone)]
593pub struct LoadingsPlotData {
594    /// Loading matrix W — rows are features, columns are factors
595    pub loadings: Array2<Float>,
596    /// Human-readable feature names
597    pub feature_names: Vec<String>,
598    /// Human-readable component/factor names
599    pub component_names: Vec<String>,
600}
601
602// ────────────────────────────────────────────────────────────────────────────
603// Internal helpers
604// ────────────────────────────────────────────────────────────────────────────
605
606/// Compute Σ = W Wᵀ + Ψ   (p × p)
607fn 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/// Exact log-likelihood using the matrix determinant lemma and Woodbury identity.
617///
618/// `log L = -n/2 (p log(2π) + log|Σ| + trace(Σ⁻¹ S))`
619///
620/// **log|Σ|** via the matrix determinant lemma:
621/// `log|Ψ + W Wᵀ| = Σⱼ log(ψⱼ) + log|M|`  where `M = Wᵀ Ψ⁻¹ W + I_q`.
622///
623/// **trace(Σ⁻¹ S)** via the Woodbury identity:
624/// `Σ⁻¹ = Ψ⁻¹ − Ψ⁻¹ W M⁻¹ Wᵀ Ψ⁻¹`
625/// `trace(Σ⁻¹ S) = trace(Ψ⁻¹ S) − trace(M⁻¹ Wᵀ Ψ⁻¹ S Ψ⁻¹ W)`
626///
627/// Both reductions are O(p·q²) instead of O(p³), and use the already-computed
628/// `m_mat` and `m_inv` from the EM E-step.
629#[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    // ── log|Σ| = Σⱼ log(ψⱼ) + log|M| ─────────────────────────────────
644    let log_psi_sum: Float = psi.iter().map(|&v| v.max(1e-300).ln()).sum();
645
646    // log|M| via sum of log of absolute diagonal of its LU / as diagonal product
647    // We approximate log|M| using the sum of log(eigenvalues) implied by the
648    // fact that M is SPD: det(M) ≈ product of its diagonal after regularisation.
649    // A more accurate method: log|M| = 2·Σ log(L_ii) where L = chol(M).
650    // Since we can't call chol directly, use direct determinant approximation:
651    // For a small (q×q) matrix, compute det numerically via cofactor expansion or
652    // use the identity: log|M| = log( prod(eigenvalues) ).
653    // Simplest numerically stable path for q ≪ p: direct cofactor or LU trace.
654    // We use the fact that M is already computed. For q ≤ ~50 this is fine.
655    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    // ── trace(Σ⁻¹ S) via Woodbury ────────────────────────────────────
663    // trace(Ψ⁻¹ S) = Σⱼ psi_inv[j] * S[j,j]
664    let trace_psi_inv_s: Float = (0..p).map(|j| psi_inv[j] * s_full[[j, j]]).sum();
665
666    // Ψ⁻¹ W  (p × q): column j of W scaled by psi_inv[j]
667    // Already captured as `wt_psi_inv.t()` but we don't have it here;
668    // recompute Ψ⁻¹ S Ψ⁻¹ W from s_full and m_inv.
669    //
670    // trace(M⁻¹ Wᵀ Ψ⁻¹ S Ψ⁻¹ W) = trace(M⁻¹ · (Wᵀ Ψ⁻¹ S Ψ⁻¹ W))
671    //   = Σᵢⱼ M⁻¹[i,j] * (Wᵀ Ψ⁻¹ S Ψ⁻¹ W)[j,i]
672    //
673    // However we don't have W available here, so we pass the already-built
674    // `m_mat = Wᵀ Ψ⁻¹ W + I` and extract Wᵀ Ψ⁻¹ = m_mat_raw (before +I).
675    // Use the identity: Wᵀ Ψ⁻¹ = (M − I) ... but that's circular.
676    //
677    // Cleaner alternative: collapse to full Σ⁻¹ only for the trace term,
678    // then add back the fast log_det computation above.
679    //
680    // For practical sizes (p ≲ 200, q ≲ 20), computing Σ (p×p) then inverting
681    // is acceptable — O(p³) per iteration, same as sklearn's FA.
682    // The key improvement is log|Σ| via the determinant lemma (exact, avoids
683    // the wrong diagonal-product formula).
684    //
685    // We pass `_w` as unused but can't use it without rebuilding sigma.
686    // Instead reconstruct Σ and invert for the trace only.
687    // NOTE: We avoid using `_w` to build sigma; instead build Σ⁻¹ from m_inv + psi.
688    //
689    // Σ⁻¹ = Ψ⁻¹ − Ψ⁻¹ W M⁻¹ Wᵀ Ψ⁻¹
690    //       = Ψ⁻¹ − (Ψ⁻¹ W) M⁻¹ (Wᵀ Ψ⁻¹)
691    //
692    // trace(Σ⁻¹ S) = trace(Ψ⁻¹ S) − trace((Ψ⁻¹ W) M⁻¹ (Wᵀ Ψ⁻¹) S)
693    //              = trace(Ψ⁻¹ S) − trace(M⁻¹ (Wᵀ Ψ⁻¹ S Ψ⁻¹ W))
694    //
695    // Wᵀ Ψ⁻¹ S Ψ⁻¹ W = (Wᵀ Ψ⁻¹) S (Ψ⁻¹ W)
696    //   = A S Aᵀ  where A = Wᵀ Ψ⁻¹  (q × p)
697    //
698    // We can extract A from m_mat:
699    //   m_mat = A W + I  ⟹  A W = m_mat − I
700    // but to get A itself we need W, which is passed as `_w`.
701    // Use `_w` directly:
702    let w = _w;
703    let p_dim = psi_inv.len();
704    // A = Wᵀ Ψ⁻¹  (q × p)
705    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    // B = A S Aᵀ  (q × q)
715    let b: Array2<Float> = a.dot(s_full).dot(&a.t());
716    // trace(M⁻¹ B)
717    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
726/// Compute log-determinant of a small SPD matrix by computing its eigenvalue
727/// product via the Gauss-Jordan identity on the matrix diagonal after pivoting.
728///
729/// For a q×q SPD matrix, use: log|M| = log(product of abs(diagonal of LU)) via
730/// Cramer's rule approximation, or more simply the product of diagonal of M
731/// (Hadamard bound approximation).  For exact results with small q we use
732/// the recursive cofactor expansion inline.
733///
734/// In practice: since M = Wᵀ Ψ⁻¹ W + I is guaranteed SPD,
735/// `det(M) = product of its eigenvalues`. We use `M^{-1}` already available;
736/// `det(M) = 1 / det(M^{-1})`.  So `log|M| = -log|M^{-1}|`.
737///
738/// We compute `|M^{-1}|` via its diagonal product (Hadamard) only if q == 1,
739/// otherwise accumulate directly from the formula used for EM convergence.
740fn 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    // For small q (≤ 6 typical in practice), compute via Leibniz rule (brute force).
748    // For larger q, use Gaussian elimination (LU-like).
749    leibniz_log_det(m, q)
750}
751
752/// Recursive Leibniz log-determinant for small matrices (q ≤ ~10).
753/// For larger q this is O(q!) so we fall back to LU.
754fn leibniz_log_det(m: &Array2<Float>, q: usize) -> Float {
755    if q <= 4 {
756        // Direct formula via cofactor expansion along first row
757        let det = small_det(m, q);
758        if det <= 0.0 {
759            Float::NEG_INFINITY
760        } else {
761            det.ln()
762        }
763    } else {
764        // Gaussian elimination to compute log-det
765        lu_log_det(m, q)
766    }
767}
768
769/// Direct determinant for 1×1 to 4×4 matrices via cofactor expansion.
770fn 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                // Extract 3×3 submatrix (rows 1..3, all cols except `col`)
783                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
802/// Gaussian elimination (partial pivoting) to compute log|M| for q > 4.
803fn 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        // Find pivot
810        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
851/// Sample from N(0, 1) using the Box-Muller transform (stdlib-only, no external
852/// rand_distr needed).
853fn sample_normal(rng: &mut StdRng) -> Float {
854    // Box-Muller transform
855    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// ────────────────────────────────────────────────────────────────────────────
861// Tests
862// ────────────────────────────────────────────────────────────────────────────
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use scirs2_core::ndarray::array;
868
869    fn make_test_data() -> Array2<Float> {
870        // Simple 10×4 dataset generated from a 2-factor model
871        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        // Loadings shape
894        assert_eq!(trained.loadings().dim(), (4, 2));
895        // Noise variance shape
896        assert_eq!(trained.noise_variance().len(), 4);
897        // All noise variances are positive
898        assert!(trained.noise_variance().iter().all(|&v| v > 0.0));
899
900        // Transform produces (n_samples, n_components) scores
901        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        // Fit with 1 and 2 factors; BIC should penalise 2 factors more
925        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        // n_free_params increases with n_components
935        assert!(m2.n_free_params() > m1.n_free_params());
936        // log-likelihood should be finite
937        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        // Communalities must be in (0, 1]
973        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        // n_components >= n_features should error
986        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    /// Hand-verifiable numerical test for the log-likelihood formula.
1004    ///
1005    /// For a q=1, p=2 model where W = [[w1], [w2]] and Ψ = diag(ψ1, ψ2):
1006    ///   Σ = [[w1² + ψ1, w1 w2],
1007    ///        [w1 w2,    w2² + ψ2]]
1008    ///   |Σ| = (w1² + ψ1)(w2² + ψ2) - (w1 w2)²
1009    ///       = ψ1 ψ2 + ψ2 w1² + ψ1 w2²          (Woodbury: = ψ1 ψ2 * |M| with M = 1 + w1²/ψ1 + w2²/ψ2)
1010    ///
1011    /// We fit a trivially small dataset where we can compute the expected
1012    /// log-likelihood independently and verify our implementation matches it.
1013    #[test]
1014    fn test_log_likelihood_numerical() {
1015        // p=2, q=1 model: W = [[1.0], [1.0]], Ψ = diag(0.5, 0.5)
1016        // Σ = [[1.5, 1.0], [1.0, 1.5]]
1017        // |Σ| = 1.5*1.5 - 1.0 = 1.25
1018        // For S = I₂ (identity), trace(Σ⁻¹) = trace([[1.5,-1.0],[-1.0,1.5]] / 1.25)
1019        //                                     = 2 * 1.5 / 1.25 = 2.4
1020        // log L = -n/2 * (2*log(2π) + log(1.25) + 2.4)
1021        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        // M = Wᵀ Ψ⁻¹ W + I₁ = [[2.0]] + [[1.0]] = [[3.0]]
1025        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        // S = I₂
1031        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        // Independent calculation:
1039        // log|Σ| = log(ψ1) + log(ψ2) + log|M|
1040        //        = log(0.5) + log(0.5) + log(3)
1041        //        = -0.6931 - 0.6931 + 1.0986 ≈ -0.2877
1042        // trace(Σ⁻¹ S) = trace(Ψ⁻¹ S) - trace(M⁻¹ Wᵀ Ψ⁻¹ S Ψ⁻¹ W)
1043        //   trace(Ψ⁻¹ S) = 2 + 2 = 4  (S = I₂, psi_inv = [2,2])
1044        //   Wᵀ Ψ⁻¹ S Ψ⁻¹ W = [[1,1]] diag(2,2) I₂ diag(2,2) [[1],[1]]
1045        //                    = [[1,1]] diag(4,4) [[1],[1]] = [[8]]
1046        //   trace(M⁻¹ * 8) = (1/3)*8 = 2.6667
1047        //   trace(Σ⁻¹ S) = 4 - 2.6667 = 1.3333
1048        // log L = -10/2 * (2*log(2π) + (-0.2877) + 1.3333)
1049        //       = -5 * (3.6759 - 0.2877 + 1.3333)
1050        //       = -5 * 4.7215 = -23.607...
1051        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    /// Verify that `DecompositionPipeline::transform` applies fitted preprocessing
1065    /// steps to new (out-of-sample) data.
1066    #[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        // ── Minimal mock algorithm for the pipeline ──────────────────────
1077        #[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        // Build pipeline with standardization
1137        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        // fit_transform trains the preprocessing + algorithm
1145        let result = pipeline
1146            .fit_transform(&x_train, &params)
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        // transform on new data should apply fitted standardization without error
1158        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}