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
diff_stream/linear.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Covariate/extra-FE SA and BJS with local weighted unit elimination.
|
|
2
|
+
|
|
3
|
+
Unit intercepts never enter a global table. Other terms use the bounded family
|
|
4
|
+
model-matrix compiler; numerical state depends on columns and effect cells.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from ._core import covariate_kernels, twostage_kernels
|
|
10
|
+
from ._core.nuisance import solve_checked
|
|
11
|
+
from ._core.within import center_groups, independent_columns, moments, row_sums, weighted_squares
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fixed_effect_terms(rhs, panel):
|
|
15
|
+
"""(covariates, additional fixed-effect terms as lists of column names)."""
|
|
16
|
+
pieces = rhs.split("|")
|
|
17
|
+
if len(pieces) != 2:
|
|
18
|
+
raise ValueError("use one fixed-effect section; IV formulas are unsupported")
|
|
19
|
+
covariates, effects = (p.strip() for p in pieces)
|
|
20
|
+
if not effects:
|
|
21
|
+
raise ValueError("the fixed-effect section cannot be empty")
|
|
22
|
+
terms = []
|
|
23
|
+
for term in effects.split("+"):
|
|
24
|
+
names = [s.strip() for s in term.strip().split("^")]
|
|
25
|
+
if any(not s.isidentifier() for s in names):
|
|
26
|
+
raise ValueError(
|
|
27
|
+
"additional fixed effects must be column names or name^name interactions"
|
|
28
|
+
)
|
|
29
|
+
if len(names) == 1 and names[0] in panel:
|
|
30
|
+
continue # Unit/time intercepts are always included.
|
|
31
|
+
if panel[0] in names:
|
|
32
|
+
raise ValueError("unit-interacted fixed effects are not supported")
|
|
33
|
+
terms.append(names)
|
|
34
|
+
return covariates or "1", terms
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def regression_formula(rhs, panel):
|
|
38
|
+
pieces = rhs.split("|")
|
|
39
|
+
if len(pieces) != 2:
|
|
40
|
+
raise ValueError("use one fixed-effect section; IV formulas are unsupported")
|
|
41
|
+
covariates, effects = (p.strip() for p in pieces)
|
|
42
|
+
if not effects:
|
|
43
|
+
raise ValueError("the fixed-effect section cannot be empty")
|
|
44
|
+
terms = []
|
|
45
|
+
for term in effects.split("+"):
|
|
46
|
+
names = [s.strip() for s in term.strip().split("^")]
|
|
47
|
+
if any(not s.isidentifier() for s in names):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
"additional fixed effects must be column names or name^name interactions"
|
|
50
|
+
)
|
|
51
|
+
if len(names) == 1 and names[0] in panel:
|
|
52
|
+
continue # Unit/time intercepts are always included.
|
|
53
|
+
if panel[0] in names:
|
|
54
|
+
raise ValueError("unit-interacted fixed effects are not supported")
|
|
55
|
+
terms.append(":".join(f"C({name})" for name in names))
|
|
56
|
+
return " + ".join([covariates, *terms])
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RowDesign:
|
|
60
|
+
def __init__(self, store):
|
|
61
|
+
self.store = store
|
|
62
|
+
self.time = np.eye(store.periods)[:, 1:]
|
|
63
|
+
names = store.design.names if store.design is not None else []
|
|
64
|
+
self.names = [f"time::{t}" for t in store.times[1:]] + list(names)
|
|
65
|
+
self.keep = np.arange(len(self.names))
|
|
66
|
+
self.dropped = []
|
|
67
|
+
|
|
68
|
+
def raw_batches(self, *, actual_cohort=False):
|
|
69
|
+
"""The store's batches (g, y, x, w) with covariates only, for the compiled passes."""
|
|
70
|
+
for g, y, x, w in self.store.batches(
|
|
71
|
+
include_x=True, include_weights=True, row_weights=True, actual_cohort=actual_cohort
|
|
72
|
+
):
|
|
73
|
+
yield g, y, (x if self.store.design is not None else x[:, :, :0]), w
|
|
74
|
+
|
|
75
|
+
def batches(self, *, actual_cohort=False):
|
|
76
|
+
for g, y, x, w in self.store.batches(
|
|
77
|
+
include_x=True, include_weights=True, row_weights=True, actual_cohort=actual_cohort
|
|
78
|
+
):
|
|
79
|
+
x = x if self.store.design is not None else x[:, :, :0]
|
|
80
|
+
X = np.concatenate(
|
|
81
|
+
[np.broadcast_to(self.time, (*y.shape, self.time.shape[1])), x], axis=2
|
|
82
|
+
)
|
|
83
|
+
yield g, y, np.nan_to_num(X, nan=0.0)[:, :, self.keep], w
|
|
84
|
+
|
|
85
|
+
def reduce(self, clean=None, source=None):
|
|
86
|
+
"""Select identified columns; optionally also the clean-row moments.
|
|
87
|
+
|
|
88
|
+
``clean(g)`` gives the rows (units by periods) of a second, clean-sample
|
|
89
|
+
regression. Its within-unit moments are accumulated in the same pass
|
|
90
|
+
over all columns and returned restricted to the identified ones:
|
|
91
|
+
centering acts column by column, so restricting afterwards is exact.
|
|
92
|
+
"""
|
|
93
|
+
P = len(self.names)
|
|
94
|
+
if twostage_kernels.ENABLED:
|
|
95
|
+
kernels = twostage_kernels.TwoStageKernels(self.store.times, 0)
|
|
96
|
+
Q, _, norms, Qc, qc = kernels.reduce(source or self.raw_batches, P, clean)
|
|
97
|
+
else:
|
|
98
|
+
Q = np.zeros((P, P))
|
|
99
|
+
norms = np.zeros(P)
|
|
100
|
+
Qc, qc = (np.zeros((P, P)), np.zeros(P)) if clean is not None else (None, None)
|
|
101
|
+
for g, y, X, w in self.batches():
|
|
102
|
+
y = np.nan_to_num(y, nan=0.0)
|
|
103
|
+
q, _ = moments(X, y, w)
|
|
104
|
+
Q += q
|
|
105
|
+
norms += weighted_squares(X, w)
|
|
106
|
+
if clean is not None:
|
|
107
|
+
a, b = moments(X, y, w * clean(g))
|
|
108
|
+
Qc += a
|
|
109
|
+
qc += b
|
|
110
|
+
self.keep = independent_columns(Q, original_norm=norms)
|
|
111
|
+
self.dropped = [n for j, n in enumerate(self.names) if j not in self.keep]
|
|
112
|
+
if not len(self.keep):
|
|
113
|
+
raise ValueError("the within-unit regression has no identified nuisance columns")
|
|
114
|
+
if clean is not None:
|
|
115
|
+
return Qc[np.ix_(self.keep, self.keep)], qc[self.keep]
|
|
116
|
+
|
|
117
|
+
def residuals(self, g, y, X, w, theta, anticipation, *, label="BJS"):
|
|
118
|
+
observed = np.isfinite(y)
|
|
119
|
+
use = observed & ((g[:, None] == 0) | (self.store.times < g[:, None] - anticipation))
|
|
120
|
+
clean_weight = w * use
|
|
121
|
+
raw = np.where(observed, y - X @ theta, 0)
|
|
122
|
+
mass = clean_weight.sum(axis=1)
|
|
123
|
+
if np.any(mass <= 0):
|
|
124
|
+
raise ValueError(f"{label} requires at least one untreated observation per unit")
|
|
125
|
+
xm = row_sums(clean_weight, X) / mass[:, None]
|
|
126
|
+
alpha = (clean_weight * raw).sum(axis=1) / mass
|
|
127
|
+
residual = np.where(observed, raw - alpha[:, None], 0)
|
|
128
|
+
first_score = row_sums(clean_weight * residual, X - xm[:, None])
|
|
129
|
+
return observed, use, xm, residual, first_score
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fit_bjs(store, cells, avgeffectsby, leaveout, workdir, anticipation=0):
|
|
133
|
+
from .unbalanced import ObservedBJSInference
|
|
134
|
+
|
|
135
|
+
model = RowDesign(store)
|
|
136
|
+
try:
|
|
137
|
+
Q, q = model.reduce(
|
|
138
|
+
clean=lambda g: (g[:, None] == 0) | (store.times < g[:, None] - anticipation)
|
|
139
|
+
)
|
|
140
|
+
except ValueError as error:
|
|
141
|
+
if "positive-weight estimation observation" not in str(error):
|
|
142
|
+
raise
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"BJS requires at least one untreated observation per unit; "
|
|
145
|
+
"exclude units without one explicitly"
|
|
146
|
+
) from None
|
|
147
|
+
P, M = len(model.keep), len(cells)
|
|
148
|
+
theta = solve_checked(
|
|
149
|
+
Q,
|
|
150
|
+
q,
|
|
151
|
+
"BJS untreated design is unidentified (a disconnected observed design, "
|
|
152
|
+
"or a collinear covariate or fixed effect)",
|
|
153
|
+
)
|
|
154
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells])
|
|
155
|
+
beta, D = np.zeros(M), np.zeros((M, P))
|
|
156
|
+
if covariate_kernels.ENABLED:
|
|
157
|
+
kernels = covariate_kernels.BJSKernels(store, cells, model.keep, theta, anticipation)
|
|
158
|
+
beta, D = kernels.cell_sums(model.raw_batches, counts)
|
|
159
|
+
else:
|
|
160
|
+
for g, y, X, w in model.batches():
|
|
161
|
+
obs, _, xm, resid, _ = model.residuals(g, y, X, w, theta, anticipation)
|
|
162
|
+
for j, (cg, t) in enumerate(cells):
|
|
163
|
+
ti = t - store.times[0]
|
|
164
|
+
mask = (g == cg) & obs[:, ti]
|
|
165
|
+
sw = w[mask, ti] / counts[j]
|
|
166
|
+
beta[j] += sw @ resid[mask, ti]
|
|
167
|
+
D[j] += sw @ (X[mask, ti] - xm[mask])
|
|
168
|
+
H = solve_checked(Q, D.T, "BJS untreated covariate/fixed-effect design").T
|
|
169
|
+
context = ObservedBJSInference(
|
|
170
|
+
store,
|
|
171
|
+
cells,
|
|
172
|
+
counts,
|
|
173
|
+
None,
|
|
174
|
+
theta,
|
|
175
|
+
H,
|
|
176
|
+
avgeffectsby,
|
|
177
|
+
leaveout,
|
|
178
|
+
workdir,
|
|
179
|
+
anticipation,
|
|
180
|
+
model=model,
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
V = context.covariance(np.eye(M), allow_unidentified=avgeffectsby != "cohort_time")
|
|
184
|
+
except BaseException:
|
|
185
|
+
context.cleanup()
|
|
186
|
+
raise
|
|
187
|
+
return beta, counts, V, context, model
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def fit_sunab(store, cells, anticipation=0):
|
|
191
|
+
model = RowDesign(store)
|
|
192
|
+
model.reduce()
|
|
193
|
+
K, M = len(model.keep), len(cells)
|
|
194
|
+
P = K + M
|
|
195
|
+
if covariate_kernels.ENABLED:
|
|
196
|
+
kernels = covariate_kernels.SunAbrahamKernels(store, cells, model.keep)
|
|
197
|
+
Q, q = kernels.moments(model.raw_batches)
|
|
198
|
+
coefficients = solve_checked(
|
|
199
|
+
Q, q, "Sun–Abraham covariate/fixed-effect interactions are unidentified"
|
|
200
|
+
)
|
|
201
|
+
H = solve_checked(Q, np.eye(P)[:, K:], "Sun–Abraham joint design")
|
|
202
|
+
V = np.zeros((M, M))
|
|
203
|
+
for raw in model.raw_batches():
|
|
204
|
+
score = kernels.scores(raw, coefficients, H)
|
|
205
|
+
V += score.T @ score
|
|
206
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells])
|
|
207
|
+
return coefficients[K:], counts, (V + V.T) / 2, model
|
|
208
|
+
Q, q = np.zeros((P, P)), np.zeros(P)
|
|
209
|
+
|
|
210
|
+
def batches():
|
|
211
|
+
for g, y, X, w in model.batches():
|
|
212
|
+
for cg in np.unique(g):
|
|
213
|
+
mask = g == cg
|
|
214
|
+
js = [j for j, (cohort, _) in enumerate(cells) if cohort == cg]
|
|
215
|
+
interaction = (
|
|
216
|
+
np.column_stack([store.times == cells[j][1] for j in js])
|
|
217
|
+
if js
|
|
218
|
+
else np.empty((store.periods, 0))
|
|
219
|
+
)
|
|
220
|
+
active = np.r_[np.arange(K), K + np.array(js, dtype=int)]
|
|
221
|
+
design = np.concatenate(
|
|
222
|
+
[X[mask], np.broadcast_to(interaction, (int(mask.sum()), *interaction.shape))],
|
|
223
|
+
axis=2,
|
|
224
|
+
)
|
|
225
|
+
yield mask, active, design, y[mask], w[mask]
|
|
226
|
+
|
|
227
|
+
for _, active, X, y, w in batches():
|
|
228
|
+
a, b = moments(X, np.nan_to_num(y, nan=0.0), w)
|
|
229
|
+
Q[np.ix_(active, active)] += a
|
|
230
|
+
q[active] += b
|
|
231
|
+
coefficients = solve_checked(
|
|
232
|
+
Q, q, "Sun–Abraham covariate/fixed-effect interactions are unidentified"
|
|
233
|
+
)
|
|
234
|
+
H = solve_checked(Q, np.eye(P)[:, K:], "Sun–Abraham joint design")
|
|
235
|
+
V = np.zeros((M, M))
|
|
236
|
+
for _, active, X, y, w in batches():
|
|
237
|
+
xc, yc, _, _ = center_groups(X, np.nan_to_num(y, nan=0.0), w)
|
|
238
|
+
residual = yc - xc @ coefficients[active]
|
|
239
|
+
score = row_sums(w * residual, xc) @ H[active]
|
|
240
|
+
V += score.T @ score
|
|
241
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells])
|
|
242
|
+
return coefficients[K:], counts, (V + V.T) / 2, model
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def fit_placebos(store, pretrends, anticipation, *, fml, depvar, panel, vcov):
|
|
246
|
+
"""Separate clean lead regression with the same richer nuisance design."""
|
|
247
|
+
import polars as pl
|
|
248
|
+
|
|
249
|
+
from .bjs_placebo import BJSPlaceboResult
|
|
250
|
+
|
|
251
|
+
model = RowDesign(store)
|
|
252
|
+
model.reduce()
|
|
253
|
+
L, K = pretrends, len(model.keep)
|
|
254
|
+
horizons = np.arange(-anticipation - L, -anticipation, dtype=np.int64)
|
|
255
|
+
support, rows = np.zeros(L, dtype=int), []
|
|
256
|
+
for g, counts in sorted(store.actual_cell_counts.items()):
|
|
257
|
+
if not g:
|
|
258
|
+
continue
|
|
259
|
+
for ti, t in enumerate(store.times):
|
|
260
|
+
j = int(t) - g + anticipation + L
|
|
261
|
+
if 0 <= j < L and counts[ti]:
|
|
262
|
+
support[j] += counts[ti]
|
|
263
|
+
rows.append(
|
|
264
|
+
dict(
|
|
265
|
+
aggregate=f"event::{horizons[j]}",
|
|
266
|
+
cohort=int(g),
|
|
267
|
+
time=int(t),
|
|
268
|
+
event_time=int(horizons[j]),
|
|
269
|
+
observed_units=int(counts[ti]),
|
|
270
|
+
weight_total=float(
|
|
271
|
+
store.actual_cell_weights[g][ti]
|
|
272
|
+
* store.weight_scale
|
|
273
|
+
/ store.weight_normalizer
|
|
274
|
+
),
|
|
275
|
+
)
|
|
276
|
+
)
|
|
277
|
+
if np.any(support == 0):
|
|
278
|
+
raise ValueError(
|
|
279
|
+
"BJS requested clean placebo horizons are not observed; reduce pretrend_leads"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def batches():
|
|
283
|
+
for g, y, X, w in model.batches(actual_cohort=True):
|
|
284
|
+
lead = ((store.times[None, :, None] - g[:, None, None]) == horizons) & (
|
|
285
|
+
g[:, None, None] != 0
|
|
286
|
+
)
|
|
287
|
+
use = np.isfinite(y) & ((g[:, None] == 0) | (store.times < g[:, None] - anticipation))
|
|
288
|
+
yield np.concatenate([X, lead], axis=2), np.nan_to_num(y, nan=0.0), w * use, use
|
|
289
|
+
|
|
290
|
+
P = K + L
|
|
291
|
+
Q, q = np.zeros((P, P)), np.zeros(P)
|
|
292
|
+
n_obs = 0
|
|
293
|
+
for X, y, w, use in batches():
|
|
294
|
+
a, b = moments(X, y, w)
|
|
295
|
+
Q += a
|
|
296
|
+
q += b
|
|
297
|
+
n_obs += int(use.sum())
|
|
298
|
+
keep = independent_columns(Q)
|
|
299
|
+
if not np.isin(np.arange(K, P), keep).all():
|
|
300
|
+
raise ValueError("BJS clean placebo design is unidentified; reduce pretrend_leads")
|
|
301
|
+
Q = Q[np.ix_(keep, keep)]
|
|
302
|
+
context = "BJS clean placebo covariate/fixed-effect design"
|
|
303
|
+
coefficient = solve_checked(Q, q[keep], context)
|
|
304
|
+
H = solve_checked(Q, np.eye(len(keep))[:, -L:], context)
|
|
305
|
+
V = np.zeros((L, L))
|
|
306
|
+
for X, y, w, _ in batches():
|
|
307
|
+
xc, yc, _, _ = center_groups(X[:, :, keep], y, w)
|
|
308
|
+
score = row_sums(w * (yc - xc @ coefficient), xc) @ H
|
|
309
|
+
V += score.T @ score
|
|
310
|
+
V *= store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1.0
|
|
311
|
+
all_names = [*(model.names[j] for j in model.keep), *(f"event::{h}" for h in horizons)]
|
|
312
|
+
return BJSPlaceboResult(
|
|
313
|
+
fml=fml,
|
|
314
|
+
depvar=depvar,
|
|
315
|
+
coefnames=[f"event::{h}" for h in horizons],
|
|
316
|
+
beta=coefficient[-L:],
|
|
317
|
+
vcov=(V + V.T) / 2,
|
|
318
|
+
vcov_type=vcov,
|
|
319
|
+
df_t=store.n_units - 1,
|
|
320
|
+
all_vcovs={vcov: (V, store.n_units - 1)},
|
|
321
|
+
n_obs=n_obs,
|
|
322
|
+
n_clusters={panel[0]: store.n_units},
|
|
323
|
+
n_units=store.n_units,
|
|
324
|
+
weights=None,
|
|
325
|
+
_support=pl.DataFrame(rows),
|
|
326
|
+
diagnostics=dict(
|
|
327
|
+
aggregation="event_time",
|
|
328
|
+
anticipation=anticipation,
|
|
329
|
+
reference_event_time=None,
|
|
330
|
+
fitted_horizons=horizons.tolist(),
|
|
331
|
+
pretrends=L,
|
|
332
|
+
base_period="all other clean observations; no fixed event-time reference",
|
|
333
|
+
inference="unit clustered; clean-sample covariate/fixed-effect WLS regression",
|
|
334
|
+
regression_columns=[all_names[j] for j in keep],
|
|
335
|
+
absorbed_or_redundant_columns=model.dropped
|
|
336
|
+
+ [n for j, n in enumerate(all_names) if j not in keep],
|
|
337
|
+
warnings=[],
|
|
338
|
+
),
|
|
339
|
+
)
|
diff_stream/periods.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Period labels, and cohorts from a treatment column.
|
|
2
|
+
|
|
3
|
+
Panels with evenly spaced periods (every year, every second year, ...) are
|
|
4
|
+
numbered consecutively inside the store, starting at the first period, so the
|
|
5
|
+
estimators count periods: event time 1 is the next period, whatever its
|
|
6
|
+
spacing, and anticipation and pre-trend leads are numbers of periods. Results
|
|
7
|
+
show the original labels of cohorts and periods.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PeriodLabels:
|
|
17
|
+
"""Original period = origin + (internal period - origin) * step."""
|
|
18
|
+
|
|
19
|
+
origin: int
|
|
20
|
+
step: int = 1
|
|
21
|
+
|
|
22
|
+
def internal(self, t):
|
|
23
|
+
"""Internal periods of on-grid original periods."""
|
|
24
|
+
return self.origin + (np.asarray(t) - self.origin) // self.step
|
|
25
|
+
|
|
26
|
+
def internal_cohorts(self, g, last_internal):
|
|
27
|
+
"""Internal cohorts; 0 stays 0. Cohorts within the sample must be periods of it."""
|
|
28
|
+
g = np.asarray(g).copy()
|
|
29
|
+
nonzero = g != 0
|
|
30
|
+
offset = g[nonzero] - self.origin
|
|
31
|
+
inside = (offset > 0) & (g[nonzero] <= self.label(last_internal))
|
|
32
|
+
off_grid = inside & (offset % self.step != 0)
|
|
33
|
+
if off_grid.any():
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"cohort {int(g[nonzero][off_grid][0])} is not one of the panel's periods "
|
|
36
|
+
f"({self.origin}, {self.origin + self.step}, ...): give the first period in "
|
|
37
|
+
"which the unit is observed treated, or use treatment="
|
|
38
|
+
)
|
|
39
|
+
# Cohorts after the sample stay after it (they are never treated within it).
|
|
40
|
+
g[nonzero] = self.origin - (-offset // self.step)
|
|
41
|
+
return g
|
|
42
|
+
|
|
43
|
+
def label(self, t):
|
|
44
|
+
"""Original labels of internal periods (scalars or arrays)."""
|
|
45
|
+
if self.step == 1:
|
|
46
|
+
return t
|
|
47
|
+
return self.origin + (np.asarray(t) - self.origin) * self.step
|
|
48
|
+
|
|
49
|
+
def label_int(self, t):
|
|
50
|
+
return int(self.label(int(t)))
|
|
51
|
+
|
|
52
|
+
def label_cohort(self, g):
|
|
53
|
+
"""Original cohort label; 0 (never treated) stays 0."""
|
|
54
|
+
return 0 if int(g) == 0 else self.label_int(g)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cohorts_from_treatment(treatment, t, ids, starts, unit):
|
|
58
|
+
"""Each row's cohort: its unit's first period with treatment 1, else 0.
|
|
59
|
+
|
|
60
|
+
Rows are sorted by unit and period. Treatment must be 0/1 and absorbing.
|
|
61
|
+
"""
|
|
62
|
+
if np.any((treatment != 0) & (treatment != 1)):
|
|
63
|
+
raise ValueError("treatment must be 0 or 1")
|
|
64
|
+
same_unit = ids[1:] == ids[:-1]
|
|
65
|
+
if np.any(same_unit & (treatment[1:] < treatment[:-1])):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"treatment must be absorbing: once 1, a unit stays treated in its later periods"
|
|
68
|
+
)
|
|
69
|
+
never = np.iinfo(np.int64).max
|
|
70
|
+
first = np.minimum.reduceat(np.where(treatment == 1, t, never), starts)
|
|
71
|
+
first[first == never] = 0
|
|
72
|
+
return first[unit]
|
diff_stream/pretrend.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Clean pre-treatment selection and transparent joint Wald tests."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
from ._present import internal
|
|
7
|
+
from .aggregation import integer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def pretrend_test(result, *, level="auto", horizons=None):
|
|
11
|
+
if result.estimator == "bjs":
|
|
12
|
+
return result.placebos().pretrend_test(level=level, horizons=horizons)
|
|
13
|
+
if level not in ("auto", "cell", "event_time"):
|
|
14
|
+
raise ValueError("level must be 'auto', 'cell' or 'event_time'")
|
|
15
|
+
A = internal(result).get("anticipation", 0)
|
|
16
|
+
if result.cells:
|
|
17
|
+
level = "cell" if level == "auto" else level
|
|
18
|
+
events = np.array([t - g for g, t in result.cells])
|
|
19
|
+
else:
|
|
20
|
+
if internal(result).get("aggregation") != "event_time" or level == "cell":
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"pretrend_test requires cohort-time estimates or an event-time aggregate"
|
|
23
|
+
)
|
|
24
|
+
level = "event_time"
|
|
25
|
+
events = np.array([int(name.split("::")[-1]) for name in result.coefnames])
|
|
26
|
+
clean = events < -A
|
|
27
|
+
requested = None
|
|
28
|
+
if horizons is not None:
|
|
29
|
+
try:
|
|
30
|
+
requested = list(horizons)
|
|
31
|
+
except TypeError as exc:
|
|
32
|
+
raise ValueError(
|
|
33
|
+
"horizons must contain clean pre-treatment integer event times"
|
|
34
|
+
) from exc
|
|
35
|
+
if not requested or not all(integer(h) and h < -A for h in requested):
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"horizons must contain clean pre-treatment integer event times < -anticipation"
|
|
38
|
+
)
|
|
39
|
+
requested = sorted(set(int(h) for h in requested))
|
|
40
|
+
missing = set(requested) - set(events[clean].tolist())
|
|
41
|
+
if missing:
|
|
42
|
+
raise ValueError(f"requested clean horizons are not estimated: {sorted(missing)}")
|
|
43
|
+
clean &= np.isin(events, requested)
|
|
44
|
+
if not clean.any():
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"no estimated clean pre-treatment coefficients; normalized references and anticipation periods are excluded"
|
|
47
|
+
)
|
|
48
|
+
if result.cells and level == "event_time":
|
|
49
|
+
result = result.aggregate(horizons=sorted(set(events[clean].tolist())))
|
|
50
|
+
events = np.array([int(name.split("::")[-1]) for name in result.coefnames])
|
|
51
|
+
clean = np.ones(len(events), dtype=bool)
|
|
52
|
+
ix = np.flatnonzero(clean)
|
|
53
|
+
names = [result.coefnames[j] for j in ix]
|
|
54
|
+
V = result.vcov[np.ix_(ix, ix)]
|
|
55
|
+
sd = np.sqrt(np.maximum(np.diag(V), 0))
|
|
56
|
+
if not np.isfinite(V).all() or np.any(sd <= 0):
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"pre-trend covariance has zero or non-finite variance; select estimable horizons"
|
|
59
|
+
)
|
|
60
|
+
correlation = V / np.outer(sd, sd)
|
|
61
|
+
if np.linalg.eigvalsh((correlation + correlation.T) / 2)[0] <= 1e-10:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"pre-trend covariance is singular or ill-conditioned; select fewer cells/horizons or use level='event_time'"
|
|
64
|
+
)
|
|
65
|
+
# Rescaling restrictions leaves the null unchanged and lets the inherited
|
|
66
|
+
# Wald solver work on the correlation scale. Otherwise its pseudoinverse
|
|
67
|
+
# can discard a small-variance restriction solely because of units/scales.
|
|
68
|
+
R = np.zeros((len(ix), len(result.beta)))
|
|
69
|
+
R[np.arange(len(ix)), ix] = 1 / sd
|
|
70
|
+
test = result.wald_test(R=R, distribution="chi2")
|
|
71
|
+
if result.cells:
|
|
72
|
+
cells = [dict(cohort=int(result.cells[j][0]), time=int(result.cells[j][1])) for j in ix]
|
|
73
|
+
else:
|
|
74
|
+
table = (
|
|
75
|
+
result.support() if result.estimator == "bjs_placebo" else result.contributions()
|
|
76
|
+
).filter(pl.col("aggregate").is_in(names))
|
|
77
|
+
cells = table.select("cohort", "time").unique().sort(["cohort", "time"]).to_dicts()
|
|
78
|
+
return dict(
|
|
79
|
+
**test,
|
|
80
|
+
level=level,
|
|
81
|
+
coefficients=names,
|
|
82
|
+
horizons=sorted(set(int(h) for h in events[ix])),
|
|
83
|
+
cells=cells,
|
|
84
|
+
n_restrictions=len(ix),
|
|
85
|
+
anticipation=A,
|
|
86
|
+
reference_event_time=internal(result).get("reference_event_time", -A - 1),
|
|
87
|
+
null="all selected clean pre-treatment effects equal zero",
|
|
88
|
+
base_period=internal(result).get("base_period"),
|
|
89
|
+
**(
|
|
90
|
+
dict(
|
|
91
|
+
fitted_horizons=result.diagnostics["fitted_horizons"],
|
|
92
|
+
sample="separate clean-sample lead regression",
|
|
93
|
+
)
|
|
94
|
+
if result.estimator == "bjs_placebo"
|
|
95
|
+
else {}
|
|
96
|
+
),
|
|
97
|
+
)
|
diff_stream/py.typed
ADDED
|
File without changes
|
diff_stream/reference.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Explicit clean last-treated reference windows on an already validated panel."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
import polars as pl
|
|
7
|
+
|
|
8
|
+
from .store import PanelStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReferenceWindowWarning(UserWarning):
|
|
12
|
+
"""Estimation uses only dates before the last reference cohort is affected."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class FrozenDesign:
|
|
17
|
+
"""Reuse validated model columns when restaging a restricted panel."""
|
|
18
|
+
|
|
19
|
+
names: list
|
|
20
|
+
|
|
21
|
+
def discover(self, frame):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def compile(self):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
def evaluate(self, frame):
|
|
28
|
+
return frame.select([f"__x{j}" for j in range(len(self.names))])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def reference_store(store, policy, anticipation):
|
|
32
|
+
"""Auto selects last-treated only when no clean never-treated group exists.
|
|
33
|
+
|
|
34
|
+
Complete-panel selection precedes reference selection. The reference cohort
|
|
35
|
+
and existing never-treated units are internally pooled as clean controls;
|
|
36
|
+
the original reference date and all restrictions remain explicit metadata.
|
|
37
|
+
"""
|
|
38
|
+
use_last = policy == "lasttreated" or (policy == "auto" and 0 not in store.gc)
|
|
39
|
+
if not use_last:
|
|
40
|
+
return store, dict(
|
|
41
|
+
reference_group_requested=policy,
|
|
42
|
+
reference_group="nevertreated",
|
|
43
|
+
reference_cohort=0,
|
|
44
|
+
reference_cutoff=None,
|
|
45
|
+
reference_dropped_rows=0,
|
|
46
|
+
reference_dropped_units=0,
|
|
47
|
+
)
|
|
48
|
+
treated = [g for g in store.gc if g]
|
|
49
|
+
if len(treated) < 2:
|
|
50
|
+
raise ValueError(
|
|
51
|
+
"last-treated reference requires at least two treated cohorts after panel selection"
|
|
52
|
+
)
|
|
53
|
+
reference = max(treated)
|
|
54
|
+
cutoff = reference - anticipation
|
|
55
|
+
if cutoff - int(store.times[0]) < 2:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"fewer than two clean dates remain before the last-treated reference is affected"
|
|
58
|
+
)
|
|
59
|
+
data = (
|
|
60
|
+
pl.scan_parquet(store.paths, low_memory=True)
|
|
61
|
+
.filter(pl.col("t") < cutoff)
|
|
62
|
+
.with_columns(pl.when(pl.col("g") == reference).then(0).otherwise(pl.col("g")).alias("g"))
|
|
63
|
+
)
|
|
64
|
+
restricted = restage(
|
|
65
|
+
store,
|
|
66
|
+
data,
|
|
67
|
+
"reference",
|
|
68
|
+
"balanced" if store.panel_mode in ("balanced", "complete") else "unbalanced",
|
|
69
|
+
anticipation,
|
|
70
|
+
)
|
|
71
|
+
dropped_rows = store.n_rows_used - restricted.n_rows_used
|
|
72
|
+
dropped_units = store.n_units - restricted.n_units
|
|
73
|
+
metadata = dict(
|
|
74
|
+
reference_group_requested=policy,
|
|
75
|
+
reference_group="lasttreated",
|
|
76
|
+
reference_cohort=int(reference),
|
|
77
|
+
reference_cohort_units=store.gc[reference],
|
|
78
|
+
reference_never_units=store.gc.get(0, 0),
|
|
79
|
+
input_time_min=int(store.times[0]),
|
|
80
|
+
input_time_max=int(store.times[-1]),
|
|
81
|
+
reference_cutoff=int(cutoff),
|
|
82
|
+
reference_last_clean_time=int(restricted.times[-1]),
|
|
83
|
+
reference_dropped_rows=dropped_rows,
|
|
84
|
+
reference_dropped_units=dropped_units,
|
|
85
|
+
reference_includes_never_treated=0 in store.gc,
|
|
86
|
+
reference_selection="after original balanced/complete/unbalanced panel policy; before reference is affected",
|
|
87
|
+
)
|
|
88
|
+
for path in store.paths:
|
|
89
|
+
path.unlink()
|
|
90
|
+
warnings.warn(
|
|
91
|
+
f"Last-treated cohort {reference} is a reference only: retain time < {cutoff} (anticipation={anticipation}); "
|
|
92
|
+
f"excluded {dropped_rows} observed rows. Effects after the reference becomes affected are not identified by this design.",
|
|
93
|
+
ReferenceWindowWarning,
|
|
94
|
+
stacklevel=3,
|
|
95
|
+
)
|
|
96
|
+
return restricted, metadata
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def restage(store, data, name, panel_mode, anticipation):
|
|
100
|
+
"""Stage a subset of a store's rows (``data``, with the store's columns) as
|
|
101
|
+
a new store, keeping the input counts and the original period labels."""
|
|
102
|
+
root = store.root / name
|
|
103
|
+
root.mkdir()
|
|
104
|
+
data = data.with_columns(pl.col("u").alias("id")).drop("u")
|
|
105
|
+
if store.weighted:
|
|
106
|
+
data = data.with_columns(pl.col("w") * store.weight_scale)
|
|
107
|
+
design = FrozenDesign(list(store.design.names)) if store.design is not None else None
|
|
108
|
+
restricted = PanelStore(
|
|
109
|
+
data,
|
|
110
|
+
root,
|
|
111
|
+
store.batch_units,
|
|
112
|
+
store.budget,
|
|
113
|
+
store.leaf_bytes * 4 / 1e6,
|
|
114
|
+
design,
|
|
115
|
+
panel_mode,
|
|
116
|
+
anticipation,
|
|
117
|
+
allow_time_varying_weights=store.allow_time_varying_weights,
|
|
118
|
+
retain_actual_cohort=store.retain_actual_cohort,
|
|
119
|
+
actual_cohort_column="g_actual" if store.retain_actual_cohort else None,
|
|
120
|
+
)
|
|
121
|
+
# The restaged rows are already numbered consecutively; keep the original labels.
|
|
122
|
+
restricted.period_labels = store.period_labels
|
|
123
|
+
restricted.n_rows = store.n_rows
|
|
124
|
+
restricted.n_units_input = store.n_units_input
|
|
125
|
+
return restricted
|