|
79 | 79 | from ._base import ( |
80 | 80 | BayesianMTEResult, |
81 | 81 | PROBIT_CLIP, |
| 82 | + _az_hdi_compat, |
82 | 83 | _require_pymc, |
83 | 84 | _sample_model, |
84 | 85 | _summarise_posterior, |
@@ -432,7 +433,7 @@ def _abscissa(a_expr, is_numpy: bool): |
432 | 433 | v_grid_out = None |
433 | 434 | for k, u in enumerate(u_grid): |
434 | 435 | 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) |
436 | 437 | row = { |
437 | 438 | 'u': float(u), |
438 | 439 | 'posterior_mean': float(np.mean(col)), |
@@ -480,35 +481,41 @@ def _abscissa(a_expr, is_numpy: bool): |
480 | 481 | U_untreated = U_pop[untreated_mask] if untreated_mask.sum() > 0 else np.array([]) |
481 | 482 |
|
482 | 483 | 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 | + """ |
483 | 489 | 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 |
492 | 491 | if selection == 'normal': |
493 | 492 | 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 | + ) |
495 | 496 | else: |
496 | 497 | pop_abscissa = U_population |
497 | 498 | u_pow_pop = np.column_stack( |
498 | 499 | [pop_abscissa ** k for k in range(poly_u + 1)] |
499 | 500 | ) |
500 | | - samples = b_mte_post @ u_pow_pop.T # (S, n_pop) |
| 501 | + samples = b_mte_post @ u_pow_pop.T # (S, n_pop) |
501 | 502 | 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 | + ) |
503 | 510 |
|
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) |
506 | 513 |
|
507 | 514 | # Primary estimand: average MTE (ATE integral) |
508 | 515 | ate_mean = float(ate_samples.mean()) |
509 | 516 | ate_median = float(np.median(ate_samples)) |
510 | 517 | 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) |
512 | 519 | prob_pos_ate = float(np.mean(ate_samples > 0)) |
513 | 520 |
|
514 | 521 | # R-hat / ESS on the average-MTE derived quantity — not perfect |
@@ -587,4 +594,10 @@ def _integrated_effect(U_population): |
587 | 594 | att=att_mean, |
588 | 595 | atu=atu_mean, |
589 | 596 | 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, |
590 | 603 | ) |
0 commit comments