Diagnostics¶
Convergence and calibration diagnostics: Simulation-Based Calibration, recovery-coverage checks, and prior-to-posterior learning.
Model diagnostics for the MMM framework.
Currently exposes learning diagnostics – how much the data updated each parameter relative to its prior (prior-to-posterior contraction, overlap, and location shift), used to flag posteriors that are over-informed by the prior rather than the data.
- exception mmm_framework.diagnostics.ConvergenceWarning[source]
Bases:
UserWarningWarning 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
- class mmm_framework.diagnostics.RecoveryCoverageResult(targets, n_sims_requested, n_sims_effective, n_failed_fits, truth_source, sampler, L, seed, levels, elapsed_s=0.0, caveats=<factory>)[source]
Bases:
objectFull recovery-coverage run: per-target stats + run metadata.
-
targets:
list[RecoveryTargetStat]
-
n_sims_requested:
int
-
n_sims_effective:
int
-
n_failed_fits:
int
-
truth_source:
str
-
sampler:
str
-
L:
int
-
seed:
int
-
elapsed_s:
float= 0.0
- property headline_level: float
- property all_nominal: bool
True when no target’s headline-level interval demonstrably under-covers.
- worst()[source]
- Return type:
- __init__(targets, n_sims_requested, n_sims_effective, n_failed_fits, truth_source, sampler, L, seed, levels, elapsed_s=0.0, caveats=<factory>)
-
targets:
- class mmm_framework.diagnostics.SBCResult(params, n_sims_requested, n_sims_effective, L, n_bins, sampler, seed, alpha, elapsed_s=0.0, n_failed_fits=0, caveats=<factory>)[source]
Bases:
objectFull SBC run: per-parameter stats + run metadata.
-
params:
list[SBCParamStat]
-
n_sims_requested:
int
-
n_sims_effective:
int
-
L:
int
-
n_bins:
int
-
sampler:
str
-
seed:
int
-
alpha:
float
-
elapsed_s:
float= 0.0
-
n_failed_fits:
int= 0
- property all_calibrated: bool
- worst()[source]
- Return type:
- __init__(params, n_sims_requested, n_sims_effective, L, n_bins, sampler, seed, alpha, elapsed_s=0.0, n_failed_fits=0, caveats=<factory>)
-
params:
- mmm_framework.diagnostics.annotate_convergence(diagnostics)
Return
diagnosticswithflagsandconvergedfilled in.Mutates and returns the same dict so it round-trips through serialization.
For a frequentist fit the sampler metrics are also nulled:
az.esshappily reports ≈``n_boot`` for iid bootstrap replicates andaz.rhatreports NaN, and a downstream table that formats whatever it finds would render both as if they meant something.
- mmm_framework.diagnostics.compute_convergence(trace)[source]
Compute
divergences/rhat_max/ess_bulk_minfrom an ArviZ trace.Best-effort: any piece that cannot be computed comes back as
Nonerather than raising, so a diagnostics computation never fails a fit.
- mmm_framework.diagnostics.compute_fit_diagnostics(mmm, results=None, *, max_parameters=40)[source]
JSON-safe model-health snapshot (schema v1) for a fitted model.
resultsis theMMMResultswhosediagnosticsdict already carries divergences/R-hat/ESS; pass None to recompute from the trace. Each layer is independently best-effort, so a learning failure still yields the convergence block (and vice versa).
- mmm_framework.diagnostics.convergence_flags(diagnostics)[source]
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
is_converged(), which returnsNonefor 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).
- mmm_framework.diagnostics.coverage_from_ranks(int_ranks, L, levels=(0.5, 0.8, 0.9, 0.95))[source]
Empirical central-interval coverage read directly off SBC ranks.
The truth lies inside the central
levelposterior interval exactly when its normalized ranku = (r + 0.5)/(L + 1)falls in[(1−level)/2, 1−(1−level)/2]— so an SBC run already contains every coverage number; this just states them in user language.- Return type:
- mmm_framework.diagnostics.failure_mode_guide()[source]
Markdown table of the ways nominal coverage fails and what to do.
Kept next to the tool so every surface (chat, Validation tab, docs) tells one story. The long-form version is
technical-docs/coverage-diagnostics.md.- Return type:
- mmm_framework.diagnostics.is_converged(diagnostics)[source]
Convergence verdict from a diagnostics dict.
Returns
True/Falsefor NUTS fits, andNonewhen 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.Noneis 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 asTrue:az.rhaton one chain is NaN → filtered toNone, aNonemetric does not raise a flag, andaz.essreturns ≈B because bootstrap replicates are iid. So without this the estimator is silently green everywhere the verdict is consumed — measured and recorded intechnical-docs/frequentist-estimation.md§8.
- mmm_framework.diagnostics.parameter_learning(prior, posterior, var_names=None, *, bins=60, c_strong=0.5, c_weak=0.1, ovl_dominated=0.85, z_relocated=1.0)[source]
How much did the data teach us about each parameter, beyond the prior?
- Return type:
Parameters¶
- prior, posterior:
Sample containers. Each may be an arviz
InferenceData(the"prior"/"posterior"group is read), an xarrayDataset, or adictmapping a parameter name to a sample array (sample axis first). Both must describe the same parameters; vector/matrix parameters are compared element-wise.- var_names:
Optional restriction. Names match either a flattened element (
"beta[0]") or a base parameter ("beta"keeps every"beta[...]"element).Nonecompares every parameter present in both containers.- bins:
Histogram resolution for the overlap coefficient.
- c_strong, c_weak, ovl_dominated, z_relocated:
Heuristic verdict thresholds (conventions, not law). Checked in order:
|shift_z| >= z_relocatedandcontraction < c_weak->"relocated"(the posterior moved at least one prior-sd without narrowing – evidence dominated the location); thencontraction < c_weakandoverlap > ovl_dominated->"prior-dominated";contraction >= c_strong->"strong";contraction >= c_weak->"moderate"; otherwise"weak".
Returns¶
- pandas.DataFrame
One row per parameter, sorted by
contractionascending (so the least-learned, most prior-dominated parameters sort to the top). Columns:parameter,prior_mean,prior_sd,post_mean,post_sd,contraction,contraction_robust,overlap,shift_z,post_ess_bulk,verdict.
Notes¶
contractionis intentionally not clipped: a negative value (posterior wider than prior) is a genuine warning sign, but it is ambiguous on its own – it can mean prior-data conflict, a likelihood that is genuinely flat in the newly-favored region, or sd inflation from heavy tails left by chains that mixed slowly into that region. Disambiguate with the companions: a large|shift_z|says the evidence dominated the location (verdict"relocated", not a failure to learn);contraction_robust>= 0 alongsidecontraction< 0 says the widening is tail-driven; a lowpost_ess_bulksays the width estimate itself may be a sampling artifact – re-run with more tune/draws before interpreting.Informativeness, not importance. High
contraction/ lowoverlapmeans the data was informative about the parameter – which includes confidently pinning it near zero. It is not a statement about effect size or sign. Always read the posteriorpost_mean/ interval alongside the diagnostic: contraction tells you the data spoke; the posterior location tells you what it said. (A sign-constrained prior such aspsi = -HalfNormalmakes “the posterior is below zero” near-automatic; a high contraction to a value of ~0 means the data confidently found a negligible effect, not a confirmed one.)
- mmm_framework.diagnostics.plot_parameter_learning(learning, ax=None, *, top=None, metric='contraction', threshold=None)[source]
Horizontal bar chart of per-parameter learning, colored by
verdict.metricis the column to plot ("contraction"by default;"overlap"also works).topkeeps only thetopleast-learned parameters (the head of the sorted frame).thresholddraws a reference line.
- mmm_framework.diagnostics.plot_prior_posterior_overlay(prior, posterior, parameter, ax=None, *, bins=60, transform=None)[source]
Overlay the prior and posterior sample histograms for a single parameter.
The clearest way to see whether the data moved/narrowed a parameter relative to its prior.
transform(callable) is applied to both sample sets before plotting – e.g.lambda x: -xto view the signed cannibalization effect from itspsi_..._rawHalfNormal parameter.
- mmm_framework.diagnostics.run_mmm_sbc(model, *, n_sims=64, L=100, n_bins=20, sampler='numpyro', params=None, tune=200, chains=2, seed=0, alpha=0.05, progress=None)[source]
Run SBC for a (built, unfitted)
BayesianMMM.Draws
n_simspaired(θ*, y_sim)from the model’s prior, refits the posterior on eachy_simby swapping the observed data on the SAME model graph (pm.observe— keepsθ*and the posterior on one fixed scale), and computes per-parameter ranks.EXPENSIVE: one refit per simulation. Use a background job; defaults are tuned for a tractable national MMM (
numpyroNUTS,n_sims=64,L=100).- Return type:
- mmm_framework.diagnostics.run_recovery_coverage(model, *, truth='posterior_mean', n_sims=24, levels=(0.5, 0.8, 0.9, 0.95), sampler='numpyro', L=200, tune=200, chains=2, seed=0, params=None, include_contributions=True, progress=None, refit=None, extra_caveats=())[source]
Fixed-truth recovery coverage for a
BayesianMMM.Fixes every free parameter at θ* (
pm.do), simulatesn_simsdatasets from the likelihood at θ*, refits the ORIGINAL model on each (pm.observeon the same graph — θ* and every posterior share one fixed standardized scale), and measures how often each central interval contains the truth — for scalar free parameters and per-channel total contributions.EXPENSIVE: one refit per simulation, like SBC. Use a background job for thorough runs;
n_sims < 30only flags gross failures (the binomial CI is reported either way).Answers: “IF the world matched my fitted model exactly, would my reported intervals cover?” Under-coverage here is a mechanical problem (approximate inference, sampler, priors); it deliberately cannot see real-world misspecification — see the module docstring.
- Parameters:
refit (
Optional[Callable[[ndarray,int],Any]]) – Estimator injection.None(default) refits the PyMC model withpm.observe+ NUTS, which is the only thing this used to be able to do. Supply a callable(y_sim, seed) -> idatato grade a non-PyMC estimator — the frequentist ridge + bootstrap path (#186) is why the hook exists. The simulate-from-the-model half is unchanged, so both paths are graded against the same θ* and the same central equal-tailed intervals. The callable may return anything carrying a.posteriormapping, or a plain{name: draws}mapping.extra_caveats (
tuple[str,...]) – Statements appended verbatim to the result’s caveats. Required for the frequentist path: this function auto-attaches an uncertainty caveat foradvi/fullrank_advionly, so ridge shrinkage bias and conditional-on-selection intervals must be passed explicitly or they go unstated.
- Return type:
Note
Parameters the injected estimator holds fixed (the frequentist path fixes adstock and saturation) have degenerate draws, and their coverage is not meaningful. Pass
params=[...]to restrict the table to the quantities the estimator actually estimates.
- mmm_framework.diagnostics.run_sbc(draw_and_fit, *, n_sims, L, n_bins=20, alpha=0.05, seed=0, sampler='generic')[source]
Model-agnostic SBC loop.
draw_and_fit(rng)returns(theta_star, posterior_draws)wheretheta_starmaps param→scalar truth andposterior_drawsmaps the same param→a 1-D array of posterior draws (length ≥L; thinned toL).- Return type:
- mmm_framework.diagnostics.saturation_learning(model, results=None, *, draws=2000, random_seed=0)[source]
Did the data move the saturation parameters? (post-fit)
Thin wrapper over
mmm_framework.diagnostics.learning.parameter_learning(), restricted to the saturation block and with the prior drawn for you.- Parameters:
model (
BayesianMMM) – A fitted model.results (
Any) – Unused; accepted so the call reads like the other diagnostics.draws (
int) – Prior draws to compare the posterior against.
- Return type:
- Returns:
The
parameter_learningframe for the saturation parameters, sorted least-learned first. A"prior-dominated"verdict means the fitted value is the prior you chose.- Raises:
ValueError – If the model has not been fitted.
- mmm_framework.diagnostics.saturation_prior_report(model, *, draws=4000, random_seed=0)[source]
Where does the saturation prior put the curve’s elbow? (pre-fit)
Draws each channel’s saturation parameter from its prior and converts it to the half-saturation point as a fraction of the channel’s maximum observed spend — the scale saturation actually sees, since media is max-normalized before the transform.
- Parameters:
model (
BayesianMMM) – A constructedBayesianMMM. It need not be fitted; the graph is built if it has not been.draws (
int) – Prior draws per channel.
- Return type:
- Returns:
One row per channel with
channel,saturation,parameter,elbow_q05/elbow_median/elbow_q95(fractions of maximum observed spend),mass_beyond_supportandverdict(anchored/diffuse/unanchored).Channels whose family has no elbow (
root,none) are omitted.
Note
A prior can be perfectly anchored and the parameter still unidentified — this reports whether the prior is defensible, not whether the data will move it. Pair it with
saturation_learning()after fitting.
- mmm_framework.diagnostics.warn_if_not_converged(diagnostics, label='model', *, stacklevel=3)[source]
Emit a
ConvergenceWarningif the fit failed convergence checks.Returns
Trueif a warning was emitted.- Return type:
- mmm_framework.diagnostics.warn_if_saturation_prior_is_unanchored(model, *, draws=2000, random_seed=0)[source]
Emit a
UserWarningper channel whose elbow prior is unanchored.Returns the same frame as
saturation_prior_report()so a caller can act on it. Silent when every channel is anchored — this is meant to be safe to call unconditionally.- Return type:
Sbc¶
Simulation-Based Calibration (SBC) for the inference engine (Talts et al. 2018).
SBC machine-checks whether the posteriors this framework reports are calibrated — i.e. whether their credible intervals have nominal coverage — rather than merely asserting it. It belongs with the prior-predictive checks: it runs on data generated from the model’s own prior, so it validates the inference engine itself, independent of any real dataset.
Algorithm (for each of n_sims iterations):
draw a “true” parameter vector
θ*from the prior and simulate a datasety_simfrom the likelihood atθ*(onesample_prior_predictivecall gives both, jointly);refit the posterior on
y_sim— on the SAME model graph (soθ*and the posterior live on the identical, fixed standardized scale);record the rank
r = #{posterior draws ≤ θ*}of the true value within itsL(thinned) posterior draws.
Under a correctly-calibrated inference procedure those ranks are Uniform{0..L}. The shape of the deviation diagnoses the failure (rank counted as draws ≤ θ*, the Talts convention):
∪ / U-shape (mass at the edges) → posterior too narrow / overconfident; the true value lands in the posterior tails too often → reported intervals under-cover. Fix: widen priors / loosen the noise prior; do not trust the intervals.
∩ / frown (mass in the centre) → posterior too wide / overdispersed; intervals over-cover (conservative). Fix: tighten priors.
left-skew (mass at low ranks, mean rank < L/2) → posterior sits above the truth → estimates biased high. Fix: check prior centring / standardization.
right-skew (mass at high ranks) → posterior biased low.
(NB: this is the standard Talts 2018 direction — ∪ = underdispersed/overconfident.
It is the opposite of the labels in some informal write-ups; the conjugate unit
test in tests/test_sbc.py pins the direction.)
SBC is expensive — one model refit per simulation — so it is an offline verification tool gated behind a background job, run once per model architecture, never per fit.
- mmm_framework.diagnostics.sbc.uniformity_chisq(int_ranks, L, n_bins=20)[source]¶
χ² goodness-of-fit of the rank histogram against the discrete uniform.
Returns
(chi2, p_value, bin_counts).p_valuelarge ⇒ ranks look uniform ⇒ calibrated on this parameter.
- mmm_framework.diagnostics.sbc.normalized_ranks(int_ranks, L)[source]¶
u = (r + 0.5) / (L + 1)∈ (0, 1); Uniform(0, 1) under calibration.- Return type:
- mmm_framework.diagnostics.sbc.classify_shape(int_ranks, L, *, chi2_p=None, alpha=0.05)[source]¶
Classify the rank-histogram geometry into a calibration verdict.
Uses N-aware z-scores so thresholds scale with the number of simulations: a bias z-score on the mean normalized rank (uniform mean = 0.5) and a dispersion z-score on its variance (uniform var = 1/12). Direction follows Talts 2018 (rank = #draws ≤ θ*).
Returns
{shape, mean_norm_rank, var_norm_rank, skewness, excess_kurtosis, bias_z, dispersion_z}whereshape∈ {uniform,smile(∪),frown(∩),left-skew,right-skew}.
- mmm_framework.diagnostics.sbc.miscalibration_score(bin_counts, L, n_bins)[source]¶
Total-variation distance of the rank histogram from uniform, in
[0, 1).0.5 · Σ_b |O_b/N − p_b|withp_bthe exact discrete-uniform bin probability. 0 = perfectly uniform; →1 − min_b p_bwhen all mass is in one bin. Monotone and comparable across parameters, so the agent can rank the worst offenders by a single number instead of “reading the picture”.- Return type:
- mmm_framework.diagnostics.sbc.rank_hist_band(n_sims, L, n_bins=20, prob=0.95)[source]¶
Simultaneous confidence band for the rank-histogram bin counts.
Returns
(lower, upper)arrays of lengthn_bins(per-bin counts the histogram should fall insideprobof the time, jointly across bins, under calibration). For equal-width bins the band is near-constant. Built from the Multinomial null with the multiplicity-adjusted level from_simultaneous_gamma().
- mmm_framework.diagnostics.sbc.ecdf_diff_band(n_sims, prob=0.95, n_points=100, n_mc=2000, seed=12345)[source]¶
Säilynoja-style simultaneous band for the ECDF-difference plot.
Under calibration the normalized ranks are Uniform(0,1), so at grid point
zthe ECDF satisfiesN·ECDF(z) ~ Binomial(N, z). Returns(z, lower, upper)forECDF(z) − zsuch that the whole curve stays in the bandprobof the time jointly across the grid (multiplicity-adjusted per-point level found by Monte-Carlo + binary search).
- class mmm_framework.diagnostics.sbc.SBCParamStat(name, int_ranks, L, n_sims, n_bins, bin_counts, chi2_stat, chi2_pvalue, shape, mean_norm_rank, var_norm_rank, skewness, excess_kurtosis, bias_z, dispersion_z, miscalibration, calibrated, coverage=<factory>)[source]¶
Bases:
objectPer-parameter SBC verdict + the integer ranks behind it.
-
coverage:
list[CoverageLevelStat]¶
- __init__(name, int_ranks, L, n_sims, n_bins, bin_counts, chi2_stat, chi2_pvalue, shape, mean_norm_rank, var_norm_rank, skewness, excess_kurtosis, bias_z, dispersion_z, miscalibration, calibrated, coverage=<factory>)¶
-
coverage:
- class mmm_framework.diagnostics.sbc.SBCResult(params, n_sims_requested, n_sims_effective, L, n_bins, sampler, seed, alpha, elapsed_s=0.0, n_failed_fits=0, caveats=<factory>)[source]¶
Bases:
objectFull SBC run: per-parameter stats + run metadata.
-
params:
list[SBCParamStat]¶
- __init__(params, n_sims_requested, n_sims_effective, L, n_bins, sampler, seed, alpha, elapsed_s=0.0, n_failed_fits=0, caveats=<factory>)¶
-
params:
- mmm_framework.diagnostics.sbc.compute_param_stat(name, int_ranks, L, *, n_bins=20, alpha=0.05, tau=0.15)[source]¶
Build the full per-parameter verdict from its integer ranks.
- Return type:
- mmm_framework.diagnostics.sbc.build_sbc_result(int_ranks_by_param, *, L, n_sims_requested, sampler='generic', n_bins=20, alpha=0.05, tau=0.15, seed=0, elapsed_s=0.0, n_failed_fits=0)[source]¶
Assemble an
SBCResultfrom per-parameter integer ranks.- Return type:
- mmm_framework.diagnostics.sbc.run_sbc(draw_and_fit, *, n_sims, L, n_bins=20, alpha=0.05, seed=0, sampler='generic')[source]¶
Model-agnostic SBC loop.
draw_and_fit(rng)returns(theta_star, posterior_draws)wheretheta_starmaps param→scalar truth andposterior_drawsmaps the same param→a 1-D array of posterior draws (length ≥L; thinned toL).- Return type:
- mmm_framework.diagnostics.sbc.run_mmm_sbc(model, *, n_sims=64, L=100, n_bins=20, sampler='numpyro', params=None, tune=200, chains=2, seed=0, alpha=0.05, progress=None)[source]¶
Run SBC for a (built, unfitted)
BayesianMMM.Draws
n_simspaired(θ*, y_sim)from the model’s prior, refits the posterior on eachy_simby swapping the observed data on the SAME model graph (pm.observe— keepsθ*and the posterior on one fixed scale), and computes per-parameter ranks.EXPENSIVE: one refit per simulation. Use a background job; defaults are tuned for a tractable national MMM (
numpyroNUTS,n_sims=64,L=100).- Return type:
Coverage¶
Interval-coverage diagnostics — does the 90% interval contain the truth 90% of the time?
Coverage is the property users implicitly assume when they read a credible interval. This module makes it checkable. Three distinct notions map to three tools:
Predictive coverage (observation scale): do posterior-predictive intervals contain the observed data at the nominal rate? → posterior predictive checks and the report’s calibration curve (elsewhere).
Engine calibration (SBC,
sbc): over datasets simulated from the model’s own prior, are posterior ranks uniform? Uniform ranks ⇔ every central interval has nominal coverage on average over the prior.coverage_from_ranks()turns SBC ranks into explicit per-level coverage numbers (“your 90% interval empirically covers 78% [68–86%]”).Recovery coverage at a fixed truth (
run_recovery_coverage(), this module’s headline): pick ONE parameter vector θ* — the fitted posterior mean, a prior draw, or user-supplied values — simulaten_simsdatasets from the model at θ*, refit each, and count how often the X% interval contains θ*, for raw parameters AND per-channel contribution estimands. Reports per-target empirical coverage with Monte-Carlo error bars plus a bias/width diagnosis: is the failure a location problem (posterior sits away from the truth) or an interval-too-narrow problem (overconfidence)?
What recovery coverage CANNOT see: it simulates data FROM the model, so it can never detect real-world misspecification (wrong adstock/saturation family, unobserved confounding, time-varying effects). Read it jointly with an external-truth check:
under-covers here → the problem is mechanical — an approximate fit (MAP/ADVI/Pathfinder uncertainty is not calibrated), a broken sampler, or priors so tight the data cannot move the posterior to the truth;
covers here but failed against an external answer key (e.g. the
mmm_framework.synthscenarios’synthetic_truth.json) → the gap IS structural: misspecification, confounding, or an estimand mismatch (contribution ROI and counterfactual ROI are different numbers).
The full failure-mode table lives in technical-docs/coverage-diagnostics.md
and failure_mode_guide().
Interval convention: central equal-tailed percentile intervals, matching the
framework’s compute_hdi_bounds (which is percentile-based despite the
name), so the coverage measured here is the coverage of the intervals actually
reported.
- class mmm_framework.diagnostics.coverage.CoverageLevelStat(level, n, hits, coverage, ci_low, ci_high, verdict)[source]¶
Bases:
objectEmpirical coverage of the central
levelinterval, with MC error.- __init__(level, n, hits, coverage, ci_low, ci_high, verdict)¶
- class mmm_framework.diagnostics.coverage.RecoveryTargetStat(name, kind, truth, n_sims, levels, bias_z, z_spread, spread_p, rmse, mean_post_sd, verdict, flags=<factory>)[source]¶
Bases:
objectPer-target recovery-coverage verdict (a parameter or an estimand).
-
levels:
list[CoverageLevelStat]¶
- __init__(name, kind, truth, n_sims, levels, bias_z, z_spread, spread_p, rmse, mean_post_sd, verdict, flags=<factory>)¶
-
levels:
- class mmm_framework.diagnostics.coverage.RecoveryCoverageResult(targets, n_sims_requested, n_sims_effective, n_failed_fits, truth_source, sampler, L, seed, levels, elapsed_s=0.0, caveats=<factory>)[source]¶
Bases:
objectFull recovery-coverage run: per-target stats + run metadata.
-
targets:
list[RecoveryTargetStat]¶
- property all_nominal: bool¶
True when no target’s headline-level interval demonstrably under-covers.
- __init__(targets, n_sims_requested, n_sims_effective, n_failed_fits, truth_source, sampler, L, seed, levels, elapsed_s=0.0, caveats=<factory>)¶
-
targets:
- mmm_framework.diagnostics.coverage.jeffreys_interval(k, n, prob=0.95)[source]¶
Jeffreys (Beta(1/2,1/2)) binomial interval for an empirical proportion.
The Monte-Carlo error bar on an estimated coverage: with
nsimulations andkhits, the coverage estimate isk/nwith this uncertainty.
- mmm_framework.diagnostics.coverage.coverage_from_ranks(int_ranks, L, levels=(0.5, 0.8, 0.9, 0.95))[source]¶
Empirical central-interval coverage read directly off SBC ranks.
The truth lies inside the central
levelposterior interval exactly when its normalized ranku = (r + 0.5)/(L + 1)falls in[(1−level)/2, 1−(1−level)/2]— so an SBC run already contains every coverage number; this just states them in user language.- Return type:
- mmm_framework.diagnostics.coverage.coverage_from_draws(truth, draws_by_sim, levels=(0.5, 0.8, 0.9, 0.95))[source]¶
Coverage of central equal-tailed percentile intervals across refits.
draws_by_sim[i]is the posterior sample for one simulated dataset; the truth is fixed. Matches the framework’s reported intervals (compute_hdi_boundsis percentile-based).- Return type:
- mmm_framework.diagnostics.coverage.recovery_diagnosis(truth, post_means, post_sds)[source]¶
Decompose a coverage failure into bias vs. interval width.
With
z_i = (mean_i − θ*)/sd_iacross refits: under calibrated recovery thez_iare ≈ standard normal, so :rtype:dict[str,Any]bias_z = mean(z)·√n≈ N(0,1) — large |bias_z| ⇒ the posterior location is systematically off (positive = estimates sit above truth);z_spread = sd(z)≈ 1 — significantly > 1 ⇒ intervals too narrow (overconfident), < 1 ⇒ too wide (conservative). Significance via(n−1)·s² ~ χ²(n−1).
- mmm_framework.diagnostics.coverage.build_recovery_result(truths, draws_by_target, *, kinds=None, levels=(0.5, 0.8, 0.9, 0.95), truth_source='fixed', sampler='generic', L=0, seed=0, n_sims_requested=0, n_failed_fits=0, elapsed_s=0.0, extra_caveats=())[source]¶
Assemble a
RecoveryCoverageResultfrom per-target refit draws.Pure (no PyMC) — the fast unit tests drive this directly with analytic posteriors.
- Return type:
- mmm_framework.diagnostics.coverage.failure_mode_guide()[source]¶
Markdown table of the ways nominal coverage fails and what to do.
Kept next to the tool so every surface (chat, Validation tab, docs) tells one story. The long-form version is
technical-docs/coverage-diagnostics.md.- Return type:
- mmm_framework.diagnostics.coverage.run_recovery_coverage(model, *, truth='posterior_mean', n_sims=24, levels=(0.5, 0.8, 0.9, 0.95), sampler='numpyro', L=200, tune=200, chains=2, seed=0, params=None, include_contributions=True, progress=None, refit=None, extra_caveats=())[source]¶
Fixed-truth recovery coverage for a
BayesianMMM.Fixes every free parameter at θ* (
pm.do), simulatesn_simsdatasets from the likelihood at θ*, refits the ORIGINAL model on each (pm.observeon the same graph — θ* and every posterior share one fixed standardized scale), and measures how often each central interval contains the truth — for scalar free parameters and per-channel total contributions.EXPENSIVE: one refit per simulation, like SBC. Use a background job for thorough runs;
n_sims < 30only flags gross failures (the binomial CI is reported either way).Answers: “IF the world matched my fitted model exactly, would my reported intervals cover?” Under-coverage here is a mechanical problem (approximate inference, sampler, priors); it deliberately cannot see real-world misspecification — see the module docstring.
- Parameters:
refit (
Optional[Callable[[ndarray,int],Any]]) – Estimator injection.None(default) refits the PyMC model withpm.observe+ NUTS, which is the only thing this used to be able to do. Supply a callable(y_sim, seed) -> idatato grade a non-PyMC estimator — the frequentist ridge + bootstrap path (#186) is why the hook exists. The simulate-from-the-model half is unchanged, so both paths are graded against the same θ* and the same central equal-tailed intervals. The callable may return anything carrying a.posteriormapping, or a plain{name: draws}mapping.extra_caveats (
tuple[str,...]) – Statements appended verbatim to the result’s caveats. Required for the frequentist path: this function auto-attaches an uncertainty caveat foradvi/fullrank_advionly, so ridge shrinkage bias and conditional-on-selection intervals must be passed explicitly or they go unstated.
- Return type:
Note
Parameters the injected estimator holds fixed (the frequentist path fixes adstock and saturation) have degenerate draws, and their coverage is not meaningful. Pass
params=[...]to restrict the table to the quantities the estimator actually estimates.
Learning¶
Prior-vs-posterior learning diagnostics.
A posterior credible interval only tells you what you believe after seeing the data – it does not tell you how much of that belief came from the data versus the prior. When a prior is informative or sign-constrained, a posterior can look “conclusive” while encoding almost nothing the prior did not already assert.
The canonical example in this framework is the cannibalization cross-effect of
MultivariateMMM, whose prior is
psi = -HalfNormal(sigma) – structurally non-positive. Reporting that the
posterior of psi is “entirely below zero” is then nearly vacuous: so is the
prior. The honest question is whether the data moved or narrowed that
parameter relative to its prior.
This module quantifies that with three complementary, sample-based diagnostics:
contraction
c = 1 - Var_post / Var_prior(Betancourt; Schad, Betancourt & Vasishth 2021).c -> 1the posterior is far narrower than the prior (the data pinned it);c ~ 0the posterior is as wide as the prior (data uninformative);c < 0the posterior is wider than the prior – a red flag for prior-data conflict or poor sampling (we deliberately do not clip it). Contraction is a pure variance ratio, so it is robust even for bounded/half-normal priors.overlap – the prior-posterior overlap coefficient
OVL = sum_i min(p_i, q_i)over shared histogram bins (probability-mass form, in[0, 1]).OVL ~ 1the posterior is indistinguishable from the prior (nothing learned);OVL ~ 0they barely overlap (strong learning, whether by narrowing or by shifting). Histogram binning – not a Gaussian KDE – is used on purpose so a hard prior edge (e.g. theHalfNormalboundary at 0) is not smeared.shift_z
= (mean_post - mean_prior) / sd_prior– how far, in prior standard deviations, the posterior mean moved. Catches pure location learning that contraction alone misses (a posterior can shift without narrowing).contraction_robust – the same variance-ratio idea computed on the interquartile range instead of the standard deviation. The sd is tail-sensitive: heavy posterior tails (a genuine feature of the likelihood, or an artifact of chains that mixed slowly into a region the prior disfavored) inflate
post_sdand dragcontractionnegative even when the posterior bulk narrowed. Ifcontractionis negative butcontraction_robustis not, the widening is tail-driven – corroborate withpost_ess_bulkbefore reading it as prior-data conflict.post_ess_bulk – the posterior bulk effective sample size per parameter (when chain structure is available). A negative contraction with low ESS may say more about the sampler than the posterior.
Each parameter gets a heuristic verdict – "strong" / "moderate" /
"weak" / "relocated" / "prior-dominated" – from contraction,
overlap, and shift_z (the thresholds are tunable; treat them as conventions,
not law). A "prior-dominated" verdict is the diagnostic the user is after: the
posterior is essentially the prior. "relocated" is the opposite failure of a
width-only reading: the posterior moved at least one prior-sd without narrowing
– the evidence dominated the location (the prior was tight in the wrong place);
the width reflects the likelihood’s flatness in the new region (or tail/mixing
artifacts – see contraction_robust / post_ess_bulk), so do not read it as
“the data taught nothing”.
The core parameter_learning() works on raw samples (arviz InferenceData,
xarray Dataset, or plain dicts of arrays), so it is unit-testable without a fit.
The model classes expose compute_parameter_learning(...) which draws prior samples
and calls it for you.
- mmm_framework.diagnostics.learning.parameter_learning(prior, posterior, var_names=None, *, bins=60, c_strong=0.5, c_weak=0.1, ovl_dominated=0.85, z_relocated=1.0)[source]¶
How much did the data teach us about each parameter, beyond the prior?
- Return type:
Parameters¶
- prior, posterior:
Sample containers. Each may be an arviz
InferenceData(the"prior"/"posterior"group is read), an xarrayDataset, or adictmapping a parameter name to a sample array (sample axis first). Both must describe the same parameters; vector/matrix parameters are compared element-wise.- var_names:
Optional restriction. Names match either a flattened element (
"beta[0]") or a base parameter ("beta"keeps every"beta[...]"element).Nonecompares every parameter present in both containers.- bins:
Histogram resolution for the overlap coefficient.
- c_strong, c_weak, ovl_dominated, z_relocated:
Heuristic verdict thresholds (conventions, not law). Checked in order:
|shift_z| >= z_relocatedandcontraction < c_weak->"relocated"(the posterior moved at least one prior-sd without narrowing – evidence dominated the location); thencontraction < c_weakandoverlap > ovl_dominated->"prior-dominated";contraction >= c_strong->"strong";contraction >= c_weak->"moderate"; otherwise"weak".
Returns¶
- pandas.DataFrame
One row per parameter, sorted by
contractionascending (so the least-learned, most prior-dominated parameters sort to the top). Columns:parameter,prior_mean,prior_sd,post_mean,post_sd,contraction,contraction_robust,overlap,shift_z,post_ess_bulk,verdict.
Notes¶
contractionis intentionally not clipped: a negative value (posterior wider than prior) is a genuine warning sign, but it is ambiguous on its own – it can mean prior-data conflict, a likelihood that is genuinely flat in the newly-favored region, or sd inflation from heavy tails left by chains that mixed slowly into that region. Disambiguate with the companions: a large|shift_z|says the evidence dominated the location (verdict"relocated", not a failure to learn);contraction_robust>= 0 alongsidecontraction< 0 says the widening is tail-driven; a lowpost_ess_bulksays the width estimate itself may be a sampling artifact – re-run with more tune/draws before interpreting.Informativeness, not importance. High
contraction/ lowoverlapmeans the data was informative about the parameter – which includes confidently pinning it near zero. It is not a statement about effect size or sign. Always read the posteriorpost_mean/ interval alongside the diagnostic: contraction tells you the data spoke; the posterior location tells you what it said. (A sign-constrained prior such aspsi = -HalfNormalmakes “the posterior is below zero” near-automatic; a high contraction to a value of ~0 means the data confidently found a negligible effect, not a confirmed one.)
- mmm_framework.diagnostics.learning.plot_parameter_learning(learning, ax=None, *, top=None, metric='contraction', threshold=None)[source]¶
Horizontal bar chart of per-parameter learning, colored by
verdict.metricis the column to plot ("contraction"by default;"overlap"also works).topkeeps only thetopleast-learned parameters (the head of the sorted frame).thresholddraws a reference line.
- mmm_framework.diagnostics.learning.plot_prior_posterior_overlay(prior, posterior, parameter, ax=None, *, bins=60, transform=None)[source]¶
Overlay the prior and posterior sample histograms for a single parameter.
The clearest way to see whether the data moved/narrowed a parameter relative to its prior.
transform(callable) is applied to both sample sets before plotting – e.g.lambda x: -xto view the signed cannibalization effect from itspsi_..._rawHalfNormal parameter.
Snapshot¶
Fit-time model-health snapshot: the JSON block a model_run carries so
the UI can show whether to believe the fit — not just what it estimated.
Two layers, mirroring the docs’ honesty contract (green diagnostics validate computation, not causality):
convergence — did the sampler work? Divergences, max R-hat, min bulk ESS (already computed by
BayesianMMM.fit; recomputed from the trace as a fallback for models that don’t stamp them).learning — did the data inform the parameters, or are the posteriors re-stating the priors? Per-parameter prior→posterior contraction / overlap / shift verdicts from
mmm_framework.diagnostics.parameter_learning(), reusing the prior group already attached to the trace at fit time (no extra sampling).
Like planning.history.compute_run_metrics, this runs kernel-side at fit
time, must touch only the fitted model, and is best-effort: callers wrap it in
try/except and a failure never fails a fit.
- mmm_framework.diagnostics.snapshot.compute_fit_diagnostics(mmm, results=None, *, max_parameters=40)[source]¶
JSON-safe model-health snapshot (schema v1) for a fitted model.
resultsis theMMMResultswhosediagnosticsdict already carries divergences/R-hat/ESS; pass None to recompute from the trace. Each layer is independently best-effort, so a learning failure still yields the convergence block (and vice versa).