Upload 3 files
Browse files- src/kfold_mlp_app.py +350 -0
- src/mlp_kfold.py +493 -0
- src/requirements.txt +5 -0
src/kfold_mlp_app.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
kfold_mlp_app.py — 피하주사 약물동태 예측 앱 (K-Fold 앙상블)
|
| 3 |
+
==============================================================
|
| 4 |
+
mlp_kfold.py 로 학습한 5개 fold 모델을 모두 불러와
|
| 5 |
+
앙상블 평균으로 예측하고, fold 간 편차로 신뢰구간을 표시한다.
|
| 6 |
+
|
| 7 |
+
사전 준비: python mlp_kfold.py 실행 후 outputs_kfold/folds/ 생성
|
| 8 |
+
실행: streamlit run kfold_mlp_app.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import matplotlib
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
import io
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
import streamlit as st
|
| 17 |
+
import tensorflow as tf
|
| 18 |
+
import keras
|
| 19 |
+
from keras import layers
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 23 |
+
OUTPUT_DIR = BASE_DIR / "outputs_kfold"
|
| 24 |
+
FOLDS_DIR = OUTPUT_DIR / "folds"
|
| 25 |
+
|
| 26 |
+
PARAM_NAMES = ["Lp_ve", "K", "p_le", "sigma_ve", "sigma_le",
|
| 27 |
+
"p_ve", "D_gel", "k_decay", "kf_m", "kr_m"]
|
| 28 |
+
OUTPUT_NAMES = ["c_lymph", "c_vessel", "c_decay", "c_ecm"]
|
| 29 |
+
LOG_TRANSFORM_PARAMS = {"K", "p_le", "p_ve", "D_gel", "k_decay", "kr_m"}
|
| 30 |
+
N_PARAMS = 10
|
| 31 |
+
N_OUTPUTS = 4
|
| 32 |
+
|
| 33 |
+
# === 모델 구조 (mlp_kfold.py와 반드시 동일) ===
|
| 34 |
+
HIDDEN_DIMS = [128, 256, 256, 128]
|
| 35 |
+
L2 = 1e-5
|
| 36 |
+
|
| 37 |
+
PARAM_RANGES = {
|
| 38 |
+
"Lp_ve": (4e-12, 1.6e-11),
|
| 39 |
+
"K": (1e-16, 1e-13),
|
| 40 |
+
"p_le": (1e-9, 1e-7),
|
| 41 |
+
"sigma_ve": (0.01, 0.99),
|
| 42 |
+
"sigma_le": (0.01, 0.50),
|
| 43 |
+
"p_ve": (1e-11, 1e-8),
|
| 44 |
+
"D_gel": (1e-12, 1e-9),
|
| 45 |
+
"k_decay": (1e-8, 1e-4),
|
| 46 |
+
"kf_m": (0.1, 3.0),
|
| 47 |
+
"kr_m": (2.5, 200.0),
|
| 48 |
+
}
|
| 49 |
+
DEFAULT_VALUES = np.array([
|
| 50 |
+
8e-12, 1e-15, 1e-8, 0.9, 0.1,
|
| 51 |
+
1e-9, 45e-12, 1.7e-6, 0.48, 4.2,
|
| 52 |
+
])
|
| 53 |
+
COLORS = ["#2ecc71", "#e74c3c", "#3498db", "#f39c12"]
|
| 54 |
+
_trapz = np.trapezoid if hasattr(np, "trapezoid") else np.trapz
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _setup_font():
|
| 58 |
+
candidates = ["AppleGothic", "Malgun Gothic", "NanumGothic", "Noto Sans CJK KR"]
|
| 59 |
+
available = {f.name for f in matplotlib.font_manager.fontManager.ttflist}
|
| 60 |
+
for name in candidates:
|
| 61 |
+
if name in available:
|
| 62 |
+
matplotlib.rc("font", family=name)
|
| 63 |
+
break
|
| 64 |
+
matplotlib.rc("axes", unicode_minus=False)
|
| 65 |
+
|
| 66 |
+
_setup_font()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_mlp(n_t):
|
| 70 |
+
"""mlp_kfold.py의 build_mlp와 동일 구조 (가중치 로드용)."""
|
| 71 |
+
inp = keras.Input(shape=(N_PARAMS,), name="params")
|
| 72 |
+
x = inp
|
| 73 |
+
for h in HIDDEN_DIMS:
|
| 74 |
+
x = layers.Dense(h, kernel_regularizer=keras.regularizers.l2(L2) if L2 > 0 else None)(x)
|
| 75 |
+
x = layers.Activation(tf.nn.silu)(x)
|
| 76 |
+
out = layers.Dense(n_t * N_OUTPUTS, activation="linear", name="curve")(x)
|
| 77 |
+
return keras.Model(inp, out, name="MLP_fold")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
@st.cache_resource
|
| 81 |
+
def load_folds():
|
| 82 |
+
"""folds/fold*/ 의 가중치 + 스케일러를 모두 로드."""
|
| 83 |
+
if not FOLDS_DIR.exists():
|
| 84 |
+
raise FileNotFoundError(
|
| 85 |
+
f"folds 폴더를 찾을 수 없습니다.\n경로: {FOLDS_DIR.resolve()}\n\n"
|
| 86 |
+
"먼저 학습을 실행하세요:\n python mlp_kfold.py"
|
| 87 |
+
)
|
| 88 |
+
fold_dirs = sorted([d for d in FOLDS_DIR.iterdir()
|
| 89 |
+
if d.is_dir() and d.name.startswith("fold")])
|
| 90 |
+
if not fold_dirs:
|
| 91 |
+
raise FileNotFoundError(f"fold 모델이 없습니다: {FOLDS_DIR.resolve()}")
|
| 92 |
+
|
| 93 |
+
# 시간축은 fold 공통 (첫 fold 기준)
|
| 94 |
+
sc0 = np.load(fold_dirs[0] / "scalers.npz")
|
| 95 |
+
time_arr = sc0["time_arr"]
|
| 96 |
+
n_t = len(time_arr)
|
| 97 |
+
|
| 98 |
+
folds = []
|
| 99 |
+
for fd in fold_dirs:
|
| 100 |
+
sc = np.load(fd / "scalers.npz")
|
| 101 |
+
model = build_mlp(n_t)
|
| 102 |
+
model.load_weights(str(fd / "model.weights.h5"))
|
| 103 |
+
folds.append({
|
| 104 |
+
"name": fd.name,
|
| 105 |
+
"model": model,
|
| 106 |
+
"param_mean": sc["param_mean"],
|
| 107 |
+
"param_std": sc["param_std"],
|
| 108 |
+
"param_log_mask": sc["param_log_mask"].astype(bool),
|
| 109 |
+
"out_mean": sc["out_mean"],
|
| 110 |
+
"out_std": sc["out_std"],
|
| 111 |
+
})
|
| 112 |
+
return folds, time_arr, n_t
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def preprocess(params, fold):
|
| 116 |
+
lm = fold["param_log_mask"]
|
| 117 |
+
pt = params.copy().astype(np.float64)
|
| 118 |
+
pt[lm] = np.log10(np.clip(pt[lm], 1e-300, None))
|
| 119 |
+
return ((pt - fold["param_mean"]) / fold["param_std"]).astype(np.float32)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def predict_ensemble(folds, params, n_t):
|
| 123 |
+
"""
|
| 124 |
+
각 fold로 예측 후 스택.
|
| 125 |
+
반환: preds (n_folds, n_t, N_OUTPUTS)
|
| 126 |
+
"""
|
| 127 |
+
preds = []
|
| 128 |
+
for fold in folds:
|
| 129 |
+
x = preprocess(params, fold).reshape(1, -1)
|
| 130 |
+
p = fold["model"](x, training=False).numpy().reshape(n_t, N_OUTPUTS)
|
| 131 |
+
p = p * fold["out_std"] + fold["out_mean"]
|
| 132 |
+
preds.append(p)
|
| 133 |
+
return np.stack(preds, axis=0)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def check_range(params):
|
| 137 |
+
warnings = []
|
| 138 |
+
for name, val in zip(PARAM_NAMES, params):
|
| 139 |
+
lo, hi = PARAM_RANGES[name]
|
| 140 |
+
if val < lo or val > hi:
|
| 141 |
+
warnings.append(f"**{name}** = {val:.3e} (허용: {lo:.1e} ~ {hi:.1e})")
|
| 142 |
+
return warnings
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def plot_combined(mean, std, time_arr):
|
| 146 |
+
fig, ax = plt.subplots(figsize=(12, 5))
|
| 147 |
+
for k, name in enumerate(OUTPUT_NAMES):
|
| 148 |
+
ax.plot(time_arr, mean[:, k], color=COLORS[k], lw=2.5, label=name)
|
| 149 |
+
ax.fill_between(time_arr, mean[:, k] - std[:, k], mean[:, k] + std[:, k],
|
| 150 |
+
color=COLORS[k], alpha=0.18)
|
| 151 |
+
ax.set_xlabel("Time (min)", fontsize=12)
|
| 152 |
+
ax.set_ylabel("농도 (%)", fontsize=12)
|
| 153 |
+
ax.set_title("약물동태 예측", fontsize=13, fontweight="bold")
|
| 154 |
+
ax.set_xlim(time_arr.min(), time_arr.max())
|
| 155 |
+
ax.set_ylim(bottom=0)
|
| 156 |
+
ax.legend(fontsize=11)
|
| 157 |
+
ax.grid(True, alpha=0.3)
|
| 158 |
+
plt.tight_layout()
|
| 159 |
+
return fig
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def plot_subplots(mean, std, time_arr):
|
| 163 |
+
fig, axes = plt.subplots(2, 2, figsize=(14, 7))
|
| 164 |
+
axes = axes.flatten()
|
| 165 |
+
for k, name in enumerate(OUTPUT_NAMES):
|
| 166 |
+
ax = axes[k]
|
| 167 |
+
ax.plot(time_arr, mean[:, k], color=COLORS[k], lw=2.5)
|
| 168 |
+
ax.fill_between(time_arr, mean[:, k] - std[:, k], mean[:, k] + std[:, k],
|
| 169 |
+
color=COLORS[k], alpha=0.18)
|
| 170 |
+
ax.set_title(name, fontsize=12, fontweight="bold")
|
| 171 |
+
ax.set_xlabel("Time (min)", fontsize=10)
|
| 172 |
+
ax.set_ylabel("농도 (%)", fontsize=10)
|
| 173 |
+
ax.set_xlim(time_arr.min(), time_arr.max())
|
| 174 |
+
ax.set_ylim(bottom=0)
|
| 175 |
+
ax.grid(True, alpha=0.3)
|
| 176 |
+
plt.suptitle("구획별 농도-시간 곡선 (± 편차)", fontsize=13, y=1.01)
|
| 177 |
+
plt.tight_layout()
|
| 178 |
+
return fig
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
st.set_page_config(page_title="PK Surrogate — K-Fold Ensemble", page_icon="💉", layout="wide")
|
| 182 |
+
st.title("💉 피하주사 약물동태 예측 모델")
|
| 183 |
+
st.markdown("10개 파라미터 → 4구획 농도-시간 곡선 (0~72시간) | "
|
| 184 |
+
"음영은 fold 간 편차(예측 신뢰도)")
|
| 185 |
+
|
| 186 |
+
try:
|
| 187 |
+
folds, time_arr, n_t = load_folds()
|
| 188 |
+
except FileNotFoundError as e:
|
| 189 |
+
st.error(str(e))
|
| 190 |
+
st.stop()
|
| 191 |
+
except Exception as e:
|
| 192 |
+
st.error(f"로딩 실패: {e}")
|
| 193 |
+
st.stop()
|
| 194 |
+
|
| 195 |
+
st.sidebar.header("📋 파라미터 입력")
|
| 196 |
+
st.sidebar.markdown("---")
|
| 197 |
+
input_params = []
|
| 198 |
+
for i, name in enumerate(PARAM_NAMES):
|
| 199 |
+
dv = float(DEFAULT_VALUES[i])
|
| 200 |
+
lo, hi = PARAM_RANGES[name]
|
| 201 |
+
fmt = "%.2e" if (abs(dv) < 1e-3 or abs(dv) > 1e3) else "%.4f"
|
| 202 |
+
val = st.sidebar.number_input(
|
| 203 |
+
label=f"{name} ({lo:.1e} ~ {hi:.1e})",
|
| 204 |
+
value=dv, format=fmt, key=f"p_{i}",
|
| 205 |
+
)
|
| 206 |
+
input_params.append(val)
|
| 207 |
+
st.sidebar.markdown("---")
|
| 208 |
+
predict_btn = st.sidebar.button("🔮 예측하기", type="primary", use_container_width=True)
|
| 209 |
+
|
| 210 |
+
with st.expander("ℹ️ 모델 정보", expanded=False):
|
| 211 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 212 |
+
c1.metric("모델", "MLP")
|
| 213 |
+
c2.metric("구조", "[128,256,256,128]")
|
| 214 |
+
c3.metric("Dropout", "0.0 (L2=1e-5)")
|
| 215 |
+
c4.metric("Fold 수", f"{len(folds)}개")
|
| 216 |
+
|
| 217 |
+
if predict_btn:
|
| 218 |
+
params = np.array(input_params, dtype=np.float64)
|
| 219 |
+
|
| 220 |
+
oor = check_range(params)
|
| 221 |
+
if oor:
|
| 222 |
+
st.warning(
|
| 223 |
+
"⚠️ **외삽 경고**: 아래 파라미터가 학습 범위를 벗어났습니다. "
|
| 224 |
+
"예측 신뢰도가 낮을 수 있습니다.\n\n"
|
| 225 |
+
+ "\n".join(f"- {w}" for w in oor)
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
with st.spinner("예측 중..."):
|
| 229 |
+
preds = predict_ensemble(folds, params, n_t) # (n_folds, n_t, 4)
|
| 230 |
+
mean = preds.mean(axis=0)
|
| 231 |
+
std = preds.std(axis=0)
|
| 232 |
+
|
| 233 |
+
# fold 간 편차가 크면 신뢰도 낮음 경고
|
| 234 |
+
rel_spread = float(std.mean())
|
| 235 |
+
if rel_spread > 1.0: # 평균 편차 1%p 이상이면
|
| 236 |
+
st.warning(
|
| 237 |
+
f"⚠️ **예측 불확실성 높음**: fold 간 평균 편차가 {rel_spread:.2f}%p입니다. "
|
| 238 |
+
"이 파라미터 영역은 학습 데이터가 성겨 신뢰도가 낮을 수 있습니다. "
|
| 239 |
+
"COMSOL 직접 검증을 권장합니다."
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
st.markdown("---")
|
| 243 |
+
st.subheader("📈 72시간 최종값 요약")
|
| 244 |
+
# 총농도(4구획 합)를 맨 앞에 추가
|
| 245 |
+
total_mean = mean.sum(axis=1) # (n_t,) 시점별 4구획 합
|
| 246 |
+
total_std = std.sum(axis=1)
|
| 247 |
+
cols = st.columns(5)
|
| 248 |
+
cols[0].metric(
|
| 249 |
+
label="c_total (총합)",
|
| 250 |
+
value=f"{total_mean[-1]:.2f}%",
|
| 251 |
+
delta=f"max: {total_mean.max():.2f}%",
|
| 252 |
+
delta_color="off",
|
| 253 |
+
)
|
| 254 |
+
for k, name in enumerate(OUTPUT_NAMES):
|
| 255 |
+
cols[k + 1].metric(
|
| 256 |
+
label=name,
|
| 257 |
+
value=f"{mean[-1, k]:.2f}%",
|
| 258 |
+
delta=f"±{std[-1, k]:.2f}%p (fold 편차)",
|
| 259 |
+
delta_color="off",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
st.markdown("---")
|
| 263 |
+
st.subheader("📊 농도-시간 곡선")
|
| 264 |
+
tab1, tab2 = st.tabs(["전체 비교", "구획별 상세"])
|
| 265 |
+
with tab1:
|
| 266 |
+
fig1 = plot_combined(mean, std, time_arr)
|
| 267 |
+
st.pyplot(fig1); plt.close(fig1)
|
| 268 |
+
with tab2:
|
| 269 |
+
fig2 = plot_subplots(mean, std, time_arr)
|
| 270 |
+
st.pyplot(fig2); plt.close(fig2)
|
| 271 |
+
|
| 272 |
+
st.markdown("---")
|
| 273 |
+
st.subheader("📋 PK 지표")
|
| 274 |
+
# %AUC: 각 구획 AUC가 전체 AUC 합에서 차지하는 비율 (합 100%)
|
| 275 |
+
auc_vals = np.array([_trapz(mean[:, k], time_arr) for k in range(N_OUTPUTS)])
|
| 276 |
+
auc_total = auc_vals.sum() + 1e-12
|
| 277 |
+
rows = []
|
| 278 |
+
for k, name in enumerate(OUTPUT_NAMES):
|
| 279 |
+
curve = mean[:, k]
|
| 280 |
+
rows.append({
|
| 281 |
+
"구획": name,
|
| 282 |
+
"Cmax (%)": f"{curve.max():.2f}",
|
| 283 |
+
"Tmax (min)": f"{time_arr[int(np.argmax(curve))]:.0f}",
|
| 284 |
+
"%AUC": f"{auc_vals[k] / auc_total * 100:.1f}%",
|
| 285 |
+
"72hr (%)": f"{curve[-1]:.2f}",
|
| 286 |
+
})
|
| 287 |
+
st.table(rows)
|
| 288 |
+
|
| 289 |
+
# ── 시점별 농도값 엑셀 다운로드 ──
|
| 290 |
+
st.markdown("---")
|
| 291 |
+
st.subheader("📥 시점별 농도값 다운로드")
|
| 292 |
+
st.markdown("모델이 예측한 42개 시점의 구획별 농도값을 엑셀로 저장합니다.")
|
| 293 |
+
|
| 294 |
+
df_out = pd.DataFrame({"time_min": time_arr, "time_hr": time_arr / 60.0})
|
| 295 |
+
for k, name in enumerate(OUTPUT_NAMES):
|
| 296 |
+
df_out[name] = mean[:, k]
|
| 297 |
+
|
| 298 |
+
buf = io.BytesIO()
|
| 299 |
+
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
|
| 300 |
+
df_out.to_excel(writer, index=False, sheet_name="농도_시간별")
|
| 301 |
+
# 입력 파라미터도 별도 시트로 기록 (재현용)
|
| 302 |
+
pd.DataFrame({"parameter": PARAM_NAMES, "value": input_params}
|
| 303 |
+
).to_excel(writer, index=False, sheet_name="입력_파라미터")
|
| 304 |
+
buf.seek(0)
|
| 305 |
+
|
| 306 |
+
st.download_button(
|
| 307 |
+
label="⬇️ 엑셀 다운로드 (.xlsx)",
|
| 308 |
+
data=buf,
|
| 309 |
+
file_name="농도_시간별_예측.xlsx",
|
| 310 |
+
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 311 |
+
use_container_width=False,
|
| 312 |
+
)
|
| 313 |
+
with st.expander("미리보기"):
|
| 314 |
+
st.dataframe(df_out.style.format({
|
| 315 |
+
"time_min": "{:.0f}", "time_hr": "{:.2f}",
|
| 316 |
+
**{name: "{:.3f}" for name in OUTPUT_NAMES},
|
| 317 |
+
}), use_container_width=True)
|
| 318 |
+
|
| 319 |
+
with st.expander("🔍 입력 파라미터 확인"):
|
| 320 |
+
st.json({
|
| 321 |
+
name: (f"{val:.3e}" if (abs(val) < 1e-3 or abs(val) > 1e3) else f"{val:.4f}")
|
| 322 |
+
for name, val in zip(PARAM_NAMES, input_params)
|
| 323 |
+
})
|
| 324 |
+
|
| 325 |
+
else:
|
| 326 |
+
st.info("👈 왼쪽 사이드바에서 파라미터를 입력하고 **예측하기** 버튼을 누르세요.")
|
| 327 |
+
st.markdown("""
|
| 328 |
+
### 예측 신뢰도
|
| 329 |
+
fold 간 **편차(음영)**가 그 파라미터 영역에서의 예측 신뢰도를 나타냅니다.
|
| 330 |
+
편차가 크면 학습 데이터가 성긴 영역이라 신뢰도가 낮습니다.
|
| 331 |
+
|
| 332 |
+
### 출력 구획
|
| 333 |
+
| 구획 | 설명 |
|
| 334 |
+
|------|------|
|
| 335 |
+
| c_lymph | 림프관 배출 |
|
| 336 |
+
| c_vessel | 혈관 배출 |
|
| 337 |
+
| c_decay | 분해 |
|
| 338 |
+
| c_ecm | 세포외기질 잔류 |
|
| 339 |
+
|
| 340 |
+
### 주의사항
|
| 341 |
+
- 파라미터 범위를 벗어나면 **외삽 경고**
|
| 342 |
+
- fold 편차가 크면 **불확실성 경고** (COMSOL 직접 검증 권장)
|
| 343 |
+
""")
|
| 344 |
+
|
| 345 |
+
if __name__ == "__main__":
|
| 346 |
+
import os, subprocess, sys
|
| 347 |
+
if os.environ.get("STREAMLIT_RUNNING") != "1":
|
| 348 |
+
env = os.environ.copy()
|
| 349 |
+
env["STREAMLIT_RUNNING"] = "1"
|
| 350 |
+
subprocess.run([sys.executable, "-m", "streamlit", "run", __file__], env=env)
|
src/mlp_kfold.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
mlp_kfold.py — MLP 단일 모델 + K-Fold 교차검증
|
| 3 |
+
================================================
|
| 4 |
+
단일 분할의 운(運)을 제거하고 전체 데이터에 대해
|
| 5 |
+
평균±std로 신뢰성 있는 성능을 평가한다.
|
| 6 |
+
|
| 7 |
+
설계:
|
| 8 |
+
- 층화 K-Fold (k_decay × p_ve 기준)로 데이터를 K등분
|
| 9 |
+
- 각 fold가 한 번씩 test가 됨 (전체 케이스가 정확히 1번씩 평가됨)
|
| 10 |
+
- 나머지에서 val 일부 분리, 나머지 train
|
| 11 |
+
- K개 모델의 test 지표를 평균±std로 보고
|
| 12 |
+
- 각 fold 모델/스케일러 저장
|
| 13 |
+
|
| 14 |
+
모델 설정 (기존 최적):
|
| 15 |
+
구조 [128,256,256,128] SiLU, dropout=0, l2=1e-5
|
| 16 |
+
시간가중 MSE(tau=2000), epochs=1000, patience_es=80
|
| 17 |
+
|
| 18 |
+
실행: python mlp_kfold.py
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
import os
|
| 23 |
+
os.environ["PYTHONHASHSEED"] = "42"
|
| 24 |
+
os.environ["TF_DETERMINISTIC_OPS"] = "1"
|
| 25 |
+
os.environ["TF_CUDNN_DETERMINISTIC"] = "1"
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import random
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import pandas as pd
|
| 33 |
+
import matplotlib
|
| 34 |
+
matplotlib.use("Agg")
|
| 35 |
+
import matplotlib.pyplot as plt
|
| 36 |
+
import matplotlib.font_manager as fm
|
| 37 |
+
|
| 38 |
+
def set_korean_font():
|
| 39 |
+
candidates = ["AppleGothic", "Malgun Gothic", "NanumGothic",
|
| 40 |
+
"NanumBarunGothic", "DejaVu Sans"]
|
| 41 |
+
available = {f.name for f in fm.fontManager.ttflist}
|
| 42 |
+
for font in candidates:
|
| 43 |
+
if font in available:
|
| 44 |
+
plt.rcParams["font.family"] = font
|
| 45 |
+
break
|
| 46 |
+
plt.rcParams["axes.unicode_minus"] = False # 유니코드 마이너스 대신 ASCII 하이픈 사용
|
| 47 |
+
|
| 48 |
+
set_korean_font()
|
| 49 |
+
|
| 50 |
+
# 폰트 글리프 경고 숨김 (U+2212 등 무해한 치환 경고)
|
| 51 |
+
import logging
|
| 52 |
+
logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
|
| 53 |
+
import warnings
|
| 54 |
+
warnings.filterwarnings("ignore", message="Glyph.*missing from font")
|
| 55 |
+
warnings.filterwarnings("ignore", message=".*does not have a glyph.*")
|
| 56 |
+
from sklearn.metrics import mean_absolute_error, r2_score
|
| 57 |
+
|
| 58 |
+
import tensorflow as tf
|
| 59 |
+
tf.get_logger().setLevel("ERROR") # retracing 등 무해한 경고 숨김
|
| 60 |
+
import keras
|
| 61 |
+
from keras import layers, callbacks
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ─────────────────────────────────────────────
|
| 65 |
+
# 0. 설정
|
| 66 |
+
# ─────────────────────────────────────────────
|
| 67 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 68 |
+
DATA_PATH = BASE_DIR / "정제_전체_long.xlsx"
|
| 69 |
+
OUTPUT_DIR = BASE_DIR / "outputs_kfold"
|
| 70 |
+
for d in (OUTPUT_DIR, OUTPUT_DIR / "folds"):
|
| 71 |
+
d.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
|
| 73 |
+
SEED = 42
|
| 74 |
+
N_OUTPUTS = 4
|
| 75 |
+
N_PARAMS = 10
|
| 76 |
+
|
| 77 |
+
PARAM_NAMES = ["Lp_ve", "K", "p_le", "sigma_ve", "sigma_le",
|
| 78 |
+
"p_ve", "D_gel", "k_decay", "kf_m", "kr_m"]
|
| 79 |
+
OUTPUT_NAMES = ["c_lymph", "c_vessel", "c_decay", "c_ecm"]
|
| 80 |
+
LOG_TRANSFORM_PARAMS = {"K", "p_le", "p_ve", "D_gel", "k_decay", "kr_m"}
|
| 81 |
+
EXCLUDE_CASES = {304, 312, 313, 317}
|
| 82 |
+
|
| 83 |
+
# === K-Fold 설정 ===
|
| 84 |
+
N_FOLDS = 5 # 5-fold (각 fold test ≈ 20%)
|
| 85 |
+
VAL_RATIO = 0.15 # train 내에서 val로 떼는 비율
|
| 86 |
+
STRATIFY_BINS = 5 # 층화 기준 분위수
|
| 87 |
+
|
| 88 |
+
# === 외딴 케이스 제외 설정 ===
|
| 89 |
+
# 곡선이 파라미터 이웃과 크게 다른(=데이터가 성겨 보간 불가능한) 케이스를
|
| 90 |
+
# 학습/평가 전에 제외. 모델 결과가 아닌 데이터 구조만으로 판정하므로 객관적.
|
| 91 |
+
# 목적: 데이터 커버리지가 충분한 정상 영역에서의 본래 성능 측정.
|
| 92 |
+
# 제외된 영역은 추가 COMSOL 샘플링으로 별도 보완.
|
| 93 |
+
EXCLUDE_SPARSE = True # True: 외딴 케이스 제외, False: 전체 사용
|
| 94 |
+
SPARSE_NEIGHBORS = 5 # 국소 불일치 계산용 이웃 수
|
| 95 |
+
SPARSE_IQR_K = 1.5 # 이상치 임계 (Q3 + K×IQR)
|
| 96 |
+
|
| 97 |
+
# === 모델 설정 (기존 최적) ===
|
| 98 |
+
HIDDEN_DIMS = [128, 256, 256, 128]
|
| 99 |
+
DROPOUT = 0.0
|
| 100 |
+
L2 = 1e-5
|
| 101 |
+
TIME_WEIGHT_TAU = 2000.0
|
| 102 |
+
EPOCHS = 1000
|
| 103 |
+
BATCH_SIZE = 16
|
| 104 |
+
LEARNING_RATE = 1e-3
|
| 105 |
+
WEIGHT_DECAY = 1e-5
|
| 106 |
+
PATIENCE_ES = 80
|
| 107 |
+
PATIENCE_LR = 30
|
| 108 |
+
MIN_DELTA = 1e-5 # 이보다 작은 val_loss 개선은 무시 (조기종료 정상 작동)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def set_seed(seed=SEED):
|
| 112 |
+
random.seed(seed)
|
| 113 |
+
np.random.seed(seed)
|
| 114 |
+
tf.random.set_seed(seed)
|
| 115 |
+
try:
|
| 116 |
+
tf.config.experimental.enable_op_determinism()
|
| 117 |
+
except Exception:
|
| 118 |
+
pass
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ─────────────────────────────────────────────
|
| 122 |
+
# 1. 데이터 로드
|
| 123 |
+
# ─────────────────────────────────────────────
|
| 124 |
+
def load_data(path):
|
| 125 |
+
df = pd.read_excel(path)
|
| 126 |
+
required = {"case", "time_min", *PARAM_NAMES, *OUTPUT_NAMES}
|
| 127 |
+
missing = required - set(df.columns)
|
| 128 |
+
if missing:
|
| 129 |
+
raise ValueError(f"누락된 컬럼: {missing}")
|
| 130 |
+
|
| 131 |
+
if EXCLUDE_CASES:
|
| 132 |
+
before = df["case"].nunique()
|
| 133 |
+
df = df[~df["case"].isin(EXCLUDE_CASES)].copy()
|
| 134 |
+
print(f" 제외된 발산 케이스: {sorted(EXCLUDE_CASES)} ({before}->{df['case'].nunique()})")
|
| 135 |
+
|
| 136 |
+
cases = sorted(df["case"].unique())
|
| 137 |
+
n = len(cases)
|
| 138 |
+
ref_t = df[df["case"] == cases[0]].sort_values("time_min")["time_min"].values
|
| 139 |
+
n_t = len(ref_t)
|
| 140 |
+
|
| 141 |
+
P = np.zeros((n, N_PARAMS), dtype=np.float64)
|
| 142 |
+
C = np.zeros((n, n_t, N_OUTPUTS), dtype=np.float64)
|
| 143 |
+
for i, c in enumerate(cases):
|
| 144 |
+
sub = df[df["case"] == c].sort_values("time_min")
|
| 145 |
+
if len(sub) != n_t or not np.allclose(sub["time_min"].values, ref_t):
|
| 146 |
+
raise ValueError(f"case {c}: 시간 그리드 불일치")
|
| 147 |
+
P[i] = sub[PARAM_NAMES].iloc[0].values
|
| 148 |
+
C[i] = sub[OUTPUT_NAMES].values
|
| 149 |
+
|
| 150 |
+
n_clip = (C < 0).sum()
|
| 151 |
+
if n_clip > 0:
|
| 152 |
+
print(f" 음수 농도 클리핑: {n_clip}개 -> 0 (최소 {C.min():.4f})")
|
| 153 |
+
C = np.clip(C, 0.0, None)
|
| 154 |
+
|
| 155 |
+
print(f" 로드 완료: 케이스 {n}개, 시간점 {n_t}개")
|
| 156 |
+
return P, C, ref_t, cases
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ─────────────────────────────────────────────
|
| 160 |
+
# 2. 스케일러
|
| 161 |
+
# ─────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# ─────────────────────────────────────────────
|
| 165 |
+
# 1-b. 외딴(sparse) 케이스 탐지
|
| 166 |
+
# ─────────────────────────────────────────────
|
| 167 |
+
def detect_sparse_cases(params, curves):
|
| 168 |
+
"""
|
| 169 |
+
각 케이스의 곡선이 '파라미터 공간 이웃들의 곡선'과 얼마나 다른지(국소 불일치)
|
| 170 |
+
측정해, IQR 이상치를 외딴 케이스로 판정한다.
|
| 171 |
+
국소 불일치가 크다 = 파라미터는 가까운데 거동이 급변 = 데이터 해상도 부족 영역.
|
| 172 |
+
|
| 173 |
+
반환: keep_mask (True=유지), sparse_idx (제외 인덱스), mismatch (점수)
|
| 174 |
+
"""
|
| 175 |
+
from scipy.spatial.distance import cdist
|
| 176 |
+
log_mask = np.array([n in LOG_TRANSFORM_PARAMS for n in PARAM_NAMES], dtype=bool)
|
| 177 |
+
Pl = params.copy().astype(float)
|
| 178 |
+
Pl[:, log_mask] = np.log10(np.clip(Pl[:, log_mask], 1e-300, None))
|
| 179 |
+
Psc = (Pl - Pl.mean(0)) / (Pl.std(0) + 1e-12)
|
| 180 |
+
|
| 181 |
+
D = cdist(Psc, Psc)
|
| 182 |
+
np.fill_diagonal(D, np.inf)
|
| 183 |
+
|
| 184 |
+
mismatch = np.zeros(len(params))
|
| 185 |
+
for i in range(len(params)):
|
| 186 |
+
nn = np.argsort(D[i])[:SPARSE_NEIGHBORS]
|
| 187 |
+
neighbor_mean = curves[nn].mean(axis=0)
|
| 188 |
+
mismatch[i] = np.abs(curves[i] - neighbor_mean).mean()
|
| 189 |
+
|
| 190 |
+
q1, q3 = np.percentile(mismatch, [25, 75])
|
| 191 |
+
thr = q3 + SPARSE_IQR_K * (q3 - q1)
|
| 192 |
+
sparse_idx = np.where(mismatch > thr)[0]
|
| 193 |
+
keep_mask = mismatch <= thr
|
| 194 |
+
return keep_mask, sparse_idx, mismatch, thr
|
| 195 |
+
|
| 196 |
+
class ParamScaler:
|
| 197 |
+
def __init__(self):
|
| 198 |
+
self.log_mask_ = np.array([n in LOG_TRANSFORM_PARAMS for n in PARAM_NAMES], dtype=bool)
|
| 199 |
+
self.mean_ = None; self.std_ = None
|
| 200 |
+
|
| 201 |
+
def _apply_log(self, X):
|
| 202 |
+
Xt = X.copy().astype(np.float64)
|
| 203 |
+
Xt[:, self.log_mask_] = np.log10(np.clip(Xt[:, self.log_mask_], 1e-300, None))
|
| 204 |
+
return Xt
|
| 205 |
+
|
| 206 |
+
def fit(self, X):
|
| 207 |
+
Xt = self._apply_log(X)
|
| 208 |
+
self.mean_ = Xt.mean(0); self.std_ = Xt.std(0) + 1e-12
|
| 209 |
+
return self
|
| 210 |
+
|
| 211 |
+
def transform(self, X): return (self._apply_log(X) - self.mean_) / self.std_
|
| 212 |
+
def fit_transform(self, X): return self.fit(X).transform(X)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class OutputScaler:
|
| 216 |
+
def __init__(self):
|
| 217 |
+
self.mean_ = None; self.std_ = None
|
| 218 |
+
|
| 219 |
+
def fit(self, Y):
|
| 220 |
+
Yf = Y.reshape(-1, N_OUTPUTS)
|
| 221 |
+
self.mean_ = Yf.mean(0); self.std_ = Yf.std(0) + 1e-12
|
| 222 |
+
return self
|
| 223 |
+
|
| 224 |
+
def transform(self, Y): return (Y - self.mean_) / self.std_
|
| 225 |
+
def inverse_transform(self, Y): return Y * self.std_ + self.mean_
|
| 226 |
+
def fit_transform(self, Y): return self.fit(Y).transform(Y)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
# ─────────────────────────────────────────────
|
| 230 |
+
# 3. 층화 K-Fold 분할
|
| 231 |
+
# ─────────────────────────────────────────────
|
| 232 |
+
def make_strata(params):
|
| 233 |
+
"""k_decay × p_ve 기준 층 라벨 생성."""
|
| 234 |
+
kd = np.log10(np.clip(params[:, PARAM_NAMES.index("k_decay")], 1e-300, None))
|
| 235 |
+
pv = np.log10(np.clip(params[:, PARAM_NAMES.index("p_ve")], 1e-300, None))
|
| 236 |
+
kb = pd.qcut(kd, q=STRATIFY_BINS, labels=False, duplicates="drop")
|
| 237 |
+
pb = pd.qcut(pv, q=STRATIFY_BINS, labels=False, duplicates="drop")
|
| 238 |
+
return kb * (STRATIFY_BINS + 1) + pb
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def stratified_kfold_indices(params, n_folds, seed):
|
| 242 |
+
"""
|
| 243 |
+
층화 K-Fold: 각 층 내부를 n_folds로 나눠 fold마다 고르게 분배.
|
| 244 |
+
반환: fold_assign (각 케이스의 fold 번호 0..n_folds-1)
|
| 245 |
+
"""
|
| 246 |
+
rng = np.random.default_rng(seed)
|
| 247 |
+
strata = make_strata(params)
|
| 248 |
+
fold_assign = np.full(len(params), -1, dtype=int)
|
| 249 |
+
for s in np.unique(strata):
|
| 250 |
+
idx = np.where(strata == s)[0]
|
| 251 |
+
rng.shuffle(idx)
|
| 252 |
+
# 층 내부를 순환 배정 (각 fold에 고르게)
|
| 253 |
+
for j, i in enumerate(idx):
|
| 254 |
+
fold_assign[i] = j % n_folds
|
| 255 |
+
return fold_assign
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def split_train_val(train_idx, params, val_ratio, seed):
|
| 259 |
+
"""train 인덱스에서 val을 층화로 분리."""
|
| 260 |
+
rng = np.random.default_rng(seed)
|
| 261 |
+
strata = make_strata(params)[train_idx]
|
| 262 |
+
tr, va = [], []
|
| 263 |
+
for s in np.unique(strata):
|
| 264 |
+
local = np.where(strata == s)[0]
|
| 265 |
+
rng.shuffle(local)
|
| 266 |
+
nv = max(1, round(len(local) * val_ratio))
|
| 267 |
+
if len(local) - nv < 1:
|
| 268 |
+
tr.extend(train_idx[local].tolist()); continue
|
| 269 |
+
va.extend(train_idx[local[:nv]].tolist())
|
| 270 |
+
tr.extend(train_idx[local[nv:]].tolist())
|
| 271 |
+
return np.array(tr), np.array(va)
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ─────────────────────────────────────────────
|
| 275 |
+
# 4. 손실 / 모델
|
| 276 |
+
# ─────────────────────────────────────────────
|
| 277 |
+
def make_time_weighted_loss(time_arr, n_t):
|
| 278 |
+
w = np.exp(-time_arr / TIME_WEIGHT_TAU)
|
| 279 |
+
w = (w / w.mean()).astype(np.float32)
|
| 280 |
+
tw = tf.constant(w.reshape(1, n_t, 1), dtype=tf.float32)
|
| 281 |
+
|
| 282 |
+
@tf.function
|
| 283 |
+
def loss_fn(y_true, y_pred):
|
| 284 |
+
yt = tf.reshape(y_true, (-1, n_t, N_OUTPUTS))
|
| 285 |
+
yp = tf.reshape(y_pred, (-1, n_t, N_OUTPUTS))
|
| 286 |
+
return tf.reduce_mean(tf.square(yt - yp) * tw)
|
| 287 |
+
return loss_fn
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def build_mlp(n_t):
|
| 291 |
+
inp = keras.Input(shape=(N_PARAMS,), name="params")
|
| 292 |
+
x = inp
|
| 293 |
+
for h in HIDDEN_DIMS:
|
| 294 |
+
x = layers.Dense(h, kernel_regularizer=keras.regularizers.l2(L2) if L2 > 0 else None)(x)
|
| 295 |
+
x = layers.Activation(tf.nn.silu)(x)
|
| 296 |
+
if DROPOUT > 0:
|
| 297 |
+
x = layers.Dropout(DROPOUT)(x)
|
| 298 |
+
out = layers.Dense(n_t * N_OUTPUTS, activation="linear", name="curve")(x)
|
| 299 |
+
return keras.Model(inp, out, name="MLP_single")
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ─────────────────────────────────────────────
|
| 303 |
+
# 5. 평가 지표
|
| 304 |
+
# ─────────────────────────────────────────────
|
| 305 |
+
def evaluate(y_true, y_pred):
|
| 306 |
+
case_mae = np.abs(y_true - y_pred).mean(axis=(1, 2))
|
| 307 |
+
per = {name: float(mean_absolute_error(y_true[..., k].reshape(-1),
|
| 308 |
+
y_pred[..., k].reshape(-1)))
|
| 309 |
+
for k, name in enumerate(OUTPUT_NAMES)}
|
| 310 |
+
return {
|
| 311 |
+
"MAE_overall": float(mean_absolute_error(y_true.reshape(-1), y_pred.reshape(-1))),
|
| 312 |
+
"R2_overall": float(r2_score(y_true.reshape(-1), y_pred.reshape(-1))),
|
| 313 |
+
"case_MAE_mean": float(case_mae.mean()),
|
| 314 |
+
"case_MAE_max": float(case_mae.max()),
|
| 315 |
+
"case_MAE_p90": float(np.percentile(case_mae, 90)),
|
| 316 |
+
"case_MAE_std": float(case_mae.std()),
|
| 317 |
+
"per_compartment_MAE": per,
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# ─────────────────────────────────────────────
|
| 322 |
+
# 6. 단일 fold 학습
|
| 323 |
+
# ─────────────────────────────────────────────
|
| 324 |
+
def train_one_fold(fold, itr, iva, ite, params, curves, time_arr, n_t):
|
| 325 |
+
print(f"\n{'='*55}")
|
| 326 |
+
print(f" Fold {fold+1}/{N_FOLDS} (train={len(itr)}, val={len(iva)}, test={len(ite)})")
|
| 327 |
+
print(f"{'='*55}")
|
| 328 |
+
|
| 329 |
+
ps = ParamScaler()
|
| 330 |
+
X_tr = ps.fit_transform(params[itr]).astype(np.float32)
|
| 331 |
+
X_va = ps.transform(params[iva]).astype(np.float32)
|
| 332 |
+
X_te = ps.transform(params[ite]).astype(np.float32)
|
| 333 |
+
|
| 334 |
+
osc = OutputScaler(); osc.fit(curves[itr])
|
| 335 |
+
Y_tr = osc.transform(curves[itr]).reshape(len(itr), -1).astype(np.float32)
|
| 336 |
+
Y_va = osc.transform(curves[iva]).reshape(len(iva), -1).astype(np.float32)
|
| 337 |
+
Y_te_raw = curves[ite]
|
| 338 |
+
|
| 339 |
+
loss_fn = make_time_weighted_loss(time_arr, n_t)
|
| 340 |
+
|
| 341 |
+
set_seed(SEED) # fold마다 동일 초기화 (모델 변동 통제, 분할 효과만 측정)
|
| 342 |
+
model = build_mlp(n_t)
|
| 343 |
+
model.compile(optimizer=keras.optimizers.Adam(LEARNING_RATE, weight_decay=WEIGHT_DECAY),
|
| 344 |
+
loss=loss_fn, metrics=["mae"])
|
| 345 |
+
cbs = [
|
| 346 |
+
callbacks.EarlyStopping(monitor="val_loss", patience=PATIENCE_ES,
|
| 347 |
+
min_delta=MIN_DELTA, restore_best_weights=True, verbose=0),
|
| 348 |
+
callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5,
|
| 349 |
+
patience=PATIENCE_LR, min_delta=MIN_DELTA,
|
| 350 |
+
min_lr=1e-7, verbose=0),
|
| 351 |
+
]
|
| 352 |
+
h = model.fit(X_tr, Y_tr, validation_data=(X_va, Y_va),
|
| 353 |
+
epochs=EPOCHS, batch_size=BATCH_SIZE, callbacks=cbs, verbose=0)
|
| 354 |
+
ep = len(h.history["val_loss"])
|
| 355 |
+
|
| 356 |
+
pred = osc.inverse_transform(model.predict(X_te, verbose=0).reshape(-1, n_t, N_OUTPUTS))
|
| 357 |
+
metrics = evaluate(Y_te_raw, pred)
|
| 358 |
+
print(f" ep={ep} test MAE 평균={metrics['case_MAE_mean']:.3f}% "
|
| 359 |
+
f"최대={metrics['case_MAE_max']:.3f}% R2={metrics['R2_overall']:.4f}")
|
| 360 |
+
|
| 361 |
+
# fold 산출물 저장 (가중치 + 스케일러 → 재현/추론용)
|
| 362 |
+
fdir = OUTPUT_DIR / "folds" / f"fold{fold+1}"
|
| 363 |
+
fdir.mkdir(parents=True, exist_ok=True)
|
| 364 |
+
model.save_weights(str(fdir / "model.weights.h5"))
|
| 365 |
+
np.savez(fdir / "scalers.npz",
|
| 366 |
+
param_mean=ps.mean_, param_std=ps.std_, param_log_mask=ps.log_mask_,
|
| 367 |
+
out_mean=osc.mean_, out_std=osc.std_, time_arr=time_arr,
|
| 368 |
+
test_idx=ite)
|
| 369 |
+
return metrics, h, pred, Y_te_raw, ite
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
# ─────────────────────────────────────────────
|
| 373 |
+
# 7. 종합 시각화
|
| 374 |
+
# ─────────────────────────────────────────────
|
| 375 |
+
def plot_kfold_summary(fold_metrics, histories, save_path):
|
| 376 |
+
fig, axes = plt.subplots(1, 2, figsize=(13, 4.5))
|
| 377 |
+
fig.suptitle("K-Fold 교차검증 결과", fontsize=14, fontweight="bold", y=1.02)
|
| 378 |
+
|
| 379 |
+
# 왼쪽: fold별 평균/최대 MAE 막대
|
| 380 |
+
ax = axes[0]
|
| 381 |
+
folds = [f"F{i+1}" for i in range(len(fold_metrics))]
|
| 382 |
+
means = [m["case_MAE_mean"] for m in fold_metrics]
|
| 383 |
+
maxes = [m["case_MAE_max"] for m in fold_metrics]
|
| 384 |
+
x = np.arange(len(folds)); width = 0.38
|
| 385 |
+
ax.bar(x - width/2, means, width, label="평균 MAE", color="#2E5C8A")
|
| 386 |
+
ax.bar(x + width/2, maxes, width, label="최대 MAE", color="#C62828")
|
| 387 |
+
ax.axhline(np.mean(means), color="#2E5C8A", linestyle="--", linewidth=1,
|
| 388 |
+
alpha=0.7, label=f"평균의 평균 {np.mean(means):.3f}%")
|
| 389 |
+
ax.set_xticks(x); ax.set_xticklabels(folds)
|
| 390 |
+
ax.set_ylabel("MAE (%)", fontsize=11)
|
| 391 |
+
ax.set_title("Fold별 test MAE", fontsize=12)
|
| 392 |
+
ax.legend(fontsize=9); ax.grid(True, alpha=0.3, axis="y")
|
| 393 |
+
|
| 394 |
+
# 오른쪽: fold별 val loss 곡선
|
| 395 |
+
ax2 = axes[1]
|
| 396 |
+
for i, h in enumerate(histories):
|
| 397 |
+
vl = h.history["val_loss"]
|
| 398 |
+
ax2.plot(np.arange(1, len(vl)+1), vl, linewidth=1.3, alpha=0.8, label=f"Fold {i+1}")
|
| 399 |
+
ax2.set_xlabel("Epoch", fontsize=11); ax2.set_ylabel("Val Loss", fontsize=11)
|
| 400 |
+
ax2.set_title("Fold별 검증 손실", fontsize=12)
|
| 401 |
+
ax2.set_yscale("log")
|
| 402 |
+
ax2.legend(fontsize=8); ax2.grid(True, alpha=0.3)
|
| 403 |
+
|
| 404 |
+
plt.tight_layout()
|
| 405 |
+
plt.savefig(save_path, dpi=150, bbox_inches="tight")
|
| 406 |
+
plt.close(fig)
|
| 407 |
+
print(f"\n 요약 그래프 저장: {save_path}")
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# ─────────────────────────────────────────────
|
| 411 |
+
# 8. 메인
|
| 412 |
+
# ─────────────────────────────────────────────
|
| 413 |
+
def main():
|
| 414 |
+
print("=" * 60)
|
| 415 |
+
print(f" MLP {N_FOLDS}-Fold 교차검증")
|
| 416 |
+
print("=" * 60)
|
| 417 |
+
|
| 418 |
+
print("\n[1] 데이터 로드")
|
| 419 |
+
params, curves, time_arr, cases = load_data(DATA_PATH)
|
| 420 |
+
n_t = len(time_arr)
|
| 421 |
+
|
| 422 |
+
if EXCLUDE_SPARSE:
|
| 423 |
+
print("\n[1-b] 외딴 케이스 제외 (국소 불일치 IQR 기준)")
|
| 424 |
+
keep, sparse_idx, mismatch, thr = detect_sparse_cases(params, curves)
|
| 425 |
+
excl_cases = [cases[i] for i in sparse_idx]
|
| 426 |
+
print(f" 임계값(Q3+{SPARSE_IQR_K}×IQR) = {thr:.2f}%")
|
| 427 |
+
print(f" 제외 {len(sparse_idx)}개 / 유지 {keep.sum()}개")
|
| 428 |
+
print(f" 제외 케이스: {sorted(excl_cases)}")
|
| 429 |
+
params = params[keep]
|
| 430 |
+
curves = curves[keep]
|
| 431 |
+
cases = [c for i, c in enumerate(cases) if keep[i]]
|
| 432 |
+
|
| 433 |
+
print(f"\n[2] 층화 {N_FOLDS}-Fold 분할")
|
| 434 |
+
fold_assign = stratified_kfold_indices(params, N_FOLDS, SEED)
|
| 435 |
+
for f in range(N_FOLDS):
|
| 436 |
+
print(f" Fold {f+1}: test {int((fold_assign==f).sum())}개")
|
| 437 |
+
|
| 438 |
+
print("\n[3] Fold별 학습")
|
| 439 |
+
fold_metrics, histories = [], []
|
| 440 |
+
# 전체 케이스에 대한 out-of-fold 예측 (각 케이스가 test였을 때의 예측)
|
| 441 |
+
oof_pred = np.zeros_like(curves)
|
| 442 |
+
oof_filled = np.zeros(len(cases), dtype=bool)
|
| 443 |
+
|
| 444 |
+
for f in range(N_FOLDS):
|
| 445 |
+
ite = np.where(fold_assign == f)[0]
|
| 446 |
+
rest = np.where(fold_assign != f)[0]
|
| 447 |
+
itr, iva = split_train_val(rest, params, VAL_RATIO, SEED + f)
|
| 448 |
+
metrics, h, pred, _, test_idx = train_one_fold(
|
| 449 |
+
f, itr, iva, ite, params, curves, time_arr, n_t)
|
| 450 |
+
fold_metrics.append(metrics); histories.append(h)
|
| 451 |
+
oof_pred[test_idx] = pred
|
| 452 |
+
oof_filled[test_idx] = True
|
| 453 |
+
|
| 454 |
+
# ── 종합 ──
|
| 455 |
+
print("\n" + "=" * 60)
|
| 456 |
+
print(" K-Fold 종합 결과")
|
| 457 |
+
print("=" * 60)
|
| 458 |
+
keys = ["case_MAE_mean", "case_MAE_max", "case_MAE_p90", "R2_overall"]
|
| 459 |
+
labels = {"case_MAE_mean": "평균 MAE", "case_MAE_max": "최대 MAE",
|
| 460 |
+
"case_MAE_p90": "p90 MAE", "R2_overall": "R²"}
|
| 461 |
+
summary = {}
|
| 462 |
+
for k in keys:
|
| 463 |
+
vals = np.array([m[k] for m in fold_metrics])
|
| 464 |
+
summary[k] = {"mean": float(vals.mean()), "std": float(vals.std()),
|
| 465 |
+
"min": float(vals.min()), "max": float(vals.max())}
|
| 466 |
+
unit = "" if k == "R2_overall" else "%"
|
| 467 |
+
print(f" {labels[k]:10s}: {vals.mean():.3f}{unit} ± {vals.std():.3f} "
|
| 468 |
+
f"[{vals.min():.3f}, {vals.max():.3f}]")
|
| 469 |
+
|
| 470 |
+
# 전체 OOF 지표 (모든 케이스가 정확히 1번씩 test됨 → 가장 신뢰도 높은 단일 수치)
|
| 471 |
+
assert oof_filled.all(), "일부 케이스가 평가되지 않음"
|
| 472 |
+
oof_metrics = evaluate(curves, oof_pred)
|
| 473 |
+
print(f"\n [전체 OOF] 평균 MAE={oof_metrics['case_MAE_mean']:.3f}% "
|
| 474 |
+
f"최대={oof_metrics['case_MAE_max']:.3f}% "
|
| 475 |
+
f"p90={oof_metrics['case_MAE_p90']:.3f}% R²={oof_metrics['R2_overall']:.4f}")
|
| 476 |
+
print(" [전체 OOF 구획별 MAE]")
|
| 477 |
+
for name, v in oof_metrics["per_compartment_MAE"].items():
|
| 478 |
+
print(f" {name:<10}: {v:.3f}%")
|
| 479 |
+
|
| 480 |
+
print("\n[4] 저장")
|
| 481 |
+
with open(OUTPUT_DIR / "kfold_metrics.json", "w", encoding="utf-8") as fp:
|
| 482 |
+
json.dump({"per_fold": fold_metrics, "summary": summary,
|
| 483 |
+
"oof": oof_metrics}, fp, indent=2, ensure_ascii=False)
|
| 484 |
+
np.savez(OUTPUT_DIR / "oof_predictions.npz",
|
| 485 |
+
y_true=curves, y_pred=oof_pred, fold_assign=fold_assign, time_arr=time_arr)
|
| 486 |
+
plot_kfold_summary(fold_metrics, histories, OUTPUT_DIR / "kfold_summary.png")
|
| 487 |
+
print(f" 저장: {OUTPUT_DIR}/ (kfold_metrics.json, oof_predictions.npz, "
|
| 488 |
+
f"kfold_summary.png, folds/)")
|
| 489 |
+
print("\n완료.")
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
if __name__ == "__main__":
|
| 493 |
+
main()
|
src/requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
streamlit
|
| 2 |
+
tensorflow-cpu
|
| 3 |
+
numpy
|
| 4 |
+
pandas
|
| 5 |
+
openpyxl
|