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,342 @@
1
+ """Compiled passes of the two-stage (Gardner) fit and its first-stage reduction.
2
+
3
+ Each pass visits every unit and does a handful of small weighted sums over the
4
+ unit's periods and the P design columns (the period dummies and covariates).
5
+ In NumPy that is a few hundred array operations per batch of units, which the
6
+ interpreter's lock serializes. The kernels do the same sums in one compiled
7
+ call per batch.
8
+
9
+ Units are processed in fixed-size chunks, and a chunk adds its units in order,
10
+ so the sums, and with them the estimates, do not depend on the number of
11
+ threads; the chunks' partial sums are added in order afterwards. The NumPy code
12
+ in ``linear.py`` and ``gardner.py`` remains the reference; the tests compare
13
+ 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; fixed so results do not depend on threads
21
+
22
+
23
+ @njit(cache=True, inline="always")
24
+ def _design(x, i, t, T1, columns, out):
25
+ """Row (unit i, period t) of the design restricted to ``columns``:
26
+ period dummies for periods 1.. then covariates, with missing values as 0."""
27
+ for a in range(columns.shape[0]):
28
+ j = columns[a]
29
+ if j < T1:
30
+ out[t, a] = 1.0 if (t >= 1 and j == t - 1) else 0.0
31
+ else:
32
+ v = x[i, t, j - T1]
33
+ out[t, a] = v if np.isfinite(v) else 0.0
34
+
35
+
36
+ @njit(parallel=True, cache=True)
37
+ def reduce_moments(y, x, w, wc, columns, do_clean, Q, q, norms, Qc, qc, bad):
38
+ """Within-unit moments of all columns for the rows with weights ``w``, and
39
+ (``Qc``, ``qc``) for the rows with weights ``wc``; ``norms`` are the columns'
40
+ weighted sums of squares. ``bad[chunk]`` is 1 when a unit has no weight.
41
+
42
+ A unit's demeaned design is nonzero only in the columns where one of its
43
+ rows is, which with fixed-effect dummies is a few of the P (its own levels,
44
+ not all of them), so when it is under half of them the products visit those
45
+ columns alone. The other entries would add exact zeros."""
46
+ n, T = y.shape
47
+ P = columns.shape[0]
48
+ T1 = T - 1
49
+ chunks = (n + CHUNK - 1) // CHUNK
50
+ for c in prange(chunks):
51
+ X = np.empty((T, P))
52
+ xm = np.zeros(P)
53
+ yv = np.zeros(T)
54
+ act = np.empty(P, dtype=np.int64)
55
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
56
+ for t in range(T):
57
+ _design(x, i, t, T1, columns, X)
58
+ yv[t] = y[i, t] if np.isfinite(y[i, t]) else 0.0
59
+ nact = 0
60
+ for a in range(P):
61
+ for t in range(T):
62
+ if X[t, a] != 0.0:
63
+ act[nact] = a
64
+ nact += 1
65
+ break
66
+ if 2 * nact >= P:
67
+ # mostly dense: contiguous loops over every column are faster than
68
+ # indexed ones, and the skipped entries are exact zeros either way
69
+ nact = P
70
+ for a in range(P):
71
+ act[a] = a
72
+ for pass_ in range(2 if do_clean else 1):
73
+ mass = 0.0
74
+ for t in range(T):
75
+ mass += w[i, t] if pass_ == 0 else wc[i, t]
76
+ if mass <= 0.0:
77
+ bad[c] = 1
78
+ continue
79
+ for a in range(P):
80
+ xm[a] = 0.0
81
+ ym = 0.0
82
+ for t in range(T):
83
+ wt = w[i, t] if pass_ == 0 else wc[i, t]
84
+ ym += wt * yv[t]
85
+ for ia in range(nact):
86
+ a = act[ia]
87
+ xm[a] += wt * X[t, a]
88
+ ym /= mass
89
+ for ia in range(nact):
90
+ a = act[ia]
91
+ xm[a] /= mass
92
+ for t in range(T):
93
+ wt = w[i, t] if pass_ == 0 else wc[i, t]
94
+ if wt == 0.0:
95
+ continue
96
+ ry = yv[t] - ym
97
+ for ia in range(nact):
98
+ a = act[ia]
99
+ xa = (X[t, a] - xm[a]) * wt
100
+ if pass_ == 0:
101
+ q[c, a] += xa * ry
102
+ if nact == P:
103
+ # every column is active: a contiguous loop vectorizes
104
+ for b in range(P):
105
+ Q[c, a, b] += xa * (X[t, b] - xm[b])
106
+ else:
107
+ for ib in range(nact):
108
+ b = act[ib]
109
+ Q[c, a, b] += xa * (X[t, b] - xm[b])
110
+ else:
111
+ qc[c, a] += xa * ry
112
+ if nact == P:
113
+ for b in range(P):
114
+ Qc[c, a, b] += xa * (X[t, b] - xm[b])
115
+ else:
116
+ for ib in range(nact):
117
+ b = act[ib]
118
+ Qc[c, a, b] += xa * (X[t, b] - xm[b])
119
+ for t in range(T):
120
+ for ia in range(nact):
121
+ a = act[ia]
122
+ norms[c, a] += w[i, t] * X[t, a] * X[t, a]
123
+
124
+
125
+ @njit(cache=True, inline="always")
126
+ def _first_stage(g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, score1, obs):
127
+ """One unit's first-stage residuals ``u1`` (clean-sample imputation), the
128
+ clean-weighted mean ``xm`` of its design and its first-stage score.
129
+ Returns 0, or 1 when the unit has no untreated observation."""
130
+ T = times.shape[0]
131
+ P = columns.shape[0]
132
+ for t in range(T):
133
+ _design(x, i, t, T1, columns, X)
134
+ mass = 0.0
135
+ for a in range(P):
136
+ xm[a] = 0.0
137
+ alpha = 0.0
138
+ for t in range(T):
139
+ obs[t] = np.isfinite(y[i, t])
140
+ use = obs[t] and (g[i] == 0 or times[t] < g[i] - anticipation)
141
+ cw = w[i, t] if use else 0.0
142
+ fit = 0.0
143
+ for a in range(P):
144
+ fit += X[t, a] * theta[a]
145
+ u1[t] = (y[i, t] - fit) if obs[t] else 0.0
146
+ mass += cw
147
+ alpha += cw * u1[t]
148
+ for a in range(P):
149
+ xm[a] += cw * X[t, a]
150
+ if mass <= 0.0:
151
+ return 1
152
+ alpha /= mass
153
+ for a in range(P):
154
+ xm[a] /= mass
155
+ score1[a] = 0.0
156
+ for t in range(T):
157
+ use = obs[t] and (g[i] == 0 or times[t] < g[i] - anticipation)
158
+ cw = w[i, t] if use else 0.0
159
+ u1[t] = (u1[t] - alpha) if obs[t] else 0.0
160
+ for a in range(P):
161
+ score1[a] += cw * u1[t] * (X[t, a] - xm[a])
162
+ return 0
163
+
164
+
165
+ @njit(cache=True, inline="always")
166
+ def _indicator(g, i, t, times, anticipation, horizons, m, dynamic):
167
+ """Second-stage indicator of coefficient m for unit i in period t (before
168
+ the observed-row condition)."""
169
+ if g[i] == 0:
170
+ return 0.0
171
+ event = times[t] - g[i]
172
+ if dynamic:
173
+ return 1.0 if event == horizons[m] else 0.0
174
+ return 1.0 if event >= -anticipation else 0.0
175
+
176
+
177
+ @njit(parallel=True, cache=True)
178
+ def second_stage_sums(
179
+ g, y, x, w, columns, theta, times, anticipation, horizons, dynamic, mass, rhs, C, bad
180
+ ):
181
+ """The second stage's sums: Z'w, Z'w u1 and Z'w (X - xm) (``gardner._fit``)."""
182
+ n, T = y.shape
183
+ P = columns.shape[0]
184
+ M = mass.shape[1]
185
+ T1 = T - 1
186
+ chunks = (n + CHUNK - 1) // CHUNK
187
+ for c in prange(chunks):
188
+ X = np.empty((T, P))
189
+ xm = np.zeros(P)
190
+ u1 = np.zeros(T)
191
+ s1 = np.zeros(P)
192
+ obs = np.zeros(T, dtype=np.bool_)
193
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
194
+ if _first_stage(
195
+ g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
196
+ ):
197
+ bad[c] = 1
198
+ continue
199
+ for t in range(T):
200
+ if not obs[t]:
201
+ continue
202
+ for m in range(M):
203
+ z = _indicator(g, i, t, times, anticipation, horizons, m, dynamic)
204
+ if z == 0.0:
205
+ continue
206
+ mass[c, m] += w[i, t]
207
+ rhs[c, m] += w[i, t] * u1[t]
208
+ for a in range(P):
209
+ C[c, m, a] += w[i, t] * (X[t, a] - xm[a])
210
+
211
+
212
+ @njit(parallel=True, cache=True)
213
+ def cluster_scores(
214
+ g, y, x, w, columns, theta, times, anticipation, horizons, dynamic, beta, H, mass, out, bad
215
+ ):
216
+ """Each unit's second-stage score, corrected for the first stage and scaled
217
+ by the indicators' mass: ``out[unit, m]``."""
218
+ n, T = y.shape
219
+ P = columns.shape[0]
220
+ M = beta.shape[0]
221
+ T1 = T - 1
222
+ chunks = (n + CHUNK - 1) // CHUNK
223
+ for c in prange(chunks):
224
+ X = np.empty((T, P))
225
+ xm = np.zeros(P)
226
+ u1 = np.zeros(T)
227
+ s1 = np.zeros(P)
228
+ obs = np.zeros(T, dtype=np.bool_)
229
+ z = np.zeros(M)
230
+ for i in range(c * CHUNK, min(n, (c + 1) * CHUNK)):
231
+ if _first_stage(
232
+ g, y, x, w, i, T1, columns, theta, times, anticipation, X, xm, u1, s1, obs
233
+ ):
234
+ bad[c] = 1
235
+ continue
236
+ score2 = np.zeros(M)
237
+ for t in range(T):
238
+ fitted = 0.0
239
+ for m in range(M):
240
+ z[m] = _indicator(g, i, t, times, anticipation, horizons, m, dynamic)
241
+ if not obs[t]:
242
+ z[m] = 0.0
243
+ fitted += z[m] * beta[m]
244
+ u2 = u1[t] - fitted
245
+ for m in range(M):
246
+ score2[m] += w[i, t] * u2 * z[m]
247
+ for m in range(M):
248
+ adjust = 0.0
249
+ for a in range(P):
250
+ adjust += s1[a] * H[a, m]
251
+ out[i, m] = (score2[m] - adjust) / mass[m]
252
+
253
+
254
+ def _arrays(raw):
255
+ g, y, x, w = raw
256
+ g = np.ascontiguousarray(g, dtype=np.int64)
257
+ y = np.ascontiguousarray(y, dtype=np.float64)
258
+ x = np.ascontiguousarray(x, dtype=np.float64)
259
+ w = np.ascontiguousarray(w, dtype=np.float64)
260
+ return g, y, x, w
261
+
262
+
263
+ def _chunks(n):
264
+ return (n + CHUNK - 1) // CHUNK
265
+
266
+
267
+ class TwoStageKernels:
268
+ """The compiled passes for a store: ``times`` are its period labels."""
269
+
270
+ def __init__(self, times, anticipation):
271
+ self.times = np.ascontiguousarray(times, dtype=np.int64)
272
+ self.anticipation = int(anticipation)
273
+
274
+ def reduce(self, batches, n_columns, clean):
275
+ """(Q, q, norms, Qc, qc) over the batches of (g, y, x, w), all columns kept."""
276
+ columns = np.arange(n_columns, dtype=np.int64)
277
+ P = n_columns
278
+ Q, norms, Qc, qc = np.zeros((P, P)), np.zeros(P), np.zeros((P, P)), np.zeros(P)
279
+ q = np.zeros(P)
280
+ for raw in batches():
281
+ g, y, x, w = _arrays(raw)
282
+ wc = w * clean(g) if clean is not None else w
283
+ k = _chunks(len(g))
284
+ parts = dict(
285
+ Q=np.zeros((k, P, P)),
286
+ q=np.zeros((k, P)),
287
+ norms=np.zeros((k, P)),
288
+ Qc=np.zeros((k, P, P)),
289
+ qc=np.zeros((k, P)),
290
+ bad=np.zeros(k, dtype=np.int8),
291
+ )
292
+ reduce_moments(
293
+ y, x, w, wc, columns, clean is not None, parts["Q"], parts["q"], parts["norms"], parts["Qc"],
294
+ parts["qc"], parts["bad"],
295
+ ) # fmt: skip
296
+ if parts["bad"].any():
297
+ raise ValueError("each group requires a positive-weight estimation observation")
298
+ Q += parts["Q"].sum(axis=0)
299
+ q += parts["q"].sum(axis=0)
300
+ norms += parts["norms"].sum(axis=0)
301
+ Qc += parts["Qc"].sum(axis=0)
302
+ qc += parts["qc"].sum(axis=0)
303
+ return Q, q, norms, Qc, qc
304
+
305
+ def second_stage(self, batches, keep, theta, horizons, dynamic):
306
+ """(mass, rhs, C): the sums of the second stage's normal equations."""
307
+ columns = np.ascontiguousarray(keep, dtype=np.int64)
308
+ M, P = len(horizons), len(columns)
309
+ mass, rhs, C = np.zeros(M), np.zeros(M), np.zeros((M, P))
310
+ hz = np.ascontiguousarray(horizons, dtype=np.int64)
311
+ for raw in batches():
312
+ g, y, x, w = _arrays(raw)
313
+ k = _chunks(len(g))
314
+ parts = (
315
+ np.zeros((k, M)),
316
+ np.zeros((k, M)),
317
+ np.zeros((k, M, P)),
318
+ np.zeros(k, dtype=np.int8),
319
+ )
320
+ second_stage_sums(
321
+ g, y, x, w, columns, theta, self.times, self.anticipation, hz, dynamic, *parts
322
+ )
323
+ if parts[3].any():
324
+ raise ValueError("requires at least one untreated observation per unit")
325
+ mass += parts[0].sum(axis=0)
326
+ rhs += parts[1].sum(axis=0)
327
+ C += parts[2].sum(axis=0)
328
+ return mass, rhs, C
329
+
330
+ def scores(self, raw, keep, theta, horizons, dynamic, beta, H, mass):
331
+ columns = np.ascontiguousarray(keep, dtype=np.int64)
332
+ g, y, x, w = _arrays(raw)
333
+ out = np.zeros((len(g), len(beta)))
334
+ bad = np.zeros(_chunks(len(g)), dtype=np.int8)
335
+ cluster_scores(
336
+ g, y, x, w, columns, theta, self.times, self.anticipation,
337
+ np.ascontiguousarray(horizons, dtype=np.int64), dynamic, beta,
338
+ np.ascontiguousarray(H), mass, out, bad,
339
+ ) # fmt: skip
340
+ if bad.any():
341
+ raise ValueError("requires at least one untreated observation per unit")
342
+ return out
@@ -0,0 +1,26 @@
1
+ """Sampling-weight validation and optional weighted batch adapters.
2
+
3
+ No treatment or panel conventions live here. Consumer code owns restrictions
4
+ such as weights being constant within a sampling unit.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ def validate_weights(values):
11
+ """Require finite, strictly positive numeric sampling weights."""
12
+ if not np.isfinite(values).all() or np.any(values <= 0):
13
+ raise ValueError("sampling weights must be finite and strictly positive")
14
+
15
+
16
+ def weighted_system(parts):
17
+ """Accept (X, d, Y[, weights]); preserve the unweighted consumer protocol."""
18
+ X, d, Y = parts[:3]
19
+ w = parts[3] if len(parts) == 4 else np.ones(len(d))
20
+ return X, d, Y, w
21
+
22
+
23
+ def weighted_linear(parts):
24
+ """Accept (X, y[, weights]) for a weighted least-squares stratum."""
25
+ X, y = parts[:2]
26
+ return X, y, parts[2] if len(parts) == 3 else np.ones(len(y))
@@ -0,0 +1,81 @@
1
+ """Bounded weighted elimination of group intercepts and small Gram reduction.
2
+
3
+ Arrays contain one batch of whole groups. No treatment, time or panel policy
4
+ lives here; these helpers are candidates for hdfe_stream.core.
5
+ """
6
+
7
+ import numpy as np
8
+ from scipy.linalg import solve_triangular
9
+
10
+
11
+ def center_groups(X, y, weights):
12
+ """Eliminate intercepts using row weights (zero means excluded internally)."""
13
+ mass = weights.sum(axis=1)
14
+ if np.any(mass <= 0):
15
+ raise ValueError("each group requires a positive-weight estimation observation")
16
+ xm = row_sums(weights, X) / mass[:, None]
17
+ ym = (weights * y).sum(axis=1) / mass
18
+ return X - xm[:, None, :], y - ym[:, None], xm, ym
19
+
20
+
21
+ def moments(X, y, weights):
22
+ xc, yc, _, _ = center_groups(X, y, weights)
23
+ flat = xc.reshape(-1, xc.shape[2])
24
+ weighted = weights.reshape(-1, 1) * flat
25
+ return flat.T @ weighted, weighted.T @ yc.ravel()
26
+
27
+
28
+ # Batched sums over the period axis of (units, periods, columns) arrays, as
29
+ # matrix products: einsum would copy its operands and run single-threaded.
30
+
31
+
32
+ def row_sums(weights, X):
33
+ """sum_t weights[b, t] * X[b, t, k] -> (units, columns)."""
34
+ return np.matmul(weights[:, None, :], X)[:, 0, :]
35
+
36
+
37
+ def weighted_gram(X, weights):
38
+ """sum_{b,t} weights[b, t] * X[b, t, i] * X[b, t, j] -> (columns, columns)."""
39
+ flat = X.reshape(-1, X.shape[2])
40
+ return flat.T @ (weights.reshape(-1, 1) * flat)
41
+
42
+
43
+ def weighted_squares(X, weights):
44
+ """sum_{b,t} weights[b, t] * X[b, t, k] ** 2 -> (columns,)."""
45
+ flat = X.reshape(-1, X.shape[2])
46
+ return (flat * flat).T @ weights.ravel()
47
+
48
+
49
+ def independent_columns(gram, *, original_norm=None, tolerance=1e-10):
50
+ """Select an ordered basis after scaling columns to unit Gram diagonal.
51
+
52
+ Earlier columns have priority. Return aliases explicitly for the caller's
53
+ diagnostics; consumers must separately protect identified target columns.
54
+
55
+ A column is kept when its squared distance from the span of the columns kept
56
+ before it exceeds `tolerance`: an ordered Cholesky factorization that grows
57
+ one row per kept column (a triangular solve per column, O(K^3) in all).
58
+ """
59
+ diagonal = np.maximum(np.diag(gram), 0)
60
+ scale = np.sqrt(diagonal)
61
+ live = diagonal > 0 if original_norm is None else diagonal > original_norm * 1e-24
62
+ safe = np.where(live, scale, 1)
63
+ correlation = gram / safe[:, None] / safe[None, :]
64
+ candidates = np.flatnonzero(live)
65
+ factor = np.zeros((len(candidates), len(candidates))) # lower triangular
66
+ kept = []
67
+ for j in candidates:
68
+ m = len(kept)
69
+ if m:
70
+ row = solve_triangular(
71
+ factor[:m, :m], correlation[j, kept], lower=True, check_finite=False
72
+ )
73
+ remaining = correlation[j, j] - row @ row
74
+ else:
75
+ row = np.zeros(0)
76
+ remaining = correlation[j, j]
77
+ if remaining > tolerance:
78
+ factor[m, :m] = row
79
+ factor[m, m] = np.sqrt(remaining)
80
+ kept.append(j)
81
+ return np.asarray(kept, dtype=int)
diff_stream/_docs.py ADDED
@@ -0,0 +1,100 @@
1
+ """Parameter descriptions shared by the public functions' docstrings."""
2
+
3
+ DATA = """\
4
+ data : polars.DataFrame, polars.LazyFrame, str or Path
5
+ The panel, or a Parquet path or glob. Rows are read in streaming
6
+ batches; the data need not fit in memory.
7
+ panel : tuple of str
8
+ The unit and time columns, ``(unit, time)``. Periods are positive
9
+ integers, evenly spaced (every year, every second year, ...). Event
10
+ times, ``anticipation`` and ``pretrend_leads`` count periods; results
11
+ label cohorts and periods as in the data.
12
+ cohort, treatment : str
13
+ Exactly one. ``cohort``: each unit's first treated period, constant
14
+ within the unit; 0 or null marks a never-treated unit. ``treatment``: a
15
+ 0/1 column, absorbing (once 1, a unit stays treated), from which each
16
+ unit's first treated period is taken."""
17
+
18
+ ANTICIPATION = """\
19
+ anticipation : int, default 0
20
+ Periods before treatment in which units may already respond. Clean
21
+ (untreated) observations are those before ``cohort - anticipation``;
22
+ event times stay relative to actual treatment."""
23
+
24
+ WEIGHTS = """\
25
+ weights : str, optional
26
+ A column of sampling weights: positive, finite, and constant within
27
+ each unit unless ``allow_time_varying_weights=True``. They enter the
28
+ estimates, aggregation and clustered standard errors.
29
+ allow_time_varying_weights : bool, default False
30
+ Accept weights that change over time within a unit."""
31
+
32
+ INCOMPLETE_UNITS = """\
33
+ incomplete_units : {"error", "drop", "keep"}, default "error"
34
+ What to do with units missing some periods: refuse the data, drop
35
+ those units, or keep their observed rows (an unbalanced panel)."""
36
+
37
+ LAST_TREATED = """\
38
+ last_treated_controls : {"if_needed", "never", "always"}, default "if_needed"
39
+ When to use the last-treated cohort as a comparison group, keeping
40
+ only periods before it is treated. "if_needed" does so only when there
41
+ are no never-treated units (with a ``ReferenceWindowWarning``);
42
+ "never" requires never-treated units; "always" uses it even when
43
+ never-treated units exist (they remain controls too)."""
44
+
45
+ FIXED_EFFECT_SOLVER = """\
46
+ fixed_effect_solver : {"auto", "dense", "iterative"}, default "auto"
47
+ How additional fixed effects (after the bar in ``fml``) are
48
+ estimated. "dense" adds a dummy for each level to the regression,
49
+ which is exact and fastest with few levels, and limited to 256 columns in
50
+ all. "iterative" absorbs them by alternating projections (hdfe-stream),
51
+ with a time that does not grow with the number of levels and no limit
52
+ on it, such as county-by-year. "auto" uses dense for up to 40 columns
53
+ (levels and covariates) and iterative above that. Both give the same
54
+ estimates and standard errors."""
55
+
56
+ VCOV = """\
57
+ vcov : {"CRV0", "CRV1"}, default "CRV0"
58
+ Standard errors clustered by unit. CRV1 multiplies the variance by
59
+ N/(N-1)."""
60
+
61
+ RESOURCES = """\
62
+ n_threads : int, optional
63
+ Threads for numba and BLAS during the fit (stream-limits'
64
+ ``thread_scope``). Default: every core the job has. Polars' threads
65
+ are fixed when polars is imported: set ``STREAM_LIMITS_MAX_THREADS``
66
+ in the job script.
67
+ max_memory_gb, memory_reserve : float, optional
68
+ The memory limit and the share of it kept in reserve for allocations
69
+ the estimates do not model (stream-limits' ``MemoryBudget``; default:
70
+ the limits it detects, including a scheduler job's, and a 15%
71
+ reserve). Fits that would exceed the limit stop with ``MemoryError``
72
+ before allocating.
73
+ workdir : str or Path, optional
74
+ Where temporary files are written. Default: the system temporary
75
+ directory.
76
+ profile : bool, default False
77
+ Return a ``Profile`` (hdfe-stream's) of the time, memory and disk the
78
+ fit needs, without fitting.
79
+ batch_units : int, optional
80
+ Advanced: units per numerical batch.
81
+ preprocess_mb : float, optional
82
+ Advanced: the working memory for sorting and validating the data, in
83
+ MB. Default: a quarter of the memory left under ``max_memory_gb``, so a
84
+ panel that fits is sorted in one piece; larger panels are split into
85
+ whole-unit pieces on disk. A smaller value uses less memory and more
86
+ time."""
87
+
88
+
89
+ def document(function):
90
+ """Fill the shared parameter blocks into a public function's docstring."""
91
+ blocks = {
92
+ name: value
93
+ for name, value in globals().items()
94
+ if name.isupper() and isinstance(value, str)
95
+ }
96
+ text = function.__doc__
97
+ for name, value in blocks.items():
98
+ text = text.replace(" {" + name + "}\n", value + "\n")
99
+ function.__doc__ = text
100
+ return function
@@ -0,0 +1,62 @@
1
+ """Public option names and values, translated to the estimators' internal ones.
2
+
3
+ The public functions spell options out (``incomplete_units="drop"``); the
4
+ estimation code was written against shorter internal names
5
+ (``panel_mode="complete"``). Translation happens once, here, so that the
6
+ estimator functions and refit_bootstrap() accept exactly the same options.
7
+ """
8
+
9
+ # public option name -> internal option name
10
+ NAMES = {
11
+ "n_threads": "threads",
12
+ "max_memory_gb": "memory_gb",
13
+ "incomplete_units": "panel_mode",
14
+ "last_treated_controls": "reference_group",
15
+ "adjustment": "est_method",
16
+ "propensity_trim": "trim_level",
17
+ "multiplier_bootstrap_reps": "bootstrap_reps",
18
+ "multiplier_bootstrap_seed": "bootstrap_seed",
19
+ "multiplier_bootstrap_weights": "bootstrap_weights",
20
+ "effective_sample_warning": "ess_warn_threshold",
21
+ "min_effective_sample": "ess_suppress_threshold",
22
+ "covariate_balance": "balance",
23
+ "balance_distributions": "balance_ecdf",
24
+ "residual_averaging": "avgeffectsby",
25
+ "leave_out": "leaveout",
26
+ "pretrend_leads": "pretrends",
27
+ "event_study": "specification",
28
+ "treat_sample_as_fixed": "inference",
29
+ "zero_tolerance": "zero_tol",
30
+ "fixed_effect_solver": "fe_solver",
31
+ }
32
+
33
+ # public option name -> {public value: internal value}
34
+ VALUES = {
35
+ "control_group": {"never_treated": "nevertreated", "not_yet_treated": "notyettreated"},
36
+ "incomplete_units": {"error": "balanced", "drop": "complete", "keep": "unbalanced"},
37
+ "last_treated_controls": {
38
+ "if_needed": "auto",
39
+ "never": "nevertreated",
40
+ "always": "lasttreated",
41
+ },
42
+ "adjustment": {"regression": "reg", "ipw": "ipw", "doubly_robust": "dr"},
43
+ "event_study": {True: "dynamic", False: "static"},
44
+ "treat_sample_as_fixed": {False: "unconditional", True: "conditional"},
45
+ }
46
+
47
+
48
+ def internal(options):
49
+ """Translate public keyword options to the internal estimator's keywords."""
50
+ out = {}
51
+ for name, value in options.items():
52
+ if name in VALUES:
53
+ choices = VALUES[name]
54
+ if isinstance(next(iter(choices)), bool):
55
+ if not isinstance(value, bool):
56
+ raise ValueError(f"{name} must be True or False")
57
+ elif not isinstance(value, str) or value not in choices:
58
+ listed = ", ".join(repr(c) for c in choices)
59
+ raise ValueError(f"{name} must be one of {listed}; got {value!r}")
60
+ value = choices[value]
61
+ out[NAMES.get(name, name)] = value
62
+ return out