Skip to main content

uno_core/
quant.rs

1//! Multi-sample quantitation over a koth-aligned matrix: correct the
2//! peptide->protein feature assignment using the across-sample intensity pattern,
3//! then emit a protein-group × sample abundance matrix.
4//!
5//! uno is an ID + quant tool, not a stats framework. It stops at the abundance
6//! matrix; normalization, differential-expression testing, and interpretation are
7//! left to downstream tools (MSstats/limma/etc.), which take this matrix plus the
8//! experimental design. So there is deliberately NO design file, contrast, or FDR
9//! here — only the pieces that make the abundance values *correct*:
10//!
11//! - [`resolve_feature_competition`] — one feature quantifies for one protein
12//!   (parsimony), so a minority peptide can't measure a mass-coincident feature
13//!   owned by an abundant background.
14//! - [`profile_reassign`] — re-route a feature to the protein whose across-sample
15//!   profile it tracks, fixing mass+RT ties the ID stage couldn't (design-free:
16//!   uses variation, not condition labels).
17//! - [`group_abundance`] — sum each protein group's assigned peptides per run.
18
19use crate::database::PeptideDb;
20use crate::input::FeatureMatrix;
21use crate::score::PeptideHit;
22use std::collections::{HashMap, HashSet};
23
24/// DirectMS1Quant two-condition statistics (reference port) and the uno
25/// per-run evidence adapter. Exposed as `uno quant`.
26pub mod directms1;
27
28/// Feature-level competition for quant: each consensus feature may quantify for
29/// only ONE protein — the best-scoring one whose peptide claims it. uno's ID stage
30/// already does this (parsimony: one peak → one protein), but the per-peptide
31/// quant feature-pick discards it and lets any peptide grab any mass+RT match. On
32/// a mixed proteome that means a minority-species peptide (e.g. E. coli) quantifies
33/// on a mass-coincident feature owned by an abundant background protein, reading
34/// its flat profile instead of the real signal. Replaying the claim here — a
35/// feature goes to the peptide whose protein scores highest — drops those stolen
36/// features, so the minority peptide falls back to its real feature or drops out.
37/// `prot_score` maps (decoy, accession) → the protein's binomial score (higher =
38/// more confident).
39pub fn resolve_feature_competition(
40    peptides: &[PeptideHit],
41    db: &PeptideDb,
42    prot_score: &std::collections::HashMap<(bool, &str), f64>,
43) -> Vec<PeptideHit> {
44    // Best backing-protein score per hit (shared peptides take their strongest
45    // protein). Non-scored proteins → -inf, so they lose every contested feature.
46    let scores: Vec<f64> = peptides
47        .iter()
48        .map(|h| {
49            db.peptides[h.peptide_idx as usize]
50                .proteins
51                .iter()
52                .filter_map(|p| prot_score.get(&(h.decoy, p.as_ref())).copied())
53                .fold(f64::NEG_INFINITY, f64::max)
54        })
55        .collect();
56    let features: Vec<u32> = peptides.iter().map(|h| h.feature_idx).collect();
57    feature_winners(&features, &scores)
58        .into_iter()
59        .map(|i| peptides[i])
60        .collect()
61}
62
63/// The one-feature-one-protein claim behind [`resolve_feature_competition`],
64/// on plain `(feature, score)` columns so per-run quantitative evidence can
65/// replay exactly the same rule. Returns the winning row indices in ascending
66/// (input) order. A feature goes to its highest-scoring row; ties keep the
67/// lower index; rows with a non-finite score never win.
68pub fn feature_winners(features: &[u32], scores: &[f64]) -> Vec<usize> {
69    assert_eq!(features.len(), scores.len());
70    let mut winner: std::collections::HashMap<u32, usize> = std::collections::HashMap::new();
71    for (i, &feature) in features.iter().enumerate() {
72        if !scores[i].is_finite() {
73            continue;
74        }
75        match winner.get(&feature) {
76            Some(&j) if scores[j] >= scores[i] => {}
77            _ => {
78                winner.insert(feature, i);
79            }
80        }
81    }
82    // Return in the input's own order, not the HashMap's. The winning SET is
83    // deterministic (ties keep the lower index, since the incumbent is always
84    // seen first), but `winner.values()` is process-random, and this Vec becomes
85    // `--out-lfq` row order and the summation order inside `group_abundance` —
86    // so without this the LFQ rows shuffle and the abundances differ in the low
87    // bits between two runs on identical input.
88    let mut idx: Vec<usize> = winner.values().copied().collect();
89    idx.sort_unstable();
90    idx
91}
92
93/// Single-pass profile-guided reassignment (combines ID and quant).
94///
95/// The feature competition settles mass ties by *ID confidence*; this settles the
96/// ones it can't by the *quant pattern*. Starting from `base` (the competition
97/// result), it builds each protein's across-sample profile, then for every
98/// candidate `(feature, peptide)` pair whose feature actually VARIES, re-routes the
99/// feature to the mass-legitimate protein whose profile it best CORRELATES with.
100/// A rising E. coli feature that mass-matching first handed to a flat background
101/// protein moves to the E. coli protein it tracks — recovering signal instead of
102/// discarding it.
103///
104/// Guardrails against circularity: (1) only mass-candidate proteins are considered
105/// (`candidates` comes from the search's PFMs — a feature can't join a protein with
106/// no peptide at its mass); (2) only VARYING features/proteins are re-routed
107/// (`min_var`) — a flat feature carries no profile information, so it keeps the
108/// competition's assignment; (3) decoys go through the identical pass. `candidates`
109/// = `(feature_idx, peptide_idx, decoy)` for confident-protein peptides.
110pub fn profile_reassign(
111    matrix: &FeatureMatrix,
112    db: &PeptideDb,
113    base: &[PeptideHit],
114    candidates: &[(u32, u32, bool)],
115    min_corr: f64,
116    min_var: f64,
117) -> Vec<PeptideHit> {
118    let n_runs = matrix.run_names.len();
119    let (run_med, grand) = run_normalizers(matrix, base, n_runs);
120    // per-run low floor for imputing missing cells (5th pct of detected)
121    let mut per_run: Vec<Vec<f64>> = vec![Vec::new(); n_runs];
122    for h in base {
123        for (r, &v) in matrix.features[h.feature_idx as usize]
124            .intensities
125            .iter()
126            .enumerate()
127            .take(n_runs)
128        {
129            if v > 0.0 {
130                per_run[r].push(v.log2());
131            }
132        }
133    }
134    let run_floor: Vec<f64> = per_run
135        .iter_mut()
136        .map(|v| {
137            v.sort_by(|a, b| a.partial_cmp(b).unwrap());
138            if v.is_empty() {
139                0.0
140            } else {
141                v[v.len() / 20]
142            }
143        })
144        .collect();
145    let profile = |fi: usize| -> Vec<f64> {
146        let ints = &matrix.features[fi].intensities;
147        (0..n_runs)
148            .map(|r| {
149                let v = ints.get(r).copied().unwrap_or(0.0);
150                let l = if v > 0.0 { v.log2() } else { run_floor[r] };
151                l - run_med[r] + grand
152            })
153            .collect()
154    };
155    let variance = |p: &[f64]| -> f64 {
156        let m = p.iter().sum::<f64>() / p.len() as f64;
157        p.iter().map(|x| (x - m).powi(2)).sum::<f64>() / p.len() as f64
158    };
159
160    // Protein profiles (per-run median over the protein's base features).
161    let mut prot_feats: HashMap<(bool, &str), Vec<usize>> = HashMap::new();
162    for h in base {
163        for prot in &db.peptides[h.peptide_idx as usize].proteins {
164            prot_feats
165                .entry((h.decoy, prot.as_ref()))
166                .or_default()
167                .push(h.feature_idx as usize);
168        }
169    }
170    let prot_profile: HashMap<(bool, &str), Vec<f64>> = prot_feats
171        .iter()
172        .map(|(k, feats)| {
173            let mut cols = vec![Vec::new(); n_runs];
174            for &fi in feats {
175                let p = profile(fi);
176                for r in 0..n_runs {
177                    cols[r].push(p[r]);
178                }
179            }
180            let prof: Vec<f64> = cols
181                .iter_mut()
182                .map(|c| {
183                    c.sort_by(|a, b| a.partial_cmp(b).unwrap());
184                    c[c.len() / 2]
185                })
186                .collect();
187            (*k, prof)
188        })
189        .collect();
190
191    // Start from the competition assignment (one peptide/protein per feature).
192    let mut by_feat: HashMap<u32, PeptideHit> = base.iter().map(|h| (h.feature_idx, *h)).collect();
193
194    // For each VARYING candidate feature, find the best-correlating candidate protein.
195    let mut best: HashMap<u32, (f64, u32, bool)> = HashMap::new(); // feat -> (corr, peptide, decoy)
196    let mut fp_cache: HashMap<u32, (Vec<f64>, bool)> = HashMap::new(); // feat -> (profile, varies)
197    for &(fi, pi, decoy) in candidates {
198        let (fp, varies) = fp_cache.entry(fi).or_insert_with(|| {
199            let p = profile(fi as usize);
200            let v = variance(&p) >= min_var;
201            (p, v)
202        });
203        if !*varies {
204            continue;
205        }
206        let fp = fp.clone();
207        for prot in &db.peptides[pi as usize].proteins {
208            if let Some(pp) = prot_profile.get(&(decoy, prot.as_ref())) {
209                if variance(pp) < min_var {
210                    continue;
211                }
212                let c = pearson(&fp, pp);
213                let e = best.entry(fi).or_insert((f64::NEG_INFINITY, pi, decoy));
214                if c > e.0 {
215                    *e = (c, pi, decoy);
216                }
217            }
218        }
219    }
220    // Apply reassignments that clear the correlation bar.
221    let mut moved = 0usize;
222    for (fi, (c, pi, decoy)) in best {
223        if c >= min_corr {
224            let prev = by_feat.get(&fi).map(|h| h.peptide_idx);
225            if prev != Some(pi) {
226                moved += 1;
227            }
228            by_feat.insert(
229                fi,
230                PeptideHit {
231                    peptide_idx: pi,
232                    feature_idx: fi,
233                    decoy,
234                },
235            );
236        }
237    }
238    log::info!(
239        "profile reassignment: {} features re-routed by quant profile",
240        moved
241    );
242    // Same reason as in `resolve_feature_competition`: emit in feature order, not
243    // HashMap order. This is the opt-in `--profile-reassign` path, which WAS used
244    // for the reported quantitation, so it is on the live path to `--out-lfq`.
245    let mut hits: Vec<PeptideHit> = by_feat.into_values().collect();
246    hits.sort_unstable_by_key(|h| (h.feature_idx, h.peptide_idx, h.decoy));
247    hits
248}
249
250/// Per-run log2 normalization offsets from the INVARIANT backbone: features
251/// detected in every run (complete cases). These are dominated by the unchanging
252/// background, so normalizing to them removes the systematic drift that a plain
253/// all-feature median suffers when a large fraction of the sample changes
254/// (e.g. a heavy spike-in): there, the all-feature median tracks the changing
255/// species and makes the constant background appear to drift. Returns
256/// `(offset_per_run, grand_mean)`; apply as `log2(v) - offset[r] + grand`.
257/// Falls back to all detected cells if too few complete cases.
258fn run_normalizers(
259    matrix: &FeatureMatrix,
260    peptides: &[PeptideHit],
261    n_runs: usize,
262) -> (Vec<f64>, f64) {
263    let mut complete: Vec<Vec<f64>> = vec![Vec::new(); n_runs];
264    let mut all: Vec<Vec<f64>> = vec![Vec::new(); n_runs];
265    for h in peptides {
266        let ints = &matrix.features[h.feature_idx as usize].intensities;
267        let full = ints.len() >= n_runs && ints[..n_runs].iter().all(|&v| v > 0.0);
268        for (r, &v) in ints.iter().enumerate().take(n_runs) {
269            if v > 0.0 {
270                all[r].push(v.log2());
271                if full {
272                    complete[r].push(v.log2());
273                }
274            }
275        }
276    }
277    let med = |v: &mut Vec<f64>| -> f64 {
278        v.sort_by(|a, b| a.partial_cmp(b).unwrap());
279        if v.is_empty() {
280            0.0
281        } else {
282            v[v.len() / 2]
283        }
284    };
285    let use_complete = complete.iter().map(|v| v.len()).min().unwrap_or(0) >= 200;
286    let src = if use_complete {
287        &mut complete
288    } else {
289        &mut all
290    };
291    let offs: Vec<f64> = src.iter_mut().map(med).collect();
292    let grand = offs.iter().sum::<f64>() / n_runs.max(1) as f64;
293    (offs, grand)
294}
295
296fn pearson(x: &[f64], y: &[f64]) -> f64 {
297    let n = x.len() as f64;
298    if n < 2.0 {
299        return 0.0;
300    }
301    let mx = x.iter().sum::<f64>() / n;
302    let my = y.iter().sum::<f64>() / n;
303    let mut sxy = 0.0;
304    let mut sxx = 0.0;
305    let mut syy = 0.0;
306    for i in 0..x.len() {
307        let dx = x[i] - mx;
308        let dy = y[i] - my;
309        sxy += dx * dy;
310        sxx += dx * dx;
311        syy += dy * dy;
312    }
313    if sxx <= 0.0 || syy <= 0.0 {
314        0.0
315    } else {
316        sxy / (sxx * syy).sqrt()
317    }
318}
319
320/// Protein-group abundance matrix — uno's quant deliverable (design-free).
321///
322/// For each confident TARGET group, sums the intensity of its assigned
323/// (group-unique + razor) peptide-features per run. This is a raw protein × sample
324/// intensity table: uno stops here (ID + quant); normalization, differential
325/// testing, and interpretation are downstream tools' job. Returns
326/// `leading accession -> (per-run intensities, distinct peptide count)`.
327pub fn group_abundance(
328    matrix: &FeatureMatrix,
329    peptides: &[PeptideHit],
330    pep_group: &HashMap<u32, (bool, String)>,
331    n_runs: usize,
332) -> HashMap<String, (Vec<f64>, HashSet<u32>)> {
333    let mut out: HashMap<String, (Vec<f64>, HashSet<u32>)> = HashMap::new();
334    for h in peptides {
335        if let Some((decoy, leading)) = pep_group.get(&h.peptide_idx) {
336            if *decoy {
337                continue; // abundance is reported for target groups only
338            }
339            let ints = &matrix.features[h.feature_idx as usize].intensities;
340            let e = out
341                .entry(leading.clone())
342                .or_insert_with(|| (vec![0.0; n_runs], HashSet::new()));
343            for (r, slot) in e.0.iter_mut().enumerate() {
344                *slot += ints.get(r).copied().unwrap_or(0.0);
345            }
346            e.1.insert(h.peptide_idx);
347        }
348    }
349    out
350}
351
352#[cfg(test)]
353mod determinism_tests {
354    use super::*;
355    use crate::database::{PeptideDb, TheoPeptide};
356    use std::sync::Arc;
357
358    fn db_of(specs: &[(&str, bool, &[&str])]) -> PeptideDb {
359        let peptides: Vec<TheoPeptide> = specs
360            .iter()
361            .map(|(seq, decoy, prots)| TheoPeptide {
362                sequence: Arc::from(*seq),
363                mass: 1000.0,
364                decoy: *decoy,
365                missed_cleavages: 0,
366                proteins: prots.iter().map(|p| Arc::from(*p)).collect(),
367            })
368            .collect();
369        let n_targets = peptides.iter().filter(|p| !p.decoy).count();
370        let n_decoys = peptides.len() - n_targets;
371        PeptideDb {
372            peptides,
373            n_targets,
374            n_decoys,
375        }
376    }
377
378    /// `resolve_feature_competition` must return a stable ORDER, not just a stable set.
379    ///
380    /// It resolves winners through a `HashMap<feature_idx, usize>` and used to emit
381    /// `winner.values()` directly. Two `HashMap`s in one process get different seeds,
382    /// so this catches the bug without spawning a second process. The returned Vec is
383    /// `--out-lfq` row order and the summation order inside `group_abundance`, so a
384    /// shuffle there means the LFQ rows move and the abundances differ in the low bits.
385    #[test]
386    fn feature_competition_output_order_is_stable() {
387        let db = db_of(&[
388            ("PEPTIDEA", false, &["P1"][..]),
389            ("PEPTIDEB", false, &["P2"][..]),
390            ("PEPTIDEC", false, &["P1"][..]),
391            ("PEPTIDED", false, &["P3"][..]),
392            ("PEPTIDEE", false, &["P2"][..]),
393            ("PEPTIDEF", false, &["P3"][..]),
394        ]);
395        let mut prot_score: std::collections::HashMap<(bool, &str), f64> =
396            std::collections::HashMap::new();
397        prot_score.insert((false, "P1"), 9.0);
398        prot_score.insert((false, "P2"), 5.0);
399        prot_score.insert((false, "P3"), 1.0);
400
401        // Several peptides contend for the same features, so the map is exercised.
402        let hits: Vec<PeptideHit> = (0..6u32)
403            .map(|i| PeptideHit {
404                peptide_idx: i,
405                feature_idx: i % 3,
406                decoy: false,
407            })
408            .collect();
409
410        let first = resolve_feature_competition(&hits, &db, &prot_score);
411        for _ in 0..12 {
412            let again = resolve_feature_competition(&hits, &db, &prot_score);
413            assert_eq!(
414                first, again,
415                "feature competition output order is not stable"
416            );
417        }
418        // And the output really is in feature order.
419        assert_eq!(first.len(), 3);
420        assert!(first
421            .windows(2)
422            .all(|w| w[0].feature_idx < w[1].feature_idx));
423    }
424}