survival / app /runtime.py
chanmin0723's picture
[feat] add 5-year RMST (restricted mean survival time)
d37bd3d
Raw
History Blame Contribute Delete
15.1 kB
"""
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))]