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,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.