escpod classify¶
Read-level classification against a model bundle, from POD5 signal plus an aligned BAM. Today that means the tRNA charging (aminoacylation) classifier: for each read it asks whether the tRNA was charged, and writes the probability onto the BAM.
escpod classify reads.pod5 -b aln.bam -r ref.fa -m bundle/ -o out.bam
Ships in the default build — no extra Cargo feature.
Not escpod demux classify
escpod demux classify is a demux stage: it assigns a barcode from a
DTW/GBM adapter fingerprint CSV. This command asks an entirely different
question of an entirely different input (a POD5 and an aligned BAM).
Between 0.11.0 and 0.18.1 this command was spelled escpod signal
classify, grouped under a signal namespace to keep the two apart. Every
other tool here is one word, so the group was retired in 0.19.0; the
deprecated escpod signal classify alias has since been removed
entirely — use escpod classify.
Usage¶
escpod classify [OPTIONS] -b <BAM> -r <FASTA> -m <BUNDLE> -o <BAM> <POD5>
Arguments¶
| Argument | Description |
|---|---|
<POD5> |
Input POD5 file or directory |
Options¶
| Option | Description |
|---|---|
-b, --bam <BAM> |
Aligned BAM with move tables (dorado --emit-moves, tags preserved through alignment) |
-r, --reference <FASTA> |
Reference FASTA the BAM was aligned to; the CCA|adapter junction is located in every record. The waveform_model variant additionally rebuilds each read's aligned reference from its MD tag rather than slicing this file — see below |
-m, --model <DIR> |
Model bundle directory (or its metadata.json) |
-o, --output <BAM> |
Output BAM: input records with cl added |
--tsv <PATH> |
Also write per-read calls as TSV (read_id, reference, p_<class>, cl, reason) |
--min-mapq <N> |
Minimum mapping quality to classify a read (default: 1) |
--orientation <MODE> |
Move-table signal frame: auto (default), time, or reversed |
-t, --threads <N> |
Threads for parallel processing |
--device <auto\|cpu\|gpu> |
Where the waveform_model variant's TCN inference runs (default auto) — see GPU acceleration. Ignored (with a one-line note) for a GBM/feature-network bundle, which has no GPU path |
-h, --help |
Print help |
Output¶
The cl tag is written as uint8, round(P(charged) · 255), onto every
record of each classified read — no modbase ML → cl round-trip. Reads that
could not be scored carry no tag, and the run reports why each was skipped:
unmapped/filtered, low mapq, reference without junction, missing mv/ns
tags, junction not aligned, query outside move table, non-UUID read name.
--tsv additionally emits one row per read as
read_id, reference, p_<positive class>, cl, reason — the convenient
form for plotting or thresholding outside a BAM reader, and the place the skip
reason is recorded per read rather than only in the summary.
Getting a model bundle¶
Bundles come from
escapepod-models. A bundle is a
directory holding the scorer plus a metadata.json describing the feature
recipe.
The recipe travels in the bundle, never in flags — feature order and offsets, the k-mer table pinned by sha256, the operating point. That is deliberate: a caller computing the features differently gets a wrong answer rather than an error, so there is nothing to configure.
The metadata schema is closed. Every key in the file is a rule the model
was built with, so a key this runtime does not implement is refused at load
rather than silently dropped. (provenance, metrics and caveats are exempt
and free-form.)
That cuts both ways, and it is worth knowing which escpod you are running: a bundle from a newer builder fails to load rather than loading with its new rule quietly ignored. Refusing to answer is the recoverable half of the trade, but it does mean a bundle and a runtime have to be released in that order.
Which basecaller called the corpus¶
A bundle may declare the basecalling model its training corpus was called with:
"basecaller": {"model": "rna004_sup@v6.0.0", "dorado_version": "2.1.1+d66c17c"}
escpod prints it at load and does not check it — it would have to read the
BAM's @PG to know what called the reads it was handed. Checking it is worth
the trouble: the charging feature set is mean + z-scored k-mer residual and
the expected level is predicted from the read's own basecall, so a charging
model substantially detects how the basecaller fails at the aminoacyl adduct.
Scoring the same reads called with a different model loses ~0.010 AUROC, loses
~3 pp of TPR while gaining ~0.4 pp of FPR at the shipped set point (so no
threshold recovers it), and flips 3.9% of per-read calls — while the
aggregate charged fraction moves 0.04 pp. The one number anyone would check
reads "no change" while one read in 26 answers differently.
Bundles published before this key exists do not carry it, and load fine; escpod says so rather than assuming comparability.
Two scorers, one feature space¶
A bundle carries either a gradient-boosted tree model (gbm, which routes
NaN natively) or a small ONNX network over the same columns
(feature_model). Which one a directory holds is a property of the bundle,
never a flag — everything upstream of the final scoring step is shared
verbatim. Both load in the default build.
The network is what escapepod-models ships, and it is the better model: 0.727 of reads callable at 99% precision, against the GBM's 0.449 on a held-out flowcell.
A third variant: the signal window¶
waveform_model reads a signal window rather than a column vector: the
normalised current and its k-mer residual, the sequence k-mer context scattered
along the signal axis, and 12 per-base dwell/level rows — three tensors, one
BCE logit out.
It is a second pipeline over the same two files, not a second scorer on the
first one. It also reads the reference differently: the sequence a read is
scored against is rebuilt from that read's MD tag, the way the training
corpus builds it, not sliced out of the FASTA. Those disagree wherever the
FASTA carries an N — the alignment recorded a real base there — and since
levels are looked up per 9-mer, one N blanks nine of them. A read with no
MD tag is skipped (no MD tag) rather than silently scored off the FASTA.
The bundle says which source it was built from, in
waveform_model.preprocessing.reference_source — absent meaning md, which is
what every published bundle was built with. That is a different question from
motif_reference, which names where the motif is searched (the FASTA, in
reference coordinates, which is right); reading one as an answer to both is how
this runtime got it wrong. A bundle naming a source escpod does not assemble is
refused at load, naming both, rather than scored off MD anyway — the ambiguity
is permanent (it is an ordered degenerate position, so no substituted base is
right for more than 55% of reads), and both sources score every read without
erroring.
The base-to-signal map is walked through the CIGAR into reference
coordinates, the anchor is the motif +2 rather than +3, the spans are
refined by a banded DP before any feature is taken, and the signal frame comes
from the bundle instead of a vote (so --orientation is ignored, with a
warning).
Two things follow from that:
- The bundle version decides whether it loads at all. It goes through the
statically linked tract like every other ONNX graph escpod runs, so a stock
release binary can run one — but only since
charging_tcn_rna004@v0.1.1. The first export,@v0.1.0, defeated tract's shape inference two ways at once — avalue_infoentry per intermediate carrying the batch axis as a symbol, so pinning the batch failed at the first convolution; andadaptive_avg_pool1d(390 → 11)open-coded into a rank-8GatherND— and no graph rewrite fixed either. The fix was the re-export (rnabioco/escapepod-models#96, and #97's build-time gate so a graph the shipped runtime cannot load never registers again). Loading@v0.1.0fails atinto_optimizedwith tract's own analysis error, naming the file. - Its shipped Platt calibration is carried, not applied. The operating point beside it is stated on the uncalibrated probability the graph emits, so calibrating would move the scale out from under the very threshold that ships with the model. escpod says so at load; a caller who wants calibrated probabilities must re-derive the threshold too.
How a read is anchored¶
- Locate the CCA–aa junction in reference coordinates (the
CCAGGCmotif, +3; the windowed variant anchors at +2, one base earlier, and says so in its own metadata). - Map reference → query through the CIGAR.
- Map query → signal through the move table, Remora convention
(
move_pos * stride + ts). - Compute per-base dwell / mean / std plus the z-scored k-mer residual, with everything before the common arm masked.
- Score.
--orientation¶
Some runs index reversed signal in their move tables. Getting this wrong silently mirrors every window, so escpod detects it per run by vote rather than assuming — but the vote needs ≥ 50 informative reads and a 95% consensus to commit.
Leave it at auto for a normal run. Use --orientation time or
--orientation reversed to force the frame on a batch too small for the vote
to resolve.
GPU acceleration¶
Only the waveform_model variant has a GPU path — the windowed TCN, batched
through tract-cuda rather than
onnxruntime. GBM and feature_model bundles have no device path at all;
--device gpu on one of those is a no-op with a one-line log note, never an
error. Build with --features gpu (see demux's GPU
section for the Cargo/pixi setup — one gpu
feature covers every GPU-capable stage in the binary, classify included).
tract-cuda needs more than the demux GPU stages do. detect --method cnn
and the CRF encoder go through onnxruntime, which only dlopens a prebuilt
libonnxruntime — the CUDA driver (libcuda.so, part of the NVIDIA driver,
always present on a GPU node) is enough for that check. classify's TCN
instead builds a CUDA context and compiles its own kernels at run time
through NVRTC, and needs two more things the driver alone does not supply:
- The CUDA runtime library,
libcudart, discoverable by name atdlopentime — not just the driver. A node can have a perfectly good driver and still fail here if thelibcudartit finds is the wrong major version (see rnabioco/escapepod-rs#347's post-mortem below). - CUDA headers (
cuda_fp16.h, the CCCL headers) on disk at run time, for NVRTC to compile against — nothing else in this binary needs headers past build time.
The pixi gpu environment (pixi run install-gpu, then pixi run -e gpu … —
see demux's runtime-libraries
section) already pins both:
cuda-cudart/cuda-cudart-dev for the runtime library and cuda-cccl for
the headers. Run escpod classify --device gpu the same way you would run any
other GPU-capable command — inside pixi run -e gpu, or with that
environment's LD_LIBRARY_PATH reproduced by hand. Running the released
-gpu binary bare, with no CUDA environment set up by the caller, means
libcudart resolution falls through to whatever the node happens to expose
on its default library path — see the warning below.
rnabioco/escapepod-rs#347: Missing symbol cudaGetDeviceProperties_v2
Reported as a raw panic —
thread 'main' panicked at cudarc-0.19.9/.../runtime/sys/mod.rs: Missing
symbol cudaGetDeviceProperties_v2: dlsym failed — on nodes where the CUDA
driver worked fine (other CUDA tools on the same node ran normally) but
escpod classify --device gpu was invoked without the pixi gpu
environment's LD_LIBRARY_PATH active. cudarc's dynamic loader tries the
bare, unversioned libcudart.so before any versioned name, so on a node
whose default library path exposes a system CUDA install of a different
major version — CUDA 13, say — that is the one it finds, and CUDA 13
dropped the _v2 symbol this build's pinned CUDA 12.x API needs.
escpod now turns this into a named error instead of a process abort
(--device gpu fails with the missing-symbol detail and a pointer to this
section; --device auto logs a warning and falls back to the CPU scorer
rather than crashing) — but the fix that lets the GPU actually run is
making sure libcudart resolves inside the pixi gpu environment before
anything from the node's own CUDA install is on the search path. A
pipeline that shadows a GPU-enabled escpod onto PATH for one command
(rather than invoking it via pixi run -e gpu) has to reproduce that
LD_LIBRARY_PATH itself, the same way it already would for the CNN/CRF
onnxruntime stages.
Notes¶
- A bundle's
abstainrule is carried and warned about, not applied — if your bundle declares one, the calls here are unabstained and you should apply the rule downstream. - Parity with the training-corpus implementation (
escapepod_models.charging) is pinned by golden-vector tests, and the ONNX arm additionally by bit-exact reference-vector parity plus a real-weights run over a real corpus. - BAM file I/O lives in the CLI; the classifier itself is the
escapepod-classifycrate, which owns every definition of the model's input.