diff-diff 2.2.0__cp39-cp39-win_amd64.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,710 @@
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
+
186
+ Returns empty string for NaN p-values (unidentified coefficients from
187
+ rank-deficient matrices).
188
+ """
189
+ import numpy as np
190
+ if np.isnan(p_value):
191
+ return ""
192
+ if p_value < 0.001:
193
+ return "***"
194
+ elif p_value < 0.01:
195
+ return "**"
196
+ elif p_value < 0.05:
197
+ return "*"
198
+ elif p_value < 0.1:
199
+ return "."
200
+ return ""
201
+
202
+
203
+ @dataclass
204
+ class PeriodEffect:
205
+ """
206
+ Treatment effect for a single time period.
207
+
208
+ Attributes
209
+ ----------
210
+ period : any
211
+ The time period identifier.
212
+ effect : float
213
+ The treatment effect estimate for this period.
214
+ se : float
215
+ Standard error of the effect estimate.
216
+ t_stat : float
217
+ T-statistic for the effect estimate.
218
+ p_value : float
219
+ P-value for the null hypothesis that effect = 0.
220
+ conf_int : tuple[float, float]
221
+ Confidence interval for the effect.
222
+ """
223
+
224
+ period: Any
225
+ effect: float
226
+ se: float
227
+ t_stat: float
228
+ p_value: float
229
+ conf_int: Tuple[float, float]
230
+
231
+ def __repr__(self) -> str:
232
+ """Concise string representation."""
233
+ sig = _get_significance_stars(self.p_value)
234
+ return (
235
+ f"PeriodEffect(period={self.period}, effect={self.effect:.4f}{sig}, "
236
+ f"SE={self.se:.4f}, p={self.p_value:.4f})"
237
+ )
238
+
239
+ @property
240
+ def is_significant(self) -> bool:
241
+ """Check if the effect is statistically significant at 0.05 level."""
242
+ return bool(self.p_value < 0.05)
243
+
244
+ @property
245
+ def significance_stars(self) -> str:
246
+ """Return significance stars based on p-value."""
247
+ return _get_significance_stars(self.p_value)
248
+
249
+
250
+ @dataclass
251
+ class MultiPeriodDiDResults:
252
+ """
253
+ Results from a Multi-Period Difference-in-Differences estimation.
254
+
255
+ Provides access to period-specific treatment effects as well as
256
+ an aggregate average treatment effect.
257
+
258
+ Attributes
259
+ ----------
260
+ period_effects : dict[any, PeriodEffect]
261
+ Dictionary mapping period identifiers to their PeriodEffect objects.
262
+ avg_att : float
263
+ Average Treatment effect on the Treated across all post-periods.
264
+ avg_se : float
265
+ Standard error of the average ATT.
266
+ avg_t_stat : float
267
+ T-statistic for the average ATT.
268
+ avg_p_value : float
269
+ P-value for the null hypothesis that average ATT = 0.
270
+ avg_conf_int : tuple[float, float]
271
+ Confidence interval for the average ATT.
272
+ n_obs : int
273
+ Number of observations used in estimation.
274
+ n_treated : int
275
+ Number of treated observations.
276
+ n_control : int
277
+ Number of control observations.
278
+ pre_periods : list
279
+ List of pre-treatment period identifiers.
280
+ post_periods : list
281
+ List of post-treatment period identifiers.
282
+ """
283
+
284
+ period_effects: Dict[Any, PeriodEffect]
285
+ avg_att: float
286
+ avg_se: float
287
+ avg_t_stat: float
288
+ avg_p_value: float
289
+ avg_conf_int: Tuple[float, float]
290
+ n_obs: int
291
+ n_treated: int
292
+ n_control: int
293
+ pre_periods: List[Any]
294
+ post_periods: List[Any]
295
+ alpha: float = 0.05
296
+ coefficients: Optional[Dict[str, float]] = field(default=None)
297
+ vcov: Optional[np.ndarray] = field(default=None)
298
+ residuals: Optional[np.ndarray] = field(default=None)
299
+ fitted_values: Optional[np.ndarray] = field(default=None)
300
+ r_squared: Optional[float] = field(default=None)
301
+
302
+ def __repr__(self) -> str:
303
+ """Concise string representation."""
304
+ sig = _get_significance_stars(self.avg_p_value)
305
+ return (
306
+ f"MultiPeriodDiDResults(avg_ATT={self.avg_att:.4f}{sig}, "
307
+ f"SE={self.avg_se:.4f}, "
308
+ f"n_post_periods={len(self.post_periods)})"
309
+ )
310
+
311
+ def summary(self, alpha: Optional[float] = None) -> str:
312
+ """
313
+ Generate a formatted summary of the estimation results.
314
+
315
+ Parameters
316
+ ----------
317
+ alpha : float, optional
318
+ Significance level for confidence intervals. Defaults to the
319
+ alpha used during estimation.
320
+
321
+ Returns
322
+ -------
323
+ str
324
+ Formatted summary table.
325
+ """
326
+ alpha = alpha or self.alpha
327
+ conf_level = int((1 - alpha) * 100)
328
+
329
+ lines = [
330
+ "=" * 80,
331
+ "Multi-Period Difference-in-Differences Estimation Results".center(80),
332
+ "=" * 80,
333
+ "",
334
+ f"{'Observations:':<25} {self.n_obs:>10}",
335
+ f"{'Treated observations:':<25} {self.n_treated:>10}",
336
+ f"{'Control observations:':<25} {self.n_control:>10}",
337
+ f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
338
+ f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
339
+ ]
340
+
341
+ if self.r_squared is not None:
342
+ lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}")
343
+
344
+ # Period-specific effects
345
+ lines.extend([
346
+ "",
347
+ "-" * 80,
348
+ "Period-Specific Treatment Effects".center(80),
349
+ "-" * 80,
350
+ f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
351
+ "-" * 80,
352
+ ])
353
+
354
+ for period in self.post_periods:
355
+ pe = self.period_effects[period]
356
+ stars = pe.significance_stars
357
+ lines.append(
358
+ f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} "
359
+ f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}"
360
+ )
361
+
362
+ # Average effect
363
+ lines.extend([
364
+ "-" * 80,
365
+ "",
366
+ "-" * 80,
367
+ "Average Treatment Effect (across post-periods)".center(80),
368
+ "-" * 80,
369
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
370
+ "-" * 80,
371
+ f"{'Avg ATT':<15} {self.avg_att:>12.4f} {self.avg_se:>12.4f} "
372
+ f"{self.avg_t_stat:>10.3f} {self.avg_p_value:>10.4f} {self.significance_stars:>6}",
373
+ "-" * 80,
374
+ "",
375
+ f"{conf_level}% Confidence Interval: [{self.avg_conf_int[0]:.4f}, {self.avg_conf_int[1]:.4f}]",
376
+ ])
377
+
378
+ # Add significance codes
379
+ lines.extend([
380
+ "",
381
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
382
+ "=" * 80,
383
+ ])
384
+
385
+ return "\n".join(lines)
386
+
387
+ def print_summary(self, alpha: Optional[float] = None) -> None:
388
+ """Print the summary to stdout."""
389
+ print(self.summary(alpha))
390
+
391
+ def get_effect(self, period) -> PeriodEffect:
392
+ """
393
+ Get the treatment effect for a specific period.
394
+
395
+ Parameters
396
+ ----------
397
+ period : any
398
+ The period identifier.
399
+
400
+ Returns
401
+ -------
402
+ PeriodEffect
403
+ The treatment effect for the specified period.
404
+
405
+ Raises
406
+ ------
407
+ KeyError
408
+ If the period is not found in post-treatment periods.
409
+ """
410
+ if period not in self.period_effects:
411
+ raise KeyError(
412
+ f"Period '{period}' not found. "
413
+ f"Available post-periods: {list(self.period_effects.keys())}"
414
+ )
415
+ return self.period_effects[period]
416
+
417
+ def to_dict(self) -> Dict[str, Any]:
418
+ """
419
+ Convert results to a dictionary.
420
+
421
+ Returns
422
+ -------
423
+ Dict[str, Any]
424
+ Dictionary containing all estimation results.
425
+ """
426
+ result: Dict[str, Any] = {
427
+ "avg_att": self.avg_att,
428
+ "avg_se": self.avg_se,
429
+ "avg_t_stat": self.avg_t_stat,
430
+ "avg_p_value": self.avg_p_value,
431
+ "avg_conf_int_lower": self.avg_conf_int[0],
432
+ "avg_conf_int_upper": self.avg_conf_int[1],
433
+ "n_obs": self.n_obs,
434
+ "n_treated": self.n_treated,
435
+ "n_control": self.n_control,
436
+ "n_pre_periods": len(self.pre_periods),
437
+ "n_post_periods": len(self.post_periods),
438
+ "r_squared": self.r_squared,
439
+ }
440
+
441
+ # Add period-specific effects
442
+ for period, pe in self.period_effects.items():
443
+ result[f"effect_period_{period}"] = pe.effect
444
+ result[f"se_period_{period}"] = pe.se
445
+ result[f"pval_period_{period}"] = pe.p_value
446
+
447
+ return result
448
+
449
+ def to_dataframe(self) -> pd.DataFrame:
450
+ """
451
+ Convert period-specific effects to a pandas DataFrame.
452
+
453
+ Returns
454
+ -------
455
+ pd.DataFrame
456
+ DataFrame with one row per post-treatment period.
457
+ """
458
+ rows = []
459
+ for period, pe in self.period_effects.items():
460
+ rows.append({
461
+ "period": period,
462
+ "effect": pe.effect,
463
+ "se": pe.se,
464
+ "t_stat": pe.t_stat,
465
+ "p_value": pe.p_value,
466
+ "conf_int_lower": pe.conf_int[0],
467
+ "conf_int_upper": pe.conf_int[1],
468
+ "is_significant": pe.is_significant,
469
+ })
470
+ return pd.DataFrame(rows)
471
+
472
+ @property
473
+ def is_significant(self) -> bool:
474
+ """Check if the average ATT is statistically significant at the alpha level."""
475
+ return bool(self.avg_p_value < self.alpha)
476
+
477
+ @property
478
+ def significance_stars(self) -> str:
479
+ """Return significance stars for the average ATT based on p-value."""
480
+ return _get_significance_stars(self.avg_p_value)
481
+
482
+
483
+ @dataclass
484
+ class SyntheticDiDResults:
485
+ """
486
+ Results from a Synthetic Difference-in-Differences estimation.
487
+
488
+ Combines DiD with synthetic control by re-weighting control units to match
489
+ pre-treatment trends of treated units.
490
+
491
+ Attributes
492
+ ----------
493
+ att : float
494
+ Average Treatment effect on the Treated (ATT).
495
+ se : float
496
+ Standard error of the ATT estimate (bootstrap or placebo-based).
497
+ t_stat : float
498
+ T-statistic for the ATT estimate.
499
+ p_value : float
500
+ P-value for the null hypothesis that ATT = 0.
501
+ conf_int : tuple[float, float]
502
+ Confidence interval for the ATT.
503
+ n_obs : int
504
+ Number of observations used in estimation.
505
+ n_treated : int
506
+ Number of treated units.
507
+ n_control : int
508
+ Number of control units.
509
+ unit_weights : dict
510
+ Dictionary mapping control unit IDs to their synthetic weights.
511
+ time_weights : dict
512
+ Dictionary mapping pre-treatment periods to their time weights.
513
+ pre_periods : list
514
+ List of pre-treatment period identifiers.
515
+ post_periods : list
516
+ List of post-treatment period identifiers.
517
+ variance_method : str
518
+ Method used for variance estimation: "bootstrap" or "placebo".
519
+ """
520
+
521
+ att: float
522
+ se: float
523
+ t_stat: float
524
+ p_value: float
525
+ conf_int: Tuple[float, float]
526
+ n_obs: int
527
+ n_treated: int
528
+ n_control: int
529
+ unit_weights: Dict[Any, float]
530
+ time_weights: Dict[Any, float]
531
+ pre_periods: List[Any]
532
+ post_periods: List[Any]
533
+ alpha: float = 0.05
534
+ variance_method: str = field(default="bootstrap")
535
+ lambda_reg: Optional[float] = field(default=None)
536
+ pre_treatment_fit: Optional[float] = field(default=None)
537
+ placebo_effects: Optional[np.ndarray] = field(default=None)
538
+ n_bootstrap: Optional[int] = field(default=None)
539
+
540
+ def __repr__(self) -> str:
541
+ """Concise string representation."""
542
+ sig = _get_significance_stars(self.p_value)
543
+ return (
544
+ f"SyntheticDiDResults(ATT={self.att:.4f}{sig}, "
545
+ f"SE={self.se:.4f}, "
546
+ f"p={self.p_value:.4f})"
547
+ )
548
+
549
+ def summary(self, alpha: Optional[float] = None) -> str:
550
+ """
551
+ Generate a formatted summary of the estimation results.
552
+
553
+ Parameters
554
+ ----------
555
+ alpha : float, optional
556
+ Significance level for confidence intervals. Defaults to the
557
+ alpha used during estimation.
558
+
559
+ Returns
560
+ -------
561
+ str
562
+ Formatted summary table.
563
+ """
564
+ alpha = alpha or self.alpha
565
+ conf_level = int((1 - alpha) * 100)
566
+
567
+ lines = [
568
+ "=" * 75,
569
+ "Synthetic Difference-in-Differences Estimation Results".center(75),
570
+ "=" * 75,
571
+ "",
572
+ f"{'Observations:':<25} {self.n_obs:>10}",
573
+ f"{'Treated units:':<25} {self.n_treated:>10}",
574
+ f"{'Control units:':<25} {self.n_control:>10}",
575
+ f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
576
+ f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
577
+ ]
578
+
579
+ if self.lambda_reg is not None:
580
+ lines.append(f"{'Regularization (lambda):':<25} {self.lambda_reg:>10.4f}")
581
+
582
+ if self.pre_treatment_fit is not None:
583
+ lines.append(f"{'Pre-treatment fit (RMSE):':<25} {self.pre_treatment_fit:>10.4f}")
584
+
585
+ # Variance method info
586
+ lines.append(f"{'Variance method:':<25} {self.variance_method:>10}")
587
+ if self.variance_method == "bootstrap" and self.n_bootstrap is not None:
588
+ lines.append(f"{'Bootstrap replications:':<25} {self.n_bootstrap:>10}")
589
+
590
+ lines.extend([
591
+ "",
592
+ "-" * 75,
593
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}",
594
+ "-" * 75,
595
+ 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}",
596
+ "-" * 75,
597
+ "",
598
+ f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]",
599
+ ])
600
+
601
+ # Show top unit weights
602
+ if self.unit_weights:
603
+ sorted_weights = sorted(
604
+ self.unit_weights.items(), key=lambda x: x[1], reverse=True
605
+ )
606
+ top_n = min(5, len(sorted_weights))
607
+ lines.extend([
608
+ "",
609
+ "-" * 75,
610
+ "Top Unit Weights (Synthetic Control)".center(75),
611
+ "-" * 75,
612
+ ])
613
+ for unit, weight in sorted_weights[:top_n]:
614
+ if weight > 0.001: # Only show meaningful weights
615
+ lines.append(f" Unit {unit}: {weight:.4f}")
616
+
617
+ # Show how many units have non-trivial weight
618
+ n_nonzero = sum(1 for w in self.unit_weights.values() if w > 0.001)
619
+ lines.append(f" ({n_nonzero} units with weight > 0.001)")
620
+
621
+ # Add significance codes
622
+ lines.extend([
623
+ "",
624
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
625
+ "=" * 75,
626
+ ])
627
+
628
+ return "\n".join(lines)
629
+
630
+ def print_summary(self, alpha: Optional[float] = None) -> None:
631
+ """Print the summary to stdout."""
632
+ print(self.summary(alpha))
633
+
634
+ def to_dict(self) -> Dict[str, Any]:
635
+ """
636
+ Convert results to a dictionary.
637
+
638
+ Returns
639
+ -------
640
+ Dict[str, Any]
641
+ Dictionary containing all estimation results.
642
+ """
643
+ result = {
644
+ "att": self.att,
645
+ "se": self.se,
646
+ "t_stat": self.t_stat,
647
+ "p_value": self.p_value,
648
+ "conf_int_lower": self.conf_int[0],
649
+ "conf_int_upper": self.conf_int[1],
650
+ "n_obs": self.n_obs,
651
+ "n_treated": self.n_treated,
652
+ "n_control": self.n_control,
653
+ "n_pre_periods": len(self.pre_periods),
654
+ "n_post_periods": len(self.post_periods),
655
+ "variance_method": self.variance_method,
656
+ "lambda_reg": self.lambda_reg,
657
+ "pre_treatment_fit": self.pre_treatment_fit,
658
+ }
659
+ if self.n_bootstrap is not None:
660
+ result["n_bootstrap"] = self.n_bootstrap
661
+ return result
662
+
663
+ def to_dataframe(self) -> pd.DataFrame:
664
+ """
665
+ Convert results to a pandas DataFrame.
666
+
667
+ Returns
668
+ -------
669
+ pd.DataFrame
670
+ DataFrame with estimation results.
671
+ """
672
+ return pd.DataFrame([self.to_dict()])
673
+
674
+ def get_unit_weights_df(self) -> pd.DataFrame:
675
+ """
676
+ Get unit weights as a pandas DataFrame.
677
+
678
+ Returns
679
+ -------
680
+ pd.DataFrame
681
+ DataFrame with unit IDs and their weights.
682
+ """
683
+ return pd.DataFrame([
684
+ {"unit": unit, "weight": weight}
685
+ for unit, weight in self.unit_weights.items()
686
+ ]).sort_values("weight", ascending=False)
687
+
688
+ def get_time_weights_df(self) -> pd.DataFrame:
689
+ """
690
+ Get time weights as a pandas DataFrame.
691
+
692
+ Returns
693
+ -------
694
+ pd.DataFrame
695
+ DataFrame with time periods and their weights.
696
+ """
697
+ return pd.DataFrame([
698
+ {"period": period, "weight": weight}
699
+ for period, weight in self.time_weights.items()
700
+ ])
701
+
702
+ @property
703
+ def is_significant(self) -> bool:
704
+ """Check if the ATT is statistically significant at the alpha level."""
705
+ return bool(self.p_value < self.alpha)
706
+
707
+ @property
708
+ def significance_stars(self) -> str:
709
+ """Return significance stars based on p-value."""
710
+ return _get_significance_stars(self.p_value)