Skip to content

Commit 59eb3fc

Browse files
docs: add v1.2/v1.3 frontier guides + cross-reference from choosers
New guides ---------- * docs/guides/v1_2_frontier.md — walks through every estimator added in v1.2 (gardner_did / harvest_did / dml_model_averaging / kernel_iv / continuous_iv_late / hal_tmle / synth_survival / RD aliases / BCF ordinal + factor-exposure / shift_share_political / causal_mas / evidence_without_injustice / causal_kalman) with when-to-use-which decision tree. * docs/guides/harvest_did.md — standalone reference for sp.harvest_did (MIT/NBER WP 34550, 2025). * docs/guides/synth_experimental.md — Abadie-Zhao (2025/2026) inverse synthetic controls for experimental design. * docs/guides/assimilative_ci.md — causal_kalman / assimilative_causal Bayesian sequential estimate combining (Nature Comms 2026). Updated existing guides ----------------------- * guides/choosing_did_estimator.md — two new rows referencing sp.gardner_did (two-stage regression) and sp.harvest_did (precision-weighted harvesting), with the table re-aligned to make the MD060 linter happy. * guides/choosing_iv_estimator.md — new §9 "Non-parametric IV (v1.2)" pointing users at sp.kernel_iv and sp.continuous_iv_late with a one-line goal → call mapping. Nav --- mkdocs.yml gains four "v1.2/v1.3 frontier" entries under Guides. The build was verified with `mkdocs build --strict` (0.77 s, no warnings). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0859763 commit 59eb3fc

7 files changed

Lines changed: 664 additions & 12 deletions

File tree

docs/guides/assimilative_ci.md

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
# Assimilative Causal Inference
2+
3+
> *Nature Communications* 2026: bridging Bayesian data assimilation
4+
> (weather forecasting, oceanography) with streaming causal inference.
5+
6+
## 1. What problem does this solve?
7+
8+
Most causal-inference pipelines assume the data arrive in one shot.
9+
But in A/B testing, pharmacovigilance, and policy evaluation you
10+
typically get **a batch at a time**, and the treatment effect itself
11+
may drift with seasons, user segments, or policy regimes.
12+
13+
Assimilative causal inference treats the causal effect as a
14+
time-varying latent state and updates its posterior every time a new
15+
batch arrives — exactly the same mathematics that a weather model
16+
uses to fuse satellite observations into a global atmospheric state.
17+
18+
## 2. State-space formulation
19+
20+
$$
21+
\begin{aligned}
22+
\theta_t &= \theta_{t-1} + w_t,
23+
\qquad w_t \sim \mathcal{N}(0, Q_t) \quad \text{(dynamics)}\\[2pt]
24+
\widehat{\theta}_t &= \theta_t + e_t,
25+
\qquad e_t \sim \mathcal{N}(0, \sigma_t^2) \quad \text{(observation)}
26+
\end{aligned}
27+
$$
28+
29+
Under Gaussianity the Kalman filter gives closed-form posteriors
30+
`θ_t | y_{1:t} ~ N(m_t, P_t)`. For non-Gaussian / nonlinear settings
31+
StatsPAI ships a bootstrap-SIR particle filter with systematic
32+
resampling.
33+
34+
## 3. Two-level API
35+
36+
### Low-level: fuse a pre-computed stream
37+
38+
If you have already produced a stream of `(θ̂_t, σ_t)` from, say,
39+
running `sp.did` or `sp.dml` on each batch:
40+
41+
```python
42+
import statspai as sp
43+
44+
res = sp.causal_kalman(
45+
estimates=[θ̂_1, θ̂_2, ...],
46+
standard_errors=[σ_1, σ_2, ...],
47+
prior_mean=0.0,
48+
prior_var=1.0,
49+
process_var=0.0, # 0 = static effect, >0 = random-walk drift
50+
alpha=0.05,
51+
)
52+
print(res.summary())
53+
```
54+
55+
For heavy-tailed observation noise or non-Gaussian priors swap in the
56+
particle filter:
57+
58+
```python
59+
res = sp.assimilation.particle_filter(
60+
estimates, standard_errors,
61+
n_particles=3000,
62+
process_sd=0.05, # random-walk SD
63+
random_state=0,
64+
)
65+
```
66+
67+
### High-level: fuse directly from raw batches
68+
69+
Pass the batches and a `(df → (θ̂, σ))` estimator callback and the
70+
pipeline handles both per-batch estimation and assimilation:
71+
72+
```python
73+
def est(df):
74+
r = sp.regress('y ~ d + x', data=df)
75+
return float(r.params['d']), float(r.std_errors['d'])
76+
77+
res = sp.assimilative_causal(
78+
batches=[batch_jan, batch_feb, ...],
79+
estimator=est,
80+
prior_mean=0.0,
81+
prior_var=1.0,
82+
process_var=0.01, # allow small month-to-month drift
83+
backend='kalman', # or 'particle' for non-Gaussian
84+
)
85+
```
86+
87+
## 4. Outputs
88+
89+
Every backend returns the same `AssimilationResult` dataclass:
90+
91+
| Field | Shape | Meaning |
92+
|--------------------|--------|-------------------------------------|
93+
| `posterior_mean` | (T,) | Running posterior mean `m_t` |
94+
| `posterior_sd` | (T,) | Running posterior SD `sqrt(P_t)` |
95+
| `posterior_ci` | (T, 2) | Per-step CI at level `alpha` |
96+
| `innovations` | (T,) | Observation surprise `θ̂_t − m_{t|t-1}` |
97+
| `ess` | (T,) | Effective sample size |
98+
| `final_mean`, `final_sd`, `final_ci` | scalars | End-of-stream summary |
99+
| `trajectory()` | method | Tidy DataFrame of the above |
100+
101+
## 5. End-to-end example
102+
103+
```python
104+
import numpy as np, pandas as pd, statspai as sp
105+
106+
# Generate 12 monthly A/B-test batches with a slowly drifting effect.
107+
rng = np.random.default_rng(0)
108+
def make_batch(n, tau, seed):
109+
r = np.random.default_rng(seed)
110+
d = r.integers(0, 2, n); x = r.normal(size=n)
111+
y = tau * d + 0.2 * x + r.normal(scale=0.3, size=n)
112+
return pd.DataFrame({'y': y, 'd': d, 'x': x})
113+
114+
tau_path = 0.5 + np.linspace(0, 0.1, 12) # drifts from 0.5 to 0.6
115+
batches = [make_batch(300, tau_path[t], seed=t) for t in range(12)]
116+
117+
def est(df):
118+
r = sp.regress('y ~ d + x', data=df)
119+
return float(r.params['d']), float(r.std_errors['d'])
120+
121+
res = sp.assimilative_causal(
122+
batches, est, prior_mean=0.0, prior_var=1.0,
123+
process_var=0.001, # mild drift allowance
124+
)
125+
print(res.summary())
126+
print(res.trajectory().tail())
127+
```
128+
129+
## 6. Choosing between the backends
130+
131+
| Symptom | Use |
132+
|-------------------------------------------|----------------------------------------|
133+
| Per-batch estimates look roughly Gaussian | `backend='kalman'` (default, fast) |
134+
| Heavy tails / outliers per batch | `backend='particle'` with Student-t obs model |
135+
| Non-Gaussian prior (log-normal, bounded) | `backend='particle'` + `prior_sampler=...` |
136+
| You need CI bands in real time | Either — both return `posterior_ci` per step |
137+
138+
## 7. Gotchas
139+
140+
- **Garbage-in, garbage-out.** The filter trusts that your per-batch
141+
estimator is well-calibrated (nominal CIs cover at their stated
142+
rate). Run `sp.smart.assumption_audit` on the estimator first.
143+
- **`process_var = 0` means a static effect.** If the real effect
144+
drifts, you'll get overconfident CIs because the filter has no room
145+
for state innovation. Start with `process_var ≈ σ_t² / 10` and
146+
tune upwards if the innovations look persistently one-sided.
147+
- **Particle-filter degeneracy.** If you see
148+
`ESS / N < 0.2` persistently, increase `n_particles` or loosen the
149+
observation model (heavier tails).
150+
151+
## 8. References
152+
153+
- *Assimilative Causal Inference* — Nature Communications (2026).
154+
- Gordon, Salmond & Smith (1993). "Novel approach to nonlinear/non-
155+
Gaussian Bayesian state estimation." *IEE Proc. F.*
156+
- Douc & Cappé (2005). "Comparison of resampling schemes for
157+
particle filtering." *ISPA*.

docs/guides/choosing_did_estimator.md

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,19 @@ TWFE over them.
6060

6161
### 2b. Staggered + heterogeneous effects
6262

63-
| Scenario | Pick |
64-
|-------------------------------------------|---------------------------------------------------|
65-
| You want group-time ATT(g,t) + event study| `sp.callaway_santanna(df, y, g, t, i)` |
66-
| Heavy-weight covariates | `sp.callaway_santanna(..., x=[...], estimator='dr')` |
67-
| Sun-Abraham interaction-weighted event | `sp.sun_abraham(df, y, g, t, i)` |
68-
| Imputation-style (no TWFE needed) | `sp.did_imputation(df, y, i, t, g)` |
69-
| Two-way Mundlak / ETWFE | `sp.wooldridge_did(df, y, group, time, first_treat)` |
70-
| Always-treated + never-treated only | `sp.stacked_did(df, y, g, t, i, event_window=6)` |
71-
| Continuous / dose treatment | `sp.continuous_did(df, y, d, t, i)` |
72-
| Changes-in-changes (CIC, not DID-in-mean) | `sp.cic(df, y, g, t)` |
73-
| de Chaisemartin-D'Haultfoeuille | `sp.did_multiplegt(df, y, treat, g, t, i)` |
63+
| Scenario | Pick |
64+
|-----------------------------------------------------|-------------------------------------------------------------------------------------|
65+
| You want group-time ATT(g,t) + event study | `sp.callaway_santanna(df, y, g, t, i)` |
66+
| Heavy-weight covariates | `sp.callaway_santanna(..., x=[...], estimator='dr')` |
67+
| Sun-Abraham interaction-weighted event | `sp.sun_abraham(df, y, g, t, i)` |
68+
| Imputation-style (no TWFE needed) | `sp.did_imputation(df, y, i, t, g)` |
69+
| Two-stage regression (event study + covariate ix) | `sp.gardner_did(df, y=..., group=..., time=..., first_treat=..., event_study=True)` |
70+
| One-call harvesting + precision-weighted | `sp.harvest_did(df, outcome=..., unit=..., time=..., cohort=...)` |
71+
| Two-way Mundlak / ETWFE | `sp.wooldridge_did(df, y, group, time, first_treat)` |
72+
| Always-treated + never-treated only | `sp.stacked_did(df, y, g, t, i, event_window=6)` |
73+
| Continuous / dose treatment | `sp.continuous_did(df, y, d, t, i)` |
74+
| Changes-in-changes (CIC, not DID-in-mean) | `sp.cic(df, y, g, t)` |
75+
| de Chaisemartin-D'Haultfoeuille | `sp.did_multiplegt(df, y, treat, g, t, i)` |
7476

7577
**Default recommendation when in doubt: `sp.callaway_santanna(..., estimator='dr')`.**
7678
Doubly-robust CS is the modern "no-regret" default — it's robust to both

docs/guides/choosing_iv_estimator.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,25 @@ r.glance() # nobs, method, log_likelihood, first-stage F if computed
132132
r.predict(new_df) # Out-of-sample prediction
133133
```
134134

135-
## 9. Sanity checks
135+
## 9. Non-parametric IV (v1.2)
136+
137+
For a **continuous treatment** where the linear `D ~ Z` first stage is
138+
too restrictive, v1.2 ships two non-parametric alternatives:
139+
140+
| Goal | Call |
141+
|---------------------------------------------------------|------------------------------------------------------------------|
142+
| Structural function `h*(d)` + **uniform** bootstrap CI | `sp.kernel_iv(df, y, treat, instrument, n_boot=200)` |
143+
| LATE on the **maximal complier class** (continuous Z) | `sp.continuous_iv_late(df, y, treat, instrument, n_quantiles=5)` |
144+
145+
- `sp.kernel_iv` (Lob et al. 2025, arXiv:2511.21603) delivers a uniform
146+
confidence band over the whole `D`-grid, not just pointwise.
147+
- `sp.continuous_iv_late` (Xie et al. 2025, arXiv:2504.03063) identifies
148+
the LATE on the subpopulation most responsive to the instrument —
149+
the natural generalisation of Angrist-Imbens LATE beyond binary `Z`.
150+
151+
See [v1.2 frontier estimators](v1_2_frontier.md) for a detailed walkthrough.
152+
153+
## 10. Sanity checks
136154

137155
Every IV paper should include these:
138156

docs/guides/harvest_did.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Harvesting DID + Event-Study Designs
2+
3+
> Borusyak, Hull & Jaravel (MIT/NBER WP 34550, 2025).
4+
5+
## 1. What "harvesting" means
6+
7+
A staggered-adoption panel implicitly defines *many* valid
8+
difference-in-differences contrasts: every pair of (cohort `g`, pre-
9+
period `t₁`, post-period `t₂`) where `t₁` is before treatment and `t₂`
10+
is after treatment, matched against a control cohort that is *not yet
11+
treated* in either period, produces an unbiased 2×2 DID estimate under
12+
parallel trends.
13+
14+
Harvesting extracts **all** such valid 2×2 estimates and combines them
15+
into a single inverse-variance-weighted aggregate. It is the natural
16+
generalisation of Callaway–Sant'Anna (2021) ATT(g, t) and coincides
17+
with their estimator when restricted to a single horizon.
18+
19+
## 2. API
20+
21+
```python
22+
import statspai as sp
23+
24+
res = sp.harvest_did(
25+
data=df,
26+
unit='unit',
27+
time='time',
28+
outcome='y',
29+
treat='treated', # binary indicator; cohort inferred
30+
# or: cohort='first_treat', never_value=0
31+
horizons=range(-3, 5), # event-time windows (-3..4 by default)
32+
reference=-1, # pre-treatment reference horizon
33+
alpha=0.05,
34+
weighting='precision', # 'precision' | 'equal' | 'n_treated'
35+
)
36+
print(res.summary())
37+
```
38+
39+
Returns a standard `CausalResult` with `estimate`, `se`, `ci`. The
40+
`model_info` payload contains:
41+
42+
- `event_study` — DataFrame with columns
43+
`relative_time, att, se, pvalue, n_comparisons`
44+
- `pretrend_test` — Wald joint test of horizon `< 0`
45+
- `n_comparisons` — total number of harvested 2×2 cells
46+
47+
The `detail` slot exposes the per-comparison table (cohort, horizon,
48+
`t₁`, `t₂`, ATT, SE, donor counts) so you can audit every cell.
49+
50+
## 3. Staggered-adoption recipe
51+
52+
```python
53+
import numpy as np, pandas as pd, statspai as sp
54+
55+
rng = np.random.default_rng(0)
56+
n_units, n_periods = 120, 12
57+
cohort = rng.choice([0, 5, 7, 9], size=n_units, p=[0.4, 0.2, 0.2, 0.2])
58+
rows = []
59+
for i in range(n_units):
60+
uf = rng.normal()
61+
g = cohort[i]
62+
for t in range(n_periods):
63+
D = 1 if (g > 0 and t >= g) else 0
64+
y = uf + 0.1 * t + 2.0 * D + rng.normal(0, 0.3)
65+
rows.append({'unit': i, 'time': t, 'y': y, 'treated': D})
66+
df = pd.DataFrame(rows)
67+
68+
res = sp.harvest_did(
69+
df, unit='unit', time='time', outcome='y', treat='treated',
70+
horizons=range(-3, 5),
71+
)
72+
print(res.summary())
73+
```
74+
75+
## 4. How it differs from its siblings
76+
77+
| Estimator | What it targets | Who to pick when |
78+
|---------------------------------|---------------------------------------------------|--------------------------------------------|
79+
| `sp.callaway_santanna` | ATT(g, t) building blocks + aggregation schemes | You want a specific g×t heatmap |
80+
| `sp.sun_abraham` | Interaction-weighted event-study coefficients | You want a clean event-study plot |
81+
| `sp.did_imputation` (BJS 2024) | Imputation-based estimator with uniform inference | You need finite-sample guarantees |
82+
| **`sp.harvest_did`** | Every valid 2×2, inverse-variance averaged | You want maximum efficiency across cells |
83+
84+
All four converge to the same true ATT under homogeneous effects +
85+
parallel trends; they differ mainly in their weighting of heterogeneity.
86+
87+
## 5. Clean-control rule
88+
89+
The key safety rule: a control cohort is valid for comparison
90+
`(g, t₁, t₂)` only if it is **not yet treated at**
91+
`max(t₁, t₂)`. This guards both post-period horizons (`t₂ > t₁`) and
92+
pre-period placebos (`t₂ < t₁`).
93+
94+
If you see the pretrend Wald test report `p < 0.05` on a DGP you
95+
believe has parallel trends, double-check that the `reference` and
96+
`horizons` combination doesn't straddle a cohort's treatment time in
97+
an unexpected way.
98+
99+
## 6. Inference caveats
100+
101+
`harvest_did` treats **units within each cohort** as independent for
102+
the per-comparison SE (unit-level cluster-robust), but it **ignores
103+
cross-horizon covariance** when aggregating. For strict finite-sample
104+
inference, wrap the call in a unit-level bootstrap:
105+
106+
```python
107+
from statspai.inference import bootstrap
108+
stat = lambda d: sp.harvest_did(d, unit='unit', time='time',
109+
outcome='y', treat='treated').estimate
110+
boot = bootstrap(df, stat, n_boot=500, cluster='unit', random_state=0)
111+
```
112+
113+
## 7. References
114+
115+
- Borusyak, Hull & Jaravel (MIT/NBER WP 34550, 2025).
116+
"Harvesting Differences-in-Differences and Event-Study Designs."
117+
- Callaway & Sant'Anna (2021). "Difference-in-Differences with
118+
multiple time periods." *JoE* 225.
119+
- Roth, Sant'Anna, Bilinski & Poe (2025).
120+
"Difference-in-Differences Designs: A Practitioner's Guide."
121+
arXiv:2503.13323.

0 commit comments

Comments
 (0)