Skip to content

fix(crf): the batch order no longer replays the split's shuffle - #231

Merged
jayhesselberth merged 1 commit into
mainfrom
fix/crf-batch-order
Aug 26, 2026
Merged

fix(crf): the batch order no longer replays the split's shuffle#231
jayhesselberth merged 1 commit into
mainfrom
fix/crf-batch-order

Conversation

@jayhesselberth

Copy link
Copy Markdown
Member

What

resolve_split seeds a generator from seed, and on both the corpus-split and held-out-batch paths it shuffles an array of the same length the epoch loop goes on to shuffle. CrfTrainer.train then opened a second default_rng(seed) — which replays that generator's first draw exactly.

So epoch 1 trained on π(π(train)): a batch order determined by the split shuffle rather than independent of it, with every later epoch the split stream shifted by one.

Why it needed a test rather than an eye

Nothing about a run looks wrong when this happens. The order is still a permutation, the loss still falls, and the seed still reproduces. The property is only visible if you state it:

pre-fix  first-epoch order == split replay? True
fixed    first-epoch order == split replay? False

epoch_order_rng spawns a distinct stream from the same seed, so runs stay reproducible and the two orders are independent. Skipping a single draw would have fixed epoch 1 and left the same defect one epoch further in, so test_the_stream_is_not_merely_offset_from_the_split_stream checks several epochs deep.

How it was found

Auditing the ported trainer against escapepod-models' train_ctc.py, which had been running ~1.4x lower final training loss over three paired seeds. The two turned out to be the same computation: identical corpus, identical split (107,878/107,878 test reads shared), identical architecture (28 tensors, 519,880 parameters), identical init seeding, and a step-for-step identical loop body. The batch-order stream was the only difference between them.

Running one loop body twice with only the RNG stream varied reproduced a gap larger than the one between the arms:

epoch fresh (leech stream) advanced (train_ctc stream)
1 0.431 0.494
3 0.075 0.084
6 0.062 0.024

That is batch-order variance, not a port defect — consistent with train_ctc.py's own note that one seed reached 0.0045 where another reached 0.0072 on the same split.

Compatibility

This changes which batches a given seed sees, so numbers from a seed will not reproduce across this release. Treat a seed as one draw from that spread rather than a fixed point.

`resolve_split` seeds a generator from `seed`, and on both the corpus-split
and held-out-batch paths it shuffles an array of the *same length* the epoch
loop goes on to shuffle. `CrfTrainer.train` then opened a second
`default_rng(seed)`, which replayed that generator's first draw exactly: epoch
1 trained on `pi(pi(train))` -- a batch order determined by the split rather
than independent of it -- and every later epoch was the split stream shifted
by one.

Nothing about a run looks wrong when this happens. The order is still a
permutation, the loss still falls, and the seed still reproduces. That is why
it gets a test that names the property rather than a comment: the pre-fix
first-epoch order is bit-identical to the split replay, and the test fails
against it.

`epoch_order_rng` spawns a distinct stream from the same seed, so runs stay
reproducible and the two orders are independent. Skipping a single draw would
have fixed epoch 1 and left the same defect one epoch further in, so the test
checks several epochs deep.

Found while auditing the ported trainer against escapepod-models' train_ctc.py
for a systematic loss difference. The two turned out to be the same
computation on the same data with the same init -- the batch-order stream was
the only difference between them, and varying it alone moves a 32-epoch run's
final training loss by more than 2x on this corpus.
@jayhesselberth
jayhesselberth merged commit 50993df into main Aug 26, 2026
3 checks passed
@jayhesselberth
jayhesselberth deleted the fix/crf-batch-order branch August 26, 2026 10:46
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