Skip to content

CRF Module

CTC-CRF sequence models: encoder, training objective, and decode.

Overview

A second task alongside leech's chunk classifiers. Where a classifier maps a signal window to a label, leech.crf maps one to a sequence: a CRF over n_base ** state_len states whose Viterbi traceback emits one base per move. It is what the barcode basecallers in escapepod-models are trained with, and what escapepod-demux's Rust decoder runs in production.

import torch
from leech.crf import CrfEncoder, CtcCrfLoss, decode_batch, encoder_config_from_toml, load_config

cfg = encoder_config_from_toml(load_config())      # packaged default geometry
model = CrfEncoder(cfg)
criterion = CtcCrfLoss(cfg.n_base, cfg.state_len)

scores = model(signal)                              # (N, 1, chunk) -> (T, N, n_score)
loss = criterion(scores.float(), targets, target_lengths)
sequences = decode_batch(scores, cfg.n_base, cfg.state_len)

Importing the subpackage costs nothing but torch and numpy, and only when a symbol that needs them is touched — never pysam or escapepod. That is deliberate: escapepod-models installs leech into a conda-forge environment with --no-deps and needs the CRF path alone. (load_manifest pulls polars, but only when you actually read a manifest.)

Four things to know before using it

The model cannot emit the first state_len bases of its target

They fix the initial state and nothing else, so a target_len-base target decodes to target_len - state_len bases — at any window width. Widening the signal window does not lengthen the decode. Size targets so the sacrificial bases come from a constant prefix, and match decodes against target[state_len:], never the full-length target. Matching against the full target still calls the right sequence, but inflates every edit distance and compresses the confidence margin that ranking depends on.

Blank is entry 0 of each state's group

score_index = state * (n_base + 1) + label, with label == 0 meaning stay. The score width is therefore n_states * (n_base + 1) = 1280 for the default geometry, not the linear layer's 1024. This is the layout escapepod-demux's Rust decoder assumes; moving the blank to the end of each group keeps every shape valid and makes every call wrong.

Output is time-major

(T, N, n_score), not (N, T, n_score). The boundary CNN in the same stack is batch-major [B, 2, L], so the two contracts sit next to each other and a consumer that assumes the wrong one silently transposes rather than failing.

The loss runs in fp32, outside autocast

The lattice scan accumulates over chunk // stride timesteps and fp16 loses the tail of that sum. Autocast the encoder — that is where the matmuls and the speed are — and cast the scores back before the loss.

The manifest seam

leech.crf cuts a corpus from a manifest: one row per read, naming what leech needs and nothing about where it came from.

column required meaning
read_id yes the read, as POD5 and BAM both name it
pod5 yes file or directory holding that read's signal
anchor_end yes signal index the window ends at (exclusive)
target yes the resolved CRF target sequence
label no class name, for evaluation and reporting
group no reporting/balancing bucket (defaults to label)
batch no acquisition batch, for leave-one-batch-out holdout
quality_score / quality_margin no label quality, gated at training time
split no train/test, when the producer carved one
from leech.crf import load_manifest, check_geometry

man = load_manifest("manifest.parquet", require=("batch",))
check_geometry(window=3000, target_len=48, samples_per_base=56.0)
print(len(man), man.batches(), man.quality_coverage())

Everything above the manifest is vocabulary — panels, codes, oligos, gates — and belongs to whatever project defines those. Everything below is signal ML. There is deliberately no keep boolean: quality travels as numbers so the gate stays sweepable without re-cutting the corpus.

Building a corpus

plan_corpus decides which reads and in which split, touching no POD5; build_corpus then extracts their signal, streaming it to a memory-mappable <out>_X.npy beside a <out>_meta.npz. The split is deliberately separate: it is where every subtle rule lives, and it is testable without a gigabyte of fixture.

from leech.crf import load_manifest, plan_corpus, build_corpus, load_corpus

plan = plan_corpus(load_manifest("manifest.parquet"), chunk=3000, per_group="auto")
print(len(plan), plan.cap, plan.counts_by_split())
build_corpus(plan, "corpus")
signal, targets, groups, read_ids, split = load_corpus("corpus")   # signal is mmap'd

Four rules it enforces, each of which fails silently when broken:

  • A cap only caps if every class can reach it. per_group="auto" uses the rarest class's trainable depth (the test fraction is reserved first), which is the only value that actually balances. A larger explicit cap warns and de-balances the corpus it was meant to balance.
  • The split is carved before capping, ranked per class and globally across batches. Ranking per (batch, class) multiplies the cap by the number of batches whenever classes are crossed with batch.
  • Batches are interleaved, not concatenated. Otherwise the whole test set comes from whichever batch sorts first and the headline number measures batch.
  • Shard after planning. The plan is deterministic in (manifest, seed), so each shard keeps its share of one global split; filtering first would give each shard its own test set drawn from one batch.

Training

from leech.crf import CrfTrainer, CrfTrainConfig

result = CrfTrainer(
    "corpus",
    config=CrfTrainConfig(epochs=32, batch_size=256, lr=2e-3, seed=0),
    output_dir="run/",
).train()

Writes run/model.pt and run/model.json. The sidecar is not optional: the standardisation constants live in neither the architecture config nor the checkpoint, so a consumer holding only weights cannot reproduce them and decodes silently worse.

This is a separate trainer from leech.training.Trainer, which is classification-locked through pos_weight, num_out, BCE/focal/CE and AUROC/F1 checkpointing. Five things it does that are easy to get wrong:

  • The loss runs in fp32, outside autocast. The lattice scan accumulates over chunk // stride timesteps and fp16 loses the tail. The encoder still gets autocast — that is where the matmuls are.
  • Standardisation is streamed over the corpus in float64, so a corpus larger than RAM costs nothing to summarise.
  • The quality gate is applied here, not at extraction, which is what keeps it sweepable. Partial score coverage is refused rather than silently training on a non-random subset.
  • The last epoch is not automatically shipped. select_checkpoint falls back to the best epoch when the last is worse by more than select_tol (default 25%) — a divergence detector, not a ranking, because training loss does not rank models at this scale.
  • Per-epoch stats separate failure modes: the worst single batch, the largest pre-clip gradient norm, discarded GradScaler steps, and non-finite gradient counts. An epoch mean alone cannot tell one blown batch from a thousand mediocre ones.

Exporting for a native runtime

export_crf_onnx writes crf_encoder.onnx plus a metadata.json contract. The encoder only — the decode is not expressible in standard ONNX ops, which is why escapepod-demux owns it.

from leech.crf.export import export_crf_onnx

export_crf_onnx("run/", "export/", sidecar="run/", references={"code01": target})
input   signal  [batch, 1, chunk]                 float32, BATCH-major
output  scores  [chunk // stride, batch, n_score] float32, TIME-major

Time-major output is the trap: the boundary CNN in the same stack is batch-major [B, 2, L], so a consumer reusing that assumption silently transposes rather than failing, and needs its own load-time shape probe.

The sidecar is not decoration. Standardisation is in neither the architecture config nor the checkpoint — the trainer derives it from the corpus — so a consumer holding only weights cannot standardise and decodes silently worse. Passing references= writes what the model emits (target[state_len:]), computed once from the state_len the encoder declares, so no caller can supply full-length targets by hand and inflate every edit distance.

Requires the onnx extra: uv sync --extra onnx.

Evaluating

leech.crf.evaluate is the generic half of scoring: decode a corpus, match each decode to its nearest reference, report per group. What a panel is — which classes exist, which share a flowcell — stays with whatever defines the panel.

from leech.crf import (decode_corpus, emitted_references, call_references,
                       balanced_recall)

refs = emitted_references(targets, state_len=4)      # target[state_len:]
decodes = decode_corpus(model, signal, test_idx, mean=..., std=..., chunk=3000)
calls = call_references(decodes, refs, candidates=classes_in_this_group)
report = balanced_recall(truth, calls, groups)

Three rules it holds:

  • Match against what the model emits. Scoring against full-length targets forces state_len leading deletions into every alignment — inflating every distance and compressing the margin, since an aligner places those deletions where they help most, discounting wrong references more than the right one.
  • Report per group, never one pooled table. When classes are crossed with batch, a 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 rather than reporting null.
  • Balanced, not raw, recall. A pooled accuracy over unbalanced classes is dominated by the deepest class.

lev_vs_refs scores one decode against the whole reference set at once — the shape of every evaluation loop. It recovers the serial insertion term exactly as j + cummin(tmp[k] - k), and that identity is asserted against the scalar implementation rather than assumed. edlib is used when importable; the pure Python fallback stays named so the two can be compared on machines that have both.

Encoder

CrfEncoder

CrfEncoder(cfg: EncoderConfig | None = None)

Bases: Module

Raw signal -> CRF transition scores.

Input (N, 1, T_samples) standardised raw pA, batch-major. Output (T_samples // stride, N, n_score) float32, time-major.

Time-major output is not incidental: it is the layout the decoder consumes, and the boundary CNN's batch-major [B,2,L] contract is a different one. A consumer that assumes batch-major will silently transpose barcode calls.

Source code in src/leech/crf/encoder.py
def __init__(self, cfg: EncoderConfig | None = None) -> None:
    super().__init__()
    self.cfg = cfg or EncoderConfig()
    c = self.cfg

    # SiLU is swish: x * sigmoid(x). torch has had it natively since 1.7, so
    # there is no reason to carry a custom activation.
    self.conv = nn.Sequential(
        nn.Conv1d(1, 4, kernel_size=5, stride=1, padding=2),
        nn.SiLU(),
        nn.Conv1d(4, 16, kernel_size=5, stride=1, padding=2),
        nn.SiLU(),
        nn.Conv1d(16, c.features, kernel_size=c.winlen, stride=c.stride, padding=c.winlen // 2),
        nn.SiLU(),
    )
    # Layer i runs backwards when (n_layers - i) is odd, so the last layer
    # is always reversed. Matching this exactly matters: get the parity
    # wrong and the weights still load, the shapes still work, and the
    # output is quietly wrong.
    self.rnns = nn.ModuleList(
        _ReversibleLSTM(c.features, reverse=(c.n_layers - i) % 2 == 1)
        for i in range(c.n_layers)
    )
    self.linear = nn.Linear(c.features, c.n_states * c.n_base)

EncoderConfig dataclass

EncoderConfig(n_base: int = 4, state_len: int = 4, features: int = 96, winlen: int = 31, stride: int = 10, scale: float = 5.0, blank_score: float = BLANK_SCORE, n_layers: int = 5, chunk: int = 3000)

Geometry of the encoder. Defaults are the shipped barcode models'.

chunk is not used to build the module — the convolutions are length-agnostic — but it records what the model was trained at, since the CRF's window and target are coupled (see issue #36).

n_states property

n_states: int

Number of CRF states: n_base ** state_len.

n_score property

n_score: int

Width of the score output: one blank + one per base, per state.

t_len property

t_len: int

Output timesteps for a chunk-sample window.

encoder_config_from_toml

encoder_config_from_toml(cfg: dict) -> EncoderConfig

Build an EncoderConfig from the architecture TOML.

One reader for the config, so the trainer, the exporter and every evaluation script derive geometry the same way instead of each reaching into the dict for the keys it happens to need. n_base is the alphabet minus the CTC blank; it is not a key of its own, and reading it as one is a KeyError that only fires on the config's first use.

Source code in src/leech/crf/encoder.py
def encoder_config_from_toml(cfg: dict) -> EncoderConfig:
    """Build an `EncoderConfig` from the architecture TOML.

    One reader for the config, so the trainer, the exporter and every evaluation
    script derive geometry the same way instead of each reaching into the dict
    for the keys it happens to need. `n_base` is the alphabet minus the CTC
    blank; it is not a key of its own, and reading it as one is a `KeyError`
    that only fires on the config's first use.
    """
    enc = cfg.get("encoder", {})
    return EncoderConfig(
        n_base=len(cfg["labels"]["labels"]) - 1,
        state_len=cfg["global_norm"]["state_len"],
        features=enc.get("features", 96),
        winlen=enc.get("winlen", 31),
        stride=enc.get("stride", 10),
        scale=enc.get("scale", 5.0),
        blank_score=enc.get("blank_score", BLANK_SCORE),
    )

load_crf_state_dict

load_crf_state_dict(model: CrfEncoder, state_dict: dict[str, Tensor], *, strict: bool = True) -> CrfEncoder

Load a checkpoint into this module, in either naming.

Accepts both because both exist and will keep existing: every model shipped before the trainer moved off bonito carries bonito's positional names (encoder.4.rnn.*), and everything trained since carries this module's (rnns.0.rnn.*). The parameters are identical — same layers, same shapes — so the only difference is the key, and refusing one naming would strand either the shipped models or the new ones.

Raises if the checkpoint contains anything unmapped, rather than silently leaving a layer at its random initialisation.

Source code in src/leech/crf/encoder.py
def load_crf_state_dict(
    model: CrfEncoder, state_dict: dict[str, Tensor], *, strict: bool = True
) -> CrfEncoder:
    """Load a checkpoint into this module, in either naming.

    Accepts both because both exist and will keep existing: every model shipped
    before the trainer moved off bonito carries bonito's positional names
    (``encoder.4.rnn.*``), and everything trained since carries this module's
    (``rnns.0.rnn.*``). The parameters are identical — same layers, same shapes —
    so the only difference is the key, and refusing one naming would strand
    either the shipped models or the new ones.

    Raises if the checkpoint contains anything unmapped, rather than silently
    leaving a layer at its random initialisation.
    """
    mapping = _legacy_key_map(model.cfg.n_layers)
    # Which naming, decided by prefix rather than by whether the keys happen to
    # map. bonito's live under its flat container (`encoder.<i>.…`); this
    # module's are `conv.*`, `rnns.*`, `linear.*`. Asking "do any keys map?"
    # instead would classify an unmapped bonito key as native naming and report
    # the mismatch as torch's generic missing-keys error rather than as the
    # unknown-layer error that says what actually went wrong.
    if not any(k.startswith("encoder.") for k in state_dict):
        model.load_state_dict(state_dict, strict=strict)
        return model

    unmapped = sorted(set(state_dict) - set(mapping))
    if unmapped and strict:
        raise KeyError(
            f"checkpoint has {len(unmapped)} key(s) this module does not know: "
            f"{unmapped[:5]}{'...' if len(unmapped) > 5 else ''}"
        )
    renamed = {mapping[k]: v for k, v in state_dict.items() if k in mapping}
    model.load_state_dict(renamed, strict=strict)
    return model

Loss

CtcCrfLoss

CtcCrfLoss(n_base: int = 4, state_len: int = 4)

Bases: Module

Negative log-likelihood of a target sequence under the CRF.

Targets are 1-indexed over the alphabet (0 is reserved), matching the encoder's score layout where label 0 is the stay edge.

The first state_len target bases are never emitted. They only fix the initial state, so a target_len-base target scores target_len + 1 - state_len states. Size targets accordingly — see issue #36, where a 40-nt target silently discarded the first four bases of a 27-nt barcode.

Source code in src/leech/crf/loss.py
def __init__(self, n_base: int = 4, state_len: int = 4) -> None:
    super().__init__()
    self.n_base = n_base
    self.state_len = state_len
    self.register_buffer("idx", predecessor_index(n_base, state_len), persistent=False)

normalise

normalise(scores: Tensor) -> Tensor

Subtract the full-lattice logZ, spread evenly across timesteps.

Source code in src/leech/crf/loss.py
def normalise(self, scores: Tensor) -> Tensor:
    """Subtract the full-lattice logZ, spread evenly across timesteps."""
    return scores - logZ_full(scores, self.idx)[None, :, None] / scores.shape[0]

gather_target_scores

gather_target_scores(scores: Tensor, targets: Tensor) -> tuple[Tensor, Tensor]

Pick out the stay/move scores along the target path.

targets is (N, L) 1-indexed. Returns (stay, move) shaped (T, N, n) and (T, N, n-1) for n = L + 1 - state_len target states.

Source code in src/leech/crf/loss.py
def gather_target_scores(self, scores: Tensor, targets: Tensor) -> tuple[Tensor, Tensor]:
    """Pick out the stay/move scores along the target path.

    `targets` is `(N, L)` 1-indexed. Returns `(stay, move)` shaped
    `(T, N, n)` and `(T, N, n-1)` for `n = L + 1 - state_len` target states.
    """
    t_len = scores.shape[0]
    n_edges = self.n_base + 1
    tgt = (targets - 1).clamp(min=0)
    n = targets.shape[1] - (self.state_len - 1)
    # State id of each window of `state_len` consecutive target bases.
    state = sum(
        tgt[:, i : n + i] * self.n_base ** (self.state_len - i - 1)
        for i in range(self.state_len)
    )
    stay_at = state * n_edges
    # Moving into the next state emits the base leaving the window, which is
    # the target base `state_len` positions back — i.e. tgt[:, :n-1].
    move_at = stay_at[:, 1:] + tgt[:, : n - 1] + 1
    stay = scores.gather(2, stay_at.expand(t_len, -1, -1))
    move = scores.gather(2, move_at.expand(t_len, -1, -1))
    return stay, move

forward

forward(scores: Tensor, targets: Tensor, target_lengths: Tensor, *, normalise: bool = True, reduction: str = 'mean') -> Tensor

Negative log-likelihood of targets, normalised over all paths.

Normalisation is applied algebraically, not by materialising it. normalise() subtracts logZ_full / T from every score, and every path through the target chain takes exactly one edge per timestep — so its partition function shifts by exactly logZ_full::

logZ_target(normalised) == logZ_target(raw) - logZ_full

Building the normalised tensor first cost 4.93 ms of a 14.68 ms loss at the training shape: 393 MB written and read back for a subtraction that cancels analytically. normalise() is kept because it is the readable statement of what this means, and the tests check the identity.

Source code in src/leech/crf/loss.py
def forward(
    self,
    scores: Tensor,
    targets: Tensor,
    target_lengths: Tensor,
    *,
    normalise: bool = True,
    reduction: str = "mean",
) -> Tensor:
    """Negative log-likelihood of `targets`, normalised over all paths.

    Normalisation is applied **algebraically, not by materialising it.**
    `normalise()` subtracts `logZ_full / T` from every score, and every path
    through the target chain takes exactly one edge per timestep — so its
    partition function shifts by exactly `logZ_full`::

        logZ_target(normalised) == logZ_target(raw) - logZ_full

    Building the normalised tensor first cost 4.93 ms of a 14.68 ms loss at
    the training shape: 393 MB written and read back for a subtraction that
    cancels analytically. `normalise()` is kept because it is the readable
    statement of what this means, and the tests check the identity.
    """
    scores = scores.to(torch.float32)
    stay, move = self.gather_target_scores(scores, targets)
    logz = logZ_target(stay, move, target_lengths + 1 - self.state_len)
    if normalise:
        logz = logz - logZ_full(scores, self.idx)
    loss = -(logz / target_lengths)
    if reduction == "mean":
        return loss.mean()
    if reduction in ("none", None):
        return loss
    raise ValueError(f"unknown reduction {reduction!r}")

predecessor_index

predecessor_index(n_base: int, state_len: int, device=None) -> Tensor

Incoming edges per state: (n_states, n_base + 1) of source-state ids.

Column 0 is the stay edge (the state itself); columns 1..n_base are the move edges, ordered by the base that falls out of the k-mer window. That ordering is not free choice — it has to match the score layout the encoder emits, state * (n_base + 1) + label, or the scores get paired with the wrong transitions and nothing complains.

Source code in src/leech/crf/loss.py
def predecessor_index(n_base: int, state_len: int, device=None) -> Tensor:
    """Incoming edges per state: `(n_states, n_base + 1)` of source-state ids.

    Column 0 is the stay edge (the state itself); columns `1..n_base` are the
    move edges, ordered by the base that falls out of the k-mer window. That
    ordering is not free choice — it has to match the score layout the encoder
    emits, `state * (n_base + 1) + label`, or the scores get paired with the
    wrong transitions and nothing complains.
    """
    n_states = n_base**state_len
    states = torch.arange(n_states, device=device)
    stay = states[:, None]
    # Predecessors share our first state_len-1 bases: shift right, then vary the
    # base that was dropped off the front.
    shifted = states // n_base
    moves = shifted[None, :] + (
        torch.arange(n_base, device=device)[:, None] * n_base ** (state_len - 1)
    )
    return torch.cat([stay, moves.T], dim=1).long()

Decode

decode_batch

decode_batch(scores: Tensor, n_base: int = 4, state_len: int = 4, alphabet: str = ALPHABET) -> list[str]

Scores (T, N, n_states*(n_base+1)) -> one sequence per read.

Drop-in for bonito.crf.model.Model.decode_batch, including its output type: a plain list of str, one per read, in batch order.

The emitted sequence is target[state_len:] of whatever the model was trained on — the model cannot emit the first state_len bases at any window width (issue #36). Match decodes against ldxlib.emitted_refs(), not against the full-length target.

Source code in src/leech/crf/decode.py
@torch.no_grad()
def decode_batch(
    scores: Tensor,
    n_base: int = 4,
    state_len: int = 4,
    alphabet: str = ALPHABET,
) -> list[str]:
    """Scores `(T, N, n_states*(n_base+1))` -> one sequence per read.

    Drop-in for `bonito.crf.model.Model.decode_batch`, including its output type:
    a plain list of `str`, one per read, in batch order.

    The emitted sequence is `target[state_len:]` of whatever the model was
    trained on — the model cannot emit the first `state_len` bases at any window
    width (issue #36). Match decodes against `ldxlib.emitted_refs()`, not against
    the full-length target.
    """
    dest, edge = best_path(scores, n_base, state_len)
    # Emit `dest % n_base` on a move, blank (0) on a stay: see the module note on
    # why the base comes from the destination and not from the edge index.
    symbols = torch.where(edge > 0, dest % n_base + 1, torch.zeros_like(dest))
    out = symbols.t().to(torch.uint8).cpu().numpy()  # (N, T), batch-major

    # Byte lookup, then one `tobytes()` per READ. The obvious form,
    # `"".join(alphabet[s] for s in row if s)`, is one interpreter step per
    # SYMBOL — 76,800 of them for a 256-read batch at T=300, which profiled at
    # 3.79 ms, the second-largest cost in the whole decode behind the encoder.
    # This is the same computation with the per-symbol work pushed into numpy.
    lut = np.frombuffer(alphabet.encode(), dtype=np.uint8)
    chars = lut[out]
    blank = lut[0]
    return [row[row != blank].tobytes().decode() for row in chars]

best_path

best_path(scores: Tensor, n_base: int = 4, state_len: int = 4) -> tuple[Tensor, Tensor]

Per-timestep best edge, as (destination_state, edge) tensors (T, N).

edge == 0 is the stay/blank edge and emits nothing. Exposed separately from decode_batch because the position profile analysis needs where bases were emitted, not just the string (scripts/ldx/decode_position_profile.py).

Source code in src/leech/crf/decode.py
def best_path(scores: Tensor, n_base: int = 4, state_len: int = 4) -> tuple[Tensor, Tensor]:
    """Per-timestep best edge, as `(destination_state, edge)` tensors `(T, N)`.

    `edge == 0` is the stay/blank edge and emits nothing. Exposed separately from
    `decode_batch` because the position profile analysis needs *where* bases were
    emitted, not just the string (`scripts/ldx/decode_position_profile.py`).
    """
    n_states, n_edges = n_base**state_len, n_base + 1
    t_len, batch, width = scores.shape
    if width != n_states * n_edges:
        raise ValueError(
            f"score width {width} != n_states*(n_base+1) = {n_states * n_edges}; "
            f"1024 here means the blank was never expanded per state"
        )

    ms = scores.to(torch.float32).reshape(t_len, batch, n_states, n_edges)
    _, post = _full_impl(ms, n_base, n_states)
    lp = torch.log(post + POSTERIOR_FLOOR)
    best = _best_edges_impl(lp, n_base, n_states)
    return best // n_edges, best % n_edges

Manifest

load_manifest

load_manifest(path: str | Path, *, require: tuple[str, ...] = ()) -> CrfManifest

Read and validate a manifest from parquet, TSV or CSV.

Parameters:

Name Type Description Default
path str | Path

manifest file; format is taken from the suffix.

required
require tuple[str, ...]

optional columns this caller additionally needs. Named up front so the failure is "your manifest has no batch column" rather than a KeyError an hour into a run.

()

Raises:

Type Description
FileNotFoundError

the manifest is not there.

ValueError

a required column is missing, the table is empty, or read_id is not unique.

Source code in src/leech/crf/manifest.py
def load_manifest(path: str | Path, *, require: tuple[str, ...] = ()) -> CrfManifest:
    """Read and validate a manifest from parquet, TSV or CSV.

    Args:
        path: manifest file; format is taken from the suffix.
        require: optional columns this caller additionally needs. Named up front
            so the failure is "your manifest has no `batch` column" rather than
            a `KeyError` an hour into a run.

    Raises:
        FileNotFoundError: the manifest is not there.
        ValueError: a required column is missing, the table is empty, or
            ``read_id`` is not unique.
    """
    import polars as pl  # lazy: `leech.crf` must import only torch and numpy

    path = Path(path)
    if not path.is_file():
        raise FileNotFoundError(f"manifest not found: {path}")

    suffix = path.suffix.lower()
    if suffix == ".parquet":
        frame = pl.read_parquet(path)
    elif suffix in (".tsv", ".txt"):
        frame = pl.read_csv(path, separator="\t")
    elif suffix == ".csv":
        frame = pl.read_csv(path)
    else:
        raise ValueError(
            f"unrecognised manifest format {suffix!r} ({path}); "
            f"expected .parquet, .tsv, .txt or .csv"
        )

    missing = [c for c in REQUIRED_COLUMNS if c not in frame.columns]
    if missing:
        raise ValueError(
            f"{path}: manifest is missing required column(s) {missing}. "
            f"Required: {list(REQUIRED_COLUMNS)}; found: {list(frame.columns)}. "
            f"`target` is the resolved sequence — if you are porting a table that "
            f"carries a class name instead, the class -> sequence lookup belongs "
            f"in whatever produced it, not here."
        )

    unknown_required = [c for c in require if c not in frame.columns]
    if unknown_required:
        raise ValueError(
            f"{path}: this run needs optional column(s) {unknown_required}, which "
            f"the manifest does not carry. "
            + "; ".join(
                f"without `{c}`: {OPTIONAL_COLUMNS.get(c, 'unknown column')}"
                for c in unknown_required
            )
        )

    if frame.height == 0:
        raise ValueError(f"{path}: manifest is empty")

    duplicates = frame.height - frame["read_id"].n_unique()
    if duplicates:
        raise ValueError(
            f"{path}: {duplicates} duplicate read_id(s). One row per read — a "
            f"duplicate silently double-weights those reads in training and puts "
            f"the same read on both sides of a split."
        )

    return CrfManifest(frame=frame, path=path)

CrfManifest dataclass

CrfManifest(frame: Any, path: Path | None = None)

A validated manifest table.

frame is a polars.DataFrame; the class is a thin wrapper that exists so validation happens in one place and callers can ask questions (quality_coverage, batches) without re-deriving column conventions.

quality_coverage

quality_coverage() -> float

Fraction of rows carrying a non-null quality_score.

Worth checking before a gated run, and the reason it is a method rather than a caller's one-liner: an unscored read cannot pass a gate, so it is dropped silently, and a partially-scored manifest trains on a small non-random subset. This once cut a corpus from 56% to 13.5% without a word, because the score table covered only the reads of an earlier extraction. Returns 1.0 when no quality column exists at all — nothing is being gated, so nothing is being lost.

Source code in src/leech/crf/manifest.py
def quality_coverage(self) -> float:
    """Fraction of rows carrying a non-null ``quality_score``.

    Worth checking before a gated run, and the reason it is a method rather
    than a caller's one-liner: an unscored read cannot pass a gate, so it is
    dropped *silently*, and a partially-scored manifest trains on a small
    non-random subset. This once cut a corpus from 56% to 13.5% without a
    word, because the score table covered only the reads of an earlier
    extraction. Returns 1.0 when no quality column exists at all — nothing
    is being gated, so nothing is being lost.
    """
    if not self.has("quality_score"):
        return 1.0
    if not len(self):
        return 0.0
    return 1.0 - (self.frame["quality_score"].null_count() / len(self))

batches

batches() -> list[str]

Distinct batch values, or [] when the column is absent.

Source code in src/leech/crf/manifest.py
def batches(self) -> list[str]:
    """Distinct ``batch`` values, or ``[]`` when the column is absent."""
    if not self.has("batch"):
        return []
    return sorted(self.frame["batch"].drop_nulls().unique().to_list())

target_lengths

target_lengths() -> set[int]

Distinct target lengths. More than one is legal but rarely intended.

Source code in src/leech/crf/manifest.py
def target_lengths(self) -> set[int]:
    """Distinct target lengths. More than one is legal but rarely intended."""
    return set(self.frame["target"].str.len_chars().unique().to_list())

check_geometry

check_geometry(window: int, target_len: int, samples_per_base: float, *, state_len: int = 4) -> None

Refuse a window that cannot hold target_len bases of signal.

Raises rather than warns. A short window does not fail loudly at training time — it trains, converges, and quietly discriminates on fewer bases than the design intended, which is how a 27-nt barcode came to be classified on 23 of them (rnabioco/escapepod-models#36).

samples_per_base is the read population's own translocation rate. Measure it (leech has dwell times; take a median) rather than carrying a constant — it is chemistry- and speed-dependent, and a stale constant makes this check pass when it should not.

Source code in src/leech/crf/manifest.py
def check_geometry(
    window: int, target_len: int, samples_per_base: float, *, state_len: int = 4
) -> None:
    """Refuse a window that cannot hold ``target_len`` bases of signal.

    Raises rather than warns. A short window does not fail loudly at training
    time — it trains, converges, and quietly discriminates on fewer bases than
    the design intended, which is how a 27-nt barcode came to be classified on
    23 of them (rnabioco/escapepod-models#36).

    ``samples_per_base`` is the read population's own translocation rate.
    Measure it (leech has dwell times; take a median) rather than carrying a
    constant — it is chemistry- and speed-dependent, and a stale constant makes
    this check pass when it should not.
    """
    if target_len <= state_len:
        raise ValueError(
            f"target of {target_len} bases emits nothing at state_len={state_len}: "
            f"the first {state_len} bases only fix the initial state, so a target "
            f"must be longer than {state_len} to decode to anything."
        )
    needed = target_len * samples_per_base
    if window < needed:
        raise ValueError(
            f"window of {window} samples cannot hold {target_len} bases at "
            f"{samples_per_base:.1f} samples/base (needs ~{needed:.0f}). "
            f"The model would train on a truncated target and report nothing: "
            f"widen the window, or shorten the target and accept that it emits "
            f"{target_len - state_len} bases."
        )

emitted_target

emitted_target(target: str, state_len: int) -> str

The part of target the model can actually emit: target[state_len:].

The first state_len bases fix the initial state and are never emitted, at any window width — widening the signal window does not lengthen the decode. Match decodes against this, never against the full-length target: the full-length comparison still picks the right reference but inflates every edit distance and compresses the confidence margin that ranking depends on.

Source code in src/leech/crf/manifest.py
def emitted_target(target: str, state_len: int) -> str:
    """The part of ``target`` the model can actually emit: ``target[state_len:]``.

    The first ``state_len`` bases fix the initial state and are never emitted,
    at any window width — widening the signal window does not lengthen the
    decode. Match decodes against this, never against the full-length target:
    the full-length comparison still picks the right reference but inflates
    every edit distance and compresses the confidence margin that ranking
    depends on.
    """
    if state_len < 0:
        raise ValueError(f"state_len must be >= 0, got {state_len}")
    return target[state_len:]

Corpus

plan_corpus

plan_corpus(manifest: CrfManifest | str | Path, *, chunk: int, test_frac: float = 0.1, per_group: int | str = 'auto', seed: int = 0, groups: list[str] | None = None, shard_batches: list[str] | None = None) -> CorpusPlan

Decide the corpus. Reads no signal.

Parameters:

Name Type Description Default
manifest CrfManifest | str | Path

a :class:CrfManifest or a path to one.

required
chunk int

window width in samples; also sets the anchor_end filter.

required
test_frac float

fraction of each class held out, before capping.

0.1
per_group int | str

cap per class, or "auto" for the rarest class's trainable depth — the only value that actually balances.

'auto'
seed int

fixes the shuffle, and therefore the split.

0
groups list[str] | None

restrict to these classes (default: every class present).

None
shard_batches list[str] | None

keep only these batches after planning globally.

None

Returns:

Name Type Description
A CorpusPlan

class:CorpusPlan whose frame carries a split column.

Source code in src/leech/crf/corpus.py
def plan_corpus(
    manifest: CrfManifest | str | Path,
    *,
    chunk: int,
    test_frac: float = 0.1,
    per_group: int | str = "auto",
    seed: int = 0,
    groups: list[str] | None = None,
    shard_batches: list[str] | None = None,
) -> CorpusPlan:
    """Decide the corpus. Reads no signal.

    Args:
        manifest: a :class:`CrfManifest` or a path to one.
        chunk: window width in samples; also sets the ``anchor_end`` filter.
        test_frac: fraction of each class held out, before capping.
        per_group: cap per class, or ``"auto"`` for the rarest class's trainable
            depth — the only value that actually balances.
        seed: fixes the shuffle, and therefore the split.
        groups: restrict to these classes (default: every class present).
        shard_batches: keep only these batches *after* planning globally.

    Returns:
        A :class:`CorpusPlan` whose frame carries a ``split`` column.
    """
    import polars as pl

    man = manifest if isinstance(manifest, CrfManifest) else load_manifest(manifest)
    frame = _group_column(man.frame)
    if "batch" not in frame.columns:
        frame = frame.with_columns(pl.lit("all").alias("batch"))

    if groups is not None:
        frame = frame.filter(pl.col("group").is_in(groups))
        if frame.height == 0:
            raise ValueError(f"no reads for groups {groups}")

    before = frame.height
    frame = frame.filter(pl.col("anchor_end") > chunk + ANCHOR_MARGIN)
    dropped = before - frame.height
    if frame.height == 0:
        raise ValueError(
            f"no read has anchor_end > {chunk + ANCHOR_MARGIN}; every one of "
            f"{before} was dropped. The window is wider than the signal available "
            f"ahead of the anchor."
        )

    # Shuffle within batch, then interleave round-robin across batches.
    parts = []
    for _batch, part in sorted(
        ((k[0], p) for k, p in frame.partition_by("batch", as_dict=True).items()),
        key=lambda kv: str(kv[0]),
    ):
        part = part.sort("read_id").sample(fraction=1.0, shuffle=True, seed=seed)
        parts.append(part.with_columns(pl.int_range(pl.len()).alias("_pos")))
    frame = pl.concat(parts).sort("_pos", maintain_order=True)

    avail = frame.group_by("group").len().sort("group")
    counts = dict(zip(avail["group"].to_list(), avail["len"].to_list(), strict=True))
    trainable = {g: int(n * (1 - test_frac)) for g, n in counts.items()}
    rarest_group = min(trainable, key=lambda g: trainable[g])
    rarest = trainable[rarest_group]

    if per_group == "auto":
        cap = rarest
        logger.info("per_group=auto -> %d (trainable depth of %s)", cap, rarest_group)
    else:
        cap = int(per_group)
        if cap > rarest:
            short = sorted(g for g, n in trainable.items() if n < cap)
            logger.warning(
                "per_group=%d exceeds the trainable depth of %d group(s); the corpus "
                "will NOT be balanced. Rarest is %s at %d — those groups contribute "
                "everything they have while the others are capped. Use per_group='auto' "
                "(%d) for a balanced corpus.",
                cap,
                len(short),
                rarest_group,
                rarest,
                rarest,
            )

    # Rank within class over the interleaved order, so rank is a random draw.
    # Per class and global across batches: per-(batch, class) would multiply the
    # cap by the number of batches whenever classes are crossed with batch.
    ranked = frame.with_columns(
        pl.int_range(pl.len()).over("group").alias("_rank"),
        pl.len().over("group").alias("_n"),
    ).with_columns(
        # At least one held-out read per class: a class with none is invisible to
        # evaluation rather than reported as missing.
        pl.max_horizontal(pl.lit(1), (pl.col("_n") * test_frac).cast(pl.Int64)).alias("_n_test")
    )
    test = ranked.filter(pl.col("_rank") < pl.col("_n_test")).with_columns(
        pl.lit("test").alias("split")
    )
    train = (
        ranked.filter(pl.col("_rank") >= pl.col("_n_test"))
        .with_columns((pl.col("_rank") - pl.col("_n_test")).alias("_train_rank"))
        .filter(pl.col("_train_rank") < cap)
        .with_columns(pl.lit("train").alias("split"))
        .drop("_train_rank")
    )
    planned = pl.concat([test, train]).drop("_pos", "_rank", "_n", "_n_test")

    if shard_batches is not None:
        want = set(shard_batches)
        missing = want - set(planned["batch"].unique().to_list())
        if missing:
            raise ValueError(
                f"shard_batches names {len(missing)} batch(es) with no reads in the "
                f"plan: {sorted(missing)}. A shard that quietly extracted nothing "
                f"would shrink the corpus without failing."
            )
        planned = planned.filter(pl.col("batch").is_in(list(want)))

    return CorpusPlan(frame=planned, cap=cap, chunk=chunk, dropped_short_anchor=dropped)

CorpusPlan dataclass

CorpusPlan(frame: Any, cap: int, chunk: int, dropped_short_anchor: int)

Which reads go in, and in which split. No signal has been read yet.

build_corpus

build_corpus(plan: CorpusPlan, out: str | Path, *, state_len: int = 4, allow_shortfall: bool = False, extract_batch: int = EXTRACT_BATCH) -> Path

Extract each planned read's window and stream it to <out>_X.npy.

Parameters:

Name Type Description Default
plan CorpusPlan

from :func:plan_corpus.

required
out str | Path

output stem; _X.npy and _meta.npz are appended.

required
state_len int

recorded in the metadata so consumers can derive what the model will emit without guessing.

4
allow_shortfall bool

permit extracting less than half the plan.

False
extract_batch int

reads per POD5 call.

EXTRACT_BATCH

Raises:

Type Description
RuntimeError

nothing was extracted, or more than half the plan is missing and allow_shortfall is false.

Source code in src/leech/crf/corpus.py
def build_corpus(
    plan: CorpusPlan,
    out: str | Path,
    *,
    state_len: int = 4,
    allow_shortfall: bool = False,
    extract_batch: int = EXTRACT_BATCH,
) -> Path:
    """Extract each planned read's window and stream it to ``<out>_X.npy``.

    Args:
        plan: from :func:`plan_corpus`.
        out: output stem; ``_X.npy`` and ``_meta.npz`` are appended.
        state_len: recorded in the metadata so consumers can derive what the
            model will emit without guessing.
        allow_shortfall: permit extracting less than half the plan.
        extract_batch: reads per POD5 call.

    Raises:
        RuntimeError: nothing was extracted, or more than half the plan is
            missing and ``allow_shortfall`` is false.
    """
    # Imported here, not at module scope: `leech.io.pod5_reader` pulls pysam and
    # escapepod, and `leech.crf` promises to cost only torch and numpy on import.
    from leech.io.pod5_reader import read_pod5_signals_batch_cached

    out = Path(out)
    out.parent.mkdir(parents=True, exist_ok=True)
    x_path = out.with_name(out.name + "_X.npy")
    frame, chunk, total = plan.frame, plan.chunk, len(plan)

    signal = np.lib.format.open_memmap(x_path, mode="w+", dtype=np.float32, shape=(total, chunk))
    kept: dict[str, list] = {k: [] for k in ("target", "group", "read_id", "split", "batch")}
    quality: dict[str, list] = {"quality_score": [], "quality_margin": []}
    has_quality = {k: k in frame.columns for k in quality}
    n = 0

    # Grouped by POD5 source: that is the unit the reader caches and the unit
    # storage order is defined within. The split above is independent of it.
    for (source,), part in sorted(
        frame.partition_by("pod5", as_dict=True).items(), key=lambda kv: str(kv[0][0])
    ):
        rows = part.to_dicts()
        for start in range(0, len(rows), extract_batch):
            block = rows[start : start + extract_batch]
            found = read_pod5_signals_batch_cached(source, [r["read_id"] for r in block])
            for row in block:
                hit = found.get(row["read_id"])
                if hit is None:
                    continue
                raw = hit[0]
                end = int(row["anchor_end"])
                if end - chunk < 0 or end > len(raw):
                    continue
                signal[n] = np.asarray(raw[end - chunk : end], dtype=np.float32)
                for key in kept:
                    kept[key].append(row[key])
                for key, present in has_quality.items():
                    quality[key].append(float(row[key]) if present else np.nan)
                n += 1
        logger.info("%s: %d extracted", source, n)
    signal.flush()

    # An empty or badly short corpus is a BROKEN build, not a small one, and
    # everything upstream can be correct and still produce it — a manifest naming
    # a parent directory rather than the one holding the .pod5 files matches zero
    # reads, writes a 0-row array, and exits cleanly. The two failures are
    # distinguished because their causes differ.
    if n == 0:
        sources = sorted({str(s) for s in frame["pod5"].unique().to_list()})[:3]
        del signal
        raise RuntimeError(
            f"extracted 0 of {total} planned reads. Nothing was read from "
            f"{', '.join(sources)}. Check that these paths hold the .pod5 files "
            f"naming these reads."
        )
    if n < 0.5 * total and not allow_shortfall:
        del signal
        raise RuntimeError(
            f"extracted {n} of {total} planned reads ({n / total:.1%}). More than "
            f"half the corpus is missing, so the manifest and the POD5s disagree "
            f"about which reads exist. Pass allow_shortfall=True if expected."
        )

    if n < total:
        _trim(x_path, n, chunk)

    targets = np.array(kept["target"], dtype=str)
    meta = {
        "y": targets,
        "code": np.array(kept["group"], dtype=str),
        "group": np.array(kept["group"], dtype=str),
        "read_id": np.array(kept["read_id"], dtype=str),
        "split": np.array(kept["split"], dtype=str),
        "batch": np.array(kept["batch"], dtype=str),
        "run": np.array(kept["batch"], dtype=str),
        "gate_score": np.array(quality["quality_score"], dtype=np.float32),
        "gate_margin": np.array(quality["quality_margin"], dtype=np.float32),
        "chunk": chunk,
        "per_code": plan.cap,
        "target_len": int(len(targets[0])) if n else 0,
        "state_len": state_len,
    }
    np.savez(out.with_name(out.name + "_meta.npz"), **meta)
    logger.info("wrote %s (%d x %d) and %s_meta.npz", x_path, n, chunk, out)
    return x_path

load_corpus

load_corpus(path: str | Path, *, mmap: bool = True)

Read a corpus written by :func:build_corpus, or the legacy single .npz.

Returns (signal, targets, groups, read_ids, split); split is None for the legacy layout, which carries none. mmap keeps the signal on disk, which is the only way to train on a corpus larger than RAM — batches are indexed out of it per step.

Source code in src/leech/crf/corpus.py
def load_corpus(path: str | Path, *, mmap: bool = True):
    """Read a corpus written by :func:`build_corpus`, or the legacy single ``.npz``.

    Returns ``(signal, targets, groups, read_ids, split)``; ``split`` is ``None``
    for the legacy layout, which carries none. ``mmap`` keeps the signal on disk,
    which is the only way to train on a corpus larger than RAM — batches are
    indexed out of it per step.
    """
    base = Path(str(path)[:-4] if str(path).endswith(".npz") else path)
    streamed = base.with_name(base.name + "_X.npy")
    if streamed.exists():
        signal = np.load(streamed, mmap_mode="r" if mmap else None)
        meta = np.load(base.with_name(base.name + "_meta.npz"), allow_pickle=True)
        group_key = "group" if "group" in meta else "code"
        split = meta["split"].astype(str) if "split" in meta else None
        return (
            signal,
            meta["y"].astype(str),
            meta[group_key].astype(str),
            meta["read_id"].astype(str),
            split,
        )
    legacy_path = base.with_suffix(".npz")
    if not legacy_path.exists():
        # Name BOTH layouts. The stem is not a file, so reporting the one that
        # happened to be tried last points at a path the caller never wrote.
        raise FileNotFoundError(
            f"no corpus at {base}: expected either {streamed.name} (the streamed "
            f"layout, written by build_corpus) or {legacy_path.name} (the legacy "
            f"single-file one) beside it. `--corpus` takes the STEM, without a "
            f"suffix."
        )
    legacy = np.load(legacy_path, allow_pickle=True)
    group_key = "group" if "group" in legacy else "code"
    return (
        legacy["X"],
        legacy["y"].astype(str),
        legacy[group_key].astype(str),
        legacy["read_id"].astype(str),
        None,
    )

load_corpus_meta

load_corpus_meta(path: str | Path) -> dict

The corpus's _meta.npz as a dict, or {} for the legacy layout.

Separate from :func:load_corpus because the optional columns really are optional: corpora written before batch and the quality pair existed must keep loading, so every consumer has to ask whether a column is there rather than assume it.

Source code in src/leech/crf/corpus.py
def load_corpus_meta(path: str | Path) -> dict:
    """The corpus's ``_meta.npz`` as a dict, or ``{}`` for the legacy layout.

    Separate from :func:`load_corpus` because the optional columns really are
    optional: corpora written before ``batch`` and the quality pair existed must
    keep loading, so every consumer has to *ask* whether a column is there
    rather than assume it.
    """
    base = Path(str(path)[:-4] if str(path).endswith(".npz") else path)
    meta_path = base.with_name(base.name + "_meta.npz")
    if not meta_path.exists():
        return {}
    return dict(np.load(meta_path, allow_pickle=True))

Training API

CrfTrainer

CrfTrainer(corpus: str | Path, *, config: CrfTrainConfig | None = None, arch_config: dict | None = None, output_dir: str | Path | None = None)

Trains a :class:~leech.crf.encoder.CrfEncoder on a streamed corpus.

Source code in src/leech/crf/training.py
def __init__(
    self,
    corpus: str | Path,
    *,
    config: CrfTrainConfig | None = None,
    arch_config: dict | None = None,
    output_dir: str | Path | None = None,
) -> None:
    from .config import load_config
    from .corpus import load_corpus, load_corpus_meta
    from .encoder import encoder_config_from_toml

    self.cfg = config or CrfTrainConfig()
    self.corpus_path = Path(corpus)
    self.output_dir = Path(output_dir) if output_dir else None

    arch = arch_config if arch_config is not None else load_config()
    self.alphabet = "".join(arch["labels"]["labels"])
    self.encoder_cfg = encoder_config_from_toml(arch)

    signal, targets, groups, read_ids, corpus_split = load_corpus(self.corpus_path)
    meta = load_corpus_meta(self.corpus_path)

    chunk = self.cfg.chunk or int(signal.shape[1])
    target_len = self.cfg.target_len or len(str(targets[0]))
    if chunk > signal.shape[1]:
        raise ValueError(f"chunk {chunk} exceeds the extracted {signal.shape[1]}")
    if target_len > len(str(targets[0])):
        raise ValueError(
            f"target_len {target_len} exceeds the extracted {len(str(targets[0]))}"
        )

    self.signal = signal
    self.targets = np.array([str(t)[-target_len:] for t in targets])
    self.groups = groups
    self.read_ids = read_ids
    self.corpus_split = corpus_split
    self.meta = meta
    self.chunk = chunk
    self.target_len = target_len
    self.encoder_cfg = replace(self.encoder_cfg, chunk=chunk)

emitted property

emitted: int

Bases the model can emit: the first state_len only fix the state.

prepare

prepare() -> dict[str, Any]

Standardisation, gate and split — everything before the first step.

Source code in src/leech/crf/training.py
def prepare(self) -> dict[str, Any]:
    """Standardisation, gate and split — everything before the first step."""
    mean, std = compute_standardisation(self.signal, self.chunk)
    score = self.meta.get("gate_score")
    margin = self.meta.get("gate_margin")
    clean, coverage = apply_quality_gate(
        score,
        margin,
        enabled=self.cfg.gate,
        min_score=self.cfg.min_score,
        min_margin=self.cfg.min_margin,
        min_coverage=self.cfg.min_coverage,
        n_reads=len(self.targets),
    )
    batches = self.meta.get("batch", self.meta.get("run"))
    train_idx, test_idx, why = resolve_split(
        clean,
        corpus_split=self.corpus_split,
        batches=batches,
        holdout_batch=self.cfg.holdout_batch,
        test_frac=self.cfg.test_frac,
        resplit=self.cfg.resplit,
        seed=self.cfg.seed,
    )
    logger.info(
        "corpus %s -> window %d samples, target %d nt (emits %d)",
        self.signal.shape,
        self.chunk,
        self.target_len,
        self.emitted,
    )
    logger.info("standardisation: mean=%.3f std=%.3f", mean, std)
    logger.info("split: %s -> train %d, test %d", why, len(train_idx), len(test_idx))
    return {
        "mean": mean,
        "std": std,
        "coverage": coverage,
        "train_idx": train_idx,
        "test_idx": test_idx,
        "split_source": why,
    }

train

train() -> dict[str, Any]

Run the schedule and return the result, writing it if asked.

Source code in src/leech/crf/training.py
def train(self) -> dict[str, Any]:
    """Run the schedule and return the result, writing it if asked."""
    import torch
    from torch import nn

    from .encoder import CrfEncoder
    from .loss import CtcCrfLoss

    cfg = self.cfg
    device = cfg.resolved_device()
    prep = self.prepare()
    mean, std = prep["mean"], prep["std"]
    train_idx = prep["train_idx"]
    if len(train_idx) < cfg.batch_size:
        raise ValueError(
            f"{len(train_idx)} training reads is fewer than one batch of "
            f"{cfg.batch_size}; nothing would be stepped"
        )

    torch.manual_seed(cfg.seed)
    rng = epoch_order_rng(cfg.seed)
    encoded = encode_targets(self.targets, self.alphabet)

    model = CrfEncoder(self.encoder_cfg).to(device)
    criterion = CtcCrfLoss(self.encoder_cfg.n_base, self.encoder_cfg.state_len).to(device)
    opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr, weight_decay=cfg.weight_decay)
    steps = cfg.epochs * (len(train_idx) // cfg.batch_size)
    sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=cfg.lr, total_steps=steps)
    scaler = torch.amp.GradScaler(device, enabled=(device == "cuda"))

    history: list[EpochStats] = []
    best_state: dict[str, Any] | None = None
    best_loss = float("inf")

    for epoch in range(1, cfg.epochs + 1):
        model.train()
        order = train_idx.copy()
        rng.shuffle(order)
        t0 = time.time()
        total = worst = grad_max = 0.0
        n_batches = n_skipped = n_nonfinite = 0

        for start in range(0, len(order) - cfg.batch_size + 1, cfg.batch_size):
            # Sorted: the signal is a memmap, and a sorted gather reads it
            # forwards instead of seeking per row.
            rows = np.sort(order[start : start + cfg.batch_size])
            window = np.asarray(self.signal[rows][:, -self.chunk :], dtype=np.float32)
            x = ((torch.from_numpy(window).to(device) - mean) / std).unsqueeze(1)
            tgt = torch.from_numpy(encoded[rows]).to(device)
            lengths = torch.full((len(rows),), tgt.shape[1], dtype=torch.long, device=device)

            with torch.amp.autocast(device, enabled=(device == "cuda")):
                scores = model(x)
            # Outside autocast, in fp32: the lattice scan accumulates over
            # chunk/stride timesteps and fp16 loses the tail of that sum.
            loss = criterion(scores.float(), tgt, lengths)

            opt.zero_grad()
            scaler.scale(loss).backward()
            scaler.unscale_(opt)
            gnorm = float(nn.utils.clip_grad_norm_(model.parameters(), cfg.max_grad_norm))
            # A skipped step is invisible from outside: on non-finite
            # gradients scaler.step() silently does nothing and update()
            # halves the scale. The scale dropping IS the signal.
            scale_before = scaler.get_scale()
            scaler.step(opt)
            scaler.update()
            sched.step()
            n_skipped += scaler.get_scale() < scale_before
            if math.isfinite(gnorm):
                grad_max = max(grad_max, gnorm)
            else:
                n_nonfinite += 1
            value = float(loss.detach())
            worst = max(worst, value)
            total += value
            n_batches += 1

        stats = EpochStats(
            epoch=epoch,
            loss=total / max(n_batches, 1),
            worst_batch=worst,
            grad_max=grad_max,
            n_skipped=int(n_skipped),
            n_nonfinite=n_nonfinite,
            n_batches=n_batches,
            lr=float(sched.get_last_lr()[0]),
            seconds=time.time() - t0,
        )
        history.append(stats)
        logger.info("%s", stats.render(cfg.epochs))

        if stats.loss < best_loss:
            best_loss = stats.loss
            best_state = {
                k: v.detach().to("cpu", copy=True) for k, v in model.state_dict().items()
            }

    epoch, loss, why = select_checkpoint(
        history, select_tol=cfg.select_tol, always_final=cfg.always_final
    )
    if epoch != history[-1].epoch and best_state is not None:
        logger.warning("shipping %s", why)
        model.load_state_dict(best_state)

    result = {
        "corpus": str(self.corpus_path),
        "chunk": self.chunk,
        "target_len": self.target_len,
        "state_len": self.encoder_cfg.state_len,
        "emits": self.emitted,
        "mean": mean,
        "std": std,
        "alphabet": self.alphabet,
        "epochs": cfg.epochs,
        "seed": cfg.seed,
        "n_train": int(len(train_idx)),
        "n_test": int(len(prep["test_idx"])),
        "test_idx": prep["test_idx"].tolist(),
        "split_source": prep["split_source"],
        "quality_coverage": prep["coverage"],
        "final_loss": history[-1].loss,
        "selected_epoch": epoch,
        "selected_loss": loss,
        "selected_because": why,
        "best_epoch": min(history, key=lambda e: e.loss).epoch,
        "best_loss": best_loss,
        "history": [asdict(e) for e in history],
        "config": asdict(cfg),
    }
    if self.output_dir is not None:
        self._write(model, result, self.output_dir)
    return result | {"model": model}

CrfTrainConfig dataclass

CrfTrainConfig(epochs: int = 32, batch_size: int = 256, lr: float = 0.002, weight_decay: float = 1e-05, max_grad_norm: float = 2.0, seed: int = 0, gate: bool = True, min_score: float = 66.0, min_margin: float = 5.0, min_coverage: float = 0.9, test_frac: float = 0.1, resplit: bool = False, holdout_batch: str | None = None, select_tol: float = 0.25, always_final: bool = False, chunk: int | None = None, target_len: int | None = None, device: str = 'auto')

Everything the loop needs that is not the data itself.

epochs class-attribute instance-attribute

epochs: int = 32

32, not 16. A longer target is under-trained at 16 and measures ~1.2pp worse than it is, which reads as a worse design rather than a shorter run.

compute_standardisation

compute_standardisation(signal, chunk: int, *, block: int = 20000) -> tuple[float, float]

Corpus mean and standard deviation over the trailing chunk samples.

Streamed in blocks and accumulated in float64: the corpus is memory-mapped and may not fit in RAM, and a float32 sum over billions of samples loses its tail. Returns plain floats, because these travel in JSON.

Source code in src/leech/crf/training.py
def compute_standardisation(signal, chunk: int, *, block: int = 20_000) -> tuple[float, float]:
    """Corpus mean and standard deviation over the trailing ``chunk`` samples.

    Streamed in blocks and accumulated in float64: the corpus is memory-mapped
    and may not fit in RAM, and a float32 sum over billions of samples loses its
    tail. Returns plain floats, because these travel in JSON.
    """
    n = 0
    total = 0.0
    total_sq = 0.0
    for start in range(0, len(signal), block):
        values = np.asarray(signal[start : start + block, -chunk:], dtype=np.float64)
        n += values.size
        total += values.sum()
        total_sq += (values * values).sum()
    if n == 0:
        raise ValueError("cannot standardise an empty corpus")
    mean = total / n
    variance = max(total_sq / n - mean * mean, 0.0)
    return float(mean), float(math.sqrt(variance))

apply_quality_gate

apply_quality_gate(score: ndarray | None, margin: ndarray | None, *, enabled: bool = True, min_score: float = 66.0, min_margin: float = 5.0, min_coverage: float = 0.9, n_reads: int | None = None) -> tuple[np.ndarray, float]

Which reads are trustworthy enough to train on, and the score coverage.

Raises rather than gating on a partially scored corpus. An unscored read cannot pass, so it is dropped silently — one corpus went from 56% usable to 13.5% that way, non-randomly, because the score table covered only the reads of an earlier extraction.

Source code in src/leech/crf/training.py
def apply_quality_gate(
    score: np.ndarray | None,
    margin: np.ndarray | None,
    *,
    enabled: bool = True,
    min_score: float = 66.0,
    min_margin: float = 5.0,
    min_coverage: float = 0.9,
    n_reads: int | None = None,
) -> tuple[np.ndarray, float]:
    """Which reads are trustworthy enough to train on, and the score coverage.

    Raises rather than gating on a partially scored corpus. An unscored read
    cannot pass, so it is dropped *silently* — one corpus went from 56% usable
    to 13.5% that way, non-randomly, because the score table covered only the
    reads of an earlier extraction.
    """
    if n_reads is None:
        n_reads = len(score) if score is not None else 0
    if not enabled:
        logger.info("quality gate disabled; training on all %d reads", n_reads)
        return np.ones(n_reads, dtype=bool), 1.0

    if score is None or margin is None:
        raise ValueError(
            "the corpus carries no label-quality columns, so the gate cannot be "
            "applied. Gating is what took one panel from 0.875 to 0.97 — label "
            "noise, not capacity, was the ceiling — so this refuses rather than "
            "quietly training ungated. Rebuild the manifest with quality columns, "
            "or pass gate=False deliberately."
        )
    score = np.asarray(score, dtype=float)
    margin = np.asarray(margin, dtype=float)
    scored = ~np.isnan(margin) & ~np.isnan(score)
    coverage = float(scored.mean()) if len(scored) else 0.0

    if coverage == 0.0:
        raise ValueError(
            "the corpus carries label-quality columns but every value is missing. "
            "Re-score it, or pass gate=False deliberately."
        )
    if coverage < min_coverage:
        raise ValueError(
            f"label quality covers only {coverage:.3f} of {n_reads} reads "
            f"(need {min_coverage}). An unscored read cannot pass the gate, so it "
            f"is silently dropped — you would train on a small, non-random subset. "
            f"Re-score the corpus."
        )
    keep = scored & (margin > min_margin) & (score >= min_score)
    logger.info(
        "quality gate: %d/%d reads pass (%.3f), coverage %.3f",
        int(keep.sum()),
        n_reads,
        keep.mean(),
        coverage,
    )
    return keep, coverage

resolve_split

resolve_split(clean: ndarray, *, corpus_split: ndarray | None = None, batches: ndarray | None = None, holdout_batch: str | None = None, test_frac: float = 0.1, resplit: bool = False, seed: int = 0) -> tuple[np.ndarray, np.ndarray, str]

(train_idx, test_idx, provenance) — three sources, in priority order.

A held-out batch is the honest generalisation number when classes are crossed with batch; a read-level split puts the same flowcell on both sides and reads optimistically. The corpus's own split comes next, because it was carved per class before capping, so every arm drawn from that corpus holds out the same reads by construction rather than by both happening to seed an RNG identically. Seeding one here is the fallback.

Source code in src/leech/crf/training.py
def resolve_split(
    clean: np.ndarray,
    *,
    corpus_split: np.ndarray | None = None,
    batches: np.ndarray | None = None,
    holdout_batch: str | None = None,
    test_frac: float = 0.1,
    resplit: bool = False,
    seed: int = 0,
) -> tuple[np.ndarray, np.ndarray, str]:
    """``(train_idx, test_idx, provenance)`` — three sources, in priority order.

    A **held-out batch** is the honest generalisation number when classes are
    crossed with batch; a read-level split puts the same flowcell on both sides
    and reads optimistically. The **corpus's own split** comes next, because it
    was carved per class before capping, so every arm drawn from that corpus
    holds out the same reads by construction rather than by both happening to
    seed an RNG identically. Seeding one here is the fallback.
    """
    rng = np.random.default_rng(seed)

    if holdout_batch is not None:
        if batches is None:
            raise ValueError("holdout_batch needs a corpus carrying a batch column")
        held = np.char.startswith(batches.astype(str), holdout_batch)
        if not held.any():
            raise ValueError(
                f"no reads from a batch starting {holdout_batch!r}; "
                f"have {sorted(set(batches.astype(str)))}"
            )
        train = np.flatnonzero(clean & ~held)
        test = np.flatnonzero(clean & held)
        rng.shuffle(train)
        n_out = len(set(batches[held].astype(str)))
        return train, test, f"held-out batch {holdout_batch} ({n_out} batch(es) out)"

    if corpus_split is not None and not resplit:
        train = np.flatnonzero(clean & (corpus_split == "train"))
        test = np.flatnonzero(clean & (corpus_split == "test"))
        rng.shuffle(train)
        return train, test, "the corpus's own split"

    idx = np.flatnonzero(clean)
    rng.shuffle(idx)
    n_test = int(test_frac * len(idx))
    why = "seeded here" + ("; corpus split overridden" if corpus_split is not None else "")
    return idx[n_test:], idx[:n_test], why

select_checkpoint

select_checkpoint(history: list[EpochStats], *, select_tol: float = 0.25, always_final: bool = False) -> tuple[int, float, str]

Which epoch's weights to ship: (epoch, loss, why).

The tolerance is sized to fire on a real divergence and nothing smaller. It is deliberately loose because 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 balanced recall. This is a divergence detector, not a ranking.

Source code in src/leech/crf/training.py
def select_checkpoint(
    history: list[EpochStats], *, select_tol: float = 0.25, always_final: bool = False
) -> tuple[int, float, str]:
    """Which epoch's weights to ship: ``(epoch, loss, why)``.

    The tolerance is sized to fire on a real divergence and nothing smaller. It
    is deliberately loose because 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 balanced recall. This is a
    divergence detector, not a ranking.
    """
    if not history:
        raise ValueError("no epochs were run")
    final = history[-1]
    best = min(history, key=lambda e: e.loss)
    if always_final or final.loss <= best.loss * (1 + select_tol):
        return final.epoch, final.loss, "the schedule's endpoint"
    return (
        best.epoch,
        best.loss,
        f"epoch {best.epoch} ({best.loss:.4f}); the last was {final.loss / best.loss:.2f}x "
        f"it, over the {select_tol:.0%} tolerance",
    )

Export API

export_crf_onnx

export_crf_onnx(checkpoint: str | Path, out_dir: str | Path, *, sidecar: dict[str, Any] | str | Path | None = None, arch_config: dict | None = None, mean: float | None = None, std: float | None = None, chunk: int | None = None, references: dict[str, str] | None = None, verify: bool = True, opset: int | None = None) -> Path

Export checkpoint to <out_dir>/crf_encoder.onnx plus metadata.json.

Parameters:

Name Type Description Default
checkpoint str | Path

a model.pt (or the directory holding one).

required
out_dir str | Path

written to; created if absent.

required
sidecar dict[str, Any] | str | Path | None

the trainer's model.json, or the directory holding it. Supplies standardisation and geometry — strongly preferred over passing them separately.

None
arch_config dict | None

architecture config; defaults to the packaged one.

None
mean float | None

standardisation mean; overrides the sidecar. Required if absent.

None
std float | None

standardisation stdev; overrides the sidecar. Required if absent.

None
chunk int | None

window width in samples; overrides the sidecar.

None
references dict[str, str] | None

{name: full_length_target}. Written as what the model emits (target[state_len:]), never as given.

None
verify bool

run the graph against torch and record the difference.

True
opset int | None

ONNX opset; defaults to :data:leech.onnx_export.OPSET.

None

Returns:

Type Description
Path

Path to the written crf_encoder.onnx.

Source code in src/leech/crf/export.py
def export_crf_onnx(
    checkpoint: str | Path,
    out_dir: str | Path,
    *,
    sidecar: dict[str, Any] | str | Path | None = None,
    arch_config: dict | None = None,
    mean: float | None = None,
    std: float | None = None,
    chunk: int | None = None,
    references: dict[str, str] | None = None,
    verify: bool = True,
    opset: int | None = None,
) -> Path:
    """Export ``checkpoint`` to ``<out_dir>/crf_encoder.onnx`` plus ``metadata.json``.

    Args:
        checkpoint: a ``model.pt`` (or the directory holding one).
        out_dir: written to; created if absent.
        sidecar: the trainer's ``model.json``, or the directory holding it.
            Supplies standardisation and geometry — strongly preferred over
            passing them separately.
        arch_config: architecture config; defaults to the packaged one.
        mean: standardisation mean; overrides the sidecar. Required if absent.
        std: standardisation stdev; overrides the sidecar. Required if absent.
        chunk: window width in samples; overrides the sidecar.
        references: ``{name: full_length_target}``. Written as what the model
            *emits* (``target[state_len:]``), never as given.
        verify: run the graph against torch and record the difference.
        opset: ONNX opset; defaults to :data:`leech.onnx_export.OPSET`.

    Returns:
        Path to the written ``crf_encoder.onnx``.
    """
    import torch

    from leech.onnx_export import OPSET, export_onnx, verify_onnx

    from .config import load_config
    from .encoder import CrfEncoder, encoder_config_from_toml, load_crf_state_dict

    opset = opset or OPSET
    checkpoint = Path(checkpoint)
    if checkpoint.is_dir():
        checkpoint = checkpoint / "model.pt"
    out_dir = Path(out_dir)

    if sidecar is not None and not isinstance(sidecar, dict):
        sidecar = load_training_sidecar(sidecar)
    side: dict[str, Any] = sidecar or {}

    mean = mean if mean is not None else side.get("mean")
    std = std if std is not None else side.get("std")
    if mean is None or std is None:
        raise ValueError(
            "standardisation (mean, std) is required and was not supplied. It is "
            "in neither the architecture config nor the checkpoint — the trainer "
            "derives it from the corpus and records it in model.json. Pass "
            "sidecar=<run dir>, or mean= and std= explicitly. A consumer without "
            "them cannot standardise and decodes silently worse."
        )

    cfg = encoder_config_from_toml(arch_config if arch_config is not None else load_config())
    chunk = chunk or side.get("chunk") or cfg.chunk
    from dataclasses import replace

    cfg = replace(cfg, chunk=chunk)

    model = CrfEncoder(cfg)
    load_crf_state_dict(model, torch.load(checkpoint, map_location="cpu"))
    model.eval()

    example = (torch.randn(1, 1, cfg.chunk),)
    onnx_path = out_dir / "crf_encoder.onnx"
    export_onnx(
        model,
        example,
        onnx_path,
        input_names=["signal"],
        output_names=["scores"],
        opset=opset,
        # Batch is axis 1 of the OUTPUT, not axis 0: the graph is time-major.
        dynamic_axes={"signal": {0: "batch"}, "scores": {1: "batch"}},
    )

    max_diff = None
    if verify:
        max_diff = verify_onnx(onnx_path, model, example, input_names=["signal"])

    state_len = cfg.state_len
    emitted = (
        {name: target[state_len:] for name, target in references.items()} if references else None
    )

    metadata: dict[str, Any] = {
        "format": "onnx",
        "opset": opset,
        "signal": {
            "chunk": cfg.chunk,
            "stride": cfg.stride,
            "window": "[anchor_end - chunk, anchor_end]",
            "layout": "[batch, 1, chunk] float32, batch-major, standardised raw pA",
        },
        "standardisation": {
            "mean": float(mean),
            "stdev": float(std),
            "source": "derived from the training corpus; in neither the config nor "
            "the checkpoint, so a consumer must read it here",
        },
        "crf": {
            "state_len": state_len,
            "n_base": cfg.n_base,
            "n_states": cfg.n_states,
            "n_score": cfg.n_score,
            "scores_per_state": cfg.n_base + 1,
            "blank_score": cfg.blank_score,
            "blank_index": 0,
            "score_index": "state * (n_base + 1) + label, label 0 = stay",
            "output_layout": "[chunk // stride, batch, n_score] float32, TIME-major "
            "— the opposite of the boundary CNN's batch-major [B, 2, L] in the same "
            "stack, so this needs its own load-time shape probe",
            "decode": "two-pass: log-semiring posteriors, then max-semiring over "
            "log(posteriors + 1e-8). Not expressible in standard ONNX ops, so it "
            "stays in the consumer. A one-pass Viterbi over these scores is a "
            "different and worse decode.",
            "emits": f"target[{state_len}:] — the first {state_len} bases only fix "
            f"the initial state and are never emitted, at any window width",
        },
    }
    if emitted is not None:
        metadata["references"] = emitted
        metadata["references_are"] = (
            f"what the model EMITS (target[{state_len}:]), not the full-length "
            f"targets. Matching full-length calls the same sequence but inflates "
            f"every edit distance and compresses the confidence margin."
        )
    if max_diff is not None:
        metadata["verification"] = {
            "onnxruntime_vs_torch_max_abs_diff": max_diff,
            "float32_eps": 1.1920929e-07,
        }
    for key in ("selected_epoch", "selected_loss", "seed", "corpus", "target_len"):
        if key in side:
            metadata.setdefault("training", {})[key] = side[key]

    (out_dir / "metadata.json").write_text(json.dumps(metadata, indent=2))
    logger.info("wrote %s and metadata.json", onnx_path)
    return onnx_path

load_training_sidecar

load_training_sidecar(path: str | Path) -> dict[str, Any]

Read a model.json written by :class:~leech.crf.training.CrfTrainer.

Preferred over passing standardisation by hand: the trainer records what it actually derived alongside the geometry it trained at, so reading them together is the only way an export cannot drift from its weights.

Source code in src/leech/crf/export.py
def load_training_sidecar(path: str | Path) -> dict[str, Any]:
    """Read a ``model.json`` written by :class:`~leech.crf.training.CrfTrainer`.

    Preferred over passing standardisation by hand: the trainer records what it
    actually derived alongside the geometry it trained at, so reading them
    together is the only way an export cannot drift from its weights.
    """
    path = Path(path)
    if path.is_dir():
        path = path / "model.json"
    if not path.is_file():
        raise FileNotFoundError(f"training sidecar not found: {path}")
    return json.loads(path.read_text())

Evaluation API

decode_corpus

decode_corpus(model, signal, indices: ndarray, *, mean: float, std: float, chunk: int, n_base: int = 4, state_len: int = 4, alphabet: str = 'NACGT', batch_size: int = 256, device: str = 'cpu') -> list[str]

Decode the reads at indices, in corpus order.

Indices are sorted per batch before gathering, because the corpus is a memmap and a sorted gather reads it forwards instead of seeking per row. The returned list is in the order of indices as given, not sorted.

Source code in src/leech/crf/evaluate.py
def decode_corpus(
    model,
    signal,
    indices: np.ndarray,
    *,
    mean: float,
    std: float,
    chunk: int,
    n_base: int = 4,
    state_len: int = 4,
    alphabet: str = "NACGT",
    batch_size: int = 256,
    device: str = "cpu",
) -> list[str]:
    """Decode the reads at ``indices``, in corpus order.

    Indices are sorted per batch before gathering, because the corpus is a
    memmap and a sorted gather reads it forwards instead of seeking per row.
    The returned list is in the order of ``indices`` as given, not sorted.
    """
    import torch

    from .decode import decode_batch

    model.eval()
    out: dict[int, str] = {}
    with torch.no_grad():
        for start in range(0, len(indices), batch_size):
            rows = np.sort(indices[start : start + batch_size])
            window = np.asarray(signal[rows][:, -chunk:], dtype=np.float32)
            x = ((torch.from_numpy(window).to(device) - mean) / std).unsqueeze(1)
            for row, seq in zip(
                rows, decode_batch(model(x), n_base, state_len, alphabet), strict=True
            ):
                out[int(row)] = seq
    return [out[int(i)] for i in indices]

emitted_references

emitted_references(targets: dict[str, str], state_len: int) -> dict[str, str]

References as the model emits them: target[state_len:].

Applied once, here, so no caller can hand a full-length target to a matcher and quietly inflate every distance.

Source code in src/leech/crf/evaluate.py
def emitted_references(targets: dict[str, str], state_len: int) -> dict[str, str]:
    """References as the model emits them: ``target[state_len:]``.

    Applied once, here, so no caller can hand a full-length target to a matcher
    and quietly inflate every distance.
    """
    from .manifest import emitted_target

    return {name: emitted_target(target, state_len) for name, target in targets.items()}

call_references

call_references(decodes: list[str], references: dict[str, str], *, candidates: list[str] | None = None) -> list[Call]

Match each decode to its nearest reference by edit distance.

Parameters:

Name Type Description Default
decodes list[str]

one sequence per read.

required
references dict[str, str]

{name: emitted_reference}. Pass :func:emitted_references output, not full-length targets.

required
candidates list[str] | None

restrict matching to these names — the honest candidate set when a group cannot contain every class.

None

Ties resolve to the lowest name with a margin of 0, rather than a silent coin flip.

Source code in src/leech/crf/evaluate.py
def call_references(
    decodes: list[str], references: dict[str, str], *, candidates: list[str] | None = None
) -> list[Call]:
    """Match each decode to its nearest reference by edit distance.

    Args:
        decodes: one sequence per read.
        references: ``{name: emitted_reference}``. Pass
            :func:`emitted_references` output, not full-length targets.
        candidates: restrict matching to these names — the honest candidate set
            when a group cannot contain every class.

    Ties resolve to the lowest name with a margin of 0, rather than a silent
    coin flip.
    """
    if candidates is not None:
        references = {n: references[n] for n in candidates}
    names, packed = encode_references(references)

    calls: list[Call] = []
    for decode in decodes:
        if packed is not None:
            distances = lev_vs_refs(decode, packed)
        else:
            distances = np.array([lev(decode, references[n]) for n in names])
        order = np.argsort(distances, kind="stable")
        best = int(order[0])
        runner_up = int(distances[order[1]]) if len(order) > 1 else int(distances[best])
        calls.append(
            Call(
                name=names[best],
                distance=int(distances[best]),
                margin=runner_up - int(distances[best]),
            )
        )
    return calls

balanced_recall

balanced_recall(truth: list[str] | ndarray, calls: list[Call] | list[str], groups: list[str] | ndarray | None = None) -> dict[str, Any]

Per-group balanced recall, and the per-class recalls behind it.

groups buckets reads by where they came from. It is not optional in spirit: when classes are crossed with batch, one pooled table measures batch rather than signal, and the number still looks reasonable. Passing None means the caller is asserting the classes are not confounded, and everything lands in one bucket named "all".

Reads are bucketed by their own group, and each group's classes are the ones that occur in it. Filtering reads by "is this class in the group" instead is right only when classes partition the groups, and counts every read in every bucket when they do not.

Source code in src/leech/crf/evaluate.py
def balanced_recall(
    truth: list[str] | np.ndarray,
    calls: list[Call] | list[str],
    groups: list[str] | np.ndarray | None = None,
) -> dict[str, Any]:
    """Per-group balanced recall, and the per-class recalls behind it.

    ``groups`` buckets reads by where they came from. **It is not optional in
    spirit**: when classes are crossed with batch, one pooled table measures
    batch rather than signal, and the number still looks reasonable. Passing
    ``None`` means the caller is asserting the classes are *not* confounded,
    and everything lands in one bucket named ``"all"``.

    Reads are bucketed by their own group, and each group's classes are the
    ones that occur in it. Filtering reads by "is this class in the group"
    instead is right only when classes partition the groups, and counts every
    read in every bucket when they do not.
    """
    called = [c.name if isinstance(c, Call) else c for c in calls]
    truth = [str(t) for t in truth]
    if groups is None:
        groups = ["all"] * len(truth)
    groups = [str(g) for g in groups]
    if not (len(truth) == len(called) == len(groups)):
        raise ValueError(
            f"lengths differ: truth={len(truth)}, calls={len(called)}, groups={len(groups)}"
        )

    per_group: dict[str, Any] = {}
    for group in sorted(set(groups)):
        tallies: dict[str, list[int]] = {}
        for t, c, g in zip(truth, called, groups, strict=True):
            if g != group:
                continue
            hit, total = tallies.setdefault(t, [0, 0])
            tallies[t] = [hit + (c == t), total + 1]
        if not tallies:
            continue
        recalls = {k: v[0] / v[1] for k, v in sorted(tallies.items())}
        per_group[group] = {
            "balanced_recall": float(np.mean(list(recalls.values()))),
            "n_classes": len(recalls),
            "n_reads": sum(v[1] for v in tallies.values()),
            "per_class": {k: {"recall": r, "n": tallies[k][1]} for k, r in recalls.items()},
        }

    if not per_group:
        raise ValueError(
            "no reporting group had any reads, so every metric would be null. That "
            "is the grouping/corpus mismatch, and it does not raise on its own — "
            "a null balanced_recall serializes fine and ships."
        )
    return {
        "groups": per_group,
        "balanced_recall": float(np.mean([g["balanced_recall"] for g in per_group.values()])),
    }

lev_vs_refs

lev_vs_refs(query: str, refs: ndarray) -> np.ndarray

lev(query, r) for every row of an (R, M) uint8 array, at once.

The insertion term cur[j-1] + 1 is a serial dependency along j, which is what normally blocks vectorising this. It is recovered exactly: once the substitution and deletion terms give tmp, the true row is cur[j] = min_{k<=j} (tmp[k] + (j - k)), i.e. j + cummin(tmp[k] - k). That identity is the only thing making the algebra trustworthy, so it is asserted against :func:_lev_py on random strings rather than assumed.

Returns an (R,) int array.

Source code in src/leech/crf/evaluate.py
def lev_vs_refs(query: str, refs: np.ndarray) -> np.ndarray:
    """``lev(query, r)`` for every row of an ``(R, M)`` uint8 array, at once.

    The insertion term ``cur[j-1] + 1`` is a serial dependency along ``j``,
    which is what normally blocks vectorising this. It is recovered *exactly*:
    once the substitution and deletion terms give ``tmp``, the true row is
    ``cur[j] = min_{k<=j} (tmp[k] + (j - k))``, i.e.
    ``j + cummin(tmp[k] - k)``. That identity is the only thing making the
    algebra trustworthy, so it is asserted against :func:`_lev_py` on random
    strings rather than assumed.

    Returns an ``(R,)`` int array.
    """
    n_refs, m = refs.shape
    cols = np.arange(m + 1, dtype=np.int32)
    prev = np.broadcast_to(cols, (n_refs, m + 1)).copy()
    if not query:
        return prev[:, -1]
    for i, ch in enumerate(query.encode(), 1):
        sub = prev[:, :-1] + (refs != ch)
        dele = prev[:, 1:] + 1
        tmp = np.empty_like(prev)
        tmp[:, 0] = i
        np.minimum(sub, dele, out=tmp[:, 1:])
        prev = cols + np.minimum.accumulate(tmp - cols, axis=1)
    return prev[:, -1]

Configuration

load_config

load_config(path: str | Path | None = None) -> dict[str, Any]

Parse a CRF architecture config; None means the packaged default.

Returns the raw mapping rather than an EncoderConfig because callers need keys the encoder does not: labels.labels is the decode alphabet, and global_norm.state_len sizes the target as well as the encoder.

Source code in src/leech/crf/config.py
def load_config(path: str | Path | None = None) -> dict[str, Any]:
    """Parse a CRF architecture config; ``None`` means the packaged default.

    Returns the raw mapping rather than an ``EncoderConfig`` because callers
    need keys the encoder does not: ``labels.labels`` is the decode alphabet,
    and ``global_norm.state_len`` sizes the target as well as the encoder.
    """
    resolved = Path(path) if path is not None else DEFAULT_CONFIG
    if not resolved.is_file():
        raise FileNotFoundError(f"CRF config not found: {resolved}")
    with resolved.open("rb") as fh:
        return tomllib.load(fh)

The packaged default is leech/crf/configs/crf_ctc.toml. It travels with the package rather than beside a corpus: an architecture config kept only in scratch means a purge leaves trained weights nobody can load.

Acceleration

Two optional fast paths, both gated and both falling back to the PyTorch reference implementation — which stays the correctness oracle.

Switch Effect
LEECH_COMPILE=1 torch.compile the CRF tail and the forward-backward scans. CUDA only; the CPU path stays eager because inductor's CPU tanh is not bit-exact.
LEECH_NO_TRITON=1 Disable the Triton lattice kernels and use the PyTorch scans.
LEECH_NO_COMPILE=1 Disable compilation of the reference scans in loss.py.

Each also answers to the ESCAPEPOD_ prefix, which is what escapepod-models' equivalence checks set.