Source code for phenotypic.analysis.abc_._quality_check

"""Base ABC for metric-driven quality-control checks on measurement frames."""

from __future__ import annotations

import abc
from abc import ABC
from typing import Any, ClassVar

import pandas as pd
from pydantic import Field, model_validator

from phenotypic.schema import OBJECT, QUALITY_CHECK

from ._set_analyzer import SetAnalyzer


[docs] class QualityCheck(SetAnalyzer, ABC): """Detect quality-control issues in measurement frames. ``QualityCheck`` is a thin layer over :class:`SetAnalyzer` that standardizes how subclasses surface flagged rows for downstream curation. Subclasses implement a single ``_compute(group)`` hook that augments one group with at minimum a raw, directional ``QC_<name>_Metric`` column (the check's headline value in its own units). The base class then derives two companion columns from the metric, using the check's ``_HIGHER_IS_BAD`` direction flag to decide which side of each threshold is "bad": * ``QC_<name>_Flag`` (``bool``): ``True`` when the metric crosses ``fail_threshold`` in the bad direction. When ``_HIGHER_IS_BAD`` is ``True`` that means ``metric >= fail_threshold``; when ``False`` (e.g. an agreement score where lower is worse) it means ``metric <= fail_threshold``. Rows with ``Flag=True`` are the ones the results-viewer GUI offers to mark for curation removal. * ``QC_<name>_Status`` (``str``): tri-state label derived from the same metric column. With ``_HIGHER_IS_BAD=True``: ``"pass"`` until ``metric >= warn_threshold`` (``"warn"``), then ``metric >= fail_threshold`` (``"fail"``), with ``warn_threshold <= fail_threshold``. With ``_HIGHER_IS_BAD=False`` the comparisons invert to ``<=`` with ``fail_threshold <= warn_threshold``. Only ``"fail"`` triggers ``Flag=True``; the ``"warn"`` tier is informational. NaN metrics (e.g. under-powered replicate bins in :class:`ReplicateAgreement`) are treated as ``"pass"`` with ``Flag=False`` so degenerate groups never gate curation. Subclasses set class-level attributes that drive column naming, threshold direction, and docstring autogeneration: * ``name`` — short identifier composed into output column names (``QC_<name>_Flag`` and friends). Must be set on every concrete subclass. * ``_HIGHER_IS_BAD`` — ``True`` when a larger metric value is worse (the common case), ``False`` when a smaller value is worse (e.g. an agreement score). Intrinsic to the metric, not user-tunable; it has **no default on the base** and must be set on every concrete subclass. * ``_measurement_infoclass`` — optional per-subclass :class:`MeasurementInfo` enum documenting any check-specific columns the subclass emits beyond the generic ``Flag``/``Metric``/``Status`` trio. When set, ``__init_subclass__`` appends its RST table to the subclass docstring. The base class drives group iteration directly in :meth:`analyze`, so the abstract :meth:`SetAnalyzer._apply2group_func` is overridden to raise — subclasses implement ``_compute`` instead. Attributes: name: Short identifier composed into output column names. Set on each concrete subclass (e.g. ``"Count"``, ``"SE"``). warn_threshold: Metric value (in the check's own units) at which ``Status`` becomes ``"warn"`` in the bad direction. A pydantic instance field; subclasses override the default by re-declaring it, and callers may override it per instance. fail_threshold: Metric value at which ``Status`` becomes ``"fail"`` and ``Flag=True`` in the bad direction. A pydantic instance field; subclasses override the default by re-declaring it, and callers may override it per instance. unmatched_groups: Groups that the check could not evaluate (for example, expected counts whose group key never appeared in the data). Populated by subclasses that need to report missing combinations; empty by default. """ name: ClassVar[str] _HIGHER_IS_BAD: ClassVar[bool] _exposes_agg_func: ClassVar[bool] = False _measurement_infoclass: ClassVar[type | None] = None warn_threshold: float = 0.05 fail_threshold: float = 0.10 unmatched_groups: list = Field(default_factory=list) @model_validator(mode="after") def _validate_threshold_order(self) -> QualityCheck: """Reject thresholds ordered against the check's bad direction. The ``warn`` tier must always sit between ``"pass"`` and ``"fail"`` in the metric's bad direction, so the two thresholds have a fixed ordering that depends only on ``_HIGHER_IS_BAD``: * ``_HIGHER_IS_BAD=True`` → ``warn_threshold <= fail_threshold`` (a larger metric is worse, so ``fail`` is the higher boundary). * ``_HIGHER_IS_BAD=False`` → ``warn_threshold >= fail_threshold`` (a smaller metric is worse, so ``fail`` is the lower boundary). Equality is allowed — it collapses the ``warn`` band so the check is purely pass/fail. A mis-ordered pair would silently invert the tri-state (e.g. a lower-is-bad check that fails *above* its warn line), so it raises ``ValueError`` (surfaced by pydantic as a ``ValidationError``) at construction. Intermediate ABCs that have not yet bound ``_HIGHER_IS_BAD`` skip the check so the base class stays instantiable for test doubles. Returns: The validated instance. Raises: ValueError: If the thresholds are ordered against the bad direction implied by ``_HIGHER_IS_BAD``. """ higher_is_bad = getattr(type(self), "_HIGHER_IS_BAD", None) if higher_is_bad is None: return self if higher_is_bad and self.warn_threshold > self.fail_threshold: raise ValueError( "warn_threshold must be <= fail_threshold for a " "higher-is-bad check " f"(got warn={self.warn_threshold}, " f"fail={self.fail_threshold})" ) if not higher_is_bad and self.warn_threshold < self.fail_threshold: raise ValueError( "warn_threshold must be >= fail_threshold for a " "lower-is-bad check " f"(got warn={self.warn_threshold}, " f"fail={self.fail_threshold})" ) return self @abc.abstractmethod def _compute(self, group: pd.DataFrame) -> pd.DataFrame: """Add the check's metric columns to one group. Must add at minimum the metric column (``QC_<name>_Metric``), the raw headline value in the check's own units. May add check-specific columns documented by ``_measurement_infoclass``. ``Flag`` and ``Status`` are derived by the base class from the metric and ``_HIGHER_IS_BAD``, so subclasses must not set them directly. Args: group: A single group as produced by ``data.groupby(self.groupby, dropna=False)``. Returns: The group frame (typically a copy) augmented with the ``QC_<name>_Metric`` column and any check-specific metric columns. """
[docs] def analyze(self, data: pd.DataFrame) -> pd.DataFrame: """Run the check on every group and return the augmented frame. Iterates over ``data.groupby(self.groupby, dropna=False)``, delegates per-group computation to :meth:`_compute`, and adds three generic columns derived from the metric: * ``QC_<name>_Metric`` (carry-through from ``_compute``) * ``QC_<name>_Flag`` (``bool``) * ``QC_<name>_Status`` (``"pass"`` / ``"warn"`` / ``"fail"``) ``Flag`` and ``Status`` are directional. With ``_HIGHER_IS_BAD=True`` a row fails when ``metric >= fail_threshold`` and warns when ``metric >= warn_threshold``; with ``_HIGHER_IS_BAD=False`` the comparisons invert to ``<=``. A ``NaN`` metric always yields ``Status="pass"`` and ``Flag=False``. Rows are never dropped. The augmented frame is stored on :attr:`_latest_measurements` and returned. Args: data: Input measurement frame. Must contain ``self.on`` and every column in ``self.groupby``. Returns: The input frame with the three generic QC columns appended plus whatever ``_compute`` contributed. Raises: KeyError: If ``self.on`` or any column in ``self.groupby`` is missing from ``data``. """ missing = [ col for col in [self.on, *self.groupby] if col not in data.columns ] if missing: raise KeyError( f"Missing required columns for QualityCheck: {missing}" ) metric_col = self.metric_col() flag_col = self.flag_col() status_col = self.status_col() pieces: list[pd.DataFrame] = [] for _, group in data.groupby(self.groupby, dropna=False): pieces.append(self._compute(group)) if pieces: result = pd.concat(pieces, axis=0) else: result = data.iloc[0:0].copy() result[metric_col] = pd.Series(dtype=float) metric = pd.to_numeric(result[metric_col], errors="coerce") status = pd.Series("pass", index=result.index, dtype=object) if self._HIGHER_IS_BAD: flag = metric.ge(self.fail_threshold) status = status.mask(metric.ge(self.warn_threshold), "warn") status = status.mask(metric.ge(self.fail_threshold), "fail") else: flag = metric.le(self.fail_threshold) status = status.mask(metric.le(self.warn_threshold), "warn") status = status.mask(metric.le(self.fail_threshold), "fail") flag = flag.fillna(False).astype(bool) status = status.where(metric.notna(), "pass") result[metric_col] = metric result[flag_col] = flag result[status_col] = status self._latest_measurements = result return result
[docs] def summary(self) -> pd.DataFrame: """Return a one-row-per-group summary of the most recent analyze. The aggregate columns are **prefixed with ``qc_``** so they can never collide with a ``groupby`` column on ``reset_index`` — a plate-layout column literally named ``status`` or ``num_rows`` would otherwise raise. The summary therefore always carries the group key columns *plus* the four prefixed aggregates. Returns: DataFrame with columns ``[*self.groupby, "qc_n_members", "qc_n_flagged", "qc_worst_metric", "qc_status"]``. ``qc_worst_metric`` is the extreme metric value in the bad direction across the group: ``group[metric_col].max()`` when ``_HIGHER_IS_BAD`` is ``True``, else ``group[metric_col].min()``. ``qc_status`` is the worst status across the group: ``"fail"`` wins over ``"warn"`` which wins over ``"pass"``. """ rank = {"pass": 0, "warn": 1, "fail": 2} inv_rank = {v: k for k, v in rank.items()} df = self._latest_measurements metric_col = self.metric_col() flag_col = self.flag_col() status_col = self.status_col() higher_is_bad = self._HIGHER_IS_BAD def _summarize(group: pd.DataFrame) -> pd.Series: worst = int(group[status_col].map(rank).max()) metric = group[metric_col] worst_metric = metric.max() if higher_is_bad else metric.min() return pd.Series({ "qc_n_members": int(len(group)), "qc_n_flagged": int(group[flag_col].sum()), "qc_worst_metric": float(worst_metric), "qc_status": inv_rank[worst], }) grouped = df.groupby(self.groupby, dropna=False) summary = grouped.apply(_summarize, include_groups=False).reset_index() return summary
[docs] def flagged_keys(self) -> list[tuple[str, int]]: """Return (``Metadata_ImageFile``, ``Object_Label``) pairs to curate. Used by the GUI "Mark all flagged for removal" button. Requires the analyzed frame to carry both ``Metadata_ImageFile`` and ``Object_Label`` columns (the curation key used by ``STORE_REMOVED_KEYS``). Returns an empty list when those columns are absent or when no rows were flagged. Returns: De-duplicated list of ``(image_file, object_label)`` tuples for rows where ``Flag=True``. """ df = self._latest_measurements flag_col = self.flag_col() label_col = str(OBJECT.LABEL) if flag_col not in df.columns: return [] if "Metadata_ImageFile" not in df.columns or label_col not in df.columns: return [] flagged = df.loc[df[flag_col].fillna(False).astype(bool), ["Metadata_ImageFile", label_col]].dropna() if flagged.empty: return [] flagged = flagged.drop_duplicates() return [ (str(row.Metadata_ImageFile), int(row.Object_Label)) for row in flagged.itertuples(index=False) ]
[docs] def group_members(self) -> dict[tuple, list[tuple[str, int, Any]]]: """Map each group key to its member rows for worklists/galleries. Walks the most recent analyzed frame and, for every group key produced by ``data.groupby(self.groupby, dropna=False)``, collects the rows that belong to it as ``(Metadata_ImageFile, Object_Label, member_value)`` tuples, where ``member_value`` is the row's ``self.on`` value (the column the check operates on). The mapping preserves group iteration order. Mirrors :meth:`flagged_keys`'s guard: if the analyzed frame lacks either ``Metadata_ImageFile`` or the object-label column, an empty mapping is returned rather than raising. Returns: Ordered mapping of group key (always a tuple, even for a single ``groupby`` column) to a list of ``(image_file, object_label, member_value)`` tuples. Empty when the curation key columns are absent. """ df = self._latest_measurements label_col = str(OBJECT.LABEL) if "Metadata_ImageFile" not in df.columns or label_col not in df.columns: return {} members: dict[tuple, list[tuple[str, int, Any]]] = {} for key, group in df.groupby(self.groupby, dropna=False): key_tuple = key if isinstance(key, tuple) else (key,) image_files = group["Metadata_ImageFile"].tolist() labels = group[label_col].tolist() values = group[self.on].tolist() members[key_tuple] = [ (str(image_file), int(label), value) for image_file, label, value in zip( image_files, labels, values ) ] return members
[docs] @classmethod def metric_col(cls) -> str: """Return the metric column name for this check.""" return f"QC_{cls.name}_Metric"
[docs] @classmethod def flag_col(cls) -> str: """Return the flag column name for this check.""" return f"QC_{cls.name}_Flag"
[docs] @classmethod def status_col(cls) -> str: """Return the status column name for this check.""" return f"QC_{cls.name}_Status"
[docs] def results(self) -> pd.DataFrame: """Return the augmented frame stored by the most recent analyze().""" return self._latest_measurements
@staticmethod def _apply2group_func(group: pd.DataFrame, **kwargs: Any) -> pd.DataFrame: """Not used by QualityCheck — implement ``_compute`` on the subclass. :meth:`QualityCheck.analyze` drives group iteration directly via :meth:`_compute`; the abstract ``_apply2group_func`` from :class:`SetAnalyzer` is satisfied here purely to keep the class instantiable. Raises ``NotImplementedError`` so accidental external calls fail loudly. Raises: NotImplementedError: Always. """ raise NotImplementedError( "QualityCheck subclasses implement _compute(group), not " "_apply2group_func. analyze() drives the iteration." )
[docs] def show(self, *args: Any, **kwargs: Any) -> Any: """QualityCheck plots are Plotly-only — see :meth:`dash`. :class:`SetAnalyzer`'s matplotlib ``show()`` is not implemented for QC because the QC tab is Plotly-driven. Raising rather than falling back to a placeholder so notebook users discover the right method. Raises: NotImplementedError: Always; use :meth:`dash` instead. """ raise NotImplementedError( f"{type(self).__name__} does not implement matplotlib " f"show(); use dash() for interactive output." )
[docs] def __init_subclass__(cls, **kwargs: Any) -> None: """Append QC and per-check RST tables to the subclass docstring. Skips intermediate ABCs that have not yet bound ``name``. When the subclass declares both a docstring and a ``name``, the generic :class:`QUALITY_CHECK` table is appended (substituting ``name`` into the column headers). If ``_measurement_infoclass`` is also set, its table is appended as well so check-specific columns are documented alongside the generic trio. """ super().__init_subclass__(**kwargs) if cls.__doc__ and getattr(cls, "name", None): cls.__doc__ = QUALITY_CHECK.append_rst_to_doc( cls.__doc__, check_name=cls.name ) mi = getattr(cls, "_measurement_infoclass", None) if mi is not None: cls.__doc__ = mi.append_rst_to_doc(cls.__doc__)