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}