Source code for finaletoolkit.frag._delfi

"""
DELFI fragmentation feature (Cristiano et al., 2019): short/long fragment
ratios per genomic bin, with optional GC correction and 100kb->5Mb merging.
"""
from __future__ import annotations

import time
import warnings
from collections import defaultdict
from multiprocessing.pool import Pool
from sys import stderr, stdout
from typing import Union

import numpy as np
import pandas
from tqdm import tqdm

from finaletoolkit.frag._delfi_gc_correct import delfi_gc_correct
from finaletoolkit.frag._delfi_merge_bins import delfi_merge_bins
from finaletoolkit.genome.gaps import GenomeGaps
from finaletoolkit.io.alignment import AlignmentWrapper
from finaletoolkit.io.reference import ReferenceWrapper
from finaletoolkit.utils import chrom_sizes_to_list, overlaps
from finaletoolkit.utils.logging import get_logger
from finaletoolkit.utils.validation import valid_interval

logger = get_logger(__name__)

__all__ = ["delfi", "trim_coverage"]


def trim_coverage(window_data: np.ndarray, trim_percentile: int = 10):
    """Trim the lowest ``trim_percentile``% of bins by fragment count.

    Bins below the percentile have ``short``/``long``/``gc`` set to NaN and
    ``num_frags`` set to 0.
    """
    threshold = np.percentile(window_data["num_frags"], trim_percentile)
    trimmed = window_data.copy()
    in_percentile = window_data["num_frags"] < threshold
    trimmed["short"][in_percentile] = np.nan
    trimmed["long"][in_percentile] = np.nan
    trimmed["gc"][in_percentile] = np.nan
    trimmed["num_frags"][in_percentile] = 0
    return trimmed


# ---------------------------------------------------------------------------
# Worker-process state, set once per worker via the Pool initializer so files
# are opened and inputs parsed once per worker instead of once per window. The
# previous implementation reopened the alignment and reference files and
# re-parsed the blacklist BED for every 100kb window (~26K windows per whole
# genome), which dominated runtime.
#
# This worker-pool optimization (~80x faster, identical output) was
# contributed by D.H.K. (Duco) Gaillard (@DucoG) in upstream PR #172
# (epifluidlab/FinaleToolkit#172) and ported onto this implementation.
# ---------------------------------------------------------------------------
_WORKER_ALIGNMENT = None  # AlignmentWrapper (BAM/CRAM/frag.gz)
_WORKER_REF = None  # ReferenceWrapper (.2bit/FASTA)
_WORKER_BLACKLIST = None  # dict[contig] -> (sorted_starts, stops)
_WORKER_CONTIG_GAPS = None  # dict[contig] -> ContigGaps


def _delfi_pool_initializer(
    input_file, reference_file, quality_threshold,
    blacklist_by_contig, contig_gaps_by_contig,
):
    """Open shared handles and stash preparsed inputs in worker globals."""
    global _WORKER_ALIGNMENT, _WORKER_REF
    global _WORKER_BLACKLIST, _WORKER_CONTIG_GAPS
    # AlignmentWrapper transparently handles BAM, CRAM, and tabix-indexed
    # frag.gz/bed.gz input, mirroring frag_generator's input handling.
    _WORKER_ALIGNMENT = AlignmentWrapper(
        input_file,
        reference_file=reference_file,
        quality_threshold=quality_threshold,
    )
    # Each worker is single-threaded, so the reference needs no lock.
    _WORKER_REF = ReferenceWrapper(reference_file, use_lock=False)
    _WORKER_BLACKLIST = blacklist_by_contig
    _WORKER_CONTIG_GAPS = contig_gaps_by_contig


def _load_blacklist_indexed(blacklist_file):
    """Parse the blacklist BED once and index it by contig.

    Returns a dict mapping each contig to a pair of sorted, position-aligned
    numpy arrays ``(starts, stops)``. Built once in the parent process and
    shared with every worker, replacing the per-window re-read and linear scan.
    """
    if blacklist_file is None:
        return {}
    by_contig = defaultdict(list)
    with open(blacklist_file) as blacklist:
        for line in blacklist:
            parts = line.split()
            if len(parts) < 3:
                continue
            by_contig[parts[0]].append((int(parts[1]), int(parts[2])))
    out = {}
    for contig, regions in by_contig.items():
        regions.sort()
        starts = np.array([r[0] for r in regions], dtype=np.int64)
        stops = np.array([r[1] for r in regions], dtype=np.int64)
        out[contig] = (starts, stops)
    return out


def _blacklist_in_window(contig, window_start, window_stop):
    """Blacklist regions fully contained in ``[window_start, window_stop]``.

    Equivalent to the original ``window_start <= region_start and
    window_stop >= region_stop`` filter, but uses binary search over the
    contig's sorted start positions instead of scanning the whole file.
    """
    if not _WORKER_BLACKLIST or contig not in _WORKER_BLACKLIST:
        return ()
    starts, stops = _WORKER_BLACKLIST[contig]
    lo = np.searchsorted(starts, window_start, side="left")
    if lo >= len(starts):
        return ()
    sub_starts = starts[lo:]
    sub_stops = stops[lo:]
    keep = sub_stops <= window_stop
    return tuple(zip(sub_starts[keep].tolist(), sub_stops[keep].tolist()))


[docs] def delfi( input_file: str, chrom_sizes: str, bins_file: str, reference_file: str, blacklist_file: str = None, gap_file: Union[str, GenomeGaps] = None, output_file: str = None, no_gc_correct: bool = False, gc_correct: bool | None = None, remove_nocov: bool = True, merge_bins: bool = True, window_size: int = 5000000, quality_threshold: int = 30, workers: int = 1, verbose: Union[int, bool] = False, ) -> pandas.DataFrame: """Compute DELFI features, replicating Cristiano et al. (2019). Parameters ---------- input_file : str BAM/CRAM/fragment file of paired-end reads. chrom_sizes : str ``.chrom.sizes`` file (autosomes only to match the original scripts). bins_file : str BED of bins (100kb to match the original methodology). reference_file : str Reference genome (``.2bit`` or FASTA; FASTA required for CRAM input). blacklist_file : str, optional BED of regions to ignore. gap_file : str or GenomeGaps, optional Telomere/centromere annotations: a BED4 file, a genome name (``"b37"``/``"hg19"``/``"hg38"``/``"GRCh38"``), or a :class:`~finaletoolkit.genome.GenomeGaps`. output_file : str, optional Output BED/`.bed.gz`/`.tsv`/`.csv` path, or ``"-"`` for stdout. no_gc_correct : bool, optional Skip GC correction (default ``False``). gc_correct : bool, optional Deprecated; overrides ``no_gc_correct`` when set. remove_nocov : bool, optional Remove the two hg19 no-coverage windows of Cristiano et al. (default ``True``). Disable for non-hg19 references. merge_bins : bool, optional Merge 100kb bins to 5Mb (default ``True``). window_size : int, optional Merge window size in bp (default 5,000,000). quality_threshold : int, optional Minimum mapping quality (default 30). workers : int, optional Worker-process count (default 1). verbose : int or bool, optional Print progress/timing. Returns ------- pandas.DataFrame DELFI results with the original author's column names. """ if verbose: start_time = time.time() stderr.write( f""" input_file: {input_file} chrom_sizes: {chrom_sizes} reference_file: {reference_file} blacklist_file: {blacklist_file} output_file: {output_file} window_size: {window_size} no_gc_correct: {no_gc_correct} gc_correct: {gc_correct} (overrides no_gc_correct if set) remove_nocov: {remove_nocov} merge_bins: {merge_bins} quality_threshold: {quality_threshold} workers: {workers} verbose: {verbose} \n""" ) stderr.write("Reading genome file...\n") contigs = chrom_sizes_to_list(chrom_sizes) # Resolve the (deprecated) gc_correct flag against no_gc_correct. if gc_correct is None: gc_correct = not no_gc_correct else: warnings.warn( "Warning: gc_correct is deprecated and may be removed in future " "releases. Use no_gc_correct instead" ) gaps = _resolve_gaps(gap_file) if verbose: stderr.write("Opening bins file...\n") bins = pandas.read_csv( bins_file, names=["contig", "start", "stop"], usecols=[0, 1, 2], dtype={"contig": str, "start": np.int32, "stop": np.int32}, delimiter="\t", comment="#", ) if verbose: stderr.write(f"{bins.shape[0]} bins read from file.\n") stderr.write("Filtering gaps...\n") if gaps is not None: overlaps_gap = overlaps( bins["contig"].to_numpy(), bins["start"].to_numpy(), bins["stop"].to_numpy(), gaps.gaps["contig"], gaps.gaps["start"], gaps.gaps["stop"], ) gapless_bins = bins.loc[~overlaps_gap] if verbose: stderr.write( f"{bins.shape[0] - gapless_bins.shape[0]} bins removed\n" ) else: if verbose: stderr.write("No gaps specified, skipping.\n") gapless_bins = bins if verbose: stderr.write("Preparing to generate short and long coverages.\n") # Pre-parse shared, read-only inputs once. These are handed to each worker # via the Pool initializer rather than pickled into every task. blacklist_by_contig = _load_blacklist_indexed(blacklist_file) contig_gaps_by_contig = {} if gaps is not None: for contig, _size in contigs: contig_gaps_by_contig[contig] = gaps.get_contig_gaps(contig) window_args = [] for contig, size in contigs: for _, start, stop, *_ in ( gapless_bins.loc[gapless_bins.loc[:, "contig"] == contig].itertuples( index=False, name=None ) ): window_args.append( ( contig, start, stop, verbose - 1 if verbose > 1 else 0, ) ) if verbose: stderr.write(f"{len(window_args)} windows created.\n") stderr.write("Calculating fragment lengths...\n") with Pool( workers, initializer=_delfi_pool_initializer, initargs=( input_file, reference_file, quality_threshold, blacklist_by_contig, contig_gaps_by_contig, ), ) as pool: windows = pool.starmap(_delfi_single_window, tqdm(window_args), 50) if verbose: stderr.write("Done.\n") stderr.write("Removing remaining accrocentric bins...\n") window_df = pandas.DataFrame( windows, columns=[ "contig", "start", "stop", "arm", "short", "long", "gc", "num_frags", ], ) trimmed_windows = window_df.loc[window_df["arm"] != "NOARM", :].copy() if verbose: stderr.write(f"{trimmed_windows.shape[0]} bins remaining...\n") stderr.write("Calculating ratio...\n") # short/long ratio, guarding against division by zero. trimmed_windows["ratio"] = np.where( trimmed_windows["long"] == 0, np.nan, trimmed_windows["short"] / trimmed_windows["long"], ) # Remove the two hg19 no-coverage windows (by positional index). if remove_nocov: keep = np.logical_and( np.arange(trimmed_windows.shape[0]) != 8779, np.arange(trimmed_windows.shape[0]) != 13664, ) corrected_delfi_drop_nocov = trimmed_windows.loc[keep].reset_index() else: corrected_delfi_drop_nocov = trimmed_windows if gc_correct: if verbose: stderr.write("GC bias correction...\n") gc_corrected = delfi_gc_correct( corrected_delfi_drop_nocov, 0.75, 8, verbose ) else: gc_corrected = corrected_delfi_drop_nocov if merge_bins: if verbose: stderr.write("Merging bins...\n") final_bins = delfi_merge_bins(gc_corrected, gc_correct, verbose=verbose) else: final_bins = gc_corrected if verbose: stderr.write(f"{final_bins.shape[0]} bins remaining.\n") if output_file is not None: _write_delfi(final_bins, output_file) num_frags = sum(window[7] for window in windows) if verbose: end_time = time.time() stderr.write(f"{num_frags} fragments included.\n") stderr.write(f"delfi took {end_time - start_time} s to complete\n") return final_bins
def _resolve_gaps(gap_file) -> GenomeGaps | None: """Coerce the ``gap_file`` argument into a :class:`GenomeGaps` or ``None``.""" if gap_file is None: return None if isinstance(gap_file, str): return GenomeGaps(gap_file) if isinstance(gap_file, GenomeGaps): return gap_file raise TypeError(f"{type(gap_file)} is not accepted type for gap_file") def _write_delfi(final_bins: pandas.DataFrame, output_file: str) -> None: """Write DELFI results to BED/TSV/CSV/gz or stdout.""" output_delfi = final_bins.rename(columns={"contig": "#contig"}) if output_file.endswith(".bed") or output_file.endswith(".tsv"): output_delfi.to_csv(output_file, sep="\t", index=False) elif output_file.endswith(".csv"): final_bins.to_csv(output_file, sep=",", index=False) elif output_file.endswith(".bed.gz"): output_delfi.to_csv(output_file, sep="\t", index=False, encoding="gzip") elif output_file == "-": # Stream tab-separated rows to stdout. (The original joined raw, # non-string tuple values here and crashed; rows are stringified now.) for window in final_bins.itertuples(index=False, name=None): stdout.write("\t".join(str(field) for field in window) + "\n") else: raise ValueError( "Invalid file type! Only .bed, .bed.gz, and .tsv suffixes allowed." ) def _delfi_single_window( contig: str, window_start: int, window_stop: int, verbose: Union[int, bool] = False, ) -> tuple: """Compute short/long counts and GC content for one window. Reads the alignment, reference, blacklist, and gap inputs from the worker-process globals set by ``_delfi_pool_initializer`` so that no file is reopened and no input re-parsed per window. """ contig_gaps = ( _WORKER_CONTIG_GAPS.get(contig) if _WORKER_CONTIG_GAPS is not None else None ) # Skip windows in centromeres/telomeres or short arms. if contig_gaps is not None: if contig_gaps.in_tcmere(window_start, window_stop): return (contig, window_start, window_stop, "NOARM", np.nan, np.nan, np.nan, 0) arm = contig_gaps.get_arm(window_start, window_stop) if arm == "NOARM": return (contig, window_start, window_stop, "NOARM", np.nan, np.nan, np.nan, 0) else: arm = contig blacklist_regions = _blacklist_in_window(contig, window_start, window_stop) short_lengths = 0 long_lengths = 0 num_frags = 0 # Iterate over each fragment in the window using the shared alignment # handle. AlignmentWrapper.fetch applies the same quality / read-pair # filtering as frag_generator; we replicate frag_generator's length window # (100-220) and its default "midpoint" intersect policy here so output is # unchanged. for frag in _WORKER_ALIGNMENT.fetch(contig, window_start, window_stop): frag_start = frag.start frag_stop = frag.stop frag_length = frag_stop - frag_start if frag_length < 100 or frag_length > 220: continue midpoint = (frag_start + frag_stop) // 2 if midpoint < window_start or midpoint >= window_stop: continue blacklisted = False for region in blacklist_regions: if ( (frag_start >= region[0] and frag_start < region[1]) and (frag_stop >= region[0] and frag_stop < region[1]) ): blacklisted = True break if contig_gaps is not None and contig_gaps.in_tcmere(frag_start, frag_stop): continue if not blacklisted: if frag_length >= 151: long_lengths += 1 else: short_lengths += 1 num_frags += 1 # GC content of the window from the shared reference handle (vectorized # base counting). ref_bases is upper-cased by ReferenceWrapper. if not valid_interval( _WORKER_REF.chroms, contig, window_start, window_stop, throw_on_error=False ): logger.warning( f"Invalid interval {contig}:{window_start}-{window_stop} for " "reference. Skipping GC calculation." ) ref_bases = "" else: ref_bases = _WORKER_REF.sequence(contig, window_start, window_stop) num_gc = ref_bases.count("G") + ref_bases.count("C") window_coverage = window_stop - window_start gc_content = num_gc / window_coverage if num_frags > 0 else np.nan coverage_short = short_lengths coverage_long = long_lengths if verbose: stderr.write( f"{contig}:{window_start}-{window_stop} short: " f"{coverage_short} long: {coverage_long}, gc_content: " f"{gc_content * 100}%\n" ) return ( contig, window_start, window_stop, arm, coverage_short, coverage_long, gc_content, num_frags, )