How the STARsolo pipeline is parameterised, and why #199
lhqing
started this conversation in
Show and tell
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Working notes from the session that produced #198. The immediate trigger was narrow — "our CRAM has no barcode" — but answering it forced us to justify every STARsolo flag we pass, and to reject two we were tempted by. Recording the reasoning here because the why behind a parameter is the part that evaporates, and next time someone will be staring at the same shell block wondering whether they can change it.
Everything below was measured on one real lane of GSE208154 / SAMN29720279 (
L001, 12,993,522 reads, ce11 + WS298, in the pinnedalign-rnaimage), not estimated. Where a number is extrapolated to a full sample it is marked as such; the extrapolation was validated at 0.3% error against the real 4.51 GB deliverable.1. The frame: three owners, and which one owns a flag
This is the single most useful thing to internalise before touching
starsolo.smk. Every parameter we pass STAR belongs to exactly one of three owners, and the answer determines where you write it:kb/specs/<tech>/spec.yaml)backend.params→config["solo"]processing.yaml→configstarsolo.smk)compose/params.pyenforces that the emitted key set is exactlyunion(KB keys, processing keys)— disjoint, no orphans. Andrequired_configis computed from module source (workflows/__init__.py), not typed beside it. Those two facts combine into a rule with teeth:So the question "should this be configurable?" has a real price attached, and the default answer for a quality/format knob is no — hardcode it. But "costs nothing" is about effort, not correctness: if the value genuinely varies by chemistry, a literal (or a branch) is not cheap, it is simply wrong, and wrong in a way that stays invisible until a new chemistry arrives. §2b is that case, caught just in time. The precedent was already in the tree:
--outSAMtypeused to be a KB param instar.smkand a literal instarsolo.smk. Two modules, two answers, one detail. It is now a literal in both.2. The parameters, and why each is what it is
Parsing — KB-owned, byte-decided
--soloTypeCB_UMI_Simple(10x) vsCB_UMI_Complex(SPLiT-seq, BD Rhapsody) is a fact about the molecule.--soloCBstart/CBlen/UMIstart/UMIlen--soloCBposition/UMIposition--soloCBwhitelistrule onlistas atemp()— 10x v3 is 6,794,880 barcodes / 111 MB, and compiling one dataset three ways used to write it three times, forever.--soloStrandkb e2ecount-matrix run catches an inverted strand.--soloBarcodeReadLengthSOLO.get(...), deliberately — a subscript would make it required for SPLiT-seq too, whose spec must not declare it.--soloCBmatchWLtype2b. The flag that changed owners, and the test that caught it
--soloCBmatchWLtypelived in the module as asoloTypebranch:1MM_multi_Nbase_pseudocountsfor Simple,1MMfor Complex. That looked fine and it was wrong, for a reason worth stealing:1MM_multi_Nbase_pseudocounts1MMEditDist_2Parse Evercode and BD Rhapsody are both
CB_UMI_Complexand need different values. A yes/no branch yields two answers; we need three. So the branch was not merely inelegant — it was incapable, and would have failed silently the day someone added Parse (they'd hit the wall, not understand it, and settle for1MM).It is now a
backend.paramskey in all 11 starsolo specs. The price is real and worth naming: a newSOLO["..."]subscript becomes a required config key (required_configis computed from module source), so every spec must declare it or compose refuses.Legality is itself chemistry-dependent, measured by running STAR rather than reading
--help:Exact,1MM1MM_multi,1MM_multi_pseudocounts,1MM_multi_Nbase_pseudocountsEditDist_2An illegal pair is a hard STAR FATAL on a compute node, after the genome loads. That is now a params-gate assertion instead — compose refuses, which is that gate's whole purpose. Note STAR's global default
1MM_multiis illegal for Complex, so the old1MMpin was load-bearing rather than stylistic.Counting — processing-owned, instructable
--soloFeaturesGene GeneFull GeneFull_ExonOverIntron GeneFull_Ex50pAS VelocytoOutput format and quality — module-owned literals
--outSAMtypeBAM SortedByCoordinateseqforge io cramno longer needssamtools sort, which deletes a whole re-sort pass and a temp-file leak (§4).--limitBAMsortRAMconfig["mem_mb"]0means "reuse the genome allocation", which is too small on a tiny fixture and FATALs. Not optional once you sort.--outSAMattributesNH HI AS nM CB UB--clipAdapterTypeCellRanger4--outFilterScoreMin30--soloUMIfilteringMultiGeneUMI_CR--soloUMIdedup1MM_CR1MM_All).--soloCellFilterEmptyDrops_CR--soloMultiMappersUnique(default, but now a decision)--readFilesInThe five CellRanger-flavoured flags come from scRecounter (Arc Institute, same problem at 10⁵ scale) and are the documented "CellRanger ≥4 equivalent" set from Kaminow, Yunusov & Dobin 2021. We were emitting STARsolo defaults, which are not CellRanger-comparable — a real problem for a corpus meant to be compared against published matrices.
3. The CRAM: what we were shipping, and what we now ship
We passed no
--outSAMattributesat all, so STAR used itsStandarddefaultNH HI AS nM. STARsolo puts only the cDNA mate in the BAM, and the barcode lives solely in R1 — so the barcode was irrecoverably absent from 920 GiB of retained CRAMs. They could not be recounted under a new GTF, could not give transcript-level counts, could not feed alevin-fry. They were browsable and nothing else.Tag costs, measured (added naively, nothing else changed):
+ CB UB+ CR UR+ CY UY+ GX GNThe surprise was on the other side of the ledger:
level=9,lzma,bzip2)--output-fmt-option lossy_names=138-char Illumina read names (
K00125:217:HCL2YBBXY:8:2111:24637:43374) were 16% of the file, and they are meaningless onceCB/UBexist — the name was only ever the join key back to R1. So:That is the whole lesson of §3: the naive framing was "how much do we pay for the barcode", and the answer was "nothing, you pay for what the barcode makes obsolete".
The road not taken, recorded because it is cheap to revisit
R1 in 10x v3 is exactly 28 nt = 16 (CB) + 12 (UMI). Nothing in it is discarded. So
CR+UR+CY+UY, plus--outSAMunmapped Within, makes the CRAM a byte-exact replacement for both FASTQs. Verified rather than assumed — reconstructed R1 and R2 (sequence and quality, un-reverse-complementing reverse-strand records) for 200,000 reads and diffed against the source:7.00 GiB vs 15.39 GiB of FASTQ — 45% — and it feeds anything via
samtools fastq. We did not take it, because for accessioned data SRA already holds the FASTQ. For in-house data with no accession the calculus flips, and the measurements are already done.4. The temp-file leak, and why the fix is structural
cram.pybuiltsamtools sortwith no-T, so temp files landed in the CWD assamtools.<pid>.<tid>.tmp.NNNN.bam. A killed sort leaked them; snakemake could not clean them because they were undeclared outputs. 41.4 GiB across 5 pipeline dirs before we noticed.The tempting fix is to pass
-Tinto a snakemake-owned directory. The better fix is--outSAMtype BAM SortedByCoordinate, which removessamtools sortfrom the pipeline entirely — the leak becomes impossible rather than configured correctly.5.
--soloCellFilter: decided on reproducibility, not biologyThe instinct is to argue about which cell caller is better. The decisive fact turned out to be architectural and is already written in our own code:
h5ad.py:27— "Everything is read fromraw/, neverfiltered/. Cell calling is a downstream decision."qc.py:127-130— thefiltered/tree is consumed only to recorddefault_filtered_barcodesin the QC bundle.So
--soloCellFilterchanges the QC bundle and not a single count in any.h5ad. Blast radius: one QC field. That reframes the question from "which is biologically right" to "which is cheap and reproducible".The real risk was that
EmptyDrops_CRis Monte-Carlo (simN = 10000), and a nondeterministic QC bundle is a problem for a content-addressed compiler. Tested withSTAR --runMode soloCellFiltering(which re-filters an existing raw matrix in ~1 s — very handy):CellRanger2.2(old default)f7439745760d9f4eEmptyDrops_CR, 24 threads23ac8197f0b7522623ac8197f0b7522623ac8197f0b75226--runRNGseed 1234523ac8197f0b75226Bit-identical across repeats, thread counts and seeds.
EmptyDrops_CRis also a strict superset here: 45 barcodes rescued, 0 dropped.Caveat kept deliberately: seed-independence is stronger than expected, and the benign explanation — no candidate sitting near the FDR boundary on this sample — cannot be excluded. So #198 carries an action to encode the check as a test rather than trust the observation. Cheap, because
soloCellFilteringruns on an existing matrix.6.
--soloMultiMappers: the headline number was a lieThis is the most instructive one. scRecounter passes
EM Uniform; we rejected it.Multi-gene UMIs measured at 18.17% (
Gene) / 19.56% (GeneFull) — high enough to look like we were throwing away a fifth of the data. Then the biotype breakdown:Excluding rRNA: 3.13%. The three genes are
rrn-1.1,rrn-1.2,rrn-3.1— the tandem rDNA array atI:15,062,083-15,071,033.rrn-1.1andrrn-1.2have 1 and 0 unique UMIs and EM hands each ~243,220: the copies are identical, EM has no information, so it splits evenly and emits a large arbitrary number that reads exactly like data. (The same array is why chromosome I holds 87.5M of the CRAM's 198.8M alignment records — a detail that looked like noise at the start of the session and turned out to be the whole story.)Also against: all four multimapper matrices are fractional (
integer=False), which breaks DESeq2-style pseudobulk and NB likelihoods; and it costs 8 extra matrices (one per gene-axis feature per method — not Velocyto), each ~18% denser than the unique matrix, i.e. ~3.4× the matrix payload of every h5ad.And the ecosystem agrees: multi-gene reads are discarded by default in Cell Ranger (MAPQ 255 only), STARsolo, alevin-fry (
cr-like), andbustools count --genecounts. EM exists everywhere and is default nowhere. The legitimate exceptions are studies where multi-mapping regions are the biology — transposable elements (scTE, TEtranscripts), immune loci, recent paralogs.The best part: we already had the diagnostic for free. Every shipped QC bundle carries
features_stats.<feature>.MultiFeatureandsubMultiFeatureMultiGenomicunder plainUnique— on the real sample, 25,037,969 and 24,463,852 (97.7% multi-genomic, the repeat signature). So we can identify a dataset with a genuine multimapping problem without paying for a single extra matrix. Andmatrix.mtxis byte-identical with and without the flag, so the decision is reversible per-dataset.7. The unrelated 280 GiB
Chasing "could we just use a loose UMI cutoff instead of a cell caller" turned up something bigger than the CRAM work. STARsolo's
raw/barcodes.tsvis the entire whitelist — 6,794,880 barcodes — and we write all of them into every h5ad. Only 845,694 (12.4%) have a nonzero count in any feature;obsalone is 108.7 MB of barcode strings indexing nothing.−85%, provably lossless. ~280 GiB across the 350 h5ad files currently on disk.
Two traps found on the way:
X.sum() > 0.XisGene(exonic) butGeneFullcounts introns, so a barcode can be zero inGeneand nonzero inGeneFull. Filtering onXalone silently drops 22,319 barcodes.8. Reusable lessons
--outSAMtype None— keep no alignment at all. That is the honest baseline. A barcode-less CRAM was the one option nobody would defend.samtools1.13 silently truncates CRAM 3.1. samtools ≥1.21 writes 3.1 by default, so anything reading our CRAMs must run insidealign-rna. This bit us mid-session and produced a confusing "Failure to decode slice".soloTypebranch gave two answers where three chemistries needed three — see §2b. If two cases agree on the branch variable but disagree on the answer, the branch is the wrong mechanism.soloCBmatchWLtype×soloTypelegality matrix came out of 12 actual STAR invocations. The help text was inconsistent (an option namedEditDist_2described as "edit distance of 3", plus a typo), and a comment in our own tree had recorded a restriction that was real but narrower than stated.EXITING because ofsurfaced both hard constraints onCB/UBand thesoloCBmatchWLtype/Complex incompatibility, faster and more reliably than the manual.Tracking issue: #198 (labelled
ready-for-agent, three scoped work items).All reactions