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