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,68 @@
1
+ """Joint covariance of cell scores and cohort-share scores, by cohort.
2
+
3
+ The CS covariance sums, over units i, the outer products of [scores_i, share_i]
4
+ where share_i = anchor_i * (e_{g_i} - p): e_g marks the cells of cohort g and p
5
+ is a constant vector. Two facts make this cheaper than one dense product per
6
+ batch (ClusterScoreAccumulator), with the same sums:
7
+
8
+ - A unit's scores are zero outside the cells its cohort enters (its own cells,
9
+ and those where it is a control), so each cohort's rows add a dense product
10
+ only into those columns. Only never-treated units touch every column.
11
+ - The share terms depend on a unit only through its cohort and anchor, so the
12
+ share and score-share blocks need just the per-cohort sums of anchor**2 and
13
+ of anchor * scores.
14
+
15
+ With many cohorts and periods (hundreds of cells) this removes most of the
16
+ work; with few cells it costs about the same as the dense product.
17
+ """
18
+
19
+ import numpy as np
20
+
21
+
22
+ class CohortScoreCovariance:
23
+ """Accumulate V = sum s s', cross = sum s h', shares = sum h h' over units.
24
+
25
+ ``cell_cohorts`` holds each cell's cohort; ``center`` is p (zeros for an
26
+ uncentered indicator share).
27
+ """
28
+
29
+ def __init__(self, cell_cohorts, center=None):
30
+ self.cell_cohorts = np.asarray(cell_cohorts)
31
+ M = len(self.cell_cohorts)
32
+ self.center = np.zeros(M) if center is None else np.asarray(center, dtype=float)
33
+ self.V = np.zeros((M, M))
34
+ self._weighted = {} # cohort -> sum of anchor * scores (M,)
35
+ self._square = {} # cohort -> sum of anchor**2
36
+ self.n_clusters = 0
37
+
38
+ def add(self, g, scores, anchor=None):
39
+ """Add one row per unit: cohort labels, cell scores and share anchors."""
40
+ anchor = np.ones(len(g)) if anchor is None else anchor
41
+ order = np.argsort(g, kind="stable")
42
+ labels = g[order]
43
+ starts = np.r_[0, np.flatnonzero(labels[1:] != labels[:-1]) + 1, len(labels)]
44
+ M = self.V.shape[0]
45
+ for a, b in zip(starts[:-1], starts[1:], strict=True):
46
+ rows = order[a:b]
47
+ k = labels[a].item()
48
+ S = scores[rows]
49
+ used = np.flatnonzero(np.any(S != 0, axis=0))
50
+ if len(used) == M:
51
+ self.V += S.T @ S
52
+ elif len(used):
53
+ block = S[:, used]
54
+ self.V[np.ix_(used, used)] += block.T @ block
55
+ w = anchor[rows]
56
+ self._weighted[k] = self._weighted.get(k, 0.0) + w @ S
57
+ self._square[k] = self._square.get(k, 0.0) + float(w @ w)
58
+ self.n_clusters += len(g)
59
+
60
+ def blocks(self):
61
+ """(V, cross, shares): the blocks of the joint [scores, share] sums."""
62
+ M = len(self.cell_cohorts)
63
+ cross, shares = np.zeros((M, M)), np.zeros((M, M))
64
+ for k, weighted in self._weighted.items():
65
+ h = (self.cell_cohorts == k) - self.center
66
+ cross += np.outer(weighted, h)
67
+ shares += self._square[k] * np.outer(h, h)
68
+ return self.V, cross, shares
diff_stream/cs.py ADDED
@@ -0,0 +1,197 @@
1
+ """CS comparison populations and earlier-period covariate conventions."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+
7
+ from ._core import cs_kernels
8
+ from ._core.contrasts import fit_contrasts
9
+ from ._core.nuisance import fit_systems
10
+ from .cohort_scores import CohortScoreCovariance
11
+
12
+
13
+ @dataclass
14
+ class Comparison:
15
+ cohort: int
16
+ controls: tuple
17
+ xperiod: int
18
+ cells: list
19
+ targets: np.ndarray
20
+ bases: np.ndarray
21
+ weightperiod: int = 0
22
+ # members[label] marks the cohort and its controls; cohort labels are
23
+ # nonnegative integers below len(members).
24
+ members: np.ndarray = None
25
+
26
+ def weights(self, raw, mask):
27
+ w = raw[3]
28
+ return w[mask, self.weightperiod] if w.ndim == 2 else w[mask]
29
+
30
+ def select(self, raw):
31
+ g, y, x = raw[:3]
32
+ if self.members is None:
33
+ mask = (g == self.cohort) | np.isin(g, self.controls)
34
+ else:
35
+ mask = self.members[g]
36
+ rows = np.flatnonzero(mask)[:, None]
37
+ return (
38
+ mask,
39
+ x[rows[:, 0], self.xperiod, :],
40
+ g[rows[:, 0]] == self.cohort,
41
+ y[rows, self.targets] - y[rows, self.bases],
42
+ )
43
+
44
+ def __call__(self, raw):
45
+ mask, X, d, Y = self.select(raw)
46
+ return (X, d, Y, self.weights(raw, mask)) if len(raw) == 4 else (X, d, Y)
47
+
48
+
49
+ # The fit makes about ten passes over the staged units. When the decoded batches
50
+ # take at most this share of the memory left they are kept after the first pass.
51
+ RETAIN_SHARE = 0.1
52
+
53
+
54
+ def balanced_bytes(store, width):
55
+ """Memory of all decoded batches of a balanced panel: outcomes, covariates, weights."""
56
+ per_period = 8 * (1 + width + (0 if store.weights_time_invariant else 1))
57
+ return store.n_units * store.periods * per_period
58
+
59
+
60
+ class RetainedBatches:
61
+ """Calls to ``make()`` that keep their batches after the first complete pass."""
62
+
63
+ def __init__(self, make, retain):
64
+ self.make, self.retain, self.items = make, retain, None
65
+
66
+ def __call__(self):
67
+ if self.items is not None:
68
+ return iter(self.items)
69
+ return self._first() if self.retain else self.make()
70
+
71
+ def _first(self):
72
+ items = []
73
+ for raw in self.make():
74
+ items.append(raw)
75
+ yield raw
76
+ self.items = items
77
+
78
+
79
+ def fit_cs(
80
+ store,
81
+ groups,
82
+ cells,
83
+ bases,
84
+ controls,
85
+ method,
86
+ trim_level,
87
+ multiplier=None,
88
+ *,
89
+ balance_collector=None,
90
+ ):
91
+ """Fit cached comparison systems, then assemble cross-cell unit covariance."""
92
+ comparisons = {}
93
+ for j, (g, t) in enumerate(cells):
94
+ controls_g = tuple(groups[k] for k in np.flatnonzero(controls[j]))
95
+ tindex = t - store.times[0]
96
+ xb = 0 if store.x_time_invariant else min(tindex, int(bases[j]))
97
+ wp = min(tindex, int(bases[j])) if not store.weights_time_invariant else 0
98
+ key = (g, controls_g, xb, wp)
99
+ if key not in comparisons:
100
+ comparisons[key] = [[], [], []]
101
+ comparisons[key][0].append(j)
102
+ comparisons[key][1].append(tindex)
103
+ comparisons[key][2].append(bases[j])
104
+ size = max(groups) + 1
105
+
106
+ def members(g, c):
107
+ table = np.zeros(size, dtype=bool)
108
+ table[[g, *c]] = True
109
+ return table
110
+
111
+ transforms = [
112
+ Comparison(g, c, xb, ix, np.array(t, dtype=int), np.array(b, dtype=int), wp, members(g, c))
113
+ for (g, c, xb, wp), (ix, t, b) in comparisons.items()
114
+ ]
115
+
116
+ names = store.design.names if store.design is not None else ["Intercept"]
117
+ batches = RetainedBatches(
118
+ lambda: store.batches(
119
+ include_x=True, include_weights=True, row_weights=not store.weights_time_invariant
120
+ ),
121
+ retain=store.is_balanced
122
+ and balanced_bytes(store, len(names)) <= RETAIN_SHARE * store.budget.headroom_gb() * 1e9,
123
+ )
124
+ M = len(cells)
125
+ fast = (
126
+ cs_kernels.BalancedCS(transforms, M) if cs_kernels.ENABLED and store.is_balanced else None
127
+ )
128
+ try:
129
+ systems, passes = fit_systems(batches, transforms, method, names, fast=fast)
130
+ except ValueError:
131
+ # A nearly collinear design can converge in one summation order and not
132
+ # in another; the array path is the reference.
133
+ if fast is None:
134
+ raise
135
+ systems, passes = fit_systems(batches, transforms, method, names)
136
+ fitted = fit_contrasts(batches, transforms, systems, method, trim_level, fast=fast)
137
+ beta = np.zeros(M)
138
+ for c, f in zip(transforms, fitted, strict=True):
139
+ beta[c.cells] = f.estimate
140
+ cohorts = np.array([g for g, t in cells])
141
+ counts = np.array([store.gw[g] for g, t in cells], dtype=float)
142
+ # Unadjusted sums of [scores, share] outer products: api.py owns the
143
+ # explicit CRV0/CRV1 factor for all covariance blocks.
144
+ accumulator = CohortScoreCovariance(cohorts, counts / store.n_units)
145
+ packed = fast.pack_fitted(fitted, len(names)) if fast is not None else None
146
+ for raw in batches():
147
+ g, _, _, w = raw
148
+ anchor = w[:, 0] if w.ndim == 2 else w
149
+ if fast is not None:
150
+ scores = fast.scores(raw, method, packed, trim_level)
151
+ else:
152
+ scores = np.zeros((len(g), M))
153
+ for c, f in zip(transforms, fitted, strict=True):
154
+ mask, X, d, Y = c.select(raw)
155
+ scores[np.ix_(np.flatnonzero(mask), c.cells)] = f.scores(
156
+ X, d, Y, c.weights(raw, mask)
157
+ )
158
+ accumulator.add(g, scores, anchor)
159
+ if multiplier is not None:
160
+ share = anchor[:, None] * ((g[:, None] == cohorts[None, :]) - counts / store.n_units)
161
+ multiplier.update(scores, share)
162
+ V, cross, share_cov = accumulator.blocks()
163
+ if balance_collector is not None:
164
+ balance_collector.balanced(transforms, fitted, cells, batches)
165
+ metadata = dict(
166
+ est_method=method,
167
+ covariate_names=names,
168
+ covariates_time_invariant=store.x_time_invariant,
169
+ nuisance_systems=len(transforms),
170
+ propensity_models=0 if method == "reg" else len(transforms),
171
+ nuisance_source_passes=passes + 2,
172
+ trim_level=trim_level,
173
+ probability_cap=1 - 1e-6 if method != "reg" else None,
174
+ comparisons=[
175
+ dict(
176
+ cohort=c.cohort,
177
+ controls=list(c.controls),
178
+ covariate_time=int(store.times[c.xperiod]),
179
+ weight_time=int(store.times[c.weightperiod])
180
+ if not store.weights_time_invariant
181
+ else None,
182
+ cells=c.cells,
183
+ **f.diagnostics,
184
+ )
185
+ for c, f in zip(transforms, fitted, strict=True)
186
+ ],
187
+ )
188
+ metadata["warnings"] = []
189
+ if any(f.diagnostics["trimmed_controls"] for f in fitted):
190
+ metadata["warnings"].append(
191
+ "Propensity trimming removed control contributions in some comparisons; inspect diagnostics['comparisons']."
192
+ )
193
+ if any(f.diagnostics["capped_probabilities"] for f in fitted):
194
+ metadata["warnings"].append(
195
+ "Some propensity probabilities were capped at 1-1e-6 under the R DRDID convention."
196
+ )
197
+ return beta, counts, V, cross, share_cov, metadata
@@ -0,0 +1,336 @@
1
+ """R did-compatible observed-row CS functionals, clustered by original unit.
2
+
3
+ Each date pair uses repeated-cross-section mean functionals on its observed
4
+ rows. Units seen at only one date contribute; scores are folded by unit.
5
+ """
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ from scipy.special import expit
11
+
12
+ from . import cs as cs_module
13
+ from ._core import cs_row_kernels
14
+ from ._core.functionals import NormalizedMeans
15
+ from ._core.nuisance import fit_linear_systems, fit_systems, solve_checked
16
+ from .cohort_scores import CohortScoreCovariance
17
+ from .cs import RetainedBatches, balanced_bytes
18
+
19
+
20
+ @dataclass
21
+ class RowComparison:
22
+ cohort: int
23
+ controls: tuple
24
+ target: int
25
+ base: int
26
+
27
+ def weights(self, raw, unit, post):
28
+ w = raw[3]
29
+ return w[unit, np.where(post, self.target, self.base)] if w.ndim == 2 else w[unit]
30
+
31
+ def select(self, raw):
32
+ g, y, x = raw[:3]
33
+ mask = (g == self.cohort) | np.isin(g, self.controls)
34
+ dates = np.array([self.base, self.target])
35
+ unit, period = np.nonzero(mask[:, None] & np.isfinite(y[:, dates]))
36
+ ti = dates[period]
37
+ return unit, x[unit, ti], g[unit] == self.cohort, y[unit, ti], period == 1
38
+
39
+ def __call__(self, raw):
40
+ unit, X, d, y, post = self.select(raw)
41
+ return (
42
+ (X, d, y[:, None], self.weights(raw, unit, post))
43
+ if len(raw) == 4
44
+ else (X, d, y[:, None])
45
+ )
46
+
47
+
48
+ class RowFunctional:
49
+ def __init__(self, comparison, method, trim, system, models, width):
50
+ self.c, self.method, self.trim, self.system = comparison, method, trim, system
51
+ # Model order is control pre/post, treated pre/post.
52
+ self.models = models
53
+ if method == "ipw":
54
+ self.signs = np.array([1, -1, -1, 1.0])
55
+ self.value_derivative = np.zeros((4, 0))
56
+ elif method == "reg":
57
+ self.signs = np.array([1, -1, -1.0])
58
+ self.value_derivative = np.array([[0, 0], [0, 0], [-1, 1.0]])
59
+ else:
60
+ self.signs = np.array([1, -1, -1, 1, 1, -1, -1, 1.0])
61
+ self.value_derivative = np.array(
62
+ [
63
+ [0, -1, 0, 0],
64
+ [-1, 0, 0, 0],
65
+ [0, -1, 0, 0],
66
+ [-1, 0, 0, 0],
67
+ [0, -1, 0, 1],
68
+ [0, -1, 0, 1],
69
+ [-1, 0, 1, 0],
70
+ [-1, 0, 1, 0.0],
71
+ ]
72
+ )
73
+ self.means = NormalizedMeans(len(self.signs), width)
74
+ self.H = np.zeros((width, width))
75
+ self.trimmed = 0
76
+ self.capped = 0
77
+ self.stratum_counts = np.zeros(4, dtype=int)
78
+ self.probability_min, self.probability_max = np.inf, -np.inf
79
+ self.control_weight_squares = np.zeros(2)
80
+ self.sample_weight = np.zeros(4)
81
+ self.sample_weight_squares = np.zeros(4)
82
+
83
+ def rows(self, raw):
84
+ unit, X, d, y, post = self.c.select(raw)
85
+ tp, tm = d & post, d & ~post
86
+ cp, cm = ~d & post, ~d & ~post
87
+ p = np.minimum(expit(X @ self.system.beta), 1 - 1e-6) if self.system is not None else None
88
+ odds = (p / (1 - p)) * (p < self.trim) if p is not None else np.ones(len(d))
89
+ if self.method == "ipw":
90
+ weights = np.column_stack([tp, tm, cp * odds, cm * odds])
91
+ values = np.broadcast_to(y[:, None], weights.shape)
92
+ elif self.method == "reg":
93
+ weights = np.column_stack([tp, tm, d]).astype(float)
94
+ values = np.column_stack([y, y, X @ (self.models[1][0] - self.models[0][0])])
95
+ else:
96
+ m = [X @ b for b, _ in self.models]
97
+ r = y - np.where(post, m[1], m[0])
98
+ weights = np.column_stack([tp, tm, cp * odds, cm * odds, d, tp, d, tm])
99
+ values = np.column_stack(
100
+ [r, r, r, r, m[3] - m[1], m[3] - m[1], m[2] - m[0], m[2] - m[0]]
101
+ )
102
+ if len(raw) == 4:
103
+ weights = weights * self.c.weights(raw, unit, post)[:, None]
104
+ return unit, X, d, y, post, p, weights, values
105
+
106
+ def update(self, raw):
107
+ unit, X, d, _, post, p, w, v = self.rows(raw)
108
+ sw = self.c.weights(raw, unit, post) if len(raw) == 4 else np.ones(len(d))
109
+ for j, mask in enumerate((~d & ~post, ~d & post, d & ~post, d & post)):
110
+ self.sample_weight[j] += sw[mask].sum()
111
+ self.sample_weight_squares[j] += sw[mask] @ sw[mask]
112
+ self.means.update(w, v, X)
113
+ self.stratum_counts += [
114
+ np.sum(~d & ~post),
115
+ np.sum(~d & post),
116
+ np.sum(d & ~post),
117
+ np.sum(d & post),
118
+ ]
119
+ if p is not None:
120
+ self.H += X.T @ ((sw * p * (1 - p))[:, None] * X)
121
+ self.trimmed += int(((~d) & (p >= self.trim)).sum())
122
+ self.capped += int((expit(X @ self.system.beta) > 1 - 1e-6).sum())
123
+ self.control_weight_squares += (w[:, [3, 2]] ** 2).sum(axis=0)
124
+ if len(p):
125
+ self.probability_min = min(self.probability_min, float(p.min()))
126
+ self.probability_max = max(self.probability_max, float(p.max()))
127
+
128
+ def load(self, acc, j):
129
+ """Take the sums of the compiled pass (``cs_row_kernels``) for comparison ``j``."""
130
+ n = len(self.signs)
131
+ self.means.weight = acc["weight"][j, :n].copy()
132
+ self.means.value = acc["value"][j, :n].copy()
133
+ self.means.x = acc["xw"][j, :n].copy()
134
+ self.means.xvalue = acc["xv"][j, :n].copy()
135
+ self.stratum_counts = acc["strata"][j].copy()
136
+ self.sample_weight = acc["sw"][j].copy()
137
+ self.sample_weight_squares = acc["sw2"][j].copy()
138
+ if self.system is not None:
139
+ self.H = acc["hess"][j].copy()
140
+ self.trimmed = int(acc["trimmed"][j])
141
+ self.capped = int(acc["capped"][j])
142
+ self.control_weight_squares = acc["cws"][j].copy()
143
+ if acc["pmax"][j] >= acc["pmin"][j]:
144
+ self.probability_min = float(acc["pmin"][j])
145
+ self.probability_max = float(acc["pmax"][j])
146
+
147
+ def finish(self):
148
+ self.means.finish()
149
+ self.estimate = self.means.mean @ self.signs
150
+ J = (self.value_derivative.T * self.signs) @ self.means.mean_x
151
+ self.reg_correction = [
152
+ solve_checked(Q, j, "outcome score correction")
153
+ for (_, Q), j in zip(self.models, J, strict=True)
154
+ ]
155
+ if self.system is not None:
156
+ Jps = -self.means.centered_x[2] + self.means.centered_x[3]
157
+ self.ps_correction = solve_checked(self.H, Jps, "propensity score correction")
158
+
159
+ def scores(self, raw):
160
+ unit, X, d, y, post, p, w, v = self.rows(raw)
161
+ phi = self.means.scores(w, v, self.signs)
162
+ sw = self.c.weights(raw, unit, post) if len(raw) == 4 else np.ones(len(d))
163
+ strata = [~d & ~post, ~d & post, d & ~post, d & post]
164
+ for (b, _), correction, mask in zip(
165
+ self.models, self.reg_correction, strata[: len(self.models)], strict=True
166
+ ):
167
+ phi += sw * mask * (y - X @ b) * (X @ correction)
168
+ if self.system is not None:
169
+ phi += sw * (d - p) * (X @ self.ps_correction)
170
+ return unit, phi
171
+
172
+
173
+ def fit_cs_unbalanced(
174
+ store, groups, cells, bases, controls, method, trim, multiplier=None, *, balance_collector=None
175
+ ):
176
+ K = len(store.design.names) if store.design is not None else 1
177
+ names = store.design.names if store.design is not None else ["Intercept"]
178
+ comparisons = [
179
+ RowComparison(g, tuple(np.asarray(groups)[c]), t - store.times[0], int(b))
180
+ for (g, t), b, c in zip(cells, bases, controls, strict=True)
181
+ ]
182
+
183
+ fast = cs_row_kernels.ENABLED
184
+ retain = (
185
+ fast
186
+ and balanced_bytes(store, K) <= cs_module.RETAIN_SHARE * store.budget.headroom_gb() * 1e9
187
+ )
188
+ batches = RetainedBatches(
189
+ lambda: store.batches(
190
+ include_x=True, include_weights=True, row_weights=not store.weights_time_invariant
191
+ ),
192
+ retain=retain,
193
+ )
194
+ size = int(max(np.max(groups), *(c.cohort for c in comparisons))) + 1
195
+
196
+ linear_keys = {}
197
+ model_indices = []
198
+ for c in comparisons:
199
+ keys = [] if method == "ipw" else [(c.controls, c.base), (c.controls, c.target)]
200
+ if method == "dr":
201
+ keys += [((c.cohort,), c.base), ((c.cohort,), c.target)]
202
+ model_indices.append([linear_keys.setdefault(key, len(linear_keys)) for key in keys])
203
+
204
+ def linear_transform(cohorts, ti):
205
+ def select(raw):
206
+ g, y, x, w = raw
207
+ mask = np.isin(g, cohorts) & np.isfinite(y[:, ti])
208
+ return x[mask, ti], y[mask, ti], w[mask, ti] if w.ndim == 2 else w[mask]
209
+
210
+ return select
211
+
212
+ if fast and linear_keys:
213
+ gram, moments = cs_row_kernels.fit_linear_rows(batches, list(linear_keys), size, K)
214
+ models = [
215
+ (solve_checked(Q, q, f"stratified outcome regression (system {j}, {K} columns)"), Q)
216
+ for j, (Q, q) in enumerate(zip(gram, moments, strict=True))
217
+ ]
218
+ else:
219
+ models = fit_linear_systems(batches, [linear_transform(*key) for key in linear_keys], K)
220
+ ps_keys, ps_indices, ps_transforms = {}, [], []
221
+ for c in comparisons:
222
+ key = (c.cohort, c.controls, *sorted([c.base, c.target]))
223
+ if key not in ps_keys:
224
+ ps_keys[key] = len(ps_keys)
225
+ ps_transforms.append(c)
226
+ ps_indices.append(ps_keys[key])
227
+ kernels = cs_row_kernels.RowKernels(comparisons, size) if fast else None
228
+
229
+ def propensity_fits(use_kernels):
230
+ return fit_systems(
231
+ batches,
232
+ ps_transforms,
233
+ "ipw",
234
+ names,
235
+ fast=cs_row_kernels.RowKernels(ps_transforms, size) if use_kernels else None,
236
+ )
237
+
238
+ if method == "reg":
239
+ systems, passes = [], 0
240
+ else:
241
+ try:
242
+ systems, passes = propensity_fits(fast)
243
+ except ValueError:
244
+ # A nearly collinear design can converge in one summation order and
245
+ # not in another; the array path is the reference.
246
+ if not fast:
247
+ raise
248
+ systems, passes = propensity_fits(False)
249
+ fitted = [
250
+ RowFunctional(c, method, trim, systems[ps] if systems else None, [models[k] for k in ix], K)
251
+ for c, ps, ix in zip(comparisons, ps_indices, model_indices, strict=True)
252
+ ]
253
+ if kernels is not None:
254
+ params = kernels.parameters(fitted, method, K)
255
+ acc = kernels.empty_moments(K)
256
+ for raw in batches():
257
+ kernels.moments(raw, method, trim, params, acc)
258
+ for j, f in enumerate(fitted):
259
+ f.load(acc, j)
260
+ else:
261
+ for raw in batches():
262
+ for f in fitted:
263
+ f.update(raw)
264
+ for f in fitted:
265
+ f.finish()
266
+ M = len(cells)
267
+ cohorts = np.array([g for g, _ in cells])
268
+ counts = np.array([store.gw[g] for g, _ in cells], float)
269
+ # Unadjusted sums of [scores, share] outer products: api.py owns the
270
+ # explicit CRV0/CRV1 factor for all covariance blocks.
271
+ accumulator = CohortScoreCovariance(cohorts, counts / store.n_units)
272
+ for raw in batches():
273
+ g, y, _, w = raw
274
+ anchor = w.sum(axis=1) / np.isfinite(y).sum(axis=1) if w.ndim == 2 else w
275
+ if kernels is not None:
276
+ scores = kernels.scores(raw, method, trim, params, fitted)
277
+ else:
278
+ scores = np.zeros((len(g), M))
279
+ for j, f in enumerate(fitted):
280
+ unit, phi = f.scores(raw)
281
+ np.add.at(scores[:, j], unit, phi)
282
+ accumulator.add(g, scores, anchor)
283
+ if multiplier is not None:
284
+ share = anchor[:, None] * ((g[:, None] == cohorts) - counts / store.n_units)
285
+ multiplier.update(scores, share)
286
+ V, cross, share_cov = accumulator.blocks()
287
+ if balance_collector is not None:
288
+ balance_collector.unbalanced(comparisons, fitted, batches)
289
+ metadata = dict(
290
+ est_method=method,
291
+ covariate_names=names,
292
+ covariate_convention="observed-date covariates; repeated-cross-section functionals",
293
+ nuisance_systems=len(linear_keys),
294
+ propensity_models=len(systems),
295
+ nuisance_source_passes=passes + 2 + int(method != "ipw"),
296
+ trim_level=trim,
297
+ comparisons=[
298
+ dict(
299
+ cohort=c.cohort,
300
+ time=int(store.times[c.target]),
301
+ base=int(store.times[c.base]),
302
+ trimmed_controls=f.trimmed,
303
+ capped_probabilities=f.capped,
304
+ n_control_pre=int(f.stratum_counts[0]),
305
+ n_control_post=int(f.stratum_counts[1]),
306
+ n_treated_pre=int(f.stratum_counts[2]),
307
+ n_treated_post=int(f.stratum_counts[3]),
308
+ propensity_range=[f.probability_min, f.probability_max]
309
+ if method != "reg"
310
+ else None,
311
+ effective_controls=(f.means.weight[[3, 2]] ** 2 / f.control_weight_squares).tolist()
312
+ if method != "reg"
313
+ else (f.sample_weight[:2] ** 2 / f.sample_weight_squares[:2]).tolist(),
314
+ effective_treated=(f.sample_weight[2:] ** 2 / f.sample_weight_squares[2:]).tolist(),
315
+ logit_iterations=f.system.iterations if method != "reg" else None,
316
+ )
317
+ for c, f in zip(comparisons, fitted, strict=True)
318
+ ],
319
+ warnings=[],
320
+ )
321
+ if any(f.trimmed for f in fitted):
322
+ metadata["warnings"].append(
323
+ "Propensity trimming removed control contributions; inspect comparisons."
324
+ )
325
+ if any(f.capped for f in fitted):
326
+ metadata["warnings"].append(
327
+ "Some propensity probabilities were capped at 1-1e-6 under the R DRDID convention."
328
+ )
329
+ return (
330
+ np.array([f.estimate for f in fitted]),
331
+ counts,
332
+ V,
333
+ cross,
334
+ share_cov,
335
+ metadata,
336
+ )
@@ -0,0 +1,73 @@
1
+ """CS comparison support, warning policy and explicit coefficient suppression."""
2
+
3
+ import warnings
4
+ from numbers import Real
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+
9
+
10
+ class LowEffectiveSampleWarning(UserWarning):
11
+ """A CS comparison relies on a low effective treated/control sample."""
12
+
13
+
14
+ def threshold(value, name, *, optional=True):
15
+ if value is None and optional:
16
+ return value
17
+ if (
18
+ isinstance(value, (bool, np.bool_))
19
+ or not isinstance(value, Real)
20
+ or not np.isfinite(value)
21
+ or value < 0
22
+ ):
23
+ raise ValueError(
24
+ f"{name} must be a finite nonnegative number" + (" or None" if optional else "")
25
+ )
26
+ return float(value)
27
+
28
+
29
+ def finish_result(result, warn_threshold, suppress_threshold):
30
+ if result.estimator != "cs":
31
+ return result
32
+ rows = []
33
+ cell_index = {cell: j for j, cell in enumerate(result.cells)}
34
+ for comparison in result.diagnostics["comparisons"]:
35
+ indices = comparison.get("cells")
36
+ if indices is None:
37
+ indices = [cell_index[(comparison["cohort"], comparison["time"])]]
38
+ for index in indices:
39
+ for group, key in (("treated", "effective_treated"), ("control", "effective_controls")):
40
+ values = np.atleast_1d(comparison[key])
41
+ labels = [group] if len(values) == 1 else [group + "_pre", group + "_post"]
42
+ for label, value in zip(labels, values, strict=True):
43
+ if not np.isfinite(value) or value <= 0:
44
+ raise ValueError("CS comparison has undefined effective sample size")
45
+ rows.append(
46
+ dict(
47
+ coefficient=result.coefnames[index],
48
+ cell=result.coefnames[index],
49
+ cohort=result.cells[index][0],
50
+ time=result.cells[index][1],
51
+ stratum=label,
52
+ effective_n=float(value),
53
+ )
54
+ )
55
+ result._ess_table = pl.DataFrame(rows)
56
+ result.diagnostics["ess_warn_threshold"] = warn_threshold
57
+ result.diagnostics["ess_suppress_threshold"] = suppress_threshold
58
+ result.diagnostics["ess_definition"] = (
59
+ "Kish weight ESS within each required treated/control comparison stratum; not regression-information or influence-score ESS"
60
+ )
61
+ minima = result._minimum_ess()
62
+ bad = minima < (warn_threshold or 0)
63
+ if bad.any():
64
+ message = (
65
+ f"Low effective sample size: {int(bad.sum())} CS coefficients have a required "
66
+ f"treated/control stratum below {warn_threshold:g} (minimum {minima.min():.3g}). "
67
+ "Inspect effective_sample_size(); suppress(min_effective_sample=...) masks coefficients without changing aggregation weights."
68
+ )
69
+ result.diagnostics["warnings"] = [*result.diagnostics.get("warnings", []), message]
70
+ warnings.warn(message, LowEffectiveSampleWarning, stacklevel=3)
71
+ if suppress_threshold is not None:
72
+ return result.suppress(min_effective_sample=suppress_threshold)
73
+ return result