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,621 @@
1
+ """Sun-Abraham and BJS with the fixed effects absorbed iteratively by hdfe-stream.
2
+
3
+ Sun-Abraham is one hdfe-stream regression: the cohort-period indicators are a
4
+ single categorical, with the reference (never-treated units and every cohort's
5
+ base period) omitted.
6
+
7
+ For BJS, the untreated model (unit, time and additional fixed effects, covariates) is
8
+ fitted by hdfe-stream on the untreated rows, and its predictions impute the
9
+ treated rows' untreated outcomes. Standard errors are Borusyak, Jaravel and
10
+ Spiess's: each unit's score sums v_it * e_it, where v are the imputation
11
+ weights and e the residuals (treated residuals net of their averages within
12
+ ``residual_averaging`` groups).
13
+
14
+ An untreated row's weight in a cell's estimate is -w_it z_it'u_j, with
15
+ u_j = S^- Z_j'v_j the solution of the untreated model's normal equations for
16
+ the cell's treated design (``HDFEResult.solve_normal_equations``). A unit's
17
+ untreated score is therefore linear in the aggregation weights: it is
18
+ computed once per cell, kept on disk next to the residuals, and combined for
19
+ any aggregation. Only the treated part, whose residual averages depend on the
20
+ aggregation, is recomputed.
21
+ """
22
+
23
+ import warnings
24
+ from tempfile import TemporaryDirectory
25
+
26
+ import numpy as np
27
+ import polars as pl
28
+
29
+ from ._core.partition import whole_group_batches
30
+
31
+ _DROPPED = ".*variables dropped due to multicollinearity"
32
+
33
+
34
+ def staged(store, terms, paths=None):
35
+ """The staged rows under the names the hdfe-stream formula uses.
36
+
37
+ hdfe-stream reserves names starting with "__", so the staged covariate
38
+ and fixed-effect columns are renamed (``__x0`` to ``x0``, ``__fe0`` to
39
+ ``fe0``).
40
+ """
41
+ covariates = list(store.design.names) if store.design is not None else []
42
+ xs = [f"x{j}" for j in range(len(covariates))]
43
+ effects = sorted({c for term, _ in terms for c in term.split("^")} - {"t"})
44
+ columns = ["u", "t", "g", "y", *(["w"] if store.weighted else [])]
45
+ frame = pl.scan_parquet(paths or store.paths, low_memory=True).select(
46
+ *columns,
47
+ *[pl.col(f"__x{j}").alias(x) for j, x in enumerate(xs)],
48
+ *[pl.col(c).alias(c[2:]) for c in effects],
49
+ )
50
+ absorbed = [term.replace("__fe", "fe") for term, _ in terms]
51
+ return frame, covariates, xs, absorbed
52
+
53
+
54
+ # hdfe-stream's row batch for models with fixed effects is 2,000,000 rows,
55
+ # whatever the design's width; a batch holds about four float64 copies of it.
56
+ DEFAULT_BATCH_ROWS = 2_000_000
57
+ BATCH_BYTES_PER_VALUE = 32
58
+
59
+ # hdfe-stream solves the normal equations for a block of columns at a time, one
60
+ # pass over the rows per block. A block holds about this many bytes per unit and
61
+ # column (the grouped sums, their copies and the solution), measured at 3 million
62
+ # units: 115 MB for a column.
63
+ SOLVE_BYTES_PER_UNIT_COLUMN = 40
64
+ SOLVE_BLOCK = 16 # hdfe-stream's default; fewest passes
65
+
66
+
67
+ def solve_block(store, columns):
68
+ """Columns per block for the normal-equations solve: hdfe-stream's default
69
+ when half the memory left holds it, fewer (at least one) when it does not."""
70
+ room = 0.5 * store.budget.headroom_gb() * 1e9
71
+ fit = int(room // (SOLVE_BYTES_PER_UNIT_COLUMN * max(store.n_units, 1)))
72
+ return max(1, min(SOLVE_BLOCK, columns, fit))
73
+
74
+
75
+ def batch_rows(store, width):
76
+ """hdfe-stream's row batch: its default when that fits a quarter of the
77
+ memory left, fewer rows (at least 50,000) when it does not."""
78
+ room = 0.25 * store.budget.headroom_gb() * 1e9
79
+ per_row = BATCH_BYTES_PER_VALUE * (width + 2)
80
+ if DEFAULT_BATCH_ROWS * per_row <= room:
81
+ return None
82
+ return max(50_000, int(room / per_row))
83
+
84
+
85
+ def hdfe_options(store, threads, width, **options):
86
+ """hdfe-stream options: diff-stream's memory budget, threads and weights.
87
+
88
+ ``width`` is the number of columns in the regression's design.
89
+ """
90
+ rows = batch_rows(store, width)
91
+ if rows is not None:
92
+ options["batch_rows"] = rows
93
+ options.update(
94
+ verbose=False,
95
+ fixef_rm="none",
96
+ max_memory_gb=store.budget.limit_gb,
97
+ memory_reserve=store.budget.reserve,
98
+ )
99
+ if store.weighted:
100
+ options["weights"] = "w"
101
+ if threads is not None:
102
+ options["n_threads"] = threads
103
+ return options
104
+
105
+
106
+ def untreated(anticipation):
107
+ return (pl.col("g") == 0) | (pl.col("t") < pl.col("g") - anticipation)
108
+
109
+
110
+ def fit_sunab_absorbed(store, cells, terms, *, threads=None):
111
+ """Sun-Abraham's regression; returns CRV0 by unit."""
112
+ from hdfe_stream import feols_stream
113
+
114
+ keys = pl.DataFrame(
115
+ {
116
+ "g": [g for g, _ in cells],
117
+ "t": [t for _, t in cells],
118
+ "cell": [f"c{j}" for j in range(len(cells))],
119
+ },
120
+ schema={"g": pl.Int64, "t": pl.Int64, "cell": pl.String},
121
+ )
122
+ frame, covariates, xs, absorbed = staged(store, terms)
123
+ data = (
124
+ frame.with_columns(pl.col("g").cast(pl.Int64), pl.col("t").cast(pl.Int64))
125
+ .join(keys.lazy(), on=["g", "t"], how="left")
126
+ .with_columns(pl.col("cell").fill_null("ref"))
127
+ )
128
+ rhs = " + ".join([*xs, "i(cell, ref='ref')"])
129
+ fml = f"y ~ {rhs} | " + " + ".join(["u", "t", *absorbed])
130
+ with warnings.catch_warnings():
131
+ warnings.filterwarnings("ignore", _DROPPED)
132
+ fit = feols_stream(
133
+ fml,
134
+ data,
135
+ workdir=store.root,
136
+ **hdfe_options(
137
+ store, threads, len(xs) + len(cells), vcov={"CRV1": "u"}, save_resid=False
138
+ ),
139
+ )
140
+ try:
141
+ names = list(fit.coefnames)
142
+ wanted = [f"cell::c{j}" for j in range(len(cells))]
143
+ missing = [cells[j] for j, n in enumerate(wanted) if n not in names]
144
+ if missing:
145
+ raise ValueError(
146
+ f"Sun-Abraham cohort-period effects {missing} are collinear with the fixed effects"
147
+ )
148
+ ix = [names.index(n) for n in wanted]
149
+ beta = np.asarray(fit.beta, dtype=float)[ix]
150
+ factor = fit.diagnostics["vcov_factors"]["CRV1:u"]
151
+ V = np.asarray(fit.vcov, dtype=float)[np.ix_(ix, ix)] / factor
152
+ finally:
153
+ fit.cleanup()
154
+ counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells])
155
+ return beta, counts, (V + V.T) / 2, info(covariates, xs, names, terms)
156
+
157
+
158
+ def info(covariates, xs, names, terms):
159
+ """Diagnostics of an absorbed fit."""
160
+ dropped = [n for n, x in zip(covariates, xs, strict=True) if x not in names]
161
+ return dict(
162
+ regression_backend="hdfe-stream alternating projections; fixed effects absorbed",
163
+ absorbed_terms=[written for _, written in terms],
164
+ regression_columns=[n for n in covariates if n not in dropped],
165
+ absorbed_or_redundant_columns=dropped,
166
+ )
167
+
168
+
169
+ def fit_bjs_absorbed(
170
+ store, cells, terms, avgeffectsby, leaveout, workdir, anticipation=0, *, threads=None
171
+ ):
172
+ from hdfe_stream import feols_stream
173
+
174
+ frame, covariates, xs, absorbed = staged(store, terms)
175
+ fml = f"y ~ {' + '.join(xs) or '1'} | " + " + ".join(["u", "t", *absorbed])
176
+ options = hdfe_options(store, threads, len(xs), vcov="iid", keep_intermediates=True)
177
+ with warnings.catch_warnings():
178
+ warnings.filterwarnings("ignore", _DROPPED)
179
+ first = feols_stream(
180
+ fml, frame.filter(untreated(anticipation)), workdir=store.root, **options
181
+ )
182
+ try:
183
+ context = AbsorbedBJSInference(
184
+ store, cells, first, terms, avgeffectsby, leaveout, workdir, anticipation
185
+ )
186
+ finally:
187
+ first.cleanup()
188
+ try:
189
+ V = context.covariance(np.eye(len(cells)), allow_unidentified=avgeffectsby != "cohort_time")
190
+ except BaseException:
191
+ context.cleanup()
192
+ raise
193
+ return context.beta, context.counts, V, context, info(covariates, xs, first.coefnames, terms)
194
+
195
+
196
+ class AbsorbedBJSInference:
197
+ """Residuals and per-cell untreated unit scores, on disk, for aggregation.
198
+
199
+ The staged rows are rewritten with the residual (the outcome less its
200
+ imputed untreated outcome), and a memory-mapped array holds, per unit, the
201
+ untreated score of every cell. ``covariance`` makes two passes over them.
202
+ """
203
+
204
+ def __init__(self, store, cells, first, terms, avgeffectsby, leaveout, workdir, anticipation):
205
+ self.workspace = TemporaryDirectory(prefix="diff-stream-bjs-", dir=workdir)
206
+ self.store, self.cells = store, cells
207
+ self.avgeffectsby, self.leaveout = avgeffectsby, leaveout
208
+ self.anticipation = anticipation
209
+ self.closed = False
210
+ M, times = len(cells), store.times
211
+ keys = pl.DataFrame(
212
+ {"g": [g for g, _ in cells], "t": [t for _, t in cells], "cell": range(M)},
213
+ schema={"g": pl.Int64, "t": pl.Int64, "cell": pl.Int64},
214
+ )
215
+ weight = pl.col("w") if store.weighted else pl.lit(1.0)
216
+
217
+ # Residuals: the outcome less the imputed untreated outcome, in one
218
+ # streaming pass that keeps the staged (unit, period) order.
219
+ frame, _, xs, absorbed = staged(store, terms)
220
+ residuals = f"{self.workspace.name}/residuals.parquet"
221
+ (
222
+ first.predict(frame, name="prediction")
223
+ .with_columns(pl.col("g").cast(pl.Int64), pl.col("t").cast(pl.Int64))
224
+ .join(keys.lazy(), on=["g", "t"], how="left", maintain_order="left")
225
+ .with_columns(
226
+ (pl.col("y") - pl.col("prediction")).alias("r"),
227
+ untreated(anticipation).alias("untreated"),
228
+ )
229
+ .drop("prediction")
230
+ .sink_parquet(residuals, row_group_size=store.read_rows, engine="streaming")
231
+ )
232
+ unidentified = pl.scan_parquet(residuals).select(pl.col("r").null_count()).collect().item()
233
+ if unidentified:
234
+ raise ValueError(
235
+ f"BJS imputation is not identified for {unidentified:,} treated "
236
+ "observations: a fixed-effect level of theirs has no untreated observations"
237
+ )
238
+ for path in store.paths:
239
+ path.unlink()
240
+ store.paths = self.paths = [residuals]
241
+
242
+ cell_rows = pl.scan_parquet(residuals).filter(pl.col("cell").is_not_null())
243
+ sums = (
244
+ cell_rows.group_by("cell")
245
+ .agg(
246
+ (weight * pl.col("r")).sum().alias("effect"),
247
+ (pl.col("w").sum() if store.weighted else pl.len().cast(pl.Float64)).alias("mass"),
248
+ )
249
+ .collect()
250
+ .sort("cell")
251
+ )
252
+ mass = np.zeros(M)
253
+ mass[sums["cell"].to_numpy()] = sums["mass"].to_numpy()
254
+ effect = np.zeros(M)
255
+ effect[sums["cell"].to_numpy()] = sums["effect"].to_numpy()
256
+ self.beta = np.divide(effect, mass, out=np.full(M, np.nan), where=mass > 0)
257
+ self.mass = mass
258
+ self.counts = np.array([store.cell_weights[g][t - times[0]] for g, t in cells])
259
+
260
+ # The untreated model's normal equations, solved for every cell's
261
+ # treated design. Untreated weights are -w z'u_j.
262
+ names = [f"v{j}" for j in range(M)]
263
+ target = cell_rows.select(
264
+ "u",
265
+ "t",
266
+ *[c for c in first_columns(first) if c not in ("u", "t")],
267
+ *[
268
+ pl.when(pl.col("cell") == j).then(weight / mass[j]).otherwise(0.0).alias(n)
269
+ for j, n in enumerate(names)
270
+ ],
271
+ )
272
+ solution = first.solve_normal_equations(target, names, block=solve_block(store, M))
273
+ try:
274
+ self._untreated_scores(solution, names, xs, absorbed)
275
+ finally:
276
+ solution.cleanup()
277
+
278
+ def _untreated_scores(self, solution, names, xs, absorbed):
279
+ """Per unit and cell: -sum over untreated rows of w e z'u_j.
280
+
281
+ The unit effects drop out: the untreated model's normal equation for a
282
+ unit's effect makes its untreated rows' weighted residuals sum to zero.
283
+ """
284
+ store, M = self.store, len(names)
285
+ tables = []
286
+ for term in absorbed_terms(absorbed):
287
+ table = solution.fixef(term).collect()
288
+ keys = [c for c in table.columns if c not in names]
289
+ tables.append((keys, table.with_row_index("__level"), table.select(names).to_numpy()))
290
+ kept = solution.coef["coefficient"].to_list()
291
+ coef = solution.coef.select(names).to_numpy()
292
+ # Staged units are numbered 0, ..., N-1: the unit is the row.
293
+ self.scores = f"{self.workspace.name}/scores.npy"
294
+ scores = np.lib.format.open_memmap(
295
+ self.scores, mode="w+", dtype=np.float64, shape=(store.n_units, M)
296
+ )
297
+ for path in self.paths:
298
+ store._guard(32e6 + 8 * M * 4 * store.batch_units * store.periods)
299
+ for d in whole_group_batches(path, "u", store.batch_units, store.read_rows):
300
+ d = d.filter(pl.col("untreated"))
301
+ if not d.height:
302
+ continue
303
+ q = np.zeros((d.height, M))
304
+ for keys, table, values in tables:
305
+ level = (
306
+ d.select(keys)
307
+ .with_columns(pl.col(c).cast(table.schema[c]) for c in keys)
308
+ .join(
309
+ table.select(*keys, "__level"),
310
+ on=keys,
311
+ how="left",
312
+ maintain_order="left",
313
+ )["__level"]
314
+ .to_numpy()
315
+ )
316
+ q += values[level]
317
+ if kept:
318
+ q += d.select(kept).to_numpy() @ coef
319
+ w = d["w"].to_numpy() if store.weighted else np.ones(d.height)
320
+ contribution = -(w * d["r"].to_numpy())[:, None] * q
321
+ u = d["u"].to_numpy()
322
+ starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
323
+ scores[u[starts]] = np.add.reduceat(contribution, starts, axis=0)
324
+ scores.flush()
325
+ del scores
326
+
327
+ def cleanup(self):
328
+ self.workspace.cleanup()
329
+ self.closed = True
330
+
331
+ def covariance(self, contrasts, *, allow_unidentified=False):
332
+ if self.closed:
333
+ raise ValueError("BJS aggregation data were removed by cleanup(); refit to aggregate")
334
+ store = self.store
335
+ A = np.atleast_2d(contrasts)
336
+ P, M = A.shape
337
+ T, t0 = store.periods, int(store.times[0])
338
+ labels, groups = {}, {}
339
+ for j, (g, t) in enumerate(self.cells):
340
+ label = {"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
341
+ self.avgeffectsby
342
+ ]
343
+ r = labels.setdefault(label, len(labels))
344
+ groups.setdefault((g, r), []).append(j)
345
+ R = len(labels)
346
+ weights = {key: A[:, js] / self.mass[js] for key, js in groups.items()}
347
+ columns = ["u", "t", "g", "r", *(["w"] if store.weighted else [])]
348
+
349
+ def batches():
350
+ untreated = np.load(self.scores, mmap_mode="r")
351
+ for path in self.paths:
352
+ for d in whole_group_batches(path, "u", store.batch_units, store.read_rows):
353
+ d = d.select(columns)
354
+ u = d["u"].to_numpy()
355
+ starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
356
+ row = np.repeat(np.arange(len(starts)), np.diff(np.r_[starts, len(u)]))
357
+ ti = d["t"].to_numpy() - t0
358
+ resid = np.full((len(starts), T), np.nan)
359
+ resid[row, ti] = d["r"].to_numpy()
360
+ sw = np.zeros((len(starts), T))
361
+ sw[row, ti] = d["w"].to_numpy() if store.weighted else 1.0
362
+ first = np.asarray(untreated[u[starts]])
363
+ yield d["g"].to_numpy()[starts], resid, sw, first
364
+
365
+ def parts(g, resid, sw):
366
+ observed = np.isfinite(resid)
367
+ resid = np.nan_to_num(resid)
368
+ for (cg, r), js in groups.items():
369
+ mask = g == cg
370
+ if not mask.any():
371
+ continue
372
+ ti = np.array([self.cells[j][1] - t0 for j in js])
373
+ w = weights[(cg, r)]
374
+ totals = (sw[mask][:, ti] * observed[mask][:, ti]) @ w.T
375
+ effects = (sw[mask][:, ti] * resid[mask][:, ti]) @ w.T
376
+ yield mask, r, totals, effects
377
+
378
+ denom, numer = np.zeros((P, R)), np.zeros((P, R))
379
+ for g, resid, sw, _ in batches():
380
+ for _, r, totals, effects in parts(g, resid, sw):
381
+ denom[:, r] += (totals**2).sum(axis=0)
382
+ numer[:, r] += (totals * effects).sum(axis=0)
383
+ averages = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0)
384
+ V, unidentified = np.zeros((P, P)), np.zeros(P, bool)
385
+ for g, resid, sw, first in batches():
386
+ scores = first @ A.T
387
+ for mask, r, totals, effects in parts(g, resid, sw):
388
+ contribution = effects - totals * averages[:, r]
389
+ if self.leaveout:
390
+ lev = np.divide(
391
+ totals**2, denom[:, r], out=np.zeros_like(totals), where=denom[:, r] > 0
392
+ )
393
+ invalid = (np.abs(totals) > 0) & (lev >= 1 - 1e-12)
394
+ unidentified |= invalid.any(axis=0)
395
+ if invalid.any() and not allow_unidentified:
396
+ raise ValueError(
397
+ "BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
398
+ )
399
+ contribution = np.divide(
400
+ contribution,
401
+ 1 - lev,
402
+ out=np.zeros_like(contribution),
403
+ where=lev < 1 - 1e-12,
404
+ )
405
+ scores[mask] += contribution
406
+ V += scores.T @ scores
407
+ V[unidentified, :] = np.nan
408
+ V[:, unidentified] = np.nan
409
+ return (V + V.T) / 2
410
+
411
+
412
+ def absorbed_terms(absorbed):
413
+ """The fixed-effect terms other than the unit's, as hdfe-stream names them."""
414
+ return ["t", *absorbed]
415
+
416
+
417
+ def first_columns(first):
418
+ """The source columns the untreated model uses."""
419
+ est = first._estimator
420
+ columns = {c for d in first.fe_names for c in est.fe_cols[d]}
421
+ for name in first.coefnames:
422
+ columns |= set(est.var_exprs[name].meta.root_names())
423
+ return sorted(columns)
424
+
425
+
426
+ def fit_placebos_absorbed(
427
+ store, pretrends, anticipation, terms, *, fml, depvar, panel, vcov, threads=None
428
+ ):
429
+ """The BJS pre-trend regression with the fixed effects absorbed.
430
+
431
+ The untreated rows (by original cohort), with indicators for the last
432
+ ``pretrend_leads`` periods before treatment, on the untreated model.
433
+ """
434
+ from hdfe_stream import feols_stream
435
+
436
+ from .bjs_placebo import BJSPlaceboResult
437
+
438
+ L = pretrends
439
+ horizons = np.arange(-anticipation - L, -anticipation, dtype=np.int64)
440
+ support, rows = np.zeros(L, dtype=int), []
441
+ scale = store.weight_scale / store.weight_normalizer
442
+ for g, counts in sorted(store.actual_cell_counts.items()):
443
+ if not g:
444
+ continue
445
+ for ti, t in enumerate(store.times):
446
+ j = int(t) - g + anticipation + L
447
+ if 0 <= j < L and counts[ti]:
448
+ support[j] += counts[ti]
449
+ rows.append(
450
+ dict(
451
+ aggregate=f"event::{horizons[j]}",
452
+ cohort=int(g),
453
+ time=int(t),
454
+ event_time=int(horizons[j]),
455
+ observed_units=int(counts[ti]),
456
+ weight_total=float(store.actual_cell_weights[g][ti] * scale),
457
+ )
458
+ )
459
+ if np.any(support == 0):
460
+ raise ValueError(
461
+ f"BJS requested clean placebo horizons are not observed: {horizons[support == 0].tolist()}; reduce pretrend_leads"
462
+ )
463
+ frame, covariates, xs, absorbed = staged(store, terms)
464
+ actual = pl.scan_parquet(store.paths, low_memory=True).select("g_actual")
465
+ event = pl.col("t") - pl.col("g_actual")
466
+ data = (
467
+ pl.concat([frame, actual], how="horizontal")
468
+ .filter((pl.col("g_actual") == 0) | (pl.col("t") < pl.col("g_actual") - anticipation))
469
+ .with_columns(
470
+ pl.when((pl.col("g_actual") != 0) & event.is_in(horizons.tolist()))
471
+ .then(pl.format("e{}", event - int(horizons[0])))
472
+ .otherwise(pl.lit("ref"))
473
+ .alias("lead")
474
+ )
475
+ )
476
+ rhs = " + ".join([*xs, "i(lead, ref='ref')"])
477
+ model = f"y ~ {rhs} | " + " + ".join(["u", "t", *absorbed])
478
+ options = hdfe_options(store, threads, len(xs) + L, vcov={"CRV1": "u"})
479
+ with warnings.catch_warnings():
480
+ warnings.filterwarnings("ignore", _DROPPED)
481
+ fit = feols_stream(model, data, workdir=store.root, **options)
482
+ try:
483
+ names = list(fit.coefnames)
484
+ wanted = [f"lead::e{j}" for j in range(L)]
485
+ if any(n not in names for n in wanted):
486
+ raise ValueError("BJS clean placebo design is unidentified; reduce pretrend_leads")
487
+ ix = [names.index(n) for n in wanted]
488
+ V = (
489
+ np.asarray(fit.vcov, dtype=float)[np.ix_(ix, ix)]
490
+ / fit.diagnostics["vcov_factors"]["CRV1:u"]
491
+ )
492
+ V *= store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1.0
493
+ beta = np.asarray(fit.beta, dtype=float)[ix]
494
+ n_obs = int(fit.n_obs)
495
+ kept = [n for n, x in zip(covariates, xs, strict=True) if x in names]
496
+ finally:
497
+ fit.cleanup()
498
+ return BJSPlaceboResult(
499
+ fml=fml,
500
+ depvar=depvar,
501
+ coefnames=[f"event::{h}" for h in horizons],
502
+ beta=beta,
503
+ vcov=(V + V.T) / 2,
504
+ vcov_type=vcov,
505
+ df_t=store.n_units - 1,
506
+ all_vcovs={vcov: ((V + V.T) / 2, store.n_units - 1)},
507
+ n_obs=n_obs,
508
+ n_clusters={panel[0]: store.n_units},
509
+ n_units=store.n_units,
510
+ weights=None,
511
+ _support=pl.DataFrame(rows),
512
+ diagnostics=dict(
513
+ aggregation="event_time",
514
+ anticipation=anticipation,
515
+ reference_event_time=None,
516
+ fitted_horizons=horizons.tolist(),
517
+ pretrends=L,
518
+ base_period="all other clean observations; no fixed event-time reference",
519
+ inference="unit clustered; clean-sample regression with absorbed fixed effects",
520
+ regression_columns=[*kept, *(f"event::{h}" for h in horizons)],
521
+ absorbed_or_redundant_columns=[n for n in covariates if n not in kept],
522
+ absorbed_terms=[written for _, written in terms],
523
+ warnings=[],
524
+ ),
525
+ )
526
+
527
+
528
+ class UnimputedObservationsWarning(UserWarning):
529
+ """BJS dropped treated observations whose untreated outcome cannot be imputed."""
530
+
531
+
532
+ def drop_unimputable(store, terms, anticipation):
533
+ """Drop the treated rows with a fixed-effect level no untreated row has.
534
+
535
+ Their untreated outcomes are not identified: the untreated model has no
536
+ effect for that level (a county-year in which every unit is treated, say).
537
+ Dropping them changes no other estimate, but the effects then average the
538
+ remaining treated observations only, so the fit warns and reports the
539
+ affected cells.
540
+ """
541
+ from .reference import restage
542
+
543
+ rows = pl.scan_parquet(store.paths, low_memory=True)
544
+ clean = untreated(anticipation)
545
+ flags = []
546
+ for j, (term, _) in enumerate(terms):
547
+ columns = term.split("^")
548
+ seen = (
549
+ rows.filter(clean)
550
+ .select(columns)
551
+ .unique()
552
+ .with_columns(pl.lit(True).alias(f"__seen{j}"))
553
+ )
554
+ rows = rows.join(seen, on=columns, how="left", maintain_order="left")
555
+ flags.append(f"__seen{j}")
556
+ unimputable = ~clean & pl.any_horizontal(pl.col(f).is_null() for f in flags)
557
+ rows = rows.with_columns(unimputable.alias("__unimputable")).drop(flags)
558
+ weight = pl.col("w") if store.weighted else pl.lit(1.0)
559
+ treated = rows.filter(~clean)
560
+ totals, by_cell = pl.collect_all(
561
+ [
562
+ treated.select(
563
+ (weight * pl.col("__unimputable").cast(pl.Float64)).sum().alias("dropped"),
564
+ (weight + 0 * pl.col("y")).sum().alias("treated"),
565
+ pl.col("__unimputable").sum().alias("rows"),
566
+ ),
567
+ treated.group_by("g", "t")
568
+ .agg(
569
+ pl.col("__unimputable").sum().alias("dropped"),
570
+ pl.len().alias("observations"),
571
+ )
572
+ .filter(pl.col("dropped") > 0)
573
+ .sort("g", "t"),
574
+ ]
575
+ )
576
+ n = int(totals["rows"][0])
577
+ if not n:
578
+ return store, dict(unimputed_observations=0)
579
+ share = float(totals["dropped"][0] / totals["treated"][0])
580
+ if n == treated.select(pl.len()).collect().item():
581
+ raise ValueError(
582
+ "BJS is unidentified: every treated observation has a fixed-effect level with no "
583
+ "untreated observations (fixed effects such as cohort-by-period absorb the treatment)"
584
+ )
585
+ cells = [
586
+ dict(
587
+ cohort=int(g),
588
+ time=int(t),
589
+ dropped_observations=int(k),
590
+ remaining_observations=int(m - k),
591
+ )
592
+ for g, t, k, m in by_cell.iter_rows()
593
+ ]
594
+ restricted = restage(
595
+ store,
596
+ rows.filter(~pl.col("__unimputable")).drop("__unimputable"),
597
+ "imputable",
598
+ "unbalanced",
599
+ anticipation,
600
+ )
601
+ for path in store.paths:
602
+ path.unlink()
603
+ labels = store.period_labels
604
+ listed = ", ".join(
605
+ f"{labels.label_cohort(c['cohort'])}/{labels.label_int(c['time'])}" for c in cells[:5]
606
+ )
607
+ more = f" and {len(cells) - 5} more" if len(cells) > 5 else ""
608
+ warnings.warn(
609
+ f"{n:,} treated observations ({share:.1%} of the treated weight) have a fixed-effect "
610
+ "level with no untreated observations, so their untreated outcomes cannot be "
611
+ "imputed. They are dropped, and the effects average the remaining treated "
612
+ f"observations. Cohort/period cells affected: {listed}{more}; see "
613
+ "fit.diagnostics['unimputed_cells'].",
614
+ UnimputedObservationsWarning,
615
+ stacklevel=4,
616
+ )
617
+ return restricted, dict(
618
+ unimputed_observations=n,
619
+ unimputed_treated_weight_share=share,
620
+ unimputed_cells=cells,
621
+ )