trop 0.1.1__py3-none-any.whl → 0.1.3__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.
- trop/__init__.py +7 -1
- trop/cv.py +411 -0
- trop/estimator.py +35 -31
- {trop-0.1.1.dist-info → trop-0.1.3.dist-info}/METADATA +6 -8
- trop-0.1.3.dist-info/RECORD +8 -0
- {trop-0.1.1.dist-info → trop-0.1.3.dist-info}/WHEEL +1 -1
- trop-0.1.1.dist-info/RECORD +0 -7
- {trop-0.1.1.dist-info → trop-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {trop-0.1.1.dist-info → trop-0.1.3.dist-info}/top_level.txt +0 -0
trop/__init__.py
CHANGED
trop/cv.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable, Optional, Sequence, Tuple, Union, List
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from joblib import Parallel, delayed
|
|
8
|
+
|
|
9
|
+
from .estimator import TROP_TWFE_average
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
ArrayLike = Union[np.ndarray, Sequence[Sequence[float]]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _validate_panel(Y: np.ndarray, treated_periods: int, n_treated_units: int) -> None:
|
|
16
|
+
"""Validate panel dimensions and placebo CV inputs."""
|
|
17
|
+
if Y.ndim != 2:
|
|
18
|
+
raise ValueError("Y must be a 2D array of shape (N, T).")
|
|
19
|
+
N, T = Y.shape
|
|
20
|
+
if treated_periods <= 0 or treated_periods >= T:
|
|
21
|
+
raise ValueError(f"treated_periods must be in [1, T-1]. Got treated_periods={treated_periods}, T={T}.")
|
|
22
|
+
if n_treated_units <= 0 or n_treated_units >= N:
|
|
23
|
+
raise ValueError(f"n_treated_units must be in [1, N-1]. Got n_treated_units={n_treated_units}, N={N}.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _as_list(grid: Iterable[float]) -> List[float]:
|
|
27
|
+
"""Convert an iterable of grid values to a non-empty list of floats."""
|
|
28
|
+
grid_list = list(grid)
|
|
29
|
+
if len(grid_list) == 0:
|
|
30
|
+
raise ValueError("lambda_grid must be non-empty.")
|
|
31
|
+
grid_list = [float(x) for x in grid_list]
|
|
32
|
+
return grid_list
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _simulate_ate(
|
|
37
|
+
seed: int,
|
|
38
|
+
Y: np.ndarray,
|
|
39
|
+
n_treated_units: int,
|
|
40
|
+
treated_periods: int,
|
|
41
|
+
lambda_unit: float,
|
|
42
|
+
lambda_time: float,
|
|
43
|
+
lambda_nn: float,
|
|
44
|
+
solver: Optional[str] = None,
|
|
45
|
+
verbose: bool = False,
|
|
46
|
+
) -> float:
|
|
47
|
+
"""
|
|
48
|
+
Simulate a single placebo ATE by randomly selecting treated units.
|
|
49
|
+
"""
|
|
50
|
+
rng = np.random.default_rng(seed)
|
|
51
|
+
N, _ = Y.shape
|
|
52
|
+
treated_units = rng.choice(N, size=n_treated_units, replace=False)
|
|
53
|
+
|
|
54
|
+
W = np.zeros_like(Y, dtype=float)
|
|
55
|
+
W[treated_units, -treated_periods:] = 1.0
|
|
56
|
+
|
|
57
|
+
return TROP_TWFE_average(
|
|
58
|
+
Y=Y,
|
|
59
|
+
W=W,
|
|
60
|
+
treated_units=treated_units,
|
|
61
|
+
lambda_unit=lambda_unit,
|
|
62
|
+
lambda_time=lambda_time,
|
|
63
|
+
lambda_nn=lambda_nn,
|
|
64
|
+
treated_periods=treated_periods,
|
|
65
|
+
solver=solver,
|
|
66
|
+
verbose=verbose,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def TROP_cv_single(
|
|
71
|
+
Y_control: ArrayLike,
|
|
72
|
+
n_treated_units: int,
|
|
73
|
+
treated_periods: int,
|
|
74
|
+
fixed_lambdas: Tuple[float, float] = (0.0, 0.0),
|
|
75
|
+
lambda_grid: Optional[Iterable[float]] = None,
|
|
76
|
+
lambda_cv: str = "unit",
|
|
77
|
+
*,
|
|
78
|
+
n_trials: int = 200,
|
|
79
|
+
n_jobs: int = -1,
|
|
80
|
+
prefer: str = "threads",
|
|
81
|
+
random_seed: int = 0,
|
|
82
|
+
solver: Optional[str] = None,
|
|
83
|
+
verbose: bool = False,
|
|
84
|
+
) -> float:
|
|
85
|
+
"""
|
|
86
|
+
Tune one TROP tuning parameter via placebo cross-validation on a control-only panel.
|
|
87
|
+
|
|
88
|
+
For each candidate value in `lambda_grid`, this routine repeatedly assigns a placebo
|
|
89
|
+
treatment to random units in the last `treated_periods` columns, computes the
|
|
90
|
+
corresponding TROP estimate, and selects the lambda that minimizes the RMSE of
|
|
91
|
+
placebo effects.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
Y_control : array_like of shape (N, T)
|
|
96
|
+
Control-only outcome panel used for placebo cross-validation.
|
|
97
|
+
n_treated_units : int
|
|
98
|
+
Number of placebo treated units sampled (without replacement) per trial.
|
|
99
|
+
treated_periods : int
|
|
100
|
+
Number of placebo treated (post) periods, taken as the final columns.
|
|
101
|
+
fixed_lambdas : tuple of float, default=(0.0, 0.0)
|
|
102
|
+
Values held fixed for the two lambdas not being tuned. Interpretation depends on
|
|
103
|
+
`lambda_cv`:
|
|
104
|
+
- 'unit': (lambda_time, lambda_nn)
|
|
105
|
+
- 'time': (lambda_unit, lambda_nn)
|
|
106
|
+
- 'nn' : (lambda_unit, lambda_time)
|
|
107
|
+
lambda_grid : iterable of float or None, default=None
|
|
108
|
+
Candidate values for the lambda being tuned. If None, uses ``np.arange(0, 2, 0.2)``.
|
|
109
|
+
lambda_cv : {'unit', 'time', 'nn'}, default='unit'
|
|
110
|
+
Which lambda to tune.
|
|
111
|
+
n_trials : int, default=200
|
|
112
|
+
Number of placebo trials per candidate lambda.
|
|
113
|
+
n_jobs : int, default=-1
|
|
114
|
+
Number of parallel jobs for placebo trials. ``-1`` uses all available cores.
|
|
115
|
+
prefer : {'threads', 'processes'}, default='threads'
|
|
116
|
+
joblib backend preference.
|
|
117
|
+
random_seed : int, default=0
|
|
118
|
+
Seed for generating trial seeds (deterministic tuning).
|
|
119
|
+
solver : str or None, default=None
|
|
120
|
+
CVXPY solver passed to ``TROP_TWFE_average``.
|
|
121
|
+
verbose : bool, default=False
|
|
122
|
+
Verbosity flag passed to ``TROP_TWFE_average``.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
float
|
|
127
|
+
Selected lambda value minimizing the RMSE of placebo estimates.
|
|
128
|
+
"""
|
|
129
|
+
Y = np.asarray(Y_control, dtype=float)
|
|
130
|
+
_validate_panel(Y, treated_periods, n_treated_units)
|
|
131
|
+
|
|
132
|
+
if lambda_cv not in {"unit", "time", "nn"}:
|
|
133
|
+
raise ValueError("lambda_cv must be one of {'unit','time','nn'}.")
|
|
134
|
+
|
|
135
|
+
if lambda_grid is None:
|
|
136
|
+
lambda_grid_list = _as_list(np.arange(0.0, 2.0, 0.2))
|
|
137
|
+
else:
|
|
138
|
+
lambda_grid_list = _as_list(lambda_grid)
|
|
139
|
+
|
|
140
|
+
if n_trials <= 0:
|
|
141
|
+
raise ValueError("n_trials must be positive.")
|
|
142
|
+
if n_jobs == 0 or n_jobs < -1:
|
|
143
|
+
raise ValueError("n_jobs must be -1 or a positive integer.")
|
|
144
|
+
|
|
145
|
+
base_rng = np.random.default_rng(random_seed)
|
|
146
|
+
seeds = base_rng.integers(0, 2**32 - 1, size=n_trials, dtype=np.uint32)
|
|
147
|
+
|
|
148
|
+
scores: List[float] = []
|
|
149
|
+
|
|
150
|
+
for lamb in lambda_grid_list:
|
|
151
|
+
if lamb < 0:
|
|
152
|
+
raise ValueError("Lambda values must be nonnegative.")
|
|
153
|
+
|
|
154
|
+
if lambda_cv == "unit":
|
|
155
|
+
lambda_unit, lambda_time, lambda_nn = lamb, float(fixed_lambdas[0]), float(fixed_lambdas[1])
|
|
156
|
+
elif lambda_cv == "time":
|
|
157
|
+
lambda_unit, lambda_time, lambda_nn = float(fixed_lambdas[0]), lamb, float(fixed_lambdas[1])
|
|
158
|
+
else: # 'nn'
|
|
159
|
+
lambda_unit, lambda_time, lambda_nn = float(fixed_lambdas[0]), float(fixed_lambdas[1]), lamb
|
|
160
|
+
|
|
161
|
+
ates = Parallel(n_jobs=n_jobs, prefer=prefer)(
|
|
162
|
+
delayed(_simulate_ate)(
|
|
163
|
+
int(seed),
|
|
164
|
+
Y,
|
|
165
|
+
n_treated_units,
|
|
166
|
+
treated_periods,
|
|
167
|
+
lambda_unit,
|
|
168
|
+
lambda_time,
|
|
169
|
+
lambda_nn,
|
|
170
|
+
solver,
|
|
171
|
+
verbose,
|
|
172
|
+
)
|
|
173
|
+
for seed in seeds
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
ates_arr = np.asarray(ates, dtype=float)
|
|
177
|
+
ates_arr = ates_arr[np.isfinite(ates_arr)]
|
|
178
|
+
|
|
179
|
+
if ates_arr.size == 0:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"All placebo trials failed or returned non-finite ATEs for lambda={lamb} "
|
|
182
|
+
f"(lambda_cv='{lambda_cv}'). Consider changing solver/settings."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
scores.append(float(np.sqrt(np.mean(ates_arr**2))))
|
|
186
|
+
|
|
187
|
+
best_idx = int(np.argmin(scores))
|
|
188
|
+
return float(lambda_grid_list[best_idx])
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def TROP_cv_cycle(
|
|
192
|
+
Y_control: ArrayLike,
|
|
193
|
+
n_treated_units: int,
|
|
194
|
+
treated_periods: int,
|
|
195
|
+
unit_grid: Sequence[float],
|
|
196
|
+
time_grid: Sequence[float],
|
|
197
|
+
nn_grid: Sequence[float],
|
|
198
|
+
lambdas_init: Optional[Tuple[float, float, float]] = None,
|
|
199
|
+
*,
|
|
200
|
+
max_iter: int = 50,
|
|
201
|
+
n_trials: int = 200,
|
|
202
|
+
n_jobs: int = -1,
|
|
203
|
+
prefer: str = "threads",
|
|
204
|
+
random_seed: int = 0,
|
|
205
|
+
solver: Optional[str] = None,
|
|
206
|
+
verbose: bool = False,
|
|
207
|
+
) -> Tuple[float, float, float]:
|
|
208
|
+
"""
|
|
209
|
+
Tune (lambda_unit, lambda_time, lambda_nn) by coordinate-descent placebo cross-validation.
|
|
210
|
+
|
|
211
|
+
Iteratively updates one tuning parameter at a time using `TROP_cv_single` (holding the
|
|
212
|
+
other two fixed) until the selected triplet stops changing or `max_iter` is reached.
|
|
213
|
+
Each update minimizes the RMSE of placebo effects on a control-only panel.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
Y_control : array_like of shape (N, T)
|
|
218
|
+
Control-only outcome panel used for placebo cross-validation.
|
|
219
|
+
n_treated_units : int
|
|
220
|
+
Number of placebo treated units sampled (without replacement) per trial.
|
|
221
|
+
treated_periods : int
|
|
222
|
+
Number of placebo treated (post) periods, taken as the final columns.
|
|
223
|
+
unit_grid : sequence of float
|
|
224
|
+
Candidate values for `lambda_unit` (unit-distance decay).
|
|
225
|
+
time_grid : sequence of float
|
|
226
|
+
Candidate values for `lambda_time` (time-distance decay).
|
|
227
|
+
nn_grid : sequence of float
|
|
228
|
+
Candidate values for `lambda_nn` (nuclear-norm penalty).
|
|
229
|
+
lambdas_init : tuple of float or None, default=None
|
|
230
|
+
Initial values (lambda_unit, lambda_time, lambda_nn). If None, initializes each
|
|
231
|
+
parameter to the mean of its grid.
|
|
232
|
+
max_iter : int, default=50
|
|
233
|
+
Maximum number of coordinate-descent iterations.
|
|
234
|
+
n_trials : int, default=200
|
|
235
|
+
Number of placebo trials per grid point in each coordinate update.
|
|
236
|
+
n_jobs : int, default=-1
|
|
237
|
+
Number of parallel jobs for placebo trials. ``-1`` uses all available cores.
|
|
238
|
+
prefer : {'threads', 'processes'}, default='threads'
|
|
239
|
+
joblib backend preference.
|
|
240
|
+
random_seed : int, default=0
|
|
241
|
+
Seed for generating trial seeds (deterministic tuning).
|
|
242
|
+
solver : str or None, default=None
|
|
243
|
+
CVXPY solver passed to ``TROP_TWFE_average``.
|
|
244
|
+
verbose : bool, default=False
|
|
245
|
+
Verbosity flag passed to ``TROP_TWFE_average``.
|
|
246
|
+
|
|
247
|
+
Returns
|
|
248
|
+
-------
|
|
249
|
+
tuple of float
|
|
250
|
+
(lambda_unit, lambda_time, lambda_nn) at the fixed point of the coordinate updates.
|
|
251
|
+
|
|
252
|
+
Raises
|
|
253
|
+
------
|
|
254
|
+
RuntimeError
|
|
255
|
+
If the procedure does not converge within `max_iter`.
|
|
256
|
+
"""
|
|
257
|
+
Y = np.asarray(Y_control, dtype=float)
|
|
258
|
+
_validate_panel(Y, treated_periods, n_treated_units)
|
|
259
|
+
|
|
260
|
+
unit_grid_list = _as_list(unit_grid)
|
|
261
|
+
time_grid_list = _as_list(time_grid)
|
|
262
|
+
nn_grid_list = _as_list(nn_grid)
|
|
263
|
+
|
|
264
|
+
if lambdas_init is None:
|
|
265
|
+
lambda_unit = float(np.mean(unit_grid_list))
|
|
266
|
+
lambda_time = float(np.mean(time_grid_list))
|
|
267
|
+
lambda_nn = float(np.mean(nn_grid_list))
|
|
268
|
+
else:
|
|
269
|
+
lambda_unit, lambda_time, lambda_nn = map(float, lambdas_init)
|
|
270
|
+
|
|
271
|
+
for _ in range(max_iter):
|
|
272
|
+
old = (lambda_unit, lambda_time, lambda_nn)
|
|
273
|
+
|
|
274
|
+
lambda_unit = TROP_cv_single(
|
|
275
|
+
Y, n_treated_units, treated_periods,
|
|
276
|
+
fixed_lambdas=(lambda_time, lambda_nn),
|
|
277
|
+
lambda_grid=unit_grid_list,
|
|
278
|
+
lambda_cv="unit",
|
|
279
|
+
n_trials=n_trials, n_jobs=n_jobs, prefer=prefer,
|
|
280
|
+
random_seed=random_seed, solver=solver, verbose=verbose
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
lambda_time = TROP_cv_single(
|
|
284
|
+
Y, n_treated_units, treated_periods,
|
|
285
|
+
fixed_lambdas=(lambda_unit, lambda_nn),
|
|
286
|
+
lambda_grid=time_grid_list,
|
|
287
|
+
lambda_cv="time",
|
|
288
|
+
n_trials=n_trials, n_jobs=n_jobs, prefer=prefer,
|
|
289
|
+
random_seed=random_seed, solver=solver, verbose=verbose
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
lambda_nn = TROP_cv_single(
|
|
293
|
+
Y, n_treated_units, treated_periods,
|
|
294
|
+
fixed_lambdas=(lambda_unit, lambda_time),
|
|
295
|
+
lambda_grid=nn_grid_list,
|
|
296
|
+
lambda_cv="nn",
|
|
297
|
+
n_trials=n_trials, n_jobs=n_jobs, prefer=prefer,
|
|
298
|
+
random_seed=random_seed, solver=solver, verbose=verbose
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
new = (lambda_unit, lambda_time, lambda_nn)
|
|
302
|
+
if new == old:
|
|
303
|
+
return new
|
|
304
|
+
|
|
305
|
+
raise RuntimeError("TROP_cv_cycle did not converge (no fixed point) within max_iter.")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def TROP_cv_joint(
|
|
309
|
+
Y_control: ArrayLike,
|
|
310
|
+
n_treated_units: int,
|
|
311
|
+
treated_periods: int,
|
|
312
|
+
unit_grid: Sequence[float],
|
|
313
|
+
time_grid: Sequence[float],
|
|
314
|
+
nn_grid: Sequence[float],
|
|
315
|
+
*,
|
|
316
|
+
n_trials: int = 200,
|
|
317
|
+
n_jobs: int = -1,
|
|
318
|
+
prefer: str = "threads",
|
|
319
|
+
random_seed: int = 0,
|
|
320
|
+
solver: Optional[str] = None,
|
|
321
|
+
verbose: bool = False,
|
|
322
|
+
) -> Tuple[float, float, float]:
|
|
323
|
+
"""
|
|
324
|
+
Select (lambda_unit, lambda_time, lambda_nn) by joint placebo cross-validation.
|
|
325
|
+
|
|
326
|
+
Performs a full grid search over `unit_grid` × `time_grid` × `nn_grid`. For each
|
|
327
|
+
candidate triple, repeatedly assigns a placebo treatment to random units in the
|
|
328
|
+
last `treated_periods` columns and selects the triple that minimizes the RMSE of
|
|
329
|
+
placebo effects on the control-only panel.
|
|
330
|
+
|
|
331
|
+
Parameters
|
|
332
|
+
----------
|
|
333
|
+
Y_control : array_like of shape (N, T)
|
|
334
|
+
Control-only outcome panel used for placebo cross-validation.
|
|
335
|
+
n_treated_units : int
|
|
336
|
+
Number of placebo treated units sampled (without replacement) per trial.
|
|
337
|
+
treated_periods : int
|
|
338
|
+
Number of placebo treated (post) periods, taken as the final columns.
|
|
339
|
+
unit_grid : sequence of float
|
|
340
|
+
Candidate values for `lambda_unit` (unit-distance decay).
|
|
341
|
+
time_grid : sequence of float
|
|
342
|
+
Candidate values for `lambda_time` (time-distance decay).
|
|
343
|
+
nn_grid : sequence of float
|
|
344
|
+
Candidate values for `lambda_nn` (nuclear-norm penalty).
|
|
345
|
+
n_trials : int, default=200
|
|
346
|
+
Number of placebo trials per candidate triple.
|
|
347
|
+
n_jobs : int, default=-1
|
|
348
|
+
Number of parallel jobs for placebo trials. ``-1`` uses all available cores.
|
|
349
|
+
prefer : {'threads', 'processes'}, default='threads'
|
|
350
|
+
joblib backend preference.
|
|
351
|
+
random_seed : int, default=0
|
|
352
|
+
Seed for generating trial seeds (deterministic tuning).
|
|
353
|
+
solver : str or None, default=None
|
|
354
|
+
CVXPY solver passed to ``TROP_TWFE_average``.
|
|
355
|
+
verbose : bool, default=False
|
|
356
|
+
Verbosity flag passed to ``TROP_TWFE_average``.
|
|
357
|
+
|
|
358
|
+
Returns
|
|
359
|
+
-------
|
|
360
|
+
tuple of float
|
|
361
|
+
(lambda_unit, lambda_time, lambda_nn) minimizing the RMSE of placebo estimates.
|
|
362
|
+
|
|
363
|
+
Raises
|
|
364
|
+
------
|
|
365
|
+
RuntimeError
|
|
366
|
+
If all parameter combinations fail (e.g., solver failures for every triple).
|
|
367
|
+
"""
|
|
368
|
+
Y = np.asarray(Y_control, dtype=float)
|
|
369
|
+
_validate_panel(Y, treated_periods, n_treated_units)
|
|
370
|
+
|
|
371
|
+
unit_grid_list = _as_list(unit_grid)
|
|
372
|
+
time_grid_list = _as_list(time_grid)
|
|
373
|
+
nn_grid_list = _as_list(nn_grid)
|
|
374
|
+
|
|
375
|
+
base_rng = np.random.default_rng(random_seed)
|
|
376
|
+
seeds = base_rng.integers(0, 2**32 - 1, size=n_trials, dtype=np.uint32)
|
|
377
|
+
|
|
378
|
+
best_params: Optional[Tuple[float, float, float]] = None
|
|
379
|
+
best_score: float = float("inf")
|
|
380
|
+
|
|
381
|
+
for lambda_unit in unit_grid_list:
|
|
382
|
+
for lambda_time in time_grid_list:
|
|
383
|
+
for lambda_nn in nn_grid_list:
|
|
384
|
+
ates = Parallel(n_jobs=n_jobs, prefer=prefer)(
|
|
385
|
+
delayed(_simulate_ate)(
|
|
386
|
+
int(seed),
|
|
387
|
+
Y,
|
|
388
|
+
n_treated_units,
|
|
389
|
+
treated_periods,
|
|
390
|
+
float(lambda_unit),
|
|
391
|
+
float(lambda_time),
|
|
392
|
+
float(lambda_nn),
|
|
393
|
+
solver,
|
|
394
|
+
verbose,
|
|
395
|
+
)
|
|
396
|
+
for seed in seeds
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
ates_arr = np.asarray(ates, dtype=float)
|
|
400
|
+
ates_arr = ates_arr[np.isfinite(ates_arr)]
|
|
401
|
+
if ates_arr.size == 0:
|
|
402
|
+
continue # skip invalid setting
|
|
403
|
+
|
|
404
|
+
score = float(np.sqrt(np.mean(ates_arr**2)))
|
|
405
|
+
if score < best_score:
|
|
406
|
+
best_score = score
|
|
407
|
+
best_params = (float(lambda_unit), float(lambda_time), float(lambda_nn))
|
|
408
|
+
|
|
409
|
+
if best_params is None:
|
|
410
|
+
raise RuntimeError("All parameter combinations failed during joint CV. Check solver/settings.")
|
|
411
|
+
return best_params
|
trop/estimator.py
CHANGED
|
@@ -22,44 +22,48 @@ def TROP_TWFE_average(
|
|
|
22
22
|
verbose: bool = False,
|
|
23
23
|
) -> float:
|
|
24
24
|
"""
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- distance-based time weights (lambda_time)
|
|
28
|
-
- optional low-rank regression adjustment (nuclear norm penalty, lambda_nn)
|
|
25
|
+
Compute the TROP treatment effect with unit/time weighting and optional low-rank
|
|
26
|
+
outcome model.
|
|
29
27
|
|
|
30
28
|
Parameters
|
|
31
29
|
----------
|
|
32
|
-
Y:
|
|
33
|
-
Outcome matrix
|
|
34
|
-
W:
|
|
35
|
-
Treatment indicator
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
treated_units:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
verbose:
|
|
56
|
-
Passed to
|
|
30
|
+
Y : array_like of shape (N, T)
|
|
31
|
+
Outcome matrix.
|
|
32
|
+
W : array_like of shape (N, T)
|
|
33
|
+
Treatment indicator (often binary). The estimator uses ``W`` as provided;
|
|
34
|
+
``treated_periods`` is used only to construct weights/masks, not to infer
|
|
35
|
+
treatment timing.
|
|
36
|
+
treated_units : sequence of int
|
|
37
|
+
Row indices of treated units used to form the reference (average) treated
|
|
38
|
+
trajectory for unit-distance weighting.
|
|
39
|
+
lambda_unit : float
|
|
40
|
+
Nonnegative decay parameter for unit weights: ``exp(-lambda_unit * dist_unit)``.
|
|
41
|
+
lambda_time : float
|
|
42
|
+
Nonnegative decay parameter for time weights: ``exp(-lambda_time * dist_time)``.
|
|
43
|
+
lambda_nn : float
|
|
44
|
+
Nuclear-norm penalty weight for the low-rank component ``L``. Use
|
|
45
|
+
``np.inf`` to disable the low-rank adjustment (i.e., omit ``L``).
|
|
46
|
+
treated_periods : int, default=10
|
|
47
|
+
Number of final columns treated as the "post/tail block" for constructing
|
|
48
|
+
(a) the pre-period mask (all but last ``treated_periods`` columns) used in
|
|
49
|
+
unit distances, and (b) the time-distance center.
|
|
50
|
+
solver : str or None, default=None
|
|
51
|
+
CVXPY solver name. If None, uses "SCS" when ``lambda_nn`` is finite and
|
|
52
|
+
"OSQP" when ``lambda_nn`` is infinite.
|
|
53
|
+
verbose : bool, default=False
|
|
54
|
+
Passed to ``cvxpy.Problem.solve``.
|
|
57
55
|
|
|
58
56
|
Returns
|
|
59
57
|
-------
|
|
60
58
|
float
|
|
61
|
-
Estimated
|
|
62
|
-
|
|
59
|
+
Estimated treatment-effect parameter ``tau`` from the weighted TWFE objective.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
ValueError
|
|
64
|
+
If input shapes are inconsistent or tuning parameters are invalid.
|
|
65
|
+
RuntimeError
|
|
66
|
+
If the optimization fails to produce a finite ``tau``.
|
|
63
67
|
"""
|
|
64
68
|
Y = np.asarray(Y, dtype=float)
|
|
65
69
|
W = np.asarray(W, dtype=float)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trop
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Triply Robust Panel (TROP) estimator: weighted TWFE with optional low-rank adjustment.
|
|
5
5
|
Author: Susan Athey, Guido Imbens, Zhaonan Qu, Davide Viviano
|
|
6
6
|
License-Expression: MIT
|
|
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: numpy>=1.23
|
|
26
26
|
Requires-Dist: cvxpy>=1.4
|
|
27
|
+
Requires-Dist: joblib>=1.2
|
|
27
28
|
Requires-Dist: osqp>=0.6.5
|
|
28
29
|
Requires-Dist: scs>=3.2.4
|
|
29
30
|
Provides-Extra: dev
|
|
@@ -34,18 +35,15 @@ Dynamic: license-file
|
|
|
34
35
|
|
|
35
36
|
# TROP: Triply Robust Panel Estimator
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
`trop` is a Python package implementing the **Triply Robust Panel (TROP)** estimator for average treatment effects (ATEs) in panel data. The core estimator is expressed as a weighted two-way fixed effects (TWFE) objective, with an optional low-rank regression adjustment via a nuclear-norm penalty.
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
Reference:
|
|
38
42
|
|
|
39
43
|
> Susan Athey, Guido Imbens, Zhaonan Qu, Davide Viviano (2025).
|
|
40
44
|
> *Triply Robust Panel Estimators*.
|
|
41
45
|
> arXiv:2508.21536.
|
|
42
46
|
|
|
43
|
-
The initial release (v0.1.0) exposes the function:
|
|
44
|
-
|
|
45
|
-
- `TROP_TWFE_average(Y, W, treated_units, lambda_unit, lambda_time, lambda_nn, treated_periods=..., solver=...)`
|
|
46
|
-
|
|
47
|
-
which estimates an average treatment effect `tau` in panel settings using a weighted TWFE objective with optional low-rank adjustment.
|
|
48
|
-
|
|
49
47
|
---
|
|
50
48
|
|
|
51
49
|
## Installation
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
trop/__init__.py,sha256=B94vrDZevg2l6ijN4lut7wo0MYTEtBTTZhqmMQtq7Qg,205
|
|
2
|
+
trop/cv.py,sha256=tbT2mvPO_gQ28GT5CIPZR1NqL11J3PjxsTHYxNWmVRk,14908
|
|
3
|
+
trop/estimator.py,sha256=iZFH9D664OsEZascxAEdSqOBPj26DofXHk6jWgz_42Q,6257
|
|
4
|
+
trop-0.1.3.dist-info/licenses/LICENSE,sha256=VqjvjioQz04uLYBj4ye0x-_Ss77-WTIuEWWCW_awEz8,1065
|
|
5
|
+
trop-0.1.3.dist-info/METADATA,sha256=paPD2_iwXAHPc-y6Dlu3CmpgrnGRHZplRqgjsV-t2y8,1997
|
|
6
|
+
trop-0.1.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
7
|
+
trop-0.1.3.dist-info/top_level.txt,sha256=jaqQZFm3D5B4vPBAKZtXfEAYnpl9FKsNHqlM49kcwTI,5
|
|
8
|
+
trop-0.1.3.dist-info/RECORD,,
|
trop-0.1.1.dist-info/RECORD
DELETED
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
trop/__init__.py,sha256=DW6eDmMyaY1tQ6wb-EP48fTNtkeUOuvqE5l88d8SnrA,73
|
|
2
|
-
trop/estimator.py,sha256=FWMO39GbL6k3Vz5g1V7SpR6t5wP3N81V5gGSFIe65Xw,6001
|
|
3
|
-
trop-0.1.1.dist-info/licenses/LICENSE,sha256=VqjvjioQz04uLYBj4ye0x-_Ss77-WTIuEWWCW_awEz8,1065
|
|
4
|
-
trop-0.1.1.dist-info/METADATA,sha256=nM0XBF9nad4XGbpHiBmtLnPXs-mKxXM3sDxvt_ALl6Y,2069
|
|
5
|
-
trop-0.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
6
|
-
trop-0.1.1.dist-info/top_level.txt,sha256=jaqQZFm3D5B4vPBAKZtXfEAYnpl9FKsNHqlM49kcwTI,5
|
|
7
|
-
trop-0.1.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|