Section 5.2 The Poisson Distribution
As another example where the outcomes of sports events follow predictable patterns, letβs look at the number of goals scored in ice hockey games.
Weβll start by simulating a 60-minute game, which is 3600 seconds, assuming that the teams score a total of 6 goals per game, on average, and that the goal-scoring probability,
p, is the same during any second.
$ n = 3600
m = 6
p = m / 3600
p
0.0016666666666666668
Now we can use the following function to simulate
n seconds and return the total number of goals scored.
def simulate_goals(n, p):
return flip(n, p).sum()
If we simulate many games, we can confirm that the average number of goals per game is close to 6.
$ goals = [simulate_goals(n, p) for i in range(1001)]
np.mean(goals)
np.float64(6.021978021978022)
We could use the binomial distribution to model these results, but when
n is large and p is small, the results are also well-modeled by a Poisson distribution, which is specified by a value usually denoted with the Greek letter Ξ» (lambda), represented in code with the variable lam. lam represents the goal-scoring rate, which is 6 goals per game in the example.
The PMF of the Poisson distribution is easy to compute β given
lam, we can use the following function to compute the probability of seeing k goals in a game.
from scipy.special import factorial
def poisson_pmf(k, lam):
"""Compute the Poisson PMF.
k (int or array-like): The number of occurrences
lam (float): The rate parameter (Ξ») of the Poisson distribution
returns: float or ndarray
"""
return (lam**k) * np.exp(-lam) / factorial(k)
If we call
poisson_pmf with a range of k values, we can make a Pmf that represents the distribution of outcomes, and confirm that the mean of the distribution is close to 6.
lam = 6
ks = np.arange(20)
ps = poisson_pmf(ks, lam)
pmf_poisson = Pmf(ps, ks, name="Poisson model")
$ pmf_poisson.normalize()
pmf_poisson.mean()
np.float64(5.999925498375129)
The following figure compares the results from the simulation to the Poisson distribution with the same mean.
pmf_sim = Pmf.from_seq(goals, name="simulation")
two_bar_plots(pmf_sim, pmf_poisson)
decorate(xlabel="Goals", ylabel="PMF")

The distributions are similar except for small differences due to random variation. That should not be surprising, because the simulation and the Poisson model are based on the same assumption that the probability of scoring a goal is the same during any second of the game. So a stronger test is to see how well the model fits real data.
I downloaded results of every game of the National Hockey League (NHL) 2023-2024 regular season. I extracted information about goals scored during 60 minutes of regulation play, not including overtime or tie-breaking shootouts. The results are in an HDF file with one key for each game, and a list of times, in seconds since the beginning of the game, when a goal was scored.
Raw data downloaded from https://www.hockey-reference.com/leagues/NHL_2024_games.html on July 16, 2024.
download("https://github.com/AllenDowney/ThinkStats/raw/v3/data/nhl_2023_2024.hdf")
Hereβs how we read the keys from the file.
$ filename = "nhl_2023_2024.hdf"
with pd.HDFStore(filename, "r") as store:
keys = store.keys()
len(keys), keys[0]
(1312, '/202310100PIT')
There were 1312 games during the regular season. Each key contains the date of the game and a three-letter abbreviation for the home team. We can use
read_hdf to look up a key and get the list of times when a goal was scored.
$ times = pd.read_hdf(filename, key=keys[0])
times
0 424
1 1916
2 2137
3 3005
4 3329
5 3513
dtype: int64
In the first game of the season, six goals were scored, the first after 424 seconds of play, the last after 3513 seconds β with only 87 seconds left in the game.
$ 3600 - times[5]
np.int64(87)
The following loop reads the results for all games, counts the number of goals in each one, and stores the results in a list.
goals = []
for key in keys:
times = pd.read_hdf(filename, key=key)
n = len(times)
goals.append(n)
The average number of goals per game is just over 6.
$ lam = np.mean(goals)
lam
np.float64(6.0182926829268295)
We can use
poisson_pmf to make a Pmf that represents a Poisson distribution with the same mean as the data, and compare that to the Pmf of the data.
ps = poisson_pmf(ks, lam)
pmf_poisson = Pmf(ps, ks, name="Poisson model")
pmf_goals = Pmf.from_seq(goals, name="goals scored")
two_bar_plots(pmf_goals, pmf_poisson)
decorate(xlabel="Goals", ylabel="PMF")

The Poisson distribution fits the data well, which suggests that it is a good model of the goal-scoring process in hockey.
