Forecasting Football Scores in R | Towards Data Science

In one of my earlier posts, I mentioned that the scores in a football match can be approximated somewhat using the Poisson distribution, but I didn’t go too much into the topic. Well, you’re in luck today... we’re going to have a look at the subject, and by the end of this post we’ll have Ggplot2 visual illustrating the likely scores of a football match, which really is excellent.
First off, a little bit on the roots of the Poisson distribution. The discovery of this probability distribution is attributed to Siméon Denis Poisson, a French mathematician from the nineteenth century. Like many of his contemporaries at the time, he dealt mainly with its theories. It wasn’t until Russian statistician Ladislaus Bortkiewicz published his book The Law of Small Numbers in 1898 that the Poisson distribution became widely used in practice.
Bortkiewicz famously had access to a data set of the Prussian army over a period of 20 years which recorded the number of soldiers that were killed by their horses’ kicks. Grim.
Anyway, he showed that these deaths could be approximated by the Poisson distribution, and the rest, as they say, is history.
Now... how does one go from Prussian cavalry to football scores?
Let’s first have a look at the basic assumptions that satisfy a Poisson process:
The probability of an event occurring in a given time interval does not vary with timeThe events occur at randomThe events occur independently
The probability of an event occurring in a given time interval does not vary with time
The events occur at random
The events occur independently
Now think of goals as an event in a football match. If we had a metric that could express a team’s scoring rate/intensity over 90 minutes, we can say that the first assumption is satisfied. This would’ve been a pain in the arse to define, but thankfully the sports nerds (people a hundred times more intelligent than me, I should add) have come up with expected goals (xG) for this. Thanks for today!
Next question- do goals occur randomly in a football match? That is to say, is there a particular point in time in a match when goals are more likely to be scored? This guy says no, and many other articles and studies also suggest that goals do occur at random in a football match. In practice, goals might not be as random as one might think but... we have enough evidence to argue that they are, so let’s tick that box and move on for now.
Now for the final assumption- that being, goals are independent of one another. If a team is behind, surely the players are working harder to get back into the game to score an equaliser. Or perhaps if a team is already leading by 5 goals, the players might take their foot off the gas and let one in due to complacency. Either way, you have to say that goals don’t occur independently of one another, and using a basic Poisson process to model football scores starts to break down here.
There are techniques to adjust for goal dependencies in a match, but we all have day jobs- so let’s leave that for another day... and agree for now that two out of three isn’t bad (also, I’m pushing to generate useful content innit, so here we are). For further reading, I suggest having a look at this fantastic blog post that has basically done a Bortkiewicz for football, showing you can approximate the number goals scored over a season with the Poisson distribution.
In my previous post on xG, I created a simple Poisson model to help predict the outcomes of a football match (home win, draw, away win) in R. We’re going to use something similar here, but focus more on obtaining the probabilities of the correct scores as an output.
R has come a long way from its base heat map libraries which haven’t aged too well. Ggplot2 is a fantastic package which allows for all sorts of graphs and charts to be built. In order to use them, we have to first install some dependencies in RStudio:
install.packages('tidyr')install.packages('dplyr')install.packages('scales')install.packages('ggplot2')library('tidyr')library('dplyr')library('scales')library('ggplot2')
Cool, now let’s go back to one of our original assumptions- that teams have a constant scoring rate over 90 minutes in a football match. We’ll be using the shot-based xG taken from FiveThirtyEight’s model for the Champions League quarter-final between Barcelona and Bayern Munich (oh boy...). Let’s construct and source a ScoreGrid:
We’ve created a data frame with 11 columns and 11 rows, each entry representing a correct score. So the entry in [row i , column j] is going to be the score i,j with i being the home score and j as the away score. Hence entry [1,2] is going to be a 2–1 win for the away team. dpois helps to generate a random Poisson probability for each score i, given the homeXg and awayXg. Finally, we cap the individual scores at 9, and once we get to 10 we’re going to sum the probabilities together and group them as a single entry. It just makes things easier.
Let’s give it a quick spin. In the RStudio console, type
ScoreGrid(1.7,1.1)
and you should get this:
Nice grid, but not too easy on the eye.
Let’s build the correct score visual now. We’re going to use geom_tile() in Ggplot2, which generates a nice heat map at the end. Here’s the code:
Ggplot2 requires the input data to be a tidy dataframe, so the conversion from base R data.frame takes place on line 10 with pivot_longer. Next, we’ll need to convert all our vars to factors and sort the levels in the correct order (remember that 10+ is a factor, using as.numeric will just cause it to become an NA. We want it to be the final level in the list). This is done on line 14 with ~forcats::fct_relevel. Everything beyond that just contains the usual building blocks of a Ggplot2 graph.
Right, here we go. Fire up the R console and enter the team names and xG inputs from wherever your source was from:
ScoreHeatMap("Barcelona", "Bayern_Munich", 1.2, 5.6, "FiveThirtyEight")
Men against boys in this match, and just by looking at the visualisation/heat map you can see the most likely score would have been 5–1 to Bayern Munich based on xG. In reality, Bayern actually won 8–2. Robert Lewangoalski indeed.
One more to illustrate a closer match, yesterday evening’s Champions League final between Paris Saint-Germain and Bayern Munich:
ScoreHeatMap("Paris_SG", "Bayern_Munich", 1.4,1.6,"FiveThirtyEight")
A much tighter match, with 1–1 being the most probable score- Bayern Munich actually won 1–0.
As always there are several caveats, one of which we’ve already discussed- namely that goals aren’t independent of one another, so the model is worth revisiting. Also, if any one of those xG chances had a different result (i.e. a shot hit the post or went wide instead of going in), we likely wouldn’t be here speaking about this today... Let’s not forget too how xG itself is a flawed metric which doesn’t capture a lot of the nuances within a football match.
Still, these visuals are a nice representation of how a match might have turned out- if anything, it’s a good exercise in using some of the impressive R libraries for graphics and visualisation.
As usual, you can nick all of this on my Github repository if I struggled to make any sense. Cheers for today...