2
\$\begingroup\$

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:

  1. For each team calculate probability to score in a given minute.
  2. 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.
  3. Calculate cumulative score of a team by a particular minute of the match.
  4. By comparing cumulative scores of both teams, count the duration of each game state.
  5. Repeat this for the required number of trials.
  6. 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))
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Please do not edit the question, especially the code, after an answer has been posted. Changing the question may cause answer invalidation. Everyone needs to be able to see what the reviewer was referring to. What to do after the question has been answered. \$\endgroup\$
    – pacmaninbw
    Commented Sep 20, 2022 at 13:38
  • \$\begingroup\$ @pacmaninbw I'm aware of that, I have only fixed a typo, that doesn't affect anything at all. \$\endgroup\$ Commented Sep 20, 2022 at 13:54

1 Answer 1

2
\$\begingroup\$

It's clear that you've been working at generic Python quality, and from that perspective this code is half-decent. Where it falls over is Numpy use.

In one sense, well-written Numpy code doesn't look very much like what we imagine to be Pythonic code. Your dataclass must go away, and your "single_" method must go away. The operations from "single_" must be pulled out to "mean_" and vectorised over the number of trials. References to the built-in math and random libraries will go away. With this done, you can run the code in much less time, and/or run many more trials.

Even if you were to skip vectorisation (which you shouldn't), the other minor things you could change:

Insufficient inter-function spacing for PEP8; you need two blank lines.

MatchGameStates is unused so delete it.

int=MATCH_LENGTH is also non-PEP8-compliant and needs spacing.

random.seed() is not the responsibility of mean_game_state; it's a top-level program concern whether the results should be repeatable or not. Similarly, round is a display concern and shouldn't be baked into your business logic function.

MatchGameState, rather than a dataclass, is simpler as a NamedTuple.

Even without Numpy, the home_ahead, draw and away_ahead variables can be calculated with sum() on generators.

Suggested

import numpy as np
from numpy.random import default_rng

MATCH_LENGTH = 95
TRIALS = 100_000


rand = default_rng()


def mean_game_state(
    home_implied_goals: float,
    away_implied_goals: float,
    match_length_minutes: 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.
    """

    implied_goals = np.array((home_implied_goals, away_implied_goals))

    # Probability to score in a given minute
    goals_per_min = implied_goals / match_length_minutes

    # For every minute in a game, 1 if a team scored in that minute, 0 otherwise
    outcomes = rand.uniform(size=(trials, match_length_minutes, 2))
    goals_by_minute = outcomes < goals_per_min

    # How many goals a team scored by a particular minute of the game
    home_cumulative_goals, away_cumulative_goals = goals_by_minute.cumsum(axis=1).T
    diff = home_cumulative_goals - away_cumulative_goals

    home_ahead = np.count_nonzero(diff > 0, axis=0)
    away_ahead = np.count_nonzero(diff < 0, axis=0)
    draw = np.count_nonzero(diff == 0, axis=0)

    return home_ahead.mean(), draw.mean(), away_ahead.mean()


if __name__ == '__main__':
    print(mean_game_state(home_implied_goals=2.15, away_implied_goals=1.20))
\$\endgroup\$
4
  • \$\begingroup\$ The code with vectorization is much more concise indeed, not to mention that it is ~20-25 times faster. I'm wondering where lies another half of decency of generic Python code, could you also make your suggestions to that? \$\endgroup\$ Commented Sep 20, 2022 at 0:25
  • \$\begingroup\$ @KonstantinKostanzhoglo there you go. \$\endgroup\$
    – Reinderien
    Commented Sep 20, 2022 at 12:43
  • \$\begingroup\$ 1. I don't skip vectorization, in fact I will utilize your suggested code moving forward. I simply used it is an opportunity to get an additional feedback regarding my Python style in general. 2. I'm not sure about where to put random.seed(). Having it at the top-level of the program might conflict with differenct functions that use random or with some external program that imports it and uses its particular seed so that I wanted to keep it local to avoid issues like this. (Though I'm not sure whether calling it locally makes any difference from calling it at the top of the program). \$\endgroup\$ Commented Sep 20, 2022 at 12:52
  • \$\begingroup\$ 3. As far as int=MATCH_LENGTH is concerned I thought a space isn't needed for default values in a function signature. \$\endgroup\$ Commented Sep 20, 2022 at 12:52

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.