diff-stream 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diff_stream/__init__.py +52 -0
- diff_stream/_bootstrap_worker.py +48 -0
- diff_stream/_core/__init__.py +5 -0
- diff_stream/_core/contrasts.py +199 -0
- diff_stream/_core/covariate_kernels.py +420 -0
- diff_stream/_core/cs_kernels.py +536 -0
- diff_stream/_core/cs_row_kernels.py +495 -0
- diff_stream/_core/design.py +105 -0
- diff_stream/_core/etwfe_kernels.py +288 -0
- diff_stream/_core/functionals.py +30 -0
- diff_stream/_core/multiplier.py +90 -0
- diff_stream/_core/nuisance.py +255 -0
- diff_stream/_core/partition.py +158 -0
- diff_stream/_core/process.py +61 -0
- diff_stream/_core/resample.py +171 -0
- diff_stream/_core/staging_kernels.py +90 -0
- diff_stream/_core/twostage_kernels.py +342 -0
- diff_stream/_core/weights.py +26 -0
- diff_stream/_core/within.py +81 -0
- diff_stream/_docs.py +100 -0
- diff_stream/_options.py +62 -0
- diff_stream/_present.py +202 -0
- diff_stream/absorbed.py +621 -0
- diff_stream/aggregation.py +225 -0
- diff_stream/api.py +1112 -0
- diff_stream/bacon.py +460 -0
- diff_stream/bacon_controls.py +188 -0
- diff_stream/balance.py +149 -0
- diff_stream/bjs.py +101 -0
- diff_stream/bjs_placebo.py +167 -0
- diff_stream/bootstrap.py +704 -0
- diff_stream/cohort_scores.py +68 -0
- diff_stream/cs.py +197 -0
- diff_stream/cs_unbalanced.py +336 -0
- diff_stream/effective_sample.py +73 -0
- diff_stream/estimators.py +95 -0
- diff_stream/etwfe.py +589 -0
- diff_stream/gardner.py +500 -0
- diff_stream/linear.py +339 -0
- diff_stream/periods.py +72 -0
- diff_stream/pretrend.py +97 -0
- diff_stream/py.typed +0 -0
- diff_stream/reference.py +125 -0
- diff_stream/results.py +521 -0
- diff_stream/simulate.py +80 -0
- diff_stream/stacked.py +405 -0
- diff_stream/store.py +706 -0
- diff_stream/twfe_store.py +181 -0
- diff_stream/twfe_weights.py +552 -0
- diff_stream/twostage_absorbed.py +190 -0
- diff_stream/unbalanced.py +297 -0
- diff_stream-0.1.0.dev0.dist-info/METADATA +192 -0
- diff_stream-0.1.0.dev0.dist-info/RECORD +56 -0
- diff_stream-0.1.0.dev0.dist-info/WHEEL +5 -0
- diff_stream-0.1.0.dev0.dist-info/licenses/LICENSE.md +21 -0
- diff_stream-0.1.0.dev0.dist-info/top_level.txt +1 -0
diff_stream/results.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""Family-compatible result methods and explicit aggregation maps."""
|
|
2
|
+
|
|
3
|
+
from copy import copy
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import polars as pl
|
|
8
|
+
from hdfe_stream.core.results_base import RegressionResult, _select_coefs
|
|
9
|
+
|
|
10
|
+
from ._core.multiplier import multiplier_band
|
|
11
|
+
from ._present import (
|
|
12
|
+
PUBLIC_ESTIMATORS,
|
|
13
|
+
internal,
|
|
14
|
+
label_frame,
|
|
15
|
+
label_name,
|
|
16
|
+
labels_of,
|
|
17
|
+
present_plan,
|
|
18
|
+
)
|
|
19
|
+
from .aggregation import period_type, plan
|
|
20
|
+
from .bjs import BJSInference
|
|
21
|
+
from .bjs_placebo import BJSPlaceboResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(kw_only=True)
|
|
25
|
+
class DIDResult(RegressionResult):
|
|
26
|
+
"""Group-time estimates and their joint covariance.
|
|
27
|
+
|
|
28
|
+
Inherits tidy, confint, wald_test, summary, summary_json and etable support.
|
|
29
|
+
``aggregate`` returns event-time, cohort, calendar-time or simple effects.
|
|
30
|
+
CS additionally retains covariance blocks accounting for estimation of
|
|
31
|
+
cohort shares; no full unit-by-effect array is retained.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
estimator: str
|
|
35
|
+
cells: list
|
|
36
|
+
counts: np.ndarray
|
|
37
|
+
n_units: int
|
|
38
|
+
_share_cross: np.ndarray | None = field(default=None, repr=False)
|
|
39
|
+
_share_cov: np.ndarray | None = field(default=None, repr=False)
|
|
40
|
+
_bjs_inference: BJSInference | None = field(default=None, repr=False)
|
|
41
|
+
observed_counts: np.ndarray | None = field(default=None, repr=False)
|
|
42
|
+
cohort_counts: np.ndarray | None = field(default=None, repr=False)
|
|
43
|
+
_contributor_table: pl.DataFrame | None = field(default=None, repr=False)
|
|
44
|
+
_composition_table: pl.DataFrame | None = field(default=None, repr=False)
|
|
45
|
+
_multiplier_draws: np.ndarray | None = field(default=None, repr=False)
|
|
46
|
+
_multiplier_shares: np.ndarray | None = field(default=None, repr=False)
|
|
47
|
+
_bjs_placebos: BJSPlaceboResult | None = field(default=None, repr=False)
|
|
48
|
+
_balance_table: pl.DataFrame | None = field(default=None, repr=False)
|
|
49
|
+
_ess_table: pl.DataFrame | None = field(default=None, repr=False)
|
|
50
|
+
_suppression_mask: np.ndarray | None = field(default=None, repr=False)
|
|
51
|
+
_suppression_source: object = field(default=None, repr=False)
|
|
52
|
+
_internal: dict | None = field(default=None, repr=False)
|
|
53
|
+
_periods: object = field(default=None, repr=False)
|
|
54
|
+
_normal_inference = True
|
|
55
|
+
|
|
56
|
+
def covariate_balance(self, *, cohort=None, time=None):
|
|
57
|
+
"""Copy saved CS before/after propensity-weight balance diagnostics.
|
|
58
|
+
|
|
59
|
+
Request balance=True at fitting. Regression-only fits report before
|
|
60
|
+
weighting; no artificial regression-adjusted covariate sample is defined.
|
|
61
|
+
Native unbalanced fits report pre/post date strata separately. Baseline
|
|
62
|
+
pooled SD scales are held fixed after weighting. These are descriptive
|
|
63
|
+
diagnostics, not tests of parallel trends or a DR identification check.
|
|
64
|
+
"""
|
|
65
|
+
if self._balance_table is None:
|
|
66
|
+
raise ValueError("fit with covariate_balance=True to save covariate balance tables")
|
|
67
|
+
table = label_frame(self._balance_table, labels_of(self))
|
|
68
|
+
if cohort is not None:
|
|
69
|
+
table = table.filter(pl.col("cohort") == cohort)
|
|
70
|
+
if time is not None:
|
|
71
|
+
table = table.filter(pl.col("time") == time)
|
|
72
|
+
return table.clone()
|
|
73
|
+
|
|
74
|
+
def effective_sample_size(self):
|
|
75
|
+
"""CS Kish ESS by required comparison stratum, including aggregate contributors.
|
|
76
|
+
|
|
77
|
+
Sampling weights define treated ESS and regression controls; IPW/DR
|
|
78
|
+
controls include propensity odds and trimming. Native unbalanced panels
|
|
79
|
+
report pre/post strata separately. This is not regression-leverage or
|
|
80
|
+
influence-score ESS. Aggregates retain component ESS, never a pooled ESS.
|
|
81
|
+
"""
|
|
82
|
+
if self._ess_table is None:
|
|
83
|
+
raise ValueError("effective_sample_size is currently available for CS")
|
|
84
|
+
return self._ess_table.clone()
|
|
85
|
+
|
|
86
|
+
def _minimum_ess(self):
|
|
87
|
+
table = (
|
|
88
|
+
self.effective_sample_size().group_by("coefficient").agg(pl.col("effective_n").min())
|
|
89
|
+
)
|
|
90
|
+
values = dict(table.iter_rows())
|
|
91
|
+
return np.array([values[name] for name in self.coefnames])
|
|
92
|
+
|
|
93
|
+
def suppress(self, min_effective_sample=30):
|
|
94
|
+
"""Copy with low-support CS coefficients and covariance rows/columns as NaN.
|
|
95
|
+
|
|
96
|
+
Uses the minimum Kish ESS among each coefficient's required component
|
|
97
|
+
strata. Existing suppression is retained. No refitting, dropping cells,
|
|
98
|
+
renormalizing aggregation weights or changing the original fit occurs.
|
|
99
|
+
Aggregates depending on a suppressed cell are also suppressed. Joint
|
|
100
|
+
inference must explicitly select unsuppressed coefficients.
|
|
101
|
+
"""
|
|
102
|
+
from .effective_sample import threshold
|
|
103
|
+
|
|
104
|
+
limit = threshold(min_effective_sample, "min_effective_sample", optional=False)
|
|
105
|
+
return self._apply_suppression(self._minimum_ess() < limit, limit)
|
|
106
|
+
|
|
107
|
+
def _apply_suppression(self, mask, limit):
|
|
108
|
+
out = copy(self)
|
|
109
|
+
mask = np.array(mask, dtype=bool, copy=True)
|
|
110
|
+
if self._suppression_mask is not None:
|
|
111
|
+
mask |= self._suppression_mask
|
|
112
|
+
out._suppression_source = self._suppression_source or self
|
|
113
|
+
out._suppression_mask = mask
|
|
114
|
+
out.beta, out.vcov = self.beta.copy(), self.vcov.copy()
|
|
115
|
+
out.beta[mask] = np.nan
|
|
116
|
+
out.vcov[mask, :] = np.nan
|
|
117
|
+
out.vcov[:, mask] = np.nan
|
|
118
|
+
out.all_vcovs = {out.vcov_type: (out.vcov, out.df_t)}
|
|
119
|
+
if self._multiplier_draws is not None:
|
|
120
|
+
out._multiplier_draws = self._multiplier_draws.copy()
|
|
121
|
+
out._multiplier_draws[:, mask] = np.nan
|
|
122
|
+
names = [name for name, bad in zip(self.coefnames, mask, strict=True) if bad]
|
|
123
|
+
out.diagnostics = {
|
|
124
|
+
**self.diagnostics,
|
|
125
|
+
"suppressed_coefficients": names,
|
|
126
|
+
"suppression_threshold": max(limit, self.diagnostics.get("suppression_threshold", 0)),
|
|
127
|
+
"suppression_rule": "minimum component-stratum ESS; aggregates retain original weights",
|
|
128
|
+
}
|
|
129
|
+
if self._contributor_table is not None:
|
|
130
|
+
out._contributor_table = self._contributor_table.with_columns(
|
|
131
|
+
pl.when(pl.col("aggregate").is_in(names))
|
|
132
|
+
.then(float("nan"))
|
|
133
|
+
.otherwise(pl.col(column))
|
|
134
|
+
.alias(column)
|
|
135
|
+
for column in ("estimate", "weighted_estimate")
|
|
136
|
+
)
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def confint(
|
|
140
|
+
self,
|
|
141
|
+
alpha=0.05,
|
|
142
|
+
keep=None,
|
|
143
|
+
drop=None,
|
|
144
|
+
exact_match=False,
|
|
145
|
+
joint=False,
|
|
146
|
+
reps=10000,
|
|
147
|
+
seed=None,
|
|
148
|
+
):
|
|
149
|
+
if joint and self._suppression_mask is not None:
|
|
150
|
+
ix = _select_coefs(self.coefnames, keep, drop, exact_match)
|
|
151
|
+
if self._suppression_mask[ix].any():
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"joint inference includes suppressed coefficients; select unsuppressed coefficients explicitly"
|
|
154
|
+
)
|
|
155
|
+
return super().confint(
|
|
156
|
+
alpha=alpha,
|
|
157
|
+
keep=keep,
|
|
158
|
+
drop=drop,
|
|
159
|
+
exact_match=exact_match,
|
|
160
|
+
joint=joint,
|
|
161
|
+
reps=reps,
|
|
162
|
+
seed=seed,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def wald_test(self, R=None, q=None, distribution=None, keep=None, drop=None, exact_match=False):
|
|
166
|
+
if self._suppression_mask is not None and self._suppression_mask.any():
|
|
167
|
+
if R is None:
|
|
168
|
+
ix = _select_coefs(self.coefnames, keep, drop, exact_match)
|
|
169
|
+
else:
|
|
170
|
+
restriction = np.atleast_2d(np.asarray(R, dtype=float))
|
|
171
|
+
if restriction.shape[1] != len(self.coefnames):
|
|
172
|
+
raise ValueError("R must have one column per coefficient")
|
|
173
|
+
ix = np.flatnonzero(np.any(restriction != 0, axis=0))
|
|
174
|
+
if self._suppression_mask[ix].any():
|
|
175
|
+
raise ValueError(
|
|
176
|
+
"Wald test includes suppressed coefficients; select unsuppressed coefficients explicitly"
|
|
177
|
+
)
|
|
178
|
+
source = self._suppression_source
|
|
179
|
+
if source.vcov_type != self.vcov_type:
|
|
180
|
+
source = source.with_vcov(self.vcov_type)
|
|
181
|
+
return source.wald_test(
|
|
182
|
+
R=R, q=q, distribution=distribution, keep=keep, drop=drop, exact_match=exact_match
|
|
183
|
+
)
|
|
184
|
+
return super().wald_test(
|
|
185
|
+
R=R, q=q, distribution=distribution, keep=keep, drop=drop, exact_match=exact_match
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def pretrend_test(self, *, level="auto", horizons=None):
|
|
189
|
+
"""Joint chi-squared Wald test of clean pre-treatment effects.
|
|
190
|
+
|
|
191
|
+
By default test cohort-time cells on an original CS/SA fit, or horizons
|
|
192
|
+
on an event-time aggregate. level="event_time" aggregates an original
|
|
193
|
+
fit first. Anticipation periods and omitted references are excluded.
|
|
194
|
+
Singular covariance raises. BJS tests its separate clean-lead fit,
|
|
195
|
+
requested at fit time with pretrend_leads=L; only pooled event-time
|
|
196
|
+
tests apply.
|
|
197
|
+
The returned dictionary lists the exact coefficients, cells and horizons.
|
|
198
|
+
"""
|
|
199
|
+
from .pretrend import pretrend_test
|
|
200
|
+
|
|
201
|
+
return pretrend_test(self, level=level, horizons=horizons)
|
|
202
|
+
|
|
203
|
+
def placebos(self):
|
|
204
|
+
"""Separate BJS clean-lead regression result, fitted with pretrend_leads=L.
|
|
205
|
+
|
|
206
|
+
Available after cleanup and on treatment-effect aggregates. Lead
|
|
207
|
+
coefficients are pooled WLS regression effects, not group-time ATTs.
|
|
208
|
+
No joint covariance with the main treatment estimates is estimated.
|
|
209
|
+
"""
|
|
210
|
+
if self.estimator != "bjs":
|
|
211
|
+
raise ValueError("placebos() is available only for BJS")
|
|
212
|
+
if self._bjs_placebos is None:
|
|
213
|
+
raise ValueError("fit did_bjs with pretrend_leads=L to estimate pre-trend leads")
|
|
214
|
+
return self._bjs_placebos
|
|
215
|
+
|
|
216
|
+
def multiplier_draws(self, *, keep=None, drop=None, exact_match=False):
|
|
217
|
+
"""Copy saved CS unit-multiplier perturbations (repetitions x effects).
|
|
218
|
+
|
|
219
|
+
Fit with bootstrap_reps>=2. Draws include share uncertainty after
|
|
220
|
+
aggregation and the selected CRV0/CRV1 scaling; no data are reread.
|
|
221
|
+
"""
|
|
222
|
+
if self._multiplier_draws is None:
|
|
223
|
+
raise ValueError(
|
|
224
|
+
"fit with multiplier_bootstrap_reps >= 2 to keep multiplier bootstrap draws"
|
|
225
|
+
)
|
|
226
|
+
ix = _select_coefs(self.coefnames, keep, drop, exact_match)
|
|
227
|
+
factor = np.sqrt(self.n_units / (self.n_units - 1)) if self.vcov_type == "CRV1" else 1.0
|
|
228
|
+
return self._multiplier_draws[:, ix] * factor
|
|
229
|
+
|
|
230
|
+
def multiplier_confint(
|
|
231
|
+
self,
|
|
232
|
+
alpha=0.05,
|
|
233
|
+
*,
|
|
234
|
+
keep=None,
|
|
235
|
+
drop=None,
|
|
236
|
+
exact_match=False,
|
|
237
|
+
joint=True,
|
|
238
|
+
studentization="bootstrap",
|
|
239
|
+
):
|
|
240
|
+
"""CS unit-multiplier intervals, simultaneous by default.
|
|
241
|
+
|
|
242
|
+
Bootstrap studentization uses the draw IQR and empirical type-1
|
|
243
|
+
quantiles, as R did. 'analytic' uses the stored analytic standard
|
|
244
|
+
errors instead. The result's vcov/se and inherited confint stay analytic.
|
|
245
|
+
Exactly the keep/drop selection defines the simultaneous family.
|
|
246
|
+
"""
|
|
247
|
+
if studentization not in ("bootstrap", "analytic"):
|
|
248
|
+
raise ValueError("studentization must be 'bootstrap' or 'analytic'")
|
|
249
|
+
ix = _select_coefs(self.coefnames, keep, drop, exact_match)
|
|
250
|
+
if self._suppression_mask is not None and self._suppression_mask[ix].any():
|
|
251
|
+
raise ValueError(
|
|
252
|
+
"multiplier inference includes suppressed coefficients; select unsuppressed coefficients explicitly"
|
|
253
|
+
)
|
|
254
|
+
draws = self.multiplier_draws(keep=keep, drop=drop, exact_match=exact_match)
|
|
255
|
+
se, critical = multiplier_band(
|
|
256
|
+
draws,
|
|
257
|
+
alpha,
|
|
258
|
+
joint=joint,
|
|
259
|
+
analytic_se=self.se[ix] if studentization == "analytic" else None,
|
|
260
|
+
)
|
|
261
|
+
beta = self.beta[ix]
|
|
262
|
+
return pl.DataFrame(
|
|
263
|
+
{
|
|
264
|
+
"Coefficient": [self.coefnames[i] for i in ix],
|
|
265
|
+
"Estimate": beta,
|
|
266
|
+
"Std. Error": se,
|
|
267
|
+
"critical_value": np.broadcast_to(critical, len(ix)),
|
|
268
|
+
f"{alpha / 2 * 100:.1f}%": beta - critical * se,
|
|
269
|
+
f"{(1 - alpha / 2) * 100:.1f}%": beta + critical * se,
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def cleanup(self):
|
|
274
|
+
"""Release optional weighted/native unbalanced BJS aggregation files.
|
|
275
|
+
|
|
276
|
+
Estimates, stored covariance and coefficient tables remain available.
|
|
277
|
+
Further BJS aggregation requires refitting. Shallow copies made by
|
|
278
|
+
with_vcov share ownership of these files.
|
|
279
|
+
"""
|
|
280
|
+
if self._bjs_inference is not None and hasattr(self._bjs_inference, "cleanup"):
|
|
281
|
+
self._bjs_inference.cleanup()
|
|
282
|
+
|
|
283
|
+
def with_vcov(self, vcov):
|
|
284
|
+
"""Copy using CRV0 or CRV1 (the latter applies N/(N-1) only)."""
|
|
285
|
+
if vcov not in ("CRV0", "CRV1"):
|
|
286
|
+
raise ValueError("vcov must be 'CRV0' or 'CRV1'")
|
|
287
|
+
factor = self.n_units / (self.n_units - 1)
|
|
288
|
+
ratio = (factor if vcov == "CRV1" else 1) / (factor if self.vcov_type == "CRV1" else 1)
|
|
289
|
+
out = copy(self)
|
|
290
|
+
out.vcov = self.vcov * ratio
|
|
291
|
+
out.vcov_type = vcov
|
|
292
|
+
out.all_vcovs = {vcov: (out.vcov, out.df_t)}
|
|
293
|
+
if self._share_cross is not None:
|
|
294
|
+
out._share_cross = self._share_cross * ratio
|
|
295
|
+
out._share_cov = self._share_cov * ratio
|
|
296
|
+
if self._bjs_placebos is not None:
|
|
297
|
+
out._bjs_placebos = self._bjs_placebos.with_vcov(vcov)
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
def cohort_time(self):
|
|
301
|
+
"""Coefficient table with cohort/time and weighting/observed/cohort counts."""
|
|
302
|
+
if not self.cells:
|
|
303
|
+
raise ValueError("cohort_time is available on unaggregated results")
|
|
304
|
+
units = self.cohort_counts if self.estimator == "cs" else self.observed_counts
|
|
305
|
+
units = self.counts if units is None else units
|
|
306
|
+
return (
|
|
307
|
+
self.tidy()
|
|
308
|
+
.with_columns(
|
|
309
|
+
*(
|
|
310
|
+
[
|
|
311
|
+
pl.Series("min_effective_sample", self._minimum_ess()),
|
|
312
|
+
pl.Series(
|
|
313
|
+
"suppressed",
|
|
314
|
+
self._suppression_mask
|
|
315
|
+
if self._suppression_mask is not None
|
|
316
|
+
else np.zeros(len(self.beta), dtype=bool),
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
if self._ess_table is not None
|
|
320
|
+
else []
|
|
321
|
+
),
|
|
322
|
+
pl.Series("cohort", [c[0] for c in self.cells]),
|
|
323
|
+
pl.Series("time", [c[1] for c in self.cells]),
|
|
324
|
+
pl.Series("event_time", [c[1] - c[0] for c in self.cells]),
|
|
325
|
+
pl.Series(
|
|
326
|
+
"period_type",
|
|
327
|
+
[
|
|
328
|
+
period_type(t - g, self.diagnostics.get("anticipation", 0))
|
|
329
|
+
for g, t in self.cells
|
|
330
|
+
],
|
|
331
|
+
),
|
|
332
|
+
pl.Series("n_units", units.astype(int)),
|
|
333
|
+
pl.Series("weight_units", units.astype(int)),
|
|
334
|
+
pl.Series("weight_total", self.counts * self.diagnostics.get("weight_scale", 1.0)),
|
|
335
|
+
pl.Series(
|
|
336
|
+
"observed_units",
|
|
337
|
+
self.observed_counts.astype(int)
|
|
338
|
+
if self.observed_counts is not None
|
|
339
|
+
else [None] * len(self.cells),
|
|
340
|
+
dtype=pl.Int64,
|
|
341
|
+
),
|
|
342
|
+
pl.Series(
|
|
343
|
+
"cohort_units",
|
|
344
|
+
self.cohort_counts.astype(int)
|
|
345
|
+
if self.cohort_counts is not None
|
|
346
|
+
else [None] * len(self.cells),
|
|
347
|
+
dtype=pl.Int64,
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
.pipe(label_frame, labels_of(self))
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def contributions(
|
|
354
|
+
self, kind=None, *, horizons=None, balanced_horizon=None, balance_window=None
|
|
355
|
+
):
|
|
356
|
+
"""Cell weights, estimates and sample counts for each aggregate.
|
|
357
|
+
|
|
358
|
+
On an original result, arguments match aggregate(). On an aggregate,
|
|
359
|
+
call without arguments to inspect its saved contributors. Inspection
|
|
360
|
+
does not reread BJS aggregation files and works after cleanup().
|
|
361
|
+
"""
|
|
362
|
+
return self._composition_view(False, kind, horizons, balanced_horizon, balance_window)
|
|
363
|
+
|
|
364
|
+
def composition(self, kind=None, *, horizons=None, balanced_horizon=None, balance_window=None):
|
|
365
|
+
"""Cohort membership, cohort weights and sample totals per aggregate.
|
|
366
|
+
|
|
367
|
+
observed_unit_periods sums target-cell observations; it is not a count
|
|
368
|
+
of unique units across dates, nor a treated/control comparison sample.
|
|
369
|
+
"""
|
|
370
|
+
return self._composition_view(True, kind, horizons, balanced_horizon, balance_window)
|
|
371
|
+
|
|
372
|
+
def _composition_view(self, summary, kind, horizons, balanced_horizon, balance_window):
|
|
373
|
+
if self.cells:
|
|
374
|
+
p = plan(
|
|
375
|
+
self,
|
|
376
|
+
"event_time" if kind is None else kind,
|
|
377
|
+
horizons,
|
|
378
|
+
balanced_horizon,
|
|
379
|
+
balance_window,
|
|
380
|
+
)
|
|
381
|
+
return label_frame(p.composition if summary else p.contributors, labels_of(self))
|
|
382
|
+
if any(x is not None for x in (kind, horizons, balanced_horizon, balance_window)):
|
|
383
|
+
raise ValueError("inspect an aggregated result without aggregation arguments")
|
|
384
|
+
table = self._composition_table if summary else self._contributor_table
|
|
385
|
+
if table is None:
|
|
386
|
+
raise ValueError("no aggregation contributors are available")
|
|
387
|
+
return label_frame(table, labels_of(self))
|
|
388
|
+
|
|
389
|
+
def aggregate(
|
|
390
|
+
self, kind="event_time", *, horizons=None, balanced_horizon=None, balance_window=None
|
|
391
|
+
):
|
|
392
|
+
"""Aggregate by event time, post-treatment cohort, calendar time or overall ATT.
|
|
393
|
+
|
|
394
|
+
kind is "event_time" (default, an event study), "cohort", "calendar" or
|
|
395
|
+
"overall" (one post-treatment ATT). horizons selects event times for
|
|
396
|
+
every kind; kinds other than "event_time" use only post-treatment cells. CS weights by retained cohort mass; SA/BJS by
|
|
397
|
+
observed cell mass (unit counts without sampling weights).
|
|
398
|
+
CS propagates estimated-share uncertainty, and BJS
|
|
399
|
+
recomputes contrast-specific smart residual/leave-out inference.
|
|
400
|
+
|
|
401
|
+
Event-time balanced_horizon=E requires supported events 0..E and retains R did's
|
|
402
|
+
event-time range [E-(Tmax-Tmin), E]. balance_window=(lo,hi) requires every
|
|
403
|
+
estimated event in that window in each retained cohort; omitted reference
|
|
404
|
+
events -(anticipation+1) are exempt. These restrictions balance cohorts, not the
|
|
405
|
+
identities of observed units. They are applied before horizons.
|
|
406
|
+
"""
|
|
407
|
+
if self._suppression_source is not None:
|
|
408
|
+
source = self._suppression_source
|
|
409
|
+
if source.vcov_type != self.vcov_type:
|
|
410
|
+
source = source.with_vcov(self.vcov_type)
|
|
411
|
+
p = plan(source, kind, horizons, balanced_horizon, balance_window)
|
|
412
|
+
out = source.aggregate(
|
|
413
|
+
kind,
|
|
414
|
+
horizons=horizons,
|
|
415
|
+
balanced_horizon=balanced_horizon,
|
|
416
|
+
balance_window=balance_window,
|
|
417
|
+
)
|
|
418
|
+
mask = np.any((p.weights != 0) & self._suppression_mask[None, :], axis=1)
|
|
419
|
+
return out._apply_suppression(mask, self.diagnostics["suppression_threshold"])
|
|
420
|
+
p = plan(self, kind, horizons, balanced_horizon, balance_window)
|
|
421
|
+
A = p.weights
|
|
422
|
+
B = np.zeros_like(A)
|
|
423
|
+
for j in range(len(p.names)):
|
|
424
|
+
ix = A[j] != 0
|
|
425
|
+
B[j, ix] = (self.beta[ix] - A[j] @ self.beta) / self.counts[ix].sum()
|
|
426
|
+
if self._bjs_inference is not None:
|
|
427
|
+
V = self._bjs_inference.covariance(A)
|
|
428
|
+
if self.vcov_type == "CRV1":
|
|
429
|
+
V *= self.n_units / (self.n_units - 1)
|
|
430
|
+
else:
|
|
431
|
+
V = A @ self.vcov @ A.T
|
|
432
|
+
if self._share_cross is not None:
|
|
433
|
+
cross = A @ self._share_cross @ B.T
|
|
434
|
+
V += cross + cross.T + B @ self._share_cov @ B.T
|
|
435
|
+
draws = None
|
|
436
|
+
if self._multiplier_draws is not None:
|
|
437
|
+
draws = self._multiplier_draws @ A.T
|
|
438
|
+
if self._multiplier_shares is not None:
|
|
439
|
+
draws += self._multiplier_shares @ B.T
|
|
440
|
+
labels = labels_of(self)
|
|
441
|
+
public_plan = present_plan(p.diagnostics, labels)
|
|
442
|
+
return DIDResult(
|
|
443
|
+
fml=self.fml,
|
|
444
|
+
depvar=self.depvar,
|
|
445
|
+
coefnames=[label_name(n, labels) for n in p.names] if labels.step != 1 else p.names,
|
|
446
|
+
beta=A @ self.beta,
|
|
447
|
+
vcov=V,
|
|
448
|
+
vcov_type=self.vcov_type,
|
|
449
|
+
df_t=self.df_t,
|
|
450
|
+
n_obs=self.n_obs,
|
|
451
|
+
n_clusters=self.n_clusters,
|
|
452
|
+
estimator=self.estimator,
|
|
453
|
+
cells=[],
|
|
454
|
+
counts=np.array([]),
|
|
455
|
+
n_units=self.n_units,
|
|
456
|
+
_balance_table=(
|
|
457
|
+
self._balance_table.join(
|
|
458
|
+
pl.DataFrame(
|
|
459
|
+
{
|
|
460
|
+
"cohort": [g for j, (g, t) in enumerate(self.cells) if np.any(A[:, j])],
|
|
461
|
+
"time": [t for j, (g, t) in enumerate(self.cells) if np.any(A[:, j])],
|
|
462
|
+
}
|
|
463
|
+
),
|
|
464
|
+
on=["cohort", "time"],
|
|
465
|
+
how="inner",
|
|
466
|
+
)
|
|
467
|
+
if self._balance_table is not None
|
|
468
|
+
else None
|
|
469
|
+
),
|
|
470
|
+
_multiplier_draws=draws,
|
|
471
|
+
_bjs_placebos=self._bjs_placebos,
|
|
472
|
+
_ess_table=(
|
|
473
|
+
p.contributors.select(pl.col("aggregate").alias("coefficient"), "cell").join(
|
|
474
|
+
self._ess_table.drop("coefficient"), on="cell", how="inner"
|
|
475
|
+
)
|
|
476
|
+
if self._ess_table is not None
|
|
477
|
+
else None
|
|
478
|
+
),
|
|
479
|
+
_internal={**internal(self), **p.diagnostics},
|
|
480
|
+
_periods=self._periods,
|
|
481
|
+
diagnostics={
|
|
482
|
+
**self.diagnostics,
|
|
483
|
+
**public_plan,
|
|
484
|
+
"warnings": list(self.diagnostics.get("warnings", []))
|
|
485
|
+
+ (
|
|
486
|
+
[
|
|
487
|
+
"Cohort contributors change across event times; inspect composition() or select a balanced window."
|
|
488
|
+
]
|
|
489
|
+
if p.kind == "event_time" and p.diagnostics["cohort_composition_changes"]
|
|
490
|
+
else ["Cohort weights vary across event times; inspect composition()."]
|
|
491
|
+
if p.kind == "event_time" and p.diagnostics["cohort_weights_change"]
|
|
492
|
+
else []
|
|
493
|
+
),
|
|
494
|
+
},
|
|
495
|
+
_contributor_table=p.contributors,
|
|
496
|
+
_composition_table=p.composition,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
def _summary_fields(self):
|
|
500
|
+
return {
|
|
501
|
+
**super()._summary_fields(),
|
|
502
|
+
"estimator": PUBLIC_ESTIMATORS.get(self.estimator, self.estimator),
|
|
503
|
+
"n_units": self.n_units,
|
|
504
|
+
"diagnostics": self.diagnostics,
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
def _summary_lines(self):
|
|
508
|
+
return [
|
|
509
|
+
f"estimator: {PUBLIC_ESTIMATORS.get(self.estimator, self.estimator)}; "
|
|
510
|
+
f"units: {self.n_units:,}",
|
|
511
|
+
*super()._summary_lines(),
|
|
512
|
+
self.diagnostics.get("inference", ""),
|
|
513
|
+
*(
|
|
514
|
+
[
|
|
515
|
+
f"suppressed coefficients: {len(self.diagnostics['suppressed_coefficients'])}; original aggregation weights retained"
|
|
516
|
+
]
|
|
517
|
+
if self.diagnostics.get("suppressed_coefficients")
|
|
518
|
+
else []
|
|
519
|
+
),
|
|
520
|
+
*self.diagnostics.get("warnings", []),
|
|
521
|
+
]
|
diff_stream/simulate.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Reproducible heterogeneous staggered-adoption panels."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import polars as pl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def simulate_panel(n_units=1000, periods=8, *, seed=42, noise=1.0):
|
|
8
|
+
"""Balanced panel with never-treated units, serial dependence and effects
|
|
9
|
+
varying by cohort and exposure. Returns id, time, first_treat, y, truth.
|
|
10
|
+
"""
|
|
11
|
+
if n_units < 10 or periods < 3:
|
|
12
|
+
raise ValueError("use at least 10 units and 3 periods")
|
|
13
|
+
rng = np.random.default_rng(seed)
|
|
14
|
+
g = rng.choice(np.r_[0, np.arange(2, periods + 1)], n_units)
|
|
15
|
+
g[:2] = [0, 2]
|
|
16
|
+
t = np.arange(1, periods + 1)
|
|
17
|
+
tau = np.where(
|
|
18
|
+
(g[:, None] > 0) & (t >= g[:, None]), 0.5 + 0.15 * g[:, None] + 0.25 * (t - g[:, None]), 0
|
|
19
|
+
)
|
|
20
|
+
err = rng.normal(size=(n_units, periods))
|
|
21
|
+
for j in range(1, periods):
|
|
22
|
+
err[:, j] += 0.4 * err[:, j - 1]
|
|
23
|
+
y = rng.normal(size=(n_units, 1)) + 0.1 * t + tau + noise * err
|
|
24
|
+
return pl.DataFrame(
|
|
25
|
+
{
|
|
26
|
+
"id": np.repeat(np.arange(n_units), periods),
|
|
27
|
+
"time": np.tile(t, n_units),
|
|
28
|
+
"first_treat": np.repeat(g, periods),
|
|
29
|
+
"y": y.ravel(),
|
|
30
|
+
"truth": tau.ravel(),
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def simulate_conditional_panel(n_units=1200, periods=5, *, seed=871, time_varying=True):
|
|
36
|
+
"""Confounded staggered panel with numeric and categorical covariates.
|
|
37
|
+
|
|
38
|
+
Cohorts depend on unit-level x/z; effects differ by cohort and exposure.
|
|
39
|
+
Time-varying x exercises the earlier-comparison-period convention.
|
|
40
|
+
"""
|
|
41
|
+
if n_units < 30 or periods < 4:
|
|
42
|
+
raise ValueError("use at least 30 units and four periods")
|
|
43
|
+
rng = np.random.default_rng(seed)
|
|
44
|
+
x0 = rng.normal(size=n_units)
|
|
45
|
+
z = rng.integers(0, 2, size=n_units)
|
|
46
|
+
category = rng.choice(["a", "b", "c"], size=n_units)
|
|
47
|
+
choices = np.array([0, periods - 2, periods - 1, periods])
|
|
48
|
+
logits = np.column_stack(
|
|
49
|
+
[np.zeros(n_units), 0.35 * x0 - 0.2 * z, -0.3 * x0 + 0.3 * z, 0.2 * x0 + 0.15 * z]
|
|
50
|
+
)
|
|
51
|
+
prob = np.exp(logits - logits.max(axis=1, keepdims=True))
|
|
52
|
+
prob /= prob.sum(axis=1, keepdims=True)
|
|
53
|
+
g = choices[(rng.random(n_units)[:, None] > prob.cumsum(axis=1)).sum(axis=1)]
|
|
54
|
+
g[:4] = choices
|
|
55
|
+
t = np.arange(1, periods + 1)
|
|
56
|
+
x = np.repeat(x0[:, None], periods, axis=1)
|
|
57
|
+
if time_varying:
|
|
58
|
+
x += 0.1 * t + 0.1 * rng.normal(size=x.shape)
|
|
59
|
+
tau = np.where(
|
|
60
|
+
(g[:, None] > 0) & (t >= g[:, None]), 0.5 + 0.1 * g[:, None] + 0.2 * (t - g[:, None]), 0
|
|
61
|
+
)
|
|
62
|
+
y0 = (
|
|
63
|
+
rng.normal(size=(n_units, 1))
|
|
64
|
+
+ 0.2 * t
|
|
65
|
+
+ 0.3 * t * x0[:, None]
|
|
66
|
+
+ 0.15 * t * z[:, None]
|
|
67
|
+
+ 0.4 * x
|
|
68
|
+
)
|
|
69
|
+
y = y0 + tau + rng.normal(size=x.shape)
|
|
70
|
+
return pl.DataFrame(
|
|
71
|
+
{
|
|
72
|
+
"id": np.repeat(np.arange(n_units), periods),
|
|
73
|
+
"time": np.tile(t, n_units),
|
|
74
|
+
"first_treat": np.repeat(g, periods),
|
|
75
|
+
"y": y.ravel(),
|
|
76
|
+
"x": x.ravel(),
|
|
77
|
+
"z": np.repeat(z, periods),
|
|
78
|
+
"category": np.repeat(category, periods),
|
|
79
|
+
}
|
|
80
|
+
)
|