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,420 @@
1
+ """Compiled passes of Sun-Abraham and BJS with covariates or extra fixed effects.
2
+
3
+ Both fit a within-unit regression on a design of period dummies and covariates
4
+ (``linear.RowDesign``); the NumPy code builds that design for each batch and
5
+ loops over cohorts and aggregation groups in Python. The kernels read the raw
6
+ batch arrays, build one unit's design rows as they go, and do the same sums in
7
+ one compiled call per batch. Units are processed in fixed-size chunks whose
8
+ partial sums are added in order, so results do not depend on the number of
9
+ threads. The NumPy code in ``linear.py`` and ``unbalanced.py`` remains the
10
+ reference; the tests compare the two.
11
+ """
12
+
13
+ import numpy as np
14
+ from numba import njit, prange
15
+
16
+ from .twostage_kernels import CHUNK, _design, _first_stage
17
+
18
+ ENABLED = True # tests switch it off to compare with the array path
19
+
20
+
21
+ def _chunks(n):
22
+ return (n + CHUNK - 1) // CHUNK
23
+
24
+
25
+ def _arrays(raw):
26
+ g, y, x, w = raw
27
+ return (
28
+ np.ascontiguousarray(y, dtype=np.float64),
29
+ np.ascontiguousarray(x, dtype=np.float64),
30
+ np.ascontiguousarray(w, dtype=np.float64),
31
+ )
32
+
33
+
34
+ # ------------------------------------------------------------------ Sun-Abraham
35
+
36
+
37
+ @njit(cache=True, inline="always")
38
+ def _sunab_row(x, i, t, T1, columns, cell, K, X, cols, vals):
39
+ """Row (i, t) of the design: the kept columns, then the cohort-period cell."""
40
+ _design(x, i, t, T1, columns, X)
41
+ for a in range(K):
42
+ cols[a] = a
43
+ vals[a] = X[t, a]
44
+ n = K
45
+ if cell >= 0:
46
+ cols[n] = K + cell
47
+ vals[n] = 1.0
48
+ n += 1
49
+ return n
50
+
51
+
52
+ @njit(parallel=True, cache=True)
53
+ def sunab_moments(gidx, y, x, w, columns, cell_at, chunk, Q, q, bad):
54
+ """Within-unit Gram matrix and moments of [kept columns | cell indicators]."""
55
+ n, T = y.shape
56
+ K = columns.shape[0]
57
+ P = Q.shape[1]
58
+ T1 = T - 1
59
+ for c in prange(Q.shape[0]):
60
+ X = np.empty((T, K))
61
+ cols = np.empty((T, K + 1), dtype=np.int64)
62
+ vals = np.empty((T, K + 1))
63
+ count = np.zeros(T, dtype=np.int64)
64
+ s = np.zeros(P)
65
+ seen = np.zeros(P, dtype=np.bool_)
66
+ touched = np.empty(P, dtype=np.int64)
67
+ for i in range(c * chunk, min(n, (c + 1) * chunk)):
68
+ mass = 0.0
69
+ ysum = 0.0
70
+ ntouch = 0
71
+ for a in range(K):
72
+ touched[ntouch] = a
73
+ ntouch += 1
74
+ seen[a] = True
75
+ gi = gidx[i]
76
+ for t in range(T):
77
+ count[t] = _sunab_row(x, i, t, T1, columns, cell_at[gi, t], K, X, cols[t], vals[t])
78
+ wt = w[i, t]
79
+ yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
80
+ mass += wt
81
+ ysum += wt * yt
82
+ for a in range(count[t]):
83
+ ca = cols[t, a]
84
+ wa = wt * vals[t, a]
85
+ q[c, ca] += wa * yt
86
+ s[ca] += wa
87
+ if not seen[ca]:
88
+ seen[ca] = True
89
+ touched[ntouch] = ca
90
+ ntouch += 1
91
+ for b in range(count[t]):
92
+ Q[c, ca, cols[t, b]] += wa * vals[t, b]
93
+ if mass <= 0.0:
94
+ bad[c] = 1
95
+ else:
96
+ for a in range(ntouch):
97
+ ca = touched[a]
98
+ q[c, ca] -= s[ca] * ysum / mass
99
+ for b in range(ntouch):
100
+ cb = touched[b]
101
+ Q[c, ca, cb] -= s[ca] * s[cb] / mass
102
+ for a in range(ntouch):
103
+ s[touched[a]] = 0.0
104
+ seen[touched[a]] = False
105
+
106
+
107
+ @njit(parallel=True, cache=True)
108
+ def sunab_scores(gidx, y, x, w, columns, cell_at, coef, H, out, bad):
109
+ """Each unit's score times H: ``out[unit, :]``."""
110
+ n, T = y.shape
111
+ K = columns.shape[0]
112
+ M = H.shape[1]
113
+ T1 = T - 1
114
+ for c in prange((n + CHUNK - 1) // CHUNK):
115
+ X = np.empty((T, K))
116
+ cols = np.empty((T, K + 1), dtype=np.int64)
117
+ vals = np.empty((T, K + 1))
118
+ count = np.zeros(T, dtype=np.int64)
119
+ resid = np.zeros(T)
120
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
121
+ gi = gidx[i]
122
+ mass = 0.0
123
+ rsum = 0.0
124
+ for t in range(T):
125
+ count[t] = _sunab_row(x, i, t, T1, columns, cell_at[gi, t], K, X, cols[t], vals[t])
126
+ fit = 0.0
127
+ for a in range(count[t]):
128
+ fit += vals[t, a] * coef[cols[t, a]]
129
+ yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
130
+ resid[t] = yt - fit
131
+ mass += w[i, t]
132
+ rsum += w[i, t] * resid[t]
133
+ if mass <= 0.0:
134
+ bad[c] = 1
135
+ continue
136
+ rsum /= mass
137
+ for t in range(T):
138
+ we = w[i, t] * (resid[t] - rsum)
139
+ if we == 0.0:
140
+ continue
141
+ for a in range(count[t]):
142
+ wa = we * vals[t, a]
143
+ ca = cols[t, a]
144
+ for m in range(M):
145
+ out[i, m] += wa * H[ca, m]
146
+
147
+
148
+ class SunAbrahamKernels:
149
+ def __init__(self, store, cells, keep):
150
+ self.store, self.cells = store, cells
151
+ self.columns = np.ascontiguousarray(keep, dtype=np.int64)
152
+ self.K, self.M = len(self.columns), len(cells)
153
+ self.groups = np.asarray(sorted(store.gc))
154
+ t0 = np.asarray(store.times)
155
+ self.cell_at = np.full((len(self.groups), store.periods), -1, dtype=np.int64)
156
+ for j, (cg, t) in enumerate(cells):
157
+ self.cell_at[np.searchsorted(self.groups, cg), int(np.searchsorted(t0, t))] = j
158
+
159
+ def _index(self, g):
160
+ return np.searchsorted(self.groups, g).astype(np.int64)
161
+
162
+ def moments(self, batches):
163
+ P = self.K + self.M
164
+ Q, q = np.zeros((P, P)), np.zeros(P)
165
+ for raw in batches():
166
+ y, x, w = _arrays(raw)
167
+ k = _chunks(len(y))
168
+ parts = (np.zeros((k, P, P)), np.zeros((k, P)), np.zeros(k, dtype=np.int8))
169
+ sunab_moments(self._index(raw[0]), y, x, w, self.columns, self.cell_at, CHUNK, *parts)
170
+ if parts[2].any():
171
+ raise ValueError("each group requires a positive-weight estimation observation")
172
+ Q += parts[0].sum(axis=0)
173
+ q += parts[1].sum(axis=0)
174
+ return Q, q
175
+
176
+ def scores(self, raw, coef, H):
177
+ y, x, w = _arrays(raw)
178
+ out = np.zeros((len(y), H.shape[1]))
179
+ bad = np.zeros(_chunks(len(y)), dtype=np.int8)
180
+ sunab_scores(
181
+ self._index(raw[0]), y, x, w, self.columns, self.cell_at,
182
+ np.ascontiguousarray(coef), np.ascontiguousarray(H), out, bad,
183
+ ) # fmt: skip
184
+ if bad.any():
185
+ raise ValueError("each group requires a positive-weight estimation observation")
186
+ return out
187
+
188
+
189
+ # ------------------------------------------------------------------------- BJS
190
+
191
+
192
+ @njit(parallel=True, cache=True)
193
+ def bjs_cell_sums(
194
+ g, gidx, y, x, w, columns, theta, times, anticipation, cell_at, counts, beta, D, bad
195
+ ): # fmt: skip
196
+ """Cell effects and their derivative with respect to the first stage."""
197
+ n, T = y.shape
198
+ P = columns.shape[0]
199
+ T1 = T - 1
200
+ for c in prange(beta.shape[0]):
201
+ X = np.empty((T, P))
202
+ xm = np.zeros(P)
203
+ u1 = np.zeros(T)
204
+ s1 = np.zeros(P)
205
+ obs = np.zeros(T, dtype=np.bool_)
206
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
207
+ if _first_stage(
208
+ g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
209
+ ):
210
+ bad[c] = 1
211
+ continue
212
+ gi = gidx[i]
213
+ for t in range(T):
214
+ j = cell_at[gi, t]
215
+ if j < 0 or not obs[t]:
216
+ continue
217
+ sw = w[i, t] / counts[j]
218
+ beta[c, j] += sw * u1[t]
219
+ for a in range(P):
220
+ D[c, j, a] += sw * (X[t, a] - xm[a])
221
+
222
+
223
+ @njit(parallel=True, cache=True)
224
+ def bjs_totals(
225
+ g, gidx, y, x, w, columns, theta, times, anticipation, cell_at, cell_slot, slot_label,
226
+ slot_ptr, Aw, denom, numer, bad,
227
+ ): # fmt: skip
228
+ """Sums of squares and cross products of each unit's weighted totals and
229
+ effects within each residual-averaging group (``ObservedBJSInference.covariance``)."""
230
+ n, T = y.shape
231
+ P = columns.shape[0]
232
+ Pc = Aw.shape[0]
233
+ T1 = T - 1
234
+ for c in prange(denom.shape[0]):
235
+ X = np.empty((T, P))
236
+ xm = np.zeros(P)
237
+ u1 = np.zeros(T)
238
+ s1 = np.zeros(P)
239
+ obs = np.zeros(T, dtype=np.bool_)
240
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
241
+ if _first_stage(
242
+ g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
243
+ ):
244
+ bad[c] = 1
245
+ continue
246
+ gi = gidx[i]
247
+ lo = slot_ptr[gi]
248
+ S = slot_ptr[gi + 1] - lo
249
+ tot = np.zeros((S, Pc))
250
+ eff = np.zeros((S, Pc))
251
+ for t in range(T):
252
+ j = cell_at[gi, t]
253
+ if j < 0:
254
+ continue
255
+ sl = cell_slot[j]
256
+ if obs[t]:
257
+ for a in range(Pc):
258
+ tot[sl, a] += w[i, t] * Aw[a, j]
259
+ for a in range(Pc):
260
+ eff[sl, a] += w[i, t] * u1[t] * Aw[a, j]
261
+ for sl in range(S):
262
+ r = slot_label[lo + sl]
263
+ for a in range(Pc):
264
+ denom[c, a, r] += tot[sl, a] * tot[sl, a]
265
+ numer[c, a, r] += tot[sl, a] * eff[sl, a]
266
+
267
+
268
+ @njit(parallel=True, cache=True)
269
+ def bjs_scores(
270
+ g, gidx, y, x, w, columns, theta, times, anticipation, cell_at, cell_slot, slot_label,
271
+ slot_ptr, Aw, derivative, averages, denom, leaveout, out, invalid, bad,
272
+ ): # fmt: skip
273
+ """Each unit's score of the aggregates: ``out[unit, :]``; ``invalid[chunk, a]``
274
+ marks aggregates with a leave-out residual group of one contributor."""
275
+ n, T = y.shape
276
+ P = columns.shape[0]
277
+ Pc = Aw.shape[0]
278
+ T1 = T - 1
279
+ for c in prange((n + CHUNK - 1) // CHUNK):
280
+ X = np.empty((T, P))
281
+ xm = np.zeros(P)
282
+ u1 = np.zeros(T)
283
+ s1 = np.zeros(P)
284
+ obs = np.zeros(T, dtype=np.bool_)
285
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
286
+ if _first_stage(
287
+ g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
288
+ ):
289
+ bad[c] = 1
290
+ continue
291
+ gi = gidx[i]
292
+ lo = slot_ptr[gi]
293
+ S = slot_ptr[gi + 1] - lo
294
+ tot = np.zeros((S, Pc))
295
+ eff = np.zeros((S, Pc))
296
+ for t in range(T):
297
+ j = cell_at[gi, t]
298
+ if j < 0:
299
+ continue
300
+ sl = cell_slot[j]
301
+ if obs[t]:
302
+ for a in range(Pc):
303
+ tot[sl, a] += w[i, t] * Aw[a, j]
304
+ for a in range(Pc):
305
+ eff[sl, a] += w[i, t] * u1[t] * Aw[a, j]
306
+ for a in range(Pc):
307
+ acc = 0.0
308
+ for p in range(P):
309
+ acc += s1[p] * derivative[a, p]
310
+ out[i, a] = -acc
311
+ for sl in range(S):
312
+ r = slot_label[lo + sl]
313
+ for a in range(Pc):
314
+ contribution = eff[sl, a] - tot[sl, a] * averages[a, r]
315
+ if leaveout:
316
+ lev = 0.0
317
+ if denom[a, r] > 0.0:
318
+ lev = tot[sl, a] * tot[sl, a] / denom[a, r]
319
+ if abs(tot[sl, a]) > 0.0 and lev >= 1.0 - 1e-12:
320
+ invalid[c, a] = 1
321
+ if lev < 1.0 - 1e-12:
322
+ contribution = contribution / (1.0 - lev)
323
+ else:
324
+ contribution = 0.0
325
+ out[i, a] += contribution
326
+
327
+
328
+ class BJSKernels:
329
+ """Compiled passes of BJS with a ``RowDesign`` (``linear.fit_bjs`` and the
330
+ aggregation covariance in ``unbalanced.ObservedBJSInference``)."""
331
+
332
+ def __init__(self, store, cells, keep, theta, anticipation):
333
+ self.store, self.cells = store, cells
334
+ self.columns = np.ascontiguousarray(keep, dtype=np.int64)
335
+ self.theta = np.ascontiguousarray(theta, dtype=np.float64)
336
+ self.anticipation = int(anticipation)
337
+ self.times = np.ascontiguousarray(store.times, dtype=np.int64)
338
+ self.groups = np.asarray(sorted(store.gc))
339
+ self.cell_at = np.full((len(self.groups), store.periods), -1, dtype=np.int64)
340
+ for j, (cg, t) in enumerate(cells):
341
+ self.cell_at[np.searchsorted(self.groups, cg), int(np.searchsorted(self.times, t))] = j
342
+
343
+ def _arrays(self, raw):
344
+ y, x, w = _arrays(raw)
345
+ g = np.ascontiguousarray(raw[0], dtype=np.int64)
346
+ return g, np.searchsorted(self.groups, g).astype(np.int64), y, x, w
347
+
348
+ def cell_sums(self, batches, counts):
349
+ P, M = len(self.columns), len(self.cells)
350
+ beta, D = np.zeros(M), np.zeros((M, P))
351
+ counts = np.ascontiguousarray(counts, dtype=np.float64)
352
+ for raw in batches():
353
+ g, gi, y, x, w = self._arrays(raw)
354
+ k = _chunks(len(g))
355
+ parts = (np.zeros((k, M)), np.zeros((k, M, P)), np.zeros(k, dtype=np.int8))
356
+ bjs_cell_sums(
357
+ g, gi, y, x, w, self.columns, self.theta, self.times, self.anticipation,
358
+ self.cell_at, counts, *parts,
359
+ ) # fmt: skip
360
+ if parts[2].any():
361
+ raise ValueError("BJS requires at least one untreated observation per unit")
362
+ beta += parts[0].sum(axis=0)
363
+ D += parts[1].sum(axis=0)
364
+ return beta, D
365
+
366
+ def grouping(self, labels, counts, A):
367
+ """Per-cohort slots for the residual-averaging groups of ``labels`` (cell -> group)."""
368
+ G = len(self.groups)
369
+ slot_ptr = np.zeros(G + 1, dtype=np.int64)
370
+ cell_slot = np.zeros(len(self.cells), dtype=np.int64)
371
+ slot_label = []
372
+ for gi, cg in enumerate(self.groups):
373
+ seen = {}
374
+ for j, (cell_cohort, _) in enumerate(self.cells):
375
+ if cell_cohort == cg:
376
+ r = labels[j]
377
+ cell_slot[j] = seen.setdefault(r, len(seen))
378
+ slot_label.extend(seen)
379
+ slot_ptr[gi + 1] = len(slot_label)
380
+ return (
381
+ cell_slot,
382
+ np.asarray(slot_label, dtype=np.int64),
383
+ slot_ptr,
384
+ np.ascontiguousarray(A / np.asarray(counts)[None, :]),
385
+ )
386
+
387
+ def totals(self, batches, grouping, R):
388
+ cell_slot, slot_label, slot_ptr, Aw = grouping
389
+ Pc = Aw.shape[0]
390
+ denom, numer = np.zeros((Pc, R)), np.zeros((Pc, R))
391
+ for raw in batches():
392
+ g, gi, y, x, w = self._arrays(raw)
393
+ k = _chunks(len(g))
394
+ parts = (np.zeros((k, Pc, R)), np.zeros((k, Pc, R)), np.zeros(k, dtype=np.int8))
395
+ bjs_totals(
396
+ g, gi, y, x, w, self.columns, self.theta, self.times, self.anticipation,
397
+ self.cell_at, cell_slot, slot_label, slot_ptr, Aw, *parts,
398
+ ) # fmt: skip
399
+ if parts[2].any():
400
+ raise ValueError("BJS requires at least one untreated observation per unit")
401
+ denom += parts[0].sum(axis=0)
402
+ numer += parts[1].sum(axis=0)
403
+ return denom, numer
404
+
405
+ def scores(self, raw, grouping, derivative, averages, denom, leaveout):
406
+ cell_slot, slot_label, slot_ptr, Aw = grouping
407
+ g, gi, y, x, w = self._arrays(raw)
408
+ out = np.zeros((len(g), Aw.shape[0]))
409
+ k = _chunks(len(g))
410
+ invalid = np.zeros((k, Aw.shape[0]), dtype=np.int8)
411
+ bad = np.zeros(k, dtype=np.int8)
412
+ bjs_scores(
413
+ g, gi, y, x, w, self.columns, self.theta, self.times, self.anticipation,
414
+ self.cell_at, cell_slot, slot_label, slot_ptr, Aw,
415
+ np.ascontiguousarray(derivative), np.ascontiguousarray(averages),
416
+ np.ascontiguousarray(denom), bool(leaveout), out, invalid, bad,
417
+ ) # fmt: skip
418
+ if bad.any():
419
+ raise ValueError("BJS requires at least one untreated observation per unit")
420
+ return out, invalid.any(axis=0)