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 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]
@@ -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