Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ Sections commonly used: Features, Bug fixes, Other changes.

### Bug fixes

- `--soloUMIfiltering MultiGeneUMI_CR` kept every gene tied at the
highest read count; CellRanger gives a tied UMI to no gene at all.
Since one read per gene is the ordinary shape of a multi-gene UMI, the
flag removed nothing in practice. On a 20k-read 10x fixture the count
matrix moves from 16 465 to 15 414 against STAR's 15 423.

- **STARsolo `Gene` assignment now requires exon concordance**, matching
STARsolo: a read counts toward a gene only when every aligned block
lies within the gene's exons, rather than merely overlapping one. This
Expand Down
76 changes: 74 additions & 2 deletions src/solo/count.rs
Original file line number Diff line number Diff line change
Expand Up @@ -822,8 +822,43 @@ fn filter_multi_gene_umi(genes: &HashMap<u32, u32>, filtering: UmiFiltering) ->
let thresh = if max == 1 { 2 } else { max };
genes.iter().filter(|&(_, &rc)| rc >= thresh).collect()
}
// CellRanger > 3.0: keep the highest-read-count gene(s); no singleton drop.
UmiFiltering::MultiGeneUmiCr => genes.iter().filter(|&(_, &rc)| rc >= max).collect(),
// CellRanger: the gene with the strictly highest read count takes the
// UMI, and a tie gives it to nobody.
//
// STAR `SoloFeature_collapseUMIall.cpp:212-224` walks the genes keeping
// a running maximum, and clears its winner whenever it meets an equal
// count:
//
// ```cpp
// if (ig.second>maxu) { maxu=ig.second; maxg=ig.first; }
// else if (ig.second==maxu) { maxg=-1; };
// ...
// if ( maxg+1==0 ) continue; // not counted for any gene
// ```
//
// The outcome does not depend on the order the genes are visited: a
// strict maximum always ends as the winner, and any tie at the maximum
// always ends with none. So iterating a `HashMap` here is safe.
//
// This previously kept every gene tied at the maximum, which is the
// opposite decision on exactly the case the rule exists for, and made
// the flag inert on the common shape of one read per gene.
UmiFiltering::MultiGeneUmiCr => {
let mut best_count = 0u32;
let mut winner: Option<&u32> = None;
for (gene, &rc) in genes {
if rc > best_count {
best_count = rc;
winner = Some(gene);
} else if rc == best_count {
winner = None;
}
}
match winner {
Some(gene) => vec![(gene, genes.get(gene).expect("winner is a key"))],
None => Vec::new(),
}
}
UmiFiltering::None => unreachable!(),
}
}
Expand Down Expand Up @@ -2057,6 +2092,43 @@ mod tests {
assert!("bogus".parse::<UmiFiltering>().is_err());
}

/// The case the rule exists for, and the one the old code got backwards:
/// when two genes tie on read count, CellRanger counts the UMI for
/// neither. STAR clears its winner on an equal count
/// (`SoloFeature_collapseUMIall.cpp:212-224`) and skips the UMI when no
/// strict maximum survives.
///
/// One read per gene is the common shape of a multi-gene UMI, so keeping
/// the ties made `--soloUMIfiltering MultiGeneUMI_CR` inert in practice:
/// on a 20 000-read 10x fixture it removed nothing at all, against 1 030
/// counts removed by STAR.
#[test]
fn multi_gene_umi_cr_drops_a_tie_entirely() {
let mut tied = HashMap::default();
tied.insert(0u32, 1u32);
tied.insert(1u32, 1u32);
assert!(filter_multi_gene_umi(&tied, UmiFiltering::MultiGeneUmiCr).is_empty());

// A tie at the maximum loses even when a third gene sits below it.
let mut tied_with_loser = HashMap::default();
tied_with_loser.insert(0u32, 5u32);
tied_with_loser.insert(1u32, 5u32);
tied_with_loser.insert(2u32, 3u32);
assert!(
filter_multi_gene_umi(&tied_with_loser, UmiFiltering::MultiGeneUmiCr).is_empty(),
"a tie at the maximum takes the UMI from everyone, including the third gene"
);

// A strict maximum still wins, whatever else is present.
let mut strict = HashMap::default();
strict.insert(0u32, 5u32);
strict.insert(1u32, 4u32);
strict.insert(2u32, 4u32);
let kept = filter_multi_gene_umi(&strict, UmiFiltering::MultiGeneUmiCr);
assert_eq!(kept.len(), 1);
assert_eq!(*kept[0].0, 0);
}

#[test]
fn multi_gene_umi_cr_keeps_top_gene() {
// UMI maps to gene 0 (3 reads) and gene 1 (1 read). CR keeps only gene 0.
Expand Down
Loading