logit.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116
  1. import numpy as np
  2. # ============================================================
  3. # Stable sigmoid
  4. # ============================================================
  5. def _sigmoid_stable(z):
  6. z = np.asarray(z, float)
  7. z = np.clip(z, -50.0, 50.0)
  8. return 1.0 / (1.0 + np.exp(-z))
  9. # ============================================================
  10. # 1) Model
  11. # ============================================================
  12. def model_p(x, b):
  13. """p(x|b) = sigmoid(b0 + b1*x)."""
  14. x = np.asarray(x, float).reshape(-1)
  15. b0, b1 = np.asarray(b, float).reshape(2)
  16. return _sigmoid_stable(b0 + b1 * x)
  17. def design_matrix(x):
  18. """Design matrix X = [1, x]."""
  19. x = np.asarray(x, float).reshape(-1)
  20. return np.column_stack([np.ones_like(x), x])
  21. # ============================================================
  22. # 2) Likelihood
  23. # ============================================================
  24. def nll(x, y, b, l2=0.0):
  25. """
  26. Penalized negative log-likelihood:
  27. NLL(b) = -sum[y log p + (1-y) log(1-p)] + 0.5*l2*||b||^2
  28. """
  29. x = np.asarray(x, float).reshape(-1)
  30. y = np.asarray(y, float).reshape(-1)
  31. b = np.asarray(b, float).reshape(2)
  32. p = model_p(x, b)
  33. eps = 1e-12
  34. p = np.clip(p, eps, 1 - eps)
  35. base = -np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))
  36. pen = 0.5 * l2 * float(np.dot(b, b))
  37. return base + pen
  38. def llf(x, y, b):
  39. """
  40. Ordinary (unpenalized) log-likelihood at fitted parameters.
  41. """
  42. x = np.asarray(x, float).reshape(-1)
  43. y = np.asarray(y, float).reshape(-1)
  44. b = np.asarray(b, float).reshape(2)
  45. p = model_p(x, b)
  46. eps = 1e-12
  47. p = np.clip(p, eps, 1 - eps)
  48. return float(np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)))
  49. # ============================================================
  50. # 3) Gradient / Hessian / Covariance
  51. # ============================================================
  52. def grad_nll(x, y, b, l2=0.0):
  53. """
  54. Gradient of penalized NLL:
  55. g(b) = X^T (p - y) + l2*b
  56. """
  57. X = design_matrix(x)
  58. y = np.asarray(y, float).reshape(-1)
  59. b = np.asarray(b, float).reshape(2)
  60. p = model_p(x, b)
  61. return X.T @ (p - y) + l2 * b
  62. def hess_nll(x, b, l2=0.0):
  63. """
  64. Hessian of penalized NLL:
  65. H(b) = X^T W X + l2*I
  66. W = diag(p*(1-p))
  67. """
  68. X = design_matrix(x)
  69. b = np.asarray(b, float).reshape(2)
  70. p = model_p(x, b)
  71. w = p * (1 - p)
  72. return X.T @ (w[:, None] * X) + l2 * np.eye(2)
  73. def covariance(x, b, l2=0.0):
  74. """
  75. Cov(b) ≈ H(b)^(-1). Robust to near-singular Hessians.
  76. """
  77. H = hess_nll(x, b, l2=l2)
  78. try:
  79. return np.linalg.inv(H)
  80. except np.linalg.LinAlgError:
  81. return np.linalg.pinv(H)
  82. def standard_errors(x, b, l2=0.0):
  83. """
  84. SE = sqrt(diag(Cov)).
  85. """
  86. C = covariance(x, b, l2=l2)
  87. return np.sqrt(np.maximum(np.diag(C), 0.0))
  88. # Compatibility alias
  89. def logit_poly_cov(x, b, l2=0.0):
  90. return covariance(x, b, l2=l2)
  91. # ============================================================
  92. # 4) Fit
  93. # ============================================================
  94. def fit_newton(x, y, b_start=None, max_iter=50, tol=1e-8, l2=0.0):
  95. """
  96. Newton updates for penalized NLL with backtracking line-search.
  97. Update:
  98. b_new = b - alpha * H^{-1} g
  99. alpha shrinks until NLL decreases.
  100. """
  101. x = np.asarray(x, float).reshape(-1)
  102. y = np.asarray(y, int).reshape(-1)
  103. if b_start is None:
  104. b = np.array([0.0, 0.0], float)
  105. else:
  106. b = np.asarray(b_start, float).reshape(2)
  107. f = nll(x, y, b, l2=l2)
  108. for _ in range(max_iter):
  109. g = grad_nll(x, y, b, l2=l2)
  110. H = hess_nll(x, b, l2=l2)
  111. try:
  112. step = np.linalg.solve(H, g)
  113. except np.linalg.LinAlgError:
  114. step = np.linalg.pinv(H) @ g
  115. alpha = 1.0
  116. while alpha > 1e-6:
  117. b_new = b - alpha * step
  118. f_new = nll(x, y, b_new, l2=l2)
  119. if np.isfinite(f_new) and f_new <= f:
  120. break
  121. alpha *= 0.5
  122. if alpha <= 1e-6:
  123. break
  124. if np.max(np.abs(b_new - b)) < tol:
  125. b = b_new
  126. break
  127. b, f = b_new, f_new
  128. return b
  129. # ============================================================
  130. # 5) Goodness of fit
  131. # ============================================================
  132. def goodness_of_fit(x, y, b, thresh=0.5, l2=0.0):
  133. """
  134. Returns:
  135. LLF, NLL, AIC, BIC, Accuracy, n, k
  136. Note:
  137. Fit may use l2 > 0, but GOF metrics below are computed from the
  138. ordinary (unpenalized) likelihood at the fitted parameters.
  139. """
  140. x = np.asarray(x, float).reshape(-1)
  141. y = np.asarray(y, int).reshape(-1)
  142. b = np.asarray(b, float).reshape(2)
  143. p = model_p(x, b)
  144. eps = 1e-12
  145. p = np.clip(p, eps, 1 - eps)
  146. LLF = np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))
  147. NLL = -LLF
  148. n = len(x)
  149. k = len(b)
  150. AIC = 2 * k - 2 * LLF
  151. BIC = k * np.log(n) - 2 * LLF
  152. yhat = (p >= thresh).astype(int)
  153. acc = np.mean(yhat == y)
  154. return {"LLF": LLF, "NLL": NLL, "AIC": AIC, "BIC": BIC, "A": acc, "n": n, "k": k}
  155. # ============================================================
  156. # 6) x50 / Wald helpers / compact fit
  157. # ============================================================
  158. def x50(b):
  159. """
  160. Model-scale midpoint:
  161. x50 = -b0 / b1
  162. For LOG panels, this is on the log(x) scale.
  163. Raw-scale SUV50 is exp(x50).
  164. """
  165. b0, b1 = np.asarray(b, float).reshape(2)
  166. return np.nan if np.abs(b1) < 1e-12 else (-b0 / b1)
  167. def x50_wald_ci(b, cov, z=1.959963984540054):
  168. """
  169. Wald CI for x50 = -b0/b1 via delta method.
  170. Returned on MODEL scale.
  171. """
  172. b = np.asarray(b, float).reshape(2)
  173. cov = np.asarray(cov, float).reshape(2, 2)
  174. b0, b1 = b
  175. if np.abs(b1) < 1e-12:
  176. return np.nan, np.nan
  177. xhat = -b0 / b1
  178. grad = np.array([-1.0 / b1, b0 / (b1 ** 2)], float)
  179. var = float(grad.T @ cov @ grad)
  180. se = np.sqrt(max(var, 0.0))
  181. return float(xhat - z * se), float(xhat + z * se)
  182. def wald_ci(b, cov, z=1.959963984540054):
  183. """
  184. Wald CI for parameters: b_i ± z*SE_i.
  185. """
  186. b = np.asarray(b, float).reshape(2)
  187. cov = np.asarray(cov, float).reshape(2, 2)
  188. se = np.sqrt(np.maximum(np.diag(cov), 0.0))
  189. return b - z * se, b + z * se
  190. def fit_pack(x, y, name="", thresh=0.5, l2=0.0):
  191. """
  192. Fit + covariance + GOF + parameter Wald CI.
  193. x should already be on the MODEL scale.
  194. """
  195. x = np.asarray(x, float).reshape(-1)
  196. y = np.asarray(y, int).reshape(-1)
  197. b = fit_newton(x, y, l2=l2)
  198. cov = covariance(x, b, l2=l2)
  199. gof = goodness_of_fit(x, y, b, thresh=thresh, l2=l2)
  200. lcl, ucl = wald_ci(b, cov)
  201. return {
  202. "name": name,
  203. "x": x,
  204. "y": y,
  205. "b": b,
  206. "cov": cov,
  207. "gof": gof,
  208. "LCL": lcl,
  209. "UCL": ucl,
  210. "l2": float(l2),
  211. }
  212. def trim_nc_by_value(x_raw, y, target=2.48, tol=0.05):
  213. """
  214. Remove ONE NC sample (y==0) with x_raw closest to target.
  215. """
  216. x_raw = np.asarray(x_raw, float).reshape(-1)
  217. y = np.asarray(y, int).reshape(-1)
  218. nc_idx = np.where(y == 0)[0]
  219. if len(nc_idx) == 0:
  220. raise ValueError("No NC samples found (y==0).")
  221. j = nc_idx[np.argmin(np.abs(x_raw[nc_idx] - target))]
  222. diff = float(np.abs(x_raw[j] - target))
  223. if diff > tol:
  224. print(f"[trim warning] closest NC to {target} is {x_raw[j]:.6f} (diff={diff:.6f}) > tol={tol}")
  225. mask = np.ones_like(y, dtype=bool)
  226. mask[j] = False
  227. print(f"[trim] removed index={j}, x_raw={x_raw[j]:.6f}, y={y[j]}")
  228. return x_raw[mask], y[mask]
  229. # ============================================================
  230. # 7) Analytic CI bands on a grid
  231. # ============================================================
  232. def eta_se_grid(x_grid, cov):
  233. """
  234. Standard error of eta(x) = b0 + b1*x on a grid.
  235. x_grid must be on the MODEL scale.
  236. """
  237. x_grid = np.asarray(x_grid, float).reshape(-1)
  238. cov = np.asarray(cov, float).reshape(2, 2)
  239. Xg = design_matrix(x_grid)
  240. var_eta = np.einsum("ij,jk,ik->i", Xg, cov, Xg)
  241. return np.sqrt(np.maximum(var_eta, 0.0))
  242. def ci_band_normal(x_grid, b, cov, z=1.959963984540054):
  243. """
  244. Normal-on-eta CI band:
  245. eta ± z*SE(eta), then transform with sigmoid.
  246. Returns: lo, mid, hi
  247. """
  248. x_grid = np.asarray(x_grid, float).reshape(-1)
  249. b = np.asarray(b, float).reshape(2)
  250. eta = b[0] + b[1] * x_grid
  251. se_eta = eta_se_grid(x_grid, cov)
  252. lo = _sigmoid_stable(eta - z * se_eta)
  253. md = _sigmoid_stable(eta)
  254. hi = _sigmoid_stable(eta + z * se_eta)
  255. return lo, md, hi
  256. def ci_band_delta(x_grid, b, cov, z=1.959963984540054):
  257. """
  258. Delta-method CI band on probability scale:
  259. p(x) ± z * SE_p(x)
  260. where
  261. SE_p = p(1-p) * SE_eta
  262. Returns: lo, mid, hi
  263. """
  264. x_grid = np.asarray(x_grid, float).reshape(-1)
  265. b = np.asarray(b, float).reshape(2)
  266. p = model_p(x_grid, b)
  267. se_eta = eta_se_grid(x_grid, cov)
  268. se_p = p * (1.0 - p) * se_eta
  269. lo = np.clip(p - z * se_p, 0.0, 1.0)
  270. md = p
  271. hi = np.clip(p + z * se_p, 0.0, 1.0)
  272. return lo, md, hi
  273. # ============================================================
  274. # 8) Bootstrap parameter generators
  275. # ============================================================
  276. def bootstrap_params_stratified(x, y, B=2000, seed=123, l2=0.0, b_start=None):
  277. """
  278. Stratified nonparametric bootstrap on MODEL-scale x.
  279. Preserves class counts exactly.
  280. Returns array of shape (n_ok, 2).
  281. """
  282. rng = np.random.default_rng(seed)
  283. x = np.asarray(x, float).reshape(-1)
  284. y = np.asarray(y, int).reshape(-1)
  285. x0 = x[y == 0]
  286. x1 = x[y == 1]
  287. n0 = len(x0)
  288. n1 = len(x1)
  289. if n0 == 0 or n1 == 0:
  290. return np.empty((0, 2), float)
  291. out = []
  292. for _ in range(B):
  293. xb0 = rng.choice(x0, size=n0, replace=True)
  294. xb1 = rng.choice(x1, size=n1, replace=True)
  295. xb = np.concatenate([xb0, xb1])
  296. yb = np.concatenate([np.zeros(n0, dtype=int), np.ones(n1, dtype=int)])
  297. try:
  298. bb = fit_newton(xb, yb, b_start=b_start, l2=l2)
  299. if np.all(np.isfinite(bb)):
  300. out.append(bb)
  301. except Exception:
  302. pass
  303. if len(out) == 0:
  304. return np.empty((0, 2), float)
  305. return np.asarray(out, float)
  306. def bootstrap_params_parametric(x, b, B=2000, seed=123, l2=0.0, min_ae=2):
  307. """
  308. Parametric bootstrap on MODEL-scale x.
  309. Simulates y* ~ Bernoulli(p_hat(x)).
  310. Keeps only samples with at least min_ae positives and at least one negative.
  311. Returns array of shape (n_ok, 2).
  312. """
  313. rng = np.random.default_rng(seed)
  314. x = np.asarray(x, float).reshape(-1)
  315. b = np.asarray(b, float).reshape(2)
  316. p = model_p(x, b)
  317. n = len(x)
  318. out = []
  319. tries = 0
  320. max_tries = max(10 * B, 1000)
  321. while len(out) < B and tries < max_tries:
  322. tries += 1
  323. yb = rng.binomial(1, p, size=n).astype(int)
  324. n1 = int(np.sum(yb))
  325. n0 = n - n1
  326. if n1 < min_ae or n0 < 1:
  327. continue
  328. try:
  329. bb = fit_newton(x, yb, b_start=b, l2=l2)
  330. if np.all(np.isfinite(bb)):
  331. out.append(bb)
  332. except Exception:
  333. pass
  334. if len(out) == 0:
  335. return np.empty((0, 2), float)
  336. return np.asarray(out, float)
  337. # ============================================================
  338. # 9) Convert bootstrap parameters to curve bands
  339. # ============================================================
  340. def bootstrap_band_from_params(x_grid, pars, alpha=0.05):
  341. """
  342. Build bootstrap CI band from bootstrap parameter draws.
  343. x_grid is on MODEL scale.
  344. Returns: lo, mid, hi
  345. """
  346. x_grid = np.asarray(x_grid, float).reshape(-1)
  347. pars = np.asarray(pars, float)
  348. if pars.ndim != 2 or pars.shape[0] == 0:
  349. nan = np.full_like(x_grid, np.nan, dtype=float)
  350. return nan, nan, nan
  351. curves = np.array([model_p(x_grid, bb) for bb in pars], float)
  352. q = np.quantile(curves, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0)
  353. return q[0], q[1], q[2]
  354. # ============================================================
  355. # 10) High-level wrapper for one panel
  356. # ============================================================
  357. def fit_ci_pack_rawgrid(
  358. x_raw,
  359. y,
  360. transform="raw",
  361. xmax_raw=None,
  362. grid_n=500,
  363. name="",
  364. l2=0.0,
  365. B=2000,
  366. seed=123,
  367. min_ae=2,
  368. z=1.959963984540054,
  369. ):
  370. """
  371. Fit one panel and return everything needed for plots/tables.
  372. """
  373. x_raw = np.asarray(x_raw, float).reshape(-1)
  374. y = np.asarray(y, int).reshape(-1)
  375. if transform not in ("raw", "log"):
  376. raise ValueError("transform must be 'raw' or 'log'")
  377. x_raw = np.clip(x_raw, 1e-12, None)
  378. x_model = x_raw if transform == "raw" else np.log(x_raw)
  379. b = fit_newton(x_model, y, l2=l2)
  380. cov = covariance(x_model, b, l2=l2)
  381. gof = goodness_of_fit(x_model, y, b, l2=l2)
  382. xmin_raw = float(np.min(x_raw))
  383. xmax0 = float(np.max(x_raw))
  384. xmax_use = xmax0 if xmax_raw is None else max(float(xmax_raw), xmax0)
  385. x_grid_raw = np.linspace(xmin_raw, xmax_use, int(grid_n))
  386. x_grid_model = x_grid_raw if transform == "raw" else np.log(x_grid_raw)
  387. lo_n, md_n, hi_n = ci_band_normal(x_grid_model, b, cov, z=z)
  388. lo_d, md_d, hi_d = ci_band_delta(x_grid_model, b, cov, z=z)
  389. pars_np = bootstrap_params_stratified(
  390. x_model, y, B=B, seed=seed + 1, l2=l2, b_start=b
  391. )
  392. pars_pm = bootstrap_params_parametric(
  393. x_model, b, B=B, seed=seed + 2, l2=l2, min_ae=min_ae
  394. )
  395. lo_np, md_np, hi_np = bootstrap_band_from_params(x_grid_model, pars_np)
  396. lo_pm, md_pm, hi_pm = bootstrap_band_from_params(x_grid_model, pars_pm)
  397. return {
  398. "name": name,
  399. "transform": transform,
  400. "l2": float(l2),
  401. "x_raw": x_raw,
  402. "x_model": x_model,
  403. "y": y,
  404. "x_grid_raw": x_grid_raw,
  405. "x_grid_model": x_grid_model,
  406. "b": b,
  407. "cov": cov,
  408. "gof": gof,
  409. "bands": {
  410. "Normal": (lo_n, md_n, hi_n),
  411. "Delta": (lo_d, md_d, hi_d),
  412. "Nonparam": (lo_np, md_np, hi_np),
  413. "Parametric": (lo_pm, md_pm, hi_pm),
  414. },
  415. "pars_nonparam": pars_np,
  416. "pars_parametric": pars_pm,
  417. }
  418. # ============================================================
  419. # 11) Model-band table with LL / UL
  420. # ============================================================
  421. def model_ci_table_4methods(
  422. P,
  423. keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"),
  424. ):
  425. """
  426. Long table of model CI bands on the grid.
  427. Includes:
  428. x_grid_model, x_grid_raw, fit, LL, UL
  429. """
  430. import pandas as pd
  431. rows = []
  432. for key in keys:
  433. pk = P[key]
  434. xg_raw = np.asarray(pk["x_grid_raw"], float)
  435. xg_mod = np.asarray(pk["x_grid_model"], float)
  436. trans = pk.get("transform", "")
  437. bands = pk["bands"]
  438. for method, (lo, md, hi) in bands.items():
  439. lo = np.asarray(lo, float)
  440. md = np.asarray(md, float)
  441. hi = np.asarray(hi, float)
  442. for i in range(len(xg_raw)):
  443. rows.append({
  444. "Panel": key,
  445. "Method": method,
  446. "transform": trans,
  447. "x_grid_raw": float(xg_raw[i]),
  448. "x_grid_model": float(xg_mod[i]),
  449. "fit": float(md[i]),
  450. "LL": float(lo[i]),
  451. "UL": float(hi[i]),
  452. })
  453. return pd.DataFrame(rows)
  454. # ============================================================
  455. # 12) Parameter/x50 CI summary table
  456. # ============================================================
  457. def param_ci_table_4methods(
  458. P,
  459. keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"),
  460. z=1.959963984540054,
  461. include_point_est=True,
  462. ):
  463. """
  464. Build tidy parameter/x50 CI table for:
  465. Normal, Delta, Nonparam, Parametric
  466. Notes
  467. -----
  468. - For parameters (b0, b1), Normal and Delta are the same analytic CI here.
  469. - x50 is on MODEL scale.
  470. - SUV50 is on RAW scale:
  471. raw panel -> same as x50
  472. log panel -> exp(x50)
  473. """
  474. import pandas as pd
  475. def _boot_ci_from_pars(pars, alpha=0.05):
  476. if pars is None or len(pars) == 0:
  477. nan2 = (np.nan, np.nan)
  478. return nan2, nan2, nan2, 0
  479. pars = np.asarray(pars, float)
  480. q = np.quantile(pars, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0)
  481. b0_ci = (float(q[0, 0]), float(q[2, 0]))
  482. b1_ci = (float(q[0, 1]), float(q[2, 1]))
  483. x50s = np.array([x50(bb) for bb in pars], float)
  484. x50s = x50s[np.isfinite(x50s)]
  485. if len(x50s) == 0:
  486. x50_ci = (np.nan, np.nan)
  487. else:
  488. xq = np.quantile(x50s, [alpha / 2, 1.0 - alpha / 2])
  489. x50_ci = (float(xq[0]), float(xq[1]))
  490. return b0_ci, b1_ci, x50_ci, int(len(pars))
  491. rows = []
  492. for key in keys:
  493. pk = P[key]
  494. b = np.asarray(pk["b"], float).reshape(2)
  495. cov = np.asarray(pk["cov"], float).reshape(2, 2)
  496. trans = pk.get("transform", "")
  497. l2 = float(pk.get("l2", 0.0))
  498. lcl, ucl = wald_ci(b, cov, z=z)
  499. x50_l, x50_u = x50_wald_ci(b, cov, z=z)
  500. x50_hat = x50(b)
  501. pars_np = pk.get("pars_nonparam", np.empty((0, 2)))
  502. pars_pm = pk.get("pars_parametric", np.empty((0, 2)))
  503. b0_np, b1_np, x50_np, n_np = _boot_ci_from_pars(pars_np)
  504. b0_pm, b1_pm, x50_pm, n_pm = _boot_ci_from_pars(pars_pm)
  505. def _to_suv50(x50_ci):
  506. lo, hi = x50_ci
  507. if trans == "log":
  508. return (float(np.exp(lo)), float(np.exp(hi)))
  509. return (float(lo), float(hi))
  510. suv50_w = _to_suv50((x50_l, x50_u))
  511. suv50_np = _to_suv50(x50_np)
  512. suv50_pm = _to_suv50(x50_pm)
  513. suv50_hat = float(np.exp(x50_hat)) if trans == "log" else float(x50_hat)
  514. def add_row(method, b0_ci, b1_ci, x50_ci, suv50_ci, B_used):
  515. row = {
  516. "Panel": key,
  517. "Method": method,
  518. "b0_LCL": float(b0_ci[0]),
  519. "b0_UCL": float(b0_ci[1]),
  520. "b1_LCL": float(b1_ci[0]),
  521. "b1_UCL": float(b1_ci[1]),
  522. "x50_LCL": float(x50_ci[0]),
  523. "x50_UCL": float(x50_ci[1]),
  524. "SUV50_LCL": float(suv50_ci[0]),
  525. "SUV50_UCL": float(suv50_ci[1]),
  526. "B_used": B_used,
  527. }
  528. if include_point_est:
  529. row.update({
  530. "b0_hat": float(b[0]),
  531. "b1_hat": float(b[1]),
  532. "x50_hat": float(x50_hat),
  533. "SUV50_hat": float(suv50_hat),
  534. "transform": trans,
  535. "l2": l2,
  536. })
  537. rows.append(row)
  538. add_row("Normal", (lcl[0], ucl[0]), (lcl[1], ucl[1]), (x50_l, x50_u), suv50_w, np.nan)
  539. add_row("Delta", (lcl[0], ucl[0]), (lcl[1], ucl[1]), (x50_l, x50_u), suv50_w, np.nan)
  540. add_row("Nonparam", b0_np, b1_np, x50_np, suv50_np, n_np)
  541. add_row("Parametric", b0_pm, b1_pm, x50_pm, suv50_pm, n_pm)
  542. return pd.DataFrame(rows)
  543. # ============================================================
  544. # 13) Elasticity analysis
  545. # ============================================================
  546. def elasticity_x50_slope(b, mode="raw"):
  547. """
  548. Elasticities for x50 and slope@50 with respect to b0 and b1.
  549. mode = 'raw' : eta = b0 + b1*x
  550. mode = 'log' : eta = b0 + b1*log(x)
  551. Returns dict with:
  552. x50, slope50,
  553. E_x50_b0, E_x50_b1,
  554. E_s50_b0, E_s50_b1
  555. """
  556. b0, b1 = map(float, np.asarray(b, float).reshape(2))
  557. out = {"b0": b0, "b1": b1, "mode": mode}
  558. if np.abs(b1) < 1e-12:
  559. out.update({
  560. "x50": np.nan,
  561. "slope50": np.nan,
  562. "E_x50_b0": np.nan,
  563. "E_x50_b1": np.nan,
  564. "E_s50_b0": np.nan,
  565. "E_s50_b1": np.nan,
  566. })
  567. return out
  568. if mode == "raw":
  569. # eta = b0 + b1*x
  570. x50 = -b0 / b1
  571. slope50 = 0.25 * b1
  572. # elasticities of x50
  573. if np.abs(x50) < 1e-12:
  574. E_x50_b0 = np.nan
  575. E_x50_b1 = np.nan
  576. else:
  577. E_x50_b0 = 1.0
  578. E_x50_b1 = -1.0
  579. # slope50 = 0.25*b1
  580. E_s50_b0 = 0.0
  581. E_s50_b1 = 1.0
  582. elif mode == "log":
  583. # eta = b0 + b1*log(x)
  584. x50 = float(np.exp(-b0 / b1))
  585. slope50 = 0.25 * b1 / x50
  586. # elasticities of x50
  587. E_x50_b0 = -b0 / b1
  588. E_x50_b1 = b0 / b1
  589. # slope elasticity
  590. # slope50 = 0.25 * b1 * exp(b0/b1)
  591. if np.abs(slope50) < 1e-12:
  592. E_s50_b0 = np.nan
  593. E_s50_b1 = np.nan
  594. else:
  595. E_s50_b0 = b0 / b1
  596. E_s50_b1 = 1.0 - (b0 / b1)
  597. else:
  598. raise ValueError("mode must be 'raw' or 'log'")
  599. out.update({
  600. "x50": x50,
  601. "slope50": slope50,
  602. "E_x50_b0": E_x50_b0,
  603. "E_x50_b1": E_x50_b1,
  604. "E_s50_b0": E_s50_b0,
  605. "E_s50_b1": E_s50_b1,
  606. })
  607. return out
  608. def elasticity_table_4panels(
  609. P,
  610. keys=("FULL-RAW", "FULL-LOG", "TRIM-RAW", "TRIM-LOG"),
  611. ):
  612. """
  613. Build a tidy elasticity table for the 4 fitted panels.
  614. Returns columns:
  615. Panel, transform, b0, b1, x50, slope50,
  616. E_x50_b0, E_x50_b1, E_s50_b0, E_s50_b1
  617. """
  618. import pandas as pd
  619. rows = []
  620. for key in keys:
  621. pk = P[key]
  622. b = np.asarray(pk["b"], float).reshape(2)
  623. transform = pk["transform"]
  624. res = elasticity_x50_slope(b, mode=transform)
  625. rows.append({
  626. "Panel": key,
  627. "transform": transform,
  628. "b0": res["b0"],
  629. "b1": res["b1"],
  630. "x50": res["x50"],
  631. "slope50": res["slope50"],
  632. "E_x50_b0": res["E_x50_b0"],
  633. "E_x50_b1": res["E_x50_b1"],
  634. "E_s50_b0": res["E_s50_b0"],
  635. "E_s50_b1": res["E_s50_b1"],
  636. })
  637. return pd.DataFrame(rows)
  638. # ============================================================
  639. # 14. NOISE ANALYSIS (logistic trained on log(X))
  640. # ============================================================
  641. from scipy.optimize import minimize
  642. import matplotlib.pyplot as plt
  643. from matplotlib.patches import Patch
  644. # ------------------------------------------------------------
  645. # logistic helpers
  646. # ------------------------------------------------------------
  647. def logistic_sigmoid(t):
  648. t = np.clip(t, -60, 60)
  649. return 1.0 / (1.0 + np.exp(-t))
  650. def fit_logistic_logx(x_raw, y):
  651. """
  652. Fit logistic model
  653. p(y=1|x) = sigmoid(b0 + b1 log(x))
  654. """
  655. x = np.clip(np.asarray(x_raw).ravel(), 1e-12, None)
  656. y = np.asarray(y).astype(float)
  657. X = np.column_stack([np.ones_like(x), np.log(x)])
  658. def nll(b):
  659. z = X @ b
  660. p = logistic_sigmoid(z)
  661. eps = 1e-12
  662. ll = np.sum(y*np.log(p+eps) + (1-y)*np.log(1-p+eps))
  663. return -ll
  664. res = minimize(nll, np.zeros(2), method="L-BFGS-B")
  665. if not res.success:
  666. raise RuntimeError("Logistic optimisation failed")
  667. return res.x
  668. def predict_curve_logx(b, x_grid):
  669. x = np.clip(np.asarray(x_grid), 1e-12, None)
  670. return logistic_sigmoid(b[0] + b[1]*np.log(x))
  671. def x50_from_b(b):
  672. """
  673. p(x)=0.5 -> b0 + b1 log(x50)=0
  674. """
  675. b0, b1 = b
  676. return float(np.exp(-b0/b1))
  677. # ------------------------------------------------------------
  678. # noise models
  679. # ------------------------------------------------------------
  680. def add_noise_mult(x, sigma, rng):
  681. return np.clip(x*np.exp(rng.normal(0, sigma, size=x.shape)),1e-12,None)
  682. def add_noise_add(x, sigma, rng):
  683. return np.clip(x+rng.normal(0, sigma, size=x.shape),1e-12,None)
  684. # ------------------------------------------------------------
  685. # band quantiles
  686. # ------------------------------------------------------------
  687. def band_quantiles(curves):
  688. C=np.vstack(curves)
  689. return np.quantile(C,[0.025,0.5,0.975],axis=0)
  690. # ------------------------------------------------------------
  691. # build noise bands
  692. # ------------------------------------------------------------
  693. def noise_logistic_bands(
  694. x_raw,
  695. y,
  696. sigma_mult=0.129,
  697. sigma_add=0.144,
  698. x_max=5,
  699. grid_n=1000,
  700. n_refit=200,
  701. n_tta=500,
  702. seed=1234
  703. ):
  704. rng=np.random.default_rng(seed)
  705. xc=np.linspace(0,x_max,grid_n)
  706. xc[0]=1e-12
  707. b_clean=fit_logistic_logx(x_raw,y)
  708. clean=predict_curve_logx(b_clean,xc)
  709. x50=x50_from_b(b_clean)
  710. # ---------- multiplicative ----------
  711. curves=[]
  712. for _ in range(n_refit):
  713. xn=add_noise_mult(x_raw,sigma_mult,rng)
  714. bn=fit_logistic_logx(xn,y)
  715. curves.append(predict_curve_logx(bn,xc))
  716. mult_refit=band_quantiles(curves)
  717. curves=[]
  718. for _ in range(n_tta):
  719. xn=add_noise_mult(xc,sigma_mult,rng)
  720. curves.append(predict_curve_logx(b_clean,xn))
  721. mult_tta=band_quantiles(curves)
  722. # ---------- additive ----------
  723. curves=[]
  724. for _ in range(n_refit):
  725. xn=add_noise_add(x_raw,sigma_add,rng)
  726. bn=fit_logistic_logx(xn,y)
  727. curves.append(predict_curve_logx(bn,xc))
  728. add_refit=band_quantiles(curves)
  729. curves=[]
  730. for _ in range(n_tta):
  731. xn=add_noise_add(xc,sigma_add,rng)
  732. curves.append(predict_curve_logx(b_clean,xn))
  733. add_tta=band_quantiles(curves)
  734. return dict(
  735. xc=xc,
  736. clean=clean,
  737. x50=x50,
  738. mult_refit=mult_refit,
  739. mult_tta=mult_tta,
  740. add_refit=add_refit,
  741. add_tta=add_tta
  742. )
  743. # ------------------------------------------------------------
  744. # plotting
  745. # ------------------------------------------------------------
  746. def plot_noise_panel(ax, pack, kind="mult", label="A", X=None, y=None):
  747. COL_MULT = "#1f78b4"
  748. COL_ADD = "#e66101"
  749. xc = pack["xc"]
  750. clean = pack["clean"]
  751. x50 = pack["x50"]
  752. if kind == "mult":
  753. refit = pack["mult_refit"]
  754. tta = pack["mult_tta"]
  755. color = COL_MULT
  756. else:
  757. refit = pack["add_refit"]
  758. tta = pack["add_tta"]
  759. color = COL_ADD
  760. lo_r, _, hi_r = refit
  761. lo_t, _, hi_t = tta
  762. # TTA band (lighter)
  763. ax.fill_between(xc, lo_t, hi_t, color=color, alpha=0.10, zorder=1)
  764. # refit band (stronger)
  765. ax.fill_between(xc, lo_r, hi_r, color=color, alpha=0.24, zorder=2)
  766. # optional thin outlines for readability
  767. ax.plot(xc, lo_r, color=color, lw=1.0, alpha=0.65, zorder=3)
  768. ax.plot(xc, hi_r, color=color, lw=1.0, alpha=0.65, zorder=3)
  769. # clean fit
  770. ax.plot(xc, clean, color="black", lw=2.5, zorder=5)
  771. # x50 reference line
  772. # x50 reference line
  773. ax.axvline(x50, color="#666666", ls="--", lw=1.4, alpha=0.9, zorder=4)
  774. # widths at x50
  775. lo_r_x = np.interp(x50, xc, lo_r)
  776. hi_r_x = np.interp(x50, xc, hi_r)
  777. lo_t_x = np.interp(x50, xc, lo_t)
  778. hi_t_x = np.interp(x50, xc, hi_t)
  779. # data points
  780. if X is not None and y is not None:
  781. X = np.asarray(X).ravel()
  782. y = np.asarray(y).astype(int)
  783. ax.scatter(
  784. X[y == 0], np.zeros(np.sum(y == 0)),
  785. color="#2b8cbe", s=28, alpha=0.75, zorder=7
  786. )
  787. ax.scatter(
  788. X[y == 1], np.ones(np.sum(y == 1)),
  789. color="#d7301f", s=28, alpha=0.75, zorder=7
  790. )
  791. # panel label
  792. ax.text(
  793. 0.50, 1.01, label,
  794. transform=ax.transAxes,
  795. ha="center", va="bottom",
  796. fontsize=18, fontweight="bold"
  797. )
  798. # only FULL/TRIM + x50
  799. variant_txt = "FULL (F)" if label in ["A", "B"] else "TRIM (T)"
  800. ax.text(0.02, 0.90, variant_txt, transform=ax.transAxes, fontsize=11, color="#111")
  801. ax.text(0.02, 0.82, f"x50={x50:.2f}", transform=ax.transAxes, fontsize=10, color="#111")
  802. # delta text box
  803. d_ref = hi_r_x - lo_r_x
  804. d_tta = hi_t_x - lo_t_x
  805. ax.text(
  806. 0.95, 0.06,
  807. f"Δr={d_ref:.2f} Δt={d_tta:.2f}",
  808. transform=ax.transAxes,
  809. fontsize=10,
  810. color="#222",
  811. ha="right",
  812. bbox=dict(facecolor="white", edgecolor=color, boxstyle="square,pad=0.2", alpha=0.9)
  813. )
  814. ax.set_xlim(0, xc.max())
  815. ax.set_ylim(-0.05, 1.05)
  816. ax.grid(alpha=0.25)
  817. ax.tick_params(axis="x", labelbottom=True)
  818. # ------------------------------------------------------------
  819. # legend
  820. # ------------------------------------------------------------
  821. def noise_legend(fig):
  822. import matplotlib.lines as mlines
  823. from matplotlib.patches import Patch
  824. legend_handles = [
  825. mlines.Line2D(
  826. [0], [0],
  827. color="black",
  828. lw=2.5,
  829. label="clean logistic fit (trained on log(SUV))"
  830. ),
  831. Patch(
  832. facecolor="#999999",
  833. alpha=0.24,
  834. edgecolor="none",
  835. label="refit band (training perturbation; 95% CI)"
  836. ),
  837. Patch(
  838. facecolor="#999999",
  839. alpha=0.10,
  840. edgecolor="none",
  841. label="TTA band (inference-time noise; 95% CI)"
  842. ),
  843. Patch(
  844. facecolor="#1f78b4",
  845. alpha=0.24,
  846. edgecolor="none",
  847. label="multiplicative noise: σ=0.129 (blue)"
  848. ),
  849. Patch(
  850. facecolor="#e66101",
  851. alpha=0.24,
  852. edgecolor="none",
  853. label="additive noise: σ=0.144 (orange)"
  854. ),
  855. mlines.Line2D(
  856. [0], [0],
  857. color="#666666",
  858. lw=1.4,
  859. ls="--",
  860. label="x50 (P=0.5)"
  861. ),
  862. ]
  863. fig.legend(
  864. handles=legend_handles,
  865. labels=[h.get_label() for h in legend_handles],
  866. loc="lower center",
  867. ncol=3,
  868. frameon=False,
  869. bbox_to_anchor=(0.5, 0.02),
  870. fontsize=10
  871. )