"""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