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/results.py ADDED
@@ -0,0 +1,521 @@
1
+ """Family-compatible result methods and explicit aggregation maps."""
2
+
3
+ from copy import copy
4
+ from dataclasses import dataclass, field
5
+
6
+ import numpy as np
7
+ import polars as pl
8
+ from hdfe_stream.core.results_base import RegressionResult, _select_coefs
9
+
10
+ from ._core.multiplier import multiplier_band
11
+ from ._present import (
12
+ PUBLIC_ESTIMATORS,
13
+ internal,
14
+ label_frame,
15
+ label_name,
16
+ labels_of,
17
+ present_plan,
18
+ )
19
+ from .aggregation import period_type, plan
20
+ from .bjs import BJSInference
21
+ from .bjs_placebo import BJSPlaceboResult
22
+
23
+
24
+ @dataclass(kw_only=True)
25
+ class DIDResult(RegressionResult):
26
+ """Group-time estimates and their joint covariance.
27
+
28
+ Inherits tidy, confint, wald_test, summary, summary_json and etable support.
29
+ ``aggregate`` returns event-time, cohort, calendar-time or simple effects.
30
+ CS additionally retains covariance blocks accounting for estimation of
31
+ cohort shares; no full unit-by-effect array is retained.
32
+ """
33
+
34
+ estimator: str
35
+ cells: list
36
+ counts: np.ndarray
37
+ n_units: int
38
+ _share_cross: np.ndarray | None = field(default=None, repr=False)
39
+ _share_cov: np.ndarray | None = field(default=None, repr=False)
40
+ _bjs_inference: BJSInference | None = field(default=None, repr=False)
41
+ observed_counts: np.ndarray | None = field(default=None, repr=False)
42
+ cohort_counts: np.ndarray | None = field(default=None, repr=False)
43
+ _contributor_table: pl.DataFrame | None = field(default=None, repr=False)
44
+ _composition_table: pl.DataFrame | None = field(default=None, repr=False)
45
+ _multiplier_draws: np.ndarray | None = field(default=None, repr=False)
46
+ _multiplier_shares: np.ndarray | None = field(default=None, repr=False)
47
+ _bjs_placebos: BJSPlaceboResult | None = field(default=None, repr=False)
48
+ _balance_table: pl.DataFrame | None = field(default=None, repr=False)
49
+ _ess_table: pl.DataFrame | None = field(default=None, repr=False)
50
+ _suppression_mask: np.ndarray | None = field(default=None, repr=False)
51
+ _suppression_source: object = field(default=None, repr=False)
52
+ _internal: dict | None = field(default=None, repr=False)
53
+ _periods: object = field(default=None, repr=False)
54
+ _normal_inference = True
55
+
56
+ def covariate_balance(self, *, cohort=None, time=None):
57
+ """Copy saved CS before/after propensity-weight balance diagnostics.
58
+
59
+ Request balance=True at fitting. Regression-only fits report before
60
+ weighting; no artificial regression-adjusted covariate sample is defined.
61
+ Native unbalanced fits report pre/post date strata separately. Baseline
62
+ pooled SD scales are held fixed after weighting. These are descriptive
63
+ diagnostics, not tests of parallel trends or a DR identification check.
64
+ """
65
+ if self._balance_table is None:
66
+ raise ValueError("fit with covariate_balance=True to save covariate balance tables")
67
+ table = label_frame(self._balance_table, labels_of(self))
68
+ if cohort is not None:
69
+ table = table.filter(pl.col("cohort") == cohort)
70
+ if time is not None:
71
+ table = table.filter(pl.col("time") == time)
72
+ return table.clone()
73
+
74
+ def effective_sample_size(self):
75
+ """CS Kish ESS by required comparison stratum, including aggregate contributors.
76
+
77
+ Sampling weights define treated ESS and regression controls; IPW/DR
78
+ controls include propensity odds and trimming. Native unbalanced panels
79
+ report pre/post strata separately. This is not regression-leverage or
80
+ influence-score ESS. Aggregates retain component ESS, never a pooled ESS.
81
+ """
82
+ if self._ess_table is None:
83
+ raise ValueError("effective_sample_size is currently available for CS")
84
+ return self._ess_table.clone()
85
+
86
+ def _minimum_ess(self):
87
+ table = (
88
+ self.effective_sample_size().group_by("coefficient").agg(pl.col("effective_n").min())
89
+ )
90
+ values = dict(table.iter_rows())
91
+ return np.array([values[name] for name in self.coefnames])
92
+
93
+ def suppress(self, min_effective_sample=30):
94
+ """Copy with low-support CS coefficients and covariance rows/columns as NaN.
95
+
96
+ Uses the minimum Kish ESS among each coefficient's required component
97
+ strata. Existing suppression is retained. No refitting, dropping cells,
98
+ renormalizing aggregation weights or changing the original fit occurs.
99
+ Aggregates depending on a suppressed cell are also suppressed. Joint
100
+ inference must explicitly select unsuppressed coefficients.
101
+ """
102
+ from .effective_sample import threshold
103
+
104
+ limit = threshold(min_effective_sample, "min_effective_sample", optional=False)
105
+ return self._apply_suppression(self._minimum_ess() < limit, limit)
106
+
107
+ def _apply_suppression(self, mask, limit):
108
+ out = copy(self)
109
+ mask = np.array(mask, dtype=bool, copy=True)
110
+ if self._suppression_mask is not None:
111
+ mask |= self._suppression_mask
112
+ out._suppression_source = self._suppression_source or self
113
+ out._suppression_mask = mask
114
+ out.beta, out.vcov = self.beta.copy(), self.vcov.copy()
115
+ out.beta[mask] = np.nan
116
+ out.vcov[mask, :] = np.nan
117
+ out.vcov[:, mask] = np.nan
118
+ out.all_vcovs = {out.vcov_type: (out.vcov, out.df_t)}
119
+ if self._multiplier_draws is not None:
120
+ out._multiplier_draws = self._multiplier_draws.copy()
121
+ out._multiplier_draws[:, mask] = np.nan
122
+ names = [name for name, bad in zip(self.coefnames, mask, strict=True) if bad]
123
+ out.diagnostics = {
124
+ **self.diagnostics,
125
+ "suppressed_coefficients": names,
126
+ "suppression_threshold": max(limit, self.diagnostics.get("suppression_threshold", 0)),
127
+ "suppression_rule": "minimum component-stratum ESS; aggregates retain original weights",
128
+ }
129
+ if self._contributor_table is not None:
130
+ out._contributor_table = self._contributor_table.with_columns(
131
+ pl.when(pl.col("aggregate").is_in(names))
132
+ .then(float("nan"))
133
+ .otherwise(pl.col(column))
134
+ .alias(column)
135
+ for column in ("estimate", "weighted_estimate")
136
+ )
137
+ return out
138
+
139
+ def confint(
140
+ self,
141
+ alpha=0.05,
142
+ keep=None,
143
+ drop=None,
144
+ exact_match=False,
145
+ joint=False,
146
+ reps=10000,
147
+ seed=None,
148
+ ):
149
+ if joint and self._suppression_mask is not None:
150
+ ix = _select_coefs(self.coefnames, keep, drop, exact_match)
151
+ if self._suppression_mask[ix].any():
152
+ raise ValueError(
153
+ "joint inference includes suppressed coefficients; select unsuppressed coefficients explicitly"
154
+ )
155
+ return super().confint(
156
+ alpha=alpha,
157
+ keep=keep,
158
+ drop=drop,
159
+ exact_match=exact_match,
160
+ joint=joint,
161
+ reps=reps,
162
+ seed=seed,
163
+ )
164
+
165
+ def wald_test(self, R=None, q=None, distribution=None, keep=None, drop=None, exact_match=False):
166
+ if self._suppression_mask is not None and self._suppression_mask.any():
167
+ if R is None:
168
+ ix = _select_coefs(self.coefnames, keep, drop, exact_match)
169
+ else:
170
+ restriction = np.atleast_2d(np.asarray(R, dtype=float))
171
+ if restriction.shape[1] != len(self.coefnames):
172
+ raise ValueError("R must have one column per coefficient")
173
+ ix = np.flatnonzero(np.any(restriction != 0, axis=0))
174
+ if self._suppression_mask[ix].any():
175
+ raise ValueError(
176
+ "Wald test includes suppressed coefficients; select unsuppressed coefficients explicitly"
177
+ )
178
+ source = self._suppression_source
179
+ if source.vcov_type != self.vcov_type:
180
+ source = source.with_vcov(self.vcov_type)
181
+ return source.wald_test(
182
+ R=R, q=q, distribution=distribution, keep=keep, drop=drop, exact_match=exact_match
183
+ )
184
+ return super().wald_test(
185
+ R=R, q=q, distribution=distribution, keep=keep, drop=drop, exact_match=exact_match
186
+ )
187
+
188
+ def pretrend_test(self, *, level="auto", horizons=None):
189
+ """Joint chi-squared Wald test of clean pre-treatment effects.
190
+
191
+ By default test cohort-time cells on an original CS/SA fit, or horizons
192
+ on an event-time aggregate. level="event_time" aggregates an original
193
+ fit first. Anticipation periods and omitted references are excluded.
194
+ Singular covariance raises. BJS tests its separate clean-lead fit,
195
+ requested at fit time with pretrend_leads=L; only pooled event-time
196
+ tests apply.
197
+ The returned dictionary lists the exact coefficients, cells and horizons.
198
+ """
199
+ from .pretrend import pretrend_test
200
+
201
+ return pretrend_test(self, level=level, horizons=horizons)
202
+
203
+ def placebos(self):
204
+ """Separate BJS clean-lead regression result, fitted with pretrend_leads=L.
205
+
206
+ Available after cleanup and on treatment-effect aggregates. Lead
207
+ coefficients are pooled WLS regression effects, not group-time ATTs.
208
+ No joint covariance with the main treatment estimates is estimated.
209
+ """
210
+ if self.estimator != "bjs":
211
+ raise ValueError("placebos() is available only for BJS")
212
+ if self._bjs_placebos is None:
213
+ raise ValueError("fit did_bjs with pretrend_leads=L to estimate pre-trend leads")
214
+ return self._bjs_placebos
215
+
216
+ def multiplier_draws(self, *, keep=None, drop=None, exact_match=False):
217
+ """Copy saved CS unit-multiplier perturbations (repetitions x effects).
218
+
219
+ Fit with bootstrap_reps>=2. Draws include share uncertainty after
220
+ aggregation and the selected CRV0/CRV1 scaling; no data are reread.
221
+ """
222
+ if self._multiplier_draws is None:
223
+ raise ValueError(
224
+ "fit with multiplier_bootstrap_reps >= 2 to keep multiplier bootstrap draws"
225
+ )
226
+ ix = _select_coefs(self.coefnames, keep, drop, exact_match)
227
+ factor = np.sqrt(self.n_units / (self.n_units - 1)) if self.vcov_type == "CRV1" else 1.0
228
+ return self._multiplier_draws[:, ix] * factor
229
+
230
+ def multiplier_confint(
231
+ self,
232
+ alpha=0.05,
233
+ *,
234
+ keep=None,
235
+ drop=None,
236
+ exact_match=False,
237
+ joint=True,
238
+ studentization="bootstrap",
239
+ ):
240
+ """CS unit-multiplier intervals, simultaneous by default.
241
+
242
+ Bootstrap studentization uses the draw IQR and empirical type-1
243
+ quantiles, as R did. 'analytic' uses the stored analytic standard
244
+ errors instead. The result's vcov/se and inherited confint stay analytic.
245
+ Exactly the keep/drop selection defines the simultaneous family.
246
+ """
247
+ if studentization not in ("bootstrap", "analytic"):
248
+ raise ValueError("studentization must be 'bootstrap' or 'analytic'")
249
+ ix = _select_coefs(self.coefnames, keep, drop, exact_match)
250
+ if self._suppression_mask is not None and self._suppression_mask[ix].any():
251
+ raise ValueError(
252
+ "multiplier inference includes suppressed coefficients; select unsuppressed coefficients explicitly"
253
+ )
254
+ draws = self.multiplier_draws(keep=keep, drop=drop, exact_match=exact_match)
255
+ se, critical = multiplier_band(
256
+ draws,
257
+ alpha,
258
+ joint=joint,
259
+ analytic_se=self.se[ix] if studentization == "analytic" else None,
260
+ )
261
+ beta = self.beta[ix]
262
+ return pl.DataFrame(
263
+ {
264
+ "Coefficient": [self.coefnames[i] for i in ix],
265
+ "Estimate": beta,
266
+ "Std. Error": se,
267
+ "critical_value": np.broadcast_to(critical, len(ix)),
268
+ f"{alpha / 2 * 100:.1f}%": beta - critical * se,
269
+ f"{(1 - alpha / 2) * 100:.1f}%": beta + critical * se,
270
+ }
271
+ )
272
+
273
+ def cleanup(self):
274
+ """Release optional weighted/native unbalanced BJS aggregation files.
275
+
276
+ Estimates, stored covariance and coefficient tables remain available.
277
+ Further BJS aggregation requires refitting. Shallow copies made by
278
+ with_vcov share ownership of these files.
279
+ """
280
+ if self._bjs_inference is not None and hasattr(self._bjs_inference, "cleanup"):
281
+ self._bjs_inference.cleanup()
282
+
283
+ def with_vcov(self, vcov):
284
+ """Copy using CRV0 or CRV1 (the latter applies N/(N-1) only)."""
285
+ if vcov not in ("CRV0", "CRV1"):
286
+ raise ValueError("vcov must be 'CRV0' or 'CRV1'")
287
+ factor = self.n_units / (self.n_units - 1)
288
+ ratio = (factor if vcov == "CRV1" else 1) / (factor if self.vcov_type == "CRV1" else 1)
289
+ out = copy(self)
290
+ out.vcov = self.vcov * ratio
291
+ out.vcov_type = vcov
292
+ out.all_vcovs = {vcov: (out.vcov, out.df_t)}
293
+ if self._share_cross is not None:
294
+ out._share_cross = self._share_cross * ratio
295
+ out._share_cov = self._share_cov * ratio
296
+ if self._bjs_placebos is not None:
297
+ out._bjs_placebos = self._bjs_placebos.with_vcov(vcov)
298
+ return out
299
+
300
+ def cohort_time(self):
301
+ """Coefficient table with cohort/time and weighting/observed/cohort counts."""
302
+ if not self.cells:
303
+ raise ValueError("cohort_time is available on unaggregated results")
304
+ units = self.cohort_counts if self.estimator == "cs" else self.observed_counts
305
+ units = self.counts if units is None else units
306
+ return (
307
+ self.tidy()
308
+ .with_columns(
309
+ *(
310
+ [
311
+ pl.Series("min_effective_sample", self._minimum_ess()),
312
+ pl.Series(
313
+ "suppressed",
314
+ self._suppression_mask
315
+ if self._suppression_mask is not None
316
+ else np.zeros(len(self.beta), dtype=bool),
317
+ ),
318
+ ]
319
+ if self._ess_table is not None
320
+ else []
321
+ ),
322
+ pl.Series("cohort", [c[0] for c in self.cells]),
323
+ pl.Series("time", [c[1] for c in self.cells]),
324
+ pl.Series("event_time", [c[1] - c[0] for c in self.cells]),
325
+ pl.Series(
326
+ "period_type",
327
+ [
328
+ period_type(t - g, self.diagnostics.get("anticipation", 0))
329
+ for g, t in self.cells
330
+ ],
331
+ ),
332
+ pl.Series("n_units", units.astype(int)),
333
+ pl.Series("weight_units", units.astype(int)),
334
+ pl.Series("weight_total", self.counts * self.diagnostics.get("weight_scale", 1.0)),
335
+ pl.Series(
336
+ "observed_units",
337
+ self.observed_counts.astype(int)
338
+ if self.observed_counts is not None
339
+ else [None] * len(self.cells),
340
+ dtype=pl.Int64,
341
+ ),
342
+ pl.Series(
343
+ "cohort_units",
344
+ self.cohort_counts.astype(int)
345
+ if self.cohort_counts is not None
346
+ else [None] * len(self.cells),
347
+ dtype=pl.Int64,
348
+ ),
349
+ )
350
+ .pipe(label_frame, labels_of(self))
351
+ )
352
+
353
+ def contributions(
354
+ self, kind=None, *, horizons=None, balanced_horizon=None, balance_window=None
355
+ ):
356
+ """Cell weights, estimates and sample counts for each aggregate.
357
+
358
+ On an original result, arguments match aggregate(). On an aggregate,
359
+ call without arguments to inspect its saved contributors. Inspection
360
+ does not reread BJS aggregation files and works after cleanup().
361
+ """
362
+ return self._composition_view(False, kind, horizons, balanced_horizon, balance_window)
363
+
364
+ def composition(self, kind=None, *, horizons=None, balanced_horizon=None, balance_window=None):
365
+ """Cohort membership, cohort weights and sample totals per aggregate.
366
+
367
+ observed_unit_periods sums target-cell observations; it is not a count
368
+ of unique units across dates, nor a treated/control comparison sample.
369
+ """
370
+ return self._composition_view(True, kind, horizons, balanced_horizon, balance_window)
371
+
372
+ def _composition_view(self, summary, kind, horizons, balanced_horizon, balance_window):
373
+ if self.cells:
374
+ p = plan(
375
+ self,
376
+ "event_time" if kind is None else kind,
377
+ horizons,
378
+ balanced_horizon,
379
+ balance_window,
380
+ )
381
+ return label_frame(p.composition if summary else p.contributors, labels_of(self))
382
+ if any(x is not None for x in (kind, horizons, balanced_horizon, balance_window)):
383
+ raise ValueError("inspect an aggregated result without aggregation arguments")
384
+ table = self._composition_table if summary else self._contributor_table
385
+ if table is None:
386
+ raise ValueError("no aggregation contributors are available")
387
+ return label_frame(table, labels_of(self))
388
+
389
+ def aggregate(
390
+ self, kind="event_time", *, horizons=None, balanced_horizon=None, balance_window=None
391
+ ):
392
+ """Aggregate by event time, post-treatment cohort, calendar time or overall ATT.
393
+
394
+ kind is "event_time" (default, an event study), "cohort", "calendar" or
395
+ "overall" (one post-treatment ATT). horizons selects event times for
396
+ every kind; kinds other than "event_time" use only post-treatment cells. CS weights by retained cohort mass; SA/BJS by
397
+ observed cell mass (unit counts without sampling weights).
398
+ CS propagates estimated-share uncertainty, and BJS
399
+ recomputes contrast-specific smart residual/leave-out inference.
400
+
401
+ Event-time balanced_horizon=E requires supported events 0..E and retains R did's
402
+ event-time range [E-(Tmax-Tmin), E]. balance_window=(lo,hi) requires every
403
+ estimated event in that window in each retained cohort; omitted reference
404
+ events -(anticipation+1) are exempt. These restrictions balance cohorts, not the
405
+ identities of observed units. They are applied before horizons.
406
+ """
407
+ if self._suppression_source is not None:
408
+ source = self._suppression_source
409
+ if source.vcov_type != self.vcov_type:
410
+ source = source.with_vcov(self.vcov_type)
411
+ p = plan(source, kind, horizons, balanced_horizon, balance_window)
412
+ out = source.aggregate(
413
+ kind,
414
+ horizons=horizons,
415
+ balanced_horizon=balanced_horizon,
416
+ balance_window=balance_window,
417
+ )
418
+ mask = np.any((p.weights != 0) & self._suppression_mask[None, :], axis=1)
419
+ return out._apply_suppression(mask, self.diagnostics["suppression_threshold"])
420
+ p = plan(self, kind, horizons, balanced_horizon, balance_window)
421
+ A = p.weights
422
+ B = np.zeros_like(A)
423
+ for j in range(len(p.names)):
424
+ ix = A[j] != 0
425
+ B[j, ix] = (self.beta[ix] - A[j] @ self.beta) / self.counts[ix].sum()
426
+ if self._bjs_inference is not None:
427
+ V = self._bjs_inference.covariance(A)
428
+ if self.vcov_type == "CRV1":
429
+ V *= self.n_units / (self.n_units - 1)
430
+ else:
431
+ V = A @ self.vcov @ A.T
432
+ if self._share_cross is not None:
433
+ cross = A @ self._share_cross @ B.T
434
+ V += cross + cross.T + B @ self._share_cov @ B.T
435
+ draws = None
436
+ if self._multiplier_draws is not None:
437
+ draws = self._multiplier_draws @ A.T
438
+ if self._multiplier_shares is not None:
439
+ draws += self._multiplier_shares @ B.T
440
+ labels = labels_of(self)
441
+ public_plan = present_plan(p.diagnostics, labels)
442
+ return DIDResult(
443
+ fml=self.fml,
444
+ depvar=self.depvar,
445
+ coefnames=[label_name(n, labels) for n in p.names] if labels.step != 1 else p.names,
446
+ beta=A @ self.beta,
447
+ vcov=V,
448
+ vcov_type=self.vcov_type,
449
+ df_t=self.df_t,
450
+ n_obs=self.n_obs,
451
+ n_clusters=self.n_clusters,
452
+ estimator=self.estimator,
453
+ cells=[],
454
+ counts=np.array([]),
455
+ n_units=self.n_units,
456
+ _balance_table=(
457
+ self._balance_table.join(
458
+ pl.DataFrame(
459
+ {
460
+ "cohort": [g for j, (g, t) in enumerate(self.cells) if np.any(A[:, j])],
461
+ "time": [t for j, (g, t) in enumerate(self.cells) if np.any(A[:, j])],
462
+ }
463
+ ),
464
+ on=["cohort", "time"],
465
+ how="inner",
466
+ )
467
+ if self._balance_table is not None
468
+ else None
469
+ ),
470
+ _multiplier_draws=draws,
471
+ _bjs_placebos=self._bjs_placebos,
472
+ _ess_table=(
473
+ p.contributors.select(pl.col("aggregate").alias("coefficient"), "cell").join(
474
+ self._ess_table.drop("coefficient"), on="cell", how="inner"
475
+ )
476
+ if self._ess_table is not None
477
+ else None
478
+ ),
479
+ _internal={**internal(self), **p.diagnostics},
480
+ _periods=self._periods,
481
+ diagnostics={
482
+ **self.diagnostics,
483
+ **public_plan,
484
+ "warnings": list(self.diagnostics.get("warnings", []))
485
+ + (
486
+ [
487
+ "Cohort contributors change across event times; inspect composition() or select a balanced window."
488
+ ]
489
+ if p.kind == "event_time" and p.diagnostics["cohort_composition_changes"]
490
+ else ["Cohort weights vary across event times; inspect composition()."]
491
+ if p.kind == "event_time" and p.diagnostics["cohort_weights_change"]
492
+ else []
493
+ ),
494
+ },
495
+ _contributor_table=p.contributors,
496
+ _composition_table=p.composition,
497
+ )
498
+
499
+ def _summary_fields(self):
500
+ return {
501
+ **super()._summary_fields(),
502
+ "estimator": PUBLIC_ESTIMATORS.get(self.estimator, self.estimator),
503
+ "n_units": self.n_units,
504
+ "diagnostics": self.diagnostics,
505
+ }
506
+
507
+ def _summary_lines(self):
508
+ return [
509
+ f"estimator: {PUBLIC_ESTIMATORS.get(self.estimator, self.estimator)}; "
510
+ f"units: {self.n_units:,}",
511
+ *super()._summary_lines(),
512
+ self.diagnostics.get("inference", ""),
513
+ *(
514
+ [
515
+ f"suppressed coefficients: {len(self.diagnostics['suppressed_coefficients'])}; original aggregation weights retained"
516
+ ]
517
+ if self.diagnostics.get("suppressed_coefficients")
518
+ else []
519
+ ),
520
+ *self.diagnostics.get("warnings", []),
521
+ ]
@@ -0,0 +1,80 @@
1
+ """Reproducible heterogeneous staggered-adoption panels."""
2
+
3
+ import numpy as np
4
+ import polars as pl
5
+
6
+
7
+ def simulate_panel(n_units=1000, periods=8, *, seed=42, noise=1.0):
8
+ """Balanced panel with never-treated units, serial dependence and effects
9
+ varying by cohort and exposure. Returns id, time, first_treat, y, truth.
10
+ """
11
+ if n_units < 10 or periods < 3:
12
+ raise ValueError("use at least 10 units and 3 periods")
13
+ rng = np.random.default_rng(seed)
14
+ g = rng.choice(np.r_[0, np.arange(2, periods + 1)], n_units)
15
+ g[:2] = [0, 2]
16
+ t = np.arange(1, periods + 1)
17
+ tau = np.where(
18
+ (g[:, None] > 0) & (t >= g[:, None]), 0.5 + 0.15 * g[:, None] + 0.25 * (t - g[:, None]), 0
19
+ )
20
+ err = rng.normal(size=(n_units, periods))
21
+ for j in range(1, periods):
22
+ err[:, j] += 0.4 * err[:, j - 1]
23
+ y = rng.normal(size=(n_units, 1)) + 0.1 * t + tau + noise * err
24
+ return pl.DataFrame(
25
+ {
26
+ "id": np.repeat(np.arange(n_units), periods),
27
+ "time": np.tile(t, n_units),
28
+ "first_treat": np.repeat(g, periods),
29
+ "y": y.ravel(),
30
+ "truth": tau.ravel(),
31
+ }
32
+ )
33
+
34
+
35
+ def simulate_conditional_panel(n_units=1200, periods=5, *, seed=871, time_varying=True):
36
+ """Confounded staggered panel with numeric and categorical covariates.
37
+
38
+ Cohorts depend on unit-level x/z; effects differ by cohort and exposure.
39
+ Time-varying x exercises the earlier-comparison-period convention.
40
+ """
41
+ if n_units < 30 or periods < 4:
42
+ raise ValueError("use at least 30 units and four periods")
43
+ rng = np.random.default_rng(seed)
44
+ x0 = rng.normal(size=n_units)
45
+ z = rng.integers(0, 2, size=n_units)
46
+ category = rng.choice(["a", "b", "c"], size=n_units)
47
+ choices = np.array([0, periods - 2, periods - 1, periods])
48
+ logits = np.column_stack(
49
+ [np.zeros(n_units), 0.35 * x0 - 0.2 * z, -0.3 * x0 + 0.3 * z, 0.2 * x0 + 0.15 * z]
50
+ )
51
+ prob = np.exp(logits - logits.max(axis=1, keepdims=True))
52
+ prob /= prob.sum(axis=1, keepdims=True)
53
+ g = choices[(rng.random(n_units)[:, None] > prob.cumsum(axis=1)).sum(axis=1)]
54
+ g[:4] = choices
55
+ t = np.arange(1, periods + 1)
56
+ x = np.repeat(x0[:, None], periods, axis=1)
57
+ if time_varying:
58
+ x += 0.1 * t + 0.1 * rng.normal(size=x.shape)
59
+ tau = np.where(
60
+ (g[:, None] > 0) & (t >= g[:, None]), 0.5 + 0.1 * g[:, None] + 0.2 * (t - g[:, None]), 0
61
+ )
62
+ y0 = (
63
+ rng.normal(size=(n_units, 1))
64
+ + 0.2 * t
65
+ + 0.3 * t * x0[:, None]
66
+ + 0.15 * t * z[:, None]
67
+ + 0.4 * x
68
+ )
69
+ y = y0 + tau + rng.normal(size=x.shape)
70
+ return pl.DataFrame(
71
+ {
72
+ "id": np.repeat(np.arange(n_units), periods),
73
+ "time": np.tile(t, n_units),
74
+ "first_treat": np.repeat(g, periods),
75
+ "y": y.ravel(),
76
+ "x": x.ravel(),
77
+ "z": np.repeat(z, periods),
78
+ "category": np.repeat(category, periods),
79
+ }
80
+ )