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.
Files changed (56) hide show
  1. diff_stream/__init__.py +52 -0
  2. diff_stream/_bootstrap_worker.py +48 -0
  3. diff_stream/_core/__init__.py +5 -0
  4. diff_stream/_core/contrasts.py +199 -0
  5. diff_stream/_core/covariate_kernels.py +420 -0
  6. diff_stream/_core/cs_kernels.py +536 -0
  7. diff_stream/_core/cs_row_kernels.py +495 -0
  8. diff_stream/_core/design.py +105 -0
  9. diff_stream/_core/etwfe_kernels.py +288 -0
  10. diff_stream/_core/functionals.py +30 -0
  11. diff_stream/_core/multiplier.py +90 -0
  12. diff_stream/_core/nuisance.py +255 -0
  13. diff_stream/_core/partition.py +158 -0
  14. diff_stream/_core/process.py +61 -0
  15. diff_stream/_core/resample.py +171 -0
  16. diff_stream/_core/staging_kernels.py +90 -0
  17. diff_stream/_core/twostage_kernels.py +342 -0
  18. diff_stream/_core/weights.py +26 -0
  19. diff_stream/_core/within.py +81 -0
  20. diff_stream/_docs.py +100 -0
  21. diff_stream/_options.py +62 -0
  22. diff_stream/_present.py +202 -0
  23. diff_stream/absorbed.py +621 -0
  24. diff_stream/aggregation.py +225 -0
  25. diff_stream/api.py +1112 -0
  26. diff_stream/bacon.py +460 -0
  27. diff_stream/bacon_controls.py +188 -0
  28. diff_stream/balance.py +149 -0
  29. diff_stream/bjs.py +101 -0
  30. diff_stream/bjs_placebo.py +167 -0
  31. diff_stream/bootstrap.py +704 -0
  32. diff_stream/cohort_scores.py +68 -0
  33. diff_stream/cs.py +197 -0
  34. diff_stream/cs_unbalanced.py +336 -0
  35. diff_stream/effective_sample.py +73 -0
  36. diff_stream/estimators.py +95 -0
  37. diff_stream/etwfe.py +589 -0
  38. diff_stream/gardner.py +500 -0
  39. diff_stream/linear.py +339 -0
  40. diff_stream/periods.py +72 -0
  41. diff_stream/pretrend.py +97 -0
  42. diff_stream/py.typed +0 -0
  43. diff_stream/reference.py +125 -0
  44. diff_stream/results.py +521 -0
  45. diff_stream/simulate.py +80 -0
  46. diff_stream/stacked.py +405 -0
  47. diff_stream/store.py +706 -0
  48. diff_stream/twfe_store.py +181 -0
  49. diff_stream/twfe_weights.py +552 -0
  50. diff_stream/twostage_absorbed.py +190 -0
  51. diff_stream/unbalanced.py +297 -0
  52. diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
  53. diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
  54. diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
  55. diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
  56. diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -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
+ }