| """ |
| mlp_kfold.py β MLP λ¨μΌ λͺ¨λΈ + K-Fold κ΅μ°¨κ²μ¦ |
| ================================================ |
| λ¨μΌ λΆν μ μ΄(ι)μ μ κ±°νκ³ μ 체 λ°μ΄ν°μ λν΄ |
| νκ· Β±stdλ‘ μ λ’°μ± μλ μ±λ₯μ νκ°νλ€. |
| |
| μ€κ³: |
| - μΈ΅ν K-Fold (k_decay Γ p_ve κΈ°μ€)λ‘ λ°μ΄ν°λ₯Ό Kλ±λΆ |
| - κ° foldκ° ν λ²μ© testκ° λ¨ (μ 체 μΌμ΄μ€κ° μ νν 1λ²μ© νκ°λ¨) |
| - λλ¨Έμ§μμ val μΌλΆ λΆλ¦¬, λλ¨Έμ§ train |
| - Kκ° λͺ¨λΈμ test μ§νλ₯Ό νκ· Β±stdλ‘ λ³΄κ³ |
| - κ° fold λͺ¨λΈ/μ€μΌμΌλ¬ μ μ₯ |
| |
| λͺ¨λΈ μ€μ (κΈ°μ‘΄ μ΅μ ): |
| ꡬ쑰 [128,256,256,128] SiLU, dropout=0, l2=1e-5 |
| μκ°κ°μ€ MSE(tau=2000), epochs=1000, patience_es=80 |
| |
| μ€ν: python mlp_kfold.py |
| """ |
|
|
| from __future__ import annotations |
| import os |
| os.environ["PYTHONHASHSEED"] = "42" |
| os.environ["TF_DETERMINISTIC_OPS"] = "1" |
| os.environ["TF_CUDNN_DETERMINISTIC"] = "1" |
|
|
| import json |
| import random |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import matplotlib |
| matplotlib.use("Agg") |
| import matplotlib.pyplot as plt |
| import matplotlib.font_manager as fm |
|
|
| def set_korean_font(): |
| candidates = ["AppleGothic", "Malgun Gothic", "NanumGothic", |
| "NanumBarunGothic", "DejaVu Sans"] |
| available = {f.name for f in fm.fontManager.ttflist} |
| for font in candidates: |
| if font in available: |
| plt.rcParams["font.family"] = font |
| break |
| plt.rcParams["axes.unicode_minus"] = False |
|
|
| set_korean_font() |
|
|
| |
| import logging |
| logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR) |
| import warnings |
| warnings.filterwarnings("ignore", message="Glyph.*missing from font") |
| warnings.filterwarnings("ignore", message=".*does not have a glyph.*") |
| from sklearn.metrics import mean_absolute_error, r2_score |
|
|
| import tensorflow as tf |
| tf.get_logger().setLevel("ERROR") |
| import keras |
| from keras import layers, callbacks |
|
|
|
|
| |
| |
| |
| BASE_DIR = Path(__file__).resolve().parent |
| DATA_PATH = BASE_DIR / "μ μ _μ 체_long.xlsx" |
| OUTPUT_DIR = BASE_DIR / "outputs_kfold" |
| for d in (OUTPUT_DIR, OUTPUT_DIR / "folds"): |
| d.mkdir(parents=True, exist_ok=True) |
|
|
| SEED = 42 |
| N_OUTPUTS = 4 |
| N_PARAMS = 10 |
|
|
| PARAM_NAMES = ["Lp_ve", "K", "p_le", "sigma_ve", "sigma_le", |
| "p_ve", "D_gel", "k_decay", "kf_m", "kr_m"] |
| OUTPUT_NAMES = ["c_lymph", "c_vessel", "c_decay", "c_ecm"] |
| LOG_TRANSFORM_PARAMS = {"K", "p_le", "p_ve", "D_gel", "k_decay", "kr_m"} |
| EXCLUDE_CASES = {304, 312, 313, 317} |
|
|
| |
| N_FOLDS = 5 |
| VAL_RATIO = 0.15 |
| STRATIFY_BINS = 5 |
|
|
| |
| |
| |
| |
| |
| EXCLUDE_SPARSE = True |
| SPARSE_NEIGHBORS = 5 |
| SPARSE_IQR_K = 1.5 |
|
|
| |
| HIDDEN_DIMS = [128, 256, 256, 128] |
| DROPOUT = 0.0 |
| L2 = 1e-5 |
| TIME_WEIGHT_TAU = 2000.0 |
| EPOCHS = 1000 |
| BATCH_SIZE = 16 |
| LEARNING_RATE = 1e-3 |
| WEIGHT_DECAY = 1e-5 |
| PATIENCE_ES = 80 |
| PATIENCE_LR = 30 |
| MIN_DELTA = 1e-5 |
|
|
|
|
| def set_seed(seed=SEED): |
| random.seed(seed) |
| np.random.seed(seed) |
| tf.random.set_seed(seed) |
| try: |
| tf.config.experimental.enable_op_determinism() |
| except Exception: |
| pass |
|
|
|
|
| |
| |
| |
| def load_data(path): |
| df = pd.read_excel(path) |
| required = {"case", "time_min", *PARAM_NAMES, *OUTPUT_NAMES} |
| missing = required - set(df.columns) |
| if missing: |
| raise ValueError(f"λλ½λ 컬λΌ: {missing}") |
|
|
| if EXCLUDE_CASES: |
| before = df["case"].nunique() |
| df = df[~df["case"].isin(EXCLUDE_CASES)].copy() |
| print(f" μ μΈλ λ°μ° μΌμ΄μ€: {sorted(EXCLUDE_CASES)} ({before}->{df['case'].nunique()})") |
|
|
| cases = sorted(df["case"].unique()) |
| n = len(cases) |
| ref_t = df[df["case"] == cases[0]].sort_values("time_min")["time_min"].values |
| n_t = len(ref_t) |
|
|
| P = np.zeros((n, N_PARAMS), dtype=np.float64) |
| C = np.zeros((n, n_t, N_OUTPUTS), dtype=np.float64) |
| for i, c in enumerate(cases): |
| sub = df[df["case"] == c].sort_values("time_min") |
| if len(sub) != n_t or not np.allclose(sub["time_min"].values, ref_t): |
| raise ValueError(f"case {c}: μκ° κ·Έλ¦¬λ λΆμΌμΉ") |
| P[i] = sub[PARAM_NAMES].iloc[0].values |
| C[i] = sub[OUTPUT_NAMES].values |
|
|
| n_clip = (C < 0).sum() |
| if n_clip > 0: |
| print(f" μμ λλ ν΄λ¦¬ν: {n_clip}κ° -> 0 (μ΅μ {C.min():.4f})") |
| C = np.clip(C, 0.0, None) |
|
|
| print(f" λ‘λ μλ£: μΌμ΄μ€ {n}κ°, μκ°μ {n_t}κ°") |
| return P, C, ref_t, cases |
|
|
|
|
| |
| |
| |
|
|
|
|
| |
| |
| |
| def detect_sparse_cases(params, curves): |
| """ |
| κ° μΌμ΄μ€μ 곑μ μ΄ 'νλΌλ―Έν° κ³΅κ° μ΄μλ€μ 곑μ 'κ³Ό μΌλ§λ λ€λ₯Έμ§(κ΅μ λΆμΌμΉ) |
| μΈ‘μ ν΄, IQR μ΄μμΉλ₯Ό μΈλ΄ μΌμ΄μ€λ‘ νμ νλ€. |
| κ΅μ λΆμΌμΉκ° ν¬λ€ = νλΌλ―Έν°λ κ°κΉμ΄λ° κ±°λμ΄ κΈλ³ = λ°μ΄ν° ν΄μλ λΆμ‘± μμ. |
| |
| λ°ν: keep_mask (True=μ μ§), sparse_idx (μ μΈ μΈλ±μ€), mismatch (μ μ) |
| """ |
| from scipy.spatial.distance import cdist |
| log_mask = np.array([n in LOG_TRANSFORM_PARAMS for n in PARAM_NAMES], dtype=bool) |
| Pl = params.copy().astype(float) |
| Pl[:, log_mask] = np.log10(np.clip(Pl[:, log_mask], 1e-300, None)) |
| Psc = (Pl - Pl.mean(0)) / (Pl.std(0) + 1e-12) |
|
|
| D = cdist(Psc, Psc) |
| np.fill_diagonal(D, np.inf) |
|
|
| mismatch = np.zeros(len(params)) |
| for i in range(len(params)): |
| nn = np.argsort(D[i])[:SPARSE_NEIGHBORS] |
| neighbor_mean = curves[nn].mean(axis=0) |
| mismatch[i] = np.abs(curves[i] - neighbor_mean).mean() |
|
|
| q1, q3 = np.percentile(mismatch, [25, 75]) |
| thr = q3 + SPARSE_IQR_K * (q3 - q1) |
| sparse_idx = np.where(mismatch > thr)[0] |
| keep_mask = mismatch <= thr |
| return keep_mask, sparse_idx, mismatch, thr |
|
|
| class ParamScaler: |
| def __init__(self): |
| self.log_mask_ = np.array([n in LOG_TRANSFORM_PARAMS for n in PARAM_NAMES], dtype=bool) |
| self.mean_ = None; self.std_ = None |
|
|
| def _apply_log(self, X): |
| Xt = X.copy().astype(np.float64) |
| Xt[:, self.log_mask_] = np.log10(np.clip(Xt[:, self.log_mask_], 1e-300, None)) |
| return Xt |
|
|
| def fit(self, X): |
| Xt = self._apply_log(X) |
| self.mean_ = Xt.mean(0); self.std_ = Xt.std(0) + 1e-12 |
| return self |
|
|
| def transform(self, X): return (self._apply_log(X) - self.mean_) / self.std_ |
| def fit_transform(self, X): return self.fit(X).transform(X) |
|
|
|
|
| class OutputScaler: |
| def __init__(self): |
| self.mean_ = None; self.std_ = None |
|
|
| def fit(self, Y): |
| Yf = Y.reshape(-1, N_OUTPUTS) |
| self.mean_ = Yf.mean(0); self.std_ = Yf.std(0) + 1e-12 |
| return self |
|
|
| def transform(self, Y): return (Y - self.mean_) / self.std_ |
| def inverse_transform(self, Y): return Y * self.std_ + self.mean_ |
| def fit_transform(self, Y): return self.fit(Y).transform(Y) |
|
|
|
|
| |
| |
| |
| def make_strata(params): |
| """k_decay Γ p_ve κΈ°μ€ μΈ΅ λΌλ²¨ μμ±.""" |
| kd = np.log10(np.clip(params[:, PARAM_NAMES.index("k_decay")], 1e-300, None)) |
| pv = np.log10(np.clip(params[:, PARAM_NAMES.index("p_ve")], 1e-300, None)) |
| kb = pd.qcut(kd, q=STRATIFY_BINS, labels=False, duplicates="drop") |
| pb = pd.qcut(pv, q=STRATIFY_BINS, labels=False, duplicates="drop") |
| return kb * (STRATIFY_BINS + 1) + pb |
|
|
|
|
| def stratified_kfold_indices(params, n_folds, seed): |
| """ |
| μΈ΅ν K-Fold: κ° μΈ΅ λ΄λΆλ₯Ό n_foldsλ‘ λλ foldλ§λ€ κ³ λ₯΄κ² λΆλ°°. |
| λ°ν: fold_assign (κ° μΌμ΄μ€μ fold λ²νΈ 0..n_folds-1) |
| """ |
| rng = np.random.default_rng(seed) |
| strata = make_strata(params) |
| fold_assign = np.full(len(params), -1, dtype=int) |
| for s in np.unique(strata): |
| idx = np.where(strata == s)[0] |
| rng.shuffle(idx) |
| |
| for j, i in enumerate(idx): |
| fold_assign[i] = j % n_folds |
| return fold_assign |
|
|
|
|
| def split_train_val(train_idx, params, val_ratio, seed): |
| """train μΈλ±μ€μμ valμ μΈ΅νλ‘ λΆλ¦¬.""" |
| rng = np.random.default_rng(seed) |
| strata = make_strata(params)[train_idx] |
| tr, va = [], [] |
| for s in np.unique(strata): |
| local = np.where(strata == s)[0] |
| rng.shuffle(local) |
| nv = max(1, round(len(local) * val_ratio)) |
| if len(local) - nv < 1: |
| tr.extend(train_idx[local].tolist()); continue |
| va.extend(train_idx[local[:nv]].tolist()) |
| tr.extend(train_idx[local[nv:]].tolist()) |
| return np.array(tr), np.array(va) |
|
|
|
|
| |
| |
| |
| def make_time_weighted_loss(time_arr, n_t): |
| w = np.exp(-time_arr / TIME_WEIGHT_TAU) |
| w = (w / w.mean()).astype(np.float32) |
| tw = tf.constant(w.reshape(1, n_t, 1), dtype=tf.float32) |
|
|
| @tf.function |
| def loss_fn(y_true, y_pred): |
| yt = tf.reshape(y_true, (-1, n_t, N_OUTPUTS)) |
| yp = tf.reshape(y_pred, (-1, n_t, N_OUTPUTS)) |
| return tf.reduce_mean(tf.square(yt - yp) * tw) |
| return loss_fn |
|
|
|
|
| def build_mlp(n_t): |
| inp = keras.Input(shape=(N_PARAMS,), name="params") |
| x = inp |
| for h in HIDDEN_DIMS: |
| x = layers.Dense(h, kernel_regularizer=keras.regularizers.l2(L2) if L2 > 0 else None)(x) |
| x = layers.Activation(tf.nn.silu)(x) |
| if DROPOUT > 0: |
| x = layers.Dropout(DROPOUT)(x) |
| out = layers.Dense(n_t * N_OUTPUTS, activation="linear", name="curve")(x) |
| return keras.Model(inp, out, name="MLP_single") |
|
|
|
|
| |
| |
| |
| def evaluate(y_true, y_pred): |
| case_mae = np.abs(y_true - y_pred).mean(axis=(1, 2)) |
| per = {name: float(mean_absolute_error(y_true[..., k].reshape(-1), |
| y_pred[..., k].reshape(-1))) |
| for k, name in enumerate(OUTPUT_NAMES)} |
| return { |
| "MAE_overall": float(mean_absolute_error(y_true.reshape(-1), y_pred.reshape(-1))), |
| "R2_overall": float(r2_score(y_true.reshape(-1), y_pred.reshape(-1))), |
| "case_MAE_mean": float(case_mae.mean()), |
| "case_MAE_max": float(case_mae.max()), |
| "case_MAE_p90": float(np.percentile(case_mae, 90)), |
| "case_MAE_std": float(case_mae.std()), |
| "per_compartment_MAE": per, |
| } |
|
|
|
|
| |
| |
| |
| def train_one_fold(fold, itr, iva, ite, params, curves, time_arr, n_t): |
| print(f"\n{'='*55}") |
| print(f" Fold {fold+1}/{N_FOLDS} (train={len(itr)}, val={len(iva)}, test={len(ite)})") |
| print(f"{'='*55}") |
|
|
| ps = ParamScaler() |
| X_tr = ps.fit_transform(params[itr]).astype(np.float32) |
| X_va = ps.transform(params[iva]).astype(np.float32) |
| X_te = ps.transform(params[ite]).astype(np.float32) |
|
|
| osc = OutputScaler(); osc.fit(curves[itr]) |
| Y_tr = osc.transform(curves[itr]).reshape(len(itr), -1).astype(np.float32) |
| Y_va = osc.transform(curves[iva]).reshape(len(iva), -1).astype(np.float32) |
| Y_te_raw = curves[ite] |
|
|
| loss_fn = make_time_weighted_loss(time_arr, n_t) |
|
|
| set_seed(SEED) |
| model = build_mlp(n_t) |
| model.compile(optimizer=keras.optimizers.Adam(LEARNING_RATE, weight_decay=WEIGHT_DECAY), |
| loss=loss_fn, metrics=["mae"]) |
| cbs = [ |
| callbacks.EarlyStopping(monitor="val_loss", patience=PATIENCE_ES, |
| min_delta=MIN_DELTA, restore_best_weights=True, verbose=0), |
| callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.5, |
| patience=PATIENCE_LR, min_delta=MIN_DELTA, |
| min_lr=1e-7, verbose=0), |
| ] |
| h = model.fit(X_tr, Y_tr, validation_data=(X_va, Y_va), |
| epochs=EPOCHS, batch_size=BATCH_SIZE, callbacks=cbs, verbose=0) |
| ep = len(h.history["val_loss"]) |
|
|
| pred = osc.inverse_transform(model.predict(X_te, verbose=0).reshape(-1, n_t, N_OUTPUTS)) |
| metrics = evaluate(Y_te_raw, pred) |
| print(f" ep={ep} test MAE νκ· ={metrics['case_MAE_mean']:.3f}% " |
| f"μ΅λ={metrics['case_MAE_max']:.3f}% R2={metrics['R2_overall']:.4f}") |
|
|
| |
| fdir = OUTPUT_DIR / "folds" / f"fold{fold+1}" |
| fdir.mkdir(parents=True, exist_ok=True) |
| model.save_weights(str(fdir / "model.weights.h5")) |
| np.savez(fdir / "scalers.npz", |
| param_mean=ps.mean_, param_std=ps.std_, param_log_mask=ps.log_mask_, |
| out_mean=osc.mean_, out_std=osc.std_, time_arr=time_arr, |
| test_idx=ite) |
| return metrics, h, pred, Y_te_raw, ite |
|
|
|
|
| |
| |
| |
| def plot_kfold_summary(fold_metrics, histories, save_path): |
| fig, axes = plt.subplots(1, 2, figsize=(13, 4.5)) |
| fig.suptitle("K-Fold κ΅μ°¨κ²μ¦ κ²°κ³Ό", fontsize=14, fontweight="bold", y=1.02) |
|
|
| |
| ax = axes[0] |
| folds = [f"F{i+1}" for i in range(len(fold_metrics))] |
| means = [m["case_MAE_mean"] for m in fold_metrics] |
| maxes = [m["case_MAE_max"] for m in fold_metrics] |
| x = np.arange(len(folds)); width = 0.38 |
| ax.bar(x - width/2, means, width, label="νκ· MAE", color="#2E5C8A") |
| ax.bar(x + width/2, maxes, width, label="μ΅λ MAE", color="#C62828") |
| ax.axhline(np.mean(means), color="#2E5C8A", linestyle="--", linewidth=1, |
| alpha=0.7, label=f"νκ· μ νκ· {np.mean(means):.3f}%") |
| ax.set_xticks(x); ax.set_xticklabels(folds) |
| ax.set_ylabel("MAE (%)", fontsize=11) |
| ax.set_title("Foldλ³ test MAE", fontsize=12) |
| ax.legend(fontsize=9); ax.grid(True, alpha=0.3, axis="y") |
|
|
| |
| ax2 = axes[1] |
| for i, h in enumerate(histories): |
| vl = h.history["val_loss"] |
| ax2.plot(np.arange(1, len(vl)+1), vl, linewidth=1.3, alpha=0.8, label=f"Fold {i+1}") |
| ax2.set_xlabel("Epoch", fontsize=11); ax2.set_ylabel("Val Loss", fontsize=11) |
| ax2.set_title("Foldλ³ κ²μ¦ μμ€", fontsize=12) |
| ax2.set_yscale("log") |
| ax2.legend(fontsize=8); ax2.grid(True, alpha=0.3) |
|
|
| plt.tight_layout() |
| plt.savefig(save_path, dpi=150, bbox_inches="tight") |
| plt.close(fig) |
| print(f"\n μμ½ κ·Έλν μ μ₯: {save_path}") |
|
|
|
|
| |
| |
| |
| def main(): |
| print("=" * 60) |
| print(f" MLP {N_FOLDS}-Fold κ΅μ°¨κ²μ¦") |
| print("=" * 60) |
|
|
| print("\n[1] λ°μ΄ν° λ‘λ") |
| params, curves, time_arr, cases = load_data(DATA_PATH) |
| n_t = len(time_arr) |
|
|
| if EXCLUDE_SPARSE: |
| print("\n[1-b] μΈλ΄ μΌμ΄μ€ μ μΈ (κ΅μ λΆμΌμΉ IQR κΈ°μ€)") |
| keep, sparse_idx, mismatch, thr = detect_sparse_cases(params, curves) |
| excl_cases = [cases[i] for i in sparse_idx] |
| print(f" μκ³κ°(Q3+{SPARSE_IQR_K}ΓIQR) = {thr:.2f}%") |
| print(f" μ μΈ {len(sparse_idx)}κ° / μ μ§ {keep.sum()}κ°") |
| print(f" μ μΈ μΌμ΄μ€: {sorted(excl_cases)}") |
| params = params[keep] |
| curves = curves[keep] |
| cases = [c for i, c in enumerate(cases) if keep[i]] |
|
|
| print(f"\n[2] μΈ΅ν {N_FOLDS}-Fold λΆν ") |
| fold_assign = stratified_kfold_indices(params, N_FOLDS, SEED) |
| for f in range(N_FOLDS): |
| print(f" Fold {f+1}: test {int((fold_assign==f).sum())}κ°") |
|
|
| print("\n[3] Foldλ³ νμ΅") |
| fold_metrics, histories = [], [] |
| |
| oof_pred = np.zeros_like(curves) |
| oof_filled = np.zeros(len(cases), dtype=bool) |
|
|
| for f in range(N_FOLDS): |
| ite = np.where(fold_assign == f)[0] |
| rest = np.where(fold_assign != f)[0] |
| itr, iva = split_train_val(rest, params, VAL_RATIO, SEED + f) |
| metrics, h, pred, _, test_idx = train_one_fold( |
| f, itr, iva, ite, params, curves, time_arr, n_t) |
| fold_metrics.append(metrics); histories.append(h) |
| oof_pred[test_idx] = pred |
| oof_filled[test_idx] = True |
|
|
| |
| print("\n" + "=" * 60) |
| print(" K-Fold μ’
ν© κ²°κ³Ό") |
| print("=" * 60) |
| keys = ["case_MAE_mean", "case_MAE_max", "case_MAE_p90", "R2_overall"] |
| labels = {"case_MAE_mean": "νκ· MAE", "case_MAE_max": "μ΅λ MAE", |
| "case_MAE_p90": "p90 MAE", "R2_overall": "RΒ²"} |
| summary = {} |
| for k in keys: |
| vals = np.array([m[k] for m in fold_metrics]) |
| summary[k] = {"mean": float(vals.mean()), "std": float(vals.std()), |
| "min": float(vals.min()), "max": float(vals.max())} |
| unit = "" if k == "R2_overall" else "%" |
| print(f" {labels[k]:10s}: {vals.mean():.3f}{unit} Β± {vals.std():.3f} " |
| f"[{vals.min():.3f}, {vals.max():.3f}]") |
|
|
| |
| assert oof_filled.all(), "μΌλΆ μΌμ΄μ€κ° νκ°λμ§ μμ" |
| oof_metrics = evaluate(curves, oof_pred) |
| print(f"\n [μ 체 OOF] νκ· MAE={oof_metrics['case_MAE_mean']:.3f}% " |
| f"μ΅λ={oof_metrics['case_MAE_max']:.3f}% " |
| f"p90={oof_metrics['case_MAE_p90']:.3f}% RΒ²={oof_metrics['R2_overall']:.4f}") |
| print(" [μ 체 OOF ꡬνλ³ MAE]") |
| for name, v in oof_metrics["per_compartment_MAE"].items(): |
| print(f" {name:<10}: {v:.3f}%") |
|
|
| print("\n[4] μ μ₯") |
| with open(OUTPUT_DIR / "kfold_metrics.json", "w", encoding="utf-8") as fp: |
| json.dump({"per_fold": fold_metrics, "summary": summary, |
| "oof": oof_metrics}, fp, indent=2, ensure_ascii=False) |
| np.savez(OUTPUT_DIR / "oof_predictions.npz", |
| y_true=curves, y_pred=oof_pred, fold_assign=fold_assign, time_arr=time_arr) |
| plot_kfold_summary(fold_metrics, histories, OUTPUT_DIR / "kfold_summary.png") |
| print(f" μ μ₯: {OUTPUT_DIR}/ (kfold_metrics.json, oof_predictions.npz, " |
| f"kfold_summary.png, folds/)") |
| print("\nμλ£.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |