Planning¶
The measurement-loop planning engine: EIG/EVOI experiment priorities, experiment design, economics, the Pareto-front optimizer, and run history.
Decision layer on top of a fitted MMM: budget optimization, EIG/EVOI experiment prioritization, and experiment-design recommendation.
Import-light by design (numpy/pandas only) so it can run inside the agent’s
session kernels via model_ops.
- mmm_framework.planning.design_experiment(dataset_path, kpi, channel, *, design_key=None, method=None, **kwargs)[source]
Dispatch to the right design family (auto-recommended when omitted).
methodnames the analysis methodology (synthetic_control/tbr/gbr/did_mmt). It selects which estimator the read-out and economics use and, whendesign_keyis not given, infers the design family. The chosen method’s metadata is attached to the returned design dict.
- mmm_framework.planning.design_options(dataset_path, kpi, channel)[source]
What designs the data supports: geo designs need >= 4 geographies.
- mmm_framework.planning.geo_lift_design(dataset_path, kpi, channel, *, design='holdout', intensity_pct=50.0, n_pairs=None, duration=8, durations=(4, 6, 8, 10, 12, 16), randomize=True, seed=42)[source]
Design a geo lift study (or a matched-market DiD when randomize=False).
Pipeline — robust by construction, on the model’s own view of history: :rtype:
dict[str,Any]Residualize each geo’s KPI on the MMM’s baseline structure (trend + yearly seasonality + the channel’s spend response).
Match on residual co-movement + standardized model-feature distance; solve the pairing globally (minimum-weight perfect matching).
Randomize treatment within each pair (or largest-geo-treated for the observational matched-market variant).
Power: the analytic DiD curve is CALIBRATED against a placebo simulation — the actual estimator run over historical windows — so the reported SE/MDE reflects how this design behaves on this data, not just a formula. Covariate balance on the model’s features is reported.
- mmm_framework.planning.flighting_design(dataset_path, kpi, channel, *, amplitude_pct=50.0, block_weeks=2, duration=12, budget_neutral=True, levels=None, x0=None, seed=42)[source]
Block-randomized flighting schedule for a national series.
Historical spend is usually smooth and co-moves with demand — the model cannot tell the channel from the season. A budget-neutral pulse schedule (randomized block order) manufactures exogenous variance: identification the data finally pays for. Block length should be >= the channel’s adstock memory so carryover doesn’t smear the contrast.
levels(spend multipliers, e.g.(0.5, 1.0, 1.5, 2.0)) gives a MULTI-LEVEL schedule — ≥3 distinct levels let the experiment trace the response CURVE (so the marginal ROAS / saturation is identified), not just a single on/off contrast. Default (None) is the binary high/low pulse at±amplitude_pct.
- mmm_framework.planning.matched_pairs(kpi_wide, n_pairs=None, spend_wide=None)[source]
Model-structured matched pairs, best-match first.
Each pair carries
correlation(raw KPI — what naive matching would have scored),residual_correlation(co-movement AFTER removing trend/ seasonality/spend — what the DiD’s precision actually depends on),size_ratio, and the matchingdistance.
- class mmm_framework.planning.BudgetOptimizationResult(table, total_budget, expected_uplift, uplift_hdi, prob_positive_uplift, n_draws, notes=<factory>, per_draw_alloc=None, optimal_alloc=None, expected_regret=0.0, n_extrapolated=0, objective='mean', objective_label='expected KPI (posterior mean)', mode='fixed', shadow_price=None, marginal_roas=None)[source]
Bases:
objectOptimal allocation plus the decision-uncertainty diagnostics.
-
table:
DataFrame
-
total_budget:
float
-
expected_uplift:
float
-
prob_positive_uplift:
float
-
n_draws:
int
-
expected_regret:
float= 0.0
-
n_extrapolated:
int= 0
-
objective:
str= 'mean'
-
objective_label:
str= 'expected KPI (posterior mean)'
-
mode:
str= 'fixed'
- __init__(table, total_budget, expected_uplift, uplift_hdi, prob_positive_uplift, n_draws, notes=<factory>, per_draw_alloc=None, optimal_alloc=None, expected_regret=0.0, n_extrapolated=0, objective='mean', objective_label='expected KPI (posterior mean)', mode='fixed', shadow_price=None, marginal_roas=None)
-
table:
- class mmm_framework.planning.ResponseCurves(channel_names, multipliers, base_spend, contributions, obs_max_spend=None, n_obs=None)[source]
Bases:
objectPer-channel contribution as a function of spend, with posterior draws.
contributions[d, c, g] = total (window-summed) original-scale contribution of channel c at spend multiplier multipliers[g], under posterior draw d.
-
multipliers:
ndarray
-
base_spend:
ndarray
-
contributions:
ndarray
- property spend_grid: ndarray
(C, G) actual spend per channel at each multiplier.
- property max_obs_multiplier: ndarray | None
(C,) the largest CURRENT-spend multiple that stays within observed support:
max_per_obs_spend / mean_per_obs_spend. Scaling current spend past this pushes the average period beyond anything observed, so the response there is the saturation FORM extrapolating.Nonewhen the observed range was not captured.
- __init__(channel_names, multipliers, base_spend, contributions, obs_max_spend=None, n_obs=None)
-
multipliers:
- mmm_framework.planning.compute_response_curves(mmm, multipliers=None, max_draws=200, random_seed=None)[source]
Sample per-channel spend-response curves from the fitted model.
One posterior-predictive evaluation per multiplier (NOT per channel × multiplier): all channels are scaled together and read off the additive
channel_contributionsdeterministic.- Return type:
ResponseCurves
- mmm_framework.planning.optimize_budget(mmm=None, *, curves=None, total_budget=None, budget_change_pct=None, min_multiplier=0.0, max_multiplier=2.0, bounds=None, abs_bounds=None, groups=None, min_channel_spend=None, objective='mean', mode='fixed', value_per_kpi=None, n_steps=400, max_draws=200, random_seed=None)[source]
Find the budget allocation that maximizes the chosen KPI objective.
- Parameters:
curves (
ResponseCurves|None) – precomputed response curves (else sampled frommmm).total_budget (
float|None) – budget to allocate. Default: current total spend (pure reallocation).budget_change_pct(e.g.-10or15) scales the current total instead. Ignored inmode="free".max_multiplier (min_multiplier /) – per-channel spend bounds as multiples of CURRENT channel spend (floors/caps keep recommendations inside the range the model has evidence for; extrapolating far beyond observed spend is curve-fiction).
bounds (
dict[str,tuple[float,float]] |None) – per-channel{name: (lo_mult, hi_mult)}multiplier overrides.abs_bounds (
dict[str,tuple[float,float]] |None) – per-channel{name: (lo_$, hi_$)}ABSOLUTE dollar bounds (override the multiplier bounds for that channel) — #139.groups (
list[dict] |None) – portfolio constraints, a list of{"name", "channels": [...], "min_share"/"max_share": frac, "min_spend"/"max_spend": $}— e.g. “digital ≥ 40%” — #139.min_channel_spend (
float|dict[str,float] |None) – keep-every-channel-on floor (a scalar applied to all channels, or per-channel{name: $}) — #139.objective (
str) – the risk objective the allocator maximizes — ‘mean’ (posterior mean), ‘p<q>’ (downside q-th percentile, e.g. ‘p10’), or ‘cvar<a>’ (mean of the worst a% of draws, e.g. ‘cvar5’) (#139).mode (
str) –"fixed"spends the budget;"free"funds each channel to breakeven (marginal value = $1), the total becomes an output (#139).value_per_kpi (
float|None) – $ value of one KPI unit (for breakeven / marginal ROAS when the KPI is not already revenue). Default 1.0.n_steps (
int) – greedy increments (granularity of the default allocation).max_draws (
int) – posterior draws used for curves and stability analysis.
- Return type:
BudgetOptimizationResult
- mmm_framework.planning.objective_curves(curves, objective='mean')[source]
(C, G) response curve for a risk objective — the curve the allocator maximizes.
'mean'= posterior mean (risk-neutral);'p<q>'= the per-channel q-th percentile across draws (a conservative downside curve);'cvar<a>'= the per-channel mean of the worst a% of draws (expected shortfall). Per-channel quantiles are a conservative proxy for the joint portfolio quantile (they ignore cross-channel draw correlation), documented intechnical-docs/budget-optimizer-v2.md.- Return type:
- mmm_framework.planning.objective_label(objective='mean')[source]
Human-readable label for an objective, for reporting which one was used.
- Return type:
- mmm_framework.planning.budget_frontier(mmm=None, *, curves=None, budget_min=None, budget_max=None, n_points=12, objective='mean', min_multiplier=0.0, max_multiplier=2.0, bounds=None, abs_bounds=None, groups=None, min_channel_spend=None, value_per_kpi=None, max_draws=120, random_seed=42)[source]
Sweep a budget range → the efficient frontier (optimized return vs budget).
At each budget the constrained allocator maximizes the
objectivecurve; the resulting plan is evaluated across all posterior draws for a return band, and the discrete slope gives the marginal ROI (the next-dollar return at that budget). By default the sweep runs from 40% to 200% of current spend.- Return type:
FrontierResult
- mmm_framework.planning.goal_seek(mmm=None, *, curves=None, target_kpi, objective='mean', min_multiplier=0.0, max_multiplier=2.0, bounds=None, abs_bounds=None, groups=None, min_channel_spend=None, value_per_kpi=None, budget_max=None, tol=0.001, max_iter=40, max_draws=120, random_seed=42)[source]
Inverse solve: the minimum budget (and its mix) whose optimized plan reaches
target_kpitotal media contribution.The frontier return is monotone increasing and concave in budget, so a bisection on the optimized return finds the required budget. Reports the plan and
prob_hit_target— the posterior probability that the plan’s contribution actually clears the target (the honest answer to “will this budget hit my number?”).- Return type:
GoalSeekResult
- class mmm_framework.planning.FrontierResult(objective, objective_label, channel_names, current_total, current_return, points=<factory>, notes=<factory>)[source]
Bases:
objectThe efficient frontier plus the current operating point.
-
objective:
str
-
objective_label:
str
-
current_total:
float
-
current_return:
float
-
points:
list[FrontierPoint]
- __init__(objective, objective_label, channel_names, current_total, current_return, points=<factory>, notes=<factory>)
-
objective:
- class mmm_framework.planning.GoalSeekResult(target_kpi, objective, objective_label, channel_names, feasible, required_budget, allocation, expected_return, prob_hit_target, notes=<factory>)[source]
Bases:
objectThe minimum budget (and mix) that reaches a target KPI contribution.
-
target_kpi:
float
-
objective:
str
-
objective_label:
str
-
feasible:
bool
- __init__(target_kpi, objective, objective_label, channel_names, feasible, required_budget, allocation, expected_return, prob_hit_target, notes=<factory>)
-
target_kpi:
- mmm_framework.planning.default_reallocation(mmm, *, deviation=0.2, max_draws=150, random_seed=42)[source]
A conservative, report-ready default reallocation of the current budget.
This is the plan surfaced in a generated client report when the user has not run the Planner studio. It reallocates the current total spend (no budget change) across channels, but constrains every channel to within
±deviationof its current spend — default ±20% — so that:no channel is turned off (the floor is
1 - deviationof current spend, never zero), andno recommendation extrapolates beyond the spend range the model has evidence for — the response curves are sampled on a multiplier grid that stays inside
[1 - deviation, 1 + deviation](plus the1.0anchor), so the greedy allocator only ever interpolates within observed support.
- Parameters:
mmm (
Any) – a fitted model exposingX_media_raw/channel_names/sample_channel_contributions(thecompute_response_curves()contract).deviation (
float) – max fractional move per channel (0.20 ⇒ ±20%).max_draws (
int) – posterior draws for the curves and decision-uncertainty.random_seed (
int|None) – seed for reproducible curve sampling.
- Return type:
- Returns:
The report-ready
allocation_resultsdict (see_result_to_report_dict()), tagged withdeviation_cap.
- mmm_framework.planning.compute_response_curves_per_geo(mmm, multipliers=None, max_draws=200, random_seed=None)[source]
Per-geography spend-response curves from a fitted geo panel.
One posterior-predictive evaluation per multiplier (all geos scaled together, as in
compute_response_curves()); contributions are then grouped bymmm.geo_idxso each geography gets its ownResponseCurvesover that geography’s current spend. Requiresmmm.has_geo.
- mmm_framework.planning.combine_geo_curves(geo_curves)[source]
Flatten
{geo: ResponseCurves}into a singleResponseCurveswhose “channels” are"{geo_index} │ {channel}"arms, so the national greedy optimizer can allocate one budget jointly across every geo×channel arm. All inputs must share the same multipliers and channel ordering.- Return type:
ResponseCurves
- mmm_framework.planning.optimize_budget_by_geo(mmm, *, total_budget=None, budget_change_pct=None, min_multiplier=0.0, max_multiplier=2.0, bounds=None, n_steps=400, max_draws=200, random_seed=None)[source]
Allocate one national budget jointly across every (geo, channel) arm.
Builds per-geo curves, flattens them to arms, and reuses
optimize_budget()so the greedy marginal allocator, per-draw stability, and uplift diagnostics are identical to the national path. The returnedtablegains ageocolumn and thechannelcolumn holds the bare channel name. Per-channelboundsapply to that channel in every geography.- Return type:
BudgetOptimizationResult
- class mmm_framework.planning.PacingChannel(channel, planned, actual, divergence_pct, status, planned_series=<factory>, actual_series=<factory>)[source]
Bases:
objectOne channel’s pacing status.
-
channel:
str
-
planned:
float
-
actual:
float
-
divergence_pct:
float
-
status:
str
- __init__(channel, planned, actual, divergence_pct, status, planned_series=<factory>, actual_series=<factory>)
-
channel:
- class mmm_framework.planning.PacingResult(channels, periods, threshold, planned_total, actual_total, divergence_pct, flagged, outcome_delta=None, join='positional')[source]
Bases:
objectPortfolio + per-channel pacing, with the flagged (off-pace) channels.
-
channels:
list[PacingChannel]
-
threshold:
float
-
planned_total:
float
-
actual_total:
float
-
divergence_pct:
float
-
join:
str= 'positional' ‘label’ when plan and actual were joined on a shared period vocabulary, ‘positional’ when either side had none. Recorded rather than assumed: a positional join on labelled data was the pre-v1.4 defect.
- __init__(channels, periods, threshold, planned_total, actual_total, divergence_pct, flagged, outcome_delta=None, join='positional')
-
channels:
- mmm_framework.planning.compute_pacing(planned, actual, *, threshold=0.1)[source]
Compare a plan against actual delivery, per channel and overall.
Both inputs normalize to per-channel per-period arrays. When both sides carry real period labels the comparison joins by label, so a mid-flight upload covering weeks 5-8 is compared against the plan’s weeks 5-8. Inputs without labels fall back to positional truncation.
PacingResult.joinrecords which happened.- Return type:
- mmm_framework.planning.expected_outcome_delta(curves, planned_totals, actual_totals, *, hdi_prob=0.9)[source]
Expected KPI delta from the divergence, off the fitted response curves.
curvesis aResponseCurves(or any object exposingchannel_names,spend_grid(C,G)andcontributions(D,C,G)). For each posterior draw we read the channel’s contribution at the ACTUAL total vs the PLANNED total and sum the difference — turning parameter uncertainty into a credible interval on the outcome impact. Returns{"mean","lower","upper","planned_kpi","actual_kpi"}orNone.
- mmm_framework.planning.pacing_report(planned, actual, *, curves=None, threshold=0.1, hdi_prob=0.9)[source]
Full pacing assessment: divergence + (when
curvesgiven) the expected KPI impact of that divergence.- Return type:
- mmm_framework.planning.build_flighting_schedule(channel_budgets, n_periods, *, pattern='even', period_labels=None, calendar=None, front_load=0.65, pulse_on=1, pulse_off=1, seasonal=None, per_channel_pattern=None)[source]
Distribute each channel’s total budget across
n_periodsfuture periods.Returns a JSON-safe dict:
{ "pattern", "n_periods", "total_budget", "periods": [labels...], "channels": [names...], "schedule": [{"period": label, "<channel>": spend, ..., "total": tot}], "by_channel": {channel: [spend per period]}, }
per_channel_patternoverrides the globalpatternfor named channels (e.g. an always-on retainer line vs a pulsed promo line).
- mmm_framework.planning.recommend_experiments(mmm, *, curves=None, optimization=None, top_k=3, max_draws=200, random_seed=None, method='eig_evoi', evidence=None)[source]
Rank channels by experiment value and propose concrete designs.
Returns
(table, designs): a per-channel scoring table (all channels, sorted by priority) and a list of design dicts for thetop_kchannels.method=”eig_evoi” (default): priority is the normalized geometric mean of EIG (what the experiment teaches) and EVOI (what that learning is worth to the budget decision) — see
planning.priority. The table gains eig / evoi / quadrant columns and designs carry the priority snapshot.- method=”heuristic”: the transparent legacy score
spend_share × roas_cv × (1 + allocation_instability)
— money at stake, times relative ROAS uncertainty, amplified when the uncertainty visibly destabilizes the optimal allocation.
- class mmm_framework.planning.ChannelPriority(channel, spend, spend_share, roi_mean, roi_sd, roi_hdi_low, roi_hdi_high, sigma_exp, design_type, eig, eig_method, evoi, evoi_pct_budget, evpi_share, priority, quadrant, weeks_since_evidence=None, eig_decayed=None, retest_due=False)[source]
Bases:
objectOne row of the priority grid (JSON-safe via .to_dict()).
-
channel:
str
-
spend:
float
-
spend_share:
float
-
roi_mean:
float
-
roi_sd:
float
-
roi_hdi_low:
float
-
roi_hdi_high:
float
-
sigma_exp:
float
-
design_type:
str
-
eig:
float
-
eig_method:
str
-
evoi:
float
-
evoi_pct_budget:
float
-
evpi_share:
float
-
priority:
float
-
quadrant:
str
-
retest_due:
bool= False
- __init__(channel, spend, spend_share, roi_mean, roi_sd, roi_hdi_low, roi_hdi_high, sigma_exp, design_type, eig, eig_method, evoi, evoi_pct_budget, evpi_share, priority, quadrant, weeks_since_evidence=None, eig_decayed=None, retest_due=False)
-
channel:
- mmm_framework.planning.compute_experiment_priorities(mmm=None, *, curves=None, optimization=None, evidence=None, as_of=None, design_type=None, sigma_exp_overrides=None, roi_draws_overrides=None, half_life_overrides=None, eig_threshold=None, evoi_threshold=None, retest_threshold_nats=0.15, n_outcomes=48, n_steps=400, max_draws=200, random_seed=42)[source]
Per-channel EIG/EVOI priority grid + portfolio summary.
- Parameters:
evidence (
dict[str,dict] |None) –{channel: {"end_date": ISO, ...}}— the newest calibrated experiment per channel (sessions.latest_calibrated_evidence). Drives information decay and the re-test trigger.as_of (
str|None) – ISO date for the decay clock (default: today).design_type (
str|None) – assumed experiment design for sigma_exp; defaultgeo_holdoutwhen the model has geo structure, elsenational_pulse.evoi_threshold (eig_threshold /) – absolute quadrant splits; default is the median of each axis across channels.
- Return type:
Returns
(grid, portfolio)where portfolio carries v_current, EVPI, total budget, draws, and the thresholds used.
- class mmm_framework.planning.EvoiResult(v_current, evpi, n_draws)[source]
Bases:
objectPortfolio-level value-of-information summary.
-
v_current:
float
-
evpi:
float
-
n_draws:
int
- __init__(v_current, evpi, n_draws)
-
v_current:
- mmm_framework.planning.compute_evoi_for_channel(curves, channel_idx, roi_draws, sigma_exp, *, optimal_alloc=None, n_outcomes=48, n_steps=400, total_budget=None, lo_spend=None, hi_spend=None, outcome_draws=None, rng=None)[source]
EVOI of an experiment on one channel, by paired preposterior Monte Carlo.
For each simulated outcome
y_j = roi[d_j] + sigma_exp * z_j: reweight the posterior draws byw_d ∝ N(y_j | roi_d, sigma_exp), re-optimize the allocation on the REWEIGHTED mean curves, and score the per-outcome gain :rtype:floatgain_j = V_j(a_j*) - V_j(a*) with V_j(a) = E_{theta|y_j}[f(a, theta)]
against the no-experiment allocation
a*under the SAME reweighted posterior (the paired estimator: identical estimand by the tower property, but the common-mode outcome noise cancels, so EVOI cannot go negative from sampling luck — each gain_j >= 0 up to allocator discretization, enforced by a per-term floor).outcome_draws= (d_idx, z) lets the caller share common random numbers across channels so near-ties don’t rank-flip on MC noise. The caller should cap the result at EVPI.
- mmm_framework.planning.cpa_interval(lift, se_lift, cost, *, alpha=0.05)[source]
CPA point + interval by inverting the lift interval — and, for contrast, the naive symmetric delta-method interval that clients are usually shown.
Returns
status: :rtype:dict[str,Any]"bounded"— the lift interval is strictly positive; the CPA interval is[cost/lift_hi, cost/lift_lo](asymmetric: the upper arm is longer)."upper_unbounded"— the lift interval touches zero; only a lower CPA bound exists. The data cannot rule out an arbitrarily bad CPA."undefined"— the whole lift interval is ≤ 0; no positive-lift evidence, no CPA statement at all.
- mmm_framework.planning.cpa_power(cost, se_lift, true_lift, *, target_cpa=None, alpha=0.05)[source]
Power on the CPA scale.
With
target_cpa=None: the probability the test comes away with a bounded CPA interval at all (the lift interval clears zero) —Φ(true_lift/se − z). With a target: the probability the CPA interval’s upper end lands belowtarget_cpa, i.e. the test certifiesCPA ≤ target— equivalent to the estimated lift clearingcost/target + z·se.- Return type:
- mmm_framework.planning.max_detectable_cpa(cost, lift_mde)[source]
The WORST (highest) cost per conversion the design can still certify:
cost / MDE. A true CPA above this is indistinguishable from “the media did nothing” — the honest planning summary on the CPA scale.- Return type:
- mmm_framework.planning.simulate_cpa_distribution(true_lift, se_lift, cost, *, alpha=0.05, n_sims=20000, seed=0)[source]
Monte-Carlo the CPA readout a design of this precision would produce — the demonstration that the reciprocal is NOT well-behaved even when the lift is.
Simulates
lift̂ ~ N(true_lift, se_lift), formscpâ = cost/lift̂where the estimate is positive (non-positive draws are “no measurable conversions” — CPA undefined/infinite), and scores both interval constructions against the true CPA. Returns the draws (for plotting) plus skew diagnostics and empirical coverage.
- class mmm_framework.planning.EvoiSurrogate(k, delta, tau, sigma_anchors=())[source]
Bases:
objectCalibrated Gaussian EVOI surrogate
sigma → k·s(sigma)·Psi(delta/s).tauis the channel’s prior (pre-experiment) ROI sd;kanddeltaare fitted to the MC anchors. Call it with any design precision to price that design’s EVOI without another preposterior MC. Accurate BETWEEN the anchor sigmas; extrapolation beyond the weak anchor under-estimates.-
k:
float
-
delta:
float
-
tau:
float
- __init__(k, delta, tau, sigma_anchors=())
-
k:
- mmm_framework.planning.fit_evoi_surrogate(tau, anchors)[source]
Fit
(k, delta)ofEvoiSurrogateto MC anchors[(sigma_exp, evoi_mc), ...].Two usable anchors (distinct sigmas, both positive EVOI) pin both parameters by bisection on the anchor ratio; one usable anchor degrades to
delta = 0(thesurrogate_evoi()scaling). ReturnsNonewhentauis degenerate or no anchor has positive EVOI.- Return type:
- mmm_framework.planning.preposterior_sd_ratio(tau, sigma_exp)[source]
sd of the preposterior mean as a FRACTION of the prior sd
tau:sqrt(tau^2 / (tau^2 + sigma_exp^2))— 1 for a perfect experiment (sigma_exp → 0), → 0 for an uninformative one.- Return type:
- mmm_framework.planning.surrogate_evoi(evoi_ref, sigma_ref, sigma_new, tau, *, evpi=None)[source]
Single-anchor fallback: EVOI scaled by the preposterior-sd ratio
s(sigma_new)/s(sigma_ref)(thedelta = 0special case of the fitted surrogate — decision boundary at the prior mean). Preferfit_evoi_surrogate()with two anchors when you can afford the second MC evaluation; the single-anchor form decays too slowly for weak designs. Capped atevpiwhen given; 0 when the anchor or prior is degenerate.- Return type:
- mmm_framework.planning.compute_evpi(curves, *, total_budget=None, lo_spend=None, hi_spend=None, n_steps=400, per_draw_alloc=None, optimal_alloc=None)[source]
V_current and EVPI from the response curves.
Pass
per_draw_alloc/optimal_allocfrom aBudgetOptimizationResultto reuse its D greedy optimizations instead of recomputing them.- Return type:
- mmm_framework.planning.eig_gaussian(sigma_k, sigma_exp)[source]
Closed-form EIG (nats) for a Gaussian prior and Gaussian experiment.
- Return type:
- mmm_framework.planning.eig_monte_carlo(roi_draws, sigma_exp, *, n_outcomes=64, rng=None)[source]
Monte Carlo EIG for non-Gaussian ROI posteriors.
Prior-predictive outcomes
y_j = roi[d_j] + sigma_exp * z_j; for each, the posterior over draws is the importance reweightingw_d ∝ N(y_j | roi_d, sigma_exp)and entropy is approximated by the Gaussian proxy0.5 * ln(2*pi*e * Var)on the (weighted) draws — exact in the Gaussian limit, robust to skew where the closed form overstates.- Return type:
- mmm_framework.planning.sigma_exp_for_design(design_type, roi_median, *, floor=0.05)[source]
Design-grounded experiment sd on the ROI scale.
sigma_exp = precision(design) * max(|roi_median|, floor)— proportional to the channel’s ROI scale (a relative-precision design) but NEVER to its posterior uncertainty.- Return type:
- mmm_framework.planning.decayed_sigma(sigma_post, weeks_elapsed, half_life_weeks)[source]
Effective sd after
weeks_elapsed:sigma_eff^2(t) = sigma_post^2 * exp(lambda * t), lambda = ln2/half-life — sosigma_eff(t) = sigma_post * exp(0.5 * lambda * t).- Return type:
- mmm_framework.planning.channel_half_life(channel, overrides=None)[source]
Deprecated alias for
information_half_life().The old name did not say which half-life it meant, and planning sat one import away from the model-derived carryover half-life.
- Return type:
- mmm_framework.planning.information_half_life(channel, overrides=None)[source]
How fast evidence about a channel goes stale, in weeks.
This is an information-decay half-life, not a media-carryover one. It reads nothing from the model — it is a keyword table over the channel name (see
DEFAULT_HALF_LIVES_WEEKS) answering “when is this channel’s last read old enough to re-test?”.Do not confuse it with
mmm_framework.transforms.carryover.carryover_half_life(), which is per-draw, read from the fitted kernel, and answers “how long does a exposure keep working?”. The two legitimately disagree by ~10x, which is why this one is no longer calledchannel_half_life:planningexported two similarly-named half-lives for the same channel meaning different things.- Return type:
- mmm_framework.planning.reexperiment_due(sigma_post, weeks_elapsed, half_life_weeks, sigma_exp, *, threshold_nats=0.15, min_age_weeks=13.0)[source]
(due, current_eig): the EIG of a fresh experiment given the decayed effective uncertainty, and whether it crosses the operational threshold. Evidence younger than
min_age_weeksis never due (freshness floor).
- class mmm_framework.planning.OpportunityCostResult(channel, kpi, design_key, design_type, duration_requested, duration_effective, n_treated_cells, n_test_rows, n_draws, carryover_basis, expected_kpi_delta, kpi_delta_median, kpi_delta_p5, kpi_delta_p95, kpi_delta_with_carryover_median, forgone_kpi_median, forgone_kpi_p95, prob_kpi_loss, pct_of_window_kpi, spend_delta, abs_spend_change, spend_at_risk, margin_per_kpi, margin_source, kpi_kind, net_profit_impact_median, net_profit_impact_p5, net_profit_impact_p95, opportunity_cost_dollar_median, opportunity_cost_dollar_p95, prob_net_loss, prob_loss_over_threshold, loss_threshold, evoi_kpi_units, evoi_per_week, cost_per_week, learning_to_cost_ratio, learning_to_cost_basis, response_horizon_weeks, low_information, extrapolation_warning, warnings=<factory>, notes=<factory>, draws=None)[source]
Bases:
objectPer-draw short-term risk of a design, summarized. JSON-safe via to_dict.
-
channel:
str
-
kpi:
str
-
design_key:
str
-
design_type:
str
-
duration_requested:
int
-
duration_effective:
int
-
n_treated_cells:
int
-
n_test_rows:
int
-
n_draws:
int
-
carryover_basis:
str
-
expected_kpi_delta:
float
-
kpi_delta_median:
float
-
kpi_delta_p5:
float
-
kpi_delta_p95:
float
-
kpi_delta_with_carryover_median:
float
-
forgone_kpi_median:
float
-
forgone_kpi_p95:
float
-
prob_kpi_loss:
float
-
spend_delta:
float
-
abs_spend_change:
float
-
spend_at_risk:
float
-
margin_source:
str
-
kpi_kind:
str
-
learning_to_cost_basis:
str
-
low_information:
bool
-
extrapolation_warning:
bool
- __init__(channel, kpi, design_key, design_type, duration_requested, duration_effective, n_treated_cells, n_test_rows, n_draws, carryover_basis, expected_kpi_delta, kpi_delta_median, kpi_delta_p5, kpi_delta_p95, kpi_delta_with_carryover_median, forgone_kpi_median, forgone_kpi_p95, prob_kpi_loss, pct_of_window_kpi, spend_delta, abs_spend_change, spend_at_risk, margin_per_kpi, margin_source, kpi_kind, net_profit_impact_median, net_profit_impact_p5, net_profit_impact_p95, opportunity_cost_dollar_median, opportunity_cost_dollar_p95, prob_net_loss, prob_loss_over_threshold, loss_threshold, evoi_kpi_units, evoi_per_week, cost_per_week, learning_to_cost_ratio, learning_to_cost_basis, response_horizon_weeks, low_information, extrapolation_warning, warnings=<factory>, notes=<factory>, draws=None)
-
channel:
- mmm_framework.planning.compute_opportunity_cost(mmm, design, *, margin_per_kpi=None, kpi_kind='revenue', price=None, preferences=None, branding=None, loss_threshold=None, evoi_kpi_units=None, response_horizon_weeks=None, max_draws=200, random_seed=42, contrib_bau=None, contrib_exp=None, return_draws=False)[source]
Short-term risk of running
designon the fitted modelmmm.- Parameters:
design (
dict) – aplanning.design.design_experimentpayload (carries channel, kpi, design_key, intensity_pct / schedule, treatment_geos, duration).kpi_kind (margin_per_kpi / price /) – net-$ valuation inputs.
revenuemultiplies KPI by margin;unitsby margin*price;otherreports KPI units only. Margin also auto-resolves frombranding/preferenceseconomicsblocks.evoi_kpi_units (
float|None) – the experiment’s EVOI (planning.evoi, KPI-contribution units) for the learning-vs-cost ratio. Pass the EVPI-capped value.response_horizon_weeks (
int|None) – denominator forevoi_per_week(defaultmmm.n_periods).
- Return type:
- class mmm_framework.planning.ExperimentNetValue(channel, unit, basis, test_loss, test_loss_p5, test_loss_p95, net_profit_during_test, evoi_raw, evpi_cap, decay_factor, reallocation_gain, net_value, net_value_p5, net_value_p95, prob_net_positive, breakeven_horizon_weeks, horizon_weeks, half_life_weeks, margin_per_kpi, assumptions=<factory>, warnings=<factory>)[source]
Bases:
objectThe netted decision figure.
unitis ‘$’ when a margin resolved, else ‘KPI units’ (spend cannot be netted against KPI — noted in assumptions).-
channel:
str
-
unit:
str
-
basis:
str
-
horizon_weeks:
int
-
assumptions:
dict
- __init__(channel, unit, basis, test_loss, test_loss_p5, test_loss_p95, net_profit_during_test, evoi_raw, evpi_cap, decay_factor, reallocation_gain, net_value, net_value_p5, net_value_p95, prob_net_positive, breakeven_horizon_weeks, horizon_weeks, half_life_weeks, margin_per_kpi, assumptions=<factory>, warnings=<factory>)
-
channel:
- mmm_framework.planning.compute_experiment_net_value(*, channel, evoi_kpi_units, evpi_kpi_units=None, kpi_delta_draws=None, spend_delta=0.0, margin_per_kpi=None, response_horizon_weeks=26, half_life_weeks=None, discount_rate_annual=0.0, model_anchored=False, opportunity_cost_result=None)[source]
Net the reallocation gain against the test’s short-term loss.
- Parameters:
evoi_kpi_units (
float|None) – the experiment’s EVOI (KPI-contribution units) — pass the realized-precision value from the anchor loopback when available and setmodel_anchored=True.evpi_kpi_units (
float|None) – the EVPI ceiling; the gain is capped at it.kpi_delta_draws (
ndarray|None) – per-draw signed window KPI delta of running the test (fromcompute_opportunity_cost(..., return_draws=True)). Gives the net-value distribution +P(net > 0); omit for point-only.spend_delta (
float) – signed $ spend change over the window (negative = holdout savings).margin_per_kpi (
float|None) – $ per KPI unit (the opportunity-cost module’s resolved value).None→ KPI-units basis, spend excluded.half_life_weeks (
float|None) – information half-life (eig.information_half_life);Nonedisables the decay haircut.opportunity_cost_result (
Any) – anOpportunityCostResultto pull draws / spend / margin / half-life defaults from (fields explicitly passed win).
- Return type:
ExperimentNetValue
- mmm_framework.planning.model_anchored_effect(mmm, design, *, max_draws=100, random_seed=42, hdi_prob=0.9, contrib_bau=None, contrib_exp=None)[source]
The model-implied effect the design would produce, as a ROAS posterior.
Returns a JSON-safe dict with the incremental-ROAS draws (the estimand the experiment measures), its median/HDI, the expected incremental KPI, the average ROAS at current spend (context), and an extrapolation flag.
contrib_bau/contrib_explet a caller pass the (shared) posterior passes so the same BAU/experiment evaluation is not recomputed here and incompute_opportunity_cost.
- mmm_framework.planning.powered_to_detect(effect, power_curve, duration, se_roas, *, overpowered_ratio=2.0)[source]
Is the design powered to detect the model’s expected effect?
assuranceis the SIGNED two-sided power averaged over the effect posterior —mean_d[Phi(eff_d/se - z) + Phi(-eff_d/se - z)]— so a null channel scores ~alpha rather than being rewarded for posterior width (ANCHOR-3).prob_detectable(the MDE indicator) leads the verdict.
- mmm_framework.planning.realized_sigma_exp_for_anchor(incremental_roas_draws, se_roas, *, roi_floor=0.05, floor=1e-06, rel_lower=0.05, rel_upper=50.0)[source]
(sigma_exp, incremental_roas_draws) for the EIG/EVOI loopback.
sigma_expis the design’s realized ROAS SE, clamped so a flat-placebose_roas -> 0can’t return an absurd EIG and an exploded SE can’t silently zero a strong design’s EIG (ANCHOR-4). It pairs withincremental_roas_draws— the SAME estimand the experiment measures, not the average-ROAS posterior the priority grid uses by default (ANCHOR-1).
- mmm_framework.planning.methodology_leaderboard(dataset_path, kpi, channel, *, mmm=None, design=None, duration=8, alpha=0.05, target_mde_roas=None, spend_delta_window=0.0, expected_effect_total=None, fixed_pct_of_baseline=0.1, max_aa_windows=200, max_ab_windows=120, seed=42)[source]
Run A/A + A/B for every estimator the data supports on ONE fixed assignment, and rank them by validity → power → cost.
- mmm_framework.planning.run_aa_simulation(panel, estimator, assignment, *, duration, alpha=0.05, max_windows=200, seed=42, name=None)[source]
- Return type:
- mmm_framework.planning.run_ab_simulation(panel, estimator, assignment, injector, *, duration, aa_result, effect_grid=(0.25, 0.5, 0.75, 1.0, 1.5, 2.0), spend_delta_window=0.0, estimand_scale=1.0, max_windows=120, seed=42, name=None)[source]
- Return type:
- mmm_framework.planning.cooldown_weeks(mmm, channel, *, threshold=0.05, min_weeks=1, max_weeks=26, default_weeks=4)[source]
Weeks for the channel’s carryover to wash out below
thresholdof the impulse, from the fitted geometric-adstock decayalpha.After a holdout (or between flights) the channel’s effect decays as
alpha**k; the cool-down is the smallestkwithalpha**k < threshold— the time before the treated cells are back to true BAU and a post-test (or next-flight) reading is clean. It also sets the minimum flighting block length (a block shorter than the memory smears the contrast).
- mmm_framework.planning.evaluate_experiment_grid(mmm, dataset_path, kpi, channel, *, duration_min=4, duration_max=12, intensity_min=50.0, intensity_max=100.0, durations=None, scaling_intensities=None, include_holdout=True, footprints=('full', 'half'), amplitudes=None, margin=None, price=None, kpi_kind='revenue', power_target=0.8, net_value_axis=True, response_horizon_weeks=26, max_draws=80, random_seed=42)[source]
Evaluate a grid of candidate designs on FOUR objectives, all lower-better: MDE, power shortfall below
power_target(default 80%), short-term cost, and duration.When a margin is known and
net_value_axis(default), the cost objective is upgraded to the net value of testing: each candidate’s EVOI (priced by the Gaussian surrogate at its own design precision, from ONE anchored preposterior-MC EVOI per grid) is decayed overresponse_horizon_weeks, capped at EVPI, converted to $, and netted against the candidate’s signed short-term test loss — the Pareto axis becomes−net_valueand every candidate carriesnet_value/evoi_kpi/reallocation_gain. If the anchor cannot compute (no draw pairing, degenerate posterior), the axis degrades to the existing net-$ downside.The design space is bounded by RANGES the caller controls: durations in
[duration_min, duration_max]weeks and signed spend variations in[intensity_min, intensity_max]% (e.g.-100go dark …+150scale up; intensities at -100% render as a go-dark holdout). The optimizer auto-samples a few points within each range; pass explicitdurations/scaling_intensitiesto override the sampling.Reuses one pure-pandas power-curve call per (footprint, intensity) for the MDE of every duration, and one shared BAU posterior pass for the whole grid’s opportunity cost. Power is the model-anchored probability each design detects the expected effect (no extra posterior passes).
- mmm_framework.planning.pareto_front(cands)[source]
Indices of the non-dominated designs over (MDE, power shortfall, cost, duration). Designs with a non-finite objective can never be on the front.
- mmm_framework.planning.suggest_experiment(mmm, dataset_path, kpi, channel, *, margin=None, price=None, kpi_kind='revenue', duration_min=4, duration_max=12, intensity_min=50.0, intensity_max=100.0, durations=None, scaling_intensities=None, include_holdout=True, footprints=('full', 'half'), power_target=0.8, net_value_axis=True, response_horizon_weeks=26, max_draws=80, random_seed=42)[source]
Recommend a runnable experiment setup for
channeland return the full Pareto front (MDE × power shortfall belowpower_target× short-term cost × duration), over a design space bounded by the duration and spend-variation ranges. The recommended design carries its test/control groups (or flighting schedule), duration, intensity, and the adstock-derived cool-down.With a known margin the cost axis is the net value of testing (see
evaluate_experiment_grid()) — gain from the learning minus the short-term loss — so the front trades precision and power against the dollars the test actually creates or destroys.
Eig¶
Expected information gain (EIG) for channel experiments, plus the information-decay model that schedules re-experimentation.
EIG measures how much an experiment of a given precision would shrink the
posterior over a channel’s ROI. The Gaussian closed form follows from the
conjugate update: with prior N(mu, sigma_k^2) and an experiment observing
y ~ N(roi, sigma_exp^2), the posterior variance is
(1/sigma_k^2 + 1/sigma_exp^2)^-1 and the entropy reduction is
EIG = 0.5 * ln(1 + sigma_k^2 / sigma_exp^2) [nats]
sigma_exp must be DESIGN-GROUNDED (what precision the experiment can
actually achieve, given geo footprint / design type) — if it were derived from
sigma_k itself, every channel would have the same EIG and the ranking
would collapse.
Information decay: experimental evidence has a shelf life. The effective
uncertainty grows as sigma_eff^2(t) = sigma_post^2 * exp(lambda * t) with
lambda = ln(2) / half_life; when the EIG of a fresh experiment (computed
from the decayed sigma) crosses an operational threshold, the channel is due
for a re-test.
Import-light (numpy only) so it can run inside the session kernels.
- mmm_framework.planning.eig.sigma_exp_for_design(design_type, roi_median, *, floor=0.05)[source]¶
Design-grounded experiment sd on the ROI scale.
sigma_exp = precision(design) * max(|roi_median|, floor)— proportional to the channel’s ROI scale (a relative-precision design) but NEVER to its posterior uncertainty.- Return type:
- mmm_framework.planning.eig.eig_gaussian(sigma_k, sigma_exp)[source]¶
Closed-form EIG (nats) for a Gaussian prior and Gaussian experiment.
- Return type:
- mmm_framework.planning.eig.use_gaussian(roi_draws)[source]¶
Cheap normality gate: closed form unless the posterior is visibly skewed or heavy-tailed (|skew| >= 0.5 or excess kurtosis >= 1).
- Return type:
- mmm_framework.planning.eig.eig_monte_carlo(roi_draws, sigma_exp, *, n_outcomes=64, rng=None)[source]¶
Monte Carlo EIG for non-Gaussian ROI posteriors.
Prior-predictive outcomes
y_j = roi[d_j] + sigma_exp * z_j; for each, the posterior over draws is the importance reweightingw_d ∝ N(y_j | roi_d, sigma_exp)and entropy is approximated by the Gaussian proxy0.5 * ln(2*pi*e * Var)on the (weighted) draws — exact in the Gaussian limit, robust to skew where the closed form overstates.- Return type:
- mmm_framework.planning.eig.information_half_life(channel, overrides=None)[source]¶
How fast evidence about a channel goes stale, in weeks.
This is an information-decay half-life, not a media-carryover one. It reads nothing from the model — it is a keyword table over the channel name (see
DEFAULT_HALF_LIVES_WEEKS) answering “when is this channel’s last read old enough to re-test?”.Do not confuse it with
mmm_framework.transforms.carryover.carryover_half_life(), which is per-draw, read from the fitted kernel, and answers “how long does a exposure keep working?”. The two legitimately disagree by ~10x, which is why this one is no longer calledchannel_half_life:planningexported two similarly-named half-lives for the same channel meaning different things.- Return type:
- mmm_framework.planning.eig.channel_half_life(channel, overrides=None)[source]¶
Deprecated alias for
information_half_life().The old name did not say which half-life it meant, and planning sat one import away from the model-derived carryover half-life.
- Return type:
- mmm_framework.planning.eig.decayed_sigma(sigma_post, weeks_elapsed, half_life_weeks)[source]¶
Effective sd after
weeks_elapsed:sigma_eff^2(t) = sigma_post^2 * exp(lambda * t), lambda = ln2/half-life — sosigma_eff(t) = sigma_post * exp(0.5 * lambda * t).- Return type:
- mmm_framework.planning.eig.reexperiment_due(sigma_post, weeks_elapsed, half_life_weeks, sigma_exp, *, threshold_nats=0.15, min_age_weeks=13.0)[source]¶
(due, current_eig): the EIG of a fresh experiment given the decayed effective uncertainty, and whether it crosses the operational threshold. Evidence younger than
min_age_weeksis never due (freshness floor).
Evoi¶
Expected value of information (EVOI) for the budget decision.
EIG says how much an experiment teaches; EVOI says how much that learning is WORTH — the expected improvement in the budget allocation’s outcome from deciding with the experiment’s information instead of without it (preposterior analysis / EVSI for the allocation decision):
EVOI_k = E_y[ max_a E_{theta|y} f(a, theta) ] - max_a E_theta f(a, theta)
with the outer expectation over the prior predictive of the experiment outcome
y ~ N(roi_k, sigma_exp^2). EVPI (perfect information about everything) is
the upper bound: EVPI = E_d[f_d(a_d*)] - E_d[f_d(a*)].
Units: KPI contribution over the response-curve window — the same units as
optimize_budget’s expected_uplift.
Reuses the greedy allocator and per-draw machinery from planning.budget.
Import-light (numpy only) so it can run inside the session kernels.
- class mmm_framework.planning.evoi.EvoiResult(v_current, evpi, n_draws)[source]¶
Bases:
objectPortfolio-level value-of-information summary.
- __init__(v_current, evpi, n_draws)¶
- mmm_framework.planning.evoi.compute_evpi(curves, *, total_budget=None, lo_spend=None, hi_spend=None, n_steps=400, per_draw_alloc=None, optimal_alloc=None)[source]¶
V_current and EVPI from the response curves.
Pass
per_draw_alloc/optimal_allocfrom aBudgetOptimizationResultto reuse its D greedy optimizations instead of recomputing them.- Return type:
- mmm_framework.planning.evoi.compute_evoi_for_channel(curves, channel_idx, roi_draws, sigma_exp, *, optimal_alloc=None, n_outcomes=48, n_steps=400, total_budget=None, lo_spend=None, hi_spend=None, outcome_draws=None, rng=None)[source]¶
EVOI of an experiment on one channel, by paired preposterior Monte Carlo.
For each simulated outcome
y_j = roi[d_j] + sigma_exp * z_j: reweight the posterior draws byw_d ∝ N(y_j | roi_d, sigma_exp), re-optimize the allocation on the REWEIGHTED mean curves, and score the per-outcome gain :rtype:floatgain_j = V_j(a_j*) - V_j(a*) with V_j(a) = E_{theta|y_j}[f(a, theta)]
against the no-experiment allocation
a*under the SAME reweighted posterior (the paired estimator: identical estimand by the tower property, but the common-mode outcome noise cancels, so EVOI cannot go negative from sampling luck — each gain_j >= 0 up to allocator discretization, enforced by a per-term floor).outcome_draws= (d_idx, z) lets the caller share common random numbers across channels so near-ties don’t rank-flip on MC noise. The caller should cap the result at EVPI.
- mmm_framework.planning.evoi.preposterior_sd_ratio(tau, sigma_exp)[source]¶
sd of the preposterior mean as a FRACTION of the prior sd
tau:sqrt(tau^2 / (tau^2 + sigma_exp^2))— 1 for a perfect experiment (sigma_exp → 0), → 0 for an uninformative one.- Return type:
- mmm_framework.planning.evoi.surrogate_evoi(evoi_ref, sigma_ref, sigma_new, tau, *, evpi=None)[source]¶
Single-anchor fallback: EVOI scaled by the preposterior-sd ratio
s(sigma_new)/s(sigma_ref)(thedelta = 0special case of the fitted surrogate — decision boundary at the prior mean). Preferfit_evoi_surrogate()with two anchors when you can afford the second MC evaluation; the single-anchor form decays too slowly for weak designs. Capped atevpiwhen given; 0 when the anchor or prior is degenerate.- Return type:
- class mmm_framework.planning.evoi.EvoiSurrogate(k, delta, tau, sigma_anchors=())[source]¶
Bases:
objectCalibrated Gaussian EVOI surrogate
sigma → k·s(sigma)·Psi(delta/s).tauis the channel’s prior (pre-experiment) ROI sd;kanddeltaare fitted to the MC anchors. Call it with any design precision to price that design’s EVOI without another preposterior MC. Accurate BETWEEN the anchor sigmas; extrapolation beyond the weak anchor under-estimates.- __init__(k, delta, tau, sigma_anchors=())¶
- mmm_framework.planning.evoi.fit_evoi_surrogate(tau, anchors)[source]¶
Fit
(k, delta)ofEvoiSurrogateto MC anchors[(sigma_exp, evoi_mc), ...].Two usable anchors (distinct sigmas, both positive EVOI) pin both parameters by bisection on the anchor ratio; one usable anchor degrades to
delta = 0(thesurrogate_evoi()scaling). ReturnsNonewhentauis degenerate or no anchor has positive EVOI.- Return type:
Priority¶
The EIG/EVOI experiment-priority grid.
Combines EIG (what an experiment would teach — planning.eig) with EVOI (what that learning is worth to the budget decision — planning.evoi) into a per-channel priority grid with the 2×2 quadrant classification:
high EIG, high EVOI → test_now (decision-critical uncertainty) high EIG, low EVOI → learn_cheaply (informative, but the decision is robust) low EIG, high EVOI → monitor (high stakes, already precise — watch drift) low EIG, low EVOI → deprioritize
Composite score: priority = sqrt(eig_norm * evoi_norm) — a normalized geometric mean, so a channel cannot win on one axis alone.
When the experiment registry supplies per-channel evidence dates, information decay (planning.eig) yields the decayed EIG and the re-test trigger.
Import-light (numpy/pandas only) so it can run inside the session kernels.
- class mmm_framework.planning.priority.ChannelPriority(channel, spend, spend_share, roi_mean, roi_sd, roi_hdi_low, roi_hdi_high, sigma_exp, design_type, eig, eig_method, evoi, evoi_pct_budget, evpi_share, priority, quadrant, weeks_since_evidence=None, eig_decayed=None, retest_due=False)[source]¶
Bases:
objectOne row of the priority grid (JSON-safe via .to_dict()).
- __init__(channel, spend, spend_share, roi_mean, roi_sd, roi_hdi_low, roi_hdi_high, sigma_exp, design_type, eig, eig_method, evoi, evoi_pct_budget, evpi_share, priority, quadrant, weeks_since_evidence=None, eig_decayed=None, retest_due=False)¶
- mmm_framework.planning.priority.compute_experiment_priorities(mmm=None, *, curves=None, optimization=None, evidence=None, as_of=None, design_type=None, sigma_exp_overrides=None, roi_draws_overrides=None, half_life_overrides=None, eig_threshold=None, evoi_threshold=None, retest_threshold_nats=0.15, n_outcomes=48, n_steps=400, max_draws=200, random_seed=42)[source]¶
Per-channel EIG/EVOI priority grid + portfolio summary.
- Parameters:
evidence (
dict[str,dict] |None) –{channel: {"end_date": ISO, ...}}— the newest calibrated experiment per channel (sessions.latest_calibrated_evidence). Drives information decay and the re-test trigger.as_of (
str|None) – ISO date for the decay clock (default: today).design_type (
str|None) – assumed experiment design for sigma_exp; defaultgeo_holdoutwhen the model has geo structure, elsenational_pulse.evoi_threshold (eig_threshold /) – absolute quadrant splits; default is the median of each axis across channels.
- Return type:
Returns
(grid, portfolio)where portfolio carries v_current, EVPI, total budget, draws, and the thresholds used.
Design¶
Experiment design engine: turn “test this channel” into a runnable design.
Three design families, chosen by the data’s granularity:
Randomized geo lift (DMA/geo panels): model-structured matched pairs — each geo’s KPI is residualized on the MMM’s own baseline structure (trend + yearly seasonality + the channel’s spend response) and pairs are matched on RESIDUAL co-movement plus a standardized covariate distance on the model’s terms, solved as an optimal (blossom) matching. Treatment is RANDOMIZED within pairs (the randomization is what buys unconfoundedness). Power is a DiD analysis whose level is CALIBRATED against a placebo simulation (the estimator run over every historical window), with a covariate balance table.
Matched-market DiD (pseudo-experimental): the same matching + power machinery without randomization, for when treatment geos are dictated by the business — the placebo distribution calibrates how big a “lift” the pre-period produces by chance, and the caveat is explicit.
Randomized flighting (national data, or any single series): a budget-neutral, block-randomized on/off spend schedule that manufactures the exogenous variance a collinear history never provides — with the induced identification gain and an on/off-contrast power estimate.
All functions are pandas/numpy only (kernel-safe) and work straight off an MFF csv — no fitted model required, so the endpoint stays fast.
- mmm_framework.planning.design.load_design_frame(dataset_path, kpi, channel)[source]¶
Pivot an MFF csv into the matrices the designs need.
Returns
{kpi_wide, spend_wide, kpi_national, spend_national, periods, geos}where the*_wideframes are weeks × geos (None for national data: no Geography column, or a single geography).
- mmm_framework.planning.design.residualize_geo_panel(kpi_wide, spend_wide=None)[source]¶
Remove the model-explained structure from each geo’s KPI.
Geo-by-geo OLS of KPI on [intercept, trend, yearly Fourier(2), channel spend (standardized, when available)] — the deterministic skeleton of the MMM applied per geo. Returns
{"residuals": DataFrame weeks×geos, "features": DataFrame geos×feature}where features live on the model’s terms: level (log mean), trend_slope, seasonal_amplitude (Fourier coefficient norm), residual_sd, spend_share.
- mmm_framework.planning.design.matched_pairs(kpi_wide, n_pairs=None, spend_wide=None)[source]¶
Model-structured matched pairs, best-match first.
Each pair carries
correlation(raw KPI — what naive matching would have scored),residual_correlation(co-movement AFTER removing trend/ seasonality/spend — what the DiD’s precision actually depends on),size_ratio, and the matchingdistance.
- mmm_framework.planning.design.geo_lift_design(dataset_path, kpi, channel, *, design='holdout', intensity_pct=50.0, n_pairs=None, duration=8, durations=(4, 6, 8, 10, 12, 16), randomize=True, seed=42)[source]¶
Design a geo lift study (or a matched-market DiD when randomize=False).
Pipeline — robust by construction, on the model’s own view of history: :rtype:
dict[str,Any]Residualize each geo’s KPI on the MMM’s baseline structure (trend + yearly seasonality + the channel’s spend response).
Match on residual co-movement + standardized model-feature distance; solve the pairing globally (minimum-weight perfect matching).
Randomize treatment within each pair (or largest-geo-treated for the observational matched-market variant).
Power: the analytic DiD curve is CALIBRATED against a placebo simulation — the actual estimator run over historical windows — so the reported SE/MDE reflects how this design behaves on this data, not just a formula. Covariate balance on the model’s features is reported.
- mmm_framework.planning.design.flighting_estimand_ses(mults, mean_spend, sigma_y, *, x0=None)[source]¶
SEs of a flighting design’s three estimands from its spend levels.
Models the local channel response as
y_t = g(x_t) + noiseand fitsgwith the design’s spend regressors (a quadratic[1, x, x²]when ≥3 distinct levels are present, else a line). From the OLS covarianceσ²(XᵀX)⁻¹it reads off the SE of: :rtype:dict[str,Any] |Nonethe LEVEL
g(x₀)at the operating point → the contribution SE (and, divided byx₀, the average ROAS SE — the same estimate rescaled);the SLOPE
g'(x₀)→ the mROAS SE.
The marginal (tangent)
g'(x₀)is only identifiable with ≥3 distinct levels; a binary on/off yields a secant slope (se_mroasis still returned butmroas_identified=False). ReturnsNonefor a degenerate design (too few / near-collinear levels, non-finite inputs, ill-conditioned fit) so a meaningless SE never reaches the power math.x0overrides the operating point (pass the MODEL’s per-week spend so the ROAS/contribution posteriors and these SEs share one basis).
- mmm_framework.planning.design.flighting_design(dataset_path, kpi, channel, *, amplitude_pct=50.0, block_weeks=2, duration=12, budget_neutral=True, levels=None, x0=None, seed=42)[source]¶
Block-randomized flighting schedule for a national series.
Historical spend is usually smooth and co-moves with demand — the model cannot tell the channel from the season. A budget-neutral pulse schedule (randomized block order) manufactures exogenous variance: identification the data finally pays for. Block length should be >= the channel’s adstock memory so carryover doesn’t smear the contrast.
levels(spend multipliers, e.g.(0.5, 1.0, 1.5, 2.0)) gives a MULTI-LEVEL schedule — ≥3 distinct levels let the experiment trace the response CURVE (so the marginal ROAS / saturation is identified), not just a single on/off contrast. Default (None) is the binary high/low pulse at±amplitude_pct.
- mmm_framework.planning.design.design_options(dataset_path, kpi, channel)[source]¶
What designs the data supports: geo designs need >= 4 geographies.
- mmm_framework.planning.design.design_experiment(dataset_path, kpi, channel, *, design_key=None, method=None, **kwargs)[source]¶
Dispatch to the right design family (auto-recommended when omitted).
methodnames the analysis methodology (synthetic_control/tbr/gbr/did_mmt). It selects which estimator the read-out and economics use and, whendesign_keyis not given, infers the design family. The chosen method’s metadata is attached to the returned design dict.
Design Anchor¶
Anchor a design’s power analysis to the fitted model’s expected effect.
The pure-data designer (planning.design) reports an MDE — the smallest ROAS
effect a test could detect — but says nothing about whether the effect we
actually EXPECT clears that bar. This module closes that gap using the model:
model_anchored_effectperturbs ONLY the treated geo × test-window rows at the design’s full intensity and reads the incremental KPI straight off the model’s deterministicchannel_contributions(paired draws, F1). It does NOT interpolate the global response curve — that scales every geo uniformly and mis-states a treated subset under heterogeneous saturation (F8/ANCHOR-2).powered_to_detectcompares the design’s MDE to that model-implied effect posterior → a powered / underpowered / overpowered / inconclusive verdict, a signed two-sided assurance, and the duration that would reach power.realized_sigma_exp_for_anchorturns the design’s realized ROAS SE into thesigma_expthe EIG/EVOI engine expects, so the priority grid can use the ACTUAL design precision instead of a generic table.
numpy/pandas only — kernel-safe. Reuses the perturbation mechanics in
opportunity_cost so the anchor and the opportunity cost see the same rows.
- mmm_framework.planning.design_anchor.model_anchored_effect(mmm, design, *, max_draws=100, random_seed=42, hdi_prob=0.9, contrib_bau=None, contrib_exp=None)[source]¶
The model-implied effect the design would produce, as a ROAS posterior.
Returns a JSON-safe dict with the incremental-ROAS draws (the estimand the experiment measures), its median/HDI, the expected incremental KPI, the average ROAS at current spend (context), and an extrapolation flag.
contrib_bau/contrib_explet a caller pass the (shared) posterior passes so the same BAU/experiment evaluation is not recomputed here and incompute_opportunity_cost.
- mmm_framework.planning.design_anchor.powered_to_detect(effect, power_curve, duration, se_roas, *, overpowered_ratio=2.0)[source]¶
Is the design powered to detect the model’s expected effect?
assuranceis the SIGNED two-sided power averaged over the effect posterior —mean_d[Phi(eff_d/se - z) + Phi(-eff_d/se - z)]— so a null channel scores ~alpha rather than being rewarded for posterior width (ANCHOR-3).prob_detectable(the MDE indicator) leads the verdict.
- mmm_framework.planning.design_anchor.realized_sigma_exp_for_anchor(incremental_roas_draws, se_roas, *, roi_floor=0.05, floor=1e-06, rel_lower=0.05, rel_upper=50.0)[source]¶
(sigma_exp, incremental_roas_draws) for the EIG/EVOI loopback.
sigma_expis the design’s realized ROAS SE, clamped so a flat-placebose_roas -> 0can’t return an absurd EIG and an exploded SE can’t silently zero a strong design’s EIG (ANCHOR-4). It pairs withincremental_roas_draws— the SAME estimand the experiment measures, not the average-ROAS posterior the priority grid uses by default (ANCHOR-1).
Opportunity Cost¶
Short-term risk / opportunity cost of running an experiment, from the model.
A design perturbs the tested channel’s spend away from business-as-usual (BAU) over the test window — a holdout goes dark (-100%) in the treated cells, a scaling test lifts spend by +X%, a national flighting schedule pulses on/off. That perturbation changes the KPI during the test, and clients want to know the short-term cost of buying the measurement: how much KPI (and money) do we forgo by deviating from BAU?
We answer it from the fitted model, with posterior uncertainty:
Build
X_experiment— a copy of the training media matrix with ONLY the treated geo × test-window rows perturbed (the rest is byte-identical BAU).Read the counterfactual per-channel contributions for BOTH the BAU and the experiment media via
mmm.sample_channel_contributions. That node is a PyMC Deterministic (no observation noise), and identicalmax_drawsgives identical posterior thinning, so the two passes are draw-paired exactly — the per-draw KPI delta is exact, not a noisy difference (F1).Summarize the per-draw KPI delta into expected / worst-case loss, the probability of a loss, the deterministic spend delta, an optional net-$ business impact (when a margin/price is supplied), and a learning-vs-cost ratio against the experiment’s EVOI.
Sign conventions are load-bearing and unit-tested:
kpi_deltais SIGNED (negative = lost KPI).forgone_kpiis the non-negative downsidemax(0, -kpi_delta).spend_deltais SIGNED and computed INTERNALLY from the perturbed vs BAU matrices (negative for a holdout = money saved). We NEVER readdesign['weekly_spend_delta']— it is an absolute magnitude and would invert the holdout’s net (F6).net_profit_impact = margin * kpi_delta - spend_deltais SIGNED;opportunity_cost_dollar = max(0, -net_profit_impact)is the non-negative cost the client asked for.
numpy/pandas only — kernel-safe (runs inside the sandboxed session kernels).
- class mmm_framework.planning.opportunity_cost.OpportunityCostResult(channel, kpi, design_key, design_type, duration_requested, duration_effective, n_treated_cells, n_test_rows, n_draws, carryover_basis, expected_kpi_delta, kpi_delta_median, kpi_delta_p5, kpi_delta_p95, kpi_delta_with_carryover_median, forgone_kpi_median, forgone_kpi_p95, prob_kpi_loss, pct_of_window_kpi, spend_delta, abs_spend_change, spend_at_risk, margin_per_kpi, margin_source, kpi_kind, net_profit_impact_median, net_profit_impact_p5, net_profit_impact_p95, opportunity_cost_dollar_median, opportunity_cost_dollar_p95, prob_net_loss, prob_loss_over_threshold, loss_threshold, evoi_kpi_units, evoi_per_week, cost_per_week, learning_to_cost_ratio, learning_to_cost_basis, response_horizon_weeks, low_information, extrapolation_warning, warnings=<factory>, notes=<factory>, draws=None)[source]¶
Bases:
objectPer-draw short-term risk of a design, summarized. JSON-safe via to_dict.
- __init__(channel, kpi, design_key, design_type, duration_requested, duration_effective, n_treated_cells, n_test_rows, n_draws, carryover_basis, expected_kpi_delta, kpi_delta_median, kpi_delta_p5, kpi_delta_p95, kpi_delta_with_carryover_median, forgone_kpi_median, forgone_kpi_p95, prob_kpi_loss, pct_of_window_kpi, spend_delta, abs_spend_change, spend_at_risk, margin_per_kpi, margin_source, kpi_kind, net_profit_impact_median, net_profit_impact_p5, net_profit_impact_p95, opportunity_cost_dollar_median, opportunity_cost_dollar_p95, prob_net_loss, prob_loss_over_threshold, loss_threshold, evoi_kpi_units, evoi_per_week, cost_per_week, learning_to_cost_ratio, learning_to_cost_basis, response_horizon_weeks, low_information, extrapolation_warning, warnings=<factory>, notes=<factory>, draws=None)¶
- mmm_framework.planning.opportunity_cost.build_experiment_media(mmm, design, *, treated_mask, window_codes)[source]¶
(X_experiment, channel_idx, n_test_rows).
A float64 copy of
mmm.X_media_rawwith the tested channel column scaled ontreated_maskrows: holdout →max(0, 1+intensity/100)(=0 at -100), scaling →1+intensity/100, national_flighting → the per-period multiplier fromdesign['schedule']mapped through the window’s period codes. BAU everywhere else.
- mmm_framework.planning.opportunity_cost.compute_opportunity_cost(mmm, design, *, margin_per_kpi=None, kpi_kind='revenue', price=None, preferences=None, branding=None, loss_threshold=None, evoi_kpi_units=None, response_horizon_weeks=None, max_draws=200, random_seed=42, contrib_bau=None, contrib_exp=None, return_draws=False)[source]¶
Short-term risk of running
designon the fitted modelmmm.- Parameters:
design (
dict) – aplanning.design.design_experimentpayload (carries channel, kpi, design_key, intensity_pct / schedule, treatment_geos, duration).kpi_kind (margin_per_kpi / price /) – net-$ valuation inputs.
revenuemultiplies KPI by margin;unitsby margin*price;otherreports KPI units only. Margin also auto-resolves frombranding/preferenceseconomicsblocks.evoi_kpi_units (
float|None) – the experiment’s EVOI (planning.evoi, KPI-contribution units) for the learning-vs-cost ratio. Pass the EVPI-capped value.response_horizon_weeks (
int|None) – denominator forevoi_per_week(defaultmmm.n_periods).
- Return type:
Simulation¶
A/A and A/B simulation on historical data — to compare experiment methodologies on their REAL false-positive rate, power, MDE, and cost.
The pure-data designer reports an analytic MDE from a first-difference noise model. But real KPI series are autocorrelated, so that analytic standard error understates the spread of the estimator and the nominal false-positive rate inflates — sometimes several-fold. The only honest way to know how a methodology behaves on THIS data is to run it on the data:
A/A — slide the design’s estimator over historical windows where no treatment was applied. The estimator should return ~0; the spread of its estimates is the true null distribution. We report the empirical false-positive rate at the analytic decision rule (the inflation diagnostic) AND a design-calibrated critical value — the empirical
1-alphaquantile of|estimate|— that restores the nominal size. An estimator whose A/A FPR far exceeds alpha is INVALID for this data regardless of its nominal power.A/B — inject a KNOWN lift (the model’s predicted incremental KPI when a model is available, otherwise a fixed magnitude) onto the treatment arm of real historical windows and re-run the estimator. Sweeping a grid of effect sizes gives an EMPIRICAL power curve and MDE that respect the real noise structure. Power is scored at the calibrated critical value, so it is power at the correct size — not a number inflated by the same SE bug.
methodology_leaderboard runs A/A + A/B for several estimators on the SAME
fixed assignment (so the comparison isolates the estimator, not the
randomization draw) and ranks them: valid (FPR within tolerance), powered
(empirical MDE below the expected effect), and cheap (opportunity cost).
numpy/pandas only — kernel-safe. Reuses planning.design for the panel,
matching, and SE helpers.
- class mmm_framework.planning.simulation.Window(pre_slice, test_slice, t_pre, t_test)[source]¶
Bases:
object- __init__(pre_slice, test_slice, t_pre, t_test)¶
- class mmm_framework.planning.simulation.Assignment(kind, treatment_geos=(), control_geos=(), pairs=(), schedule_mult=(), seed=42)[source]¶
Bases:
objectA FROZEN treatment design, reused across every estimator so the methodology comparison isolates the estimator (SIM-6).
- __init__(kind, treatment_geos=(), control_geos=(), pairs=(), schedule_mult=(), seed=42)¶
- class mmm_framework.planning.simulation.EstimatorResult(estimate, se, spend_delta, n_eff)[source]¶
Bases:
object- __init__(estimate, se, spend_delta, n_eff)¶
- class mmm_framework.planning.simulation.AAResult(estimator, n_windows, n_eff_windows, null_mean, null_sd, crit_value, fpr_at_nominal, fpr_at_crit, fpr_ci, fpr_inflated, fpr_tolerance, null_method, status)[source]¶
Bases:
object- __init__(estimator, n_windows, n_eff_windows, null_mean, null_sd, crit_value, fpr_at_nominal, fpr_at_crit, fpr_ci, fpr_inflated, fpr_tolerance, null_method, status)¶
- class mmm_framework.planning.simulation.ABResult(estimator, per_effect, empirical_mde, empirical_mde_roas, mde_method, mde_ci, expected_effect, power_at_expected, null_sd, status)[source]¶
Bases:
object- __init__(estimator, per_effect, empirical_mde, empirical_mde_roas, mde_method, mde_ci, expected_effect, power_at_expected, null_sd, status)¶
- class mmm_framework.planning.simulation.SimPanel(kpi_wide, spend_wide, kpi_national, spend_national, residuals, periods, geos)[source]¶
Bases:
object- __init__(kpi_wide, spend_wide, kpi_national, spend_national, residuals, periods, geos)¶
- mmm_framework.planning.simulation.pooled_did_estimator(panel, assignment, window)[source]¶
- Return type:
- mmm_framework.planning.simulation.per_pair_did_estimator(panel, assignment, window)[source]¶
- Return type:
- mmm_framework.planning.simulation.regadj_geo_estimator(panel, assignment, window, *, ridge=1.0)[source]¶
Synthetic-control style: fit control weights on the pre-period, score the test-window gap. Robust to between-geo scale differences the pooled DiD ignores.
- Return type:
- mmm_framework.planning.simulation.national_onoff_estimator(panel, assignment, window)[source]¶
High-block vs low-block KPI contrast over the test window, using the design’s on/off labels. On un-injected history the contrast is null.
- Return type:
- mmm_framework.planning.simulation.build_geo_assignment(panel, *, n_pairs=None, seed=42)[source]¶
- Return type:
- mmm_framework.planning.simulation.build_national_assignment(*, duration, block_weeks=2, amplitude_pct=50.0, seed=42)[source]¶
- Return type:
- mmm_framework.planning.simulation.run_aa_simulation(panel, estimator, assignment, *, duration, alpha=0.05, max_windows=200, seed=42, name=None)[source]¶
- Return type:
- class mmm_framework.planning.simulation.LiftInjector(kind, expected_total, per_draw_total=None, spend_share=None, per_draw_total_list=<factory>)[source]¶
Bases:
objectAdds a known lift to the treatment arm of a window.
expected_totalis the lift magnitude at scale=1 (model-predicted when anchored).__call__returns(true_total, injected_kpi_wide)for the geo path or(true_total, injected_kpi_national)for national.- __init__(kind, expected_total, per_draw_total=None, spend_share=None, per_draw_total_list=<factory>)¶
- mmm_framework.planning.simulation.fixed_lift_injector(panel, assignment, *, duration, pct_of_baseline=None, kpi_units=None)[source]¶
Model-free injector: lift =
kpi_unitsorpct_of_baselineof the treatment arm’s mean window KPI. Distributed by spend share (clipped).- Return type:
- mmm_framework.planning.simulation.run_ab_simulation(panel, estimator, assignment, injector, *, duration, aa_result, effect_grid=(0.25, 0.5, 0.75, 1.0, 1.5, 2.0), spend_delta_window=0.0, estimand_scale=1.0, max_windows=120, seed=42, name=None)[source]¶
- Return type:
- mmm_framework.planning.simulation.methodology_leaderboard(dataset_path, kpi, channel, *, mmm=None, design=None, duration=8, alpha=0.05, target_mde_roas=None, spend_delta_window=0.0, expected_effect_total=None, fixed_pct_of_baseline=0.1, max_aa_windows=200, max_ab_windows=120, seed=42)[source]¶
Run A/A + A/B for every estimator the data supports on ONE fixed assignment, and rank them by validity → power → cost.
Experiment Optimizer¶
Suggest an experiment setup, and Pareto-optimize the design space.
Given a fitted MMM and a channel, this turns “test this channel” into a recommended, runnable setup — design family, intensity, duration, test/control groups (or a flighting schedule), and a cool-down period derived from the channel’s fitted adstock — by exploring a grid of candidate designs and ranking them on FOUR objectives the client cares about:
lowest MDE — the smallest ROAS effect the test can detect (precision),
highest statistical power (as a shortfall below an 80% target) to detect the model’s expected effect — for flighting designs the power is computed for the ROAS, contribution AND marginal-ROAS estimands separately (a multi-level spend schedule is what makes the marginal / saturation curve identifiable),
smallest short-term tradeoff — the opportunity cost of deviating from business-as-usual (forgone KPI, or net-$ downside when a margin is known),
shortest duration — weeks in market.
These trade off against each other (a longer, bigger test detects more but costs more), so there is no single best design — there is a Pareto front of non-dominated designs. We compute that front, then recommend the design at its “knee” that is also powered to detect the model’s expected effect.
Efficiency: the MDE for every duration comes from one pure-pandas power-curve call per (footprint, intensity); the opportunity cost reuses a single shared BAU posterior pass across the whole grid (only the perturbed pass is per-config).
numpy/pandas only — kernel-safe. Reuses planning.design (designs + power),
planning.opportunity_cost (short-term risk), and the fitted adstock.
- mmm_framework.planning.experiment_optimizer.cooldown_weeks(mmm, channel, *, threshold=0.05, min_weeks=1, max_weeks=26, default_weeks=4)[source]¶
Weeks for the channel’s carryover to wash out below
thresholdof the impulse, from the fitted geometric-adstock decayalpha.After a holdout (or between flights) the channel’s effect decays as
alpha**k; the cool-down is the smallestkwithalpha**k < threshold— the time before the treated cells are back to true BAU and a post-test (or next-flight) reading is clean. It also sets the minimum flighting block length (a block shorter than the memory smears the contrast).
- class mmm_framework.planning.experiment_optimizer.CandidateEval(index, design_key, mode, footprint, n_pairs, intensity_pct, duration, mde_roas, power_shortfall, tradeoff, tradeoff_basis, forgone_kpi_median, opportunity_cost_dollar_median, net_profit_impact_median, spend_at_risk, pct_of_window_kpi, duration_effective, powered, power=None, power_lower=None, power_target=0.8, power_breakdown=None, sigma_exp=None, evoi_kpi=None, reallocation_gain=None, net_value=None, net_value_basis=None, on_pareto=False, is_recommended=False, treatment_geos=<factory>, control_geos=<factory>, schedule=None, block_weeks=None, duration_requested=None, warnings=<factory>)[source]¶
Bases:
objectOne evaluated experiment design with its three Pareto objectives + the runnable setup. JSON-safe via
to_dict.- __init__(index, design_key, mode, footprint, n_pairs, intensity_pct, duration, mde_roas, power_shortfall, tradeoff, tradeoff_basis, forgone_kpi_median, opportunity_cost_dollar_median, net_profit_impact_median, spend_at_risk, pct_of_window_kpi, duration_effective, powered, power=None, power_lower=None, power_target=0.8, power_breakdown=None, sigma_exp=None, evoi_kpi=None, reallocation_gain=None, net_value=None, net_value_basis=None, on_pareto=False, is_recommended=False, treatment_geos=<factory>, control_geos=<factory>, schedule=None, block_weeks=None, duration_requested=None, warnings=<factory>)¶
- mmm_framework.planning.experiment_optimizer.evaluate_experiment_grid(mmm, dataset_path, kpi, channel, *, duration_min=4, duration_max=12, intensity_min=50.0, intensity_max=100.0, durations=None, scaling_intensities=None, include_holdout=True, footprints=('full', 'half'), amplitudes=None, margin=None, price=None, kpi_kind='revenue', power_target=0.8, net_value_axis=True, response_horizon_weeks=26, max_draws=80, random_seed=42)[source]¶
Evaluate a grid of candidate designs on FOUR objectives, all lower-better: MDE, power shortfall below
power_target(default 80%), short-term cost, and duration.When a margin is known and
net_value_axis(default), the cost objective is upgraded to the net value of testing: each candidate’s EVOI (priced by the Gaussian surrogate at its own design precision, from ONE anchored preposterior-MC EVOI per grid) is decayed overresponse_horizon_weeks, capped at EVPI, converted to $, and netted against the candidate’s signed short-term test loss — the Pareto axis becomes−net_valueand every candidate carriesnet_value/evoi_kpi/reallocation_gain. If the anchor cannot compute (no draw pairing, degenerate posterior), the axis degrades to the existing net-$ downside.The design space is bounded by RANGES the caller controls: durations in
[duration_min, duration_max]weeks and signed spend variations in[intensity_min, intensity_max]% (e.g.-100go dark …+150scale up; intensities at -100% render as a go-dark holdout). The optimizer auto-samples a few points within each range; pass explicitdurations/scaling_intensitiesto override the sampling.Reuses one pure-pandas power-curve call per (footprint, intensity) for the MDE of every duration, and one shared BAU posterior pass for the whole grid’s opportunity cost. Power is the model-anchored probability each design detects the expected effect (no extra posterior passes).
- mmm_framework.planning.experiment_optimizer.pareto_front(cands)[source]¶
Indices of the non-dominated designs over (MDE, power shortfall, cost, duration). Designs with a non-finite objective can never be on the front.
- mmm_framework.planning.experiment_optimizer.recommend(cands, front)[source]¶
The recommended design: among the powered designs on the front (or all front designs if none are powered), the ‘knee’ — the one closest to the ideal point in objectives normalized over the front.
- mmm_framework.planning.experiment_optimizer.suggest_experiment(mmm, dataset_path, kpi, channel, *, margin=None, price=None, kpi_kind='revenue', duration_min=4, duration_max=12, intensity_min=50.0, intensity_max=100.0, durations=None, scaling_intensities=None, include_holdout=True, footprints=('full', 'half'), power_target=0.8, net_value_axis=True, response_horizon_weeks=26, max_draws=80, random_seed=42)[source]¶
Recommend a runnable experiment setup for
channeland return the full Pareto front (MDE × power shortfall belowpower_target× short-term cost × duration), over a design space bounded by the duration and spend-variation ranges. The recommended design carries its test/control groups (or flighting schedule), duration, intensity, and the adstock-derived cool-down.With a known margin the cost axis is the net value of testing (see
evaluate_experiment_grid()) — gain from the learning minus the short-term loss — so the front trades precision and power against the dollars the test actually creates or destroys.
History¶
Per-run history metrics: the JSON snapshot each fit leaves behind so the Performance page can plot cycle-over-cycle trajectories (ROI CI contraction, budget-share migration, misallocation cost, portfolio marginal ROI) WITHOUT ever unpickling old models.
compute_run_metrics runs at fit time, kernel-side: it must touch only the
fitted model (no registry / DB access — that enrichment happens host-side in
mmm_framework.platform.history) and never fail a fit (callers wrap it in
try/except). One compute_response_curves pass is shared by the ROI draws,
the budget optimization, and the EIG/EVOI grid.
- mmm_framework.planning.history.compute_run_metrics(mmm, *, max_draws=200, n_outcomes=48, random_seed=42)[source]¶
JSON-safe per-run metrics snapshot (schema v2).
Per channel: spend & share, ROI posterior (mean/sd/5–95% interval/width), marginal ROI at current spend, optimal-vs-current share gap, allocation instability, and the EIG/EVOI priority fields. Portfolio: total spend, portfolio marginal ROI, the misallocation proxy (median uplift of the optimal vs current allocation), v_current/EVPI, and the mean CI width (the contraction series). Schema v2 adds
response_curves: the compact per-channel saturation curve (spend grid × mean/5–95% contribution) the UI needs to show why average and marginal ROAS diverge.
Identification¶
Structural-parameter identification design for multi-level flighting.
The reduced-form flighting power (planning.design.flighting_estimand_ses)
answers can the test measure the contribution / average-ROAS / marginal-ROAS.
This module answers a different, deeper question the client asked for: will the
next MMM refit, fed the manufactured exogenous spend variance, actually move the
channel’s STRUCTURAL parameters — the response-curve shape psi (saturation),
the carryover alpha (adstock), and the coefficient beta — off their
priors? Per transforms.adstock §3.6 those three are entangled on an
equifinality ridge and only weakly identified from smooth observational spend; a
schedule that spans spend levels (curvature → psi) and pulses sharply
(temporal contrast → alpha) is what breaks the ridge.
Method (a LOCAL Laplace design, kernel-safe — numpy + transforms only,
no model/pymc import; the fitted-model anchor is extracted by the caller and
passed in as plain arrays):
Reconstruct the model’s exact forward op on the NORMALIZED basis
x_norm = mult * op_spend / raw_max→ normalized-FIR adstock → logistic saturation1 - exp(-clip(lam*a, 0, 20))→beta * s * y_std(original KPI scale). This byte-mirrorsBayesianMMM.sample_channel_contributionsfor the v1-gated geometric + logistic parametric path.Build the per-week Jacobian
J_t = dc_t/dtheta(analyticdc/dbeta; central finite differences fordc/dalphaanddc/dpsiTHROUGH the realadstock_weights``+``apply_adstock, never the infinite-recursion analytic derivative). Take it over the EXPERIMENT-WINDOW INCREMENTAL rows only — the flighting contrast vs business-as-usual (J_tminus the BAU row) — so a flat schedule contributes nothing and the historical data is never double-counted.PROFILE OUT the nuisance baseline (intercept + linear trend + yearly Fourier) by residualizing every Jacobian column against it, so a schedule collinear with trend/season can’t earn phantom identification.
Experiment Fisher
F = (1/sigma^2) J_res^T J_res; posterior precisionF_post = F + diag(1/prior_var)(a DIAGONAL prior-precision ridge from the robust draw-based widths — NEVERinv(cov)of the skewed, banana-shaped structural posterior). Per-parameter posterior SD =sqrt(diag(inv(F_post))).
Honesty rails (all from the locked design review): identification is driven by
the smallest eigenvalue / per-parameter contraction (E-optimality), NEVER the
determinant; a parameter is only “claimed” when the design actually excites it
(alpha only with temporal contrast, psi only with >=3 in-support levels);
the result is an OPTIMISTIC UPPER BOUND on what a refit achieves and must never
solely drive a Pareto objective; degenerate / near-zero-gradient / over-condition
designs return None rather than a falsely-tight CI.
- mmm_framework.planning.identification.ar1_design_effect(residual, window, *, rho_max=0.95)[source]¶
AR(1) variance design-effect for a length-
windowaverage/contrast.Autocorrelated residuals inflate the variance of a windowed estimator beyond the i.i.d.
sigma^2/nrule bydeff = 1 + 2*sum_{k=1}^{L-1}(1 - k/L)*rho^k(Newey-West / batch-means form).rhois the lag-1 autocorrelation of the baseline-residualized series, clipped to[0, rho_max];deffis capped at the window lengthL(the fully-correlated bound). Multiplysigmabysqrt(deff)BEFORE the OLS covariance so an autocorrelated window cannot be labelled powered on an i.i.d. SE.
- mmm_framework.planning.identification.cross_channel_vif(target_window_spend, other_window_spend)[source]¶
Variance-inflation from co-spending channels over the scheduled window.
Regresses the test channel’s SCHEDULED window spend on the other channels’ actual window spend and returns
R2and the inflation1/(1-R2). The scheduled variance is randomized, but if other channels happen to co-move with it over the window the design’s effective contrast shrinks. Surface it and degrade power whenR2is high;other_window_spendis(L, n_other)orNone(single-channel → no inflation).
- mmm_framework.planning.identification.structural_jacobian(mults, op_spend, raw_max, y_std, theta, *, l_max=8, normalize=True, prior_sd=None, fd_rel=0.01, residualize=True)[source]¶
Residualized, BAU-incremental per-week Jacobian of channel contribution w.r.t.
theta = {beta, alpha, lam}for a multiplier schedule.Columns: analytic
dc/dbeta = s*y_std; central finite differences fordc/dalphaanddc/dlamthrough the real normalized-FIR adstock + logistic saturation. Each column is taken as the CONTRAST vs the BAU (all-ones) schedule and residualized against the nuisance basis. Rows where the saturation exponentlam*ais clamped (>20) are zeroed (the model’s own gradient is exactly 0 there). ReturnsNoneon non-finite/degenerate input.
- mmm_framework.planning.identification.structural_information(J, names, sigma, prior_sd)[source]¶
Experiment + posterior Fisher information from a residualized Jacobian.
F = (1/sigma^2) J^T J;F_post = F + diag(1/prior_var)(a diagonal prior-precision ridge from the robust draw widths — neverinv(cov)of the structural posterior). Returns per-parameter posterior SD, the smallest eigenvalue + its eigenvector (the equifinality direction the design fails to pin), the condition number, and a relative information gain.Noneif the inputs are degenerate.
- mmm_framework.planning.identification.structural_identification(mults, op_spend, raw_max, y_std, theta_draws, *, sigma_lo, sigma_hi, l_max=8, normalize=True, in_support=True, power_target=0.8, fd_rel=0.01)[source]¶
Per-parameter identification power / MDE / contraction for a multi-level flighting schedule — the structural (beta, alpha, psi=lam) block.
OPTIMISTIC UPPER BOUND on what the next refit achieves; never let it solely drive a Pareto objective.
theta_drawsare the channel’s flattened posterior draws (beta,alpha,lam);theta_hatis the median.sigma_lo(model sigma*y_std, optimistic floor) andsigma_hi(regression residual, pessimistic) bracket the noise; the headline power uses the conservativemax(sigma_lo, sigma_hi). A parameter is “claimed” only when the design is structurally eligible AND the experiment actually contracts it (an absolute floor):beta/alphaneed real contrast,lamneeds >=3 in-support distinct levels. When nothing is claimed (a flat / near-flat / nuisance-collinear schedule)binding_powerisNoneandidentifies_anythingisFalse— never a prior-driven false confidence. ReturnsNoneonly on degenerate INPUTS.
Pacing¶
In-flight pacing — actual-vs-plan loop (issue #107).
MMM is retrospective and the measurement loop re-tests on a decay cadence, but nothing connects a recommended plan to what’s actually being spent in-flight. Pacing — planned vs actual delivery — is where media planners live day-to-day, and between fits it is a blind spot: the recommendation is delivered and then goes dark until the next model run.
This module closes that loop with pure, testable numpy:
compute_pacing()— aligns a plan and actual delivery per channel/period, computes divergence, and flags channels pacing outside a threshold.expected_outcome_delta()— the expected KPI impact of the divergence, read off the fitted response curves with uncertainty (contribution at actual spend vs at planned spend, per posterior draw).pacing_report()— combines both into the report/agent payload.
The plan and the actuals can be given as a flighting schedule
({"schedule": [{"period", "<channel>": spend, ...}]}), a list of period rows,
a {channel: [per-period]} map, or a {channel: total} map — all normalize
to per-channel per-period arrays.
Alignment: when both sides carry real period labels the join is by label; inputs without a usable vocabulary fall back to positional truncation. Before v1.4 the claim above read “aligned by period” and was false — the join was always positional, so a mid-flight upload was compared against the wrong weeks.
- class mmm_framework.planning.pacing.PacingChannel(channel, planned, actual, divergence_pct, status, planned_series=<factory>, actual_series=<factory>)[source]¶
Bases:
objectOne channel’s pacing status.
- __init__(channel, planned, actual, divergence_pct, status, planned_series=<factory>, actual_series=<factory>)¶
- class mmm_framework.planning.pacing.PacingResult(channels, periods, threshold, planned_total, actual_total, divergence_pct, flagged, outcome_delta=None, join='positional')[source]¶
Bases:
objectPortfolio + per-channel pacing, with the flagged (off-pace) channels.
-
channels:
list[PacingChannel]¶
-
join:
str= 'positional'¶ ‘label’ when plan and actual were joined on a shared period vocabulary, ‘positional’ when either side had none. Recorded rather than assumed: a positional join on labelled data was the pre-v1.4 defect.
- __init__(channels, periods, threshold, planned_total, actual_total, divergence_pct, flagged, outcome_delta=None, join='positional')¶
-
channels:
- mmm_framework.planning.pacing.compute_pacing(planned, actual, *, threshold=0.1)[source]¶
Compare a plan against actual delivery, per channel and overall.
Both inputs normalize to per-channel per-period arrays. When both sides carry real period labels the comparison joins by label, so a mid-flight upload covering weeks 5-8 is compared against the plan’s weeks 5-8. Inputs without labels fall back to positional truncation.
PacingResult.joinrecords which happened.- Return type:
- mmm_framework.planning.pacing.expected_outcome_delta(curves, planned_totals, actual_totals, *, hdi_prob=0.9)[source]¶
Expected KPI delta from the divergence, off the fitted response curves.
curvesis aResponseCurves(or any object exposingchannel_names,spend_grid(C,G)andcontributions(D,C,G)). For each posterior draw we read the channel’s contribution at the ACTUAL total vs the PLANNED total and sum the difference — turning parameter uncertainty into a credible interval on the outcome impact. Returns{"mean","lower","upper","planned_kpi","actual_kpi"}orNone.
- mmm_framework.planning.pacing.pacing_report(planned, actual, *, curves=None, threshold=0.1, hdi_prob=0.9)[source]¶
Full pacing assessment: divergence + (when
curvesgiven) the expected KPI impact of that divergence.- Return type:
- mmm_framework.planning.pacing.DEFAULT_PACING_THRESHOLD = 0.1¶
Default divergence threshold (fraction of planned spend) above which a channel is flagged as off-pace.
Cpa¶
Cost-per-conversion (CPA) power math — the client metric that CANNOT be naively inverted from the lift.
CPA = cost / incremental conversions. The lift estimate is approximately
Gaussian, but its reciprocal is not: dividing a constant by a noisy quantity
whose interval approaches zero produces an extremely right-skewed ratio
distribution — the estimated CPA has a long expensive tail, its mean
overshoots the truth, and the symmetric “delta-method” interval
cpa ± z·(cost·se/lift²) under-covers (it can even go negative). Three
consequences this module encodes:
Maximum detectable CPA — the planning summary that DOES survive inversion. A design that can detect lift ≥ MDE can certify
CPA ≤ cost/MDE; larger (worse) CPAs are indistinguishable from “no effect”. Invert the detectable-lift bound, never the point estimate.Interval by inverting the lift bound — the CPA interval is the image of the lift interval under
x → cost/x(monotone on the positive half-line): asymmetric, and unbounded above whenever the lift interval touches zero (the data cannot rule out an infinite cost per conversion — reporting that honestly is the point).Power on the CPA scale — power to come away with a bounded CPA at all, or to certify
CPA ≤ target(equivalent to detectinglift ≥ cost/target).
numpy only (kernel-safe). Cost and lift can be on any per-unit basis as long as they share it (e.g. ghost ads: cost per treated user vs per-user lift).
- mmm_framework.planning.cpa.max_detectable_cpa(cost, lift_mde)[source]¶
The WORST (highest) cost per conversion the design can still certify:
cost / MDE. A true CPA above this is indistinguishable from “the media did nothing” — the honest planning summary on the CPA scale.- Return type:
- mmm_framework.planning.cpa.cpa_interval(lift, se_lift, cost, *, alpha=0.05)[source]¶
CPA point + interval by inverting the lift interval — and, for contrast, the naive symmetric delta-method interval that clients are usually shown.
Returns
status: :rtype:dict[str,Any]"bounded"— the lift interval is strictly positive; the CPA interval is[cost/lift_hi, cost/lift_lo](asymmetric: the upper arm is longer)."upper_unbounded"— the lift interval touches zero; only a lower CPA bound exists. The data cannot rule out an arbitrarily bad CPA."undefined"— the whole lift interval is ≤ 0; no positive-lift evidence, no CPA statement at all.
- mmm_framework.planning.cpa.cpa_power(cost, se_lift, true_lift, *, target_cpa=None, alpha=0.05)[source]¶
Power on the CPA scale.
With
target_cpa=None: the probability the test comes away with a bounded CPA interval at all (the lift interval clears zero) —Φ(true_lift/se − z). With a target: the probability the CPA interval’s upper end lands belowtarget_cpa, i.e. the test certifiesCPA ≤ target— equivalent to the estimated lift clearingcost/target + z·se.- Return type:
- mmm_framework.planning.cpa.simulate_cpa_distribution(true_lift, se_lift, cost, *, alpha=0.05, n_sims=20000, seed=0)[source]¶
Monte-Carlo the CPA readout a design of this precision would produce — the demonstration that the reciprocal is NOT well-behaved even when the lift is.
Simulates
lift̂ ~ N(true_lift, se_lift), formscpâ = cost/lift̂where the estimate is positive (non-positive draws are “no measurable conversions” — CPA undefined/infinite), and scores both interval constructions against the true CPA. Returns the draws (for plotting) plus skew diagnostics and empirical coverage.