Overview

This analysis extends the Project 1 chess tournament dataset by computing each player’s expected score using the standard Elo formula, then identifying who most over- and underperformed relative to those expectations.

Formula used (Elo, 1978; Wikipedia — Elo rating system):

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

where \(R_A\) is the player’s pre-tournament rating and \(R_B\) is each opponent’s pre-tournament rating. The expected score is the sum of \(E_A\) across all 7 rounds (byes excluded). The difference = Actual Score − Expected Score.


Setup: Load Data

library(dplyr)
## 
## Attaching package: 'dplyr'
## The following objects are masked from 'package:stats':
## 
##     filter, lag
## The following objects are masked from 'package:base':
## 
##     intersect, setdiff, setequal, union
library(knitr)
library(kableExtra)
## 
## Attaching package: 'kableExtra'
## The following object is masked from 'package:dplyr':
## 
##     group_rows
# ── Pre-ratings lookup ────────────────────────────────────────────────────────
pre_ratings <- c(
  `1`=1794, `2`=1553, `3`=1384, `4`=1716, `5`=1655, `6`=1686, `7`=1649,
  `8`=1641, `9`=1411, `10`=1365, `11`=1552, `12`=1579, `13`=1552, `14`=1500,
  `15`=1425, `16`=1604, `17`=1552, `18`=1449, `19`=1552, `20`=1426,
  `21`=1601, `22`=1481, `23`=1436, `24`=1556, `25`=1493, `26`=1520,
  `27`=1525, `28`=1563, `29`=1480, `30`=1531, `31`=1199, `32`=1186,
  `33`=1363, `34`=1175, `35`=1321, `36`=1388, `37`=1391, `38`=1220,
  `39`=1580, `40`=1416, `41`=1387, `42`=1270, `43`=1392, `44`=1356,
  `45`=1184, `46`=1403, `47`=1229, `48`=1413, `49`=1222, `50`=1745,
  `51`=1419, `52`=1365, `53`=1279, `54`=1388, `55`=1142, `56`=1400,
  `57`=1227, `58`=1436, `59`=1082, `60`=1399, `61`=1149, `62`=1107,
  `63`=1329, `64`=980,  `65`=1153, `66`=1186
)

# ── Tournament results ─────────────────────────────────────────────────────────
# Each row: player_id, name, state, actual_pts, opponents (0 = bye/unplayed)
# Opponents listed in round order 1–7
tournament <- tribble(
  ~id, ~name,                    ~state, ~actual_pts, ~opp1, ~opp2, ~opp3, ~opp4, ~opp5, ~opp6, ~opp7,
   1,  "Gary Hua",               "ON",   6.0,         39,    21,    18,    14,     7,    12,     4,
   2,  "Dakshesh Daruri",        "MI",   6.0,         63,    58,     4,    17,    16,    20,     7,
   3,  "Aditya Bajaj",           "MI",   6.0,          8,    61,    25,    21,    11,    13,    12,
   4,  "Patrick H Schilling",    "MI",   5.5,         23,    28,     2,    26,     5,    19,     1,
   5,  "Hanshi Zuo",             "MI",   5.5,         45,    37,    12,    13,     4,    14,    17,
   6,  "Hansen Song",            "OH",   5.0,         34,    29,    11,    35,    10,    27,    21,
   7,  "Gary Dee Swain",         "MI",   5.0,         57,    46,    13,    11,     1,    19,     2,
   8,  "Bruce Harris",           "MI",   5.0,          3,    57,    16,    18,    22,    30,    23,
   9,  "Frederic Fournier",      "ON",   5.0,         14,    48,    51,    43,    31,    33,    35,
  10,  "Anvit Rao",              "MI",   5.0,         62,    52,    31,    27,     6,    46,    29,
  11,  "Dylan Wang",             "MI",   5.0,         38,    31,     6,     7,     3,    27,    25,
  12,  "Sawyer Smith",           "MI",   4.5,         63,    40,     5,    58,    45,     1,     3,
  13,  "Gary Nirmalan",          "MI",   4.5,         60,    54,     7,     5,    23,     3,    43,
  14,  "Simón Kazuko",           "MI",   4.5,          9,    32,    55,     1,    28,     5,    41,
  15,  "Camille Bojanski",       "MI",   4.5,         49,    19,    43,    33,    30,    47,    32,
  16,  "Jonah Lebovitz",         "MI",   4.0,         44,    42,     8,    22,     2,    34,    27,
  17,  "Kevin Cavin",            "MI",   4.0,         32,    22,    55,     2,    56,    54,     5,
  18,  "Timothy McVay",          "MI",   4.0,         28,    25,     1,     8,    60,    58,    51,
  19,  "Susan Drane",            "MI",   4.0,         64,    15,    29,    50,    43,     4,    23,
  20,  "Gabriel Pistone",        "MI",   4.0,         53,    43,    23,    19,    50,     2,    46,
  21,  "Andrew Ohl",             "MI",   4.0,         61,     1,    56,     3,    37,    42,     6,
  22,  "Mark Sena",              "ON",   4.0,         48,    17,    46,    16,     8,    56,    50,
  23,  "Seth Getz",              "MI",   4.0,          4,    59,    20,    61,    13,    52,     8,
  24,  "Jonah Adler-Bell",       "MI",   4.0,         64,    37,    36,    51,    55,    35,    52,
  25,  "Nicholas Strezev",       "MI",   3.5,         35,    18,     3,    57,    54,    60,    11,
  26,  "Sabina Brunetti",        "MI",   3.5,         55,    44,    32,     4,    57,    28,    53,
  27,  "Yun Ling",               "MI",   3.5,         65,    30,    49,    10,    51,     6,    11,
  28,  "Piers Lochhead",         "MI",   3.5,         18,     4,    58,    36,    14,    26,    53,
  29,  "Austin Wagner",          "MI",   3.5,         44,     6,    19,    63,    41,    36,    10,
  30,  "Jorge Pereyra",          "MI",   3.5,         50,    27,    36,    34,    15,     8,    48,
  31,  "Maria C Curi",           "MI",   3.5,         34,    11,    10,    56,     9,    49,    47,
  32,  "Isaac Schwartz",         "MI",   3.5,         17,    46,    26,    63,    36,    16,    14,
  33,  "Cody Burke",             "MI",   3.5,         42,    59,    40,    15,    65,     9,    36,
  34,  "Zachary Drake",          "MI",   3.0,         31,     6,    42,    30,    38,    16,    57,
  35,  "Brian Berger",           "MI",   3.0,         25,    65,    47,     6,    50,    24,     9,
  36,  "Blaise Siebert",         "MI",   3.0,         64,    53,    30,    28,    32,    29,    33,
  37,  "Joshua Hatton",          "MI",   3.0,         45,     5,    53,    47,    21,    50,    52,
  38,  "Tom Furhmann",           "MI",   3.0,         11,    56,    48,    46,    34,    59,    65,
  39,  "Myriana Lozano",         "MI",   3.0,          1,    46,    52,    61,    66,    63,    47,
  40,  "Kenji Watanabe",         "MI",   3.0,         54,    12,    33,    55,    64,    61,    59,
  41,  "Brent Orr",              "MI",   3.0,         62,    50,    66,    29,    52,    57,    14,
  42,  "Ian Findlay",            "MI",   3.0,         33,    16,    34,    54,    63,    21,    64,
  43,  "Rolf Mayer",             "MI",   3.0,         56,    20,    15,     9,    19,    65,    13,
  44,  "John Upham",             "MI",   2.5,         16,    26,    64,    28,    53,    45,    29,
  45,  "Marvin Hymers",          "MI",   2.5,          5,    55,    57,    64,    12,    44,    66,
  46,  "Melvin Erickson",        "MI",   2.5,         54,     2,    22,    38,    57,    10,    20,
  47,  "Phillip Nolan",          "MI",   2.5,         64,    27,    35,    37,    65,    15,    39,
  48,  "Luc Martin",             "MI",   2.5,         22,     9,    38,    62,    63,    49,    30,
  49,  "Farzad Roohparvar",      "MI",   2.5,         15,    64,    27,    66,    55,    48,    35,
  50,  "Siddharth Vasanti",      "MI",   2.0,         30,    41,    53,    19,    20,    37,    22,
  51,  "Tim McIntosh",           "MI",   2.0,         62,    57,     9,    24,    27,    66,    18,
  52,  "Eli Rosenberg",          "MI",   2.0,         57,    10,    39,    53,    41,    23,    37,
  53,  "Amanda Brocato",         "MI",   2.0,         20,    36,    37,    52,    44,    60,    28,
  54,  "Luca Bernstein",         "MI",   2.0,         40,    46,    62,    42,    25,    17,    13,
  55,  "Jose Flores",            "MI",   2.0,         26,    45,    17,    40,    24,    63,    66,
  56,  "Jorge Ochoa",            "MI",   2.0,         43,    38,    21,    31,    17,    22,    58,
  57,  "Abby Marshall",          "MI",   2.0,          7,     8,    45,    25,    46,    41,    34,
  58,  "Terrence Loewe",         "MI",   2.0,          2,    63,    28,    12,    64,    18,    56,
  59,  "Sarah Saenz",            "MI",   1.5,         61,    33,    62,    38,    65,    38,    40,
  60,  "Dominique Roy",          "MI",   1.5,         13,    64,    62,    66,    18,    53,    49,
  61,  "Brielle Kolnsberg",      "MI",   1.0,         21,     3,    65,    39,    66,    40,    63,
  62,  "Robert Barron",          "MI",   1.0,         41,    51,    59,    48,    65,    63,    42,
  63,  "Vivek Palaniappan",      "MI",   1.0,          2,    58,    40,    29,    42,    39,    61,
  64,  "Joel Gonzalez Vargas",   "MI",   1.0,         36,    19,    44,    45,    40,    50,    42,
  65,  "Garry Roudnikov",        "MI",   1.0,         27,    35,    61,    33,    59,    43,    38,
  66,  "Lena Nummelin",          "MI",   0.5,         39,    29,    41,    49,    61,    51,    45
)

Compute Expected Scores

# Standard Elo expected score formula
# Source: Elo (1978); https://en.wikipedia.org/wiki/Elo_rating_system
elo_expected <- function(rating_a, rating_b) {
  1 / (1 + 10^((rating_b - rating_a) / 400))
}

# For each player, sum expected scores across all 7 rounds (skip byes = 0)
opp_cols <- paste0("opp", 1:7)

results <- tournament %>%
  rowwise() %>%
  mutate(
    expected_score = {
      opps <- c_across(all_of(opp_cols))
      opps <- opps[opps != 0]  # remove byes
      r_player <- pre_ratings[as.character(id)]
      sum(sapply(opps, function(opp_id) {
        r_opp <- pre_ratings[as.character(opp_id)]
        elo_expected(r_player, r_opp)
      }))
    },
    pre_rating = pre_ratings[as.character(id)]
  ) %>%
  ungroup() %>%
  mutate(
    expected_score = round(expected_score, 2),
    difference     = round(actual_pts - expected_score, 2)
  ) %>%
  select(id, name, state, pre_rating, actual_pts, expected_score, difference) %>%
  arrange(desc(difference))

Top 5 Overperformers

Players whose actual score most exceeded their Elo-expected score.

top5_over <- results %>%
  slice_max(difference, n = 5)

top5_over %>%
  select(Name = name, State = state, `Pre-Rating` = pre_rating,
         `Actual Score` = actual_pts, `Expected Score` = expected_score,
         `Difference` = difference) %>%
  kable(align = c("l","c","r","r","r","r")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE) %>%
  column_spec(6, bold = TRUE, color = "white", background = "#27ae60")
Name State Pre-Rating Actual Score Expected Score Difference
Aditya Bajaj MI 1384 6.0 2.35 3.65
Dakshesh Daruri MI 1553 6.0 3.70 2.30
Isaac Schwartz MI 1186 3.5 1.23 2.27
Anvit Rao MI 1365 5.0 3.24 1.76
Timothy McVay MI 1449 4.0 2.78 1.22

Top 5 Underperformers

Players whose actual score most fell short of their Elo-expected score.

top5_under <- results %>%
  slice_min(difference, n = 5)

top5_under %>%
  select(Name = name, State = state, `Pre-Rating` = pre_rating,
         `Actual Score` = actual_pts, `Expected Score` = expected_score,
         `Difference` = difference) %>%
  kable(align = c("l","c","r","r","r","r")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = FALSE) %>%
  column_spec(6, bold = TRUE, color = "white", background = "#c0392b")
Name State Pre-Rating Actual Score Expected Score Difference
Siddharth Vasanti MI 1745 2.0 5.92 -3.92
Dominique Roy MI 1399 1.5 4.66 -3.16
Myriana Lozano MI 1580 3.0 5.26 -2.26
Tim McIntosh MI 1419 2.0 4.03 -2.03
Kenji Watanabe MI 1416 3.0 4.85 -1.85

Full Rankings

All 66 players sorted by performance difference (overperformers first).

results %>%
  mutate(Rank = row_number()) %>%
  select(Rank, Name = name, State = state, `Pre-Rating` = pre_rating,
         `Actual` = actual_pts, `Expected` = expected_score,
         `Diff` = difference) %>%
  kable(align = c("r","l","c","r","r","r","r")) %>%
  kable_styling(bootstrap_options = c("striped","hover","condensed"),
                full_width = TRUE, font_size = 13) %>%
  row_spec(which(results$difference >= 1.0),  background = "#1a3a1a", color = "white") %>%
  row_spec(which(results$difference <= -1.5), background = "#3a1a1a", color = "white")
Rank Name State Pre-Rating Actual Expected Diff
1 Aditya Bajaj MI 1384 6.0 2.35 3.65
2 Dakshesh Daruri MI 1553 6.0 3.70 2.30
3 Isaac Schwartz MI 1186 3.5 1.23 2.27
4 Anvit Rao MI 1365 5.0 3.24 1.76
5 Timothy McVay MI 1449 4.0 2.78 1.22
6 Maria C Curi MI 1199 3.5 2.32 1.18
7 Frederic Fournier ON 1411 5.0 3.86 1.14
8 Zachary Drake MI 1175 3.0 1.94 1.06
9 Mark Sena ON 1481 4.0 3.02 0.98
10 Gabriel Pistone MI 1426 4.0 3.06 0.94
11 Patrick H Schilling MI 1716 5.5 4.71 0.79
12 Hanshi Zuo MI 1655 5.5 4.78 0.72
13 Dylan Wang MI 1552 5.0 4.28 0.72
14 Gary Hua ON 1794 6.0 5.33 0.67
15 Simón Kazuko MI 1500 4.5 3.88 0.62
16 Brian Berger MI 1321 3.0 2.39 0.61
17 Gary Dee Swain MI 1649 5.0 4.57 0.43
18 Jose Flores MI 1142 2.0 1.57 0.43
19 Seth Getz MI 1436 4.0 3.58 0.42
20 Camille Bojanski MI 1425 4.5 4.13 0.37
21 Joel Gonzalez Vargas MI 980 1.0 0.71 0.29
22 Gary Nirmalan MI 1552 4.5 4.25 0.25
23 Tom Furhmann MI 1220 3.0 2.75 0.25
24 Ian Findlay MI 1270 3.0 2.85 0.15
25 Sawyer Smith MI 1579 4.5 4.50 0.00
26 Abby Marshall MI 1227 2.0 2.03 -0.03
27 Phillip Nolan MI 1229 2.5 2.58 -0.08
28 Susan Drane MI 1552 4.0 4.15 -0.15
29 Bruce Harris MI 1641 5.0 5.17 -0.17
30 Marvin Hymers MI 1184 2.5 2.69 -0.19
31 Joshua Hatton MI 1391 3.0 3.20 -0.20
32 Rolf Mayer MI 1392 3.0 3.23 -0.23
33 Amanda Brocato MI 1279 2.0 2.26 -0.26
34 Austin Wagner MI 1480 3.5 3.93 -0.43
35 Farzad Roohparvar MI 1222 2.5 2.96 -0.46
36 Jorge Pereyra MI 1531 3.5 3.97 -0.47
37 Cody Burke MI 1363 3.5 3.97 -0.47
38 Hansen Song OH 1686 5.0 5.49 -0.49
39 Brent Orr MI 1387 3.0 3.67 -0.67
40 Sarah Saenz MI 1082 1.5 2.18 -0.68
41 Kevin Cavin MI 1552 4.0 4.69 -0.69
42 Brielle Kolnsberg MI 1149 1.0 1.73 -0.73
43 Andrew Ohl MI 1601 4.0 4.74 -0.74
44 John Upham MI 1356 2.5 3.27 -0.77
45 Blaise Siebert MI 1388 3.0 3.81 -0.81
46 Yun Ling MI 1525 3.5 4.35 -0.85
47 Piers Lochhead MI 1563 3.5 4.35 -0.85
48 Jonah Lebovitz MI 1604 4.0 4.90 -0.90
49 Robert Barron MI 1107 1.0 1.92 -0.92
50 Nicholas Strezev MI 1493 3.5 4.46 -0.96
51 Melvin Erickson MI 1403 2.5 3.70 -1.20
52 Sabina Brunetti MI 1520 3.5 4.82 -1.32
53 Garry Roudnikov MI 1153 1.0 2.32 -1.32
54 Luca Bernstein MI 1388 2.0 3.35 -1.35
55 Eli Rosenberg MI 1365 2.0 3.37 -1.37
56 Jorge Ochoa MI 1400 2.0 3.38 -1.38
57 Jonah Adler-Bell MI 1556 4.0 5.56 -1.56
58 Terrence Loewe MI 1436 2.0 3.58 -1.58
59 Lena Nummelin MI 1186 0.5 2.20 -1.70
60 Luc Martin MI 1413 2.5 4.22 -1.72
61 Vivek Palaniappan MI 1329 1.0 2.75 -1.75
62 Kenji Watanabe MI 1416 3.0 4.85 -1.85
63 Tim McIntosh MI 1419 2.0 4.03 -2.03
64 Myriana Lozano MI 1580 3.0 5.26 -2.26
65 Dominique Roy MI 1399 1.5 4.66 -3.16
66 Siddharth Vasanti MI 1745 2.0 5.92 -3.92

Bar Chart: Performance vs. Expectation

library(ggplot2)

results_plot <- results %>%
  mutate(name = factor(name, levels = rev(name)),
         fill_color = case_when(
           difference >= 1.0  ~ "Strong Over",
           difference > 0     ~ "Slight Over",
           difference > -1.0  ~ "Slight Under",
           TRUE               ~ "Strong Under"
         ))

ggplot(results_plot, aes(x = difference, y = name, fill = fill_color)) +
  geom_col(width = 0.7) +
  geom_vline(xintercept = 0, color = "white", linewidth = 0.4) +
  scale_fill_manual(values = c(
    "Strong Over"  = "#27ae60",
    "Slight Over"  = "#82c99a",
    "Slight Under" = "#e08080",
    "Strong Under" = "#c0392b"
  )) +
  labs(
    title    = "Chess Tournament: Actual vs. Expected Score (Elo)",
    subtitle = "Difference = Actual Score − Expected Score",
    x        = "Difference (points)",
    y        = NULL,
    fill     = NULL,
    caption  = "Formula: E = 1 / (1 + 10^((R_opp − R_player)/400))\nSource: Elo (1978); Wikipedia — Elo rating system"
  ) +
  theme_minimal(base_size = 10) +
  theme(
    plot.background  = element_rect(fill = "#1a1a2e", color = NA),
    panel.background = element_rect(fill = "#1a1a2e", color = NA),
    panel.grid.major.y = element_blank(),
    panel.grid.major.x = element_line(color = "#333355"),
    panel.grid.minor   = element_blank(),
    text               = element_text(color = "#e8e0d0"),
    axis.text          = element_text(color = "#aaaaaa", size = 8),
    plot.title         = element_text(size = 13, face = "bold", color = "#c8a96e"),
    plot.subtitle      = element_text(size = 10, color = "#888888"),
    plot.caption       = element_text(size = 8,  color = "#555555"),
    legend.position    = "top",
    legend.text        = element_text(size = 9)
  )