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