Spaces:
Running
Running
| """ | |
| runtime.py | |
| ========== | |
| Web μλΉμ€μ© λ°νμ λνΌ. webtool_runtime.py λ₯Ό HF Space μ λ§κ² μ΄μνλ€. | |
| λ‘λ λμ (κΈ°λ μ 1ν): | |
| webtool_core.py λͺ¨λΈ μ μ + μμ + feature + ensemble | |
| webtool_baseline.json λκ²°λ risk -> S(H) λ§€ν (+ bootstrap CI κ³μ) | |
| weights/seed_*/fold_*.pt 31 seed x 10 fold μμλΈ (310 checkpoints) | |
| μ λ ₯ : T stage(1..6), stationλ³ μ μ΄ count, stationλ³ harvest(μ μ node μ) | |
| μΆλ ₯ : risk_mean, risk_std, SCR_3, entropy, total_meta, | |
| surv_60m = κΈ°λ 5λ μμ‘΄νλ₯ (+95% CI), extrapolated flag | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import threading | |
| from pathlib import Path | |
| from typing import Any | |
| import numpy as np | |
| import pandas as pd | |
| import torch | |
| from .webtool_core import ( | |
| STATIONS, T_MAP, load_canonical, | |
| build_ln_features, build_t_onehot, scr_and_entropy, MultiSeedEnsemble, | |
| ) | |
| def _n_label(total_meta: float) -> str: | |
| """μ μ΄ node μ΄μ -> AJCC N stage (N0/N1/N2/N3a/N3b).""" | |
| n = int(round(total_meta or 0)) | |
| if n == 0: | |
| return "N0" | |
| if n <= 2: | |
| return "N1" | |
| if n <= 6: | |
| return "N2" | |
| if n <= 15: | |
| return "N3a" | |
| return "N3b" | |
| # --------------------------------------------------------------------------- # | |
| # κ²½λ‘ / μ€μ | |
| # --------------------------------------------------------------------------- # | |
| BUNDLE_ROOT = Path(__file__).resolve().parents[1] | |
| CONFIG = { | |
| "WEIGHTS_ROOT": os.environ.get("WEIGHTS_ROOT", str(BUNDLE_ROOT / "weights")), | |
| "CANON_SEED": int(os.environ.get("CANON_SEED", "9999")), | |
| "SEEDS": None, # None μ΄λ©΄ seed_* μ λΆ μλ νμ (baseline λκ²° μμ κ³Ό λμΌν΄μΌ ν¨) | |
| "BATCH": int(os.environ.get("BATCH", "256")), | |
| "DEVICE": "cuda" if torch.cuda.is_available() else "cpu", | |
| "ARTIFACT_PATH": os.environ.get( | |
| "ARTIFACT_PATH", str(BUNDLE_ROOT / "webtool_baseline.json") | |
| ), | |
| } | |
| DEVICE = torch.device(CONFIG["DEVICE"]) | |
| # T stage μ½λ(1..6) -> λΌλ²¨. UI/μ€ν€λ§μμ μ¬μ©. | |
| T_OPTIONS = [{"value": k, "label": v} for k, v in sorted(T_MAP.items())] | |
| # μ§μ° μ΄κΈ°ν μν (thread-safe) | |
| _STATE: dict[str, Any] = {} | |
| _INIT_LOCK = threading.Lock() | |
| def _ensure_baseline() -> dict[str, Any]: | |
| """baseline json + canonical config λ§ λ‘λνλ€(μ λ ΄). μμλΈμ λ‘λνμ§ μμ. | |
| schema/health κ° μμλΈ λ‘λ©μ κΈ°λ€λ¦¬μ§ μλλ‘ λΆλ¦¬νλ€.""" | |
| if "art" in _STATE: | |
| return _STATE | |
| with _INIT_LOCK: | |
| if "art" in _STATE: | |
| return _STATE | |
| print("[runtime] loading baseline + canonical config β¦", flush=True) | |
| with open(CONFIG["ARTIFACT_PATH"]) as f: | |
| art = json.load(f) | |
| model_config, t_cols = load_canonical(CONFIG["WEIGHTS_ROOT"], CONFIG["CANON_SEED"]) | |
| # λκ²° μμ κ³Ό λ°νμμ ꡬ쑰 λ° T μΈμ½λ© μ ν©μ κ°μ νλ€. | |
| assert art["t_cols"] == t_cols, "runtime t_cols κ° baseline κ³Ό λΆμΌμΉ" | |
| assert art["model_config"] == model_config, "runtime model_config κ° baseline κ³Ό λΆμΌμΉ" | |
| _STATE.update( | |
| art=art, model_config=model_config, t_cols=t_cols, | |
| horizon=art["horizon_months"], | |
| ) | |
| print(f"[runtime] baseline OK. horizon={art['horizon_months']}mo", flush=True) | |
| return _STATE | |
| def init() -> dict[str, Any]: | |
| """μ 체 μμλΈ(310 checkpoints)κΉμ§ λ‘λνλ€. idempotent, thread-safe. | |
| κΈ°λ μ§μ°μ νΌνλ €κ³ μλ²λ μ΄ ν¨μλ₯Ό λ°±κ·ΈλΌμ΄λ μ€λ λμμ νΈμΆνλ€.""" | |
| if _STATE.get("ready"): | |
| return _STATE | |
| _ensure_baseline() | |
| with _INIT_LOCK: | |
| if _STATE.get("ready"): | |
| return _STATE | |
| print("[runtime] building ensemble (310 checkpoints) β¦", flush=True) | |
| ensemble = MultiSeedEnsemble( | |
| weights_root=CONFIG["WEIGHTS_ROOT"], | |
| model_config=_STATE["model_config"], | |
| batch=CONFIG["BATCH"], | |
| device=DEVICE, | |
| seeds=CONFIG["SEEDS"], | |
| ) | |
| _STATE.update(ensemble=ensemble, ready=True, n_seeds=len(ensemble.seeds)) | |
| print(f"[runtime] ensemble ready: {len(ensemble.seeds)} seeds", flush=True) | |
| return _STATE | |
| # --------------------------------------------------------------------------- # | |
| # risk -> κΈ°λ survival (+ CI). webtool_runtime.py μμ κ·Έλλ‘. | |
| # --------------------------------------------------------------------------- # | |
| def _expected_survival(risk_series: pd.Series, art: dict) -> tuple[pd.Series, pd.Series]: | |
| r = risk_series.astype(float) | |
| s = art["S0_H"] ** np.exp(art["beta"] * (r - art["risk_center"])) | |
| oor = (r < art["risk_ref_min"]) | (r > art["risk_ref_max"]) | |
| return ( | |
| pd.Series(s, index=r.index, name=f"surv_{art['horizon_months']}m"), | |
| pd.Series(oor, index=r.index, name="extrapolated"), | |
| ) | |
| def _expected_survival_ci( | |
| risk_series: pd.Series, per_seed: pd.DataFrame | None, art: dict, mode: str = "cox" | |
| ) -> pd.DataFrame: | |
| r = risk_series.astype(float) | |
| boot = art["boot"] | |
| B, N = len(boot), len(r) | |
| M = np.empty((B, N)) | |
| seeds_arr = None | |
| if mode == "full": | |
| assert per_seed is not None, "mode='full' μλ per_seed DataFrame νμ" | |
| seeds_arr = per_seed.reindex(r.index).values # [N, n_seeds] | |
| for j, bp in enumerate(boot): | |
| if mode == "full": | |
| rng = np.random.default_rng(1000 + j) | |
| n_seed = seeds_arr.shape[1] | |
| r_eval = np.array( | |
| [ | |
| rng.choice(seeds_arr[i], size=n_seed, replace=True).mean() | |
| for i in range(N) | |
| ] | |
| ) | |
| else: | |
| r_eval = r.values | |
| M[j] = bp["S0_H"] ** np.exp(bp["beta"] * (r_eval - bp["risk_center"])) | |
| a = art["ci_alpha"] | |
| lo = np.percentile(M, 100 * a / 2, axis=0) | |
| hi = np.percentile(M, 100 * (1 - a / 2), axis=0) | |
| return pd.DataFrame({"lo": lo, "hi": hi}, index=r.index) | |
| # --------------------------------------------------------------------------- # | |
| # End-to-end μ±μ (DataFrame λ°ν) | |
| # --------------------------------------------------------------------------- # | |
| def score_frame( | |
| tstage: pd.Series, | |
| count_df: pd.DataFrame, | |
| harvest_df: pd.DataFrame, | |
| ci: bool = True, | |
| ci_mode: str = "cox", | |
| ) -> pd.DataFrame: | |
| """λ€μ νμ μ±μ . index 보쑴. μ»¬λΌ μμ/μλ―Έλ webtool_runtime κ³Ό λμΌ.""" | |
| st = init() | |
| art, ensemble, t_cols = st["art"], st["ensemble"], st["t_cols"] | |
| ln = build_ln_features(count_df, harvest_df) | |
| t_df = build_t_onehot(tstage.reindex(count_df.index), t_cols) | |
| risk, per_seed = ensemble.predict(ln, t_df, return_per_seed=True) | |
| se = scr_and_entropy(count_df) | |
| s_H, oor = _expected_survival(risk, art) | |
| H = art["horizon_months"] | |
| out = pd.DataFrame(index=count_df.index) | |
| out["Tstage"] = tstage.reindex(count_df.index).astype(int) | |
| out["total_meta"] = se["total_meta"] | |
| out["risk_mean"] = risk | |
| out["risk_std"] = per_seed.std(axis=1) | |
| out["SCR_3"] = se["SCR_3"] | |
| out["entropy"] = se["entropy"] | |
| out[f"surv_{H}m"] = s_H | |
| if ci: | |
| cid = _expected_survival_ci(risk, per_seed=per_seed, art=art, mode=ci_mode) | |
| out[f"surv_{H}m_lo"] = cid["lo"] | |
| out[f"surv_{H}m_hi"] = cid["hi"] | |
| out["ci_mode"] = ci_mode | |
| out["extrapolated"] = oor | |
| return out | |
| # --------------------------------------------------------------------------- # | |
| # μ λ ₯ μ κ·ν ν¬νΌ (dict νν -> STATIONS μμ DataFrame) | |
| # --------------------------------------------------------------------------- # | |
| def _station_df(mapping: dict[str, float], index) -> pd.DataFrame: | |
| """{station: κ°} -> [1,16] DataFrame. λ―ΈκΈ°μ¬ station μ 0.""" | |
| mapping = mapping or {} | |
| return pd.DataFrame( | |
| [[float(mapping.get(s, 0) or 0) for s in STATIONS]], | |
| columns=STATIONS, | |
| index=index, | |
| ) | |
| def _clean(v): | |
| """numpy/pandas μ€μΉΌλΌ -> JSON μ§λ ¬ν κ°λ₯ν νμ΄μ¬ κ°. NaN -> None.""" | |
| if v is None: | |
| return None | |
| if isinstance(v, (np.floating, float)): | |
| v = float(v) | |
| return None if np.isnan(v) else v | |
| if isinstance(v, (np.integer,)): | |
| return int(v) | |
| if isinstance(v, (np.bool_, bool)): | |
| return bool(v) | |
| return v | |
| def _row_to_result(row: pd.Series, patient_id: str, H: int) -> dict[str, Any]: | |
| """score_frame ν ν -> API μλ΅ dict.""" | |
| return { | |
| "id": patient_id, | |
| "Tstage": _clean(row["Tstage"]), | |
| "T_label": T_MAP.get(int(row["Tstage"])), | |
| "total_meta": _clean(row["total_meta"]), | |
| "risk_mean": _clean(row["risk_mean"]), | |
| "risk_std": _clean(row["risk_std"]), | |
| "SCR_3": _clean(row["SCR_3"]), | |
| "entropy": _clean(row["entropy"]), | |
| "surv": _clean(row[f"surv_{H}m"]), | |
| "surv_lo": _clean(row.get(f"surv_{H}m_lo")), | |
| "surv_hi": _clean(row.get(f"surv_{H}m_hi")), | |
| "extrapolated": _clean(row["extrapolated"]), | |
| "horizon_months": H, | |
| } | |
| def patient_curve(risk: float) -> dict[str, Any] | None: | |
| """νμ μνμ μ -> μκ°μΆ μ쑴곑μ S(t|risk) + 95% CI band + μ°Έμ‘° KM. | |
| S(t|risk) = S_center(t) ** exp(beta*(risk - risk_center)). S_center(60)=S0_H μ΄λ―λ‘ | |
| 곑μ μ 60κ°μ κ°μ point surv μ μ νν μΌμΉνλ€. baseline μν°ν©νΈκ° curve λ₯Ό | |
| ν¬ν¨νμ§ μμΌλ©΄ None.""" | |
| art = init()["art"] | |
| if "curve_base" not in art: | |
| return None | |
| beta, rc = art["beta"], art["risk_center"] | |
| t = np.asarray(art["curve_months"], dtype=int) | |
| base = np.asarray(art["curve_base"], dtype=float) | |
| s = base ** np.exp(beta * (risk - rc)) | |
| bb = np.asarray(art["curve_boot_base"], dtype=float) # [B, T] | |
| bbeta = np.asarray(art["curve_boot_beta"], dtype=float)[:, None] | |
| bcen = np.asarray(art["curve_boot_center"], dtype=float)[:, None] | |
| sb = bb ** np.exp(bbeta * (risk - bcen)) # [B, T] | |
| a = art.get("ci_alpha", 0.05) | |
| lo = np.percentile(sb, 100 * a / 2, axis=0) | |
| hi = np.percentile(sb, 100 * (1 - a / 2), axis=0) | |
| # μμ‘΄ν¨μλ μμ μμ (t=0)μμ μ μμ 1. baseline curve μ 0κ°μ κ°μ΄ μ νν | |
| # 1 μ΄ μλ μ μμΌλ―λ‘ κ·Έλνμ RMST μ λΆ λͺ¨λμμ κ°μ λ‘ λ³΄μ νλ€. | |
| s[0] = 1.0 | |
| lo[0] = 1.0 | |
| hi[0] = 1.0 | |
| sb[:, 0] = 1.0 | |
| # RMST_H = β«_0^H S(t) dt (trapezoidal, μ λ¨μ). νμ 0..H λ²μ. | |
| # CI λ μ΅μ’ lo/hi 곑μ μ μ λΆνλ κ² μλλΌ, bootstrap replicate 곑μ λ³ | |
| # RMST λΆν¬μ 2.5/97.5 percentile μ μ΄λ€. | |
| tf = t.astype(float) | |
| _trapz = getattr(np, "trapezoid", np.trapz) | |
| rmst = float(_trapz(s, tf)) | |
| rmst_boot = _trapz(sb, tf, axis=1) | |
| rmst_lo, rmst_hi = ( | |
| float(x) | |
| for x in np.percentile(rmst_boot, [100 * a / 2, 100 * (1 - a / 2)]) | |
| ) | |
| r5 = lambda arr: [round(float(x), 5) for x in arr] | |
| return { | |
| "t": t.tolist(), | |
| "s": r5(s), | |
| "lo": r5(lo), | |
| "hi": r5(hi), | |
| "ref_km": r5(art["ref_km"]) if "ref_km" in art else None, | |
| "rmst": round(rmst, 2), | |
| "rmst_lo": round(rmst_lo, 2), | |
| "rmst_hi": round(rmst_hi, 2), | |
| } | |
| def score_single( | |
| tstage: int, | |
| counts: dict[str, float], | |
| harvests: dict[str, float], | |
| patient_id: str = "patient", | |
| ci_mode: str = "cox", | |
| ) -> dict[str, Any]: | |
| """λ¨μΌ νμ μ±μ -> API μλ΅ dict (+ μκ°μΆ μ쑴곑μ ).""" | |
| st = init() | |
| idx = [patient_id] | |
| count_df = _station_df(counts, idx) | |
| harvest_df = _station_df(harvests, idx) | |
| t_series = pd.Series([int(tstage)], index=idx) | |
| out = score_frame(t_series, count_df, harvest_df, ci=True, ci_mode=ci_mode) | |
| res = _row_to_result(out.iloc[0], patient_id, st["horizon"]) | |
| res["curve"] = patient_curve(float(res["risk_mean"])) | |
| # RMST (H-κ°μ restricted mean survival time) λ₯Ό μμ λ λ²¨λ‘ μΉκ²©. | |
| # κ³ μ ν€(rmst*)λ νλ‘ νΈ/μΌλ° μλΉμμ©, rmst_{H}m* λ horizon λͺ μμ©. | |
| H = st["horizon"] | |
| cur = res.get("curve") | |
| if cur and cur.get("rmst") is not None: | |
| res["rmst"] = cur["rmst"] | |
| res["rmst_lo"] = cur["rmst_lo"] | |
| res["rmst_hi"] = cur["rmst_hi"] | |
| res[f"rmst_{H}m"] = cur["rmst"] | |
| res[f"rmst_{H}m_lo"] = cur["rmst_lo"] | |
| res[f"rmst_{H}m_hi"] = cur["rmst_hi"] | |
| res["rmst_horizon_months"] = H | |
| art = st["art"] | |
| # TNM λΆλ₯ (T + total_meta λ‘λΆν° N μ λ -> TNM κ·Έλ£Ή/stage) | |
| t_clin = T_MAP.get(int(res["Tstage"])) | |
| t_grp = art.get("tmap_tnm", {}).get(str(int(res["Tstage"])), t_clin) | |
| n_lab = _n_label(res["total_meta"]) | |
| group = art.get("tnm_dict", {}).get(f"{t_grp}|{n_lab}") | |
| stage = art.get("tnm_label_map", {}).get(str(group)) if group else None | |
| res["tnm"] = {"t": t_clin, "n": n_lab, "tn": f"{t_clin}{n_lab}", | |
| "group": group, "stage": stage} | |
| # SCR: μ λ κΈ°μ€ 0.75 (high/low). entropy: ln2/ln4 λ‘ low/intermediate/high. | |
| import math | |
| scr, ent = res["SCR_3"], res["entropy"] | |
| scr_thr = 0.75 | |
| res["scr_flag"] = None if scr is None else ("high" if scr >= scr_thr else "low") | |
| res["scr_threshold"] = scr_thr | |
| if ent is None: | |
| res["entropy_level"] = None | |
| elif ent < math.log(2): | |
| res["entropy_level"] = "low" | |
| elif ent < math.log(4): | |
| res["entropy_level"] = "intermediate" | |
| else: | |
| res["entropy_level"] = "high" | |
| res["entropy_thresholds"] = [round(math.log(2), 4), round(math.log(4), 4)] | |
| return res | |
| # --------------------------------------------------------------------------- # | |
| # CSV λ°°μΉ μ±μ | |
| # --------------------------------------------------------------------------- # | |
| def csv_columns() -> list[str]: | |
| """λ°°μΉ CSV νμ€ μ»¬λΌ: id, Tstage, meta_<station>, harv_<station> x 16.""" | |
| cols = ["id", "Tstage"] | |
| for s in STATIONS: | |
| cols += [f"meta_{s}", f"harv_{s}"] | |
| return cols | |
| def score_csv(df: pd.DataFrame, ci_mode: str = "cox") -> list[dict[str, Any]]: | |
| """νμ€ CSV DataFrame μ±μ . λλ½ station 컬λΌμ 0 μΌλ‘ μ±μ΄λ€.""" | |
| st = init() | |
| H = st["horizon"] | |
| df = df.copy() | |
| if "Tstage" not in df.columns: | |
| raise ValueError("CSV μ 'Tstage' 컬λΌμ΄ νμν©λλ€ (κ° 1..6).") | |
| if "id" not in df.columns: | |
| df["id"] = [f"row_{i:04d}" for i in range(len(df))] | |
| ids = df["id"].astype(str).tolist() | |
| index = pd.RangeIndex(len(df)) | |
| count_df = pd.DataFrame(index=index, columns=STATIONS, dtype=float) | |
| harvest_df = pd.DataFrame(index=index, columns=STATIONS, dtype=float) | |
| for s in STATIONS: | |
| count_df[s] = pd.to_numeric(df.get(f"meta_{s}", 0), errors="coerce").fillna(0).values | |
| harvest_df[s] = pd.to_numeric(df.get(f"harv_{s}", 0), errors="coerce").fillna(0).values | |
| t_series = pd.Series( | |
| pd.to_numeric(df["Tstage"], errors="coerce").fillna(1).astype(int).values, | |
| index=index, | |
| ) | |
| out = score_frame(t_series, count_df, harvest_df, ci=True, ci_mode=ci_mode) | |
| return [_row_to_result(out.iloc[i], ids[i], H) for i in range(len(out))] | |