learn-experimentation-with-phoebe / Builder session 9 of 10
Learn Experimentation with Phoebe · Builder session 9 of 10

Difference-in-differences, and the trend you have to trust

When a change rolls out to some regions before others, you get a natural experiment for free - if you analyze it honestly. Difference-in-differences compares the change over time in a treated group against the change in a control, differencing out fixed group gaps and shared time shocks. This session builds DiD three ways in Python on Lumen's staggered loyalty rollout - by hand, as a regression, and as two-way fixed effects - then codes the event-study plot that tests its one load-bearing assumption. You will also meet the modern critique that broke naive TWFE.

🔴 Hardest Builders · DA / DE / DS Python · linearmodels 45 min
0-3 · Setup 3-16 · DiD + parallel trends 16-40 · Build it in Python 40-45 · Wrap
Part 0

Two differences beat one

Lumen rolled a loyalty program out to some regions before others. You cannot rerun history to see what the early-adopter regions would have earned without loyalty - the counterfactual is missing, as always. But a plain before/after in the treated regions is confounded by everything else that changed over that time (seasonality, a price move, macro). Difference-in-differences fixes this by subtracting the control group's change from the treated group's change: whatever common shock hit both cancels out, leaving the treatment effect. The whole method rests on one assumption - parallel trends - and this session teaches you to build DiD and to interrogate that assumption in code.

Live - built in session Self-study - read after class ★ Build-along - everyone codes it The data: Lumen Skincare
★ What you walk out with today A notebook that (1) computes the 2x2 DiD estimate by hand from four group means, (2) reproduces it as a regression with a Treat×Post interaction and then scales to two-way fixed effects with region and week effects, and (3) plots an event study of leads and lags to check parallel trends. You leave knowing not just how to run DiD, but when the modern staggered-adoption critique says you should not trust the naive version.
Part 1 · the estimator

The 2x2 estimate and its regression form 6 min live

With one treated group, one control group, and two periods (pre and post), DiD is a difference of two differences. Take the treated group's post-minus-pre change, subtract the control group's post-minus-pre change, and what remains is the effect - because any shock common to both groups sits in both differences and cancels:

DiD = (ȲT,post - ȲT,pre) - (ȲC,post - ȲC,pre)

weekly revenue pre-rollout post-rollout rollout treated (observed) treated (parallel projection) control DiD effect The dashed line is where treated "should" have gone if it kept moving parallel to control. The gap to the real treated line is the DiD estimate. Any shock common to both groups (a promo, seasonality) shifts both lines equally and cancels in the double difference.
🔍 Click to zoom - parallel pre-trends, then divergence; the difference of differences is the effect
LiveThe regression that gives you the same number3 min

The 2x2 DiD is exactly one coefficient in a regression, which is convenient because you get a standard error and can add covariates:

Y = β₀ + β₁·Treat + β₂·Post + β₃·(Treat × Post) + ε

  • β₀ - control group, pre-period baseline.
  • β₁ - fixed gap between treated and control (differenced out).
  • β₂ - common time shift from pre to post (differenced out).
  • β₃ - the interaction. This is the DiD estimate - the extra change in the treated group beyond the common time shift.
β₃ is the whole answer When you fit the model, the Treat×Post coefficient equals the four-mean hand calculation to the decimal. The regression just hands you inference and room for controls for free.
Build-along 1 of 3

Compute the 2x2 DiD by hand ★ 7 min · everyone

Start with the four-means version so the estimate is never a black box. Take Lumen's early-adopter regions as treated and the late-adopter regions as control, split weekly revenue into pre- and post-rollout, and compute the double difference.

Label groups and periods on the weekly-revenue panel:

demo1_two_by_two.py
import pandas as pd, numpy as np

df = pd.read_parquet("lumen_loyalty_panel.parquet")   # region, week, revenue
early = ["US-CA", "US-NY", "US-TX"]                    # got loyalty first
ROLLOUT = pd.Timestamp("2026-04-06")

df["Treat"] = df.region.isin(early).astype(int)
df["Post"]  = (df.week >= ROLLOUT).astype(int)

Four cell means, then the difference of differences:

did_by_hand.py
m = df.groupby(["Treat", "Post"])["revenue"].mean()
T_pre,  T_post = m[(1, 0)], m[(1, 1)]
C_pre,  C_post = m[(0, 0)], m[(0, 1)]

did = (T_post - T_pre) - (C_post - C_pre)
print(f"treated change  = {T_post - T_pre:,.0f}")
print(f"control change  = {C_post - C_pre:,.0f}")
print(f"DiD estimate    = {did:,.0f}")   # the effect, common shocks cancelled

The treated change includes the loyalty effect plus whatever moved the whole market; the control change is just the market move. Subtracting isolates the effect - provided the two groups would have moved together anyway. That "provided" is the parallel-trends assumption you test in build-along 3.

Build-along 2 of 3

Regression DiD, then two-way fixed effects ★ 9 min · everyone

Reproduce the hand number as a regression coefficient, then scale to a full panel with region and week fixed effects. The Treat×Post interaction is your estimate; TWFE absorbs every fixed region gap and every common weekly shock.

The interaction regression - β₃ should match build-along 1 exactly:

demo2a_interaction.py
import statsmodels.formula.api as smf

ols = smf.ols("revenue ~ Treat * Post", data=df).fit(
        cov_type="cluster", cov_kwds={"groups": df["region"]})
print(ols.params["Treat:Post"])          # == the by-hand DiD
print(ols.summary())                     # SE + CI on the interaction term

Two-way fixed effects with linearmodels - region + week effects, clustered SEs:

demo2b_twfe.py
from linearmodels.panel import PanelOLS

panel = df.set_index(["region", "week"])
panel["TreatPost"] = panel["Treat"] * panel["Post"]

twfe = PanelOLS.from_formula(
    "revenue ~ TreatPost + EntityEffects + TimeEffects",
    data=panel).fit(cov_type="clustered", cluster_entity=True)
print(twfe.params["TreatPost"])          # DiD with region + week fixed effects

Entity (region) effects soak up permanent differences between regions; time (week) effects soak up shocks that hit every region in a given week. What is left on TreatPost is the effect - the same logic as the 2x2, generalized to many units and periods.

Real world

Lumen's regions have wildly different baselines - California dwarfs Washington. Without region fixed effects, that level gap would swamp the analysis. TWFE differences it away automatically, which is why panel DiD is the workhorse for staggered rollouts.

Build-along 3 of 3

Event study - test parallel trends in code ★ 8 min · everyone

Regress on event-time leads and lags relative to each region's rollout week. Pre-period (lead) coefficients near zero support parallel trends; post-period (lag) coefficients trace the dynamic effect. Then plot it with confidence bands.

Build event time and dummies (omit -1 as the reference period):

demo3a_event_time.py
# weeks relative to each region's own rollout; controls never treated -> large event time
df["evt"] = ((df.week - df.rollout_week) / np.timedelta64(1, "W")).round().astype(int)
df["evt"] = df["evt"].clip(-4, 6)                 # bin the tails

es = smf.ols("revenue ~ C(evt, Treatment(reference=-1)) + C(region) + C(week)",
             data=df).fit(cov_type="cluster", cov_kwds={"groups": df["region"]})

Pull the lead/lag coefficients and plot them with 95% bands:

demo3b_event_plot.py
import matplotlib.pyplot as plt
coef = es.params.filter(like="C(evt")
ci   = es.conf_int().loc[coef.index]
k    = [int(s.split("[T.")[1].rstrip("]")) for s in coef.index]

plt.errorbar(k, coef.values,
             yerr=[coef.values - ci[0], ci[1] - coef.values], fmt="o")
plt.axhline(0, ls="--"); plt.axvline(-0.5, color="red", ls="--")
plt.xlabel("weeks since rollout"); plt.ylabel("coefficient")
# leads (k < 0) near zero -> parallel trends holds; lags (k >= 0) -> the effect

If the lead coefficients hug zero, you have real support for parallel trends and can trust the DiD. If they drift, the groups were already diverging - the estimate is contaminated. And because Lumen's rollout is staggered, note the caveat: for a headline number, prefer Callaway-Sant'Anna group-time ATTs over naive TWFE, which can misweight dynamic effects.

The leads are the honesty check You cannot test parallel trends after treatment - that region is the whole point. All you can do is show the groups tracked before treatment and argue nothing else changed at the rollout. Report the event-study plot every time; a DiD without it is a claim, not evidence.
Before Session 10

This week ◐ 40 min total

Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · In the regression Y = β₀ + β₁·Treat + β₂·Post + β₃·(Treat×Post) + ε, the DiD estimate is...

β₃ captures the extra change in the treated group beyond the common time shift - the difference of differences. β₁ and β₂ are the fixed group gap and common time move, both differenced out.

2 · The key identifying assumption of DiD is...

DiD allows different baseline levels (β₁ absorbs them); it requires that the groups would have moved in parallel without treatment. It is untestable directly - support it with an event-study pre-trend check.

3 · Why can naive two-way fixed effects be biased under staggered adoption?

Goodman-Bacon (2021) showed TWFE is a weighted mix of 2x2s, some using earlier-treated units as controls for later ones. Under dynamic effects those get negative weights - Callaway-Sant'Anna use only clean controls to fix it.

Source material

What this session covers

We build the working core of DiD - the estimator, the assumption, and the honest test - and flag the modern staggered-adoption literature. The full econometric treatment lives in the sources below.

2x2 DiD, regression form, TWFE, event studyParts 1-2 + all three build-alongs on Lumen's loyalty rollout
Parallel trends and the staggered-adoption critiqueevent-study pre-trend check + Goodman-Bacon / Callaway-Sant'Anna
Card & Krueger 1994; Goodman-Bacon 2021; Callaway-Sant'Anna 2021canonical + modern papers - primary sources
Econometrics & Statistics for Business (Resende, Udemy)DiD + panel code depth - extends this session
Instrumental variables & regression discontinuitysiblings of DiD - named, not built (out of scope)

Builder Session 9 cheat sheet · pin this

2x2 DiDT,postT,pre) - (ȲC,postC,pre). Common shocks cancel in the double difference.
Regression formY = β₀ + β₁Treat + β₂Post + β₃(Treat×Post). β₃ is the DiD estimate.
Parallel trendsAbsent treatment, both groups move the same. Untestable directly - support with pre-trends.
TWFEUnit + time fixed effects absorb fixed group gaps and common time shocks. Cluster your SEs.
Event studyLeads near zero support parallel trends; lags trace the dynamic effect. Always plot it.
Staggered caveatNaive TWFE is biased with staggered adoption + dynamic effects (Goodman-Bacon). Use Callaway-Sant'Anna.