diff-diff 2.3.2__cp313-cp313-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,794 @@
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
+ "",
112
+ "-" * 70,
113
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}",
114
+ "-" * 70,
115
+ 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}",
116
+ "-" * 70,
117
+ "",
118
+ f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]",
119
+ ]
120
+ )
121
+
122
+ # Add significance codes
123
+ lines.extend(
124
+ [
125
+ "",
126
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
127
+ "=" * 70,
128
+ ]
129
+ )
130
+
131
+ return "\n".join(lines)
132
+
133
+ def print_summary(self, alpha: Optional[float] = None) -> None:
134
+ """Print the summary to stdout."""
135
+ print(self.summary(alpha))
136
+
137
+ def to_dict(self) -> Dict[str, Any]:
138
+ """
139
+ Convert results to a dictionary.
140
+
141
+ Returns
142
+ -------
143
+ Dict[str, Any]
144
+ Dictionary containing all estimation results.
145
+ """
146
+ result = {
147
+ "att": self.att,
148
+ "se": self.se,
149
+ "t_stat": self.t_stat,
150
+ "p_value": self.p_value,
151
+ "conf_int_lower": self.conf_int[0],
152
+ "conf_int_upper": self.conf_int[1],
153
+ "n_obs": self.n_obs,
154
+ "n_treated": self.n_treated,
155
+ "n_control": self.n_control,
156
+ "r_squared": self.r_squared,
157
+ "inference_method": self.inference_method,
158
+ }
159
+ if self.n_bootstrap is not None:
160
+ result["n_bootstrap"] = self.n_bootstrap
161
+ if self.n_clusters is not None:
162
+ result["n_clusters"] = self.n_clusters
163
+ return result
164
+
165
+ def to_dataframe(self) -> pd.DataFrame:
166
+ """
167
+ Convert results to a pandas DataFrame.
168
+
169
+ Returns
170
+ -------
171
+ pd.DataFrame
172
+ DataFrame with estimation results.
173
+ """
174
+ return pd.DataFrame([self.to_dict()])
175
+
176
+ @property
177
+ def is_significant(self) -> bool:
178
+ """Check if the ATT is statistically significant at the alpha level."""
179
+ return bool(self.p_value < self.alpha)
180
+
181
+ @property
182
+ def significance_stars(self) -> str:
183
+ """Return significance stars based on p-value."""
184
+ return _get_significance_stars(self.p_value)
185
+
186
+
187
+ def _get_significance_stars(p_value: float) -> str:
188
+ """Return significance stars based on p-value.
189
+
190
+ Returns empty string for NaN p-values (unidentified coefficients from
191
+ rank-deficient matrices).
192
+ """
193
+ import numpy as np
194
+
195
+ if np.isnan(p_value):
196
+ return ""
197
+ if p_value < 0.001:
198
+ return "***"
199
+ elif p_value < 0.01:
200
+ return "**"
201
+ elif p_value < 0.05:
202
+ return "*"
203
+ elif p_value < 0.1:
204
+ return "."
205
+ return ""
206
+
207
+
208
+ @dataclass
209
+ class PeriodEffect:
210
+ """
211
+ Treatment effect for a single time period.
212
+
213
+ Attributes
214
+ ----------
215
+ period : any
216
+ The time period identifier.
217
+ effect : float
218
+ The treatment effect estimate for this period.
219
+ se : float
220
+ Standard error of the effect estimate.
221
+ t_stat : float
222
+ T-statistic for the effect estimate.
223
+ p_value : float
224
+ P-value for the null hypothesis that effect = 0.
225
+ conf_int : tuple[float, float]
226
+ Confidence interval for the effect.
227
+ """
228
+
229
+ period: Any
230
+ effect: float
231
+ se: float
232
+ t_stat: float
233
+ p_value: float
234
+ conf_int: Tuple[float, float]
235
+
236
+ def __repr__(self) -> str:
237
+ """Concise string representation."""
238
+ sig = _get_significance_stars(self.p_value)
239
+ return (
240
+ f"PeriodEffect(period={self.period}, effect={self.effect:.4f}{sig}, "
241
+ f"SE={self.se:.4f}, p={self.p_value:.4f})"
242
+ )
243
+
244
+ @property
245
+ def is_significant(self) -> bool:
246
+ """Check if the effect is statistically significant at 0.05 level."""
247
+ return bool(self.p_value < 0.05)
248
+
249
+ @property
250
+ def significance_stars(self) -> str:
251
+ """Return significance stars based on p-value."""
252
+ return _get_significance_stars(self.p_value)
253
+
254
+
255
+ @dataclass
256
+ class MultiPeriodDiDResults:
257
+ """
258
+ Results from a Multi-Period Difference-in-Differences estimation.
259
+
260
+ Provides access to period-specific treatment effects as well as
261
+ an aggregate average treatment effect.
262
+
263
+ Attributes
264
+ ----------
265
+ period_effects : dict[any, PeriodEffect]
266
+ Dictionary mapping period identifiers to their PeriodEffect objects.
267
+ Contains all estimated period effects (pre and post, excluding
268
+ the reference period which is normalized to zero).
269
+ avg_att : float
270
+ Average Treatment effect on the Treated across post-periods only.
271
+ avg_se : float
272
+ Standard error of the average ATT.
273
+ avg_t_stat : float
274
+ T-statistic for the average ATT.
275
+ avg_p_value : float
276
+ P-value for the null hypothesis that average ATT = 0.
277
+ avg_conf_int : tuple[float, float]
278
+ Confidence interval for the average ATT.
279
+ n_obs : int
280
+ Number of observations used in estimation.
281
+ n_treated : int
282
+ Number of treated observations.
283
+ n_control : int
284
+ Number of control observations.
285
+ pre_periods : list
286
+ List of pre-treatment period identifiers.
287
+ post_periods : list
288
+ List of post-treatment period identifiers.
289
+ reference_period : any, optional
290
+ The reference (omitted) period. Its coefficient is zero by
291
+ construction and it is excluded from ``period_effects``.
292
+ interaction_indices : dict, optional
293
+ Mapping from period identifier to column index in the full
294
+ variance-covariance matrix. Used internally for sub-VCV
295
+ extraction (e.g., by HonestDiD and PreTrendsPower).
296
+ """
297
+
298
+ period_effects: Dict[Any, PeriodEffect]
299
+ avg_att: float
300
+ avg_se: float
301
+ avg_t_stat: float
302
+ avg_p_value: float
303
+ avg_conf_int: Tuple[float, float]
304
+ n_obs: int
305
+ n_treated: int
306
+ n_control: int
307
+ pre_periods: List[Any]
308
+ post_periods: List[Any]
309
+ alpha: float = 0.05
310
+ coefficients: Optional[Dict[str, float]] = field(default=None)
311
+ vcov: Optional[np.ndarray] = field(default=None)
312
+ residuals: Optional[np.ndarray] = field(default=None)
313
+ fitted_values: Optional[np.ndarray] = field(default=None)
314
+ r_squared: Optional[float] = field(default=None)
315
+ reference_period: Optional[Any] = field(default=None)
316
+ interaction_indices: Optional[Dict[Any, int]] = field(default=None, repr=False)
317
+
318
+ def __repr__(self) -> str:
319
+ """Concise string representation."""
320
+ sig = _get_significance_stars(self.avg_p_value)
321
+ return (
322
+ f"MultiPeriodDiDResults(avg_ATT={self.avg_att:.4f}{sig}, "
323
+ f"SE={self.avg_se:.4f}, "
324
+ f"n_post_periods={len(self.post_periods)})"
325
+ )
326
+
327
+ @property
328
+ def pre_period_effects(self) -> Dict[Any, PeriodEffect]:
329
+ """Pre-period effects only (for parallel trends assessment)."""
330
+ return {p: pe for p, pe in self.period_effects.items() if p in self.pre_periods}
331
+
332
+ @property
333
+ def post_period_effects(self) -> Dict[Any, PeriodEffect]:
334
+ """Post-period effects only."""
335
+ return {p: pe for p, pe in self.period_effects.items() if p in self.post_periods}
336
+
337
+ def summary(self, alpha: Optional[float] = None) -> str:
338
+ """
339
+ Generate a formatted summary of the estimation results.
340
+
341
+ Parameters
342
+ ----------
343
+ alpha : float, optional
344
+ Significance level for confidence intervals. Defaults to the
345
+ alpha used during estimation.
346
+
347
+ Returns
348
+ -------
349
+ str
350
+ Formatted summary table.
351
+ """
352
+ alpha = alpha or self.alpha
353
+ conf_level = int((1 - alpha) * 100)
354
+
355
+ lines = [
356
+ "=" * 80,
357
+ "Multi-Period Difference-in-Differences Estimation Results".center(80),
358
+ "=" * 80,
359
+ "",
360
+ f"{'Observations:':<25} {self.n_obs:>10}",
361
+ f"{'Treated observations:':<25} {self.n_treated:>10}",
362
+ f"{'Control observations:':<25} {self.n_control:>10}",
363
+ f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
364
+ f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
365
+ ]
366
+
367
+ if self.r_squared is not None:
368
+ lines.append(f"{'R-squared:':<25} {self.r_squared:>10.4f}")
369
+
370
+ # Pre-period effects (parallel trends test)
371
+ pre_effects = {p: pe for p, pe in self.period_effects.items() if p in self.pre_periods}
372
+ if pre_effects:
373
+ lines.extend(
374
+ [
375
+ "",
376
+ "-" * 80,
377
+ "Pre-Period Effects (Parallel Trends Test)".center(80),
378
+ "-" * 80,
379
+ f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
380
+ "-" * 80,
381
+ ]
382
+ )
383
+
384
+ for period in self.pre_periods:
385
+ if period in self.period_effects:
386
+ pe = self.period_effects[period]
387
+ stars = pe.significance_stars
388
+ lines.append(
389
+ f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} "
390
+ f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}"
391
+ )
392
+
393
+ # Show reference period
394
+ if self.reference_period is not None:
395
+ lines.append(
396
+ f"[ref: {self.reference_period}]"
397
+ f"{'0.0000':>21} {'---':>12} {'---':>10} {'---':>10} {'':>6}"
398
+ )
399
+
400
+ lines.append("-" * 80)
401
+
402
+ # Post-period treatment effects
403
+ lines.extend(
404
+ [
405
+ "",
406
+ "-" * 80,
407
+ "Post-Period Treatment Effects".center(80),
408
+ "-" * 80,
409
+ f"{'Period':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
410
+ "-" * 80,
411
+ ]
412
+ )
413
+
414
+ for period in self.post_periods:
415
+ pe = self.period_effects[period]
416
+ stars = pe.significance_stars
417
+ lines.append(
418
+ f"{str(period):<15} {pe.effect:>12.4f} {pe.se:>12.4f} "
419
+ f"{pe.t_stat:>10.3f} {pe.p_value:>10.4f} {stars:>6}"
420
+ )
421
+
422
+ # Average effect
423
+ lines.extend(
424
+ [
425
+ "-" * 80,
426
+ "",
427
+ "-" * 80,
428
+ "Average Treatment Effect (across post-periods)".center(80),
429
+ "-" * 80,
430
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
431
+ "-" * 80,
432
+ f"{'Avg ATT':<15} {self.avg_att:>12.4f} {self.avg_se:>12.4f} "
433
+ f"{self.avg_t_stat:>10.3f} {self.avg_p_value:>10.4f} {self.significance_stars:>6}",
434
+ "-" * 80,
435
+ "",
436
+ f"{conf_level}% Confidence Interval: [{self.avg_conf_int[0]:.4f}, {self.avg_conf_int[1]:.4f}]",
437
+ ]
438
+ )
439
+
440
+ # Add significance codes
441
+ lines.extend(
442
+ [
443
+ "",
444
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
445
+ "=" * 80,
446
+ ]
447
+ )
448
+
449
+ return "\n".join(lines)
450
+
451
+ def print_summary(self, alpha: Optional[float] = None) -> None:
452
+ """Print the summary to stdout."""
453
+ print(self.summary(alpha))
454
+
455
+ def get_effect(self, period) -> PeriodEffect:
456
+ """
457
+ Get the treatment effect for a specific period.
458
+
459
+ Parameters
460
+ ----------
461
+ period : any
462
+ The period identifier.
463
+
464
+ Returns
465
+ -------
466
+ PeriodEffect
467
+ The treatment effect for the specified period.
468
+
469
+ Raises
470
+ ------
471
+ KeyError
472
+ If the period is not found in post-treatment periods.
473
+ """
474
+ if period not in self.period_effects:
475
+ if hasattr(self, "reference_period") and period == self.reference_period:
476
+ raise KeyError(
477
+ f"Period '{period}' is the reference period (coefficient "
478
+ f"normalized to zero by construction). Its effect is 0.0 with "
479
+ f"no associated uncertainty."
480
+ )
481
+ raise KeyError(
482
+ f"Period '{period}' not found. "
483
+ f"Available periods: {list(self.period_effects.keys())}"
484
+ )
485
+ return self.period_effects[period]
486
+
487
+ def to_dict(self) -> Dict[str, Any]:
488
+ """
489
+ Convert results to a dictionary.
490
+
491
+ Returns
492
+ -------
493
+ Dict[str, Any]
494
+ Dictionary containing all estimation results.
495
+ """
496
+ result: Dict[str, Any] = {
497
+ "avg_att": self.avg_att,
498
+ "avg_se": self.avg_se,
499
+ "avg_t_stat": self.avg_t_stat,
500
+ "avg_p_value": self.avg_p_value,
501
+ "avg_conf_int_lower": self.avg_conf_int[0],
502
+ "avg_conf_int_upper": self.avg_conf_int[1],
503
+ "n_obs": self.n_obs,
504
+ "n_treated": self.n_treated,
505
+ "n_control": self.n_control,
506
+ "n_pre_periods": len(self.pre_periods),
507
+ "n_post_periods": len(self.post_periods),
508
+ "r_squared": self.r_squared,
509
+ "reference_period": self.reference_period,
510
+ }
511
+
512
+ # Add period-specific effects
513
+ for period, pe in self.period_effects.items():
514
+ result[f"effect_period_{period}"] = pe.effect
515
+ result[f"se_period_{period}"] = pe.se
516
+ result[f"pval_period_{period}"] = pe.p_value
517
+
518
+ return result
519
+
520
+ def to_dataframe(self) -> pd.DataFrame:
521
+ """
522
+ Convert period-specific effects to a pandas DataFrame.
523
+
524
+ Returns
525
+ -------
526
+ pd.DataFrame
527
+ DataFrame with one row per estimated period (pre and post).
528
+ """
529
+ rows = []
530
+ for period, pe in self.period_effects.items():
531
+ rows.append(
532
+ {
533
+ "period": period,
534
+ "effect": pe.effect,
535
+ "se": pe.se,
536
+ "t_stat": pe.t_stat,
537
+ "p_value": pe.p_value,
538
+ "conf_int_lower": pe.conf_int[0],
539
+ "conf_int_upper": pe.conf_int[1],
540
+ "is_significant": pe.is_significant,
541
+ "is_post": period in self.post_periods,
542
+ }
543
+ )
544
+ return pd.DataFrame(rows)
545
+
546
+ @property
547
+ def is_significant(self) -> bool:
548
+ """Check if the average ATT is statistically significant at the alpha level."""
549
+ return bool(self.avg_p_value < self.alpha)
550
+
551
+ @property
552
+ def significance_stars(self) -> str:
553
+ """Return significance stars for the average ATT based on p-value."""
554
+ return _get_significance_stars(self.avg_p_value)
555
+
556
+
557
+ @dataclass
558
+ class SyntheticDiDResults:
559
+ """
560
+ Results from a Synthetic Difference-in-Differences estimation.
561
+
562
+ Combines DiD with synthetic control by re-weighting control units to match
563
+ pre-treatment trends of treated units.
564
+
565
+ Attributes
566
+ ----------
567
+ att : float
568
+ Average Treatment effect on the Treated (ATT).
569
+ se : float
570
+ Standard error of the ATT estimate (bootstrap or placebo-based).
571
+ t_stat : float
572
+ T-statistic for the ATT estimate.
573
+ p_value : float
574
+ P-value for the null hypothesis that ATT = 0.
575
+ conf_int : tuple[float, float]
576
+ Confidence interval for the ATT.
577
+ n_obs : int
578
+ Number of observations used in estimation.
579
+ n_treated : int
580
+ Number of treated units.
581
+ n_control : int
582
+ Number of control units.
583
+ unit_weights : dict
584
+ Dictionary mapping control unit IDs to their synthetic weights.
585
+ time_weights : dict
586
+ Dictionary mapping pre-treatment periods to their time weights.
587
+ pre_periods : list
588
+ List of pre-treatment period identifiers.
589
+ post_periods : list
590
+ List of post-treatment period identifiers.
591
+ variance_method : str
592
+ Method used for variance estimation: "bootstrap" or "placebo".
593
+ """
594
+
595
+ att: float
596
+ se: float
597
+ t_stat: float
598
+ p_value: float
599
+ conf_int: Tuple[float, float]
600
+ n_obs: int
601
+ n_treated: int
602
+ n_control: int
603
+ unit_weights: Dict[Any, float]
604
+ time_weights: Dict[Any, float]
605
+ pre_periods: List[Any]
606
+ post_periods: List[Any]
607
+ alpha: float = 0.05
608
+ variance_method: str = field(default="placebo")
609
+ noise_level: Optional[float] = field(default=None)
610
+ zeta_omega: Optional[float] = field(default=None)
611
+ zeta_lambda: Optional[float] = field(default=None)
612
+ pre_treatment_fit: Optional[float] = field(default=None)
613
+ placebo_effects: Optional[np.ndarray] = field(default=None)
614
+ n_bootstrap: Optional[int] = field(default=None)
615
+
616
+ def __repr__(self) -> str:
617
+ """Concise string representation."""
618
+ sig = _get_significance_stars(self.p_value)
619
+ return (
620
+ f"SyntheticDiDResults(ATT={self.att:.4f}{sig}, "
621
+ f"SE={self.se:.4f}, "
622
+ f"p={self.p_value:.4f})"
623
+ )
624
+
625
+ def summary(self, alpha: Optional[float] = None) -> str:
626
+ """
627
+ Generate a formatted summary of the estimation results.
628
+
629
+ Parameters
630
+ ----------
631
+ alpha : float, optional
632
+ Significance level for confidence intervals. Defaults to the
633
+ alpha used during estimation.
634
+
635
+ Returns
636
+ -------
637
+ str
638
+ Formatted summary table.
639
+ """
640
+ alpha = alpha or self.alpha
641
+ conf_level = int((1 - alpha) * 100)
642
+
643
+ lines = [
644
+ "=" * 75,
645
+ "Synthetic Difference-in-Differences Estimation Results".center(75),
646
+ "=" * 75,
647
+ "",
648
+ f"{'Observations:':<25} {self.n_obs:>10}",
649
+ f"{'Treated units:':<25} {self.n_treated:>10}",
650
+ f"{'Control units:':<25} {self.n_control:>10}",
651
+ f"{'Pre-treatment periods:':<25} {len(self.pre_periods):>10}",
652
+ f"{'Post-treatment periods:':<25} {len(self.post_periods):>10}",
653
+ ]
654
+
655
+ if self.zeta_omega is not None:
656
+ lines.append(f"{'Zeta (unit weights):':<25} {self.zeta_omega:>10.4f}")
657
+ if self.zeta_lambda is not None:
658
+ lines.append(f"{'Zeta (time weights):':<25} {self.zeta_lambda:>10.6f}")
659
+ if self.noise_level is not None:
660
+ lines.append(f"{'Noise level:':<25} {self.noise_level:>10.4f}")
661
+
662
+ if self.pre_treatment_fit is not None:
663
+ lines.append(f"{'Pre-treatment fit (RMSE):':<25} {self.pre_treatment_fit:>10.4f}")
664
+
665
+ # Variance method info
666
+ lines.append(f"{'Variance method:':<25} {self.variance_method:>10}")
667
+ if self.variance_method == "bootstrap" and self.n_bootstrap is not None:
668
+ lines.append(f"{'Bootstrap replications:':<25} {self.n_bootstrap:>10}")
669
+
670
+ lines.extend(
671
+ [
672
+ "",
673
+ "-" * 75,
674
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} {'t-stat':>10} {'P>|t|':>10} {'':>5}",
675
+ "-" * 75,
676
+ 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}",
677
+ "-" * 75,
678
+ "",
679
+ f"{conf_level}% Confidence Interval: [{self.conf_int[0]:.4f}, {self.conf_int[1]:.4f}]",
680
+ ]
681
+ )
682
+
683
+ # Show top unit weights
684
+ if self.unit_weights:
685
+ sorted_weights = sorted(self.unit_weights.items(), key=lambda x: x[1], reverse=True)
686
+ top_n = min(5, len(sorted_weights))
687
+ lines.extend(
688
+ [
689
+ "",
690
+ "-" * 75,
691
+ "Top Unit Weights (Synthetic Control)".center(75),
692
+ "-" * 75,
693
+ ]
694
+ )
695
+ for unit, weight in sorted_weights[:top_n]:
696
+ if weight > 0.001: # Only show meaningful weights
697
+ lines.append(f" Unit {unit}: {weight:.4f}")
698
+
699
+ # Show how many units have non-trivial weight
700
+ n_nonzero = sum(1 for w in self.unit_weights.values() if w > 0.001)
701
+ lines.append(f" ({n_nonzero} units with weight > 0.001)")
702
+
703
+ # Add significance codes
704
+ lines.extend(
705
+ [
706
+ "",
707
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
708
+ "=" * 75,
709
+ ]
710
+ )
711
+
712
+ return "\n".join(lines)
713
+
714
+ def print_summary(self, alpha: Optional[float] = None) -> None:
715
+ """Print the summary to stdout."""
716
+ print(self.summary(alpha))
717
+
718
+ def to_dict(self) -> Dict[str, Any]:
719
+ """
720
+ Convert results to a dictionary.
721
+
722
+ Returns
723
+ -------
724
+ Dict[str, Any]
725
+ Dictionary containing all estimation results.
726
+ """
727
+ result = {
728
+ "att": self.att,
729
+ "se": self.se,
730
+ "t_stat": self.t_stat,
731
+ "p_value": self.p_value,
732
+ "conf_int_lower": self.conf_int[0],
733
+ "conf_int_upper": self.conf_int[1],
734
+ "n_obs": self.n_obs,
735
+ "n_treated": self.n_treated,
736
+ "n_control": self.n_control,
737
+ "n_pre_periods": len(self.pre_periods),
738
+ "n_post_periods": len(self.post_periods),
739
+ "variance_method": self.variance_method,
740
+ "noise_level": self.noise_level,
741
+ "zeta_omega": self.zeta_omega,
742
+ "zeta_lambda": self.zeta_lambda,
743
+ "pre_treatment_fit": self.pre_treatment_fit,
744
+ }
745
+ if self.n_bootstrap is not None:
746
+ result["n_bootstrap"] = self.n_bootstrap
747
+ return result
748
+
749
+ def to_dataframe(self) -> pd.DataFrame:
750
+ """
751
+ Convert results to a pandas DataFrame.
752
+
753
+ Returns
754
+ -------
755
+ pd.DataFrame
756
+ DataFrame with estimation results.
757
+ """
758
+ return pd.DataFrame([self.to_dict()])
759
+
760
+ def get_unit_weights_df(self) -> pd.DataFrame:
761
+ """
762
+ Get unit weights as a pandas DataFrame.
763
+
764
+ Returns
765
+ -------
766
+ pd.DataFrame
767
+ DataFrame with unit IDs and their weights.
768
+ """
769
+ return pd.DataFrame(
770
+ [{"unit": unit, "weight": weight} for unit, weight in self.unit_weights.items()]
771
+ ).sort_values("weight", ascending=False)
772
+
773
+ def get_time_weights_df(self) -> pd.DataFrame:
774
+ """
775
+ Get time weights as a pandas DataFrame.
776
+
777
+ Returns
778
+ -------
779
+ pd.DataFrame
780
+ DataFrame with time periods and their weights.
781
+ """
782
+ return pd.DataFrame(
783
+ [{"period": period, "weight": weight} for period, weight in self.time_weights.items()]
784
+ )
785
+
786
+ @property
787
+ def is_significant(self) -> bool:
788
+ """Check if the ATT is statistically significant at the alpha level."""
789
+ return bool(self.p_value < self.alpha)
790
+
791
+ @property
792
+ def significance_stars(self) -> str:
793
+ """Return significance stars based on p-value."""
794
+ return _get_significance_stars(self.p_value)