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,288 @@
1
+ """Compiled passes of the extended TWFE regression.
2
+
3
+ The expanded design has a column for every cohort, period, cohort-by-covariate,
4
+ period-by-covariate and treatment-cell effect, but a row has only a handful of
5
+ nonzero entries: its cohort and period, the covariates interacted with each,
6
+ and the covariates (and a one) in its treatment cell. The NumPy code builds the
7
+ dense design for a batch of units and multiplies it; the kernels here visit
8
+ only the nonzero entries, and remove the unit means (``fixed_effects="unit"``)
9
+ analytically.
10
+
11
+ Units are processed in fixed-size chunks whose partial sums are added in order,
12
+ so results do not depend on the number of threads. The NumPy code in
13
+ ``etwfe.py`` remains the reference; the tests compare the two.
14
+ """
15
+
16
+ import numpy as np
17
+ from numba import njit, prange
18
+
19
+ ENABLED = True # tests switch it off to compare with the array path
20
+ CHUNK = 64 # units per chunk
21
+ PARTIAL_BYTES = 128e6 # memory of the chunks' partial Gram matrices
22
+
23
+
24
+ @njit(cache=True, inline="always")
25
+ def _row(x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at, cols, vals):
26
+ """The nonzero entries of one row of the design: fills ``cols`` and ``vals``
27
+ and returns their number and the row's treatment cell (-1 for none)."""
28
+ n = 0
29
+ at = 0
30
+ if cohort_fe:
31
+ cols[n] = gi
32
+ vals[n] = 1.0
33
+ n += 1
34
+ at = G
35
+ if t >= 1:
36
+ cols[n] = at + t - 1
37
+ vals[n] = 1.0
38
+ n += 1
39
+ cell = cell_at[gi, t]
40
+ for k in range(K):
41
+ v = x[i, t, k]
42
+ if not np.isfinite(v):
43
+ v = 0.0
44
+ cols[n] = base_c + gi * K + k
45
+ vals[n] = v
46
+ n += 1
47
+ if t >= 1:
48
+ cols[n] = base_t + (t - 1) * K + k
49
+ vals[n] = v
50
+ n += 1
51
+ if cell >= 0:
52
+ cols[n] = offset + cell * (K + 1)
53
+ vals[n] = 1.0
54
+ n += 1
55
+ for k in range(K):
56
+ v = x[i, t, k]
57
+ cols[n] = offset + cell * (K + 1) + 1 + k
58
+ vals[n] = v if np.isfinite(v) else 0.0
59
+ n += 1
60
+ return n, cell
61
+
62
+
63
+ @njit(parallel=True, cache=True)
64
+ def etwfe_moments(
65
+ gidx, y, x, w, G, cohort_fe, unit_fe, base_c, base_t, offset, cell_at, chunk, Q, q, norms,
66
+ means, cdiag, bad,
67
+ ): # fmt: skip
68
+ """Gram matrix and moments of the regression, the unit-centered column
69
+ norms, and the weighted covariate sums of each treatment cell."""
70
+ n, T = y.shape
71
+ K = x.shape[2]
72
+ P = Q.shape[1]
73
+ chunks = Q.shape[0]
74
+ nz = 3 * K + 3
75
+ for c in prange(chunks):
76
+ cols = np.empty((T, nz), dtype=np.int64)
77
+ vals = np.empty((T, nz))
78
+ count = np.zeros(T, dtype=np.int64)
79
+ s = np.zeros(P)
80
+ wn = np.zeros(P)
81
+ seen = np.zeros(P, dtype=np.bool_)
82
+ touched = np.empty(T * nz, dtype=np.int64)
83
+ for i in range(c * chunk, min(n, (c + 1) * chunk)):
84
+ mass = 0.0
85
+ ysum = 0.0
86
+ ntouch = 0
87
+ gi = gidx[i]
88
+ for t in range(T):
89
+ m, cell = _row(
90
+ x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at,
91
+ cols[t], vals[t],
92
+ ) # fmt: skip
93
+ count[t] = m
94
+ wt = w[i, t]
95
+ yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
96
+ mass += wt
97
+ ysum += wt * yt
98
+ for a in range(m):
99
+ ca = cols[t, a]
100
+ wa = wt * vals[t, a]
101
+ q[c, ca] += wa * yt
102
+ if unit_fe:
103
+ norms[c, ca] += wa * vals[t, a]
104
+ s[ca] += wa
105
+ wn[ca] += wt
106
+ if not seen[ca]:
107
+ seen[ca] = True
108
+ touched[ntouch] = ca
109
+ ntouch += 1
110
+ for b in range(m):
111
+ Q[c, ca, cols[t, b]] += wa * vals[t, b]
112
+ if cell >= 0:
113
+ means[c, cell, 0] += wt
114
+ for k in range(K):
115
+ v = x[i, t, k]
116
+ means[c, cell, 1 + k] += wt * (v if np.isfinite(v) else 0.0)
117
+ if unit_fe:
118
+ if mass <= 0.0:
119
+ bad[c] = 1
120
+ else:
121
+ for a in range(ntouch):
122
+ ca = touched[a]
123
+ q[c, ca] -= s[ca] * ysum / mass
124
+ for b in range(ntouch):
125
+ cb = touched[b]
126
+ Q[c, ca, cb] -= s[ca] * s[cb] / mass
127
+ # the centered sum of squares itself, without the cancellation
128
+ # of the subtraction above: a column the unit means remove
129
+ # entirely must come out as zero
130
+ for t in range(T):
131
+ for a in range(count[t]):
132
+ ca = cols[t, a]
133
+ d = vals[t, a] - s[ca] / mass
134
+ cdiag[c, ca] += w[i, t] * d * d
135
+ for a in range(ntouch):
136
+ ca = touched[a]
137
+ dbar = s[ca] / mass
138
+ cdiag[c, ca] += dbar * dbar * (mass - wn[ca])
139
+ for a in range(ntouch):
140
+ s[touched[a]] = 0.0
141
+ wn[touched[a]] = 0.0
142
+ seen[touched[a]] = False
143
+
144
+
145
+ @njit(parallel=True, cache=True)
146
+ def etwfe_scores(
147
+ gidx, y, x, w, G, cohort_fe, unit_fe, base_c, base_t, offset, cell_at, coef, H, out, bad
148
+ ): # fmt: skip
149
+ """Each unit's score of the regression times H: ``out[unit, :]``."""
150
+ n, T = y.shape
151
+ K = x.shape[2]
152
+ M = H.shape[1]
153
+ nz = 3 * K + 3
154
+ chunks = (n + CHUNK - 1) // CHUNK
155
+ for c in prange(chunks):
156
+ cols = np.empty((T, nz), dtype=np.int64)
157
+ vals = np.empty((T, nz))
158
+ count = np.zeros(T, dtype=np.int64)
159
+ resid = np.zeros(T)
160
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
161
+ gi = gidx[i]
162
+ mass = 0.0
163
+ rsum = 0.0
164
+ for t in range(T):
165
+ m, cell = _row(
166
+ x, i, t, gi, K, G, T, cohort_fe, base_c, base_t, offset, cell_at,
167
+ cols[t], vals[t],
168
+ ) # fmt: skip
169
+ count[t] = m
170
+ fit = 0.0
171
+ for a in range(m):
172
+ fit += vals[t, a] * coef[cols[t, a]]
173
+ yt = y[i, t] if np.isfinite(y[i, t]) else 0.0
174
+ resid[t] = yt - fit
175
+ mass += w[i, t]
176
+ rsum += w[i, t] * resid[t]
177
+ if unit_fe:
178
+ if mass <= 0.0:
179
+ bad[c] = 1
180
+ continue
181
+ rsum /= mass
182
+ else:
183
+ rsum = 0.0
184
+ for t in range(T):
185
+ we = w[i, t] * (resid[t] - rsum)
186
+ if we == 0.0:
187
+ continue
188
+ for a in range(count[t]):
189
+ wa = we * vals[t, a]
190
+ ca = cols[t, a]
191
+ for m in range(M):
192
+ out[i, m] += wa * H[ca, m]
193
+
194
+
195
+ def _arrays(raw):
196
+ g, y, x, w = raw
197
+ return (
198
+ np.ascontiguousarray(y, dtype=np.float64),
199
+ np.ascontiguousarray(x, dtype=np.float64),
200
+ np.ascontiguousarray(w, dtype=np.float64),
201
+ )
202
+
203
+
204
+ class ETWFEKernels:
205
+ """The compiled passes for an ``etwfe.ExtendedDesign``."""
206
+
207
+ def __init__(self, model):
208
+ self.model = model
209
+ self.P = len(model.names)
210
+ self.G = len(model.groups)
211
+ self.cohort_fe = model.fixed_effects == "cohort"
212
+ T = model.store.periods
213
+ K = model.K
214
+ self.base_c = (self.G if self.cohort_fe else 0) + T - 1
215
+ self.base_t = self.base_c + self.G * K
216
+ self.offset = model.offset
217
+ self.cell_at = np.ascontiguousarray(model.cell_at)
218
+ # chunks per batch: the partial Gram matrices must fit
219
+ self.max_chunks = int(PARTIAL_BYTES // (8 * self.P * self.P))
220
+
221
+ @property
222
+ def usable(self):
223
+ return ENABLED and self.max_chunks >= 2
224
+
225
+ def _chunking(self, n):
226
+ chunks = min((n + CHUNK - 1) // CHUNK, self.max_chunks)
227
+ return chunks, (n + chunks - 1) // chunks
228
+
229
+ def group_index(self, g):
230
+ return np.searchsorted(self.model.group_array, g).astype(np.int64)
231
+
232
+ def moments(self, batches):
233
+ """(Q, q, norms, means): the Gram matrix, moments, column norms and
234
+ covariate sums of each cell."""
235
+ model = self.model
236
+ P, M, K = self.P, len(model.cells), model.K
237
+ Q, q, norms = np.zeros((P, P)), np.zeros(P), np.zeros(P)
238
+ means = np.zeros((M, K + 1))
239
+ cdiag = np.zeros(P)
240
+ for raw in batches():
241
+ y, x, w = _arrays(raw)
242
+ g = raw[0]
243
+ chunks, size = self._chunking(len(g))
244
+ parts = (
245
+ np.zeros((chunks, P, P)),
246
+ np.zeros((chunks, P)),
247
+ np.zeros((chunks, P)),
248
+ np.zeros((chunks, M, K + 1)),
249
+ np.zeros((chunks, P)),
250
+ np.zeros(chunks, dtype=np.int8),
251
+ )
252
+ etwfe_moments(
253
+ self.group_index(g), y, x, w, self.G,
254
+ self.cohort_fe, model.fixed_effects == "unit", self.base_c, self.base_t,
255
+ self.offset, self.cell_at, size, *parts,
256
+ ) # fmt: skip
257
+ if parts[5].any():
258
+ raise ValueError("each group requires a positive-weight estimation observation")
259
+ Q += parts[0].sum(axis=0)
260
+ q += parts[1].sum(axis=0)
261
+ norms += parts[2].sum(axis=0)
262
+ means += parts[3].sum(axis=0)
263
+ cdiag += parts[4].sum(axis=0)
264
+ if model.fixed_effects == "unit":
265
+ # columns the unit means remove entirely are exactly zero, as in
266
+ # the array path; the subtraction leaves rounding noise
267
+ Q[np.diag_indices(P)] = cdiag
268
+ dead = cdiag <= norms * 1e-24
269
+ Q[dead, :] = 0.0
270
+ Q[:, dead] = 0.0
271
+ q[dead] = 0.0
272
+ return Q, q, norms, means
273
+
274
+ def scores(self, raw, coef, H_full):
275
+ """The batch's scores times H (units by cells); ``H_full`` has a row for
276
+ every column of the design (zero for the dropped ones)."""
277
+ model = self.model
278
+ y, x, w = _arrays(raw)
279
+ out = np.zeros((len(y), H_full.shape[1]))
280
+ bad = np.zeros((len(y) + CHUNK - 1) // CHUNK, dtype=np.int8)
281
+ etwfe_scores(
282
+ self.group_index(raw[0]), y, x, w, self.G, self.cohort_fe,
283
+ model.fixed_effects == "unit", self.base_c, self.base_t, self.offset, self.cell_at,
284
+ np.ascontiguousarray(coef), np.ascontiguousarray(H_full), out, bad,
285
+ ) # fmt: skip
286
+ if bad.any():
287
+ raise ValueError("each group requires a positive-weight estimation observation")
288
+ return out
@@ -0,0 +1,30 @@
1
+ """Streaming normalized means and their smooth-functional derivatives.
2
+
3
+ These primitives have no panel, date, treatment, or cohort conventions.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+
9
+ class NormalizedMeans:
10
+ def __init__(self, terms, width):
11
+ self.weight = np.zeros(terms)
12
+ self.value = np.zeros(terms)
13
+ self.x = np.zeros((terms, width))
14
+ self.xvalue = np.zeros((terms, width))
15
+
16
+ def update(self, weights, values, X):
17
+ self.weight += weights.sum(axis=0)
18
+ self.value += (weights * values).sum(axis=0)
19
+ self.x += weights.T @ X
20
+ self.xvalue += (weights * values).T @ X
21
+
22
+ def finish(self):
23
+ if np.any(self.weight <= 0):
24
+ raise ValueError("a normalized mean has no contributing observations after trimming")
25
+ self.mean = self.value / self.weight
26
+ self.mean_x = self.x / self.weight[:, None]
27
+ self.centered_x = self.xvalue / self.weight[:, None] - self.mean[:, None] * self.mean_x
28
+
29
+ def scores(self, weights, values, signs):
30
+ return (weights * (values - self.mean) / self.weight) @ signs
@@ -0,0 +1,90 @@
1
+ """Bounded streaming sums of cluster influence scores times multipliers.
2
+
3
+ The multiplier samplers are reused from hdfe-stream's wild bootstrap. Its OLS
4
+ restricted-fit/studentization engine is deliberately not used here. Scores may
5
+ belong to any asymptotically linear estimator; no DiD conventions live here.
6
+ """
7
+
8
+ from numbers import Integral
9
+
10
+ import numpy as np
11
+ from hdfe_stream.core.wildboot import WEIGHTS
12
+ from scipy.special import ndtri
13
+
14
+ MAX_DRAW_ENTRIES = 1 << 18
15
+
16
+
17
+ def validate_bootstrap(reps, seed, weights):
18
+ if (
19
+ isinstance(reps, (bool, np.bool_))
20
+ or not isinstance(reps, Integral)
21
+ or (reps != 0 and reps < 2)
22
+ ):
23
+ raise ValueError("multiplier_bootstrap_reps must be 0 (disabled) or an integer >= 2")
24
+ if seed is not None and (
25
+ isinstance(seed, (bool, np.bool_)) or not isinstance(seed, Integral) or seed < 0
26
+ ):
27
+ raise ValueError("multiplier_bootstrap_seed must be a nonnegative integer or None")
28
+ if not isinstance(weights, str) or weights not in WEIGHTS:
29
+ raise ValueError(f"multiplier_bootstrap_weights must be one of {sorted(WEIGHTS)}")
30
+
31
+
32
+ class StreamingMultiplier:
33
+ """Keep repetitions-by-score-width state, never clusters-by-repetitions.
34
+
35
+ A unit receives the same multiplier in every score block. RNG draws are
36
+ unit-major, so changing numerical batch size preserves assignments for a
37
+ fixed unit traversal. Repartitioning can change traversal and assignments.
38
+ """
39
+
40
+ def __init__(self, width, reps, seed, weights):
41
+ validate_bootstrap(reps, seed, weights)
42
+ self.reps = int(reps)
43
+ self.rng = np.random.default_rng(seed)
44
+ self.sampler = WEIGHTS[weights]
45
+ self.draws = np.zeros((self.reps, width))
46
+ self.n_clusters = 0
47
+
48
+ def update(self, *blocks):
49
+ rows = len(blocks[0])
50
+ if (
51
+ any(len(b) != rows or b.ndim != 2 for b in blocks)
52
+ or sum(b.shape[1] for b in blocks) != self.draws.shape[1]
53
+ ):
54
+ raise ValueError("multiplier score blocks have incompatible shapes")
55
+ step = max(1, MAX_DRAW_ENTRIES // self.reps)
56
+ for start in range(0, rows, step):
57
+ stop = min(rows, start + step)
58
+ xi = np.asarray(
59
+ self.sampler((stop - start) * self.reps, self.rng), dtype=float
60
+ ).reshape(stop - start, self.reps)
61
+ column = 0
62
+ for block in blocks:
63
+ width = block.shape[1]
64
+ self.draws[:, column : column + width] += xi.T @ block[start:stop]
65
+ column += width
66
+ self.n_clusters += rows
67
+
68
+
69
+ def multiplier_band(draws, alpha, *, joint=True, analytic_se=None):
70
+ """IQR-studentized bands, using R did's empirical (type 1) quantiles.
71
+
72
+ Optional analytic_se uses fixed analytic studentization instead. Draws
73
+ are centered perturbations on the estimate scale, not resampled estimates.
74
+ """
75
+ if isinstance(alpha, (bool, np.bool_)) or not np.isfinite(alpha) or not 0 < alpha < 1:
76
+ raise ValueError("alpha must be in (0, 1)")
77
+ if not isinstance(joint, bool):
78
+ raise ValueError("joint must be boolean")
79
+ if analytic_se is None:
80
+ q = np.quantile(draws, [0.25, 0.75], axis=0, method="inverted_cdf")
81
+ se = (q[1] - q[0]) / (ndtri(0.75) - ndtri(0.25))
82
+ else:
83
+ se = np.asarray(analytic_se)
84
+ if not np.isfinite(draws).all() or not np.isfinite(se).all() or np.any(se <= 0):
85
+ raise ValueError(
86
+ "selected coefficients have zero or non-finite bootstrap/analytic scale; select supported coefficients or increase multiplier_bootstrap_reps"
87
+ )
88
+ t = np.abs(draws / se)
89
+ critical = np.quantile(t.max(axis=1) if joint else t, 1 - alpha, axis=0, method="inverted_cdf")
90
+ return se, critical
@@ -0,0 +1,255 @@
1
+ """Shared-pass binary MLE and multi-outcome control-regression systems.
2
+
3
+ No panel or treatment-cohort conventions live here. A transform selects a
4
+ system's sample and returns (design, binary group, outcome matrix[, weights]) per batch.
5
+ Intended for eventual hdfe_stream.core; current models are unpenalized, no FE.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+ from scipy.special import expit
12
+
13
+ from .weights import weighted_linear, weighted_system
14
+
15
+
16
+ def solve_checked(matrix, rhs, context, *, verify=True):
17
+ """Solve in diagonally scaled coordinates; never silently drop columns.
18
+
19
+ ``verify=False`` skips the eigenvalue check, for a matrix already checked
20
+ (its scaled form is the same whatever the right-hand side), which for a
21
+ matrix of hundreds of columns is most of the time of a solve."""
22
+ scale = np.sqrt(np.maximum(np.diag(matrix), 0))
23
+ if np.any(scale <= 0) or not np.isfinite(matrix).all():
24
+ raise ValueError(f"{context}: singular nuisance design")
25
+ normalized = matrix / np.outer(scale, scale)
26
+ if verify and np.linalg.eigvalsh(normalized)[0] <= 1e-12:
27
+ raise ValueError(f"{context}: singular or ill-conditioned nuisance design")
28
+ vector = np.ndim(rhs) == 1
29
+ target = np.asarray(rhs) / (scale if vector else scale[:, None])
30
+ answer = np.linalg.solve(normalized, target)
31
+ return answer / (scale if vector else scale[:, None])
32
+
33
+
34
+ def fit_linear_systems(batches, transforms, width):
35
+ """Fit weighted OLS on arbitrary row strata together in one source pass.
36
+
37
+ Transforms return (X, y[, weights]). Return coefficient and Gram pairs, so callers can
38
+ propagate estimation uncertainty through arbitrary smooth functionals.
39
+ """
40
+ if not transforms:
41
+ return []
42
+ grams = [np.zeros((width, width)) for _ in transforms]
43
+ rhs = [np.zeros(width) for _ in transforms]
44
+ counts = np.zeros(len(transforms), dtype=np.int64)
45
+ for raw in batches():
46
+ for j, transform in enumerate(transforms):
47
+ X, y, w = weighted_linear(transform(raw))
48
+ counts[j] += len(y)
49
+ grams[j] += X.T @ (w[:, None] * X)
50
+ rhs[j] += X.T @ (w * y)
51
+ return [
52
+ (
53
+ solve_checked(
54
+ Q,
55
+ q,
56
+ f"stratified outcome regression (system {j}, {counts[j]} observations, {width} columns)",
57
+ ),
58
+ Q,
59
+ )
60
+ for j, (Q, q) in enumerate(zip(grams, rhs, strict=True))
61
+ ]
62
+
63
+
64
+ @dataclass
65
+ class NuisanceSystem:
66
+ beta: np.ndarray
67
+ regression: np.ndarray
68
+ control_gram: np.ndarray
69
+ n_treated: int = 0
70
+ n_controls: int = 0
71
+ iterations: int = 0
72
+ treated_weight: float = 0.0
73
+ control_weight: float = 0.0
74
+ control_weight_squares: float = 0.0
75
+ treated_weight_squares: float = 0.0
76
+ design_scale: np.ndarray | None = None
77
+
78
+
79
+ def fit_systems(batches, transforms, method, names, *, tol=1e-10, maxiter=100, fast=None):
80
+ """Fit independent systems together, scanning the source once per Newton step.
81
+
82
+ Multi-outcome control OLS systems share a Gram solve. Logistic MLE uses
83
+ damped Newton steps, scaled solves and an explicit separation check.
84
+ ``fast`` (``cs_kernels.BalancedCS``) does the passes in compiled code, with the
85
+ same sums.
86
+ """
87
+ K = len(names)
88
+ systems = [None] * len(transforms)
89
+ passes = 0
90
+ if fast is not None:
91
+ C = len(transforms)
92
+ acc = dict(
93
+ counts=np.zeros((C, 2), dtype=np.int64),
94
+ wsums=np.zeros((C, 4)),
95
+ scale=np.zeros((C, K)),
96
+ gram=np.zeros((C, K, K)),
97
+ reg=np.zeros((C, K, fast.Lmax)),
98
+ )
99
+ for raw in batches():
100
+ fast.initial(raw, K, method != "ipw", acc)
101
+ for j in range(C):
102
+ L = int(fast.sizes[j])
103
+ systems[j] = NuisanceSystem(
104
+ np.zeros(K),
105
+ acc["reg"][j, :, :L].copy(),
106
+ acc["gram"][j].copy(),
107
+ n_treated=int(acc["counts"][j, 0]),
108
+ n_controls=int(acc["counts"][j, 1]),
109
+ treated_weight=float(acc["wsums"][j, 0]),
110
+ control_weight=float(acc["wsums"][j, 1]),
111
+ treated_weight_squares=float(acc["wsums"][j, 2]),
112
+ control_weight_squares=float(acc["wsums"][j, 3]),
113
+ design_scale=acc["scale"][j].copy(),
114
+ )
115
+ else:
116
+ for raw in batches():
117
+ for j, transform in enumerate(transforms):
118
+ X, d, Y, w = weighted_system(transform(raw))
119
+ if systems[j] is None:
120
+ systems[j] = NuisanceSystem(
121
+ np.zeros(K),
122
+ np.zeros((K, Y.shape[1])),
123
+ np.zeros((K, K)),
124
+ design_scale=np.zeros(K),
125
+ )
126
+ s = systems[j]
127
+ s.n_treated += int(d.sum())
128
+ s.n_controls += int((~d).sum())
129
+ s.treated_weight += float(w[d].sum())
130
+ s.control_weight += float(w[~d].sum())
131
+ s.control_weight_squares += float(w[~d] @ w[~d])
132
+ s.treated_weight_squares += float(w[d] @ w[d])
133
+ s.design_scale += np.einsum("i,ij,ij->j", w, X, X)
134
+ if method != "ipw":
135
+ xc = X[~d]
136
+ s.control_gram += xc.T @ (w[~d, None] * xc)
137
+ s.regression += xc.T @ (w[~d, None] * Y[~d])
138
+ passes += 1
139
+ for s in systems:
140
+ if not s.n_treated or not s.n_controls:
141
+ raise ValueError("both nuisance groups need observations")
142
+ s.design_scale = np.sqrt(s.design_scale / (s.treated_weight + s.control_weight))
143
+ if method != "ipw":
144
+ s.regression = solve_checked(s.control_gram, s.regression, "outcome regression")
145
+ if "Intercept" in names:
146
+ s.beta[names.index("Intercept")] = np.log(s.treated_weight / s.control_weight)
147
+ if method == "reg":
148
+ return systems, passes
149
+
150
+ def fast_stats(betas, indices):
151
+ nonlocal passes
152
+ C = len(transforms)
153
+ B = np.array(betas)
154
+ active = np.zeros(C, dtype=bool)
155
+ active[list(indices)] = True
156
+ loss, correction = np.zeros(C), np.zeros(C)
157
+ grad, hess, margin = np.zeros((C, K)), np.zeros((C, K, K)), np.full(C, np.inf)
158
+ for raw in batches():
159
+ inc, g_part, h_part, m_part = fast.newton(raw, B, active, K)
160
+ # compensated accumulation of the objective, as in the array path
161
+ step = inc - correction
162
+ total = loss + step
163
+ correction = (total - loss) - step
164
+ loss = total
165
+ grad += g_part
166
+ hess += h_part
167
+ margin = np.minimum(margin, m_part)
168
+ passes += 1
169
+ return {
170
+ j: [float(loss[j]), grad[j].copy(), hess[j].copy(), float(margin[j])] for j in indices
171
+ }
172
+
173
+ def stats(betas, indices):
174
+ nonlocal passes
175
+ if fast is not None:
176
+ return fast_stats(betas, indices)
177
+ out = {j: [0.0, np.zeros(K), np.zeros((K, K)), np.inf] for j in indices}
178
+ loss_correction = dict.fromkeys(indices, 0.0)
179
+ for raw in batches():
180
+ for j in indices:
181
+ X, d, _, w = weighted_system(transforms[j](raw))
182
+ if not len(d):
183
+ continue
184
+ eta = X @ betas[j]
185
+ p = expit(eta)
186
+ # Compensated accumulation keeps the objective stable across
187
+ # many small batches without retaining their individual sums.
188
+ increment = float(w @ (np.logaddexp(0, eta) - d * eta)) - loss_correction[j]
189
+ total = out[j][0] + increment
190
+ loss_correction[j] = (total - out[j][0]) - increment
191
+ out[j][0] = total
192
+ out[j][1] += X.T @ (w * (d - p))
193
+ out[j][2] += X.T @ ((w * p * (1 - p))[:, None] * X)
194
+ out[j][3] = min(out[j][3], float(np.min((2 * d.astype(float) - 1) * eta)))
195
+ passes += 1
196
+ return out
197
+
198
+ betas = [s.beta.copy() for s in systems]
199
+ active = set(range(len(systems)))
200
+ current = stats(betas, active)
201
+ for _iteration in range(maxiter):
202
+ steps, done = {}, set()
203
+ for j in active:
204
+ loss, gradient, H, margin = current[j]
205
+ if margin > 1e-8:
206
+ raise ValueError("propensity logit: perfect separation / no overlap")
207
+ step = solve_checked(H, gradient, "propensity logit")
208
+ # Coefficients have inverse covariate units. Test each coordinate
209
+ # in weighted RMS design units so changing meters to micrometers
210
+ # cannot turn an unfinished fit into a converged one. The scale is
211
+ # fixed from the first pass, rather than shrinking with p*(1-p)
212
+ # near separation. No extra source pass or row storage is needed.
213
+ scale = systems[j].design_scale
214
+ if np.all(np.abs(step) * scale <= tol * (1 + np.abs(betas[j]) * scale)):
215
+ done.add(j)
216
+ else:
217
+ steps[j] = step
218
+ active -= done
219
+ if not active:
220
+ for j, s in enumerate(systems):
221
+ s.beta = betas[j]
222
+ return systems, passes
223
+ candidates = list(betas)
224
+ for j in active:
225
+ candidates[j] = betas[j] + steps[j]
226
+ trial = stats(candidates, active)
227
+ for _backtrack in range(30):
228
+ # The objective is a SUM, so its floating-point resolution grows
229
+ # with sample size. Near the optimum, a genuine Newton improvement
230
+ # can be smaller than summation noise. An absolute-only allowance
231
+ # causes repeated tiny backtracks/full source scans at large N.
232
+ # Keep the coefficient-step convergence test unchanged.
233
+ bad = {
234
+ j
235
+ for j in active
236
+ if not np.isfinite(trial[j][0])
237
+ or trial[j][0]
238
+ > current[j][0]
239
+ + max(1e-10, 64 * np.finfo(float).eps * max(1.0, abs(current[j][0])))
240
+ }
241
+ if not bad:
242
+ break
243
+ for j in bad:
244
+ steps[j] *= 0.5
245
+ candidates[j] = betas[j] + steps[j]
246
+ trial.update(stats(candidates, bad))
247
+ else:
248
+ raise ValueError(
249
+ "propensity logit line search failed; check overlap and covariate scaling"
250
+ )
251
+ for j in active:
252
+ betas[j] = candidates[j]
253
+ current[j] = trial[j]
254
+ systems[j].iterations += 1
255
+ raise ValueError("propensity logit did not converge; possible quasi-separation / poor overlap")