Continuous Learning¶
Model-free geo response-surface learning: CCD designs, Thompson allocation, funding-line and ENBS stopping, Laplace knowledge-gradient design scoring.
Continuous sequential learning — a model-free geo response-surface bandit.
A self-contained Bayesian sequential-experimentation loop that allocates continuous budget across channels by repeatedly (1) fitting a response surface directly from designed experiment data, (2) choosing the most valuable next experiment, and (3) stopping when further testing no longer pays — without requiring a pre-fit MMM. The experiment’s designed cross-sectional variation identifies the surface, so the priors inform but the data dominates.
This complements (does not replace) the model-anchored planning layer in
mmm_framework.planning, which sits on top of a fitted
BayesianMMM. Here there is no observational model:
the loop learns from experiments alone.
Layers¶
surface— the differentiable JAX response surface (single source of truth, shared by the likelihood, the DGP, and the allocator).model— the NumPyro generative model + priors + thePosteriorcontainer andfit().design— central-composite geo cells (central_composite()) and geo assignment (assign_geos()).dgp— a syntheticTrueWorldwith causal ground truth, the recovery harnesssimulate_panel(), and the fantasy engine.planner— the allocator (allocate_under_sample(),thompson_wave()), the funding line (marginal_roas()), the stopping rule (expected_regret(),enbs()), and decision-aware EVSI (knowledge_gradient()).loop—LearningState(carry the posterior across waves) andrun_closed_loop()(the end-to-end demo / closure test).
The Hill activation matches SaturationType.HILL (slope = alpha,
sat_half = kappa), so a continuous-learning posterior is directly comparable
to a BayesianMMM Hill fit on the same channel.
See technical-docs/continuous-learning.md and assets/continous_learning.md.
- mmm_framework.continuous_learning.activation(spend, kappa, alpha)[source]
Beta-stripped Hill activation
f_c(s_c) in [0, 1), elementwise.- Parameters:
spend – scaled spend, shape
(K,)(non-negative; a shutoff cell is 0).kappa – half-saturation per channel, shape
(K,)(positive).alpha – Hill shape per channel, shape
(K,)(positive).
- Returns:
The activation fraction, shape
(K,).
- mmm_framework.continuous_learning.logistic(spend, lam)[source]
Exponential-saturation activation
f(s) = 1 - exp(-lam * s)in[0, 1).Smooth, strictly increasing, strictly concave (no inflection),
f(0)=0. One shape parameter per channel; the half-saturation point isln(2)/lam. Matches the framework’sSaturationType.LOGISTIC.
- mmm_framework.continuous_learning.hill_mixture(spend, kappa1, alpha1, kappa2, alpha2, w)[source]
A weighted sum of two Hill curves:
w f(κ1,α1) + (1-w) f(κ2,α2).Still smooth, monotone and saturating (
f(0)=0,->a value in[0,1)), but far more flexible than a single Hill — it can express a two-phase / shoulder shape (a low-κ, high-α component that switches on early plus a high-κ component that keeps rising) that a single Hill or a logistic curve cannot represent. Used as a true DGP to study model misspecification: fit it with a single Hill (mild) or a logistic (severe) and watch what breaks.
- mmm_framework.continuous_learning.monotone_spline(spend, w1, w2, w3, w4, w5, w6, w7, w8, w9)[source]
Shape-agnostic monotone-spline activation, values in
[0, 1).f(s) = sum_j w_j I_j(s / MSPLINE_S_MAX) / sum_j w_jwith positive weightsw1..w9(one per I-spline basis function; only the ratios matter). Smooth, strictly monotone on[0, MSPLINE_S_MAX),f(0) = 0, fully saturated at/afterMSPLINE_S_MAX. Early weights buy an early, steep rise; late weights buy a slow, late one — mixtures express two-phase and plateau shapes no single Hill or logistic can.
- mmm_framework.continuous_learning.monotone_spline_basis(spend)[source]
The
MSPLINE_Jmonotone I-spline basis functions atspend.Returns shape
spend.shape + (MSPLINE_J,); columnjisI_{j+1}(spend / MSPLINE_S_MAX), rising monotonically 0 -> 1 (earlier columns rise earlier). Useful for plotting and for understanding what the fitted weights mean.
- mmm_framework.continuous_learning.incremental(spend, beta, kappa, alpha, gamma)[source]
Incremental response to one scaled spend vector.
- Parameters:
spend – scaled spend, shape
(K,).beta – channel ceilings, shape
(K,)(non-negative).kappa – half-saturations, shape
(K,).alpha – Hill shapes, shape
(K,).gamma – upper-triangular interaction matrix, shape
(K, K)(zeros on and below the diagonal).
- Returns:
Scalar incremental response.
- mmm_framework.continuous_learning.response_curve(spend_matrix, beta, kappa, alpha, gamma)[source]
Incremental response for every row of a
(N, K)scaled-spend panel.A thin numpy-friendly wrapper over
incremental_batchreturning a plainjax.Arrayof shape(N,).
- mmm_framework.continuous_learning.surface_value(spend, beta, gamma, act_fn, shape)[source]
Incremental response for one spend vector under an arbitrary activation.
act_fn(spend, *shape) -> fcomputes the per-channel activations;shapeis a tuple of(K,)arrays in the activation’s parameter order. The interaction block is unchanged, so any activation drops straight in.
- mmm_framework.continuous_learning.surface_over_rows(spend_matrix, beta, gamma, act_fn, shape)[source]
surface_value()for every row of a(N, K)panel ->(N,).
- mmm_framework.continuous_learning.fit(data, *, channels, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', beta_scale=1.0, gamma_scale=0.8, num_warmup=500, num_samples=500, num_chains=2, seed=0, progress_bar=False, spend_ref=None, prior_scaling='auto', discount_half_life=None, spline_prior='iid')[source]
Fit the response surface to a geo-week panel and/or summary readouts.
- Parameters:
data (
dict[str,Any]) – the data contract —{"spend": (N, K), "geo_idx": (N,), "n_geo": int, "y": (N,)}(scaled spend, natural-unity), optionally with"summaries": list[dict](see the module header), an optional"period_idx": (N,)(0-based int; required whentime_effect="national"), or summaries-only ({"summaries": [...], "n_geo": 0}/ an empty(0, K)panel). At least one of panel rows / summaries required.pairs (
list[tuple[int,int]] |None) – interaction pairs to model (default: all upper-triangular pairs).pair_signs (
dict[tuple[int,int],str] |None) – sign-informed prior family per pair (default: all"weak"). Keys are normalized to(min, max)orientation; conflicting duplicate entries raise.likelihood (
str) – panel observation family —"normal"(default, byte-identical to the original graph),"studentt"for heavy-tailed robust continuous KPIs (a learned tail dfnujoinssigma; outlier geo-weeks stop dragging the surface), or"negbinomial"for count KPIs (ymust be non-negative integer counts; the summary block stays Gaussian either way). Link caveat for the planner: with"negbinomial"the marginal readouts (marginal_roas,expected_regret, free-mode allocation) differentiate the LATENT surfaceR, while the observable count mean issoftplus(mu)whose derivative issigmoid(mu)— so the count-scale marginal response issigmoid(mu) * dR/ds. Sincesigmoid(mu) ≈ 1whenevermu >> 1(typical counts), the readouts are asymptotically exact; nearmu ≈ 0(per-row means of a few counts) they OVERSTATE the count-scale marginal response by up to1/sigmoid(mu).fit()warns loudly when the observed count mean is below ~20. Fixed-budget Thompson argmax is unaffected (softplus is monotone).time_effect (
str) –"none"(default) or"national"— a zero-centered hierarchical per-period national shocktau_tadded to the panel mean (a non-empty panel requiresdata["period_idx"]; seemodel()). A summaries-only fit needs NO period identity: there are no panel rows fortau_tto index and a national per-period constant cancels in the lift difference, so the summary path is unchanged (no tau sites are sampled).gamma_scale (
float) – interaction prior scale — the most consequential knob; audit it withprior_sensitivity()(guide §8.2).spend_ref (
ndarray|None) – per-channel reference constant used to scale spend, carried on the returnedPosteriorfor dollar mapping (seeto_dollars()).prior_scaling (
str) –"auto"(default) derives the intercept/noise/effect prior scales from the evidence so natural-unit KPIs at any magnitude fit sanely;"unit"reproduces the original O(1) priors exactly (see_resolve_prior_scaling()).discount_half_life (
float|None) – information-decay half-lifehin WEEKS (None— the default — is the historical static fit, every row weighing the same forever). When set, each panel row’s likelihood is discounted by its age: the observation scale is inflated tosigma * exp(0.5 * lambda * age)withlambda = ln2 / h— the math page’s Eq. 22 decay clock moved INTO the likelihood (West–Harrison discounting; the count family power-scales the log-likelihood byexp(-lambda * age)instead, since it has no noise scale). Row ages come fromdata["row_age"]((N,)weeks,0= newest —LearningStatemaintains this automatically) or, failing that, are derived fromdata["period_idx"]asmax - period; a discounted panel fit with neither raises. Summaries may carry an"age_weeks"key — their SE is inflated by the same clock. The effect: effective sample size SATURATES instead of growing without bound, so the posterior keeps an honest variance floor when media behaviour drifts (a static fit’s bands shrink like 1/sqrt(rows) while the truth moves — narrow and wrong). Useestimate_half_life()to measurehfrom the programme’s own wave-to-wave drift; a measuredlambda ~ 0recovers full pooling.spline_prior (
str) – monotone-spline weight prior —"iid"(default, the historical independentLogNormal(0, 1)weights, byte-identical) or"pspline": a first-order random walk on the log-weights with a learned per-channel smoothnessw_tau(P-spline / adaptive-smoothing construction).tau -> 0collapses to the neutral near-linear ramp, so the EFFECTIVE basis dimension grows only as the designed cells earn it — the adaptive answer to the fixed-basis approximation-bias floor (math page Eq. 24) without hand-tuning the knot count. Only valid withactivation="monotone_spline"; the publicw1..wJsites are unchanged (the acquisition layer and planner need no changes).
- Return type:
- Returns:
A
Posteriorwith merged-chain numpy samples, R-hat/ESS diagnostics on the key parameters, anddiagnostics["evidence"] = {"n_rows", "n_summaries"}.
- class mmm_framework.continuous_learning.Posterior(samples, channels, pairs, pair_signs=<factory>, activation='hill', likelihood='normal', time_effect='none', spend_ref=None, diagnostics=<factory>)[source]
Bases:
objectPosterior draws plus the metadata the planner needs.
samplesholds plain numpy arrays keyed by site name (beta,kappa,alpha,A,sigma_a,a_geo,sigma, and onegamma_<ci>_<cj>per pair; the intercept/noise sites are absent for a summaries-only fit). Alikelihood="negbinomial"fit carriesphi(the NB concentration) instead ofsigma; alikelihood="studentt"fit carriesnu(the tail df) alongsidesigma; atime_effect="national"fit addssigma_tauandtau((draws, n_period)).spend_refis the per-channel reference constant used to scale spend — convert withto_dollars()/to_scaled().-
activation:
str= 'hill'
-
likelihood:
str= 'normal'
-
time_effect:
str= 'none'
- property n_channels: int
- property n_draws: int
- gamma_matrix(d)[source]
Assemble the
(K, K)upper-triangular gamma matrix for drawd.- Return type:
- draw_params(d)[source]
Per-draw params for the surface functions, activation-agnostic.
Returns
{beta, gamma, shape, act_fn, activation}whereshapeis a tuple of(K,)arrays in the activation’s parameter order andact_fnis its JAX activation. (For the Hill default the shape is(kappa, alpha).)
- gamma_summary()[source]
Per-pair posterior mean and 5/95 percentiles of the synergy.
- __init__(samples, channels, pairs, pair_signs=<factory>, activation='hill', likelihood='normal', time_effect='none', spend_ref=None, diagnostics=<factory>)
-
activation:
- mmm_framework.continuous_learning.default_pairs(n_channels)[source]
All upper-triangular channel pairs
(i, j)withi < j.
- mmm_framework.continuous_learning.pair_name(channels, pair)[source]
Posterior site name for a pair’s interaction,
gamma_<ci>_<cj>.- Return type:
- mmm_framework.continuous_learning.demote_channel(channels, name, *, pairs=None, base=None)[source]
Demote a non-randomizable channel (a walled garden) to main-effect-only.
Every interaction that touches
namebecomes"zero"(prior-dominated); its main effect is still identified. Flaggamma(., name)as the least-trustworthy parameter in any decision (guide §5.4).
- mmm_framework.continuous_learning.probe_pairs_excluding(channels, name, *, pairs=None)[source]
Drop the off-axis design cells for a demoted channel’s pairs.
- mmm_framework.continuous_learning.refit_fn_from_data(base_data, *, channels, pairs=None, pair_signs=None, activation='hill', likelihood='normal', beta_scale=1.0, gamma_scale=0.8, num_warmup=200, num_samples=200, num_chains=1, seed=7, prior_scaling='auto', time_effect='none')[source]
Build a
refit_fn(extra_spend, extra_geo_idx, extra_y) -> Posterior.The knowledge-gradient acquisition (
planner.knowledge_gradient()) fantasizes wave outcomes and refits; this closure appends the fantasy rows tobase_dataand runs a short NUTS chain. This is the expensive path — see guide §9.1 for the Laplace-update replacement in production.Requires a panel base dataset: fantasy observations are geo-week rows, so a summaries-only data dict has nothing to append them to.
likelihoodmust match the posterior the knowledge gradient is scoring: the fantasyyvalues are drawn from that same observation family (Gaussian, Student-t, or NB counts), so refitting them under a different family would score the design against a model nobody will run.time_effectother than"none"raisesNotImplementedError: the knowledge-gradient fantasy rows carry no period identity, so a nationaltau_tcannot be refit on the augmented panel. Aperiod_idxpresent inbase_datais DROPPED from the refit base for the same reason (the refit models no time effect, so the index is inert either way).
- mmm_framework.continuous_learning.central_composite(center, delta, probe_pairs)[source]
Build the CCD cells (rows = cells, columns = channels).
- Parameters:
- Return type:
- Returns:
A
(n_cells, K)array of non-negative scaled allocations, withn_cells = 1 + 2K + 2 * len(probe_pairs) + K.
- mmm_framework.continuous_learning.assign_geos(design, n_geo, rng, *, n_holdout=0, center=None, baseline=None)[source]
Assign CCD cells to geos — shuffled round-robin, or stratified/blocked.
Without a
baseline(the default, byte-identical to the historical behavior) cells are tiled round-robin and shuffled: balanced cell counts, zero covariate awareness, and the firstn_holdoutgeos (positional) become holdouts.With a
baseline(a per-geo covariate such as the pre-period KPI level, matched-market style) the assignment is a classic blocked randomization: geos are sorted by baseline, walked in blocks ofn_cells, and each block receives a random permutation of the cell indices (the ragged tail gets a random subset without replacement). Every cell’s geos are then spread evenly across the baseline distribution, so between-cell baseline means are nearly equal. Holdouts are carved FIRST and are stratum-aware too: exactlyn_holdoutevenly spaced positions in baseline-sorted order, so the status-quo counterfactual spans the baseline range AND honors the requested count (a strided pick would silently under-deliver whenevern_holdoutdoes not dividen_geoevenly).- Parameters:
design (
ndarray) – CCD cells, shape(n_cells, K).n_geo (
int) – number of geos.rng (
Generator) – a numpy random generator (caller owns the seed).n_holdout (
int) – holdn_holdoutgeos atcenterfor the test window (a status-quo counterfactual). Requirescenter.center (
ndarray|None) – the status-quo allocation for holdout geos, shape(K,).baseline (
Union[ndarray,Sequence[float],None]) – optional per-geo covariate, lengthn_geo, positionally aligned with the geo indices (the caller resolves geo ids).Nonekeeps the legacy shuffled round-robin path.
- Return type:
- Returns:
(geo_alloc, cell_idx)wheregeo_allocis(n_geo, K)(each geo’s test allocation) andcell_idxis(n_geo,)(the design-row index, or-1for a holdout geo).
- class mmm_framework.continuous_learning.TrueWorld(beta, gamma_pairs, channels, kappa=None, alpha=None, activation='hill', shape=<factory>, pairs=<factory>, a_level=4.0, sigma_a=1.0, phi_true=10.0, nu_true=4.0)[source]
Bases:
objectA known response surface for recovery / closure testing.
gamma_pairsis aligned topairs(one synergy value per pair); the matrix form is assembled on demand.A/sigma_aset the geo-intercept distribution. The activation is pluggable: pass Hillkappa/alpha(the default and back-compatible path), or setactivation+shapefor another family (e.g.activation="logistic",shape={"lam": …}).phi_trueis the NegativeBinomial concentration used when a simulation asks fornoise_family="negbinomial";nu_trueis the Student-t tail df used bynoise_family="studentt"(each ignored otherwise).-
beta:
ndarray
-
gamma_pairs:
ndarray
-
activation:
str= 'hill'
-
a_level:
float= 4.0
-
sigma_a:
float= 1.0
-
phi_true:
float= 10.0
-
nu_true:
float= 4.0
- property n_channels: int
- act_fn()[source]
The JAX activation for this world’s family.
- shape_tuple()[source]
Shape-parameter arrays in the activation’s parameter order.
- Return type:
- response_mean(spend_matrix)[source]
Mean incremental response for a
(N, K)spend panel (no noise).- Return type:
- __init__(beta, gamma_pairs, channels, kappa=None, alpha=None, activation='hill', shape=<factory>, pairs=<factory>, a_level=4.0, sigma_a=1.0, phi_true=10.0, nu_true=4.0)
-
beta:
- mmm_framework.continuous_learning.make_world(seed=0, channels=None)[source]
A reproducible, non-trivial 4-channel world for tests and the demo.
Channels
["Chatter", "Pulse", "Orbit", "Vibe"](generic social-network surfaces) with a mix of strong and weak main effects, distinct half- saturations and Hill shapes, and a synergy structure that includes a genuine cannibalization (negative gamma) and a complementarity (positive gamma) so recovery has to get signs right.- Return type:
- mmm_framework.continuous_learning.make_world_logistic(seed=0, channels=None)[source]
A known logistic (exponential-saturation) world, sibling of
make_world().Same channels and synergy structure, but each channel saturates as
f(s) = 1 - exp(-lam * s)— a concave curve with no S-shape — rather than a Hill. Demonstrates that the whole loop (fit, plan, stop, animate) is activation-agnostic. The saturation rateslamare chosen so half- saturationln(2)/lamlands in the same O(1) scaled range as the Hillkappaabove, keeping the two worlds comparable in scale.- Return type:
- mmm_framework.continuous_learning.make_world_hill_mixture(seed=0, channels=None)[source]
A known weighted-sum-of-two-Hills world (a misspecification stress test).
Each channel’s true response is
w·Hill(κ1,α1) + (1-w)·Hill(κ2,α2)with a low-κ, high-α early component (a soft activation threshold) plus a high-κ later component — a two-phase / shoulder shape a single Hill can only average over and a logistic (concave, no inflection) cannot represent at all. Fit it withactivation="hill"(mild misspecification) or"logistic"(severe) to see what a wrong response family does to the recovered curve, the funding line, and the recommended allocation.- Return type:
- mmm_framework.continuous_learning.simulate_panel(world, center, *, n_geo=80, t_pre=6, t_test=10, delta=0.6, probe_pairs=None, noise=0.6, noise_family='normal', tau_scale=0.0, n_holdout=0, a_geo=None, adstock_alpha=None, adstock_l_max=8, stratify=False, seed=0)[source]
Simulate a CCD wave against a known world.
Returns the data contract (
spend,geo_idx,n_geo,y,period_idx) plusdesign,geo_alloc,cell_idx,a_geo,tau_trueandanswer_key. Pass an existinga_geo(and the sameseedoffset) to simulate a later wave over the same geos with their baselines intact.period_idxis always provided (np.repeat(arange(t_pre + t_test), n_geo)— the row order is week-major, pre block then test block); a fit only uses it when it opts intotime_effect="national".noise_family="negbinomial"draws count outcomes (gamma–Poisson with meansoftplus(mu)and concentrationworld.phi_true); the default"normal"path is byte-identical to the old Gaussian draw.tau_scale > 0adds true national per-period shockstau_t ~ Normal(0, tau_scale)tomu(0.0leavesybyte-identical and draws nothing).adstock_alphaadds carryover: the response is driven by the geometric-adstocked spend series (within each geo over weeks), while the returnedspendstays raw. Fitting on the raw panel is then biased; thepreprocess.adstock_prepassrecovers it (guide §9.4).stratify=True(opt-in — the default keeps the historical rng stream byte-identical) resolvesa_geoBEFORE the geo assignment and blocks the randomization on it (assign_geos(..., baseline=a_geo)), matching the production practice of stratifying on the pre-period KPI level.
- mmm_framework.continuous_learning.simulate_wave(world, design, a_geo, *, t_test=10, center=None, n_holdout=0, noise=0.6, noise_family='normal', tau_scale=0.0, stratify=False, seed=0)[source]
Simulate a single test window for a recentered design over the SAME geos.
Used by the closed loop to generate wave
t > 0outcomes: the geos and their baselines (a_geo) persist, only the test allocations change.period_idx(wave-LOCAL,np.repeat(arange(t_test), n_geo)) is always provided;ingest()applies the cross-wave offset.noise_family/tau_scalebehave as insimulate_panel()(defaults byte-identical to the old draw).stratify=True(opt-in) blocks the geo→cell randomization on the known per-geo baselinesa_geo.
- mmm_framework.continuous_learning.drift_world(world, *, rate=0.08, drift_beta=True, drift_shape=True, drift_gamma=False, seed=0)[source]
A slightly different
TrueWorld— media behaviour drifting.Applies one step of multiplicative log-normal jitter,
x -> x * exp(Normal(0, rate)), to the channel ceilingsbetaand the activation shape params (bounded params are clipped back to their family’s support;drift_gamma=Truealso jitters the synergy magnitudes, sign-preserving). Applying it once per wave makes the response surface a geometric random walk — the changing-media- behaviour regime the information discount (fit(discount_half_life=...)) is built for. Channels, pairs and the geo-intercept hypers are untouched, so pair it withsimulate_wave()over a fixeda_geo: the baselines stay pinned while the RESPONSE moves.- Return type:
- mmm_framework.continuous_learning.allocate_under_sample(params, B, value, *, x0=None, n_starts=3, seed=0, mode='fixed', cap=None, group_budgets=None)[source]
Optimal allocation + profit under one posterior draw’s params.
group_budgetsfixes sub-budgets over channel groups (arms):[(indices, B_g), ...]addssum(s[indices]) == B_gper group.
- mmm_framework.continuous_learning.thompson_wave(post, B, value, *, q=300, seed=0, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]
A posterior over the optimal split — solve the allocation for
qdraws.Returns
(allocs, profits, draws):allocsis(q, K)(the mean is the recommended allocation, the spread is the exploration signal),profitsis(q,)(each draw’s optimum under itself), anddrawsare the sampled draw indices.
- mmm_framework.continuous_learning.recommend_allocation(post, B, value, **kwargs)[source]
The recommended allocation — the Thompson posterior mean split.
- Return type:
- mmm_framework.continuous_learning.marginal_roas(post, alloc, value, *, q=300, seed=1)[source]
Posterior of
value * dR/ds_catalloc— the funding line.Returns
(mean_mroas, prob_above_line, draws_mroas). A channel is funded whereprob_above_line > 0.5.
- mmm_framework.continuous_learning.expected_regret(post, B, value, *, q=300, seed=2, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]
Expected regret of acting on the consensus allocation (guide §7.4).
regret_d = profit(best-for-draw-d under d) - profit(consensus under d) >= 0— the profit still on the table from posterior uncertainty. Each draw’s “best” ismax(cold multistart, a solve warm-started FROM the consensus, profit at the consensus)(review fix F3): the warm-started second pass catches per-draw optima the cold multistart missed near the consensus, so the regret is less biased low and the ENBS stop fires less prematurely. Returns(E[regret], consensus_alloc, alloc_sd, optimal_profit_sd).
- mmm_framework.continuous_learning.plan_from_posterior(post, B, value, *, q=300, seed=0, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]
One Thompson pass producing every decision readout coherently (fix F4).
Runs
thompson_wave()once; the recommendation is the draw-mean (rescaled to the simplex infixedmode); the funding line and the warm-started regret pass reuse the SAME draw indices, so the funded set, the consensus, andE[regret]all describe one allocation.- Return type:
- class mmm_framework.continuous_learning.PlanResult(channels, B, value, mode, recommendation, consensus, allocs, profits, draws, mroas_mean, prob_above_line, mroas_draws, e_regret, alloc_sd, profit_sd)[source]
Bases:
objectEvery per-wave decision readout, derived from ONE Thompson sample.
Historically
recommend/funding/regreteach re-sampled with a different seed (planner seeds 0/1/2), so the funded set and the reported regret referred to different consensus vectors and paid 2-3x the SLSQP cost. Here all readouts share the sameqdraws:recommendationis the Thompson mean (rescaled onto the budget simplex infixedmode),consensusis that same vector, the funding line is evaluated at it, and the regret pass is warm-started from it.-
B:
float
-
value:
float
-
mode:
str
-
recommendation:
ndarray
-
consensus:
ndarray
-
allocs:
ndarray
-
profits:
ndarray
-
draws:
ndarray
-
mroas_mean:
ndarray
-
prob_above_line:
ndarray
-
mroas_draws:
ndarray
-
e_regret:
float
-
alloc_sd:
ndarray
-
profit_sd:
float
- to_dict()[source]
A small JSON-safe snapshot (summary stats, not the draw matrices).
- __init__(channels, B, value, mode, recommendation, consensus, allocs, profits, draws, mroas_mean, prob_above_line, mroas_draws, e_regret, alloc_sd, profit_sd)
-
B:
- mmm_framework.continuous_learning.posterior_optimal_allocation(post, B, value, *, q=200, seed=3, mode='fixed', cap=None, n_starts=4, group_budgets=None)[source]
argmax_a E_post[profit(a)]and its value — optimize the mean surface.
- mmm_framework.continuous_learning.knowledge_gradient(post, candidate_design, refit_fn, B, value, *, n_fantasy=10, t_test=10, n_geo=None, noise=None, mode='fixed', cap=None, q=120, seed=4)[source]
One-step-lookahead EVSI for a candidate test design (guide §7.5).
- Return type:
- ``KG(d) = E_y[ max_a profit(a | posterior updated with fantasised y) ]
max_a profit(a | current posterior)``
For each fantasy: draw params, simulate the candidate design’s outcomes, refit via
refit_fn(extra_spend, extra_geo_idx, extra_y) -> Posterior, and re-optimize. Expensive —refit_fnruns a (short) NUTS chain per fantasy; in production swap it for a Laplace/conjugate update (guide §9.1).noise=None(default) fantasizes with the posterior mean ofsigma— the fitted observation noise — rather than a hard-coded constant. Fantasy outcomes are drawn from the posterior’s own observation family (_fantasy_outcomes()): Gaussian, Student-t (posterior-meannu), or NB counts (posterior-meanphi;noiseignored).refit_fnmust refit under that same family — build it withmodel.refit_fn_from_data()’s matchinglikelihood.Requires a panel-fitted posterior: fantasies simulate geo-week outcomes off the geo intercepts (
a_geoor theA/sigma_ahypers), which a summaries-only fit never samples.
- mmm_framework.continuous_learning.response_grid(post, spend_matrix, draws)[source]
Incremental response for every
(spend row, draw)pair ->(G, D).Activation-agnostic (reads
post.activation): the visualizations use this to build the posterior mean/uncertainty surfaces for any activation family, so the same animation code renders a Hill world or a logistic one.- Return type:
- mmm_framework.continuous_learning.enbs(e_regret, *, margin, population, wave_cost)[source]
Expected net benefit of sampling
= E[regret] * margin * population - cost.E[regret]is profit-per-affected-unit on the response-curve scale;marginandpopulationconvert it to a total dollar value of resolving the uncertainty, which a wave must beat to be worth running.- Return type:
- mmm_framework.continuous_learning.should_stop(e_regret, *, margin, population, wave_cost)[source]
(stop, enbs)— stop when no wave’s expected value clears its cost.
- mmm_framework.continuous_learning.adstock_panel(spend, n_geo, t_pre, t_test, *, alpha, l_max=8, normalize=True)[source]
Geometric-adstock each channel’s spend series within each geo.
Reuses
transforms.adstock(normalize=Truekeeps the kernel a weighted moving average so total magnitude stays in the coefficient). Returns a spend array of the same shape, in the same row order.- Return type:
- mmm_framework.continuous_learning.adstock_prepass(data, t_pre, t_test, *, alpha, l_max=8)[source]
Return a copy of the data contract with the spend adstocked (guide §9.4).
Fit the surface on this instead of the raw panel when the response has carryover; the decay
alphais a hyperparameter (sweep it, or set it from a known channel half-life).
- mmm_framework.continuous_learning.cuped_covariate(data, t_pre)[source]
Per-geo, mean-centered pre-period KPI — the CUPED control covariate.
- Return type:
- mmm_framework.continuous_learning.cuped_adjust(data, t_pre)[source]
CUPED-adjust the outcome and report the variance reduction.
y_adj = y - theta * x_pre[geo]withtheta = Cov(y_test, x_pre) / Var(x_pre)(the regression of the geo’s test-period mean on its pre-period covariate). Returns(adjusted_data, info)whereinfocarriestheta,rho(the correlation CUPED exploits) andvar_reduction(1 - rho^2, the fraction of geo-level outcome variance removed).Incompatible with
likelihood="negbinomial": the adjustment mutatesyto non-integer (and possibly negative) values, which the count likelihood’s validation rejects. For count KPIs fit the raw counts — the geo intercept plus the national time effect (time_effect="national") covers most of what CUPED buys here.
- mmm_framework.continuous_learning.laplace_knowledge_gradient(post, design_cells, B, value, *, sigma=None, n_geo=80, t_test=10, n_outcomes=64, mode='fixed', cap=None, n_starts=3, cell_weights=None, seed=0)[source]
Decision-aware EVSI of a candidate design, with no refit (guide §9.1).
Gaussian-linear surrogate for
mmm_framework.continuous_learning.planner.knowledge_gradient(): the pre-posterior spread of the updated mean isV = Sigma - Sigma_post; fantasy meanseta_m ~ N(mu, V)are mapped back to valid surface parameters, re-optimized and averaged against the current best. Orders designs the same way as the NUTS KG at a fraction of the cost.Works for any registered activation and fitted observation family (the Fisher weights come from
observation_unit_info());sigma=Nonederives the Gaussian-family noise scale from the posterior.Each fantasy’s re-optimization is warm-started at the base (
mu) allocation, in addition to the usual uniform/random multi-starts. Fantasy means are draws local tomu(Vis a reduction in spread from the prior), so the true optimum is almost always near the base one; without the anchor, a non-concave surface (gammacan be negative) evaluated with only a handful of random restarts can converge to a different local optimum depending on the platform’s BLAS/LAPACK build (SLSQP’s floating- point path through the same starting point is not bitwise-portable), occasionally flipping a close two-candidate comparison. Anchoring removes that basin-hopping risk at no extra cost (it replaces one of the existing random starts, per_starts()).- Return type:
- mmm_framework.continuous_learning.design_eig(post, design_cells, *, sigma=None, target='all', n_geo=80, t_test=10, cell_weights=None)[source]
Pure information gain of a design (D-optimal
target="all"or D_s-optimaltarget="gamma"over the synergy sub-block).Works for any registered activation and any fitted observation family;
sigma=Nonederives the noise scale (Gaussian families) from the posterior.sigmais ignored for a count posterior — the GLM weights come fromphiand the baseline instead.- Return type:
- mmm_framework.continuous_learning.design_information(design_cells, theta_bar, *, sigma=None, k=None, pairs=None, tmap=None, unit_info=None, cell_weights=None, n_geo=80, t_test=10, residualize=True)[source]
Fisher information
Lambda(dim×dim) a design carries abouteta.Lambda = sum_c w_c u_c (g_c - g_bar)(g_c - g_bar)^Twhereg_cis the unconstrained-space parameter gradient at cellc,u_cthe observation family’s per-cell unit information (unit_info; defaults to the homoskedastic Gaussian1/sigma^2), andg_barthe weighted mean across cells (profiling the geo intercept).residualize=Falseskips the centering (treats the baseline as known).theta_baris the unconstrainedetavector fromtheta_moments(). Pass the matchingtmap; when omitted, a default Hill map with identity gamma transforms is built from(k, pairs)— only correct for a Hill posterior with default (“weak”/”zero”) pair signs.- Return type:
- mmm_framework.continuous_learning.gaussian_eig(sigma0, lam, *, idx=None)[source]
EIG (nats) of a parameter block under a Gaussian-linear update.
0.5 * (logdet Sigma0_SS - logdet Sigma_post_SS)for the blockidx(all parameters ifNone) — the entropy the design removes. Both log-dets run through the_logdet_psd()symmetrize-and-clip guard:Sigma_postis PSD in exact arithmetic (Lambda >= 0) but the finite-precision double inversion can leave a marginal sub-block — equivalently the Schur complementLambda_{S|rest}— with tiny negative eigenvalues.- Return type:
- mmm_framework.continuous_learning.observation_unit_info(post, design_cells, tmap, mu_eta, *, sigma=None)[source]
Per-cell Fisher information of ONE observation, for the fitted family. :rtype:
ndarray"normal":1 / sigma^2(homoskedastic — constant across cells)."studentt":(nu + 1) / ((nu + 3) sigma^2)with the posterior-mean tail df — the classic heavy-tail efficiency discount (< the Gaussian info at any finitenu, recovering it asnu -> inf)."negbinomial": the GLM weight through the model’s softplus link,sigmoid(eta_c)^2 / (m_c + m_c^2 / phi)witheta_c = baseline + R(s_c; mu),m_c = softplus(eta_c)and the posterior-meanphi. The baseline is the posterior-meanA(or the grand mean ofa_geowhen the hyper is absent).sigmais ignored — a count family has no Gaussian noise scale.
sigma=Nonereads the posterior-meansigmasite for the Gaussian families; a missing required site raisesValueError(summaries-only fits never sample the observation sites, and no fixed guess is meaningful across KPI scales).
- mmm_framework.continuous_learning.surrogate_validity(post, *, tmap=None, tail_prob=0.25, khat_threshold=0.7, skew_threshold=0.5, kurt_threshold=1.0)[source]
Diagnose whether
N(mu, Sigma)is a valid stand-in for the posterior.The Laplace acquisitions replace the carried NUTS posterior with its Gaussian moment-match in the unconstrained
etaspace (Eq. 12 of the math page). This runs entirely off the existing draws — no densities, no refit — and checks the two ways that stand-in fails: :rtype:dict[str,Any]Shape: per-parameter skewness and excess kurtosis of the unconstrained draws (a Gaussian has both ~0; the transforms already absorb the generic positivity skew, so what remains is real).
Tails / ridges: the generalized-Pareto shape
khatof the Mahalanobis-distance exceedances above their1 - tail_probquantile. Under a genuinely Gaussian posterior the squared distances are chi-square (exponential-class tail,khat ~ 0); a skewed, heavy-tailed or ridge-shaped posterior (the negative-gammacannibalisation ridge is the expected offender) inflateskhat. The alarm bar is the same 0.7 PSIS uses (Vehtari et al. 2024), estimated with the same Zhang–Stephens fit.
Returns a JSON-safe dict:
ok(False whenkhatclears the threshold, any parameter is flagged, or there are too few draws to tell),khat,params_flagged,max_abs_skew,max_excess_kurtosis, and theper_paramdetail. Whenokis False, spot-check or replace the wave’s Laplace scores with the NUTS-refitknowledge_gradient()(the correction path in the literature is a skewness-aware expansion — Rue, Martino & Chopin 2009).
- mmm_framework.continuous_learning.theta_map(post)[source]
Build the
ThetaMapfor a posterior’s activation + pair signs.- Return type:
- mmm_framework.continuous_learning.theta_moments(post, *, ridge=1e-06, tmap=None)[source]
Gaussian
(mu, Sigma)matched to the posterior — inetaspace.The match happens in the unconstrained reparameterization of
theta_map(): positive parameters in log space, bounded ones through a scaled logit, sign-constrained synergies through a sign-aware log. Any activation with aSHAPE_TRANSFORMSentry is supported; an unknown family raisesNotImplementedError(the planner’s decision readouts stay activation-agnostic).
- mmm_framework.continuous_learning.bocd(x, *, hazard=0.04, mu0=None, kappa0=1.0, alpha0=3.0, beta0=None, threshold=0.5)[source]
Bayesian online changepoint detection (Adams & MacKay 2007).
Normal observations with unknown mean and variance: the conjugate Normal–Inverse-Gamma model, whose posterior predictive is a Student-t — so the detector needs no known noise scale.
hazardis the constant per-step changepoint priorP(r_t = 0)(1/hazard= expected run length; the default expects a run of ~25 periods, conservative for a 10-week wave).mu0/beta0default empirically: the series median, and a noise variance from the MAD of the first differences (robust to the very level shift being tested for — a full-series spread estimate would fold the shift into the noise prior and mask it, while differences see only the step at the break). An empirical-Bayes convenience that is appropriate for the retrospective wave check; pass explicit priors for genuinely online use.Returns a
BocdResult;changepointsflags every stept >= 2withP(a sustained restart happened one step ago) > threshold— seeBocdResultfor why the flag isP(r_t = 2), notP(r_t = 0). A flag attmeans the break onset was att - 1.- Return type:
BocdResult
- class mmm_framework.continuous_learning.BocdResult(cp_prob, run_length_map, changepoints, threshold)[source]
Bases:
objectRun-length posterior summary from
bocd().cp_prob[t]is the posterior probability that a sustained restart happened one step ago —P(r_t = 2 | x_{1:t}), the hypothesis that the current regime contains exactly the last two observations. Two naive alternatives fail:P(r_t = 0)is identically the hazard under a constant-hazard BOCD (the changepoint and growth branches share the same predictive weights, so their mass split isH : 1-Hregardless of the data), andP(r_t = 1)fires on any single-point outlier (the fresh run’s wide prior predictive absorbs a spike that the tight long-run model rejects). Requiring the restarted run to explain two consecutive points is the minimum evidence for a regime rather than a blip — a spike reverts at the next step and the long run recovers the mass. The price is a one-period detection lag: a flag attmeans the break onset was att - 1.run_length_map[t]is the MAP run length after stept;changepointsare the steps (t >= 2) whosecp_probcleared the threshold.-
cp_prob:
ndarray
-
run_length_map:
ndarray
-
threshold:
float
- __init__(cp_prob, run_length_map, changepoints, threshold)
-
cp_prob:
- mmm_framework.continuous_learning.wave_stationarity_check(wave_data, *, hazard=0.04, threshold=0.5, min_geos_per_cell=1)[source]
Detect a mid-wave regime change in a designed wave’s contrasts.
For every design cell, build the per-period treatment-minus-control series (mean outcome of the cell’s geos minus the control’s — holdout geos
cell_idx == -1when the wave has them, else the center cell, design row 0) and runbocd()on it. Common shocks cancel in the contrast; a shock that hits treatment differently from control — the kind that biases the readout — appears as a level shift and fires the flag.wave_dataneeds the simulate/ingest contract keysspend,geo_idx,y,period_idxand the per-geocell_idx. RaisesValueErrorwhen a required key is missing (the guard cannot run blind). Returns aStationarityReport; whenflagged, eithercensor_periods()the affected periods before ingesting or extend / repeat the wave.- Return type:
StationarityReport
- class mmm_framework.continuous_learning.StationarityReport(flagged, break_periods, cell_breaks, control, n_test_periods, n_series, threshold, max_cp_prob, note='', cp_prob=<factory>)[source]
Bases:
objectResult of
wave_stationarity_check()(JSON-safe viato_dict()).-
flagged:
bool
-
control:
str
-
n_test_periods:
int
-
n_series:
int
-
threshold:
float
-
max_cp_prob:
float
-
note:
str= ''
- __init__(flagged, break_periods, cell_breaks, control, n_test_periods, n_series, threshold, max_cp_prob, note='', cp_prob=<factory>)
-
flagged:
- mmm_framework.continuous_learning.censor_periods(wave_data, periods)[source]
Drop every row of the given (global) periods from a wave dict.
Returns a shallow copy with
spend/geo_idx/y/period_idxfiltered row-wise (and any other key left untouched) — feed the result toLearningState.ingest()in place of the contaminated wave. Censoring from a detected break onward is the conservative choice when the regime did not revert (a level shift, not a spike); censoring just the flagged periods suits a transient.
- mmm_framework.continuous_learning.to_scaled(dollars, spend_ref)[source]
Convert dollar spend to scaled units:
dollars / spend_ref.- Parameters:
- Return type:
- Returns:
The scaled spend, same shape as
dollars(float64).
- mmm_framework.continuous_learning.to_dollars(scaled, spend_ref)[source]
Convert scaled spend back to dollars:
scaled * spend_ref.
- mmm_framework.continuous_learning.experiments_to_summaries(experiments, *, channels, spend_ref, center_scaled, period_days=7.0)[source]
Convert registry experiment dicts into summary observations.
- Parameters:
experiments (
list[dict[str,Any]]) – rows fromsessions.list_experiments(plain dicts withstatus/channel/estimand/value/seplus thedesignandreadoutJSON snapshots).channels (
list[str]) – the program’s channel (or flattened arm) names, lengthK.spend_ref (
ndarray) – dollars per scaled unit per channel, shape(K,).center_scaled (
ndarray) – the program’s baseline allocation in scaled units, shape(K,)— the counterfactual every readout is measured against.period_days (
float) – registry-date cadence (7 = weekly).
- Return type:
- Returns:
(summaries, skipped)—summariesare ready forfit(data={"summaries": ...})(each also carriesexperiment_id/channelprovenance);skippeditems are{"id", "reason"}.
- mmm_framework.continuous_learning.posterior_to_payload(post)[source]
A JSON-safe dict capturing a
Posteriorcompletely.Sample arrays become (nested) lists of float64 — Python’s JSON float representation round-trips doubles exactly, so
posterior_from_payload()reconstructs bit-identical samples.
- mmm_framework.continuous_learning.posterior_from_payload(d)[source]
Inverse of
posterior_to_payload().- Return type:
- mmm_framework.continuous_learning.state_to_npz(state, path)[source]
Persist a
LearningStateto one compressed.npzfile.Arrays (panel, posterior samples, summary spend vectors) are stored natively (exact float64); config/history/pair_signs/geo_ids ride an embedded JSON string under the
metakey. Returns the written path.- Return type:
- mmm_framework.continuous_learning.state_from_npz(path)[source]
Reconstruct a
LearningStatewritten bystate_to_npz().- Return type:
- class mmm_framework.continuous_learning.ArmSpec(channels, parents, groups)[source]
Bases:
objectThe flattened arm layout of a (possibly partially) split channel list.
channelsare the flattened arm names (surface dimensions, in order);parents[i]is armi’s parent channel (== the name itself when the channel is unsplit);groupsmaps every parent to its arm indices.- property n_arms: int
- split_parents()[source]
Parents that were actually split into more than one arm.
- __init__(channels, parents, groups)
- mmm_framework.continuous_learning.arm_shares(post, spec, parent, spend_ref, *, breakout_name_map, mode='zero_out', draws=500, rng=None)[source]
Export a parent’s within-channel share composition from a CL posterior.
The outbound bridge to the breakout-weighted MMM’s share calibration (
ShareMeasurementinmmm_framework.calibration.likelihood): per posterior draw, compute each of the parent’s arms’ incremental response at the reference spendspend_ref, normalize to a within-parent share simplex, and summarize the draws as shares + the empirical covariance of theK-1additive log-ratios (ALR, last arm as reference).Location/covariance consistency: the exported
sharesare the inverse-ALR (softmax) of the mean log-ratios over the surviving draws – NOT the arithmetic mean of the share draws – so the consumer’sMvNormallocation and its observedz_hat = ALR(shares)are exactlymean(z), on the same draws as the exported covariance.Non-positive responses (
"zero_out"mode): a draw where any arm’s zero-out response is non-positive (strong cannibalization atspend_ref) has NO well-defined share composition, so such draws are excluded from both the shares and the ALR covariance rather than floored into the statistics (a floored draw would injectlog(eps)outliers that inflate the covariance by orders of magnitude and silently down-weight the share evidence). Any exclusion warns; more than 20% excluded raises (the zero-out shares are ill-defined – usemode="main_effect"or a differentspend_ref); fewer than 10 surviving draws raises (too few to estimate the covariance).Estimand caveat (read before use): the MMM’s
breakout_share_<C>is an effectiveness share through one shared response curve at the panel’s spend mix, while an arm share here is a ratio of PER-ARM response curves atspend_ref(with interactions in"zero_out"mode). The two coincide only near the panel’s observed sub-stream spend mix and under the breakout model’s own shared-curve assumption – sospend_refshould approximate the panel’s operating point, NOT a reallocated/optimal spend.- Parameters:
post (
Posterior) – A fitted continuous-learningPosteriorover the arms surface (each arm a full dimension).spec (
ArmSpec) – TheArmSpecdescribing the flattened arm layout.parent (
str) – The split parent channel to export (must have >= 2 arms).spend_ref (
ndarray) – Scaled reference spend vector over ALL arms, shape(spec.n_arms,)– the operating point the shares are read at.breakout_name_map (
dict[str,str]) – REQUIRED mapping from each arm’s sub-name (the part afterARM_SEP, e.g."Brand"from"Search │ Brand") to the MFF breakout column name (e.g."Search_Brand"). No fuzzy fallback: a missing key raises. The returnedbreakoutsare the mapped names in the ARM (group) order; the MMM consumer requires them to match its model order exactly, so build the map (and the arm order) againstBreakoutWeightedParams.breakout_groups.mode (
str) –"zero_out"(default) – armi’s response isR(s_ref) - R(s_ref with arm i zeroed), capturing interactionsgamma;"main_effect"–beta_i * act_fn(s_ref)_i, main-effect only (strictly positive sincebetais HalfNormal).draws (
int) – Maximum posterior draws to use; the posterior is subsampled without replacement when it has more.rng (
Any) – Seed ornumpy.random.Generatorfor the subsample.
- Returns:
{channel, breakouts, shares, log_ratio_cov, distribution, source}.channelis set toparent; overwrite it if the MMM’s virtual channel name differs.sourcerecords{"mode", "spend_ref", "n_draws", "n_excluded"}as provenance for the double-counting guard (n_drawscounts the SURVIVING draws the summary is computed on).- Return type:
A
ShareMeasurement-shaped payload- Raises:
ValueError – If more than 20% of the used draws have a non-positive arm response in
"zero_out"mode, or if fewer than 10 draws survive the exclusion.
- mmm_framework.continuous_learning.expand_arms(channels, arms)[source]
Flatten
channelswitharms(parent -> sub-names) into an ArmSpec.Channels absent from
armsstay single arms named after themselves; split channels expand in place, preserving the channel order. Raises on an unknown parent, an empty/duplicated sub-list, or a flattened-name clash.- Return type:
ArmSpec
- mmm_framework.continuous_learning.within_parent_pairs(spec)[source]
All sibling pairs
(i, j),i < j, sharing a parent (substitutes).
- mmm_framework.continuous_learning.cross_parent_pairs(spec, pairs_of_parents=None)[source]
Arm pairs spanning two different parents.
pairs_of_parentsrestricts to specific (unordered) parent pairs — probe only the decision-pivotal cross-channel synergies rather than paying the full off-axis cell cost for every arm combination.
- mmm_framework.continuous_learning.default_arm_pair_signs(spec, *, within='neg', base=None)[source]
The default sign map for an arms surface.
Within-parent siblings get
within(default"neg"— shared-audience substitution: creatives/keywords cannibalize each other), cross-parent pairs get"weak", andbaseentries override everything.
- class mmm_framework.continuous_learning.LearningState(channels, center, B, value, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', mode='fixed', cap=None, beta_scale=1.0, gamma_scale=0.8, spend_ref=None, discount_half_life=None, spline_prior='iid', data=None, posterior=None, history=<factory>, summaries=<factory>, geo_ids=None, stationarity_reports=<factory>)[source]
Bases:
objectAccumulated experiment data + the current posterior + decision readouts.
spend_ref(dollars per scaled unit, per channel) is carried for the dollar boundary — convert withto_dollars()/to_scaled(); everything inside the state (center, panel spend, summaries) is scaled units.geo_ids(optional, from the data dict’s"geo_ids"key) pins the geo identity across waves — the misspecification study showed the loop diverges if geo baselines are silently re-drawn under the samegeo_idx.likelihood/time_effectmirrormodel.fit()’s knobs (defaults reproduce the old behavior exactly) and are threaded on every refit; atime_effect != "none"program requires and accumulates a globalperiod_idxacross waves (seeingest()).-
center:
ndarray
-
B:
float
-
value:
float
-
activation:
str= 'hill'
-
likelihood:
str= 'normal'
-
time_effect:
str= 'none'
-
mode:
str= 'fixed'
-
beta_scale:
float= 1.0
-
gamma_scale:
float= 0.8
-
spline_prior:
str= 'iid'
-
history:
list[WaveRecord]
- ingest(wave_data, *, check_stationarity=False)[source]
Append a wave’s rows to the accumulated panel (same geos throughout).
wave_datamay carry an optional"geo_ids"key (list[str]of lengthn_geo): the first wave pins the program’s geo identities and later waves must match them exactly (order included).check_stationarity=True(opt-in; requires the wave to carryperiod_idxandcell_idx) runs the within-wave stationarity guard (wave_stationarity_check()) before ingesting: the report is appended toself.stationarity_reportsand a flagged mid-wave regime change raises aUserWarning— the wave is still ingested, so the caller decides whether tocensor_periods()and re-ingest, or extend/repeat the wave.When the program models a national time effect (
time_effect != "none"), every wave MUST carry a"period_idx"(wave-local, 0-based); it is accumulated with a per-wave offset (shifted by the accumulated maximum + 1) so two waves’ shocks never alias onto one globaltau_t. Programs withtime_effect="none"ignore anyperiod_idxa wave carries (the accumulated data dict is unchanged).- Return type:
- ingest_summaries(items)[source]
Append summary observations (historical lift readouts, no panel).
Each item follows the summary schema in
mmm_framework.continuous_learning.model(spend_test/spend_basein SCALED units, totallift±sein natural KPI units,scale= geo-periods aggregated). Validated eagerly.- Return type:
- fit(**fit_kwargs)[source]
Refit the surface on ALL accumulated evidence (carries the posterior).
The state’s
discount_half_life/spline_priorare threaded to every refit (both overridable per call viafit_kwargs). When discounting is active, per-row ages are derived from the accumulatedrow_weekcolumn thatingest()maintains (age = newest week − row week, in weeks).- Return type:
- funding(alloc=None, **kwargs)[source]
- regret(**kwargs)[source]
- plan(**kwargs)[source]
All per-wave readouts from ONE Thompson pass (see fix F4).
Delegates to
plan_from_posterior()with this program’s budget/value/mode/cap; passq/seed/group_budgetsetc. through as keyword arguments.- Return type:
- recenter(alloc, *, min_frac=0.05)[source]
Move the trust-region center to
alloc, floored away from zero.The CCD is multiplicative (review fix F5): recentering a channel onto ~0 zeroes every axial/off-axis/shutoff cell for it — no variation, so beta is unidentified and the spend floor’s zero gradient means the channel can never be resurrected by the loop. Each channel is therefore clamped to
>= min_frac * (B / K)in the center’s scaled units (min_frac=0restores the old unfloored behavior). Warns whenever a clamp fires.- Return type:
- __init__(channels, center, B, value, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', mode='fixed', cap=None, beta_scale=1.0, gamma_scale=0.8, spend_ref=None, discount_half_life=None, spline_prior='iid', data=None, posterior=None, history=<factory>, summaries=<factory>, geo_ids=None, stationarity_reports=<factory>)
-
center:
- class mmm_framework.continuous_learning.WaveRecord(wave, n_rows, e_regret, enbs, stop, recommendation, funded, mroas_mean, prob_above_line, profit_gap, profit_gap_rel, max_rhat, n_summaries=0, kg_used=False, chosen_delta=None)[source]
Bases:
objectA JSON-safe snapshot of one wave’s decision state.
kg_used/chosen_deltadescribe the designed wave whose data this record’s fit ingested: whether the Laplace knowledge-gradient selected the design (seeselect_next_design()) and which trust-regiondeltait chose. Both default to the fixed-design values so historical records (and the byte-stable default loop) are unchanged.-
wave:
int
-
n_rows:
int
-
e_regret:
float
-
enbs:
float
-
stop:
bool
-
profit_gap:
float
-
profit_gap_rel:
float
-
n_summaries:
int= 0
-
kg_used:
bool= False
- __init__(wave, n_rows, e_regret, enbs, stop, recommendation, funded, mroas_mean, prob_above_line, profit_gap, profit_gap_rel, max_rhat, n_summaries=0, kg_used=False, chosen_delta=None)
-
wave:
- mmm_framework.continuous_learning.run_closed_loop(world, *, center, B, value, n_geo=80, t_pre=6, t_test=10, delta=0.6, noise=0.6, n_holdout=0, mode='fixed', cap=None, gamma_scale=0.8, pair_signs=None, margin=1.0, population=1.0, wave_cost=0.05, max_waves=4, recenter=True, planner_q=120, fit_kwargs=None, use_laplace_kg=False, candidate_deltas=(0.3, 0.6, 0.9), kg_n_outcomes=32, stratify_geos=False, stop_patience=1, discount_half_life=None, spline_prior='iid', seed=0)[source]
Run the full loop against a known world and return the decision trace.
Each pass: refit on all data, compute the recommendation + funding line + expected regret, evaluate the ENBS stopping rule, then (unless stopping) recenter on the recommendation, run a fresh designed wave over the SAME geos, and ingest it. Returns the per-wave
WaveRecordhistory plus the final recommendation and the truth-optimal target for comparison.use_laplace_kg=True(opt-in; the default path is byte-identical to the historical loop) scorescandidate_deltaswithselect_next_design()each wave and runs the EVSI-best design instead of the fixed-deltaCCD; the choice is recorded on the NEXT wave’sWaveRecord(kg_used/chosen_delta).stratify_geos=True(opt-in) blocks the geo→cell randomization on the true per-geo baselinesa_geo(seeassign_geos()).stop_patience(default 1 — byte-identical to the historical loop) requires that many consecutiveENBS <= 0evaluations before the loop stops. Evaluating ENBS every wave is NOT frequentist optional stopping — a Bayesian expected-value rule is stopping-rule-invariant under the likelihood principle (Edwards, Lindman & Savage 1963; Berger & Wolpert 1988) — but that invariance assumes a correctly specified model, and the misspecification study shows intervals can be narrow and wrong.stop_patience=2is the cheap belt-and-suspenders guard: one optimistically-low regret read (from a temporarily overconfident, possibly misspecified posterior) cannot end the programme on its own. EachWaveRecord’sstopstays the raw per-wave verdict.
- mmm_framework.continuous_learning.select_next_design(post, center, pairs, B, value, *, mode='fixed', cap=None, candidate_deltas=(0.3, 0.6, 0.9), candidate_probe_sets=None, n_geo=80, t_test=10, n_outcomes=32, seed=0, fallback_delta=0.6, cost_fn=None)[source]
Score candidate CCDs with the Laplace knowledge-gradient; pick the best.
Candidates are
central_composite(center, delta, probe_set)for everydeltaincandidate_deltas(clipped to(0, 1]) × every probe set incandidate_probe_sets(default: justpairs). Each candidate is scored withlaplace_knowledge_gradient()— decision-aware EVSI, no MCMC — using the SAMEseedfor every candidate (common random numbers, so the Monte-Carlo argmax does not flap). The Fisher weights come from the fitted observation family (observation_unit_info()): Gaussian and Student-t posteriors need thesigmasite (the noise scale), NegBinomial posteriors needphiplus theA/a_geobaseline. There is deliberately NO numeric fallback for missing sites: a summaries-only fit (whosebeta/gammalive on the KPI’s natural scale underprior_scaling="auto") has no meaningful observation scale to score with, so a hard-coded guess would make every candidate’s Fisher information explode identically and the reported per-candidate scores carry no information while claimingkg_used=True.cost_fn(optional) makes the selection cost-aware: each candidate is ranked byKG / cost_fn(cells)— decision value per dollar, the knowledge-gradient analogue of expected-improvement-per-unit-cost (EIpu; Lee, Perrone, Archambeau & Seeger 2020). Cell counts grow ~3 per extra arm plus 2 per probed pair, so a cost-blind KG systematically over-favors large designs; dividing by the design’s own cost is the greedy myopic version of the ENBS cost-benefit trade (planner.enbs), which keeps the acquisition and the stopping rule coherent.cost_fnreceives the candidate’s cell matrix and must return a positive finite cost (e.g.lambda cells: fixed + per_cell * len(cells)). The rawscoreand the rankingscore_per_costare both recorded;cost_fn=Noneis byte-identical to the historical ranking.Degrades gracefully: an activation without a transform spec, an unknown likelihood family, a posterior missing its observation-noise sites (summaries-only fit), a non-finite candidate score, a non-positive or non-finite candidate cost, a near-singular moment-matched covariance, or any scoring
ValueErrorfalls back to the fixed designcentral_composite(center, fallback_delta, pairs).- Returns:
on success
{"kg_used": True, "chosen_delta", "chosen_probe_pairs", "kg_scores": [{"delta", "probe_pairs", "score"[, "cost", "score_per_cost"]}, ...], "sigma", "cost_aware", "surrogate"}(sigmaisNonefor a count posterior, which has no Gaussian noise scale); on fallback{"kg_used": False, "reason", "surrogate"}.surrogateis the compactsurrogate_validity()report (ok/khat/params_flagged…) — whenokis False the Gaussian surrogate is suspect for this posterior and the wave’s scores should be spot-checked against (or replaced by) the NUTS-refitknowledge_gradient().- Return type:
(cells, meta)— the chosen design array and a JSON-safe meta dict
- mmm_framework.continuous_learning.world_optimal_allocation(world, B, value, *, mode='fixed', cap=None, n_starts=8, seed=0)[source]
The truth-optimal allocation + profit (the recovery/closure target).
- mmm_framework.continuous_learning.due_for_retest(sigma_post, weeks_elapsed, channel, sigma_exp, *, half_life_overrides=None)[source]
Whether a channel’s evidence has decayed enough to warrant a re-test.
Thin reuse of
mmm_framework.planning.eig.reexperiment_due()so the continuous-learning loop’s re-test trigger uses the framework’s existing information-decay model (sigma_eff^2(t) = sigma_post^2 exp(lambda t),lambda = ln2 / half_life) rather than a private one. Returns(due, current_eig_nats).
- mmm_framework.continuous_learning.adstock_half_life(adstock_alpha)[source]
Half-life (periods) of a geometric adstock with retention
alpha.alpha**h = 1/2 => h = ln(1/2) / ln(alpha). The advertising literature finds carryover half-lives are strongly category-specific (industry FMCG figures cluster near 2–2.5 weeks against academic estimates of 7–12 — Fry, Broadbent & Dixon 2000), which is the empirical argument against one global decay constant: use each channel’s measured carryover as the anchor for its own evidence half-life, viadue_for_retest(..., half_life_overrides={channel: h}). The carryover half-life is a lower anchor — a finding decays at least as fast as the mechanism that produced it drifts, and audience/creative drift usually puts the evidence half-life at a multiple of it; when the programme has accumulated waves, prefer the measuredestimate_half_life().alpha <= 0(no carryover) returns0.0;alpha >= 1(no decay) returnsinf.- Return type:
- mmm_framework.continuous_learning.estimate_half_life(gaps_weeks, shifts, sigmas)[source]
Estimate the information-decay half-life from realized posterior drift.
The decay clock (math page Eq. 22) inflates a finding’s variance as
sigma_eff^2(t) = sigma_post^2 * exp(lambda * t)withlambda = ln2 / h— buthneed not be guessed: the loop observes how fast its own beliefs drift. Between two fits of the same quantity separated by a gapt_j(weeks), the decay model says the realized mean shift is a draw with variancesigma_j^2 * (exp(lambda t_j) - 1)on top of the posterior spread, soz_j = (shift_j / sigma_j)^2has expectationexp(lambda t_j) - 1. The method-of-moments estimate solvesmean_j[z_j] = mean_j[exp(lambda t_j) - 1]forlambda(monotone — a bisection). This is the adaptive-discounting idea of the dynamic-linear-model literature: West & Harrison (1997, §6.3) model each component as losing a constant information fractiondeltaper period (delta = exp(-lambda)here, returned as"discount"), with separate discounts per component — i.e. estimate onehper channel, not one global constant.- Parameters:
- Return type:
- Returns:
{"half_life": h, "lambda": lam, "discount": exp(-lam), "n": J}—half_life = infwhen no excess drift is observed (the findings are not decaying measurably). Requires at least one observation with positive gap and sigma.
Surface¶
The response surface — one differentiable JAX function, used everywhere.
This module is the single source of truth for how scaled spend drives
incremental outcome in the continuous-learning loop. The very same
incremental() is evaluated inside
the NumPyro likelihood (
mmm_framework.continuous_learning.model),the synthetic data-generating process (
dgp), andthe allocator / acquisition planner (
planner, which differentiates it withjax.grad()).
Because all three call the identical function, the optimizer can never disagree with the fitted surface and the recovery test exercises the exact map the planner exploits.
Functional form (per geo-week, for a scaled spend vector s of length K):
f_c(s_c) = s_c^alpha_c / (kappa_c^alpha_c + s_c^alpha_c) # Hill in [0, 1)
incremental(s) = sum_c beta_c f_c(s_c)
+ sum_{c<c'} gamma_{cc'} f_c(s_c) f_c'(s_c')
The Hill activation matches the framework’s SaturationType.HILL
(x^slope / (x^slope + sat_half^slope)) with slope = alpha and
sat_half = kappa — so a continuous-learning posterior is directly comparable
to a BayesianMMM Hill fit on the same channel.
The interaction block uses an upper-triangular gamma matrix (gamma[i, j]
for i < j); gamma carries the sign of the synergy (positive =
complementarity, negative = cannibalization). The cross-partial is
d^2 incremental / ds_c ds_c' = gamma_{cc'} f_c'(s_c) f_c''(... ) — see the
guide for the algebra; the planner never needs it by hand because it
auto-differentiates incremental() directly.
- mmm_framework.continuous_learning.surface.activation(spend, kappa, alpha)[source]¶
Beta-stripped Hill activation
f_c(s_c) in [0, 1), elementwise.- Parameters:
spend – scaled spend, shape
(K,)(non-negative; a shutoff cell is 0).kappa – half-saturation per channel, shape
(K,)(positive).alpha – Hill shape per channel, shape
(K,)(positive).
- Returns:
The activation fraction, shape
(K,).
- mmm_framework.continuous_learning.surface.incremental(spend, beta, kappa, alpha, gamma)[source]¶
Incremental response to one scaled spend vector.
- Parameters:
spend – scaled spend, shape
(K,).beta – channel ceilings, shape
(K,)(non-negative).kappa – half-saturations, shape
(K,).alpha – Hill shapes, shape
(K,).gamma – upper-triangular interaction matrix, shape
(K, K)(zeros on and below the diagonal).
- Returns:
Scalar incremental response.
- mmm_framework.continuous_learning.surface.incremental_batch(spend, beta, kappa, alpha, gamma)¶
Incremental response to one scaled spend vector.
- Parameters:
spend – scaled spend, shape
(K,).beta – channel ceilings, shape
(K,)(non-negative).kappa – half-saturations, shape
(K,).alpha – Hill shapes, shape
(K,).gamma – upper-triangular interaction matrix, shape
(K, K)(zeros on and below the diagonal).
- Returns:
Scalar incremental response.
- mmm_framework.continuous_learning.surface.incremental_jit(spend, beta, kappa, alpha, gamma)¶
Incremental response to one scaled spend vector.
- Parameters:
spend – scaled spend, shape
(K,).beta – channel ceilings, shape
(K,)(non-negative).kappa – half-saturations, shape
(K,).alpha – Hill shapes, shape
(K,).gamma – upper-triangular interaction matrix, shape
(K, K)(zeros on and below the diagonal).
- Returns:
Scalar incremental response.
- mmm_framework.continuous_learning.surface.response_curve(spend_matrix, beta, kappa, alpha, gamma)[source]¶
Incremental response for every row of a
(N, K)scaled-spend panel.A thin numpy-friendly wrapper over
incremental_batchreturning a plainjax.Arrayof shape(N,).
- mmm_framework.continuous_learning.surface.logistic(spend, lam)[source]¶
Exponential-saturation activation
f(s) = 1 - exp(-lam * s)in[0, 1).Smooth, strictly increasing, strictly concave (no inflection),
f(0)=0. One shape parameter per channel; the half-saturation point isln(2)/lam. Matches the framework’sSaturationType.LOGISTIC.
- mmm_framework.continuous_learning.surface.hill_mixture(spend, kappa1, alpha1, kappa2, alpha2, w)[source]¶
A weighted sum of two Hill curves:
w f(κ1,α1) + (1-w) f(κ2,α2).Still smooth, monotone and saturating (
f(0)=0,->a value in[0,1)), but far more flexible than a single Hill — it can express a two-phase / shoulder shape (a low-κ, high-α component that switches on early plus a high-κ component that keeps rising) that a single Hill or a logistic curve cannot represent. Used as a true DGP to study model misspecification: fit it with a single Hill (mild) or a logistic (severe) and watch what breaks.
- mmm_framework.continuous_learning.surface.MSPLINE_J = 9¶
Number of monotone I-spline basis functions (= weight parameters w1..wJ).
- mmm_framework.continuous_learning.surface.monotone_spline_basis(spend)[source]¶
The
MSPLINE_Jmonotone I-spline basis functions atspend.Returns shape
spend.shape + (MSPLINE_J,); columnjisI_{j+1}(spend / MSPLINE_S_MAX), rising monotonically 0 -> 1 (earlier columns rise earlier). Useful for plotting and for understanding what the fitted weights mean.
- mmm_framework.continuous_learning.surface.monotone_spline(spend, w1, w2, w3, w4, w5, w6, w7, w8, w9)[source]¶
Shape-agnostic monotone-spline activation, values in
[0, 1).f(s) = sum_j w_j I_j(s / MSPLINE_S_MAX) / sum_j w_jwith positive weightsw1..w9(one per I-spline basis function; only the ratios matter). Smooth, strictly monotone on[0, MSPLINE_S_MAX),f(0) = 0, fully saturated at/afterMSPLINE_S_MAX. Early weights buy an early, steep rise; late weights buy a slow, late one — mixtures express two-phase and plateau shapes no single Hill or logistic can.
- mmm_framework.continuous_learning.surface.surface_value(spend, beta, gamma, act_fn, shape)[source]¶
Incremental response for one spend vector under an arbitrary activation.
act_fn(spend, *shape) -> fcomputes the per-channel activations;shapeis a tuple of(K,)arrays in the activation’s parameter order. The interaction block is unchanged, so any activation drops straight in.
- mmm_framework.continuous_learning.surface.surface_over_rows(spend_matrix, beta, gamma, act_fn, shape)[source]¶
surface_value()for every row of a(N, K)panel ->(N,).
Model¶
Layer 1 — the model-free response model (NumPyro).
A lightweight Bayesian response surface fit directly from designed experiment data — no observational time series, no pre-fit MMM. The whole point of the continuous-learning loop is that the experiment’s designed cross-sectional variation identifies the surface, so the priors inform but the data dominates.
Priors (guide §4.2; y_loc/y_scale default to 0/1):
beta_c ~ HalfNormal(beta_scale * y_scale) # ceilings on the y scale
kappa_c ~ LogNormal(0, 0.5) # O(1) half-saturation
alpha_c ~ TruncatedNormal(2, 0.5, [0.5, 5]) # Hill shape
gamma ~ sign-informed per pair (see PAIR_SIGNS) # synergy (× y_scale)
A ~ Normal(y_loc, 5 * y_scale) # geo-intercept hypers
sigma_a ~ HalfNormal(2 * y_scale)
a_geo ~ Normal(A, sigma_a) # baseline random intercept
sigma ~ HalfNormal(y_scale) # observation noise
The data contract mandates y in NATURAL units (never normalized), so the
prior scales cannot be hard-coded O(1): fit() derives y_loc/y_scale
from the evidence (prior_scaling="auto", the default — y_loc = mean(y)
and y_scale = `` the nearest DECADE of ``std(y), or of the |lift|/scale
magnitudes for summaries-only fits) so a revenue-scale KPI (1e5+) gets priors
on its own order of magnitude while O(1) data keeps the original priors
exactly (y_scale = 1). prior_scaling="unit" forces the original O(1)
priors regardless of the data.
Likelihood:
y ~ Normal(a_geo[geo_idx] + incremental(spend), sigma)
The geo intercept a_geo is pinned by the pre-period (where every geo shares
the status-quo allocation), which breaks the within-geo collinearity between
baseline and incremental response — the CUPED-style identification requirement
of guide §3.2.
Two opt-in extensions (the defaults reproduce the graph above byte-identically — no new sample sites, same rng stream):
likelihood="studentt"— heavy-tailed robust observation for continuous KPIs with occasional wild geo-weeks (data glitches, local shocks). Same location/scale structure as the Gaussian plus a learned tailnu ~ Gamma(2, 0.1)(the Juárez–Steel robustness prior);y ~ StudentT(nu, mu, sigma). Everything downstream that reads only the surface parameters is unchanged; thesigmasite persists (it is the t SCALE — the sd issigma * sqrt(nu/(nu-2))).likelihood="negbinomial"— count KPIs. The panel observation becomesy ~ NegativeBinomial2(softplus(mu), phi)with concentrationphi ~ LogNormal(log 30, 2)replacing thesigmasite;ymust be non-negative integer counts (_validate_panelenforces it — notepreprocess.cuped_adjust()mutatesyto non-integer/possibly-negative values and is therefore incompatible). The summary block stays Gaussian for BOTH likelihoods: a historicallift ± sereadout is already a normal-approximation aggregate, so a count observation model has nothing to add there. Link caveat: the planner’s marginal readouts differentiate the LATENT surfaceRwhile the observable count mean issoftplus(mu)(derivativesigmoid(mu)) — asymptotically exact formu >> 1, an overstatement of the count-scale marginal nearmu ≈ 0(seefit()).time_effect="national"— a zero-centered hierarchical national per-period shocktau_t ~ Normal(0, sigma_tau),sigma_tau ~ HalfNormal(y_scale), added to every panel row of periodt(requiresdata["period_idx"]). Zero-centered partial pooling, NOT fixed effects: a free tau level is exactly collinear with the intercept hyperA. Summaries need no tau — a national per-period constant cancels in the lift difference exactly as the geo intercept does, so the evidence/summary path is unchanged.
Summary observations (past experiments, no panel required). A historical lift test is a difference measurement, so the geo intercept cancels and no pre-period is needed — the atomic evidence unit is:
{"spend_test": (K,), "spend_base": (K,), "lift": float, "se": float,
"scale": float} # scale = n_units * n_periods the total lift aggregates
with likelihood lift ~ Normal(scale * (R(spend_test) - R(spend_base)), se).
fit() accepts data["summaries"] alongside the panel, or instead of
it ({"summaries": [...], "n_geo": 0}) — a team with historical readouts and
no panel can still fit the surface. Same structural-stationarity caveat as the
MMM’s off-panel calibration: the response curve is assumed stable across the
periods the summaries span. A handful of summaries constrains only a few
contrasts of the surface, so κ/α stay prior-dominated — trust the funded set,
not the curve shape.
- mmm_framework.continuous_learning.model.VALID_LIKELIHOODS = ('normal', 'negbinomial', 'studentt')¶
Observation families for the panel likelihood (“normal” is the default and reproduces the original graph byte-identically).
- mmm_framework.continuous_learning.model.VALID_TIME_EFFECTS = ('none', 'national')¶
National per-period time-effect modes (“none” default = no tau sites).
- mmm_framework.continuous_learning.model.default_pairs(n_channels)[source]¶
All upper-triangular channel pairs
(i, j)withi < j.
- mmm_framework.continuous_learning.model.normalize_pair_signs(pair_signs)[source]¶
Normalize pair-sign keys to
(min, max)orientation and merge.The model’s pairs are always upper-triangular (
default_pairs()) and the prior lookup ispair_signs.get((i, j))— a reversed key like(1, 0)would otherwise validate but be silently ignored (the user’s explicit prior replaced by"weak"). Duplicate entries that agree are merged; conflicting duplicates ((0, 1)vs(1, 0)with different signs) raise.
- mmm_framework.continuous_learning.model.pair_name(channels, pair)[source]¶
Posterior site name for a pair’s interaction,
gamma_<ci>_<cj>.- Return type:
- mmm_framework.continuous_learning.model.demote_channel(channels, name, *, pairs=None, base=None)[source]¶
Demote a non-randomizable channel (a walled garden) to main-effect-only.
Every interaction that touches
namebecomes"zero"(prior-dominated); its main effect is still identified. Flaggamma(., name)as the least-trustworthy parameter in any decision (guide §5.4).
- mmm_framework.continuous_learning.model.probe_pairs_excluding(channels, name, *, pairs=None)[source]¶
Drop the off-axis design cells for a demoted channel’s pairs.
- mmm_framework.continuous_learning.model.model(spend, geo_idx, n_geo, y=None, *, channels, pairs, pair_signs, beta_scale=1.0, gamma_scale=0.8, activation='hill', likelihood='normal', time_effect='none', period_idx=None, n_period=0, y_loc=0.0, y_scale=1.0, noise_mult=None, spline_prior='iid', summary_test=None, summary_base=None, summary_scale=None, summary_lift=None, summary_se=None)[source]¶
NumPyro generative model (guide §4.2/4.3), for any pluggable activation.
noise_mult(optional,(N,), all>= 1) is the per-row information-discount multiplierexp(0.5 * lambda * age)computed byfit(): for the Gaussian families the observation scale becomessigma * noise_mult(old rows are noisier — exactly the Eq. 22sigma_effsemantics moved into the likelihood), and for the count family (which has no noise scale) the same discount is applied as a power likelihood,log pscaled by1 / noise_mult^2 = exp(-lambda * age)(the two coincide in how they down-weight the data term).Noneis the historical, byte-identical graph.spline_priorselects the monotone-spline weight prior (see_sample_activation_shape()).y=Nonedraws from the prior predictive (prior checks).pair_signsmaps each pair to a prior family; pairs absent from the map default to"weak". Sign-constrained pairs (neg/pos) record a signed deterministicgamma_<ci>_<cj>so every pair has a uniform posterior site.activationselects the per-channel saturation family ("hill"default,"logistic"for exponential saturation).likelihoodselects the panel observation family:"normal"(default — the original graph, byte-identical),"studentt"for heavy-tailed robust continuous KPIs (adds a tail dfnu ~ Gamma(2, 0.1)next tosigma;y ~ StudentT(nu, mu, sigma)), or"negbinomial"for count KPIs (phi ~ LogNormal(log 30, 2)replaces thesigmasite andy ~ NegativeBinomial2(softplus(mu), phi); the identity-linkmucompositiona_geo[geo_idx] + surface (+ tau)is unchanged, with a softplus positivity guard). The summary block below stays Gaussian for both likelihoods: a lift ± se is a normal-approximation aggregate, and the geo intercept AND any national per-period constant cancel in the difference.time_effect="national"(withperiod_idx (N,)/n_period) adds a zero-centered hierarchical national shocktau_t ~ Normal(0, sigma_tau)to every panel row of periodt. Zero-centered partial pooling, NOT fixed effects — a free tau level is exactly collinear with the intercept hyperA(both are national constants); the hierarchical prior resolves it softly and the pre-period still pinsa_geo. The tau sites are only sampled on the opt-in path, so the default graph is untouched.y_loc/y_scale(defaults0/1= the original O(1) priors, byte-identical graph) put the intercept/noise/effect priors on the KPI’s natural scale:A ~ N(y_loc, 5·y_scale),sigma_a ~ HalfNormal(2·y_scale),sigma ~ HalfNormal(y_scale), and thebeta/gammascales multiply byy_scaletoo — the incremental response must reach y-magnitude, so the effect ceilings live on the y scale.fit()derives them from the data.The panel likelihood is gated on
spend.shape[0] > 0— a summaries-only fit skips the geo-intercept plate and observation-noise sites entirely. The optional summary block (summary_test (M, K),summary_base (M, K),summary_scale (M,),summary_lift (M,),summary_se (M,)) adds one Gaussian observation per historical lift readout:lift_m ~ Normal(scale_m * (R(test_m) - R(base_m)), se_m)
The geo intercept cancels in the difference, so summaries need no pre-period. Works with any activation.
- class mmm_framework.continuous_learning.model.Posterior(samples, channels, pairs, pair_signs=<factory>, activation='hill', likelihood='normal', time_effect='none', spend_ref=None, diagnostics=<factory>)[source]¶
Bases:
objectPosterior draws plus the metadata the planner needs.
samplesholds plain numpy arrays keyed by site name (beta,kappa,alpha,A,sigma_a,a_geo,sigma, and onegamma_<ci>_<cj>per pair; the intercept/noise sites are absent for a summaries-only fit). Alikelihood="negbinomial"fit carriesphi(the NB concentration) instead ofsigma; alikelihood="studentt"fit carriesnu(the tail df) alongsidesigma; atime_effect="national"fit addssigma_tauandtau((draws, n_period)).spend_refis the per-channel reference constant used to scale spend — convert withto_dollars()/to_scaled().- draw_params(d)[source]¶
Per-draw params for the surface functions, activation-agnostic.
Returns
{beta, gamma, shape, act_fn, activation}whereshapeis a tuple of(K,)arrays in the activation’s parameter order andact_fnis its JAX activation. (For the Hill default the shape is(kappa, alpha).)
- __init__(samples, channels, pairs, pair_signs=<factory>, activation='hill', likelihood='normal', time_effect='none', spend_ref=None, diagnostics=<factory>)¶
- mmm_framework.continuous_learning.model.fit(data, *, channels, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', beta_scale=1.0, gamma_scale=0.8, num_warmup=500, num_samples=500, num_chains=2, seed=0, progress_bar=False, spend_ref=None, prior_scaling='auto', discount_half_life=None, spline_prior='iid')[source]¶
Fit the response surface to a geo-week panel and/or summary readouts.
- Parameters:
data (
dict[str,Any]) – the data contract —{"spend": (N, K), "geo_idx": (N,), "n_geo": int, "y": (N,)}(scaled spend, natural-unity), optionally with"summaries": list[dict](see the module header), an optional"period_idx": (N,)(0-based int; required whentime_effect="national"), or summaries-only ({"summaries": [...], "n_geo": 0}/ an empty(0, K)panel). At least one of panel rows / summaries required.pairs (
list[tuple[int,int]] |None) – interaction pairs to model (default: all upper-triangular pairs).pair_signs (
dict[tuple[int,int],str] |None) – sign-informed prior family per pair (default: all"weak"). Keys are normalized to(min, max)orientation; conflicting duplicate entries raise.likelihood (
str) – panel observation family —"normal"(default, byte-identical to the original graph),"studentt"for heavy-tailed robust continuous KPIs (a learned tail dfnujoinssigma; outlier geo-weeks stop dragging the surface), or"negbinomial"for count KPIs (ymust be non-negative integer counts; the summary block stays Gaussian either way). Link caveat for the planner: with"negbinomial"the marginal readouts (marginal_roas,expected_regret, free-mode allocation) differentiate the LATENT surfaceR, while the observable count mean issoftplus(mu)whose derivative issigmoid(mu)— so the count-scale marginal response issigmoid(mu) * dR/ds. Sincesigmoid(mu) ≈ 1whenevermu >> 1(typical counts), the readouts are asymptotically exact; nearmu ≈ 0(per-row means of a few counts) they OVERSTATE the count-scale marginal response by up to1/sigmoid(mu).fit()warns loudly when the observed count mean is below ~20. Fixed-budget Thompson argmax is unaffected (softplus is monotone).time_effect (
str) –"none"(default) or"national"— a zero-centered hierarchical per-period national shocktau_tadded to the panel mean (a non-empty panel requiresdata["period_idx"]; seemodel()). A summaries-only fit needs NO period identity: there are no panel rows fortau_tto index and a national per-period constant cancels in the lift difference, so the summary path is unchanged (no tau sites are sampled).gamma_scale (
float) – interaction prior scale — the most consequential knob; audit it withprior_sensitivity()(guide §8.2).spend_ref (
ndarray|None) – per-channel reference constant used to scale spend, carried on the returnedPosteriorfor dollar mapping (seeto_dollars()).prior_scaling (
str) –"auto"(default) derives the intercept/noise/effect prior scales from the evidence so natural-unit KPIs at any magnitude fit sanely;"unit"reproduces the original O(1) priors exactly (see_resolve_prior_scaling()).discount_half_life (
float|None) – information-decay half-lifehin WEEKS (None— the default — is the historical static fit, every row weighing the same forever). When set, each panel row’s likelihood is discounted by its age: the observation scale is inflated tosigma * exp(0.5 * lambda * age)withlambda = ln2 / h— the math page’s Eq. 22 decay clock moved INTO the likelihood (West–Harrison discounting; the count family power-scales the log-likelihood byexp(-lambda * age)instead, since it has no noise scale). Row ages come fromdata["row_age"]((N,)weeks,0= newest —LearningStatemaintains this automatically) or, failing that, are derived fromdata["period_idx"]asmax - period; a discounted panel fit with neither raises. Summaries may carry an"age_weeks"key — their SE is inflated by the same clock. The effect: effective sample size SATURATES instead of growing without bound, so the posterior keeps an honest variance floor when media behaviour drifts (a static fit’s bands shrink like 1/sqrt(rows) while the truth moves — narrow and wrong). Useestimate_half_life()to measurehfrom the programme’s own wave-to-wave drift; a measuredlambda ~ 0recovers full pooling.spline_prior (
str) – monotone-spline weight prior —"iid"(default, the historical independentLogNormal(0, 1)weights, byte-identical) or"pspline": a first-order random walk on the log-weights with a learned per-channel smoothnessw_tau(P-spline / adaptive-smoothing construction).tau -> 0collapses to the neutral near-linear ramp, so the EFFECTIVE basis dimension grows only as the designed cells earn it — the adaptive answer to the fixed-basis approximation-bias floor (math page Eq. 24) without hand-tuning the knot count. Only valid withactivation="monotone_spline"; the publicw1..wJsites are unchanged (the acquisition layer and planner need no changes).
- Return type:
- Returns:
A
Posteriorwith merged-chain numpy samples, R-hat/ESS diagnostics on the key parameters, anddiagnostics["evidence"] = {"n_rows", "n_summaries"}.
- mmm_framework.continuous_learning.model.refit_fn_from_data(base_data, *, channels, pairs=None, pair_signs=None, activation='hill', likelihood='normal', beta_scale=1.0, gamma_scale=0.8, num_warmup=200, num_samples=200, num_chains=1, seed=7, prior_scaling='auto', time_effect='none')[source]¶
Build a
refit_fn(extra_spend, extra_geo_idx, extra_y) -> Posterior.The knowledge-gradient acquisition (
planner.knowledge_gradient()) fantasizes wave outcomes and refits; this closure appends the fantasy rows tobase_dataand runs a short NUTS chain. This is the expensive path — see guide §9.1 for the Laplace-update replacement in production.Requires a panel base dataset: fantasy observations are geo-week rows, so a summaries-only data dict has nothing to append them to.
likelihoodmust match the posterior the knowledge gradient is scoring: the fantasyyvalues are drawn from that same observation family (Gaussian, Student-t, or NB counts), so refitting them under a different family would score the design against a model nobody will run.time_effectother than"none"raisesNotImplementedError: the knowledge-gradient fantasy rows carry no period identity, so a nationaltau_tcannot be refit on the augmented panel. Aperiod_idxpresent inbase_datais DROPPED from the refit base for the same reason (the refit models no time effect, so the index is inert either way).
Design¶
Layer 2 — experimental design (central-composite geo cells).
A wave is a designed batch of geo cells run for a fixed window. The central-composite design (CCD) is the minimal set of scaled allocations that identifies the local response surface around an operating point:
1 center cell — the current operating allocation (the trust-region center).
2K axial cells — each channel scaled to
(1 +/- delta)while the others hold at center; these give the gradient / main effects.2 off-axis cells per probed pair — two channels moved jointly
(1 +/- delta); the only way to recoverd^2 R / ds_c ds_c'(the synergy gamma). Probe the decision-pivotal pairs only.K shutoff cells — one channel set to 0. These isolate the remaining terms and break the beta/gamma collinearity; without them beta attenuates.
delta is a multiplicative trust-region step (a spend-variation fraction):
delta = 0.6 moves a channel +/-60% off its center. A shutoff is the
-100% corner. Working multiplicatively keeps the design scale-free, so it
behaves the same whether spend is scaled to O(1) or left in dollars.
- mmm_framework.continuous_learning.design.central_composite(center, delta, probe_pairs)[source]¶
Build the CCD cells (rows = cells, columns = channels).
- Parameters:
- Return type:
- Returns:
A
(n_cells, K)array of non-negative scaled allocations, withn_cells = 1 + 2K + 2 * len(probe_pairs) + K.
- mmm_framework.continuous_learning.design.assign_geos(design, n_geo, rng, *, n_holdout=0, center=None, baseline=None)[source]¶
Assign CCD cells to geos — shuffled round-robin, or stratified/blocked.
Without a
baseline(the default, byte-identical to the historical behavior) cells are tiled round-robin and shuffled: balanced cell counts, zero covariate awareness, and the firstn_holdoutgeos (positional) become holdouts.With a
baseline(a per-geo covariate such as the pre-period KPI level, matched-market style) the assignment is a classic blocked randomization: geos are sorted by baseline, walked in blocks ofn_cells, and each block receives a random permutation of the cell indices (the ragged tail gets a random subset without replacement). Every cell’s geos are then spread evenly across the baseline distribution, so between-cell baseline means are nearly equal. Holdouts are carved FIRST and are stratum-aware too: exactlyn_holdoutevenly spaced positions in baseline-sorted order, so the status-quo counterfactual spans the baseline range AND honors the requested count (a strided pick would silently under-deliver whenevern_holdoutdoes not dividen_geoevenly).- Parameters:
design (
ndarray) – CCD cells, shape(n_cells, K).n_geo (
int) – number of geos.rng (
Generator) – a numpy random generator (caller owns the seed).n_holdout (
int) – holdn_holdoutgeos atcenterfor the test window (a status-quo counterfactual). Requirescenter.center (
ndarray|None) – the status-quo allocation for holdout geos, shape(K,).baseline (
Union[ndarray,Sequence[float],None]) – optional per-geo covariate, lengthn_geo, positionally aligned with the geo indices (the caller resolves geo ids).Nonekeeps the legacy shuffled round-robin path.
- Return type:
- Returns:
(geo_alloc, cell_idx)wheregeo_allocis(n_geo, K)(each geo’s test allocation) andcell_idxis(n_geo,)(the design-row index, or-1for a holdout geo).
Planner¶
Layer 4 — planner / acquisition.
The decision is max_s value * R(s) - 1^T s under one of two regimes:
fixed budget (
mode="fixed"):1^T s = B— a pure simplex.free budget (
mode="free"):0 <= s_c <= cap— the total is itself a decision; each channel self-funds until its marginal ROAS hits 1.
Every routine here differentiates the same incremental surface the
likelihood fits (via mmm_framework.continuous_learning.surface.grad_incremental),
so the allocator can never disagree with the fitted model. The surface is
non-concave (negative gamma), so the allocator multi-starts and keeps the
best.
Acquisition / readouts:
plan_from_posterior()— ONE Thompson pass producing every per-wave readout coherently (recommendation, funding line at it, warm-started regret from it) as aPlanResult; prefer it over calling the pieces below separately, which each re-sample.thompson_wave()— a posterior over the optimal split (the spread is the exploration signal; the mean is the recommendation).marginal_roas()— the funding line: a channel is funded whereP(value * dR/ds_c > 1) > 0.5.expected_regret()—E[regret]from posterior uncertainty; withenbs()it drives the stopping rule.knowledge_gradient()— decision-aware EVSI for scoring candidate test designs (the expensive path; see guide §7.5/9.1).
Import-light apart from JAX (shared with the model) and SciPy SLSQP.
- mmm_framework.continuous_learning.planner.response_grid(post, spend_matrix, draws)[source]¶
Incremental response for every
(spend row, draw)pair ->(G, D).Activation-agnostic (reads
post.activation): the visualizations use this to build the posterior mean/uncertainty surfaces for any activation family, so the same animation code renders a Hill world or a logistic one.- Return type:
- mmm_framework.continuous_learning.planner.allocate_under_sample(params, B, value, *, x0=None, n_starts=3, seed=0, mode='fixed', cap=None, group_budgets=None)[source]¶
Optimal allocation + profit under one posterior draw’s params.
group_budgetsfixes sub-budgets over channel groups (arms):[(indices, B_g), ...]addssum(s[indices]) == B_gper group.
- mmm_framework.continuous_learning.planner.thompson_wave(post, B, value, *, q=300, seed=0, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]¶
A posterior over the optimal split — solve the allocation for
qdraws.Returns
(allocs, profits, draws):allocsis(q, K)(the mean is the recommended allocation, the spread is the exploration signal),profitsis(q,)(each draw’s optimum under itself), anddrawsare the sampled draw indices.
- mmm_framework.continuous_learning.planner.recommend_allocation(post, B, value, **kwargs)[source]¶
The recommended allocation — the Thompson posterior mean split.
- Return type:
- mmm_framework.continuous_learning.planner.marginal_roas(post, alloc, value, *, q=300, seed=1)[source]¶
Posterior of
value * dR/ds_catalloc— the funding line.Returns
(mean_mroas, prob_above_line, draws_mroas). A channel is funded whereprob_above_line > 0.5.
- mmm_framework.continuous_learning.planner.expected_regret(post, B, value, *, q=300, seed=2, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]¶
Expected regret of acting on the consensus allocation (guide §7.4).
regret_d = profit(best-for-draw-d under d) - profit(consensus under d) >= 0— the profit still on the table from posterior uncertainty. Each draw’s “best” ismax(cold multistart, a solve warm-started FROM the consensus, profit at the consensus)(review fix F3): the warm-started second pass catches per-draw optima the cold multistart missed near the consensus, so the regret is less biased low and the ENBS stop fires less prematurely. Returns(E[regret], consensus_alloc, alloc_sd, optimal_profit_sd).
- mmm_framework.continuous_learning.planner.posterior_optimal_allocation(post, B, value, *, q=200, seed=3, mode='fixed', cap=None, n_starts=4, group_budgets=None)[source]¶
argmax_a E_post[profit(a)]and its value — optimize the mean surface.
- class mmm_framework.continuous_learning.planner.PlanResult(channels, B, value, mode, recommendation, consensus, allocs, profits, draws, mroas_mean, prob_above_line, mroas_draws, e_regret, alloc_sd, profit_sd)[source]¶
Bases:
objectEvery per-wave decision readout, derived from ONE Thompson sample.
Historically
recommend/funding/regreteach re-sampled with a different seed (planner seeds 0/1/2), so the funded set and the reported regret referred to different consensus vectors and paid 2-3x the SLSQP cost. Here all readouts share the sameqdraws:recommendationis the Thompson mean (rescaled onto the budget simplex infixedmode),consensusis that same vector, the funding line is evaluated at it, and the regret pass is warm-started from it.- __init__(channels, B, value, mode, recommendation, consensus, allocs, profits, draws, mroas_mean, prob_above_line, mroas_draws, e_regret, alloc_sd, profit_sd)¶
- mmm_framework.continuous_learning.planner.plan_from_posterior(post, B, value, *, q=300, seed=0, mode='fixed', cap=None, n_starts=3, group_budgets=None)[source]¶
One Thompson pass producing every decision readout coherently (fix F4).
Runs
thompson_wave()once; the recommendation is the draw-mean (rescaled to the simplex infixedmode); the funding line and the warm-started regret pass reuse the SAME draw indices, so the funded set, the consensus, andE[regret]all describe one allocation.- Return type:
- mmm_framework.continuous_learning.planner.enbs(e_regret, *, margin, population, wave_cost)[source]¶
Expected net benefit of sampling
= E[regret] * margin * population - cost.E[regret]is profit-per-affected-unit on the response-curve scale;marginandpopulationconvert it to a total dollar value of resolving the uncertainty, which a wave must beat to be worth running.- Return type:
- mmm_framework.continuous_learning.planner.should_stop(e_regret, *, margin, population, wave_cost)[source]¶
(stop, enbs)— stop when no wave’s expected value clears its cost.
- mmm_framework.continuous_learning.planner.knowledge_gradient(post, candidate_design, refit_fn, B, value, *, n_fantasy=10, t_test=10, n_geo=None, noise=None, mode='fixed', cap=None, q=120, seed=4)[source]¶
One-step-lookahead EVSI for a candidate test design (guide §7.5).
- Return type:
- ``KG(d) = E_y[ max_a profit(a | posterior updated with fantasised y) ]
max_a profit(a | current posterior)``
For each fantasy: draw params, simulate the candidate design’s outcomes, refit via
refit_fn(extra_spend, extra_geo_idx, extra_y) -> Posterior, and re-optimize. Expensive —refit_fnruns a (short) NUTS chain per fantasy; in production swap it for a Laplace/conjugate update (guide §9.1).noise=None(default) fantasizes with the posterior mean ofsigma— the fitted observation noise — rather than a hard-coded constant. Fantasy outcomes are drawn from the posterior’s own observation family (_fantasy_outcomes()): Gaussian, Student-t (posterior-meannu), or NB counts (posterior-meanphi;noiseignored).refit_fnmust refit under that same family — build it withmodel.refit_fn_from_data()’s matchinglikelihood.Requires a panel-fitted posterior: fantasies simulate geo-week outcomes off the geo intercepts (
a_geoor theA/sigma_ahypers), which a summaries-only fit never samples.
Loop¶
The outer control loop: carry the posterior across waves and know when to stop.
LearningState accumulates every wave’s data and refits the response
surface on all of it each pass — that is how the posterior is “carried”
across waves (each wave borrows strength from all prior data). It exposes the
per-wave decision readouts (recommend / funding line / regret / stop) over the
current posterior.
run_closed_loop() drives a TrueWorld
through the full loop — fit -> score -> run designed wave -> update -> ENBS stop
— and is both the runnable demo and the closure/stopping test backbone. In a
real deployment you would replace the synthetic simulate_wave collector with
the actual geo holdout results; nothing else changes.
Re-test scheduling reuses the framework’s existing information-decay model
(mmm_framework.planning.eig.reexperiment_due()) so a continuous-learning
program and a model-anchored program agree on when evidence has gone stale.
- mmm_framework.continuous_learning.loop.world_optimal_allocation(world, B, value, *, mode='fixed', cap=None, n_starts=8, seed=0)[source]¶
The truth-optimal allocation + profit (the recovery/closure target).
- class mmm_framework.continuous_learning.loop.WaveRecord(wave, n_rows, e_regret, enbs, stop, recommendation, funded, mroas_mean, prob_above_line, profit_gap, profit_gap_rel, max_rhat, n_summaries=0, kg_used=False, chosen_delta=None)[source]¶
Bases:
objectA JSON-safe snapshot of one wave’s decision state.
kg_used/chosen_deltadescribe the designed wave whose data this record’s fit ingested: whether the Laplace knowledge-gradient selected the design (seeselect_next_design()) and which trust-regiondeltait chose. Both default to the fixed-design values so historical records (and the byte-stable default loop) are unchanged.- __init__(wave, n_rows, e_regret, enbs, stop, recommendation, funded, mroas_mean, prob_above_line, profit_gap, profit_gap_rel, max_rhat, n_summaries=0, kg_used=False, chosen_delta=None)¶
- class mmm_framework.continuous_learning.loop.LearningState(channels, center, B, value, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', mode='fixed', cap=None, beta_scale=1.0, gamma_scale=0.8, spend_ref=None, discount_half_life=None, spline_prior='iid', data=None, posterior=None, history=<factory>, summaries=<factory>, geo_ids=None, stationarity_reports=<factory>)[source]¶
Bases:
objectAccumulated experiment data + the current posterior + decision readouts.
spend_ref(dollars per scaled unit, per channel) is carried for the dollar boundary — convert withto_dollars()/to_scaled(); everything inside the state (center, panel spend, summaries) is scaled units.geo_ids(optional, from the data dict’s"geo_ids"key) pins the geo identity across waves — the misspecification study showed the loop diverges if geo baselines are silently re-drawn under the samegeo_idx.likelihood/time_effectmirrormodel.fit()’s knobs (defaults reproduce the old behavior exactly) and are threaded on every refit; atime_effect != "none"program requires and accumulates a globalperiod_idxacross waves (seeingest()).-
history:
list[WaveRecord]¶
- ingest(wave_data, *, check_stationarity=False)[source]¶
Append a wave’s rows to the accumulated panel (same geos throughout).
wave_datamay carry an optional"geo_ids"key (list[str]of lengthn_geo): the first wave pins the program’s geo identities and later waves must match them exactly (order included).check_stationarity=True(opt-in; requires the wave to carryperiod_idxandcell_idx) runs the within-wave stationarity guard (wave_stationarity_check()) before ingesting: the report is appended toself.stationarity_reportsand a flagged mid-wave regime change raises aUserWarning— the wave is still ingested, so the caller decides whether tocensor_periods()and re-ingest, or extend/repeat the wave.When the program models a national time effect (
time_effect != "none"), every wave MUST carry a"period_idx"(wave-local, 0-based); it is accumulated with a per-wave offset (shifted by the accumulated maximum + 1) so two waves’ shocks never alias onto one globaltau_t. Programs withtime_effect="none"ignore anyperiod_idxa wave carries (the accumulated data dict is unchanged).- Return type:
- ingest_summaries(items)[source]¶
Append summary observations (historical lift readouts, no panel).
Each item follows the summary schema in
mmm_framework.continuous_learning.model(spend_test/spend_basein SCALED units, totallift±sein natural KPI units,scale= geo-periods aggregated). Validated eagerly.- Return type:
- fit(**fit_kwargs)[source]¶
Refit the surface on ALL accumulated evidence (carries the posterior).
The state’s
discount_half_life/spline_priorare threaded to every refit (both overridable per call viafit_kwargs). When discounting is active, per-row ages are derived from the accumulatedrow_weekcolumn thatingest()maintains (age = newest week − row week, in weeks).- Return type:
- plan(**kwargs)[source]¶
All per-wave readouts from ONE Thompson pass (see fix F4).
Delegates to
plan_from_posterior()with this program’s budget/value/mode/cap; passq/seed/group_budgetsetc. through as keyword arguments.- Return type:
- recenter(alloc, *, min_frac=0.05)[source]¶
Move the trust-region center to
alloc, floored away from zero.The CCD is multiplicative (review fix F5): recentering a channel onto ~0 zeroes every axial/off-axis/shutoff cell for it — no variation, so beta is unidentified and the spend floor’s zero gradient means the channel can never be resurrected by the loop. Each channel is therefore clamped to
>= min_frac * (B / K)in the center’s scaled units (min_frac=0restores the old unfloored behavior). Warns whenever a clamp fires.- Return type:
- __init__(channels, center, B, value, pairs=None, pair_signs=None, activation='hill', likelihood='normal', time_effect='none', mode='fixed', cap=None, beta_scale=1.0, gamma_scale=0.8, spend_ref=None, discount_half_life=None, spline_prior='iid', data=None, posterior=None, history=<factory>, summaries=<factory>, geo_ids=None, stationarity_reports=<factory>)¶
-
history:
- mmm_framework.continuous_learning.loop.select_next_design(post, center, pairs, B, value, *, mode='fixed', cap=None, candidate_deltas=(0.3, 0.6, 0.9), candidate_probe_sets=None, n_geo=80, t_test=10, n_outcomes=32, seed=0, fallback_delta=0.6, cost_fn=None)[source]¶
Score candidate CCDs with the Laplace knowledge-gradient; pick the best.
Candidates are
central_composite(center, delta, probe_set)for everydeltaincandidate_deltas(clipped to(0, 1]) × every probe set incandidate_probe_sets(default: justpairs). Each candidate is scored withlaplace_knowledge_gradient()— decision-aware EVSI, no MCMC — using the SAMEseedfor every candidate (common random numbers, so the Monte-Carlo argmax does not flap). The Fisher weights come from the fitted observation family (observation_unit_info()): Gaussian and Student-t posteriors need thesigmasite (the noise scale), NegBinomial posteriors needphiplus theA/a_geobaseline. There is deliberately NO numeric fallback for missing sites: a summaries-only fit (whosebeta/gammalive on the KPI’s natural scale underprior_scaling="auto") has no meaningful observation scale to score with, so a hard-coded guess would make every candidate’s Fisher information explode identically and the reported per-candidate scores carry no information while claimingkg_used=True.cost_fn(optional) makes the selection cost-aware: each candidate is ranked byKG / cost_fn(cells)— decision value per dollar, the knowledge-gradient analogue of expected-improvement-per-unit-cost (EIpu; Lee, Perrone, Archambeau & Seeger 2020). Cell counts grow ~3 per extra arm plus 2 per probed pair, so a cost-blind KG systematically over-favors large designs; dividing by the design’s own cost is the greedy myopic version of the ENBS cost-benefit trade (planner.enbs), which keeps the acquisition and the stopping rule coherent.cost_fnreceives the candidate’s cell matrix and must return a positive finite cost (e.g.lambda cells: fixed + per_cell * len(cells)). The rawscoreand the rankingscore_per_costare both recorded;cost_fn=Noneis byte-identical to the historical ranking.Degrades gracefully: an activation without a transform spec, an unknown likelihood family, a posterior missing its observation-noise sites (summaries-only fit), a non-finite candidate score, a non-positive or non-finite candidate cost, a near-singular moment-matched covariance, or any scoring
ValueErrorfalls back to the fixed designcentral_composite(center, fallback_delta, pairs).- Returns:
on success
{"kg_used": True, "chosen_delta", "chosen_probe_pairs", "kg_scores": [{"delta", "probe_pairs", "score"[, "cost", "score_per_cost"]}, ...], "sigma", "cost_aware", "surrogate"}(sigmaisNonefor a count posterior, which has no Gaussian noise scale); on fallback{"kg_used": False, "reason", "surrogate"}.surrogateis the compactsurrogate_validity()report (ok/khat/params_flagged…) — whenokis False the Gaussian surrogate is suspect for this posterior and the wave’s scores should be spot-checked against (or replaced by) the NUTS-refitknowledge_gradient().- Return type:
(cells, meta)— the chosen design array and a JSON-safe meta dict
- mmm_framework.continuous_learning.loop.run_closed_loop(world, *, center, B, value, n_geo=80, t_pre=6, t_test=10, delta=0.6, noise=0.6, n_holdout=0, mode='fixed', cap=None, gamma_scale=0.8, pair_signs=None, margin=1.0, population=1.0, wave_cost=0.05, max_waves=4, recenter=True, planner_q=120, fit_kwargs=None, use_laplace_kg=False, candidate_deltas=(0.3, 0.6, 0.9), kg_n_outcomes=32, stratify_geos=False, stop_patience=1, discount_half_life=None, spline_prior='iid', seed=0)[source]¶
Run the full loop against a known world and return the decision trace.
Each pass: refit on all data, compute the recommendation + funding line + expected regret, evaluate the ENBS stopping rule, then (unless stopping) recenter on the recommendation, run a fresh designed wave over the SAME geos, and ingest it. Returns the per-wave
WaveRecordhistory plus the final recommendation and the truth-optimal target for comparison.use_laplace_kg=True(opt-in; the default path is byte-identical to the historical loop) scorescandidate_deltaswithselect_next_design()each wave and runs the EVSI-best design instead of the fixed-deltaCCD; the choice is recorded on the NEXT wave’sWaveRecord(kg_used/chosen_delta).stratify_geos=True(opt-in) blocks the geo→cell randomization on the true per-geo baselinesa_geo(seeassign_geos()).stop_patience(default 1 — byte-identical to the historical loop) requires that many consecutiveENBS <= 0evaluations before the loop stops. Evaluating ENBS every wave is NOT frequentist optional stopping — a Bayesian expected-value rule is stopping-rule-invariant under the likelihood principle (Edwards, Lindman & Savage 1963; Berger & Wolpert 1988) — but that invariance assumes a correctly specified model, and the misspecification study shows intervals can be narrow and wrong.stop_patience=2is the cheap belt-and-suspenders guard: one optimistically-low regret read (from a temporarily overconfident, possibly misspecified posterior) cannot end the programme on its own. EachWaveRecord’sstopstays the raw per-wave verdict.
- mmm_framework.continuous_learning.loop.due_for_retest(sigma_post, weeks_elapsed, channel, sigma_exp, *, half_life_overrides=None)[source]¶
Whether a channel’s evidence has decayed enough to warrant a re-test.
Thin reuse of
mmm_framework.planning.eig.reexperiment_due()so the continuous-learning loop’s re-test trigger uses the framework’s existing information-decay model (sigma_eff^2(t) = sigma_post^2 exp(lambda t),lambda = ln2 / half_life) rather than a private one. Returns(due, current_eig_nats).
- mmm_framework.continuous_learning.loop.adstock_half_life(adstock_alpha)[source]¶
Half-life (periods) of a geometric adstock with retention
alpha.alpha**h = 1/2 => h = ln(1/2) / ln(alpha). The advertising literature finds carryover half-lives are strongly category-specific (industry FMCG figures cluster near 2–2.5 weeks against academic estimates of 7–12 — Fry, Broadbent & Dixon 2000), which is the empirical argument against one global decay constant: use each channel’s measured carryover as the anchor for its own evidence half-life, viadue_for_retest(..., half_life_overrides={channel: h}). The carryover half-life is a lower anchor — a finding decays at least as fast as the mechanism that produced it drifts, and audience/creative drift usually puts the evidence half-life at a multiple of it; when the programme has accumulated waves, prefer the measuredestimate_half_life().alpha <= 0(no carryover) returns0.0;alpha >= 1(no decay) returnsinf.- Return type:
- mmm_framework.continuous_learning.loop.estimate_half_life(gaps_weeks, shifts, sigmas)[source]¶
Estimate the information-decay half-life from realized posterior drift.
The decay clock (math page Eq. 22) inflates a finding’s variance as
sigma_eff^2(t) = sigma_post^2 * exp(lambda * t)withlambda = ln2 / h— buthneed not be guessed: the loop observes how fast its own beliefs drift. Between two fits of the same quantity separated by a gapt_j(weeks), the decay model says the realized mean shift is a draw with variancesigma_j^2 * (exp(lambda t_j) - 1)on top of the posterior spread, soz_j = (shift_j / sigma_j)^2has expectationexp(lambda t_j) - 1. The method-of-moments estimate solvesmean_j[z_j] = mean_j[exp(lambda t_j) - 1]forlambda(monotone — a bisection). This is the adaptive-discounting idea of the dynamic-linear-model literature: West & Harrison (1997, §6.3) model each component as losing a constant information fractiondeltaper period (delta = exp(-lambda)here, returned as"discount"), with separate discounts per component — i.e. estimate onehper channel, not one global constant.- Parameters:
- Return type:
- Returns:
{"half_life": h, "lambda": lam, "discount": exp(-lam), "n": J}—half_life = infwhen no excess drift is observed (the findings are not decaying measurably). Requires at least one observation with positive gap and sigma.
Acquisition¶
Fast acquisition — Laplace knowledge-gradient and pure-EIG (guide §9.1/9.2).
The reference mmm_framework.continuous_learning.planner.knowledge_gradient()
refits the model with full NUTS once per fantasy — too slow to score many
candidate designs. This module replaces that with a Gaussian-linear
treatment that needs no MCMC:
Moment-match the current posterior over the surface parameters to a Gaussian
N(mu, Sigma)— in an unconstrained reparameterizationeta(ThetaMap): positive parameters (beta,kappa,lam, …) are matched in log space, bounded ones (alpha, the mixture weightw) through a scaled logit, and sign-constrained synergies through a sign-aware log. Skewed posteriors of positive parameters are far closer to Gaussian on the log scale, and every fantasy sample maps back to a VALID parameter vector by construction (no clipping).Linearize the surface in
etaaroundmu(the Laplace step). A candidate design’s Fisher information is thenLambda = sum_cells w_c * u_c * g_c g_c^Twithg_c = d incremental / d etaandu_cthe observation family’s unit Fisher information at cellc(1/sigma^2for the Gaussian;(nu+1)/((nu+3) sigma^2)for the Student-t;sigmoid(eta)^2 / (m + m^2/phi)withm = softplus(eta)for the NegBinomial count family — the GLM weight through the softplus link). The posterior covariance after running the design isSigma_post = (Sigma^-1 + Lambda)^-1.
From this one linear-algebra object two acquisitions fall out cheaply:
Laplace knowledge-gradient (decision value, EVSI): the pre-posterior spread of the updated mean is
V = Sigma - Sigma_post; sample fantasy meanseta_m ~ N(mu, V), map them back to valid surface parameters, re-optimize the allocation for each, and average the uplift overmax_a profit(a | mu). Milliseconds instead of minutes.Pure EIG (information, D-/D_s-optimality): the entropy reduction of a parameter block
Sis0.5 * (logdet Sigma_SS - logdet Sigma_post_SS). Use the full block for D-optimality, or thegammasub-block (target= "gamma") for D_s-optimality — synergy-targeted waves the exploit-heavy Thompson waves under-probe. (EIG differences are invariant to the fixed reparameterization — the transform’s log-Jacobian cancels between the prior and posterior terms — so the D/D_s orderings match the constrained-space treatment.)
Any activation registered in surface.ACTIVATIONS with a transform spec
in SHAPE_TRANSFORMS is supported (Hill, logistic, hill_mixture,
monotone_spline), as is
any observation family in model.VALID_LIKELIHOODS. The geo intercept is
profiled out by centering the cell gradients across cells (the per-geo
baselines are randomized, so the identifying variation is between-cell),
mirroring planning.identification.
Prefer the Laplace KG when the goal is the decision; use EIG only to shore up decision-pivotal interactions (guide §9.2).
Two robustness layers guard the surrogate itself:
Numerical PSD safeguards.
V = Sigma - Sigma_postis PSD in exact arithmetic (Lambda >= 0) but finite-precision inversion/subtraction can leave tiny negative eigenvalues inVor a log-det sub-block.Vis symmetrized and eigenvalue-clipped before fantasy sampling, and every log-determinant goes through the same symmetrize-and-clip guard — the nearest-PSD projection of Higham (1988; 2002).Surrogate validity (
surrogate_validity()). The Gaussian moment-match is an approximation to the carried NUTS posterior; it degrades when the posterior is skewed, heavy-tailed or ridge-shaped (negative-gamma cannibalisation ridges are the expected failure mode — Kuss & Rasmussen 2005 for the intuition). The diagnostic scores the fit ofN(mu, Sigma)to the draws in the unconstrained space — per-parameter skew/kurtosis flags plus a generalized-Pareto tail-shapekhatof the Mahalanobis exceedances (the same Zhang & Stephens 2009 estimator and the same 0.7 alarm bar PSIS uses; Vehtari, Simpson, Gelman, Yao & Gabry 2024). When it fires, prefer the NUTS-refitplanner.knowledge_gradient()for that wave;loop.select_next_design()records the report in its meta.
- class mmm_framework.continuous_learning.acquisition.Transform(label, unconstrain, constrain)[source]¶
Bases:
NamedTupleA support <-> R bijection: numpy
unconstrain, JAXconstrain.
- mmm_framework.continuous_learning.acquisition.SHAPE_TRANSFORMS: dict[str, dict[str, Transform]] = {'hill': {'alpha': ('logit[0.5,5]', <function _interval.<locals>.unconstrain>, <function _interval.<locals>.constrain>), 'kappa': ('log', <function <lambda>>, jax.numpy.exp)}, 'hill_mixture': {'alpha1': ('logit[0.5,6]', <function _interval.<locals>.unconstrain>, <function _interval.<locals>.constrain>), 'alpha2': ('logit[0.5,5]', <function _interval.<locals>.unconstrain>, <function _interval.<locals>.constrain>), 'kappa1': ('log', <function <lambda>>, jax.numpy.exp), 'kappa2': ('log', <function <lambda>>, jax.numpy.exp), 'w': ('logit[0,1]', <function _interval.<locals>.unconstrain>, <function _interval.<locals>.constrain>)}, 'logistic': {'lam': ('log', <function <lambda>>, jax.numpy.exp)}, 'monotone_spline': {'w1': ('log', <function <lambda>>, jax.numpy.exp), 'w2': ('log', <function <lambda>>, jax.numpy.exp), 'w3': ('log', <function <lambda>>, jax.numpy.exp), 'w4': ('log', <function <lambda>>, jax.numpy.exp), 'w5': ('log', <function <lambda>>, jax.numpy.exp), 'w6': ('log', <function <lambda>>, jax.numpy.exp), 'w7': ('log', <function <lambda>>, jax.numpy.exp), 'w8': ('log', <function <lambda>>, jax.numpy.exp), 'w9': ('log', <function <lambda>>, jax.numpy.exp)}}¶
Per-activation transform spec for the shape parameters, matching the prior supports in
model._sample_activation_shape(). Register a new activation family here (plus insurface.ACTIVATIONSand the model’s shape sampler) and the whole acquisition layer picks it up.
- class mmm_framework.continuous_learning.acquisition.ThetaMap(channels, pairs, activation, site_transforms, gamma_transforms)[source]¶
Bases:
objectLayout + bijection between posterior sites and the packed
etavector.The flat layout is
[beta (K), *shape sites (K each), gamma_pairs (P)]— the Hill case reproduces the historical[beta, kappa, alpha, gamma]packing order. All moment matching, linearization and fantasy sampling happen in the unconstrainedetaspace;constrain_params()maps anyetaback to a VALID planner params dict.- unconstrain_draws(samples)[source]¶
Posterior draws -> the unconstrained
(n_draws, dim)matrix.- Return type:
- constrain_params(eta)[source]¶
eta-> a planner params dict (allocate_under_sampleformat).The transforms enforce every support constraint, so the result is always a valid surface parameterization — no clipping needed.
- __init__(channels, pairs, activation, site_transforms, gamma_transforms)¶
- mmm_framework.continuous_learning.acquisition.theta_map(post)[source]¶
Build the
ThetaMapfor a posterior’s activation + pair signs.- Return type:
- mmm_framework.continuous_learning.acquisition.eta_grad(eta_bar, spend_rows, tmap)[source]¶
d incremental / d etaateta_barfor each spend row -> (n, dim).The chain rule through the constraining bijection is automatic — JAX differentiates
surface(constrain(eta))directly.- Return type:
- mmm_framework.continuous_learning.acquisition.theta_moments(post, *, ridge=1e-06, tmap=None)[source]¶
Gaussian
(mu, Sigma)matched to the posterior — inetaspace.The match happens in the unconstrained reparameterization of
theta_map(): positive parameters in log space, bounded ones through a scaled logit, sign-constrained synergies through a sign-aware log. Any activation with aSHAPE_TRANSFORMSentry is supported; an unknown family raisesNotImplementedError(the planner’s decision readouts stay activation-agnostic).
- mmm_framework.continuous_learning.acquisition.surrogate_validity(post, *, tmap=None, tail_prob=0.25, khat_threshold=0.7, skew_threshold=0.5, kurt_threshold=1.0)[source]¶
Diagnose whether
N(mu, Sigma)is a valid stand-in for the posterior.The Laplace acquisitions replace the carried NUTS posterior with its Gaussian moment-match in the unconstrained
etaspace (Eq. 12 of the math page). This runs entirely off the existing draws — no densities, no refit — and checks the two ways that stand-in fails: :rtype:dict[str,Any]Shape: per-parameter skewness and excess kurtosis of the unconstrained draws (a Gaussian has both ~0; the transforms already absorb the generic positivity skew, so what remains is real).
Tails / ridges: the generalized-Pareto shape
khatof the Mahalanobis-distance exceedances above their1 - tail_probquantile. Under a genuinely Gaussian posterior the squared distances are chi-square (exponential-class tail,khat ~ 0); a skewed, heavy-tailed or ridge-shaped posterior (the negative-gammacannibalisation ridge is the expected offender) inflateskhat. The alarm bar is the same 0.7 PSIS uses (Vehtari et al. 2024), estimated with the same Zhang–Stephens fit.
Returns a JSON-safe dict:
ok(False whenkhatclears the threshold, any parameter is flagged, or there are too few draws to tell),khat,params_flagged,max_abs_skew,max_excess_kurtosis, and theper_paramdetail. Whenokis False, spot-check or replace the wave’s Laplace scores with the NUTS-refitknowledge_gradient()(the correction path in the literature is a skewness-aware expansion — Rue, Martino & Chopin 2009).
- mmm_framework.continuous_learning.acquisition.observation_unit_info(post, design_cells, tmap, mu_eta, *, sigma=None)[source]¶
Per-cell Fisher information of ONE observation, for the fitted family. :rtype:
ndarray"normal":1 / sigma^2(homoskedastic — constant across cells)."studentt":(nu + 1) / ((nu + 3) sigma^2)with the posterior-mean tail df — the classic heavy-tail efficiency discount (< the Gaussian info at any finitenu, recovering it asnu -> inf)."negbinomial": the GLM weight through the model’s softplus link,sigmoid(eta_c)^2 / (m_c + m_c^2 / phi)witheta_c = baseline + R(s_c; mu),m_c = softplus(eta_c)and the posterior-meanphi. The baseline is the posterior-meanA(or the grand mean ofa_geowhen the hyper is absent).sigmais ignored — a count family has no Gaussian noise scale.
sigma=Nonereads the posterior-meansigmasite for the Gaussian families; a missing required site raisesValueError(summaries-only fits never sample the observation sites, and no fixed guess is meaningful across KPI scales).
- mmm_framework.continuous_learning.acquisition.design_information(design_cells, theta_bar, *, sigma=None, k=None, pairs=None, tmap=None, unit_info=None, cell_weights=None, n_geo=80, t_test=10, residualize=True)[source]¶
Fisher information
Lambda(dim×dim) a design carries abouteta.Lambda = sum_c w_c u_c (g_c - g_bar)(g_c - g_bar)^Twhereg_cis the unconstrained-space parameter gradient at cellc,u_cthe observation family’s per-cell unit information (unit_info; defaults to the homoskedastic Gaussian1/sigma^2), andg_barthe weighted mean across cells (profiling the geo intercept).residualize=Falseskips the centering (treats the baseline as known).theta_baris the unconstrainedetavector fromtheta_moments(). Pass the matchingtmap; when omitted, a default Hill map with identity gamma transforms is built from(k, pairs)— only correct for a Hill posterior with default (“weak”/”zero”) pair signs.- Return type:
- mmm_framework.continuous_learning.acquisition.gaussian_eig(sigma0, lam, *, idx=None)[source]¶
EIG (nats) of a parameter block under a Gaussian-linear update.
0.5 * (logdet Sigma0_SS - logdet Sigma_post_SS)for the blockidx(all parameters ifNone) — the entropy the design removes. Both log-dets run through the_logdet_psd()symmetrize-and-clip guard:Sigma_postis PSD in exact arithmetic (Lambda >= 0) but the finite-precision double inversion can leave a marginal sub-block — equivalently the Schur complementLambda_{S|rest}— with tiny negative eigenvalues.- Return type:
- mmm_framework.continuous_learning.acquisition.design_eig(post, design_cells, *, sigma=None, target='all', n_geo=80, t_test=10, cell_weights=None)[source]¶
Pure information gain of a design (D-optimal
target="all"or D_s-optimaltarget="gamma"over the synergy sub-block).Works for any registered activation and any fitted observation family;
sigma=Nonederives the noise scale (Gaussian families) from the posterior.sigmais ignored for a count posterior — the GLM weights come fromphiand the baseline instead.- Return type:
- mmm_framework.continuous_learning.acquisition.laplace_knowledge_gradient(post, design_cells, B, value, *, sigma=None, n_geo=80, t_test=10, n_outcomes=64, mode='fixed', cap=None, n_starts=3, cell_weights=None, seed=0)[source]¶
Decision-aware EVSI of a candidate design, with no refit (guide §9.1).
Gaussian-linear surrogate for
mmm_framework.continuous_learning.planner.knowledge_gradient(): the pre-posterior spread of the updated mean isV = Sigma - Sigma_post; fantasy meanseta_m ~ N(mu, V)are mapped back to valid surface parameters, re-optimized and averaged against the current best. Orders designs the same way as the NUTS KG at a fraction of the cost.Works for any registered activation and fitted observation family (the Fisher weights come from
observation_unit_info());sigma=Nonederives the Gaussian-family noise scale from the posterior.Each fantasy’s re-optimization is warm-started at the base (
mu) allocation, in addition to the usual uniform/random multi-starts. Fantasy means are draws local tomu(Vis a reduction in spread from the prior), so the true optimum is almost always near the base one; without the anchor, a non-concave surface (gammacan be negative) evaluated with only a handful of random restarts can converge to a different local optimum depending on the platform’s BLAS/LAPACK build (SLSQP’s floating- point path through the same starting point is not bitwise-portable), occasionally flipping a close two-candidate comparison. Anchoring removes that basin-hopping risk at no extra cost (it replaces one of the existing random starts, per_starts()).- Return type:
Dgp¶
Synthetic geo-RSM world with causal ground truth (recovery + fantasies).
A TrueWorld is a known response surface (beta, kappa, alpha,
gamma) plus geo-intercept hyperparameters. simulate_panel() runs a
central-composite wave against it and returns the data contract of guide §3 plus
the answer key — this is BOTH the recovery harness (fit and compare to truth) and
the engine that fantasizes wave outcomes inside the knowledge-gradient
acquisition.
The DGP evaluates the same mmm_framework.continuous_learning.surface()
functions the model fits, so a successful recovery test certifies the exact map
the planner later exploits — there is no second, drifting implementation of the
surface.
Identification (guide §3.2) is built in: a pre-period where every geo shares the
center allocation (pins each geo intercept), then a test-period of designed
cross-sectional variation (the CCD cells). Without the pre-period the geo
intercept and the incremental response are collinear.
- class mmm_framework.continuous_learning.dgp.TrueWorld(beta, gamma_pairs, channels, kappa=None, alpha=None, activation='hill', shape=<factory>, pairs=<factory>, a_level=4.0, sigma_a=1.0, phi_true=10.0, nu_true=4.0)[source]¶
Bases:
objectA known response surface for recovery / closure testing.
gamma_pairsis aligned topairs(one synergy value per pair); the matrix form is assembled on demand.A/sigma_aset the geo-intercept distribution. The activation is pluggable: pass Hillkappa/alpha(the default and back-compatible path), or setactivation+shapefor another family (e.g.activation="logistic",shape={"lam": …}).phi_trueis the NegativeBinomial concentration used when a simulation asks fornoise_family="negbinomial";nu_trueis the Student-t tail df used bynoise_family="studentt"(each ignored otherwise).- response_mean(spend_matrix)[source]¶
Mean incremental response for a
(N, K)spend panel (no noise).- Return type:
- __init__(beta, gamma_pairs, channels, kappa=None, alpha=None, activation='hill', shape=<factory>, pairs=<factory>, a_level=4.0, sigma_a=1.0, phi_true=10.0, nu_true=4.0)¶
- mmm_framework.continuous_learning.dgp.make_world(seed=0, channels=None)[source]¶
A reproducible, non-trivial 4-channel world for tests and the demo.
Channels
["Chatter", "Pulse", "Orbit", "Vibe"](generic social-network surfaces) with a mix of strong and weak main effects, distinct half- saturations and Hill shapes, and a synergy structure that includes a genuine cannibalization (negative gamma) and a complementarity (positive gamma) so recovery has to get signs right.- Return type:
- mmm_framework.continuous_learning.dgp.make_world_logistic(seed=0, channels=None)[source]¶
A known logistic (exponential-saturation) world, sibling of
make_world().Same channels and synergy structure, but each channel saturates as
f(s) = 1 - exp(-lam * s)— a concave curve with no S-shape — rather than a Hill. Demonstrates that the whole loop (fit, plan, stop, animate) is activation-agnostic. The saturation rateslamare chosen so half- saturationln(2)/lamlands in the same O(1) scaled range as the Hillkappaabove, keeping the two worlds comparable in scale.- Return type:
- mmm_framework.continuous_learning.dgp.make_world_hill_mixture(seed=0, channels=None)[source]¶
A known weighted-sum-of-two-Hills world (a misspecification stress test).
Each channel’s true response is
w·Hill(κ1,α1) + (1-w)·Hill(κ2,α2)with a low-κ, high-α early component (a soft activation threshold) plus a high-κ later component — a two-phase / shoulder shape a single Hill can only average over and a logistic (concave, no inflection) cannot represent at all. Fit it withactivation="hill"(mild misspecification) or"logistic"(severe) to see what a wrong response family does to the recovered curve, the funding line, and the recommended allocation.- Return type:
- mmm_framework.continuous_learning.dgp.draw_geo_intercepts(world, n_geo, rng)[source]¶
Sample the per-geo baseline intercepts
a_geo ~ Normal(A, sigma_a).- Return type:
- mmm_framework.continuous_learning.dgp.drift_world(world, *, rate=0.08, drift_beta=True, drift_shape=True, drift_gamma=False, seed=0)[source]¶
A slightly different
TrueWorld— media behaviour drifting.Applies one step of multiplicative log-normal jitter,
x -> x * exp(Normal(0, rate)), to the channel ceilingsbetaand the activation shape params (bounded params are clipped back to their family’s support;drift_gamma=Truealso jitters the synergy magnitudes, sign-preserving). Applying it once per wave makes the response surface a geometric random walk — the changing-media- behaviour regime the information discount (fit(discount_half_life=...)) is built for. Channels, pairs and the geo-intercept hypers are untouched, so pair it withsimulate_wave()over a fixeda_geo: the baselines stay pinned while the RESPONSE moves.- Return type:
- mmm_framework.continuous_learning.dgp.simulate_panel(world, center, *, n_geo=80, t_pre=6, t_test=10, delta=0.6, probe_pairs=None, noise=0.6, noise_family='normal', tau_scale=0.0, n_holdout=0, a_geo=None, adstock_alpha=None, adstock_l_max=8, stratify=False, seed=0)[source]¶
Simulate a CCD wave against a known world.
Returns the data contract (
spend,geo_idx,n_geo,y,period_idx) plusdesign,geo_alloc,cell_idx,a_geo,tau_trueandanswer_key. Pass an existinga_geo(and the sameseedoffset) to simulate a later wave over the same geos with their baselines intact.period_idxis always provided (np.repeat(arange(t_pre + t_test), n_geo)— the row order is week-major, pre block then test block); a fit only uses it when it opts intotime_effect="national".noise_family="negbinomial"draws count outcomes (gamma–Poisson with meansoftplus(mu)and concentrationworld.phi_true); the default"normal"path is byte-identical to the old Gaussian draw.tau_scale > 0adds true national per-period shockstau_t ~ Normal(0, tau_scale)tomu(0.0leavesybyte-identical and draws nothing).adstock_alphaadds carryover: the response is driven by the geometric-adstocked spend series (within each geo over weeks), while the returnedspendstays raw. Fitting on the raw panel is then biased; thepreprocess.adstock_prepassrecovers it (guide §9.4).stratify=True(opt-in — the default keeps the historical rng stream byte-identical) resolvesa_geoBEFORE the geo assignment and blocks the randomization on it (assign_geos(..., baseline=a_geo)), matching the production practice of stratifying on the pre-period KPI level.
- mmm_framework.continuous_learning.dgp.simulate_wave(world, design, a_geo, *, t_test=10, center=None, n_holdout=0, noise=0.6, noise_family='normal', tau_scale=0.0, stratify=False, seed=0)[source]¶
Simulate a single test window for a recentered design over the SAME geos.
Used by the closed loop to generate wave
t > 0outcomes: the geos and their baselines (a_geo) persist, only the test allocations change.period_idx(wave-LOCAL,np.repeat(arange(t_test), n_geo)) is always provided;ingest()applies the cross-wave offset.noise_family/tau_scalebehave as insimulate_panel()(defaults byte-identical to the old draw).stratify=True(opt-in) blocks the geo→cell randomization on the known per-geo baselinesa_geo.