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,536 @@
1
+ """Compiled passes of the Callaway-Sant'Anna covariate fit on balanced panels.
2
+
3
+ A fit has many comparisons (a cohort against its controls, with the covariates
4
+ of one period), and every pass over the staged units visits each unit once per
5
+ comparison it belongs to. In NumPy that is a few hundred small array operations
6
+ per batch, which the interpreter's lock serializes. The kernels here do the
7
+ same sums in one compiled call per batch, in parallel over comparisons.
8
+
9
+ Each comparison is handled by one thread and adds its rows in the order they
10
+ come, so the sums, and with them the estimates, do not depend on the number of
11
+ threads. The NumPy code in ``nuisance.py``, ``contrasts.py`` and ``cs.py``
12
+ remains the reference (and the path for unbalanced panels); the tests compare
13
+ the two.
14
+ """
15
+
16
+ import numpy as np
17
+ from numba import njit, prange
18
+
19
+ ENABLED = True # tests switch it off to compare with the array path
20
+
21
+ REG, IPW, DR = 0, 1, 2
22
+ METHOD_CODES = {"reg": REG, "ipw": IPW, "dr": DR}
23
+
24
+
25
+ @njit(cache=True, inline="always")
26
+ def _expit(z):
27
+ if z >= 0.0:
28
+ return 1.0 / (1.0 + np.exp(-z))
29
+ e = np.exp(z)
30
+ return e / (1.0 + e)
31
+
32
+
33
+ @njit(parallel=True, cache=True)
34
+ def initial_sums(
35
+ g,
36
+ y,
37
+ x,
38
+ w2,
39
+ members,
40
+ cohort,
41
+ xper,
42
+ wper,
43
+ cell_ptr,
44
+ tgt,
45
+ base,
46
+ need_reg,
47
+ counts,
48
+ wsums,
49
+ scale,
50
+ gram,
51
+ reg,
52
+ ):
53
+ """Counts, weight sums, design scales and the control regression's moments.
54
+
55
+ counts[c] = (treated, controls); wsums[c] = (treated weight, control weight,
56
+ treated weight squares, control weight squares).
57
+ """
58
+ n = g.shape[0]
59
+ K = x.shape[2]
60
+ for c in prange(members.shape[0]):
61
+ first = cell_ptr[c]
62
+ L = cell_ptr[c + 1] - first
63
+ xp = xper[c]
64
+ wp = wper[c]
65
+ # local accumulators: neighbouring comparisons' outputs share cache lines
66
+ cnt = np.zeros(2, dtype=np.int64)
67
+ ws = np.zeros(4)
68
+ sc = np.zeros(K)
69
+ gr = np.zeros((K, K))
70
+ rg = np.zeros((K, L))
71
+ for i in range(n):
72
+ if members[c, g[i]] == 0:
73
+ continue
74
+ treated = g[i] == cohort[c]
75
+ wi = w2[i, wp]
76
+ if treated:
77
+ cnt[0] += 1
78
+ ws[0] += wi
79
+ ws[2] += wi * wi
80
+ else:
81
+ cnt[1] += 1
82
+ ws[1] += wi
83
+ ws[3] += wi * wi
84
+ for a in range(K):
85
+ xa = x[i, xp, a]
86
+ sc[a] += wi * xa * xa
87
+ if need_reg and not treated:
88
+ for a in range(K):
89
+ xa = x[i, xp, a] * wi
90
+ for b in range(K):
91
+ gr[a, b] += xa * x[i, xp, b]
92
+ for k in range(L):
93
+ rg[a, k] += xa * (y[i, tgt[first + k]] - y[i, base[first + k]])
94
+ counts[c, :] = cnt
95
+ wsums[c, :] = ws
96
+ scale[c, :] = sc
97
+ if need_reg:
98
+ gram[c, :, :] = gr
99
+ reg[c, :, :L] = rg
100
+
101
+
102
+ @njit(parallel=True, cache=True)
103
+ def newton_stats(g, x, w2, members, cohort, xper, wper, beta, active, loss, grad, hess, margin):
104
+ """Logit loss, gradient, information and smallest margin of the active comparisons."""
105
+ n = g.shape[0]
106
+ K = x.shape[2]
107
+ for c in prange(members.shape[0]):
108
+ if not active[c]:
109
+ continue
110
+ xp = xper[c]
111
+ wp = wper[c]
112
+ total = 0.0
113
+ low = np.inf
114
+ gd = np.zeros(K)
115
+ hs = np.zeros((K, K))
116
+ for i in range(n):
117
+ if members[c, g[i]] == 0:
118
+ continue
119
+ d = 1.0 if g[i] == cohort[c] else 0.0
120
+ wi = w2[i, wp]
121
+ eta = 0.0
122
+ for a in range(K):
123
+ eta += x[i, xp, a] * beta[c, a]
124
+ e = np.exp(-abs(eta))
125
+ p = 1.0 / (1.0 + e) if eta >= 0.0 else e / (1.0 + e)
126
+ total += wi * (max(eta, 0.0) + np.log1p(e) - d * eta)
127
+ r = wi * (d - p)
128
+ v = wi * p * (1.0 - p)
129
+ for a in range(K):
130
+ xa = x[i, xp, a]
131
+ gd[a] += xa * r
132
+ xv = xa * v
133
+ for b in range(K):
134
+ hs[a, b] += xv * x[i, xp, b]
135
+ m = (2.0 * d - 1.0) * eta
136
+ if m < low:
137
+ low = m
138
+ loss[c] = total
139
+ grad[c, :] = gd
140
+ hess[c, :, :] = hs
141
+ margin[c] = low
142
+
143
+
144
+ @njit(parallel=True, cache=True)
145
+ def contrast_moments(
146
+ g,
147
+ y,
148
+ x,
149
+ w2,
150
+ members,
151
+ cohort,
152
+ xper,
153
+ wper,
154
+ cell_ptr,
155
+ tgt,
156
+ base,
157
+ method,
158
+ beta,
159
+ reg,
160
+ control_max,
161
+ s1,
162
+ xt,
163
+ w0,
164
+ w02,
165
+ s0,
166
+ xc,
167
+ xcy,
168
+ hess,
169
+ pmin,
170
+ pmax,
171
+ trimmed,
172
+ capped,
173
+ ):
174
+ """The moments of one pass after the nuisance fits (``fit_contrasts``)."""
175
+ n = g.shape[0]
176
+ K = x.shape[2]
177
+ cap = 1.0 - 1e-6
178
+ for c in prange(members.shape[0]):
179
+ first = cell_ptr[c]
180
+ L = cell_ptr[c + 1] - first
181
+ xp = xper[c]
182
+ wp = wper[c]
183
+ res = np.empty(L)
184
+ a1 = np.zeros(L)
185
+ axt = np.zeros(K)
186
+ a0 = np.zeros(L)
187
+ axc = np.zeros(K)
188
+ axcy = np.zeros((K, L))
189
+ ah = np.zeros((K, K))
190
+ sw0 = 0.0
191
+ sw02 = 0.0
192
+ lo = 1.0
193
+ hi = 0.0
194
+ nt = 0
195
+ nc = 0
196
+ for i in range(n):
197
+ if members[c, g[i]] == 0:
198
+ continue
199
+ treated = g[i] == cohort[c]
200
+ wi = w2[i, wp]
201
+ for k in range(L):
202
+ r = y[i, tgt[first + k]] - y[i, base[first + k]]
203
+ if method != IPW:
204
+ for a in range(K):
205
+ r -= x[i, xp, a] * reg[c, a, k]
206
+ res[k] = r
207
+ if treated:
208
+ for k in range(L):
209
+ a1[k] += wi * res[k]
210
+ for a in range(K):
211
+ axt[a] += wi * x[i, xp, a]
212
+ if method == REG:
213
+ continue
214
+ eta = 0.0
215
+ for a in range(K):
216
+ eta += x[i, xp, a] * beta[c, a]
217
+ p = min(_expit(eta), cap)
218
+ if p < lo:
219
+ lo = p
220
+ if p > hi:
221
+ hi = p
222
+ if (not treated) and p >= control_max:
223
+ nt += 1
224
+ if p >= cap:
225
+ nc += 1
226
+ wc = 0.0
227
+ if (not treated) and p < control_max:
228
+ wc = p / (1.0 - p) * wi
229
+ sw0 += wc
230
+ sw02 += wc * wc
231
+ for k in range(L):
232
+ a0[k] += wc * res[k]
233
+ v = wi * p * (1.0 - p)
234
+ for a in range(K):
235
+ xa = x[i, xp, a]
236
+ axc[a] += wc * xa
237
+ for k in range(L):
238
+ axcy[a, k] += xa * wc * res[k]
239
+ xv = xa * v
240
+ for b in range(K):
241
+ ah[a, b] += xv * x[i, xp, b]
242
+ s1[c, :L] = a1
243
+ xt[c, :] = axt
244
+ s0[c, :L] = a0
245
+ xc[c, :] = axc
246
+ xcy[c, :, :L] = axcy
247
+ hess[c, :, :] = ah
248
+ w0[c] = sw0
249
+ w02[c] = sw02
250
+ pmin[c] = lo
251
+ pmax[c] = hi
252
+ trimmed[c] = nt
253
+ capped[c] = nc
254
+
255
+
256
+ @njit(parallel=True, cache=True)
257
+ def unit_scores(
258
+ g,
259
+ y,
260
+ x,
261
+ w2,
262
+ members,
263
+ cohort,
264
+ xper,
265
+ wper,
266
+ cell_ptr,
267
+ tgt,
268
+ base,
269
+ col,
270
+ method,
271
+ beta,
272
+ reg,
273
+ control_max,
274
+ treated_mean,
275
+ control_mean,
276
+ treated_total,
277
+ control_total,
278
+ outcome_adj,
279
+ prop_adj,
280
+ scores,
281
+ ):
282
+ """Each unit's influence-function scores, written into its comparison's columns."""
283
+ n = g.shape[0]
284
+ K = x.shape[2]
285
+ cap = 1.0 - 1e-6
286
+ for c in prange(members.shape[0]):
287
+ first = cell_ptr[c]
288
+ L = cell_ptr[c + 1] - first
289
+ xp = xper[c]
290
+ wp = wper[c]
291
+ for i in range(n):
292
+ if members[c, g[i]] == 0:
293
+ continue
294
+ treated = g[i] == cohort[c]
295
+ wi = w2[i, wp]
296
+ p = 0.0
297
+ wc = 0.0
298
+ if method != REG:
299
+ eta = 0.0
300
+ for a in range(K):
301
+ eta += x[i, xp, a] * beta[c, a]
302
+ p = min(_expit(eta), cap)
303
+ if (not treated) and p < control_max:
304
+ wc = p / (1.0 - p)
305
+ oadj = 0.0
306
+ if method != IPW and not treated:
307
+ for a in range(K):
308
+ oadj += x[i, xp, a] * outcome_adj[c, a]
309
+ dpx = (1.0 if treated else 0.0) - p
310
+ for k in range(L):
311
+ r = y[i, tgt[first + k]] - y[i, base[first + k]]
312
+ if method != IPW:
313
+ for a in range(K):
314
+ r -= x[i, xp, a] * reg[c, a, k]
315
+ phi = 0.0
316
+ if treated:
317
+ phi += (r - treated_mean[c, k]) / treated_total[c]
318
+ if method != REG:
319
+ phi -= wc * (r - control_mean[c, k]) / control_total[c]
320
+ adj = 0.0
321
+ for a in range(K):
322
+ adj += x[i, xp, a] * prop_adj[c, a, k]
323
+ phi += dpx * adj
324
+ if method != IPW and not treated:
325
+ phi += r * oadj
326
+ scores[i, col[first + k]] = phi * wi
327
+
328
+
329
+ class BalancedCS:
330
+ """The compiled passes for a set of comparisons on balanced batches.
331
+
332
+ ``comparisons`` are ``cs.Comparison`` objects. ``size`` is the number of
333
+ cohort labels (members tables have that length).
334
+ """
335
+
336
+ def __init__(self, comparisons, n_cells):
337
+ self.comparisons = comparisons
338
+ C = len(comparisons)
339
+ self.C = C
340
+ self.members = np.ascontiguousarray(
341
+ np.vstack([c.members for c in comparisons]), dtype=np.uint8
342
+ )
343
+ self.cohort = np.array([c.cohort for c in comparisons], dtype=np.int64)
344
+ self.xper = np.array([c.xperiod for c in comparisons], dtype=np.int64)
345
+ self.wper = np.array([c.weightperiod for c in comparisons], dtype=np.int64)
346
+ sizes = np.array([len(c.cells) for c in comparisons], dtype=np.int64)
347
+ self.cell_ptr = np.r_[0, np.cumsum(sizes)].astype(np.int64)
348
+ self.tgt = np.concatenate([c.targets for c in comparisons]).astype(np.int64)
349
+ self.base = np.concatenate([c.bases for c in comparisons]).astype(np.int64)
350
+ self.col = np.concatenate([np.asarray(c.cells) for c in comparisons]).astype(np.int64)
351
+ self.sizes = sizes
352
+ self.Lmax = int(sizes.max())
353
+ self.n_cells = n_cells
354
+
355
+ def arrays(self, raw):
356
+ """(g, y, x, w2): the batch as contiguous arrays, weights as units by periods."""
357
+ g, y, x, w = raw
358
+ g = np.ascontiguousarray(g, dtype=np.int64)
359
+ y = np.ascontiguousarray(y, dtype=np.float64)
360
+ x = np.ascontiguousarray(x, dtype=np.float64)
361
+ w = np.asarray(w, dtype=np.float64)
362
+ w2 = np.ascontiguousarray(w if w.ndim == 2 else w[:, None])
363
+ return g, y, x, w2
364
+
365
+ def _wper(self, w2):
366
+ return self.wper if w2.shape[1] > 1 else np.zeros(self.C, dtype=np.int64)
367
+
368
+ # ---------------------------------------------------------------- nuisance
369
+ def initial(self, raw, K, need_reg, out):
370
+ """Add a batch's counts, weights, scales and control moments to ``out``."""
371
+ g, y, x, w2 = self.arrays(raw)
372
+ C = self.C
373
+ counts = np.zeros((C, 2), dtype=np.int64)
374
+ wsums = np.zeros((C, 4))
375
+ scale = np.zeros((C, K))
376
+ gram = np.zeros((C, K, K)) if need_reg else np.zeros((1, 1, 1))
377
+ reg = np.zeros((C, K, self.Lmax)) if need_reg else np.zeros((1, 1, 1))
378
+ initial_sums(
379
+ g,
380
+ y,
381
+ x,
382
+ w2,
383
+ self.members,
384
+ self.cohort,
385
+ self.xper,
386
+ self._wper(w2),
387
+ self.cell_ptr,
388
+ self.tgt,
389
+ self.base,
390
+ need_reg,
391
+ counts,
392
+ wsums,
393
+ scale,
394
+ gram,
395
+ reg,
396
+ )
397
+ out["counts"] += counts
398
+ out["wsums"] += wsums
399
+ out["scale"] += scale
400
+ if need_reg:
401
+ out["gram"] += gram
402
+ out["reg"] += reg
403
+
404
+ def newton(self, raw, betas, active, K):
405
+ """(loss, gradient, information, margin) of a batch for the active comparisons."""
406
+ g, _, x, w2 = self.arrays(raw)
407
+ C = self.C
408
+ loss = np.zeros(C)
409
+ grad = np.zeros((C, K))
410
+ hess = np.zeros((C, K, K))
411
+ margin = np.full(C, np.inf)
412
+ newton_stats(
413
+ g,
414
+ x,
415
+ w2,
416
+ self.members,
417
+ self.cohort,
418
+ self.xper,
419
+ self._wper(w2),
420
+ betas,
421
+ active,
422
+ loss,
423
+ grad,
424
+ hess,
425
+ margin,
426
+ )
427
+ return loss, grad, hess, margin
428
+
429
+ # ---------------------------------------------------------------- contrasts
430
+ def moments(self, raw, method, beta, reg, control_max, K, acc):
431
+ g, y, x, w2 = self.arrays(raw)
432
+ C, L = self.C, self.Lmax
433
+ part = dict(
434
+ s1=np.zeros((C, L)),
435
+ xt=np.zeros((C, K)),
436
+ w0=np.zeros(C),
437
+ w02=np.zeros(C),
438
+ s0=np.zeros((C, L)),
439
+ xc=np.zeros((C, K)),
440
+ xcy=np.zeros((C, K, L)),
441
+ hess=np.zeros((C, K, K)),
442
+ pmin=np.ones(C),
443
+ pmax=np.zeros(C),
444
+ trimmed=np.zeros(C, dtype=np.int64),
445
+ capped=np.zeros(C, dtype=np.int64),
446
+ )
447
+ contrast_moments(
448
+ g,
449
+ y,
450
+ x,
451
+ w2,
452
+ self.members,
453
+ self.cohort,
454
+ self.xper,
455
+ self._wper(w2),
456
+ self.cell_ptr,
457
+ self.tgt,
458
+ self.base,
459
+ METHOD_CODES[method],
460
+ beta,
461
+ reg,
462
+ control_max,
463
+ part["s1"],
464
+ part["xt"],
465
+ part["w0"],
466
+ part["w02"],
467
+ part["s0"],
468
+ part["xc"],
469
+ part["xcy"],
470
+ part["hess"],
471
+ part["pmin"],
472
+ part["pmax"],
473
+ part["trimmed"],
474
+ part["capped"],
475
+ )
476
+ for key in ("s1", "xt", "w0", "w02", "s0", "xc", "xcy", "hess", "trimmed", "capped"):
477
+ acc[key] += part[key]
478
+ acc["pmin"] = np.minimum(acc["pmin"], part["pmin"])
479
+ acc["pmax"] = np.maximum(acc["pmax"], part["pmax"])
480
+
481
+ # ---------------------------------------------------------------- scores
482
+ def scores(self, raw, method, packed, control_max):
483
+ """The batch's scores, units by cells; ``packed`` as ``pack_fitted`` makes it."""
484
+ g, y, x, w2 = self.arrays(raw)
485
+ out = np.zeros((len(g), self.n_cells))
486
+ unit_scores(
487
+ g,
488
+ y,
489
+ x,
490
+ w2,
491
+ self.members,
492
+ self.cohort,
493
+ self.xper,
494
+ self._wper(w2),
495
+ self.cell_ptr,
496
+ self.tgt,
497
+ self.base,
498
+ self.col,
499
+ METHOD_CODES[method],
500
+ packed["beta"],
501
+ packed["reg"],
502
+ control_max,
503
+ packed["treated_mean"],
504
+ packed["control_mean"],
505
+ packed["treated_total"],
506
+ packed["control_total"],
507
+ packed["outcome_adj"],
508
+ packed["prop_adj"],
509
+ out,
510
+ )
511
+ return out
512
+
513
+ def pack_fitted(self, fitted, K):
514
+ """Arrays of the fitted contrasts' parameters, padded to the widest comparison."""
515
+ C, L = self.C, self.Lmax
516
+ packed = dict(
517
+ beta=np.zeros((C, K)),
518
+ reg=np.zeros((C, K, L)),
519
+ treated_mean=np.zeros((C, L)),
520
+ control_mean=np.zeros((C, L)),
521
+ treated_total=np.ones(C),
522
+ control_total=np.ones(C),
523
+ outcome_adj=np.zeros((C, K)),
524
+ prop_adj=np.zeros((C, K, L)),
525
+ )
526
+ for j, f in enumerate(fitted):
527
+ n = self.sizes[j]
528
+ packed["beta"][j] = f.nuisance.beta
529
+ packed["reg"][j, :, :n] = f.nuisance.regression
530
+ packed["treated_mean"][j, :n] = f.treated_mean
531
+ packed["control_mean"][j, :n] = f.control_mean
532
+ packed["treated_total"][j] = f.treated_total
533
+ packed["control_total"][j] = f.control_total
534
+ packed["outcome_adj"][j] = f.outcome_adjustment
535
+ packed["prop_adj"][j, :, :n] = f.propensity_adjustment
536
+ return packed