For this assignment, I continued working with the chess tournament data from Project 1. I used each player’s pre-tournament rating and their individual opponents’ ratings to calculate expected tournament scores using the Elo expected-score formula.
I imported the original tournament data into R, extracted the opponents and results for each round, and matched each player with their pre-tournament rating. I then calculated the expected score for every played game and added these values to obtain each player’s total expected score.
Next, I compared each player’s expected score with their actual score and calculated the difference. Finally, I identified the five players who most overperformed and the five who most underperformed, presenting the results in tables and a visualization.
I used the original chess tournament data from Project 1. The original text file contains each player’s pre-tournament rating, tournament score, and opponents for each round. I also imported my cleaned dataset to verify the player information.
# Import the cleaned tournament data
chess_data <- read.csv("chess_tournament_clean.csv")
# Read the original tournament file
tournament_lines <- readLines("tournamentinfo.txt")## Warning in readLines("tournamentinfo.txt"): incomplete final line found on
## 'tournamentinfo.txt'
## Player_Name State Total_Points Pre_Rating Avg_Opponent_Rating
## 1 GARY HUA ON 6.0 1794 1605
## 2 DAKSHESH DARURI MI 6.0 1553 1469
## 3 ADITYA BAJAJ MI 6.0 1384 1564
## 4 PATRICK H SCHILLING MI 5.5 1716 1574
## 5 HANSHI ZUO MI 5.5 1655 1501
## 6 HANSEN SONG OH 5.0 1686 1519
## [1] "Player_Name" "State" "Total_Points"
## [4] "Pre_Rating" "Avg_Opponent_Rating"
## [1] 64 5
## -----------------------------------------------------------------------------------------
## Pair | Player Name |Total|Round|Round|Round|Round|Round|Round|Round|
## Num | USCF ID / Rtg (Pre->Post) | Pts | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
## -----------------------------------------------------------------------------------------
## 1 | GARY HUA |6.0 |W 39|W 21|W 18|W 14|W 7|D 12|D 4|
## ON | 15445895 / R: 1794 ->1817 |N:2 |W |B |W |B |W |B |W |
## -----------------------------------------------------------------------------------------
## 2 | DAKSHESH DARURI |6.0 |W 63|W 58|L 4|W 17|W 16|W 20|W 7|
## MI | 14598900 / R: 1553 ->1663 |N:2 |B |W |B |W |B |W |B |
## -----------------------------------------------------------------------------------------
## 3 | ADITYA BAJAJ |6.0 |L 8|W 61|W 25|W 21|W 11|W 13|W 12|
## MI | 14959604 / R: 1384 ->1640 |N:2 |W |B |W |B |W |B |W |
## -----------------------------------------------------------------------------------------
## 4 | PATRICK H SCHILLING |5.5 |W 23|D 28|W 2|W 26|D 5|W 19|D 1|
## MI | 12616049 / R: 1716 ->1744 |N:2 |W |B |W |B |W |B |B |
I extracted each player’s opponents and round results from the original tournament file. These individual results allowed me to calculate an expected score for every played game using the Elo formula.
# Identify player result lines (the lines beginning with a player number)
player_lines <- tournament_lines[
grepl("^\\s*[0-9]+\\s*\\|", tournament_lines)
]
# Extract the seven round-result fields for each player
round_data <- lapply(player_lines, function(line) {
fields <- strsplit(line, "\\|")[[1]]
fields <- trimws(fields)
data.frame(
Player_ID = as.integer(fields[1]),
Round = 1:7,
Result = fields[4:10]
)
})
round_data <- bind_rows(round_data)
# Separate game result (W, L, D) from opponent number
round_data <- round_data %>%
mutate(
Game_Result = str_extract(Result, "^[WLD]"),
Opponent_ID = as.integer(str_extract(Result, "[0-9]+$")),
Actual_Game_Score = case_when(
Game_Result == "W" ~ 1,
Game_Result == "D" ~ 0.5,
Game_Result == "L" ~ 0,
TRUE ~ NA_real_
)
)
# Inspect the first player's seven rounds
print(round_data %>% filter(Player_ID == 1))## Player_ID Round Result Game_Result Opponent_ID Actual_Game_Score
## 1 1 1 W 39 W 39 1.0
## 2 1 2 W 21 W 21 1.0
## 3 1 3 W 18 W 18 1.0
## 4 1 4 W 14 W 14 1.0
## 5 1 5 W 7 W 7 1.0
## 6 1 6 D 12 D 12 0.5
## 7 1 7 D 4 D 4 0.5
## [1] 64
## [1] 448
## Player_ID Round Result Game_Result Opponent_ID Actual_Game_Score
## 1 12 5 H <NA> NA NA
## 2 16 3 H <NA> NA NA
## 3 16 7 U <NA> NA NA
## 4 22 5 H <NA> NA NA
## 5 27 7 U <NA> NA NA
## 6 29 7 U <NA> NA NA
## 7 36 5 H <NA> NA NA
## 8 37 1 B <NA> NA NA
## 9 37 5 H <NA> NA NA
## 10 38 5 H <NA> NA NA
## 11 41 5 X <NA> NA NA
## 12 41 6 U <NA> NA NA
## 13 41 7 U <NA> NA NA
## 14 44 1 B <NA> NA NA
## 15 48 3 H <NA> NA NA
## 16 48 5 H <NA> NA NA
## 17 49 6 H <NA> NA NA
## 18 49 7 U <NA> NA NA
## 19 50 5 H <NA> NA NA
## 20 53 1 H <NA> NA NA
## 21 53 3 H <NA> NA NA
## 22 53 5 U <NA> NA NA
## 23 53 7 U <NA> NA NA
## 24 54 4 B <NA> NA NA
## 25 55 5 B <NA> NA NA
## 26 56 1 H <NA> NA NA
## 27 56 5 H <NA> NA NA
## 28 57 7 B <NA> NA NA
## 29 58 6 B <NA> NA NA
## 30 59 2 B <NA> NA NA
## 31 60 6 H <NA> NA NA
## 32 60 7 U <NA> NA NA
## 33 62 2 U <NA> NA NA
## 34 62 3 U <NA> NA NA
## 35 62 4 U <NA> NA NA
## 36 62 5 U <NA> NA NA
## 37 62 6 U <NA> NA NA
## 38 62 7 U <NA> NA NA
## 39 63 6 H <NA> NA NA
## 40 63 7 U <NA> NA NA
I matched each player and their opponents to their pre-tournament ratings. I checked that the player names were in the same order in the original and cleaned datasets before assigning player numbers.
# Extract player names from the original tournament file
original_players <- data.frame(
Player_ID = as.integer(
trimws(sapply(strsplit(player_lines, "\\|"), `[`, 1))
),
Original_Name = trimws(
sapply(strsplit(player_lines, "\\|"), `[`, 2)
)
)
# Compare original names with the cleaned CSV
name_check <- original_players %>%
mutate(
Cleaned_Name = chess_data$Player_Name,
Names_Match = toupper(trimws(Original_Name)) ==
toupper(trimws(Cleaned_Name))
)
print(name_check %>% filter(!Names_Match))## [1] Player_ID Original_Name Cleaned_Name Names_Match
## <0 rows> (or 0-length row.names)
# Stop if the player order does not match
stopifnot(all(name_check$Names_Match))
# Add player numbers to the cleaned dataset
players <- chess_data %>%
mutate(Player_ID = original_players$Player_ID)
# Attach each player's and opponent's rating
games <- round_data %>%
left_join(
players %>% select(Player_ID, Player_Name, Pre_Rating),
by = "Player_ID"
) %>%
left_join(
players %>%
select(
Opponent_ID = Player_ID,
Opponent_Rating = Pre_Rating
),
by = "Opponent_ID"
)
# Inspect Gary Hua's games
print(games %>% filter(Player_ID == 1))## Player_ID Round Result Game_Result Opponent_ID Actual_Game_Score Player_Name
## 1 1 1 W 39 W 39 1.0 GARY HUA
## 2 1 2 W 21 W 21 1.0 GARY HUA
## 3 1 3 W 18 W 18 1.0 GARY HUA
## 4 1 4 W 14 W 14 1.0 GARY HUA
## 5 1 5 W 7 W 7 1.0 GARY HUA
## 6 1 6 D 12 D 12 0.5 GARY HUA
## 7 1 7 D 4 D 4 0.5 GARY HUA
## Pre_Rating Opponent_Rating
## 1 1794 1436
## 2 1794 1563
## 3 1794 1600
## 4 1794 1610
## 5 1794 1649
## 6 1794 1663
## 7 1794 1716
The expected score for player A against player B is:
\[ E_A = \frac{1}{1 + 10^{(R_B-R_A)/400}} \]
Here, \(R_A\) is the player’s pre-tournament rating, \(R_B\) is the opponent’s pre-tournament rating, and \(E_A\) is the expected score for that game.
I used this formula to calculate the expected score for each played game, then summed the results for each player.
I calculated each player’s expected score against every opponent using the Elo expected-score formula. I then added the expected scores across all played games and compared them with the points the player actually earned in those games. Byes, forfeits, and unplayed rounds were excluded because they do not have an opponent rating.
# Calculate expected scores for games with an opponent
elo_games <- games %>%
filter(
!is.na(Opponent_ID),
!is.na(Pre_Rating),
!is.na(Opponent_Rating),
!is.na(Actual_Game_Score)
) %>%
mutate(
Expected_Game_Score = 1 /
(1 + 10^((Opponent_Rating - Pre_Rating) / 400))
)
# Add expected and actual scores for each player
elo_results <- elo_games %>%
group_by(Player_ID, Player_Name, Pre_Rating) %>%
summarise(
Games_Played = n(),
Expected_Score = sum(Expected_Game_Score),
Actual_Score = sum(Actual_Game_Score),
.groups = "drop"
) %>%
mutate(
Performance_Difference = Actual_Score - Expected_Score
) %>%
arrange(desc(Performance_Difference))
# Display Gary Hua's individual games
print(
elo_games %>%
filter(Player_ID == 1) %>%
select(
Round,
Opponent_ID,
Opponent_Rating,
Actual_Game_Score,
Expected_Game_Score
)
)## Round Opponent_ID Opponent_Rating Actual_Game_Score Expected_Game_Score
## 1 1 39 1436 1.0 0.8870357
## 2 2 21 1563 1.0 0.7907981
## 3 3 18 1600 1.0 0.7533861
## 4 4 14 1610 1.0 0.7425356
## 5 5 7 1649 1.0 0.6973451
## 6 6 12 1663 0.5 0.6800707
## 7 7 4 1716 0.5 0.6104024
## # A tibble: 1 × 7
## Player_ID Player_Name Pre_Rating Games_Played Expected_Score Actual_Score
## <int> <chr> <int> <int> <dbl> <dbl>
## 1 1 GARY HUA 1794 7 5.16 6
## # ℹ 1 more variable: Performance_Difference <dbl>
# Validate the number of records and players
stopifnot(nrow(elo_games) == 408)
stopifnot(nrow(elo_results) == 64)
cat("Elo calculations validated successfully.\n")## Elo calculations validated successfully.
The dataset contained 64 players and seven rounds, producing 448 player-round records. Of these, 40 did not have an identified opponent. I excluded those records from the Elo calculations, leaving 408 player-game records representing 204 played games.
The Elo rating system estimates a player’s expected score using the difference between two players’ ratings. A player rated 400 points higher than an opponent has expected-score odds of 10 to 1. A win is worth one point, a draw is worth half a point, and a loss is worth zero points. I used the expected-score formula explained in A Comprehensive Guide to Chess Ratings by Mark E. Glickman to calculate each player’s expected performance.
I ranked the players by subtracting their expected scores from their actual scores. A positive difference indicates that a player performed better than expected, while a negative difference indicates that they performed worse than expected.
# Round values for presentation without changing the original calculations
ranked_players <- elo_results %>%
mutate(
Expected_Score = round(Expected_Score, 2),
Actual_Score = round(Actual_Score, 2),
Performance_Difference = round(Performance_Difference, 2)
)
# Five players who performed most above expectations
top_overperformers <- ranked_players %>%
arrange(desc(Performance_Difference)) %>%
slice_head(n = 5)
# Five players who performed most below expectations
top_underperformers <- ranked_players %>%
arrange(Performance_Difference) %>%
slice_head(n = 5)
print(
knitr::kable(
top_overperformers %>%
select(
Player_Name,
Pre_Rating,
Games_Played,
Expected_Score,
Actual_Score,
Performance_Difference
),
caption = "Five Most Overperforming Players"
)
)| Player_Name | Pre_Rating | Games_Played | Expected_Score | Actual_Score | Performance_Difference |
|---|---|---|---|---|---|
| ADITYA BAJAJ | 1384 | 7 | 1.95 | 6.0 | 4.05 |
| ZACHARY JAMES HOUGHTON | 1220 | 7 | 1.37 | 4.5 | 3.13 |
| ANVIT RAO | 1365 | 7 | 1.94 | 5.0 | 3.06 |
| JACOB ALEXANDER LAVALLEY | 377 | 7 | 0.04 | 3.0 | 2.96 |
| STEFANO LEE | 1411 | 7 | 2.29 | 5.0 | 2.71 |
print(
knitr::kable(
top_underperformers %>%
select(
Player_Name,
Pre_Rating,
Games_Played,
Expected_Score,
Actual_Score,
Performance_Difference
),
caption = "Five Most Underperforming Players"
)
)| Player_Name | Pre_Rating | Games_Played | Expected_Score | Actual_Score | Performance_Difference |
|---|---|---|---|---|---|
| LOREN SCHWIEBERT | 1745 | 7 | 6.28 | 3.5 | -2.78 |
| GEORGE AVERY JONES | 1522 | 7 | 6.02 | 3.5 | -2.52 |
| LARRY HODGE | 1270 | 6 | 3.40 | 1.0 | -2.40 |
| JARED GE | 1332 | 7 | 5.01 | 3.0 | -2.01 |
| RISHI SHETTY | 1494 | 7 | 5.09 | 3.5 | -1.59 |
The following chart compares the players who performed furthest above and below their expected scores.
performance_chart <- bind_rows(
top_overperformers %>% mutate(Group = "Overperformed"),
top_underperformers %>% mutate(Group = "Underperformed")
) %>%
mutate(
Player_Label = paste(Player_Name, "(", Player_ID, ")")
)
ggplot(
performance_chart,
aes(
x = reorder(Player_Label, Performance_Difference),
y = Performance_Difference,
fill = Group
)
) +
geom_col() +
geom_hline(yintercept = 0, color = "gray40") +
coord_flip() +
labs(
title = "Chess Tournament: Actual vs. Expected Performance",
x = "Player",
y = "Actual Score − Expected Score",
fill = "Performance"
) +
theme_minimal()I wanted to investigate whether players with higher pre-tournament ratings were more likely to perform above expectations. To explore this relationship, I compared each player’s pre-tournament rating with the difference between their actual and expected scores.
A positive performance difference indicates that a player exceeded expectations, while a negative difference indicates that they performed below expectations.
ggplot(
elo_results,
aes(
x = Pre_Rating,
y = Performance_Difference
)
) +
geom_point(
color = "steelblue",
size = 3,
alpha = 0.7
) +
geom_smooth(
method = "lm",
se = TRUE,
color = "red"
) +
geom_hline(
yintercept = 0,
linetype = "dashed"
) +
labs(
title = "Pre-Tournament Rating vs. Performance",
subtitle = "Comparison of all 64 chess players",
x = "Pre-Tournament Rating",
y = "Actual Score - Expected Score"
) +
theme_minimal()## `geom_smooth()` using formula = 'y ~ x'
correlation <- cor(
elo_results$Pre_Rating,
elo_results$Performance_Difference,
use = "complete.obs"
)
cat(
"Correlation between pre-tournament rating and performance:",
round(correlation, 3)
)## Correlation between pre-tournament rating and performance: -0.325
The correlation between pre-tournament rating and performance difference was −0.325, indicating a weak-to-moderate negative relationship.
The scatter plot shows a downward trend, suggesting that lower-rated players generally performed better relative to their expected scores, while higher-rated players were somewhat more likely to perform below expectations.
However, the points are widely scattered, indicating that pre-tournament rating alone does not strongly predict whether a player will exceed expectations. This relationship describes the 64 players in this tournament and does not necessarily apply to other tournaments.
Aditya Bajaj had the largest positive difference between his actual and expected scores. Zachary James Houghton, Anvit Rao, Jacob Alexander Lavalley, and Stefano Lee were the other four highest overperformers.
These players earned more points than predicted by their pre-tournament ratings and the ratings of their opponents.
Loren Schwiebert had the largest negative performance difference. His expected score was 6.28 points, but he earned only 3.50 points in played games, a difference of −2.78 points.
George Avery Jones was next, with a difference of −2.52 points, followed by Larry Hodge (−2.40), Jared Ge (−2.01), and Rishi Shetty (−1.59).
The Elo expected-score formula estimates a player’s expected performance based on the difference between their rating and their opponent’s rating. It does not guarantee the outcome of an individual game.
For this analysis, I excluded byes, forfeits, and unplayed rounds from both expected and actual scores. This allowed me to compare the points earned in played games with the points predicted for those same games.
Using the Elo expected-score formula, I calculated expected scores for all 64 players in the chess tournament and compared them with their actual results. I identified the five players who most exceeded expectations and the five players who fell furthest below expectations.
I also examined the relationship between pre-tournament rating and performance difference. The correlation was −0.325, indicating a weak-to-moderate negative relationship. In this tournament, lower-rated players tended to perform better relative to their expected scores, although the relationship was not strong.
Overall, this analysis demonstrated how Elo ratings can estimate tournament performance and how actual results can differ from those expectations.
Glickman, M. E. A Comprehensive Guide to Chess Ratings. https://www.glicko.net/research/acjpaper.pdf