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/absorbed.py
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""Sun-Abraham and BJS with the fixed effects absorbed iteratively by hdfe-stream.
|
|
2
|
+
|
|
3
|
+
Sun-Abraham is one hdfe-stream regression: the cohort-period indicators are a
|
|
4
|
+
single categorical, with the reference (never-treated units and every cohort's
|
|
5
|
+
base period) omitted.
|
|
6
|
+
|
|
7
|
+
For BJS, the untreated model (unit, time and additional fixed effects, covariates) is
|
|
8
|
+
fitted by hdfe-stream on the untreated rows, and its predictions impute the
|
|
9
|
+
treated rows' untreated outcomes. Standard errors are Borusyak, Jaravel and
|
|
10
|
+
Spiess's: each unit's score sums v_it * e_it, where v are the imputation
|
|
11
|
+
weights and e the residuals (treated residuals net of their averages within
|
|
12
|
+
``residual_averaging`` groups).
|
|
13
|
+
|
|
14
|
+
An untreated row's weight in a cell's estimate is -w_it z_it'u_j, with
|
|
15
|
+
u_j = S^- Z_j'v_j the solution of the untreated model's normal equations for
|
|
16
|
+
the cell's treated design (``HDFEResult.solve_normal_equations``). A unit's
|
|
17
|
+
untreated score is therefore linear in the aggregation weights: it is
|
|
18
|
+
computed once per cell, kept on disk next to the residuals, and combined for
|
|
19
|
+
any aggregation. Only the treated part, whose residual averages depend on the
|
|
20
|
+
aggregation, is recomputed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import warnings
|
|
24
|
+
from tempfile import TemporaryDirectory
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
import polars as pl
|
|
28
|
+
|
|
29
|
+
from ._core.partition import whole_group_batches
|
|
30
|
+
|
|
31
|
+
_DROPPED = ".*variables dropped due to multicollinearity"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def staged(store, terms, paths=None):
|
|
35
|
+
"""The staged rows under the names the hdfe-stream formula uses.
|
|
36
|
+
|
|
37
|
+
hdfe-stream reserves names starting with "__", so the staged covariate
|
|
38
|
+
and fixed-effect columns are renamed (``__x0`` to ``x0``, ``__fe0`` to
|
|
39
|
+
``fe0``).
|
|
40
|
+
"""
|
|
41
|
+
covariates = list(store.design.names) if store.design is not None else []
|
|
42
|
+
xs = [f"x{j}" for j in range(len(covariates))]
|
|
43
|
+
effects = sorted({c for term, _ in terms for c in term.split("^")} - {"t"})
|
|
44
|
+
columns = ["u", "t", "g", "y", *(["w"] if store.weighted else [])]
|
|
45
|
+
frame = pl.scan_parquet(paths or store.paths, low_memory=True).select(
|
|
46
|
+
*columns,
|
|
47
|
+
*[pl.col(f"__x{j}").alias(x) for j, x in enumerate(xs)],
|
|
48
|
+
*[pl.col(c).alias(c[2:]) for c in effects],
|
|
49
|
+
)
|
|
50
|
+
absorbed = [term.replace("__fe", "fe") for term, _ in terms]
|
|
51
|
+
return frame, covariates, xs, absorbed
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# hdfe-stream's row batch for models with fixed effects is 2,000,000 rows,
|
|
55
|
+
# whatever the design's width; a batch holds about four float64 copies of it.
|
|
56
|
+
DEFAULT_BATCH_ROWS = 2_000_000
|
|
57
|
+
BATCH_BYTES_PER_VALUE = 32
|
|
58
|
+
|
|
59
|
+
# hdfe-stream solves the normal equations for a block of columns at a time, one
|
|
60
|
+
# pass over the rows per block. A block holds about this many bytes per unit and
|
|
61
|
+
# column (the grouped sums, their copies and the solution), measured at 3 million
|
|
62
|
+
# units: 115 MB for a column.
|
|
63
|
+
SOLVE_BYTES_PER_UNIT_COLUMN = 40
|
|
64
|
+
SOLVE_BLOCK = 16 # hdfe-stream's default; fewest passes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def solve_block(store, columns):
|
|
68
|
+
"""Columns per block for the normal-equations solve: hdfe-stream's default
|
|
69
|
+
when half the memory left holds it, fewer (at least one) when it does not."""
|
|
70
|
+
room = 0.5 * store.budget.headroom_gb() * 1e9
|
|
71
|
+
fit = int(room // (SOLVE_BYTES_PER_UNIT_COLUMN * max(store.n_units, 1)))
|
|
72
|
+
return max(1, min(SOLVE_BLOCK, columns, fit))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def batch_rows(store, width):
|
|
76
|
+
"""hdfe-stream's row batch: its default when that fits a quarter of the
|
|
77
|
+
memory left, fewer rows (at least 50,000) when it does not."""
|
|
78
|
+
room = 0.25 * store.budget.headroom_gb() * 1e9
|
|
79
|
+
per_row = BATCH_BYTES_PER_VALUE * (width + 2)
|
|
80
|
+
if DEFAULT_BATCH_ROWS * per_row <= room:
|
|
81
|
+
return None
|
|
82
|
+
return max(50_000, int(room / per_row))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hdfe_options(store, threads, width, **options):
|
|
86
|
+
"""hdfe-stream options: diff-stream's memory budget, threads and weights.
|
|
87
|
+
|
|
88
|
+
``width`` is the number of columns in the regression's design.
|
|
89
|
+
"""
|
|
90
|
+
rows = batch_rows(store, width)
|
|
91
|
+
if rows is not None:
|
|
92
|
+
options["batch_rows"] = rows
|
|
93
|
+
options.update(
|
|
94
|
+
verbose=False,
|
|
95
|
+
fixef_rm="none",
|
|
96
|
+
max_memory_gb=store.budget.limit_gb,
|
|
97
|
+
memory_reserve=store.budget.reserve,
|
|
98
|
+
)
|
|
99
|
+
if store.weighted:
|
|
100
|
+
options["weights"] = "w"
|
|
101
|
+
if threads is not None:
|
|
102
|
+
options["n_threads"] = threads
|
|
103
|
+
return options
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def untreated(anticipation):
|
|
107
|
+
return (pl.col("g") == 0) | (pl.col("t") < pl.col("g") - anticipation)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fit_sunab_absorbed(store, cells, terms, *, threads=None):
|
|
111
|
+
"""Sun-Abraham's regression; returns CRV0 by unit."""
|
|
112
|
+
from hdfe_stream import feols_stream
|
|
113
|
+
|
|
114
|
+
keys = pl.DataFrame(
|
|
115
|
+
{
|
|
116
|
+
"g": [g for g, _ in cells],
|
|
117
|
+
"t": [t for _, t in cells],
|
|
118
|
+
"cell": [f"c{j}" for j in range(len(cells))],
|
|
119
|
+
},
|
|
120
|
+
schema={"g": pl.Int64, "t": pl.Int64, "cell": pl.String},
|
|
121
|
+
)
|
|
122
|
+
frame, covariates, xs, absorbed = staged(store, terms)
|
|
123
|
+
data = (
|
|
124
|
+
frame.with_columns(pl.col("g").cast(pl.Int64), pl.col("t").cast(pl.Int64))
|
|
125
|
+
.join(keys.lazy(), on=["g", "t"], how="left")
|
|
126
|
+
.with_columns(pl.col("cell").fill_null("ref"))
|
|
127
|
+
)
|
|
128
|
+
rhs = " + ".join([*xs, "i(cell, ref='ref')"])
|
|
129
|
+
fml = f"y ~ {rhs} | " + " + ".join(["u", "t", *absorbed])
|
|
130
|
+
with warnings.catch_warnings():
|
|
131
|
+
warnings.filterwarnings("ignore", _DROPPED)
|
|
132
|
+
fit = feols_stream(
|
|
133
|
+
fml,
|
|
134
|
+
data,
|
|
135
|
+
workdir=store.root,
|
|
136
|
+
**hdfe_options(
|
|
137
|
+
store, threads, len(xs) + len(cells), vcov={"CRV1": "u"}, save_resid=False
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
names = list(fit.coefnames)
|
|
142
|
+
wanted = [f"cell::c{j}" for j in range(len(cells))]
|
|
143
|
+
missing = [cells[j] for j, n in enumerate(wanted) if n not in names]
|
|
144
|
+
if missing:
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"Sun-Abraham cohort-period effects {missing} are collinear with the fixed effects"
|
|
147
|
+
)
|
|
148
|
+
ix = [names.index(n) for n in wanted]
|
|
149
|
+
beta = np.asarray(fit.beta, dtype=float)[ix]
|
|
150
|
+
factor = fit.diagnostics["vcov_factors"]["CRV1:u"]
|
|
151
|
+
V = np.asarray(fit.vcov, dtype=float)[np.ix_(ix, ix)] / factor
|
|
152
|
+
finally:
|
|
153
|
+
fit.cleanup()
|
|
154
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells])
|
|
155
|
+
return beta, counts, (V + V.T) / 2, info(covariates, xs, names, terms)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def info(covariates, xs, names, terms):
|
|
159
|
+
"""Diagnostics of an absorbed fit."""
|
|
160
|
+
dropped = [n for n, x in zip(covariates, xs, strict=True) if x not in names]
|
|
161
|
+
return dict(
|
|
162
|
+
regression_backend="hdfe-stream alternating projections; fixed effects absorbed",
|
|
163
|
+
absorbed_terms=[written for _, written in terms],
|
|
164
|
+
regression_columns=[n for n in covariates if n not in dropped],
|
|
165
|
+
absorbed_or_redundant_columns=dropped,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fit_bjs_absorbed(
|
|
170
|
+
store, cells, terms, avgeffectsby, leaveout, workdir, anticipation=0, *, threads=None
|
|
171
|
+
):
|
|
172
|
+
from hdfe_stream import feols_stream
|
|
173
|
+
|
|
174
|
+
frame, covariates, xs, absorbed = staged(store, terms)
|
|
175
|
+
fml = f"y ~ {' + '.join(xs) or '1'} | " + " + ".join(["u", "t", *absorbed])
|
|
176
|
+
options = hdfe_options(store, threads, len(xs), vcov="iid", keep_intermediates=True)
|
|
177
|
+
with warnings.catch_warnings():
|
|
178
|
+
warnings.filterwarnings("ignore", _DROPPED)
|
|
179
|
+
first = feols_stream(
|
|
180
|
+
fml, frame.filter(untreated(anticipation)), workdir=store.root, **options
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
context = AbsorbedBJSInference(
|
|
184
|
+
store, cells, first, terms, avgeffectsby, leaveout, workdir, anticipation
|
|
185
|
+
)
|
|
186
|
+
finally:
|
|
187
|
+
first.cleanup()
|
|
188
|
+
try:
|
|
189
|
+
V = context.covariance(np.eye(len(cells)), allow_unidentified=avgeffectsby != "cohort_time")
|
|
190
|
+
except BaseException:
|
|
191
|
+
context.cleanup()
|
|
192
|
+
raise
|
|
193
|
+
return context.beta, context.counts, V, context, info(covariates, xs, first.coefnames, terms)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class AbsorbedBJSInference:
|
|
197
|
+
"""Residuals and per-cell untreated unit scores, on disk, for aggregation.
|
|
198
|
+
|
|
199
|
+
The staged rows are rewritten with the residual (the outcome less its
|
|
200
|
+
imputed untreated outcome), and a memory-mapped array holds, per unit, the
|
|
201
|
+
untreated score of every cell. ``covariance`` makes two passes over them.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
def __init__(self, store, cells, first, terms, avgeffectsby, leaveout, workdir, anticipation):
|
|
205
|
+
self.workspace = TemporaryDirectory(prefix="diff-stream-bjs-", dir=workdir)
|
|
206
|
+
self.store, self.cells = store, cells
|
|
207
|
+
self.avgeffectsby, self.leaveout = avgeffectsby, leaveout
|
|
208
|
+
self.anticipation = anticipation
|
|
209
|
+
self.closed = False
|
|
210
|
+
M, times = len(cells), store.times
|
|
211
|
+
keys = pl.DataFrame(
|
|
212
|
+
{"g": [g for g, _ in cells], "t": [t for _, t in cells], "cell": range(M)},
|
|
213
|
+
schema={"g": pl.Int64, "t": pl.Int64, "cell": pl.Int64},
|
|
214
|
+
)
|
|
215
|
+
weight = pl.col("w") if store.weighted else pl.lit(1.0)
|
|
216
|
+
|
|
217
|
+
# Residuals: the outcome less the imputed untreated outcome, in one
|
|
218
|
+
# streaming pass that keeps the staged (unit, period) order.
|
|
219
|
+
frame, _, xs, absorbed = staged(store, terms)
|
|
220
|
+
residuals = f"{self.workspace.name}/residuals.parquet"
|
|
221
|
+
(
|
|
222
|
+
first.predict(frame, name="prediction")
|
|
223
|
+
.with_columns(pl.col("g").cast(pl.Int64), pl.col("t").cast(pl.Int64))
|
|
224
|
+
.join(keys.lazy(), on=["g", "t"], how="left", maintain_order="left")
|
|
225
|
+
.with_columns(
|
|
226
|
+
(pl.col("y") - pl.col("prediction")).alias("r"),
|
|
227
|
+
untreated(anticipation).alias("untreated"),
|
|
228
|
+
)
|
|
229
|
+
.drop("prediction")
|
|
230
|
+
.sink_parquet(residuals, row_group_size=store.read_rows, engine="streaming")
|
|
231
|
+
)
|
|
232
|
+
unidentified = pl.scan_parquet(residuals).select(pl.col("r").null_count()).collect().item()
|
|
233
|
+
if unidentified:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"BJS imputation is not identified for {unidentified:,} treated "
|
|
236
|
+
"observations: a fixed-effect level of theirs has no untreated observations"
|
|
237
|
+
)
|
|
238
|
+
for path in store.paths:
|
|
239
|
+
path.unlink()
|
|
240
|
+
store.paths = self.paths = [residuals]
|
|
241
|
+
|
|
242
|
+
cell_rows = pl.scan_parquet(residuals).filter(pl.col("cell").is_not_null())
|
|
243
|
+
sums = (
|
|
244
|
+
cell_rows.group_by("cell")
|
|
245
|
+
.agg(
|
|
246
|
+
(weight * pl.col("r")).sum().alias("effect"),
|
|
247
|
+
(pl.col("w").sum() if store.weighted else pl.len().cast(pl.Float64)).alias("mass"),
|
|
248
|
+
)
|
|
249
|
+
.collect()
|
|
250
|
+
.sort("cell")
|
|
251
|
+
)
|
|
252
|
+
mass = np.zeros(M)
|
|
253
|
+
mass[sums["cell"].to_numpy()] = sums["mass"].to_numpy()
|
|
254
|
+
effect = np.zeros(M)
|
|
255
|
+
effect[sums["cell"].to_numpy()] = sums["effect"].to_numpy()
|
|
256
|
+
self.beta = np.divide(effect, mass, out=np.full(M, np.nan), where=mass > 0)
|
|
257
|
+
self.mass = mass
|
|
258
|
+
self.counts = np.array([store.cell_weights[g][t - times[0]] for g, t in cells])
|
|
259
|
+
|
|
260
|
+
# The untreated model's normal equations, solved for every cell's
|
|
261
|
+
# treated design. Untreated weights are -w z'u_j.
|
|
262
|
+
names = [f"v{j}" for j in range(M)]
|
|
263
|
+
target = cell_rows.select(
|
|
264
|
+
"u",
|
|
265
|
+
"t",
|
|
266
|
+
*[c for c in first_columns(first) if c not in ("u", "t")],
|
|
267
|
+
*[
|
|
268
|
+
pl.when(pl.col("cell") == j).then(weight / mass[j]).otherwise(0.0).alias(n)
|
|
269
|
+
for j, n in enumerate(names)
|
|
270
|
+
],
|
|
271
|
+
)
|
|
272
|
+
solution = first.solve_normal_equations(target, names, block=solve_block(store, M))
|
|
273
|
+
try:
|
|
274
|
+
self._untreated_scores(solution, names, xs, absorbed)
|
|
275
|
+
finally:
|
|
276
|
+
solution.cleanup()
|
|
277
|
+
|
|
278
|
+
def _untreated_scores(self, solution, names, xs, absorbed):
|
|
279
|
+
"""Per unit and cell: -sum over untreated rows of w e z'u_j.
|
|
280
|
+
|
|
281
|
+
The unit effects drop out: the untreated model's normal equation for a
|
|
282
|
+
unit's effect makes its untreated rows' weighted residuals sum to zero.
|
|
283
|
+
"""
|
|
284
|
+
store, M = self.store, len(names)
|
|
285
|
+
tables = []
|
|
286
|
+
for term in absorbed_terms(absorbed):
|
|
287
|
+
table = solution.fixef(term).collect()
|
|
288
|
+
keys = [c for c in table.columns if c not in names]
|
|
289
|
+
tables.append((keys, table.with_row_index("__level"), table.select(names).to_numpy()))
|
|
290
|
+
kept = solution.coef["coefficient"].to_list()
|
|
291
|
+
coef = solution.coef.select(names).to_numpy()
|
|
292
|
+
# Staged units are numbered 0, ..., N-1: the unit is the row.
|
|
293
|
+
self.scores = f"{self.workspace.name}/scores.npy"
|
|
294
|
+
scores = np.lib.format.open_memmap(
|
|
295
|
+
self.scores, mode="w+", dtype=np.float64, shape=(store.n_units, M)
|
|
296
|
+
)
|
|
297
|
+
for path in self.paths:
|
|
298
|
+
store._guard(32e6 + 8 * M * 4 * store.batch_units * store.periods)
|
|
299
|
+
for d in whole_group_batches(path, "u", store.batch_units, store.read_rows):
|
|
300
|
+
d = d.filter(pl.col("untreated"))
|
|
301
|
+
if not d.height:
|
|
302
|
+
continue
|
|
303
|
+
q = np.zeros((d.height, M))
|
|
304
|
+
for keys, table, values in tables:
|
|
305
|
+
level = (
|
|
306
|
+
d.select(keys)
|
|
307
|
+
.with_columns(pl.col(c).cast(table.schema[c]) for c in keys)
|
|
308
|
+
.join(
|
|
309
|
+
table.select(*keys, "__level"),
|
|
310
|
+
on=keys,
|
|
311
|
+
how="left",
|
|
312
|
+
maintain_order="left",
|
|
313
|
+
)["__level"]
|
|
314
|
+
.to_numpy()
|
|
315
|
+
)
|
|
316
|
+
q += values[level]
|
|
317
|
+
if kept:
|
|
318
|
+
q += d.select(kept).to_numpy() @ coef
|
|
319
|
+
w = d["w"].to_numpy() if store.weighted else np.ones(d.height)
|
|
320
|
+
contribution = -(w * d["r"].to_numpy())[:, None] * q
|
|
321
|
+
u = d["u"].to_numpy()
|
|
322
|
+
starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
|
|
323
|
+
scores[u[starts]] = np.add.reduceat(contribution, starts, axis=0)
|
|
324
|
+
scores.flush()
|
|
325
|
+
del scores
|
|
326
|
+
|
|
327
|
+
def cleanup(self):
|
|
328
|
+
self.workspace.cleanup()
|
|
329
|
+
self.closed = True
|
|
330
|
+
|
|
331
|
+
def covariance(self, contrasts, *, allow_unidentified=False):
|
|
332
|
+
if self.closed:
|
|
333
|
+
raise ValueError("BJS aggregation data were removed by cleanup(); refit to aggregate")
|
|
334
|
+
store = self.store
|
|
335
|
+
A = np.atleast_2d(contrasts)
|
|
336
|
+
P, M = A.shape
|
|
337
|
+
T, t0 = store.periods, int(store.times[0])
|
|
338
|
+
labels, groups = {}, {}
|
|
339
|
+
for j, (g, t) in enumerate(self.cells):
|
|
340
|
+
label = {"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
|
|
341
|
+
self.avgeffectsby
|
|
342
|
+
]
|
|
343
|
+
r = labels.setdefault(label, len(labels))
|
|
344
|
+
groups.setdefault((g, r), []).append(j)
|
|
345
|
+
R = len(labels)
|
|
346
|
+
weights = {key: A[:, js] / self.mass[js] for key, js in groups.items()}
|
|
347
|
+
columns = ["u", "t", "g", "r", *(["w"] if store.weighted else [])]
|
|
348
|
+
|
|
349
|
+
def batches():
|
|
350
|
+
untreated = np.load(self.scores, mmap_mode="r")
|
|
351
|
+
for path in self.paths:
|
|
352
|
+
for d in whole_group_batches(path, "u", store.batch_units, store.read_rows):
|
|
353
|
+
d = d.select(columns)
|
|
354
|
+
u = d["u"].to_numpy()
|
|
355
|
+
starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
|
|
356
|
+
row = np.repeat(np.arange(len(starts)), np.diff(np.r_[starts, len(u)]))
|
|
357
|
+
ti = d["t"].to_numpy() - t0
|
|
358
|
+
resid = np.full((len(starts), T), np.nan)
|
|
359
|
+
resid[row, ti] = d["r"].to_numpy()
|
|
360
|
+
sw = np.zeros((len(starts), T))
|
|
361
|
+
sw[row, ti] = d["w"].to_numpy() if store.weighted else 1.0
|
|
362
|
+
first = np.asarray(untreated[u[starts]])
|
|
363
|
+
yield d["g"].to_numpy()[starts], resid, sw, first
|
|
364
|
+
|
|
365
|
+
def parts(g, resid, sw):
|
|
366
|
+
observed = np.isfinite(resid)
|
|
367
|
+
resid = np.nan_to_num(resid)
|
|
368
|
+
for (cg, r), js in groups.items():
|
|
369
|
+
mask = g == cg
|
|
370
|
+
if not mask.any():
|
|
371
|
+
continue
|
|
372
|
+
ti = np.array([self.cells[j][1] - t0 for j in js])
|
|
373
|
+
w = weights[(cg, r)]
|
|
374
|
+
totals = (sw[mask][:, ti] * observed[mask][:, ti]) @ w.T
|
|
375
|
+
effects = (sw[mask][:, ti] * resid[mask][:, ti]) @ w.T
|
|
376
|
+
yield mask, r, totals, effects
|
|
377
|
+
|
|
378
|
+
denom, numer = np.zeros((P, R)), np.zeros((P, R))
|
|
379
|
+
for g, resid, sw, _ in batches():
|
|
380
|
+
for _, r, totals, effects in parts(g, resid, sw):
|
|
381
|
+
denom[:, r] += (totals**2).sum(axis=0)
|
|
382
|
+
numer[:, r] += (totals * effects).sum(axis=0)
|
|
383
|
+
averages = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0)
|
|
384
|
+
V, unidentified = np.zeros((P, P)), np.zeros(P, bool)
|
|
385
|
+
for g, resid, sw, first in batches():
|
|
386
|
+
scores = first @ A.T
|
|
387
|
+
for mask, r, totals, effects in parts(g, resid, sw):
|
|
388
|
+
contribution = effects - totals * averages[:, r]
|
|
389
|
+
if self.leaveout:
|
|
390
|
+
lev = np.divide(
|
|
391
|
+
totals**2, denom[:, r], out=np.zeros_like(totals), where=denom[:, r] > 0
|
|
392
|
+
)
|
|
393
|
+
invalid = (np.abs(totals) > 0) & (lev >= 1 - 1e-12)
|
|
394
|
+
unidentified |= invalid.any(axis=0)
|
|
395
|
+
if invalid.any() and not allow_unidentified:
|
|
396
|
+
raise ValueError(
|
|
397
|
+
"BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
|
|
398
|
+
)
|
|
399
|
+
contribution = np.divide(
|
|
400
|
+
contribution,
|
|
401
|
+
1 - lev,
|
|
402
|
+
out=np.zeros_like(contribution),
|
|
403
|
+
where=lev < 1 - 1e-12,
|
|
404
|
+
)
|
|
405
|
+
scores[mask] += contribution
|
|
406
|
+
V += scores.T @ scores
|
|
407
|
+
V[unidentified, :] = np.nan
|
|
408
|
+
V[:, unidentified] = np.nan
|
|
409
|
+
return (V + V.T) / 2
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def absorbed_terms(absorbed):
|
|
413
|
+
"""The fixed-effect terms other than the unit's, as hdfe-stream names them."""
|
|
414
|
+
return ["t", *absorbed]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def first_columns(first):
|
|
418
|
+
"""The source columns the untreated model uses."""
|
|
419
|
+
est = first._estimator
|
|
420
|
+
columns = {c for d in first.fe_names for c in est.fe_cols[d]}
|
|
421
|
+
for name in first.coefnames:
|
|
422
|
+
columns |= set(est.var_exprs[name].meta.root_names())
|
|
423
|
+
return sorted(columns)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def fit_placebos_absorbed(
|
|
427
|
+
store, pretrends, anticipation, terms, *, fml, depvar, panel, vcov, threads=None
|
|
428
|
+
):
|
|
429
|
+
"""The BJS pre-trend regression with the fixed effects absorbed.
|
|
430
|
+
|
|
431
|
+
The untreated rows (by original cohort), with indicators for the last
|
|
432
|
+
``pretrend_leads`` periods before treatment, on the untreated model.
|
|
433
|
+
"""
|
|
434
|
+
from hdfe_stream import feols_stream
|
|
435
|
+
|
|
436
|
+
from .bjs_placebo import BJSPlaceboResult
|
|
437
|
+
|
|
438
|
+
L = pretrends
|
|
439
|
+
horizons = np.arange(-anticipation - L, -anticipation, dtype=np.int64)
|
|
440
|
+
support, rows = np.zeros(L, dtype=int), []
|
|
441
|
+
scale = store.weight_scale / store.weight_normalizer
|
|
442
|
+
for g, counts in sorted(store.actual_cell_counts.items()):
|
|
443
|
+
if not g:
|
|
444
|
+
continue
|
|
445
|
+
for ti, t in enumerate(store.times):
|
|
446
|
+
j = int(t) - g + anticipation + L
|
|
447
|
+
if 0 <= j < L and counts[ti]:
|
|
448
|
+
support[j] += counts[ti]
|
|
449
|
+
rows.append(
|
|
450
|
+
dict(
|
|
451
|
+
aggregate=f"event::{horizons[j]}",
|
|
452
|
+
cohort=int(g),
|
|
453
|
+
time=int(t),
|
|
454
|
+
event_time=int(horizons[j]),
|
|
455
|
+
observed_units=int(counts[ti]),
|
|
456
|
+
weight_total=float(store.actual_cell_weights[g][ti] * scale),
|
|
457
|
+
)
|
|
458
|
+
)
|
|
459
|
+
if np.any(support == 0):
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"BJS requested clean placebo horizons are not observed: {horizons[support == 0].tolist()}; reduce pretrend_leads"
|
|
462
|
+
)
|
|
463
|
+
frame, covariates, xs, absorbed = staged(store, terms)
|
|
464
|
+
actual = pl.scan_parquet(store.paths, low_memory=True).select("g_actual")
|
|
465
|
+
event = pl.col("t") - pl.col("g_actual")
|
|
466
|
+
data = (
|
|
467
|
+
pl.concat([frame, actual], how="horizontal")
|
|
468
|
+
.filter((pl.col("g_actual") == 0) | (pl.col("t") < pl.col("g_actual") - anticipation))
|
|
469
|
+
.with_columns(
|
|
470
|
+
pl.when((pl.col("g_actual") != 0) & event.is_in(horizons.tolist()))
|
|
471
|
+
.then(pl.format("e{}", event - int(horizons[0])))
|
|
472
|
+
.otherwise(pl.lit("ref"))
|
|
473
|
+
.alias("lead")
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
rhs = " + ".join([*xs, "i(lead, ref='ref')"])
|
|
477
|
+
model = f"y ~ {rhs} | " + " + ".join(["u", "t", *absorbed])
|
|
478
|
+
options = hdfe_options(store, threads, len(xs) + L, vcov={"CRV1": "u"})
|
|
479
|
+
with warnings.catch_warnings():
|
|
480
|
+
warnings.filterwarnings("ignore", _DROPPED)
|
|
481
|
+
fit = feols_stream(model, data, workdir=store.root, **options)
|
|
482
|
+
try:
|
|
483
|
+
names = list(fit.coefnames)
|
|
484
|
+
wanted = [f"lead::e{j}" for j in range(L)]
|
|
485
|
+
if any(n not in names for n in wanted):
|
|
486
|
+
raise ValueError("BJS clean placebo design is unidentified; reduce pretrend_leads")
|
|
487
|
+
ix = [names.index(n) for n in wanted]
|
|
488
|
+
V = (
|
|
489
|
+
np.asarray(fit.vcov, dtype=float)[np.ix_(ix, ix)]
|
|
490
|
+
/ fit.diagnostics["vcov_factors"]["CRV1:u"]
|
|
491
|
+
)
|
|
492
|
+
V *= store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1.0
|
|
493
|
+
beta = np.asarray(fit.beta, dtype=float)[ix]
|
|
494
|
+
n_obs = int(fit.n_obs)
|
|
495
|
+
kept = [n for n, x in zip(covariates, xs, strict=True) if x in names]
|
|
496
|
+
finally:
|
|
497
|
+
fit.cleanup()
|
|
498
|
+
return BJSPlaceboResult(
|
|
499
|
+
fml=fml,
|
|
500
|
+
depvar=depvar,
|
|
501
|
+
coefnames=[f"event::{h}" for h in horizons],
|
|
502
|
+
beta=beta,
|
|
503
|
+
vcov=(V + V.T) / 2,
|
|
504
|
+
vcov_type=vcov,
|
|
505
|
+
df_t=store.n_units - 1,
|
|
506
|
+
all_vcovs={vcov: ((V + V.T) / 2, store.n_units - 1)},
|
|
507
|
+
n_obs=n_obs,
|
|
508
|
+
n_clusters={panel[0]: store.n_units},
|
|
509
|
+
n_units=store.n_units,
|
|
510
|
+
weights=None,
|
|
511
|
+
_support=pl.DataFrame(rows),
|
|
512
|
+
diagnostics=dict(
|
|
513
|
+
aggregation="event_time",
|
|
514
|
+
anticipation=anticipation,
|
|
515
|
+
reference_event_time=None,
|
|
516
|
+
fitted_horizons=horizons.tolist(),
|
|
517
|
+
pretrends=L,
|
|
518
|
+
base_period="all other clean observations; no fixed event-time reference",
|
|
519
|
+
inference="unit clustered; clean-sample regression with absorbed fixed effects",
|
|
520
|
+
regression_columns=[*kept, *(f"event::{h}" for h in horizons)],
|
|
521
|
+
absorbed_or_redundant_columns=[n for n in covariates if n not in kept],
|
|
522
|
+
absorbed_terms=[written for _, written in terms],
|
|
523
|
+
warnings=[],
|
|
524
|
+
),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
class UnimputedObservationsWarning(UserWarning):
|
|
529
|
+
"""BJS dropped treated observations whose untreated outcome cannot be imputed."""
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def drop_unimputable(store, terms, anticipation):
|
|
533
|
+
"""Drop the treated rows with a fixed-effect level no untreated row has.
|
|
534
|
+
|
|
535
|
+
Their untreated outcomes are not identified: the untreated model has no
|
|
536
|
+
effect for that level (a county-year in which every unit is treated, say).
|
|
537
|
+
Dropping them changes no other estimate, but the effects then average the
|
|
538
|
+
remaining treated observations only, so the fit warns and reports the
|
|
539
|
+
affected cells.
|
|
540
|
+
"""
|
|
541
|
+
from .reference import restage
|
|
542
|
+
|
|
543
|
+
rows = pl.scan_parquet(store.paths, low_memory=True)
|
|
544
|
+
clean = untreated(anticipation)
|
|
545
|
+
flags = []
|
|
546
|
+
for j, (term, _) in enumerate(terms):
|
|
547
|
+
columns = term.split("^")
|
|
548
|
+
seen = (
|
|
549
|
+
rows.filter(clean)
|
|
550
|
+
.select(columns)
|
|
551
|
+
.unique()
|
|
552
|
+
.with_columns(pl.lit(True).alias(f"__seen{j}"))
|
|
553
|
+
)
|
|
554
|
+
rows = rows.join(seen, on=columns, how="left", maintain_order="left")
|
|
555
|
+
flags.append(f"__seen{j}")
|
|
556
|
+
unimputable = ~clean & pl.any_horizontal(pl.col(f).is_null() for f in flags)
|
|
557
|
+
rows = rows.with_columns(unimputable.alias("__unimputable")).drop(flags)
|
|
558
|
+
weight = pl.col("w") if store.weighted else pl.lit(1.0)
|
|
559
|
+
treated = rows.filter(~clean)
|
|
560
|
+
totals, by_cell = pl.collect_all(
|
|
561
|
+
[
|
|
562
|
+
treated.select(
|
|
563
|
+
(weight * pl.col("__unimputable").cast(pl.Float64)).sum().alias("dropped"),
|
|
564
|
+
(weight + 0 * pl.col("y")).sum().alias("treated"),
|
|
565
|
+
pl.col("__unimputable").sum().alias("rows"),
|
|
566
|
+
),
|
|
567
|
+
treated.group_by("g", "t")
|
|
568
|
+
.agg(
|
|
569
|
+
pl.col("__unimputable").sum().alias("dropped"),
|
|
570
|
+
pl.len().alias("observations"),
|
|
571
|
+
)
|
|
572
|
+
.filter(pl.col("dropped") > 0)
|
|
573
|
+
.sort("g", "t"),
|
|
574
|
+
]
|
|
575
|
+
)
|
|
576
|
+
n = int(totals["rows"][0])
|
|
577
|
+
if not n:
|
|
578
|
+
return store, dict(unimputed_observations=0)
|
|
579
|
+
share = float(totals["dropped"][0] / totals["treated"][0])
|
|
580
|
+
if n == treated.select(pl.len()).collect().item():
|
|
581
|
+
raise ValueError(
|
|
582
|
+
"BJS is unidentified: every treated observation has a fixed-effect level with no "
|
|
583
|
+
"untreated observations (fixed effects such as cohort-by-period absorb the treatment)"
|
|
584
|
+
)
|
|
585
|
+
cells = [
|
|
586
|
+
dict(
|
|
587
|
+
cohort=int(g),
|
|
588
|
+
time=int(t),
|
|
589
|
+
dropped_observations=int(k),
|
|
590
|
+
remaining_observations=int(m - k),
|
|
591
|
+
)
|
|
592
|
+
for g, t, k, m in by_cell.iter_rows()
|
|
593
|
+
]
|
|
594
|
+
restricted = restage(
|
|
595
|
+
store,
|
|
596
|
+
rows.filter(~pl.col("__unimputable")).drop("__unimputable"),
|
|
597
|
+
"imputable",
|
|
598
|
+
"unbalanced",
|
|
599
|
+
anticipation,
|
|
600
|
+
)
|
|
601
|
+
for path in store.paths:
|
|
602
|
+
path.unlink()
|
|
603
|
+
labels = store.period_labels
|
|
604
|
+
listed = ", ".join(
|
|
605
|
+
f"{labels.label_cohort(c['cohort'])}/{labels.label_int(c['time'])}" for c in cells[:5]
|
|
606
|
+
)
|
|
607
|
+
more = f" and {len(cells) - 5} more" if len(cells) > 5 else ""
|
|
608
|
+
warnings.warn(
|
|
609
|
+
f"{n:,} treated observations ({share:.1%} of the treated weight) have a fixed-effect "
|
|
610
|
+
"level with no untreated observations, so their untreated outcomes cannot be "
|
|
611
|
+
"imputed. They are dropped, and the effects average the remaining treated "
|
|
612
|
+
f"observations. Cohort/period cells affected: {listed}{more}; see "
|
|
613
|
+
"fit.diagnostics['unimputed_cells'].",
|
|
614
|
+
UnimputedObservationsWarning,
|
|
615
|
+
stacklevel=4,
|
|
616
|
+
)
|
|
617
|
+
return restricted, dict(
|
|
618
|
+
unimputed_observations=n,
|
|
619
|
+
unimputed_treated_weight_share=share,
|
|
620
|
+
unimputed_cells=cells,
|
|
621
|
+
)
|