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,495 @@
1
+ """Compiled passes of the Callaway-Sant'Anna fit on observed rows.
2
+
3
+ Unbalanced panels, panels with sampling weights that vary over time, and
4
+ repeated cross sections are fitted from observed rows: a comparison takes each
5
+ unit's rows at its two dates (the base and the target) that have an outcome.
6
+ ``cs_unbalanced.py`` does that in NumPy, one comparison at a time, and each
7
+ pass over the staged units repeats a few hundred small array operations per
8
+ batch. The kernels here do the same sums in one compiled call per batch, in
9
+ parallel over comparisons, as ``cs_kernels`` does for balanced panels: each
10
+ comparison is handled by one thread and adds its rows in the order they come
11
+ (a unit's base row before its target row), so results do not depend on the
12
+ number of threads. The NumPy code remains the reference, and the tests compare
13
+ the two.
14
+ """
15
+
16
+ import numpy as np
17
+ from numba import njit, prange
18
+
19
+ from .cs_kernels import DR, IPW, METHOD_CODES, REG, _expit # noqa: F401
20
+
21
+ ENABLED = True # tests switch it off to compare with the array path
22
+ CAP = 1.0 - 1e-6
23
+
24
+
25
+ @njit(cache=True, inline="always")
26
+ def _weight(w2, i, t):
27
+ return w2[i, t] if w2.shape[1] > 1 else w2[i, 0]
28
+
29
+
30
+ @njit(parallel=True, cache=True)
31
+ def row_initial(g, y, x, w2, members, cohort, dates, counts, wsums, scale):
32
+ """Counts, weight sums and design scales of each logit system's rows."""
33
+ n = g.shape[0]
34
+ K = x.shape[2]
35
+ for c in prange(members.shape[0]):
36
+ cnt = np.zeros(2, dtype=np.int64)
37
+ ws = np.zeros(4)
38
+ sc = np.zeros(K)
39
+ for i in range(n):
40
+ if members[c, g[i]] == 0:
41
+ continue
42
+ treated = g[i] == cohort[c]
43
+ for pi in range(2):
44
+ t = dates[c, pi]
45
+ if not np.isfinite(y[i, t]):
46
+ continue
47
+ wi = _weight(w2, i, t)
48
+ if treated:
49
+ cnt[0] += 1
50
+ ws[0] += wi
51
+ ws[2] += wi * wi
52
+ else:
53
+ cnt[1] += 1
54
+ ws[1] += wi
55
+ ws[3] += wi * wi
56
+ for a in range(K):
57
+ sc[a] += wi * x[i, t, a] * x[i, t, a]
58
+ counts[c, :] = cnt
59
+ wsums[c, :] = ws
60
+ scale[c, :] = sc
61
+
62
+
63
+ @njit(parallel=True, cache=True)
64
+ def row_newton(g, y, x, w2, members, cohort, dates, beta, active, loss, grad, hess, margin):
65
+ """Logit loss, gradient, information and smallest margin of the active systems."""
66
+ n = g.shape[0]
67
+ K = x.shape[2]
68
+ for c in prange(members.shape[0]):
69
+ if not active[c]:
70
+ continue
71
+ total = 0.0
72
+ low = np.inf
73
+ gd = np.zeros(K)
74
+ hs = np.zeros((K, K))
75
+ for i in range(n):
76
+ if members[c, g[i]] == 0:
77
+ continue
78
+ d = 1.0 if g[i] == cohort[c] else 0.0
79
+ for pi in range(2):
80
+ t = dates[c, pi]
81
+ if not np.isfinite(y[i, t]):
82
+ continue
83
+ wi = _weight(w2, i, t)
84
+ eta = 0.0
85
+ for a in range(K):
86
+ eta += x[i, t, a] * beta[c, a]
87
+ e = np.exp(-abs(eta))
88
+ p = 1.0 / (1.0 + e) if eta >= 0.0 else e / (1.0 + e)
89
+ total += wi * (max(eta, 0.0) + np.log1p(e) - d * eta)
90
+ r = wi * (d - p)
91
+ v = wi * p * (1.0 - p)
92
+ for a in range(K):
93
+ xa = x[i, t, a]
94
+ gd[a] += xa * r
95
+ xv = xa * v
96
+ for b in range(K):
97
+ hs[a, b] += xv * x[i, t, b]
98
+ m = (2.0 * d - 1.0) * eta
99
+ if m < low:
100
+ low = m
101
+ loss[c] = total
102
+ grad[c, :] = gd
103
+ hess[c, :, :] = hs
104
+ margin[c] = low
105
+
106
+
107
+ @njit(parallel=True, cache=True)
108
+ def row_linear(g, y, x, w2, members, dates, gram, rhs):
109
+ """Weighted least squares moments of each (cohorts, date) stratum."""
110
+ n = g.shape[0]
111
+ K = x.shape[2]
112
+ for c in prange(members.shape[0]):
113
+ t = dates[c]
114
+ gr = np.zeros((K, K))
115
+ rh = np.zeros(K)
116
+ for i in range(n):
117
+ if members[c, g[i]] == 0 or not np.isfinite(y[i, t]):
118
+ continue
119
+ wi = _weight(w2, i, t)
120
+ for a in range(K):
121
+ xa = x[i, t, a] * wi
122
+ rh[a] += xa * y[i, t]
123
+ for b in range(K):
124
+ gr[a, b] += xa * x[i, t, b]
125
+ gram[c, :, :] = gr
126
+ rhs[c, :] = rh
127
+
128
+
129
+ @njit(inline="always")
130
+ def _row_terms(method, treated, post, odds, yv, m0, m1, m2, m3, wt, vt):
131
+ """The comparison's terms for one row: weights (before sampling weights) and values."""
132
+ tp = 1.0 if (treated and post) else 0.0
133
+ tm = 1.0 if (treated and not post) else 0.0
134
+ cp = 1.0 if (not treated and post) else 0.0
135
+ cm = 1.0 if (not treated and not post) else 0.0
136
+ d = 1.0 if treated else 0.0
137
+ if method == IPW:
138
+ wt[0], wt[1], wt[2], wt[3] = tp, tm, cp * odds, cm * odds
139
+ vt[0] = vt[1] = vt[2] = vt[3] = yv
140
+ return 4
141
+ if method == REG:
142
+ wt[0], wt[1], wt[2] = tp, tm, d
143
+ vt[0] = vt[1] = yv
144
+ vt[2] = m1 - m0
145
+ return 3
146
+ r = yv - (m1 if post else m0)
147
+ wt[0], wt[1], wt[2], wt[3] = tp, tm, cp * odds, cm * odds
148
+ wt[4], wt[5], wt[6], wt[7] = d, tp, d, tm
149
+ vt[0] = vt[1] = vt[2] = vt[3] = r
150
+ vt[4] = vt[5] = m3 - m1
151
+ vt[6] = vt[7] = m2 - m0
152
+ return 8
153
+
154
+
155
+ @njit(parallel=True, cache=True)
156
+ def row_moments(
157
+ g,
158
+ y,
159
+ x,
160
+ w2,
161
+ members,
162
+ cohort,
163
+ dates,
164
+ method,
165
+ trim,
166
+ has_system,
167
+ beta,
168
+ models,
169
+ weight,
170
+ value,
171
+ xw,
172
+ xv,
173
+ hess,
174
+ strata,
175
+ sw,
176
+ sw2,
177
+ trimmed,
178
+ capped,
179
+ cws,
180
+ pmin,
181
+ pmax,
182
+ ):
183
+ """The sums of one pass after the nuisance fits (``RowFunctional.update``)."""
184
+ n = g.shape[0]
185
+ K = x.shape[2]
186
+ for c in prange(members.shape[0]):
187
+ Wt = np.zeros(8)
188
+ Vt = np.zeros(8)
189
+ a_w = np.zeros(8)
190
+ a_v = np.zeros(8)
191
+ a_x = np.zeros((8, K))
192
+ a_xv = np.zeros((8, K))
193
+ a_h = np.zeros((K, K))
194
+ a_n = np.zeros(4, dtype=np.int64)
195
+ a_s = np.zeros(4)
196
+ a_s2 = np.zeros(4)
197
+ a_c = np.zeros(2)
198
+ lo = np.inf
199
+ hi = -np.inf
200
+ ntrim = 0
201
+ ncap = 0
202
+ nt = 0
203
+ for i in range(n):
204
+ if members[c, g[i]] == 0:
205
+ continue
206
+ treated = g[i] == cohort[c]
207
+ for pi in range(2):
208
+ t = dates[c, pi]
209
+ if not np.isfinite(y[i, t]):
210
+ continue
211
+ post = pi == 1
212
+ wi = _weight(w2, i, t)
213
+ stratum = (2 if treated else 0) + (1 if post else 0)
214
+ a_n[stratum] += 1
215
+ a_s[stratum] += wi
216
+ a_s2[stratum] += wi * wi
217
+ odds = 1.0
218
+ p = 0.0
219
+ if has_system[c]:
220
+ eta = 0.0
221
+ for a in range(K):
222
+ eta += x[i, t, a] * beta[c, a]
223
+ raw_p = _expit(eta)
224
+ p = min(raw_p, CAP)
225
+ odds = (p / (1.0 - p)) if p < trim else 0.0
226
+ if raw_p > CAP:
227
+ ncap += 1
228
+ if (not treated) and p >= trim:
229
+ ntrim += 1
230
+ v = wi * p * (1.0 - p)
231
+ for a in range(K):
232
+ xa = x[i, t, a] * v
233
+ for b in range(K):
234
+ a_h[a, b] += xa * x[i, t, b]
235
+ lo = min(lo, p)
236
+ hi = max(hi, p)
237
+ m0 = m1 = m2 = m3 = 0.0
238
+ if method != IPW:
239
+ for a in range(K):
240
+ xa = x[i, t, a]
241
+ m0 += xa * models[c, 0, a]
242
+ m1 += xa * models[c, 1, a]
243
+ if method == DR:
244
+ m2 += xa * models[c, 2, a]
245
+ m3 += xa * models[c, 3, a]
246
+ nt = _row_terms(method, treated, post, odds, y[i, t], m0, m1, m2, m3, Wt, Vt)
247
+ for k in range(nt):
248
+ wk = Wt[k] * wi
249
+ wv = wk * Vt[k]
250
+ a_w[k] += wk
251
+ a_v[k] += wv
252
+ for a in range(K):
253
+ xa = x[i, t, a]
254
+ a_x[k, a] += wk * xa
255
+ a_xv[k, a] += wv * xa
256
+ if has_system[c]:
257
+ # the control terms' weights are the last two ipw/dr terms
258
+ a_c[0] += (Wt[3] * wi) ** 2
259
+ a_c[1] += (Wt[2] * wi) ** 2
260
+ weight[c, :] = a_w
261
+ value[c, :] = a_v
262
+ xw[c, :, :] = a_x
263
+ xv[c, :, :] = a_xv
264
+ hess[c, :, :] = a_h
265
+ strata[c, :] = a_n
266
+ sw[c, :] = a_s
267
+ sw2[c, :] = a_s2
268
+ trimmed[c] = ntrim
269
+ capped[c] = ncap
270
+ cws[c, :] = a_c
271
+ pmin[c] = lo
272
+ pmax[c] = hi
273
+
274
+
275
+ @njit(parallel=True, cache=True)
276
+ def row_scores(
277
+ g,
278
+ y,
279
+ x,
280
+ w2,
281
+ members,
282
+ cohort,
283
+ dates,
284
+ method,
285
+ trim,
286
+ has_system,
287
+ beta,
288
+ models,
289
+ n_models,
290
+ correction,
291
+ ps_correction,
292
+ mean,
293
+ total,
294
+ signs,
295
+ out,
296
+ ):
297
+ """The unit scores of each comparison; ``out[c, unit]`` (``RowFunctional.scores``)."""
298
+ n = g.shape[0]
299
+ K = x.shape[2]
300
+ for c in prange(members.shape[0]):
301
+ Wt = np.zeros(8)
302
+ Vt = np.zeros(8)
303
+ for i in range(n):
304
+ if members[c, g[i]] == 0:
305
+ continue
306
+ treated = g[i] == cohort[c]
307
+ acc = 0.0
308
+ for pi in range(2):
309
+ t = dates[c, pi]
310
+ if not np.isfinite(y[i, t]):
311
+ continue
312
+ post = pi == 1
313
+ wi = _weight(w2, i, t)
314
+ odds = 1.0
315
+ p = 0.0
316
+ if has_system[c]:
317
+ eta = 0.0
318
+ for a in range(K):
319
+ eta += x[i, t, a] * beta[c, a]
320
+ p = min(_expit(eta), CAP)
321
+ odds = (p / (1.0 - p)) if p < trim else 0.0
322
+ m0 = m1 = m2 = m3 = 0.0
323
+ if method != IPW:
324
+ for a in range(K):
325
+ xa = x[i, t, a]
326
+ m0 += xa * models[c, 0, a]
327
+ m1 += xa * models[c, 1, a]
328
+ if method == DR:
329
+ m2 += xa * models[c, 2, a]
330
+ m3 += xa * models[c, 3, a]
331
+ nt = _row_terms(method, treated, post, odds, y[i, t], m0, m1, m2, m3, Wt, Vt)
332
+ phi = 0.0
333
+ for k in range(nt):
334
+ phi += signs[c, k] * (Wt[k] * wi) * (Vt[k] - mean[c, k]) / total[c, k]
335
+ stratum = (2 if treated else 0) + (1 if post else 0)
336
+ if stratum < n_models[c]:
337
+ fitted = 0.0
338
+ adjust = 0.0
339
+ for a in range(K):
340
+ fitted += x[i, t, a] * models[c, stratum, a]
341
+ adjust += x[i, t, a] * correction[c, stratum, a]
342
+ phi += wi * (y[i, t] - fitted) * adjust
343
+ if has_system[c]:
344
+ adjust = 0.0
345
+ for a in range(K):
346
+ adjust += x[i, t, a] * ps_correction[c, a]
347
+ phi += wi * ((1.0 if treated else 0.0) - p) * adjust
348
+ acc += phi
349
+ out[c, i] = acc
350
+
351
+
352
+ def fit_linear_rows(batches, keys, size, K):
353
+ """Gram matrices and moments of the strata ``keys`` = [(cohorts, date)]."""
354
+ members = np.zeros((len(keys), size), dtype=np.uint8)
355
+ for j, (cohorts, _) in enumerate(keys):
356
+ members[j, list(cohorts)] = 1
357
+ dates = np.array([t for _, t in keys], dtype=np.int64)
358
+ gram = np.zeros((len(keys), K, K))
359
+ rhs = np.zeros((len(keys), K))
360
+ for raw in batches():
361
+ g, y, x, w2 = RowKernels.arrays(raw)
362
+ q = np.zeros_like(gram)
363
+ r = np.zeros_like(rhs)
364
+ row_linear(g, y, x, w2, members, dates, q, r)
365
+ gram += q
366
+ rhs += r
367
+ return gram, rhs
368
+
369
+
370
+ class RowKernels:
371
+ """Compiled passes for a set of ``cs_unbalanced.RowComparison`` objects."""
372
+
373
+ def __init__(self, comparisons, size):
374
+ self.comparisons = comparisons
375
+ self.C = len(comparisons)
376
+ members = np.zeros((self.C, size), dtype=np.uint8)
377
+ for j, c in enumerate(comparisons):
378
+ members[j, [c.cohort, *c.controls]] = 1
379
+ self.members = members
380
+ self.cohort = np.array([c.cohort for c in comparisons], dtype=np.int64)
381
+ self.dates = np.array([[c.base, c.target] for c in comparisons], dtype=np.int64)
382
+ # the interface fit_systems expects of a balanced-panel kernel set
383
+ self.sizes = np.ones(self.C, dtype=np.int64)
384
+ self.Lmax = 1
385
+
386
+ @staticmethod
387
+ def arrays(raw):
388
+ g, y, x, w = raw
389
+ g = np.ascontiguousarray(g, dtype=np.int64)
390
+ y = np.ascontiguousarray(y, dtype=np.float64)
391
+ x = np.ascontiguousarray(x, dtype=np.float64)
392
+ w = np.asarray(w, dtype=np.float64)
393
+ return g, y, x, np.ascontiguousarray(w if w.ndim == 2 else w[:, None])
394
+
395
+ # ---------------------------------------------------------------- logit fits
396
+ def initial(self, raw, K, need_reg, out):
397
+ g, y, x, w2 = self.arrays(raw)
398
+ C = self.C
399
+ counts = np.zeros((C, 2), dtype=np.int64)
400
+ wsums = np.zeros((C, 4))
401
+ scale = np.zeros((C, K))
402
+ row_initial(g, y, x, w2, self.members, self.cohort, self.dates, counts, wsums, scale)
403
+ out["counts"] += counts
404
+ out["wsums"] += wsums
405
+ out["scale"] += scale
406
+
407
+ def newton(self, raw, betas, active, K):
408
+ g, y, x, w2 = self.arrays(raw)
409
+ C = self.C
410
+ loss = np.zeros(C)
411
+ grad = np.zeros((C, K))
412
+ hess = np.zeros((C, K, K))
413
+ margin = np.full(C, np.inf)
414
+ row_newton(
415
+ g, y, x, w2, self.members, self.cohort, self.dates, betas, active, loss, grad, hess,
416
+ margin,
417
+ ) # fmt: skip
418
+ return loss, grad, hess, margin
419
+
420
+ # ---------------------------------------------------------------- the rest
421
+ def parameters(self, fitted, method, K):
422
+ """Arrays of the fitted comparisons' parameters for the moment and score passes."""
423
+ C = self.C
424
+ has_system = np.array([f.system is not None for f in fitted], dtype=bool)
425
+ beta = np.zeros((C, K))
426
+ models = np.zeros((C, 4, K))
427
+ for j, f in enumerate(fitted):
428
+ if f.system is not None:
429
+ beta[j] = f.system.beta
430
+ for k, (b, _) in enumerate(f.models):
431
+ models[j, k] = b
432
+ return has_system, beta, models
433
+
434
+ def moments(self, raw, method, trim, params, acc):
435
+ g, y, x, w2 = self.arrays(raw)
436
+ K = x.shape[2]
437
+ has_system, beta, models = params
438
+ part = self.empty_moments(K)
439
+ row_moments(
440
+ g, y, x, w2, self.members, self.cohort, self.dates, METHOD_CODES[method], trim,
441
+ has_system, beta, models, part["weight"], part["value"], part["xw"], part["xv"],
442
+ part["hess"], part["strata"], part["sw"], part["sw2"], part["trimmed"],
443
+ part["capped"], part["cws"], part["pmin"], part["pmax"],
444
+ ) # fmt: skip
445
+ for key in part:
446
+ if key == "pmin":
447
+ acc[key] = np.minimum(acc[key], part[key])
448
+ elif key == "pmax":
449
+ acc[key] = np.maximum(acc[key], part[key])
450
+ else:
451
+ acc[key] += part[key]
452
+
453
+ def empty_moments(self, K):
454
+ C = self.C
455
+ return dict(
456
+ weight=np.zeros((C, 8)),
457
+ value=np.zeros((C, 8)),
458
+ xw=np.zeros((C, 8, K)),
459
+ xv=np.zeros((C, 8, K)),
460
+ hess=np.zeros((C, K, K)),
461
+ strata=np.zeros((C, 4), dtype=np.int64),
462
+ sw=np.zeros((C, 4)),
463
+ sw2=np.zeros((C, 4)),
464
+ trimmed=np.zeros(C, dtype=np.int64),
465
+ capped=np.zeros(C, dtype=np.int64),
466
+ cws=np.zeros((C, 2)),
467
+ pmin=np.full(C, np.inf),
468
+ pmax=np.full(C, -np.inf),
469
+ )
470
+
471
+ def scores(self, raw, method, trim, params, fitted):
472
+ """Scores of a batch, units by comparisons."""
473
+ g, y, x, w2 = self.arrays(raw)
474
+ C, K = self.C, x.shape[2]
475
+ has_system, beta, models = params
476
+ n_models = np.array([len(f.models) for f in fitted], dtype=np.int64)
477
+ correction = np.zeros((C, 4, K))
478
+ ps_correction = np.zeros((C, K))
479
+ mean = np.zeros((C, 8))
480
+ total = np.ones((C, 8))
481
+ signs = np.zeros((C, 8))
482
+ for j, f in enumerate(fitted):
483
+ for k, corr in enumerate(f.reg_correction):
484
+ correction[j, k] = corr
485
+ if f.system is not None:
486
+ ps_correction[j] = f.ps_correction
487
+ mean[j, : len(f.signs)] = f.means.mean
488
+ total[j, : len(f.signs)] = f.means.weight
489
+ signs[j, : len(f.signs)] = f.signs
490
+ out = np.zeros((C, len(g)))
491
+ row_scores(
492
+ g, y, x, w2, self.members, self.cohort, self.dates, METHOD_CODES[method], trim,
493
+ has_system, beta, models, n_models, correction, ps_correction, mean, total, signs, out,
494
+ ) # fmt: skip
495
+ return out.T
@@ -0,0 +1,105 @@
1
+ """Bounded formula-design discovery using hdfe-stream's existing compiler.
2
+
3
+ Generic staging adapter; intended for eventual hdfe_stream.core.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+
8
+ import numpy as np
9
+ import polars as pl
10
+ from hdfe_stream.core.formula import categorical_vars, compile_design, numeric_expr
11
+
12
+
13
+ @dataclass
14
+ class CovariatePlan:
15
+ outcome: str
16
+ rhs: str
17
+ schema: object
18
+ required: list
19
+ categorical: set
20
+ levels: dict = field(default_factory=dict)
21
+ names: list = field(default_factory=list)
22
+ expressions: list = field(default_factory=list)
23
+ max_columns: int = 256
24
+
25
+ @classmethod
26
+ def parse(cls, outcome, rhs, schema):
27
+ import formulaic
28
+
29
+ formula = formulaic.Formula(rhs)
30
+ variables = set(formula.required_variables)
31
+ # formulaic does not know pyfixest's i() function during discovery.
32
+ if not any(f.expr == "i" for term in formula for f in term.factors):
33
+ variables.discard("i")
34
+ functions = {"abs", "log", "log1p", "log10", "exp", "sqrt", "np", "I", "C"}
35
+ scalar_names = {f.expr for term in formula for f in term.factors}
36
+ variables -= functions - scalar_names
37
+ required = sorted(variables)
38
+ missing = set(required) - set(schema)
39
+ if missing:
40
+ raise ValueError(f"unknown variables in formula: {sorted(missing)}")
41
+ cats = categorical_vars(rhs, schema)
42
+ return cls(outcome, rhs, schema, required, cats, {c: set() for c in cats})
43
+
44
+ @property
45
+ def raw_columns(self):
46
+ return [f"__raw{j}" for j in range(len(self.required))]
47
+
48
+ def discover(self, frame):
49
+ for j, c in enumerate(self.required):
50
+ if c in self.categorical:
51
+ self.levels[c].update(frame[f"__raw{j}"].unique().to_list())
52
+ if len(self.levels[c]) > self.max_columns:
53
+ raise ValueError(
54
+ "categorical design exceeds the current 256-column design limit"
55
+ )
56
+
57
+ def compile(self):
58
+ import formulaic
59
+
60
+ self.levels = {c: sorted(v) for c, v in self.levels.items()}
61
+ # Guard expansion before constructing the compiler's small synthetic frame.
62
+ upper = 0
63
+ for term in formulaic.Formula(self.rhs):
64
+ cats = (
65
+ set().union(*(factor.required_variables for factor in term.factors))
66
+ & self.categorical
67
+ )
68
+ upper += int(np.prod([len(self.levels[c]) for c in cats])) if cats else 1
69
+ if upper > self.max_columns * 4:
70
+ raise ValueError("formula expansion exceeds the current 256-column design limit")
71
+ if not self.categorical:
72
+ # Numeric terms need no synthetic model matrix or pyfixest import.
73
+ # Reuse the same expression compiler as the family frontend.
74
+ design = []
75
+ for term in formulaic.Formula(self.rhs):
76
+ if str(term) == "1":
77
+ design.append(("Intercept", pl.lit(1.0)))
78
+ else:
79
+ expr = pl.lit(1.0)
80
+ for factor in term.factors:
81
+ expr = expr * numeric_expr(factor.expr)
82
+ design.append((str(term), expr))
83
+ else:
84
+ _, design = compile_design(
85
+ self.outcome, self.rhs, self.schema, self.levels, intercept=True
86
+ )
87
+ if not design or len(design) > self.max_columns:
88
+ raise ValueError("the formula needs between 1 and 256 design columns")
89
+ self.names = [n for n, e in design]
90
+ self.expressions = [e.cast(pl.Float64).alias(f"__x{j}") for j, (n, e) in enumerate(design)]
91
+
92
+ def evaluate(self, frame):
93
+ anchor = "__diff_anchor"
94
+ while anchor in self.required:
95
+ anchor += "_"
96
+ work = frame.select(
97
+ pl.col("y").alias(anchor),
98
+ *[pl.col(f"__raw{j}").alias(c) for j, c in enumerate(self.required)],
99
+ )
100
+ result = work.with_columns(self.expressions).select(
101
+ [f"__x{j}" for j in range(len(self.names))]
102
+ )
103
+ if sum(result.null_count().row(0)) or not np.isfinite(result.to_numpy()).all():
104
+ raise ValueError("covariate design has missing or non-finite values")
105
+ return result