5B ELO Calculations

Author

Madina Kudanova

Introduction

The purpose of this assignment is to evaluate player performance in the Project 1 chess tournament using the Elo rating model. Based on the rating differences between each player and their opponents, the goal is to calculate each player’s expected tournament score and compare it to their actual score. The assignment then requires identifying the five players who most overperformed and the five who most underperformed relative to Elo expectations.

Approach

My approach to this assignment is to first use the pre-tournament ratings from the Project 1 dataset, along with the list of opponents each player faced. Since the Elo expected score depends only on rating differences, these pre-ratings are sufficient for the calculation.

For each game, I compute the expected score using the standard Elo formula:

Expected Score = 1 / (1 + 10^((opponent_rating – player_rating) / 400))

This formula follows the standard Elo rating model (Elo, 1978).

Next, I sum the expected scores across all games to obtain each player’s total expected tournament score. All calculations use pre-tournament ratings only, and ratings are not updated between rounds.

Finally, I calculate the performance difference by subtracting each player’s expected score from their actual tournament score. Players are then ranked based on this difference to identify the five largest overperformers and the five largest underperformers relative to Elo expectations.

Code Base

library(tidyverse)

Load and Prepare Project 1 Players Data

# Load the original tournament cross-table text file from Project 1.
# This file contains the round-by-round opponent IDs needed to compute
# expected scores using the Elo formula.
players <- read_csv("https://raw.githubusercontent.com/MKudanova/Data607/refs/heads/main/Project%201/chess_players.csv")
Rows: 64 Columns: 6
── Column specification ────────────────────────────────────────────────────────
Delimiter: ","
chr (2): PlayerName, State
dbl (4): PairNum, TotalPoints, PreRating, AvgOppPreRating

ℹ Use `spec()` to retrieve the full column specification for this data.
ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
glimpse(players)
Rows: 64
Columns: 6
$ PairNum         <dbl> 1, 3, 2, 4, 5, 10, 9, 6, 8, 7, 14, 12, 13, 15, 11, 17,…
$ PlayerName      <chr> "GARY HUA", "ADITYA BAJAJ", "DAKSHESH DARURI", "PATRIC…
$ State           <chr> "ON", "MI", "MI", "MI", "MI", "MI", "ON", "OH", "MI", …
$ TotalPoints     <dbl> 6.0, 6.0, 6.0, 5.5, 5.5, 5.0, 5.0, 5.0, 5.0, 5.0, 4.5,…
$ PreRating       <dbl> 1794, 1384, 1553, 1716, 1655, 1365, 1411, 1686, 1641, …
$ AvgOppPreRating <dbl> 1605, 1564, 1469, 1574, 1501, 1554, 1523, 1519, 1468, …
# Load tournament text file to extract opponent IDs per round
tournament_raw <- read_lines("https://raw.githubusercontent.com/MKudanova/Data607/refs/heads/main/Project%201/tournamentinfo.txt")

length(tournament_raw)
[1] 196
head(tournament_raw, 30)
 [1] "-----------------------------------------------------------------------------------------" 
 [2] " Pair | Player Name                     |Total|Round|Round|Round|Round|Round|Round|Round| "
 [3] " Num  | USCF ID / Rtg (Pre->Post)       | Pts |  1  |  2  |  3  |  4  |  5  |  6  |  7  | "
 [4] "-----------------------------------------------------------------------------------------" 
 [5] "    1 | GARY HUA                        |6.0  |W  39|W  21|W  18|W  14|W   7|D  12|D   4|" 
 [6] "   ON | 15445895 / R: 1794   ->1817     |N:2  |W    |B    |W    |B    |W    |B    |W    |" 
 [7] "-----------------------------------------------------------------------------------------" 
 [8] "    2 | DAKSHESH DARURI                 |6.0  |W  63|W  58|L   4|W  17|W  16|W  20|W   7|" 
 [9] "   MI | 14598900 / R: 1553   ->1663     |N:2  |B    |W    |B    |W    |B    |W    |B    |" 
[10] "-----------------------------------------------------------------------------------------" 
[11] "    3 | ADITYA BAJAJ                    |6.0  |L   8|W  61|W  25|W  21|W  11|W  13|W  12|" 
[12] "   MI | 14959604 / R: 1384   ->1640     |N:2  |W    |B    |W    |B    |W    |B    |W    |" 
[13] "-----------------------------------------------------------------------------------------" 
[14] "    4 | PATRICK H SCHILLING             |5.5  |W  23|D  28|W   2|W  26|D   5|W  19|D   1|" 
[15] "   MI | 12616049 / R: 1716   ->1744     |N:2  |W    |B    |W    |B    |W    |B    |B    |" 
[16] "-----------------------------------------------------------------------------------------" 
[17] "    5 | HANSHI ZUO                      |5.5  |W  45|W  37|D  12|D  13|D   4|W  14|W  17|" 
[18] "   MI | 14601533 / R: 1655   ->1690     |N:2  |B    |W    |B    |W    |B    |W    |B    |" 
[19] "-----------------------------------------------------------------------------------------" 
[20] "    6 | HANSEN SONG                     |5.0  |W  34|D  29|L  11|W  35|D  10|W  27|W  21|" 
[21] "   OH | 15055204 / R: 1686   ->1687     |N:3  |W    |B    |W    |B    |B    |W    |B    |" 
[22] "-----------------------------------------------------------------------------------------" 
[23] "    7 | GARY DEE SWATHELL               |5.0  |W  57|W  46|W  13|W  11|L   1|W   9|L   2|" 
[24] "   MI | 11146376 / R: 1649   ->1673     |N:3  |W    |B    |W    |B    |B    |W    |W    |" 
[25] "-----------------------------------------------------------------------------------------" 
[26] "    8 | EZEKIEL HOUGHTON                |5.0  |W   3|W  32|L  14|L   9|W  47|W  28|W  19|" 
[27] "   MI | 15142253 / R: 1641P17->1657P24  |N:3  |B    |W    |B    |W    |B    |W    |W    |" 
[28] "-----------------------------------------------------------------------------------------" 
[29] "    9 | STEFANO LEE                     |5.0  |W  25|L  18|W  59|W   8|W  26|L   7|W  20|" 
[30] "   ON | 14954524 / R: 1411   ->1564     |N:2  |W    |B    |W    |B    |W    |B    |B    |" 
# Display the first 20 lines of the tournament file to inspect its structure
tournament_raw[1:20]
 [1] "-----------------------------------------------------------------------------------------" 
 [2] " Pair | Player Name                     |Total|Round|Round|Round|Round|Round|Round|Round| "
 [3] " Num  | USCF ID / Rtg (Pre->Post)       | Pts |  1  |  2  |  3  |  4  |  5  |  6  |  7  | "
 [4] "-----------------------------------------------------------------------------------------" 
 [5] "    1 | GARY HUA                        |6.0  |W  39|W  21|W  18|W  14|W   7|D  12|D   4|" 
 [6] "   ON | 15445895 / R: 1794   ->1817     |N:2  |W    |B    |W    |B    |W    |B    |W    |" 
 [7] "-----------------------------------------------------------------------------------------" 
 [8] "    2 | DAKSHESH DARURI                 |6.0  |W  63|W  58|L   4|W  17|W  16|W  20|W   7|" 
 [9] "   MI | 14598900 / R: 1553   ->1663     |N:2  |B    |W    |B    |W    |B    |W    |B    |" 
[10] "-----------------------------------------------------------------------------------------" 
[11] "    3 | ADITYA BAJAJ                    |6.0  |L   8|W  61|W  25|W  21|W  11|W  13|W  12|" 
[12] "   MI | 14959604 / R: 1384   ->1640     |N:2  |W    |B    |W    |B    |W    |B    |W    |" 
[13] "-----------------------------------------------------------------------------------------" 
[14] "    4 | PATRICK H SCHILLING             |5.5  |W  23|D  28|W   2|W  26|D   5|W  19|D   1|" 
[15] "   MI | 12616049 / R: 1716   ->1744     |N:2  |W    |B    |W    |B    |W    |B    |B    |" 
[16] "-----------------------------------------------------------------------------------------" 
[17] "    5 | HANSHI ZUO                      |5.5  |W  45|W  37|D  12|D  13|D   4|W  14|W  17|" 
[18] "   MI | 14601533 / R: 1655   ->1690     |N:2  |B    |W    |B    |W    |B    |W    |B    |" 
[19] "-----------------------------------------------------------------------------------------" 
[20] "    6 | HANSEN SONG                     |5.0  |W  34|D  29|L  11|W  35|D  10|W  27|W  21|" 

Extract Opponent Data and Build Game-Level Dataset (from .txt file)

# Use regex to filter player lines and prepare for opponent ID extraction.
# Keep only lines that begin with a player number
player_lines <- tournament_raw %>%
  str_subset("^\\s*\\d+\\s+\\|")

length(player_lines)
[1] 64
head(player_lines, 5)
[1] "    1 | GARY HUA                        |6.0  |W  39|W  21|W  18|W  14|W   7|D  12|D   4|"
[2] "    2 | DAKSHESH DARURI                 |6.0  |W  63|W  58|L   4|W  17|W  16|W  20|W   7|"
[3] "    3 | ADITYA BAJAJ                    |6.0  |L   8|W  61|W  25|W  21|W  11|W  13|W  12|"
[4] "    4 | PATRICK H SCHILLING             |5.5  |W  23|D  28|W   2|W  26|D   5|W  19|D   1|"
[5] "    5 | HANSHI ZUO                      |5.5  |W  45|W  37|D  12|D  13|D   4|W  14|W  17|"
# Build Game-Level Dataset from Tournament Text File
# ------------------------------------------------------------

# 1) Identify the starting line of each player record.
# These are lines that begin with: whitespace + player number + |
idx <- which(str_detect(tournament_raw, "^\\s*\\d+\\s+\\|"))

# 2) Determine where each player record ends.
# Each record ends right before the next player record begins.
idx_end <- c(idx[-1] - 1, length(tournament_raw))

# 3) Collapse all wrapped lines for each player into one full string.
# Some player records span multiple physical lines in the text file.
player_lines_full <- map2_chr(
  idx,
  idx_end,
  ~ str_c(tournament_raw[.x:.y], collapse = " ")
)

# 4) Extract Player ID (PairNum) from the beginning of each full record.
pair_ids <- str_extract(player_lines_full, "^\\s*\\d+") %>%
  as.integer()

# 5) Extract opponent IDs for each round.
# Pattern explanation:
#   \\|        → literal pipe symbol
#   [WLD]      → match result letter (Win, Loss, Draw)
#   \\s*       → optional spaces
#   (\\d+)     → capture opponent number
opponents_list <- str_match_all(
  player_lines_full,
  "\\|[WLD]\\s*(\\d+)"
) %>%
  map(~ .x[,2])   # Extract only the captured opponent number

# 6) Count half-point byes ("H") for each player.
# A bye has no opponent number, so we must account for it separately.
h_df <- tibble(
  PairNum = pair_ids,
  h_rounds = str_count(player_lines_full, "\\|H\\s*\\|")
)

# 7) Create the game-level dataset.
# Each row represents one actual game with an opponent.
games <- tibble(
  PairNum = pair_ids,
  OppPairNum = opponents_list
) %>%
  unnest(OppPairNum) %>%
  mutate(OppPairNum = as.integer(OppPairNum))

# inSanity checks 
nrow(games)                  # should be 408 (actual played games only)
[1] 408
table(lengths(opponents_list))  # shows distribution of games per player

 1  3  4  5  6  7 
 1  1  1  7 13 41 

Merge Player Ratings with Game Records

#Attach Ratings to Each Game

# A small lookup table (clean and prevents accidental duplicate columns)
ratings_lookup <- players %>%
  select(PairNum, PlayerName, PreRating, TotalPoints)

# Join player rating, then opponent rating
games_rated <- games %>%
  left_join(ratings_lookup %>% select(PairNum, PreRating),
            by = "PairNum") %>%
  rename(player_rating = PreRating) %>%
  left_join(ratings_lookup %>% select(PairNum, PreRating),
            by = c("OppPairNum" = "PairNum")) %>%
  rename(opponent_rating = PreRating)

head(games_rated)
# A tibble: 6 × 4
  PairNum OppPairNum player_rating opponent_rating
    <dbl>      <dbl>         <dbl>           <dbl>
1       1         39          1794            1436
2       1         21          1794            1563
3       1         18          1794            1600
4       1         14          1794            1610
5       1          7          1794            1649
6       1         12          1794            1663
nrow(games_rated)                       # should be 408
[1] 408
sum(is.na(games_rated$opponent_rating)) # should be 0
[1] 0

Compute Elo Expected Score per Game

# Compute Expected Score Per Game

games_expected <- games_rated %>%
  mutate(expected_game =
           1 / (1 + 10 ^ ((opponent_rating - player_rating) / 400)))

Aggregate Expected Scores

# Aggregate Expected Scores

expected_totals <- games_expected %>%
  group_by(PairNum) %>%
  summarise(expected_total = sum(expected_game), .groups = "drop")

Compare Expected vs Actual and Rank

# ------------------------------------------------------------
# Compute Total Expected Score per Player (from scratch)
# ------------------------------------------------------------

expected_totals <- games_expected %>%
  group_by(PairNum) %>%
  summarise(expected_total = sum(expected_game), .groups = "drop") %>%
  mutate(PairNum = as.numeric(PairNum))

# Add 0.5 expected points for each half-point bye ("H") round
expected_totals <- expected_totals %>%
  left_join(h_df %>% mutate(PairNum = as.numeric(PairNum)), by = "PairNum") %>%
  mutate(
    h_rounds = replace_na(h_rounds, 0),
    expected_total = expected_total + 0.5 * h_rounds
  ) %>%
  select(PairNum, expected_total)

# ------------------------------------------------------------
# Compare Expected vs Actual and Rank Players
# ------------------------------------------------------------

results <- ratings_lookup %>%
  mutate(PairNum = as.numeric(PairNum)) %>%
  select(PairNum, PlayerName, TotalPoints) %>%
  left_join(expected_totals, by = "PairNum") %>%
  mutate(diff = TotalPoints - expected_total) %>%
  arrange(desc(diff))


top5_over  <- results %>% slice_head(n = 5) %>%
  select(PairNum, PlayerName, TotalPoints, expected_total, diff)

top5_under <- results %>% arrange(diff) %>% slice_head(n = 5) %>%
  select(PairNum, PlayerName, TotalPoints, expected_total, diff)

top5_over
# A tibble: 5 × 5
  PairNum PlayerName               TotalPoints expected_total  diff
    <dbl> <chr>                          <dbl>          <dbl> <dbl>
1       3 ADITYA BAJAJ                     6           1.95    4.05
2      15 ZACHARY JAMES HOUGHTON           4.5         1.37    3.13
3      10 ANVIT RAO                        5           1.94    3.06
4      46 JACOB ALEXANDER LAVALLEY         3           0.0432  2.96
5       9 STEFANO LEE                      5           2.29    2.71
top5_under
# A tibble: 5 × 5
  PairNum PlayerName         TotalPoints expected_total  diff
    <dbl> <chr>                    <dbl>          <dbl> <dbl>
1      25 LOREN SCHWIEBERT           3.5           6.28 -2.78
2      30 GEORGE AVERY JONES         3.5           6.02 -2.52
3      42 JARED GE                   3             5.01 -2.01
4      31 RISHI SHETTY               3.5           5.09 -1.59
5      35 JOSHUA DAVID LEE           3.5           4.96 -1.46
# ------------------------------------------------------------
#  LAST Sanity Checks
# ------------------------------------------------------------
nrow(games)                              # 408 played games (byes not games)
[1] 408
sum(is.na(results$expected_total))       # should be 0
[1] 0
summary(results$expected_total)          # should be roughly 0–7
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
0.04325 2.04008 3.57679 3.31250 4.60130 6.27565 
table(h_df$h_rounds)                     # bye counts

 0  1  2 
51 10  3 

Conclusion

This analysis applied the classical Elo expected score model (Elo, 1978) to evaluate tournament performance relative to pre-tournament ratings. For each game, the expected score was computed using the standard Elo formula, and total expected scores were obtained by summing game-level expectations and adding 0.5 for each half-point bye. Comparing actual tournament points to expected totals allowed for a quantitative assessment of over- and underperformance.

The results reveal substantial variation between projected and observed outcomes. The largest positive differentials exceeded +4 points. For example, Aditya Bajaj earned 6.0 points while his expected total was approximately 1.95, producing a differential of roughly +4.05. This indicates performance far above probabilistic expectation based on rating. Such a deviation suggests either that the player’s pre-tournament rating underestimated their true strength or that they experienced exceptional tournament form.

Conversely, several higher-rated players underperformed relative to expectation. For instance, players with expected totals above 6 points in some cases scored closer to 3–3.5 points, producing negative differentials approaching −3 points. These outcomes demonstrate that Elo ratings, while predictive on average, do not guarantee performance in short tournaments.

The distribution of expected totals ranged from approximately 0.04 to 6.28, reflecting the rating hierarchy within the field. Higher-rated players were statistically projected to earn most of the available points, while lower-rated players were projected to score substantially fewer. The observed deviations from expectation illustrate the probabilistic nature of the Elo system: ratings provide expected values over repeated play, but individual tournament outcomes can vary meaningfully from those projections.

Overall, the analysis demonstrates how the Elo framework can quantify performance relative to expectation and identify players whose results diverged most significantly from rating-based predictions. While some deviations may reflect short-term variance, large differentials may also signal rating miscalibration or rapid changes in player strength.