Calibrating Marketing Mix Models with Geo-Experiments

We estimate the MMM parameters from geo-experiments, using all the information they provide.
Published

August 20, 2026

Introduction

The previous post ended on an uncomfortable result. A Marketing Mix Model, honestly specified and cleanly converged, estimated the return on paid search (PLA) at 11.5x against a true 4.2x — and handing it perfect controls only brought the estimate down to 9.8x, still more than double the truth. The diagnosis was not a modelling flaw but a property of the data: PLA spend was set by planners and algorithms reacting to the same demand that drives sales, so spend never varied independently of demand, and no amount of historical data contains the information that identifies its effect. Estimated on observational data alone, a Marketing Mix Model cannot in general recover the true effects of marketing.

This post turns to another source of variation: marketing A/B tests and geo-experiments, which many companies run. A geo-experiment randomly splits the regions of a country into two groups, a treatment group and a control group. Given enough regions, chance makes the two groups comparable, and we can partly verify this: when we examine past sales of the two groups, the two series should track each other closely. To measure the effect of marketing, a channel is then switched off for a few weeks — but only in the treatment group; in the control group, everything continues as before. Because the two groups are otherwise fully comparable, any difference in sales that emerges between them is due to the effect of marketing.

The idea of using experiments to calibrate Marketing Mix Models has existed for some time (as described here and here). Standard practice first compresses each experiment into a single number: the incremental sales due to marketing, as measured by the experiment, per euro spent on marketing. This is also referred to as ‘return on ad spend’ (ROAS). This number is then added to the likelihood when estimating the MMM from observational time series, rewarding the Bayesian model for parameter estimates that match the ROAS. Compared to uncalibrated MMM, the approach has been a step forward, but it does not take advantage of all the information available. The time series from an experiment (for the treatment and control groups) contain far more information — enough to estimate the MMM’s complete parameter set for the tested channel: carryover \(\alpha\), saturation \(\lambda\), and effectiveness \(\beta\).

This post shows how to estimate these parameters directly from the experimental time series. The approach is, to my knowledge, new; it is the subject of my companion paper, Structural Estimation of Marketing Mix Model Parameters from Geo-Experiments (on arXiv), which carries the formal identification argument and a fuller set of results. This post is the hands-on version and stands on its own.

Setting up the environment

We start by setting up the environment. As in the previous posts, the plotting functions live in a separate helper file (mmm3_helpers.py) to avoid clutter; everything else happens in this post.

import numpy as np
import pandas as pd
import pymc as pm
import pytensor.tensor as pt
import arviz as az
import matplotlib.pyplot as plt

# The plotting functions live in a helper file with the same visual
# conventions as the earlier posts, so the figures read as one series
from mmm3_helpers import (
    visualize_test_calendar,
    visualize_geo_test,
    visualize_parameter_posteriors,
    visualize_joint_posteriors,
    visualize_identified_response_curve,
    visualize_method_comparison,
)

plt.rcParams.update({
    'axes.spines.top': False, 'axes.spines.right': False,
    'axes.grid': True, 'grid.color': '0.92', 'grid.linewidth': 0.6,
    'axes.axisbelow': True, 'axes.titlelocation': 'left',
    'axes.titleweight': 'normal', 'axes.titlesize': 10.5,
})

L_MAX = 6
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)

df = pd.read_csv('synthetic_mmm_data.csv')   # from the synthetic-data post
TRUE_ROAS_PLA = df['pla_effect'].sum() / df['pla_spend'].sum()
print(f"{len(df)} weeks; true PLA ROAS = {TRUE_ROAS_PLA:.2f}x")

# The same column maxima the estimation post scaled by
scale_factors = {
    'total_sales': float(df['total_sales'].max()),
    'pla_spend': float(df['pla_spend'].max()),
}
scale_factors
156 weeks; true PLA ROAS = 4.20x
{'total_sales': 6034.659108756031, 'pla_spend': 213.73883549513883}

Step 1: Simulating the geo-experiments

We continue with the synthetic dataset from the earlier posts, so we have both realistic sales and marketing series and know the true effects to compare our estimates against. What is new is the geo-experiment on top of it.

The design we simulate is a go-dark test: the treatment regions stop spending on the tested marketing channel entirely for the duration of the test, while the control regions continue as usual. Simulating this in our synthetic world is easy: we take the country-level time series and create two parallel universes.

  • In the control universe nothing changes: We use our synthetic dataset as is.
  • In the treatment universe, marketing spend on product listing ads (PLA) is set to zero during the geo-test, and the PLA channel’s effect on sales is recomputed with exactly the adstock and saturation transformations that built the data in the first post.

Three parts of the implementation deserve a note:

  • Scaling. To replicate the data of an actual geo-experiment we should scale both series by 0.5, for a 50–50 split of the country. We skip this and keep both universes at the scale of the whole country, as if treatment and control each covered the entire market. This spares us the bookkeeping between market-level and test-group level units: the parameters we estimate live on the country scale and can be compared to those of the MMM directly.
  • An imperfect split. Randomisation makes the two groups comparable, not identical. Week to week, a little more demand happens to land in one group and a little less in the other — and since the country’s total is whatever it is, these idiosyncratic differences are opposite-signed by construction. We simulate them as opposite-signed noise on the two sales series, sized at one percent of average sales and running over the entire history, not only during the tests. This means that even though the treatment and control groups are fully comparable, sales differ a little week-to-week due to random variation. (We keep these differences independent across weeks; in reality they can persist.)
  • Carryover in the simulated data. Going dark during the test also changes adstock in the weeks after the test, since adstock is a function of past spend; and in the first dark week, adstock does not drop to zero at once. We therefore set spend to zero in the test windows and recompute adstock and the channel’s effect over the entire series, not over the windows alone.

We simulate four go-dark tests for PLA, each with four dark weeks, four pre-test weeks and eight cooldown weeks of measurement. The windows are spaced further apart than the adstock memory, so no test’s carryover reaches the next test’s measurement window. The staggering is deliberate: because spend levels vary over the three years, tests run at different times are tests run at different spending levels — and, as we will see, that variation across levels is what identifies the saturation curve.

def geometric_adstock(spend_series, adstock_rate, l_max=6):
    weights = adstock_rate ** np.arange(l_max)
    weights = weights / weights.sum()
    return np.convolve(spend_series, weights)[:len(spend_series)]


def logistic_saturation(adstocked_series, lam):
    return (1 - np.exp(-lam * adstocked_series)) / (1 + np.exp(-lam * adstocked_series))


CHANNEL_CONFIGS = {
    'pla':  {'adstock_rate': 0.2, 'saturation_lambda': 0.008, 'coefficient': 1100},
    'meta': {'adstock_rate': 0.4, 'saturation_lambda': 0.01,  'coefficient': 600},
    'tv':   {'adstock_rate': 0.7, 'saturation_lambda': 0.006, 'coefficient': 560},
}
TEST_DURATION, PRE_WEEKS, POST_WEEKS = 4, 4, 8

test_calendar = pd.DataFrame({
    'test_id': [1, 2, 3, 4],
    'test_start_week': [20, 55, 100, 140],
    'noise_seed': [10, 20, 30, 40],
})
test_calendar['test_end_week'] = (test_calendar['test_start_week']
                                  + TEST_DURATION - 1)


def create_parallel_universes(dataset, test_calendar, test_channel='pla',
                              noise_share=0.01, noise_seed=42):
    """
    A go-dark geo-test as two parallel universes, both at country scale.
    The control universe is the dataset as planned. In the treatment
    universe the tested channel goes dark during every window of the test
    calendar, and its effect is recomputed once, over the full history, so
    carryover into and out of every window is correct. The other channels
    are untouched, so total sales only needs the tested channel's
    contribution swapped.

    The two groups are comparable, not identical: week to week, a little
    more demand lands in one group's regions and a little less in the
    other's. At country scale this appears as opposite-signed noise on
    observed sales, running over the whole history — not only during tests.
    """
    np.random.seed(noise_seed)
    config = CHANNEL_CONFIGS[test_channel]

    # The treatment universe differs in one channel only
    dark_weeks = np.zeros(len(dataset), dtype=bool)
    for test in test_calendar.itertuples():
        dark_weeks |= dataset['week'].between(test.test_start_week,
                                              test.test_end_week).values
    dark_spend = dataset[f'{test_channel}_spend'].mask(dark_weeks, 0.0)
    dark_effect = config['coefficient'] * logistic_saturation(
        geometric_adstock(dark_spend.values, config['adstock_rate'], L_MAX),
        config['saturation_lambda'])

    control = dataset.copy()
    treatment = dataset.assign(**{
        f'{test_channel}_spend': dark_spend,
        f'{test_channel}_effect': dark_effect,
        'total_sales': dataset['total_sales']
                       - dataset[f'{test_channel}_effect'] + dark_effect,
    })

    # The imperfect split: idiosyncratic, opposite-signed weekly differences
    noise = np.random.normal(0, noise_share * dataset['total_sales'].mean(),
                             len(dataset))
    control['sales'] = control['total_sales'] + noise
    treatment['sales'] = treatment['total_sales'] - noise
    control['region'], treatment['region'] = 'control', 'treatment'
    return control, treatment

control_universe, treatment_universe = create_parallel_universes(df, test_calendar)

The picture below shows our geo-tests and their design: the treatment universe’s PLA spend drops to zero in each of the four windows, while the control universe continues to spend as normal — visibly at different levels in different windows.

visualize_test_calendar(control_universe, treatment_universe, test_calendar)

The dataset for one geo-test is deliberately minimal: the week, the group, the phase of the test, the tested channel’s spend, and observed sales — the columns that a real experiment would hand us, with the pre-test and cooldown weeks part of the measurement. The one exception is pla_effect, the channel’s true contribution, which we carry along only so that we also draw the truth in the figures below.

geo_tests = []
for test in test_calendar.itertuples():
    window = df['week'].between(test.test_start_week - PRE_WEEKS,
                                test.test_end_week + POST_WEEKS)
    measured = pd.concat([control_universe[window], treatment_universe[window]],
                         ignore_index=True)
    measured['test_id'] = test.test_id
    measured['period'] = np.select(
        [measured['week'] < test.test_start_week,
         measured['week'] > test.test_end_week],
        ['pre', 'post'], default='test')
    geo_tests.append(measured[['test_id', 'week', 'region', 'period',
                               'pla_spend', 'pla_effect', 'sales']])

geo_tests[0]
test_id week region period pla_spend pla_effect sales
0 1 16 control pre 64.981209 272.736086 2427.709604
1 1 17 control pre 46.761810 217.407858 3530.917545
2 1 18 control pre 79.656167 315.365118 2566.868156
3 1 19 control pre 49.398292 235.092857 2382.698796
4 1 20 control test 46.699235 209.542395 2282.506295
5 1 21 control test 61.933199 255.673341 2381.857589
6 1 22 control test 52.023726 231.686875 2460.115675
7 1 23 control test 57.157454 244.108085 2356.281219
8 1 24 control post 47.121892 212.793671 2268.930620
9 1 25 control post 52.009374 223.041095 2172.283902
10 1 26 control post 79.725280 316.665296 2157.705616
11 1 27 control post 89.238533 364.950056 2138.075697
12 1 28 control post 102.422144 414.857574 2440.317660
13 1 29 control post 72.184382 330.819108 3798.857544
14 1 30 control post 84.718696 353.507986 3580.388323
15 1 31 control post 73.110177 321.039624 2007.864644
16 1 16 treatment pre 64.981209 272.736086 2458.652442
17 1 17 treatment pre 46.761810 217.407858 3586.653922
18 1 18 treatment pre 79.656167 315.365118 2549.575038
19 1 19 treatment pre 49.398292 235.092857 2432.667613
20 1 20 treatment test 0.000000 47.711866 2198.395229
21 1 21 treatment test 0.000000 9.535326 2055.064517
22 1 22 treatment test 0.000000 1.892471 2242.745803
23 1 23 treatment test 0.000000 0.367959 2108.824998
24 1 24 treatment post 47.121892 164.688010 2299.229246
25 1 25 treatment post 52.009374 213.516776 2192.717115
26 1 26 treatment post 79.725280 314.855387 2149.791606
27 1 27 treatment post 89.238533 364.611369 2201.076506
28 1 28 treatment post 102.422144 414.802345 2419.587665
29 1 29 treatment post 72.184382 330.819108 3831.910857
30 1 30 treatment post 84.718696 353.507986 3596.440311
31 1 31 treatment post 73.110177 321.039624 2040.976726

Step 2: A first look at the experiments

With the datasets in hand, we look at what one experiment shows. The figure below plots the first test. The top panel shows the check promised in the introduction: outside the test window, sales in the two groups track each other closely, and during the test the go-dark group falls behind. The middle panel shows the intervention itself — PLA spend, identical in both groups except where it is switched off. The bottom panel shows the weekly sales gap between the groups, which is the object we will model, together with the true effect gap, known here because the data is synthetic.

visualize_geo_test(geo_tests[0])

The edges of the gap panel deserve a word — at least in principle. The gap should not open to its full size in the first test week, because carryover from pre-test advertising still supports sales in the treatment group; and after the test it should not close at once, but fade over the cooldown weeks. The pace of that opening and closing is the carryover parameter: this is where the experiment traces \(\alpha\). In our data the transitions are hard to see because PLA’s carryover is short (\(\alpha = 0.2\)), so they play out within about a week, and the week-to-week noise between the groups is of a similar size. The signal is faint rather than absent — the model below will pool it across every transition of every test — but it is not something to point at in this figure.

The one-number reading

Standard practice, as described in the introduction, compresses all of this into a single number per test: the total sales gap between the groups, divided by the total spend gap over the test window. Two versions of the count are worth computing: one takes only the sales gap in the test window; the other also counts the cooldown weeks — methodologically the more correct reading, since carryover means some of the effect arrives after the window. Applied to our four tests:

def naive_roas(geo_test, include_cooldown=False):
    periods = ['test', 'post'] if include_cooldown else ['test']
    counted = geo_test[geo_test['period'].isin(periods)]
    control = counted[counted['region'] == 'control']
    treatment = counted[counted['region'] == 'treatment']
    sales_gap = control['sales'].sum() - treatment['sales'].sum()

    window = geo_test[geo_test['period'] == 'test']
    spend_gap = (window[window['region'] == 'control']['pla_spend'].sum()
                 - window[window['region'] == 'treatment']['pla_spend'].sum())
    return sales_gap / spend_gap


for i, test in enumerate(geo_tests):
    print(f"Test {i+1}: one-number ROAS {naive_roas(test):.2f}x, "
          f"including cooldown {naive_roas(test, include_cooldown=True):.2f}x "
          f"(true {TRUE_ROAS_PLA:.2f}x)")
Test 1: one-number ROAS 4.02x, including cooldown 3.25x (true 4.20x)
Test 2: one-number ROAS 3.83x, including cooldown 4.08x (true 4.20x)
Test 3: one-number ROAS 3.57x, including cooldown 3.69x (true 4.20x)
Test 4: one-number ROAS 3.71x, including cooldown 3.92x (true 4.20x)

In my run the window-only numbers land between 3.57x and 4.02x, against a truth of 4.20x: close, a touch low, and scattered. The shortfall and the scatter both have causes. Each number misses the roughly 6% of the effect that carryover pushes past the window’s edge; each measures the return at the spending level of its own test rather than the three-year average; and the week-to-week noise between the groups does the rest. Including the cooldown repairs the first of these — though with PLA’s short memory, a week or two would recover almost all of it. The version computed above counts all eight cooldown weeks instead, most of which add nothing but noise to the numerator, and the numbers duly scatter more widely, from 3.25x to 4.08x. How to trade the two off depends on the carryover — which is precisely what a single number per test cannot estimate.

And that is the general problem with the compression. Averaging the numbers gives a serviceable overall ROAS, and this is roughly where good current practice stops; but even a good single number cannot say how fast the effects decay or how the return would change at a different spending level. That information is present in the gap panel above; the summation retains none of it.

Step 3: The differencing equation

The route from the experiment to the model’s parameters is a single subtraction. It is worth taking slowly, because it is the whole method.

Start from the previous post. There we wrote weekly sales in the canonical MMM formulation: a baseline, plus one term per marketing channel, plus controls and seasonality, plus noise:

\[y_t = \beta_0 + \sum_{m \,\in\, \{\text{pla},\,\text{meta},\,\text{tv}\}} \beta_m\,\mathrm{sat}\!\left(x^{*}_{t,m};\,\lambda_m\right) + \text{controls}_t + \text{seasonality}_t + \varepsilon_t,\]

where each channel’s adstock is the weighted sum of its current and past spends,

\[x^{*}_{t,m} = \frac{\sum_{\ell=0}^{L-1} \alpha_m^{\ell}\, x_{t-\ell,\,m}}{\sum_{\ell=0}^{L-1} \alpha_m^{\ell}},\]

so that three parameters per channel — \(\alpha_m\), \(\lambda_m\), \(\beta_m\) — govern how its spend becomes sales. This is the equation we estimated on three years of observational data, and for PLA the estimate came back confidently wrong: nothing in that data separates the sales that caused the spend from the sales the spend caused.

Now write this same equation twice — once for the control universe, once for the treatment universe. Because we kept both universes at the scale of the whole country in Step 1, the country-level equation applies to each of them exactly as it stands, with the same parameters; no unit conversion is needed. (In a real experiment, this is what scaling each group’s series up to country level buys.) Then go through the right-hand side term by term and ask: which terms differ between the two universes?

  • The baseline \(\beta_0\): the same in both.
  • Meta and TV: their spends were never touched by the experiment, so their adstocks, their saturations, and their contributions are identical in both universes, week by week.
  • Controls and seasonality: the same calendar, the same promotions, the same prices on both sides.
  • And everything the equation does not contain — the sentiment and quality drifts that undid the observational estimates: the same too, because randomisation made the two groups comparable. This is what the experimental design was for.

The only term that differs between the universes is PLA’s, because PLA spend is the one thing the experiment changed. So collect everything shared into a single symbol, \(\mu_t\), and the two universes read:

\[y_t^{c} = \mu_t + \beta\,\mathrm{sat}\!\left(x^{*,c}_{t};\lambda\right) + \varepsilon_t^{c},\qquad y_t^{tr} = \mu_t + \beta\,\mathrm{sat}\!\left(x^{*,tr}_{t};\lambda\right) + \varepsilon_t^{tr},\]

where \(x^*_t\) is PLA’s adstocked spend in each universe — it contains \(\alpha\) — and \(\mu_t\) is everything else: the baseline, the other channels, the calendar, and every demand factor, observed or not, modelled or not. We never need to know what is inside \(\mu_t\); it only needs to be the same in both universes. Because it is, subtracting the second line from the first removes it:

\[y_t^{c} - y_t^{tr} = \beta\left[\mathrm{sat}\!\left(x^{*,c}_{t};\lambda\right) - \mathrm{sat}\!\left(x^{*,tr}_{t};\lambda\right)\right] + \nu_t.\]

Everything that made the observational problem hard has dropped out: the baseline is gone, Meta and TV are gone, the calendar is gone, and — the part no amount of extra data or better controls could deliver — the unobserved confounders are gone too, not because we measured them, but because randomisation put the same amount of them on both sides of the subtraction. What remains relates observed quantities — the weekly gap on the left, the two spend paths inside the adstocks on the right — to exactly the three parameters we want. The remaining noise, \(\nu_t = \varepsilon_t^{c} - \varepsilon_t^{tr}\), is the imperfect split from Step 1: the idiosyncratic weekly differences between two comparable groups.

Step 4: The model

The Bayesian model is the differencing equation, written out in PyMC. Reading the equation tells us what the model must contain. Spend enters twice, once per universe. Both spend paths then pass through the same transformations as in the previous post — adstock, then saturation — with a single set of parameters \((\alpha, \lambda, \beta)\) shared between the two universes and across all tests: the experiment changed the spend, not how the channel works. The model’s prediction for a week is the difference between the two contributions, and the observed treatment-control gap scatters around this prediction with some noise.

def build_structural_model(control_lags, treatment_lags, y_observed):
    """The differencing equation as a PyMC model: two spend paths through
    one shared adstock and saturation; the likelihood sits on the weekly gap."""
    coords = {'week': np.arange(len(y_observed)), 'lag': np.arange(L_MAX)}
    with pm.Model(coords=coords) as model:

        # One shared parameter set for both universes
        adstock_alpha = pm.Beta('alpha', alpha=2, beta=1)
        saturation_lambda = pm.Gamma('lam', alpha=2, beta=1)
        beta = pm.Normal('beta', mu=0, sigma=0.5)
        sigma = pm.HalfNormal('sigma', sigma=0.5)

        # The spends enter as data: current + lagged, per week, per universe
        spend_control = pm.Data('spend_control', control_lags,
                                dims=('week', 'lag'))
        spend_treatment = pm.Data('spend_treatment', treatment_lags,
                                  dims=('week', 'lag'))

        decay_weights = pt.power(adstock_alpha, pt.arange(L_MAX))
        decay_weights = decay_weights / pt.sum(decay_weights)

        def contribution(group, lagged_spends):
            """Spend -> adstock -> saturation -> contribution: the MMM's
            transformation chain, applied identically to either universe."""
            adstock = pm.Deterministic(
                f'adstock_{group}',
                pt.sum(lagged_spends * decay_weights, axis=1), dims='week')
            saturated = pm.Deterministic(
                f'saturated_{group}',
                (1 - pt.exp(-saturation_lambda * adstock))
                / (1 + pt.exp(-saturation_lambda * adstock)), dims='week')
            return pm.Deterministic(
                f'impact_{group}', beta * saturated, dims='week')

        predicted_gap = pm.Deterministic(
            'predicted_gap',
            contribution('control', spend_control)
            - contribution('treatment', spend_treatment),
            dims='week')

        pm.Normal('y', mu=predicted_gap, sigma=sigma,
                  observed=y_observed, dims='week')
    return model

As in the previous post, the easiest way to read the model is to draw it. The diagram shows only the model’s structure, not any numbers, so we do not need data yet: instantiating the model on an empty placeholder of the right shape is enough to draw it.

n_weeks = PRE_WEEKS + TEST_DURATION + POST_WEEKS   # one test's worth of weeks
placeholder = build_structural_model(
    control_lags=np.zeros((n_weeks, L_MAX)),
    treatment_lags=np.zeros((n_weeks, L_MAX)),
    y_observed=np.zeros(n_weeks))
pm.model_to_graphviz(placeholder)

Reading the diagram top to bottom: the spends enter as data — for every week, the current and five lagged spends of each universe. Each universe’s spends then go through the two transformations of the MMM: \(\alpha\) determines the adstock, \(\lambda\) the saturation, and \(\beta\) converts the result into the channel’s contribution to sales. Note that the parameters sit outside the plate: there is one \(\alpha\), one \(\lambda\) and one \(\beta\), applied to both universes and to every week of every test. predicted_gap is the difference between the two contributions. And the shaded node at the bottom — what the model conditions on — is not sales, as in the previous post, but the weekly gap between the groups: the quantity that the differencing equation showed to be free of confounding.

The priors are the same as in the previous post: Beta(2, 1) for the carryover, Gamma(2, 1) for the saturation, Normal(0, 0.5) for the coefficient, and HalfNormal(0.5) for the noise of the differenced series. Using the same priors in both posts keeps the comparison clean: whatever differs between the estimates, it is not the priors. (The companion paper uses pymc-marketing’s default priors instead, with the same results; nothing here depends on the prior.)

Step 5: Preparing the data

The model is precise about the data it needs: for every week, the observed gap on the left-hand side and, on the right, the current and the \(L-1\) previous spends of each universe, from which it builds the adstock. We now reshape the geo-test datasets accordingly — as in the estimation post, into wide format. Each row is one week of one experiment, and it carries two things: the week’s sales gap (sales are observed in both groups, so the difference is a simple subtraction), and six weeks of spend per universe — the week’s own spend plus the five before it, looked up from the full spend histories. The lagged spends repeat what earlier rows already contain, but that repetition is the point: it makes each row self-contained, so pooling tests is nothing more than stacking their rows. Everything is placed on the MMM’s scale by dividing by the same column maxima as before. The preview below shows the result for the first test: the same information as the long-format dataset of Step 1, just reshaped much wider.

def reshape_data(geo_test, control_spend_full, treatment_spend_full, l_max=L_MAX):
    """One row per measured week: the sales gap, plus current and lagged
    spends of both universes, everything on the MMM's scale."""
    control = geo_test[geo_test['region'] == 'control'].sort_values('week')
    treatment = geo_test[geo_test['region'] == 'treatment'].sort_values('week')
    weeks = control['week'].values
    if weeks.min() - (l_max - 1) < 1:
        raise ValueError("Not enough spend history before the test "
                         "to build the lagged spends.")

    wide = pd.DataFrame({
        'week': weeks,
        'period': control['period'].values,
        'y': (control['sales'].values - treatment['sales'].values)
             / scale_factors['total_sales'],
    })
    for lag in range(l_max):
        wide[f'control_spend_lag{lag}'] = (
            control_spend_full[weeks - 1 - lag] / scale_factors['pla_spend'])
        wide[f'treatment_spend_lag{lag}'] = (
            treatment_spend_full[weeks - 1 - lag] / scale_factors['pla_spend'])
    return wide


prepared = [reshape_data(test, df['pla_spend'].values,
                         treatment_universe['pla_spend'].values)
            for test in geo_tests]
data_2tests = pd.concat(prepared[:2], ignore_index=True)
data_4tests = pd.concat(prepared, ignore_index=True)
print(f"2 tests: {len(data_2tests)} weekly observations; 4 tests: {len(data_4tests)}")
prepared[0].round(3).head()
2 tests: 32 weekly observations; 4 tests: 64
week period y control_spend_lag0 treatment_spend_lag0 control_spend_lag1 treatment_spend_lag1 control_spend_lag2 treatment_spend_lag2 control_spend_lag3 treatment_spend_lag3 control_spend_lag4 treatment_spend_lag4 control_spend_lag5 treatment_spend_lag5
0 16 pre -0.005 0.304 0.304 0.266 0.266 0.268 0.268 0.231 0.231 0.222 0.222 0.275 0.275
1 17 pre -0.009 0.219 0.219 0.304 0.304 0.266 0.266 0.268 0.268 0.231 0.231 0.222 0.222
2 18 pre 0.003 0.373 0.373 0.219 0.219 0.304 0.304 0.266 0.266 0.268 0.268 0.231 0.231
3 19 pre -0.008 0.231 0.231 0.373 0.373 0.219 0.219 0.304 0.304 0.266 0.266 0.268 0.268
4 20 test 0.014 0.218 0.000 0.231 0.231 0.373 0.373 0.219 0.219 0.304 0.304 0.266 0.266

Step 6: Estimating the model

With the model structure and the data in place, we can now estimate the model. There is one bit of glue in worth pointing out in fit_structural: we collect the lagged spend columns of the wide table into one week-by-lag matrix per universe — as this is the format that the model expects — before actually estimating the model. For sampling, we use the same settings as in the previous post: NUTS, four chains, target_accept = 0.95, and 2,000 draws after 1,000 tuning steps. We estimate the model twice, once on the first two tests and once on all four, to see how much the additional experiments improve the estimates. Below we show the summary of the four-test estimation; the two-test one passes the same checks. The r_hat and ESS values clear the convergence bar of the previous post — but, as discussed there, this only tells us that the sampling worked. Whether the estimates are any good is the question for the rest of the post.

def fit_structural(wide, seed=RANDOM_SEED, draws=2000, tune=1000):
    # The lag columns of the wide table, collected into one (week x lag)
    # matrix per universe — a change of format, not of information
    control_lags = wide.filter(like='control_spend_lag').values
    treatment_lags = wide.filter(like='treatment_spend_lag').values

    with build_structural_model(control_lags, treatment_lags, wide['y'].values):
        idata = pm.sample(draws=draws, tune=tune, chains=4, target_accept=0.95,
                          random_seed=seed, return_inferencedata=True)
    return idata


trace_2 = fit_structural(data_2tests, seed=RANDOM_SEED)
trace_4 = fit_structural(data_4tests, seed=RANDOM_SEED + 1)
az.summary(trace_4, var_names=['alpha', 'lam', 'beta', 'sigma'])
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [alpha, lam, beta, sigma]

Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 8 seconds.
There were 4 divergences after tuning. Increase `target_accept` or reparameterize.
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [alpha, lam, beta, sigma]

Sampling 4 chains for 1_000 tune and 2_000 draw iterations (4_000 + 8_000 draws total) took 8 seconds.
There were 1 divergences after tuning. Increase `target_accept` or reparameterize.
mean sd hdi_3% hdi_97% mcse_mean mcse_sd ess_bulk ess_tail r_hat
alpha 0.165 0.048 0.076 0.258 0.001 0.001 3749.0 2935.0 1.0
lam 0.859 0.390 0.246 1.553 0.008 0.006 1839.0 2142.0 1.0
beta 0.414 0.204 0.144 0.801 0.005 0.006 1834.0 2053.0 1.0
sigma 0.008 0.001 0.007 0.009 0.000 0.000 3662.0 3365.0 1.0

Step 7: Reading the results

How should we judge the estimates? The previous post made the case that the individual parameters are the wrong place to start: \(\lambda\) and \(\beta\) are only weakly identified, because different combinations of the two fit the data almost equally well — they trade off along a ridge, which we will see directly later in this section. What is well identified is what the parameters jointly imply. So we start there, with the most important implied quantity: the ROAS.

The calculation is simple. Every posterior draw is one candidate set of parameters \((\alpha, \lambda, \beta)\). For each draw, we apply these parameters to the actual PLA spend of the past three years — adstock, saturation, coefficient — which gives the incremental sales this draw implies; dividing by the spend gives its ROAS. Doing this for every draw yields a distribution: our estimate of the average ROAS over the full three years, not just over the test windows. This is the same quantity the previous post estimated with the MMM, so the numbers of the two posts are directly comparable. And because the ROAS is computed draw by draw, the correlation between the parameters is carried into the interval automatically.

def roas_from_trace(trace, n_draws=1000, seed=0):
    alpha_draws = trace.posterior['alpha'].values.flatten()
    lambda_draws = trace.posterior['lam'].values.flatten()
    beta_draws = trace.posterior['beta'].values.flatten()
    idx = np.random.default_rng(seed).choice(len(alpha_draws), n_draws,
                                             replace=False)
    spend_scaled = df['pla_spend'].values / scale_factors['pla_spend']
    total_spend = df['pla_spend'].sum()

    roas_draws = []
    for a, lam, b in zip(alpha_draws[idx], lambda_draws[idx], beta_draws[idx]):
        contribution = b * logistic_saturation(
            geometric_adstock(spend_scaled, a, L_MAX), lam).sum()
        roas_draws.append(contribution * scale_factors['total_sales'] / total_spend)
    return np.array(roas_draws)


roas_2tests = roas_from_trace(trace_2)
roas_4tests = roas_from_trace(trace_4)
for label, roas in [('2 tests', roas_2tests), ('4 tests', roas_4tests)]:
    print(f"Structural ROAS ({label}): {roas.mean():.2f}x "
          f"[{np.percentile(roas, 5):.2f}, {np.percentile(roas, 95):.2f}] "
          f"(true {TRUE_ROAS_PLA:.2f}x)")
Structural ROAS (2 tests): 4.14x [3.82, 4.50] (true 4.20x)
Structural ROAS (4 tests): 4.02x [3.75, 4.29] (true 4.20x)

The response curve shows the same result in the units that matter for budget decisions. Over the range of adstocked spend that the experiments covered — the rug at the foot of the figure — the estimated curve lies on top of the true one. Because the tests were staggered and ran at different spending levels, this range is wide: in this run it spans roughly EUR 40K to EUR 200K, almost the entire range observed in the three years of data, so the curve is well estimated everywhere the company has actually operated. Beyond it, the posterior draws fan out. The reason is the same weak identification as before: many parameter combinations produce nearly the same curve over the covered range — which is why the data cannot tell them apart — but these combinations increasingly disagree as the curve is extended beyond it. Within the tested range this ambiguity is harmless; outside it, the fan shows honestly how little the experiments say about spending levels that nobody has tried.

experiment_weeks = np.concatenate(
    [test.loc[test['region'] == 'control', 'week'].values for test in geo_tests])

visualize_identified_response_curve(
    trace_4,
    spend=df['pla_spend'].values,
    scale_factors=scale_factors,
    true_config=CHANNEL_CONFIGS['pla'],
    adstock_fn=geometric_adstock,
    saturation_fn=logistic_saturation,
    experiment_weeks=experiment_weeks)

Only now do we look at the parameters themselves. The scaled truths to compare against are \(\alpha = 0.20\), \(\lambda \approx 1.71\), \(\beta \approx 0.18\). As we have just seen, what the parameters jointly imply is estimated well; the individual parameters are a different story. The carryover comes out about right: the posterior centres near 0.17 and the truth of 0.20 lies well inside, though the posterior is not narrow — only a handful of transition weeks per test carry information about \(\alpha\). For \(\lambda\) and \(\beta\) the picture is more interesting. Their posteriors point to a flatter saturation curve with a larger coefficient than the truth — with four tests, the 90% intervals of both just exclude the true values — and yet the ROAS above is accurate. This is possible because the data cannot separate \(\lambda\) from \(\beta\), only their joint implication: the estimates can drift along the ridge, but everything the ridge preserves — the response curve over the tested range, and with it the ROAS — remains correct. Adding tests also does little for the individual parameters: from two tests to four, \(\lambda\)’s interval narrows by about a fifth, while \(\alpha\)’s and \(\beta\)’s barely change. None of this is a weakness of the estimator; it is simply how much a few short experiments can say about individual parameters — and the reason we judged on the ROAS first.

visualize_parameter_posteriors(
    traces_by_label={'2 tests': trace_2, '4 tests': trace_4},
    parameters={
        r'Adstock $\alpha$': {
            'var': 'alpha',
            'truth': CHANNEL_CONFIGS['pla']['adstock_rate']},
        r'Saturation $\lambda$ (scaled)': {
            'var': 'lam',
            'truth': (CHANNEL_CONFIGS['pla']['saturation_lambda']
                      * scale_factors['pla_spend'])},
        r'Coefficient $\beta$ (scaled)': {
            'var': 'beta',
            'truth': (CHANNEL_CONFIGS['pla']['coefficient']
                      / scale_factors['total_sales'])},
    })

We can also draw this trade-off directly. The figure below shows the pairwise joint posteriors of the three parameters, estimated on four tests. The \(\lambda\)–\(\beta\) panel is the important one: the posterior forms a narrow ridge — the correlation between the draws is \(-0.84\) — running from flat curves with large coefficients to steep curves with small coefficients, all of which imply nearly the same response over the tested range. The panel also resolves the puzzle from above: the true \((\lambda, \beta)\) lies at the thin, steep-curve end of the ridge, outside both 90% marginals but exactly along the direction the data cannot resolve. And it explains why the ROAS interval is so much tighter than either marginal: the ROAS barely varies along the ridge.

visualize_joint_posteriors(
    trace_4,
    parameters={
        r'Adstock $\alpha$': {
            'var': 'alpha',
            'truth': CHANNEL_CONFIGS['pla']['adstock_rate']},
        r'Saturation $\lambda$ (scaled)': {
            'var': 'lam',
            'truth': (CHANNEL_CONFIGS['pla']['saturation_lambda']
                      * scale_factors['pla_spend'])},
        r'Coefficient $\beta$ (scaled)': {
            'var': 'beta',
            'truth': (CHANNEL_CONFIGS['pla']['coefficient']
                      / scale_factors['total_sales'])},
    })

Step 8: The verdict

Placing this post’s estimates next to the previous post’s completes the argument: the same synthetic world, the same true return, and two routes to estimating it. Each estimate carries its posterior mean and 90% interval directly in the figure.

visualize_method_comparison(
    results_by_method={
        'MMM\nobserved\ncontrols': (11.5, 6.9, 15.7),   # previous post
        'MMM\noracle\ncontrols': (9.8, 7.2, 12.4),      # previous post
        'Structural\n2 geo-tests': roas_2tests,
        'Structural\n4 geo-tests': roas_4tests,
    },
    true_roas=TRUE_ROAS_PLA)

Two experiments already produce an accurate point estimate — 4.1x against a truth of 4.2x — and four tighten the interval further. The observational model, with three years of weekly data behind it, remains confidently wrong. The contrast is not about the amount of data — 156 weekly observations against 64 — but about its content: the observational weeks contain no variation in spend that is independent of demand, and the experimental weeks contain little else.

Next steps

The method is developed, and on synthetic data it works. What does that mean for a company that is building a MMM — and runs, or could run, geo-tests? There are three pragmatic routes, in increasing order of ambition.

The first two need nothing beyond this post. If geo-tests exist for every channel, the approach here is already the whole answer: estimate \((\alpha, \lambda, \beta)\) channel by channel, and every parameter the MMM needs for budget optimisation comes from experimental variation. If geo-tests exist for only some channels — the common case — estimate those channels here and hand the results to the MMM as very narrow priors, so that they stay essentially fixed while the MMM estimates the remaining channels from observational data.1 The caveat of the previous post applies with full force to those remaining channels: where spend is endogenous — and in any well-run company it is — their estimates remain uncalibrated correlations.

The third route is full integration: enter the differencing equation into the MMM’s likelihood, so that the experiments inform carryover and saturation jointly with the observational data. This is the natural extension of the lift-based calibration described in the introduction — where pymc-marketing’s add_lift_test_measurements enters one number per test into the likelihood, an add_lift_test_timeseries would enter the entire weekly path of the gap.2 The paper develops this combined estimator; an implementation in pymc-marketing is on my to-do list.

Designing geo-tests for calibration. All three routes work better if the experiments are run with calibration in mind, and four design choices follow from the mechanics above. Measure and keep the cooldown, for at least as long as the plausible carryover: the gap-closing weeks are where \(\alpha\) is identified. Stagger tests over time, or run them deliberately at different spending levels: saturation is identified by comparing gaps across levels, not by repeating the same test. Make the pre-period at least as long as the adstock memory, so that the gap-opening transition is fully usable. And archive the weekly series for both groups, not only the headline lift: everything in this post was estimated from the weekly path, and none of it survives the summary.

Conclusion

The series set out to answer one question — whether a Marketing Mix Model can recover the true returns to marketing — and the answer now has three parts. On realistic observational data, it cannot: for PLA — the channel where spend is most endogenous, because automated bidding raises it whenever sales run above their recent average — the estimates were confidently wrong, and better controls could not repair them. The reason is identification, not implementation: when spend responds to demand, nothing in the data separates the sales that caused the spend from the sales the spend caused. And the repair is experimental variation, used in full: two to four geo-tests of a kind many retailers already run, analysed through the model’s own equations rather than compressed into a lift number, recover the response curve and the return with useful precision.

Two caveats belong next to the result. The simulated experiments inherit the usual assumptions of any geo-test — a clean split, no spillovers between regions, no competitor reacting. And, as in the earlier posts, the estimator was handed the functional forms that generated the data; with real data, nobody guarantees that geometric adstock and logistic saturation are the right shapes. Beyond that, the argument is complete. The experiments were already being run, and the information was already in them; all this post did was decline to throw it away.

References

  1. Heusch, N. (2026). Structural Estimation of Marketing Mix Model Parameters from Geo-Experiments. https://arxiv.org/abs/2608.21128
  2. Jin, Y., Wang, Y., Sun, Y., Chan, D., & Koehler, J. (2017). Bayesian Methods for Media Mix Modeling with Carryover and Shape Effects. Google Inc.
  3. Zhang, Y., et al. (2024). Media Mix Model Calibration with Bayesian Priors. Google Research.
  4. Gordon, B. R., Zettelmeyer, F., Bhargava, N., & Chapsky, D. (2019). A Comparison of Approaches to Advertising Measurement. Marketing Science, 38(2).
  5. Vaver, J., & Koehler, J. (2011). Measuring Ad Effectiveness Using Geo Experiments. Google Inc.
  6. PyMC Labs (2024). PyMC-Marketing. https://www.pymc-marketing.io/
  7. PyMC Labs. Lift Test Calibration (pymc-marketing documentation). https://www.pymc-marketing.io/en/stable/notebooks/mmm/mmm_lift_test.html

Note: All data in this post is synthetic; see the first post in the series for the generating process and the accompanying disclosure.

Footnotes

  1. If the MMM implementation cannot take priors for individual channels, there is a rougher shortcut: compute the tested channels’ weekly contributions from the estimated parameters, subtract them from sales, and estimate the MMM on the residual outcome with the untested channels only. This ignores the uncertainty of the first stage and should be treated accordingly — but if a decision must be made today, it beats an uncalibrated MMM.↩︎

  2. The division of labour is then clean: the observational weeks do what they are genuinely good for — baseline, seasonality, the untested channels — while the experimental weeks identify the causal parameters of the tested ones.↩︎