library(stringr)
library(knitr)
library(kableExtra)
Elo scores and Expected Outcomes
This project parses a particular chess crosstable and it translates the results into 2 metrics meant to measure each players performance in relation to their expected perfromance
The expected performance is measured as a function of the Elo scores of each player and the Elo scores of each of that players oppontents.
The Elo rating system is a standardized method of calculating relative skill of players in games like chess. Elo is not an acronym. It is named after its creator Arpad Elo. It is a only a function of the result so a “close” victory counts the same as an “easy” victory
There may be variations but wikipedia reports the following equation to determine ones Elo rating.
\[ELO \ Rating \ = \ \frac{\sum{OpponentsRatings} + 400 * (wins-losses)}{total \ games}\]
We will use the Expected outcome formula shown below.
\[E_a \ = \ \frac{1}{1 + 10^{\frac{(R_b \ - \ R_a)}{400}}} \]
Below is the function to calculate Expected Outcome which returns some number between 0 and 1 reflective of the rating differential between the 2 players.
calculateExpectedOutcome <- function(rating, opp_rating) {
expected_outcome<-1/(1 + 10^((opp_rating - rating)/400))
return(expected_outcome)
}
In addition, by comparison, we will calculate an Improvement Percent which is an intuitive metric of the delta Elo rating over starting Elo rating as shown below.
\[ImprovementPercent \ = \ \frac{PostRating \ - \ PreRating}{PreRating} \]
Below is the function which gathers the opponents data and calculates both metrics.
calculatePerformanceMetrics <- function(chess_df) {
# note length(chess_df) is the columns = 7
for (row in 1:nrow(chess_df)) {
expected_points<-0
tot_ratings<-0
tot_points<-0
ids<-str_extract_all(chess_df[row,"opponent_ids"], "\\d+")
n_ids<-length(ids[[1]])
# calculate average pre rating
pre_rating=as.integer(chess_df[row,"pre_rating"])
post_rating=as.integer(chess_df[row,"post_rating"])
player=chess_df[row,"player"]
tot_points<-chess_df[row,"total_points"]
for (id in ids[[1]]) {
id<-as.integer(id)
if (id==0) {
next
}
x_df<-subset(chess_df,player_id==id)
his_rating<-x_df$pre_rating
opp_pre_rating<-as.integer(subset(chess_df,player_id==id)$pre_rating)
tot_ratings<-tot_ratings+opp_pre_rating
expected_outcome<-calculateExpectedOutcome(pre_rating, opp_pre_rating)
expected_points<-expected_points+expected_outcome
}
avg_rating=tot_ratings/n_ids
chess_df[row,"opp_avg_pre_rating"]=avg_rating
chess_df[row,"expected_points"]=expected_points
chess_df[row,"delta_by_exp_outcome"]=tot_points-expected_points
i<-(post_rating-pre_rating)/pre_rating
chess_df[row,"delta_by_pct"]=i
}
return (chess_df)
}
Create DataFrame to hold our parsed data.
# create the dataframe schema
crosstable_df <- data.frame(
player_id=integer(),
player=character(),
state=character(),
pre_rating=integer(),
post_rating=integer(),
total_points=double(),
opponent_ids=character(), # one string containing an array of opponent ids
opp_avg_pre_rating=integer(), # 3 extra fields to calculate performance metrics
expected_points=double(),
delta_by_exp_outcome=double(),
delta_by_pct=double()
)
Open the file.
data_file<-'https://raw.githubusercontent.com/TheReallyBigApple/CunyAssignments/main/DATA607/tournamentinfo.txt'
con = file(data_file, "r")
header_processed<-FALSE
Main Loop. Read 3 lines at a time. Skip the headers. Parse into dataframe.
while ( TRUE ) {
# read 3 lines at a time
line = readLines(con, n = 3)
# if we didnt read 3 lines, then it must be EOF
if ( length(line) < 3 ) {
break
}
# first time through. Skip the column headers
if ( header_processed == FALSE ) {
header_processed=TRUE
next
}
# pipe delimited
line2<-unlist(strsplit(line[2], split='\\|'))
line3<-unlist(strsplit(line[3], split='\\|'))
player_id<-as.integer(line2[1])
player<-line2[2]
state<-line3[1]
# to properly isolate the rating, we need to know exactly what the rule is
#
# the pre rating follows "R: " which may or may not have a space
# but some are suffixed with a P, some are only 3 digits
#
# the post rating follows "->" which may or may not have a space
# below regex matches any# of spaces+digits following R:
# the ?<= construct is what omits the "/ R:" from being part of the match
# once any non-digit is found, the match ends
pre_rating<-str_extract(line3[2], "(?<=/ R:)\\s*(\\d+)")
# below regex matches any consective digits after a "->"
post_rating<-str_extract(line3[2], "(?<=->)\\s*(\\d+)")
total_points<-as.double(line2[3])
opponent_ids<-str_c(line2[4],line2[5],line2[6], line2[7],line2[8],line2[9],line2[10])
# remove everything but the numbers
opponent_ids<-str_replace_all(opponent_ids, "\\D", " ")
ids<-str_extract_all(opponent_ids, "\\d+")
# we will calculate these in calculatePerformanceMetrics()
opp_avg_pre_rating=0
expected_points=0
delta_by_pct=0
delta_by_exp_outcome<-0
crosstable_df<-rbind(crosstable_df,data.frame(
player_id=player_id,
player=player,
state=state,
pre_rating=pre_rating,
post_rating=post_rating,
total_points=total_points,
opponent_ids=opponent_ids,
opp_avg_pre_rating=opp_avg_pre_rating,
expected_points=expected_points,
delta_by_exp_outcome=delta_by_exp_outcome,
delta_by_pct=delta_by_pct
))
}
Close connection.
close(con)
Calculate.
crosstable_df<- calculatePerformanceMetrics(crosstable_df)
Format the numbers.
crosstable_df$opp_avg_pre_rating<-round(crosstable_df$opp_avg_pre_rating,0)
crosstable_df$delta_by_exp_outcome<-round(crosstable_df$delta_by_exp_outcome,2)
crosstable_df$delta_by_pct<-round(crosstable_df$delta_by_pct,4)
Let plot the 2 metrics together. Well scale the percentage to equate them to points delta.
y <- crosstable_df$delta_by_exp_outcome
x <- crosstable_df$delta_by_pct*25
plot(y, ann = FALSE, type = "n")
# lines(y, col = "green4", lty = "dotted")
points(x, bg = "limegreen", pch = 21)
points(y, bg = "IndianRed4", pch = 21)
legend("topright", c("Pct", "Exp Out"), cex=0.8, col=c("limegreen","IndianRed4"), lty=1:1, lwd=2, bty="n");
title(main = "Improvement Measurements",
xlab = "Player",
ylab = "Performance",
col.main = "blue", col.lab = gray(.8),
cex.main = 1.2, cex.lab = 1.0, font.main = 4, font.lab = 3)
The 2 metrics trend together but they are not mathematically equivalent.
Let see the top 5 performers vs Expected Outcome.
# use minus sign to sort descending
df_sorted<-crosstable_df[order(-crosstable_df$delta_by_exp_outcome),]
#select the fields you want
df_few_fields<-df_sorted[c("player","pre_rating", "post_rating", "opp_avg_pre_rating", "total_points","delta_by_exp_outcome")]
top6<-head(df_few_fields)
# rename the columns
colnames(top6)<-c("player","pre", "post", "opp", "points","by_exp_outcome")
kable(top6, caption="",row.names = FALSE,format="simple", booktabs=TRUE)
| player | pre | post | opp | points | by_exp_outcome |
|---|---|---|---|---|---|
| ADITYA BAJAJ | 1384 | 1640 | 1564 | 6.0 | 4.05 |
| ZACHARY JAMES HOUGHTON | 1220 | 1416 | 1484 | 4.5 | 3.13 |
| ANVIT RAO | 1365 | 1544 | 1554 | 5.0 | 3.06 |
| JACOB ALEXANDER LAVALLEY | 377 | 1076 | 1358 | 3.0 | 2.96 |
| AMIYATOSH PWNANANDAM | 980 | 1077 | 1385 | 3.5 | 2.73 |
| STEFANO LEE | 1411 | 1564 | 1523 | 5.0 | 2.71 |
Let see the top 5 performers by percentage improvement.
df_sorted<-crosstable_df[order(-crosstable_df$delta_by_pct),]
df_few_fields<-df_sorted[c("player","pre_rating", "post_rating", "opp_avg_pre_rating", "total_points","delta_by_pct")]
top6<-head(df_few_fields)
colnames(top6)<-c("player","pre", "post", "opp", "points","by_pct")
kable(top6, caption="",row.names = FALSE,format="simple", booktabs=TRUE)
| player | pre | post | opp | points | by_pct |
|---|---|---|---|---|---|
| JACOB ALEXANDER LAVALLEY | 377 | 1076 | 1358 | 3.0 | 1.8541 |
| ADITYA BAJAJ | 1384 | 1640 | 1564 | 6.0 | 0.1850 |
| ETHAN GUO | 935 | 1092 | 1495 | 2.5 | 0.1679 |
| ZACHARY JAMES HOUGHTON | 1220 | 1416 | 1484 | 4.5 | 0.1607 |
| ANVIT RAO | 1365 | 1544 | 1554 | 5.0 | 0.1311 |
| STEFANO LEE | 1411 | 1564 | 1523 | 5.0 | 0.1084 |
Both metrics represent Jacob and Zachary and Aditya did well, but the metric are not mathematically equivalent either.
There may be several factors that cause deviations. One is the scaling. Jacob started with a horrible rating of 377 and scored 3 points. His post rating was then 1076 which greatly inflated the simple percent improvement metric.
Write our final data to a csv file in the default working directory.
If you are not sure where that is, type getwd().
# write data to working directory ( see getwd() )
write.csv(crosstable_df,"chess_data.csv",row.names = FALSE)