Author: Ethan Heinzen

# 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.