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}