Skip to content

feat(crf): decode, match and report — the generic half of evaluation - #224

Merged
jayhesselberth merged 2 commits into
mainfrom
feat/crf-eval
Aug 26, 2026
Merged

feat(crf): decode, match and report — the generic half of evaluation#224
jayhesselberth merged 2 commits into
mainfrom
feat/crf-eval

Conversation

@jayhesselberth

Copy link
Copy Markdown
Member

The last leech-side piece of the CRF chain. leech.crf.evaluate takes a
reference 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:

rule what goes wrong without it
emitted_references applies target[state_len:] once full-length matching forces state_len leading 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 one
balanced_recall takes a grouping and raises on an empty one with classes crossed with batch, a pooled table measures batch; and an empty grouping yields balanced_recall: null, which serializes fine and ships
recall averaged per class, not per read a pooled accuracy over unbalanced classes is decided by the deepest class

The empty-grouping refusal is not hypothetical — escapepod-models'
crf_metrics.py documents having to guard exactly that downstream, because
registry.ship only checks that metrics carry a source.

The edit distance

lev_vs_refs scores one decode against the whole reference set at once — the
shape 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 algebra
trustworthy, so it is asserted
against the scalar implementation on 50 random
queries × 24 references rather than commented.

_lev_py keeps its own name deliberately: lev is edlib where edlib imports,
so 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 rather
than being padded to a common width, which would change the distances instead of
computing them.

Validated on production data

16 references, emitted length 44 from 48
decoded 4000 reads in 53s, mean len 42.0
LDX1-8:  8 classes, 1800 reads
LDX9-16: 8 classes, 2200 reads

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

  • 25 tests; full suite 1494 passed, 44 skipped
  • ruff format --check, ruff check, ty check, zensical build clean

Where this leaves the goal

With escapepod-models#83 (its crf/ deleted, 161 tests passing against leech
0.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

`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
jayhesselberth merged commit 7d88e3e into main Aug 26, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the feat/crf-eval branch August 26, 2026 01:00
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`).
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