Brief background

If you ever checked out FiveThirtyEight’s predictions you probably came across their usage of an Elo rating system. While the wikipedia page is really the best starting point to learn about the background, in short, its a rating system for teams/players (originally designed for chess) based on head-to-head match-ups.

NFL 2019 data

We’re going to walkthrough the Elo rating calculation using data from the current 2019 NFL season. First, we’ll read in the data available on the workshop website that was accessed using nflscrapR:

Let’s take a look at the format of this data:

We’ll easily be able to use this data for generating Elo ratings over the course of the NFL season. The first step we need to take is create a column denoting whether the home team won (1), tied (0.5), or lost (0) with mutate and case_when:

Elo rating basics

Since we want to update ratings throughout the entire course of the season, we’re going to need to keep track of each team’s rating in a separate table. Plus, we need initial ratings for each team! We could proceed to use the same value for every team (typically 1500) to start. But instead we’re going to use the initial ratings from [FiveThirtyEight that are publicly available] and already saved on the workshop website:

We have a single rating for each team, along with a column for the week. We’re going to be updating this table incrementally for each match-up in nfl_games_19.

We’re going to use the most basic version of Elo ratings covered in wikipedia. Let the rating for the home team be \(R_{home}\), and the away team rating be \(R_{away}\). Then the expected score for the home team is: \[ E_{home} = \frac{1}{1 + 10^{(R_{away} - R_{home}) / 400}} \] and similarly for the away team it is: \[ E_{away} = \frac{1}{1 + 10^{(R_{home} - R_{away}) / 400}} \] The 400 and 10 basically determine the scaling of the ratings and can be modified. These expected scores represent the probability of winning plus half the probability of drawing - but for our purposes, basically the probability of winning.

We then update the ratings for the home team if they scored \(S_{home}\) points: \[ R^{new}_{home} = R_{home} + K \cdot (S_{home} - E_{home}) \] where \(K\) is the update factor. For now we we’ll set this to 20, but this is the maximum number of points a team gains from winning a single game.

To simplify this process, we’re going to create functions to calculate both the expected score and new rating for a team:

As an example calculation, in week one the Steelers lost to the Patriots 33-3. The Steelers initial rating was:

and the Patriots were:

Given these ratings, the Steelers expected score was:

And their updated rating following the loss?

Elo ratings for 2019 season

Now with the basics, let’s move on to perform these calculations over the entire season, updating our table to include each team’s Elo rating following every game. Basically, you can imagine a for loop to go through each game in nfl_games_19, looking up each team’s previous ratings and performing the above calculations.

It worked! What do our final ratings look like?

Let’s plot the ratings over the season:

There are way too many colors displayed here! Instead one could take advantage of the teamcolors package by Ben Baumer and Gregory Matthews to highlight individual teams. This is a little more involved, while we won’t walk through this code in the workshop, here is how one could highlight each division:

> # First read in the team colors data from the website:
> nfl_team_colors <- read_csv("http://www.stat.cmu.edu/cmsac/football/data/nfl_team_colors.csv")
> nfl_team_colors <- nfl_team_colors %>%
+   filter(abbr %in% unique(nfl_elo_ratings$team)) %>%
+   mutate(primary = ifelse(abbr %in% c("OAK", "PIT", "SEA", "TEN",
+                                          "JAX", "NE", "ATL"), 
+                           secondary, primary))
> 
> # Create a dataset that has each team's initial Elo rating
> nfl_team_start <- nfl_elo_ratings %>%
+   filter(week == 0) %>%
+   inner_join(nfl_team_colors, by = c("team" = "abbr")) %>%
+   arrange(desc(elo_rating))
> 
> # Need ggrepel:
> library(ggrepel)
> 
> division_plots <- lapply(sort(unique(nfl_team_start$division)),
+                          function(nfl_division) {
+                            
+                            # Pull out the teams in the division
+                            division_teams <- nfl_team_start %>%
+                              filter(division == nfl_division) %>%
+                              mutate(team = fct_reorder(team, desc(elo_rating)))
+                            
+                            # Get the Elo ratings data just for these teams:
+                            division_data <- nfl_elo_ratings %>%
+                              filter(team %in% division_teams$team) %>%
+                              mutate(team = factor(team,
+                                                   levels = levels(division_teams$team))) %>%
+                              # Make text labels for them:
+                              mutate(team_label = if_else(week == min(week),
+                                                          as.character(team), 
+                                                          NA_character_))
+ 
+                            # Now make the full plot
+                            nfl_elo_ratings %>%
+                              # Plot all of the other teams as gray lines:
+                              filter(!(team %in% division_teams$team)) %>%
+                              ggplot(aes(x = week, y = elo_rating, group = team)) +
+                              geom_line(color = "gray", alpha = 0.5) +
+                              # But display the division teams with their colors:
+                              geom_line(data = division_data,
+                                        aes(x = week, y = elo_rating, group = team,
+                                            color = team)) +
+                              geom_label_repel(data = division_data,
+                                               aes(label = team_label,
+                                                   color = team), nudge_x = 1, na.rm = TRUE,
+                                               direction = "y") +
+                              scale_color_manual(values = division_teams$primary, guide = FALSE) +
+                              scale_x_continuous(limits = c(0, 8),
+                                                 breaks = c(1:8)) +
+                              theme_bw() +
+                              labs(x = "Week", y = "Elo rating",
+                                   title = paste0("Division: ", nfl_division)) 
+                          })
> # Display the grid of plots with cowplot!
> library(cowplot)
> plot_grid(plotlist = division_plots, ncol = 4, align = "hv")
NFL 2019 Elo ratings by division

NFL 2019 Elo ratings by division

More resources