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
diff_stream/stacked.py ADDED
@@ -0,0 +1,405 @@
1
+ """Stacked difference-in-differences with corrective weights, without the stack.
2
+
3
+ Wing, Freedman and Hollingsworth (2024) stack one sub-experiment per adoption
4
+ cohort a: the cohort's units and its clean controls (never treated, or not
5
+ treated through the end of the event window), over event times lo..hi. The
6
+ weighted stacked regression
7
+
8
+ y = a0 + a1 D + sum_e lambda_e 1[e] + sum_{e != r} delta_e D 1[e] + u,
9
+
10
+ with weight 1 for treated rows and (N^D_a / N^D) / (N^C_a / N^C) for the
11
+ controls of sub-experiment a, is saturated in (D, e), so each delta_h is a
12
+ difference of weighted cell means:
13
+
14
+ delta_h = (m[1, h] - m[1, r]) - (m[0, h] - m[0, r]).
15
+
16
+ Its covariance clustered by unit is the sum over units of the squared
17
+ influence of those cell means, a unit's contributions summed over every
18
+ sub-experiment it enters. Both need two passes over whole units: the stacked
19
+ data, whose rows repeat each control once per sub-experiment it enters, are
20
+ never built.
21
+ """
22
+
23
+ from copy import copy
24
+ from dataclasses import dataclass
25
+ from numbers import Integral
26
+ from pathlib import Path
27
+ from tempfile import TemporaryDirectory
28
+
29
+ import numpy as np
30
+ import polars as pl
31
+ import pyarrow as pa
32
+ from hdfe_stream.core.influence import ClusterScoreAccumulator
33
+ from hdfe_stream.core.profile import Component, Profile
34
+ from stream_limits import MemoryBudget, current_rss_gb, detect_cpus, thread_scope
35
+
36
+ from . import _docs, _options
37
+ from ._core.partition import preprocess_budget_mb, staging_bytes
38
+ from ._present import label_frame, labels_of, present, public_profile
39
+ from .gardner import TwoStageResult
40
+ from .store import PanelStore, inspect
41
+
42
+
43
+ @dataclass(kw_only=True)
44
+ class StackedResult(TwoStageResult):
45
+ """Event-time coefficients of the weighted stacked regression.
46
+
47
+ ``select(horizons)`` reports a subset with its covariance, ``overall()``
48
+ the average of the post-treatment coefficients, ``sub_experiments()`` the
49
+ stack's composition and weights, and ``pretrend_test()`` tests the leads.
50
+ """
51
+
52
+ _sub_experiments: pl.DataFrame = None
53
+ estimator = "stacked"
54
+
55
+ def sub_experiments(self):
56
+ """One row per sub-experiment: its cohort, units and corrective weights."""
57
+ return label_frame(self._sub_experiments, labels_of(self))
58
+
59
+ def overall(self, horizons=None):
60
+ """The equally weighted average of post-treatment event-time effects.
61
+
62
+ ``horizons`` selects the event times averaged (default: every fitted
63
+ event time from 0 on).
64
+ """
65
+ events = [int(n.split("::")[1]) for n in self.coefnames]
66
+ chosen = [e for e in events if e >= 0] if horizons is None else list(horizons)
67
+ if not chosen or not set(chosen) <= set(events):
68
+ raise ValueError("horizons must be fitted event times")
69
+ a = np.zeros(len(events))
70
+ for e in chosen:
71
+ a[events.index(e)] = 1 / len(chosen)
72
+ out = copy(self)
73
+ out.coefnames = ["ATT"]
74
+ out.beta = np.array([a @ self.beta])
75
+ out.vcov = np.array([[a @ self.vcov @ a]])
76
+ out.all_vcovs = {self.vcov_type: (out.vcov, self.df_t)}
77
+ out._support = self._support.filter(
78
+ pl.col("aggregate").is_in([f"event::{e}" for e in chosen])
79
+ )
80
+ out.diagnostics = {**self.diagnostics, "averaged_horizons": sorted(chosen)}
81
+ return out
82
+
83
+
84
+ def _stack(store, window, anticipation):
85
+ """The included sub-experiments, their clean controls and weights."""
86
+ lo, hi = window
87
+ first, last = int(store.times[0]), int(store.times[-1])
88
+ groups = np.array(sorted(store.gw), dtype=np.int64)
89
+ mass = np.array([store.gw[g] for g in groups])
90
+ units = np.array([store.gc[g] for g in groups])
91
+ rows, excluded = [], []
92
+ for a in (int(g) for g in groups if g > 0):
93
+ if a + lo < first or a + hi > last:
94
+ excluded.append(dict(cohort=a, reason="event window outside the sample"))
95
+ continue
96
+ clean = (groups == 0) | (groups - anticipation > a + hi)
97
+ if not clean.any():
98
+ excluded.append(dict(cohort=a, reason="no unit untreated through the window"))
99
+ continue
100
+ rows.append(
101
+ dict(
102
+ cohort=a,
103
+ n_treated_units=int(units[groups == a].sum()),
104
+ n_control_units=int(units[clean].sum()),
105
+ treated_weight_total=float(mass[groups == a].sum()),
106
+ control_weight_total=float(mass[clean].sum()),
107
+ control_cohorts=[int(g) for g in groups[clean]],
108
+ )
109
+ )
110
+ if not rows:
111
+ raise ValueError(
112
+ "no sub-experiment fits: every cohort's event window leaves the sample or has "
113
+ "no clean controls; narrow event_window"
114
+ )
115
+ table = pl.DataFrame(rows)
116
+ nd, nc = table["treated_weight_total"].sum(), table["control_weight_total"].sum()
117
+ table = table.with_columns(
118
+ (pl.col("treated_weight_total") / nd).alias("treated_share"),
119
+ ((pl.col("treated_weight_total") / nd) / (pl.col("control_weight_total") / nc)).alias(
120
+ "control_weight"
121
+ ),
122
+ )
123
+ return table, excluded
124
+
125
+
126
+ def _fit(store, table, window, anticipation):
127
+ lo, hi = window
128
+ ref = -anticipation - 1
129
+ events = list(range(lo, hi + 1))
130
+ L, t0 = len(events), int(store.times[0])
131
+ r = events.index(ref)
132
+ stack = [
133
+ (a, np.array(c, dtype=np.int64), q)
134
+ for a, c, q in zip(
135
+ table["cohort"].to_list(),
136
+ table["control_cohorts"].to_list(),
137
+ table["control_weight"].to_list(),
138
+ strict=True,
139
+ )
140
+ ]
141
+
142
+ def windows(g, y, w):
143
+ """Per sub-experiment: (treated rows, control rows, control weight, Y, W)."""
144
+ for a, controls, q in stack:
145
+ cols = slice(a + lo - t0, a + hi - t0 + 1)
146
+ yield (g == a), np.isin(g, controls), q, y[:, cols], w[:, cols]
147
+
148
+ sums, mass = np.zeros((2, L)), np.zeros((2, L))
149
+ for g, y, w in store.batches(include_weights=True, row_weights=True):
150
+ for treated, controls, q, Y, W in windows(g, y, w):
151
+ observed = np.isfinite(Y)
152
+ for d, rows, scale in ((1, treated, 1.0), (0, controls, q)):
153
+ weight = np.where(observed[rows], W[rows], 0.0) * scale
154
+ sums[d] += (weight * np.nan_to_num(Y[rows])).sum(axis=0)
155
+ mass[d] += weight.sum(axis=0)
156
+ if np.any(mass <= 0):
157
+ missing = [events[j] for j in np.flatnonzero((mass <= 0).any(axis=0))]
158
+ raise ValueError(f"no observations at event times {missing} in the stack")
159
+ means = sums / mass
160
+ keep = [j for j in range(L) if j != r]
161
+ beta = (means[1, keep] - means[1, r]) - (means[0, keep] - means[0, r])
162
+ accumulator = ClusterScoreAccumulator()
163
+ for g, y, w in store.batches(include_weights=True, row_weights=True):
164
+ score = np.zeros((len(g), L - 1))
165
+ for treated, controls, q, Y, W in windows(g, y, w):
166
+ observed = np.isfinite(Y)
167
+ for d, rows, scale, sign in ((1, treated, 1.0, 1.0), (0, controls, q, -1.0)):
168
+ if not rows.any():
169
+ continue
170
+ weight = np.where(observed[rows], W[rows], 0.0) * scale
171
+ influence = weight * (np.nan_to_num(Y[rows]) - means[d]) / mass[d]
172
+ score[rows] += sign * (influence[:, keep] - influence[:, [r]])
173
+ accumulator.add_clusters(score)
174
+ names = [f"event::{events[j]}" for j in keep]
175
+ support = pl.DataFrame(
176
+ [
177
+ dict(
178
+ aggregate=f"event::{e}",
179
+ cohort=a,
180
+ time=a + e,
181
+ event_time=e,
182
+ treated_units=n,
183
+ )
184
+ for e in (events[j] for j in keep)
185
+ for a, n in zip(table["cohort"].to_list(), table["n_treated_units"], strict=True)
186
+ ]
187
+ )
188
+ return beta, accumulator.sums, names, support
189
+
190
+
191
+ def _stacked(
192
+ fml,
193
+ data,
194
+ *,
195
+ panel,
196
+ event_window,
197
+ cohort=None,
198
+ treatment=None,
199
+ anticipation=0,
200
+ weights=None,
201
+ allow_time_varying_weights=False,
202
+ panel_mode="balanced",
203
+ vcov="CRV0",
204
+ threads=None,
205
+ memory_gb=None,
206
+ memory_reserve=None,
207
+ workdir=None,
208
+ profile=False,
209
+ batch_units=4096,
210
+ preprocess_mb=None,
211
+ ):
212
+ """Stacked DiD from internal option names; see did_stacked."""
213
+ if (
214
+ isinstance(anticipation, (bool, np.bool_))
215
+ or not isinstance(anticipation, Integral)
216
+ or anticipation < 0
217
+ ):
218
+ raise ValueError("anticipation must be a nonnegative integer")
219
+ try:
220
+ lo, hi = (int(e) for e in event_window)
221
+ except (TypeError, ValueError) as exc:
222
+ raise ValueError("event_window must be a pair of event times, (first, last)") from exc
223
+ if any(isinstance(e, bool) or not isinstance(e, Integral) for e in event_window):
224
+ raise ValueError("event_window must hold integer event times")
225
+ if not lo <= -anticipation - 1 < 0 <= hi:
226
+ raise ValueError(
227
+ "event_window must include the reference period (-anticipation - 1) and event time 0"
228
+ )
229
+ if vcov not in ("CRV0", "CRV1"):
230
+ raise ValueError("vcov must be 'CRV0' or 'CRV1'")
231
+ if panel_mode not in ("balanced", "complete", "unbalanced"):
232
+ raise ValueError("incomplete_units must be 'error', 'drop' or 'keep'")
233
+ if threads is not None and (
234
+ isinstance(threads, bool) or not isinstance(threads, int) or threads < 1
235
+ ):
236
+ raise ValueError("n_threads must be a positive integer")
237
+ if isinstance(batch_units, bool) or not isinstance(batch_units, int) or batch_units < 1:
238
+ raise ValueError("batch_units must be a positive integer")
239
+ budget = MemoryBudget(memory_gb, memory_reserve)
240
+ preprocess_mb = preprocess_budget_mb(preprocess_mb, budget)
241
+ baseline = current_rss_gb() or 0
242
+ with thread_scope(threads):
243
+ lf, yname, design = inspect(fml, data, panel, cohort, treatment=treatment, weights=weights)
244
+ if workdir is not None:
245
+ Path(workdir).mkdir(parents=True, exist_ok=True)
246
+ with TemporaryDirectory(prefix="diff-stream-stacked-", dir=workdir) as run:
247
+ store = PanelStore(
248
+ lf,
249
+ run,
250
+ batch_units,
251
+ budget,
252
+ preprocess_mb,
253
+ None,
254
+ panel_mode,
255
+ anticipation,
256
+ allow_time_varying_weights=allow_time_varying_weights,
257
+ require_never_treated=False,
258
+ )
259
+ pa.default_memory_pool().release_unused()
260
+ table, excluded = _stack(store, (lo, hi), anticipation)
261
+ T, L, B = store.periods, hi - lo + 1, min(batch_units, store.n_units)
262
+ numeric = 8 * (6 * L * L + 10 * B * T + 8 * B * L * len(table))
263
+ diagnostics = dict(
264
+ specification="dynamic",
265
+ aggregation="event_time",
266
+ anticipation=anticipation,
267
+ reference_event_time=-anticipation - 1,
268
+ event_window=[lo, hi],
269
+ sub_experiments=table["cohort"].to_list(),
270
+ excluded_cohorts=excluded,
271
+ time_min=int(store.times[0]),
272
+ time_max=int(store.times[-1]),
273
+ panel_mode=panel_mode,
274
+ balanced_sample=store.is_balanced,
275
+ n_units_input=store.n_units_input,
276
+ dropped_units=store.n_units_input - store.n_units,
277
+ sampling_weights=weights,
278
+ weight_convention="corrective weights (Wing, Freedman and Hollingsworth 2024)"
279
+ + (" times sampling weights" if weights is not None else ""),
280
+ partitions=store.partition_count,
281
+ preprocess_mb=preprocess_mb,
282
+ inference="unit clustered; units in several sub-experiments are one cluster",
283
+ )
284
+ if profile:
285
+ return Profile(
286
+ model="diff-stream stacked",
287
+ threads=threads or detect_cpus(),
288
+ memory_limit_gb=budget.limit_gb,
289
+ memory_reserve=budget.reserve,
290
+ polars_threads=pl.thread_pool_size(),
291
+ baseline_gb=baseline,
292
+ components=[
293
+ Component(
294
+ "numerical buffers and runtime", numeric + 192e6, numeric * 1.5 + 256e6
295
+ ),
296
+ Component(
297
+ "bounded partition preprocessing",
298
+ staging_bytes(preprocess_mb, store) + 96e6,
299
+ preprocess_mb * 1e6 + 256e6,
300
+ ),
301
+ Component(
302
+ "sorted panel",
303
+ 64 * store.n_units * T,
304
+ 128 * store.n_units * T,
305
+ where="disk",
306
+ ),
307
+ ],
308
+ notes={**diagnostics, "n_units": store.n_units, "periods": T},
309
+ warnings=["Heuristic; the stacked rows are never built."],
310
+ )
311
+ if not budget.fits(numeric / 1e9 + 0.128):
312
+ raise MemoryError(
313
+ "estimated numerical buffers exceed job headroom; reduce batch_units or request more memory"
314
+ )
315
+ beta, V, names, support = _fit(store, table, (lo, hi), anticipation)
316
+ V *= store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1
317
+ V = (V + V.T) / 2
318
+ result = StackedResult(
319
+ fml=fml,
320
+ depvar=yname,
321
+ coefnames=names,
322
+ beta=beta,
323
+ vcov=V,
324
+ vcov_type=vcov,
325
+ df_t=store.n_units - 1,
326
+ n_obs=store.n_rows_used,
327
+ n_units=store.n_units,
328
+ n_clusters={panel[0]: store.n_units},
329
+ all_vcovs={vcov: (V, store.n_units - 1)},
330
+ weights=weights,
331
+ _support=support,
332
+ _sub_experiments=table.drop("control_cohorts"),
333
+ diagnostics={**diagnostics, "warnings": []},
334
+ )
335
+ return present(result, store.period_labels)
336
+
337
+
338
+ @_docs.document
339
+ def did_stacked(
340
+ fml,
341
+ data,
342
+ *,
343
+ panel,
344
+ event_window,
345
+ cohort=None,
346
+ treatment=None,
347
+ anticipation=0,
348
+ weights=None,
349
+ allow_time_varying_weights=False,
350
+ incomplete_units="error",
351
+ vcov="CRV0",
352
+ n_threads=None,
353
+ max_memory_gb=None,
354
+ memory_reserve=None,
355
+ workdir=None,
356
+ profile=False,
357
+ batch_units=4096,
358
+ preprocess_mb=None,
359
+ ):
360
+ """Stacked difference-in-differences with corrective weights.
361
+
362
+ One sub-experiment per adoption cohort: the cohort's units and the units
363
+ not yet treated by the end of its event window (never-treated units and
364
+ later cohorts), over that window. The sub-experiments are stacked and an
365
+ event study is fitted on the stack, with the corrective weights of Wing,
366
+ Freedman and Hollingsworth (2024), so that each event-time effect is the
367
+ average of the sub-experiments' effects weighted by their treated units
368
+ (their "trimmed aggregate ATT"). Only cohorts whose whole window lies in
369
+ the sample are included. The stacked data, which repeat a control unit
370
+ once for each sub-experiment it enters, are never built.
371
+
372
+ Cengiz, D., Dube, A., Lindner, A. and Zipperer, B. (2019). The effect of
373
+ minimum wages on low-wage jobs. *Quarterly Journal of Economics* 134(3),
374
+ 1405-1454.
375
+
376
+ Wing, C., Freedman, S. M. and Hollingsworth, A. (2024). Stacked
377
+ difference-in-differences. NBER Working Paper 32054.
378
+
379
+ Parameters
380
+ ----------
381
+ fml : str
382
+ ``"y ~ 1"``. Covariates are not supported.
383
+ {DATA}
384
+ event_window : tuple of int
385
+ The first and last event times of each sub-experiment, e.g. ``(-4, 4)``;
386
+ it must include the reference period (``-anticipation - 1``) and 0.
387
+ {ANTICIPATION}
388
+ {WEIGHTS}
389
+ {INCOMPLETE_UNITS}
390
+ {VCOV}
391
+ A unit in several sub-experiments is one cluster.
392
+ {RESOURCES}
393
+
394
+ Returns
395
+ -------
396
+ StackedResult or Profile
397
+ Event-time coefficients ``event::h`` (all but the reference period),
398
+ with ``select()``, ``overall()``, ``pretrend_test()`` and
399
+ ``sub_experiments()``.
400
+ """
401
+ options = dict(locals())
402
+ fml, data = options.pop("fml"), options.pop("data")
403
+ if fml.replace(" ", "").split("~")[1] != "1":
404
+ raise ValueError("did_stacked takes no covariates: use a formula 'y ~ 1'")
405
+ return public_profile(_stacked(fml, data, **_options.internal(options)))