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,288 @@
|
|
|
1
|
+
"""Compiled passes of the extended TWFE regression.
|
|
2
|
+
|
|
3
|
+
The expanded design has a column for every cohort, period, cohort-by-covariate,
|
|
4
|
+
period-by-covariate and treatment-cell effect, but a row has only a handful of
|
|
5
|
+
nonzero entries: its cohort and period, the covariates interacted with each,
|
|
6
|
+
and the covariates (and a one) in its treatment cell. The NumPy code builds the
|
|
7
|
+
dense design for a batch of units and multiplies it; the kernels here visit
|
|
8
|
+
only the nonzero entries, and remove the unit means (``fixed_effects="unit"``)
|
|
9
|
+
analytically.
|
|
10
|
+
|
|
11
|
+
Units are processed in fixed-size chunks whose partial sums are added in order,
|
|
12
|
+
so results do not depend on the number of threads. The NumPy code in
|
|
13
|
+
``etwfe.py`` remains the reference; the tests compare 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
|
|
21
|
+
PARTIAL_BYTES = 128e6 # memory of the chunks' partial Gram matrices
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@njit(cache=True, inline="always")
|
|
25
|
+
def _row(x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at, cols, vals):
|
|
26
|
+
"""The nonzero entries of one row of the design: fills ``cols`` and ``vals``
|
|
27
|
+
and returns their number and the row's treatment cell (-1 for none)."""
|
|
28
|
+
n = 0
|
|
29
|
+
at = 0
|
|
30
|
+
if cohort_fe:
|
|
31
|
+
cols[n] = gi
|
|
32
|
+
vals[n] = 1.0
|
|
33
|
+
n += 1
|
|
34
|
+
at = G
|
|
35
|
+
if t >= 1:
|
|
36
|
+
cols[n] = at + t - 1
|
|
37
|
+
vals[n] = 1.0
|
|
38
|
+
n += 1
|
|
39
|
+
cell = cell_at[gi, t]
|
|
40
|
+
for k in range(K):
|
|
41
|
+
v = x[i, t, k]
|
|
42
|
+
if not np.isfinite(v):
|
|
43
|
+
v = 0.0
|
|
44
|
+
cols[n] = base_c + gi * K + k
|
|
45
|
+
vals[n] = v
|
|
46
|
+
n += 1
|
|
47
|
+
if t >= 1:
|
|
48
|
+
cols[n] = base_t + (t - 1) * K + k
|
|
49
|
+
vals[n] = v
|
|
50
|
+
n += 1
|
|
51
|
+
if cell >= 0:
|
|
52
|
+
cols[n] = offset + cell * (K + 1)
|
|
53
|
+
vals[n] = 1.0
|
|
54
|
+
n += 1
|
|
55
|
+
for k in range(K):
|
|
56
|
+
v = x[i, t, k]
|
|
57
|
+
cols[n] = offset + cell * (K + 1) + 1 + k
|
|
58
|
+
vals[n] = v if np.isfinite(v) else 0.0
|
|
59
|
+
n += 1
|
|
60
|
+
return n, cell
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@njit(parallel=True, cache=True)
|
|
64
|
+
def etwfe_moments(
|
|
65
|
+
gidx, y, x, w, G, cohort_fe, unit_fe, base_c, base_t, offset, cell_at, chunk, Q, q, norms,
|
|
66
|
+
means, cdiag, bad,
|
|
67
|
+
): # fmt: skip
|
|
68
|
+
"""Gram matrix and moments of the regression, the unit-centered column
|
|
69
|
+
norms, and the weighted covariate sums of each treatment cell."""
|
|
70
|
+
n, T = y.shape
|
|
71
|
+
K = x.shape[2]
|
|
72
|
+
P = Q.shape[1]
|
|
73
|
+
chunks = Q.shape[0]
|
|
74
|
+
nz = 3 * K + 3
|
|
75
|
+
for c in prange(chunks):
|
|
76
|
+
cols = np.empty((T, nz), dtype=np.int64)
|
|
77
|
+
vals = np.empty((T, nz))
|
|
78
|
+
count = np.zeros(T, dtype=np.int64)
|
|
79
|
+
s = np.zeros(P)
|
|
80
|
+
wn = np.zeros(P)
|
|
81
|
+
seen = np.zeros(P, dtype=np.bool_)
|
|
82
|
+
touched = np.empty(T * nz, dtype=np.int64)
|
|
83
|
+
for i in range(c * chunk, min(n, (c + 1) * chunk)):
|
|
84
|
+
mass = 0.0
|
|
85
|
+
ysum = 0.0
|
|
86
|
+
ntouch = 0
|
|
87
|
+
gi = gidx[i]
|
|
88
|
+
for t in range(T):
|
|
89
|
+
m, cell = _row(
|
|
90
|
+
x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at,
|
|
91
|
+
cols[t], vals[t],
|
|
92
|
+
) # fmt: skip
|
|
93
|
+
count[t] = m
|
|
94
|
+
wt = w[i, t]
|
|
95
|
+
yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
|
|
96
|
+
mass += wt
|
|
97
|
+
ysum += wt * yt
|
|
98
|
+
for a in range(m):
|
|
99
|
+
ca = cols[t, a]
|
|
100
|
+
wa = wt * vals[t, a]
|
|
101
|
+
q[c, ca] += wa * yt
|
|
102
|
+
if unit_fe:
|
|
103
|
+
norms[c, ca] += wa * vals[t, a]
|
|
104
|
+
s[ca] += wa
|
|
105
|
+
wn[ca] += wt
|
|
106
|
+
if not seen[ca]:
|
|
107
|
+
seen[ca] = True
|
|
108
|
+
touched[ntouch] = ca
|
|
109
|
+
ntouch += 1
|
|
110
|
+
for b in range(m):
|
|
111
|
+
Q[c, ca, cols[t, b]] += wa * vals[t, b]
|
|
112
|
+
if cell >= 0:
|
|
113
|
+
means[c, cell, 0] += wt
|
|
114
|
+
for k in range(K):
|
|
115
|
+
v = x[i, t, k]
|
|
116
|
+
means[c, cell, 1 + k] += wt * (v if np.isfinite(v) else 0.0)
|
|
117
|
+
if unit_fe:
|
|
118
|
+
if mass <= 0.0:
|
|
119
|
+
bad[c] = 1
|
|
120
|
+
else:
|
|
121
|
+
for a in range(ntouch):
|
|
122
|
+
ca = touched[a]
|
|
123
|
+
q[c, ca] -= s[ca] * ysum / mass
|
|
124
|
+
for b in range(ntouch):
|
|
125
|
+
cb = touched[b]
|
|
126
|
+
Q[c, ca, cb] -= s[ca] * s[cb] / mass
|
|
127
|
+
# the centered sum of squares itself, without the cancellation
|
|
128
|
+
# of the subtraction above: a column the unit means remove
|
|
129
|
+
# entirely must come out as zero
|
|
130
|
+
for t in range(T):
|
|
131
|
+
for a in range(count[t]):
|
|
132
|
+
ca = cols[t, a]
|
|
133
|
+
d = vals[t, a] - s[ca] / mass
|
|
134
|
+
cdiag[c, ca] += w[i, t] * d * d
|
|
135
|
+
for a in range(ntouch):
|
|
136
|
+
ca = touched[a]
|
|
137
|
+
dbar = s[ca] / mass
|
|
138
|
+
cdiag[c, ca] += dbar * dbar * (mass - wn[ca])
|
|
139
|
+
for a in range(ntouch):
|
|
140
|
+
s[touched[a]] = 0.0
|
|
141
|
+
wn[touched[a]] = 0.0
|
|
142
|
+
seen[touched[a]] = False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@njit(parallel=True, cache=True)
|
|
146
|
+
def etwfe_scores(
|
|
147
|
+
gidx, y, x, w, G, cohort_fe, unit_fe, base_c, base_t, offset, cell_at, coef, H, out, bad
|
|
148
|
+
): # fmt: skip
|
|
149
|
+
"""Each unit's score of the regression times H: ``out[unit, :]``."""
|
|
150
|
+
n, T = y.shape
|
|
151
|
+
K = x.shape[2]
|
|
152
|
+
M = H.shape[1]
|
|
153
|
+
nz = 3 * K + 3
|
|
154
|
+
chunks = (n + CHUNK - 1) // CHUNK
|
|
155
|
+
for c in prange(chunks):
|
|
156
|
+
cols = np.empty((T, nz), dtype=np.int64)
|
|
157
|
+
vals = np.empty((T, nz))
|
|
158
|
+
count = np.zeros(T, dtype=np.int64)
|
|
159
|
+
resid = np.zeros(T)
|
|
160
|
+
for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
|
|
161
|
+
gi = gidx[i]
|
|
162
|
+
mass = 0.0
|
|
163
|
+
rsum = 0.0
|
|
164
|
+
for t in range(T):
|
|
165
|
+
m, cell = _row(
|
|
166
|
+
x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at,
|
|
167
|
+
cols[t], vals[t],
|
|
168
|
+
) # fmt: skip
|
|
169
|
+
count[t] = m
|
|
170
|
+
fit = 0.0
|
|
171
|
+
for a in range(m):
|
|
172
|
+
fit += vals[t, a] * coef[cols[t, a]]
|
|
173
|
+
yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
|
|
174
|
+
resid[t] = yt - fit
|
|
175
|
+
mass += w[i, t]
|
|
176
|
+
rsum += w[i, t] * resid[t]
|
|
177
|
+
if unit_fe:
|
|
178
|
+
if mass <= 0.0:
|
|
179
|
+
bad[c] = 1
|
|
180
|
+
continue
|
|
181
|
+
rsum /= mass
|
|
182
|
+
else:
|
|
183
|
+
rsum = 0.0
|
|
184
|
+
for t in range(T):
|
|
185
|
+
we = w[i, t] * (resid[t] - rsum)
|
|
186
|
+
if we == 0.0:
|
|
187
|
+
continue
|
|
188
|
+
for a in range(count[t]):
|
|
189
|
+
wa = we * vals[t, a]
|
|
190
|
+
ca = cols[t, a]
|
|
191
|
+
for m in range(M):
|
|
192
|
+
out[i, m] += wa * H[ca, m]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _arrays(raw):
|
|
196
|
+
g, y, x, w = raw
|
|
197
|
+
return (
|
|
198
|
+
np.ascontiguousarray(y, dtype=np.float64),
|
|
199
|
+
np.ascontiguousarray(x, dtype=np.float64),
|
|
200
|
+
np.ascontiguousarray(w, dtype=np.float64),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ETWFEKernels:
|
|
205
|
+
"""The compiled passes for an ``etwfe.ExtendedDesign``."""
|
|
206
|
+
|
|
207
|
+
def __init__(self, model):
|
|
208
|
+
self.model = model
|
|
209
|
+
self.P = len(model.names)
|
|
210
|
+
self.G = len(model.groups)
|
|
211
|
+
self.cohort_fe = model.fixed_effects == "cohort"
|
|
212
|
+
T = model.store.periods
|
|
213
|
+
K = model.K
|
|
214
|
+
self.base_c = (self.G if self.cohort_fe else 0) + T - 1
|
|
215
|
+
self.base_t = self.base_c + self.G * K
|
|
216
|
+
self.offset = model.offset
|
|
217
|
+
self.cell_at = np.ascontiguousarray(model.cell_at)
|
|
218
|
+
# chunks per batch: the partial Gram matrices must fit
|
|
219
|
+
self.max_chunks = int(PARTIAL_BYTES // (8 * self.P * self.P))
|
|
220
|
+
|
|
221
|
+
@property
|
|
222
|
+
def usable(self):
|
|
223
|
+
return ENABLED and self.max_chunks >= 2
|
|
224
|
+
|
|
225
|
+
def _chunking(self, n):
|
|
226
|
+
chunks = min((n + CHUNK - 1) // CHUNK, self.max_chunks)
|
|
227
|
+
return chunks, (n + chunks - 1) // chunks
|
|
228
|
+
|
|
229
|
+
def group_index(self, g):
|
|
230
|
+
return np.searchsorted(self.model.group_array, g).astype(np.int64)
|
|
231
|
+
|
|
232
|
+
def moments(self, batches):
|
|
233
|
+
"""(Q, q, norms, means): the Gram matrix, moments, column norms and
|
|
234
|
+
covariate sums of each cell."""
|
|
235
|
+
model = self.model
|
|
236
|
+
P, M, K = self.P, len(model.cells), model.K
|
|
237
|
+
Q, q, norms = np.zeros((P, P)), np.zeros(P), np.zeros(P)
|
|
238
|
+
means = np.zeros((M, K + 1))
|
|
239
|
+
cdiag = np.zeros(P)
|
|
240
|
+
for raw in batches():
|
|
241
|
+
y, x, w = _arrays(raw)
|
|
242
|
+
g = raw[0]
|
|
243
|
+
chunks, size = self._chunking(len(g))
|
|
244
|
+
parts = (
|
|
245
|
+
np.zeros((chunks, P, P)),
|
|
246
|
+
np.zeros((chunks, P)),
|
|
247
|
+
np.zeros((chunks, P)),
|
|
248
|
+
np.zeros((chunks, M, K + 1)),
|
|
249
|
+
np.zeros((chunks, P)),
|
|
250
|
+
np.zeros(chunks, dtype=np.int8),
|
|
251
|
+
)
|
|
252
|
+
etwfe_moments(
|
|
253
|
+
self.group_index(g), y, x, w, self.G,
|
|
254
|
+
self.cohort_fe, model.fixed_effects == "unit", self.base_c, self.base_t,
|
|
255
|
+
self.offset, self.cell_at, size, *parts,
|
|
256
|
+
) # fmt: skip
|
|
257
|
+
if parts[5].any():
|
|
258
|
+
raise ValueError("each group requires a positive-weight estimation observation")
|
|
259
|
+
Q += parts[0].sum(axis=0)
|
|
260
|
+
q += parts[1].sum(axis=0)
|
|
261
|
+
norms += parts[2].sum(axis=0)
|
|
262
|
+
means += parts[3].sum(axis=0)
|
|
263
|
+
cdiag += parts[4].sum(axis=0)
|
|
264
|
+
if model.fixed_effects == "unit":
|
|
265
|
+
# columns the unit means remove entirely are exactly zero, as in
|
|
266
|
+
# the array path; the subtraction leaves rounding noise
|
|
267
|
+
Q[np.diag_indices(P)] = cdiag
|
|
268
|
+
dead = cdiag <= norms * 1e-24
|
|
269
|
+
Q[dead, :] = 0.0
|
|
270
|
+
Q[:, dead] = 0.0
|
|
271
|
+
q[dead] = 0.0
|
|
272
|
+
return Q, q, norms, means
|
|
273
|
+
|
|
274
|
+
def scores(self, raw, coef, H_full):
|
|
275
|
+
"""The batch's scores times H (units by cells); ``H_full`` has a row for
|
|
276
|
+
every column of the design (zero for the dropped ones)."""
|
|
277
|
+
model = self.model
|
|
278
|
+
y, x, w = _arrays(raw)
|
|
279
|
+
out = np.zeros((len(y), H_full.shape[1]))
|
|
280
|
+
bad = np.zeros((len(y) + CHUNK - 1) // CHUNK, dtype=np.int8)
|
|
281
|
+
etwfe_scores(
|
|
282
|
+
self.group_index(raw[0]), y, x, w, self.G, self.cohort_fe,
|
|
283
|
+
model.fixed_effects == "unit", self.base_c, self.base_t, self.offset, self.cell_at,
|
|
284
|
+
np.ascontiguousarray(coef), np.ascontiguousarray(H_full), out, bad,
|
|
285
|
+
) # fmt: skip
|
|
286
|
+
if bad.any():
|
|
287
|
+
raise ValueError("each group requires a positive-weight estimation observation")
|
|
288
|
+
return out
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Streaming normalized means and their smooth-functional derivatives.
|
|
2
|
+
|
|
3
|
+
These primitives have no panel, date, treatment, or cohort conventions.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NormalizedMeans:
|
|
10
|
+
def __init__(self, terms, width):
|
|
11
|
+
self.weight = np.zeros(terms)
|
|
12
|
+
self.value = np.zeros(terms)
|
|
13
|
+
self.x = np.zeros((terms, width))
|
|
14
|
+
self.xvalue = np.zeros((terms, width))
|
|
15
|
+
|
|
16
|
+
def update(self, weights, values, X):
|
|
17
|
+
self.weight += weights.sum(axis=0)
|
|
18
|
+
self.value += (weights * values).sum(axis=0)
|
|
19
|
+
self.x += weights.T @ X
|
|
20
|
+
self.xvalue += (weights * values).T @ X
|
|
21
|
+
|
|
22
|
+
def finish(self):
|
|
23
|
+
if np.any(self.weight <= 0):
|
|
24
|
+
raise ValueError("a normalized mean has no contributing observations after trimming")
|
|
25
|
+
self.mean = self.value / self.weight
|
|
26
|
+
self.mean_x = self.x / self.weight[:, None]
|
|
27
|
+
self.centered_x = self.xvalue / self.weight[:, None] - self.mean[:, None] * self.mean_x
|
|
28
|
+
|
|
29
|
+
def scores(self, weights, values, signs):
|
|
30
|
+
return (weights * (values - self.mean) / self.weight) @ signs
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Bounded streaming sums of cluster influence scores times multipliers.
|
|
2
|
+
|
|
3
|
+
The multiplier samplers are reused from hdfe-stream's wild bootstrap. Its OLS
|
|
4
|
+
restricted-fit/studentization engine is deliberately not used here. Scores may
|
|
5
|
+
belong to any asymptotically linear estimator; no DiD conventions live here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from numbers import Integral
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from hdfe_stream.core.wildboot import WEIGHTS
|
|
12
|
+
from scipy.special import ndtri
|
|
13
|
+
|
|
14
|
+
MAX_DRAW_ENTRIES = 1 << 18
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validate_bootstrap(reps, seed, weights):
|
|
18
|
+
if (
|
|
19
|
+
isinstance(reps, (bool, np.bool_))
|
|
20
|
+
or not isinstance(reps, Integral)
|
|
21
|
+
or (reps != 0 and reps < 2)
|
|
22
|
+
):
|
|
23
|
+
raise ValueError("multiplier_bootstrap_reps must be 0 (disabled) or an integer >= 2")
|
|
24
|
+
if seed is not None and (
|
|
25
|
+
isinstance(seed, (bool, np.bool_)) or not isinstance(seed, Integral) or seed < 0
|
|
26
|
+
):
|
|
27
|
+
raise ValueError("multiplier_bootstrap_seed must be a nonnegative integer or None")
|
|
28
|
+
if not isinstance(weights, str) or weights not in WEIGHTS:
|
|
29
|
+
raise ValueError(f"multiplier_bootstrap_weights must be one of {sorted(WEIGHTS)}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class StreamingMultiplier:
|
|
33
|
+
"""Keep repetitions-by-score-width state, never clusters-by-repetitions.
|
|
34
|
+
|
|
35
|
+
A unit receives the same multiplier in every score block. RNG draws are
|
|
36
|
+
unit-major, so changing numerical batch size preserves assignments for a
|
|
37
|
+
fixed unit traversal. Repartitioning can change traversal and assignments.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, width, reps, seed, weights):
|
|
41
|
+
validate_bootstrap(reps, seed, weights)
|
|
42
|
+
self.reps = int(reps)
|
|
43
|
+
self.rng = np.random.default_rng(seed)
|
|
44
|
+
self.sampler = WEIGHTS[weights]
|
|
45
|
+
self.draws = np.zeros((self.reps, width))
|
|
46
|
+
self.n_clusters = 0
|
|
47
|
+
|
|
48
|
+
def update(self, *blocks):
|
|
49
|
+
rows = len(blocks[0])
|
|
50
|
+
if (
|
|
51
|
+
any(len(b) != rows or b.ndim != 2 for b in blocks)
|
|
52
|
+
or sum(b.shape[1] for b in blocks) != self.draws.shape[1]
|
|
53
|
+
):
|
|
54
|
+
raise ValueError("multiplier score blocks have incompatible shapes")
|
|
55
|
+
step = max(1, MAX_DRAW_ENTRIES // self.reps)
|
|
56
|
+
for start in range(0, rows, step):
|
|
57
|
+
stop = min(rows, start + step)
|
|
58
|
+
xi = np.asarray(
|
|
59
|
+
self.sampler((stop - start) * self.reps, self.rng), dtype=float
|
|
60
|
+
).reshape(stop - start, self.reps)
|
|
61
|
+
column = 0
|
|
62
|
+
for block in blocks:
|
|
63
|
+
width = block.shape[1]
|
|
64
|
+
self.draws[:, column : column + width] += xi.T @ block[start:stop]
|
|
65
|
+
column += width
|
|
66
|
+
self.n_clusters += rows
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def multiplier_band(draws, alpha, *, joint=True, analytic_se=None):
|
|
70
|
+
"""IQR-studentized bands, using R did's empirical (type 1) quantiles.
|
|
71
|
+
|
|
72
|
+
Optional analytic_se uses fixed analytic studentization instead. Draws
|
|
73
|
+
are centered perturbations on the estimate scale, not resampled estimates.
|
|
74
|
+
"""
|
|
75
|
+
if isinstance(alpha, (bool, np.bool_)) or not np.isfinite(alpha) or not 0 < alpha < 1:
|
|
76
|
+
raise ValueError("alpha must be in (0, 1)")
|
|
77
|
+
if not isinstance(joint, bool):
|
|
78
|
+
raise ValueError("joint must be boolean")
|
|
79
|
+
if analytic_se is None:
|
|
80
|
+
q = np.quantile(draws, [0.25, 0.75], axis=0, method="inverted_cdf")
|
|
81
|
+
se = (q[1] - q[0]) / (ndtri(0.75) - ndtri(0.25))
|
|
82
|
+
else:
|
|
83
|
+
se = np.asarray(analytic_se)
|
|
84
|
+
if not np.isfinite(draws).all() or not np.isfinite(se).all() or np.any(se <= 0):
|
|
85
|
+
raise ValueError(
|
|
86
|
+
"selected coefficients have zero or non-finite bootstrap/analytic scale; select supported coefficients or increase multiplier_bootstrap_reps"
|
|
87
|
+
)
|
|
88
|
+
t = np.abs(draws / se)
|
|
89
|
+
critical = np.quantile(t.max(axis=1) if joint else t, 1 - alpha, axis=0, method="inverted_cdf")
|
|
90
|
+
return se, critical
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Shared-pass binary MLE and multi-outcome control-regression systems.
|
|
2
|
+
|
|
3
|
+
No panel or treatment-cohort conventions live here. A transform selects a
|
|
4
|
+
system's sample and returns (design, binary group, outcome matrix[, weights]) per batch.
|
|
5
|
+
Intended for eventual hdfe_stream.core; current models are unpenalized, no FE.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from scipy.special import expit
|
|
12
|
+
|
|
13
|
+
from .weights import weighted_linear, weighted_system
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def solve_checked(matrix, rhs, context, *, verify=True):
|
|
17
|
+
"""Solve in diagonally scaled coordinates; never silently drop columns.
|
|
18
|
+
|
|
19
|
+
``verify=False`` skips the eigenvalue check, for a matrix already checked
|
|
20
|
+
(its scaled form is the same whatever the right-hand side), which for a
|
|
21
|
+
matrix of hundreds of columns is most of the time of a solve."""
|
|
22
|
+
scale = np.sqrt(np.maximum(np.diag(matrix), 0))
|
|
23
|
+
if np.any(scale <= 0) or not np.isfinite(matrix).all():
|
|
24
|
+
raise ValueError(f"{context}: singular nuisance design")
|
|
25
|
+
normalized = matrix / np.outer(scale, scale)
|
|
26
|
+
if verify and np.linalg.eigvalsh(normalized)[0] <= 1e-12:
|
|
27
|
+
raise ValueError(f"{context}: singular or ill-conditioned nuisance design")
|
|
28
|
+
vector = np.ndim(rhs) == 1
|
|
29
|
+
target = np.asarray(rhs) / (scale if vector else scale[:, None])
|
|
30
|
+
answer = np.linalg.solve(normalized, target)
|
|
31
|
+
return answer / (scale if vector else scale[:, None])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fit_linear_systems(batches, transforms, width):
|
|
35
|
+
"""Fit weighted OLS on arbitrary row strata together in one source pass.
|
|
36
|
+
|
|
37
|
+
Transforms return (X, y[, weights]). Return coefficient and Gram pairs, so callers can
|
|
38
|
+
propagate estimation uncertainty through arbitrary smooth functionals.
|
|
39
|
+
"""
|
|
40
|
+
if not transforms:
|
|
41
|
+
return []
|
|
42
|
+
grams = [np.zeros((width, width)) for _ in transforms]
|
|
43
|
+
rhs = [np.zeros(width) for _ in transforms]
|
|
44
|
+
counts = np.zeros(len(transforms), dtype=np.int64)
|
|
45
|
+
for raw in batches():
|
|
46
|
+
for j, transform in enumerate(transforms):
|
|
47
|
+
X, y, w = weighted_linear(transform(raw))
|
|
48
|
+
counts[j] += len(y)
|
|
49
|
+
grams[j] += X.T @ (w[:, None] * X)
|
|
50
|
+
rhs[j] += X.T @ (w * y)
|
|
51
|
+
return [
|
|
52
|
+
(
|
|
53
|
+
solve_checked(
|
|
54
|
+
Q,
|
|
55
|
+
q,
|
|
56
|
+
f"stratified outcome regression (system {j}, {counts[j]} observations, {width} columns)",
|
|
57
|
+
),
|
|
58
|
+
Q,
|
|
59
|
+
)
|
|
60
|
+
for j, (Q, q) in enumerate(zip(grams, rhs, strict=True))
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class NuisanceSystem:
|
|
66
|
+
beta: np.ndarray
|
|
67
|
+
regression: np.ndarray
|
|
68
|
+
control_gram: np.ndarray
|
|
69
|
+
n_treated: int = 0
|
|
70
|
+
n_controls: int = 0
|
|
71
|
+
iterations: int = 0
|
|
72
|
+
treated_weight: float = 0.0
|
|
73
|
+
control_weight: float = 0.0
|
|
74
|
+
control_weight_squares: float = 0.0
|
|
75
|
+
treated_weight_squares: float = 0.0
|
|
76
|
+
design_scale: np.ndarray | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def fit_systems(batches, transforms, method, names, *, tol=1e-10, maxiter=100, fast=None):
|
|
80
|
+
"""Fit independent systems together, scanning the source once per Newton step.
|
|
81
|
+
|
|
82
|
+
Multi-outcome control OLS systems share a Gram solve. Logistic MLE uses
|
|
83
|
+
damped Newton steps, scaled solves and an explicit separation check.
|
|
84
|
+
``fast`` (``cs_kernels.BalancedCS``) does the passes in compiled code, with the
|
|
85
|
+
same sums.
|
|
86
|
+
"""
|
|
87
|
+
K = len(names)
|
|
88
|
+
systems = [None] * len(transforms)
|
|
89
|
+
passes = 0
|
|
90
|
+
if fast is not None:
|
|
91
|
+
C = len(transforms)
|
|
92
|
+
acc = dict(
|
|
93
|
+
counts=np.zeros((C, 2), dtype=np.int64),
|
|
94
|
+
wsums=np.zeros((C, 4)),
|
|
95
|
+
scale=np.zeros((C, K)),
|
|
96
|
+
gram=np.zeros((C, K, K)),
|
|
97
|
+
reg=np.zeros((C, K, fast.Lmax)),
|
|
98
|
+
)
|
|
99
|
+
for raw in batches():
|
|
100
|
+
fast.initial(raw, K, method != "ipw", acc)
|
|
101
|
+
for j in range(C):
|
|
102
|
+
L = int(fast.sizes[j])
|
|
103
|
+
systems[j] = NuisanceSystem(
|
|
104
|
+
np.zeros(K),
|
|
105
|
+
acc["reg"][j, :, :L].copy(),
|
|
106
|
+
acc["gram"][j].copy(),
|
|
107
|
+
n_treated=int(acc["counts"][j, 0]),
|
|
108
|
+
n_controls=int(acc["counts"][j, 1]),
|
|
109
|
+
treated_weight=float(acc["wsums"][j, 0]),
|
|
110
|
+
control_weight=float(acc["wsums"][j, 1]),
|
|
111
|
+
treated_weight_squares=float(acc["wsums"][j, 2]),
|
|
112
|
+
control_weight_squares=float(acc["wsums"][j, 3]),
|
|
113
|
+
design_scale=acc["scale"][j].copy(),
|
|
114
|
+
)
|
|
115
|
+
else:
|
|
116
|
+
for raw in batches():
|
|
117
|
+
for j, transform in enumerate(transforms):
|
|
118
|
+
X, d, Y, w = weighted_system(transform(raw))
|
|
119
|
+
if systems[j] is None:
|
|
120
|
+
systems[j] = NuisanceSystem(
|
|
121
|
+
np.zeros(K),
|
|
122
|
+
np.zeros((K, Y.shape[1])),
|
|
123
|
+
np.zeros((K, K)),
|
|
124
|
+
design_scale=np.zeros(K),
|
|
125
|
+
)
|
|
126
|
+
s = systems[j]
|
|
127
|
+
s.n_treated += int(d.sum())
|
|
128
|
+
s.n_controls += int((~d).sum())
|
|
129
|
+
s.treated_weight += float(w[d].sum())
|
|
130
|
+
s.control_weight += float(w[~d].sum())
|
|
131
|
+
s.control_weight_squares += float(w[~d] @ w[~d])
|
|
132
|
+
s.treated_weight_squares += float(w[d] @ w[d])
|
|
133
|
+
s.design_scale += np.einsum("i,ij,ij->j", w, X, X)
|
|
134
|
+
if method != "ipw":
|
|
135
|
+
xc = X[~d]
|
|
136
|
+
s.control_gram += xc.T @ (w[~d, None] * xc)
|
|
137
|
+
s.regression += xc.T @ (w[~d, None] * Y[~d])
|
|
138
|
+
passes += 1
|
|
139
|
+
for s in systems:
|
|
140
|
+
if not s.n_treated or not s.n_controls:
|
|
141
|
+
raise ValueError("both nuisance groups need observations")
|
|
142
|
+
s.design_scale = np.sqrt(s.design_scale / (s.treated_weight + s.control_weight))
|
|
143
|
+
if method != "ipw":
|
|
144
|
+
s.regression = solve_checked(s.control_gram, s.regression, "outcome regression")
|
|
145
|
+
if "Intercept" in names:
|
|
146
|
+
s.beta[names.index("Intercept")] = np.log(s.treated_weight / s.control_weight)
|
|
147
|
+
if method == "reg":
|
|
148
|
+
return systems, passes
|
|
149
|
+
|
|
150
|
+
def fast_stats(betas, indices):
|
|
151
|
+
nonlocal passes
|
|
152
|
+
C = len(transforms)
|
|
153
|
+
B = np.array(betas)
|
|
154
|
+
active = np.zeros(C, dtype=bool)
|
|
155
|
+
active[list(indices)] = True
|
|
156
|
+
loss, correction = np.zeros(C), np.zeros(C)
|
|
157
|
+
grad, hess, margin = np.zeros((C, K)), np.zeros((C, K, K)), np.full(C, np.inf)
|
|
158
|
+
for raw in batches():
|
|
159
|
+
inc, g_part, h_part, m_part = fast.newton(raw, B, active, K)
|
|
160
|
+
# compensated accumulation of the objective, as in the array path
|
|
161
|
+
step = inc - correction
|
|
162
|
+
total = loss + step
|
|
163
|
+
correction = (total - loss) - step
|
|
164
|
+
loss = total
|
|
165
|
+
grad += g_part
|
|
166
|
+
hess += h_part
|
|
167
|
+
margin = np.minimum(margin, m_part)
|
|
168
|
+
passes += 1
|
|
169
|
+
return {
|
|
170
|
+
j: [float(loss[j]), grad[j].copy(), hess[j].copy(), float(margin[j])] for j in indices
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
def stats(betas, indices):
|
|
174
|
+
nonlocal passes
|
|
175
|
+
if fast is not None:
|
|
176
|
+
return fast_stats(betas, indices)
|
|
177
|
+
out = {j: [0.0, np.zeros(K), np.zeros((K, K)), np.inf] for j in indices}
|
|
178
|
+
loss_correction = dict.fromkeys(indices, 0.0)
|
|
179
|
+
for raw in batches():
|
|
180
|
+
for j in indices:
|
|
181
|
+
X, d, _, w = weighted_system(transforms[j](raw))
|
|
182
|
+
if not len(d):
|
|
183
|
+
continue
|
|
184
|
+
eta = X @ betas[j]
|
|
185
|
+
p = expit(eta)
|
|
186
|
+
# Compensated accumulation keeps the objective stable across
|
|
187
|
+
# many small batches without retaining their individual sums.
|
|
188
|
+
increment = float(w @ (np.logaddexp(0, eta) - d * eta)) - loss_correction[j]
|
|
189
|
+
total = out[j][0] + increment
|
|
190
|
+
loss_correction[j] = (total - out[j][0]) - increment
|
|
191
|
+
out[j][0] = total
|
|
192
|
+
out[j][1] += X.T @ (w * (d - p))
|
|
193
|
+
out[j][2] += X.T @ ((w * p * (1 - p))[:, None] * X)
|
|
194
|
+
out[j][3] = min(out[j][3], float(np.min((2 * d.astype(float) - 1) * eta)))
|
|
195
|
+
passes += 1
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
betas = [s.beta.copy() for s in systems]
|
|
199
|
+
active = set(range(len(systems)))
|
|
200
|
+
current = stats(betas, active)
|
|
201
|
+
for _iteration in range(maxiter):
|
|
202
|
+
steps, done = {}, set()
|
|
203
|
+
for j in active:
|
|
204
|
+
loss, gradient, H, margin = current[j]
|
|
205
|
+
if margin > 1e-8:
|
|
206
|
+
raise ValueError("propensity logit: perfect separation / no overlap")
|
|
207
|
+
step = solve_checked(H, gradient, "propensity logit")
|
|
208
|
+
# Coefficients have inverse covariate units. Test each coordinate
|
|
209
|
+
# in weighted RMS design units so changing meters to micrometers
|
|
210
|
+
# cannot turn an unfinished fit into a converged one. The scale is
|
|
211
|
+
# fixed from the first pass, rather than shrinking with p*(1-p)
|
|
212
|
+
# near separation. No extra source pass or row storage is needed.
|
|
213
|
+
scale = systems[j].design_scale
|
|
214
|
+
if np.all(np.abs(step) * scale <= tol * (1 + np.abs(betas[j]) * scale)):
|
|
215
|
+
done.add(j)
|
|
216
|
+
else:
|
|
217
|
+
steps[j] = step
|
|
218
|
+
active -= done
|
|
219
|
+
if not active:
|
|
220
|
+
for j, s in enumerate(systems):
|
|
221
|
+
s.beta = betas[j]
|
|
222
|
+
return systems, passes
|
|
223
|
+
candidates = list(betas)
|
|
224
|
+
for j in active:
|
|
225
|
+
candidates[j] = betas[j] + steps[j]
|
|
226
|
+
trial = stats(candidates, active)
|
|
227
|
+
for _backtrack in range(30):
|
|
228
|
+
# The objective is a SUM, so its floating-point resolution grows
|
|
229
|
+
# with sample size. Near the optimum, a genuine Newton improvement
|
|
230
|
+
# can be smaller than summation noise. An absolute-only allowance
|
|
231
|
+
# causes repeated tiny backtracks/full source scans at large N.
|
|
232
|
+
# Keep the coefficient-step convergence test unchanged.
|
|
233
|
+
bad = {
|
|
234
|
+
j
|
|
235
|
+
for j in active
|
|
236
|
+
if not np.isfinite(trial[j][0])
|
|
237
|
+
or trial[j][0]
|
|
238
|
+
> current[j][0]
|
|
239
|
+
+ max(1e-10, 64 * np.finfo(float).eps * max(1.0, abs(current[j][0])))
|
|
240
|
+
}
|
|
241
|
+
if not bad:
|
|
242
|
+
break
|
|
243
|
+
for j in bad:
|
|
244
|
+
steps[j] *= 0.5
|
|
245
|
+
candidates[j] = betas[j] + steps[j]
|
|
246
|
+
trial.update(stats(candidates, bad))
|
|
247
|
+
else:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"propensity logit line search failed; check overlap and covariate scaling"
|
|
250
|
+
)
|
|
251
|
+
for j in active:
|
|
252
|
+
betas[j] = candidates[j]
|
|
253
|
+
current[j] = trial[j]
|
|
254
|
+
systems[j].iterations += 1
|
|
255
|
+
raise ValueError("propensity logit did not converge; possible quasi-separation / poor overlap")
|