guminhong commited on
Commit
d1ebc0e
·
verified ·
1 Parent(s): 27727e6

Upload 3 files

Browse files
Files changed (3) hide show
  1. src/kfold_mlp_app.py +350 -0
  2. src/mlp_kfold.py +493 -0
  3. 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