Skip to content

feat(crf): a CTC-CRF trainer - #219

Merged
jayhesselberth merged 1 commit into
mainfrom
feat/crf-trainer
Aug 25, 2026
Merged

feat(crf): a CTC-CRF trainer#219
jayhesselberth merged 1 commit into
mainfrom
feat/crf-trainer

Conversation

@jayhesselberth

Copy link
Copy Markdown
Member

CrfTrainer runs the schedule over a corpus (#218) 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 weights alone cannot
be used correctly.

Why a separate trainer

leech.training.Trainer 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 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:

function the silent failure it prevents
compute_standardisation float32 summation over billions of samples loses its tail; streaming in float64 blocks also means a corpus larger than RAM costs nothing to summarise
apply_quality_gate an unscored read cannot pass a gate, so partial coverage trains on a small non-random subset — one corpus went 56% → 13.5% that way. Refuses rather than gates
resolve_split a read-level split when classes are crossed with batch reads optimistically; held-out batch wins, then the corpus's own split, then a seed
encode_targets 12,288 dict lookups per step (1.34 ms of a 39 ms step) vs 0.06 ms for a prebuilt array
select_checkpoint a run that passed through 0.0047, diverged, and recovered to 0.0072 ships weights it already beat by 53%

select_tol is deliberately loose (25%). Training loss does not rank models
at 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:

computed      : mean=61.8216743766  std=9.5716818880
shipped model : mean=61.8216743766  stdev=9.5716818880
delta         : 0.000e+00 on both

Train/test counts (283,296 / 107,878) match what plan_corpus derives
independently 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 normal GradScaler
warm-up — but it is undetectable without the counters, because on non-finite
gradients scaler.step() silently does nothing and update() halves the scale.
The scale dropping is the signal.

Testing

  • 34 trainer tests, including an end-to-end CPU run and a check that the loss
    actually falls
  • Full suite 1442 passed, 44 skipped
  • ruff format --check, ruff check, ty check, zensical build clean

Not in this PR

The CLI surface (leech model train-crf vs a leech crf group — worth settling
before 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 run
that is now cheap enough to be worth doing properly as its own exercise.

🤖 Generated with Claude Code

`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
jayhesselberth merged commit a9885b4 into main Aug 25, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the feat/crf-trainer branch August 25, 2026 20: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