feat(crf): a CTC-CRF trainer - #219
Merged
Merged
Conversation
`CrfTrainer` runs the schedule over a corpus and writes `model.pt` beside a `model.json` sidecar. The sidecar is not optional: standardisation lives in neither the architecture config nor the checkpoint, so a consumer holding only weights cannot reproduce it and decodes silently worse. Separate from `leech.training.Trainer` rather than a generalisation of it. That one is classification-locked all the way down — `pos_weight`, `num_out`, BCE/focal/CE, AUROC/F1 checkpointing, and a wrapper that forwards three input branches. A sequence task shares none of it, and threading one through would put the production classifier path at risk to save a few hundred lines. The decisions are plain functions, not loop internals, because the loop is mechanical and the decisions are where runs go wrong without failing: - `compute_standardisation` streams the corpus in float64 blocks, so a corpus larger than RAM costs nothing to summarise and a float32 sum over billions of samples does not lose its tail. - `apply_quality_gate` runs at TRAINING time, which is what keeps the threshold sweepable — gating one panel's labels moved accuracy from 0.875 to 0.97, and baking a decision into the corpus costs a re-extraction per threshold. It refuses a partially scored corpus rather than gating it: an unscored read cannot pass, so it is dropped silently, and one corpus went from 56% usable to a non-random 13.5% exactly that way. - `resolve_split` prefers a held-out batch (the honest number when classes are crossed with batch), then the corpus's own split (carved per class before capping, so every arm holds out the same reads by construction), then a seed. - `encode_targets` builds the int array once for the corpus. Per step it is 12,288 dict lookups on the critical path — 1.34 ms of a 39 ms step against 0.06 ms to index a prebuilt array. - `select_checkpoint` falls back to the best epoch when the last is worse by more than `select_tol`. A run that passed through 0.0047, diverged, and recovered only to 0.0072 shipped weights it had already beaten by 53%. The tolerance is deliberately loose: training loss does NOT rank models at this scale, so this is a divergence detector and not a ranking. The loss runs in fp32 outside autocast — the lattice scan accumulates over `chunk // stride` timesteps and fp16 loses the tail — while the encoder is autocast, which is where the matmuls are. Batch indices are sorted before the gather so a memmapped corpus is read forwards rather than seeked per row. Per-epoch stats carry the worst single batch, the largest pre-clip gradient norm, discarded GradScaler steps and non-finite gradient counts, because an epoch mean cannot tell one catastrophic batch from a thousand mediocre ones. A skipped step is otherwise invisible: on non-finite gradients `scaler.step()` silently does nothing and `update()` halves the scale, so the scale dropping IS the signal. That instrumentation earned itself on the first real run — epoch 1 of the GPU smoke test reported 3 skipped steps and 3 non-finite gradients at |g|max 50.45, settling to 0 and 2.22 by epoch 2 (normal scaler warm-up, and undetectable without the counters). Verified against production data, not only fixtures. Standardisation over the 391,174 x 3000 ldx corpus reproduces the shipped model's recorded constants (61.8216743766 / 9.5716818880) with a delta of 0.000e+00 on both, and the train/test counts match what `plan_corpus` derives independently from the manifest (283,296 / 107,878). Two epochs on an A30 run at ~40 s/epoch with loss 0.4446 -> 0.0639. The gate was confirmed live rather than a no-op: the corpus was extracted at exactly (66, 5) so everything passes at the defaults, while (70, 8) keeps 236,119 and (75, 10) keeps 167,197. 34 tests. Full suite 1442 passed, 44 skipped.
jayhesselberth
added a commit
that referenced
this pull request
Aug 26, 2026
Minor rather than patch: new capability throughout, and two behaviour changes -- one confined to CRF training, one to how a corpus that cannot supply `signal_kmer` is handled. The release is the second half of the CTC-CRF port plus ONNX export: - `leech.crf.evaluate` (#224) -- decode a corpus, match to references by edit distance, report per group. The generic half of evaluation; what a panel is stays with whatever defines the panel. - ONNX export for the classifier arms and the CRF encoder (#217, #222), dynamo exporter at opset 18, each with a contract sidecar and a round-trip check against torch across the serialization boundary. - `leech model train-crf` (#219) -- the CLI for the trainer, plus the corpus builder (`plan_corpus`/`build_corpus`) and `CrfTrainer` itself. - The signal-level k-mer encoding now comes from escapepod-signal (#222) rather than being held in a cdylib no Rust consumer could link. Two behaviour changes, both worth reading before upgrading: `signal_kmer` no longer degrades quietly (#230/#232). The encoding is decided from the whole corpus rather than chunk 0, an encoding named on the command line is no longer substituted, and the config records what the run actually used. This one is coupled to the ONNX work above and is why the release waited for it: the contract is derived from the config and exists so a non-Python consumer can trust the input spec, so a config that misstates its encoding is now refused at export rather than published. CRF batch order (#231). `CrfTrainer.train` re-seeded `default_rng(seed)` and replayed the permutation `resolve_split` had already drawn, so epoch 1 trained on `pi(pi(train))`. Fixed, which means a given seed now sees different batches -- numbers from a seed will not reproduce against 0.8.0. Batch order alone moves a 32-epoch run's final training loss by more than 2x, so a seed is one draw from that spread, not a fixed point. The CRF trainer was validated against the implementation it was ported from over six paired seeds: balanced recall differs by -0.17pp +/- 0.28pp, sign test p = 0.688, against a within-arm seed range of 0.71pp. Also in this commit, not from the PRs: - README listed neither `leech model train-crf` (a shipped command missing from the CLI table) nor ONNX export at all, including the single-BCE-logit contract point that makes a misread graph silently wrong. - CLAUDE.md said "feature-complete (v0.7.0)" while listing CRF and ONNX. - CHANGELOG's Unreleased section had accumulated three separate `### Added` headings from different PRs; consolidated to one Added/Changed/Fixed set. Full suite 1512 passed, 44 skipped. Docs build clean. Both lockfiles verified against their manifests (`cargo metadata --locked`, `uv lock --check`).
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.
CrfTrainerruns the schedule over a corpus (#218) and writesmodel.ptbesidea
model.jsonsidecar. The sidecar is not optional — standardisation livesin neither the architecture config nor the checkpoint, so weights alone cannot
be used correctly.
Why a separate trainer
leech.training.Traineris classification-locked all the way down:pos_weight,num_out, BCE/focal/CE, AUROC/F1 checkpointing, and a wrapper that forwardsthree input branches. A sequence task shares none of that, and threading one
through would put the production classifier path at risk to save a few hundred
lines.
The decisions are functions, not loop internals
The loop is mechanical; the decisions are where runs go wrong without failing.
Each is a plain function, tested without a GPU:
compute_standardisationapply_quality_gateresolve_splitencode_targetsselect_checkpointselect_tolis deliberately loose (25%). Training loss does not rank modelsat this scale — one seed reached 0.0045 where another reached 0.0072 on the same
split and measured 0.2pp worse on held-out recall — so this is a divergence
detector, not a ranking.
Verified against production data
Standardisation over the real 391,174 × 3000 ldx corpus:
Train/test counts (283,296 / 107,878) match what
plan_corpusderivesindependently from the manifest in #218. Two epochs on an A30: ~40 s/epoch, loss
0.4446 → 0.0639.
The gate was checked to be live rather than a no-op — the corpus was extracted
at exactly (66, 5) so everything passes at defaults, while (70, 8) keeps 236,119
and (75, 10) keeps 167,197. That is the point of gating at training time: a
sweep costs a run, not a re-extraction.
The instrumentation earned itself immediately
Epoch 1 of the GPU run reported 3 skipped steps and 3 non-finite gradients at
|g|max 50.45, settling to 0 and 2.22 by epoch 2. That is normalGradScalerwarm-up — but it is undetectable without the counters, because on non-finite
gradients
scaler.step()silently does nothing andupdate()halves the scale.The scale dropping is the signal.
Testing
actually falls
ruff format --check,ruff check,ty check,zensical buildcleanNot in this PR
The CLI surface (
leech model train-crfvs aleech crfgroup — worth settlingbefore it ships), the ONNX export, and metrics/eval. Nor the paired 3-seed
retrain against the shipped
barcode_crf_ldx16: at ~20 min per 32-epoch runthat is now cheap enough to be worth doing properly as its own exercise.
🤖 Generated with Claude Code