What my program does
I'm trying to estimate for how many minutes in average a football(soccer) match will be in different game states depending on implied goals of both teams.
Problem domain
For our purposes there are three possible game states:
- Home team is ahead, e.g. 1-0
- Both teams are drawing, e.g. 2-2
- Away team is ahead, e.g. 0-2
And implied goals means how many goals teams are expected to score in average, for example 1.80 for home team and 1.45 for away team.
For simplicity we may assume that:
- Teams' scoring rate doesn't depend on the current scoreline
- The probability to score is equal regardless of the part of the game
- If one team scored in a given minute, it doesn't affect the probability of the other team to score in the same minute
A typical football match consists of 90 minutes of regular time and usually 1 minute of injury time in the first half and 4 minutes in the second half, which gives us 95 minutes in total.
Chosen approach
I use the following algorithm to accomplish this task:
- For each team calculate probability to score in a given minute.
- For each team make a bitmap with length of match duration in minutes (95 in our example) where 1 indicates that a team scored in a given minute of the match and 0 indicates it didn't score.
- Calculate cumulative score of a team by a particular minute of the match.
- By comparing cumulative scores of both teams, count the duration of each game state.
- Repeat this for the required number of trials.
- Calculate average duration of each game state.
My questions
I'm interested in what are the alternatives to my solution both conceptually and implementation wise. Even though I'm aware that my program can be speed up by orders of magnitude via incorporating numpy, it is not that relevant in this particular case since it takes only a few seconds to run and it isn't supposed to be invoked a large number of times in a quick succession.
Code
from dataclasses import dataclass
import random
from statistics import mean
from numpy import cumsum
MATCH_LENGTH = 95
TRIALS = 100_000
@dataclass
class MatchGameState:
"""
Represents for how many minutes of a particular game:
- home team was ahead
- teams were drawing
- away team was ahead
"""
home_ahead: int
draw: int
away_ahead: int
MatchGameStates = list[MatchGameState]
def mean_game_state(home_implied_goals: float,
away_implied_goals: float,
match_length: int=MATCH_LENGTH,
trials: int=TRIALS) -> tuple[float, float, float]:
"""
Given match length in minutes and implied goals for home and away teams,
calculates for how many minutes per match in average home team will be ahead,
there will be a draw and away team will be ahead.
"""
random.seed()
sims = [single_match_game_state(home_implied_goals, away_implied_goals, match_length) for _ in range(trials)]
home_ahead_mean = round(mean(s.home_ahead for s in sims), 2)
draw_mean = round(mean(s.draw for s in sims), 2)
away_ahead_mean = round(mean(s.away_ahead for s in sims), 2)
return home_ahead_mean, draw_mean, away_ahead_mean
def single_match_game_state(home_implied_goals: float,
away_implied_goals: float,
match_length: int=95) -> MatchGameState:
"""
Given match length in minutes and implied goals for home and away teams,
simulates teams scoring minute by minute for a particular game.
Returns MatchGameState for the game.
"""
# Probability to score in a given minute
home_goals_per_min = home_implied_goals / match_length
away_goals_per_min = away_implied_goals / match_length
# For every minute in a game, 1 if a team scored in that minute, 0 otherwise
home_outcomes = [random.random() for _ in range(match_length)]
away_outcomes = [random.random() for _ in range(match_length)]
home_goals_by_minute = [int(home_outcome < home_goals_per_min) for home_outcome in home_outcomes]
away_goals_by_minute = [int(away_outcome < away_goals_per_min) for away_outcome in away_outcomes]
# How many goals a team scored by a particular minute of the game
home_cumulative_goals = cumsum(home_goals_by_minute)
away_cumulative_goals = cumsum(away_goals_by_minute)
home_ahead, draw, away_ahead = 0, 0, 0
for home_cumulative_score, away_cumulative_score in zip(home_cumulative_goals, away_cumulative_goals):
if home_cumulative_score > away_cumulative_score:
home_ahead += 1
elif home_cumulative_score == away_cumulative_score:
draw += 1
else:
away_ahead += 1
assert home_ahead + draw + away_ahead == match_length
return MatchGameState(home_ahead, draw, away_ahead)
if __name__ == '__main__':
print(mean_game_state(2.15, 1.20))