Introduction

This analysis builds on the chess tournament data from Project 1 to compare each player’s actual tournament score with the score that would be expected from the rating differences between that player and each opponent. The business question is: Which players performed most above or below what their pre-tournament ratings predicted? I use the standard Elo expected-score formula and compare expected points with actual points from the same played games. Official tournament totals are also reported, but unplayed/administrative rounds cannot be assigned an Elo expectation without an opponent.

For the expected-score calculation, I use the logistic Elo formula \(E_A = 1/(1 + 10^{(R_B-R_A)/400})\), where \(R_A\) is the player’s rating and \(R_B\) is the opponent’s rating. FIDE’s rating regulations similarly determine a scoring probability from the rating difference for each rated game: FIDE Rating Regulations. The logistic formula used here is also described in this Elo rating system reference.

Load Packages

I use tidyverse for importing, parsing, joining, reshaping, and summarizing the tournament data, and knitr for formatted tables.

library(tidyverse)
library(knitr)

options(dplyr.summarise.inform = FALSE)

Import the Tournament Data

To make the analysis reproducible, the tournament file is read directly from the public GitHub repository used for Project 1 rather than from a file stored on my computer.

data_url <- "https://raw.githubusercontent.com/chanicemcken/Data-607-Project-1/main/tournamentinfo.txt"

tournament_raw <- readLines(data_url, warn = FALSE)

head(tournament_raw, 10)
##  [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] "-----------------------------------------------------------------------------------------"

Parse the Player Records

The tournament file stores each player across two lines. The first line contains pair number, player name, total score, and seven round results. The second line contains state and rating information. I identify the 64 player rows, match each one to the following detail row, and extract the fields needed for the Elo analysis.

player_idx <- which(str_detect(
  tournament_raw,
  "^\\s*\\d+\\s*\\|"
))

stopifnot(length(player_idx) == 64)

player_lines <- tournament_raw[player_idx]
detail_lines <- tournament_raw[player_idx + 1]

player_fields <- str_split_fixed(player_lines, "\\|", 11)
detail_fields <- str_split_fixed(detail_lines, "\\|", 11)

players <- tibble(
  Pair_Number = as.integer(str_trim(player_fields[, 1])),
  Player_Name = str_squish(player_fields[, 2]),
  Actual_Score = as.numeric(str_trim(player_fields[, 3])),
  State = str_trim(detail_fields[, 1]),
  Pre_Rating = as.integer(
    str_extract(detail_fields[, 2], "(?<=R:)\\s*\\d+")
  ),
  Round_1 = str_trim(player_fields[, 4]),
  Round_2 = str_trim(player_fields[, 5]),
  Round_3 = str_trim(player_fields[, 6]),
  Round_4 = str_trim(player_fields[, 7]),
  Round_5 = str_trim(player_fields[, 8]),
  Round_6 = str_trim(player_fields[, 9]),
  Round_7 = str_trim(player_fields[, 10])
)

kable(
  head(players, 10),
  caption = "First 10 Parsed Player Records"
)
First 10 Parsed Player Records
Pair_Number Player_Name Actual_Score State Pre_Rating Round_1 Round_2 Round_3 Round_4 Round_5 Round_6 Round_7
1 GARY HUA 6.0 ON 1794 W 39 W 21 W 18 W 14 W 7 D 12 D 4
2 DAKSHESH DARURI 6.0 MI 1553 W 63 W 58 L 4 W 17 W 16 W 20 W 7
3 ADITYA BAJAJ 6.0 MI 1384 L 8 W 61 W 25 W 21 W 11 W 13 W 12
4 PATRICK H SCHILLING 5.5 MI 1716 W 23 D 28 W 2 W 26 D 5 W 19 D 1
5 HANSHI ZUO 5.5 MI 1655 W 45 W 37 D 12 D 13 D 4 W 14 W 17
6 HANSEN SONG 5.0 OH 1686 W 34 D 29 L 11 W 35 D 10 W 27 W 21
7 GARY DEE SWATHELL 5.0 MI 1649 W 57 W 46 W 13 W 11 L 1 W 9 L 2
8 EZEKIEL HOUGHTON 5.0 MI 1641 W 3 W 32 L 14 L 9 W 47 W 28 W 19
9 STEFANO LEE 5.0 ON 1411 W 25 L 18 W 59 W 8 W 26 L 7 W 20
10 ANVIT RAO 5.0 MI 1365 D 16 L 19 W 55 W 31 D 6 W 25 W 18

The numeric extraction of Pre_Rating intentionally keeps the rating itself while dropping provisional-rating text that may follow it. This prevents values such as provisional ratings from being treated as nonnumeric.

Reshape the Round Results

The seven rounds are stored in separate columns, so I transform them into long format. Each row below represents one player-round observation. The result code (W, D, L, H, or U) is separated from the opponent pair number.

games <- players %>%
  pivot_longer(
    cols = starts_with("Round_"),
    names_to = "Round",
    values_to = "Round_Result"
  ) %>%
  mutate(
    Round = as.integer(str_remove(Round, "Round_")),
    Result = str_extract(Round_Result, "^[WDLHU]"),
    Opponent_Pair = as.integer(str_extract(Round_Result, "\\d+")),
    Game_Score = case_when(
      Result == "W" ~ 1,
      Result == "D" ~ 0.5,
      Result == "L" ~ 0,
      TRUE ~ NA_real_
    )
  )

kable(
  head(games, 14),
  caption = "Tournament Results in Long Format"
)
Tournament Results in Long Format
Pair_Number Player_Name Actual_Score State Pre_Rating Round Round_Result Result Opponent_Pair Game_Score
1 GARY HUA 6 ON 1794 1 W 39 W 39 1.0
1 GARY HUA 6 ON 1794 2 W 21 W 21 1.0
1 GARY HUA 6 ON 1794 3 W 18 W 18 1.0
1 GARY HUA 6 ON 1794 4 W 14 W 14 1.0
1 GARY HUA 6 ON 1794 5 W 7 W 7 1.0
1 GARY HUA 6 ON 1794 6 D 12 D 12 0.5
1 GARY HUA 6 ON 1794 7 D 4 D 4 0.5
2 DAKSHESH DARURI 6 MI 1553 1 W 63 W 63 1.0
2 DAKSHESH DARURI 6 MI 1553 2 W 58 W 58 1.0
2 DAKSHESH DARURI 6 MI 1553 3 L 4 L 4 0.0
2 DAKSHESH DARURI 6 MI 1553 4 W 17 W 17 1.0
2 DAKSHESH DARURI 6 MI 1553 5 W 16 W 16 1.0
2 DAKSHESH DARURI 6 MI 1553 6 W 20 W 20 1.0
2 DAKSHESH DARURI 6 MI 1553 7 W 7 W 7 1.0

Special entries such as H and U do not contain a rated opponent pair number, so they are not treated as normal opponent matchups. I retain them in the data for validation, but only rounds with a valid opponent number are used to calculate Elo expected score.

Match Opponents to Their Pre-Ratings

The expected score for a game depends on both players’ ratings. I therefore create a lookup table using pair number and pre-rating, then join it back to each player’s opponent number.

opponent_lookup <- players %>%
  select(
    Opponent_Pair = Pair_Number,
    Opponent_Name = Player_Name,
    Opponent_Rating = Pre_Rating
  )

rated_games <- games %>%
  filter(Result %in% c("W", "D", "L"), !is.na(Opponent_Pair)) %>%
  left_join(opponent_lookup, by = "Opponent_Pair")

opponent_validation <- rated_games %>%
  summarise(
    Rated_Game_Records = n(),
    Missing_Opponent_Matches = sum(is.na(Opponent_Rating))
  )

kable(
  opponent_validation,
  caption = "Opponent Matching Validation"
)
Opponent Matching Validation
Rated_Game_Records Missing_Opponent_Matches
408 0
stopifnot(opponent_validation$Missing_Opponent_Matches == 0)
stopifnot(!anyDuplicated(players$Pair_Number))
stopifnot(all(!is.na(players$Pre_Rating)))
stopifnot(all(!is.na(rated_games$Game_Score)))

Validate the Project 1 Opponent Matching

Before calculating expected scores, I validate the opponent matching using Gary Hua from Project 1. His listed opponents are pair numbers 39, 21, 18, 14, 7, 12, and 4. Their average pre-rating should be approximately 1605.

gary_check <- rated_games %>%
  filter(Player_Name == "GARY HUA") %>%
  select(
    Round,
    Player_Name,
    Pre_Rating,
    Opponent_Pair,
    Opponent_Name,
    Opponent_Rating
  )

kable(
  gary_check,
  caption = "Gary Hua Opponent Matching Check"
)
Gary Hua Opponent Matching Check
Round Player_Name Pre_Rating Opponent_Pair Opponent_Name Opponent_Rating
1 GARY HUA 1794 39 JOEL R HENDON 1436
2 GARY HUA 1794 21 DINH DANG BUI 1563
3 GARY HUA 1794 18 DAVID SUNDEEN 1600
4 GARY HUA 1794 14 BRADLEY SHAW 1610
5 GARY HUA 1794 7 GARY DEE SWATHELL 1649
6 GARY HUA 1794 12 KENNETH J TACK 1663
7 GARY HUA 1794 4 PATRICK H SCHILLING 1716
stopifnot(nrow(gary_check) == 7)
stopifnot(identical(sort(gary_check$Opponent_Pair), sort(c(39L, 21L, 18L, 14L, 7L, 12L, 4L))))
gary_average <- mean(gary_check$Opponent_Rating)
stopifnot(abs(gary_average - 1605) < 1)

tibble(
  Validation = "Gary Hua average opponent pre-rating",
  Calculated_Value = round(gary_average, 2),
  Expected_Approximate_Value = 1605
) %>%
  kable(caption = "Project 1 Validation Check")
Project 1 Validation Check
Validation Calculated_Value Expected_Approximate_Value
Gary Hua average opponent pre-rating 1605.29 1605

This check is important because an incorrect opponent match would affect the expected probability for a game and therefore the player’s final expected tournament score.

Calculate Elo Expected Score for Each Game

For each rated matchup, I calculate the player’s expected score using:

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

An expected value near 0.50 indicates an approximately even matchup. A value above 0.50 means the player was favored based on pre-tournament rating, while a value below 0.50 means the opponent was favored.

elo_expected <- function(player_rating, opponent_rating) {
  1 / (1 + 10 ^ ((opponent_rating - player_rating) / 400))
}

rated_games <- rated_games %>%
  mutate(
    Rating_Difference = Pre_Rating - Opponent_Rating,
    Expected_Score_Game = elo_expected(
      Pre_Rating,
      Opponent_Rating
    )
  )

kable(
  rated_games %>%
    select(
      Player_Name,
      Round,
      Pre_Rating,
      Opponent_Name,
      Opponent_Rating,
      Rating_Difference,
      Expected_Score_Game
    ) %>%
    head(12),
  digits = 3,
  caption = "Example Elo Expected-Score Calculations"
)
Example Elo Expected-Score Calculations
Player_Name Round Pre_Rating Opponent_Name Opponent_Rating Rating_Difference Expected_Score_Game
GARY HUA 1 1794 JOEL R HENDON 1436 358 0.887
GARY HUA 2 1794 DINH DANG BUI 1563 231 0.791
GARY HUA 3 1794 DAVID SUNDEEN 1600 194 0.753
GARY HUA 4 1794 BRADLEY SHAW 1610 184 0.743
GARY HUA 5 1794 GARY DEE SWATHELL 1649 145 0.697
GARY HUA 6 1794 KENNETH J TACK 1663 131 0.680
GARY HUA 7 1794 PATRICK H SCHILLING 1716 78 0.610
DAKSHESH DARURI 1 1553 THOMAS JOSEPH HOSMER 1175 378 0.898
DAKSHESH DARURI 2 1553 VIRAJ MOHILE 917 636 0.975
DAKSHESH DARURI 3 1553 PATRICK H SCHILLING 1716 -163 0.281
DAKSHESH DARURI 4 1553 RONALD GRZEGORCZYK 1629 -76 0.392
DAKSHESH DARURI 5 1553 MIKE NIKITIN 1604 -51 0.427

Calculate Each Player’s Expected and Actual Scores

The official tournament score may include half-point byes or other administrative results that do not have a rated opponent. Comparing that official total with an expectation computed only for played games would inflate or deflate the difference. For the primary ranking, both actual and expected points are therefore restricted to the same rated games. I retain the official score in a separate column for transparency.

expected_totals <- rated_games %>%
  group_by(Pair_Number) %>%
  summarise(
    Rated_Games = n(),
    Actual_Rated_Score = sum(Game_Score),
    Expected_Score = sum(Expected_Score_Game),
    .groups = "drop"
  )

player_performance <- players %>%
  select(Pair_Number, Player_Name, State, Pre_Rating,
         Official_Tournament_Score = Actual_Score) %>%
  left_join(expected_totals, by = "Pair_Number") %>%
  mutate(
    Rated_Games = replace_na(Rated_Games, 0L),
    Actual_Rated_Score = replace_na(Actual_Rated_Score, 0),
    Expected_Score = replace_na(Expected_Score, 0),
    Performance_Difference = Actual_Rated_Score - Expected_Score,
    Nonrated_Score = Official_Tournament_Score - Actual_Rated_Score
  ) %>%
  arrange(desc(Performance_Difference), Pair_Number)

kable(
  player_performance %>%
    mutate(across(c(Official_Tournament_Score, Actual_Rated_Score,
                    Expected_Score, Performance_Difference,
                    Nonrated_Score), ~round(.x, 2))),
  caption = "All Players: Actual vs. Expected Points in Rated Games"
)
All Players: Actual vs. Expected Points in Rated Games
Pair_Number Player_Name State Pre_Rating Official_Tournament_Score Rated_Games Actual_Rated_Score Expected_Score Performance_Difference Nonrated_Score
3 ADITYA BAJAJ MI 1384 6.0 7 6.0 1.95 4.05 0.0
15 ZACHARY JAMES HOUGHTON MI 1220 4.5 7 4.5 1.37 3.13 0.0
10 ANVIT RAO MI 1365 5.0 7 5.0 1.94 3.06 0.0
46 JACOB ALEXANDER LAVALLEY MI 377 3.0 7 3.0 0.04 2.96 0.0
9 STEFANO LEE ON 1411 5.0 7 5.0 2.29 2.71 0.0
2 DAKSHESH DARURI MI 1553 6.0 7 6.0 3.78 2.22 0.0
52 ETHAN GUO MI 935 2.5 7 2.5 0.30 2.20 0.0
51 TEJAS AYYAGARI MI 1011 2.5 7 2.5 1.03 1.47 0.0
24 MICHAEL R ALDRICH MI 1229 4.0 7 4.0 2.55 1.45 0.0
37 AMIYATOSH PWNANANDAM MI 980 3.5 5 2.0 0.77 1.23 1.5
5 HANSHI ZUO MI 1655 5.5 7 5.5 4.38 1.12 0.0
1 GARY HUA ON 1794 6.0 7 6.0 5.16 0.84 0.0
4 PATRICK H SCHILLING MI 1716 5.5 7 5.5 4.74 0.76 0.0
59 SEAN M MC CORMICK MI 853 2.0 6 1.0 0.41 0.59 1.0
50 SHIVAM JHA MI 1056 2.5 6 2.0 1.42 0.58 0.5
58 VIRAJ MOHILE MI 917 2.0 6 1.0 0.43 0.57 1.0
61 JEZZEL FARKAS ON 955 1.5 7 1.5 0.97 0.53 0.0
7 GARY DEE SWATHELL MI 1649 5.0 7 5.0 4.58 0.42 0.0
60 JULIA SHEN MI 967 1.5 5 1.0 0.60 0.40 0.5
38 BRIAN LIU MI 1423 3.0 6 2.5 2.13 0.37 0.5
14 BRADLEY SHAW MI 1610 4.5 7 4.5 4.18 0.32 0.0
36 SIDDHARTH JHA MI 1355 3.5 6 3.0 2.70 0.30 0.5
28 SOFIA ADINA STANESCU-BELLU MI 1507 3.5 7 3.5 3.31 0.19 0.0
62 ASHWIN BALAJI MI 1530 1.0 1 1.0 0.88 0.12 0.0
34 MICHAEL JEFFERY THOMAS MI 1399 3.5 7 3.5 3.44 0.06 0.0
40 FOREST ZHANG MI 1348 3.0 7 3.0 2.94 0.06 0.0
23 ALAN BUI ON 1363 4.0 7 4.0 3.94 0.06 0.0
6 HANSEN SONG OH 1686 5.0 7 5.0 4.94 0.06 0.0
8 EZEKIEL HOUGHTON MI 1641 5.0 7 5.0 5.03 -0.03 0.0
44 JUSTIN D SCHILLING MI 1199 3.0 6 2.0 2.07 -0.07 1.0
56 MARISA RICCI MI 1153 2.0 5 1.0 1.08 -0.08 1.0
12 KENNETH J TACK MI 1663 4.5 6 4.0 4.11 -0.11 0.5
32 JOSHUA PHILIP MATHEWS ON 1441 3.5 7 3.5 3.72 -0.22 0.0
16 MIKE NIKITIN MI 1604 4.0 5 3.5 3.80 -0.30 0.5
57 MICHAEL LU MI 1092 2.0 6 1.0 1.30 -0.30 1.0
21 DINH DANG BUI ON 1563 4.0 7 4.0 4.32 -0.32 0.0
19 DIPANKAR ROY MI 1564 4.0 7 4.0 4.33 -0.33 0.0
41 KYLE WILLIAM MURPHY MI 1403 3.0 4 2.0 2.36 -0.36 1.0
55 ALEX KONG MI 1186 2.0 6 1.0 1.44 -0.44 1.0
13 TORRANCE HENRY JR MI 1666 4.5 7 4.5 4.95 -0.45 0.0
27 GAURAV GIDWANI MI 1552 3.5 6 3.5 4.00 -0.50 0.0
49 MICHAEL J MARTIN MI 1291 2.5 5 2.0 2.54 -0.54 0.5
18 DAVID SUNDEEN MI 1600 4.0 7 4.0 4.59 -0.59 0.0
26 MAX ZHU ON 1579 3.5 7 3.5 4.10 -0.60 0.0
39 JOEL R HENDON MI 1436 3.0 7 3.0 3.62 -0.62 0.0
17 RONALD GRZEGORCZYK MI 1629 4.0 7 4.0 4.66 -0.66 0.0
47 ERIC WRIGHT MI 1362 2.5 7 2.5 3.19 -0.69 0.0
53 JOSE C YBARRA MI 1393 2.0 3 1.0 1.72 -0.72 1.0
11 CAMERON WILLIAM MC LEMAN MI 1712 4.5 7 4.5 5.34 -0.84 0.0
63 THOMAS JOSEPH HOSMER MI 1175 1.0 5 0.5 1.43 -0.93 0.5
22 EUGENE L MCCLURE MI 1555 4.0 6 3.5 4.48 -0.98 0.5
48 DANIEL KHAIN MI 1382 2.5 5 1.5 2.53 -1.03 1.0
29 CHIEDOZIE OKORIE MI 1602 3.5 6 3.5 4.60 -1.10 0.0
20 JASON ZHENG MI 1595 4.0 7 4.0 5.13 -1.13 0.0
33 JADE GE MI 1449 3.5 7 3.5 4.64 -1.14 0.0
64 BEN LI MI 1163 1.0 7 1.0 2.27 -1.27 0.0
43 ROBERT GLEN VASEY MI 1283 3.0 7 3.0 4.33 -1.33 0.0
45 DEREK YAN MI 1242 3.0 7 3.0 4.37 -1.37 0.0
35 JOSHUA DAVID LEE MI 1438 3.5 7 3.5 4.96 -1.46 0.0
31 RISHI SHETTY MI 1494 3.5 7 3.5 5.09 -1.59 0.0
42 JARED GE MI 1332 3.0 7 3.0 5.01 -2.01 0.0
54 LARRY HODGE MI 1270 2.0 6 1.0 3.40 -2.40 1.0
30 GEORGE AVERY JONES ON 1522 3.5 7 3.5 6.02 -2.52 0.0
25 LOREN SCHWIEBERT MI 1745 3.5 7 3.5 6.28 -2.78 0.0

Check Special Rounds and Game Consistency

# Identify rounds without a standard rated opponent
special_rounds <- games %>%
  filter(!Result %in% c("W", "D", "L") |
           is.na(Opponent_Pair)) %>%
  select(
    Player_Name,
    Round,
    Round_Result,
    Result,
    Opponent_Pair
  )

knitr::kable(
  special_rounds,
  caption = "Rounds Without a Standard Rated Opponent"
)
Rounds Without a Standard Rated Opponent
Player_Name Round Round_Result Result Opponent_Pair
KENNETH J TACK 5 H H NA
MIKE NIKITIN 3 H H NA
MIKE NIKITIN 7 U U NA
EUGENE L MCCLURE 5 H H NA
GAURAV GIDWANI 7 U U NA
CHIEDOZIE OKORIE 7 U U NA
SIDDHARTH JHA 5 H H NA
AMIYATOSH PWNANANDAM 1 B NA NA
AMIYATOSH PWNANANDAM 5 H H NA
BRIAN LIU 5 H H NA
KYLE WILLIAM MURPHY 5 X NA NA
KYLE WILLIAM MURPHY 6 U U NA
KYLE WILLIAM MURPHY 7 U U NA
JUSTIN D SCHILLING 1 B NA NA
DANIEL KHAIN 3 H H NA
DANIEL KHAIN 5 H H NA
MICHAEL J MARTIN 6 H H NA
MICHAEL J MARTIN 7 U U NA
SHIVAM JHA 5 H H NA
JOSE C YBARRA 1 H H NA
JOSE C YBARRA 3 H H NA
JOSE C YBARRA 5 U U NA
JOSE C YBARRA 7 U U NA
LARRY HODGE 4 B NA NA
ALEX KONG 5 B NA NA
MARISA RICCI 1 H H NA
MARISA RICCI 5 H H NA
MICHAEL LU 7 B NA NA
VIRAJ MOHILE 6 B NA NA
SEAN M MC CORMICK 2 B NA NA
JULIA SHEN 6 H H NA
JULIA SHEN 7 U U NA
ASHWIN BALAJI 2 U U NA
ASHWIN BALAJI 3 U U NA
ASHWIN BALAJI 4 U U NA
ASHWIN BALAJI 5 U U NA
ASHWIN BALAJI 6 U U NA
ASHWIN BALAJI 7 U U NA
THOMAS JOSEPH HOSMER 6 H H NA
THOMAS JOSEPH HOSMER 7 U U NA
# Check that each rated game has a corresponding
# opponent entry in the same round
reciprocal_check <- rated_games %>%
  select(Pair_Number, Round, Opponent_Pair, Result) %>%
  left_join(
    rated_games %>%
      select(
        Opponent_Pair = Pair_Number,
        Pair_Number = Opponent_Pair,
        Round,
        Opponent_Result = Result
      ),
    by = c("Pair_Number", "Opponent_Pair", "Round")
  ) %>%
  mutate(
    Valid = case_when(
      Result == "W" & Opponent_Result == "L" ~ TRUE,
      Result == "L" & Opponent_Result == "W" ~ TRUE,
      Result == "D" & Opponent_Result == "D" ~ TRUE,
      TRUE ~ FALSE
    )
  )

# Identify inconsistent or unmatched games
invalid_games <- reciprocal_check %>%
  filter(!Valid)

# Display validation summary
validation_summary <- reciprocal_check %>%
  summarise(
    Total_Rated_Rows = n(),
    Valid_Rows = sum(Valid),
    Missing_Reciprocal_Rows =
      sum(is.na(Opponent_Result)),
    Invalid_Rows = sum(!Valid)
  )

knitr::kable(
  validation_summary,
  caption = "Reciprocal Game Validation Summary"
)
Reciprocal Game Validation Summary
Total_Rated_Rows Valid_Rows Missing_Reciprocal_Rows Invalid_Rows
408 408 0 0
# Display any games requiring further review
if (nrow(invalid_games) > 0) {

  knitr::kable(
    invalid_games,
    caption = "Games Requiring Further Validation"
  )

  warning(
    "Some game records failed reciprocal validation. ",
    "Review these records before interpreting Elo results."
  )

} else {

  cat("All rated game records passed reciprocal validation.")

}
## All rated game records passed reciprocal validation.

Handling Special Rounds

The tournament dataset includes special round codes (H, U, B, and X) that represent administrative outcomes rather than standard games with a recorded opponent and result. These rounds were excluded from the Elo expected-score calculations because the formula requires both players’ pre-tournament ratings to estimate the probability of winning or drawing. Including rounds without a valid opponent rating could produce inaccurate expectations.

To maintain a fair comparison, I calculated each player’s actual score using only the rated games included in their expected-score calculation. This ensures that the difference between actual and expected performance reflects games played against identifiable opponents rather than administrative results.

Validate Expected-Score Ranges

Each single-game expected score must fall between 0 and 1. A player’s total expected score must also fall between 0 and the number of rated games. I check both conditions before ranking the players.

validation_checks <- tibble(
  Check = c(
    "All game expected scores are between 0 and 1",
    "All player expected totals are between 0 and rated games",
    "All 64 tournament players are included",
    "Actual rated-game points are between 0 and rated games"
  ),
  Passed = c(
    all(between(rated_games$Expected_Score_Game, 0, 1)),
    all(
      player_performance$Expected_Score >= 0 &
      player_performance$Expected_Score <= player_performance$Rated_Games
    ),
    nrow(player_performance) == 64,
    all(player_performance$Actual_Rated_Score >= 0 &
        player_performance$Actual_Rated_Score <= player_performance$Rated_Games)
  )
)

kable(
  validation_checks,
  caption = "Expected-Score Validation Checks"
)
Expected-Score Validation Checks
Check Passed
All game expected scores are between 0 and 1 TRUE
All player expected totals are between 0 and rated games TRUE
All 64 tournament players are included TRUE
Actual rated-game points are between 0 and rated games TRUE
stopifnot(all(validation_checks$Passed))

Five Players Who Most Overperformed

The five largest positive values of Performance_Difference identify the players who scored the most points above their Elo-based expectation in games with rated opponents.

top_overperformers <- player_performance %>%
  slice_max(
    order_by = Performance_Difference,
    n = 5,
    with_ties = FALSE
  ) %>%
  mutate(
    Expected_Score = round(Expected_Score, 2),
    Performance_Difference = round(Performance_Difference, 2)
  ) %>%
  select(
    Player_Name,
    Pre_Rating,
    Rated_Games,
    Actual_Rated_Score,
    Expected_Score,
    Performance_Difference
  )

kable(
  top_overperformers,
  caption = "Five Players Who Most Overperformed Their Expected Score"
)
Five Players Who Most Overperformed Their Expected Score
Player_Name Pre_Rating Rated_Games Actual_Rated_Score Expected_Score Performance_Difference
ADITYA BAJAJ 1384 7 6.0 1.95 4.05
ZACHARY JAMES HOUGHTON 1220 7 4.5 1.37 3.13
ANVIT RAO 1365 7 5.0 1.94 3.06
JACOB ALEXANDER LAVALLEY 377 7 3.0 0.04 2.96
STEFANO LEE 1411 7 5.0 2.29 2.71

Five Players Who Most Underperformed

The five most negative values identify the players who scored the most points below their Elo-based expectation in games with rated opponents.

top_underperformers <- player_performance %>%
  slice_min(
    order_by = Performance_Difference,
    n = 5,
    with_ties = FALSE
  ) %>%
  mutate(
    Expected_Score = round(Expected_Score, 2),
    Performance_Difference = round(Performance_Difference, 2)
  ) %>%
  select(
    Player_Name,
    Pre_Rating,
    Rated_Games,
    Actual_Rated_Score,
    Expected_Score,
    Performance_Difference
  )

kable(
  top_underperformers,
  caption = "Five Players Who Most Underperformed Their Expected Score"
)
Five Players Who Most Underperformed Their Expected Score
Player_Name Pre_Rating Rated_Games Actual_Rated_Score Expected_Score Performance_Difference
LOREN SCHWIEBERT 1745 7 3.5 6.28 -2.78
GEORGE AVERY JONES 1522 7 3.5 6.02 -2.52
LARRY HODGE 1270 6 1.0 3.40 -2.40
JARED GE 1332 7 3.0 5.01 -2.01
RISHI SHETTY 1494 7 3.5 5.09 -1.59

Visualize Performance Relative to Expectation

The chart provides an additional way to see how actual tournament performance differs from Elo expectation. Players above zero scored more points than expected, while players below zero scored fewer points than expected.

library(dplyr)
library(ggplot2)

# Combine the five biggest overperformers
# and five biggest underperformers
top_bottom_players <- player_performance %>%
  arrange(desc(Performance_Difference)) %>%
  slice_head(n = 5) %>%
  bind_rows(
    player_performance %>%
      arrange(Performance_Difference) %>%
      slice_head(n = 5)
  ) %>%
  mutate(
    Performance = if_else(
      Performance_Difference > 0,
      "Overperformed",
      "Underperformed"
    ),
    Player_Name = reorder(
      Player_Name,
      Performance_Difference
    )
  )

# Create horizontal bar chart
ggplot(
  top_bottom_players,
  aes(
    x = Player_Name,
    y = Performance_Difference,
    fill = Performance
  )
) +
  geom_col(width = 0.7) +
  geom_text(
    aes(
      label = sprintf("%+.2f", Performance_Difference)
    ),
    hjust = ifelse(
      top_bottom_players$Performance_Difference > 0,
      -0.15,
      1.15
    ),
    size = 3.5
  ) +
  coord_flip() +
  scale_fill_manual(
    values = c(
      "Overperformed" = "#2A9D8F",
      "Underperformed" = "#E76F51"
    )
  ) +
  labs(
    title = "Chess Tournament: Actual vs. Expected Performance",
    subtitle = "Five largest overperformers and underperformers",
    x = NULL,
    y = "Actual Score - Expected Score (Points)",
    fill = "Performance"
  ) +
  theme_minimal(base_size = 12) +
  theme(
    plot.title = element_text(face = "bold"),
    legend.position = "bottom",
    panel.grid.major.y = element_blank()
  ) +
  scale_y_continuous(
    expand = expansion(mult = c(0.15, 0.15))
  )

Conclusions

This analysis compares tournament results with the scores predicted by each player’s pre-tournament rating and the ratings of the opponents they actually faced. The final overperformance and underperformance tables identify the five players with the largest positive and negative differences between actual and expected score in rated games. The tables are generated from the source data at render time, so their numeric results are not manually entered.

The results should be interpreted as performance relative to rating-based expectation rather than as a ranking of the strongest players. Because administrative rounds are excluded from both sides of the comparison, the rankings describe performance in played rated games, not the difference from the official total including byes. A lower-rated player can overperform by scoring more points than expected against stronger opponents, while a highly rated player can underperform even with a relatively high tournament score if that score falls below expectation.

A useful extension would be to compare the logistic Elo probabilities used here with FIDE’s published scoring-probability table to see whether the choice of implementation changes the top-five rankings. Another extension would be to repeat the analysis across multiple tournaments to determine whether the largest overperformances persist or are specific to this event.

Reproducibility Information

The analysis reads the tournament text file directly from a public GitHub URL and does not depend on local file paths. The session information below documents the R environment and package versions used when the report is rendered.

sessionInfo()
## R version 4.5.2 (2025-10-31)
## Platform: aarch64-apple-darwin20
## Running under: macOS Sequoia 15.7.3
## 
## Matrix products: default
## BLAS:   /System/Library/Frameworks/Accelerate.framework/Versions/A/Frameworks/vecLib.framework/Versions/A/libBLAS.dylib 
## LAPACK: /Library/Frameworks/R.framework/Versions/4.5-arm64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.1
## 
## locale:
## [1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8
## 
## time zone: America/New_York
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
##  [1] knitr_1.51      lubridate_1.9.4 forcats_1.0.1   stringr_1.6.0  
##  [5] dplyr_1.2.1     purrr_1.2.1     readr_2.2.0     tidyr_1.3.2    
##  [9] tibble_3.3.1    ggplot2_4.0.2   tidyverse_2.0.0
## 
## loaded via a namespace (and not attached):
##  [1] gtable_0.3.6       jsonlite_2.0.0     compiler_4.5.2     tidyselect_1.2.1  
##  [5] dichromat_2.0-0.1  jquerylib_0.1.4    scales_1.4.0       yaml_2.3.12       
##  [9] fastmap_1.2.0      R6_2.6.1           labeling_0.4.3     generics_0.1.4    
## [13] tzdb_0.5.0         bslib_0.10.0       pillar_1.11.1      RColorBrewer_1.1-3
## [17] rlang_1.1.7        stringi_1.8.7      cachem_1.1.0       xfun_0.56         
## [21] sass_0.4.10        S7_0.2.1           otel_0.2.0         timechange_0.4.0  
## [25] cli_3.6.5          withr_3.0.2        magrittr_2.0.4     digest_0.6.39     
## [29] grid_4.5.2         rstudioapi_0.18.0  hms_1.1.4          lifecycle_1.0.5   
## [33] vctrs_0.7.1        evaluate_1.0.5     glue_1.8.0         farver_2.1.2      
## [37] rmarkdown_2.30     tools_4.5.2        pkgconfig_2.0.3    htmltools_0.5.9

Citations

Elo rating system. (n.d.). Wikipedia. https://en.wikipedia.org/wiki/Elo_rating_system

FIDE. (2024). FIDE rating regulations. https://handbook.fide.com/chapter/B022024

OpenAI. (2026). ChatGPT [Large language model]. https://chatgpt.com.

LLM Transcript