Calculating Running Elo Updates

Ethan Heinzen

library(elo)

The elo.run() function

It is useful to allow Elos to update as matches occur. We refer to this as “running” Elo scores.

With two variable Elos

To calculate a series of Elo updates, use elo.run(). This function has a formula = and data = interface. We first load the dataset tournament.

data(tournament)
str(tournament)
## 'data.frame':    56 obs. of  6 variables:
##  $ team.Home     : chr  "Blundering Baboons" "Defense-less Dogs" "Fabulous Frogs" "Helpless Hyenas" ...
##  $ team.Visitor  : chr  "Athletic Armadillos" "Cunning Cats" "Elegant Emus" "Gallivanting Gorillas" ...
##  $ points.Home   : num  14 21 15 13 22 18 20 23 25 23 ...
##  $ points.Visitor: num  22 18 11 15 13 20 22 10 16 18 ...
##  $ week          : num  1 1 1 1 2 2 2 2 3 3 ...
##  $ half          : chr  "First Half of Season" "First Half of Season" "First Half of Season" "First Half of Season" ...

formula = should be in the format of wins.A ~ team.A + team.B. The score() function will help to calculate winners on the fly (1 = win, 0.5 = tie, 0 = loss).

tournament$wins.A <- tournament$points.Home > tournament$points.Visitor
elo.run(wins.A ~ team.Home + team.Visitor, data = tournament, k = 20)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.
# on the fly
elo.run(score(points.Home, points.Visitor) ~ team.Home + team.Visitor, data = tournament, k = 20)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.

For more complicated Elo updates, you can include the special function k() in the formula = argument. Here we’re taking the log of the win margin as part of our update.

elo.run(score(points.Home, points.Visitor) ~ team.Home + team.Visitor +
        k(20*log(abs(points.Home - points.Visitor) + 1)), data = tournament)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.

You can also adjust the home and visitor teams with different k’s (but note that this no longer conserves total Elo score!):

k1 <- 20*log(abs(tournament$points.Home - tournament$points.Visitor) + 1)
elo.run(score(points.Home, points.Visitor) ~ team.Home + team.Visitor + k(k1, k1/2), data = tournament)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.

It’s also possible to adjust one team’s Elo for a variety of factors (e.g., home-field advantage). The adjust() special function will take as its second argument a vector or a constant.

elo.run(score(points.Home, points.Visitor) ~ adjust(team.Home, 10) + team.Visitor,
        data = tournament, k = 20)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.

With a fixed-Elo opponent

elo.run() also recognizes if the second column is numeric, and interprets that as a fixed-Elo opponent.

tournament$elo.Visitor <- 1500
elo.run(score(points.Home, points.Visitor) ~ team.Home + elo.Visitor,
        data = tournament, k = 20)
## 
## An object of class 'elo.run', containing information on 8 teams and 56 matches.

Why would you want to do this? One instance might be when a person plays against a computer whose Elo score is known (or estimated).

Regress Elos back to the mean

The special function regress() can be used to regress Elos back to a fixed value after certain matches. Giving a logical vector identifies these matches after which to regress back to the mean. Giving any other kind of vector regresses after the appropriate groupings (e.g., duplicated(..., fromLast = TRUE)). The other three arguments determine what Elo to regress to (to =, which could be a different value for different teams), by how much to regress toward that value (by =), and whether to regress teams which aren’t actively playing (regress.unused =). Note here again that total Elo score might not be conserved.

tournament$elo.Visitor <- 1500
elo.run(score(points.Home, points.Visitor) ~ team.Home + elo.Visitor +
        regress(half, 1500, 0.2),
        data = tournament, k = 20)
## 
## An object of class 'elo.run.regressed', containing information on 8 teams and 56 matches, with 2 regressions.

Group matches

The special function group() tells elo.run() when to update Elos. It also determines matches to group together in as.matrix().

er <- elo.run(score(points.Home, points.Visitor) ~ team.Home + team.Visitor +
                group(week),
              data = tournament, k = 20)
as.matrix(er)
##       Athletic Armadillos Blundering Baboons Cunning Cats Defense-less Dogs
##  [1,]            1510.000           1490.000     1490.000          1510.000
##  [2,]            1499.425           1500.575     1500.575          1499.425
##  [3,]            1489.425           1490.575     1510.575          1509.425
##  [4,]            1499.458           1480.542     1520.542          1499.458
##  [5,]            1489.458           1470.542     1520.542          1489.458
##  [6,]            1490.033           1461.971     1509.681          1480.033
##  [7,]            1501.153           1453.313     1499.945          1470.647
##  [8,]            1509.785           1444.682     1509.104          1461.489
##  [9,]            1519.765           1455.165     1499.124          1451.005
## [10,]            1527.812           1446.423     1507.865          1442.959
## [11,]            1537.372           1458.979     1508.123          1434.819
## [12,]            1547.007           1450.239     1518.364          1437.130
## [13,]            1556.067           1461.837     1528.173          1429.335
## [14,]            1564.318           1453.079     1518.019          1421.394
##       Elegant Emus Fabulous Frogs Gallivanting Gorillas Helpless Hyenas
##  [1,]     1490.000       1510.000              1510.000        1490.000
##  [2,]     1500.575       1499.425              1499.425        1500.575
##  [3,]     1490.575       1489.425              1509.425        1510.575
##  [4,]     1480.542       1499.458              1499.458        1520.542
##  [5,]     1490.542       1509.458              1509.458        1520.542
##  [6,]     1501.403       1518.883              1508.883        1529.113
##  [7,]     1510.789       1528.618              1517.541        1517.993
##  [8,]     1501.302       1538.106              1527.554        1507.980
##  [9,]     1502.056       1527.241              1526.800        1518.844
## [10,]     1512.539       1537.228              1516.813        1508.362
## [11,]     1502.978       1524.672              1516.555        1516.501
## [12,]     1511.718       1515.038              1514.245        1506.260
## [13,]     1501.910       1522.832              1505.185        1494.661
## [14,]     1509.851       1532.986              1513.944        1486.411

This can be useful in situations when using the Elo framework for games which aren’t explicitly head-to-head (e.g., golf, swimming). For those situations, the person who won can be considered as having beaten (head-to-head) every other person. The person who came in second “beat” everyone but the first. However, we wouldn’t want to update Elos after every “head-to-head”; rather, they should all be considered together in updating Elo.

An example might help clarify. Suppose participants 1-3 go head-to-head in a game, with participant 2 coming in first, participant 1 coming in second, and participant 3 coming in last. Then we might have a dataset like

d <- data.frame(
  team1 = c("Part 2", "Part 2", "Part 1"),
  team2 = c("Part 1", "Part 3", "Part 3"),
  won = 1
)
d
##    team1  team2 won
## 1 Part 2 Part 1   1
## 2 Part 2 Part 3   1
## 3 Part 1 Part 3   1

We would want to consider all three of these matches at the same time, so we add a grouping variable and run elo.run():

d$group <- 1
final.elos(elo.run(won ~ team1 + team2 + group(group), data = d, k = 20))
## Part 1 Part 2 Part 3 
##   1500   1520   1480

elo.run.multiteam()

The situation described immediately above (multiple teams instead of pairwise head-to-head) has a shortcut implemented: elo.run.multiteam(). The helper function multiteam() takes vectors of first-place teams (first column), second place teams (second column), etc., and does the heavy lifting of data manipulation for you. Note that this runs elo.run() in the background, but is less flexible than elo.run() because (1) there cannot be ties; (2) it does not accept adjustments; and (3) k-values are constant for each “game” (sets of head-to-head matchups).

d2 <- data.frame(
  first = "Part 2",
  second = "Part 1",
  third = "Part 3"
)
final.elos(elo.run.multiteam(~ multiteam(first, second, third), k = 20, data = d2))
## Part 1 Part 2 Part 3 
##   1500   1520   1480

A larger example shows the utility of this function:

data("tournament.multiteam")
str(tournament.multiteam)
## Classes 'tbl_df', 'tbl' and 'data.frame':    28 obs. of  6 variables:
##  $ week   : num  1 1 2 2 3 3 4 4 5 5 ...
##  $ half   : chr  "First Half of Season" "First Half of Season" "First Half of Season" "First Half of Season" ...
##  $ Place_1: chr  "Defense-less Dogs" "Athletic Armadillos" "Helpless Hyenas" "Elegant Emus" ...
##  $ Place_2: chr  "Fabulous Frogs" "Cunning Cats" "Cunning Cats" "Blundering Baboons" ...
##  $ Place_3: chr  "Blundering Baboons" "Gallivanting Gorillas" "Gallivanting Gorillas" "Athletic Armadillos" ...
##  $ Place_4: chr  "Helpless Hyenas" "Elegant Emus" "Defense-less Dogs" "Fabulous Frogs" ...
erm <- elo.run.multiteam(~ multiteam(Place_1, Place_2, Place_3, Place_4),
                         data = tournament.multiteam, k = 20)
final.elos(erm)
##   Athletic Armadillos    Blundering Baboons          Cunning Cats 
##              1672.333              1439.738              1559.373 
##     Defense-less Dogs          Elegant Emus        Fabulous Frogs 
##              1336.748              1449.914              1524.204 
## Gallivanting Gorillas       Helpless Hyenas 
##              1517.780              1499.910

Helper functions

There are several helper functions that are useful to use when interacting with objects of class "elo.run".

summary.elo.run() reports some summary statistics.

e <- elo.run(score(points.Home, points.Visitor) ~ team.Home + team.Visitor,
             data = tournament, k = 20)
summary(e)
## 
## An object of class 'summary.elo.run', containing information on 8 teams and 56 matches.
## 
## Mean Square Error: 0.2195
## AUC: 0.6304
## Favored Teams vs. Actual Wins: 
##        Actual
## Favored  0 0.5  1
##   TRUE   6   1 16
##   (tie)  2   1  9
##   FALSE  8   3 10
rank.teams(e)
##   Athletic Armadillos    Blundering Baboons          Cunning Cats 
##                     1                     7                     3 
##     Defense-less Dogs          Elegant Emus        Fabulous Frogs 
##                     8                     5                     2 
## Gallivanting Gorillas       Helpless Hyenas 
##                     4                     6

as.matrix.elo.run() creates a matrix of running Elos.

head(as.matrix(e))
##      Athletic Armadillos Blundering Baboons Cunning Cats Defense-less Dogs
## [1,]            1510.000           1490.000     1500.000          1500.000
## [2,]            1510.000           1490.000     1490.000          1510.000
## [3,]            1510.000           1490.000     1490.000          1510.000
## [4,]            1510.000           1490.000     1490.000          1510.000
## [5,]            1499.425           1490.000     1500.575          1510.000
## [6,]            1499.425           1500.575     1500.575          1499.425
##      Elegant Emus Fabulous Frogs Gallivanting Gorillas Helpless Hyenas
## [1,]         1500           1500                  1500            1500
## [2,]         1500           1500                  1500            1500
## [3,]         1490           1510                  1500            1500
## [4,]         1490           1510                  1510            1490
## [5,]         1490           1510                  1510            1490
## [6,]         1490           1510                  1510            1490

as.data.frame.elo.run() gives the long version (perfect, for, e.g., ggplot2).

str(as.data.frame(e))
## 'data.frame':    56 obs. of  8 variables:
##  $ team.A  : Factor w/ 8 levels "Athletic Armadillos",..: 2 4 6 8 3 4 7 8 4 3 ...
##  $ team.B  : Factor w/ 8 levels "Athletic Armadillos",..: 1 3 5 7 1 2 5 6 1 2 ...
##  $ p.A     : num  0.5 0.5 0.5 0.5 0.471 ...
##  $ wins.A  : num  0 1 1 0 1 0 0 1 1 1 ...
##  $ update.A: num  -10 10 10 -10 10.6 ...
##  $ update.B: num  10 -10 -10 10 -10.6 ...
##  $ elo.A   : num  1490 1510 1510 1490 1501 ...
##  $ elo.B   : num  1510 1490 1490 1510 1499 ...

Finally, final.elos() will extract the final Elos per team.

final.elos(e)
##   Athletic Armadillos    Blundering Baboons          Cunning Cats 
##              1564.318              1453.079              1518.019 
##     Defense-less Dogs          Elegant Emus        Fabulous Frogs 
##              1421.394              1509.851              1532.986 
## Gallivanting Gorillas       Helpless Hyenas 
##              1513.944              1486.411

Making Predictions

It is also possible to use the Elos calculated by elo.run() to make predictions on future match-ups.

results <- elo.run(score(points.Home, points.Visitor) ~ adjust(team.Home, 10) + team.Visitor,
                   data = tournament, k = 20)
newdat <- data.frame(
  team.Home = "Athletic Armadillos",
  team.Visitor = "Blundering Baboons"
)
predict(results, newdata = newdat)
## [1] 0.6676045

Advanced: custom probability and updates

We now get to elo.run() when custom probability calculations and Elo updates are needed. Note that these use cases are coded in R instead of C++ and may run as much as 50x slower than the default.

For instance, suppose you want to change the adjustment based on team A’s current Elo:

custom_update <- function(wins.A, elo.A, elo.B, k, adjust.A, adjust.B, ...)
{
  k*(wins.A - elo.prob(elo.A, elo.B, adjust.B = adjust.B,
                       adjust.A = ifelse(elo.A > 1500, adjust.A / 2, adjust.A)))
}
custom_prob <- function(elo.A, elo.B, adjust.A, adjust.B)
{
  1/(1 + 10^(((elo.B + adjust.B) - (elo.A + ifelse(elo.A > 1500, adjust.A / 2, adjust.A)))/400.0))
}
er2 <- elo.run(score(points.Home, points.Visitor) ~ adjust(team.Home, 10) + team.Visitor,
               data = tournament, k = 20, prob.fun = custom_prob, update.fun = custom_update)
## Using R instead of C++
final.elos(er2)
##   Athletic Armadillos    Blundering Baboons          Cunning Cats 
##              1564.660              1452.278              1518.165 
##     Defense-less Dogs          Elegant Emus        Fabulous Frogs 
##              1420.876              1510.015              1533.209 
## Gallivanting Gorillas       Helpless Hyenas 
##              1514.233              1486.564

Compare this to the results from the default:

er3 <- elo.run(score(points.Home, points.Visitor) ~ adjust(team.Home, 10) + team.Visitor,
               data = tournament, k = 20)
final.elos(er3)
##   Athletic Armadillos    Blundering Baboons          Cunning Cats 
##              1563.967              1452.822              1517.847 
##     Defense-less Dogs          Elegant Emus        Fabulous Frogs 
##              1421.358              1509.906              1533.127 
## Gallivanting Gorillas       Helpless Hyenas 
##              1514.205              1486.768

This example is a bit contrived, as it’d be easier just to use adjust() (actually, this is tested for in the tests), but the point remains.

Why would you want this? Consider fivethirtyeight’s NFL Elo model, which uses a custom Elo update.

Final Thoughts

Elo is great, but is it the best ranking/rating system? The third vignette discusses alternatives implemented in the elo package.