diff --git a/.gitignore b/.gitignore index 1f6eb73..f69f50e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ logs/ venv/ models/*.pkl data/*.parquet +.worktrees/ diff --git a/docs/plans/2026-03-01-oi-nan-epsilon-precision-threshold.md b/docs/plans/2026-03-01-oi-nan-epsilon-precision-threshold.md new file mode 100644 index 0000000..4af0aff --- /dev/null +++ b/docs/plans/2026-03-01-oi-nan-epsilon-precision-threshold.md @@ -0,0 +1,463 @@ +# OI NaN 마스킹 / 분모 epsilon / 정밀도 우선 임계값 구현 계획 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** OI 데이터 결측 구간을 np.nan으로 처리하고, 분모 연산을 1e-8 패턴으로 통일하며, 임계값 탐색을 정밀도 우선(최소 재현율 조건부)으로 변경한다. + +**Architecture:** +- `dataset_builder.py`: OI/펀딩비 nan 마스킹 + 분모 epsilon 통일 + `_rolling_zscore`의 nan-safe 처리 +- `mlx_filter.py`: `fit()` 정규화 시 `np.nanmean`/`np.nanstd` + `nan_to_num` 적용 +- `train_model.py`: 임계값 탐색 함수를 `precision_recall_curve` 기반으로 교체 +- `train_mlx_model.py`: 동일한 임계값 탐색 함수 적용 + +**Tech Stack:** numpy, pandas, scikit-learn(precision_recall_curve), lightgbm, mlx + +--- + +### Task 1: `dataset_builder.py` — OI/펀딩비 nan 마스킹 + +**Files:** +- Modify: `src/dataset_builder.py:261-268` +- Test: `tests/test_dataset_builder.py` + +**Step 1: 기존 테스트 실행 (기준선 확인)** + +```bash +python -m pytest tests/test_dataset_builder.py -v +``` +Expected: 기존 테스트 전부 PASS (변경 전 기준선) + +**Step 2: OI nan 마스킹 테스트 작성** + +`tests/test_dataset_builder.py`에 아래 테스트 추가: + +```python +def test_oi_nan_masking_no_column(): + """oi_change 컬럼이 없으면 전체가 nan이어야 한다.""" + import numpy as np + import pandas as pd + from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators + + # 최소한의 OHLCV 데이터 (지표 계산에 충분한 길이) + n = 100 + np.random.seed(0) + df = pd.DataFrame({ + "open": np.random.uniform(1, 2, n), + "high": np.random.uniform(2, 3, n), + "low": np.random.uniform(0.5, 1, n), + "close": np.random.uniform(1, 2, n), + "volume": np.random.uniform(1000, 5000, n), + }) + d = _calc_indicators(df) + sig = _calc_signals(d) + feat = _calc_features_vectorized(d, sig) + + # oi_change 컬럼이 없으면 oi_change 피처는 전부 nan이어야 함 + # (rolling zscore 후에도 nan이 전파되어야 함) + assert feat["oi_change"].isna().all(), "oi_change 컬럼 없을 때 전부 nan이어야 함" + + +def test_oi_nan_masking_with_zeros(): + """oi_change 컬럼이 있어도 0.0 구간은 nan으로 마스킹되어야 한다.""" + import numpy as np + import pandas as pd + from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators + + n = 100 + np.random.seed(0) + df = pd.DataFrame({ + "open": np.random.uniform(1, 2, n), + "high": np.random.uniform(2, 3, n), + "low": np.random.uniform(0.5, 1, n), + "close": np.random.uniform(1, 2, n), + "volume": np.random.uniform(1000, 5000, n), + "oi_change": np.concatenate([np.zeros(50), np.random.uniform(-0.1, 0.1, 50)]), + }) + d = _calc_indicators(df) + sig = _calc_signals(d) + feat = _calc_features_vectorized(d, sig) + + # 앞 50개 구간은 0이었으므로 nan으로 마스킹 → rolling zscore 후에도 nan 전파 + # 뒤 50개 구간은 실제 값이 있으므로 일부는 유한값이어야 함 + assert feat["oi_change"].iloc[50:].notna().any(), "실제 OI 값 구간에 유한값이 있어야 함" +``` + +**Step 3: 테스트 실행 (FAIL 확인)** + +```bash +python -m pytest tests/test_dataset_builder.py::test_oi_nan_masking_no_column tests/test_dataset_builder.py::test_oi_nan_masking_with_zeros -v +``` +Expected: FAIL (현재 0.0으로 채우므로 isna().all()이 False) + +**Step 4: `dataset_builder.py` 수정** + +`src/dataset_builder.py` 261~268줄을 아래로 교체: + +```python + # OI 변화율 / 펀딩비 피처 + # 컬럼 없으면 전체 nan, 있으면 0.0 구간(데이터 미제공 구간)을 nan으로 마스킹 + # LightGBM은 nan을 자체 처리; MLX는 fit()에서 nanmean/nanstd + nan_to_num 처리 + if "oi_change" in d.columns: + oi_raw = np.where(d["oi_change"].values == 0.0, np.nan, d["oi_change"].values) + else: + oi_raw = np.full(len(d), np.nan) + + if "funding_rate" in d.columns: + fr_raw = np.where(d["funding_rate"].values == 0.0, np.nan, d["funding_rate"].values) + else: + fr_raw = np.full(len(d), np.nan) + + result["oi_change"] = _rolling_zscore(oi_raw.astype(np.float64)) + result["funding_rate"] = _rolling_zscore(fr_raw.astype(np.float64)) +``` + +**Step 5: `_rolling_zscore` nan-safe 처리 확인 및 수정** + +`src/dataset_builder.py` `_rolling_zscore` 함수 (118~128줄)를 nan-safe하게 수정: + +```python +def _rolling_zscore(arr: np.ndarray, window: int = 288) -> np.ndarray: + """rolling window z-score 정규화. nan은 전파된다(nan-safe). + 15분봉 기준 3일(288캔들) 윈도우. min_periods=1로 초반 데이터도 활용.""" + s = pd.Series(arr.astype(np.float64)) + r = s.rolling(window=window, min_periods=1) + mean = r.mean() # pandas rolling은 nan을 자동으로 건너뜀 + std = r.std(ddof=0) + std = std.where(std >= 1e-8, other=1e-8) + z = (s - mean) / std + return z.values.astype(np.float32) +``` + +> 참고: pandas `rolling().mean()`은 기본적으로 nan을 건너뛰므로 별도 처리 불필요. +> nan 입력 → nan 출력이 자연스럽게 전파됨. + +**Step 6: 테스트 재실행 (PASS 확인)** + +```bash +python -m pytest tests/test_dataset_builder.py -v +``` +Expected: 모든 테스트 PASS + +**Step 7: 커밋** + +```bash +git add src/dataset_builder.py tests/test_dataset_builder.py +git commit -m "feat: OI/펀딩비 결측 구간을 np.nan으로 마스킹 (0.0 → nan)" +``` + +--- + +### Task 2: `dataset_builder.py` — 분모 epsilon 통일 + +**Files:** +- Modify: `src/dataset_builder.py:157-168` +- Test: `tests/test_dataset_builder.py` + +**Step 1: epsilon 통일 테스트 작성** + +`tests/test_dataset_builder.py`에 추가: + +```python +def test_epsilon_no_division_by_zero(): + """bb_range=0, close=0, vol_ma20=0 극단값에서 nan/inf가 발생하지 않아야 한다.""" + import numpy as np + import pandas as pd + from src.dataset_builder import _calc_features_vectorized, _calc_signals, _calc_indicators + + n = 100 + # close를 모두 같은 값으로 → bb_range=0 유발 + df = pd.DataFrame({ + "open": np.ones(n), + "high": np.ones(n), + "low": np.ones(n), + "close": np.ones(n), + "volume": np.ones(n), + }) + d = _calc_indicators(df) + sig = _calc_signals(d) + feat = _calc_features_vectorized(d, sig) + + numeric_cols = feat.select_dtypes(include=[np.number]).columns + assert not feat[numeric_cols].isin([np.inf, -np.inf]).any().any(), \ + "inf 값이 있으면 안 됨" +``` + +**Step 2: 테스트 실행 (기준선)** + +```bash +python -m pytest tests/test_dataset_builder.py::test_epsilon_no_division_by_zero -v +``` + +**Step 3: `_calc_features_vectorized` 분모 epsilon 통일** + +`src/dataset_builder.py` 157~168줄을 아래로 교체: + +```python + bb_range = bb_upper - bb_lower + bb_pct = (close - bb_lower) / (bb_range + 1e-8) + + ema_align = np.where( + (ema9 > ema21) & (ema21 > ema50), 1, + np.where( + (ema9 < ema21) & (ema21 < ema50), -1, 0 + ) + ).astype(np.float32) + + atr_pct = atr / (close + 1e-8) + vol_ratio = volume / (vol_ma20 + 1e-8) +``` + +그리고 상대강도 계산 (246~247줄): + +```python + xrp_btc_rs_raw = (xrp_r1 / (btc_r1 + 1e-8)).astype(np.float32) + xrp_eth_rs_raw = (xrp_r1 / (eth_r1 + 1e-8)).astype(np.float32) +``` + +**Step 4: 테스트 재실행** + +```bash +python -m pytest tests/test_dataset_builder.py -v +``` +Expected: 모든 테스트 PASS + +**Step 5: 커밋** + +```bash +git add src/dataset_builder.py tests/test_dataset_builder.py +git commit -m "refactor: 분모 연산을 1e-8 epsilon 패턴으로 통일" +``` + +--- + +### Task 3: `mlx_filter.py` — nan-safe 정규화 + +**Files:** +- Modify: `src/mlx_filter.py:140-145` +- Test: `tests/test_mlx_filter.py` + +**Step 1: nan-safe 정규화 테스트 작성** + +`tests/test_mlx_filter.py`에 추가: + +```python +def test_fit_with_nan_features(): + """oi_change 피처에 nan이 포함된 경우 학습이 정상 완료되어야 한다.""" + import numpy as np + import pandas as pd + from src.mlx_filter import MLXFilter + from src.ml_features import FEATURE_COLS + + n = 300 + np.random.seed(42) + X = pd.DataFrame( + np.random.randn(n, len(FEATURE_COLS)).astype(np.float32), + columns=FEATURE_COLS, + ) + # oi_change 앞 절반을 nan으로 + X["oi_change"] = np.where(np.arange(n) < n // 2, np.nan, X["oi_change"]) + y = pd.Series((np.random.rand(n) > 0.5).astype(np.float32)) + + model = MLXFilter(input_dim=len(FEATURE_COLS), hidden_dim=32, epochs=3) + model.fit(X, y) # nan 있어도 예외 없이 완료되어야 함 + + proba = model.predict_proba(X) + assert not np.any(np.isnan(proba)), "예측 확률에 nan이 없어야 함" + assert proba.min() >= 0.0 and proba.max() <= 1.0 +``` + +**Step 2: 테스트 실행 (FAIL 확인)** + +```bash +python -m pytest tests/test_mlx_filter.py::test_fit_with_nan_features -v +``` +Expected: FAIL (현재 nan이 그대로 들어가 loss=nan 발생) + +**Step 3: `mlx_filter.py` fit() 정규화 수정** + +`src/mlx_filter.py` 140~145줄을 아래로 교체: + +```python + X_np = X[FEATURE_COLS].values.astype(np.float32) + y_np = y.values.astype(np.float32) + + # nan-safe 정규화: nanmean/nanstd로 통계 계산 후 nan → 0.0 대치 + # (z-score 후 0.0 = 평균값, 신경망에 줄 수 있는 가장 무난한 결측 대치값) + self._mean = np.nanmean(X_np, axis=0) + self._std = np.nanstd(X_np, axis=0) + 1e-8 + X_np = (X_np - self._mean) / self._std + X_np = np.nan_to_num(X_np, nan=0.0) +``` + +**Step 4: `predict_proba`도 nan_to_num 적용** + +`src/mlx_filter.py` 185~189줄: + +```python + def predict_proba(self, X: pd.DataFrame) -> np.ndarray: + X_np = X[FEATURE_COLS].values.astype(np.float32) + if self._trained and self._mean is not None: + X_np = (X_np - self._mean) / self._std + X_np = np.nan_to_num(X_np, nan=0.0) +``` + +**Step 5: 테스트 재실행** + +```bash +python -m pytest tests/test_mlx_filter.py -v +``` +Expected: 모든 테스트 PASS + +**Step 6: 커밋** + +```bash +git add src/mlx_filter.py tests/test_mlx_filter.py +git commit -m "fix: MLXFilter fit/predict에 nan-safe 정규화 적용 (nanmean + nan_to_num)" +``` + +--- + +### Task 4: `train_model.py` — 정밀도 우선 임계값 탐색 + +**Files:** +- Modify: `scripts/train_model.py:236-246` +- Test: 없음 (스크립트 레벨 변경, 수동 검증) + +**Step 1: `train_model.py` 임계값 탐색 교체** + +`scripts/train_model.py` 234~246줄을 아래로 교체: + +```python + val_proba = model.predict_proba(X_val)[:, 1] + auc = roc_auc_score(y_val, val_proba) + + # 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화 + from sklearn.metrics import precision_recall_curve + precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba) + # precision_recall_curve의 마지막 원소는 (1.0, 0.0)이므로 제외 + precisions, recalls = precisions[:-1], recalls[:-1] + + MIN_RECALL = 0.15 + valid_idx = np.where(recalls >= MIN_RECALL)[0] + if len(valid_idx) > 0: + best_idx = valid_idx[np.argmax(precisions[valid_idx])] + best_thr = float(thresholds[best_idx]) + best_prec = float(precisions[best_idx]) + best_rec = float(recalls[best_idx]) + else: + best_thr, best_prec, best_rec = 0.50, 0.0, 0.0 + print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용") + + print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} " + f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})") + print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0)) +``` + +그리고 로그 저장 부분 (261~271줄)에 임계값 정보 추가: + +```python + log.append({ + "date": datetime.now().isoformat(), + "backend": "lgbm", + "auc": round(auc, 4), + "best_threshold": round(best_thr, 4), + "best_precision": round(best_prec, 3), + "best_recall": round(best_rec, 3), + "samples": len(dataset), + "features": len(actual_feature_cols), + "time_weight_decay": time_weight_decay, + "model_path": str(MODEL_PATH), + }) +``` + +**Step 2: 수동 검증 (dry-run)** + +```bash +python scripts/train_model.py --data data/combined_15m.parquet 2>&1 | tail -30 +``` +Expected: "최적 임계값: X.XXXX (Precision=X.XXX, Recall=X.XXX)" 형태 출력 + +**Step 3: 커밋** + +```bash +git add scripts/train_model.py +git commit -m "feat: LightGBM 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경" +``` + +--- + +### Task 5: `train_mlx_model.py` — 동일한 임계값 탐색 적용 + +**Files:** +- Modify: `scripts/train_mlx_model.py:119-122` + +**Step 1: `train_mlx_model.py` 임계값 탐색 교체** + +`scripts/train_mlx_model.py` 119~122줄을 아래로 교체: + +```python + val_proba = model.predict_proba(X_val) + auc = roc_auc_score(y_val, val_proba) + + # 최적 임계값 탐색: 최소 재현율(0.15) 조건부 정밀도 최대화 + from sklearn.metrics import precision_recall_curve, classification_report + precisions, recalls, thresholds = precision_recall_curve(y_val, val_proba) + precisions, recalls = precisions[:-1], recalls[:-1] + + MIN_RECALL = 0.15 + valid_idx = np.where(recalls >= MIN_RECALL)[0] + if len(valid_idx) > 0: + best_idx = valid_idx[np.argmax(precisions[valid_idx])] + best_thr = float(thresholds[best_idx]) + best_prec = float(precisions[best_idx]) + best_rec = float(recalls[best_idx]) + else: + best_thr, best_prec, best_rec = 0.50, 0.0, 0.0 + print(f" [경고] recall >= {MIN_RECALL} 조건 만족 임계값 없음 → 기본값 0.50 사용") + + print(f"\n검증 AUC: {auc:.4f} | 최적 임계값: {best_thr:.4f} " + f"(Precision={best_prec:.3f}, Recall={best_rec:.3f})") + print(classification_report(y_val, (val_proba >= best_thr).astype(int), zero_division=0)) +``` + +그리고 로그 저장 부분에 임계값 정보 추가: + +```python + log.append({ + "date": datetime.now().isoformat(), + "backend": "mlx", + "auc": round(auc, 4), + "best_threshold": round(best_thr, 4), + "best_precision": round(best_prec, 3), + "best_recall": round(best_rec, 3), + "samples": len(dataset), + "train_sec": round(t3 - t2, 1), + "time_weight_decay": time_weight_decay, + "model_path": str(MLX_MODEL_PATH), + }) +``` + +**Step 2: 커밋** + +```bash +git add scripts/train_mlx_model.py +git commit -m "feat: MLX 임계값 탐색을 정밀도 우선(recall>=0.15 조건부)으로 변경" +``` + +--- + +### Task 6: 전체 테스트 통과 확인 + +**Step 1: 전체 테스트 실행** + +```bash +python -m pytest tests/ -v --tb=short 2>&1 | tail -40 +``` +Expected: 모든 테스트 PASS + +**Step 2: 최종 커밋 (필요 시)** + +```bash +git add -A +git commit -m "chore: OI nan 마스킹 / epsilon 통일 / 정밀도 우선 임계값 전체 통합" +``` diff --git a/models/mlx_filter.meta.npz b/models/mlx_filter.meta.npz index 5b2f111..73636bc 100644 Binary files a/models/mlx_filter.meta.npz and b/models/mlx_filter.meta.npz differ diff --git a/models/mlx_filter.npz b/models/mlx_filter.npz index bc1a242..b020242 100644 Binary files a/models/mlx_filter.npz and b/models/mlx_filter.npz differ diff --git a/models/mlx_filter.onnx b/models/mlx_filter.onnx index a7c90e3..7a37287 100644 Binary files a/models/mlx_filter.onnx and b/models/mlx_filter.onnx differ diff --git a/models/training_log.json b/models/training_log.json index 395e5ad..2ab3344 100644 --- a/models/training_log.json +++ b/models/training_log.json @@ -189,5 +189,32 @@ "train_sec": 0.1, "time_weight_decay": 2.0, "model_path": "models/mlx_filter.weights" + }, + { + "date": "2026-03-01T22:26:46.459326", + "backend": "mlx", + "auc": 0.6167, + "samples": 533, + "train_sec": 0.2, + "time_weight_decay": 2.0, + "model_path": "models/mlx_filter.weights" + }, + { + "date": "2026-03-01T22:45:55.473533", + "backend": "lgbm", + "auc": 0.556, + "samples": 533, + "features": 23, + "time_weight_decay": 2.0, + "model_path": "models/lgbm_filter.pkl" + }, + { + "date": "2026-03-01T23:04:51.194544", + "backend": "mlx", + "auc": 0.5972, + "samples": 533, + "train_sec": 0.1, + "time_weight_decay": 2.0, + "model_path": "models/mlx_filter.weights" } ] \ No newline at end of file diff --git a/scripts/train_and_deploy.sh b/scripts/train_and_deploy.sh index 82dd7ee..f6b0d2b 100755 --- a/scripts/train_and_deploy.sh +++ b/scripts/train_and_deploy.sh @@ -6,7 +6,8 @@ # bash scripts/train_and_deploy.sh # LightGBM + Walk-Forward 5폴드 (기본값) # bash scripts/train_and_deploy.sh mlx # MLX GPU 학습 + Walk-Forward 5폴드 # bash scripts/train_and_deploy.sh lgbm 3 # LightGBM + Walk-Forward 3폴드 -# bash scripts/train_and_deploy.sh lgbm 0 # Walk-Forward 건너뜀 (단일 학습만) +# bash scripts/train_and_deploy.sh mlx 0 # MLX 학습만 (Walk-Forward 건너뜀) +# bash scripts/train_and_deploy.sh lgbm 0 # LightGBM 학습만 (Walk-Forward 건너뜀) set -euo pipefail @@ -44,15 +45,23 @@ else python scripts/train_model.py --data data/combined_15m.parquet --decay "$DECAY" fi -# Walk-Forward 검증 (WF_SPLITS > 0 인 경우, lgbm 백엔드만 지원) -if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null && [ "$BACKEND" != "mlx" ]; then +# Walk-Forward 검증 (WF_SPLITS > 0 인 경우) +if [ "$WF_SPLITS" -gt 0 ] 2>/dev/null; then echo "" echo "=== [2.5/3] Walk-Forward 검증 (${WF_SPLITS}폴드) ===" - python scripts/train_model.py \ - --data data/combined_15m.parquet \ - --decay "$DECAY" \ - --wf \ - --wf-splits "$WF_SPLITS" + if [ "$BACKEND" = "mlx" ]; then + python scripts/train_mlx_model.py \ + --data data/combined_15m.parquet \ + --decay "$DECAY" \ + --wf \ + --wf-splits "$WF_SPLITS" + else + python scripts/train_model.py \ + --data data/combined_15m.parquet \ + --decay "$DECAY" \ + --wf \ + --wf-splits "$WF_SPLITS" + fi fi echo "" diff --git a/scripts/train_mlx_model.py b/scripts/train_mlx_model.py index 9efa2a2..d6008b1 100644 --- a/scripts/train_mlx_model.py +++ b/scripts/train_mlx_model.py @@ -144,6 +144,92 @@ def train_mlx(data_path: str, time_weight_decay: float = 2.0) -> float: return auc +def walk_forward_auc( + data_path: str, + time_weight_decay: float = 2.0, + n_splits: int = 5, + train_ratio: float = 0.6, +) -> None: + """Walk-Forward 검증: 슬라이딩 윈도우로 n_splits번 학습/검증 반복.""" + print(f"\n=== Walk-Forward 검증 ({n_splits}폴드, decay={time_weight_decay}) ===") + raw = pd.read_parquet(data_path) + df, btc_df, eth_df = _split_combined(raw) + + dataset = generate_dataset_vectorized( + df, btc_df=btc_df, eth_df=eth_df, time_weight_decay=time_weight_decay + ) + missing = [c for c in FEATURE_COLS if c not in dataset.columns] + for col in missing: + dataset[col] = 0.0 + + X_all = dataset[FEATURE_COLS].values.astype(np.float32) + y_all = dataset["label"].values.astype(np.float32) + w_all = dataset["sample_weight"].values.astype(np.float32) + n = len(dataset) + + step = max(1, int(n * (1 - train_ratio) / n_splits)) + train_end_start = int(n * train_ratio) + + aucs = [] + for i in range(n_splits): + tr_end = train_end_start + i * step + val_end = tr_end + step + if val_end > n: + break + + X_tr_raw = X_all[:tr_end] + y_tr = y_all[:tr_end] + w_tr = w_all[:tr_end] + X_val_raw = X_all[tr_end:val_end] + y_val = y_all[tr_end:val_end] + + pos_idx = np.where(y_tr == 1)[0] + neg_idx = np.where(y_tr == 0)[0] + if len(neg_idx) > len(pos_idx): + np.random.seed(42) + neg_idx = np.random.choice(neg_idx, size=len(pos_idx), replace=False) + bal_idx = np.sort(np.concatenate([pos_idx, neg_idx])) + + X_tr_bal = X_tr_raw[bal_idx] + y_tr_bal = y_tr[bal_idx] + w_tr_bal = w_tr[bal_idx] + + # 폴드별 정규화 (학습 데이터 기준으로 계산, 검증에도 동일 적용) + mean = X_tr_bal.mean(axis=0) + std = X_tr_bal.std(axis=0) + 1e-8 + X_tr_norm = (X_tr_bal - mean) / std + X_val_norm = (X_val_raw - mean) / std + + # DataFrame으로 래핑해서 MLXFilter.fit()에 전달 + # fit() 내부 정규화가 덮어쓰지 않도록 이미 정규화된 데이터를 넘기고 + # _mean=0, _std=1로 고정해 이중 정규화를 방지 + X_tr_df = pd.DataFrame(X_tr_norm, columns=FEATURE_COLS) + X_val_df = pd.DataFrame(X_val_norm, columns=FEATURE_COLS) + + model = MLXFilter( + input_dim=len(FEATURE_COLS), + hidden_dim=128, + lr=1e-3, + epochs=100, + batch_size=256, + ) + model.fit(X_tr_df, pd.Series(y_tr_bal), sample_weight=w_tr_bal) + # fit()이 내부에서 다시 정규화하므로 저장된 mean/std를 항등 변환으로 교체 + model._mean = np.zeros(len(FEATURE_COLS), dtype=np.float32) + model._std = np.ones(len(FEATURE_COLS), dtype=np.float32) + + proba = model.predict_proba(X_val_df) + auc = roc_auc_score(y_val, proba) if len(np.unique(y_val)) > 1 else 0.5 + aucs.append(auc) + print( + f" 폴드 {i+1}/{n_splits}: 학습={tr_end}개, " + f"검증={tr_end}~{val_end} ({step}개), AUC={auc:.4f}" + ) + + print(f"\n Walk-Forward 평균 AUC: {np.mean(aucs):.4f} ± {np.std(aucs):.4f}") + print(f" 폴드별: {[round(a, 4) for a in aucs]}") + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--data", default="data/combined_15m.parquet") @@ -151,8 +237,14 @@ def main(): "--decay", type=float, default=2.0, help="시간 가중치 감쇠 강도 (0=균등, 2.0=최신이 ~7.4배 높음)", ) + parser.add_argument("--wf", action="store_true", help="Walk-Forward 검증 실행") + parser.add_argument("--wf-splits", type=int, default=5, help="Walk-Forward 폴드 수") args = parser.parse_args() - train_mlx(args.data, time_weight_decay=args.decay) + + if args.wf: + walk_forward_auc(args.data, time_weight_decay=args.decay, n_splits=args.wf_splits) + else: + train_mlx(args.data, time_weight_decay=args.decay) if __name__ == "__main__":