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.
Files changed (54) hide show
  1. {diff_diff-2.7.2 → diff_diff-2.7.4}/PKG-INFO +1 -1
  2. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/__init__.py +16 -1
  3. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/estimators.py +222 -27
  4. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation.py +10 -6
  5. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation_bootstrap.py +39 -70
  6. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/linalg.py +490 -146
  7. diff_diff-2.7.4/diff_diff/power.py +2588 -0
  8. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/pretrends.py +3 -2
  9. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/results.py +78 -10
  10. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/stacked_did.py +1 -0
  11. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/sun_abraham.py +29 -51
  12. diff_diff-2.7.4/diff_diff/survey.py +668 -0
  13. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/triple_diff.py +5 -1
  14. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/twfe.py +56 -9
  15. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage.py +9 -4
  16. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage_bootstrap.py +38 -58
  17. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/utils.py +168 -111
  18. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/visualization.py +51 -14
  19. {diff_diff-2.7.2 → diff_diff-2.7.4}/pyproject.toml +1 -1
  20. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/Cargo.lock +5 -5
  21. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/Cargo.toml +1 -1
  22. diff_diff-2.7.2/diff_diff/power.py +0 -1350
  23. {diff_diff-2.7.2 → diff_diff-2.7.4}/README.md +0 -0
  24. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/_backend.py +0 -0
  25. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/bacon.py +0 -0
  26. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/bootstrap_utils.py +0 -0
  27. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did.py +0 -0
  28. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did_bspline.py +0 -0
  29. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/continuous_did_results.py +0 -0
  30. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/datasets.py +0 -0
  31. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/diagnostics.py +0 -0
  32. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did.py +0 -0
  33. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_bootstrap.py +0 -0
  34. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_results.py +0 -0
  35. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/efficient_did_weights.py +0 -0
  36. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/honest_did.py +0 -0
  37. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/imputation_results.py +0 -0
  38. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/prep.py +0 -0
  39. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/prep_dgp.py +0 -0
  40. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/stacked_did_results.py +0 -0
  41. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered.py +0 -0
  42. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_aggregation.py +0 -0
  43. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_bootstrap.py +0 -0
  44. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/staggered_results.py +0 -0
  45. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/synthetic_did.py +0 -0
  46. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/trop.py +0 -0
  47. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/trop_results.py +0 -0
  48. {diff_diff-2.7.2 → diff_diff-2.7.4}/diff_diff/two_stage_results.py +0 -0
  49. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/build.rs +0 -0
  50. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/bootstrap.rs +0 -0
  51. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/lib.rs +0 -0
  52. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/linalg.rs +0 -0
  53. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/trop.rs +0 -0
  54. {diff_diff-2.7.2 → diff_diff-2.7.4}/rust/src/weights.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 2.7.2
3
+ Version: 2.7.4
4
4
  Classifier: Development Status :: 5 - Production/Stable
5
5
  Classifier: Intended Audience :: Science/Research
6
6
  Classifier: Operating System :: OS Independent
@@ -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.2"
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
- # Apply within-transformation for each absorbed variable
238
- # Only demean outcome and covariates, NOT treatment/time indicators
239
- # Treatment is typically time-invariant (within unit), and time is
240
- # unit-invariant, so demeaning them would create multicollinearity
241
- vars_to_demean = [outcome] + (covariates or [])
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, vars_to_demean, ab_var, inplace=True
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
- dt = d * t
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=cluster_ids if self.inference != "wild_bootstrap" else None,
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 = int(np.sum(d))
320
- n_control = int(np.sum(1 - d))
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
- # Warn if wild bootstrap is requested but not supported
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
- vars_to_demean = [outcome] + (covariates or [])
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, vars_to_demean, ab_var, inplace=True
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
- d = working_data[treatment].values.astype(float)
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
- period_dummy = (t == period).astype(float)
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
- interaction = d * (t == period).astype(float)
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=True,
997
- cluster_ids=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 using effective rank (non-NaN coefficients)
1180
+ # Degrees of freedom: survey df overrides standard df
1004
1181
  k_effective = int(np.sum(~np.isnan(coefficients)))
1005
- df = len(y) - k_effective - n_absorbed_effects
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 = int(np.sum(d))
1086
- n_control = int(np.sum(1 - d))
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 ImputationBootstrapResults, ImputationDiDResults # noqa: F401 (re-export)
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)