import numpy as np # ============================================================ # Stable sigmoid # ============================================================ def _sigmoid_stable(z): z = np.asarray(z, float) z = np.clip(z, -50.0, 50.0) return 1.0 / (1.0 + np.exp(-z)) # ============================================================ # 1) Model # ============================================================ def model_p(x, b): """p(x|b) = sigmoid(b0 + b1*x).""" x = np.asarray(x, float).reshape(-1) b0, b1 = np.asarray(b, float).reshape(2) return _sigmoid_stable(b0 + b1 * x) def design_matrix(x): """Design matrix X = [1, x].""" x = np.asarray(x, float).reshape(-1) return np.column_stack([np.ones_like(x), x]) # ============================================================ # 2) Likelihood # ============================================================ def nll(x, y, b, l2=0.0): """ Penalized negative log-likelihood: NLL(b) = -sum[y log p + (1-y) log(1-p)] + 0.5*l2*||b||^2 """ x = np.asarray(x, float).reshape(-1) y = np.asarray(y, float).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x, b) eps = 1e-12 p = np.clip(p, eps, 1 - eps) base = -np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)) pen = 0.5 * l2 * float(np.dot(b, b)) return base + pen def llf(x, y, b): """ Ordinary (unpenalized) log-likelihood at fitted parameters. """ x = np.asarray(x, float).reshape(-1) y = np.asarray(y, float).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x, b) eps = 1e-12 p = np.clip(p, eps, 1 - eps) return float(np.sum(y * np.log(p) + (1 - y) * np.log(1 - p))) # ============================================================ # 3) Gradient / Hessian / Covariance # ============================================================ def grad_nll(x, y, b, l2=0.0): """ Gradient of penalized NLL: g(b) = X^T (p - y) + l2*b """ X = design_matrix(x) y = np.asarray(y, float).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x, b) return X.T @ (p - y) + l2 * b def hess_nll(x, b, l2=0.0): """ Hessian of penalized NLL: H(b) = X^T W X + l2*I W = diag(p*(1-p)) """ X = design_matrix(x) b = np.asarray(b, float).reshape(2) p = model_p(x, b) w = p * (1 - p) return X.T @ (w[:, None] * X) + l2 * np.eye(2) def covariance(x, b, l2=0.0): """ Cov(b) ≈ H(b)^(-1). Robust to near-singular Hessians. """ H = hess_nll(x, b, l2=l2) try: return np.linalg.inv(H) except np.linalg.LinAlgError: return np.linalg.pinv(H) def standard_errors(x, b, l2=0.0): """ SE = sqrt(diag(Cov)). """ C = covariance(x, b, l2=l2) return np.sqrt(np.maximum(np.diag(C), 0.0)) # Compatibility alias def logit_poly_cov(x, b, l2=0.0): return covariance(x, b, l2=l2) # ============================================================ # 4) Fit # ============================================================ def fit_newton(x, y, b_start=None, max_iter=50, tol=1e-8, l2=0.0): """ Newton updates for penalized NLL with backtracking line-search. Update: b_new = b - alpha * H^{-1} g alpha shrinks until NLL decreases. """ x = np.asarray(x, float).reshape(-1) y = np.asarray(y, int).reshape(-1) if b_start is None: b = np.array([0.0, 0.0], float) else: b = np.asarray(b_start, float).reshape(2) f = nll(x, y, b, l2=l2) for _ in range(max_iter): g = grad_nll(x, y, b, l2=l2) H = hess_nll(x, b, l2=l2) try: step = np.linalg.solve(H, g) except np.linalg.LinAlgError: step = np.linalg.pinv(H) @ g alpha = 1.0 while alpha > 1e-6: b_new = b - alpha * step f_new = nll(x, y, b_new, l2=l2) if np.isfinite(f_new) and f_new <= f: break alpha *= 0.5 if alpha <= 1e-6: break if np.max(np.abs(b_new - b)) < tol: b = b_new break b, f = b_new, f_new return b # ============================================================ # 5) Goodness of fit # ============================================================ def goodness_of_fit(x, y, b, thresh=0.5, l2=0.0): """ Returns: LLF, NLL, AIC, BIC, Accuracy, n, k Note: Fit may use l2 > 0, but GOF metrics below are computed from the ordinary (unpenalized) likelihood at the fitted parameters. """ x = np.asarray(x, float).reshape(-1) y = np.asarray(y, int).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x, b) eps = 1e-12 p = np.clip(p, eps, 1 - eps) LLF = np.sum(y * np.log(p) + (1 - y) * np.log(1 - p)) NLL = -LLF n = len(x) k = len(b) AIC = 2 * k - 2 * LLF BIC = k * np.log(n) - 2 * LLF yhat = (p >= thresh).astype(int) acc = np.mean(yhat == y) return {"LLF": LLF, "NLL": NLL, "AIC": AIC, "BIC": BIC, "A": acc, "n": n, "k": k} # ============================================================ # 6) x50 / Wald helpers / compact fit # ============================================================ def x50(b): """ Model-scale midpoint: x50 = -b0 / b1 For LOG panels, this is on the log(x) scale. Raw-scale SUV50 is exp(x50). """ b0, b1 = np.asarray(b, float).reshape(2) return np.nan if np.abs(b1) < 1e-12 else (-b0 / b1) def x50_wald_ci(b, cov, z=1.959963984540054): """ Wald CI for x50 = -b0/b1 via delta method. Returned on MODEL scale. """ b = np.asarray(b, float).reshape(2) cov = np.asarray(cov, float).reshape(2, 2) b0, b1 = b if np.abs(b1) < 1e-12: return np.nan, np.nan xhat = -b0 / b1 grad = np.array([-1.0 / b1, b0 / (b1 ** 2)], float) var = float(grad.T @ cov @ grad) se = np.sqrt(max(var, 0.0)) return float(xhat - z * se), float(xhat + z * se) def wald_ci(b, cov, z=1.959963984540054): """ Wald CI for parameters: b_i ± z*SE_i. """ b = np.asarray(b, float).reshape(2) cov = np.asarray(cov, float).reshape(2, 2) se = np.sqrt(np.maximum(np.diag(cov), 0.0)) return b - z * se, b + z * se def fit_pack(x, y, name="", thresh=0.5, l2=0.0): """ Fit + covariance + GOF + parameter Wald CI. x should already be on the MODEL scale. """ x = np.asarray(x, float).reshape(-1) y = np.asarray(y, int).reshape(-1) b = fit_newton(x, y, l2=l2) cov = covariance(x, b, l2=l2) gof = goodness_of_fit(x, y, b, thresh=thresh, l2=l2) lcl, ucl = wald_ci(b, cov) return { "name": name, "x": x, "y": y, "b": b, "cov": cov, "gof": gof, "LCL": lcl, "UCL": ucl, "l2": float(l2), } def trim_nc_by_value(x_raw, y, target=2.48, tol=0.05): """ Remove ONE NC sample (y==0) with x_raw closest to target. """ x_raw = np.asarray(x_raw, float).reshape(-1) y = np.asarray(y, int).reshape(-1) nc_idx = np.where(y == 0)[0] if len(nc_idx) == 0: raise ValueError("No NC samples found (y==0).") j = nc_idx[np.argmin(np.abs(x_raw[nc_idx] - target))] diff = float(np.abs(x_raw[j] - target)) if diff > tol: print(f"[trim warning] closest NC to {target} is {x_raw[j]:.6f} (diff={diff:.6f}) > tol={tol}") mask = np.ones_like(y, dtype=bool) mask[j] = False print(f"[trim] removed index={j}, x_raw={x_raw[j]:.6f}, y={y[j]}") return x_raw[mask], y[mask] # ============================================================ # 7) Analytic CI bands on a grid # ============================================================ def eta_se_grid(x_grid, cov): """ Standard error of eta(x) = b0 + b1*x on a grid. x_grid must be on the MODEL scale. """ x_grid = np.asarray(x_grid, float).reshape(-1) cov = np.asarray(cov, float).reshape(2, 2) Xg = design_matrix(x_grid) var_eta = np.einsum("ij,jk,ik->i", Xg, cov, Xg) return np.sqrt(np.maximum(var_eta, 0.0)) def ci_band_normal(x_grid, b, cov, z=1.959963984540054): """ Normal-on-eta CI band: eta ± z*SE(eta), then transform with sigmoid. Returns: lo, mid, hi """ x_grid = np.asarray(x_grid, float).reshape(-1) b = np.asarray(b, float).reshape(2) eta = b[0] + b[1] * x_grid se_eta = eta_se_grid(x_grid, cov) lo = _sigmoid_stable(eta - z * se_eta) md = _sigmoid_stable(eta) hi = _sigmoid_stable(eta + z * se_eta) return lo, md, hi def ci_band_delta(x_grid, b, cov, z=1.959963984540054): """ Delta-method CI band on probability scale: p(x) ± z * SE_p(x) where SE_p = p(1-p) * SE_eta Returns: lo, mid, hi """ x_grid = np.asarray(x_grid, float).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x_grid, b) se_eta = eta_se_grid(x_grid, cov) se_p = p * (1.0 - p) * se_eta lo = np.clip(p - z * se_p, 0.0, 1.0) md = p hi = np.clip(p + z * se_p, 0.0, 1.0) return lo, md, hi # ============================================================ # 8) Bootstrap parameter generators # ============================================================ def bootstrap_params_stratified(x, y, B=2000, seed=123, l2=0.0, b_start=None): """ Stratified nonparametric bootstrap on MODEL-scale x. Preserves class counts exactly. Returns array of shape (n_ok, 2). """ rng = np.random.default_rng(seed) x = np.asarray(x, float).reshape(-1) y = np.asarray(y, int).reshape(-1) x0 = x[y == 0] x1 = x[y == 1] n0 = len(x0) n1 = len(x1) if n0 == 0 or n1 == 0: return np.empty((0, 2), float) out = [] for _ in range(B): xb0 = rng.choice(x0, size=n0, replace=True) xb1 = rng.choice(x1, size=n1, replace=True) xb = np.concatenate([xb0, xb1]) yb = np.concatenate([np.zeros(n0, dtype=int), np.ones(n1, dtype=int)]) try: bb = fit_newton(xb, yb, b_start=b_start, l2=l2) if np.all(np.isfinite(bb)): out.append(bb) except Exception: pass if len(out) == 0: return np.empty((0, 2), float) return np.asarray(out, float) def bootstrap_params_parametric(x, b, B=2000, seed=123, l2=0.0, min_ae=2): """ Parametric bootstrap on MODEL-scale x. Simulates y* ~ Bernoulli(p_hat(x)). Keeps only samples with at least min_ae positives and at least one negative. Returns array of shape (n_ok, 2). """ rng = np.random.default_rng(seed) x = np.asarray(x, float).reshape(-1) b = np.asarray(b, float).reshape(2) p = model_p(x, b) n = len(x) out = [] tries = 0 max_tries = max(10 * B, 1000) while len(out) < B and tries < max_tries: tries += 1 yb = rng.binomial(1, p, size=n).astype(int) n1 = int(np.sum(yb)) n0 = n - n1 if n1 < min_ae or n0 < 1: continue try: bb = fit_newton(x, yb, b_start=b, l2=l2) if np.all(np.isfinite(bb)): out.append(bb) except Exception: pass if len(out) == 0: return np.empty((0, 2), float) return np.asarray(out, float) # ============================================================ # 9) Convert bootstrap parameters to curve bands # ============================================================ def bootstrap_band_from_params(x_grid, pars, alpha=0.05): """ Build bootstrap CI band from bootstrap parameter draws. x_grid is on MODEL scale. Returns: lo, mid, hi """ x_grid = np.asarray(x_grid, float).reshape(-1) pars = np.asarray(pars, float) if pars.ndim != 2 or pars.shape[0] == 0: nan = np.full_like(x_grid, np.nan, dtype=float) return nan, nan, nan curves = np.array([model_p(x_grid, bb) for bb in pars], float) q = np.quantile(curves, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0) return q[0], q[1], q[2] # ============================================================ # 10) High-level wrapper for one panel # ============================================================ def fit_ci_pack_rawgrid( x_raw, y, transform="raw", xmax_raw=None, grid_n=500, name="", l2=0.0, B=2000, seed=123, min_ae=2, z=1.959963984540054, ): """ Fit one panel and return everything needed for plots/tables. """ x_raw = np.asarray(x_raw, float).reshape(-1) y = np.asarray(y, int).reshape(-1) if transform not in ("raw", "log"): raise ValueError("transform must be 'raw' or 'log'") x_raw = np.clip(x_raw, 1e-12, None) x_model = x_raw if transform == "raw" else np.log(x_raw) b = fit_newton(x_model, y, l2=l2) cov = covariance(x_model, b, l2=l2) gof = goodness_of_fit(x_model, y, b, l2=l2) xmin_raw = float(np.min(x_raw)) xmax0 = float(np.max(x_raw)) xmax_use = xmax0 if xmax_raw is None else max(float(xmax_raw), xmax0) x_grid_raw = np.linspace(xmin_raw, xmax_use, int(grid_n)) x_grid_model = x_grid_raw if transform == "raw" else np.log(x_grid_raw) lo_n, md_n, hi_n = ci_band_normal(x_grid_model, b, cov, z=z) lo_d, md_d, hi_d = ci_band_delta(x_grid_model, b, cov, z=z) pars_np = bootstrap_params_stratified( x_model, y, B=B, seed=seed + 1, l2=l2, b_start=b ) pars_pm = bootstrap_params_parametric( x_model, b, B=B, seed=seed + 2, l2=l2, min_ae=min_ae ) lo_np, md_np, hi_np = bootstrap_band_from_params(x_grid_model, pars_np) lo_pm, md_pm, hi_pm = bootstrap_band_from_params(x_grid_model, pars_pm) return { "name": name, "transform": transform, "l2": float(l2), "x_raw": x_raw, "x_model": x_model, "y": y, "x_grid_raw": x_grid_raw, "x_grid_model": x_grid_model, "b": b, "cov": cov, "gof": gof, "bands": { "Normal": (lo_n, md_n, hi_n), "Delta": (lo_d, md_d, hi_d), "Nonparam": (lo_np, md_np, hi_np), "Parametric": (lo_pm, md_pm, hi_pm), }, "pars_nonparam": pars_np, "pars_parametric": pars_pm, } # ============================================================ # 11) Model-band table with LL / UL # ============================================================ def model_ci_table_4methods( P, keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"), ): """ Long table of model CI bands on the grid. Includes: x_grid_model, x_grid_raw, fit, LL, UL """ import pandas as pd rows = [] for key in keys: pk = P[key] xg_raw = np.asarray(pk["x_grid_raw"], float) xg_mod = np.asarray(pk["x_grid_model"], float) trans = pk.get("transform", "") bands = pk["bands"] for method, (lo, md, hi) in bands.items(): lo = np.asarray(lo, float) md = np.asarray(md, float) hi = np.asarray(hi, float) for i in range(len(xg_raw)): rows.append({ "Panel": key, "Method": method, "transform": trans, "x_grid_raw": float(xg_raw[i]), "x_grid_model": float(xg_mod[i]), "fit": float(md[i]), "LL": float(lo[i]), "UL": float(hi[i]), }) return pd.DataFrame(rows) # ============================================================ # 12) Parameter/x50 CI summary table # ============================================================ def param_ci_table_4methods( P, keys=("FULL-RAW", "TRIM-RAW", "FULL-LOG", "TRIM-LOG"), z=1.959963984540054, include_point_est=True, ): """ Build tidy parameter/x50 CI table for: Normal, Delta, Nonparam, Parametric Notes ----- - For parameters (b0, b1), Normal and Delta are the same analytic CI here. - x50 is on MODEL scale. - SUV50 is on RAW scale: raw panel -> same as x50 log panel -> exp(x50) """ import pandas as pd def _boot_ci_from_pars(pars, alpha=0.05): if pars is None or len(pars) == 0: nan2 = (np.nan, np.nan) return nan2, nan2, nan2, 0 pars = np.asarray(pars, float) q = np.quantile(pars, [alpha / 2, 0.5, 1.0 - alpha / 2], axis=0) b0_ci = (float(q[0, 0]), float(q[2, 0])) b1_ci = (float(q[0, 1]), float(q[2, 1])) x50s = np.array([x50(bb) for bb in pars], float) x50s = x50s[np.isfinite(x50s)] if len(x50s) == 0: x50_ci = (np.nan, np.nan) else: xq = np.quantile(x50s, [alpha / 2, 1.0 - alpha / 2]) x50_ci = (float(xq[0]), float(xq[1])) return b0_ci, b1_ci, x50_ci, int(len(pars)) rows = [] for key in keys: pk = P[key] b = np.asarray(pk["b"], float).reshape(2) cov = np.asarray(pk["cov"], float).reshape(2, 2) trans = pk.get("transform", "") l2 = float(pk.get("l2", 0.0)) lcl, ucl = wald_ci(b, cov, z=z) x50_l, x50_u = x50_wald_ci(b, cov, z=z) x50_hat = x50(b) pars_np = pk.get("pars_nonparam", np.empty((0, 2))) pars_pm = pk.get("pars_parametric", np.empty((0, 2))) b0_np, b1_np, x50_np, n_np = _boot_ci_from_pars(pars_np) b0_pm, b1_pm, x50_pm, n_pm = _boot_ci_from_pars(pars_pm) def _to_suv50(x50_ci): lo, hi = x50_ci if trans == "log": return (float(np.exp(lo)), float(np.exp(hi))) return (float(lo), float(hi)) suv50_w = _to_suv50((x50_l, x50_u)) suv50_np = _to_suv50(x50_np) suv50_pm = _to_suv50(x50_pm) suv50_hat = float(np.exp(x50_hat)) if trans == "log" else float(x50_hat) def add_row(method, b0_ci, b1_ci, x50_ci, suv50_ci, B_used): row = { "Panel": key, "Method": method, "b0_LCL": float(b0_ci[0]), "b0_UCL": float(b0_ci[1]), "b1_LCL": float(b1_ci[0]), "b1_UCL": float(b1_ci[1]), "x50_LCL": float(x50_ci[0]), "x50_UCL": float(x50_ci[1]), "SUV50_LCL": float(suv50_ci[0]), "SUV50_UCL": float(suv50_ci[1]), "B_used": B_used, } if include_point_est: row.update({ "b0_hat": float(b[0]), "b1_hat": float(b[1]), "x50_hat": float(x50_hat), "SUV50_hat": float(suv50_hat), "transform": trans, "l2": l2, }) rows.append(row) add_row("Normal", (lcl[0], ucl[0]), (lcl[1], ucl[1]), (x50_l, x50_u), suv50_w, np.nan) add_row("Delta", (lcl[0], ucl[0]), (lcl[1], ucl[1]), (x50_l, x50_u), suv50_w, np.nan) add_row("Nonparam", b0_np, b1_np, x50_np, suv50_np, n_np) add_row("Parametric", b0_pm, b1_pm, x50_pm, suv50_pm, n_pm) return pd.DataFrame(rows) # ============================================================ # 13) Elasticity analysis # ============================================================ def elasticity_x50_slope(b, mode="raw"): """ Elasticities for x50 and slope@50 with respect to b0 and b1. mode = 'raw' : eta = b0 + b1*x mode = 'log' : eta = b0 + b1*log(x) Returns dict with: x50, slope50, E_x50_b0, E_x50_b1, E_s50_b0, E_s50_b1 """ b0, b1 = map(float, np.asarray(b, float).reshape(2)) out = {"b0": b0, "b1": b1, "mode": mode} if np.abs(b1) < 1e-12: out.update({ "x50": np.nan, "slope50": np.nan, "E_x50_b0": np.nan, "E_x50_b1": np.nan, "E_s50_b0": np.nan, "E_s50_b1": np.nan, }) return out if mode == "raw": # eta = b0 + b1*x x50 = -b0 / b1 slope50 = 0.25 * b1 # elasticities of x50 if np.abs(x50) < 1e-12: E_x50_b0 = np.nan E_x50_b1 = np.nan else: E_x50_b0 = 1.0 E_x50_b1 = -1.0 # slope50 = 0.25*b1 E_s50_b0 = 0.0 E_s50_b1 = 1.0 elif mode == "log": # eta = b0 + b1*log(x) x50 = float(np.exp(-b0 / b1)) slope50 = 0.25 * b1 / x50 # elasticities of x50 E_x50_b0 = -b0 / b1 E_x50_b1 = b0 / b1 # slope elasticity # slope50 = 0.25 * b1 * exp(b0/b1) if np.abs(slope50) < 1e-12: E_s50_b0 = np.nan E_s50_b1 = np.nan else: E_s50_b0 = b0 / b1 E_s50_b1 = 1.0 - (b0 / b1) else: raise ValueError("mode must be 'raw' or 'log'") out.update({ "x50": x50, "slope50": slope50, "E_x50_b0": E_x50_b0, "E_x50_b1": E_x50_b1, "E_s50_b0": E_s50_b0, "E_s50_b1": E_s50_b1, }) return out def elasticity_table_4panels( P, keys=("FULL-RAW", "FULL-LOG", "TRIM-RAW", "TRIM-LOG"), ): """ Build a tidy elasticity table for the 4 fitted panels. Returns columns: Panel, transform, b0, b1, x50, slope50, E_x50_b0, E_x50_b1, E_s50_b0, E_s50_b1 """ import pandas as pd rows = [] for key in keys: pk = P[key] b = np.asarray(pk["b"], float).reshape(2) transform = pk["transform"] res = elasticity_x50_slope(b, mode=transform) rows.append({ "Panel": key, "transform": transform, "b0": res["b0"], "b1": res["b1"], "x50": res["x50"], "slope50": res["slope50"], "E_x50_b0": res["E_x50_b0"], "E_x50_b1": res["E_x50_b1"], "E_s50_b0": res["E_s50_b0"], "E_s50_b1": res["E_s50_b1"], }) return pd.DataFrame(rows) # ============================================================ # 14. NOISE ANALYSIS (logistic trained on log(X)) # ============================================================ from scipy.optimize import minimize import matplotlib.pyplot as plt from matplotlib.patches import Patch # ------------------------------------------------------------ # logistic helpers # ------------------------------------------------------------ def logistic_sigmoid(t): t = np.clip(t, -60, 60) return 1.0 / (1.0 + np.exp(-t)) def fit_logistic_logx(x_raw, y): """ Fit logistic model p(y=1|x) = sigmoid(b0 + b1 log(x)) """ x = np.clip(np.asarray(x_raw).ravel(), 1e-12, None) y = np.asarray(y).astype(float) X = np.column_stack([np.ones_like(x), np.log(x)]) def nll(b): z = X @ b p = logistic_sigmoid(z) eps = 1e-12 ll = np.sum(y*np.log(p+eps) + (1-y)*np.log(1-p+eps)) return -ll res = minimize(nll, np.zeros(2), method="L-BFGS-B") if not res.success: raise RuntimeError("Logistic optimisation failed") return res.x def predict_curve_logx(b, x_grid): x = np.clip(np.asarray(x_grid), 1e-12, None) return logistic_sigmoid(b[0] + b[1]*np.log(x)) def x50_from_b(b): """ p(x)=0.5 -> b0 + b1 log(x50)=0 """ b0, b1 = b return float(np.exp(-b0/b1)) # ------------------------------------------------------------ # noise models # ------------------------------------------------------------ def add_noise_mult(x, sigma, rng): return np.clip(x*np.exp(rng.normal(0, sigma, size=x.shape)),1e-12,None) def add_noise_add(x, sigma, rng): return np.clip(x+rng.normal(0, sigma, size=x.shape),1e-12,None) # ------------------------------------------------------------ # band quantiles # ------------------------------------------------------------ def band_quantiles(curves): C=np.vstack(curves) return np.quantile(C,[0.025,0.5,0.975],axis=0) # ------------------------------------------------------------ # build noise bands # ------------------------------------------------------------ def noise_logistic_bands( x_raw, y, sigma_mult=0.129, sigma_add=0.144, x_max=5, grid_n=1000, n_refit=200, n_tta=500, seed=1234 ): rng=np.random.default_rng(seed) xc=np.linspace(0,x_max,grid_n) xc[0]=1e-12 b_clean=fit_logistic_logx(x_raw,y) clean=predict_curve_logx(b_clean,xc) x50=x50_from_b(b_clean) # ---------- multiplicative ---------- curves=[] for _ in range(n_refit): xn=add_noise_mult(x_raw,sigma_mult,rng) bn=fit_logistic_logx(xn,y) curves.append(predict_curve_logx(bn,xc)) mult_refit=band_quantiles(curves) curves=[] for _ in range(n_tta): xn=add_noise_mult(xc,sigma_mult,rng) curves.append(predict_curve_logx(b_clean,xn)) mult_tta=band_quantiles(curves) # ---------- additive ---------- curves=[] for _ in range(n_refit): xn=add_noise_add(x_raw,sigma_add,rng) bn=fit_logistic_logx(xn,y) curves.append(predict_curve_logx(bn,xc)) add_refit=band_quantiles(curves) curves=[] for _ in range(n_tta): xn=add_noise_add(xc,sigma_add,rng) curves.append(predict_curve_logx(b_clean,xn)) add_tta=band_quantiles(curves) return dict( xc=xc, clean=clean, x50=x50, mult_refit=mult_refit, mult_tta=mult_tta, add_refit=add_refit, add_tta=add_tta ) # ------------------------------------------------------------ # plotting # ------------------------------------------------------------ def plot_noise_panel(ax, pack, kind="mult", label="A", X=None, y=None): COL_MULT = "#1f78b4" COL_ADD = "#e66101" xc = pack["xc"] clean = pack["clean"] x50 = pack["x50"] if kind == "mult": refit = pack["mult_refit"] tta = pack["mult_tta"] color = COL_MULT else: refit = pack["add_refit"] tta = pack["add_tta"] color = COL_ADD lo_r, _, hi_r = refit lo_t, _, hi_t = tta # TTA band (lighter) ax.fill_between(xc, lo_t, hi_t, color=color, alpha=0.10, zorder=1) # refit band (stronger) ax.fill_between(xc, lo_r, hi_r, color=color, alpha=0.24, zorder=2) # optional thin outlines for readability ax.plot(xc, lo_r, color=color, lw=1.0, alpha=0.65, zorder=3) ax.plot(xc, hi_r, color=color, lw=1.0, alpha=0.65, zorder=3) # clean fit ax.plot(xc, clean, color="black", lw=2.5, zorder=5) # x50 reference line # x50 reference line ax.axvline(x50, color="#666666", ls="--", lw=1.4, alpha=0.9, zorder=4) # widths at x50 lo_r_x = np.interp(x50, xc, lo_r) hi_r_x = np.interp(x50, xc, hi_r) lo_t_x = np.interp(x50, xc, lo_t) hi_t_x = np.interp(x50, xc, hi_t) # data points if X is not None and y is not None: X = np.asarray(X).ravel() y = np.asarray(y).astype(int) ax.scatter( X[y == 0], np.zeros(np.sum(y == 0)), color="#2b8cbe", s=28, alpha=0.75, zorder=7 ) ax.scatter( X[y == 1], np.ones(np.sum(y == 1)), color="#d7301f", s=28, alpha=0.75, zorder=7 ) # panel label ax.text( 0.50, 1.01, label, transform=ax.transAxes, ha="center", va="bottom", fontsize=18, fontweight="bold" ) # only FULL/TRIM + x50 variant_txt = "FULL (F)" if label in ["A", "B"] else "TRIM (T)" ax.text(0.02, 0.90, variant_txt, transform=ax.transAxes, fontsize=11, color="#111") ax.text(0.02, 0.82, f"x50={x50:.2f}", transform=ax.transAxes, fontsize=10, color="#111") # delta text box d_ref = hi_r_x - lo_r_x d_tta = hi_t_x - lo_t_x ax.text( 0.95, 0.06, f"Δr={d_ref:.2f} Δt={d_tta:.2f}", transform=ax.transAxes, fontsize=10, color="#222", ha="right", bbox=dict(facecolor="white", edgecolor=color, boxstyle="square,pad=0.2", alpha=0.9) ) ax.set_xlim(0, xc.max()) ax.set_ylim(-0.05, 1.05) ax.grid(alpha=0.25) ax.tick_params(axis="x", labelbottom=True) # ------------------------------------------------------------ # legend # ------------------------------------------------------------ def noise_legend(fig): import matplotlib.lines as mlines from matplotlib.patches import Patch legend_handles = [ mlines.Line2D( [0], [0], color="black", lw=2.5, label="clean logistic fit (trained on log(SUV))" ), Patch( facecolor="#999999", alpha=0.24, edgecolor="none", label="refit band (training perturbation; 95% CI)" ), Patch( facecolor="#999999", alpha=0.10, edgecolor="none", label="TTA band (inference-time noise; 95% CI)" ), Patch( facecolor="#1f78b4", alpha=0.24, edgecolor="none", label="multiplicative noise: σ=0.129 (blue)" ), Patch( facecolor="#e66101", alpha=0.24, edgecolor="none", label="additive noise: σ=0.144 (orange)" ), mlines.Line2D( [0], [0], color="#666666", lw=1.4, ls="--", label="x50 (P=0.5)" ), ] fig.legend( handles=legend_handles, labels=[h.get_label() for h in legend_handles], loc="lower center", ncol=3, frameon=False, bbox_to_anchor=(0.5, 0.02), fontsize=10 )