fix(dataset): decide seq_encoding from the whole corpus, and record it - #232
Merged
Conversation
`LeechDataset` fell back from `signal_kmer` to `base_onehot` on the strength of chunk 0, at WARNING, and the saved config still recorded the requested encoding. Three separable defects that combine into a run which finishes, looks fine, and trained on a different model input than was asked for — `(4, kmer_len)` where `signal_kmer` is `(36, signal_len)`. The decision is now a whole-corpus count (`_signal_kmer_coverage`): one vectorised pass over the CSR offsets and the context column on the streaming path, and the message names the affected fraction. Chunk 0 was wrong in both directions — one empty first row flipped an entire corpus to `base_onehot`, and a corpus whose first row was fine encoded every later row that had no map from data that isn't there, as all-zero sequence channels, with nothing raised and nothing logged. A corpus carries the maps for every chunk or for none. None is the version-skew case the fallback exists for; partial coverage now raises, because both available answers are the same silent representation change and `data merge` already refuses to merge a mapped corpus with an unmapped one — so ragged means a damaged file, not skew. An encoding named on the command line is no longer substituted: `--seq-encoding signal_kmer` over a corpus that cannot supply it stops the run, taking the default still falls back, and `--encoding-fallback` / `--no-encoding-fallback` overrides either way (`LeechDataset(allow_encoding_fallback=...)` in code). The model and `config.json` are both built from `LeechDataset.effective_seq_encoding` rather than the request. That is what makes a fallen-back arm auditable after the fact, and it keeps config and exists precisely so a non-Python consumer can trust the input spec. It also has to land with the policy fix rather than without it: alone, it would restore the silent success this is about. Three other sites where a fallback can never work, because the trained model's sequence branch has a fixed channel count: `eval test` and both calibration paths refuse it, and `model benchmark` builds its model from the effective encoding. Before this the middle state was the worst of the three — the dataset half fell back and the model half did not, so a run died at the first forward pass with a channel-count RuntimeError naming neither the corpus nor the encoding. Closes #230
jayhesselberth
force-pushed
the
fix/seq-encoding-fallback-230
branch
from
August 26, 2026 11:43
87eacfc to
c588c09
Compare
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.
Closes #230.
What
maindoes todayI reproduced all three defects before touching anything, and one detail changes the picture from the issue:
mainRuntimeError: expected input[4, 4, 11] to have 36 channelsat the first forward passbase_onehot→ same channel-count crashThe silent-train from 0.6.7 is now a confusing crash instead — except for the ragged corpus, which is still silently wrong, and except for sequence-blind arms, which train fine on the wrong input. The middle state is the worst of the three: the dataset half falls back and the model half does not, so the run dies several steps later on a message naming neither the corpus nor the encoding.
Changes
The decision is a whole-corpus count, never chunk 0.
_signal_kmer_coverageis one vectorised pass over the CSR offsets and the context column on the streaming path — data already in memory — and the message names the affected fraction. A warning that does not say how much of the corpus it covers is the one that got read past for four releases.A corpus carries the maps for every chunk or for none, and partial coverage raises. None is the version-skew case the fallback exists for. Anything in between is damage, where both available answers are the same silent representation change: encoding the uncovered rows gives them the all-zero channels above, and switching a 6.7M-chunk corpus to
base_onehoton the strength of a few bad rows discards the encoding for every good one.data mergealready refuses to merge a mapped corpus with an unmapped one (_validate_member_setsfolds the three CSR spellings into one logical member), so ragged means a damaged file rather than skew. The escape hatch is--seq-encoding base_onehot, which is explicit.An encoding named on the command line is not substituted.
--seq-encoding signal_kmerover a corpus that cannot supply it stops the run; taking the default still falls back, which is the case the fallback exists for. click'sget_parameter_sourceseparates the two, and--encoding-fallback/--no-encoding-fallbackoverrides either way (LeechDataset(allow_encoding_fallback=...)in code, defaultTrue, so nothing outside training changes behaviour).The model and
config.jsonboth come fromLeechDataset.effective_seq_encoding. That is the issue's suggested minimum, and it is what makes a fallen-back arm auditable after the fact — one field, where before there was no way to find out at all. It also keeps #217's ONNX contract honest: the contract is derived from the same config and exists precisely so a non-Python consumer can trust the input spec.This half has to land with the policy fix rather than without it. On its own it would make the whole-corpus fallback train coherently to completion — restoring exactly the silent success the issue is about.
Three other sites where a fallback can never work, because the trained model's sequence branch has a fixed channel count and any fallback guarantees a shape error several steps later:
eval testand both calibration paths passallow_encoding_fallback=False;model benchmarkbuilds its model from the effective encoding.Tests
16 new tests, all failing on
main(run against the main checkout'ssrcto confirm) except the two healthy-corpus controls:test_signal_kmer.py::TestEncodingFallbackPolicy— ragged stops the run in both flag positions; one empty row is reported as one row, not as the whole corpus; a ragged corpus still reads asbase_onehotwhen asked for by name; a corpus with no maps at all falls back and the warning carries the fraction; the CSR count and the per-dict count agree.test_config_propagation.py::TestSeqEncodingProvenance— the config records the effective encoding; the checkpoint's weights load into a model rebuilt from the saved config alone; explicit--seq-encodingerrors on the CLI while the default does not;--encoding-fallbackoverrides.test_onnx_export.py::test_a_config_that_misstates_its_encoding_cannot_be_exported— the issue's open question ("I have not tested whether that fails loudly at export"). It does: refused atload_state_dictwith a size mismatch, not published as a 36-channel contract. Unreachable fromleech model trainnow, but it keeps a hand-edited or older config from being trusted.Full suite: 1510 passed, 44 skipped (3:01, in a Slurm allocation).
ruff format/ruff checkclean.ty check src/leech/reports the same 2 pre-existing diagnostics asmain(crf/evaluate.py,signal_refine.py), neither in a touched file.CHANGELOG, CLAUDE.md (new "The sequence encoding is decided once, from the whole corpus, and recorded" section) and
docs/reference/cli.mdupdated.🤖 Generated with Claude Code