bayesian.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245
  1. # bayesian.py
  2. # ============================================================
  3. # BAYESIAN FIT + CI + ELASTICITY
  4. # ============================================================
  5. import os
  6. import numpy as np
  7. import pandas as pd
  8. import matplotlib.pyplot as plt
  9. from scipy import optimize, stats
  10. from scipy.io import loadmat
  11. from scipy.optimize import brentq
  12. from scipy.special import betaln, gammaln
  13. from data_utils import get_data
  14. # ============================================================
  15. # 1) DATA LOADING
  16. # ============================================================
  17. def load_xy(
  18. perc=95,
  19. suv_path="suv_percentilesSLOthenUWM.mat",
  20. flags_path="flags_combined.mat",
  21. ):
  22. """
  23. Load feature x and binary label y.
  24. """
  25. here = os.path.dirname(os.path.abspath(__file__))
  26. suv_full = os.path.join(here, suv_path)
  27. flags_full = os.path.join(here, flags_path)
  28. print("Loading SUV from:", suv_full)
  29. print("Loading FLAGS from:", flags_full)
  30. suv_dict = loadmat(suv_full)
  31. flags_dict = loadmat(flags_full)
  32. x, y = get_data(perc, suv_dict, flags_dict)
  33. x = np.asarray(x, float).ravel()
  34. y = np.asarray(y, int).ravel()
  35. m = np.isfinite(x)
  36. x, y = x[m], y[m]
  37. x = np.clip(x, 1e-12, None)
  38. return x, y
  39. # ============================================================
  40. # 2) CORE MODEL FUNCTIONS
  41. # ============================================================
  42. def logistic(z):
  43. return 1.0 / (1.0 + np.exp(-np.clip(z, -60, 60)))
  44. def sigmoid(t):
  45. return 1.0 / (1.0 + np.exp(-np.clip(t, -60, 60)))
  46. def dE_full(x, a, b, s, k, th):
  47. """
  48. log f_BP(x|a,b,s) - log f_Gamma(x|k,th), including constants.
  49. """
  50. x = np.asarray(x, float)
  51. return (
  52. (a - k) * np.log(x)
  53. - (a + b) * np.log1p(x / s)
  54. + x / th
  55. - a * np.log(s)
  56. - betaln(a, b)
  57. + k * np.log(th)
  58. + gammaln(k)
  59. )
  60. def softplus(t):
  61. t = np.asarray(t, float)
  62. return np.log1p(np.exp(-np.abs(t))) + np.maximum(t, 0.0)
  63. def theta_max(a, b, k, s, eps=1e-12):
  64. """
  65. Monotonicity cap for theta.
  66. """
  67. A = a - k
  68. if A <= 0:
  69. return np.inf
  70. r = np.sqrt(a + b) - np.sqrt(max(A, eps))
  71. return np.inf if r <= 1e-12 else s / (r * r)
  72. def unpack(phi):
  73. """
  74. Reparameterisation:
  75. phi = [p_raw, b_raw, s_raw, k_raw, d_raw, u_raw]
  76. p in (0,1)
  77. b,s,k > 0
  78. a = k + delta with delta > 0
  79. theta = theta_cap * sigmoid(u_raw)
  80. """
  81. p_raw, b_raw, s_raw, k_raw, d_raw, u_raw = phi
  82. p = sigmoid(p_raw)
  83. b = softplus(b_raw) + 1e-6
  84. s = softplus(s_raw) + 1e-6
  85. k = softplus(k_raw) + 1e-6
  86. delta = softplus(d_raw) + 1e-6
  87. a = k + delta
  88. thcap = theta_max(a, b, k, s)
  89. th = thcap * sigmoid(u_raw)
  90. return p, a, b, s, k, th, thcap
  91. def make_priors(y, tau=25.0):
  92. """
  93. Beta(TAU*p_emp, TAU*(1-p_emp)) prior on prevalence p.
  94. """
  95. p_emp = float(np.mean(y))
  96. alpha = max(tau * p_emp, 1e-6)
  97. beta = max(tau * (1.0 - p_emp), 1e-6)
  98. return alpha, beta
  99. def neg_post(phi, X, y, alpha, beta, use_prior_p=True, prior_r=(1.05, 1.05)):
  100. """
  101. Negative log-posterior = NLL + optional priors.
  102. Priors used here:
  103. - Beta prior on prevalence p
  104. - Beta prior on r = theta/theta_cap
  105. No extra priors on a, b, s, k.
  106. """
  107. p, a, b, s, k, th, thcap = unpack(phi)
  108. eps = 1e-12
  109. logit_val = (np.log(p) - np.log(1.0 - p)) + dE_full(X, a, b, s, k, th)
  110. px = logistic(logit_val)
  111. nll = -np.sum(y * np.log(px + eps) + (1 - y) * np.log(1 - px + eps))
  112. if use_prior_p:
  113. nll += -((alpha - 1) * np.log(p + eps) + (beta - 1) * np.log(1 - p + eps))
  114. if prior_r is not None and np.isfinite(thcap) and thcap > 0:
  115. r = np.clip(th / thcap, 1e-9, 1 - 1e-9)
  116. nll += -((prior_r[0] - 1) * np.log(r) + (prior_r[1] - 1) * np.log(1 - r))
  117. return float(nll)
  118. def init_phi(X, y):
  119. """
  120. Stable initial values.
  121. """
  122. X = np.asarray(X, float)
  123. y = np.asarray(y, int)
  124. X0 = X[y == 0]
  125. m0 = X0.mean() if X0.size else X.mean()
  126. v0 = X0.var() if X0.size else X.var()
  127. k0 = 2.0 if v0 <= 0 else max((m0 * m0) / (v0 + 1e-9), 1.5)
  128. X1 = X[y == 1]
  129. m1 = np.median(X1) if X1.size else np.median(X)
  130. p0 = np.clip(float(np.mean(y)), 1e-3, 1 - 1e-3)
  131. b0 = 1.5
  132. s0 = max(m1, 0.5)
  133. return np.array(
  134. [
  135. np.log(p0 / (1 - p0)), # p_raw
  136. np.log(np.expm1(b0) + 1e-9), # b_raw
  137. np.log(np.expm1(s0) + 1e-9), # s_raw
  138. np.log(np.expm1(k0) + 1e-9), # k_raw
  139. np.log(np.expm1(1.0) + 1e-9), # d_raw
  140. -0.2, # u_raw
  141. ],
  142. dtype=float,
  143. )
  144. def fit_bayes(X, y, seed=0, use_prior_p=True, prior_r=(1.05, 1.05), tau=25.0):
  145. """
  146. MAP fit using L-BFGS-B.
  147. """
  148. X = np.asarray(X, float)
  149. y = np.asarray(y, int)
  150. alpha, beta = make_priors(y, tau=tau)
  151. obj = lambda w: neg_post(
  152. w,
  153. X,
  154. y,
  155. alpha,
  156. beta,
  157. use_prior_p=use_prior_p,
  158. prior_r=prior_r,
  159. )
  160. w0 = init_phi(X, y)
  161. res = optimize.minimize(
  162. obj,
  163. w0,
  164. method="L-BFGS-B",
  165. options={"maxiter": 6000, "ftol": 1e-9},
  166. )
  167. if not (res.success and np.isfinite(res.fun)):
  168. rng = np.random.default_rng(seed)
  169. w1 = w0 + rng.normal(0, 0.2, size=w0.shape)
  170. res = optimize.minimize(
  171. obj,
  172. w1,
  173. method="L-BFGS-B",
  174. options={"maxiter": 6000, "ftol": 1e-9},
  175. )
  176. theta_hat = unpack(res.x)
  177. return theta_hat, res
  178. def P_with(theta_hat, x):
  179. """
  180. Posterior risk curve P(AE|x) under fitted model.
  181. """
  182. p, a, b, s, k, th, _ = theta_hat
  183. x = np.asarray(x, float)
  184. logit_val = (np.log(p) - np.log(1 - p)) + dE_full(x, a, b, s, k, th)
  185. return logistic(logit_val)
  186. # ============================================================
  187. # 3) ORIGINAL / TRIM DATASETS
  188. # ============================================================
  189. def make_trimmed_dataset(X, y, value_to_drop=2.48122597, tol=1e-3):
  190. """
  191. Remove point(s) with x approximately equal to value_to_drop.
  192. """
  193. X = np.asarray(X, float)
  194. y = np.asarray(y, int)
  195. mask_keep = np.abs(X - value_to_drop) > tol
  196. removed_idx = np.where(~mask_keep)[0]
  197. return {
  198. "X_orig": X.copy(),
  199. "y_orig": y.copy(),
  200. "X_trim": X[mask_keep],
  201. "y_trim": y[mask_keep],
  202. "removed_idx": removed_idx,
  203. }
  204. def run_bayesian_group_fit(
  205. perc=95,
  206. suv_path="suv_percentilesSLOthenUWM.mat",
  207. flags_path="flags_combined.mat",
  208. value_to_drop=2.48122597,
  209. tol=1e-3,
  210. use_prior_p=True,
  211. prior_r=(1.05, 1.05),
  212. tau=25.0,
  213. ):
  214. """
  215. Load data, create ORIGINAL/TRIM datasets,
  216. and fit constrained Bayesian group model on both.
  217. """
  218. X_all, y_all = load_xy(perc=perc, suv_path=suv_path, flags_path=flags_path)
  219. ds = make_trimmed_dataset(X_all, y_all, value_to_drop=value_to_drop, tol=tol)
  220. theta_orig, res_orig = fit_bayes(
  221. ds["X_orig"],
  222. ds["y_orig"],
  223. seed=0,
  224. use_prior_p=use_prior_p,
  225. prior_r=prior_r,
  226. tau=tau,
  227. )
  228. theta_trim, res_trim = fit_bayes(
  229. ds["X_trim"],
  230. ds["y_trim"],
  231. seed=1,
  232. use_prior_p=use_prior_p,
  233. prior_r=prior_r,
  234. tau=tau,
  235. )
  236. return {
  237. **ds,
  238. "theta_orig": theta_orig,
  239. "theta_trim": theta_trim,
  240. "res_orig": res_orig,
  241. "res_trim": res_trim,
  242. }
  243. def summarize_theta(theta_hat):
  244. p, a, b, s, k, th, thcap = theta_hat
  245. return {
  246. "p": p,
  247. "a": a,
  248. "b": b,
  249. "s": s,
  250. "k": k,
  251. "theta": th,
  252. "theta_cap": thcap,
  253. "r": th / thcap if np.isfinite(thcap) and thcap > 0 else np.nan,
  254. }
  255. def plot_bayesian_orig_trim_raw(
  256. fit_results,
  257. xmax=6.0,
  258. suptitle="Conditional Probability of AE",
  259. figsize=(12, 7),
  260. dpi=140,
  261. ):
  262. """
  263. One raw-x plot:
  264. - ORIGINAL curve
  265. - TRIM curve
  266. - ORIGINAL data dots
  267. - highlight removed point(s)
  268. """
  269. X_orig = fit_results["X_orig"]
  270. y_orig = fit_results["y_orig"]
  271. removed_idx = fit_results["removed_idx"]
  272. theta_orig = fit_results["theta_orig"]
  273. theta_trim = fit_results["theta_trim"]
  274. fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
  275. fig.text(0.02, 0.5, suptitle, va="center", rotation="vertical", fontsize=14)
  276. x_min = max(float(np.min(X_orig)), 1e-8)
  277. x_max = float(xmax)
  278. ax.set_xlim(x_min, x_max)
  279. ax.set_xlabel("x")
  280. ax.set_ylabel("P(AE | x)")
  281. ax.set_ylim(-0.10, 1.10)
  282. ax.grid(alpha=0.35)
  283. x_grid = np.exp(np.linspace(np.log(x_min), np.log(x_max), 900))
  284. p_curve_orig = P_with(theta_orig, x_grid)
  285. p_curve_trim = P_with(theta_trim, x_grid)
  286. l1, = ax.plot(x_grid, p_curve_orig, lw=2.2, color="C0", label="ORIGINAL (Bayesian fit)")
  287. l2, = ax.plot(x_grid, p_curve_trim, lw=2.2, color="C1", label="TRIM (Bayesian fit)")
  288. rng = np.random.default_rng(999)
  289. jit = (rng.random(len(y_orig)) - 0.5) * 0.06
  290. d_nc = ax.scatter(
  291. X_orig[y_orig == 0],
  292. (y_orig + jit)[y_orig == 0],
  293. s=22,
  294. alpha=0.65,
  295. edgecolors="none",
  296. color="C0",
  297. label="NC samples (ORIGINAL)",
  298. )
  299. d_ae = ax.scatter(
  300. X_orig[y_orig == 1],
  301. (y_orig + jit)[y_orig == 1],
  302. s=26,
  303. alpha=0.85,
  304. edgecolors="none",
  305. color="C1",
  306. label="AE samples (ORIGINAL)",
  307. )
  308. dout = None
  309. if removed_idx.size > 0:
  310. for j, i in enumerate(removed_idx):
  311. jit_out = (rng.random() - 0.5) * 0.06
  312. label = "Removed point" if j == 0 else None
  313. dout = ax.scatter(
  314. [float(X_orig[i])],
  315. [float(y_orig[i] + jit_out)],
  316. marker="x",
  317. s=90,
  318. linewidths=2,
  319. color="k",
  320. label=label,
  321. )
  322. handles = [l1, l2, d_nc, d_ae]
  323. if dout is not None:
  324. handles.append(dout)
  325. labels = [h.get_label() for h in handles]
  326. ax.legend(handles, labels, frameon=False, ncol=2, loc="lower right")
  327. plt.tight_layout(rect=(0.06, 0.0, 1.0, 1.0))
  328. return fig, ax
  329. # ============================================================
  330. # 4) CI ESTIMATION
  331. # ============================================================
  332. def x_at_p(theta_hat, p_target=0.5, lo=1e-6, hi=10.0):
  333. """
  334. Solve P(AE|x) = p_target for x.
  335. """
  336. f = lambda x: P_with(theta_hat, x) - p_target
  337. try:
  338. if f(lo) * f(hi) > 0:
  339. return np.nan
  340. return float(brentq(f, lo, hi))
  341. except Exception:
  342. return np.nan
  343. def slope_at_x(theta_hat, x0):
  344. """
  345. Numerical derivative of P(AE|x) at x0.
  346. """
  347. if not np.isfinite(x0):
  348. return np.nan
  349. h = 1e-3 * (1 + abs(x0))
  350. return float((P_with(theta_hat, x0 + h) - P_with(theta_hat, x0 - h)) / (2 * h))
  351. def hess_fd(F, x):
  352. """
  353. Finite-difference Hessian.
  354. """
  355. x = np.asarray(x, float)
  356. n = x.size
  357. H = np.zeros((n, n))
  358. h = 1e-4 * (1 + np.abs(x))
  359. def grad_fd(G, z):
  360. g = np.zeros_like(z)
  361. for j in range(n):
  362. ej = np.zeros_like(z)
  363. ej[j] = 1.0
  364. g[j] = (G(z + h[j] * ej) - G(z - h[j] * ej)) / (2 * h[j])
  365. return g
  366. for i in range(n):
  367. ei = np.zeros_like(x)
  368. ei[i] = 1.0
  369. g_plus = grad_fd(F, x + h[i] * ei)
  370. g_minus = grad_fd(F, x - h[i] * ei)
  371. H[:, i] = (g_plus - g_minus) / (2 * h[i])
  372. return 0.5 * (H + H.T)
  373. def jac_fd(Fvec, w):
  374. """
  375. Finite-difference Jacobian for vector-valued function.
  376. """
  377. f0 = Fvec(w)
  378. m = f0.size
  379. n = w.size
  380. J = np.zeros((m, n))
  381. h = 1e-4 * (1 + np.abs(w))
  382. for j in range(n):
  383. ej = np.zeros_like(w)
  384. ej[j] = 1.0
  385. J[:, j] = (Fvec(w + h[j] * ej) - Fvec(w - h[j] * ej)) / (2 * h[j])
  386. return J
  387. def estimate_ci_bundle(
  388. X,
  389. y,
  390. label,
  391. x_grid,
  392. B_nonpar=400,
  393. B_param=400,
  394. seed=123,
  395. use_prior_p=True,
  396. prior_r=(1.05, 1.05),
  397. tau=25.0,
  398. ):
  399. X = np.asarray(X, float)
  400. y = np.asarray(y, int)
  401. rng = np.random.default_rng(seed)
  402. n = len(X)
  403. theta_hat, res = fit_bayes(
  404. X,
  405. y,
  406. seed=seed,
  407. use_prior_p=use_prior_p,
  408. prior_r=prior_r,
  409. tau=tau,
  410. )
  411. pmap = P_with(theta_hat, x_grid)
  412. x50 = x_at_p(theta_hat, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
  413. s50 = slope_at_x(theta_hat, x50)
  414. phi_hat = res.x
  415. alpha, beta = make_priors(y, tau=tau)
  416. H = hess_fd(
  417. lambda w: neg_post(
  418. w,
  419. X,
  420. y,
  421. alpha,
  422. beta,
  423. use_prior_p=use_prior_p,
  424. prior_r=prior_r,
  425. ),
  426. phi_hat,
  427. )
  428. Jp = jac_fd(lambda w: P_with(unpack(w), x_grid), phi_hat)
  429. try:
  430. Sigma_phi = np.linalg.inv(H)
  431. except np.linalg.LinAlgError:
  432. Sigma_phi = np.linalg.pinv(H)
  433. var_p = np.einsum("ij,jk,ik->i", Jp, Sigma_phi, Jp)
  434. se_p = np.sqrt(np.maximum(var_p, 0.0))
  435. wald_lo = np.clip(pmap - 1.96 * se_p, 0, 1)
  436. wald_hi = np.clip(pmap + 1.96 * se_p, 0, 1)
  437. def theta_vec_from_phi(w):
  438. p, a, b, s, k, th, thcap = unpack(w)
  439. r = th / thcap if np.isfinite(thcap) and thcap > 0 else np.nan
  440. return np.array([p, a, b, s, k, th, r], float)
  441. Jtheta = jac_fd(theta_vec_from_phi, phi_hat)
  442. Sigma_theta = Jtheta @ Sigma_phi @ Jtheta.T
  443. theta_hat_vec = theta_vec_from_phi(phi_hat)
  444. se_theta = np.sqrt(np.maximum(np.diag(Sigma_theta), 0.0))
  445. wald_param_lo = theta_hat_vec - 1.96 * se_theta
  446. wald_param_hi = theta_hat_vec + 1.96 * se_theta
  447. curves_np = []
  448. theta_np = []
  449. x50_np = []
  450. used_np = 0
  451. for _ in range(B_nonpar):
  452. idx = rng.integers(0, n, n)
  453. Xb, yb = X[idx], y[idx]
  454. if yb.sum() == 0 or yb.sum() == len(yb):
  455. continue
  456. try:
  457. thb, rb = fit_bayes(
  458. Xb,
  459. yb,
  460. seed=int(rng.integers(0, 10_000_000)),
  461. use_prior_p=use_prior_p,
  462. prior_r=prior_r,
  463. tau=tau,
  464. )
  465. if not rb.success or not np.isfinite(rb.fun):
  466. continue
  467. curves_np.append(P_with(thb, x_grid))
  468. p_b, a_b, b_b, s_b, k_b, th_b, thcap_b = thb
  469. r_b = th_b / thcap_b if np.isfinite(thcap_b) and thcap_b > 0 else np.nan
  470. theta_np.append([p_b, a_b, b_b, s_b, k_b, th_b, r_b])
  471. x50_b = x_at_p(thb, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
  472. x50_np.append(x50_b)
  473. used_np += 1
  474. except Exception:
  475. continue
  476. curves_np = np.asarray(curves_np)
  477. theta_np = np.asarray(theta_np, float) if len(theta_np) else np.empty((0, 7))
  478. x50_np = np.asarray(x50_np, float) if len(x50_np) else np.empty((0,))
  479. np_lo = np.percentile(curves_np, 2.5, axis=0) if used_np else None
  480. np_hi = np.percentile(curves_np, 97.5, axis=0) if used_np else None
  481. curves_pb = []
  482. theta_pb = []
  483. x50_pb = []
  484. used_pb = 0
  485. p_hat, a_hat, b_hat, s_hat, k_hat, th_hat, _ = theta_hat
  486. for _ in range(B_param):
  487. yb = rng.binomial(1, p_hat, size=n)
  488. if yb.sum() == 0 or yb.sum() == n:
  489. continue
  490. Xb = np.zeros(n, dtype=float)
  491. idx_nc = np.where(yb == 0)[0]
  492. idx_ae = np.where(yb == 1)[0]
  493. if len(idx_nc) > 0:
  494. Xb[idx_nc] = stats.gamma.rvs(
  495. k_hat,
  496. scale=th_hat,
  497. size=len(idx_nc),
  498. random_state=rng,
  499. )
  500. if len(idx_ae) > 0:
  501. Xb[idx_ae] = stats.betaprime.rvs(
  502. a_hat,
  503. b_hat,
  504. scale=s_hat,
  505. size=len(idx_ae),
  506. random_state=rng,
  507. )
  508. Xb = np.clip(Xb, 1e-12, None)
  509. try:
  510. thb, rb = fit_bayes(
  511. Xb,
  512. yb,
  513. seed=int(rng.integers(0, 10_000_000)),
  514. use_prior_p=use_prior_p,
  515. prior_r=prior_r,
  516. tau=tau,
  517. )
  518. if not rb.success or not np.isfinite(rb.fun):
  519. continue
  520. curves_pb.append(P_with(thb, x_grid))
  521. p_b, a_b, b_b, s_b, k_b, th_b, thcap_b = thb
  522. r_b = th_b / thcap_b if np.isfinite(thcap_b) and thcap_b > 0 else np.nan
  523. theta_pb.append([p_b, a_b, b_b, s_b, k_b, th_b, r_b])
  524. x50_b = x_at_p(thb, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
  525. x50_pb.append(x50_b)
  526. used_pb += 1
  527. except Exception:
  528. continue
  529. curves_pb = np.asarray(curves_pb)
  530. theta_pb = np.asarray(theta_pb, float) if len(theta_pb) else np.empty((0, 7))
  531. x50_pb = np.asarray(x50_pb, float) if len(x50_pb) else np.empty((0,))
  532. pb_lo = np.percentile(curves_pb, 2.5, axis=0) if used_pb else None
  533. pb_hi = np.percentile(curves_pb, 97.5, axis=0) if used_pb else None
  534. return {
  535. "label": label,
  536. "theta_hat": theta_hat,
  537. "res": res,
  538. "x50": x50,
  539. "s50": s50,
  540. "pmap": pmap,
  541. "wald_lo": wald_lo,
  542. "wald_hi": wald_hi,
  543. "np_lo": np_lo,
  544. "np_hi": np_hi,
  545. "pb_lo": pb_lo,
  546. "pb_hi": pb_hi,
  547. "used_np": used_np,
  548. "used_pb": used_pb,
  549. "theta_hat_vec": theta_hat_vec,
  550. "wald_param_lo": wald_param_lo,
  551. "wald_param_hi": wald_param_hi,
  552. "theta_np": theta_np,
  553. "theta_pb": theta_pb,
  554. "x50_np": x50_np,
  555. "x50_pb": x50_pb,
  556. }
  557. def run_bayesian_ci(
  558. perc=95,
  559. suv_path="suv_percentilesSLOthenUWM.mat",
  560. flags_path="flags_combined.mat",
  561. value_to_drop=2.48122597,
  562. tol=1e-3,
  563. xmax=10.0,
  564. n_grid=600,
  565. B_nonpar=400,
  566. B_param=400,
  567. seed=123,
  568. use_prior_p=True,
  569. prior_r=(1.05, 1.05),
  570. tau=25.0,
  571. ):
  572. """
  573. Run CI estimation for both ORIGINAL and TRIM datasets.
  574. """
  575. X_all, y_all = load_xy(perc=perc, suv_path=suv_path, flags_path=flags_path)
  576. ds = make_trimmed_dataset(X_all, y_all, value_to_drop=value_to_drop, tol=tol)
  577. x_grid = np.linspace(0, xmax, n_grid)
  578. out_orig = estimate_ci_bundle(
  579. ds["X_orig"],
  580. ds["y_orig"],
  581. label="ORIGINAL",
  582. x_grid=x_grid,
  583. B_nonpar=B_nonpar,
  584. B_param=B_param,
  585. seed=seed,
  586. use_prior_p=use_prior_p,
  587. prior_r=prior_r,
  588. tau=tau,
  589. )
  590. out_trim = estimate_ci_bundle(
  591. ds["X_trim"],
  592. ds["y_trim"],
  593. label="TRIM",
  594. x_grid=x_grid,
  595. B_nonpar=B_nonpar,
  596. B_param=B_param,
  597. seed=seed + 1,
  598. use_prior_p=use_prior_p,
  599. prior_r=prior_r,
  600. tau=tau,
  601. )
  602. return {
  603. **ds,
  604. "x_grid": x_grid,
  605. "orig": out_orig,
  606. "trim": out_trim,
  607. }
  608. def make_param_ci_table(ci_res):
  609. """
  610. Parameter CI table for FULL and TRIM, for Wald / Nonparam / Parametric.
  611. """
  612. rows = []
  613. names = ["p", "a", "b", "s", "k", "theta", "r"]
  614. for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
  615. out = ci_res[dataset_key]
  616. hat = out["theta_hat_vec"]
  617. for i, name in enumerate(names):
  618. rows.append(
  619. {
  620. "Dataset": dataset_name,
  621. "Method": "Wald",
  622. "Parameter": name,
  623. "Estimate": hat[i],
  624. "LL": out["wald_param_lo"][i],
  625. "UL": out["wald_param_hi"][i],
  626. }
  627. )
  628. if out["theta_np"].shape[0] > 0:
  629. lo = np.nanpercentile(out["theta_np"], 2.5, axis=0)
  630. hi = np.nanpercentile(out["theta_np"], 97.5, axis=0)
  631. for i, name in enumerate(names):
  632. rows.append(
  633. {
  634. "Dataset": dataset_name,
  635. "Method": "Nonparam",
  636. "Parameter": name,
  637. "Estimate": hat[i],
  638. "LL": lo[i],
  639. "UL": hi[i],
  640. }
  641. )
  642. if out["theta_pb"].shape[0] > 0:
  643. lo = np.nanpercentile(out["theta_pb"], 2.5, axis=0)
  644. hi = np.nanpercentile(out["theta_pb"], 97.5, axis=0)
  645. for i, name in enumerate(names):
  646. rows.append(
  647. {
  648. "Dataset": dataset_name,
  649. "Method": "Parametric",
  650. "Parameter": name,
  651. "Estimate": hat[i],
  652. "LL": lo[i],
  653. "UL": hi[i],
  654. }
  655. )
  656. return pd.DataFrame(rows)
  657. def make_x50_ci_table(ci_res):
  658. """
  659. x50 CI table for FULL and TRIM, for Wald / Nonparam / Parametric.
  660. """
  661. rows = []
  662. for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
  663. out = ci_res[dataset_key]
  664. wald_x50_lo = np.nan
  665. wald_x50_hi = np.nan
  666. try:
  667. wald_x50_lo = np.interp(0.5, out["wald_lo"], ci_res["x_grid"])
  668. wald_x50_hi = np.interp(0.5, out["wald_hi"], ci_res["x_grid"])
  669. except Exception:
  670. pass
  671. rows.append(
  672. {
  673. "Dataset": dataset_name,
  674. "Method": "Wald",
  675. "Estimate": out["x50"],
  676. "LL": wald_x50_lo,
  677. "UL": wald_x50_hi,
  678. }
  679. )
  680. if len(out["x50_np"]) > 0:
  681. rows.append(
  682. {
  683. "Dataset": dataset_name,
  684. "Method": "Nonparam",
  685. "Estimate": out["x50"],
  686. "LL": np.nanpercentile(out["x50_np"], 2.5),
  687. "UL": np.nanpercentile(out["x50_np"], 97.5),
  688. }
  689. )
  690. if len(out["x50_pb"]) > 0:
  691. rows.append(
  692. {
  693. "Dataset": dataset_name,
  694. "Method": "Parametric",
  695. "Estimate": out["x50"],
  696. "LL": np.nanpercentile(out["x50_pb"], 2.5),
  697. "UL": np.nanpercentile(out["x50_pb"], 97.5),
  698. }
  699. )
  700. return pd.DataFrame(rows)
  701. def make_curve_ci_table(ci_res, grid_every=25):
  702. """
  703. Long-format curve CI table.
  704. Contains LL/UL of P(AE|X) across x-grid for all methods.
  705. """
  706. rows = []
  707. x_grid = ci_res["x_grid"][::grid_every]
  708. for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
  709. out = ci_res[dataset_key]
  710. for method, lo_key, hi_key in [
  711. ("Wald", "wald_lo", "wald_hi"),
  712. ("Nonparam", "np_lo", "np_hi"),
  713. ("Parametric", "pb_lo", "pb_hi"),
  714. ]:
  715. lo = out.get(lo_key, None)
  716. hi = out.get(hi_key, None)
  717. if lo is None or hi is None:
  718. continue
  719. lo = lo[::grid_every]
  720. hi = hi[::grid_every]
  721. est = out["pmap"][::grid_every]
  722. for x, e, l, u in zip(x_grid, est, lo, hi):
  723. rows.append(
  724. {
  725. "Dataset": dataset_name,
  726. "Method": method,
  727. "X": x,
  728. "Estimate": e,
  729. "LL": l,
  730. "UL": u,
  731. }
  732. )
  733. return pd.DataFrame(rows)
  734. def plot_ci_original_trim(ci_res, xmax=6.0):
  735. """
  736. 2-panel figure:
  737. left = FULL
  738. right = TRIM
  739. """
  740. x_grid = ci_res["x_grid"]
  741. rng = np.random.default_rng(999)
  742. fig, axes = plt.subplots(1, 2, figsize=(16, 7.0), dpi=150, sharey=True)
  743. for ax, X, y, out, title, panel in [
  744. (axes[0], ci_res["X_orig"], ci_res["y_orig"], ci_res["orig"], "FULL", "A"),
  745. (axes[1], ci_res["X_trim"], ci_res["y_trim"], ci_res["trim"], "TRIM", "B"),
  746. ]:
  747. ax.fill_between(x_grid, out["wald_lo"], out["wald_hi"], color="#2ca02c", alpha=0.10)
  748. if out["used_np"]:
  749. ax.fill_between(x_grid, out["np_lo"], out["np_hi"], color="#17becf", alpha=0.10)
  750. if out["used_pb"]:
  751. ax.fill_between(x_grid, out["pb_lo"], out["pb_hi"], color="#e91e63", alpha=0.10)
  752. ax.plot(x_grid, out["wald_lo"], color="#2ca02c", lw=1.6, ls="--")
  753. h_wald, = ax.plot(
  754. x_grid,
  755. out["wald_hi"],
  756. color="#2ca02c",
  757. lw=1.6,
  758. ls="--",
  759. label="CI: Wald (delta) 95%",
  760. )
  761. h_np = None
  762. if out["used_np"]:
  763. ax.plot(x_grid, out["np_lo"], color="#17becf", lw=1.6, ls=(0, (1, 2)))
  764. h_np, = ax.plot(
  765. x_grid,
  766. out["np_hi"],
  767. color="#17becf",
  768. lw=1.6,
  769. ls=(0, (1, 2)),
  770. label="CI: Nonparam bootstrap 95%",
  771. )
  772. h_pb = None
  773. if out["used_pb"]:
  774. ax.plot(x_grid, out["pb_lo"], color="#e91e63", lw=1.6, ls="-.")
  775. h_pb, = ax.plot(
  776. x_grid,
  777. out["pb_hi"],
  778. color="#e91e63",
  779. lw=1.6,
  780. ls="-.",
  781. label="CI: Parametric bootstrap 95%",
  782. )
  783. h_fit, = ax.plot(x_grid, out["pmap"], color="k", lw=2.4, label="Bayesian fit")
  784. jit = (rng.random(len(y)) - 0.5) * 0.035
  785. h_nc = ax.scatter(
  786. X[y == 0],
  787. (y + jit)[y == 0],
  788. s=18,
  789. alpha=0.55,
  790. color="#5dade2",
  791. edgecolors="none",
  792. label="NC",
  793. )
  794. h_ae = ax.scatter(
  795. X[y == 1],
  796. (y + jit)[y == 1],
  797. s=24,
  798. alpha=0.80,
  799. color="#f39c3d",
  800. edgecolors="none",
  801. label="AE",
  802. )
  803. ax.text(
  804. 0.02,
  805. 0.98,
  806. panel,
  807. transform=ax.transAxes,
  808. ha="left",
  809. va="top",
  810. fontsize=16,
  811. fontweight="bold",
  812. )
  813. ax.set_xlim(0, xmax)
  814. ax.set_ylim(-0.05, 1.05)
  815. ax.set_xlabel("X", fontsize=13, fontweight="bold")
  816. ax.set_title(title, fontsize=13, fontweight="bold")
  817. ax.grid(alpha=0.25)
  818. handles = [h_nc, h_ae, h_fit, h_wald]
  819. if h_np is not None:
  820. handles.append(h_np)
  821. if h_pb is not None:
  822. handles.append(h_pb)
  823. labels = [h.get_label() for h in handles]
  824. ax.legend(
  825. handles,
  826. labels,
  827. loc="upper center",
  828. bbox_to_anchor=(0.5, -0.20),
  829. ncol=2,
  830. frameon=False,
  831. fontsize=10,
  832. )
  833. axes[0].set_ylabel("P(AE | X)", fontsize=13, fontweight="bold")
  834. plt.tight_layout(rect=(0, 0.08, 1, 1))
  835. return fig, axes
  836. # ============================================================
  837. # 5) X50 ELASTICITY ANALYSIS
  838. # ============================================================
  839. PARAM_NAMES_X50_ELAS = [r"$\pi$", r"$a$", r"$b$", r"$s$", r"$k$", r"$\vartheta$"]
  840. def theta6_from_hat(theta_hat):
  841. """
  842. Extract the first 6 raw model parameters from theta_hat:
  843. (p, a, b, s, k, th)
  844. """
  845. th = np.asarray(theta_hat, float).ravel()
  846. if th.size < 6:
  847. raise ValueError(f"Expected at least 6 parameters, got {th.size}")
  848. return th[:6].copy()
  849. def step_vec_theta(theta, rel_step=1e-6, abs_min=1e-10):
  850. """
  851. Relative finite-difference step on raw theta scale.
  852. """
  853. theta = np.asarray(theta, float).ravel()
  854. return np.maximum(abs_min, rel_step * np.maximum(1.0, np.abs(theta)))
  855. def grad_central_theta(F_theta, theta0, rel_step=1e-6, abs_min=1e-10, pi_eps=1e-12):
  856. """
  857. Central differences in RAW theta.
  858. Keeps:
  859. - p in (pi_eps, 1-pi_eps)
  860. - positive parameters > 0
  861. """
  862. theta0 = np.asarray(theta0, float).ravel()
  863. h = step_vec_theta(theta0, rel_step=rel_step, abs_min=abs_min)
  864. g = np.zeros_like(theta0)
  865. for j in range(theta0.size):
  866. th_plus = theta0.copy()
  867. th_minus = theta0.copy()
  868. hj = h[j]
  869. if j == 0:
  870. p0 = float(np.clip(theta0[0], pi_eps, 1 - pi_eps))
  871. hj = min(hj, p0 - pi_eps, (1 - pi_eps) - p0)
  872. hj = max(hj, abs_min)
  873. th_plus[0] = np.clip(p0 + hj, pi_eps, 1 - pi_eps)
  874. th_minus[0] = np.clip(p0 - hj, pi_eps, 1 - pi_eps)
  875. else:
  876. q0 = float(max(theta0[j], 1e-15))
  877. hj = min(hj, 0.5 * q0)
  878. hj = max(hj, abs_min)
  879. th_plus[j] = q0 + hj
  880. th_minus[j] = max(q0 - hj, 1e-15)
  881. g[j] = (F_theta(th_plus) - F_theta(th_minus)) / (2.0 * hj)
  882. return g
  883. def P_with_theta6(theta6, x):
  884. """
  885. Same posterior risk curve as P_with(), but accepts only the 6 raw parameters:
  886. (p, a, b, s, k, th)
  887. """
  888. p, a, b, s, k, th = np.asarray(theta6, float).ravel()[:6]
  889. x = np.asarray(x, float)
  890. eps = 1e-12
  891. logit_val = (
  892. np.log(np.clip(p, eps, 1 - eps))
  893. - np.log(np.clip(1 - p, eps, 1.0))
  894. + dE_full(x, a, b, s, k, th)
  895. )
  896. return logistic(logit_val)
  897. def x50_theta6(theta6, lo=1e-6, hi=6.0, hi_max=100.0):
  898. """
  899. Solve P(AE|x) = 0.5 using theta6 = (p, a, b, s, k, th),
  900. with adaptive bracketing.
  901. """
  902. f = lambda x: P_with_theta6(theta6, x) - 0.5
  903. fa = f(lo)
  904. fb = f(hi)
  905. while np.isfinite(fa) and np.isfinite(fb) and fa * fb > 0 and hi < hi_max:
  906. hi *= 2.0
  907. fb = f(hi)
  908. if (not np.isfinite(fa)) or (not np.isfinite(fb)) or fa * fb > 0:
  909. return np.nan
  910. try:
  911. return float(brentq(f, lo, hi))
  912. except Exception:
  913. return np.nan
  914. def compute_x50_elasticity_rawtheta(
  915. theta0,
  916. x50_fun=x50_theta6,
  917. rel_step=1e-6,
  918. abs_min=1e-10,
  919. pi_eps=1e-12,
  920. ):
  921. """
  922. x50 elasticity on RAW theta scale:
  923. E_x50_j = | (theta_j / x50) * d x50 / d theta_j |
  924. where theta0 = [p, a, b, s, k, th]
  925. """
  926. theta0 = np.asarray(theta0, float).ravel()
  927. if theta0.size != 6:
  928. raise ValueError(f"Expected theta0 of length 6, got {theta0.size}")
  929. x50_0 = float(x50_fun(theta0))
  930. d_x_dth = grad_central_theta(
  931. x50_fun, theta0, rel_step=rel_step, abs_min=abs_min, pi_eps=pi_eps
  932. )
  933. eps = 1e-12
  934. x_safe = max(abs(x50_0), eps)
  935. th_safe = theta0.copy()
  936. th_safe[0] = float(np.clip(th_safe[0], pi_eps, 1 - pi_eps))
  937. th_safe[1:] = np.maximum(th_safe[1:], 1e-15)
  938. elas_x = np.abs(d_x_dth) * np.abs(th_safe) / x_safe
  939. return {
  940. "theta0": theta0,
  941. "x50": x50_0,
  942. "d_x_dtheta": d_x_dth,
  943. "elas_x50": elas_x,
  944. "param_names": PARAM_NAMES_X50_ELAS,
  945. }
  946. def run_x50_elasticity_from_fit_results(
  947. fit_results,
  948. rel_step=1e-6,
  949. abs_min=1e-10,
  950. pi_eps=1e-12,
  951. ):
  952. """
  953. Compute x50 elasticity for FULL (orig) and TRIM directly from fit_results.
  954. """
  955. elas_orig = compute_x50_elasticity_rawtheta(
  956. theta6_from_hat(fit_results["theta_orig"]),
  957. x50_fun=x50_theta6,
  958. rel_step=rel_step,
  959. abs_min=abs_min,
  960. pi_eps=pi_eps,
  961. )
  962. elas_trim = compute_x50_elasticity_rawtheta(
  963. theta6_from_hat(fit_results["theta_trim"]),
  964. x50_fun=x50_theta6,
  965. rel_step=rel_step,
  966. abs_min=abs_min,
  967. pi_eps=pi_eps,
  968. )
  969. return {
  970. "orig": elas_orig,
  971. "trim": elas_trim,
  972. }
  973. def make_x50_elasticity_table(elas_res, dataset_name="FULL"):
  974. """
  975. Tidy x50 elasticity table.
  976. """
  977. rows = []
  978. for name, ex in zip(
  979. elas_res["param_names"],
  980. elas_res["elas_x50"],
  981. ):
  982. rows.append(
  983. {
  984. "Dataset": dataset_name,
  985. "Parameter": name,
  986. "Elasticity_x50": float(ex),
  987. }
  988. )
  989. return pd.DataFrame(rows)
  990. def plot_x50_elasticity_bars(elas_full, elas_trim, figsize=(7, 5), dpi=150):
  991. """
  992. One-panel bar plot for x50 elasticity.
  993. """
  994. names = elas_full["param_names"]
  995. x = np.arange(len(names))
  996. width = 0.36
  997. fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
  998. ax.bar(
  999. x - width / 2,
  1000. elas_full["elas_x50"],
  1001. width=width,
  1002. label="FULL",
  1003. alpha=0.85,
  1004. )
  1005. ax.bar(
  1006. x + width / 2,
  1007. elas_trim["elas_x50"],
  1008. width=width,
  1009. label="TRIM",
  1010. alpha=0.85,
  1011. )
  1012. ax.set_xticks(x)
  1013. ax.set_xticklabels(names)
  1014. ax.set_ylabel("Elasticity", fontweight="bold")
  1015. ax.set_title("Elasticity of x50", fontweight="bold")
  1016. ax.grid(axis="y", alpha=0.25)
  1017. ax.legend(frameon=False)
  1018. plt.tight_layout()
  1019. return fig, ax