Validation¶
Rolling-origin backtests, specification-curve analysis, calibration checks and channel diagnostics.
Model Validation Package for MMM Framework.
Provides comprehensive tools for verifying the robustness of marketing mix models:
Posterior Predictive Checks (PPC)
Residual Diagnostics
Channel Diagnostics (VIF, convergence)
Model Comparison (LOO-CV, WAIC)
Cross-Validation (time-series CV)
Sensitivity Analysis
Stability Analysis
Experimental Calibration
Examples¶
Quick validation:
>>> from mmm_framework.validation import ModelValidator
>>> validator = ModelValidator(model, results)
>>> summary = validator.quick_check()
>>> print(summary.overall_quality)
>>> print(summary.recommendations)
Standard validation with model comparison:
>>> from mmm_framework.validation import ValidationConfig
>>> config = ValidationConfig.standard()
>>> summary = validator.validate(config)
Thorough validation with custom settings:
>>> from mmm_framework.validation import ValidationConfigBuilder
>>> config = (ValidationConfigBuilder()
... .thorough()
... .with_cross_validation(n_folds=5, strategy="expanding")
... .with_residual_tests(("durbin_watson", "ljung_box"))
... .build())
>>> summary = validator.validate(config)
Generate HTML report:
>>> html = summary.to_html_report()
>>> with open("validation_report.html", "w") as f:
... f.write(html)
- class mmm_framework.validation.ModelValidator(model, results=None)[source]
Bases:
objectMain validation orchestrator.
Provides unified interface for all validation types.
Examples¶
>>> from mmm_framework.validation import ModelValidator, ValidationConfig >>> >>> # Quick validation >>> validator = ModelValidator(model, results) >>> summary = validator.quick_check() >>> >>> # Thorough validation with calibration >>> config = (ValidationConfigBuilder() ... .thorough() ... .with_calibration(lift_tests) ... .build()) >>> summary = validator.validate(config) >>> summary.to_html_report()
- __init__(model, results=None)[source]
Initialize model validator.
Parameters¶
- modelAny
Fitted model (BayesianMMM, NestedMMM, MultivariateMMM, etc.).
- resultsAny, optional
Model results container. If None, extracted from model.
- validate(config=None)[source]
Run validation according to config.
- Return type:
ValidationSummary
Parameters¶
- configValidationConfig, optional
Validation configuration. Defaults to standard validation.
Returns¶
- ValidationSummary
Comprehensive validation results.
- class mmm_framework.validation.ValidationConfigBuilder[source]
Bases:
objectFluent builder for ValidationConfig.
Examples¶
>>> # Quick validation >>> config = ValidationConfigBuilder().quick().build()
>>> # Standard with custom residual tests >>> config = (ValidationConfigBuilder() ... .standard() ... .with_residual_tests(("durbin_watson", "ljung_box")) ... .build())
>>> # Thorough with calibration >>> config = (ValidationConfigBuilder() ... .thorough() ... .with_calibration(lift_tests) ... .build())
- __init__()[source]
Initialize builder with default values.
- quick()[source]
Configure for quick validation.
- Return type:
Self
- standard()[source]
Configure for standard validation.
- Return type:
Self
- thorough()[source]
Configure for thorough validation.
- Return type:
Self
- with_ppc(n_samples=500, checks=None, include_channel_checks=True)[source]
Configure posterior predictive checks.
- Return type:
Self
Parameters¶
- n_samplesint
Number of posterior samples to use.
- checkstuple[str, …], optional
Which checks to run. Default: mean, variance, autocorrelation, skewness, extremes.
- include_channel_checksbool
Whether to include channel-specific checks.
Returns¶
- Self
Builder instance for chaining.
- with_residual_tests(tests, max_lag=20, significance_level=0.05)[source]
Configure residual diagnostic tests.
- Return type:
Self
Parameters¶
- teststuple[str, …]
Which tests to run. Options: durbin_watson, ljung_box, breusch_pagan, shapiro_wilk, jarque_bera.
- max_lagint
Maximum lag for autocorrelation tests.
- significance_levelfloat
Significance level for hypothesis tests.
Returns¶
- Self
Builder instance for chaining.
- with_channel_diagnostics(vif_threshold=10.0, correlation_threshold=0.8, rhat_threshold=1.01, ess_threshold=400)[source]
Configure channel diagnostics.
- Return type:
Self
Parameters¶
- vif_thresholdfloat
VIF threshold for multicollinearity warning.
- correlation_thresholdfloat
Correlation threshold for multicollinearity warning.
- rhat_thresholdfloat
R-hat threshold for convergence.
- ess_thresholdint
ESS threshold for convergence.
Returns¶
- Self
Builder instance for chaining.
- with_cross_validation(n_folds=5, strategy='expanding', min_train_size=52, gap=0, test_size=None)[source]
Enable cross-validation.
- Return type:
Self
Parameters¶
- n_foldsint
Number of CV folds.
- strategystr
CV strategy: expanding, rolling, or blocked.
- min_train_sizeint
Minimum training set size.
- gapint
Gap between train and test (for blocked CV).
- test_sizeint, optional
Fixed test size (for rolling CV).
Returns¶
- Self
Builder instance for chaining.
- with_model_comparison(method='loo', pointwise=True)[source]
Enable model comparison.
- Return type:
Self
Parameters¶
- methodstr
Comparison method: loo, waic, or both.
- pointwisebool
Whether to compute pointwise values.
Returns¶
- Self
Builder instance for chaining.
- with_sensitivity_analysis(prior_multipliers=(0.5, 2.0), parameters_of_interest=None, include_specification_tests=True)[source]
Enable sensitivity analysis.
- Return type:
Self
Parameters¶
- prior_multiplierstuple[float, …]
Multipliers for prior variance in sensitivity tests.
- parameters_of_interesttuple[str, …], optional
Specific parameters to analyze.
- include_specification_testsbool
Whether to test specification variants.
Returns¶
- Self
Builder instance for chaining.
- with_stability_analysis(n_bootstrap=100, loo_subset_size=None, perturbation_level=0.1, n_perturbations=20)[source]
Enable stability analysis.
- Return type:
Self
Parameters¶
- n_bootstrapint
Number of bootstrap samples.
- loo_subset_sizeint, optional
Subset size for LOO influence (None = all).
- perturbation_levelfloat
Perturbation level for sensitivity.
- n_perturbationsint
Number of perturbation runs.
Returns¶
- Self
Builder instance for chaining.
- with_calibration(lift_tests, ci_level=0.94, tolerance_multiplier=1.5)[source]
Enable calibration with external experiments.
- Return type:
Self
Parameters¶
- lift_testslist[LiftTestResult]
List of lift test results for calibration.
- ci_levelfloat
Credible interval level for comparison.
- tolerance_multiplierfloat
Tolerance multiplier for SE deviation.
Returns¶
- Self
Builder instance for chaining.
- with_unobserved_confounding(q=1.0)[source]
Enable per-channel robustness-value sensitivity to unobserved confounding.
- Return type:
Self
Parameters¶
- qfloat
Fraction of the effect a hidden confounder must explain away (
1.0-> nullify the effect entirely).
- with_causal_refutation(*, placebo=True, negative_control=True, random_common_cause=True, data_subset=True, subset_fraction=0.8, draws=300, tune=300, chains=2)[source]
Enable the causal refutation suite (each enabled test refits once).
Expensive: every enabled test refits the model on perturbed data. Tests either expect the effect to vanish (placebo, negative control) or to stay stable (random common cause, data subset).
- Return type:
Self
- without_ppc()[source]
Disable posterior predictive checks.
- Return type:
Self
- without_residuals()[source]
Disable residual diagnostics.
- Return type:
Self
- without_channel_diagnostics()[source]
Disable channel diagnostics.
- Return type:
Self
- without_plots()[source]
Disable plot generation.
- Return type:
Self
- silent()[source]
Disable verbose output.
- Return type:
Self
- mmm_framework.validation.loo_pit_check(*, y, y_hat, log_weights=None, alpha=0.05)[source]
LOO-PIT calibration check from observed values + posterior-predictive draws.
- Return type:
Parameters¶
- yarray (n_obs,)
Observed outcomes.
- y_hatarray (n_obs, n_samples) or (n_samples, n_obs)
Posterior-predictive draws (orientation auto-detected).
- log_weightsarray, optional
PSIS log-weights aligned to
y_hat. If omitted, uniform weights are used (this reduces LOO-PIT to ordinary PIT — still a useful check).- alphafloat
Significance level for the KS uniformity test.
calibratedisks_pvalue > alpha.
- class mmm_framework.validation.LooPitResult(pit, ks_stat, ks_pvalue, calibrated, n)[source]
Bases:
object-
pit:
ndarray
-
ks_stat:
float
-
ks_pvalue:
float
-
calibrated:
bool
-
n:
int
- __init__(pit, ks_stat, ks_pvalue, calibrated, n)
-
pit:
- mmm_framework.validation.simulation_based_calibration(sample_prior, simulate, fit, *, n_sims=100, seed=0, alpha=0.05)[source]
Simulation-based calibration (Talts et al. 2018), model-agnostic.
For each of
n_simsiterations: draw a “true” parameter set from the prior, simulate a dataset, fit it, and record where each true value falls within its posterior draws (the normalized rank). Under correctly-calibrated inference those normalized ranks are Uniform(0, 1); a per-parameter KS test flags miscalibration.Callbacks (each receives the shared
Generatorfor reproducibility): :rtype:SBCResultsample_prior(rng) -> {param: scalar}— one draw of the true parameters.simulate(theta, rng) -> data— synthetic data given the true params.fit(data, rng) -> {param: 1-D posterior draws}— keys must matchsample_prior.
Scalar parameters only (the common SBC case). Returns an
SBCResult.
- class mmm_framework.validation.SBCResult(param_names, n_sims, normalized_ranks=<factory>, ks_pvalue=<factory>, calibrated=<factory>)[source]
Bases:
object-
n_sims:
int
- property all_calibrated: bool
- __init__(param_names, n_sims, normalized_ranks=<factory>, ks_pvalue=<factory>, calibrated=<factory>)
-
n_sims:
- class mmm_framework.validation.BacktestConfig(min_train_size=104, horizon=13, step=None, max_origins=None, coverage_levels=(0.5, 0.8, 0.95), draws=500, tune=500, chains=4, include_noise=True, season_period=None, random_seed=42)[source]
Bases:
objectConfiguration for a rolling-origin backtest.
Attributes¶
- min_train_sizeint
Periods in the first training window. Two seasonal cycles of weekly data (104) is a sensible floor; below ~78 the seasonality and trend posteriors are barely informed and forecast intervals balloon.
- horizonint
Forecast length (periods) past each training cutoff.
- stepint or None
Spacing between consecutive training cutoffs.
Noneuseshorizon(non-overlapping forecast windows).- max_originsint or None
Cap on the number of refits (
None= as many as the data allows).- coverage_levelstuple[float, …]
Central prediction-interval levels to record and grade.
- draws, tune, chainsint
MCMC budget per refit. Backtests refit the model once per origin, so these default lower than a production fit; convergence is still recorded per origin in
BacktestResult.fits.- include_noisebool
Forecast the observable (mean + observation noise) rather than the latent mean. Keep
Truefor honest interval coverage.- season_periodint or None
Seasonal lag for the seasonal-naive baseline and the MASE scale.
Nonederives it from the data frequency (weekly -> 52).- random_seedint
Base seed; each origin offsets it deterministically.
-
min_train_size:
int= 104
-
horizon:
int= 13
-
draws:
int= 500
-
tune:
int= 500
-
chains:
int= 4
-
include_noise:
bool= True
-
random_seed:
int= 42
- __init__(min_train_size=104, horizon=13, step=None, max_origins=None, coverage_levels=(0.5, 0.8, 0.95), draws=500, tune=500, chains=4, include_noise=True, season_period=None, random_seed=42)
- class mmm_framework.validation.BacktestResult(config, records, fits, season_period, mase_scales=<factory>)[source]
Bases:
objectRolling-origin backtest records plus summary views.
Attributes¶
- recordspd.DataFrame
One row per (origin, horizon) forecast:
origin,position,date,horizon,y_true,y_pred(posterior-mean),pred_naive,pred_snaive, per-level interval bounds (lo_80/hi_80, …) and coverage flags (cov_80, …).- fitspd.DataFrame
One row per refit origin: train size, wall-clock seconds, R-hat max, divergences. Check this before trusting the records.
-
config:
BacktestConfig
-
records:
DataFrame
-
fits:
DataFrame
-
season_period:
int
- property n_origins: int
- property mape: float
Headline out-of-time MAPE of the MMM over all records (the number the docs quote). Convenience for
summary().loc["mmm", "mape"].
- summary()[source]
Headline accuracy: the model vs naive baselines, all records.
- Return type:
- by_horizon()[source]
Accuracy and coverage as a function of forecast lead time.
- Return type:
- by_origin()[source]
Accuracy per refit origin (forecast-window heterogeneity).
- Return type:
- coverage()[source]
Interval calibration: nominal vs empirical coverage + sharpness.
- Return type:
- __init__(config, records, fits, season_period, mase_scales=<factory>)
- class mmm_framework.validation.PosteriorForecaster(model, *, strict=True)[source]
Bases:
objectOut-of-time forecasts from a fitted BayesianMMM’s posterior draws.
Replays the model’s structural forward pass (intercept, trend, seasonality, geo and product level offsets, adstock, saturation, controls, observation noise) in NumPy at arbitrary future period positions, using the full spend history for adstock carryover.
The replay is exact: over training positions,
forecast(include_noise= False)equals the sum of the model’s registered component Deterministics to floating-point tolerance. Terms it cannot replay are refused here, in the constructor, rather than at forecast time — so a caller cannot hold a forecaster whose output it is not allowed to trust.Parameters¶
- modelBayesianMMM
A fitted model, possibly trained on a prefix of the full series.
- strictbool, default True
When
True, an unreplayable term raisesForecastUnsupportedError.Falsedowngrades the refusal to a warning and records it onself.unsupported— for diagnostic use on a model you already know is incomplete. Never use it for planning: the resulting forecast omits a term the model estimated.
Attributes¶
- trend_extrapolationTrendExtrapolation
The stated policy for continuing the trend past the training window.
- unsupportedlist[tuple[str, str]]
Empty unless
strict=Falsesuppressed a refusal.
Raises¶
- ForecastUnsupportedError
The model carries a term the forward pass cannot replay.
- __init__(model, *, strict=True)[source]
- property n_samples: int
- forecast(X_media_full_raw, X_controls_full_raw, positions, *, include_noise=True, random_seed=None, train_offset=0)[source]
Posterior predictive draws at absolute period
positions.- Return type:
Parameters¶
- X_media_full_rawnp.ndarray
Raw media, shape
(n_full, n_channels)– the FULL history (training + forecast periods) so adstock carryover is correct.- X_controls_full_rawnp.ndarray or None
Raw controls over the full history (required if the model has controls; future control values are assumed known/planned).
- positionsnp.ndarray
Absolute period positions (0-based on the full axis) to forecast.
- train_offsetint
Absolute position of the trained model’s first period. 0 for prefix training (the backtest); the window start for rolling-window clones (validator cross-validation).
Returns¶
- np.ndarray
Samples in original KPI scale, shape
(n_samples, len(positions)).
- mmm_framework.validation.rolling_origins(n_periods, *, min_train_size, horizon, step=None, max_origins=None)[source]
Training cutoffs for a rolling-origin (expanding-window) backtest.
Each returned cutoff
Tmeans: train on periods[0, T), forecast periods[T, min(T + horizon, n_periods)). Only full forecast windows are emitted so every origin is graded on the same horizons.
- exception mmm_framework.validation.ForecastUnsupportedError(feature, reason, all_unsupported=None)[source]
Bases:
NotImplementedErrorThe fitted model carries a term the forward pass cannot replay.
Raised instead of silently dropping the term, mirroring
mmm_framework.frequentist.design.UnsupportedModelError. Carriesfeatureso callers (the backtest harness, the agent, the REST job) can report which configuration blocked the forecast rather than a generic refusal, andall_unsupportedso a UI can list every blocker at once instead of making the user fix them one at a time.- __init__(feature, reason, all_unsupported=None)[source]
- class mmm_framework.validation.TrendExtrapolation(policy, trend_type, n_train_periods)[source]
Bases:
objectHow the forecaster continues the trend past the training window.
Recorded rather than assumed, because the three policies carry very different forecast semantics and only one of them is model-defined.
Attributes¶
- policy{‘none’, ‘linear’, ‘held_flat’}
held_flatis a heuristic: a spline/GP/piecewise basis has no out-of-time forecast, so the last fitted level is carried forward and the interval consequently does not widen with horizon.- trend_typestr
The configured trend family.
- n_train_periodsint
Training length. Load-bearing for
linear: the fitted slope is on at_scaled = pos / (n_train - 1)axis, so the same posterior implies a different slope per period depending on how long the training panel was. Reporting it makes that visible rather than surprising.
-
policy:
str
-
trend_type:
str
-
n_train_periods:
int
- property is_model_defined: bool
False when the continuation is a heuristic rather than the model’s.
- __init__(policy, trend_type, n_train_periods)
- mmm_framework.validation.audit_forward_pass(model)[source]
Every fitted-mean term
PosteriorForecastercannot replay.Returns
(feature, reason)pairs, most-likely-to-be-hit first, so the first message a user sees names the thing they actually configured. Empty means the forward pass reproduces the fitted mean exactly.The reference for “every term” is the mu construction in
model/base.py::_build_model:intercept + trend + seasonality + geo + product + media + controlsplus the conditionalevent,interactionandleverblocks. Each conditional block below is one of those.
- mmm_framework.validation.audit_refit(model)[source]
What a rolling-origin refit would change about the model under test.
Distinct from
audit_forward_pass(): these terms do not break the forward pass, they make the backtest grade a different model than the one the user holds. Reported byrun_backtest(), not by the forecaster.
- mmm_framework.validation.rebuild_like(model, panel, **overrides)[source]
A fresh, unfitted model of
type(model)onpanel.The single place that reconstructs a model from another one. Preserving the CLASS and
model_paramsis load-bearing: hard-constructingBayesianMMMmeans a garden or custom model is fit and graded as a plain additive MMM and its results reported under the custom model’s name.- Return type:
Raises¶
- ForecastUnsupportedError
The class cannot be rebuilt from
(panel, model_config, trend_config, adstock_alphas, model_params)— e.g. a model declaringREQUIRED_DATASET_CAPABILITIESthe sliced panel no longer satisfies. Refusing beats falling back to a different model class.
- mmm_framework.validation.run_backtest(model, config=None, **fit_kwargs)[source]
Rolling-origin backtest of a BayesianMMM specification.
Refits the model’s exact configuration on an expanding training window, forecasts
config.horizonperiods past each cutoff withPosteriorForecaster, and grades against held-out actuals and naive baselines.- Return type:
Parameters¶
- modelBayesianMMM
The full-data model (does not need to be fitted); supplies the panel, configuration, and the raw media/control history.
- configBacktestConfig, optional
Backtest settings; defaults to
BacktestConfig().- **fit_kwargs
Extra arguments forwarded to each refit’s
fit()(e.g.progressbar=False).
Returns¶
BacktestResult
- class mmm_framework.validation.SpecSet(**data)[source]
Bases:
BaseModelA pre-registered set of defensible specs, declared before results.
Serialize this (
model_dump) into the design-readout / assumption log before fitting so the robustness report is auditable — the spec set was fixed in advance, not chosen after seeing which answer looked nicest.- variants: list[SpecVariant]
- rationale: str
- registered_at: str | None
ISO timestamp of pre-registration (set by the caller; kept as data, not computed here so the module stays deterministic / clock-free).
- property primary_variant: SpecVariant | None
- model_config: ClassVar[ConfigDict] = {}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class mmm_framework.validation.SpecVariant(**data)[source]
Bases:
BaseModelOne defensible specification, expressed as overrides on a base spec.
The structured axes cover the four the review calls out (adstock form, saturation form, control set, pooling);
overridesis an escape hatch for any other spec key (deep-merged last).- name: str
- adstock: str | None
Adstock family applied to every media channel (“geometric”/”weibull”/”delayed”).
- saturation: str | None
Saturation family applied to every media channel (“hill”/”logistic”/”michaelis_menten”/”tanh”).
- controls: list[str] | None
Full replacement control set (channel-name list).
Nonekeeps the base’s.
- kpi_level: str | None
Pooling scheme (“national” / “geo”).
- media_prior_mode: str | None
Default media-prior parameterization (“roi” / “coefficient”).
- trend: str | None
Trend family (“none”/”linear”/”piecewise”/”spline”/”gaussian_process”).
- seasonality: dict[str, Any] | None
Seasonality override, e.g.
{"yearly": 4}(or{}to disable).
- overrides: dict[str, Any]
Arbitrary deep-merge overrides (applied last).
- primary: bool
Whether this is the pre-registered PRIMARY specification.
- description: str
- model_config: ClassVar[ConfigDict] = {}
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class mmm_framework.validation.SpecFit(name, primary, roi=<factory>, roi_draws=<factory>, loo=None, weight=0.0, error=None)[source]
Bases:
objectOne spec’s realized ROI + model-fit summary.
-
name:
str
-
primary:
bool
-
weight:
float= 0.0
- __init__(name, primary, roi=<factory>, roi_draws=<factory>, loo=None, weight=0.0, error=None)
-
name:
- class mmm_framework.validation.SpecCurveResult(channels, specs, primary, hdi_prob, fits, weights, bma, robustness, weighting='equal', predictive_weights=<factory>)[source]
Bases:
objectSpec-curve outcome: per-spec ROI, applied weights, BMA, robustness.
-
hdi_prob:
float
-
weighting:
str= 'equal' Which weighting formed
bma—"equal"(default) or"stacking".
-
predictive_weights:
dict[str,float] LOO-stacking weights, reported for diagnosis even when not applied. Empty when
compute_loo=Falseor arviz could not run.
- to_dict()[source]
JSON-friendly payload for the report bundle / agent tool (no draws).
- __init__(channels, specs, primary, hdi_prob, fits, weights, bma, robustness, weighting='equal', predictive_weights=<factory>)
-
hdi_prob:
- mmm_framework.validation.apply_variant(base_spec, variant)[source]
Return a new spec =
base_specwithvariantapplied.The structured axes are applied first (adstock/saturation forms are set on every media channel; controls/pooling/prior-mode/trend/seasonality set the corresponding top-level keys), then
variant.overridesis deep-merged.- Return type:
- mmm_framework.validation.default_spec_variants(base_spec, *, adstock_forms=('geometric', 'weibull'), saturation_forms=('hill', 'logistic'), include_prior_mode=False)[source]
The standard defensible spec grid: adstock × saturation forms.
The base spec’s own (adstock, saturation) combination is marked
primary.include_prior_modeadds a coefficient-scale-prior sibling of the primary (the other big modelling fork). Callers may of course hand-write their ownSpecVariantlist instead.- Return type:
- mmm_framework.validation.run_spec_curve(base_spec, dataset_path, *, variants=None, hdi_prob=0.94, max_draws=400, random_seed=42, compute_loo=True, weighting='equal', fit_fn=None, roi_fn=None)[source]
Fit a spec set, collect per-channel ROI, and model-average across specs.
- Return type:
Parameters¶
- base_spec, dataset_path:
The base spec and the dataset file each variant is fit against.
- variants:
A
SpecSet, a list ofSpecVariant, orNone(usesdefault_spec_variants()).- hdi_prob, max_draws, random_seed:
Credible-interval mass, ROI-draw thinning, and seed.
- compute_loo:
Compute per-spec LOO and the diagnostic LOO-stacking weights. These are reported either way; whether they are applied is
weighting.- weighting:
"equal"(default) weights every non-failing spec equally — the pre-registration-consistent choice for averaging a causal estimand."stacking"applies LOO-stacking weights instead and emits a warning: stacking maximizes expected predictive utility, which is a different objective from validity for ROI. See the module docstring.- fit_fn, roi_fn:
Injection points for testing.
fit_fn(spec, dataset_path) -> model(default: build + fit, no serialization);roi_fn(model, channels, max_draws=, random_seed=) -> {channel: draws}(default:channel_roi_draws()).
- class mmm_framework.validation.PPCValidator(model, config=None)[source]
Bases:
objectPosterior predictive check orchestrator.
Runs a suite of posterior predictive checks to assess model adequacy.
Examples¶
>>> validator = PPCValidator(results) >>> ppc_results = validator.run() >>> print(ppc_results.summary())
- __init__(model, config=None)[source]
Initialize PPC validator.
Parameters¶
- modelAny
Fitted model with trace and predict method.
- configPPCConfig, optional
Configuration for PPC checks.
- run(y_obs=None, y_rep=None, random_seed=None)[source]
Run posterior predictive checks.
- Return type:
PPCResults
Parameters¶
- y_obsnp.ndarray, optional
Observed data. If None, extracted from model.
- y_repnp.ndarray, optional
Replicated data. If None, generated from posterior.
- random_seedint, optional
Random seed for reproducibility.
Returns¶
- PPCResults
Results of all PPC checks.
- class mmm_framework.validation.ResidualDiagnostics(model, config=None)[source]
Bases:
objectComprehensive residual analysis.
Performs statistical tests on model residuals to check for autocorrelation, heteroscedasticity, and non-normality.
Examples¶
>>> diagnostics = ResidualDiagnostics(model, results) >>> diag_results = diagnostics.run_all() >>> print(diag_results.summary())
- __init__(model, config=None)[source]
Initialize residual diagnostics.
Parameters¶
- modelAny
Fitted model with trace.
- configResidualConfig, optional
Configuration for residual tests.
- run_all(residuals=None, fitted_values=None)[source]
Run all configured residual diagnostic tests.
- Return type:
ResidualDiagnosticsResults
Parameters¶
- residualsnp.ndarray, optional
Model residuals. If None, computed from model.
- fitted_valuesnp.ndarray, optional
Fitted values. If None, computed from model.
Returns¶
- ResidualDiagnosticsResults
Results of all diagnostic tests.
- class mmm_framework.validation.ChannelDiagnostics(model, config=None)[source]
Bases:
objectComprehensive channel-level diagnostics.
Combines VIF analysis, correlation matrix, and per-channel convergence.
Examples¶
>>> diagnostics = ChannelDiagnostics(model) >>> results = diagnostics.run_all() >>> print(results.summary())
- __init__(model, config=None)[source]
Initialize channel diagnostics.
Parameters¶
- modelAny
Fitted model with media data and trace.
- configChannelDiagnosticsConfig, optional
Configuration for diagnostics.
- run_all()[source]
Run all channel diagnostics.
- Return type:
ChannelDiagnosticsResults
Returns¶
- ChannelDiagnosticsResults
Comprehensive channel diagnostics results.
- class mmm_framework.validation.FrozenPredictor(predict_fn, input_names, input_shapes, posterior_samples, sigma_samples, config)[source]
Bases:
objectCompiled predictor using frozen posterior samples.
Reuses the model’s actual computation graph with: - Free RVs replaced by frozen posterior samples - Input data replaced by new symbolic inputs - SpecifyShape ops removed for dynamic sizing
This avoids manually reconstructing model logic and ensures predictions match exactly what the model would produce.
Parameters¶
- predict_fncallable
Compiled PyTensor function for predictions.
- input_nameslist[str]
Names of input variables (pm.Data nodes).
- input_shapesdict[str, tuple]
Expected shapes for each input (for validation).
- posterior_samplesdict[str, np.ndarray]
Flattened posterior samples for all needed RVs.
- sigma_samplesnp.ndarray | None
Flattened sigma samples for observation noise.
- configFrozenPredictorConfig
Configuration used to create this predictor.
- __init__(predict_fn, input_names, input_shapes, posterior_samples, sigma_samples, config)[source]
- property n_samples: int
Number of posterior samples available.
- predict(inputs, include_noise=None, seed=None)[source]
Generate predictions for new inputs.
- Return type:
FrozenPredictorOutput
Parameters¶
- inputsdict[str, np.ndarray]
New input data. Keys must match input_names. Values should have shape (n_points, …) matching original dims.
- include_noisebool | None
Whether to add observation noise. If None, uses config setting.
- seedint | None
Random seed for noise generation. If None, uses config seed.
Returns¶
- FrozenPredictorOutput
Contains mu (deterministic) and optionally y_samples (with noise).
- class mmm_framework.validation.FrozenPredictorConfig(output_var='y_obs', include_noise=True, seed=None, num_samples=None)[source]
Bases:
objectConfiguration for frozen predictor creation.
Parameters¶
- output_varstr
Name of the output variable to predict (e.g., “y_obs”, “mu”). Default is “y_obs” which gives the full likelihood output.
- include_noisebool
Whether to include observation noise (sigma) in predictions. If True, samples from Normal(mu, sigma). Default is True.
- seedint | None
Random seed for reproducibility. Affects both posterior sample selection and observation noise generation.
- num_samplesint | None
Number of posterior samples to use. If None, uses all available.
-
output_var:
str= 'y_obs'
-
include_noise:
bool= True
- __init__(output_var='y_obs', include_noise=True, seed=None, num_samples=None)
- exception mmm_framework.validation.FrozenPredictorError[source]
Bases:
ExceptionRaised when frozen predictor cannot be created or used.
- class mmm_framework.validation.FrozenPredictorOutput(mu, y_samples=None, sigma=None)[source]
Bases:
objectOutput from frozen predictor evaluation.
Parameters¶
- munp.ndarray
Deterministic predictions (mean), shape (n_samples, n_points).
- y_samplesnp.ndarray | None
Predictions with observation noise (if include_noise=True), shape (n_samples, n_points).
- sigmanp.ndarray | None
Sigma samples used for noise generation.
- __init__(mu, y_samples=None, sigma=None)
- mmm_framework.validation.create_frozen_predictor(model, trace, config=None)[source]
Create a frozen predictor from a fitted PyMC model.
This function creates a compiled PyTensor function that evaluates model expressions at new input values, vectorized over posterior samples. It reuses the actual model graph rather than reconstructing it manually.
- Return type:
FrozenPredictor
Parameters¶
- modelpm.Model
The fitted PyMC model.
- traceaz.InferenceData
ArviZ InferenceData with posterior samples.
- configFrozenPredictorConfig | None
Configuration options. If None, uses defaults.
Returns¶
- FrozenPredictor
Compiled predictor ready for inference.
Raises¶
- FrozenPredictorError
If the predictor cannot be created (e.g., missing output var, ICDF not available for observed RV).
Examples¶
>>> predictor = create_frozen_predictor(model.model, model._trace) >>> output = predictor.predict({ ... "X_media_low": X_new_low, ... "X_media_high": X_new_high, ... "X_controls": X_controls_new, ... "time_idx": time_idx_new, ... "geo_idx": geo_idx_new, ... "product_idx": product_idx_new, ... }) >>> y_pred = output.y_samples.mean(axis=0)
- mmm_framework.validation.create_frozen_predictor_from_model(mmm_model, config=None)[source]
Create a frozen predictor from an MMM model object.
Convenience wrapper that extracts the PyMC model and trace from a BayesianMMM or extended model object.
- Return type:
FrozenPredictor
Parameters¶
- mmm_modelHasPyMCModel
MMM model with .model and ._trace attributes.
- configFrozenPredictorConfig | None
Configuration options.
Returns¶
- FrozenPredictor
Compiled predictor.
Raises¶
- FrozenPredictorError
If model hasn’t been fitted or predictor creation fails.
- class mmm_framework.validation.ValidationConfig(level=ValidationLevel.STANDARD, ppc=<factory>, residuals=<factory>, channel_diagnostics=<factory>, cross_validation=<factory>, model_comparison=<factory>, sensitivity=<factory>, stability=<factory>, calibration=<factory>, causal_refutation=<factory>, run_ppc=True, run_residuals=True, run_channel_diagnostics=True, run_model_comparison=False, run_cross_validation=False, run_sensitivity=False, run_stability=False, run_calibration=False, run_unobserved_confounding=False, run_causal_refutation=False, unobserved_confounding_q=1.0, lift_tests=None, generate_plots=True, verbose=True)[source]
Bases:
objectComplete validation configuration.
Controls which validations to run and their parameters.
Examples¶
>>> # Quick validation >>> config = ValidationConfig.quick()
>>> # Standard with custom residual tests >>> config = ValidationConfig.standard()
>>> # Thorough with calibration data >>> config = ValidationConfig.thorough()
-
level:
ValidationLevel= 'standard'
-
ppc:
PPCConfig
-
residuals:
ResidualConfig
-
channel_diagnostics:
ChannelDiagnosticsConfig
-
cross_validation:
CrossValidationConfig
-
model_comparison:
ModelComparisonConfig
-
sensitivity:
SensitivityConfig
-
stability:
StabilityConfig
-
calibration:
CalibrationConfig
-
causal_refutation:
CausalRefutationConfig
-
run_ppc:
bool= True
-
run_residuals:
bool= True
-
run_channel_diagnostics:
bool= True
-
run_model_comparison:
bool= False
-
run_cross_validation:
bool= False
-
run_sensitivity:
bool= False
-
run_stability:
bool= False
-
run_calibration:
bool= False
-
run_unobserved_confounding:
bool= False
-
run_causal_refutation:
bool= False
-
unobserved_confounding_q:
float= 1.0
-
generate_plots:
bool= True
-
verbose:
bool= True
- classmethod quick()[source]
Quick validation (convergence, PPC, residuals, channel diagnostics).
Fast feedback on model quality, suitable for iterative development.
- Return type:
ValidationConfig
- classmethod standard()[source]
Standard validation (quick + LOO-CV, WAIC).
Good balance of thoroughness and compute time.
- Return type:
ValidationConfig
- classmethod thorough()[source]
Thorough validation (all checks).
Comprehensive validation for production models.
- Return type:
ValidationConfig
- __init__(level=ValidationLevel.STANDARD, ppc=<factory>, residuals=<factory>, channel_diagnostics=<factory>, cross_validation=<factory>, model_comparison=<factory>, sensitivity=<factory>, stability=<factory>, calibration=<factory>, causal_refutation=<factory>, run_ppc=True, run_residuals=True, run_channel_diagnostics=True, run_model_comparison=False, run_cross_validation=False, run_sensitivity=False, run_stability=False, run_calibration=False, run_unobserved_confounding=False, run_causal_refutation=False, unobserved_confounding_q=1.0, lift_tests=None, generate_plots=True, verbose=True)
-
level:
- class mmm_framework.validation.ValidationLevel(value, names=<not given>, *values, module=None, qualname=None, type=None, start=1, boundary=None)[source]
-
Validation thoroughness level.
- QUICK = 'quick'
- STANDARD = 'standard'
- THOROUGH = 'thorough'
- class mmm_framework.validation.PPCConfig(n_samples=500, checks=('mean', 'variance', 'autocorrelation', 'skewness', 'extremes'), include_channel_checks=True, significance_level=0.05)[source]
Bases:
objectConfiguration for posterior predictive checks.
-
n_samples:
int= 500
-
include_channel_checks:
bool= True
-
significance_level:
float= 0.05
- __init__(n_samples=500, checks=('mean', 'variance', 'autocorrelation', 'skewness', 'extremes'), include_channel_checks=True, significance_level=0.05)
-
n_samples:
- class mmm_framework.validation.ResidualConfig(max_lag=20, significance_level=0.05, tests=('durbin_watson', 'ljung_box', 'breusch_pagan', 'shapiro_wilk', 'jarque_bera'))[source]
Bases:
objectConfiguration for residual diagnostics.
-
max_lag:
int= 20
-
significance_level:
float= 0.05
-
tests:
tuple[str,...] = ('durbin_watson', 'ljung_box', 'breusch_pagan', 'shapiro_wilk', 'jarque_bera')
- __init__(max_lag=20, significance_level=0.05, tests=('durbin_watson', 'ljung_box', 'breusch_pagan', 'shapiro_wilk', 'jarque_bera'))
-
max_lag:
- class mmm_framework.validation.ChannelDiagnosticsConfig(vif_threshold=10.0, correlation_threshold=0.8, rhat_threshold=1.01, ess_threshold=400)[source]
Bases:
objectConfiguration for channel diagnostics.
-
vif_threshold:
float= 10.0
-
correlation_threshold:
float= 0.8
-
rhat_threshold:
float= 1.01
-
ess_threshold:
int= 400
- __init__(vif_threshold=10.0, correlation_threshold=0.8, rhat_threshold=1.01, ess_threshold=400)
-
vif_threshold:
- class mmm_framework.validation.CrossValidationConfig(strategy='expanding', n_folds=5, min_train_size=52, gap=0, test_size=None, draws_per_fold=500, tune_per_fold=250, chains_per_fold=2, use_frozen_predictor=True, frozen_predictor_seed=42)[source]
Bases:
objectConfiguration for cross-validation.
-
strategy:
Literal['expanding','rolling','blocked'] = 'expanding'
-
n_folds:
int= 5
-
min_train_size:
int= 52
-
gap:
int= 0
-
draws_per_fold:
int= 500
-
tune_per_fold:
int= 250
-
chains_per_fold:
int= 2
-
use_frozen_predictor:
bool= True
- __init__(strategy='expanding', n_folds=5, min_train_size=52, gap=0, test_size=None, draws_per_fold=500, tune_per_fold=250, chains_per_fold=2, use_frozen_predictor=True, frozen_predictor_seed=42)
-
strategy:
- class mmm_framework.validation.ModelComparisonConfig(method='loo', pointwise=True, pareto_k_threshold=0.7)[source]
Bases:
objectConfiguration for model comparison.
-
method:
Literal['loo','waic','both'] = 'loo'
-
pointwise:
bool= True
-
pareto_k_threshold:
float= 0.7
- __init__(method='loo', pointwise=True, pareto_k_threshold=0.7)
-
method:
- class mmm_framework.validation.SensitivityConfig(prior_multipliers=(0.5, 2.0), parameters_of_interest=None, include_specification_tests=True, draws_per_variant=500, tune_per_variant=250, chains_per_variant=2)[source]
Bases:
objectConfiguration for sensitivity analysis.
-
include_specification_tests:
bool= True
-
draws_per_variant:
int= 500
-
tune_per_variant:
int= 250
-
chains_per_variant:
int= 2
- __init__(prior_multipliers=(0.5, 2.0), parameters_of_interest=None, include_specification_tests=True, draws_per_variant=500, tune_per_variant=250, chains_per_variant=2)
-
include_specification_tests:
- class mmm_framework.validation.StabilityConfig(n_bootstrap=100, loo_subset_size=None, perturbation_level=0.1, n_perturbations=20)[source]
Bases:
objectConfiguration for stability analysis.
-
n_bootstrap:
int= 100
-
perturbation_level:
float= 0.1
-
n_perturbations:
int= 20
- __init__(n_bootstrap=100, loo_subset_size=None, perturbation_level=0.1, n_perturbations=20)
-
n_bootstrap:
- class mmm_framework.validation.CalibrationConfig(ci_level=0.94, tolerance_multiplier=1.5)[source]
Bases:
objectConfiguration for calibration checks.
-
ci_level:
float= 0.94
-
tolerance_multiplier:
float= 1.5
- __init__(ci_level=0.94, tolerance_multiplier=1.5)
-
ci_level:
- class mmm_framework.validation.CausalRefutationConfig(run_placebo=True, run_negative_control=True, run_random_common_cause=True, run_data_subset=True, subset_fraction=0.8, media_r2_threshold=0.05, negative_control_r2_threshold=0.3, move_tolerance=0.5, underpowered_se_ratio=1.0, draws=300, tune=300, chains=2, random_seed=1234)[source]
Bases:
objectConfiguration for the causal refutation suite (each test refits once).
Because the media coefficient prior is positive-mean, a placebo coefficient does NOT vanish to zero – so vanishing-effect tests are measured by fit, not by the coefficient:
negative_control_outcomepermutes the KPI; a valid, regularized model should be unable to fit the scrambled outcome (refit R^2 belownegative_control_r2_threshold).placebo_treatmentpermutes media; scrambled media should add no incremental explanatory power over a media-free baseline (incremental media R^2 belowmedia_r2_threshold).
Stability tests compare the per-channel coefficient before/after and pass when it moves less than
move_tolerance(relative):random_common_causeinjects a random control.data_subsetrefits on a random subset.
precision(refit coefficient SD) is reported on stability tests so an underpowered “pass” is not oversold.-
run_placebo:
bool= True
-
run_negative_control:
bool= True
-
run_random_common_cause:
bool= True
-
run_data_subset:
bool= True
-
subset_fraction:
float= 0.8
-
media_r2_threshold:
float= 0.05
-
negative_control_r2_threshold:
float= 0.3
-
move_tolerance:
float= 0.5
-
underpowered_se_ratio:
float= 1.0
-
draws:
int= 300
-
tune:
int= 300
-
chains:
int= 2
-
random_seed:
int= 1234
- __init__(run_placebo=True, run_negative_control=True, run_random_common_cause=True, run_data_subset=True, subset_fraction=0.8, media_r2_threshold=0.05, negative_control_r2_threshold=0.3, move_tolerance=0.5, underpowered_se_ratio=1.0, draws=300, tune=300, chains=2, random_seed=1234)
- class mmm_framework.validation.UnobservedConfoundingAnalysis(model)[source]
Bases:
objectPer-channel sensitivity of media effects to unobserved confounding.
Parameters¶
- modelBayesianMMM
A fitted model exposing
_trace.posteriorwithbeta_<channel>variables,channel_namesandn_obs.
- __init__(model)[source]
- run(q=1.0)[source]
Compute robustness values for every channel coefficient.
- Return type:
UnobservedConfoundingSensitivity
- class mmm_framework.validation.UnobservedConfoundingSensitivity(channels, dof, q, caveat)[source]
Bases:
objectRobustness of each channel effect to an unobserved confounder.
-
channels:
list[ChannelRobustness]
-
dof:
int
-
q:
float
-
caveat:
str
- property unassessable_channels: list[str]
Channels whose RV could not be computed (see
ChannelRobustness.is_assessable).These are NOT in
fragile_channels— a non-finite RV fails therv < thresholdtest — so any caller reading an emptyfragile_channelsas “all robust” must consult this list too.
- __init__(channels, dof, q, caveat)
-
channels:
- class mmm_framework.validation.ChannelRobustness(channel, estimate, std_error, t_value, dof, partial_r2, robustness_value, robustness_value_half, fragile_threshold=0.1, prior_contraction=None, prior_dominated_threshold=0.2)[source]
Bases:
objectPer-channel sensitivity of the media effect to unobserved confounding.
-
channel:
str
-
estimate:
float
-
std_error:
float
-
t_value:
float
-
dof:
int
-
partial_r2:
float
-
robustness_value:
float
-
robustness_value_half:
float
-
fragile_threshold:
float= 0.1
-
prior_contraction:
float|None= None Prior->posterior contraction
1 - Var_post / Var_priorfor this channel’s coefficient, orNonewhen no prior group was available. The RV is strictly increasing in|t| = |mean| / sd, and a tight prior shrinkssd— so a prior-dominated coefficient reports a high RV without any supporting evidence. Seemmm_framework.validation.sensitivity_unobserved.
-
prior_dominated_threshold:
float= 0.2 Below this contraction the posterior is treated as prior-dominated and the RV is not quotable as evidence of robustness.
- property is_fragile: bool
Effect could be overturned by a weak (< threshold) confounder.
- property is_prior_dominated: bool
The posterior barely narrowed the prior, so the RV reflects the prior.
Nonecontraction (no prior group sampled) reads as not flagged — absence of the check is reported separately rather than as a failure.
- property is_assessable: bool
Whether a robustness value could be computed at all.
A non-finite RV means
|t| = |mean| / sdwas undefined — typically an approximate (MAP/ADVI) fit, whose degenerate posterior gives a zero or undefined sd. This is distinct fromis_prior_dominated, where the RV exists but reflects the prior: here there is no value to quote.Load-bearing because
is_fragileisisfinite(rv) and rv < thr, so a non-finite RV is not fragile — and every consumer that reads “not fragile” as “robust” would otherwise report a passed sensitivity check that was never computed.
- property rv_is_quotable: bool
Whether the robustness value can be quoted as evidence at all.
- __init__(channel, estimate, std_error, t_value, dof, partial_r2, robustness_value, robustness_value_half, fragile_threshold=0.1, prior_contraction=None, prior_dominated_threshold=0.2)
-
channel:
- class mmm_framework.validation.CausalRefutationResults(tests, underpowered=False)[source]
Bases:
objectAggregate results of the causal refutation suite.
-
tests:
list[RefutationTest]
-
underpowered:
bool= False
-
n_passed:
int
-
n_failed:
int
-
all_passed:
bool
- __init__(tests, underpowered=False)
-
tests:
- class mmm_framework.validation.RefutationTest(name, kind, passed, description, original_effect=None, refuted_effect=None, refuted_ci_low=None, refuted_ci_high=None, precision=None, channel=None, details='')[source]
Bases:
objectResult of a single causal refutation test.
For vanishing-effect tests (placebo treatment, negative-control outcome) a correct model should collapse the effect toward zero. For stability tests (random common cause, data subset) a correct model’s estimate should barely move.
passedencodes the appropriate per-test criterion;precisionis the refit standard error, reported so an underpowered “pass” is not oversold.-
name:
str
-
kind:
Literal['vanish','stable']
-
passed:
bool
-
description:
str
-
details:
str= ''
- __init__(name, kind, passed, description, original_effect=None, refuted_effect=None, refuted_ci_low=None, refuted_ci_high=None, precision=None, channel=None, details='')
-
name:
- class mmm_framework.validation.ValidationSummary(model_name, validation_date=<factory>, convergence=None, ppc=None, residuals=None, channel_diagnostics=None, model_comparison=None, cross_validation=None, sensitivity=None, stability=None, calibration=None, unobserved_confounding=None, causal_refutation=None, overall_quality='acceptable', critical_issues=<factory>, warnings=<factory>, recommendations=<factory>, _full_y_actual=None)[source]
Bases:
objectComprehensive validation summary.
Aggregates all validation results with overall assessment.
-
model_name:
str
-
validation_date:
str
-
convergence:
ConvergenceSummary|None= None
-
ppc:
PPCResults|None= None
-
residuals:
ResidualDiagnosticsResults|None= None
-
channel_diagnostics:
ChannelDiagnosticsResults|None= None
-
model_comparison:
ModelComparisonResults|None= None
-
cross_validation:
CrossValidationResults|None= None
-
sensitivity:
SensitivityResults|None= None
-
stability:
StabilityResults|None= None
-
calibration:
CalibrationResults|None= None
-
unobserved_confounding:
UnobservedConfoundingSensitivity|None= None
-
causal_refutation:
CausalRefutationResults|None= None
-
overall_quality:
Literal['excellent','good','acceptable','poor'] = 'acceptable'
- to_html_report(include_charts=True)[source]
Generate HTML validation report.
- Return type:
Parameters¶
- include_chartsbool
Whether to include interactive Plotly charts.
Returns¶
- str
HTML report string.
- __init__(model_name, validation_date=<factory>, convergence=None, ppc=None, residuals=None, channel_diagnostics=None, model_comparison=None, cross_validation=None, sensitivity=None, stability=None, calibration=None, unobserved_confounding=None, causal_refutation=None, overall_quality='acceptable', critical_issues=<factory>, warnings=<factory>, recommendations=<factory>, _full_y_actual=None)
-
model_name:
- class mmm_framework.validation.ConvergenceSummary(divergences, rhat_max, ess_bulk_min, ess_tail_min, converged)[source]
Bases:
objectMCMC convergence summary.
-
divergences:
int
-
rhat_max:
float
-
ess_bulk_min:
float
-
ess_tail_min:
float
-
converged:
bool
- __init__(divergences, rhat_max, ess_bulk_min, ess_tail_min, converged)
-
divergences:
- class mmm_framework.validation.TestResult(test_name, statistic, p_value, passed, threshold, interpretation)[source]
Bases:
objectResult of a single statistical test.
-
test_name:
str
-
statistic:
float
-
p_value:
float
-
passed:
bool
-
threshold:
float
-
interpretation:
str
- __init__(test_name, statistic, p_value, passed, threshold, interpretation)
-
test_name:
- class mmm_framework.validation.PPCCheckResult(check_name, observed_statistic, replicated_mean, replicated_std, p_value, passed, description)[source]
Bases:
objectResult of a single posterior predictive check.
-
check_name:
str
-
observed_statistic:
float
-
replicated_mean:
float
-
replicated_std:
float
-
p_value:
float
-
passed:
bool
-
description:
str
- __init__(check_name, observed_statistic, replicated_mean, replicated_std, p_value, passed, description)
-
check_name:
- class mmm_framework.validation.PPCResults(checks, y_obs, y_rep)[source]
Bases:
objectPosterior predictive check results.
-
checks:
list[PPCCheckResult]
-
y_obs:
ndarray
-
y_rep:
ndarray
-
overall_pass:
bool
- __init__(checks, y_obs, y_rep)
-
checks:
- class mmm_framework.validation.ResidualDiagnosticsResults(test_results, residuals, fitted_values, acf_values, pacf_values, recommendations=<factory>)[source]
Bases:
objectResults from residual diagnostics.
-
test_results:
list[TestResult]
-
residuals:
ndarray
-
fitted_values:
ndarray
-
acf_values:
ndarray
-
pacf_values:
ndarray
-
overall_adequate:
bool
- __init__(test_results, residuals, fitted_values, acf_values, pacf_values, recommendations=<factory>)
-
test_results:
- class mmm_framework.validation.ChannelConvergenceResult(channel, rhat, ess_bulk, ess_tail, converged)[source]
Bases:
objectConvergence diagnostics for a single channel.
-
channel:
str
-
rhat:
float
-
ess_bulk:
float
-
ess_tail:
float
-
converged:
bool
- __init__(channel, rhat, ess_bulk, ess_tail, converged)
-
channel:
- class mmm_framework.validation.ChannelDiagnosticsResults(vif_scores, correlation_matrix, convergence_by_channel, identifiability_issues=<factory>, collinear_clusters=<factory>, condition_number=None, grouped_prior_recommendations=<factory>)[source]
Bases:
objectResults from channel diagnostics.
-
correlation_matrix:
DataFrame
-
collinear_clusters:
list[CollinearCluster]
-
multicollinearity_warning:
bool
-
convergence_warning:
bool
-
weak_identification_warning:
bool
- __init__(vif_scores, correlation_matrix, convergence_by_channel, identifiability_issues=<factory>, collinear_clusters=<factory>, condition_number=None, grouped_prior_recommendations=<factory>)
-
correlation_matrix:
- class mmm_framework.validation.CollinearCluster(channels, max_correlation, explanation)[source]
Bases:
objectA cluster of channels that are too collinear to identify separately.
When channels move together (spend is scaled up/down jointly), the data cannot attribute effect between them: their combined effect is identified but the per-channel split is not, regardless of confounding. The cluster’s per-channel ROIs should be read as a group, not individually.
-
max_correlation:
float
-
explanation:
str
- __init__(channels, max_correlation, explanation)
-
max_correlation:
- class mmm_framework.validation.GeoIdentificationDiagnostic(n_geos, channels, cv_threshold, caveat)[source]
Bases:
objectWhether cross-geo spend variation can support geo-level identification.
-
n_geos:
int
-
channels:
list[GeoSpendVariation]
-
cv_threshold:
float
-
caveat:
str
- property weak_channels: list[str]
Channels whose spend is too uniform across geos to inform inference.
- property has_identifying_variation: bool
- __init__(n_geos, channels, cv_threshold, caveat)
-
n_geos:
- class mmm_framework.validation.GeoSpendVariation(channel, cv_across_geos, sufficient)[source]
Bases:
objectCross-geo spend dispersion for a single channel.
-
channel:
str
-
cv_across_geos:
float
-
sufficient:
bool
- __init__(channel, cv_across_geos, sufficient)
-
channel:
- mmm_framework.validation.geo_spend_variation_diagnostic(model, cv_threshold=0.15)[source]
Report per-channel cross-geo spend variation for a fitted/built model.
- Return type:
GeoIdentificationDiagnostic
Parameters¶
- model
A model exposing
has_geo,n_geos,geo_idx(obs -> geo code),X_media_raw(n_obs x n_channels) andchannel_names.- cv_threshold
Minimum coefficient of variation of per-geo spend for a channel to be considered to carry geo-level identifying variation.
Raises¶
- ValueError
If the model is national (no geo dimension); the diagnostic only applies to multi-geo panels.
- class mmm_framework.validation.LOOResults(elpd_loo, se_elpd_loo, p_loo, pareto_k, n_bad_k, pointwise_elpd=None)[source]
Bases:
objectLeave-one-out cross-validation results (PSIS-LOO).
-
elpd_loo:
float
-
se_elpd_loo:
float
-
p_loo:
float
-
pareto_k:
ndarray
-
n_bad_k:
int
- __init__(elpd_loo, se_elpd_loo, p_loo, pareto_k, n_bad_k, pointwise_elpd=None)
-
elpd_loo:
- class mmm_framework.validation.WAICResults(waic, se_waic, p_waic, pointwise=None)[source]
Bases:
objectWAIC results.
-
waic:
float
-
se_waic:
float
-
p_waic:
float
- __init__(waic, se_waic, p_waic, pointwise=None)
-
waic:
- class mmm_framework.validation.ModelComparisonEntry(name, loo=None, waic=None)[source]
Bases:
objectSingle model entry for comparison.
-
name:
str
-
loo:
LOOResults|None= None
-
waic:
WAICResults|None= None
- __init__(name, loo=None, waic=None)
-
name:
- class mmm_framework.validation.ModelComparisonResults(models, loo_comparison=None, waic_comparison=None, stacking_weights=None)[source]
Bases:
objectResults from comparing multiple models.
-
models:
list[ModelComparisonEntry]
-
best_model:
str
- __init__(models, loo_comparison=None, waic_comparison=None, stacking_weights=None)
-
models:
- class mmm_framework.validation.CVFoldResult(fold_idx, train_size, test_size, rmse, mae, mape, r2, coverage, test_indices=None, y_true=None, y_pred_mean=None, y_pred_ci_low=None, y_pred_ci_high=None)[source]
Bases:
objectResult for a single cross-validation fold.
-
fold_idx:
int
-
train_size:
int
-
test_size:
int
-
rmse:
float
-
mae:
float
-
mape:
float
-
r2:
float
-
coverage:
float
- __init__(fold_idx, train_size, test_size, rmse, mae, mape, r2, coverage, test_indices=None, y_true=None, y_pred_mean=None, y_pred_ci_low=None, y_pred_ci_high=None)
-
fold_idx:
- class mmm_framework.validation.CrossValidationResults(strategy, n_folds, fold_results)[source]
Bases:
objectResults from cross-validation.
-
strategy:
str
-
n_folds:
int
-
fold_results:
list[CVFoldResult]
-
mean_rmse:
float
-
std_rmse:
float
-
mean_mae:
float
-
mean_mape:
float
-
mean_r2:
float
-
mean_coverage:
float
- __init__(strategy, n_folds, fold_results)
-
strategy:
- class mmm_framework.validation.SensitivityResults(base_estimates, variant_estimates, sensitivity_indices, robust_parameters=<factory>, sensitive_parameters=<factory>)[source]
Bases:
objectResults from sensitivity analysis.
- __init__(base_estimates, variant_estimates, sensitivity_indices, robust_parameters=<factory>, sensitive_parameters=<factory>)
- class mmm_framework.validation.BootstrapResults(n_bootstrap, parameter_means, parameter_stds, parameter_ci_low, parameter_ci_high)[source]
Bases:
objectResults from parametric bootstrap.
-
n_bootstrap:
int
- __init__(n_bootstrap, parameter_means, parameter_stds, parameter_ci_low, parameter_ci_high)
-
n_bootstrap:
- class mmm_framework.validation.InfluenceResults(observation_influence, influential_indices, influence_threshold)[source]
Bases:
objectResults from leave-one-out influence analysis.
-
observation_influence:
ndarray
-
influence_threshold:
float
- __init__(observation_influence, influential_indices, influence_threshold)
-
observation_influence:
- class mmm_framework.validation.StabilityResults(bootstrap_results=None, influence_results=None, influential_observations=<factory>, stability_score=1.0)[source]
Bases:
objectResults from stability analysis.
-
bootstrap_results:
BootstrapResults|None= None
-
influence_results:
InfluenceResults|None= None
-
stability_score:
float= 1.0
- __init__(bootstrap_results=None, influence_results=None, influential_observations=<factory>, stability_score=1.0)
-
bootstrap_results:
- class mmm_framework.validation.LiftTestResult(channel, test_period, measured_lift, lift_se, holdout_regions=None, confidence_level=0.95)[source]
Bases:
objectExternal lift test result for calibration.
-
channel:
str
-
measured_lift:
float
-
lift_se:
float
-
confidence_level:
float= 0.95
- __init__(channel, test_period, measured_lift, lift_se, holdout_regions=None, confidence_level=0.95)
-
channel:
- class mmm_framework.validation.LiftTestComparison(channel, model_estimate, model_ci_low, model_ci_high, experimental_estimate, experimental_se, within_ci, relative_error)[source]
Bases:
objectComparison of model estimate to lift test.
-
channel:
str
-
model_estimate:
float
-
model_ci_low:
float
-
model_ci_high:
float
-
experimental_estimate:
float
-
experimental_se:
float
-
within_ci:
bool
-
relative_error:
float
- __init__(channel, model_estimate, model_ci_low, model_ci_high, experimental_estimate, experimental_se, within_ci, relative_error)
-
channel:
- class mmm_framework.validation.CalibrationResults(lift_test_comparisons, coverage_rate, mean_absolute_calibration_error)[source]
Bases:
objectResults from calibration check.
-
lift_test_comparisons:
list[LiftTestComparison]
-
coverage_rate:
float
-
mean_absolute_calibration_error:
float
-
calibrated:
bool
- __init__(lift_test_comparisons, coverage_rate, mean_absolute_calibration_error)
-
lift_test_comparisons:
- class mmm_framework.validation.Validatable(*args, **kwargs)[source]
Bases:
ProtocolProtocol for models that can be validated.
All model types (BayesianMMM, NestedMMM, MultivariateMMM, CombinedMMM) should satisfy this protocol to be used with the validation package.
- property model: pm.Model
Access the underlying PyMC model.
- property n_obs: int
Number of observations in the dataset.
- predict(**kwargs)[source]
Generate predictions from the fitted model.
- Return type:
- __init__(*args, **kwargs)
- class mmm_framework.validation.HasMediaData(*args, **kwargs)[source]
Bases:
ProtocolProtocol for models with media spend data.
- property X_media: ndarray
Media spend data matrix (n_obs, n_channels).
- property y: ndarray
Target variable array.
- __init__(*args, **kwargs)
- class mmm_framework.validation.HasPanelData(*args, **kwargs)[source]
Bases:
ProtocolProtocol for models with panel dataset.
- property panel: Any
PanelDataset containing the model data.
- __init__(*args, **kwargs)
- class mmm_framework.validation.HasScalingParams(*args, **kwargs)[source]
Bases:
ProtocolProtocol for models with scaling parameters.
- property y_mean: float
Mean of target variable used for standardization.
- property y_std: float
Standard deviation of target variable used for standardization.
- __init__(*args, **kwargs)
- class mmm_framework.validation.HasControlData(*args, **kwargs)[source]
Bases:
ProtocolProtocol for models with control variables.
- __init__(*args, **kwargs)
Backtest¶
Rolling-origin backtesting and forecast-accuracy evaluation for BayesianMMM.
Answers the question every client asks and most MMM documentation dodges: “how accurate were the model’s forecasts on data it had not seen?”
The harness refits the model on an expanding training window, forecasts a fixed horizon past each training cutoff (a genuine out-of-time forecast, not an in-sample re-prediction), and grades the forecasts against the held-out actuals: point accuracy (MAPE, sMAPE, RMSE, MASE), interval calibration (empirical coverage of the central 50/80/95% prediction intervals), and skill relative to naive baselines (last-value and seasonal-naive).
Out-of-time prediction cannot go through BayesianMMM.predict: the model
graph bakes the seasonality lookup and trend scale over the training
periods, so future time indices are out of range. Instead
PosteriorForecaster replays the model’s forward pass in NumPy from
the posterior draws – the same approach as the cross-validation path in
mmm_framework.validation.validator, generalized to both adstock
parameterizations (the legacy fixed-alpha blend and the parametric
geometric/delayed/Weibull kernels) and all configured saturation types.
Adstock is convolved over the full spend history so carryover from the
training period flows into the forecast window correctly.
Scope (enforced with explicit errors, not silent wrong answers).
The forward pass reproduces the fitted mean exactly for the terms it supports,
and ForecastUnsupportedError refuses the rest at construction time,
so a caller cannot obtain a forecaster it is not allowed to trust:
supported — national and geo/product panels, every trend family, seasonality, parametric and legacy adstock, all saturation families, controls, per-geo media coefficients (
vary_media_by_geo), and the geo/product level offsets;refused — price and promotion levers, event/holiday effects, cross-channel interactions, reach/frequency channels, time-varying coefficients, and the multiplicative specification. Each of these contributes to the fitted mean and the forward pass cannot replay it, so dropping it would shift the forecast level while leaving the interval width untouched;
trend extrapolation is a stated policy, not a silent default — read
forecaster.trend_extrapolation.LINEARextrapolates in closed form; spline/GP/piecewise hold the last fitted level flat, so their intervals do not widen with horizon;experiment-calibration likelihood terms are dropped by the backtest refit (their estimands reference the full-period spend), so
run_backtest()refuses a calibrated model rather than reporting an uncalibrated model’s accuracy under the calibrated model’s name.
Before v1.3.1 the forward pass summed five of the fitted mean’s ten terms and
raised nothing, and the refit downcast any garden/custom model class to a plain
additive BayesianMMM. See the CHANGELOG entry for v1.3.1.
A good backtest validates the predictive model, not the causal one: a model can forecast well while attributing wrongly (and vice versa). Use this alongside – never instead of – the pressure-testing and calibration machinery.
Examples¶
>>> from mmm_framework.validation import BacktestConfig, run_backtest
>>> config = BacktestConfig(min_train_size=104, horizon=13, step=13)
>>> result = run_backtest(mmm, config) # mmm: an (unfitted) BayesianMMM
>>> result.summary() # model vs naive baselines
>>> result.by_horizon() # accuracy decay with lead time
>>> result.coverage() # interval calibration
- class mmm_framework.validation.backtest.BacktestConfig(min_train_size=104, horizon=13, step=None, max_origins=None, coverage_levels=(0.5, 0.8, 0.95), draws=500, tune=500, chains=4, include_noise=True, season_period=None, random_seed=42)[source]¶
Bases:
objectConfiguration for a rolling-origin backtest.
Attributes¶
- min_train_sizeint
Periods in the first training window. Two seasonal cycles of weekly data (104) is a sensible floor; below ~78 the seasonality and trend posteriors are barely informed and forecast intervals balloon.
- horizonint
Forecast length (periods) past each training cutoff.
- stepint or None
Spacing between consecutive training cutoffs.
Noneuseshorizon(non-overlapping forecast windows).- max_originsint or None
Cap on the number of refits (
None= as many as the data allows).- coverage_levelstuple[float, …]
Central prediction-interval levels to record and grade.
- draws, tune, chainsint
MCMC budget per refit. Backtests refit the model once per origin, so these default lower than a production fit; convergence is still recorded per origin in
BacktestResult.fits.- include_noisebool
Forecast the observable (mean + observation noise) rather than the latent mean. Keep
Truefor honest interval coverage.- season_periodint or None
Seasonal lag for the seasonal-naive baseline and the MASE scale.
Nonederives it from the data frequency (weekly -> 52).- random_seedint
Base seed; each origin offsets it deterministically.
- __init__(min_train_size=104, horizon=13, step=None, max_origins=None, coverage_levels=(0.5, 0.8, 0.95), draws=500, tune=500, chains=4, include_noise=True, season_period=None, random_seed=42)¶
- class mmm_framework.validation.backtest.BacktestResult(config, records, fits, season_period, mase_scales=<factory>)[source]¶
Bases:
objectRolling-origin backtest records plus summary views.
Attributes¶
- recordspd.DataFrame
One row per (origin, horizon) forecast:
origin,position,date,horizon,y_true,y_pred(posterior-mean),pred_naive,pred_snaive, per-level interval bounds (lo_80/hi_80, …) and coverage flags (cov_80, …).- fitspd.DataFrame
One row per refit origin: train size, wall-clock seconds, R-hat max, divergences. Check this before trusting the records.
-
config:
BacktestConfig¶
- property mape: float¶
Headline out-of-time MAPE of the MMM over all records (the number the docs quote). Convenience for
summary().loc["mmm", "mape"].
- __init__(config, records, fits, season_period, mase_scales=<factory>)¶
- exception mmm_framework.validation.backtest.ForecastUnsupportedError(feature, reason, all_unsupported=None)[source]¶
Bases:
NotImplementedErrorThe fitted model carries a term the forward pass cannot replay.
Raised instead of silently dropping the term, mirroring
mmm_framework.frequentist.design.UnsupportedModelError. Carriesfeatureso callers (the backtest harness, the agent, the REST job) can report which configuration blocked the forecast rather than a generic refusal, andall_unsupportedso a UI can list every blocker at once instead of making the user fix them one at a time.
- class mmm_framework.validation.backtest.PosteriorForecaster(model, *, strict=True)[source]¶
Bases:
objectOut-of-time forecasts from a fitted BayesianMMM’s posterior draws.
Replays the model’s structural forward pass (intercept, trend, seasonality, geo and product level offsets, adstock, saturation, controls, observation noise) in NumPy at arbitrary future period positions, using the full spend history for adstock carryover.
The replay is exact: over training positions,
forecast(include_noise= False)equals the sum of the model’s registered component Deterministics to floating-point tolerance. Terms it cannot replay are refused here, in the constructor, rather than at forecast time — so a caller cannot hold a forecaster whose output it is not allowed to trust.Parameters¶
- modelBayesianMMM
A fitted model, possibly trained on a prefix of the full series.
- strictbool, default True
When
True, an unreplayable term raisesForecastUnsupportedError.Falsedowngrades the refusal to a warning and records it onself.unsupported— for diagnostic use on a model you already know is incomplete. Never use it for planning: the resulting forecast omits a term the model estimated.
Attributes¶
- trend_extrapolationTrendExtrapolation
The stated policy for continuing the trend past the training window.
- unsupportedlist[tuple[str, str]]
Empty unless
strict=Falsesuppressed a refusal.
Raises¶
- ForecastUnsupportedError
The model carries a term the forward pass cannot replay.
- forecast(X_media_full_raw, X_controls_full_raw, positions, *, include_noise=True, random_seed=None, train_offset=0)[source]¶
Posterior predictive draws at absolute period
positions.- Return type:
Parameters¶
- X_media_full_rawnp.ndarray
Raw media, shape
(n_full, n_channels)– the FULL history (training + forecast periods) so adstock carryover is correct.- X_controls_full_rawnp.ndarray or None
Raw controls over the full history (required if the model has controls; future control values are assumed known/planned).
- positionsnp.ndarray
Absolute period positions (0-based on the full axis) to forecast.
- train_offsetint
Absolute position of the trained model’s first period. 0 for prefix training (the backtest); the window start for rolling-window clones (validator cross-validation).
Returns¶
- np.ndarray
Samples in original KPI scale, shape
(n_samples, len(positions)).
- class mmm_framework.validation.backtest.TrendExtrapolation(policy, trend_type, n_train_periods)[source]¶
Bases:
objectHow the forecaster continues the trend past the training window.
Recorded rather than assumed, because the three policies carry very different forecast semantics and only one of them is model-defined.
Attributes¶
- policy{‘none’, ‘linear’, ‘held_flat’}
held_flatis a heuristic: a spline/GP/piecewise basis has no out-of-time forecast, so the last fitted level is carried forward and the interval consequently does not widen with horizon.- trend_typestr
The configured trend family.
- n_train_periodsint
Training length. Load-bearing for
linear: the fitted slope is on at_scaled = pos / (n_train - 1)axis, so the same posterior implies a different slope per period depending on how long the training panel was. Reporting it makes that visible rather than surprising.
- property is_model_defined: bool¶
False when the continuation is a heuristic rather than the model’s.
- __init__(policy, trend_type, n_train_periods)¶
- mmm_framework.validation.backtest.rebuild_like(model, panel, **overrides)[source]¶
A fresh, unfitted model of
type(model)onpanel.The single place that reconstructs a model from another one. Preserving the CLASS and
model_paramsis load-bearing: hard-constructingBayesianMMMmeans a garden or custom model is fit and graded as a plain additive MMM and its results reported under the custom model’s name.- Return type:
Raises¶
- ForecastUnsupportedError
The class cannot be rebuilt from
(panel, model_config, trend_config, adstock_alphas, model_params)— e.g. a model declaringREQUIRED_DATASET_CAPABILITIESthe sliced panel no longer satisfies. Refusing beats falling back to a different model class.
- mmm_framework.validation.backtest.rolling_origins(n_periods, *, min_train_size, horizon, step=None, max_origins=None)[source]¶
Training cutoffs for a rolling-origin (expanding-window) backtest.
Each returned cutoff
Tmeans: train on periods[0, T), forecast periods[T, min(T + horizon, n_periods)). Only full forecast windows are emitted so every origin is graded on the same horizons.
- mmm_framework.validation.backtest.run_backtest(model, config=None, **fit_kwargs)[source]¶
Rolling-origin backtest of a BayesianMMM specification.
Refits the model’s exact configuration on an expanding training window, forecasts
config.horizonperiods past each cutoff withPosteriorForecaster, and grades against held-out actuals and naive baselines.- Return type:
Parameters¶
- modelBayesianMMM
The full-data model (does not need to be fitted); supplies the panel, configuration, and the raw media/control history.
- configBacktestConfig, optional
Backtest settings; defaults to
BacktestConfig().- **fit_kwargs
Extra arguments forwarded to each refit’s
fit()(e.g.progressbar=False).
Returns¶
BacktestResult
Spec Curve¶
Spec-curve / Bayesian model-averaging over a pre-registered spec set (#103).
There are dozens of defensible MMM specifications — adstock form, saturation form, control set, pooling scheme — and each can move a channel’s ROI. Landing on the single spec that gives the “nice” answer is the garden of forking paths. The honest alternative is to declare a set of defensible specs before seeing results, fit them all, and report how each channel’s ROI moves across the whole set. Robustness across specs is itself a headline; fragility across specs is a warning the single-spec number hides.
This module provides:
SpecVariant/SpecSet— a serializable, pre-registerable declaration of the spec set (store it in the design-readout / assumption log before the fit).apply_variant()— deep-merge a variant onto a base spec (the same spec dictmmm_framework.agents.fitting.build_and_fit()consumes).default_spec_variants()— the standard defensible grid (adstock × saturation forms, optionally pooling / prior mode) when the caller does not hand-pick one.run_spec_curve()— fit/collect every spec’s per-channel ROI posterior and produce a model-averaged (BMA) estimate with propagated uncertainty.SpecCurveResult— the per-spec ROI table, the applied weights, the BMA estimate, and a per-channel robustness summary;to_dict()feeds the report robustness section.
Weighting: predictive weights are not causal weights¶
The BMA step defaults to equal weights over the pre-registered set, not to LOO-stacking weights, and the distinction is methodological rather than cosmetic.
Stacking (Yao et al. 2018) chooses the mixture that maximizes expected
predictive utility — it answers “which combination forecasts held-out y
best?”. A spec curve averages a causal estimand (per-channel ROI), and the
two objectives come apart precisely where MMM specs differ. Two specs can
predict the KPI equally well while splitting that same fitted mean very
differently between media and baseline; predictive skill is close to
uninformative about which split is right, because the decomposition is the free
parameter. Worse, the direction can invert: a spec that overfits the confounder
block often predicts better while being less trustworthy for the causal
contrast, so stacking can systematically upweight the specs a causal analyst
should trust least.
Equal weights encode what pre-registration already asserted — every variant in
the set was declared defensible before the fit, so there is no post-hoc
predictive reason to promote one. Set weighting="stacking" to opt back in
(it warns), which is defensible only when every spec in the set is identified
for the same estimand and you genuinely want a predictive mixture. Stacking
weights are still computed and reported as predictive_weights whenever
compute_loo is on: divergence between them and equal weights is a useful
diagnostic (it says predictive fit discriminates between your specs), it is just
not a reason to follow them.
The ROI is computed with the framework’s canonical semantics (per-channel
contribution draws over the full window ÷ the measurement-aware divisor), so a
spec-curve number is directly comparable to the contribution_roi estimand.
Fitting and ROI extraction are injectable (fit_fn / roi_fn) so the engine
is unit-testable without MCMC.
- class mmm_framework.validation.spec_curve.SpecVariant(**data)[source]¶
Bases:
BaseModelOne defensible specification, expressed as overrides on a base spec.
The structured axes cover the four the review calls out (adstock form, saturation form, control set, pooling);
overridesis an escape hatch for any other spec key (deep-merged last).- name: str¶
- adstock: str | None¶
Adstock family applied to every media channel (“geometric”/”weibull”/”delayed”).
- saturation: str | None¶
Saturation family applied to every media channel (“hill”/”logistic”/”michaelis_menten”/”tanh”).
- controls: list[str] | None¶
Full replacement control set (channel-name list).
Nonekeeps the base’s.
- kpi_level: str | None¶
Pooling scheme (“national” / “geo”).
- media_prior_mode: str | None¶
Default media-prior parameterization (“roi” / “coefficient”).
- trend: str | None¶
Trend family (“none”/”linear”/”piecewise”/”spline”/”gaussian_process”).
- seasonality: dict[str, Any] | None¶
Seasonality override, e.g.
{"yearly": 4}(or{}to disable).
- overrides: dict[str, Any]¶
Arbitrary deep-merge overrides (applied last).
- primary: bool¶
Whether this is the pre-registered PRIMARY specification.
- description: str¶
- model_config: ClassVar[ConfigDict] = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class mmm_framework.validation.spec_curve.SpecSet(**data)[source]¶
Bases:
BaseModelA pre-registered set of defensible specs, declared before results.
Serialize this (
model_dump) into the design-readout / assumption log before fitting so the robustness report is auditable — the spec set was fixed in advance, not chosen after seeing which answer looked nicest.- variants: list[SpecVariant]¶
- rationale: str¶
- registered_at: str | None¶
ISO timestamp of pre-registration (set by the caller; kept as data, not computed here so the module stays deterministic / clock-free).
- property primary_variant: SpecVariant | None¶
- model_config: ClassVar[ConfigDict] = {}¶
Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].
- class mmm_framework.validation.spec_curve.SpecFit(name, primary, roi=<factory>, roi_draws=<factory>, loo=None, weight=0.0, error=None)[source]¶
Bases:
objectOne spec’s realized ROI + model-fit summary.
- __init__(name, primary, roi=<factory>, roi_draws=<factory>, loo=None, weight=0.0, error=None)¶
- class mmm_framework.validation.spec_curve.SpecCurveResult(channels, specs, primary, hdi_prob, fits, weights, bma, robustness, weighting='equal', predictive_weights=<factory>)[source]¶
Bases:
objectSpec-curve outcome: per-spec ROI, applied weights, BMA, robustness.
-
predictive_weights:
dict[str,float]¶ LOO-stacking weights, reported for diagnosis even when not applied. Empty when
compute_loo=Falseor arviz could not run.
- __init__(channels, specs, primary, hdi_prob, fits, weights, bma, robustness, weighting='equal', predictive_weights=<factory>)¶
-
predictive_weights:
- mmm_framework.validation.spec_curve.apply_variant(base_spec, variant)[source]¶
Return a new spec =
base_specwithvariantapplied.The structured axes are applied first (adstock/saturation forms are set on every media channel; controls/pooling/prior-mode/trend/seasonality set the corresponding top-level keys), then
variant.overridesis deep-merged.- Return type:
- mmm_framework.validation.spec_curve.default_spec_variants(base_spec, *, adstock_forms=('geometric', 'weibull'), saturation_forms=('hill', 'logistic'), include_prior_mode=False)[source]¶
The standard defensible spec grid: adstock × saturation forms.
The base spec’s own (adstock, saturation) combination is marked
primary.include_prior_modeadds a coefficient-scale-prior sibling of the primary (the other big modelling fork). Callers may of course hand-write their ownSpecVariantlist instead.- Return type:
- mmm_framework.validation.spec_curve.run_spec_curve(base_spec, dataset_path, *, variants=None, hdi_prob=0.94, max_draws=400, random_seed=42, compute_loo=True, weighting='equal', fit_fn=None, roi_fn=None)[source]¶
Fit a spec set, collect per-channel ROI, and model-average across specs.
- Return type:
Parameters¶
- base_spec, dataset_path:
The base spec and the dataset file each variant is fit against.
- variants:
A
SpecSet, a list ofSpecVariant, orNone(usesdefault_spec_variants()).- hdi_prob, max_draws, random_seed:
Credible-interval mass, ROI-draw thinning, and seed.
- compute_loo:
Compute per-spec LOO and the diagnostic LOO-stacking weights. These are reported either way; whether they are applied is
weighting.- weighting:
"equal"(default) weights every non-failing spec equally — the pre-registration-consistent choice for averaging a causal estimand."stacking"applies LOO-stacking weights instead and emits a warning: stacking maximizes expected predictive utility, which is a different objective from validity for ROI. See the module docstring.- fit_fn, roi_fn:
Injection points for testing.
fit_fn(spec, dataset_path) -> model(default: build + fit, no serialization);roi_fn(model, channels, max_draws=, random_seed=) -> {channel: draws}(default:channel_roi_draws()).
- mmm_framework.validation.spec_curve.channel_roi_draws(model, channels, *, max_draws=400, random_seed=42)[source]¶
Per-channel ROI posterior draws over the full window.
Uses
model.sample_channel_contributions(KPI-unit per-draw contributions) summed over observations, divided by the measurement-aware divisor (resolve_channel_divisor— spend $ or efficiency volume) — the same numerator/denominator as thecontribution_roiestimand. Returns{channel: (n_draws,) ndarray}; a channel with a non-positive divisor is omitted.
- mmm_framework.validation.spec_curve.WEIGHTING_CAVEAT = {'equal': 'Model-averaged with equal weights over the pre-registered spec set. Every variant was declared defensible before the fit, so none is promoted on post-hoc grounds. Predictive (LOO-stacking) weights are reported alongside for diagnosis but deliberately not applied: stacking optimizes held-out prediction, and two specs can predict the KPI equally well while splitting it very differently between media and baseline — which is the quantity being averaged.', 'stacking': 'Model-averaged with LOO-stacking weights, which optimize expected PREDICTIVE utility, not validity for a causal estimand. Two specs can predict the KPI equally well while disagreeing sharply on the media/baseline split, and a spec that overfits the confounder block can predict better while being less trustworthy for ROI. Treat this mixture as a forecast-weighted summary, not a causal estimate.'}¶
Surfaced next to any model-averaged ROI so a reader knows what the mixture weights mean. See the module docstring for the full argument.
Calibration¶
Inference-calibration verification: LOO-PIT (predictive) + SBC (structural).
The framework’s headline claim is genuine uncertainty quantification — that the posterior intervals it reports have nominal coverage. This module machine-verifies that claim instead of merely asserting it:
loo_pit_check()— leave-one-out probability-integral-transform. From an existing fit’s posterior-predictive draws, the LOO-PIT values should be Uniform(0, 1) if the predictive distribution is calibrated. Cheap.simulation_based_calibration()— SBC (Talts et al. 2018). Draw “true” parameters from the prior, simulate data, fit, and record the rank of each true value within its posterior draws. Under a correctly-implemented inference engine those ranks are uniform. This validates the inference engine itself, on data generated from the model’s own prior — the strongest calibration check. It is EXPENSIVE (one fit per simulation), so it is a verification tool, not a per-fit step. The harness is model-agnostic (callbacks for prior/simulate/fit), so it can be unit-tested on a fast conjugate model and applied to the MMM offline.
- class mmm_framework.validation.calibration.LooPitResult(pit, ks_stat, ks_pvalue, calibrated, n)[source]¶
Bases:
object- __init__(pit, ks_stat, ks_pvalue, calibrated, n)¶
- mmm_framework.validation.calibration.loo_pit_check(*, y, y_hat, log_weights=None, alpha=0.05)[source]¶
LOO-PIT calibration check from observed values + posterior-predictive draws.
- Return type:
Parameters¶
- yarray (n_obs,)
Observed outcomes.
- y_hatarray (n_obs, n_samples) or (n_samples, n_obs)
Posterior-predictive draws (orientation auto-detected).
- log_weightsarray, optional
PSIS log-weights aligned to
y_hat. If omitted, uniform weights are used (this reduces LOO-PIT to ordinary PIT — still a useful check).- alphafloat
Significance level for the KS uniformity test.
calibratedisks_pvalue > alpha.
- class mmm_framework.validation.calibration.SBCResult(param_names, n_sims, normalized_ranks=<factory>, ks_pvalue=<factory>, calibrated=<factory>)[source]¶
Bases:
object- __init__(param_names, n_sims, normalized_ranks=<factory>, ks_pvalue=<factory>, calibrated=<factory>)¶
- mmm_framework.validation.calibration.simulation_based_calibration(sample_prior, simulate, fit, *, n_sims=100, seed=0, alpha=0.05)[source]¶
Simulation-based calibration (Talts et al. 2018), model-agnostic.
For each of
n_simsiterations: draw a “true” parameter set from the prior, simulate a dataset, fit it, and record where each true value falls within its posterior draws (the normalized rank). Under correctly-calibrated inference those normalized ranks are Uniform(0, 1); a per-parameter KS test flags miscalibration.Callbacks (each receives the shared
Generatorfor reproducibility): :rtype:SBCResultsample_prior(rng) -> {param: scalar}— one draw of the true parameters.simulate(theta, rng) -> data— synthetic data given the true params.fit(data, rng) -> {param: 1-D posterior draws}— keys must matchsample_prior.
Scalar parameters only (the common SBC case). Returns an
SBCResult.
Channel Diagnostics¶
Channel-level diagnostics for model validation.
Provides multicollinearity detection (VIF), per-channel convergence checks, and identifiability analysis.
- class mmm_framework.validation.channel_diagnostics.VIFCalculator[source]¶
Bases:
objectVariance Inflation Factor calculator.
VIF measures multicollinearity between media channels. VIF > 10 indicates problematic multicollinearity.
- class mmm_framework.validation.channel_diagnostics.ChannelConvergenceChecker[source]¶
Bases:
objectPer-channel MCMC convergence diagnostics.
Checks R-hat and ESS for channel-specific parameters.
- check(trace, channel_names, config)[source]¶
Check convergence for each channel.
Parameters¶
- traceaz.InferenceData
ArviZ InferenceData with posterior samples.
- channel_nameslist[str]
Names of media channels.
- configChannelDiagnosticsConfig
Configuration with thresholds.
Returns¶
- dict[str, ChannelConvergenceResult]
Convergence result for each channel.
- class mmm_framework.validation.channel_diagnostics.ChannelDiagnostics(model, config=None)[source]¶
Bases:
objectComprehensive channel-level diagnostics.
Combines VIF analysis, correlation matrix, and per-channel convergence.
Examples¶
>>> diagnostics = ChannelDiagnostics(model) >>> results = diagnostics.run_all() >>> print(results.summary())
- __init__(model, config=None)[source]¶
Initialize channel diagnostics.
Parameters¶
- modelAny
Fitted model with media data and trace.
- configChannelDiagnosticsConfig, optional
Configuration for diagnostics.
- run_all()[source]¶
Run all channel diagnostics.
- Return type:
ChannelDiagnosticsResults
Returns¶
- ChannelDiagnosticsResults
Comprehensive channel diagnostics results.
- mmm_framework.validation.channel_diagnostics.detect_collinear_clusters(correlation_matrix, names, threshold)[source]¶
Group variables into clusters that cannot be separately identified.
Two variables are linked when their absolute correlation exceeds
threshold; clusters are the connected components of that graph. A cluster of size >= 2 is weakly identified: the data sees their combined movement, not the per-variable split.- Return type:
list[CollinearCluster]