ferrolearn_decomp/fast_ica.rs
1//! Fast Independent Component Analysis (FastICA).
2//!
3//! FastICA separates a multivariate signal into additive independent components
4//! by maximising non-Gaussianity (negentropy approximation).
5//!
6//! # Algorithm
7//!
8//! 1. **Centre**: subtract the mean of each feature.
9//! 2. **Whiten** (PCA whitening): decorrelate and scale the data so that each
10//! component has unit variance.
11//! 3. **FastICA iteration**: for each unmixing direction `w`, iterate:
12//! ```text
13//! w' = E[X g(w^T X)] - E[g'(w^T X)] w
14//! w' = w' / ||w'||
15//! ```
16//! until convergence, using a chosen nonlinearity `g`.
17//! 4. Two variants are supported: `Parallel` (update all directions
18//! simultaneously) and `Deflation` (extract one at a time via Gram-Schmidt).
19//!
20//! # Non-linearities
21//!
22//! - [`NonLinearity::LogCosh`]: `g(u) = tanh(u)`.
23//! - [`NonLinearity::Exp`]: `g(u) = u exp(-u²/2)`.
24//! - [`NonLinearity::Cube`]: `g(u) = u³`.
25//!
26//! # Examples
27//!
28//! ```
29//! use ferrolearn_decomp::fast_ica::{FastICA, Algorithm, NonLinearity};
30//! use ferrolearn_core::traits::{Fit, Transform};
31//! use ndarray::Array2;
32//!
33//! let ica = FastICA::new(2)
34//! .with_algorithm(Algorithm::Deflation)
35//! .with_fun(NonLinearity::LogCosh)
36//! .with_random_state(0);
37//!
38//! let x = Array2::from_shape_vec(
39//! (6, 2),
40//! vec![1.0, 0.0, 0.0, 1.0, -1.0, 0.0, 0.0, -1.0, 1.0, 1.0, -1.0, -1.0],
41//! ).unwrap();
42//! let fitted = ica.fit(&x, &()).unwrap();
43//! let sources = fitted.transform(&x).unwrap();
44//! assert_eq!(sources.ncols(), 2);
45//! ```
46//!
47//! ## REQ status
48//!
49//! Design: `.design/decomp/fast_ica.md`. Tracking: #1571. Each REQ is BINARY —
50//! SHIPPED (impl + non-test consumer + tests + green verification) or NOT-STARTED
51//! (concrete open blocker). Non-test consumers: crate re-export (`lib.rs:87`), the
52//! PyO3 `_RsFastICA` binding (`ferrolearn-python/src/extras.rs:1109`), and
53//! `PipelineTransformer`. Oracle = live sklearn 1.5.2 (`_fastica.py`), run from
54//! `/tmp` (R-CHAR-3). ICA components/sources are identifiable only up to
55//! permutation+sign+scale; combined with the RNG `w_init` and the eigh-vs-svd
56//! whitening solver, exact VALUES are a carve-out (the algorithm structure matches
57//! sklearn `_ica_par`/`_ica_def` + `_sym_decorrelation`).
58//!
59//! | REQ | Scope | Status | Evidence / Blocker |
60//! |---|---|---|---|
61//! | REQ-1 | Structural: whitening + 3 nonlinearities (LogCosh/Exp/Cube) × 2 algorithms (Parallel/Deflation) recover finite sources `(n_samples,n_components)`, n_iter≥1, determinism, g(0)=0, + ICA source-recovery up to perm+sign+scale + whitening→identity covariance | SHIPPED (scoped) | `fit` (`fast_ica.rs:452`); green-guards `ica_correctness_recovers_known_sources` (abs-corr > 0.9), `whitening_produces_identity_covariance` + in-module tests. STRUCTURAL only, NOT exact values (REQ-4) |
62//! | REQ-2 | Fitted-attr shapes (components k×n_features, mixing n_features×k, mean) | SHIPPED | accessors `components`/`mixing`/`mean`/`n_iter`; `fitted_attribute_shapes` |
63//! | REQ-3 | Error/parameter contracts (n_components 0/>n_features, n_samples<2, transform feature mismatch, NON-FINITE rejection) | SHIPPED (scoped) | `fit`/`transform` guards. NON-FINITE: `fit`+`transform` call `reject_non_finite` (`fast_ica.rs` symbol `reject_non_finite`) BEFORE whitening/the unmixing, returning `InvalidParameter{name:"X", reason:"Input X contains NaN or infinity."}` = sklearn `_validate_data(force_all_finite=True)` (`_fastica.py:564`/`:756`,`utils/validation.py:147-154`) — replaces the prior silent-garbage `Ok`. `tests/divergence_nonfinite.rs::divergence_fast_ica_fit_nan_`/`_fit_inf_rejects_for_finiteness` match the live sklearn 1.5.2 oracle. Was #2288/#2289, fixed. Consumer: FastICA `fit`/`transform` + re-export `lib.rs` |
64//! | REQ-4 | EXACT `components`/source value parity | NOT-STARTED | CARVE-OUT (R-DEFER-3): Xoshiro vs numpy RNG `w_init` + covariance-eigh vs SVD-default whitening + ICA perm/sign/scale identifiability — blocker #1572 |
65//! | REQ-5 | `components_ = W@K` attribute semantics | SHIPPED | `components = w.dot(&whitening)` (= W@K, k×n_features) per sklearn `_fastica.py:683`; `transform` returns `(X−mean)@components_.T` (`:762`). Transform contract `S == (X−mean)@components_.T` pinned by `divergence_fast_ica_components_transform_contract` (#2412, was wrong by ~3.52). |
66//! | REQ-6 | `mixing_ = pinv(components_)` | SHIPPED | `mixing = pinv(components)` (`pinv` symbol: symmetric-eigendecomp `Aᵀ(AAᵀ)⁻¹` Moore-Penrose pseudo-inverse) per sklearn `_fastica.py:689`, replacing the old `Kᵀ·Wᵀ`. Inverse contract `X−mean == S@mixing_.T` pinned by `divergence_fast_ica_mixing_pinv_contract` (#2411, was wrong by ~8.71). |
67//! | REQ-7 | `whiten_solver` svd(default)/eigh + `whiten` modes + `X1*=sqrt(n)` + unit-variance `S_std` rescale | NOT-STARTED | sklearn `_fastica.py:605-631,:676-681` — blocker #1575 |
68//! | REQ-8 | `fun` as callable + `fun_args` (alpha) | NOT-STARTED | sklearn `_fastica.py:141-150`; ferrolearn 3-enum only — blocker #1576 |
69//! | REQ-9 | `w_init` custom init param | NOT-STARTED | sklearn `_fastica.py:638-641` — blocker #1577 |
70//! | REQ-10 | `n_components=None` auto-default | NOT-STARTED | sklearn `_fastica.py:591-592` — blocker #1578 |
71//! | REQ-11 | `inverse_transform` | NOT-STARTED | sklearn `_fastica.py:764+` — blocker #1579 |
72//! | REQ-12 | fitted attrs `whitening_`/`n_features_in_`/`n_iter_`/`mixing_` naming | NOT-STARTED | blocker #1580 |
73//! | REQ-13 | numpy-RandomState `w_init` parity | NOT-STARTED | CARVE-OUT (Xoshiro ≠ numpy) — blocker #1581 |
74//! | REQ-14 | PyO3 `_RsFastICA` binding surface (n_components/fit/transform only) | NOT-STARTED | blocker #1582 |
75//! | REQ-15 | ferray substrate | NOT-STARTED | `ndarray` + `rand` + hand-rolled Jacobi — blocker #1583 |
76//!
77//! Count: **5 SHIPPED (REQ-1,2,3,5,6) / 10 NOT-STARTED (REQ-4,7..15)**.
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 rand::SeedableRng;
85use rand_distr::{Distribution, StandardNormal};
86
87/// Reject non-finite input the way sklearn's `_validate_data` does.
88///
89/// sklearn runs `check_array` with the default `force_all_finite=True` at the
90/// top of `FastICA.fit`/`transform` (`_fastica.py:564`), raising
91/// `ValueError("Input X contains NaN.")` / `"... contains infinity ..."`
92/// (`sklearn/utils/validation.py:147-154`) BEFORE the whitening/ICA iteration.
93/// FastICA has no missing-value support, so NaN AND infinity are both rejected.
94/// The message names "NaN" and "infinity" to mirror sklearn. Never panics
95/// (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// Configuration enums
108// ---------------------------------------------------------------------------
109
110/// FastICA iteration strategy.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum Algorithm {
113 /// Update all unmixing directions simultaneously.
114 Parallel,
115 /// Extract one unmixing direction at a time (Gram-Schmidt orthogonalisation).
116 Deflation,
117}
118
119/// Non-linearity function used to approximate negentropy.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum NonLinearity {
122 /// `g(u) = tanh(u)`, `g'(u) = 1 - tanh²(u)`.
123 LogCosh,
124 /// `g(u) = u exp(-u²/2)`, `g'(u) = (1 - u²) exp(-u²/2)`.
125 Exp,
126 /// `g(u) = u³`, `g'(u) = 3u²`.
127 Cube,
128}
129
130// ---------------------------------------------------------------------------
131// FastICA (unfitted)
132// ---------------------------------------------------------------------------
133
134/// FastICA configuration.
135///
136/// Calling [`Fit::fit`] whitens the data and runs the FastICA algorithm,
137/// returning a [`FittedFastICA`].
138///
139/// # Type Parameters
140///
141/// - `F`: The floating-point scalar type.
142#[derive(Debug, Clone)]
143pub struct FastICA<F> {
144 /// Number of independent components to extract.
145 n_components: usize,
146 /// Iteration strategy.
147 algorithm: Algorithm,
148 /// Non-linearity function.
149 fun: NonLinearity,
150 /// Maximum number of iterations.
151 max_iter: usize,
152 /// Convergence tolerance.
153 tol: f64,
154 /// Optional random seed.
155 random_state: Option<u64>,
156 _marker: std::marker::PhantomData<F>,
157}
158
159impl<F: Float + Send + Sync + 'static> FastICA<F> {
160 /// Create a new `FastICA` that extracts `n_components` independent components.
161 ///
162 /// Defaults: `algorithm = Parallel`, `fun = LogCosh`, `max_iter = 200`,
163 /// `tol = 1e-4`, no fixed random seed.
164 #[must_use]
165 pub fn new(n_components: usize) -> Self {
166 Self {
167 n_components,
168 algorithm: Algorithm::Parallel,
169 fun: NonLinearity::LogCosh,
170 max_iter: 200,
171 tol: 1e-4,
172 random_state: None,
173 _marker: std::marker::PhantomData,
174 }
175 }
176
177 /// Set the iteration strategy.
178 #[must_use]
179 pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
180 self.algorithm = algorithm;
181 self
182 }
183
184 /// Set the non-linearity function.
185 #[must_use]
186 pub fn with_fun(mut self, fun: NonLinearity) -> Self {
187 self.fun = fun;
188 self
189 }
190
191 /// Set the maximum number of iterations.
192 #[must_use]
193 pub fn with_max_iter(mut self, max_iter: usize) -> Self {
194 self.max_iter = max_iter;
195 self
196 }
197
198 /// Set the convergence tolerance.
199 #[must_use]
200 pub fn with_tol(mut self, tol: f64) -> Self {
201 self.tol = tol;
202 self
203 }
204
205 /// Set the random seed for reproducibility.
206 #[must_use]
207 pub fn with_random_state(mut self, seed: u64) -> Self {
208 self.random_state = Some(seed);
209 self
210 }
211
212 /// Return the number of components.
213 #[must_use]
214 pub fn n_components(&self) -> usize {
215 self.n_components
216 }
217}
218
219impl<F: Float + Send + Sync + 'static> Default for FastICA<F> {
220 fn default() -> Self {
221 Self::new(1)
222 }
223}
224
225// ---------------------------------------------------------------------------
226// FittedFastICA
227// ---------------------------------------------------------------------------
228
229/// A fitted FastICA model.
230///
231/// Created by calling [`Fit::fit`] on a [`FastICA`].
232/// Implements [`Transform<Array2<F>>`] to unmix new signals.
233#[derive(Debug, Clone)]
234pub struct FittedFastICA<F> {
235 /// Unmixing operator on centered data (`components_ = W @ K`), shape
236 /// `(n_components, n_features)`.
237 ///
238 /// To recover sources from centered data: `S = (X - mean) @ components_.T`
239 /// (sklearn `_fastica.py:683`).
240 components: Array2<F>,
241
242 /// Mixing matrix (`pinv(components_)`), shape `(n_features, n_components)`
243 /// (sklearn `_fastica.py:689`).
244 mixing: Array2<F>,
245
246 /// Per-feature mean, shape `(n_features,)`.
247 mean: Array1<F>,
248
249 /// Whitening matrix `K` (sklearn `whitening_`), shape `(n_components, n_features)`.
250 ///
251 /// Retained as the sklearn `whitening_` attribute (REQ-12). Now that
252 /// `transform` uses the composed `components_ = W @ K` directly, this is no
253 /// longer read internally; kept for the forthcoming `whitening_` accessor.
254 #[allow(
255 dead_code,
256 reason = "sklearn whitening_ attribute (REQ-12 #1580); composed into components_, accessor pending"
257 )]
258 whitening: Array2<F>,
259
260 /// Number of iterations performed.
261 n_iter: usize,
262
263 /// Number of features seen during fitting.
264 n_features: usize,
265}
266
267impl<F: Float + Send + Sync + 'static> FittedFastICA<F> {
268 /// Unmixing operator on centered data (`W @ K`), shape `(n_components, n_features)`.
269 #[must_use]
270 pub fn components(&self) -> &Array2<F> {
271 &self.components
272 }
273
274 /// Mixing matrix (`pinv(components_)`), shape `(n_features, n_components)`.
275 #[must_use]
276 pub fn mixing(&self) -> &Array2<F> {
277 &self.mixing
278 }
279
280 /// Per-feature mean learned during fitting.
281 #[must_use]
282 pub fn mean(&self) -> &Array1<F> {
283 &self.mean
284 }
285
286 /// Number of iterations performed.
287 #[must_use]
288 pub fn n_iter(&self) -> usize {
289 self.n_iter
290 }
291}
292
293// ---------------------------------------------------------------------------
294// Internal helpers
295// ---------------------------------------------------------------------------
296
297/// Apply the non-linearity `g` and its derivative `g'` element-wise.
298///
299/// Returns `(g_vals, g_prime_vals)`.
300fn apply_nonlinearity<F: Float>(u: &Array1<F>, fun: NonLinearity) -> (Array1<F>, Array1<F>) {
301 let n = u.len();
302 let mut g_vals = Array1::<F>::zeros(n);
303 let mut gp_vals = Array1::<F>::zeros(n);
304 let half = F::from(0.5).unwrap();
305 for i in 0..n {
306 let ui = u[i];
307 match fun {
308 NonLinearity::LogCosh => {
309 // g(u) = tanh(u)
310 // Use the formula: tanh(x) = (e^2x - 1)/(e^2x + 1)
311 let t = if ui > F::from(20.0).unwrap() {
312 F::one()
313 } else if ui < F::from(-20.0).unwrap() {
314 -F::one()
315 } else {
316 let e2 = (ui * F::from(2.0).unwrap()).exp();
317 (e2 - F::one()) / (e2 + F::one())
318 };
319 g_vals[i] = t;
320 gp_vals[i] = F::one() - t * t;
321 }
322 NonLinearity::Exp => {
323 // g(u) = u exp(-u²/2)
324 let neg_u2_half = -(ui * ui) * half;
325 let exp_v = neg_u2_half.exp();
326 g_vals[i] = ui * exp_v;
327 gp_vals[i] = (F::one() - ui * ui) * exp_v;
328 }
329 NonLinearity::Cube => {
330 // g(u) = u³
331 g_vals[i] = ui * ui * ui;
332 gp_vals[i] = F::from(3.0).unwrap() * ui * ui;
333 }
334 }
335 }
336 (g_vals, gp_vals)
337}
338
339/// Compute `g` and mean of `g'` for all samples.
340///
341/// `x_white_w`: the projections `W_row @ X_white`, shape `(n_samples,)`.
342/// Returns `(mean_g_prime, g_vals)` where `g_vals` has shape `(n_samples,)`.
343fn ica_step_values<F: Float>(projections: &Array1<F>, fun: NonLinearity) -> (F, Array1<F>) {
344 let (g_vals, gp_vals) = apply_nonlinearity(projections, fun);
345 let n_f = F::from(projections.len()).unwrap();
346 let mean_gp = gp_vals.iter().copied().fold(F::zero(), |a, b| a + b) / n_f;
347 (mean_gp, g_vals)
348}
349
350/// Gram-Schmidt orthogonalisation of `W` (row vectors).
351fn gs_orthogonalise<F: Float>(w: &mut Array2<F>, col: usize) {
352 let k = col;
353 // w[k] -= sum_{j<k} (w[k] . w[j]) w[j]
354 for j in 0..k {
355 let dot = (0..w.ncols())
356 .map(|d| w[[k, d]] * w[[j, d]])
357 .fold(F::zero(), |a, b| a + b);
358 for d in 0..w.ncols() {
359 let wd = w[[j, d]];
360 w[[k, d]] = w[[k, d]] - dot * wd;
361 }
362 }
363 // Normalise.
364 let norm = (0..w.ncols())
365 .map(|d| w[[k, d]] * w[[k, d]])
366 .fold(F::zero(), |a, b| a + b)
367 .sqrt();
368 if norm > F::from(1e-15).unwrap() {
369 for d in 0..w.ncols() {
370 w[[k, d]] = w[[k, d]] / norm;
371 }
372 }
373}
374
375/// Symmetric orthogonalisation: W ← (W W^T)^{-1/2} W.
376fn sym_orthogonalise<F: Float + Send + Sync + 'static>(
377 w: &mut Array2<F>,
378) -> Result<(), FerroError> {
379 let k = w.nrows();
380 // Compute S = W W^T (k × k).
381 let mut s = Array2::<F>::zeros((k, k));
382 for i in 0..k {
383 for j in 0..k {
384 let dot = (0..w.ncols())
385 .map(|d| w[[i, d]] * w[[j, d]])
386 .fold(F::zero(), |a, b| a + b);
387 s[[i, j]] = dot;
388 }
389 }
390 // Eigendecompose S = V D V^T.
391 let max_iter = k * k * 100 + 1000;
392 let (eigenvalues, eigenvectors) = jacobi_eigen_small(&s, max_iter)?;
393 // W_new = V D^{-1/2} V^T W
394 // = Σ_i (1/sqrt(d_i)) (v_i v_i^T) W
395 let mut w_new = Array2::<F>::zeros((k, w.ncols()));
396 let eps = F::from(1e-10).unwrap();
397 for i in 0..k {
398 let d = eigenvalues[i];
399 let scale = if d > eps {
400 F::one() / d.sqrt()
401 } else {
402 F::one()
403 };
404 // v_i is column i of eigenvectors.
405 // outer product: v_i v_i^T W = v_i (v_i^T W)
406 // (v_i^T W) is a row vector of shape (1, n_comp).
407 let mut vi_t_w = Array1::<F>::zeros(w.ncols());
408 for d_idx in 0..k {
409 for col in 0..w.ncols() {
410 vi_t_w[col] = vi_t_w[col] + eigenvectors[[d_idx, i]] * w[[d_idx, col]];
411 }
412 }
413 for row in 0..k {
414 for col in 0..w.ncols() {
415 w_new[[row, col]] =
416 w_new[[row, col]] + scale * eigenvectors[[row, i]] * vi_t_w[col];
417 }
418 }
419 }
420 *w = w_new;
421 Ok(())
422}
423
424/// Moore-Penrose pseudo-inverse of a `(k × n)` matrix `a` (k ≤ n, full row
425/// rank in practice for `components_ = W @ K`).
426///
427/// Mirrors sklearn's `mixing_ = linalg.pinv(self.components_)`
428/// (`sklearn/decomposition/_fastica.py:689`). Computed via the symmetric
429/// eigendecomposition of `a aᵀ` (a `k × k` symmetric matrix):
430/// `a aᵀ = V D Vᵀ` ⇒ `a⁺ = aᵀ V D⁺ Vᵀ`, where `D⁺` inverts the eigenvalues
431/// above a relative threshold (zeroing the rest, the Moore-Penrose
432/// rank-deficient convention). For full-row-rank `a` this is exact:
433/// `a⁺ = aᵀ (a aᵀ)⁻¹`. Returns shape `(n × k)`. Never panics (R-CODE-2):
434/// the eigendecomposition `Result` is propagated.
435fn pinv<F: Float + Send + Sync + 'static>(a: &Array2<F>) -> Result<Array2<F>, FerroError> {
436 let k = a.nrows();
437 let n = a.ncols();
438 // Gram matrix g = a aᵀ (k × k, symmetric).
439 let g = a.dot(&a.t());
440 let max_iter = k * k * 100 + 1000;
441 let (eigenvalues, eigenvectors) = jacobi_eigen_small(&g, max_iter)?;
442 // Relative threshold (rcond) on the eigenvalues of a aᵀ — singular values
443 // squared. sklearn's pinv uses rcond ≈ max(m,n)·eps on the singular values;
444 // here we threshold on the eigenvalues (sv²) with a conservative bound.
445 let mut max_ev = F::zero();
446 for i in 0..k {
447 if eigenvalues[i] > max_ev {
448 max_ev = eigenvalues[i];
449 }
450 }
451 let rcond = F::from(1e-15).unwrap_or_else(F::epsilon);
452 let cutoff = max_ev * rcond;
453 // ginv = V D⁺ Vᵀ (k × k), then a⁺ = aᵀ ginv (n × k).
454 let mut ginv = Array2::<F>::zeros((k, k));
455 for idx in 0..k {
456 let d = eigenvalues[idx];
457 if d > cutoff {
458 let inv_d = F::one() / d;
459 for r in 0..k {
460 for c in 0..k {
461 ginv[[r, c]] =
462 ginv[[r, c]] + inv_d * eigenvectors[[r, idx]] * eigenvectors[[c, idx]];
463 }
464 }
465 }
466 }
467 // a⁺ = aᵀ · ginv → (n × k)
468 let mut pinv_mat = Array2::<F>::zeros((n, k));
469 for r in 0..n {
470 for c in 0..k {
471 let mut acc = F::zero();
472 for j in 0..k {
473 acc = acc + a[[j, r]] * ginv[[j, c]];
474 }
475 pinv_mat[[r, c]] = acc;
476 }
477 }
478 Ok(pinv_mat)
479}
480
481/// Jacobi eigendecomposition for a small k×k symmetric matrix.
482fn jacobi_eigen_small<F: Float + Send + Sync + 'static>(
483 a: &Array2<F>,
484 max_iter: usize,
485) -> Result<(Array1<F>, Array2<F>), FerroError> {
486 let n = a.nrows();
487 let mut mat = a.to_owned();
488 let mut v = Array2::<F>::zeros((n, n));
489 for i in 0..n {
490 v[[i, i]] = F::one();
491 }
492 let tol = F::from(1e-12).unwrap_or_else(F::epsilon);
493 let two = F::from(2.0).unwrap();
494 for _ in 0..max_iter {
495 let mut max_off = F::zero();
496 let mut p = 0;
497 let mut q = 1;
498 for i in 0..n {
499 for j in (i + 1)..n {
500 let val = mat[[i, j]].abs();
501 if val > max_off {
502 max_off = val;
503 p = i;
504 q = j;
505 }
506 }
507 }
508 if max_off < tol {
509 let eigenvalues = Array1::from_shape_fn(n, |i| mat[[i, i]]);
510 return Ok((eigenvalues, v));
511 }
512 let app = mat[[p, p]];
513 let aqq = mat[[q, q]];
514 let apq = mat[[p, q]];
515 let theta = if (app - aqq).abs() < tol {
516 F::from(std::f64::consts::FRAC_PI_4).unwrap_or_else(F::one)
517 } else {
518 let tau = (aqq - app) / (two * apq);
519 let t = if tau >= F::zero() {
520 F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
521 } else {
522 -F::one() / (tau.abs() + (F::one() + tau * tau).sqrt())
523 };
524 t.atan()
525 };
526 let c = theta.cos();
527 let s = theta.sin();
528 let mut new_mat = mat.clone();
529 for i in 0..n {
530 if i != p && i != q {
531 let mip = mat[[i, p]];
532 let miq = mat[[i, q]];
533 new_mat[[i, p]] = c * mip - s * miq;
534 new_mat[[p, i]] = new_mat[[i, p]];
535 new_mat[[i, q]] = s * mip + c * miq;
536 new_mat[[q, i]] = new_mat[[i, q]];
537 }
538 }
539 new_mat[[p, p]] = c * c * app - two * s * c * apq + s * s * aqq;
540 new_mat[[q, q]] = s * s * app + two * s * c * apq + c * c * aqq;
541 new_mat[[p, q]] = F::zero();
542 new_mat[[q, p]] = F::zero();
543 mat = new_mat;
544 for i in 0..n {
545 let vip = v[[i, p]];
546 let viq = v[[i, q]];
547 v[[i, p]] = c * vip - s * viq;
548 v[[i, q]] = s * vip + c * viq;
549 }
550 }
551 // Didn't fully converge, but return best estimate.
552 let eigenvalues = Array1::from_shape_fn(n, |i| mat[[i, i]]);
553 Ok((eigenvalues, v))
554}
555
556// ---------------------------------------------------------------------------
557// Fit
558// ---------------------------------------------------------------------------
559
560impl<F: Float + Send + Sync + 'static> Fit<Array2<F>, ()> for FastICA<F> {
561 type Fitted = FittedFastICA<F>;
562 type Error = FerroError;
563
564 /// Fit FastICA to data.
565 ///
566 /// # Errors
567 ///
568 /// - [`FerroError::InvalidParameter`] if `n_components` is zero or
569 /// exceeds `n_features`.
570 /// - [`FerroError::InsufficientSamples`] if fewer than 2 samples are provided.
571 fn fit(&self, x: &Array2<F>, _y: &()) -> Result<FittedFastICA<F>, FerroError> {
572 let (n_samples, n_features) = x.dim();
573
574 if self.n_components == 0 {
575 return Err(FerroError::InvalidParameter {
576 name: "n_components".into(),
577 reason: "must be at least 1".into(),
578 });
579 }
580 if self.n_components > n_features {
581 return Err(FerroError::InvalidParameter {
582 name: "n_components".into(),
583 reason: format!(
584 "n_components ({}) exceeds n_features ({})",
585 self.n_components, n_features
586 ),
587 });
588 }
589 if n_samples < 2 {
590 return Err(FerroError::InsufficientSamples {
591 required: 2,
592 actual: n_samples,
593 context: "FastICA requires at least 2 samples".into(),
594 });
595 }
596
597 // Finiteness: sklearn `FastICA.fit` runs `_validate_data`
598 // (`_fastica.py:564`) with the default `force_all_finite=True`, raising
599 // `ValueError("Input X contains NaN."/"...infinity...")`
600 // (`utils/validation.py:147-154`) BEFORE whitening/the ICA iteration.
601 // NaN AND infinity both rejected — replaces the prior silent-garbage
602 // `Ok` (#2288).
603 reject_non_finite(x)?;
604
605 let k = self.n_components;
606 let n_f = F::from(n_samples).unwrap();
607
608 // --- Step 1: Centre --------------------------------------------------
609 let mut mean = Array1::<F>::zeros(n_features);
610 for j in 0..n_features {
611 let s = x.column(j).iter().copied().fold(F::zero(), |a, b| a + b);
612 mean[j] = s / n_f;
613 }
614 let mut xc = x.to_owned();
615 for mut row in xc.rows_mut() {
616 for (v, &m) in row.iter_mut().zip(mean.iter()) {
617 *v = *v - m;
618 }
619 }
620
621 // --- Step 2: Whiten (PCA) -------------------------------------------
622 // Covariance matrix C = X_c^T X_c / n (n_features × n_features)
623 let cov = xc.t().dot(&xc).mapv(|v| v / n_f);
624
625 // Eigendecompose C.
626 let max_jacobi = n_features * n_features * 100 + 1000;
627 let (eigenvalues, eigenvectors) = jacobi_eigen_small(&cov, max_jacobi)?;
628
629 // Sort descending.
630 let mut indices: Vec<usize> = (0..n_features).collect();
631 indices.sort_by(|&a, &b| {
632 eigenvalues[b]
633 .partial_cmp(&eigenvalues[a])
634 .unwrap_or(std::cmp::Ordering::Equal)
635 });
636
637 // Build whitening matrix K: k × n_features.
638 // K[i, :] = eigenvectors[:, indices[i]] / sqrt(eigenvalues[indices[i]])
639 let eps = F::from(1e-10).unwrap();
640 let mut whitening = Array2::<F>::zeros((k, n_features));
641 for i in 0..k {
642 let idx = indices[i];
643 let ev = eigenvalues[idx];
644 let scale = if ev > eps {
645 F::one() / ev.sqrt()
646 } else {
647 F::zero()
648 };
649 for j in 0..n_features {
650 whitening[[i, j]] = eigenvectors[[j, idx]] * scale;
651 }
652 }
653
654 // Whitened data X_w = K @ X_c^T (k × n_samples), then transpose to n × k.
655 let x_white_t = whitening.dot(&xc.t()); // k × n
656 let x_white = x_white_t.t().to_owned(); // n × k
657
658 // --- Step 3: FastICA -------------------------------------------------
659 let seed = self.random_state.unwrap_or(42);
660 let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(seed);
661 let std_normal = StandardNormal;
662
663 // Initialise W as a k × k random matrix (rows are unmixing directions).
664 let mut w = Array2::<F>::zeros((k, k));
665 for i in 0..k {
666 for j in 0..k {
667 let v: f64 = std_normal.sample(&mut rng);
668 w[[i, j]] = F::from(v).unwrap();
669 }
670 }
671 // Orthogonalise initial W.
672 sym_orthogonalise(&mut w)?;
673
674 let tol_f = F::from(self.tol).unwrap();
675 let mut n_iter = 0usize;
676
677 match self.algorithm {
678 Algorithm::Parallel => {
679 for iter in 0..self.max_iter {
680 let mut w_new = Array2::<F>::zeros((k, k));
681 // For each component i, update using all samples.
682 for i in 0..k {
683 // Projection: u = X_w @ w[i] (n_samples,)
684 let w_row: Array1<F> = w.row(i).to_owned();
685 let u: Array1<F> = x_white.dot(&w_row);
686 let (mean_gp, g_vals) = ica_step_values(&u, self.fun);
687 // w_new[i] = (1/n) X_w^T g(u) - mean_g' * w[i]
688 // X_w^T g(u) = sum_t x_w[t] g(u[t]) (k-vector)
689 let mut xw_t_g = Array1::<F>::zeros(k);
690 for t in 0..n_samples {
691 for d in 0..k {
692 xw_t_g[d] = xw_t_g[d] + x_white[[t, d]] * g_vals[t];
693 }
694 }
695 for d in 0..k {
696 xw_t_g[d] = xw_t_g[d] / n_f;
697 }
698 for d in 0..k {
699 w_new[[i, d]] = xw_t_g[d] - mean_gp * w_row[d];
700 }
701 }
702 // Symmetric orthogonalisation.
703 sym_orthogonalise(&mut w_new)?;
704
705 // Convergence: max |1 - |w_new[i] . w[i]||
706 let mut max_change = F::zero();
707 for i in 0..k {
708 let dot: F = (0..k)
709 .map(|d| w_new[[i, d]] * w[[i, d]])
710 .fold(F::zero(), |a, b| a + b);
711 let change = (F::one() - dot.abs()).abs();
712 if change > max_change {
713 max_change = change;
714 }
715 }
716 w = w_new;
717 n_iter = iter + 1;
718 if max_change < tol_f {
719 break;
720 }
721 }
722 }
723 Algorithm::Deflation => {
724 for i in 0..k {
725 for iter in 0..self.max_iter {
726 // Projection: u = X_w @ w[i] (n_samples,)
727 let w_row: Array1<F> = w.row(i).to_owned();
728 let u: Array1<F> = x_white.dot(&w_row);
729 let (mean_gp, g_vals) = ica_step_values(&u, self.fun);
730 // w_new = (1/n) X_w^T g(u) - mean_g' * w[i]
731 let mut w_new_row = Array1::<F>::zeros(k);
732 for t in 0..n_samples {
733 for d in 0..k {
734 w_new_row[d] = w_new_row[d] + x_white[[t, d]] * g_vals[t];
735 }
736 }
737 for d in 0..k {
738 w_new_row[d] = w_new_row[d] / n_f - mean_gp * w_row[d];
739 }
740 // Gram-Schmidt orthogonalisation.
741 for j in 0..i {
742 let dot: F = (0..k)
743 .map(|d| w_new_row[d] * w[[j, d]])
744 .fold(F::zero(), |a, b| a + b);
745 for d in 0..k {
746 let wd = w[[j, d]];
747 w_new_row[d] = w_new_row[d] - dot * wd;
748 }
749 }
750 // Normalise.
751 let norm = w_new_row
752 .iter()
753 .copied()
754 .map(|v| v * v)
755 .fold(F::zero(), |a, b| a + b)
756 .sqrt();
757 if norm > F::from(1e-15).unwrap() {
758 w_new_row.mapv_inplace(|v| v / norm);
759 }
760 // Convergence: |1 - |w_new . w_old||
761 let dot: F = (0..k)
762 .map(|d| w_new_row[d] * w_row[d])
763 .fold(F::zero(), |a, b| a + b);
764 let change = (F::one() - dot.abs()).abs();
765 for d in 0..k {
766 w[[i, d]] = w_new_row[d];
767 }
768 n_iter = iter + 1;
769 if change < tol_f {
770 break;
771 }
772 }
773 // Gram-Schmidt after finalising component i.
774 gs_orthogonalise(&mut w, i);
775 }
776 }
777 }
778
779 // --- Components & mixing matrix --------------------------------------
780 // sklearn stores the FULL unmixing operator on centered data:
781 // `components_ = W @ K` (`_fastica.py:683`, shape k × n_features), so the
782 // transform contract `S = (X - mean_) @ components_.T` holds; and
783 // `mixing_ = linalg.pinv(components_)` (`_fastica.py:689`, the
784 // Moore-Penrose pseudo-inverse, shape n_features × k), so the inverse
785 // contract `X - mean_ == S @ mixing_.T` holds. The previous `KᵀWᵀ` was
786 // only an approximation of the pinv (exact only when K's rows are
787 // orthonormal), diverging by O(1).
788 let components = w.dot(&whitening); // (k × n_features)
789 let mixing = pinv(&components)?; // (n_features × k)
790
791 Ok(FittedFastICA {
792 components,
793 mixing,
794 mean,
795 whitening,
796 n_iter,
797 n_features,
798 })
799 }
800}
801
802// ---------------------------------------------------------------------------
803// Transform
804// ---------------------------------------------------------------------------
805
806impl<F: Float + Send + Sync + 'static> Transform<Array2<F>> for FittedFastICA<F> {
807 type Output = Array2<F>;
808 type Error = FerroError;
809
810 /// Unmix new signals: `S = (X - mean) @ components_.T` (`components_ = W @ K`).
811 ///
812 /// Returns an array of shape `(n_samples, n_components)`.
813 ///
814 /// # Errors
815 ///
816 /// Returns [`FerroError::ShapeMismatch`] if the number of features does not
817 /// match the model.
818 fn transform(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
819 if x.ncols() != self.n_features {
820 return Err(FerroError::ShapeMismatch {
821 expected: vec![x.nrows(), self.n_features],
822 actual: vec![x.nrows(), x.ncols()],
823 context: "FittedFastICA::transform".into(),
824 });
825 }
826 // Finiteness on the query X: sklearn `FastICA.transform` runs
827 // `_validate_data(..., reset=False)` (`_fastica.py:756`),
828 // `force_all_finite=True` raising a `ValueError` BEFORE the unmixing
829 // (`utils/validation.py:147-154`). NaN AND infinity both rejected (#2289).
830 reject_non_finite(x)?;
831 // Centre.
832 let mut xc = x.to_owned();
833 for mut row in xc.rows_mut() {
834 for (v, &m) in row.iter_mut().zip(self.mean.iter()) {
835 *v = *v - m;
836 }
837 }
838 // Unmix directly with the stored full unmixing operator:
839 // S = (X - mean) @ components_.T, where components_ = W @ K
840 // (sklearn `_fastica.py:762` `np.dot(X, self.components_.T)`). This is
841 // identical to applying K then W explicitly, but uses the composed
842 // matrix so the stored `components_` satisfies the transform contract.
843 let sources = xc.dot(&self.components.t()); // n × k
844 Ok(sources)
845 }
846}
847
848// ---------------------------------------------------------------------------
849// Pipeline integration (generic)
850// ---------------------------------------------------------------------------
851
852impl<F: Float + Send + Sync + 'static> PipelineTransformer<F> for FastICA<F> {
853 /// Fit using the pipeline interface (ignores `y`).
854 ///
855 /// # Errors
856 ///
857 /// Propagates errors from [`Fit::fit`].
858 fn fit_pipeline(
859 &self,
860 x: &Array2<F>,
861 _y: &Array1<F>,
862 ) -> Result<Box<dyn FittedPipelineTransformer<F>>, FerroError> {
863 let fitted = self.fit(x, &())?;
864 Ok(Box::new(fitted))
865 }
866}
867
868impl<F: Float + Send + Sync + 'static> FittedPipelineTransformer<F> for FittedFastICA<F> {
869 /// Transform via the pipeline interface.
870 ///
871 /// # Errors
872 ///
873 /// Propagates errors from [`Transform::transform`].
874 fn transform_pipeline(&self, x: &Array2<F>) -> Result<Array2<F>, FerroError> {
875 self.transform(x)
876 }
877}
878
879// ---------------------------------------------------------------------------
880// Tests
881// ---------------------------------------------------------------------------
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use approx::assert_abs_diff_eq;
887 use ndarray::Array2;
888
889 fn mixed_signals() -> Array2<f64> {
890 // Two synthetic source signals, then mixed.
891 let n = 50;
892 let mut x = Array2::<f64>::zeros((n, 2));
893 for i in 0..n {
894 let t = i as f64 * 0.2;
895 // source 1: sine wave, source 2: sawtooth
896 let s1 = t.sin();
897 let s2 = (t * 0.5).cos();
898 // mixing matrix
899 x[[i, 0]] = 0.5 * s1 + 0.5 * s2;
900 x[[i, 1]] = 0.2 * s1 + 0.8 * s2;
901 }
902 x
903 }
904
905 #[test]
906 fn test_ica_fit_returns_fitted() {
907 let ica = FastICA::<f64>::new(2).with_random_state(0);
908 let x = mixed_signals();
909 let fitted = ica.fit(&x, &()).unwrap();
910 assert_eq!(fitted.components().dim(), (2, 2));
911 }
912
913 #[test]
914 fn test_ica_transform_shape() {
915 let ica = FastICA::<f64>::new(2).with_random_state(0);
916 let x = mixed_signals();
917 let fitted = ica.fit(&x, &()).unwrap();
918 let sources = fitted.transform(&x).unwrap();
919 assert_eq!(sources.dim(), (50, 2));
920 }
921
922 #[test]
923 fn test_ica_parallel_algorithm() {
924 let ica = FastICA::<f64>::new(2)
925 .with_algorithm(Algorithm::Parallel)
926 .with_random_state(1);
927 let x = mixed_signals();
928 let fitted = ica.fit(&x, &()).unwrap();
929 assert_eq!(fitted.components().nrows(), 2);
930 }
931
932 #[test]
933 fn test_ica_deflation_algorithm() {
934 let ica = FastICA::<f64>::new(2)
935 .with_algorithm(Algorithm::Deflation)
936 .with_random_state(2);
937 let x = mixed_signals();
938 let fitted = ica.fit(&x, &()).unwrap();
939 assert_eq!(fitted.components().nrows(), 2);
940 }
941
942 #[test]
943 fn test_ica_logcosh() {
944 let ica = FastICA::<f64>::new(2)
945 .with_fun(NonLinearity::LogCosh)
946 .with_random_state(3);
947 let x = mixed_signals();
948 let fitted = ica.fit(&x, &()).unwrap();
949 let s = fitted.transform(&x).unwrap();
950 assert_eq!(s.ncols(), 2);
951 }
952
953 #[test]
954 fn test_ica_exp() {
955 let ica = FastICA::<f64>::new(2)
956 .with_fun(NonLinearity::Exp)
957 .with_random_state(4);
958 let x = mixed_signals();
959 let fitted = ica.fit(&x, &()).unwrap();
960 let s = fitted.transform(&x).unwrap();
961 assert_eq!(s.ncols(), 2);
962 }
963
964 #[test]
965 fn test_ica_cube() {
966 let ica = FastICA::<f64>::new(2)
967 .with_fun(NonLinearity::Cube)
968 .with_random_state(5);
969 let x = mixed_signals();
970 let fitted = ica.fit(&x, &()).unwrap();
971 let s = fitted.transform(&x).unwrap();
972 assert_eq!(s.ncols(), 2);
973 }
974
975 #[test]
976 fn test_ica_n_iter_positive() {
977 let ica = FastICA::<f64>::new(2).with_random_state(0);
978 let x = mixed_signals();
979 let fitted = ica.fit(&x, &()).unwrap();
980 assert!(fitted.n_iter() >= 1);
981 }
982
983 #[test]
984 fn test_ica_mixing_shape() {
985 let ica = FastICA::<f64>::new(2).with_random_state(0);
986 let x = mixed_signals();
987 let fitted = ica.fit(&x, &()).unwrap();
988 assert_eq!(fitted.mixing().dim(), (2, 2));
989 }
990
991 #[test]
992 fn test_ica_mean_shape() {
993 let ica = FastICA::<f64>::new(2).with_random_state(0);
994 let x = mixed_signals();
995 let fitted = ica.fit(&x, &()).unwrap();
996 assert_eq!(fitted.mean().len(), 2);
997 }
998
999 #[test]
1000 fn test_ica_transform_shape_mismatch() {
1001 let ica = FastICA::<f64>::new(2).with_random_state(0);
1002 let x = mixed_signals();
1003 let fitted = ica.fit(&x, &()).unwrap();
1004 let x_bad = Array2::<f64>::zeros((3, 5));
1005 assert!(fitted.transform(&x_bad).is_err());
1006 }
1007
1008 #[test]
1009 fn test_ica_error_zero_components() {
1010 let ica = FastICA::<f64>::new(0);
1011 let x = mixed_signals();
1012 assert!(ica.fit(&x, &()).is_err());
1013 }
1014
1015 #[test]
1016 fn test_ica_error_too_many_components() {
1017 let ica = FastICA::<f64>::new(10); // n_features = 2
1018 let x = mixed_signals();
1019 assert!(ica.fit(&x, &()).is_err());
1020 }
1021
1022 #[test]
1023 fn test_ica_error_insufficient_samples() {
1024 let ica = FastICA::<f64>::new(1);
1025 let x = Array2::<f64>::zeros((1, 2));
1026 assert!(ica.fit(&x, &()).is_err());
1027 }
1028
1029 #[test]
1030 fn test_ica_single_component() {
1031 let ica = FastICA::<f64>::new(1).with_random_state(0);
1032 let x = mixed_signals();
1033 let fitted = ica.fit(&x, &()).unwrap();
1034 let s = fitted.transform(&x).unwrap();
1035 assert_eq!(s.dim(), (50, 1));
1036 }
1037
1038 #[test]
1039 fn test_ica_sources_not_all_zero() {
1040 let ica = FastICA::<f64>::new(2).with_random_state(0);
1041 let x = mixed_signals();
1042 let fitted = ica.fit(&x, &()).unwrap();
1043 let s = fitted.transform(&x).unwrap();
1044 let total: f64 = s.iter().map(|v| v.abs()).sum();
1045 assert!(total > 0.0);
1046 }
1047
1048 #[test]
1049 fn test_ica_reproducible_with_seed() {
1050 let ica1 = FastICA::<f64>::new(2).with_random_state(7);
1051 let ica2 = FastICA::<f64>::new(2).with_random_state(7);
1052 let x = mixed_signals();
1053 let f1 = ica1.fit(&x, &()).unwrap();
1054 let f2 = ica2.fit(&x, &()).unwrap();
1055 for (a, b) in f1.components().iter().zip(f2.components().iter()) {
1056 assert_abs_diff_eq!(a, b, epsilon = 1e-12);
1057 }
1058 }
1059
1060 #[test]
1061 fn test_ica_pipeline_transformer() {
1062 use ferrolearn_core::pipeline::PipelineTransformer;
1063 let ica = FastICA::<f64>::new(2).with_random_state(0);
1064 let x = mixed_signals();
1065 let y = Array1::<f64>::zeros(50);
1066 let fitted = ica.fit_pipeline(&x, &y).unwrap();
1067 let out = fitted.transform_pipeline(&x).unwrap();
1068 assert_eq!(out.ncols(), 2);
1069 }
1070
1071 #[test]
1072 fn test_ica_n_components_getter() {
1073 let ica = FastICA::<f64>::new(3);
1074 assert_eq!(ica.n_components(), 3);
1075 }
1076
1077 #[test]
1078 fn test_ica_nonlinearity_values() {
1079 // Check g(0) = 0 for all non-linearities.
1080 let u = Array1::from_vec(vec![0.0f64]);
1081 let (g_lc, _) = apply_nonlinearity(&u, NonLinearity::LogCosh);
1082 let (g_exp, _) = apply_nonlinearity(&u, NonLinearity::Exp);
1083 let (g_cube, _) = apply_nonlinearity(&u, NonLinearity::Cube);
1084 assert_abs_diff_eq!(g_lc[0], 0.0, epsilon = 1e-10);
1085 assert_abs_diff_eq!(g_exp[0], 0.0, epsilon = 1e-10);
1086 assert_abs_diff_eq!(g_cube[0], 0.0, epsilon = 1e-10);
1087 }
1088}