fix(ml): ML 앙상블 전면 미작동 — 시계열 역방향 + 시퀀스 워밍업 NaN 크래시

9/23~9/28 닷새간 stock_analysis.ml_score 가 200/200 NULL 이었고 로그는 0줄이었다.
원인 3건이 겹쳐 있었다.

1. 시계열 역방향 (추론 ≠ 학습)
   market_analyzer 는 chart_data 를 oldest-first 로 정규화해 넘기는데
   chart_data_to_dataframe() 은 "입력은 최신순" 가정으로 무조건 뒤집는다.
   → ML 피처가 역방향 시계열에서 계산됐다. 학습은 db_data_to_dataframe
   (trade_date ASC, 정방향)이므로 추론이 학습과 정반대 방향이었다.
   ensemble_score 관측 범위가 18~50 에 갇혀 ML BUY 가 0건이던 것의 직접 원인.
   → chart_data_to_dataframe_v2(): stck_bsop_date 오름차순 정렬로 방향 의존성 제거.

2. LSTM 시퀀스 워밍업 NaN → int(nan) ValueError
   탭뷸러는 NaN 검사를 받지만 시퀀스는 검사되지 않았다. 시퀀스 피처
   volatility_20d 의 첫 유효 인덱스가 20 이므로 요구 봉수는 60+20=80 인데
   게이트가 <61 이어서 61~79 구간이 크래시 밴드였다.
   NaN → LSTM 이 [nan,nan,nan] 반환(예외 아님) → 가중평균 전체 NaN
   → ml_ensemble.py int(prob_up*100) 에서 ValueError.
   → MIN_BARS_FOR_PREDICT=80 + 시퀀스 유한성 검사. NaN 을 0 으로 채우지 않고
     사유를 담은 error 로 반환한다.
   prepare_training_data 도 idx 60~79 샘플 시퀀스에 NaN 을 넣고 있었다
   (valid_mask[:60]=False 만 존재) → X_seq.mean 이 NaN → seq_mean/std 오염.

3. except Exception: pass 가 1·2 를 조용히 삼켰다
   → _run_ml_ensemble() 분리 + 사유별 집계(get/reset_ml_skip_stats,
     log_ml_skip_summary). 최초 1건 + 이후 50건마다 warning, 사이클 종료 시
     사유별 1줄 요약. batch_daily_analysis 에 배선.

부수 수정
- 네이버 ML 차트 80일창(75봉) → 200일창(154봉). 룰 입력은 _trim_to_legacy_window
  로 기존 창과 동일 배열로 절단 — adx_wilder 는 Wilder 재귀라 배열 길이에
  민감하므로 절단 없이는 SIDEWAYS 히스테리시스 판정이 바뀐다(실측 14.21→14.55).
  전 지표 동일성 확인: rsi/ma/bb/atr/adx/stochastic 모두 일치.
- predict() 가 어떤 입력에도 예외를 던지지 않도록 얇은 가드 + _predict_impl 분리.
  비유한 확률을 내는 모델은 warning 과 함께 앙상블에서 제외(degraded_models).
- training_metrics 가 항상 {} 였던 원인: self.training_metrics = metrics 가
  _save_models() 뒤에 있어 직전 값이 기록됨. 순서 정정 + per-class 지표 추가
  ("전부 HOLD" 모델을 정확도만으로 판별 못하는 문제 대응).
- endpoints/ml_ensemble.py: get_daily_chart 는 output2 를 채우는데 output 을
  읽어 항상 400 이었다. 게이트 61→MIN_BARS_FOR_PREDICT, async 핸들러의
  동기 predict() → predict_async().

미변경: 모델 파일, 아키텍처·하이퍼파라미터, 임계값(60/0.3/0.7, 가중치
0.4/0.35/0.25), torch.load(weights_only=True), 지표 계산식, KIS 호출량.

검증: 579 passed / 0 failed (소켓·KIS 차단 하, 기준선 559 + 신규 20).
신규 tests/test_ml_feature_direction.py 는 결함 역주입으로 검출력 증명 —
방향 역전 2건 + 크래시 밴드 61/70/79 3건 + 게이트 1건 = 6건 실패 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 11:51:36 +09:00
parent 2425fd2276
commit 43b4b1a7aa
6 changed files with 607 additions and 78 deletions

View File

@@ -21,7 +21,10 @@ import joblib
from app.services.feature_engineering import (
FEATURE_COLUMNS,
LSTM_SEQUENCE_FEATURES,
LSTM_SEQUENCE_LENGTH,
MIN_BARS_FOR_PREDICT,
extract_single_features,
extract_single_features_v2,
chart_data_to_dataframe,
extract_features,
prepare_training_data,
@@ -33,6 +36,35 @@ logger = logging.getLogger(__name__)
MODEL_DIR = Path(__file__).parent.parent.parent / "models" / "ensemble"
_CLASS_NAMES = ("down", "hold", "up")
def _per_class_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> Dict[str, Dict[str, float]]:
"""3클래스 precision/recall/support (sklearn 의존 없이 계산).
모델 교체 판단의 근거로 metrics.json 에 기록한다.
정확도만으로는 "전부 HOLD" 모델이 좋아 보이는 문제를 잡을 수 없다.
"""
y_true = np.asarray(y_true, dtype=int)
y_pred = np.asarray(y_pred, dtype=int)
out: Dict[str, Dict[str, float]] = {}
for cls, name in enumerate(_CLASS_NAMES):
tp = int(np.sum((y_pred == cls) & (y_true == cls)))
fp = int(np.sum((y_pred == cls) & (y_true != cls)))
fn = int(np.sum((y_pred != cls) & (y_true == cls)))
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
f1 = (2 * precision * recall / (precision + recall)) if (precision + recall) > 0 else 0.0
out[name] = {
"precision": round(float(precision), 4),
"recall": round(float(recall), 4),
"f1": round(float(f1), 4),
"support": int(np.sum(y_true == cls)),
"predicted": int(np.sum(y_pred == cls)),
}
return out
# ─── PyTorch LSTM 모델 정의 ───
def _get_torch():
@@ -251,14 +283,32 @@ class MLEnsemble:
Returns:
학습 메트릭
"""
from datetime import datetime, timezone, timedelta
self._ensure_dir()
metrics = {}
metrics: Dict[str, Any] = {}
if len(X_tabular) < 50:
return {"error": f"학습 데이터 부족: {len(X_tabular)}개 (최소 50개 필요)"}
logger.info(f"앙상블 학습 시작: {len(X_tabular)}개 샘플, 클래스 분포: {np.bincount(y)}")
# ─── 학습 메타데이터 (모델 품질·교체 판단의 근거) ───
_KST = timezone(timedelta(hours=9))
_class_counts = np.bincount(np.asarray(y, dtype=int), minlength=3)
metrics["trained_at"] = datetime.now(_KST).isoformat()
metrics["n_samples"] = int(len(X_tabular))
metrics["n_features"] = int(X_tabular.shape[1]) if X_tabular.ndim > 1 else 0
metrics["sequence_shape"] = list(np.asarray(X_seq).shape[1:]) if len(X_seq) else []
metrics["class_distribution"] = {
"down": int(_class_counts[0]), "hold": int(_class_counts[1]), "up": int(_class_counts[2]),
}
metrics["class_ratio"] = {
k: round(float(v) / max(1, len(y)), 4)
for k, v in zip(("down", "hold", "up"), _class_counts[:3])
}
metrics["weights"] = {"lgbm": self.weights[0], "lstm": self.weights[1], "rf": self.weights[2]}
# 탭뷸러 피처 스케일링
from sklearn.preprocessing import StandardScaler
self.scaler = StandardScaler()
@@ -272,6 +322,12 @@ class MLEnsemble:
X_train, X_val = X_scaled[:split_idx], X_scaled[split_idx:]
y_train, y_val = y[:split_idx], y[split_idx:]
X_seq_train, X_seq_val = X_seq[:split_idx], X_seq[split_idx:]
metrics["n_train"] = int(len(X_train))
metrics["n_val"] = int(len(X_val))
_val_counts = np.bincount(np.asarray(y_val, dtype=int), minlength=3)
metrics["val_class_distribution"] = {
"down": int(_val_counts[0]), "hold": int(_val_counts[1]), "up": int(_val_counts[2]),
}
# 1. LightGBM
logger.info(">>> LightGBM 학습 중...")
@@ -299,6 +355,7 @@ class MLEnsemble:
lgbm_val_pred = self.lgbm_model.predict(X_val)
lgbm_acc = np.mean(lgbm_val_pred == y_val)
metrics["lgbm_accuracy"] = float(lgbm_acc)
metrics["lgbm_per_class"] = _per_class_metrics(y_val, lgbm_val_pred)
logger.info(f"LightGBM 검증 정확도: {lgbm_acc:.4f}")
importance = self.lgbm_model.feature_importances_
@@ -331,7 +388,12 @@ class MLEnsemble:
lstm_val_pred = np.argmax(lstm_val_probs, axis=1)
lstm_acc = np.mean(lstm_val_pred == y_val)
metrics["lstm_accuracy"] = float(lstm_acc)
metrics["lstm_per_class"] = _per_class_metrics(y_val, lstm_val_pred)
metrics["lstm_training"] = lstm_metrics
# seq_std 가 0 에 수렴하면(예: 전부 동일값) 정규화가 폭주한다 → 기록해 둔다
if self.lstm_model.seq_std is not None:
metrics["lstm_seq_std_min"] = float(np.min(self.lstm_model.seq_std))
metrics["lstm_seq_mean_abs_max"] = float(np.max(np.abs(self.lstm_model.seq_mean)))
logger.info(f"LSTM 검증 정확도: {lstm_acc:.4f}")
except Exception as e:
logger.error(f"LSTM 학습 실패: {e}")
@@ -353,54 +415,86 @@ class MLEnsemble:
rf_val_pred = self.rf_model.predict(X_val)
rf_acc = np.mean(rf_val_pred == y_val)
metrics["rf_accuracy"] = float(rf_acc)
metrics["rf_per_class"] = _per_class_metrics(y_val, rf_val_pred)
logger.info(f"Random Forest 검증 정확도: {rf_acc:.4f}")
except Exception as e:
logger.error(f"Random Forest 학습 실패: {e}")
metrics["rf_error"] = str(e)
# 앙상블 검증
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val)
_degraded: list = []
ensemble_probs = self._ensemble_predict_proba(X_val, X_seq_val, degraded=_degraded)
if _degraded:
metrics["degraded_models"] = _degraded
if ensemble_probs is not None:
ensemble_pred = np.argmax(ensemble_probs, axis=1)
ensemble_acc = np.mean(ensemble_pred == y_val)
metrics["ensemble_accuracy"] = float(ensemble_acc)
metrics["ensemble_per_class"] = _per_class_metrics(y_val, ensemble_pred)
_pred_counts = np.bincount(np.asarray(ensemble_pred, dtype=int), minlength=3)
metrics["ensemble_pred_distribution"] = {
"down": int(_pred_counts[0]), "hold": int(_pred_counts[1]), "up": int(_pred_counts[2]),
}
logger.info(f"앙상블 검증 정확도: {ensemble_acc:.4f}")
else:
metrics["ensemble_error"] = "모든 모델 예측 실패"
self._save_models()
# ⚠️ 순서 중요: _save_models() 가 self.training_metrics 를 metrics.json 으로
# 쓴다. 예전에는 저장이 대입보다 **먼저** 실행돼 항상 이전 값({})이 기록됐고,
# 그래서 운영 metrics.json 이 빈 dict 였다. (2026-09-28 수정)
self.is_trained = True
self.training_metrics = metrics
self._save_models()
logger.info(f"앙상블 학습 완료: {metrics}")
return metrics
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray) -> Optional[np.ndarray]:
"""3개 모델의 가중 평균 확률"""
def _ensemble_predict_proba(self, X_tabular: np.ndarray, X_seq: np.ndarray,
degraded: Optional[list] = None) -> Optional[np.ndarray]:
"""3개 모델의 가중 평균 확률.
Args:
degraded: (선택) 제외된 모델 사유를 담을 리스트. 호출부가 넘기면
"lgbm:non-finite" 같은 문자열이 추가된다.
비유한(NaN/inf) 확률을 뱉는 모델은 앙상블에서 **제외**한다.
예전에는 NaN 이 그대로 가중평균에 섞여 최종 확률 전체를 NaN 으로 만들고
`int(prob_up*100)` 에서 예외로 터졌다. 제외 사실은 로그와 degraded 로
드러내며, 조용히 넘기지 않는다.
"""
probs_list = []
weights_list = []
def _record(name: str, reason: str):
logger.warning("[ML] %s 모델 제외: %s", name, reason)
if degraded is not None:
degraded.append(f"{name}:{reason}")
def _add(name: str, probs, weight: float):
arr = np.asarray(probs, dtype=float)
if not np.all(np.isfinite(arr)):
_record(name, "non-finite")
return
probs_list.append(arr)
weights_list.append(weight)
if self.lgbm_model is not None:
try:
lgbm_probs = self.lgbm_model.predict_proba(X_tabular)
probs_list.append(lgbm_probs)
weights_list.append(self.weights[0])
except Exception:
pass
_add("lgbm", self.lgbm_model.predict_proba(X_tabular), self.weights[0])
except Exception as e:
_record("lgbm", f"{type(e).__name__}: {e}")
if self.lstm_model is not None and self.lstm_model.model is not None:
try:
lstm_probs = self.lstm_model.predict_proba(X_seq)
probs_list.append(lstm_probs)
weights_list.append(self.weights[1])
except Exception:
pass
_add("lstm", self.lstm_model.predict_proba(X_seq), self.weights[1])
except Exception as e:
_record("lstm", f"{type(e).__name__}: {e}")
if self.rf_model is not None:
try:
rf_probs = self.rf_model.predict_proba(X_tabular)
probs_list.append(rf_probs)
weights_list.append(self.weights[2])
except Exception:
pass
_add("rf", self.rf_model.predict_proba(X_tabular), self.weights[2])
except Exception as e:
_record("rf", f"{type(e).__name__}: {e}")
if not probs_list:
return None
@@ -421,22 +515,63 @@ class MLEnsemble:
Returns:
{ensemble_score, confidence, signal, model_scores, probabilities}
또는 {"error": 사유, ...} — **어떤 입력에도 예외를 던지지 않는다.**
주의: 호출부는 반드시 `"error" in result` 를 확인해야 한다.
error 응답의 ensemble_score=50 은 "중립 플레이스홀더"이며 모델 판단이 아니다.
"""
try:
return self._predict_impl(chart_data)
except Exception as e:
# predict() 는 절대 예외를 밖으로 내지 않는다. 호출부의
# `except Exception: pass` 에 삼켜져 5일간 무증상이던 것이 9/23 사고였다.
logger.warning("[ML] predict 예외 (%s): %s", type(e).__name__, e, exc_info=True)
return {
"error": f"예측 예외 {type(e).__name__}: {e}",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
def _predict_impl(self, chart_data: list) -> Dict[str, Any]:
"""predict() 의 실제 구현 (예외 가드는 predict() 가 담당)."""
if not self.is_trained:
return {"error": "모델이 학습되지 않았습니다", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
if self.scaler is None:
return {"error": "스케일러 없음", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
tabular, sequence = extract_single_features(chart_data)
tabular, sequence, reason = extract_single_features_v2(chart_data)
if tabular is None or sequence is None:
return {"error": "피처 추출 실패 (데이터 부족)", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
return {
"error": f"피처 추출 실패: {reason or '데이터 부족'}",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
tabular_scaled = self.scaler.transform(tabular.reshape(1, -1))
sequence_batch = sequence.reshape(1, 60, len(LSTM_SEQUENCE_FEATURES))
tabular_scaled = np.asarray(self.scaler.transform(tabular.reshape(1, -1)), dtype=float)
if not np.all(np.isfinite(tabular_scaled)):
# 스케일러 분산 0 등으로 스케일링 결과가 깨진 경우 — 점수를 만들지 않는다
return {
"error": "스케일링 결과 비유한(NaN/inf)",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
sequence_batch = sequence.reshape(1, LSTM_SEQUENCE_LENGTH, len(LSTM_SEQUENCE_FEATURES))
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch)
degraded: list = []
probs = self._ensemble_predict_proba(tabular_scaled, sequence_batch, degraded=degraded)
if probs is None:
return {"error": "모든 모델 예측 실패", "ensemble_score": 50, "confidence": 0.0, "signal": "HOLD"}
return {
"error": "모든 모델 예측 실패" + (f" ({','.join(degraded)})" if degraded else ""),
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
prob_down, prob_hold, prob_up = probs[0]
probs = np.asarray(probs, dtype=float)
if probs.shape[0] < 1 or probs.shape[1] < 3 or not np.all(np.isfinite(probs[0])):
# NaN 을 0 이나 그럴듯한 값으로 치환하지 않는다 — 0점은 "ML이 매수 반대"로
# 오해되므로, 정직하게 error 를 돌려 호출부가 ML 없이 진행하게 한다.
return {
"error": "앙상블 확률 비유한(NaN/inf)",
"ensemble_score": 50, "confidence": 0.0, "signal": "HOLD",
}
prob_down, prob_hold, prob_up = probs[0][:3]
ensemble_score = int(prob_up * 100)
max_prob = max(prob_down, prob_hold, prob_up)
confidence = float(max_prob)
@@ -473,6 +608,8 @@ class MLEnsemble:
"confidence": round(confidence, 4),
"signal": signal,
"model_scores": model_scores,
# 비유한 확률/예외로 앙상블에서 빠진 모델 — 비어 있어야 정상
"degraded_models": degraded,
"probabilities": {
"up": round(float(prob_up), 4),
"hold": round(float(prob_hold), 4),