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.
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)
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.
Parallel trends, TWFE, and the modern critique 5 min live
DiD is only causal if parallel trends holds: absent the treatment, the treated and control groups would have moved by the same amount over time. This is untestable directly - it is a claim about the missing counterfactual - but you can build confidence by checking that the groups moved in parallel before treatment, using an event study. Scaling from 2x2 to many units and periods, you add unit and time fixed effects: two-way fixed effects (TWFE).
Self-studyWhy naive TWFE broke under staggered adoption3 min read▶
When units adopt treatment at different times (as Lumen's regions do) and the effect grows or changes over time, the classic two-way fixed effects regression can go badly wrong. Goodman-Bacon (2021) showed that TWFE is a weighted average of many 2x2 comparisons - and some of those use already-treated units as "controls" for later-treated ones ("forbidden comparisons"). Under dynamic effects those comparisons get negative weights, so TWFE can even flip sign relative to the true effect.
Card & Krueger (1994) is the canonical DiD: New Jersey raised its minimum wage, Pennsylvania did not, and comparing the change in fast-food employment across the two states challenged the textbook prediction. Same shape as Lumen's loyalty rollout - one side treated, one side not, over time.
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:
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:
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.
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:
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:
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.
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.
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):
# 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:
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.
This week ◐ 40 min total
- Break parallel trends on purpose. Add a pre-existing upward trend to the treated regions only, rerun DiD, and watch it report a "loyalty effect" that is really just the trend. Confirm the event-study leads now slope - the tell.
- Reconcile the three numbers. Show the by-hand 2x2, the interaction β₃, and TWFE agree on a clean balanced panel, and explain what changes when the panel is unbalanced.
- Try pyfixest. Re-fit the TWFE and event study in
pyfixestand compare speed and syntax to linearmodels - it is becoming the default for large panels. - Optional: read the Goodman-Bacon decomposition of your TWFE estimate and identify which 2x2 comparisons carry negative weight under the staggered rollout.
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.
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.