diff-diff 2.1.0__cp39-cp39-macosx_11_0_arm64.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 +234 -0
- diff_diff/_backend.py +64 -0
- diff_diff/_rust_backend.cpython-39-darwin.so +0 -0
- diff_diff/bacon.py +979 -0
- diff_diff/datasets.py +708 -0
- diff_diff/diagnostics.py +927 -0
- diff_diff/estimators.py +1000 -0
- diff_diff/honest_did.py +1493 -0
- diff_diff/linalg.py +980 -0
- diff_diff/power.py +1350 -0
- diff_diff/prep.py +1338 -0
- diff_diff/pretrends.py +1067 -0
- diff_diff/results.py +703 -0
- diff_diff/staggered.py +2297 -0
- diff_diff/sun_abraham.py +1176 -0
- diff_diff/synthetic_did.py +738 -0
- diff_diff/triple_diff.py +1291 -0
- diff_diff/trop.py +1348 -0
- diff_diff/twfe.py +344 -0
- diff_diff/utils.py +1481 -0
- diff_diff/visualization.py +1627 -0
- diff_diff-2.1.0.dist-info/METADATA +2511 -0
- diff_diff-2.1.0.dist-info/RECORD +24 -0
- diff_diff-2.1.0.dist-info/WHEEL +4 -0
diff_diff/results.py
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Results classes for difference-in-differences estimation.
|
|
3
|
+
|
|
4
|
+
Provides statsmodels-style output with a more Pythonic interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class DiDResults:
|
|
16
|
+
"""
|
|
17
|
+
Results from a Difference-in-Differences estimation.
|
|
18
|
+
|
|
19
|
+
Provides easy access to coefficients, standard errors, confidence intervals,
|
|
20
|
+
and summary statistics in a Pythonic way.
|
|
21
|
+
|
|
22
|
+
Attributes
|
|
23
|
+
----------
|
|
24
|
+
att : float
|
|
25
|
+
Average Treatment effect on the Treated (ATT).
|
|
26
|
+
se : float
|
|
27
|
+
Standard error of the ATT estimate.
|
|
28
|
+
t_stat : float
|
|
29
|
+
T-statistic for the ATT estimate.
|
|
30
|
+
p_value : float
|
|
31
|
+
P-value for the null hypothesis that ATT = 0.
|
|
32
|
+
conf_int : tuple[float, float]
|
|
33
|
+
Confidence interval for the ATT.
|
|
34
|
+
n_obs : int
|
|
35
|
+
Number of observations used in estimation.
|
|
36
|
+
n_treated : int
|
|
37
|
+
Number of treated units.
|
|
38
|
+
n_control : int
|
|
39
|
+
Number of control units.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
att: float
|
|
43
|
+
se: float
|
|
44
|
+
t_stat: float
|
|
45
|
+
p_value: float
|
|
46
|
+
conf_int: Tuple[float, float]
|
|
47
|
+
n_obs: int
|
|
48
|
+
n_treated: int
|
|
49
|
+
n_control: int
|
|
50
|
+
alpha: float = 0.05
|
|
51
|
+
coefficients: Optional[Dict[str, float]] = field(default=None)
|
|
52
|
+
vcov: Optional[np.ndarray] = field(default=None)
|
|
53
|
+
residuals: Optional[np.ndarray] = field(default=None)
|
|
54
|
+
fitted_values: Optional[np.ndarray] = field(default=None)
|
|
55
|
+
r_squared: Optional[float] = field(default=None)
|
|
56
|
+
# Bootstrap inference fields
|
|
57
|
+
inference_method: str = field(default="analytical")
|
|
58
|
+
n_bootstrap: Optional[int] = field(default=None)
|
|
59
|
+
n_clusters: Optional[int] = field(default=None)
|
|
60
|
+
bootstrap_distribution: Optional[np.ndarray] = field(default=None, repr=False)
|
|
61
|
+
|
|
62
|
+
def __repr__(self) -> str:
|
|
63
|
+
"""Concise string representation."""
|
|
64
|
+
return (
|
|
65
|
+
f"DiDResults(ATT={self.att:.4f}{self.significance_stars}, "
|
|
66
|
+
f"SE={self.se:.4f}, "
|
|
67
|
+
f"p={self.p_value:.4f})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def summary(self, alpha: Optional[float] = None) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Generate a formatted summary of the estimation results.
|
|
73
|
+
|
|
74
|
+
Parameters
|
|
75
|
+
----------
|
|
76
|
+
alpha : float, optional
|
|
77
|
+
Significance level for confidence intervals. Defaults to the
|
|
78
|
+
alpha used during estimation.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
str
|
|
83
|
+
Formatted summary table.
|
|
84
|
+
"""
|
|
85
|
+
alpha = alpha or self.alpha
|
|
86
|
+
conf_level = int((1 - alpha) * 100)
|
|
87
|
+
|
|
88
|
+
lines = [
|
|
89
|
+
"=" * 70,
|
|
90
|
+
"Difference-in-Differences Estimation Results".center(70),
|
|
91
|
+
"=" * 70,
|
|
92
|
+
"",
|
|
93
|
+
f"{'Observations:':<25} {self.n_obs:>10}",
|
|
94
|
+
f"{'Treated units:':<25} {self.n_treated:>10}",
|
|
95
|
+
f"{'Control units:':<25} {self.n_control:>10}",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
if self.r_squared is not None:
|
|
99
|
+
lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}")
|
|
100
|
+
|
|
101
|
+
# Add inference method info
|
|
102
|
+
if self.inference_method != "analytical":
|
|
103
|
+
lines.append(f"{'Inference method:':<25} {self.inference_method:>10}")
|
|
104
|
+
if self.n_bootstrap is not None:
|
|
105
|
+
lines.append(f"{'Bootstrap replications:':<25} {self.n_bootstrap:>10}")
|
|
106
|
+
if self.n_clusters is not None:
|
|
107
|
+
lines.append(f"{'Number of clusters:':<25} {self.n_clusters:>10}")
|
|
108
|
+
|
|
109
|
+
lines.extend([
|
|
110
|
+
"",
|
|
111
|
+
"-" * 70,
|
|
112
|
+
f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}",
|
|
113
|
+
"-" * 70,
|
|
114
|
+
f"{'ATT':<15} {self.att:>12.4f} {self.se:>12.4f} {self.t_stat:>10.3f} {self.p_value:>10.4f} {self.significance_stars:>5}",
|
|
115
|
+
"-" * 70,
|
|
116
|
+
"",
|
|
117
|
+
f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]",
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
# Add significance codes
|
|
121
|
+
lines.extend([
|
|
122
|
+
"",
|
|
123
|
+
"Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
|
|
124
|
+
"=" * 70,
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
def print_summary(self, alpha: Optional[float] = None) -> None:
|
|
130
|
+
"""Print the summary to stdout."""
|
|
131
|
+
print(self.summary(alpha))
|
|
132
|
+
|
|
133
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
134
|
+
"""
|
|
135
|
+
Convert results to a dictionary.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
Dict[str, Any]
|
|
140
|
+
Dictionary containing all estimation results.
|
|
141
|
+
"""
|
|
142
|
+
result = {
|
|
143
|
+
"att": self.att,
|
|
144
|
+
"se": self.se,
|
|
145
|
+
"t_stat": self.t_stat,
|
|
146
|
+
"p_value": self.p_value,
|
|
147
|
+
"conf_int_lower": self.conf_int[0],
|
|
148
|
+
"conf_int_upper": self.conf_int[1],
|
|
149
|
+
"n_obs": self.n_obs,
|
|
150
|
+
"n_treated": self.n_treated,
|
|
151
|
+
"n_control": self.n_control,
|
|
152
|
+
"r_squared": self.r_squared,
|
|
153
|
+
"inference_method": self.inference_method,
|
|
154
|
+
}
|
|
155
|
+
if self.n_bootstrap is not None:
|
|
156
|
+
result["n_bootstrap"] = self.n_bootstrap
|
|
157
|
+
if self.n_clusters is not None:
|
|
158
|
+
result["n_clusters"] = self.n_clusters
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
162
|
+
"""
|
|
163
|
+
Convert results to a pandas DataFrame.
|
|
164
|
+
|
|
165
|
+
Returns
|
|
166
|
+
-------
|
|
167
|
+
pd.DataFrame
|
|
168
|
+
DataFrame with estimation results.
|
|
169
|
+
"""
|
|
170
|
+
return pd.DataFrame([self.to_dict()])
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def is_significant(self) -> bool:
|
|
174
|
+
"""Check if the ATT is statistically significant at the alpha level."""
|
|
175
|
+
return bool(self.p_value < self.alpha)
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def significance_stars(self) -> str:
|
|
179
|
+
"""Return significance stars based on p-value."""
|
|
180
|
+
return _get_significance_stars(self.p_value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _get_significance_stars(p_value: float) -> str:
|
|
184
|
+
"""Return significance stars based on p-value."""
|
|
185
|
+
if p_value < 0.001:
|
|
186
|
+
return "***"
|
|
187
|
+
elif p_value < 0.01:
|
|
188
|
+
return "**"
|
|
189
|
+
elif p_value < 0.05:
|
|
190
|
+
return "*"
|
|
191
|
+
elif p_value < 0.1:
|
|
192
|
+
return "."
|
|
193
|
+
return ""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass
|
|
197
|
+
class PeriodEffect:
|
|
198
|
+
"""
|
|
199
|
+
Treatment effect for a single time period.
|
|
200
|
+
|
|
201
|
+
Attributes
|
|
202
|
+
----------
|
|
203
|
+
period : any
|
|
204
|
+
The time period identifier.
|
|
205
|
+
effect : float
|
|
206
|
+
The treatment effect estimate for this period.
|
|
207
|
+
se : float
|
|
208
|
+
Standard error of the effect estimate.
|
|
209
|
+
t_stat : float
|
|
210
|
+
T-statistic for the effect estimate.
|
|
211
|
+
p_value : float
|
|
212
|
+
P-value for the null hypothesis that effect = 0.
|
|
213
|
+
conf_int : tuple[float, float]
|
|
214
|
+
Confidence interval for the effect.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
period: Any
|
|
218
|
+
effect: float
|
|
219
|
+
se: float
|
|
220
|
+
t_stat: float
|
|
221
|
+
p_value: float
|
|
222
|
+
conf_int: Tuple[float, float]
|
|
223
|
+
|
|
224
|
+
def __repr__(self) -> str:
|
|
225
|
+
"""Concise string representation."""
|
|
226
|
+
sig = _get_significance_stars(self.p_value)
|
|
227
|
+
return (
|
|
228
|
+
f"PeriodEffect(period={self.period}, effect={self.effect:.4f}{sig}, "
|
|
229
|
+
f"SE={self.se:.4f}, p={self.p_value:.4f})"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_significant(self) -> bool:
|
|
234
|
+
"""Check if the effect is statistically significant at 0.05 level."""
|
|
235
|
+
return bool(self.p_value < 0.05)
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def significance_stars(self) -> str:
|
|
239
|
+
"""Return significance stars based on p-value."""
|
|
240
|
+
return _get_significance_stars(self.p_value)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@dataclass
|
|
244
|
+
class MultiPeriodDiDResults:
|
|
245
|
+
"""
|
|
246
|
+
Results from a Multi-Period Difference-in-Differences estimation.
|
|
247
|
+
|
|
248
|
+
Provides access to period-specific treatment effects as well as
|
|
249
|
+
an aggregate average treatment effect.
|
|
250
|
+
|
|
251
|
+
Attributes
|
|
252
|
+
----------
|
|
253
|
+
period_effects : dict[any, PeriodEffect]
|
|
254
|
+
Dictionary mapping period identifiers to their PeriodEffect objects.
|
|
255
|
+
avg_att : float
|
|
256
|
+
Average Treatment effect on the Treated across all post-periods.
|
|
257
|
+
avg_se : float
|
|
258
|
+
Standard error of the average ATT.
|
|
259
|
+
avg_t_stat : float
|
|
260
|
+
T-statistic for the average ATT.
|
|
261
|
+
avg_p_value : float
|
|
262
|
+
P-value for the null hypothesis that average ATT = 0.
|
|
263
|
+
avg_conf_int : tuple[float, float]
|
|
264
|
+
Confidence interval for the average ATT.
|
|
265
|
+
n_obs : int
|
|
266
|
+
Number of observations used in estimation.
|
|
267
|
+
n_treated : int
|
|
268
|
+
Number of treated observations.
|
|
269
|
+
n_control : int
|
|
270
|
+
Number of control observations.
|
|
271
|
+
pre_periods : list
|
|
272
|
+
List of pre-treatment period identifiers.
|
|
273
|
+
post_periods : list
|
|
274
|
+
List of post-treatment period identifiers.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
period_effects: Dict[Any, PeriodEffect]
|
|
278
|
+
avg_att: float
|
|
279
|
+
avg_se: float
|
|
280
|
+
avg_t_stat: float
|
|
281
|
+
avg_p_value: float
|
|
282
|
+
avg_conf_int: Tuple[float, float]
|
|
283
|
+
n_obs: int
|
|
284
|
+
n_treated: int
|
|
285
|
+
n_control: int
|
|
286
|
+
pre_periods: List[Any]
|
|
287
|
+
post_periods: List[Any]
|
|
288
|
+
alpha: float = 0.05
|
|
289
|
+
coefficients: Optional[Dict[str, float]] = field(default=None)
|
|
290
|
+
vcov: Optional[np.ndarray] = field(default=None)
|
|
291
|
+
residuals: Optional[np.ndarray] = field(default=None)
|
|
292
|
+
fitted_values: Optional[np.ndarray] = field(default=None)
|
|
293
|
+
r_squared: Optional[float] = field(default=None)
|
|
294
|
+
|
|
295
|
+
def __repr__(self) -> str:
|
|
296
|
+
"""Concise string representation."""
|
|
297
|
+
sig = _get_significance_stars(self.avg_p_value)
|
|
298
|
+
return (
|
|
299
|
+
f"MultiPeriodDiDResults(avg_ATT={self.avg_att:.4f}{sig}, "
|
|
300
|
+
f"SE={self.avg_se:.4f}, "
|
|
301
|
+
f"n_post_periods={len(self.post_periods)})"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def summary(self, alpha: Optional[float] = None) -> str:
|
|
305
|
+
"""
|
|
306
|
+
Generate a formatted summary of the estimation results.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
alpha : float, optional
|
|
311
|
+
Significance level for confidence intervals. Defaults to the
|
|
312
|
+
alpha used during estimation.
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
str
|
|
317
|
+
Formatted summary table.
|
|
318
|
+
"""
|
|
319
|
+
alpha = alpha or self.alpha
|
|
320
|
+
conf_level = int((1 - alpha) * 100)
|
|
321
|
+
|
|
322
|
+
lines = [
|
|
323
|
+
"=" * 80,
|
|
324
|
+
"Multi-Period Difference-in-Differences Estimation Results".center(80),
|
|
325
|
+
"=" * 80,
|
|
326
|
+
"",
|
|
327
|
+
f"{'Observations:':<25} {self.n_obs:>10}",
|
|
328
|
+
f"{'Treated observations:':<25} {self.n_treated:>10}",
|
|
329
|
+
f"{'Control observations:':<25} {self.n_control:>10}",
|
|
330
|
+
f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
|
|
331
|
+
f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
if self.r_squared is not None:
|
|
335
|
+
lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}")
|
|
336
|
+
|
|
337
|
+
# Period-specific effects
|
|
338
|
+
lines.extend([
|
|
339
|
+
"",
|
|
340
|
+
"-" * 80,
|
|
341
|
+
"Period-Specific Treatment Effects".center(80),
|
|
342
|
+
"-" * 80,
|
|
343
|
+
f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
|
|
344
|
+
"-" * 80,
|
|
345
|
+
])
|
|
346
|
+
|
|
347
|
+
for period in self.post_periods:
|
|
348
|
+
pe = self.period_effects[period]
|
|
349
|
+
stars = pe.significance_stars
|
|
350
|
+
lines.append(
|
|
351
|
+
f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} "
|
|
352
|
+
f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}"
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# Average effect
|
|
356
|
+
lines.extend([
|
|
357
|
+
"-" * 80,
|
|
358
|
+
"",
|
|
359
|
+
"-" * 80,
|
|
360
|
+
"Average Treatment Effect (across post-periods)".center(80),
|
|
361
|
+
"-" * 80,
|
|
362
|
+
f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
|
|
363
|
+
"-" * 80,
|
|
364
|
+
f"{'Avg ATT':<15} {self.avg_att:>12.4f} {self.avg_se:>12.4f} "
|
|
365
|
+
f"{self.avg_t_stat:>10.3f} {self.avg_p_value:>10.4f} {self.significance_stars:>6}",
|
|
366
|
+
"-" * 80,
|
|
367
|
+
"",
|
|
368
|
+
f"{conf_level}% Confidence Interval: [{self.avg_conf_int[0]:.4f}, {self.avg_conf_int[1]:.4f}]",
|
|
369
|
+
])
|
|
370
|
+
|
|
371
|
+
# Add significance codes
|
|
372
|
+
lines.extend([
|
|
373
|
+
"",
|
|
374
|
+
"Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
|
|
375
|
+
"=" * 80,
|
|
376
|
+
])
|
|
377
|
+
|
|
378
|
+
return "\n".join(lines)
|
|
379
|
+
|
|
380
|
+
def print_summary(self, alpha: Optional[float] = None) -> None:
|
|
381
|
+
"""Print the summary to stdout."""
|
|
382
|
+
print(self.summary(alpha))
|
|
383
|
+
|
|
384
|
+
def get_effect(self, period) -> PeriodEffect:
|
|
385
|
+
"""
|
|
386
|
+
Get the treatment effect for a specific period.
|
|
387
|
+
|
|
388
|
+
Parameters
|
|
389
|
+
----------
|
|
390
|
+
period : any
|
|
391
|
+
The period identifier.
|
|
392
|
+
|
|
393
|
+
Returns
|
|
394
|
+
-------
|
|
395
|
+
PeriodEffect
|
|
396
|
+
The treatment effect for the specified period.
|
|
397
|
+
|
|
398
|
+
Raises
|
|
399
|
+
------
|
|
400
|
+
KeyError
|
|
401
|
+
If the period is not found in post-treatment periods.
|
|
402
|
+
"""
|
|
403
|
+
if period not in self.period_effects:
|
|
404
|
+
raise KeyError(
|
|
405
|
+
f"Period '{period}' not found. "
|
|
406
|
+
f"Available post-periods: {list(self.period_effects.keys())}"
|
|
407
|
+
)
|
|
408
|
+
return self.period_effects[period]
|
|
409
|
+
|
|
410
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
411
|
+
"""
|
|
412
|
+
Convert results to a dictionary.
|
|
413
|
+
|
|
414
|
+
Returns
|
|
415
|
+
-------
|
|
416
|
+
Dict[str, Any]
|
|
417
|
+
Dictionary containing all estimation results.
|
|
418
|
+
"""
|
|
419
|
+
result: Dict[str, Any] = {
|
|
420
|
+
"avg_att": self.avg_att,
|
|
421
|
+
"avg_se": self.avg_se,
|
|
422
|
+
"avg_t_stat": self.avg_t_stat,
|
|
423
|
+
"avg_p_value": self.avg_p_value,
|
|
424
|
+
"avg_conf_int_lower": self.avg_conf_int[0],
|
|
425
|
+
"avg_conf_int_upper": self.avg_conf_int[1],
|
|
426
|
+
"n_obs": self.n_obs,
|
|
427
|
+
"n_treated": self.n_treated,
|
|
428
|
+
"n_control": self.n_control,
|
|
429
|
+
"n_pre_periods": len(self.pre_periods),
|
|
430
|
+
"n_post_periods": len(self.post_periods),
|
|
431
|
+
"r_squared": self.r_squared,
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
# Add period-specific effects
|
|
435
|
+
for period, pe in self.period_effects.items():
|
|
436
|
+
result[f"effect_period_{period}"] = pe.effect
|
|
437
|
+
result[f"se_period_{period}"] = pe.se
|
|
438
|
+
result[f"pval_period_{period}"] = pe.p_value
|
|
439
|
+
|
|
440
|
+
return result
|
|
441
|
+
|
|
442
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
443
|
+
"""
|
|
444
|
+
Convert period-specific effects to a pandas DataFrame.
|
|
445
|
+
|
|
446
|
+
Returns
|
|
447
|
+
-------
|
|
448
|
+
pd.DataFrame
|
|
449
|
+
DataFrame with one row per post-treatment period.
|
|
450
|
+
"""
|
|
451
|
+
rows = []
|
|
452
|
+
for period, pe in self.period_effects.items():
|
|
453
|
+
rows.append({
|
|
454
|
+
"period": period,
|
|
455
|
+
"effect": pe.effect,
|
|
456
|
+
"se": pe.se,
|
|
457
|
+
"t_stat": pe.t_stat,
|
|
458
|
+
"p_value": pe.p_value,
|
|
459
|
+
"conf_int_lower": pe.conf_int[0],
|
|
460
|
+
"conf_int_upper": pe.conf_int[1],
|
|
461
|
+
"is_significant": pe.is_significant,
|
|
462
|
+
})
|
|
463
|
+
return pd.DataFrame(rows)
|
|
464
|
+
|
|
465
|
+
@property
|
|
466
|
+
def is_significant(self) -> bool:
|
|
467
|
+
"""Check if the average ATT is statistically significant at the alpha level."""
|
|
468
|
+
return bool(self.avg_p_value < self.alpha)
|
|
469
|
+
|
|
470
|
+
@property
|
|
471
|
+
def significance_stars(self) -> str:
|
|
472
|
+
"""Return significance stars for the average ATT based on p-value."""
|
|
473
|
+
return _get_significance_stars(self.avg_p_value)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass
|
|
477
|
+
class SyntheticDiDResults:
|
|
478
|
+
"""
|
|
479
|
+
Results from a Synthetic Difference-in-Differences estimation.
|
|
480
|
+
|
|
481
|
+
Combines DiD with synthetic control by re-weighting control units to match
|
|
482
|
+
pre-treatment trends of treated units.
|
|
483
|
+
|
|
484
|
+
Attributes
|
|
485
|
+
----------
|
|
486
|
+
att : float
|
|
487
|
+
Average Treatment effect on the Treated (ATT).
|
|
488
|
+
se : float
|
|
489
|
+
Standard error of the ATT estimate (bootstrap or placebo-based).
|
|
490
|
+
t_stat : float
|
|
491
|
+
T-statistic for the ATT estimate.
|
|
492
|
+
p_value : float
|
|
493
|
+
P-value for the null hypothesis that ATT = 0.
|
|
494
|
+
conf_int : tuple[float, float]
|
|
495
|
+
Confidence interval for the ATT.
|
|
496
|
+
n_obs : int
|
|
497
|
+
Number of observations used in estimation.
|
|
498
|
+
n_treated : int
|
|
499
|
+
Number of treated units.
|
|
500
|
+
n_control : int
|
|
501
|
+
Number of control units.
|
|
502
|
+
unit_weights : dict
|
|
503
|
+
Dictionary mapping control unit IDs to their synthetic weights.
|
|
504
|
+
time_weights : dict
|
|
505
|
+
Dictionary mapping pre-treatment periods to their time weights.
|
|
506
|
+
pre_periods : list
|
|
507
|
+
List of pre-treatment period identifiers.
|
|
508
|
+
post_periods : list
|
|
509
|
+
List of post-treatment period identifiers.
|
|
510
|
+
variance_method : str
|
|
511
|
+
Method used for variance estimation: "bootstrap" or "placebo".
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
att: float
|
|
515
|
+
se: float
|
|
516
|
+
t_stat: float
|
|
517
|
+
p_value: float
|
|
518
|
+
conf_int: Tuple[float, float]
|
|
519
|
+
n_obs: int
|
|
520
|
+
n_treated: int
|
|
521
|
+
n_control: int
|
|
522
|
+
unit_weights: Dict[Any, float]
|
|
523
|
+
time_weights: Dict[Any, float]
|
|
524
|
+
pre_periods: List[Any]
|
|
525
|
+
post_periods: List[Any]
|
|
526
|
+
alpha: float = 0.05
|
|
527
|
+
variance_method: str = field(default="bootstrap")
|
|
528
|
+
lambda_reg: Optional[float] = field(default=None)
|
|
529
|
+
pre_treatment_fit: Optional[float] = field(default=None)
|
|
530
|
+
placebo_effects: Optional[np.ndarray] = field(default=None)
|
|
531
|
+
n_bootstrap: Optional[int] = field(default=None)
|
|
532
|
+
|
|
533
|
+
def __repr__(self) -> str:
|
|
534
|
+
"""Concise string representation."""
|
|
535
|
+
sig = _get_significance_stars(self.p_value)
|
|
536
|
+
return (
|
|
537
|
+
f"SyntheticDiDResults(ATT={self.att:.4f}{sig}, "
|
|
538
|
+
f"SE={self.se:.4f}, "
|
|
539
|
+
f"p={self.p_value:.4f})"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def summary(self, alpha: Optional[float] = None) -> str:
|
|
543
|
+
"""
|
|
544
|
+
Generate a formatted summary of the estimation results.
|
|
545
|
+
|
|
546
|
+
Parameters
|
|
547
|
+
----------
|
|
548
|
+
alpha : float, optional
|
|
549
|
+
Significance level for confidence intervals. Defaults to the
|
|
550
|
+
alpha used during estimation.
|
|
551
|
+
|
|
552
|
+
Returns
|
|
553
|
+
-------
|
|
554
|
+
str
|
|
555
|
+
Formatted summary table.
|
|
556
|
+
"""
|
|
557
|
+
alpha = alpha or self.alpha
|
|
558
|
+
conf_level = int((1 - alpha) * 100)
|
|
559
|
+
|
|
560
|
+
lines = [
|
|
561
|
+
"=" * 75,
|
|
562
|
+
"Synthetic Difference-in-Differences Estimation Results".center(75),
|
|
563
|
+
"=" * 75,
|
|
564
|
+
"",
|
|
565
|
+
f"{'Observations:':<25} {self.n_obs:>10}",
|
|
566
|
+
f"{'Treated units:':<25} {self.n_treated:>10}",
|
|
567
|
+
f"{'Control units:':<25} {self.n_control:>10}",
|
|
568
|
+
f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
|
|
569
|
+
f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
|
|
570
|
+
]
|
|
571
|
+
|
|
572
|
+
if self.lambda_reg is not None:
|
|
573
|
+
lines.append(f"{'Regularization (lambda):':<25} {self.lambda_reg:>10.4f}")
|
|
574
|
+
|
|
575
|
+
if self.pre_treatment_fit is not None:
|
|
576
|
+
lines.append(f"{'Pre-treatment fit (RMSE):':<25} {self.pre_treatment_fit:>10.4f}")
|
|
577
|
+
|
|
578
|
+
# Variance method info
|
|
579
|
+
lines.append(f"{'Variance method:':<25} {self.variance_method:>10}")
|
|
580
|
+
if self.variance_method == "bootstrap" and self.n_bootstrap is not None:
|
|
581
|
+
lines.append(f"{'Bootstrap replications:':<25} {self.n_bootstrap:>10}")
|
|
582
|
+
|
|
583
|
+
lines.extend([
|
|
584
|
+
"",
|
|
585
|
+
"-" * 75,
|
|
586
|
+
f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}",
|
|
587
|
+
"-" * 75,
|
|
588
|
+
f"{'ATT':<15} {self.att:>12.4f} {self.se:>12.4f} {self.t_stat:>10.3f} {self.p_value:>10.4f} {self.significance_stars:>5}",
|
|
589
|
+
"-" * 75,
|
|
590
|
+
"",
|
|
591
|
+
f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]",
|
|
592
|
+
])
|
|
593
|
+
|
|
594
|
+
# Show top unit weights
|
|
595
|
+
if self.unit_weights:
|
|
596
|
+
sorted_weights = sorted(
|
|
597
|
+
self.unit_weights.items(), key=lambda x: x[1], reverse=True
|
|
598
|
+
)
|
|
599
|
+
top_n = min(5, len(sorted_weights))
|
|
600
|
+
lines.extend([
|
|
601
|
+
"",
|
|
602
|
+
"-" * 75,
|
|
603
|
+
"Top Unit Weights (Synthetic Control)".center(75),
|
|
604
|
+
"-" * 75,
|
|
605
|
+
])
|
|
606
|
+
for unit, weight in sorted_weights[:top_n]:
|
|
607
|
+
if weight > 0.001: # Only show meaningful weights
|
|
608
|
+
lines.append(f" Unit {unit}: {weight:.4f}")
|
|
609
|
+
|
|
610
|
+
# Show how many units have non-trivial weight
|
|
611
|
+
n_nonzero = sum(1 for w in self.unit_weights.values() if w > 0.001)
|
|
612
|
+
lines.append(f" ({n_nonzero} units with weight > 0.001)")
|
|
613
|
+
|
|
614
|
+
# Add significance codes
|
|
615
|
+
lines.extend([
|
|
616
|
+
"",
|
|
617
|
+
"Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
|
|
618
|
+
"=" * 75,
|
|
619
|
+
])
|
|
620
|
+
|
|
621
|
+
return "\n".join(lines)
|
|
622
|
+
|
|
623
|
+
def print_summary(self, alpha: Optional[float] = None) -> None:
|
|
624
|
+
"""Print the summary to stdout."""
|
|
625
|
+
print(self.summary(alpha))
|
|
626
|
+
|
|
627
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
628
|
+
"""
|
|
629
|
+
Convert results to a dictionary.
|
|
630
|
+
|
|
631
|
+
Returns
|
|
632
|
+
-------
|
|
633
|
+
Dict[str, Any]
|
|
634
|
+
Dictionary containing all estimation results.
|
|
635
|
+
"""
|
|
636
|
+
result = {
|
|
637
|
+
"att": self.att,
|
|
638
|
+
"se": self.se,
|
|
639
|
+
"t_stat": self.t_stat,
|
|
640
|
+
"p_value": self.p_value,
|
|
641
|
+
"conf_int_lower": self.conf_int[0],
|
|
642
|
+
"conf_int_upper": self.conf_int[1],
|
|
643
|
+
"n_obs": self.n_obs,
|
|
644
|
+
"n_treated": self.n_treated,
|
|
645
|
+
"n_control": self.n_control,
|
|
646
|
+
"n_pre_periods": len(self.pre_periods),
|
|
647
|
+
"n_post_periods": len(self.post_periods),
|
|
648
|
+
"variance_method": self.variance_method,
|
|
649
|
+
"lambda_reg": self.lambda_reg,
|
|
650
|
+
"pre_treatment_fit": self.pre_treatment_fit,
|
|
651
|
+
}
|
|
652
|
+
if self.n_bootstrap is not None:
|
|
653
|
+
result["n_bootstrap"] = self.n_bootstrap
|
|
654
|
+
return result
|
|
655
|
+
|
|
656
|
+
def to_dataframe(self) -> pd.DataFrame:
|
|
657
|
+
"""
|
|
658
|
+
Convert results to a pandas DataFrame.
|
|
659
|
+
|
|
660
|
+
Returns
|
|
661
|
+
-------
|
|
662
|
+
pd.DataFrame
|
|
663
|
+
DataFrame with estimation results.
|
|
664
|
+
"""
|
|
665
|
+
return pd.DataFrame([self.to_dict()])
|
|
666
|
+
|
|
667
|
+
def get_unit_weights_df(self) -> pd.DataFrame:
|
|
668
|
+
"""
|
|
669
|
+
Get unit weights as a pandas DataFrame.
|
|
670
|
+
|
|
671
|
+
Returns
|
|
672
|
+
-------
|
|
673
|
+
pd.DataFrame
|
|
674
|
+
DataFrame with unit IDs and their weights.
|
|
675
|
+
"""
|
|
676
|
+
return pd.DataFrame([
|
|
677
|
+
{"unit": unit, "weight": weight}
|
|
678
|
+
for unit, weight in self.unit_weights.items()
|
|
679
|
+
]).sort_values("weight", ascending=False)
|
|
680
|
+
|
|
681
|
+
def get_time_weights_df(self) -> pd.DataFrame:
|
|
682
|
+
"""
|
|
683
|
+
Get time weights as a pandas DataFrame.
|
|
684
|
+
|
|
685
|
+
Returns
|
|
686
|
+
-------
|
|
687
|
+
pd.DataFrame
|
|
688
|
+
DataFrame with time periods and their weights.
|
|
689
|
+
"""
|
|
690
|
+
return pd.DataFrame([
|
|
691
|
+
{"period": period, "weight": weight}
|
|
692
|
+
for period, weight in self.time_weights.items()
|
|
693
|
+
])
|
|
694
|
+
|
|
695
|
+
@property
|
|
696
|
+
def is_significant(self) -> bool:
|
|
697
|
+
"""Check if the ATT is statistically significant at the alpha level."""
|
|
698
|
+
return bool(self.p_value < self.alpha)
|
|
699
|
+
|
|
700
|
+
@property
|
|
701
|
+
def significance_stars(self) -> str:
|
|
702
|
+
"""Return significance stars based on p-value."""
|
|
703
|
+
return _get_significance_stars(self.p_value)
|