Chess Data Analysis

Author

Ag.

Published

June 9, 2025

Introduction

The main goal behind this is to check whether White has an advantage in Chess. We will use some of the concepts learnt in IPS to test and estimate this effect.

Data set

The data is taken from here

knitr::opts_chunk$set(echo = TRUE, dev = "ragg_png", dpi = 400,warning = FALSE, message = FALSE)

library(tidyverse)
library(viridis)
library(knitr)
theme_set(theme(legend.position = "none"))

dat <- read.csv("./club_games_data.csv")
dat %>% glimpse()
Rows: 66,879
Columns: 14
$ white_username <chr> "-Amos-", "-Amos-", "-Amos-", "enhmandah", "-Amos-", "-…
$ black_username <chr> "miniman2804", "koltcho69", "enhmandah", "-Amos-", "Sha…
$ white_id       <chr> "https://api.chess.com/pub/player/-amos-", "https://api…
$ black_id       <chr> "https://api.chess.com/pub/player/miniman2804", "https:…
$ white_rating   <int> 1708, 1726, 1727, 819, 1729, 1744, 1554, 1744, 1856, 10…
$ black_rating   <int> 1608, 1577, 842, 1727, 1116, 1565, 1744, 1554, 1726, 17…
$ white_result   <chr> "win", "win", "win", "checkmated", "win", "win", "timeo…
$ black_result   <chr> "checkmated", "resigned", "resigned", "win", "resigned"…
$ time_class     <chr> "daily", "daily", "daily", "daily", "daily", "daily", "…
$ time_control   <chr> "1/259200", "1/172800", "1/172800", "1/172800", "1/1728…
$ rules          <chr> "chess", "chess", "chess", "chess", "chess", "chess", "…
$ rated          <chr> "True", "True", "True", "True", "True", "True", "True",…
$ fen            <chr> "r2r4/p2p1p1p/b6R/n1p1kp2/2P2P2/3BP3/PP5P/4K2R b K f3 1…
$ pgn            <chr> "[Event \"Enjoyable games 2 - Round 1\"]\n[Site \"Chess…

For us, the important variables are

  1. time_class
  2. white / black result
  3. white / black ratings
  4. rules: we only want the normal chess variant

From the above, we will extract the following information:

  1. Winner: White / Black / Draw
  2. White / Black Ratings
  3. time_class
dat <- dat %>%
    filter(rules == "chess") %>%
    mutate(winner = if_else(
        black_result == "win",
        "black",
        if_else(white_result == "win", "white", "draw")
    )) %>%
    select(black_rating, white_rating, winner, time_class) %>%
    mutate(
        winner = factor(winner, levels = c("white", "black", "draw")),
        time_class = factor(time_class, levels = c("bullet", "blitz", "rapid", "daily"))
    )

dat %>% glimpse()
Rows: 65,778
Columns: 4
$ black_rating <int> 1608, 1577, 842, 1727, 1116, 1565, 1744, 1554, 1726, 1727…
$ white_rating <int> 1708, 1726, 1727, 819, 1729, 1744, 1554, 1744, 1856, 1099…
$ winner       <fct> white, white, white, black, white, white, black, white, w…
$ time_class   <fct> daily, daily, daily, daily, daily, daily, daily, daily, d…
dat %>% 
    group_by(winner) %>% 
    summarise(Percentage = n() * 100 / nrow(dat),
              count = n()) %>% kable()
winner Percentage count
white 49.838852 32783
black 46.667579 30697
draw 3.493569 2298
dat %>%
    group_by(winner) %>%
    summarise(Percentage = n() * 100 / nrow(dat), count = n()) %>%
    ggplot(aes(winner, Percentage, fill = winner)) +
    geom_col(color = "black") +
    coord_flip() +
    scale_fill_manual(values = list(
        "white" = "white",
        "black" = "black",
        "draw" = "grey50"
    )) + theme(axis.title.y = element_blank()) + 
    ggtitle("Percentage of Wins")

We will discard the draws as it is easier to model binary outcomes.

dat <- 
    dat %>% filter(winner != "draw")

dat %>% glimpse()
Rows: 63,480
Columns: 4
$ black_rating <int> 1608, 1577, 842, 1727, 1116, 1565, 1744, 1554, 1726, 1727…
$ white_rating <int> 1708, 1726, 1727, 819, 1729, 1744, 1554, 1744, 1856, 1099…
$ winner       <fct> white, white, white, black, white, white, black, white, w…
$ time_class   <fct> daily, daily, daily, daily, daily, daily, daily, daily, d…
summary(dat)
  black_rating   white_rating    winner       time_class   
 Min.   : 100   Min.   : 100   white:32783   bullet:21727  
 1st Qu.: 978   1st Qu.: 979   black:30697   blitz :27665  
 Median :1252   Median :1252   draw :    0   rapid :12256  
 Mean   :1248   Mean   :1249                 daily : 1832  
 3rd Qu.:1523   3rd Qu.:1523                               
 Max.   :3172   Max.   :3172                               
dat %>% sample_n(10) %>% kable()
black_rating white_rating winner time_class
1267 1279 black blitz
1658 1677 black rapid
1183 1226 white rapid
1061 895 black rapid
1010 981 white blitz
347 342 white blitz
1345 1256 black blitz
1954 1944 black blitz
438 523 black blitz
640 686 white bullet
dat %>% 
    group_by(winner) %>% 
    summarise(Percentage = n() * 100 / nrow(dat),
              count = n()) %>% kable()
winner Percentage count
white 51.64304 32783
black 48.35696 30697
dat %>%
    group_by(winner) %>%
    summarise(Percentage = n() * 100 / nrow(dat), count = n()) %>%
    ggplot(aes(winner, Percentage, fill = winner)) +
    geom_col(color = "black") + theme_minimal()+
    coord_flip() +
    scale_fill_manual(values = list(
        "white" = "white",
        "black" = "black",
        "draw" = "grey50"
    )) + theme(axis.title.y = element_blank()) +
    ggtitle("Percentage of Wins") + theme(
        text = element_text(family = "Cabin", colour = "#4C3232"),
        legend.position = "none",
        axis.title.y = element_blank(),
        plot.subtitle = ggtext::element_textbox_simple(
            size = 16,
            vjust = 1,
            margin = margin(0, 0, 12, 0)
        ),
        plot.title = element_text(
            family = "OPTIAuvantGothic-DemiBold",
            size = 24,
            face = "bold",
            colour = "#200000",
            margin = margin(12, 0, 12, 0)
        ),
        axis.text = element_text(colour = "#4C3232")
    ) +
    scale_y_continuous(expand = expansion(c(0, 0.02))) +
    ggtext::geom_textbox(
        aes(
            label = paste0(
                "<span style='font-size:32pt'><br>",
                round(Percentage, 2),
                "% <br></span>",
                winner, " wins    "
            ),
            hjust =  case_when(Percentage < 30 ~ 0, TRUE ~ 1),
            halign = case_when(Percentage < 30 ~ 0, TRUE ~ 1),
            colour = case_when(Percentage < 30 ~ "#4C3232", TRUE ~ "grey50")
        ),
        vjust = 0.35,
        fill = NA,
        box.colour = NA,
        family = "Cabin",
        size = 7,
        fontface = "bold"
    ) +
    scale_colour_identity() +
    theme(
        axis.text.y = element_blank(),
        plot.title.position = "plot",
        axis.text.x = element_blank(),
        axis.title.x = element_blank(),
        panel.grid = element_blank()
    )
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database

dat %>%
    mutate(white_win = ifelse(winner == "white", 1, 0)) %>%
    ggplot(aes(time_class, white_win)) +
    stat_summary(fun = mean, geom = "point") +
    stat_summary(fun.data = mean_cl_boot,
                 geom = "errorbar",
                 width = 0.2) +
    theme(legend.position = "top") +
    labs(x = "", y = "Observed Probabilty of White Wins") + 
    ggtitle("Proportions of White Wins Across Time Classes") 

Methodology

We will fit 3 models of varying complexity and select the one that results in the best predictions.

First, we need to change the data format to allow us to estimate the white advantage. Basically, we want to see how much does the probability of success of player changes if he plays white instead of black.

convert_dataframe <- function(df) {
    random_selection <- sample(c('white', 'black'), nrow(df), replace = TRUE)
    p1_white <- ifelse(random_selection == 'white', 1, 0)
    
    p1_rating <- p1_white * df$white_rating + (1 - p1_white) * df$black_rating
    
    p2_rating <- p1_white * df$black_rating + (1 - p1_white) * df$white_rating
    
    p1_winner <- ifelse((random_selection == 'white' &
                             df$winner == 'white') |
                            (random_selection == 'black' &
                                 df$winner == 'black'),
                        1,
                        0
    )
    
    df %>%
        transmute(p1_rating, p2_rating, p1_white, time_class, p1_winner)
}

df <- convert_dataframe(dat)
df %>% head() %>% knitr::kable()
p1_rating p2_rating p1_white time_class p1_winner
1708 1608 1 daily 1
1726 1577 1 daily 1
1727 842 1 daily 1
1727 819 0 daily 1
1729 1116 1 daily 1
1744 1565 1 daily 1
df %>% glimpse()
Rows: 63,480
Columns: 5
$ p1_rating  <dbl> 1708, 1726, 1727, 1727, 1729, 1744, 1744, 1554, 1726, 1727,…
$ p2_rating  <dbl> 1608, 1577, 842, 819, 1116, 1565, 1554, 1744, 1856, 1099, 1…
$ p1_white   <dbl> 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0,…
$ time_class <fct> daily, daily, daily, daily, daily, daily, daily, daily, dai…
$ p1_winner  <dbl> 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0,…

From this, extract the following information:

  1. p1_white: to get white’s advantage.

  2. time_class: Different types of games may have a different effect

  3. Average_Ratings: We will use the average ratings of the 2 players as a proxy for high the level of the game is. Average ratings / 100.

  4. Log Odds computed from Ratings difference based on the Elo formula

The way this works is that

\[ P(A) = \frac{1}{1 + 10^{(R_a - R_b)/400}} \]

So, we can get the odds ratio by simply:

\[Odds_A = \frac{P(A)}{1-P(A)}\]

We will take the log odds of this and use it as a linear predictor of the log odds.

df %>% 
    mutate(
        P_p1 = 1 / (1 + 10^(-(p1_rating - p2_rating)/400)),
        P_p2 = 1 - P_p1,
        log_odds = log(P_p1) - log(P_p2),
        average_ratings = (p1_rating + p2_rating) / 2,
        p1_white = factor(p1_white),
        p1_winner = factor(p1_winner)
    ) %>% select(
        p1_winner,
        p1_white,
        log_odds,
        average_ratings,
        time_class
    ) -> elo_df

elo_df %>% 
    glimpse()
Rows: 63,480
Columns: 5
$ p1_winner       <fct> 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, …
$ p1_white        <fct> 1, 1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, …
$ log_odds        <dbl> 0.57564627, 0.85771295, 5.09446952, 5.22686816, 3.5287…
$ average_ratings <dbl> 1658.0, 1651.5, 1284.5, 1273.0, 1422.5, 1654.5, 1649.0…
$ time_class      <fct> daily, daily, daily, daily, daily, daily, daily, daily…
elo_df %>% 
    summary()
 p1_winner p1_white     log_odds          average_ratings   time_class   
 0:31756   0:31813   Min.   :-10.724290   Min.   : 111.0   bullet:21727  
 1:31724   1:31667   1st Qu.: -0.224502   1st Qu.: 983.4   blitz :27665  
                     Median :  0.000000   Median :1252.0   rapid :12256  
                     Mean   :  0.002473   Mean   :1248.6   daily : 1832  
                     3rd Qu.:  0.224502   3rd Qu.:1521.5                 
                     Max.   : 11.789236   Max.   :2682.5                 

We will fit the model of increasing complexity on the above predictors. Interaction terms between p1_white and the other variables to see how the white advantage across skill, game, and player differences.

use K-Fold Cross Validation to determine the best model and analyse and visualize those results.

Models

Hi, we are pretty much only using logistic regression models.

That means we are modeling the log odds of player 1 winning:

\[ log(Odds_{p_1}) = log \ \frac{P(P_1 \ wins)}{1 - P(P_1 \ wins)} \]

library(caret)
train_control <- trainControl(method = "cv", number = 5)

Basic Model

attach(elo_df)
library(sjPlot)

fit.1 <- glm(p1_winner ~ p1_white + log_odds + average_ratings,
             data = elo_df,
             family = binomial())

fit.1 %>% summary()

Call:
glm(formula = p1_winner ~ p1_white + log_odds + average_ratings, 
    family = binomial(), data = elo_df)

Coefficients:
                  Estimate Std. Error z value Pr(>|z|)    
(Intercept)     -1.044e-01  3.012e-02  -3.467 0.000526 ***
p1_white1        1.460e-01  1.719e-02   8.493  < 2e-16 ***
log_odds         1.713e+00  2.257e-02  75.909  < 2e-16 ***
average_ratings  2.225e-05  2.204e-05   1.010 0.312569    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 88002  on 63479  degrees of freedom
Residual deviance: 77190  on 63476  degrees of freedom
AIC: 77198

Number of Fisher Scoring iterations: 5

Interaction Effects

fit.2 <- glm(p1_winner ~ (p1_white + log_odds + average_ratings)^2,
             data = elo_df,
             family = binomial())

fit.2 %>% summary()

Call:
glm(formula = p1_winner ~ (p1_white + log_odds + average_ratings)^2, 
    family = binomial(), data = elo_df)

Coefficients:
                            Estimate Std. Error z value Pr(>|z|)    
(Intercept)               -1.108e-01  4.159e-02  -2.665  0.00771 ** 
p1_white1                  1.527e-01  5.861e-02   2.606  0.00915 ** 
log_odds                   2.858e+00  7.564e-02  37.783  < 2e-16 ***
average_ratings            2.675e-05  3.138e-05   0.852  0.39399    
p1_white1:log_odds        -2.721e-02  4.452e-02  -0.611  0.54110    
p1_white1:average_ratings -5.078e-06  4.427e-05  -0.115  0.90868    
log_odds:average_ratings  -8.742e-04  5.094e-05 -17.161  < 2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 88002  on 63479  degrees of freedom
Residual deviance: 76907  on 63473  degrees of freedom
AIC: 76921

Number of Fisher Scoring iterations: 5

Varying Slopes by Time Class

fit.3 <- glm(
    p1_winner ~ (p1_white + log_odds + average_ratings) ^ 2 + p1_white * time_class,
    data = elo_df,
    family = binomial()
)

fit.3 %>% summary()

Call:
glm(formula = p1_winner ~ (p1_white + log_odds + average_ratings)^2 + 
    p1_white * time_class, family = binomial(), data = elo_df)

Coefficients:
                            Estimate Std. Error z value Pr(>|z|)    
(Intercept)               -5.707e-02  4.634e-02  -1.231   0.2181    
p1_white1                  1.102e-01  6.516e-02   1.691   0.0908 .  
log_odds                   2.859e+00  7.566e-02  37.784   <2e-16 ***
average_ratings            1.850e-05  3.152e-05   0.587   0.5574    
time_classblitz           -6.580e-02  2.775e-02  -2.371   0.0177 *  
time_classrapid           -7.831e-02  3.468e-02  -2.258   0.0239 *  
time_classdaily            8.524e-02  9.884e-02   0.862   0.3885    
p1_white1:log_odds        -2.738e-02  4.452e-02  -0.615   0.5385    
p1_white1:average_ratings  3.127e-06  4.443e-05   0.070   0.9439    
log_odds:average_ratings  -8.745e-04  5.095e-05 -17.163   <2e-16 ***
p1_white1:time_classblitz  6.141e-02  3.926e-02   1.564   0.1178    
p1_white1:time_classrapid  4.478e-02  4.902e-02   0.913   0.3610    
p1_white1:time_classdaily -2.500e-01  1.400e-01  -1.786   0.0742 .  
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 88002  on 63479  degrees of freedom
Residual deviance: 76894  on 63467  degrees of freedom
AIC: 76920

Number of Fisher Scoring iterations: 5
fit.4 <- glm(p1_winner ~ (p1_white + log_odds + average_ratings + time_class)^2,
             data = elo_df,
             family = binomial())

fit.4 %>% summary()

Call:
glm(formula = p1_winner ~ (p1_white + log_odds + average_ratings + 
    time_class)^2, family = binomial(), data = elo_df)

Coefficients:
                                  Estimate Std. Error z value Pr(>|z|)    
(Intercept)                     -8.514e-02  6.002e-02  -1.418   0.1560    
p1_white1                        1.137e-01  6.534e-02   1.741   0.0818 .  
log_odds                         2.586e+00  8.467e-02  30.545  < 2e-16 ***
average_ratings                  4.010e-05  4.296e-05   0.933   0.3507    
time_classblitz                  2.199e-04  6.882e-02   0.003   0.9975    
time_classrapid                 -9.562e-02  9.269e-02  -1.032   0.3022    
time_classdaily                  4.189e-02  2.835e-01   0.148   0.8825    
p1_white1:log_odds              -1.878e-02  4.212e-02  -0.446   0.6558    
p1_white1:average_ratings       -4.633e-08  4.474e-05  -0.001   0.9992    
p1_white1:time_classblitz        6.580e-02  3.941e-02   1.670   0.0950 .  
p1_white1:time_classrapid        4.865e-02  4.924e-02   0.988   0.3231    
p1_white1:time_classdaily       -1.821e-01  1.162e-01  -1.568   0.1169    
log_odds:average_ratings        -7.679e-04  5.254e-05 -14.615  < 2e-16 ***
log_odds:time_classblitz         5.607e-01  5.453e-02  10.283  < 2e-16 ***
log_odds:time_classrapid         3.763e-01  6.889e-02   5.462  4.7e-08 ***
log_odds:time_classdaily        -7.369e-01  5.549e-02 -13.279  < 2e-16 ***
average_ratings:time_classblitz -5.532e-05  4.925e-05  -1.123   0.2613    
average_ratings:time_classrapid  1.351e-05  6.771e-05   0.200   0.8419    
average_ratings:time_classdaily  1.863e-05  2.050e-04   0.091   0.9276    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 88002  on 63479  degrees of freedom
Residual deviance: 76495  on 63461  degrees of freedom
AIC: 76533

Number of Fisher Scoring iterations: 6

3 Stupid Models

mods <- list(fit.1, fit.2, fit.3)

Results?

plot_models(mods) +
    scale_fill_discrete(name = "Model", labels = c("1", "2", "3"))+
    ggtitle("Estimates and CI across 3 Models") +
    viridis::scale_color_viridis(discrete = T) + 
    theme(legend.position = "top",  legend.title = element_blank())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.

tab_model(fit.2)
Profiled confidence intervals may take longer time to compute.
  Use `ci_method="wald"` for faster computation of CIs.
  p1_winner
Predictors Odds Ratios CI p
(Intercept) 0.90 0.83 – 0.97 0.008
p1 white [1] 1.17 1.04 – 1.31 0.009
log odds 17.42 15.02 – 20.21 <0.001
average ratings 1.00 1.00 – 1.00 0.394
p1 white [1] × log odds 0.97 0.89 – 1.06 0.541
p1 white [1] × average
ratings
1.00 1.00 – 1.00 0.909
log odds × average
ratings
1.00 1.00 – 1.00 <0.001
Observations 63480
R2 Tjur 0.168

Cross Validation Scores

library(caret)

train_control <- trainControl(method = "cv", number = 10, savePredictions = "final")

res_ <- list()
for (mod in 1:length(mods)) {
    formula = mods[[mod]]$formula
    
    a <- train(
        formula,
        data = elo_df,
        method = "glm",
        family = "binomial",
        trControl = train_control
    )
    
    res_[[mod]] <- a$results
}
res <- do.call(rbind, res_)

res$model <- c(1:3)

res %>%
    mutate(
        ymin = (Accuracy - 1 * AccuracySD)*100,
        # Lower bound (mean - 2 * SD)
        ymax = (Accuracy + 1 * AccuracySD)*100,
        "Accuracy (%)" = Accuracy * 100
    ) %>%
    ggplot(aes(model, `Accuracy (%)`)) + geom_point() + geom_errorbar(aes(ymin = ymin, ymax = ymax)) +
    ggtitle("Accuracy Scores in Cross Validation",
            subtitle = "Mean Accuracy +- 2 SD") + 
    theme(axis.title.x =  element_blank())

anova(fit.1, fit.2, fit.3, fit.4)
Analysis of Deviance Table

Model 1: p1_winner ~ p1_white + log_odds + average_ratings
Model 2: p1_winner ~ (p1_white + log_odds + average_ratings)^3
Model 3: p1_winner ~ (p1_white + log_odds + average_ratings)^2 + p1_white * 
    time_class
Model 4: p1_winner ~ (p1_white + log_odds + average_ratings + time_class)^2
  Resid. Df Resid. Dev Df Deviance Pr(>Chi)    
1     63476      77184                         
2     63472      76897  4   286.90   <2e-16 ***
3     63467      76888  5     8.90   0.1131    
4     63461      76486  6   401.87   <2e-16 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

We choose the second model as it seems to be the best among the 3.

plot_model(fit.4) + coord_flip()
Profiled confidence intervals may take longer time to compute.
  Use `ci_method="wald"` for faster computation of CIs.
Coordinate system already present. Adding new coordinate system, which will
replace the existing one.

Final Model Results

summary(fit.2)

Call:
glm(formula = p1_winner ~ (p1_white + log_odds + average_ratings)^3, 
    family = binomial(), data = elo_df)

Coefficients:
                                     Estimate Std. Error z value Pr(>|z|)    
(Intercept)                        -0.0109238  0.0415819  -0.263  0.79278    
p1_white1                           0.1516555  0.0586142   2.587  0.00967 ** 
log_odds                            2.9696004  0.1054772  28.154  < 2e-16 ***
average_ratings                    -0.0027142  0.0015698  -1.729  0.08380 .  
p1_white1:log_odds                 -0.2423014  0.1453703  -1.667  0.09556 .  
p1_white1:average_ratings          -0.0002179  0.0022141  -0.098  0.92161    
log_odds:average_ratings           -0.0469083  0.0036917 -12.707  < 2e-16 ***
p1_white1:log_odds:average_ratings  0.0061666  0.0050982   1.210  0.22645    
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

(Dispersion parameter for binomial family taken to be 1)

    Null deviance: 88002  on 63479  degrees of freedom
Residual deviance: 76897  on 63472  degrees of freedom
AIC: 76913

Number of Fisher Scoring iterations: 5

3 things are significant: 1. p1_white 2. log_odds 3. log_odds:average_ratings

Inferences:

  1. white advantage.
  2. not really changing with log_odds or game_level :/
  3. log_odds has different effects at different levels.

What do I need to do?

log_odds and predicted probability according to the model.

c <- 3
n <- 1000

df <- tibble(.rows = c*n*2) %>% 
    mutate(
        Ratings_Difference = rep(c(0:(n-1)), c*2),
        Predicted_Probability = 1 / (1 + 10^(-(Ratings_Difference)/400)),
        log_odds = log(Predicted_Probability) - log(1 - Predicted_Probability),
        
        average_ratings = rep(c(rep(750, n), rep(1500, n), rep(2250, n)), 2),
        p1_white = factor(c(rep(1, n*c), rep(0, n*c)))
    )

rbind(
    mutate(df, `Time_Class` = "bullet"),
    mutate(df, `Time_Class` = "blitz"),
    mutate(df, `Time_Class` = "rapid"),
    mutate(df, `Time_Class` = "daily")
) %>% 
    mutate(time_class = factor(Time_Class, levels = c("bullet", "blitz", "rapid", "daily"))) -> df
df %>%
    ggplot(aes(Ratings_Difference, Predicted_Probability)) +
    geom_line() +
    labs(
        title = "Probability of Winning",
        subtitle = latex2exp::TeX("Formula: $P(A) = \\frac{1}{1 + 10^{(R_a - R_b)/400}}$")
    )

df %>%
    mutate(average_ratings = factor(average_ratings, levels = c(750, 1500, 2250))) %>% 
   ggplot(aes(Ratings_Difference, fit_2_prediction, colour = average_ratings)) + 
       geom_line() + facet_wrap(~ p1_white)

test_df <-
    data.frame(
        log_odds = c(-5, 0, 5),
        average_ratings = c(1500, 1500, 1500),
        p1_white = factor(c(1, 1, 1))
    )

predict(fit.1, newdata = test_df, type = "response")
           1            2            3 
3.006482e-06 1.553111e-02 9.880644e-01 
library(gtsummary)

elo_df %>%
    mutate(
        p1_winner = factor(
            p1_winner,
            levels = c(1, 0),
            labels = c("Player 1", "Player 2")
        ),
        white = factor(
            p1_white,
            levels = c(1, 0),
            labels = c("Player 1", "Player 2")
        )
    ) %>%
    select(p1_winner, white, log_odds, average_ratings, time_class) %>%
    gtsummary::tbl_summary(by = p1_winner) %>% add_n() |>  modify_header(label = "**Variable**") |> # update the column header
    bold_labels()

Variable

N

Player 1
N = 31,660

1

Player 2
N = 31,820

1
white 63,480

    Player 1
16,451 (52%) 15,488 (49%)
    Player 2
15,209 (48%) 16,332 (51%)
log_odds 63,480 0.16 (-0.03, 0.37) -0.16 (-0.36, 0.03)
average_ratings 63,480 1,249 (979, 1,516) 1,255 (988, 1,528)
time_class 63,480

    bullet
10,842 (34%) 10,885 (34%)
    blitz
13,802 (44%) 13,863 (44%)
    rapid
6,099 (19%) 6,157 (19%)
    daily
917 (2.9%) 915 (2.9%)
1

n (%); Median (Q1, Q3)

\[ logit(p) \sim p1\_wht+lg\_odds+avg\_rting +\\ p1\_white*log\_odds + p1\_wht*avg\_rting + lg\_odds*avg\_rting \] # Last Visualisations

dat %>%
    group_by(winner) %>%
    summarise(Percentage = n() * 100 / nrow(dat), count = n()) %>%
    ggplot(aes(winner, Percentage, fill = winner)) +
    geom_col(color = "black", linewidth = 2) + theme_minimal()+
    coord_flip() +
    scale_fill_manual(values = list(
        "white" = "white",
        "black" = "black",
        "draw" = "grey50"
    )) + theme(axis.title.y = element_blank()) +
    ggtitle("Percentage of Wins") + theme(
        text = element_text(family = "Cabin", colour = "#4C3232"),
        legend.position = "none",
        axis.title.y = element_blank(),
        plot.subtitle = ggtext::element_textbox_simple(
            size = 16,
            vjust = 1,
            margin = margin(0, 0, 12, 0)
        ),
        plot.title = element_text(
            family = "OPTIAuvantGothic-DemiBold",
            size = 24,
            face = "bold",
            colour = "#200000",
            margin = margin(12, 0, 12, 0)
        ),
        axis.text = element_text(colour = "#4C3232")
    ) +
    scale_y_continuous(expand = expansion(c(0, 0.02))) +
    ggtext::geom_textbox(
        aes(
            label = paste0(
                "<span style='font-size:32pt'><br>",
                round(Percentage, 2),
                "% <br></span>",
                winner, " wins    "
            ),
            hjust =  case_when(Percentage < 30 ~ 0, TRUE ~ 1),
            halign = case_when(Percentage < 30 ~ 0, TRUE ~ 1),
            colour = case_when(Percentage < 30 ~ "#4C3232", TRUE ~ "grey50")
        ),
        vjust = 0.35,
        fill = NA,
        box.colour = NA,
        family = "Cabin",
        size = 7,
        fontface = "bold"
    ) +
    scale_colour_identity() +
    theme(
        axis.text.y = element_blank(),
        plot.title.position = "plot",
        axis.text.x = element_blank(),
        axis.title.x = element_blank(),
        panel.grid = element_blank()
    ) + theme(panel.background = element_rect(fill = "white", color = "white"))
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_stringMetric, as.graphicsAnnot(x$label)): font family
not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call(C_textBounds, as.graphicsAnnot(x$label), x$x, x$y, : font
family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
font family not found in Windows font database

dat %>% 
    ggplot(aes(white_rating, black_rating)) +
    geom_point()

dat %>% sample_n(1000) %>% 
    mutate(average_rating = (white_rating + black_rating) * 0.5) %>%  
    ggplot(aes(white_rating, black_rating)) +
    geom_point(alpha = 0.5) +
    labs(
        x = "White Player Elo Rating",
        y = "Black Player Elo Rating",
        title = "Black vs White Ratings",
        subtitle = "Most People Play Against Similarly Rated"
    ) + geom_smooth(method = "lm", se = TRUE, color = "firebrick", linewidth = 2)
`geom_smooth()` using formula = 'y ~ x'

dat %>%
    mutate(white_win = ifelse(winner == "white", 1, 0)) %>%
    ggplot(aes(time_class, white_win)) +
    stat_summary(fun = mean, geom = "point") +
    stat_summary(fun.data = mean_cl_boot,
                 geom = "errorbar",
                 width = 0.2) +
    theme(legend.position = "top") +
    labs(x = "", y = "Observed Probabilty of White Wins") + 
    ggtitle("Proportions of White Wins", subtitle = "Sample Average and Confidence Interval ") 

dat %>% 
    pivot_longer(
        cols = ends_with("rating"),
        names_to = "player",
        values_to = "rating"
    ) %>% 
    ggplot(
        aes(rating, time_class, fill = player)) + 
    geom_violin() +
    scale_fill_manual(values = c("#333", "#fff")) + coord_flip() +
    labs(x = "Elo Rating", title = "Distribution of Ratings") + 
    theme(axis.title.x = element_blank())

plot_models(mods) +
    scale_fill_discrete(name = "Model", labels = c("1", "2", "3"))+
    ggtitle("Estimates and CI across 3 Models") +
    viridis::scale_color_viridis(discrete = T) + 
    theme(legend.position = "top",  legend.title = element_blank())
Scale for colour is already present.
Adding another scale for colour, which will replace the existing scale.