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/store.py ADDED
@@ -0,0 +1,706 @@
1
+ """Snapshot, recursively partition whole units, validate and sort bounded leaves."""
2
+
3
+ from concurrent.futures import ThreadPoolExecutor, wait
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+ import pyarrow.parquet as pq
9
+
10
+ from ._core import staging_kernels
11
+ from ._core.partition import GroupPartitions, Part
12
+ from ._core.weights import validate_weights
13
+ from .periods import PeriodLabels, cohorts_from_treatment
14
+
15
+ PREFETCH = True # read the next batch in a background thread; tests switch it off
16
+
17
+
18
+ def source(data):
19
+ if isinstance(data, pl.DataFrame):
20
+ return data.lazy()
21
+ if isinstance(data, pl.LazyFrame):
22
+ return data
23
+ if isinstance(data, (str, Path)):
24
+ return pl.scan_parquet(str(data), low_memory=True)
25
+ raise TypeError("data must be a Polars frame or a Parquet path/glob")
26
+
27
+
28
+ # "auto" absorbs additional fixed effects iteratively above this many design
29
+ # columns (levels plus covariates). The dense design's time grows with the number
30
+ # of levels, the iterative solver's does not: with one covariate over 8 periods,
31
+ # from 100,000 to 3 million units, the two took the same time at about 40 to 48
32
+ # levels, and at 240 dense was 7 to 8 times slower. Choosing dense wrongly costs
33
+ # far more than choosing iterative wrongly, so the limit is just below the
34
+ # crossover. Explicit fixed_effect_solver="dense" still goes to 256 columns.
35
+ DENSE_MAX_LEVELS = 40
36
+
37
+
38
+ def inspect(
39
+ fml,
40
+ data,
41
+ panel,
42
+ cohort=None,
43
+ *,
44
+ allow_covariates=False,
45
+ weights=None,
46
+ allow_fe=False,
47
+ treatment=None,
48
+ fe_solver=None,
49
+ keep_fe_columns=False,
50
+ ):
51
+ """Compile the projection and check its schema without scanning rows.
52
+
53
+ Exactly one of ``cohort`` (the first treated period, 0 or null for never
54
+ treated) and ``treatment`` (a 0/1 column, absorbing) names the timing. A
55
+ treatment column is projected as ``d``; the store derives each unit's
56
+ cohort from it, a whole unit at a time.
57
+ """
58
+ if (cohort is None) == (treatment is None):
59
+ raise ValueError("give exactly one of cohort= (first treated period) or treatment= (0/1)")
60
+ timing = cohort if cohort is not None else treatment
61
+ if not isinstance(fml, str) or fml.count("~") != 1:
62
+ raise ValueError("use a single outcome formula: 'y ~ 1'")
63
+ y, rhs = [s.strip() for s in fml.split("~")]
64
+ absorbed, terms = None, []
65
+ if "|" in rhs and allow_fe:
66
+ from .linear import fixed_effect_terms, regression_formula
67
+
68
+ covariates, terms = fixed_effect_terms(rhs, panel)
69
+ if terms and fe_solver in ("auto", "iterative"):
70
+ levels = 0
71
+ if fe_solver == "auto":
72
+ # One streaming pass counts the dummies a dense design would need.
73
+ # The levels are hashed first: counting distinct structs of
74
+ # millions of levels held several GB, the hashes about a third.
75
+ counts = source(data).select(
76
+ [pl.struct(t).hash().n_unique().alias(str(j)) for j, t in enumerate(terms)]
77
+ )
78
+ levels = sum(counts.collect(engine="streaming").row(0))
79
+ levels += 0 if covariates == "1" else covariates.count("+") + 1
80
+ if fe_solver == "iterative" or levels > DENSE_MAX_LEVELS:
81
+ absorbed = terms
82
+ rhs = covariates
83
+ if absorbed is None:
84
+ rhs = regression_formula(rhs, panel)
85
+ if not y.isidentifier() or "|" in rhs or (rhs != "1" and not allow_covariates):
86
+ raise NotImplementedError("the first milestone supports column-name outcomes and '~ 1'")
87
+ if len(panel) != 2 or len(set([y, *panel, timing])) != 4:
88
+ raise ValueError("outcome, unit, time and cohort/treatment must be distinct column names")
89
+ unit, time = panel
90
+ src = source(data)
91
+ if weights is not None:
92
+ if not isinstance(weights, str) or not weights:
93
+ raise ValueError("weights must be a sampling-weight column name or None")
94
+ if weights in (y, unit, time, timing):
95
+ raise ValueError(
96
+ "sampling weights must be distinct from outcome, unit, time and cohort"
97
+ )
98
+ ws = src.collect_schema()[weights]
99
+ if not ws.is_numeric() or ws == pl.Boolean:
100
+ raise ValueError("sampling weights must be numeric")
101
+ # Absorbed terms keep their raw columns (the unit and time are id and t),
102
+ # and so do dense ones when asked (BJS checks their levels can be imputed).
103
+ carried = absorbed if absorbed is not None else (terms if keep_fe_columns else [])
104
+ fe_columns = sorted({c for t in carried for c in t} - set(panel))
105
+ plan = None
106
+ if rhs != "1":
107
+ from ._core.design import CovariatePlan
108
+
109
+ plan = CovariatePlan.parse(y, rhs, src.collect_schema())
110
+ lf = src.select(
111
+ pl.col(unit).alias("id"),
112
+ pl.col(time).alias("t"),
113
+ *(
114
+ [pl.col(cohort).fill_null(0).alias("g")]
115
+ if cohort is not None
116
+ else [pl.lit(0, dtype=pl.Int64).alias("g"), pl.col(treatment).alias("d")]
117
+ ),
118
+ pl.col(y).cast(pl.Float64).alias("y"),
119
+ *([] if weights is None else [pl.col(weights).cast(pl.Float64).alias("w")]),
120
+ *(
121
+ []
122
+ if plan is None
123
+ else [pl.col(c).alias(f"__raw{j}") for j, c in enumerate(plan.required)]
124
+ ),
125
+ *([pl.col(c).alias(f"__fe{j}") for j, c in enumerate(fe_columns)]),
126
+ )
127
+ schema = lf.collect_schema()
128
+ if not schema["t"].is_integer() or not schema["g"].is_integer():
129
+ raise ValueError("time and cohort must be integers; 0 or null denotes never treated")
130
+ if treatment is not None:
131
+ if not (schema["d"].is_integer() or schema["d"] == pl.Boolean):
132
+ raise ValueError("treatment must be a 0/1 integer or Boolean column")
133
+ lf = lf.with_columns(pl.col("d").cast(pl.Int8))
134
+ if not (schema["id"].is_integer() or schema["id"].is_float() or schema["id"] == pl.String):
135
+ raise ValueError("unit IDs must be numeric or strings")
136
+ lf = lf.with_columns(pl.col("t").cast(pl.Int64), pl.col("g").cast(pl.Int64))
137
+ if fe_solver is None:
138
+ return lf, y, plan
139
+ internal = {c: f"__fe{j}" for j, c in enumerate(fe_columns)} | {panel[1]: "t"}
140
+ # (internal term over the staged columns, the term as written)
141
+ staged_terms = [("^".join(internal.get(c, c) for c in t), "^".join(t)) for t in carried]
142
+ return lf, y, plan, staged_terms or None, absorbed is not None
143
+
144
+
145
+ def _width(dtype):
146
+ """Bytes per value of a fixed-width column (8 for anything wider or unknown)."""
147
+ for kinds, width in [
148
+ ((pl.Int8, pl.UInt8, pl.Boolean), 1),
149
+ ((pl.Int16, pl.UInt16), 2),
150
+ ((pl.Int32, pl.UInt32, pl.Float32, pl.Date), 4),
151
+ ]:
152
+ if dtype in kinds:
153
+ return width
154
+ return 8
155
+
156
+
157
+ class PanelStore(GroupPartitions):
158
+ """Every leaf contains whole units. No table of all unit IDs is retained.
159
+
160
+ preprocess_mb budgets decoded partition data plus sort/validation copies;
161
+ readers/writers and the source LazyFrame have separate runtime overhead.
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ lf,
167
+ root,
168
+ batch_units,
169
+ budget,
170
+ preprocess_mb=64,
171
+ design=None,
172
+ panel_mode="balanced",
173
+ anticipation=0,
174
+ *,
175
+ require_never_treated=True,
176
+ retain_actual_cohort=False,
177
+ allow_time_varying_weights=False,
178
+ allow_always_treated=False,
179
+ actual_cohort_column=None,
180
+ ):
181
+ super().__init__(root, budget, preprocess_mb)
182
+ self.batch_units = batch_units
183
+ self.panel_mode = panel_mode
184
+ self.anticipation = anticipation
185
+ self.retain_actual_cohort = retain_actual_cohort
186
+ self.actual_cohort_column = actual_cohort_column
187
+ if actual_cohort_column is not None and (
188
+ not retain_actual_cohort
189
+ or actual_cohort_column not in lf.collect_schema()
190
+ or not lf.collect_schema()[actual_cohort_column].is_integer()
191
+ ):
192
+ raise ValueError("an original cohort column requires retained integer adoption dates")
193
+ self.allow_time_varying_weights = allow_time_varying_weights
194
+ self.allow_always_treated = allow_always_treated
195
+ if allow_always_treated and anticipation:
196
+ raise ValueError("always-treated storage is only available without anticipation")
197
+ self.weights_time_invariant = True
198
+ self.actual_gc = {}
199
+ self.actual_cell_counts = {}
200
+ self.actual_cell_weights = {}
201
+ self.is_balanced = True
202
+ self.n_units_input = 0
203
+ self.n_rows_used = 0
204
+ self.cell_counts = {}
205
+ self.cell_weights = {}
206
+ self.gw = {}
207
+ self.gw_observed_mean = {}
208
+ self.weighted = "w" in lf.collect_schema()
209
+ self.weight_scale = 1.0 if not self.weighted else 0.0
210
+ self.design = design
211
+ self.x_time_invariant = True
212
+ self.paths = []
213
+ self.gc = {}
214
+ self.sums = {}
215
+ self.n_units = 0
216
+ self._guard(preprocess_mb * 1e6 + 96e6)
217
+ raw = self.root / "snapshot.parquet"
218
+ lf.sink_parquet(raw, engine="streaming", row_group_size=self.read_rows)
219
+ times = set()
220
+ size = 0
221
+ if self.design is None:
222
+ times, size = self._validate_query(raw)
223
+ else:
224
+ times, size = self._validate_batches(raw)
225
+ labels = np.array(sorted(times), dtype=np.int64)
226
+ self.periods = len(labels)
227
+ if self.periods < 2 or labels[0] <= 0:
228
+ raise ValueError("at least two positive integer periods are required")
229
+ steps = np.diff(labels)
230
+ if np.any(steps != steps[0]):
231
+ raise ValueError(
232
+ "periods must be evenly spaced (every year, every second year, ...); "
233
+ f"the panel has gaps of {sorted(set(steps.tolist()))}"
234
+ )
235
+ # Spaced periods are numbered consecutively inside (t0, t0+1, ...), so
236
+ # every estimator counts periods; results show the original labels.
237
+ self.period_labels = PeriodLabels(int(labels[0]), int(steps[0]))
238
+ self.times = labels[0] + np.arange(self.periods, dtype=np.int64)
239
+ if self.design is not None:
240
+ self.design.compile()
241
+ self.extra_row_bytes = len(self.design.names) * 8
242
+ if self.retain_actual_cohort:
243
+ self.extra_row_bytes += 8
244
+ self._prepare(Part(raw, self.n_rows, size), 0)
245
+ self.unit_weight_anchor = "first observed period per retained unit"
246
+ if not self.is_balanced and not self.weights_time_invariant:
247
+ self.gw = self.gw_observed_mean
248
+ self.unit_weight_anchor = "mean observed weight per retained unit"
249
+ if require_never_treated and (0 not in self.gc or len(self.gc) < 2):
250
+ raise ValueError("at least one never-treated and one treated cohort are required")
251
+ if not require_never_treated and len(self.gc) < 2:
252
+ raise ValueError(
253
+ "at least two distinct treatment-timing groups are required; an all-never-treated sample has no treatment effects"
254
+ )
255
+ self.weight_normalizer = self.n_units / sum(self.gw.values())
256
+ self.gw = {g: v * self.weight_normalizer for g, v in self.gw.items()}
257
+ self.cell_weights = {g: v * self.weight_normalizer for g, v in self.cell_weights.items()}
258
+ self.actual_cell_weights = {
259
+ g: v * self.weight_normalizer for g, v in self.actual_cell_weights.items()
260
+ }
261
+ self.means = np.vstack(
262
+ [
263
+ np.divide(
264
+ self.sums[g] * self.weight_normalizer,
265
+ self.cell_weights[g],
266
+ out=np.full(self.periods, np.nan),
267
+ where=self.cell_counts[g] > 0,
268
+ )
269
+ for g in sorted(self.gc)
270
+ ]
271
+ )
272
+ del self.sums
273
+
274
+ def _validate_batches(self, raw):
275
+ """Check values and time support, and discover covariate levels, by batch."""
276
+ times, size = set(), 0
277
+ with pq.ParquetFile(raw, pre_buffer=False) as pf:
278
+ self.n_rows = pf.metadata.num_rows
279
+ for b in pf.iter_batches(batch_size=self.read_rows):
280
+ self._guard(16e6)
281
+ d = pl.from_arrow(b)
282
+ self._check_values(
283
+ sum(d.null_count().row(0)) + int((~d["y"].is_finite()).sum()),
284
+ d.schema["id"].is_float() and not d["id"].is_finite().all(),
285
+ )
286
+ if self.weighted:
287
+ w = d["w"].to_numpy()
288
+ validate_weights(w)
289
+ self.weight_scale = max(self.weight_scale, float(w.max()))
290
+ times.update(d["t"].unique().to_list())
291
+ # Prevent an invalid long time grid from growing unchecked.
292
+ self._guard(len(times) * 128 + 16e6)
293
+ size += b.nbytes
294
+ self.design.discover(d)
295
+ return times, size
296
+
297
+ def _validate_query(self, raw):
298
+ """The same checks as one streaming query (no covariate levels to find)."""
299
+ scan = pl.scan_parquet(raw)
300
+ schema = scan.collect_schema()
301
+ exprs = [
302
+ pl.sum_horizontal([pl.col(c).null_count() for c in schema.names()]).alias("nulls"),
303
+ (~pl.col("y").is_finite()).sum().alias("nonfinite"),
304
+ pl.col("t").n_unique().alias("periods"),
305
+ ]
306
+ if schema["id"].is_float():
307
+ exprs.append((~pl.col("id").is_finite()).sum().alias("bad_ids"))
308
+ if self.weighted:
309
+ exprs += [
310
+ ((~pl.col("w").is_finite()) | (pl.col("w") <= 0)).sum().alias("bad_weights"),
311
+ pl.col("w").max().alias("weight_max"),
312
+ ]
313
+ # Decoded (Arrow) size, which decides whether the panel is split: fixed
314
+ # widths, plus the bytes and offsets of string columns.
315
+ strings = [c for c, dtype in schema.items() if dtype == pl.String]
316
+ exprs += [
317
+ (pl.col(c).str.len_bytes().sum() + 4 * pl.len()).alias(f"bytes_{c}") for c in strings
318
+ ]
319
+ exprs.append(pl.len().alias("rows"))
320
+ stats = scan.select(exprs).collect(engine="streaming").row(0, named=True)
321
+ self._check_values(stats["nulls"] + stats["nonfinite"], stats.get("bad_ids", 0) > 0)
322
+ if self.weighted:
323
+ if stats["bad_weights"]:
324
+ raise ValueError("sampling weights must be finite and strictly positive")
325
+ self.weight_scale = float(stats["weight_max"])
326
+ # An invalid long time grid is refused before it is collected.
327
+ self._guard(stats["periods"] * 128 + 16e6)
328
+ times = set(scan.select(pl.col("t").unique()).collect(engine="streaming")["t"].to_list())
329
+ self.n_rows = stats["rows"]
330
+ size = sum(stats[f"bytes_{c}"] for c in strings) + self.n_rows * sum(
331
+ _width(dtype) for c, dtype in schema.items() if c not in strings
332
+ )
333
+ return times, size
334
+
335
+ @staticmethod
336
+ def _check_values(bad_values, bad_ids):
337
+ if bad_values:
338
+ raise ValueError(
339
+ "missing or non-finite values are not supported; clean the panel explicitly"
340
+ )
341
+ if bad_ids:
342
+ raise ValueError("unit IDs must be finite")
343
+
344
+ def _oversized_group(self, part):
345
+ if self.panel_mode == "balanced" and part.rows != self.periods:
346
+ raise ValueError("balanced panels with unique unit-time keys are required")
347
+ return super()._oversized_group(part)
348
+
349
+ def _leaf(self, part):
350
+ effective = part.size + part.rows * self.extra_row_bytes
351
+ self._guard(effective * 4 + 32e6)
352
+ with pq.ParquetFile(part.path, pre_buffer=False) as pf:
353
+ d = pl.from_arrow(pf.read()).sort(["id", "t"])
354
+ self.max_leaf_bytes = max(self.max_leaf_bytes, effective)
355
+ ids = d["id"].to_numpy()
356
+ starts = np.r_[0, np.flatnonzero(ids[1:] != ids[:-1]) + 1]
357
+ lengths = np.diff(np.r_[starts, len(ids)])
358
+ t = d["t"].to_numpy()
359
+ g = d["g"].to_numpy()
360
+ summary = self._summarize(d, t, g, starts, lengths)
361
+ if summary is not None:
362
+ unit, cohort, y, w, lengths = summary
363
+ actual = None
364
+ else:
365
+ unit = np.repeat(np.arange(len(starts)), lengths)
366
+ if np.any((ids[1:] == ids[:-1]) & (t[1:] == t[:-1])):
367
+ raise ValueError(
368
+ "balanced panels with unique unit-time keys are required; duplicate unit-time key"
369
+ )
370
+ if "d" in d.columns:
371
+ g = cohorts_from_treatment(d["d"].to_numpy(), t, ids, starts, unit)
372
+ if self.period_labels.step != 1:
373
+ t = self.period_labels.internal(t)
374
+ g = self.period_labels.internal_cohorts(g, int(self.times[-1]))
375
+ if np.any(g != g[starts][unit]):
376
+ raise ValueError("cohort must be constant within unit")
377
+ if self.weighted:
378
+ w = d["w"].to_numpy()
379
+ if np.any(w != w[starts][unit]) and not self.allow_time_varying_weights:
380
+ raise ValueError(
381
+ "sampling weights must be constant within unit; time-varying weights are unsupported"
382
+ )
383
+ cohort = g[starts].copy()
384
+ actual = cohort.copy() if self.retain_actual_cohort else None
385
+ if self.actual_cohort_column is not None:
386
+ original = d[self.actual_cohort_column].to_numpy()
387
+ if np.any(original < 0) or np.any(original != original[starts][unit]):
388
+ raise ValueError("original cohort must be nonnegative and constant within unit")
389
+ actual = original[starts].copy()
390
+ if np.any(cohort < 0):
391
+ raise ValueError(
392
+ "always-treated/negative cohorts are unsupported; exclude them explicitly"
393
+ )
394
+ onset = cohort - self.anticipation
395
+ if self.allow_always_treated:
396
+ cohort[(cohort > 0) & (onset <= self.times[0])] = self.times[0]
397
+ elif np.any((cohort != 0) & (onset <= self.times[0])):
398
+ raise ValueError(
399
+ "always-treated/negative cohorts or anticipation without a clean pre-period are unsupported; exclude them explicitly"
400
+ )
401
+ cohort[onset > self.times[-1]] = 0
402
+ self.n_units_input += len(starts)
403
+ complete = lengths == self.periods
404
+ if self.panel_mode == "balanced" and not complete.all():
405
+ raise ValueError(
406
+ "balanced panels with unique unit-time keys are required; set incomplete_units='keep' or 'drop'"
407
+ )
408
+ if self.panel_mode == "complete":
409
+ keep = complete[unit]
410
+ d = d.filter(pl.Series(keep))
411
+ t = t[keep]
412
+ cohort = cohort[complete]
413
+ if actual is not None:
414
+ actual = actual[complete]
415
+ lengths = lengths[complete]
416
+ unit = np.repeat(np.arange(len(cohort)), lengths)
417
+ else:
418
+ self.is_balanced &= bool(complete.all())
419
+ if not len(cohort):
420
+ part.path.unlink()
421
+ return
422
+ y = d["y"].to_numpy()
423
+ w = d["w"].to_numpy() / self.weight_scale if self.weighted else np.ones(len(y))
424
+ validate_weights(w)
425
+ first = np.r_[0, np.cumsum(lengths)[:-1]]
426
+ self.weights_time_invariant &= bool(np.all(w == w[first][unit]))
427
+ mean_weight = np.bincount(unit, weights=w, minlength=len(cohort)) / lengths
428
+ ti = t - self.times[0]
429
+ T = self.periods
430
+ # Cohort-period sums in one bincount over (cohort index, period), not
431
+ # a full-length mask per cohort.
432
+ labels, code = np.unique(cohort, return_inverse=True)
433
+ row = code[unit] * T + ti
434
+ n = len(labels) * T
435
+ sums = np.bincount(row, weights=y * w, minlength=n).reshape(-1, T)
436
+ mass = np.bincount(row, weights=w, minlength=n).reshape(-1, T)
437
+ counts = np.bincount(row, minlength=n).reshape(-1, T)
438
+ units = np.bincount(code, minlength=len(labels))
439
+ first_weight = np.bincount(code, weights=w[first], minlength=len(labels))
440
+ mean_by = np.bincount(code, weights=mean_weight, minlength=len(labels))
441
+ for i, k in enumerate(labels.tolist()):
442
+ self.gc[k] = self.gc.get(k, 0) + int(units[i])
443
+ self.gw[k] = self.gw.get(k, 0.0) + float(first_weight[i])
444
+ self.gw_observed_mean[k] = self.gw_observed_mean.get(k, 0.0) + float(mean_by[i])
445
+ self.cell_weights[k] = self.cell_weights.get(k, np.zeros(T)) + mass[i]
446
+ self.sums[k] = self.sums.get(k, np.zeros(T)) + sums[i]
447
+ self.cell_counts[k] = self.cell_counts.get(k, np.zeros(T, dtype=int)) + counts[i]
448
+ if actual is not None:
449
+ labels, code = np.unique(actual, return_inverse=True)
450
+ row = code[unit] * T + ti
451
+ n = len(labels) * T
452
+ counts = np.bincount(row, minlength=n).reshape(-1, T)
453
+ mass = np.bincount(row, weights=w, minlength=n).reshape(-1, T)
454
+ units = np.bincount(code, minlength=len(labels))
455
+ for i, k in enumerate(labels.tolist()):
456
+ self.actual_gc[k] = self.actual_gc.get(k, 0) + int(units[i])
457
+ self.actual_cell_counts[k] = (
458
+ self.actual_cell_counts.get(k, np.zeros(T, dtype=int)) + counts[i]
459
+ )
460
+ self.actual_cell_weights[k] = (
461
+ self.actual_cell_weights.get(k, np.zeros(T)) + mass[i]
462
+ )
463
+ self._guard(32e6 + 32 * (len(self.gc) + len(self.actual_gc)) * self.periods)
464
+ path = self._name("units")
465
+ output = pl.DataFrame({"u": unit + self.n_units, "t": t, "g": cohort[unit], "y": y})
466
+ if actual is not None:
467
+ output = output.with_columns(pl.Series("g_actual", actual[unit]))
468
+ if self.weighted:
469
+ output = output.with_columns(pl.Series("w", w))
470
+ carried = [c for c in d.columns if c.startswith("__fe")]
471
+ if carried:
472
+ output = output.hstack(d.select(carried))
473
+ self.n_units += len(cohort)
474
+ self.n_rows_used += len(y)
475
+ if self.design is not None:
476
+ x = self.design.evaluate(d)
477
+ self._guard(x.estimated_size() * 3 + 32e6)
478
+ output = output.hstack(x)
479
+ xm = x.to_numpy()
480
+ first = np.r_[0, np.cumsum(lengths)[:-1]]
481
+ self.x_time_invariant &= bool(np.all(xm == xm[first][unit]))
482
+ output.write_parquet(path, row_group_size=self.read_rows)
483
+ self.paths.append(path)
484
+ self.partition_count += 1
485
+ part.path.unlink()
486
+
487
+ def _summarize(self, d, t, g, starts, lengths):
488
+ """The checks and cohort-period sums of a sorted leaf in one compiled pass
489
+ (``staging_kernels``), as the NumPy code in ``_leaf`` does; None when the
490
+ leaf is one the kernel does not cover. Returns the leaf's units' index of
491
+ each row, their cohorts, the outcomes, the scaled weights (None when
492
+ unweighted) and the unit lengths."""
493
+ if not (
494
+ staging_kernels.ENABLED
495
+ and "d" not in d.columns
496
+ and self.period_labels.step == 1
497
+ and self.panel_mode != "complete"
498
+ and not self.retain_actual_cohort
499
+ and self.actual_cohort_column is None
500
+ and t.dtype.kind in "iu"
501
+ and g.dtype.kind in "iu"
502
+ ):
503
+ return None
504
+ y = d["y"].to_numpy()
505
+ raw_w = d["w"].to_numpy() if self.weighted else None
506
+ cohort = g[starts].copy()
507
+ labels, code = np.unique(cohort, return_inverse=True)
508
+ T = self.periods
509
+ flags, sums, mass, counts, mean_weight = staging_kernels.summarize(
510
+ starts, lengths, t, g, y, raw_w, self.weight_scale, code, len(labels), self.times[0], T
511
+ )
512
+ if flags[staging_kernels.PERIOD]:
513
+ return None
514
+ if flags[staging_kernels.DUPLICATE]:
515
+ raise ValueError(
516
+ "balanced panels with unique unit-time keys are required; duplicate unit-time key"
517
+ )
518
+ if flags[staging_kernels.COHORT]:
519
+ raise ValueError("cohort must be constant within unit")
520
+ if self.weighted and flags[staging_kernels.WEIGHT] and not self.allow_time_varying_weights:
521
+ raise ValueError(
522
+ "sampling weights must be constant within unit; time-varying weights are unsupported"
523
+ )
524
+ if np.any(cohort < 0):
525
+ raise ValueError(
526
+ "always-treated/negative cohorts are unsupported; exclude them explicitly"
527
+ )
528
+ onset = cohort - self.anticipation
529
+ if self.allow_always_treated:
530
+ cohort[(cohort > 0) & (onset <= self.times[0])] = self.times[0]
531
+ elif np.any((cohort != 0) & (onset <= self.times[0])):
532
+ raise ValueError(
533
+ "always-treated/negative cohorts or anticipation without a clean pre-period are unsupported; exclude them explicitly"
534
+ )
535
+ cohort[onset > self.times[-1]] = 0
536
+ self.n_units_input += len(starts)
537
+ complete = lengths == self.periods
538
+ if self.panel_mode == "balanced" and not complete.all():
539
+ raise ValueError(
540
+ "balanced panels with unique unit-time keys are required; set incomplete_units='keep' or 'drop'"
541
+ )
542
+ self.is_balanced &= bool(complete.all())
543
+ if flags[staging_kernels.BAD_WEIGHT]:
544
+ validate_weights(np.array([-1.0]))
545
+ self.weights_time_invariant &= not flags[staging_kernels.WEIGHT]
546
+ weight = raw_w / self.weight_scale if self.weighted else None
547
+ first_weight = weight[starts] if self.weighted else np.ones(len(starts))
548
+ # the cohorts above may have been moved (always-treated, beyond the last period)
549
+ labels, code = np.unique(cohort, return_inverse=True)
550
+ sums, mass, counts = self._regroup(sums, mass, counts, g[starts], cohort)
551
+ units = np.bincount(code, minlength=len(labels))
552
+ first_weight_by = np.bincount(code, weights=first_weight, minlength=len(labels))
553
+ mean_by = np.bincount(code, weights=mean_weight, minlength=len(labels))
554
+ for i, k in enumerate(labels.tolist()):
555
+ self.gc[k] = self.gc.get(k, 0) + int(units[i])
556
+ self.gw[k] = self.gw.get(k, 0.0) + float(first_weight_by[i])
557
+ self.gw_observed_mean[k] = self.gw_observed_mean.get(k, 0.0) + float(mean_by[i])
558
+ self.cell_weights[k] = self.cell_weights.get(k, np.zeros(T)) + mass[i]
559
+ self.sums[k] = self.sums.get(k, np.zeros(T)) + sums[i]
560
+ self.cell_counts[k] = self.cell_counts.get(k, np.zeros(T, dtype=int)) + counts[i]
561
+ unit = np.repeat(np.arange(len(starts)), lengths)
562
+ return unit, cohort, y, weight, lengths
563
+
564
+ @staticmethod
565
+ def _regroup(sums, mass, counts, original, cohort):
566
+ """The kernel's per-cohort sums regrouped by the final cohorts (a cohort
567
+ can be moved to another by anticipation or beyond-sample timing; the move
568
+ depends only on the cohort)."""
569
+ before, first = np.unique(original, return_index=True)
570
+ after = np.unique(cohort)
571
+ if len(before) == len(after) and np.array_equal(before, after):
572
+ return sums, mass, counts
573
+ slot = np.searchsorted(after, cohort[first])
574
+ out_sums = np.zeros((len(after), sums.shape[1]))
575
+ out_mass = np.zeros_like(out_sums)
576
+ out_counts = np.zeros((len(after), sums.shape[1]), dtype=np.int64)
577
+ for i, j in enumerate(slot.tolist()):
578
+ out_sums[j] += sums[i]
579
+ out_mass[j] += mass[i]
580
+ out_counts[j] += counts[i]
581
+ return out_sums, out_mass, out_counts
582
+
583
+ def batches(self, **options):
584
+ """The staged units in batches of whole units, ``(g, y[, x][, w])``.
585
+
586
+ The next batch is read and decoded in a background thread while the
587
+ current one is worked on (Arrow's reads and decoding release the
588
+ interpreter lock, as do the compiled passes), so a pass costs about
589
+ the larger of the two rather than their sum. One batch is held ahead;
590
+ the numerical code is only ever called from the consuming thread.
591
+ """
592
+ source = self._batches(**options)
593
+ if not PREFETCH:
594
+ yield from source
595
+ return
596
+ done = object()
597
+ with ThreadPoolExecutor(1) as pool:
598
+ pending = pool.submit(next, source, done)
599
+ try:
600
+ while True:
601
+ current = pending.result()
602
+ if current is done:
603
+ return
604
+ pending = pool.submit(next, source, done)
605
+ yield current
606
+ del current
607
+ finally:
608
+ # a generator cannot be closed while another thread advances it
609
+ wait([pending])
610
+ source.close()
611
+
612
+ def _batches(
613
+ self, *, include_x=False, include_weights=False, actual_cohort=False, row_weights=False
614
+ ):
615
+ if actual_cohort and not self.retain_actual_cohort:
616
+ raise ValueError("original cohorts were not retained for this fit")
617
+ if not self.is_balanced:
618
+ yield from self._sparse_batches(
619
+ include_x=include_x,
620
+ include_weights=include_weights,
621
+ actual_cohort=actual_cohort,
622
+ row_weights=row_weights,
623
+ )
624
+ return
625
+ for path in self.paths:
626
+ with pq.ParquetFile(path, pre_buffer=False) as pf:
627
+ K = len(self.design.names) if self.design is not None else 1
628
+ columns = ["g_actual" if actual_cohort else "g", "y"] + (
629
+ [f"__x{j}" for j in range(K)] if include_x and self.design is not None else []
630
+ )
631
+ if include_weights and self.weighted:
632
+ columns.append("w")
633
+ for b in pf.iter_batches(
634
+ batch_size=self.batch_units * self.periods, columns=columns
635
+ ):
636
+ g = b.column(0).to_numpy().reshape(-1, self.periods)[:, 0].copy()
637
+ y = b.column(1).to_numpy().reshape(-1, self.periods).copy()
638
+ result = (g, y)
639
+ if include_x and self.design is not None:
640
+ # Polars assembles the K design columns in parallel, about
641
+ # four times faster than stacking them one by one.
642
+ x = pl.from_arrow(b.select([f"__x{j}" for j in range(K)])).to_numpy(
643
+ order="c"
644
+ )
645
+ result += (x.reshape(-1, self.periods, K),)
646
+ elif include_x:
647
+ result += (np.ones((*y.shape, 1)),)
648
+ if include_weights:
649
+ if row_weights:
650
+ w = (
651
+ b.column("w").to_numpy().reshape(-1, self.periods)
652
+ * self.weight_normalizer
653
+ if self.weighted
654
+ else np.ones(y.shape)
655
+ )
656
+ else:
657
+ w = (
658
+ b.column("w").to_numpy().reshape(-1, self.periods)[:, 0]
659
+ * self.weight_normalizer
660
+ if self.weighted
661
+ else np.ones(len(g))
662
+ )
663
+ result += (w,)
664
+ yield result
665
+
666
+ def _sparse_batches(
667
+ self, *, include_x=False, include_weights=False, actual_cohort=False, row_weights=False
668
+ ):
669
+ """Pad only a numerical batch, never a whole sparse partition."""
670
+ from ._core.partition import whole_group_batches
671
+
672
+ K = len(self.design.names) if self.design is not None else 1
673
+ for path in self.paths:
674
+ for d in whole_group_batches(path, "u", self.batch_units, self.read_rows):
675
+ u = d["u"].to_numpy()
676
+ starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
677
+ lengths = np.diff(np.r_[starts, len(u)])
678
+ row = np.repeat(np.arange(len(starts)), lengths)
679
+ ti = d["t"].to_numpy() - self.times[0]
680
+ self._guard(32e6 + 8 * len(starts) * self.periods * (K + 1))
681
+ y = np.full((len(starts), self.periods), np.nan)
682
+ y[row, ti] = d["y"].to_numpy()
683
+ g = d["g_actual" if actual_cohort else "g"].to_numpy()[starts].copy()
684
+ result = (g, y)
685
+ if include_x:
686
+ x = np.full((len(starts), self.periods, K), np.nan)
687
+ x[row, ti] = (
688
+ d.select([f"__x{j}" for j in range(K)]).to_numpy()
689
+ if self.design is not None
690
+ else 1.0
691
+ )
692
+ result += (x,)
693
+ if include_weights:
694
+ if row_weights:
695
+ w = np.zeros(y.shape)
696
+ w[row, ti] = (
697
+ d["w"].to_numpy() * self.weight_normalizer if self.weighted else 1.0
698
+ )
699
+ else:
700
+ w = (
701
+ d["w"].to_numpy()[starts] * self.weight_normalizer
702
+ if self.weighted
703
+ else np.ones(len(g))
704
+ )
705
+ result += (w,)
706
+ yield result