From d21ba48fdd773900a0e2fa46762e4ccd19a56226 Mon Sep 17 00:00:00 2001 From: Ian Dirk Fichtner Date: Sun, 11 Oct 2026 16:22:13 +0200 Subject: [PATCH] Feature: pl.ann_hist() Histogram of .obs/.var annotation columns, a thin wrapper around seaborn.histplot. level_key reduces repeated higher-level annotations (e.g. protein q-values on peptide-level data) to one value per group and asserts consistency within each group. vline/hline add reference lines with an optional legend. Co-Authored-By: Claude Opus 5.5 (1M context) --- HISTORY.md | 6 + docs/sphinx/source/api/pl.rst | 1 + proteopy/pl/__init__.py | 2 + proteopy/pl/ann_hist.py | 428 ++++++++++++++++++++++++++++++++++ 4 files changed, 437 insertions(+) create mode 100644 proteopy/pl/ann_hist.py diff --git a/HISTORY.md b/HISTORY.md index 47e9a18..8909f00 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -13,6 +13,12 @@ and this project adheres to **Plotting** (`pr.pl`) - `pairwise_peptide_correlations_heatmap()`: new feature +- `ann_hist()`: histogram of `.obs`/`.var` annotations (numeric, str or + categorical), a thin wrapper around `seaborn.histplot`. `level_key` + plots one value per higher-level group (e.g. one `protein_q_value` + per `protein_id` in peptide-level data) and checks the value is + constant within each group. `vline` / `hline` add labelled + reference lines - `peptide_intensities()`, `proteoform_intensities()`: new `facet_by` parameter splitting the samples (`.obs`) across a grid of subplots diff --git a/docs/sphinx/source/api/pl.rst b/docs/sphinx/source/api/pl.rst index 2d45192..8e5c4d0 100644 --- a/docs/sphinx/source/api/pl.rst +++ b/docs/sphinx/source/api/pl.rst @@ -36,6 +36,7 @@ control, exploratory analysis, and statistical results. :toctree: generated/ :nosignatures: + proteopy.pl.ann_hist proteopy.pl.n_samples_per_category proteopy.pl.n_peptides_per_sample proteopy.pl.n_proteins_per_sample diff --git a/proteopy/pl/__init__.py b/proteopy/pl/__init__.py index f72c78d..8459681 100644 --- a/proteopy/pl/__init__.py +++ b/proteopy/pl/__init__.py @@ -38,6 +38,8 @@ hclustv_profile_intensities, ) +from .ann_hist import ann_hist + from .sequence import ( peptides_on_sequence, peptides_on_prot_sequence, diff --git a/proteopy/pl/ann_hist.py b/proteopy/pl/ann_hist.py new file mode 100644 index 0000000..a6e0be6 --- /dev/null +++ b/proteopy/pl/ann_hist.py @@ -0,0 +1,428 @@ +from pathlib import Path +from typing import Literal + +import numpy as np +import pandas as pd +import anndata as ad +from pandas.api.types import ( + is_bool_dtype, + is_numeric_dtype, + is_object_dtype, + is_string_dtype, +) +import matplotlib.pyplot as plt +from matplotlib.axes import Axes +import seaborn as sns + +from proteopy.utils.anndata import check_proteodata + + +_RESERVED_KWARGS = ("data", "ax") +_COLUMN_KWARGS = ("hue", "weights") +_MAX_REPORTED_GROUPS = 5 +_LINE_DEFAULTS = { + "value": None, + "color": "red", + "linestyle": "dashed", + "text": None, +} + + +def _resolve_frame( + adata: ad.AnnData, + columns: list[str], + axis: str | None, +) -> tuple[pd.DataFrame, str]: + if axis is not None: + if axis not in ("obs", "var"): + raise ValueError("`axis` must be 'obs', 'var' or None.") + frame = adata.obs if axis == "obs" else adata.var + missing = [c for c in columns if c not in frame.columns] + if missing: + raise KeyError(f"Columns {missing} not found in adata.{axis}.") + return frame, axis + + locations = {} + for column in columns: + found = [ + name + for name, frame in (("obs", adata.obs), ("var", adata.var)) + if column in frame.columns + ] + if not found: + raise KeyError( + f"Column '{column}' not found in adata.obs or adata.var." + ) + if len(found) == 2: + raise ValueError( + f"Column '{column}' exists in both adata.obs and " + "adata.var, so the annotation axis is ambiguous. Pass " + "`axis='obs'` or `axis='var'` to choose." + ) + locations[column] = found[0] + + axes = set(locations.values()) + if len(axes) > 1: + placement = ", ".join( + f"'{c}' in adata.{a}" for c, a in locations.items() + ) + raise ValueError( + "All plotted columns must come from the same annotation " + f"axis, got {placement}." + ) + name = axes.pop() + return (adata.obs if name == "obs" else adata.var), name + + +def _check_dtype(series: pd.Series, column: str) -> None: + if is_bool_dtype(series): + raise TypeError( + f"Column '{column}' is boolean, which is not supported. " + f"Convert it with `.astype('category')` first." + ) + if isinstance(series.dtype, pd.CategoricalDtype): + return + if is_numeric_dtype(series): + return + if is_object_dtype(series) or is_string_dtype(series): + return + raise TypeError( + f"Column '{column}' has unsupported dtype '{series.dtype}'. " + "Supported dtypes are int, float, str, object and categorical." + ) + + +def _collapse_to_level( + frame: pd.DataFrame, + columns: list[str], + level_key: list[str] | None, +) -> pd.DataFrame: + if level_key is None: + return frame[columns].copy() + + missing = [c for c in level_key if c not in frame.columns] + if missing: + raise KeyError( + f"`level_key` columns {missing} not found in the frame " + "holding the plotted columns." + ) + overlap = [c for c in level_key if c in columns] + if overlap: + raise ValueError( + f"`level_key` columns {overlap} are also plotted columns." + ) + if frame[level_key].isna().any().any(): + raise ValueError("`level_key` columns must not contain NaN.") + + grouped = frame.groupby(level_key, observed=True)[columns] + n_values = grouped.nunique(dropna=False) + for column in columns: + bad = n_values.index[n_values[column] > 1] + if len(bad) == 0: + continue + examples = [] + for group in bad[:_MAX_REPORTED_GROUPS]: + key = group if isinstance(group, tuple) else (group,) + key_values = pd.Series(key, index=level_key) + mask = (frame[level_key] == key_values).all(axis=1) + values = frame.loc[mask, column].unique().tolist() + examples.append(f" {group}: {values}") + raise ValueError( + f"Column '{column}' is not constant within {len(bad)} " + f"group(s) of `level_key={level_key}`. Examples:\n" + + "\n".join(examples) + ) + + return frame.drop_duplicates(subset=level_key)[columns].copy() + + +def _apply_na_handling( + df: pd.DataFrame, + columns: list[str], + zero_to_na: bool, + fill_na: float | int | str | None, +) -> None: + if zero_to_na: + numeric = [c for c in columns if _is_plain_numeric(df[c])] + if not numeric: + raise ValueError( + "`zero_to_na=True` requires at least one numeric " + "plotted column." + ) + for column in numeric: + df[column] = df[column].mask(df[column] == 0) + + if fill_na is not None: + for column in columns: + series = df[column] + if ( + isinstance(series.dtype, pd.CategoricalDtype) + and fill_na not in series.cat.categories + ): + series = series.cat.add_categories([fill_na]) + df[column] = series.fillna(fill_na) + + +def _is_plain_numeric(series: pd.Series) -> bool: + return is_numeric_dtype(series) and not isinstance( + series.dtype, pd.CategoricalDtype + ) + + +def _to_ordered_categorical(series: pd.Series) -> pd.Series: + if isinstance(series.dtype, pd.CategoricalDtype): + return series + as_str = series.where(series.isna(), series.astype(str)) + categories = sorted(map(str, as_str.dropna().unique())) + return pd.Series( + pd.Categorical(as_str, categories=categories), + index=series.index, + name=series.name, + ) + + +def _normalize_lines( + lines: dict | list[dict] | None, + name: str, +) -> list[dict]: + if lines is None: + return [] + if isinstance(lines, dict): + lines = [lines] + if not isinstance(lines, list) or not all( + isinstance(line, dict) for line in lines + ): + raise TypeError(f"`{name}` must be a dict or a list of dicts.") + + normalized = [] + for line in lines: + unknown = set(line) - set(_LINE_DEFAULTS) + if unknown: + raise ValueError( + f"Unknown `{name}` keys {sorted(unknown)}; allowed keys " + f"are {list(_LINE_DEFAULTS)}." + ) + value = line.get("value") + if ( + isinstance(value, bool) + or not isinstance(value, (int, float, np.number)) + or not np.isfinite(value) + ): + raise ValueError( + f"Each `{name}` dict needs a finite numeric 'value', " + f"got {value!r}." + ) + normalized.append({**_LINE_DEFAULTS, **line}) + return normalized + + +def _draw_lines( + ax: Axes, + vlines: list[dict], + hlines: list[dict], +) -> None: + handles = [] + for lines, draw in ((vlines, ax.axvline), (hlines, ax.axhline)): + for line in lines: + handle = draw( + line["value"], + color=line["color"], + linestyle=line["linestyle"], + label=line["text"], + ) + if line["text"] is not None: + handles.append(handle) + if not handles: + return + + # ax.legend() replaces seaborn's hue legend, so re-attach it + hue_legend = ax.get_legend() + ax.legend( + handles=handles, + loc="upper left", + bbox_to_anchor=(1.02, 1), + borderaxespad=0, + frameon=False, + ) + if hue_legend is not None: + ax.add_artist(hue_legend) + + +def ann_hist( + adata: ad.AnnData, + x: str | None = None, + y: str | None = None, + level_key: str | list[str] | None = None, + axis: Literal["obs", "var"] | None = None, + zero_to_na: bool = False, + fill_na: float | int | str | None = None, + vline: dict | list[dict] | None = None, + hline: dict | list[dict] | None = None, + show: bool = True, + save: str | Path | None = None, + **kwargs, +) -> Axes: + """ + Plot a histogram of ``.obs`` or ``.var`` annotation columns. + + Thin wrapper around :func:`seaborn.histplot`. The annotation + columns are collected from ``adata.obs`` or ``adata.var``, + optionally reduced to one row per ``level_key`` group, and passed + to ``sns.histplot`` together with all extra keyword arguments. + + Parameters + ---------- + adata : AnnData + :class:`~anndata.AnnData` holding the annotations. + x : str | None + Annotation column plotted on the x axis. Same meaning as + ``x`` in :func:`seaborn.histplot`, except that it names a + ``.obs``/``.var`` column. + y : str | None + Annotation column plotted on the y axis. Same meaning as + ``y`` in :func:`seaborn.histplot`. Pass only ``y`` for a + horizontal histogram, or both ``x`` and ``y`` for a bivariate + histogram. At least one of ``x`` and ``y`` is required. + level_key : str | list[str] | None + Column(s) defining the level the annotation belongs to. + Annotations often describe a higher level than the axis and + are repeated for every row of that level, e.g. + ``protein_q_value`` is repeated for all peptides of a protein + in a peptide-level AnnData. With ``level_key="protein_id"``, + each protein contributes one value instead of one per peptide. + axis : {"obs", "var"} | None + Annotation axis holding the columns. If ``None``, it is + inferred from where the columns live. + zero_to_na : bool + If True, replace zeros with NaN in the numeric ``x`` / ``y`` + columns before plotting. + fill_na : float | int | str | None + If not None, replace NaN in the ``x`` / ``y`` columns with this + value, e.g. ``"missing"`` for str or categorical annotations. + vline : dict | list[dict] | None + Vertical line(s) drawn with + :meth:`matplotlib.axes.Axes.axvline`. A dict defines one + line, a list of dicts several. Keys: ``value`` (required, + numeric x position), ``color`` (default ``"red"``), + ``linestyle`` (default ``"dashed"``) and ``text`` (default + ``None``). Lines with ``text`` are listed in a legend to the + right of the plot. On a categorical axis, category ``i`` sits + at position ``i``. + hline : dict | list[dict] | None + Horizontal line(s) drawn with + :meth:`matplotlib.axes.Axes.axhline`. Same format as + ``vline``, with ``value`` as the y position. + show : bool + Call :func:`matplotlib.pyplot.show` at the end. + save : str | Path | None + Save the figure to this path. If None, do not save. + **kwargs + Passed verbatim to :func:`seaborn.histplot` (e.g. ``bins``, + ``binwidth``, ``stat``, ``discrete``, ``shrink``, ``element``, + ``kde``, ``log_scale``, ``hue``, ``multiple``, ``palette``). + See https://seaborn.pydata.org/generated/seaborn.histplot.html + for the full list and what each argument does. ``hue`` and + ``weights`` given as strings name ``.obs``/``.var`` columns + from the same frame as ``x``/``y``. ``data`` and ``ax`` are + reserved. + + Returns + ------- + Axes + The Axes the histogram was drawn on. + + Raises + ------ + KeyError + If a referenced column is missing. + ValueError + If neither ``x`` nor ``y`` is given, ``axis`` is ``None`` and + a plotted column exists in both ``.obs`` and ``.var``, the + plotted columns come from different annotation axes, a + plotted column is not constant within a ``level_key`` group, + or a ``vline`` / ``hline`` dict has unknown keys or no finite + numeric ``value``. + TypeError + If a reserved keyword is passed, a column has an unsupported + dtype (e.g. bool or datetime), or ``vline`` / ``hline`` is not + a dict or a list of dicts. + + Notes + ----- + Non-numeric values are ordered by category order for categorical + columns, otherwise lexicographically after ``str`` coercion. To + impose a custom order, store the column as an ordered Categorical: + + >>> adata.var["fraction"] = pd.Categorical( + ... adata.var["fraction"], + ... categories=["F1", "F2", "F10"], + ... ordered=True, + ... ) + + Examples + -------- + >>> import proteopy as pr + >>> adata = pr.load.toy_simple_peptides( + ... n_proteins=10, + ... n_peptides_per_protein=( + ... [5] * 1 + [6] * 2 + [7] * 5 + [8] * 2 + ... ), + ... ) + >>> pr.pl.ann_hist( + ... adata, + ... x="Protein.Q.Value", + ... level_key="protein_id", + ... vline=[ + ... {"value": 0.01, "text": "1% FDR"}, + ... {"value": 0.05, "color": "grey", "text": "5% FDR"}, + ... ], + ... bins=50, + ... ) + >>> pr.pl.ann_hist( + ... adata, + ... x="Q.Value", + ... y="Q.Value.Global", + ... ) + >>> pr.pl.ann_hist(adata, x="condition", shrink=0.8) + """ + check_proteodata(adata) + + if x is None and y is None: + raise ValueError("At least one of `x` and `y` must be given.") + for name in _RESERVED_KWARGS: + if name in kwargs: + hint = " (`ax` is not supported yet)" if name == "ax" else "" + raise TypeError( + f"`{name}` is set by ann_hist and cannot be passed{hint}." + ) + if isinstance(level_key, str): + level_key = [level_key] + vlines = _normalize_lines(vline, "vline") + hlines = _normalize_lines(hline, "hline") + + # -- Collect and validate the referenced columns + plot_columns = [c for c in (x, y) if c is not None] + kwarg_columns = [ + kwargs[k] for k in _COLUMN_KWARGS if isinstance(kwargs.get(k), str) + ] + columns = list(dict.fromkeys(plot_columns + kwarg_columns)) + frame, _ = _resolve_frame(adata, columns, axis) + for column in columns: + _check_dtype(frame[column], column) + + # -- Reduce to one row per level and prepare the values + df = _collapse_to_level(frame, columns, level_key) + _apply_na_handling(df, plot_columns, zero_to_na, fill_na) + for column in columns: + if not _is_plain_numeric(df[column]): + df[column] = _to_ordered_categorical(df[column]) + + _, ax = plt.subplots() + sns.histplot(data=df, x=x, y=y, ax=ax, **kwargs) + _draw_lines(ax, vlines, hlines) + + if save is not None: + ax.figure.savefig(save, bbox_inches="tight") + if show: + plt.show() + return ax