Skip to content

Commit 2fd88de

Browse files
feat: v0.9.13 — ArviZ HDI compat shim + ATT/ATU uncertainty
Small-but-load-bearing cleanup release closing two items deferred across v0.9.10/11/12 code reviews. Added: - `_az_hdi_compat(samples, hdi_prob)` in statspai.bayes._base: routes every az.hdi() call in the Bayesian sub-package through one place so the inevitable arviz >= 0.18 kwarg rename (hdi_prob -> prob) is a one-line future change. Try hdi_prob first, fall back to prob on TypeError. - ATT/ATU uncertainty on BayesianMTEResult: att_sd/att_hdi_lower/att_hdi_upper + atu_*. posterior_sd covers ATE (primary estimand), no redundant ate_sd. - _integrated_effect returns (mean, sd, hdi_low, hdi_high); caller unpacks into the new fields. Six new dataclass fields all NaN- defaulted and appended after v0.9.12 schema (back-compat with pre-v0.9.13 serialised results). Round B review: no blockers, one MEDIUM (test docstring mislabel) fixed inline. Reviewer verified: - Shim fallback shape correct under monkey-patched future arviz. - Dataclass field order confirmed via live introspection. - No __hash__ risk; .tidy()/.glance() intentionally don't surface the new fields (opt-in). - Imports clean in mte.py + hte_iv.py. - Empty-population NaN guardrail is defensive-only; unreachable from bayes_mte because _logit_propensity enforces 2-class upstream. Test renamed to reflect this. Incident log: a regex rewrite from az.hdi(...) to _az_hdi_compat(...) accidentally matched the helper's own body, creating a self-recursive _az_hdi_compat. Caught by running the Bayesian suite in-session before any test was shipped. Manual revert + re-apply. Tests: - tests/test_bayes_hdi_compat.py (4) — forward on current arviz, fall back on monkey-patched future arviz, length-2 return, propagate TypeError when both kwargs rejected. - tests/test_bayes_mte_uncertainty.py (4) — SDs finite + positive, HDI brackets mean, no ate_sd, realistic-DGP finite. - Bayesian family suite: 145/145 green. Design spec: docs/superpowers/specs/2026-04-20-v0913-hdi-compat-and-att-sd.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a546ead commit 2fd88de

9 files changed

Lines changed: 401 additions & 23 deletions

File tree

CHANGELOG.md

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

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

5+
## [0.9.13] - 2026-04-20 — ArviZ HDI compat shim + ATT/ATU uncertainty
6+
7+
Small-but-load-bearing cleanup release. Closes two items deferred
8+
across the v0.9.10 / v0.9.11 / v0.9.12 code reviews.
9+
10+
### Added (0.9.13)
11+
12+
- **`_az_hdi_compat(samples, hdi_prob)`** in `statspai.bayes._base`
13+
— calls `az.hdi(samples, hdi_prob=...)` first, falls back to
14+
`az.hdi(samples, prob=...)` on `TypeError`. Routes **every**
15+
`az.hdi(...)` call site in the Bayesian sub-package through one
16+
place so the inevitable arviz ≥ 0.18 kwarg rename is a one-line
17+
change. Previously identified as time-bomb by v0.9.12 round-C
18+
review.
19+
20+
- **ATT / ATU uncertainty** on `BayesianMTEResult`:
21+
- `att_sd`, `att_hdi_lower`, `att_hdi_upper`
22+
- `atu_sd`, `atu_hdi_lower`, `atu_hdi_upper`
23+
24+
`_integrated_effect` now returns `(mean, sd, hdi_lower,
25+
hdi_upper)` instead of `(mean, sd)`. `posterior_sd` on the parent
26+
result already covers ATE uncertainty — no redundant `ate_sd`.
27+
28+
- **Appended-at-end field order** on `BayesianMTEResult` — all six
29+
new fields are NaN-defaulted and positioned after the v0.9.12
30+
schema (`selection`). Serialised results from earlier releases
31+
deserialise cleanly.
32+
33+
### Round-B code review found no blockers
34+
35+
Reviewer confirmed:
36+
1. `_az_hdi_compat` fallback shape correct for any future arviz
37+
kwarg rename.
38+
2. Dataclass field order verified via live introspection.
39+
3. No `__hash__` risk on NaN fields; broom-style `.tidy()` /
40+
`.glance()` intentionally do not surface the new SD/HDI fields
41+
(opt-in access).
42+
4. Imports clean in `mte.py` + `hte_iv.py`.
43+
5. Empty-population NaN guardrail is defensive-only; unreachable
44+
from `bayes_mte` because `_logit_propensity` enforces 2-class
45+
requirement upstream. Test renamed to reflect this honestly.
46+
47+
One MEDIUM item (test-docstring mislabel) fixed inline.
48+
49+
### Incident log
50+
51+
A mass `regex` rewrite from `az.hdi(...)` to `_az_hdi_compat(...)`
52+
accidentally matched the helper's own body, creating a
53+
`_az_hdi_compat → _az_hdi_compat` self-recursion. Caught by running
54+
the Bayesian focused suite (would have been a stack-overflow the
55+
moment any Bayesian estimator shipped). Reverted + re-applied
56+
manually in the same session before tests ever ran outside dev.
57+
58+
### Tests (0.9.13)
59+
60+
- `tests/test_bayes_hdi_compat.py` (4 tests) — forwards on current
61+
arviz, falls back on monkey-patched future arviz, returns length-2
62+
array, propagates `TypeError` when both kwargs rejected (no silent
63+
success).
64+
- `tests/test_bayes_mte_uncertainty.py` (4 tests) — ATT/ATU SD
65+
populated + > 0, HDI brackets mean, no redundant `ate_sd`, realistic-
66+
DGP both-finite.
67+
- Bayesian family suite: 145/145 focused MTE + sibling tests green.
68+
69+
### Design spec
70+
71+
- `docs/superpowers/specs/2026-04-20-v0913-hdi-compat-and-att-sd.md`
72+
73+
### Non-goals (0.9.13)
74+
75+
- Full bivariate-normal HV `(U_0, U_1, V) ~ N(0, Σ)` — stays queued.
76+
- Rust Phase 2.
77+
- Expose ATT/ATU HDI on `.tidy()` — today `.tidy()` describes the
78+
primary estimand (ATE); adding a multi-row variant for ATT/ATU is
79+
a v0.9.14+ API question.
80+
81+
---
82+
583
## [0.9.12] - 2026-04-20 — Probit-scale MTE (Heckman selection frame)
684

785
Adds the third orthogonal axis to `sp.bayes_mte`: the MTE polynomial
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# StatsPAI v0.9.13 — arviz HDI-kwarg compat + ATT/ATU uncertainty
2+
3+
**Author:** Bryce Wang · **Date:** 2026-04-20 · **Status:** design → implementation
4+
5+
## 1. Motivation
6+
7+
Two items were explicitly deferred across v0.9.10 / v0.9.11 / v0.9.12 code reviews:
8+
9+
1. **`az.hdi(..., hdi_prob=...)` kwarg renamed to `prob` in arviz ≥ 0.18.** All our Bayesian estimators call `az.hdi(samples, hdi_prob=self.hdi_prob)`. The day a user upgrades arviz past that version, every Bayesian estimator returns malformed output. We ship a tiny compatibility shim now so the jump is a no-op.
10+
11+
2. **`BayesianMTEResult.att` / `.atu` expose the posterior mean only.** The internal `_integrated_effect` helper already computes `(mean, sd)` but v0.9.12 discarded the SD. Without SD / HDI, users can't tell whether ATT differs from ATU meaningfully.
12+
13+
Both are small, surgical, and high-value-per-LOC.
14+
15+
## 2. Scope
16+
17+
### In scope (v0.9.13)
18+
19+
- **`_az_hdi_compat(samples, hdi_prob=...)`** in `src/statspai/bayes/_base.py` — calls `az.hdi(samples, hdi_prob=...)` first, falls back to `az.hdi(samples, prob=...)` on `TypeError`. All internal Bayesian code replaces the direct `az.hdi(...)` call with this shim.
20+
21+
- **ATT / ATU posterior SD + HDI** on `BayesianMTEResult`:
22+
- `att_sd`, `att_hdi_lower`, `att_hdi_upper`
23+
- `atu_sd`, `atu_hdi_lower`, `atu_hdi_upper`
24+
25+
- **`_integrated_effect` returns the full posterior summary** (mean, sd, hdi_low, hdi_high); caller records all four.
26+
27+
- **Summary output** prints ATT / ATU with uncertainty when non-NaN.
28+
29+
### Out of scope
30+
31+
- Full bivariate-normal HV.
32+
- Rust Phase 2.
33+
- Per-unit ATT (only population-level integrated ATT).
34+
35+
## 3. API changes
36+
37+
### 3.1 `_az_hdi_compat`
38+
39+
```python
40+
def _az_hdi_compat(samples, hdi_prob=0.95):
41+
_, az = _require_pymc()
42+
try:
43+
return np.asarray(az.hdi(samples, hdi_prob=hdi_prob)).ravel()
44+
except TypeError:
45+
return np.asarray(az.hdi(samples, prob=hdi_prob)).ravel()
46+
```
47+
48+
Every `az.hdi` call in `statspai.bayes.*` goes through this wrapper.
49+
50+
### 3.2 `BayesianMTEResult` new fields
51+
52+
Append (default NaN → preserves serialised-result backward compat):
53+
54+
```
55+
att_sd, att_hdi_lower, att_hdi_upper
56+
atu_sd, atu_hdi_lower, atu_hdi_upper
57+
```
58+
59+
`ate_sd` is NOT a new field — `posterior_sd` covers the primary estimand.
60+
61+
### 3.3 `summary()` update
62+
63+
When ATT/ATU SD is finite, print `ATT: 1.234 (sd 0.567, HDI [...])`.
64+
65+
## 4. File plan
66+
67+
| File | Change |
68+
|---|---|
69+
| `src/statspai/bayes/_base.py` | NEW `_az_hdi_compat`; `BayesianMTEResult` new fields; `summary()` extended |
70+
| `src/statspai/bayes/mte.py` | `_integrated_effect` returns full summary; caller wires into result |
71+
| `src/statspai/bayes/did.py`, `rd.py`, `iv.py`, `fuzzy_rd.py`, `hte_iv.py` | Replace `az.hdi(...)` calls with shim |
72+
| `tests/test_bayes_mte_uncertainty.py` | NEW |
73+
| `tests/test_bayes_hdi_compat.py` | NEW |
74+
| `pyproject.toml` | `version = "0.9.13"` |
75+
76+
## 5. Test plan
77+
78+
### `test_bayes_hdi_compat.py`
79+
80+
1. `test_shim_accepts_hdi_prob_kwarg` — forwards to `az.hdi`.
81+
2. `test_shim_handles_typeerror` — monkey-patch `az.hdi` to raise on `hdi_prob`, confirm fallback to `prob`.
82+
3. `test_shim_returns_length_2_array`.
83+
84+
### `test_bayes_mte_uncertainty.py`
85+
86+
1. `test_att_atu_sd_fields_populated` — after fit, both SDs finite.
87+
2. `test_att_atu_hdi_brackets_posterior_mean``hdi_lower < mean < hdi_upper`.
88+
3. `test_summary_shows_att_atu_uncertainty` — string contains `sd ` and `HDI [`.
89+
4. `test_empty_population_nan_safe` — all-treated DGP → ATU SD is NaN, no crash.
90+
91+
## 6. Success criteria
92+
93+
1. Existing arviz still works.
94+
2. Mocked future-arviz (raises on `hdi_prob`) works via fallback.
95+
3. `r.att_sd`, `r.atu_sd` finite on a typical fit.
96+
4. Two rounds of code review, zero ship-blockers.

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.12"
7+
version = "0.9.13"
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: 1 addition & 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.12"
25+
__version__ = "0.9.13"
2626
__author__ = "Biaoyue Wang"
2727
__email__ = "brycew6m@stanford.edu"
2828

src/statspai/bayes/_base.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,25 @@ def _require_pymc():
4646
return pm, az
4747

4848

49+
def _az_hdi_compat(samples, hdi_prob: float = 0.95) -> np.ndarray:
50+
"""Call ``arviz.hdi`` with the correct kwarg regardless of version.
51+
52+
arviz < 0.18 accepts ``hdi_prob=...``; arviz ≥ 0.18 renamed it to
53+
``prob=...``. Everything else downstream expects a length-2 numpy
54+
array (``[lower, upper]``), so we normalise the output too.
55+
56+
Code-review history: the v0.9.12 round-C review flagged the
57+
direct ``az.hdi(..., hdi_prob=...)`` calls as a time-bomb for
58+
arviz ≥ 0.18 upgrades. This shim routes all calls through one
59+
place so a future arviz rename only touches this function.
60+
"""
61+
_, az = _require_pymc()
62+
try:
63+
return np.asarray(az.hdi(samples, hdi_prob=hdi_prob)).ravel()
64+
except TypeError:
65+
return np.asarray(az.hdi(samples, prob=hdi_prob)).ravel()
66+
67+
4968
# ---------------------------------------------------------------------------
5069
# Result type
5170
# ---------------------------------------------------------------------------
@@ -365,7 +384,7 @@ def predict_cate(self, values: Dict[str, float]) -> Dict[str, float]:
365384
m_vec[i] = v - self._modifier_means[i]
366385

367386
cate_samples = tau0 + tau_hte @ m_vec
368-
hdi = np.asarray(az.hdi(cate_samples, hdi_prob=self.hdi_prob)).ravel()
387+
hdi = _az_hdi_compat(cate_samples, hdi_prob=self.hdi_prob)
369388
return {
370389
'mean': float(np.mean(cate_samples)),
371390
'median': float(np.median(cate_samples)),
@@ -396,6 +415,16 @@ class BayesianMTEResult(BayesianCausalResult):
396415
# remembers this so `policy_effect` can transform the abscissa
397416
# before integrating against the user's weight_fn.
398417
selection: str = 'uniform'
418+
# v0.9.13: ATT / ATU uncertainty triplets. Default NaN so
419+
# pre-v0.9.13 serialised results back-compat. ``posterior_sd`` on
420+
# the parent already covers ATE uncertainty (ATE is the primary
421+
# estimand) so there is no ``ate_sd`` field.
422+
att_sd: float = float('nan')
423+
att_hdi_lower: float = float('nan')
424+
att_hdi_upper: float = float('nan')
425+
atu_sd: float = float('nan')
426+
atu_hdi_lower: float = float('nan')
427+
atu_hdi_upper: float = float('nan')
399428

400429
def policy_effect(
401430
self,
@@ -478,7 +507,7 @@ def policy_effect(
478507
numer_samples = np.trapezoid(mte_samples * weights, x=u, axis=1)
479508
policy_samples = numer_samples / denom
480509

481-
hdi = np.asarray(az.hdi(policy_samples, hdi_prob=self.hdi_prob)).ravel()
510+
hdi = _az_hdi_compat(policy_samples, hdi_prob=self.hdi_prob)
482511
summary = {
483512
'label': label,
484513
'estimate': float(np.mean(policy_samples)),
@@ -548,9 +577,7 @@ def _summarise_posterior(
548577
mean = float(np.mean(posterior))
549578
median = float(np.median(posterior))
550579
sd = float(np.std(posterior, ddof=1))
551-
hdi = az.hdi(posterior, hdi_prob=hdi_prob)
552-
# az.hdi may return a numpy array (older arviz) or DataArray
553-
hdi_arr = np.asarray(hdi).ravel()
580+
hdi_arr = _az_hdi_compat(posterior, hdi_prob=hdi_prob)
554581
hdi_lower = float(hdi_arr[0])
555582
hdi_upper = float(hdi_arr[1])
556583
prob_positive = float(np.mean(posterior > 0))

src/statspai/bayes/hte_iv.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
from ._base import (
2424
BayesianHTEIVResult,
25+
_az_hdi_compat,
2526
_require_pymc,
2627
_sample_model,
2728
_summarise_posterior,
@@ -225,7 +226,7 @@ def bayes_hte_iv(
225226
_, az = _require_pymc()
226227
for k, name in enumerate(mod_cols):
227228
col = flat[:, k]
228-
hdi = np.asarray(az.hdi(col, hdi_prob=hdi_prob)).ravel()
229+
hdi = _az_hdi_compat(col, hdi_prob=hdi_prob)
229230
slope_rows.append({
230231
'term': name,
231232
'estimate': float(np.mean(col)),

src/statspai/bayes/mte.py

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
from ._base import (
8080
BayesianMTEResult,
8181
PROBIT_CLIP,
82+
_az_hdi_compat,
8283
_require_pymc,
8384
_sample_model,
8485
_summarise_posterior,
@@ -432,7 +433,7 @@ def _abscissa(a_expr, is_numpy: bool):
432433
v_grid_out = None
433434
for k, u in enumerate(u_grid):
434435
col = mte_samples[:, k]
435-
hdi = np.asarray(az.hdi(col, hdi_prob=hdi_prob)).ravel()
436+
hdi = _az_hdi_compat(col, hdi_prob=hdi_prob)
436437
row = {
437438
'u': float(u),
438439
'posterior_mean': float(np.mean(col)),
@@ -480,35 +481,41 @@ def _abscissa(a_expr, is_numpy: bool):
480481
U_untreated = U_pop[untreated_mask] if untreated_mask.sum() > 0 else np.array([])
481482

482483
def _integrated_effect(U_population):
484+
"""Posterior summary of the MTE integrated over a subpopulation.
485+
486+
Returns (mean, sd, hdi_lower, hdi_high). All NaN when the
487+
subpopulation is empty (e.g. all units treated ⇒ no ATU).
488+
"""
483489
if U_population.size == 0:
484-
return float('nan'), float('nan')
485-
# For each posterior draw, evaluate MTE at each population
486-
# unit's *abscissa*. Under `selection='normal'` the polynomial
487-
# is in V = Φ^{-1}(U_D), so we must transform U_population
488-
# before raising to powers — otherwise ATT/ATU evaluate
489-
# `b_0 + b_1·u + …` on unit-level U_D ∈ [0,1] while the
490-
# posterior `b_mte` describes `b_0 + b_1·v + …` on v ∈ ℝ.
491-
# That was the BLOCKER found in v0.9.12 round-B review.
490+
return (float('nan'),) * 4
492491
if selection == 'normal':
493492
from scipy.stats import norm as _norm_dist
494-
pop_abscissa = _norm_dist.ppf(np.clip(U_population, PROBIT_CLIP, 1 - PROBIT_CLIP))
493+
pop_abscissa = _norm_dist.ppf(
494+
np.clip(U_population, PROBIT_CLIP, 1 - PROBIT_CLIP)
495+
)
495496
else:
496497
pop_abscissa = U_population
497498
u_pow_pop = np.column_stack(
498499
[pop_abscissa ** k for k in range(poly_u + 1)]
499500
)
500-
samples = b_mte_post @ u_pow_pop.T # (S, n_pop)
501+
samples = b_mte_post @ u_pow_pop.T # (S, n_pop)
501502
per_draw_mean = samples.mean(axis=1) # integrated over population
502-
return float(per_draw_mean.mean()), float(per_draw_mean.std(ddof=1))
503+
hdi = _az_hdi_compat(per_draw_mean, hdi_prob=hdi_prob)
504+
return (
505+
float(per_draw_mean.mean()),
506+
float(per_draw_mean.std(ddof=1)),
507+
float(hdi[0]),
508+
float(hdi[1]),
509+
)
503510

504-
att_mean, _ = _integrated_effect(U_treated)
505-
atu_mean, _ = _integrated_effect(U_untreated)
511+
att_mean, att_sd, att_hdi_lo, att_hdi_hi = _integrated_effect(U_treated)
512+
atu_mean, atu_sd, atu_hdi_lo, atu_hdi_hi = _integrated_effect(U_untreated)
506513

507514
# Primary estimand: average MTE (ATE integral)
508515
ate_mean = float(ate_samples.mean())
509516
ate_median = float(np.median(ate_samples))
510517
ate_sd = float(ate_samples.std(ddof=1))
511-
ate_hdi = np.asarray(az.hdi(ate_samples, hdi_prob=hdi_prob)).ravel()
518+
ate_hdi = _az_hdi_compat(ate_samples, hdi_prob=hdi_prob)
512519
prob_pos_ate = float(np.mean(ate_samples > 0))
513520

514521
# R-hat / ESS on the average-MTE derived quantity — not perfect
@@ -587,4 +594,10 @@ def _integrated_effect(U_population):
587594
att=att_mean,
588595
atu=atu_mean,
589596
selection=selection,
597+
att_sd=att_sd,
598+
att_hdi_lower=att_hdi_lo,
599+
att_hdi_upper=att_hdi_hi,
600+
atu_sd=atu_sd,
601+
atu_hdi_lower=atu_hdi_lo,
602+
atu_hdi_upper=atu_hdi_hi,
590603
)

0 commit comments

Comments
 (0)