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 +10 -4
- diff_diff/estimators.py +356 -7
- diff_diff/results.py +293 -0
- diff_diff/utils.py +74 -20
- {diff_diff-0.1.0.dist-info → diff_diff-0.2.0.dist-info}/METADATA +128 -2
- diff_diff-0.2.0.dist-info/RECORD +8 -0
- diff_diff-0.1.0.dist-info/RECORD +0 -8
- {diff_diff-0.1.0.dist-info → diff_diff-0.2.0.dist-info}/WHEEL +0 -0
- {diff_diff-0.1.0.dist-info → diff_diff-0.2.0.dist-info}/top_level.txt +0 -0
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.
|
|
12
|
-
__all__ = [
|
|
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
|
-
|
|
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
|
-
|
|
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 /
|
|
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
|
-
|
|
325
|
-
|
|
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 =
|
|
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 (
|
|
395
|
+
# and normalized Wasserstein is small (below threshold)
|
|
387
396
|
plausible = bool(
|
|
388
397
|
wasserstein_p > 0.05 and
|
|
389
|
-
(wasserstein_normalized <
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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.
|
|
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,,
|
diff_diff-0.1.0.dist-info/RECORD
DELETED
|
@@ -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,,
|
|
File without changes
|
|
File without changes
|