Source code for figrecipe._annotations._stat_result

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``StatResult`` — the display-side port for a completed statistical test.

figrecipe DISPLAYS statistics; it never computes them. This dataclass is the
boundary between those two concerns (ports & adapters): a producer — scitex-stats,
scipy, R, a CSV of pre-computed results — fills in the fields, and figrecipe turns
them into a doctrine-compliant, italic-symbol annotation string. figrecipe does not
import scitex-stats, and scitex-stats does not know figrecipe exists; the only
coupling is the field names below.

The doctrine (see ``_skills/figrecipe/27_six-stat-annotation-doctrine.md``) requires
all six of n / CI / method / p / effect size / test statistic on every statistical
annotation. Before this module the only way to satisfy it was to hand-type the
mathtext:

    ax.add_stat_annotation(x1=0, x2=1, text=r"$\\it{N}$=12, $\\it{n}$=340, ...")

which is both tedious and unverifiable — a typo'd exponent or a forgotten field
looks fine until review. ``StatResult`` builds that string instead, so the six
fields are structurally present or you get a warning naming exactly which ones
are not.
"""

from __future__ import annotations

__all__ = ["StatResult", "MISSING_STAT_FIELD_WARNING"]

import warnings
from dataclasses import dataclass, field
from typing import Any, Mapping, Optional, Sequence, Tuple, Union

MISSING_STAT_FIELD_WARNING = (
    "Statistical annotation is missing required field(s): {missing}. The six-stat "
    "doctrine requires n, CI, method, p-value, effect size, and test statistic "
    "(see figrecipe skill 27_six-stat-annotation-doctrine). Supply them, or move "
    "them into the caption and pass require_complete=False to silence this."
)

# Statistical symbols that are NOT simply an italicised latin letter.
_SYMBOL_MATHTEXT = {
    "chi2": r"\chi^2",
    "chisq": r"\chi^2",
    "eta2": r"\eta^2",
    "eta_squared": r"\eta^2",
    "partial_eta2": r"\eta_p^2",
    "omega2": r"\omega^2",
    "rho": r"\rho",
    "tau": r"\tau",
}

_DofT = Union[float, int, Sequence[float], None]


def _mathtext(symbol: str) -> str:
    """Render a statistical symbol as italic mathtext (doctrine rule: italics)."""
    special = _SYMBOL_MATHTEXT.get(symbol.lower())
    body = special if special is not None else rf"\it{{{symbol}}}"
    return f"${body}$"


def _format_one_dof(dof: float) -> str:
    """Integer dof render bare; a fractional dof (Welch) keeps one decimal.

    Welch's correction produces a non-integer dof (e.g. 694.053). Printing its
    full float precision is noise -- no reader needs the third decimal of a
    degrees-of-freedom -- so collapse it to a single decimal, and to a bare
    integer when it is one.
    """
    if float(dof).is_integer():
        return f"{int(dof)}"
    return f"{dof:.1f}"


def _format_dof(dof: _DofT) -> str:
    """Render degrees of freedom as ``(338)`` or ``(2, 57)``; empty when absent."""
    if dof is None:
        return ""
    if isinstance(dof, (int, float)):
        return f"({_format_one_dof(dof)})"
    return "(" + ", ".join(_format_one_dof(d) for d in dof) + ")"


def _format_p(p_value: float, precision: int = 3) -> str:
    """Render a p-value, collapsing to ``< 0.001`` below display precision."""
    floor = 10.0**-precision
    symbol = _mathtext("p")
    if p_value < floor:
        return f"{symbol} < {floor:g}"
    return f"{symbol} = {p_value:.{precision}f}"


[docs] @dataclass(frozen=True) class StatResult: """A completed statistical test, ready to be rendered onto a figure. Only ``p_value`` is structurally required — a result with no p-value is not a test result at all. The other five doctrine fields are optional at the type level but their absence is reported by :meth:`missing_fields` and warned about at render time, because "incomplete but loud" is more useful than "complete or TypeError": a producer that genuinely cannot supply a CI (e.g. a permutation test) should still be able to render, with the gap visible. Parameters ---------- p_value : float The p-value of the test. method : str, optional Human-readable test name, e.g. ``"Welch's t-test"``. Rendered upright. statistic : float, optional The test statistic's value. statistic_name : str Symbol for the test statistic (``"t"``, ``"F"``, ``"U"``, ``"chi2"``, ...). dof : float or sequence of float, optional Degrees of freedom; a scalar renders ``t(338)``, a pair renders ``F(2, 57)``. effect_size : float, optional The effect size's value. effect_name : str Symbol for the effect size (``"d"``, ``"r"``, ``"eta2"``, ...). ci : tuple of (float, float), optional Confidence interval around the estimate. ci_level : int Confidence level as a percentage. Default 95. n : int, optional Number of observations (windows / trials / samples) — lowercase *n*. n_subjects : int, optional Number of subjects — capital *N*. Keep distinct from ``n``: collapsing the two hides the unit of statistical independence from the reader. n_unit, n_subjects_unit : str Optional units, e.g. ``"windows"`` / ``"patients"``. Examples -------- >>> result = StatResult( ... p_value=0.0004, method="Pearson correlation", ... statistic=5.1, statistic_name="t", dof=338, ... effect_size=0.42, effect_name="r", ci=(0.21, 0.60), ... n=340, n_subjects=12, ... ) >>> result.stars '***' >>> "Pearson correlation" in result.annotation() True """ p_value: float method: Optional[str] = None statistic: Optional[float] = None statistic_name: str = "t" dof: _DofT = None effect_size: Optional[float] = None effect_name: str = "d" ci: Optional[Tuple[float, float]] = None ci_level: int = 95 n: Optional[int] = None n_subjects: Optional[int] = None n_unit: str = "" n_subjects_unit: str = "" extras: Mapping[str, Any] = field(default_factory=dict) @property def stars(self) -> str: """Significance stars for this p-value.""" # Single source of truth: the same threshold table the bracket helper uses. from .._wrappers._stat_annotation import p_to_stars return p_to_stars(self.p_value)
[docs] def missing_fields(self) -> list: """Names of the doctrine's six fields that this result cannot render.""" missing = [] if self.n is None and self.n_subjects is None: missing.append("n") if self.ci is None: missing.append("CI") if not self.method: missing.append("method") if self.effect_size is None: missing.append("effect size") if self.statistic is None: missing.append("test statistic") return missing
def _sample_size_part(self) -> str: parts = [] for symbol, value, unit in ( ("N", self.n_subjects, self.n_subjects_unit), ("n", self.n, self.n_unit), ): if value is None: continue suffix = f" {unit}" if unit else "" parts.append(f"{_mathtext(symbol)} = {value:,}{suffix}") return ", ".join(parts)
[docs] def annotation( self, sep: str = ", ", precision: int = 3, stars: bool = False, require_complete: bool = True, ) -> str: """Render the six-stat annotation string, with italic statistical symbols. Parameters ---------- sep : str Separator between fields. Pass ``",\\n"`` to wrap onto two lines when a panel is too narrow for one. precision : int Decimal places for the p-value before it collapses to ``< 0.001``. stars : bool Prepend the significance stars on their own line. The stars are a convenience for the reader, never a substitute for the p-value — the exact number is always rendered alongside them. require_complete : bool Warn (never raise) when any of the six doctrine fields is absent. Set False when the missing fields genuinely live in the caption instead. Returns ------- str e.g. ``"$N$ = 12, $n$ = 340, Pearson correlation, $t$(338) = 5.10, $p$ < 0.001, $r$ = 0.42, 95% CI [0.21, 0.60]"`` """ if require_complete: missing = self.missing_fields() if missing: warnings.warn( MISSING_STAT_FIELD_WARNING.format(missing=", ".join(missing)), UserWarning, stacklevel=2, ) parts = [] sample_sizes = self._sample_size_part() if sample_sizes: parts.append(sample_sizes) if self.method: parts.append(self.method) if self.statistic is not None: symbol = _mathtext(self.statistic_name) parts.append(f"{symbol}{_format_dof(self.dof)} = {self.statistic:.2f}") parts.append(_format_p(self.p_value, precision=precision)) if self.effect_size is not None: parts.append(f"{_mathtext(self.effect_name)} = {self.effect_size:.2f}") if self.ci is not None: low, high = self.ci parts.append(f"{self.ci_level}% CI [{low:.2f}, {high:.2f}]") body = sep.join(parts) return f"{self.stars}\n{body}" if stars else body
[docs] @classmethod def from_mapping(cls, mapping: Mapping[str, Any]) -> "StatResult": """Build a ``StatResult`` from a producer's result dict — the adapter seam. This is how scitex-stats (or scipy, or a stored results table) hands a test result to figrecipe without either side importing the other. Common key spellings are accepted. Keys that match nothing are preserved verbatim in ``extras`` rather than dropped, so a producer whose schema drifts leaves a trace instead of vanishing into a silently-incomplete annotation — and the render-time completeness warning still names whatever field went missing. Raises ------ KeyError If no p-value key is present — that is not a test result. """ aliases = { "p_value": ("p_value", "pval", "p", "pvalue"), "method": ("method", "test", "test_name", "testname"), "statistic": ("statistic", "stat", "test_statistic"), "statistic_name": ("statistic_name", "stat_name", "statistic_symbol"), "dof": ("dof", "df", "degrees_of_freedom"), "effect_size": ("effect_size", "effect", "effsize"), "effect_name": ("effect_name", "effect_type", "effect_symbol"), "ci": ("ci", "conf_int", "confidence_interval"), "ci_level": ("ci_level", "confidence_level"), "n": ("n", "nobs", "n_samples", "sample_size"), "n_subjects": ("n_subjects", "N", "n_subj"), "n_unit": ("n_unit",), "n_subjects_unit": ("n_subjects_unit",), } consumed = set() kwargs = {} for target, keys in aliases.items(): for key in keys: if key in mapping: kwargs[target] = mapping[key] consumed.add(key) break if "p_value" not in kwargs: raise KeyError( "StatResult.from_mapping requires a p-value; none of " f"{aliases['p_value']} found in keys {sorted(mapping)}." ) # A CI split across two scalar keys is the other common producer shape. if "ci" not in kwargs and {"ci_lower", "ci_upper"} <= set(mapping): kwargs["ci"] = (mapping["ci_lower"], mapping["ci_upper"]) consumed |= {"ci_lower", "ci_upper"} unmapped = set(mapping) - consumed if unmapped: kwargs["extras"] = {key: mapping[key] for key in sorted(unmapped)} if kwargs.get("ci") is not None: kwargs["ci"] = tuple(kwargs["ci"]) return cls(**kwargs)
# EOF