Modelling Event Buildup Effects: A Technical Guide¶
Target Audience: Data scientists, marketing analysts, econometricians
Last Updated: October 2025
AMMM Version: ≥2.0
Table of Contents¶
Introduction¶
What Are Event Buildup Effects?¶
Event buildup effects capture the pre-event period during which consumer behaviour changes in anticipation of a major event. For example:
Black Friday/Cyber Monday (BFCM): Consumers delay purchases in the weeks before the event, expecting discounts
Christmas: Gift-buying accelerates in the 4-6 weeks before 25 December
Back-to-School: Purchase patterns shift 2-3 weeks before school starts
These effects are distinct from the event itself and must be modelled separately to avoid biased attribution of media effectiveness.
Why Model Buildup Effects?¶
Problem: If you don’t account for buildup periods, your MMM will misattribute sales changes to media spend rather than consumer anticipation.
Example:
Week -2 before BFCM: Media spend £100K → Sales £50K (below baseline)
Week 0 (BFCM): Media spend £100K → Sales £300K (above baseline)
Without buildup variables, the model sees:
Week -2: Low ROI (£50K/£100K = 0.5×)
Week 0: High ROI (£300K/£100K = 3.0×)
The model incorrectly concludes media is more effective during BFCM, when the true driver is the event itself (discounts, consumer anticipation). This leads to:
Overestimated event-week media effectiveness
Underestimated non-event media effectiveness
Incorrect budget allocation recommendations
Statistical Framework¶
Distributed Lag Models¶
Event buildup effects are modelled using distributed lag models from econometrics (Almon, 1965; Greene, 2003).
General Form:
y_t = β₀ + β₁·x_t + β₂·event_minus_4_t + β₃·event_minus_3_t + ... + β_k·event_t + ε_t
Where:
y_t= KPI (sales, revenue, conversions) at time tx_t= Media spend and other controlsevent_minus_k_t= Binary indicator for k weeks before the eventevent_t= Binary indicator for the event week itselfβ_k= Incremental effect of being k weeks before the event
Key Property: Each time period (week) gets its own coefficient β_k, allowing the data to reveal the shape of the buildup curve.
Why Binary Dummies Are Statistically Sound¶
Common Question: “Should I use binary dummies (0/1) or continuous variables (0.25, 0.5, 0.75, 1.0)?”
Answer: Use binary dummies for exploratory analysis because:
No Functional Form Assumption
Binary dummies: Let data determine if β₁ < β₂ < β₃ or β₁ > β₂ > β₃
Continuous ramp: Assumes β₁ = 0.25·β₄, β₂ = 0.5·β₄, etc. (linear relationship)
Testable Hypotheses
You can test if buildup effect exists: H₀: β₁ = β₂ = β₃ = 0
You can test if effect is increasing: H₀: β₁ = β₂ = β₃
Standard Regression Framework
Same as any other control variable
Well-understood statistical properties
Posterior credible intervals quantify uncertainty
When to Use Continuous Variables:
You have strong prior evidence the effect is linear (e.g., from previous research)
Limited data (can’t afford k parameters)
Confirmatory analysis after discovering the pattern with dummies
Implementation Guide¶
Step 1: Data Preparation¶
Create binary dummy variables for each buildup week and the event week.
Python Example:
import pandas as pd
import numpy as np
# Load your data
data = pd.read_csv('your_data.csv')
data['date'] = pd.to_datetime(data['date'])
# Initialize all buildup dummies to 0
data['bfcm_week_minus_4'] = 0
data['bfcm_week_minus_3'] = 0
data['bfcm_week_minus_2'] = 0
data['bfcm_week_minus_1'] = 0
data['bfcm_event'] = 0
# Set to 1 for relevant weeks (example for 2024)
# BFCM 2024: Black Friday = 29 Nov, Cyber Monday = 2 Dec
# Event week (containing both): Monday 25 Nov - Sunday 1 Dec
# Using .between() for date filtering (recommended)
data.loc[data['date'].between('2024-10-28', '2024-11-03'), 'bfcm_week_minus_4'] = 1
data.loc[data['date'].between('2024-11-04', '2024-11-10'), 'bfcm_week_minus_3'] = 1
data.loc[data['date'].between('2024-11-11', '2024-11-17'), 'bfcm_week_minus_2'] = 1
data.loc[data['date'].between('2024-11-18', '2024-11-24'), 'bfcm_week_minus_1'] = 1
data.loc[data['date'].between('2024-11-25', '2024-12-01'), 'bfcm_event'] = 1
# Repeat for other years if you have multi-year data
# ...
# Save updated data
data.to_csv('your_data_with_bfcm_dummies.csv', index=False)
Critical Detail: Ensure week boundaries align with your data’s definition of “week”. If your data starts on Monday, buildup weeks should also start on Monday.
Step 2: YAML Configuration¶
Add the buildup variables to your AMMM config as control variables (extra features).
Example: config.yml
# ... existing config ...
# List control variables
extra_features_cols:
- temperature # Existing control (if any)
- bfcm_week_minus_4
- bfcm_week_minus_3
- bfcm_week_minus_2
- bfcm_week_minus_1
- bfcm_event
# Set global prior for all control variables
custom_priors:
gamma_control:
dist: TruncatedNormal
kwargs:
mu: 0.0
sigma: 0.5 # Moderately informative prior
lower: -2.0 # Allow negative (purchase delay)
upper: 2.0 # Allow positive (early buying)
Advanced: Per-Control Priors (Optional)
If you need different priors for different variables (e.g., wider prior for event), use vectorized sigma:
custom_priors:
gamma_control:
dist: TruncatedNormal
kwargs:
mu: 0.0
sigma: [0.5, 0.5, 0.5, 0.5, 0.5, 1.0] # Wider for event (last)
lower: -2.0
upper: 2.0
⚠️ Critical: The vector must match the exact order and length of extra_features_cols. Mismatches cause silent errors.
Verification:
import yaml
config = yaml.safe_load(open('config.yml'))
sigma = config['custom_priors']['gamma_control']['kwargs']['sigma']
if isinstance(sigma, list):
assert len(sigma) == len(config['extra_features_cols']), \
f"Sigma vector ({len(sigma)}) must match extra_features_cols ({len(config['extra_features_cols'])})"
Prior Selection Notes:
Sigma (σ): Controls coefficient variance. σ=0.5 is moderately informative.
Lower/Upper Bounds:
Negative lower bound allows for purchase delay (sales decrease during buildup)
Positive upper bound allows for purchase acceleration (early buying)
Use vectorized sigma for different variables
Step 3: Run the Model¶
python runme.py --data your_data_with_bfcm_dummies.csv --config config.yml
The model will estimate 5 coefficients: β₁, β₂, β₃, β₄, β₅ for the buildup weeks and event week.
Step 4: Interpret Results¶
After model fitting, examine the posterior distributions of the buildup coefficients.
Extracting Control Coefficients from Trace:
AMMM stores all control variable coefficients in a single gamma_control variable indexed by the control coordinate.
import numpy as np
import arviz as az
# Access the gamma_control posterior
coef = trace.posterior['gamma_control']
# List of your control variables (must match extra_features_cols order)
control_vars = ['temperature', 'bfcm_week_minus_4', 'bfcm_week_minus_3',
'bfcm_week_minus_2', 'bfcm_week_minus_1', 'bfcm_event']
# Extract BFCM-related coefficients
bfcm_vars = ['bfcm_week_minus_4', 'bfcm_week_minus_3',
'bfcm_week_minus_2', 'bfcm_week_minus_1', 'bfcm_event']
for var in bfcm_vars:
# Select by control coordinate
vals = coef.sel(control=var).values.flatten()
mean = np.mean(vals)
ci_low, ci_high = np.percentile(vals, [2.5, 97.5])
print(f"{var}: {mean:.3f} [{ci_low:.3f}, {ci_high:.3f}]")
Note: Control coefficients in AMMM are denoted as γ (gamma), not β (beta). β is reserved for media channel coefficients.
Example Output:
Week -4: -0.05 [-0.15, 0.05] → Slight purchase delay (not significant)
Week -3: -0.10 [-0.22, 0.02] → Modest purchase delay
Week -2: -0.20 [-0.35, -0.05] → Strong purchase delay (significant)
Week -1: -0.15 [-0.30, 0.00] → Strong purchase delay
Event: +1.50 [+1.20, +1.80] → Large positive event effect (significant)
Interpretation:
Negative coefficients in buildup weeks: Consumers delay purchases (waiting for BFCM discounts)
Positive coefficient in event week: Large sales spike during BFCM
Pattern confirms U-shaped response: dip before event, spike during event
Case Study: BFCM Buildup¶
Context¶
Black Friday/Cyber Monday (BFCM) is a multi-day shopping event:
Black Friday: Friday after US Thanksgiving (late November)
Cyber Monday: Monday following Black Friday (3 days later)
For weekly data (Monday-Sunday), these typically fall within the same week or span two weeks.
Data Structure¶
Assumption: Weekly data starting on Monday
Example for 2024:
Thanksgiving: Thursday, 28 November 2024
Black Friday: Friday, 29 November 2024
Cyber Monday: Monday, 2 December 2024
Week Definitions:
Week -4: Monday, 28 October - Sunday, 3 November
Week -3: Monday, 4 November - Sunday, 10 November
Week -2: Monday, 11 November - Sunday, 17 November
Week -1: Monday, 18 November - Sunday, 24 November
Event Week: Monday, 25 November - Sunday, 1 December (contains both Black Friday and Cyber Monday)
Implementation¶
1. Create Dummy Variables
# BFCM dates for multiple years
bfcm_events = {
2022: {'event_week': ('2022-11-21', '2022-11-27')},
2023: {'event_week': ('2023-11-20', '2023-11-26')},
2024: {'event_week': ('2024-11-25', '2024-12-01')},
}
def create_bfcm_dummies(data, bfcm_events):
"""Create BFCM buildup and event dummies for multiple years."""
# Initialize all dummies to 0
for col in ['bfcm_week_minus_4', 'bfcm_week_minus_3',
'bfcm_week_minus_2', 'bfcm_week_minus_1', 'bfcm_event']:
data[col] = 0
for year, dates in bfcm_events.items():
# Parse event week
event_start = pd.to_datetime(dates['event_week'][0])
event_end = pd.to_datetime(dates['event_week'][1])
# Calculate buildup weeks (4 weeks before event_start)
week_minus_1_start = event_start - pd.Timedelta(weeks=1)
week_minus_2_start = event_start - pd.Timedelta(weeks=2)
week_minus_3_start = event_start - pd.Timedelta(weeks=3)
week_minus_4_start = event_start - pd.Timedelta(weeks=4)
# Set dummies
data.loc[data['date'].between(week_minus_4_start, week_minus_4_start + pd.Timedelta(days=6)),
'bfcm_week_minus_4'] = 1
data.loc[data['date'].between(week_minus_3_start, week_minus_3_start + pd.Timedelta(days=6)),
'bfcm_week_minus_3'] = 1
data.loc[data['date'].between(week_minus_2_start, week_minus_2_start + pd.Timedelta(days=6)),
'bfcm_week_minus_2'] = 1
data.loc[data['date'].between(week_minus_1_start, week_minus_1_start + pd.Timedelta(days=6)),
'bfcm_week_minus_1'] = 1
data.loc[data['date'].between(event_start, event_end),
'bfcm_event'] = 1
return data
# Apply
data = create_bfcm_dummies(data, bfcm_events)
2. Verify Dummy Variables
# Check that only one dummy is active per week
dummy_cols = ['bfcm_week_minus_4', 'bfcm_week_minus_3',
'bfcm_week_minus_2', 'bfcm_week_minus_1', 'bfcm_event']
# Sum should be 0 or 1 for each row (never >1)
data['dummy_sum'] = data[dummy_cols].sum(axis=1)
assert data['dummy_sum'].max() <= 1, "Multiple dummies active in same week!"
# Print weeks with active dummies
for col in dummy_cols:
print(f"\n{col}:")
print(data[data[col] == 1][['date', col]])
3. Add to Config
Use the YAML configuration shown in Step 2.
4. Model Estimation
Run the model:
python runme.py --data data_with_bfcm.csv --config config_bfcm.yml
5. Expected Results
Based on typical BFCM patterns, expect:
Week |
Expected Effect |
Interpretation |
|---|---|---|
Week -4 |
β₁ ≈ -0.05 |
Minimal delay effect |
Week -3 |
β₂ ≈ -0.10 |
Consumers start holding off |
Week -2 |
β₃ ≈ -0.20 |
Peak purchase delay (awareness of coming deals) |
Week -1 |
β₄ ≈ -0.15 |
Continued delay, some early buying |
Event Week |
β₅ ≈ +1.5 to +2.5 |
Large positive spike from event |
Visualization:
import matplotlib.pyplot as plt
weeks = ['Week -4', 'Week -3', 'Week -2', 'Week -1', 'Event']
effects = [-0.05, -0.10, -0.20, -0.15, +1.80]
plt.figure(figsize=(10, 6))
plt.axhline(y=0, color='black', linestyle='--', alpha=0.3)
plt.plot(weeks, effects, marker='o', linewidth=2, markersize=8)
plt.title('BFCM Buildup Effect Pattern', fontsize=14)
plt.ylabel('Coefficient Estimate (β)', fontsize=12)
plt.xlabel('Time Relative to BFCM', fontsize=12)
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig('bfcm_buildup_pattern.png')
Post-Event Decay (Optional)¶
You may also want to model post-event decay (hangover effect):
# Add post-event weeks
data['bfcm_week_plus_1'] = 0
data['bfcm_week_plus_2'] = 0
# Set for weeks after BFCM
data.loc['2024-12-02':'2024-12-08', 'bfcm_week_plus_1'] = 1
data.loc['2024-12-09':'2024-12-15', 'bfcm_week_plus_2'] = 1
Expected post-event coefficients:
Week +1: β₆ ≈ -0.10 (purchases fulfilled, reduced demand)
Week +2: β₇ ≈ -0.05 (returning to baseline)
Alternative Approaches¶
1. Step Function for Hypothesis Testing (Recommended for Simplicity)¶
Goal: Test if buildup effect exists (yes/no), not estimate its shape.
Approach: Model entire buildup period as a single uniform effect.
# Create single buildup variable (all buildup weeks = 1)
data['bfcm_buildup'] = 0
data['bfcm_event'] = 0
# Buildup period: 4 weeks before event (all weeks get same value)
data.loc['2024-10-28':'2024-11-24', 'bfcm_buildup'] = 1 # Weeks -4 to -1
data.loc['2024-11-25':'2024-12-01', 'bfcm_event'] = 1 # Event week
YAML Configuration:
extra_features_cols:
- bfcm_buildup # Single variable covering all buildup weeks
- bfcm_event
custom_priors:
gamma_control:
dist: TruncatedNormal
kwargs:
mu: 0.0
sigma: [0.5, 1.0] # Wider sigma for event (second in list)
lower: -2.0
upper: 2.0
Statistical Framework:
This is a restricted distributed lag model where all buildup weeks share the same coefficient:
y_t = β₀ + β₁·media_t + β_buildup·buildup_t + β_event·event_t + ε_t
where: buildup_t = 1 if week ∈ {-4, -3, -2, -1}, else 0
Hypothesis Test:
H₀: β_buildup = 0 (no buildup effect)
H₁: β_buildup ≠ 0 (buildup effect exists)
Interpretation:
β_buildup: Average effect across all buildup weeks
If credible interval excludes 0 → buildup effect confirmed
Magnitude = average weekly impact during buildup period
Example Results:
β_buildup: -0.15 [-0.25, -0.05] → Significant negative effect (purchase delay)
β_event: +1.80 [+1.50, +2.10] → Large positive event effect
Interpretation: On average, sales are 15% lower during the 4-week buildup period (consumers delay purchases), then spike 180% during the event week.
Pros:
Parsimonious: Only 2 parameters (buildup + event)
Statistical power: More power to detect effect existence (pooling data)
Clear hypothesis test: Yes/no answer to “does buildup exist?”
Simple interpretation: Single number for buildup magnitude
Efficient: Works well with limited data
Cons:
Assumes uniform effect: Cannot detect if delay accelerates near event
Averages over pattern: May underestimate peak delay (e.g., if week -1 has stronger effect)
Shape unknown: Cannot tell if β₁ = β₂ = β₃ = β₄ or β₁ < β₂ < β₃ < β₄
When to Use:
✅ Primary goal: Test if buildup effect exists (hypothesis testing)
✅ Quantify average buildup magnitude (not week-by-week pattern)
✅ Limited data (cannot afford 4+ parameters)
✅ Simplicity valued over granularity
✅ Hypothesis-driven rather than exploratory analysis
When NOT to Use:
❌ Need to estimate buildup shape (use multiple dummies instead)
❌ Want to detect acceleration pattern (e.g., week -1 > week -4)
❌ Sufficient data to estimate multiple coefficients
Validation:
This approach is statistically sound and aligned with econometric practice (Greene, 2003):
It’s a restricted distributed lag model with equality constraint: β₁ = β₂ = β₃ = β₄ = β
The restriction is testable via model comparison (ELPD)
If assumption holds (uniform effect), it’s more efficient than unrestricted model
If assumption fails, unrestricted model (multiple dummies) will fit better
Comparison to Multiple Dummies:
Aspect |
Step Function |
Multiple Dummies |
|---|---|---|
Parameters |
1 (β_buildup) |
4 (β₁, β₂, β₃, β₄) |
Degrees of Freedom |
Low (1 DOF) |
High (4 DOF) |
Statistical Power |
High (pooled data) |
Lower (split data) |
Assumption |
Uniform effect |
No assumption |
Use Case |
Hypothesis testing |
Pattern discovery |
Interpretation |
Average effect |
Week-by-week effect |
2. Single Event Dummy (Baseline)¶
Simplest approach: Only model the event week itself, ignore buildup.
control:
- col: bfcm_event # Only 1 dummy
prior:
dist: TruncatedNormal
kwargs: {mu: 0.0, sigma: 1.0, lower: -2.0, upper: 5.0}
Pros:
Simple (1 parameter)
Easy to interpret
Cons:
Misses purchase delay effect in buildup weeks
Biased estimates if buildup effect exists
When to use: As a baseline for model comparison.
3. Continuous Ramp Function¶
Approach: Use a predetermined functional form.
Linear Ramp:
# Create single variable with linear ramp
data['bfcm_buildup'] = 0
data.loc['2024-10-28':'2024-11-03', 'bfcm_buildup'] = -0.20 # Week -4
data.loc['2024-11-04':'2024-11-10', 'bfcm_buildup'] = -0.40 # Week -3
data.loc['2024-11-11':'2024-11-17', 'bfcm_buildup'] = -0.60 # Week -2
data.loc['2024-11-18':'2024-11-24', 'bfcm_buildup'] = -0.80 # Week -1
data.loc['2024-11-25':'2024-12-01', 'bfcm_buildup'] = +1.00 # Event
control:
- col: bfcm_buildup # Single parameter
prior:
dist: TruncatedNormal
kwargs: {mu: 0.0, sigma: 1.0, lower: -2.0, upper: 5.0}
Pros:
Parsimonious (1 parameter)
Smooth curve
Cons:
Strong assumption about functional form
Cannot test if linear relationship holds
When to use:
Confirmatory analysis after discovering linear pattern with dummies
Limited data (cannot afford multiple parameters)
4. Hierarchical Prior for Smoothness (Advanced)¶
Approach: Encourage smooth buildup pattern without imposing rigid functional form.
# In PyMC model (pseudo-code)
with pm.Model() as model:
# Global BFCM effect
beta_bfcm_global = pm.Normal('beta_bfcm_global', mu=0, sigma=1)
# Smooth deviations from global effect
beta_bfcm_deviations = pm.GaussianRandomWalk(
'beta_bfcm_deviations',
mu=0,
sigma=0.1, # Small sigma enforces smoothness
shape=5 # 5 time periods
)
# Final coefficients
beta_bfcm_final = beta_bfcm_global + beta_bfcm_deviations
Pros:
Flexible yet regularized
Data can override smoothness prior if evidence is strong
Cons:
Requires custom PyMC code (not supported in AMMM YAML config)
More complex to implement and interpret
When to use: Advanced Bayesian modeling for complex event patterns.
Bayesian Perspective: Purist vs Pragmatic¶
The Bayesian Purist’s View¶
A Bayesian purist would argue that the step function approach (single buildup variable) imposes a hard equality constraint (β₁ = β₂ = β₃ = β₄ = β), which is equivalent to a point mass prior with infinite certainty. This violates the core Bayesian principle of encoding uncertainty about everything.
The Critique:
“The step function says there’s zero probability that week -1 differs from week -4. If you truly believe all weeks have equal effect, you should encode this as a hierarchical prior with soft constraints, not a deterministic hard constraint. Your approach is frequentist hypothesis testing masquerading as Bayesian inference.”
Mathematical interpretation:
Step function constraint: β₁ = β₂ = β₃ = β₄ = β
Bayesian translation:
P(β₁ = β₂ = β₃ = β₄) = 1.0 (infinite certainty)
P(β₁ ≠ β₂) = 0.0 (impossible by construction)
The Pragmatic Bayesian’s Defence¶
However, a pragmatic Bayesian (in the tradition of Andrew Gelman) would counter:
“The step function is acceptable if it genuinely represents your prior belief. If you have domain knowledge that all buildup weeks should have similar effects, encoding this as a constraint is informative prior specification, not a violation of Bayesian principles. The key question is: Is this your actual prior belief, or just a computational shortcut?”
When the step function is Bayesian enough:
✅ If you genuinely believe (based on domain knowledge):
All buildup weeks have similar psychological effects on consumers
Consumer behaviour doesn’t change substantially week-to-week during buildup
The distinction between week -4 and week -3 is measurement noise, not meaningful signal
Then encoding β₁ = β₂ = β₃ = β₄ is valid prior specification.
✅ Model comparison via ELPD is fundamentally Bayesian:
Compare step function vs unrestricted model
Let out-of-sample predictive performance decide
This is Bayesian model selection, not frequentist hypothesis testing
Making Your Approach More Bayesian¶
Option 1: Hierarchical Prior (Most Bayesian)¶
Replace hard constraint with soft prior:
# PyMC pseudo-code
with pm.Model() as model:
# Shared mean for all buildup weeks
μ_buildup = pm.Normal('mu_buildup', mu=0, sigma=1)
# How much can individual weeks deviate?
σ_weeks = pm.HalfNormal('sigma_weeks', sigma=0.1) # Small = similar
# Individual week effects (pooled towards μ_buildup)
β_weeks = pm.Normal('beta_weeks', mu=μ_buildup, sigma=σ_weeks, shape=4)
# β_weeks[0] = week -4, β_weeks[1] = week -3, etc.
Bayesian interpretation:
Prior: Weeks are similar (σ_weeks small) but can differ
Posterior: If data shows divergence, it will override the prior
Uncertainty: Full posterior distribution for each β_k
Partial pooling: Weeks borrow strength from each other
Key difference from step function:
Step function: β₁ = β₂ = β₃ = β₄ (hard constraint, 0% chance of difference)
Hierarchical prior: β₁ ≈ β₂ ≈ β₃ ≈ β₄ (soft constraint, small but non-zero chance of difference)
Option 2: Informative Prior on Single Parameter¶
If you stick with the step function, make the prior informative:
control:
- col: bfcm_buildup
prior:
dist: Normal # Not TruncatedNormal
kwargs:
mu: -0.15 # Domain knowledge: expect ~15% delay
sigma: 0.10 # Moderate uncertainty (allow data to override)
This encodes:
Prior belief: β_buildup ≈ -0.15 (based on previous research/industry knowledge)
Uncertainty: Could reasonably be anywhere from -0.35 to +0.05 (95% prior interval)
Bayesian updating: Data shifts posterior away from prior if evidence is strong
Contrast with weakly informative prior:
# Current approach (weakly informative)
control:
- col: bfcm_buildup
prior:
dist: TruncatedNormal
kwargs:
mu: 0.0 # No prior belief (neutral)
sigma: 0.5 # Wide uncertainty
lower: -2.0
upper: 2.0
This is maximally agnostic - doesn’t encode domain knowledge, so it’s less Bayesian in spirit.
Option 3: Gaussian Random Walk (Advanced)¶
For smooth but flexible buildup patterns:
# PyMC code
with pm.Model() as model:
# Smooth evolution of buildup effect
β_buildup = pm.GaussianRandomWalk(
'beta_buildup',
mu=0,
sigma=0.05, # Small sigma = smooth transitions
shape=4 # 4 buildup weeks
)
Properties:
Smoothness: Adjacent weeks are constrained to be similar (β_k ≈ β_k+1)
Flexibility: Can still capture acceleration patterns if data demands
Bayesian: Encodes belief in smooth temporal evolution without hard constraints
The Bayesian Hierarchy of Virtue¶
From most Bayesian to least Bayesian:
Gaussian Random Walk (Most Bayesian)
Smooth temporal evolution
Flexible (data can override)
Full posterior uncertainty
Implementation: Requires custom PyMC code
Hierarchical Prior with Partial Pooling
Soft constraint on similarity
Learns shared mean from data
Weeks can differ if evidence is strong
Implementation: Requires custom PyMC code
Informative Prior on Single Parameter (Pragmatically Bayesian)
Encodes domain knowledge
Simple and interpretable
Appropriate for hypothesis testing
Implementation: Standard AMMM YAML config
Weakly Informative Prior (Frequentist-flavored Bayesian)
Hard constraint (step function)
Vague prior (mu=0, wide sigma)
Resembles frequentist null hypothesis
Implementation: Standard AMMM YAML config
Practical Recommendation¶
For hypothesis testing use cases, the pragmatic Bayesian compromise:
Use step function for simplicity (as validated in Section 5.1)
Make the prior informative using domain knowledge:
control:
- col: bfcm_buildup
prior:
dist: Normal
kwargs:
mu: -0.10 # Expect negative (purchase delay)
sigma: 0.15 # Moderate uncertainty
- col: bfcm_event
prior:
dist: Normal
kwargs:
mu: +1.00 # Expect positive (event spike)
sigma: 0.50 # Allow larger effects
Report full posterior, not just significance:
Example results:
β_buildup: -0.15 (95% credible interval: [-0.25, -0.05])
P(β_buildup < 0 | data) = 99.9%
Interpretation:
- 99.9% probability that buildup causes purchase delay
- Expected magnitude: 15% decrease in sales during buildup
- Could be as low as 5% or as high as 25% (95% credible)
This is Bayesian enough for most applied work while maintaining analytical simplicity.
Frequentist vs Bayesian Mindset¶
Hypothesis testing mindset (frequentist):
Goal: Test H₀: β = 0 vs H₁: β ≠ 0
Result: Reject H₀ at p < 0.05
Interpretation: Buildup effect is "statistically significant"
Bayesian mindset:
Goal: Estimate P(β | data) - full posterior distribution
Result: β ~ Normal(-0.15, 0.05²)
Interpretation:
- Best estimate: -15% effect
- Uncertainty: ±5% (standard error)
- Probability of delay: P(β < 0) = 99.9%
- Probability of large delay: P(β < -0.10) = 84%
Key differences:
Frequentist: Binary decision (reject/fail to reject)
Bayesian: Probabilistic statements about parameters
Frequentist: p-value (probability of data given H₀)
Bayesian: Posterior probability (probability of hypothesis given data)
Summary: Which Approach to Choose?¶
Your Goal |
Recommended Approach |
Bayesian Purity |
|---|---|---|
Hypothesis testing (does effect exist?) |
Step function + informative prior |
Pragmatically Bayesian ✓ |
Pattern discovery (what is the shape?) |
Multiple dummies (unrestricted) |
Moderately Bayesian ✓✓ |
Smooth estimation with flexibility |
Hierarchical prior or random walk |
Purely Bayesian ✓✓✓ |
Maximum simplicity |
Step function + weakly informative prior |
Frequentist-flavored |
Bottom line: All approaches are valid. The choice depends on:
Your research question (hypothesis testing vs pattern discovery)
Available domain knowledge (strong prior beliefs vs agnostic)
Computational constraints (simple YAML config vs custom PyMC code)
Philosophical preference (pragmatic vs purist Bayesian)
Model Diagnostics¶
1. Posterior Predictive Checks¶
Goal: Verify the model accurately captures the buildup pattern in actual data.
import arviz as az
# Generate posterior predictive samples
with model:
ppc = pm.sample_posterior_predictive(trace)
# Plot observed vs predicted
az.plot_ppc(az.from_pymc3(posterior_predictive=ppc, model=model))
What to look for:
Observed data (blue) should fall within posterior predictive distribution (orange)
Check buildup weeks specifically - does model capture the dip?
2. ELPD Comparison¶
Goal: Compare models with/without buildup variables.
AMMM automatically computes Expected Log Pointwise Predictive Density (ELPD) in results/other/ELPD.txt.
Procedure:
Run baseline model (no buildup variables)
Run model with buildup variables
Compare ELPD scores
# Baseline
python runme.py --config config_no_buildup.yml
# ELPD: -1250.4
# With buildup
python runme.py --config config_with_buildup.yml
# ELPD: -1205.8
# Difference: 44.6 (buildup model is better)
Rule of Thumb:
ΔELPD > 10: Strong evidence for buildup model
ΔELPD > 4: Moderate evidence
ΔELPD < 4: Weak evidence
3. Multicollinearity (VIF) Checks¶
Goal: Ensure buildup dummies don’t create collinearity issues.
AMMM computes Variance Inflation Factor (VIF) in pre-diagnostics:
python runme.py --config config_with_buildup.yml
# Check results/csv/vif_summary.csv
What to check:
variable,VIF
media_google,2.1
media_facebook,1.8
bfcm_week_minus_4,1.5
bfcm_week_minus_3,1.6
bfcm_week_minus_2,1.7
bfcm_week_minus_1,1.8
bfcm_event,2.3
Rule of Thumb:
VIF < 5: No collinearity concern
VIF 5-10: Moderate collinearity (monitor)
VIF > 10: High collinearity (problematic)
Common Issue: If you have multiple overlapping events (e.g., BFCM + Christmas within 4 weeks), VIF may be high.
Solution:
Aggregate overlapping events into single periods
Use hierarchical priors to pool similar events
4. Coefficient Stability¶
Goal: Verify buildup coefficients are stable across model specifications.
Procedure:
Add buildup variables one at a time
Check if coefficients change drastically
# Model A: Only week -1 and event
# β_week_minus_1 = -0.18, β_event = +1.75
# Model B: All buildup weeks
# β_week_minus_1 = -0.15, β_event = +1.80
# Stable? Yes (coefficients changed <20%)
Unstable coefficients suggest collinearity or overfitting.
Common Pitfalls¶
1. Overfitting with Too Many Event Variables¶
Problem: If you have 52 weeks of data and 20 event dummies, you’re fitting too many parameters.
Symptom:
Wide credible intervals
Unstable coefficients
Poor out-of-sample predictive performance
Solution:
Group similar events (e.g., all “seasonal sales” into one category)
Use hierarchical priors to pool events
Increase data collection (more years)
2. Confounding with Media Spend Patterns¶
Problem: If you always increase Google Ads during BFCM buildup, the model can’t separate Google effect from buildup effect.
Symptom:
Buildup coefficient is positive (opposite of expected)
Media coefficient for Google is lower than other models
Solution:
Check correlation between buildup dummies and media spend
If correlation > 0.7, consider interaction terms or reduce media spend variation during events
Diagnostic:
# Check correlation
corr_matrix = data[['bfcm_week_minus_1', 'media_google']].corr()
print(corr_matrix)
# If corr > 0.7:
print("WARNING: High correlation between buildup and Google spend")
3. Prophet vs PyMC Control Variables¶
Problem: Buildup dummies in PyMC config do not automatically transfer to Prophet (used for multi-period optimization forecasting).
Current State of Prophet Integration:
AMMM uses Prophet for seasonality decomposition and forecasting, but custom event handling is limited:
For model fitting (PyMC):
Use
extra_features_colsin YAML config (as shown in this guide)Event variables (BFCM buildup, etc.) are modeled as PyMC control variables
For forecasting/optimization (Prophet):
Prophet uses built-in country holidays via config:
prophet: include_holidays: true holiday_country: "UK" # or your country
Custom holidays from
holidays.xlsxare currently not passed to ProphetThe file is read by AMMM but not used to add custom events to Prophet forecasts
Impact:
When using multi-period optimization (--multiperiod):
Prophet forecasts will not account for your custom BFCM buildup effects
Only country holidays (e.g., UK Bank Holidays) are included in forecasts
Event buildup patterns learned in PyMC are not propagated to Prophet
Workaround:
Model custom events (like BFCM) only as PyMC control variables for now:
This captures the effect in model fitting and attribution
For multi-period optimization, be aware that Prophet forecasts won’t include these patterns
Consider this when interpreting optimization results for periods with known events
Future Enhancement:
If custom Prophet holiday support is needed, the code in src/prepro/seas.py::prophet_decomp() would need to be extended to:
Accept the
holidays.xlsxDataFramePass it to Prophet via
Prophet(holidays=dt_holidays)Support
lower_window/upper_windowfor event buildup patterns
Recommendation:
For most use cases, the current approach is sufficient:
PyMC captures event effects in historical data (what happened)
Prophet captures seasonality and trend (business-as-usual patterns)
If you need event-aware optimization, consider scenario-based planning instead
4. Forgetting to Update for New Years¶
Problem: Hard-coded dates in your data preparation script become outdated.
Solution: Create a centralized events configuration file.
Example: events_calendar.json
{
"BFCM": {
"2022": {"event_week": ["2022-11-21", "2022-11-27"]},
"2023": {"event_week": ["2023-11-20", "2023-11-26"]},
"2024": {"event_week": ["2024-11-25", "2024-12-01"]},
"2025": {"event_week": ["2025-11-24", "2025-11-30"]}
},
"Christmas": {
"2022": {"event_week": ["2022-12-19", "2022-12-25"]},
"2023": {"event_week": ["2023-12-18", "2023-12-24"]},
"2024": {"event_week": ["2024-12-23", "2024-12-29"]}
}
}
Load and apply programmatically:
import json
with open('events_calendar.json', 'r') as f:
events = json.load(f)
data = create_bfcm_dummies(data, events['BFCM'])
data = create_christmas_dummies(data, events['Christmas'])
5. Mixing Weekly and Daily Granularity¶
Problem: Your data is weekly but BFCM spans Friday-Monday (4 days).
Solution:
Option A: Keep weekly data, treat entire week as “event week” (recommended for simplicity)
Option B: Switch to daily data for finer granularity (but increases model complexity)
For most use cases, weekly granularity is sufficient since:
MMM is about medium-term trends (weeks/months), not daily spikes
Adstock effects smooth out daily fluctuations
Weekly data has better signal-to-noise ratio
References¶
Academic Literature¶
Almon, S. (1965). “The Distributed Lag Between Capital Appropriations and Expenditures.” Econometrica, 33(1), 178-196.
Foundational paper on distributed lag models
Greene, W. H. (2003). Econometric Analysis (5th ed.). Prentice Hall.
Chapter 17: Models with Lagged Variables
Jin, Y., Wang, Y., Sun, Y., Chan, D., & Koehler, J. (2017). “Bayesian Methods for Media Mix Modeling with Carryover and Shape Effects.” Google Research.
Modern MMM with event effects
Box, G. E. P., Jenkins, G. M., Reinsel, G. C., & Ljung, G. M. (2015). Time Series Analysis: Forecasting and Control (5th ed.). Wiley.
Chapter on intervention analysis (related to event modeling)
AMMM Documentation¶
User Guide: Control Variables (see
documentation/USER_GUIDE.md)Configuration Schema: Control Section (see
documentation/SCHEMA.md)Multi-Period Optimization Guide (coming soon)
Prophet Integration (see project documentation)
Online Resources¶
PyMC Documentation: Distributed Lag Models
Facebook Prophet: Holiday Effects
Cross Validated: Event Study Methodology
Appendix: Full Working Example¶
Complete Python Script¶
#!/usr/bin/env python3
"""
Create BFCM buildup dummies for AMMM input data.
Usage:
python create_bfcm_dummies.py --input raw_data.csv --output data_with_bfcm.csv
"""
import pandas as pd
import numpy as np
import argparse
from datetime import timedelta
# BFCM event dates (Monday of event week)
BFCM_EVENTS = {
2022: pd.Timestamp('2022-11-21'),
2023: pd.Timestamp('2023-11-20'),
2024: pd.Timestamp('2024-11-25'),
2025: pd.Timestamp('2025-11-24'),
}
def create_bfcm_dummies(data: pd.DataFrame, date_col: str = 'date') -> pd.DataFrame:
"""
Create BFCM buildup and event dummy variables.
Args:
data: DataFrame with date column
date_col: Name of date column
Returns:
DataFrame with added dummy columns
"""
# Ensure date column is datetime
data[date_col] = pd.to_datetime(data[date_col])
# Initialize dummy columns
dummy_cols = [
'bfcm_week_minus_4',
'bfcm_week_minus_3',
'bfcm_week_minus_2',
'bfcm_week_minus_1',
'bfcm_event',
]
for col in dummy_cols:
data[col] = 0
# Create dummies for each year
for year, event_start in BFCM_EVENTS.items():
# Calculate week boundaries (7-day periods starting on Monday)
weeks = {
'bfcm_week_minus_4': (event_start - timedelta(weeks=4), event_start - timedelta(weeks=4) + timedelta(days=6)),
'bfcm_week_minus_3': (event_start - timedelta(weeks=3), event_start - timedelta(weeks=3) + timedelta(days=6)),
'bfcm_week_minus_2': (event_start - timedelta(weeks=2), event_start - timedelta(weeks=2) + timedelta(days=6)),
'bfcm_week_minus_1': (event_start - timedelta(weeks=1), event_start - timedelta(weeks=1) + timedelta(days=6)),
'bfcm_event': (event_start, event_start + timedelta(days=6)),
}
# Set dummies
for dummy_col, (start, end) in weeks.items():
mask = data[date_col].between(start, end)
data.loc[mask, dummy_col] = 1
if mask.sum() > 0:
print(f"{year} {dummy_col}: {start.date()} to {end.date()} ({mask.sum()} rows)")
# Validation: Ensure no overlaps
dummy_sum = data[dummy_cols].sum(axis=1)
if (dummy_sum > 1).any():
print("WARNING: Multiple dummies active in same row!")
print(data[dummy_sum > 1][['date'] + dummy_cols])
return data
def main():
parser = argparse.ArgumentParser(description='Create BFCM buildup dummies')
parser.add_argument('--input', required=True, help='Input CSV file')
parser.add_argument('--output', required=True, help='Output CSV file')
parser.add_argument('--date-col', default='date', help='Name of date column')
args = parser.parse_args()
# Load data
print(f"Loading data from {args.input}...")
data = pd.read_csv(args.input)
print(f"Loaded {len(data)} rows")
# Create dummies
print("\nCreating BFCM buildup dummies...")
data = create_bfcm_dummies(data, date_col=args.date_col)
# Save
print(f"\nSaving to {args.output}...")
data.to_csv(args.output, index=False)
print("Done!")
# Summary
dummy_cols = ['bfcm_week_minus_4', 'bfcm_week_minus_3', 'bfcm_week_minus_2',
'bfcm_week_minus_1', 'bfcm_event']
print("\nSummary:")
for col in dummy_cols:
n_active = (data[col] == 1).sum()
print(f" {col}: {n_active} weeks")
if __name__ == '__main__':
main()
Complete YAML Config¶
# AMMM Configuration with BFCM Buildup Effects
# Save as: config_bfcm.yml
# Data configuration
date_col: date
kpi_col: revenue
raw_data_granularity: weekly
# Media channels (simple list - priors set globally below)
media:
- spend_col: google_spend
- spend_col: facebook_spend
# Control variables (BFCM buildup + any baseline controls)
extra_features_cols:
- temperature # Example baseline control
- bfcm_week_minus_4
- bfcm_week_minus_3
- bfcm_week_minus_2
- bfcm_week_minus_1
- bfcm_event
# Global priors for all model parameters
custom_priors:
# Media channel coefficients
beta_channel:
dist: Normal
kwargs:
mu: 0.0
sigma: 2.0
# Adstock decay parameter
alpha:
dist: Beta
kwargs:
alpha: 2
beta: 2
# Saturation lambda parameter
lam:
dist: Gamma
kwargs:
alpha: 2
beta: 0.0001
# Control variable coefficients (uniform for all, or vectorized)
gamma_control:
dist: TruncatedNormal
kwargs:
mu: 0.0
# Option 1: Uniform prior for all controls
sigma: 0.5
# Option 2: Vectorized sigma (must match extra_features_cols order)
# sigma: [0.5, 0.5, 0.5, 0.5, 0.5, 1.0] # Wider for event
lower: -2.0
upper: 2.0
# Sampling configuration
draws: 2000
tune: 1000
chains: 4
target_accept: 0.95
Key Changes from Old Syntax:
Media: Simple list of
spend_colonly (no per-channel priors)Controls: Use
extra_features_colslist (notcontrol:blocks)Priors: All priors in
custom_priorssectionSampler: Use
draws,tune,chains(notmcmc_*)
End of Guide
For questions or issues, consult the AMMM GitHub repository or contact the development team.