feat(crf): decode, match and report — the generic half of evaluation - #224
Merged
Conversation
`leech.crf.evaluate` takes a reference set, a grouping and a corpus. What a PANEL is — which classes exist, which of them share a flowcell — stays with whatever defines the panel. That is the same seam as the manifest: vocabulary on one side, signal ML on the other. Three rules live here rather than in callers, because each produces a plausible-looking wrong number rather than an error: - **Match what the model emits.** `emitted_references` applies `target[state_len:]` once. Scoring against full-length targets forces `state_len` leading deletions into every alignment, which inflates every distance AND compresses the margin — an aligner places those deletions where they help most, discounting wrong references more than the right one, and the margin is what a recovery threshold ranks on. - **Report per group.** When classes are crossed with batch, one pooled table measures batch. The grouping is an argument because only the caller knows whether their classes are confounded; the refusal to pool is here, and an empty grouping RAISES. That failure is otherwise silent: every bucket comes out empty, the headline reduces to None, and `balanced_recall: null` serializes fine and ships. escapepod-models' crf_metrics.py documents exactly that, having had to guard it downstream. - **Balanced, not raw, recall.** A pooled accuracy over unbalanced classes is decided by whichever class is deepest. `lev_vs_refs` scores one decode against the whole reference set at once, which is the shape of every evaluation loop; the scalar form is R DP tables per read, affordable on a 16k sample and not on a run. Its vectorisation recovers the serial insertion term exactly as `j + cummin(tmp[k] - k)` — that identity is the only thing making the algebra trustworthy, so it is asserted against `_lev_py` on random strings rather than commented. `_lev_py` keeps its own name because `lev` is edlib where edlib imports: a test written against `lev` would compare edlib with itself precisely on the machines that have it. Ragged references fall back to the scalar path instead of being padded to a common width, which would change the distances rather than compute them. Validated end to end on the production ldx corpus, not only fixtures: 16 references at emitted length 44 from 48, 4,000 held-out reads decoded on an A30, and per-flowcell reporting that finds 8 classes in each — the pilot's code-flowcell confound, which is exactly why pooling would be wrong. (The balanced recall there is the 2-epoch smoke model's, not a quality claim; what the run establishes is the mechanics.) 25 tests. Full suite 1494 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.
The last leech-side piece of the CRF chain.
leech.crf.evaluatetakes areference set, a grouping and a corpus; what a panel is — which classes exist,
which share a flowcell — stays with whatever defines the panel. Same seam as the
manifest: vocabulary on one side, signal ML on the other.
Three rules it holds rather than leaving to callers
Each produces a plausible-looking wrong number rather than an error, which is
why they belong in the library:
emitted_referencesappliestarget[state_len:]oncestate_lenleading deletions into every alignment — inflating distances and compressing the margin, since an aligner puts them where they help most, discounting wrong references more than the right onebalanced_recalltakes a grouping and raises on an empty onebalanced_recall: null, which serializes fine and shipsThe empty-grouping refusal is not hypothetical — escapepod-models'
crf_metrics.pydocuments having to guard exactly that downstream, becauseregistry.shiponly checks that metrics carry asource.The edit distance
lev_vs_refsscores one decode against the whole reference set at once — theshape of every evaluation loop. The scalar form is R DP tables per read:
affordable on a 16k sample, not on a run.
Its vectorisation recovers the serial insertion term exactly as
j + cummin(tmp[k] - k). That identity is the only thing making the algebratrustworthy, so it is asserted against the scalar implementation on 50 random
queries × 24 references rather than commented.
_lev_pykeeps its own name deliberately:levis edlib where edlib imports,so a test written against
levwould compare edlib with itself precisely on themachines that have it. Ragged references fall back to the scalar path rather
than being padded to a common width, which would change the distances instead of
computing them.
Validated on production data
Eight classes per flowcell is the pilot's code↔flowcell confound — exactly why
pooling would be wrong, and why the API makes the grouping explicit. (The
balanced recall from that run is the 2-epoch smoke model's, not a quality
claim; the shipped model reaches ~0.97 after 32. What this establishes is the
mechanics.)
Testing
ruff format --check,ruff check,ty check,zensical buildcleanWhere this leaves the goal
With escapepod-models#83 (its
crf/deleted, 161 tests passing against leech0.8.0), the CRF chain is out of that repo bar the pieces that belong to it: the
panels, the corpus extractor, the bundle, the DAG, and the bonito oracle.
🤖 Generated with Claude Code