Source code for acia.analysis.properties

"""Before/after property-distribution histograms for filtered cell populations.

This module renders a grid of density-normalized histograms for one or more
properties from a property-extractor DataFrame (the output of
:class:`acia.analysis.ExtractorExecutor`), optionally comparing an unfiltered
population against a filtered one (e.g. via
:func:`acia.segm.filter.apply_cell_filters`). Properties are laid out as
columns; when a filtered ("after") DataFrame is given, before/after are
stacked as rows within the same column and share identical bin edges and axis
limits, so outliers can be visually confirmed to "stay or vanish" under
filtering.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING

import numpy as np
import pandas as pd

if TYPE_CHECKING:
    import pint
    from matplotlib.figure import Figure


def _axis_label(prop: str, units: dict[str, pint.Unit] | None) -> str:
    """Build the x-axis label for ``prop``, appending a pint unit suffix.

    Args:
        prop: the property/column name.
        units: optional column -> :class:`pint.Unit` mapping (e.g.
            :attr:`acia.analysis.ExtractorExecutor.units`). When ``prop`` is
            absent (or ``units`` is ``None``), no unit suffix is added.

    Returns:
        ``f"{prop} [{units[prop]:~P}]"`` when a unit is known, else the bare
        ``prop`` name. The ``~P`` (pretty) pint format yields Unicode (e.g.
        ``µm²``) that matplotlib renders directly; ``~L`` (LaTeX) is avoided
        because matplotlib only interprets LaTeX inside ``$...$`` and would
        otherwise show the raw ``\\mathrm{...}`` markup.
    """
    if units is not None and prop in units:
        return f"{prop} [{units[prop]:~P}]"
    return prop


def _finite(series) -> np.ndarray:
    """Return a column's finite float values as a 1-D array (drops NaN/inf)."""
    arr = np.asarray(series, dtype=float)
    return np.asarray(arr[np.isfinite(arr)])


def _hist_or_note(ax, values: np.ndarray, bins, **hist_kwargs) -> None:
    """Density-histogram ``values`` on ``ax``, or annotate "no cells" if empty.

    An empty population (no detected cells, or all cells filtered out) is a valid
    state, not an error -- the axis is drawn empty with a note instead of raising.
    """
    if values.size:
        ax.hist(values, bins=bins, density=True, **hist_kwargs)
    else:
        ax.text(
            0.5,
            0.5,
            "no cells",
            ha="center",
            va="center",
            transform=ax.transAxes,
            color="0.6",
        )


[docs] def plot_property_histograms( df_before: pd.DataFrame, properties: Sequence[str], *, df_after: pd.DataFrame | None = None, units: dict[str, pint.Unit] | None = None, bins: int = 50, log_y: bool = False, show_removed: bool = False, ) -> Figure: """Plot density-normalized before/after histograms for one or more properties. Properties are h-stacked as columns. When ``df_after`` is given, before (row 0) and after (row 1) are v-stacked as rows within each column, and share identical bin edges (computed from the combined range of ``df_before``/``df_after`` for that property) and identical x/y axis limits, so the two histograms for one property stay directly comparable. Args: df_before: the unfiltered (or "before") property DataFrame, e.g. the output of :meth:`acia.analysis.ExtractorExecutor.execute`. properties: column names of ``df_before`` (and ``df_after``, if given) to plot, one column of the grid per property. df_after: optional filtered (or "after") property DataFrame with the same columns as ``df_before``. When given, the grid gains a second row per property for direct before/after comparison. When ``None``, only ``df_before`` is plotted (one row). units: optional column -> :class:`pint.Unit` mapping (e.g. :attr:`acia.analysis.ExtractorExecutor.units`) used to format each column's x-axis label as ``f"{prop} [{unit:~L}]"``. Properties missing from ``units`` (or ``units=None``) fall back to the bare property name. bins: number of histogram bins (default ``50``). log_y: if ``True``, every Axes' y-scale is set to ``"log"`` (still density-normalized). Default is linear. show_removed: if ``True`` and ``df_after`` is an index-subset of ``df_before``, overlay the filtered-out cells (``before`` minus ``after``) on each "after" histogram as a red step outline, so you can see where in the property's range the filter cut. Density-normalized (shows location/shape, not count). Default ``False``. Returns: A matplotlib :class:`~matplotlib.figure.Figure` with a ``1 x len(properties)`` grid (``df_after=None``) or ``2 x len(properties)`` grid (``df_after`` given) of density histograms. The figure is not shown (no ``plt.show()``). Raises: TypeError: if ``properties`` is a bare ``str`` instead of a sequence of column names (a plausible typo, e.g. ``"area"`` instead of ``["area"]``, that would otherwise be silently iterated character-by-character). ValueError: if ``properties`` is empty. A property with no finite values to plot -- an empty population (0 rows), an empty ``df_after`` (all cells filtered out), or an all-NaN column -- is *not* an error: that axis is drawn empty with a "no cells" note. KeyError: if a property in ``properties`` is not a column of ``df_before`` (or ``df_after``, when given). """ import matplotlib.pyplot as plt if isinstance(properties, str): raise TypeError( "properties must be a sequence of column names, not a single string" ) n_props = len(properties) if n_props == 0: raise ValueError("properties must be non-empty") has_after = df_after is not None # Pass 1: validate everything and precompute finite-filtered arrays (and, # for the df_after case, shared bin edges) BEFORE any Figure is created, # so a validation failure can never leak an orphaned/partial Figure. before_arrays: list[np.ndarray] = [] after_arrays: list[np.ndarray] = [] removed_arrays: list[np.ndarray] = [] bins_per_prop: list[np.ndarray | int] = [] # the removed cells = before rows whose index is not among the survivors # (only meaningful when df_after is an index-subset of df_before) removed_index = None if ( df_after is not None and show_removed and df_after.index.isin(df_before.index).all() ): removed_index = df_before.index.difference(df_after.index) for prop in properties: if prop not in df_before.columns: raise KeyError(f"'{prop}' not found in df_before") before_finite = _finite(df_before[prop]) before_arrays.append(before_finite) if df_after is not None: if prop not in df_after.columns: raise KeyError(f"'{prop}' not found in df_after") after_finite = _finite(df_after[prop]) after_arrays.append(after_finite) # bin edges from whatever finite values exist -- an empty "before" or # "after" (e.g. all cells filtered out) is drawn empty, not raised. populated = [a for a in (before_finite, after_finite) if a.size] if populated: combined = np.concatenate(populated) bins_per_prop.append(np.histogram_bin_edges(combined, bins=bins)) else: bins_per_prop.append(bins) if removed_index is not None and len(removed_index): rem = np.asarray(df_before.loc[removed_index, prop], dtype=float) removed_arrays.append(rem[np.isfinite(rem)]) else: removed_arrays.append(np.array([])) else: bins_per_prop.append(bins) # Pass 2: only now create the Figure and plot, using the precomputed # finite arrays/bin edges from Pass 1. fig, axes = plt.subplots( 2 if has_after else 1, n_props, squeeze=False, sharex="col" if has_after else False, sharey="col" if has_after else False, ) for j, prop in enumerate(properties): prop_bins = bins_per_prop[j] if has_after: ax_before = axes[0, j] ax_after = axes[1, j] _hist_or_note(ax_before, before_arrays[j], prop_bins) _hist_or_note(ax_after, after_arrays[j], prop_bins, label="kept") # overlay the removed cells in red so you can see WHERE (in this # property's range) the filter cut cells -- density-normalized, so it # shows the shape/location of the removed population, not its count. if show_removed and removed_arrays and removed_arrays[j].size: ax_after.hist( removed_arrays[j], bins=prop_bins, density=True, histtype="step", color="red", linewidth=1.5, label="removed", ) ax_after.legend(fontsize="small") ax_before.set_title(prop) ax_after.set_xlabel(_axis_label(prop, units)) if j == 0: ax_before.set_ylabel("before") ax_after.set_ylabel("after") for ax in (ax_before, ax_after): ax.grid(True, linestyle=":", alpha=0.4) ax.set_axisbelow(True) if log_y: ax.set_yscale("log") else: ax = axes[0, j] _hist_or_note(ax, before_arrays[j], prop_bins) ax.set_title(prop) ax.set_xlabel(_axis_label(prop, units)) ax.grid(True, linestyle=":", alpha=0.4) ax.set_axisbelow(True) if log_y: ax.set_yscale("log") fig.tight_layout() return fig