Source code for phenotypic.analysis._error_cutoffs

"""Good-vs-error-category measurement screen with per-measurement cutoffs.

For one error category, :class:`ErrorCutoffFinder` compares the *good* baseline
distribution against the *error* distribution on every measurement column and
ranks the measurements by how cleanly they separate the two (AUC). Each
discriminative measurement gets a ROC/Youden's-J cutoff with the recall and
specificity it achieves (plus the count of good objects it would wrongly
flag), the one-way ANOVA F/p, and a Benjamini-Hochberg FDR-adjusted p. The
result is the table the Error-analysis tab reads so the user can adopt a cutoff
to filter similar bad data.

The engine is deliberately **GUI/IO-free and mode-agnostic**: it takes a *good*
frame and an *error* frame and does not know whether the good baseline is
"all unlabeled" or the verified-only set — the caller decides (spec §7).
"""

from __future__ import annotations

import warnings

import numpy as np
import numpy.typing as npt
import pandas as pd
from pydantic import BaseModel, ConfigDict, field_validator
from scipy.stats import f_oneway, false_discovery_control

#: Column-name prefixes treated as numeric **phenotype** measurements.
#: Absolute position (``Bbox_`` centroids/corners) is intentionally **excluded**
#: (resolved decision) — a "cutoff" on plate position is a spatial artifact, not
#: a phenotype filter. ``Texture`` (no trailing ``_``) matches the whole
#: ``Texture``-prefixed namespace regardless of the matrix/scale suffix. This
#: list is defined independently — the colony grid's ``_MEASUREMENT_PREFIXES``
#: is a UI axis-exclusion list, not an authoritative phenotype-measurement set.
MEASUREMENT_PREFIXES: tuple[str, ...] = (
    "Size_",
    "Shape_",
    "Intensity_",
    "Texture",
    "SymZones_",
    "GridSpatial_",
    "RadialExpansion_",
)

#: Output columns of :meth:`ErrorCutoffFinder.analyze`, in order.
RESULT_COLUMNS: tuple[str, ...] = (
    "measurement",
    "auc",
    "direction",
    "cutoff",
    "recall",
    "specificity",
    "good_flagged",
    "f_stat",
    "p_value",
    "p_bh",
    "good_n",
    "error_n",
)

#: Column dtypes for the output frame, so the empty and populated shapes agree
#: (downstream ``concat``/parquet must not infer ``object`` for an empty result).
_RESULT_DTYPES: dict[str, str] = {
    "measurement": "object",
    "auc": "float64",
    "direction": "object",
    "cutoff": "float64",
    "recall": "float64",
    "specificity": "float64",
    "good_flagged": "int64",
    "f_stat": "float64",
    "p_value": "float64",
    "p_bh": "float64",
    "good_n": "int64",
    "error_n": "int64",
}


[docs] class ErrorCutoffFinder(BaseModel): """Rank measurements by good-vs-error separability with suggested cutoffs. Note: ``p_value``/``p_bh`` are reported for reference only — ranking and cutoffs are distribution-free (AUC / ROC + Youden's J), because the ANOVA normality / equal-variance assumptions rarely hold on error subpopulations. ``good_n``/``error_n`` are the per-measurement non-NaN counts and may individually fall below ``min_good_n``/``min_error_n`` (which is a frame-level guard, not a per-measurement one). Args: min_error_n: Minimum error-class sample size (>= 2); below it, :meth:`analyze` returns an empty frame (the statistics are unstable). min_good_n: Minimum good-class sample size (>= 2); same behaviour. measurement_prefixes: Column-name prefixes treated as numeric measurements. Defaults to :data:`MEASUREMENT_PREFIXES`. """ model_config = ConfigDict(extra="forbid") min_error_n: int = 8 min_good_n: int = 8 measurement_prefixes: tuple[str, ...] = MEASUREMENT_PREFIXES @field_validator("min_error_n", "min_good_n") @classmethod def _min_n_at_least_two(cls, value: int) -> int: """Reject min sample sizes below 2 (separability is undefined below 2).""" if value < 2: raise ValueError("min sample sizes must be >= 2.") return value
[docs] def measurement_columns(self, df: pd.DataFrame) -> list[str]: """Return the numeric measurement columns of ``df`` in column order. A column qualifies iff its name starts with one of :attr:`measurement_prefixes` and its dtype is numeric. Args: df: A measurement frame (good or error). Returns: The qualifying measurement column names. """ return [ col for col in df.columns if col.startswith(self.measurement_prefixes) and pd.api.types.is_numeric_dtype(df[col]) ]
[docs] def enough_data(self, good: pd.DataFrame, error: pd.DataFrame) -> bool: """Return whether both classes meet their minimum sample sizes.""" return len(good) >= self.min_good_n and len(error) >= self.min_error_n
@staticmethod def _empty_result() -> pd.DataFrame: """Return a typed 0-row result (dtypes match the populated frame).""" return pd.DataFrame( {c: pd.Series(dtype=_RESULT_DTYPES[c]) for c in RESULT_COLUMNS} ) @staticmethod def _clean(series: pd.Series) -> "npt.NDArray[np.float64]": """Return the NaN-free values of ``series`` as a float array. Fast-paths already-numeric columns (the common case post-measurement), coercing only non-numeric ones. """ if not pd.api.types.is_numeric_dtype(series): series = pd.to_numeric(series, errors="coerce") return series.dropna().to_numpy(dtype=np.float64)
[docs] def analyze(self, good: pd.DataFrame, error: pd.DataFrame) -> pd.DataFrame: """Screen every measurement for good-vs-error separation. Args: good: The good-baseline frame (caller chooses all-unlabeled vs verified-only — the engine is agnostic). error: The frame of objects labelled with the target error category. Returns: A frame with one row per measurement, columns :data:`RESULT_COLUMNS`, sorted by ``auc`` (separability) descending. Empty (0 rows, same columns) when :meth:`enough_data` is ``False`` or no measurement column has enough non-NaN values in both classes. Examples: >>> import numpy as np, pandas as pd >>> rng = np.random.default_rng(0) >>> good = pd.DataFrame({"Size_Area": rng.normal(0, 1, 40)}) >>> error = pd.DataFrame({"Size_Area": rng.normal(5, 1, 12)}) >>> res = ErrorCutoffFinder().analyze(good, error) >>> res.iloc[0]["measurement"], bool(res.iloc[0]["auc"] > 0.9) ('Size_Area', True) """ if not self.enough_data(good, error): return self._empty_result() rows: list[dict[str, object]] = [] for col in self.measurement_columns(good): if col not in error.columns: continue g = self._clean(good[col]) e = self._clean(error[col]) scored = self._score_measurement(g, e) if scored is None: continue scored["measurement"] = col rows.append(scored) if not rows: return self._empty_result() res = pd.DataFrame(rows) # Benjamini-Hochberg across the screened measurements. res["p_bh"] = false_discovery_control(res["p_value"].to_numpy(), method="bh") # Stable, deterministic order: AUC desc, then raw p asc, then name — so # AUC ties (common when several measurements separate cleanly) are # reproducible across runs and pandas versions. res = res.sort_values( ["auc", "p_value", "measurement"], ascending=[False, True, True], kind="stable", ignore_index=True, ) return res[list(RESULT_COLUMNS)]
@staticmethod def _score_measurement( g: npt.NDArray[np.float64], e: npt.NDArray[np.float64] ) -> dict[str, object] | None: """Score one measurement: ANOVA F/p, AUC + direction, gap-midpoint cutoff. Args: g: Good-class values (NaN-free 1-D array). e: Error-class values (NaN-free 1-D array). Returns: A dict of the per-measurement statistics, or ``None`` when either class has < 2 values, the combined values are constant, or the ANOVA F/p is non-finite (degenerate) — so no non-finite p ever reaches the BH step. """ if len(g) < 2 or len(e) < 2: return None scores = np.concatenate([g, e]) if np.ptp(scores) == 0: # all identical -> nothing to separate return None y = np.concatenate([np.zeros(len(g)), np.ones(len(e))]) # 1 = error with warnings.catch_warnings(): warnings.simplefilter("ignore") # silence scipy ConstantInputWarning f_stat, p_value = f_oneway(g, e) # Degenerate (e.g. each class internally constant -> F=inf/p=nan): skip # BEFORE the value can poison BH-FDR (false_discovery_control rejects NaN). if not np.isfinite(f_stat) or not np.isfinite(p_value): return None # Deferred: scikit-learn is heavy; only import when actually scoring. from sklearn.metrics import roc_auc_score, roc_curve auc_raw = roc_auc_score(y, scores) # P(error score > good score) if auc_raw >= 0.5: direction = ">" # error is the HIGH side; flag when value > cutoff separability = auc_raw fpr, tpr, thr = roc_curve(y, scores) else: direction = "<" # error is the LOW side; flag when value < cutoff separability = 1.0 - auc_raw fpr, tpr, thr = roc_curve(y, -scores) thr = -thr # map thresholds back to the measurement scale # Youden's J optimal operating point (skip roc_curve's +/-inf point). valid = np.isfinite(thr) k = int(np.argmax(tpr[valid] - fpr[valid])) thr_v = float(thr[valid][k]) tpr_k = float(tpr[valid][k]) fpr_k = float(fpr[valid][k]) # Midpoint-nudge (resolved decision): the ROC threshold is an *attained* # value, so shift the cutoff to the midpoint between it and the nearest # observed value on the good side. This puts it in the gap (mid-gap on # perfect separation) and makes >/< strictness irrelevant. The nudge # stays between two adjacent observed values, so no point crosses it — # tpr/fpr at the operating point are unchanged. if direction == ">": lower = scores[scores < thr_v] cutoff = float((thr_v + lower.max()) / 2) if lower.size else thr_v else: upper = scores[scores > thr_v] cutoff = float((thr_v + upper.min()) / 2) if upper.size else thr_v return { "auc": float(separability), "direction": direction, "cutoff": cutoff, "recall": tpr_k, # fraction of errors caught "specificity": float(1.0 - fpr_k), # fraction of good kept "good_flagged": int(round(fpr_k * len(g))), # # good wrongly flagged "f_stat": float(f_stat), "p_value": float(p_value), "good_n": int(len(g)), "error_n": int(len(e)), }