fix(solo): MultiGeneUMI_CR was inert — a tied UMI goes to nobody, not everybody - #173
fix(solo): MultiGeneUMI_CR was inert — a tied UMI goes to nobody, not everybody#173BenjaminDEMAILLE wants to merge 2 commits into
Conversation
`--soloUMIfiltering MultiGeneUMI_CR` kept every gene tied at the highest
read count. CellRanger's rule is the opposite on exactly that case: the
gene with the *strictly* highest count takes the UMI, and a tie means no
gene counts it.
STAR walks the genes keeping a running maximum and clears its winner
whenever it meets an equal count
(`SoloFeature_collapseUMIall.cpp:212-224`):
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
One read per gene is the ordinary shape of a multi-gene UMI, and it is
always a tie, so the old rule made the flag inert in practice rather
than merely inaccurate. Measured on a 20 000-read 10x fixture (200 cells
from the real v3 whitelist, 400 genes, 720 UMIs deliberately shared
between two genes), against STAR 2.7.11b with the same flags:
identical entries STAR counts rustar counts
before 13 749 / 14 806 15 423 16 465
after 13 902 / 13 967 15 423 15 414
The flag removed nothing at all before; STAR removes 1 030 counts. The
gap goes from +1 042 to -9.
The outcome does not depend on the order the genes are visited — a
strict maximum always ends as the winner, a tie always ends with none —
so iterating a `HashMap` here stays deterministic.
`multi_gene_umi_cr_drops_a_tie_entirely` pins the case the old tests
missed: they only covered 3 reads against 1, where both rules agree.
Not yet implemented, and stated so rather than left to be discovered:
STAR applies a second condition, that the winning gene must also hold
the top count among *uncorrected* UMIs (`umiGeneMapCount0`, same file,
lines 226-232). That needs the pre-correction counts, which this code
does not keep. The 65 entries still differing out of 13 967 are the
place to look for its effect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The two inert flags in #172 turn out to be fixed by two separate PRs, and together they close almost the whole gap. AttributionSame 20 000-read 10x fixture, STAR 2.7.11b against each build, raw
The pseudocounts were already wired on #165 also fixes the baseline: at default flags it takes rustar from 21 entries STAR does not have down to zero, 14 773 / 14 787 identical.
Both together
Full CellRanger-matching flag set, so this is the configuration the flags exist for. What is left37 entries of 13 708: 28 STAR-only, 2 rustar-only, 7 present in both and differing. One known cause, already stated on #173: STAR requires the winning gene of a multi-gene UMI to hold the top count among uncorrected UMIs as well ( The 28 STAR-only entries are the shape that condition would produce — STAR counting a UMI that we drop — so I would look there first. Note on merge orderNeither PR depends on the other, but they touch |
--soloUMIfiltering MultiGeneUMI_CRremoved nothing at all. It kept every gene tied at the highest read count; CellRanger's rule gives a tied UMI to no gene.Fixes the first of the two inert flags in #172.
The rule
STAR walks the genes for a UMI keeping a running maximum, and clears its winner whenever it meets an equal count (
SoloFeature_collapseUMIall.cpp:212-224):Strict maximum takes the UMI; a tie takes it from everyone. The code here kept
rc >= max, which on a tie keeps all the tied genes — the opposite decision on precisely the case the rule exists to settle.That is why the flag was inert rather than merely inaccurate: one read per gene is the ordinary shape of a multi-gene UMI, and it is always a tie.
Measured
20 000-read 10x fixture: 200 cells drawn from the real
3M-february-2018whitelist, 400 yeast genes, 720 UMIs deliberately shared between two genes in the same cell. STAR 2.7.11b against this branch, same flags, rawGenematrix compared entry by entry.The gap goes from +1 042 counts to −9. STAR removes 1 030 counts with this flag; before this change rustar removed zero.
With the full CellRanger-matching flag set, the total gap drops from +1 341 (+8.9%) to +290 (+1.9%). The remainder is
--soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts, the second inert flag in #172, which is a separate change.Determinism
The rule is order-independent: a strict maximum always ends as the winner whatever order the genes are visited in, and a tie at the maximum always ends with none. Iterating a
HashMaphere therefore stays deterministic, and the comment says so at the site.What is still missing, said plainly
STAR applies a second condition I have not implemented: the winning gene must also hold the top count among uncorrected UMIs (
umiGeneMapCount0, same file, lines 226-232). That needs the counts from before UMI correction, which this code does not retain, so it is a larger change. The 65 entries still differing out of 13 967 are where to look for its effect — 30 STAR-only, 23 rustar-only, 12 differing.I would rather land the 1 030-count fix now and treat the second condition as its own change than bundle a restructure into it.
Verification
multi_gene_umi_cr_drops_a_tie_entirely— a two-gene tie, a tie at the maximum with a third gene below it, and a strict maximum that still wins. The existingmulti_gene_umi_cr_keeps_top_geneonly covered 3 reads against 1, where both rules agree, which is why the defect survived.Gate: 561 lib + 26 integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check, all green.The fixture generator and matrix comparison are not in this PR; they belong with the replacement of the three-entry differential (item 4 of #172), so that the numbers above become a test rather than a claim.