Skip to main content

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}