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
diff_stream/balance.py ADDED
@@ -0,0 +1,149 @@
1
+ """CS-specific comparison selection around shared weighted balance kernels.
2
+
3
+ Optional one-comparison-at-a-time staging avoids retaining all unit covariates
4
+ or propensity scores on a result. Regression adjustment does not reweight controls.
5
+ """
6
+
7
+ from pathlib import Path
8
+ from tempfile import TemporaryDirectory
9
+
10
+ import numpy as np
11
+ import polars as pl
12
+ import pyarrow.parquet as pq
13
+ from hdfe_stream.core.balance import effective_sizes, weighted_balance
14
+
15
+ from ._core.contrasts import att_parts
16
+
17
+
18
+ class CSBalanceCollector:
19
+ def __init__(self, store, ecdf):
20
+ self.store, self.ecdf = store, ecdf
21
+ names = store.design.names if store.design is not None else []
22
+ self.keep = [j for j, name in enumerate(names) if name != "Intercept"]
23
+ if not self.keep:
24
+ raise ValueError("covariate_balance=True requires covariates in the formula")
25
+ self.names = [names[j] for j in self.keep]
26
+ self.columns = [f"__x{j}" for j in range(len(self.keep))]
27
+ self.tables = []
28
+ self.source_passes = 0
29
+
30
+ def compute(self, rows, adjusted):
31
+ """Shared kernels consume a lazy one-comparison temporary Parquet file."""
32
+ with TemporaryDirectory(prefix="cs-balance-", dir=self.store.root) as run:
33
+ path = Path(run) / "rows.parquet"
34
+ writer = None
35
+ try:
36
+ for X, d, w, after in rows():
37
+ if not len(d):
38
+ continue
39
+ self.store._guard(32e6 + X.nbytes * 4)
40
+ frame = pl.DataFrame(
41
+ {
42
+ **{
43
+ name: X[:, k]
44
+ for name, k in zip(self.columns, self.keep, strict=True)
45
+ },
46
+ "__d": d,
47
+ "__before": w,
48
+ "__after": after,
49
+ }
50
+ )
51
+ table = frame.to_arrow()
52
+ if writer is None:
53
+ writer = pq.ParquetWriter(path, table.schema)
54
+ writer.write_table(table, row_group_size=self.store.read_rows)
55
+ finally:
56
+ if writer is not None:
57
+ writer.close()
58
+ self.source_passes += 1
59
+ if writer is None:
60
+ raise ValueError("CS balance comparison has no observations")
61
+ lf = pl.scan_parquet(path, low_memory=True)
62
+ before = weighted_balance(lf, "__d", self.columns, "__before", ecdf=self.ecdf)
63
+ scales = {
64
+ row["term"]: float(np.sqrt((row["var1"] + row["var0"]) / 2))
65
+ for row in before.iter_rows(named=True)
66
+ }
67
+ tables = []
68
+ for stage, table, weight in [
69
+ ("before", before, "__before"),
70
+ *([("after", None, "__after")] if adjusted else []),
71
+ ]:
72
+ if table is None:
73
+ table = weighted_balance(
74
+ lf, "__d", self.columns, weight, scale=scales, ecdf=self.ecdf
75
+ )
76
+ sizes = effective_sizes(lf, "__d", weight)
77
+ tables.append(
78
+ table.with_columns(
79
+ pl.col("term").replace_strict(
80
+ dict(zip(self.columns, self.names, strict=True))
81
+ ),
82
+ pl.lit(stage).alias("stage"),
83
+ pl.lit(sizes.n1, dtype=pl.Int64).alias("n_treated"),
84
+ pl.lit(sizes.n0, dtype=pl.Int64).alias("n_controls"),
85
+ pl.lit(sizes.ess1).alias("ess_treated"),
86
+ pl.lit(sizes.ess0).alias("ess_controls"),
87
+ )
88
+ )
89
+ return pl.concat(tables)
90
+
91
+ def balanced(self, comparisons, fitted, cells, batches):
92
+ for c, f in zip(comparisons, fitted, strict=True):
93
+
94
+ def rows(c=c, f=f):
95
+ for raw in batches():
96
+ mask, X, d, _ = c.select(raw)
97
+ sw = c.weights(raw, mask)
98
+ wc = (
99
+ att_parts(X, d, f.nuisance.beta, f.control_max)[1]
100
+ if f.method != "reg"
101
+ else np.ones(len(d))
102
+ )
103
+ yield X, d, sw, sw * np.where(d, 1, wc)
104
+
105
+ table = self.compute(rows, f.method != "reg")
106
+ for k, j in enumerate(c.cells):
107
+ self.tables.append(
108
+ table.with_columns(
109
+ pl.lit(c.cohort, dtype=pl.Int64).alias("cohort"),
110
+ pl.lit(cells[j][1], dtype=pl.Int64).alias("time"),
111
+ pl.lit(int(self.store.times[c.bases[k]]), dtype=pl.Int64).alias("base"),
112
+ pl.lit("comparison").alias("stratum"),
113
+ pl.lit(int(self.store.times[c.xperiod]), dtype=pl.Int64).alias(
114
+ "covariate_time"
115
+ ),
116
+ )
117
+ )
118
+
119
+ def unbalanced(self, comparisons, fitted, batches):
120
+ for c, f in zip(comparisons, fitted, strict=True):
121
+ for use_post, stratum in [(False, "pre"), (True, "post")]:
122
+
123
+ def rows(c=c, f=f, use_post=use_post):
124
+ for raw in batches():
125
+ unit, X, d, _, post = c.select(raw)
126
+ sw = c.weights(raw, unit, post)
127
+ wc = (
128
+ att_parts(X, d, f.system.beta, f.trim)[1]
129
+ if f.method != "reg"
130
+ else np.ones(len(d))
131
+ )
132
+ mask = post == use_post
133
+ yield X[mask], d[mask], sw[mask], (sw * np.where(d, 1, wc))[mask]
134
+
135
+ table = self.compute(rows, f.method != "reg")
136
+ self.tables.append(
137
+ table.with_columns(
138
+ pl.lit(c.cohort, dtype=pl.Int64).alias("cohort"),
139
+ pl.lit(int(self.store.times[c.target]), dtype=pl.Int64).alias("time"),
140
+ pl.lit(int(self.store.times[c.base]), dtype=pl.Int64).alias("base"),
141
+ pl.lit(stratum).alias("stratum"),
142
+ pl.lit(int(self.store.times[c.target if use_post else c.base])).alias(
143
+ "covariate_time"
144
+ ),
145
+ )
146
+ )
147
+
148
+ def table(self):
149
+ return pl.concat(self.tables).sort("cohort", "time", "stratum", "stage", "term")
diff_stream/bjs.py ADDED
@@ -0,0 +1,101 @@
1
+ """BJS smart residual averaging and cluster leave-out adjustments.
2
+
3
+ The implementation uses cohort-sized first/second moments. It implements the
4
+ published score algebra independently; no author implementation is vendored.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+
11
+
12
+ @dataclass
13
+ class BJSInference:
14
+ groups: np.ndarray
15
+ sizes: np.ndarray
16
+ times: np.ndarray
17
+ cells: list
18
+ means: np.ndarray
19
+ Z: np.ndarray
20
+ theta: np.ndarray
21
+ H: np.ndarray
22
+ scatter: np.ndarray
23
+ avgeffectsby: str
24
+ leaveout: bool
25
+ anticipation: int = 0
26
+
27
+ def covariance(self, contrasts, *, allow_unidentified=False):
28
+ """Covariance for contrasts of the original cell estimates.
29
+
30
+ Averaging is contrast-specific. Coarser residual groups therefore need
31
+ fresh smart averages, rather than a linear transform of the cell vcov.
32
+ """
33
+ A = np.atleast_2d(contrasts)
34
+ P, M = A.shape
35
+ T, G = len(self.times), len(self.groups)
36
+ weights = np.zeros((G, P, T))
37
+ labels = {}
38
+ cell_groups = []
39
+ for j, (g, t) in enumerate(self.cells):
40
+ k = int(np.searchsorted(self.groups, g))
41
+ weights[k, :, t - self.times[0]] = A[:, j] / self.sizes[k]
42
+ label = {"cohort_time": (g, t), "event_time": t - g, "cohort": g, "all": 0}[
43
+ self.avgeffectsby
44
+ ]
45
+ cell_groups.append(labels.setdefault(label, len(labels)))
46
+ R = len(labels)
47
+ residual_means = np.zeros((G, T))
48
+ denom, numer = np.zeros((P, R)), np.zeros((P, R))
49
+
50
+ def group_totals(k):
51
+ totals, effects = np.zeros((P, R)), np.zeros((P, R))
52
+ for j, (g, t) in enumerate(self.cells):
53
+ if g == self.groups[k]:
54
+ ti, r = t - self.times[0], cell_groups[j]
55
+ totals[:, r] += weights[k, :, ti]
56
+ effects[:, r] += weights[k, :, ti] * residual_means[k, ti]
57
+ return totals, effects
58
+
59
+ for k, g in enumerate(self.groups):
60
+ use = self.times < g - self.anticipation if g else np.ones(T, dtype=bool)
61
+ mu = self.means[k] - self.Z @ self.theta
62
+ residual_means[k] = mu - mu[use].mean()
63
+ totals, effects = group_totals(k)
64
+ denom += self.sizes[k] * totals**2
65
+ numer += self.sizes[k] * totals * effects
66
+ averages = np.divide(numer, denom, out=np.zeros_like(numer), where=denom > 0)
67
+ target_derivative = A @ self.H
68
+ V = np.zeros((P, P))
69
+ unidentified = np.zeros(P, dtype=bool)
70
+ for k, g in enumerate(self.groups):
71
+ use = self.times < g - self.anticipation if g else np.ones(T, dtype=bool)
72
+ totals, _ = group_totals(k)
73
+ leverage = np.divide(totals**2, denom, out=np.zeros_like(totals), where=denom > 0)
74
+ raw = weights[k].copy()
75
+ raw[:, use] = -target_derivative @ self.Z[use].T
76
+ center = np.zeros(P)
77
+ for j, (cg, t) in enumerate(self.cells):
78
+ if cg != g:
79
+ continue
80
+ ti, r = t - self.times[0], cell_groups[j]
81
+ w = weights[k, :, ti]
82
+ lev = leverage[:, r]
83
+ if self.leaveout:
84
+ invalid = (w != 0) & (lev >= 1 - 1e-12)
85
+ if np.any(invalid) and not allow_unidentified:
86
+ raise ValueError(
87
+ "BJS leave_out requires more than one contributing unit in each residual group; use a coarser residual_averaging"
88
+ )
89
+ unidentified |= invalid
90
+ raw[:, ti] = np.divide(w, 1 - lev, out=np.zeros_like(w), where=lev < 1 - 1e-12)
91
+ center += raw[:, ti] * averages[:, r]
92
+ # Scatter was formed after local elimination of unit intercepts.
93
+ # A PSD root avoids cancellation when residual variation is tiny.
94
+ values, vectors = np.linalg.eigh(self.scatter[k])
95
+ linear = raw @ (vectors * np.sqrt(np.maximum(values, 0)))
96
+ mean_score = raw @ residual_means[k] - center
97
+ V += linear @ linear.T + self.sizes[k] * np.outer(mean_score, mean_score)
98
+ V = (V + V.T) / 2
99
+ V[unidentified, :] = np.nan
100
+ V[:, unidentified] = np.nan
101
+ return V
@@ -0,0 +1,167 @@
1
+ """Separate clean-sample BJS lead regression with bounded unit elimination.
2
+
3
+ The authors' pre-trend exercise augments the untreated unit/time regression
4
+ with lead indicators. It does not average residuals from the post-effect fit.
5
+ No author code is copied; the regression is implemented via sufficient sums.
6
+ """
7
+
8
+ from copy import copy
9
+ from dataclasses import dataclass, field
10
+
11
+ import numpy as np
12
+ import polars as pl
13
+ from hdfe_stream.core.results_base import RegressionResult
14
+
15
+ from ._core.nuisance import solve_checked
16
+ from ._present import label_frame, labels_of
17
+
18
+
19
+ @dataclass(kw_only=True)
20
+ class BJSPlaceboResult(RegressionResult):
21
+ """Pooled clean lead coefficients from a separate untreated-sample WLS fit.
22
+
23
+ Standard coefficient/interval/Wald methods are inherited from the family.
24
+ There is no estimated joint covariance with the main treatment effects.
25
+ """
26
+
27
+ n_units: int
28
+ _support: pl.DataFrame = field(repr=False)
29
+ _internal: dict | None = field(default=None, repr=False)
30
+ _periods: object = field(default=None, repr=False)
31
+ estimator = "bjs_placebo"
32
+ cells = ()
33
+ _normal_inference = True
34
+
35
+ def support(self):
36
+ """Observed cohort/date support and original-scale weight totals."""
37
+ return label_frame(self._support, labels_of(self))
38
+
39
+ def pretrend_test(self, *, level="auto", horizons=None):
40
+ """Joint clean-lead Wald test; horizon selection does not refit the model."""
41
+ from .pretrend import pretrend_test
42
+
43
+ return pretrend_test(self, level=level, horizons=horizons)
44
+
45
+ def with_vcov(self, vcov):
46
+ if vcov not in ("CRV0", "CRV1"):
47
+ raise ValueError("vcov must be 'CRV0' or 'CRV1'")
48
+ factor = self.n_units / (self.n_units - 1)
49
+ ratio = (factor if vcov == "CRV1" else 1) / (factor if self.vcov_type == "CRV1" else 1)
50
+ out = copy(self)
51
+ out.vcov = self.vcov * ratio
52
+ out.vcov_type = vcov
53
+ out.all_vcovs = {vcov: (out.vcov, out.df_t)}
54
+ return out
55
+
56
+
57
+ def fit_placebos(store, pretrends, anticipation, *, fml, depvar, panel, vcov):
58
+ """Eliminate unit intercepts locally, solve time/lead coefficients jointly."""
59
+ if store.design is not None or not store.weights_time_invariant:
60
+ from .linear import fit_placebos as fit_rich_placebos
61
+
62
+ return fit_rich_placebos(
63
+ store, pretrends, anticipation, fml=fml, depvar=depvar, panel=panel, vcov=vcov
64
+ )
65
+ T, L = store.periods, pretrends
66
+ horizons = np.arange(-anticipation - L, -anticipation, dtype=np.int64)
67
+ rows = []
68
+ raw_scale = store.weight_scale / store.weight_normalizer
69
+ support = np.zeros(L, dtype=int)
70
+ for g, counts in sorted(store.actual_cell_counts.items()):
71
+ if not g:
72
+ continue
73
+ for ti, t in enumerate(store.times):
74
+ j = int(t) - g + anticipation + L
75
+ if 0 <= j < L and counts[ti]:
76
+ support[j] += counts[ti]
77
+ rows.append(
78
+ dict(
79
+ aggregate=f"event::{horizons[j]}",
80
+ cohort=int(g),
81
+ time=int(t),
82
+ event_time=int(horizons[j]),
83
+ observed_units=int(counts[ti]),
84
+ weight_total=float(store.actual_cell_weights[g][ti] * raw_scale),
85
+ )
86
+ )
87
+ if np.any(support == 0):
88
+ raise ValueError(
89
+ f"BJS requested clean placebo horizons are not observed: {horizons[support == 0].tolist()}; reduce pretrend_leads"
90
+ )
91
+ Z = np.eye(T)[:, 1:]
92
+ P = T - 1 + L
93
+ Q, q = np.zeros((P, P)), np.zeros(P)
94
+ context = "BJS clean placebo design is unidentified (missing earlier clean reference or disconnected/collinear design); reduce pretrend_leads"
95
+
96
+ def batches():
97
+ return store.batches(include_weights=True, actual_cohort=True)
98
+
99
+ def design(g):
100
+ lead = (store.times[:, None] - g == horizons) & (g != 0)
101
+ return np.column_stack([Z, lead])
102
+
103
+ def parts(g, values):
104
+ obs = np.isfinite(values)
105
+ use = obs & ((store.times < g - anticipation) if g else True)
106
+ n = use.sum(axis=1)
107
+ if np.any(n == 0):
108
+ raise ValueError(
109
+ "BJS requires at least one untreated observation per unit for clean placebo fitting"
110
+ )
111
+ return use, n, np.where(use, values, 0)
112
+
113
+ n_obs = 0
114
+ for g, y, w in batches():
115
+ for cg in np.unique(g):
116
+ mask = g == cg
117
+ use, n, raw = parts(cg, y[mask])
118
+ X, sw = design(cg), w[mask]
119
+ xm = use @ X / n[:, None]
120
+ centered = np.where(use, raw - raw.sum(axis=1)[:, None] / n[:, None], 0)
121
+ Q += X.T @ ((sw @ use)[:, None] * X) - xm.T @ ((sw * n)[:, None] * xm)
122
+ q += X.T @ (sw @ centered)
123
+ n_obs += int(n.sum())
124
+ coefficients = solve_checked(Q, q, context)
125
+ H = solve_checked(Q, np.eye(P)[:, -L:], context)
126
+ V = np.zeros((L, L))
127
+ for g, y, w in batches():
128
+ scores = np.zeros((len(g), L))
129
+ for cg in np.unique(g):
130
+ mask = g == cg
131
+ use, n, raw = parts(cg, y[mask])
132
+ X = design(cg)
133
+ residual = np.where(use, raw - X @ coefficients, 0)
134
+ residual = np.where(use, residual - residual.sum(axis=1)[:, None] / n[:, None], 0)
135
+ scores[mask] = w[mask, None] * (residual @ X @ H)
136
+ V += scores.T @ scores
137
+ factor = store.n_units / (store.n_units - 1) if vcov == "CRV1" else 1.0
138
+ V *= factor
139
+ return BJSPlaceboResult(
140
+ fml=fml,
141
+ depvar=depvar,
142
+ coefnames=[f"event::{h}" for h in horizons],
143
+ beta=coefficients[-L:],
144
+ vcov=(V + V.T) / 2,
145
+ vcov_type=vcov,
146
+ df_t=store.n_units - 1,
147
+ all_vcovs={vcov: (V, store.n_units - 1)},
148
+ n_obs=n_obs,
149
+ n_clusters={panel[0]: store.n_units},
150
+ n_units=store.n_units,
151
+ weights=None,
152
+ _support=pl.DataFrame(rows),
153
+ diagnostics=dict(
154
+ aggregation="event_time",
155
+ anticipation=anticipation,
156
+ reference_event_time=None,
157
+ fitted_horizons=horizons.tolist(),
158
+ pretrends=L,
159
+ base_period="all other clean observations; no fixed event-time reference",
160
+ inference="separate clean-sample WLS; unit-clustered analytic scores",
161
+ cohort_sizes={str(g): int(n) for g, n in store.actual_gc.items()},
162
+ reference_observations=n_obs - int(support.sum()),
163
+ normalized_future_cohorts="original timing retained for placebo indicators",
164
+ post_effect_covariance="not estimated",
165
+ warnings=[],
166
+ ),
167
+ )