Skip to main content

ferrolearn_decomp/
factor_analysis.rs

1//! Factor Analysis (FA) via the EM algorithm.
2//!
3//! Factor Analysis assumes that data is generated by a linear combination of
4//! latent factors plus independent Gaussian noise:
5//!
6//! ```text
7//! X = W Z + μ + ε,   Z ~ N(0, I),   ε ~ N(0, diag(ψ))
8//! ```
9//!
10//! where:
11//! - `W` is the `(n_features × n_components)` loading matrix,
12//! - `Z` is the `(n_components,)` latent factor vector,
13//! - `ψ` is the `(n_features,)` noise variance vector.
14//!
15//! # Algorithm
16//!
17//! 1. Centre the data: `X_c = X - μ`.
18//! 2. **E-step**: compute the posterior mean and covariance of `Z`:
19//!    ```text
20//!    Σ_z = (I + W^T diag(ψ)⁻¹ W)⁻¹
21//!    E[Z | X] = Σ_z W^T diag(ψ)⁻¹ X_c^T
22//!    ```
23//! 3. **M-step**: update `W` and `ψ` via maximum-likelihood closed-form
24//!    updates.
25//! 4. Repeat until convergence (log-likelihood change < `tol`).
26//!
27//! # Examples
28//!
29//! ```
30//! use ferrolearn_decomp::factor_analysis::FactorAnalysis;
31//! use ferrolearn_core::traits::{Fit, Transform};
32//! use ndarray::Array2;
33//!
34//! let fa = FactorAnalysis::new(2);
35//! let x = Array2::from_shape_vec(
36//!     (10, 4),
37//!     (0..40).map(|v| v as f64 * 0.1 + (v % 3) as f64 * 0.5).collect(),
38//! ).unwrap();
39//! let fitted = fa.fit(&x, &()).unwrap();
40//! let scores = fitted.transform(&x).unwrap();
41//! assert_eq!(scores.ncols(), 2);
42//! ```
43//!
44//! ## REQ status
45//!
46//! Design: `.design/decomp/factor_analysis.md`. Tracking: #1526. Each REQ is BINARY
47//! — SHIPPED (impl + non-test consumer + tests + green verification) or NOT-STARTED
48//! (concrete open blocker). Non-test consumers: crate re-export (`lib.rs:86`), the
49//! PyO3 `_RsFactorAnalysis` binding (`ferrolearn-python/src/extras.rs:1137`,
50//! registered `lib.rs:78`), `PipelineTransformer`. Oracle = live sklearn 1.5.2
51//! (`_factor_analysis.py`, `svd_method='lapack'`), run from `/tmp` (R-CHAR-3).
52//! ferrolearn's `fit` now implements sklearn's deterministic SVD-based EM
53//! (`_factor_analysis.py:250-311`).
54//!
55//! | REQ | Scope | Status | Evidence / Blocker |
56//! |---|---|---|---|
57//! | REQ-1 | FA VALUE parity vs `svd_method='lapack'`: rotation-invariant `noise_variance_`/implied-covariance `WWᵀ+diag(ψ)`/log-likelihood + `components_`/`transform` up to per-component sign | SHIPPED | `fit` = sklearn SVD-EM (`_factor_analysis.py:250-311`); matches sklearn `lapack` ~1e-13 on fresh 12×5 (noise 2.5e-14, cov 7.1e-15, ll 1.4e-14, n_iter 48==48, components after sign-align 4.8e-14). Was #1527, fixed. Tests `divergence_fa_rotation_invariant_covariance`/`_simple_data_loglike` |
58//! | REQ-2 | exact `components_` per-component SIGN parity | NOT-STARTED | CARVE-OUT (R-DEFER-3): faer vs LAPACK SVD sign; sklearn FA applies no `svd_flip`, no canonical sign — blocker #1528 |
59//! | REQ-3 | SVD-EM algorithm = sklearn `lapack` (incl. one-sided convergence) | SHIPPED | `fit` mirrors `_factor_analysis.py:250-311`; faer thin SVD dispatched f64/f32 via `factor_analysis_svd` (TypeId, mirrors `pca.rs::eigen_dispatch`) |
60//! | REQ-4 | Structural: shapes, ψ>0, mean, n_iter≥1, finite ll, determinism | SHIPPED | in-module `test_fa_*` (17/17) + divergence green-guards; deterministic algorithm |
61//! | REQ-5 | `inverse_transform` structural round-trip | SHIPPED | `Z @ Wᵀ + mean` (transposed layout); col-mismatch `ShapeMismatch`; `green_inverse_transform_*` |
62//! | REQ-6 | Error/parameter contracts (n_components 0/>n_features, n_samples<2, transform/inverse_transform col mismatch, NON-FINITE rejection) | SHIPPED (scoped) | `fit`/`transform` guards. FLAG: sklearn raises `InvalidParameterError`, allows n_components 0/None, doesn't pre-reject n_samples<2. NON-FINITE: `fit`+`transform` call `reject_non_finite` (`factor_analysis.rs` symbol `reject_non_finite`) BEFORE the SVD/projection, returning the CLEAN finiteness `InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."}` = sklearn `_validate_data(force_all_finite=True)` (`_factor_analysis.py:222`/`:332`,`utils/validation.py:147-154`) — replaces the incidental faer `NoConvergence` `NumericalInstability` (R-DEV-2). `tests/divergence_nonfinite.rs::divergence_factor_analysis_fit_nan_`/`_transform_nan_rejects_for_finiteness` match the live sklearn 1.5.2 oracle. Was #2288/#2289, fixed. Consumer: FactorAnalysis `fit`/`transform` + re-export `lib.rs` |
63//! | REQ-7 | `PipelineTransformer` integration | SHIPPED | `fit_pipeline`/`transform_pipeline`; `test_fa_pipeline_transformer` |
64//! | REQ-8 | PyO3 `_RsFactorAnalysis` binding (scoped: fit/transform, scores up to sign) | SHIPPED | `extras.rs:1137`, registered `lib.rs:78`; f64-only, NO noise_variance_/loglike_/score getters |
65//! | REQ-9 | `svd_method='randomized'` + `iterated_power` RNG path | NOT-STARTED | sklearn `_factor_analysis.py:266-276`; ferrolearn lapack-only — blocker #1529 |
66//! | REQ-10 | `rotation` varimax/quartimax | NOT-STARTED | sklearn `_factor_analysis.py:307-308,:428-460` — blocker #1530 |
67//! | REQ-11 | `noise_variance_init` | NOT-STARTED | sklearn `_factor_analysis.py:239-248`; ferrolearn psi hard-init ones — blocker #1531 |
68//! | REQ-12 | `loglike_` per-iteration LIST attr | NOT-STARTED | ferrolearn keeps only final scalar — blocker #1532 |
69//! | REQ-13 | `score`/`score_samples` Gaussian log-likelihood | NOT-STARTED | sklearn `_factor_analysis.py:388-426` — blocker #1533 |
70//! | REQ-14 | `get_covariance`/`get_precision` METHODS | NOT-STARTED | value matches via accessors but no method — blocker #1534 |
71//! | REQ-15 | `n_components=None` default + `copy` + `n_features_in_` | NOT-STARTED | sklearn `_factor_analysis.py:228-229` — blocker #1535 |
72//! | REQ-16 | `tol` DEFAULT now 1e-2 matching sklearn `_factor_analysis.py:185` | SHIPPED | `tol: 1e-2` in `pub fn new` — closes #2392/#1536 |
73//! | REQ-17 | `components_` ORIENTATION (ferro `(n_features,n_components)` = sklearn `components_.T`) | NOT-STARTED | blocker #1537 |
74//! | REQ-18 | production `assert_eq!` debug-assert in `transform` (R-CODE-2) | NOT-STARTED | blocker #1538 |
75//! | REQ-19 | ferray substrate | NOT-STARTED | `ndarray` + faer-direct + hand-rolled Cholesky — blocker #1539 |
76//!
77//! Count: **7 SHIPPED (REQ-1,3,4,5,6,7,8) / 12 NOT-STARTED (REQ-2,9..19)**.
78
79use ferrolearn_core::error::FerroError;
80use ferrolearn_core::pipeline::{FittedPipelineTransformer, PipelineTransformer};
81use ferrolearn_core::traits::{Fit, Transform};
82use ndarray::{Array1, Array2};
83use num_traits::Float;
84use std::any::TypeId;
85
86/// Reject non-finite input the way sklearn's `_validate_data` does.
87///
88/// sklearn runs `check_array` with the default `force_all_finite=True` at the
89/// top of `FactorAnalysis.fit`/`transform` (`_factor_analysis.py:222`), raising
90/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
91/// (`sklearn/utils/validation.py:147-154`) BEFORE the SVD/EM iteration. This
92/// fires before the SVD, so the clean finiteness error replaces the incidental
93/// `NumericalInstability` (faer `NoConvergence`) the SVD would otherwise raise
94/// on non-finite input (R-DEV-2). NaN AND infinity both rejected. The message
95/// names "NaN" and "infinity" to mirror sklearn. Never panics (R-CODE-2).
96fn reject_non_finite<F: Float>(x: &Array2<F>) -> Result<(), FerroError> {
97    if x.iter().any(|v| !v.is_finite()) {
98        return Err(FerroError::InvalidParameter {
99            name: "X".into(),
100            reason: "Input X contains NaN or infinity.".into(),
101        });
102    }
103    Ok(())
104}
105
106// ---------------------------------------------------------------------------
107// FactorAnalysis (unfitted)
108// ---------------------------------------------------------------------------
109
110/// Factor Analysis configuration.
111///
112/// Calling [`Fit::fit`] fits the EM algorithm and returns a
113/// [`FittedFactorAnalysis`].
114///
115/// # Type Parameters
116///
117/// - `F`: The floating-point scalar type.
118#[derive(Debug, Clone)]
119pub struct FactorAnalysis<F> {
120    /// Number of latent factors to extract.
121    n_components: usize,
122    /// Maximum number of EM iterations.
123    max_iter: usize,
124    /// Convergence tolerance on the log-likelihood change.
125    tol: f64,
126    /// Optional random seed for reproducibility.
127    random_state: Option<u64>,
128    _marker: std::marker::PhantomData<F>,
129}
130
131impl<F: Float + Send + Sync + 'static> FactorAnalysis<F> {
132    /// Create a new `FactorAnalysis` with `n_components` factors.
133    ///
134    /// Defaults: `max_iter = 1000`, `tol = 1e-2`, no fixed random seed.
135    #[must_use]
136    pub fn new(n_components: usize) -> Self {
137        Self {
138            n_components,
139            max_iter: 1000,
140            tol: 1e-2,
141            random_state: None,
142            _marker: std::marker::PhantomData,
143        }
144    }
145
146    /// Set the maximum number of EM iterations.
147    #[must_use]
148    pub fn with_max_iter(mut self, max_iter: usize) -> Self {
149        self.max_iter = max_iter;
150        self
151    }
152
153    /// Set the convergence tolerance.
154    #[must_use]
155    pub fn with_tol(mut self, tol: f64) -> Self {
156        self.tol = tol;
157        self
158    }
159
160    /// Set the random seed for reproducibility.
161    #[must_use]
162    pub fn with_random_state(mut self, seed: u64) -> Self {
163        self.random_state = Some(seed);
164        self
165    }
166
167    /// Return the number of latent factors.
168    #[must_use]
169    pub fn n_components(&self) -> usize {
170        self.n_components
171    }
172}
173
174impl<F: Float + Send + Sync + 'static> Default for FactorAnalysis<F> {
175    fn default() -> Self {
176        Self::new(1)
177    }
178}
179
180// ---------------------------------------------------------------------------
181// FittedFactorAnalysis
182// ---------------------------------------------------------------------------
183
184/// A fitted Factor Analysis model.
185///
186/// Created by calling [`Fit::fit`] on a [`FactorAnalysis`].
187/// Implements [`Transform<Array2<F>>`] to compute factor scores for new data.
188#[derive(Debug, Clone)]
189pub struct FittedFactorAnalysis<F> {
190    /// Loading matrix `W`, shape `(n_features, n_components)`.
191    components: Array2<F>,
192
193    /// Noise variance vector `ψ`, shape `(n_features,)`.
194    noise_variance: Array1<F>,
195
196    /// Per-feature mean, shape `(n_features,)`.
197    mean: Array1<F>,
198
199    /// Number of EM iterations actually performed.
200    n_iter: usize,
201
202    /// Final log-likelihood value.
203    log_likelihood: F,
204}
205
206impl<F: Float + Send + Sync + 'static> FittedFactorAnalysis<F> {
207    /// Loading matrix `W`, shape `(n_features, n_components)`.
208    #[must_use]
209    pub fn components(&self) -> &Array2<F> {
210        &self.components
211    }
212
213    /// Noise variance vector `ψ`, shape `(n_features,)`.
214    #[must_use]
215    pub fn noise_variance(&self) -> &Array1<F> {
216        &self.noise_variance
217    }
218
219    /// Per-feature mean learned during fitting.
220    #[must_use]
221    pub fn mean(&self) -> &Array1<F> {
222        &self.mean
223    }
224
225    /// Number of EM iterations performed.
226    #[must_use]
227    pub fn n_iter(&self) -> usize {
228        self.n_iter
229    }
230
231    /// Final log-likelihood value.
232    #[must_use]
233    pub fn log_likelihood(&self) -> F {
234        self.log_likelihood
235    }
236
237    /// Map latent representation back to the original feature space.
238    /// Mirrors sklearn `FactorAnalysis.inverse_transform`. Returns
239    /// `Z @ Wᵀ + mean` where `W` is the loading matrix.
240    ///
241    /// Note: ferrolearn's FactorAnalysis stores `components` with shape
242    /// `(n_features, n_components)` (transposed relative to sklearn's
243    /// `components_` layout), so the formula transposes accordingly.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`FerroError::ShapeMismatch`] if `z.ncols()` does not
248    /// equal the number of components.
249    pub fn inverse_transform(&self, z: &Array2<F>) -> Result<Array2<F>, FerroError> {
250        let n_components = self.components.ncols();
251        if z.ncols() != n_components {
252            return Err(FerroError::ShapeMismatch {
253                expected: vec![z.nrows(), n_components],
254                actual: vec![z.nrows(), z.ncols()],
255                context: "FittedFactorAnalysis::inverse_transform".into(),
256            });
257        }
258        let mut result = z.dot(&self.components.t());
259        for mut row in result.rows_mut() {
260            for (v, &m) in row.iter_mut().zip(self.mean.iter()) {
261                *v = *v + m;
262            }
263        }
264        Ok(result)
265    }
266}
267
268// ---------------------------------------------------------------------------
269// Internal helpers
270// ---------------------------------------------------------------------------
271
272/// Invert a small symmetric positive-definite matrix via Cholesky.
273fn cholesky_inv<F: Float>(a: &Array2<F>) -> Result<Array2<F>, FerroError> {
274    let n = a.nrows();
275    // Compute lower triangular L.
276    let mut l = Array2::<F>::zeros((n, n));
277    for i in 0..n {
278        for j in 0..=i {
279            let mut s = a[[i, j]];
280            for k in 0..j {
281                s = s - l[[i, k]] * l[[j, k]];
282            }
283            if i == j {
284                if s <= F::zero() {
285                    // Regularise.
286                    s = F::from(1e-10).unwrap();
287                }
288                l[[i, j]] = s.sqrt();
289            } else {
290                l[[i, j]] = s / l[[j, j]];
291            }
292        }
293    }
294    // Invert L using forward substitution: L L_inv = I.
295    let mut l_inv = Array2::<F>::zeros((n, n));
296    for j in 0..n {
297        l_inv[[j, j]] = F::one() / l[[j, j]];
298        for i in (j + 1)..n {
299            let mut s = F::zero();
300            for k in j..i {
301                s = s + l[[i, k]] * l_inv[[k, j]];
302            }
303            l_inv[[i, j]] = -s / l[[i, i]];
304        }
305    }
306    // A_inv = L_inv^T @ L_inv.
307    let mut inv = Array2::<F>::zeros((n, n));
308    for i in 0..n {
309        for j in 0..n {
310            let mut s = F::zero();
311            let start = i.max(j);
312            for k in start..n {
313                s = s + l_inv[[k, i]] * l_inv[[k, j]];
314            }
315            inv[[i, j]] = s;
316        }
317    }
318    Ok(inv)
319}
320
321/// Compute the singular values and the top-`k` right singular vectors of an
322/// `(n × p)` matrix `y` via faer's SVD, mirroring sklearn's `my_svd` for the
323/// `svd_method='lapack'` branch (`_factor_analysis.py:258-264`).
324///
325/// Returns `(s, vt_top)` where `s` is the full vector of singular values
326/// (sorted descending, length `min(n, p)`) and `vt_top` is the `(k × p)`
327/// matrix whose row `c` is the `c`-th right singular vector (`Vᵀ[0..k]`).
328fn factor_analysis_svd_f64(
329    y: &Array2<f64>,
330    k: usize,
331) -> Result<(Array1<f64>, Array2<f64>), FerroError> {
332    let (n, p) = y.dim();
333    let mat = faer::Mat::from_fn(n, p, |i, j| y[[i, j]]);
334    let svd = mat
335        .thin_svd()
336        .map_err(|e| FerroError::NumericalInstability {
337            message: format!("FactorAnalysis: faer SVD failed: {e:?}"),
338        })?;
339    let size = n.min(p);
340    let s = Array1::from_shape_fn(size, |i| svd.S().column_vector()[i]);
341    // V is (p × size); right singular vector c is column c of V, so
342    // Vt_top[c, j] = V[j, c].
343    let v = svd.V();
344    let vt_top = Array2::from_shape_fn((k, p), |(c, j)| v[(j, c)]);
345    Ok((s, vt_top))
346}
347
348/// f32 specialisation of [`factor_analysis_svd_f64`].
349fn factor_analysis_svd_f32(
350    y: &Array2<f32>,
351    k: usize,
352) -> Result<(Array1<f32>, Array2<f32>), FerroError> {
353    let (n, p) = y.dim();
354    let mat = faer::Mat::from_fn(n, p, |i, j| y[[i, j]]);
355    let svd = mat
356        .thin_svd()
357        .map_err(|e| FerroError::NumericalInstability {
358            message: format!("FactorAnalysis: faer SVD failed: {e:?}"),
359        })?;
360    let size = n.min(p);
361    let s = Array1::from_shape_fn(size, |i| svd.S().column_vector()[i]);
362    let v = svd.V();
363    let vt_top = Array2::from_shape_fn((k, p), |(c, j)| v[(j, c)]);
364    Ok((s, vt_top))
365}
366
367/// Dispatch the SVD used by the LAPACK-style FA EM to faer for f64/f32.
368///
369/// Returns `(s, vt_top)` with `s` the full descending singular-value vector and
370/// `vt_top` the `(k × p)` top-`k` right singular vectors.
371fn factor_analysis_svd<F: Float + Send + Sync + 'static>(
372    y: &Array2<F>,
373    k: usize,
374) -> Result<(Array1<F>, Array2<F>), FerroError> {
375    if TypeId::of::<F>() == TypeId::of::<f64>() {
376        // SAFETY: TypeId confirms F == f64, so reinterpreting the reference and
377        // transmuting the results between identical types is sound.
378        let y_f64: &Array2<f64> = unsafe { &*(std::ptr::from_ref(y).cast::<Array2<f64>>()) };
379        let (s, vt) = factor_analysis_svd_f64(y_f64, k)?;
380        // SAFETY: F == f64; transmute_copy reinterprets identical types.
381        let s_f: Array1<F> = unsafe { std::mem::transmute_copy::<Array1<f64>, Array1<F>>(&s) };
382        let vt_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f64>, Array2<F>>(&vt) };
383        std::mem::forget(s);
384        std::mem::forget(vt);
385        Ok((s_f, vt_f))
386    } else if TypeId::of::<F>() == TypeId::of::<f32>() {
387        // SAFETY: TypeId confirms F == f32.
388        let y_f32: &Array2<f32> = unsafe { &*(std::ptr::from_ref(y).cast::<Array2<f32>>()) };
389        let (s, vt) = factor_analysis_svd_f32(y_f32, k)?;
390        // SAFETY: F == f32; transmute_copy reinterprets identical types.
391        let s_f: Array1<F> = unsafe { std::mem::transmute_copy::<Array1<f32>, Array1<F>>(&s) };
392        let vt_f: Array2<F> = unsafe { std::mem::transmute_copy::<Array2<f32>, Array2<F>>(&vt) };
393        std::mem::forget(s);
394        std::mem::forget(vt);
395        Ok((s_f, vt_f))
396    } else {
397        Err(FerroError::NumericalInstability {
398            message: "FactorAnalysis: SVD only supported for f32/f64".into(),
399        })
400    }
401}
402
403// ---------------------------------------------------------------------------
404// Fit
405// ---------------------------------------------------------------------------
406
407impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for FactorAnalysis<F> {
408    type Fitted = FittedFactorAnalysis<F>;
409    type Error = FerroError;
410
411    /// Fit the Factor Analysis model using the EM algorithm.
412    ///
413    /// # Errors
414    ///
415    /// - [`FerroError::InvalidParameter`] if `n_components` is zero or exceeds
416    ///   `n_features`.
417    /// - [`FerroError::InsufficientSamples`] if fewer than 2 samples are provided.
418    fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedFactorAnalysis<F>, FerroError> {
419        let (n_samples, n_features) = x.dim();
420
421        if self.n_components == 0 {
422            return Err(FerroError::InvalidParameter {
423                name: "n_components".into(),
424                reason: "must be at least 1".into(),
425            });
426        }
427        if self.n_components > n_features {
428            return Err(FerroError::InvalidParameter {
429                name: "n_components".into(),
430                reason: format!(
431                    "n_components ({}) exceeds n_features ({})",
432                    self.n_components, n_features
433                ),
434            });
435        }
436        if n_samples < 2 {
437            return Err(FerroError::InsufficientSamples {
438                required: 2,
439                actual: n_samples,
440                context: "FactorAnalysis requires at least 2 samples".into(),
441            });
442        }
443
444        // Finiteness: sklearn `FactorAnalysis.fit` runs `_validate_data`
445        // (`_factor_analysis.py:222`) with the default `force_all_finite=True`,
446        // raising `ValueError("Input X contains NaN."/"...infinity...")`
447        // (`utils/validation.py:147-154`) BEFORE the SVD/EM iteration. This
448        // fires before the SVD, so the clean finiteness error replaces the
449        // incidental `NumericalInstability` (faer `NoConvergence`) the SVD would
450        // otherwise raise (R-DEV-2). NaN AND infinity both rejected (#2288).
451        reject_non_finite(x)?;
452
453        let k = self.n_components;
454        let p = n_features;
455        let n_f = F::from(n_samples).unwrap();
456
457        // Compute mean and centre data.
458        let mut mean = Array1::<F>::zeros(p);
459        for j in 0..p {
460            let s = x.column(j).iter().copied().fold(F::zero(), |a, b| a + b);
461            mean[j] = s / n_f;
462        }
463        let mut xc = x.to_owned();
464        for mut row in xc.rows_mut() {
465            for (v, &m) in row.iter_mut().zip(mean.iter()) {
466                *v = *v - m;
467            }
468        }
469
470        // Deterministic SVD-based EM, mirroring scikit-learn's
471        // `svd_method='lapack'` branch (`_factor_analysis.py:235-311`). The
472        // `random_state` field is retained for API stability but does not
473        // affect the result (LAPACK FA is deterministic).
474        //
475        // Convert an `f64` constant into `F`, propagating an error rather than
476        // panicking if the (in practice impossible) conversion fails.
477        let to_f = |v: f64| -> Result<F, FerroError> {
478            F::from(v).ok_or_else(|| FerroError::NumericalInstability {
479                message: "FactorAnalysis: failed to convert constant into target float type".into(),
480            })
481        };
482        let nsqrt = n_f.sqrt();
483        let two_pi = to_f(2.0 * std::f64::consts::PI)?;
484        // llconst = n_features * ln(2π) + n_components   (:236)
485        let llconst = to_f(p as f64)? * two_pi.ln() + to_f(k as f64)?;
486        // var[j] = (1/n) Σ_i Xc[i,j]²  (mean already removed)   (:237)
487        let mut var = Array1::<F>::zeros(p);
488        for j in 0..p {
489            let s = xc
490                .column(j)
491                .iter()
492                .copied()
493                .map(|v| v * v)
494                .fold(F::zero(), |a, b| a + b);
495            var[j] = s / n_f;
496        }
497
498        let small = to_f(1e-12)?; // SMALL (:252)
499        let two = to_f(2.0)?;
500        let mut psi = Array1::<F>::from_elem(p, F::one()); // (:239-240)
501        let mut w = Array2::<F>::zeros((p, k)); // stored as Wᵀ (p × k)
502        let mut old_ll = F::neg_infinity();
503        let mut last_ll = F::neg_infinity();
504        let mut n_iter = 0usize;
505        let tol_f = to_f(self.tol)?;
506
507        for iter in 0..self.max_iter {
508            // sqrt_psi = sqrt(psi) + SMALL   (:280)
509            let sqrt_psi: Array1<F> = psi.mapv(|v| v.sqrt() + small);
510
511            // Y[i,j] = Xc[i,j] / (sqrt_psi[j] * nsqrt)   (:281)
512            let mut y = Array2::<F>::zeros((n_samples, p));
513            for i in 0..n_samples {
514                for j in 0..p {
515                    y[[i, j]] = xc[[i, j]] / (sqrt_psi[j] * nsqrt);
516                }
517            }
518
519            // my_svd: top-k singular values/vectors + unexplained variance.
520            let (s_all, vt_top) = factor_analysis_svd(&y, k)?;
521            // unexp_var = squared_norm(s[k..])   (:263)
522            let unexp_var = s_all
523                .iter()
524                .skip(k)
525                .copied()
526                .map(|v| v * v)
527                .fold(F::zero(), |a, b| a + b);
528
529            // s **= 2  (:282)  (only the top-k singular values are needed)
530            let mut s_sq = Array1::<F>::zeros(k);
531            for c in 0..k {
532                s_sq[c] = s_all[c] * s_all[c];
533            }
534
535            // W = sqrt(max(s_sq - 1, 0))[:,None] * Vt_top; W *= sqrt_psi
536            // (:284, :286). Stored transposed: w[j,c] = W[c,j].
537            let mut w_new = Array2::<F>::zeros((p, k));
538            for c in 0..k {
539                let coef = (s_sq[c] - F::one()).max(F::zero()).sqrt();
540                for j in 0..p {
541                    w_new[[j, c]] = coef * vt_top[[c, j]] * sqrt_psi[j];
542                }
543            }
544
545            // ll = llconst + Σ_c ln(s_sq[c]) + unexp_var + Σ_j ln(psi[j])
546            // ll *= -n/2   (:289-291)
547            let mut ll = llconst + unexp_var;
548            for c in 0..k {
549                ll = ll + s_sq[c].ln();
550            }
551            for j in 0..p {
552                ll = ll + psi[j].ln();
553            }
554            ll = ll * (-n_f / two);
555
556            w = w_new;
557            last_ll = ll;
558            n_iter = iter + 1;
559
560            // One-sided convergence: (ll - old_ll) < tol   (:293)
561            if ll - old_ll < tol_f {
562                break;
563            }
564            old_ll = ll;
565
566            // psi[j] = max(var[j] - Σ_c W[c,j]², SMALL)   (:297)
567            for j in 0..p {
568                let mut sw = F::zero();
569                for c in 0..k {
570                    sw = sw + w[[j, c]] * w[[j, c]];
571                }
572                psi[j] = (var[j] - sw).max(small);
573            }
574        }
575
576        Ok(FittedFactorAnalysis {
577            components: w,
578            noise_variance: psi,
579            mean,
580            n_iter,
581            log_likelihood: last_ll,
582        })
583    }
584}
585
586// ---------------------------------------------------------------------------
587// Transform — compute factor scores
588// ---------------------------------------------------------------------------
589
590impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedFactorAnalysis<F> {
591    type Output = Array2<F>;
592    type Error = FerroError;
593
594    /// Compute factor scores: `E[Z | X] = Σ_z W^T Ψ⁻¹ (X - μ)^T`.
595    ///
596    /// Returns an array of shape `(n_samples, n_components)`.
597    ///
598    /// # Errors
599    ///
600    /// Returns [`FerroError::ShapeMismatch`] if the number of columns in `x`
601    /// does not match the model.
602    fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
603        let n_features = self.mean.len();
604        if x.ncols() != n_features {
605            return Err(FerroError::ShapeMismatch {
606                expected: vec![x.nrows(), n_features],
607                actual: vec![x.nrows(), x.ncols()],
608                context: "FittedFactorAnalysis::transform".into(),
609            });
610        }
611        // Finiteness on the query X: sklearn `FactorAnalysis.transform` runs
612        // `_validate_data(..., reset=False)` (`_factor_analysis.py:332`),
613        // `force_all_finite=True` raising a `ValueError` BEFORE the projection
614        // (`utils/validation.py:147-154`). NaN AND infinity both rejected (#2289).
615        reject_non_finite(x)?;
616        let (n_samples, _) = x.dim();
617        let k = self.components.ncols();
618
619        // Centre.
620        let mut xc = x.to_owned();
621        for mut row in xc.rows_mut() {
622            for (v, &m) in row.iter_mut().zip(self.mean.iter()) {
623                *v = *v - m;
624            }
625        }
626
627        // Σ_z = (I + W^T Ψ⁻¹ W)⁻¹
628        let mut wzw = Array2::<F>::zeros((k, k));
629        for i in 0..k {
630            for j in 0..k {
631                let mut s = F::zero();
632                for d in 0..n_features {
633                    s = s + self.components[[d, i]] * self.components[[d, j]]
634                        / self.noise_variance[d];
635                }
636                wzw[[i, j]] = s;
637            }
638        }
639        for i in 0..k {
640            wzw[[i, i]] = wzw[[i, i]] + F::one();
641        }
642        let sigma_z = cholesky_inv(&wzw).map_err(|_| FerroError::NumericalInstability {
643            message: "FittedFactorAnalysis::transform: (I + W^T Ψ⁻¹ W) is singular".into(),
644        })?;
645
646        // β = Σ_z W^T Ψ⁻¹  (k × p)
647        let mut beta = Array2::<F>::zeros((k, n_features));
648        for i in 0..k {
649            for d in 0..n_features {
650                let mut s = F::zero();
651                for j in 0..k {
652                    s = s + sigma_z[[i, j]] * self.components[[d, j]];
653                }
654                beta[[i, d]] = s / self.noise_variance[d];
655            }
656        }
657
658        // scores = (β @ X_c^T)^T  (n × k)
659        let ez = beta.dot(&xc.t()); // k × n
660        let scores = ez.t().to_owned(); // n × k
661        assert_eq!(scores.dim(), (n_samples, k));
662        Ok(scores)
663    }
664}
665
666// ---------------------------------------------------------------------------
667// Pipeline integration
668// ---------------------------------------------------------------------------
669
670impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for FactorAnalysis<F> {
671    /// Fit using the pipeline interface (ignores `y`).
672    ///
673    /// # Errors
674    ///
675    /// Propagates errors from [`Fit::fit`].
676    fn fit_pipeline(
677        &self,
678        x: &Array2<F>,
679        _y: &Array1<F>,
680    ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
681        let fitted = self.fit(x, &())?;
682        Ok(Box::new(fitted))
683    }
684}
685
686impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for FittedFactorAnalysis<F> {
687    /// Transform via the pipeline interface.
688    ///
689    /// # Errors
690    ///
691    /// Propagates errors from [`Transform::transform`].
692    fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
693        self.transform(x)
694    }
695}
696
697// ---------------------------------------------------------------------------
698// Tests
699// ---------------------------------------------------------------------------
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704    use approx::assert_abs_diff_eq;
705    use ndarray::Array2;
706
707    fn simple_data() -> Array2<f64> {
708        // 10 samples, 4 features with some latent structure.
709        Array2::from_shape_vec(
710            (10, 4),
711            vec![
712                1.0, 2.0, 1.5, 3.0, 1.1, 2.1, 1.6, 3.1, 0.9, 1.9, 1.4, 2.9, 2.0, 4.0, 3.0, 6.0,
713                2.1, 4.1, 3.1, 6.1, 1.9, 3.9, 2.9, 5.9, 0.5, 1.0, 0.7, 1.5, 0.4, 0.9, 0.6, 1.4,
714                0.6, 1.1, 0.8, 1.6, 1.5, 3.0, 2.2, 4.5,
715            ],
716        )
717        .unwrap()
718    }
719
720    #[test]
721    fn test_fa_fit_returns_fitted() {
722        let fa = FactorAnalysis::<f64>::new(2);
723        let x = simple_data();
724        let fitted = fa.fit(&x, &()).unwrap();
725        assert_eq!(fitted.components().dim(), (4, 2));
726    }
727
728    #[test]
729    fn test_fa_transform_shape() {
730        let fa = FactorAnalysis::<f64>::new(2);
731        let x = simple_data();
732        let fitted = fa.fit(&x, &()).unwrap();
733        let scores = fitted.transform(&x).unwrap();
734        assert_eq!(scores.dim(), (10, 2));
735    }
736
737    #[test]
738    fn test_fa_transform_new_data() {
739        let fa = FactorAnalysis::<f64>::new(1);
740        let x = simple_data();
741        let fitted = fa.fit(&x, &()).unwrap();
742        let x_new = Array2::from_shape_vec(
743            (3, 4),
744            vec![1.0, 2.0, 1.5, 3.0, 2.0, 4.0, 3.0, 6.0, 0.5, 1.0, 0.7, 1.5],
745        )
746        .unwrap();
747        let scores = fitted.transform(&x_new).unwrap();
748        assert_eq!(scores.dim(), (3, 1));
749    }
750
751    #[test]
752    fn test_fa_noise_variance_positive() {
753        let fa = FactorAnalysis::<f64>::new(1);
754        let x = simple_data();
755        let fitted = fa.fit(&x, &()).unwrap();
756        for &v in fitted.noise_variance() {
757            assert!(v > 0.0, "noise variance must be positive, got {v}");
758        }
759    }
760
761    #[test]
762    fn test_fa_mean_shape() {
763        let fa = FactorAnalysis::<f64>::new(1);
764        let x = simple_data();
765        let fitted = fa.fit(&x, &()).unwrap();
766        assert_eq!(fitted.mean().len(), 4);
767    }
768
769    #[test]
770    fn test_fa_n_iter_positive() {
771        let fa = FactorAnalysis::<f64>::new(1);
772        let x = simple_data();
773        let fitted = fa.fit(&x, &()).unwrap();
774        assert!(fitted.n_iter() >= 1);
775    }
776
777    #[test]
778    fn test_fa_log_likelihood_finite() {
779        let fa = FactorAnalysis::<f64>::new(1);
780        let x = simple_data();
781        let fitted = fa.fit(&x, &()).unwrap();
782        assert!(fitted.log_likelihood().is_finite());
783    }
784
785    #[test]
786    fn test_fa_error_zero_components() {
787        let fa = FactorAnalysis::<f64>::new(0);
788        let x = simple_data();
789        assert!(fa.fit(&x, &()).is_err());
790    }
791
792    #[test]
793    fn test_fa_error_too_many_components() {
794        let fa = FactorAnalysis::<f64>::new(10); // more than n_features = 4
795        let x = simple_data();
796        assert!(fa.fit(&x, &()).is_err());
797    }
798
799    #[test]
800    fn test_fa_error_insufficient_samples() {
801        let fa = FactorAnalysis::<f64>::new(1);
802        let x = Array2::from_shape_vec((1, 4), vec![1.0, 2.0, 3.0, 4.0]).unwrap();
803        assert!(fa.fit(&x, &()).is_err());
804    }
805
806    #[test]
807    fn test_fa_transform_shape_mismatch() {
808        let fa = FactorAnalysis::<f64>::new(1);
809        let x = simple_data();
810        let fitted = fa.fit(&x, &()).unwrap();
811        let x_bad = Array2::<f64>::zeros((3, 7));
812        assert!(fitted.transform(&x_bad).is_err());
813    }
814
815    #[test]
816    fn test_fa_reproducible_with_seed() {
817        let fa1 = FactorAnalysis::<f64>::new(2).with_random_state(42);
818        let fa2 = FactorAnalysis::<f64>::new(2).with_random_state(42);
819        let x = simple_data();
820        let f1 = fa1.fit(&x, &()).unwrap();
821        let f2 = fa2.fit(&x, &()).unwrap();
822        let c1 = f1.components();
823        let c2 = f2.components();
824        for (a, b) in c1.iter().zip(c2.iter()) {
825            assert_abs_diff_eq!(a, b, epsilon = 1e-12);
826        }
827    }
828
829    #[test]
830    fn test_fa_different_seeds_differ() {
831        let fa1 = FactorAnalysis::<f64>::new(2)
832            .with_random_state(0)
833            .with_max_iter(1);
834        let fa2 = FactorAnalysis::<f64>::new(2)
835            .with_random_state(99)
836            .with_max_iter(1);
837        let x = simple_data();
838        let f1 = fa1.fit(&x, &()).unwrap();
839        let f2 = fa2.fit(&x, &()).unwrap();
840        // After 1 iteration with different seeds the components should differ.
841        let diff: f64 = f1
842            .components()
843            .iter()
844            .zip(f2.components().iter())
845            .map(|(a, b)| (a - b).abs())
846            .sum();
847        // They may differ unless the initialisation is identical.
848        let _ = diff; // just check it doesn't panic
849    }
850
851    #[test]
852    fn test_fa_components_accessor() {
853        let fa = FactorAnalysis::<f64>::new(2);
854        let x = simple_data();
855        let fitted = fa.fit(&x, &()).unwrap();
856        assert_eq!(fitted.components().ncols(), 2);
857        assert_eq!(fitted.components().nrows(), 4);
858    }
859
860    #[test]
861    fn test_fa_n_components_getter() {
862        let fa = FactorAnalysis::<f64>::new(3);
863        assert_eq!(fa.n_components(), 3);
864    }
865
866    #[test]
867    fn test_fa_pipeline_transformer() {
868        use ferrolearn_core::pipeline::PipelineTransformer;
869        let fa = FactorAnalysis::<f64>::new(2);
870        let x = simple_data();
871        let y = Array1::<f64>::zeros(10);
872        let fitted = fa.fit_pipeline(&x, &y).unwrap();
873        let out = fitted.transform_pipeline(&x).unwrap();
874        assert_eq!(out.ncols(), 2);
875    }
876
877    #[test]
878    fn test_fa_scores_not_all_zero() {
879        let fa = FactorAnalysis::<f64>::new(2);
880        let x = simple_data();
881        let fitted = fa.fit(&x, &()).unwrap();
882        let scores = fitted.transform(&x).unwrap();
883        let total: f64 = scores.iter().map(|v| v.abs()).sum();
884        assert!(total > 0.0, "Factor scores should not all be zero");
885    }
886}