Skip to content

Commit ea66c57

Browse files
authored
Merge pull request #233 from igerber/survey-roadmap
Phase 4: Survey support for ImputationDiD, TwoStageDiD, CallawaySantAnna
2 parents b75138c + e6f410d commit ea66c57

14 files changed

Lines changed: 3061 additions & 419 deletions

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ Deferred items from PR reviews that were not addressed before merge.
5252
| ImputationDiD dense `(A0'A0).toarray()` scales O((U+T+K)^2), OOM risk on large panels | `imputation.py` | #141 | Medium (deferred — only triggers when sparse solver fails; fixing requires sparse least-squares alternatives) |
5353
| EfficientDiD: API docs / tutorial page for new public estimator | `docs/` | #192 | Medium |
5454
| Multi-absorb weighted demeaning needs iterative alternating projections for N > 1 absorbed FE with survey weights; unweighted multi-absorb also uses single-pass (pre-existing, exact only for balanced panels) | `estimators.py` | #218 | Medium |
55+
| CallawaySantAnna survey: strata/PSU/FPC rejected at runtime. Full design-based SEs require routing the combined IF/WIF through `compute_survey_vcov()`. Currently weights-only. | `staggered.py` | #233 | Medium |
56+
| CallawaySantAnna survey + covariates + IPW/DR: DRDID panel nuisance-estimation IF corrections not implemented. Currently gated with NotImplementedError. Regression method with covariates works (has WLS nuisance IF correction). | `staggered.py` | #233 | Medium |
57+
| EfficientDiD hausman_pretest() clustered covariance uses stale `n_cl` after filtering non-finite EIF rows — should recompute effective cluster count and remap indices after `row_finite` filtering | `efficient_did.py` | #230 | Medium |
58+
| EfficientDiD `control_group="last_cohort"` trims at `last_g - anticipation` but REGISTRY says `t >= last_g`. With `anticipation=0` (default) these are identical. With `anticipation>0`, code is arguably more conservative (excludes anticipation-contaminated periods). Either align REGISTRY with code or change code to `t < last_g` — needs design decision. | `efficient_did.py` | #230 | Low |
5559
| TripleDifference power: `generate_ddd_data` is a fixed 2×2×2 cross-sectional DGP — no multi-period or unbalanced-group support. Add a `generate_ddd_panel_data` for panel DDD power analysis. | `prep_dgp.py`, `power.py` | #208 | Low |
5660
| ContinuousDiD event-study aggregation does not filter by `anticipation` — uses all (g,t) cells instead of anticipation-filtered subset; pre-existing in both survey and non-survey paths | `continuous_did.py` | #226 | Medium |
5761
| Survey design resolution/collapse patterns are inconsistent across panel estimators — ContinuousDiD rebuilds unit-level design in SE code, EfficientDiD builds once in fit(), StackedDiD re-resolves on stacked data; extract shared helpers for panel-to-unit collapse, post-filter re-resolution, and metadata recomputation | `continuous_did.py`, `efficient_did.py`, `stacked_did.py` | #226 | Low |

diff_diff/imputation.py

Lines changed: 291 additions & 49 deletions
Large diffs are not rendered by default.

diff_diff/imputation_results.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class ImputationDiDResults:
139139
bootstrap_results: Optional[ImputationBootstrapResults] = field(default=None, repr=False)
140140
# Internal: stores data needed for pretrend_test()
141141
_estimator_ref: Optional[Any] = field(default=None, repr=False)
142+
# Survey design metadata (SurveyMetadata instance from diff_diff.survey)
143+
survey_metadata: Optional[Any] = field(default=None, repr=False)
142144

143145
def __repr__(self) -> str:
144146
"""Concise string representation."""
@@ -182,6 +184,27 @@ def summary(self, alpha: Optional[float] = None) -> str:
182184
"",
183185
]
184186

187+
# Survey design info
188+
if self.survey_metadata is not None:
189+
sm = self.survey_metadata
190+
lines.extend(
191+
[
192+
"-" * 85,
193+
"Survey Design".center(85),
194+
"-" * 85,
195+
f"{'Weight type:':<30} {sm.weight_type:>10}",
196+
]
197+
)
198+
if sm.n_strata is not None:
199+
lines.append(f"{'Strata:':<30} {sm.n_strata:>10}")
200+
if sm.n_psu is not None:
201+
lines.append(f"{'PSU/Cluster:':<30} {sm.n_psu:>10}")
202+
lines.append(f"{'Effective sample size:':<30} {sm.effective_n:>10.1f}")
203+
lines.append(f"{'Design effect (DEFF):':<30} {sm.design_effect:>10.2f}")
204+
if sm.df_survey is not None:
205+
lines.append(f"{'Survey d.f.:':<30} {sm.df_survey:>10}")
206+
lines.extend(["-" * 85, ""])
207+
185208
# Overall ATT
186209
lines.extend(
187210
[

diff_diff/linalg.py

Lines changed: 39 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -390,24 +390,18 @@ def _validate_weights(weights, weight_type, n):
390390
"""Validate weights array and weight_type for solve_ols/LinearRegression."""
391391
if weight_type not in _VALID_WEIGHT_TYPES:
392392
raise ValueError(
393-
f"weight_type must be one of {_VALID_WEIGHT_TYPES}, "
394-
f"got '{weight_type}'"
393+
f"weight_type must be one of {_VALID_WEIGHT_TYPES}, " f"got '{weight_type}'"
395394
)
396395
if weights is not None:
397396
weights = np.asarray(weights, dtype=np.float64)
398397
if weights.shape[0] != n:
399-
raise ValueError(
400-
f"weights length ({weights.shape[0]}) must match "
401-
f"X rows ({n})"
402-
)
398+
raise ValueError(f"weights length ({weights.shape[0]}) must match " f"X rows ({n})")
403399
if np.any(np.isnan(weights)):
404400
raise ValueError("Weights contain NaN values")
405401
if np.any(np.isinf(weights)):
406402
raise ValueError("Weights contain Inf values")
407403
if np.any(weights < 0):
408-
raise ValueError(
409-
"Weights must be non-negative"
410-
)
404+
raise ValueError("Weights must be non-negative")
411405
if weight_type == "fweight":
412406
fractional = weights - np.round(weights)
413407
if np.any(np.abs(fractional) > 1e-10):
@@ -693,13 +687,9 @@ def solve_ols(
693687
weights=weights,
694688
weight_type=weight_type,
695689
)
696-
vcov_out = _expand_vcov_with_nan(
697-
vcov_reduced, _original_X.shape[1], kept_cols
698-
)
690+
vcov_out = _expand_vcov_with_nan(vcov_reduced, _original_X.shape[1], kept_cols)
699691
else:
700-
vcov_out = np.full(
701-
(_original_X.shape[1], _original_X.shape[1]), np.nan
702-
)
692+
vcov_out = np.full((_original_X.shape[1], _original_X.shape[1]), np.nan)
703693
else:
704694
vcov_out = _compute_robust_vcov_numpy(
705695
_original_X,
@@ -1122,6 +1112,7 @@ def solve_logit(
11221112
tol: float = 1e-8,
11231113
check_separation: bool = True,
11241114
rank_deficient_action: str = "warn",
1115+
weights: Optional[np.ndarray] = None,
11251116
) -> Tuple[np.ndarray, np.ndarray]:
11261117
"""
11271118
Fit logistic regression via IRLS (Fisher scoring).
@@ -1147,6 +1138,13 @@ def solve_logit(
11471138
- "warn": Emit warning and drop columns (default)
11481139
- "error": Raise ValueError
11491140
- "silent": Drop columns silently
1141+
weights : np.ndarray, optional
1142+
Survey/observation weights of shape (n_samples,). When provided,
1143+
the IRLS working weights become ``weights * mu * (1 - mu)``
1144+
instead of ``mu * (1 - mu)``. This produces the survey-weighted
1145+
maximum likelihood estimator, matching R's ``svyglm(family=binomial)``.
1146+
When None (default), behavior is identical to unweighted logistic
1147+
regression.
11501148
11511149
Returns
11521150
-------
@@ -1159,6 +1157,18 @@ def solve_logit(
11591157
X_with_intercept = np.column_stack([np.ones(n), X])
11601158
k = p + 1 # number of parameters including intercept
11611159

1160+
# Validate weights
1161+
if weights is not None:
1162+
weights = np.asarray(weights, dtype=np.float64)
1163+
if weights.shape != (n,):
1164+
raise ValueError(f"weights must have shape ({n},), got {weights.shape}")
1165+
if np.any(np.isnan(weights)):
1166+
raise ValueError("weights contain NaN values")
1167+
if np.any(~np.isfinite(weights)):
1168+
raise ValueError("weights contain Inf values")
1169+
if np.any(weights <= 0):
1170+
raise ValueError("weights must be strictly positive")
1171+
11621172
# Validate rank_deficient_action
11631173
valid_actions = {"warn", "error", "silent"}
11641174
if rank_deficient_action not in valid_actions:
@@ -1203,11 +1213,16 @@ def solve_logit(
12031213
mu = np.clip(mu, 1e-10, 1 - 1e-10)
12041214

12051215
# Working weights and working response
1206-
w = mu * (1.0 - mu)
1207-
z = eta + (y - mu) / w
1216+
w_irls = mu * (1.0 - mu)
1217+
z = eta + (y - mu) / w_irls
1218+
1219+
if weights is not None:
1220+
w_total = weights * w_irls
1221+
else:
1222+
w_total = w_irls
12081223

12091224
# Weighted least squares: solve (X'WX) beta = X'Wz
1210-
sqrt_w = np.sqrt(w)
1225+
sqrt_w = np.sqrt(w_total)
12111226
Xw = X_solve * sqrt_w[:, None]
12121227
zw = z * sqrt_w
12131228
beta_new, _, _, _ = np.linalg.lstsq(Xw, zw, rcond=None)
@@ -1593,10 +1608,7 @@ def fit(
15931608
_use_survey_vcov = self.survey_design.needs_survey_vcov
15941609
# Canonicalize weights from survey_design to ensure consistency
15951610
# between coefficient estimation and survey vcov computation
1596-
if (
1597-
self.weights is not None
1598-
and self.weights is not self.survey_design.weights
1599-
):
1611+
if self.weights is not None and self.weights is not self.survey_design.weights:
16001612
warnings.warn(
16011613
"Explicit weights= differ from survey_design.weights. "
16021614
"Using survey_design weights for both coefficient "
@@ -1609,9 +1621,7 @@ def fit(
16091621
self.weight_type = self.survey_design.weight_type
16101622

16111623
if self.weights is not None:
1612-
self.weights = _validate_weights(
1613-
self.weights, self.weight_type, X.shape[0]
1614-
)
1624+
self.weights = _validate_weights(self.weights, self.weight_type, X.shape[0])
16151625

16161626
# Inject cluster as PSU for survey variance when no PSU specified.
16171627
# Use a local variable to avoid mutating self.survey_design, which
@@ -1622,7 +1632,9 @@ def fit(
16221632
and _effective_survey_design is not None
16231633
and _use_survey_vcov
16241634
):
1625-
from diff_diff.survey import ResolvedSurveyDesign as _RSD, _inject_cluster_as_psu
1635+
from diff_diff.survey import ResolvedSurveyDesign as _RSD
1636+
from diff_diff.survey import _inject_cluster_as_psu
1637+
16261638
if isinstance(_effective_survey_design, _RSD) and _effective_survey_design.psu is None:
16271639
_effective_survey_design = _inject_cluster_as_psu(
16281640
_effective_survey_design, effective_cluster_ids
@@ -1864,9 +1876,7 @@ def get_inference(
18641876
# Use project-standard NaN-safe inference (returns all-NaN when SE <= 0)
18651877
from diff_diff.utils import safe_inference
18661878

1867-
t_stat, p_value, conf_int = safe_inference(
1868-
coef, se, alpha=effective_alpha, df=effective_df
1869-
)
1879+
t_stat, p_value, conf_int = safe_inference(coef, se, alpha=effective_alpha, df=effective_df)
18701880

18711881
return InferenceResult(
18721882
coefficient=coef,

0 commit comments

Comments
 (0)