Source code for mixle.ppl.summarize
"""Posterior summarization for the mixle PPL: highest-density intervals and an ArviZ-style table.
After an MCMC / ensemble fit you want a compact, readable report of each parameter's posterior. The
equal-tailed credible interval in :meth:`RandomVariable.summary` is fine for symmetric posteriors;
:func:`hdi` gives the *highest-density* interval (the narrowest interval holding the mass, the right
choice for skewed or bounded posteriors), and :func:`posterior_summary` assembles the mean / sd / HDI
together with the convergence diagnostics (effective sample size, R-hat) into one per-parameter dict.
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
import numpy as np
from mixle.ppl.core import RandomVariable
[docs]
def hdi(samples: Sequence[float], prob: float = 0.94) -> tuple[float, float]:
"""Highest-density interval: the narrowest interval containing ``prob`` of the posterior mass.
For a unimodal posterior this is the shortest ``(low, high)`` such that ``P(low <= x <= high) =
prob``; unlike an equal-tailed interval it tracks an asymmetric or bounded posterior correctly.
"""
if not 0.0 < prob < 1.0:
raise ValueError("prob must be in (0, 1).")
x = np.sort(np.asarray(samples, dtype=float).ravel())
n = x.size
if n == 0:
raise ValueError("samples is empty.")
k = int(np.floor(prob * n))
if k >= n:
return float(x[0]), float(x[-1])
k = max(k, 1)
widths = x[k:] - x[: n - k]
i = int(np.argmin(widths))
return float(x[i]), float(x[i + k])
[docs]
def posterior_summary(fitted: RandomVariable, *, hdi_prob: float = 0.94) -> dict[str, dict[str, Any]]:
"""Per-parameter posterior summary table for a fitted PPL model (best after ``how='mcmc'``).
Returns ``{param_name: {'mean', 'sd', 'hdi_low', 'hdi_high', 'ess', 'r_hat'}}``. ``mean``/``sd`` come
from the fit's own summary; the HDI is computed from the posterior draws (when the fit exposes them);
``ess`` (effective sample size) and ``r_hat`` (Gelman-Rubin, multi-chain) come from the sampler's
diagnostics when present. A point fit (em/map) yields just ``mean``/``sd``.
"""
summ = fitted.summary()
result = getattr(fitted, "_result", None)
rhat = getattr(result, "rhat", None) if result is not None else None
ess = getattr(result, "ess", None) if result is not None else None
out: dict[str, dict[str, Any]] = {}
for name, stat in summ.items():
if name.startswith("_") or not isinstance(stat, dict):
continue
row: dict[str, Any] = {"mean": stat.get("mean"), "sd": stat.get("std", stat.get("sd"))}
draws = None
try:
draws = np.asarray(fitted.posterior(name), dtype=float).ravel()
except Exception:
draws = None
if draws is not None and draws.size > 1:
lo, hi = hdi(draws, hdi_prob)
row["hdi_low"] = lo
row["hdi_high"] = hi
if isinstance(rhat, dict) and name in rhat:
row["r_hat"] = float(rhat[name])
if ess is not None and isinstance(ess, (int, float)):
row["ess"] = float(ess)
out[name] = row
return out
__all__ = ["hdi", "posterior_summary"]