diff-diff 2.7.2__tar.gz → 2.7.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diff_diff-2.7.2 → diff_diff-2.7.4}/PKG-INFO +1 -1
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/__init__.py +16 -1
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/estimators.py +222 -27
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation.py +10 -6
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation_bootstrap.py +39 -70
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/linalg.py +490 -146
- diff_diff-2.7.4/diff_diff/power.py +2588 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/pretrends.py +3 -2
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/results.py +78 -10
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/stacked_did.py +1 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/sun_abraham.py +29 -51
- diff_diff-2.7.4/diff_diff/survey.py +668 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/triple_diff.py +5 -1
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/twfe.py +56 -9
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage.py +9 -4
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage_bootstrap.py +38 -58
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/utils.py +168 -111
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/visualization.py +51 -14
- {diff_diff-2.7.2 → diff_diff-2.7.4}/pyproject.toml +1 -1
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/Cargo.lock +5 -5
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/Cargo.toml +1 -1
- diff_diff-2.7.2/diff_diff/power.py +0 -1350
- {diff_diff-2.7.2 → diff_diff-2.7.4}/README.md +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/_backend.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/bacon.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/bootstrap_utils.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did_bspline.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/datasets.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/diagnostics.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_bootstrap.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_weights.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/honest_did.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/prep.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/prep_dgp.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/stacked_did_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_aggregation.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_bootstrap.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/synthetic_did.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/trop.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/trop_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage_results.py +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/build.rs +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/bootstrap.rs +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/lib.rs +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/linalg.rs +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/trop.rs +0 -0
- {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/weights.rs +0 -0
|
@@ -53,11 +53,15 @@ from diff_diff.honest_did import (
|
|
|
53
53
|
from diff_diff.power import (
|
|
54
54
|
PowerAnalysis,
|
|
55
55
|
PowerResults,
|
|
56
|
+
SimulationMDEResults,
|
|
56
57
|
SimulationPowerResults,
|
|
58
|
+
SimulationSampleSizeResults,
|
|
57
59
|
compute_mde,
|
|
58
60
|
compute_power,
|
|
59
61
|
compute_sample_size,
|
|
62
|
+
simulate_mde,
|
|
60
63
|
simulate_power,
|
|
64
|
+
simulate_sample_size,
|
|
61
65
|
)
|
|
62
66
|
from diff_diff.pretrends import (
|
|
63
67
|
PreTrendsPower,
|
|
@@ -90,6 +94,10 @@ from diff_diff.results import (
|
|
|
90
94
|
PeriodEffect,
|
|
91
95
|
SyntheticDiDResults,
|
|
92
96
|
)
|
|
97
|
+
from diff_diff.survey import (
|
|
98
|
+
SurveyDesign,
|
|
99
|
+
SurveyMetadata,
|
|
100
|
+
)
|
|
93
101
|
from diff_diff.staggered import (
|
|
94
102
|
CallawaySantAnna,
|
|
95
103
|
CallawaySantAnnaResults,
|
|
@@ -179,7 +187,7 @@ Stacked = StackedDiD
|
|
|
179
187
|
Bacon = BaconDecomposition
|
|
180
188
|
EDiD = EfficientDiD
|
|
181
189
|
|
|
182
|
-
__version__ = "2.7.
|
|
190
|
+
__version__ = "2.7.4"
|
|
183
191
|
__all__ = [
|
|
184
192
|
# Estimators
|
|
185
193
|
"DifferenceInDifferences",
|
|
@@ -291,11 +299,15 @@ __all__ = [
|
|
|
291
299
|
# Power analysis
|
|
292
300
|
"PowerAnalysis",
|
|
293
301
|
"PowerResults",
|
|
302
|
+
"SimulationMDEResults",
|
|
294
303
|
"SimulationPowerResults",
|
|
304
|
+
"SimulationSampleSizeResults",
|
|
295
305
|
"compute_mde",
|
|
296
306
|
"compute_power",
|
|
297
307
|
"compute_sample_size",
|
|
308
|
+
"simulate_mde",
|
|
298
309
|
"simulate_power",
|
|
310
|
+
"simulate_sample_size",
|
|
299
311
|
"plot_power_curve",
|
|
300
312
|
# Pre-trends power analysis
|
|
301
313
|
"PreTrendsPower",
|
|
@@ -304,6 +316,9 @@ __all__ = [
|
|
|
304
316
|
"compute_pretrends_power",
|
|
305
317
|
"compute_mdv",
|
|
306
318
|
"plot_pretrends_power",
|
|
319
|
+
# Survey support
|
|
320
|
+
"SurveyDesign",
|
|
321
|
+
"SurveyMetadata",
|
|
307
322
|
# Rust backend
|
|
308
323
|
"HAS_RUST_BACKEND",
|
|
309
324
|
# Linear algebra helpers
|
|
@@ -20,6 +20,7 @@ import pandas as pd
|
|
|
20
20
|
|
|
21
21
|
from diff_diff.linalg import (
|
|
22
22
|
LinearRegression,
|
|
23
|
+
_expand_vcov_with_nan,
|
|
23
24
|
compute_r_squared,
|
|
24
25
|
compute_robust_vcov,
|
|
25
26
|
solve_ols,
|
|
@@ -153,6 +154,7 @@ class DifferenceInDifferences:
|
|
|
153
154
|
covariates: Optional[List[str]] = None,
|
|
154
155
|
fixed_effects: Optional[List[str]] = None,
|
|
155
156
|
absorb: Optional[List[str]] = None,
|
|
157
|
+
survey_design=None,
|
|
156
158
|
) -> DiDResults:
|
|
157
159
|
"""
|
|
158
160
|
Fit the Difference-in-Differences model.
|
|
@@ -180,6 +182,10 @@ class DifferenceInDifferences:
|
|
|
180
182
|
List of categorical column names for high-dimensional fixed effects.
|
|
181
183
|
Uses within-transformation (demeaning) instead of dummy variables.
|
|
182
184
|
More efficient for large numbers of categories (e.g., firm, individual).
|
|
185
|
+
survey_design : SurveyDesign, optional
|
|
186
|
+
Survey design specification for design-based inference. When provided,
|
|
187
|
+
uses Taylor Series Linearization for variance estimation and
|
|
188
|
+
applies sampling weights to the regression.
|
|
183
189
|
|
|
184
190
|
Returns
|
|
185
191
|
-------
|
|
@@ -228,20 +234,59 @@ class DifferenceInDifferences:
|
|
|
228
234
|
if ab not in data.columns:
|
|
229
235
|
raise ValueError(f"Absorb column '{ab}' not found in data")
|
|
230
236
|
|
|
237
|
+
# Resolve survey design if provided
|
|
238
|
+
from diff_diff.survey import _resolve_effective_cluster, _resolve_survey_for_fit
|
|
239
|
+
|
|
240
|
+
resolved_survey, survey_weights, survey_weight_type, survey_metadata = (
|
|
241
|
+
_resolve_survey_for_fit(survey_design, data, self.inference)
|
|
242
|
+
)
|
|
243
|
+
|
|
231
244
|
# Handle absorbed fixed effects (within-transformation)
|
|
232
245
|
working_data = data.copy()
|
|
233
246
|
absorbed_vars = []
|
|
234
247
|
n_absorbed_effects = 0
|
|
235
248
|
|
|
249
|
+
# Save raw treatment counts before absorb demeaning
|
|
250
|
+
n_treated_raw = int(np.sum(data[treatment].values.astype(float)))
|
|
251
|
+
n_control_raw = len(data) - n_treated_raw
|
|
252
|
+
|
|
253
|
+
# Reject multi-absorb with survey weights (single-pass demeaning is
|
|
254
|
+
# not the correct weighted FWL projection for N > 1 dimensions)
|
|
255
|
+
if absorb and len(absorb) > 1 and survey_weights is not None:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
f"Multiple absorbed fixed effects (absorb={absorb}) with survey "
|
|
258
|
+
"weights is not supported. Single-pass sequential demeaning is not "
|
|
259
|
+
"the correct weighted FWL projection for multiple absorbed dimensions. "
|
|
260
|
+
"Use absorb with a single variable, or use fixed_effects= instead."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if absorb and fixed_effects:
|
|
264
|
+
raise ValueError(
|
|
265
|
+
"Cannot use both absorb and fixed_effects. "
|
|
266
|
+
"The absorb within-transformation does not residualize "
|
|
267
|
+
"fixed_effects dummies, violating the FWL theorem. "
|
|
268
|
+
"Use absorb alone (for high-dimensional FE) "
|
|
269
|
+
"or fixed_effects alone (for low-dimensional FE)."
|
|
270
|
+
)
|
|
271
|
+
|
|
236
272
|
if absorb:
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
|
|
241
|
-
|
|
273
|
+
# FWL theorem: demean ALL regressors alongside outcome.
|
|
274
|
+
# Regressors collinear with absorbed FE (e.g., treatment after
|
|
275
|
+
# absorbing unit FE) will zero out and be handled by rank-deficiency.
|
|
276
|
+
working_data["_treat_time"] = (
|
|
277
|
+
working_data[treatment].values.astype(float)
|
|
278
|
+
* working_data[time].values.astype(float)
|
|
279
|
+
)
|
|
280
|
+
vars_to_demean = (
|
|
281
|
+
[outcome, treatment, time, "_treat_time"] + (covariates or [])
|
|
282
|
+
)
|
|
242
283
|
for ab_var in absorb:
|
|
243
284
|
working_data, n_fe = demean_by_group(
|
|
244
|
-
working_data,
|
|
285
|
+
working_data,
|
|
286
|
+
vars_to_demean,
|
|
287
|
+
ab_var,
|
|
288
|
+
inplace=True,
|
|
289
|
+
weights=survey_weights,
|
|
245
290
|
)
|
|
246
291
|
n_absorbed_effects += n_fe
|
|
247
292
|
absorbed_vars.append(ab_var)
|
|
@@ -252,7 +297,10 @@ class DifferenceInDifferences:
|
|
|
252
297
|
t = working_data[time].values.astype(float)
|
|
253
298
|
|
|
254
299
|
# Create interaction term
|
|
255
|
-
|
|
300
|
+
if absorb:
|
|
301
|
+
dt = working_data["_treat_time"].values.astype(float)
|
|
302
|
+
else:
|
|
303
|
+
dt = d * t
|
|
256
304
|
|
|
257
305
|
# Build design matrix
|
|
258
306
|
X = np.column_stack([np.ones(len(y)), d, t, dt])
|
|
@@ -285,17 +333,35 @@ class DifferenceInDifferences:
|
|
|
285
333
|
# Always use LinearRegression for initial fit (unified code path)
|
|
286
334
|
# For wild bootstrap, we don't need cluster SEs from the initial fit
|
|
287
335
|
cluster_ids = data[self.cluster].values if self.cluster is not None else None
|
|
336
|
+
|
|
337
|
+
# When survey PSU is present, it overrides cluster for variance estimation
|
|
338
|
+
effective_cluster_ids = _resolve_effective_cluster(
|
|
339
|
+
resolved_survey, cluster_ids, self.cluster
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
# Inject cluster as effective PSU for survey variance estimation
|
|
343
|
+
if resolved_survey is not None and effective_cluster_ids is not None:
|
|
344
|
+
from diff_diff.survey import _inject_cluster_as_psu, compute_survey_metadata
|
|
345
|
+
resolved_survey = _inject_cluster_as_psu(resolved_survey, effective_cluster_ids)
|
|
346
|
+
if resolved_survey.psu is not None and survey_metadata is not None:
|
|
347
|
+
raw_w = data[survey_design.weights].values.astype(np.float64) if survey_design.weights else np.ones(len(data), dtype=np.float64)
|
|
348
|
+
survey_metadata = compute_survey_metadata(resolved_survey, raw_w)
|
|
349
|
+
|
|
288
350
|
reg = LinearRegression(
|
|
289
351
|
include_intercept=False, # Intercept already in X
|
|
290
352
|
robust=self.robust,
|
|
291
|
-
cluster_ids=
|
|
353
|
+
cluster_ids=effective_cluster_ids if self.inference != "wild_bootstrap" else None,
|
|
292
354
|
alpha=self.alpha,
|
|
293
355
|
rank_deficient_action=self.rank_deficient_action,
|
|
356
|
+
weights=survey_weights,
|
|
357
|
+
weight_type=survey_weight_type,
|
|
358
|
+
survey_design=resolved_survey,
|
|
294
359
|
).fit(X, y, df_adjustment=n_absorbed_effects)
|
|
295
360
|
|
|
296
361
|
coefficients = reg.coefficients_
|
|
297
362
|
residuals = reg.residuals_
|
|
298
363
|
fitted = reg.fitted_values_
|
|
364
|
+
assert coefficients is not None
|
|
299
365
|
att = coefficients[att_idx]
|
|
300
366
|
|
|
301
367
|
# Get inference - either from bootstrap or analytical
|
|
@@ -315,9 +381,9 @@ class DifferenceInDifferences:
|
|
|
315
381
|
|
|
316
382
|
r_squared = compute_r_squared(y, residuals)
|
|
317
383
|
|
|
318
|
-
# Count observations
|
|
319
|
-
n_treated =
|
|
320
|
-
n_control =
|
|
384
|
+
# Count observations (use raw counts to avoid demeaned values from absorb)
|
|
385
|
+
n_treated = n_treated_raw
|
|
386
|
+
n_control = n_control_raw
|
|
321
387
|
|
|
322
388
|
# Create coefficient dictionary
|
|
323
389
|
coef_dict = {name: coef for name, coef in zip(var_names, coefficients)}
|
|
@@ -350,6 +416,7 @@ class DifferenceInDifferences:
|
|
|
350
416
|
inference_method=inference_method,
|
|
351
417
|
n_bootstrap=n_bootstrap_used,
|
|
352
418
|
n_clusters=n_clusters_used,
|
|
419
|
+
survey_metadata=survey_metadata,
|
|
353
420
|
)
|
|
354
421
|
|
|
355
422
|
self._coefficients = coefficients
|
|
@@ -729,6 +796,7 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
729
796
|
absorb: Optional[List[str]] = None,
|
|
730
797
|
reference_period: Any = None,
|
|
731
798
|
unit: Optional[str] = None,
|
|
799
|
+
survey_design=None,
|
|
732
800
|
) -> MultiPeriodDiDResults:
|
|
733
801
|
"""
|
|
734
802
|
Fit the Multi-Period Difference-in-Differences model.
|
|
@@ -764,6 +832,10 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
764
832
|
is detected (suggests CallawaySantAnna instead). Does NOT affect
|
|
765
833
|
standard error computation -- use the ``cluster`` parameter for
|
|
766
834
|
cluster-robust SEs.
|
|
835
|
+
survey_design : SurveyDesign, optional
|
|
836
|
+
Survey design specification for design-based inference. When provided,
|
|
837
|
+
uses Taylor Series Linearization for variance estimation and
|
|
838
|
+
applies sampling weights to the regression.
|
|
767
839
|
|
|
768
840
|
Returns
|
|
769
841
|
-------
|
|
@@ -775,13 +847,16 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
775
847
|
ValueError
|
|
776
848
|
If required parameters are missing or data validation fails.
|
|
777
849
|
"""
|
|
778
|
-
#
|
|
850
|
+
# Fall back to analytical inference if wild bootstrap requested
|
|
851
|
+
# (must happen before _resolve_survey_for_fit which rejects bootstrap+survey)
|
|
852
|
+
effective_inference = self.inference
|
|
779
853
|
if self.inference == "wild_bootstrap":
|
|
780
854
|
warnings.warn(
|
|
781
855
|
"Wild bootstrap inference is not yet supported for MultiPeriodDiD. "
|
|
782
856
|
"Using analytical inference instead.",
|
|
783
857
|
UserWarning,
|
|
784
858
|
)
|
|
859
|
+
effective_inference = "analytical"
|
|
785
860
|
|
|
786
861
|
# Validate basic inputs
|
|
787
862
|
if outcome is None or treatment is None or time is None:
|
|
@@ -925,21 +1000,79 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
925
1000
|
if ab not in data.columns:
|
|
926
1001
|
raise ValueError(f"Absorb column '{ab}' not found in data")
|
|
927
1002
|
|
|
1003
|
+
# Resolve survey design if provided
|
|
1004
|
+
from diff_diff.survey import _resolve_effective_cluster, _resolve_survey_for_fit
|
|
1005
|
+
|
|
1006
|
+
resolved_survey, survey_weights, survey_weight_type, survey_metadata = (
|
|
1007
|
+
_resolve_survey_for_fit(survey_design, data, effective_inference)
|
|
1008
|
+
)
|
|
1009
|
+
|
|
928
1010
|
# Handle absorbed fixed effects (within-transformation)
|
|
929
1011
|
working_data = data.copy()
|
|
930
1012
|
n_absorbed_effects = 0
|
|
931
1013
|
|
|
1014
|
+
# Save raw treatment counts before absorb demeaning
|
|
1015
|
+
n_treated_raw = int(np.sum(data[treatment].values.astype(float)))
|
|
1016
|
+
n_control_raw = len(data) - n_treated_raw
|
|
1017
|
+
|
|
1018
|
+
# Reject multi-absorb with survey weights (single-pass demeaning is
|
|
1019
|
+
# not the correct weighted FWL projection for N > 1 dimensions)
|
|
1020
|
+
if absorb and len(absorb) > 1 and survey_weights is not None:
|
|
1021
|
+
raise ValueError(
|
|
1022
|
+
f"Multiple absorbed fixed effects (absorb={absorb}) with survey "
|
|
1023
|
+
"weights is not supported. Single-pass sequential demeaning is not "
|
|
1024
|
+
"the correct weighted FWL projection for multiple absorbed dimensions. "
|
|
1025
|
+
"Use absorb with a single variable, or use fixed_effects= instead."
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
if absorb and fixed_effects:
|
|
1029
|
+
raise ValueError(
|
|
1030
|
+
"Cannot use both absorb and fixed_effects. "
|
|
1031
|
+
"The absorb within-transformation does not residualize "
|
|
1032
|
+
"fixed_effects dummies, violating the FWL theorem. "
|
|
1033
|
+
"Use absorb alone (for high-dimensional FE) "
|
|
1034
|
+
"or fixed_effects alone (for low-dimensional FE)."
|
|
1035
|
+
)
|
|
1036
|
+
|
|
1037
|
+
# Pre-compute non_ref_periods (needed for absorb demeaning)
|
|
1038
|
+
non_ref_periods = [p for p in all_periods if p != reference_period]
|
|
1039
|
+
|
|
932
1040
|
if absorb:
|
|
933
|
-
|
|
1041
|
+
# FWL theorem: demean ALL regressors alongside outcome.
|
|
1042
|
+
# Regressors collinear with absorbed FE (e.g., treatment after
|
|
1043
|
+
# absorbing unit FE) will zero out and be handled by rank-deficiency.
|
|
1044
|
+
d_raw = working_data[treatment].values.astype(float)
|
|
1045
|
+
t_raw = working_data[time].values
|
|
1046
|
+
working_data["_did_treatment"] = d_raw
|
|
1047
|
+
for period in non_ref_periods:
|
|
1048
|
+
working_data[f"_did_period_{period}"] = (
|
|
1049
|
+
t_raw == period
|
|
1050
|
+
).astype(float)
|
|
1051
|
+
working_data[f"_did_interact_{period}"] = (
|
|
1052
|
+
d_raw * (t_raw == period).astype(float)
|
|
1053
|
+
)
|
|
1054
|
+
vars_to_demean = (
|
|
1055
|
+
[outcome, "_did_treatment"]
|
|
1056
|
+
+ [f"_did_period_{p}" for p in non_ref_periods]
|
|
1057
|
+
+ [f"_did_interact_{p}" for p in non_ref_periods]
|
|
1058
|
+
+ (covariates or [])
|
|
1059
|
+
)
|
|
934
1060
|
for ab_var in absorb:
|
|
935
1061
|
working_data, n_fe = demean_by_group(
|
|
936
|
-
working_data,
|
|
1062
|
+
working_data,
|
|
1063
|
+
vars_to_demean,
|
|
1064
|
+
ab_var,
|
|
1065
|
+
inplace=True,
|
|
1066
|
+
weights=survey_weights,
|
|
937
1067
|
)
|
|
938
1068
|
n_absorbed_effects += n_fe
|
|
939
1069
|
|
|
940
|
-
# Extract outcome and treatment
|
|
1070
|
+
# Extract outcome and treatment (may be demeaned if absorb was used)
|
|
941
1071
|
y = working_data[outcome].values.astype(float)
|
|
942
|
-
|
|
1072
|
+
if absorb:
|
|
1073
|
+
d = working_data["_did_treatment"].values.astype(float)
|
|
1074
|
+
else:
|
|
1075
|
+
d = working_data[treatment].values.astype(float)
|
|
943
1076
|
t = working_data[time].values
|
|
944
1077
|
|
|
945
1078
|
# Build design matrix
|
|
@@ -948,11 +1081,15 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
948
1081
|
var_names = ["const", treatment]
|
|
949
1082
|
|
|
950
1083
|
# Add period dummies (excluding reference period)
|
|
951
|
-
non_ref_periods = [p for p in all_periods if p != reference_period]
|
|
952
1084
|
period_dummy_indices = {} # Map period -> column index in X
|
|
953
1085
|
|
|
954
1086
|
for period in non_ref_periods:
|
|
955
|
-
|
|
1087
|
+
if absorb:
|
|
1088
|
+
period_dummy = working_data[
|
|
1089
|
+
f"_did_period_{period}"
|
|
1090
|
+
].values.astype(float)
|
|
1091
|
+
else:
|
|
1092
|
+
period_dummy = (t == period).astype(float)
|
|
956
1093
|
X = np.column_stack([X, period_dummy])
|
|
957
1094
|
var_names.append(f"period_{period}")
|
|
958
1095
|
period_dummy_indices[period] = X.shape[1] - 1
|
|
@@ -963,7 +1100,12 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
963
1100
|
interaction_indices = {} # Map period -> column index in X
|
|
964
1101
|
|
|
965
1102
|
for period in non_ref_periods:
|
|
966
|
-
|
|
1103
|
+
if absorb:
|
|
1104
|
+
interaction = working_data[
|
|
1105
|
+
f"_did_interact_{period}"
|
|
1106
|
+
].values.astype(float)
|
|
1107
|
+
else:
|
|
1108
|
+
interaction = d * (t == period).astype(float)
|
|
967
1109
|
X = np.column_stack([X, interaction])
|
|
968
1110
|
var_names.append(f"{treatment}:period_{period}")
|
|
969
1111
|
interaction_indices[period] = X.shape[1] - 1
|
|
@@ -987,26 +1129,77 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
987
1129
|
# This handles rank-deficient matrices by returning NaN for dropped columns
|
|
988
1130
|
cluster_ids = data[self.cluster].values if self.cluster is not None else None
|
|
989
1131
|
|
|
1132
|
+
# When survey PSU is present, it overrides cluster for variance estimation
|
|
1133
|
+
effective_cluster_ids = _resolve_effective_cluster(
|
|
1134
|
+
resolved_survey, cluster_ids, self.cluster
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
# Inject cluster as effective PSU for survey variance estimation
|
|
1138
|
+
if resolved_survey is not None and effective_cluster_ids is not None:
|
|
1139
|
+
from diff_diff.survey import _inject_cluster_as_psu, compute_survey_metadata
|
|
1140
|
+
resolved_survey = _inject_cluster_as_psu(resolved_survey, effective_cluster_ids)
|
|
1141
|
+
if resolved_survey.psu is not None and survey_metadata is not None:
|
|
1142
|
+
raw_w = data[survey_design.weights].values.astype(np.float64) if survey_design.weights else np.ones(len(data), dtype=np.float64)
|
|
1143
|
+
survey_metadata = compute_survey_metadata(resolved_survey, raw_w)
|
|
1144
|
+
|
|
1145
|
+
# Determine if survey vcov should be used
|
|
1146
|
+
_use_survey_vcov = resolved_survey is not None and resolved_survey.needs_survey_vcov
|
|
1147
|
+
|
|
990
1148
|
# Note: Wild bootstrap for multi-period effects is complex (multiple coefficients)
|
|
991
1149
|
# For now, we use analytical inference even if inference="wild_bootstrap"
|
|
992
1150
|
coefficients, residuals, fitted, vcov = solve_ols(
|
|
993
1151
|
X,
|
|
994
1152
|
y,
|
|
995
1153
|
return_fitted=True,
|
|
996
|
-
return_vcov=
|
|
997
|
-
cluster_ids=
|
|
1154
|
+
return_vcov=not _use_survey_vcov,
|
|
1155
|
+
cluster_ids=effective_cluster_ids,
|
|
998
1156
|
column_names=var_names,
|
|
999
1157
|
rank_deficient_action=self.rank_deficient_action,
|
|
1158
|
+
weights=survey_weights,
|
|
1159
|
+
weight_type=survey_weight_type,
|
|
1000
1160
|
)
|
|
1161
|
+
|
|
1162
|
+
# Compute survey vcov if applicable
|
|
1163
|
+
if _use_survey_vcov:
|
|
1164
|
+
from diff_diff.survey import compute_survey_vcov
|
|
1165
|
+
|
|
1166
|
+
nan_mask = np.isnan(coefficients)
|
|
1167
|
+
if np.any(nan_mask):
|
|
1168
|
+
kept_cols = np.where(~nan_mask)[0]
|
|
1169
|
+
if len(kept_cols) > 0:
|
|
1170
|
+
vcov_reduced = compute_survey_vcov(
|
|
1171
|
+
X[:, kept_cols], residuals, resolved_survey
|
|
1172
|
+
)
|
|
1173
|
+
vcov = _expand_vcov_with_nan(vcov_reduced, X.shape[1], kept_cols)
|
|
1174
|
+
else:
|
|
1175
|
+
vcov = np.full((X.shape[1], X.shape[1]), np.nan)
|
|
1176
|
+
else:
|
|
1177
|
+
vcov = compute_survey_vcov(X, residuals, resolved_survey)
|
|
1001
1178
|
r_squared = compute_r_squared(y, residuals)
|
|
1002
1179
|
|
|
1003
|
-
# Degrees of freedom
|
|
1180
|
+
# Degrees of freedom: survey df overrides standard df
|
|
1004
1181
|
k_effective = int(np.sum(~np.isnan(coefficients)))
|
|
1005
|
-
df
|
|
1182
|
+
# For fweights, df uses sum(w) - k (effective sample size)
|
|
1183
|
+
n_eff_df = len(y)
|
|
1184
|
+
if survey_weights is not None and survey_weight_type == "fweight":
|
|
1185
|
+
n_eff_df = int(round(np.sum(survey_weights)))
|
|
1186
|
+
df = n_eff_df - k_effective - n_absorbed_effects
|
|
1187
|
+
if resolved_survey is not None and resolved_survey.df_survey is not None:
|
|
1188
|
+
df = resolved_survey.df_survey
|
|
1189
|
+
|
|
1190
|
+
# Guard: fall back to normal distribution if df is non-positive
|
|
1191
|
+
if df is not None and df <= 0:
|
|
1192
|
+
warnings.warn(
|
|
1193
|
+
f"Degrees of freedom is non-positive (df={df}). "
|
|
1194
|
+
"Using normal distribution instead of t-distribution for inference.",
|
|
1195
|
+
UserWarning,
|
|
1196
|
+
stacklevel=2,
|
|
1197
|
+
)
|
|
1198
|
+
df = None
|
|
1006
1199
|
|
|
1007
1200
|
# For non-robust, non-clustered case, we need homoskedastic vcov
|
|
1008
1201
|
# solve_ols returns HC1 by default, so compute homoskedastic if needed
|
|
1009
|
-
if not self.robust and self.cluster is None:
|
|
1202
|
+
if not self.robust and self.cluster is None and survey_weights is None:
|
|
1010
1203
|
n = len(y)
|
|
1011
1204
|
mse = np.sum(residuals**2) / (n - k_effective)
|
|
1012
1205
|
# Use solve() instead of inv() for numerical stability
|
|
@@ -1029,6 +1222,7 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
1029
1222
|
post_effect_values = []
|
|
1030
1223
|
post_effect_indices = []
|
|
1031
1224
|
|
|
1225
|
+
assert vcov is not None
|
|
1032
1226
|
for period in non_ref_periods:
|
|
1033
1227
|
idx = interaction_indices[period]
|
|
1034
1228
|
effect = coefficients[idx]
|
|
@@ -1081,9 +1275,9 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
1081
1275
|
avg_att, avg_se, alpha=self.alpha, df=df
|
|
1082
1276
|
)
|
|
1083
1277
|
|
|
1084
|
-
# Count observations
|
|
1085
|
-
n_treated =
|
|
1086
|
-
n_control =
|
|
1278
|
+
# Count observations (use raw counts to avoid demeaned values from absorb)
|
|
1279
|
+
n_treated = n_treated_raw
|
|
1280
|
+
n_control = n_control_raw
|
|
1087
1281
|
|
|
1088
1282
|
# Create coefficient dictionary
|
|
1089
1283
|
coef_dict = {name: coef for name, coef in zip(var_names, coefficients)}
|
|
@@ -1109,6 +1303,7 @@ class MultiPeriodDiD(DifferenceInDifferences):
|
|
|
1109
1303
|
r_squared=r_squared,
|
|
1110
1304
|
reference_period=reference_period,
|
|
1111
1305
|
interaction_indices=interaction_indices,
|
|
1306
|
+
survey_metadata=survey_metadata,
|
|
1112
1307
|
)
|
|
1113
1308
|
|
|
1114
1309
|
self._coefficients = coefficients
|
|
@@ -23,12 +23,13 @@ from scipy import sparse, stats
|
|
|
23
23
|
from scipy.sparse.linalg import spsolve
|
|
24
24
|
|
|
25
25
|
from diff_diff.imputation_bootstrap import ImputationDiDBootstrapMixin, _compute_target_weights
|
|
26
|
-
from diff_diff.imputation_results import
|
|
26
|
+
from diff_diff.imputation_results import ( # noqa: F401 (re-export)
|
|
27
|
+
ImputationBootstrapResults,
|
|
28
|
+
ImputationDiDResults,
|
|
29
|
+
)
|
|
27
30
|
from diff_diff.linalg import solve_ols
|
|
28
31
|
from diff_diff.utils import safe_inference
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
33
|
# =============================================================================
|
|
33
34
|
# Main Estimator
|
|
34
35
|
# =============================================================================
|
|
@@ -417,9 +418,7 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
|
|
|
417
418
|
kept_cov_mask=kept_cov_mask,
|
|
418
419
|
)
|
|
419
420
|
|
|
420
|
-
overall_t, overall_p, overall_ci = safe_inference(
|
|
421
|
-
overall_att, overall_se, alpha=self.alpha
|
|
422
|
-
)
|
|
421
|
+
overall_t, overall_p, overall_ci = safe_inference(overall_att, overall_se, alpha=self.alpha)
|
|
423
422
|
|
|
424
423
|
# Event study and group aggregation
|
|
425
424
|
event_study_effects = None
|
|
@@ -553,7 +552,9 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
|
|
|
553
552
|
and event_study_effects[h].get("n_obs", 1) > 0
|
|
554
553
|
):
|
|
555
554
|
event_study_effects[h]["se"] = bootstrap_results.event_study_ses[h]
|
|
555
|
+
assert bootstrap_results.event_study_cis is not None
|
|
556
556
|
event_study_effects[h]["conf_int"] = bootstrap_results.event_study_cis[h]
|
|
557
|
+
assert bootstrap_results.event_study_p_values is not None
|
|
557
558
|
event_study_effects[h]["p_value"] = bootstrap_results.event_study_p_values[
|
|
558
559
|
h
|
|
559
560
|
]
|
|
@@ -568,7 +569,9 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
|
|
|
568
569
|
for g in group_effects:
|
|
569
570
|
if g in bootstrap_results.group_ses:
|
|
570
571
|
group_effects[g]["se"] = bootstrap_results.group_ses[g]
|
|
572
|
+
assert bootstrap_results.group_cis is not None
|
|
571
573
|
group_effects[g]["conf_int"] = bootstrap_results.group_cis[g]
|
|
574
|
+
assert bootstrap_results.group_p_values is not None
|
|
572
575
|
group_effects[g]["p_value"] = bootstrap_results.group_p_values[g]
|
|
573
576
|
eff_val = group_effects[g]["effect"]
|
|
574
577
|
se_val = group_effects[g]["se"]
|
|
@@ -1614,6 +1617,7 @@ class ImputationDiD(ImputationDiDBootstrapMixin):
|
|
|
1614
1617
|
)
|
|
1615
1618
|
coefficients = result[0]
|
|
1616
1619
|
vcov = result[2]
|
|
1620
|
+
assert vcov is not None
|
|
1617
1621
|
|
|
1618
1622
|
# Extract lead coefficients and their sub-VCV
|
|
1619
1623
|
n_leads_actual = len(lead_cols)
|