feat(crf): CTC-CRF sequence models, and the manifest seam - #216
Merged
Conversation
A second task alongside the chunk classifiers. Where a classifier maps a signal window to a label, a CRF maps one to a *sequence*: over `n_base ** state_len` states whose Viterbi traceback emits one base per move. `CrfEncoder` (3 convs -> 5 alternating LSTMs -> linear -> tanh/scale -> per-state blank splice), `CtcCrfLoss` with an analytic forward-backward that replaces a 300-step autograd replay with one elementwise multiply, optional Triton lattice kernels, the two-pass Viterbi `decode_batch`, and the packaged architecture config. The formulation is Oxford Nanopore's, introduced in bonito; the seven architecture hyperparameters are SeqTagger's published values (Genome Res 35:956), cited in the config beside each. Ported from `escapepod_models.crf` (rnabioco/escapepod-models#40), which now imports it from here: it had grown two consumers and no dependency on that repo's model vocabulary, and it is signal ML, which is what leech is for. The move is pinned, not assumed. The equivalence checks that establish this implementation's correctness run the reference implementation as the oracle, so they stay in escapepod-models where it is available. What this commit can establish is the narrower and, for a file move, the sharper claim: that nothing changed in the moving. Both copies were run in one process on identical seeded inputs, and the encoder forward, the loss forward AND backward (a wrong gradient does not crash, it just trains slightly worse), the analytic forward-backward's posteriors, and the decoded strings are bit-identical — on CPU and on GPU with the Triton kernels engaged. The check reads the pre-move copy out of escapepod-models@main, so it stays reproducible once that repo deletes it. `import leech.crf` pulls only torch and numpy, and only on demand: the config path is eager, everything else lazy (PEP 562, as in `leech.models`). Both halves matter. escapepod-models installs leech `--no-deps` into a conda-forge pixi environment precisely so the solver never reconciles leech's POD5/BAM stack against conda's pytorch; and `ldxlib.CRF_CONFIG` is a module constant imported by two dozen scripts that want only edit distances and panel lookups, so an eager import would make all of them pay for torch — undoing the care `load_crf` already takes to import it in the function body. Three changes from a straight copy: - `test_crf_encoder.py` split in two. Its bit-exactness cases compare against a built model's `reference_io.npz` and skipped unconditionally here, since leech ships no weights. leech gets a seeded-weights fixture that runs in CI (82 KB; weights are filled from a generator rather than stored, so the fixture pins parameter names and shapes too, and a renamed layer fails at the fill). It carries a tolerance because a committed fixture crosses machines and torch releases and CPU LSTM kernels promise no bit-reproducibility across either — still five orders of magnitude tighter than any architecture error, which moves scores by whole units on a [-5, 5] scale. The real-weights claim stays in escapepod-models, the only place that can make it. - `pytest.importorskip("torch")` dropped from the moved tests. torch is a hard leech dependency; here that was a skip that could silently hide the whole CRF suite. `importorskip` is for `leech_core`. - `load_bonito_state_dict` -> `load_crf_state_dict`, `_bonito_key_map` -> `_legacy_key_map`, and the config's `[model] package` key dropped — nothing read it, and it named the trainer this no longer runs under. The loader still accepts both checkpoint namings, and still says whose flat positional layout the legacy one is, because that is a real format it has to match. Not included: the trainer, the ONNX export, and the corpus paths. Tests: 59 CRF tests, all passing on a GPU node (Triton and CUDA cases skip on CPU). Full suite 1353 passed, 44 skipped.
`leech.crf.manifest` defines what a CRF corpus builder is handed: one row per read, `read_id, pod5, anchor_end, target`, plus optional `label`, `group`, `batch`, `quality_score`, `quality_margin`, `split`. Nothing about where those facts came from. This is the interface that lets the rest of the CRF chain leave escapepod-models. Its extractor already declared itself vocabulary-free — "this script knows about signal windows and nothing about where a family keeps its labels" — and was, except for one thread: it took a `--panel` argument to do exactly two things, canonicalise a class name and map it to `oligo[-target:]`. Both are pure vocabulary. Resolving the target into the manifest cuts that thread, and the extractor becomes assay-free code that belongs here. `target` is therefore the RESOLVED sequence, not a class name. A class name may ride along in `label` for reporting; nothing in leech looks one up. Two rules encoded here rather than left to callers, because both fail silently: - **Label quality is numbers, never a `keep` boolean.** The gate is applied at training time so it stays sweepable: gating one panel's labels moved accuracy from 0.875 to 0.97, and a boolean decided at extraction time would mean re-cutting an 8 GB corpus to try a threshold. `quality_coverage()` exists because an unscored read cannot pass a gate and is dropped without a word — a partially scored table once cut a corpus from 56% to a non-random 13.5%. - **`anchor_end` and `target` are coupled.** `check_geometry` raises rather than warns: a short window trains, converges, and quietly discriminates on fewer bases than the design intended, which is how a 27-nt barcode came to be classified on 23 of them. Its message names what the target WOULD emit, because an error that only says "too short" invites widening the window — the one fix that recovers nothing, since emission is `target - state_len` at any width. `samples_per_base` is a parameter, not a constant, so it can be measured from the reads' own dwells. `emitted_target()` is that rule as a function, so the `target[state_len:]` convention has one definition rather than being rediscovered per caller. polars is imported inside `load_manifest`, not at module scope, so `import leech.crf` still costs only torch and numpy — and resolving the lazy `load_manifest` attribute, which does import this module, still does not pull polars. Both are pinned by tests; the second is the one a top-level import would break while the first kept passing. 28 tests, one per failure mode. Full suite 1382 passed, 44 skipped.
The ported docstrings opened by listing the dependencies the code does without, and `encoder.py` led with a licence argument rather than a description. That framing made sense in the repo where removing a dependency was the project. It is not what this package is, and it is not what someone opening these files needs to read first. Each module now opens with what it does. Attribution stays and is stated plainly where it is owed: the CTC-CRF formulation is Oxford Nanopore's, introduced in bonito; the seven architecture hyperparameters are SeqTagger's published values (Genome Res 35:956); the analytic backward makes the same trade koi's does. Technical references to the reference implementation stay too — whose checkpoint key layout the loader accepts, which quantity the two-pass decode reproduces — because those describe real prior art a reader has to match against. Also kept, because it is a rule rather than a slogan: the equivalence checks that establish this code's correctness live in escapepod-models, where the reference implementation is available to run as the oracle. Dropping that comparison would leave them checking nothing. No code change. 74 CRF tests pass; docs build clean.
jayhesselberth
force-pushed
the
feat/crf-ctc-training
branch
from
August 25, 2026 16:22
3f546ee to
2dcbe41
Compare
jayhesselberth
added a commit
that referenced
this pull request
Aug 25, 2026
Minor rather than patch: one new capability, no change to anything that existed. `leech.crf` is additive and nothing outside it was touched, so 0.7.0 behaviour is unchanged — the full suite is 1382 passed against both. The release is #216: the CTC-CRF stack (encoder, training objective with an analytic forward-backward, optional Triton lattice kernels, two-pass Viterbi decode) and the manifest that describes a CRF corpus. Ported from escapepod_models.crf and pinned bit-identical to it across the encoder forward, the loss forward and backward, the forward-backward posteriors and the decoded strings, on CPU and on GPU. Also in this commit, not from the PR: - README said 20 model architectures; the registry has 29. The count had not moved as families were added, and the CRF task was not mentioned at all.
jayhesselberth
added a commit
to rnabioco/escapepod-rs
that referenced
this pull request
Aug 25, 2026
`crf/refchain.rs` computes `logZ_target(ref) - logZ_full` and documented that by pointing at `escapepod_models.crf.loss.CtcCrfLoss`. That module moved to `leech.crf` (rnabioco/leech#216), so the path no longer resolves. Renames it, and makes explicit what the old note left implicit: **both terms now exist twice, in two languages.** | | this crate | leech | |---|---|---| | `logZ_full`, `logZ_target` | yes | yes | | backward pass (`d logZ / d score`) | no | yes — this is what makes it a *loss* | | AVX2 / AVX-512 / CUDA | yes | Triton only | | purpose | per-read scoring | training | Nothing checks the two forwards against each other today. That is worth knowing before anyone assumes they agree — every divergence in this stack so far has been invisible to the check written for the previous one. Doc-only, no behaviour change. `cargo doc -p escapepod-demux` is clean on this file apart from the pre-existing unresolved `super::barcode::BarcodeMatch` link at line 185, which this PR does not touch. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.
Brings the CTC-CRF stack into
src/leech/crf/, and defines the manifest thatlets the rest of the chain follow it out of escapepod-models. The trainer, the
ONNX export and the corpus builder are not in this PR.
What lands
A second task alongside the chunk classifiers. Where a classifier maps a signal
window to a label, a CRF maps one to a sequence: over
n_base ** state_lenstates whose Viterbi traceback emits one base per move.
CrfEncoder— 3 convs → 5 alternating LSTMs → linear → tanh/scale → per-stateblank splice
CtcCrfLoss— with an analytic forward-backward that replaces a 300-stepautograd replay with one elementwise multiply
_triton.py— optional CUDA lattice kernels, gated, falling back to thePyTorch reference which stays the correctness oracle
decode.py— the two-pass Viterbimanifest.py— the corpus contract (below)configs/crf_ctc.toml— the shipped geometry, as package dataThe CTC-CRF formulation is Oxford Nanopore's, introduced in bonito; the seven
architecture hyperparameters are SeqTagger's published values (Genome Res
35:956), cited in the config next to each. It moved here because it had grown
two consumers and no dependency on escapepod-models' model vocabulary, and it is
signal ML — which is what leech is for.
The move is pinned, not assumed
Both copies were run in one process on identical seeded inputs:
decode_batchstringsOn CPU (PyTorch reference paths) and on GPU (Triton kernels engaged). The
gradient is checked explicitly because a wrong one does not crash — it just
trains slightly worse. The script reads the pre-move copy out of
escapepod-models@main, so it stays reproducible once that repo deletes it.The manifest seam
leech.crf.manifestis one table, one row per read —read_id, pod5, anchor_end, target, plus optionallabel,group,batch,quality_score,quality_margin,split. Nothing about where those facts came from.This is the interface that lets the corpus builder, the metrics and the generic
half of eval leave escapepod-models. Its extractor already declared itself
vocabulary-free — "this script knows about signal windows and nothing about
where a family keeps its labels" — and was, except for one thread:
--panel,used to do exactly two things, canonicalise a class name and map it to
oligo[-target:]. Resolving the target into the manifest cuts that thread.Two rules encoded here rather than left to callers, because both fail silently:
keepboolean, so the gate stayssweepable at training time. Gating one panel's labels moved accuracy from
0.875 to 0.97; a boolean decided at extraction would mean re-cutting an 8 GB
corpus per threshold.
quality_coverage()reports partial scoring, because anunscored read cannot pass a gate and is dropped without a word — this once cut
a corpus to a non-random 13.5%.
anchor_endandtargetare coupled.check_geometryraises rather thanwarns: a short window trains, converges, and quietly discriminates on fewer
bases than designed. Its message names what the target would emit, because
an error that only says "too short" invites widening the window — the one fix
that recovers nothing, since emission is
target - state_lenat any width.Import weight is a contract
import leech.crfpulls only torch and numpy, and only on demand — configpath eager, everything else lazy (PEP 562, as in
leech.models). escapepod-modelsinstalls leech
--no-depsinto a conda-forge pixi env so the solver neverreconciles leech's POD5/BAM stack against conda's pytorch, and
ldxlib.CRF_CONFIGis a module constant imported by two dozen scripts that want only edit distances.
polars is imported inside
load_manifestfor the same reason. Both pinned bytests — including the sharper one that resolves the lazy attribute, which a
top-level import would break while the blanket check kept passing.
Changes from a straight copy
test_crf_encoder.pysplit in two. Its bit-exactness cases compare againsta built model's
reference_io.npzand skipped unconditionally here, since leechships no weights. leech gets a seeded-weights fixture that runs in CI (82 KB;
weights are filled from a generator rather than stored, so it pins parameter
names and shapes too). It carries a tolerance — a committed fixture crosses
machines and torch releases, and CPU LSTM kernels promise no bit-reproducibility
across either; still five orders of magnitude tighter than any architecture
error. The real-weights claim stays in escapepod-models.
pytest.importorskip("torch")dropped. torch is a hard leech dependency;here it was a skip that could silently hide the whole CRF suite.
load_bonito_state_dict→load_crf_state_dict,_bonito_key_map→
_legacy_key_map; the config's[model] packagekey dropped (nothing readit, and it named a trainer we no longer use).
Attribution is stated plainly where owed.
Testing
ruff format --check,ruff check,ty check,zensical buildcleanleech/crf/configs/crf_ctc.tomlFollow-ups
CrfTrainer+leech model train-crf; then the ONNX exportescapepod-demux::crf— which now also coversrefchain.rs, whoselogZ_full/logZ_targetduplicate this loss's forwardterms with no test between them (docs(crf): the CTC-CRF loss refchain mirrors now lives in leech escapepod-rs#269)
🤖 Generated with Claude Code