Elo and the EPL: a tutorial

Tyler Richards


The Elo rating system is a dynamically updated rating system originally created for chess by Arpad Elo that thrives in ranking head to head interactions with many iterations. By the end of this tutorial, any R user should be able to calculate the Elo score of any English Premier League Team during the course of any season, and have a basic understanding of how apply this technique to other leagues and sports.

Let’s get started!

In broad strokes the steps to this analysis are:

  1. Find and load data
  2. Clean and format data
  3. Apply Elo
  4. Visualize results

Below is all the packages that we will use. If you haven’t installed any of them, do so now using the install.packages() function!


Finding and Loading Data

Thankfully, this website has an incredible amount of Premier League data. Download it to your working directory below.

download.file(url = "http://www.football-data.co.uk/mmz4281/1617/E0.csv", destfile = "epl1617.csv")

Your file is now downloaded. Use read.csv() and head() to check out the format and structure of the data.

epl_1617 <- read.csv("epl1617.csv")
##   Div     Date       HomeTeam   AwayTeam FTHG
## 1  E0 13/08/16        Burnley    Swansea    0
## 2  E0 13/08/16 Crystal Palace  West Brom    0
## 3  E0 13/08/16        Everton  Tottenham    1
## 4  E0 13/08/16           Hull  Leicester    2
## 5  E0 13/08/16       Man City Sunderland    2
## 6  E0 13/08/16  Middlesbrough      Stoke    1

For the most basic application of Elo, we need to know what the result was (win, lose, draw), and who was playing. Let’s use dplyr’s handy case_when() function to quickly get our data in the correct format.

epl_1617$winner = case_when(epl_1617$FTR == 'H' ~ as.character(epl_1617$HomeTeam),
                            epl_1617$FTR == 'A' ~ as.character(epl_1617$AwayTeam),
                            epl_1617$FTR == 'D' ~ as.character(epl_1617$HomeTeam))
epl_1617$loser = case_when(epl_1617$FTR == 'A' ~ as.character(epl_1617$HomeTeam),
                            epl_1617$FTR == 'H' ~ as.character(epl_1617$AwayTeam),
                            epl_1617$FTR == 'D' ~ as.character(epl_1617$AwayTeam))

epl_1617$Draw = case_when(epl_1617$FTR == "D" ~ TRUE,
                          epl_1617$FTR != "D" ~ FALSE)
head(epl_1617[,c('winner', 'loser', 'Draw')])
##          winner          loser  Draw
## 1       Swansea        Burnley FALSE
## 2     West Brom Crystal Palace FALSE
## 3       Everton      Tottenham  TRUE
## 4          Hull      Leicester FALSE
## 5      Man City     Sunderland FALSE
## 6 Middlesbrough          Stoke  TRUE

This works quite well. For the package we’ll be using to calculate elo (EloRating), we need a winner, loser, and a Boolean column for a Draw in the next column. Also, if the Draw column is TRUE, it doesn’t matter who is in the winner column vs the loser so I just put the home team in the winning column and the away team in the losing column.

Now let’s filter for the columns we need.

epl_1617_elo <- epl_1617[,c('Date', 'winner', 'loser', 'Draw')]

Currently the Date column is in the wrong format, and is a factor. Use substring to get it in ‘year/month/date’ format and as.Date() to make R recognize it as a Date.

epl_1617_elo$Date <- as.Date(epl_1617_elo$Date,"%d/%m/%Y")
epl_1617_elo$Date <- as.character(epl_1617_elo$Date)
substr(epl_1617_elo$Date, 1, 2) <- "20"
epl_1617_elo$Date <- as.Date(epl_1617_elo$Date)
##         Date        winner          loser  Draw
## 1 2016-08-13       Swansea        Burnley FALSE
## 2 2016-08-13     West Brom Crystal Palace FALSE
## 3 2016-08-13       Everton      Tottenham  TRUE
## 4 2016-08-13          Hull      Leicester FALSE
## 5 2016-08-13      Man City     Sunderland FALSE
## 6 2016-08-13 Middlesbrough          Stoke  TRUE

Now we have all the data in the right format. The function elo.seq returns an object with the calculated elo scores, with each team starting at 1000 points.

res_elo <- elo.seq(winner = epl_1617_elo$winner, loser = epl_1617_elo$loser, Date = epl_1617_elo$Date, runcheck = TRUE, draw = epl_1617_elo$Draw, progressbar = FALSE)
## Elo ratings from 20 individuals
## total (mean/median) number of interactions: 380 (38/38)
## range of interactions: 38 - 38 
## date range: 2016-08-13 - 2017-05-21 
## startvalue: 1000 
## uppon arrival treatment: average 
## k: 100 
## proportion of draws in the data set: 0.22

It worked perfectly! We know that 22% percent of matches last year were draws, and the date range is correct. We can use those fields to make sure the function did what we wanted. We can use the eloplot() function to look at a time series calculation for each team.


This isn’t the best visualization for our use case. We can do so much better. The res_elo$mat matrix has everything we’ll need. Turn it into a data frame and then view.

elo_totals <- res_elo$mat
elo_totals <- as.data.frame(elo_totals)
##   Swansea West Brom Everton Hull Man City
## 1    1050      1050    1000 1050     1050
## 2      NA        NA      NA   NA       NA
## 3      NA        NA      NA   NA       NA
## 4      NA        NA      NA   NA       NA
## 5      NA        NA      NA   NA       NA
## 6      NA        NA      NA   NA       NA

This data frame has each team’s Elo score by index where the index is related to the different game days in the Premier League. Note that not every team plays on the same day, so let’s add the dates to make visualization easier.

dates <- res_elo$truedates
elo_totals$Dates <- dates

Now create a function for graphing each team’s performance throughout the year.

plotting_elo <- function(team_name){
  filtered_data <- elo_totals[,c(team_name, "Dates")]
  filtered_data <- filtered_data[!is.na(filtered_data[,team_name]),]
  x <- ggplot(data = filtered_data, aes(x = Dates, y = filtered_data[,1])) +
    geom_line() + 
    ggtitle((paste("2016-2017 EPL Season: ", team_name))) +
    labs(y = "Elo Score", x = "Date") +

Let’s test it out with the winner of the 16/17 season, Chelsea.

Chelsea_elo <- plotting_elo("Chelsea")

This makes perfect sense to the loyal Chelsea fan that I am. Chelsea had a couple key losses to top talent in September to Arsenal and Liverpool, and tied a worse team (Swansea). The drop between December and January is explained by Chelsea’s 2-0 loss to Tottenham.

Now let’s check out the most continuously disappointing team in the league, Arsenal.

Arsenal_elo <- plotting_elo("Arsenal")

Arsenal managed to get almost to the 1200 Elo score with their late push for the Champion’s League spot but still ended far below the league champions, finishing in 5th.

How does the final Elo score compare to the final league ranking? Let’s extract the elo ranking from the result of our model and compare it with the actual result.

final_elo <- as.data.frame(extract.elo(res_elo))
teams <- rownames(final_elo)
final_elo$Team <- teams
rownames(final_elo) <- NULL
ActualFinal <- c("Chelsea", "Tottenham", "Man City", "Liverpool", "Arsenal", "Man United", "Everton", "Southampton", "Bournemouth", "West Brom", "West Ham", "Leicester City", "Stoke City", "Crystal Palace", "Swansea City", "Burnley", "Watford", "Hull City", "Middlesbrough", "Sunderland")
final_elo$ActualResult <- ActualFinal

colnames(final_elo) <- c("Elo Score", "Elo Rank", "Actual Final")
head(final_elo, 20)
##    Elo Score       Elo Rank   Actual Final
## 1       1290      Tottenham        Chelsea
## 2       1288        Chelsea      Tottenham
## 3       1221       Man City       Man City
## 4       1196        Arsenal      Liverpool
## 5       1161      Liverpool        Arsenal
## 6       1105     Man United     Man United
## 7       1022        Swansea        Everton
## 8       1018    Bournemouth    Southampton
## 9       1006        Everton    Bournemouth
## 10       993      Leicester      West Brom
## 11       988       West Ham       West Ham
## 12       964 Crystal Palace Leicester City
## 13       940    Southampton     Stoke City
## 14       940          Stoke Crystal Palace
## 15       855        Burnley   Swansea City
## 16       835           Hull        Burnley
## 17       826      West Brom        Watford
## 18       819        Watford      Hull City
## 19       787  Middlesbrough  Middlesbrough
## 20       746     Sunderland     Sunderland

The Elo score seems to compare fairly well to the final rankings. Note that the goal was not to predict who would win the league, but to measure the skill of each team in comparison so we should not be worried with small errors like Arsenal and Liverpool being swapped. The largest error is clearly Swansea, who is ranked highly by Elo but finished near the bottom of the league. Why would that be?

Swansea_elo <- plotting_elo("Swansea")

By early April, Swansea was ranked at 775, one of the lowest scores. However, they went on a streak, beating Stoke, Everton, Sunderland, and West Brom while tying Man United, all at the end of the season. This illustrates some of the fundamental flaws of Elo, mainly that depending on the k value we specify (we used the default value) it can shift scores in a disproportionate way compared to how much games at the end of the season matter (games at the end of the season matter more for those who have the potential to win the league, get a spot in the Champion’s League, or who can get relegated). Elo is therefore overly simplistic, but can provide insight regardless.

That’s the end! Thankfully, the data between leagues is in similar/identical formats so applications of this methodology for different leagues and years should be very doable for beginners and a breeze for experienced R users.

If you have any questions or comments, please reach out to me here