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,225 @@
|
|
|
1
|
+
"""DiD aggregation maps, cohort-support rules and composition tables.
|
|
2
|
+
|
|
3
|
+
Only cell/cohort-sized metadata is used. No source reread or unit-ID table is
|
|
4
|
+
needed to inspect contributions, even for a disk-backed BJS result.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from numbers import Integral
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import polars as pl
|
|
12
|
+
|
|
13
|
+
from ._present import internal
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def integer(value):
|
|
17
|
+
return isinstance(value, Integral) and not isinstance(value, (bool, np.bool_))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def period_type(event_time, anticipation):
|
|
21
|
+
if event_time >= 0:
|
|
22
|
+
return "post_treatment"
|
|
23
|
+
return "anticipation" if event_time >= -anticipation else "pre_treatment"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class AggregationPlan:
|
|
28
|
+
kind: str
|
|
29
|
+
names: list
|
|
30
|
+
weights: np.ndarray
|
|
31
|
+
contributors: pl.DataFrame
|
|
32
|
+
composition: pl.DataFrame
|
|
33
|
+
diagnostics: dict
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def plan(result, kind, horizons, balanced_horizon, balance_window):
|
|
37
|
+
if not result.cells:
|
|
38
|
+
raise ValueError("aggregate the original group-time result")
|
|
39
|
+
if kind not in ("event_time", "overall", "cohort", "calendar"):
|
|
40
|
+
raise ValueError("kind must be 'event_time', 'overall', 'cohort' or 'calendar'")
|
|
41
|
+
if balanced_horizon is not None and balance_window is not None:
|
|
42
|
+
raise ValueError("choose balanced_horizon or balance_window, not both")
|
|
43
|
+
if (balanced_horizon is not None or balance_window is not None) and kind != "event_time":
|
|
44
|
+
raise ValueError("balancing options are available only for event-time aggregation")
|
|
45
|
+
g = np.array([g for g, t in result.cells])
|
|
46
|
+
t = np.array([t for g, t in result.cells])
|
|
47
|
+
h = t - g
|
|
48
|
+
anticipation = internal(result).get("anticipation", 0)
|
|
49
|
+
cohorts = sorted(
|
|
50
|
+
set(g.tolist()) | {int(k) for k in internal(result).get("cohort_sizes", {}) if int(k) != 0}
|
|
51
|
+
)
|
|
52
|
+
select = np.ones(len(h), dtype=bool)
|
|
53
|
+
requested = None
|
|
54
|
+
if horizons is not None:
|
|
55
|
+
try:
|
|
56
|
+
requested = list(horizons)
|
|
57
|
+
except TypeError as exc:
|
|
58
|
+
raise ValueError("horizons must be an iterable of integer event times") from exc
|
|
59
|
+
if not all(integer(e) for e in requested):
|
|
60
|
+
raise ValueError("horizons must contain integer event times")
|
|
61
|
+
requested = sorted(set(int(e) for e in requested))
|
|
62
|
+
last = internal(result).get("time_max", int(t.max()))
|
|
63
|
+
first = internal(result).get(
|
|
64
|
+
"time_min", last - internal(result).get("periods", int(t.max() - t.min() + 1)) + 1
|
|
65
|
+
)
|
|
66
|
+
span = last - first
|
|
67
|
+
excluded = []
|
|
68
|
+
if balanced_horizon is not None:
|
|
69
|
+
if not integer(balanced_horizon) or balanced_horizon < 0:
|
|
70
|
+
raise ValueError("balanced_horizon must be a nonnegative integer")
|
|
71
|
+
balanced_horizon = int(balanced_horizon)
|
|
72
|
+
if balanced_horizon > span:
|
|
73
|
+
raise ValueError("no cohorts support the requested balanced window")
|
|
74
|
+
required = set(range(balanced_horizon + 1))
|
|
75
|
+
low, high = balanced_horizon - span, balanced_horizon
|
|
76
|
+
elif balance_window is not None:
|
|
77
|
+
try:
|
|
78
|
+
bounds = list(balance_window)
|
|
79
|
+
except TypeError as exc:
|
|
80
|
+
raise ValueError("balance_window must be a pair of integer event times") from exc
|
|
81
|
+
if len(bounds) != 2 or not all(integer(e) for e in bounds) or bounds[0] > bounds[1]:
|
|
82
|
+
raise ValueError("balance_window must be an ordered pair of integer event times")
|
|
83
|
+
low, high = (int(e) for e in bounds)
|
|
84
|
+
balance_window = [low, high]
|
|
85
|
+
if high - low > span:
|
|
86
|
+
raise ValueError("no cohorts support the requested balanced window")
|
|
87
|
+
if result.estimator == "bjs" and low < -anticipation:
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"BJS balance_window must be post-treatment"
|
|
90
|
+
if anticipation == 0
|
|
91
|
+
else "BJS balance_window must start at event time >= -anticipation"
|
|
92
|
+
)
|
|
93
|
+
required = set(range(low, high + 1))
|
|
94
|
+
# A normalized baseline is not an estimated cell or a support failure.
|
|
95
|
+
if (
|
|
96
|
+
result.estimator == "sunab"
|
|
97
|
+
or (
|
|
98
|
+
result.estimator == "etwfe"
|
|
99
|
+
and internal(result).get("control_group") == "nevertreated"
|
|
100
|
+
)
|
|
101
|
+
or (result.estimator == "cs" and internal(result).get("base_period") == "universal")
|
|
102
|
+
):
|
|
103
|
+
required.discard(-anticipation - 1)
|
|
104
|
+
else:
|
|
105
|
+
required = None
|
|
106
|
+
if required is not None:
|
|
107
|
+
keep = []
|
|
108
|
+
for cg in cohorts:
|
|
109
|
+
missing = sorted(required - set(h[g == cg].tolist()))
|
|
110
|
+
if missing:
|
|
111
|
+
excluded.append(
|
|
112
|
+
dict(
|
|
113
|
+
cohort=cg,
|
|
114
|
+
reason="missing required event-time support",
|
|
115
|
+
missing_horizons=missing,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
keep.append(cg)
|
|
120
|
+
select &= np.isin(g, keep) & (h >= low) & (h <= high)
|
|
121
|
+
if requested is not None:
|
|
122
|
+
select &= np.isin(h, requested)
|
|
123
|
+
if kind != "event_time":
|
|
124
|
+
select &= h >= 0
|
|
125
|
+
if not select.any():
|
|
126
|
+
raise ValueError("no supported cells in the requested aggregation")
|
|
127
|
+
values = {"event_time": h, "cohort": g, "calendar": t}.get(kind)
|
|
128
|
+
levels = sorted(set(values[select].tolist())) if values is not None else ["ATT"]
|
|
129
|
+
prefix = {"event_time": "event", "cohort": "cohort", "calendar": "calendar"}.get(kind)
|
|
130
|
+
names = ["ATT" if kind == "overall" else f"{prefix}::{v}" for v in levels]
|
|
131
|
+
A = np.zeros((len(levels), len(h)))
|
|
132
|
+
for j, level in enumerate(levels):
|
|
133
|
+
mask = select if values is None else select & (values == level)
|
|
134
|
+
A[j, mask] = result.counts[mask] / result.counts[mask].sum()
|
|
135
|
+
contributing = sorted(set(g[select].tolist()))
|
|
136
|
+
excluded_g = {d["cohort"] for d in excluded}
|
|
137
|
+
excluded += [
|
|
138
|
+
dict(cohort=cg, reason="no cells in requested aggregation", missing_horizons=[])
|
|
139
|
+
for cg in cohorts
|
|
140
|
+
if cg not in contributing and cg not in excluded_g
|
|
141
|
+
]
|
|
142
|
+
observed = result.observed_counts
|
|
143
|
+
populations = (
|
|
144
|
+
result.cohort_counts
|
|
145
|
+
if result.cohort_counts is not None
|
|
146
|
+
else np.array(
|
|
147
|
+
[
|
|
148
|
+
internal(result).get("cohort_sizes", {}).get(str(cg), result.counts[j])
|
|
149
|
+
for j, cg in enumerate(g)
|
|
150
|
+
]
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
rows, summaries = [], []
|
|
154
|
+
previous = None
|
|
155
|
+
for j, name in enumerate(names):
|
|
156
|
+
ix = np.flatnonzero(A[j])
|
|
157
|
+
cg = sorted(set(g[ix].tolist()))
|
|
158
|
+
weights = [float(A[j, g == k].sum()) for k in cg]
|
|
159
|
+
current = dict(zip(cg, weights, strict=True))
|
|
160
|
+
changed = previous is not None and set(current) != set(previous)
|
|
161
|
+
weights_changed = previous is not None and (
|
|
162
|
+
changed
|
|
163
|
+
or any(not np.isclose(w, previous[k], atol=1e-12, rtol=0) for k, w in current.items())
|
|
164
|
+
)
|
|
165
|
+
summaries.append(
|
|
166
|
+
dict(
|
|
167
|
+
aggregate=name,
|
|
168
|
+
n_cohorts=len(cg),
|
|
169
|
+
n_cells=len(ix),
|
|
170
|
+
cohorts=cg,
|
|
171
|
+
cohort_weights=weights,
|
|
172
|
+
cohort_units=int(sum(populations[next(k for k in ix if g[k] == c)] for c in cg)),
|
|
173
|
+
observed_unit_periods=int(observed[ix].sum()) if observed is not None else None,
|
|
174
|
+
target_weight_total=float(
|
|
175
|
+
result.counts[ix].sum() * internal(result).get("weight_scale", 1.0)
|
|
176
|
+
),
|
|
177
|
+
cohorts_changed=changed,
|
|
178
|
+
cohort_weights_changed=weights_changed,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
previous = current
|
|
182
|
+
for k in ix:
|
|
183
|
+
rows.append(
|
|
184
|
+
dict(
|
|
185
|
+
aggregate=name,
|
|
186
|
+
cell=result.coefnames[k],
|
|
187
|
+
cohort=int(g[k]),
|
|
188
|
+
time=int(t[k]),
|
|
189
|
+
event_time=int(h[k]),
|
|
190
|
+
period_type=period_type(int(h[k]), anticipation),
|
|
191
|
+
estimate=float(result.beta[k]),
|
|
192
|
+
weight=float(A[j, k]),
|
|
193
|
+
weighted_estimate=float(A[j, k] * result.beta[k]),
|
|
194
|
+
weight_units=int(populations[k] if result.estimator == "cs" else observed[k])
|
|
195
|
+
if observed is not None
|
|
196
|
+
else int(result.counts[k]),
|
|
197
|
+
weight_total=float(
|
|
198
|
+
result.counts[k] * internal(result).get("weight_scale", 1.0)
|
|
199
|
+
),
|
|
200
|
+
observed_units=int(observed[k]) if observed is not None else None,
|
|
201
|
+
cohort_units=int(populations[k]),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
composition = pl.DataFrame(summaries)
|
|
205
|
+
diagnostics = dict(
|
|
206
|
+
aggregation=kind,
|
|
207
|
+
horizons=requested,
|
|
208
|
+
balanced_horizon=balanced_horizon,
|
|
209
|
+
balance_window=balance_window,
|
|
210
|
+
contributing_cohorts=contributing,
|
|
211
|
+
excluded_cohorts=excluded,
|
|
212
|
+
cohort_composition_changes=bool(composition["cohorts_changed"].any()),
|
|
213
|
+
cohort_weights_change=bool(composition["cohort_weights_changed"].any()),
|
|
214
|
+
weight_basis=(
|
|
215
|
+
"retained cohort sampling-weight totals"
|
|
216
|
+
if result.estimator == "cs"
|
|
217
|
+
else "observed cohort-time sampling-weight totals"
|
|
218
|
+
)
|
|
219
|
+
if internal(result).get("sampling_weights") is not None
|
|
220
|
+
else "retained cohort units"
|
|
221
|
+
if result.estimator == "cs"
|
|
222
|
+
else "observed cohort-time units",
|
|
223
|
+
observed_counts_available=result.observed_counts is not None,
|
|
224
|
+
)
|
|
225
|
+
return AggregationPlan(kind, names, A, pl.DataFrame(rows), composition, diagnostics)
|