Skip to main content

bixverse_methods/methods/
cis_target.rs

1//! Implementation of the RCisTarget algorithm from Aibar, et al., Nat Methods,
2//! 2018.
3
4use faer::MatRef;
5use rayon::prelude::*;
6
7use crate::prelude::*;
8use bixverse_core::core::math::vector_helpers::standard_deviation;
9
10////////////////
11// Structures //
12////////////////
13
14/// Results structure for a single enriched motif
15#[derive(Debug, Clone)]
16pub struct MotifEnrichment<T> {
17    /// Index of the motif
18    pub motif_idx: usize,
19    /// Normalised enrichment score
20    pub nes: T,
21    /// Area under the curve
22    pub auc: T,
23    /// Rank position at maximum enrichment
24    pub rank_at_max: u32,
25    /// Number of enriched genes
26    pub n_enriched: usize,
27    /// Indices of enriched genes in the leading edge
28    pub enriched_gene_indices: Vec<usize>,
29}
30
31/// Enum for the RCC calculation types
32#[derive(Clone, Debug, Default)]
33pub enum RccType {
34    /// Use the `ICisTarget` type for the RCC calculation
35    #[default]
36    ICisTarget,
37    /// Use the `Approx` type for the RCC calculation
38    Approx,
39}
40
41/// Parse the RCC calculation type
42///
43/// ### Params
44///
45/// * `s` - The string to parse
46///
47/// ### Returns
48///
49/// Option of the `RccType`
50pub fn parse_rcc_type(s: &str) -> Option<RccType> {
51    match s.to_lowercase().as_str() {
52        "icistarget" => Some(RccType::ICisTarget),
53        "approx" => Some(RccType::Approx),
54        _ => None,
55    }
56}
57
58/////////////////////////
59// Helpers - AUC & NES //
60/////////////////////////
61
62/// Calculate AUC for a single motif's gene ranks
63///
64/// ### Params
65///
66/// * `gene_ranks` - Slice of gene ranks for this motif
67/// * `auc_threshold` - Maximum rank to consider for AUC calculation
68///
69/// ### Returns
70///
71/// Normalised AUC score
72fn calculate_auc_single<T>(gene_ranks: &[i32], auc_threshold: i32) -> T
73where
74    T: BixverseFloat,
75{
76    let mut filtered = gene_ranks
77        .iter()
78        .copied()
79        .filter(|&r| r < auc_threshold)
80        .collect::<Vec<i32>>();
81
82    if filtered.is_empty() {
83        return T::zero();
84    }
85
86    filtered.sort_unstable();
87    filtered.push(auc_threshold);
88
89    let n_genes = T::from_usize(gene_ranks.len()).unwrap();
90    let max_auc = T::from_i32(auc_threshold).unwrap() * n_genes;
91
92    let auc = filtered
93        .windows(2)
94        .enumerate()
95        .map(|(i, w)| T::from_i32(w[1] - w[0]).unwrap() * T::from_usize(i + 1).unwrap())
96        .fold(T::zero(), |acc, x| acc + x);
97
98    auc / max_auc
99}
100
101/// Calculate AUC scores for all motifs given a gene set
102///
103/// ### Params
104///
105/// * `matrix` - Matrix reference containing gene-motif ranks
106/// * `gene_indices` - Indices of genes in the gene set
107/// * `auc_threshold` - Maximum rank to consider for AUC calculation
108///
109/// ### Returns
110///
111/// Vector of AUC scores for each motif
112pub fn calculate_aucs<T>(
113    matrix: MatRef<'_, i32>,
114    gene_indices: &[usize],
115    auc_threshold: i32,
116) -> Vec<T>
117where
118    T: BixverseFloat,
119{
120    let n_motifs = matrix.ncols();
121
122    (0..n_motifs)
123        .into_par_iter()
124        .map(|motif_idx| {
125            let gene_ranks: Vec<i32> = gene_indices
126                .iter()
127                .map(|&row_idx| *matrix.get(row_idx, motif_idx))
128                .collect();
129
130            calculate_auc_single(&gene_ranks, auc_threshold)
131        })
132        .collect()
133}
134
135/// Calculate Normalised Enrichment Scores from AUC values
136///
137/// ### Params
138///
139/// * `auc_scores` - Slice of AUC scores
140///
141/// ### Returns
142///
143/// Vector of z-score normalised enrichment scores
144pub fn calculate_nes<T>(auc_scores: &[T]) -> Vec<T>
145where
146    T: BixverseFloat + std::iter::Sum,
147{
148    let n = T::from_usize(auc_scores.len()).unwrap();
149    let mean = auc_scores.iter().copied().fold(T::zero(), |acc, x| acc + x) / n;
150
151    let std = standard_deviation(auc_scores);
152
153    if std == T::zero() {
154        return vec![T::zero(); auc_scores.len()];
155    }
156
157    auc_scores
158        .par_iter()
159        .map(|&auc| (auc - mean) / std)
160        .collect()
161}
162
163/////////
164// RCC //
165/////////
166
167/// Calculate rolling mean with centre alignment and extend fill
168///
169/// ### Params
170///
171/// * `values` - Slice of values to smooth
172/// * `window` - Window size for rolling mean
173///
174/// ### Returns
175///
176/// Vector of smoothed values
177fn rolling_mean<T: BixverseFloat>(values: &[T], window: usize) -> Vec<T> {
178    let n = values.len();
179    let mut result = Vec::with_capacity(n);
180    let half_window = window / 2;
181    let mut sum = T::zero();
182    let mut count = 0;
183
184    let start = 0;
185    let end = (half_window + 1).min(n);
186    for &v in &values[start..end] {
187        sum += v;
188        count += 1;
189    }
190    result.push(sum / T::from_usize(count).unwrap());
191
192    for i in 1..n {
193        let new_start = i.saturating_sub(half_window);
194        let new_end = (i + half_window + 1).min(n);
195        let old_start = (i - 1).saturating_sub(half_window);
196        let old_end = (i - 1 + half_window + 1).min(n);
197
198        for &v in &values[old_start..new_start] {
199            sum -= v;
200            count -= 1;
201        }
202
203        for &v in &values[old_end..new_end] {
204            sum += v;
205            count += 1;
206        }
207
208        result.push(sum / T::from_usize(count).unwrap());
209    }
210
211    result
212}
213
214/// Calculate Recovery Curve for a single motif
215///
216/// ### Params
217///
218/// * `gene_ranks` - Slice of gene ranks for this motif
219/// * `max_rank` - Maximum rank to consider
220///
221/// ### Returns
222///
223/// Vector where position i equals the number of genes recovered by rank i
224fn calculate_rcc(gene_ranks: &[i32], max_rank: i32) -> Vec<u32> {
225    let mut rcc = vec![0u32; max_rank as usize];
226    let mut filtered: Vec<i32> = gene_ranks
227        .iter()
228        .copied()
229        .filter(|&r| r > 0 && r < max_rank)
230        .collect();
231
232    if filtered.is_empty() {
233        return rcc;
234    }
235
236    filtered.sort_unstable();
237    filtered.push(max_rank);
238
239    let mut prev_rank = 0;
240    for (gene_count, &rank) in filtered.iter().enumerate() {
241        #[allow(clippy::needless_range_loop)]
242        for pos in prev_rank..(rank as usize).min(max_rank as usize) {
243            rcc[pos] = gene_count as u32;
244        }
245        prev_rank = rank as usize;
246    }
247
248    rcc
249}
250
251/// Calculate mean+2*sd thresholds using approximate method
252///
253/// ### Params
254///
255/// * `matrix` - Matrix reference containing gene-motif ranks
256/// * `gene_indices` - Indices of genes in the gene set
257/// * `max_rank` - Maximum rank to consider
258/// * `n_mean` - Window size for smoothing
259///
260/// ### Returns
261///
262/// Vector of smoothed threshold values at each rank position
263fn calculate_rcc_thresholds_aprox<T>(
264    matrix: MatRef<'_, i32>,
265    gene_indices: &[usize],
266    max_rank: i32,
267    n_mean: usize,
268) -> Vec<T>
269where
270    T: BixverseFloat,
271{
272    let n_motifs = matrix.ncols();
273    let n_genes = gene_indices.len();
274    let max_rank_extra = (max_rank as usize) + n_mean;
275
276    let mut global_mat = vec![vec![0usize; max_rank_extra]; n_genes + 1];
277
278    for m_idx in 0..n_motifs {
279        let mut ranks: Vec<i32> = gene_indices
280            .iter()
281            .map(|&idx| *matrix.get(idx, m_idx))
282            .filter(|&r| r > 0 && r <= max_rank_extra as i32)
283            .collect();
284
285        if ranks.is_empty() {
286            continue;
287        }
288
289        ranks.sort_unstable();
290
291        for (gene_count, &rank) in ranks.iter().enumerate() {
292            if (rank as usize) < max_rank_extra {
293                global_mat[gene_count + 1][rank as usize] += 1;
294            }
295        }
296    }
297
298    let mut rcc_mean_raw = vec![T::zero(); max_rank_extra];
299    let mut rcc_sd_raw = vec![T::zero(); max_rank_extra];
300
301    for rank_pos in 0..max_rank_extra {
302        let total_count: usize = global_mat.iter().map(|row| row[rank_pos]).sum();
303
304        if total_count == 0 {
305            if rank_pos > 0 {
306                rcc_mean_raw[rank_pos] = rcc_mean_raw[rank_pos - 1];
307                rcc_sd_raw[rank_pos] = rcc_sd_raw[rank_pos - 1];
308            }
309            continue;
310        }
311
312        let total_count_t = T::from_usize(total_count).unwrap();
313
314        let mut sum = T::zero();
315        let mut sum_sq = T::zero();
316
317        for (num_genes, count) in global_mat.iter().enumerate() {
318            let c = T::from_usize(count[rank_pos]).unwrap();
319            let ng = T::from_usize(num_genes).unwrap();
320            sum += ng * c;
321            sum_sq += ng * ng * c;
322        }
323
324        let mean = sum / total_count_t;
325        let variance = (sum_sq / total_count_t) - (mean * mean);
326
327        rcc_mean_raw[rank_pos] = mean;
328        rcc_sd_raw[rank_pos] = variance.sqrt();
329    }
330
331    let mut rcc_mean = vec![T::zero(); max_rank as usize];
332    let mut rcc_sd = vec![T::zero(); max_rank as usize];
333
334    let smoothed_mean = rolling_mean(&rcc_mean_raw, n_mean);
335    let smoothed_sd = rolling_mean(&rcc_sd_raw, n_mean);
336
337    for i in 0..(max_rank as usize) {
338        if i < 5 {
339            rcc_mean[i] = rcc_mean_raw[i];
340            rcc_sd[i] = rcc_sd_raw[i];
341        } else if i < smoothed_mean.len() {
342            rcc_mean[i] = smoothed_mean[i];
343            rcc_sd[i] = smoothed_sd[i];
344        }
345    }
346
347    rcc_mean
348        .iter()
349        .zip(rcc_sd.iter())
350        .map(|(&m, &s)| m + T::from_f64(2.0).unwrap() * s)
351        .collect()
352}
353
354/// Find the leading edge for a motif
355///
356/// ### Params
357///
358/// * `matrix` - Matrix reference containing gene-motif ranks
359/// * `gene_indices` - Indices of genes in the gene set
360/// * `motif_idx` - Index of the motif to analyse
361/// * `rcc_m2sd` - Pre-calculated threshold values
362/// * `all_rccs` - Optional pre-calculated RCC curves (for exact method)
363///
364/// ### Returns
365///
366/// Tuple of (rank at maximum enrichment, number of enriched genes, enriched gene indices)
367fn find_leading_edge<T>(
368    matrix: MatRef<'_, i32>,
369    gene_indices: &[usize],
370    motif_idx: usize,
371    rcc_m2sd: &[T],
372    all_rccs: Option<&Vec<Vec<u32>>>,
373) -> (u32, usize, Vec<usize>)
374where
375    T: BixverseFloat,
376{
377    let current_rcc = match all_rccs {
378        Some(rccs) => &rccs[motif_idx],
379        None => &calculate_rcc(
380            &gene_indices
381                .iter()
382                .map(|&idx| *matrix.get(idx, motif_idx))
383                .collect::<Vec<_>>(),
384            rcc_m2sd.len() as i32,
385        ),
386    };
387
388    let mut max_enrichment = T::neg_infinity();
389    let mut best_rank = 0;
390
391    for (rank_pos, &threshold) in rcc_m2sd.iter().enumerate() {
392        if rank_pos < current_rcc.len() {
393            let enrichment = T::from_u32(current_rcc[rank_pos]).unwrap() - threshold;
394            if enrichment > max_enrichment {
395                max_enrichment = enrichment;
396                best_rank = rank_pos;
397            }
398        }
399    }
400
401    let rank_at_max = best_rank as u32;
402
403    let enriched: Vec<usize> = gene_indices
404        .iter()
405        .filter(|&&idx| {
406            let rank = *matrix.get(idx, motif_idx);
407            rank > 0 && rank <= rank_at_max as i32
408        })
409        .copied()
410        .collect();
411
412    let n_enriched = enriched.len();
413
414    (rank_at_max, n_enriched, enriched)
415}
416
417/// Calculate RCC for all motifs
418///
419/// ### Params
420///
421/// * `matrix` - Matrix reference containing gene-motif ranks
422/// * `gene_indices` - Indices of genes in the gene set
423/// * `max_rank` - Maximum rank to consider
424///
425/// ### Returns
426///
427/// Vector of RCC curves for each motif
428fn calculate_all_rccs(
429    matrix: MatRef<'_, i32>,
430    gene_indices: &[usize],
431    max_rank: i32,
432) -> Vec<Vec<u32>> {
433    let n_motifs = matrix.ncols();
434
435    (0..n_motifs)
436        .into_par_iter()
437        .map(|motif_idx| {
438            let gene_ranks: Vec<i32> = gene_indices
439                .iter()
440                .map(|&idx| *matrix.get(idx, motif_idx))
441                .collect();
442
443            calculate_rcc(&gene_ranks, max_rank)
444        })
445        .collect()
446}
447
448/// Calculate mean+2*sd thresholds from pre-calculated RCCs (exact method)
449///
450/// ### Params
451///
452/// * `all_rccs` - Pre-calculated RCC curves for all motifs
453/// * `max_rank` - Maximum rank to consider
454///
455/// ### Returns
456///
457/// Vector of threshold values at each rank position
458fn calculate_rcc_thresholds<T>(all_rccs: &[Vec<u32>], max_rank: i32) -> Vec<T>
459where
460    T: BixverseFloat,
461{
462    let max_rank_usize = max_rank as usize;
463    let n_motifs = T::from_usize(all_rccs.len()).unwrap();
464
465    // one pass per curve, so the reads are contiguous
466    let mut sums = vec![T::zero(); max_rank_usize];
467    let mut sums_sq = vec![T::zero(); max_rank_usize];
468    for rcc in all_rccs {
469        for ((sum, sum_sq), &count) in sums.iter_mut().zip(sums_sq.iter_mut()).zip(rcc) {
470            let val = T::from_u32(count).unwrap();
471            *sum += val;
472            *sum_sq += val * val;
473        }
474    }
475
476    sums.into_iter()
477        .zip(sums_sq)
478        .map(|(sum, sum_sq)| {
479            let mean = sum / n_motifs;
480            let variance = (sum_sq / n_motifs) - (mean * mean);
481            mean + T::from_f64(2.0).unwrap() * variance.sqrt()
482        })
483        .collect()
484}
485
486/// Process a single gene set - calculate AUC, NES, and find significant motifs
487///
488/// ### Params
489///
490/// * `matrix` - Matrix reference containing gene-motif ranks
491/// * `gene_indices` - Indices of genes in the gene set
492/// * `auc_threshold` - Maximum rank to consider for AUC calculation
493/// * `nes_threshold` - Minimum NES value for significance
494/// * `max_rank` - Maximum rank to consider for RCC
495/// * `method` - Method to use ("icistarget" for exact, other for approximate)
496/// * `n_mean` - Window size for smoothing (used in approximate method)
497///
498/// ### Returns
499///
500/// Vector of significant motif enrichments
501pub fn process_gene_set<T>(
502    matrix: MatRef<'_, i32>,
503    gene_indices: &[usize],
504    auc_threshold: i32,
505    nes_threshold: T,
506    max_rank: i32,
507    method: &RccType,
508    n_mean: usize,
509) -> Vec<MotifEnrichment<T>>
510where
511    T: BixverseFloat + std::iter::Sum,
512{
513    let auc_scores = calculate_aucs(matrix, gene_indices, auc_threshold);
514
515    let nes_scores = calculate_nes(&auc_scores);
516
517    let (rcc_m2sd, all_rccs) = match method {
518        RccType::ICisTarget => {
519            let all_rccs = calculate_all_rccs(matrix, gene_indices, max_rank);
520            let rcc_m2sd: Vec<T> = calculate_rcc_thresholds(&all_rccs, max_rank);
521            (rcc_m2sd, Some(all_rccs))
522        }
523        RccType::Approx => {
524            let rcc_m2sd = calculate_rcc_thresholds_aprox(matrix, gene_indices, max_rank, n_mean);
525            (rcc_m2sd, None)
526        }
527    };
528
529    nes_scores
530        .iter()
531        .enumerate()
532        .filter(|&(_, &nes)| nes >= nes_threshold)
533        .map(|(motif_idx, &nes)| {
534            let (rank_at_max, n_enriched, enriched_gene_indices) = find_leading_edge(
535                matrix,
536                gene_indices,
537                motif_idx,
538                &rcc_m2sd,
539                all_rccs.as_ref(),
540            );
541
542            MotifEnrichment {
543                motif_idx,
544                nes,
545                auc: auc_scores[motif_idx],
546                rank_at_max,
547                n_enriched,
548                enriched_gene_indices,
549            }
550        })
551        .collect()
552}
553
554///////////
555// Tests //
556///////////
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use approx::assert_relative_eq;
562
563    /// Regression: the priming loop primed `half_window` elements instead of
564    /// `half_window + 1`, so `values[2]` here was dropped and never recovered.
565    /// Every mean except the middle one was wrong.
566    #[test]
567    fn rolling_mean_is_centred_and_clipped_at_the_edges() {
568        let got: Vec<f64> = rolling_mean(&[1.0, 2.0, 3.0, 4.0, 5.0], 5);
569
570        assert_eq!(got.len(), 5);
571        for (g, w) in got.iter().zip([2.0, 2.5, 3.0, 3.5, 4.0]) {
572            assert_relative_eq!(*g, w, epsilon = 1e-12);
573        }
574    }
575
576    /// A window of one is the identity, and must not divide by a zero count.
577    #[test]
578    fn rolling_mean_window_of_one_is_the_identity() {
579        let values = [1.0_f64, 7.0, 3.0];
580        let got: Vec<f64> = rolling_mean(&values, 1);
581
582        for (g, w) in got.iter().zip(values) {
583            assert_relative_eq!(*g, w, epsilon = 1e-12);
584        }
585    }
586
587    /// AUC normalises by the full gene count, not the below-threshold subset:
588    /// ranks [1, 3] contribute 2*1 + 2*2 = 6 against a maximum of 5 * 3.
589    #[test]
590    fn auc_single_normalises_by_the_unfiltered_gene_count() {
591        assert_relative_eq!(
592            calculate_auc_single::<f64>(&[1, 3, 7], 5),
593            0.4,
594            epsilon = 1e-12
595        );
596    }
597
598    /// Every rank at or above the threshold leaves nothing to integrate.
599    #[test]
600    fn auc_single_is_zero_when_no_gene_beats_the_threshold() {
601        assert_relative_eq!(
602            calculate_auc_single::<f64>(&[7, 9], 5),
603            0.0,
604            epsilon = 1e-12
605        );
606    }
607
608    /// The recovery curve steps at each retained rank and holds until the next.
609    #[test]
610    fn rcc_steps_at_each_retained_rank() {
611        assert_eq!(calculate_rcc(&[2, 4, 9], 6), vec![0, 0, 1, 1, 2, 2]);
612    }
613
614    /// Rank 0 is filtered out by the `r > 0` guard, so it counts as absent
615    /// rather than as the best possible rank.
616    #[test]
617    fn rcc_treats_rank_zero_as_absent() {
618        assert_eq!(calculate_rcc(&[0, 3], 5), vec![0, 0, 0, 1, 1]);
619    }
620
621    /// NES z-scores against the sample standard deviation.
622    #[test]
623    fn nes_z_scores_against_the_sample_sd() {
624        let got: Vec<f64> = calculate_nes(&[1.0, 2.0, 3.0]);
625
626        for (g, w) in got.iter().zip([-1.0, 0.0, 1.0]) {
627            assert_relative_eq!(*g, w, epsilon = 1e-12);
628        }
629    }
630
631    /// A constant input has no spread to normalise by, so every score is zero
632    /// rather than NaN.
633    #[test]
634    fn nes_of_a_constant_input_is_all_zero() {
635        assert_eq!(calculate_nes::<f64>(&[2.0; 4]), vec![0.0; 4]);
636    }
637}