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.
- diff_diff/__init__.py +382 -0
- diff_diff/_backend.py +134 -0
- diff_diff/_rust_backend.cp314-win_amd64.pyd +0 -0
- diff_diff/bacon.py +1140 -0
- diff_diff/bootstrap_utils.py +730 -0
- diff_diff/continuous_did.py +1626 -0
- diff_diff/continuous_did_bspline.py +190 -0
- diff_diff/continuous_did_results.py +374 -0
- diff_diff/datasets.py +815 -0
- diff_diff/diagnostics.py +882 -0
- diff_diff/efficient_did.py +1770 -0
- diff_diff/efficient_did_bootstrap.py +359 -0
- diff_diff/efficient_did_covariates.py +899 -0
- diff_diff/efficient_did_results.py +368 -0
- diff_diff/efficient_did_weights.py +617 -0
- diff_diff/estimators.py +1501 -0
- diff_diff/honest_did.py +2585 -0
- diff_diff/imputation.py +2458 -0
- diff_diff/imputation_bootstrap.py +418 -0
- diff_diff/imputation_results.py +448 -0
- diff_diff/linalg.py +2538 -0
- diff_diff/power.py +2588 -0
- diff_diff/practitioner.py +869 -0
- diff_diff/prep.py +1738 -0
- diff_diff/prep_dgp.py +1718 -0
- diff_diff/pretrends.py +1105 -0
- diff_diff/results.py +918 -0
- diff_diff/stacked_did.py +1049 -0
- diff_diff/stacked_did_results.py +339 -0
- diff_diff/staggered.py +3895 -0
- diff_diff/staggered_aggregation.py +864 -0
- diff_diff/staggered_bootstrap.py +752 -0
- diff_diff/staggered_results.py +416 -0
- diff_diff/staggered_triple_diff.py +1545 -0
- diff_diff/staggered_triple_diff_results.py +416 -0
- diff_diff/sun_abraham.py +1685 -0
- diff_diff/survey.py +1981 -0
- diff_diff/synthetic_did.py +1136 -0
- diff_diff/triple_diff.py +2047 -0
- diff_diff/trop.py +952 -0
- diff_diff/trop_global.py +1270 -0
- diff_diff/trop_local.py +1307 -0
- diff_diff/trop_results.py +356 -0
- diff_diff/twfe.py +542 -0
- diff_diff/two_stage.py +1952 -0
- diff_diff/two_stage_bootstrap.py +520 -0
- diff_diff/two_stage_results.py +400 -0
- diff_diff/utils.py +1902 -0
- diff_diff/visualization/__init__.py +61 -0
- diff_diff/visualization/_common.py +328 -0
- diff_diff/visualization/_continuous.py +274 -0
- diff_diff/visualization/_diagnostic.py +817 -0
- diff_diff/visualization/_event_study.py +1086 -0
- diff_diff/visualization/_power.py +661 -0
- diff_diff/visualization/_staggered.py +833 -0
- diff_diff/visualization/_synthetic.py +197 -0
- diff_diff/wooldridge.py +1285 -0
- diff_diff/wooldridge_results.py +349 -0
- diff_diff-3.0.1.dist-info/METADATA +2997 -0
- diff_diff-3.0.1.dist-info/RECORD +62 -0
- diff_diff-3.0.1.dist-info/WHEEL +4 -0
- 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)
|