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,95 @@
1
+ """Balanced-panel algebra and bounded, parallel cluster-score kernels.
2
+
3
+ In a saturated SA regression with a never-treated reference, the within-unit
4
+ normal equations reduce exactly to cohort-versus-never changes from g-1.
5
+ This avoids a dense cohort-interaction regression system altogether.
6
+ """
7
+
8
+ import numpy as np
9
+ from numba import njit, prange
10
+
11
+
12
+ def setup(method, times, gc, control_group, base_period, anticipation=0):
13
+ groups = sorted(gc)
14
+ cells, bases, controls = [], [], []
15
+ for g in groups[1:]:
16
+ onset = g - anticipation
17
+ for t in times:
18
+ if (method == "bjs" and t < onset) or (
19
+ t == onset - 1 and (method == "sunab" or base_period == "universal")
20
+ ):
21
+ continue
22
+ b = (
23
+ int(t - 1)
24
+ if method == "cs" and base_period == "varying" and t < onset
25
+ else onset - 1
26
+ )
27
+ if b < times[0]:
28
+ continue
29
+ c = [
30
+ h == 0
31
+ or (control_group == "notyettreated" and h - anticipation > max(t, b) and h != g)
32
+ for h in groups
33
+ ]
34
+ cells.append((g, int(t)))
35
+ bases.append(b - times[0])
36
+ controls.append(c)
37
+ return (
38
+ groups,
39
+ cells,
40
+ np.array(bases, dtype=np.int64),
41
+ np.array(controls, dtype=np.bool_),
42
+ np.array([gc[g] for g in groups], dtype=float),
43
+ )
44
+
45
+
46
+ def fit_means(method, times, groups, cells, bases, controls, sizes, means, anticipation=0):
47
+ T = len(times)
48
+ gi = np.array([groups.index(g) for g, t in cells], dtype=np.int64)
49
+ ti = np.array([t - times[0] for g, t in cells], dtype=np.int64)
50
+ counts = sizes[gi]
51
+ if method != "bjs":
52
+ nc = controls @ sizes
53
+ control_mean = np.sum(controls * sizes * (means[:, ti].T - means[:, bases].T), axis=1) / nc
54
+ att = means[gi, ti] - means[gi, bases] - control_mean
55
+ return att, counts, (gi, ti, bases, controls, nc, means, control_mean)
56
+ # Unit effects are eliminated exactly on untreated rows. Only T-1 time
57
+ # coefficients enter the solve. Normalize the first time effect to zero.
58
+ Z = np.ascontiguousarray(np.eye(T)[:, 1:])
59
+ Q, q = np.zeros((T - 1, T - 1)), np.zeros(T - 1)
60
+ zbar, alpha = [], []
61
+ for k, g in enumerate(groups):
62
+ use = (times < g - anticipation) if g else np.ones(T, dtype=bool)
63
+ z = Z[use]
64
+ zm = z.mean(axis=0)
65
+ zbar.append(zm)
66
+ zd = z - zm
67
+ Q += sizes[k] * zd.T @ zd
68
+ q += sizes[k] * zd.T @ means[k, use]
69
+ theta = np.linalg.solve(Q, q)
70
+ for k, g in enumerate(groups):
71
+ use = (times < g - anticipation) if g else np.ones(T, dtype=bool)
72
+ alpha.append(np.mean(means[k, use] - Z[use] @ theta))
73
+ att = means[gi, ti] - np.array(alpha)[gi] - Z[ti] @ theta
74
+ H = np.linalg.solve(Q, (Z[ti] - np.array(zbar)[gi]).T).T
75
+ return att, counts, (gi, ti, Z, theta, H)
76
+
77
+
78
+ @njit(cache=True, parallel=True)
79
+ def change_scores(gidx, y, gi, ti, bases, controls, nc, means, control_mean, sizes):
80
+ B, T = y.shape
81
+ M = len(gi)
82
+ scores = np.zeros((B, M))
83
+ shares = np.zeros((B, M))
84
+ for i in prange(B):
85
+ k = gidx[i]
86
+ for j in range(M):
87
+ change = y[i, ti[j]] - y[i, bases[j]]
88
+ if k == gi[j]:
89
+ scores[i, j] = (change - means[k, ti[j]] + means[k, bases[j]]) / sizes[k]
90
+ shares[i, j] = 1.0
91
+ elif controls[j, k]:
92
+ # With changing controls, center over the entire comparison
93
+ # population, not separately within its constituent cohorts.
94
+ scores[i, j] = -(change - control_mean[j]) / nc[j]
95
+ return scores, shares
diff_stream/etwfe.py ADDED
@@ -0,0 +1,589 @@
1
+ """Linear extended TWFE with bounded saturated designs and streamed margins.
2
+
3
+ Raw covariates span the same regression as cohort-demeaned interaction coding;
4
+ weighted margin gradients recover effects without depending on the coding basis.
5
+ """
6
+
7
+ from numbers import Integral
8
+ from pathlib import Path
9
+ from tempfile import TemporaryDirectory
10
+
11
+ import numpy as np
12
+ import polars as pl
13
+ import pyarrow as pa
14
+ from hdfe_stream.core.influence import ClusterScoreAccumulator
15
+ from hdfe_stream.core.profile import Component, Profile
16
+ from stream_limits import MemoryBudget, current_rss_gb, detect_cpus, thread_scope
17
+
18
+ from . import _docs, _options
19
+ from ._core import etwfe_kernels
20
+ from ._core.nuisance import solve_checked
21
+ from ._core.partition import preprocess_budget_mb, staging_bytes
22
+ from ._core.within import center_groups, independent_columns
23
+ from ._present import present, public_profile
24
+ from .cs import RETAIN_SHARE, RetainedBatches
25
+ from .results import DIDResult
26
+ from .store import PanelStore, inspect
27
+
28
+
29
+ class ExtendedDesign:
30
+ """Cohort/time slopes and cell-specific slopes, with bounded row batches."""
31
+
32
+ def __init__(self, store, control_group, fixed_effects, anticipation):
33
+ self.store, self.fixed_effects = store, fixed_effects
34
+ self.groups = sorted(store.gc)
35
+ self.K = len(store.design.names) if store.design is not None else 0
36
+ self.cells = [
37
+ (g, int(t))
38
+ for g in self.groups
39
+ if g
40
+ for t, n in zip(store.times, store.cell_counts[g], strict=True)
41
+ if n
42
+ and (
43
+ t >= g - anticipation
44
+ if control_group == "notyettreated"
45
+ else t != g - anticipation - 1
46
+ )
47
+ ]
48
+ xnames = store.design.names if store.design is not None else []
49
+ self.names = [] if fixed_effects == "unit" else [f"cohort::{g}" for g in self.groups]
50
+ self.names += [f"time::{t}" for t in store.times[1:]]
51
+ self.names += [f"cohort::{g}:{name}" for g in self.groups for name in xnames]
52
+ self.names += [f"time::{t}:{name}" for t in store.times[1:] for name in xnames]
53
+ self.offset = len(self.names)
54
+ self.names += [f"cell::{g}::{t}:{name}" for g, t in self.cells for name in ["1", *xnames]]
55
+ self.time = np.eye(store.periods)[:, 1:]
56
+ # cell_at[cohort index, time index] = estimated cell number, or -1
57
+ self.group_array = np.asarray(self.groups)
58
+ self.cell_at = np.full((len(self.groups), store.periods), -1, dtype=np.int64)
59
+ t0 = int(store.times[0])
60
+ for j, (cg, t) in enumerate(self.cells):
61
+ self.cell_at[self.groups.index(cg), t - t0] = j
62
+
63
+ def raw_batches(self):
64
+ """The store's batches (g, y, x, w) with covariates only, for the compiled passes."""
65
+ for g, y, x, w in self.store.batches(
66
+ include_x=True, include_weights=True, row_weights=True
67
+ ):
68
+ yield g, y, (x if self.K else x[:, :, :0]), w
69
+
70
+ def cell_rows(self, g, x):
71
+ """(rows, periods, cell numbers, [1, x] at those rows) of a batch's treatment cells."""
72
+ cell = self.cell_at[np.searchsorted(self.group_array, g)]
73
+ rows, periods = np.nonzero(cell >= 0)
74
+ one_x = np.concatenate(
75
+ [np.ones((len(rows), 1)), np.nan_to_num(x[rows, periods], nan=0)], axis=1
76
+ )
77
+ return rows, periods, cell[rows, periods], one_x
78
+
79
+ def batches(self):
80
+ for g, y, x, w in self.store.batches(
81
+ include_x=True, include_weights=True, row_weights=True
82
+ ):
83
+ x = x if self.K else x[:, :, :0]
84
+ x = np.nan_to_num(x, nan=0)
85
+ B, T = y.shape
86
+ D = np.zeros((B, T, len(self.names)))
87
+ at = 0
88
+ if self.fixed_effects == "cohort":
89
+ D[:, :, : len(self.groups)] = (g[:, None] == np.asarray(self.groups))[:, None, :]
90
+ at += len(self.groups)
91
+ D[:, :, at : at + T - 1] = self.time
92
+ at += T - 1
93
+ for cg in self.groups:
94
+ D[:, :, at : at + self.K] = x * (g == cg)[:, None, None]
95
+ at += self.K
96
+ for ti in range(1, T):
97
+ D[:, ti, at : at + self.K] = x[:, ti]
98
+ at += self.K
99
+ one_x = np.concatenate([np.ones((B, T, 1)), x], axis=2)
100
+ # Each unit-period belongs to at most one estimated cell; set every
101
+ # cell's slope block in one scatter instead of a mask per cell.
102
+ cell = self.cell_at[np.searchsorted(self.group_array, g)]
103
+ rows, periods = np.nonzero(cell >= 0)
104
+ index = cell[rows, periods]
105
+ K1 = self.K + 1
106
+ columns = self.offset + index[:, None] * K1 + np.arange(K1)
107
+ D[rows[:, None], periods[:, None], columns] = one_x[rows, periods]
108
+ yield g, np.nan_to_num(y, nan=0), D, w, one_x, (rows, periods, index)
109
+
110
+
111
+ class UnconditionalScores:
112
+ """``ClusterScoreAccumulator``'s sums for the scores ``[s, share - c]``,
113
+ where ``s`` is a unit's cell scores, ``share`` its weight in each cell it
114
+ belongs to (a unit has at most one row per cell) and ``c`` the cells' share
115
+ of the units' total. The ``share`` block is sparse, so its products with
116
+ ``s`` and itself are formed from the few entries, and the centering by
117
+ ``c`` is added afterwards; the dense product is then the cells' scores
118
+ alone, a quarter of the work of the full width."""
119
+
120
+ def __init__(self, c, cells):
121
+ self.c = np.asarray(c, dtype=np.float64)
122
+ M = len(self.c)
123
+ self.score_score = np.zeros((M, M))
124
+ self.score_share = np.zeros((M, M))
125
+ self.share_share = np.zeros((M, M))
126
+ self.score_sum = np.zeros(M)
127
+ self.share_sum = np.zeros(M)
128
+ self.n_clusters = 0
129
+ # A unit's cells are its cohort's, so the share block is block diagonal
130
+ # by cohort: cell j is column ``position[j]`` of its cohort's block.
131
+ self.by_cohort = {}
132
+ self.position = np.zeros(M, dtype=np.int64)
133
+ for j, (cohort, _) in enumerate(cells):
134
+ members = self.by_cohort.setdefault(cohort, [])
135
+ self.position[j] = len(members)
136
+ members.append(j)
137
+ self.by_cohort = {k: np.array(v) for k, v in self.by_cohort.items()}
138
+ self.width = max((len(v) for v in self.by_cohort.values()), default=0)
139
+
140
+ def add(self, score, cohort, rows, index, weight):
141
+ """A batch of units: their cell scores, cohorts, and the (unit, cell)
142
+ pairs with the unit's weight in the cell."""
143
+ share = np.zeros((score.shape[0], self.width))
144
+ share[rows, self.position[index]] = weight
145
+ self.score_score += score.T @ score
146
+ self.score_sum += score.sum(axis=0)
147
+ # One reordering puts each cohort's units in a contiguous slice.
148
+ order = np.argsort(cohort, kind="stable")
149
+ ordered = cohort[order]
150
+ score, share = score[order], share[order]
151
+ for g, cells in self.by_cohort.items():
152
+ start = np.searchsorted(ordered, g, side="left")
153
+ stop = np.searchsorted(ordered, g, side="right")
154
+ if start == stop:
155
+ continue
156
+ block = share[start:stop, : len(cells)]
157
+ self.score_share[:, cells] += score[start:stop].T @ block
158
+ self.share_share[np.ix_(cells, cells)] += block.T @ block
159
+ self.share_sum[cells] += block.sum(axis=0)
160
+ self.n_clusters += score.shape[0]
161
+
162
+ @property
163
+ def sums(self):
164
+ c, n = self.c, self.n_clusters
165
+ cross = self.score_share - np.outer(self.score_sum, c)
166
+ share = (
167
+ self.share_share
168
+ - np.outer(self.share_sum, c)
169
+ - np.outer(c, self.share_sum)
170
+ + n * np.outer(c, c)
171
+ )
172
+ joint = np.block([[self.score_score, cross], [cross.T, share]])
173
+ return (joint + joint.T) / 2
174
+
175
+
176
+ def _fit(model, inference):
177
+ store = model.store
178
+ P, M = len(model.names), len(model.cells)
179
+ Q, q, norms = np.zeros((P, P)), np.zeros(P), np.zeros(P)
180
+ mass = np.array([store.cell_weights[g][t - int(store.times[0])] for g, t in model.cells])
181
+ means = np.zeros((M, model.K + 1))
182
+ kernels = etwfe_kernels.ETWFEKernels(model)
183
+ compiled = kernels.usable
184
+ source = RetainedBatches(
185
+ model.raw_batches,
186
+ retain=compiled
187
+ and store.n_units * store.periods * 8 * (2 + model.K)
188
+ <= RETAIN_SHARE * store.budget.headroom_gb() * 1e9,
189
+ )
190
+ if compiled:
191
+ Q, q, norms, means = kernels.moments(source)
192
+ else:
193
+ for _g, y, D, w, one_x, (rows, periods, index) in model.batches():
194
+ ww = w.ravel()
195
+ if model.fixed_effects == "unit":
196
+ flat = D.reshape(-1, P)
197
+ norms += (flat * flat).T @ ww
198
+ D, y, _, _ = center_groups(D, y, w)
199
+ flat = D.reshape(-1, P)
200
+ weighted = ww[:, None] * flat
201
+ Q += flat.T @ weighted
202
+ q += weighted.T @ y.ravel()
203
+ contribution = w[rows, periods][:, None] * one_x[rows, periods]
204
+ for k in range(model.K + 1):
205
+ means[:, k] += np.bincount(index, weights=contribution[:, k], minlength=M)
206
+ if model.fixed_effects != "unit":
207
+ # Without centering, the column norms are the Gram diagonal.
208
+ norms = np.diag(Q).copy()
209
+ means /= mass[:, None]
210
+ keep = independent_columns(Q, original_norm=norms)
211
+ if not len(keep):
212
+ raise ValueError("ETWFE regression has no identified columns")
213
+ L = np.zeros((M, P))
214
+ for j in range(M):
215
+ L[j, model.offset + j * (model.K + 1) : model.offset + (j + 1) * (model.K + 1)] = means[j]
216
+ # Individual interaction aliases are permissible only if every reported
217
+ # margin is invariant to their normalization. Check in scaled coordinates.
218
+ scale = np.sqrt(np.maximum(np.diag(Q), 0))
219
+ safe = np.where(scale > 0, scale, 1)
220
+ normalized = Q / safe[:, None] / safe[None, :]
221
+ relation = solve_checked(
222
+ normalized[np.ix_(keep, keep)], normalized[keep], "ETWFE column identification"
223
+ )
224
+ gradient = L / safe
225
+ residual = gradient - gradient[:, keep] @ relation
226
+ bad = np.max(abs(residual), axis=1) > 1e-8 * np.maximum(np.max(abs(gradient), axis=1), 1e-12)
227
+ if bad.any():
228
+ raise ValueError(
229
+ f"ETWFE treatment effects are unidentified for cells {[model.cells[j] for j in np.flatnonzero(bad)]}; check reference support and covariate variation"
230
+ )
231
+ Q = Q[np.ix_(keep, keep)]
232
+ # Q[keep, keep] scaled is the matrix the identification solve above checked.
233
+ coef = solve_checked(Q, q[keep], "ETWFE saturated regression", verify=False)
234
+ beta = L[:, keep] @ coef
235
+ H = solve_checked(Q, L[:, keep].T, "ETWFE margin covariance", verify=False)
236
+ full_coef = np.zeros(P)
237
+ full_coef[keep] = coef
238
+ acc = (
239
+ UnconditionalScores(mass / store.n_units, model.cells)
240
+ if inference == "unconditional"
241
+ else ClusterScoreAccumulator()
242
+ )
243
+ blocks = full_coef[model.offset :].reshape(M, model.K + 1)
244
+ all_columns = len(keep) == P
245
+
246
+ def finish_batch(g, w, score, rows, periods, index, one_x):
247
+ if inference == "unconditional":
248
+ # Recover each row's margin from the retained coefficient coding;
249
+ # its sample average equals the reported cell effect. A unit has
250
+ # one row per cell, so the (unit, cell) pairs are distinct.
251
+ margin = np.einsum("rk,rk->r", one_x, blocks[index])
252
+ row_weight = w[rows, periods]
253
+ score[rows, index] += row_weight * (margin - beta[index]) / mass[index]
254
+ acc.add(score, g, rows, index, row_weight)
255
+ else:
256
+ acc.add_clusters(score)
257
+
258
+ if compiled:
259
+ H_full = np.zeros((P, M))
260
+ H_full[keep] = H
261
+ for raw in source():
262
+ g, _, x, w = raw
263
+ score = kernels.scores(raw, full_coef, H_full)
264
+ rows, periods, index, one_x = model.cell_rows(g, x)
265
+ finish_batch(g, w, score, rows, periods, index, one_x)
266
+ return _result(beta, acc, inference, M, keep, coef)
267
+ for g, y, D, w, one_x, (rows, periods, index) in model.batches():
268
+ if model.fixed_effects == "unit":
269
+ D, y, _, _ = center_groups(D, y, w)
270
+ if not all_columns:
271
+ D = D[:, :, keep]
272
+ e = y - D @ coef
273
+ score = np.matmul((w * e)[:, None, :], D)[:, 0, :] @ H
274
+ finish_batch(g, w, score, rows, periods, index, one_x[rows, periods])
275
+ return _result(beta, acc, inference, M, keep, coef)
276
+
277
+
278
+ def _result(beta, acc, inference, M, keep, coef):
279
+ joint = acc.sums
280
+ return (
281
+ beta,
282
+ joint[:M, :M],
283
+ joint[:M, M:] if inference == "unconditional" else None,
284
+ joint[M:, M:] if inference == "unconditional" else None,
285
+ keep,
286
+ coef,
287
+ )
288
+
289
+
290
+ def _extended_twfe(
291
+ fml,
292
+ data,
293
+ *,
294
+ panel,
295
+ cohort=None,
296
+ treatment=None,
297
+ control_group="nevertreated",
298
+ fixed_effects="cohort",
299
+ inference="unconditional",
300
+ vcov="CRV0",
301
+ weights=None,
302
+ allow_time_varying_weights=False,
303
+ panel_mode="balanced",
304
+ anticipation=0,
305
+ batch_units=None,
306
+ threads=None,
307
+ memory_gb=None,
308
+ memory_reserve=None,
309
+ preprocess_mb=None,
310
+ workdir=None,
311
+ profile=False,
312
+ max_design_columns=2048,
313
+ reference_group="auto",
314
+ ):
315
+ """Linear extended TWFE from internal option names; see did_extended_twfe.
316
+
317
+ Saturates covariates with cohort, time and treatment-cell interactions.
318
+ Never-treated controls also estimate leads, omitting g-anticipation-1;
319
+ not-yet-treated controls estimate affected cells only. Unconditional
320
+ inference includes covariate-distribution and aggregation-share
321
+ variation; conditional holds both fixed.
322
+ """
323
+ if control_group not in ("nevertreated", "notyettreated"):
324
+ raise ValueError("control_group must be nevertreated or notyettreated")
325
+ if reference_group not in ("auto", "nevertreated", "lasttreated"):
326
+ raise ValueError("reference_group must be auto, nevertreated or lasttreated")
327
+ if fixed_effects not in ("cohort", "unit"):
328
+ raise ValueError("fixed_effects must be cohort or unit")
329
+ if inference not in ("conditional", "unconditional"):
330
+ raise ValueError("inference must be conditional or unconditional")
331
+ if vcov not in ("CRV0", "CRV1"):
332
+ raise ValueError("vcov must be CRV0 or CRV1")
333
+ if panel_mode not in ("balanced", "complete", "unbalanced"):
334
+ raise ValueError("panel_mode must be balanced, complete or unbalanced")
335
+ if (
336
+ isinstance(anticipation, (bool, np.bool_))
337
+ or not isinstance(anticipation, Integral)
338
+ or not 0 <= anticipation < np.iinfo(np.int64).max
339
+ ):
340
+ raise ValueError("anticipation must be a nonnegative integer within int64 range")
341
+ anticipation = int(anticipation)
342
+ checked = [(max_design_columns, "max_design_columns")]
343
+ if batch_units is not None:
344
+ checked.append((batch_units, "batch_units"))
345
+ for value, name in checked:
346
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
347
+ raise ValueError(f"{name} must be a positive integer")
348
+ if threads is not None and (
349
+ isinstance(threads, bool) or not isinstance(threads, int) or threads < 1
350
+ ):
351
+ raise ValueError("n_threads must be a positive integer")
352
+ if not isinstance(allow_time_varying_weights, bool):
353
+ raise ValueError("allow_time_varying_weights must be boolean")
354
+ budget = MemoryBudget(memory_gb, memory_reserve)
355
+ preprocess_mb = preprocess_budget_mb(preprocess_mb, budget)
356
+ baseline = current_rss_gb() or 0
357
+ with thread_scope(threads):
358
+ lf, yname, design = inspect(
359
+ fml, data, panel, cohort, treatment=treatment, allow_covariates=True, weights=weights
360
+ )
361
+ if not budget.fits((preprocess_mb * 1e6 + 96e6) / 1e9):
362
+ raise MemoryError(
363
+ "preprocessing buffers exceed job headroom before staging; reduce preprocess_mb or request more memory"
364
+ )
365
+ if workdir is not None:
366
+ Path(workdir).mkdir(parents=True, exist_ok=True)
367
+ with TemporaryDirectory(prefix="diff-stream-etwfe-", dir=workdir) as run:
368
+ store = PanelStore(
369
+ lf,
370
+ run,
371
+ batch_units or 4096,
372
+ budget,
373
+ preprocess_mb,
374
+ design,
375
+ panel_mode,
376
+ anticipation,
377
+ allow_time_varying_weights=allow_time_varying_weights,
378
+ require_never_treated=reference_group == "nevertreated",
379
+ )
380
+ from .reference import reference_store
381
+
382
+ store, reference_metadata = reference_store(store, reference_group, anticipation)
383
+ pa.default_memory_pool().release_unused()
384
+ K = len(design.names) if design is not None else 0
385
+ cells = [
386
+ (g, int(t))
387
+ for g in sorted(store.gc)
388
+ if g
389
+ for t, n in zip(store.times, store.cell_counts[g], strict=True)
390
+ if n
391
+ and (
392
+ t >= g - anticipation
393
+ if control_group == "notyettreated"
394
+ else t != g - anticipation - 1
395
+ )
396
+ ]
397
+ G, T, M = len(store.gc), store.periods, len(cells)
398
+ P = (G if fixed_effects == "cohort" else 0) + T - 1 + K * (G + T - 1) + M * (K + 1)
399
+ if not M:
400
+ raise ValueError("no supported ETWFE effects")
401
+ if P > max_design_columns:
402
+ raise ValueError(
403
+ f"ETWFE expanded design requires {P} columns, exceeding max_design_columns={max_design_columns}"
404
+ )
405
+
406
+ def buffers(B):
407
+ return 8 * (
408
+ 12 * P * P + 12 * M * M + 8 * M * P + 10 * B * T * (P + K + T) + 8 * B * M
409
+ )
410
+
411
+ # An automatic batch starts at 4096 units and halves until it fits.
412
+ B = min(batch_units or 4096, store.n_units)
413
+ if batch_units is None:
414
+ while B > 128 and not budget.fits(buffers(B) / 1e9 + 0.128):
415
+ B //= 2
416
+ numeric = buffers(B)
417
+ store.batch_units = B
418
+ diagnostics = dict(
419
+ **reference_metadata,
420
+ control_group=control_group,
421
+ fixed_effects=fixed_effects,
422
+ inference=inference,
423
+ anticipation=anticipation,
424
+ reference_event_time=-anticipation - 1,
425
+ base_period="cohort clean pre-period"
426
+ if control_group == "nevertreated"
427
+ else "common time effects from unaffected observations",
428
+ panel_mode=panel_mode,
429
+ balanced_sample=store.is_balanced,
430
+ n_units_input=store.n_units_input,
431
+ dropped_units=store.n_units_input - store.n_units,
432
+ cohort_sizes={str(g): int(n) for g, n in store.gc.items()},
433
+ time_min=int(store.times[0]),
434
+ time_max=int(store.times[-1]),
435
+ sampling_weights=weights,
436
+ weights_time_invariant=store.weights_time_invariant,
437
+ weight_scale=store.weight_scale / store.weight_normalizer,
438
+ expanded_columns=P,
439
+ max_design_columns=max_design_columns,
440
+ partitions=store.partition_count,
441
+ max_decoded_leaf_bytes=store.max_leaf_bytes,
442
+ preprocess_mb=preprocess_mb,
443
+ aggregation_weight="observed target-cell sampling weight totals",
444
+ warnings=[],
445
+ )
446
+ if profile:
447
+ return Profile(
448
+ model="diff-stream etwfe",
449
+ threads=threads or detect_cpus(),
450
+ memory_limit_gb=budget.limit_gb,
451
+ memory_reserve=budget.reserve,
452
+ polars_threads=pl.thread_pool_size(),
453
+ baseline_gb=baseline,
454
+ components=[
455
+ Component(
456
+ "saturated numerical design", numeric + 192e6, numeric * 1.5 + 256e6
457
+ ),
458
+ Component(
459
+ "bounded partition preprocessing",
460
+ staging_bytes(preprocess_mb, store) + 96e6,
461
+ preprocess_mb * 1e6 + 256e6,
462
+ ),
463
+ Component(
464
+ "snapshot and sorted partitions",
465
+ 64 * store.n_units * T,
466
+ 128 * store.n_units * T,
467
+ where="disk",
468
+ ),
469
+ ],
470
+ notes={**diagnostics, "n_units": store.n_units, "periods": T, "effects": M},
471
+ warnings=[
472
+ "Heuristic; third-party source execution and allocators are not hard capped."
473
+ ],
474
+ )
475
+ if not budget.fits(numeric / 1e9 + 0.128):
476
+ raise MemoryError(
477
+ "estimated ETWFE design buffers exceed job headroom; reduce batch_units or request more memory"
478
+ )
479
+ model = ExtendedDesign(store, control_group, fixed_effects, anticipation)
480
+ beta, V, cross, share_cov, keep, coef = _fit(model, inference)
481
+ factor = store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1
482
+ V = (V + V.T) / 2 * factor
483
+ result = DIDResult(
484
+ fml=fml,
485
+ depvar=yname,
486
+ coefnames=[f"ATT::{g}::{t}" for g, t in cells],
487
+ beta=beta,
488
+ vcov=V,
489
+ vcov_type=vcov,
490
+ df_t=store.n_units - 1,
491
+ n_obs=store.n_rows_used,
492
+ n_units=store.n_units,
493
+ estimator="etwfe",
494
+ cells=cells,
495
+ counts=np.array([store.cell_weights[g][t - int(store.times[0])] for g, t in cells]),
496
+ observed_counts=np.array(
497
+ [store.cell_counts[g][t - int(store.times[0])] for g, t in cells]
498
+ ),
499
+ cohort_counts=np.array([store.gc[g] for g, t in cells]),
500
+ weights=weights,
501
+ n_clusters={panel[0]: store.n_units},
502
+ all_vcovs={vcov: (V, store.n_units - 1)},
503
+ _share_cross=cross * factor if cross is not None else None,
504
+ _share_cov=share_cov * factor if share_cov is not None else None,
505
+ diagnostics={
506
+ **diagnostics,
507
+ "regression_columns": [model.names[j] for j in keep],
508
+ "absorbed_or_redundant_columns": [
509
+ name for j, name in enumerate(model.names) if j not in keep
510
+ ],
511
+ "regression_coefficients": coef.tolist(),
512
+ },
513
+ )
514
+ return present(result, store.period_labels)
515
+
516
+
517
+ @_docs.document
518
+ def did_extended_twfe(
519
+ fml,
520
+ data,
521
+ *,
522
+ panel,
523
+ cohort=None,
524
+ treatment=None,
525
+ control_group="never_treated",
526
+ fixed_effects="cohort",
527
+ treat_sample_as_fixed=False,
528
+ anticipation=0,
529
+ weights=None,
530
+ allow_time_varying_weights=False,
531
+ incomplete_units="error",
532
+ last_treated_controls="if_needed",
533
+ vcov="CRV0",
534
+ max_design_columns=2048,
535
+ n_threads=None,
536
+ max_memory_gb=None,
537
+ memory_reserve=None,
538
+ workdir=None,
539
+ profile=False,
540
+ batch_units=None,
541
+ preprocess_mb=None,
542
+ ):
543
+ """Wooldridge's extended two-way fixed effects (linear).
544
+
545
+ A single regression with a separate treatment effect for every cohort and
546
+ period, and covariates interacted with cohort, time and treatment, so
547
+ that the effects can differ with the covariates. The cohort-time effects
548
+ are averaged over the sample's covariates and aggregated with
549
+ ``aggregate()``, as in R's etwfe and emfx. Only the linear model is
550
+ implemented; for logit or Poisson ETWFE use R's etwfe or Stata's jwdid.
551
+
552
+ Wooldridge, J. M. (2021). Two-way fixed effects, the two-way Mundlak
553
+ regression, and difference-in-differences estimators. Working paper,
554
+ SSRN 3906345.
555
+
556
+ Parameters
557
+ ----------
558
+ fml : str
559
+ ``"y ~ 1"`` or ``"y ~ x + z"``. Additional fixed effects are not
560
+ supported.
561
+ {DATA}
562
+ control_group : {"never_treated", "not_yet_treated"}, default "never_treated"
563
+ The comparison units. With never-treated controls, pre-treatment
564
+ effects are estimated too (for a pre-trend test). R's etwfe defaults
565
+ to not-yet-treated.
566
+ fixed_effects : {"cohort", "unit"}, default "cohort"
567
+ Cohort intercepts (R etwfe's default) or unit fixed effects.
568
+ treat_sample_as_fixed : bool, default False
569
+ Standard errors for the effect in these units, holding the observed
570
+ covariates and cohort shares fixed (smaller), instead of in the
571
+ population they were drawn from (R emfx's conditional option).
572
+ {ANTICIPATION}
573
+ {WEIGHTS}
574
+ {INCOMPLETE_UNITS}
575
+ {LAST_TREATED}
576
+ {VCOV}
577
+ max_design_columns : int, default 2048
578
+ The limit on the expanded design, after interacting the covariates.
579
+ {RESOURCES}
580
+
581
+ Returns
582
+ -------
583
+ DIDResult or Profile
584
+ ``cohort_time()`` lists the cohort-time effects; ``aggregate()``
585
+ gives an event study, ``aggregate("overall")`` the overall ATT.
586
+ """
587
+ options = dict(locals())
588
+ fml, data = options.pop("fml"), options.pop("data")
589
+ return public_profile(_extended_twfe(fml, data, **_options.internal(options)))