Skip to content

Modelling Event Buildup Effects: A Technical Guide

Target Audience: Data scientists, marketing analysts, econometricians Last Updated: October 2025 AMMM Version: ≥2.0


  1. {ref}Introduction <introduction>
  2. {ref}Statistical Framework <statistical-framework>
  3. {ref}Implementation Guide <implementation-guide>
  4. {ref}Case Study: BFCM Buildup <case-study-bfcm-buildup>
  5. {ref}Alternative Approaches <alternative-approaches>
  6. {ref}Model Diagnostics <model-diagnostics>
  7. {ref}Common Pitfalls <common-pitfalls>
  8. {ref}References <references>

(introduction)=

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.

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)=

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 t
  • x_t = Media spend and other controls
  • event_minus_k_t = Binary indicator for k weeks before the event
  • event_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

Section titled “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:

  1. No Functional Form Assumption

    • Binary dummies: Let data determine if β₁ < β₂ < β₃ or β₁ > β₂ > β₃
    • Continuous ramp: Assumes β₁ = 0.25·β₄, β₂ = 0.5·β₄, etc. (linear relationship)
  2. Testable Hypotheses

    • You can test if buildup effect exists: H₀: β₁ = β₂ = β₃ = 0
    • You can test if effect is increasing: H₀: β₁ = β₂ = β₃
  3. 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)=

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
Terminal window
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.

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)=

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.

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)

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 {ref}Step 2 <step-2-yaml-configuration>.

4. Model Estimation

Run the model:

Terminal window
python runme.py --data data_with_bfcm.csv --config config_bfcm.yml

5. Expected Results

Based on typical BFCM patterns, expect:

WeekExpected EffectInterpretation
Week -4β₁ ≈ -0.05Minimal delay effect
Week -3β₂ ≈ -0.10Consumers start holding off
Week -2β₃ ≈ -0.20Peak purchase delay (awareness of coming deals)
Week -1β₄ ≈ -0.15Continued delay, some early buying
Event Weekβ₅ ≈ +1.5 to +2.5Large 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')

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)=

Section titled “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:

AspectStep FunctionMultiple Dummies
Parameters1 (β_buildup)4 (β₁, β₂, β₃, β₄)
Degrees of FreedomLow (1 DOF)High (4 DOF)
Statistical PowerHigh (pooled data)Lower (split data)
AssumptionUniform effectNo assumption
Use CaseHypothesis testingPattern discovery
InterpretationAverage effectWeek-by-week effect

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.

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)

Section titled “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 modelling for complex event patterns.


(bayesian-perspective-purist-vs-pragmatic)=

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)

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

Option 1: Hierarchical Prior (Most Bayesian)

Section titled “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

Section titled “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.

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

From most Bayesian to least Bayesian:

  1. Gaussian Random Walk (Most Bayesian)

    • Smooth temporal evolution
    • Flexible (data can override)
    • Full posterior uncertainty
    • Implementation: Requires custom PyMC code
  2. 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
  3. Informative Prior on Single Parameter (Pragmatically Bayesian)

    • Encodes domain knowledge
    • Simple and interpretable
    • Appropriate for hypothesis testing
    • Implementation: Standard AMMM YAML config
  4. 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

For hypothesis testing use cases, the pragmatic Bayesian compromise:

  1. Use step function for simplicity (as validated in Section 5.1)
  2. 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
  1. 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.

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)
Your GoalRecommended ApproachBayesian Purity
Hypothesis testing (does effect exist?)Step function + informative priorPragmatically Bayesian ✓
Pattern discovery (what is the shape?)Multiple dummies (unrestricted)Moderately Bayesian ✓✓
Smooth estimation with flexibilityHierarchical prior or random walkPurely Bayesian ✓✓✓
Maximum simplicityStep function + weakly informative priorFrequentist-flavored

Bottom line: All approaches are valid. The choice depends on:

  1. Your research question (hypothesis testing vs pattern discovery)
  2. Available domain knowledge (strong prior beliefs vs agnostic)
  3. Computational constraints (simple YAML config vs custom PyMC code)
  4. Philosophical preference (pragmatic vs purist Bayesian)

(model-diagnostics)=

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?

Goal: Compare models with/without buildup variables.

AMMM computes Expected Log Pointwise Predictive Density (ELPD) and writes artefacts under 50_diagnostics/ (for example ELPD.txt and ELPD_summary.csv).

Procedure:

  1. Run baseline model (no buildup variables)
  2. Run model with buildup variables
  3. Compare ELPD scores
Terminal window
# 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

Goal: Ensure buildup dummies don’t create collinearity issues.

AMMM computes Variance Inflation Factor (VIF) in pre-diagnostics:

Terminal window
python runme.py --config config_with_buildup.yml
# Check results/10_pre_diagnostics/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

Goal: Verify buildup coefficients are stable across model specifications.

Procedure:

  1. Add buildup variables one at a time
  2. 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

Section titled “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)

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")

Problem: Buildup dummies in PyMC config do not automatically transfer to Prophet (used for multi-period optimisation 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_cols in YAML config (as shown in this guide)
  • Event variables (BFCM buildup, etc.) are modeled as PyMC control variables

For forecasting/optimisation (Prophet):

  • Prophet uses built-in country holidays via config:
    prophet:
    include_holidays: true
    holiday_country: "UK" # or your country
  • Custom holidays from holidays.xlsx are currently not passed to Prophet
  • The file is read by AMMM but not used to add custom events to Prophet forecasts

Impact:

When using multi-period optimisation (--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 optimisation, be aware that Prophet forecasts will not include these patterns
  • Consider this when interpreting optimisation 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:

  1. Accept the holidays.xlsx DataFrame
  2. Pass it to Prophet via Prophet(holidays=dt_holidays)
  3. Support lower_window/upper_window for 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 optimisation, consider scenario-based planning instead

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'])

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)=

  1. Almon, S. (1965). “The Distributed Lag Between Capital Appropriations and Expenditures.” Econometrica, 33(1), 178-196.

    • Foundational paper on distributed lag models
  2. Greene, W. H. (2003). Econometric Analysis (5th ed.). Prentice Hall.

    • Chapter 17: Models with Lagged Variables
  3. 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
  4. 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 modelling)

#!/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()
config_bfcm.yml
# AMMM Configuration with BFCM Buildup Effects
# 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:

  1. Media: Simple list of spend_col only (no per-channel priors)
  2. Controls: Use extra_features_cols list (not control: blocks)
  3. Priors: All priors in custom_priors section
  4. Sampler: Use draws, tune, chains (not mcmc_*)

End of Guide

For questions or issues, consult the AMMM GitHub repository or contact the development team.