diff-diff 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -5,8 +5,14 @@ This library provides sklearn-like estimators for causal inference
5
5
  using the difference-in-differences methodology.
6
6
  """
7
7
 
8
- from diff_diff.estimators import DifferenceInDifferences
9
- from diff_diff.results import DiDResults
8
+ from diff_diff.estimators import DifferenceInDifferences, MultiPeriodDiD
9
+ from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
10
10
 
11
- __version__ = "0.1.0"
12
- __all__ = ["DifferenceInDifferences", "DiDResults"]
11
+ __version__ = "0.2.0"
12
+ __all__ = [
13
+ "DifferenceInDifferences",
14
+ "MultiPeriodDiD",
15
+ "DiDResults",
16
+ "MultiPeriodDiDResults",
17
+ "PeriodEffect",
18
+ ]
diff_diff/estimators.py CHANGED
@@ -8,7 +8,7 @@ import numpy as np
8
8
  import pandas as pd
9
9
  from scipy import stats
10
10
 
11
- from diff_diff.results import DiDResults
11
+ from diff_diff.results import DiDResults, MultiPeriodDiDResults, PeriodEffect
12
12
  from diff_diff.utils import (
13
13
  validate_binary,
14
14
  compute_robust_se,
@@ -169,6 +169,10 @@ class DifferenceInDifferences:
169
169
  # Validate inputs
170
170
  self._validate_data(data, outcome, treatment, time, covariates)
171
171
 
172
+ # Validate binary variables BEFORE any transformations
173
+ validate_binary(data[treatment].values, "treatment")
174
+ validate_binary(data[time].values, "time")
175
+
172
176
  # Validate fixed effects and absorb columns
173
177
  if fixed_effects:
174
178
  for fe in fixed_effects:
@@ -186,6 +190,9 @@ class DifferenceInDifferences:
186
190
 
187
191
  if absorb:
188
192
  # Apply within-transformation for each absorbed variable
193
+ # Only demean outcome and covariates, NOT treatment/time indicators
194
+ # Treatment is typically time-invariant (within unit), and time is
195
+ # unit-invariant, so demeaning them would create multicollinearity
189
196
  vars_to_demean = [outcome] + (covariates or [])
190
197
  for ab_var in absorb:
191
198
  n_absorbed_effects += working_data[ab_var].nunique() - 1
@@ -194,15 +201,11 @@ class DifferenceInDifferences:
194
201
  working_data[var] = working_data[var] - group_means
195
202
  absorbed_vars.append(ab_var)
196
203
 
197
- # Extract variables
204
+ # Extract variables (may be demeaned if absorb was used)
198
205
  y = working_data[outcome].values.astype(float)
199
206
  d = working_data[treatment].values.astype(float)
200
207
  t = working_data[time].values.astype(float)
201
208
 
202
- # Validate binary variables
203
- validate_binary(d, "treatment")
204
- validate_binary(t, "time")
205
-
206
209
  # Create interaction term
207
210
  dt = d * t
208
211
 
@@ -220,7 +223,8 @@ class DifferenceInDifferences:
220
223
  if fixed_effects:
221
224
  for fe in fixed_effects:
222
225
  # Create dummies, drop first category to avoid multicollinearity
223
- dummies = pd.get_dummies(data[fe], prefix=fe, drop_first=True)
226
+ # Use working_data to be consistent with absorbed FE if both are used
227
+ dummies = pd.get_dummies(working_data[fe], prefix=fe, drop_first=True)
224
228
  for col in dummies.columns:
225
229
  X = np.column_stack([X, dummies[col].values.astype(float)])
226
230
  var_names.append(col)
@@ -298,7 +302,21 @@ class DifferenceInDifferences:
298
302
  -------
299
303
  tuple
300
304
  (coefficients, residuals, fitted_values, r_squared)
305
+
306
+ Raises
307
+ ------
308
+ ValueError
309
+ If design matrix is rank-deficient (perfect multicollinearity).
301
310
  """
311
+ # Check for rank deficiency (perfect multicollinearity)
312
+ rank = np.linalg.matrix_rank(X)
313
+ if rank < X.shape[1]:
314
+ raise ValueError(
315
+ f"Design matrix is rank-deficient (rank {rank} < {X.shape[1]} columns). "
316
+ "This indicates perfect multicollinearity. Check your fixed effects "
317
+ "and covariates for linear dependencies."
318
+ )
319
+
302
320
  # Solve normal equations: β = (X'X)^(-1) X'y
303
321
  coefficients = np.linalg.lstsq(X, y, rcond=None)[0]
304
322
 
@@ -699,3 +717,334 @@ class TwoWayFixedEffects(DifferenceInDifferences):
699
717
  data[f"{var}_demeaned"] = data[var] - unit_means - time_means + grand_mean
700
718
 
701
719
  return data
720
+
721
+
722
+ class MultiPeriodDiD(DifferenceInDifferences):
723
+ """
724
+ Multi-Period Difference-in-Differences estimator.
725
+
726
+ Extends the standard DiD to handle multiple pre-treatment and
727
+ post-treatment time periods, providing period-specific treatment
728
+ effects as well as an aggregate average treatment effect.
729
+
730
+ Parameters
731
+ ----------
732
+ robust : bool, default=True
733
+ Whether to use heteroskedasticity-robust standard errors (HC1).
734
+ cluster : str, optional
735
+ Column name for cluster-robust standard errors.
736
+ alpha : float, default=0.05
737
+ Significance level for confidence intervals.
738
+
739
+ Attributes
740
+ ----------
741
+ results_ : MultiPeriodDiDResults
742
+ Estimation results after calling fit().
743
+ is_fitted_ : bool
744
+ Whether the model has been fitted.
745
+
746
+ Examples
747
+ --------
748
+ Basic usage with multiple time periods:
749
+
750
+ >>> import pandas as pd
751
+ >>> from diff_diff import MultiPeriodDiD
752
+ >>>
753
+ >>> # Create sample panel data with 6 time periods
754
+ >>> # Periods 0-2 are pre-treatment, periods 3-5 are post-treatment
755
+ >>> data = create_panel_data() # Your data
756
+ >>>
757
+ >>> # Fit the model
758
+ >>> did = MultiPeriodDiD()
759
+ >>> results = did.fit(
760
+ ... data,
761
+ ... outcome='sales',
762
+ ... treatment='treated',
763
+ ... time='period',
764
+ ... post_periods=[3, 4, 5] # Specify which periods are post-treatment
765
+ ... )
766
+ >>>
767
+ >>> # View period-specific effects
768
+ >>> for period, effect in results.period_effects.items():
769
+ ... print(f"Period {period}: {effect.effect:.3f} (SE: {effect.se:.3f})")
770
+ >>>
771
+ >>> # View average treatment effect
772
+ >>> print(f"Average ATT: {results.avg_att:.3f}")
773
+
774
+ Notes
775
+ -----
776
+ The model estimates:
777
+
778
+ Y_it = α + β*D_i + Σ_t γ_t*Period_t + Σ_t∈post δ_t*(D_i × Post_t) + ε_it
779
+
780
+ Where:
781
+ - D_i is the treatment indicator
782
+ - Period_t are time period dummies
783
+ - D_i × Post_t are treatment-by-post-period interactions
784
+ - δ_t are the period-specific treatment effects
785
+
786
+ The average ATT is computed as the mean of the δ_t coefficients.
787
+ """
788
+
789
+ def fit(
790
+ self,
791
+ data: pd.DataFrame,
792
+ outcome: str,
793
+ treatment: str,
794
+ time: str,
795
+ post_periods: list = None,
796
+ covariates: list = None,
797
+ fixed_effects: list = None,
798
+ absorb: list = None,
799
+ reference_period: any = None
800
+ ) -> MultiPeriodDiDResults:
801
+ """
802
+ Fit the Multi-Period Difference-in-Differences model.
803
+
804
+ Parameters
805
+ ----------
806
+ data : pd.DataFrame
807
+ DataFrame containing the outcome, treatment, and time variables.
808
+ outcome : str
809
+ Name of the outcome variable column.
810
+ treatment : str
811
+ Name of the treatment group indicator column (0/1).
812
+ time : str
813
+ Name of the time period column (can have multiple values).
814
+ post_periods : list
815
+ List of time period values that are post-treatment.
816
+ All other periods are treated as pre-treatment.
817
+ covariates : list, optional
818
+ List of covariate column names to include as linear controls.
819
+ fixed_effects : list, optional
820
+ List of categorical column names to include as fixed effects.
821
+ absorb : list, optional
822
+ List of categorical column names for high-dimensional fixed effects.
823
+ reference_period : any, optional
824
+ The reference (omitted) time period for the period dummies.
825
+ Defaults to the first pre-treatment period.
826
+
827
+ Returns
828
+ -------
829
+ MultiPeriodDiDResults
830
+ Object containing period-specific and average treatment effects.
831
+
832
+ Raises
833
+ ------
834
+ ValueError
835
+ If required parameters are missing or data validation fails.
836
+ """
837
+ # Validate basic inputs
838
+ if outcome is None or treatment is None or time is None:
839
+ raise ValueError(
840
+ "Must provide 'outcome', 'treatment', and 'time'"
841
+ )
842
+
843
+ # Validate columns exist
844
+ self._validate_data(data, outcome, treatment, time, covariates)
845
+
846
+ # Validate treatment is binary
847
+ validate_binary(data[treatment].values, "treatment")
848
+
849
+ # Get all unique time periods
850
+ all_periods = sorted(data[time].unique())
851
+
852
+ if len(all_periods) < 2:
853
+ raise ValueError("Time variable must have at least 2 unique periods")
854
+
855
+ # Determine pre and post periods
856
+ if post_periods is None:
857
+ # Default: last half of periods are post-treatment
858
+ mid_point = len(all_periods) // 2
859
+ post_periods = all_periods[mid_point:]
860
+ pre_periods = all_periods[:mid_point]
861
+ else:
862
+ post_periods = list(post_periods)
863
+ pre_periods = [p for p in all_periods if p not in post_periods]
864
+
865
+ if len(post_periods) == 0:
866
+ raise ValueError("Must have at least one post-treatment period")
867
+
868
+ if len(pre_periods) == 0:
869
+ raise ValueError("Must have at least one pre-treatment period")
870
+
871
+ # Validate post_periods are in the data
872
+ for p in post_periods:
873
+ if p not in all_periods:
874
+ raise ValueError(f"Post-period '{p}' not found in time column")
875
+
876
+ # Determine reference period (omitted dummy)
877
+ if reference_period is None:
878
+ reference_period = pre_periods[0]
879
+ elif reference_period not in all_periods:
880
+ raise ValueError(f"Reference period '{reference_period}' not found in time column")
881
+
882
+ # Validate fixed effects and absorb columns
883
+ if fixed_effects:
884
+ for fe in fixed_effects:
885
+ if fe not in data.columns:
886
+ raise ValueError(f"Fixed effect column '{fe}' not found in data")
887
+ if absorb:
888
+ for ab in absorb:
889
+ if ab not in data.columns:
890
+ raise ValueError(f"Absorb column '{ab}' not found in data")
891
+
892
+ # Handle absorbed fixed effects (within-transformation)
893
+ working_data = data.copy()
894
+ n_absorbed_effects = 0
895
+
896
+ if absorb:
897
+ vars_to_demean = [outcome] + (covariates or [])
898
+ for ab_var in absorb:
899
+ n_absorbed_effects += working_data[ab_var].nunique() - 1
900
+ for var in vars_to_demean:
901
+ group_means = working_data.groupby(ab_var)[var].transform("mean")
902
+ working_data[var] = working_data[var] - group_means
903
+
904
+ # Extract outcome and treatment
905
+ y = working_data[outcome].values.astype(float)
906
+ d = working_data[treatment].values.astype(float)
907
+ t = working_data[time].values
908
+
909
+ # Build design matrix
910
+ # Start with intercept and treatment main effect
911
+ X = np.column_stack([np.ones(len(y)), d])
912
+ var_names = ["const", treatment]
913
+
914
+ # Add period dummies (excluding reference period)
915
+ non_ref_periods = [p for p in all_periods if p != reference_period]
916
+ period_dummy_indices = {} # Map period -> column index in X
917
+
918
+ for period in non_ref_periods:
919
+ period_dummy = (t == period).astype(float)
920
+ X = np.column_stack([X, period_dummy])
921
+ var_names.append(f"period_{period}")
922
+ period_dummy_indices[period] = X.shape[1] - 1
923
+
924
+ # Add treatment × post-period interactions
925
+ # These are our coefficients of interest
926
+ interaction_indices = {} # Map post-period -> column index in X
927
+
928
+ for period in post_periods:
929
+ interaction = d * (t == period).astype(float)
930
+ X = np.column_stack([X, interaction])
931
+ var_names.append(f"{treatment}:period_{period}")
932
+ interaction_indices[period] = X.shape[1] - 1
933
+
934
+ # Add covariates if provided
935
+ if covariates:
936
+ for cov in covariates:
937
+ X = np.column_stack([X, working_data[cov].values.astype(float)])
938
+ var_names.append(cov)
939
+
940
+ # Add fixed effects as dummy variables
941
+ if fixed_effects:
942
+ for fe in fixed_effects:
943
+ dummies = pd.get_dummies(working_data[fe], prefix=fe, drop_first=True)
944
+ for col in dummies.columns:
945
+ X = np.column_stack([X, dummies[col].values.astype(float)])
946
+ var_names.append(col)
947
+
948
+ # Fit OLS
949
+ coefficients, residuals, fitted, r_squared = self._fit_ols(X, y)
950
+
951
+ # Compute standard errors
952
+ if self.cluster is not None:
953
+ cluster_ids = data[self.cluster].values
954
+ vcov = compute_robust_se(X, residuals, cluster_ids)
955
+ elif self.robust:
956
+ vcov = compute_robust_se(X, residuals)
957
+ else:
958
+ n = len(y)
959
+ k = X.shape[1]
960
+ mse = np.sum(residuals ** 2) / (n - k)
961
+ vcov = mse * np.linalg.inv(X.T @ X)
962
+
963
+ # Degrees of freedom
964
+ df = len(y) - X.shape[1] - n_absorbed_effects
965
+
966
+ # Extract period-specific treatment effects
967
+ period_effects = {}
968
+ effect_values = []
969
+ effect_indices = []
970
+
971
+ for period in post_periods:
972
+ idx = interaction_indices[period]
973
+ effect = coefficients[idx]
974
+ se = np.sqrt(vcov[idx, idx])
975
+ t_stat = effect / se
976
+ p_value = compute_p_value(t_stat, df=df)
977
+ conf_int = compute_confidence_interval(effect, se, self.alpha, df=df)
978
+
979
+ period_effects[period] = PeriodEffect(
980
+ period=period,
981
+ effect=effect,
982
+ se=se,
983
+ t_stat=t_stat,
984
+ p_value=p_value,
985
+ conf_int=conf_int
986
+ )
987
+ effect_values.append(effect)
988
+ effect_indices.append(idx)
989
+
990
+ # Compute average treatment effect
991
+ # Average ATT = mean of period-specific effects
992
+ avg_att = np.mean(effect_values)
993
+
994
+ # Standard error of average: need to account for covariance
995
+ # Var(avg) = (1/n^2) * sum of all elements in the sub-covariance matrix
996
+ n_post = len(post_periods)
997
+ sub_vcov = vcov[np.ix_(effect_indices, effect_indices)]
998
+ avg_var = np.sum(sub_vcov) / (n_post ** 2)
999
+ avg_se = np.sqrt(avg_var)
1000
+
1001
+ avg_t_stat = avg_att / avg_se if avg_se > 0 else 0.0
1002
+ avg_p_value = compute_p_value(avg_t_stat, df=df)
1003
+ avg_conf_int = compute_confidence_interval(avg_att, avg_se, self.alpha, df=df)
1004
+
1005
+ # Count observations
1006
+ n_treated = int(np.sum(d))
1007
+ n_control = int(np.sum(1 - d))
1008
+
1009
+ # Create coefficient dictionary
1010
+ coef_dict = {name: coef for name, coef in zip(var_names, coefficients)}
1011
+
1012
+ # Store results
1013
+ self.results_ = MultiPeriodDiDResults(
1014
+ period_effects=period_effects,
1015
+ avg_att=avg_att,
1016
+ avg_se=avg_se,
1017
+ avg_t_stat=avg_t_stat,
1018
+ avg_p_value=avg_p_value,
1019
+ avg_conf_int=avg_conf_int,
1020
+ n_obs=len(y),
1021
+ n_treated=n_treated,
1022
+ n_control=n_control,
1023
+ pre_periods=pre_periods,
1024
+ post_periods=post_periods,
1025
+ alpha=self.alpha,
1026
+ coefficients=coef_dict,
1027
+ vcov=vcov,
1028
+ residuals=residuals,
1029
+ fitted_values=fitted,
1030
+ r_squared=r_squared,
1031
+ )
1032
+
1033
+ self._coefficients = coefficients
1034
+ self._vcov = vcov
1035
+ self.is_fitted_ = True
1036
+
1037
+ return self.results_
1038
+
1039
+ def summary(self) -> str:
1040
+ """
1041
+ Get summary of estimation results.
1042
+
1043
+ Returns
1044
+ -------
1045
+ str
1046
+ Formatted summary.
1047
+ """
1048
+ if not self.is_fitted_:
1049
+ raise RuntimeError("Model must be fitted before calling summary()")
1050
+ return self.results_.summary()
diff_diff/results.py CHANGED
@@ -168,3 +168,296 @@ class DiDResults:
168
168
  elif self.p_value < 0.1:
169
169
  return "."
170
170
  return ""
171
+
172
+
173
+ def _get_significance_stars(p_value: float) -> str:
174
+ """Return significance stars based on p-value."""
175
+ if p_value < 0.001:
176
+ return "***"
177
+ elif p_value < 0.01:
178
+ return "**"
179
+ elif p_value < 0.05:
180
+ return "*"
181
+ elif p_value < 0.1:
182
+ return "."
183
+ return ""
184
+
185
+
186
+ @dataclass
187
+ class PeriodEffect:
188
+ """
189
+ Treatment effect for a single time period.
190
+
191
+ Attributes
192
+ ----------
193
+ period : any
194
+ The time period identifier.
195
+ effect : float
196
+ The treatment effect estimate for this period.
197
+ se : float
198
+ Standard error of the effect estimate.
199
+ t_stat : float
200
+ T-statistic for the effect estimate.
201
+ p_value : float
202
+ P-value for the null hypothesis that effect = 0.
203
+ conf_int : tuple[float, float]
204
+ Confidence interval for the effect.
205
+ """
206
+
207
+ period: any
208
+ effect: float
209
+ se: float
210
+ t_stat: float
211
+ p_value: float
212
+ conf_int: tuple
213
+
214
+ def __repr__(self) -> str:
215
+ """Concise string representation."""
216
+ sig = _get_significance_stars(self.p_value)
217
+ return (
218
+ f"PeriodEffect(period={self.period}, effect={self.effect:.4f}{sig}, "
219
+ f"SE={self.se:.4f}, p={self.p_value:.4f})"
220
+ )
221
+
222
+ @property
223
+ def is_significant(self) -> bool:
224
+ """Check if the effect is statistically significant at 0.05 level."""
225
+ return bool(self.p_value < 0.05)
226
+
227
+ @property
228
+ def significance_stars(self) -> str:
229
+ """Return significance stars based on p-value."""
230
+ return _get_significance_stars(self.p_value)
231
+
232
+
233
+ @dataclass
234
+ class MultiPeriodDiDResults:
235
+ """
236
+ Results from a Multi-Period Difference-in-Differences estimation.
237
+
238
+ Provides access to period-specific treatment effects as well as
239
+ an aggregate average treatment effect.
240
+
241
+ Attributes
242
+ ----------
243
+ period_effects : dict[any, PeriodEffect]
244
+ Dictionary mapping period identifiers to their PeriodEffect objects.
245
+ avg_att : float
246
+ Average Treatment effect on the Treated across all post-periods.
247
+ avg_se : float
248
+ Standard error of the average ATT.
249
+ avg_t_stat : float
250
+ T-statistic for the average ATT.
251
+ avg_p_value : float
252
+ P-value for the null hypothesis that average ATT = 0.
253
+ avg_conf_int : tuple[float, float]
254
+ Confidence interval for the average ATT.
255
+ n_obs : int
256
+ Number of observations used in estimation.
257
+ n_treated : int
258
+ Number of treated observations.
259
+ n_control : int
260
+ Number of control observations.
261
+ pre_periods : list
262
+ List of pre-treatment period identifiers.
263
+ post_periods : list
264
+ List of post-treatment period identifiers.
265
+ """
266
+
267
+ period_effects: dict
268
+ avg_att: float
269
+ avg_se: float
270
+ avg_t_stat: float
271
+ avg_p_value: float
272
+ avg_conf_int: tuple
273
+ n_obs: int
274
+ n_treated: int
275
+ n_control: int
276
+ pre_periods: list
277
+ post_periods: list
278
+ alpha: float = 0.05
279
+ coefficients: Optional[dict] = field(default=None)
280
+ vcov: Optional[np.ndarray] = field(default=None)
281
+ residuals: Optional[np.ndarray] = field(default=None)
282
+ fitted_values: Optional[np.ndarray] = field(default=None)
283
+ r_squared: Optional[float] = field(default=None)
284
+
285
+ def __repr__(self) -> str:
286
+ """Concise string representation."""
287
+ sig = _get_significance_stars(self.avg_p_value)
288
+ return (
289
+ f"MultiPeriodDiDResults(avg_ATT={self.avg_att:.4f}{sig}, "
290
+ f"SE={self.avg_se:.4f}, "
291
+ f"n_post_periods={len(self.post_periods)})"
292
+ )
293
+
294
+ def summary(self, alpha: Optional[float] = None) -> str:
295
+ """
296
+ Generate a formatted summary of the estimation results.
297
+
298
+ Parameters
299
+ ----------
300
+ alpha : float, optional
301
+ Significance level for confidence intervals. Defaults to the
302
+ alpha used during estimation.
303
+
304
+ Returns
305
+ -------
306
+ str
307
+ Formatted summary table.
308
+ """
309
+ alpha = alpha or self.alpha
310
+ conf_level = int((1 - alpha) * 100)
311
+
312
+ lines = [
313
+ "=" * 80,
314
+ "Multi-Period Difference-in-Differences Estimation Results".center(80),
315
+ "=" * 80,
316
+ "",
317
+ f"{'Observations:':<25} {self.n_obs:>10}",
318
+ f"{'Treated observations:':<25} {self.n_treated:>10}",
319
+ f"{'Control observations:':<25} {self.n_control:>10}",
320
+ f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
321
+ f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
322
+ ]
323
+
324
+ if self.r_squared is not None:
325
+ lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}")
326
+
327
+ # Period-specific effects
328
+ lines.extend([
329
+ "",
330
+ "-" * 80,
331
+ "Period-Specific Treatment Effects".center(80),
332
+ "-" * 80,
333
+ f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
334
+ "-" * 80,
335
+ ])
336
+
337
+ for period in self.post_periods:
338
+ pe = self.period_effects[period]
339
+ stars = pe.significance_stars
340
+ lines.append(
341
+ f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} "
342
+ f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}"
343
+ )
344
+
345
+ # Average effect
346
+ lines.extend([
347
+ "-" * 80,
348
+ "",
349
+ "-" * 80,
350
+ "Average Treatment Effect (across post-periods)".center(80),
351
+ "-" * 80,
352
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10}",
353
+ "-" * 80,
354
+ f"{'Avg ATT':<15} {self.avg_att:>12.4f} {self.avg_se:>12.4f} "
355
+ f"{self.avg_t_stat:>10.3f} {self.avg_p_value:>10.4f}",
356
+ "-" * 80,
357
+ "",
358
+ f"{conf_level}% Confidence Interval: [{self.avg_conf_int[0]:.4f}, {self.avg_conf_int[1]:.4f}]",
359
+ ])
360
+
361
+ # Add significance codes
362
+ lines.extend([
363
+ "",
364
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
365
+ "=" * 80,
366
+ ])
367
+
368
+ return "\n".join(lines)
369
+
370
+ def print_summary(self, alpha: Optional[float] = None) -> None:
371
+ """Print the summary to stdout."""
372
+ print(self.summary(alpha))
373
+
374
+ def get_effect(self, period) -> PeriodEffect:
375
+ """
376
+ Get the treatment effect for a specific period.
377
+
378
+ Parameters
379
+ ----------
380
+ period : any
381
+ The period identifier.
382
+
383
+ Returns
384
+ -------
385
+ PeriodEffect
386
+ The treatment effect for the specified period.
387
+
388
+ Raises
389
+ ------
390
+ KeyError
391
+ If the period is not found in post-treatment periods.
392
+ """
393
+ if period not in self.period_effects:
394
+ raise KeyError(
395
+ f"Period '{period}' not found. "
396
+ f"Available post-periods: {list(self.period_effects.keys())}"
397
+ )
398
+ return self.period_effects[period]
399
+
400
+ def to_dict(self) -> dict:
401
+ """
402
+ Convert results to a dictionary.
403
+
404
+ Returns
405
+ -------
406
+ dict
407
+ Dictionary containing all estimation results.
408
+ """
409
+ result = {
410
+ "avg_att": self.avg_att,
411
+ "avg_se": self.avg_se,
412
+ "avg_t_stat": self.avg_t_stat,
413
+ "avg_p_value": self.avg_p_value,
414
+ "avg_conf_int_lower": self.avg_conf_int[0],
415
+ "avg_conf_int_upper": self.avg_conf_int[1],
416
+ "n_obs": self.n_obs,
417
+ "n_treated": self.n_treated,
418
+ "n_control": self.n_control,
419
+ "n_pre_periods": len(self.pre_periods),
420
+ "n_post_periods": len(self.post_periods),
421
+ "r_squared": self.r_squared,
422
+ }
423
+
424
+ # Add period-specific effects
425
+ for period, pe in self.period_effects.items():
426
+ result[f"effect_period_{period}"] = pe.effect
427
+ result[f"se_period_{period}"] = pe.se
428
+ result[f"pval_period_{period}"] = pe.p_value
429
+
430
+ return result
431
+
432
+ def to_dataframe(self) -> pd.DataFrame:
433
+ """
434
+ Convert period-specific effects to a pandas DataFrame.
435
+
436
+ Returns
437
+ -------
438
+ pd.DataFrame
439
+ DataFrame with one row per post-treatment period.
440
+ """
441
+ rows = []
442
+ for period, pe in self.period_effects.items():
443
+ rows.append({
444
+ "period": period,
445
+ "effect": pe.effect,
446
+ "se": pe.se,
447
+ "t_stat": pe.t_stat,
448
+ "p_value": pe.p_value,
449
+ "conf_int_lower": pe.conf_int[0],
450
+ "conf_int_upper": pe.conf_int[1],
451
+ "is_significant": pe.is_significant,
452
+ })
453
+ return pd.DataFrame(rows)
454
+
455
+ @property
456
+ def is_significant(self) -> bool:
457
+ """Check if the average ATT is statistically significant at the alpha level."""
458
+ return bool(self.avg_p_value < self.alpha)
459
+
460
+ @property
461
+ def significance_stars(self) -> str:
462
+ """Return significance stars for the average ATT based on p-value."""
463
+ return _get_significance_stars(self.avg_p_value)
diff_diff/utils.py CHANGED
@@ -218,13 +218,18 @@ def check_parallel_trends(
218
218
  mean_t = np.mean(time_norm)
219
219
  mean_y = np.mean(outcome_values)
220
220
 
221
- slope = np.sum((time_norm - mean_t) * (outcome_values - mean_y)) / np.sum((time_norm - mean_t) ** 2)
221
+ # Check for zero variance in time (all same time period)
222
+ time_var = np.sum((time_norm - mean_t) ** 2)
223
+ if time_var == 0:
224
+ return np.nan, np.nan
225
+
226
+ slope = np.sum((time_norm - mean_t) * (outcome_values - mean_y)) / time_var
222
227
 
223
228
  # Compute standard error of slope
224
229
  y_hat = mean_y + slope * (time_norm - mean_t)
225
230
  residuals = outcome_values - y_hat
226
231
  mse = np.sum(residuals ** 2) / (n - 2)
227
- se_slope = np.sqrt(mse / np.sum((time_norm - mean_t) ** 2))
232
+ se_slope = np.sqrt(mse / time_var)
228
233
 
229
234
  return slope, se_slope
230
235
 
@@ -258,7 +263,8 @@ def check_parallel_trends_robust(
258
263
  unit: str = None,
259
264
  pre_periods: list = None,
260
265
  n_permutations: int = 1000,
261
- seed: int = None
266
+ seed: int = None,
267
+ wasserstein_threshold: float = 0.2
262
268
  ) -> dict:
263
269
  """
264
270
  Perform robust parallel trends testing using distributional comparisons.
@@ -286,6 +292,9 @@ def check_parallel_trends_robust(
286
292
  Number of permutations for computing p-value.
287
293
  seed : int, optional
288
294
  Random seed for reproducibility.
295
+ wasserstein_threshold : float, default=0.2
296
+ Threshold for normalized Wasserstein distance. Values below this
297
+ threshold (combined with p > 0.05) suggest parallel trends are plausible.
289
298
 
290
299
  Returns
291
300
  -------
@@ -321,8 +330,8 @@ def check_parallel_trends_robust(
321
330
  of pre-treatment changes are similar, supporting the parallel trends
322
331
  assumption.
323
332
  """
324
- if seed is not None:
325
- np.random.seed(seed)
333
+ # Use local RNG to avoid affecting global random state
334
+ rng = np.random.default_rng(seed)
326
335
 
327
336
  # Identify pre-treatment periods
328
337
  if pre_periods is None:
@@ -361,7 +370,7 @@ def check_parallel_trends_robust(
361
370
 
362
371
  permuted_distances = np.zeros(n_permutations)
363
372
  for i in range(n_permutations):
364
- perm_idx = np.random.permutation(n_total)
373
+ perm_idx = rng.permutation(n_total)
365
374
  perm_treated = all_changes[perm_idx[:n_treated]]
366
375
  perm_control = all_changes[perm_idx[n_treated:]]
367
376
  permuted_distances[i] = stats.wasserstein_distance(perm_treated, perm_control)
@@ -383,10 +392,10 @@ def check_parallel_trends_robust(
383
392
  wasserstein_normalized = wasserstein_dist / pooled_std if pooled_std > 0 else np.nan
384
393
 
385
394
  # Assessment: parallel trends plausible if p-value > 0.05
386
- # and normalized Wasserstein is small (< 0.2 as rule of thumb)
395
+ # and normalized Wasserstein is small (below threshold)
387
396
  plausible = bool(
388
397
  wasserstein_p > 0.05 and
389
- (wasserstein_normalized < 0.2 if not np.isnan(wasserstein_normalized) else True)
398
+ (wasserstein_normalized < wasserstein_threshold if not np.isnan(wasserstein_normalized) else True)
390
399
  )
391
400
 
392
401
  return {
@@ -523,23 +532,64 @@ def equivalence_test_trends(
523
532
  pre_data, outcome, time, treatment_group, unit
524
533
  )
525
534
 
535
+ # Need at least 2 observations per group to compute variance
536
+ # and at least 3 total for meaningful df calculation
526
537
  if len(treated_changes) < 2 or len(control_changes) < 2:
527
538
  return {
528
539
  "mean_difference": np.nan,
540
+ "se_difference": np.nan,
529
541
  "equivalence_margin": np.nan,
542
+ "lower_t_stat": np.nan,
543
+ "upper_t_stat": np.nan,
530
544
  "lower_p_value": np.nan,
531
545
  "upper_p_value": np.nan,
532
546
  "tost_p_value": np.nan,
547
+ "degrees_of_freedom": np.nan,
533
548
  "equivalent": None,
534
- "error": "Insufficient data",
549
+ "error": "Insufficient data (need at least 2 observations per group)",
535
550
  }
536
551
 
537
552
  # Compute statistics
553
+ var_t = np.var(treated_changes, ddof=1)
554
+ var_c = np.var(control_changes, ddof=1)
555
+ n_t = len(treated_changes)
556
+ n_c = len(control_changes)
557
+
538
558
  mean_diff = np.mean(treated_changes) - np.mean(control_changes)
539
- se_diff = np.sqrt(
540
- np.var(treated_changes, ddof=1) / len(treated_changes) +
541
- np.var(control_changes, ddof=1) / len(control_changes)
542
- )
559
+
560
+ # Handle zero variance case
561
+ if var_t == 0 and var_c == 0:
562
+ return {
563
+ "mean_difference": mean_diff,
564
+ "se_difference": 0.0,
565
+ "equivalence_margin": np.nan,
566
+ "lower_t_stat": np.nan,
567
+ "upper_t_stat": np.nan,
568
+ "lower_p_value": np.nan,
569
+ "upper_p_value": np.nan,
570
+ "tost_p_value": np.nan,
571
+ "degrees_of_freedom": np.nan,
572
+ "equivalent": None,
573
+ "error": "Zero variance in both groups - cannot perform t-test",
574
+ }
575
+
576
+ se_diff = np.sqrt(var_t / n_t + var_c / n_c)
577
+
578
+ # Handle zero SE case (cannot divide by zero in t-stat calculation)
579
+ if se_diff == 0:
580
+ return {
581
+ "mean_difference": mean_diff,
582
+ "se_difference": 0.0,
583
+ "equivalence_margin": np.nan,
584
+ "lower_t_stat": np.nan,
585
+ "upper_t_stat": np.nan,
586
+ "lower_p_value": np.nan,
587
+ "upper_p_value": np.nan,
588
+ "tost_p_value": np.nan,
589
+ "degrees_of_freedom": np.nan,
590
+ "equivalent": None,
591
+ "error": "Zero standard error - cannot perform t-test",
592
+ }
543
593
 
544
594
  # Set equivalence margin if not provided
545
595
  if equivalence_margin is None:
@@ -547,13 +597,17 @@ def equivalence_test_trends(
547
597
  equivalence_margin = 0.5 * np.std(pooled_changes, ddof=1)
548
598
 
549
599
  # Degrees of freedom (Welch-Satterthwaite approximation)
550
- var_t = np.var(treated_changes, ddof=1)
551
- var_c = np.var(control_changes, ddof=1)
552
- n_t = len(treated_changes)
553
- n_c = len(control_changes)
554
-
555
- df = ((var_t/n_t + var_c/n_c)**2 /
556
- ((var_t/n_t)**2/(n_t-1) + (var_c/n_c)**2/(n_c-1)))
600
+ # Guard against division by zero when one group has zero variance
601
+ numerator = (var_t/n_t + var_c/n_c)**2
602
+ denom_t = (var_t/n_t)**2/(n_t-1) if var_t > 0 else 0
603
+ denom_c = (var_c/n_c)**2/(n_c-1) if var_c > 0 else 0
604
+ denominator = denom_t + denom_c
605
+
606
+ if denominator == 0:
607
+ # Fall back to minimum of n_t-1 and n_c-1 when one variance is zero
608
+ df = min(n_t - 1, n_c - 1)
609
+ else:
610
+ df = numerator / denominator
557
611
 
558
612
  # TOST: Two one-sided tests
559
613
  # Test 1: H0: diff <= -margin vs H1: diff > -margin
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: diff-diff
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A library for Difference-in-Differences causal inference analysis
5
5
  Author: diff-diff contributors
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/igerber/diff-diff
8
8
  Project-URL: Documentation, https://github.com/igerber/diff-diff#readme
9
9
  Project-URL: Repository, https://github.com/igerber/diff-diff
@@ -103,6 +103,7 @@ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
103
103
  - **Multiple interfaces**: Column names or R-style formulas
104
104
  - **Robust inference**: Heteroskedasticity-robust (HC1) and cluster-robust standard errors
105
105
  - **Panel data support**: Two-way fixed effects estimator for panel designs
106
+ - **Multi-period analysis**: Event-study style DiD with period-specific treatment effects
106
107
 
107
108
  ## Usage
108
109
 
@@ -224,6 +225,66 @@ results = twfe.fit(
224
225
  )
225
226
  ```
226
227
 
228
+ ### Multi-Period DiD (Event Study)
229
+
230
+ For settings with multiple pre- and post-treatment periods:
231
+
232
+ ```python
233
+ from diff_diff import MultiPeriodDiD
234
+
235
+ # Fit with multiple time periods
236
+ did = MultiPeriodDiD()
237
+ results = did.fit(
238
+ panel_data,
239
+ outcome='sales',
240
+ treatment='treated',
241
+ time='period',
242
+ post_periods=[3, 4, 5], # Periods 3-5 are post-treatment
243
+ reference_period=0 # Reference period for comparison
244
+ )
245
+
246
+ # View period-specific treatment effects
247
+ for period, effect in results.period_effects.items():
248
+ print(f"Period {period}: {effect.effect:.3f} (SE: {effect.se:.3f})")
249
+
250
+ # View average treatment effect across post-periods
251
+ print(f"Average ATT: {results.avg_att:.3f}")
252
+ print(f"Average SE: {results.avg_se:.3f}")
253
+
254
+ # Full summary with all period effects
255
+ results.print_summary()
256
+ ```
257
+
258
+ Output:
259
+ ```
260
+ ================================================================================
261
+ Multi-Period Difference-in-Differences Estimation Results
262
+ ================================================================================
263
+
264
+ Observations: 600
265
+ Pre-treatment periods: 3
266
+ Post-treatment periods: 3
267
+
268
+ --------------------------------------------------------------------------------
269
+ Average Treatment Effect
270
+ --------------------------------------------------------------------------------
271
+ Average ATT 5.2000 0.8234 6.315 0.0000
272
+ --------------------------------------------------------------------------------
273
+ 95% Confidence Interval: [3.5862, 6.8138]
274
+
275
+ Period-Specific Effects:
276
+ --------------------------------------------------------------------------------
277
+ Period Effect Std. Err. t-stat P>|t|
278
+ --------------------------------------------------------------------------------
279
+ 3 4.5000 0.9512 4.731 0.0000***
280
+ 4 5.2000 0.8876 5.858 0.0000***
281
+ 5 5.9000 0.9123 6.468 0.0000***
282
+ --------------------------------------------------------------------------------
283
+
284
+ Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1
285
+ ================================================================================
286
+ ```
287
+
227
288
  ## Working with Results
228
289
 
229
290
  ### Export Results
@@ -395,6 +456,71 @@ DifferenceInDifferences(
395
456
  | `to_dict()` | Convert to dictionary |
396
457
  | `to_dataframe()` | Convert to pandas DataFrame |
397
458
 
459
+ ### MultiPeriodDiD
460
+
461
+ ```python
462
+ MultiPeriodDiD(
463
+ robust=True, # Use HC1 robust standard errors
464
+ cluster=None, # Column for cluster-robust SEs
465
+ alpha=0.05 # Significance level for CIs
466
+ )
467
+ ```
468
+
469
+ **fit() Parameters:**
470
+
471
+ | Parameter | Type | Description |
472
+ |-----------|------|-------------|
473
+ | `data` | DataFrame | Input data |
474
+ | `outcome` | str | Outcome variable column name |
475
+ | `treatment` | str | Treatment indicator column (0/1) |
476
+ | `time` | str | Time period column (multiple values) |
477
+ | `post_periods` | list | List of post-treatment period values |
478
+ | `covariates` | list | Linear control variables |
479
+ | `fixed_effects` | list | Categorical FE columns (creates dummies) |
480
+ | `absorb` | list | High-dimensional FE (within-transformation) |
481
+ | `reference_period` | any | Omitted period for time dummies |
482
+
483
+ ### MultiPeriodDiDResults
484
+
485
+ **Attributes:**
486
+
487
+ | Attribute | Description |
488
+ |-----------|-------------|
489
+ | `period_effects` | Dict mapping periods to PeriodEffect objects |
490
+ | `avg_att` | Average ATT across post-treatment periods |
491
+ | `avg_se` | Standard error of average ATT |
492
+ | `avg_t_stat` | T-statistic for average ATT |
493
+ | `avg_p_value` | P-value for average ATT |
494
+ | `avg_conf_int` | Confidence interval for average ATT |
495
+ | `n_obs` | Number of observations |
496
+ | `pre_periods` | List of pre-treatment periods |
497
+ | `post_periods` | List of post-treatment periods |
498
+
499
+ **Methods:**
500
+
501
+ | Method | Description |
502
+ |--------|-------------|
503
+ | `get_effect(period)` | Get PeriodEffect for specific period |
504
+ | `summary(alpha)` | Get formatted summary string |
505
+ | `print_summary(alpha)` | Print summary to stdout |
506
+ | `to_dict()` | Convert to dictionary |
507
+ | `to_dataframe()` | Convert to pandas DataFrame |
508
+
509
+ ### PeriodEffect
510
+
511
+ **Attributes:**
512
+
513
+ | Attribute | Description |
514
+ |-----------|-------------|
515
+ | `period` | Time period identifier |
516
+ | `effect` | Treatment effect estimate |
517
+ | `se` | Standard error |
518
+ | `t_stat` | T-statistic |
519
+ | `p_value` | P-value |
520
+ | `conf_int` | Confidence interval |
521
+ | `is_significant` | Boolean for significance at 0.05 |
522
+ | `significance_stars` | String of significance stars |
523
+
398
524
  ## Requirements
399
525
 
400
526
  - Python >= 3.9
@@ -0,0 +1,8 @@
1
+ diff_diff/__init__.py,sha256=Xs0N2vloLpWcfVdNrzNHVyywGWeLyYXvEGqC0IbDKbI,495
2
+ diff_diff/estimators.py,sha256=M-HwruLO3JK0EEpDOfc3BtUe3WBXc4VNSbZEvnNkxMQ,34774
3
+ diff_diff/results.py,sha256=bBa-exmj81SZRZsHGBwkXF46-dGUEi6Oa8dpZhLVmpc,14113
4
+ diff_diff/utils.py,sha256=M8k92rxZbLZaRabaJlk4i21pTTyvukStn76v_cK8kEQ,20281
5
+ diff_diff-0.2.0.dist-info/METADATA,sha256=8LHL1vsG4TvQYg-I_of1TRngFQ0iGue8uqkieetN4sc,15389
6
+ diff_diff-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ diff_diff-0.2.0.dist-info/top_level.txt,sha256=-7mAFgjEQIA2okDLHlh5pDwBQXnsO1Z85qvtHjZILoQ,10
8
+ diff_diff-0.2.0.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- diff_diff/__init__.py,sha256=Jx8koPCUIMTcs8-Q-iuuYfXRztp-ccfrujGPYWoEHj0,360
2
- diff_diff/estimators.py,sha256=9yylm11pVjyQCmx2RmJYWYQxLETfBAu2-IYNI-xlyZg,21991
3
- diff_diff/results.py,sha256=PortPWdfZh25zVcSYSFnJ5maeredP6D4sNLIYd2ntzc,4961
4
- diff_diff/utils.py,sha256=LLzBPo6xmK7ZRkMGFEmlRSLq-VW1gxP-FzzG8zvAsiw,18200
5
- diff_diff-0.1.0.dist-info/METADATA,sha256=jw5DWL1DczDQcvmQHUdO_xyV2xZlKJkk6caP8iMmXPs,10974
6
- diff_diff-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- diff_diff-0.1.0.dist-info/top_level.txt,sha256=-7mAFgjEQIA2okDLHlh5pDwBQXnsO1Z85qvtHjZILoQ,10
8
- diff_diff-0.1.0.dist-info/RECORD,,