Assignment 5B ELO Calculations

Author

Andre Thomson

Approach

For this assignment, I used the chess tournament data from Project 1. I calculated an expected score for every matchup using the two players’ pre-tournament ratings. I then added each player’s expected scores and compared the result with the player’s actual tournament points.

The difference is:

Actual points - Expected points

A positive number means the player performed above the Elo expectation. A negative number means the player performed below it.

Load packages and data

Show code
library(tidyverse)
library(knitr)

data_url <- paste0(
  "https://raw.githubusercontent.com/longflin/DATA-607/",
  "refs/heads/main/Assignment%205B/tournamentinfo.txt"
)

raw_lines <- readLines(data_url, warn = FALSE)

I used the same tournament source structure as Project 1. Each player has one line with the name, points, and round results, followed by a second line with the state and pre-rating.

Parse the player records

Show code
clean_lines <- raw_lines[
  !grepl("^-+$", raw_lines) &
  nzchar(trimws(raw_lines))
]

# Remove the two header rows after the separator rows are removed.
data_lines <- clean_lines[3:length(clean_lines)]

name_rows <- data_lines[seq(1, length(data_lines), by = 2)]
info_rows <- data_lines[seq(2, length(data_lines), by = 2)]

names_df <- tibble(
  pair_num = as.numeric(str_sub(name_rows, 1, 5)),
  player_name = str_squish(str_sub(name_rows, 9, 40)),
  total_pts = as.numeric(str_sub(name_rows, 42, 44))
)

info_df <- tibble(
  state = str_squish(str_sub(info_rows, 1, 5)),
  pre_rating = as.numeric(
    str_extract(info_rows, "(?<=R:)\\s*\\d+")
  )
)

tournament_df <- bind_cols(names_df, info_df)

stopifnot(nrow(tournament_df) == 64)
stopifnot(!anyDuplicated(tournament_df$pair_num))
stopifnot(!anyNA(tournament_df$pre_rating))

head(tournament_df)
# A tibble: 6 × 5
  pair_num player_name         total_pts state pre_rating
     <dbl> <chr>                   <dbl> <chr>      <dbl>
1        1 GARY HUA                  6   ON          1794
2        2 DAKSHESH DARURI           6   MI          1553
3        3 ADITYA BAJAJ              6   MI          1384
4        4 PATRICK H SCHILLING       5.5 MI          1716
5        5 HANSHI ZUO                5.5 MI          1655
6        6 HANSEN SONG               5   OH          1686

These checks make sure I parsed 64 players, did not duplicate a player number, and did not lose a pre-rating.

Extract each player’s opponents

Show code
round_text <- str_sub(name_rows, 46)

opponents_list <- str_extract_all(
  round_text,
  "(?<=W\\s|L\\s|D\\s)\\s*\\d+"
)

opponents_list <- map(
  opponents_list,
  ~ as.numeric(str_trim(.x))
)

opponents_df <- tibble(
  pair_num = rep(tournament_df$pair_num, lengths(opponents_list)),
  opponent_id = as.numeric(unlist(opponents_list))
) |>
  left_join(
    tournament_df |>
      select(opponent_id = pair_num,
             opponent_pre_rating = pre_rating),
    by = "opponent_id"
  ) |>
  left_join(
    tournament_df |>
      select(pair_num, player_name, pre_rating),
    by = "pair_num"
  )

stopifnot(!anyNA(opponents_df$opponent_pre_rating))

head(opponents_df)
# A tibble: 6 × 5
  pair_num opponent_id opponent_pre_rating player_name pre_rating
     <dbl>       <dbl>               <dbl> <chr>            <dbl>
1        1          39                1436 GARY HUA          1794
2        1          21                1563 GARY HUA          1794
3        1          18                1600 GARY HUA          1794
4        1          14                1610 GARY HUA          1794
5        1           7                1649 GARY HUA          1794
6        1          12                1663 GARY HUA          1794

The round results contain W, L, or D followed by the opponent’s player number. I extracted those player numbers and matched them back to the opponent’s pre-rating.

Calculate the expected score

The course reference video uses the logistic Elo expectation:

[ E = \frac{1}{1 + 10^{(R_B-R_A)/400}} ]

where (R_A) is the player’s rating and (R_B) is the opponent’s rating.

Show code
matches_df <- opponents_df |>
  mutate(
    expected_score =
      1 / (1 + 10^((opponent_pre_rating - pre_rating) / 400))
  )

summary(matches_df$expected_score)
     Min.   1st Qu.    Median      Mean   3rd Qu.      Max. 
0.0006603 0.2007600 0.5000000 0.5000000 0.7992400 0.9993397 

Every expected score should be between 0 and 1.

Show code
stopifnot(all(matches_df$expected_score > 0))
stopifnot(all(matches_df$expected_score < 1))

Expected tournament score

Show code
expected_points_df <- matches_df |>
  group_by(pair_num) |>
  summarise(
    expected_points = sum(expected_score),
    .groups = "drop"
  )

results <- tournament_df |>
  left_join(expected_points_df, by = "pair_num") |>
  mutate(
    difference = total_pts - expected_points
  )

head(results)
# A tibble: 6 × 7
  pair_num player_name     total_pts state pre_rating expected_points difference
     <dbl> <chr>               <dbl> <chr>      <dbl>           <dbl>      <dbl>
1        1 GARY HUA              6   ON          1794            5.16     0.838 
2        2 DAKSHESH DARURI       6   MI          1553            3.78     2.22  
3        3 ADITYA BAJAJ          6   MI          1384            1.95     4.05  
4        4 PATRICK H SCHI…       5.5 MI          1716            4.74     0.758 
5        5 HANSHI ZUO            5.5 MI          1655            4.38     1.12  
6        6 HANSEN SONG           5   OH          1686            4.94     0.0554

The expected tournament score is the sum of the expected scores from all of a player’s games.

Five players who overperformed the most

Show code
top5_over <- results |>
  arrange(desc(difference)) |>
  slice_head(n = 5) |>
  select(
    player_name,
    total_pts,
    pre_rating,
    expected_points,
    difference
  )

kable(
  top5_over,
  digits = 2,
  caption = "Five Players Who Most Overperformed"
)
Five Players Who Most Overperformed
player_name total_pts pre_rating expected_points difference
ADITYA BAJAJ 6.0 1384 1.95 4.05
ZACHARY JAMES HOUGHTON 4.5 1220 1.37 3.13
ANVIT RAO 5.0 1365 1.94 3.06
JACOB ALEXANDER LAVALLEY 3.0 377 0.04 2.96
AMIYATOSH PWNANANDAM 3.5 980 0.77 2.73

Five players who underperformed the most

Show code
top5_under <- results |>
  arrange(difference) |>
  slice_head(n = 5) |>
  select(
    player_name,
    total_pts,
    pre_rating,
    expected_points,
    difference
  )

kable(
  top5_under,
  digits = 2,
  caption = "Five Players Who Most Underperformed"
)
Five Players Who Most Underperformed
player_name total_pts pre_rating expected_points difference
LOREN SCHWIEBERT 3.5 1745 6.28 -2.78
GEORGE AVERY JONES 3.5 1522 6.02 -2.52
JARED GE 3.0 1332 5.01 -2.01
RISHI SHETTY 3.5 1494 5.09 -1.59
JOSHUA DAVID LEE 3.5 1438 4.96 -1.46

Performance chart

Show code
plot_data <- results |>
  mutate(
    performance_group = case_when(
      player_name %in% top5_over$player_name ~ "Top 5 Over",
      player_name %in% top5_under$player_name ~ "Top 5 Under",
      TRUE ~ "Other Players"
    )
  )

ggplot(
  plot_data,
  aes(
    x = expected_points,
    y = total_pts,
    color = performance_group
  )
) +
  geom_abline(
    slope = 1,
    intercept = 0,
    linetype = "dashed",
    color = "grey50"
  ) +
  geom_point(size = 2.5, alpha = 0.8) +
  labs(
    title = "Actual Points Compared with Expected Points",
    subtitle = "Points above the dashed line are above the Elo expectation",
    x = "Expected Points",
    y = "Actual Tournament Points",
    color = NULL
  ) +
  theme_minimal(base_size = 12)

I kept the chart simple. The dashed line represents equal actual and expected points. Players above the line scored more than expected, while players below it scored less than expected.

Gary Hua check

Show code
gary_games <- matches_df |>
  filter(player_name == "GARY HUA")

gary_expected <- sum(gary_games$expected_score)

gary_check <- results |>
  filter(player_name == "GARY HUA") |>
  select(
    player_name,
    total_pts,
    pre_rating,
    expected_points,
    difference
  )

gary_expected
[1] 5.161574
Show code
kable(gary_check, digits = 3)
player_name total_pts pre_rating expected_points difference
GARY HUA 6 1794 5.162 0.838

This gives me a separate check of Gary Hua’s expected score. I can compare the sum from the individual games with the value stored in the final results table.

Export results

Show code
write_csv(results, "assignment5b_elo_results.csv")
write_csv(top5_over, "assignment5b_top5_overperformers.csv")
write_csv(top5_under, "assignment5b_top5_underperformers.csv")

Conclusion

This assignment extends the Project 1 chess data by using the players’ ratings to estimate how many points they were expected to score. Comparing actual points with expected points makes it possible to identify players who performed above or below the Elo expectation.

The result should not be treated as a complete measure of player ability. It is a comparison based on the pre-tournament ratings and the opponents in this tournament.

AI Use

I used GitHub Copilot in Visual Studio Code to help review the R code, troubleshoot errors, and improve comments. I reviewed the suggestions and kept the code I understood. The calculations and final tables come from the R code and tournament data.

References

  • singingbanana. (2019, February 15). The Elo Rating System for Chess and Beyond [Video]. YouTube. https://www.youtube.com/watch?v=AsYfbmp0To0
  • Wickham, H., Çetinkaya-Rundel, M., & Grolemund, G. R for Data Science (2e). https://r4ds.hadley.nz/
  • R Graph Gallery. https://r-graph-gallery.com/