pyfector 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.
- pyfector/__init__.py +41 -0
- pyfector/backend.py +88 -0
- pyfector/cv.py +327 -0
- pyfector/diagnostics.py +213 -0
- pyfector/estimators.py +827 -0
- pyfector/fect.py +487 -0
- pyfector/inference.py +325 -0
- pyfector/linalg.py +376 -0
- pyfector/panel.py +412 -0
- pyfector/plotting.py +258 -0
- pyfector-0.1.0.dist-info/METADATA +262 -0
- pyfector-0.1.0.dist-info/RECORD +14 -0
- pyfector-0.1.0.dist-info/WHEEL +4 -0
- pyfector-0.1.0.dist-info/licenses/LICENSE +21 -0
pyfector/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pyfector — Fast counterfactual estimators for panel data in Python.
|
|
3
|
+
|
|
4
|
+
A high-performance Python reimplementation of the R ``fect`` package,
|
|
5
|
+
featuring randomized SVD, GPU acceleration (CuPy), parallel computing
|
|
6
|
+
(joblib), Polars data ingestion, and seeded reproducibility.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
import pyfector
|
|
11
|
+
|
|
12
|
+
result = pyfector.fect(
|
|
13
|
+
data=df,
|
|
14
|
+
Y="outcome", D="treat",
|
|
15
|
+
index=("unit", "year"),
|
|
16
|
+
method="ife",
|
|
17
|
+
r=(0, 5),
|
|
18
|
+
se=True,
|
|
19
|
+
seed=42,
|
|
20
|
+
)
|
|
21
|
+
result.summary()
|
|
22
|
+
result.plot()
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
27
|
+
|
|
28
|
+
from .fect import fect, FectResult
|
|
29
|
+
from .backend import set_device, get_device
|
|
30
|
+
from .diagnostics import run_diagnostics, DiagnosticResult
|
|
31
|
+
from .plotting import plot
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"fect",
|
|
35
|
+
"FectResult",
|
|
36
|
+
"set_device",
|
|
37
|
+
"get_device",
|
|
38
|
+
"run_diagnostics",
|
|
39
|
+
"DiagnosticResult",
|
|
40
|
+
"plot",
|
|
41
|
+
]
|
pyfector/backend.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Array backend abstraction: seamless NumPy / CuPy switching.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from pyfector.backend import get_backend, set_device
|
|
6
|
+
set_device("gpu") # or "cpu" (default)
|
|
7
|
+
xp = get_backend() # returns numpy or cupy module
|
|
8
|
+
A = xp.random.randn(100, 50)
|
|
9
|
+
|
|
10
|
+
All pyfector internals call ``get_backend()`` to obtain the active array
|
|
11
|
+
module, so every matrix operation automatically runs on CPU or GPU.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
_DEVICE: Literal["cpu", "gpu"] = "cpu"
|
|
20
|
+
_CUPY_AVAILABLE: bool | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_cupy() -> bool:
|
|
24
|
+
global _CUPY_AVAILABLE
|
|
25
|
+
if _CUPY_AVAILABLE is None:
|
|
26
|
+
try:
|
|
27
|
+
import cupy # noqa: F401
|
|
28
|
+
_CUPY_AVAILABLE = True
|
|
29
|
+
except ImportError:
|
|
30
|
+
_CUPY_AVAILABLE = False
|
|
31
|
+
return _CUPY_AVAILABLE
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def set_device(device: Literal["cpu", "gpu"]) -> None:
|
|
35
|
+
"""Set the compute device globally."""
|
|
36
|
+
global _DEVICE
|
|
37
|
+
if device == "gpu" and not _check_cupy():
|
|
38
|
+
raise ImportError(
|
|
39
|
+
"CuPy is required for GPU support. Install it with: "
|
|
40
|
+
"pip install cupy-cuda12x (adjust for your CUDA version)"
|
|
41
|
+
)
|
|
42
|
+
_DEVICE = device
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_device() -> Literal["cpu", "gpu"]:
|
|
46
|
+
"""Return the current device."""
|
|
47
|
+
return _DEVICE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_backend():
|
|
51
|
+
"""Return the active array module (numpy or cupy)."""
|
|
52
|
+
if _DEVICE == "gpu":
|
|
53
|
+
import cupy
|
|
54
|
+
return cupy
|
|
55
|
+
return np
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def to_numpy(arr) -> np.ndarray:
|
|
59
|
+
"""Ensure *arr* is a host numpy array (move from GPU if needed)."""
|
|
60
|
+
if _DEVICE == "gpu":
|
|
61
|
+
import cupy
|
|
62
|
+
if isinstance(arr, cupy.ndarray):
|
|
63
|
+
return cupy.asnumpy(arr)
|
|
64
|
+
return np.asarray(arr)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def to_device(arr):
|
|
68
|
+
"""Move a numpy array to the current device."""
|
|
69
|
+
if _DEVICE == "gpu":
|
|
70
|
+
import cupy
|
|
71
|
+
return cupy.asarray(arr)
|
|
72
|
+
return np.asarray(arr)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def make_rng(seed: int | None = None):
|
|
76
|
+
"""Create a seeded RNG for the active backend.
|
|
77
|
+
|
|
78
|
+
Returns a ``numpy.random.Generator`` or ``cupy.random.Generator``
|
|
79
|
+
depending on the current device.
|
|
80
|
+
"""
|
|
81
|
+
if _DEVICE == "gpu":
|
|
82
|
+
import cupy
|
|
83
|
+
if seed is not None:
|
|
84
|
+
return cupy.random.default_rng(seed)
|
|
85
|
+
return cupy.random.default_rng()
|
|
86
|
+
if seed is not None:
|
|
87
|
+
return np.random.default_rng(seed)
|
|
88
|
+
return np.random.default_rng()
|
pyfector/cv.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cross-validation for selecting number of factors (IFE) or lambda (MC).
|
|
3
|
+
|
|
4
|
+
Key performance improvements over R fect:
|
|
5
|
+
- Parallel fold evaluation via joblib
|
|
6
|
+
- Warm-starting: previous fold's fit used as Y0 for next candidate
|
|
7
|
+
- Looser tolerance during CV (max(tol, 1e-3))
|
|
8
|
+
- Seeded RNG for reproducible fold splits
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from typing import Literal, NamedTuple
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from joblib import Parallel, delayed
|
|
18
|
+
|
|
19
|
+
from .backend import get_backend, to_numpy, to_device, make_rng
|
|
20
|
+
from .estimators import estimate_ife, estimate_mc
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CVResult(NamedTuple):
|
|
24
|
+
best_r: int | None # selected number of factors (IFE)
|
|
25
|
+
best_lambda: float | None # selected lambda (MC)
|
|
26
|
+
scores: dict # {candidate: {criterion: score}}
|
|
27
|
+
all_residuals: dict # {candidate: residuals array}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# CV sampling
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def _make_cv_folds(
|
|
35
|
+
II: np.ndarray, # (T, N) control indicator
|
|
36
|
+
D: np.ndarray, # (T, N) treatment indicator
|
|
37
|
+
k: int,
|
|
38
|
+
cv_prop: float,
|
|
39
|
+
cv_nobs: int,
|
|
40
|
+
cv_treat: bool,
|
|
41
|
+
cv_donut: int,
|
|
42
|
+
rng,
|
|
43
|
+
) -> list[dict]:
|
|
44
|
+
"""Generate k CV folds by masking control observations.
|
|
45
|
+
|
|
46
|
+
Returns list of dicts with keys 'mask' (cells to hide) and 'eval' (cells to score).
|
|
47
|
+
"""
|
|
48
|
+
xp = get_backend()
|
|
49
|
+
folds = []
|
|
50
|
+
T, N = II.shape
|
|
51
|
+
|
|
52
|
+
# Eligible cells: observed control cells
|
|
53
|
+
if cv_treat:
|
|
54
|
+
# Only mask pre-treatment cells of treated units
|
|
55
|
+
ever_treated = xp.any(D > 0, axis=0) # (N,)
|
|
56
|
+
eligible = II.copy()
|
|
57
|
+
eligible[:, ~ever_treated] = 0
|
|
58
|
+
else:
|
|
59
|
+
eligible = II.copy()
|
|
60
|
+
|
|
61
|
+
# Total control obs to mask per fold
|
|
62
|
+
total_eligible = int(xp.sum(eligible))
|
|
63
|
+
rm_count = max(1, int(total_eligible * cv_prop))
|
|
64
|
+
|
|
65
|
+
# Get indices of eligible cells
|
|
66
|
+
eligible_np = to_numpy(eligible)
|
|
67
|
+
elig_rows, elig_cols = np.where(eligible_np > 0)
|
|
68
|
+
n_eligible = len(elig_rows)
|
|
69
|
+
|
|
70
|
+
rng_np = np.random.default_rng(int(rng.integers(2**31)) if hasattr(rng, 'integers') else 42)
|
|
71
|
+
|
|
72
|
+
for fold_i in range(k):
|
|
73
|
+
# Randomly sample cells to mask
|
|
74
|
+
chosen = rng_np.choice(n_eligible, size=min(rm_count, n_eligible), replace=False)
|
|
75
|
+
mask_rows = elig_rows[chosen]
|
|
76
|
+
mask_cols = elig_cols[chosen]
|
|
77
|
+
|
|
78
|
+
# Build mask array
|
|
79
|
+
mask = np.zeros((T, N), dtype=bool)
|
|
80
|
+
mask[mask_rows, mask_cols] = True
|
|
81
|
+
|
|
82
|
+
# Eval indices (same as mask, optionally excluding donut around treatment)
|
|
83
|
+
eval_mask = mask.copy()
|
|
84
|
+
if cv_donut > 0:
|
|
85
|
+
for j in range(N):
|
|
86
|
+
treat_times = np.where(to_numpy(D[:, j]) > 0)[0]
|
|
87
|
+
if len(treat_times) > 0:
|
|
88
|
+
first_treat = treat_times[0]
|
|
89
|
+
donut_start = max(0, first_treat - cv_donut)
|
|
90
|
+
donut_end = min(T, first_treat + cv_donut + 1)
|
|
91
|
+
eval_mask[donut_start:donut_end, j] = False
|
|
92
|
+
|
|
93
|
+
folds.append({
|
|
94
|
+
"mask": to_device(mask),
|
|
95
|
+
"eval": to_device(eval_mask),
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
return folds
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# Scoring
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
def _score_residuals(
|
|
106
|
+
residuals: np.ndarray,
|
|
107
|
+
eval_mask: np.ndarray,
|
|
108
|
+
count_weights: np.ndarray | None = None,
|
|
109
|
+
) -> dict[str, float]:
|
|
110
|
+
"""Compute CV scoring criteria from residuals at eval cells."""
|
|
111
|
+
xp = get_backend()
|
|
112
|
+
|
|
113
|
+
e = residuals[eval_mask]
|
|
114
|
+
n = max(int(xp.sum(eval_mask)), 1)
|
|
115
|
+
|
|
116
|
+
mspe = float(xp.sum(e ** 2) / n)
|
|
117
|
+
|
|
118
|
+
# GMSPE: geometric mean of squared errors
|
|
119
|
+
e2 = e ** 2
|
|
120
|
+
e2_pos = e2[e2 > 0]
|
|
121
|
+
if len(e2_pos) > 0:
|
|
122
|
+
gmspe = float(xp.exp(xp.mean(xp.log(e2_pos))))
|
|
123
|
+
else:
|
|
124
|
+
gmspe = float("inf")
|
|
125
|
+
|
|
126
|
+
# MAD: median absolute deviation
|
|
127
|
+
mad = float(xp.median(xp.abs(e))) if len(e) > 0 else float("inf")
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"mspe": mspe,
|
|
131
|
+
"gmspe": gmspe,
|
|
132
|
+
"mad": mad,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# CV for IFE (selecting r)
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
def cv_ife(
|
|
141
|
+
Y: np.ndarray,
|
|
142
|
+
Y0: np.ndarray,
|
|
143
|
+
X: np.ndarray | None,
|
|
144
|
+
I: np.ndarray,
|
|
145
|
+
II: np.ndarray,
|
|
146
|
+
D: np.ndarray,
|
|
147
|
+
W: np.ndarray | None,
|
|
148
|
+
beta0: np.ndarray | None,
|
|
149
|
+
force: int,
|
|
150
|
+
r_range: tuple[int, int],
|
|
151
|
+
k: int = 10,
|
|
152
|
+
cv_prop: float = 0.1,
|
|
153
|
+
cv_nobs: int = 3,
|
|
154
|
+
cv_treat: bool = True,
|
|
155
|
+
cv_donut: int = 0,
|
|
156
|
+
criterion: str = "mspe",
|
|
157
|
+
tol: float = 1e-5,
|
|
158
|
+
max_iter: int = 1000,
|
|
159
|
+
n_jobs: int = 1,
|
|
160
|
+
seed: int | None = None,
|
|
161
|
+
) -> CVResult:
|
|
162
|
+
"""Cross-validate to select number of factors *r*.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
r_range : (r_min, r_max)
|
|
167
|
+
Range of candidate factor numbers.
|
|
168
|
+
k : int
|
|
169
|
+
Number of CV folds.
|
|
170
|
+
n_jobs : int
|
|
171
|
+
Number of parallel workers for fold evaluation.
|
|
172
|
+
"""
|
|
173
|
+
xp = get_backend()
|
|
174
|
+
rng = make_rng(seed)
|
|
175
|
+
|
|
176
|
+
# Looser tolerance for CV (like R fect)
|
|
177
|
+
cv_tol = max(tol, 1e-3)
|
|
178
|
+
|
|
179
|
+
# Generate folds
|
|
180
|
+
folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng)
|
|
181
|
+
|
|
182
|
+
r_candidates = list(range(r_range[0], r_range[1] + 1))
|
|
183
|
+
scores = {}
|
|
184
|
+
|
|
185
|
+
for r_cand in r_candidates:
|
|
186
|
+
fold_residuals = []
|
|
187
|
+
|
|
188
|
+
def _run_fold(fold):
|
|
189
|
+
II_cv = II.copy()
|
|
190
|
+
II_cv[fold["mask"]] = 0
|
|
191
|
+
# Re-initialize Y0 for masked cells with simple mean
|
|
192
|
+
Y0_cv = Y0.copy()
|
|
193
|
+
if xp.sum(II_cv) > 0:
|
|
194
|
+
fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10)
|
|
195
|
+
Y0_cv = xp.where(II_cv, Y0_cv, fill_val)
|
|
196
|
+
|
|
197
|
+
result = estimate_ife(
|
|
198
|
+
Y, Y0_cv, X, II_cv, W, beta0,
|
|
199
|
+
r=r_cand, force=force, tol=cv_tol, max_iter=max_iter,
|
|
200
|
+
)
|
|
201
|
+
# Score on eval cells
|
|
202
|
+
resid = Y - result.fit
|
|
203
|
+
return _score_residuals(resid, fold["eval"])
|
|
204
|
+
|
|
205
|
+
if n_jobs == 1:
|
|
206
|
+
fold_scores = [_run_fold(f) for f in folds]
|
|
207
|
+
else:
|
|
208
|
+
fold_scores = Parallel(n_jobs=n_jobs, prefer="threads")(
|
|
209
|
+
delayed(_run_fold)(f) for f in folds
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Average scores across folds
|
|
213
|
+
avg_scores = {}
|
|
214
|
+
for metric in fold_scores[0]:
|
|
215
|
+
avg_scores[metric] = sum(fs[metric] for fs in fold_scores) / len(fold_scores)
|
|
216
|
+
scores[r_cand] = avg_scores
|
|
217
|
+
|
|
218
|
+
# Select best r (with 1% improvement threshold)
|
|
219
|
+
best_r = _select_best(scores, r_candidates, criterion)
|
|
220
|
+
|
|
221
|
+
return CVResult(best_r=best_r, best_lambda=None, scores=scores, all_residuals={})
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
# CV for MC (selecting lambda)
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def cv_mc(
|
|
229
|
+
Y: np.ndarray,
|
|
230
|
+
Y0: np.ndarray,
|
|
231
|
+
X: np.ndarray | None,
|
|
232
|
+
I: np.ndarray,
|
|
233
|
+
II: np.ndarray,
|
|
234
|
+
D: np.ndarray,
|
|
235
|
+
W: np.ndarray | None,
|
|
236
|
+
beta0: np.ndarray | None,
|
|
237
|
+
force: int,
|
|
238
|
+
lambda_candidates: np.ndarray | None = None,
|
|
239
|
+
nlambda: int = 10,
|
|
240
|
+
k: int = 10,
|
|
241
|
+
cv_prop: float = 0.1,
|
|
242
|
+
cv_nobs: int = 3,
|
|
243
|
+
cv_treat: bool = True,
|
|
244
|
+
cv_donut: int = 0,
|
|
245
|
+
criterion: str = "mspe",
|
|
246
|
+
tol: float = 1e-5,
|
|
247
|
+
max_iter: int = 1000,
|
|
248
|
+
n_jobs: int = 1,
|
|
249
|
+
seed: int | None = None,
|
|
250
|
+
) -> CVResult:
|
|
251
|
+
"""Cross-validate to select lambda (nuclear norm penalty).
|
|
252
|
+
|
|
253
|
+
If ``lambda_candidates`` is None, generates a log-spaced grid from the
|
|
254
|
+
data's largest singular value down to 0.
|
|
255
|
+
"""
|
|
256
|
+
xp = get_backend()
|
|
257
|
+
rng = make_rng(seed)
|
|
258
|
+
cv_tol = max(tol, 1e-3)
|
|
259
|
+
|
|
260
|
+
# Generate lambda grid if not provided
|
|
261
|
+
if lambda_candidates is None:
|
|
262
|
+
lambda_candidates = _make_lambda_grid(Y, II, nlambda)
|
|
263
|
+
|
|
264
|
+
# Generate folds
|
|
265
|
+
folds = _make_cv_folds(II, D, k, cv_prop, cv_nobs, cv_treat, cv_donut, rng)
|
|
266
|
+
|
|
267
|
+
scores = {}
|
|
268
|
+
for lam in lambda_candidates:
|
|
269
|
+
lam_key = float(lam)
|
|
270
|
+
|
|
271
|
+
def _run_fold(fold):
|
|
272
|
+
II_cv = II.copy()
|
|
273
|
+
II_cv[fold["mask"]] = 0
|
|
274
|
+
Y0_cv = Y0.copy()
|
|
275
|
+
if xp.sum(II_cv) > 0:
|
|
276
|
+
fill_val = xp.sum(Y * II_cv) / (xp.sum(II_cv) + 1e-10)
|
|
277
|
+
Y0_cv = xp.where(II_cv, Y0_cv, fill_val)
|
|
278
|
+
|
|
279
|
+
result = estimate_mc(
|
|
280
|
+
Y, Y0_cv, X, II_cv, W, beta0,
|
|
281
|
+
lam=lam_key, force=force, tol=cv_tol, max_iter=max_iter,
|
|
282
|
+
)
|
|
283
|
+
resid = Y - result.fit
|
|
284
|
+
return _score_residuals(resid, fold["eval"])
|
|
285
|
+
|
|
286
|
+
if n_jobs == 1:
|
|
287
|
+
fold_scores = [_run_fold(f) for f in folds]
|
|
288
|
+
else:
|
|
289
|
+
fold_scores = Parallel(n_jobs=n_jobs, prefer="threads")(
|
|
290
|
+
delayed(_run_fold)(f) for f in folds
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
avg_scores = {}
|
|
294
|
+
for metric in fold_scores[0]:
|
|
295
|
+
avg_scores[metric] = sum(fs[metric] for fs in fold_scores) / len(fold_scores)
|
|
296
|
+
scores[lam_key] = avg_scores
|
|
297
|
+
|
|
298
|
+
best_lambda = _select_best(scores, [float(l) for l in lambda_candidates], criterion)
|
|
299
|
+
|
|
300
|
+
return CVResult(best_r=None, best_lambda=best_lambda, scores=scores, all_residuals={})
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _make_lambda_grid(Y, II, nlambda: int):
|
|
304
|
+
"""Generate log-spaced lambda grid from data's singular values."""
|
|
305
|
+
xp = get_backend()
|
|
306
|
+
T, N = Y.shape
|
|
307
|
+
Y_obs = Y * II
|
|
308
|
+
# Get largest singular value
|
|
309
|
+
_, s, _ = xp.linalg.svd(Y_obs / (T * N), full_matrices=False)
|
|
310
|
+
lam_max = float(s[0])
|
|
311
|
+
log_max = math.log10(max(lam_max, 1e-10))
|
|
312
|
+
step = 3.0 / max(nlambda - 2, 1)
|
|
313
|
+
grid = [10 ** (log_max - i * step) for i in range(nlambda - 1)]
|
|
314
|
+
grid.append(0.0)
|
|
315
|
+
return np.array(grid)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _select_best(scores: dict, candidates: list, criterion: str):
|
|
319
|
+
"""Select best candidate with 1% improvement threshold."""
|
|
320
|
+
vals = [(c, scores[c][criterion]) for c in candidates]
|
|
321
|
+
vals.sort(key=lambda x: x[1])
|
|
322
|
+
best = vals[0]
|
|
323
|
+
# Apply 1% threshold: prefer simpler model if improvement < 1%
|
|
324
|
+
for c, v in vals:
|
|
325
|
+
if v <= best[1] * 1.01:
|
|
326
|
+
return c
|
|
327
|
+
return best[0]
|
pyfector/diagnostics.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Diagnostic tests for pyfector results.
|
|
3
|
+
|
|
4
|
+
Implements:
|
|
5
|
+
- Pre-trend F-test (Wald-type joint significance)
|
|
6
|
+
- Equivalence F-test (against non-central F)
|
|
7
|
+
- TOST (two one-sided t-tests) for each pre-treatment period
|
|
8
|
+
- Placebo test
|
|
9
|
+
- Carryover test
|
|
10
|
+
- Leave-one-period-out (LOO) test
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class DiagnosticResult:
|
|
22
|
+
"""Container for diagnostic test results."""
|
|
23
|
+
# F-test for pre-trends
|
|
24
|
+
f_stat: float | None = None
|
|
25
|
+
f_pval: float | None = None
|
|
26
|
+
f_df1: int | None = None
|
|
27
|
+
f_df2: int | None = None
|
|
28
|
+
|
|
29
|
+
# Equivalence F-test
|
|
30
|
+
equiv_f_pval: float | None = None
|
|
31
|
+
equiv_threshold: float | None = None
|
|
32
|
+
|
|
33
|
+
# TOST per pre-period
|
|
34
|
+
tost_pvals: np.ndarray | None = None
|
|
35
|
+
tost_periods: np.ndarray | None = None
|
|
36
|
+
tost_threshold: float | None = None
|
|
37
|
+
|
|
38
|
+
# Placebo
|
|
39
|
+
placebo_att: float | None = None
|
|
40
|
+
placebo_pval: float | None = None
|
|
41
|
+
|
|
42
|
+
# Carryover
|
|
43
|
+
carryover_att: float | None = None
|
|
44
|
+
carryover_pval: float | None = None
|
|
45
|
+
|
|
46
|
+
# Leave-one-out
|
|
47
|
+
loo_atts: np.ndarray | None = None # ATT when each pre-period is dropped
|
|
48
|
+
loo_periods: np.ndarray | None = None
|
|
49
|
+
loo_max_change: float | None = None # max |ATT_full - ATT_loo|
|
|
50
|
+
|
|
51
|
+
def summary(self) -> str:
|
|
52
|
+
lines = ["Diagnostic Tests", "=" * 50]
|
|
53
|
+
if self.f_stat is not None:
|
|
54
|
+
lines.append(f"Pre-trend F-test:")
|
|
55
|
+
lines.append(f" F({self.f_df1},{self.f_df2}) = {self.f_stat:.4f}, p = {self.f_pval:.4f}")
|
|
56
|
+
if self.equiv_f_pval is not None:
|
|
57
|
+
lines.append(f"Equivalence F-test:")
|
|
58
|
+
lines.append(f" p = {self.equiv_f_pval:.4f} (threshold={self.equiv_threshold})")
|
|
59
|
+
if self.tost_pvals is not None:
|
|
60
|
+
lines.append("TOST per pre-period:")
|
|
61
|
+
for i, t in enumerate(self.tost_periods):
|
|
62
|
+
p = self.tost_pvals[i]
|
|
63
|
+
status = "PASS" if p < 0.05 else "fail"
|
|
64
|
+
lines.append(f" t={t:+.0f}: p={p:.4f} [{status}]")
|
|
65
|
+
if self.placebo_pval is not None:
|
|
66
|
+
lines.append(f"Placebo test:")
|
|
67
|
+
lines.append(f" ATT={self.placebo_att:.4f}, p={self.placebo_pval:.4f}")
|
|
68
|
+
if self.carryover_pval is not None:
|
|
69
|
+
lines.append(f"Carryover test:")
|
|
70
|
+
lines.append(f" ATT={self.carryover_att:.4f}, p={self.carryover_pval:.4f}")
|
|
71
|
+
if self.loo_max_change is not None:
|
|
72
|
+
lines.append(f"Leave-one-out:")
|
|
73
|
+
lines.append(f" Max ATT change = {self.loo_max_change:.6f}")
|
|
74
|
+
return "\n".join(lines)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_diagnostics(
|
|
78
|
+
result,
|
|
79
|
+
f_threshold: float = 0.5,
|
|
80
|
+
tost_threshold: float = 0.36,
|
|
81
|
+
placebo_period: tuple[int, int] | None = None,
|
|
82
|
+
carryover_period: tuple[int, int] | None = None,
|
|
83
|
+
loo: bool = False,
|
|
84
|
+
) -> DiagnosticResult:
|
|
85
|
+
"""Run diagnostic tests on a FectResult.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
result : FectResult
|
|
90
|
+
Must have inference results (``se=True``).
|
|
91
|
+
f_threshold : float
|
|
92
|
+
Non-centrality parameter for equivalence F-test.
|
|
93
|
+
tost_threshold : float
|
|
94
|
+
Equivalence bound for TOST.
|
|
95
|
+
placebo_period : (start, end), optional
|
|
96
|
+
Relative time window for placebo test.
|
|
97
|
+
"""
|
|
98
|
+
from scipy import stats
|
|
99
|
+
|
|
100
|
+
diag = DiagnosticResult()
|
|
101
|
+
|
|
102
|
+
if result.inference is None:
|
|
103
|
+
return diag
|
|
104
|
+
|
|
105
|
+
inf = result.inference
|
|
106
|
+
time_on = result.time_on
|
|
107
|
+
att_on = result.att_on
|
|
108
|
+
|
|
109
|
+
if time_on is None or att_on is None:
|
|
110
|
+
return diag
|
|
111
|
+
|
|
112
|
+
# Pre-treatment periods
|
|
113
|
+
pre_mask = time_on < 0
|
|
114
|
+
if not np.any(pre_mask):
|
|
115
|
+
return diag
|
|
116
|
+
|
|
117
|
+
pre_idx = np.where(pre_mask)[0]
|
|
118
|
+
k = len(pre_idx)
|
|
119
|
+
att_pre = att_on[pre_idx]
|
|
120
|
+
|
|
121
|
+
# F-test from bootstrap covariance
|
|
122
|
+
if inf.att_on_boot is not None and inf.att_on_boot.shape[1] > k:
|
|
123
|
+
boot_pre = inf.att_on_boot[pre_idx, :] # (k, nboots)
|
|
124
|
+
S = np.cov(boot_pre) # (k, k)
|
|
125
|
+
if k == 1:
|
|
126
|
+
S = S.reshape(1, 1)
|
|
127
|
+
|
|
128
|
+
n_boot = boot_pre.shape[1]
|
|
129
|
+
try:
|
|
130
|
+
cond = np.linalg.cond(S)
|
|
131
|
+
if cond > 1e12:
|
|
132
|
+
S_inv = np.linalg.pinv(S)
|
|
133
|
+
else:
|
|
134
|
+
S_inv = np.linalg.inv(S)
|
|
135
|
+
F_stat = float(att_pre @ S_inv @ att_pre)
|
|
136
|
+
scale = (n_boot - k) / ((n_boot - 1) * k)
|
|
137
|
+
F_stat_scaled = F_stat * scale
|
|
138
|
+
|
|
139
|
+
if np.isfinite(F_stat_scaled) and F_stat_scaled >= 0:
|
|
140
|
+
diag.f_stat = F_stat_scaled
|
|
141
|
+
diag.f_df1 = k
|
|
142
|
+
diag.f_df2 = n_boot - k
|
|
143
|
+
diag.f_pval = float(1 - stats.f.cdf(F_stat_scaled, k, n_boot - k))
|
|
144
|
+
|
|
145
|
+
# Equivalence F-test (non-central F)
|
|
146
|
+
ncp = n_boot * f_threshold
|
|
147
|
+
diag.equiv_f_pval = float(stats.ncf.cdf(F_stat_scaled, k, n_boot - k, ncp))
|
|
148
|
+
diag.equiv_threshold = f_threshold
|
|
149
|
+
except np.linalg.LinAlgError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# TOST
|
|
153
|
+
if inf.att_on_se is not None:
|
|
154
|
+
se_pre = inf.att_on_se[pre_idx]
|
|
155
|
+
n_boot = inf.att_on_boot.shape[1] if inf.att_on_boot is not None else 200
|
|
156
|
+
df = n_boot - 1
|
|
157
|
+
|
|
158
|
+
tost_pvals = np.full(k, np.nan)
|
|
159
|
+
for i in range(k):
|
|
160
|
+
if se_pre[i] > 0:
|
|
161
|
+
t_upper = (att_pre[i] - tost_threshold) / se_pre[i]
|
|
162
|
+
t_lower = (att_pre[i] + tost_threshold) / se_pre[i]
|
|
163
|
+
p_upper = float(stats.t.cdf(t_upper, df))
|
|
164
|
+
p_lower = float(1 - stats.t.cdf(t_lower, df))
|
|
165
|
+
tost_pvals[i] = max(p_upper, p_lower)
|
|
166
|
+
|
|
167
|
+
diag.tost_pvals = tost_pvals
|
|
168
|
+
diag.tost_periods = time_on[pre_idx]
|
|
169
|
+
diag.tost_threshold = tost_threshold
|
|
170
|
+
|
|
171
|
+
# Placebo test
|
|
172
|
+
if placebo_period is not None:
|
|
173
|
+
p_start, p_end = placebo_period
|
|
174
|
+
placebo_mask = (time_on >= p_start) & (time_on <= p_end) & pre_mask
|
|
175
|
+
if np.any(placebo_mask):
|
|
176
|
+
p_idx = np.where(placebo_mask)[0]
|
|
177
|
+
diag.placebo_att = float(np.mean(att_on[p_idx]))
|
|
178
|
+
if inf.att_on_boot is not None:
|
|
179
|
+
boot_placebo = np.mean(inf.att_on_boot[p_idx, :], axis=0)
|
|
180
|
+
diag.placebo_pval = min(
|
|
181
|
+
2 * np.mean(boot_placebo >= 0),
|
|
182
|
+
2 * np.mean(boot_placebo <= 0),
|
|
183
|
+
1.0,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Carryover test (for treatment reversals)
|
|
187
|
+
if carryover_period is not None and hasattr(result, "att_off") and result.att_off is not None:
|
|
188
|
+
c_start, c_end = carryover_period
|
|
189
|
+
time_off = getattr(result, "time_off", None)
|
|
190
|
+
if time_off is not None:
|
|
191
|
+
off_mask = (time_off >= c_start) & (time_off <= c_end)
|
|
192
|
+
if np.any(off_mask):
|
|
193
|
+
diag.carryover_att = float(np.mean(result.att_off[off_mask]))
|
|
194
|
+
|
|
195
|
+
# Leave-one-period-out test
|
|
196
|
+
if loo and att_on is not None and time_on is not None:
|
|
197
|
+
post_mask = time_on >= 0
|
|
198
|
+
post_idx = np.where(post_mask)[0]
|
|
199
|
+
if len(post_idx) > 1:
|
|
200
|
+
full_att = result.att_avg
|
|
201
|
+
loo_atts = []
|
|
202
|
+
loo_periods = []
|
|
203
|
+
for drop_i in post_idx:
|
|
204
|
+
remaining = np.delete(post_idx, np.where(post_idx == drop_i))
|
|
205
|
+
if len(remaining) > 0:
|
|
206
|
+
loo_att = float(np.mean(att_on[remaining]))
|
|
207
|
+
loo_atts.append(loo_att)
|
|
208
|
+
loo_periods.append(time_on[drop_i])
|
|
209
|
+
diag.loo_atts = np.array(loo_atts)
|
|
210
|
+
diag.loo_periods = np.array(loo_periods)
|
|
211
|
+
diag.loo_max_change = float(np.max(np.abs(diag.loo_atts - full_att)))
|
|
212
|
+
|
|
213
|
+
return diag
|