diff-diff 0.1.0__py3-none-any.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 +12 -0
- diff_diff/estimators.py +701 -0
- diff_diff/results.py +170 -0
- diff_diff/utils.py +581 -0
- diff_diff-0.1.0.dist-info/METADATA +421 -0
- diff_diff-0.1.0.dist-info/RECORD +8 -0
- diff_diff-0.1.0.dist-info/WHEEL +5 -0
- diff_diff-0.1.0.dist-info/top_level.txt +1 -0
diff_diff/utils.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for difference-in-differences estimation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Union
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from scipy import stats
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate_binary(arr: np.ndarray, name: str) -> None:
|
|
13
|
+
"""
|
|
14
|
+
Validate that an array contains only binary values (0 or 1).
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
arr : np.ndarray
|
|
19
|
+
Array to validate.
|
|
20
|
+
name : str
|
|
21
|
+
Name of the variable (for error messages).
|
|
22
|
+
|
|
23
|
+
Raises
|
|
24
|
+
------
|
|
25
|
+
ValueError
|
|
26
|
+
If array contains non-binary values.
|
|
27
|
+
"""
|
|
28
|
+
unique_values = np.unique(arr[~np.isnan(arr)])
|
|
29
|
+
if not np.all(np.isin(unique_values, [0, 1])):
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"{name} must be binary (0 or 1). "
|
|
32
|
+
f"Found values: {unique_values}"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def compute_robust_se(
|
|
37
|
+
X: np.ndarray,
|
|
38
|
+
residuals: np.ndarray,
|
|
39
|
+
cluster_ids: np.ndarray = None
|
|
40
|
+
) -> np.ndarray:
|
|
41
|
+
"""
|
|
42
|
+
Compute heteroskedasticity-robust (HC1) or cluster-robust standard errors.
|
|
43
|
+
|
|
44
|
+
Parameters
|
|
45
|
+
----------
|
|
46
|
+
X : np.ndarray
|
|
47
|
+
Design matrix of shape (n, k).
|
|
48
|
+
residuals : np.ndarray
|
|
49
|
+
Residuals from regression of shape (n,).
|
|
50
|
+
cluster_ids : np.ndarray, optional
|
|
51
|
+
Cluster identifiers for cluster-robust SEs.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
np.ndarray
|
|
56
|
+
Variance-covariance matrix of shape (k, k).
|
|
57
|
+
"""
|
|
58
|
+
n, k = X.shape
|
|
59
|
+
|
|
60
|
+
# Compute (X'X)^(-1)
|
|
61
|
+
XtX_inv = np.linalg.inv(X.T @ X)
|
|
62
|
+
|
|
63
|
+
if cluster_ids is None:
|
|
64
|
+
# HC1 robust standard errors
|
|
65
|
+
# HC1 adjustment factor: n / (n - k)
|
|
66
|
+
adjustment = n / (n - k)
|
|
67
|
+
|
|
68
|
+
# Create diagonal matrix with squared residuals
|
|
69
|
+
u_squared = residuals ** 2
|
|
70
|
+
|
|
71
|
+
# Meat of the sandwich: X' * diag(u^2) * X
|
|
72
|
+
meat = X.T @ (X * u_squared[:, np.newaxis])
|
|
73
|
+
|
|
74
|
+
vcov = adjustment * XtX_inv @ meat @ XtX_inv
|
|
75
|
+
else:
|
|
76
|
+
# Cluster-robust standard errors
|
|
77
|
+
unique_clusters = np.unique(cluster_ids)
|
|
78
|
+
n_clusters = len(unique_clusters)
|
|
79
|
+
|
|
80
|
+
# Adjustment factor for cluster-robust SEs
|
|
81
|
+
adjustment = (n_clusters / (n_clusters - 1)) * ((n - 1) / (n - k))
|
|
82
|
+
|
|
83
|
+
# Compute the meat of the sandwich
|
|
84
|
+
meat = np.zeros((k, k))
|
|
85
|
+
for cluster in unique_clusters:
|
|
86
|
+
mask = cluster_ids == cluster
|
|
87
|
+
X_c = X[mask]
|
|
88
|
+
u_c = residuals[mask]
|
|
89
|
+
score_c = X_c.T @ u_c
|
|
90
|
+
meat += np.outer(score_c, score_c)
|
|
91
|
+
|
|
92
|
+
vcov = adjustment * XtX_inv @ meat @ XtX_inv
|
|
93
|
+
|
|
94
|
+
return vcov
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def compute_confidence_interval(
|
|
98
|
+
estimate: float,
|
|
99
|
+
se: float,
|
|
100
|
+
alpha: float = 0.05,
|
|
101
|
+
df: int = None
|
|
102
|
+
) -> tuple:
|
|
103
|
+
"""
|
|
104
|
+
Compute confidence interval for an estimate.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
estimate : float
|
|
109
|
+
Point estimate.
|
|
110
|
+
se : float
|
|
111
|
+
Standard error.
|
|
112
|
+
alpha : float
|
|
113
|
+
Significance level (default 0.05 for 95% CI).
|
|
114
|
+
df : int, optional
|
|
115
|
+
Degrees of freedom. If None, uses normal distribution.
|
|
116
|
+
|
|
117
|
+
Returns
|
|
118
|
+
-------
|
|
119
|
+
tuple
|
|
120
|
+
(lower_bound, upper_bound) of confidence interval.
|
|
121
|
+
"""
|
|
122
|
+
if df is not None:
|
|
123
|
+
critical_value = stats.t.ppf(1 - alpha / 2, df)
|
|
124
|
+
else:
|
|
125
|
+
critical_value = stats.norm.ppf(1 - alpha / 2)
|
|
126
|
+
|
|
127
|
+
lower = estimate - critical_value * se
|
|
128
|
+
upper = estimate + critical_value * se
|
|
129
|
+
|
|
130
|
+
return (lower, upper)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compute_p_value(t_stat: float, df: int = None, two_sided: bool = True) -> float:
|
|
134
|
+
"""
|
|
135
|
+
Compute p-value for a t-statistic.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
t_stat : float
|
|
140
|
+
T-statistic.
|
|
141
|
+
df : int, optional
|
|
142
|
+
Degrees of freedom. If None, uses normal distribution.
|
|
143
|
+
two_sided : bool
|
|
144
|
+
Whether to compute two-sided p-value (default True).
|
|
145
|
+
|
|
146
|
+
Returns
|
|
147
|
+
-------
|
|
148
|
+
float
|
|
149
|
+
P-value.
|
|
150
|
+
"""
|
|
151
|
+
if df is not None:
|
|
152
|
+
p_value = stats.t.sf(np.abs(t_stat), df)
|
|
153
|
+
else:
|
|
154
|
+
p_value = stats.norm.sf(np.abs(t_stat))
|
|
155
|
+
|
|
156
|
+
if two_sided:
|
|
157
|
+
p_value *= 2
|
|
158
|
+
|
|
159
|
+
return p_value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_parallel_trends(
|
|
163
|
+
data: pd.DataFrame,
|
|
164
|
+
outcome: str,
|
|
165
|
+
time: str,
|
|
166
|
+
treatment_group: str,
|
|
167
|
+
pre_periods: list = None
|
|
168
|
+
) -> dict:
|
|
169
|
+
"""
|
|
170
|
+
Perform a simple check for parallel trends assumption.
|
|
171
|
+
|
|
172
|
+
This computes the trend (slope) in the outcome variable for both
|
|
173
|
+
treatment and control groups during pre-treatment periods.
|
|
174
|
+
|
|
175
|
+
Parameters
|
|
176
|
+
----------
|
|
177
|
+
data : pd.DataFrame
|
|
178
|
+
Panel data.
|
|
179
|
+
outcome : str
|
|
180
|
+
Name of outcome variable column.
|
|
181
|
+
time : str
|
|
182
|
+
Name of time period column.
|
|
183
|
+
treatment_group : str
|
|
184
|
+
Name of treatment group indicator column.
|
|
185
|
+
pre_periods : list, optional
|
|
186
|
+
List of pre-treatment time periods. If None, infers from data.
|
|
187
|
+
|
|
188
|
+
Returns
|
|
189
|
+
-------
|
|
190
|
+
dict
|
|
191
|
+
Dictionary with trend statistics and test results.
|
|
192
|
+
"""
|
|
193
|
+
if pre_periods is None:
|
|
194
|
+
# Assume treatment happens at median time period
|
|
195
|
+
all_periods = sorted(data[time].unique())
|
|
196
|
+
mid_point = len(all_periods) // 2
|
|
197
|
+
pre_periods = all_periods[:mid_point]
|
|
198
|
+
|
|
199
|
+
pre_data = data[data[time].isin(pre_periods)]
|
|
200
|
+
|
|
201
|
+
# Compute trends for each group
|
|
202
|
+
treated_data = pre_data[pre_data[treatment_group] == 1]
|
|
203
|
+
control_data = pre_data[pre_data[treatment_group] == 0]
|
|
204
|
+
|
|
205
|
+
# Simple linear regression for trends
|
|
206
|
+
def compute_trend(group_data):
|
|
207
|
+
time_values = group_data[time].values
|
|
208
|
+
outcome_values = group_data[outcome].values
|
|
209
|
+
|
|
210
|
+
# Normalize time to start at 0
|
|
211
|
+
time_norm = time_values - time_values.min()
|
|
212
|
+
|
|
213
|
+
# Compute slope using least squares
|
|
214
|
+
n = len(time_norm)
|
|
215
|
+
if n < 2:
|
|
216
|
+
return np.nan, np.nan
|
|
217
|
+
|
|
218
|
+
mean_t = np.mean(time_norm)
|
|
219
|
+
mean_y = np.mean(outcome_values)
|
|
220
|
+
|
|
221
|
+
slope = np.sum((time_norm - mean_t) * (outcome_values - mean_y)) / np.sum((time_norm - mean_t) ** 2)
|
|
222
|
+
|
|
223
|
+
# Compute standard error of slope
|
|
224
|
+
y_hat = mean_y + slope * (time_norm - mean_t)
|
|
225
|
+
residuals = outcome_values - y_hat
|
|
226
|
+
mse = np.sum(residuals ** 2) / (n - 2)
|
|
227
|
+
se_slope = np.sqrt(mse / np.sum((time_norm - mean_t) ** 2))
|
|
228
|
+
|
|
229
|
+
return slope, se_slope
|
|
230
|
+
|
|
231
|
+
treated_slope, treated_se = compute_trend(treated_data)
|
|
232
|
+
control_slope, control_se = compute_trend(control_data)
|
|
233
|
+
|
|
234
|
+
# Test for difference in trends
|
|
235
|
+
slope_diff = treated_slope - control_slope
|
|
236
|
+
se_diff = np.sqrt(treated_se ** 2 + control_se ** 2)
|
|
237
|
+
t_stat = slope_diff / se_diff if se_diff > 0 else np.nan
|
|
238
|
+
p_value = compute_p_value(t_stat) if not np.isnan(t_stat) else np.nan
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
"treated_trend": treated_slope,
|
|
242
|
+
"treated_trend_se": treated_se,
|
|
243
|
+
"control_trend": control_slope,
|
|
244
|
+
"control_trend_se": control_se,
|
|
245
|
+
"trend_difference": slope_diff,
|
|
246
|
+
"trend_difference_se": se_diff,
|
|
247
|
+
"t_statistic": t_stat,
|
|
248
|
+
"p_value": p_value,
|
|
249
|
+
"parallel_trends_plausible": p_value > 0.05 if not np.isnan(p_value) else None,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def check_parallel_trends_robust(
|
|
254
|
+
data: pd.DataFrame,
|
|
255
|
+
outcome: str,
|
|
256
|
+
time: str,
|
|
257
|
+
treatment_group: str,
|
|
258
|
+
unit: str = None,
|
|
259
|
+
pre_periods: list = None,
|
|
260
|
+
n_permutations: int = 1000,
|
|
261
|
+
seed: int = None
|
|
262
|
+
) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
Perform robust parallel trends testing using distributional comparisons.
|
|
265
|
+
|
|
266
|
+
Uses the Wasserstein (Earth Mover's) distance to compare the full
|
|
267
|
+
distribution of outcome changes between treated and control groups,
|
|
268
|
+
with permutation-based inference.
|
|
269
|
+
|
|
270
|
+
Parameters
|
|
271
|
+
----------
|
|
272
|
+
data : pd.DataFrame
|
|
273
|
+
Panel data with repeated observations over time.
|
|
274
|
+
outcome : str
|
|
275
|
+
Name of outcome variable column.
|
|
276
|
+
time : str
|
|
277
|
+
Name of time period column.
|
|
278
|
+
treatment_group : str
|
|
279
|
+
Name of treatment group indicator column (0/1).
|
|
280
|
+
unit : str, optional
|
|
281
|
+
Name of unit identifier column. If provided, computes unit-level
|
|
282
|
+
changes. Otherwise uses observation-level data.
|
|
283
|
+
pre_periods : list, optional
|
|
284
|
+
List of pre-treatment time periods. If None, uses first half of periods.
|
|
285
|
+
n_permutations : int, default=1000
|
|
286
|
+
Number of permutations for computing p-value.
|
|
287
|
+
seed : int, optional
|
|
288
|
+
Random seed for reproducibility.
|
|
289
|
+
|
|
290
|
+
Returns
|
|
291
|
+
-------
|
|
292
|
+
dict
|
|
293
|
+
Dictionary containing:
|
|
294
|
+
- wasserstein_distance: Wasserstein distance between group distributions
|
|
295
|
+
- wasserstein_p_value: Permutation-based p-value
|
|
296
|
+
- ks_statistic: Kolmogorov-Smirnov test statistic
|
|
297
|
+
- ks_p_value: KS test p-value
|
|
298
|
+
- mean_difference: Difference in mean changes
|
|
299
|
+
- variance_ratio: Ratio of variances in changes
|
|
300
|
+
- treated_changes: Array of outcome changes for treated
|
|
301
|
+
- control_changes: Array of outcome changes for control
|
|
302
|
+
- parallel_trends_plausible: Boolean assessment
|
|
303
|
+
|
|
304
|
+
Examples
|
|
305
|
+
--------
|
|
306
|
+
>>> results = check_parallel_trends_robust(
|
|
307
|
+
... data, outcome='sales', time='year',
|
|
308
|
+
... treatment_group='treated', unit='firm_id'
|
|
309
|
+
... )
|
|
310
|
+
>>> print(f"Wasserstein distance: {results['wasserstein_distance']:.4f}")
|
|
311
|
+
>>> print(f"P-value: {results['wasserstein_p_value']:.4f}")
|
|
312
|
+
|
|
313
|
+
Notes
|
|
314
|
+
-----
|
|
315
|
+
The Wasserstein distance (Earth Mover's Distance) measures the minimum
|
|
316
|
+
"cost" of transforming one distribution into another. Unlike simple
|
|
317
|
+
mean comparisons, it captures differences in the entire distribution
|
|
318
|
+
shape, making it more robust to non-normal data and heterogeneous effects.
|
|
319
|
+
|
|
320
|
+
A small Wasserstein distance and high p-value suggest the distributions
|
|
321
|
+
of pre-treatment changes are similar, supporting the parallel trends
|
|
322
|
+
assumption.
|
|
323
|
+
"""
|
|
324
|
+
if seed is not None:
|
|
325
|
+
np.random.seed(seed)
|
|
326
|
+
|
|
327
|
+
# Identify pre-treatment periods
|
|
328
|
+
if pre_periods is None:
|
|
329
|
+
all_periods = sorted(data[time].unique())
|
|
330
|
+
mid_point = len(all_periods) // 2
|
|
331
|
+
pre_periods = all_periods[:mid_point]
|
|
332
|
+
|
|
333
|
+
pre_data = data[data[time].isin(pre_periods)].copy()
|
|
334
|
+
|
|
335
|
+
# Compute outcome changes
|
|
336
|
+
treated_changes, control_changes = _compute_outcome_changes(
|
|
337
|
+
pre_data, outcome, time, treatment_group, unit
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if len(treated_changes) < 2 or len(control_changes) < 2:
|
|
341
|
+
return {
|
|
342
|
+
"wasserstein_distance": np.nan,
|
|
343
|
+
"wasserstein_p_value": np.nan,
|
|
344
|
+
"ks_statistic": np.nan,
|
|
345
|
+
"ks_p_value": np.nan,
|
|
346
|
+
"mean_difference": np.nan,
|
|
347
|
+
"variance_ratio": np.nan,
|
|
348
|
+
"treated_changes": treated_changes,
|
|
349
|
+
"control_changes": control_changes,
|
|
350
|
+
"parallel_trends_plausible": None,
|
|
351
|
+
"error": "Insufficient data for comparison",
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
# Compute Wasserstein distance
|
|
355
|
+
wasserstein_dist = stats.wasserstein_distance(treated_changes, control_changes)
|
|
356
|
+
|
|
357
|
+
# Permutation test for Wasserstein distance
|
|
358
|
+
all_changes = np.concatenate([treated_changes, control_changes])
|
|
359
|
+
n_treated = len(treated_changes)
|
|
360
|
+
n_total = len(all_changes)
|
|
361
|
+
|
|
362
|
+
permuted_distances = np.zeros(n_permutations)
|
|
363
|
+
for i in range(n_permutations):
|
|
364
|
+
perm_idx = np.random.permutation(n_total)
|
|
365
|
+
perm_treated = all_changes[perm_idx[:n_treated]]
|
|
366
|
+
perm_control = all_changes[perm_idx[n_treated:]]
|
|
367
|
+
permuted_distances[i] = stats.wasserstein_distance(perm_treated, perm_control)
|
|
368
|
+
|
|
369
|
+
# P-value: proportion of permuted distances >= observed
|
|
370
|
+
wasserstein_p = np.mean(permuted_distances >= wasserstein_dist)
|
|
371
|
+
|
|
372
|
+
# Kolmogorov-Smirnov test
|
|
373
|
+
ks_stat, ks_p = stats.ks_2samp(treated_changes, control_changes)
|
|
374
|
+
|
|
375
|
+
# Additional summary statistics
|
|
376
|
+
mean_diff = np.mean(treated_changes) - np.mean(control_changes)
|
|
377
|
+
var_treated = np.var(treated_changes, ddof=1)
|
|
378
|
+
var_control = np.var(control_changes, ddof=1)
|
|
379
|
+
var_ratio = var_treated / var_control if var_control > 0 else np.nan
|
|
380
|
+
|
|
381
|
+
# Normalized Wasserstein (relative to pooled std)
|
|
382
|
+
pooled_std = np.std(all_changes, ddof=1)
|
|
383
|
+
wasserstein_normalized = wasserstein_dist / pooled_std if pooled_std > 0 else np.nan
|
|
384
|
+
|
|
385
|
+
# Assessment: parallel trends plausible if p-value > 0.05
|
|
386
|
+
# and normalized Wasserstein is small (< 0.2 as rule of thumb)
|
|
387
|
+
plausible = bool(
|
|
388
|
+
wasserstein_p > 0.05 and
|
|
389
|
+
(wasserstein_normalized < 0.2 if not np.isnan(wasserstein_normalized) else True)
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
"wasserstein_distance": wasserstein_dist,
|
|
394
|
+
"wasserstein_normalized": wasserstein_normalized,
|
|
395
|
+
"wasserstein_p_value": wasserstein_p,
|
|
396
|
+
"ks_statistic": ks_stat,
|
|
397
|
+
"ks_p_value": ks_p,
|
|
398
|
+
"mean_difference": mean_diff,
|
|
399
|
+
"variance_ratio": var_ratio,
|
|
400
|
+
"n_treated": len(treated_changes),
|
|
401
|
+
"n_control": len(control_changes),
|
|
402
|
+
"treated_changes": treated_changes,
|
|
403
|
+
"control_changes": control_changes,
|
|
404
|
+
"parallel_trends_plausible": plausible,
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _compute_outcome_changes(
|
|
409
|
+
data: pd.DataFrame,
|
|
410
|
+
outcome: str,
|
|
411
|
+
time: str,
|
|
412
|
+
treatment_group: str,
|
|
413
|
+
unit: str = None
|
|
414
|
+
) -> tuple:
|
|
415
|
+
"""
|
|
416
|
+
Compute period-to-period outcome changes for treated and control groups.
|
|
417
|
+
|
|
418
|
+
Parameters
|
|
419
|
+
----------
|
|
420
|
+
data : pd.DataFrame
|
|
421
|
+
Panel data.
|
|
422
|
+
outcome : str
|
|
423
|
+
Outcome variable column.
|
|
424
|
+
time : str
|
|
425
|
+
Time period column.
|
|
426
|
+
treatment_group : str
|
|
427
|
+
Treatment group indicator column.
|
|
428
|
+
unit : str, optional
|
|
429
|
+
Unit identifier column.
|
|
430
|
+
|
|
431
|
+
Returns
|
|
432
|
+
-------
|
|
433
|
+
tuple
|
|
434
|
+
(treated_changes, control_changes) as numpy arrays.
|
|
435
|
+
"""
|
|
436
|
+
if unit is not None:
|
|
437
|
+
# Unit-level changes: compute change for each unit across periods
|
|
438
|
+
data_sorted = data.sort_values([unit, time])
|
|
439
|
+
data_sorted["_outcome_change"] = data_sorted.groupby(unit)[outcome].diff()
|
|
440
|
+
|
|
441
|
+
# Remove NaN from first period of each unit
|
|
442
|
+
changes_data = data_sorted.dropna(subset=["_outcome_change"])
|
|
443
|
+
|
|
444
|
+
treated_changes = changes_data[
|
|
445
|
+
changes_data[treatment_group] == 1
|
|
446
|
+
]["_outcome_change"].values
|
|
447
|
+
|
|
448
|
+
control_changes = changes_data[
|
|
449
|
+
changes_data[treatment_group] == 0
|
|
450
|
+
]["_outcome_change"].values
|
|
451
|
+
else:
|
|
452
|
+
# Aggregate changes: compute mean change per period per group
|
|
453
|
+
periods = sorted(data[time].unique())
|
|
454
|
+
|
|
455
|
+
treated_data = data[data[treatment_group] == 1]
|
|
456
|
+
control_data = data[data[treatment_group] == 0]
|
|
457
|
+
|
|
458
|
+
# Compute period means
|
|
459
|
+
treated_means = treated_data.groupby(time)[outcome].mean()
|
|
460
|
+
control_means = control_data.groupby(time)[outcome].mean()
|
|
461
|
+
|
|
462
|
+
# Compute changes between consecutive periods
|
|
463
|
+
treated_changes = np.diff(treated_means.values)
|
|
464
|
+
control_changes = np.diff(control_means.values)
|
|
465
|
+
|
|
466
|
+
return treated_changes.astype(float), control_changes.astype(float)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def equivalence_test_trends(
|
|
470
|
+
data: pd.DataFrame,
|
|
471
|
+
outcome: str,
|
|
472
|
+
time: str,
|
|
473
|
+
treatment_group: str,
|
|
474
|
+
unit: str = None,
|
|
475
|
+
pre_periods: list = None,
|
|
476
|
+
equivalence_margin: float = None
|
|
477
|
+
) -> dict:
|
|
478
|
+
"""
|
|
479
|
+
Perform equivalence testing (TOST) for parallel trends.
|
|
480
|
+
|
|
481
|
+
Tests whether the difference in trends is practically equivalent to zero
|
|
482
|
+
using Two One-Sided Tests (TOST) procedure.
|
|
483
|
+
|
|
484
|
+
Parameters
|
|
485
|
+
----------
|
|
486
|
+
data : pd.DataFrame
|
|
487
|
+
Panel data.
|
|
488
|
+
outcome : str
|
|
489
|
+
Name of outcome variable column.
|
|
490
|
+
time : str
|
|
491
|
+
Name of time period column.
|
|
492
|
+
treatment_group : str
|
|
493
|
+
Name of treatment group indicator column.
|
|
494
|
+
unit : str, optional
|
|
495
|
+
Name of unit identifier column.
|
|
496
|
+
pre_periods : list, optional
|
|
497
|
+
List of pre-treatment time periods.
|
|
498
|
+
equivalence_margin : float, optional
|
|
499
|
+
The margin for equivalence (delta). If None, uses 0.5 * pooled SD
|
|
500
|
+
of outcome changes as a default.
|
|
501
|
+
|
|
502
|
+
Returns
|
|
503
|
+
-------
|
|
504
|
+
dict
|
|
505
|
+
Dictionary containing:
|
|
506
|
+
- mean_difference: Difference in mean changes
|
|
507
|
+
- equivalence_margin: The margin used
|
|
508
|
+
- lower_p_value: P-value for lower bound test
|
|
509
|
+
- upper_p_value: P-value for upper bound test
|
|
510
|
+
- tost_p_value: Maximum of the two p-values
|
|
511
|
+
- equivalent: Boolean indicating equivalence at alpha=0.05
|
|
512
|
+
"""
|
|
513
|
+
# Get pre-treatment periods
|
|
514
|
+
if pre_periods is None:
|
|
515
|
+
all_periods = sorted(data[time].unique())
|
|
516
|
+
mid_point = len(all_periods) // 2
|
|
517
|
+
pre_periods = all_periods[:mid_point]
|
|
518
|
+
|
|
519
|
+
pre_data = data[data[time].isin(pre_periods)].copy()
|
|
520
|
+
|
|
521
|
+
# Compute outcome changes
|
|
522
|
+
treated_changes, control_changes = _compute_outcome_changes(
|
|
523
|
+
pre_data, outcome, time, treatment_group, unit
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
if len(treated_changes) < 2 or len(control_changes) < 2:
|
|
527
|
+
return {
|
|
528
|
+
"mean_difference": np.nan,
|
|
529
|
+
"equivalence_margin": np.nan,
|
|
530
|
+
"lower_p_value": np.nan,
|
|
531
|
+
"upper_p_value": np.nan,
|
|
532
|
+
"tost_p_value": np.nan,
|
|
533
|
+
"equivalent": None,
|
|
534
|
+
"error": "Insufficient data",
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
# Compute statistics
|
|
538
|
+
mean_diff = np.mean(treated_changes) - np.mean(control_changes)
|
|
539
|
+
se_diff = np.sqrt(
|
|
540
|
+
np.var(treated_changes, ddof=1) / len(treated_changes) +
|
|
541
|
+
np.var(control_changes, ddof=1) / len(control_changes)
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Set equivalence margin if not provided
|
|
545
|
+
if equivalence_margin is None:
|
|
546
|
+
pooled_changes = np.concatenate([treated_changes, control_changes])
|
|
547
|
+
equivalence_margin = 0.5 * np.std(pooled_changes, ddof=1)
|
|
548
|
+
|
|
549
|
+
# Degrees of freedom (Welch-Satterthwaite approximation)
|
|
550
|
+
var_t = np.var(treated_changes, ddof=1)
|
|
551
|
+
var_c = np.var(control_changes, ddof=1)
|
|
552
|
+
n_t = len(treated_changes)
|
|
553
|
+
n_c = len(control_changes)
|
|
554
|
+
|
|
555
|
+
df = ((var_t/n_t + var_c/n_c)**2 /
|
|
556
|
+
((var_t/n_t)**2/(n_t-1) + (var_c/n_c)**2/(n_c-1)))
|
|
557
|
+
|
|
558
|
+
# TOST: Two one-sided tests
|
|
559
|
+
# Test 1: H0: diff <= -margin vs H1: diff > -margin
|
|
560
|
+
t_lower = (mean_diff - (-equivalence_margin)) / se_diff
|
|
561
|
+
p_lower = stats.t.sf(t_lower, df)
|
|
562
|
+
|
|
563
|
+
# Test 2: H0: diff >= margin vs H1: diff < margin
|
|
564
|
+
t_upper = (mean_diff - equivalence_margin) / se_diff
|
|
565
|
+
p_upper = stats.t.cdf(t_upper, df)
|
|
566
|
+
|
|
567
|
+
# TOST p-value is the maximum of the two
|
|
568
|
+
tost_p = max(p_lower, p_upper)
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
"mean_difference": mean_diff,
|
|
572
|
+
"se_difference": se_diff,
|
|
573
|
+
"equivalence_margin": equivalence_margin,
|
|
574
|
+
"lower_t_stat": t_lower,
|
|
575
|
+
"upper_t_stat": t_upper,
|
|
576
|
+
"lower_p_value": p_lower,
|
|
577
|
+
"upper_p_value": p_upper,
|
|
578
|
+
"tost_p_value": tost_p,
|
|
579
|
+
"degrees_of_freedom": df,
|
|
580
|
+
"equivalent": bool(tost_p < 0.05),
|
|
581
|
+
}
|