diff-diff 3.0.1__cp314-cp314-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.
Files changed (62) hide show
  1. diff_diff/__init__.py +382 -0
  2. diff_diff/_backend.py +134 -0
  3. diff_diff/_rust_backend.cp314-win_amd64.pyd +0 -0
  4. diff_diff/bacon.py +1140 -0
  5. diff_diff/bootstrap_utils.py +730 -0
  6. diff_diff/continuous_did.py +1626 -0
  7. diff_diff/continuous_did_bspline.py +190 -0
  8. diff_diff/continuous_did_results.py +374 -0
  9. diff_diff/datasets.py +815 -0
  10. diff_diff/diagnostics.py +882 -0
  11. diff_diff/efficient_did.py +1770 -0
  12. diff_diff/efficient_did_bootstrap.py +359 -0
  13. diff_diff/efficient_did_covariates.py +899 -0
  14. diff_diff/efficient_did_results.py +368 -0
  15. diff_diff/efficient_did_weights.py +617 -0
  16. diff_diff/estimators.py +1501 -0
  17. diff_diff/honest_did.py +2585 -0
  18. diff_diff/imputation.py +2458 -0
  19. diff_diff/imputation_bootstrap.py +418 -0
  20. diff_diff/imputation_results.py +448 -0
  21. diff_diff/linalg.py +2538 -0
  22. diff_diff/power.py +2588 -0
  23. diff_diff/practitioner.py +869 -0
  24. diff_diff/prep.py +1738 -0
  25. diff_diff/prep_dgp.py +1718 -0
  26. diff_diff/pretrends.py +1105 -0
  27. diff_diff/results.py +918 -0
  28. diff_diff/stacked_did.py +1049 -0
  29. diff_diff/stacked_did_results.py +339 -0
  30. diff_diff/staggered.py +3895 -0
  31. diff_diff/staggered_aggregation.py +864 -0
  32. diff_diff/staggered_bootstrap.py +752 -0
  33. diff_diff/staggered_results.py +416 -0
  34. diff_diff/staggered_triple_diff.py +1545 -0
  35. diff_diff/staggered_triple_diff_results.py +416 -0
  36. diff_diff/sun_abraham.py +1685 -0
  37. diff_diff/survey.py +1981 -0
  38. diff_diff/synthetic_did.py +1136 -0
  39. diff_diff/triple_diff.py +2047 -0
  40. diff_diff/trop.py +952 -0
  41. diff_diff/trop_global.py +1270 -0
  42. diff_diff/trop_local.py +1307 -0
  43. diff_diff/trop_results.py +356 -0
  44. diff_diff/twfe.py +542 -0
  45. diff_diff/two_stage.py +1952 -0
  46. diff_diff/two_stage_bootstrap.py +520 -0
  47. diff_diff/two_stage_results.py +400 -0
  48. diff_diff/utils.py +1902 -0
  49. diff_diff/visualization/__init__.py +61 -0
  50. diff_diff/visualization/_common.py +328 -0
  51. diff_diff/visualization/_continuous.py +274 -0
  52. diff_diff/visualization/_diagnostic.py +817 -0
  53. diff_diff/visualization/_event_study.py +1086 -0
  54. diff_diff/visualization/_power.py +661 -0
  55. diff_diff/visualization/_staggered.py +833 -0
  56. diff_diff/visualization/_synthetic.py +197 -0
  57. diff_diff/wooldridge.py +1285 -0
  58. diff_diff/wooldridge_results.py +349 -0
  59. diff_diff-3.0.1.dist-info/METADATA +2997 -0
  60. diff_diff-3.0.1.dist-info/RECORD +62 -0
  61. diff_diff-3.0.1.dist-info/WHEEL +4 -0
  62. diff_diff-3.0.1.dist-info/sboms/diff_diff_rust.cyclonedx.json +5843 -0
@@ -0,0 +1,190 @@
1
+ """
2
+ B-spline utilities for continuous Difference-in-Differences estimation.
3
+
4
+ Provides basis construction, evaluation, and derivative computation for
5
+ the dose-response curve estimation in ContinuousDiD.
6
+ """
7
+
8
+ import numpy as np
9
+ from scipy.interpolate import BSpline
10
+
11
+ __all__ = [
12
+ "build_bspline_basis",
13
+ "bspline_design_matrix",
14
+ "bspline_derivative_design_matrix",
15
+ "default_dose_grid",
16
+ ]
17
+
18
+
19
+ def build_bspline_basis(dose, degree=3, num_knots=0):
20
+ """
21
+ Construct B-spline knot vector from positive dose values.
22
+
23
+ Interior knots are placed at quantiles of the dose distribution,
24
+ matching R's ``choose_knots_quantile`` convention.
25
+
26
+ Parameters
27
+ ----------
28
+ dose : array-like
29
+ Positive dose values from treated units.
30
+ degree : int, default=3
31
+ Degree of the B-spline (3 = cubic).
32
+ num_knots : int, default=0
33
+ Number of interior knots.
34
+
35
+ Returns
36
+ -------
37
+ knots : np.ndarray
38
+ Full knot vector with boundary clamping.
39
+ degree : int
40
+ The B-spline degree (echoed back for convenience).
41
+ """
42
+ dose = np.asarray(dose, dtype=float)
43
+ d_L = float(np.min(dose))
44
+ d_U = float(np.max(dose))
45
+
46
+ if num_knots > 0:
47
+ # Interior knots at evenly-spaced quantiles of dose distribution
48
+ probs = np.linspace(0, 1, num_knots + 2)[1:-1]
49
+ interior_knots = np.quantile(dose, probs)
50
+ else:
51
+ interior_knots = np.array([])
52
+
53
+ # Full knot vector: clamped at boundaries
54
+ knots = np.concatenate(
55
+ [
56
+ np.repeat(d_L, degree + 1),
57
+ interior_knots,
58
+ np.repeat(d_U, degree + 1),
59
+ ]
60
+ )
61
+
62
+ return knots, degree
63
+
64
+
65
+ def bspline_design_matrix(x, knots, degree, include_intercept=True):
66
+ """
67
+ Evaluate B-spline basis functions at points ``x``.
68
+
69
+ To match R's ``splines2::bSpline(intercept=FALSE)`` plus an explicit
70
+ intercept column: drop the first B-spline column and prepend a
71
+ column of ones.
72
+
73
+ Parameters
74
+ ----------
75
+ x : array-like
76
+ Evaluation points, shape ``(n,)``.
77
+ knots : np.ndarray
78
+ Full knot vector (from :func:`build_bspline_basis`).
79
+ degree : int
80
+ B-spline degree.
81
+ include_intercept : bool, default=True
82
+ If True, drop first B-spline column and prepend intercept column.
83
+
84
+ Returns
85
+ -------
86
+ np.ndarray
87
+ Design matrix, shape ``(n, n_cols)``.
88
+ """
89
+ x = np.asarray(x, dtype=float)
90
+
91
+ # scipy requires evaluation within [knots[degree], knots[-(degree+1)]]
92
+ # Clamp to boundary knots to avoid extrapolation issues
93
+ t_min = knots[degree]
94
+ t_max = knots[-(degree + 1)]
95
+ x_clamped = np.clip(x, t_min, t_max)
96
+
97
+ # Sparse design matrix from scipy, convert to dense
98
+ B = BSpline.design_matrix(x_clamped, knots, degree).toarray()
99
+
100
+ if include_intercept:
101
+ # Drop first B-spline column, prepend intercept
102
+ B = np.column_stack([np.ones(len(x)), B[:, 1:]])
103
+
104
+ return B
105
+
106
+
107
+ def bspline_derivative_design_matrix(x, knots, degree, include_intercept=True):
108
+ """
109
+ Evaluate first derivatives of B-spline basis functions at points ``x``.
110
+
111
+ Parameters
112
+ ----------
113
+ x : array-like
114
+ Evaluation points, shape ``(n,)``.
115
+ knots : np.ndarray
116
+ Full knot vector.
117
+ degree : int
118
+ B-spline degree.
119
+ include_intercept : bool, default=True
120
+ If True, drop derivative of first B-spline (replaced by intercept
121
+ whose derivative is 0) and prepend a zeros column.
122
+
123
+ Returns
124
+ -------
125
+ np.ndarray
126
+ Derivative design matrix, shape ``(n, n_cols)``.
127
+ """
128
+ x = np.asarray(x, dtype=float)
129
+
130
+ # Number of basis functions
131
+ n_basis = len(knots) - degree - 1
132
+
133
+ # Clamp evaluation points to boundary
134
+ t_min = knots[degree]
135
+ t_max = knots[-(degree + 1)]
136
+ x_clamped = np.clip(x, t_min, t_max)
137
+
138
+ # Build derivative for each basis function
139
+ dB = np.zeros((len(x), n_basis))
140
+
141
+ # Check if knot vector is degenerate (all identical, e.g. single dose)
142
+ if knots[0] == knots[-1]:
143
+ # All knots identical: derivatives are all zero
144
+ pass
145
+ else:
146
+ for j in range(n_basis):
147
+ c = np.zeros(n_basis)
148
+ c[j] = 1.0
149
+ try:
150
+ spline_j = BSpline(knots, c, degree)
151
+ deriv_j = spline_j.derivative()
152
+ dB[:, j] = deriv_j(x_clamped)
153
+ except ValueError:
154
+ # Degenerate knot vector: derivative is zero
155
+ pass
156
+
157
+ if include_intercept:
158
+ # Drop first column (intercept derivative = 0), prepend zeros
159
+ dB = np.column_stack([np.zeros(len(x)), dB[:, 1:]])
160
+
161
+ return dB
162
+
163
+
164
+ def default_dose_grid(dose, lower_quantile=0.10, upper_quantile=0.99):
165
+ """
166
+ Compute a quantile-based evaluation grid from positive dose values.
167
+
168
+ Matches R's default: ``quantile(dose[dose > 0], probs=seq(0.10, 0.99, 0.01))``,
169
+ producing 90 evaluation points.
170
+
171
+ Parameters
172
+ ----------
173
+ dose : array-like
174
+ Dose values (only positive values are used).
175
+ lower_quantile : float, default=0.10
176
+ Lower quantile bound.
177
+ upper_quantile : float, default=0.99
178
+ Upper quantile bound.
179
+
180
+ Returns
181
+ -------
182
+ np.ndarray
183
+ Dose evaluation grid.
184
+ """
185
+ dose = np.asarray(dose, dtype=float)
186
+ positive_dose = dose[dose > 0]
187
+ if len(positive_dose) == 0:
188
+ return np.array([])
189
+ probs = np.arange(lower_quantile, upper_quantile + 0.005, 0.01)
190
+ return np.quantile(positive_dose, probs)
@@ -0,0 +1,374 @@
1
+ """
2
+ Result container classes for Continuous Difference-in-Differences estimator.
3
+
4
+ Provides dataclass containers for dose-response curves, group-time effects,
5
+ and aggregated estimation results.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional, Tuple
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+ from diff_diff.results import _format_survey_block, _get_significance_stars
15
+
16
+ __all__ = ["ContinuousDiDResults", "DoseResponseCurve"]
17
+
18
+
19
+ @dataclass
20
+ class DoseResponseCurve:
21
+ """
22
+ Dose-response curve from continuous DiD estimation.
23
+
24
+ Attributes
25
+ ----------
26
+ dose_grid : np.ndarray
27
+ Evaluation points, shape ``(n_grid,)``.
28
+ effects : np.ndarray
29
+ ATT(d) or ACRT(d) values, shape ``(n_grid,)``.
30
+ se : np.ndarray
31
+ Standard errors, shape ``(n_grid,)``.
32
+ conf_int_lower : np.ndarray
33
+ Lower CI bounds, shape ``(n_grid,)``.
34
+ conf_int_upper : np.ndarray
35
+ Upper CI bounds, shape ``(n_grid,)``.
36
+ target : str
37
+ ``"att"`` or ``"acrt"``.
38
+ """
39
+
40
+ dose_grid: np.ndarray
41
+ effects: np.ndarray
42
+ se: np.ndarray
43
+ conf_int_lower: np.ndarray
44
+ conf_int_upper: np.ndarray
45
+ target: str
46
+ p_value: Optional[np.ndarray] = None
47
+ n_bootstrap: int = 0
48
+ df_survey: Optional[int] = None
49
+
50
+ def to_dataframe(self) -> pd.DataFrame:
51
+ """Convert to DataFrame with dose, effect, se, CI, t_stat, p_value."""
52
+ n = len(self.effects)
53
+ if self.n_bootstrap > 0 and self.p_value is not None:
54
+ # Bootstrap inference: use stored p-values, t-stat is undefined
55
+ t_stat = np.full(n, np.nan)
56
+ p_value = self.p_value
57
+ else:
58
+ # Analytic inference: compute t-stat and p-value from normal approx
59
+ from diff_diff.utils import safe_inference
60
+
61
+ t_stat = np.full(n, np.nan)
62
+ p_value = np.full(n, np.nan)
63
+ for i in range(n):
64
+ t_i, p_i, _ = safe_inference(self.effects[i], self.se[i], df=self.df_survey)
65
+ t_stat[i] = t_i
66
+ p_value[i] = p_i
67
+ return pd.DataFrame(
68
+ {
69
+ "dose": self.dose_grid,
70
+ "effect": self.effects,
71
+ "se": self.se,
72
+ "conf_int_lower": self.conf_int_lower,
73
+ "conf_int_upper": self.conf_int_upper,
74
+ "t_stat": t_stat,
75
+ "p_value": p_value,
76
+ }
77
+ )
78
+
79
+
80
+ @dataclass
81
+ class ContinuousDiDResults:
82
+ """
83
+ Results from Continuous Difference-in-Differences estimation.
84
+
85
+ Implements Callaway, Goodman-Bacon & Sant'Anna (2024).
86
+
87
+ Attributes
88
+ ----------
89
+ dose_response_att : DoseResponseCurve
90
+ ATT(d) dose-response curve.
91
+ dose_response_acrt : DoseResponseCurve
92
+ ACRT(d) dose-response curve.
93
+ overall_att : float
94
+ Binarized overall ATT (ATT^{loc} under PT, equals ATT^{glob} under SPT).
95
+ overall_acrt : float
96
+ Plug-in overall ACRT^{glob}.
97
+ group_time_effects : dict
98
+ Per (g,t) cell results.
99
+ base_period : str
100
+ Base period strategy (``"varying"`` or ``"universal"``).
101
+ anticipation : int
102
+ Number of anticipation periods.
103
+ n_bootstrap : int
104
+ Number of bootstrap iterations used.
105
+ bootstrap_weights : str
106
+ Bootstrap weight type (``"rademacher"``, ``"mammen"``, or ``"webb"``).
107
+ seed : int or None
108
+ Random seed used for bootstrap.
109
+ rank_deficient_action : str
110
+ How rank deficiency is handled (``"warn"``, ``"error"``, ``"silent"``).
111
+ """
112
+
113
+ dose_response_att: DoseResponseCurve
114
+ dose_response_acrt: DoseResponseCurve
115
+ overall_att: float
116
+ overall_att_se: float
117
+ overall_att_t_stat: float
118
+ overall_att_p_value: float
119
+ overall_att_conf_int: Tuple[float, float]
120
+ overall_acrt: float
121
+ overall_acrt_se: float
122
+ overall_acrt_t_stat: float
123
+ overall_acrt_p_value: float
124
+ overall_acrt_conf_int: Tuple[float, float]
125
+ group_time_effects: Dict[Tuple[Any, Any], Dict[str, Any]]
126
+ dose_grid: np.ndarray
127
+ groups: List[Any]
128
+ time_periods: List[Any]
129
+ n_obs: int
130
+ n_treated_units: int
131
+ n_control_units: int
132
+ alpha: float = 0.05
133
+ control_group: str = "never_treated"
134
+ degree: int = 3
135
+ num_knots: int = 0
136
+ base_period: str = "varying"
137
+ anticipation: int = 0
138
+ n_bootstrap: int = 0
139
+ bootstrap_weights: str = "rademacher"
140
+ seed: Optional[int] = None
141
+ rank_deficient_action: str = "warn"
142
+ event_study_effects: Optional[Dict[int, Dict[str, Any]]] = field(default=None)
143
+ # Survey design metadata (SurveyMetadata instance from diff_diff.survey)
144
+ survey_metadata: Optional[Any] = field(default=None)
145
+
146
+ def __repr__(self) -> str:
147
+ sig_att = _get_significance_stars(self.overall_att_p_value)
148
+ sig_acrt = _get_significance_stars(self.overall_acrt_p_value)
149
+ return (
150
+ f"ContinuousDiDResults("
151
+ f"ATT_glob={self.overall_att:.4f}{sig_att}, "
152
+ f"ACRT_glob={self.overall_acrt:.4f}{sig_acrt}, "
153
+ f"n_groups={len(self.groups)}, "
154
+ f"n_periods={len(self.time_periods)})"
155
+ )
156
+
157
+ @property
158
+ def coef_var(self) -> float:
159
+ """Coefficient of variation: SE / |overall ATT|. NaN when ATT is 0 or SE non-finite."""
160
+ if not (np.isfinite(self.overall_att_se) and self.overall_att_se >= 0):
161
+ return np.nan
162
+ if not np.isfinite(self.overall_att) or self.overall_att == 0:
163
+ return np.nan
164
+ return self.overall_att_se / abs(self.overall_att)
165
+
166
+ def summary(self, alpha: Optional[float] = None) -> str:
167
+ """Generate formatted summary."""
168
+ alpha = alpha or self.alpha
169
+ conf_level = int((1 - alpha) * 100)
170
+ w = 85
171
+
172
+ lines = [
173
+ "=" * w,
174
+ "Continuous Difference-in-Differences Results".center(w),
175
+ "(Callaway, Goodman-Bacon & Sant'Anna 2024)".center(w),
176
+ "=" * w,
177
+ "",
178
+ f"{'Total observations:':<30} {self.n_obs:>10}",
179
+ f"{'Treated units:':<30} {self.n_treated_units:>10}",
180
+ f"{'Control units:':<30} {self.n_control_units:>10}",
181
+ f"{'Treatment cohorts:':<30} {len(self.groups):>10}",
182
+ f"{'Time periods:':<30} {len(self.time_periods):>10}",
183
+ f"{'Control group:':<30} {self.control_group:>10}",
184
+ f"{'B-spline degree:':<30} {self.degree:>10}",
185
+ f"{'Interior knots:':<30} {self.num_knots:>10}",
186
+ f"{'Base period:':<30} {self.base_period:>10}",
187
+ f"{'Anticipation:':<30} {self.anticipation:>10}",
188
+ "",
189
+ ]
190
+
191
+ # Add survey design info
192
+ if self.survey_metadata is not None:
193
+ sm = self.survey_metadata
194
+ lines.extend(_format_survey_block(sm, w))
195
+
196
+ # Overall summary parameters
197
+ lines.extend(
198
+ [
199
+ "-" * w,
200
+ "Overall Summary Parameters".center(w),
201
+ "-" * w,
202
+ f"{'Parameter':<15} {'Estimate':>12} {'Std. Err.':>12} "
203
+ f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
204
+ "-" * w,
205
+ ]
206
+ )
207
+ for label, est, se, t, p in [
208
+ (
209
+ "ATT_glob",
210
+ self.overall_att,
211
+ self.overall_att_se,
212
+ self.overall_att_t_stat,
213
+ self.overall_att_p_value,
214
+ ),
215
+ (
216
+ "ACRT_glob",
217
+ self.overall_acrt,
218
+ self.overall_acrt_se,
219
+ self.overall_acrt_t_stat,
220
+ self.overall_acrt_p_value,
221
+ ),
222
+ ]:
223
+ t_str = f"{t:>10.3f}" if np.isfinite(t) else f"{'NaN':>10}"
224
+ p_str = f"{p:>10.4f}" if np.isfinite(p) else f"{'NaN':>10}"
225
+ sig = _get_significance_stars(p)
226
+ lines.append(f"{label:<15} {est:>12.4f} {se:>12.4f} {t_str} {p_str} {sig:>6}")
227
+ lines.extend(
228
+ [
229
+ "-" * w,
230
+ "",
231
+ f"{conf_level}% CI for ATT_glob: "
232
+ f"[{self.overall_att_conf_int[0]:.4f}, {self.overall_att_conf_int[1]:.4f}]",
233
+ f"{conf_level}% CI for ACRT_glob: "
234
+ f"[{self.overall_acrt_conf_int[0]:.4f}, {self.overall_acrt_conf_int[1]:.4f}]",
235
+ ]
236
+ )
237
+
238
+ cv = self.coef_var
239
+ if np.isfinite(cv):
240
+ lines.append(f"{'CV (SE/|ATT|):':<25} {cv:>10.4f}")
241
+
242
+ lines.append("")
243
+
244
+ # Dose-response curve summary (first/mid/last points)
245
+ if len(self.dose_grid) > 0:
246
+ lines.extend(
247
+ [
248
+ "-" * w,
249
+ "Dose-Response Curve (selected points)".center(w),
250
+ "-" * w,
251
+ f"{'Dose':>10} {'ATT(d)':>12} {'SE':>10} " f"{'ACRT(d)':>12} {'SE':>10}",
252
+ "-" * w,
253
+ ]
254
+ )
255
+ n_grid = len(self.dose_grid)
256
+ indices = sorted(set([0, n_grid // 4, n_grid // 2, 3 * n_grid // 4, n_grid - 1]))
257
+ for idx in indices:
258
+ if idx < n_grid:
259
+ lines.append(
260
+ f"{self.dose_grid[idx]:>10.3f} "
261
+ f"{self.dose_response_att.effects[idx]:>12.4f} "
262
+ f"{self.dose_response_att.se[idx]:>10.4f} "
263
+ f"{self.dose_response_acrt.effects[idx]:>12.4f} "
264
+ f"{self.dose_response_acrt.se[idx]:>10.4f}"
265
+ )
266
+ lines.extend(["-" * w, ""])
267
+
268
+ # Event study effects if available
269
+ if self.event_study_effects:
270
+ lines.extend(
271
+ [
272
+ "-" * w,
273
+ "Event Study (Dynamic) Effects (Binarized ATT)".center(w),
274
+ "-" * w,
275
+ f"{'Rel. Period':<15} {'Estimate':>12} {'Std. Err.':>12} "
276
+ f"{'t-stat':>10} {'P>|t|':>10} {'Sig.':>6}",
277
+ "-" * w,
278
+ ]
279
+ )
280
+ for rel_t in sorted(self.event_study_effects.keys()):
281
+ eff = self.event_study_effects[rel_t]
282
+ sig = _get_significance_stars(eff["p_value"])
283
+ t_str = f"{eff['t_stat']:>10.3f}" if np.isfinite(eff["t_stat"]) else f"{'NaN':>10}"
284
+ p_str = (
285
+ f"{eff['p_value']:>10.4f}" if np.isfinite(eff["p_value"]) else f"{'NaN':>10}"
286
+ )
287
+ lines.append(
288
+ f"{rel_t:<15} {eff['effect']:>12.4f} {eff['se']:>12.4f} "
289
+ f"{t_str} {p_str} {sig:>6}"
290
+ )
291
+ lines.extend(["-" * w, ""])
292
+
293
+ lines.extend(
294
+ [
295
+ "Signif. codes: '***' 0.001, '**' 0.01, '*' 0.05, '.' 0.1",
296
+ "=" * w,
297
+ ]
298
+ )
299
+ return "\n".join(lines)
300
+
301
+ def print_summary(self, alpha: Optional[float] = None) -> None:
302
+ """Print summary to stdout."""
303
+ print(self.summary(alpha))
304
+
305
+ def to_dataframe(self, level: str = "dose_response") -> pd.DataFrame:
306
+ """
307
+ Convert results to DataFrame.
308
+
309
+ Parameters
310
+ ----------
311
+ level : str, default="dose_response"
312
+ ``"dose_response"``, ``"group_time"``, or ``"event_study"``.
313
+ """
314
+ if level == "dose_response":
315
+ att_df = self.dose_response_att.to_dataframe()
316
+ acrt_df = self.dose_response_acrt.to_dataframe()
317
+ return pd.DataFrame(
318
+ {
319
+ "dose": att_df["dose"],
320
+ "att": att_df["effect"],
321
+ "att_se": att_df["se"],
322
+ "att_ci_lower": att_df["conf_int_lower"],
323
+ "att_ci_upper": att_df["conf_int_upper"],
324
+ "acrt": acrt_df["effect"],
325
+ "acrt_se": acrt_df["se"],
326
+ "acrt_ci_lower": acrt_df["conf_int_lower"],
327
+ "acrt_ci_upper": acrt_df["conf_int_upper"],
328
+ }
329
+ )
330
+ elif level == "group_time":
331
+ rows = []
332
+ for (g, t), data in sorted(self.group_time_effects.items()):
333
+ rows.append(
334
+ {
335
+ "group": g,
336
+ "time": t,
337
+ "att_glob": data.get("att_glob", np.nan),
338
+ "acrt_glob": data.get("acrt_glob", np.nan),
339
+ "n_treated": data.get("n_treated", 0),
340
+ "n_control": data.get("n_control", 0),
341
+ }
342
+ )
343
+ return pd.DataFrame(rows)
344
+ elif level == "event_study":
345
+ if self.event_study_effects is None:
346
+ raise ValueError("Event study effects not computed. Use aggregate='eventstudy'.")
347
+ rows = []
348
+ for rel_t, data in sorted(self.event_study_effects.items()):
349
+ rows.append(
350
+ {
351
+ "relative_period": rel_t,
352
+ "att_glob": data["effect"],
353
+ "se": data["se"],
354
+ "t_stat": data["t_stat"],
355
+ "p_value": data["p_value"],
356
+ "conf_int_lower": data["conf_int"][0],
357
+ "conf_int_upper": data["conf_int"][1],
358
+ }
359
+ )
360
+ return pd.DataFrame(rows)
361
+ else:
362
+ raise ValueError(
363
+ f"Unknown level: {level}. Use 'dose_response', 'group_time', or 'event_study'."
364
+ )
365
+
366
+ @property
367
+ def is_significant(self) -> bool:
368
+ """Check if overall ATT is significant."""
369
+ return bool(self.overall_att_p_value < self.alpha)
370
+
371
+ @property
372
+ def significance_stars(self) -> str:
373
+ """Significance stars for overall ATT."""
374
+ return _get_significance_stars(self.overall_att_p_value)