Source code for phenotypic.analysis.qc._expected_vs_detected

"""Expected-vs-detected colony count quality check.

Compares the detected colony count per group in a measurement frame against
the expected count derived from a separately-provided metadata frame
(usually the plate's layout CSV). Surfaces groups where colonies are
missing or over-detected.
"""

from __future__ import annotations

from pathlib import Path
from typing import Annotated, Any, Callable, ClassVar

import pandas as pd
import plotly.graph_objects as go
from pydantic import (
    PrivateAttr,
    WithJsonSchema,
    field_serializer,
    field_validator,
)

from phenotypic.analysis.abc_._quality_check import QualityCheck
from phenotypic.sdk_ import ColumnRef
from phenotypic.schema import OBJECT, QUALITY_COUNT

# ``metadata`` is a single, unified field that accepts **either** an
# in-memory layout :class:`pandas.DataFrame` (an "array") **or** a path
# string to a ``.csv``/``.parquet`` layout file. The value is stored
# verbatim — ``self.metadata`` echoes exactly what the caller passed —
# while the *resolved* frame is precomputed onto the private ``_metadata``
# slot.
#
# Only a path string is JSON-native, so that is the form that round-trips
# through ``model_dump`` / ``pipeline.json``: ``_serialize_metadata``
# emits the path when ``metadata`` is a string and ``None`` when it is an
# in-memory frame (which therefore cannot be rebuilt from JSON — configure
# the check from a path whenever it must round-trip). A raw
# ``pandas.DataFrame`` has no JSON schema, so the object branch attaches a
# placeholder so ``model_json_schema()`` succeeds.
_MetadataField = Annotated[
    pd.DataFrame | str,
    WithJsonSchema({
        "oneOf": [
            {
                "type": "string",
                "description": (
                    "Path to a .csv/.parquet layout file (the form that "
                    "round-trips through JSON)."
                ),
            },
            {
                "type": "object",
                "description": (
                    "In-memory pandas DataFrame layout (runtime-only; not "
                    "JSON-serializable)."
                ),
            },
        ]
    }),
]


[docs] class ExpectedVsDetectedCount(QualityCheck): """Flag groups whose detected colony count diverges from metadata. For each ``groupby`` combination the check compares the number of rows in the measurement frame (``detected``) against the number of rows in the externally-provided ``metadata`` frame for the same key (``expected``). The signed difference and its normalized magnitude drive a tri-state pass/warn/fail label: * ``QC_Count_Metric = |detected - expected| / expected`` * ``QC_Count_Metric = numpy.inf`` when ``expected == 0`` (i.e. the measurement group has no metadata counterpart). This always exceeds ``fail_threshold`` so the status becomes ``"fail"`` and the rows are flagged. The offending key tuple is recorded in :attr:`unmatched_groups` so the GUI can distinguish a real biology fail from a metadata-mismatch fail. ``_HIGHER_IS_BAD`` is ``True``: a larger normalized count divergence is worse, so the base class flags rows whose metric meets or exceeds ``fail_threshold`` (including the infinite metric of an unmatched group). The check does **not** aggregate measurement values — it counts rows — so :attr:`_exposes_agg_func` is ``False`` and the GUI parameter-form rendering driver hides the ``agg_func`` field. The base ``SetAnalyzer.agg_func`` is pinned to ``"first"`` internally. The single ``metadata`` argument accepts **either** an in-memory :class:`pandas.DataFrame` (an "array") **or** a path (``Path`` or ``str``) to a ``.csv``/``.parquet`` file. The value is stored verbatim — ``self.metadata`` echoes exactly what was passed — and the *resolved* frame is read once at construction time onto the private ``_metadata`` slot. Every column named in ``groupby`` must be present in the resolved frame; otherwise :class:`KeyError` is raised at ``__init__`` so the failure surfaces before ``analyze`` runs. **Serialization:** only a path is JSON-native, so the path is the form that round-trips. When ``metadata`` is a path, ``model_dump`` / ``pipeline.json`` persist that path string under the same ``metadata`` key and a reloaded instance re-reads the file. When ``metadata`` is an in-memory DataFrame there is no source path to persist — the JSON form is ``None`` and the check cannot be rebuilt from JSON alone (it fails to instantiate with a clear error, surfaced as a skip-with-warning by the lazy QC instantiation path). Configure QC checks from a metadata *path* whenever the pipeline is meant to round-trip. Args: metadata: Layout whose row count per ``groupby`` key is the expected colony count. Either an in-memory DataFrame or a path (``Path``/``str``) to a ``.csv``/``.parquet`` file. The path form is what serializes and round-trips through JSON; an in-memory frame is runtime-only. groupby: Columns that define a comparison unit. Must be present in both the metadata frame and the measurement frame passed to :meth:`analyze`. on: Measurement column the check operates on. Defaults to ``"Object_Label"`` since "detected" means "a measurement row exists". warn_threshold: Normalized count divergence at which ``Status`` becomes ``"warn"``. Defaults to ``0.05``. fail_threshold: Normalized count divergence at which ``Status`` becomes ``"fail"`` and ``Flag=True``. Defaults to ``0.10``. n_jobs: Worker count. Currently unused by the base ``analyze`` loop; kept on the signature for parity with :class:`SetAnalyzer`. Raises: FileNotFoundError: If ``metadata`` is a path that does not exist. KeyError: If any column in ``groupby`` is absent from the resolved metadata frame. ValueError: If ``metadata`` is a path with an unsupported suffix, or if it is ``None`` — i.e. reconstructing from JSON that was built from an in-memory frame, which has no source path to persist. Attributes: unmatched_groups: List of group-key tuples that appeared in the measurement frame but had no counterpart in the metadata frame during the most recent :meth:`analyze` call. Reset at the top of each ``analyze`` so re-runs do not accumulate. Examples: Basic match — 96-well metadata vs. a measurement frame missing one well: >>> import pandas as pd >>> from phenotypic.analysis.qc import ( ... ExpectedVsDetectedCount, ... ) >>> metadata = pd.DataFrame({ ... "MetadataImage_ImageName": ["plate1.png"] * 96, ... "Object_Label": list(range(96)), ... }) >>> measurements = pd.DataFrame({ ... "MetadataImage_ImageName": ["plate1.png"] * 95, ... "Object_Label": list(range(95)), ... }) >>> chk = ExpectedVsDetectedCount( ... metadata=metadata, ... groupby=["MetadataImage_ImageName"], ... ) >>> result = chk.analyze(measurements) # doctest: +SKIP >>> "QC_Count_Metric" in result.columns # doctest: +SKIP True Advanced — a measurement group has no metadata counterpart, so the metric is infinite and the key is recorded: >>> metadata = pd.DataFrame({ ... "MetadataImage_ImageName": ["plate1.png"] * 96, ... "Object_Label": list(range(96)), ... }) >>> measurements = pd.DataFrame({ ... "MetadataImage_ImageName": ["plate2.png"] * 10, ... "Object_Label": list(range(10)), ... }) >>> chk = ExpectedVsDetectedCount( ... metadata=metadata, ... groupby=["MetadataImage_ImageName"], ... ) >>> _ = chk.analyze(measurements) # doctest: +SKIP >>> chk.unmatched_groups # doctest: +SKIP [('plate2.png',)] """ name: ClassVar[str] = "Count" _HIGHER_IS_BAD: ClassVar[bool] = True _exposes_agg_func: ClassVar[bool] = False # Annotate with the base ClassVar type (not a bare assignment) so # subclasses may override it with their own MeasurementInfo enum. _measurement_infoclass: ClassVar[type | None] = QUALITY_COUNT warn_threshold: float = 0.05 fail_threshold: float = 0.10 on: ColumnRef = str(OBJECT.LABEL) agg_func: Callable | str | list | dict | None = "first" # Single unified field: an in-memory layout DataFrame *or* a path to a # ``.csv``/``.parquet`` layout file. Stored verbatim; the resolved # frame is precomputed onto the private ``_metadata`` slot. Only a path # round-trips through JSON (see ``_serialize_metadata``). NOTE: # reassigning ``metadata`` post-construction does not re-resolve # ``_metadata``/``_expected_counts`` (``model_post_init`` runs once); # construct a new instance to change the layout. metadata: _MetadataField _metadata: pd.DataFrame = PrivateAttr(default_factory=pd.DataFrame) _expected_counts: pd.Series = PrivateAttr(default_factory=pd.Series) @field_validator("metadata", mode="before") @classmethod def _normalize_metadata( cls, value: pd.DataFrame | Path | str | None ) -> pd.DataFrame | str: """Normalize the raw ``metadata`` input to a frame or a path string. Keeps the value in its caller-supplied form — a frame stays a frame, a path becomes a plain ``str`` — so ``self.metadata`` echoes what was passed and a path round-trips through JSON. The file is *not* read here; resolution into the working frame happens in :meth:`model_post_init`. Args: value: An in-memory DataFrame, a path (``Path`` or ``str``) to a ``.csv``/``.parquet`` layout file, or ``None`` — the sentinel a JSON dump of an in-memory-frame check emits, which cannot be rebuilt. Returns: The DataFrame unchanged, or the path coerced to ``str``. Raises: ValueError: If ``value`` is ``None`` — i.e. the check was serialized from an in-memory frame (which has no source path) and cannot be reconstructed from JSON. Configure it from a ``.csv``/``.parquet`` path so the source round-trips. """ if value is None: raise ValueError( f"{cls.__name__} requires 'metadata' as a layout DataFrame " "or a .csv/.parquet path. Got None — a check serialized " "from an in-memory DataFrame has no source path to " "round-trip; configure it from a metadata file path so it " "can be rebuilt from JSON." ) if isinstance(value, Path): return str(value) return value @field_serializer("metadata") def _serialize_metadata( self, value: pd.DataFrame | str, info: Any ) -> str | pd.DataFrame | None: """Serialize ``metadata`` as its source *path*, never the frame. A path string is JSON-native and round-trips; an in-memory frame is not, so the JSON form is ``None`` (a reload then fails fast in :meth:`_normalize_metadata` with a clear error). In Python mode the frame passes through unchanged. Args: value: The stored ``metadata`` value (a frame or a path str). info: Pydantic serialization info; ``info.mode`` is ``"json"`` or ``"python"``. Returns: The path string when ``metadata`` is a path; otherwise the frame in Python mode, or ``None`` in JSON mode. """ if isinstance(value, str): return value if info.mode == "json": return None return value
[docs] def model_post_init(self, __context: Any) -> None: """Resolve the layout frame, validate columns, pre-compute counts. Runs after pydantic has validated every field. Resolves ``self.metadata`` (a frame or a path) into the working frame on the private ``_metadata`` slot, verifies every ``groupby`` column is present, and caches the per-key expected colony counts. Args: __context: Pydantic post-init context (unused). Raises: FileNotFoundError: If ``metadata`` is a path that does not exist. ValueError: If ``metadata`` is a path with an unsupported suffix. KeyError: If any column in ``groupby`` is absent from the resolved metadata frame. """ super().model_post_init(__context) self._metadata = self._resolve_metadata(self.metadata) missing = [ col for col in self.groupby if col not in self._metadata.columns ] if missing: raise KeyError( "metadata frame is missing required groupby column(s): " f"{missing}" ) self._expected_counts = self._metadata.groupby( self.groupby, dropna=False ).size()
@staticmethod def _resolve_metadata( metadata: pd.DataFrame | Path | str, ) -> pd.DataFrame: """Coerce a DataFrame-or-path metadata argument into a DataFrame. Args: metadata: Either an in-memory DataFrame or a path (``Path`` or ``str``) to a ``.csv``/``.parquet`` file. Returns: The resolved DataFrame. If ``metadata`` is already a DataFrame it is returned as-is (no copy). Raises: FileNotFoundError: If ``metadata`` is a path that does not exist. ValueError: If the path has an unsupported suffix. """ if isinstance(metadata, pd.DataFrame): return metadata path = Path(metadata) if not path.exists(): raise FileNotFoundError( f"metadata path does not exist: {path}" ) suffix = path.suffix.lower() if suffix == ".csv": return pd.read_csv(path) if suffix == ".parquet": return pd.read_parquet(path) raise ValueError( "metadata path must be a .csv or .parquet file; got " f"suffix {suffix!r}" ) def _lookup_expected(self, key: Any) -> int: """Return the expected row count for one ``groupby`` key. Args: key: The group key as produced by ``DataFrame.groupby(groupby).__iter__`` — a scalar when ``groupby`` is a single column, otherwise a tuple. Returns: The expected row count, or ``0`` if the key is not present in the metadata's index. """ try: value = self._expected_counts.loc[key] except KeyError: return 0 if isinstance(value, pd.Series): return int(value.sum()) return int(value) def _compute(self, group: pd.DataFrame) -> pd.DataFrame: """Compute count-divergence metrics for one group. Looks up the group's expected count, broadcasts the detected / expected / delta / metric scalars across every row, and records the key tuple in :attr:`unmatched_groups` when no metadata counterpart was found. Args: group: One group as produced by ``data.groupby(self.groupby, dropna=False)``. Returns: The group frame (a copy) with four new columns appended: ``QC_Count_Detected``, ``QC_Count_Expected``, ``QC_Count_Delta``, ``QC_Count_Metric``. """ detected = int(len(group)) key = self._group_key(group) expected = self._lookup_expected(key) if expected == 0: self.unmatched_groups.append(key) metric = float("inf") else: metric = abs(detected - expected) / expected delta = detected - expected out = group.copy() out[str(QUALITY_COUNT.DETECTED)] = detected out[str(QUALITY_COUNT.EXPECTED)] = expected out[str(QUALITY_COUNT.DELTA)] = delta out[self.metric_col()] = float(metric) return out def _group_key(self, group: pd.DataFrame) -> tuple: """Extract the ``groupby`` key for a single group as a tuple. Args: group: One group frame. The values in ``self.groupby`` columns are constant within the group, so the first row suffices. Returns: A tuple of the group's ``groupby`` values, regardless of whether ``groupby`` has one or many columns. Tuples are used uniformly so the per-key index lookup is independent of ``groupby`` arity. """ row = group.iloc[0] return tuple(row[col] for col in self.groupby)
[docs] def analyze(self, data: pd.DataFrame) -> pd.DataFrame: """Reset :attr:`unmatched_groups` and run the base ``analyze``. Re-running the check on a different measurement frame must not carry over unmatched groups from a previous run, so the list is cleared before delegating to the base class. Args: data: Measurement frame to evaluate. Returns: The augmented frame from :meth:`QualityCheck.analyze`. """ self.unmatched_groups = [] return super().analyze(data)
[docs] def dash(self, **kwargs: Any) -> go.Figure: """Render a horizontal lollipop chart of ``Delta`` per group. Each group's signed ``Delta`` is drawn as a horizontal stem from zero to ``Delta``, with a marker at the tip colored by ``Status``. The hover label exposes detected, expected, and the metric for the group. Args: **kwargs: Passed through to :func:`plotly.graph_objects.Figure` / ``Figure.update_layout`` — accepted keys are ``title`` and ``height``. Returns: A :class:`plotly.graph_objects.Figure` with one stem trace and one marker trace. Raises: RuntimeError: If :meth:`analyze` has not been called yet. """ df = self._latest_measurements if df.empty: raise RuntimeError("call analyze() first") metric_col = self.metric_col() status_col = self.status_col() delta_col = str(QUALITY_COUNT.DELTA) detected_col = str(QUALITY_COUNT.DETECTED) expected_col = str(QUALITY_COUNT.EXPECTED) per_group = ( df.groupby(self.groupby, dropna=False) .agg({ delta_col: "first", detected_col: "first", expected_col: "first", metric_col: "first", status_col: "first", }) .reset_index() ) labels = per_group[self.groupby].astype(str).agg(" | ".join, axis=1) deltas = per_group[delta_col].astype(float) statuses = per_group[status_col].astype(str) status_colors = { "pass": "#2E86AB", "warn": "#F4A261", "fail": "#E63946", } marker_colors = statuses.map(status_colors).fillna("#888888") hover = [ ( f"Detected: {int(d)}<br>" f"Expected: {int(e)}<br>" f"Delta: {int(dl)}<br>" f"Metric: {sv:.4f}<br>" f"Status: {st}" ) for d, e, dl, sv, st in zip( per_group[detected_col], per_group[expected_col], deltas, per_group[metric_col].astype(float), statuses, ) ] fig = go.Figure() for label, delta in zip(labels, deltas): fig.add_trace( go.Scatter( x=[0, delta], y=[label, label], mode="lines", line={"color": "#888888", "width": 2}, showlegend=False, hoverinfo="skip", ) ) fig.add_trace( go.Scatter( x=deltas, y=labels, mode="markers", marker={"color": marker_colors.tolist(), "size": 12}, text=hover, hoverinfo="text", showlegend=False, ) ) fig.add_vline(x=0, line={"color": "black", "width": 1}) fig.update_layout( title=kwargs.get( "title", "Expected vs. Detected Colony Count" ), xaxis_title="Detected − Expected", yaxis_title=" | ".join(self.groupby), height=kwargs.get("height", max(240, 24 * len(labels) + 80)), ) return fig