from dataclasses import dataclass, field
import numpy as np
from matplotlib.figure import Figure
from footix.utils.typing import ArrayLikeF, ProbaResult
from footix.vizu.score_matrix import plot_goal_matrix
[docs]
@dataclass
class GoalMatrix:
"""Utilities for match score probability matrices.
`GoalMatrix` builds a joint distribution over football scores
(home_goals, away_goals) from two marginal probability vectors.
Optionally, a non-negative correlation/weight matrix can be applied element-wise
to reweight scorelines.
Notes:
The input probability vectors are validated and normalized to sum to 1.
Args:
home_goals_probs: 1D array-like of non-negative goal probabilities for the home team.
away_goals_probs: 1D array-like of non-negative goal probabilities for the away team.
correlation_matrix: Optional 2D non-negative array of shape (n, n) applied
element-wise to the outer product.
Raises:
ValueError: If inputs are not 1D, contain NaN/Inf, contain negative values,
have incompatible lengths, have zero total probability mass, or if the
correlation matrix is invalid.
"""
home_goals_probs: ArrayLikeF
away_goals_probs: ArrayLikeF
correlation_matrix: np.ndarray | None = None
matrix_array: np.ndarray = field(init=False)
def __post_init__(self):
self._checks_init()
self.matrix_array = np.outer(self.home_goals_probs, self.away_goals_probs)
if self.correlation_matrix is not None:
self.matrix_array = self.matrix_array * self.correlation_matrix
mass = float(np.sum(self.matrix_array))
if (not np.isfinite(mass)) or (mass <= 0.0):
raise ValueError(
"correlation_matrix produces zero or non-finite total probability mass"
)
self.matrix_array = self.matrix_array / mass
def _checks_init(self) -> None:
"""Validate and normalize initialization inputs.
Raises:
ValueError: If any of the invariants described in the class docstring are violated.
"""
self.home_goals_probs = np.asarray(self.home_goals_probs, dtype=float)
self.away_goals_probs = np.asarray(self.away_goals_probs, dtype=float)
if (self.home_goals_probs.ndim != 1) or (self.away_goals_probs.ndim != 1):
raise ValueError("home_goals_probs and away_goals_probs must be 1D arrays")
if self.home_goals_probs.size == 0:
raise ValueError("home_goals_probs and away_goals_probs must be non-empty")
if self.home_goals_probs.shape[0] != self.away_goals_probs.shape[0]:
raise ValueError("home_goals_probs and away_goals_probs must have the same length")
for name, arr in (
("home_goals_probs", self.home_goals_probs),
("away_goals_probs", self.away_goals_probs),
):
if not np.all(np.isfinite(arr)):
raise ValueError(f"{name} must contain only finite values")
if np.any(arr < 0.0):
raise ValueError(f"{name} must be non-negative")
mass = float(np.sum(arr))
if (not np.isfinite(mass)) or (mass <= 0.0):
raise ValueError(f"{name} must have positive total probability mass")
self.home_goals_probs = self.home_goals_probs / float(np.sum(self.home_goals_probs))
self.away_goals_probs = self.away_goals_probs / float(np.sum(self.away_goals_probs))
if self.correlation_matrix is not None:
corr = np.asarray(self.correlation_matrix, dtype=float)
if corr.ndim != 2:
raise ValueError("correlation_matrix must be a 2D array")
n = int(self.home_goals_probs.shape[0])
if corr.shape != (n, n):
raise ValueError(
"correlation_matrix must have shape (n, n) matching probabilities length"
)
if not np.all(np.isfinite(corr)):
raise ValueError("correlation_matrix must contain only finite values")
if np.any(corr < 0.0):
raise ValueError("correlation_matrix must be non-negative")
self.correlation_matrix = corr
[docs]
def return_probas(self) -> ProbaResult:
"""Return results probabilities in this order: home_win, draw, away_win.
Returns:
ProbaResult: NamedTuple of probabilities
Raises:
ValueError: If the internal probability matrix has zero mass.
"""
home_win = np.sum(np.tril(self.matrix_array, -1))
draw = np.sum(np.diag(self.matrix_array))
away_win = np.sum(np.triu(self.matrix_array, 1))
total = float(home_win + draw + away_win)
if (not np.isfinite(total)) or (total <= 0.0):
raise ValueError("matrix_array must have positive total probability mass")
if not np.isclose(total, 1.0, rtol=1e-12, atol=1e-12):
home_win, draw, away_win = home_win / total, draw / total, away_win / total
return ProbaResult(proba_home=home_win, proba_draw=draw, proba_away=away_win)
[docs]
def less_15_goals(self) -> float:
self.assert_format_15()
return self.matrix_array[0, 0] + self.matrix_array[0, 1] + self.matrix_array[1, 0]
[docs]
def less_25_goals(self) -> float:
self.assert_format_25()
return (
self.less_15_goals()
+ self.matrix_array[0, 2]
+ self.matrix_array[1, 1]
+ self.matrix_array[2, 0]
)
[docs]
def more_25_goals(self) -> float:
return 1 - self.less_25_goals()
[docs]
def more_15_goals(self) -> float:
return 1.0 - self.less_15_goals()
[docs]
def visualize(self, n_goals: int = 5) -> Figure:
"""Visualize the goal matrix.
Args:
n_goals (int): Number of goals to visualize in the matrix. Defaults to 5.
Returns:
matplotlib.figure.Figure: The generated figure.
"""
return plot_goal_matrix(self.matrix_array, n_goals=n_goals)
[docs]
def asian_handicap_results(self, handicap: float) -> ProbaResult:
"""Calculate the probabilities for a home win, draw, and away win after applying an Asian
handicap using vectorized operations. The handicap is added to the home team's goal count.
Args:
handicap (float): The handicap to be applied to the home team's score.
Returns:
ProbaResult: home_win, draw, away_win probabilities.
"""
n = len(self.home_goals_probs)
tol = 1e-6 # tolerance for float equality
# Create a grid of differences between home and away goals
home_indices = np.arange(n)[:, None] + handicap # Add handicap to home goals
away_indices = np.arange(n)
diff_matrix = home_indices - away_indices
# Calculate probabilities based on the difference matrix
home_win = np.sum(self.matrix_array[diff_matrix > tol])
away_win = np.sum(self.matrix_array[diff_matrix < -tol])
draw = np.sum(self.matrix_array[np.abs(diff_matrix) <= tol])
return ProbaResult(proba_home=home_win, proba_draw=draw, proba_away=away_win)
def __str__(self) -> str:
home_str = ", ".join(f"{x:.2f}" for x in self.home_goals_probs[:5])
away_str = ", ".join(f"{x:.2f}" for x in self.away_goals_probs[:5])
return f"Goal Matrix computed using [{home_str}, ...] and [{away_str}, ...]."
[docs]
def get_probable_score(self) -> tuple[int, int]:
"""Return the most probable score (home_goals, away_goals) based on the matrix_array.
Returns
-------
tuple of int
The (home_goals, away_goals) corresponding to the highest probability in matrix_array.
Examples
--------
>>> gm = GoalMatrix(home_goals_probs, away_goals_probs)
>>> gm.get_probable_score()
(2, 1)
"""
idx = np.unravel_index(np.argmax(self.matrix_array), self.matrix_array.shape)
return int(idx[0]), int(idx[1])
[docs]
def double_chance(self) -> tuple[float, float, float]:
"""Calculates the double chance probabilities for a football match outcome.
Double chance is a betting market that covers two of the three possible outcomes
in a match:
- Home win or Draw (1X)
- Draw or Away win (X2)
- Home win or Away win (12)
Returns:
tuple[float, float, float]: A tuple containing:
- Probability of Home win or Draw (1X)
- Probability of Draw or Away win (X2)
- Probability of Home win or Away win (12)
"""
probas = self.return_probas()
p_1_x = probas.proba_home + probas.proba_draw
p_x_2 = probas.proba_draw + probas.proba_away
p_1_2 = probas.proba_home + probas.proba_away
return p_1_x, p_x_2, p_1_2
[docs]
def probability_both_teams_scores(self) -> float:
return np.sum(self.matrix_array[1:, 1:])