Skip to content

Commit dd52d27

Browse files
igerberclaude
andcommitted
Round 18: fix sigma_fe to use paper w_{g,t} (not contribution weights)
P1: Round 17's sigma_fe fix still used the contribution weights w_gt = s * w_paper as if they were the paper's w_{g,t} object. The paper's Corollary 1 defines w_{g,t} = eps / E_treated[eps], which does NOT include the observation-share factor in the numerator. The contribution weights (which include the share factor) are the right object for the TWFE decomposition itself, but sigma_fe needs the paper's w_{g,t} centered at 1. The fix computes w_paper separately: w_paper = eps_treated / sum(s * eps_treated) sigma(w) = sqrt(sum(s * (w_paper - 1)^2)) sigma_fe = |beta_fe| / sigma(w) The exported weights column still contains the contribution weights (the TWFE decomposition object), not the paper weights. P2: added test_twfe_diagnostic_hand_checkable_sigma_fe in TestTWFEDiagnostic with a 4-group 3-period staggered panel. Asserts beta_fe = 3.5, fraction_negative = 0.0, and sigma_fe = 6.8641 numerically. This test would have caught both the Round 17 and pre-Round-17 sigma_fe formula errors. Test counts: 119 -> 120. Black, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 62b0f93 commit dd52d27

2 files changed

Lines changed: 73 additions & 9 deletions

File tree

diff_diff/chaisemartin_dhaultfoeuille.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2091,18 +2091,30 @@ def _compute_twfe_diagnostic(
20912091

20922092
# Step 6: sigma_fe per Corollary 1 of AER 2020
20932093
#
2094-
# sigma_fe = |beta_fe| / sigma(w), where sigma(w) is the
2095-
# observation-share-weighted standard deviation of w_{g,t} across
2096-
# treated cells, using shares s_{g,t} = N_{g,t} / N_1 where
2097-
# N_1 = sum of N_{g,t} over treated cells. This matches the
2098-
# paper's population-expectation formula for sigma(w).
2099-
w_treated = w_gt[treated_mask]
2094+
# The paper defines w_{g,t} = eps_{g,t} / E_treated[eps], which
2095+
# is DIFFERENT from the contribution weights w_gt exported in the
2096+
# weights DataFrame (contribution_weight = s * w_paper). The paper
2097+
# weight has the property that sum(s * w_paper) = 1 (centered at
2098+
# 1 under observation-share weighting). sigma_fe uses the paper
2099+
# weight:
2100+
#
2101+
# w_paper = eps / sum_treated(s * eps)
2102+
# sigma(w) = sqrt(sum_treated(s * (w_paper - 1)^2))
2103+
# sigma_fe = |beta_fe| / sigma(w)
2104+
#
2105+
# where s_{g,t} = N_{g,t} / N_1 are observation shares.
2106+
eps_treated = eps[treated_mask]
21002107
n_treated_arr = n_arr[treated_mask]
21012108
n1 = float(n_treated_arr.sum()) # total treated observations
21022109
if n1 > 0:
2103-
shares = n_treated_arr / n1 # observation shares
2104-
w_bar = float((shares * w_treated).sum())
2105-
var_w = float((shares * (w_treated - w_bar) ** 2).sum())
2110+
shares = n_treated_arr / n1 # s_{g,t} = N_{g,t} / N_1
2111+
denom_paper = float((shares * eps_treated).sum())
2112+
if abs(denom_paper) > 0:
2113+
w_paper = eps_treated / denom_paper # paper's w_{g,t}
2114+
# Weighted variance around 1 (the weighted mean of w_paper is 1 by construction)
2115+
var_w = float((shares * (w_paper - 1.0) ** 2).sum())
2116+
else:
2117+
var_w = 0.0
21062118
else:
21072119
var_w = 0.0
21082120
if var_w > 0 and np.isfinite(beta_fe):

tests/test_methodology_chaisemartin_dhaultfoeuille.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,58 @@ def test_twfe_diagnostic_runs_on_real_data(self):
445445
# For simplicity, just verify the weights array is not all zero
446446
assert (weights_df["weight"] != 0).any()
447447

448+
def test_twfe_diagnostic_hand_checkable_sigma_fe(self):
449+
"""
450+
Hand-checkable TWFE diagnostic on a 4-group 3-period panel with
451+
staggered treatment (g1 at t=1, g2 at t=2, g3-g4 never).
452+
453+
Expected values computed analytically (equal cell sizes):
454+
- beta_fe = 3.5 (TWFE coefficient from OLS of y on FE + d)
455+
- Treated cells: (g1,t1), (g1,t2), (g2,t2) with contribution
456+
weights [0.4, 0.1, 0.5]
457+
- Paper weights w_{g,t} (Corollary 1): [1.2, 0.3, 1.5]
458+
(contribution_weight / share, centered at 1.0)
459+
- sigma(w) = sqrt(sum(s * (w_paper - 1)^2)) = 0.5099
460+
- sigma_fe = |3.5| / 0.5099 = 6.8641
461+
- fraction_negative = 0.0 (all treated weights positive)
462+
"""
463+
df = pd.DataFrame(
464+
{
465+
"group": [1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4],
466+
"period": [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2],
467+
"treatment": [0, 1, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0],
468+
"outcome": [
469+
10,
470+
14,
471+
15,
472+
10,
473+
11,
474+
16,
475+
10,
476+
11,
477+
12,
478+
10,
479+
11,
480+
12,
481+
],
482+
}
483+
)
484+
from diff_diff import twowayfeweights
485+
486+
result = twowayfeweights(
487+
df,
488+
outcome="outcome",
489+
group="group",
490+
time="period",
491+
treatment="treatment",
492+
)
493+
# beta_fe: the plain TWFE coefficient
494+
assert result.beta_fe == pytest.approx(3.5, abs=0.01)
495+
# fraction_negative: all treated weights positive
496+
assert result.fraction_negative == pytest.approx(0.0)
497+
# sigma_fe: the Corollary 1 sign-flip threshold
498+
assert result.sigma_fe == pytest.approx(6.8641, abs=0.01)
499+
448500
def test_twfe_disabled_means_none(self):
449501
data = generate_reversible_did_data(n_groups=30, n_periods=4, seed=1)
450502
est = ChaisemartinDHaultfoeuille(twfe_diagnostic=False)

0 commit comments

Comments
 (0)