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,342 @@
|
|
|
1
|
+
"""Compiled passes of the two-stage (Gardner) fit and its first-stage reduction.
|
|
2
|
+
|
|
3
|
+
Each pass visits every unit and does a handful of small weighted sums over the
|
|
4
|
+
unit's periods and the P design columns (the period dummies and covariates).
|
|
5
|
+
In NumPy that is a few hundred array operations per batch of units, which the
|
|
6
|
+
interpreter's lock serializes. The kernels do the same sums in one compiled
|
|
7
|
+
call per batch.
|
|
8
|
+
|
|
9
|
+
Units are processed in fixed-size chunks, and a chunk adds its units in order,
|
|
10
|
+
so the sums, and with them the estimates, do not depend on the number of
|
|
11
|
+
threads; the chunks' partial sums are added in order afterwards. The NumPy code
|
|
12
|
+
in ``linear.py`` and ``gardner.py`` remains the reference; 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
|
+
CHUNK = 64 # units per chunk; fixed so results do not depend on threads
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@njit(cache=True, inline="always")
|
|
24
|
+
def _design(x, i, t, T1, columns, out):
|
|
25
|
+
"""Row (unit i, period t) of the design restricted to ``columns``:
|
|
26
|
+
period dummies for periods 1.. then covariates, with missing values as 0."""
|
|
27
|
+
for a in range(columns.shape[0]):
|
|
28
|
+
j = columns[a]
|
|
29
|
+
if j < T1:
|
|
30
|
+
out[t, a] = 1.0 if (t >= 1 and j == t - 1) else 0.0
|
|
31
|
+
else:
|
|
32
|
+
v = x[i, t, j - T1]
|
|
33
|
+
out[t, a] = v if np.isfinite(v) else 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@njit(parallel=True, cache=True)
|
|
37
|
+
def reduce_moments(y, x, w, wc, columns, do_clean, Q, q, norms, Qc, qc, bad):
|
|
38
|
+
"""Within-unit moments of all columns for the rows with weights ``w``, and
|
|
39
|
+
(``Qc``, ``qc``) for the rows with weights ``wc``; ``norms`` are the columns'
|
|
40
|
+
weighted sums of squares. ``bad[chunk]`` is 1 when a unit has no weight.
|
|
41
|
+
|
|
42
|
+
A unit's demeaned design is nonzero only in the columns where one of its
|
|
43
|
+
rows is, which with fixed-effect dummies is a few of the P (its own levels,
|
|
44
|
+
not all of them), so when it is under half of them the products visit those
|
|
45
|
+
columns alone. The other entries would add exact zeros."""
|
|
46
|
+
n, T = y.shape
|
|
47
|
+
P = columns.shape[0]
|
|
48
|
+
T1 = T - 1
|
|
49
|
+
chunks = (n + CHUNK - 1) // CHUNK
|
|
50
|
+
for c in prange(chunks):
|
|
51
|
+
X = np.empty((T, P))
|
|
52
|
+
xm = np.zeros(P)
|
|
53
|
+
yv = np.zeros(T)
|
|
54
|
+
act = np.empty(P, dtype=np.int64)
|
|
55
|
+
for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
|
|
56
|
+
for t in range(T):
|
|
57
|
+
_design(x, i, t, T1, columns, X)
|
|
58
|
+
yv[t] = y[i, t] if np.isfinite(y[i, t]) else 0.0
|
|
59
|
+
nact = 0
|
|
60
|
+
for a in range(P):
|
|
61
|
+
for t in range(T):
|
|
62
|
+
if X[t, a] != 0.0:
|
|
63
|
+
act[nact] = a
|
|
64
|
+
nact += 1
|
|
65
|
+
break
|
|
66
|
+
if 2 * nact >= P:
|
|
67
|
+
# mostly dense: contiguous loops over every column are faster than
|
|
68
|
+
# indexed ones, and the skipped entries are exact zeros either way
|
|
69
|
+
nact = P
|
|
70
|
+
for a in range(P):
|
|
71
|
+
act[a] = a
|
|
72
|
+
for pass_ in range(2 if do_clean else 1):
|
|
73
|
+
mass = 0.0
|
|
74
|
+
for t in range(T):
|
|
75
|
+
mass += w[i, t] if pass_ == 0 else wc[i, t]
|
|
76
|
+
if mass <= 0.0:
|
|
77
|
+
bad[c] = 1
|
|
78
|
+
continue
|
|
79
|
+
for a in range(P):
|
|
80
|
+
xm[a] = 0.0
|
|
81
|
+
ym = 0.0
|
|
82
|
+
for t in range(T):
|
|
83
|
+
wt = w[i, t] if pass_ == 0 else wc[i, t]
|
|
84
|
+
ym += wt * yv[t]
|
|
85
|
+
for ia in range(nact):
|
|
86
|
+
a = act[ia]
|
|
87
|
+
xm[a] += wt * X[t, a]
|
|
88
|
+
ym /= mass
|
|
89
|
+
for ia in range(nact):
|
|
90
|
+
a = act[ia]
|
|
91
|
+
xm[a] /= mass
|
|
92
|
+
for t in range(T):
|
|
93
|
+
wt = w[i, t] if pass_ == 0 else wc[i, t]
|
|
94
|
+
if wt == 0.0:
|
|
95
|
+
continue
|
|
96
|
+
ry = yv[t] - ym
|
|
97
|
+
for ia in range(nact):
|
|
98
|
+
a = act[ia]
|
|
99
|
+
xa = (X[t, a] - xm[a]) * wt
|
|
100
|
+
if pass_ == 0:
|
|
101
|
+
q[c, a] += xa * ry
|
|
102
|
+
if nact == P:
|
|
103
|
+
# every column is active: a contiguous loop vectorizes
|
|
104
|
+
for b in range(P):
|
|
105
|
+
Q[c, a, b] += xa * (X[t, b] - xm[b])
|
|
106
|
+
else:
|
|
107
|
+
for ib in range(nact):
|
|
108
|
+
b = act[ib]
|
|
109
|
+
Q[c, a, b] += xa * (X[t, b] - xm[b])
|
|
110
|
+
else:
|
|
111
|
+
qc[c, a] += xa * ry
|
|
112
|
+
if nact == P:
|
|
113
|
+
for b in range(P):
|
|
114
|
+
Qc[c, a, b] += xa * (X[t, b] - xm[b])
|
|
115
|
+
else:
|
|
116
|
+
for ib in range(nact):
|
|
117
|
+
b = act[ib]
|
|
118
|
+
Qc[c, a, b] += xa * (X[t, b] - xm[b])
|
|
119
|
+
for t in range(T):
|
|
120
|
+
for ia in range(nact):
|
|
121
|
+
a = act[ia]
|
|
122
|
+
norms[c, a] += w[i, t] * X[t, a] * X[t, a]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@njit(cache=True, inline="always")
|
|
126
|
+
def _first_stage(g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, score1, obs):
|
|
127
|
+
"""One unit's first-stage residuals ``u1`` (clean-sample imputation), the
|
|
128
|
+
clean-weighted mean ``xm`` of its design and its first-stage score.
|
|
129
|
+
Returns 0, or 1 when the unit has no untreated observation."""
|
|
130
|
+
T = times.shape[0]
|
|
131
|
+
P = columns.shape[0]
|
|
132
|
+
for t in range(T):
|
|
133
|
+
_design(x, i, t, T1, columns, X)
|
|
134
|
+
mass = 0.0
|
|
135
|
+
for a in range(P):
|
|
136
|
+
xm[a] = 0.0
|
|
137
|
+
alpha = 0.0
|
|
138
|
+
for t in range(T):
|
|
139
|
+
obs[t] = np.isfinite(y[i, t])
|
|
140
|
+
use = obs[t] and (g[i] == 0 or times[t] < g[i] - anticipation)
|
|
141
|
+
cw = w[i, t] if use else 0.0
|
|
142
|
+
fit = 0.0
|
|
143
|
+
for a in range(P):
|
|
144
|
+
fit += X[t, a] * theta[a]
|
|
145
|
+
u1[t] = (y[i, t] - fit) if obs[t] else 0.0
|
|
146
|
+
mass += cw
|
|
147
|
+
alpha += cw * u1[t]
|
|
148
|
+
for a in range(P):
|
|
149
|
+
xm[a] += cw * X[t, a]
|
|
150
|
+
if mass <= 0.0:
|
|
151
|
+
return 1
|
|
152
|
+
alpha /= mass
|
|
153
|
+
for a in range(P):
|
|
154
|
+
xm[a] /= mass
|
|
155
|
+
score1[a] = 0.0
|
|
156
|
+
for t in range(T):
|
|
157
|
+
use = obs[t] and (g[i] == 0 or times[t] < g[i] - anticipation)
|
|
158
|
+
cw = w[i, t] if use else 0.0
|
|
159
|
+
u1[t] = (u1[t] - alpha) if obs[t] else 0.0
|
|
160
|
+
for a in range(P):
|
|
161
|
+
score1[a] += cw * u1[t] * (X[t, a] - xm[a])
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@njit(cache=True, inline="always")
|
|
166
|
+
def _indicator(g, i, t, times, anticipation, horizons, m, dynamic):
|
|
167
|
+
"""Second-stage indicator of coefficient m for unit i in period t (before
|
|
168
|
+
the observed-row condition)."""
|
|
169
|
+
if g[i] == 0:
|
|
170
|
+
return 0.0
|
|
171
|
+
event = times[t] - g[i]
|
|
172
|
+
if dynamic:
|
|
173
|
+
return 1.0 if event == horizons[m] else 0.0
|
|
174
|
+
return 1.0 if event >= -anticipation else 0.0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@njit(parallel=True, cache=True)
|
|
178
|
+
def second_stage_sums(
|
|
179
|
+
g, y, x, w, columns, theta, times, anticipation, horizons, dynamic, mass, rhs, C, bad
|
|
180
|
+
):
|
|
181
|
+
"""The second stage's sums: Z'w, Z'w u1 and Z'w (X - xm) (``gardner._fit``)."""
|
|
182
|
+
n, T = y.shape
|
|
183
|
+
P = columns.shape[0]
|
|
184
|
+
M = mass.shape[1]
|
|
185
|
+
T1 = T - 1
|
|
186
|
+
chunks = (n + CHUNK - 1) // CHUNK
|
|
187
|
+
for c in prange(chunks):
|
|
188
|
+
X = np.empty((T, P))
|
|
189
|
+
xm = np.zeros(P)
|
|
190
|
+
u1 = np.zeros(T)
|
|
191
|
+
s1 = np.zeros(P)
|
|
192
|
+
obs = np.zeros(T, dtype=np.bool_)
|
|
193
|
+
for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
|
|
194
|
+
if _first_stage(
|
|
195
|
+
g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
|
|
196
|
+
):
|
|
197
|
+
bad[c] = 1
|
|
198
|
+
continue
|
|
199
|
+
for t in range(T):
|
|
200
|
+
if not obs[t]:
|
|
201
|
+
continue
|
|
202
|
+
for m in range(M):
|
|
203
|
+
z = _indicator(g, i, t, times, anticipation, horizons, m, dynamic)
|
|
204
|
+
if z == 0.0:
|
|
205
|
+
continue
|
|
206
|
+
mass[c, m] += w[i, t]
|
|
207
|
+
rhs[c, m] += w[i, t] * u1[t]
|
|
208
|
+
for a in range(P):
|
|
209
|
+
C[c, m, a] += w[i, t] * (X[t, a] - xm[a])
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@njit(parallel=True, cache=True)
|
|
213
|
+
def cluster_scores(
|
|
214
|
+
g, y, x, w, columns, theta, times, anticipation, horizons, dynamic, beta, H, mass, out, bad
|
|
215
|
+
):
|
|
216
|
+
"""Each unit's second-stage score, corrected for the first stage and scaled
|
|
217
|
+
by the indicators' mass: ``out[unit, m]``."""
|
|
218
|
+
n, T = y.shape
|
|
219
|
+
P = columns.shape[0]
|
|
220
|
+
M = beta.shape[0]
|
|
221
|
+
T1 = T - 1
|
|
222
|
+
chunks = (n + CHUNK - 1) // CHUNK
|
|
223
|
+
for c in prange(chunks):
|
|
224
|
+
X = np.empty((T, P))
|
|
225
|
+
xm = np.zeros(P)
|
|
226
|
+
u1 = np.zeros(T)
|
|
227
|
+
s1 = np.zeros(P)
|
|
228
|
+
obs = np.zeros(T, dtype=np.bool_)
|
|
229
|
+
z = np.zeros(M)
|
|
230
|
+
for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
|
|
231
|
+
if _first_stage(
|
|
232
|
+
g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
|
|
233
|
+
):
|
|
234
|
+
bad[c] = 1
|
|
235
|
+
continue
|
|
236
|
+
score2 = np.zeros(M)
|
|
237
|
+
for t in range(T):
|
|
238
|
+
fitted = 0.0
|
|
239
|
+
for m in range(M):
|
|
240
|
+
z[m] = _indicator(g, i, t, times, anticipation, horizons, m, dynamic)
|
|
241
|
+
if not obs[t]:
|
|
242
|
+
z[m] = 0.0
|
|
243
|
+
fitted += z[m] * beta[m]
|
|
244
|
+
u2 = u1[t] - fitted
|
|
245
|
+
for m in range(M):
|
|
246
|
+
score2[m] += w[i, t] * u2 * z[m]
|
|
247
|
+
for m in range(M):
|
|
248
|
+
adjust = 0.0
|
|
249
|
+
for a in range(P):
|
|
250
|
+
adjust += s1[a] * H[a, m]
|
|
251
|
+
out[i, m] = (score2[m] - adjust) / mass[m]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _arrays(raw):
|
|
255
|
+
g, y, x, w = raw
|
|
256
|
+
g = np.ascontiguousarray(g, dtype=np.int64)
|
|
257
|
+
y = np.ascontiguousarray(y, dtype=np.float64)
|
|
258
|
+
x = np.ascontiguousarray(x, dtype=np.float64)
|
|
259
|
+
w = np.ascontiguousarray(w, dtype=np.float64)
|
|
260
|
+
return g, y, x, w
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _chunks(n):
|
|
264
|
+
return (n + CHUNK - 1) // CHUNK
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class TwoStageKernels:
|
|
268
|
+
"""The compiled passes for a store: ``times`` are its period labels."""
|
|
269
|
+
|
|
270
|
+
def __init__(self, times, anticipation):
|
|
271
|
+
self.times = np.ascontiguousarray(times, dtype=np.int64)
|
|
272
|
+
self.anticipation = int(anticipation)
|
|
273
|
+
|
|
274
|
+
def reduce(self, batches, n_columns, clean):
|
|
275
|
+
"""(Q, q, norms, Qc, qc) over the batches of (g, y, x, w), all columns kept."""
|
|
276
|
+
columns = np.arange(n_columns, dtype=np.int64)
|
|
277
|
+
P = n_columns
|
|
278
|
+
Q, norms, Qc, qc = np.zeros((P, P)), np.zeros(P), np.zeros((P, P)), np.zeros(P)
|
|
279
|
+
q = np.zeros(P)
|
|
280
|
+
for raw in batches():
|
|
281
|
+
g, y, x, w = _arrays(raw)
|
|
282
|
+
wc = w * clean(g) if clean is not None else w
|
|
283
|
+
k = _chunks(len(g))
|
|
284
|
+
parts = dict(
|
|
285
|
+
Q=np.zeros((k, P, P)),
|
|
286
|
+
q=np.zeros((k, P)),
|
|
287
|
+
norms=np.zeros((k, P)),
|
|
288
|
+
Qc=np.zeros((k, P, P)),
|
|
289
|
+
qc=np.zeros((k, P)),
|
|
290
|
+
bad=np.zeros(k, dtype=np.int8),
|
|
291
|
+
)
|
|
292
|
+
reduce_moments(
|
|
293
|
+
y, x, w, wc, columns, clean is not None, parts["Q"], parts["q"], parts["norms"], parts["Qc"],
|
|
294
|
+
parts["qc"], parts["bad"],
|
|
295
|
+
) # fmt: skip
|
|
296
|
+
if parts["bad"].any():
|
|
297
|
+
raise ValueError("each group requires a positive-weight estimation observation")
|
|
298
|
+
Q += parts["Q"].sum(axis=0)
|
|
299
|
+
q += parts["q"].sum(axis=0)
|
|
300
|
+
norms += parts["norms"].sum(axis=0)
|
|
301
|
+
Qc += parts["Qc"].sum(axis=0)
|
|
302
|
+
qc += parts["qc"].sum(axis=0)
|
|
303
|
+
return Q, q, norms, Qc, qc
|
|
304
|
+
|
|
305
|
+
def second_stage(self, batches, keep, theta, horizons, dynamic):
|
|
306
|
+
"""(mass, rhs, C): the sums of the second stage's normal equations."""
|
|
307
|
+
columns = np.ascontiguousarray(keep, dtype=np.int64)
|
|
308
|
+
M, P = len(horizons), len(columns)
|
|
309
|
+
mass, rhs, C = np.zeros(M), np.zeros(M), np.zeros((M, P))
|
|
310
|
+
hz = np.ascontiguousarray(horizons, dtype=np.int64)
|
|
311
|
+
for raw in batches():
|
|
312
|
+
g, y, x, w = _arrays(raw)
|
|
313
|
+
k = _chunks(len(g))
|
|
314
|
+
parts = (
|
|
315
|
+
np.zeros((k, M)),
|
|
316
|
+
np.zeros((k, M)),
|
|
317
|
+
np.zeros((k, M, P)),
|
|
318
|
+
np.zeros(k, dtype=np.int8),
|
|
319
|
+
)
|
|
320
|
+
second_stage_sums(
|
|
321
|
+
g, y, x, w, columns, theta, self.times, self.anticipation, hz, dynamic, *parts
|
|
322
|
+
)
|
|
323
|
+
if parts[3].any():
|
|
324
|
+
raise ValueError("requires at least one untreated observation per unit")
|
|
325
|
+
mass += parts[0].sum(axis=0)
|
|
326
|
+
rhs += parts[1].sum(axis=0)
|
|
327
|
+
C += parts[2].sum(axis=0)
|
|
328
|
+
return mass, rhs, C
|
|
329
|
+
|
|
330
|
+
def scores(self, raw, keep, theta, horizons, dynamic, beta, H, mass):
|
|
331
|
+
columns = np.ascontiguousarray(keep, dtype=np.int64)
|
|
332
|
+
g, y, x, w = _arrays(raw)
|
|
333
|
+
out = np.zeros((len(g), len(beta)))
|
|
334
|
+
bad = np.zeros(_chunks(len(g)), dtype=np.int8)
|
|
335
|
+
cluster_scores(
|
|
336
|
+
g, y, x, w, columns, theta, self.times, self.anticipation,
|
|
337
|
+
np.ascontiguousarray(horizons, dtype=np.int64), dynamic, beta,
|
|
338
|
+
np.ascontiguousarray(H), mass, out, bad,
|
|
339
|
+
) # fmt: skip
|
|
340
|
+
if bad.any():
|
|
341
|
+
raise ValueError("requires at least one untreated observation per unit")
|
|
342
|
+
return out
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Sampling-weight validation and optional weighted batch adapters.
|
|
2
|
+
|
|
3
|
+
No treatment or panel conventions live here. Consumer code owns restrictions
|
|
4
|
+
such as weights being constant within a sampling unit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_weights(values):
|
|
11
|
+
"""Require finite, strictly positive numeric sampling weights."""
|
|
12
|
+
if not np.isfinite(values).all() or np.any(values <= 0):
|
|
13
|
+
raise ValueError("sampling weights must be finite and strictly positive")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def weighted_system(parts):
|
|
17
|
+
"""Accept (X, d, Y[, weights]); preserve the unweighted consumer protocol."""
|
|
18
|
+
X, d, Y = parts[:3]
|
|
19
|
+
w = parts[3] if len(parts) == 4 else np.ones(len(d))
|
|
20
|
+
return X, d, Y, w
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def weighted_linear(parts):
|
|
24
|
+
"""Accept (X, y[, weights]) for a weighted least-squares stratum."""
|
|
25
|
+
X, y = parts[:2]
|
|
26
|
+
return X, y, parts[2] if len(parts) == 3 else np.ones(len(y))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Bounded weighted elimination of group intercepts and small Gram reduction.
|
|
2
|
+
|
|
3
|
+
Arrays contain one batch of whole groups. No treatment, time or panel policy
|
|
4
|
+
lives here; these helpers are candidates for hdfe_stream.core.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy.linalg import solve_triangular
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def center_groups(X, y, weights):
|
|
12
|
+
"""Eliminate intercepts using row weights (zero means excluded internally)."""
|
|
13
|
+
mass = weights.sum(axis=1)
|
|
14
|
+
if np.any(mass <= 0):
|
|
15
|
+
raise ValueError("each group requires a positive-weight estimation observation")
|
|
16
|
+
xm = row_sums(weights, X) / mass[:, None]
|
|
17
|
+
ym = (weights * y).sum(axis=1) / mass
|
|
18
|
+
return X - xm[:, None, :], y - ym[:, None], xm, ym
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def moments(X, y, weights):
|
|
22
|
+
xc, yc, _, _ = center_groups(X, y, weights)
|
|
23
|
+
flat = xc.reshape(-1, xc.shape[2])
|
|
24
|
+
weighted = weights.reshape(-1, 1) * flat
|
|
25
|
+
return flat.T @ weighted, weighted.T @ yc.ravel()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Batched sums over the period axis of (units, periods, columns) arrays, as
|
|
29
|
+
# matrix products: einsum would copy its operands and run single-threaded.
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def row_sums(weights, X):
|
|
33
|
+
"""sum_t weights[b, t] * X[b, t, k] -> (units, columns)."""
|
|
34
|
+
return np.matmul(weights[:, None, :], X)[:, 0, :]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def weighted_gram(X, weights):
|
|
38
|
+
"""sum_{b,t} weights[b, t] * X[b, t, i] * X[b, t, j] -> (columns, columns)."""
|
|
39
|
+
flat = X.reshape(-1, X.shape[2])
|
|
40
|
+
return flat.T @ (weights.reshape(-1, 1) * flat)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def weighted_squares(X, weights):
|
|
44
|
+
"""sum_{b,t} weights[b, t] * X[b, t, k] ** 2 -> (columns,)."""
|
|
45
|
+
flat = X.reshape(-1, X.shape[2])
|
|
46
|
+
return (flat * flat).T @ weights.ravel()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def independent_columns(gram, *, original_norm=None, tolerance=1e-10):
|
|
50
|
+
"""Select an ordered basis after scaling columns to unit Gram diagonal.
|
|
51
|
+
|
|
52
|
+
Earlier columns have priority. Return aliases explicitly for the caller's
|
|
53
|
+
diagnostics; consumers must separately protect identified target columns.
|
|
54
|
+
|
|
55
|
+
A column is kept when its squared distance from the span of the columns kept
|
|
56
|
+
before it exceeds `tolerance`: an ordered Cholesky factorization that grows
|
|
57
|
+
one row per kept column (a triangular solve per column, O(K^3) in all).
|
|
58
|
+
"""
|
|
59
|
+
diagonal = np.maximum(np.diag(gram), 0)
|
|
60
|
+
scale = np.sqrt(diagonal)
|
|
61
|
+
live = diagonal > 0 if original_norm is None else diagonal > original_norm * 1e-24
|
|
62
|
+
safe = np.where(live, scale, 1)
|
|
63
|
+
correlation = gram / safe[:, None] / safe[None, :]
|
|
64
|
+
candidates = np.flatnonzero(live)
|
|
65
|
+
factor = np.zeros((len(candidates), len(candidates))) # lower triangular
|
|
66
|
+
kept = []
|
|
67
|
+
for j in candidates:
|
|
68
|
+
m = len(kept)
|
|
69
|
+
if m:
|
|
70
|
+
row = solve_triangular(
|
|
71
|
+
factor[:m, :m], correlation[j, kept], lower=True, check_finite=False
|
|
72
|
+
)
|
|
73
|
+
remaining = correlation[j, j] - row @ row
|
|
74
|
+
else:
|
|
75
|
+
row = np.zeros(0)
|
|
76
|
+
remaining = correlation[j, j]
|
|
77
|
+
if remaining > tolerance:
|
|
78
|
+
factor[m, :m] = row
|
|
79
|
+
factor[m, m] = np.sqrt(remaining)
|
|
80
|
+
kept.append(j)
|
|
81
|
+
return np.asarray(kept, dtype=int)
|
diff_stream/_docs.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Parameter descriptions shared by the public functions' docstrings."""
|
|
2
|
+
|
|
3
|
+
DATA = """\
|
|
4
|
+
data : polars.DataFrame, polars.LazyFrame, str or Path
|
|
5
|
+
The panel, or a Parquet path or glob. Rows are read in streaming
|
|
6
|
+
batches; the data need not fit in memory.
|
|
7
|
+
panel : tuple of str
|
|
8
|
+
The unit and time columns, ``(unit, time)``. Periods are positive
|
|
9
|
+
integers, evenly spaced (every year, every second year, ...). Event
|
|
10
|
+
times, ``anticipation`` and ``pretrend_leads`` count periods; results
|
|
11
|
+
label cohorts and periods as in the data.
|
|
12
|
+
cohort, treatment : str
|
|
13
|
+
Exactly one. ``cohort``: each unit's first treated period, constant
|
|
14
|
+
within the unit; 0 or null marks a never-treated unit. ``treatment``: a
|
|
15
|
+
0/1 column, absorbing (once 1, a unit stays treated), from which each
|
|
16
|
+
unit's first treated period is taken."""
|
|
17
|
+
|
|
18
|
+
ANTICIPATION = """\
|
|
19
|
+
anticipation : int, default 0
|
|
20
|
+
Periods before treatment in which units may already respond. Clean
|
|
21
|
+
(untreated) observations are those before ``cohort - anticipation``;
|
|
22
|
+
event times stay relative to actual treatment."""
|
|
23
|
+
|
|
24
|
+
WEIGHTS = """\
|
|
25
|
+
weights : str, optional
|
|
26
|
+
A column of sampling weights: positive, finite, and constant within
|
|
27
|
+
each unit unless ``allow_time_varying_weights=True``. They enter the
|
|
28
|
+
estimates, aggregation and clustered standard errors.
|
|
29
|
+
allow_time_varying_weights : bool, default False
|
|
30
|
+
Accept weights that change over time within a unit."""
|
|
31
|
+
|
|
32
|
+
INCOMPLETE_UNITS = """\
|
|
33
|
+
incomplete_units : {"error", "drop", "keep"}, default "error"
|
|
34
|
+
What to do with units missing some periods: refuse the data, drop
|
|
35
|
+
those units, or keep their observed rows (an unbalanced panel)."""
|
|
36
|
+
|
|
37
|
+
LAST_TREATED = """\
|
|
38
|
+
last_treated_controls : {"if_needed", "never", "always"}, default "if_needed"
|
|
39
|
+
When to use the last-treated cohort as a comparison group, keeping
|
|
40
|
+
only periods before it is treated. "if_needed" does so only when there
|
|
41
|
+
are no never-treated units (with a ``ReferenceWindowWarning``);
|
|
42
|
+
"never" requires never-treated units; "always" uses it even when
|
|
43
|
+
never-treated units exist (they remain controls too)."""
|
|
44
|
+
|
|
45
|
+
FIXED_EFFECT_SOLVER = """\
|
|
46
|
+
fixed_effect_solver : {"auto", "dense", "iterative"}, default "auto"
|
|
47
|
+
How additional fixed effects (after the bar in ``fml``) are
|
|
48
|
+
estimated. "dense" adds a dummy for each level to the regression,
|
|
49
|
+
which is exact and fastest with few levels, and limited to 256 columns in
|
|
50
|
+
all. "iterative" absorbs them by alternating projections (hdfe-stream),
|
|
51
|
+
with a time that does not grow with the number of levels and no limit
|
|
52
|
+
on it, such as county-by-year. "auto" uses dense for up to 40 columns
|
|
53
|
+
(levels and covariates) and iterative above that. Both give the same
|
|
54
|
+
estimates and standard errors."""
|
|
55
|
+
|
|
56
|
+
VCOV = """\
|
|
57
|
+
vcov : {"CRV0", "CRV1"}, default "CRV0"
|
|
58
|
+
Standard errors clustered by unit. CRV1 multiplies the variance by
|
|
59
|
+
N/(N-1)."""
|
|
60
|
+
|
|
61
|
+
RESOURCES = """\
|
|
62
|
+
n_threads : int, optional
|
|
63
|
+
Threads for numba and BLAS during the fit (stream-limits'
|
|
64
|
+
``thread_scope``). Default: every core the job has. Polars' threads
|
|
65
|
+
are fixed when polars is imported: set ``STREAM_LIMITS_MAX_THREADS``
|
|
66
|
+
in the job script.
|
|
67
|
+
max_memory_gb, memory_reserve : float, optional
|
|
68
|
+
The memory limit and the share of it kept in reserve for allocations
|
|
69
|
+
the estimates do not model (stream-limits' ``MemoryBudget``; default:
|
|
70
|
+
the limits it detects, including a scheduler job's, and a 15%
|
|
71
|
+
reserve). Fits that would exceed the limit stop with ``MemoryError``
|
|
72
|
+
before allocating.
|
|
73
|
+
workdir : str or Path, optional
|
|
74
|
+
Where temporary files are written. Default: the system temporary
|
|
75
|
+
directory.
|
|
76
|
+
profile : bool, default False
|
|
77
|
+
Return a ``Profile`` (hdfe-stream's) of the time, memory and disk the
|
|
78
|
+
fit needs, without fitting.
|
|
79
|
+
batch_units : int, optional
|
|
80
|
+
Advanced: units per numerical batch.
|
|
81
|
+
preprocess_mb : float, optional
|
|
82
|
+
Advanced: the working memory for sorting and validating the data, in
|
|
83
|
+
MB. Default: a quarter of the memory left under ``max_memory_gb``, so a
|
|
84
|
+
panel that fits is sorted in one piece; larger panels are split into
|
|
85
|
+
whole-unit pieces on disk. A smaller value uses less memory and more
|
|
86
|
+
time."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def document(function):
|
|
90
|
+
"""Fill the shared parameter blocks into a public function's docstring."""
|
|
91
|
+
blocks = {
|
|
92
|
+
name: value
|
|
93
|
+
for name, value in globals().items()
|
|
94
|
+
if name.isupper() and isinstance(value, str)
|
|
95
|
+
}
|
|
96
|
+
text = function.__doc__
|
|
97
|
+
for name, value in blocks.items():
|
|
98
|
+
text = text.replace(" {" + name + "}\n", value + "\n")
|
|
99
|
+
function.__doc__ = text
|
|
100
|
+
return function
|
diff_stream/_options.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Public option names and values, translated to the estimators' internal ones.
|
|
2
|
+
|
|
3
|
+
The public functions spell options out (``incomplete_units="drop"``); the
|
|
4
|
+
estimation code was written against shorter internal names
|
|
5
|
+
(``panel_mode="complete"``). Translation happens once, here, so that the
|
|
6
|
+
estimator functions and refit_bootstrap() accept exactly the same options.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
# public option name -> internal option name
|
|
10
|
+
NAMES = {
|
|
11
|
+
"n_threads": "threads",
|
|
12
|
+
"max_memory_gb": "memory_gb",
|
|
13
|
+
"incomplete_units": "panel_mode",
|
|
14
|
+
"last_treated_controls": "reference_group",
|
|
15
|
+
"adjustment": "est_method",
|
|
16
|
+
"propensity_trim": "trim_level",
|
|
17
|
+
"multiplier_bootstrap_reps": "bootstrap_reps",
|
|
18
|
+
"multiplier_bootstrap_seed": "bootstrap_seed",
|
|
19
|
+
"multiplier_bootstrap_weights": "bootstrap_weights",
|
|
20
|
+
"effective_sample_warning": "ess_warn_threshold",
|
|
21
|
+
"min_effective_sample": "ess_suppress_threshold",
|
|
22
|
+
"covariate_balance": "balance",
|
|
23
|
+
"balance_distributions": "balance_ecdf",
|
|
24
|
+
"residual_averaging": "avgeffectsby",
|
|
25
|
+
"leave_out": "leaveout",
|
|
26
|
+
"pretrend_leads": "pretrends",
|
|
27
|
+
"event_study": "specification",
|
|
28
|
+
"treat_sample_as_fixed": "inference",
|
|
29
|
+
"zero_tolerance": "zero_tol",
|
|
30
|
+
"fixed_effect_solver": "fe_solver",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# public option name -> {public value: internal value}
|
|
34
|
+
VALUES = {
|
|
35
|
+
"control_group": {"never_treated": "nevertreated", "not_yet_treated": "notyettreated"},
|
|
36
|
+
"incomplete_units": {"error": "balanced", "drop": "complete", "keep": "unbalanced"},
|
|
37
|
+
"last_treated_controls": {
|
|
38
|
+
"if_needed": "auto",
|
|
39
|
+
"never": "nevertreated",
|
|
40
|
+
"always": "lasttreated",
|
|
41
|
+
},
|
|
42
|
+
"adjustment": {"regression": "reg", "ipw": "ipw", "doubly_robust": "dr"},
|
|
43
|
+
"event_study": {True: "dynamic", False: "static"},
|
|
44
|
+
"treat_sample_as_fixed": {False: "unconditional", True: "conditional"},
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def internal(options):
|
|
49
|
+
"""Translate public keyword options to the internal estimator's keywords."""
|
|
50
|
+
out = {}
|
|
51
|
+
for name, value in options.items():
|
|
52
|
+
if name in VALUES:
|
|
53
|
+
choices = VALUES[name]
|
|
54
|
+
if isinstance(next(iter(choices)), bool):
|
|
55
|
+
if not isinstance(value, bool):
|
|
56
|
+
raise ValueError(f"{name} must be True or False")
|
|
57
|
+
elif not isinstance(value, str) or value not in choices:
|
|
58
|
+
listed = ", ".join(repr(c) for c in choices)
|
|
59
|
+
raise ValueError(f"{name} must be one of {listed}; got {value!r}")
|
|
60
|
+
value = choices[value]
|
|
61
|
+
out[NAMES.get(name, name)] = value
|
|
62
|
+
return out
|