Show code
library(tidyverse)
library(knitr)DATA 607 · Week 5
This assignment builds on the Project 1 tournament data: the same 64-player, seven-round USCF tournament report I parsed for Project 1. This time, instead of just averaging each player’s opponent ratings, I use the rating difference in every game to calculate each player’s expected score for the tournament, compare it to their actual score, and find the five players who most overperformed and the five who most underperformed.
I reused the Project 1 parsing logic, with one addition. Project 1 only needed the opponent’s pair number from each round (the 39 in W 39), so it threw away the result letter. Here I kept both the letter and the number, so each round tells me who the player faced and how the game ended.
From there I:
B, H, U, or X are byes, unplayed rounds, or forfeits, so they have no opponent rating to compare against.One thing I changed from my Approach write-up: I compare expected score to the points a player earned in games actually played, not their listed total. The total in the report includes bye and forfeit points (a half-point bye counts as 0.5, a full bye or forfeit win as 1). Those rounds have no opponent, so they can’t have an expected score. Leaving them on the actual side only would make players with byes look like they overperformed when they really just got free points. For most players the two numbers are the same.
For a player rated \(R_A\) facing an opponent rated \(R_B\), the expected score for that game is:
\[ E_A = \frac{1}{1 + 10^{(R_B - R_A)/400}} \]
This is a number between 0 and 1. Two equally rated players each have an expected score of 0.5. A player rated 400 points higher has an expected score of about 0.91, meaning they’d be expected to score roughly 9 out of 10 points over 10 games against that opponent. Summing \(E_A\) across all of a player’s games gives their expected score for the tournament.
Source: Elo, A. E. (1978). The Rating of Chess Players, Past and Present. Arco Publishing. This is the same logistic formula used by FIDE and the USCF for expected scores.
library(tidyverse)
library(knitr)data_url <- paste0(
"https://raw.githubusercontent.com/dillonleeper/",
"DATA-607/main/assignments/week04/tournament-results.txt"
)
tournament_lines <- readLines(data_url, warn = FALSE)This is the same file from Project 1, read from its public GitHub URL. It has 196 lines: a short header, then two lines per player.
Each player takes up two lines in the report. The first line has the pair number, name, total points, and seven round fields like W 39. The second has the state and the pre-tournament rating, written like R: 1794 ->1817. Provisional ratings like 1641P17 are cut down to just the number.
player_line_numbers <- grep("^\\s*[0-9]+\\s*\\|", tournament_lines)
split_fields <- function(line) {
trimws(strsplit(line, "|", fixed = TRUE)[[1]])
}
players <- map_dfr(player_line_numbers, function(i) {
player_fields <- split_fields(tournament_lines[i])
detail_fields <- split_fields(tournament_lines[i + 1])
tibble(
player_number = as.integer(player_fields[1]),
player_name = player_fields[2],
state = detail_fields[1],
total_points = as.numeric(player_fields[3]),
pre_rating = as.integer(str_match(detail_fields[2], "R:\\s*([0-9]+)")[, 2]),
rounds = list(player_fields[4:10])
)
})
players |>
head() |>
transmute(player_number, player_name = str_to_title(player_name), state, total_points, pre_rating) |>
kable(col.names = c("#", "Player", "State", "Total points", "Pre-rating"),
caption = "First six players")| # | Player | State | Total points | Pre-rating |
|---|---|---|---|---|
| 1 | Gary Hua | ON | 6.0 | 1794 |
| 2 | Dakshesh Daruri | MI | 6.0 | 1553 |
| 3 | Aditya Bajaj | MI | 6.0 | 1384 |
| 4 | Patrick H Schilling | MI | 5.5 | 1716 |
| 5 | Hanshi Zuo | MI | 5.5 | 1655 |
| 6 | Hansen Song | OH | 5.0 | 1686 |
Now each player’s seven round fields get split into a result letter and an opponent number, giving one row per player per round:
rounds <- players |>
select(player_number, rounds) |>
unnest_longer(rounds, values_to = "round_field", indices_to = "round") |>
mutate(
result = str_extract(round_field, "^[A-Z]"),
opponent_number = as.integer(str_extract(round_field, "[0-9]+"))
)
rounds |>
filter(player_number == 1) |>
select(round, round_field, result, opponent_number) |>
kable(col.names = c("Round", "Raw field", "Result", "Opponent #"),
caption = "Gary Hua's seven rounds")| Round | Raw field | Result | Opponent # |
|---|---|---|---|
| 1 | W 39 | W | 39 |
| 2 | W 21 | W | 21 |
| 3 | W 18 | W | 18 |
| 4 | W 14 | W | 14 |
| 5 | W 7 | W | 7 |
| 6 | D 12 | D | 12 |
| 7 | D 4 | D | 4 |
That’s Gary Hua’s seven rounds: five wins and two draws, matching his 6.0 points.
rating_lookup <- setNames(players$pre_rating, players$player_number)
games <- rounds |>
filter(result %in% c("W", "L", "D")) |> # real games only
mutate(
player_rating = rating_lookup[as.character(player_number)],
opponent_rating = rating_lookup[as.character(opponent_number)],
actual = recode(result, W = 1, D = 0.5, L = 0),
expected = 1 / (1 + 10^((opponent_rating - player_rating) / 400))
)
games |>
filter(player_number == 1) |>
select(round, result, opponent_number, player_rating, opponent_rating, actual, expected) |>
mutate(expected = round(expected, 3)) |>
kable(col.names = c("Round", "Result", "Opponent #", "Gary's rating",
"Opponent rating", "Actual", "Expected"),
caption = "Gary Hua's games, scored")| Round | Result | Opponent # | Gary’s rating | Opponent rating | Actual | Expected |
|---|---|---|---|---|---|---|
| 1 | W | 39 | 1794 | 1436 | 1.0 | 0.887 |
| 2 | W | 21 | 1794 | 1563 | 1.0 | 0.791 |
| 3 | W | 18 | 1794 | 1600 | 1.0 | 0.753 |
| 4 | W | 14 | 1794 | 1610 | 1.0 | 0.743 |
| 5 | W | 7 | 1794 | 1649 | 1.0 | 0.697 |
| 6 | D | 12 | 1794 | 1663 | 0.5 | 0.680 |
| 7 | D | 4 | 1794 | 1716 | 0.5 | 0.610 |
For example, in round 1 Gary (1794) beat player 39, who was rated 1436. With a 358-point rating edge, his expected score for that game was about 0.89, so the win only beat expectation by about 0.11.
elo_results <- games |>
group_by(player_number) |>
summarise(
games_played = n(),
actual_score = sum(actual),
expected_score = sum(expected)
) |>
right_join(
players |> select(player_number, player_name, state, total_points, pre_rating),
by = "player_number"
) |>
mutate(
across(c(games_played, actual_score, expected_score), \(x) replace_na(x, 0)),
difference = actual_score - expected_score
) |>
select(player_number, player_name, state, pre_rating, total_points,
games_played, actual_score, expected_score, difference) |>
arrange(player_number)gary <- elo_results |> filter(player_number == 1)
stopifnot(
nrow(elo_results) == 64,
!anyNA(games$opponent_rating), # every opponent found
all(games$expected > 0 & games$expected < 1),
gary$actual_score == 6,
# games-only score never exceeds the listed total (byes only add points)
all(elo_results$actual_score <= elo_results$total_points)
)
# Each game appears twice in `games`, once from each player's side. The two
# sides' expected scores add up to exactly 1, and so do their actual results,
# so both totals should equal the number of distinct games.
tibble(
Check = c("Total expected points", "Total actual points", "Games played"),
Value = c(round(sum(games$expected), 2), sum(games$actual), nrow(games) / 2)
) |>
kable(caption = "All three should match")| Check | Value |
|---|---|
| Total expected points | 204 |
| Total actual points | 204 |
| Games played | 204 |
The totals line up: 204 games were played, and both the expected and actual scores across the whole tournament add up to 204. Every game shows up twice in games (once from each player’s side), and the two sides always split exactly 1 point between them, in expectation and in reality. That’s a good sign the rating lookups and the formula are working.
show_cols <- c("Player", "State", "Pre-rating", "Games", "Actual", "Expected", "Difference")
over <- elo_results |>
slice_max(difference, n = 5)
over |>
transmute(player_name = str_to_title(player_name), state, pre_rating, games_played, actual_score,
expected_score = round(expected_score, 2), difference = round(difference, 2)) |>
kable(col.names = show_cols, caption = "Biggest overperformers (actual minus expected)")| Player | State | Pre-rating | Games | Actual | Expected | Difference |
|---|---|---|---|---|---|---|
| Aditya Bajaj | MI | 1384 | 7 | 6.0 | 1.95 | 4.05 |
| Zachary James Houghton | MI | 1220 | 7 | 4.5 | 1.37 | 3.13 |
| Anvit Rao | MI | 1365 | 7 | 5.0 | 1.94 | 3.06 |
| Jacob Alexander Lavalley | MI | 377 | 7 | 3.0 | 0.04 | 2.96 |
| Stefano Lee | ON | 1411 | 7 | 5.0 | 2.29 | 2.71 |
under <- elo_results |>
slice_min(difference, n = 5)
under |>
transmute(player_name = str_to_title(player_name), state, pre_rating, games_played, actual_score,
expected_score = round(expected_score, 2), difference = round(difference, 2)) |>
kable(col.names = show_cols, caption = "Biggest underperformers (actual minus expected)")| Player | State | Pre-rating | Games | Actual | Expected | Difference |
|---|---|---|---|---|---|---|
| Loren Schwiebert | MI | 1745 | 7 | 3.5 | 6.28 | -2.78 |
| George Avery Jones | ON | 1522 | 7 | 3.5 | 6.02 | -2.52 |
| Larry Hodge | MI | 1270 | 6 | 1.0 | 3.40 | -2.40 |
| Jared Ge | MI | 1332 | 7 | 3.0 | 5.01 | -2.01 |
| Rishi Shetty | MI | 1494 | 7 | 3.5 | 5.09 | -1.59 |
highlight <- c(over$player_number, under$player_number)
elo_results |>
mutate(
group = case_when(
player_number %in% over$player_number ~ "Top 5 over",
player_number %in% under$player_number ~ "Top 5 under",
TRUE ~ "Everyone else"
),
label = fct_reorder(str_to_title(player_name), difference)
) |>
ggplot(aes(x = difference, y = label, fill = group)) +
geom_col() +
geom_vline(xintercept = 0, color = "grey40") +
scale_fill_manual(values = c("Top 5 over" = "#2c5f8a",
"Top 5 under" = "#c0392b",
"Everyone else" = "grey75")) +
labs(
title = "Actual score minus expected score",
x = "Points above (right) or below (left) expectation",
y = NULL, fill = NULL
) +
theme_minimal(base_size = 10) +
theme(legend.position = "top")The chart above shows where everyone ended up. This one shows how they got there. Each line is one player’s running total of points above or below expectation: it starts at zero, rises when the player beats their expected score in a game, and falls when they come up short. Byes and unplayed rounds leave the line flat, since they have no expected score. The top five over- and underperformers are highlighted, and everyone else is in gray. (The report only lists ratings before and after the tournament, so this tracks results against expectation rather than a round-by-round rating.)
round_progress <- rounds |>
select(player_number, round) |>
left_join(games |> select(player_number, round, actual, expected),
by = c("player_number", "round")) |>
mutate(round_diff = replace_na(actual - expected, 0)) |> # byes and unplayed rounds add 0
bind_rows(tibble(player_number = players$player_number, round = 0L, round_diff = 0)) |>
arrange(player_number, round) |>
group_by(player_number) |>
mutate(running_diff = cumsum(round_diff)) |>
ungroup() |>
left_join(elo_results |> select(player_number, player_name), by = "player_number") |>
mutate(group = case_when(
player_number %in% over$player_number ~ "Top 5 over",
player_number %in% under$player_number ~ "Top 5 under",
TRUE ~ "Everyone else"
))
# Name labels at the end of each highlighted line, spaced so they don't overlap
end_labels <- round_progress |>
filter(round == 7, group != "Everyone else") |>
arrange(running_diff) |>
mutate(
label_y = accumulate(running_diff, \(prev, y) max(y, prev + 0.32)),
label_y = label_y - (mean(label_y) - mean(running_diff)),
label = paste0(str_to_title(player_name), " (", sprintf("%+.2f", running_diff), ")"),
.by = group
)
highlight_colors <- c("Top 5 over" = "#2c5f8a", "Top 5 under" = "#c0392b")
ggplot(round_progress, aes(x = round, y = running_diff, group = player_number)) +
geom_hline(yintercept = 0, color = "grey50", linewidth = 0.4) +
geom_line(data = \(d) filter(d, group == "Everyone else"),
color = "grey80", linewidth = 0.5) +
geom_line(data = \(d) filter(d, group != "Everyone else"),
aes(color = group), linewidth = 1.1) +
geom_point(data = \(d) filter(d, group != "Everyone else"),
aes(color = group), size = 1.4) +
geom_segment(data = end_labels,
aes(x = 7.05, xend = 7.35, y = running_diff, yend = label_y, color = group),
linewidth = 0.3) +
geom_text(data = end_labels,
aes(x = 7.4, y = label_y, label = label, color = group),
hjust = 0, size = 3.1) +
scale_color_manual(values = highlight_colors) +
scale_x_continuous(breaks = 0:7, limits = c(0, 10.2), expand = c(0.01, 0)) +
labs(
title = "Running points above or below expectation, round by round",
x = "Round (0 = start)", y = "Points vs. expectation", color = NULL
) +
theme_minimal(base_size = 11) +
theme(legend.position = "none",
panel.grid.minor = element_blank())Most of the gray lines stay close to zero, meaning those players scored about what their ratings predicted. The top overperformers built their lead over several rounds rather than in one game. Aditya Bajaj, for example, lost his first game, beat a lower-rated player, and then won five straight against higher-rated opponents. Jacob Alexander Lavalley’s line has a different shape: with a 377 rating he was expected to lose almost every game, so his losses barely moved the line and his three wins account for nearly all of his gain. The underperformers mostly slide over several rounds too, which points to a whole tournament below their rating rather than one bad game.
elo_results |>
arrange(desc(difference)) |>
transmute(player_number, player_name = str_to_title(player_name), state, pre_rating,
total_points, games_played, actual_score,
expected_score = round(expected_score, 2), difference = round(difference, 2)) |>
kable(col.names = c("#", "Player", "State", "Pre-rating", "Total points",
"Games", "Actual", "Expected", "Difference"),
caption = "All 64 players, sorted by difference")| # | Player | State | Pre-rating | Total points | Games | Actual | Expected | Difference |
|---|---|---|---|---|---|---|---|---|
| 3 | Aditya Bajaj | MI | 1384 | 6.0 | 7 | 6.0 | 1.95 | 4.05 |
| 15 | Zachary James Houghton | MI | 1220 | 4.5 | 7 | 4.5 | 1.37 | 3.13 |
| 10 | Anvit Rao | MI | 1365 | 5.0 | 7 | 5.0 | 1.94 | 3.06 |
| 46 | Jacob Alexander Lavalley | MI | 377 | 3.0 | 7 | 3.0 | 0.04 | 2.96 |
| 9 | Stefano Lee | ON | 1411 | 5.0 | 7 | 5.0 | 2.29 | 2.71 |
| 2 | Dakshesh Daruri | MI | 1553 | 6.0 | 7 | 6.0 | 3.78 | 2.22 |
| 52 | Ethan Guo | MI | 935 | 2.5 | 7 | 2.5 | 0.30 | 2.20 |
| 51 | Tejas Ayyagari | MI | 1011 | 2.5 | 7 | 2.5 | 1.03 | 1.47 |
| 24 | Michael R Aldrich | MI | 1229 | 4.0 | 7 | 4.0 | 2.55 | 1.45 |
| 37 | Amiyatosh Pwnanandam | MI | 980 | 3.5 | 5 | 2.0 | 0.77 | 1.23 |
| 5 | Hanshi Zuo | MI | 1655 | 5.5 | 7 | 5.5 | 4.38 | 1.12 |
| 1 | Gary Hua | ON | 1794 | 6.0 | 7 | 6.0 | 5.16 | 0.84 |
| 4 | Patrick H Schilling | MI | 1716 | 5.5 | 7 | 5.5 | 4.74 | 0.76 |
| 59 | Sean M Mc Cormick | MI | 853 | 2.0 | 6 | 1.0 | 0.41 | 0.59 |
| 50 | Shivam Jha | MI | 1056 | 2.5 | 6 | 2.0 | 1.42 | 0.58 |
| 58 | Viraj Mohile | MI | 917 | 2.0 | 6 | 1.0 | 0.43 | 0.57 |
| 61 | Jezzel Farkas | ON | 955 | 1.5 | 7 | 1.5 | 0.97 | 0.53 |
| 7 | Gary Dee Swathell | MI | 1649 | 5.0 | 7 | 5.0 | 4.58 | 0.42 |
| 60 | Julia Shen | MI | 967 | 1.5 | 5 | 1.0 | 0.60 | 0.40 |
| 38 | Brian Liu | MI | 1423 | 3.0 | 6 | 2.5 | 2.13 | 0.37 |
| 14 | Bradley Shaw | MI | 1610 | 4.5 | 7 | 4.5 | 4.18 | 0.32 |
| 36 | Siddharth Jha | MI | 1355 | 3.5 | 6 | 3.0 | 2.70 | 0.30 |
| 28 | Sofia Adina Stanescu-Bellu | MI | 1507 | 3.5 | 7 | 3.5 | 3.31 | 0.19 |
| 62 | Ashwin Balaji | MI | 1530 | 1.0 | 1 | 1.0 | 0.88 | 0.12 |
| 34 | Michael Jeffery Thomas | MI | 1399 | 3.5 | 7 | 3.5 | 3.44 | 0.06 |
| 40 | Forest Zhang | MI | 1348 | 3.0 | 7 | 3.0 | 2.94 | 0.06 |
| 23 | Alan Bui | ON | 1363 | 4.0 | 7 | 4.0 | 3.94 | 0.06 |
| 6 | Hansen Song | OH | 1686 | 5.0 | 7 | 5.0 | 4.94 | 0.06 |
| 8 | Ezekiel Houghton | MI | 1641 | 5.0 | 7 | 5.0 | 5.03 | -0.03 |
| 44 | Justin D Schilling | MI | 1199 | 3.0 | 6 | 2.0 | 2.07 | -0.07 |
| 56 | Marisa Ricci | MI | 1153 | 2.0 | 5 | 1.0 | 1.08 | -0.08 |
| 12 | Kenneth J Tack | MI | 1663 | 4.5 | 6 | 4.0 | 4.11 | -0.11 |
| 32 | Joshua Philip Mathews | ON | 1441 | 3.5 | 7 | 3.5 | 3.72 | -0.22 |
| 16 | Mike Nikitin | MI | 1604 | 4.0 | 5 | 3.5 | 3.80 | -0.30 |
| 57 | Michael Lu | MI | 1092 | 2.0 | 6 | 1.0 | 1.30 | -0.30 |
| 21 | Dinh Dang Bui | ON | 1563 | 4.0 | 7 | 4.0 | 4.32 | -0.32 |
| 19 | Dipankar Roy | MI | 1564 | 4.0 | 7 | 4.0 | 4.33 | -0.33 |
| 41 | Kyle William Murphy | MI | 1403 | 3.0 | 4 | 2.0 | 2.36 | -0.36 |
| 55 | Alex Kong | MI | 1186 | 2.0 | 6 | 1.0 | 1.44 | -0.44 |
| 13 | Torrance Henry Jr | MI | 1666 | 4.5 | 7 | 4.5 | 4.95 | -0.45 |
| 27 | Gaurav Gidwani | MI | 1552 | 3.5 | 6 | 3.5 | 4.00 | -0.50 |
| 49 | Michael J Martin | MI | 1291 | 2.5 | 5 | 2.0 | 2.54 | -0.54 |
| 18 | David Sundeen | MI | 1600 | 4.0 | 7 | 4.0 | 4.59 | -0.59 |
| 26 | Max Zhu | ON | 1579 | 3.5 | 7 | 3.5 | 4.10 | -0.60 |
| 39 | Joel R Hendon | MI | 1436 | 3.0 | 7 | 3.0 | 3.62 | -0.62 |
| 17 | Ronald Grzegorczyk | MI | 1629 | 4.0 | 7 | 4.0 | 4.66 | -0.66 |
| 47 | Eric Wright | MI | 1362 | 2.5 | 7 | 2.5 | 3.19 | -0.69 |
| 53 | Jose C Ybarra | MI | 1393 | 2.0 | 3 | 1.0 | 1.72 | -0.72 |
| 11 | Cameron William Mc Leman | MI | 1712 | 4.5 | 7 | 4.5 | 5.34 | -0.84 |
| 63 | Thomas Joseph Hosmer | MI | 1175 | 1.0 | 5 | 0.5 | 1.43 | -0.93 |
| 22 | Eugene L Mcclure | MI | 1555 | 4.0 | 6 | 3.5 | 4.48 | -0.98 |
| 48 | Daniel Khain | MI | 1382 | 2.5 | 5 | 1.5 | 2.53 | -1.03 |
| 29 | Chiedozie Okorie | MI | 1602 | 3.5 | 6 | 3.5 | 4.60 | -1.10 |
| 20 | Jason Zheng | MI | 1595 | 4.0 | 7 | 4.0 | 5.13 | -1.13 |
| 33 | Jade Ge | MI | 1449 | 3.5 | 7 | 3.5 | 4.64 | -1.14 |
| 64 | Ben Li | MI | 1163 | 1.0 | 7 | 1.0 | 2.27 | -1.27 |
| 43 | Robert Glen Vasey | MI | 1283 | 3.0 | 7 | 3.0 | 4.33 | -1.33 |
| 45 | Derek Yan | MI | 1242 | 3.0 | 7 | 3.0 | 4.37 | -1.37 |
| 35 | Joshua David Lee | MI | 1438 | 3.5 | 7 | 3.5 | 4.96 | -1.46 |
| 31 | Rishi Shetty | MI | 1494 | 3.5 | 7 | 3.5 | 5.09 | -1.59 |
| 42 | Jared Ge | MI | 1332 | 3.0 | 7 | 3.0 | 5.01 | -2.01 |
| 54 | Larry Hodge | MI | 1270 | 2.0 | 6 | 1.0 | 3.40 | -2.40 |
| 30 | George Avery Jones | ON | 1522 | 3.5 | 7 | 3.5 | 6.02 | -2.52 |
| 25 | Loren Schwiebert | MI | 1745 | 3.5 | 7 | 3.5 | 6.28 | -2.78 |
The biggest overperformer was Aditya Bajaj, who started with a pre-tournament rating of 1384 and was expected to score about 1.9 points, but actually scored 6, beating expectation by 4.05 points. The biggest underperformer was Loren Schwiebert (rated 1745), who was expected to score about 6.3 points and scored 3.5.
Four of the five overperformers came in rated below the middle of the field, and the fifth (Stefano Lee, 1411) was right at it (the median pre-rating is 1407), while the underperformers are more of a mix. Some of the overperformance is probably real improvement, but some of it is how ratings work. A low or provisional rating is a rough estimate, so a player whose rating hasn’t caught up to their actual skill will look like a big overperformer. Jacob Alexander Lavalley is the clearest example: his pre-tournament rating of 377 was provisional, based on only 3 games, so the formula expected him to score almost nothing, and his rating jumped to 1076 after this tournament. Comparing each player’s difference to their post-tournament rating change would be a good next step, since the USCF uses a version of this same expected-score math to update ratings.
The full results are saved as elo-results.csv.
The tournament report was supplied by the course instructor and is stored in my repo as tournament-results.txt (from Project 1).
Claude (Opus 5.5, accessed through claude.ai) helped write the code and text in this document, including extending the Project 1 parser to keep each round’s result, the expected-score calculation and checks, the over/underperformer tables and charts (including the round-by-round chart), the decision to compare expected score against points from games actually played, and the document’s styling and table formatting.
Anthropic. (2026). Claude (Opus 5.5) [Large language model]. https://claude.ai/. Accessed October 3–4, 2026.