feat(crf): plan and build a CRF corpus from a manifest - #218
Merged
Conversation
`plan_corpus` decides which reads and in which split, touching no POD5; `build_corpus` extracts their signal into a memory-mappable `<out>_X.npy` beside a `<out>_meta.npz`. This is what lets escapepod-models' extractor become assay-free code that lives here: with the target resolved into the manifest, nothing in the path needs a panel. The two stages are separate on purpose. Everything subtle is in the plan, so it is testable without a gigabyte of fixture — and a corpus planned wrongly does not fail, it trains and reports a number. Four rules, each pinned: - **A cap only caps if every class can reach it.** `per_group="auto"` is the rarest class's *trainable* depth, with the test fraction reserved first. A larger explicit cap warns: one class then contributes everything it has while the others are held back, de-balancing the corpus it was meant to balance. - **The split is carved before capping, ranked per class and globally across batches.** Per-`(batch, class)` ranking multiplies the cap by the number of batches whenever classes are crossed with batch. - **Batches are interleaved, not concatenated.** Reads shuffle within a batch and are drawn round-robin across them. Concatenating ranks the whole first batch ahead of the second, so the first `test_frac` of each class — the test set — comes entirely from whichever batch sorts first, and the headline number measures batch rather than signal. Sorting by `read_id` before shuffling keeps the draw independent of manifest row order. - **Shard after planning.** The plan is deterministic in (manifest, seed), so every shard computes the same global split and keeps its share. Filtering first would rank each shard's reads against only themselves. Extracting nothing and extracting less than half the plan are both hard errors, with different messages, because the causes differ: zero means the POD5 path is wrong, a shortfall means the manifest and the POD5s disagree about which reads exist. Neither is a small corpus, and without the check a 0-row array exits cleanly and reaches a GPU job. `_trim` clamps its block bound on BOTH sides — the source keeps the planned row count, so an unclamped final block refuses to broadcast and loses an already-extracted corpus at the last step. Validated on the production ldx manifest, not just fixtures: 1,139,602 rows plan to **391,174** reads at chunk=3000 — the same count escapepod-models' extractor reports for that input — in 0.3 s, with the training pool balanced exactly across all 16 groups (17,706 each) and held-out reads drawn from both flowcells. That number is the parity evidence; the unit tests pin the rules. polars and `leech.io.pod5_reader` are imported inside the functions that need them, so `leech.crf` still costs only torch and numpy on import and planning code that never extracts pays for neither. Guarded by a test that resolves the lazy attributes and asserts polars, pysam and escapepod are all still absent. 25 corpus tests. Full suite 1408 passed, 44 skipped.
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.
plan_corpusdecides which reads and in which split, touching no POD5;build_corpusextracts their signal into a memory-mappable<out>_X.npybesidea
<out>_meta.npz. This is the piece that lets escapepod-models' extractorbecome assay-free code that lives here — with the target resolved into the
manifest (#216), nothing in the path needs a panel.
Why the two stages are separate
Everything subtle is in the plan, so it is testable without a gigabyte of
fixture. And it needs testing: a corpus planned wrongly does not fail, it trains
and reports a number.
per_group="auto"= rarest class's trainable depth (test fraction reserved first)(batch, class)ranking multiplies the cap by the batch count whenever classes are crossed with batchReads are sorted by
read_idbefore shuffling, so the draw does not depend onmanifest row order.
Validated on production data, not just fixtures
The real ldx manifest — 1,139,602 rows — plans in 0.3 s to:
391,174 is the same count escapepod-models' extractor reports for that
manifest at
chunk=3000(it appears in that script's own comments). That isthe parity evidence; the unit tests pin the individual rules.
Failure modes are hard errors
Extracting nothing and extracting less than half the plan get different
messages, because the causes differ — zero means the POD5 path is wrong, a
shortfall means the manifest and the POD5s disagree about which reads exist.
Neither is a small corpus. Without the check a 0-row array is written, exits
cleanly, and reaches a GPU job, which is the expensive way to find out.
_trimclamps its block bound on both sides: the source keeps the plannedrow count, so an unclamped final block refuses to broadcast and loses an
already-extracted corpus at the very last step. It only fires when reads were
dropped and the remainder straddles a block boundary — pinned by a test.
Import weight
polars and
leech.io.pod5_reader(which pulls pysam and escapepod) are importedinside the functions that need them, so
leech.crfstill costs only torch andnumpy on import, and planning code that never extracts pays for neither. Guarded
by a test that resolves the lazy attributes and asserts all three are absent.
Testing
ruff format --check,ruff check,ty check,zensical buildcleanNext
Trainer (
CrfTrainer+ CLI) over this corpus, then the ONNX export and metrics.The trainer's acceptance test is a paired 3-seed retrain of
barcode_crf_ldx16against the shipped run — the corpus and all its inputs are intact in scratch.
🤖 Generated with Claude Code