Skip to content

Commit 2c77702

Browse files
igerberclaude
andcommitted
Fix EIF centering for per-unit weights, add overlap diagnostics
P0 fix: EIF now centers on scalar ATT (EIF_i = w_i @ gen_out_i - ATT) instead of per-pair means, ensuring mean(EIF) ≈ 0 when weights vary by unit. Added unit test verifying mean-zero property. P1 fix: Add overlap warning when sieve ratios require clipping, with test asserting the warning fires on near-separation covariates. P1 fix: Document unconditional-pi Omega* as deviation in REGISTRY.md; downgrade "efficient" claim in docstring for covariate path. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 74d7723 commit 2c77702

4 files changed

Lines changed: 55 additions & 26 deletions

File tree

diff_diff/efficient_did.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ class EfficientDiD(EfficientDiDBootstrapMixin):
5454
sample means and covariances. With covariates, uses the doubly robust
5555
path: sieve-based propensity score ratios (Eq 4.1-4.2) with AIC/BIC
5656
selection, OLS outcome regression, and kernel-smoothed conditional
57-
Omega*(X) for per-unit efficient weights.
57+
Omega*(X) for per-unit efficient weights. The conditional Omega*
58+
currently uses unconditional cohort fractions rather than per-unit
59+
conditional propensities (see REGISTRY.md deviation note).
5860
5961
Parameters
6062
----------
@@ -534,7 +536,8 @@ def fit(
534536
att_gt = np.nan
535537

536538
# EIF with per-unit weights (Remark 4.2: plug-in valid)
537-
eif_vals = compute_eif_cov(per_unit_w, gen_out, y_hat, n_units)
539+
# Center on scalar ATT, not per-pair means (ensures mean(EIF) ≈ 0)
540+
eif_vals = compute_eif_cov(per_unit_w, gen_out, att_gt, n_units)
538541
eif_by_gt[(g, t)] = eif_vals
539542
else:
540543
# No-covariates path (closed-form)

diff_diff/efficient_did_covariates.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,19 @@ def estimate_propensity_ratio_sieve(
235235
best_ic = ic_val
236236
best_ratio = r_hat.copy()
237237

238+
# Overlap diagnostics: warn if ratios require significant clipping
239+
n_extreme = int(np.sum((best_ratio < 1.0 / ratio_clip) | (best_ratio > ratio_clip)))
240+
if n_extreme > 0:
241+
pct = 100.0 * n_extreme / n_units
242+
warnings.warn(
243+
f"Sieve propensity ratios for {n_extreme} of {n_units} units "
244+
f"({pct:.1f}%) were outside [{1.0/ratio_clip:.2f}, {ratio_clip:.1f}] "
245+
f"and will be clipped. This may indicate overlap assumption "
246+
f"violations (near-zero propensity scores for some covariate values).",
247+
UserWarning,
248+
stacklevel=2,
249+
)
250+
238251
# Clip: population ratio p_g(X)/p_{g'}(X) is non-negative
239252
best_ratio = np.clip(best_ratio, 1.0 / ratio_clip, ratio_clip)
240253

@@ -626,44 +639,46 @@ def compute_per_unit_weights(
626639
def compute_eif_cov(
627640
weights: np.ndarray,
628641
generated_outcomes: np.ndarray,
629-
y_hat_mean: np.ndarray,
642+
att_gt: float,
630643
n_units: int,
631644
) -> np.ndarray:
632645
"""Per-unit efficient influence function from DR generated outcomes.
633646
634647
Supports both global weights ``(H,)`` and per-unit weights ``(n_units, H)``.
635648
636-
The plug-in EIF treats estimated per-unit weights w(X_i) as fixed.
637-
This is valid under Neyman orthogonality (Remark 4.2): estimation
638-
error in the conditional Omega*(X) weights is second-order and does
639-
not affect the first-order asymptotics of the EIF.
649+
For global weights: ``EIF_i = w @ (gen_out_i - y_bar) = w @ gen_out_i - ATT``
650+
For per-unit weights: ``EIF_i = w(X_i) @ gen_out_i - ATT``
651+
652+
In both cases the EIF centers on the scalar ATT estimate, ensuring
653+
``mean(EIF) ≈ 0``. The plug-in EIF treats estimated per-unit weights
654+
as fixed, valid under Neyman orthogonality (Remark 4.2).
640655
641656
Parameters
642657
----------
643658
weights : ndarray, shape (H,) or (n_units, H)
644659
Efficient combination weights.
645660
generated_outcomes : ndarray, shape (n_units, H)
646661
Per-unit generated outcomes.
647-
y_hat_mean : ndarray, shape (H,)
648-
Sample average of generated outcomes per pair.
662+
att_gt : float
663+
Scalar ATT estimate for this (g, t) cell.
649664
n_units : int
650665
Total number of units.
651666
652667
Returns
653668
-------
654669
eif : ndarray, shape (n_units,)
655-
EIF value for every unit.
670+
EIF value for every unit. Sample mean is approximately zero.
656671
"""
657672
if weights.size == 0:
658673
return np.zeros(n_units)
659674

660-
centered = generated_outcomes - y_hat_mean # (n_units, H)
661-
662675
if weights.ndim == 1:
663-
# Global weights: (n_units,) = (n_units, H) @ (H,)
664-
eif = centered @ weights
676+
# Global weights: w @ gen_out_i for each unit
677+
weighted_scores = generated_outcomes @ weights # (n_units,)
665678
else:
666-
# Per-unit weights: element-wise multiply then sum
667-
eif = np.sum(weights * centered, axis=1)
679+
# Per-unit weights: w_i @ gen_out_i for each unit
680+
weighted_scores = np.sum(weights * generated_outcomes, axis=1)
668681

682+
# Center on the scalar ATT estimate (ensures mean(EIF) ≈ 0)
683+
eif = weighted_scores - att_gt
669684
return eif

docs/methodology/REGISTRY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,7 @@ where `q_{g,e} = pi_g / sum_{g' in G_{trt,e}} pi_{g'}`.
671671
- [x] Overlap diagnostics for propensity score ratios
672672
- **Note:** Sieve ratio estimation uses polynomial basis functions (total degree up to K) with AIC/BIC model selection. The paper describes sieve estimators generally without specifying a particular basis family; polynomial sieves are a standard choice (Section 4, Eq 4.2). Negative sieve ratio predictions are clipped to a small positive value since the population ratio p_g(X)/p_{g'}(X) is non-negative.
673673
- **Note:** Kernel-smoothed conditional covariance Omega*(X) uses Gaussian kernel with Silverman's rule-of-thumb bandwidth by default. The paper specifies kernel smoothing (step 5, Section 4) without mandating a particular kernel or bandwidth selection method.
674+
- **Note (deviation from source):** The conditional covariance Omega*(X) scales each term by unconditional cohort fractions pi_g rather than conditional generalized propensities p_g(X) as in Eq 3.12. Implementing the full conditional propensity scaling requires per-unit group probability estimation (algorithm step 4: s_hat_{g'}(X) = 1/p_{g'}(X) via convex minimization), which is deferred. The unconditional-pi approximation is consistent under double robustness but does not achieve the full conditional efficiency bound of Eq 3.12.
674675

675676
---
676677

tests/test_efficient_did.py

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1348,14 +1348,9 @@ def test_shuffled_units_match_ordered(self):
13481348
f"vs shuffled={r_shuffled.overall_att:.6f}"
13491349
)
13501350

1351-
def test_extreme_covariates_still_valid(self):
1352-
"""Extreme covariates (near-separation) should still produce valid results.
1353-
1354-
The sieve ratio estimator clips extreme ratios; conditional Omega*
1355-
handles the resulting variation in weights gracefully.
1356-
"""
1351+
def test_extreme_covariates_warns_overlap(self):
1352+
"""Extreme covariates should trigger overlap warning and still produce valid results."""
13571353
df = _make_covariate_panel(n_units=300, seed=77)
1358-
# Create a covariate that nearly separates treated from control
13591354
rng = np.random.default_rng(77)
13601355
units = df["unit"].unique()
13611356
n_units = len(units)
@@ -1367,12 +1362,27 @@ def test_extreme_covariates_still_valid(self):
13671362
)
13681363
sep_map = dict(zip(units, sep_vals))
13691364
df["x_sep"] = df["unit"].map(sep_map)
1370-
result = EfficientDiD(pt_assumption="post").fit(
1371-
df, "y", "unit", "time", "first_treat", covariates=["x_sep"]
1372-
)
1365+
with pytest.warns(UserWarning, match="overlap|clipped|propensity"):
1366+
result = EfficientDiD(pt_assumption="post").fit(
1367+
df, "y", "unit", "time", "first_treat", covariates=["x_sep"]
1368+
)
13731369
assert np.isfinite(result.overall_att)
13741370
assert result.overall_se > 0
13751371

1372+
def test_eif_mean_approximately_zero(self):
1373+
"""EIF with per-unit weights should have sample mean ≈ 0."""
1374+
from diff_diff.efficient_did_covariates import compute_eif_cov
1375+
1376+
rng = np.random.default_rng(42)
1377+
n, H = 200, 3
1378+
gen_out = rng.normal(0, 1, (n, H))
1379+
# Non-constant per-unit weights (each row sums to 1)
1380+
raw_w = rng.exponential(1, (n, H))
1381+
per_unit_w = raw_w / raw_w.sum(axis=1, keepdims=True)
1382+
att = float(np.mean(np.sum(per_unit_w * gen_out, axis=1)))
1383+
eif = compute_eif_cov(per_unit_w, gen_out, att, n)
1384+
assert abs(np.mean(eif)) < 1e-10, f"EIF mean should be ≈ 0, got {np.mean(eif):.2e}"
1385+
13761386

13771387
class TestCovariatesBootstrap:
13781388
"""Tier 2: bootstrap with covariates."""

0 commit comments

Comments
 (0)