Skip to content

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:

  • hill is saturating and chc.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, as chc.spine does, 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.prescribe takes one (budgets, since 0.10.0), but spend here is priced through Lever.unit_cost instead, 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_c is 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 myopic arm exists to measure that rather than assume it, and causaldyn_bench.allocation (Track M) scores both over seeds: on the parameters below the two TIE (mean lift 46.43 against 46.11 over eight seeds, sign flipping 3/5), while the adjusted arm beats the equal split and the confounded arm at 8/8 each. Here beta_c/theta_c ranks the channels (1.29, 1.50, 1.60) against gamma_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/theta of (0.10, 8.00, 1.60) at unchanged gamma -- and the whole-horizon plan wins 8/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_residual reads the state rate as a forward difference (x_next - x)/dt while chc.plan.causal_plan rolls out with RK4, so at a coarse dt the 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.503 against the -0.7 handed over as known, and the control channel with it. chc.decision.prescribe now defaults to integrator="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 with integrator="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.

lift(name)

Cumulative sales above the do-nothing arm --- what the incremental spend bought.

efficiency(name)

Lift per unit of incremental spend; nan for the baseline, which spends no extra.

table()

A Markdown comparison, deterministic and assertable.

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, so season is 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 the adjusted arm'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 myopic arm ranks the channels.