logit.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845
  1. import numpy as np
  2. import matplotlib.pyplot as plt
  3. from scipy.optimize import minimize
  4. # ============================================================
  5. # Stable sigmoid
  6. # ============================================================
  7. def _sigmoid_stable(z):
  8. z = np.asarray(z, float)
  9. z = np.clip(z, -50.0, 50.0)
  10. return 1.0 / (1.0 + np.exp(-z))
  11. # ============================================================
  12. # 1) Model
  13. # ============================================================
  14. def model_p(x, b):
  15. """p(x|b) = sigmoid(b0 + b1*x)."""
  16. x = np.asarray(x, float).reshape(-1)
  17. b0, b1 = np.asarray(b, float).reshape(2)
  18. return _sigmoid_stable(b0 + b1 * x)
  19. def design_matrix(x):
  20. """Design matrix X = [1, x]."""
  21. x = np.asarray(x, float).reshape(-1)
  22. return np.column_stack([np.ones_like(x), x])
  23. # ============================================================
  24. # 2) Likelihood
  25. # ============================================================
  26. def nll(x, y, b, l2=0.0):
  27. """
  28. Penalized negative log-likelihood:
  29. NLL(b) = -sum[y log p + (1-y) log(1-p)] + 0.5*l2*||b||^2
  30. """
  31. x = np.asarray(x, float).reshape(-1)
  32. y = np.asarray(y, float).reshape(-1)
  33. b = np.asarray(b, float).reshape(2)
  34. p = model_p(x, b)
  35. eps = 1e-12
  36. p = np.clip(p, eps, 1 - eps)
  37. base = -np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))
  38. pen = 0.5 * l2 * float(np.dot(b, b))
  39. return base + pen
  40. def llf(x, y, b):
  41. """
  42. Ordinary (unpenalized) log-likelihood at fitted parameters.
  43. """
  44. x = np.asarray(x, float).reshape(-1)
  45. y = np.asarray(y, float).reshape(-1)
  46. b = np.asarray(b, float).reshape(2)
  47. p = model_p(x, b)
  48. eps = 1e-12
  49. p = np.clip(p, eps, 1 - eps)
  50. return float(np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)))
  51. # ============================================================
  52. # 3) Gradient / Hessian / Covariance
  53. # ============================================================
  54. def grad_nll(x, y, b, l2=0.0):
  55. """
  56. Gradient of penalized NLL:
  57. g(b) = X^T (p - y) + l2*b
  58. """
  59. X = design_matrix(x)
  60. y = np.asarray(y, float).reshape(-1)
  61. b = np.asarray(b, float).reshape(2)
  62. p = model_p(x, b)
  63. return X.T @ (p - y) + l2 * b
  64. def hess_nll(x, b, l2=0.0):
  65. """
  66. Hessian of penalized NLL:
  67. H(b) = X^T W X + l2*I
  68. W = diag(p*(1-p))
  69. """
  70. X = design_matrix(x)
  71. b = np.asarray(b, float).reshape(2)
  72. p = model_p(x, b)
  73. w = p * (1 - p)
  74. return X.T @ (w[:, None] * X) + l2 * np.eye(2)
  75. def covariance(x, b, l2=0.0):
  76. """
  77. Cov(b) ≈ H(b)^(-1), where H is the penalized Hessian if l2 > 0.
  78. Robust to near-singular Hessians.
  79. """
  80. H = hess_nll(x, b, l2=l2)
  81. try:
  82. return np.linalg.inv(H)
  83. except np.linalg.LinAlgError:
  84. return np.linalg.pinv(H)
  85. def standard_errors(x, b, l2=0.0):
  86. """
  87. SE = sqrt(diag(Cov)).
  88. """
  89. C = covariance(x, b, l2=l2)
  90. return np.sqrt(np.maximum(np.diag(C), 0.0))
  91. # Compatibility alias
  92. def logit_poly_cov(x, b, l2=0.0):
  93. return covariance(x, b, l2=l2)
  94. # ============================================================
  95. # 4) Fit
  96. # ============================================================
  97. def fit_newton(x, y, b_start=None, max_iter=50, tol=1e-8, l2=0.0):
  98. """
  99. Newton updates for penalized NLL with backtracking line-search.
  100. Update:
  101. b_new = b - alpha * H^{-1} g
  102. alpha shrinks until NLL decreases.
  103. """
  104. x = np.asarray(x, float).reshape(-1)
  105. y = np.asarray(y, int).reshape(-1)
  106. if b_start is None:
  107. b = np.array([0.0, 0.0], float)
  108. else:
  109. b = np.asarray(b_start, float).reshape(2)
  110. f = nll(x, y, b, l2=l2)
  111. for _ in range(max_iter):
  112. g = grad_nll(x, y, b, l2=l2)
  113. H = hess_nll(x, b, l2=l2)
  114. try:
  115. step = np.linalg.solve(H, g)
  116. except np.linalg.LinAlgError:
  117. step = np.linalg.pinv(H) @ g
  118. alpha = 1.0
  119. while alpha > 1e-6:
  120. b_new = b - alpha * step
  121. f_new = nll(x, y, b_new, l2=l2)
  122. if np.isfinite(f_new) and f_new <= f:
  123. break
  124. alpha *= 0.5
  125. if alpha <= 1e-6:
  126. break
  127. if np.max(np.abs(b_new - b)) < tol:
  128. b = b_new
  129. break
  130. b, f = b_new, f_new
  131. return b
  132. # ============================================================
  133. # 14) Overlay plot (LOG left, RAW right)
  134. # ============================================================
  135. def plot_overlay_two_panels_final(
  136. r_log_full, r_log_trim, r_raw_full, r_raw_trim,
  137. dy_full=-0.010, dy_trim=0.010
  138. ):
  139. import numpy as np
  140. import matplotlib.pyplot as plt
  141. COL_NC = "#4C78A8"
  142. COL_AE = "#F58518"
  143. COL_FULL = "#1f77b4"
  144. COL_TRIM = "#ff7f0e"
  145. fig, axes = plt.subplots(1, 2, figsize=(9.5, 3.5), sharey=True)
  146. ax1, ax2 = axes
  147. def draw_panel(ax, r_full, r_trim, xlabel, panel_label,
  148. show_legend=False):
  149. xF = np.asarray(r_full["x"], float)
  150. yF = np.asarray(r_full["y"], int)
  151. xT = np.asarray(r_trim["x"], float)
  152. yT = np.asarray(r_trim["y"], int)
  153. xx = np.linspace(
  154. min(xF.min(), xT.min()),
  155. max(xF.max(), xT.max()),
  156. 500
  157. )
  158. # keep x-values unchanged
  159. xF_plot = xF
  160. xT_plot = xT
  161. # vertical offsets only
  162. yF_plot = yF + np.where(yF == 0, dy_full, -dy_full)
  163. yT_plot = yT + np.where(yT == 0, dy_trim, -dy_trim)
  164. # FULL = filled markers
  165. ax.scatter(
  166. xF_plot[yF == 0], yF_plot[yF == 0],
  167. s=16,
  168. color=COL_NC,
  169. alpha=0.70,
  170. edgecolors="none",
  171. label="data: FULL NC",
  172. zorder=3
  173. )
  174. ax.scatter(
  175. xF_plot[yF == 1], yF_plot[yF == 1],
  176. s=16,
  177. color=COL_AE,
  178. alpha=0.80,
  179. edgecolors="none",
  180. label="data: FULL AE",
  181. zorder=3
  182. )
  183. # TRIM = outlined markers
  184. ax.scatter(
  185. xT_plot[yT == 0], yT_plot[yT == 0],
  186. s=24,
  187. facecolors=COL_NC,
  188. edgecolors="black",
  189. linewidths=0.45,
  190. alpha=0.95,
  191. label="data: TRIM NC",
  192. zorder=4
  193. )
  194. ax.scatter(
  195. xT_plot[yT == 1], yT_plot[yT == 1],
  196. s=24,
  197. facecolors=COL_AE,
  198. edgecolors="black",
  199. linewidths=0.45,
  200. alpha=0.95,
  201. label="data: TRIM AE",
  202. zorder=4
  203. )
  204. # logistic fits
  205. ax.plot(
  206. xx,
  207. model_p(xx, r_full["b"]),
  208. lw=1.8,
  209. color=COL_FULL,
  210. label="fit FULL",
  211. zorder=2
  212. )
  213. ax.plot(
  214. xx,
  215. model_p(xx, r_trim["b"]),
  216. lw=1.8,
  217. color=COL_TRIM,
  218. label="fit TRIM",
  219. zorder=2
  220. )
  221. # legend only in panel B
  222. if show_legend:
  223. ax.legend(
  224. loc="lower right",
  225. fontsize=7,
  226. markerscale=0.9,
  227. frameon=True,
  228. framealpha=1.0,
  229. edgecolor="0.7",
  230. handlelength=1.8,
  231. borderpad=0.4,
  232. labelspacing=0.4,
  233. handletextpad=0.5
  234. )
  235. ax.set_xlabel(xlabel)
  236. ax.set_ylim(-0.08, 1.08)
  237. ax.text(
  238. 0.05, 0.90,
  239. panel_label,
  240. transform=ax.transAxes,
  241. fontsize=11
  242. )
  243. draw_panel(
  244. ax1,
  245. r_log_full,
  246. r_log_trim,
  247. "log(X)",
  248. "A",
  249. show_legend=False
  250. )
  251. draw_panel(
  252. ax2,
  253. r_raw_full,
  254. r_raw_trim,
  255. "X",
  256. "B",
  257. show_legend=True
  258. )
  259. ax1.set_ylabel("P(AE | X = x)")
  260. for ax in axes:
  261. ax.grid(False)
  262. ax.tick_params(labelsize=8)
  263. plt.tight_layout()
  264. plt.show()
  265. return fig, axes
  266. return fig, axes
  267. # ============================================================
  268. # 5) Goodness of fit
  269. # ============================================================
  270. def goodness_of_fit(x, y, b, thresh=0.5, l2=0.0):
  271. """
  272. Returns:
  273. LLF, NLL, AIC, BIC, Accuracy, n, k
  274. Notes
  275. -----
  276. Fit may use l2 > 0, but GOF metrics below are computed from the
  277. ordinary (unpenalized) likelihood evaluated at the fitted parameters.
  278. The argument l2 is kept only for interface consistency.
  279. """
  280. x = np.asarray(x, float).reshape(-1)
  281. y = np.asarray(y, int).reshape(-1)
  282. b = np.asarray(b, float).reshape(2)
  283. p = model_p(x, b)
  284. eps = 1e-12
  285. p = np.clip(p, eps, 1 - eps)
  286. LLF = np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))
  287. NLL = -LLF
  288. n = len(x)
  289. k = len(b)
  290. AIC = 2 * k - 2 * LLF
  291. BIC = k * np.log(n) - 2 * LLF
  292. yhat = (p >= thresh).astype(int)
  293. acc = np.mean(yhat == y)
  294. return {
  295. "LLF": float(LLF),
  296. "NLL": float(NLL),
  297. "AIC": float(AIC),
  298. "BIC": float(BIC),
  299. "A": float(acc),
  300. "n": int(n),
  301. "k": int(k),
  302. }
  303. # ============================================================
  304. # 6) x50 / Wald helpers / compact fit
  305. # ============================================================
  306. def x50(b):
  307. """
  308. Model-scale midpoint:
  309. x50 = -b0 / b1
  310. For LOG panels, this is on the log(x) scale.
  311. Raw-scale SUV50 is exp(x50).
  312. """
  313. b0, b1 = np.asarray(b, float).reshape(2)
  314. return np.nan if np.abs(b1) < 1e-12 else (-b0 / b1)
  315. def check_x50_consistency(P):
  316. """
  317. Diagnostic check for x50 consistency.
  318. For LOG models:
  319. x50_model is on log(X) scale
  320. SUV50 is on raw X scale = exp(x50_model)
  321. For RAW models:
  322. x50_model = SUV50
  323. Correct result:
  324. P(x50_model) should be approximately 0.5
  325. """
  326. for key, pk in P.items():
  327. b = np.asarray(pk["b"], float).reshape(2)
  328. trans = pk.get("transform", "")
  329. x50_model = x50(b)
  330. suv50 = np.exp(x50_model) if trans == "log" else x50_model
  331. p_at_x50 = model_p(np.array([x50_model]), b)[0]
  332. print(
  333. key,
  334. "| transform =", trans,
  335. "| x50_model =", x50_model,
  336. "| SUV50 =", suv50,
  337. "| P(x50) =", p_at_x50
  338. )
  339. def x50_wald_ci(b, cov, z=1.959963984540054):
  340. """
  341. Wald CI for x50 = -b0/b1 via delta method.
  342. Returned on MODEL scale.
  343. """
  344. b = np.asarray(b, float).reshape(2)
  345. cov = np.asarray(cov, float).reshape(2, 2)
  346. b0, b1 = b
  347. if np.abs(b1) < 1e-12:
  348. return np.nan, np.nan
  349. xhat = -b0 / b1
  350. grad = np.array([-1.0 / b1, b0 / (b1 ** 2)], float)
  351. var = float(grad.T @ cov @ grad)
  352. se = np.sqrt(max(var, 0.0))
  353. return float(xhat - z * se), float(xhat + z * se)
  354. def wald_ci(b, cov, z=1.959963984540054):
  355. """
  356. Wald CI for parameters: b_i ± z*SE_i.
  357. """
  358. b = np.asarray(b, float).reshape(2)
  359. cov = np.asarray(cov, float).reshape(2, 2)
  360. se = np.sqrt(np.maximum(np.diag(cov), 0.0))
  361. return b - z * se, b + z * se
  362. def fit_pack(x, y, name="", thresh=0.5, l2=0.0, z=1.959963984540054):
  363. """
  364. Fit + covariance + GOF + parameter Wald CI.
  365. x should already be on the MODEL scale.
  366. """
  367. x = np.asarray(x, float).reshape(-1)
  368. y = np.asarray(y, int).reshape(-1)
  369. b = fit_newton(x, y, l2=l2)
  370. cov = covariance(x, b, l2=l2)
  371. gof = goodness_of_fit(x, y, b, thresh=thresh, l2=l2)
  372. lcl, ucl = wald_ci(b, cov, z=z)
  373. return {
  374. "name": name,
  375. "x": x,
  376. "y": y,
  377. "b": b,
  378. "cov": cov,
  379. "gof": gof,
  380. "LCL": lcl,
  381. "UCL": ucl,
  382. "l2": float(l2),
  383. }
  384. def trim_nc_by_value(x_raw, y, target=2.48, tol=0.05):
  385. """
  386. Remove ONE NC sample (y==0) with x_raw closest to target.
  387. """
  388. x_raw = np.asarray(x_raw, float).reshape(-1)
  389. y = np.asarray(y, int).reshape(-1)
  390. nc_idx = np.where(y == 0)[0]
  391. if len(nc_idx) == 0:
  392. raise ValueError("No NC samples found (y==0).")
  393. j = nc_idx[np.argmin(np.abs(x_raw[nc_idx] - target))]
  394. diff = float(np.abs(x_raw[j] - target))
  395. if diff > tol:
  396. print(f"[trim warning] closest NC to {target} is {x_raw[j]:.6f} (diff={diff:.6f}) > tol={tol}")
  397. mask = np.ones_like(y, dtype=bool)
  398. mask[j] = False
  399. print(f"[trim] removed index={j}, x_raw={x_raw[j]:.6f}, y={y[j]}")
  400. return x_raw[mask], y[mask]
  401. # ============================================================
  402. # 7) Analytic CI bands on a grid
  403. # ============================================================
  404. def eta_se_grid(x_grid, cov):
  405. """
  406. Standard error of eta(x) = b0 + b1*x on a grid.
  407. x_grid must be on the MODEL scale.
  408. """
  409. x_grid = np.asarray(x_grid, float).reshape(-1)
  410. cov = np.asarray(cov, float).reshape(2, 2)
  411. Xg = design_matrix(x_grid)
  412. var_eta = np.einsum("ij,jk,ik->i", Xg, cov, Xg)
  413. return np.sqrt(np.maximum(var_eta, 0.0))
  414. def ci_band_normal_mle_sim(
  415. x_grid, b, cov,
  416. M=20000,
  417. seed=123,
  418. alpha=0.05,
  419. enforce_positive_slope=True,
  420. enforce_x50_in_grid=True,
  421. slope_eps=1e-10
  422. ):
  423. """
  424. Normal-on-MLE simulation CI band with admissible logistic draws.
  425. Draw beta* ~ N(beta_hat, Cov_hat), then keep only curves that:
  426. 1) are increasing: beta1 > 0
  427. 2) have x50 inside the plotted model-scale grid, if requested
  428. This avoids pathological Normal curves in near-separated TRIM data.
  429. """
  430. rng = np.random.default_rng(seed)
  431. x_grid = np.asarray(x_grid, float).reshape(-1)
  432. b = np.asarray(b, float).reshape(2)
  433. cov = np.asarray(cov, float).reshape(2, 2)
  434. x_min = float(np.min(x_grid))
  435. x_max = float(np.max(x_grid))
  436. curves = []
  437. tries = 0
  438. max_tries = 50 * M
  439. while len(curves) < M and tries < max_tries:
  440. tries += 1
  441. try:
  442. bb = rng.multivariate_normal(mean=b, cov=cov)
  443. except Exception:
  444. break
  445. if not np.all(np.isfinite(bb)):
  446. continue
  447. b0, b1 = bb
  448. if enforce_positive_slope and b1 <= slope_eps:
  449. continue
  450. x50_draw = x50(bb)
  451. if not np.isfinite(x50_draw):
  452. continue
  453. if enforce_x50_in_grid and not (x_min <= x50_draw <= x_max):
  454. continue
  455. pp = model_p(x_grid, bb)
  456. if np.all(np.isfinite(pp)):
  457. curves.append(pp)
  458. if len(curves) == 0:
  459. nan = np.full_like(x_grid, np.nan, dtype=float)
  460. return nan, nan, nan
  461. curves = np.asarray(curves, float)
  462. q = np.quantile(curves, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0)
  463. return q[0], q[1], q[2]
  464. def x50_normal_ci_from_mvnorm(
  465. b, cov, M=200000, seed=123, alpha=0.05,
  466. enforce_positive_slope=True, slope_eps=1e-10
  467. ):
  468. """
  469. Normal-on-MLE CI for x50.
  470. Draw beta* ~ N(beta_hat, Cov_hat), optionally retain only
  471. monotone increasing draws beta1 > 0, then compute x50 = -b0/b1.
  472. """
  473. rng = np.random.default_rng(seed)
  474. b = np.asarray(b, float).reshape(2)
  475. cov = np.asarray(cov, float).reshape(2, 2)
  476. vals = []
  477. tries = 0
  478. max_tries = 20 * M
  479. while len(vals) < M and tries < max_tries:
  480. tries += 1
  481. try:
  482. bb = rng.multivariate_normal(mean=b, cov=cov)
  483. except Exception:
  484. break
  485. if not np.all(np.isfinite(bb)):
  486. continue
  487. if enforce_positive_slope and bb[1] <= slope_eps:
  488. continue
  489. val = x50(bb)
  490. if np.isfinite(val):
  491. vals.append(val)
  492. if len(vals) == 0:
  493. return np.nan, np.nan, np.nan, 0
  494. vals = np.asarray(vals, float)
  495. q = np.quantile(vals, [alpha / 2, 0.5, 1.0 - alpha / 2])
  496. return float(q[0]), float(q[1]), float(q[2]), int(len(vals))
  497. def ci_band_delta(x_grid, b, cov, z=1.959963984540054):
  498. """
  499. Delta-method CI band on probability scale:
  500. p(x) ± z * SE_p(x)
  501. where
  502. SE_p = p(1-p) * SE_eta
  503. Returns: lo, mid, hi
  504. """
  505. x_grid = np.asarray(x_grid, float).reshape(-1)
  506. b = np.asarray(b, float).reshape(2)
  507. p = model_p(x_grid, b)
  508. se_eta = eta_se_grid(x_grid, cov)
  509. se_p = p * (1.0 - p) * se_eta
  510. lo = np.clip(p - z * se_p, 0.0, 1.0)
  511. md = p
  512. hi = np.clip(p + z * se_p, 0.0, 1.0)
  513. return lo, md, hi
  514. # ============================================================
  515. # 8) Bootstrap parameter generators
  516. # ============================================================
  517. def bootstrap_params_stratified(x, y, B=2000, seed=123, l2=0.0, b_start=None):
  518. """
  519. Stratified nonparametric bootstrap on MODEL-scale x.
  520. Preserves class counts exactly.
  521. Returns array of shape (n_ok, 2).
  522. """
  523. rng = np.random.default_rng(seed)
  524. x = np.asarray(x, float).reshape(-1)
  525. y = np.asarray(y, int).reshape(-1)
  526. x0 = x[y == 0]
  527. x1 = x[y == 1]
  528. n0 = len(x0)
  529. n1 = len(x1)
  530. if n0 == 0 or n1 == 0:
  531. return np.empty((0, 2), float)
  532. out = []
  533. for _ in range(B):
  534. xb0 = rng.choice(x0, size=n0, replace=True)
  535. xb1 = rng.choice(x1, size=n1, replace=True)
  536. xb = np.concatenate([xb0, xb1])
  537. yb = np.concatenate([np.zeros(n0, dtype=int), np.ones(n1, dtype=int)])
  538. try:
  539. bb = fit_newton(xb, yb, b_start=b_start, l2=l2)
  540. if np.all(np.isfinite(bb)):
  541. out.append(bb)
  542. except Exception:
  543. pass
  544. if len(out) == 0:
  545. return np.empty((0, 2), float)
  546. return np.asarray(out, float)
  547. def bootstrap_params_parametric(x, b, B=2000, seed=123, l2=0.0, min_ae=2):
  548. """
  549. Parametric bootstrap on MODEL-scale x.
  550. Simulates y* ~ Bernoulli(p_hat(x)).
  551. Keeps only samples with at least min_ae positives and at least one negative.
  552. Returns array of shape (n_ok, 2).
  553. """
  554. rng = np.random.default_rng(seed)
  555. x = np.asarray(x, float).reshape(-1)
  556. b = np.asarray(b, float).reshape(2)
  557. p = model_p(x, b)
  558. n = len(x)
  559. out = []
  560. tries = 0
  561. max_tries = max(10 * B, 1000)
  562. while len(out) < B and tries < max_tries:
  563. tries += 1
  564. yb = rng.binomial(1, p, size=n).astype(int)
  565. n1 = int(np.sum(yb))
  566. n0 = n - n1
  567. if n1 < min_ae or n0 < 1:
  568. continue
  569. try:
  570. bb = fit_newton(x, yb, b_start=b, l2=l2)
  571. if np.all(np.isfinite(bb)):
  572. out.append(bb)
  573. except Exception:
  574. pass
  575. if len(out) == 0:
  576. return np.empty((0, 2), float)
  577. return np.asarray(out, float)
  578. # ============================================================
  579. # 9) Convert bootstrap parameters to curve bands
  580. # ============================================================
  581. def bootstrap_band_from_params(x_grid, pars, alpha=0.05):
  582. """
  583. Build bootstrap CI band from bootstrap parameter draws.
  584. x_grid is on MODEL scale.
  585. Returns: lo, mid, hi
  586. """
  587. x_grid = np.asarray(x_grid, float).reshape(-1)
  588. pars = np.asarray(pars, float)
  589. if pars.ndim != 2 or pars.shape[0] == 0:
  590. nan = np.full_like(x_grid, np.nan, dtype=float)
  591. return nan, nan, nan
  592. curves = np.array([model_p(x_grid, bb) for bb in pars], float)
  593. q = np.quantile(curves, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0)
  594. return q[0], q[1], q[2]
  595. # ============================================================
  596. # 10) High-level wrapper for one panel
  597. # ============================================================
  598. def fit_ci_pack_rawgrid(
  599. x_raw, y, transform="raw", xmax_raw=None, grid_n=500,
  600. name="", l2=0.0, B=2000, seed=123, min_ae=2,
  601. z=1.959963984540054,
  602. ):
  603. x_raw = np.asarray(x_raw, float).reshape(-1)
  604. y = np.asarray(y, int).reshape(-1)
  605. if transform not in ("raw", "log"):
  606. raise ValueError("transform must be 'raw' or 'log'")
  607. x_raw = np.clip(x_raw, 1e-12, None)
  608. x_model = x_raw if transform == "raw" else np.log(x_raw)
  609. b = fit_newton(x_model, y, l2=l2)
  610. cov = covariance(x_model, b, l2=l2)
  611. gof = goodness_of_fit(x_model, y, b, l2=l2)
  612. xmin_raw = float(np.min(x_raw))
  613. xmax0 = float(np.max(x_raw))
  614. xmax_use = xmax0 if xmax_raw is None else max(float(xmax_raw), xmax0)
  615. x_grid_raw = np.linspace(xmin_raw, xmax_use, int(grid_n))
  616. x_grid_raw = np.clip(x_grid_raw, 1e-12, None)
  617. x_grid_model = x_grid_raw if transform == "raw" else np.log(x_grid_raw)
  618. lo_n, md_n, hi_n = ci_band_normal_mle_sim(
  619. x_grid_model,
  620. b,
  621. cov,
  622. M=20000,
  623. seed=seed + 10,
  624. alpha=0.05,
  625. enforce_positive_slope=True,
  626. enforce_x50_in_grid=True
  627. )
  628. lo_d, md_d, hi_d = ci_band_delta(
  629. x_grid_model,
  630. b,
  631. cov,
  632. z=z
  633. )
  634. pars_np = bootstrap_params_stratified(
  635. x_model, y,
  636. B=B,
  637. seed=seed + 1,
  638. l2=l2,
  639. b_start=b
  640. )
  641. pars_pm = bootstrap_params_parametric(
  642. x_model, b,
  643. B=B,
  644. seed=seed + 2,
  645. l2=l2,
  646. min_ae=min_ae
  647. )
  648. lo_np, md_np, hi_np = bootstrap_band_from_params(x_grid_model, pars_np)
  649. lo_pm, md_pm, hi_pm = bootstrap_band_from_params(x_grid_model, pars_pm)
  650. return {
  651. "name": name,
  652. "transform": transform,
  653. "l2": float(l2),
  654. "x_raw": x_raw,
  655. "x_model": x_model,
  656. "y": y,
  657. "x_grid_raw": x_grid_raw,
  658. "x_grid_model": x_grid_model,
  659. "b": b,
  660. "cov": cov,
  661. "gof": gof,
  662. "bands": {
  663. "Normal": (lo_n, md_n, hi_n),
  664. "Delta": (lo_d, md_d, hi_d),
  665. "Nonparam": (lo_np, md_np, hi_np),
  666. "Parametric": (lo_pm, md_pm, hi_pm),
  667. },
  668. "pars_nonparam": pars_np,
  669. "pars_parametric": pars_pm,
  670. }
  671. # ============================================================
  672. # 11) Model-band table with LL / UL
  673. # ============================================================
  674. def model_ci_table_4methods(
  675. P,
  676. keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"),
  677. ):
  678. """
  679. Long table of model CI bands on the grid.
  680. Includes:
  681. x_grid_model, x_grid_raw, fit, LL, UL, width
  682. """
  683. import pandas as pd
  684. rows = []
  685. for key in keys:
  686. pk = P[key]
  687. xg_raw = np.asarray(pk["x_grid_raw"], float)
  688. xg_mod = np.asarray(pk["x_grid_model"], float)
  689. trans = pk.get("transform", "")
  690. bands = pk["bands"]
  691. for method, (lo, md, hi) in bands.items():
  692. lo = np.asarray(lo, float)
  693. md = np.asarray(md, float)
  694. hi = np.asarray(hi, float)
  695. for i in range(len(xg_raw)):
  696. rows.append({
  697. "Panel": key,
  698. "Method": method,
  699. "transform": trans,
  700. "x_grid_raw": float(xg_raw[i]),
  701. "x_grid_model": float(xg_mod[i]),
  702. "fit": float(md[i]),
  703. "LL": float(lo[i]),
  704. "UL": float(hi[i]),
  705. "width": float(hi[i] - lo[i]),
  706. })
  707. return pd.DataFrame(rows)
  708. # ============================================================
  709. # 12) Parameter/x50 CI summary table
  710. # ============================================================
  711. def param_ci_table_4methods(
  712. P,
  713. keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"),
  714. z=1.959963984540054,
  715. alpha=0.05,
  716. include_point_est=True,
  717. M_normal=200000,
  718. seed_normal=123,
  719. ):
  720. """
  721. Build tidy parameter/x50 CI table for:
  722. Normal, Delta, Nonparam, Parametric
  723. Definitions
  724. -----------
  725. Normal:
  726. - b0, b1: Wald CI from MLE covariance
  727. - x50: beta ~ N(b_hat, cov_hat), transform x50 = -b0/b1, take quantiles
  728. Delta:
  729. - b0, b1: Wald CI from MLE covariance
  730. - x50: delta/Wald CI using gradient of x50 = -b0/b1
  731. Nonparam:
  732. - bootstrap quantiles from nonparametric bootstrap parameter draws
  733. Parametric:
  734. - bootstrap quantiles from parametric bootstrap parameter draws
  735. Notes
  736. -----
  737. - x50 is on MODEL scale.
  738. - SUV50 is on RAW scale:
  739. raw panel -> same as x50
  740. log panel -> exp(x50)
  741. """
  742. import pandas as pd
  743. def _boot_ci_from_pars(pars, alpha=0.05):
  744. if pars is None or len(pars) == 0:
  745. nan2 = (np.nan, np.nan)
  746. return nan2, nan2, nan2, np.nan, 0
  747. pars = np.asarray(pars, float)
  748. q = np.quantile(pars, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0)
  749. b0_ci = (float(q[0, 0]), float(q[2, 0]))
  750. b1_ci = (float(q[0, 1]), float(q[2, 1]))
  751. x50s = np.array([x50(bb) for bb in pars], float)
  752. x50s = x50s[np.isfinite(x50s)]
  753. if len(x50s) == 0:
  754. x50_ci = (np.nan, np.nan)
  755. x50_med = np.nan
  756. else:
  757. xq = np.quantile(x50s, [alpha / 2, 0.5, 1.0 - alpha / 2])
  758. x50_ci = (float(xq[0]), float(xq[2]))
  759. x50_med = float(xq[1])
  760. return b0_ci, b1_ci, x50_ci, x50_med, int(len(pars))
  761. def _to_suv50(x50_pair, trans):
  762. lo, hi = x50_pair
  763. if not (np.isfinite(lo) and np.isfinite(hi)):
  764. return (np.nan, np.nan)
  765. if trans == "log":
  766. return (float(np.exp(lo)), float(np.exp(hi)))
  767. return (float(lo), float(hi))
  768. def _to_suv50_scalar(x50_val, trans):
  769. if not np.isfinite(x50_val):
  770. return np.nan
  771. if trans == "log":
  772. return float(np.exp(x50_val))
  773. return float(x50_val)
  774. rows = []
  775. for ik, key in enumerate(keys):
  776. pk = P[key]
  777. b = np.asarray(pk["b"], float).reshape(2)
  778. cov = np.asarray(pk["cov"], float).reshape(2, 2)
  779. trans = pk.get("transform", "")
  780. l2 = float(pk.get("l2", 0.0))
  781. b0_hat, b1_hat = float(b[0]), float(b[1])
  782. x50_hat = float(x50(b))
  783. suv50_hat = _to_suv50_scalar(x50_hat, trans)
  784. lcl, ucl = wald_ci(b, cov, z=z)
  785. b0_wald = (float(lcl[0]), float(ucl[0]))
  786. b1_wald = (float(lcl[1]), float(ucl[1]))
  787. x50_l_d, x50_u_d = x50_wald_ci(b, cov, z=z)
  788. x50_med_d = x50_hat
  789. x50_delta = (float(x50_l_d), float(x50_u_d))
  790. suv50_delta = _to_suv50(x50_delta, trans)
  791. x50_l_n, x50_med_n, x50_u_n, n_ok_norm = x50_normal_ci_from_mvnorm(
  792. b, cov,
  793. M=M_normal,
  794. seed=seed_normal + 1000 * ik,
  795. alpha=alpha
  796. )
  797. x50_normal = (float(x50_l_n), float(x50_u_n))
  798. suv50_normal = _to_suv50(x50_normal, trans)
  799. pars_np = pk.get("pars_nonparam", np.empty((0, 2)))
  800. pars_pm = pk.get("pars_parametric", np.empty((0, 2)))
  801. b0_np, b1_np, x50_np, x50_med_np, n_np = _boot_ci_from_pars(pars_np, alpha=alpha)
  802. b0_pm, b1_pm, x50_pm, x50_med_pm, n_pm = _boot_ci_from_pars(pars_pm, alpha=alpha)
  803. suv50_np = _to_suv50(x50_np, trans)
  804. suv50_pm = _to_suv50(x50_pm, trans)
  805. def _width(ci):
  806. lo, hi = ci
  807. if np.isfinite(lo) and np.isfinite(hi):
  808. return float(hi - lo)
  809. return np.nan
  810. def add_row(method, b0_ci, b1_ci, x50_ci, x50_med, suv50_ci, B_used):
  811. row = {
  812. "Panel": key,
  813. "Method": method,
  814. "b0_hat": b0_hat,
  815. "b0_LCL": float(b0_ci[0]),
  816. "b0_UCL": float(b0_ci[1]),
  817. "b0_width": _width(b0_ci),
  818. "b1_hat": b1_hat,
  819. "b1_LCL": float(b1_ci[0]),
  820. "b1_UCL": float(b1_ci[1]),
  821. "b1_width": _width(b1_ci),
  822. "x50_hat": x50_hat,
  823. "x50_med": float(x50_med) if np.isfinite(x50_med) else np.nan,
  824. "x50_LCL": float(x50_ci[0]),
  825. "x50_UCL": float(x50_ci[1]),
  826. "x50_width": _width(x50_ci),
  827. "SUV50_hat": suv50_hat,
  828. "SUV50_med": _to_suv50_scalar(x50_med, trans),
  829. "SUV50_LCL": float(suv50_ci[0]),
  830. "SUV50_UCL": float(suv50_ci[1]),
  831. "SUV50_width": _width(suv50_ci),
  832. "transform": trans,
  833. "l2": l2,
  834. "B_used": B_used,
  835. }
  836. if not include_point_est:
  837. for col in [
  838. "b0_hat", "b1_hat", "x50_hat", "x50_med",
  839. "SUV50_hat", "SUV50_med", "transform", "l2"
  840. ]:
  841. row.pop(col, None)
  842. rows.append(row)
  843. add_row("Normal", b0_wald, b1_wald, x50_normal, x50_med_n, suv50_normal, n_ok_norm)
  844. add_row("Delta", b0_wald, b1_wald, x50_delta, x50_med_d, suv50_delta, np.nan)
  845. add_row("Nonparam", b0_np, b1_np, x50_np, x50_med_np, suv50_np, n_np)
  846. add_row("Parametric", b0_pm, b1_pm, x50_pm, x50_med_pm, suv50_pm, n_pm)
  847. return pd.DataFrame(rows)
  848. def combined_x50_model_bounds_table(
  849. P,
  850. keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"),
  851. z=1.959963984540054,
  852. alpha=0.05,
  853. M_normal=200000,
  854. seed_normal=123,
  855. ):
  856. """
  857. Combine x50/SUV50 CI with global model-band bounds in one table.
  858. Global model bounds are defined as:
  859. global_LL = min_x LL(x)
  860. global_UL = max_x UL(x)
  861. Returns one row per Panel × Method.
  862. """
  863. import pandas as pd
  864. # x50 / SUV50 table
  865. param_df = param_ci_table_4methods(
  866. P,
  867. keys=keys,
  868. z=z,
  869. alpha=alpha,
  870. include_point_est=True,
  871. M_normal=M_normal,
  872. seed_normal=seed_normal,
  873. ).copy()
  874. # pointwise model-band table
  875. model_df = model_ci_table_4methods(P, keys=keys).copy()
  876. # global envelope over x-grid
  877. global_df = (
  878. model_df
  879. .groupby(["Panel", "Method"], as_index=False)
  880. .agg(
  881. global_LL=("LL", "min"),
  882. global_UL=("UL", "max"),
  883. fit_min=("fit", "min"),
  884. fit_max=("fit", "max"),
  885. mean_width=("width", "mean"),
  886. max_width=("width", "max"),
  887. )
  888. )
  889. global_df["global_width"] = global_df["global_UL"] - global_df["global_LL"]
  890. # merge
  891. out = pd.merge(
  892. param_df,
  893. global_df,
  894. on=["Panel", "Method"],
  895. how="left"
  896. )
  897. # choose a nice column order
  898. preferred = [
  899. "Panel", "Method", "transform",
  900. "x50_hat", "x50_LCL", "x50_UCL", "x50_width",
  901. "SUV50_hat", "SUV50_LCL", "SUV50_UCL", "SUV50_width",
  902. "global_LL", "global_UL", "global_width",
  903. "fit_min", "fit_max",
  904. "mean_width", "max_width",
  905. "B_used"
  906. ]
  907. cols = [c for c in preferred if c in out.columns] + [
  908. c for c in out.columns if c not in preferred
  909. ]
  910. return out[cols]
  911. # ============================================================
  912. def plot_ci_four_panels(P):
  913. import numpy as np
  914. import matplotlib.pyplot as plt
  915. import matplotlib.lines as mlines
  916. plt.style.use("default")
  917. COL_NC = "#4c9ed9"
  918. COL_AE = "#f28e2b"
  919. COL_FIT = "#000000"
  920. COL_NORMAL = "#d62728" # red
  921. COL_DELTA = "#2ca02c" # green
  922. COL_NP = "#1f77b4" # blue
  923. COL_PB = "#17becf" # cyan
  924. FILL_NORMAL = "#d62728"
  925. FILL_DELTA = "#2ca02c"
  926. FILL_NP = "#1f77b4"
  927. FILL_PB = "#17becf"
  928. METHOD_ORDER = ["Normal", "Delta", "Nonparam", "Parametric"]
  929. panel_order = ["FULL-LOG", "FULL-RAW", "TRIM-LOG", "TRIM-RAW"]
  930. panel_letters = ["A", "B", "C", "D"]
  931. fig, axs = plt.subplots(2, 2, figsize=(15, 10), dpi=180, sharex="col", sharey=True)
  932. styles = {
  933. "Normal": (FILL_NORMAL, COL_NORMAL, "--"),
  934. "Delta": (FILL_DELTA, COL_DELTA, "-."),
  935. "Nonparam": (FILL_NP, COL_NP, ":"),
  936. "Parametric": (FILL_PB, COL_PB, (0, (6, 2))),
  937. }
  938. for ax, key, letter in zip(axs.flat, panel_order, panel_letters):
  939. pk = P[key]
  940. x_raw = np.asarray(pk["x_raw"], float)
  941. y = np.asarray(pk["y"], int)
  942. xg_raw = np.asarray(pk["x_grid_raw"], float)
  943. xg_model = np.asarray(pk["x_grid_model"], float)
  944. bands = pk["bands"]
  945. transform = pk["transform"]
  946. if transform == "raw":
  947. xs = x_raw
  948. xg = xg_raw
  949. else:
  950. xs = np.log(x_raw)
  951. xg = xg_model
  952. rng = np.random.default_rng(123 + ord(letter))
  953. jit = (rng.random(len(y)) - 0.5) * 0.04
  954. ax.scatter(xs[y == 0], (y + jit)[y == 0],
  955. s=22, alpha=0.45, color=COL_NC,
  956. edgecolors="none", zorder=5)
  957. ax.scatter(xs[y == 1], (y + jit)[y == 1],
  958. s=24, alpha=0.85, color=COL_AE,
  959. edgecolors="none", zorder=5)
  960. for method in METHOD_ORDER:
  961. lo, md, hi = bands[method]
  962. fill_c, edge_c, ls = styles[method]
  963. ax.fill_between(xg, lo, hi, color=fill_c, alpha=0.18, zorder=1)
  964. ax.plot(xg, lo, color=edge_c, linestyle=ls, lw=1.8, zorder=2)
  965. ax.plot(xg, hi, color=edge_c, linestyle=ls, lw=1.8, zorder=2)
  966. fit_curve = model_p(xg_model, pk["b"])
  967. ax.plot(xg, fit_curve, color=COL_FIT, lw=2.5, zorder=6)
  968. ax.text(0.03, 0.95, letter,
  969. transform=ax.transAxes,
  970. fontsize=15, ha="left", va="top")
  971. ax.set_ylim(-0.05, 1.05)
  972. ax.grid(False)
  973. ax.tick_params(axis="both", which="major",
  974. labelsize=11, length=4, width=0.8,
  975. direction="out")
  976. axs[0, 0].set_ylabel(r"$\mathrm{P(AE \mid X = x)}$", fontsize=13)
  977. axs[1, 0].set_ylabel(r"$\mathrm{P(AE \mid X = x)}$", fontsize=13)
  978. axs[1, 0].set_xlabel(r"$\log(\mathrm{X})$", fontsize=13)
  979. axs[1, 1].set_xlabel(r"$\mathrm{X}$", fontsize=13)
  980. for ax in axs[0, :]:
  981. ax.tick_params(axis="x", which="both", labelbottom=False)
  982. for ax in axs[:, 1]:
  983. ax.tick_params(axis="y", which="both", labelleft=False)
  984. axD = axs[1, 1]
  985. legend_handles = [
  986. mlines.Line2D([], [], marker="o", color=COL_NC,
  987. linestyle="None", markersize=8, label="data: NC"),
  988. mlines.Line2D([], [], marker="o", color=COL_AE,
  989. linestyle="None", markersize=8, label="data: AE"),
  990. mlines.Line2D([], [], color="black", lw=2.5, label="fit"),
  991. mlines.Line2D([], [], color=COL_NORMAL, lw=2, linestyle="--", label="CI: normal 95%"),
  992. mlines.Line2D([], [], color=COL_DELTA, lw=2, linestyle="-.", label="CI: delta 95%"),
  993. mlines.Line2D([], [], color=COL_NP, lw=2, linestyle=":", label="CI: nonparam_boots 95%"),
  994. mlines.Line2D([], [], color=COL_PB, lw=2, linestyle=(0, (6, 2)), label="CI: parametric_boots 95%"),
  995. ]
  996. leg = axD.legend(handles=legend_handles, loc="lower right",
  997. bbox_to_anchor=(0.95, 0.05),
  998. fontsize=10, frameon=True)
  999. frame = leg.get_frame()
  1000. frame.set_facecolor("white")
  1001. frame.set_edgecolor("#bdbdbd")
  1002. frame.set_linewidth(0.8)
  1003. fig.subplots_adjust(left=0.08, right=0.98,
  1004. bottom=0.08, top=0.98,
  1005. wspace=0.06, hspace=0.06)
  1006. return fig, axs
  1007. # ============================================================
  1008. # ELASTICITY ANALYSIS (x50 and s50)
  1009. # ============================================================
  1010. import numpy as np
  1011. import matplotlib.pyplot as plt
  1012. # ------------------------------------------------------------
  1013. # Core elasticity computation
  1014. # ------------------------------------------------------------
  1015. def elasticity_x50_s50(theta, mode="raw"):
  1016. """
  1017. Elasticity for x50 and s50 with respect to theta0 and theta1.
  1018. mode
  1019. ----
  1020. 'raw' : eta = theta0 + theta1*x
  1021. 'log' : eta = theta0 + theta1*log(x)
  1022. Returns
  1023. -------
  1024. dict with:
  1025. theta0, theta1,
  1026. x50, s50,
  1027. E_x50_theta0, E_x50_theta1,
  1028. E_s50_theta0, E_s50_theta1
  1029. """
  1030. theta0, theta1 = map(float, np.asarray(theta).reshape(2))
  1031. if np.abs(theta1) < 1e-12:
  1032. return dict(
  1033. theta0=theta0,
  1034. theta1=theta1,
  1035. x50=np.nan,
  1036. s50=np.nan,
  1037. E_x50_theta0=np.nan,
  1038. E_x50_theta1=np.nan,
  1039. E_s50_theta0=np.nan,
  1040. E_s50_theta1=np.nan,
  1041. )
  1042. # =========================
  1043. # RAW MODEL
  1044. # =========================
  1045. if mode == "raw":
  1046. x50 = -theta0 / theta1
  1047. s50 = theta1 / 4.0
  1048. E_x50_theta0 = 1.0
  1049. E_x50_theta1 = -1.0
  1050. E_s50_theta0 = 0.0
  1051. E_s50_theta1 = 1.0
  1052. # =========================
  1053. # LOG MODEL
  1054. # =========================
  1055. elif mode == "log":
  1056. x50 = float(np.exp(-theta0 / theta1))
  1057. s50 = theta1 / (4.0 * x50)
  1058. E_x50_theta0 = -theta0 / theta1
  1059. E_x50_theta1 = theta0 / theta1
  1060. E_s50_theta0 = -E_x50_theta0
  1061. E_s50_theta1 = 1.0 - E_x50_theta1
  1062. else:
  1063. raise ValueError("mode must be 'raw' or 'log'")
  1064. return dict(
  1065. theta0=theta0,
  1066. theta1=theta1,
  1067. x50=x50,
  1068. s50=s50,
  1069. E_x50_theta0=E_x50_theta0,
  1070. E_x50_theta1=E_x50_theta1,
  1071. E_s50_theta0=E_s50_theta0,
  1072. E_s50_theta1=E_s50_theta1,
  1073. )
  1074. # ------------------------------------------------------------
  1075. # Table for 4 panels
  1076. # ------------------------------------------------------------
  1077. def elasticity_table_4panels(
  1078. P,
  1079. keys=("FULL-RAW", "FULL-LOG", "TRIM-RAW", "TRIM-LOG"),
  1080. make_plots=True
  1081. ):
  1082. """
  1083. Build elasticity table for all panels.
  1084. Notes
  1085. -----
  1086. This expects each P[key] entry to contain:
  1087. - "theta" : fitted parameter vector [theta0, theta1]
  1088. - "transform" : "raw" or "log"
  1089. """
  1090. import pandas as pd
  1091. rows = []
  1092. for key in keys:
  1093. pk = P[key]
  1094. theta = np.asarray(pk["theta"]).reshape(2)
  1095. transform = pk["transform"]
  1096. res = elasticity_x50_s50(theta, mode=transform)
  1097. rows.append({
  1098. "Panel": key,
  1099. "transform": transform,
  1100. **res
  1101. })
  1102. df = pd.DataFrame(rows)
  1103. if make_plots:
  1104. plot_x50_values(df)
  1105. plot_s50_values(df)
  1106. plot_x50_theta1_elasticity(df)
  1107. plot_s50_theta1_elasticity(df)
  1108. return df
  1109. # ------------------------------------------------------------
  1110. # Plots
  1111. # ------------------------------------------------------------
  1112. def plot_x50_values(df):
  1113. fig, ax = plt.subplots(figsize=(7, 4))
  1114. ax.bar(df["Panel"], df["x50"])
  1115. ax.set_ylabel("x50")
  1116. ax.set_title("x50 across panels")
  1117. plt.xticks(rotation=30)
  1118. plt.tight_layout()
  1119. plt.show()
  1120. def plot_s50_values(df):
  1121. fig, ax = plt.subplots(figsize=(7, 4))
  1122. ax.bar(df["Panel"], df["s50"])
  1123. ax.set_ylabel("s50")
  1124. ax.set_title("s50 across panels")
  1125. plt.xticks(rotation=30)
  1126. plt.tight_layout()
  1127. plt.show()
  1128. def plot_x50_theta1_elasticity(df):
  1129. fig, ax = plt.subplots(figsize=(7, 4))
  1130. ax.bar(df["Panel"], df["E_x50_theta1"])
  1131. ax.set_ylabel("Elasticity")
  1132. ax.set_title("Elasticity of x50 w.r.t. theta1")
  1133. plt.xticks(rotation=30)
  1134. plt.tight_layout()
  1135. plt.show()
  1136. def plot_s50_theta1_elasticity(df):
  1137. fig, ax = plt.subplots(figsize=(7, 4))
  1138. ax.bar(df["Panel"], df["E_s50_theta1"])
  1139. ax.set_ylabel("Elasticity")
  1140. ax.set_title("Elasticity of s50 w.r.t. theta1")
  1141. plt.xticks(rotation=30)
  1142. plt.tight_layout()
  1143. plt.show()
  1144. # ------------------------------------------------------------
  1145. # logistic helpers for noise analysis
  1146. # ------------------------------------------------------------
  1147. # ============================================================
  1148. # NOISE ANALYSIS FOR LOGISTIC MODEL
  1149. # Correct x50 for RAW and LOG models
  1150. # ============================================================
  1151. import os
  1152. import numpy as np
  1153. import matplotlib.pyplot as plt
  1154. import matplotlib.lines as mlines
  1155. # ------------------------------------------------------------
  1156. # logistic fit / prediction / x50
  1157. # CONSISTENT WITH MAIN LOGISTIC ANALYSIS
  1158. # ------------------------------------------------------------
  1159. def fit_logistic_x(x_raw, y, transform="raw", l2=1e-8):
  1160. x_raw = np.clip(np.asarray(x_raw, float).ravel(), 1e-12, None)
  1161. y = np.asarray(y, int).ravel()
  1162. if transform == "raw":
  1163. x_model = x_raw
  1164. elif transform == "log":
  1165. x_model = np.log(x_raw)
  1166. else:
  1167. raise ValueError("transform must be 'raw' or 'log'")
  1168. return fit_newton(x_model, y, l2=l2)
  1169. def predict_curve_x(b, x_grid_raw, transform="raw"):
  1170. x_grid_raw = np.clip(np.asarray(x_grid_raw, float), 1e-12, None)
  1171. if transform == "raw":
  1172. x_model = x_grid_raw
  1173. elif transform == "log":
  1174. x_model = np.log(x_grid_raw)
  1175. else:
  1176. raise ValueError("transform must be 'raw' or 'log'")
  1177. return model_p(x_model, b)
  1178. def x50_from_b(b, transform="raw"):
  1179. b = np.asarray(b, float).reshape(2)
  1180. x50_model = x50(b)
  1181. if not np.isfinite(x50_model):
  1182. return np.nan
  1183. if transform == "raw":
  1184. return float(x50_model)
  1185. elif transform == "log":
  1186. return float(np.exp(x50_model))
  1187. else:
  1188. raise ValueError("transform must be 'raw' or 'log'")
  1189. def check_noise_x50(pack):
  1190. b = np.asarray(pack["b_clean"], float).reshape(2)
  1191. transform = pack["transform"]
  1192. x50_raw = pack["x50"]
  1193. x50_model = x50_raw if transform == "raw" else np.log(x50_raw)
  1194. p50 = model_p(np.array([x50_model]), b)[0]
  1195. print(
  1196. "transform =", transform,
  1197. "| x50_raw =", x50_raw,
  1198. "| P(x50) =", p50
  1199. )
  1200. # ------------------------------------------------------------
  1201. # noise helpers
  1202. # ------------------------------------------------------------
  1203. def add_noise_mult(x, sigma, rng):
  1204. x = np.asarray(x, float)
  1205. return np.clip(x * np.exp(rng.normal(0, sigma, size=x.shape)), 1e-12, None)
  1206. def add_noise_add(x, sigma, rng):
  1207. x = np.asarray(x, float)
  1208. return np.clip(x + rng.normal(0, sigma, size=x.shape), 1e-12, None)
  1209. def band_quantiles(curves):
  1210. C = np.vstack(curves)
  1211. return np.quantile(C, [0.025, 0.5, 0.975], axis=0)
  1212. # ------------------------------------------------------------
  1213. # build noise bands
  1214. # ------------------------------------------------------------
  1215. def noise_logistic_bands(
  1216. x_raw,
  1217. y,
  1218. transform="raw",
  1219. sigma_mult=0.129,
  1220. sigma_add=0.144,
  1221. x_max=5,
  1222. grid_n=1000,
  1223. n_refit=200,
  1224. n_tta=3000,
  1225. seed=1234,
  1226. l2=1e-8,
  1227. ):
  1228. rng = np.random.default_rng(seed)
  1229. x_raw = np.clip(np.asarray(x_raw, float).ravel(), 1e-12, None)
  1230. y = np.asarray(y).astype(int).ravel()
  1231. xc = np.linspace(1e-12, x_max, grid_n)
  1232. b_clean = fit_logistic_x(x_raw, y, transform=transform, l2=l2)
  1233. clean = predict_curve_x(b_clean, xc, transform=transform)
  1234. x50_val = x50_from_b(b_clean, transform=transform)
  1235. curves = []
  1236. for _ in range(n_refit):
  1237. xn = add_noise_mult(x_raw, sigma_mult, rng)
  1238. bn = fit_logistic_x(xn, y, transform=transform, l2=l2)
  1239. curves.append(predict_curve_x(bn, xc, transform=transform))
  1240. mult_refit = band_quantiles(curves)
  1241. curves = []
  1242. for _ in range(n_tta):
  1243. xn = add_noise_mult(xc, sigma_mult, rng)
  1244. curves.append(predict_curve_x(b_clean, xn, transform=transform))
  1245. mult_tta = band_quantiles(curves)
  1246. curves = []
  1247. for _ in range(n_refit):
  1248. xn = add_noise_add(x_raw, sigma_add, rng)
  1249. bn = fit_logistic_x(xn, y, transform=transform, l2=l2)
  1250. curves.append(predict_curve_x(bn, xc, transform=transform))
  1251. add_refit = band_quantiles(curves)
  1252. curves = []
  1253. for _ in range(n_tta):
  1254. xn = add_noise_add(xc, sigma_add, rng)
  1255. curves.append(predict_curve_x(b_clean, xn, transform=transform))
  1256. add_tta = band_quantiles(curves)
  1257. return {
  1258. "xc": xc,
  1259. "clean": clean,
  1260. "x50": x50_val,
  1261. "b_clean": b_clean,
  1262. "transform": transform,
  1263. "l2": float(l2),
  1264. "mult_refit": mult_refit,
  1265. "mult_tta": mult_tta,
  1266. "add_refit": add_refit,
  1267. "add_tta": add_tta,
  1268. }
  1269. from scipy.ndimage import gaussian_filter1d
  1270. lo = np.quantile(curves, 0.025, axis=0)
  1271. md = np.quantile(curves, 0.500, axis=0)
  1272. hi = np.quantile(curves, 0.975, axis=0)
  1273. # smooth boundaries
  1274. lo = gaussian_filter1d(lo, sigma=8)
  1275. md = gaussian_filter1d(md, sigma=8)
  1276. hi = gaussian_filter1d(hi, sigma=8)
  1277. return lo, md, hi
  1278. # ------------------------------------------------------------
  1279. # legend
  1280. # ------------------------------------------------------------
  1281. def noise_legend_handles():
  1282. return [
  1283. mlines.Line2D([], [], marker="o", color="#2b8cbe",
  1284. linestyle="None", markersize=7, label="NC data"),
  1285. mlines.Line2D([], [], marker="o", color="#d7301f",
  1286. linestyle="None", markersize=7, label="AE data"),
  1287. mlines.Line2D([], [], color="black", lw=2.2, label="clean fit"),
  1288. mlines.Line2D([], [], color="#1f78b4", lw=6, alpha=0.24,
  1289. label="refit band, multiplicative noise"),
  1290. mlines.Line2D([], [], color="#1f78b4", lw=6, alpha=0.10,
  1291. label="TTA band, multiplicative noise"),
  1292. mlines.Line2D([], [], color="#e66101", lw=6, alpha=0.24,
  1293. label="refit band, additive noise"),
  1294. mlines.Line2D([], [], color="#e66101", lw=6, alpha=0.10,
  1295. label="TTA band, additive noise"),
  1296. mlines.Line2D([], [], color="#666666", ls="--", lw=1.2,
  1297. label=r"$x_{50}$"),
  1298. ]
  1299. # ------------------------------------------------------------
  1300. # plot one panel
  1301. # ------------------------------------------------------------
  1302. def plot_noise_panel(ax, pack, kind="mult", label="A", X=None, y=None):
  1303. COL_MULT = "#1f78b4"
  1304. COL_ADD = "#e66101"
  1305. COL_NC = "#2b8cbe"
  1306. COL_AE = "#d7301f"
  1307. xc = pack["xc"]
  1308. clean = pack["clean"]
  1309. x50_val = pack["x50"]
  1310. if kind == "mult":
  1311. refit = pack["mult_refit"]
  1312. tta = pack["mult_tta"]
  1313. color = COL_MULT
  1314. elif kind == "add":
  1315. refit = pack["add_refit"]
  1316. tta = pack["add_tta"]
  1317. color = COL_ADD
  1318. else:
  1319. raise ValueError("kind must be 'mult' or 'add'")
  1320. lo_r, _, hi_r = refit
  1321. lo_t, _, hi_t = tta
  1322. ax.fill_between(xc, lo_t, hi_t, color=color, alpha=0.10, zorder=1)
  1323. ax.fill_between(xc, lo_r, hi_r, color=color, alpha=0.24, zorder=2)
  1324. ax.plot(xc, lo_r, color=color, lw=1.0, alpha=0.65, zorder=3)
  1325. ax.plot(xc, hi_r, color=color, lw=1.0, alpha=0.65, zorder=3)
  1326. ax.plot(xc, clean, color="black", lw=2.2, zorder=5)
  1327. ax.axvline(x50_val, color="#666666", ls="--", lw=1.2, alpha=0.9, zorder=4)
  1328. lo_r_x = np.interp(x50_val, xc, lo_r)
  1329. hi_r_x = np.interp(x50_val, xc, hi_r)
  1330. lo_t_x = np.interp(x50_val, xc, lo_t)
  1331. hi_t_x = np.interp(x50_val, xc, hi_t)
  1332. if X is not None and y is not None:
  1333. X = np.asarray(X).ravel()
  1334. y = np.asarray(y).astype(int).ravel()
  1335. ax.scatter(
  1336. X[y == 0], np.zeros(np.sum(y == 0)),
  1337. color=COL_NC, s=24, alpha=0.75,
  1338. edgecolors="none", zorder=7
  1339. )
  1340. ax.scatter(
  1341. X[y == 1], np.ones(np.sum(y == 1)),
  1342. color=COL_AE, s=24, alpha=0.75,
  1343. edgecolors="none", zorder=7
  1344. )
  1345. ax.text(0.03, 0.97, label, transform=ax.transAxes,
  1346. ha="left", va="top", fontsize=15)
  1347. variant_txt = "FULL" if label in ["A", "B"] else "TRIM"
  1348. d_ref = hi_r_x - lo_r_x
  1349. d_tta = hi_t_x - lo_t_x
  1350. info_txt = (
  1351. f"{variant_txt}\n"
  1352. f"$x_{{50}}$={x50_val:.2f}\n"
  1353. f"$\\Delta r$={d_ref:.2f} $\\Delta t$={d_tta:.2f}"
  1354. )
  1355. ax.text(
  1356. 0.02, 0.14,
  1357. info_txt,
  1358. transform=ax.transAxes,
  1359. fontsize=10,
  1360. color="#222",
  1361. ha="left", va="bottom",
  1362. bbox=dict(facecolor="white", edgecolor=color,
  1363. boxstyle="square,pad=0.25", alpha=0.9)
  1364. )
  1365. ax.set_xlim(0, xc.max())
  1366. ax.set_ylim(-0.05, 1.05)
  1367. ax.grid(alpha=0.25)
  1368. ax.tick_params(axis="both", labelsize=10)
  1369. # ------------------------------------------------------------
  1370. # full noise figure
  1371. # ------------------------------------------------------------
  1372. def plot_noise_figure(
  1373. pack_full,
  1374. pack_trim,
  1375. X_full,
  1376. y_full,
  1377. X_trim,
  1378. y_trim,
  1379. figsize=(12, 9),
  1380. dpi=300,
  1381. ):
  1382. fig, axes = plt.subplots(
  1383. 2, 2,
  1384. figsize=figsize,
  1385. dpi=dpi,
  1386. sharex=True,
  1387. sharey=True
  1388. )
  1389. axes = axes.ravel()
  1390. plot_noise_panel(axes[0], pack_full, kind="mult", label="A", X=X_full, y=y_full)
  1391. plot_noise_panel(axes[1], pack_full, kind="add", label="B", X=X_full, y=y_full)
  1392. plot_noise_panel(axes[2], pack_trim, kind="mult", label="C", X=X_trim, y=y_trim)
  1393. plot_noise_panel(axes[3], pack_trim, kind="add", label="D", X=X_trim, y=y_trim)
  1394. axes[0].set_ylabel(r"$P(\mathrm{AE}\mid X=x)$", fontsize=12)
  1395. axes[2].set_ylabel(r"$P(\mathrm{AE}\mid X=x)$", fontsize=12)
  1396. axes[2].set_xlabel(r"$X$", fontsize=12)
  1397. axes[3].set_xlabel(r"$X$", fontsize=12)
  1398. handles = noise_legend_handles()
  1399. leg = axes[3].legend(
  1400. handles=handles,
  1401. loc="lower right",
  1402. bbox_to_anchor=(0.97, 0.05),
  1403. fontsize=9,
  1404. frameon=True
  1405. )
  1406. frame = leg.get_frame()
  1407. frame.set_facecolor("white")
  1408. frame.set_edgecolor("#bdbdbd")
  1409. frame.set_linewidth(0.8)
  1410. fig.tight_layout()
  1411. return fig, axes
  1412. # ------------------------------------------------------------
  1413. # wrapper
  1414. # ------------------------------------------------------------
  1415. def make_noise_figure(
  1416. X_full,
  1417. y_full,
  1418. X_trim,
  1419. y_trim,
  1420. transform="raw",
  1421. sigma_mult=0.129,
  1422. sigma_add=0.144,
  1423. x_max=5,
  1424. grid_n=1000,
  1425. n_refit=1100,
  1426. n_tta=10000,
  1427. seed=1234,
  1428. l2=1e-8,
  1429. save_path=None,
  1430. ):
  1431. pack_full = noise_logistic_bands(
  1432. X_full, y_full,
  1433. transform=transform,
  1434. sigma_mult=sigma_mult,
  1435. sigma_add=sigma_add,
  1436. x_max=x_max,
  1437. grid_n=grid_n,
  1438. n_refit=n_refit,
  1439. n_tta=n_tta,
  1440. seed=seed,
  1441. l2=l2,
  1442. )
  1443. pack_trim = noise_logistic_bands(
  1444. X_trim, y_trim,
  1445. transform=transform,
  1446. sigma_mult=sigma_mult,
  1447. sigma_add=sigma_add,
  1448. x_max=x_max,
  1449. grid_n=grid_n,
  1450. n_refit=n_refit,
  1451. n_tta=n_tta,
  1452. seed=seed + 100,
  1453. l2=l2,
  1454. )
  1455. print("FULL n:", len(X_full), "x50:", pack_full["x50"])
  1456. print("TRIM n:", len(X_trim), "x50:", pack_trim["x50"])
  1457. check_noise_x50(pack_full)
  1458. check_noise_x50(pack_trim)
  1459. fig, axes = plot_noise_figure(
  1460. pack_full, pack_trim,
  1461. X_full, y_full,
  1462. X_trim, y_trim,
  1463. figsize=(12, 9),
  1464. dpi=300,
  1465. )
  1466. if save_path is not None:
  1467. folder = os.path.dirname(save_path)
  1468. if folder:
  1469. os.makedirs(folder, exist_ok=True)
  1470. fig.savefig(f"{save_path}.png", dpi=300, bbox_inches="tight")
  1471. fig.savefig(f"{save_path}.pdf", bbox_inches="tight")
  1472. return fig, axes, pack_full, pack_trim