Parcourir la source

Update 'logit.py'

Zahra Alirezaei il y a 2 mois
Parent
commit
b49702fa24
1 fichiers modifiés avec 1115 ajouts et 1115 suppressions
  1. 1115 1115
      logit.py

+ 1115 - 1115
logit.py

@@ -1,1116 +1,1116 @@
-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
+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
     )