"""What a finished regression found, in prose, for the console.
The output is a paragraph rather than a table: the coefficient table is
already on screen and in a CSV. The paragraph states how many hypotheses were
tested, how many passed and under which rule, which genes they were, whether
the assay worked, and -- because the calibration on real screens is routinely
off in one direction or the other -- whether the test was conservative or
anti-conservative.
The numbers come from the same helpers the figures in
:mod:`spacr.figures.panels` use. A summary that recomputed them separately
would be a second implementation that could disagree with the figures beside
it, with no way for the reader to tell which one was wrong.
"""
from __future__ import annotations
from typing import Optional
import numpy as np
from .panels import (control_threshold, effect_column, label_series, p_column,
q_column, tested)
def _fmt(value, digits=3) -> str:
"""Format a numeric value compactly and preserve non-numeric text.
:param value: Value to format.
:param digits: Significant digits used by general numeric formatting.
:returns: Compact numeric text, or ``str(value)`` when conversion fails.
"""
try:
return f"{float(value):.{digits}g}"
except (TypeError, ValueError):
return str(value)
[docs]
def summarise(frame, *, alpha: float = 0.05,
effect_threshold: Optional[float] = "auto",
max_named: int = 8) -> str:
"""A paragraph describing what this coefficient table found.
:param frame: the coefficient table a run returned.
:param alpha: the level the calls were made at.
:param effect_threshold: ``'auto'`` to use the control-based cut, a
number to force one, or ``None`` for significance only.
:param max_named: how many hits to name before saying "and N more".
:returns: the summary, ready to print. Empty string when there is
nothing to summarise -- a caller should say so itself rather than
print a paragraph about an absence.
"""
if frame is None or not len(frame):
return ""
effect, p = effect_column(frame), p_column(frame)
if effect is None:
return ""
keep = tested(frame)
sub = frame.loc[keep]
dropped = int((~keep).sum())
values = np.asarray(sub[effect], dtype="float64")
q = q_column(frame)
if q is not None:
called = np.asarray(sub[q], dtype="float64") <= alpha
rule = f"BH q ≤ {alpha:g}"
elif p is not None:
called = np.asarray(sub[p], dtype="float64") <= alpha
rule = f"uncorrected p ≤ {alpha:g} (NO correction was applied)"
else:
called = np.zeros(len(sub), bool)
rule = "no p-value: this backend ranks by selection frequency"
called = np.nan_to_num(called, nan=False).astype(bool)
cut_rule, cut = "", None
if effect_threshold == "auto":
cut_rule, cut = control_threshold(frame)
elif effect_threshold:
cut_rule, cut = "the value you set", float(effect_threshold)
if cut:
big = np.abs(values) >= cut
called_and_big = called & big
else:
big = called_and_big = called
lines = [
f"{int(keep.sum())} coefficients were tested"
+ (f"; {dropped} nuisance terms (intercept, row and column) are not "
f"hypotheses and were excluded." if dropped else "."),
]
if cut:
lines.append(
f"{int(called.sum())} pass {rule}. Of those, "
f"{int(called_and_big.sum())} also clear the effect-size cut of "
f"{_fmt(cut)} ({cut_rule}) — the rest are detectable but smaller "
f"than the spread of the guides that target nothing.")
else:
lines.append(f"{int(called.sum())} pass {rule}.")
if called_and_big.any():
names = label_series(sub)
order = np.argsort(-np.abs(np.where(called_and_big, values, 0.0)))
picked = [i for i in order if called_and_big[i]][:max_named]
listed = ", ".join(
f"{names.iloc[i]} ({values[i]:+.3g})" for i in picked)
more = int(called_and_big.sum()) - len(picked)
lines.append(f"Strongest: {listed}"
+ (f", and {more} more." if more > 0 else "."))
condition = None
for name in ("condition", "control", "class"):
if name in frame.columns:
condition = name
break
if condition is not None:
names = sub[condition].astype(str).str.lower()
positive = values[names.isin(("pc", "positive")).to_numpy()]
negative = values[names.isin(("nc", "control",
"negative")).to_numpy()]
positive = positive[np.isfinite(positive)]
negative = negative[np.isfinite(negative)]
if positive.size and negative.size:
gap = float(np.median(positive) - np.median(negative))
spread = float(np.median(np.abs(negative - np.median(negative)))
* 1.4826) or float("nan")
lines.append(
f"Assay window: the {positive.size} positive control(s) sit "
f"{_fmt(gap)} from the {negative.size} negative(s), "
f"{_fmt(abs(gap) / spread)}σ of the negative spread."
if np.isfinite(spread) and spread else
f"Assay window: {positive.size} positive and "
f"{negative.size} negative controls.")
elif negative.size:
lines.append(
f"{negative.size} negative controls and no positive ones, so "
f"there is nothing to check the assay window against.")
if p is not None:
raw = np.asarray(sub[p], dtype="float64")
raw = raw[np.isfinite(raw) & (raw > 0)]
if raw.size >= 20:
from scipy.stats import chi2
lam = float(np.median(chi2.isf(raw, 1)) / chi2.ppf(0.5, 1))
if lam > 1.15:
verdict = ("inflated — more small p-values than the null "
"predicts, which is either real signal or a "
"mis-specified model, so treat the count as an "
"upper bound")
elif lam < 0.85:
verdict = ("deflated — the test is CONSERVATIVE here, so the "
"hits above are more likely an undercount than an "
"overcount")
else:
verdict = "close to calibrated"
lines.append(f"Calibration: λ = {lam:.2f}, {verdict}.")
return " ".join(lines)
__all__ = ["summarise"]