Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions changelog.d/20261023-v4-pr4b-tutorials-twfe-es.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
### Documentation
- **Tutorials 02/04/05/07/23 are rebuilt on `TwoWayFixedEffects().fit(..., event_study=True)`**
(`spec="within"`: unit + period fixed effects, unit-clustered SEs) ahead of the
MultiPeriodDiD removal (`M-010`, 4.0 cut PR 4c). Point estimates match the former
MultiPeriodDiD fits on these balanced panels; the SEs change because the within spec
clusters by unit. Tutorial 23's naive headline is the equal-weight average of the two
post-period coefficients computed in the cell (ATT unchanged at -4.29; the SE is now the
unit-clustered one), and tutorial 02's period-effects loop reads `to_dataframe()`.
- The static `TwoWayFixedEffects().fit(..., time=...)` calls in tutorials 01/02/09 (the
`M-082` alias, misused there as the post dummy) are replaced: 01 passes `post="post"`;
02 and 09 report `TwoWayFixedEffects().decompose(...).twfe_estimate`, the unit + period
fixed-effects coefficient their Goodman-Bacon sections decompose (the old cells printed
`treated x period` artefacts: 0.6309 and 0.0001). Tutorial 02 cell 36's covariate draw is
seeded. Tutorials 02 and 09 are re-executed on the Rust backend, which produced the
committed outputs, so the seeded bootstrap cells are unchanged.
- Tutorials 08 and 16 describe `StaggeredTripleDifference` as the removed class (4.0 cut,
PR 4a). `diff_diff/guides/llms-full.txt`'s event-study snippet calls `print(es.summary())`
(`EventStudyResults` has no `print_summary`).

### Testing
- `tests/test_t23_spillover_tva_drift.py`'s `naive_fit` fixture mirrors the notebook's TWFE
event-study headline cell, runs the fit with warnings escalated to errors, and pins the
post-period-average identity and the unit-clustered vcov-block SE alongside the existing
-4.29 endpoint.

### Internal
- `docs/v4-design.md` cut-sequence item 4b marked shipped; item 4c lists the two remaining
markdown-only MultiPeriodDiD mentions (06 cell 34, 16 cell 40). `docs/doc-deps.yaml` maps
the TWFE module to tutorials 01/02/04/05/07/09/23. No ledger row flips.
2 changes: 1 addition & 1 deletion diff_diff/guides/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ results.print_summary()
# auto-cluster applies with the static carve-outs)
es = twfe.fit(data, outcome='y', treatment='treated', unit='unit_id',
event_study=True, time='period', post_periods=[4, 5, 6, 7])
es.print_summary()
print(es.summary())
```

**Note:** TWFE can be biased with staggered treatment timing and heterogeneous effects. Consider CallawaySantAnna, SunAbraham, or ImputationDiD for staggered designs.
Expand Down
21 changes: 21 additions & 0 deletions docs/doc-deps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ sources:
note: "TWFE block documents the full fit signature incl. event-study params; the Conley section's panel examples use post=/event_study (rows M-010/M-082)"
- path: docs/migration-4.0.md
type: user_guide
- path: docs/tutorials/01_basic_did.ipynb
type: tutorial
note: "Static TWFE example (post= dummy, row M-082)"
- path: docs/tutorials/02_staggered_did.ipynb
type: tutorial
note: "decompose() TWFE estimate + the TWFE event-study comparison section (event_study=True, spec within)"
- path: docs/tutorials/04_parallel_trends.ipynb
type: tutorial
note: "Event-study parallel-trends check uses event_study=True"
- path: docs/tutorials/05_honest_did.ipynb
type: tutorial
note: "HonestDiD inputs come from TWFE event_study=True"
- path: docs/tutorials/07_pretrends_power.ipynb
type: tutorial
note: "PreTrendsPower inputs come from TWFE event_study=True"
- path: docs/tutorials/09_real_world_examples.ipynb
type: tutorial
note: "decompose() TWFE estimate in the castle-doctrine section"
- path: docs/tutorials/23_spillover_tva.ipynb
type: tutorial
note: "Naive headline = TWFE event_study=True post-period average"

# ── CallawaySantAnna (staggered group) ─────��────────────────────────

Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/01_basic_did.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@
"source": [
"## Two-Way Fixed Effects (TWFE)\n",
"\n",
"For panel data, the `TwoWayFixedEffects` estimator automatically includes both unit and time fixed effects using within-transformation."
"For panel data, the `TwoWayFixedEffects` estimator absorbs unit fixed effects and the pre/post period effect by within-transformation: the regressor is `treated x post`, and `post=` names the 0/1 post-treatment column. For period-by-period effects with calendar-time fixed effects use the event-study mode, `fit(..., unit=, time=, event_study=True, post_periods=)` (tutorials 04 and 05)."
]
},
{
Expand All @@ -321,7 +321,7 @@
" panel_df,\n",
" outcome=\"outcome\",\n",
" treatment=\"treated\",\n",
" time=\"period\", # Use actual time periods\n",
" post=\"post\", # 0/1 post-treatment indicator\n",
" unit=\"unit\"\n",
")\n",
"\n",
Expand Down
410 changes: 178 additions & 232 deletions docs/tutorials/02_staggered_did.ipynb

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions docs/tutorials/04_parallel_trends.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"source": [
"import numpy as np\n",
"import pandas as pd\n",
"from diff_diff import DifferenceInDifferences, MultiPeriodDiD\n",
"from diff_diff import DifferenceInDifferences, TwoWayFixedEffects\n",
"from diff_diff.utils import (\n",
" check_parallel_trends,\n",
" check_parallel_trends_robust,\n",
Expand Down Expand Up @@ -543,7 +543,7 @@
"source": [
"## Event Study as a Parallel Trends Check\n",
"\n",
"An **event study** shows period-by-period effects. Pre-treatment coefficients should be close to zero if parallel trends holds."
"An **event study** shows period-by-period effects. Pre-treatment coefficients should be close to zero if parallel trends holds. We fit a TWFE event study (`TwoWayFixedEffects().fit(..., event_study=True)`): unit and period fixed effects, one coefficient per period relative to the reference period 3."
]
},
{
Expand All @@ -552,13 +552,15 @@
"metadata": {},
"outputs": [],
"source": [
"# Event study\n",
"mp_did = MultiPeriodDiD()\n",
"event_results = mp_did.fit(\n",
"# Event study: unit + period fixed effects, one coefficient per period\n",
"twfe = TwoWayFixedEffects()\n",
"event_results = twfe.fit(\n",
" df_parallel,\n",
" outcome='outcome',\n",
" treatment='treated',\n",
" unit='unit',\n",
" time='period',\n",
" event_study=True,\n",
" post_periods=[4, 5, 6, 7],\n",
" reference_period=3 # Use period 3 as reference\n",
")\n",
Expand Down
12 changes: 7 additions & 5 deletions docs/tutorials/05_honest_did.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"source": [
"import numpy as np\n",
"import pandas as pd\n",
"from diff_diff import MultiPeriodDiD\n",
"from diff_diff import TwoWayFixedEffects\n",
"from diff_diff.honest_did import (\n",
" HonestDiD,\n",
" compute_honest_did,\n",
Expand Down Expand Up @@ -148,7 +148,7 @@
"source": [
"## Fit Standard Event Study\n",
"\n",
"First, let's estimate a standard event study using `MultiPeriodDiD`."
"First, let's estimate a standard event study with `TwoWayFixedEffects().fit(..., event_study=True)` (unit and period fixed effects; the last pre-period, 4, is the reference)."
]
},
{
Expand All @@ -158,13 +158,15 @@
"metadata": {},
"outputs": [],
"source": [
"# Fit event study\n",
"mp_did = MultiPeriodDiD()\n",
"event_results = mp_did.fit(\n",
"# Fit event study (unit + period fixed effects)\n",
"twfe = TwoWayFixedEffects()\n",
"event_results = twfe.fit(\n",
" df,\n",
" outcome='outcome',\n",
" treatment='treated',\n",
" unit='unit',\n",
" time='period',\n",
" event_study=True,\n",
" post_periods=[5, 6, 7, 8, 9]\n",
")\n",
"\n",
Expand Down
64 changes: 61 additions & 3 deletions docs/tutorials/07_pretrends_power.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"import pandas as pd\n",
"\n",
"from diff_diff import (\n",
" MultiPeriodDiD,\n",
" TwoWayFixedEffects,\n",
" PreTrendsPower,\n",
" compute_pretrends_power,\n",
" compute_mdv,\n",
Expand Down Expand Up @@ -131,7 +131,26 @@
"id": "cell-6",
"metadata": {},
"outputs": [],
"source": "# Fit event study with ALL periods (pre and post) relative to reference period\n# For pre-trends power analysis, we need coefficients for pre-periods too\nmp_did = MultiPeriodDiD()\n\n# Use period 4 as the reference period (last pre-period, excluded from estimation)\n# Specify post_periods as the actual post-treatment periods; MultiPeriodDiD\n# automatically estimates pre-period coefficients for the event study.\nevent_results = mp_did.fit(\n df,\n outcome='outcome',\n treatment='treated',\n time='period',\n post_periods=[5, 6, 7, 8, 9]\n)\n\nprint(event_results.summary())"
"source": [
"# Fit event study with ALL periods (pre and post) relative to reference period\n",
"# For pre-trends power analysis, we need coefficients for pre-periods too\n",
"twfe = TwoWayFixedEffects()\n",
"\n",
"# Use period 4 as the reference period (last pre-period, excluded from estimation)\n",
"# Specify post_periods as the actual post-treatment periods; the event-study mode\n",
"# estimates one coefficient per other period, pre-periods included.\n",
"event_results = twfe.fit(\n",
" df,\n",
" outcome='outcome',\n",
" treatment='treated',\n",
" unit='unit',\n",
" time='period',\n",
" event_study=True,\n",
" post_periods=[5, 6, 7, 8, 9]\n",
")\n",
"\n",
"print(event_results.summary())"
]
},
{
"cell_type": "code",
Expand Down Expand Up @@ -520,7 +539,46 @@
"id": "cell-30",
"metadata": {},
"outputs": [],
"source": "# Typical workflow for pre-trends power analysis\n\n# Step 1: Estimate event study with proper pre/post period classification\nmp_did = MultiPeriodDiD()\n\n# Specify actual post-treatment periods; pre-period coefficients are\n# estimated automatically by MultiPeriodDiD for the event study\npre_treatment_periods = [0, 1, 2, 3] # Define which are pre-treatment\n\nresults = mp_did.fit(\n df, \n outcome='outcome',\n treatment='treated', \n time='period',\n post_periods=[5, 6, 7, 8, 9]\n)\n\n# Step 2: Assess power of the pre-trends test \nprint(\"Step 2: Pre-Trends Power Analysis\")\npt = PreTrendsPower(alpha=0.05, power=0.80, violation_type='linear')\npt_results = pt.fit(results, pre_periods=pre_treatment_periods)\nprint(f\"MDV (80% power): {pt_results.mdv:.3f}\")\nprint(\"\")\n\n# Step 3: Interpret\nprint(\"Step 3: Interpretation\")\nprint(f\"Your pre-trends test could only detect violations >= {pt_results.mdv:.3f}\")\nprint(f\"Violations smaller than this would likely go undetected.\")\nprint(\"\")\n\n# Step 4: Connect to Honest DiD for robust inference\nprint(\"Step 4: Robust Inference with Honest DiD\")\nhonest = HonestDiD(method='smoothness', M=pt_results.mdv)\nhonest_results = honest.fit(results)\nprint(f\"Robust 95% CI (M=MDV): [{honest_results.ci_lb:.3f}, {honest_results.ci_ub:.3f}]\")\nprint(f\"Conclusion: {'Effect is robust' if honest_results.is_significant else 'Effect may not be robust'}\")"
"source": [
"# Typical workflow for pre-trends power analysis\n",
"\n",
"# Step 1: Estimate event study with proper pre/post period classification\n",
"twfe = TwoWayFixedEffects()\n",
"\n",
"# Specify actual post-treatment periods; the event-study mode estimates\n",
"# the pre-period coefficients automatically\n",
"pre_treatment_periods = [0, 1, 2, 3] # Define which are pre-treatment\n",
"\n",
"results = twfe.fit(\n",
" df, \n",
" outcome='outcome',\n",
" treatment='treated', \n",
" unit='unit',\n",
" time='period',\n",
" event_study=True,\n",
" post_periods=[5, 6, 7, 8, 9]\n",
")\n",
"\n",
"# Step 2: Assess power of the pre-trends test \n",
"print(\"Step 2: Pre-Trends Power Analysis\")\n",
"pt = PreTrendsPower(alpha=0.05, power=0.80, violation_type='linear')\n",
"pt_results = pt.fit(results, pre_periods=pre_treatment_periods)\n",
"print(f\"MDV (80% power): {pt_results.mdv:.3f}\")\n",
"print(\"\")\n",
"\n",
"# Step 3: Interpret\n",
"print(\"Step 3: Interpretation\")\n",
"print(f\"Your pre-trends test could only detect violations >= {pt_results.mdv:.3f}\")\n",
"print(f\"Violations smaller than this would likely go undetected.\")\n",
"print(\"\")\n",
"\n",
"# Step 4: Connect to Honest DiD for robust inference\n",
"print(\"Step 4: Robust Inference with Honest DiD\")\n",
"honest = HonestDiD(method='smoothness', M=pt_results.mdv)\n",
"honest_results = honest.fit(results)\n",
"print(f\"Robust 95% CI (M=MDV): [{honest_results.ci_lb:.3f}, {honest_results.ci_ub:.3f}]\")\n",
"print(f\"Conclusion: {'Effect is robust' if honest_results.is_significant else 'Effect may not be robust'}\")"
]
},
{
"cell_type": "markdown",
Expand Down
4 changes: 2 additions & 2 deletions docs/tutorials/08_triple_diff.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -673,8 +673,8 @@
"The staggered parameters are keyword-only, and mixing the two designs' parameters\n",
"raises rather than guessing which one you meant.\n",
"\n",
"> This replaces `StaggeredTripleDifference`, which is deprecated in 3.9 and\n",
"> removed in 4.0. It runs the same engine, so the numbers are unchanged; on the\n",
"> This replaces the former `StaggeredTripleDifference` class (deprecated in 3.9,\n",
"> removed at the 4.0 cut). It runs the same engine, so the numbers are unchanged; on the\n",
"> merged surface the third dimension is named `partition=` (not `eligibility=`)\n",
"> and `control_group` takes `\"not_yet_treated\"` / `\"never_treated\"`."
]
Expand Down
272 changes: 122 additions & 150 deletions docs/tutorials/09_real_world_examples.ipynb

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/tutorials/16_survey_did.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -1767,8 +1767,8 @@
"- **Diagnostic**: Weighted descriptive statistics only (no inference)\n",
"- **--**: Not supported\n",
"\n",
"**Migration note:** `StaggeredTripleDifference` is deprecated in 3.9 and removed in\n",
"4.0. It is not a separate capability - it runs the SAME staggered engine as the\n",
"**Migration note:** The former `StaggeredTripleDifference` class (removed at the 4.0\n",
"cut) was not a separate capability - it ran the SAME staggered engine as the\n",
"row above, reached by passing `first_treat=` to **`fit()`** (it is a fit\n",
"argument, not a constructor one):\n",
"\n",
Expand Down
57 changes: 31 additions & 26 deletions docs/tutorials/23_spillover_tva.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"from diff_diff import MultiPeriodDiD, SpilloverDiD\n",
"from diff_diff import SpilloverDiD, TwoWayFixedEffects\n",
"\n",
"MAIN_SEED = 23\n",
"N_TREATED = 25\n",
Expand Down Expand Up @@ -185,8 +185,12 @@
"source": [
"## The naive headline \u2014 multi-period TWFE on the full sample\n",
"\n",
"A practitioner reaching for a default DiD specification fits a\n",
"multi-period TWFE on the full panel, treating ALL non-treated-cluster\n",
"A practitioner reaching for a default DiD specification fits a TWFE\n",
"event study (`TwoWayFixedEffects().fit(..., event_study=True)`: unit and\n",
"period fixed effects, one coefficient per period relative to the last\n",
"pre-period, which is the reference) on the full panel and reports the\n",
"equal-weight average of the two post-period coefficients as the headline\n",
"ATT, treating ALL non-treated-cluster\n",
"units as controls \u2014 both the near-controls (which absorb spillover)\n",
"and the far-controls (which don't).\n"
]
Expand All @@ -198,28 +202,29 @@
"metadata": {},
"outputs": [],
"source": [
"naive = MultiPeriodDiD()\n",
"with warnings.catch_warnings():\n",
" # absorb=['unit'] makes the unit-invariant 'ever_treated' indicator\n",
" # perfectly collinear with unit FE; MultiPeriodDiD drops it (with a\n",
" # UserWarning) and identifies the ATT through the ever_treated \u00d7 post\n",
" # interaction columns. This is the expected TWFE specification.\n",
" warnings.filterwarnings(\"ignore\", category=UserWarning, message=\"Rank-deficient design matrix\")\n",
" naive_res = naive.fit(\n",
" df,\n",
" outcome=\"y\",\n",
" treatment=\"ever_treated\",\n",
" time=\"time\",\n",
" post_periods=[3, 4],\n",
" unit=\"unit\",\n",
" absorb=[\"unit\"],\n",
" reference_period=2, # explicit pre-period; matches the current MPD default\n",
" )\n",
"naive = TwoWayFixedEffects()\n",
"naive_es = naive.fit(\n",
" df,\n",
" outcome=\"y\",\n",
" treatment=\"ever_treated\",\n",
" unit=\"unit\",\n",
" time=\"time\",\n",
" event_study=True,\n",
" post_periods=[3, 4],\n",
" reference_period=2, # last pre-period\n",
")\n",
"\n",
"print(f\"Naive TWFE ATT: {naive_res.att:.4f} (true tau_total = {TAU_TOTAL})\")\n",
"print(f\" SE: {naive_res.se:.4f}\")\n",
"print(f\" Ratio to true: {naive_res.att / TAU_TOTAL:.3f}\")\n",
"print(f\" Understatement: {(1 - naive_res.att / TAU_TOTAL) * 100:.1f}%\")\n"
"# Headline ATT: equal-weight average of the post-period coefficients; its SE comes\n",
"# from the matching block of the event-study vcov (unit-clustered).\n",
"post_rows = np.isin(naive_es.event_time, naive_es.post_periods)\n",
"post_cols = np.isin(naive_es.vcov_index, naive_es.post_periods)\n",
"naive_att = float(naive_es.att[post_rows].mean())\n",
"naive_se = float(np.sqrt(naive_es.vcov[np.ix_(post_cols, post_cols)].sum()) / post_cols.sum())\n",
"\n",
"print(f\"Naive TWFE ATT: {naive_att:.4f} (true tau_total = {TAU_TOTAL})\")\n",
"print(f\" SE: {naive_se:.4f}\")\n",
"print(f\" Ratio to true: {naive_att / TAU_TOTAL:.3f}\")\n",
"print(f\" Understatement: {(1 - naive_att / TAU_TOTAL) * 100:.1f}%\")\n"
]
},
{
Expand Down Expand Up @@ -358,8 +363,8 @@
"\n",
"comparison = pd.DataFrame(\n",
" {\n",
" \"estimate\": [naive_res.att, spill.att, delta_1_row[\"coef\"]],\n",
" \"se\": [naive_res.se, spill.se, delta_1_row[\"se\"]],\n",
" \"estimate\": [naive_att, spill.att, delta_1_row[\"coef\"]],\n",
" \"se\": [naive_se, spill.se, delta_1_row[\"se\"]],\n",
" \"true value\": [TAU_TOTAL, TAU_TOTAL, DELTA_1],\n",
" },\n",
" index=[\"naive TWFE (direct, biased)\", \"SpilloverDiD tau_total\", \"SpilloverDiD delta_1\"],\n",
Expand Down
Loading
Loading