Spaces:
Sleeping
Sleeping
File size: 9,014 Bytes
4d18cf9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """
backend/services/model_service.py
βββββββββββββββββββββββββββββββββββββββββββββ
Carga, gestiΓ³n e inferencia de los 3 modelos finales:
1. XGBoost (Optuna, EXP-C) β tabular, fast
2. LSTM + Self-Attention (EXP-E) β sequential, best AUC
3. Logistic Regression β calibrated baseline
DiseΓ±ado para carga ΓΊnica al startup de FastAPI.
βββββββββββββββββββββββββββββββββββββββββββββ
"""
from __future__ import annotations
import logging
from typing import Dict, List, Optional
import joblib
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from backend.config import (
FEATURE_COLUMNS,
LOGREG_MODEL_PATH,
LSTM_DROPOUT,
LSTM_HIDDEN_DIM,
LSTM_MAX_SEQ_LEN,
LSTM_MODEL_PATH,
SCALER_PATH,
XGBOOST_MODEL_PATH,
)
logger = logging.getLogger(__name__)
# βββββββββββββββββββββββββββββββββββββββββββββ
# LSTM Architecture (must match training exactly)
# βββββββββββββββββββββββββββββββββββββββββββββ
class LSTMWithAttention(nn.Module):
"""LSTM + Self-Attention para predicciΓ³n de win probability.
Replica exacta de la arquitectura entrenada en notebook 08 (EXP-E).
"""
def __init__(self, input_dim: int, hidden_dim: int, dropout: float = 0.3):
super().__init__()
self.lstm = nn.LSTM(input_dim, hidden_dim, batch_first=True)
self.attention = nn.Linear(hidden_dim, 1)
self.dropout = nn.Dropout(dropout)
self.fc = nn.Linear(hidden_dim, 1)
def forward(self, x: torch.Tensor) -> torch.Tensor:
out, _ = self.lstm(x) # (B, T, H)
attn_w = torch.softmax(self.attention(out), dim=1) # (B, T, 1)
context = (attn_w * out).sum(dim=1) # (B, H)
return self.fc(self.dropout(context))
# βββββββββββββββββββββββββββββββββββββββββββββ
# Model Service (Singleton-ish via module state)
# βββββββββββββββββββββββββββββββββββββββββββββ
class ModelService:
"""Servicio centralizado de carga e inferencia.
Se inicializa una sola vez al arrancar la app y mantiene
los 3 modelos en memoria para inferencia rΓ‘pida.
"""
def __init__(self):
self._xgb_model = None
self._lstm_model: Optional[LSTMWithAttention] = None
self._logreg_model = None
self._scaler = None
self._device = "cpu" # No GPU en HF Spaces gratuito
self._loaded = False
@property
def is_loaded(self) -> bool:
return self._loaded
# ββ Load ββββββββββββββββββββββββββββββββββββββββββββββββββ
def load_models(self) -> None:
"""Carga los 3 modelos desde disco. Llamar una sola vez."""
if self._loaded:
logger.info("Models already loaded β skipping.")
return
logger.info("Loading ML modelsβ¦")
# 1. XGBoost
try:
self._xgb_model = joblib.load(XGBOOST_MODEL_PATH)
logger.info("β
XGBoost loaded from %s", XGBOOST_MODEL_PATH)
except Exception as e:
logger.error("β Failed to load XGBoost: %s", e)
raise
# 2. Logistic Regression + Scaler
try:
self._logreg_model = joblib.load(LOGREG_MODEL_PATH)
self._scaler = joblib.load(SCALER_PATH)
logger.info("β
LogReg + Scaler loaded")
except Exception as e:
logger.error("β Failed to load LogReg/Scaler: %s", e)
raise
# 3. LSTM + Self-Attention
try:
input_dim = len(FEATURE_COLUMNS)
self._lstm_model = LSTMWithAttention(
input_dim=input_dim,
hidden_dim=LSTM_HIDDEN_DIM,
dropout=LSTM_DROPOUT,
)
checkpoint = torch.load(
LSTM_MODEL_PATH,
map_location=self._device,
weights_only=False,
)
# El modelo fue guardado como un diccionario con metadatos
if "model_state_dict" in checkpoint:
self._lstm_model.load_state_dict(checkpoint["model_state_dict"])
else:
self._lstm_model.load_state_dict(checkpoint)
self._lstm_model.to(self._device)
self._lstm_model.eval()
logger.info("β
LSTM loaded from %s (device=%s)", LSTM_MODEL_PATH, self._device)
except Exception as e:
logger.error("β Failed to load LSTM: %s", e)
raise
self._loaded = True
logger.info("All models loaded successfully.")
# ββ Predict βββββββββββββββββββββββββββββββββββββββββββββββ
def predict(self, features_df: pd.DataFrame) -> Dict[str, List[float]]:
"""Genera predicciones minuto-a-minuto con los 3 modelos.
Args:
features_df: DataFrame con columnas == FEATURE_COLUMNS.
Cada fila es un minuto de la partida.
Returns:
Dict con keys "xgboost", "lstm", "logreg", cada uno
una lista de floats (probabilidad de blue_win por minuto).
"""
if not self._loaded:
raise RuntimeError("Models not loaded. Call load_models() first.")
# Validate columns
missing = set(FEATURE_COLUMNS) - set(features_df.columns)
if missing:
raise ValueError(f"Missing features in input: {missing}")
# Ensure correct column order
X = features_df[FEATURE_COLUMNS].astype(np.float32)
results: Dict[str, List[float]] = {}
# ββ XGBoost (tabular, row-by-row) βββββββββββββββββββββ
try:
xgb_probs = self._xgb_model.predict_proba(X)[:, 1]
results["xgboost"] = xgb_probs.tolist()
except Exception as e:
logger.error("XGBoost prediction failed: %s", e)
results["xgboost"] = []
# ββ Logistic Regression (scaled, row-by-row) ββββββββββ
try:
X_scaled = self._scaler.transform(X)
logreg_probs = self._logreg_model.predict_proba(X_scaled)[:, 1]
results["logreg"] = logreg_probs.tolist()
except Exception as e:
logger.error("LogReg prediction failed: %s", e)
results["logreg"] = []
# ββ LSTM (sequence, padded to MAX_SEQ_LEN) ββββββββββββ
try:
results["lstm"] = self._predict_lstm(X.values)
except Exception as e:
logger.error("LSTM prediction failed: %s", e)
results["lstm"] = []
return results
def _predict_lstm(self, X_arr: np.ndarray) -> List[float]:
"""Inferencia LSTM minuto-a-minuto (acumulativa).
Para cada minuto t, construimos la secuencia [0..t] (max 20 min),
la pasamos por el modelo, y obtenemos P(blue_win) hasta ese punto.
Esto simula cΓ³mo se usarΓa en producciΓ³n: la partida avanza y
el modelo ve la secuencia acumulada.
"""
n_minutes = len(X_arr)
probs: List[float] = []
for t in range(n_minutes):
# Secuencia acumulada hasta minuto t (inclusive)
seq = X_arr[:t + 1]
# Truncar a MAX_SEQ_LEN (tomar los ΓΊltimos N minutos)
if len(seq) > LSTM_MAX_SEQ_LEN:
seq = seq[-LSTM_MAX_SEQ_LEN:]
# Post-pad with zeros after real data β matches training exactly
# (pad_sequences in notebook fills [:l] then leaves zeros at the end)
seq_len = len(seq)
if seq_len < LSTM_MAX_SEQ_LEN:
pad = np.zeros(
(LSTM_MAX_SEQ_LEN - seq_len, seq.shape[1]),
dtype=np.float32,
)
seq = np.concatenate([seq, pad], axis=0)
# (1, MAX_SEQ_LEN, n_features)
tensor = torch.tensor(seq, dtype=torch.float32).unsqueeze(0).to(self._device)
with torch.no_grad():
logit = self._lstm_model(tensor).squeeze()
prob = torch.sigmoid(logit).item()
probs.append(prob)
return probs
# ββ Module-level singleton ββββββββββββββββββββββββββββββββββββ
model_service = ModelService() |