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
diff_stream/gardner.py ADDED
@@ -0,0 +1,500 @@
1
+ """Gardner's two-stage DiD with bounded unit elimination and adjusted inference."""
2
+
3
+ from copy import copy
4
+ from dataclasses import dataclass, field
5
+ from numbers import Integral
6
+ from pathlib import Path
7
+ from tempfile import TemporaryDirectory
8
+
9
+ import numpy as np
10
+ import polars as pl
11
+ import pyarrow as pa
12
+ from hdfe_stream.core.influence import ClusterScoreAccumulator
13
+ from hdfe_stream.core.profile import Component, Profile
14
+ from hdfe_stream.core.results_base import RegressionResult
15
+ from stream_limits import MemoryBudget, current_rss_gb, detect_cpus, thread_scope
16
+
17
+ from . import _docs, _options
18
+ from ._core import twostage_kernels
19
+ from ._core.nuisance import solve_checked
20
+ from ._core.partition import preprocess_budget_mb, staging_bytes
21
+ from ._core.within import row_sums
22
+ from ._present import internal, label_frame, labels_of, present, public_profile
23
+ from .cs import RETAIN_SHARE, RetainedBatches
24
+ from .linear import RowDesign
25
+ from .store import PanelStore, inspect
26
+
27
+
28
+ @dataclass(kw_only=True)
29
+ class TwoStageResult(RegressionResult):
30
+ """Direct second-stage coefficients, rather than cohort-time estimates.
31
+
32
+ Selection returns fitted coefficients without changing either regression.
33
+ No temporary store survives estimation.
34
+ """
35
+
36
+ n_units: int
37
+ _support: pl.DataFrame = field(repr=False)
38
+ _internal: dict | None = field(default=None, repr=False)
39
+ _periods: object = field(default=None, repr=False)
40
+ estimator = "gardner"
41
+ cells = ()
42
+ _normal_inference = True
43
+
44
+ def support(self):
45
+ """Observed cohort/date counts and original-scale weights per coefficient."""
46
+ return label_frame(self._support, labels_of(self))
47
+
48
+ def contributions(self):
49
+ """Support table used by the common pre-treatment Wald helper."""
50
+ return self.support()
51
+
52
+ def select(self, horizons):
53
+ """Select fitted event-time coefficients, keeping their joint covariance."""
54
+ if internal(self)["specification"] != "dynamic":
55
+ raise ValueError("horizon selection requires an event-study fit (event_study=True)")
56
+ try:
57
+ requested = list(horizons)
58
+ except TypeError as exc:
59
+ raise ValueError("horizons must contain fitted integer event times") from exc
60
+ if not requested or any(
61
+ isinstance(h, bool) or not isinstance(h, Integral) for h in requested
62
+ ):
63
+ raise ValueError("horizons must contain fitted integer event times")
64
+ names = [f"event::{int(h)}" for h in sorted(set(requested))]
65
+ if not set(names) <= set(self.coefnames):
66
+ raise ValueError(
67
+ "requested horizons were not estimated (possibly the reference period)"
68
+ )
69
+ ix = [self.coefnames.index(name) for name in names]
70
+ out = copy(self)
71
+ out.coefnames = names
72
+ out.beta = self.beta[ix].copy()
73
+ out.vcov = self.vcov[np.ix_(ix, ix)].copy()
74
+ out.all_vcovs = {self.vcov_type: (out.vcov, self.df_t)}
75
+ out._support = self._support.filter(pl.col("aggregate").is_in(names))
76
+ out.diagnostics = {
77
+ **self.diagnostics,
78
+ "selected_horizons": sorted(set(map(int, requested))),
79
+ }
80
+ return out
81
+
82
+ def pretrend_test(self, *, level="auto", horizons=None):
83
+ from .pretrend import pretrend_test
84
+
85
+ return pretrend_test(self, level=level, horizons=horizons)
86
+
87
+ def with_vcov(self, vcov):
88
+ if vcov not in ("CRV0", "CRV1"):
89
+ raise ValueError("vcov must be 'CRV0' or 'CRV1'")
90
+ factor = self.n_units / (self.n_units - 1)
91
+ ratio = (factor if vcov == "CRV1" else 1) / (factor if self.vcov_type == "CRV1" else 1)
92
+ out = copy(self)
93
+ out.vcov = self.vcov * ratio
94
+ out.vcov_type = vcov
95
+ out.all_vcovs = {vcov: (out.vcov, out.df_t)}
96
+ return out
97
+
98
+
99
+ def second_stage_support(store, specification, reference, anticipation):
100
+ """The second stage's coefficients: (support table rows, event times, names).
101
+
102
+ Static fits have one indicator for affected observations (t >=
103
+ g-anticipation); dynamic fits one per observed event time except the
104
+ reference.
105
+ """
106
+ support = []
107
+ scale = store.weight_scale / store.weight_normalizer
108
+ for g, counts in sorted(store.cell_counts.items()):
109
+ if not g:
110
+ continue
111
+ for ti, t in enumerate(store.times):
112
+ event = int(t) - g
113
+ if not counts[ti] or (specification == "static" and event < -anticipation):
114
+ continue
115
+ if specification == "dynamic" and event == reference:
116
+ continue
117
+ support.append(
118
+ dict(
119
+ aggregate="ATT" if specification == "static" else f"event::{event}",
120
+ cohort=int(g),
121
+ time=int(t),
122
+ event_time=event,
123
+ observed_units=int(counts[ti]),
124
+ weight_total=float(store.cell_weights[g][ti] * scale),
125
+ )
126
+ )
127
+ if not support:
128
+ raise ValueError("no supported Gardner second-stage coefficients")
129
+ horizons = sorted({r["event_time"] for r in support}) if specification == "dynamic" else []
130
+ names = [f"event::{h}" for h in horizons] if specification == "dynamic" else ["ATT"]
131
+ return support, horizons, names
132
+
133
+
134
+ def _fit(store, specification, reference, anticipation):
135
+ model = RowDesign(store)
136
+ compiled = twostage_kernels.ENABLED
137
+ source = RetainedBatches(
138
+ model.raw_batches,
139
+ retain=compiled
140
+ and store.n_units * store.periods * 8 * (2 + len(model.names))
141
+ <= RETAIN_SHARE * store.budget.headroom_gb() * 1e9,
142
+ )
143
+ Q, q = model.reduce(
144
+ clean=lambda g: (g[:, None] == 0) | (store.times < g[:, None] - anticipation),
145
+ source=source,
146
+ )
147
+ support, horizons, names = second_stage_support(store, specification, reference, anticipation)
148
+ P, M = len(model.keep), len(names)
149
+ theta = solve_checked(Q, q, "Gardner untreated covariate/fixed-effect design is unidentified")
150
+
151
+ def batches():
152
+ for g, y, X, w in model.batches():
153
+ obs, _, xm, u1, score1 = model.residuals(
154
+ g, y, X, w, theta, anticipation, label="Gardner"
155
+ )
156
+ event = store.times[None, :] - g[:, None]
157
+ if specification == "static":
158
+ Z = ((g[:, None] != 0) & (event >= -anticipation))[:, :, None]
159
+ else:
160
+ Z = (g[:, None, None] != 0) & (event[:, :, None] == np.asarray(horizons))
161
+ Z = (Z & obs[:, :, None]).astype(np.float64)
162
+ yield X - xm[:, None], Z, w, u1, score1
163
+
164
+ if compiled:
165
+ kernels = twostage_kernels.TwoStageKernels(store.times, anticipation)
166
+ dynamic = specification == "dynamic"
167
+ marks = np.asarray(horizons if dynamic else [0], dtype=np.int64)
168
+ mass, rhs, C = kernels.second_stage(source, model.keep, theta, marks, dynamic)
169
+ if np.any(mass <= 0):
170
+ raise ValueError("Gardner second-stage design is unidentified")
171
+ beta = rhs / mass
172
+ H = solve_checked(Q, C.T, "Gardner first-stage inference design is unidentified")
173
+ accumulator = ClusterScoreAccumulator()
174
+ for raw in source():
175
+ accumulator.add_clusters(
176
+ kernels.scores(raw, model.keep, theta, marks, dynamic, beta, H, mass)
177
+ )
178
+ return beta, accumulator.sums, names, pl.DataFrame(support), model
179
+
180
+ # Unit first-stage scores vanish exactly after clean-sample elimination.
181
+ # The Schur-complement cross derivative uses X - clean-unit-mean(X),
182
+ # even though the second-stage regression itself does not demean Z.
183
+ mass, rhs, C = np.zeros(M), np.zeros(M), np.zeros((M, P))
184
+ for xc, Z, w, u1, _ in batches():
185
+ flat = Z.reshape(-1, M)
186
+ mass += flat.T @ w.ravel()
187
+ rhs += flat.T @ (w * u1).ravel()
188
+ C += flat.T @ (w.reshape(-1, 1) * xc.reshape(-1, xc.shape[2]))
189
+ if np.any(mass <= 0):
190
+ raise ValueError("Gardner second-stage design is unidentified")
191
+ beta = rhs / mass # Supported indicators are mutually exclusive; no intercept.
192
+ H = solve_checked(Q, C.T, "Gardner first-stage inference design is unidentified")
193
+ accumulator = ClusterScoreAccumulator()
194
+ for _, Z, w, u1, score1 in batches():
195
+ u2 = u1 - Z @ beta
196
+ score2 = row_sums(w * u2, Z)
197
+ accumulator.add_clusters((score2 - score1 @ H) / mass)
198
+ return beta, accumulator.sums, names, pl.DataFrame(support), model
199
+
200
+
201
+ def _two_stage(
202
+ fml,
203
+ data,
204
+ *,
205
+ panel,
206
+ cohort=None,
207
+ treatment=None,
208
+ specification="dynamic",
209
+ reference_period=None,
210
+ vcov="CRV0",
211
+ batch_units=4096,
212
+ threads=None,
213
+ memory_gb=None,
214
+ memory_reserve=None,
215
+ workdir=None,
216
+ profile=False,
217
+ preprocess_mb=None,
218
+ panel_mode="balanced",
219
+ anticipation=0,
220
+ weights=None,
221
+ allow_time_varying_weights=False,
222
+ reference_group="auto",
223
+ fe_solver="auto",
224
+ ):
225
+ """Two-stage DiD from internal option names; see did_two_stage.
226
+
227
+ Static fits estimate one indicator for affected observations (t >=
228
+ g-anticipation); dynamic fits every observed event time except the
229
+ reference. There is no second-stage intercept: the omitted reference,
230
+ never-treated rows and a last-treated reference cohort (with its leads)
231
+ have a zero second-stage design. The covariance clusters unit scores
232
+ corrected for the estimated first stage.
233
+ """
234
+ if specification not in ("static", "dynamic"):
235
+ raise ValueError("specification must be 'static' or 'dynamic'")
236
+ if reference_group not in ("auto", "nevertreated", "lasttreated"):
237
+ raise ValueError("reference_group must be auto, nevertreated or lasttreated")
238
+ if (
239
+ isinstance(anticipation, (bool, np.bool_))
240
+ or not isinstance(anticipation, Integral)
241
+ or not 0 <= anticipation < np.iinfo(np.int64).max
242
+ ):
243
+ raise ValueError("anticipation must be a nonnegative integer within the int64 range")
244
+ anticipation = int(anticipation)
245
+ if reference_period is not None and (
246
+ specification != "dynamic"
247
+ or isinstance(reference_period, (bool, np.bool_))
248
+ or not isinstance(reference_period, Integral)
249
+ or not -np.iinfo(np.int64).max <= reference_period < -anticipation
250
+ ):
251
+ raise ValueError(
252
+ "reference_period must be a clean integer event time, with event_study=True"
253
+ )
254
+ reference = -anticipation - 1 if reference_period is None else int(reference_period)
255
+ if vcov not in ("CRV0", "CRV1"):
256
+ raise ValueError("vcov must be 'CRV0' or 'CRV1'")
257
+ if panel_mode not in ("balanced", "complete", "unbalanced"):
258
+ raise ValueError("panel_mode must be 'balanced', 'complete' or 'unbalanced'")
259
+ for value, name in [(batch_units, "batch_units"), (threads, "n_threads")]:
260
+ if value is not None and (
261
+ isinstance(value, bool) or not isinstance(value, int) or value < 1
262
+ ):
263
+ raise ValueError(f"{name} must be a positive integer")
264
+ if batch_units is None:
265
+ raise ValueError("batch_units must be a positive integer")
266
+ if not isinstance(allow_time_varying_weights, bool):
267
+ raise ValueError("allow_time_varying_weights must be boolean")
268
+ budget = MemoryBudget(memory_gb, memory_reserve)
269
+ preprocess_mb = preprocess_budget_mb(preprocess_mb, budget)
270
+ baseline = current_rss_gb() or 0
271
+ with thread_scope(threads):
272
+ if fe_solver not in ("auto", "dense", "iterative"):
273
+ raise ValueError("fixed_effect_solver must be 'auto', 'dense' or 'iterative'")
274
+ lf, yname, design, fe_terms, iterative = inspect(
275
+ fml,
276
+ data,
277
+ panel,
278
+ cohort,
279
+ treatment=treatment,
280
+ allow_covariates=True,
281
+ weights=weights,
282
+ allow_fe=True,
283
+ fe_solver=fe_solver,
284
+ )
285
+ if not budget.fits((preprocess_mb * 1e6 + 96e6) / 1e9):
286
+ raise MemoryError(
287
+ "preprocessing buffers exceed job headroom before staging; reduce preprocess_mb or request more memory"
288
+ )
289
+ if workdir is not None:
290
+ Path(workdir).mkdir(parents=True, exist_ok=True)
291
+ with TemporaryDirectory(prefix="diff-stream-gardner-", dir=workdir) as run:
292
+ store = PanelStore(
293
+ lf,
294
+ run,
295
+ batch_units,
296
+ budget,
297
+ preprocess_mb,
298
+ design,
299
+ panel_mode,
300
+ anticipation,
301
+ allow_time_varying_weights=allow_time_varying_weights,
302
+ require_never_treated=reference_group == "nevertreated",
303
+ )
304
+ from .reference import reference_store
305
+
306
+ store, reference_metadata = reference_store(store, reference_group, anticipation)
307
+ pa.default_memory_pool().release_unused()
308
+ T, B = store.periods, min(batch_units, store.n_units)
309
+ K = T - 1 + (len(design.names) if design is not None else 0)
310
+ events = {
311
+ int(t) - g
312
+ for g, counts in store.cell_counts.items()
313
+ if g
314
+ for t, n in zip(store.times, counts, strict=True)
315
+ if n
316
+ }
317
+ if specification == "dynamic" and reference not in events:
318
+ raise ValueError("reference_period is not observed in the retained treated cohorts")
319
+ M = len(events - {reference}) if specification == "dynamic" else 1
320
+ numeric = 8 * (10 * K * K + 6 * M * M + 8 * K * M + 12 * B * T * (K + M + T))
321
+ diagnostics = dict(
322
+ **reference_metadata,
323
+ specification=specification,
324
+ aggregation="event_time" if specification == "dynamic" else "static",
325
+ anticipation=anticipation,
326
+ reference_event_time=reference if specification == "dynamic" else None,
327
+ reference_second_stage="zero for designated reference cohorts, including their leads",
328
+ time_min=int(store.times[0]),
329
+ time_max=int(store.times[-1]),
330
+ base_period="zero second-stage design for omitted reference and never-treated observations; no intercept",
331
+ panel_mode=panel_mode,
332
+ balanced_sample=store.is_balanced,
333
+ n_units_input=store.n_units_input,
334
+ dropped_units=store.n_units_input - store.n_units,
335
+ n_rows_used=store.n_rows_used,
336
+ sampling_weights=weights,
337
+ weights_time_invariant=store.weights_time_invariant,
338
+ weight_convention="observation WLS in both stages; unit clustering",
339
+ partitions=store.partition_count,
340
+ max_decoded_leaf_bytes=store.max_leaf_bytes,
341
+ preprocess_mb=preprocess_mb,
342
+ inference="unit scores with first-stage correction; CRV1 is N/(N-1) only",
343
+ )
344
+ if profile:
345
+ return Profile(
346
+ model="diff-stream gardner",
347
+ threads=threads or detect_cpus(),
348
+ memory_limit_gb=budget.limit_gb,
349
+ memory_reserve=budget.reserve,
350
+ polars_threads=pl.thread_pool_size(),
351
+ baseline_gb=baseline,
352
+ components=[
353
+ Component(
354
+ "numerical buffers and runtime", numeric + 192e6, numeric * 1.5 + 256e6
355
+ ),
356
+ Component(
357
+ "bounded partition preprocessing",
358
+ staging_bytes(preprocess_mb, store) + 96e6,
359
+ preprocess_mb * 1e6 + 256e6,
360
+ ),
361
+ Component(
362
+ "snapshot and sorted partitions",
363
+ 64 * store.n_units * T,
364
+ 128 * store.n_units * T,
365
+ where="disk",
366
+ ),
367
+ ],
368
+ notes={
369
+ **diagnostics,
370
+ "n_units": store.n_units,
371
+ "periods": T,
372
+ "coefficients": M,
373
+ },
374
+ warnings=[
375
+ "Heuristic; third-party source operators and allocators are not hard capped."
376
+ ],
377
+ )
378
+ if not budget.fits(numeric / 1e9 + 0.128):
379
+ raise MemoryError(
380
+ "estimated numerical buffers exceed job headroom; reduce batch_units or request more memory"
381
+ )
382
+ if iterative:
383
+ from .twostage_absorbed import fit_two_stage_absorbed
384
+
385
+ support, horizons, names = second_stage_support(
386
+ store, specification, reference, anticipation
387
+ )
388
+ beta, V, absorbed_info = fit_two_stage_absorbed(
389
+ store, fe_terms, specification, horizons, anticipation, threads=threads
390
+ )
391
+ fit_info = {
392
+ "first_stage_columns": absorbed_info["first_stage_columns"],
393
+ "absorbed_or_redundant_columns": absorbed_info["absorbed_or_redundant_columns"],
394
+ "fixed_effect_solver": "iterative",
395
+ "absorbed_terms": absorbed_info["absorbed_terms"],
396
+ "regression_backend": absorbed_info["regression_backend"],
397
+ }
398
+ support = pl.DataFrame(support)
399
+ else:
400
+ beta, V, names, support, model = _fit(store, specification, reference, anticipation)
401
+ fit_info = {
402
+ "first_stage_columns": [model.names[j] for j in model.keep],
403
+ "absorbed_or_redundant_columns": model.dropped,
404
+ "fixed_effect_solver": "dense",
405
+ }
406
+ V *= store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1
407
+ V = (V + V.T) / 2
408
+ result = TwoStageResult(
409
+ fml=fml,
410
+ depvar=yname,
411
+ coefnames=names,
412
+ beta=beta,
413
+ vcov=V,
414
+ vcov_type=vcov,
415
+ df_t=store.n_units - 1,
416
+ n_obs=store.n_rows_used,
417
+ n_units=store.n_units,
418
+ n_clusters={panel[0]: store.n_units},
419
+ all_vcovs={vcov: (V, store.n_units - 1)},
420
+ weights=weights,
421
+ _support=support,
422
+ diagnostics={
423
+ **diagnostics,
424
+ **fit_info,
425
+ "warnings": [],
426
+ },
427
+ )
428
+ return present(result, store.period_labels)
429
+
430
+
431
+ @_docs.document
432
+ def did_two_stage(
433
+ fml,
434
+ data,
435
+ *,
436
+ panel,
437
+ cohort=None,
438
+ treatment=None,
439
+ event_study=True,
440
+ reference_period=None,
441
+ anticipation=0,
442
+ weights=None,
443
+ allow_time_varying_weights=False,
444
+ incomplete_units="error",
445
+ last_treated_controls="if_needed",
446
+ vcov="CRV0",
447
+ fixed_effect_solver="auto",
448
+ n_threads=None,
449
+ max_memory_gb=None,
450
+ memory_reserve=None,
451
+ workdir=None,
452
+ profile=False,
453
+ batch_units=4096,
454
+ preprocess_mb=None,
455
+ ):
456
+ """Gardner's two-stage difference-in-differences.
457
+
458
+ The first stage fits unit and time fixed effects (and any covariates) on
459
+ the untreated observations only; the second stage regresses the
460
+ first-stage residuals of all observations on treatment indicators. The
461
+ standard errors account for the estimated first stage. The point
462
+ estimates match BJS imputation in many designs; the inference differs.
463
+
464
+ Gardner, J. (2022). Two-stage differences in differences. arXiv:2207.05943.
465
+
466
+ Butts, K. and Gardner, J. (2022). did2s: two-stage
467
+ difference-in-differences. *The R Journal* 14(3), 162-173.
468
+
469
+ Parameters
470
+ ----------
471
+ fml : str
472
+ ``"y ~ 1"``; first-stage covariates ``"y ~ x"``; additional fixed
473
+ effects after a bar, ``"y ~ x | region^time"``. Unit and time fixed
474
+ effects are always included. Fixed effects with many levels
475
+ (county-by-year) are absorbed iteratively; see ``fixed_effect_solver``.
476
+ {DATA}
477
+ event_study : bool, default True
478
+ Estimate one coefficient per event time (an event study). False
479
+ estimates a single overall effect of treatment.
480
+ reference_period : int, optional
481
+ With ``event_study``, the omitted pre-treatment event time. Default:
482
+ the last clean period, ``-anticipation - 1``.
483
+ {ANTICIPATION}
484
+ {WEIGHTS}
485
+ {INCOMPLETE_UNITS}
486
+ {LAST_TREATED}
487
+ {VCOV}
488
+ {FIXED_EFFECT_SOLVER}
489
+ {RESOURCES}
490
+
491
+ Returns
492
+ -------
493
+ TwoStageResult or Profile
494
+ The second-stage coefficients (``event::h`` or ``ATT``) and their
495
+ joint covariance. ``select(horizons)`` reports a subset of event
496
+ times without refitting.
497
+ """
498
+ options = dict(locals())
499
+ fml, data = options.pop("fml"), options.pop("data")
500
+ return public_profile(_two_stage(fml, data, **_options.internal(options)))