diff-stream 0.1.0.dev0__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.
- diff_stream/__init__.py +52 -0
- diff_stream/_bootstrap_worker.py +48 -0
- diff_stream/_core/__init__.py +5 -0
- diff_stream/_core/contrasts.py +199 -0
- diff_stream/_core/covariate_kernels.py +420 -0
- diff_stream/_core/cs_kernels.py +536 -0
- diff_stream/_core/cs_row_kernels.py +495 -0
- diff_stream/_core/design.py +105 -0
- diff_stream/_core/etwfe_kernels.py +288 -0
- diff_stream/_core/functionals.py +30 -0
- diff_stream/_core/multiplier.py +90 -0
- diff_stream/_core/nuisance.py +255 -0
- diff_stream/_core/partition.py +158 -0
- diff_stream/_core/process.py +61 -0
- diff_stream/_core/resample.py +171 -0
- diff_stream/_core/staging_kernels.py +90 -0
- diff_stream/_core/twostage_kernels.py +342 -0
- diff_stream/_core/weights.py +26 -0
- diff_stream/_core/within.py +81 -0
- diff_stream/_docs.py +100 -0
- diff_stream/_options.py +62 -0
- diff_stream/_present.py +202 -0
- diff_stream/absorbed.py +621 -0
- diff_stream/aggregation.py +225 -0
- diff_stream/api.py +1112 -0
- diff_stream/bacon.py +460 -0
- diff_stream/bacon_controls.py +188 -0
- diff_stream/balance.py +149 -0
- diff_stream/bjs.py +101 -0
- diff_stream/bjs_placebo.py +167 -0
- diff_stream/bootstrap.py +704 -0
- diff_stream/cohort_scores.py +68 -0
- diff_stream/cs.py +197 -0
- diff_stream/cs_unbalanced.py +336 -0
- diff_stream/effective_sample.py +73 -0
- diff_stream/estimators.py +95 -0
- diff_stream/etwfe.py +589 -0
- diff_stream/gardner.py +500 -0
- diff_stream/linear.py +339 -0
- diff_stream/periods.py +72 -0
- diff_stream/pretrend.py +97 -0
- diff_stream/py.typed +0 -0
- diff_stream/reference.py +125 -0
- diff_stream/results.py +521 -0
- diff_stream/simulate.py +80 -0
- diff_stream/stacked.py +405 -0
- diff_stream/store.py +706 -0
- diff_stream/twfe_store.py +181 -0
- diff_stream/twfe_weights.py +552 -0
- diff_stream/twostage_absorbed.py +190 -0
- diff_stream/unbalanced.py +297 -0
- diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
- diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
- diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
- diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
diff_stream/bootstrap.py
ADDED
|
@@ -0,0 +1,704 @@
|
|
|
1
|
+
"""Ordinary unit-cluster pairs bootstrap with serial or parallel full refits."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import pickle
|
|
5
|
+
import sys
|
|
6
|
+
import warnings
|
|
7
|
+
from concurrent.futures import as_completed
|
|
8
|
+
from contextlib import closing, nullcontext
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from inspect import signature
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from tempfile import TemporaryDirectory
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import polars as pl
|
|
16
|
+
import pyarrow as pa
|
|
17
|
+
from scipy.stats import norm
|
|
18
|
+
from stream_limits import MemoryBudget, detect_cpus, thread_scope
|
|
19
|
+
|
|
20
|
+
from . import _options
|
|
21
|
+
from ._core.partition import preprocess_budget_mb
|
|
22
|
+
from ._core.process import ProcessBatch
|
|
23
|
+
from ._core.resample import DiskGroupResampler
|
|
24
|
+
from .api import _fit, did_bjs, did_callaway_santanna, did_sun_abraham
|
|
25
|
+
from .etwfe import did_extended_twfe
|
|
26
|
+
from .gardner import did_two_stage
|
|
27
|
+
from .store import inspect, source
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BootstrapFailureWarning(UserWarning):
|
|
31
|
+
"""Some ordinary resamples failed; inference requires explicit acknowledgment."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(kw_only=True)
|
|
35
|
+
class RefitBootstrapResult:
|
|
36
|
+
"""Point estimates, ordinary refit draws and pointwise bootstrap inference.
|
|
37
|
+
|
|
38
|
+
No source or fit scratch survives. Failed attempts remain in the ledger and
|
|
39
|
+
are never redrawn. Inference on successful draws requires allow_incomplete
|
|
40
|
+
when any attempt failed; such inference is conditional on successful fits.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
fml: str
|
|
44
|
+
estimator: str
|
|
45
|
+
coefnames: list
|
|
46
|
+
beta: np.ndarray
|
|
47
|
+
diagnostics: dict
|
|
48
|
+
_draws: np.ndarray = field(repr=False)
|
|
49
|
+
_failures: list = field(repr=False)
|
|
50
|
+
_warnings: list = field(repr=False)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def successful_reps(self):
|
|
54
|
+
return int(np.isfinite(self._draws).all(axis=1).sum())
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def se(self):
|
|
58
|
+
"""Sample SD of refitted estimates; refuses incomplete bootstrap runs."""
|
|
59
|
+
return self.standard_errors()
|
|
60
|
+
|
|
61
|
+
def _valid(self, allow_incomplete):
|
|
62
|
+
if self._failures and not allow_incomplete:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"some refits failed; inspect failures() and explicitly set allow_incomplete=True for inference conditional on success"
|
|
65
|
+
)
|
|
66
|
+
data = self._draws[np.isfinite(self._draws).all(axis=1)]
|
|
67
|
+
if len(data) < 2:
|
|
68
|
+
raise ValueError("at least two successful refits are required for bootstrap inference")
|
|
69
|
+
return data
|
|
70
|
+
|
|
71
|
+
def standard_errors(self, *, allow_incomplete=False):
|
|
72
|
+
return np.std(self._valid(allow_incomplete), axis=0, ddof=1)
|
|
73
|
+
|
|
74
|
+
def confint(self, alpha=0.05, *, method="percentile", allow_incomplete=False):
|
|
75
|
+
"""Pointwise percentile/type-7, basic, or uncorrected normal intervals.
|
|
76
|
+
|
|
77
|
+
Normal intervals are point estimate +/- normal critical value times
|
|
78
|
+
bootstrap SD, without boot.ci's normal bias correction. No simultaneous,
|
|
79
|
+
studentized or BCa intervals are supplied.
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(alpha, bool) or not np.isfinite(alpha) or not 0 < alpha < 1:
|
|
82
|
+
raise ValueError("alpha must be between zero and one")
|
|
83
|
+
if method not in ("percentile", "basic", "normal"):
|
|
84
|
+
raise ValueError("method must be percentile, basic or normal")
|
|
85
|
+
draws = self._valid(allow_incomplete)
|
|
86
|
+
if method == "normal":
|
|
87
|
+
spread = norm.ppf(1 - alpha / 2) * np.std(draws, axis=0, ddof=1)
|
|
88
|
+
lower, upper = self.beta - spread, self.beta + spread
|
|
89
|
+
else:
|
|
90
|
+
low, high = np.quantile(draws, [alpha / 2, 1 - alpha / 2], axis=0, method="linear")
|
|
91
|
+
lower, upper = (
|
|
92
|
+
(low, high)
|
|
93
|
+
if method == "percentile"
|
|
94
|
+
else (2 * self.beta - high, 2 * self.beta - low)
|
|
95
|
+
)
|
|
96
|
+
return pl.DataFrame(
|
|
97
|
+
{"Coefficient": self.coefnames, "Estimate": self.beta, "Lower": lower, "Upper": upper}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def tidy(self, alpha=0.05, *, method="percentile", allow_incomplete=False):
|
|
101
|
+
draws = self._valid(allow_incomplete)
|
|
102
|
+
return self.confint(alpha, method=method, allow_incomplete=allow_incomplete).with_columns(
|
|
103
|
+
pl.Series("Std. Error", np.std(draws, axis=0, ddof=1)),
|
|
104
|
+
pl.Series("Bootstrap Mean", draws.mean(axis=0)),
|
|
105
|
+
pl.Series("Bias", draws.mean(axis=0) - self.beta),
|
|
106
|
+
pl.lit(len(draws)).alias("Successful Reps"),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def draws(self):
|
|
110
|
+
"""Copy attempted estimates; failed rows are NaN for every coefficient."""
|
|
111
|
+
return pl.DataFrame(
|
|
112
|
+
{
|
|
113
|
+
"draw": np.arange(len(self._draws)),
|
|
114
|
+
"successful": np.isfinite(self._draws).all(axis=1),
|
|
115
|
+
**{n: self._draws[:, j].copy() for j, n in enumerate(self.coefnames)},
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
def failures(self):
|
|
120
|
+
return pl.DataFrame(
|
|
121
|
+
self._failures, schema={"draw": pl.Int64, "error_type": pl.String, "reason": pl.String}
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def refit_warnings(self):
|
|
125
|
+
return pl.DataFrame(
|
|
126
|
+
self._warnings, schema={"draw": pl.Int64, "category": pl.String, "message": pl.String}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def summary_dict(self):
|
|
130
|
+
return dict(
|
|
131
|
+
fml=self.fml,
|
|
132
|
+
estimator=self.estimator,
|
|
133
|
+
coefficients=dict(zip(self.coefnames, self.beta.tolist(), strict=True)),
|
|
134
|
+
requested_reps=len(self._draws),
|
|
135
|
+
successful_reps=self.successful_reps,
|
|
136
|
+
failures=self.failures().to_dicts(),
|
|
137
|
+
refit_warnings=self.refit_warnings().to_dicts(),
|
|
138
|
+
diagnostics=self.diagnostics,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def summary(self):
|
|
142
|
+
"""Run/sample/failure diagnostics; inference is available through tidy()."""
|
|
143
|
+
return self.summary_dict()
|
|
144
|
+
|
|
145
|
+
def summary_json(self, path=None):
|
|
146
|
+
content = json.dumps(self.summary_dict(), indent=2, allow_nan=False, default=str)
|
|
147
|
+
if path is not None:
|
|
148
|
+
Path(path).write_text(content + "\n")
|
|
149
|
+
return content
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _target(result, aggregate, aggregate_options, coefficients):
|
|
153
|
+
target = result.aggregate(aggregate, **aggregate_options) if aggregate is not None else result
|
|
154
|
+
names = list(target.coefnames) if coefficients is None else list(coefficients)
|
|
155
|
+
if not names or len(set(names)) != len(names) or not set(names) <= set(target.coefnames):
|
|
156
|
+
raise ValueError("requested coefficient names are missing, duplicated or empty")
|
|
157
|
+
values = target.beta[[target.coefnames.index(n) for n in names]].copy()
|
|
158
|
+
if not np.isfinite(values).all():
|
|
159
|
+
raise ValueError("requested coefficients are nonfinite or suppressed")
|
|
160
|
+
return names, values
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _replicate_formula(fml, unit):
|
|
164
|
+
if "|" not in fml:
|
|
165
|
+
return fml
|
|
166
|
+
regression, fe = fml.split("|")
|
|
167
|
+
terms = [x.strip() for x in fe.split("+") if x.strip() != unit]
|
|
168
|
+
return regression if not terms else regression + "|" + "+".join(terms)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _refit_attempt(task):
|
|
172
|
+
"""Identical fit/sample/failure policy for in-process and subprocess draws."""
|
|
173
|
+
result, values, failure, captured = None, None, None, []
|
|
174
|
+
b = task["draw"]
|
|
175
|
+
try:
|
|
176
|
+
if task["dates"] != task["expected_dates"]:
|
|
177
|
+
raise ValueError("resample loses dates from the fixed analysis window")
|
|
178
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
179
|
+
warnings.simplefilter("always")
|
|
180
|
+
try:
|
|
181
|
+
result = _fit(
|
|
182
|
+
task["fml"],
|
|
183
|
+
task["path"],
|
|
184
|
+
panel=task["panel"],
|
|
185
|
+
cohort=task["cohort"],
|
|
186
|
+
treatment=task["treatment"],
|
|
187
|
+
estimator=task["estimator"],
|
|
188
|
+
workdir=task["workdir"],
|
|
189
|
+
**task["options"],
|
|
190
|
+
)
|
|
191
|
+
if result.n_units != task["n_units"] or result.n_obs != task["draw_rows"]:
|
|
192
|
+
raise ValueError("refit changed the fixed sampled population")
|
|
193
|
+
_, values = _target(
|
|
194
|
+
result, task["aggregate"], task["aggregate_options"], task["names"]
|
|
195
|
+
)
|
|
196
|
+
finally:
|
|
197
|
+
captured.extend(
|
|
198
|
+
dict(draw=b, category=w.category.__name__, message=str(w.message)[:2000])
|
|
199
|
+
for w in caught
|
|
200
|
+
)
|
|
201
|
+
except (ValueError, FloatingPointError, np.linalg.LinAlgError) as exc:
|
|
202
|
+
failure = dict(draw=b, error_type=type(exc).__name__, reason=str(exc)[:2000])
|
|
203
|
+
finally:
|
|
204
|
+
if result is not None:
|
|
205
|
+
result.cleanup()
|
|
206
|
+
pa.default_memory_pool().release_unused()
|
|
207
|
+
return values, failure, captured
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parallel_refits(tasks, workers, threads, memory_gb):
|
|
211
|
+
"""Yield completed bounded-batch results; stop children before scratch cleanup."""
|
|
212
|
+
with ProcessBatch(workers) as batch:
|
|
213
|
+
pending = {}
|
|
214
|
+
for task in tasks:
|
|
215
|
+
root = Path(task["workdir"]).parent
|
|
216
|
+
job, output, log = root / "job.pkl", root / "result.pkl", root / "worker.log"
|
|
217
|
+
task = dict(task, options=dict(task["options"], threads=threads, memory_gb=memory_gb))
|
|
218
|
+
with job.open("wb") as f:
|
|
219
|
+
pickle.dump(task, f, protocol=pickle.HIGHEST_PROTOCOL)
|
|
220
|
+
command = [
|
|
221
|
+
sys.executable,
|
|
222
|
+
str(Path(__file__).with_name("_bootstrap_worker.py")),
|
|
223
|
+
str(job),
|
|
224
|
+
str(output),
|
|
225
|
+
str(threads),
|
|
226
|
+
repr(memory_gb),
|
|
227
|
+
]
|
|
228
|
+
pending[batch.submit(command, log)] = (task["draw"], output, log)
|
|
229
|
+
for future in as_completed(pending):
|
|
230
|
+
b, output, log = pending[future]
|
|
231
|
+
returncode = future.result()
|
|
232
|
+
if returncode or not output.exists():
|
|
233
|
+
with log.open("rb") as f:
|
|
234
|
+
f.seek(max(0, log.stat().st_size - 4000))
|
|
235
|
+
detail = f.read().decode(errors="replace")
|
|
236
|
+
raise RuntimeError(f"bootstrap worker for draw {b} exited {returncode}: {detail}")
|
|
237
|
+
with output.open("rb") as f:
|
|
238
|
+
payload = pickle.load(f) # Private output of our own worker.
|
|
239
|
+
if "error" in payload:
|
|
240
|
+
error = payload["error"]
|
|
241
|
+
# Keep the exception type (notably MemoryError) and remote trace.
|
|
242
|
+
raise error from RuntimeError(payload["traceback"])
|
|
243
|
+
yield b, payload["result"], payload["resources"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _refit_bootstrap(
|
|
247
|
+
fml,
|
|
248
|
+
data,
|
|
249
|
+
*,
|
|
250
|
+
panel,
|
|
251
|
+
cohort=None,
|
|
252
|
+
treatment=None,
|
|
253
|
+
estimator,
|
|
254
|
+
public_estimator,
|
|
255
|
+
public_options,
|
|
256
|
+
reps=199,
|
|
257
|
+
seed=None,
|
|
258
|
+
aggregate=None,
|
|
259
|
+
aggregate_options=None,
|
|
260
|
+
coefficients=None,
|
|
261
|
+
workers=1,
|
|
262
|
+
threads=None,
|
|
263
|
+
memory_gb=None,
|
|
264
|
+
memory_reserve=None,
|
|
265
|
+
preprocess_mb=None,
|
|
266
|
+
batch_units=256,
|
|
267
|
+
workdir=None,
|
|
268
|
+
**fit_options,
|
|
269
|
+
):
|
|
270
|
+
"""refit_bootstrap with internal estimator codes and option names."""
|
|
271
|
+
for name, value, minimum in [
|
|
272
|
+
("reps", reps, 2),
|
|
273
|
+
("batch_units", batch_units, 1),
|
|
274
|
+
("n_workers", workers, 1),
|
|
275
|
+
]:
|
|
276
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
277
|
+
raise ValueError(f"{name} must be an integer >= {minimum}")
|
|
278
|
+
if seed is not None and (isinstance(seed, bool) or not isinstance(seed, int) or seed < 0):
|
|
279
|
+
raise ValueError("seed must be a nonnegative integer or None")
|
|
280
|
+
if threads is not None and (
|
|
281
|
+
isinstance(threads, bool) or not isinstance(threads, int) or threads < 1
|
|
282
|
+
):
|
|
283
|
+
raise ValueError("n_threads must be a positive integer")
|
|
284
|
+
active_workers = min(workers, reps)
|
|
285
|
+
job_threads = threads if threads is not None else detect_cpus()
|
|
286
|
+
if active_workers > 1 and active_workers > job_threads:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"n_workers cannot exceed the total thread budget; increase n_threads or reduce n_workers"
|
|
289
|
+
)
|
|
290
|
+
worker_threads = max(1, job_threads // active_workers)
|
|
291
|
+
if aggregate not in (None, "event_time", "overall", "cohort", "calendar"):
|
|
292
|
+
raise ValueError("aggregate must be 'event_time', 'overall', 'cohort', 'calendar' or None")
|
|
293
|
+
if estimator == "gardner" and aggregate is not None:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"did_two_stage coefficients are reported directly: use aggregate=None "
|
|
296
|
+
"(and event_study=False for a single ATT)"
|
|
297
|
+
)
|
|
298
|
+
aggregate_options = {} if aggregate_options is None else dict(aggregate_options)
|
|
299
|
+
if aggregate is None and aggregate_options:
|
|
300
|
+
raise ValueError("aggregate_options requires an aggregate target")
|
|
301
|
+
if isinstance(coefficients, str):
|
|
302
|
+
raise ValueError("coefficients must be a sequence of exact coefficient names")
|
|
303
|
+
if coefficients is not None:
|
|
304
|
+
coefficients = list(coefficients)
|
|
305
|
+
if (
|
|
306
|
+
fit_options.get("profile", False)
|
|
307
|
+
or fit_options.get("bootstrap_reps", 0)
|
|
308
|
+
or fit_options.get("bootstrap_seed") is not None
|
|
309
|
+
):
|
|
310
|
+
raise ValueError(
|
|
311
|
+
"refit_bootstrap fits the model itself: profile and the multiplier bootstrap "
|
|
312
|
+
"options cannot be passed to it"
|
|
313
|
+
)
|
|
314
|
+
budget = MemoryBudget(memory_gb, memory_reserve)
|
|
315
|
+
# Refits size their own buffers from their own budgets (workers have less).
|
|
316
|
+
# The draw plan follows the sampler's unit order, which depends on its
|
|
317
|
+
# partitions: a fixed default keeps seeded draws the same on every machine.
|
|
318
|
+
refit_preprocess_mb = preprocess_mb
|
|
319
|
+
preprocess_mb = preprocess_budget_mb(64 if preprocess_mb is None else preprocess_mb, budget)
|
|
320
|
+
if not budget.fits((preprocess_mb * 1e6 + 96e6) / 1e9):
|
|
321
|
+
raise MemoryError("bootstrap preprocessing buffers exceed job headroom")
|
|
322
|
+
weights = fit_options.get("weights")
|
|
323
|
+
_, y, design, absorbed, _ = inspect(
|
|
324
|
+
fml,
|
|
325
|
+
data,
|
|
326
|
+
panel,
|
|
327
|
+
cohort,
|
|
328
|
+
treatment=treatment,
|
|
329
|
+
allow_covariates=True,
|
|
330
|
+
weights=weights,
|
|
331
|
+
allow_fe=estimator in ("sunab", "bjs", "gardner"),
|
|
332
|
+
fe_solver=fit_options.get("fe_solver", "auto"),
|
|
333
|
+
)
|
|
334
|
+
required = list(
|
|
335
|
+
dict.fromkeys(
|
|
336
|
+
[
|
|
337
|
+
*panel,
|
|
338
|
+
cohort if cohort is not None else treatment,
|
|
339
|
+
y,
|
|
340
|
+
*([] if weights is None else [weights]),
|
|
341
|
+
*([] if design is None else design.required),
|
|
342
|
+
*[c for _, written in absorbed or [] for c in written.split("^")],
|
|
343
|
+
]
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
src = source(data)
|
|
347
|
+
used = set(src.collect_schema().names())
|
|
348
|
+
index_column = "__diff_source_unit"
|
|
349
|
+
while index_column in used:
|
|
350
|
+
index_column += "_"
|
|
351
|
+
used.add(index_column)
|
|
352
|
+
draw_column = "__diff_boot_unit"
|
|
353
|
+
while draw_column in used:
|
|
354
|
+
draw_column += "_"
|
|
355
|
+
options = dict(
|
|
356
|
+
fit_options,
|
|
357
|
+
threads=threads,
|
|
358
|
+
memory_gb=memory_gb,
|
|
359
|
+
memory_reserve=memory_reserve,
|
|
360
|
+
preprocess_mb=refit_preprocess_mb,
|
|
361
|
+
batch_units=batch_units,
|
|
362
|
+
)
|
|
363
|
+
seed_sequence = np.random.SeedSequence(seed)
|
|
364
|
+
rng = np.random.default_rng(seed_sequence)
|
|
365
|
+
if workdir is not None:
|
|
366
|
+
Path(workdir).mkdir(parents=True, exist_ok=True)
|
|
367
|
+
with thread_scope(threads), TemporaryDirectory(prefix="diff-stream-refit-", dir=workdir) as run:
|
|
368
|
+
run = Path(run)
|
|
369
|
+
snapshot = run / "source.parquet"
|
|
370
|
+
src.select(required).sink_parquet(snapshot, engine="streaming", row_group_size=32768)
|
|
371
|
+
original_dates = (
|
|
372
|
+
pl.scan_parquet(snapshot)
|
|
373
|
+
.select(
|
|
374
|
+
pl.col(panel[1]).min().alias("lo"),
|
|
375
|
+
pl.col(panel[1]).max().alias("hi"),
|
|
376
|
+
pl.len().alias("rows"),
|
|
377
|
+
)
|
|
378
|
+
.collect(engine="streaming")
|
|
379
|
+
.row(0)
|
|
380
|
+
)
|
|
381
|
+
original = _fit(
|
|
382
|
+
fml,
|
|
383
|
+
snapshot,
|
|
384
|
+
panel=panel,
|
|
385
|
+
cohort=cohort,
|
|
386
|
+
treatment=treatment,
|
|
387
|
+
estimator=estimator,
|
|
388
|
+
workdir=run / "fit",
|
|
389
|
+
**options,
|
|
390
|
+
)
|
|
391
|
+
try:
|
|
392
|
+
names, beta = _target(original, aggregate, aggregate_options, coefficients)
|
|
393
|
+
n_units, n_obs = original.n_units, original.n_obs
|
|
394
|
+
diag = dict(original.diagnostics)
|
|
395
|
+
finally:
|
|
396
|
+
original.cleanup()
|
|
397
|
+
del original
|
|
398
|
+
step = diag.get("period_step", 1)
|
|
399
|
+
expected_dates = list(range(diag["time_min"], diag["time_max"] + 1, step))
|
|
400
|
+
cutoff = diag.get("reference_cutoff")
|
|
401
|
+
mode = options.get("panel_mode", "balanced")
|
|
402
|
+
|
|
403
|
+
def select(frame):
|
|
404
|
+
if mode == "complete":
|
|
405
|
+
ids = frame[panel[0]].to_numpy()
|
|
406
|
+
starts = np.r_[0, np.flatnonzero(ids[1:] != ids[:-1]) + 1]
|
|
407
|
+
lengths = np.diff(np.r_[starts, len(frame)])
|
|
408
|
+
frame = frame.filter(
|
|
409
|
+
pl.Series(
|
|
410
|
+
np.repeat(
|
|
411
|
+
lengths == (original_dates[1] - original_dates[0]) // step + 1, lengths
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
if cutoff is not None:
|
|
416
|
+
frame = frame.filter(pl.col(panel[1]) < cutoff)
|
|
417
|
+
return frame
|
|
418
|
+
|
|
419
|
+
stage = run / "units"
|
|
420
|
+
stage.mkdir()
|
|
421
|
+
sampler = DiskGroupResampler(
|
|
422
|
+
snapshot,
|
|
423
|
+
stage,
|
|
424
|
+
budget,
|
|
425
|
+
preprocess_mb,
|
|
426
|
+
key=panel[0],
|
|
427
|
+
sort_columns=list(panel),
|
|
428
|
+
select=select,
|
|
429
|
+
index_column=index_column,
|
|
430
|
+
draw_column=draw_column,
|
|
431
|
+
)
|
|
432
|
+
if (sampler.n_units, sampler.n_rows) != (n_units, n_obs):
|
|
433
|
+
raise RuntimeError("bootstrap staging differs from the original fitted sample")
|
|
434
|
+
numeric = 8 * 4 * reps * len(names) + 48e6
|
|
435
|
+
pa.default_memory_pool().release_unused()
|
|
436
|
+
if not budget.fits(numeric / 1e9):
|
|
437
|
+
raise MemoryError(
|
|
438
|
+
"bootstrap draw matrix exceeds job headroom; reduce reps/coefficients"
|
|
439
|
+
)
|
|
440
|
+
draws = np.full((reps, len(names)), np.nan)
|
|
441
|
+
failures, captured, hashes = [], [], []
|
|
442
|
+
options.update(
|
|
443
|
+
reference_group="nevertreated",
|
|
444
|
+
panel_mode="unbalanced" if mode == "unbalanced" else "balanced",
|
|
445
|
+
)
|
|
446
|
+
replicate_fml = _replicate_formula(fml, panel[0])
|
|
447
|
+
batches, worker_resources = [], []
|
|
448
|
+
for start in range(0, reps, active_workers):
|
|
449
|
+
with TemporaryDirectory(prefix="batch-", dir=run) as batch_root:
|
|
450
|
+
tasks = []
|
|
451
|
+
for b in range(start, min(start + active_workers, reps)):
|
|
452
|
+
attempt = Path(batch_root) / f"draw-{b}"
|
|
453
|
+
attempt.mkdir()
|
|
454
|
+
path, dates, draw_rows, identity = sampler.materialize(
|
|
455
|
+
rng, attempt / "data", time_column=panel[1]
|
|
456
|
+
)
|
|
457
|
+
hashes.append(identity)
|
|
458
|
+
tasks.append(
|
|
459
|
+
dict(
|
|
460
|
+
draw=b,
|
|
461
|
+
fml=replicate_fml,
|
|
462
|
+
path=path,
|
|
463
|
+
panel=(draw_column, panel[1]),
|
|
464
|
+
cohort=cohort,
|
|
465
|
+
treatment=treatment,
|
|
466
|
+
estimator=estimator,
|
|
467
|
+
workdir=attempt / "fit",
|
|
468
|
+
options=options,
|
|
469
|
+
dates=dates,
|
|
470
|
+
expected_dates=expected_dates,
|
|
471
|
+
n_units=n_units,
|
|
472
|
+
draw_rows=draw_rows,
|
|
473
|
+
aggregate=aggregate,
|
|
474
|
+
aggregate_options=aggregate_options,
|
|
475
|
+
names=names,
|
|
476
|
+
)
|
|
477
|
+
)
|
|
478
|
+
if active_workers == 1:
|
|
479
|
+
completed = [(start, _refit_attempt(tasks[0]), None)]
|
|
480
|
+
else:
|
|
481
|
+
pa.default_memory_pool().release_unused()
|
|
482
|
+
# Parent staging is idle while children fit. Reserve its
|
|
483
|
+
# current RSS plus the job reserve; workers share only the
|
|
484
|
+
# remainder and also retain their own MemoryBudget reserve.
|
|
485
|
+
worker_memory = budget.headroom_gb() / active_workers
|
|
486
|
+
minimum = 0.35 + (refit_preprocess_mb or 64) / 1000 + 0.096
|
|
487
|
+
if worker_memory * (1 - budget.reserve) < minimum:
|
|
488
|
+
raise MemoryError(
|
|
489
|
+
"insufficient whole-job memory for parallel refits including worker runtimes; "
|
|
490
|
+
"reduce n_workers/preprocess_mb or increase max_memory_gb"
|
|
491
|
+
)
|
|
492
|
+
batches.append(
|
|
493
|
+
dict(first_draw=start, refits=len(tasks), worker_memory_gb=worker_memory)
|
|
494
|
+
)
|
|
495
|
+
completed = _parallel_refits(
|
|
496
|
+
tasks, active_workers, worker_threads, worker_memory
|
|
497
|
+
)
|
|
498
|
+
# Closing the generator stops/reaps workers before the batch
|
|
499
|
+
# temporary directory is removed, including on interruption.
|
|
500
|
+
with (
|
|
501
|
+
closing(completed) if active_workers > 1 else nullcontext(completed) as results
|
|
502
|
+
):
|
|
503
|
+
for b, (values, failure, notes), resources in results:
|
|
504
|
+
if values is not None:
|
|
505
|
+
draws[b] = values
|
|
506
|
+
if failure is not None:
|
|
507
|
+
failures.append(failure)
|
|
508
|
+
captured.extend(notes)
|
|
509
|
+
if resources is not None:
|
|
510
|
+
worker_resources.append(dict(draw=b, **resources))
|
|
511
|
+
failures.sort(key=lambda x: x["draw"])
|
|
512
|
+
captured.sort(key=lambda x: x["draw"])
|
|
513
|
+
worker_resources.sort(key=lambda x: x["draw"])
|
|
514
|
+
diagnostics = dict(
|
|
515
|
+
workers_requested=workers,
|
|
516
|
+
workers=active_workers,
|
|
517
|
+
job_threads=job_threads,
|
|
518
|
+
worker_threads=worker_threads if active_workers > 1 else threads,
|
|
519
|
+
parallel_batches=batches,
|
|
520
|
+
worker_resources=worker_resources,
|
|
521
|
+
seed=seed,
|
|
522
|
+
seed_entropy=seed_sequence.entropy,
|
|
523
|
+
bit_generator=type(rng.bit_generator).__name__,
|
|
524
|
+
reps=reps,
|
|
525
|
+
unit_count=n_units,
|
|
526
|
+
original_rows=n_obs,
|
|
527
|
+
analysis_rows=n_obs,
|
|
528
|
+
source_rows=original_dates[2],
|
|
529
|
+
source_panel=list(panel),
|
|
530
|
+
cohort=cohort,
|
|
531
|
+
fit_options=public_options,
|
|
532
|
+
aggregate=aggregate,
|
|
533
|
+
aggregate_options=aggregate_options,
|
|
534
|
+
incomplete_units={"balanced": "error", "complete": "drop", "unbalanced": "keep"}[mode],
|
|
535
|
+
fixed_reference=diag.get("reference"),
|
|
536
|
+
fixed_reference_cohort=diag.get("reference_cohort"),
|
|
537
|
+
reference_cutoff=cutoff,
|
|
538
|
+
analysis_time_min=diag["time_min"],
|
|
539
|
+
analysis_time_max=diag["time_max"],
|
|
540
|
+
replicate_reference="never_treated",
|
|
541
|
+
sampling="ordinary uniform unit pairs with replacement; fresh ID for each copy",
|
|
542
|
+
inference="pointwise; complete successful rows only; failed attempts never redrawn",
|
|
543
|
+
unit_order="hash leaf traversal, sorted source IDs within leaf; fixed input/layout reproducibility",
|
|
544
|
+
unit_order_sha256=sampler.identity.hexdigest(),
|
|
545
|
+
draw_plan_sha256=hashes,
|
|
546
|
+
source_columns=required,
|
|
547
|
+
partitions=sampler.partition_count,
|
|
548
|
+
preprocess_mb=preprocess_mb,
|
|
549
|
+
batch_units=batch_units,
|
|
550
|
+
added_generic_infrastructure="bounded whole-group resampling adapter in diff_stream._core.resample",
|
|
551
|
+
)
|
|
552
|
+
out = RefitBootstrapResult(
|
|
553
|
+
fml=fml,
|
|
554
|
+
estimator=public_estimator,
|
|
555
|
+
coefnames=names,
|
|
556
|
+
beta=beta,
|
|
557
|
+
diagnostics=diagnostics,
|
|
558
|
+
_draws=draws,
|
|
559
|
+
_failures=failures,
|
|
560
|
+
_warnings=captured,
|
|
561
|
+
)
|
|
562
|
+
if failures:
|
|
563
|
+
warnings.warn(
|
|
564
|
+
f"{len(failures)} of {reps} unit-cluster refits failed. Inspect failures(); incomplete-run inference requires allow_incomplete=True and is conditional on successful fits.",
|
|
565
|
+
BootstrapFailureWarning,
|
|
566
|
+
stacklevel=2,
|
|
567
|
+
)
|
|
568
|
+
return out
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# refit_bootstrap() sets these itself, for the original fit and every refit.
|
|
572
|
+
_CONTROLLED = {
|
|
573
|
+
"fml",
|
|
574
|
+
"data",
|
|
575
|
+
"panel",
|
|
576
|
+
"cohort",
|
|
577
|
+
"n_threads",
|
|
578
|
+
"max_memory_gb",
|
|
579
|
+
"memory_reserve",
|
|
580
|
+
"workdir",
|
|
581
|
+
"profile",
|
|
582
|
+
"batch_units",
|
|
583
|
+
"preprocess_mb",
|
|
584
|
+
"multiplier_bootstrap_reps",
|
|
585
|
+
"multiplier_bootstrap_seed",
|
|
586
|
+
"multiplier_bootstrap_weights",
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def refit_bootstrap(
|
|
591
|
+
fml,
|
|
592
|
+
data,
|
|
593
|
+
*,
|
|
594
|
+
panel,
|
|
595
|
+
cohort=None,
|
|
596
|
+
treatment=None,
|
|
597
|
+
estimator,
|
|
598
|
+
reps=199,
|
|
599
|
+
seed=None,
|
|
600
|
+
aggregate=None,
|
|
601
|
+
aggregate_options=None,
|
|
602
|
+
coefficients=None,
|
|
603
|
+
n_workers=1,
|
|
604
|
+
n_threads=None,
|
|
605
|
+
max_memory_gb=None,
|
|
606
|
+
memory_reserve=None,
|
|
607
|
+
workdir=None,
|
|
608
|
+
batch_units=256,
|
|
609
|
+
preprocess_mb=None,
|
|
610
|
+
**fit_options,
|
|
611
|
+
):
|
|
612
|
+
"""Bootstrap by resampling units and refitting the estimator.
|
|
613
|
+
|
|
614
|
+
Draws ``reps`` samples of the units with replacement (each copy of a unit
|
|
615
|
+
gets its own ID) and refits the whole estimator on each, including its
|
|
616
|
+
propensity scores, trimming and aggregation weights. Reports bootstrap
|
|
617
|
+
standard errors and percentile, basic or normal intervals for each
|
|
618
|
+
coefficient. The sample and comparison group chosen by the original fit
|
|
619
|
+
(incomplete units dropped, a last-treated reference window) are held
|
|
620
|
+
fixed across draws. This is slower than analytic standard errors, by
|
|
621
|
+
about ``reps`` fits; for CS, the multiplier bootstrap
|
|
622
|
+
(``multiplier_bootstrap_reps``) is far cheaper.
|
|
623
|
+
|
|
624
|
+
Parameters
|
|
625
|
+
----------
|
|
626
|
+
fml, data, panel, cohort
|
|
627
|
+
As for the estimator.
|
|
628
|
+
estimator : function
|
|
629
|
+
One of the estimator functions, e.g. ``did_bjs``.
|
|
630
|
+
reps : int, default 199
|
|
631
|
+
Bootstrap draws.
|
|
632
|
+
seed : int, optional
|
|
633
|
+
Seed for the draws. With the same data layout, the same seed gives
|
|
634
|
+
the same draws.
|
|
635
|
+
aggregate : {"event_time", "overall", "cohort", "calendar"}, optional
|
|
636
|
+
Bootstrap an aggregate (``result.aggregate(aggregate,
|
|
637
|
+
**aggregate_options)``) instead of the fitted coefficients. Not
|
|
638
|
+
available for ``did_two_stage``, whose coefficients are reported
|
|
639
|
+
directly.
|
|
640
|
+
aggregate_options : dict, optional
|
|
641
|
+
Options for ``aggregate()``, such as ``{"balanced_horizon": 2}``.
|
|
642
|
+
coefficients : list of str, optional
|
|
643
|
+
Bootstrap only these coefficients (exact names).
|
|
644
|
+
n_workers : int, default 1
|
|
645
|
+
Refits run in parallel, each in a fresh process. ``n_threads`` and
|
|
646
|
+
``max_memory_gb`` are shared out among them.
|
|
647
|
+
n_threads, max_memory_gb, memory_reserve, workdir, batch_units, preprocess_mb
|
|
648
|
+
As for the estimator; they cover the whole job.
|
|
649
|
+
**fit_options
|
|
650
|
+
The estimator's other options, e.g. ``adjustment="ipw"``.
|
|
651
|
+
|
|
652
|
+
Returns
|
|
653
|
+
-------
|
|
654
|
+
RefitBootstrapResult
|
|
655
|
+
``tidy()``, ``confint()`` and ``standard_errors()``. Failed draws are
|
|
656
|
+
recorded in ``failures()``, never redrawn; inference then requires
|
|
657
|
+
``allow_incomplete=True``.
|
|
658
|
+
"""
|
|
659
|
+
code = _ESTIMATOR_CODES.get(estimator)
|
|
660
|
+
if code is None:
|
|
661
|
+
raise TypeError(
|
|
662
|
+
"estimator must be one of diff-stream's estimator functions, such as did_bjs"
|
|
663
|
+
)
|
|
664
|
+
allowed = set(signature(estimator).parameters) - _CONTROLLED
|
|
665
|
+
unknown = sorted(set(fit_options) - allowed)
|
|
666
|
+
if any(name.startswith("multiplier_bootstrap") or name == "profile" for name in unknown):
|
|
667
|
+
raise ValueError(
|
|
668
|
+
"refit_bootstrap fits the model itself: profile and the multiplier bootstrap "
|
|
669
|
+
"options cannot be passed to it"
|
|
670
|
+
)
|
|
671
|
+
if unknown:
|
|
672
|
+
raise TypeError(f"{estimator.__name__} has no option {', '.join(unknown)}")
|
|
673
|
+
return _refit_bootstrap(
|
|
674
|
+
fml,
|
|
675
|
+
data,
|
|
676
|
+
panel=panel,
|
|
677
|
+
cohort=cohort,
|
|
678
|
+
treatment=treatment,
|
|
679
|
+
estimator=code,
|
|
680
|
+
public_estimator=estimator.__name__,
|
|
681
|
+
public_options=dict(fit_options),
|
|
682
|
+
reps=reps,
|
|
683
|
+
seed=seed,
|
|
684
|
+
aggregate=aggregate,
|
|
685
|
+
aggregate_options=aggregate_options,
|
|
686
|
+
coefficients=coefficients,
|
|
687
|
+
workers=n_workers,
|
|
688
|
+
threads=n_threads,
|
|
689
|
+
memory_gb=max_memory_gb,
|
|
690
|
+
memory_reserve=memory_reserve,
|
|
691
|
+
workdir=workdir,
|
|
692
|
+
batch_units=batch_units,
|
|
693
|
+
preprocess_mb=preprocess_mb,
|
|
694
|
+
**_options.internal(fit_options),
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
_ESTIMATOR_CODES = {
|
|
699
|
+
did_callaway_santanna: "cs",
|
|
700
|
+
did_sun_abraham: "sunab",
|
|
701
|
+
did_bjs: "bjs",
|
|
702
|
+
did_two_stage: "gardner",
|
|
703
|
+
did_extended_twfe: "etwfe",
|
|
704
|
+
}
|