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/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)))
|