Skip to content

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 processing
  • leech.chunking - Training chunk extraction and serialization
  • leech.splitting - Train/val/test data splitting
  • leech.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_pod5_signal(pod5_path: Path, read_id: str) -> tuple[np.ndarray, dict]

Read raw signal from a POD5 source for a specific read.

Parameters:

Name Type Description Default
pod5_path Path

Path to a .pod5 file or a directory of .pod5 files.

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 pod5_path.

Examples:

>>> signal, meta = read_pod5_signal(Path("reads.pod5"), "read_001")
>>> print(f"Signal length: {len(signal)}")
>>> print(f"Sample rate: {meta['sample_rate']}")

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 (name, array) feature rows every chunk is cut from. Resolved once here; mutating either dict afterwards will not be picked up.

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 to the aligned region, the full pre-crop normalized signal is stashed here so get_chunk can optionally read into soft-clipped/unaligned regions at chunk-window edges. None when no crop happened.

signal_offset

Index of signal[0] within full_signal — the translation between cropped (self.signal) coordinates and absolute coordinates for full_signal. Zero when not cropped.

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 full_signal is set (ref-anchored mode), fill chunk-window samples that fall outside the aligned region with real soft-clipped signal instead of zeros. Off by default to preserve Remora-compatible behavior — see R4 in the coordinate audit.

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 anchor="reference", the basecall otherwise. Must be the same string both backends extract against, since the returned indices are positions in it.

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 sequence, motif offset already applied.

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_chunks(chunks: list[dict], output_path: Path, *, compressed: bool = True) -> None

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 (np.savez_compressed); if False, store them (np.savez) for faster writes at larger file size.

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:

>>> chunks = extract_training_chunks(read, motif="CCAGGC")
>>> save_chunks(chunks, Path("output/chunks.npz"))

load_chunks

load_chunks(input_path: Path, *, defer: Collection[str] = ()) -> list[dict]

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:DEFERRABLE_FIELDS). The key is still present on each chunk, set to None. Callers that convert the arrays themselves — :class:~leech.dataset.LeechDataset streams them with :func:iter_npz_row_blocks — use this to avoid holding a second full copy.

()

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:

>>> chunks = load_chunks(Path("output/chunks.npz"))
>>> print(f"Loaded {len(chunks)} chunks")
>>> for chunk in chunks[:5]:
...     print(f"{chunk['read_id']}: {chunk['label']}")

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

npz_array_members(input_path: Path) -> dict[str, tuple[tuple[int, ...], np.dtype]]

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 (shape, dtype).

Examples:

>>> members = npz_array_members(Path("chunks.npz"))
>>> members["signals_flat"][0]
(6668328, 540)

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:npz_array_members).

required
block_rows int | None

Rows per block. Default sizes the block from block_bytes so a wide signal does not silently allocate a huge buffer, and never exceeds the member's row count.

None
block_bytes int

Target resident bytes across all streamed members, used when block_rows is not given.

8 << 20

Yields:

Type Description
int

(row_start, blocks) where blocks[name] has ``min(block_rows,

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_seq_to_sig_csr(input_path: Path) -> tuple[np.ndarray, np.ndarray] | None

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

(values, offsets), or None if the file has no base-to-signal maps.

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:

>>> with ChunkNpzWriter(Path("out/all.npz")) as writer:
...     for batch in batches:
...         writer.append(batch)

append

append(chunks: list[dict]) -> None

Add a batch of chunks. The caller may drop them immediately after.

close

close() -> None

Write the .npz and drop the spill files.

ChunkSpool

ChunkSpool(spill_dir: Path, *, compressed: bool = True, batch_rows: int = 4096)

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:write_npz.

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"))

n_chunks property

n_chunks: int

Chunks appended so far.

append

append(chunks: list[dict]) -> None

Add a batch of chunks. The caller may drop them immediately after.

text_column

text_column(name: str) -> np.ndarray

One text member as a single array, at the width the .npz will use.

read_ids

read_ids() -> np.ndarray

The read_ids column, for computing a read-level split.

write_npz

write_npz(output_path: Path, *, rows: ndarray | None = None, compressed: bool | None = None) -> int

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 writes every row in arrival order. Used to split a spooled corpus into train/val/test without ever materialising a split as a list.

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.

close

close() -> None

Drop the spill files. The spool cannot be used afterwards.

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

ChunkTable(columns: dict[str, _Column], n_chunks: int)

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

from_npz(input_path: Path, *, skip: Collection[str] = ()) -> ChunkTable

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: sequence_with_kmer_context is 56 bytes a chunk that only signal_kmer encoding touches.

()

Returns:

Type Description
ChunkTable

A table with one row per chunk in file order.

select

select(mask: ndarray) -> ChunkTable

Return a table holding only the rows where mask is True.

values

values(field: str) -> np.ndarray | None

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

require_values(field: str) -> np.ndarray

The raw column for field, raising if the corpus lacks it.

value

value(field: str, index: int)

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.

nbytes

nbytes() -> int

Total bytes held by the columns.

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 data prepare writes a corpus without ever holding it (#211); see :class:~leech.chunking.ChunkSpool. The statistics are the same either way.

None

Returns:

Type Description
list[dict[str, ndarray | str | int | None]]

Tuple of (chunks, statistics). chunks is empty when chunk_sink

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
1
2
3
4
5
# Prepare data
uv run leech data prepare --pod5 reads.pod5 --bam alignments.bam --output-dir chunks/

# Merge and split (multi-sample)
uv run leech data merge -i charged=a.npz -i uncharged=b.npz -o merged/

For programmatic access, import the specific modules you need.