| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245 |
- # bayesian.py
- # ============================================================
- # BAYESIAN FIT + CI + ELASTICITY
- # ============================================================
- import os
- import numpy as np
- import pandas as pd
- import matplotlib.pyplot as plt
- from scipy import optimize, stats
- from scipy.io import loadmat
- from scipy.optimize import brentq
- from scipy.special import betaln, gammaln
- from data_utils import get_data
- # ============================================================
- # 1) DATA LOADING
- # ============================================================
- def load_xy(
- perc=95,
- suv_path="suv_percentilesSLOthenUWM.mat",
- flags_path="flags_combined.mat",
- ):
- """
- Load feature x and binary label y.
- """
- here = os.path.dirname(os.path.abspath(__file__))
- suv_full = os.path.join(here, suv_path)
- flags_full = os.path.join(here, flags_path)
- print("Loading SUV from:", suv_full)
- print("Loading FLAGS from:", flags_full)
- suv_dict = loadmat(suv_full)
- flags_dict = loadmat(flags_full)
- x, y = get_data(perc, suv_dict, flags_dict)
- x = np.asarray(x, float).ravel()
- y = np.asarray(y, int).ravel()
- m = np.isfinite(x)
- x, y = x[m], y[m]
- x = np.clip(x, 1e-12, None)
- return x, y
- # ============================================================
- # 2) CORE MODEL FUNCTIONS
- # ============================================================
- def logistic(z):
- return 1.0 / (1.0 + np.exp(-np.clip(z, -60, 60)))
- def sigmoid(t):
- return 1.0 / (1.0 + np.exp(-np.clip(t, -60, 60)))
- def dE_full(x, a, b, s, k, th):
- """
- log f_BP(x|a,b,s) - log f_Gamma(x|k,th), including constants.
- """
- x = np.asarray(x, float)
- return (
- (a - k) * np.log(x)
- - (a + b) * np.log1p(x / s)
- + x / th
- - a * np.log(s)
- - betaln(a, b)
- + k * np.log(th)
- + gammaln(k)
- )
- def softplus(t):
- t = np.asarray(t, float)
- return np.log1p(np.exp(-np.abs(t))) + np.maximum(t, 0.0)
- def theta_max(a, b, k, s, eps=1e-12):
- """
- Monotonicity cap for theta.
- """
- A = a - k
- if A <= 0:
- return np.inf
- r = np.sqrt(a + b) - np.sqrt(max(A, eps))
- return np.inf if r <= 1e-12 else s / (r * r)
- def unpack(phi):
- """
- Reparameterisation:
- phi = [p_raw, b_raw, s_raw, k_raw, d_raw, u_raw]
- p in (0,1)
- b,s,k > 0
- a = k + delta with delta > 0
- theta = theta_cap * sigmoid(u_raw)
- """
- p_raw, b_raw, s_raw, k_raw, d_raw, u_raw = phi
- p = sigmoid(p_raw)
- b = softplus(b_raw) + 1e-6
- s = softplus(s_raw) + 1e-6
- k = softplus(k_raw) + 1e-6
- delta = softplus(d_raw) + 1e-6
- a = k + delta
- thcap = theta_max(a, b, k, s)
- th = thcap * sigmoid(u_raw)
- return p, a, b, s, k, th, thcap
- def make_priors(y, tau=25.0):
- """
- Beta(TAU*p_emp, TAU*(1-p_emp)) prior on prevalence p.
- """
- p_emp = float(np.mean(y))
- alpha = max(tau * p_emp, 1e-6)
- beta = max(tau * (1.0 - p_emp), 1e-6)
- return alpha, beta
- def neg_post(phi, X, y, alpha, beta, use_prior_p=True, prior_r=(1.05, 1.05)):
- """
- Negative log-posterior = NLL + optional priors.
- Priors used here:
- - Beta prior on prevalence p
- - Beta prior on r = theta/theta_cap
- No extra priors on a, b, s, k.
- """
- p, a, b, s, k, th, thcap = unpack(phi)
- eps = 1e-12
- logit_val = (np.log(p) - np.log(1.0 - p)) + dE_full(X, a, b, s, k, th)
- px = logistic(logit_val)
- nll = -np.sum(y * np.log(px + eps) + (1 - y) * np.log(1 - px + eps))
- if use_prior_p:
- nll += -((alpha - 1) * np.log(p + eps) + (beta - 1) * np.log(1 - p + eps))
- if prior_r is not None and np.isfinite(thcap) and thcap > 0:
- r = np.clip(th / thcap, 1e-9, 1 - 1e-9)
- nll += -((prior_r[0] - 1) * np.log(r) + (prior_r[1] - 1) * np.log(1 - r))
- return float(nll)
- def init_phi(X, y):
- """
- Stable initial values.
- """
- X = np.asarray(X, float)
- y = np.asarray(y, int)
- X0 = X[y == 0]
- m0 = X0.mean() if X0.size else X.mean()
- v0 = X0.var() if X0.size else X.var()
- k0 = 2.0 if v0 <= 0 else max((m0 * m0) / (v0 + 1e-9), 1.5)
- X1 = X[y == 1]
- m1 = np.median(X1) if X1.size else np.median(X)
- p0 = np.clip(float(np.mean(y)), 1e-3, 1 - 1e-3)
- b0 = 1.5
- s0 = max(m1, 0.5)
- return np.array(
- [
- np.log(p0 / (1 - p0)), # p_raw
- np.log(np.expm1(b0) + 1e-9), # b_raw
- np.log(np.expm1(s0) + 1e-9), # s_raw
- np.log(np.expm1(k0) + 1e-9), # k_raw
- np.log(np.expm1(1.0) + 1e-9), # d_raw
- -0.2, # u_raw
- ],
- dtype=float,
- )
- def fit_bayes(X, y, seed=0, use_prior_p=True, prior_r=(1.05, 1.05), tau=25.0):
- """
- MAP fit using L-BFGS-B.
- """
- X = np.asarray(X, float)
- y = np.asarray(y, int)
- alpha, beta = make_priors(y, tau=tau)
- obj = lambda w: neg_post(
- w,
- X,
- y,
- alpha,
- beta,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- )
- w0 = init_phi(X, y)
- res = optimize.minimize(
- obj,
- w0,
- method="L-BFGS-B",
- options={"maxiter": 6000, "ftol": 1e-9},
- )
- if not (res.success and np.isfinite(res.fun)):
- rng = np.random.default_rng(seed)
- w1 = w0 + rng.normal(0, 0.2, size=w0.shape)
- res = optimize.minimize(
- obj,
- w1,
- method="L-BFGS-B",
- options={"maxiter": 6000, "ftol": 1e-9},
- )
- theta_hat = unpack(res.x)
- return theta_hat, res
- def P_with(theta_hat, x):
- """
- Posterior risk curve P(AE|x) under fitted model.
- """
- p, a, b, s, k, th, _ = theta_hat
- x = np.asarray(x, float)
- logit_val = (np.log(p) - np.log(1 - p)) + dE_full(x, a, b, s, k, th)
- return logistic(logit_val)
- # ============================================================
- # 3) ORIGINAL / TRIM DATASETS
- # ============================================================
- def make_trimmed_dataset(X, y, value_to_drop=2.48122597, tol=1e-3):
- """
- Remove point(s) with x approximately equal to value_to_drop.
- """
- X = np.asarray(X, float)
- y = np.asarray(y, int)
- mask_keep = np.abs(X - value_to_drop) > tol
- removed_idx = np.where(~mask_keep)[0]
- return {
- "X_orig": X.copy(),
- "y_orig": y.copy(),
- "X_trim": X[mask_keep],
- "y_trim": y[mask_keep],
- "removed_idx": removed_idx,
- }
- def run_bayesian_group_fit(
- perc=95,
- suv_path="suv_percentilesSLOthenUWM.mat",
- flags_path="flags_combined.mat",
- value_to_drop=2.48122597,
- tol=1e-3,
- use_prior_p=True,
- prior_r=(1.05, 1.05),
- tau=25.0,
- ):
- """
- Load data, create ORIGINAL/TRIM datasets,
- and fit constrained Bayesian group model on both.
- """
- X_all, y_all = load_xy(perc=perc, suv_path=suv_path, flags_path=flags_path)
- ds = make_trimmed_dataset(X_all, y_all, value_to_drop=value_to_drop, tol=tol)
- theta_orig, res_orig = fit_bayes(
- ds["X_orig"],
- ds["y_orig"],
- seed=0,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- theta_trim, res_trim = fit_bayes(
- ds["X_trim"],
- ds["y_trim"],
- seed=1,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- return {
- **ds,
- "theta_orig": theta_orig,
- "theta_trim": theta_trim,
- "res_orig": res_orig,
- "res_trim": res_trim,
- }
- def summarize_theta(theta_hat):
- p, a, b, s, k, th, thcap = theta_hat
- return {
- "p": p,
- "a": a,
- "b": b,
- "s": s,
- "k": k,
- "theta": th,
- "theta_cap": thcap,
- "r": th / thcap if np.isfinite(thcap) and thcap > 0 else np.nan,
- }
- def plot_bayesian_orig_trim_raw(
- fit_results,
- xmax=6.0,
- suptitle="Conditional Probability of AE",
- figsize=(12, 7),
- dpi=140,
- ):
- """
- One raw-x plot:
- - ORIGINAL curve
- - TRIM curve
- - ORIGINAL data dots
- - highlight removed point(s)
- """
- X_orig = fit_results["X_orig"]
- y_orig = fit_results["y_orig"]
- removed_idx = fit_results["removed_idx"]
- theta_orig = fit_results["theta_orig"]
- theta_trim = fit_results["theta_trim"]
- fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
- fig.text(0.02, 0.5, suptitle, va="center", rotation="vertical", fontsize=14)
- x_min = max(float(np.min(X_orig)), 1e-8)
- x_max = float(xmax)
- ax.set_xlim(x_min, x_max)
- ax.set_xlabel("x")
- ax.set_ylabel("P(AE | x)")
- ax.set_ylim(-0.10, 1.10)
- ax.grid(alpha=0.35)
- x_grid = np.exp(np.linspace(np.log(x_min), np.log(x_max), 900))
- p_curve_orig = P_with(theta_orig, x_grid)
- p_curve_trim = P_with(theta_trim, x_grid)
- l1, = ax.plot(x_grid, p_curve_orig, lw=2.2, color="C0", label="ORIGINAL (Bayesian fit)")
- l2, = ax.plot(x_grid, p_curve_trim, lw=2.2, color="C1", label="TRIM (Bayesian fit)")
- rng = np.random.default_rng(999)
- jit = (rng.random(len(y_orig)) - 0.5) * 0.06
- d_nc = ax.scatter(
- X_orig[y_orig == 0],
- (y_orig + jit)[y_orig == 0],
- s=22,
- alpha=0.65,
- edgecolors="none",
- color="C0",
- label="NC samples (ORIGINAL)",
- )
- d_ae = ax.scatter(
- X_orig[y_orig == 1],
- (y_orig + jit)[y_orig == 1],
- s=26,
- alpha=0.85,
- edgecolors="none",
- color="C1",
- label="AE samples (ORIGINAL)",
- )
- dout = None
- if removed_idx.size > 0:
- for j, i in enumerate(removed_idx):
- jit_out = (rng.random() - 0.5) * 0.06
- label = "Removed point" if j == 0 else None
- dout = ax.scatter(
- [float(X_orig[i])],
- [float(y_orig[i] + jit_out)],
- marker="x",
- s=90,
- linewidths=2,
- color="k",
- label=label,
- )
- handles = [l1, l2, d_nc, d_ae]
- if dout is not None:
- handles.append(dout)
- labels = [h.get_label() for h in handles]
- ax.legend(handles, labels, frameon=False, ncol=2, loc="lower right")
- plt.tight_layout(rect=(0.06, 0.0, 1.0, 1.0))
- return fig, ax
- # ============================================================
- # 4) CI ESTIMATION
- # ============================================================
- def x_at_p(theta_hat, p_target=0.5, lo=1e-6, hi=10.0):
- """
- Solve P(AE|x) = p_target for x.
- """
- f = lambda x: P_with(theta_hat, x) - p_target
- try:
- if f(lo) * f(hi) > 0:
- return np.nan
- return float(brentq(f, lo, hi))
- except Exception:
- return np.nan
- def slope_at_x(theta_hat, x0):
- """
- Numerical derivative of P(AE|x) at x0.
- """
- if not np.isfinite(x0):
- return np.nan
- h = 1e-3 * (1 + abs(x0))
- return float((P_with(theta_hat, x0 + h) - P_with(theta_hat, x0 - h)) / (2 * h))
- def hess_fd(F, x):
- """
- Finite-difference Hessian.
- """
- x = np.asarray(x, float)
- n = x.size
- H = np.zeros((n, n))
- h = 1e-4 * (1 + np.abs(x))
- def grad_fd(G, z):
- g = np.zeros_like(z)
- for j in range(n):
- ej = np.zeros_like(z)
- ej[j] = 1.0
- g[j] = (G(z + h[j] * ej) - G(z - h[j] * ej)) / (2 * h[j])
- return g
- for i in range(n):
- ei = np.zeros_like(x)
- ei[i] = 1.0
- g_plus = grad_fd(F, x + h[i] * ei)
- g_minus = grad_fd(F, x - h[i] * ei)
- H[:, i] = (g_plus - g_minus) / (2 * h[i])
- return 0.5 * (H + H.T)
- def jac_fd(Fvec, w):
- """
- Finite-difference Jacobian for vector-valued function.
- """
- f0 = Fvec(w)
- m = f0.size
- n = w.size
- J = np.zeros((m, n))
- h = 1e-4 * (1 + np.abs(w))
- for j in range(n):
- ej = np.zeros_like(w)
- ej[j] = 1.0
- J[:, j] = (Fvec(w + h[j] * ej) - Fvec(w - h[j] * ej)) / (2 * h[j])
- return J
- def estimate_ci_bundle(
- X,
- y,
- label,
- x_grid,
- B_nonpar=400,
- B_param=400,
- seed=123,
- use_prior_p=True,
- prior_r=(1.05, 1.05),
- tau=25.0,
- ):
- X = np.asarray(X, float)
- y = np.asarray(y, int)
- rng = np.random.default_rng(seed)
- n = len(X)
- theta_hat, res = fit_bayes(
- X,
- y,
- seed=seed,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- pmap = P_with(theta_hat, x_grid)
- x50 = x_at_p(theta_hat, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
- s50 = slope_at_x(theta_hat, x50)
- phi_hat = res.x
- alpha, beta = make_priors(y, tau=tau)
- H = hess_fd(
- lambda w: neg_post(
- w,
- X,
- y,
- alpha,
- beta,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- ),
- phi_hat,
- )
- Jp = jac_fd(lambda w: P_with(unpack(w), x_grid), phi_hat)
- try:
- Sigma_phi = np.linalg.inv(H)
- except np.linalg.LinAlgError:
- Sigma_phi = np.linalg.pinv(H)
- var_p = np.einsum("ij,jk,ik->i", Jp, Sigma_phi, Jp)
- se_p = np.sqrt(np.maximum(var_p, 0.0))
- wald_lo = np.clip(pmap - 1.96 * se_p, 0, 1)
- wald_hi = np.clip(pmap + 1.96 * se_p, 0, 1)
- def theta_vec_from_phi(w):
- p, a, b, s, k, th, thcap = unpack(w)
- r = th / thcap if np.isfinite(thcap) and thcap > 0 else np.nan
- return np.array([p, a, b, s, k, th, r], float)
- Jtheta = jac_fd(theta_vec_from_phi, phi_hat)
- Sigma_theta = Jtheta @ Sigma_phi @ Jtheta.T
- theta_hat_vec = theta_vec_from_phi(phi_hat)
- se_theta = np.sqrt(np.maximum(np.diag(Sigma_theta), 0.0))
- wald_param_lo = theta_hat_vec - 1.96 * se_theta
- wald_param_hi = theta_hat_vec + 1.96 * se_theta
- curves_np = []
- theta_np = []
- x50_np = []
- used_np = 0
- for _ in range(B_nonpar):
- idx = rng.integers(0, n, n)
- Xb, yb = X[idx], y[idx]
- if yb.sum() == 0 or yb.sum() == len(yb):
- continue
- try:
- thb, rb = fit_bayes(
- Xb,
- yb,
- seed=int(rng.integers(0, 10_000_000)),
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- if not rb.success or not np.isfinite(rb.fun):
- continue
- curves_np.append(P_with(thb, x_grid))
- p_b, a_b, b_b, s_b, k_b, th_b, thcap_b = thb
- r_b = th_b / thcap_b if np.isfinite(thcap_b) and thcap_b > 0 else np.nan
- theta_np.append([p_b, a_b, b_b, s_b, k_b, th_b, r_b])
- x50_b = x_at_p(thb, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
- x50_np.append(x50_b)
- used_np += 1
- except Exception:
- continue
- curves_np = np.asarray(curves_np)
- theta_np = np.asarray(theta_np, float) if len(theta_np) else np.empty((0, 7))
- x50_np = np.asarray(x50_np, float) if len(x50_np) else np.empty((0,))
- np_lo = np.percentile(curves_np, 2.5, axis=0) if used_np else None
- np_hi = np.percentile(curves_np, 97.5, axis=0) if used_np else None
- curves_pb = []
- theta_pb = []
- x50_pb = []
- used_pb = 0
- p_hat, a_hat, b_hat, s_hat, k_hat, th_hat, _ = theta_hat
- for _ in range(B_param):
- yb = rng.binomial(1, p_hat, size=n)
- if yb.sum() == 0 or yb.sum() == n:
- continue
- Xb = np.zeros(n, dtype=float)
- idx_nc = np.where(yb == 0)[0]
- idx_ae = np.where(yb == 1)[0]
- if len(idx_nc) > 0:
- Xb[idx_nc] = stats.gamma.rvs(
- k_hat,
- scale=th_hat,
- size=len(idx_nc),
- random_state=rng,
- )
- if len(idx_ae) > 0:
- Xb[idx_ae] = stats.betaprime.rvs(
- a_hat,
- b_hat,
- scale=s_hat,
- size=len(idx_ae),
- random_state=rng,
- )
- Xb = np.clip(Xb, 1e-12, None)
- try:
- thb, rb = fit_bayes(
- Xb,
- yb,
- seed=int(rng.integers(0, 10_000_000)),
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- if not rb.success or not np.isfinite(rb.fun):
- continue
- curves_pb.append(P_with(thb, x_grid))
- p_b, a_b, b_b, s_b, k_b, th_b, thcap_b = thb
- r_b = th_b / thcap_b if np.isfinite(thcap_b) and thcap_b > 0 else np.nan
- theta_pb.append([p_b, a_b, b_b, s_b, k_b, th_b, r_b])
- x50_b = x_at_p(thb, 0.5, lo=max(1e-6, x_grid.min()), hi=x_grid.max())
- x50_pb.append(x50_b)
- used_pb += 1
- except Exception:
- continue
- curves_pb = np.asarray(curves_pb)
- theta_pb = np.asarray(theta_pb, float) if len(theta_pb) else np.empty((0, 7))
- x50_pb = np.asarray(x50_pb, float) if len(x50_pb) else np.empty((0,))
- pb_lo = np.percentile(curves_pb, 2.5, axis=0) if used_pb else None
- pb_hi = np.percentile(curves_pb, 97.5, axis=0) if used_pb else None
- return {
- "label": label,
- "theta_hat": theta_hat,
- "res": res,
- "x50": x50,
- "s50": s50,
- "pmap": pmap,
- "wald_lo": wald_lo,
- "wald_hi": wald_hi,
- "np_lo": np_lo,
- "np_hi": np_hi,
- "pb_lo": pb_lo,
- "pb_hi": pb_hi,
- "used_np": used_np,
- "used_pb": used_pb,
- "theta_hat_vec": theta_hat_vec,
- "wald_param_lo": wald_param_lo,
- "wald_param_hi": wald_param_hi,
- "theta_np": theta_np,
- "theta_pb": theta_pb,
- "x50_np": x50_np,
- "x50_pb": x50_pb,
- }
- def run_bayesian_ci(
- perc=95,
- suv_path="suv_percentilesSLOthenUWM.mat",
- flags_path="flags_combined.mat",
- value_to_drop=2.48122597,
- tol=1e-3,
- xmax=10.0,
- n_grid=600,
- B_nonpar=400,
- B_param=400,
- seed=123,
- use_prior_p=True,
- prior_r=(1.05, 1.05),
- tau=25.0,
- ):
- """
- Run CI estimation for both ORIGINAL and TRIM datasets.
- """
- X_all, y_all = load_xy(perc=perc, suv_path=suv_path, flags_path=flags_path)
- ds = make_trimmed_dataset(X_all, y_all, value_to_drop=value_to_drop, tol=tol)
- x_grid = np.linspace(0, xmax, n_grid)
- out_orig = estimate_ci_bundle(
- ds["X_orig"],
- ds["y_orig"],
- label="ORIGINAL",
- x_grid=x_grid,
- B_nonpar=B_nonpar,
- B_param=B_param,
- seed=seed,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- out_trim = estimate_ci_bundle(
- ds["X_trim"],
- ds["y_trim"],
- label="TRIM",
- x_grid=x_grid,
- B_nonpar=B_nonpar,
- B_param=B_param,
- seed=seed + 1,
- use_prior_p=use_prior_p,
- prior_r=prior_r,
- tau=tau,
- )
- return {
- **ds,
- "x_grid": x_grid,
- "orig": out_orig,
- "trim": out_trim,
- }
- def make_param_ci_table(ci_res):
- """
- Parameter CI table for FULL and TRIM, for Wald / Nonparam / Parametric.
- """
- rows = []
- names = ["p", "a", "b", "s", "k", "theta", "r"]
- for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
- out = ci_res[dataset_key]
- hat = out["theta_hat_vec"]
- for i, name in enumerate(names):
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Wald",
- "Parameter": name,
- "Estimate": hat[i],
- "LL": out["wald_param_lo"][i],
- "UL": out["wald_param_hi"][i],
- }
- )
- if out["theta_np"].shape[0] > 0:
- lo = np.nanpercentile(out["theta_np"], 2.5, axis=0)
- hi = np.nanpercentile(out["theta_np"], 97.5, axis=0)
- for i, name in enumerate(names):
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Nonparam",
- "Parameter": name,
- "Estimate": hat[i],
- "LL": lo[i],
- "UL": hi[i],
- }
- )
- if out["theta_pb"].shape[0] > 0:
- lo = np.nanpercentile(out["theta_pb"], 2.5, axis=0)
- hi = np.nanpercentile(out["theta_pb"], 97.5, axis=0)
- for i, name in enumerate(names):
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Parametric",
- "Parameter": name,
- "Estimate": hat[i],
- "LL": lo[i],
- "UL": hi[i],
- }
- )
- return pd.DataFrame(rows)
- def make_x50_ci_table(ci_res):
- """
- x50 CI table for FULL and TRIM, for Wald / Nonparam / Parametric.
- """
- rows = []
- for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
- out = ci_res[dataset_key]
- wald_x50_lo = np.nan
- wald_x50_hi = np.nan
- try:
- wald_x50_lo = np.interp(0.5, out["wald_lo"], ci_res["x_grid"])
- wald_x50_hi = np.interp(0.5, out["wald_hi"], ci_res["x_grid"])
- except Exception:
- pass
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Wald",
- "Estimate": out["x50"],
- "LL": wald_x50_lo,
- "UL": wald_x50_hi,
- }
- )
- if len(out["x50_np"]) > 0:
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Nonparam",
- "Estimate": out["x50"],
- "LL": np.nanpercentile(out["x50_np"], 2.5),
- "UL": np.nanpercentile(out["x50_np"], 97.5),
- }
- )
- if len(out["x50_pb"]) > 0:
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": "Parametric",
- "Estimate": out["x50"],
- "LL": np.nanpercentile(out["x50_pb"], 2.5),
- "UL": np.nanpercentile(out["x50_pb"], 97.5),
- }
- )
- return pd.DataFrame(rows)
- def make_curve_ci_table(ci_res, grid_every=25):
- """
- Long-format curve CI table.
- Contains LL/UL of P(AE|X) across x-grid for all methods.
- """
- rows = []
- x_grid = ci_res["x_grid"][::grid_every]
- for dataset_key, dataset_name in [("orig", "FULL"), ("trim", "TRIM")]:
- out = ci_res[dataset_key]
- for method, lo_key, hi_key in [
- ("Wald", "wald_lo", "wald_hi"),
- ("Nonparam", "np_lo", "np_hi"),
- ("Parametric", "pb_lo", "pb_hi"),
- ]:
- lo = out.get(lo_key, None)
- hi = out.get(hi_key, None)
- if lo is None or hi is None:
- continue
- lo = lo[::grid_every]
- hi = hi[::grid_every]
- est = out["pmap"][::grid_every]
- for x, e, l, u in zip(x_grid, est, lo, hi):
- rows.append(
- {
- "Dataset": dataset_name,
- "Method": method,
- "X": x,
- "Estimate": e,
- "LL": l,
- "UL": u,
- }
- )
- return pd.DataFrame(rows)
- def plot_ci_original_trim(ci_res, xmax=6.0):
- """
- 2-panel figure:
- left = FULL
- right = TRIM
- """
- x_grid = ci_res["x_grid"]
- rng = np.random.default_rng(999)
- fig, axes = plt.subplots(1, 2, figsize=(16, 7.0), dpi=150, sharey=True)
- for ax, X, y, out, title, panel in [
- (axes[0], ci_res["X_orig"], ci_res["y_orig"], ci_res["orig"], "FULL", "A"),
- (axes[1], ci_res["X_trim"], ci_res["y_trim"], ci_res["trim"], "TRIM", "B"),
- ]:
- ax.fill_between(x_grid, out["wald_lo"], out["wald_hi"], color="#2ca02c", alpha=0.10)
- if out["used_np"]:
- ax.fill_between(x_grid, out["np_lo"], out["np_hi"], color="#17becf", alpha=0.10)
- if out["used_pb"]:
- ax.fill_between(x_grid, out["pb_lo"], out["pb_hi"], color="#e91e63", alpha=0.10)
- ax.plot(x_grid, out["wald_lo"], color="#2ca02c", lw=1.6, ls="--")
- h_wald, = ax.plot(
- x_grid,
- out["wald_hi"],
- color="#2ca02c",
- lw=1.6,
- ls="--",
- label="CI: Wald (delta) 95%",
- )
- h_np = None
- if out["used_np"]:
- ax.plot(x_grid, out["np_lo"], color="#17becf", lw=1.6, ls=(0, (1, 2)))
- h_np, = ax.plot(
- x_grid,
- out["np_hi"],
- color="#17becf",
- lw=1.6,
- ls=(0, (1, 2)),
- label="CI: Nonparam bootstrap 95%",
- )
- h_pb = None
- if out["used_pb"]:
- ax.plot(x_grid, out["pb_lo"], color="#e91e63", lw=1.6, ls="-.")
- h_pb, = ax.plot(
- x_grid,
- out["pb_hi"],
- color="#e91e63",
- lw=1.6,
- ls="-.",
- label="CI: Parametric bootstrap 95%",
- )
- h_fit, = ax.plot(x_grid, out["pmap"], color="k", lw=2.4, label="Bayesian fit")
- jit = (rng.random(len(y)) - 0.5) * 0.035
- h_nc = ax.scatter(
- X[y == 0],
- (y + jit)[y == 0],
- s=18,
- alpha=0.55,
- color="#5dade2",
- edgecolors="none",
- label="NC",
- )
- h_ae = ax.scatter(
- X[y == 1],
- (y + jit)[y == 1],
- s=24,
- alpha=0.80,
- color="#f39c3d",
- edgecolors="none",
- label="AE",
- )
- ax.text(
- 0.02,
- 0.98,
- panel,
- transform=ax.transAxes,
- ha="left",
- va="top",
- fontsize=16,
- fontweight="bold",
- )
- ax.set_xlim(0, xmax)
- ax.set_ylim(-0.05, 1.05)
- ax.set_xlabel("X", fontsize=13, fontweight="bold")
- ax.set_title(title, fontsize=13, fontweight="bold")
- ax.grid(alpha=0.25)
- handles = [h_nc, h_ae, h_fit, h_wald]
- if h_np is not None:
- handles.append(h_np)
- if h_pb is not None:
- handles.append(h_pb)
- labels = [h.get_label() for h in handles]
- ax.legend(
- handles,
- labels,
- loc="upper center",
- bbox_to_anchor=(0.5, -0.20),
- ncol=2,
- frameon=False,
- fontsize=10,
- )
- axes[0].set_ylabel("P(AE | X)", fontsize=13, fontweight="bold")
- plt.tight_layout(rect=(0, 0.08, 1, 1))
- return fig, axes
- # ============================================================
- # 5) X50 ELASTICITY ANALYSIS
- # ============================================================
- PARAM_NAMES_X50_ELAS = [r"$\pi$", r"$a$", r"$b$", r"$s$", r"$k$", r"$\vartheta$"]
- def theta6_from_hat(theta_hat):
- """
- Extract the first 6 raw model parameters from theta_hat:
- (p, a, b, s, k, th)
- """
- th = np.asarray(theta_hat, float).ravel()
- if th.size < 6:
- raise ValueError(f"Expected at least 6 parameters, got {th.size}")
- return th[:6].copy()
- def step_vec_theta(theta, rel_step=1e-6, abs_min=1e-10):
- """
- Relative finite-difference step on raw theta scale.
- """
- theta = np.asarray(theta, float).ravel()
- return np.maximum(abs_min, rel_step * np.maximum(1.0, np.abs(theta)))
- def grad_central_theta(F_theta, theta0, rel_step=1e-6, abs_min=1e-10, pi_eps=1e-12):
- """
- Central differences in RAW theta.
- Keeps:
- - p in (pi_eps, 1-pi_eps)
- - positive parameters > 0
- """
- theta0 = np.asarray(theta0, float).ravel()
- h = step_vec_theta(theta0, rel_step=rel_step, abs_min=abs_min)
- g = np.zeros_like(theta0)
- for j in range(theta0.size):
- th_plus = theta0.copy()
- th_minus = theta0.copy()
- hj = h[j]
- if j == 0:
- p0 = float(np.clip(theta0[0], pi_eps, 1 - pi_eps))
- hj = min(hj, p0 - pi_eps, (1 - pi_eps) - p0)
- hj = max(hj, abs_min)
- th_plus[0] = np.clip(p0 + hj, pi_eps, 1 - pi_eps)
- th_minus[0] = np.clip(p0 - hj, pi_eps, 1 - pi_eps)
- else:
- q0 = float(max(theta0[j], 1e-15))
- hj = min(hj, 0.5 * q0)
- hj = max(hj, abs_min)
- th_plus[j] = q0 + hj
- th_minus[j] = max(q0 - hj, 1e-15)
- g[j] = (F_theta(th_plus) - F_theta(th_minus)) / (2.0 * hj)
- return g
- def P_with_theta6(theta6, x):
- """
- Same posterior risk curve as P_with(), but accepts only the 6 raw parameters:
- (p, a, b, s, k, th)
- """
- p, a, b, s, k, th = np.asarray(theta6, float).ravel()[:6]
- x = np.asarray(x, float)
- eps = 1e-12
- logit_val = (
- np.log(np.clip(p, eps, 1 - eps))
- - np.log(np.clip(1 - p, eps, 1.0))
- + dE_full(x, a, b, s, k, th)
- )
- return logistic(logit_val)
- def x50_theta6(theta6, lo=1e-6, hi=6.0, hi_max=100.0):
- """
- Solve P(AE|x) = 0.5 using theta6 = (p, a, b, s, k, th),
- with adaptive bracketing.
- """
- f = lambda x: P_with_theta6(theta6, x) - 0.5
- fa = f(lo)
- fb = f(hi)
- while np.isfinite(fa) and np.isfinite(fb) and fa * fb > 0 and hi < hi_max:
- hi *= 2.0
- fb = f(hi)
- if (not np.isfinite(fa)) or (not np.isfinite(fb)) or fa * fb > 0:
- return np.nan
- try:
- return float(brentq(f, lo, hi))
- except Exception:
- return np.nan
- def compute_x50_elasticity_rawtheta(
- theta0,
- x50_fun=x50_theta6,
- rel_step=1e-6,
- abs_min=1e-10,
- pi_eps=1e-12,
- ):
- """
- x50 elasticity on RAW theta scale:
- E_x50_j = | (theta_j / x50) * d x50 / d theta_j |
- where theta0 = [p, a, b, s, k, th]
- """
- theta0 = np.asarray(theta0, float).ravel()
- if theta0.size != 6:
- raise ValueError(f"Expected theta0 of length 6, got {theta0.size}")
- x50_0 = float(x50_fun(theta0))
- d_x_dth = grad_central_theta(
- x50_fun, theta0, rel_step=rel_step, abs_min=abs_min, pi_eps=pi_eps
- )
- eps = 1e-12
- x_safe = max(abs(x50_0), eps)
- th_safe = theta0.copy()
- th_safe[0] = float(np.clip(th_safe[0], pi_eps, 1 - pi_eps))
- th_safe[1:] = np.maximum(th_safe[1:], 1e-15)
- elas_x = np.abs(d_x_dth) * np.abs(th_safe) / x_safe
- return {
- "theta0": theta0,
- "x50": x50_0,
- "d_x_dtheta": d_x_dth,
- "elas_x50": elas_x,
- "param_names": PARAM_NAMES_X50_ELAS,
- }
- def run_x50_elasticity_from_fit_results(
- fit_results,
- rel_step=1e-6,
- abs_min=1e-10,
- pi_eps=1e-12,
- ):
- """
- Compute x50 elasticity for FULL (orig) and TRIM directly from fit_results.
- """
- elas_orig = compute_x50_elasticity_rawtheta(
- theta6_from_hat(fit_results["theta_orig"]),
- x50_fun=x50_theta6,
- rel_step=rel_step,
- abs_min=abs_min,
- pi_eps=pi_eps,
- )
- elas_trim = compute_x50_elasticity_rawtheta(
- theta6_from_hat(fit_results["theta_trim"]),
- x50_fun=x50_theta6,
- rel_step=rel_step,
- abs_min=abs_min,
- pi_eps=pi_eps,
- )
- return {
- "orig": elas_orig,
- "trim": elas_trim,
- }
- def make_x50_elasticity_table(elas_res, dataset_name="FULL"):
- """
- Tidy x50 elasticity table.
- """
- rows = []
- for name, ex in zip(
- elas_res["param_names"],
- elas_res["elas_x50"],
- ):
- rows.append(
- {
- "Dataset": dataset_name,
- "Parameter": name,
- "Elasticity_x50": float(ex),
- }
- )
- return pd.DataFrame(rows)
- def plot_x50_elasticity_bars(elas_full, elas_trim, figsize=(7, 5), dpi=150):
- """
- One-panel bar plot for x50 elasticity.
- """
- names = elas_full["param_names"]
- x = np.arange(len(names))
- width = 0.36
- fig, ax = plt.subplots(figsize=figsize, dpi=dpi)
- ax.bar(
- x - width / 2,
- elas_full["elas_x50"],
- width=width,
- label="FULL",
- alpha=0.85,
- )
- ax.bar(
- x + width / 2,
- elas_trim["elas_x50"],
- width=width,
- label="TRIM",
- alpha=0.85,
- )
- ax.set_xticks(x)
- ax.set_xticklabels(names)
- ax.set_ylabel("Elasticity", fontweight="bold")
- ax.set_title("Elasticity of x50", fontweight="bold")
- ax.grid(axis="y", alpha=0.25)
- ax.legend(frameon=False)
- plt.tight_layout()
- return fig, ax
|