114 lines
4.0 KiB
Python
114 lines
4.0 KiB
Python
"""Poisson 分佈賽果預測模組。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import numpy as np
|
||
from scipy.stats import poisson
|
||
|
||
|
||
class PoissonMatchPredictor:
|
||
"""基於攻守強度的雙方進球機率預測器。"""
|
||
|
||
def __init__(
|
||
self,
|
||
home_attack_strength: float,
|
||
home_defense_strength: float,
|
||
away_attack_strength: float,
|
||
away_defense_strength: float,
|
||
league_avg_home_goals: float,
|
||
) -> None:
|
||
for value, name in [
|
||
(home_attack_strength, 'home_attack_strength'),
|
||
(home_defense_strength, 'home_defense_strength'),
|
||
(away_attack_strength, 'away_attack_strength'),
|
||
(away_defense_strength, 'away_defense_strength'),
|
||
(league_avg_home_goals, 'league_avg_home_goals'),
|
||
]:
|
||
if value <= 0:
|
||
raise ValueError(f'{name} 必須大於 0')
|
||
|
||
self.home_attack_strength = float(home_attack_strength)
|
||
self.home_defense_strength = float(home_defense_strength)
|
||
self.away_attack_strength = float(away_attack_strength)
|
||
self.away_defense_strength = float(away_defense_strength)
|
||
self.league_avg_home_goals = float(league_avg_home_goals)
|
||
|
||
def calculate_expected_goals(self) -> tuple[float, float]:
|
||
"""根據攻守強度與聯盟均值估算預期進球數(λ 值)。
|
||
|
||
使用比值校正避免極端值放大風險:
|
||
- 主隊 λ = 聯盟主場均值 × (主攻 / 客守)
|
||
- 客隊 λ = 聯盟客場均值 × (客攻 / 主守)
|
||
"""
|
||
|
||
league_avg_away_goals = self.league_avg_home_goals * 0.95
|
||
|
||
home_lambda = self.league_avg_home_goals * (self.home_attack_strength / self.away_defense_strength)
|
||
away_lambda = league_avg_away_goals * (self.away_attack_strength / self.home_defense_strength)
|
||
|
||
home_lambda = max(0.01, min(home_lambda, 8.0))
|
||
away_lambda = max(0.01, min(away_lambda, 8.0))
|
||
|
||
return home_lambda, away_lambda
|
||
|
||
def predict_exact_score_matrix(self, max_goals: int = 5) -> np.ndarray:
|
||
"""輸出 0~max_goals 間所有比分組合的機率矩陣。
|
||
|
||
回傳 shape = (max_goals+1, max_goals+1),
|
||
index [i,j] 代表主隊 i 球、客隊 j 球的機率。
|
||
"""
|
||
|
||
if max_goals < 0:
|
||
raise ValueError('max_goals 必須大於等於 0')
|
||
|
||
home_lambda, away_lambda = self.calculate_expected_goals()
|
||
|
||
goals = np.arange(max_goals + 1)
|
||
home_prob = poisson.pmf(goals, home_lambda)
|
||
away_prob = poisson.pmf(goals, away_lambda)
|
||
|
||
matrix = np.outer(home_prob, away_prob)
|
||
matrix = matrix.astype(float)
|
||
matrix /= matrix.sum() if matrix.sum() > 0 else 1.0
|
||
return matrix
|
||
|
||
def predict_1x2_probabilities(self) -> dict[str, float]:
|
||
"""由波膽矩陣匯總 1x2(主勝/平/客勝)機率。"""
|
||
|
||
matrix = self.predict_exact_score_matrix(max_goals=8)
|
||
|
||
draw = float(np.trace(matrix))
|
||
home_win = float(np.tril(matrix, -1).sum())
|
||
away_win = float(np.triu(matrix, 1).sum())
|
||
|
||
total = home_win + draw + away_win
|
||
if total <= 0:
|
||
return {'home_win': 0.0, 'draw': 0.0, 'away_win': 0.0}
|
||
|
||
return {
|
||
'home_win': home_win / total,
|
||
'draw': draw / total,
|
||
'away_win': away_win / total,
|
||
}
|
||
|
||
def predict_over_under_prob(self, line: float = 2.5, max_goals: int = 8) -> tuple[float, float]:
|
||
"""回傳(Under 機率, Over 機率)。"""
|
||
|
||
if line < 0:
|
||
raise ValueError('line 必須大於等於 0')
|
||
|
||
matrix = self.predict_exact_score_matrix(max_goals=max_goals)
|
||
goals = np.arange(max_goals + 1)
|
||
home, away = np.meshgrid(goals, goals)
|
||
total = home + away
|
||
|
||
under_mask = total <= line
|
||
under = float(matrix[under_mask].sum())
|
||
over = float(matrix[~under_mask].sum())
|
||
normalizer = under + over
|
||
|
||
if normalizer <= 0:
|
||
return 0.0, 0.0
|
||
|
||
return under / normalizer, over / normalizer
|