Skip to main content

sc_neurocore_engine/analysis/
dimensionality.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Commercial license available
3// © Concepts 1996–2026 Miroslav Šotek. All rights reserved.
4// © Code 2020–2026 Miroslav Šotek. All rights reserved.
5// ORCID: 0009-0009-3560-0851
6// Contact: www.anulum.li | protoscience@anulum.li
7// SC-NeuroCore — Dimensionality reduction for spike train populations
8
9use super::basic;
10use rayon::prelude::*;
11
12// ── helpers ─────────────────────────────────────────────────────────
13
14/// Symmetric eigendecomposition via Jacobi rotations.
15/// `a` is row-major `n x n` (symmetric).
16/// Returns `(eigenvalues, eigenvectors_col_major)` sorted descending.
17fn symmetric_eigen(a: &[f64], n: usize) -> (Vec<f64>, Vec<f64>) {
18    let mut mat = a.to_vec();
19    let mut vecs = vec![0.0f64; n * n];
20    for i in 0..n {
21        vecs[i * n + i] = 1.0;
22    }
23    let max_iter = 100 * n * n;
24    for _ in 0..max_iter {
25        // Find largest off-diagonal
26        let mut p = 0;
27        let mut q = 1;
28        let mut max_val = 0.0f64;
29        for i in 0..n {
30            for j in i + 1..n {
31                let v = mat[i * n + j].abs();
32                if v > max_val {
33                    max_val = v;
34                    p = i;
35                    q = j;
36                }
37            }
38        }
39        if max_val < 1e-15 {
40            break;
41        }
42        let app = mat[p * n + p];
43        let aqq = mat[q * n + q];
44        let apq = mat[p * n + q];
45        let theta = if (app - aqq).abs() < 1e-30 {
46            std::f64::consts::FRAC_PI_4
47        } else {
48            0.5 * (2.0 * apq / (app - aqq)).atan()
49        };
50        let c = theta.cos();
51        let s = theta.sin();
52        // Apply rotation (unrolled)
53        for i in 0..n {
54            let i_off = i * n;
55            let ip = mat[i_off + p];
56            let iq = mat[i_off + q];
57            mat[i_off + p] = c * ip + s * iq;
58            mat[i_off + q] = -s * ip + c * iq;
59        }
60        let p_off = p * n;
61        let q_off = q * n;
62        for j in 0..n {
63            let pj = mat[p_off + j];
64            let qj = mat[q_off + j];
65            mat[p_off + j] = c * pj + s * qj;
66            mat[q_off + j] = -s * pj + c * qj;
67        }
68        // Update eigenvectors (unrolled)
69        for i in 0..n {
70            let i_off = i * n;
71            let vip = vecs[i_off + p];
72            let viq = vecs[i_off + q];
73            vecs[i_off + p] = c * vip + s * viq;
74            vecs[i_off + q] = -s * vip + c * viq;
75        }
76    }
77    let eigenvalues: Vec<f64> = (0..n).map(|i| mat[i * n + i]).collect();
78    // Sort descending
79    let mut idx: Vec<usize> = (0..n).collect();
80    idx.sort_by(|&a, &b| eigenvalues[b].partial_cmp(&eigenvalues[a]).unwrap());
81    let sorted_vals: Vec<f64> = idx.iter().map(|&i| eigenvalues[i]).collect();
82    let mut sorted_vecs = vec![0.0f64; n * n];
83    for (new_col, &old_col) in idx.iter().enumerate() {
84        for row in 0..n {
85            sorted_vecs[row * n + new_col] = vecs[row * n + old_col];
86        }
87    }
88    (sorted_vals, sorted_vecs)
89}
90
91/// PCA on binned spike count matrix (neurons x time_bins).
92///
93/// `trains`: list of binary trains (each `&[i32]`).
94/// Returns `(projected, explained_variance_ratio)` where
95/// `projected` is row-major `(n_components, min_bins)`.
96pub fn spike_train_pca(
97    trains: &[&[i32]],
98    n_components: usize,
99    bin_size: usize,
100) -> (Vec<f64>, Vec<f64>) {
101    if trains.is_empty() {
102        return (vec![], vec![]);
103    }
104    let binned: Vec<Vec<f64>> = trains
105        .iter()
106        .map(|t| {
107            basic::bin_spike_train(t, bin_size)
108                .into_iter()
109                .map(|c| c as f64)
110                .collect()
111        })
112        .collect();
113    let min_bins = binned.iter().map(|b| b.len()).min().unwrap_or(0);
114    if min_bins == 0 {
115        return (vec![], vec![]);
116    }
117    let d = trains.len(); // neurons
118                          // Mean-centre each neuron
119    let mut mat = vec![0.0f64; d * min_bins];
120    for i in 0..d {
121        let mean: f64 = binned[i][..min_bins].iter().sum::<f64>() / min_bins as f64;
122        for j in 0..min_bins {
123            mat[i * min_bins + j] = binned[i][j] - mean;
124        }
125    }
126    if d < 2 {
127        return (mat[..min_bins].to_vec(), vec![1.0]);
128    }
129    // Covariance matrix (d x d)
130    let mut cov = vec![0.0f64; d * d];
131    for i in 0..d {
132        for j in i..d {
133            let mut s = 0.0;
134            for t in 0..min_bins {
135                s += mat[i * min_bins + t] * mat[j * min_bins + t];
136            }
137            s /= (min_bins - 1).max(1) as f64;
138            cov[i * d + j] = s;
139            cov[j * d + i] = s;
140        }
141    }
142    let (eigvals, eigvecs) = symmetric_eigen(&cov, d);
143    let nc = n_components.min(d);
144    let total: f64 = eigvals.iter().sum();
145    let explained: Vec<f64> = eigvals[..nc]
146        .iter()
147        .map(|&v| if total > 0.0 { v / total } else { v })
148        .collect();
149    // Project: components^T @ mat
150    // components are first nc columns of eigvecs
151    let mut projected = vec![0.0f64; nc * min_bins];
152    for c in 0..nc {
153        for t in 0..min_bins {
154            let mut s = 0.0;
155            for i in 0..d {
156                s += eigvecs[i * d + c] * mat[i * min_bins + t];
157            }
158            projected[c * min_bins + t] = s;
159        }
160    }
161    (projected, explained)
162}
163
164/// Demixed PCA. Kobak et al. 2016.
165///
166/// `conditions`: list of (condition trains) where each is a `Vec<&[i32]>`.
167/// Returns `(projected, explained_variance_ratio)`.
168pub fn demixed_pca(
169    conditions: &[Vec<&[i32]>],
170    n_components: usize,
171    bin_size: usize,
172) -> (Vec<f64>, Vec<f64>) {
173    if conditions.len() < 2 {
174        return (vec![], vec![]);
175    }
176    // Compute mean binned vector per condition
177    let mut all_means: Vec<Vec<f64>> = Vec::new();
178    for trains in conditions {
179        let binned: Vec<Vec<f64>> = trains
180            .iter()
181            .map(|t| {
182                basic::bin_spike_train(t, bin_size)
183                    .into_iter()
184                    .map(|c| c as f64)
185                    .collect()
186            })
187            .collect();
188        let min_bins = binned.iter().map(|b| b.len()).min().unwrap_or(0);
189        if min_bins == 0 {
190            continue;
191        }
192        let n = binned.len();
193        let mut mean = vec![0.0f64; min_bins];
194        for b in &binned {
195            for j in 0..min_bins {
196                mean[j] += b[j];
197            }
198        }
199        for v in &mut mean {
200            *v /= n as f64;
201        }
202        all_means.push(mean);
203    }
204    if all_means.len() < 2 {
205        return (vec![], vec![]);
206    }
207    let min_bins = all_means.iter().map(|m| m.len()).min().unwrap();
208    let n_cond = all_means.len();
209    // Grand mean
210    let mut grand = vec![0.0f64; min_bins];
211    for m in &all_means {
212        for j in 0..min_bins {
213            grand[j] += m[j];
214        }
215    }
216    for v in &mut grand {
217        *v /= n_cond as f64;
218    }
219    // Centre
220    let mut mean_mat = vec![0.0f64; n_cond * min_bins];
221    for i in 0..n_cond {
222        for j in 0..min_bins {
223            mean_mat[i * min_bins + j] = all_means[i][j] - grand[j];
224        }
225    }
226    // Covariance: M^T M / n_cond (min_bins x min_bins) - unrolled & SIMD
227    let t = min_bins;
228    let mut cov = vec![0.0f64; t * t];
229    let n_cond_f = n_cond as f64;
230
231    // Transpose mean_mat to column-major for SIMD dots: (t x n_cond)
232    let mut m_cols = vec![vec![0.0_f64; n_cond]; t];
233    for c in 0..n_cond {
234        for i in 0..t {
235            m_cols[i][c] = mean_mat[c * t + i];
236        }
237    }
238
239    cov.par_chunks_exact_mut(t)
240        .enumerate()
241        .for_each(|(i, row)| {
242            for j in i..t {
243                let dot = crate::simd::dot_f64_dispatch(&m_cols[i], &m_cols[j]);
244                row[j] = dot / n_cond_f;
245            }
246        });
247    // Mirror
248    for i in 0..t {
249        for j in (i + 1)..t {
250            cov[j * t + i] = cov[i * t + j];
251        }
252    }
253    let (eigvals, eigvecs) = symmetric_eigen(&cov, t);
254    let nc = n_components.min(t);
255    let total: f64 = eigvals.iter().sum();
256    let explained: Vec<f64> = eigvals[..nc]
257        .iter()
258        .map(|&v| if total > 0.0 { v / total } else { v })
259        .collect();
260    // Project: mean_mat @ eigvecs[:, :nc]
261    let mut projected = vec![0.0f64; n_cond * nc];
262    for c in 0..n_cond {
263        for k in 0..nc {
264            let mut s = 0.0;
265            for j in 0..t {
266                s += mean_mat[c * t + j] * eigvecs[j * t + k];
267            }
268            projected[c * nc + k] = s;
269        }
270    }
271    (projected, explained)
272}
273
274/// Factor analysis via EM. Rubin & Thayer 1982.
275///
276/// Returns `(loading_matrix [d*n_factors], uniquenesses [d])`.
277pub fn factor_analysis(
278    trains: &[&[i32]],
279    n_factors: usize,
280    bin_size: usize,
281    n_iter: usize,
282) -> (Vec<f64>, Vec<f64>) {
283    let d = trains.len();
284    if d == 0 {
285        return (vec![], vec![]);
286    }
287    let binned: Vec<Vec<f64>> = trains
288        .iter()
289        .map(|t| {
290            basic::bin_spike_train(t, bin_size)
291                .into_iter()
292                .map(|c| c as f64)
293                .collect()
294        })
295        .collect();
296    let t = binned.iter().map(|b| b.len()).min().unwrap_or(0);
297    if t == 0 {
298        return (vec![0.0; d * n_factors], vec![1.0; d]);
299    }
300    // Mean-centre
301    let mut mat = vec![0.0f64; d * t];
302    for i in 0..d {
303        let mean: f64 = binned[i][..t].iter().sum::<f64>() / t as f64;
304        for j in 0..t {
305            mat[i * t + j] = binned[i][j] - mean;
306        }
307    }
308    // Covariance (d x d)
309    let mut cov = vec![0.0f64; d * d];
310    for i in 0..d {
311        for j in i..d {
312            let mut s = 0.0;
313            for k in 0..t {
314                s += mat[i * t + k] * mat[j * t + k];
315            }
316            s /= t as f64;
317            cov[i * d + j] = s;
318            cov[j * d + i] = s;
319        }
320    }
321    let nf = n_factors.min(d);
322    let mut psi: Vec<f64> = (0..d).map(|i| cov[i * d + i]).collect();
323    // Initialise loadings (deterministic pseudo-random)
324    let mut loadings = vec![0.0f64; d * nf];
325    let mut rng = 42u64;
326    for v in &mut loadings {
327        rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
328        *v = ((rng >> 33) as f64 / (1u64 << 31) as f64 - 0.5) * 0.2;
329    }
330
331    for _ in 0..n_iter {
332        // psi_inv: diagonal
333        let psi_inv: Vec<f64> = psi.iter().map(|&p| 1.0 / (p + 1e-10)).collect();
334
335        // M = L^T psi_inv L + I (nf x nf)
336        let mut m = vec![0.0f64; nf * nf];
337        for i in 0..nf {
338            for j in 0..nf {
339                let mut s = 0.0;
340                for k in 0..d {
341                    s += loadings[k * nf + i] * psi_inv[k] * loadings[k * nf + j];
342                }
343                m[i * nf + j] = s + if i == j { 1.0 } else { 0.0 };
344            }
345        }
346        let m_inv = mat_inv_small(&m, nf);
347
348        // beta = m_inv @ L^T @ psi_inv (nf x d)
349        let mut beta = vec![0.0f64; nf * d];
350        for i in 0..nf {
351            for j in 0..d {
352                let mut s = 0.0;
353                for k in 0..nf {
354                    s += m_inv[i * nf + k] * loadings[j * nf + k] * psi_inv[j];
355                }
356                beta[i * d + j] = s;
357            }
358        }
359
360        // E[z] = beta @ mat (nf x t)
361        let mut ez = vec![0.0f64; nf * t];
362        for i in 0..nf {
363            for j in 0..t {
364                let mut s = 0.0;
365                for k in 0..d {
366                    s += beta[i * d + k] * mat[k * t + j];
367                }
368                ez[i * t + j] = s;
369            }
370        }
371
372        // E[zz^T] = nf * m_inv + ez @ ez^T / t (nf x nf)
373        let mut ezzt = vec![0.0f64; nf * nf];
374        for i in 0..nf {
375            for j in 0..nf {
376                let mut s = 0.0;
377                for k in 0..t {
378                    s += ez[i * t + k] * ez[j * t + k];
379                }
380                // The Python uses: nf * m_inv + ez @ ez.T / t
381                // But the correct EM formula is: t * m_inv + ez @ ez.T
382                // Python's formula: ezzt = n_factors * m_inv + ez @ ez.T / t
383                ezzt[i * nf + j] = nf as f64 * m_inv[i * nf + j] + s / t as f64;
384            }
385        }
386
387        // L_new = (mat @ ez^T / t) @ inv(ezzt / t * t) = (mat @ ez^T / t) @ inv(ezzt)
388        // Actually Python: loadings = mat @ ez.T / t @ np.linalg.inv(ezzt / t * t)
389        // This simplifies to: (mat @ ez^T) @ inv(t * ezzt)
390        // Let's just follow the Python exactly:
391        // mat_ez_t = mat @ ez.T (d x nf)
392        let mut mat_ez_t = vec![0.0f64; d * nf];
393        for i in 0..d {
394            for j in 0..nf {
395                let mut s = 0.0;
396                for k in 0..t {
397                    s += mat[i * t + k] * ez[j * t + k];
398                }
399                mat_ez_t[i * nf + j] = s / t as f64;
400            }
401        }
402        // Scale ezzt for inversion: the Python does ezzt / t * t which is just ezzt
403        let ezzt_inv = mat_inv_small(&ezzt, nf);
404        // L_new = mat_ez_t @ ezzt_inv
405        for i in 0..d {
406            for j in 0..nf {
407                let mut s = 0.0;
408                for k in 0..nf {
409                    s += mat_ez_t[i * nf + k] * ezzt_inv[k * nf + j];
410                }
411                loadings[i * nf + j] = s;
412            }
413        }
414
415        // psi = diag(cov - L @ ez @ mat^T / t)
416        // L @ ez (d x t)
417        let mut l_ez = vec![0.0f64; d * t];
418        for i in 0..d {
419            for j in 0..t {
420                let mut s = 0.0;
421                for k in 0..nf {
422                    s += loadings[i * nf + k] * ez[k * t + j];
423                }
424                l_ez[i * t + j] = s;
425            }
426        }
427        for i in 0..d {
428            let mut s = 0.0;
429            for k in 0..t {
430                s += l_ez[i * t + k] * mat[i * t + k];
431            }
432            psi[i] = (cov[i * d + i] - s / t as f64).max(1e-6);
433        }
434    }
435
436    (loadings, psi)
437}
438
439/// Small matrix inverse (Gauss-Jordan) for nf x nf matrices.
440fn mat_inv_small(a: &[f64], n: usize) -> Vec<f64> {
441    let mut aug = vec![0.0f64; n * 2 * n];
442    for i in 0..n {
443        for j in 0..n {
444            aug[i * 2 * n + j] = a[i * n + j];
445        }
446        aug[i * 2 * n + n + i] = 1.0;
447    }
448    for col in 0..n {
449        let mut max_row = col;
450        let mut max_val = aug[col * 2 * n + col].abs();
451        for row in col + 1..n {
452            let v = aug[row * 2 * n + col].abs();
453            if v > max_val {
454                max_val = v;
455                max_row = row;
456            }
457        }
458        if max_val < 1e-30 {
459            continue;
460        }
461        if max_row != col {
462            for k in 0..2 * n {
463                aug.swap(col * 2 * n + k, max_row * 2 * n + k);
464            }
465        }
466        let pivot = aug[col * 2 * n + col];
467        for k in 0..2 * n {
468            aug[col * 2 * n + k] /= pivot;
469        }
470        for row in 0..n {
471            if row == col {
472                continue;
473            }
474            let factor = aug[row * 2 * n + col];
475            for k in 0..2 * n {
476                aug[row * 2 * n + k] -= factor * aug[col * 2 * n + k];
477            }
478        }
479    }
480    let mut inv = vec![0.0f64; n * n];
481    for i in 0..n {
482        for j in 0..n {
483            inv[i * n + j] = aug[i * 2 * n + n + j];
484        }
485    }
486    inv
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn make_trains() -> Vec<Vec<i32>> {
494        // 5 neurons, 200 steps each, varied rates
495        let mut trains = Vec::new();
496        for n in 0..5 {
497            let mut t = vec![0i32; 200];
498            let step = 5 + n * 3;
499            for i in (0..200).step_by(step) {
500                t[i] = 1;
501            }
502            trains.push(t);
503        }
504        trains
505    }
506
507    #[test]
508    fn test_spike_train_pca_basic() {
509        let trains = make_trains();
510        let refs: Vec<&[i32]> = trains.iter().map(|t| t.as_slice()).collect();
511        let (proj, explained) = spike_train_pca(&refs, 3, 10);
512        assert_eq!(explained.len(), 3);
513        // Explained variances should sum to <= 1
514        let total: f64 = explained.iter().sum();
515        assert!(total <= 1.0 + 1e-6, "Total explained {total} > 1");
516        // First component should explain the most
517        assert!(explained[0] >= explained[1]);
518        assert!(!proj.is_empty());
519    }
520
521    #[test]
522    fn test_spike_train_pca_empty() {
523        let (proj, expl) = spike_train_pca(&[], 3, 10);
524        assert!(proj.is_empty());
525        assert!(expl.is_empty());
526    }
527
528    #[test]
529    fn test_spike_train_pca_single_neuron() {
530        let train = vec![1, 0, 1, 0, 1, 0, 1, 0, 1, 0];
531        let refs = vec![train.as_slice()];
532        let (proj, expl) = spike_train_pca(&refs, 1, 2);
533        // Single neuron -> 1 component
534        assert_eq!(expl.len(), 1);
535        assert!(!proj.is_empty());
536    }
537
538    #[test]
539    fn test_demixed_pca_basic() {
540        let trains_a = make_trains();
541        let trains_b: Vec<Vec<i32>> = (0..5)
542            .map(|n| {
543                let mut t = vec![0i32; 200];
544                let step = 3 + n * 2;
545                for i in (0..200).step_by(step) {
546                    t[i] = 1;
547                }
548                t
549            })
550            .collect();
551        let cond_a: Vec<&[i32]> = trains_a.iter().map(|t| t.as_slice()).collect();
552        let cond_b: Vec<&[i32]> = trains_b.iter().map(|t| t.as_slice()).collect();
553        let conditions = vec![cond_a, cond_b];
554        let (proj, expl) = demixed_pca(&conditions, 2, 10);
555        assert!(!expl.is_empty());
556        assert!(!proj.is_empty());
557    }
558
559    #[test]
560    fn test_demixed_pca_single_condition() {
561        let t = [vec![1, 0, 1, 0]];
562        let refs: Vec<&[i32]> = t.iter().map(|v| v.as_slice()).collect();
563        let (proj, expl) = demixed_pca(&[refs], 2, 2);
564        assert!(proj.is_empty());
565        assert!(expl.is_empty());
566    }
567
568    #[test]
569    fn test_factor_analysis_basic() {
570        let trains = make_trains();
571        let refs: Vec<&[i32]> = trains.iter().map(|t| t.as_slice()).collect();
572        let (loadings, psi) = factor_analysis(&refs, 2, 10, 20);
573        assert_eq!(loadings.len(), 5 * 2);
574        assert_eq!(psi.len(), 5);
575        // Uniquenesses should be positive
576        assert!(psi.iter().all(|&p| p > 0.0));
577    }
578
579    #[test]
580    fn test_factor_analysis_empty() {
581        let (l, p) = factor_analysis(&[], 2, 10, 20);
582        assert!(l.is_empty());
583        assert!(p.is_empty());
584    }
585
586    #[test]
587    fn test_symmetric_eigen_identity() {
588        let eye = vec![1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0];
589        let (vals, _) = symmetric_eigen(&eye, 3);
590        for v in &vals {
591            assert!((v - 1.0).abs() < 1e-10);
592        }
593    }
594
595    #[test]
596    fn test_symmetric_eigen_known() {
597        // [[2, 1], [1, 2]] -> eigenvalues 3, 1
598        let a = vec![2.0, 1.0, 1.0, 2.0];
599        let (vals, _) = symmetric_eigen(&a, 2);
600        assert!((vals[0] - 3.0).abs() < 1e-10);
601        assert!((vals[1] - 1.0).abs() < 1e-10);
602    }
603
604    #[test]
605    fn test_pca_explains_variance() {
606        let trains = make_trains();
607        let refs: Vec<&[i32]> = trains.iter().map(|t| t.as_slice()).collect();
608        let (_, explained) = spike_train_pca(&refs, 5, 10);
609        // All components should explain the full variance
610        let total: f64 = explained.iter().sum();
611        assert!(
612            (total - 1.0).abs() < 0.05,
613            "Total explained {total} should be ~1.0"
614        );
615    }
616}