Source code for vampire.anno.pp._read

from __future__ import annotations
from typing import TYPE_CHECKING
import polars as pl
import logging
import numba
import numpy as np

from vampire._anno import make_raw

if TYPE_CHECKING:
    import warnings
    import pyarrow
    import numpy as np
    import pandas as pd
    from pathlib import Path
    import anndata as ad
    from vampire._report_utils import(
        get_copy_number
    )

logger = logging.getLogger(__name__)

MATCH_SCORE = 2
MISMATCH_PENALTY = 4
GAP_OPEN_PENALTY = 7
GAP_EXTEND_PENALTY = 4

###
#
# read bed, bedgraph functions
#
###
BEDGRAPH_COLS = {
    "chrom": pl.Utf8,
    "start": pl.Int64,
    "end": pl.Int64,
    "value": pl.Float64,
}

BED_COLS = {
    "chrom": pl.Utf8,
    "start": pl.Int64,
    "end": pl.Int64,
    "name": pl.Utf8,
    "score": pl.Float64,
    "strand": pl.Utf8,
    "thickStart": pl.Int64,
    "thickEnd": pl.Int64,
    "itemRgb": pl.Utf8,
    "blockCount": pl.Int64,
    "blockSizes": pl.Utf8,
    "blockStarts": pl.Utf8,
}

[docs] def read_bedgraph( bedgraph_file: str, columns: dict[str, pl.DataType] = BEDGRAPH_COLS, ) -> pl.LazyFrame: """ Lazily read a BEDGraph file using Polars. This function returns a :class:`polars.LazyFrame` and does **not** read or parse the file immediately. The file will only be read when the lazy query is executed (e.g. via ``collect()``). The input file is assumed to be in BEDGraph format with **at least four columns**: - ``chrom`` (chromosome name) - ``start`` (0-based start position) - ``end`` (end position, exclusive) - ``value`` (value) Any additional columns present in the file are preserved and automatically assigned standard BEDGraph column names when possible. Parameters ---------- bedgraph_file : str Path to the input BEDGraph file. Both uncompressed ``.bedgraph`` and gzip-compressed ``.bedgraph.gz`` files are supported. columns : dict[str, pl.DataType] Column names and data types to read. Returns ------- polars.LazyFrame A lazily-evaluated Polars LazyFrame representing the BEDGraph file contents. Raises ------ FileNotFoundError If the specified BEDGraph file does not exist. """ import polars as pl from pathlib import Path import warnings # check suffix if not bedgraph_file.endswith(".bedgraph") and not bedgraph_file.endswith(".bedgraph.gz"): warnings.warn(f"Invalid file suffix: {bedgraph_file}, read anyway...") # check file existence bedgraph_file = Path(bedgraph_file) if not bedgraph_file.exists(): raise FileNotFoundError(bedgraph_file) # read bedgraph file lf = pl.scan_csv( bedgraph_file, separator="\t", has_header=False, comment_prefix="#", null_values=".", ) # standard BEDGraph column names bedgraph_cols = list(columns.keys()) # rename columns safely (only up to existing width) schema = lf.collect_schema() lf = lf.rename( { f"column_{i + 1}": bedgraph_cols[i] for i in range(min(len(bedgraph_cols), len(schema))) } ) existing_cols = set(lf.collect_schema().names()) # build cast expressions for existing columns cast_exprs = [ pl.col(col).cast(dtype) for col, dtype in columns.items() if col in existing_cols ] lf = lf.with_columns(cast_exprs) return lf
[docs] def read_bed( bed_file: str, columns: dict[str, pl.DataType] = BED_COLS, ) -> pl.LazyFrame: """ Read a BED or BED.GZ file using pysam and polars. This function returns a :class:`polars.LazyFrame`. The input file is assumed to be in BED format with **at least three columns**: - ``chrom`` (chromosome name) - ``start`` (0-based start position) - ``end`` (end position, exclusive) Any additional columns present in the file are preserved and automatically assigned standard BED column names when possible. Parameters ---------- bed_file : str Path to the input BED file. Both uncompressed ``.bed`` and gzip-compressed ``.bed.gz`` files are supported. columns : dict[str, pl.DataType] Column names and data types to read. Returns ------- polars.LazyFrame A lazily-evaluated Polars LazyFrame representing the BED file contents. Raises ------ FileNotFoundError If the specified BED file does not exist. """ import polars as pl from pathlib import Path import warnings # check suffix if not bed_file.endswith(".bed") and not bed_file.endswith(".bed.gz"): warnings.warn(f"Invalid file suffix: {bed_file}, read anyway...") # check file existence bed_file = Path(bed_file) if not bed_file.exists(): raise FileNotFoundError(bed_file) # read bed file lf = pl.scan_csv( bed_file, separator="\t", has_header=False, comment_prefix="#", null_values=".", ) # standard BED column names bed_cols = list(columns.keys()) # rename columns safely (only up to existing width) schema = lf.collect_schema() lf = lf.rename( { f"column_{i + 1}": bed_cols[i] for i in range(min(len(bed_cols), len(schema))) } ) existing_cols = set(lf.collect_schema().names()) # build cast expressions for existing columns cast_exprs = [ pl.col(col).cast(dtype) for col, dtype in columns.items() if col in existing_cols ] lf = lf.with_columns(cast_exprs) return lf
[docs] def read_indexed_bed( bed_file: str, chrom: str, start: int = 0, end: int = 1e9, columns: dict[str, pl.DataType] = BED_COLS, ) -> pl.LazyFrame: """ Read a indexed BED.GZ file using pysam and polars. This function returns a :class:`polars.LazyFrame` and does **not** read or parse the file immediately. The file will only be read when the lazy query is executed (e.g. via ``collect()``). The input file is assumed to be in BED format with **at least three columns**: - ``chrom`` (chromosome name) - ``start`` (0-based start position) - ``end`` (end position, exclusive) Any additional columns present in the file are preserved and automatically assigned standard BED column names when possible. Parameters ---------- bed_file : str Path to the input BED.GZ file. chrom : str Chromosome (sequence name) to read. start : int Start coordinate of a region to read. Default is 0. end : int End coordinate of a region to read. Default is 1e9. columns : dict[str, pl.DataType] Column names and data types to read. Returns ------- polars.LazyFrame A lazily-evaluated Polars LazyFrame representing the BED file contents. Raises ------ FileNotFoundError If the specified BED file does not exist. """ import polars as pl from pathlib import Path import warnings import pysam # check suffix if not bed_file.endswith(".bed.gz"): warnings.warn(f"Invalid file suffix: {bed_file}, read anyway...") # check file and index existence bed_file = Path(bed_file) if not bed_file.exists(): raise FileNotFoundError(bed_file) index_file = Path(str(bed_file) + ".tbi") if not index_file.exists(): raise FileNotFoundError(index_file) # open bed file bed = pysam.TabixFile(str(bed_file)) # get number of columns first_line = next(bed.fetch(chrom, start, end)) num_cols = len(first_line.strip().split("\t")) # build generator to convert tabix fetch output to tuple schema = list(columns.keys())[:num_cols] # build generator to convert tabix fetch output to tuple def fetch_to_rows(): # first line already fetched, yield it yield tuple(first_line.strip().split("\t")) # remaining lines for line in bed.fetch(chrom, start, end): yield tuple(line.strip().split("\t")) # build lazy dataframe lf = pl.DataFrame(fetch_to_rows(), schema=schema).lazy() existing_cols = set(lf.collect_schema().names()) # build cast expressions for existing columns cast_exprs = [ pl.col(col).cast(dtype) for col, dtype in columns.items() if col in existing_cols ] lf = lf.with_columns(cast_exprs) # collect dataframe return lf
### # # read vampire annotation result (for single TR locus) # ###
[docs] def read_anno( file: str, use_raw: bool = False, match_score: int = 2, mismatch_penalty: int = 4, gap_open_penalty: int = 7, gap_extend_penalty: int = 4, ) -> ad.AnnData: """ Read a vampire annotation result file using pysam and polars. Parameters ---------- file : str Path to the input annotation result file. e.g. example.annotation.tsv use_raw: bool Whether to read the real sequences instead of annotated motifs. This may dramatically increase the number of motifs. Default is False. Returns ------- anndata.AnnData An AnnData object containing the annotation result. Each row (obs) is a sample, each column (var) is a motif. Notes ----- **Indexing:** - obs index corresponds to samples (chrom) - var index corresponds to motifs - X[i, j] aligns with obs[i] and var[j] **Structure:** :: X: (n_obs × n_var) motif abundance / copy-number matrix X[i, j] = copy number of motif j in chromosome i obs: Sample metadata (n_obs × metadata) - length : int - copy_number : float - score : int var: Motif metadata (n_var × metadata) - motif : str - motif_length : int - copy_number : float - label : str varp: Motif-level pairwise relations (n_var × n_var) - motif_distance : int - rc_motif_distance : int uns: Unstructured genomic annotations (not aligned to X) - sequence : dict[str, str] - motif_array : dict[str, list[str]] - orientation_array : dict[str, list[str]] """ import pyarrow import numpy as np import pandas as pd import anndata as ad from pathlib import Path from vampire._report_utils import( get_copy_number ) # check file existence if not file.endswith(".annotation.tsv"): raise ValueError(f"Input file should be *.annotation.tsv, but found: {file}") anno_file = Path(file) if not anno_file.exists(): raise FileNotFoundError(anno_file) concise_file = Path(file.replace(".annotation.tsv", ".concise.tsv")) if not concise_file.exists(): raise FileNotFoundError(concise_file) motif_file = Path(file.replace(".annotation.tsv", ".motif.tsv")) if not motif_file.exists(): raise FileNotFoundError(motif_file) dist_file = Path(file.replace(".annotation.tsv", ".distance.tsv")) if not dist_file.exists(): raise FileNotFoundError(dist_file) if match_score < 0: raise ValueError("match_score must be positive!") if mismatch_penalty < 0: raise ValueError("mismatch_penalty must be positive!") if gap_open_penalty < 0: raise ValueError("gap_open_penalty must be positive!") if gap_extend_penalty < 0: raise ValueError("gap_extend_penalty must be positive!") # read anno_df: pl.DataFrame = pl.read_csv(anno_file, separator = "\t", has_header = True, null_values=".") concise_df: pl.DataFrame = pl.read_csv(concise_file, separator = "\t", has_header = True, null_values=".") motif_df: pl.DataFrame = pl.read_csv(motif_file, separator = "\t", has_header = True, null_values=".") dist_df: pl.DataFrame = pl.read_csv(dist_file, separator = "\t", has_header = True, null_values=".") # apply use_raw, this need to calculate motifs again if use_raw: anno_df, concise_df, motif_df, dist_df = make_raw( anno_df, concise_df, motif_df, dist_df, match_score = match_score, mismatch_penalty = mismatch_penalty, gap_open_penalty = gap_open_penalty, gap_extend_penalty = gap_extend_penalty, ) # filter anno_df = anno_df.filter(pl.col("motif").is_not_null()) chrom_order: list[str] = anno_df["chrom"].unique(maintain_order=True).to_list() order_df: pl.DataFrame = pl.DataFrame({ "chrom": chrom_order, "order": range(len(chrom_order)) }) # make obs and meta information obs: pd.DataFrame = ( concise_df .select([ pl.col("chrom").alias("sample"), pl.col("length"), pl.col("copyNumber").alias("copy_number"), pl.col("score"), ]) .to_pandas() .set_index("sample") ) obs = obs.loc[chrom_order] # sort logger.debug("obs dataframe is created") # make var and meta information var: pd.DataFrame = ( motif_df.select([ pl.col("id").cast(pl.Utf8), pl.col("motif"), pl.col("motif").map_elements(lambda x: len(x), return_dtype=pl.Int64).alias("motif_length"), pl.col("copyNumber").alias("copy_number"), pl.col("label"), ]) .to_pandas() .set_index("id") ) var["label"] = var["label"].astype("category") motif_order: list[str] = var.index logger.debug("var dataframe is created") # make X matrix (represent the count of motif in each sample) motif2length: dict[str, int] = dict(zip(var.index, var["motif_length"])) anno_df: pl.DataFrame = anno_df.with_columns( pl.col("motif") .cast(pl.Utf8) .replace(motif2length) .cast(pl.Int64) .alias("motif_length") ) # _use_raw stores the per-block copy number explicitly because the CIGAR # is simplified to a perfect match; avoid recomputing it from that CIGAR. if "copyNumber" in anno_df.columns: anno_df: pl.DataFrame = anno_df.with_columns(pl.col("copyNumber").alias("copy_number")) else: anno_df: pl.DataFrame = anno_df.with_columns( pl.struct(["motif_length", "cigar"]) .map_elements(lambda x: get_copy_number(x["cigar"], x["motif_length"]), return_dtype=pl.Float64) .alias("copy_number") ) X_df: pl.DataFrame = ( anno_df .pivot( values="copy_number", index="chrom", on="motif", aggregate_function="sum" ) .fill_null(0) .join(order_df, on="chrom", how="left") .sort("order") .drop("order") ) # _use_raw may produce a motif catalog where a motif has no referencing # blocks (e.g., after filtering empty/ambiguous motifs). Ensure the X # matrix has exactly the columns declared in var. for col in motif_order: if col not in X_df.columns: X_df = X_df.with_columns(pl.lit(0.0).alias(col)) X_df = X_df.select(["chrom"] + list(motif_order)) X: np.ndarray = X_df.drop("chrom").to_numpy() del chrom_order, order_df logger.debug("X matrix is created") # make anndata object adata = ad.AnnData(X = X, obs = obs, var = var) logger.debug("anndata object is created") # make uns metadata - sequence seq_df: pl.DataFrame = ( anno_df .group_by("chrom") .agg( pl.col("sequence").str.join("") ) ) seq_dict: dict[str, str] = dict(zip(seq_df["chrom"], seq_df["sequence"])) adata.uns["sequence"] = seq_dict del seq_dict logger.debug("added .uns['sequence']") # make uns metadata - motif_array motif_array_dict: dict[str, str] = dict(zip(concise_df["chrom"], concise_df["motif"])) # {"sample1" : "0,1,0,3,4"} motif_array_dict: dict[str, list[str]] = {c: motif_array_dict[c].split(",") for c in adata.obs.index} # {"sample1" : ["0", "1", "0", "3" ,"4"]} adata.uns["motif_array"] = motif_array_dict del motif_array_dict logger.debug("added .uns['motif_array']") # make uns metadata - orientation_array orientation_array_dict: dict[str, str] = dict(zip(concise_df["chrom"], concise_df["orientation"])) # {"sample1" : "+,+,-,-,+"} orientation_array_dict: dict[str, list[str]] = {c: orientation_array_dict[c].split(",") for c in adata.obs.index} # {"sample1" : ["+", "+", "-", "-" ,"+"]} adata.uns["orientation_array"] = orientation_array_dict del orientation_array_dict logger.debug("added .uns['orientation_array']") # make uns metadata - per-block copy numbers for accurate plotting of partial copies block_cn_df = ( anno_df .group_by("chrom") .agg(pl.col("copy_number")) ) block_cn_dict: dict[str, list[float]] = { chrom: [float(cn) if cn is not None else float("nan") for cn in cns] for chrom, cns in zip(block_cn_df["chrom"].to_list(), block_cn_df["copy_number"].to_list()) } adata.uns["block_copy_number"] = block_cn_dict logger.debug("added .uns['block_copy_number']") # make varp (pairwise) - motif distance target_idx: np.ndarray = dist_df["target"].to_numpy() query_idx: np.ndarray = dist_df["query"].to_numpy() distance: np.ndarray = dist_df["distance"].to_numpy() is_rc: np.ndarray = dist_df["is_rc"].to_numpy() n: int = max(target_idx.max(), query_idx.max()) + 1 mat_false: np.ndarray = np.zeros((n, n)) mat_true: np.ndarray = np.zeros((n, n)) mat_false[target_idx[~is_rc], query_idx[~is_rc]] = distance[~is_rc] mat_false[query_idx[~is_rc], target_idx[~is_rc]] = distance[~is_rc] mat_true[target_idx[is_rc], query_idx[is_rc]] = distance[is_rc] mat_true[query_idx[is_rc], target_idx[is_rc]] = distance[is_rc] adata.varp["motif_distance"] = mat_false adata.varp["rc_motif_distance"] = mat_true logger.debug("added .varp['motif_distance'] and .varp['rc_motif_distance']") return adata