Skip to main content

bixverse_methods/methods/
ica.rs

1//! Implementation of independent component analysis, based on the fastICA from
2//! Hyvrinen and Oja, Neural Comput., 1997
3
4use faer::{
5    Accum, Mat, MatRef, Par, Scale,
6    linalg::solvers::{PartialPivLu, Solve},
7};
8use rand::prelude::*;
9use rand_distr::Distribution;
10use rand_distr::Normal;
11use rayon::prelude::*;
12
13use crate::prelude::*;
14use bixverse_core::core::math::matrix_helpers::scale_matrix_col;
15use bixverse_core::core::math::pca_svd::randomised_svd;
16use bixverse_core::utils::faer_parallelism;
17use bixverse_core::utils::gemm::gemm;
18
19///////////
20// Types //
21///////////
22
23/// Type alias of the ICA results
24///
25/// ### Fields
26///
27/// * `0` - Mixing matrix w
28/// * `1` - Tolerance
29type IcaRes<T> = (Mat<T>, T);
30
31///////////
32// Enums //
33///////////
34
35/// Enum for the ICA types
36#[derive(Clone, Debug, Default)]
37pub enum IcaType {
38    /// Use the `LogCosh` type implementation of fastICA
39    #[default]
40    LogCosh,
41    /// Use the `Exp` type implementation of fastICA
42    Exp,
43}
44
45/// Parsing the ICA types
46///
47/// ### Params
48///
49/// * `s` - string defining the ICA type
50///
51/// ### Returns
52///
53/// The `IcaType`.
54pub fn parse_ica_type(s: &str) -> Option<IcaType> {
55    match s.to_lowercase().as_str() {
56        "exp" => Some(IcaType::Exp),
57        "logcosh" => Some(IcaType::LogCosh),
58        _ => None,
59    }
60}
61
62////////////
63// Params //
64////////////
65
66/// Structure to save ICA parameters
67#[derive(Clone, Debug)]
68pub struct IcaParams<T: BixverseFloat> {
69    /// Maximum number of iterations for ICA
70    pub maxit: usize,
71    /// Alpha parameter for the `logcosh` variant.
72    pub alpha: T,
73    /// Tolerance parameter.
74    pub tol: T,
75    /// Controls ICA internal verbosity
76    pub verbose: bool,
77}
78
79///////////////
80// Functions //
81///////////////
82
83/// Prepare the whitening.
84///
85/// This is needed pre-processing for ICA. Has the option to use randomised SVD
86/// for faster computations.
87///
88/// ### Params
89///
90/// * `x` - The pre-processed matrix on which to apply ICA.
91/// * `fast_svd` - Shall the faster version of SVD be used.
92/// * `seed` - Random seed for reproducibility purposes
93/// * `rank` - The target rank of the approximation (number of singular values,
94///   vectors to compute).
95/// * `oversampling` - Additional samples beyond the target rank to improve accuracy.
96///   Defaults to 10 if not specified.
97/// * `n_power_iter` - Number of power iterations to perform for better approximation
98///   quality. More iterations generally improve accuracy but increase computation time.
99///   Defaults to 2 if not specified.
100///
101/// ### Returns
102///
103/// A tuple of the processed matrix (pre-whitening) and the whitening matrix K.
104pub fn prepare_whitening<T: BixverseFloat>(
105    x: MatRef<T>,
106    fast_svd: bool,
107    seed: usize,
108    rank: usize,
109    oversampling: Option<usize>,
110    n_power_iter: Option<usize>,
111) -> Result<(Mat<T>, Mat<T>), MethodsErrors> {
112    let n = x.nrows();
113
114    let centered = scale_matrix_col(&x, false);
115
116    let centered = centered.transpose();
117
118    let n_recip = T::from_usize(n).unwrap().recip();
119    let v = Scale(n_recip) * (centered * centered.transpose());
120
121    let k = if fast_svd {
122        let svd_res = randomised_svd(v.as_ref(), rank, seed, oversampling, n_power_iter)?;
123        let s: Vec<T> = svd_res.s.iter().map(|x| x.sqrt().recip()).collect();
124        let d = faer_diagonal_from_vec(s);
125        d * svd_res.u.transpose()
126    } else {
127        let svd_res = v
128            .thin_svd()
129            .map_err(|e| CoreErrors::FaerSvdError(format!("{e:?}")))?;
130        let s = svd_res
131            .S()
132            .column_vector()
133            .iter()
134            .map(|x| x.sqrt().recip())
135            .collect::<Vec<_>>();
136        let d = faer_diagonal_from_vec(s);
137        let u = svd_res.U();
138        let u_t = u.transpose();
139        d * u_t
140    };
141
142    Ok((centered.cloned(), k))
143}
144
145/// Helper function to update the mixing matrix for ICA
146///
147/// ### Params
148///
149/// * `w` The mixing matrix
150///
151/// ### Returns
152///
153/// The updated mixing matrix.
154fn update_mix_mat<T: BixverseFloat>(w: MatRef<T>) -> Result<Mat<T>, MethodsErrors> {
155    // SVD
156    let svd_res = w
157        .thin_svd()
158        .map_err(|e| CoreErrors::FaerSvdError(format!("{e:?}")))?;
159
160    let s = svd_res.S();
161    let u = svd_res.U();
162    let s = s
163        .column_vector()
164        .iter()
165        .map(|x| x.recip())
166        .collect::<Vec<_>>();
167    let d = faer_diagonal_from_vec(s);
168
169    Ok(u * d * u.transpose() * w)
170}
171
172/// Generate a random mixing matrix
173///
174/// ### Params
175///
176/// * `n_comp` - Number of independent components. This will influence the dimensionality
177///   of the randomly initialised mixing matrix.
178/// * `seed` - Random seed for reproducibility purposes
179///
180/// ### Returns
181///
182/// Mixing matrix of dimensions `n_comp` x `n_comp`
183fn create_w_init<T: BixverseFloat>(n_comp: usize, seed: u64) -> Mat<T> {
184    let mut rng = StdRng::seed_from_u64(seed);
185    let normal = Normal::new(0.0, 1.0).unwrap();
186    let vec_size = n_comp.pow(2);
187    let data: Vec<T> = (0..vec_size)
188        .map(|_| T::from_f64(normal.sample(&mut rng)).unwrap())
189        .collect();
190
191    Mat::from_fn(n_comp, n_comp, |i, j| data[i + j * n_comp])
192}
193
194///////////////////////////
195// Cross validation data //
196///////////////////////////
197
198/// Structure to save ICA CV results
199pub struct IcaCvData<T: BixverseFloat> {
200    /// Vector of pre-processed matrices, ready for whitening
201    pub pre_white_matrices: Vec<Mat<T>>,
202    /// Vector of pre-whitening matrices
203    pub k_matrices: Vec<Mat<T>>,
204}
205
206impl<T: BixverseFloat> IcaCvData<T> {
207    /// Generate the `IcaCvData` from data
208    ///
209    /// This function will default to the faster randomised SVD for the whitening
210    /// process.
211    ///
212    /// ### Params
213    ///
214    /// * `x` - The matrix for which to generate a cross-validation like set
215    /// * `num_folds` - In how many folds to split the data
216    /// * `seed` - Random seed for reproducibility purposes
217    /// * `rank` - Optional rank for the randomised SVD that is used to generate the
218    ///   the pre-whitened matrix and whitening matrix k.
219    ///
220    /// ### Returns
221    ///
222    /// An `IcaCvData` structure.
223    pub fn create_from_data(
224        x: MatRef<T>,
225        num_folds: usize,
226        seed: usize,
227        rank: Option<usize>,
228    ) -> Result<IcaCvData<T>, MethodsErrors> {
229        let no_samples = x.nrows();
230        let no_features = x.ncols();
231        let mut indices: Vec<usize> = (0..no_samples).collect();
232        let mut rng = StdRng::seed_from_u64(seed as u64);
233        indices.shuffle(&mut rng);
234
235        let svd_rank = rank.unwrap_or(no_features);
236
237        let fold_size = no_samples / num_folds;
238        let remainder = no_samples % num_folds;
239
240        let mut folds = Vec::with_capacity(num_folds);
241        let mut start = 0;
242
243        for idx in 0..num_folds {
244            let current_fold_size = if idx < remainder {
245                fold_size + 1
246            } else {
247                fold_size
248            };
249
250            let end = start + current_fold_size;
251            folds.push(indices[start..end].to_vec());
252            start = end;
253        }
254
255        let k_x_matrices: Vec<(Mat<T>, Mat<T>)> = folds
256            .par_iter()
257            .map(|test_indices| {
258                let mut is_test = vec![false; no_samples];
259                for &idx in test_indices {
260                    is_test[idx] = true;
261                }
262                let train_indices: Vec<usize> = indices
263                    .iter()
264                    .filter(|&&idx| !is_test[idx])
265                    .cloned()
266                    .collect();
267                let x_i = Mat::<T>::from_fn(train_indices.len(), no_features, |new_row, j| {
268                    x[(train_indices[new_row], j)]
269                });
270                prepare_whitening(x_i.as_ref(), true, seed + 1, svd_rank, None, None)
271            })
272            .collect::<Result<Vec<_>, _>>()?;
273
274        let mut pre_white_matrices = Vec::with_capacity(num_folds);
275        let mut k_matrices = Vec::with_capacity(num_folds);
276
277        for (x_i, k_i) in k_x_matrices {
278            pre_white_matrices.push(x_i);
279            k_matrices.push(k_i);
280        }
281
282        Ok(Self {
283            pre_white_matrices,
284            k_matrices,
285        })
286    }
287}
288
289////////////////////
290// Main functions //
291////////////////////
292
293/// Shared fastICA fixed-point loop
294///
295/// `contrast` maps a projection `u` to `(g(u), g'(u))`. The products run with
296/// the given parallelism, so callers inside an outer parallel loop pass
297/// `Par::Seq`.
298///
299/// ### Params
300///
301/// * `par` - Parallelism of the matrix products
302/// * `x` - Whitened matrix
303/// * `w_init` - Initial, random mixing matrix
304/// * `tol` - Tolerance parameter.
305/// * `maxit` - Maximum number of iterations to run ICA for.
306/// * `verbose` - Shall print messages be returned for each iteration.
307/// * `contrast` - The contrast function and its derivative
308///
309/// ### Returns
310///
311/// A tuple of the final identified mixing matrix w and the reached tolerance
312/// value.
313fn fast_ica_core<T: BixverseFloat>(
314    par: Par,
315    x: MatRef<T>,
316    w_init: MatRef<T>,
317    tol: T,
318    maxit: usize,
319    verbose: bool,
320    contrast: impl Fn(T) -> (T, T),
321) -> Result<IcaRes<T>, MethodsErrors> {
322    let p = x.ncols();
323    let mut w = update_mix_mat(w_init)?;
324    let n_comp = w.nrows();
325    let mut lim = vec![T::from_f64(1000.0).unwrap(); maxit];
326
327    let p_recip = T::from_usize(p).unwrap().recip();
328    let ones = Mat::<T>::from_fn(p, 1, |_, _| T::one());
329
330    // scratch reused across iterations
331    let mut wx = Mat::<T>::zeros(n_comp, p);
332    let mut gwx = Mat::<T>::zeros(n_comp, p);
333    let mut gwx_2 = Mat::<T>::zeros(n_comp, p);
334    let mut v1 = Mat::<T>::zeros(n_comp, n_comp);
335    let mut row_sums = Mat::<T>::zeros(n_comp, 1);
336
337    let mut it = 0;
338
339    while it < maxit && lim[it] > tol {
340        gemm(wx.as_mut(), Accum::Replace, w.as_ref(), x, T::one(), par);
341
342        for j in 0..p {
343            let wx_col = wx.col_as_slice(j);
344            let gwx_col = gwx.col_as_slice_mut(j);
345            let gwx_2_col = gwx_2.col_as_slice_mut(j);
346            for ((&u, g), g2) in wx_col.iter().zip(gwx_col).zip(gwx_2_col) {
347                (*g, *g2) = contrast(u);
348            }
349        }
350
351        gemm(
352            v1.as_mut(),
353            Accum::Replace,
354            gwx.as_ref(),
355            x.transpose(),
356            p_recip,
357            par,
358        );
359        gemm(
360            row_sums.as_mut(),
361            Accum::Replace,
362            gwx_2.as_ref(),
363            ones.as_ref(),
364            T::one(),
365            par,
366        );
367
368        let v2 = Mat::<T>::from_fn(n_comp, n_comp, |i, j| {
369            row_sums[(i, 0)] * p_recip * w[(i, j)]
370        });
371
372        let w1 = update_mix_mat((v1.as_ref() - v2.as_ref()).as_ref())?;
373
374        // only the diagonal of w1 * w^T is read
375        let tol_it = (0..n_comp)
376            .map(|i| {
377                let d = (0..n_comp).fold(T::zero(), |acc, c| acc + w1[(i, c)] * w[(i, c)]);
378                (d.abs() - T::one()).abs()
379            })
380            .fold(T::neg_infinity(), |a, b| a.max(b));
381
382        if it + 1 < maxit {
383            lim[it + 1] = tol_it
384        }
385
386        if verbose {
387            println!("Iteration: {:?}, tol: {:?}", it + 1, tol_it)
388        }
389
390        w = w1;
391
392        it += 1;
393    }
394
395    let min_tol = array_min(&lim);
396
397    Ok((w, min_tol))
398}
399
400/// Contrast function and derivative for the logcosh variant
401///
402/// ### Params
403///
404/// * `alpha` - Alpha parameter for this variant
405///
406/// ### Returns
407///
408/// A closure mapping `u` to `(tanh(alpha u), alpha (1 - tanh(alpha u)^2))`.
409fn logcosh_contrast<T: BixverseFloat>(alpha: T) -> impl Fn(T) -> (T, T) {
410    move |u| {
411        let g = (alpha * u).tanh();
412        (g, alpha * (T::one() - g * g))
413    }
414}
415
416/// Contrast function and derivative for the exp variant
417///
418/// ### Returns
419///
420/// A closure mapping `u` to `(u e, (1 - u^2) e)` with `e = exp(-u^2 / 2)`.
421fn exp_contrast<T: BixverseFloat>() -> impl Fn(T) -> (T, T) {
422    let neg_half = T::from_f64(-0.5).unwrap();
423    move |u| {
424        let e = (neg_half * u * u).exp();
425        (u * e, (T::one() - u * u) * e)
426    }
427}
428
429/// Fast ICA implementation based on logcosh.
430///
431/// ### Params
432///
433/// * `x` - Whitened matrix
434/// * `w_init` - Initial, random mixing matrix
435/// * `maxit` - Maximum number of iterations to run ICA for.
436/// * `alpha` - Alpha parameter for this variant
437/// * `tol` - Tolerance parameter.
438/// * `verbose` - Shall print messages be returned for each iteration.
439///
440/// ### Returns
441///
442/// A tuple of the final identified mixing matrix w and the reached tolerance
443/// value.
444pub fn fast_ica_logcosh<T: BixverseFloat>(
445    x: MatRef<T>,
446    w_init: MatRef<T>,
447    tol: T,
448    alpha: T,
449    maxit: usize,
450    verbose: bool,
451) -> Result<IcaRes<T>, MethodsErrors> {
452    fast_ica_core(
453        faer_parallelism(),
454        x,
455        w_init,
456        tol,
457        maxit,
458        verbose,
459        logcosh_contrast(alpha),
460    )
461}
462
463/// Fast ICA implementation based on exp algorithm.
464///
465/// ### Params
466///
467/// * `x` - Whitened matrix
468/// * `w_init` - Initial, random mixing matrix
469/// * `maxit` - Maximum number of iterations to run ICA for.
470/// * `tol` - Tolerance parameter.
471/// * `verbose` - Shall print messages be returned for each iteration.
472///
473/// ### Returns
474///
475/// A tuple of the final identified mixing matrix w and the reached tolerance
476/// value.
477pub fn fast_ica_exp<T: BixverseFloat>(
478    x: MatRef<T>,
479    w_init: MatRef<T>,
480    tol: T,
481    maxit: usize,
482    verbose: bool,
483) -> Result<IcaRes<T>, MethodsErrors> {
484    fast_ica_core(
485        faer_parallelism(),
486        x,
487        w_init,
488        tol,
489        maxit,
490        verbose,
491        exp_contrast(),
492    )
493}
494
495////////////////////
496// Stabilised ICA //
497////////////////////
498
499/// Stabilised ICA iteration implementation
500///
501/// Iterate through a set of random initialisations with a given pre-whitened
502/// matrix, the whitening matrix k and the respective ICA parameters. It will
503/// generate `no_iters` random seeds and generate the S matrix for all of them.
504///
505/// ### Params
506///
507/// * `x_pre_whiten` - he pre-processed, but not yet whitened matrix.
508/// * `k` - Whitening matrix k.
509/// * `no_comp` - Number of independent components to test for.
510/// * `no_iters` - Number of random iterations.
511/// * `ica_type` - Which of the implemented versions of ICA to test.
512/// * `ica_params` - `IcaParams` structure with the parameters for the
513///   individual runs.
514/// * `random_seed` - Seed for reproducibility purposes.
515///
516/// ### Returns
517///
518/// Returns a tuple of the column bound S matrices and a vector of the final tolerances
519/// each individual run achieved.
520pub fn stabilised_ica_iters<T: BixverseFloat>(
521    x_pre_whiten: MatRef<T>,
522    k: MatRef<T>,
523    no_comp: usize,
524    no_iters: usize,
525    ica_type: &str,
526    ica_params: IcaParams<T>,
527    random_seed: usize,
528) -> Result<(Mat<T>, Vec<bool>), MethodsErrors> {
529    // generate the random w_inits
530    let w_inits: Vec<Mat<T>> = (0..no_iters)
531        .map(|iter| create_w_init(no_comp, (random_seed + iter) as u64))
532        .collect();
533    let k_ncol = k.ncols();
534    let k_red = k.get(0..no_comp, 0..k_ncol);
535    let x_whiten = k_red * x_pre_whiten;
536
537    let ica_type = parse_ica_type(ica_type).unwrap();
538    let iter_res: Vec<(Mat<T>, T)> = w_inits
539        .par_iter()
540        .map(|w_init| match ica_type {
541            IcaType::Exp => fast_ica_core(
542                Par::Seq,
543                x_whiten.as_ref(),
544                w_init.as_ref(),
545                ica_params.tol,
546                ica_params.maxit,
547                ica_params.verbose,
548                exp_contrast(),
549            ),
550            IcaType::LogCosh => fast_ica_core(
551                Par::Seq,
552                x_whiten.as_ref(),
553                w_init.as_ref(),
554                ica_params.tol,
555                ica_params.maxit,
556                ica_params.verbose,
557                logcosh_contrast(ica_params.alpha),
558            ),
559        })
560        .collect::<Result<Vec<(Mat<T>, T)>, MethodsErrors>>()?;
561
562    let mut convergence = Vec::new();
563    let mut a_matrices = Vec::new();
564
565    for (a, final_tol) in iter_res {
566        a_matrices.push(a);
567        convergence.push(final_tol < ica_params.tol);
568    }
569
570    let s_matrices: Vec<Mat<T>> = a_matrices
571        .par_iter()
572        .map(|a| {
573            let mut w = Mat::<T>::zeros(a.nrows(), k_red.ncols());
574            gemm(
575                w.as_mut(),
576                Accum::Replace,
577                a.as_ref(),
578                k_red,
579                T::one(),
580                Par::Seq,
581            );
582            let mut to_solve = Mat::<T>::zeros(w.nrows(), w.nrows());
583            gemm(
584                to_solve.as_mut(),
585                Accum::Replace,
586                w.as_ref(),
587                w.transpose(),
588                T::one(),
589                Par::Seq,
590            );
591            let identity = Mat::<T>::identity(to_solve.nrows(), to_solve.ncols());
592            let lu = PartialPivLu::new(to_solve.as_ref());
593            let solved = lu.solve(&identity);
594            let mut out = Mat::<T>::zeros(w.ncols(), solved.ncols());
595            gemm(
596                out.as_mut(),
597                Accum::Replace,
598                w.transpose(),
599                solved.as_ref(),
600                T::one(),
601                Par::Seq,
602            );
603            out
604        })
605        .collect();
606
607    let s_combined = colbind_matrices(&s_matrices);
608
609    Ok((s_combined, convergence))
610}
611
612/// Run stabilised ICA iterations over the CV-like data
613///
614/// ### Params
615///
616/// * `x` - The matrix for which to run the stabilised ICA over CV-like subsets.
617/// * `no_comp` - Number of independent components to test for.
618/// * `no_folds` - Number of folds to use for the cross-validation.
619/// * `no_iters` - Number of random iterations.
620/// * `ica_type` - Which of the implemented versions of ICA to test.
621/// * `ica_params` - `IcaParams` structure with the parameters for the individual
622///   runs.
623/// * `ica_cv_data` - Optional pre-processed `IcaCvData` structure. If not provided,
624///   these will be automatically generated.
625/// * `random_seed` - Seed for reproducibility purposes.
626///
627/// ### Returns
628///
629/// Returns a tuple of the column bound S matrices (in this case across the different
630/// folds and random initialisations of the mixing matrix) and a vector of the final tolerances
631/// each individual run achieved.
632#[allow(clippy::too_many_arguments)]
633pub fn stabilised_ica_cv<T: BixverseFloat>(
634    x: MatRef<T>,
635    no_comp: usize,
636    no_folds: usize,
637    no_iters: usize,
638    ica_type: &str,
639    ica_params: IcaParams<T>,
640    ica_cv_data: Option<IcaCvData<T>>,
641    seed: usize,
642) -> Result<(Mat<T>, Vec<bool>), MethodsErrors> {
643    // generate cross-validation data if not provided
644    let cv_data = match ica_cv_data {
645        Some(data) => Ok(data), // Use the provided data
646        None => IcaCvData::create_from_data(x, no_folds, seed, Some(no_comp)),
647    }?;
648
649    // Iterate through bootstrapped samples
650    let cv_res: Vec<(Mat<T>, Vec<bool>)> = cv_data
651        .k_matrices
652        .par_iter()
653        .zip(cv_data.pre_white_matrices)
654        .map(|(k_i, x_i)| {
655            stabilised_ica_iters(
656                x_i.as_ref(),
657                k_i.as_ref(),
658                no_comp,
659                no_iters,
660                ica_type,
661                ica_params.clone(),
662                seed + 2,
663            )
664        })
665        .collect::<Result<Vec<(Mat<T>, Vec<bool>)>, MethodsErrors>>()?;
666
667    let mut s_final = Vec::new();
668    let mut converged_final = Vec::new();
669
670    for (s_i, converged_i) in cv_res {
671        s_final.push(s_i);
672        converged_final.push(converged_i);
673    }
674
675    let s_final = colbind_matrices(&s_final);
676    let converged_final = flatten_vector(converged_final);
677
678    Ok((s_final, converged_final))
679}