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

  1. Introduction

  2. Statistical Framework

  3. Implementation Guide

  4. Case Study: BFCM Buildup

  5. Alternative Approaches

  6. Model Diagnostics

  7. Common Pitfalls

  8. References


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

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

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

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:

  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

Practical Recommendation

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.

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:

  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

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:

  1. Run baseline model (no buildup variables)

  2. Run model with buildup variables

  3. 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:

  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

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_cols in 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.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 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:

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

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


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:

  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.