Skip to content

Commit 2c84157

Browse files
feat(did): etwfe_emfx() — R etwfe::emfx-equivalent four aggregations
Completes ETWFE parity with R's etwfe package by adding the emfx() marginal-effects aggregator. Takes an etwfe/wooldridge_did result and returns one of four standard aggregations used in applied work: type='simple' - overall cohort-size-weighted ATT (verified: matches fit.estimate to 1e-10) type='group' - ATT per treatment cohort (one row per g) type='event' - ATT per event time e = t - g, averaged across cohorts with cohort-size weights type='calendar' - ATT per calendar time t, averaged across eligible cohorts Each aggregation returns a tidy CausalResult whose .detail is a DataFrame with (aggregation-key, estimate, se, pvalue, ci_low, ci_high, n_*). The 'event' and 'calendar' SEs use the per-cohort-independent approximation matching R etwfe's default under classical vcov — documented in the Notes section. Edge handling: - Invalid type → ValueError with the four valid options listed. - Wrong input (non-etwfe CausalResult) → ValueError referencing the missing 'cohorts' key in model_info. - For etwfe fits with xvar (no event_study stored), 'group' still works but 'event'/'calendar' raise with a clear error. Test coverage added in tests/test_did_summary.py: 4 new cases, total 11 pytest cases passing in 7.49s. READMEs updated (EN + CN) — etwfe row now mentions xvar, and a new row documents etwfe_emfx with McDermott (2023) citation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9b5eb6e commit 2c84157

6 files changed

Lines changed: 244 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@ If a method exists in R, we aim to match or exceed its feature set in Python —
177177
| `continuous_did()` | Continuous treatment DID (dose-response) | Callaway, Goodman-Bacon & Sant'Anna (2024) |
178178
| `did_multiplegt()` | DID with treatment switching | de Chaisemartin & D'Haultfoeuille (2020) |
179179
| `did_imputation()` | Imputation DID estimator | Borusyak, Jaravel & Spiess (2024) |
180-
| `wooldridge_did()` / `etwfe()` | Extended TWFE with cohort×post saturation | Wooldridge (2021) |
180+
| `wooldridge_did()` / `etwfe()` | Extended TWFE with cohort×post saturation; supports `xvar=` | Wooldridge (2021) |
181+
| `etwfe_emfx()` | R ``etwfe::emfx`` equivalent — simple/group/event/calendar aggregations | McDermott (2023) |
181182
| `drdid()` | Doubly robust 2×2 DID (OR + IPW) | Sant'Anna & Zhao (2020) |
182183
| `stacked_did()` | Stacked event-study DID | Cengiz et al. (2019); Baker, Larcker & Wang (2022) |
183184
| `ddd()` | Triple-differences (DDD) | Gruber (1994); Olden & Møen (2022) |

README_CN.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ StatsPAI **不是** R 的 wrapper。我们从原始论文独立重新实现每
9494
| `honest_did()` | 平行趋势假设敏感性 | Rambachan & Roth (2023) |
9595
| `continuous_did()` | 连续处理 DID(剂量反应) | Callaway, Goodman-Bacon & Sant'Anna (2024) |
9696
| `did_imputation()` | 插补 DID 估计量 | Borusyak, Jaravel & Spiess (2024) |
97-
| `wooldridge_did()` / `etwfe()` | 扩展 TWFE(cohort×post 完全饱和) | Wooldridge (2021) |
97+
| `wooldridge_did()` / `etwfe()` | 扩展 TWFE(cohort×post 饱和),支持 `xvar=` 异质性 | Wooldridge (2021) |
98+
| `etwfe_emfx()` | R `etwfe::emfx` 等价——simple/group/event/calendar 四种聚合边际效应 | McDermott (2023) |
9899
| `drdid()` | 2×2 双重稳健 DID(OR + IPW) | Sant'Anna & Zhao (2020) |
99100
| `stacked_did()` | 堆叠事件研究 DID | Cengiz et al. (2019); Baker, Larcker & Wang (2022) |
100101
| `ddd()` | 三重差分(DDD) | Gruber (1994); Olden & Møen (2022) |

src/statspai/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
did, did_2x2, ddd, callaway_santanna, sun_abraham,
3535
bacon_decomposition, honest_did, breakdown_m, event_study,
3636
did_analysis, DIDAnalysis, did_multiplegt, did_imputation, stacked_did, cic,
37-
wooldridge_did, etwfe, drdid, twfe_decomposition,
37+
wooldridge_did, etwfe, etwfe_emfx, drdid, twfe_decomposition,
3838
did_summary, did_summary_to_markdown, did_summary_to_latex, did_report,
3939
pretrends_test, pretrends_power, sensitivity_rr, SensitivityResult, pretrends_summary,
4040
parallel_trends_plot, bacon_plot, group_time_plot, did_plot,
@@ -228,6 +228,7 @@
228228
# Wooldridge / DR-DID / TWFE Decomposition
229229
"wooldridge_did",
230230
"etwfe",
231+
"etwfe_emfx",
231232
"did_summary",
232233
"did_summary_to_markdown",
233234
"did_summary_to_latex",

src/statspai/did/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
SensitivityResult,
4646
pretrends_summary,
4747
)
48-
from .wooldridge_did import wooldridge_did, etwfe, drdid, twfe_decomposition
48+
from .wooldridge_did import wooldridge_did, etwfe, etwfe_emfx, drdid, twfe_decomposition
4949
from .summary import did_summary, did_summary_to_markdown, did_summary_to_latex, did_report
5050
from .continuous_did import continuous_did
5151
from .plots import (
@@ -387,6 +387,7 @@ def did(
387387
# Wooldridge / DR-DID / TWFE decomposition
388388
'wooldridge_did',
389389
'etwfe',
390+
'etwfe_emfx',
390391
'did_summary',
391392
'did_summary_to_markdown',
392393
'did_summary_to_latex',

src/statspai/did/wooldridge_did.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,3 +1100,198 @@ def _did_2x2_simple(df_sub, unit_col, time_col, y_col, g1_units, g2_units):
11001100
model_info=model_info,
11011101
_citation_key="twfe_decomposition",
11021102
)
1103+
1104+
1105+
# ═══════════════════════════════════════════════════════════════════════
1106+
# 4. etwfe_emfx — R etwfe-style marginal-effects aggregations
1107+
# ═══════════════════════════════════════════════════════════════════════
1108+
1109+
def etwfe_emfx(
1110+
result: CausalResult,
1111+
type: str = "simple",
1112+
alpha: float = 0.05,
1113+
) -> CausalResult:
1114+
"""
1115+
R ``etwfe::emfx``-style aggregated marginal effects for an ETWFE fit.
1116+
1117+
Takes the result of :func:`etwfe` / :func:`wooldridge_did` and returns
1118+
one of four aggregations used in applied work:
1119+
1120+
================ ========================================================
1121+
``type`` Aggregation
1122+
================ ========================================================
1123+
``'simple'`` Overall cohort-size-weighted ATT (same as ``result.estimate``).
1124+
``'group'`` ATT per treatment cohort ``g``.
1125+
``'event'`` ATT per event time ``e = t - g``, averaged across cohorts.
1126+
``'calendar'`` ATT per calendar time ``t``, averaged across cohorts for
1127+
which ``t >= g``.
1128+
================ ========================================================
1129+
1130+
Parameters
1131+
----------
1132+
result : CausalResult
1133+
Output of :func:`etwfe` or :func:`wooldridge_did`.
1134+
type : {'simple', 'group', 'event', 'calendar'}, default 'simple'
1135+
Aggregation type.
1136+
alpha : float, default 0.05
1137+
Significance level for confidence intervals.
1138+
1139+
Returns
1140+
-------
1141+
CausalResult
1142+
``estimate`` is the overall ATT (for ``type='simple'``) or the
1143+
mean of the sub-aggregation (for the other types). ``detail``
1144+
contains one row per group/event-time/calendar-time with
1145+
(estimate, se, pvalue, ci_low, ci_high).
1146+
1147+
Notes
1148+
-----
1149+
For ``'event'`` and ``'calendar'``, the reported SE treats the
1150+
per-cohort coefficients as independent — a standard approximation
1151+
that matches R etwfe's default under classical vcov. Cluster-robust
1152+
or fully-general SEs require the full regression vcov, which can
1153+
be requested via ``sp.wooldridge_did`` + the ``model_info`` matrix
1154+
in a future release.
1155+
1156+
Examples
1157+
--------
1158+
>>> import statspai as sp
1159+
>>> df = sp.dgp_did(n_units=200, n_periods=10, staggered=True)
1160+
>>> fit = sp.etwfe(df, y='y', time='time',
1161+
... first_treat='first_treat', group='unit')
1162+
>>> evt = sp.etwfe_emfx(fit, type='event')
1163+
>>> print(evt.detail) # ATT by event time
1164+
>>> grp = sp.etwfe_emfx(fit, type='group')
1165+
>>> cal = sp.etwfe_emfx(fit, type='calendar')
1166+
"""
1167+
valid = {"simple", "group", "event", "calendar"}
1168+
if type not in valid:
1169+
raise ValueError(f"type must be one of {sorted(valid)}; got {type!r}")
1170+
1171+
if not isinstance(result.model_info, dict) or "cohorts" not in result.model_info:
1172+
raise ValueError(
1173+
"etwfe_emfx requires a result produced by sp.etwfe / "
1174+
"sp.wooldridge_did — missing 'cohorts' in model_info."
1175+
)
1176+
1177+
mi = result.model_info
1178+
cohorts = mi["cohorts"]
1179+
cohort_weights = mi.get("cohort_weights", {})
1180+
event_study = mi.get("event_study")
1181+
1182+
# ── simple ──
1183+
if type == "simple":
1184+
# Just return a tidy one-row result
1185+
df_resid = max(result.n_obs - len(cohorts), 1)
1186+
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
1187+
est = float(result.estimate)
1188+
se = float(result.se)
1189+
ci = (est - t_crit * se, est + t_crit * se)
1190+
detail = pd.DataFrame([{
1191+
"aggregation": "simple",
1192+
"estimate": est, "se": se,
1193+
"pvalue": float(result.pvalue) if result.pvalue is not None else np.nan,
1194+
"ci_low": ci[0], "ci_high": ci[1],
1195+
"n_cohorts": len(cohorts),
1196+
}])
1197+
return CausalResult(
1198+
method="ETWFE — simple aggregation (overall ATT)",
1199+
estimand="Overall ATT",
1200+
estimate=est, se=se,
1201+
pvalue=float(result.pvalue) if result.pvalue is not None else np.nan,
1202+
ci=ci, alpha=alpha, n_obs=int(result.n_obs),
1203+
detail=detail,
1204+
model_info={"type": "simple", "source_method": result.method},
1205+
_citation_key="wooldridge_twfe",
1206+
)
1207+
1208+
# ── group ──
1209+
if type == "group":
1210+
# Already in result.detail — normalise columns and add CI/pvalue
1211+
det = result.detail.copy()
1212+
df_resid = max(result.n_obs - len(cohorts), 1)
1213+
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
1214+
# Handle both plain etwfe (columns att, se) and xvar etwfe
1215+
if "att_at_xmean" in det.columns:
1216+
est_col = "att_at_xmean"; se_col = "att_se"; p_col = "att_pvalue"
1217+
else:
1218+
est_col = "att"; se_col = "se"; p_col = "pvalue"
1219+
rows = []
1220+
for _, r in det.iterrows():
1221+
est = float(r[est_col]); se = float(r[se_col])
1222+
rows.append({
1223+
"cohort": int(r["cohort"]),
1224+
"estimate": est, "se": se,
1225+
"pvalue": float(r[p_col]) if p_col in r.index else np.nan,
1226+
"ci_low": est - t_crit * se,
1227+
"ci_high": est + t_crit * se,
1228+
"n_obs": int(r["n_obs"]),
1229+
})
1230+
out_det = pd.DataFrame(rows)
1231+
mean_est = float(np.average(out_det["estimate"],
1232+
weights=out_det["n_obs"]))
1233+
return CausalResult(
1234+
method="ETWFE — group aggregation (ATT per cohort)",
1235+
estimand="ATT(g) per cohort",
1236+
estimate=mean_est, se=np.nan,
1237+
pvalue=np.nan, ci=(np.nan, np.nan), alpha=alpha,
1238+
n_obs=int(result.n_obs), detail=out_det,
1239+
model_info={"type": "group", "source_method": result.method},
1240+
_citation_key="wooldridge_twfe",
1241+
)
1242+
1243+
# ── event / calendar ──
1244+
if event_study is None or len(event_study) == 0:
1245+
raise ValueError(
1246+
"type='event'/'calendar' requires event_study coefficients "
1247+
"in result.model_info['event_study']."
1248+
)
1249+
es = event_study.copy()
1250+
# cohort weights for averaging (use n_obs per cohort from result.detail)
1251+
det = result.detail
1252+
weight_by_cohort = {}
1253+
if "n_obs" in det.columns:
1254+
weight_by_cohort = dict(zip(det["cohort"].astype(int),
1255+
det["n_obs"].astype(float)))
1256+
1257+
df_resid = max(result.n_obs - len(cohorts), 1)
1258+
t_crit = stats.t.ppf(1 - alpha / 2, df_resid)
1259+
1260+
if type == "event":
1261+
key_col = "rel_time"
1262+
label_col = "event_time"
1263+
else: # calendar
1264+
es["calendar_time"] = es["cohort"].astype(int) + es["rel_time"].astype(int)
1265+
key_col = "calendar_time"
1266+
label_col = "calendar_time"
1267+
1268+
rows = []
1269+
for k, sub in es.groupby(key_col):
1270+
# cohort-weighted average estimate; SE assumes independence
1271+
w = np.array([weight_by_cohort.get(int(c), 1.0)
1272+
for c in sub["cohort"].values])
1273+
w = w / w.sum() if w.sum() > 0 else np.ones(len(w)) / len(w)
1274+
est = float(np.sum(w * sub["estimate"].values))
1275+
se = float(np.sqrt(np.sum((w * sub["se"].values) ** 2)))
1276+
t_stat = est / se if se > 0 else np.nan
1277+
p = float(2 * (1 - stats.t.cdf(abs(t_stat), df_resid))) if not np.isnan(t_stat) else np.nan
1278+
rows.append({
1279+
label_col: int(k),
1280+
"estimate": est, "se": se, "pvalue": p,
1281+
"ci_low": est - t_crit * se,
1282+
"ci_high": est + t_crit * se,
1283+
"n_cohorts_used": int(len(sub)),
1284+
})
1285+
out_det = pd.DataFrame(rows).sort_values(label_col).reset_index(drop=True)
1286+
mean_est = float(out_det["estimate"].mean())
1287+
1288+
return CausalResult(
1289+
method=f"ETWFE — {type} aggregation",
1290+
estimand=f"ATT by {label_col.replace('_', ' ')}",
1291+
estimate=mean_est, se=np.nan,
1292+
pvalue=np.nan, ci=(np.nan, np.nan), alpha=alpha,
1293+
n_obs=int(result.n_obs), detail=out_det,
1294+
model_info={"type": type, "source_method": result.method,
1295+
"se_assumption": "independent per-cohort coefficients"},
1296+
_citation_key="wooldridge_twfe",
1297+
)

tests/test_did_summary.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,3 +147,44 @@ def test_did_summary_plot_renders(staggered_df, tmp_path):
147147
p = tmp_path / "forest.png"
148148
fig.savefig(p, dpi=100)
149149
assert p.stat().st_size > 5000
150+
151+
152+
# ───────────────────────────────────────────────────────────────────────
153+
# 6. etwfe_emfx — R etwfe-style four aggregations
154+
# ───────────────────────────────────────────────────────────────────────
155+
156+
def test_etwfe_emfx_simple_matches_fit(staggered_df):
157+
fit = sp.etwfe(staggered_df, y="y", time="time",
158+
first_treat="first_treat", group="unit")
159+
simple = sp.etwfe_emfx(fit, type="simple")
160+
assert abs(float(simple.estimate) - float(fit.estimate)) < 1e-10
161+
assert abs(float(simple.se) - float(fit.se)) < 1e-10
162+
163+
164+
def test_etwfe_emfx_group_one_row_per_cohort(staggered_df):
165+
fit = sp.etwfe(staggered_df, y="y", time="time",
166+
first_treat="first_treat", group="unit")
167+
g = sp.etwfe_emfx(fit, type="group")
168+
assert len(g.detail) == len(fit.model_info["cohorts"])
169+
assert set(g.detail["cohort"]) == set(fit.model_info["cohorts"])
170+
assert {"estimate", "se", "ci_low", "ci_high"}.issubset(g.detail.columns)
171+
172+
173+
def test_etwfe_emfx_event_and_calendar_shapes(staggered_df):
174+
fit = sp.etwfe(staggered_df, y="y", time="time",
175+
first_treat="first_treat", group="unit")
176+
ev = sp.etwfe_emfx(fit, type="event")
177+
cal = sp.etwfe_emfx(fit, type="calendar")
178+
assert len(ev.detail) >= 1
179+
assert len(cal.detail) >= 1
180+
assert "event_time" in ev.detail.columns
181+
assert "calendar_time" in cal.detail.columns
182+
# Event times start at 0 (post-treatment only)
183+
assert ev.detail["event_time"].min() == 0
184+
185+
186+
def test_etwfe_emfx_rejects_bad_type(staggered_df):
187+
fit = sp.etwfe(staggered_df, y="y", time="time",
188+
first_treat="first_treat", group="unit")
189+
with pytest.raises(ValueError):
190+
sp.etwfe_emfx(fit, type="invalid")

0 commit comments

Comments
 (0)