MMM Dataset

Source-of-truth parameters.

Synthetic DTC subscription dataset for MMM learning modules. The data-generating process is transparent so lessons can compare model estimates against known truth.

Scenario

DTC subscription brand, weekly acquisition data.

Business

DTC subscription brand

Outcome: observed_subscriptions

Unit

new paid subscriptions

spend columns are in thousands of dollars

Start date

2023-01-02

156 weekly observations

Channel truth

Memory, saturation, and role by channel.

Channel Lambda Half-life Beta Half-saturation Slope Role
Paid Search paid_search 0.18 0.4 weeks 310 135 1.25 Demand capture with fast decay and visible promo responsiveness.
Paid Social paid_social 0.42 0.8 weeks 250 165 1.35 Paced prospecting channel with moderate memory.
CTV / Video ctv_video 0.72 2.11 weeks 420 360 1.8 Flighted awareness channel with long memory.
Podcast / Audio podcast_audio 0.62 1.45 weeks 210 175 1.55 Flighted audio with delayed-seeming carryover under geometric memory.
Influencer influencer 0.35 0.66 weeks 160 105 1.3 Bursty creator drops with some spillover.

Formulas

The generator writes observable data and known truth.

Adstock

Media memory carries a share of prior effective media into the current week.

Half-life

Half-life translates lambda into stakeholder-friendly calendar time.

Saturation

The Hill curve turns effective media into diminishing incremental subscriptions.

Outcome

The observed outcome is the known signal plus measurement noise.

Reproduction

Python script with highlighted generation logic.

This dependency-free Python script reproduces the same CSV and parameter JSON as the Node generator. Highlighted comment lines mark the business assumptions, memory step, saturation step, and noisy observed outcome. Open the raw Python script to copy or save it.


						1
						#!/usr/bin/env python3
					
						2
						"""Generate the MMM Dataset CSV and source-of-truth parameter JSON.
					
						3
						
					
						4
						This mirrors scripts/generate-mmm-dataset.mjs, but is written in plain Python
					
						5
						so readers can reproduce the dataset from the marketing science side without
					
						6
						needing the Astro/Node toolchain.
					
						7
						"""
					
						8
						 
					
						9
						from __future__ import annotations
					
						10
						 
					
						11
						import json
					
						12
						import math
					
						13
						from datetime import date, timedelta
					
						14
						from pathlib import Path
					
						15
						 
					
						16
						 
					
						17
						PROJECT_ROOT = Path(__file__).resolve().parents[1]
					
						18
						OUTPUT_DIR = PROJECT_ROOT / "public" / "data" / "mmm"
					
						19
						CSV_PATH = OUTPUT_DIR / "mmm-dataset.csv"
					
						20
						PARAMS_PATH = OUTPUT_DIR / "mmm-dataset-params.json"
					
						21
						 
					
						22
						SEED = 20260520
					
						23
						WEEK_COUNT = 156
					
						24
						START_DATE = date(2023, 1, 2)
					
						25
						MASK_32 = 0xFFFFFFFF
					
						26
						 
					
						27
						 
					
						28
						def to_int32(value: int) -> int:
					
						29
						    value &= MASK_32
					
						30
						    return value - 0x100000000 if value & 0x80000000 else value
					
						31
						 
					
						32
						 
					
						33
						def unsigned_right_shift(value: int, bits: int) -> int:
					
						34
						    return (value & MASK_32) >> bits
					
						35
						 
					
						36
						 
					
						37
						def imul(left: int, right: int) -> int:
					
						38
						    return to_int32((left & MASK_32) * (right & MASK_32))
					
						39
						 
					
						40
						 
					
						41
						def mulberry32(initial_seed: int):
					
						42
						    """Match the JavaScript Mulberry32 PRNG used by the Node generator."""
					
						43
						    state = to_int32(initial_seed)
					
						44
						 
					
						45
						    def random() -> float:
					
						46
						        nonlocal state
					
						47
						        state = to_int32(state + 0x6D2B79F5)
					
						48
						        t = imul(state ^ unsigned_right_shift(state, 15), 1 | state)
					
						49
						        t = to_int32(to_int32(t + imul(t ^ unsigned_right_shift(t, 7), 61 | t)) ^ t)
					
						50
						        return unsigned_right_shift(t ^ unsigned_right_shift(t, 14), 0) / 4294967296
					
						51
						 
					
						52
						    return random
					
						53
						 
					
						54
						 
					
						55
						random = mulberry32(SEED)
					
						56
						 
					
						57
						 
					
						58
						def normal(mean: float = 0, std: float = 1) -> float:
					
						59
						    u1 = max(random(), 2.220446049250313e-16)
					
						60
						    u2 = random()
					
						61
						    return mean + std * math.sqrt(-2 * math.log(u1)) * math.cos(2 * math.pi * u2)
					
						62
						 
					
						63
						 
					
						64
						def clamp(value: float, minimum: float, maximum: float) -> float:
					
						65
						    return min(maximum, max(minimum, value))
					
						66
						 
					
						67
						 
					
						68
						def js_round(value: float, decimals: int = 2) -> float:
					
						69
						    multiplier = 10**decimals
					
						70
						    return math.floor(value * multiplier + 0.5) / multiplier
					
						71
						 
					
						72
						 
					
						73
						def js_number(value) -> str:
					
						74
						    if isinstance(value, str):
					
						75
						        return value
					
						76
						    if isinstance(value, int):
					
						77
						        return str(value)
					
						78
						    if isinstance(value, float) and value.is_integer():
					
						79
						        return str(int(value))
					
						80
						    return f"{value:.15g}"
					
						81
						 
					
						82
						 
					
						83
						def iso_week_date(index: int) -> str:
					
						84
						    return (START_DATE + timedelta(days=index * 7)).isoformat()
					
						85
						 
					
						86
						 
					
						87
						def hill(value: float, beta: float, half_saturation: float, slope: float) -> float:
					
						88
						    if value <= 0:
					
						89
						        return 0
					
						90
						    numerator = value**slope
					
						91
						    denominator = numerator + half_saturation**slope
					
						92
						    return (beta * numerator) / denominator
					
						93
						 
					
						94
						 
					
						95
						# Highlight: channel parameters are the known truth future lessons can test against.
					
						96
						CHANNELS = {
					
						97
						    "paid_search": {
					
						98
						        "label": "Paid Search",
					
						99
						        "lambda": 0.18,
					
						100
						        "beta": 310,
					
						101
						        "halfSaturation": 135,
					
						102
						        "slope": 1.25,
					
						103
						        "role": "Demand capture with fast decay and visible promo responsiveness.",
					
						104
						    },
					
						105
						    "paid_social": {
					
						106
						        "label": "Paid Social",
					
						107
						        "lambda": 0.42,
					
						108
						        "beta": 250,
					
						109
						        "halfSaturation": 165,
					
						110
						        "slope": 1.35,
					
						111
						        "role": "Paced prospecting channel with moderate memory.",
					
						112
						    },
					
						113
						    "ctv_video": {
					
						114
						        "label": "CTV / Video",
					
						115
						        "lambda": 0.72,
					
						116
						        "beta": 420,
					
						117
						        "halfSaturation": 360,
					
						118
						        "slope": 1.8,
					
						119
						        "role": "Flighted awareness channel with long memory.",
					
						120
						    },
					
						121
						    "podcast_audio": {
					
						122
						        "label": "Podcast / Audio",
					
						123
						        "lambda": 0.62,
					
						124
						        "beta": 210,
					
						125
						        "halfSaturation": 175,
					
						126
						        "slope": 1.55,
					
						127
						        "role": "Flighted audio with delayed-seeming carryover under geometric memory.",
					
						128
						    },
					
						129
						    "influencer": {
					
						130
						        "label": "Influencer",
					
						131
						        "lambda": 0.35,
					
						132
						        "beta": 160,
					
						133
						        "halfSaturation": 105,
					
						134
						        "slope": 1.3,
					
						135
						        "role": "Bursty creator drops with some spillover.",
					
						136
						    },
					
						137
						}
					
						138
						 
					
						139
						 
					
						140
						def build_spend(index: int, promo_flag: int, seasonality: float) -> dict[str, float]:
					
						141
						    quarter_week = index % 13
					
						142
						    year_week = index % 52
					
						143
						    growth = 1 + index / WEEK_COUNT * 0.2
					
						144
						 
					
						145
						    # Highlight: spend patterns encode realistic media behavior before any model sees the data.
					
						146
						    ctv_flight = (
					
						147
						        230 + 60 * math.sin((quarter_week - 2) / 3 * math.pi)
					
						148
						        if 2 <= quarter_week <= 5
					
						149
						        else 0
					
						150
						    )
					
						151
						    podcast_flight = (
					
						152
						        80 + 55 * random()
					
						153
						        if year_week in [4, 5, 6, 17, 18, 19, 31, 32, 33, 43, 44, 45]
					
						154
						        else 18 + 8 * random()
					
						155
						    )
					
						156
						    influencer_drop = (
					
						157
						        90 + 90 * random()
					
						158
						        if year_week in [7, 20, 34, 47]
					
						159
						        else 35 + 50 * random()
					
						160
						        if random() > 0.82
					
						161
						        else 0
					
						162
						    )
					
						163
						 
					
						164
						    return {
					
						165
						        "paid_search": clamp((94 + 34 * promo_flag + 18 * seasonality + normal(0, 8)) * growth, 45, 190),
					
						166
						        "paid_social": clamp(
					
						167
						            (112 + 26 * math.sin((2 * math.pi * (index + 5)) / 26) + normal(0, 14)) * growth,
					
						168
						            45,
					
						169
						            210,
					
						170
						        ),
					
						171
						        "ctv_video": clamp((ctv_flight + normal(0, 18 if ctv_flight > 0 else 0)) * growth, 0, 360),
					
						172
						        "podcast_audio": clamp((podcast_flight + normal(0, 7)) * growth, 0, 175),
					
						173
						        "influencer": clamp(influencer_drop + normal(0, 10 if influencer_drop > 0 else 0), 0, 205),
					
						174
						    }
					
						175
						 
					
						176
						 
					
						177
						def generate_rows() -> list[dict[str, str | int | float]]:
					
						178
						    rows = []
					
						179
						    adstock_state = {channel: 0.0 for channel in CHANNELS}
					
						180
						 
					
						181
						    for index in range(WEEK_COUNT):
					
						182
						        week = index + 1
					
						183
						        trend = js_round(index / (WEEK_COUNT - 1), 4)
					
						184
						        seasonality = js_round(math.sin((2 * math.pi * index) / 52), 4)
					
						185
						        holiday_season = 1 if index % 52 in [46, 47, 48, 49, 50, 51] else 0
					
						186
						        promo_flag = 1 if index % 52 in [8, 9, 21, 22, 34, 35, 47, 48] else 0
					
						187
						        price_index = js_round(
					
						188
						            1 + 0.018 * math.sin((2 * math.pi * (index + 9)) / 52) + (-0.045 if promo_flag else 0),
					
						189
						            4,
					
						190
						        )
					
						191
						        competitor_pressure = js_round(
					
						192
						            clamp(0.52 + 0.12 * math.sin((2 * math.pi * (index + 18)) / 39) + normal(0, 0.035), 0.25, 0.82),
					
						193
						            4,
					
						194
						        )
					
						195
						 
					
						196
						        spend = build_spend(index, promo_flag, seasonality)
					
						197
						        adstock = {}
					
						198
						        contribution = {}
					
						199
						 
					
						200
						        # Highlight: recursive adstock is the memory step: current spend plus decayed prior effective media.
					
						201
						        for channel, params in CHANNELS.items():
					
						202
						            adstock_state[channel] = spend[channel] + params["lambda"] * adstock_state[channel]
					
						203
						            adstock[channel] = adstock_state[channel]
					
						204
						 
					
						205
						            # Highlight: Hill saturation turns remembered media into incremental subscriptions.
					
						206
						            contribution[channel] = hill(
					
						207
						                adstock[channel],
					
						208
						                params["beta"],
					
						209
						                params["halfSaturation"],
					
						210
						                params["slope"],
					
						211
						            )
					
						212
						 
					
						213
						        base_demand = (
					
						214
						            720
					
						215
						            + 210 * trend
					
						216
						            + 90 * seasonality
					
						217
						            + 150 * holiday_season
					
						218
						            + 125 * promo_flag
					
						219
						            - 360 * (price_index - 1)
					
						220
						            - 110 * competitor_pressure
					
						221
						        )
					
						222
						        total_media_contribution = sum(contribution.values())
					
						223
						        expected_subscriptions = base_demand + total_media_contribution
					
						224
						 
					
						225
						        # Highlight: the final observed outcome adds noise, leaving truth columns for validation.
					
						226
						        observed_subscriptions = max(0, math.floor(expected_subscriptions + normal(0, 38) + 0.5))
					
						227
						 
					
						228
						        row = {
					
						229
						            "week": week,
					
						230
						            "date": iso_week_date(index),
					
						231
						            "trend": trend,
					
						232
						            "seasonality": seasonality,
					
						233
						            "holiday_season": holiday_season,
					
						234
						            "promo_flag": promo_flag,
					
						235
						            "price_index": price_index,
					
						236
						            "competitor_pressure": competitor_pressure,
					
						237
						        }
					
						238
						        row.update({f"{channel}_spend": js_round(value) for channel, value in spend.items()})
					
						239
						        row.update({f"{channel}_adstock": js_round(value) for channel, value in adstock.items()})
					
						240
						        row.update({f"{channel}_contribution": js_round(value) for channel, value in contribution.items()})
					
						241
						        row.update(
					
						242
						            {
					
						243
						                "base_demand": js_round(base_demand),
					
						244
						                "total_media_contribution": js_round(total_media_contribution),
					
						245
						                "expected_subscriptions": js_round(expected_subscriptions),
					
						246
						                "observed_subscriptions": observed_subscriptions,
					
						247
						            }
					
						248
						        )
					
						249
						        rows.append(row)
					
						250
						 
					
						251
						    return rows
					
						252
						 
					
						253
						 
					
						254
						def build_params(headers: list[str]) -> dict:
					
						255
						    return {
					
						256
						        "name": "MMM Dataset",
					
						257
						        "version": "1.0.0",
					
						258
						        "description": (
					
						259
						            "Synthetic DTC subscription dataset for MMM learning modules. The data-generating "
					
						260
						            "process is transparent so lessons can compare model estimates against known truth."
					
						261
						        ),
					
						262
						        "seed": SEED,
					
						263
						        "startDate": START_DATE.isoformat(),
					
						264
						        "weekCount": WEEK_COUNT,
					
						265
						        "outcome": "observed_subscriptions",
					
						266
						        "cadence": "weekly",
					
						267
						        "scenario": {
					
						268
						            "business": "DTC subscription brand",
					
						269
						            "unit": "new paid subscriptions",
					
						270
						            "currency": "spend columns are in thousands of dollars",
					
						271
						        },
					
						272
						        "formulas": {
					
						273
						            "adstock": "A[c,t] = spend[c,t] + lambda[c] * A[c,t-1]",
					
						274
						            "halfLife": "half_life_weeks = ln(0.5) / ln(lambda)",
					
						275
						            "saturation": (
					
						276
						                "contribution[c,t] = beta[c] * A[c,t]^slope[c] / "
					
						277
						                "(half_saturation[c]^slope[c] + A[c,t]^slope[c])"
					
						278
						            ),
					
						279
						            "outcome": "observed_subscriptions[t] = round(base_demand[t] + sum(contribution[c,t]) + Normal(0, 38))",
					
						280
						        },
					
						281
						        "channels": {
					
						282
						            channel: {
					
						283
						                **params,
					
						284
						                "halfLifeWeeks": js_round(math.log(0.5) / math.log(params["lambda"]), 2),
					
						285
						            }
					
						286
						            for channel, params in CHANNELS.items()
					
						287
						        },
					
						288
						        "observedColumns": [
					
						289
						            "week",
					
						290
						            "date",
					
						291
						            "trend",
					
						292
						            "seasonality",
					
						293
						            "holiday_season",
					
						294
						            "promo_flag",
					
						295
						            "price_index",
					
						296
						            "competitor_pressure",
					
						297
						            "paid_search_spend",
					
						298
						            "paid_social_spend",
					
						299
						            "ctv_video_spend",
					
						300
						            "podcast_audio_spend",
					
						301
						            "influencer_spend",
					
						302
						            "observed_subscriptions",
					
						303
						        ],
					
						304
						        "truthColumns": [
					
						305
						            header
					
						306
						            for header in headers
					
						307
						            if header.endswith("_adstock")
					
						308
						            or header.endswith("_contribution")
					
						309
						            or header in ["base_demand", "total_media_contribution", "expected_subscriptions"]
					
						310
						        ],
					
						311
						    }
					
						312
						 
					
						313
						 
					
						314
						def write_outputs() -> None:
					
						315
						    rows = generate_rows()
					
						316
						    headers = list(rows[0].keys())
					
						317
						    csv_lines = [",".join(headers)]
					
						318
						    csv_lines.extend(",".join(js_number(row[header]) for header in headers) for row in rows)
					
						319
						 
					
						320
						    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
					
						321
						    CSV_PATH.write_text("\n".join(csv_lines) + "\n")
					
						322
						    PARAMS_PATH.write_text(json.dumps(build_params(headers), indent=2) + "\n")
					
						323
						    print(f"Wrote {len(rows)} rows to {CSV_PATH}")
					
						324
						    print(f"Wrote parameters to {PARAMS_PATH}")
					
						325
						 
					
						326
						 
					
						327
						if __name__ == "__main__":
					
						328
						    write_outputs()
					

Columns

What learners observe versus what the synthetic generator knows.

Observed columns

  • week
  • date
  • trend
  • seasonality
  • holiday_season
  • promo_flag
  • price_index
  • competitor_pressure
  • paid_search_spend
  • paid_social_spend
  • ctv_video_spend
  • podcast_audio_spend
  • influencer_spend
  • observed_subscriptions

Truth columns

  • paid_search_adstock
  • paid_social_adstock
  • ctv_video_adstock
  • podcast_audio_adstock
  • influencer_adstock
  • paid_search_contribution
  • paid_social_contribution
  • ctv_video_contribution
  • podcast_audio_contribution
  • influencer_contribution
  • base_demand
  • total_media_contribution
  • expected_subscriptions