Synthethic data for Marketing Mix Modeling

We build a realistic dataset of sales and marketing spends so we can test MMMs on it.
Published

August 18, 2026

Disclaimer: I used Claude to come up with realistic mechanisms and parameter values; everything in the post here is fictional and not based on any actual company.

Introduction

Marketing Mix Models have become a standard tool for allocating marketing budgets. The promise is considerable: if you give the model historical time series of sales and marketing spends, the model will tells you the incremental sales from each marketing channel, and thereby quantify the return on investment for every channel. No experiments are required, but only the data — spends and sales — that is easily available anyway.

Coming from economics, I found this promise hard to believe. Recovering causal effects from historical time series is tricky, because it requires that (1) you either decided your marketing spends randomly or (2) you also have data on everything that affects both marketing spend and sales. This is a high bar. For example, demand generally varies seasonally — and companies act strategically and advertise more in seasons with higher demand. But in the context of MMMs, this now means that the MMM needs to include a ‘seasonal demand’ variable; otherwise its estimates of the marketing effects will be wrong.

The problem is that in reality, there are more factors than just seasonal demand: to give another example, pricing and promotions also affect both sales and marketing spends - at least, if you advertise strategically to build awareness or generate traffic during these periods. The MMM requires that you also include data on these factors. At the same time, nothing in the standard MMM workflow then verifies that all relevant factors are accounted for - and you can hence read the numbers the MMM produces as estimates of the true effect of marketing.

A common defence is that the model need not be exactly right, only closer to the truth than the alternatives. Perhaps. But with real data this claim is difficult to evaluate, because the true effect of marketing is never observed — no ledger anywhere records what sales would have been without the campaigns. Validation in practice reduces to predictive fit, or to comparing one MMM’s estimates against another’s. Neither establishes that the attribution is correct — and that the estimates get you at least a little closer to the truth.

Synthetic data offers a way forward. If we construct the data ourselves, we control the entire causal structure and know the true contribution of every marketing channel. We can then ask directly whether an MMM recovers it, and if it does not, we can say where the failure comes from.

For that test to mean anything, the synthetic data must be realistic — above all in how marketing spend comes about. The existing synthetic datasets that I found (here and here) generate spend more or less at random, and real companies do not: TV campaigns are coordinated with promotional events, bidding algorithms for online advertisements chase performance signals, quarterly budget reviews adjust spend in response to recent results. Synthetic data without these coordination patterns tests the model on an easier problem than the one it faces in production.

This post builds such a dataset: three years of weekly data from a hypothetical retailer, with the coordination patterns of a real operation and full knowledge of the true causal effects. It is the foundation for the posts that follow, in which we estimate a Marketing Mix Model on this data and then calibrate it with geo-experiments.

The design: four layers

The data is built in four steps. Each step adds one layer of business reality, and each layer will matter for the estimation problems of the posts that follow.

Step 1 — Base sales. The counterfactual: what sales would look like without any marketing. Not a flat line — a retailer has seasonal cycles, a changing product mix, an evolving competitive position, and demand shocks that nobody records. These are the patterns marketing effects sit on top of, and some of them will later act as confounders.

Step 2 — Promotional pricing. Retailers run sales on a predictable calendar: Black Friday in the same week every year, January clearance after Christmas, mid-season sales at fixed intervals. Price sensitivity varies with the occasion — Black Friday shoppers hunt for deals and respond more strongly to a 40% discount than customers in an ordinary week. The observed promotional data also carries measurement error, as it does in practice.

Step 3 — Marketing spend. The step most synthetic datasets simplify away. Marketing teams ramp up campaigns two to three weeks before a sale; algorithmic bidding raises spend when recent performance is strong; budget reviews reinvest after good quarters. The resulting correlations between spend and sales are not defects in the data. They are properties of how the business operates, and they are what makes attribution hard.

Step 4 — Marketing effects. Spend is converted into sales through the two transformations MMMs assume: geometric adstock for carryover, logistic saturation for diminishing returns. Using the model’s own functional forms as the truth is deliberate — any estimation failure later can then be attributed to identification, not to a mismatch in functional form.

The result is data with the endogeneity and coordination of a real operation, and a complete record of the true effects underneath. The attribution problem is not made artificially easy; it is made realistically hard, with the answers retained for grading.

Implementation

Each step shows the reasoning first and the code second. The plotting functions live in the separate helper file mmm1_helpers.py to keep the post readable.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from mmm1_helpers import (
    visualize_base_sales_components,
    visualize_promotional_effects,
    visualize_marketing_spend_patterns,
    visualize_marketing_transformations,
    visualize_complete_dataset,
    visualize_adstock_weights,
    visualize_saturation_curves
)

Step 1: Base Sales

The first layer is sales in a world without marketing, which are not a flat line: there are seasonal effects, the product portfolio changes, the competitive position drifts, and sometimes the economy is doing and well (and demand is high) and sometimes it’s not. Four components:

  • Seasonality. Three harmonics: an annual cycle (15% amplitude), a semi-annual cycle for the two fashion seasons (8%), and a quarterly rhythm (5%). Layered, but perfectly predictable — a function of the calendar and nothing else.

  • Competitive price positioning. The slow-moving question of whether the retailer is cheap or expensive relative to competitors — distinct from promotional discounts, which come in Step 2 and apply on top of this level. It follows a mean-reverting random walk, because extreme positioning is not sustainable, and it moves demand with a price elasticity of −0.9.

  • Product quality. The portfolio changes continuously — launches, discontinuations, inventory shifts. Also a mean-reverting random walk, but more persistent, and entering multiplicatively: a better assortment lifts sales in proportion to their level.

  • Market sentiment. A demand factor no data system records: consumer sentiment and the economy. Moderately persistent.

One could ask whether all three firm-level factors are needed, or whether a single latent demand factor would do. They could be condensed, but they earn their places differently, along three dimensions that matter later on.

Observability. Price positioning is the only one the analyst will ever see — through a noisy price variable. So we can include it in the model, even if imperfectly measured. Quality and sentiment stay hidden.

Frequency. Seasonality is a function of the calendar, so calendar-based controls can in principle absorb it, and the next post shows this working. Quality drifts too slowly to be separated from trend.

Functional form. Quality enters multiplicatively, sentiment additively — a distinction that looks pedantic here but decides, in the next post, how much even perfect controls could repair: a linear control absorbs an additive factor exactly, a multiplicative one only approximately.

If I wanted a leaner generator, the quality factor would be the first to go. The price factor is structurally necessary, because promotions discount from it, and sentiment is what makes the endogeneity bite. Readers who prefer a minimal generator can treat this as a menu rather than a prescription.

BASE_PRICE_ELASTICITY = -0.9 # The base price elasticity of demand.


def generate_base_sales(n_weeks=156, base_sales_level=2000,
                        price_elasticity=BASE_PRICE_ELASTICITY, random_state=42):
    """
    Sales in a world without marketing: seasonality, quality, competitive
    pricing, and unobserved sentiment. All monetary values in thousands of euros.
    """
    np.random.seed(random_state)
    t_index = np.arange(n_weeks)

    # Seasonality: annual, semi-annual (fashion seasons), quarterly
    seasonal_component = (
        0.15 * np.sin(2 * np.pi * t_index / 52) +
        0.08 * np.sin(4 * np.pi * t_index / 52) +
        0.05 * np.sin(6 * np.pi * t_index / 52)
    )

    # Product quality: mean-reverting random walk around 0, high persistence
    quality_factor = np.zeros(n_weeks)
    quality_factor[0] = np.random.normal(0, 0.1)
    for t in range(1, n_weeks):
        quality_factor[t] = 0.95 * quality_factor[t - 1] + np.random.normal(0, 0.02)

    # Competitive price positioning: mean-reverting around 1.0 (= market level)
    base_price_level = np.zeros(n_weeks)
    base_price_level[0] = 1.0
    for t in range(1, n_weeks):
        base_price_level[t] = (0.9 * base_price_level[t - 1] + 0.1 * 1.0
                               + np.random.normal(0, 0.03))

    # Market sentiment: unobserved, moderate persistence
    market_sentiment = np.zeros(n_weeks)
    market_sentiment[0] = np.random.normal(0, 0.05)
    for t in range(1, n_weeks):
        market_sentiment[t] = 0.7 * market_sentiment[t - 1] + np.random.normal(0, 0.03)

    # Combine: seasonality and quality scale the business multiplicatively,
    # pricing acts through the elasticity, sentiment adds on top
    base_sales = (base_sales_level
                  * (1 + seasonal_component)
                  * (1 + quality_factor)
                  * base_price_level ** price_elasticity
                  + market_sentiment * base_sales_level)

    # Weekly noise, and a floor at 50% of the base level as a guardrail
    base_sales += np.random.normal(0, base_sales_level * 0.02, n_weeks)

    return pd.DataFrame({
        'week': t_index + 1,
        'base_sales': base_sales,
        'seasonal_component': seasonal_component,
        'quality_factor': quality_factor,
        'base_price_level': base_price_level,
        'market_sentiment': market_sentiment
    })


base_data = generate_base_sales(n_weeks=156)
print(f"Base sales range: EUR {base_data['base_sales'].min():,.0f}K "
      f"to EUR {base_data['base_sales'].max():,.0f}K per week")
Base sales range: EUR 1,320K to EUR 2,878K per week
base_data.head(5)
week base_sales seasonal_component quality_factor base_price_level market_sentiment
0 1 2239.520824 0.000000 0.049671 1.000000 0.054939
1 2 2203.448705 0.054956 0.044423 1.055973 0.063220
2 3 2320.906782 0.106231 0.055155 1.064591 0.068659
3 4 2605.964209 0.150513 0.082858 1.022393 0.087226
4 5 2489.169213 0.185183 0.074032 1.039850 0.061688
visualize_base_sales_components(base_data, show_summary=False)

This is the counterfactual baseline the rest of the post builds on. The three firm-level factors move sales by broadly similar magnitudes — a few percent up or down in a typical week — but with very different dynamics. With the baseline in place, we can turn to the first layer of business activity: promotional pricing.

Step 2: Promotional Pricing

The promotional calendar is fixed and repeats every year: Cyber Week in late November, winter clearance in January, mid-season sales in spring and autumn, summer clearance in July. Major sales discount 40%, mid-season sales 30%.

Customer response depends on the occasion. Deal-hunting Cyber Week shoppers react more strongly to a 40% discount than customers in an ordinary week. I implement this by scaling the base price elasticity of −0.9 with an occasion-specific multiplier:

Occasion Discount Multiplier Resulting
Price Elasticity
No promotion - 1.0 −0.90
Mid-season sale 30% 1.3 −1.17
Clearance 40% 1.5 −1.35
Cyber Week 40% 1.8 −1.62

The sales multiplier in a promotion week is then \((1-d)^{\varepsilon}\), with \(d\) the discount and \(\varepsilon\) the occasion’s elasticity: a 40% discount in Cyber Week lifts sales by a factor of \(0.6^{-1.62} \approx 2.3\), the same discount in a clearance week by \(0.6^{-1.35} \approx 2.0\).

# The promotional calendar: fixed weeks within the year, fixed discount depths
PROMOTION_SCHEDULE = {
    'cyber_week':        {'weeks_in_year': [46, 47],  'discount': 0.4},
    'winter_clearance':  {'weeks_in_year': [2, 3, 4], 'discount': 0.4},
    'spring_mid_season': {'weeks_in_year': [16],      'discount': 0.3},
    'summer_clearance':  {'weeks_in_year': [28, 29],  'discount': 0.4},
    'autumn_mid_season': {'weeks_in_year': [40],      'discount': 0.3},
}

# Occasion-specific scaling of the base price elasticity
ELASTICITY_MULTIPLIERS = {
    'none': 1.0,
    'spring_mid_season': 1.3,
    'autumn_mid_season': 1.3,
    'winter_clearance': 1.5,
    'summer_clearance': 1.5,
    'cyber_week': 1.8,
}


def generate_promotional_calendar(n_weeks=156):
    """Roll the yearly schedule out over the full sample."""
    promotion_types = ['none'] * n_weeks
    true_discount_rates = np.zeros(n_weeks)
    for year_offset in range(0, n_weeks, 52):
        for promo_type, config in PROMOTION_SCHEDULE.items():
            for week in config['weeks_in_year']:
                t = year_offset + week
                if t < n_weeks:
                    promotion_types[t] = promo_type
                    true_discount_rates[t] = config['discount']
    return promotion_types, true_discount_rates
def calculate_true_promotional_effects(base_sales, base_price_level,
                                       promotion_types, true_discount_rates):
    """
    Apply occasion-dependent price elasticity to the promotional discounts.
    Returns the true (error-free) promotional price level and sales uplift.
    """
    n_weeks = len(base_sales)

    # Promotional prices discount from the competitive price level of Step 1
    true_promotional_price_level = base_price_level.copy()
    for t in range(n_weeks):
        if promotion_types[t] != 'none':
            true_promotional_price_level[t] = (base_price_level[t]
                                               * (1 - true_discount_rates[t]))

    # Elasticity by week: base elasticity scaled by the occasion multiplier
    true_elasticity_by_week = np.array([
        BASE_PRICE_ELASTICITY * ELASTICITY_MULTIPLIERS[promo]
        for promo in promotion_types
    ])

    # Sales multiplier (1 - d)^elasticity, with guardrails that never bind here
    price_ratio = np.maximum(1 - true_discount_rates, 0.5)
    promotional_sales_multipliers = np.power(price_ratio, true_elasticity_by_week)
    promotional_sales_multipliers = np.clip(promotional_sales_multipliers, 0.8, 3.0)

    true_sales_with_promotions = base_sales * promotional_sales_multipliers
    true_promotional_uplift = true_sales_with_promotions - base_sales

    return {
        'true_promotional_price_level': true_promotional_price_level,
        'true_elasticity_by_week': true_elasticity_by_week,
        'true_sales_with_promotions': true_sales_with_promotions,
        'true_promotional_uplift': true_promotional_uplift
    }

Measurement error. Real promotional data is imperfect, and it is worth building the imperfection in rather than assuming it away. In practice, promotion periods sometimes get mislabelled; and pricing feeds carry errors that persist across weeks rather than averaging out. Two illustrative error processes mimic this: the observed promotion flag captures only 70% of true promotion weeks and adds false positives in 15% of ordinary weeks, and the observed price level carries autocorrelated noise with 20% persistence.

This creates a dual data structure that runs through the rest of the series: we have columns keeping track of the true promotional state, which we keep for grading, and the observed state, which is all the model will be given.

def generate_measurement_errors(true_promotional_price_level, promotion_types,
                                random_state=42):
    """
    The two data-quality problems of real promotional data:
    1. Promotion weeks are mislabelled (70% captured, 15% false positives)
    2. Observed prices carry sticky, autocorrelated errors (20% persistence)
    """
    np.random.seed(random_state)
    n_weeks = len(promotion_types)

    # Problem 1: the observed promotion flag
    observed_promotion_dummy = np.zeros(n_weeks)
    true_promo_indices = [i for i, p in enumerate(promotion_types) if p != 'none']
    if len(true_promo_indices) > 0:
        captured_count = int(len(true_promo_indices) * 0.70)
        captured = np.random.choice(true_promo_indices, captured_count, replace=False)
        observed_promotion_dummy[captured] = 1

    non_promo_indices = [i for i, p in enumerate(promotion_types) if p == 'none']
    if len(non_promo_indices) > 0:
        false_positive_count = int(len(non_promo_indices) * 0.15)
        false_positives = np.random.choice(non_promo_indices, false_positive_count,
                                           replace=False)
        observed_promotion_dummy[false_positives] = 1

    # Problem 2: sticky pricing errors
    pricing_noise = np.zeros(n_weeks)
    pricing_noise[0] = np.random.normal(0, 0.08)
    for t in range(1, n_weeks):
        pricing_noise[t] = 0.2 * pricing_noise[t - 1] + np.random.normal(0, 0.08)

    observed_promotional_price_level = true_promotional_price_level * (1 + pricing_noise)
    observed_promotional_price_level = np.clip(observed_promotional_price_level, 0.5, 1.8)

    return {
        'observed_promotion_dummy': observed_promotion_dummy,
        'observed_promotional_price_level': observed_promotional_price_level,
        'pricing_noise': pricing_noise
    }

Putting the pieces together:

promotion_types, true_discount_rates = generate_promotional_calendar(156)

true_promotion_effects = calculate_true_promotional_effects(
    base_data['base_sales'],
    base_data['base_price_level'],
    promotion_types,
    true_discount_rates
)

measured_promotion_effects = generate_measurement_errors(
    true_promotion_effects['true_promotional_price_level'],
    promotion_types
)

promotional_data_df = pd.DataFrame({
    'week': range(1, 157),
    'promotion_type': promotion_types,
    'true_discount_rate': true_discount_rates,
    'true_promotion_dummy': [1 if p != 'none' else 0 for p in promotion_types],
    'observed_promotion_dummy': measured_promotion_effects['observed_promotion_dummy'],
    'base_sales': base_data['base_sales'],
    'true_promotional_price_level': true_promotion_effects['true_promotional_price_level'],
    'true_sales_with_promotions': true_promotion_effects['true_sales_with_promotions'],
    'true_promotional_uplift': true_promotion_effects['true_promotional_uplift'],
    'observed_promotional_price_level': measured_promotion_effects['observed_promotional_price_level'],
})
promotional_data_df.head(5)
week promotion_type true_discount_rate true_promotion_dummy observed_promotion_dummy base_sales true_promotional_price_level true_sales_with_promotions true_promotional_uplift observed_promotional_price_level
0 1 none 0.0 0 0.0 2239.520824 1.000000 2239.520824 0.000000 0.957247
1 2 none 0.0 0 0.0 2203.448705 1.055973 2203.448705 0.000000 0.920645
2 3 winter_clearance 0.4 1 1.0 2320.906782 0.638755 4625.444876 2304.538094 0.582051
3 4 winter_clearance 0.4 1 1.0 2605.964209 0.613436 5193.549301 2587.585093 0.639042
4 5 winter_clearance 0.4 1 1.0 2489.169213 0.623910 4960.783030 2471.613818 0.618529
visualize_promotional_effects(promotional_data_df, show_summary=False)

The promotional layer contributes three things the estimation will have to cope with: sharp, recurring sales spikes; customer response that varies by occasion; and observed promotion data that is wrong in realistic ways. None of these is exotic; they are the ordinary conditions under which the model will have to work. Next, marketing spend that coordinates around this calendar.

Step 3: Marketing Spend

Marketing spend in real companies is not random. It is the outcome of strategic planning, algorithmic optimisation, and budget cycles.

Strategic coordination. Marketing teams know the promotional calendar months in advance and ramp up spend two to three weeks before a sale to build awareness. Spend increases before sales-events, which creates a forward-looking correlation.

Algorithmic performance chasing. Bidding systems — Google product listing ads (PLA) in this data — monitor recent sales and adjust spend automatically: more when sales are strong, less when they are weak. Spend ends up correlated with sales through the optimisation loop, whether or not the spend caused anything.

Quarterly budget reviews. After a good quarter, the marketing budget grows; after a weak one, it is cut. A slow-moving correlation between past performance and future spend, unrelated to current effectiveness.

We model three marketing channels - television, Meta (Instagram and Facebook), and Google product listing ads (PLA). The channels combine these mechanisms differently, which is itself realistic. Meta follows the strategic calendar. TV adds concentrated bursts around Christmas and summer, planned long in advance and indifferent to weekly performance. PLA follows the calendar and chases performance. The implementation mirrors this structure: a planned component for every channel, and a performance-chasing multiplier applied on top for PLA.

Quarterly budget reviews

Every 13 weeks, the budget for the coming quarter is scaled by last quarter’s sales relative to the quarter before, with a sensitivity of 0.3 and bounds of -20% down, +40% up.

def generate_quarterly_budget_adjustments(sales_signal, sensitivity=0.3):
    """
    Quarterly budget reviews: each quarter's budget multiplier responds to
    the previous quarter's sales relative to the quarter before that.
    The first review with two full quarters of history is at week 26.
    """
    sales_signal = np.asarray(sales_signal)
    n_weeks = len(sales_signal)
    multipliers = np.ones(n_weeks)

    for quarter_start in range(26, n_weeks, 13):
        prev_quarter = sales_signal[quarter_start - 13:quarter_start].mean()
        quarter_before = sales_signal[quarter_start - 26:quarter_start - 13].mean()
        performance_ratio = prev_quarter / quarter_before
        adjustment = np.clip(1 + sensitivity * (performance_ratio - 1), 0.8, 1.4)
        multipliers[quarter_start:min(n_weeks, quarter_start + 13)] = adjustment

    return multipliers


sales_before_marketing = true_promotion_effects['true_sales_with_promotions']
quarterly_multipliers = generate_quarterly_budget_adjustments(sales_before_marketing)

One simplification: the reviews respond to sales before marketing effects, which keeps the data generation sequential. In reality budgets respond to total sales, marketing effects included. The coordination pattern that matters — budgets follow business performance with a quarterly lag — survives this simplification.

Planned spend

Planned spend for each channel starts from a base level and moves, in log space, with three terms: the quarterly budget multiplier, the seasonal planning cycle, and the number of promotion weeks coming up in the next three weeks — the strategic ramp-up. TV adds its bursts on top: 15× the base level in the five weeks before Christmas, 8× in early summer.

def count_upcoming_promotions(promotion_types, horizon=3):
    """For each week, the number of promotion weeks among the next `horizon` weeks."""
    n_weeks = len(promotion_types)
    is_promo = np.array([p != 'none' for p in promotion_types], dtype=int)
    upcoming = np.zeros(n_weeks, dtype=int)
    for t in range(n_weeks):
        upcoming[t] = is_promo[t + 1:min(n_weeks, t + 1 + horizon)].sum()
    return upcoming


# Base spend in thousands of euros; promo_coordination scales the pre-promotion
# ramp-up. TV coordinates through its planned bursts, not tactically.
CHANNEL_PLANNING = {
    'pla':  {'base_spend': 60, 'promo_coordination': 0.4},
    'meta': {'base_spend': 40, 'promo_coordination': 0.4},
    'tv':   {'base_spend': 10, 'promo_coordination': 0.0},
}

TV_BURSTS = {
    'christmas': {'weeks_in_year': range(47, 52), 'multiplier': 15},
    'summer':    {'weeks_in_year': range(20, 23), 'multiplier': 8},
}


def generate_planned_spend(n_weeks, seasonal_component, upcoming_promotions,
                           quarterly_multipliers, random_state=42):
    """
    The planned component of spend for every channel: base level, quarterly
    budget, seasonal planning, strategic ramp-up before promotions, and
    TV's burst calendar. All values in thousands of euros.
    """
    np.random.seed(random_state)
    seasonal_component = np.asarray(seasonal_component)
    spend_data = {}

    for channel, config in CHANNEL_PLANNING.items():
        spend = np.zeros(n_weeks)
        for t in range(n_weeks):
            log_spend = np.log(config['base_spend'])
            log_spend += np.log(quarterly_multipliers[t])
            log_spend += 0.15 * seasonal_component[t]
            log_spend += config['promo_coordination'] * upcoming_promotions[t]

            if channel == 'tv':
                week_in_year = t % 52
                for burst in TV_BURSTS.values():
                    if week_in_year in burst['weeks_in_year']:
                        log_spend += np.log(burst['multiplier'])

            log_spend += np.random.normal(0, 0.1)   # planning noise
            spend[t] = np.exp(log_spend)
        spend_data[channel] = spend

    return spend_data


upcoming_promotions = count_upcoming_promotions(promotion_types)
planned_spend = generate_planned_spend(
    156, base_data['seasonal_component'], upcoming_promotions, quarterly_multipliers
)

Performance chasing

The algorithmic component deserves the most care, because it produces the endogeneity that later proves hardest to remove.

Modern bidding systems adjust spend continuously in response to results. The stylised version here: each week, the algorithm compares recent sales — with a one-week reporting lag — against its own baseline, a ten-week rolling average, and scales spend up or down in proportion to the gap, within bounds. When sales are above the recent average, PLA spend rises; when they are below, it falls.

The attribution problem follows directly. When PLA spend is high during a sales spike, there are two candidate explanations: the spend caused the spike, or the algorithm detected the spike and moved to capture it. In the data both look identical — spend and sales moving together — and a Marketing Mix Model has no way to tell them apart from the time series alone. Strategic coordination at least follows a calendar, which a model can control for. The algorithm responds to realised performance, including its random component, and no function of the calendar can proxy for that.

One simplification (similar to the one for modelling budget reviews): the algorithm here observes sales before marketing effects. In reality it would observe total sales, its own contribution included — a feedback loop. Modelling the loop would require simulating week by week; I keep the causal structure acyclic instead, at the cost of understating PLA’s endogeneity somewhat. The essential mechanism — spend follows performance signals — is preserved.

def performance_chasing_multipliers(sales_signal, sensitivity=0.8,
                                    response_lag=1, baseline_window=10,
                                    bounds=(0.5, 3.0)):
    """
    The bidding algorithm as a weekly spend multiplier: recent sales
    (with a reporting lag) relative to a rolling baseline, scaled by the
    algorithm's sensitivity and clipped to its safety bounds.
    Deterministic given the sales signal.
    """
    sales_signal = np.asarray(sales_signal)
    n_weeks = len(sales_signal)
    multipliers = np.ones(n_weeks)

    for t in range(baseline_window + response_lag, n_weeks):
        recent = sales_signal[t - response_lag:t].mean()
        baseline = sales_signal[t - baseline_window - response_lag:t - response_lag].mean()
        performance_ratio = recent / baseline
        multipliers[t] = np.clip(1 + sensitivity * (performance_ratio - 1),
                                 bounds[0], bounds[1])

    return multipliers


chasing_multipliers = performance_chasing_multipliers(sales_before_marketing)

# Final spend: the planned series for every channel, with the chasing
# multiplier applied on top for PLA only
marketing_spend = dict(planned_spend)
marketing_spend['pla'] = planned_spend['pla'] * chasing_multipliers
visualize_marketing_spend_patterns(marketing_spend, planned_spend['pla'],
                                   promotion_types, base_data,
                                   true_promotion_effects, quarterly_multipliers,
                                   show_summary=False)

The third panel compresses the endogeneity into one picture. At negative lags, the correlation measures how well spend predicts future sales; at positive lags, how well past sales predict spend. Both PLA and Meta correlate strongly at negative lags — the strategic ramp-up ahead of promotions, which the two channels share. The difference sits on the right-hand side: once sales lead spend, Meta’s correlation falls away, while PLA’s stays positive — the signature of an algorithm following realised performance. TV shows little of either; its bursts follow the calendar, not the sales signal. The fourth panel isolates the algorithmic component directly — the same PLA series with and without the chasing multiplier, on identical random draws.

None of this coordination is noise to be cleaned away. It is how a competent retailer operates, and a synthetic benchmark that omitted it would flatter the model. What remains is the final layer: converting this spend into the sales effects the model will later try to recover.

Step 4: Marketing Effects

Spend becomes sales through the two transformations that Marketing Mix Models assume: adstock for carryover, saturation for diminishing returns. Because the goal is to test whether an MMM can recover known effects, the data uses exactly the transformations the model will later fit. This is a deliberate gift to the model — whatever goes wrong in estimation cannot be blamed on functional form.

The two functions below reappear in identical form throughout the series: the estimation notebook implements the same equations inside PyMC, and the calibration notebook uses these definitions directly.

Adstock: carryover over time

Advertising this week does not only translate into additional sales this week. The ad lingers on people’s minds, or people simply take time to purchase after seeing it. This delayed effect is called carryover or adstock: each week’s spend is spread over the current and following weeks with geometrically decaying weights — with a retention rate of 70%, each week carries 70% of the previous week’s share, truncated here at six weeks.

It is convention to normalise the weights to sum to one, so adstock redistributes spend over time without changing its total: total exposure equals total spend, which keeps the coefficients — and later the ROAS figures — interpretable. One consequence is worth stating explicitly, because it trips up the intuition: with 70% retention, a given week does not carry the full effect of the spend in that week, but only about a third of it (34%), the next week 24%, the week after 17%, and so on. The 70% describes the decay between weeks, not the share of the first one.

def geometric_adstock(spend_series, adstock_rate, l_max=6):
    """
    Geometric carryover with weights normalised to sum to one, so total
    exposure equals total spend. Weeks before the sample are treated as zero.
    """
    weights = adstock_rate ** np.arange(l_max)
    weights = weights / weights.sum()
    return np.convolve(spend_series, weights)[:len(spend_series)]
visualize_adstock_weights(rates=(0.2, 0.4, 0.7))

Saturation: diminishing returns

When spending on marketing, the second EUR 100K might buy you less than the first EUR 100K: the most receptive customers have already been reached. We model this through logistic saturation, \(\frac{1-e^{-\lambda x}}{1+e^{-\lambda x}}\), which maps adstocked spend into \([0,1)\): it rises steeply at first and flattens toward a ceiling of 1. The parameter \(\lambda\) controls how quickly the ceiling is approached, but it has no intuitive units on its own. A more readable quantity is the half-saturation spend — the spend at which a channel delivers half of its maximum possible effect. Setting the expression equal to \(\tfrac{1}{2}\) and solving gives \(x_{1/2} = \ln 3/ \lambda\). For the flattest of the three curves below, \(\lambda = 0.006\), that is about EUR 183K: at that level of (adstocked) spend, the channel has exhausted half of what it can ever contribute per week.

def logistic_saturation(adstocked_series, lam):
    """Diminishing returns. Spend is in thousands of euros; no further scaling."""
    return (1 - np.exp(-lam * adstocked_series)) / (1 + np.exp(-lam * adstocked_series))
visualize_saturation_curves(logistic_saturation, lambdas=(0.008, 0.01, 0.006))

Channel parameters

The channels differ in carryover and saturation according to their role. PLA: 20% carryover — performance marketing acts quickly — with half-saturation near EUR 137K and a coefficient set for roughly 4× ROAS. Meta: 40% carryover, mixed brand and performance marketing, half-saturation near EUR 110K, roughly 3× ROAS. TV: 70% carryover — brand building — the flattest curve with half-saturation near EUR 183K, roughly 1.5× ROAS. The ROAS levels are illustrative, in the range of published industry benchmarks.

# All monetary values in thousands of euros.
CHANNEL_CONFIGS = {
    'pla': {
        'adstock_rate': 0.2,         # fast decay: performance marketing acts now
        'saturation_lambda': 0.008,  # half-saturation at ln(3)/0.008 = EUR 137K
        'coefficient': 1100          # gives roughly 4x ROAS
    },
    'meta': {
        'adstock_rate': 0.4,         # mixed brand / performance
        'saturation_lambda': 0.01,   # half-saturation at EUR 110K
        'coefficient': 600           # roughly 3x ROAS
    },
    'tv': {
        'adstock_rate': 0.7,         # long brand-building carryover
        'saturation_lambda': 0.006,  # flattest curve: half-saturation at EUR 183K
        'coefficient': 560           # roughly 1.5x ROAS
    }
}

The three retention rates and three saturation curves above are, in fact, the three channels.

A note on units, looking ahead. This notebook works in unscaled thousands of euros, so \(\lambda\) is numerically small (0.006–0.010 per EUR K). The estimation notebook will divide every series by its maximum before sampling. The parameters \(\lambda\) and \(\beta\) are not scale-invariant - hence it matters whether we estimate a model with spends expressed in EUR, spends in thousands of EUR, or spends scaled in other ways: the parameters only mean something relative to the scale of the data. Keeping this in mind avoids the most common confusion when comparing estimates against the ground truth.

The pipeline per channel is spend → adstock → saturation → coefficient. The function records the intermediate series as well; they feed the figure and the validation printout.

def apply_marketing_transformations(spend_data):
    """Convert spend into sales effects: adstock, then saturation, then scale."""
    marketing_effects = {}
    total_marketing_uplift = 0
    transformation_details = {}

    for channel, spend in spend_data.items():
        config = CHANNEL_CONFIGS[channel]

        adstocked = geometric_adstock(spend, config['adstock_rate'])
        saturated = logistic_saturation(adstocked, config['saturation_lambda'])
        marketing_effects[channel] = config['coefficient'] * saturated
        total_marketing_uplift += marketing_effects[channel]

        transformation_details[channel] = {
            'avg_spend': spend.mean(),
            'avg_adstocked': adstocked.mean(),
            'avg_saturation': saturated.mean(),
            'avg_effect': marketing_effects[channel].mean(),
            'implied_roas': (marketing_effects[channel].mean() / spend.mean()
                             if spend.mean() > 0 else 0),
            'adstocked': adstocked,
            'saturated': saturated,
        }

    return marketing_effects, total_marketing_uplift, transformation_details
marketing_effects, total_marketing_uplift, transformation_details = \
    apply_marketing_transformations(marketing_spend)

adstocked = {ch: d['adstocked'] for ch, d in transformation_details.items()}
saturated = {ch: d['saturated'] for ch, d in transformation_details.items()}

visualize_marketing_transformations(
    marketing_spend=marketing_spend,
    adstocked_spend=adstocked,
    saturated_spend=saturated,
    marketing_effects=marketing_effects,
    transformation_details=transformation_details,
    CHANNEL_CONFIGS=CHANNEL_CONFIGS,
    show_details=True,
    n_weeks=156
)

TRANSFORMATION CHECKS
==================================================

PLA:
  average spend           EUR 76K per week
  half-saturation spend   EUR 137K (spend at which the curve reaches 50%)
  implied ROAS            4.2x
  saturation at spend of EUR 50K: 20%   EUR 100K: 38%   EUR 150K: 54%   EUR 200K: 66%   

META:
  average spend           EUR 53K per week
  half-saturation spend   EUR 110K (spend at which the curve reaches 50%)
  implied ROAS            2.9x
  saturation at spend of EUR 50K: 24%   EUR 100K: 46%   EUR 150K: 64%   EUR 200K: 76%   

TV:
  average spend           EUR 28K per week
  half-saturation spend   EUR 183K (spend at which the curve reaches 50%)
  implied ROAS            1.6x
  saturation at spend of EUR 50K: 15%   EUR 100K: 29%   EUR 150K: 42%   EUR 200K: 54%   

Each channel’s effect now follows the model’s own assumptions: carryover that preserves total impact, diminishing returns at plausible spend levels, and ROAS in a realistic range.

Step 5: The Complete Dataset

The four layers combine additively:

\[\text{total sales}_t \;=\; \text{base}_t \;+\; \text{promotional uplift}_t \;+\; \sum_{c} \text{marketing effect}_{c,t}\]

The assembled table carries the dual structure introduced in Step 2, now for everything. The observed columns are what an analyst would receive: total sales, spend per channel, the noisy promotion flag and price level. The ground-truth columns are what we keep for grading: the decomposition of every euro of sales, the true promotional state, each channel’s true effect. The estimator will be fit on the former; the latter exist to evaluate it.

def assemble_final_dataset(base_data, promotional_data_df, marketing_spend,
                           marketing_effects):
    """
    The complete dataset. Causal flow:
    base_sales + promotional_uplift + marketing_uplift = total_sales
    """
    n_weeks = len(base_data)

    total_sales = (base_data['base_sales'] +
                   promotional_data_df['true_promotional_uplift'] +
                   sum(marketing_effects.values()))

    dataset = pd.DataFrame({
        # Time
        'week': range(1, n_weeks + 1),
        'year': [(w - 1) // 52 + 1 for w in range(1, n_weeks + 1)],
        'week_of_year': [(w - 1) % 52 + 1 for w in range(1, n_weeks + 1)],

        # Outcome and its true decomposition
        'total_sales': total_sales,
        'base_sales': base_data['base_sales'],
        'promotional_sales_uplift': promotional_data_df['true_promotional_uplift'],
        'marketing_sales_uplift': sum(marketing_effects.values()),

        # Base components (unobserved in real data)
        'seasonal_component': base_data['seasonal_component'],
        'quality_factor': base_data['quality_factor'],
        'base_price_level': base_data['base_price_level'],
        'market_sentiment': base_data['market_sentiment'],

        # Promotions: true state (for grading)
        'promotion_type': promotional_data_df['promotion_type'],
        'true_discount_rate': promotional_data_df['true_discount_rate'],
        'true_promotion_dummy': promotional_data_df['true_promotion_dummy'],
        'true_promotional_price_level': promotional_data_df['true_promotional_price_level'],

        # Promotions: observed state (what the model sees)
        'observed_promotion_dummy': promotional_data_df['observed_promotion_dummy'],
        'observed_promotional_price_level': promotional_data_df['observed_promotional_price_level'],

        # Marketing spend (what the model sees)
        'pla_spend': marketing_spend['pla'],
        'meta_spend': marketing_spend['meta'],
        'tv_spend': marketing_spend['tv'],

        # Marketing effects (for grading)
        'pla_effect': marketing_effects['pla'],
        'meta_effect': marketing_effects['meta'],
        'tv_effect': marketing_effects['tv'],
        'total_marketing_effect': sum(marketing_effects.values())
    })

    return dataset


synthetic_dataset = assemble_final_dataset(
    base_data=base_data,
    promotional_data_df=promotional_data_df,
    marketing_spend=marketing_spend,
    marketing_effects=marketing_effects
)
synthetic_dataset.head(5)
week year week_of_year total_sales base_sales promotional_sales_uplift marketing_sales_uplift seasonal_component quality_factor base_price_level ... true_promotional_price_level observed_promotion_dummy observed_promotional_price_level pla_spend meta_spend tv_spend pla_effect meta_effect tv_effect total_marketing_effect
0 1 1 1 2896.414275 2239.520824 0.000000 656.893451 0.000000 0.049671 1.000000 ... 1.000000 0.0 0.957247 140.332692 107.281573 10.860439 463.268805 187.421404 6.203243 656.893451
1 2 1 2 3196.504325 2203.448705 0.000000 993.055621 0.054956 0.044423 1.055973 ... 1.055973 0.0 0.920645 198.097935 140.401496 10.937305 681.164797 301.302236 10.588588 993.055621
2 3 1 3 5495.854098 2320.906782 2304.538094 870.409222 0.106231 0.055155 1.064591 ... 0.638755 1.0 0.582051 144.755790 80.293109 11.577548 596.991585 259.394564 14.023073 870.409222
3 4 1 4 5903.182328 2605.964209 2587.585093 709.633027 0.150513 0.082858 1.022393 ... 0.613436 1.0 0.639042 106.615036 65.177337 10.249844 475.591387 218.372803 15.668836 709.633027
4 5 1 5 5439.086606 2489.169213 2471.613818 478.303576 0.185183 0.074032 1.039850 ... 0.623910 1.0 0.618529 60.262295 37.307287 11.007290 305.694983 155.355628 17.252965 478.303576

5 rows × 24 columns

visualize_complete_dataset(synthetic_dataset, show_summary=True)

COMPLETE DATASET SUMMARY
==================================================
Shape: 156 weeks (3.0 years), 24 columns
Total sales: EUR 1,812K to EUR 6,035K per week

Decomposition of cumulative sales:
  base sales           70.3%
  promotional uplift   11.1%
  marketing uplift     18.7%

True ROAS by channel:
  PLA   4.20x
  META  2.90x
  TV    1.56x

Promotion detection accuracy: 82.1%
Price measurement correlation: 0.891
synthetic_dataset.to_csv("synthetic_mmm_data.csv", index=False)

Conclusion

The dataset now carries the complexity that a Marketing Mix Model will realistically have to deal with: seasonality, sales and discounts - and consumers responding to them, marketing spend shaped by planning and by algorithms, and smaller measurement errors — together with an exact record of the truth underneath.

The simplifications are worth listing. There are no competitive responses, no supply-chain constraints forcing promotional timing, no other shocks to demand. The bidding algorithm is stylised — real systems use richer signals than recent sales — and it deliberately does not see its own effects, which understates the feedback problem rather than overstating it. These choices keep the causal structure tractable while retaining the coordination patterns that matter most.

In the next post, we build a Bayesian Marketing Mix Model from scratch, fit it to this data, and grade it against the truth we have just buried in it.

References

  1. Jin, Y., Wang, Y., Sun, Y., Chan, D., & Koehler, J. (2017). Bayesian Methods for Media Mix Modeling with Carryover and Shape Effects. Google Inc.

  2. Runge, J., Skokan, I., Zhou, G., & Pauwels, K. (2025). Packaging Up Media Mix Modeling: An Introduction to Robyn. arXiv:2403.14674.

  3. Meta Marketing Science (2024). Robyn: Open Source Marketing Mix Modeling. GitHub repository: https://github.com/facebookexperimental/Robyn

  4. Nguyen, J. (2022). siMMMulator: An Open-Source R Package to Generate Simulated Data for Marketing Mix Models. Meta. https://facebookexperimental.github.io/siMMMulator/

  5. Vaver, J., & Zhang, S. (2017). Introduction to the Aggregate Marketing System Simulator. Google Inc.

  6. PyMC Labs (2024). PyMC-Marketing: Open Source Marketing Analytics Solution. https://www.pymc-marketing.io/

  7. Google (2025). Meridian: Marketing Mix Modeling Framework. https://developers.google.com/meridian

Note: All data, parameters, and business patterns in this post are fictional and do not reflect any specific company’s operations, performance metrics, or proprietary information. Parameter values were chosen by Claude, based on publicly available knowledge and the academic literature.