Skip to main content

stats_claw/algorithms/decomposition/
ica.rs

1//! Independent component analysis via the symmetric `FastICA` fixed-point iteration.
2//!
3//! `FastICA` (Hyvärinen & Oja 2000) separates a linear mixture into statistically
4//! independent sources. The mixed data is mean-centered and whitened (decorrelated
5//! to unit variance) using the covariance eigen-decomposition, then a set of
6//! orthonormal unmixing directions is found by the symmetric fixed-point update with
7//! the `logcosh` contrast `g(u) = tanh(u)` — the same nonlinearity `scikit-learn`'s
8//! `FastICA` uses by default. The recovered sources are the whitened data projected
9//! onto those directions, normalized to unit variance (matching `whiten=
10//! "unit-variance"`).
11//!
12//! Neither the order nor the sign of the recovered sources is identifiable, so the
13//! equivalence suite matches each reference source to its best-correlated recovered
14//! source and checks the magnitude of that correlation rather than raw values.
15
16use crate::algorithms::decomposition::{
17    at, count_to_f64, descending_order, jacobi_eigen, mean_center,
18};
19use crate::rng::SplitMix64;
20
21/// Outcome of a `FastICA` fit.
22#[derive(Debug, Clone)]
23pub struct IcaResult {
24    /// The recovered independent sources, one row per observation and one column per
25    /// component, each column zero-mean and unit-variance.
26    pub sources: Vec<Vec<f64>>,
27}
28
29/// Separates the linear mixture `mixed` into `n_components` independent sources.
30///
31/// # Arguments
32///
33/// * `mixed` — observed mixed signals; each inner slice is one observation. Empty
34///   input yields an empty result.
35/// * `n_components` — number of sources to recover, clamped to the feature dimension.
36/// * `max_iter` — maximum fixed-point iterations.
37/// * `tol` — convergence tolerance on the unmixing matrix between iterations.
38/// * `seed` — RNG seed for the orthogonal initialisation (determinism contract).
39///
40/// # Returns
41///
42/// An [`IcaResult`] whose `sources` columns are the recovered unit-variance signals.
43///
44/// # Examples
45///
46/// ```
47/// use stats_claw::algorithms::decomposition::fast_ica;
48///
49/// // Two simple signals mixed by an invertible matrix.
50/// let mixed: Vec<Vec<f64>> = (0..50)
51///     .map(|i| {
52///         let t = f64::from(i) * 0.3;
53///         let s1 = t.sin();
54///         let s2 = if i % 2 == 0 { 1.0 } else { -1.0 };
55///         vec![s1 + 0.5 * s2, 0.3 * s1 + s2]
56///     })
57///     .collect();
58/// let r = fast_ica(&mixed, 2, 200, 1e-4, 1);
59/// assert_eq!(r.sources.len(), 50, "one source row per observation");
60/// ```
61#[must_use]
62pub fn fast_ica(
63    mixed: &[Vec<f64>],
64    n_components: usize,
65    max_iter: usize,
66    tol: f64,
67    seed: u64,
68) -> IcaResult {
69    let dim = mixed.first().map_or(0, Vec::len);
70    let k = n_components.min(dim);
71    let n = mixed.len();
72    if dim == 0 || k == 0 || n == 0 {
73        return IcaResult {
74            sources: Vec::new(),
75        };
76    }
77    let whitened = whiten(mixed, dim, k);
78    let unmixing = symmetric_fastica(&whitened, k, max_iter, tol, seed);
79    let sources = project(&whitened, &unmixing, k);
80    IcaResult { sources }
81}
82
83/// Whitens the data: mean-center, then project onto the covariance eigenvectors
84/// scaled to unit variance, keeping the `k` leading directions.
85///
86/// Returns the `n × k` whitened matrix whose columns are uncorrelated with unit
87/// variance, the standard `FastICA` preprocessing.
88fn whiten(mixed: &[Vec<f64>], dim: usize, k: usize) -> Vec<Vec<f64>> {
89    let (centered, _means) = mean_center(mixed, dim);
90    let mut cov = vec![0.0_f64; dim * dim];
91    for row in &centered {
92        for i in 0..dim {
93            let ri = row.get(i).copied().unwrap_or(0.0);
94            for j in 0..dim {
95                let rj = row.get(j).copied().unwrap_or(0.0);
96                if let Some(slot) = cov.get_mut(i * dim + j) {
97                    *slot = ri.mul_add(rj, *slot);
98                }
99            }
100        }
101    }
102    let denom = count_to_f64(centered.len());
103    if denom > 0.0 {
104        for c in &mut cov {
105            *c /= denom;
106        }
107    }
108    let (values, vectors) = jacobi_eigen(&cov, dim);
109    let order = descending_order(&values);
110    let top: Vec<usize> = order.into_iter().take(k).collect();
111    centered
112        .iter()
113        .map(|row| {
114            top.iter()
115                .map(|&col| {
116                    let eig = values.get(col).copied().unwrap_or(0.0).max(1e-12);
117                    let projection: f64 = (0..dim)
118                        .map(|f| row.get(f).copied().unwrap_or(0.0) * at(&vectors, dim, f, col))
119                        .sum();
120                    projection / eig.sqrt()
121                })
122                .collect()
123        })
124        .collect()
125}
126
127/// Runs the symmetric `FastICA` fixed-point iteration with the `logcosh` contrast.
128///
129/// Returns the `k × k` orthonormal unmixing matrix acting on the whitened data.
130///
131/// The short names (`w`, `c`, `g`, `u`, `e`) are the canonical `FastICA` notation —
132/// `w` the unmixing matrix, `g` the contrast, `u = wᵀx` the projection; renaming
133/// them would obscure the standard update rather than clarify it.
134#[allow(clippy::many_single_char_names)]
135fn symmetric_fastica(
136    whitened: &[Vec<f64>],
137    k: usize,
138    max_iter: usize,
139    tol: f64,
140    seed: u64,
141) -> Vec<Vec<f64>> {
142    let mut rng = SplitMix64::new(seed);
143    let mut w: Vec<Vec<f64>> = (0..k)
144        .map(|_| (0..k).map(|_| rng.standard_normal()).collect())
145        .collect();
146    symmetric_decorrelate(&mut w, k);
147
148    let n = count_to_f64(whitened.len()).max(1.0);
149    for _ in 0..max_iter {
150        let mut next = vec![vec![0.0_f64; k]; k];
151        for (c, wc) in w.iter().enumerate() {
152            // For component c: E[x g(wᵀx)] − E[g'(wᵀx)] w, with g = tanh.
153            let mut expectation = vec![0.0_f64; k];
154            let mut mean_gprime = 0.0_f64;
155            for row in whitened {
156                let u: f64 = row.iter().zip(wc).map(|(&x, &wi)| x * wi).sum();
157                let g = u.tanh();
158                let gprime = g.mul_add(-g, 1.0);
159                for (e, &x) in expectation.iter_mut().zip(row) {
160                    *e = x.mul_add(g, *e);
161                }
162                mean_gprime += gprime;
163            }
164            mean_gprime /= n;
165            if let Some(target) = next.get_mut(c) {
166                for (slot, (&e, &wi)) in target.iter_mut().zip(expectation.iter().zip(wc)) {
167                    *slot = (-mean_gprime).mul_add(wi, e / n);
168                }
169            }
170        }
171        symmetric_decorrelate(&mut next, k);
172        let delta = max_abs_change(&w, &next, k);
173        w = next;
174        if delta < tol {
175            break;
176        }
177    }
178    w
179}
180
181/// Symmetric decorrelation `W ← (W Wᵀ)^{-1/2} W` via the eigen-decomposition of
182/// `W Wᵀ`, keeping the rows orthonormal without privileging any component.
183fn symmetric_decorrelate(w: &mut [Vec<f64>], k: usize) {
184    let mut gram = vec![0.0_f64; k * k];
185    for a in 0..k {
186        for b in 0..k {
187            let dot: f64 = w.get(a).zip(w.get(b)).map_or(0.0, |(wa, wb)| {
188                wa.iter().zip(wb).map(|(&x, &y)| x * y).sum()
189            });
190            if let Some(slot) = gram.get_mut(a * k + b) {
191                *slot = dot;
192            }
193        }
194    }
195    let (values, vectors) = jacobi_eigen(&gram, k);
196    // inv_sqrt = V diag(1/sqrt(λ)) Vᵀ
197    let mut inv_sqrt = vec![0.0_f64; k * k];
198    for i in 0..k {
199        for j in 0..k {
200            let mut acc = 0.0_f64;
201            for (e, &lambda) in values.iter().enumerate().take(k) {
202                let safe = lambda.max(1e-12).sqrt();
203                acc += at(&vectors, k, i, e) * at(&vectors, k, j, e) / safe;
204            }
205            put(&mut inv_sqrt, k, i, j, acc);
206        }
207    }
208    let original: Vec<Vec<f64>> = w.iter().map(Clone::clone).collect();
209    for (a, row) in w.iter_mut().enumerate().take(k) {
210        for (b, slot) in row.iter_mut().enumerate().take(k) {
211            *slot = (0..k)
212                .map(|m| {
213                    at(&inv_sqrt, k, a, m)
214                        * original
215                            .get(m)
216                            .and_then(|r| r.get(b))
217                            .copied()
218                            .unwrap_or(0.0)
219                })
220                .sum();
221        }
222    }
223}
224
225/// Writes entry `(i, j)` into a row-major `n×n` buffer (no-op if out of range).
226fn put(matrix: &mut [f64], n: usize, i: usize, j: usize, value: f64) {
227    if let Some(slot) = matrix.get_mut(i * n + j) {
228        *slot = value;
229    }
230}
231
232/// Largest absolute entrywise change between two `k×k` unmixing matrices.
233fn max_abs_change(a: &[Vec<f64>], b: &[Vec<f64>], k: usize) -> f64 {
234    let mut worst = 0.0_f64;
235    for row in 0..k {
236        for col in 0..k {
237            let av = a.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0);
238            let bv = b.get(row).and_then(|r| r.get(col)).copied().unwrap_or(0.0);
239            // Sign-insensitive: a flipped row is the same direction.
240            worst = worst.max((av.abs() - bv.abs()).abs());
241        }
242    }
243    worst
244}
245
246/// Projects the whitened data onto the unmixing directions, normalizing each source
247/// column to zero mean and unit variance (matching `whiten="unit-variance"`).
248fn project(whitened: &[Vec<f64>], unmixing: &[Vec<f64>], k: usize) -> Vec<Vec<f64>> {
249    let mut sources: Vec<Vec<f64>> = whitened
250        .iter()
251        .map(|row| {
252            (0..k)
253                .map(|c| {
254                    unmixing
255                        .get(c)
256                        .map_or(0.0, |w| row.iter().zip(w).map(|(&x, &wi)| x * wi).sum())
257                })
258                .collect()
259        })
260        .collect();
261    let n = count_to_f64(sources.len()).max(1.0);
262    for c in 0..k {
263        let mean: f64 = sources
264            .iter()
265            .map(|r| r.get(c).copied().unwrap_or(0.0))
266            .sum::<f64>()
267            / n;
268        let var: f64 = sources
269            .iter()
270            .map(|r| {
271                let d = r.get(c).copied().unwrap_or(0.0) - mean;
272                d * d
273            })
274            .sum::<f64>()
275            / n;
276        let std = var.sqrt().max(1e-12);
277        for row in &mut sources {
278            if let Some(slot) = row.get_mut(c) {
279                *slot = (*slot - mean) / std;
280            }
281        }
282    }
283    sources
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    /// Builds a deterministic 2-source mixture for the recovery tests.
291    fn mixture() -> Vec<Vec<f64>> {
292        (0..200)
293            .map(|i| {
294                let t = f64::from(i) * 0.2;
295                let s1 = (2.0 * t).sin();
296                let s2 = if (3.0 * t).sin() >= 0.0 { 1.0 } else { -1.0 };
297                vec![0.6_f64.mul_add(s2, s1), 0.4_f64.mul_add(s1, 1.2 * s2)]
298            })
299            .collect()
300    }
301
302    #[test]
303    fn recovers_two_unit_variance_sources() {
304        let r = fast_ica(&mixture(), 2, 300, 1e-5, 3);
305        assert_eq!(r.sources.len(), 200, "source row count");
306        // Each recovered column has (near) unit variance by construction.
307        let n = count_to_f64(r.sources.len());
308        for c in 0..2 {
309            let var: f64 = r
310                .sources
311                .iter()
312                .map(|row| {
313                    let v = row.get(c).copied().unwrap_or(0.0);
314                    v * v
315                })
316                .sum::<f64>()
317                / n;
318            assert!((var - 1.0).abs() < 1e-6, "source {c} variance was {var}");
319        }
320    }
321
322    #[test]
323    fn deterministic_for_fixed_seed() {
324        let a = fast_ica(&mixture(), 2, 300, 1e-5, 9).sources;
325        let b = fast_ica(&mixture(), 2, 300, 1e-5, 9).sources;
326        let first_a = a.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
327        let first_b = b.first().and_then(|r| r.first()).copied().unwrap_or(0.0);
328        assert_eq!(first_a.to_bits(), first_b.to_bits(), "non-deterministic");
329    }
330
331    #[test]
332    fn empty_input_is_empty() {
333        assert!(fast_ica(&[], 2, 10, 1e-4, 0).sources.is_empty());
334    }
335}