{ "cells": [ { "cell_type": "markdown", "id": "a1b2c3d4", "metadata": {}, "source": "# Wooldridge Extended Two-Way Fixed Effects (ETWFE)\n\nThis tutorial demonstrates the `WooldridgeDiD` estimator (alias: `ETWFE`), which implements Wooldridge's (2021, 2023) Extended Two-Way Fixed Effects approach — the basis of the Stata `jwdid` package.\n\n**What ETWFE does:** Estimates cohort×time Average Treatment Effects (ATT(g,t)) via a single saturated regression that interacts treatment indicators with cohort×time cells. Unlike standard TWFE, it correctly handles heterogeneous treatment effects across cohorts and time periods. The key insight is to include all cohort×time interaction terms simultaneously, with unit and time fixed effects absorbed via within-transformation.\n\n**Key features:**\n- Follows the Stata `jwdid` specification (OLS and nonlinear paths; see Methodology Registry for documented SE/aggregation deviations)\n- Supports **linear (OLS)**, **Poisson**, and **logit** link functions\n- Nonlinear ATTs use the Average Structural Function (ASF): E[f(η₁)] − E[f(η₀)]\n- Delta-method standard errors for all aggregations\n- Cluster-robust sandwich variance\n\n**Topics covered:**\n1. Basic OLS estimation\n2. Cohort×time cell estimates ATT(g,t)\n3. Aggregation: event-study, group, simple\n4. Poisson QMLE for count / non-negative outcomes\n5. Logit for binary outcomes\n6. Comparison with Callaway-Sant'Anna\n7. Parameter reference and guidance\n\n*Prerequisites: [Tutorial 02](02_staggered_did.ipynb) (Staggered DiD).*\n\n*See also: [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD, [Tutorial 11](11_imputation_did.ipynb) for Imputation DiD.*" }, { "cell_type": "code", "execution_count": null, "id": "b2c3d4e5", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "from diff_diff import WooldridgeDiD, CallawaySantAnna, generate_staggered_data\n", "\n", "try:\n", " import matplotlib.pyplot as plt\n", " plt.style.use('seaborn-v0_8-whitegrid')\n", " HAS_MATPLOTLIB = True\n", "except ImportError:\n", " HAS_MATPLOTLIB = False\n", " print(\"matplotlib not installed - visualization examples will be skipped\")" ] }, { "cell_type": "markdown", "id": "c3d4e5f6", "metadata": {}, "source": [ "## Data Setup\n", "\n", "We use `generate_staggered_data()` to create a balanced panel with 3 treatment cohorts, a never-treated group, and a known ATT of 2.0. This makes it easy to verify estimation accuracy.\n", "\n", "We also demonstrate with the **mpdta** dataset (Callaway & Sant'Anna 2021), which contains county-level log employment data with staggered minimum-wage adoption — the canonical benchmark for staggered DiD methods." ] }, { "cell_type": "code", "execution_count": null, "id": "d4e5f6a7", "metadata": {}, "outputs": [], "source": [ "# Simulated data\n", "data = generate_staggered_data(\n", " n_units=300, n_periods=10, treatment_effect=2.0,\n", " dynamic_effects=False, seed=42\n", ")\n", "\n", "print(f\"Shape: {data.shape}\")\n", "print(f\"Cohorts: {sorted(data['first_treat'].unique())}\")\n", "print(f\"Periods: {sorted(data['period'].unique())}\")\n", "print()\n", "data.head()" ] }, { "cell_type": "markdown", "id": "e5f6a7b8", "metadata": {}, "source": "## Basic OLS Estimation\n\nThe default `method='ols'` fits a single regression with:\n- Treatment interaction dummies (one per treatment cohort x post-treatment period cell)\n- Unit fixed effects (absorbed via within-transformation)\n- Time fixed effects (absorbed via within-transformation)\n\nWith `control_group='not_yet_treated'` (default), pre-treatment observations from treated units sit in the regression baseline alongside not-yet-treated controls. With `control_group='never_treated'`, pre-treatment interaction indicators are added so only never-treated units define the counterfactual baseline, and pre-treatment coefficients serve as placebo checks." }, { "cell_type": "code", "execution_count": null, "id": "f6a7b8c9", "metadata": {}, "outputs": [], "source": [ "m = WooldridgeDiD() # default: method='ols'\n", "r = m.fit(data, outcome='outcome', unit='unit', time='period', cohort='first_treat')\n", "\n", "# Compute aggregations\n", "r.aggregate('event').aggregate('group').aggregate('simple')\n", "\n", "print(r.summary('simple'))" ] }, { "cell_type": "markdown", "id": "a7b8c9d0", "metadata": {}, "source": [ "## Cohort×Time Cell Estimates ATT(g,t)\n", "\n", "The raw building blocks are ATT(g,t) — the treatment effect for cohort `g` at calendar time `t`. These are stored in `r.group_time_effects` and correspond to Stata's regression output table (`first_treat#year#c.__tr__`).\n", "\n", "Post-treatment cells have `t >= g`; pre-treatment cells (`t < g`) serve as placebo checks." ] }, { "cell_type": "code", "execution_count": null, "id": "b8c9d0e1", "metadata": {}, "outputs": [], "source": [ "print(\"Post-treatment ATT(g,t) cells\")\n", "print(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n", " \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\n", "print(\"-\" * 60)\n", "\n", "for (g, t), v in sorted(r.group_time_effects.items()):\n", " if t < g:\n", " continue\n", " row = \"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n", " int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']\n", " )\n", " print(row)" ] }, { "cell_type": "code", "execution_count": null, "id": "c9d0e1f2", "metadata": {}, "outputs": [], "source": [ "# Also show pre-treatment placebo cells\n", "print(\"Pre-treatment placebo ATT(g,t) cells (should be ~0 under parallel trends)\")\n", "print(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n", " \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\n", "print(\"-\" * 60)\n", "\n", "for (g, t), v in sorted(r.group_time_effects.items()):\n", " if t >= g:\n", " continue\n", " row = \"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n", " int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']\n", " )\n", " print(row)" ] }, { "cell_type": "markdown", "id": "d0e1f2a3", "metadata": {}, "source": [ "## Aggregation Methods\n", "\n", "ETWFE supports four aggregation types, matching Stata's `estat` post-estimation commands:\n", "\n", "| Python | Stata | Description |\n", "|--------|-------|-------------|\n", "| `aggregate('event')` | `estat event` | By relative time k = t − g |\n", "| `aggregate('group')` | `estat group` | By treatment cohort g |\n", "| `aggregate('calendar')` | `estat calendar` | By calendar time t |\n", "| `aggregate('simple')` | `estat simple` | Overall weighted average ATT |\n", "\n", "Standard errors use the delta method, propagating uncertainty from the cell-level ATT covariance matrix." ] }, { "cell_type": "code", "execution_count": null, "id": "e1f2a3b4", "metadata": {}, "outputs": [], "source": [ "# Event-study aggregation: ATT by relative time k = t - g\n", "print(r.summary('event'))" ] }, { "cell_type": "code", "execution_count": null, "id": "f2a3b4c5", "metadata": {}, "outputs": [], "source": [ "# Group aggregation: ATT averaged across post-treatment periods for each cohort\n", "print(r.summary('group'))" ] }, { "cell_type": "code", "execution_count": null, "id": "a3b4c5d6", "metadata": {}, "outputs": [], "source": [ "# Simple ATT: overall weighted average\n", "print(r.summary('simple'))" ] }, { "cell_type": "code", "execution_count": null, "id": "b4c5d6e7", "metadata": {}, "outputs": [], "source": [ "# Event study plot\n", "if HAS_MATPLOTLIB:\n", " es = r.event_study_effects\n", " ks = sorted(es.keys())\n", " atts = [es[k]['att'] for k in ks]\n", " lo = [es[k]['conf_int'][0] for k in ks]\n", " hi = [es[k]['conf_int'][1] for k in ks]\n", "\n", " fig, ax = plt.subplots(figsize=(9, 5))\n", " ax.errorbar(ks, atts, yerr=[np.array(atts) - np.array(lo), np.array(hi) - np.array(atts)],\n", " fmt='o-', capsize=4, color='steelblue', label='ETWFE (OLS)')\n", " ax.axhline(0, color='black', linestyle='--', linewidth=0.8)\n", " ax.axvline(-0.5, color='red', linestyle=':', linewidth=0.8, label='Treatment onset')\n", " ax.set_xlabel('Relative period (k = t − g)')\n", " ax.set_ylabel('ATT')\n", " ax.set_title('ETWFE Event Study')\n", " ax.legend()\n", " plt.tight_layout()\n", " plt.show()\n", "else:\n", " print(\"Install matplotlib to see the event study plot: pip install matplotlib\")" ] }, { "cell_type": "markdown", "id": "c5d6e7f8", "metadata": {}, "source": [ "## Poisson QMLE for Count / Non-Negative Outcomes\n", "\n", "`method='poisson'` fits a Poisson QMLE regression. This is valid for any non-negative continuous outcome, not just count data — the Poisson log-likelihood produces consistent estimates whenever the conditional mean is correctly specified as exp(Xβ).\n", "\n", "The ATT is computed as the **Average Structural Function (ASF) difference**:\n", "\n", "$$\\text{ATT}(g,t) = \\frac{1}{N_{g,t}} \\sum_{i \\in g,t} \\left[\\exp(\\eta_{i,1}) - \\exp(\\eta_{i,0})\\right]$$\n", "\n", "where η₁ = Xβ (with treatment) and η₀ = Xβ − δ (counterfactual without treatment).\n", "\n", "This matches Stata's `jwdid y, method(poisson)`." ] }, { "cell_type": "code", "execution_count": null, "id": "d6e7f8a9", "metadata": {}, "outputs": [], "source": [ "# Simulate a non-negative outcome (e.g., employment level)\n", "data_pois = data.copy()\n", "data_pois['emp'] = np.exp(data_pois['outcome'] / 4 + 3) # positive outcome\n", "\n", "m_pois = WooldridgeDiD(method='poisson')\n", "r_pois = m_pois.fit(data_pois, outcome='emp', unit='unit', time='period', cohort='first_treat')\n", "r_pois.aggregate('event').aggregate('group').aggregate('simple')\n", "\n", "print(r_pois.summary('simple'))" ] }, { "cell_type": "code", "execution_count": null, "id": "e7f8a9b0", "metadata": {}, "outputs": [], "source": [ "# Cohort×time cells (post-treatment, Poisson)\n", "print(\"Poisson ATT(g,t) — post-treatment cells\")\n", "print(\"{:>8} {:>8} | {:>10} {:>10} {:>7} {:>7}\".format(\n", " \"cohort\", \"year\", \"ATT\", \"Std.Err.\", \"t\", \"P>|t|\"))\n", "print(\"-\" * 60)\n", "\n", "for (g, t), v in sorted(r_pois.group_time_effects.items()):\n", " if t < g:\n", " continue\n", " print(\"{:>8} {:>8} | {:>10.4f} {:>10.4f} {:>7.2f} {:>7.3f}\".format(\n", " int(g), int(t), v['att'], v['se'], v['t_stat'], v['p_value']\n", " ))" ] }, { "cell_type": "code", "execution_count": null, "id": "f8a9b0c1", "metadata": {}, "outputs": [], "source": [ "print(r_pois.summary('event'))\n", "print(r_pois.summary('group'))" ] }, { "cell_type": "markdown", "id": "a9b0c1d2", "metadata": {}, "source": [ "## Logit for Binary Outcomes\n", "\n", "`method='logit'` fits a logit model and computes ATT as the ASF probability difference:\n", "\n", "$$\\text{ATT}(g,t) = \\frac{1}{N_{g,t}} \\sum_{i \\in g,t} \\left[\\Lambda(\\eta_{i,1}) - \\Lambda(\\eta_{i,0})\\right]$$\n", "\n", "where Λ(·) is the logistic function. Standard errors use the delta method.\n", "\n", "This matches Stata's `jwdid y, method(logit)`." ] }, { "cell_type": "code", "execution_count": null, "id": "b0c1d2e3", "metadata": {}, "outputs": [], "source": [ "# Create a binary outcome\n", "data_logit = data.copy()\n", "median_val = data_logit.loc[data_logit['period'] == data_logit['period'].min(), 'outcome'].median()\n", "data_logit['hi_outcome'] = (data_logit['outcome'] > median_val).astype(int)\n", "\n", "print(f\"Binary outcome mean: {data_logit['hi_outcome'].mean():.3f}\")\n", "\n", "m_logit = WooldridgeDiD(method='logit')\n", "r_logit = m_logit.fit(data_logit, outcome='hi_outcome', unit='unit', time='period', cohort='first_treat')\n", "r_logit.aggregate('event').aggregate('group').aggregate('simple')\n", "\n", "print(r_logit.summary('simple'))" ] }, { "cell_type": "code", "execution_count": null, "id": "c1d2e3f4", "metadata": {}, "outputs": [], "source": [ "print(r_logit.summary('group'))" ] }, { "cell_type": "markdown", "id": "d2e3f4a5", "metadata": {}, "source": "## mpdta: Real-World Example\n\nThe **mpdta** dataset (Callaway & Sant'Anna 2021) contains county-level log employment (`lemp`) data with staggered minimum-wage adoption (`first_treat` = year of treatment, 0 = never treated). It is the canonical benchmark for staggered DiD methods.\n\nThis follows Stata's `jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)` specification. See the Methodology Registry for documented SE/aggregation deviations." }, { "cell_type": "code", "execution_count": null, "id": "e3f4a5b6", "metadata": {}, "outputs": [], "source": "from diff_diff import load_mpdta\n\nmpdta = load_mpdta()\nprint(f\"mpdta loaded: {mpdta.shape}\")\nprint(f\"Cohorts: {sorted(mpdta['first_treat'].unique())}\")" }, { "cell_type": "code", "execution_count": null, "id": "f4a5b6c7", "metadata": {}, "outputs": [], "source": "# OLS — matches: jwdid lemp, ivar(countyreal) tvar(year) gvar(first_treat)\nm_ols = WooldridgeDiD(method='ols')\nr_ols = m_ols.fit(mpdta, outcome='lemp', unit='countyreal', time='year', cohort='first_treat')\nr_ols.aggregate('event').aggregate('group').aggregate('simple')\nprint(r_ols.summary('event'))" }, { "cell_type": "code", "execution_count": null, "id": "a5b6c7d8", "metadata": {}, "outputs": [], "source": "# cohort x time ATT cells (post-treatment)\n# Matches Stata: first_treat#year#c.__tr__ output table\nprint(\"ATT(g,t) — post-treatment cells (matches Stata jwdid output)\")\nprint(\"{:>6} {:>6} | {:>9} {:>9} {:>7} {:>7}\".format(\n \"cohort\", \"year\", \"Coef.\", \"Std.Err.\", \"t\", \"P>|t|\"))\nprint(\"-\" * 55)\nfor (g, t), v in sorted(r_ols.group_time_effects.items()):\n if t < g:\n continue\n print(\"{:>6} {:>6} | {:>9.4f} {:>9.4f} {:>7.2f} {:>7.3f}\".format(\n g, t, v['att'], v['se'], v['t_stat'], v['p_value']))" }, { "cell_type": "code", "execution_count": null, "id": "b6c7d8e9", "metadata": {}, "outputs": [], "source": "# Poisson — matches: gen emp=exp(lemp) / jwdid emp, method(poisson)\nmpdta['emp'] = np.exp(mpdta['lemp'])\n\nm_pois2 = WooldridgeDiD(method='poisson')\nr_pois2 = m_pois2.fit(mpdta, outcome='emp', unit='countyreal', time='year', cohort='first_treat')\nr_pois2.aggregate('event').aggregate('group').aggregate('simple')\n\nprint(r_pois2.summary('event'))\nprint(r_pois2.summary('group'))\nprint(r_pois2.summary('simple'))" }, { "cell_type": "markdown", "id": "c7d8e9f0", "metadata": {}, "source": [ "## Comparison with Callaway-Sant'Anna\n", "\n", "ETWFE and Callaway-Sant'Anna are both valid for staggered designs. Under homogeneous treatment effects and additive parallel trends, they should produce similar ATT(g,t) point estimates. Key differences:\n", "\n", "| Aspect | WooldridgeDiD (ETWFE) | CallawaySantAnna |\n", "|--------|----------------------|------------------|\n", "| Approach | Single saturated regression | Separate 2×2 DiD per cell |\n", "| Nonlinear outcomes | Yes (Poisson, Logit) | No |\n", "| Covariates | Via regression (linear index) | OR, IPW, DR |\n", "| SE for aggregations | Delta method | Multiplier bootstrap |\n", "| Stata equivalent | `jwdid` | `csdid` |" ] }, { "cell_type": "code", "execution_count": null, "id": "d8e9f0a1", "metadata": {}, "outputs": [], "source": "# Compare overall ATT: ETWFE vs Callaway-Sant'Anna\ncs = CallawaySantAnna()\nr_cs = cs.fit(data, outcome='outcome', unit='unit', time='period', first_treat='first_treat')\n\nm_etwfe = WooldridgeDiD(method='ols')\nr_etwfe = m_etwfe.fit(data, outcome='outcome', unit='unit', time='period', cohort='first_treat')\nr_etwfe.aggregate('event').aggregate('simple')\n\nprint(\"Overall ATT Comparison (true effect = 2.0)\")\nprint(\"=\" * 60)\nprint(\"{:<25} {:>10} {:>10} {:>12}\".format(\"Estimator\", \"ATT\", \"SE\", \"95% CI\"))\nprint(\"-\" * 60)\n\nfor name, est_r in [(\"WooldridgeDiD (ETWFE)\", r_etwfe), (\"CallawaySantAnna\", r_cs)]:\n ci = est_r.overall_conf_int\n print(\"{:<25} {:>10.4f} {:>10.4f} [{:.3f}, {:.3f}]\".format(\n name, est_r.overall_att, est_r.overall_se, ci[0], ci[1]\n ))" }, { "cell_type": "code", "execution_count": null, "id": "e9f0a1b2", "metadata": {}, "outputs": [], "source": [ "# Event-study comparison\n", "r_cs_es = CallawaySantAnna().fit(\n", " data, outcome='outcome', unit='unit', time='period',\n", " first_treat='first_treat', aggregate='event_study'\n", ")\n", "\n", "if HAS_MATPLOTLIB:\n", " es_etwfe = r_etwfe.event_study_effects\n", " es_cs = {int(row['relative_period']): row\n", " for _, row in r_cs_es.to_dataframe(level='event_study').iterrows()}\n", "\n", " ks = sorted(es_etwfe.keys())\n", "\n", " fig, ax = plt.subplots(figsize=(10, 5))\n", " offset = 0.1\n", "\n", " atts_e = [es_etwfe[k]['att'] for k in ks]\n", " lo_e = [es_etwfe[k]['conf_int'][0] for k in ks]\n", " hi_e = [es_etwfe[k]['conf_int'][1] for k in ks]\n", " ax.errorbar([k - offset for k in ks], atts_e,\n", " yerr=[np.array(atts_e) - np.array(lo_e), np.array(hi_e) - np.array(atts_e)],\n", " fmt='o-', capsize=4, color='steelblue', label='ETWFE')\n", "\n", " ks_cs = sorted(es_cs.keys())\n", " atts_cs = [es_cs[k]['effect'] for k in ks_cs]\n", " lo_cs = [es_cs[k]['conf_int_lower'] for k in ks_cs]\n", " hi_cs = [es_cs[k]['conf_int_upper'] for k in ks_cs]\n", " ax.errorbar([k + offset for k in ks_cs], atts_cs,\n", " yerr=[np.array(atts_cs) - np.array(lo_cs), np.array(hi_cs) - np.array(atts_cs)],\n", " fmt='s--', capsize=4, color='darkorange', label='Callaway-Sant\\'Anna')\n", "\n", " ax.axhline(0, color='black', linestyle='--', linewidth=0.8)\n", " ax.axvline(-0.5, color='red', linestyle=':', linewidth=0.8)\n", " ax.set_xlabel('Relative period (k = t − g)')\n", " ax.set_ylabel('ATT')\n", " ax.set_title('Event Study: ETWFE vs Callaway-Sant\\'Anna')\n", " ax.legend()\n", " plt.tight_layout()\n", " plt.show()\n", "else:\n", " print(\"Install matplotlib to see the comparison plot: pip install matplotlib\")" ] }, { "cell_type": "markdown", "id": "f0a1b2c3", "metadata": {}, "source": "## Summary\n\n**Key takeaways:**\n\n1. **ETWFE via a single regression**: all ATT(g,t) cells estimated jointly, not separately — computationally efficient and internally consistent\n2. **OLS path** follows the Stata `jwdid` specification: unit + time FEs (absorbed via within-transformation), treatment interaction dummies\n3. **Nonlinear paths** (Poisson, Logit) use the ASF formula: E[f(η₁)] − E[f(η₀)] — the only valid ATT definition for nonlinear models\n4. **Four aggregations** mirror Stata's `estat` commands: event, group, calendar, simple\n5. **Delta-method SEs** for all aggregations, including nonlinear paths\n6. **When to prefer ETWFE**: nonlinear outcomes, or when a single-regression framework is preferred\n7. **When to prefer CS/ImputationDiD**: covariate adjustment via IPW/DR, or multiplier bootstrap inference\n\n**Parameter reference:**\n\n| Parameter | Default | Description |\n|-----------|---------|-------------|\n| `method` | `'ols'` | `'ols'`, `'poisson'`, or `'logit'` |\n| `control_group` | `'not_yet_treated'` | `'not_yet_treated'` or `'never_treated'` |\n| `anticipation` | `0` | Anticipation periods before treatment |\n| `alpha` | `0.05` | Significance level |\n| `cluster` | `None` | Column for clustering (default: unit variable) |\n\n**References:**\n- Wooldridge, J. M. (2021). Two-Way Fixed Effects, the Two-Way Mundlak Regression, and Difference-in-Differences Estimators. *SSRN 3906345*.\n- Wooldridge, J. M. (2023). Simple approaches to nonlinear difference-in-differences with panel data. *The Econometrics Journal*, 26(3), C31–C66.\n- Friosavila, F. (2021). `jwdid`: Stata module for ETWFE. SSC s459114.\n\n*See also: [Tutorial 02](02_staggered_did.ipynb) for Callaway-Sant'Anna, [Tutorial 15](15_efficient_did.ipynb) for Efficient DiD.*" } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.10.0" } }, "nbformat": 4, "nbformat_minor": 5 }