How to Calculate Expected Goals (xG): A Simple Tutorial Using R

Introduction

When I started learning about soccer analytics, it was obvious that one of the things that gets talked about a lot is expected goals (also known as xG). I understood the basic idea of it – how likely is a shot to be a goal, given characteristics of the shot, such as distance and angle from the goal – but could never find a concrete step-by-step guide. So, I’m hoping this might help someone out. Also, a lot of analyses that I’ll be conducting and writing for this blog will use xG. Given this, it makes sense to go through how I’ll be calculating these values.

Before starting, it’s important to note that this is a basic way of calculating xG. Only a couple of variables are included in this model (where the shot was taken from, and whether the shot was with the right foot, left foot, or head). There are lots of other things that can be included in more complex models, such as whether there was a defender in the way, whether the foot used to shoot was the dominant foot, etc. However, as this is a basic introduction, and given the data I have, this will work fine for now. It probably still gives a reasonable estimate as it is, though maybe not as fine-tuned as the more complex models.

Software and Data

I’ll be doing all of this in R and Rstudio, so those will need to be downloaded from here and here before getting started. Both of these programs are free.

As this is a blog on the USL, the data is from the USL. This data file contains information from every shot taken in the 2019 regular season – the shot result (goal, missed, blocked, or saved), what the shot was with (right foot, left foot, or header), and where the shot was from (18 different options). This data comes from the text commentary that the USL Championship website provides. For an example, click here.

Step 1: Loading the data and R packages

One feature of R is that many developers and researchers create ‘packages’, which are chunks of commonly-used code that can be used by anyone so that they (or us, in this case) don’t have to type out a lot of code repeatedly. The first thing we’ll do is install and load three packages that are commonly used for data manipulation (also sometimes called ‘wrangling’): magittr, dplyr, and tidyr. Even if you already have these packages installed, the following lines of code will load them:

if(!require(magrittr)){install.packages('magrittr')}; library(magrittr)
if(!require(dplyr)){install.packages('dplyr')}; library(dplyr)
if(!require(tidyr)){install.packages('tidyr')}; library(tidyr)

For each of these lines of code, the part before the semicolon is essentially saying "If this package isn’t installed, then install it." The part after the semicolon is saying "Load the package."

Next, we’ll load the data in, and preview the first few rows to ensure it’s loaded correctly. Note that you’ll need to change the file directory to wherever you downloaded the data file to.

shots % symbols that are going to appear need to be explained. They're known as 'pipes', and they essentially tell R to follow the step that was just conducted immediately with the next one. It is entirely possible to do every step one at a time, but this is a bit tidier and intuitive, after a bit of practice.

First, we create a new data object called 'xG'. In this case, we're going to start off by making xG a clone of the shots data that we loaded in. This is because we want to start with this data, and then manipulate it to give us the probabilities of different types of shots ending up as goals.

```{r}
xG % pipe operator and the 'group_by' function, which will create subgroups of all the different combinations of shot locations, body parts, and shot results. This won't actually make the output look any different, but now R knows how to group the data.

```{r}
xG % 
  group_by(shot_with, shot_where, shot_result)
xG

Now, we want R to count the number of shots that fall within each subgroup.

xG % 
  group_by(shot_with, shot_where, shot_result) %>% 
  summarise(n = n())
xG

Suddenly, the size of our xG data object has changed form 15937×3 to 208×4, and we now have how many times there were shots of each subtype.

We change the size of the data again with the ‘pivot_wider’ line that is added below. This line is now making each shot subtype appear on one line, and the number of shots of those subtypes for each result.

xG % 
  group_by(shot_with, shot_where, shot_result) %>% 
  summarise(n = n())%>% 
  pivot_wider(id_cols = c("shot_with", "shot_where"), names_from = shot_result, values_from = n)
xG

You might notice that the table is now scattered with ‘NA’ in various spots. This means that there were no cases of this particular shot subtype ending up in that particular result throughout the entire 2019 season. For example, at no point in the 2019 season did a header from a difficult angle on the left ever end up hitting the left post. To sort this out, we recode the data by telling R to replace NA with zero.

This next chunk looks like a lot of extra code, but really we’re just doing the same thing for each potential shot result over and over:

xG % 
  group_by(shot_with, shot_where, shot_result) %>% 
  summarise(n = n())%>% 
  pivot_wider(id_cols = c("shot_with", "shot_where"), names_from = shot_result, values_from = n) %>% 
  mutate(bar = replace_na(bar, 0),
         blocked = replace_na(blocked, 0),
         goal = replace_na(goal, 0),
         missed = replace_na(missed, 0),
         saved = replace_na(saved, 0),
         `left post` = replace_na(`left post`, 0),
         `right post` = replace_na(`right post`, 0))
xG

We now have all our numbers in place! There are only two more things to do, and we’re going to do them at the same time.

We need to make a column of total shots for each subtype (i.e., add up all the values in each row). Then, we need to make another column that calculates goals divided by our new ‘total shots’ column, called xG. We do this by using the same ‘mutate’ function from the previous block of code. In fact, I’m just going to add these two new columns into the same mutate function to make the code a little more tidy:

xG % 
  group_by(shot_with, shot_where, shot_result) %>% 
  summarise(n = n())%>% 
  pivot_wider(id_cols = c("shot_with", "shot_where"), names_from = shot_result, values_from = n) %>% 
  mutate(bar = replace_na(bar, 0),
         blocked = replace_na(blocked, 0),
         goal = replace_na(goal, 0),
         missed = replace_na(missed, 0),
         saved = replace_na(saved, 0),
         `left post` = replace_na(`left post`, 0),
         `right post` = replace_na(`right post`, 0),
         total = bar + blocked + goal + missed + saved + `left post` + `right post`,
         xG = goal/total)
xG

Our xG column is our expected goals for a lot of different shot locations, and we can now calculate the number of expected goals for various games.

Limitations

This model is a very simple example of how to calculate xG, so there are some limitations. First, this doesn’t take into account the shooting ability of the player taking the shot (or shot-stopping ability of the goalkeeper). It assumes that every shot taker has the same ability. Likewise, whether the shot is taken with a player’s dominant foot or not (obviously some players are better with one foot that another). We also don’t take into account whether there are any defenders in direct line between the ball and the goal. Like I said, it’s a basic model. More complex models take these various factors into account.

Lastly, and maybe most importantly, all the shots from only one season is actually a pretty small number of shots, once it gets split into over 40 different types of shots like in this example. Having more of each type of shot gives better estimates of whether a shot from that location, with that body part, will go in over the long run.

How to Calculate Expected Assists

Expected assists are actually calculated in the exact same way as xG – there are no further steps beyond what I’ve already written about. Of course, it’s helpful to know the names of players who take the shots and create the chances, but I’ll save that for a future post.

Summary

This post hopefully showed that it’s quite simple to calculate expected goals, if you have the data from which to calculate it. I’ll be using this model in future posts when I discuss findings from the USL Championship, although with more data than just the 2019 season.

Advertisement

One thought on “How to Calculate Expected Goals (xG): A Simple Tutorial Using R

Comments are closed.