Single-cell I/O: compress intermediates, add tagged and deduplicated BAM outputs - #421
Open
andrewprzh wants to merge 18 commits into
Open
Single-cell I/O: compress intermediates, add tagged and deduplicated BAM outputs#421andrewprzh wants to merge 18 commits into
andrewprzh wants to merge 18 commits into
Conversation
…ugh the shared helper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Four independent single-cell I/O changes: two reclaim disk on existing outputs, two add BAM
outputs carrying information the pipeline already computes but only emitted as text.
1. The split-reads FASTA is gzipped
<prefix>.split_reads_<i>.fa.gzunless--no_gzip. Compression happens inside the chunkworkers, and the merge stays a plain byte copy because concatenated gzip members form a valid
stream. Nothing downstream slows down: the only consumer is minimap2, which decompresses
natively.
2. The barcoded read tables are gzipped at the end of the run
They stay plain for the whole run, since
split_read_barcode_tableand the tagged-BAM buildread them, and are compressed once every sample is done. The per-worker split tables under
aux/are deliberately left alone: short-lived temporaries whose compression would cost CPU inevery chromosome worker.
3.
--large_output tagged_bam(off by default)<prefix>.tagged.bam— a copy of the input alignments with--barcode_tag/--umi_tag(CB/UB). Every alignment is kept: primary, secondary, supplementary and unmapped, across all
references including unplaced scaffolds. Purely a side output; nothing downstream reads it.
Skipped with a warning when
--barcoded_bamwas the input (those alignments already carry thetags) or in modes with no barcodes.
4.
--large_output deduplicated_bam(off by default)<prefix>.deduplicated.bam— primary alignments of the reads that survived UMI filtering, oneper detected molecule, tagged with barcode, UMI,
GX(gene) andTX(transcript). All fourvalues come from the
ReadAssignmentalready in hand where survivors are chosen, so no extrapass over the assignments is needed.
Deliberately not wired into fusion detection: UMI filtering keeps one read per assigned
(gene, barcode, UMI) molecule and requires a gene assignment, so the chimeric reads fusion
calling depends on are collapsed or dropped. Deduplication for fusion belongs inside the fusion
algorithm.
Also here
--split_moleculeswith an already aligned input (--bam) now aborts instead of producing aFASTA that is never re-aligned. Splitting rewrites the reads so the pieces must be mapped
again, while a BAM says not to map — the requests contradict, so IsoQuant asks the user to
choose rather than guessing. This applies to
autoas well astrue. Four CI configs thatpair
--bamwith a splitting-capable mode now pass--split_molecules false; all four hit thecrash before this.
--read_group file_nameon a gzipped input changes label:reads.fq.gzgrouped asreads.fqbefore (one
splitextoff a two-part extension) and groups asreadsnow. The split-readsFASTA needed this to keep a sane group name once it gained
.gz, andFileNameGrouperandStringPoolManagerwere changed together so they still agree. No CI baseline moves —STEREO.TOYis the only config pairingfile_namewith a gzipped input and it asserts on fileexistence, not contents.
Review pass
Follow-ups on top of the original branch (
163aafde..9dea1a9e):The merge could not have worked on a many-contig reference. One fragment per non-empty
reference plus a single
samtools mergecall means every fragment is opened at once, andsamtools gives up just above a thousand handles no matter what
ulimit -nsays — measured:1200 fragments fail at fragment 1019 with
RLIMIT_NOFILEat 1048576. GRCh38's full analysisset has 3366 contigs, so
tagged_bamon it would have crashed at the very end of the run.Merging now goes in batches of 500 (
_merge_in_rounds), verified on the same 1200 fragments:0.6 s, all records present, no intermediates left behind.
The unmapped-read pass read every BAM twice, end to end, in the parent. Unplaced reads
sit at the tail of a coordinate-sorted file and
fetch(until_eof=True)walked the whole fileto reach them, once to collect ids and once to copy.
unplaced_reads()now usesfetch("*")on an indexed BAM, with the linear scan kept as the fallback; a test pins that both paths
return the same records.
Compression level, and it now covers the whole project. Both new writers used Python's
gzipdefault of 9. Measured on real ONT outputs, the two kinds of data want differentlevels, so
gzip_level_for(name)picks one from the file's extension:tables at 6 (a real barcode table runs 16.0 MB/s at level 9 against 39.2 MB/s at 6 for
3.0% less output; going below 6 was measured and rejected — level 1 is 2.4x faster again but
8.5% larger, levels 4-5 save almost nothing) and FASTA/FASTQ at 4 (nucleotide data sits
near gzip's entropy floor, so level 6 grinds at 12.5 MB/s against 65.3 MB/s at 4 for 5.7%
more output).
Every gzip writer in the project now goes through
open_text_write/gzip_file_in_placerather than calling
gzip.openitself, so both levels apply everywhere: the allinfo writers,the four in
convert_grouped_counts,TextFileAssignmentPrinter(read_info /read_assignments / corrected_bed / read2transcripts) and the two
scripts/converters.One defect fell out of checking the output size rather than the code: the split FASTA is
compressed in the chunk workers and those temps were named
subreads.gzwith no.fa, sothey took the table level while the single-threaded path, which passes the real output
name, took the sequence level — the level differed by thread count.
numbered_chunk_namenow keeps the whole extension chain last (
subreads.fa.gz→subreads_3.fa.gz). Verifiedend to end: 5.28 MB → 5.58 MB, exactly the level-4 number, with identical read-id sets.
The tagged BAM was rebuilt on every
--resume. It is a full copy of the input and it iswritten before read collection, so a crash in the long stages meant re-copying the whole BAM
on each resume attempt. It now has a resume marker in
aux/(guarded by an existence checkon the BAM), matching how the other expensive stages behave.
The reference list is derived once and passed into
write_tagged_bamrather thanrecomputed. It has to be the same list that drove the barcode-table split or a fragment finds
no table and its reads come out untagged; the invariant is now explicit in the signature.
The split dict also builds its paths with
sample.get_barcodes_split_file(), the same helperthe workers read them back with — the two disagreed for a reference name containing
/.Barcode calling output order depended on which chunk finished first.
run_chunks_in_parallelwaits onFIRST_COMPLETEDand the merging caller appended chunktemp names in that order, so the row order of
barcoded_reads.tsvand the record order ofthe split FASTA varied run to run — two runs of identical code on identical input differed
at line 200003, a chunk boundary. Row sets were always identical and every consumer keys by
read id, so nothing was wrong, but the outputs were not reproducible.
handle_resultnowreceives
(result, chunk_index); the merging caller keys a dict on the index and sorts atmerge time, and the counting caller ignores it (its selector already sorts by
(count, barcode), a total order). Scheduling is untouched — waiting for chunks in orderwould idle the pool behind one slow chunk. Two runs now give a byte-identical barcode table
and a byte-identical decompressed FASTA; the compressed FASTA differs in exactly 10 bytes,
all gzip header
mtimefields, so compare these outputs decompressed.Also fixed:
docs/barcode_calling.mddocuments the standaloneisoquant_detect_barcodes.py,but named the pipeline's
*.split_reads_<i>.fa.gz; the standalone tool writes<prefix>.split_reads.fasta.gz. Its--no_gzipwas undocumented.Verification
Unit: 1524 tests pass (console tests need minimap2 on PATH), including
test_bam_utils.py(35)and
test_file_compression.py(18).End-to-end on the full
SC.Mouse.10x.allinfoCI dataset (2M reads, 3660591 records), which iswhere three
tagged_bamdefects surfaced that a single-chromosome subset could not:tagged.bam— 3660591 records, equal to the input; 1655717 secondary, 4874 supplementary and3577 unmapped all preserved; CB/UB match the barcode table on all 1794571 barcoded reads
with zero differences.
deduplicated.bam— 903570 records, all primary, exactly the read set ofUMI_filtered.ED3.allinfo; CB/UB/GX matchallinfoon every row; records byte-identical tothe input apart from the tags, SA tags preserved.
SC.Mouse.10x.barcoded_bam.allinfo(898640 records), which exercises thededup BAM on the tags-read-from-BAM path.
allinfobaselines pass on every run.Re-verified after the review pass on chr19 + all 15 unplaced scaffolds + the unmapped tail
(120595 records): tagged BAM 120595 records with secondary/supplementary/unmapped counts
matching the input exactly, CB/UB correct on all 53760 barcoded alignments (0 missing, 0
mismatched, 0 spurious) including 3193 tagged unmapped reads; deduplicated BAM 25056 records,
all primary, read set identical to
allinfowith 0 GX and 0 CB mismatches. Resume skips thetagged BAM with its mtime unmoved. The standalone splitter still emits a valid multi-member
out.split_reads.fasta.gz.Note that CI produces these BAMs but does not assert on them — the baselines only cover
allinfo, so a crash or knock-on regression is caught but a wrong tag value would not be.🤖 Generated with Claude Code