All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
LeechDatasetno longer holds three copies of the corpus while it loads (#211).load_chunksread every npz member, the tensorize loop built one tensor per chunk from them, andtorch.stackallocated the whole contiguous output while that list was still alive — a 41 GB npz peaked at 116 GB and hit the 120 GB cgroup limit before epoch 1. The fields now fill a preallocated tensor in bounded batches (torch.stack(..., out=)), and the arrays are read from the npz in row blocks rather than materialised, so the numpy source is never resident alongside the tensors built from it. Measured on a 300k-chunk corpus (1.8 GB npz, 1.6 GB of output tensors): peak RSS 6.19 GB -> 2.36 GB, load time 21.5 s -> 19.7 s, tensors bit-identical.- Only the members a run consumes are decompressed.
signal_residuals_flatis skipped for--signal-mode signal,features_flatfor models without a feature branch, and the base-to-signal maps unless--seq-encoding signal_kmerasks for them — up to 20 GB of decompression that used to happen on every load regardless. - Chunk metadata is stored as columns, not a dict per chunk (#211). The
dicts measured 780 bytes each — 5.2 GB for a 6.7M-chunk corpus — holding a
handful of small integers and a few hundred distinct strings.
ChunkTablekeeps the npz's own arrays (text packed to bytes, integers narrowed) and hands out a row view on demand: 112 B/chunk measured, with no conversion transient, anddataset.chunksstill reads as a sequence of mappings. load_chunks's docstring no longer claims the data is memory-mapped.np.loadnever maps a zip member, compressed or not; it is always a full read, which is what made this path look lazy when it was not.
seq_to_sig_mapsis stored asseq_to_sig_values+seq_to_sig_offsets(CSR: rowiisvalues[offsets[i]:offsets[i+1]]) instead of a pickled object array. The old member cost one Python ndarray per chunk to unpickle and could not be read in row blocks.load_chunks,data mergeand the dataset still read the legacy member, so existing corpora stay valid — but a file written by this version and read by leech <= 0.6.7 has noseq_to_sig_maps, so asignal_kmerrun on that older version falls back tobase_onehot(with the warning it already emits).
Promotes 0.6.7-rc.1 unchanged — no commits landed between the two tags. The
release candidate exercised the new PyPI path end to end, so this is the first
version installable with uv add "leech[rust]" / pip install "leech[rust]"
rather than from a checkout.
leechandleech-corepublish to PyPI automatically on av*tag (#210), via Trusted Publishing (OIDC) — no API token, no repository secret. Publishing was previously a manualuv publish, andleech-corewas never published at all, so therustextra could not resolve for anyone outside the workspace.- Two release gates.
check-versionfails the tag before anything builds if it disagrees with either declared version — a PyPI upload cannot be replaced, so a wrong version reaching the index is permanent.testruns the suite at the tagged revision, which nothing did before: CI triggers on pushes tomainand on PRs, never on a tag.
leech-coreships one stable-ABI wheel per platform (pyo3abi3-py312) rather than one per interpreter, so a new CPython release no longer needs a newleechrelease to get a wheel. Wheels cover manylinux x86_64 and aarch64; other platforms build from the sdist, which needs a Rust toolchain and network access to github.com.- The
rustextra pinsleech-coreexactly.check_rust()only warns on a mismatch, so for a PyPI install the pin is the only thing preventing a currentleechfrom pairing with a stale extension — the hazard that letleech_coresit at0.3.0across ten releases.
- A correctly paired pre-release reported a version mismatch. The two
halves report versions in different dialects:
leech's throughimportlib.metadatain PEP 440 normal form,leech_core's fromenv!("CARGO_PKG_VERSION")as literal Cargo semver. A final release spells the same in both, so the raw==looked correct until the first pre-release — where it warned on every install and failed the newtestgate, making pre-releases unpublishable. Found by the0.6.7-rc.1rehearsal.
First release candidate. leech is public, and both distributions now publish
to PyPI automatically from a v* tag — this rc exists to exercise that path
end to end before a final release depends on it. It is a pre-release, so
pip install leech will not resolve to it.
leechandleech-corepublish to PyPI on tag (#210), via Trusted Publishing (OIDC) — no API token, no repository secret. Install withuv add "leech[rust]"orpip install "leech[rust]". Publishing was previously a manualuv publish, andleech-corewas never published at all, soleech[rust]could not resolve for anyone outside the workspace.- Two release gates that did not exist.
check-versionfails the tag before anything builds if it disagrees with either declared version — a PyPI upload cannot be replaced, so a wrong version reaching the index is permanent.testruns the suite at the tagged revision, which nothing did: CI triggers on pushes tomainand on PRs, never on a tag.
leech-coreships one stable-ABI wheel per platform (pyo3abi3-py312) instead of one per interpreter. It loads on CPython 3.12 and every later 3.x, so a new CPython release no longer needs a newleechrelease to get a wheel. Wheels cover manylinux x86_64 and aarch64.- The
rustextra pinsleech-coreexactly.check_rust()only warns on a mismatch, so for a PyPI install the pin is the only thing preventing a currentleechfrom pairing with a stale extension — the hazard that letleech_coresit at0.3.0across ten releases. The version now lives in three files and the test suite enforces all three agree with each other and with the tag.
- A correctly paired pre-release reported a version mismatch. The two halves
report versions in different dialects:
leech's arrives viaimportlib.metadatain PEP 440 normal form (0.6.7rc1),leech_core's fromenv!("CARGO_PKG_VERSION")as the literal Cargo semver (0.6.7-rc.1). A final release spells the same in both, so the raw==comparison looked correct right up to the first rc — where it warned on every install and failed the version-pairing test, and so would have failed the newtestgate and made pre-releases unpublishable.rust_version_mismatch()now compares normalized forms, without taking a dependency onpackaging(which is not a runtime dependency, and whose presence in dev environments is exactly how this would have come back).
Patch release completing the DataLoader-worker fix started in 0.6.5. eval test
was fixed there; the in-training validation loader and both calibration.py
loaders were not, so a GPU still went near-idle once per epoch.
- Validation loader starved the GPU once per epoch (#207).
trainresolved workers for the training loader and hardcodednum_workers=0for validation three lines below. On a 1,176,763-chunk validation set that was ~5 minutes of near-idle GPU at every epoch boundary — roughly 75 minutes across a 15-epoch run, scaling with validation size. calibration.pypassed a literal 0 through on CUDA. Both loaders tooknum_workers: int = 0and forwarded it unresolved, so0meant "no workers" rather than AUTO.
-
resolve_val_dataloader_workers, besideresolve_dataloader_workers. Same rule, with one scoped exception: a dataset that fell back to per-chunk lists keeps 0 workers.LeechDatasetstacks into contiguous buffers precisely so a fork COW-shares them; only the_try_stackfallback multiplies peak RSS. That exception wins even over an explicit--num-workers N, because OOM is not a throughput tradeoff — which is what the previous blanket 0 was protecting, at the cost of every other run. -
A guard test:
num_workersmay not be a bare literal anywhere in the package. It must come from a resolver, from a local named for what it carries, or carry a call-site markerdataloader-workers: unresolvedwith a reason. Two markers exist —commands/benchmark.py(worker count is the variable under test) and the legacySignalCNNpath (SignalDatasethas no_signals_tensor, so the validation guard would force 0 and change behaviour).The guard is checked against the value wherever it is bound, not against
DataLoader(...)call sites. Two earlier versions failed their own mutation test: a file-scoped allow-list exempted a whole file so the #207 bug passed, and a call-site check also passed it because that bug lives in a kwargs dict reaching the loader via**, in a function that resolves a different loader.
Performance and build-correctness release. leech eval test was feeding the GPU
from a single process and now uses DataLoader workers, and leech_core's
version tracks leech's so uv can no longer restore a stale compiled
extension over a current build.
-
leech eval testfed the GPU from a single core. The eval DataLoader was built withnum_workerspinned to 0 and no flag to change it, so collate, the host-to-device copy and the forward pass all ran serially in one process: 8% GPU utilisation on an A5000 over a 7,835,334-chunk test set, against 98% formodel trainon the same corpus and the same card — same dataset class, same collate function, the only difference being that training had workers.The rule for sizing a loader now lives in exactly one place,
dataset.resolve_dataloader_workers, which training carried inline and evaluation did not have at all. Its semantics are training's:0means auto, auto is 0 on CPU (workers there would compete with the compute) and0 on CUDA, and a daemonic process — a grid-search
mp.Poolworker — always gets 0, because it cannot spawn children.Auto is now also capped by the CPUs the process may actually run on (
sched_getaffinity, which respects the Slurm cpuset). Without that, the pipeline's GPU eval rules, which requestcpus_per_task=2, would have forked 8 workers onto 2 cores. An explicit--num-workers Nis honoured as given. -
leech_core's version now tracksleech's. It sat at0.3.0from v0.3.1 to v0.6.4 — ten releases, spanning #176, #185, #187, #188, #192, #195, #200 and #202 — while the Rust changed underneath it. That is not cosmetic:uvkeys its archive cache on the version string, souv synccould restore a compiled extension built from any earlier revision that shared it, over a current build. Observed doing exactly that: 43 tests failing with pre-#188 behaviour (chunk_signal_kmer_inputsno longer snappingmap[0] = 0) against an up-to-date working tree.rust/Cargo.tomlis the single source;rust/pyproject.tomltakes it viadynamic = ["version"]rather than carrying a third copy to keep in sync. -
leech_coreexports__version__, andcheck_rust()reports a mismatch. The two are separate distributions built from one repository, so an extension compiled at one revision can sit alongside aleechfrom another. That pairing does not raise — it produces different numbers, which is how #176 stayed hidden (new Rust, old serial driver).check_rust()printed a bareleech_corewith no version at all; it now names it and says which half to rebuild.
-
--num-workersonleech eval test, so the auto default can be overridden where it is wrong (default0= auto, as onmodel train). -
tests/test_rust_version_pairing.py: asserts the two declared versions agree in the source tree, thatrust/pyproject.tomldefers rather than pinning a third copy, and that the installed extension matches the tree — the last of which is the stale-build hazard itself.
- The release process (
.claude/commands/release.md) bumps both versions and re-verifiescheck_rust()afterwards, so this cannot drift again by omission.
-
Four
leech modelsubcommands were missing from the CLI reference entirely --benchmark,release,listandfetch. All four are now documented with their options and defaults taken from the click definitions. -
CLAUDE.md's module tree namedutil.pyandinference.py, neither of which has existed since they were split intobundling.py/model_loading.py/model_export.py/metrics.pyand theinference/package. Thirteen modules were unlisted; the tree is now checked against the source. -
The docs workflow watched
mkdocs.ymlfor changes. The site has built with zensical since the migration, so edits tozensical.tomlnever triggered a deploy.
Dependency and internal-consolidation release. No user-facing behaviour change;
the only numeric movement is float32 rounding in level features, described
below. Requires escapepod >= 0.15.0.
-
escapepod bumped to v0.15.0, and four locally-held primitives handed back to it. All four are things leech was carrying only because escapepod had no home for them; each was filed upstream during the Rust/Python audit and each is now adopted. Net: 391 lines of Rust deleted against 102 added, with no behaviour change beyond float32 rounding.
-
The refinement settings are escapepod's preset (escapepod-rs#257).
refinement.rs::build_settingswas a 28-lineRefineSettingsliteral duplicating the one inside escapepod's Python binding; the two drifted ondwell_targetand that is what caused #193. It is nowRefineSettings::move_table_refinement(half_bandwidth, n_iters, seed), which is field-for-field identical to what leech was building — verified before switching — so the drift is now structurally impossible rather than test-guarded. -
The POD5 reader cache is escapepod's (escapepod-rs#258). leech's process-global
OnceLock<Mutex<HashMap<String, Arc<Reader>>>>is replaced byescapepod_signal::cached_reader, which warms the read-id index before publishing the entry and opens outside the lock — the two properties that made leech's version worth having. The batch-signal helper stays here and builds on it. -
Per-base statistics are
features::span_stats(escapepod-rs#260), withSpanFill::Zero,SpanBounds::ClampandMedianConvention::SortPartialCmpto preserve leech's semantics exactly. escapepod computes inf64prefix sums where leech accumulated inf32, so level features move by at most 7e-07 (level_mean) and 1.2e-07 (level_std);level_medianandlevel_rangeare bit-identical. Both leech paths moved together, and Python-vs-Rust agreement is still exactly zero. leech'smedian_f32— kept only to matchnumpy.median's even-length tie-break — is deleted, since that rule is nowMedianConvention. -
Move-table and CIGAR mapping are
escapepod_signal::mapping(escapepod-rs#259).build_seq_to_sig_mapandcompute_ref_to_signalnow delegate toseq_to_signal_from_movesandref_to_signal. Verified byte-identical on every alignment in the tRNA fixtures before switching, and the Rust-vs-numpy parity test still passes. leech retains only the BAM op-code toCigarKindtable, since escapepod takes the typed enum. -
The Python half takes the preset too. With
escapepod0.15.0 now on PyPI, the floor moves to>=0.15.0andSigMapRefiner.refinestops passingdwell_targetat all — on 0.15.0 the default means "take the preset", which is what leech_core takes. Pinning one field on the Python side while the Rust side took the whole preset would have left the two halves free to drift apart again the moment the preset changed, which is the exact shape of #193. The floor is load-bearing rather than cosmetic: on 0.14.0 omitting the argument silently reinstates the fixed4.0this override existed to correct, and the backend parity suite fails loudly if that happens.
-
Backend parity. An audit of the Rust/Python boundary found eight divergences, one of them on a code path that is on by default. Read the first entry below before reusing any prepared corpus.
-
The Python and Rust
data preparebackends refined signal maps differently whenever--refine-signal-mapwas on, which is the default. Both halves of #168 had been applied toleech_coreonly:SigMapRefiner.refinecalled escapepod'srefine_signal_mapwithoutdwell_target, taking its fixed4.0default. RNA004 at 130 bps and 4 kHz sits near 31 samples/base, so the asymmetric dwell penalty treated every base as ~8x too long and moved the boundaries accordingly. leech_core has passed0.0(resolve the target from the read's own median dwell) since #168.SigMapRefiner.refinethen rewrote the signal with the fitted(scale, shift, drift). That replaces one shared median-MAD transform with a per-read fit estimated on a chunk that sits largely in a constant 3' adapter, where the fit is weakly identified — observed scales ran from 15 to 1084 and were frequently negative. leech_core stopped applying it in #168.
Measured on the tRNA fixtures, the two backends disagreed on every chunk: max |signal delta| 3.44 in normalized units, every dwell different, max |feature delta| 3.57. After the fix: signal delta 0, dwells identical, features within float32 rounding.
Which backend ran was decided by whether
leech_corewas installed and byrust_prepare_unsupported_reason, so a corpus's features depended on the install;predictsplits the same way viacheck_rust_extraction_available, so a model could be trained and served on different transforms. Re-prepare any corpus built with the Python backend and refinement on (that is: withoutleech_coreinstalled, or with--workers 1, a non median-MAD--signal-norm,--recover-softclip-signal, or a focus TSV). -
--scale-iters -1meant two different things. Python skipped the banded DP and rough-rescaled the signal instead; Rust clamped to0, which escapepod reads as "one DP pass without rescaling", so it refined the map. With the fitted rescale no longer applied, the Python behaviour is a no-op on both outputs, so-1now means "no refinement" on both backends. -
Refiner settings reached the Rust
data preparebackend incompletely._prepare_batch_rustasked theSigMapRefinerforkmer_center_idx, an attribute it does not have, sogetattr(..., -1)pinned the Rust path to escapepod'skmer_len / 2default however the k-mer centre was configured — while the Python backend used the configured value. Half-bandwidth and scale-iters are now read off the same object too, rather than offSignalConfig, whoserefine_*fields could only agree with the refiner by convention. -
compute_kmer_residual_featuresand the signal-residual channel extracted expected levels atkmer_len // 2while refinement used the refiner'scenter_idx, so a non-default centre offset every residual feature against the boundaries that produced it. Both now take the refiner's value, as the Rust pipeline already did. -
prepare_config.jsonrecordedrefine_half_bandwidth,refine_do_rough_rescaleandrefine_kmer_center_idxas dataclass defaults rather than what ran, becausedata preparebuilt the refiner without them.model traincopies these into the model config andpredictrebuilds a refiner from them, so the provenance chain carried defaults end to end. -
Reads whose signal map is shorter than their sequence were dropped by the Python
data preparebackend and kept by the Rust one. Underanchor="reference"the sequence is the aligned reference slice while the map comes fromcompute_ref_to_signal, which strips trailing non-match CIGAR ops, so an alignment ending in a deletion hasnum_mapped_bases < num_bases. Bothcompute_kmer_residual_featuresandcompute_signal_residualthen handed numpy a length mismatch; theValueErrorpropagated out ofbuild_leech_readand the workers'exceptturned it into losing the whole read. This is #185's failure mode with the backends swapped, and it selects the same population: indel-heavy and supplementary alignments. Levels are now fitted to the mapped-base grid on both sides (levels_for_mapped_bases), matching what the Rust pipeline did by zipping. -
The Rust pipeline emitted
kmer_expectedat full sequence length while derivingkmer_residual/kmer_residual_absat the shorter mapped-base length, so one read could produce feature rows of two different widths and chunk extraction'ssafe_end <= feat_row.len()guard would zero some rows and not others. All three are now mapped-base width.compute_signal_residualalso indexes levels with.getinstead of[i], since an out-of-range index inside a rayon worker is a panic that takes the whole batch down. -
--no-rough-rescalenow warns that it is not honored. Refinement is delegated to escapepod, whoserefine_signal_mapalways applies its least-squares rough rescale and exposes no switch, so neither backend could act on the flag. -
predictdroppedbase_justifyon the Rust extraction path.build_rust_extraction_kwargscarried no such key, so the Rust signature's"center"default silently overrode a model trained with--base-justify start/end— which moves the focus sample within the base and so shifts every signal window.data preparepassed it correctly; onlypredictlost it, and nothing validates it, so the symptom was degraded accuracy with no error. -
dwell_offsetwas inert on the Rustpredictpath, and a wide feature window was passed to the model at full width. The Rust extractor returns features over the whole requested window, exactly as a Python chunk does, but the Rust consumers appended them to the batch without the narrowing the Python path applies.validate_inference_shapeschecks feature count, not width, so this did not raise. -
The bundle's Python path appended dwell template channels after narrowing to the k-mer window, while training (
dataset.py) appends before. The templates were keyed to the stored window's column 0 but applied to an array that had already been shifted out from under them.All four copies of this transform are now one function,
prepare_inference_features, which mirrorsChunkDataset._prepare_featuresand raises when the requested window does not fit the stored one — training already raised for the same condition rather than sliding the window. -
Two of
predict's three extraction paths searched for the motif in the basecall while cutting chunks in reference coordinates. Motif positions index whatever sequence chunks come from, which underanchor="reference"is the aligned reference slice.ReferenceMotifSearcherignores itssequenceargument, which is why this was invisible — butpredictpicks the searcher withmode="fasta" if reference_sequences else "bam", so a run without a reference FASTA gets the basecalled searcher, where the argument decides the answer. The rule now lives in one place,chunking.extraction_sequence, whichdata preparealso goes through. -
require_query_mappingdid not reachpredict. It is recorded inprepare_config.jsonbut was neither copied into the model config bymodel trainnor read by either inference entry point, so a corpus prepared with--no-require-query-mappingwas scored with the gate back on — a different read population than the model was trained on, and on aminoacyl-tRNA a label-correlated one (the adduct mis-calls the CCA junction, dropping 28% of charged reads against 6% of uncharged). -
encode_kmermapped only ACGT, so aUencoded as an all-zero column while every other base encoder in the tree —features.sequence_to_int,encoding.seq_to_int, and both Rust encoders — folds U onto T. The same base produced two different model inputs depending on which encoder the path reached.
-
tests/test_backend_parity.py: a field-by-field comparison of the twodata preparebackends over the fixtures, across a matrix of anchors, refinement settings,base_justifyvalues, feature windows,scale_itersvalues and signal contexts — including the exact flag set from #193.It serializes both backends through
save_chunksand compares every array in the npz, failing on any field it has not been told how to compare. Every divergence so far was invisible to the check that caught the previous one (#185 counts, #186 signal_kmer fields, #189 window width, #193 values), because each check was written one field at a time. This one extends itself: adding a field to the chunk format without classifying it is a test failure.
-
One per-base statistics implementation in Rust instead of two.
signal_stats::compute_signal_stats(the Python fast path) andinference_pipeline::features::compute_per_base_stats(the Rust extraction path) were near-identical copies that disagreed on negative map entries: the first casti64tousizeraw and skipped the base, the second clamps to 0 and computes over the truncated span. The pyfunction is now a wrapper. -
One POD5 batch-read helper instead of four copies. Parsing read ids as UUIDs,
reads_by_ids,get_signal_bulkwas written out inpod5_iotwice and in both pipeline entry points — four places to forgetcached_reader, which is the one thing that must not be got wrong there (#176). -
SigMapRefinerwarns whenalgoorsd_paramsare set. Neither reaches the DP any more:refinedelegates to escapepod, which builds its own settings and uses an asymmetric dwell penalty rather than leech's short-dwell table. -
The backend parity test (
tests/test_parallel_prep.py) now parametrizesrefine_signal_mapandbase_justifyinstead of pinning them toFalseand"center". Pinning them is why the divergence above survived four releases: the only test comparing the backends opted out of the default configuration.
Two silent correctness bugs, both of the same shape: a value that looked like
an absent default but wasn't. Re-prepare any Rust-backend corpus built with
an explicit --feature-start 0, and note that read-level splits no longer
reproduce from pre-0.6.2 seeds.
-
The Rust
data preparebackend stamped the wrong feature window onto every chunk when--feature-start 0was requested (#189)._prepare_batch_rustresolved the value it stored withconfig.chunk.feature_start or -5, and0— features beginning at the focus base, the right-only window used for tRNA 3' ends — is falsy, so the chunk recordedfeature_start=-5.The extraction itself was correct: Rust receives the window and cuts the requested bases, so the arrays have the right shape and the right contents. What was wrong is the number the corpus reports about them, and that number is load-bearing —
dataset.pyslices the k-mer window out of the feature array at(-kmer_context) - feature_start, andmodel traincopies it into the model config thatpredictlater reads. A corpus prepared this way trains and infers on a window shifted bykmer_contextbases, silently, with no shape error anywhere. If you prepared with the Rust backend and an explicit--feature-start 0, re-prepare (or rewritefeature_startsin the.npz).Only a falsy start triggered it;
--feature-start -15was always stored correctly, which is why it went unnoticed. Both backends now resolve the window through oneresolve_feature_window, andTestFeatureWindowParityholds them to the same stored metadata and the same array widths across four windows. -
Read-level splits depended on the order chunks happened to arrive in, not just on the seed.
split_chunks_by_readshuffledlist(read_to_chunks)— chunk arrival order — and the merge and k-fold splitters shuffled a list built from aset, whose iteration order is PYTHONHASHSEED-dependent. The Pythondata preparebackend returns batches throughimap_unordered, so a seeded split was already not reproducible run to run on that path. All five sites now sort before shuffling, which is what_split_by_groupalready did.Splits change for existing seeds. A split regenerated after this fix will not match one generated before it, so do not re-split a corpus whose models are already trained without re-training — the previous train/test boundary is not recoverable.
data preparelogs the resolved feature window ([+0, +20] relative to the focus base, 21 bases wide) and recordsfeature_start_resolved,feature_end_resolved, andfeature_widthinprepare_config.json. The requested values are optional and default to the k-mer window, so "asked for 21 bases, got 11" was previously visible only by diffing two corpora field by field (#189).data mergenow warns when inputs disagree on the resolved window.
Two silent parity bugs between the data prepare backends. If you built a
corpus with the Rust backend on 0.6.0 or earlier, re-prepare it — it is
missing ~1% of reads, non-randomly, and if you trained with
--seq-encoding signal_kmer the sequence context differed from what the Python
backend would have produced.
-
The Rust
data preparebackend silently dropped ~1% of reads that the Python backend kept — non-randomly, biased toward supplementary-aligned and indel-heavy reads (#185). On a 1,052,751-read production corpus the two backends differed by 9,772 reads, Rust's output a strict subset of Python's, with nothing logged and exit 0.extract_training_chunks_from_readskipped any focus base whose k-mer window ran off either end of the sequence, whereLeechRead.get_chunkpads it withN. Under--anchor referencethe sequence is the aligned reference slice, so a read whose alignment stops within--kmer-context(default 5) of the motif lost its only chunk — exactly the population produced by supplementary alignments and by the elevated indels that--no-require-query-mappingexists to keep. Rust now pads instead of skipping, and drops a focus base only when it has no signal boundaries, which is the Python rule.The same guard sat in
inference.rs, so the same reads got no prediction on the Rust predict backend. Fixed alongside. -
seq_to_sig_mapandsequence_with_kmer_context— the two chunk fields that drive--seq-encoding signal_kmer— never agreed between the prepare backends (#186). Not at edges: on every chunk, at every focus position.LeechRead.get_chunkderives them from the signal window, locating bases with twosearchsortedcalls over the read's map, and snaps the partially overlapping first and last bases to the window edges; Rust derived them from the k-mer window, which spans a different number of bases, and truncated rather thanN-padding. Rust now uses the Python definition — the one every trainedsignal_kmermodel has seen — via a sharedchunk_signal_kmer_inputs, in bothtraining.rsandinference.rs. The(4 * kmer_len, signal_len)encodings the model consumes are now identical between backends. -
LeechRead.get_chunkbounded the focus base on the sequence length while indexingseq_to_sig_map, which is shorter whenevercompute_ref_to_signalstrips trailing non-match CIGAR ops. The resultingIndexErrormade the prepare workers drop the whole read rather than the one chunk. It now bounds onnum_mapped_bases, the same quantity Rust uses.
-
Both prepare backends now go through one
find_focus_bases, instead of the Rust path carrying a second copy of the rule inparallel.py. The copies had drifted: the Rust one searched the basecall under--anchor reference, where positions are reference-relative, and its all-bases fallback ranged over the basecall's length rather than the reference slice's. -
data preparelogs a read yield — reads that produced chunks over reads seen — on both backends, andstats["reads_with_motif"]counts reads rather than chunks, which is what it counts in the sequential path. A backend that yields less than the other now says so in the log.
- Document the read yield line in the data preparation guide, and add a troubleshooting entry for a run that produces fewer chunks than a previous one or than the other backend.
-
data prepareon the Rust path was 10-80x slower than the Python multiprocessing fallback on a large POD5 (#176), so installingleech_coremade the step slower with nothing in the log to say so. Three compounding causes, all fixed:- The POD5 reader was opened once per batch.
escapepod_signal::Readercaches its read-id index in aOnceLockon itself, so a fresh reader threw the index away every time, andreads_by_idsfell back to a single-threaded scan of the whole reads table — all 22 columns, stopping only once every target was found. Reads arrive in BAM order, which is unrelated to POD5 storage order, so that scan ran to end-of-file on each batch. On a 145 GB POD5 on BeeGFS a single 1000-read batch had not finished after 13.5 minutes, spending it in uninterruptible sleep infolio_wait_bit_commonat 0.006 of a core and ~119 major faults/s. The newrust/src/pod5_cache.rsopens each POD5 once per process and warms its index once, in memory, from a read_id-only projected scan; it writes no.p5ssidecar and needs none. All fourReader::opensites now use it. - Phase 1 held the GIL.
py.detachwrapped only per-read processing, so POD5 I/O serialized every caller thread and no caller could overlap batches. Phase 1 now runs GIL-free in bothtraining.rsandinference.rs. - Batches were dispatched from a serial
forloop.--workersselected a rayon pool size but nothing dispatched batches concurrently, leaving one POD5 read outstanding at a time while the Python fallback had--workersof them. Both backends now expose the same(n_reads, chunks)iterator, withnum_workersbatches in flight — threads for Rust, processes for Python — and a bounded window so the BAM is not pulled into memory up front.tests/test_prepare_dispatch.pyfails if the loop comes back.
- The POD5 reader was opened once per batch.
-
--signal-normis no longer silently ignored when the Rust extraction path is active.rust/src/inference_pipeline/processing.rsalways applies median-MAD — itsPipelineConfigcarries no normalization field — but neither the training gate (preparation/parallel.py) nor the inference gate (inference/helpers.py) checked the method, so--signal-norm zscorewithleech_coreinstalled produced median-MAD chunks instead. Measured on the tRNA fixtures, every chunk differed, by up to 1.77 in normalized-signal units. Becausepredictreadssignal_normback out of the model config, this also meant a model trained with a non-default norm was served under a different one. Both gates now consultrust_supports_norm_method():--backend autofalls back to Python with a warning, and--backend rustraises rather than mis-normalizing.median_madruns are unaffected. -
recover_softclip_signalis likewise no longer silently dropped on the Rust path. Recovery fills chunk-window samples outside the aligned region from the full pre-crop signal, which the RustProcessedReaddiscards when it crops — so enabling the flag withleech_coreactive produced the zero-padding the flag exists to avoid.preparewarned about this for--workers > 1but never gated on it, and the inference path did not warn at all. Both now route to Python automatically (--backend rustraises). The prepare warning is downgraded to info and no longer tells users to pass--workers 1, since the fallback is now automatic; enabling the flag costs throughput, not correctness.
- escapepod bumped to v0.14.0 —
rust/Cargo.tomlreturns to a tag pin (from the1c55668frev that took the unreleased kmer-level primitives) and theescapepodfloor moves to>=0.14.0. v0.14.0 fixes the upstream half of #176 (escapepod-rs#251):reads_by_ids,find_signal_rows_by_idsandfind_signal_rows_with_calibration_by_idsused to take the indexed path only when a.p5ssidecar existed on disk, and otherwise ran a full 22-column scan of the reads table that never built the index it declined — so even a cached reader re-scanned on every call. The scan variants are gone and all three route throughread_index().escapepod-pod5also gainedtracing, so an index build now reports that it is happening and what it cost.rust/src/pod5_cache.rskeeps its reader cache, which is what makes the index survive across batches, but its index warm-up is now belt-and-braces rather than load-bearing; its docs no longer describe the removed scan fallback as current behaviour. - Rust prepare-backend selection moved into
preparation.parallel.rust_prepare_unsupported_reason(), a pure function returning the reason Rust cannot serve a givenPrepareConfig(orNone). It collects the pre-existingfocus_mapbypass alongside the two new capability checks, so the rules can be unit-tested without running a preparation pass — the previous inline gate could only be exercised by spawning the multiprocessing pool.
--no-require-query-mappingondata prepare, for modifications that mis-call the motif they are measured at (#175). By default a motif found in the reference is accepted only if it also maps cleanly to query coordinates through the CIGAR. Under--anchor referencethat mapping is used only to accept or reject — the returned coordinate is reference-relative and the query coordinates are discarded — so the check is a quality gate, not a requirement of window placement, and reads failing it can be kept without moving any chunk. This matters because the gate selects on the label: on aminoacyl-tRNA the adduct mis-calls the CCA junction, dropping 28% of charged reads against 6% of uncharged, before any model sees the data. Only valid with--anchor reference; combining it with--anchor basecallraises, since there the returned coordinate is the query start.--emit-scores PATHonleech eval testwrites the per-chunkread_ids,labelsandprobsbehind the metrics to an.npz(#181).evaluate_modelalready computed a probability for every chunk and then dropped it, leaving the confusion matrix — a summary at ONE threshold — as the only artifact. That made AUPRC for the minority class, per-group error breakdowns, paired model comparison, calibration, and any other operating point unanswerable without re-running inference. Off by default. The join to read ids is positional and is checked, not trusted: a length mismatch raises rather than writing a well-formed file full of misattributed scores.compute_metricsnow attaches athreshold_sweepreporting the best operating point rather than only the caller's implied 0.5 (#180). Three points —at_youden,at_mcc,at_f1— each with threshold, TPR, FPR, MCC, F1, Youden's J and called-positive fraction, plusprevalencebeside them. The default threshold is the wrong choice whenever training and evaluation see different class ratios, which--oversample-minorityguarantees: on a tRNA charging corpus at 13.0% positive, the same predictions scored 0.6066 precision at observed prevalence against 0.9114 at 50/50, so the model was being blamed for the class ratio.at_youdenis prevalence-invariant and is the right default when the deployment ratio is unknown or is itself what is being measured.data prepare's startup line now names the dispatch, not just the backend:[Rust (rayon), 32 batches in flight via threads].leech_coreis a separate package fromleech, somaturin developcan leave a freshly built extension beside a staleleech— new Rust, old serial driver — and the only symptom is being slow.[Rust (rayon)]alone predates #178 and does not distinguish the two; a build without "batches in flight" is the stale pairing.data preparelogs achieved reads/s on every progress line and in the final summary, on both backends, and names the backend in each. The #176 regression was invisible in the logs until the allocation ran out; a rate to compare against would have surfaced it in the first minute.- Rust/Python chunk parity is now tested for
anchor="reference", not justanchor="basecall". The test helper already accepted the parameter but no call site ever passed it, leaving the reference-anchored path — the default, and the one that crops the signal to the aligned region and runscompute_ref_to_signal— with no parity coverage. The two backends agree exactly on signal, sequence and dwells there, and to float32 rounding on features; the assertions now also cover features, which are what the model actually consumes.
- Config-driven model layer (bonito-style).
leech/models/nn.pyprovides a layer registry withregister/to_dict/from_dictand the composition primitivesSerial,Stack,Parallel(nestable fan-out + concat) andGraph(flat named dataflow for leech's multi-input, multi-branch models). Architectures are declared in TOML underleech/models/configs/. Graphaccepts an optionalbuild_order: node declaration order still drives execution, but layers are constructed and installed inbuild_order. This is what lets a config reproduce a class whose module-construction order was not its dataflow order (a subclass appending layers after its parent's head), keeping bothstate_dict()key order and seeded initialization.- Layer registry entries for the TCN family:
tcn,temporalblock,normmlphead,normproj,slice,signalchannelandrangemeanpool. TCNDwellResidualMotor,TCNDwellResidualLNMotor,TCNDwellResidualDwellAttnandTCNDwellResidualLNDwellAttnare now registered models (29 total).SignalCNN, a signal-only classifier for tasks where only the raw signal carries the label (e.g. barcode demultiplexing from adapter signal). It ignores the sequence and feature inputs inforward()while still honoring the standard batch contract, so it reuses the existing Trainer /collate_fn/ModelInferenceWrapperstack rather than duplicating it. AddsSignalDatasetandcompute_class_weights_from_labels.- Model release workflow:
leech model release,leech model listandleech model fetchpublish trained bundles to GitHub Releases, browse what is available, and download them. Release metadata comes from a YAML spec that merges editorial fields (description, organism, metrics) with technical bundle metadata (architecture, pairs) into generated release notes. Tag convention:model-{name}-v{version}. Implemented in the newleech/release/subpackage over theghCLI, so no new Python dependency. - Training-loop features ported from bonito, all off by default:
--grad-accum-split Nsplits a batch into N sub-batches and steps the optimizer once (larger effective batch without the memory);--quantile-grad-clipclips at 2x the median of the last 100 gradient norms instead of a fixed threshold;--save-optim-every Nwrites optimizer state only every N epochs while still writing weights on every save. --confoundnow accepts an inlinesource:mapping[:table]spec (e.g.source_group:lookup:groups.json) in addition to the built-in aliases, and a new--confound-configreads a JSON/YAML confound definition. Both resolve to one canonical token at the CLI boundary.
- The k-mer level mechanics moved down to escapepod-signal
(rnabioco/escapepod-rs#204/#205), which is now their canonical home:
leech_core'sextract_levelsdelegates there (same f64 numerics), the deadrough_rescalebinding (per-base-mean variant, no callers) is removed, and a newrough_rescale_quantilebinding delegates the quantile rough rescale —leech.signal_refine.rough_rescale_quantilenow dispatches to it for float32 inputs whenleech_coreis available. escapepod pins the parity with leech's NumPy implementations bit-for-bit in golden-vector tests, so leech andescpodcan no longer drift on the k-mer residual's definition. Pure-Python fallbacks are unchanged. escapepod-signal is pinned by rev until the next escapepod release. - The ConvLSTM family (10 registry names) is now declared in
models/configs/conv_lstm.tomlandconv_lstm_attn.tomlinstead of the hand-writtenmodels/conv_lstm.py/conv_lstm_attn.py, which were removed. - The TCN family (12 registry names) is now declared in
models/configs/tcn_dwell.toml,tcn_dwell_residual.toml,tcn_dwell_split_residual.toml,tcn_dwell_residual_motor.tomlandtcn_dwell_residual_dwell_attn.toml. The five hand-written modules (models/tcn_dwell*.py) were removed; their reusableTemporalBlockandTCNblocks moved tomodels/components.py.state_dict()keys and their order, seeded initialization, and forward outputs are unchanged — existing checkpoints and bundles load as before. (The two*Motornames are the one exception to seeded-init parity: their old class built a classifier head and immediately discarded it, consuming random numbers the graph does not. Keys, key order and outputs still match, and those names were never in the registry before this release, so no checkpoint depends on it.) kind = "class"configs are gone;kind = "graph"is now the only config kind, so there is a single config semantics rather than two.- Renamed
leech.features.normalize_signaltonormalize_read_signal. The old name collided withescapepod.normalize_signal, which is a different transform (int16 input, no 1.4826 scale factor) that would silently rescale every signal in the pipeline if swapped in by name. median_madnormalization now returnsfloat32rather thanfloat64, matching the Rust path. Values agree with the previous output to f32 precision (~7e-8 relative).- The
acBAM tag now always carries the winning class probability. It previously carried1.0 - conffor reads filtered by--min-confidence/--min-margin, so a filtered read atmax_prob=0.4reportedac=0.6and read as more confident than a read that passed at 0.5. The below-threshold sentinel in theaatag is now the only signal that a call was filtered, and it moved toconstants.BELOW_THRESHOLD_LABEL. - Bundles now record
pair_labels(pair →[negative, positive]), derived from each model's ownlabel_map.resolve_pair_labels()prefers that map and falls back — warning once — to the oldpair.split("_", 1)for bundles built before the field existed. - The adversarial (gradient-reversal) training path is now config-driven. A
new
confounds.pydescribes a confound assource(which chunk field) ×mapping(identityorlookup), replacing the hardcodeddisc_base/trna_idbranch intraining.pyand the two parallel maps indataset.py.--confound disc_baseand--confound trna_idbehave exactly as before. - The model registry is now a torch-free
_MODEL_SPECStable (name → submodule, class). Model classes — and therefore torch — load only on actual access, soleech model train -hno longer pays a ~10s torch import just to render its--modelchoices. - Training now uses fused SDPA attention (
need_weights=Falseat everynn.MultiheadAttentioncall site, which is what lets PyTorch 2.x dispatch to the flash / memory-efficient kernel), fused AdamW on CUDA, and TF32 matmuls (torch.set_float32_matmul_precision("high"), matching the eval and inference paths). Behavior-preserving; existing checkpoints load and run identically. The trainingDataLoadergenerator is also seeded from the run seed, so shuffle order and per-worker augmentation RNG are reproducible regardless of how much global RNG model init consumed. --scheduler cosineis now a singleLambdaLRwarmup-plus-cosine schedule that owns its own warmup, replacing a manual linear warmup bolted ontoCosineAnnealingLR. The LR now holds at the1e-6floor past the epoch budget instead of cycling back up.reduce_on_plateauis unchanged.- Resuming from a checkpoint with no optimizer state now warns and continues
with a fresh optimizer instead of raising
KeyError. escapepodmoved from the optionalpod5extra into required dependencies —leech.io.pod5_readerandleech.featuresboth import it unconditionally, so a base install previously failed onimport leech.io. Thepod5extra is retained as a no-op alias.- Dropped the
escapepod-rsgit submodule. Now that the repository is public,escapepod-signalis a tag-pinned git dependency inrust/Cargo.tomland theescapepodPython package installs from PyPI as a prebuilt wheel, so a plaingit clone+uv syncis enough to build leech. - Bumped
escapepodto v0.6.3, which fixes amad_normalizeabort on constant (dead-pore or flat) signal that could kill a long run on a single bad read. - CI no longer needs the
ESCAPEPOD_PATsecret, so dependabot now tracks GitHub Actions and therust/cargo manifest too. - The pipeline's
motifconfig key is now required rather than defaulting to"CCA". A wrong motif does not fail — it silently produces a whole run of chunks centered on the wrong base — and the shipped config,prepare.smkanddiagnose.smkdisagreed on the default. The motif offset default drops to 0.
- The
diagnoseSnakemake rule and itsall_diagnosetarget. Both referenced files removed in a60eb09 (../envs/leech.yaml,scripts/diagnose_signal_orientation.py) and could not run. This also retires thelabel != "uncharged"filter, the one place the DAG branched on a hardcoded class name.
- Signal-map refinement corrupted every level-derived feature, in two ways.
DWELL_TARGETwas hardcoded to 4.0 samples/base while RNA004 at 130 bps and 4 kHz sits near 31, so the asymmetric penalty treated every base as ~8x too long; the target now resolves from the read's own move-table median. And rough rescale rewrote the signal with escapepod's affine fit, discarding the shared median-MAD normalization the per-base stats, k-mer residuals and trained models are calibrated against — the fit is estimated on a chunk sitting largely in a constant 3' adapter, where it is weakly identified (observed scales from 15 to 1084, frequently negative, i.e. sign-flipping the read). Refinement now takes only the refined boundaries. Measured on tRNA-Met chunks, per-base level vs the expected 9-mer level went from r = -0.03 to r = +0.82, and Met/HPG discrimination recovered from AUC 0.681 to 0.822. - Four architectures listed in
ModelInferenceWrapper.FEATURE_MODELSwere never added to the model registry, soget_model()rejected them while the inference path assumed they existed. They are registered now, with explicit constructor signatures — previously their**kwargsconstructors mademodel_loading._instantiate_modelsilently drop themotor_*/num_dwell_*parameters when rebuilding a model from a saved config. - POD5 read lookups now use escapepod's read-id index.
DatasetReaderwas constructed but never entered, and entering it is what warms the index — so everyreads(selection=...)call re-scanned the whole reads table, making a per-read lookup O(reads-in-file). Affects both the cached module-level reader andPOD5Reader. median_madnormalization no longer returns an all-NaNsignal for a constant (dead-pore or flat) read. It now delegates toescapepod.mad_normalize, the same routineleech_corealready used, so the Python and Rust normalization paths agree by construction rather than by two parallel implementations.- Class names containing
_no longer produce garbage aggregation keys. The aggregators recovered class names withpair.split("_", 1), but class names are free-form (they come from directory names andlabel_map), soaggregate_one_vs_allsilently inverted the prediction for e.g.notzeta_zeta. - The
grid_searchpipeline rule passed--param-gridand--max-epochs, neither of whichleech model optimizeaccepts, so it failed at argument parsing every time it was scheduled. The rule, its config block anddocs/pipeline.mdnow match whatoptimizeactually searches (signal context windows and dwell offset), and agrid_search_parallelrule makes the existing--paralleloption reachable.
- Mark leech as alpha-quality in the README and docs front page
--focus-tsvfor per-read labels and externally-anchored chunk extraction- POD5 directory (not just a single file) accepted as a preparation source
leech model benchmarkfor training-step profiling- Soft-clipped signal recovery at reference-anchored chunk edges
- Kmer table fingerprint captured in model config for provenance
- Bumped
escapepod-rsto v0.6.0 - Delegated Python signal-map refinement to escapepod with a reproducible Theil-Sen seed
- Access POD5 signal via escapepod's
DatasetReader - Delegated banded DP and MAD normalization to
escapepod-signal - Improved multiclass training stability (memory, optimizer, selection metric,
disc_baseconfound) - Faster preparation: bypass the Rust pipeline and early-skip reads when a focus map is set
- Compute multiclass AUROC per-class to avoid the sum-to-1 trap
- Migrated the docs site to
zensical.toml
trna_idadversarial confound for full isoacceptor identity debiasing- Rust-accelerated training chunk extraction with rayon parallelism
- Dwell template features, motor/dwell-attn models, and rough-rescale option
--no-compressflag and streaming BAM for data preparation--split-byfor group-level train/test splitssignal_mode, TCNDwellSplitResidual, and per-channel augmentation--copy-tagsoption to copy BAM tags into TSV predict output- leech version and git commit captured in model config and bundles
- Rust monolithic extraction for bundle inference
leech_coreparallelization viamp.Poolworkers for bundle inference
- Bumped
escapepod-rsto v0.1.3 (SSSE3 SIMD SVB16, audit-driven hot-path optimizations, dynamic versioning) - Replaced
pod5Python package withescapepodbindings for POD5 I/O escapepodmoved to optionalpod5extra for pixi compatibility- Config.json is now the source of truth for model construction
- Replaced hardcoded model allowlists with signature introspection
- Consolidated
models/from 27 to 13 files - Split
util.pyintomodel_loading,model_export,bundling,metrics - Split
inference.pyintoinference/package - Split Rust
inference_pipeline.rsinto 8 submodules - Queue-based extraction pipeline for improved GPU inference throughput
- Module-level
npshadowing from local numpy imports _parse_and_validate_inputsreturn type annotation- Plumb
dwell_template_tablethrough calibrate/eval/predict - Byte-identical Rust↔Python parity for CIGAR ref-to-signal mapping
- Pass
signal_in_channelsto model during calibration - Resolve remaining clippy warnings in Rust code
- Excluded
vulture_whitelist.pyfrom ruff linting - CI submodule checkout with
ESCAPEPOD_PAT; ruff format compliance - Benchmark script ruff lint errors
- TSV prediction output (
TsvPredictionWriter) as alternative to BAM tag output for multiclass models - escapepod-rs integration for ~10x faster Rust-accelerated inference via POD5 batch reads
- Multiclass temperature scaling calibration with ECE improvement gating
- Adversarial training with gradient reversal layer and confound maps for discriminator base debiasing
- CL regression head for multi-task charging level prediction
- Cross-layer augmentation: time masking, cross-layer shift, per-channel feature noise
- Mega-batch streaming inference with double-buffered GPU pipeline
--signal-contextCLI option forleech data prepareto set asymmetric signal windows--min-confidenceand--min-marginthresholds forleech predict--oversample-minorityflag for class imbalance handling- Auto-read
anchorandreference_fastafrom model config at predict time am(margin) BAM tag in predict outputenable_repr_capture()forModelInferenceWrapperinternal activations- Centralized Rich console with wide fallback for SLURM batch jobs
- TCNDwellResidualGN and TCNDwellResidualLN model variants (22 total architectures)
- Label smoothing and cosine annealing scheduler options
py.typedPEP 561 marker for type checker support- vulture dead code detection in dev tooling
- K-fold merge now caches input files in RAM for faster processing
- Array-level merge without zlib compression for merge step
- Disable DataLoader workers for validation to reduce memory usage
- Checkpoint multiclass models on
val_f1instead ofval_acc - Development status upgraded from Alpha to Beta
- Version string now uses
importlib.metadatawith git hash fallback - Removed seaborn from notebook extras (plotnine-only policy)
- Bumped leech_core to v0.3.0 and Rust edition 2024
- Store
focus_signal_posin chunks for asymmetricsignal_context - Enable TF32 matmul precision in inference path
- Training summary reports actual best
val_acc/F1 instead of last epoch - Use
tolist()for multiclasspos_weightserialization in config - Handle k-fold directories in multiclass bundle discovery
label_map.jsonlookup for k-fold adversarial training- Use
reads_by_ids()for O(1) indexed POD5 lookup instead of full scan - Gate multiclass temperature scaling on ECE improvement
- Route multiclass through parallel inference path
- FASTA index race condition under parallel SLURM jobs
- Label smoothing no longer alters labels used for metrics
- Propagate
pa_mean,pa_stdev,skip_motif_indels, and refiner params through config chain - Align signal map refinement
scale_itersbetween prepare and inference - Auto-read
base_justifyfrom model config in single-model inference - Use reference-based motif search in inference
- Signal kmer coordinate adjustment and
reference_fastaplumbing - Default
skip_motif_indelstoFalseeverywhere
- Prefetch pipeline with rayon contention fix in inference
- Sub-batch extraction with async BAM writes for GPU pipelining
- Multi-threaded extraction in sequential inference path
- Optimized inference pipeline for fast prediction
- Dead code:
config.py(replaced byconfigs.py),calibrate_model_temperature(),_is_leech_export(),load_predictions_from_bam(),prepare_chunks_with_context(),extract_disc_bases_from_fasta(),handle_branch_contribution(),display_logo() - Unused constants:
DEFAULT_DWELL_MARGIN_LEFT/RIGHT,DEFAULT_NUM_WORKERS,DEFAULT_SEQ_ENCODING,DEFAULT_MIN_MAPQ,DEFAULT_MOTIF,DEFAULT_MOTIF_OFFSET,DEFAULT_REMORA_NUM_OUT - Unused Rust accel imports:
_rs_test_process_read,_rs_extract_levels,_rs_rough_rescale - Unused IO methods:
BAMReader.get_header(),BAMReader.count_alignments(),POD5Reader.get_signals_batch(),POD5Reader.iter_all_reads(),ReferenceManager.get_all_sequences()
- 12 new model architectures (20 total): BatchNorm (BN), Attention, GroupNorm (GN), LayerNorm (LN) variants for ConvLSTM; TCNDwellGN, TCNDwellLN, TCNDwellResidual; TransformerDwellResidual
- Multi-channel signal input (
signal_in_channels) across all architectures for 2-channel (raw + kmer residual) models - Kmer residual features:
kmer_expected,kmer_residual,kmer_residual_absfrom kmer level table lookup - Signal map refinement rewritten to match Remora's banded Viterbi algorithm with iterative Theil-Sen rescaling
- Signal-level kmer encoding (
signal_kmer) as default sequence encoding - Reference-anchored mode (
--anchor reference) andpa_scalingnormalization - Composable config dataclasses (
configs.py) replacing 6-layer parameter threading feature_start/feature_endparameters replacing confusingdwell_marginparams- Dwell cross-attention in TransformerDwell, TCNDwell, ResNetDwell, ConvOnly
- Multi-class classification with confidence-weighted and tournament pairwise aggregation
- K-fold cross-validation with stratified read-level splits (
--k-fold) - Balance-groups sampling for equal source group contribution per epoch
- Platt scaling calibration (
leech model calibrate) with guardrails and best-fold selection - TorchScript export (
leech model export) for standalone model deployment - Rust-accelerated signal statistics via PyO3 (
leech-corecrate) check-rustCLI command to verify Rust extension availability- Auto-load bundled kmer table when
--refine-signal-maphas no--kmer-table --reference-fastasupport inleech predictfor reference-anchored bundle inference- Remora-compatible model variants (ConvLSTMRemora, ConvLSTMRemoraBase)
- Parallel inference with batch POD5 reads in workers
- GitHub Actions release workflow with platform wheel builds (linux x86_64/aarch64, macOS x86_64/arm64)
- Default sequence encoding from
base_onehottosignal_kmer - Default
scale_itersfrom 0 to 2 for signal map refinement - Migrate model export from
torch.jittotorch.export(PyTorch 2+) - Unify
--anchorflag acrossprepareandpredictcommands - Lazy-load
MODEL_REGISTRYto speed up CLI help (5.3s to 0.6s) - Lazy imports in
__init__.pyto cut CLI startup from ~9s to ~0.5s - Rust signal stats 217x faster than NumPy; pre-tensorize dataset; flatten serialization
- Enable
torch.compileon CPU/GPU, TF32 matmul precision,inference_modein eval - Optimize test suite runtime from 287s to ~20s
- Speed up
leech eval testwith GPU optimizations and larger batch size - Refactor CLI handlers into
commands/subpackage - Upgrade PyO3 and rust-numpy from 0.23 to 0.28
- Ensure
model_best.ptalways exists after training resume - Load all checkpoints/bundles to CPU first to avoid device mismatch
- Batch bundle inference for GPU utilization
- Normalize
signal_in_channelsin architecture config comparison - Return reference-relative coords from ReferenceMotifSearcher when
anchor=reference - Prevent
num_outfrom leaking into model constructors that don't accept it - Resolve constructor params for
**kwargssubclasses in_instantiate_model - Set
num_workers=0in evaluation DataLoader to reduce memory usage - Fix
torch.compile_orig_modprefix in checkpoint loading - Fix missing
model_best.ptwhen resuming completed training - Fix bundle discovery for k-fold CV and batch size for small datasets
- Fix evaluation to use softmax for cross-entropy (multi-output) models
- Fix stale FASTA index by regenerating .fai before opening
- Fix DataLoader workers in parallel grid search
- Rename ResNetDwell
bn1/bn2tonorm1/norm2to match checkpoint migration - Strip explicit kwargs from model config to prevent duplicates
- Tunable
dwell_offsethyperparameter for motor-sensor offset correction base_justifyparameter to control signal chunk centering ("start", "center", "end")- Range syntax (
start:stop:step) for grid search context parameters best_params.jsonoutput from grid search for Snakemake integration- Self-documenting run summary for pipeline runs
- Motor-pore offset analysis notebook
- Reorganize CLI into workflow-based command groups:
data,model,eval,predict - Speed up grid search with CPU optimizations and parallel execution
- Migrate docs from MkDocs + Material to Zensical
- Switch LSF profile to use snakemake-executor-plugin-lsf
- Consolidate guides into 3 professional documentation pages
- Replace mypy with ty for type checking
- Deduplicate
_TRAINING_PARAMSset and extract_instantiate_model()helper in util.py - Consolidate
FEATURE_MODELSset (dataset.py now references ModelInferenceWrapper) - Standardize project acronym across docs, pyproject.toml, and CLAUDE.md
- O(n²) BAM scan in inference.py: build alignment dict in one pass instead of rescanning per read
- Off-by-one error in
to_seq_to_sig_mapto match Remora convention - Default
min_mapqfiltering that drops most tRNA reads - TypeError from grid search context params passed to model constructor
- Python badge in docs/index.md now shows 3.12+ (matches requires-python)
- Stale
data_prep.py.bakbackup file
Initial alpha release of leech for aa-tRNA-seq nanopore signal classification.
- Complete CLI with 6 commands:
prepare,merge-and-split,train,test,infer,grid-search - Feature extraction from POD5 and BAM files with move table parsing for dwell time computation
- Six model architectures: ConvLSTMDwell, ConvLSTMBase, TransformerDwell, ConvOnly, TCNDwell, ResNetDwell
- Parallel data preparation with multiprocessing support (8 workers default)
- Reference-based motif search to prevent training bias from basecalling errors
- Read-level data splitting to prevent leakage in multi-sample datasets
- Class weighting for imbalanced datasets
- CPU/GPU training support with automatic device detection
- Rich CLI with progress bars and modern interface
- Grid search for chunk context optimization
- Automated GitHub release workflow
- Multi-sample merge-and-split with label=file syntax
- TSV-based comparison specifications for batch processing
- Parallel POD5/BAM processing with configurable workers and chunk size
- Reference-based and basecalled motif search strategies
- Optional indel filtering at motif sites
- Training with early stopping, checkpointing, and validation
- Comprehensive metrics: accuracy, precision, recall, F1, ROC AUC, confusion matrix
- Grid search over signal context parameters
- Model checkpoint management with best model tracking
- Complete MkDocs documentation site with API reference
- CLI usage guide with all commands documented
- Architecture documentation and ADRs (Architecture Decision Records)
- Guides for cluster setup (Alpine/SLURM, Bodhi/LSF)
- Troubleshooting and implementation guides
- Complete test suite with pytest
- Modern tooling: uv for dependencies, ruff for linting, ty for type checking
- GitHub Actions CI/CD with linting, testing, and documentation deployment
- Snakemake pipeline for production workflows