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.
Files changed (56) hide show
  1. diff_stream/__init__.py +52 -0
  2. diff_stream/_bootstrap_worker.py +48 -0
  3. diff_stream/_core/__init__.py +5 -0
  4. diff_stream/_core/contrasts.py +199 -0
  5. diff_stream/_core/covariate_kernels.py +420 -0
  6. diff_stream/_core/cs_kernels.py +536 -0
  7. diff_stream/_core/cs_row_kernels.py +495 -0
  8. diff_stream/_core/design.py +105 -0
  9. diff_stream/_core/etwfe_kernels.py +288 -0
  10. diff_stream/_core/functionals.py +30 -0
  11. diff_stream/_core/multiplier.py +90 -0
  12. diff_stream/_core/nuisance.py +255 -0
  13. diff_stream/_core/partition.py +158 -0
  14. diff_stream/_core/process.py +61 -0
  15. diff_stream/_core/resample.py +171 -0
  16. diff_stream/_core/staging_kernels.py +90 -0
  17. diff_stream/_core/twostage_kernels.py +342 -0
  18. diff_stream/_core/weights.py +26 -0
  19. diff_stream/_core/within.py +81 -0
  20. diff_stream/_docs.py +100 -0
  21. diff_stream/_options.py +62 -0
  22. diff_stream/_present.py +202 -0
  23. diff_stream/absorbed.py +621 -0
  24. diff_stream/aggregation.py +225 -0
  25. diff_stream/api.py +1112 -0
  26. diff_stream/bacon.py +460 -0
  27. diff_stream/bacon_controls.py +188 -0
  28. diff_stream/balance.py +149 -0
  29. diff_stream/bjs.py +101 -0
  30. diff_stream/bjs_placebo.py +167 -0
  31. diff_stream/bootstrap.py +704 -0
  32. diff_stream/cohort_scores.py +68 -0
  33. diff_stream/cs.py +197 -0
  34. diff_stream/cs_unbalanced.py +336 -0
  35. diff_stream/effective_sample.py +73 -0
  36. diff_stream/estimators.py +95 -0
  37. diff_stream/etwfe.py +589 -0
  38. diff_stream/gardner.py +500 -0
  39. diff_stream/linear.py +339 -0
  40. diff_stream/periods.py +72 -0
  41. diff_stream/pretrend.py +97 -0
  42. diff_stream/py.typed +0 -0
  43. diff_stream/reference.py +125 -0
  44. diff_stream/results.py +521 -0
  45. diff_stream/simulate.py +80 -0
  46. diff_stream/stacked.py +405 -0
  47. diff_stream/store.py +706 -0
  48. diff_stream/twfe_store.py +181 -0
  49. diff_stream/twfe_weights.py +552 -0
  50. diff_stream/twostage_absorbed.py +190 -0
  51. diff_stream/unbalanced.py +297 -0
  52. diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
  53. diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
  54. diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
  55. diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
  56. 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)))