Module 5 · Days 41–50 · Part II

Quasi-Experiments: DiD, Synthetic Control, RDD

Evaluating rollouts and campaigns when you can't randomize.

Start Day 41
0/10 days completed
0/10 videos published 10/10 lessons written

What you'll learn

  1. 1
    Days 41–44
    Difference-in-differences, event studies and modern staggered estimators.

    By the end: Students can estimate a difference-in-differences with fixed-effects regression and correctly clustered standard errors, read an event study and know what a passed pre-trend test does and doesn't prove, explain why two-way fixed effects fails with staggered rollouts, and estimate staggered effects with Callaway–Sant'Anna and imputation estimators.

  2. 2
    Days 45–47
    Synthetic control, synthetic DiD and Bayesian structural time series.

    By the end: Students can build a synthetic control and judge it by its pre-period fit and robustness to dropped donors, explain when synthetic control fails and how an intercept or synthetic DiD fixes it, and estimate a campaign's lift with a Bayesian time-series model in CausalPy, turning the posterior into a probability that the campaign paid off.

  3. 3
    Days 48–50
    Regression discontinuity and placebo tests.

    By the end: Students can estimate sharp and fuzzy regression discontinuities with local linear regression and rdrobust, run the falsification checks every quasi-experiment needs (placebo dates, placebo units, placebo cutoffs, covariate continuity, density tests), and evaluate a real campaign with several designs, reaching a decision with money and uncertainty attached.

Tools

pyfixestCausalPyPyMCrdrobustrddensity

Mini-capstone

Regional campaign ROI: DiD, synthetic control, synthetic DiD and a Bayesian model, stress-tested with placebos and turned into a profit decision.

The ten days

Before you start: module setup and project structure

Course: Applied Causal Inference & Decision Science Audience: Economists and analysts who completed Modules 1–4 Environment: GitHub Codespaces (VS Code in the browser) Time per day: ~5 hours (including breaks and exercises) Goal by end of Day 44: Students can estimate a difference-in-differences with fixed-effects regression and correctly clustered standard errors, read an event study and know what a passed pre-trend test does and doesn't prove, explain why two-way fixed effects fails with staggered rollouts, and estimate staggered effects with Callaway–Sant'Anna and imputation estimators.


Where we are

Module 4 needed either every confounder measured, or a lucky random nudge. Module 5 uses a third kind of leverage: structure in time and in rules.

DaysDesignLeverageKey assumption
41–44Difference-in-differencesa group that wasn't treated, followed over the same weeksparallel trends
45–47Synthetic control and Bayesian time seriesa weighted mix of untreated units that tracked the treated onethe mix keeps tracking it
48Regression discontinuitya rule with a cutoffnothing else jumps at the cutoff
49–50Placebo tests, then the capstone

Three simulators

All three are provided. Read only the docstrings: the code contains the capstone answers.

FileWhat it simulatesUsed on
causalkit/stores.py240 stores × 60 weeks: a regional campaign, and a new store format rolled out in wavesDays 41–44, 49
causalkit/regions.py30 regions × 104 weeks: one region runs an 8-week TV campaignDays 45–47, 49, 50
causalkit/tiers.py30,000 customers: Gold status starts at $1,000 of spendingDays 48–49
causalkit/stores.py (provided; click to expand)
"""
NorthMart store panel (simulated). Provided for Module 5: read the docstrings, not the code,
until the Day 50 capstone is done.

    from causalkit.stores import campaign, rollout, truth

    df = campaign()                  # 240 stores x 60 weeks; stores in two regions get a campaign from week 40
    df = campaign(trend_gap=0.15)    # same, but treated stores were already growing faster
    df = rollout()                   # a new store format, adopted in waves (weeks 15, 30, 45) or never
    truth(df)                        # true average effects (for checking only)

Columns an analyst has:
    store_id, region, week, sales ($ thousands per week),
    treated_group (1 = store is ever treated), cohort (first treated week; 0 = never),
    post (1 = week >= start; campaign only), d (1 = treated in this week)
Columns starting with '_' are the simulator's hidden truth: _y0 (sales without treatment), _tau (effect).
"""
import numpy as np
import polars as pl

REGIONS = ["BC", "AB", "SK", "MB", "ON", "QC", "NB", "NS"]


def _base(n_stores: int, weeks: int, rng: np.random.Generator, rho: float = 0.7, region_sd: float = 0.0):
    """Store effects, shared seasonality, regional shocks and AR(1) store noise."""
    region = rng.choice(REGIONS, n_stores)
    store_fe = rng.normal(100, 20, n_stores)
    t = np.arange(1, weeks + 1)
    week_fe = 4 * np.sin(2 * np.pi * t / 52) + 0.05 * t + rng.normal(0, 1.0, weeks)
    region_shock = {r: np.cumsum(rng.normal(0, 1, weeks)) * region_sd for r in REGIONS}
    e = np.zeros((n_stores, weeks))
    u = rng.normal(0, 2.5, (n_stores, weeks))
    e[:, 0] = u[:, 0] / np.sqrt(1 - rho**2)
    for w in range(1, weeks):
        e[:, w] = rho * e[:, w - 1] + u[:, w]
    y0 = store_fe[:, None] + week_fe[None, :] + np.vstack([region_shock[r] for r in region]) + e
    return region, t, y0


def _frame(region, t, y0, tau, d, cohort, extra=None) -> pl.DataFrame:
    n, w = y0.shape
    df = pl.DataFrame({
        "store_id": np.repeat([f"S{i:03d}" for i in range(1, n + 1)], w),
        "region": np.repeat(region, w),
        "week": np.tile(t, n).astype(np.int64),
        "sales": np.round((y0 + tau * d).ravel(), 2),
        "treated_group": np.repeat((cohort > 0).astype(np.int64), w),
        "cohort": np.repeat(cohort, w).astype(np.int64),
        "d": d.ravel().astype(np.int64),
        "_y0": y0.ravel(),
        "_tau": (tau * d).ravel(),
    })
    return df.with_columns(**(extra or {}))


def campaign(n_stores: int = 240, weeks: int = 60, start: int = 40, seed: int = 41,
             effect: float = 3.0, trend_gap: float = 0.0, treated_regions=("ON", "QC"),
             region_sd: float = 0.0) -> pl.DataFrame:
    """A regional campaign: every store in treated_regions is treated from week `start`.
    effect:    true sales lift, $ thousands per store-week (constant over time)
    trend_gap: extra weekly growth of treated stores' sales, before AND after the campaign
               (violates parallel trends when > 0)
    region_sd: size of week-to-week shocks shared by all stores in a region (random walk; 0 = none)"""
    rng = np.random.default_rng(seed)
    region, t, y0 = _base(n_stores, weeks, rng, region_sd=region_sd)
    g = np.isin(region, treated_regions)
    y0 = y0 + trend_gap * g[:, None] * (t[None, :] - start)
    d = g[:, None] & (t[None, :] >= start)
    tau = np.full(y0.shape, effect)
    cohort = np.where(g, start, 0)
    return _frame(region, t, y0, tau, d, cohort,
                  {"post": (pl.col("week") >= start).cast(pl.Int64)}).select(
        "store_id", "region", "week", "sales", "treated_group", "cohort", "post", "d", "_y0", "_tau")


def rollout(n_stores: int = 240, weeks: int = 60, seed: int = 47, cohorts=(15, 30, 45),
            growth: float = 0.4, cap: int = 20, dynamic: bool = True) -> pl.DataFrame:
    """A new store format adopted in waves. A quarter of stores adopt in each cohort week; a quarter never do.
    The effect grows by `growth` per week since adoption up to `cap` weeks (dynamic=True), and is larger
    for earlier adopters (stores that volunteered first were better suited to the format).
    dynamic=False gives the same average effect but constant over time."""
    rng = np.random.default_rng(seed)
    region, t, y0 = _base(n_stores, weeks, rng)
    groups = np.array(list(cohorts) + [0])
    cohort = groups[rng.permutation(np.arange(n_stores) % len(groups))]
    rel = t[None, :] - cohort[:, None]
    d = (cohort[:, None] > 0) & (rel >= 0)
    suit = np.select([cohort == cohorts[0], cohort == cohorts[1]], [1.5, 1.0], 0.6)[:, None]
    if dynamic:
        tau = suit * growth * np.minimum(rel + 1, cap)
    else:
        tau = suit * growth * np.minimum(np.maximum(rel, 0) + 1, cap).mean() * np.ones_like(y0)
    tau = np.where(d, tau, 0.0)
    return _frame(region, t, y0, tau, d, cohort)


def truth(df: pl.DataFrame) -> dict:
    """True ATT over treated store-weeks, and by cohort."""
    tr = df.filter(pl.col("d") == 1)
    out = {"ATT": round(tr["_tau"].mean(), 3)}
    for c in sorted(tr["cohort"].unique().to_list()):
        out[f"ATT_cohort_{c}"] = round(tr.filter(pl.col("cohort") == c)["_tau"].mean(), 3)
    return out


def public(df: pl.DataFrame) -> pl.DataFrame:
    return df.select([c for c in df.columns if not c.startswith("_")])

New folders and packages

causal-decision-lab/
├── causalkit/
│   ├── stores.py, regions.py, tiers.py   # provided simulators
│   ├── did.py                            # Days 43–44: staggered DiD by hand
│   └── synth.py                          # Days 45–49: synthetic control, SDID, placebos
├── data/make_campaign_scenario.py        # Day 50 capstone data
└── day41/ ... day50/

Add to requirements.txt, then pip install -r requirements.txt:

pyfixest==0.60.0
wildboottest==0.3.2
rdrobust==2.1.1
rddensity==3.0
causalpy==0.9.0
pymc==5.28.5

One change to an earlier pin. PyMC compiles models with a library (numba) that doesn't support numpy 2.5 yet. Change the numpy line at the top of requirements.txt to:

numpy==2.4.6           # not 2.5 yet: PyMC (Day 47) needs numpy < 2.5

Every Module 1–4 result is identical under numpy 2.4.6; we checked.

mkdir -p day41 day42 day43 day44 day45 day46 day47 day48 day49 day50