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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user