Module 5 · Day 45 of 80

Synthetic Control

About 5 hours
Video lesson coming soon
Day 45 · Synthetic Control

What you'll learn today

  • I can explain why DiD fails with one treated unit on a different trend
  • I can build a synthetic control, read its weights and judge its pre-period fit
  • I know that a close fit over a short pre-period is weak evidence
  • I check robustness by dropping each donor in turn

Today's plan

Timed blocks for the day. Hover a bar for details.

LessonHands-on projectBreak5:00 total
Written lesson
TimeBlock
0:00 – 0:4045.1 One treated region
0:40 – 1:3045.2 Why DiD fails here
1:30 – 1:45Break
1:45 – 2:5045.3 Building a synthetic region
2:50 – 3:2045.4 How long a pre-period?
3:20 – 3:35Break
3:35 – 4:4045.5 Day 45 mini-project: leave-one-out
4:40 – 5:0045.6 Save your work with Git

45.1 One treated region (40 min)

Region R07 ran an 8-week TV campaign starting in week 80. Its sales lift was about $30k a week during the campaign, then faded over the following weeks. That's a typical marketing question: one treated unit, many untreated ones, a long history before.

Create causalkit/regions.py (provided; read only the docstrings):

causalkit/regions.py (provided; click to expand)
"""
NorthMart regional sales (simulated). Provided for Module 5: read the docstrings, not the code,
until the Day 50 capstone is done.

    from causalkit.regions import regions, truth, wide

    df = regions()          # 30 regions x 104 weeks; region R07 runs an 8-week TV campaign from week 80
    truth(df)               # true lift: average per post week, cumulative, during the campaign (for checking only)
    Y = wide(df)            # weeks x regions matrix of sales, for synthetic control

Columns an analyst has:
    region, week, sales ($ thousands per week), treated (1 = the campaign region), post (1 = week >= start)
Columns starting with '_' are the simulator's hidden truth: _y0 (sales without the campaign), _tau (lift).

Regions differ in size, seasonality and exposure to two national trends; one of them, urban growth,
accelerates over the two years, and some regions ride it much more than others.
"""
import numpy as np
import polars as pl


def regions(n_regions: int = 30, weeks: int = 104, start: int = 80, seed: int = 45, treated: str = "R07",
            campaign_weeks: int = 8, lift: float = 30.0, decay: float = 0.8, treated_urban: float = 1.6,
            treated_size: float = 0.0,
            noise: float = 4.0) -> pl.DataFrame:
    """treated_size: extra weekly sales of the campaign region ($ thousands) on top of the median region.
    lift:   true weekly sales lift during the campaign ($ thousands); after it ends the lift decays
               by `decay` per week (carry-over).
    treated_urban: the campaign region's exposure to urban growth (other regions: uniform 0 to 2)."""
    rng = np.random.default_rng(seed)
    t = np.arange(1, weeks + 1)
    names = [f"R{j:02d}" for j in range(1, n_regions + 1)]
    level = rng.normal(500, 60, n_regions)
    season_amp = rng.uniform(10, 30, n_regions)
    national = 0.4 * t + np.cumsum(rng.normal(0, 1.5, weeks))
    urban = 30 * (t / weeks) ** 2 + np.cumsum(rng.normal(0, 0.8, weeks))
    load_nat = rng.uniform(0.6, 1.4, n_regions)
    load_urb = rng.uniform(0, 2, n_regions)
    j = names.index(treated)
    load_urb[j] = treated_urban
    level[j] = np.median(level) + treated_size
    season = np.sin(2 * np.pi * (t - 10) / 52)
    y0 = (level[:, None] + season_amp[:, None] * season[None, :] + load_nat[:, None] * national[None, :]
          + load_urb[:, None] * urban[None, :] + rng.normal(0, noise, (n_regions, weeks)))
    k = t - start
    tau_t = np.where(k < 0, 0.0, np.where(k < campaign_weeks, lift, lift * decay ** (k - campaign_weeks + 1)))
    tau = np.zeros_like(y0)
    tau[j] = tau_t
    return pl.DataFrame({
        "region": np.repeat(names, weeks),
        "week": np.tile(t, n_regions).astype(np.int64),
        "sales": np.round((y0 + tau).ravel(), 2),
        "treated": np.repeat((np.arange(n_regions) == j).astype(np.int64), weeks),
        "post": np.tile((t >= start).astype(np.int64), n_regions),
        "_y0": y0.ravel(),
        "_tau": tau.ravel(),
    })


def truth(df: pl.DataFrame, campaign_weeks: int = 8) -> dict:
    tr = df.filter((pl.col("treated") == 1) & (pl.col("post") == 1)).sort("week")
    tau = tr["_tau"].to_numpy()
    return {"avg_post_lift": round(float(tau.mean()), 2), "cumulative_lift": round(float(tau.sum()), 1),
            "campaign_weeks_lift": round(float(tau[:campaign_weeks].sum()), 1)}


def wide(df: pl.DataFrame, col: str = "sales") -> pl.DataFrame:
    """Weeks in rows, one column per region."""
    return df.pivot(on="region", index="week", values=col).sort("week")


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

The docstring gives a hint: regions ride a national trend and an urban growth trend, to different degrees, and urban growth accelerates.


45.2 Why DiD fails here (50 min)

Create day45/did_fails.py:

# day45/did_fails.py   (python -m day45.did_fails)
"""
Region R07 ran an 8-week TV campaign from week 80. Only ONE region was treated, and it is an urban
region that was already growing faster than most. DiD against the average region, and why it fails.
"""
import matplotlib.pyplot as plt
import polars as pl

from causalkit.regions import public, regions, truth
from causalkit.synth import did

full = regions()
df = public(full)
t = truth(full)
print(f"{df['region'].n_unique()} regions x {df['week'].n_unique()} weeks; campaign region R07 from week 80")
print(f"True lift: {t['avg_post_lift']:.1f} $k per post week on average, "
      f"{t['cumulative_lift']:.0f} $k in total ({t['campaign_weeks_lift']:.0f} during the 8 campaign weeks)")

d = did(df, "R07", 80)
print(f"\nDiD vs the average of the other 29 regions: {d['effect']:.1f} $k per week")

avg = df.filter(pl.col("region") != "R07").group_by("week").agg(pl.col("sales").mean()).sort("week")
r07 = df.filter(pl.col("region") == "R07").sort("week")
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(r07["week"], r07["sales"], label="R07", color="#dc2626")
axes[0].plot(avg["week"], avg["sales"], label="average of other regions", color="#64748b")
axes[0].set_title("Sales ($k per week)")
axes[1].plot(d["weeks"], d["gap"], color="#2563eb")
axes[1].set_title("R07 minus average, centred on the pre-period")
for ax in axes:
    ax.axvline(80, color="grey", ls="--", lw=0.8)
    ax.set_xlabel("week")
axes[0].legend()
fig.tight_layout()
fig.savefig("figures/day45_did_fails.png", dpi=120)
print("Saved figures/day45_did_fails.png")
python -m day45.did_fails
30 regions x 104 weeks; campaign region R07 from week 80
True lift: 14.3 $k per post week on average, 357 $k in total (240 during the 8 campaign weeks)

DiD vs the average of the other 29 regions: 32.1 $k per week
Saved figures/day45_did_fails.png

DiD more than doubles the lift. Open the figure: the gap between R07 and the average region drifts upward long before week 80. R07 is an urban region, more exposed to urban growth than the average region, so parallel trends fail. With 240 stores (Day 41) the average of many controls was a fine comparison; with one urban region, the average region is not.


45.3 Building a synthetic region (65 min)

Concept. Synthetic control (Abadie & Gardeazabal 2003; Abadie, Diamond & Hainmueller 2010) builds a synthetic R07: a weighted average of other regions, with weights that are non-negative and sum to 1, chosen so that synthetic R07's weekly sales match real R07's before week 80 as closely as possible. If the match is close for 79 weeks, it's credible that it would have kept matching without the campaign. After week 80, the gap is the effect.

Why non-negative weights that sum to 1? The synthetic region is then an interpolation of real regions, never an extrapolation, and you can read the weights: "synthetic R07 is 23% R18, 22% R14, ...".

Create causalkit/synth.py. Today you need simplex_ls, _split, did and synthetic_control; sdid and placebo_in_space follow on Days 46 and 49.

causalkit/synth.py (complete, built Days 45–49; click to expand)
"""Synthetic control, synthetic DiD and their placebo tests (Days 45-49)."""
import numpy as np
import polars as pl
from scipy.optimize import minimize

from causalkit.regions import wide


def simplex_ls(X: np.ndarray, y: np.ndarray, intercept: bool = False, ridge: float = 0.0):
    """Weights w >= 0 summing to 1 that minimise ||y - (a) - X w||^2 + ridge * ||w||^2.
    With intercept=True, a free constant a absorbs any level difference. Returns (w, a)."""
    k = X.shape[1]
    if intercept:                           # the optimal constant is the mean gap: demean, then solve
        Xc, yc = X - X.mean(0), y - y.mean()
    else:
        Xc, yc = X, y

    def loss(w):
        r = yc - Xc @ w
        return r @ r + ridge * w @ w

    def grad(w):
        return -2 * Xc.T @ (yc - Xc @ w) + 2 * ridge * w

    res = minimize(loss, np.full(k, 1 / k), jac=grad, method="SLSQP", bounds=[(0, 1)] * k,
                   constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1, "jac": lambda w: np.ones(k)}],
                   options={"maxiter": 1000, "ftol": 1e-12})
    w = np.clip(res.x, 0, None)
    w /= w.sum()
    a = float((y - X @ w).mean()) if intercept else 0.0
    return w, a


def _split(df: pl.DataFrame, treated: str, start: int):
    Y = wide(df)
    weeks = Y["week"].to_numpy()
    donors = [c for c in Y.columns if c not in ("week", treated)]
    y1 = Y[treated].to_numpy()
    Y0 = Y.select(donors).to_numpy()
    pre = weeks < start
    return weeks, donors, y1, Y0, pre


def did(df: pl.DataFrame, treated: str, start: int) -> dict:
    """Plain DiD: treated region vs the average of all other regions, post minus pre."""
    weeks, donors, y1, Y0, pre = _split(df, treated, start)
    gap = y1 - Y0.mean(1)
    return {"effect": float(gap[~pre].mean() - gap[pre].mean()), "gap": gap - gap[pre].mean(), "weeks": weeks}


def synthetic_control(df: pl.DataFrame, treated: str, start: int, intercept: bool = False) -> dict:
    """Abadie-style synthetic control: donor weights fitted on pre-period sales only.
    Returns the average post-period gap (effect), the weekly gap, weights and fit statistics."""
    weeks, donors, y1, Y0, pre = _split(df, treated, start)
    w, a = simplex_ls(Y0[pre], y1[pre], intercept=intercept)
    synth = a + Y0 @ w
    gap = y1 - synth
    rmspe_pre = float(np.sqrt((gap[pre] ** 2).mean()))
    rmspe_post = float(np.sqrt((gap[~pre] ** 2).mean()))
    return {"effect": float(gap[~pre].mean()), "gap": gap, "synthetic": synth, "actual": y1, "weeks": weeks,
            "weights": dict(sorted(((d, round(float(x), 3)) for d, x in zip(donors, w) if x > 0.005),
                                   key=lambda kv: -kv[1])),
            "rmspe_pre": rmspe_pre, "rmspe_post": rmspe_post, "ratio": rmspe_post / rmspe_pre}


def sdid(df: pl.DataFrame, treated: str, start: int) -> dict:
    """Synthetic difference-in-differences (Arkhangelsky et al., 2021), one treated unit.
    Unit weights: like SC but with an intercept and a ridge penalty.
    Time weights: pre-period weeks that best predict the donors' post-period average."""
    weeks, donors, y1, Y0, pre = _split(df, treated, start)
    T0, T1 = pre.sum(), (~pre).sum()
    zeta = (1 * T1) ** 0.25 * np.diff(Y0[pre], axis=0).std(ddof=1)
    omega, _ = simplex_ls(Y0[pre], y1[pre], intercept=True, ridge=zeta**2 * T0)
    lam, _ = simplex_ls(Y0[pre].T, Y0[~pre].mean(0), intercept=True, ridge=1e-6 * T0)
    treated_change = y1[~pre].mean() - lam @ y1[pre]
    donor_change = (Y0[~pre].mean(0) - lam @ Y0[pre]) @ omega
    return {"effect": float(treated_change - donor_change),
            "unit_weights": dict(sorted(((d, round(float(x), 3)) for d, x in zip(donors, omega) if x > 0.005),
                                        key=lambda kv: -kv[1])),
            "time_weights": {int(w): round(float(x), 3) for w, x in zip(weeks[pre], lam) if x > 0.005}}


def placebo_in_space(df: pl.DataFrame, treated: str, start: int, intercept: bool = False) -> pl.DataFrame:
    """Re-run synthetic control pretending each region was treated (the real one is dropped from donor pools).
    Inference: where does the real region's post/pre RMSPE ratio rank?"""
    rows = []
    for r in df["region"].unique().sort().to_list():
        sub = df if r == treated else df.filter(pl.col("region") != treated)
        sc = synthetic_control(sub, r, start, intercept=intercept)
        rows.append({"region": r, "is_treated": r == treated, "effect": sc["effect"],
                     "rmspe_pre": sc["rmspe_pre"], "ratio": sc["ratio"]})
    return pl.DataFrame(rows).sort("ratio", descending=True)

Create day45/synthetic_control.py:

# day45/synthetic_control.py   (python -m day45.synthetic_control)
"""
Synthetic control (Abadie, Diamond & Hainmueller, 2010): build a 'synthetic R07' as a weighted average
of other regions, with weights >= 0 that sum to 1, chosen to match R07's sales BEFORE the campaign.
After week 80, the synthetic region shows what R07 would have sold without the campaign.
"""
import matplotlib.pyplot as plt

from causalkit.regions import public, regions, truth
from causalkit.synth import synthetic_control

full = regions()
sc = synthetic_control(public(full), "R07", 80)
t = truth(full)

print("Donor weights:", sc["weights"])
print(f"Pre-period fit: RMSPE {sc['rmspe_pre']:.1f} $k (R07 sells about {sc['actual'][:79].mean():.0f} $k a week)")
print(f"Effect: {sc['effect']:.1f} $k per post week (truth {t['avg_post_lift']:.1f}); "
      f"cumulative {sc['gap'][79:].sum():.0f} $k (truth {t['cumulative_lift']:.0f})")

fig, axes = plt.subplots(1, 2, figsize=(11, 4))
axes[0].plot(sc["weeks"], sc["actual"], color="#dc2626", label="R07")
axes[0].plot(sc["weeks"], sc["synthetic"], color="#2563eb", ls="--", label="synthetic R07")
axes[0].legend()
axes[0].set_title("Sales ($k per week)")
axes[1].plot(sc["weeks"], sc["gap"], color="#2563eb", label="estimated")
axes[1].plot(sc["weeks"], full.filter(full["region"] == "R07")["_tau"], color="k", lw=1, label="true lift")
axes[1].axhline(0, color="grey", lw=0.8)
axes[1].legend()
axes[1].set_title("Gap: actual minus synthetic")
for ax in axes:
    ax.axvline(80, color="grey", ls="--", lw=0.8)
    ax.set_xlabel("week")
fig.tight_layout()
fig.savefig("figures/day45_synthetic_control.png", dpi=120)
print("Saved figures/day45_synthetic_control.png")
python -m day45.synthetic_control
Donor weights: {'R18': 0.225, 'R14': 0.216, 'R13': 0.209, 'R05': 0.174, 'R22': 0.161, 'R03': 0.01}
Pre-period fit: RMSPE 3.9 $k (R07 sells about 514 $k a week)
Effect: 17.3 $k per post week (truth 14.3); cumulative 433 $k (truth 357)
Saved figures/day45_synthetic_control.png

Six regions get weight; the other 23 get zero. The pre-period fit is within $3.9k a week (under 1% of sales). The estimate is $17.3k against a true $14.3k: much closer than DiD's $32k. Open the figure: the estimated gap tracks the true lift's shape, the jump at week 80 and the decay after week 88.


45.4 How long a pre-period? (30 min)

Concept. A close pre-period fit is only convincing if it's long. With a few weeks, any mix of regions can match by luck, including a mix that doesn't share R07's trends.

Try it. Fit synthetic control using only weeks 60 onwards (20 pre-period weeks instead of 79). Create day45/short_pre_period.py.

Solution
# day45/short_pre_period.py   (python -m day45.short_pre_period)
import polars as pl

from causalkit.regions import public, regions, truth
from causalkit.synth import synthetic_control

full = regions()
df = public(full)
for first_week in [1, 60]:
    sc = synthetic_control(df.filter(pl.col("week") >= first_week), "R07", 80)
    print(f"pre-period from week {first_week:>2}: effect {sc['effect']:.1f}, pre RMSPE {sc['rmspe_pre']:.1f}, "
          f"{len(sc['weights'])} donors")
print(f"truth {truth(full)['avg_post_lift']:.1f}")
pre-period from week  1: effect 17.3, pre RMSPE 3.9, 6 donors
pre-period from week 60: effect 27.4, pre RMSPE 2.9, 6 donors
truth 14.3

The short pre-period gives a better fit (RMSPE 2.9) and a worse answer ($27.4k). The weights fitted the noise of 20 weeks, not R07's long-run exposure to urban growth. A good fit over a short window is weak evidence; Abadie (2021) recommends a pre-period much longer than the number of donors that end up with weight.


45.5 Day 45 mini-project: leave-one-out (65 min)

Is the answer driven by one donor? Drop each donor with a positive weight in turn and re-estimate. Create day45/leave_one_out.py:

# day45/leave_one_out.py   (python -m day45.leave_one_out)
"""
Mini-project: is the answer driven by one donor? Drop each donor with a positive weight in turn and
re-estimate. A robust synthetic control gives similar answers every time.
"""
import polars as pl

from causalkit.regions import public, regions
from causalkit.synth import synthetic_control

df = public(regions())
base = synthetic_control(df, "R07", 80)
rows = [{"dropped": "(none)", "effect": round(base["effect"], 1), "pre RMSPE": round(base["rmspe_pre"], 1),
         "largest weights": str(dict(list(base["weights"].items())[:3]))}]
for donor in base["weights"]:
    sc = synthetic_control(df.filter(pl.col("region") != donor), "R07", 80)
    rows.append({"dropped": donor, "effect": round(sc["effect"], 1), "pre RMSPE": round(sc["rmspe_pre"], 1),
                 "largest weights": str(dict(list(sc["weights"].items())[:3]))})
with pl.Config(tbl_width_chars=140, fmt_str_lengths=60):
    print(pl.DataFrame(rows))
python -m day45.leave_one_out
│ dropped ┆ effect ┆ pre RMSPE ┆ largest weights                            │
│ (none)  ┆ 17.3   ┆ 3.9       ┆ {'R18': 0.225, 'R14': 0.216, 'R13': 0.209} │
│ R18     ┆ 20.6   ┆ 4.2       ┆ {'R14': 0.286, 'R22': 0.167, 'R04': 0.163} │
│ R14     ┆ 20.0   ┆ 4.1       ┆ {'R13': 0.31, 'R18': 0.293, 'R05': 0.205}  │
│ R13     ┆ 17.8   ┆ 4.0       ┆ {'R14': 0.314, 'R18': 0.209, 'R22': 0.178} │
│ R05     ┆ 17.3   ┆ 3.9       ┆ {'R18': 0.274, 'R14': 0.244, 'R13': 0.202} │
│ R22     ┆ 17.4   ┆ 3.9       ┆ {'R18': 0.296, 'R13': 0.212, 'R14': 0.207} │
│ R03     ┆ 17.3   ┆ 3.9       ┆ {'R18': 0.229, 'R14': 0.212, 'R13': 0.209} │

The estimate stays between $17.3k and $20.6k whatever is dropped, and the fit hardly changes. That's what a robust synthetic control looks like. If dropping one donor had moved the estimate by half, the result would be that donor's story, not R07's.

Report the leave-one-out range alongside the estimate: "$17k a week (between $17k and $21k when any single donor region is dropped)".


45.6 Save your work with Git (20 min)

git add .
git commit -m "Day 45: regions simulator, synthetic control, pre-period length, leave-one-out"
git push

Day 45 checklist

  • I can explain why DiD fails with one treated unit on a different trend
  • I can build a synthetic control, read its weights and judge its pre-period fit
  • I know that a close fit over a short pre-period is weak evidence
  • I check robustness by dropping each donor in turn