Adstock
Media memory carries a share of prior effective media into the current week.
MMM Dataset
Synthetic DTC subscription dataset for MMM learning modules. The data-generating process is transparent so lessons can compare model estimates against known truth.
Scenario
Business
Outcome: observed_subscriptions
Unit
spend columns are in thousands of dollars
Start date
156 weekly observations
Channel truth
| 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
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
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
Observed columns
Truth columns