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,552 @@
|
|
|
1
|
+
"""Published feTR treatment-effect weights for an observed-cell TWFE regression."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from tempfile import TemporaryDirectory
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import polars as pl
|
|
10
|
+
import pyarrow.parquet as pq
|
|
11
|
+
from hdfe_stream.core.profile import Component, Profile
|
|
12
|
+
from stream_limits import MemoryBudget, current_rss_gb, detect_cpus, thread_scope
|
|
13
|
+
|
|
14
|
+
from . import _docs, _options
|
|
15
|
+
from ._core.nuisance import solve_checked
|
|
16
|
+
from ._core.partition import preprocess_budget_mb, staging_bytes
|
|
17
|
+
from ._core.within import center_groups, independent_columns
|
|
18
|
+
from ._present import public_profile
|
|
19
|
+
from .twfe_store import WeightStore, inspect_weights
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(kw_only=True)
|
|
23
|
+
class TWFEWeightResult:
|
|
24
|
+
"""Descriptive feTR weights and sensitivity summaries; no ATT inference.
|
|
25
|
+
|
|
26
|
+
Cell storage is optional (keep_cells=True). Use cleanup() or a context
|
|
27
|
+
manager to release retained temporary files. Reduced summaries survive it.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
fml: str
|
|
31
|
+
twfe_estimate: float
|
|
32
|
+
n_units: int
|
|
33
|
+
n_obs: int
|
|
34
|
+
diagnostics: dict
|
|
35
|
+
_summary: dict = field(repr=False)
|
|
36
|
+
_sensitivity: dict = field(repr=False)
|
|
37
|
+
_run: object = field(default=None, repr=False)
|
|
38
|
+
_path: Path | None = field(default=None, repr=False)
|
|
39
|
+
|
|
40
|
+
def summary(self):
|
|
41
|
+
"""Reduced sign counts/sums, weight extrema and population shares."""
|
|
42
|
+
return pl.DataFrame(self._summary["by_sign"])
|
|
43
|
+
|
|
44
|
+
def sensitivity(self):
|
|
45
|
+
"""Two published sensitivity thresholds with explicit status/conventions."""
|
|
46
|
+
return pl.DataFrame(self._sensitivity["measures"])
|
|
47
|
+
|
|
48
|
+
def summary_dict(self):
|
|
49
|
+
return dict(
|
|
50
|
+
model="TWFE treatment-effect weights (feTR)",
|
|
51
|
+
fml=self.fml,
|
|
52
|
+
twfe_estimate=self.twfe_estimate,
|
|
53
|
+
n_units=self.n_units,
|
|
54
|
+
n_obs=self.n_obs,
|
|
55
|
+
diagnostics=self.diagnostics,
|
|
56
|
+
weights=json.loads(json.dumps(self._summary)),
|
|
57
|
+
sensitivity=json.loads(json.dumps(self._sensitivity)),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def summary_json(self, path=None):
|
|
61
|
+
content = json.dumps(self.summary_dict(), indent=2, allow_nan=False)
|
|
62
|
+
if path is not None:
|
|
63
|
+
Path(path).write_text(content + "\n")
|
|
64
|
+
return content
|
|
65
|
+
|
|
66
|
+
def _require_cells(self):
|
|
67
|
+
if self._path is None or not self._path.exists():
|
|
68
|
+
raise ValueError(
|
|
69
|
+
"cell storage is unavailable; fit with keep_cells=True and export before cleanup()"
|
|
70
|
+
)
|
|
71
|
+
return self._path
|
|
72
|
+
|
|
73
|
+
def cells(self):
|
|
74
|
+
"""Lazy, disk-backed cell table; valid until cleanup()."""
|
|
75
|
+
return pl.scan_parquet(self._require_cells(), low_memory=True)
|
|
76
|
+
|
|
77
|
+
def iter_cells(self, batch_rows=32768):
|
|
78
|
+
"""Bounded cell frames (ordering across partitions is unspecified)."""
|
|
79
|
+
if isinstance(batch_rows, bool) or not isinstance(batch_rows, int) or batch_rows < 1:
|
|
80
|
+
raise ValueError("batch_rows must be a positive integer")
|
|
81
|
+
with pq.ParquetFile(self._require_cells(), pre_buffer=False) as pf:
|
|
82
|
+
for b in pf.iter_batches(batch_size=batch_rows):
|
|
83
|
+
yield pl.from_arrow(b)
|
|
84
|
+
|
|
85
|
+
def write_cells(self, path):
|
|
86
|
+
"""Stream cells to a caller-owned Parquet file that survives cleanup()."""
|
|
87
|
+
path = Path(path)
|
|
88
|
+
src = self._require_cells()
|
|
89
|
+
if path.resolve() == src.resolve() or src.parent.resolve() in path.resolve().parents:
|
|
90
|
+
raise ValueError("export cells outside the temporary fit directory")
|
|
91
|
+
if path.exists():
|
|
92
|
+
raise FileExistsError(path)
|
|
93
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
writer = None
|
|
95
|
+
try:
|
|
96
|
+
for b in self.iter_cells():
|
|
97
|
+
table = b.to_arrow()
|
|
98
|
+
if writer is None:
|
|
99
|
+
writer = pq.ParquetWriter(path, table.schema, compression="zstd")
|
|
100
|
+
writer.write_table(table, row_group_size=32768)
|
|
101
|
+
except BaseException:
|
|
102
|
+
if writer is not None:
|
|
103
|
+
writer.close()
|
|
104
|
+
writer = None
|
|
105
|
+
path.unlink(missing_ok=True)
|
|
106
|
+
raise
|
|
107
|
+
finally:
|
|
108
|
+
if writer is not None:
|
|
109
|
+
writer.close()
|
|
110
|
+
return path
|
|
111
|
+
|
|
112
|
+
def cleanup(self):
|
|
113
|
+
if self._run is not None:
|
|
114
|
+
self._run.cleanup()
|
|
115
|
+
self._run = None
|
|
116
|
+
self._path = None
|
|
117
|
+
|
|
118
|
+
def __enter__(self):
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
def __exit__(self, *args):
|
|
122
|
+
self.cleanup()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _opposite_threshold(path, beta, max_multiplier, zero_tol):
|
|
126
|
+
"""Solve the paper's sorted-tail cutoff with bounded repeated reductions.
|
|
127
|
+
|
|
128
|
+
For cells with nonzero reference-classified q and W<c, accumulate P,S,T.
|
|
129
|
+
The optimal tail satisfies c=-S/(1-P). Active-set Newton steps converge
|
|
130
|
+
monotonically from c=0; safeguarded bisection avoids a global cell sort.
|
|
131
|
+
"""
|
|
132
|
+
lo, hi, cutoff = 0.0, max_multiplier, 0.0
|
|
133
|
+
last = None
|
|
134
|
+
for iteration in range(100):
|
|
135
|
+
P = S = T = 0.0
|
|
136
|
+
with pq.ParquetFile(path, pre_buffer=False) as pf:
|
|
137
|
+
for b in pf.iter_batches(
|
|
138
|
+
batch_size=32768, columns=["relative_weight", "treated_share", "weight"]
|
|
139
|
+
):
|
|
140
|
+
f = pl.from_arrow(b)
|
|
141
|
+
W, p, q = [f[c].to_numpy() for c in f.columns]
|
|
142
|
+
keep = (np.abs(q) >= zero_tol) & (q != 0) & (W < cutoff)
|
|
143
|
+
P += float(p[keep].sum())
|
|
144
|
+
S += float(q[keep].sum())
|
|
145
|
+
T += float(np.sum(p[keep] * W[keep] ** 2))
|
|
146
|
+
remaining = 1 - P
|
|
147
|
+
if remaining <= 0:
|
|
148
|
+
hi = cutoff
|
|
149
|
+
cutoff = (lo + hi) / 2
|
|
150
|
+
continue
|
|
151
|
+
next_cutoff = -S / remaining
|
|
152
|
+
if last == (P, S, T) or abs(next_cutoff - cutoff) <= 1e-12 * max(1, abs(cutoff)):
|
|
153
|
+
variance = T + S * S / remaining
|
|
154
|
+
if variance > 0 and np.isfinite(variance):
|
|
155
|
+
return abs(beta) / np.sqrt(variance), iteration + 1
|
|
156
|
+
break
|
|
157
|
+
last = (P, S, T)
|
|
158
|
+
if cutoff * remaining + S < 0:
|
|
159
|
+
lo = cutoff
|
|
160
|
+
else:
|
|
161
|
+
hi = cutoff
|
|
162
|
+
cutoff = next_cutoff if lo < next_cutoff < hi else (lo + hi) / 2
|
|
163
|
+
raise FloatingPointError(
|
|
164
|
+
"opposite-sign sensitivity cutoff did not converge; rescale weights/covariates"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fit(store, path, zero_tol):
|
|
169
|
+
T = store.periods
|
|
170
|
+
names = [f"time::{t}" for t in store.times[1:]] + (
|
|
171
|
+
[] if store.plan is None else [n for n in store.plan.names if n != "Intercept"]
|
|
172
|
+
)
|
|
173
|
+
P = len(names)
|
|
174
|
+
Q, rhs, norms = np.zeros((P, P)), np.zeros(P), np.zeros(P)
|
|
175
|
+
dd = treated_mass = total_mass = 0.0
|
|
176
|
+
for _, X, D, _Y, w, _, _ in store.matrices():
|
|
177
|
+
xc, dc, _, _ = center_groups(X, D, w)
|
|
178
|
+
z, sw = xc.reshape(-1, P), w.ravel()
|
|
179
|
+
Q += z.T @ (sw[:, None] * z)
|
|
180
|
+
rhs += z.T @ (sw * dc.ravel())
|
|
181
|
+
norms += np.einsum("btp,bt,btp->p", X, w, X, optimize=True)
|
|
182
|
+
dd += float(np.sum(w * dc * dc))
|
|
183
|
+
treated_mass += float(np.sum(w * D))
|
|
184
|
+
total_mass += float(w.sum())
|
|
185
|
+
keep = independent_columns(Q, original_norm=norms)
|
|
186
|
+
coef = np.zeros(P)
|
|
187
|
+
if len(keep):
|
|
188
|
+
coef[keep] = solve_checked(Q[np.ix_(keep, keep)], rhs[keep], "TWFE treatment projection")
|
|
189
|
+
rr = rd = ry = 0.0
|
|
190
|
+
orth = np.zeros(P)
|
|
191
|
+
for _, X, D, Y, w, _, _ in store.matrices():
|
|
192
|
+
xc, dc, _, _ = center_groups(X, D, w)
|
|
193
|
+
_, yc, _, _ = center_groups(X, Y, w)
|
|
194
|
+
r = dc - xc @ coef
|
|
195
|
+
rr += float(np.sum(w * r * r))
|
|
196
|
+
rd += float(np.sum(w * r * dc))
|
|
197
|
+
ry += float(np.sum(w * r * yc))
|
|
198
|
+
orth += np.einsum("btp,bt,bt->p", xc, w, r, optimize=True)
|
|
199
|
+
if not np.isfinite(rr) or rr <= 1e-12 * max(dd, 1e-300) or treated_mass <= 0:
|
|
200
|
+
raise ValueError(
|
|
201
|
+
"treatment is collinear with unit/time effects and covariates; no identified TWFE diagnostic"
|
|
202
|
+
)
|
|
203
|
+
orth_error = float(np.max(np.abs(orth) / np.sqrt(np.maximum(np.diag(Q) * rr, 1e-300))))
|
|
204
|
+
if not np.isclose(rd, rr, rtol=1e-7, atol=1e-14 * dd) or orth_error > 1e-7:
|
|
205
|
+
raise FloatingPointError(
|
|
206
|
+
"TWFE treatment projection lost orthogonality; rescale covariates/weights"
|
|
207
|
+
)
|
|
208
|
+
beta = ry / rr
|
|
209
|
+
if not np.isfinite(beta):
|
|
210
|
+
raise FloatingPointError("TWFE coefficient overflowed; rescale the outcome")
|
|
211
|
+
rows = {
|
|
212
|
+
s: dict(sign=s, n_cells=0, weight_sum=0.0, treated_share=0.0)
|
|
213
|
+
for s in ["positive", "negative", "zero"]
|
|
214
|
+
}
|
|
215
|
+
sums = dict(weight_sum=0.0, outcome_reconstruction=0.0, multiplier_variance=0.0)
|
|
216
|
+
count = 0
|
|
217
|
+
extrema = [np.inf, -np.inf, -np.inf]
|
|
218
|
+
writer = None
|
|
219
|
+
pending, pending_rows = [], 0
|
|
220
|
+
try:
|
|
221
|
+
for frame, X, D, Y, w, u, t in store.matrices():
|
|
222
|
+
xc, dc, _, _ = center_groups(X, D, w)
|
|
223
|
+
_, yc, _, _ = center_groups(X, Y, w)
|
|
224
|
+
r = (dc - xc @ coef)[u, t]
|
|
225
|
+
m, d = w[u, t], D[u, t]
|
|
226
|
+
loading = m * r / rr
|
|
227
|
+
q, p = loading * d, m * d / treated_mass
|
|
228
|
+
W = r * treated_mass / rr
|
|
229
|
+
if not np.isfinite(W).all() or not np.isfinite(q).all():
|
|
230
|
+
raise FloatingPointError("TWFE cell weights overflowed; rescale weights/covariates")
|
|
231
|
+
classified = np.where(np.abs(q) < zero_tol, 0.0, q)
|
|
232
|
+
active = d == 1
|
|
233
|
+
count += int(active.sum())
|
|
234
|
+
for sign, mask in [
|
|
235
|
+
("positive", classified > 0),
|
|
236
|
+
("negative", classified < 0),
|
|
237
|
+
("zero", classified == 0),
|
|
238
|
+
]:
|
|
239
|
+
mask &= active
|
|
240
|
+
rows[sign]["n_cells"] += int(mask.sum())
|
|
241
|
+
rows[sign]["weight_sum"] += float(classified[mask].sum())
|
|
242
|
+
rows[sign]["treated_share"] += float(p[mask].sum())
|
|
243
|
+
if active.any():
|
|
244
|
+
extrema[0] = min(extrema[0], float(q[active].min()))
|
|
245
|
+
extrema[1] = max(extrema[1], float(q[active].max()))
|
|
246
|
+
extrema[2] = max(extrema[2], float(W[active].max()))
|
|
247
|
+
sums["weight_sum"] += float(q.sum())
|
|
248
|
+
sums["outcome_reconstruction"] += float(loading @ yc[u, t])
|
|
249
|
+
sums["multiplier_variance"] += float(np.sum(p * (W - 1) ** 2))
|
|
250
|
+
output = frame.select(
|
|
251
|
+
pl.col("id").alias("unit"),
|
|
252
|
+
pl.col("t").alias("time"),
|
|
253
|
+
pl.col("d").alias("treatment"),
|
|
254
|
+
pl.col("w").alias("sampling_weight"),
|
|
255
|
+
)
|
|
256
|
+
output = output.with_columns(
|
|
257
|
+
pl.Series("treatment_residual", r),
|
|
258
|
+
pl.Series("outcome_loading", loading),
|
|
259
|
+
pl.Series("weight", q),
|
|
260
|
+
pl.Series("treated_share", p),
|
|
261
|
+
pl.Series("relative_weight", np.where(active, W, np.nan)).fill_nan(None),
|
|
262
|
+
pl.Series("classified_weight", classified),
|
|
263
|
+
)
|
|
264
|
+
if writer is None:
|
|
265
|
+
writer = pq.ParquetWriter(
|
|
266
|
+
path, output.to_arrow().schema, compression="zstd", use_dictionary=False
|
|
267
|
+
)
|
|
268
|
+
# Small unit batches must not create one Parquet row group each:
|
|
269
|
+
# all-row-group reader metadata would then grow unnecessarily fast.
|
|
270
|
+
pending.append(output)
|
|
271
|
+
pending_rows += len(output)
|
|
272
|
+
if pending_rows >= 32768:
|
|
273
|
+
writer.write_table(pl.concat(pending).to_arrow(), row_group_size=32768)
|
|
274
|
+
pending, pending_rows = [], 0
|
|
275
|
+
if pending:
|
|
276
|
+
writer.write_table(pl.concat(pending).to_arrow(), row_group_size=32768)
|
|
277
|
+
finally:
|
|
278
|
+
if writer is not None:
|
|
279
|
+
writer.close()
|
|
280
|
+
if not all(np.isfinite(v) for v in sums.values()):
|
|
281
|
+
raise FloatingPointError(
|
|
282
|
+
"TWFE sensitivity moments overflowed; inspect weight concentration"
|
|
283
|
+
)
|
|
284
|
+
if not np.isclose(sums["weight_sum"], 1, atol=1e-8, rtol=0):
|
|
285
|
+
raise FloatingPointError("TWFE treatment-effect weights fail normalization")
|
|
286
|
+
summary = dict(
|
|
287
|
+
by_sign=list(rows.values()),
|
|
288
|
+
raw_weight_sum=sums["weight_sum"],
|
|
289
|
+
negative_weight_mass=-rows["negative"]["weight_sum"],
|
|
290
|
+
min_weight=extrema[0],
|
|
291
|
+
max_weight=extrema[1],
|
|
292
|
+
n_treated_cells=count,
|
|
293
|
+
zero_tolerance=zero_tol,
|
|
294
|
+
)
|
|
295
|
+
var = sums["multiplier_variance"]
|
|
296
|
+
measures = []
|
|
297
|
+
if var > 1e-24 and count > 1:
|
|
298
|
+
value = abs(beta) / np.sqrt(var * count / (count - 1))
|
|
299
|
+
status = "zero_coefficient" if beta == 0 else "finite"
|
|
300
|
+
else:
|
|
301
|
+
value, status = (
|
|
302
|
+
None,
|
|
303
|
+
"undefined_zero_coefficient" if beta == 0 else "infinite_constant_multiplier",
|
|
304
|
+
)
|
|
305
|
+
measures.append(dict(measure="zero_average_sd", value=value, status=status))
|
|
306
|
+
iterations = 0
|
|
307
|
+
if beta == 0:
|
|
308
|
+
value, status = None, "undefined_zero_coefficient"
|
|
309
|
+
elif rows["negative"]["n_cells"]:
|
|
310
|
+
value, iterations = _opposite_threshold(path, beta, extrema[2], zero_tol)
|
|
311
|
+
status = "finite"
|
|
312
|
+
else:
|
|
313
|
+
value, status = None, "infeasible_no_negative_weights"
|
|
314
|
+
measures.append(dict(measure="all_opposite_sign_sd", value=value, status=status))
|
|
315
|
+
sensitivity = dict(
|
|
316
|
+
measures=measures,
|
|
317
|
+
multiplier_population_variance=var,
|
|
318
|
+
zero_average_sd_convention="R TwoWayFEWeights: weighted SD times sqrt(M/(M-1)), M=treated cell count",
|
|
319
|
+
opposite_sign_algorithm="bounded-pass tail-cutoff reduction",
|
|
320
|
+
cutoff_iterations=iterations,
|
|
321
|
+
)
|
|
322
|
+
meta = dict(
|
|
323
|
+
projection_columns=[names[j] for j in keep],
|
|
324
|
+
absorbed_or_redundant_columns=[n for j, n in enumerate(names) if j not in keep],
|
|
325
|
+
date_components=T - len(independent_columns(Q[: T - 1, : T - 1])),
|
|
326
|
+
projection_orthogonality_error=orth_error,
|
|
327
|
+
residualized_treatment_variance=rr / total_mass,
|
|
328
|
+
outcome_loading_reconstruction_error=sums["outcome_reconstruction"] - beta,
|
|
329
|
+
)
|
|
330
|
+
return float(beta), summary, sensitivity, meta
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _twfe_weights(
|
|
334
|
+
fml,
|
|
335
|
+
data,
|
|
336
|
+
*,
|
|
337
|
+
panel,
|
|
338
|
+
treatment=None,
|
|
339
|
+
cohort=None,
|
|
340
|
+
panel_mode="balanced",
|
|
341
|
+
weights=None,
|
|
342
|
+
allow_time_varying_weights=False,
|
|
343
|
+
keep_cells=False,
|
|
344
|
+
zero_tol=1e-10,
|
|
345
|
+
batch_units=256,
|
|
346
|
+
preprocess_mb=None,
|
|
347
|
+
threads=None,
|
|
348
|
+
memory_gb=None,
|
|
349
|
+
memory_reserve=None,
|
|
350
|
+
workdir=None,
|
|
351
|
+
profile=False,
|
|
352
|
+
max_design_columns=2048,
|
|
353
|
+
):
|
|
354
|
+
"""TWFE treatment-effect weights from internal option names; see twfe_weights.
|
|
355
|
+
|
|
356
|
+
Native unbalanced fits use observed cells. zero_tol classifies summary
|
|
357
|
+
weights as zero (R's 1e-10 default) without changing raw cell weights.
|
|
358
|
+
keep_cells retains temporary Parquet until cleanup().
|
|
359
|
+
"""
|
|
360
|
+
if panel_mode not in ("balanced", "unbalanced", "complete"):
|
|
361
|
+
raise ValueError("panel_mode must be balanced, unbalanced or complete")
|
|
362
|
+
for name, value in [("batch_units", batch_units), ("max_design_columns", max_design_columns)]:
|
|
363
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
364
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
365
|
+
if threads is not None and (
|
|
366
|
+
isinstance(threads, bool) or not isinstance(threads, int) or threads < 1
|
|
367
|
+
):
|
|
368
|
+
raise ValueError("n_threads must be a positive integer")
|
|
369
|
+
if isinstance(zero_tol, bool) or not np.isfinite(zero_tol) or zero_tol < 0:
|
|
370
|
+
raise ValueError("zero_tolerance must be finite and nonnegative")
|
|
371
|
+
budget = MemoryBudget(memory_gb, memory_reserve)
|
|
372
|
+
preprocess_mb = preprocess_budget_mb(preprocess_mb, budget)
|
|
373
|
+
baseline = current_rss_gb() or 0
|
|
374
|
+
lf, y, plan = inspect_weights(fml, data, panel, treatment, cohort, weights)
|
|
375
|
+
if workdir is not None:
|
|
376
|
+
Path(workdir).mkdir(parents=True, exist_ok=True)
|
|
377
|
+
run = TemporaryDirectory(prefix="diff-stream-twfe-weights-", dir=workdir)
|
|
378
|
+
try:
|
|
379
|
+
with thread_scope(threads):
|
|
380
|
+
store = WeightStore(
|
|
381
|
+
lf,
|
|
382
|
+
run.name,
|
|
383
|
+
budget,
|
|
384
|
+
preprocess_mb,
|
|
385
|
+
batch_units,
|
|
386
|
+
plan,
|
|
387
|
+
panel_mode,
|
|
388
|
+
allow_time_varying_weights,
|
|
389
|
+
)
|
|
390
|
+
K = 0 if plan is None else sum(n != "Intercept" for n in plan.names)
|
|
391
|
+
P, T, B = store.periods - 1 + K, store.periods, min(batch_units, store.n_units)
|
|
392
|
+
numeric = 8 * (10 * P * P + 12 * B * T * (P + 4)) + 48e6
|
|
393
|
+
meta = dict(
|
|
394
|
+
type="feTR",
|
|
395
|
+
panel_mode=panel_mode,
|
|
396
|
+
treatment=treatment,
|
|
397
|
+
cohort=cohort,
|
|
398
|
+
sampling_weights=weights,
|
|
399
|
+
weights_time_invariant=store.weights_time_invariant,
|
|
400
|
+
periods=T,
|
|
401
|
+
input_rows=store.input_rows,
|
|
402
|
+
input_units=store.input_units,
|
|
403
|
+
dropped_rows=store.input_rows - store.n_obs,
|
|
404
|
+
dropped_units=store.input_units - store.n_units,
|
|
405
|
+
partitions=store.partition_count,
|
|
406
|
+
max_decoded_leaf_bytes=store.max_leaf_bytes,
|
|
407
|
+
design_columns=P,
|
|
408
|
+
batch_units=batch_units,
|
|
409
|
+
numerical_bytes=numeric,
|
|
410
|
+
covariate_names=[] if plan is None else [n for n in plan.names if n != "Intercept"],
|
|
411
|
+
treatment_effect_assumptions="common trends and contemporaneous binary treatment effects; no stable-effect restriction",
|
|
412
|
+
zero_tolerance=zero_tol,
|
|
413
|
+
keep_cells=keep_cells,
|
|
414
|
+
)
|
|
415
|
+
if profile:
|
|
416
|
+
return Profile(
|
|
417
|
+
model="diff-stream TWFE treatment-effect weights",
|
|
418
|
+
threads=threads or detect_cpus(),
|
|
419
|
+
memory_limit_gb=budget.limit_gb,
|
|
420
|
+
memory_reserve=budget.reserve,
|
|
421
|
+
polars_threads=pl.thread_pool_size(),
|
|
422
|
+
baseline_gb=baseline,
|
|
423
|
+
components=[
|
|
424
|
+
Component(
|
|
425
|
+
"bounded treatment projection and cell reductions",
|
|
426
|
+
numeric,
|
|
427
|
+
1.5 * numeric,
|
|
428
|
+
),
|
|
429
|
+
Component(
|
|
430
|
+
"bounded partition preprocessing",
|
|
431
|
+
staging_bytes(preprocess_mb, store) + 96e6,
|
|
432
|
+
preprocess_mb * 1e6 + 256e6,
|
|
433
|
+
),
|
|
434
|
+
Component(
|
|
435
|
+
"snapshot, partitions and cell weights",
|
|
436
|
+
store.n_obs * 128,
|
|
437
|
+
store.n_obs * 256,
|
|
438
|
+
where="disk",
|
|
439
|
+
),
|
|
440
|
+
],
|
|
441
|
+
notes=meta,
|
|
442
|
+
warnings=[
|
|
443
|
+
"Heuristic; dense state grows with dates/covariates and batch size. Cell output scales on disk."
|
|
444
|
+
],
|
|
445
|
+
)
|
|
446
|
+
if P > max_design_columns:
|
|
447
|
+
raise ValueError(
|
|
448
|
+
"TWFE projection exceeds max_design_columns; reduce dates/covariates or explicitly raise the guard"
|
|
449
|
+
)
|
|
450
|
+
if not budget.fits(numeric / 1e9):
|
|
451
|
+
raise MemoryError(
|
|
452
|
+
"TWFE projection buffers exceed job headroom; reduce batch_units/dates/covariates"
|
|
453
|
+
)
|
|
454
|
+
path = Path(run.name) / "cells.parquet"
|
|
455
|
+
beta, summary, sensitivity, projection = _fit(store, path, zero_tol)
|
|
456
|
+
meta.update(projection)
|
|
457
|
+
for source_path in store.paths:
|
|
458
|
+
source_path.unlink()
|
|
459
|
+
result = TWFEWeightResult(
|
|
460
|
+
fml=fml,
|
|
461
|
+
twfe_estimate=beta,
|
|
462
|
+
n_units=store.n_units,
|
|
463
|
+
n_obs=store.n_obs,
|
|
464
|
+
diagnostics=meta,
|
|
465
|
+
_summary=summary,
|
|
466
|
+
_sensitivity=sensitivity,
|
|
467
|
+
)
|
|
468
|
+
if keep_cells:
|
|
469
|
+
result._run, result._path = run, path
|
|
470
|
+
run = None
|
|
471
|
+
return result
|
|
472
|
+
finally:
|
|
473
|
+
if run is not None:
|
|
474
|
+
run.cleanup()
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@_docs.document
|
|
478
|
+
def twfe_weights(
|
|
479
|
+
fml,
|
|
480
|
+
data,
|
|
481
|
+
*,
|
|
482
|
+
panel,
|
|
483
|
+
treatment=None,
|
|
484
|
+
cohort=None,
|
|
485
|
+
weights=None,
|
|
486
|
+
allow_time_varying_weights=False,
|
|
487
|
+
incomplete_units="error",
|
|
488
|
+
keep_cells=False,
|
|
489
|
+
zero_tolerance=1e-10,
|
|
490
|
+
max_design_columns=2048,
|
|
491
|
+
n_threads=None,
|
|
492
|
+
max_memory_gb=None,
|
|
493
|
+
memory_reserve=None,
|
|
494
|
+
workdir=None,
|
|
495
|
+
profile=False,
|
|
496
|
+
batch_units=256,
|
|
497
|
+
preprocess_mb=None,
|
|
498
|
+
):
|
|
499
|
+
"""de Chaisemartin and D'Haultfoeuille's weights behind a TWFE estimate.
|
|
500
|
+
|
|
501
|
+
Under heterogeneous effects, the coefficient on a binary treatment in a
|
|
502
|
+
regression with unit and time fixed effects is a weighted sum of the
|
|
503
|
+
effects in each treated unit-period, and some weights can be negative.
|
|
504
|
+
This reports those weights and two sensitivity measures: how much effect
|
|
505
|
+
heterogeneity would be needed for the estimate to have the opposite sign
|
|
506
|
+
from every unit-period's effect. It is a diagnostic, not an estimator of
|
|
507
|
+
a causal effect. It follows the authors' R package TwoWayFEWeights
|
|
508
|
+
(``type="feTR"``).
|
|
509
|
+
|
|
510
|
+
de Chaisemartin, C. and D'Haultfoeuille, X. (2020). Two-way fixed effects
|
|
511
|
+
estimators with heterogeneous treatment effects. *American Economic
|
|
512
|
+
Review* 110(9), 2964-2996.
|
|
513
|
+
|
|
514
|
+
Parameters
|
|
515
|
+
----------
|
|
516
|
+
fml : str
|
|
517
|
+
``"y ~ 1"``, or ``"y ~ x + z"`` with covariates. Unit and time fixed
|
|
518
|
+
effects are always included.
|
|
519
|
+
data : polars.DataFrame, polars.LazyFrame, str or Path
|
|
520
|
+
The panel, or a Parquet path or glob.
|
|
521
|
+
panel : tuple of str
|
|
522
|
+
The unit and time columns. Times are positive integers; gaps are
|
|
523
|
+
allowed.
|
|
524
|
+
treatment, cohort : str, optional
|
|
525
|
+
Exactly one: a binary treatment column (which may switch on and off),
|
|
526
|
+
or a first-treated-period column (0 = never treated).
|
|
527
|
+
weights : str, optional
|
|
528
|
+
A column of positive sampling weights.
|
|
529
|
+
allow_time_varying_weights : bool, default False
|
|
530
|
+
Accept weights that change over time within a unit.
|
|
531
|
+
incomplete_units : {"error", "drop", "keep"}, default "error"
|
|
532
|
+
Refuse a panel with missing unit-periods, drop the incomplete units,
|
|
533
|
+
or use the observed unit-periods.
|
|
534
|
+
keep_cells : bool, default False
|
|
535
|
+
Keep every treated cell's weight for ``cells()``, ``iter_cells()`` and
|
|
536
|
+
``write_cells()`` (on disk, until ``cleanup()``).
|
|
537
|
+
zero_tolerance : float, default 1e-10
|
|
538
|
+
Weights smaller than this in absolute value count as zero in the
|
|
539
|
+
summary (R's default).
|
|
540
|
+
max_design_columns : int, default 2048
|
|
541
|
+
The limit on the expanded covariate design.
|
|
542
|
+
{RESOURCES}
|
|
543
|
+
|
|
544
|
+
Returns
|
|
545
|
+
-------
|
|
546
|
+
TWFEWeightResult or Profile
|
|
547
|
+
``summary()`` reports the positive and negative weights and the
|
|
548
|
+
sensitivity measures.
|
|
549
|
+
"""
|
|
550
|
+
options = dict(locals())
|
|
551
|
+
fml, data = options.pop("fml"), options.pop("data")
|
|
552
|
+
return public_profile(_twfe_weights(fml, data, **_options.internal(options)))
|