Source code for phenotypic.analysis.qc._relative_mad

"""Robust replicate-agreement quality check based on the relative MAD.

Flags ``(group, time)`` bins whose biological replicates disagree on a
phenotype, using a robust spread estimate that resists single
contaminated or mis-segmented colonies. For each timepoint the check
computes the median absolute deviation (MAD) of the measurement across
replicates, normalizes by the absolute median to produce a
relative-MAD metric, and broadcasts the per-bin scalars back to every
replicate row in the bin so downstream curation can pick up the flag
from any row.

This is the robust analogue of
:class:`~phenotypic.analysis.qc._replicate_agreement.ReplicateAgreement`'s
relative standard error: where the SE check is sensitive to a single
outlying replicate, the MAD's 50% breakdown point keeps the metric
stable until more than half the replicates disagree.
"""

from __future__ import annotations

from typing import ClassVar

import numpy as np
import pandas as pd

from phenotypic.analysis._qc_math import median_abs_deviation
from phenotypic.analysis.abc_._quality_check import QualityCheck
from phenotypic.schema import QUALITY_MAD
from phenotypic.sdk_ import ColumnRef


[docs] class RelativeMAD(QualityCheck): """Flag ``(group, time)`` bins with poor robust agreement across replicates. For each combination of ``self.groupby`` columns, this check splits the group by ``self.time_label`` and computes the median absolute deviation (MAD) of the measurement across replicates at every timepoint. The relative MAD ``metric = MAD / |median|`` is the per-bin metric; bins whose metric exceeds the warn/fail thresholds are surfaced for curation. The per-bin scalars are broadcast back to every replicate row in the bin so the GUI can pick up the flag from any row. Because the MAD has a 50% breakdown point, the metric stays accurate even when up to half the replicates in a bin are contaminated — a single mis-segmented or contaminated colony will not inflate it the way it inflates the relative standard error. It is therefore the robust counterpart to :class:`~phenotypic.analysis.qc._replicate_agreement.ReplicateAgreement`. ``_HIGHER_IS_BAD`` is ``True``: a larger relative MAD means worse replicate agreement, so the base class flags rows whose metric meets or exceeds ``fail_threshold``. Three guard paths short-circuit to ``metric = NaN`` so under-powered or degenerate bins never gate curation (the base class treats ``NaN`` metric as ``Status="pass"``): 1. **``n < min_replicates``** — too few replicates for a meaningful spread estimate. Defaults to ``min_replicates=2``; raising it lets callers demand more statistical power. 2. **``|median| < eps``** — the relative-MAD ratio blows up at zero median, so near-zero baseline measurements (t=0 wells, blank wells, true-zero conditions) would otherwise flag every row. The default ``eps=1e-9`` catches sensor-zero readouts without losing genuinely-above-noise-floor measurements. 3. **``MAD == 0`` and ``median == 0``** — degenerate bin (all replicates exactly zero); mathematically undefined. Treated as pass. When ``self.time_label`` is absent from the input data, the entire group is treated as a single timepoint bin so the check remains usable on snapshot (non-time-course) measurement frames. The check does **not** aggregate measurement values — it builds the median/MAD summary statistics inside :meth:`_compute` — 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 preserved on the signature for parity only. Attributes: time_label: Column name carrying the timepoint within each group. Defaults to ``"Metadata_Time"``. min_replicates: Minimum replicate count required before the MAD is considered meaningful. Bins below this threshold receive ``metric = NaN``. eps: Floor on ``|median|`` below which the relative-MAD ratio is considered undefined. Bins below this floor receive ``metric = NaN``. warn_threshold: Relative MAD at which ``Status`` becomes ``"warn"``. Defaults to ``0.10``. fail_threshold: Relative MAD at which ``Status`` becomes ``"fail"`` and ``Flag=True``. Defaults to ``0.20``. Examples: Basic — three-replicate, four-timepoint synthetic frame; the check adds ``QC_MAD_Metric`` plus the per-bin summary columns: >>> import pandas as pd >>> from phenotypic.analysis.qc import RelativeMAD >>> times = [0, 1, 2, 3] >>> data = pd.DataFrame({ ... "Plate": ["P1"] * 12, ... "Metadata_Time": [t for t in times for _ in range(3)], ... "Replicate": [1, 2, 3] * 4, ... "Size_Area": [ ... 10.0, 10.1, 9.9, ... 20.0, 20.2, 19.8, ... 40.0, 40.4, 39.6, ... 80.0, 80.8, 79.2, ... ], ... }) >>> chk = RelativeMAD( ... on="Size_Area", ... groupby=["Plate"], ... time_label="Metadata_Time", ... ) >>> result = chk.analyze(data) # doctest: +SKIP >>> "QC_MAD_Metric" in result.columns # doctest: +SKIP True Advanced — only one replicate per ``(group, time)`` bin with ``min_replicates=2`` triggers the under-powered guard: >>> singleton = pd.DataFrame({ ... "Plate": ["P1", "P1"], ... "Metadata_Time": [0, 1], ... "Size_Area": [10.0, 20.0], ... }) >>> chk = RelativeMAD( ... on="Size_Area", ... groupby=["Plate"], ... min_replicates=2, ... ) >>> result = chk.analyze(singleton) # doctest: +SKIP >>> bool(result["QC_MAD_Metric"].isna().all()) # doctest: +SKIP True """ name: ClassVar[str] = "MAD" _HIGHER_IS_BAD: ClassVar[bool] = True _exposes_agg_func: ClassVar[bool] = False _measurement_infoclass = QUALITY_MAD warn_threshold: float = 0.10 fail_threshold: float = 0.20 time_label: ColumnRef = "Metadata_Time" min_replicates: int = 2 eps: float = 1e-9 def _compute(self, group: pd.DataFrame) -> pd.DataFrame: """Compute per-``(group, time)`` MAD statistics and broadcast back. 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_MAD_Median``, ``QC_MAD_MAD``, ``QC_MAD_NumMembers``, ``QC_MAD_Metric``. The metric column is ``NaN`` for bins that hit any of the three guard paths documented on the class. """ out = group.copy() median_col = str(QUALITY_MAD.MEDIAN) mad_col = str(QUALITY_MAD.MAD) n_col = str(QUALITY_MAD.NUM_MEMBERS) metric_col = self.metric_col() # Initialize emitted columns so partial bins still produce a # consistent column set on return. out[median_col] = np.nan out[mad_col] = np.nan out[n_col] = 0 out[metric_col] = np.nan if len(out) == 0: return out if self.time_label in out.columns: time_iter = out.groupby(self.time_label, dropna=False) else: # Single-bin fallback for snapshot data. time_iter = [(None, out)] for _, bin_frame in time_iter: idx = bin_frame.index values = bin_frame[self.on].dropna().to_numpy(dtype=float) n = int(len(values)) median_val = float(np.nanmedian(values)) if n > 0 else float("nan") mad_val = median_abs_deviation(values) if n > 0 else float("nan") under_powered = n < self.min_replicates near_zero_median = abs(median_val) < self.eps degenerate = mad_val == 0 and median_val == 0 if under_powered or near_zero_median or degenerate: metric = float("nan") else: metric = mad_val / abs(median_val) out.loc[idx, median_col] = median_val out.loc[idx, mad_col] = mad_val out.loc[idx, n_col] = n out.loc[idx, metric_col] = metric out[n_col] = out[n_col].astype(int) return out