Spaces:
Sleeping
Sleeping
File size: 7,281 Bytes
f6dac2a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 | """Block-bootstrap confidence intervals.
Per docs/METHODOLOGY.md §10. Two estimators:
cluster_bootstrap_did — for DiD δ (resample entities with replacement)
stationary_bootstrap — for time-series metrics (mean block length 5d)
Both return a ``BootstrapCI(point, lower, upper, n_replications)`` triple.
Default: 5,000 replications, 5–95 percentile interval.
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import numpy as np
import pandas as pd
from src.constants import BOOTSTRAP_BLOCK_LENGTH, BOOTSTRAP_REPLICATIONS
from src.matching import did_panel
@dataclass(frozen=True, slots=True)
class BootstrapCI:
"""Point estimate plus a percentile confidence interval."""
metric: str
scope: str
point: float
lower: float
upper: float
n_replications: int
lower_pct: float = 5.0
upper_pct: float = 95.0
# ---------------------------------------------------------------------------
# Cluster (entity) bootstrap for DiD
# ---------------------------------------------------------------------------
def cluster_bootstrap_did(
panel: pd.DataFrame,
*,
treated: set[str],
controls: set[str],
metric_name: str,
scope: str,
n_reps: int = BOOTSTRAP_REPLICATIONS,
event_t0: int = 0,
outcome: str = "ret",
lower_pct: float = 5.0,
upper_pct: float = 95.0,
seed: int = 0,
) -> BootstrapCI:
"""Cluster-bootstrap δ from a Treated × Post DiD on the panel.
Resamples *entities* (tickers) with replacement, preserving the within-
entity time series. Treated and control sets are sampled separately so
the treated/control mix is preserved. Refits ``did_panel`` each rep.
Returns the point estimate (full-sample δ) plus the bootstrap percentile
CI from the resampled distribution.
"""
rng = np.random.default_rng(seed)
treated_arr = np.array(sorted(treated))
controls_arr = np.array(sorted(controls))
if len(treated_arr) == 0 or len(controls_arr) == 0:
raise ValueError("treated or controls set is empty")
# Point estimate from the full sample
point_res = did_panel(
panel, treated=set(treated_arr), controls=set(controls_arr),
event_t0=event_t0, outcome=outcome,
)
point = float(point_res.delta)
# Pre-index the panel by ticker for fast slicing
panel_idx = panel.set_index("ticker", drop=False).sort_index()
deltas: list[float] = []
for _ in range(n_reps):
t_sample = rng.choice(treated_arr, size=len(treated_arr), replace=True)
c_sample = rng.choice(controls_arr, size=len(controls_arr), replace=True)
# Build resampled panel; preserve replicated entities by suffixing
frames = []
new_treated: set[str] = set()
new_controls: set[str] = set()
for j, tk in enumerate(t_sample):
sub = panel_idx.loc[[tk]].copy()
new_id = f"{tk}__t{j}"
sub["ticker"] = new_id
frames.append(sub)
new_treated.add(new_id)
for j, tk in enumerate(c_sample):
sub = panel_idx.loc[[tk]].copy()
new_id = f"{tk}__c{j}"
sub["ticker"] = new_id
frames.append(sub)
new_controls.add(new_id)
boot = pd.concat(frames, ignore_index=True)
try:
res = did_panel(
boot,
treated=new_treated,
controls=new_controls,
event_t0=event_t0,
outcome=outcome,
)
deltas.append(float(res.delta))
except Exception:
continue
if not deltas:
raise RuntimeError("all bootstrap reps failed; check input data")
arr = np.array(deltas)
lo = float(np.percentile(arr, lower_pct))
hi = float(np.percentile(arr, upper_pct))
return BootstrapCI(
metric=metric_name,
scope=scope,
point=point,
lower=lo,
upper=hi,
n_replications=len(deltas),
lower_pct=lower_pct,
upper_pct=upper_pct,
)
# ---------------------------------------------------------------------------
# Stationary (time-series) bootstrap
# ---------------------------------------------------------------------------
def stationary_bootstrap(
series: pd.Series | np.ndarray,
fn: Callable[[np.ndarray], float],
*,
metric_name: str,
scope: str,
n_reps: int = BOOTSTRAP_REPLICATIONS,
mean_block_len: int = BOOTSTRAP_BLOCK_LENGTH,
lower_pct: float = 5.0,
upper_pct: float = 95.0,
seed: int = 0,
) -> BootstrapCI:
"""Politis–Romano stationary bootstrap on a 1-D series.
Block lengths are i.i.d. Geometric(1/mean_block_len); start indices are
uniform on [0, n). For each rep, resample of length n is built by
concatenating blocks (wrapping at the end), then ``fn`` is evaluated.
"""
if isinstance(series, pd.Series):
x = series.to_numpy(dtype="float64")
else:
x = np.asarray(series, dtype="float64")
n = len(x)
if n < 2:
raise ValueError("series too short for bootstrap")
rng = np.random.default_rng(seed)
p = 1.0 / max(mean_block_len, 1)
point = float(fn(x))
estimates = np.empty(n_reps, dtype="float64")
for r in range(n_reps):
sample = np.empty(n, dtype="float64")
i = 0
while i < n:
start = int(rng.integers(0, n))
block_len = int(rng.geometric(p))
block_len = min(block_len, n - i)
for k in range(block_len):
sample[i + k] = x[(start + k) % n]
i += block_len
estimates[r] = fn(sample)
return BootstrapCI(
metric=metric_name,
scope=scope,
point=point,
lower=float(np.percentile(estimates, lower_pct)),
upper=float(np.percentile(estimates, upper_pct)),
n_replications=n_reps,
lower_pct=lower_pct,
upper_pct=upper_pct,
)
# ---------------------------------------------------------------------------
# Mean CAR bootstrap (cluster on ticker)
# ---------------------------------------------------------------------------
def cluster_bootstrap_mean_car(
car_by_ticker: pd.Series,
*,
metric_name: str,
scope: str,
n_reps: int = BOOTSTRAP_REPLICATIONS,
lower_pct: float = 5.0,
upper_pct: float = 95.0,
seed: int = 0,
) -> BootstrapCI:
"""Bootstrap the mean of per-ticker CARs by resampling tickers with
replacement. Faster path than full DiD bootstrap; same idea."""
arr = car_by_ticker.dropna().to_numpy(dtype="float64")
if len(arr) == 0:
raise ValueError("car_by_ticker is empty after dropna")
rng = np.random.default_rng(seed)
point = float(arr.mean())
n = len(arr)
means = np.empty(n_reps, dtype="float64")
for r in range(n_reps):
idx = rng.integers(0, n, size=n)
means[r] = arr[idx].mean()
return BootstrapCI(
metric=metric_name,
scope=scope,
point=point,
lower=float(np.percentile(means, lower_pct)),
upper=float(np.percentile(means, upper_pct)),
n_replications=n_reps,
lower_pct=lower_pct,
upper_pct=upper_pct,
)
|