chc.mmm¶
Stability tier: evolving.
A case-study plant: the saturating-carryover plant the media-budgets case study plans on, not a media-mix model.
Marketing-mix budget scheduling: which channel to spend on, when, from confounded logs.
The case study the facade was built for, in a domain where the confounding is not hypothetical.
Media spend is planned against demand: a marketer raises budget in the weeks a product sells
anyway, so a model fitted on the log credits the channel with the season. Fit that model and
optimise against it and the plan under-invests: crediting every channel with the season, it
believes it needs less spend to reach its sales target than it does. That is the
observational-response failure chc.dynamics_id exists to stop --- here with the channel
being an incremental return and the plant a saturating carryover system.
The plant, control-affine by construction so identification and safety read the same object:
d(sales)/dt = -decay*(sales - base) + sum_c beta_c * hill(adstock_c) + sum_c gamma_c * spend_c
d(adstock_c)/dt = -theta_c * adstock_c + spend_c
Each channel acts twice: gamma_c is the immediate incremental return and is the control
channel, beta_c the carried-over return through a saturating adstock and is part of the
drift. That split is the point rather than a modelling convenience. Under a policy that chases
seasonality it is gamma_c that is confounded, and gamma_c is exactly what cross-fit
Robinson partialling-out identifies; the adstock rows are mechanical and are handed to
chc.prescribe as known=, so the fit has only the sales row to learn.
HONEST SCOPE, and it bounds what the numbers below mean:
hillis saturating andchc.residual.ControlAffineResidual's drift is a polynomial in the state, so the fitted plant is a local surrogate of the truth. Every arm is therefore audited by rolling its schedule out on the true plant, aschc.spinedoes, and the reported sales are the audited ones rather than the planner's own forecast.- The arms are not held to a total budget.
chc.prescribetakes one (budgets, since 0.10.0), but spend here is priced throughLever.unit_costinstead, and the arms are compared at matched total spend and by return per unit spent, which is the comparison that does not depend on the budget anyway. theta_cis taken as known. In practice it is fitted (Robyn, Meridian); treating it as known here isolates the question this module is about, which is the incremental return and not the carryover rate.- What the whole-horizon plan buys over a myopic one is a property of these parameters, not of
the method. The
myopicarm exists to measure that rather than assume it, andcausaldyn_bench.allocation(Track M) scores both over seeds: on the parameters below the two TIE (mean lift46.43against46.11over eight seeds, sign flipping3/5), while the adjusted arm beats the equal split and the confounded arm at8/8each. Herebeta_c/theta_cranks the channels(1.29, 1.50, 1.60)againstgamma_c's(0.50, 0.20, 0.35): the two orderings disagree about the top channel but AGREE about which to drop. Move the carryover until they contradict --beta/thetaof(0.10, 8.00, 1.60)at unchangedgamma-- and the whole-horizon plan wins8/8. So the headline of this case study is the identification, and the horizon is a second, smaller and plant-dependent effect. known=was exact only up to the integrator, and this module is where that was found.chc.dynamics_id.fit_causal_residualreads the state rate as a forward difference(x_next - x)/dtwhilechc.plan.causal_planrolls out with RK4, so at a coarsedtthe residual quietly absorbed the gap between the two. It was not small and it was not noise: on the fastest-decaying channel (theta*dt = 0.7) the fitted decay came back-0.503against the-0.7handed over as known, and the control channel with it.chc.decision.prescribenow defaults tointegrator="rk4", which closes the gap by defect correction; a test asserts both halves --- that the known rows come back empty under RK4, and that the same log read withintegrator="euler"still leaves exactly the closed-form amplification difference. Every headline number here is audited on the true plant either way, so none of them ever depended on the gap; a schedule read off the planner's own forecast did.
MarketingMixPlant
¶
Bases: Module
The true continuous plant: saturating carryover plus an immediate incremental return.
State is [sales, adstock_0, ..., adstock_{C-1}] and control is spend per channel. The
seasonality term is a state here rather than an exogenous input, so that the plant stays a
plain Dynamics that chc.integrate.rollout can audit; the logging
simulation drives it week by week and records the season it drove with.
MarketingMixSystem
dataclass
¶
A synthetic multi-region weekly media log whose planner chased the season.
Nothing here is anyone's data: the channel names are generic and the parameters are chosen to put the confounding in the incremental return, which is the mechanism under study.
plant(season=0.0)
¶
The true vector field at a given exogenous demand level.
sample(*, n_regions=40, n_weeks=26, dt=1.0, seed=0)
¶
Simulate the log week by week, with spend chosen from the season it is chasing.
Integrated with chc.integrate.rk4_step --- the same integrator the planner rolls
out with --- so the demo carries no hidden discretisation gap between the truth and the
model class fitted to it.
graph()
¶
Season confounds every channel; each channel drives sales; adstock carries it forward.
MmmArm
dataclass
¶
One allocation rule, audited on the true plant rather than on its own forecast.
cumulative_sales
property
¶
Sales summed over the planned weeks --- the area under the curve, which is the revenue.
Deliberately not the terminal value. An optimiser that understands carryover front-loads spend and then tapers, which maximises the area and lowers the endpoint; scoring on the endpoint would rank that behaviour last for doing the right thing. The first draft of this module did exactly that and ranked a flat split above the prescribed one.
MmmReport
dataclass
¶
Three allocation rules on one log, and the truth none of them was allowed to see.
adstock_dynamics(theta)
¶
The mechanical half of the plant, exactly: carryover decays, spend adds to it.
Handed to chc.prescribe as known= so the fit has only the sales row left to learn.
A test asserts the learned residual is near zero on these rows, which is what "known" is
supposed to mean and is not otherwise checked anywhere.
run_marketing_mix(system=None, *, horizon=12, dt=1.0, n_regions=40, n_weeks=26, unit_cost=0.08, sales_target=8.0, seed=0, integrator='rk4')
¶
Plan the next horizon weeks three ways and audit all three on the true plant.
The arms are the same optimiser reading the same log through three different causal claims:
adjusted--- the graph, soseasonis adjusted for and the incremental returns are identified;confounded--- an empty adjustment set asserted, which is what fitting the log directly amounts to and is the arm that credits the channel with the season;flat--- an equal split across channels held constant, at theadjustedarm's realised total spend, so the head-to-head is at matched budget and differs only in allocation;myopic--- the same identified fit spent on this week's return alone, also at that budget, which isolates what the objective's HORIZON buys from what identification buys;none--- spend held at the floor, which is the do-nothing counterfactual every lift is measured against.
Audited at season = 0: the plan is for an average week, and letting an arm be scored on a
season it did not know about would measure the draw rather than the allocation.
Raises:
| Type | Description |
|---|---|
ValueError
|
where the log does not identify a channel's own return, by which the
|