pyfector.inference

Non-parametric inference: bootstrap, jackknife, and permutation.

This module builds confidence intervals and standard errors around the point estimates returned by pyfector.fect. All three routines resample units (not cells), so they naturally respect within-unit dependence:

  • bootstrap() — draws nboots bootstrap samples of unit indices with replacement, re-estimates on each sample, and returns pivotal confidence intervals, bootstrap SEs, and two-sided p-values. Treated, control, and reversal units are resampled separately so the treated share stays fixed across replicates (stratified bootstrap); when cluster is supplied we fall back to a cluster bootstrap.
  • jackknife() — delete-one-unit jackknife with the standard (N-1)/N variance correction and t-distribution CIs.
  • permutation_test() — shuffles treatment assignment and compares the observed ATT to the null distribution of permuted ATTs.
Implementation notes
  • Replications are parallelised via joblib's loky (process) backend with inner_max_num_threads=1. Processes avoid the GIL contention that throttles the weighted EM path, whose per-replicate work is dominated by GIL-holding elementwise numpy operations rather than GIL-releasing BLAS calls. Pinning each worker to a single BLAS thread keeps n_jobs workers from oversubscribing the cores that a multithreaded BLAS would otherwise claim inside every replicate.
  • Every replicate receives a deterministic seed derived from the master seed so results are reproducible.
  • The dynamic-ATT grid is pinned to the point-estimate's time_on so replicate outputs can be stacked into a (n_periods, nboots) matrix even when a resampled draw happens to miss a relative-time value.
  1"""
  2Non-parametric inference: bootstrap, jackknife, and permutation.
  3
  4This module builds confidence intervals and standard errors around the
  5point estimates returned by :func:`pyfector.fect`.  All three routines
  6resample *units* (not cells), so they naturally respect within-unit
  7dependence:
  8
  9* :func:`bootstrap` — draws ``nboots`` bootstrap samples of unit indices
 10  with replacement, re-estimates on each sample, and returns pivotal
 11  confidence intervals, bootstrap SEs, and two-sided p-values.  Treated,
 12  control, and reversal units are resampled separately so the treated
 13  share stays fixed across replicates (stratified bootstrap); when
 14  ``cluster`` is supplied we fall back to a cluster bootstrap.
 15* :func:`jackknife` — delete-one-unit jackknife with the standard
 16  ``(N-1)/N`` variance correction and t-distribution CIs.
 17* :func:`permutation_test` — shuffles treatment assignment and compares
 18  the observed ATT to the null distribution of permuted ATTs.
 19
 20Implementation notes
 21--------------------
 22* Replications are parallelised via joblib's ``loky`` (process) backend
 23  with ``inner_max_num_threads=1``.  Processes avoid the GIL contention
 24  that throttles the weighted EM path, whose per-replicate work is
 25  dominated by GIL-holding elementwise numpy operations rather than
 26  GIL-releasing BLAS calls.  Pinning each worker to a single BLAS thread
 27  keeps ``n_jobs`` workers from oversubscribing the cores that a
 28  multithreaded BLAS would otherwise claim inside every replicate.
 29* Every replicate receives a deterministic seed derived from the
 30  master ``seed`` so results are reproducible.
 31* The dynamic-ATT grid is pinned to the point-estimate's ``time_on``
 32  so replicate outputs can be stacked into a ``(n_periods, nboots)``
 33  matrix even when a resampled draw happens to miss a relative-time
 34  value.
 35"""
 36
 37from __future__ import annotations
 38
 39from dataclasses import dataclass, field
 40from typing import Literal
 41import warnings
 42
 43import numpy as np
 44from joblib import Parallel, delayed, parallel_config
 45
 46from .backend import get_backend, to_numpy, to_device, make_rng
 47
 48
 49@dataclass
 50class InferenceResult:
 51    """Inference results and distributions.
 52
 53    For bootstrap inference, ``n_boot`` and ``n_boot_failed`` count valid
 54    and failed replications. These counts are zero for jackknife results.
 55    """
 56    att_avg: float                          # point estimate of overall ATT
 57    """Overall ATT point estimate."""
 58    att_avg_se: float                       # standard error
 59    """Standard error of the overall ATT."""
 60    att_avg_ci: tuple[float, float]         # confidence interval
 61    """Confidence interval for the overall ATT."""
 62    att_avg_pval: float                     # p-value
 63    """Two-sided p-value for zero overall ATT."""
 64
 65    att_on: np.ndarray                      # (n_periods,) dynamic ATT
 66    """Dynamic ATT point estimates on time_on."""
 67    att_on_se: np.ndarray                   # (n_periods,) SE
 68    """Standard errors of dynamic ATT estimates."""
 69    att_on_ci_lower: np.ndarray             # (n_periods,)
 70    """Lower confidence limits for dynamic ATT."""
 71    att_on_ci_upper: np.ndarray             # (n_periods,)
 72    """Upper confidence limits for dynamic ATT."""
 73    att_on_pval: np.ndarray                 # (n_periods,)
 74    """Two-sided p-values for zero dynamic ATT."""
 75    time_on: np.ndarray                     # (n_periods,) relative time indices
 76    """Relative event-time grid."""
 77
 78    # Bootstrap distributions (for advanced use)
 79    att_avg_boot: np.ndarray | None = None  # (nboots,)
 80    """Overall ATT distribution across valid resampled fits."""
 81    att_on_boot: np.ndarray | None = None   # (n_periods, nboots)
 82    """Dynamic ATT distribution, shaped (n_periods, n_valid_replications)."""
 83    n_boot: int = 0
 84    """Number of valid bootstrap draws; zero for jackknife."""
 85    n_boot_failed: int = 0
 86    """Number of failed bootstrap draws; zero for jackknife."""
 87
 88
 89def _make_bootstrap_sampler(unit_type, cluster=None):
 90    """Prepare whole-unit or whole-cluster draws once per bootstrap."""
 91    unit_type = np.asarray(unit_type)
 92    if cluster is not None:
 93        cluster = np.asarray(cluster)
 94        if cluster.ndim != 1 or len(cluster) != len(unit_type):
 95            raise ValueError("cluster must have one label per unit")
 96        if any(label is None or label != label for label in cluster):
 97            raise ValueError("cluster labels must not be missing")
 98        labels, inverse, counts = np.unique(cluster, return_inverse=True, return_counts=True)
 99        if len(labels) < 2:
100            raise ValueError("cluster bootstrap requires at least two clusters")
101        order = np.argsort(inverse, kind="stable")
102        groups = np.split(order, np.cumsum(counts)[:-1])
103
104        def sample(rng):
105            selected = rng.integers(len(groups), size=len(groups))
106            return np.concatenate([groups[i] for i in selected])
107    else:
108        groups = [np.flatnonzero(unit_type == kind) for kind in (2, 1, 3)]
109
110        def sample(rng):
111            return np.concatenate([
112                rng.choice(group, size=len(group), replace=True) if len(group) else np.array([], dtype=int)
113                for group in groups
114            ])
115    return sample
116
117
118def _check_bootstrap_replicates(n_valid, n_requested, label="bootstrap"):
119    """Require an estimable SE and report discarded replications."""
120    n_failed = n_requested - n_valid
121    if n_valid == 0:
122        raise RuntimeError(f"All {label} replications failed ({n_requested} requested)")
123    if n_valid < 2:
124        raise RuntimeError(f"Too few valid {label} replications: {n_valid} of {n_requested}")
125    if n_failed:
126        warnings.warn(
127            f"{n_failed} of {n_requested} {label} replications failed; using {n_valid} valid replications.",
128            RuntimeWarning, stacklevel=3,
129        )
130    return n_failed
131
132
133def bootstrap(
134    estimate_fn,
135    Y: np.ndarray,
136    D: np.ndarray,
137    I: np.ndarray,
138    T_on: np.ndarray,
139    unit_type: np.ndarray,
140    nboots: int = 200,
141    alpha: float = 0.05,
142    n_jobs: int = 1,
143    seed: int | None = None,
144    cluster: np.ndarray | None = None,
145    point_estimate=None,
146) -> InferenceResult:
147    """Non-parametric cluster bootstrap.
148
149    Parameters
150    ----------
151    estimate_fn : callable
152        Function that takes (unit_indices,) and returns:
153        - eff: (T, N_sub) treatment effects
154        - D_sub: (T, N_sub) treatment indicators
155        - T_on_sub: (T, N_sub) relative time
156        - I_sub: (T, N_sub) observation indicators
157    unit_type : (N,) array
158        1=control, 2=treated, 3=reversal
159    cluster : (N,) array, optional
160        Cluster assignments. If None, each unit is its own cluster.
161    point_estimate : tuple, optional
162        Precomputed full-sample ``(eff, D, T_on, I)`` result. If supplied,
163        bootstrap reuses it as the original point estimate and only calls
164        ``estimate_fn`` for resampled replications.
165    """
166    rng = np.random.default_rng(seed)
167    if isinstance(nboots, (bool, np.bool_)) or not isinstance(nboots, (int, np.integer)) or nboots < 2:
168        raise ValueError("nboots must be an integer of at least 2")
169    N = Y.shape[1]
170    sample_units = _make_bootstrap_sampler(unit_type, cluster)
171
172    # Point estimate. fect() already has this before entering inference, so
173    # callers may pass it to avoid re-estimating the unchanged full sample.
174    point_result = point_estimate if point_estimate is not None else estimate_fn(np.arange(N))
175    eff_point, D_point, T_on_point, I_point = _unpack_estimate(
176        point_result, I
177    )
178    att_avg_point, att_on_point, time_on = _compute_att(eff_point, D_point, T_on_point, I_point)
179
180    n_periods = len(time_on)
181
182    def _one_boot(boot_seed):
183        boot_rng = np.random.default_rng(boot_seed)
184
185        unit_idx = sample_units(boot_rng)
186
187        try:
188            eff_b, D_b, T_on_b, I_b = _unpack_estimate(
189                estimate_fn(unit_idx), I[:, unit_idx]
190            )
191            att_avg_b, att_on_b, _ = _compute_att(eff_b, D_b, T_on_b, I_b, time_on)
192            return att_avg_b, att_on_b
193        except Exception:
194            return None, None
195
196    # Generate deterministic seeds for each replicate
197    boot_seeds = rng.integers(0, 2**31, size=nboots)
198
199    if n_jobs == 1:
200        results = [_one_boot(s) for s in boot_seeds]
201    else:
202        with parallel_config(backend="loky", inner_max_num_threads=1):
203            results = Parallel(n_jobs=n_jobs)(
204                delayed(_one_boot)(s) for s in boot_seeds
205            )
206
207    # Filter failed boots
208    valid = [(avg, on) for avg, on in results if avg is not None and np.isfinite(avg)]
209    n_failed = _check_bootstrap_replicates(len(valid), nboots)
210
211    att_avg_boot = np.array([v[0] for v in valid])
212    att_on_boot = np.column_stack([v[1] for v in valid])  # (n_periods, nboots_valid)
213
214    # Pivotal confidence intervals: 2*theta - quantile(1-alpha/2) to 2*theta - quantile(alpha/2)
215    att_avg_se = float(np.std(att_avg_boot, ddof=1))
216    att_avg_ci = (
217        2 * att_avg_point - float(np.quantile(att_avg_boot, 1 - alpha / 2)),
218        2 * att_avg_point - float(np.quantile(att_avg_boot, alpha / 2)),
219    )
220    att_avg_pval = min(
221        2 * np.mean(att_avg_boot >= 0),
222        2 * np.mean(att_avg_boot <= 0),
223        1.0,
224    )
225
226    att_on_se = np.nanstd(att_on_boot, axis=1, ddof=1)
227    att_on_ci_lower = 2 * att_on_point - np.nanquantile(att_on_boot, 1 - alpha / 2, axis=1)
228    att_on_ci_upper = 2 * att_on_point - np.nanquantile(att_on_boot, alpha / 2, axis=1)
229    valid_on = np.isfinite(att_on_boot)
230    valid_counts = valid_on.sum(axis=1)
231    with np.errstate(invalid="ignore", divide="ignore"):
232        boot_ge_0 = np.sum((att_on_boot >= 0) & valid_on, axis=1) / valid_counts
233        boot_le_0 = np.sum((att_on_boot <= 0) & valid_on, axis=1) / valid_counts
234    att_on_pval = np.minimum(2 * boot_ge_0, np.minimum(2 * boot_le_0, 1.0))
235    att_on_pval[valid_counts == 0] = np.nan
236
237    return InferenceResult(
238        att_avg=att_avg_point,
239        att_avg_se=att_avg_se,
240        att_avg_ci=att_avg_ci,
241        att_avg_pval=att_avg_pval,
242        att_on=att_on_point,
243        att_on_se=att_on_se,
244        att_on_ci_lower=att_on_ci_lower,
245        att_on_ci_upper=att_on_ci_upper,
246        att_on_pval=att_on_pval,
247        time_on=time_on,
248        att_avg_boot=att_avg_boot,
249        att_on_boot=att_on_boot,
250        n_boot=len(valid),
251        n_boot_failed=n_failed,
252    )
253
254
255def jackknife(
256    estimate_fn,
257    Y: np.ndarray,
258    D: np.ndarray,
259    I: np.ndarray,
260    T_on: np.ndarray,
261    unit_type: np.ndarray,
262    alpha: float = 0.05,
263    n_jobs: int = 1,
264) -> InferenceResult:
265    """Delete-one-unit jackknife inference.
266
267    Drops one unit at a time, re-estimates, computes jackknife SE
268    with the standard (N-1)/N correction.
269    """
270    from scipy import stats
271
272    N = Y.shape[1]
273
274    # Point estimate
275    eff_point, D_point, T_on_point, I_point = _unpack_estimate(
276        estimate_fn(np.arange(N)), I
277    )
278    att_avg_point, att_on_point, time_on = _compute_att(eff_point, D_point, T_on_point, I_point)
279    n_periods = len(time_on)
280
281    def _one_jack(j):
282        idx = np.concatenate([np.arange(j), np.arange(j + 1, N)])
283        try:
284            eff_j, D_j, T_on_j, I_j = _unpack_estimate(
285                estimate_fn(idx), I[:, idx]
286            )
287            att_avg_j, att_on_j, _ = _compute_att(eff_j, D_j, T_on_j, I_j, time_on)
288            return att_avg_j, att_on_j
289        except Exception:
290            return None, None
291
292    if n_jobs == 1:
293        results = [_one_jack(j) for j in range(N)]
294    else:
295        with parallel_config(backend="loky", inner_max_num_threads=1):
296            results = Parallel(n_jobs=n_jobs)(
297                delayed(_one_jack)(j) for j in range(N)
298            )
299
300    valid = [(avg, on) for avg, on in results if avg is not None]
301    n_valid = len(valid)
302    if n_valid != N:
303        raise RuntimeError(
304            f"{N - n_valid} of {N} jackknife deletions failed; "
305            "delete-one-unit inference requires every deletion."
306        )
307    if n_valid < 2:
308        raise RuntimeError("Too few valid jackknife replications")
309
310    att_avg_jack = np.array([v[0] for v in valid])
311    att_on_jack = np.column_stack([v[1] for v in valid])
312
313    # Jackknife variance: (N-1)/N * sum((theta_j - theta_bar)^2)
314    att_avg_se = float(np.sqrt((n_valid - 1) / n_valid * np.sum((att_avg_jack - np.mean(att_avg_jack)) ** 2)))
315    att_on_se = np.sqrt(
316        (n_valid - 1) / n_valid * np.sum((att_on_jack - att_on_jack.mean(axis=1, keepdims=True)) ** 2, axis=1)
317    )
318
319    # t-distribution CI
320    t_crit = stats.t.ppf(1 - alpha / 2, df=n_valid - 1)
321    att_avg_ci = (att_avg_point - t_crit * att_avg_se, att_avg_point + t_crit * att_avg_se)
322    att_on_ci_lower = att_on_point - t_crit * att_on_se
323    att_on_ci_upper = att_on_point + t_crit * att_on_se
324
325    # p-values from t-distribution
326    t_avg = att_avg_point / max(att_avg_se, 1e-10)
327    att_avg_pval = float(2 * stats.t.sf(abs(t_avg), df=n_valid - 1))
328
329    t_on = att_on_point / np.maximum(att_on_se, 1e-10)
330    att_on_pval = 2 * stats.t.sf(np.abs(t_on), df=n_valid - 1)
331
332    return InferenceResult(
333        att_avg=att_avg_point,
334        att_avg_se=att_avg_se,
335        att_avg_ci=att_avg_ci,
336        att_avg_pval=att_avg_pval,
337        att_on=att_on_point,
338        att_on_se=att_on_se,
339        att_on_ci_lower=att_on_ci_lower,
340        att_on_ci_upper=att_on_ci_upper,
341        att_on_pval=att_on_pval,
342        time_on=time_on,
343        att_avg_boot=att_avg_jack,
344        att_on_boot=att_on_jack,
345    )
346
347
348def permutation_test(
349    estimate_fn,
350    att_avg_observed: float,
351    N: int,
352    T: int,
353    n_perms: int = 200,
354    block_size: int = 1,
355    seed: int | None = None,
356    n_jobs: int = 1,
357) -> tuple[float, np.ndarray]:
358    """Permutation test: shuffle treatment assignment, compare ATTs.
359
360    Returns (p_value, permuted_atts).
361    """
362    rng = np.random.default_rng(seed)
363    perm_seeds = rng.integers(0, 2**31, size=n_perms)
364
365    def _one_perm(perm_seed):
366        try:
367            att_perm = estimate_fn(perm_seed)
368            return att_perm
369        except Exception:
370            return None
371
372    if n_jobs == 1:
373        results = [_one_perm(s) for s in perm_seeds]
374    else:
375        with parallel_config(backend="loky", inner_max_num_threads=1):
376            results = Parallel(n_jobs=n_jobs)(
377                delayed(_one_perm)(s) for s in perm_seeds
378            )
379
380    valid = [r for r in results if r is not None]
381    if len(valid) == 0:
382        return 1.0, np.array([])
383
384    permuted_atts = np.array(valid)
385    p_value = float(np.mean(np.abs(permuted_atts) >= abs(att_avg_observed)))
386    return p_value, permuted_atts
387
388
389# ---------------------------------------------------------------------------
390# ATT computation helpers
391# ---------------------------------------------------------------------------
392
393def _unpack_estimate(result, fallback_I: np.ndarray):
394    """Accept old 3-tuple estimate closures and new 4-tuple closures."""
395    if len(result) == 3:
396        eff, D, T_on = result
397        return eff, D, T_on, fallback_I
398    if len(result) == 4:
399        return result
400    raise ValueError("estimate_fn must return (eff, D, T_on) or (eff, D, T_on, I)")
401
402
403def _compute_att(
404    eff: np.ndarray,     # (T, N) effects
405    D: np.ndarray,       # (T, N) treatment indicator
406    T_on: np.ndarray,    # (T, N) relative time
407    I: np.ndarray | None = None,  # (T, N) observation indicator
408    time_on: np.ndarray | None = None,
409) -> tuple[float, np.ndarray, np.ndarray]:
410    """Compute overall and dynamic ATT from the effect matrix.
411
412    Missing raw outcomes are excluded by ``I``.  This keeps bootstrap and
413    jackknife replicates aligned with point estimation: a model-imputed
414    counterfactual can exist at a missing treated cell, but that cell has
415    no observed treated outcome and therefore cannot enter the ATT.
416
417    Uses ``np.bincount`` for the event-time grouping so the cost is linear
418    in the number of observed cells.  When ``time_on`` is supplied the
419    caller's grid is preserved exactly so bootstrap replicates can be
420    stacked into a ``(n_periods, nboots)`` matrix even if some replicates
421    happen to not observe every event time.
422    """
423    if I is not None and np.shape(I) != np.shape(D) and time_on is None:
424        time_on = np.asarray(I)
425        I = None
426
427    if I is None:
428        observed_mask = np.ones_like(D, dtype=bool)
429    else:
430        observed_mask = I > 0
431
432    treated_mask = (D > 0) & observed_mask
433    n_treated = int(np.sum(treated_mask))
434    if n_treated == 0:
435        raise ValueError("No observed treated cells remain for ATT estimation")
436    att_avg = float(np.sum(eff[treated_mask]) / n_treated)
437
438    ever_treated = np.any(D > 0, axis=0)                            # (N,)
439    all_mask = observed_mask & np.broadcast_to(ever_treated[np.newaxis, :], T_on.shape)
440    if not np.all(np.isfinite(eff[all_mask])):
441        raise ValueError("Non-finite effects in observed ever-treated cells")
442    T_on_flat = T_on[all_mask]
443    eff_flat = eff[all_mask]
444    valid = ~np.isnan(T_on_flat)
445    T_on_flat = T_on_flat[valid]
446    eff_flat = eff_flat[valid]
447
448    if T_on_flat.size == 0:
449        if time_on is None:
450            return att_avg, np.array([]), np.array([])
451        return att_avg, np.full(len(time_on), np.nan), time_on
452
453    T_on_int = T_on_flat.astype(np.int64)
454
455    if time_on is None:
456        offset = int(T_on_int.min())
457        minlength = int(T_on_int.max() - offset + 1)
458        idx = T_on_int - offset
459        sums = np.bincount(idx, weights=eff_flat, minlength=minlength)
460        counts = np.bincount(idx, minlength=minlength)
461        keep = counts > 0
462        time_on = (offset + np.arange(minlength))[keep].astype(np.float64)
463        att_on = sums[keep] / counts[keep]
464        return att_avg, att_on, time_on
465
466    # Caller supplied a grid: produce att_on aligned to time_on so that
467    # bootstrap replicates can be stacked with a fixed shape.
468    time_on_int = np.asarray(time_on, dtype=np.int64)
469    grid_offset = int(time_on_int.min())
470    grid_len = int(time_on_int.max() - grid_offset + 1)
471    # Bin flat values that fall inside the grid range.
472    in_range = (T_on_int >= grid_offset) & (T_on_int < grid_offset + grid_len)
473    T_clip = T_on_int[in_range] - grid_offset
474    e_clip = eff_flat[in_range]
475    sums = np.bincount(T_clip, weights=e_clip, minlength=grid_len)
476    counts = np.bincount(T_clip, minlength=grid_len)
477    gather = time_on_int - grid_offset                              # (n_periods,)
478    att_on = np.full(len(time_on), np.nan)
479    counts_at_grid = counts[gather]
480    valid_grid = counts_at_grid > 0
481    att_on[valid_grid] = sums[gather[valid_grid]] / counts_at_grid[valid_grid]
482    return att_avg, att_on, time_on
@dataclass
class InferenceResult:
50@dataclass
51class InferenceResult:
52    """Inference results and distributions.
53
54    For bootstrap inference, ``n_boot`` and ``n_boot_failed`` count valid
55    and failed replications. These counts are zero for jackknife results.
56    """
57    att_avg: float                          # point estimate of overall ATT
58    """Overall ATT point estimate."""
59    att_avg_se: float                       # standard error
60    """Standard error of the overall ATT."""
61    att_avg_ci: tuple[float, float]         # confidence interval
62    """Confidence interval for the overall ATT."""
63    att_avg_pval: float                     # p-value
64    """Two-sided p-value for zero overall ATT."""
65
66    att_on: np.ndarray                      # (n_periods,) dynamic ATT
67    """Dynamic ATT point estimates on time_on."""
68    att_on_se: np.ndarray                   # (n_periods,) SE
69    """Standard errors of dynamic ATT estimates."""
70    att_on_ci_lower: np.ndarray             # (n_periods,)
71    """Lower confidence limits for dynamic ATT."""
72    att_on_ci_upper: np.ndarray             # (n_periods,)
73    """Upper confidence limits for dynamic ATT."""
74    att_on_pval: np.ndarray                 # (n_periods,)
75    """Two-sided p-values for zero dynamic ATT."""
76    time_on: np.ndarray                     # (n_periods,) relative time indices
77    """Relative event-time grid."""
78
79    # Bootstrap distributions (for advanced use)
80    att_avg_boot: np.ndarray | None = None  # (nboots,)
81    """Overall ATT distribution across valid resampled fits."""
82    att_on_boot: np.ndarray | None = None   # (n_periods, nboots)
83    """Dynamic ATT distribution, shaped (n_periods, n_valid_replications)."""
84    n_boot: int = 0
85    """Number of valid bootstrap draws; zero for jackknife."""
86    n_boot_failed: int = 0
87    """Number of failed bootstrap draws; zero for jackknife."""

Inference results and distributions.

For bootstrap inference, n_boot and n_boot_failed count valid and failed replications. These counts are zero for jackknife results.

InferenceResult( att_avg: float, att_avg_se: float, att_avg_ci: tuple[float, float], att_avg_pval: float, att_on: numpy.ndarray, att_on_se: numpy.ndarray, att_on_ci_lower: numpy.ndarray, att_on_ci_upper: numpy.ndarray, att_on_pval: numpy.ndarray, time_on: numpy.ndarray, att_avg_boot: numpy.ndarray | None = None, att_on_boot: numpy.ndarray | None = None, n_boot: int = 0, n_boot_failed: int = 0)
att_avg: float

Overall ATT point estimate.

att_avg_se: float

Standard error of the overall ATT.

att_avg_ci: tuple[float, float]

Confidence interval for the overall ATT.

att_avg_pval: float

Two-sided p-value for zero overall ATT.

att_on: numpy.ndarray

Dynamic ATT point estimates on time_on.

att_on_se: numpy.ndarray

Standard errors of dynamic ATT estimates.

att_on_ci_lower: numpy.ndarray

Lower confidence limits for dynamic ATT.

att_on_ci_upper: numpy.ndarray

Upper confidence limits for dynamic ATT.

att_on_pval: numpy.ndarray

Two-sided p-values for zero dynamic ATT.

time_on: numpy.ndarray

Relative event-time grid.

att_avg_boot: numpy.ndarray | None = None

Overall ATT distribution across valid resampled fits.

att_on_boot: numpy.ndarray | None = None

Dynamic ATT distribution, shaped (n_periods, n_valid_replications).

n_boot: int = 0

Number of valid bootstrap draws; zero for jackknife.

n_boot_failed: int = 0

Number of failed bootstrap draws; zero for jackknife.

def bootstrap( estimate_fn, Y: numpy.ndarray, D: numpy.ndarray, I: numpy.ndarray, T_on: numpy.ndarray, unit_type: numpy.ndarray, nboots: int = 200, alpha: float = 0.05, n_jobs: int = 1, seed: int | None = None, cluster: numpy.ndarray | None = None, point_estimate=None) -> InferenceResult:
134def bootstrap(
135    estimate_fn,
136    Y: np.ndarray,
137    D: np.ndarray,
138    I: np.ndarray,
139    T_on: np.ndarray,
140    unit_type: np.ndarray,
141    nboots: int = 200,
142    alpha: float = 0.05,
143    n_jobs: int = 1,
144    seed: int | None = None,
145    cluster: np.ndarray | None = None,
146    point_estimate=None,
147) -> InferenceResult:
148    """Non-parametric cluster bootstrap.
149
150    Parameters
151    ----------
152    estimate_fn : callable
153        Function that takes (unit_indices,) and returns:
154        - eff: (T, N_sub) treatment effects
155        - D_sub: (T, N_sub) treatment indicators
156        - T_on_sub: (T, N_sub) relative time
157        - I_sub: (T, N_sub) observation indicators
158    unit_type : (N,) array
159        1=control, 2=treated, 3=reversal
160    cluster : (N,) array, optional
161        Cluster assignments. If None, each unit is its own cluster.
162    point_estimate : tuple, optional
163        Precomputed full-sample ``(eff, D, T_on, I)`` result. If supplied,
164        bootstrap reuses it as the original point estimate and only calls
165        ``estimate_fn`` for resampled replications.
166    """
167    rng = np.random.default_rng(seed)
168    if isinstance(nboots, (bool, np.bool_)) or not isinstance(nboots, (int, np.integer)) or nboots < 2:
169        raise ValueError("nboots must be an integer of at least 2")
170    N = Y.shape[1]
171    sample_units = _make_bootstrap_sampler(unit_type, cluster)
172
173    # Point estimate. fect() already has this before entering inference, so
174    # callers may pass it to avoid re-estimating the unchanged full sample.
175    point_result = point_estimate if point_estimate is not None else estimate_fn(np.arange(N))
176    eff_point, D_point, T_on_point, I_point = _unpack_estimate(
177        point_result, I
178    )
179    att_avg_point, att_on_point, time_on = _compute_att(eff_point, D_point, T_on_point, I_point)
180
181    n_periods = len(time_on)
182
183    def _one_boot(boot_seed):
184        boot_rng = np.random.default_rng(boot_seed)
185
186        unit_idx = sample_units(boot_rng)
187
188        try:
189            eff_b, D_b, T_on_b, I_b = _unpack_estimate(
190                estimate_fn(unit_idx), I[:, unit_idx]
191            )
192            att_avg_b, att_on_b, _ = _compute_att(eff_b, D_b, T_on_b, I_b, time_on)
193            return att_avg_b, att_on_b
194        except Exception:
195            return None, None
196
197    # Generate deterministic seeds for each replicate
198    boot_seeds = rng.integers(0, 2**31, size=nboots)
199
200    if n_jobs == 1:
201        results = [_one_boot(s) for s in boot_seeds]
202    else:
203        with parallel_config(backend="loky", inner_max_num_threads=1):
204            results = Parallel(n_jobs=n_jobs)(
205                delayed(_one_boot)(s) for s in boot_seeds
206            )
207
208    # Filter failed boots
209    valid = [(avg, on) for avg, on in results if avg is not None and np.isfinite(avg)]
210    n_failed = _check_bootstrap_replicates(len(valid), nboots)
211
212    att_avg_boot = np.array([v[0] for v in valid])
213    att_on_boot = np.column_stack([v[1] for v in valid])  # (n_periods, nboots_valid)
214
215    # Pivotal confidence intervals: 2*theta - quantile(1-alpha/2) to 2*theta - quantile(alpha/2)
216    att_avg_se = float(np.std(att_avg_boot, ddof=1))
217    att_avg_ci = (
218        2 * att_avg_point - float(np.quantile(att_avg_boot, 1 - alpha / 2)),
219        2 * att_avg_point - float(np.quantile(att_avg_boot, alpha / 2)),
220    )
221    att_avg_pval = min(
222        2 * np.mean(att_avg_boot >= 0),
223        2 * np.mean(att_avg_boot <= 0),
224        1.0,
225    )
226
227    att_on_se = np.nanstd(att_on_boot, axis=1, ddof=1)
228    att_on_ci_lower = 2 * att_on_point - np.nanquantile(att_on_boot, 1 - alpha / 2, axis=1)
229    att_on_ci_upper = 2 * att_on_point - np.nanquantile(att_on_boot, alpha / 2, axis=1)
230    valid_on = np.isfinite(att_on_boot)
231    valid_counts = valid_on.sum(axis=1)
232    with np.errstate(invalid="ignore", divide="ignore"):
233        boot_ge_0 = np.sum((att_on_boot >= 0) & valid_on, axis=1) / valid_counts
234        boot_le_0 = np.sum((att_on_boot <= 0) & valid_on, axis=1) / valid_counts
235    att_on_pval = np.minimum(2 * boot_ge_0, np.minimum(2 * boot_le_0, 1.0))
236    att_on_pval[valid_counts == 0] = np.nan
237
238    return InferenceResult(
239        att_avg=att_avg_point,
240        att_avg_se=att_avg_se,
241        att_avg_ci=att_avg_ci,
242        att_avg_pval=att_avg_pval,
243        att_on=att_on_point,
244        att_on_se=att_on_se,
245        att_on_ci_lower=att_on_ci_lower,
246        att_on_ci_upper=att_on_ci_upper,
247        att_on_pval=att_on_pval,
248        time_on=time_on,
249        att_avg_boot=att_avg_boot,
250        att_on_boot=att_on_boot,
251        n_boot=len(valid),
252        n_boot_failed=n_failed,
253    )

Non-parametric cluster bootstrap.

Parameters
  • estimate_fn (callable): Function that takes (unit_indices,) and returns:
    • eff: (T, N_sub) treatment effects
    • D_sub: (T, N_sub) treatment indicators
    • T_on_sub: (T, N_sub) relative time
    • I_sub: (T, N_sub) observation indicators
  • unit_type ((N,) array): 1=control, 2=treated, 3=reversal
  • cluster ((N,) array, optional): Cluster assignments. If None, each unit is its own cluster.
  • point_estimate (tuple, optional): Precomputed full-sample (eff, D, T_on, I) result. If supplied, bootstrap reuses it as the original point estimate and only calls estimate_fn for resampled replications.
def jackknife( estimate_fn, Y: numpy.ndarray, D: numpy.ndarray, I: numpy.ndarray, T_on: numpy.ndarray, unit_type: numpy.ndarray, alpha: float = 0.05, n_jobs: int = 1) -> InferenceResult:
256def jackknife(
257    estimate_fn,
258    Y: np.ndarray,
259    D: np.ndarray,
260    I: np.ndarray,
261    T_on: np.ndarray,
262    unit_type: np.ndarray,
263    alpha: float = 0.05,
264    n_jobs: int = 1,
265) -> InferenceResult:
266    """Delete-one-unit jackknife inference.
267
268    Drops one unit at a time, re-estimates, computes jackknife SE
269    with the standard (N-1)/N correction.
270    """
271    from scipy import stats
272
273    N = Y.shape[1]
274
275    # Point estimate
276    eff_point, D_point, T_on_point, I_point = _unpack_estimate(
277        estimate_fn(np.arange(N)), I
278    )
279    att_avg_point, att_on_point, time_on = _compute_att(eff_point, D_point, T_on_point, I_point)
280    n_periods = len(time_on)
281
282    def _one_jack(j):
283        idx = np.concatenate([np.arange(j), np.arange(j + 1, N)])
284        try:
285            eff_j, D_j, T_on_j, I_j = _unpack_estimate(
286                estimate_fn(idx), I[:, idx]
287            )
288            att_avg_j, att_on_j, _ = _compute_att(eff_j, D_j, T_on_j, I_j, time_on)
289            return att_avg_j, att_on_j
290        except Exception:
291            return None, None
292
293    if n_jobs == 1:
294        results = [_one_jack(j) for j in range(N)]
295    else:
296        with parallel_config(backend="loky", inner_max_num_threads=1):
297            results = Parallel(n_jobs=n_jobs)(
298                delayed(_one_jack)(j) for j in range(N)
299            )
300
301    valid = [(avg, on) for avg, on in results if avg is not None]
302    n_valid = len(valid)
303    if n_valid != N:
304        raise RuntimeError(
305            f"{N - n_valid} of {N} jackknife deletions failed; "
306            "delete-one-unit inference requires every deletion."
307        )
308    if n_valid < 2:
309        raise RuntimeError("Too few valid jackknife replications")
310
311    att_avg_jack = np.array([v[0] for v in valid])
312    att_on_jack = np.column_stack([v[1] for v in valid])
313
314    # Jackknife variance: (N-1)/N * sum((theta_j - theta_bar)^2)
315    att_avg_se = float(np.sqrt((n_valid - 1) / n_valid * np.sum((att_avg_jack - np.mean(att_avg_jack)) ** 2)))
316    att_on_se = np.sqrt(
317        (n_valid - 1) / n_valid * np.sum((att_on_jack - att_on_jack.mean(axis=1, keepdims=True)) ** 2, axis=1)
318    )
319
320    # t-distribution CI
321    t_crit = stats.t.ppf(1 - alpha / 2, df=n_valid - 1)
322    att_avg_ci = (att_avg_point - t_crit * att_avg_se, att_avg_point + t_crit * att_avg_se)
323    att_on_ci_lower = att_on_point - t_crit * att_on_se
324    att_on_ci_upper = att_on_point + t_crit * att_on_se
325
326    # p-values from t-distribution
327    t_avg = att_avg_point / max(att_avg_se, 1e-10)
328    att_avg_pval = float(2 * stats.t.sf(abs(t_avg), df=n_valid - 1))
329
330    t_on = att_on_point / np.maximum(att_on_se, 1e-10)
331    att_on_pval = 2 * stats.t.sf(np.abs(t_on), df=n_valid - 1)
332
333    return InferenceResult(
334        att_avg=att_avg_point,
335        att_avg_se=att_avg_se,
336        att_avg_ci=att_avg_ci,
337        att_avg_pval=att_avg_pval,
338        att_on=att_on_point,
339        att_on_se=att_on_se,
340        att_on_ci_lower=att_on_ci_lower,
341        att_on_ci_upper=att_on_ci_upper,
342        att_on_pval=att_on_pval,
343        time_on=time_on,
344        att_avg_boot=att_avg_jack,
345        att_on_boot=att_on_jack,
346    )

Delete-one-unit jackknife inference.

Drops one unit at a time, re-estimates, computes jackknife SE with the standard (N-1)/N correction.

def permutation_test( estimate_fn, att_avg_observed: float, N: int, T: int, n_perms: int = 200, block_size: int = 1, seed: int | None = None, n_jobs: int = 1) -> tuple[float, numpy.ndarray]:
349def permutation_test(
350    estimate_fn,
351    att_avg_observed: float,
352    N: int,
353    T: int,
354    n_perms: int = 200,
355    block_size: int = 1,
356    seed: int | None = None,
357    n_jobs: int = 1,
358) -> tuple[float, np.ndarray]:
359    """Permutation test: shuffle treatment assignment, compare ATTs.
360
361    Returns (p_value, permuted_atts).
362    """
363    rng = np.random.default_rng(seed)
364    perm_seeds = rng.integers(0, 2**31, size=n_perms)
365
366    def _one_perm(perm_seed):
367        try:
368            att_perm = estimate_fn(perm_seed)
369            return att_perm
370        except Exception:
371            return None
372
373    if n_jobs == 1:
374        results = [_one_perm(s) for s in perm_seeds]
375    else:
376        with parallel_config(backend="loky", inner_max_num_threads=1):
377            results = Parallel(n_jobs=n_jobs)(
378                delayed(_one_perm)(s) for s in perm_seeds
379            )
380
381    valid = [r for r in results if r is not None]
382    if len(valid) == 0:
383        return 1.0, np.array([])
384
385    permuted_atts = np.array(valid)
386    p_value = float(np.mean(np.abs(permuted_atts) >= abs(att_avg_observed)))
387    return p_value, permuted_atts

Permutation test: shuffle treatment assignment, compare ATTs.

Returns (p_value, permuted_atts).