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
|
@@ -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
|