100 lines
3.0 KiB
Python
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
|