solo: metrics_summary.csv under the CellRanger layout (stacked on #178) - #179
solo: metrics_summary.csv under the CellRanger layout (stacked on #178)#179BenjaminDEMAILLE wants to merge 10 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>
…w matrix
STARsolo's raw matrix has a column per whitelist barcode. For 10x v3 that
is 3 686 400 columns and a 62 MB `barcodes.tsv`, nearly all zeros.
CellRanger's `raw_feature_bc_matrix` has a column per *observed* barcode.
The two files therefore share no keys, which is not a rounding difference
in a comparison, it is zero overlap: comparing our raw output against a
real `cellranger count` run gave 0 identical entries out of 27 396 until
the columns were reconciled.
`--soloOutRawBarcodes Observed` narrows the raw matrix to the barcodes
that carry a count. Default `Whitelist` keeps what STARsolo writes, so
nothing changes for anyone not asking.
Measured on the 20 000-read fixture:
Whitelist 3 686 400 barcodes barcodes.tsv 62 668 800 bytes
Observed 200 barcodes barcodes.tsv 3 400 bytes
with identical counts on both sides: 13 937 entries, 15 414 counts.
`finalize_matrix` already took a column remap for the filtered matrix, so
this reuses it rather than adding a second path. The observed set is read
back from the streamed body, which costs one pass and only when the flag
is on.
This is a **non-STAR flag** and needs sign-off; recorded in
`DIVERGENCE.md` §3.2 rather than presented as parity.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
STAR corrects UMIs within each gene *before* deciding which gene owns a UMI, and applies two conditions, not one (`SoloFeature_collapseUMIall.cpp:134-148` and `:203-235`): 1. one gene must hold a strictly higher read count than every other, on the **corrected** UMI map — that is scverse#173, already landed; 2. and that winner must not be beaten in the **uncorrected** map at the same key. The second condition exists because correction moves reads between UMIs: a gene can win only because correction folded a neighbouring UMI onto it, and STAR rejects that win rather than counting it. Reproducing it needs the order STAR uses. The generic path here filters multi-gene UMIs first and corrects afterwards, which cannot express either condition: by the time correction happens the ownership decision is already made. `MultiGeneUMI_CR` therefore takes its own path, which is also what STAR does — the flag is only valid with `--soloUMIdedup 1MM_CR`, so there is no combination this bypasses. `cellranger_1mm_map` exposes the correction mapping that `cellranger_1mm` already computed and threw away. Measured against **CellRanger 10.0.0** on the 20 000-read fixture from scverse#172, with scverse#165 and scverse#173 also applied: identical entries CellRanger rustar scverse#165 + scverse#173 13 651 / 13 709 15 111 15 091 plus this change 13 676 / 13 709 15 111 15 116 Entries CellRanger has and we do not go from 29 to 7, and the count gap from -20 to +5, which is 0.03%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Aligning 10x data and comparing the count matrix against CellRanger gave
a successful run and different numbers, with nothing in the output
pointing at the five flags that explain the difference. Measured against
CellRanger 10.0.0 on a 20 000-read fixture, those flags are the whole
gap: 8.9% away without them, 0.03% with them.
When the geometry is unambiguously 10x — `CB_UMI_Simple`, a whitelist, a
16-base cell barcode, a 10- or 12-base UMI — the five now default to
their CellRanger values:
--clipAdapterType CellRanger4
--outFilterScoreMin 30
--soloCBmatchWLtype 1MM_multi_Nbase_pseudocounts
--soloUMIfiltering MultiGeneUMI_CR
--soloUMIdedup 1MM_CR
A flag given on the command line always wins, including when the value
asked for is STARsolo's own default: `value_source` distinguishes an
explicit flag from a default, so the divergence is escapable by naming
what you want. Every substitution is logged at INFO with the geometry
that triggered it.
**This changes default output behaviour on 10x runs and diverges from
STARsolo**, which is why it is confined to a geometry nothing else in
common use shares, why it is announced on every run it touches, and why
it is in `DIVERGENCE.md` §1.3 as the largest entry in that file. It needs
sign-off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-derived both sides from one clean state: the five flags move the matrix from 8.96% above CellRanger to 2.17% above it, not to 0.03%. The earlier figure compared a rustar run against a CellRanger run built from a different state of the fixture. STAR 2.7.11b with the same flags is at +0.09%, so the remaining gap is open rather than closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The figure I replaced this with was measured without scverse#165, whose cbMinP posterior threshold is a precondition for it. With scverse#165 the five flags move the matrix from 8.96% above CellRanger to 0.03% above it, which is what the original text said. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes the same numbers where `cellranger count` writes them:
outs/{raw,filtered}_feature_bc_matrix/, gzipped, a -1 GEM-well suffix on
every barcode, one raw column per observed barcode, and no per-feature
subdirectory when a single feature is requested.
The layout implies --soloOutGzip yes, --soloOutRawBarcodes Observed and an
outs/ output directory; each is still overridable on the command line. On
10x geometry it is the default, alongside the five flags from the previous
commit, so it changes where output files are written on those runs.
Counts are untouched: 13 959 entries and 15 439 counts in both layouts on
the 20 000-read fixture, compared entry by entry. Against a real
cellranger count 10.0.0 run on the same fixture the raw barcode sets match
exactly, 200 of 200.
Recorded in DIVERGENCE.md 3.3.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Writes CellRanger 10.0.0's 20 metrics, in its order and value formats (thousands separators, quoted when that adds a comma; percentages to one decimal). STARsolo's Summary.csv is unchanged and still written alongside. Twelve of the 20 match a real cellranger count 10.0.0 run exactly on the 20 000-read fixture. The other eight rest on an interpretation of a denominator CellRanger does not document, and each interpretation is stated in DIVERGENCE.md 3.4 rather than left implicit. Three metrics needed new counters: Q30Stats tallies Phred >= 30 bases over the barcode, the UMI and the cDNA read, populated only when the layout asks for the metrics. The exonic/intronic split needs the gene-body overlap query, so a Gene-only run under this layout now performs it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10x define it as 1 - n_deduped_reads / n_reads over unique (barcode, UMI, gene) combinations among confidently mapped reads. Implementing that literally, counting distinct triples before UMI correction, gives 0.0% on the fixture: no two reads there share an exact triple. So the literal reading is wrong and their numerator is the corrected molecule count, like ours. What differs is the read denominator, which 3.4 now says instead of leaving the disagreement unexplained. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
41848ba to
7ac9000
Compare
Human data: the +0.03% is a yeast artefact, and the real gap is intronsThe 20 000-read yeast fixture is the only dataset these PRs were measured on, and I flagged more than once that a second, differently-shaped one was needed. I ran it: 10x's own Raw matrix, against CellRanger
Our default is 30.5% below CellRanger on real human data. The +0.03% I measured on yeast is correct for that fixture and does not generalise at all — yeast has almost no introns, so exonic-only counting happened to agree. On human, CellRanger has included intronic reads by default since v7.0 and we do not. That is one flag:
|
| metric | CellRanger | rustar |
|---|---|---|
| Q30 Bases in Barcode | 94.2% | 94.2% |
| Q30 Bases in RNA Read | 90.0% | 90.0% |
| Q30 Bases in UMI | 92.8% | 92.8% |
| Reads Mapped Confidently to Intergenic Regions | 3.7% | 3.7% |
| Sequencing Saturation | 42.0% | 42.1% |
| Valid Barcodes | 97.3% | 97.5% |
| Reads Mapped Confidently to Intronic Regions | 31.0% | 30.6% |
| Reads Mapped Antisense to Gene | 7.9% | 7.7% |
Sequencing Saturation was the metric I said I was least sure of. On yeast it read 12.7% against CellRanger's 7.4% and I could not explain it. On human it is 42.1% against 42.0%. The yeast disagreement was a property of a fixture where no two reads share a UMI, not a defect in the formula. DIVERGENCE.md §3.4 needs updating to say that, and I will.
The metrics that remain far apart — Reads Mapped Confidently to Transcriptome 81.3% vs 55.2%, Total Genes Detected 23 893 vs 19 531, Median UMI Counts per Cell 6 353 vs 4 555 — are all downstream of the same intron difference, not separate problems.
What I think this means for the stack
Nothing here invalidates #173–#179: the rules they implement are still the rules, and the yeast numbers in their descriptions are still what that fixture produces. What changes is the headline. "Within 0.03% of CellRanger" is true of one small yeast fixture and false of real human data, and I would rather say so here than have a reviewer find it.
The concrete consequence is that --soloFeatures GeneFull belongs in CELLRANGER_DEFAULTS in #176, alongside the five flags already there. It is a one-line change and I have not made it, because it changes default output again and #176 is already asking for sign-off on that. Your call; the measurement is above.
Stacked on #178 → #176 → #175 → #174 → #173. Merge in that order.
Part of #172. #178 got CellRanger's matrix files right; this adds the file a 10x pipeline reads to judge whether the run worked.
What this does
Under
--soloOutLayout CellRanger, writesmetrics_summary.csvwith CellRanger 10.0.0's 20 metrics, in its order and its value formats: thousands separators on integers, quoted once that puts a comma in the field, and percentages to one decimal.STARsolo's
Summary.csvis untouched and still written alongside. The olderCellRanger.summary.csvis not written under this layout, because its four rows are a subset of the new file.Measured against a real
cellranger count10.0.0 runSame 20 000-read fixture as the rest of the stack, with #165 applied — that is a precondition for every number in this stack, not a footnote, and I got it wrong once already (see the retraction on #175). The header row is byte-identical. Thirteen of the 20 values match exactly:
And the seven that do not:
Without #165 it is 12 of 20, and
Median UMI Counts per Cellis the one that moves.What it costs
Three metrics needed counters that did not exist:
Q30Statstallies Phred ≥ 30 bases over the barcode, the UMI and the cDNA read, on the unclipped read, populated only when the layout asks for the metrics.The exonic/intronic split needs the gene-body overlap query, which a
Gene-only run does not otherwise do. Under this layout it now performs it: one extra interval query per read. Named here because it is a change to a hot path, and it is why--soloFeatures Genealone can now report the funnel at all.No count changes:
matrix.mtxis byte-identical with and without this commit.Verification
test_solo_out_layout_cellranger— extended: the header row is compared against the literal string from a real CellRanger run, then the 20 values, including saturation (8 reads collapsing to 2 molecules = 75.0%) and the three Q30 metricsmetric_values_are_formatted_the_way_cellranger_formats_them— both value formats, including the quoting threshold at 1 000 and division by zeroq30_counts_bases_at_phred_30_and_above— the boundary at'>'(Phred 29) and'?'(Phred 30), and a read with no barcodeGate: 572 lib + 27 integration tests,
cargo clippy --all-targets -- -D warnings,cargo fmt --check,cargo +1.89 check --all-targets, all green.Not in this PR
raw_feature_bc_matrix.h5andfiltered_feature_bc_matrix.h5need an HDF5 dependency; that is #177, raised before writing any of it.molecule_info.h5,web_summary.html,cloupe.cloupeandanalysis/I am not proposing: the last two are an undocumented proprietary format and a clustering pipeline that does not belong in an aligner.