목차 — M3 머신러닝 모듈
1. 모듈 개요
M3 머신러닝 모듈은 M2에서 생성한 75개 피처를 입력으로 3종 ML 분류기(RandomForest, XGBoost, LightGBM)와 2종 DL 시계열 분류기(LSTM, Transformer)를 학습하고, Optuna로 하이퍼파라미터를 자동 튜닝한 뒤, 종목별 BUY/SELL/HOLD 시그널을 생성하는 파이프라인이다. 학습부터 예측까지 전 과정이 자동화되어 있으며, 모델 버전 관리와 성능 이력을 DB에 기록한다.
| 항목 | 수치 |
|---|---|
| 알고리즘 | RandomForest, XGBoost, LightGBM + LSTM, Transformer (5종) |
| 입력 피처 | 최대 75개 (NaN 50% 초과 피처 자동 제외) |
| 타겟 변수 | target_class_1d (1일), target_class_5d (5일) — 이진 분류 |
| 튜닝 | Optuna 50 trials, F1 Score maximize, YAML 기반 탐색 공간 |
| 시그널 | BUY (prob ≥ 0.6) / SELL (prob ≤ 0.4) / HOLD |
| 평가 지표 | Accuracy, Precision, Recall, F1, AUC-ROC |
| DB 테이블 | 4개 (feature_store, ml_model, ml_training_log, ml_prediction) |
2. 아키텍처 — ML 파이프라인
M3 파이프라인은 크게 학습(Train)과 예측(Predict) 두 경로로 나뉜다. 학습 스케줄러가 데이터 수집부터 모델 학습까지 5단계를 자동 오케스트레이션하며, 예측 파이프라인은 활성 모델을 로드하여 실시간 시그널을 생성한다.
학습 경로 (Train)
가격수집 → KIS수집 → DART수집 → 피처계산 → 학습
시계열 분할 → NaN 필터 → Impute → RobustScaler → Optuna 튜닝 → 학습 → 평가
sklearn
xgboost
lightgbm
메타데이터 + 성능지표
학습 이력 + Optuna 결과
.joblib 파일
예측 경로 (Predict)
모델 로드 → 피처 추출 → NaN 처리 → 스케일링 → predict_proba → 시그널 생성
BUY / SELL / HOLD + confidence
예측 결과 DB 저장
3. 학습 파이프라인
ModelTrainer.train()은 데이터 로드부터 모델 저장까지 11단계를 순차 실행한다. 시계열 데이터의 특성을 반영하여 미래 데이터 누수(data leakage)를 방지하는 것이 핵심이다.
3-1. 데이터 분할 — 시계열 순서 보장
일반 ML과 달리 shuffle=False로 시간 순서를 유지한다. 과거 데이터로 학습하고 미래 데이터로 검증하는 구조다.
| 구간 | 비율 | 용도 |
|---|---|---|
| Train | 85% | 모델 학습 (fit) |
| Validation | 15% | Optuna 튜닝 + 평가 |
| Test | 나머지 | 최종 평가 (있으면 사용, 없으면 val로 대체) |
# trainer.py — _split_data() n = len(df) train_end = int(n * train_ratio) # 85% val_end = int(n * (train_ratio + val_ratio)) # 100% train_df = df.iloc[:train_end].copy() # 과거 → 학습 val_df = df.iloc[train_end:val_end].copy() # 중간 → 검증 test_df = df.iloc[val_end:].copy() # 최신 → 평가
Update: _split_data()는 현재 BaseTrainer에서 상속받아 사용한다. ML/DL/RL Trainer 모두 공통 분할 로직을 base_trainer.py에서 상속받으며, 단일 85/15 분할 외에 tune_hyperparameters_cv()를 통한 Purged Expanding Window CV 방식도 사용할 수 있다.
3-2. NaN 처리 전략
피처별 NaN 비율이 50% 이상이면 해당 피처를 자동 제외하고, 나머지 NaN은 알고리즘별로 처리한다.
| 단계 | 처리 | 상세 |
|---|---|---|
| 피처 필터 | NaN ≥ 50% 제외 | _filter_features_by_nan(threshold=0.5) |
| RandomForest | 중앙값 대체 | SimpleImputer(strategy="median") |
| XGBoost / LightGBM | 내장 NaN 처리 | 트리 분기 시 NaN을 최적 방향으로 자동 할당 |
3-3. 스케일링
RobustScaler를 사용한다. 중앙값과 IQR(사분위 범위) 기반이라 이상치(극단적 수익률, 급등/급락)에 강건하다. StandardScaler 대비 금융 데이터에 적합하다.
3-4. 학습 → 평가 → 저장
# 학습 11-Step 핵심 흐름 ① 학습 이력 생성 (ml_training_log, status=running) ② 데이터 로드 (feature_store → DataFrame, 최소 100행 검증) ③ NaN 비율 높은 피처 자동 제외 (threshold=0.5) ④ 시계열 분할 (85% train / 15% val) ⑤ NaN Impute (RandomForest만 — median) ⑥ RobustScaler 적용 ⑦ Optuna 하이퍼파라미터 튜닝 (50 trials, F1 maximize) ⑧ 최적 파라미터로 최종 학습 (model.fit) ⑨ 평가 (accuracy, precision, recall, F1, AUC-ROC) ⑩ 모델 번들 저장 (joblib — model+scaler+features+imputer) ⑪ DB 저장 (ml_model + ml_training_log 업데이트)
3-5. 평가 지표
| 지표 | 의미 | 용도 |
|---|---|---|
| Accuracy | 전체 정확도 | 전반적 성능 |
| Precision | 양성 예측 정확도 | BUY 시그널 신뢰도 |
| Recall | 양성 검출률 | 상승 종목 포착률 |
| F1 Score | Precision×Recall 조화평균 | Optuna 최적화 목표 |
| AUC-ROC | 확률 기반 판별력 | 모델 전반 판별 능력 |
4. Optuna 하이퍼파라미터 튜닝
하이퍼파라미터 탐색 공간을 ml_config.yaml에 선언하고, tuner.py가 이를 읽어 제네릭 objective 함수로 모든 알고리즘을 동일한 로직으로 튜닝한다. 알고리즘 추가 시 YAML만 수정하면 된다.
4-1. 제네릭 설계
# tuner.py — 핵심 흐름 def _generic_objective(trial, algorithm, X_train, y_train, X_val, y_val): # 1. YAML에서 탐색 파라미터 생성 params = _build_params_from_space(trial, algorithm) # 2. YAML 기본값과 병합 defaults = get_algorithm_defaults(algorithm).copy() defaults.update(params) # 3. 분류기 동적 생성 + 학습 clf_class = get_classifier_class(algorithm) model = clf_class(**defaults) model.fit(X_train, y_train) y_pred = model.predict(X_val) return f1_score(y_val, y_pred, average="binary") study = optuna.create_study(direction="maximize") study.optimize(objective, n_trials=50)
Update: 단일 train/val 분할 기반의 tune_hyperparameters() 외에, tune_hyperparameters_cv()가 추가되었다. 이 메서드는 Purged Expanding Window CV 방식으로 시계열 교차 검증을 수행하여, 단일 분할보다 과적합에 강건한 하이퍼파라미터를 탐색한다. 각 fold에서 학습 윈도우가 확장되며, fold 간 purge gap을 두어 데이터 누수를 방지한다.
4-2. 알고리즘별 탐색 공간
| 알고리즘 | 탐색 파라미터 | 범위 |
|---|---|---|
| RandomForest | n_estimators | 100 ~ 500 (step 50) |
| max_depth | 5 ~ 20 | |
| min_samples_split | 2 ~ 20 | |
| min_samples_leaf | 1 ~ 10 | |
| XGBoost | n_estimators | 100 ~ 500 (step 50) |
| max_depth | 3 ~ 10 | |
| learning_rate | 0.01 ~ 0.3 (log scale) | |
| subsample / colsample_bytree | 0.6 ~ 1.0 | |
| scale_pos_weight | 0.5 ~ 2.0 | |
| LightGBM | n_estimators | 100 ~ 500 (step 50) |
| num_leaves | 15 ~ 63 | |
| learning_rate | 0.01 ~ 0.3 (log scale) | |
| feature_fraction / bagging_fraction | 0.6 ~ 1.0 | |
| bagging_freq | 1 ~ 7 |
4-3. 클래스 불균형 보정
주가 상승/하락이 정확히 50:50은 아니므로, 각 알고리즘이 내장 불균형 보정을 사용한다.
| 알고리즘 | 보정 방식 | 설정 |
|---|---|---|
| RandomForest | class_weight | "balanced" — 클래스 빈도 역수로 자동 가중 |
| XGBoost | scale_pos_weight | Optuna가 0.5~2.0 범위에서 최적값 탐색 |
| LightGBM | is_unbalance | true — 내장 불균형 자동 처리 |
5. 예측 파이프라인
Predictor 클래스는 학습된 모델을 로드하여 실시간 예측을 수행한다. 단일 종목(predict_single)과 마켓 전체(predict_market) 두 가지 모드를 지원한다.
5-1. 예측 6단계
# predictor.py — _run_prediction() ① 모델 파일 로드 (joblib — model + scaler + features + imputer 번들) ② 피처 추출 (feature_store 행에서 학습 시 사용한 피처만 추출) ③ NaN 처리 (imputer 있으면 transform, 없으면 nan_to_num(0.0)) ④ 스케일링 (저장된 RobustScaler.transform) ⑤ 예측 (predict + predict_proba → probability_up, probability_down) ⑥ 시그널 생성 (generate_signal → BUY/SELL/HOLD + confidence)
5-2. 마켓 전체 예측
predict_market(market)은 해당 시장의 전 종목을 순회하며 활성 모델 전부로 예측을 실행한다. 결과를 ml_prediction 테이블에 upsert하고, BUY/SELL/HOLD 시그널 분포를 반환한다.
# 반환 예시 { "total": 950, # 전체 종목 수 "predicted": 832, # 예측 성공 "failed": 118, # 피처 없음 등 실패 "signals": { "BUY": 287, "SELL": 214, "HOLD": 331 } }
5-3. 타겟 날짜 계산
예측 결과에 실제 도달할 영업일을 기록한다. target_class_1d면 1영업일 후, target_class_5d면 5영업일 후를 core.market_calendar.next_trading_day()로 계산한다. 시장 캘린더 조회 실패 시 단순 +N일로 fallback한다.
6. 시그널 생성 (BUY / SELL / HOLD)
모델의 predict_proba가 반환하는 상승 확률(probability_up)을 3구간으로 분할하여 트레이딩 시그널로 변환한다.
# signal_generator.py def generate_signal(probability_up, buy_threshold=0.6, sell_threshold=0.4): if probability_up >= 0.6: signal = "BUY" elif probability_up <= 0.4: signal = "SELL" else: signal = "HOLD" confidence = max(probability_up, 1 - probability_up) return signal, confidence
| 시그널 | 조건 | confidence 범위 | 해석 |
|---|---|---|---|
| BUY | prob_up ≥ 0.6 | 0.6 ~ 1.0 | 상승 확률 우위 — 매수 권고 |
| SELL | prob_up ≤ 0.4 | 0.6 ~ 1.0 | 하락 확률 우위 — 매도 권고 |
| HOLD | 0.4 < prob_up < 0.6 | 0.5 ~ 0.6 | 불확실 구간 — 관망 |
confidence는 max(prob_up, 1 - prob_up)으로 계산한다. 상승 확률이 0.85면 confidence=0.85(BUY), 0.15면 confidence=0.85(SELL)이다. 0.5에 가까울수록 확신도가 낮다.
7. 모델 버전 관리 & DB 스키마
동일 알고리즘+마켓+타겟 조합에 대해 새 모델이 학습되면, 기존 모델은 비활성화(is_active=False)되고 새 모델이 활성화된다. 버전은 model_name 기준 자동 증가하며, 이전 버전은 삭제하지 않고 이력으로 보관한다.
7-1. 모델 네이밍 규칙
# {알고리즘 약어}_class_{마켓}_{타겟일수}d_v{버전} ra_class_kospi_1d_v3 # RandomForest, KOSPI, 1일, 버전 3 xg_class_kosdaq_5d_v1 # XGBoost, KOSDAQ, 5일, 버전 1 li_class_kospi_1d_v2 # LightGBM, KOSPI, 1일, 버전 2
7-2. Joblib 번들 구조
모델 파일 하나에 예측에 필요한 모든 객체를 묶어서 저장한다. 예측 시 이 파일 하나만 로드하면 된다.
# joblib.dump(save_data, model_path) save_data = { "model": 학습된 분류기 객체, "scaler": RobustScaler (fit 완료 상태), "feature_columns": ["return_1d", "rsi_14", ...], "algorithm": "random_forest", "target_column": "target_class_1d", "imputer": SimpleImputer (RandomForest만), }
7-3. DB 테이블 — 4개
| 테이블 | 역할 | 주요 컬럼 |
|---|---|---|
| feature_store | 종목별 일별 피처 저장소 | market, code, date, 69개 피처 + 4개 타겟 |
| ml_model | 모델 메타데이터 + 성능지표 | model_name, algorithm, version, is_active, accuracy~auc_roc, model_path |
| ml_training_log | 학습 이력 (성공/실패) | status, metrics_json, feature_importance_json, optuna_trials, best_trial_value |
| ml_prediction | 예측 결과 | model_id, code, prediction_date, target_date, signal, confidence, probability_up |
7-4. 버전 관리 흐름
# 동일 알고리즘+타겟의 기존 활성 모델 비활성화 후 새 버전 등록 ① _get_next_version("ra_class_kospi_1d") → 기존 v2 → 다음 v3 ② deactivate_models(KOSPI, classification, target_class_1d, random_forest) → ra_class_kospi_1d_v2.is_active = False ③ save_model(ra_class_kospi_1d, version=3, is_active=True) ④ 예측 시 get_active_models() → 최신 활성 모델만 사용
8. YAML 설정 시스템
알고리즘 추가·수정 시 코드 변경 없이 YAML만 편집하면 되는 선언형 설정 시스템이다. ml_config_loader.py가 싱글톤으로 캐싱하여 한 번만 읽는다.
# ml_config.yaml — 알고리즘 선언 구조 algorithms: random_forest: classifier: sklearn.ensemble.RandomForestClassifier # 동적 import 대상 defaults: class_weight: "balanced" random_state: 42 n_jobs: -1 search_space: # Optuna 탐색 공간 n_estimators: { type: int, low: 100, high: 500, step: 50 } max_depth: { type: int, low: 5, high: 20 } xgboost: classifier: xgboost.XGBClassifier defaults: eval_metric: "logloss" verbosity: 0 search_space: learning_rate: { type: float, low: 0.01, high: 0.3, log: true } ... # ── 딥러닝 (Phase 8A) ── lstm: model_class: ml.deep_learning.architectures.LSTMClassifier type: deep_learning # DL 라우팅 판별 키 defaults: seq_len: 20 hidden_size: 128 num_layers: 2 bidirectional: true transformer: model_class: ml.deep_learning.architectures.TransformerClassifier type: deep_learning defaults: seq_len: 20 d_model: 128 nhead: 8 num_layers: 3
8-1. 동적 클래스 로딩
get_classifier_class()가 importlib으로 분류기 클래스를 동적 로딩한다. YAML의 classifier 필드에 패키지 경로를 적으면 학습기/튜너가 자동으로 해당 클래스를 사용한다.
# ml_config_loader.py def get_classifier_class(algorithm): config = get_algorithm_config(algorithm) module_path, class_name = config["classifier"].rsplit(".", 1) # "sklearn.ensemble.RandomForestClassifier" # → module="sklearn.ensemble", class="RandomForestClassifier" module = importlib.import_module(module_path) return getattr(module, class_name)
8-2. 알고리즘 추가 절차
새 알고리즘(예: CatBoost)을 추가하려면 YAML에 블록을 추가하기만 하면 된다. trainer, tuner, predictor 코드 변경이 불필요하다.
# ml_config.yaml 에 추가하면 끝 catboost: classifier: catboost.CatBoostClassifier defaults: verbose: 0 random_seed: 42 auto_class_weights: "Balanced" search_space: iterations: { type: int, low: 100, high: 500, step: 50 } depth: { type: int, low: 4, high: 10 } learning_rate: { type: float, low: 0.01, high: 0.3, log: true }
9. 학습 스케줄러 & 자동화
ML 학습은 scheduler_service.py의 Step 기반 파이프라인으로 실행된다. STEP_REGISTRY에 등록된 10개 핸들러가 순서대로 실행되며, ML 학습은 Step 10에 해당한다. 어드민 대시보드에서 수동 트리거하거나, 스케줄러가 주기적으로 호출한다.
9-1. 10-Step 핸들러 레지스트리
| Step | step_type | 핸들러 | 설명 |
|---|---|---|---|
| 1 | price | _handle_price | 주가 수집 (M0) |
| 2 | fundamental | _handle_fundamental | KIS/DART 재무 수집 (M0) |
| 3 | market_investor | _handle_market_investor | 투자자별 매매동향 (M0) |
| 4 | macro | _handle_macro | 거시경제 지표 수집 (M0) |
| 5 | news | _handle_news | 뉴스 수집 + NLP (M0/M1) |
| 6 | disclosure | _handle_disclosure | DART 공시 수집 (M0) |
| 7 | supply | _handle_supply | KRX 수급 수집 (M0) |
| 8 | alternative | _handle_alternative | 대안 데이터 수집 (M0) |
| 9 | feature | _handle_feature | 75개 피처 계산 (M2) |
| 10 | ml | _handle_ml | ML 모델 학습 (M3) |
각 Step은 JobStep 테이블에 정의되며, 잡 생성 시 필요한 Step만 선택적으로 활성화할 수 있다. _execute_pipeline()이 활성화된 Step을 step_order 순으로 실행하며, 컨텍스트(ctx)를 통해 Step 간 데이터를 전달한다.
9-2. 조합별 순차 학습
Step 10(_handle_ml)에서는 JobStep.config JSON에 지정된 마켓 × 타겟일수 × 알고리즘 모든 조합을 순차 학습한다.
# JobStep config 예시 { "markets": ["KOSPI", "KOSDAQ"], "algorithms": ["random_forest", "xgboost", "lightgbm"], "target_days": [1, 5], "optuna_trials": 50 } # → 2 마켓 × 2 타겟 × 3 알고리즘 = 12개 모델 순차 학습 (DL 추가 시 5 알고리즘 = 20개) for market in config["markets"]: for target in config["target_days"]: for algo in config["algorithms"]: trainer.train(market, algo, f"target_class_{target}d", optuna_trials=50)
9-3. 피처 계산과 ML 학습의 분리
이전에는 training_scheduler.py가 데이터 수집부터 학습까지 직접 관리했으나, Step 기반 전환 후 데이터 수집(Step 1~8)과 피처 계산(Step 9)은 별도 핸들러가 담당한다. _handle_ml은 학습만 수행하며, 피처 데이터가 이미 feature_store에 준비된 상태에서 호출된다.
# scheduler_service.py — _handle_ml() def _handle_ml(market, days_back, config, ctx): from ml.training_scheduler import run_training_schedule ml_result = run_training_schedule( markets=config.get("markets", [market]), algorithms=config.get("algorithms"), target_days=config.get("target_days"), include_price_collect=False, # Step 1~8에서 이미 수집 완료 include_kis_collect=False, include_dart_collect=False, include_feature_compute=False, # Step 9에서 이미 계산 완료 optuna_trials=config.get("optuna_trials", 50), ) return {"saved": ml_result.get("trained", 0)}
10. 코드 구조 & 통계
| 파일 | 줄 수 | 역할 |
|---|---|---|
| ml/trainer.py | 413 | 학습 파이프라인 (로드→분할→튜닝→학습→평가→저장) |
| ml/predictor.py | 224 | 예측 파이프라인 (로드→피처추출→예측→시그널) |
| ml/training_scheduler.py | 202 | 5-Step 학습 오케스트레이션 |
| ml/tuner.py | 91 | Optuna 하이퍼파라미터 튜닝 (제네릭 objective) |
| ml/signal_generator.py | 36 | BUY/SELL/HOLD 시그널 변환 |
| ml/deep_learning/ | ~1,030 | DL 서브패키지 (architectures, dataset, dl_trainer, dl_tuner, dl_predictor) |
| ml/ml_config_loader.py | 42 | YAML 설정 싱글톤 로더 |
| ml/ml_config.yaml | 95 | ML/DL 알고리즘 선언 (defaults + search_space) |
| models/ml.py | 254 | DB 모델 4개 (FeatureStore, MLModel, MLTrainingLog, MLPrediction) |
| repositories/ml_repository.py | 249 | 피처/모델/이력/예측 CRUD + Upsert |
| api/routes/ml.py | ~150 | REST API (학습/예측/모델/이력 엔드포인트) |
| admin/pages/ml_*.py | ~689 | 어드민 3페이지 (모델관리, 예측결과, 학습매니저) |
| 합계 | ~3,424 | M3 모듈 전체 |
의존성
| 패키지 | 용도 |
|---|---|
| scikit-learn | RandomForestClassifier, RobustScaler, SimpleImputer, 평가 metrics |
| xgboost | XGBClassifier |
| lightgbm | LGBMClassifier |
| optuna | 하이퍼파라미터 자동 튜닝 (Bayesian optimization) |
| torch (PyTorch) | LSTM, Transformer 딥러닝 모델 (nn.Module, DataLoader, GradScaler) |
| joblib | 모델 직렬화 (번들 저장/로드) |
| PyYAML | ml_config.yaml 파싱 |
11. ADR (Architecture Decision Records)
M3 머신러닝 모듈의 핵심 아키텍처 의사결정을 기록한다. 각 ADR은 맥락(Context) → 결정(Decision) → 근거(Rationale) → 결과(Consequences) 구조로 작성되었다.
ADR-M3-001
RobustScaler 채택 — 주가 이상치 대응
Context
75개 피처를 ML 모델에 입력하기 전에 스케일링이 필요하다. 주가 데이터는 상한가(+30%), 하한가(-30%), 거래량 급등(100x) 등 극단적 이상치가 빈번하게 발생한다. 스케일러 선택이 모델 학습 안정성을 좌우한다.
Decision
sklearn.preprocessing.RobustScaler를 사용한다. 중앙값(median)과 사분위 범위(IQR)를 기준으로 스케일링한다.
Rationale
| 스케일러 | 기준값 | 이상치 영향 | 주가 데이터 적합성 |
|---|---|---|---|
| StandardScaler | (X - mean) / std | 높음 (mean 왜곡) | 상한가/하한가로 mean 편향 |
| MinMaxScaler | (X - min) / (max - min) | 매우 높음 | 극단값 1개가 전체 범위 결정 |
| RobustScaler (채택) | (X - median) / IQR | 낮음 (median 안정) | 극단값이 IQR 밖으로 격리 |
Consequences
긍정: 급등/급락 이벤트가 다른 피처의 스케일링을 왜곡하지 않는다. Median/IQR은 이상치에 대한 breakdown point가 25%로 StandardScaler(0%)보다 월등히 견고하다.
부정: 스케일링된 값이 0~1 범위에 한정되지 않아 일부 시각화에서 직관적이지 않다. 학습/예측 시 동일한 scaler 객체를 유지해야 하므로 joblib 번들에 함께 저장한다.
ADR-M3-002
Optuna TPE — 하이퍼파라미터 자동 튜닝
Context
3종 ML + 2종 DL = 5개 알고리즘 × 2 마켓 × 2 타겟 = 20개 모델의 하이퍼파라미터를 튜닝해야 한다. 수동 튜닝은 비현실적이며, 알고리즘별 탐색 공간이 다르다.
Decision
Optuna의 TPE(Tree-structured Parzen Estimator) sampler를 사용하여 알고리즘별 50 trials 자동 튜닝한다. 탐색 공간은 ml_config.yaml에 선언하고, tuner.py의 제네릭 objective 함수가 처리한다.
Rationale
| 방법 | 탐색 효율 | Trial 수 | 기각 사유 |
|---|---|---|---|
| GridSearchCV | 전수 탐색 | 조합 폭발 (5^6 = 15,625) | 시간 비용 과다 |
| RandomSearchCV | 무작위 | 50 (산발적) | 좋은 영역에 수렴하지 않음 |
| Bayesian (sklearn) | 가우시안 프로세스 | 50 | 고차원에서 느림, API 불편 |
| Optuna TPE (채택) | 베이지안 + Pruning | 50 (집중 탐색) | — |
Consequences
긍정: 50 trials로 GridSearch 대비 동등하거나 더 나은 F1 달성 (baseline 0.62 → 0.65~0.68). Pruning으로 성능 나쁜 trial을 조기 종료하여 실제 계산량 ~30% 절감. YAML 기반 탐색 공간으로 코드 변경 없이 범위 조정 가능.
부정: optuna 패키지 의존성 추가. trial 결과의 재현성을 위해 seed 관리 필요 (현재 random_state=42로 고정).
ADR-M3-003
YAML 기반 알고리즘 설정 — 코드/설정 분리
Context
5개 알고리즘의 클래스 경로, 기본 파라미터, Optuna 탐색 공간을 관리해야 한다. 하드코딩하면 새 알고리즘 추가/파라미터 변경 시마다 Python 코드를 수정해야 하고, 비개발자가 조정할 수 없다.
Decision
ml_config.yaml에 알고리즘 정의를 선언하고, ml_config_loader.py가 동적 import + 파라미터 매핑을 수행한다.
# ml_config.yaml — 알고리즘 선언 algorithms: random_forest: classifier: sklearn.ensemble.RandomForestClassifier defaults: class_weight: balanced random_state: 42 n_jobs: -1 search_space: n_estimators: {type: int, low: 100, high: 500, step: 50} max_depth: {type: int, low: 5, high: 20} # ml_config_loader.py — 동적 import def get_classifier_class(algorithm): config = get_algorithm_config(algorithm) module_path, class_name = config["classifier"].rsplit(".", 1) return importlib.import_module(module_path).__dict__[class_name]
Consequences
긍정: 새 알고리즘(예: CatBoost) 추가 시 YAML에 8줄 추가만 하면 된다. Python 코드 변경 불필요. Optuna 탐색 범위도 YAML 수정으로 조정 가능.
부정: 동적 import(importlib)를 사용하므로 IDE 자동완성이 동작하지 않는다. YAML 오타 시 런타임 에러가 발생한다. 이를 완화하기 위해 trainer.py 초기화 시 YAML 유효성 검증을 수행한다.
ADR-M3-004
시계열 순차 분할 — 미래 정보 누수 방지
Context
금융 데이터는 시간 순서에 강한 의존성이 있다. sklearn의 train_test_split(shuffle=True)을 사용하면 2024년 12월 데이터로 학습한 모델이 2024년 6월을 예측하는 상황이 발생하여, 미래 정보가 학습에 포함되는 data leakage가 일어난다.
Decision
시간 순서를 유지한 순차 분할을 사용한다. shuffle=False로 과거 85%를 학습, 최신 15%를 검증에 사용한다.
# trainer.py — _split_data() n = len(df) train_end = int(n * 0.85) train_df = df.iloc[:train_end] # 과거 85% → 학습 val_df = df.iloc[train_end:] # 최신 15% → 검증/평가 # ✗ df.sample(frac=0.85) → 미래 데이터 혼입 # ✗ KFold(shuffle=True) → 교차 검증에 미래 포함 # ※ _split_data()는 현재 BaseTrainer로 이동 (base_trainer.py) # ※ Purged Expanding Window CV도 tune_hyperparameters_cv()로 지원
Rationale
| 방법 | Data Leakage | 실전 정확도 |
|---|---|---|
| Random Split | 발생 | 백테스트 과적합 → 실전 하락 |
| KFold (shuffle) | 발생 | 교차 검증 성능 부풀림 |
| TimeSeriesSplit | 없음 | 정확하지만 구현 복잡도 증가 |
| 순차 분할 (채택) | 없음 | 단순하고 실전 환경 모사 |
Consequences
긍정: 백테스트 결과와 실전 성능 간 괴리가 최소화된다. 모델이 "과거 패턴으로 미래를 예측"하는 실제 운용 환경을 정확히 모사한다.
부정: 검증 셋이 시간적으로 편향되어 있어, 특정 시기의 시장 특성(불장/약장)에 성능이 좌우될 수 있다. Walk-forward validation으로 확장하면 이 문제를 완화할 수 있다.
ADR-M3-005
Median Imputation — NaN 처리 전략
Context
M2의 Graceful NULL 전략(ADR-M2-003)에 따라 feature_store에는 NULL 값이 존재한다. Tree 모델(RF/XGB/LGBM)은 NaN을 네이티브로 처리할 수 있지만, RobustScaler는 NaN 입력을 지원하지 않으며, DL 모델(LSTM/Transformer)도 NaN 연산이 불가능하다.
Decision
SimpleImputer(strategy="median")을 스케일링 전 단계에서 적용한다. Imputer, Scaler, Model을 하나의 joblib 번들로 저장하여 예측 시에도 동일한 통계치를 사용한다.
# trainer.py — NaN 처리 + 스케일링 파이프라인 imputer = SimpleImputer(strategy="median") X_train = imputer.fit_transform(X_train) # 학습 데이터 기준 median 계산 X_val = imputer.transform(X_val) # 동일 median 적용 scaler = RobustScaler() X_train = scaler.fit_transform(X_train) X_val = scaler.transform(X_val) # 번들 저장 — 예측 시 동일 통계치 재사용 save_data = { "model": model, "scaler": scaler, "imputer": imputer, "feature_columns": features }
Rationale
| 전략 | 이상치 민감도 | 적합성 |
|---|---|---|
| Mean Imputation | 높음 (이상치가 mean 왜곡) | RobustScaler 철학과 불일치 |
| KNN Imputation | 낮음 | 계산 비용 O(n²), 대규모 피처에 비실용적 |
| Median Imputation (채택) | 낮음 (이상치 무관) | RobustScaler와 동일한 견고성 철학 |
Consequences
긍정: RobustScaler + Median Imputer가 일관된 이상치 방어 체계를 구성한다. joblib 번들로 imputer/scaler/model을 함께 저장하여 학습-예측 간 통계치 불일치를 원천 차단한다.
부정: Median imputation은 MCAR(Missing Completely At Random) 가정을 전제한다. 금융 데이터에서 NaN은 "데이터 없음"(소형주 뉴스 부재)일 수 있어 이 가정이 엄밀히 성립하지 않지만, feature_store에서 NaN 비율 50% 초과 피처를 사전 제거하여 실질적 영향을 최소화한다.
ADR-M3-006
시그널 임계값 설계 — BUY 60% / SELL 40%
Context
ML 분류기의 predict_proba() 출력(상승 확률 0.0~1.0)을 BUY/SELL/HOLD 시그널로 변환해야 한다. 임계값 설정이 시그널 빈도와 정밀도 사이의 트레이드오프를 결정한다.
Decision
BUY ≥ 0.6, SELL ≤ 0.4, 나머지 HOLD로 설정한다. Confidence는 max(prob, 1-prob)로 계산한다.
# signal_generator.py def generate_signal(prob_up: float): if prob_up >= 0.6: signal = "BUY" elif prob_up <= 0.4: signal = "SELL" else: signal = "HOLD" confidence = max(prob_up, 1 - prob_up) return signal, confidence
Rationale
| 임계값 | BUY/SELL 빈도 | 정밀도 | 특성 |
|---|---|---|---|
| 0.5 / 0.5 | 높음 (HOLD 없음) | 낮음 | 모든 예측이 시그널 → 노이즈 과다 |
| 0.6 / 0.4 (채택) | 중간 | 중~상 | 확신 있을 때만 시그널 → 실용적 |
| 0.7 / 0.3 | 낮음 | 높음 | 시그널 희소 → 거래 기회 부족 |
Consequences
긍정: HOLD 구간(0.4~0.6)이 버퍼 역할을 하여 모델 불확실 영역에서의 잘못된 시그널을 억제한다. 약 30~40%의 예측이 HOLD로 분류되어 과매매를 방지한다.
부정: 고정 임계값이므로 시장 변동성에 따른 적응이 없다. 향후 동적 임계값(모델 calibration 기반) 또는 포트폴리오 최적화 단계에서 조정 가능.
'Project > 🚀 AI Finance 2026' 카테고리의 다른 글
| #23 [모듈3-2] Optuna — 하이퍼파라미터 자동 튜닝의 모든 것 (0) | 2026.03.16 |
|---|---|
| #22 [모듈3-1] scikit-learn, XGBoost, LightGBM — 3대 ML 라이브러리 (0) | 2026.03.13 |
| #20 [모듈2-4] 피어 비교 피처 — 섹터 상대강도 + 복합 인덱스 (0) | 2026.03.13 |
| #19 [모듈2-3] 증분 파이프라인 + Lookback 버퍼 (0) | 2026.03.13 |
| #18 [모듈2-2] 결측 데이터 전략 - Graceful NULL + 이중 Forward-Fill (0) | 2026.03.13 |