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,190 @@
|
|
|
1
|
+
"""Gardner's two-stage estimator with the fixed effects absorbed iteratively.
|
|
2
|
+
|
|
3
|
+
The first stage is BJS's: the untreated rows' model (unit, time and additional
|
|
4
|
+
fixed effects, covariates) fitted by hdfe-stream, whose predictions give every
|
|
5
|
+
row its first-stage residual. The second stage regresses those residuals on the
|
|
6
|
+
treatment indicators, one per event time or a single one. The indicators do not
|
|
7
|
+
overlap and there is no intercept, so a coefficient is the weighted mean of its
|
|
8
|
+
rows' residuals.
|
|
9
|
+
|
|
10
|
+
The covariance is Gardner's: each unit's second-stage score, net of the effect of
|
|
11
|
+
the estimated first stage on it. A row's weight in the first stage's effect on
|
|
12
|
+
coefficient m is -w_it z_it'u_m, with u_m = S^- Z'v_m the solution of the
|
|
13
|
+
untreated model's normal equations for the rows of m
|
|
14
|
+
(``HDFEResult.solve_normal_equations``), the computation that BJS uses per
|
|
15
|
+
cohort-period cell. Here there is one column per coefficient, so the solve is
|
|
16
|
+
small however many fixed-effect levels there are, and a single pass over the
|
|
17
|
+
residuals gives each unit's score.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import warnings
|
|
21
|
+
from tempfile import TemporaryDirectory
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import polars as pl
|
|
25
|
+
from hdfe_stream.core.influence import ClusterScoreAccumulator
|
|
26
|
+
|
|
27
|
+
from ._core.partition import whole_group_batches
|
|
28
|
+
from .absorbed import (
|
|
29
|
+
_DROPPED,
|
|
30
|
+
absorbed_terms,
|
|
31
|
+
first_columns,
|
|
32
|
+
hdfe_options,
|
|
33
|
+
info,
|
|
34
|
+
solve_block,
|
|
35
|
+
staged,
|
|
36
|
+
untreated,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _indicator(specification, horizons, anticipation):
|
|
41
|
+
"""The row's second-stage coefficient (an index), or null for none."""
|
|
42
|
+
event = pl.col("t") - pl.col("g")
|
|
43
|
+
if specification == "static":
|
|
44
|
+
return (
|
|
45
|
+
pl.when((pl.col("g") != 0) & (event >= -anticipation))
|
|
46
|
+
.then(pl.lit(0, dtype=pl.Int64))
|
|
47
|
+
.otherwise(None)
|
|
48
|
+
)
|
|
49
|
+
return (
|
|
50
|
+
pl.when(pl.col("g") != 0)
|
|
51
|
+
.then(
|
|
52
|
+
event.replace_strict(
|
|
53
|
+
horizons, list(range(len(horizons))), default=None, return_dtype=pl.Int64
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
.otherwise(None)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def fit_two_stage_absorbed(store, terms, specification, horizons, anticipation, *, threads=None):
|
|
61
|
+
"""(beta, covariance sums, diagnostics) of the two-stage fit, CRV0 by unit.
|
|
62
|
+
|
|
63
|
+
``horizons`` are the event times of a dynamic fit (empty for a static one).
|
|
64
|
+
"""
|
|
65
|
+
from hdfe_stream import feols_stream
|
|
66
|
+
|
|
67
|
+
frame, covariates, xs, absorbed = staged(store, terms)
|
|
68
|
+
H = len(horizons) if specification == "dynamic" else 1
|
|
69
|
+
fml = f"y ~ {' + '.join(xs) or '1'} | " + " + ".join(["u", "t", *absorbed])
|
|
70
|
+
options = hdfe_options(store, threads, len(xs), vcov="iid", keep_intermediates=True)
|
|
71
|
+
with warnings.catch_warnings():
|
|
72
|
+
warnings.filterwarnings("ignore", _DROPPED)
|
|
73
|
+
first = feols_stream(
|
|
74
|
+
fml, frame.filter(untreated(anticipation)), workdir=store.root, **options
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
with TemporaryDirectory(prefix="diff-stream-gardner-", dir=store.root) as workspace:
|
|
78
|
+
beta, V = _second_stage(
|
|
79
|
+
store, first, terms, frame, workspace, specification, horizons, anticipation, H
|
|
80
|
+
)
|
|
81
|
+
diagnostics = info(covariates, xs, list(first.coefnames), terms)
|
|
82
|
+
finally:
|
|
83
|
+
first.cleanup()
|
|
84
|
+
diagnostics["first_stage_columns"] = diagnostics["regression_columns"]
|
|
85
|
+
return beta, V, diagnostics
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _second_stage(store, first, terms, frame, workspace, specification, horizons, anticipation, H):
|
|
89
|
+
weight = pl.col("w") if store.weighted else pl.lit(1.0)
|
|
90
|
+
_, _, xs, absorbed = staged(store, terms)
|
|
91
|
+
|
|
92
|
+
# Every row's first-stage residual: the outcome less the imputed untreated one.
|
|
93
|
+
residuals = f"{workspace}/residuals.parquet"
|
|
94
|
+
(
|
|
95
|
+
first.predict(frame, name="prediction")
|
|
96
|
+
.with_columns(pl.col("g").cast(pl.Int64), pl.col("t").cast(pl.Int64))
|
|
97
|
+
.with_columns(
|
|
98
|
+
(pl.col("y") - pl.col("prediction")).alias("r"),
|
|
99
|
+
untreated(anticipation).alias("untreated"),
|
|
100
|
+
_indicator(specification, horizons, anticipation).alias("m"),
|
|
101
|
+
)
|
|
102
|
+
.drop("prediction")
|
|
103
|
+
.sink_parquet(residuals, row_group_size=store.read_rows, engine="streaming")
|
|
104
|
+
)
|
|
105
|
+
rows = pl.scan_parquet(residuals).filter(pl.col("m").is_not_null())
|
|
106
|
+
unidentified = rows.select(pl.col("r").null_count()).collect().item()
|
|
107
|
+
if unidentified:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Gardner's second stage is not identified for {unidentified:,} observations: "
|
|
110
|
+
"a fixed-effect level of theirs has no untreated observation"
|
|
111
|
+
)
|
|
112
|
+
sums = (
|
|
113
|
+
rows.group_by("m")
|
|
114
|
+
.agg(
|
|
115
|
+
(weight * pl.col("r")).sum().alias("effect"),
|
|
116
|
+
(pl.col("w").sum() if store.weighted else pl.len().cast(pl.Float64)).alias("mass"),
|
|
117
|
+
)
|
|
118
|
+
.collect()
|
|
119
|
+
.sort("m")
|
|
120
|
+
)
|
|
121
|
+
if sums.height != H or not (sums["mass"].to_numpy() > 0).all():
|
|
122
|
+
raise ValueError("Gardner second-stage design is unidentified")
|
|
123
|
+
mass = sums["mass"].to_numpy()
|
|
124
|
+
beta = sums["effect"].to_numpy() / mass
|
|
125
|
+
|
|
126
|
+
# The untreated model's normal equations for each coefficient's rows.
|
|
127
|
+
names = [f"v{j}" for j in range(H)]
|
|
128
|
+
target = rows.select(
|
|
129
|
+
"u",
|
|
130
|
+
"t",
|
|
131
|
+
*[c for c in first_columns(first) if c not in ("u", "t")],
|
|
132
|
+
*[
|
|
133
|
+
pl.when(pl.col("m") == j).then(weight / mass[j]).otherwise(0.0).alias(n)
|
|
134
|
+
for j, n in enumerate(names)
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
solution = first.solve_normal_equations(target, names, block=solve_block(store, H))
|
|
138
|
+
try:
|
|
139
|
+
V = _covariance(store, solution, residuals, names, xs, absorbed, beta, mass)
|
|
140
|
+
finally:
|
|
141
|
+
solution.cleanup()
|
|
142
|
+
return beta, V
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _covariance(store, solution, residuals, names, xs, absorbed, beta, mass):
|
|
146
|
+
"""Sum over units of the outer products of their corrected second-stage scores."""
|
|
147
|
+
H = len(names)
|
|
148
|
+
tables = []
|
|
149
|
+
for term in absorbed_terms(absorbed):
|
|
150
|
+
table = solution.fixef(term).collect()
|
|
151
|
+
keys = [c for c in table.columns if c not in names]
|
|
152
|
+
tables.append((keys, table.with_row_index("__level"), table.select(names).to_numpy()))
|
|
153
|
+
kept = solution.coef["coefficient"].to_list()
|
|
154
|
+
coef = solution.coef.select(names).to_numpy()
|
|
155
|
+
accumulator = ClusterScoreAccumulator()
|
|
156
|
+
store._guard(32e6 + 8 * H * 4 * store.batch_units * store.periods)
|
|
157
|
+
for d in whole_group_batches(residuals, "u", store.batch_units, store.read_rows):
|
|
158
|
+
w = d["w"].to_numpy() if store.weighted else np.ones(d.height)
|
|
159
|
+
r = d["r"].to_numpy()
|
|
160
|
+
rowscore = np.zeros((d.height, H))
|
|
161
|
+
# the first stage's part: a clean row's weight in each coefficient is
|
|
162
|
+
# -w z'u, so its share of the score is that times its residual
|
|
163
|
+
clean = np.flatnonzero(d["untreated"].to_numpy())
|
|
164
|
+
if clean.size:
|
|
165
|
+
c = d[clean]
|
|
166
|
+
q = np.zeros((clean.size, H))
|
|
167
|
+
for keys, table, values in tables:
|
|
168
|
+
level = (
|
|
169
|
+
c.select(keys)
|
|
170
|
+
.with_columns(pl.col(k).cast(table.schema[k]) for k in keys)
|
|
171
|
+
.join(
|
|
172
|
+
table.select(*keys, "__level"),
|
|
173
|
+
on=keys,
|
|
174
|
+
how="left",
|
|
175
|
+
maintain_order="left",
|
|
176
|
+
)["__level"]
|
|
177
|
+
.to_numpy()
|
|
178
|
+
)
|
|
179
|
+
q += values[level]
|
|
180
|
+
if kept:
|
|
181
|
+
q += c.select(kept).to_numpy() @ coef
|
|
182
|
+
rowscore[clean] = -(w[clean] * r[clean])[:, None] * q
|
|
183
|
+
# the second stage's part: the rows of coefficient m, net of the mean
|
|
184
|
+
m = d["m"].fill_null(-1).to_numpy()
|
|
185
|
+
hit = np.flatnonzero(m >= 0)
|
|
186
|
+
rowscore[hit, m[hit]] += w[hit] * (r[hit] - beta[m[hit]]) / mass[m[hit]]
|
|
187
|
+
u = d["u"].to_numpy()
|
|
188
|
+
starts = np.r_[0, np.flatnonzero(u[1:] != u[:-1]) + 1]
|
|
189
|
+
accumulator.add_clusters(np.add.reduceat(rowscore, starts, axis=0))
|
|
190
|
+
return accumulator.sums
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Observed-row Sun–Abraham and BJS systems for unbalanced panels."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from tempfile import TemporaryDirectory
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ._core import covariate_kernels
|
|
9
|
+
from ._core.nuisance import solve_checked
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fit_sunab(store, cells, anticipation=0):
|
|
13
|
+
"""Exact saturated SA regression via independent cohort calendar effects.
|
|
14
|
+
|
|
15
|
+
Unit intercepts absorb each cohort's calendar-effect normalization. Since
|
|
16
|
+
every other observed cohort/date has an interaction, the regression splits
|
|
17
|
+
into one unit/time FE fit per cohort. Subtract the never-treated calendar
|
|
18
|
+
change from each treated cohort's change relative to g-1. This identity
|
|
19
|
+
holds with arbitrary observed-row patterns; no balanced change shortcut or
|
|
20
|
+
all-row interaction design is used.
|
|
21
|
+
"""
|
|
22
|
+
T, M = store.periods, len(cells)
|
|
23
|
+
systems = {}
|
|
24
|
+
for g, counts in store.cell_counts.items():
|
|
25
|
+
reference = g - anticipation - 1 - store.times[0] if g else 0
|
|
26
|
+
if counts[reference] == 0 or (g == 0 and np.any(counts == 0)):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
"Sun–Abraham interactions are unidentified: missing cohort reference or never-treated date"
|
|
29
|
+
)
|
|
30
|
+
free = np.flatnonzero((counts > 0) & (np.arange(T) != reference))
|
|
31
|
+
Z = np.eye(T)[:, free]
|
|
32
|
+
systems[g] = [Z, np.zeros((len(free), len(free))), np.zeros(len(free))]
|
|
33
|
+
for g, y, w in store.batches(include_weights=True):
|
|
34
|
+
for cg in np.unique(g):
|
|
35
|
+
Z, Q, q = systems[cg]
|
|
36
|
+
values = y[g == cg]
|
|
37
|
+
sw = w[g == cg]
|
|
38
|
+
obs = np.isfinite(values)
|
|
39
|
+
n = obs.sum(axis=1)
|
|
40
|
+
zbar = obs @ Z / n[:, None]
|
|
41
|
+
raw = np.where(obs, values, 0)
|
|
42
|
+
centered = np.where(obs, raw - raw.sum(axis=1)[:, None] / n[:, None], 0)
|
|
43
|
+
Q += Z.T @ ((sw @ obs)[:, None] * Z) - zbar.T @ ((sw * n)[:, None] * zbar)
|
|
44
|
+
q += Z.T @ (sw @ centered)
|
|
45
|
+
coefficients, derivatives = {}, {}
|
|
46
|
+
for g, (Z, Q, q) in systems.items():
|
|
47
|
+
context = f"Sun–Abraham interactions are unidentified in cohort {g} (disconnected observed design)"
|
|
48
|
+
b = solve_checked(Q, q, context) if len(q) else np.zeros(0)
|
|
49
|
+
coefficients[g] = Z @ b
|
|
50
|
+
ix = [j for j, (cg, t) in enumerate(cells) if cg == g] if g else list(range(M))
|
|
51
|
+
D = np.zeros((len(ix), Z.shape[1]))
|
|
52
|
+
for k, j in enumerate(ix):
|
|
53
|
+
cg, t = cells[j]
|
|
54
|
+
ti, bi = t - store.times[0], cg - anticipation - 1 - store.times[0]
|
|
55
|
+
D[k] = (Z[ti] - Z[bi]) * (1 if g else -1)
|
|
56
|
+
H = solve_checked(Q, D.T, context) if len(q) else np.zeros((0, len(ix)))
|
|
57
|
+
derivatives[g] = (ix, H)
|
|
58
|
+
beta = np.array(
|
|
59
|
+
[
|
|
60
|
+
coefficients[g][t - store.times[0]]
|
|
61
|
+
- coefficients[g][g - anticipation - 1 - store.times[0]]
|
|
62
|
+
- coefficients[0][t - store.times[0]]
|
|
63
|
+
+ coefficients[0][g - anticipation - 1 - store.times[0]]
|
|
64
|
+
for g, t in cells
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells], float)
|
|
68
|
+
V = np.zeros((M, M))
|
|
69
|
+
for g, y, w in store.batches(include_weights=True):
|
|
70
|
+
scores = np.zeros((len(g), M))
|
|
71
|
+
for cg in np.unique(g):
|
|
72
|
+
mask = g == cg
|
|
73
|
+
obs = np.isfinite(y[mask])
|
|
74
|
+
raw = np.where(obs, y[mask] - coefficients[cg], 0)
|
|
75
|
+
residual = np.where(obs, raw - raw.sum(axis=1)[:, None] / obs.sum(axis=1)[:, None], 0)
|
|
76
|
+
ix, H = derivatives[cg]
|
|
77
|
+
scores[np.ix_(np.flatnonzero(mask), ix)] = w[mask, None] * (
|
|
78
|
+
residual @ systems[cg][0] @ H
|
|
79
|
+
)
|
|
80
|
+
V += scores.T @ scores
|
|
81
|
+
return beta, counts, V
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def residuals(g, y, times, Z, theta, anticipation=0):
|
|
85
|
+
observed = np.isfinite(y)
|
|
86
|
+
untreated = observed & ((g[:, None] == 0) | (times[None, :] < g[:, None] - anticipation))
|
|
87
|
+
n0 = untreated.sum(axis=1)
|
|
88
|
+
if np.any(n0 == 0):
|
|
89
|
+
raise ValueError(
|
|
90
|
+
"BJS requires at least one untreated observation per unit; exclude units without one explicitly"
|
|
91
|
+
)
|
|
92
|
+
zbar = untreated @ Z / n0[:, None]
|
|
93
|
+
raw = np.where(observed, y - Z @ theta, 0)
|
|
94
|
+
alpha = (raw * untreated).sum(axis=1) / n0
|
|
95
|
+
r = np.where(observed, raw - alpha[:, None], 0)
|
|
96
|
+
return observed, untreated, zbar, r
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def fit_bjs(store, cells, avgeffectsby, leaveout, workdir, anticipation=0):
|
|
100
|
+
T, M = store.periods, len(cells)
|
|
101
|
+
Z = np.eye(T)[:, 1:]
|
|
102
|
+
Q, q = np.zeros((T - 1, T - 1)), np.zeros(T - 1)
|
|
103
|
+
for g, y, w in store.batches(include_weights=True):
|
|
104
|
+
_, use, zm, centered = residuals(g, y, store.times, Z, np.zeros(T - 1), anticipation)
|
|
105
|
+
n0 = use.sum(axis=1)
|
|
106
|
+
Q += Z.T @ ((w @ use)[:, None] * Z) - zm.T @ ((w * n0)[:, None] * zm)
|
|
107
|
+
q += Z.T @ (w @ (centered * use))
|
|
108
|
+
theta = solve_checked(Q, q, "BJS untreated time effects (disconnected observed design)")
|
|
109
|
+
counts = np.array([store.cell_weights[g][t - store.times[0]] for g, t in cells], float)
|
|
110
|
+
beta, D = np.zeros(M), np.zeros((M, T - 1))
|
|
111
|
+
for g, y, w in store.batches(include_weights=True):
|
|
112
|
+
observed, _, zm, r = residuals(g, y, store.times, Z, theta, anticipation)
|
|
113
|
+
for j, (cg, t) in enumerate(cells):
|
|
114
|
+
ti = t - store.times[0]
|
|
115
|
+
mask = (g == cg) & observed[:, ti]
|
|
116
|
+
beta[j] += w[mask] @ r[mask, ti] / counts[j]
|
|
117
|
+
D[j] += w[mask] @ (Z[ti] - zm[mask]) / counts[j]
|
|
118
|
+
H = solve_checked(Q, D.T, "BJS untreated time effects").T
|
|
119
|
+
context = ObservedBJSInference(
|
|
120
|
+
store, cells, counts, Z, theta, H, avgeffectsby, leaveout, workdir, anticipation
|
|
121
|
+
)
|
|
122
|
+
try:
|
|
123
|
+
V = context.covariance(np.eye(M), allow_unidentified=avgeffectsby != "cohort_time")
|
|
124
|
+
except BaseException:
|
|
125
|
+
context.cleanup()
|
|
126
|
+
raise
|
|
127
|
+
return beta, counts, V, context
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class ObservedBJSInference:
|
|
131
|
+
"""Disk-backed, bounded cluster inference including missingness patterns.
|
|
132
|
+
|
|
133
|
+
The result owns these sorted observed-row partitions. Each aggregation uses
|
|
134
|
+
two streaming passes; no unit-by-effect score matrix is saved or retained.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(
|
|
138
|
+
self,
|
|
139
|
+
store,
|
|
140
|
+
cells,
|
|
141
|
+
counts,
|
|
142
|
+
Z,
|
|
143
|
+
theta,
|
|
144
|
+
H,
|
|
145
|
+
avgeffectsby,
|
|
146
|
+
leaveout,
|
|
147
|
+
workdir,
|
|
148
|
+
anticipation=0,
|
|
149
|
+
*,
|
|
150
|
+
model=None,
|
|
151
|
+
):
|
|
152
|
+
self.workspace = TemporaryDirectory(prefix="diff-stream-bjs-", dir=workdir)
|
|
153
|
+
self.store, self.cells, self.counts = store, cells, counts
|
|
154
|
+
self.Z, self.theta, self.H = Z, theta, H
|
|
155
|
+
self.model = model
|
|
156
|
+
self.avgeffectsby, self.leaveout = avgeffectsby, leaveout
|
|
157
|
+
self.anticipation = anticipation
|
|
158
|
+
self.closed = False
|
|
159
|
+
paths = []
|
|
160
|
+
for path in store.paths:
|
|
161
|
+
paths.append(shutil.move(str(path), self.workspace.name + "/" + path.name))
|
|
162
|
+
store.paths = paths
|
|
163
|
+
|
|
164
|
+
def cleanup(self):
|
|
165
|
+
self.workspace.cleanup()
|
|
166
|
+
self.closed = True
|
|
167
|
+
|
|
168
|
+
def covariance(self, contrasts, *, allow_unidentified=False):
|
|
169
|
+
if self.closed:
|
|
170
|
+
raise ValueError("BJS aggregation data were removed by cleanup(); refit to aggregate")
|
|
171
|
+
A = np.atleast_2d(contrasts)
|
|
172
|
+
P, M = A.shape
|
|
173
|
+
self.store._guard(
|
|
174
|
+
8
|
|
175
|
+
* (
|
|
176
|
+
8 * P * P
|
|
177
|
+
+ 8 * min(self.store.batch_units, self.store.n_units) * (P + self.store.periods)
|
|
178
|
+
)
|
|
179
|
+
+ 32e6
|
|
180
|
+
+ (
|
|
181
|
+
8
|
|
182
|
+
* (
|
|
183
|
+
8
|
|
184
|
+
* min(self.store.batch_units, self.store.n_units)
|
|
185
|
+
* self.store.periods
|
|
186
|
+
* (len(self.model.keep) + 1)
|
|
187
|
+
+ 8 * P * len(self.model.keep)
|
|
188
|
+
)
|
|
189
|
+
if self.model is not None
|
|
190
|
+
else 0
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
labels, groups = {}, {}
|
|
194
|
+
for j, (g, t) in enumerate(self.cells):
|
|
195
|
+
label = {"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
|
|
196
|
+
self.avgeffectsby
|
|
197
|
+
]
|
|
198
|
+
r = labels.setdefault(label, len(labels))
|
|
199
|
+
groups.setdefault((g, r), []).append(j)
|
|
200
|
+
R = len(labels)
|
|
201
|
+
denom, numer = np.zeros((P, R)), np.zeros((P, R))
|
|
202
|
+
|
|
203
|
+
def batches():
|
|
204
|
+
if self.model is not None:
|
|
205
|
+
yield from self.model.batches()
|
|
206
|
+
else:
|
|
207
|
+
for g, y, sw in self.store.batches(include_weights=True):
|
|
208
|
+
yield g, y, None, sw
|
|
209
|
+
|
|
210
|
+
def parts(g, y, X, sw):
|
|
211
|
+
if self.model is not None:
|
|
212
|
+
observed, untreated, _, resid, first_score = self.model.residuals(
|
|
213
|
+
g, y, X, sw, self.theta, self.anticipation
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
observed, untreated, _, resid = residuals(
|
|
217
|
+
g, y, self.store.times, self.Z, self.theta, self.anticipation
|
|
218
|
+
)
|
|
219
|
+
first_score = sw[:, None] * ((resid * untreated) @ self.Z)
|
|
220
|
+
sw = np.broadcast_to(sw[:, None], y.shape)
|
|
221
|
+
yield first_score
|
|
222
|
+
for (cg, r), js in groups.items():
|
|
223
|
+
mask = g == cg
|
|
224
|
+
if not mask.any():
|
|
225
|
+
continue
|
|
226
|
+
ti = np.array([self.cells[j][1] - self.store.times[0] for j in js])
|
|
227
|
+
w = A[:, js] / self.counts[js]
|
|
228
|
+
totals = (sw[mask][:, ti] * observed[mask][:, ti]) @ w.T
|
|
229
|
+
effects = (sw[mask][:, ti] * resid[mask][:, ti]) @ w.T
|
|
230
|
+
yield mask, r, totals, effects
|
|
231
|
+
|
|
232
|
+
compiled = self.model is not None and covariate_kernels.ENABLED
|
|
233
|
+
if compiled:
|
|
234
|
+
kernels = covariate_kernels.BJSKernels(
|
|
235
|
+
self.store, self.cells, self.model.keep, self.theta, self.anticipation
|
|
236
|
+
)
|
|
237
|
+
cell_labels = [
|
|
238
|
+
labels[
|
|
239
|
+
{"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
|
|
240
|
+
self.avgeffectsby
|
|
241
|
+
]
|
|
242
|
+
]
|
|
243
|
+
for g, t in self.cells
|
|
244
|
+
]
|
|
245
|
+
grouping = kernels.grouping(cell_labels, self.counts, A)
|
|
246
|
+
denom, numer = kernels.totals(self.model.raw_batches, grouping, R)
|
|
247
|
+
else:
|
|
248
|
+
for g, y, X, sw in batches():
|
|
249
|
+
iterator = parts(g, y, X, sw)
|
|
250
|
+
next(iterator)
|
|
251
|
+
for _mask, r, totals, effects in iterator:
|
|
252
|
+
denom[:, r] += (totals**2).sum(axis=0)
|
|
253
|
+
numer[:, r] += (totals * effects).sum(axis=0)
|
|
254
|
+
averages = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0)
|
|
255
|
+
V, unidentified = np.zeros((P, P)), np.zeros(P, bool)
|
|
256
|
+
derivative = A @ self.H
|
|
257
|
+
if compiled:
|
|
258
|
+
for raw in self.model.raw_batches():
|
|
259
|
+
scores, invalid = kernels.scores(
|
|
260
|
+
raw, grouping, derivative, averages, denom, self.leaveout
|
|
261
|
+
)
|
|
262
|
+
if self.leaveout:
|
|
263
|
+
unidentified |= invalid
|
|
264
|
+
if invalid.any() and not allow_unidentified:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
|
|
267
|
+
)
|
|
268
|
+
V += scores.T @ scores
|
|
269
|
+
V[unidentified, :] = np.nan
|
|
270
|
+
V[:, unidentified] = np.nan
|
|
271
|
+
return (V + V.T) / 2
|
|
272
|
+
for g, y, X, sw in batches():
|
|
273
|
+
iterator = parts(g, y, X, sw)
|
|
274
|
+
scores = -next(iterator) @ derivative.T
|
|
275
|
+
for mask, r, totals, effects in iterator:
|
|
276
|
+
contribution = effects - totals * averages[:, r]
|
|
277
|
+
if self.leaveout:
|
|
278
|
+
lev = np.divide(
|
|
279
|
+
totals**2, denom[:, r], out=np.zeros_like(totals), where=denom[:, r] > 0
|
|
280
|
+
)
|
|
281
|
+
invalid = (np.abs(totals) > 0) & (lev >= 1 - 1e-12)
|
|
282
|
+
unidentified |= invalid.any(axis=0)
|
|
283
|
+
if invalid.any() and not allow_unidentified:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
"BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
|
|
286
|
+
)
|
|
287
|
+
contribution = np.divide(
|
|
288
|
+
contribution,
|
|
289
|
+
1 - lev,
|
|
290
|
+
out=np.zeros_like(contribution),
|
|
291
|
+
where=lev < 1 - 1e-12,
|
|
292
|
+
)
|
|
293
|
+
scores[mask] += contribution
|
|
294
|
+
V += scores.T @ scores
|
|
295
|
+
V[unidentified, :] = np.nan
|
|
296
|
+
V[:, unidentified] = np.nan
|
|
297
|
+
return (V + V.T) / 2
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: diff-stream
|
|
3
|
+
Version: 0.1.0.dev0
|
|
4
|
+
Summary: Staggered difference-in-differences on data larger than memory: Callaway-Sant'Anna, Sun-Abraham, Borusyak-Jaravel-Spiess imputation, two-stage DiD and extended TWFE, with TWFE diagnostics
|
|
5
|
+
Author: Lee Tucker
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/leetucker/diff-stream
|
|
8
|
+
Project-URL: Source, https://github.com/leetucker/diff-stream
|
|
9
|
+
Project-URL: Issues, https://github.com/leetucker/diff-stream/issues
|
|
10
|
+
Project-URL: Documentation, https://leetucker.github.io/diff-stream/
|
|
11
|
+
Project-URL: Changelog, https://github.com/leetucker/diff-stream/blob/main/CHANGELOG.md
|
|
12
|
+
Keywords: econometrics,difference-in-differences,staggered adoption,event study,Callaway-Sant'Anna,Sun-Abraham,imputation,two-way fixed effects,Goodman-Bacon,treatment effects,out-of-core
|
|
13
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Classifier: Typing :: Typed
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE.md
|
|
27
|
+
Requires-Dist: hdfe-stream[formula]>=0.10.0
|
|
28
|
+
Requires-Dist: stream-limits>=0.3.0
|
|
29
|
+
Requires-Dist: polars!=1.41.*,>=1.37
|
|
30
|
+
Requires-Dist: numpy>=2.0
|
|
31
|
+
Requires-Dist: scipy>=1.13
|
|
32
|
+
Requires-Dist: numba>=0.60
|
|
33
|
+
Requires-Dist: pyarrow>=16.0
|
|
34
|
+
Provides-Extra: benchmark
|
|
35
|
+
Requires-Dist: stream-limits[launcher]>=0.3.0; extra == "benchmark"
|
|
36
|
+
Requires-Dist: matplotlib>=3.7; extra == "benchmark"
|
|
37
|
+
Requires-Dist: psutil>=5.9; extra == "benchmark"
|
|
38
|
+
Requires-Dist: pandas>=2.0; extra == "benchmark"
|
|
39
|
+
Provides-Extra: docs
|
|
40
|
+
Requires-Dist: sphinx>=8.0; extra == "docs"
|
|
41
|
+
Requires-Dist: myst-parser>=4.0; extra == "docs"
|
|
42
|
+
Requires-Dist: furo>=2024.8.6; extra == "docs"
|
|
43
|
+
Requires-Dist: sphinx-copybutton>=0.5; extra == "docs"
|
|
44
|
+
Requires-Dist: sphinx-design>=0.6; extra == "docs"
|
|
45
|
+
Provides-Extra: test
|
|
46
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
47
|
+
Requires-Dist: stream-limits[testing]>=0.3.0; extra == "test"
|
|
48
|
+
Dynamic: license-file
|
|
49
|
+
|
|
50
|
+
# diff-stream
|
|
51
|
+
|
|
52
|
+
Difference-in-differences with staggered treatment timing, on panels larger than
|
|
53
|
+
memory. Includes Callaway and Sant'Anna, Sun and Abraham, Borusyak, Jaravel and
|
|
54
|
+
Spiess imputation, Gardner's two-stage DiD, Wooldridge's extended two-way fixed
|
|
55
|
+
effects, and stacked DiD. Two diagnostics show what a conventional two-way fixed
|
|
56
|
+
effects estimate averages. Results are tested against the authors' R and Stata
|
|
57
|
+
packages.
|
|
58
|
+
|
|
59
|
+
It reads Parquet in a stream, sorts the panel once (in memory when it fits,
|
|
60
|
+
in whole-unit pieces on disk when it does not), and computes standard errors
|
|
61
|
+
clustered by unit without holding all units' scores.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from diff_stream import did_callaway_santanna
|
|
65
|
+
|
|
66
|
+
fit = did_callaway_santanna(
|
|
67
|
+
"earnings ~ age + C(region)",
|
|
68
|
+
"panel/*.parquet",
|
|
69
|
+
panel=("person", "year"),
|
|
70
|
+
cohort="first_treated_year",
|
|
71
|
+
control_group="not_yet_treated",
|
|
72
|
+
)
|
|
73
|
+
fit.aggregate().summary() # event study
|
|
74
|
+
fit.aggregate("overall").summary() # overall ATT
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Status: in development** (version 0.1.0.dev0, not yet on PyPI). The
|
|
78
|
+
estimators, inference and documentation are complete and tested; the
|
|
79
|
+
[benchmarks](docs/benchmarks/index.md) compare them with other implementations.
|
|
80
|
+
|
|
81
|
+
## What it does
|
|
82
|
+
|
|
83
|
+
**Estimators** (each cites its paper in its docstring)
|
|
84
|
+
- `did_callaway_santanna`: group-time effects, with outcome regression, IPW
|
|
85
|
+
or doubly robust covariate adjustment; never-treated or not-yet-treated
|
|
86
|
+
controls; varying or universal base periods; panels or repeated cross
|
|
87
|
+
sections.
|
|
88
|
+
- `did_sun_abraham`: the interaction-weighted event study, with covariates
|
|
89
|
+
and additional fixed effects.
|
|
90
|
+
- `did_bjs`: imputation, with the authors' standard errors (residual
|
|
91
|
+
averaging groups, leave-out) and their pre-trend test.
|
|
92
|
+
- `did_two_stage`: Gardner's two-stage DiD, event study or overall ATT.
|
|
93
|
+
- `did_extended_twfe`: Wooldridge's extended TWFE (linear), with
|
|
94
|
+
covariate-interacted effects.
|
|
95
|
+
- `did_stacked`: stacked DiD with corrective weights, without building the
|
|
96
|
+
stack.
|
|
97
|
+
|
|
98
|
+
**Data**
|
|
99
|
+
- Unbalanced panels, sampling weights (also time-varying), anticipation, and
|
|
100
|
+
panels where every unit is eventually treated (the last-treated cohort as
|
|
101
|
+
comparison).
|
|
102
|
+
|
|
103
|
+
**Results and inference**
|
|
104
|
+
- Aggregation by event time, cohort and calendar period, or overall, with
|
|
105
|
+
balanced event windows and the cells and cohorts behind every estimate.
|
|
106
|
+
- Standard errors clustered by unit, including the uncertainty in the cohort
|
|
107
|
+
shares used to aggregate; pre-trend tests.
|
|
108
|
+
- Callaway-Sant'Anna's multiplier bootstrap for simultaneous bands, and a
|
|
109
|
+
bootstrap that refits any estimator on unit resamples, in parallel.
|
|
110
|
+
|
|
111
|
+
**Diagnostics**
|
|
112
|
+
- `bacon_decomp`: the Goodman-Bacon decomposition (with covariates and
|
|
113
|
+
weights).
|
|
114
|
+
- `twfe_weights`: de Chaisemartin and D'Haultfoeuille's weights and
|
|
115
|
+
sensitivity measures.
|
|
116
|
+
|
|
117
|
+
**Resources**
|
|
118
|
+
- `n_threads=` and `max_memory_gb=` respect a job's limits (stream-limits);
|
|
119
|
+
`profile=True` reports the memory and disk a fit needs, without fitting.
|
|
120
|
+
- Results with hdfe-stream's reporting: `summary()`, `tidy()`, `confint()`,
|
|
121
|
+
`wald_test()`, `etable`.
|
|
122
|
+
|
|
123
|
+
## Why it is fast
|
|
124
|
+
|
|
125
|
+
- **Sorted once.** The panel is sorted by unit in one piece when it fits in
|
|
126
|
+
memory, in whole-unit pieces on disk otherwise; later passes read whole units
|
|
127
|
+
in large batches.
|
|
128
|
+
- **Sufficient statistics.** Without covariates, the estimators reduce to
|
|
129
|
+
cohort-by-period means plus unit scores computed in parallel.
|
|
130
|
+
- **Covariance by cohort.** A unit's scores touch only its cohort's cells, so
|
|
131
|
+
the joint covariance of hundreds of effects is accumulated cohort by cohort.
|
|
132
|
+
- **Shared passes.** All comparisons' propensity and outcome models are
|
|
133
|
+
fitted together.
|
|
134
|
+
|
|
135
|
+
On a simulated panel of one million units over eight periods (8 million
|
|
136
|
+
rows), each estimator fits in one to two seconds on a 16-vCPU cloud instance,
|
|
137
|
+
and ten million units (80 million rows) in under half a minute, with a peak of
|
|
138
|
+
about 4 GB. With `max_memory_gb=2`, a million units fit within about 0.8 GB.
|
|
139
|
+
The benchmarks give the comparisons with R and Python implementations.
|
|
140
|
+
|
|
141
|
+
## Validation against other implementations
|
|
142
|
+
|
|
143
|
+
| Implementation | Compared on |
|
|
144
|
+
|---|---|
|
|
145
|
+
| R did, DRDID | Callaway-Sant'Anna: effects, full covariance, aggregations, multiplier bootstrap; the `mpdta` example |
|
|
146
|
+
| R fixest (`sunab`) | Sun-Abraham: coefficients, covariance, aggregation; the Castle Doctrine panel |
|
|
147
|
+
| Stata `did_imputation` (authors), R didimputation | BJS: estimates and full covariance, every averaging group, leave-out, pre-trend leads |
|
|
148
|
+
| R did2s (authors) | two-stage: coefficients and covariance |
|
|
149
|
+
| R etwfe, emfx (authors) | extended TWFE: effects and the covariance of every aggregation |
|
|
150
|
+
| fixest on the stacked data | stacked DiD: coefficients and covariance |
|
|
151
|
+
| R bacondecomp | Goodman-Bacon: every comparison; the `math_reform` example |
|
|
152
|
+
| R TwoWayFEWeights (authors) | TWFE weights (`feTR`) and sensitivity measures |
|
|
153
|
+
|
|
154
|
+
Agreement is within 2e-7 absolute, usually 1e-10. The frozen references and
|
|
155
|
+
the R and Stata scripts that make them are in `tests/data/` and
|
|
156
|
+
`benchmarks/external/`; `docs/implementations.md` lists the conventions on
|
|
157
|
+
which these packages differ.
|
|
158
|
+
|
|
159
|
+
## Install
|
|
160
|
+
|
|
161
|
+
diff-stream is not on PyPI yet. Install it from the repository:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install git+https://github.com/leetucker/diff-stream
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Python 3.10 or later. It depends on hdfe-stream (with its formula extra),
|
|
168
|
+
stream-limits, Polars, NumPy 2, numba, SciPy and pyarrow.
|
|
169
|
+
|
|
170
|
+
## Documentation
|
|
171
|
+
|
|
172
|
+
Build the documentation locally with:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
pip install -e ".[docs]"
|
|
176
|
+
sphinx-build -W --keep-going -b html docs docs/_build/html
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
It covers choosing an estimator, your data, each estimator, results and
|
|
180
|
+
inference, the diagnostics, worked examples with their output, how it works,
|
|
181
|
+
memory and threads, the conventions that differ from other packages, and the
|
|
182
|
+
validation. `examples/` has runnable scripts, and `benchmarks/` the
|
|
183
|
+
performance comparisons (see `benchmarks/README.md`).
|
|
184
|
+
|
|
185
|
+
A sibling of [hdfe-stream](https://github.com/leetucker/hdfe-stream),
|
|
186
|
+
[logit-stream](https://github.com/leetucker/logit-stream),
|
|
187
|
+
[dpd-stream](https://github.com/leetucker/dpd-stream) and
|
|
188
|
+
[match-stream](https://github.com/leetucker/match-stream).
|
|
189
|
+
|
|
190
|
+
## License
|
|
191
|
+
|
|
192
|
+
MIT.
|