uno_core/group.rs
1//! Protein grouping for group-aware ID and quant.
2//!
3//! Highly conserved proteins (isoforms, orthologs, paralogs) share most or all of
4//! their peptides. uno's greedy inference otherwise reports a single arbitrary
5//! "leading" protein and silently drops its indistinguishable twins — and, worse
6//! for quant, would attribute a shared peptide's intensity (the SUM over every
7//! protein that produces it) to that one leading protein.
8//!
9//! This groups proteins that are **indistinguishable** by the observed data — i.e.
10//! that map to the identical set of observed peptides — and reports them as one
11//! group. For quant it also marks each observed peptide **group-unique** (maps only
12//! within a single group) vs **cross-group shared** (ambiguous, its intensity is a
13//! blend across groups). Only group-unique peptides are trustworthy for quant;
14//! shared ones are excluded. Principle: quantify at the level the data can resolve.
15
16use std::collections::{BTreeSet, HashMap, HashSet};
17
18use crate::database::PeptideDb;
19use crate::score::PeptideHit;
20
21/// A set of indistinguishable proteins (identical observed-peptide sets).
22#[derive(Debug, Clone)]
23pub struct ProteinGroup {
24 /// Representative accession (a confident member if any, else the first).
25 pub leading: String,
26 /// All indistinguishable member accessions (includes `leading`), sorted.
27 pub members: Vec<String>,
28 pub decoy: bool,
29}
30
31/// Build protein groups from the observed (matched) peptides.
32///
33/// Returns `(groups, pep_group)` where `pep_group[peptide_idx] = (decoy, leading)`
34/// only for **group-unique** peptides — those that map within a single group and
35/// are therefore usable for that group's quant. Cross-group-shared peptides are
36/// absent from `pep_group` (excluded from quant).
37pub fn build_groups(
38 db: &PeptideDb,
39 observed: &[PeptideHit],
40 confident: &HashSet<String>,
41) -> (Vec<ProteinGroup>, HashMap<u32, (bool, String)>) {
42 // Per protein: the set of observed peptides mapping to it (target/decoy kept
43 // separate). Only proteins that carry observed evidence appear here.
44 let mut prot_peps: HashMap<(bool, &str), BTreeSet<u32>> = HashMap::new();
45 for h in observed {
46 for prot in &db.peptides[h.peptide_idx as usize].proteins {
47 prot_peps
48 .entry((h.decoy, prot.as_ref()))
49 .or_default()
50 .insert(h.peptide_idx);
51 }
52 }
53
54 // Group proteins by identical observed-peptide signature.
55 let mut sig_group: HashMap<(bool, Vec<u32>), usize> = HashMap::new();
56 let mut groups: Vec<ProteinGroup> = Vec::new();
57 let mut prot_group: HashMap<(bool, &str), usize> = HashMap::new();
58 for ((decoy, acc), peps) in &prot_peps {
59 let sig = (*decoy, peps.iter().copied().collect::<Vec<_>>());
60 let gid = *sig_group.entry(sig).or_insert_with(|| {
61 groups.push(ProteinGroup {
62 leading: String::new(),
63 members: Vec::new(),
64 decoy: *decoy,
65 });
66 groups.len() - 1
67 });
68 groups[gid].members.push((*acc).to_string());
69 prot_group.insert((*decoy, *acc), gid);
70 }
71 for g in &mut groups {
72 g.members.sort();
73 g.leading = g
74 .members
75 .iter()
76 .find(|m| confident.contains(*m))
77 .cloned()
78 .unwrap_or_else(|| g.members[0].clone());
79 }
80
81 // Groups each observed peptide touches (deduped).
82 let mut pep_gids: HashMap<u32, Vec<usize>> = HashMap::new();
83 for h in observed {
84 let mut set: HashSet<usize> = HashSet::new();
85 for prot in &db.peptides[h.peptide_idx as usize].proteins {
86 if let Some(&gid) = prot_group.get(&(h.decoy, prot.as_ref())) {
87 set.insert(gid);
88 }
89 }
90 // Sorted, so `gids[0]` is a defined element rather than whichever one the
91 // HashSet happened to yield first. The `max_by` below already tie-breaks
92 // deterministically; this covers the `unwrap_or(&gids[0])` fallback, which
93 // is inert today only because its result is discarded at the `confident`
94 // check a few lines down.
95 pep_gids.entry(h.peptide_idx).or_insert_with(|| {
96 let mut v: Vec<usize> = set.into_iter().collect();
97 v.sort_unstable();
98 v
99 });
100 }
101 // Unique-peptide count per group (peptides touching exactly one group).
102 let mut uniq_count: HashMap<usize, usize> = HashMap::new();
103 for gids in pep_gids.values() {
104 if gids.len() == 1 {
105 *uniq_count.entry(gids[0]).or_default() += 1;
106 }
107 }
108 // Assign each peptide to ONE group: unique peptides to their group; shared
109 // (razor) peptides to the group with the most unique evidence (deterministic
110 // tie-break by leading accession). One group per peptide -> no double-counting.
111 // Tested universe = confident target groups + all decoy groups.
112 let mut pep_group: HashMap<u32, (bool, String)> = HashMap::new();
113 for h in observed {
114 let gids = &pep_gids[&h.peptide_idx];
115 if gids.is_empty() {
116 continue;
117 }
118 let gid = *gids
119 .iter()
120 .filter(|&&g| h.decoy || confident.contains(&groups[g].leading))
121 .max_by(|&&a, &&b| {
122 uniq_count
123 .get(&a)
124 .copied()
125 .unwrap_or(0)
126 .cmp(&uniq_count.get(&b).copied().unwrap_or(0))
127 .then(groups[b].leading.cmp(&groups[a].leading))
128 })
129 .unwrap_or(&gids[0]);
130 let leading = &groups[gid].leading;
131 if h.decoy || confident.contains(leading) {
132 pep_group.insert(h.peptide_idx, (h.decoy, leading.clone()));
133 }
134 }
135
136 (groups, pep_group)
137}
138
139/// Map each group's `(decoy, leading accession)` to its full member list (for output).
140///
141/// Keyed on the decoy flag as well as the accession, and it must be: uno's decoy
142/// peptides carry the SAME protein accessions as the targets they were shuffled
143/// from (see `database.rs`), so a target group and a decoy group routinely share a
144/// leading accession. Keyed on the accession alone, `collect()` silently kept
145/// whichever of the two came later in `groups` -- and `groups` is built by
146/// iterating a `HashMap`, so that was decided by the process hash seed.
147///
148/// This was the last source of run-to-run difference in `--out-proteins`: two runs
149/// agreed on every protein, score, peptide count and q-value, and disagreed on the
150/// group members of ~1% of rows, because those rows were being handed a decoy
151/// group's membership at random.
152pub fn members_by_leading(groups: &[ProteinGroup]) -> HashMap<(bool, String), Vec<String>> {
153 groups
154 .iter()
155 .map(|g| ((g.decoy, g.leading.clone()), g.members.clone()))
156 .collect()
157}