| Time | Block |
|---|---|
| 0:00 – 0:40 | 42.1 One coefficient per week |
| 0:40 – 1:40 | 42.2 Reading an event study |
| 1:40 – 1:55 | Break |
| 1:55 – 2:50 | 42.3 What a passed pre-trend test proves |
| 2:50 – 3:20 | 42.4 How big a trend would it take? |
| 3:20 – 3:35 | Break |
| 3:35 – 4:40 | 42.5 Day 42 mini-project: detrending |
| 4:40 – 5:00 | 42.6 Save your work with Git |
42.1 One coefficient per week (40 min)
Concept. Instead of one DiD coefficient, estimate one for each week relative to the start: −39, −38, …, −2, 0, 1, …, 20. Week −1 is the reference (set to zero). This is an event study:
- Leads (weeks before the start) should be about zero if the groups were on parallel paths before the campaign. They are the main visual evidence for parallel trends.
- Lags (weeks after) show how the effect evolves: immediate, growing, or fading.
In pyfixest: i(rel_week, treated_group, ref=-1) creates the interaction of each relative week with the treated group.
42.2 Reading an event study (60 min)
Create day42/event_study.py. It also defines pretrend_test, a one-number test of whether treated stores were already growing faster before week 40:
# day42/event_study.py (python -m day42.event_study)
"""
An event study: one DiD coefficient per week relative to the campaign start (week 39 = reference).
Leads (weeks before) test parallel trends; lags (weeks after) show how the effect evolves.
Two datasets: parallel trends hold, and treated stores were already growing faster.
"""
import matplotlib.pyplot as plt
import numpy as np
import pyfixest as pf
from causalkit.stores import campaign, public
def event_study(df):
"""Fit the event study; return relative weeks, the tidy table and the fitted model."""
df = df.assign(rel_week=df.week - 40)
es = pf.feols("sales ~ i(rel_week, treated_group, ref=-1) | store_id + week", df, vcov={"CRV1": "store_id"})
tidy = es.tidy()
rel = np.array([int(name.split("::")[1].split(":")[0]) for name in tidy.index])
return rel, tidy, es
def pretrend_test(df):
"""A one-number pre-trend test: in the weeks BEFORE the campaign, did treated stores' sales
grow faster than the others'? Returns the extra weekly growth and its p-value."""
pre = df[df.week < 40]
fit = pf.feols("sales ~ treated_group:week | store_id + week", pre, vcov={"CRV1": "store_id"})
return fit.coef().iloc[0], fit.pvalue().iloc[0]
if __name__ == "__main__":
fig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=True)
for ax, (title, gap) in zip(axes, [("parallel trends hold", 0.0),
("treated stores already growing faster", 0.15)]):
df = public(campaign(trend_gap=gap)).to_pandas()
rel, tidy, es = event_study(df)
lead = rel < -1
slope, p = pretrend_test(df)
print(f"{title}:")
print(f" average lead (should be 0): {tidy.Estimate[lead].mean():+.2f} "
f"average lag: {tidy.Estimate[~lead].mean():+.2f}")
print(f" pre-trend test: treated stores grew {slope:+.3f} $k/week faster before week 40, p = {p:.4f}")
ax.errorbar(rel, tidy.Estimate, yerr=1.96 * tidy["Std. Error"], fmt="o", ms=3, color="#2563eb")
ax.axhline(0, color="grey", lw=0.8)
ax.axvline(-0.5, color="#dc2626", ls="--", lw=0.8)
ax.set_title(title)
ax.set_xlabel("weeks relative to campaign start")
axes[0].set_ylabel("effect on weekly sales ($k)")
fig.tight_layout()
fig.savefig("figures/day42_event_study.png", dpi=120)
print("Saved figures/day42_event_study.png")
python -m day42.event_study
parallel trends hold:
average lead (should be 0): +0.53 average lag: +3.25
pre-trend test: treated stores grew -0.019 $k/week faster before week 40, p = 0.2263
treated stores already growing faster:
average lead (should be 0): -2.40 average lag: +4.90
pre-trend test: treated stores grew +0.131 $k/week faster before week 40, p = 0.0000
Saved figures/day42_event_study.png
Open the figure. Left: leads flat around zero, then a jump at week 0. Right: a straight upward line through both periods. There's no visible jump at week 0 at all; the "effect" DiD reports is mostly the trend continuing. Always plot the event study before reporting a DiD.
Why are the left panel's leads around +0.5, not 0? Every coefficient is measured relative to one week (week −1), so that week's noise shifts all of them together. Read the shape, not the level of individual dots.
42.3 What a passed pre-trend test proves (55 min)
Concept. A pre-trend test that fails is strong evidence against parallel trends. A test that passes is weak evidence for it: it may simply lack power to detect a small trend, and a small trend can still bias the estimate (Roth, 2022).
Create day42/pretrend_power.py:
# day42/pretrend_power.py (python -m day42.pretrend_power)
"""
Passing a pre-trend test is not proof of parallel trends (Roth, 2022).
Small trend gaps, 40 datasets each: how often does the pre-test catch the gap, and how biased is DiD
in the datasets that PASS it?
"""
import numpy as np
import polars as pl
import pyfixest as pf
from causalkit.stores import campaign, public
from day42.event_study import pretrend_test
rows = []
for gap in [0.0, 0.02, 0.05, 0.10]:
caught, errors_passed, errors_all = 0, [], []
for s in range(40):
full = campaign(seed=8000 + s, trend_gap=gap)
df = public(full).to_pandas()
p = pretrend_test(df)[1]
err = pf.feols("sales ~ d | store_id + week", df).coef()["d"] - 3.0
errors_all.append(err)
if p < 0.05:
caught += 1
else:
errors_passed.append(err)
rows.append({"trend gap ($k/week)": gap, "pre-test rejects": f"{caught / 40:.0%}",
"DiD bias, all datasets": round(np.mean(errors_all), 2),
"DiD bias, datasets that pass": round(np.mean(errors_passed), 2) if errors_passed else None})
with pl.Config(tbl_width_chars=120):
print(pl.DataFrame(rows))
python -m day42.pretrend_power
│ trend gap ($k/week) ┆ pre-test rejects ┆ DiD bias, all datasets ┆ DiD bias, datasets that pass │
│ 0.0 ┆ 2% ┆ 0.03 ┆ 0.02 │
│ 0.02 ┆ 22% ┆ 0.63 ┆ 0.64 │
│ 0.05 ┆ 88% ┆ 1.53 ┆ 1.49 │
│ 0.1 ┆ 100% ┆ 3.03 ┆ null │
A trend gap of $0.02k a week is caught only 22% of the time, and when it passes, it biases the estimate by $0.64k: 21% of the true effect, with a clean-looking event study. "We tested for pre-trends and found none" is a statement about power as much as about trends.
42.4 How big a trend would it take? (30 min)
Concept. A more useful question than "did the test pass?" is: how large would a trend gap have to be to explain the whole estimate, and can the data rule that out? With a linear trend gap δ, TWFE's bias is δ × (average post week − average pre week). (This is the idea behind the HonestDiD approach of Rambachan & Roth, 2023.)
Try it. Compute that "breakdown" trend for the parallel-trends dataset and compare it with the 95% CI of the pre-period trend gap. Create day42/breakdown_trend.py.
Solution
# day42/breakdown_trend.py (python -m day42.breakdown_trend)
import pyfixest as pf
from causalkit.stores import campaign, public
from day42.event_study import pretrend_test
df = public(campaign()).to_pandas()
est = pf.feols("sales ~ d | store_id + week", df).coef()["d"]
distance = df[df.week >= 40].week.mean() - df[df.week < 40].week.mean()
slope, p = pretrend_test(df)
pre = df[df.week < 40]
se = pf.feols("sales ~ treated_group:week | store_id + week", pre, vcov={"CRV1": "store_id"}).se().iloc[0]
print(f"DiD estimate {est:.2f}; post weeks are on average {distance:.1f} weeks after pre weeks")
print(f"A trend gap of {est / distance:.3f} $k/week would explain the whole estimate")
print(f"Pre-period trend gap: {slope:+.3f}, 95% CI [{slope - 1.96 * se:+.3f}, {slope + 1.96 * se:+.3f}]")
DiD estimate 2.74; post weeks are on average 30.0 weeks after pre weeks
A trend gap of 0.091 $k/week would explain the whole estimate
Pre-period trend gap: -0.019, 95% CI [-0.049, +0.011]
To explain away the whole effect, treated stores would need to grow $0.091k a week faster, eight times the upper end of the pre-period CI (+0.011). Report it as: "the effect survives any trend gap up to 0.09; the pre-period data rule out gaps above 0.01". That is a much stronger statement than "the pre-trend test passed".
42.5 Day 42 mini-project: detrending (65 min)
When pre-trends are clearly not parallel, a common fix is to give the treated group its own linear trend. Create day42/detrend.py:
# day42/detrend.py (python -m day42.detrend)
"""
Mini-project: when the pre-trends are clearly not parallel, a common fix is to let each group have its
own linear trend. It works if the gap really is a straight line; it costs precision; and if the EFFECT
itself grew over time, the trend term would absorb part of it.
"""
import polars as pl
import pyfixest as pf
from causalkit.stores import campaign, public, truth
rows = []
for title, kw in [("parallel trends", {}), ("linear trend gap 0.15", {"trend_gap": 0.15})]:
full = campaign(**kw)
df = public(full).to_pandas()
plain = pf.feols("sales ~ d | store_id + week", df, vcov={"CRV1": "store_id"})
trend = pf.feols("sales ~ d + treated_group:week | store_id + week", df, vcov={"CRV1": "store_id"})
rows.append({"data": title, "truth": truth(full)["ATT"],
"DiD": round(plain.coef()["d"], 2), "DiD + group trend": round(trend.coef()["d"], 2),
"SE without / with trend": f"{plain.se()['d']:.2f} / {trend.se()['d']:.2f}"})
with pl.Config(tbl_width_chars=120, fmt_str_lengths=30):
print(pl.DataFrame(rows))
python -m day42.detrend
│ data ┆ truth ┆ DiD ┆ DiD + group trend ┆ SE without / with trend │
│ parallel trends ┆ 3.0 ┆ 2.74 ┆ 3.32 ┆ 0.30 / 0.52 │
│ linear trend gap 0.15 ┆ 3.0 ┆ 7.24 ┆ 3.32 ┆ 0.30 / 0.52 │
The group trend removes the bias completely here, because the gap really is a straight line, and costs 70% more standard error. Two warnings to write in day42/NOTES.md:
- A linear trend fitted through the whole period also fits the post-period. If the campaign's effect grew week by week, the trend term would absorb part of the effect and the estimate would be too small.
- Real trend gaps are rarely straight lines. If you need a trend term, say so prominently: the result now rests on a functional-form assumption the reader should judge.
42.6 Save your work with Git (20 min)
git add .
git commit -m "Day 42: event studies, pre-trend power, breakdown trend, detrending"
git push
Day 42 checklist
- I can estimate and plot an event study, and read leads and lags
- I know that a passed pre-trend test may just lack power, and that small trends still bias DiD
- I can compute the trend gap that would explain away an estimate, and compare it with the data
- I can add a group-specific trend, and explain its costs and risks