Skip to content

feat(crf): CTC-CRF sequence models, and the manifest seam - #216

Merged
jayhesselberth merged 3 commits into
mainfrom
feat/crf-ctc-training
Aug 25, 2026
Merged

feat(crf): CTC-CRF sequence models, and the manifest seam#216
jayhesselberth merged 3 commits into
mainfrom
feat/crf-ctc-training

Conversation

@jayhesselberth

@jayhesselberth jayhesselberth commented Aug 25, 2026

Copy link
Copy Markdown
Member

Brings the CTC-CRF stack into src/leech/crf/, and defines the manifest that
lets 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_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
  • _triton.py — optional CUDA lattice kernels, gated, falling back to the
    PyTorch reference which stays the correctness oracle
  • decode.py — the two-pass Viterbi
  • manifest.py — the corpus contract (below)
  • configs/crf_ctc.toml — the shipped geometry, as package data

The 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:

surface result
encoder forward bit-identical
loss forward bit-identical
loss backward bit-identical
forward-backward posteriors bit-identical
decode_batch strings bit-identical
geometry from each repo's TOML identical

On 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.manifest is one table, 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 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:

  • Label quality is numbers, never a keep boolean, so the gate stays
    sweepable 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 an
    unscored read cannot pass a gate and is dropped without a word — this once cut
    a corpus 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 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_len at any width.

Import weight is a contract

import leech.crf pulls only torch and numpy, and only on demand — config
path eager, everything else lazy (PEP 562, as in leech.models). escapepod-models
installs leech --no-deps into a conda-forge pixi env 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.
polars is imported inside load_manifest for the same reason. Both pinned by
tests — 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.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 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.
  • Renames: load_bonito_state_dictload_crf_state_dict, _bonito_key_map
    _legacy_key_map; the config's [model] package key dropped (nothing read
    it, and it named a trainer we no longer use).
  • Docstrings describe what the modules are, not what they do not depend on.
    Attribution is stated plainly where owed.

Testing

  • 74 CRF tests, all passing on a GPU node; Triton and CUDA cases skip on CPU
  • Full suite: 1382 passed, 44 skipped
  • ruff format --check, ruff check, ty check, zensical build clean
  • Wheel verified to contain leech/crf/configs/crf_ctc.toml

Follow-ups

  • escapepod-models rewiring — prepared and handed off; blocked on a leech release
  • Corpus builder, metrics and the generic half of eval, over the manifest
  • CrfTrainer + leech model train-crf; then the ONNX export
  • A cross-language golden with escapepod-demux::crf — which now also covers
    refchain.rs, whose logZ_full/logZ_target duplicate this loss's forward
    terms with no test between them (docs(crf): the CTC-CRF loss refchain mirrors now lives in leech escapepod-rs#269)

🤖 Generated with Claude Code

@jayhesselberth jayhesselberth changed the title feat(crf): CTC-CRF sequence models — encoder, loss and decode feat(crf): CTC-CRF sequence models, and the manifest seam Aug 25, 2026
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
jayhesselberth merged commit 5ccac0e into main Aug 25, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the feat/crf-ctc-training branch August 25, 2026 17:15
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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant