Skip to main content

uno_core/quant/directms1/
mod.rs

1//! Rust reimplementation of pinned DirectMS1Quant contrast analysis, part of uno-core.
2//! Adapted from Mark Ivanov's Apache-2.0 implementation; see THIRD_PARTY.md.
3pub mod input;
4pub mod order;
5pub mod output;
6pub mod rng;
7pub mod stats;
8pub mod uno_adapter;
9use anyhow::{ensure, Context, Result};
10use serde::{Deserialize, Serialize};
11use stats::{mean, median};
12use std::collections::{BTreeMap, BTreeSet};
13
14#[derive(Clone, Debug, Serialize, Deserialize)]
15pub struct Config {
16    pub min_samples: usize,
17    pub min_length: usize,
18    pub normalization: u8,
19    pub fold_change: f64,
20    pub absolute_fold_change: bool,
21    pub qvalue: f64,
22    pub decoy_prefix: String,
23    pub seed: u32,
24}
25impl Default for Config {
26    fn default() -> Self {
27        Self {
28            min_samples: 0,
29            min_length: 7,
30            normalization: 2,
31            fold_change: 2.,
32            absolute_fold_change: false,
33            qvalue: 0.05,
34            decoy_prefix: "DECOY_".into(),
35            seed: 42,
36        }
37    }
38}
39#[derive(Clone, Debug)]
40pub struct InputPeptide {
41    pub key: String,
42    pub sequence: String,
43    pub proteins: Vec<String>,
44    pub values: Vec<Option<f64>>,
45}
46#[derive(Clone, Debug)]
47pub struct Dataset {
48    pub samples: Vec<String>,
49    pub n_s1: usize,
50    pub rows: Vec<InputPeptide>,
51}
52#[derive(Clone, Debug, Serialize)]
53pub struct Peptide {
54    pub peptide: String,
55    pub sequence: String,
56    pub protein: String,
57    pub decoy: bool,
58    pub intensities: Vec<f64>,
59    pub missing: usize,
60    pub observed_s1: usize,
61    pub observed_s2: usize,
62    pub intensity: f64,
63    pub bin: usize,
64    pub raw_fc: f64,
65    pub fc: f64,
66    pub corrected_fc: f64,
67    pub pvalue: f64,
68    pub running_fc: f64,
69    pub better: usize,
70    pub significant: bool,
71    pub up: bool,
72    pub down: bool,
73}
74#[derive(Clone, Debug, Serialize)]
75pub struct Protein {
76    pub name: String,
77    pub group: usize,
78    pub n_peptides: usize,
79    pub n_changed: usize,
80    pub score: f64,
81    pub pvalue: f64,
82    pub fc: f64,
83    pub raw_fc: f64,
84    pub all_fc: f64,
85    pub all_raw_fc: f64,
86    pub fc_pass: bool,
87    pub fdr_pass: Option<bool>,
88    pub bh_pass: bool,
89}
90#[derive(Clone, Debug, Serialize)]
91pub struct Analysis {
92    pub config: Config,
93    pub samples: Vec<String>,
94    pub n_s1: usize,
95    pub normalization_factors: Vec<f64>,
96    pub calibration_mean: f64,
97    pub calibration_std: f64,
98    pub fold_change_threshold: f64,
99    pub decoy_probability: f64,
100    pub n_decoy_sequences: usize,
101    pub input_rows: usize,
102    pub eligible_precursors: usize,
103    pub peptides: Vec<Peptide>,
104    pub proteins: Vec<Protein>,
105}
106fn observed(v: &[Option<f64>]) -> Vec<f64> {
107    v.iter().flatten().copied().collect()
108}
109fn opt_mean(v: &[Option<f64>]) -> f64 {
110    mean(&observed(v))
111}
112fn bounds(v: &[f64]) -> (f64, f64) {
113    v.iter()
114        .filter(|v| v.is_finite())
115        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), &x| {
116            (lo.min(x), hi.max(x))
117        })
118}
119fn fill_absent(fc: &mut [f64], counts: &[(usize, usize)]) -> Result<()> {
120    let (lo, hi) = bounds(fc);
121    ensure!(
122        lo.is_finite() && hi.is_finite(),
123        "no peptides observed in both conditions"
124    );
125    for (f, &(a, b)) in fc.iter_mut().zip(counts) {
126        if a == 0 {
127            *f = hi;
128        } else if b == 0 {
129            *f = lo;
130        }
131    }
132    Ok(())
133}
134
135pub fn analyze(data: &Dataset, cfg: &Config) -> Result<Analysis> {
136    ensure!(
137        data.n_s1 <= data.samples.len(),
138        "condition size exceeds sample count"
139    );
140    ensure!(
141        data.n_s1 >= 2 && data.samples.len() - data.n_s1 >= 2,
142        "at least two replicates per condition are required"
143    );
144    ensure!(
145        data.samples.iter().collect::<BTreeSet<_>>().len() == data.samples.len(),
146        "duplicate sample names"
147    );
148    ensure!(
149        cfg.normalization <= 2 && cfg.qvalue.is_finite() && cfg.qvalue > 0. && cfg.qvalue <= 1.,
150        "invalid normalization or qvalue"
151    );
152    ensure!(
153        cfg.fold_change.is_finite() && cfg.fold_change >= 0. && !cfg.decoy_prefix.is_empty(),
154        "invalid fold change or decoy prefix"
155    );
156    let n = data.samples.len();
157    let required = if cfg.min_samples == 0 {
158        n / 2
159    } else {
160        cfg.min_samples
161    };
162    ensure!(
163        required > 0 && required <= n,
164        "min_samples exceeds sample count"
165    );
166    let mut seen = BTreeSet::new();
167    for row in &data.rows {
168        ensure!(seen.insert(&row.key), "duplicate precursor key {}", row.key);
169        ensure!(
170            row.values.len() == n && !row.proteins.is_empty(),
171            "invalid row shape: {}",
172            row.key
173        );
174        ensure!(
175            row.values
176                .iter()
177                .flatten()
178                .all(|v| v.is_finite() && *v > 0.),
179            "intensities must be positive and finite; use missing for absent signal: {}",
180            row.key
181        );
182    }
183    let rows: Vec<_> = data
184        .rows
185        .iter()
186        .filter(|r| r.values.iter().flatten().count() >= required)
187        .collect();
188    ensure!(
189        rows.len() >= 6,
190        "at least six eligible precursor rows needed for quintiles/calibration"
191    );
192    let counts: Vec<_> = rows
193        .iter()
194        .map(|r| {
195            (
196                r.values[..data.n_s1].iter().flatten().count(),
197                r.values[data.n_s1..].iter().flatten().count(),
198            )
199        })
200        .collect();
201    let mut raw_fc: Vec<_> = rows
202        .iter()
203        .map(|r| (opt_mean(&r.values[data.n_s1..]) / opt_mean(&r.values[..data.n_s1])).log2())
204        .collect();
205    fill_absent(&mut raw_fc, &counts)?;
206    let factors: Vec<f64> = (0..n)
207        .map(|col| {
208            let mut vals: Vec<_> = rows.iter().filter_map(|r| r.values[col]).collect();
209            match cfg.normalization {
210                0 => 1.,
211                1 => median(&vals),
212                _ => {
213                    vals.sort_by(|a, b| b.total_cmp(a));
214                    vals.iter().take(1000).sum()
215                }
216            }
217        })
218        .collect();
219    ensure!(
220        factors.iter().all(|x| x.is_finite() && *x > 0.),
221        "a sample has no valid normalization factor"
222    );
223    let normalized: Vec<Vec<Option<f64>>> = rows
224        .iter()
225        .map(|r| {
226            r.values
227                .iter()
228                .zip(&factors)
229                .map(|(v, f)| v.map(|v| v / f))
230                .collect()
231        })
232        .collect();
233    let minima: Vec<_> = (0..n)
234        .map(|i| {
235            normalized
236                .iter()
237                .filter_map(|r| r[i])
238                .fold(f64::INFINITY, f64::min)
239        })
240        .collect();
241    ensure!(
242        minima.iter().all(|x| x.is_finite() && *x > 0.),
243        "a sample has no observed signal after filtering"
244    );
245    let means: Vec<_> = normalized
246        .iter()
247        .map(|r| (opt_mean(&r[..data.n_s1]), opt_mean(&r[data.n_s1..])))
248        .collect();
249    let intensity: Vec<_> = means
250        .iter()
251        .map(|&(a, b)| {
252            if a.is_nan() {
253                b
254            } else if b.is_nan() {
255                a
256            } else {
257                a.max(b)
258            }
259        })
260        .collect();
261    let edges: Vec<_> = (0..=5)
262        .map(|i| stats::quantile(&intensity, i as f64 / 5.))
263        .collect();
264    ensure!(
265        edges.windows(2).all(|w| w[0] < w[1]),
266        "nonunique intensity quintile edges; upstream qcut is undefined for this panel"
267    );
268    let mut fc: Vec<_> = means.iter().map(|&(a, b)| (b / a).log2()).collect();
269    let (lo, hi) = bounds(&fc);
270    let calib: Vec<_> = fc
271        .iter()
272        .filter(|x| x.is_finite() && **x >= lo / 2. && **x <= hi / 2.)
273        .copied()
274        .collect();
275    let (center, sigma) = stats::calibrate(&calib).context("fold-change calibration")?;
276    fill_absent(&mut fc, &counts)?;
277    let threshold = cfg.fold_change * if cfg.absolute_fold_change { 1. } else { sigma };
278    let mut peptides = Vec::new();
279    for (i, row) in rows.iter().enumerate() {
280        let norm = &normalized[i];
281        let completed: Vec<_> = norm
282            .iter()
283            .enumerate()
284            .map(|(c, v)| v.unwrap_or(minima[c]))
285            .collect();
286        let log_a: Vec<_> = observed(&norm[..data.n_s1])
287            .iter()
288            .map(|v| v.log10())
289            .collect();
290        let log_b: Vec<_> = observed(&norm[data.n_s1..])
291            .iter()
292            .map(|v| v.log10())
293            .collect();
294        let mut p = stats::ttest(&log_a, &log_b);
295        if !p.is_finite() {
296            p = stats::ttest(
297                &completed[..data.n_s1]
298                    .iter()
299                    .map(|x| x.log10())
300                    .collect::<Vec<_>>(),
301                &completed[data.n_s1..]
302                    .iter()
303                    .map(|x| x.log10())
304                    .collect::<Vec<_>>(),
305            );
306        }
307        if !p.is_finite() {
308            p = 1.;
309        }
310        let bin = edges[1..].partition_point(|x| *x < intensity[i]).min(4);
311        let decoy = row
312            .proteins
313            .iter()
314            .all(|p| p.starts_with(&cfg.decoy_prefix));
315        for protein in &row.proteins {
316            peptides.push(Peptide {
317                peptide: row.key.clone(),
318                sequence: row.sequence.clone(),
319                protein: protein.clone(),
320                decoy,
321                intensities: completed.clone(),
322                missing: n - counts[i].0 - counts[i].1,
323                observed_s1: counts[i].0,
324                observed_s2: counts[i].1,
325                intensity: intensity[i],
326                bin,
327                raw_fc: raw_fc[i],
328                fc: fc[i],
329                corrected_fc: fc[i] - center,
330                pvalue: p,
331                running_fc: 0.,
332                better: 0,
333                significant: false,
334                up: false,
335                down: false,
336            });
337        }
338    }
339    peptides.sort_by(|a, b| {
340        a.missing
341            .cmp(&b.missing)
342            .then(b.intensity.total_cmp(&a.intensity))
343    });
344    let mut seen = BTreeSet::new();
345    peptides.retain(|p| seen.insert((p.sequence.clone(), p.protein.clone())));
346    let sorted = order::argsort(
347        &peptides
348            .iter()
349            .map(|p| p.corrected_fc.abs())
350            .collect::<Vec<_>>(),
351        false,
352    );
353    peptides = sorted.into_iter().map(|i| peptides[i].clone()).collect();
354    let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
355    for (i, p) in peptides.iter().enumerate() {
356        groups.entry(p.protein.clone()).or_default().push(i);
357    }
358    for indices in groups.values() {
359        let mut sum = 0.;
360        for (k, &i) in indices.iter().enumerate() {
361            sum += peptides[i].corrected_fc;
362            peptides[i].running_fc = sum / (k + 1) as f64;
363        }
364    }
365    let mut pools = vec![Vec::new(); 5];
366    for p in &peptides {
367        if p.decoy {
368            pools[p.bin].push((p.corrected_fc, p.pvalue));
369        }
370    }
371    for (bin, pool) in pools.iter().enumerate() {
372        ensure!(
373            !pool.is_empty(),
374            "no decoy observations in intensity quintile {bin}; cannot resample null"
375        );
376    }
377    let mut rng = rng::PythonRandom::new(cfg.seed);
378    for indices in groups.values() {
379        for _ in 0..100 {
380            let mut sum = 0.;
381            for (k, &i) in indices.iter().enumerate() {
382                let p = &mut peptides[i];
383                let pair = pools[p.bin][rng.choice(pools[p.bin].len())];
384                sum += pair.0;
385                if p.running_fc.abs() >= (sum / (k + 1) as f64).abs() && p.pvalue <= pair.1 {
386                    p.better += 1;
387                }
388            }
389        }
390        for &i in indices {
391            let p = &mut peptides[i];
392            p.significant = p.better >= 58;
393            p.up = p.significant && p.corrected_fc > 0.;
394            p.down = p.significant && p.corrected_fc < 0.;
395        }
396    }
397    // The peptide export is before majority-direction replacement, as upstream.
398    let exported = peptides.clone();
399    for indices in groups.values() {
400        let up = indices.iter().filter(|&&i| peptides[i].up).count();
401        let down = indices.iter().filter(|&&i| peptides[i].down).count();
402        if down > up {
403            for &i in indices {
404                peptides[i].up = peptides[i].down;
405            }
406        }
407    }
408    let mut by_support: Vec<_> = (0..peptides.len()).collect();
409    by_support.sort_by(|&a, &b| {
410        peptides[a]
411            .missing
412            .cmp(&peptides[b].missing)
413            .then(peptides[b].intensity.total_cmp(&peptides[a].intensity))
414    });
415    let mut decoy_seen = BTreeSet::new();
416    let mut decoy_changed = 0usize;
417    for &i in &by_support {
418        let p = &peptides[i];
419        if p.decoy && decoy_seen.insert(&p.sequence) {
420            decoy_changed += usize::from(p.up);
421        }
422    }
423    ensure!(
424        !decoy_seen.is_empty(),
425        "no decoy sequences for protein calibration"
426    );
427    let p_up = decoy_changed as f64 / decoy_seen.len() as f64;
428    let mut signatures: BTreeMap<String, usize> = BTreeMap::new();
429    let mut proteins = Vec::new();
430    for (name, indices) in &groups {
431        let mut seqs: Vec<_> = indices
432            .iter()
433            .map(|&i| peptides[i].sequence.as_str())
434            .collect();
435        seqs.sort();
436        let signature = seqs.concat();
437        let next = signatures.len() + 1;
438        let group = *signatures.entry(signature).or_insert(next);
439        if name.starts_with(&cfg.decoy_prefix) {
440            continue;
441        }
442        let selected: Vec<_> = indices
443            .iter()
444            .copied()
445            .filter(|&i| peptides[i].up)
446            .collect();
447        let all_fc = median(
448            &indices
449                .iter()
450                .map(|&i| peptides[i].corrected_fc)
451                .collect::<Vec<_>>(),
452        );
453        let all_raw_fc = median(
454            &indices
455                .iter()
456                .map(|&i| peptides[i].raw_fc)
457                .collect::<Vec<_>>(),
458        );
459        let fc = if selected.is_empty() {
460            all_fc
461        } else {
462            median(
463                &selected
464                    .iter()
465                    .map(|&i| peptides[i].corrected_fc)
466                    .collect::<Vec<_>>(),
467            )
468        };
469        let raw_fc = if selected.is_empty() {
470            all_raw_fc
471        } else {
472            median(
473                &selected
474                    .iter()
475                    .map(|&i| peptides[i].raw_fc)
476                    .collect::<Vec<_>>(),
477            )
478        };
479        let score = stats::binomial_score(selected.len() as u64, indices.len() as u64, p_up);
480        proteins.push(Protein {
481            name: name.clone(),
482            group,
483            n_peptides: indices.len(),
484            n_changed: selected.len(),
485            score,
486            pvalue: 10f64.powf(-score),
487            fc,
488            raw_fc,
489            all_fc,
490            all_raw_fc,
491            fc_pass: fc.abs() >= threshold,
492            fdr_pass: None,
493            bh_pass: false,
494        });
495    }
496    let sorted = order::argsort(&proteins.iter().map(|p| p.score).collect::<Vec<_>>(), true);
497    proteins = sorted.into_iter().map(|i| proteins[i].clone()).collect();
498    let n_groups = proteins
499        .iter()
500        .filter(|p| p.n_peptides >= 3 && p.fc_pass)
501        .map(|p| p.group)
502        .collect::<BTreeSet<_>>()
503        .len();
504    let mut ranked = BTreeSet::new();
505    for p in &mut proteins {
506        if p.n_peptides >= 3 && p.fc_pass {
507            ranked.insert(p.group);
508            let bh_threshold = -(ranked.len() as f64 * cfg.qvalue / n_groups as f64).log10();
509            p.bh_pass = p.score >= bh_threshold;
510            p.fdr_pass = Some(p.score >= -cfg.qvalue.log10());
511        }
512    }
513    Ok(Analysis {
514        config: cfg.clone(),
515        samples: data.samples.clone(),
516        n_s1: data.n_s1,
517        normalization_factors: factors,
518        calibration_mean: center,
519        calibration_std: sigma,
520        fold_change_threshold: threshold,
521        decoy_probability: p_up,
522        n_decoy_sequences: decoy_seen.len(),
523        input_rows: data.rows.len(),
524        eligible_precursors: rows.len(),
525        peptides: exported,
526        proteins,
527    })
528}