Source code for finaletoolkit.frag._frag_length

"""
Fragment-length features: raw lengths, binned length distributions, and
per-interval length summary statistics.
"""
from __future__ import annotations

import gzip
import time
import warnings
from functools import partial
from multiprocessing import Pool
from pathlib import Path
from sys import stderr, stdout
from typing import NamedTuple, Union

import numpy as np
import pysam
from tqdm import tqdm

from finaletoolkit.utils import frag_generator, get_intervals
from finaletoolkit.utils.typing import FragFile

__all__ = [
    "frag_length",
    "frag_length_bins",
    "frag_length_intervals",
    "FragLengthStats",
    "plot_histogram",
]


[docs] class FragLengthStats(NamedTuple): """Fragment-length summary statistics over one interval. A named tuple: it unpacks and indexes like a plain tuple and also exposes named fields. Intervals with no fragments report ``-1`` for every numeric field. Attributes ---------- contig : str Interval contig. start : int 0-based start coordinate. stop : int Stop coordinate. name : str Interval name. mean : float Mean fragment length. median : float Median fragment length. stdev : float Standard deviation of the fragment lengths. minimum : int Shortest fragment length. maximum : int Longest fragment length. count : int Number of fragments in the interval. frac_short_reads : float Fraction of fragments below the short-read length threshold. """ contig: str start: int stop: int name: str mean: float median: float stdev: float minimum: int maximum: int count: int frac_short_reads: float
def plot_histogram( data_dict, num_bins, histogram_path: str = "./frag_length_bins_histogram.png", stats=None, ) -> None: """Render a fragment-length histogram PNG from ``frag_length_bins`` data. Parameters ---------- data_dict : dict Mapping of fragment length to count. num_bins : int Number of histogram bins. histogram_path : str, optional Output PNG path. stats : list of (str, value), optional Summary statistics to annotate on the plot. """ import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter keys = list(data_dict.keys()) values = list(data_dict.values()) fig_size = (6, 4) font_size = 12 plt.figure(figsize=fig_size, dpi=1000) plt.hist( keys, bins=num_bins, weights=values, color="salmon", edgecolor="white", linewidth=0.1, ) plt.xlabel("Fragment Size (bp)", fontsize=font_size * 0.8) plt.ylabel("Number of Fragments", fontsize=font_size * 0.8) plt.xticks(fontsize=font_size * 0.7) plt.yticks(fontsize=font_size * 0.7) def format_ticks(value, pos): if value >= 1e6: return "{:1.0f}M".format(value * 1e-6) elif value >= 1e3: return "{:1.0f}K".format(value * 1e-3) return "{:1.0f}".format(value) plt.gca().yaxis.set_major_formatter(FuncFormatter(format_ticks)) plt.gca().spines["top"].set_visible(False) plt.gca().spines["right"].set_visible(False) if stats: stats_str = "\n".join([f"{stat[0]}: {stat[1]}" for stat in stats]) plt.text( 0.95, 0.95, stats_str, transform=plt.gca().transAxes, fontsize=font_size * 0.6, verticalalignment="top", horizontalalignment="right", bbox=dict(facecolor="white", alpha=0.7, edgecolor="none"), ) plt.tight_layout() plt.savefig(histogram_path) def _distribution_from_gen(generator) -> dict[int, int]: """Count fragments by length from a ``frag_generator`` stream.""" value_counts: dict[int, int] = {} for fragment in generator: length_of_fragment = fragment[2] - fragment[1] value_counts[length_of_fragment] = value_counts.get(length_of_fragment, 0) + 1 return value_counts def _find_median(val_freq_dict: dict[int, int]) -> float: """Compute the median of a value->frequency distribution.""" val = np.array(list(val_freq_dict.keys())) freq = np.array(list(val_freq_dict.values())) order = np.argsort(val) val = val[order] freq = freq[order] cdf = np.cumsum(freq) total_count = cdf[-1] if total_count % 2 == 1: median_index = np.searchsorted(cdf, total_count // 2) return float(val[median_index]) median_indices = np.searchsorted( cdf, [total_count // 2, total_count // 2 + 1] ) return float(np.mean(val[median_indices])) def _frag_length_stats( input_file: FragFile, contig: str, start: int, stop: int, name: str, min_length: int, max_length: int, short_reads: int, intersect_policy: str, quality_threshold: int, verbose: Union[bool, int], reference_file: str | Path | None = None, ) -> FragLengthStats: """Compute fragment-length statistics for a single interval.""" frag_gen = frag_generator( input_file, contig, quality_threshold, start, stop, min_length, max_length, intersect_policy, verbose, reference_file=reference_file, ) frag_len_dict = _distribution_from_gen(frag_gen) total_count = sum(frag_len_dict.values()) if total_count == 0: return FragLengthStats(contig, start, stop, name, -1, -1, -1, -1, -1, -1, -1) mean = ( sum(value * count for value, count in frag_len_dict.items()) / total_count ) median = _find_median(frag_len_dict) variance = ( sum(count * ((value - mean) ** 2) for value, count in frag_len_dict.items()) / total_count ) stdev = variance**0.5 minimum = min(frag_len_dict.keys()) maximum = max(frag_len_dict.keys()) n_short_reads = sum( count for length, count in frag_len_dict.items() if length <= short_reads ) frac_short_reads = n_short_reads / total_count return FragLengthStats( contig, start, stop, name, mean, median, stdev, minimum, maximum, total_count, frac_short_reads, ) def _frag_length_stats_star(partial_frag_stat, interval) -> FragLengthStats: contig, start, stop, name = interval return partial_frag_stat(contig=contig, start=start, stop=stop, name=name)
[docs] def frag_length( input_file: Union[str, pysam.AlignmentFile, pysam.TabixFile], contig: str | None = None, start: int | None = None, stop: int | None = None, intersect_policy: str = "midpoint", output_file: str | None = None, quality_threshold: int = 30, verbose: bool = False, reference_file: str | Path | None = None, ) -> np.ndarray: """Return an array of fragment lengths from an alignment/fragment file. Parameters ---------- input_file : str, AlignmentFile, or TabixFile BAM, CRAM, or tabix-indexed fragment file (or open pysam handle). contig : str, optional Restrict to this contig. start : int, optional 0-based left-most coordinate of the interval. stop : int, optional 1-based right-most coordinate of the interval. intersect_policy : {"midpoint", "any"}, optional Region-membership policy (default ``"midpoint"``). output_file : str, optional ``.bin`` writes a raw binary array; ``"-"`` writes one length per line to stdout. quality_threshold : int, optional Minimum mapping quality (default 30). verbose : bool, optional Print timing information. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- numpy.ndarray ``int32`` array of fragment lengths. """ if verbose: start_time = time.time() stderr.write("Finding frag lengths.\n") frag_gen = frag_generator( input_file=input_file, contig=contig, quality_threshold=quality_threshold, start=start, stop=stop, min_length=0, max_length=1000000000, intersect_policy=intersect_policy, verbose=verbose, reference_file=reference_file, ) lengths = [frag_stop - frag_start for _, frag_start, frag_stop, _, _ in frag_gen] if verbose: stderr.write("Converting to array.\n") lengths = np.array(lengths, dtype=np.int32) if isinstance(output_file, str): if output_file.endswith(".bin"): with open(output_file, "wt") as out: lengths.tofile(out) elif output_file == "-": for line in lengths: stdout.write(f"{line}\n") else: raise ValueError("output_file can only have suffixes .wig or .wig.gz.") elif output_file is not None: raise TypeError( f'output_file is unsupported type "{type(input_file)}". ' "output_file should be a string specifying the path of the file " "to write output scores to." ) if verbose: end_time = time.time() stderr.write(f"frag_length took {end_time - start_time} s to complete\n") return lengths
[docs] def frag_length_bins( input_file: FragFile, contig: str | None = None, start: int | None = None, stop: int | None = None, min_length: int | None = 0, max_length: int | None = None, bin_size: int = 1, output_file: str | None = None, intersect_policy: str = "midpoint", quality_threshold: int = 30, summary_stats: bool = False, short_fraction: int | None = None, histogram_path: str | None = None, verbose: Union[bool, int] = False, reference_file: str | Path | None = None, ) -> tuple[np.ndarray, list]: """Bin fragment lengths and optionally write a TSV table or histogram. Parameters ---------- input_file : str, AlignmentFile, or TabixFile BAM/CRAM/fragment input. contig : str, optional Restrict to this contig (genome-wide if omitted). start, stop : int, optional Interval bounds (require ``contig``). min_length, max_length : int, optional Fragment-length filter applied before binning. bin_size : int, optional Bin width in bp (default 1). output_file : str, optional TSV/`.gz` path, or ``"-"`` for stdout. intersect_policy : {"midpoint", "any"}, optional Region-membership policy. quality_threshold : int, optional Minimum mapping quality (default 30). summary_stats : bool, optional Append summary statistics as ``#``-comment lines to the TSV. short_fraction : int, optional If set, add a short-fraction statistic (fragments ``<=`` this length). histogram_path : str, optional If set, also render a histogram PNG here. verbose : bool or int, optional Print timing/config information. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- bins : numpy.ndarray Bin lower bounds. counts : list of int Fragment count per bin (same length as ``bins``). """ if verbose: stderr.write( f""" input_file: {input_file} contig: {contig} start: {start} stop: {stop} bin_size: {bin_size} output_file: {output_file} intersect_policy: {intersect_policy} quality_threshold: {quality_threshold} summary_stats: {summary_stats} short_fraction: {short_fraction} histogram_path: {histogram_path} verbose: {verbose} \n""" ) start_time = time.time() stderr.write("Generating fragment dictionary. \n") frag_gen = frag_generator( input_file, contig, quality_threshold, start, stop, min_length, max_length, intersect_policy, verbose, reference_file=reference_file, ) frag_len_dict = _distribution_from_gen(frag_gen) total_count = sum(frag_len_dict.values()) if total_count == 0: warnings.warn( "No fragments found in the specified region. Returning empty result.", RuntimeWarning, stacklevel=2, ) return np.array([]), np.array([]) mean = ( sum(value * count for value, count in frag_len_dict.items()) / total_count ) variance = ( sum(count * ((value - mean) ** 2) for value, count in frag_len_dict.items()) / total_count ) stats = [ ("mean", mean), ("median", _find_median(frag_len_dict)), ("stdev", variance**0.5), ("min", min(frag_len_dict.keys())), ("max", max(frag_len_dict.keys())), ("total count", total_count), ] if short_fraction is not None: short_coverage = sum( count for length, count in frag_len_dict.items() if length <= short_fraction ) stats.append( (f"short fraction (s{short_fraction})", short_coverage / total_count) ) bin_start = min(frag_len_dict.keys()) bin_stop = max(frag_len_dict.keys()) n_bins = (bin_stop - bin_start) // bin_size bins = np.arange(bin_start, bin_stop + bin_size, bin_size) # Vectorized binning: accumulate each length's frequency into its bin. lengths_arr = np.fromiter(frag_len_dict.keys(), dtype=np.int64) freqs_arr = np.fromiter(frag_len_dict.values(), dtype=np.int64) bin_index = (lengths_arr - bin_start) // bin_size counts_arr = np.zeros(n_bins + 1, dtype=np.int64) np.add.at(counts_arr, bin_index, freqs_arr) counts = counts_arr.tolist() if output_file is not None: out_is_file = False try: if output_file == "-": out = stdout elif output_file.endswith(".gz"): out_is_file = True out = gzip.open(output_file, "wt") else: out_is_file = True out = open(output_file, "w") out.write("min\tmax\tcount\n") for bin_val, count in zip(bins, counts): out.write(f"{bin_val}\t{bin_val + bin_size - 1}\t{count}\n") if summary_stats: for name, value in stats: out.write(f"#{name}: {value}\n") finally: if out_is_file: out.close() if histogram_path is not None: plot_histogram( frag_len_dict, num_bins=n_bins, histogram_path=histogram_path, stats=stats, ) if verbose: stop_time = time.time() stderr.write( f"frag_length_bins took {stop_time - start_time} s to complete.\n" ) return bins, counts
[docs] def frag_length_intervals( input_file: Union[str, pysam.AlignmentFile], interval_file: str, output_file: str | None = None, min_length: int | None = 0, max_length: int | None = None, quality_threshold: int = 30, intersect_policy: str = "midpoint", short_reads: int = 150, workers: int = 1, verbose: Union[bool, int] = False, reference_file: str | Path | None = None, ) -> list[FragLengthStats]: """Compute per-interval fragment-length statistics over a BED file. Parameters ---------- input_file : str or AlignmentFile BAM/CRAM/fragment input. interval_file : str BED file of intervals. output_file : str, optional BED/`.gz` path or ``"-"`` for stdout. min_length, max_length : int, optional Fragment-length filter. quality_threshold : int, optional Minimum mapping quality (default 30). intersect_policy : {"midpoint", "any"}, optional Region-membership policy. short_reads : int, optional Short-read length cutoff for the short fraction (default 150). workers : int, optional Worker-process count (default 1). verbose : bool or int, optional Print timing/config information. reference_file : str or Path, optional Reference genome (required for CRAM). Returns ------- list of FragLengthStats One record per interval (``contig, start, stop, name, mean, median, stdev, min, max, count, frac_short_reads``). """ if verbose: stderr.write( f""" input_file: {input_file} interval_file: {interval_file} output_file: {output_file} min_length: {min_length} max_length: {max_length} quality_threshold: {quality_threshold} workers: {workers} verbose: {verbose} \n""" ) start_time = time.time() stderr.write("Creating process pool.\n") pool = Pool(processes=workers) try: if verbose: stderr.write("Reading intervals.\n") intervals = get_intervals(interval_file) partial_frag_stat = partial( _frag_length_stats, input_file=input_file, min_length=min_length, max_length=max_length, short_reads=short_reads, intersect_policy=intersect_policy, quality_threshold=quality_threshold, verbose=verbose, reference_file=reference_file, ) results = pool.map( partial(_frag_length_stats_star, partial_frag_stat), intervals, chunksize=max(len(intervals) // workers, 1), ) if verbose: tqdm.write("Retrieving fragment statistics for file\n") output_is_file = False if output_file is not None: if verbose: tqdm.write("Writing results to output. \n") try: if output_file.endswith(".bed") or output_file.endswith(".bedgraph"): output_is_file = True output = open(output_file, "w") elif output_file.endswith(".bed.gz"): output = gzip.open(output_file, "w") output_is_file = True elif output_file == "-": output = stdout else: raise ValueError( "The output file should have .bed or .bed.gz as as suffix." ) output.write( "contig\tstart\tstop\tname\tmean\tmedian\t" "stdev\tmin\tmax\tcount" f"\ts{short_reads}\n" ) output.write( "\n".join( "\t".join(str(element) for element in item) for item in results ) ) output.write("\n") finally: if output_is_file: output.close() finally: pool.close() if verbose: stop_time = time.time() stderr.write( "Calculating fragment length statistics for intervals took " f"{stop_time - start_time} s\n" ) return results