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.
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.)
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.
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.
(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 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.
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.
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.
fromleech.crfimportload_manifest,plan_corpus,build_corpus,load_corpusplan=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.
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.
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.
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.
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.
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.
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.
def__init__(self,cfg:EncoderConfig|None=None)->None:super().__init__()self.cfg=cfgorEncoderConfig()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)foriinrange(c.n_layers))self.linear=nn.Linear(c.features,c.n_states*c.n_base)
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).
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.
defencoder_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",{})returnEncoderConfig(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 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.
defload_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.ifnotany(k.startswith("encoder.")forkinstate_dict):model.load_state_dict(state_dict,strict=strict)returnmodelunmapped=sorted(set(state_dict)-set(mapping))ifunmappedandstrict:raiseKeyError(f"checkpoint has {len(unmapped)} key(s) this module does not know: "f"{unmapped[:5]}{'...'iflen(unmapped)>5else''}")renamed={mapping[k]:vfork,vinstate_dict.items()ifkinmapping}model.load_state_dict(renamed,strict=strict)returnmodel
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.
defnormalise(self,scores:Tensor)->Tensor:"""Subtract the full-lattice logZ, spread evenly across timesteps."""returnscores-logZ_full(scores,self.idx)[None,:,None]/scores.shape[0]
defgather_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+1tgt=(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)foriinrange(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]+1stay=scores.gather(2,stay_at.expand(t_len,-1,-1))move=scores.gather(2,move_at.expand(t_len,-1,-1))returnstay,move
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::
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.
defforward(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)ifnormalise:logz=logz-logZ_full(scores,self.idx)loss=-(logz/target_lengths)ifreduction=="mean":returnloss.mean()ifreductionin("none",None):returnlossraiseValueError(f"unknown reduction {reduction!r}")
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.
defpredecessor_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_lenstates=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_basemoves=shifted[None,:]+(torch.arange(n_base,device=device)[:,None]*n_base**(state_len-1))returntorch.cat([stay,moves.T],dim=1).long()
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.
@torch.no_grad()defdecode_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()forrowinchars]
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).
defbest_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+1t_len,batch,width=scores.shapeifwidth!=n_states*n_edges:raiseValueError(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)returnbest//n_edges,best%n_edges
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.
defload_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. """importpolarsaspl# lazy: `leech.crf` must import only torch and numpypath=Path(path)ifnotpath.is_file():raiseFileNotFoundError(f"manifest not found: {path}")suffix=path.suffix.lower()ifsuffix==".parquet":frame=pl.read_parquet(path)elifsuffixin(".tsv",".txt"):frame=pl.read_csv(path,separator="\t")elifsuffix==".csv":frame=pl.read_csv(path)else:raiseValueError(f"unrecognised manifest format {suffix!r} ({path}); "f"expected .parquet, .tsv, .txt or .csv")missing=[cforcinREQUIRED_COLUMNSifcnotinframe.columns]ifmissing:raiseValueError(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=[cforcinrequireifcnotinframe.columns]ifunknown_required:raiseValueError(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')}"forcinunknown_required))ifframe.height==0:raiseValueError(f"{path}: manifest is empty")duplicates=frame.height-frame["read_id"].n_unique()ifduplicates:raiseValueError(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.")returnCrfManifest(frame=frame,path=path)
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.
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.
defquality_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. """ifnotself.has("quality_score"):return1.0ifnotlen(self):return0.0return1.0-(self.frame["quality_score"].null_count()/len(self))
defbatches(self)->list[str]:"""Distinct ``batch`` values, or ``[]`` when the column is absent."""ifnotself.has("batch"):return[]returnsorted(self.frame["batch"].drop_nulls().unique().to_list())
deftarget_lengths(self)->set[int]:"""Distinct target lengths. More than one is legal but rarely intended."""returnset(self.frame["target"].str.len_chars().unique().to_list())
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.
defcheck_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. """iftarget_len<=state_len:raiseValueError(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_baseifwindow<needed:raiseValueError(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.")
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.
defemitted_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. """ifstate_len<0:raiseValueError(f"state_len must be >= 0, got {state_len}")returntarget[state_len:]
defplan_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. """importpolarsasplman=manifestifisinstance(manifest,CrfManifest)elseload_manifest(manifest)frame=_group_column(man.frame)if"batch"notinframe.columns:frame=frame.with_columns(pl.lit("all").alias("batch"))ifgroupsisnotNone:frame=frame.filter(pl.col("group").is_in(groups))ifframe.height==0:raiseValueError(f"no reads for groups {groups}")before=frame.heightframe=frame.filter(pl.col("anchor_end")>chunk+ANCHOR_MARGIN)dropped=before-frame.heightifframe.height==0:raiseValueError(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,partinsorted(((k[0],p)fork,pinframe.partition_by("batch",as_dict=True).items()),key=lambdakv: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))forg,nincounts.items()}rarest_group=min(trainable,key=lambdag:trainable[g])rarest=trainable[rarest_group]ifper_group=="auto":cap=rarestlogger.info("per_group=auto -> %d (trainable depth of %s)",cap,rarest_group)else:cap=int(per_group)ifcap>rarest:short=sorted(gforg,nintrainable.items()ifn<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")ifshard_batchesisnotNone:want=set(shard_batches)missing=want-set(planned["batch"].unique().to_list())ifmissing:raiseValueError(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)))returnCorpusPlan(frame=planned,cap=cap,chunk=chunk,dropped_short_anchor=dropped)
defbuild_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.fromleech.io.pod5_readerimportread_pod5_signals_batch_cachedout=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:[]forkin("target","group","read_id","split","batch")}quality:dict[str,list]={"quality_score":[],"quality_margin":[]}has_quality={k:kinframe.columnsforkinquality}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,),partinsorted(frame.partition_by("pod5",as_dict=True).items(),key=lambdakv:str(kv[0][0])):rows=part.to_dicts()forstartinrange(0,len(rows),extract_batch):block=rows[start:start+extract_batch]found=read_pod5_signals_batch_cached(source,[r["read_id"]forrinblock])forrowinblock:hit=found.get(row["read_id"])ifhitisNone:continueraw=hit[0]end=int(row["anchor_end"])ifend-chunk<0orend>len(raw):continuesignal[n]=np.asarray(raw[end-chunk:end],dtype=np.float32)forkeyinkept:kept[key].append(row[key])forkey,presentinhas_quality.items():quality[key].append(float(row[key])ifpresentelsenp.nan)n+=1logger.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.ifn==0:sources=sorted({str(s)forsinframe["pod5"].unique().to_list()})[:3]delsignalraiseRuntimeError(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.")ifn<0.5*totalandnotallow_shortfall:delsignalraiseRuntimeError(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.")ifn<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]))ifnelse0,"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)returnx_path
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.
defload_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]ifstr(path).endswith(".npz")elsepath)streamed=base.with_name(base.name+"_X.npy")ifstreamed.exists():signal=np.load(streamed,mmap_mode="r"ifmmapelseNone)meta=np.load(base.with_name(base.name+"_meta.npz"),allow_pickle=True)group_key="group"if"group"inmetaelse"code"split=meta["split"].astype(str)if"split"inmetaelseNonereturn(signal,meta["y"].astype(str),meta[group_key].astype(str),meta["read_id"].astype(str),split,)legacy_path=base.with_suffix(".npz")ifnotlegacy_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.raiseFileNotFoundError(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"inlegacyelse"code"return(legacy["X"],legacy["y"].astype(str),legacy[group_key].astype(str),legacy["read_id"].astype(str),None,)
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.
defload_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]ifstr(path).endswith(".npz")elsepath)meta_path=base.with_name(base.name+"_meta.npz")ifnotmeta_path.exists():return{}returndict(np.load(meta_path,allow_pickle=True))
defprepare(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,}
deftrain(self)->dict[str,Any]:"""Run the schedule and return the result, writing it if asked."""importtorchfromtorchimportnnfrom.encoderimportCrfEncoderfrom.lossimportCtcCrfLosscfg=self.cfgdevice=cfg.resolved_device()prep=self.prepare()mean,std=prep["mean"],prep["std"]train_idx=prep["train_idx"]iflen(train_idx)<cfg.batch_size:raiseValueError(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=Nonebest_loss=float("inf")forepochinrange(1,cfg.epochs+1):model.train()order=train_idx.copy()rng.shuffle(order)t0=time.time()total=worst=grad_max=0.0n_batches=n_skipped=n_nonfinite=0forstartinrange(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)withtorch.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_beforeifmath.isfinite(gnorm):grad_max=max(grad_max,gnorm)else:n_nonfinite+=1value=float(loss.detach())worst=max(worst,value)total+=valuen_batches+=1stats=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))ifstats.loss<best_loss:best_loss=stats.lossbest_state={k:v.detach().to("cpu",copy=True)fork,vinmodel.state_dict().items()}epoch,loss,why=select_checkpoint(history,select_tol=cfg.select_tol,always_final=cfg.always_final)ifepoch!=history[-1].epochandbest_stateisnotNone: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=lambdae:e.loss).epoch,"best_loss":best_loss,"history":[asdict(e)foreinhistory],"config":asdict(cfg),}ifself.output_dirisnotNone:self._write(model,result,self.output_dir)returnresult|{"model":model}
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.
defcompute_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=0total=0.0total_sq=0.0forstartinrange(0,len(signal),block):values=np.asarray(signal[start:start+block,-chunk:],dtype=np.float64)n+=values.sizetotal+=values.sum()total_sq+=(values*values).sum()ifn==0:raiseValueError("cannot standardise an empty corpus")mean=total/nvariance=max(total_sq/n-mean*mean,0.0)returnfloat(mean),float(math.sqrt(variance))
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.
defapply_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. """ifn_readsisNone:n_reads=len(score)ifscoreisnotNoneelse0ifnotenabled:logger.info("quality gate disabled; training on all %d reads",n_reads)returnnp.ones(n_reads,dtype=bool),1.0ifscoreisNoneormarginisNone:raiseValueError("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())iflen(scored)else0.0ifcoverage==0.0:raiseValueError("the corpus carries label-quality columns but every value is missing. ""Re-score it, or pass gate=False deliberately.")ifcoverage<min_coverage:raiseValueError(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,)returnkeep,coverage
(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.
defresolve_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)ifholdout_batchisnotNone:ifbatchesisNone:raiseValueError("holdout_batch needs a corpus carrying a batch column")held=np.char.startswith(batches.astype(str),holdout_batch)ifnotheld.any():raiseValueError(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)))returntrain,test,f"held-out batch {holdout_batch} ({n_out} batch(es) out)"ifcorpus_splitisnotNoneandnotresplit:train=np.flatnonzero(clean&(corpus_split=="train"))test=np.flatnonzero(clean&(corpus_split=="test"))rng.shuffle(train)returntrain,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"ifcorpus_splitisnotNoneelse"")returnidx[n_test:],idx[:n_test],why
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.
defselect_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. """ifnothistory:raiseValueError("no epochs were run")final=history[-1]best=min(history,key=lambdae:e.loss)ifalways_finalorfinal.loss<=best.loss*(1+select_tol):returnfinal.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",)
defexport_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``. """importtorchfromleech.onnx_exportimportOPSET,export_onnx,verify_onnxfrom.configimportload_configfrom.encoderimportCrfEncoder,encoder_config_from_toml,load_crf_state_dictopset=opsetorOPSETcheckpoint=Path(checkpoint)ifcheckpoint.is_dir():checkpoint=checkpoint/"model.pt"out_dir=Path(out_dir)ifsidecarisnotNoneandnotisinstance(sidecar,dict):sidecar=load_training_sidecar(sidecar)side:dict[str,Any]=sidecaror{}mean=meanifmeanisnotNoneelseside.get("mean")std=stdifstdisnotNoneelseside.get("std")ifmeanisNoneorstdisNone:raiseValueError("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_configifarch_configisnotNoneelseload_config())chunk=chunkorside.get("chunk")orcfg.chunkfromdataclassesimportreplacecfg=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=Noneifverify:max_diff=verify_onnx(onnx_path,model,example,input_names=["signal"])state_len=cfg.state_lenemitted=({name:target[state_len:]forname,targetinreferences.items()}ifreferenceselseNone)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",},}ifemittedisnotNone:metadata["references"]=emittedmetadata["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.")ifmax_diffisnotNone:metadata["verification"]={"onnxruntime_vs_torch_max_abs_diff":max_diff,"float32_eps":1.1920929e-07,}forkeyin("selected_epoch","selected_loss","seed","corpus","target_len"):ifkeyinside: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)returnonnx_path
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.
defload_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)ifpath.is_dir():path=path/"model.json"ifnotpath.is_file():raiseFileNotFoundError(f"training sidecar not found: {path}")returnjson.loads(path.read_text())
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.
defdecode_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. """importtorchfrom.decodeimportdecode_batchmodel.eval()out:dict[int,str]={}withtorch.no_grad():forstartinrange(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)forrow,seqinzip(rows,decode_batch(model(x),n_base,state_len,alphabet),strict=True):out[int(row)]=seqreturn[out[int(i)]foriinindices]
defemitted_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.manifestimportemitted_targetreturn{name:emitted_target(target,state_len)forname,targetintargets.items()}
defcall_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. """ifcandidatesisnotNone:references={n:references[n]fornincandidates}names,packed=encode_references(references)calls:list[Call]=[]fordecodeindecodes:ifpackedisnotNone:distances=lev_vs_refs(decode,packed)else:distances=np.array([lev(decode,references[n])forninnames])order=np.argsort(distances,kind="stable")best=int(order[0])runner_up=int(distances[order[1]])iflen(order)>1elseint(distances[best])calls.append(Call(name=names[best],distance=int(distances[best]),margin=runner_up-int(distances[best]),))returncalls
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.
defbalanced_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.nameifisinstance(c,Call)elsecforcincalls]truth=[str(t)fortintruth]ifgroupsisNone:groups=["all"]*len(truth)groups=[str(g)forgingroups]ifnot(len(truth)==len(called)==len(groups)):raiseValueError(f"lengths differ: truth={len(truth)}, calls={len(called)}, groups={len(groups)}")per_group:dict[str,Any]={}forgroupinsorted(set(groups)):tallies:dict[str,list[int]]={}fort,c,ginzip(truth,called,groups,strict=True):ifg!=group:continuehit,total=tallies.setdefault(t,[0,0])tallies[t]=[hit+(c==t),total+1]ifnottallies:continuerecalls={k:v[0]/v[1]fork,vinsorted(tallies.items())}per_group[group]={"balanced_recall":float(np.mean(list(recalls.values()))),"n_classes":len(recalls),"n_reads":sum(v[1]forvintallies.values()),"per_class":{k:{"recall":r,"n":tallies[k][1]}fork,rinrecalls.items()},}ifnotper_group:raiseValueError("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"]forginper_group.values()])),}
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.
deflev_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.shapecols=np.arange(m+1,dtype=np.int32)prev=np.broadcast_to(cols,(n_refs,m+1)).copy()ifnotquery:returnprev[:,-1]fori,chinenumerate(query.encode(),1):sub=prev[:,:-1]+(refs!=ch)dele=prev[:,1:]+1tmp=np.empty_like(prev)tmp[:,0]=inp.minimum(sub,dele,out=tmp[:,1:])prev=cols+np.minimum.accumulate(tmp-cols,axis=1)returnprev[:,-1]
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.
defload_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)ifpathisnotNoneelseDEFAULT_CONFIGifnotresolved.is_file():raiseFileNotFoundError(f"CRF config not found: {resolved}")withresolved.open("rb")asfh:returntomllib.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.