core.py 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672
  1. # bayesian.py
  2. # ============================================================
  3. # CONDITIONAL BAYESIAN RISK FIT + CONSISTENT CI
  4. # ============================================================
  5. # bayesian_final_clean.py
  6. # ============================================================
  7. # FINAL CONSTRAINED CONDITIONAL RISK MODEL + UNCERTAINTY
  8. # ============================================================
  9. #
  10. # Model fitted by MAP:
  11. # Y | X=x ~ Bernoulli(P(AE | X=x))
  12. #
  13. # Constraints are enforced through an unconstrained parameter vector phi.
  14. # The risk function P(AE | X=x) is derived from Bayes' rule.
  15. #
  16. # Uncertainty:
  17. # - local Wald/delta approximation in phi-space
  18. # - nonparametric pairs bootstrap (NPBS)
  19. # - generative constrained-Bayesian parametric bootstrap (PBS)
  20. #
  21. # Every clean and bootstrap dataset uses the same conditional MAP estimator.
  22. # Bootstrap refits begin at the clean fit and use seeded fallback starts.
  23. # ============================================================
  24. import os
  25. import time
  26. import warnings
  27. import numpy as np
  28. import pandas as pd
  29. import matplotlib.pyplot as plt
  30. from matplotlib.lines import Line2D
  31. from scipy import optimize, stats
  32. from scipy.ndimage import gaussian_filter1d
  33. from scipy.io import loadmat
  34. from scipy.optimize import brentq
  35. from scipy.special import betaln, gammaln
  36. from ..data import get_data
  37. # ============================================================
  38. # 1) DATA
  39. # ============================================================
  40. def load_xy(
  41. perc=95,
  42. suv_path="suv_percentilesSLOthenUWM.mat",
  43. flags_path="flags_combined.mat",
  44. ):
  45. here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  46. suv_full = os.path.join(here, suv_path)
  47. flags_full = os.path.join(here, flags_path)
  48. print("Loading SUV from:", suv_full)
  49. print("Loading FLAGS from:", flags_full)
  50. suv_dict = loadmat(suv_full)
  51. flags_dict = loadmat(flags_full)
  52. X, y = get_data(perc, suv_dict, flags_dict)
  53. X = np.asarray(X, float).ravel()
  54. y = np.asarray(y, int).ravel()
  55. mask = np.isfinite(X)
  56. X, y = X[mask], y[mask]
  57. X = np.clip(X, 1e-12, None)
  58. return X, y
  59. def make_trimmed_dataset(X, y, value_to_drop=2.48122597, tol=1e-3):
  60. X = np.asarray(X, float)
  61. y = np.asarray(y, int)
  62. mask_keep = np.abs(X - value_to_drop) > tol
  63. return {
  64. "X_orig": X.copy(),
  65. "y_orig": y.copy(),
  66. "X_trim": X[mask_keep],
  67. "y_trim": y[mask_keep],
  68. "removed_idx": np.where(~mask_keep)[0],
  69. }
  70. # ============================================================
  71. # 2) PARAMETERIZATION AND MODEL
  72. # ============================================================
  73. def sigmoid(t):
  74. return 1.0 / (1.0 + np.exp(-np.clip(t, -60.0, 60.0)))
  75. def softplus(t):
  76. t = np.asarray(t, float)
  77. return np.log1p(np.exp(-np.abs(t))) + np.maximum(t, 0.0)
  78. def theta_max(a, b, k, s, eps=1e-12):
  79. """Monotonicity cap for vartheta."""
  80. A = a - k
  81. if A <= 0.0:
  82. return np.inf
  83. r = np.sqrt(a + b) - np.sqrt(max(A, eps))
  84. return np.inf if r <= 1e-12 else s / (r * r)
  85. def unpack(phi):
  86. """
  87. Map unconstrained phi to scientific parameters.
  88. phi = [omega_raw, b_raw, s_raw, k_raw, delta_raw, r_raw]
  89. omega in (0,1)
  90. b,s,k > 0
  91. a = k + delta, delta > 0
  92. vartheta = vartheta_cap * sigmoid(r_raw)
  93. """
  94. omega_raw, b_raw, s_raw, k_raw, delta_raw, r_raw = np.asarray(phi, float)
  95. omega = sigmoid(omega_raw)
  96. b = float(softplus(b_raw) + 1e-6)
  97. s = float(softplus(s_raw) + 1e-6)
  98. k = float(softplus(k_raw) + 1e-6)
  99. delta = float(softplus(delta_raw) + 1e-6)
  100. a = k + delta
  101. vartheta_cap = theta_max(a, b, k, s)
  102. vartheta = vartheta_cap * sigmoid(r_raw)
  103. return omega, a, b, s, k, vartheta, vartheta_cap
  104. def dE_full(x, a, b, s, k, vartheta):
  105. """log f_AE(x) - log f_NC(x), including normalizing constants."""
  106. x = np.asarray(x, float)
  107. return (
  108. (a - k) * np.log(x)
  109. - (a + b) * np.log1p(x / s)
  110. + x / vartheta
  111. - a * np.log(s)
  112. - betaln(a, b)
  113. + k * np.log(vartheta)
  114. + gammaln(k)
  115. )
  116. def P_with(theta_hat, x):
  117. """Derived risk function P(AE | X=x)."""
  118. omega, a, b, s, k, vartheta, _ = theta_hat
  119. x = np.asarray(x, float)
  120. logit_omega = np.log(omega) - np.log1p(-omega)
  121. eta = logit_omega + dE_full(x, a, b, s, k, vartheta)
  122. return sigmoid(eta)
  123. def make_priors(y, tau=25.0):
  124. """Beta(tau*p_emp, tau*(1-p_emp)) prior on prevalence omega."""
  125. p_emp = float(np.mean(y))
  126. alpha = max(tau * p_emp, 1e-6)
  127. beta = max(tau * (1.0 - p_emp), 1e-6)
  128. return alpha, beta
  129. def init_phi(X, y):
  130. """Stable generic start in unconstrained coordinates."""
  131. X = np.asarray(X, float)
  132. y = np.asarray(y, int)
  133. X0 = X[y == 0]
  134. m0 = X0.mean() if X0.size else X.mean()
  135. v0 = X0.var() if X0.size else X.var()
  136. k0 = 2.0 if v0 <= 0 else max((m0 * m0) / (v0 + 1e-9), 1.5)
  137. X1 = X[y == 1]
  138. m1 = np.median(X1) if X1.size else np.median(X)
  139. omega0 = np.clip(float(np.mean(y)), 1e-3, 1.0 - 1e-3)
  140. b0 = 1.5
  141. s0 = max(m1, 0.5)
  142. return np.array(
  143. [
  144. np.log(omega0 / (1.0 - omega0)),
  145. np.log(np.expm1(b0) + 1e-9),
  146. np.log(np.expm1(s0) + 1e-9),
  147. np.log(np.expm1(k0) + 1e-9),
  148. np.log(np.expm1(1.0) + 1e-9),
  149. -0.2,
  150. ],
  151. dtype=float,
  152. )
  153. # ============================================================
  154. # 3) CONDITIONAL NEGATIVE LOG-POSTERIOR
  155. # ============================================================
  156. def neg_post(
  157. phi,
  158. X,
  159. y,
  160. alpha,
  161. beta,
  162. use_prior_p=True,
  163. prior_r=(1.05, 1.05),
  164. ):
  165. """Conditional Bernoulli negative log-posterior for P(Y=1 | X)."""
  166. phi = np.asarray(phi, float).reshape(-1)
  167. X = np.asarray(X, float).reshape(-1)
  168. y = np.asarray(y, int).reshape(-1)
  169. if (
  170. phi.size != 6
  171. or X.size != y.size
  172. or not np.all(np.isfinite(phi))
  173. or not np.all(np.isfinite(X))
  174. or np.any(X <= 0.0)
  175. ):
  176. return 1e100
  177. try:
  178. omega, a, b, s, k, vartheta, vartheta_cap = unpack(phi)
  179. except Exception:
  180. return 1e100
  181. pars = np.asarray(
  182. [omega, a, b, s, k, vartheta, vartheta_cap],
  183. float,
  184. )
  185. if not np.all(np.isfinite(pars)):
  186. return 1e100
  187. if not (
  188. 0.0 < omega < 1.0
  189. and a > k > 0.0
  190. and b > 0.0
  191. and s > 0.0
  192. and vartheta > 0.0
  193. and vartheta_cap > 0.0
  194. and vartheta < vartheta_cap
  195. ):
  196. return 1e100
  197. eps = 1e-12
  198. omega_safe = float(np.clip(omega, eps, 1.0 - eps))
  199. eta = (
  200. np.log(omega_safe)
  201. - np.log1p(-omega_safe)
  202. + dE_full(X, a, b, s, k, vartheta)
  203. )
  204. probability = np.clip(sigmoid(eta), eps, 1.0 - eps)
  205. loglik = float(
  206. np.sum(y * np.log(probability) + (1 - y) * np.log1p(-probability))
  207. )
  208. logprior = 0.0
  209. if use_prior_p:
  210. logprior += float(
  211. (alpha - 1.0) * np.log(omega_safe)
  212. + (beta - 1.0) * np.log1p(-omega_safe)
  213. )
  214. if prior_r is not None:
  215. r = vartheta / vartheta_cap
  216. if not np.isfinite(r) or not (0.0 < r < 1.0):
  217. return 1e100
  218. r = float(np.clip(r, 1e-12, 1.0 - 1e-12))
  219. logprior += float(
  220. (prior_r[0] - 1.0) * np.log(r)
  221. + (prior_r[1] - 1.0) * np.log1p(-r)
  222. )
  223. value = -(loglik + logprior)
  224. return float(value) if np.isfinite(value) else 1e100
  225. # ============================================================
  226. # 4) MAP FITTING
  227. # ============================================================
  228. def fit_bayes(
  229. X,
  230. y,
  231. seed=0,
  232. use_prior_p=True,
  233. prior_r=(1.05, 1.05),
  234. tau=25.0,
  235. phi_start=None,
  236. n_starts=1,
  237. maxiter=6000,
  238. ftol=1e-9,
  239. gtol=1e-8,
  240. maxls=50,
  241. refine=False,
  242. prior_alpha_beta=None,
  243. ):
  244. """Fit the conditional MAP estimator with deterministic multi-starts.
  245. ``phi_start`` is used first when supplied (notably for bootstrap refits).
  246. Additional starts are seeded perturbations, and the best converged fit is
  247. returned. This keeps the estimator consistent across all datasets while
  248. making the previously exposed fitting arguments effective.
  249. """
  250. X = np.asarray(X, float).reshape(-1)
  251. y = np.asarray(y, int).reshape(-1)
  252. valid = np.isfinite(X) & (X > 0.0)
  253. X, y = X[valid], y[valid]
  254. if X.size == 0 or X.size != y.size:
  255. raise ValueError("Invalid or empty dataset.")
  256. if np.unique(y).size < 2:
  257. raise ValueError("Both outcome classes are required.")
  258. if prior_alpha_beta is None:
  259. alpha, beta = make_priors(y, tau=tau)
  260. else:
  261. alpha, beta = map(float, prior_alpha_beta)
  262. objective = lambda w: neg_post(
  263. w,
  264. X,
  265. y,
  266. alpha,
  267. beta,
  268. use_prior_p=use_prior_p,
  269. prior_r=prior_r,
  270. )
  271. del refine
  272. rng = np.random.default_rng(seed)
  273. default_start = init_phi(X, y)
  274. first_start = (
  275. np.asarray(phi_start, float).reshape(-1)
  276. if phi_start is not None else default_start
  277. )
  278. if first_start.size != 6 or not np.all(np.isfinite(first_start)):
  279. raise ValueError("phi_start must contain six finite values.")
  280. starts = [first_start]
  281. if phi_start is not None and int(n_starts) > 1:
  282. starts.append(default_start)
  283. while len(starts) < max(1, int(n_starts)):
  284. starts.append(first_start + rng.normal(0.0, 0.2, size=6))
  285. results = []
  286. options = {
  287. "maxiter": int(maxiter),
  288. "ftol": float(ftol),
  289. "gtol": float(gtol),
  290. "maxls": int(maxls),
  291. }
  292. for start in starts:
  293. candidate = optimize.minimize(
  294. objective, start, method="L-BFGS-B", options=options
  295. )
  296. if candidate.success and np.isfinite(candidate.fun):
  297. results.append(candidate)
  298. if not results:
  299. fallback = first_start + rng.normal(0.0, 0.2, size=6)
  300. candidate = optimize.minimize(
  301. objective, fallback, method="L-BFGS-B", options=options
  302. )
  303. if candidate.success and np.isfinite(candidate.fun):
  304. results.append(candidate)
  305. if not results:
  306. raise RuntimeError("Conditional MAP fit did not converge.")
  307. res = min(results, key=lambda item: float(item.fun))
  308. return unpack(res.x), res
  309. def _fit_bootstrap(
  310. Xb,
  311. yb,
  312. phi_clean,
  313. seed,
  314. use_prior_p,
  315. prior_r,
  316. tau,
  317. n_starts_boot=5,
  318. prior_alpha_beta=None,
  319. ):
  320. """Refit a bootstrap dataset using the clean fit as the first start."""
  321. return fit_bayes(
  322. Xb,
  323. yb,
  324. seed=seed,
  325. use_prior_p=use_prior_p,
  326. prior_r=prior_r,
  327. tau=tau,
  328. phi_start=phi_clean,
  329. n_starts=n_starts_boot,
  330. maxiter=6000,
  331. ftol=1e-9,
  332. gtol=1e-7,
  333. maxls=50,
  334. refine=False,
  335. prior_alpha_beta=prior_alpha_beta,
  336. )
  337. # ============================================================
  338. # 5) DERIVED QUANTITIES
  339. # ============================================================
  340. def x_at_p(
  341. theta_hat,
  342. p_target=0.5,
  343. lo=1e-8,
  344. hi=6.0,
  345. hi_max=100.0,
  346. expansion_factor=2.0,
  347. ):
  348. lo = max(float(lo), 1e-12)
  349. hi = max(float(hi), lo * 1.01)
  350. hi_max = max(float(hi_max), hi)
  351. def f(x):
  352. return float(np.asarray(P_with(theta_hat, x))) - float(p_target)
  353. try:
  354. f_lo = f(lo)
  355. f_hi = f(hi)
  356. if not (np.isfinite(f_lo) and np.isfinite(f_hi)):
  357. return np.nan
  358. while f_lo > 0.0 and lo > 1e-12:
  359. lo_new = max(lo / expansion_factor, 1e-12)
  360. if lo_new == lo:
  361. break
  362. lo = lo_new
  363. f_lo = f(lo)
  364. while f_hi < 0.0 and hi < hi_max:
  365. hi_new = min(hi * expansion_factor, hi_max)
  366. if hi_new == hi:
  367. break
  368. hi = hi_new
  369. f_hi = f(hi)
  370. if f_lo == 0.0:
  371. return lo
  372. if f_hi == 0.0:
  373. return hi
  374. if f_lo * f_hi > 0.0:
  375. return np.nan
  376. return float(brentq(f, lo, hi))
  377. except Exception:
  378. return np.nan
  379. def slope_at_x(theta_hat, x0):
  380. if not np.isfinite(x0) or x0 <= 0.0:
  381. return np.nan
  382. h = 1e-4 * max(1.0, abs(float(x0)))
  383. xm = max(float(x0) - h, 1e-12)
  384. xp = float(x0) + h
  385. try:
  386. pm = float(P_with(theta_hat, xm))
  387. pp = float(P_with(theta_hat, xp))
  388. return float((pp - pm) / (xp - xm))
  389. except Exception:
  390. return np.nan
  391. # ============================================================
  392. # 6) NUMERICAL DIFFERENTIATION / WALD
  393. # ============================================================
  394. def hess_fd(F, x):
  395. x = np.asarray(x, float).reshape(-1)
  396. n = x.size
  397. H = np.zeros((n, n), float)
  398. h = 1e-4 * (1.0 + np.abs(x))
  399. def grad(G, z):
  400. g = np.full(n, np.nan)
  401. for j in range(n):
  402. zp, zm = z.copy(), z.copy()
  403. zp[j] += h[j]
  404. zm[j] -= h[j]
  405. fp, fm = G(zp), G(zm)
  406. if np.isfinite(fp) and np.isfinite(fm):
  407. g[j] = (fp - fm) / (2.0 * h[j])
  408. return g
  409. for i in range(n):
  410. xp, xm = x.copy(), x.copy()
  411. xp[i] += h[i]
  412. xm[i] -= h[i]
  413. H[:, i] = (grad(F, xp) - grad(F, xm)) / (2.0 * h[i])
  414. return 0.5 * (H + H.T)
  415. def jac_fd(Fvec, w):
  416. w = np.asarray(w, float).reshape(-1)
  417. f0 = np.asarray(Fvec(w), float).reshape(-1)
  418. J = np.zeros((f0.size, w.size), float)
  419. h = 1e-4 * (1.0 + np.abs(w))
  420. for j in range(w.size):
  421. wp, wm = w.copy(), w.copy()
  422. wp[j] += h[j]
  423. wm[j] -= h[j]
  424. fp = np.asarray(Fvec(wp), float).reshape(-1)
  425. fm = np.asarray(Fvec(wm), float).reshape(-1)
  426. J[:, j] = (fp - fm) / (2.0 * h[j])
  427. return J
  428. def grad_scalar_fd(F, w):
  429. w = np.asarray(w, float).reshape(-1)
  430. g = np.full(w.size, np.nan)
  431. h = 1e-4 * (1.0 + np.abs(w))
  432. for j in range(w.size):
  433. wp, wm = w.copy(), w.copy()
  434. wp[j] += h[j]
  435. wm[j] -= h[j]
  436. fp, fm = F(wp), F(wm)
  437. if np.isfinite(fp) and np.isfinite(fm):
  438. g[j] = (fp - fm) / (2.0 * h[j])
  439. return g
  440. def covariance_from_hessian(
  441. H,
  442. max_condition=1e6,
  443. condition_warn=1e8,
  444. ):
  445. """Positive spectral stabilization followed by covariance inversion.
  446. Eigenvalues smaller than ``lambda_max / max_condition`` (including
  447. negative eigenvalues) are replaced by that positive floor. The selected
  448. condition limit is recorded so regularized Wald/MCA results remain fully
  449. auditable and can be subjected to a sensitivity analysis.
  450. """
  451. H = np.asarray(H, float)
  452. H = 0.5 * (H + H.T)
  453. eigvals, eigvecs = np.linalg.eigh(H)
  454. if not np.all(np.isfinite(eigvals)):
  455. raise RuntimeError("Non-finite Hessian eigenvalues.")
  456. max_abs = float(np.max(np.abs(eigvals)))
  457. if not np.isfinite(max_abs) or max_abs <= 0.0:
  458. raise RuntimeError("Hessian has no usable curvature.")
  459. positive = eigvals[eigvals > 0.0]
  460. original_condition = (
  461. float(eigvals.max() / positive.min())
  462. if positive.size == eigvals.size else np.inf
  463. )
  464. max_condition = float(max_condition)
  465. if not np.isfinite(max_condition) or max_condition <= 1.0:
  466. raise ValueError("max_condition must be a finite number greater than 1.")
  467. eig_floor = max(max_abs / max_condition, 1e-12)
  468. eigvals_stable = np.maximum(eigvals, eig_floor)
  469. stabilized = bool(np.any(eigvals < eig_floor))
  470. condition = float(eigvals_stable.max() / eigvals_stable.min())
  471. reliable = bool(
  472. np.all(eigvals > 0.0)
  473. and np.isfinite(original_condition)
  474. and original_condition <= max_condition
  475. and not stabilized
  476. )
  477. if not reliable:
  478. warnings.warn(
  479. f"Hessian is weak or indefinite and required spectral "
  480. f"stabilization (original condition={original_condition:.3e}; "
  481. f"stabilized condition={condition:.3e}). "
  482. "Interpret Wald/MCA as local sensitivity approximations.",
  483. RuntimeWarning,
  484. )
  485. Sigma = eigvecs @ np.diag(1.0 / eigvals_stable) @ eigvecs.T
  486. Sigma = 0.5 * (Sigma + Sigma.T)
  487. diagnostics = {
  488. "eigenvalues": eigvals,
  489. "minimum_eigenvalue": float(eigvals.min()),
  490. "maximum_eigenvalue": float(eigvals.max()),
  491. "condition_number": condition,
  492. "original_condition_number": original_condition,
  493. "positive_definite": bool(np.all(eigvals > 0.0)),
  494. "original_positive_definite": bool(np.all(eigvals > 0.0)),
  495. "stabilized": stabilized,
  496. "eigenvalue_floor": eig_floor,
  497. "stabilized_eigenvalues": eigvals_stable,
  498. "reliable": reliable,
  499. "maximum_allowed_condition": max_condition,
  500. }
  501. return Sigma, diagnostics
  502. def reported_theta_from_phi(phi):
  503. omega, a, b, s, k, vartheta, _ = unpack(phi)
  504. return np.array([omega, a, b, s, k, vartheta], float)
  505. def reported_theta_from_hat(theta_hat):
  506. omega, a, b, s, k, vartheta, _ = theta_hat
  507. return np.array([omega, a, b, s, k, vartheta], float)
  508. def _wald_scalar(estimate, gradient, covariance, z):
  509. gradient = np.asarray(gradient, float)
  510. if not np.isfinite(estimate) or not np.all(np.isfinite(gradient)):
  511. return np.nan, np.nan
  512. var = float(gradient @ covariance @ gradient)
  513. if not np.isfinite(var) or var < -1e-12:
  514. return np.nan, np.nan
  515. se = np.sqrt(max(var, 0.0))
  516. return float(estimate - z * se), float(estimate + z * se)
  517. # ============================================================
  518. # 7) CI FOR ONE DATASET
  519. # ============================================================
  520. def estimate_ci_bundle(
  521. X,
  522. y,
  523. label,
  524. x_grid,
  525. B_nonpar=400,
  526. B_param=400,
  527. M_mca=10000,
  528. mca_seed=None,
  529. seed=123,
  530. use_prior_p=True,
  531. prior_r=(1.05, 1.05),
  532. tau=25.0,
  533. alpha_ci=0.05,
  534. n_starts_clean=1,
  535. n_starts_boot=1,
  536. progress_every=25,
  537. max_attempt_multiplier=20,
  538. max_hessian_condition=1e6,
  539. ):
  540. X = np.asarray(X, float).reshape(-1)
  541. y = np.asarray(y, int).reshape(-1)
  542. x_grid = np.asarray(x_grid, float).reshape(-1)
  543. rng = np.random.default_rng(seed)
  544. n = X.size
  545. z = float(stats.norm.ppf(1.0 - alpha_ci / 2.0))
  546. qlo, qhi = 100.0 * alpha_ci / 2.0, 100.0 * (1.0 - alpha_ci / 2.0)
  547. # ---------------- CLEAN MAP ----------------
  548. t0 = time.time()
  549. theta_hat, res = fit_bayes(
  550. X,
  551. y,
  552. seed=seed,
  553. use_prior_p=use_prior_p,
  554. prior_r=prior_r,
  555. tau=tau,
  556. n_starts=n_starts_clean,
  557. refine=False,
  558. )
  559. phi_hat = np.asarray(res.x, float)
  560. theta_hat_vec = reported_theta_from_phi(phi_hat)
  561. pmap = np.asarray(P_with(theta_hat, x_grid), float)
  562. # Freeze the empirical-Bayes prior calibrated on the original dataset.
  563. # Bootstrap samples vary through resampling/simulation, not by redefining
  564. # the prior from each replicate's random event count.
  565. prior_alpha_beta = make_priors(y, tau=tau)
  566. x50_hat = x_at_p(theta_hat, 0.5, hi=max(6.0, float(x_grid.max())))
  567. s50_hat = slope_at_x(theta_hat, x50_hat)
  568. print(f"{label}: clean MAP completed in {(time.time()-t0):.1f} s")
  569. # ---------------- WALD ----------------
  570. alpha_p, beta_p = make_priors(y, tau=tau)
  571. objective = lambda w: neg_post(
  572. w, X, y, alpha_p, beta_p,
  573. use_prior_p=use_prior_p,
  574. prior_r=prior_r,
  575. )
  576. H = hess_fd(objective, phi_hat)
  577. wald_available = True
  578. try:
  579. Sigma_phi, hdiag = covariance_from_hessian(
  580. H, max_condition=max_hessian_condition
  581. )
  582. J_curve = jac_fd(lambda w: P_with(unpack(w), x_grid), phi_hat)
  583. var_curve = np.einsum("ij,jk,ik->i", J_curve, Sigma_phi, J_curve)
  584. se_curve = np.sqrt(np.maximum(var_curve, 0.0))
  585. wald_lo = np.clip(pmap - z * se_curve, 0.0, 1.0)
  586. wald_hi = np.clip(pmap + z * se_curve, 0.0, 1.0)
  587. J_theta = jac_fd(reported_theta_from_phi, phi_hat)
  588. Sigma_theta = J_theta @ Sigma_phi @ J_theta.T
  589. Sigma_theta = 0.5 * (Sigma_theta + Sigma_theta.T)
  590. se_theta = np.sqrt(np.maximum(np.diag(Sigma_theta), 0.0))
  591. wald_param_lo = theta_hat_vec - z * se_theta
  592. wald_param_hi = theta_hat_vec + z * se_theta
  593. def x50_phi(w):
  594. return x_at_p(
  595. unpack(w),
  596. 0.5,
  597. hi=max(6.0, float(x_grid.max())),
  598. )
  599. gx = grad_scalar_fd(x50_phi, phi_hat)
  600. wald_x50_lo, wald_x50_hi = _wald_scalar(
  601. x50_hat, gx, Sigma_phi, z
  602. )
  603. def s50_phi(w):
  604. th = unpack(w)
  605. xx = x_at_p(
  606. th,
  607. 0.5,
  608. hi=max(6.0, float(x_grid.max())),
  609. )
  610. return slope_at_x(th, xx)
  611. gs = grad_scalar_fd(s50_phi, phi_hat)
  612. wald_s50_lo, wald_s50_hi = _wald_scalar(
  613. s50_hat, gs, Sigma_phi, z
  614. )
  615. except RuntimeError as exc:
  616. warnings.warn(f"{label}: Wald unavailable: {exc}", RuntimeWarning)
  617. wald_available = False
  618. Sigma_phi = np.full((6, 6), np.nan)
  619. Sigma_theta = np.full((6, 6), np.nan)
  620. hdiag = {}
  621. wald_lo = wald_hi = np.full_like(pmap, np.nan)
  622. wald_param_lo = wald_param_hi = np.full(6, np.nan)
  623. wald_x50_lo = wald_x50_hi = np.nan
  624. wald_s50_lo = wald_s50_hi = np.nan
  625. # ---------------- MONTE CARLO APPROXIMATION ----------------
  626. # Laplace/normal propagation using the same covariance as Wald. The
  627. # diagnostics state explicitly when historical spectral stabilization was
  628. # required before inversion.
  629. mca_curves, mca_theta, mca_x50, mca_s50 = [], [], [], []
  630. mca_attempted = int(M_mca)
  631. if mca_seed is None:
  632. mca_seed = int(seed) + 10_000
  633. if wald_available and int(M_mca) > 0:
  634. rng_mca = np.random.default_rng(mca_seed)
  635. try:
  636. phi_draws = rng_mca.multivariate_normal(
  637. mean=phi_hat,
  638. cov=Sigma_phi,
  639. size=int(M_mca),
  640. check_valid="raise",
  641. )
  642. for draw in np.atleast_2d(phi_draws):
  643. try:
  644. thm = unpack(draw)
  645. curve = np.asarray(P_with(thm, x_grid), float)
  646. xx = x_at_p(
  647. thm, 0.5, hi=max(6.0, float(x_grid.max()))
  648. )
  649. ss = slope_at_x(thm, xx)
  650. theta_vec = reported_theta_from_hat(thm)
  651. if (
  652. np.all(np.isfinite(curve))
  653. and np.all(np.isfinite(theta_vec))
  654. and np.isfinite(xx)
  655. and np.isfinite(ss)
  656. ):
  657. mca_curves.append(curve)
  658. mca_theta.append(theta_vec)
  659. mca_x50.append(xx)
  660. mca_s50.append(ss)
  661. except Exception:
  662. continue
  663. except Exception as exc:
  664. warnings.warn(f"{label}: MCA unavailable: {exc}", RuntimeWarning)
  665. mca_curves = np.asarray(mca_curves, float)
  666. mca_theta = np.asarray(mca_theta, float)
  667. mca_x50 = np.asarray(mca_x50, float)
  668. mca_s50 = np.asarray(mca_s50, float)
  669. mca_lo = (
  670. np.percentile(mca_curves, qlo, axis=0) if len(mca_curves) else None
  671. )
  672. mca_hi = (
  673. np.percentile(mca_curves, qhi, axis=0) if len(mca_curves) else None
  674. )
  675. diagnostics_mca = {
  676. "scheme": "local_Gaussian_phi_with_spectral_stabilization",
  677. "target": int(M_mca),
  678. "attempted": mca_attempted,
  679. "successful_curve": len(mca_curves),
  680. "successful_x50": len(mca_x50),
  681. "successful_s50": len(mca_s50),
  682. "available": bool(len(mca_curves) > 0),
  683. "reason_if_unavailable": (
  684. None if len(mca_curves) else
  685. "No usable covariance or no finite Monte Carlo draws"
  686. ),
  687. }
  688. # ---------------- NPBS ----------------
  689. np_curves, np_theta, np_x50, np_s50 = [], [], [], []
  690. attempted_np = rejected_single_np = rejected_fit_np = 0
  691. t_np = time.time()
  692. max_attempts_np = max(int(B_nonpar), int(max_attempt_multiplier * B_nonpar))
  693. while len(np_curves) < int(B_nonpar) and attempted_np < max_attempts_np:
  694. attempted_np += 1
  695. idx = rng.integers(0, n, size=n)
  696. Xb, yb = X[idx], y[idx]
  697. if np.unique(yb).size < 2:
  698. rejected_single_np += 1
  699. continue
  700. try:
  701. thb, rb = _fit_bootstrap(
  702. Xb, yb, phi_hat,
  703. int(rng.integers(0, 10_000_000)),
  704. use_prior_p, prior_r, tau,
  705. n_starts_boot=n_starts_boot,
  706. prior_alpha_beta=prior_alpha_beta,
  707. )
  708. curve = np.asarray(P_with(thb, x_grid), float)
  709. xx = x_at_p(thb, 0.5, hi=max(6.0, float(x_grid.max())))
  710. ss = slope_at_x(thb, xx)
  711. if not (
  712. np.all(np.isfinite(curve))
  713. and np.isfinite(xx)
  714. and np.isfinite(ss)
  715. ):
  716. rejected_fit_np += 1
  717. continue
  718. np_curves.append(curve)
  719. np_theta.append(reported_theta_from_hat(thb))
  720. np_x50.append(xx)
  721. np_s50.append(ss)
  722. if progress_every and len(np_curves) % progress_every == 0:
  723. elapsed = (time.time() - t_np) / 60.0
  724. print(
  725. f"{label} NPBS: {len(np_curves)}/{B_nonpar} "
  726. f"({elapsed:.1f} min)"
  727. )
  728. except Exception:
  729. rejected_fit_np += 1
  730. # ---------------- PBS ----------------
  731. pb_curves, pb_theta, pb_x50, pb_s50 = [], [], [], []
  732. attempted_pb = rejected_single_pb = rejected_fit_pb = 0
  733. omega_hat, a_hat, b_hat, s_hat, k_hat, vartheta_hat, _ = theta_hat
  734. t_pb = time.time()
  735. max_attempts_pb = max(int(B_param), int(max_attempt_multiplier * B_param))
  736. while len(pb_curves) < int(B_param) and attempted_pb < max_attempts_pb:
  737. attempted_pb += 1
  738. # Generative CB-PBS from the fitted prevalence and class-conditional
  739. # biomarker distributions, as specified in the methodology.
  740. yb = rng.binomial(1, omega_hat, size=n).astype(int)
  741. if np.unique(yb).size < 2:
  742. rejected_single_pb += 1
  743. continue
  744. Xb = np.empty(n, float)
  745. is_nc = yb == 0
  746. is_ae = ~is_nc
  747. Xb[is_nc] = rng.gamma(
  748. shape=k_hat, scale=vartheta_hat, size=int(is_nc.sum())
  749. )
  750. numerator = rng.gamma(shape=a_hat, scale=1.0, size=int(is_ae.sum()))
  751. denominator = rng.gamma(shape=b_hat, scale=1.0, size=int(is_ae.sum()))
  752. Xb[is_ae] = s_hat * numerator / denominator
  753. try:
  754. thb, rb = _fit_bootstrap(
  755. Xb, yb, phi_hat,
  756. int(rng.integers(0, 10_000_000)),
  757. use_prior_p, prior_r, tau,
  758. n_starts_boot=n_starts_boot,
  759. prior_alpha_beta=prior_alpha_beta,
  760. )
  761. curve = np.asarray(P_with(thb, x_grid), float)
  762. xx = x_at_p(thb, 0.5, hi=max(6.0, float(x_grid.max())))
  763. ss = slope_at_x(thb, xx)
  764. if not (
  765. np.all(np.isfinite(curve))
  766. and np.isfinite(xx)
  767. and np.isfinite(ss)
  768. ):
  769. rejected_fit_pb += 1
  770. continue
  771. pb_curves.append(curve)
  772. pb_theta.append(reported_theta_from_hat(thb))
  773. pb_x50.append(xx)
  774. pb_s50.append(ss)
  775. if progress_every and len(pb_curves) % progress_every == 0:
  776. elapsed = (time.time() - t_pb) / 60.0
  777. print(
  778. f"{label} PBS: {len(pb_curves)}/{B_param} "
  779. f"({elapsed:.1f} min)"
  780. )
  781. except Exception:
  782. rejected_fit_pb += 1
  783. np_curves = np.asarray(np_curves, float)
  784. np_theta = np.asarray(np_theta, float)
  785. np_x50 = np.asarray(np_x50, float)
  786. np_s50 = np.asarray(np_s50, float)
  787. pb_curves = np.asarray(pb_curves, float)
  788. pb_theta = np.asarray(pb_theta, float)
  789. pb_x50 = np.asarray(pb_x50, float)
  790. pb_s50 = np.asarray(pb_s50, float)
  791. np_lo = np.percentile(np_curves, qlo, axis=0) if len(np_curves) else None
  792. np_hi = np.percentile(np_curves, qhi, axis=0) if len(np_curves) else None
  793. pb_lo = np.percentile(pb_curves, qlo, axis=0) if len(pb_curves) else None
  794. pb_hi = np.percentile(pb_curves, qhi, axis=0) if len(pb_curves) else None
  795. diagnostics_np = {
  796. "scheme": "ordinary_pairs",
  797. "target": B_nonpar,
  798. "attempted": attempted_np,
  799. "successful_curve": len(np_curves),
  800. "successful_x50": len(np_x50),
  801. "successful_s50": len(np_s50),
  802. "rejected_single_class": rejected_single_np,
  803. "rejected_fit": rejected_fit_np,
  804. "completed_target": bool(len(np_curves) == int(B_nonpar)),
  805. "max_attempts": max_attempts_np,
  806. }
  807. diagnostics_pb = {
  808. "scheme": "generative_CB_prevalence_and_class_conditional_X",
  809. "target": B_param,
  810. "attempted": attempted_pb,
  811. "successful_curve": len(pb_curves),
  812. "successful_x50": len(pb_x50),
  813. "successful_s50": len(pb_s50),
  814. "rejected_single_class": rejected_single_pb,
  815. "rejected_fit": rejected_fit_pb,
  816. "completed_target": bool(len(pb_curves) == int(B_param)),
  817. "max_attempts": max_attempts_pb,
  818. }
  819. return {
  820. "label": label,
  821. "theta_hat": theta_hat,
  822. "theta_hat_vec": theta_hat_vec,
  823. "phi_hat": phi_hat,
  824. "res": res,
  825. "objective": float(res.fun),
  826. "x50": x50_hat,
  827. "s50": s50_hat,
  828. "pmap": pmap,
  829. "wald_available": wald_available,
  830. "wald_lo": wald_lo,
  831. "wald_hi": wald_hi,
  832. "wald_param_lo": wald_param_lo,
  833. "wald_param_hi": wald_param_hi,
  834. "wald_x50_lo": wald_x50_lo,
  835. "wald_x50_hi": wald_x50_hi,
  836. "wald_s50_lo": wald_s50_lo,
  837. "wald_s50_hi": wald_s50_hi,
  838. "np_lo": np_lo,
  839. "np_hi": np_hi,
  840. "pb_lo": pb_lo,
  841. "pb_hi": pb_hi,
  842. "mca_lo": mca_lo,
  843. "mca_hi": mca_hi,
  844. "theta_np": np_theta,
  845. "theta_pb": pb_theta,
  846. "x50_np": np_x50,
  847. "x50_pb": pb_x50,
  848. "s50_np": np_s50,
  849. "s50_pb": pb_s50,
  850. "theta_mca": mca_theta,
  851. "x50_mca": mca_x50,
  852. "s50_mca": mca_s50,
  853. "used_np": len(np_curves),
  854. "used_pb": len(pb_curves),
  855. "used_mca": len(mca_curves),
  856. "diagnostics_np": diagnostics_np,
  857. "diagnostics_pb": diagnostics_pb,
  858. "diagnostics_mca": diagnostics_mca,
  859. "hessian_diagnostics": hdiag,
  860. "prior_alpha_beta": prior_alpha_beta,
  861. "alpha_ci": float(alpha_ci),
  862. "max_hessian_condition": float(max_hessian_condition),
  863. "H_phi": H,
  864. "Sigma_phi": Sigma_phi,
  865. "Sigma_theta": Sigma_theta,
  866. }
  867. # ============================================================
  868. # 8) FULL + TRIM CI
  869. # ============================================================
  870. def run_bayesian_ci(
  871. perc=95,
  872. suv_path="suv_percentilesSLOthenUWM.mat",
  873. flags_path="flags_combined.mat",
  874. value_to_drop=2.48122597,
  875. tol=1e-3,
  876. xmax=4.5,
  877. n_grid=600,
  878. B_nonpar=400,
  879. B_param=400,
  880. M_mca=10000,
  881. mca_seed=None,
  882. seed=123,
  883. use_prior_p=True,
  884. prior_r=(1.05, 1.05),
  885. tau=25.0,
  886. alpha_ci=0.05,
  887. grid_lower_fraction=0.75,
  888. n_starts_clean=1,
  889. n_starts_boot=1,
  890. progress_every=25,
  891. max_attempt_multiplier=20,
  892. max_hessian_condition=1e6,
  893. ):
  894. X_all, y_all = load_xy(
  895. perc=perc,
  896. suv_path=suv_path,
  897. flags_path=flags_path,
  898. )
  899. ds = make_trimmed_dataset(
  900. X_all,
  901. y_all,
  902. value_to_drop=value_to_drop,
  903. tol=tol,
  904. )
  905. x_min = max(
  906. 1e-8,
  907. grid_lower_fraction
  908. * float(min(ds["X_orig"].min(), ds["X_trim"].min())),
  909. )
  910. x_max = max(
  911. float(xmax),
  912. float(ds["X_orig"].max()),
  913. float(ds["X_trim"].max()),
  914. )
  915. x_grid = np.linspace(x_min, x_max, int(n_grid))
  916. full = estimate_ci_bundle(
  917. ds["X_orig"],
  918. ds["y_orig"],
  919. "FULL",
  920. x_grid,
  921. B_nonpar=B_nonpar,
  922. B_param=B_param,
  923. M_mca=M_mca,
  924. mca_seed=(int(seed) + 10_000 if mca_seed is None else int(mca_seed)),
  925. seed=seed,
  926. use_prior_p=use_prior_p,
  927. prior_r=prior_r,
  928. tau=tau,
  929. alpha_ci=alpha_ci,
  930. n_starts_clean=n_starts_clean,
  931. n_starts_boot=n_starts_boot,
  932. progress_every=progress_every,
  933. max_attempt_multiplier=max_attempt_multiplier,
  934. max_hessian_condition=max_hessian_condition,
  935. )
  936. trim = estimate_ci_bundle(
  937. ds["X_trim"],
  938. ds["y_trim"],
  939. "TRIM",
  940. x_grid,
  941. B_nonpar=B_nonpar,
  942. B_param=B_param,
  943. M_mca=M_mca,
  944. mca_seed=(int(seed) + 10_001 if mca_seed is None else int(mca_seed) + 1),
  945. seed=seed + 1,
  946. use_prior_p=use_prior_p,
  947. prior_r=prior_r,
  948. tau=tau,
  949. alpha_ci=alpha_ci,
  950. n_starts_clean=n_starts_clean,
  951. n_starts_boot=n_starts_boot,
  952. progress_every=progress_every,
  953. max_attempt_multiplier=max_attempt_multiplier,
  954. max_hessian_condition=max_hessian_condition,
  955. )
  956. return {
  957. **ds,
  958. "x_grid": x_grid,
  959. "orig": full,
  960. "trim": trim,
  961. }
  962. # ============================================================
  963. # 9) TABLES / DIAGNOSTICS
  964. # ============================================================
  965. PARAMETER_NAMES = ["omega", "a", "b", "s", "k", "vartheta"]
  966. HISTORICAL_REFERENCE = {
  967. "FULL": {
  968. "objective": 9.612766,
  969. "theta": np.array([
  970. 0.05013031639340312,
  971. 835.7394119971459,
  972. 812.0599466844761,
  973. 1.5849829783592195,
  974. 260.69110931079496,
  975. 0.005737139709007281,
  976. ]),
  977. "x50": 1.7923,
  978. },
  979. "TRIM": {
  980. "objective": 7.423491,
  981. "theta": np.array([
  982. 0.050915239967693926,
  983. 547.8229343628883,
  984. 297.1652388267381,
  985. 0.9319626391393511,
  986. 116.9852193858385,
  987. 0.012177886089862443,
  988. ]),
  989. "x50": 1.7541,
  990. },
  991. }
  992. def check_historical_reference(ci_res, rtol=5e-3, atol=5e-4):
  993. """Compare the clean fit with the saved approved notebook results."""
  994. rows = []
  995. for key, dataset in (("orig", "FULL"), ("trim", "TRIM")):
  996. out = ci_res[key]
  997. ref = HISTORICAL_REFERENCE[dataset]
  998. theta = np.asarray(out["theta_hat_vec"], float)
  999. rows.append({
  1000. "Dataset": dataset,
  1001. "Objective": float(out["objective"]),
  1002. "Reference_objective": ref["objective"],
  1003. "x50": float(out["x50"]),
  1004. "Reference_x50": ref["x50"],
  1005. "Objective_match": bool(np.isclose(
  1006. out["objective"], ref["objective"], rtol=rtol, atol=atol
  1007. )),
  1008. "Parameters_match": bool(np.allclose(
  1009. theta, ref["theta"], rtol=rtol, atol=atol
  1010. )),
  1011. "x50_match": bool(np.isclose(
  1012. out["x50"], ref["x50"], rtol=rtol, atol=atol
  1013. )),
  1014. })
  1015. return pd.DataFrame(rows)
  1016. def print_ci_diagnostics(ci_res):
  1017. for key, name in (("orig", "FULL"), ("trim", "TRIM")):
  1018. out = ci_res[key]
  1019. print(f"\n{name}")
  1020. print("-" * len(name))
  1021. print(f"Conditional negative log-posterior: {out['objective']:.10g}")
  1022. hd = out["hessian_diagnostics"]
  1023. if hd:
  1024. print("Original Hessian positive definite:", hd["positive_definite"])
  1025. print("Hessian minimum eigenvalue:", hd["minimum_eigenvalue"])
  1026. print("Hessian condition number:", hd["condition_number"])
  1027. print(
  1028. "Original Hessian condition number:",
  1029. hd["original_condition_number"],
  1030. )
  1031. print("Hessian spectrally stabilized:", hd["stabilized"])
  1032. print("Wald/MCA covariance reliable:", hd["reliable"])
  1033. print("Eigenvalue floor:", hd["eigenvalue_floor"])
  1034. print(
  1035. "Maximum allowed stabilized condition:",
  1036. hd["maximum_allowed_condition"],
  1037. )
  1038. print(
  1039. "MAP parameters:",
  1040. dict(zip(PARAMETER_NAMES, out["theta_hat_vec"])),
  1041. )
  1042. print(f"x50={out['x50']:.8g}, s50={out['s50']:.8g}")
  1043. print("NPBS:", out["diagnostics_np"])
  1044. print("PBS:", out["diagnostics_pb"])
  1045. print("MCA:", out["diagnostics_mca"])
  1046. def _percentile_or_nan(values, q):
  1047. values = np.asarray(values, float)
  1048. return float(np.percentile(values, q)) if values.size else np.nan
  1049. def _ci_percentiles(out):
  1050. alpha = float(out.get("alpha_ci", 0.05))
  1051. return 100.0 * alpha / 2.0, 100.0 * (1.0 - alpha / 2.0)
  1052. def _column_or_empty(values, j):
  1053. values = np.asarray(values, float)
  1054. return values[:, j] if values.ndim == 2 and values.shape[1] > j else []
  1055. def make_parameter_ci_table(ci_res):
  1056. rows = []
  1057. for key, dataset in (("orig", "FULL"), ("trim", "TRIM")):
  1058. out = ci_res[key]
  1059. estimate = np.asarray(out["theta_hat_vec"], float)
  1060. qlo, qhi = _ci_percentiles(out)
  1061. for j, name in enumerate(PARAMETER_NAMES):
  1062. rows.append({
  1063. "Dataset": dataset,
  1064. "Parameter": name,
  1065. "Estimate": estimate[j],
  1066. "Wald_LL": out["wald_param_lo"][j],
  1067. "Wald_UL": out["wald_param_hi"][j],
  1068. "NPBS_LL": _percentile_or_nan(_column_or_empty(out["theta_np"], j), qlo),
  1069. "NPBS_UL": _percentile_or_nan(_column_or_empty(out["theta_np"], j), qhi),
  1070. "PBS_LL": _percentile_or_nan(_column_or_empty(out["theta_pb"], j), qlo),
  1071. "PBS_UL": _percentile_or_nan(_column_or_empty(out["theta_pb"], j), qhi),
  1072. "MCA_LL": _percentile_or_nan(
  1073. out["theta_mca"][:, j] if out["theta_mca"].ndim == 2
  1074. else [], qlo
  1075. ),
  1076. "MCA_UL": _percentile_or_nan(
  1077. out["theta_mca"][:, j] if out["theta_mca"].ndim == 2
  1078. else [], qhi
  1079. ),
  1080. })
  1081. return pd.DataFrame(rows)
  1082. def make_derived_ci_table(ci_res):
  1083. rows = []
  1084. for key, dataset in (("orig", "FULL"), ("trim", "TRIM")):
  1085. out = ci_res[key]
  1086. qlo, qhi = _ci_percentiles(out)
  1087. for char, est, wlo, whi, npv, pbv in [
  1088. (
  1089. "x50",
  1090. out["x50"],
  1091. out["wald_x50_lo"],
  1092. out["wald_x50_hi"],
  1093. out["x50_np"],
  1094. out["x50_pb"],
  1095. ),
  1096. (
  1097. "s50",
  1098. out["s50"],
  1099. out["wald_s50_lo"],
  1100. out["wald_s50_hi"],
  1101. out["s50_np"],
  1102. out["s50_pb"],
  1103. ),
  1104. ]:
  1105. rows.append({
  1106. "Dataset": dataset,
  1107. "Characteristic": char,
  1108. "Estimate": est,
  1109. "Wald_LL": wlo,
  1110. "Wald_UL": whi,
  1111. "NPBS_LL": _percentile_or_nan(npv, qlo),
  1112. "NPBS_UL": _percentile_or_nan(npv, qhi),
  1113. "PBS_LL": _percentile_or_nan(pbv, qlo),
  1114. "PBS_UL": _percentile_or_nan(pbv, qhi),
  1115. "MCA_LL": _percentile_or_nan(
  1116. out[f"{char}_mca"], qlo
  1117. ),
  1118. "MCA_UL": _percentile_or_nan(
  1119. out[f"{char}_mca"], qhi
  1120. ),
  1121. })
  1122. return pd.DataFrame(rows)
  1123. def smooth_ci_bounds(lower, upper, sigma=0.0):
  1124. """Optionally smooth CI boundaries for presentation only.
  1125. The empirical confidence limits stored in ``ci_res`` are not modified.
  1126. Numerical summaries and tables therefore continue to use the original
  1127. unsmoothed bootstrap distributions and confidence limits.
  1128. """
  1129. lower = np.asarray(lower, dtype=float)
  1130. upper = np.asarray(upper, dtype=float)
  1131. if lower.shape != upper.shape:
  1132. raise ValueError("Lower and upper CI boundaries must have equal shape.")
  1133. if lower.ndim != 1:
  1134. raise ValueError("CI boundaries must be one-dimensional arrays.")
  1135. if sigma <= 0:
  1136. return lower.copy(), upper.copy()
  1137. lower_smooth = gaussian_filter1d(lower, sigma=sigma, mode="nearest")
  1138. upper_smooth = gaussian_filter1d(upper, sigma=sigma, mode="nearest")
  1139. lower_smooth = np.clip(lower_smooth, 0.0, 1.0)
  1140. upper_smooth = np.clip(upper_smooth, 0.0, 1.0)
  1141. # Maintain a valid ordered confidence band after numerical smoothing.
  1142. lower_final = np.minimum(lower_smooth, upper_smooth)
  1143. upper_final = np.maximum(lower_smooth, upper_smooth)
  1144. return lower_final, upper_final
  1145. def plot_bayesian_ci(
  1146. ci_res,
  1147. xmax=4.5,
  1148. figsize=(10, 4),
  1149. dpi=140,
  1150. smooth_sigma=0.0,
  1151. jitter=0.018,
  1152. jitter_seed=123,
  1153. band_support="observed",
  1154. ):
  1155. """Plot Bayesian risk functions and confidence bands.
  1156. Empirical boundaries are shown without smoothing by default. They are
  1157. smoothed for visualization only when
  1158. ``smooth_sigma`` is positive. The original empirical boundaries in
  1159. ``ci_res`` remain unchanged and continue to support all calculations.
  1160. Set ``smooth_sigma=0`` to display the original unsmoothed boundaries.
  1161. With ``band_support='observed'`` (default), all bands are displayed only
  1162. between the smallest and largest observed biomarker values in each panel;
  1163. use ``band_support='grid'`` to display bands over the entire model grid.
  1164. """
  1165. fig, axes = plt.subplots(
  1166. 1,
  1167. 2,
  1168. figsize=figsize,
  1169. dpi=dpi,
  1170. sharey=True,
  1171. )
  1172. alpha_plot = float(ci_res["orig"].get("alpha_ci", 0.05))
  1173. ci_level = 100.0 * (1.0 - alpha_plot)
  1174. ci_text = f"{ci_level:g}%"
  1175. # Colours corresponding to the previous figure
  1176. colors = {
  1177. "wald": "#138A24", # green
  1178. "npbs": "#173BFF", # blue
  1179. "pbs": "#00CFE3", # cyan
  1180. "mca": "#B23AEE", # purple
  1181. "nc": "#7479FF", # periwinkle
  1182. "ae": "#FFBE63", # orange
  1183. }
  1184. rng = np.random.default_rng(jitter_seed)
  1185. for ax, key, x_key, y_key, panel_label in zip(
  1186. axes,
  1187. ("orig", "trim"),
  1188. ("X_orig", "X_trim"),
  1189. ("y_orig", "y_trim"),
  1190. ("A", "B"),
  1191. ):
  1192. out = ci_res[key]
  1193. xg = ci_res["x_grid"]
  1194. # Observed samples with small vertical jitter to prevent overlap.
  1195. x_obs = np.asarray(ci_res[x_key])
  1196. y_obs = np.asarray(ci_res[y_key])
  1197. if band_support == "observed":
  1198. band_mask = (xg >= float(x_obs.min())) & (xg <= float(x_obs.max()))
  1199. elif band_support == "grid":
  1200. band_mask = np.ones(xg.shape, dtype=bool)
  1201. else:
  1202. raise ValueError("band_support must be 'observed' or 'grid'.")
  1203. x_band = xg[band_mask]
  1204. y_jittered = y_obs + rng.uniform(-jitter, jitter, size=y_obs.size)
  1205. ax.scatter(
  1206. x_obs[y_obs == 0],
  1207. y_jittered[y_obs == 0],
  1208. s=22,
  1209. color=colors["nc"],
  1210. alpha=0.72,
  1211. edgecolors="none",
  1212. zorder=7,
  1213. )
  1214. ax.scatter(
  1215. x_obs[y_obs == 1],
  1216. y_jittered[y_obs == 1],
  1217. s=25,
  1218. color=colors["ae"],
  1219. alpha=0.90,
  1220. edgecolors="none",
  1221. zorder=8,
  1222. )
  1223. # Fitted risk function
  1224. ax.plot(
  1225. xg,
  1226. out["pmap"],
  1227. color="black",
  1228. lw=2.2,
  1229. label="Fitted risk function",
  1230. zorder=5,
  1231. )
  1232. # Wald confidence band
  1233. if out["wald_available"]:
  1234. ax.fill_between(
  1235. x_band,
  1236. out["wald_lo"][band_mask],
  1237. out["wald_hi"][band_mask],
  1238. color=colors["wald"],
  1239. alpha=0.16,
  1240. label=f"Wald {ci_text} CI",
  1241. zorder=1,
  1242. )
  1243. ax.plot(
  1244. x_band,
  1245. out["wald_lo"][band_mask],
  1246. color=colors["wald"],
  1247. ls="-.",
  1248. lw=1.4,
  1249. zorder=3,
  1250. )
  1251. ax.plot(
  1252. x_band,
  1253. out["wald_hi"][band_mask],
  1254. color=colors["wald"],
  1255. ls="-.",
  1256. lw=1.4,
  1257. zorder=3,
  1258. )
  1259. # Nonparametric pairs bootstrap band
  1260. if out["np_lo"] is not None:
  1261. np_lo_plot, np_hi_plot = smooth_ci_bounds(
  1262. out["np_lo"],
  1263. out["np_hi"],
  1264. sigma=smooth_sigma,
  1265. )
  1266. ax.fill_between(
  1267. x_band,
  1268. np_lo_plot[band_mask],
  1269. np_hi_plot[band_mask],
  1270. color=colors["npbs"],
  1271. alpha=0.14,
  1272. label=f"NPBS {ci_text} CI",
  1273. zorder=1,
  1274. )
  1275. ax.plot(
  1276. x_band,
  1277. np_lo_plot[band_mask],
  1278. color=colors["npbs"],
  1279. ls=":",
  1280. lw=1.4,
  1281. zorder=3,
  1282. )
  1283. ax.plot(
  1284. x_band,
  1285. np_hi_plot[band_mask],
  1286. color=colors["npbs"],
  1287. ls=":",
  1288. lw=1.4,
  1289. zorder=3,
  1290. )
  1291. # Parametric bootstrap band
  1292. if out["pb_lo"] is not None:
  1293. pb_lo_plot, pb_hi_plot = smooth_ci_bounds(
  1294. out["pb_lo"],
  1295. out["pb_hi"],
  1296. sigma=smooth_sigma,
  1297. )
  1298. ax.fill_between(
  1299. x_band,
  1300. pb_lo_plot[band_mask],
  1301. pb_hi_plot[band_mask],
  1302. color=colors["pbs"],
  1303. alpha=0.14,
  1304. label=f"PBS {ci_text} CI",
  1305. zorder=1,
  1306. )
  1307. ax.plot(
  1308. x_band,
  1309. pb_lo_plot[band_mask],
  1310. color=colors["pbs"],
  1311. ls="--",
  1312. lw=1.6,
  1313. zorder=3,
  1314. )
  1315. ax.plot(
  1316. x_band,
  1317. pb_hi_plot[band_mask],
  1318. color=colors["pbs"],
  1319. ls="--",
  1320. lw=1.6,
  1321. zorder=3,
  1322. )
  1323. # Strict local-Gaussian Monte Carlo approximation
  1324. if out["mca_lo"] is not None:
  1325. mca_lo_plot, mca_hi_plot = smooth_ci_bounds(
  1326. out["mca_lo"], out["mca_hi"], sigma=smooth_sigma
  1327. )
  1328. ax.fill_between(
  1329. x_band, mca_lo_plot[band_mask], mca_hi_plot[band_mask],
  1330. color=colors["mca"], alpha=0.10, zorder=1,
  1331. )
  1332. ax.plot(
  1333. x_band, mca_lo_plot[band_mask], color=colors["mca"],
  1334. ls=(0, (5, 2, 1, 2)), lw=1.5, zorder=3,
  1335. )
  1336. ax.plot(
  1337. x_band, mca_hi_plot[band_mask], color=colors["mca"],
  1338. ls=(0, (5, 2, 1, 2)), lw=1.5, zorder=3,
  1339. )
  1340. ax.set_xlim(xg.min(), xmax)
  1341. ax.set_ylim(-0.05, 1.05)
  1342. ax.set_xlabel(r"$X$")
  1343. ax.grid(False)
  1344. ax.text(
  1345. 0.045,
  1346. 0.955,
  1347. panel_label,
  1348. transform=ax.transAxes,
  1349. ha="left",
  1350. va="top",
  1351. fontsize=14,
  1352. fontweight="normal",
  1353. zorder=10,
  1354. )
  1355. axes[0].set_ylabel(r"$P(\mathrm{AE}\mid X=x)$")
  1356. # Complete legend inside the lower-right corner of panel B.
  1357. legend_handles = [
  1358. Line2D(
  1359. [0], [0],
  1360. marker="o",
  1361. linestyle="none",
  1362. markerfacecolor=colors["nc"],
  1363. markeredgecolor="none",
  1364. markersize=7,
  1365. label="data: NC",
  1366. ),
  1367. Line2D(
  1368. [0], [0],
  1369. marker="o",
  1370. linestyle="none",
  1371. markerfacecolor=colors["ae"],
  1372. markeredgecolor="none",
  1373. markersize=7,
  1374. label="data: AE",
  1375. ),
  1376. Line2D(
  1377. [0], [0],
  1378. color="black",
  1379. ls="-",
  1380. lw=2.2,
  1381. label="fit",
  1382. ),
  1383. ]
  1384. if any(ci_res[key]["wald_available"] for key in ("orig", "trim")):
  1385. legend_handles.append(Line2D(
  1386. [0], [0], color=colors["wald"], ls="-.", lw=1.8,
  1387. label=f"CI: Wald {ci_text}",
  1388. ))
  1389. if any(ci_res[key]["np_lo"] is not None for key in ("orig", "trim")):
  1390. legend_handles.append(Line2D(
  1391. [0], [0], color=colors["npbs"], ls=":", lw=1.8,
  1392. label=f"CI: NPBS {ci_text}",
  1393. ))
  1394. if any(ci_res[key]["pb_lo"] is not None for key in ("orig", "trim")):
  1395. legend_handles.append(Line2D(
  1396. [0], [0], color=colors["pbs"], ls="--", lw=2.0,
  1397. label=f"CI: PBS {ci_text}",
  1398. ))
  1399. if any(ci_res[key]["mca_lo"] is not None for key in ("orig", "trim")):
  1400. legend_handles.append(Line2D(
  1401. [0], [0], color=colors["mca"],
  1402. ls=(0, (5, 2, 1, 2)), lw=1.8,
  1403. label=f"CI: MCA {ci_text}",
  1404. ))
  1405. axes[1].legend(
  1406. handles=legend_handles,
  1407. loc="lower right",
  1408. frameon=True,
  1409. framealpha=0.95,
  1410. fontsize=9,
  1411. )
  1412. plt.tight_layout()
  1413. return fig, axes
  1414. # ============================================================