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/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)