mca_diagnostic.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """Controlled numerical diagnostic for constrained-Bayesian MCA.
  2. Data are generated exactly from the Gamma/Betaprime class-conditional model.
  3. This checks numerical behavior under the model assumptions; it is not a
  4. clinical validation or a simulation study of statistical performance.
  5. """
  6. from __future__ import annotations
  7. import os
  8. import sys
  9. from pathlib import Path
  10. import matplotlib
  11. matplotlib.use("Agg")
  12. import matplotlib.pyplot as plt
  13. import numpy as np
  14. import pandas as pd
  15. from scipy import stats
  16. EXAMPLE_DIR = Path(__file__).resolve().parent
  17. PROJECT_ROOT = EXAMPLE_DIR.parents[1]
  18. if str(PROJECT_ROOT.parent) not in sys.path:
  19. sys.path.insert(0, str(PROJECT_ROOT.parent))
  20. from organized_uncertainty_analysis.bayesian.core import (
  21. P_with,
  22. estimate_ci_bundle,
  23. slope_at_x,
  24. theta_max,
  25. x_at_p,
  26. )
  27. SEED = 20260914
  28. SAMPLE_SIZES = (1000, 500, 200, 60)
  29. MCA_DRAWS = int(os.getenv("BAYES_MCA_DIAGNOSTIC_DRAWS", "5000"))
  30. OUTPUT_DIR = Path(
  31. os.getenv("BAYES_MCA_DIAGNOSTIC_OUTPUT", str(EXAMPLE_DIR / "mca_diagnostic_outputs"))
  32. ).resolve()
  33. OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
  34. # Feasible generating parameters: omega, a, b, s, k, vartheta.
  35. OMEGA = 0.30
  36. A = 8.0
  37. B = 5.0
  38. S = 4.0
  39. K = 5.0
  40. VARTTHETA = 1.0
  41. CAP = theta_max(A, B, K, S)
  42. if not (0.0 < VARTTHETA < CAP):
  43. raise RuntimeError("The selected generating parameters violate the model constraints.")
  44. TRUE_THETA = (OMEGA, A, B, S, K, VARTTHETA, CAP)
  45. def generate_exact_model_data(n: int, seed: int) -> tuple[np.ndarray, np.ndarray]:
  46. """Draw y from prevalence and x from the matching class distribution."""
  47. rng = np.random.default_rng(seed)
  48. y = rng.binomial(1, OMEGA, size=n).astype(int)
  49. x = np.empty(n, dtype=float)
  50. nc = y == 0
  51. ae = ~nc
  52. x[nc] = rng.gamma(shape=K, scale=VARTTHETA, size=int(nc.sum()))
  53. x[ae] = stats.betaprime.rvs(A, B, scale=S, size=int(ae.sum()), random_state=rng)
  54. return np.clip(x, 1e-12, None), y
  55. def interval(values: np.ndarray) -> tuple[float, float]:
  56. values = np.asarray(values, float)
  57. values = values[np.isfinite(values)]
  58. if not len(values):
  59. return np.nan, np.nan
  60. lo, hi = np.quantile(values, [0.025, 0.975])
  61. return float(lo), float(hi)
  62. def run_one(n: int, seed: int):
  63. x, y = generate_exact_model_data(n, seed)
  64. x_upper = max(float(np.quantile(x, 0.995) * 1.10), 12.0)
  65. x_grid = np.linspace(max(1e-5, float(x.min()) * 0.75), x_upper, 400)
  66. result = estimate_ci_bundle(
  67. x,
  68. y,
  69. f"n={n}",
  70. x_grid,
  71. B_nonpar=0,
  72. B_param=0,
  73. M_mca=MCA_DRAWS,
  74. mca_seed=seed + 10000,
  75. seed=seed,
  76. use_prior_p=True,
  77. prior_r=(1.05, 1.05),
  78. tau=25.0,
  79. n_starts_clean=5,
  80. n_starts_boot=1,
  81. progress_every=max(MCA_DRAWS, 1),
  82. max_hessian_condition=1e6,
  83. )
  84. h = result["hessian_diagnostics"]
  85. m = result["diagnostics_mca"]
  86. x50_lo, x50_hi = interval(result["x50_mca"])
  87. s50_lo, s50_hi = interval(result["s50_mca"])
  88. band_width = np.asarray(result["mca_hi"]) - np.asarray(result["mca_lo"])
  89. summary = {
  90. "n": n,
  91. "events": int(y.sum()),
  92. "event_fraction": float(y.mean()),
  93. "objective": float(result["objective"]),
  94. "x50_hat": float(result["x50"]),
  95. "x50_mca_lo": x50_lo,
  96. "x50_mca_hi": x50_hi,
  97. "s50_hat": float(result["s50"]),
  98. "s50_mca_lo": s50_lo,
  99. "s50_mca_hi": s50_hi,
  100. "hessian_min_eigenvalue": float(h["minimum_eigenvalue"]),
  101. "hessian_original_condition": float(h["original_condition_number"]),
  102. "hessian_positive_definite": bool(h["original_positive_definite"]),
  103. "hessian_stabilized": bool(h["stabilized"]),
  104. "hessian_reliable": bool(h["reliable"]),
  105. "mca_attempted": int(m["attempted"]),
  106. "mca_accepted": int(m["successful_curve"]),
  107. "mca_acceptance_fraction": float(m["successful_curve"] / max(m["attempted"], 1)),
  108. "mca_band_mean_width": float(np.mean(band_width)),
  109. "mca_band_max_width": float(np.max(band_width)),
  110. }
  111. curve_table = pd.DataFrame(
  112. {
  113. "n": n,
  114. "x": x_grid,
  115. "true_probability": P_with(TRUE_THETA, x_grid),
  116. "fitted_probability": result["pmap"],
  117. "mca_lower": result["mca_lo"],
  118. "mca_upper": result["mca_hi"],
  119. }
  120. )
  121. return summary, curve_table
  122. def main() -> None:
  123. true_x50 = x_at_p(TRUE_THETA, hi=20.0, hi_max=200.0)
  124. true_s50 = slope_at_x(TRUE_THETA, true_x50)
  125. summaries = []
  126. curves = []
  127. for index, n in enumerate(SAMPLE_SIZES):
  128. summary, curve = run_one(n, SEED + index)
  129. summaries.append(summary)
  130. curves.append(curve)
  131. summary_table = pd.DataFrame(summaries)
  132. summary_table.insert(3, "true_x50", true_x50)
  133. summary_table.insert(7, "true_s50", true_s50)
  134. curve_table = pd.concat(curves, ignore_index=True)
  135. summary_table.to_csv(OUTPUT_DIR / "mca_diagnostic_summary.csv", index=False)
  136. curve_table.to_csv(OUTPUT_DIR / "mca_diagnostic_curves.csv", index=False)
  137. fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharey=True)
  138. for ax, n in zip(axes.flat, SAMPLE_SIZES):
  139. block = curve_table[curve_table["n"] == n]
  140. ax.fill_between(block["x"], block["mca_lower"], block["mca_upper"], alpha=0.22)
  141. ax.plot(block["x"], block["true_probability"], "--", color="tab:orange", label="generating risk")
  142. ax.plot(block["x"], block["fitted_probability"], color="black", label="MAP fit")
  143. ax.set_title(f"n={n}")
  144. ax.set_xlabel("x")
  145. ax.set_ylim(-0.02, 1.02)
  146. axes[0, 0].set_ylabel("P(y=1 | x)")
  147. axes[1, 0].set_ylabel("P(y=1 | x)")
  148. axes[0, 0].legend(frameon=True)
  149. fig.tight_layout()
  150. fig.savefig(OUTPUT_DIR / "mca_diagnostic.png", dpi=300, bbox_inches="tight")
  151. fig.savefig(OUTPUT_DIR / "mca_diagnostic.pdf", bbox_inches="tight")
  152. plt.close(fig)
  153. print(f"True x50={true_x50:.6f}; true s50={true_s50:.6f}")
  154. print(summary_table.to_string(index=False))
  155. print(f"\nOutputs: {OUTPUT_DIR}")
  156. if __name__ == "__main__":
  157. main()