Skip to main content

data_beans/aux/
gene_sets.rs

1//! Gene-set sources for enrichment, reduced to one common `term → gene-set`
2//! form regardless of origin:
3//!   - **GAF** (GO Consortium gene-association format): one annotation per row,
4//!     `gene → GO id`; propagated up the ontology (true-path rule) into
5//!     `term → genes`. Read [`read_gaf`] then [`GafRaw::into_gene_sets`].
6//!   - **GMT** (MSigDB): one gene-set per row, `term <TAB> description <TAB>
7//!     genes…`; taken as-is, no propagation. Read [`read_gmt`].
8//!
9//! Each gene carries the identifier aliases the source provides (symbol /
10//! accession / synonyms for GAF) so a downstream reconciler can match it against
11//! an expression dictionary whether that uses HGNC, ENSG, or `ENSG_HGNC`.
12
13use crate::aux::ontology::Ontology;
14use crate::utilities::name_matching::GeneIndex;
15use anyhow::{Context, Result};
16use legume_numeric::matrix::common_io::open_buf_reader;
17use rustc_hash::{FxHashMap, FxHashSet};
18use std::io::BufRead;
19
20/// A collection of gene-sets keyed by term id — the common form all sources
21/// (GAF/GMT/markers) reduce to before scoring.
22#[derive(Default)]
23pub struct GeneSets {
24    /// term id → display name (absent ⇒ display the id itself).
25    pub names: FxHashMap<Box<str>, Box<str>>,
26    /// term id → member gene keys (upper-cased primary identifier).
27    pub term_genes: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
28    /// gene key → all identifier aliases for that gene (including the key), used
29    /// to reconcile against an expression dictionary (HGNC / ENSG / UniProt).
30    pub gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
31}
32
33impl GeneSets {
34    /// Number of distinct terms (gene-sets).
35    #[must_use]
36    pub fn n_terms(&self) -> usize {
37        self.term_genes.len()
38    }
39
40    /// Total membership entries summed over terms (post-propagation for GAF).
41    #[must_use]
42    pub fn n_annotations(&self) -> usize {
43        self.term_genes.values().map(FxHashSet::len).sum()
44    }
45
46    /// Number of distinct member genes across all sets.
47    #[must_use]
48    pub fn n_genes(&self) -> usize {
49        self.gene_aliases.len()
50    }
51}
52
53/// Read an MSigDB-style GMT: each non-comment line is
54/// `term <TAB> description <TAB> gene1 <TAB> gene2 …`. Terms are used verbatim
55/// (they become tree nodes only if they resolve against an `--obo`); genes are
56/// their own keys with no extra aliases; no propagation.
57pub fn read_gmt(path: &str) -> Result<GeneSets> {
58    let reader = open_buf_reader(path).with_context(|| format!("failed to open GMT: {path}"))?;
59    let mut gs = GeneSets::default();
60    for line in reader.lines() {
61        let line = line?;
62        let line = line.trim_end();
63        if line.is_empty() || line.starts_with('#') {
64            continue;
65        }
66        let mut it = line.split('\t');
67        let term = it.next().map(str::trim).unwrap_or_default();
68        if term.is_empty() {
69            continue;
70        }
71        let desc = it.next().map(str::trim).unwrap_or_default();
72        let genes: FxHashSet<Box<str>> = it
73            .map(str::trim)
74            .filter(|g| !g.is_empty())
75            .map(|g| g.to_uppercase().into_boxed_str())
76            .collect();
77        if genes.is_empty() {
78            continue;
79        }
80        let term: Box<str> = term.into();
81        for g in &genes {
82            gs.gene_aliases
83                .entry(g.clone())
84                .or_default()
85                .insert(g.clone());
86        }
87        if !desc.is_empty() {
88            gs.names.insert(term.clone(), desc.into());
89        }
90        gs.term_genes.entry(term).or_default().extend(genes);
91    }
92    Ok(gs)
93}
94
95/// Parse a two-column membership file — `gene <TAB> label` (a marker panel,
96/// a TF→target list, any gene→category table) — into `(gene, label)` pairs
97/// via the shared, gz-aware line reader (tab or comma delimited). Takes the
98/// first two tokens per line; skips blank lines, `#` comments, a
99/// `gene`/`symbol` header row and rows missing a label. Labels are kept
100/// verbatim; genes are not case-folded.
101pub fn read_membership_pairs(path: &str) -> Result<Vec<(Box<str>, Box<str>)>> {
102    let lines =
103        legume_numeric::matrix::common_io::read_lines_of_words_delim(path, &['\t', ','][..], -1)
104            .with_context(|| format!("reading membership pairs from {path}"))?
105            .lines;
106    Ok(lines
107        .into_iter()
108        .filter_map(|words| {
109            let gene = words.first()?.trim();
110            let label = words.get(1)?.trim();
111            let gl = gene.to_lowercase();
112            if gene.is_empty()
113                || gene.starts_with('#')
114                || label.is_empty()
115                || gl == "gene"
116                || gl == "symbol"
117            {
118                return None;
119            }
120            Some((Box::from(gene), Box::from(label)))
121        })
122        .collect())
123}
124
125/// Options for [`read_gaf`].
126#[derive(Default, Clone, Copy)]
127pub struct GafOpts {
128    /// Drop IEA (inferred-from-electronic-annotation) rows — the low-confidence
129    /// bulk of a GAF (evidence-code column).
130    pub no_iea: bool,
131}
132
133/// Raw GAF annotations before ontology propagation: gene key → direct GO ids,
134/// plus the per-gene identifier aliases harvested from the GAF.
135pub struct GafRaw {
136    gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
137    gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
138}
139
140/// Parse a GAF (`.gaf` or `.gaf.gz`). Columns (1-based) used: 2 = object id /
141/// accession, 3 = symbol (the gene key), 4 = qualifier (rows with `NOT` are
142/// dropped), 5 = GO id, 7 = evidence code (for `no_iea`), 11 = synonyms,
143/// 12 = object type (only proteins and genes are kept). The symbol,
144/// accession, and pipe-split synonyms are kept as match aliases.
145pub fn read_gaf(path: &str, opts: &GafOpts) -> Result<GafRaw> {
146    let reader = open_buf_reader(path).with_context(|| format!("failed to open GAF: {path}"))?;
147    let mut gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
148    let mut n_other_objects = 0usize;
149    let mut gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
150    for line in reader.lines() {
151        let line = line?;
152        if line.starts_with('!') {
153            continue;
154        }
155        let f: Vec<&str> = line.split('\t').collect();
156        if f.len() < 15 {
157            continue; // GAF 2.x has ≥15 columns
158        }
159        let symbol = f[2].trim();
160        let go = f[4].trim();
161        if symbol.is_empty() || !go.starts_with("GO:") {
162            continue;
163        }
164        if f[3].split('|').any(|q| q.trim() == "NOT") {
165            continue;
166        }
167        if opts.no_iea && f[6].trim() == "IEA" {
168            continue;
169        }
170        // Column 12 is the object type; a gene graph places proteins and
171        // genes, not miRNAs, rRNAs or protein complexes, whose GAF symbols
172        // (`hsa-miR-21-5p`, `abeta-42-oligomer_human`) are not gene names.
173        if !matches!(f[11].trim(), "protein" | "gene" | "gene_product" | "") {
174            n_other_objects += 1;
175            continue;
176        }
177        let key: Box<str> = symbol.to_uppercase().into();
178        let aliases = gene_aliases.entry(key.clone()).or_default();
179        aliases.insert(key.clone());
180        let acc = f[1].trim();
181        if !acc.is_empty() {
182            aliases.insert(acc.to_uppercase().into());
183        }
184        for syn in f[10].split('|').map(str::trim).filter(|s| !s.is_empty()) {
185            aliases.insert(syn.to_uppercase().into());
186        }
187        gene2direct.entry(key).or_default().insert(go.into());
188    }
189    if n_other_objects > 0 {
190        log::info!(
191            "{path}: {n_other_objects} rows on non-protein objects (RNAs, complexes) skipped"
192        );
193    }
194    Ok(GafRaw {
195        gene2direct,
196        gene_aliases,
197    })
198}
199
200impl GafRaw {
201    /// Reduce to the common [`GeneSets`] form. With an ontology, propagate each
202    /// gene up the `is_a` + `part_of` closure (true-path rule) and pull display
203    /// names; without one, keep the direct annotations only.
204    #[must_use]
205    pub fn into_gene_sets(self, onto: Option<&Ontology>) -> GeneSets {
206        let mut gs = GeneSets {
207            gene_aliases: self.gene_aliases,
208            ..Default::default()
209        };
210        for (gene, direct) in &self.gene2direct {
211            let mut full: FxHashSet<Box<str>> = FxHashSet::default();
212            for go in direct {
213                match onto {
214                    Some(o) if o.contains(go) => {
215                        full.extend(o.ancestors_or_self_with_part_of(go));
216                    }
217                    _ => {
218                        full.insert(go.clone());
219                    }
220                }
221            }
222            for t in full {
223                gs.term_genes.entry(t).or_default().insert(gene.clone());
224            }
225        }
226        if let Some(o) = onto {
227            let terms: Vec<Box<str>> = gs.term_genes.keys().cloned().collect();
228            for t in terms {
229                if let Some(n) = o.name(&t) {
230                    gs.names.insert(t, n.into());
231                }
232            }
233        }
234        gs
235    }
236}
237
238/// Gene-sets reconciled against an expression dictionary: term → matched row
239/// indices, plus coverage statistics. This is the sparse membership the
240/// enrichment scorer consumes (term → its rows in the gene profile).
241pub struct Reconciled {
242    /// term id → matched dictionary row indices (deduped, ascending); only
243    /// terms retaining ≥ the requested minimum members are kept.
244    pub term_rows: FxHashMap<Box<str>, Vec<usize>>,
245    /// term id → display name (carried through from the source).
246    pub names: FxHashMap<Box<str>, Box<str>>,
247    /// All matched dictionary rows that carry ≥1 annotation (the enrichment
248    /// background "universe"), ascending. Drawn from every source term — NOT
249    /// only the size-windowed `term_rows` — so the hypergeometric background is
250    /// the full annotated set.
251    pub universe: Vec<usize>,
252    /// distinct gene keys in the source.
253    pub n_genes_total: usize,
254    /// gene keys that matched at least one dictionary row.
255    pub n_genes_matched: usize,
256    /// terms retained (≥ min members after reconciliation).
257    pub n_terms_kept: usize,
258    /// terms in the source before the min-member filter.
259    pub n_terms_total: usize,
260}
261
262impl GeneSets {
263    /// Reconcile each member gene against `index` (built over the expression
264    /// dictionary), trying every identifier alias and taking the first hit, then
265    /// keep only terms whose matched-member count is in
266    /// `[min_members, max_members]`. The upper bound matters: GSEA `es_std` is
267    /// ill-behaved for near-universal sets (their restandardization SD collapses
268    /// → spurious huge z), so giant root-level terms must be excluded — the
269    /// standard GSEA size window. `max_members = None` disables the cap. Handles
270    /// HGNC / ENSG / `ENSG_HGNC` conventions via [`GeneIndex`].
271    #[must_use]
272    pub fn reconcile(
273        &self,
274        index: &GeneIndex,
275        min_members: usize,
276        max_members: Option<usize>,
277    ) -> Reconciled {
278        // gene key → matched dictionary row (first alias that resolves).
279        let mut gene_row: FxHashMap<&str, usize> = FxHashMap::default();
280        for (key, aliases) in &self.gene_aliases {
281            let row = index
282                .match_gene(key)
283                .or_else(|| aliases.iter().find_map(|a| index.match_gene(a)));
284            if let Some(r) = row {
285                gene_row.insert(key, r);
286            }
287        }
288        let n_genes_matched = gene_row.len();
289        // Background universe = every matched annotated row (deduped, ascending),
290        // independent of the term size window applied below.
291        let mut universe: Vec<usize> = gene_row.values().copied().collect();
292        universe.sort_unstable();
293        universe.dedup();
294
295        let mut term_rows: FxHashMap<Box<str>, Vec<usize>> = FxHashMap::default();
296        for (term, genes) in &self.term_genes {
297            let mut rows: Vec<usize> = genes
298                .iter()
299                .filter_map(|g| gene_row.get(g.as_ref()).copied())
300                .collect();
301            rows.sort_unstable();
302            rows.dedup();
303            let n = rows.len();
304            if n >= min_members && max_members.is_none_or(|mx| n <= mx) {
305                term_rows.insert(term.clone(), rows);
306            }
307        }
308
309        Reconciled {
310            n_terms_kept: term_rows.len(),
311            n_terms_total: self.term_genes.len(),
312            n_genes_total: self.gene_aliases.len(),
313            n_genes_matched,
314            names: self.names.clone(),
315            universe,
316            term_rows,
317        }
318    }
319}
320
321/// Below this matched-gene fraction, [`Reconciled::log_coverage`] warns rather
322/// than logs at info — a thin overlap silently produces no enrichment.
323pub const COVERAGE_WARN_FRAC: f32 = 0.5;
324
325impl Reconciled {
326    /// Fraction of source genes matched to the dictionary.
327    #[must_use]
328    pub fn match_frac(&self) -> f32 {
329        if self.n_genes_total == 0 {
330            0.0
331        } else {
332            self.n_genes_matched as f32 / self.n_genes_total as f32
333        }
334    }
335
336    /// Total membership entries across retained terms.
337    #[must_use]
338    pub fn n_memberships(&self) -> usize {
339        self.term_rows.values().map(Vec::len).sum()
340    }
341
342    /// Log a one-line coverage summary — `info`, or `warn` when overlap is thin
343    /// (a near-empty map silently yields no enrichment).
344    pub fn log_coverage(&self) {
345        let frac = self.match_frac();
346        let msg = format!(
347            "gene-set coverage: {}/{} genes matched ({:.1}%); {}/{} terms kept (≥min members); {} memberships",
348            self.n_genes_matched,
349            self.n_genes_total,
350            100.0 * frac,
351            self.n_terms_kept,
352            self.n_terms_total,
353            self.n_memberships(),
354        );
355        if frac < COVERAGE_WARN_FRAC {
356            log::warn!("{msg}");
357        } else {
358            log::info!("{msg}");
359        }
360    }
361
362    /// Fail when coverage is too thin to produce meaningful enrichment.
363    pub fn ensure_coverage(&self, min_frac: f32, min_terms: usize) -> Result<()> {
364        let frac = self.match_frac();
365        anyhow::ensure!(
366            frac >= min_frac && self.n_terms_kept >= min_terms,
367            "insufficient gene→term coverage: {:.1}% of genes matched (need ≥{:.0}%), \
368             {} terms kept (need ≥{}). Check that gene-set ids (HGNC/ENSG) match the \
369             expression dictionary's gene names.",
370            100.0 * frac,
371            100.0 * min_frac,
372            self.n_terms_kept,
373            min_terms,
374        );
375        Ok(())
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use std::io::Write;
383
384    #[test]
385    fn gaf_rows_on_rnas_and_complexes_are_skipped() {
386        let row = |sym: &str, go: &str, ty: &str| {
387            let mut c = vec![""; 17];
388            c[0] = "UniProtKB";
389            c[1] = "P1";
390            c[2] = sym;
391            c[3] = "involved_in";
392            c[4] = go;
393            c[5] = "PMID:1";
394            c[6] = "IDA";
395            c[8] = "P";
396            c[10] = sym;
397            c[11] = ty;
398            c[12] = "taxon:9606";
399            c[13] = "20200101";
400            c[14] = "UniProt";
401            format!("{}\n", c.join("\t"))
402        };
403        let text = format!(
404            "!gaf-version: 2.2\n{}{}{}{}",
405            row("TP53", "GO:1", "protein"),
406            row("hsa-miR-21-5p", "GO:1", "miRNA"),
407            row("abeta-42-oligomer_human", "GO:1", "protein_complex"),
408            row("BAX", "GO:2", "gene")
409        );
410        let f = tmp(&text, ".gaf");
411        let gs = read_gaf(f.path().to_str().unwrap(), &GafOpts::default())
412            .unwrap()
413            .into_gene_sets(None);
414        let mut genes: Vec<&str> = gs.gene_aliases.keys().map(|k| k.as_ref()).collect();
415        genes.sort();
416        assert_eq!(genes, vec!["BAX", "TP53"]);
417    }
418
419    #[test]
420    fn membership_pairs_skip_headers_comments_and_short_rows_and_keep_labels_verbatim() {
421        let f = tmp(
422            "gene\tcelltype\n# note\nCD3E\tT cell\nMS4A1,B cell\nLONELY\n\nSymbol\tx\nCD14\t Monocyte \n",
423            ".tsv",
424        );
425        let pairs = read_membership_pairs(f.path().to_str().unwrap()).unwrap();
426        assert_eq!(
427            pairs,
428            vec![
429                (Box::from("CD3E"), Box::from("T cell")),
430                (Box::from("MS4A1"), Box::from("B cell")),
431                (Box::from("CD14"), Box::from("Monocyte")),
432            ]
433        );
434    }
435
436    fn tmp(contents: &str, suffix: &str) -> tempfile::NamedTempFile {
437        let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
438        f.write_all(contents.as_bytes()).unwrap();
439        f.flush().unwrap();
440        f
441    }
442
443    #[test]
444    fn gmt_round_trip() {
445        let f = tmp(
446            "# comment\n\
447             SET_A\tset A desc\tCD3D\tcd8a\tGZMK\n\
448             SET_B\tset B desc\tMS4A1\tCD79A\n",
449            ".gmt",
450        );
451        let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
452        assert_eq!(gs.n_terms(), 2);
453        assert_eq!(gs.names.get("SET_A").map(|n| &**n), Some("set A desc"));
454        // genes upper-cased, deduped into keys.
455        assert!(gs.term_genes["SET_A"].contains("CD8A"));
456        assert_eq!(gs.term_genes["SET_A"].len(), 3);
457        assert!(gs.gene_aliases.contains_key("CD3D"));
458    }
459
460    /// GO-shaped OBO: GO:0000001 (leaf) is_a GO:0000002 (parent).
461    fn write_go_obo() -> tempfile::NamedTempFile {
462        tmp(
463            "format-version: 1.2\n\n\
464             [Term]\nid: GO:0000002\nname: parent process\n\n\
465             [Term]\nid: GO:0000001\nname: leaf process\nis_a: GO:0000002 ! parent process\n",
466            ".obo",
467        )
468    }
469
470    #[test]
471    fn gaf_parse_filters_and_propagates() {
472        // 17-col GAF rows: FOO→GO:0000001 (IDA, kept); BAR→GO:0000001 (NOT, dropped);
473        // BAZ→GO:0000001 (IEA, dropped under no_iea). Tabs are literal.
474        let rows = "\
475UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo protein\tFOO_ALT|ENSG00000011111\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
476UniProtKB\tP22222\tBAR\tNOT|enables\tGO:0000001\tPMID:2\tIDA\t\tP\tBar protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
477UniProtKB\tP33333\tBAZ\t\tGO:0000001\tPMID:3\tIEA\t\tP\tBaz protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
478        let gaf = tmp(rows, ".gaf");
479        let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts { no_iea: true }).unwrap();
480
481        let onto = Ontology::load_obo(write_go_obo().path().to_str().unwrap()).unwrap();
482        let gs = raw.into_gene_sets(Some(&onto));
483
484        // Only FOO survives (NOT + IEA dropped).
485        assert_eq!(gs.n_genes(), 1);
486        // True-path: FOO is a member of the leaf AND its is_a parent.
487        assert!(gs.term_genes["GO:0000001"].contains("FOO"));
488        assert!(gs.term_genes["GO:0000002"].contains("FOO"));
489        // names pulled from the ontology.
490        assert_eq!(
491            gs.names.get("GO:0000002").map(|n| &**n),
492            Some("parent process")
493        );
494        // aliases harvested: accession + synonym (incl. an ENSG).
495        let al = &gs.gene_aliases["FOO"];
496        assert!(al.contains("P11111"));
497        assert!(al.contains("ENSG00000011111"));
498    }
499
500    #[test]
501    fn reconcile_matches_aliases_and_filters() {
502        let f = tmp("SET_A\tdesc\tCD8A\tMS4A1\tGHOSTGENE\n", ".gmt");
503        let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
504        // dict mixes ENSG_SYMBOL and bare symbol; GHOSTGENE is absent.
505        let dict: Vec<Box<str>> = ["ENSG00000153563_CD8A", "MS4A1"]
506            .iter()
507            .map(|s| Box::from(*s))
508            .collect();
509        let idx = GeneIndex::build(&dict);
510
511        let rec = gs.reconcile(&idx, 1, None);
512        // CD8A (via ENSG_… symbol tier) + MS4A1 (exact) match; GHOSTGENE doesn't.
513        assert_eq!(rec.n_genes_total, 3);
514        assert_eq!(rec.n_genes_matched, 2);
515        assert_eq!(rec.universe, vec![0, 1]); // both matched rows, deduped
516        assert_eq!(rec.term_rows["SET_A"].len(), 2);
517        assert!(rec.ensure_coverage(0.5, 1).is_ok());
518
519        // min_members filter drops the term → coverage check fails.
520        let rec2 = gs.reconcile(&idx, 3, None);
521        assert!(rec2.term_rows.is_empty());
522        assert!(rec2.ensure_coverage(0.5, 1).is_err());
523
524        // max_members cap also drops the (2-member) term.
525        let rec3 = gs.reconcile(&idx, 1, Some(1));
526        assert!(rec3.term_rows.is_empty());
527    }
528
529    #[test]
530    fn gaf_without_ontology_keeps_direct() {
531        let rows = "\
532UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
533        let gaf = tmp(rows, ".gaf");
534        let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts::default()).unwrap();
535        let gs = raw.into_gene_sets(None);
536        assert_eq!(gs.n_terms(), 1); // no propagation → just the direct term
537        assert!(gs.term_genes["GO:0000001"].contains("FOO"));
538    }
539}