Data Preparation Modules¶
Data loading, feature extraction, and training chunk preparation.
Overview¶
The data preparation functionality has been refactored into modular components for better maintainability and testing. The previous monolithic data_prep.py module has been split into:
leech.io- Input/output operations (BAM/POD5 reading, motif search, reference handling)leech.preparation- Data preparation orchestration and parallel processingleech.chunking- Training chunk extraction and serializationleech.splitting- Train/val/test data splittingleech.commands- CLI command implementations
I/O Module (leech.io)¶
BAM Reading¶
Iterator for reading BAM alignments with filtering.
iter_bam_alignments
¶
iter_bam_alignments(bam_path: Path, min_mapq: int = 0, require_tags: list[str] | None = None, include_unmapped: bool = False, include_secondary: bool = False, include_supplementary: bool = False) -> Iterator[pysam.AlignedSegment]
Iterate over BAM alignments with filtering.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bam_path
|
Path
|
Path to BAM file |
required |
min_mapq
|
int
|
Minimum mapping quality |
0
|
require_tags
|
list[str] | None
|
List of required BAM tags (default: ["mv", "ns"]) |
None
|
include_unmapped
|
bool
|
Include unmapped reads |
False
|
include_secondary
|
bool
|
Include secondary alignments |
False
|
include_supplementary
|
bool
|
Include supplementary alignments |
False
|
Yields:
| Type | Description |
|---|---|
AlignedSegment
|
Filtered BAM alignments |
Example
for aln in iter_bam_alignments(Path("alignments.bam"), min_mapq=10): ... print(f"{aln.query_name}: {aln.mapping_quality}")
POD5 Reading¶
Read raw signal from POD5 files.
read_pod5_signal
¶
Read raw signal from a POD5 source for a specific read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pod5_path
|
Path
|
Path to a |
required |
read_id
|
str
|
Read identifier |
required |
Returns:
| Type | Description |
|---|---|
tuple[ndarray, dict]
|
Tuple of (signal_array, metadata_dict) |
Raises:
| Type | Description |
|---|---|
ValueError
|
If read_id not found in any file under |
Examples:
Motif Search¶
Base class for motif search strategies.
MotifSearcher
¶
Bases: ABC
Abstract base class for motif search strategies.
Subclasses implement different strategies for finding motifs in reads.
find_motif_positions
abstractmethod
¶
find_motif_positions(read_id: str, sequence: str, alignment: AlignedSegment | None, motif: str) -> list[int]
Find positions of motif in read.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
read_id
|
str
|
Read identifier |
required |
sequence
|
str
|
Basecalled sequence |
required |
alignment
|
AlignedSegment | None
|
BAM alignment (may be None for basecalled search) |
required |
motif
|
str
|
Motif to search for |
required |
Returns:
| Type | Description |
|---|---|
list[int]
|
List of positions in query sequence where motif starts (0-based) |
Chunking Module (leech.chunking)¶
LeechRead¶
Container for a single read's data with all features.
LeechRead
¶
LeechRead(read_id: str, sequence: str, signal: ndarray, seq_to_sig_map: ndarray, dwells: ndarray, dwell_features: dict[str, ndarray], signal_features: dict[str, ndarray], labels: ndarray | None = None, metadata: dict | None = None, signal_residual: ndarray | None = None, full_signal: ndarray | None = None, signal_offset: int = 0)
Container for a single read's data with all features.
Attributes:
| Name | Type | Description |
|---|---|---|
read_id |
Unique read identifier |
|
sequence |
Basecalled sequence |
|
signal |
Normalized signal array (cropped to the aligned region in ref-anchored mode; full trimmed/reversed signal otherwise) |
|
seq_to_sig_map |
Mapping from base indices to signal indices |
|
dwells |
Per-base dwell times |
|
dwell_features |
Dict of dwell-derived features |
|
signal_features |
Dict of signal-level features |
|
feature_channels |
The two dicts merged into the ordered |
|
labels |
Optional labels for training (e.g., 0=uncharged, 1=charged) |
|
metadata |
Additional metadata (alignment info, etc.) |
|
full_signal |
When ref-anchored mode crops |
|
signal_offset |
Index of |
get_chunk
¶
get_chunk(base_idx: int, config: ChunkConfig | None = None, signal_context: tuple[int, int] = DEFAULT_SIGNAL_CONTEXT, kmer_context: int = DEFAULT_KMER_CONTEXT, base_justify: str = 'center', feature_start: int | None = None, feature_end: int | None = None, recover_softclip_signal: bool = False) -> dict[str, np.ndarray | str | int | None] | None
Extract a training chunk centered on a specific base.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_idx
|
int
|
Index of the focus base |
required |
config
|
ChunkConfig | None
|
Optional ChunkConfig that overrides individual params. |
None
|
signal_context
|
tuple[int, int]
|
(left, right) signal padding around focus base |
DEFAULT_SIGNAL_CONTEXT
|
kmer_context
|
int
|
Number of bases on each side for k-mer encoding |
DEFAULT_KMER_CONTEXT
|
base_justify
|
str
|
"center", "start", or "end" |
'center'
|
feature_start
|
int | None
|
Signed offset from focus for feature window start. |
None
|
feature_end
|
int | None
|
Signed offset from focus for feature window end (inclusive). |
None
|
recover_softclip_signal
|
bool
|
When True and |
False
|
Returns:
| Type | Description |
|---|---|
dict[str, ndarray | str | int | None] | None
|
Dictionary with 'signal', 'kmer', 'dwell', 'features' arrays, |
dict[str, ndarray | str | int | None] | None
|
or None if chunk cannot be extracted |
Extraction Sequence¶
The single definition of which sequence chunks are cut from, and that focus
base indices refer to: the aligned reference slice under anchor="reference",
the basecall otherwise. Both data prepare and leech predict go through it.
extraction_sequence
¶
extraction_sequence(*, anchor: str, basecall: str, reference_sequence: str | None, cigar_tuples: list[tuple[int, int]] | None) -> str
The sequence chunks are cut from, and that focus bases index into.
Must track build_leech_read's choice exactly: under
anchor="reference", with both a reference sequence and a CIGAR to map
through, chunks come from the aligned reference slice; otherwise from the
basecall. Motif positions are indices into this string, so handing the
searcher the other one returns coordinates in the wrong frame.
Only observable with a BasecalledMotifSearcher --
ReferenceMotifSearcher ignores the sequence argument and reads the
alignment instead -- which is why two of the three inference paths could
pass the basecall under anchor="reference" without anyone noticing.
That combination is reachable: predict selects the searcher with
mode="fasta" if reference_sequences else "bam", so a run without a
reference FASTA gets the basecalled searcher while chunks are still cut in
reference coordinates.
Find Focus Bases¶
The single definition of which bases a read contributes chunks at. Both prepare backends call it.
find_focus_bases
¶
find_focus_bases(read_id: str, sequence: str, alignment: AlignedSegment | None, motif_config: MotifConfig, motif_searcher: MotifSearcher | None) -> list[int]
Which bases of a read contribute chunks.
The single definition of that rule. Both prepare backends call it: the
Python one from :func:extract_training_chunks with a built
:class:LeechRead, the Rust one from
leech.preparation.parallel._find_motif_positions with the ReadInfo
it has not yet turned into a read. They used to carry a copy each, which
drifted — the Rust copy fell back to all-bases over the query sequence
while this one used the reference (issue #185).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
read_id
|
str
|
Read identifier, for the searcher's diagnostics. |
required |
sequence
|
str
|
The sequence chunks are cut from — the aligned reference
slice under |
required |
alignment
|
AlignedSegment | None
|
BAM alignment (or mock), required for reference search. |
required |
motif_config
|
MotifConfig
|
Motif, offset, and search mode. |
required |
motif_searcher
|
MotifSearcher | None
|
Searcher strategy; required when a motif is set. |
required |
Returns:
| Type | Description |
|---|---|
list[int]
|
Focus base indices into |
list[int]
|
Out-of-range indices are possible and are the caller's to reject. |
Extract Training Chunks¶
Extract training chunks centered on motifs.
extract_training_chunks
¶
extract_training_chunks(leech_read: LeechRead, motif_config: MotifConfig, chunk_config: ChunkConfig, labeling: LabelConfig, motif_searcher: MotifSearcher | None = None) -> list[dict[str, np.ndarray | str | int | None]]
Extract all training chunks from a read, optionally filtered by motif.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
leech_read
|
LeechRead
|
LeechRead object |
required |
motif_config
|
MotifConfig
|
Motif configuration (motif, motif_offset) |
required |
chunk_config
|
ChunkConfig
|
Chunk configuration (base_justify, feature_start/end, etc.) |
required |
labeling
|
LabelConfig
|
Label configuration (label, label_int) |
required |
motif_searcher
|
MotifSearcher | None
|
MotifSearcher instance (required if motif is provided) |
None
|
Returns:
| Type | Description |
|---|---|
list[dict[str, ndarray | str | int | None]]
|
List of chunk dictionaries |
Chunk Serialization¶
Save and load training chunks.
save_chunks
¶
Save training chunks to numpy format.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[dict]
|
List of chunk dictionaries from extract_training_chunks |
required |
output_path
|
Path
|
Output file path (.npz) |
required |
compressed
|
bool
|
If True (default), compress members with zlib
( |
True
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If chunks list is empty |
Format
Saves as .npz with arrays:
- signals: (N, signal_len) raw signal chunks (object array for variable length)
- sequences: (N,) string array of k-mer sequences
- dwells: (N, kmer_len) dwell times (object array for variable length)
- features: (N, num_features, kmer_len) feature arrays (object array)
- labels: (N,) string labels (e.g., "Ala", "Gly")
- labels_int: (N,) integer labels (0, 1, or -1 if unset)
- read_ids: (N,) string array of read IDs
- base_indices: (N,) base indices
- seq_to_sig_values / seq_to_sig_offsets: base-to-signal maps in CSR
form; row i is values[offsets[i]:offsets[i + 1]]. Files written
before v0.6.8 carry a pickled object array named seq_to_sig_maps
instead, which :func:load_chunks still reads.
Note
Members are stacked and written one at a time rather than collected
into a np.savez call, so only one of them is resident at a time.
Callers that do not already have the whole corpus as a list should use
:class:ChunkNpzWriter, which never builds one.
Examples:
load_chunks
¶
Load training chunks from compressed numpy format.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_path
|
Path
|
Path to .npz file |
required |
defer
|
Collection[str]
|
Chunk array fields to leave unread (see :data: |
()
|
Returns:
| Type | Description |
|---|---|
list[dict]
|
List of chunk dictionaries compatible with extract_training_chunks output |
Note
Every member requested is read in full: np.load does not memory-map
zip members, compressed or not, so there is no lazy path here. On a
large corpus this dominates peak memory, which is what defer and
:func:iter_npz_row_blocks exist to avoid (#211).
Examples:
Streaming Chunk Arrays¶
Read the per-chunk arrays a row block at a time instead of materialising whole
members — how LeechDataset builds its tensors without holding a second copy
of the corpus.
npz_array_members
¶
Shapes and dtypes of the row-streamable members of an .npz, without reading data.
Only the zip central directory and each member's .npy header are read, so
this is O(number of members) regardless of file size. Members that
:func:iter_npz_row_blocks cannot stream — object dtypes (pickled),
Fortran order, 0-d — are omitted, so name in npz_array_members(path)
is the test for "can I stream this".
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_path
|
Path
|
Path to .npz file |
required |
Returns:
| Type | Description |
|---|---|
dict[str, tuple[tuple[int, ...], dtype]]
|
Mapping of member name (without the .npy suffix) to |
Examples:
iter_npz_row_blocks
¶
iter_npz_row_blocks(input_path: Path, names: Collection[str], block_rows: int | None = None, *, block_bytes: int = 8 << 20) -> Iterator[tuple[int, dict[str, np.ndarray]]]
Yield (row_start, {name: block}) over the rows of fixed-shape npz members.
np.load reads a whole member at once — it never memory-maps a zip
member, compressed or not — so converting a large corpus means holding the
numpy source and the converted output at the same time (#211). This walks
the members as sequential row blocks instead, so only one block per member
is resident.
Blocks are reused between iterations. The yielded arrays are views into buffers that the next iteration overwrites; copy anything you need to keep.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_path
|
Path
|
Path to .npz file |
required |
names
|
Collection[str]
|
Member names to stream, without the .npy suffix. All must have
the same number of rows (see :func: |
required |
block_rows
|
int | None
|
Rows per block. Default sizes the block from |
None
|
block_bytes
|
int
|
Target resident bytes across all streamed members, used
when |
8 << 20
|
Yields:
| Type | Description |
|---|---|
int
|
|
dict[str, ndarray]
|
n_rows - row_start)`` rows. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If a member is not streamable, row counts disagree, or the member is truncated. |
load_seq_to_sig_csr
¶
Load the base-to-signal maps in CSR form: (values, offsets).
Row i is values[offsets[i]:offsets[i + 1]]. Files written before
v0.6.8 store these as a pickled object array (seq_to_sig_maps); those
are converted here so callers see one representation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_path
|
Path
|
Path to .npz file |
required |
Returns:
| Type | Description |
|---|---|
tuple[ndarray, ndarray] | None
|
|
Writing a Corpus Incrementally¶
The write-direction counterpart to the row-block reader above. data prepare
used to build the whole corpus as a list of chunk dicts and hand it to
save_chunks, so peak memory held the dicts, their arrays and the stacked copy
at once. ChunkNpzWriter takes batches as they are extracted and spools each
member to disk, assembling the .npz at the end — peak drops from ~2.5x the
corpus to ~0.25x.
The output is byte-compatible with save_chunks: same members, dtypes, shapes
and semantics, readable by load_chunks, ChunkTable.from_npz and
iter_npz_row_blocks alike. Note that the corpus is written twice (spill, then
.npz), so the output directory needs room for it twice over.
ChunkNpzWriter
¶
ChunkNpzWriter(output_path: Path, *, compressed: bool = True, spill_dir: Path | None = None, batch_rows: int = 4096)
Write one .npz from chunk batches, without ever holding the corpus.
The streaming counterpart of :func:save_chunks, for callers that produce
chunks a batch at a time (data prepare). Output is byte-compatible; see
:class:ChunkSpool for the mechanics and the disk-space trade-off.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
output_path
|
Path
|
Output file path (.npz appended if absent). |
required |
compressed
|
bool
|
If True (default), compress members with zlib. |
True
|
spill_dir
|
Path | None
|
Where the temp files go. Defaults to the output directory. |
None
|
batch_rows
|
int
|
Chunks buffered before a spill write. |
4096
|
Examples:
ChunkSpool
¶
Accumulate chunk batches on disk, then write one or more .npz corpora.
save_chunks needs the whole corpus as a list before it writes anything,
which is what makes data prepare peak at the corpus plus its stacked
copy (#211). A spool takes the same chunks a batch at a time, spills each
npz member to its own temp file as it goes, and assembles the .npz at the
end — so no batch outlives the append call that delivered it.
Output is byte-compatible with :func:save_chunks: same member names,
order, dtypes, shapes and values, including the CSR
seq_to_sig_values/seq_to_sig_offsets pair and the object-array
fallbacks for ragged chunks. tests/test_chunk_writer.py holds the two
writers to that.
Trade-off: the corpus is written twice (once to the spill, once into the
.npz) and the spill needs corpus-sized scratch space in spill_dir,
which defaults to the output directory. That buys back the corpus-sized
peak in RAM.
What is not spilled: string members are held in memory (a few hundred
bytes per chunk — they are what the read-level split is computed from), and
the ragged object-array fallback is buffered like save_chunks does,
since a pickled member cannot be appended to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
spill_dir
|
Path
|
Directory for the temp files. Must have room for the corpus. |
required |
compressed
|
bool
|
Default for :meth: |
True
|
batch_rows
|
int
|
Chunks buffered before a spill write. Callers that append one read at a time (the sequential prepare path) would otherwise pay a per-member array build per read. |
4096
|
Examples:
>>> with ChunkSpool(Path("out")) as spool:
... for batch in batches:
... spool.append(batch)
... spool.write_npz(Path("out/all.npz"))
append
¶
Add a batch of chunks. The caller may drop them immediately after.
text_column
¶
One text member as a single array, at the width the .npz will use.
write_npz
¶
Write the spooled chunks to output_path as an .npz.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
output_path
|
Path
|
Output file path (.npz appended if absent). |
required |
rows
|
ndarray | None
|
Row indices to write, in output order. |
None
|
compressed
|
bool | None
|
Overrides the spool's default. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
Number of chunks written. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the spool is empty or a member has the wrong length. |
Chunk Metadata Table¶
Per-chunk metadata as columns, read as a sequence of mappings — what
LeechDataset.chunks holds when the corpus is loaded from a path.
ChunkTable
¶
Bases: Sequence
Chunk metadata as columns, presented as a sequence of read-only mappings.
Indexing yields a :class:ChunkRow; iterating yields one per chunk. Rows
are views, so they cost nothing to keep out of and nothing is shared with
the caller to mutate.
Examples:
>>> table = ChunkTable.from_npz(Path("chunks.npz"))
>>> table[0]["label_int"]
1
>>> table.values("label_int") # raw column, for vectorized tallies
...
array([1, 0, 1, ...], dtype=int8)
from_npz
classmethod
¶
Read a corpus's metadata members — never its per-chunk arrays.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
input_path
|
Path
|
Path to .npz file. |
required |
skip
|
Collection[str]
|
Chunk field names to leave out. Text the run will not read is
worth skipping: |
()
|
Returns:
| Type | Description |
|---|---|
ChunkTable
|
A table with one row per chunk in file order. |
select
¶
Return a table holding only the rows where mask is True.
values
¶
The raw column for field, or None if the table lacks it.
Raw means as stored: text comes back as bytes, and missing integers as their negative sentinel rather than as None. Use it to tally a field across a whole corpus without building a row per chunk.
require_values
¶
The raw column for field, raising if the corpus lacks it.
value
¶
One field of one row, without materialising a :class:ChunkRow.
Same result as table[index].get(field) — including the
missing-value translation values deliberately skips — for callers
that need the translated form of a handful of rows (the distinct
values of a column, say) rather than of every row.
Preparation Module (leech.preparation)¶
Sequential Preparation¶
Main data preparation function (sequential).
prepare_training_data
¶
prepare_training_data(bam_path: Path, config: PrepareConfig, min_mapq: int = 0) -> tuple[list[dict[str, np.ndarray | str | int | None]], dict[str, int]]
Prepare training data from BAM and POD5 files with statistics tracking.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bam_path
|
Path
|
Path to BAM file with alignments |
required |
config
|
PrepareConfig
|
Preparation configuration |
required |
min_mapq
|
int
|
Minimum mapping quality |
0
|
Returns:
| Type | Description |
|---|---|
tuple[list[dict[str, ndarray | str | int | None]], dict[str, int]]
|
Tuple of (chunks, statistics) |
Parallel Preparation¶
Parallel data preparation for large datasets.
prepare_training_data_parallel
¶
prepare_training_data_parallel(bam_path: Path, config: PrepareConfig, num_workers: int = 8, chunk_size: int = 100, min_mapq: int = 0, chunk_sink: Callable[[list[dict]], None] | None = None) -> tuple[list[dict[str, np.ndarray | str | int | None]], dict[str, int]]
Prepare training data from BAM and POD5 files using multiprocessing.
Streams BAM reads in mega-batches so processing overlaps with BAM iteration rather than waiting for the entire BAM to be read first.
num_workers sets the number of batches in flight on either backend:
threads for Rust, worker processes for the Python fallback. It is not
advisory on either path.
Logs achieved reads/s as it goes, so a backend that is slower than the one it replaced shows up in the first progress line rather than at the end of the allocation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
bam_path
|
Path
|
Path to BAM file with alignments |
required |
config
|
PrepareConfig
|
Preparation configuration |
required |
num_workers
|
int
|
Number of parallel workers |
8
|
chunk_size
|
int
|
Number of reads to process per worker batch |
100
|
min_mapq
|
int
|
Minimum mapping quality |
0
|
chunk_sink
|
Callable[[list[dict]], None] | None
|
Optional callback handed each batch's chunks as it
completes. When given, chunks are NOT accumulated and the returned
list is empty — the sink owns them. This is how |
None
|
Returns:
| Type | Description |
|---|---|
list[dict[str, ndarray | str | int | None]]
|
Tuple of (chunks, statistics). |
dict[str, int]
|
is given. |
Splitting Module (leech.splitting)¶
Split by Read¶
Split data into train/val/test sets at the read level to prevent data leakage.
split_chunks_by_read
¶
split_chunks_by_read(chunks: list[dict], train_frac: float = 0.7, val_frac: float = 0.15, seed: int | None = None) -> tuple[list[dict], list[dict], list[dict]]
Split chunks into train/val/test sets at the READ level to prevent data leakage.
Groups chunks by read_id, then splits the read IDs into train/val/test sets. This ensures that no read appears in multiple splits, preventing the model from seeing similar signals from the same molecule during training and validation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
chunks
|
list[dict]
|
List of chunk dictionaries (must have 'read_id' key) |
required |
train_frac
|
float
|
Fraction of reads for training |
0.7
|
val_frac
|
float
|
Fraction of reads for validation |
0.15
|
seed
|
int | None
|
Random seed for reproducibility |
None
|
Returns:
| Type | Description |
|---|---|
tuple[list[dict], list[dict], list[dict]]
|
Tuple of (train_chunks, val_chunks, test_chunks) |
Raises:
| Type | Description |
|---|---|
ValueError
|
If fractions don't sum to <= 1.0 |
Example
chunks = load_chunks(Path("all_chunks.npz")) train, val, test = split_chunks_by_read(chunks, seed=42) print(f"Train: {len(train)}, Val: {len(val)}, Test: {len(test)}")
Usage¶
For most users, the CLI commands provide the easiest interface:
| Bash | |
|---|---|
For programmatic access, import the specific modules you need.