Source code for mmm_framework.diagnostics.convergence
"""Centralized MCMC convergence verdict + warning.
Single source of truth for *"did the sampler work?"* so the core
(:class:`mmm_framework.model.BayesianMMM`) and the extended models
(:mod:`mmm_framework.mmm_extensions`) agree on the same thresholds, and so a
non-converged fit can never be returned without a signal.
The thresholds mirror the opt-in :class:`mmm_framework.validation.ModelValidator`
and the fit-health snapshot (R-hat per Vehtari et al. 2021; the ~100-per-chain
bulk-ESS rule of thumb at 4 chains):
* ``rhat_max <= 1.01``
* ``ess_bulk_min >= 400``
* ``divergences == 0``
For *approximate* fits (MAP / Laplace / ADVI / Pathfinder) R-hat and ESS are
undefined, so the verdict is ``None`` (not assessable) — callers must not treat
``None`` as "converged". SMC fits are EXACT (``approximate`` False): R-hat/ESS
are computed across the independent SMC runs and the verdict applies —
disagreeing runs (high R-hat) are SMC's multimodality signal; divergences do
not exist for SMC and stay ``None`` (never flagged).
"""
from __future__ import annotations
import math
import warnings
from typing import Any
from . import provenance as _provenance
# Conventional sampler-health thresholds. Kept in step with
# diagnostics.snapshot.RHAT_OK / ESS_BULK_OK and validation.validator.
RHAT_OK = 1.01
ESS_BULK_OK = 400.0
[docs]
class ConvergenceWarning(UserWarning):
"""Warning category emitted when an MCMC fit fails standard convergence checks.
Filter or escalate it explicitly, e.g.::
import warnings
from mmm_framework.diagnostics.convergence import ConvergenceWarning
warnings.simplefilter("error", ConvergenceWarning) # treat as failure
"""
def _finite(x: Any) -> float | None:
"""Finite float or ``None`` (keeps verdicts JSON-safe; no NaN/inf)."""
try:
v = float(x)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
[docs]
def compute_convergence(trace: Any) -> dict[str, Any]:
"""Compute ``divergences`` / ``rhat_max`` / ``ess_bulk_min`` from an ArviZ trace.
Best-effort: any piece that cannot be computed comes back as ``None`` rather
than raising, so a diagnostics computation never fails a fit.
"""
import arviz as az
from ..utils import arviz_compat
out: dict[str, Any] = {
"divergences": None,
"rhat_max": None,
"ess_bulk_min": None,
}
try:
out["divergences"] = int(trace.sample_stats.diverging.sum().values)
except Exception:
pass
try:
out["rhat_max"] = _finite(arviz_compat.dataset_extremum(az.rhat(trace), "max"))
except Exception:
pass
try:
out["ess_bulk_min"] = _finite(
arviz_compat.dataset_extremum(az.ess(trace, method="bulk"), "min")
)
except Exception:
pass
return out
[docs]
def convergence_flags(diagnostics: dict[str, Any]) -> list[str]:
"""Which checks FAILED: subset of ``{"divergences", "rhat", "ess"}``.
Empty for a **frequentist** fit — not because it passed, but because none of
these checks apply to it. Read alongside :func:`is_converged`, which returns
``None`` for the same fit; an empty flag list on its own must never be taken
as a pass (that conflation is exactly what made a bootstrap trace read as
converged).
"""
if _provenance.is_frequentist(diagnostics):
return []
flags: list[str] = []
div = diagnostics.get("divergences")
rhat = _finite(diagnostics.get("rhat_max"))
ess = _finite(diagnostics.get("ess_bulk_min"))
if div:
flags.append("divergences")
if rhat is not None and rhat > RHAT_OK:
flags.append("rhat")
if ess is not None and ess < ESS_BULK_OK:
flags.append("ess")
return flags
[docs]
def is_converged(diagnostics: dict[str, Any]) -> bool | None:
"""Convergence verdict from a diagnostics dict.
Returns ``True``/``False`` for NUTS fits, and ``None`` when convergence is
not assessable — a **frequentist** fit (no chain exists), an approximate fit
(``diagnostics["approximate"]`` truthy), or one with no usable
R-hat/ESS/divergence signal. ``None`` is NOT "converged"; callers should
surface it as "N/A".
The frequentist branch is load-bearing rather than defensive. A
``(chain=1, draw=B)`` bootstrap trace passes every check as ``True``:
``az.rhat`` on one chain is NaN → filtered to ``None``, a ``None`` metric
does not raise a flag, and ``az.ess`` returns ≈B because bootstrap
replicates are iid. So without this the estimator is silently green
everywhere the verdict is consumed — measured and recorded in
``technical-docs/frequentist-estimation.md`` §8.
"""
if _provenance.is_frequentist(diagnostics):
return None
if diagnostics.get("approximate"):
return None
rhat = _finite(diagnostics.get("rhat_max"))
ess = _finite(diagnostics.get("ess_bulk_min"))
div = diagnostics.get("divergences")
if rhat is None and ess is None and div is None:
return None
return not convergence_flags(diagnostics)
def convergence_warning_message(
diagnostics: dict[str, Any], label: str = "model"
) -> str | None:
"""Human-readable non-convergence message, or ``None`` if it converged / N/A."""
flags = convergence_flags(diagnostics)
if not flags:
return None
parts: list[str] = []
if "divergences" in flags:
parts.append(
f"{int(diagnostics.get('divergences') or 0)} divergent transition(s)"
)
if "rhat" in flags:
parts.append(
f"max R-hat={_finite(diagnostics.get('rhat_max')):.3f} (> {RHAT_OK})"
)
if "ess" in flags:
parts.append(
f"min bulk-ESS={_finite(diagnostics.get('ess_bulk_min')):.0f} "
f"(< {ESS_BULK_OK:.0f})"
)
return (
f"{label} fit has NOT converged: "
+ "; ".join(parts)
+ ". The posterior is unreliable — do not trust the intervals/ROI. "
"Increase tune/draws/chains or reparameterize and re-fit before acting."
)
[docs]
def warn_if_not_converged(
diagnostics: dict[str, Any], label: str = "model", *, stacklevel: int = 3
) -> bool:
"""Emit a :class:`ConvergenceWarning` if the fit failed convergence checks.
Returns ``True`` if a warning was emitted.
"""
msg = convergence_warning_message(diagnostics, label)
if msg is None:
return False
warnings.warn(msg, ConvergenceWarning, stacklevel=stacklevel)
return True
def annotate(diagnostics: dict[str, Any]) -> dict[str, Any]:
"""Return ``diagnostics`` with ``flags`` and ``converged`` filled in.
Mutates and returns the same dict so it round-trips through serialization.
For a frequentist fit the sampler metrics are also **nulled**: ``az.ess``
happily reports ≈``n_boot`` for iid bootstrap replicates and ``az.rhat``
reports NaN, and a downstream table that formats whatever it finds would
render both as if they meant something.
"""
if _provenance.is_frequentist(diagnostics):
for key in ("rhat_max", "ess_bulk_min", "ess_tail_min", "divergences"):
diagnostics[key] = None
diagnostics["flags"] = convergence_flags(diagnostics)
diagnostics["converged"] = is_converged(diagnostics)
return diagnostics