This markdown file will implement hyperparameter tuning to optimize the weights estimated in this paper. First I will tune over the number of bins, fix that number, and then explore various stepwise optimization strategies to find a best weighting.
Timing note: running this code took my machine ~ 25 minutes.
Setup
Code
library(devtools)load_all()rm(list =ls())load("../../data/district_quals_09_24.rda")# normalize match scores so we can do cross-week and cross-season comparisons# this simplifies a lot of later codematches <-lapply( matches, function(match_df){ scaled <-scale(c(match_df$red_score, match_df$blue_score)) match_df$red_score <- scaled[1:nrow(match_df)] match_df$blue_score <- scaled[(nrow(match_df) +1):length(scaled)]return(match_df) })library(extrafont)library(gt)gos_blue <-"#337DFC"gos_red <-"#F7041A"gos_theme <-theme_bw() +theme(text =element_text(family ="Futura"))# fits WLS on the match dataframesweighted_fit <-function(match_df, w){return(fit_lineup_lm( match_df, responses =list(red = match_df$red_score, blue = match_df$blue_score ), w = w ) )}# fits the standard opr model on a regular match dataframe from TheBlueAlliancefit_opr <-function(match_df){return(fit_lineup_lm( match_df, responses =list(red = match_df$red_score, blue = match_df$blue_score) ) )}normalize_match_number <-function(opr_residual){# we know we can divide the length by 2 because there are 2 alliances/match alliance_corrected <- ((1:length(opr_residual) -1) %% (length(opr_residual) /2)) +1return((alliance_corrected /length(opr_residual)) *2)}fits <-lapply(matches, fit_opr)residuals <-lapply(fits, function(fit){return(residuals(fit))})# match percentile is the match number divided by the number of matchesmatch_percentile <-unlist(lapply(residuals, normalize_match_number), use.names =FALSE)
Bin Number Optimization
The key numbers to cover for the optimization were 2 through 12. 2 bins represents splitting the tournament in half, the fewest divisions we can make, and 12 represents treating each “round” of matches differently. Because teams will not fit precisely into evenly divided rounds, we cannot guarantee with 12 bins that each team gets 1 match in each bin, but on average it represents that division.
Code
# assumes that residuals have been standardizedgenerate_weights <-function(n_bins, residuals, match_percentile){ cuts <-cut(match_percentile, breaks = n_bins) bin_vars <-rep(0, length(unique(cuts)))for (i in1:length(unique(cuts))){ bin_vars[i] <-var(unlist(residuals)[cuts ==levels(cuts)[i]]) }return(1/ bin_vars)}mean_loocvs <-c() # define an empty vectorn_bins <-2:15# define n_binsfor (k in n_bins){ bin_weight_fits <-lapply( matches, weighted_fit, w =generate_weights(k, residuals, match_percentile) ) mean_loocvs <-c(mean_loocvs, mean(sapply(bin_weight_fits, LOOCV)))}names(mean_loocvs) <-paste0("Bins = ", n_bins)viz <-data.frame(bins = n_bins, mean_loocvs = mean_loocvs)ggplot(viz, aes(x = n_bins, y = mean_loocvs)) +geom_point(col = gos_red) +labs(title ="Hyperparameter Tuning for Bins", x ="Bin #", y ="LOOCV Mean") +scale_x_continuous(breaks = n_bins) + gos_theme
The minimum occurs at Bins = 12 with LOOCV: 0.6689429
Takeaway: Really any number between 6 and 12 will be fine, but 12 does technically come out on top.
Stepwise Grid Optimization
To optimize over 12 bins, we could skip interaction effects and optimize in a stepwise fashion. This will give up some precision, but \(O(n^{12})\) is too much to optimize over exhaustively.
Single bin
The strategy only considers weightings that are identical to the originally estimated weights, except for a change in one bin.
Code
load("../../data/opr_weights.rda")step_size <-0.1steps_radius <-10# store the CV errorsmean_loocvs <-matrix(0, nrow =length(w_bin_resid_vars), ncol = steps_radius *2+1)# store the weights in an indexable wayw_lookup <-matrix(0, nrow =length(w_bin_resid_vars), ncol = steps_radius *2+1)for (i inseq_along(w_bin_resid_vars)){ # i indexes the bins grid <-seq( w_bin_resid_vars[i] - (step_size * steps_radius), w_bin_resid_vars[i] + (step_size * steps_radius), by = step_size )# store the grid in our lookup matrix w_lookup[i, ] <- grid# reset w_star to the estimated weighting w_star <- w_bin_resid_vars# optimization within a single binfor (j inseq_along(grid)){ # j indexes the grid w_star[i] <- grid[j] fits_star <-lapply(matches, weighted_fit, w = w_star) mean_loocvs[i, j] <-mean(sapply(fits_star, LOOCV)) }}loocv_min_onebin <-min(mean_loocvs)min_idx <-which(mean_loocvs ==min(mean_loocvs), arr.ind =TRUE)w_onebin <- w_bin_resid_varsw_onebin[min_idx[1, 1]] <- w_lookup[min_idx[1, 1], min_idx[1, 2]]cat("The minimum while changing only one bin occurs at", min_idx, "\n","with LOOCV:", loocv_min_onebin, "\n", "and weighting:", w_onebin, "\n")
The minimum while changing only one bin occurs at 1 4
with LOOCV: 0.6680777
and weighting: 1.900177 2.760391 2.842327 2.833273 2.843733 2.898895 2.871657 2.851502 2.823041 2.791074 2.76293 2.729227
Stepwise Simultaneous
This version optimizes each bin in the context of the weights estimated by residual variance binning, then adopts the optimal weight for each individual bin as the final weighting.
Code
min_idx <-cbind(row =1:12, col =apply(mean_loocvs, 1, which.min))w_stepwise <- w_lookup[cbind(min_idx[, 1], min_idx[, 2])]fits_stepwise <-lapply(matches, weighted_fit, w = w_stepwise)loocv_stepwise <-mean(sapply(fits_stepwise, LOOCV))cat("The minimum using the optimum from each bin occurs with weighting:", "\n", w_stepwise, "\n", "with LOOCV:", loocv_stepwise, "\n")
The minimum using the optimum from each bin occurs with weighting:
1.900177 2.560391 2.942327 3.133273 3.243733 3.198895 3.171657 3.151502 2.923041 2.791074 2.66293 2.429227
with LOOCV: 0.6674667
Code
cat("Stepwise Optimization over all bins improves on the estimated binning by:", loocv_min_bins - loocv_stepwise, "standard deviations.", "\n", "Stepwise Optimization over a single bin improves on the estimated binning by:", loocv_min_bins - loocv_min_onebin, "standard deviations.", "\n")
Stepwise Optimization over all bins improves on the estimated binning by: 0.001476205 standard deviations.
Stepwise Optimization over a single bin improves on the estimated binning by: 0.0008652703 standard deviations.
Sequential Weight Fixing
This version optimizes in a single bin and then fixes that optimized value before evaluating the rest of the weights.
Code
step_size <-0.1steps_radius <-10mean_loocvs <-matrix(0, nrow =length(w_bin_resid_vars), ncol = steps_radius *2+1)w_star <- w_bin_resid_varsfor (i inseq_along(w_bin_resid_vars)){ grid <-seq( w_bin_resid_vars[i] - (step_size * steps_radius), w_bin_resid_vars[i] + (step_size * steps_radius), by = step_size )# optimization within a single binfor (j inseq_along(grid)){ w_star[i] <- grid[j] fits_star <-lapply(matches, weighted_fit, w = w_star) mean_loocvs[i, j] <-mean(sapply(fits_star, LOOCV)) }# now fix the optimal value for bin i for the rest of the optimization w_star[i] <- grid[which.min(mean_loocvs[, i])]}w_sequential <- w_starfits_star <-lapply(matches, weighted_fit, w_sequential)cat("The minimum using fixing occurs at:\n", w_sequential, "\n","with LOOCV:", mean(sapply(fits_star, LOOCV)), "\n")
The minimum using fixing occurs at:
1.700177 1.960391 2.142327 2.233273 2.343733 2.498895 2.571657 2.651502 2.723041 2.791074 2.86293 1.829227
with LOOCV: 0.6696457
Results
Stepwise simultaneous weight optimization performed better than fixing and single bin optimization, so we’ll save that weighting.
Given the new weighting, let’s show the improvement over unweighted OPR.
Code
rm(list =ls())library(devtools)load_all()rm(list =ls())load("../../data/district_quals_09_24.rda")load("../../data/optimized_opr_weighting.rda")# normalize match scores so we can do cross-week and cross-season comparisons# this simplifies a lot of later codematches <-lapply( matches, function(match_df){ scaled <-scale(c(match_df$red_score, match_df$blue_score)) match_df$red_score <- scaled[1:nrow(match_df)] match_df$blue_score <- scaled[(nrow(match_df) +1):length(scaled)]return(match_df) })library(extrafont)library(gt)gos_blue <-"#337DFC"gos_red <-"#F7041A"gos_theme <-theme_bw() +theme(text =element_text(family ="Futura"))# fits WLS on the match dataframesweighted_fit <-function(match_df, w){return(fit_lineup_lm( match_df, responses =list(red = match_df$red_score, blue = match_df$blue_score ), w = w ) )}# fits the standard opr model on a regular match dataframe from TheBlueAlliancefit_opr <-function(match_df){return(fit_lineup_lm( match_df, responses =list(red = match_df$red_score, blue = match_df$blue_score) ) )}normalize_match_number <-function(opr_residual){# we know we can divide the length by 2 because there are 2 alliances/match alliance_corrected <- ((1:length(opr_residual) -1) %% (length(opr_residual) /2)) +1return((alliance_corrected /length(opr_residual)) *2)}fits <-lapply(matches, fit_opr)residuals <-lapply(fits, function(fit){return(residuals(fit))})# match percentile is the match number divided by the number of matchesmatch_percentile <-unlist(lapply(residuals, normalize_match_number), use.names =FALSE)
Code
linear_weight_fits <-lapply(matches, weighted_fit, w = w_linear)bin_weight_fits <-lapply(matches, weighted_fit, w = w_bin_resid_vars)stepwise_fits <-lapply(matches, weighted_fit, w = w_stepwise)raw_fits <-lapply(matches, weighted_fit, w =1)linear_weight_loocvs <-sapply(linear_weight_fits, LOOCV)resid_weight_loocvs <-sapply(bin_weight_fits, LOOCV)stepwise_loocvs <-sapply(stepwise_fits, LOOCV)raw_loocvs <-sapply(raw_fits, LOOCV)viz <-data.frame(linear_ratio = raw_loocvs / linear_weight_loocvs, residvar_ratio = raw_loocvs / resid_weight_loocvs,stepwise_ratio = raw_loocvs / stepwise_loocvs, year =as.factor(substr(names(raw_loocvs), 1, 4)))ggplot(viz, aes(x = stepwise_ratio)) +geom_histogram(fill = gos_blue, color ="black", bins =ceiling(sqrt(nrow(viz)))) +geom_vline(aes(xintercept =mean(stepwise_ratio)), color = gos_red) +annotate("label", x =mean(viz$stepwise_ratio), y =15, label =paste0("Mean: ", round(mean(viz$stepwise_ratio), 3))) +labs(title ="OPR Weighting LOOCV Ratio", subtitle ="District Event Qualifications, 2009 - 2024", x ="Event-Normalized LOOCV Error Ratio (raw:stepwise-optimized)", y ="# Events") + gos_theme
Code
ggplot(viz, aes(x = stepwise_ratio)) +geom_density(alpha =0.2, fill = gos_blue) +facet_wrap(~year) +labs(title ="OPR Weighting LOOCV Ratio", x ="LOOCV Ratio (raw:stepwise-optimized", y ="Density") + gos_theme