Skip to main content

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}