Source code for uchrom.fea.project
"""Projection helpers for genomic interval features.
The functions in this module are deliberately independent of
``uchrom.auto_discovery``. They provide a small shared layer for mapping
canonical interval-level feature tables onto the spot axis of a
``ChromData`` object.
"""
from __future__ import annotations
from typing import Iterable, Sequence
import numpy as np
import pandas as pd
INTERVAL_COLUMNS = ("chrom", "start", "end")
[docs]
def unique_spot_intervals(cdata_or_spots) -> pd.DataFrame:
"""Return unique ``chrom/start/end`` intervals from a spot table.
The first occurrence order is preserved. ``cdata_or_spots`` may be a
``ChromData``-like object with a ``spots`` attribute or a DataFrame.
"""
spots = _spots_dataframe(cdata_or_spots)
intervals = _normalise_interval_frame(spots, columns=INTERVAL_COLUMNS)
return intervals.drop_duplicates(list(INTERVAL_COLUMNS), keep="first").reset_index(drop=True)
[docs]
def project_interval_features_to_spots(
cdata_or_spots,
features: pd.DataFrame,
*,
prefix: str | None = None,
value_columns: Sequence[str] | None = None,
into: pd.DataFrame | None = None,
overwrite: bool = False,
) -> pd.DataFrame:
"""Project interval-level features onto the spot axis.
Parameters
----------
cdata_or_spots
A ``ChromData``-like object or a spot DataFrame with ``chrom``,
``start``, and ``end`` columns.
features
DataFrame with one row per interval and feature columns.
prefix
Optional namespace prefix for projected columns. For example,
``prefix="seq"`` maps ``gc_fraction`` to ``seq.gc_fraction``.
value_columns
Feature columns to project. By default all non-interval columns are
used.
into
Optional existing spot-aligned DataFrame to extend.
overwrite
If False, raise when projected columns already exist in ``into``.
Returns
-------
DataFrame
A spot-aligned DataFrame containing the existing ``into`` columns plus
projected feature columns.
"""
spots = _spots_dataframe(cdata_or_spots)
if features is None:
raise ValueError("features must be a DataFrame")
if not isinstance(features, pd.DataFrame):
raise TypeError("features must be a pandas DataFrame")
spot_keys = _normalise_interval_frame(spots, columns=INTERVAL_COLUMNS)
feature_keys = _normalise_interval_frame(features, columns=INTERVAL_COLUMNS)
if value_columns is None:
value_columns = [c for c in features.columns if c not in INTERVAL_COLUMNS]
value_columns = list(value_columns)
if not value_columns:
raise ValueError("no feature columns to project")
missing_values = [c for c in value_columns if c not in features.columns]
if missing_values:
raise ValueError(f"features missing value columns: {missing_values}")
duplicated = feature_keys.duplicated(list(INTERVAL_COLUMNS), keep=False)
if duplicated.any():
dup = feature_keys.loc[duplicated, list(INTERVAL_COLUMNS)].head(3).to_dict("records")
raise ValueError(f"features contain duplicate intervals: {dup}")
table = pd.concat(
[
feature_keys.reset_index(drop=True),
features[value_columns].reset_index(drop=True),
],
axis=1,
)
projected = spot_keys.merge(table, on=list(INTERVAL_COLUMNS), how="left", sort=False)
out = _empty_or_copy_tracks(into, len(spots))
for col in value_columns:
out_col = _prefixed_name(col, prefix)
if out_col in out.columns and not overwrite:
raise ValueError(f"projected column already exists: {out_col}")
out[out_col] = projected[col].to_numpy()
return out
def _spots_dataframe(cdata_or_spots) -> pd.DataFrame:
if isinstance(cdata_or_spots, pd.DataFrame):
return cdata_or_spots
spots = getattr(cdata_or_spots, "spots", None)
if isinstance(spots, pd.DataFrame):
return spots
raise TypeError("expected a ChromData-like object or a pandas DataFrame")
def _normalise_interval_frame(
frame: pd.DataFrame,
*,
columns: Iterable[str],
) -> pd.DataFrame:
columns = tuple(columns)
missing = [c for c in columns if c not in frame.columns]
if missing:
raise ValueError(f"interval table missing columns: {missing}")
out = frame.loc[:, columns].copy()
out["chrom"] = out["chrom"].astype(str)
out["start"] = out["start"].astype(np.int64)
out["end"] = out["end"].astype(np.int64)
bad = out["end"] <= out["start"]
if bad.any():
rows = out.loc[bad, list(columns)].head(3).to_dict("records")
raise ValueError(f"intervals must satisfy end > start: {rows}")
return out
def _empty_or_copy_tracks(into: pd.DataFrame | None, n_spots: int) -> pd.DataFrame:
if into is None:
return pd.DataFrame(index=pd.RangeIndex(n_spots))
if len(into) != n_spots:
raise ValueError(f"into rows ({len(into)}) != n_spots ({n_spots})")
return into.copy()
def _prefixed_name(name: str, prefix: str | None) -> str:
if not prefix:
return str(name)
prefix = str(prefix).rstrip(".")
name = str(name)
if name.startswith(prefix + "."):
return name
return f"{prefix}.{name}"
__all__ = [
"INTERVAL_COLUMNS",
"project_interval_features_to_spots",
"unique_spot_intervals",
]