Files
2026FIFAWorldCup/platform/backend/app/analytics/ml_inference.py

100 lines
3.0 KiB
Python

"""XGBoost 推論 API 套件。"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from xgboost import Booster, DMatrix
@dataclass(frozen=True)
class XGBoostPrediction:
home_win: float
draw: float
away_win: float
def _safe_probability(x: float) -> float:
return float(max(0.0, min(1.0, x)))
class XGBoostPredictor:
"""XGBoost 預測器:輸入特徵 => 輸出 1x2 機率。"""
def __init__(
self,
model_path: str | None = None,
*,
feature_columns: list[str] | None = None,
) -> None:
self.feature_columns = feature_columns or []
self.model_path = model_path
self.model = self._load_model(model_path) if model_path else None
def _load_model(self, model_path: str | None) -> Booster | None:
if not model_path:
return None
path = Path(model_path)
if not path.exists():
return None
model = Booster()
model.load_model(str(path))
return model
def predict_match_outcome(self, features: dict[str, float]) -> dict[str, float]:
"""輸出主勝/平/客勝機率。"""
if self.model is None:
# fallback: 均分
return {'home': 1 / 3, 'draw': 1 / 3, 'away': 1 / 3}
ordered_values = [float(features.get(col, 0.0)) for col in self.feature_columns]
dmatrix = DMatrix(np.array([ordered_values]), feature_names=self.feature_columns)
probs = self.model.predict(dmatrix)
if probs.ndim == 1:
probs = probs.reshape(1, -1)
arr = probs[0]
if arr.size < 3:
raise ValueError('模型輸出維度不足 3')
raw = np.array(arr[:3], dtype=float)
raw = np.maximum(raw, 0.0)
s = raw.sum()
if s <= 0:
raise ValueError('模型輸出總和異常為 0')
norm = raw / s
return {'home': _safe_probability(norm[0]), 'draw': _safe_probability(norm[1]), 'away': _safe_probability(norm[2])}
def find_model_edge(
self,
ml_probs: dict[str, float],
bookmaker_implied_probs: dict[str, float],
) -> list[dict[str, Any]]:
"""回傳模型超越莊家 4% 以上的投注選項。"""
mapping = [('home', 'home'), ('draw', 'draw'), ('away', 'away')]
outputs: list[dict[str, Any]] = []
for model_key, book_key in mapping:
ml_v = float(ml_probs.get(model_key, 0.0))
book_v = float(bookmaker_implied_probs.get(book_key, 0.0))
edge = ml_v - book_v
if edge >= 0.04:
outputs.append(
{
'selection': model_key,
'ml_prob': round(ml_v, 6),
'bookmaker_implied_prob': round(book_v, 6),
'edge': round(edge, 6),
'label': 'Strong Buy',
},
)
return outputs