diff-stream 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diff_stream/__init__.py +52 -0
- diff_stream/_bootstrap_worker.py +48 -0
- diff_stream/_core/__init__.py +5 -0
- diff_stream/_core/contrasts.py +199 -0
- diff_stream/_core/covariate_kernels.py +420 -0
- diff_stream/_core/cs_kernels.py +536 -0
- diff_stream/_core/cs_row_kernels.py +495 -0
- diff_stream/_core/design.py +105 -0
- diff_stream/_core/etwfe_kernels.py +288 -0
- diff_stream/_core/functionals.py +30 -0
- diff_stream/_core/multiplier.py +90 -0
- diff_stream/_core/nuisance.py +255 -0
- diff_stream/_core/partition.py +158 -0
- diff_stream/_core/process.py +61 -0
- diff_stream/_core/resample.py +171 -0
- diff_stream/_core/staging_kernels.py +90 -0
- diff_stream/_core/twostage_kernels.py +342 -0
- diff_stream/_core/weights.py +26 -0
- diff_stream/_core/within.py +81 -0
- diff_stream/_docs.py +100 -0
- diff_stream/_options.py +62 -0
- diff_stream/_present.py +202 -0
- diff_stream/absorbed.py +621 -0
- diff_stream/aggregation.py +225 -0
- diff_stream/api.py +1112 -0
- diff_stream/bacon.py +460 -0
- diff_stream/bacon_controls.py +188 -0
- diff_stream/balance.py +149 -0
- diff_stream/bjs.py +101 -0
- diff_stream/bjs_placebo.py +167 -0
- diff_stream/bootstrap.py +704 -0
- diff_stream/cohort_scores.py +68 -0
- diff_stream/cs.py +197 -0
- diff_stream/cs_unbalanced.py +336 -0
- diff_stream/effective_sample.py +73 -0
- diff_stream/estimators.py +95 -0
- diff_stream/etwfe.py +589 -0
- diff_stream/gardner.py +500 -0
- diff_stream/linear.py +339 -0
- diff_stream/periods.py +72 -0
- diff_stream/pretrend.py +97 -0
- diff_stream/py.typed +0 -0
- diff_stream/reference.py +125 -0
- diff_stream/results.py +521 -0
- diff_stream/simulate.py +80 -0
- diff_stream/stacked.py +405 -0
- diff_stream/store.py +706 -0
- diff_stream/twfe_store.py +181 -0
- diff_stream/twfe_weights.py +552 -0
- diff_stream/twostage_absorbed.py +190 -0
- diff_stream/unbalanced.py +297 -0
- diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
- diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
- diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
- diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
diff_stream/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
|