use crate::aux::ontology::Ontology;
use crate::utilities::name_matching::GeneIndex;
use anyhow::{Context, Result};
use legume_numeric::matrix::common_io::open_buf_reader;
use rustc_hash::{FxHashMap, FxHashSet};
use std::io::BufRead;
#[derive(Default)]
pub struct GeneSets {
pub names: FxHashMap<Box<str>, Box<str>>,
pub term_genes: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
pub gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
}
impl GeneSets {
#[must_use]
pub fn n_terms(&self) -> usize {
self.term_genes.len()
}
#[must_use]
pub fn n_annotations(&self) -> usize {
self.term_genes.values().map(FxHashSet::len).sum()
}
#[must_use]
pub fn n_genes(&self) -> usize {
self.gene_aliases.len()
}
}
pub fn read_gmt(path: &str) -> Result<GeneSets> {
let reader = open_buf_reader(path).with_context(|| format!("failed to open GMT: {path}"))?;
let mut gs = GeneSets::default();
for line in reader.lines() {
let line = line?;
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut it = line.split('\t');
let term = it.next().map(str::trim).unwrap_or_default();
if term.is_empty() {
continue;
}
let desc = it.next().map(str::trim).unwrap_or_default();
let genes: FxHashSet<Box<str>> = it
.map(str::trim)
.filter(|g| !g.is_empty())
.map(|g| g.to_uppercase().into_boxed_str())
.collect();
if genes.is_empty() {
continue;
}
let term: Box<str> = term.into();
for g in &genes {
gs.gene_aliases
.entry(g.clone())
.or_default()
.insert(g.clone());
}
if !desc.is_empty() {
gs.names.insert(term.clone(), desc.into());
}
gs.term_genes.entry(term).or_default().extend(genes);
}
Ok(gs)
}
pub fn read_membership_pairs(path: &str) -> Result<Vec<(Box<str>, Box<str>)>> {
let lines =
legume_numeric::matrix::common_io::read_lines_of_words_delim(path, &['\t', ','][..], -1)
.with_context(|| format!("reading membership pairs from {path}"))?
.lines;
Ok(lines
.into_iter()
.filter_map(|words| {
let gene = words.first()?.trim();
let label = words.get(1)?.trim();
let gl = gene.to_lowercase();
if gene.is_empty()
|| gene.starts_with('#')
|| label.is_empty()
|| gl == "gene"
|| gl == "symbol"
{
return None;
}
Some((Box::from(gene), Box::from(label)))
})
.collect())
}
#[derive(Default, Clone, Copy)]
pub struct GafOpts {
pub no_iea: bool,
}
pub struct GafRaw {
gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>>,
}
pub fn read_gaf(path: &str, opts: &GafOpts) -> Result<GafRaw> {
let reader = open_buf_reader(path).with_context(|| format!("failed to open GAF: {path}"))?;
let mut gene2direct: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
let mut n_other_objects = 0usize;
let mut gene_aliases: FxHashMap<Box<str>, FxHashSet<Box<str>>> = FxHashMap::default();
for line in reader.lines() {
let line = line?;
if line.starts_with('!') {
continue;
}
let f: Vec<&str> = line.split('\t').collect();
if f.len() < 15 {
continue; }
let symbol = f[2].trim();
let go = f[4].trim();
if symbol.is_empty() || !go.starts_with("GO:") {
continue;
}
if f[3].split('|').any(|q| q.trim() == "NOT") {
continue;
}
if opts.no_iea && f[6].trim() == "IEA" {
continue;
}
if !matches!(f[11].trim(), "protein" | "gene" | "gene_product" | "") {
n_other_objects += 1;
continue;
}
let key: Box<str> = symbol.to_uppercase().into();
let aliases = gene_aliases.entry(key.clone()).or_default();
aliases.insert(key.clone());
let acc = f[1].trim();
if !acc.is_empty() {
aliases.insert(acc.to_uppercase().into());
}
for syn in f[10].split('|').map(str::trim).filter(|s| !s.is_empty()) {
aliases.insert(syn.to_uppercase().into());
}
gene2direct.entry(key).or_default().insert(go.into());
}
if n_other_objects > 0 {
log::info!(
"{path}: {n_other_objects} rows on non-protein objects (RNAs, complexes) skipped"
);
}
Ok(GafRaw {
gene2direct,
gene_aliases,
})
}
impl GafRaw {
#[must_use]
pub fn into_gene_sets(self, onto: Option<&Ontology>) -> GeneSets {
let mut gs = GeneSets {
gene_aliases: self.gene_aliases,
..Default::default()
};
for (gene, direct) in &self.gene2direct {
let mut full: FxHashSet<Box<str>> = FxHashSet::default();
for go in direct {
match onto {
Some(o) if o.contains(go) => {
full.extend(o.ancestors_or_self_with_part_of(go));
}
_ => {
full.insert(go.clone());
}
}
}
for t in full {
gs.term_genes.entry(t).or_default().insert(gene.clone());
}
}
if let Some(o) = onto {
let terms: Vec<Box<str>> = gs.term_genes.keys().cloned().collect();
for t in terms {
if let Some(n) = o.name(&t) {
gs.names.insert(t, n.into());
}
}
}
gs
}
}
pub struct Reconciled {
pub term_rows: FxHashMap<Box<str>, Vec<usize>>,
pub names: FxHashMap<Box<str>, Box<str>>,
pub universe: Vec<usize>,
pub n_genes_total: usize,
pub n_genes_matched: usize,
pub n_terms_kept: usize,
pub n_terms_total: usize,
}
impl GeneSets {
#[must_use]
pub fn reconcile(
&self,
index: &GeneIndex,
min_members: usize,
max_members: Option<usize>,
) -> Reconciled {
let mut gene_row: FxHashMap<&str, usize> = FxHashMap::default();
for (key, aliases) in &self.gene_aliases {
let row = index
.match_gene(key)
.or_else(|| aliases.iter().find_map(|a| index.match_gene(a)));
if let Some(r) = row {
gene_row.insert(key, r);
}
}
let n_genes_matched = gene_row.len();
let mut universe: Vec<usize> = gene_row.values().copied().collect();
universe.sort_unstable();
universe.dedup();
let mut term_rows: FxHashMap<Box<str>, Vec<usize>> = FxHashMap::default();
for (term, genes) in &self.term_genes {
let mut rows: Vec<usize> = genes
.iter()
.filter_map(|g| gene_row.get(g.as_ref()).copied())
.collect();
rows.sort_unstable();
rows.dedup();
let n = rows.len();
if n >= min_members && max_members.is_none_or(|mx| n <= mx) {
term_rows.insert(term.clone(), rows);
}
}
Reconciled {
n_terms_kept: term_rows.len(),
n_terms_total: self.term_genes.len(),
n_genes_total: self.gene_aliases.len(),
n_genes_matched,
names: self.names.clone(),
universe,
term_rows,
}
}
}
pub const COVERAGE_WARN_FRAC: f32 = 0.5;
impl Reconciled {
#[must_use]
pub fn match_frac(&self) -> f32 {
if self.n_genes_total == 0 {
0.0
} else {
self.n_genes_matched as f32 / self.n_genes_total as f32
}
}
#[must_use]
pub fn n_memberships(&self) -> usize {
self.term_rows.values().map(Vec::len).sum()
}
pub fn log_coverage(&self) {
let frac = self.match_frac();
let msg = format!(
"gene-set coverage: {}/{} genes matched ({:.1}%); {}/{} terms kept (≥min members); {} memberships",
self.n_genes_matched,
self.n_genes_total,
100.0 * frac,
self.n_terms_kept,
self.n_terms_total,
self.n_memberships(),
);
if frac < COVERAGE_WARN_FRAC {
log::warn!("{msg}");
} else {
log::info!("{msg}");
}
}
pub fn ensure_coverage(&self, min_frac: f32, min_terms: usize) -> Result<()> {
let frac = self.match_frac();
anyhow::ensure!(
frac >= min_frac && self.n_terms_kept >= min_terms,
"insufficient gene→term coverage: {:.1}% of genes matched (need ≥{:.0}%), \
{} terms kept (need ≥{}). Check that gene-set ids (HGNC/ENSG) match the \
expression dictionary's gene names.",
100.0 * frac,
100.0 * min_frac,
self.n_terms_kept,
min_terms,
);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn gaf_rows_on_rnas_and_complexes_are_skipped() {
let row = |sym: &str, go: &str, ty: &str| {
let mut c = vec![""; 17];
c[0] = "UniProtKB";
c[1] = "P1";
c[2] = sym;
c[3] = "involved_in";
c[4] = go;
c[5] = "PMID:1";
c[6] = "IDA";
c[8] = "P";
c[10] = sym;
c[11] = ty;
c[12] = "taxon:9606";
c[13] = "20200101";
c[14] = "UniProt";
format!("{}\n", c.join("\t"))
};
let text = format!(
"!gaf-version: 2.2\n{}{}{}{}",
row("TP53", "GO:1", "protein"),
row("hsa-miR-21-5p", "GO:1", "miRNA"),
row("abeta-42-oligomer_human", "GO:1", "protein_complex"),
row("BAX", "GO:2", "gene")
);
let f = tmp(&text, ".gaf");
let gs = read_gaf(f.path().to_str().unwrap(), &GafOpts::default())
.unwrap()
.into_gene_sets(None);
let mut genes: Vec<&str> = gs.gene_aliases.keys().map(|k| k.as_ref()).collect();
genes.sort();
assert_eq!(genes, vec!["BAX", "TP53"]);
}
#[test]
fn membership_pairs_skip_headers_comments_and_short_rows_and_keep_labels_verbatim() {
let f = tmp(
"gene\tcelltype\n# note\nCD3E\tT cell\nMS4A1,B cell\nLONELY\n\nSymbol\tx\nCD14\t Monocyte \n",
".tsv",
);
let pairs = read_membership_pairs(f.path().to_str().unwrap()).unwrap();
assert_eq!(
pairs,
vec![
(Box::from("CD3E"), Box::from("T cell")),
(Box::from("MS4A1"), Box::from("B cell")),
(Box::from("CD14"), Box::from("Monocyte")),
]
);
}
fn tmp(contents: &str, suffix: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::Builder::new().suffix(suffix).tempfile().unwrap();
f.write_all(contents.as_bytes()).unwrap();
f.flush().unwrap();
f
}
#[test]
fn gmt_round_trip() {
let f = tmp(
"# comment\n\
SET_A\tset A desc\tCD3D\tcd8a\tGZMK\n\
SET_B\tset B desc\tMS4A1\tCD79A\n",
".gmt",
);
let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
assert_eq!(gs.n_terms(), 2);
assert_eq!(gs.names.get("SET_A").map(|n| &**n), Some("set A desc"));
assert!(gs.term_genes["SET_A"].contains("CD8A"));
assert_eq!(gs.term_genes["SET_A"].len(), 3);
assert!(gs.gene_aliases.contains_key("CD3D"));
}
fn write_go_obo() -> tempfile::NamedTempFile {
tmp(
"format-version: 1.2\n\n\
[Term]\nid: GO:0000002\nname: parent process\n\n\
[Term]\nid: GO:0000001\nname: leaf process\nis_a: GO:0000002 ! parent process\n",
".obo",
)
}
#[test]
fn gaf_parse_filters_and_propagates() {
let rows = "\
UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo protein\tFOO_ALT|ENSG00000011111\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
UniProtKB\tP22222\tBAR\tNOT|enables\tGO:0000001\tPMID:2\tIDA\t\tP\tBar protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n\
UniProtKB\tP33333\tBAZ\t\tGO:0000001\tPMID:3\tIEA\t\tP\tBaz protein\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
let gaf = tmp(rows, ".gaf");
let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts { no_iea: true }).unwrap();
let onto = Ontology::load_obo(write_go_obo().path().to_str().unwrap()).unwrap();
let gs = raw.into_gene_sets(Some(&onto));
assert_eq!(gs.n_genes(), 1);
assert!(gs.term_genes["GO:0000001"].contains("FOO"));
assert!(gs.term_genes["GO:0000002"].contains("FOO"));
assert_eq!(
gs.names.get("GO:0000002").map(|n| &**n),
Some("parent process")
);
let al = &gs.gene_aliases["FOO"];
assert!(al.contains("P11111"));
assert!(al.contains("ENSG00000011111"));
}
#[test]
fn reconcile_matches_aliases_and_filters() {
let f = tmp("SET_A\tdesc\tCD8A\tMS4A1\tGHOSTGENE\n", ".gmt");
let gs = read_gmt(f.path().to_str().unwrap()).unwrap();
let dict: Vec<Box<str>> = ["ENSG00000153563_CD8A", "MS4A1"]
.iter()
.map(|s| Box::from(*s))
.collect();
let idx = GeneIndex::build(&dict);
let rec = gs.reconcile(&idx, 1, None);
assert_eq!(rec.n_genes_total, 3);
assert_eq!(rec.n_genes_matched, 2);
assert_eq!(rec.universe, vec![0, 1]); assert_eq!(rec.term_rows["SET_A"].len(), 2);
assert!(rec.ensure_coverage(0.5, 1).is_ok());
let rec2 = gs.reconcile(&idx, 3, None);
assert!(rec2.term_rows.is_empty());
assert!(rec2.ensure_coverage(0.5, 1).is_err());
let rec3 = gs.reconcile(&idx, 1, Some(1));
assert!(rec3.term_rows.is_empty());
}
#[test]
fn gaf_without_ontology_keeps_direct() {
let rows = "\
UniProtKB\tP11111\tFOO\t\tGO:0000001\tPMID:1\tIDA\t\tP\tFoo\t\tprotein\ttaxon:9606\t20200101\tUniProt\t\t\n";
let gaf = tmp(rows, ".gaf");
let raw = read_gaf(gaf.path().to_str().unwrap(), &GafOpts::default()).unwrap();
let gs = raw.into_gene_sets(None);
assert_eq!(gs.n_terms(), 1); assert!(gs.term_genes["GO:0000001"].contains("FOO"));
}
}