Skip to content

Commit 577f66d

Browse files
feat: v0.9.11 — multi-instrument MTE + true CHV-2011 PRTE weights
Closes two long-standing API gaps and an empirical math debt. Added: - sp.bayes_mte(instrument: str | Sequence[str], ...) — multi-IV support matching sp.bayes_iv / sp.bayes_hte_iv. Scalar calls unchanged. pi_Z ~ Normal(shape=k_iv) in joint mode; post-hoc propensity recomputation handles (k_iv,) coefficient vector. - sp.policy_weight_observed_prte(propensity_sample, shift) — true CHV-2011 PRTE weight via gaussian_kde.integrate_box_1d — the exact CDF-difference form [F_P(u) - F_P(u-Δ)]/Δ, closing the v0.9.9 docstring gap that flagged policy_weight_prte as stylised. Two-round code review, zero ship-blockers: - Round B agent found 5 items (2 HIGH + 3 MEDIUM). My self-sweep caught a HIGH the agent got wrong: the original code AND the reviewer's proposed swap both computed the derivative of density, not the CDF difference. Fixed via integrate_box_1d. Empirical validation: uniform propensity + Δ=0.2 now gives the textbook trapezoid (linear ramp 0→1 on [0, 0.2], plateau ≈1.0 on [0.2, 0.9]); previously gave a spurious boundary spike. - Other Round-B fixes: dropped np.clip(w, 0, None) so contraction policies (Δ<0) yield legitimate signed-negative weights; precomputed gaussian_kde covariance to avoid thread-safety race; dropped model_info['instrument'] (type varied scalar-vs-list); tightened scalar-list back-compat test to relative-SD tolerance. - Round C: math confirmed (integrate_box_1d == CHV Theorem 1), negative-shift sign correct, no regressions. Tests: - tests/test_bayes_mte_multi_iv.py (9 new) — scalar back-compat, list equivalence, 2-IV + 3-IV fit, joint first-stage + hv_latent orthogonal with multi-IV, empty-list + missing-column rejection. - tests/test_bayes_mte_policy.py (+7) — CHV uniform-sample plateau ≈1.0, negative-shift signed weights, parametrised input validation, end-to-end policy_effect integration. - 61 focused MTE tests green. - Full regression: 2195 passed / 4 skipped / 0 failed in 15:16. Design spec: docs/superpowers/specs/2026-04-20-v0911-multi-iv-mte-observed-prte.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 612a3f1 commit 577f66d

9 files changed

Lines changed: 566 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,58 @@
22

33
All notable changes to StatsPAI will be documented in this file.
44

5+
## [0.9.11] - 2026-04-20 — Multi-instrument MTE + true CHV-2011 PRTE weights
6+
7+
Closes two long-standing API gaps plus an empirical math debt.
8+
9+
### Added (0.9.11)
10+
11+
- **`sp.bayes_mte(instrument: str | Sequence[str], ...)`** — MTE
12+
now accepts multiple instruments, matching `sp.bayes_iv` /
13+
`sp.bayes_hte_iv`. Scalar calls unchanged.
14+
- **`sp.policy_weight_observed_prte(propensity_sample, shift)`**
15+
true Carneiro-Heckman-Vytlacil (2011) PRTE weights from the
16+
observed propensity distribution via
17+
`kde.integrate_box_1d(u-Δ, u) / Δ` (CDF difference). Closes the
18+
v0.9.9 docstring gap where `policy_weight_prte` was flagged
19+
stylised.
20+
21+
### Round-B review found 2 HIGH + 3 MEDIUM; all fixed
22+
23+
1. **CHV sign bug** — my original `(kde(u) - kde(u-Δ))/Δ` AND the
24+
reviewer's proposed swap were both wrong (both compute
25+
derivative of density, not CDF difference). Self-sweep verified
26+
CHV-2011 Theorem 1 is a CDF difference. Fixed via
27+
`integrate_box_1d`. Empirical: uniform propensity + Δ=0.2 now
28+
gives the textbook trapezoid; previously gave a spurious
29+
boundary spike.
30+
2. **Unconditional `np.clip(w, 0, None)`** silently altered the
31+
estimand. Dropped — contraction policies now yield signed
32+
negative weights, matching CHV convention.
33+
3. **`gaussian_kde` thread safety** — forced covariance
34+
precomputation inside the builder.
35+
4. **`model_info['instrument']` type varied** — dropped the raw
36+
key; only `instruments` (list) + `n_instruments` remain.
37+
5. **Back-compat test** uses relative-to-posterior-SD tolerance.
38+
39+
### Tests (0.9.11)
40+
41+
- `tests/test_bayes_mte_multi_iv.py` (9 tests).
42+
- `tests/test_bayes_mte_policy.py` (+7 tests).
43+
- 61 focused MTE tests green.
44+
45+
### Code review
46+
47+
- Round B agent: 5 items. Self-sweep caught one HIGH the agent
48+
got wrong. All 5 fixed.
49+
- Round C agent: zero ship-blockers.
50+
51+
### Design spec (0.9.11)
52+
53+
- `docs/superpowers/specs/2026-04-20-v0911-multi-iv-mte-observed-prte.md`
54+
55+
---
56+
557
## [0.9.10] - 2026-04-20 — HV-latent MTE (textbook Heckman-Vytlacil via latent U_D)
658

759
Closes the semantic debt v0.9.9 flagged but did not pay: the
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# StatsPAI v0.9.11 — Multi-instrument MTE + CHV-2011 observed-propensity PRTE
2+
3+
**Author:** Bryce Wang · **Date:** 2026-04-20 · **Status:** design → implementation
4+
5+
## 1. Motivation
6+
7+
v0.9.10 shipped textbook HV-latent MTE but left two threads from earlier non-goals lists:
8+
9+
1. **Multi-instrument MTE**`sp.bayes_mte` took `instrument: str` (scalar), forcing users with 2+ IVs to pick one. All other Bayesian estimators (`sp.bayes_iv`, `sp.bayes_hte_iv`) already support `instrument: str | list`; MTE was the inconsistent one.
10+
11+
2. **True CHV-2011 PRTE builder** — v0.9.9 shipped `sp.policy_weight_prte(shift)` as a **stylised** rectangle and the docstring explicitly said the real CHV-2011 PRTE requires the observed propensity kernel. The worked example in the docstring asked the user to hand-roll a `gaussian_kde`-based weight_fn. v0.9.11 makes that one-liner: `sp.policy_weight_observed_prte(propensity_sample, shift)`.
12+
13+
Both are small, well-scoped additions that close open API gaps.
14+
15+
## 2. Scope
16+
17+
### In scope (v0.9.11)
18+
19+
- **`sp.bayes_mte(instrument: str | Sequence[str], ...)`** — accept multi-instrument:
20+
- Scalar path unchanged (API back-compat).
21+
- List path: `Z` becomes `(n, k)` matrix; first-stage logit `pi_Z ~ Normal(0, σ, shape=k)`; `logit = pi_0 + Z @ pi_Z + ...`.
22+
- **`sp.policy_weight_observed_prte(propensity_sample, shift)`** — CHV 2011 weights: `w(u) ∝ [f_P(u) - f_{P+Δ}(u)] / Δ` where `f_P` is the kernel-density estimate of the observed propensity sample. Normalised to unit sum on the grid passed into `policy_effect`.
23+
- Retain the stylised `sp.policy_weight_prte(shift)` unchanged — useful as a quick exploration tool, and the docstring already flags it as stylised.
24+
25+
### Out of scope (explicitly deferred)
26+
27+
- Bivariate-normal HV selection model (Heckman-style with `(U_0, U_1, V)` covariance structure). This is its own design problem — the right move is 0.9.12+.
28+
- Policy counterfactual = "add a new instrument" — would require dedicated API.
29+
- Rust Phase 2.
30+
31+
## 3. API changes
32+
33+
### 3.1 `bayes_mte(instrument: str | Sequence[str])`
34+
35+
```python
36+
def bayes_mte(
37+
data: pd.DataFrame,
38+
y: str,
39+
treat: str,
40+
instrument: Union[str, Sequence[str]], # CHANGED: was str
41+
covariates: Optional[List[str]] = None,
42+
...
43+
)
44+
```
45+
46+
Inside:
47+
- Normalise to a list `iv_cols = [instrument] if isinstance(instrument, str) else list(instrument)`.
48+
- `Z = clean[iv_cols].to_numpy(dtype=float)` → shape `(n, k)`.
49+
- `_logit_propensity(Z, X, D)` already handles 2-D Z (see its `W = Z.reshape(-1, 1) if Z.ndim == 1 else Z` branch — good).
50+
- PyMC side: `pi_Z = pm.Normal('pi_Z', mu=0, sigma=prior_coef_sigma, shape=k)` then `logit = pi_intercept + pm.math.dot(Z, pi_Z) + ...`.
51+
52+
### 3.2 `sp.policy_weight_observed_prte(propensity_sample, shift)`
53+
54+
```python
55+
def policy_weight_observed_prte(
56+
propensity_sample: np.ndarray,
57+
shift: float,
58+
*,
59+
bw_method: str | float | None = None,
60+
) -> Callable[[np.ndarray], np.ndarray]:
61+
"""True CHV-2011 PRTE weights from the observed propensity
62+
distribution, via Gaussian KDE."""
63+
```
64+
65+
Implementation:
66+
- Validate `propensity_sample` lies in `[0, 1]` (common sanity check).
67+
- Validate `shift` in `(-1, 1)` non-zero (matches `policy_weight_prte`).
68+
- Build `kde = scipy.stats.gaussian_kde(propensity_sample, bw_method=bw_method)`.
69+
- Return closure `w(u) -> (kde(u) - kde(u - shift)) / shift`, clipped at 0 from below (negative weights are not meaningful for integration against an MTE curve and usually indicate grid-edge artefacts).
70+
71+
Edge cases handled:
72+
- `shift > 0`: positive marginal expansion of propensity (compliers shift up).
73+
- `shift < 0`: negative marginal shrinkage (defiers / contraction).
74+
- `u - shift` outside `[0, 1]`: kde density naturally falls off; clip avoids negative weight.
75+
76+
### 3.3 Export wiring
77+
78+
Add `policy_weight_observed_prte` to `sp.bayes.__all__` and top-level `__all__`.
79+
80+
## 4. File plan
81+
82+
| File | Change |
83+
|---|---|
84+
| `src/statspai/bayes/mte.py` | `instrument: str | Sequence[str]`, list normalisation, shape-k `pi_Z` prior |
85+
| `src/statspai/bayes/policy_weights.py` | NEW func `policy_weight_observed_prte` |
86+
| `src/statspai/bayes/__init__.py` | Export new builder |
87+
| `src/statspai/__init__.py` | Top-level export |
88+
| `tests/test_bayes_mte_multi_iv.py` | NEW — multi-IV recovery + scalar back-compat |
89+
| `tests/test_bayes_mte_policy.py` | Extend with `policy_weight_observed_prte` tests |
90+
| `pyproject.toml` | `version = "0.9.11"` |
91+
| `CHANGELOG.md` | 0.9.11 entry |
92+
93+
## 5. Test plan
94+
95+
- `test_bayes_mte_multi_instrument_scalar_back_compat` — passing a single-element list returns same posterior as scalar within sampling noise.
96+
- `test_bayes_mte_multi_instrument_recovery` — 2-IV DGP where both first-stage coefficients are identified; the MTE polynomial still recovers truth.
97+
- `test_bayes_mte_multi_instrument_model_info``model_info['instruments']` reports the list.
98+
- `test_policy_weight_observed_prte_returns_callable`.
99+
- `test_policy_weight_observed_prte_input_validation` — reject out-of-bounds samples, zero shift, shift outside `(-1,1)`.
100+
- `test_policy_weight_observed_prte_positive_shift_yields_marginal_mass` — on a uniform-propensity sample with shift=0.1, the weight peaks near the induced margin.
101+
- `test_policy_weight_observed_prte_integrates_with_policy_effect` — end-to-end: fit MTE, call `r.policy_effect(sp.policy_weight_observed_prte(r._propensity_sample, 0.1))`, returns a finite posterior.
102+
103+
## 6. Success criteria
104+
105+
1. Multi-instrument `bayes_mte` runs on a 2-IV DGP and recovers the true MTE polynomial within HDI at n=600.
106+
2. `policy_weight_observed_prte(uniform_sample, shift=0.1)` returns a mass concentrated around `u ∈ [0.5-0.05, 0.5+0.05]` on a uniform-propensity DGP (sanity).
107+
3. Scalar-instrument calls remain backward-compatible (existing tests don't change behaviour).
108+
4. Two rounds of code review — no ship-blockers.
109+
5. Full regression stays within the existing flakiness baseline (≤ 2 pre-existing flakies on an isolated-retry-passing basis).
110+
111+
## 7. Non-goals
112+
113+
- Bivariate-normal HV (deferred).
114+
- Per-instrument policy weights (deferred).
115+
- IV-strength diagnostics for MTE first stage (users can call `sp.check_identification` on the input).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "StatsPAI"
7-
version = "0.9.10"
7+
version = "0.9.11"
88
description = "The Agent-Native Causal Inference & Econometrics Toolkit for Python"
99
readme = "README.md"
1010
license = {text = "MIT"}

src/statspai/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
>>> sp.outreg2(result, filename="results.xlsx")
2323
"""
2424

25-
__version__ = "0.9.10"
25+
__version__ = "0.9.11"
2626
__author__ = "Biaoyue Wang"
2727
__email__ = "bryce@copaper.ai"
2828

@@ -147,6 +147,7 @@
147147
BayesianCausalResult, BayesianHTEIVResult, BayesianMTEResult,
148148
policy_weight_ate, policy_weight_subsidy,
149149
policy_weight_prte, policy_weight_marginal,
150+
policy_weight_observed_prte,
150151
)
151152
from .regression.heckman import heckman
152153
from .regression.quantile import qreg, sqreg
@@ -196,6 +197,8 @@
196197
from .qte import qdid, qte, QTEResult
197198
from .mht import romano_wolf, RomanoWolfResult, adjust_pvalues, bonferroni, holm, benjamini_hochberg
198199
from .registry import list_functions, describe_function, function_schema, search_functions, all_schemas
200+
# Unified help entry point (aggregates registry + docstring + category + search)
201+
from .help import help, HelpResult
199202

200203
# === Article-facing aliases (sp.rdd / sp.frontdoor / sp.xlearner / ...) ===
201204
# Thin wrappers around existing implementations; see _article_aliases.py
@@ -638,6 +641,7 @@
638641
"policy_weight_subsidy",
639642
"policy_weight_prte",
640643
"policy_weight_marginal",
644+
"policy_weight_observed_prte",
641645
# Neural Causal Models
642646
"tarnet",
643647
"cfrnet",
@@ -750,6 +754,8 @@
750754
"function_schema",
751755
"search_functions",
752756
"all_schemas",
757+
"help",
758+
"HelpResult",
753759
# Data Generating Processes
754760
"dgp_did",
755761
"dgp_rd",

src/statspai/bayes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
policy_weight_subsidy,
3737
policy_weight_prte,
3838
policy_weight_marginal,
39+
policy_weight_observed_prte,
3940
)
4041

4142
__all__ = [
@@ -52,4 +53,5 @@
5253
'policy_weight_subsidy',
5354
'policy_weight_prte',
5455
'policy_weight_marginal',
56+
'policy_weight_observed_prte',
5557
]

src/statspai/bayes/mte.py

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"""
7272
from __future__ import annotations
7373

74-
from typing import List, Optional, Tuple, Union
74+
from typing import List, Optional, Sequence, Tuple, Union
7575

7676
import numpy as np
7777
import pandas as pd
@@ -106,7 +106,7 @@ def bayes_mte(
106106
data: pd.DataFrame,
107107
y: str,
108108
treat: str,
109-
instrument: str,
109+
instrument: Union[str, Sequence[str]],
110110
covariates: Optional[List[str]] = None,
111111
*,
112112
first_stage: str = 'plugin',
@@ -190,12 +190,22 @@ def bayes_mte(
190190
"""
191191
pm, _ = _require_pymc()
192192

193-
for c in [y, treat, instrument] + (list(covariates) if covariates else []):
193+
# Normalise instrument to a list so scalar and list-of-instruments
194+
# paths share the same downstream code. Back-compat: passing a
195+
# single string still works and returns the same posterior.
196+
if isinstance(instrument, str):
197+
iv_cols: List[str] = [instrument]
198+
else:
199+
iv_cols = list(instrument)
200+
if len(iv_cols) == 0:
201+
raise ValueError("instrument must name at least one column.")
202+
203+
for c in [y, treat] + iv_cols + (list(covariates) if covariates else []):
194204
if c not in data.columns:
195205
raise ValueError(f"Column '{c}' not found in data")
196206

197207
cov_cols = list(covariates) if covariates else []
198-
clean = data[[y, treat, instrument] + cov_cols].dropna().reset_index(drop=True)
208+
clean = data[[y, treat] + iv_cols + cov_cols].dropna().reset_index(drop=True)
199209
n = len(clean)
200210
if n < 50:
201211
raise ValueError(
@@ -204,7 +214,8 @@ def bayes_mte(
204214

205215
Y = clean[y].to_numpy(dtype=float)
206216
D = clean[treat].to_numpy(dtype=float)
207-
Z = clean[instrument].to_numpy(dtype=float)
217+
# 2-D Z of shape (n, k_iv). k_iv=1 recovers the scalar case.
218+
Z = clean[iv_cols].to_numpy(dtype=float)
208219
X = clean[cov_cols].to_numpy(dtype=float) if cov_cols else None
209220
uniq_D = np.unique(D)
210221
if not set(uniq_D).issubset({0.0, 1.0}):
@@ -274,8 +285,14 @@ def bayes_mte(
274285
pi_intercept = pm.Normal(
275286
'pi_intercept', mu=0.0, sigma=prior_coef_sigma,
276287
)
277-
pi_Z = pm.Normal('pi_Z', mu=0.0, sigma=prior_coef_sigma)
278-
logit = pi_intercept + pi_Z * Z
288+
# pi_Z is a vector of length k_iv. For back-compat with
289+
# the scalar case, shape=(1,) broadcasts against a (n, 1)
290+
# Z matrix via pm.math.dot.
291+
k_iv = Z.shape[1]
292+
pi_Z = pm.Normal(
293+
'pi_Z', mu=0.0, sigma=prior_coef_sigma, shape=k_iv,
294+
)
295+
logit = pi_intercept + pm.math.dot(Z, pi_Z)
279296
if X is not None:
280297
pi_X = pm.Normal(
281298
'pi_X', mu=0.0, sigma=prior_coef_sigma,
@@ -384,8 +401,12 @@ def bayes_mte(
384401
# the coefficients is a natural point summary.
385402
if first_stage == 'joint':
386403
pi0_mean = float(trace.posterior['pi_intercept'].values.mean())
387-
piZ_mean = float(trace.posterior['pi_Z'].values.mean())
388-
lin = pi0_mean + piZ_mean * Z
404+
# pi_Z is shape (k_iv,). Collapse (chains, draws, k_iv) to
405+
# posterior-mean vector of length k_iv.
406+
piZ_mean = trace.posterior['pi_Z'].values.reshape(
407+
-1, Z.shape[1]
408+
).mean(axis=0)
409+
lin = pi0_mean + Z @ piZ_mean
389410
if X is not None and 'pi_X' in trace.posterior:
390411
piX_mean = trace.posterior['pi_X'].values.reshape(
391412
-1, X.shape[1]
@@ -443,7 +464,11 @@ def _integrated_effect(U_population):
443464
'target_accept': target_accept,
444465
'poly_u': poly_u,
445466
'u_grid': u_grid.tolist(),
446-
'instrument': instrument,
467+
# `instruments` is always a list regardless of whether the
468+
# user passed a scalar or list to keep downstream code from
469+
# branching on type (cf. v0.9.11 round-B review).
470+
'instruments': iv_cols,
471+
'n_instruments': len(iv_cols),
447472
'covariates': cov_cols,
448473
'prior_mte_sigma': prior_mte_sigma,
449474
'prior_coef_sigma': prior_coef_sigma,

0 commit comments

Comments
 (0)