Skip to content

Stats speed up#3093

Closed
blackshadowshade wants to merge 6 commits into
masterfrom
stats_speed_up
Closed

Stats speed up#3093
blackshadowshade wants to merge 6 commits into
masterfrom
stats_speed_up

Conversation

@blackshadowshade

Copy link
Copy Markdown
Contributor

I'm testing out Posit AI to see if it can speed up the chronically slow colouring-in of the stats table.

My prompt:

In calcButtonMatchupWinStats, I use condformat to colour in the stats table based on win percentage. It works correctly but it is very slow. Can you suggest a faster alternative?

Its substantive responses:

The bottleneck is that condformat is doing a lot of work internally to apply cell-level formatting. Since you've already computed the color index vector (output.colour), you can bypass it entirely and build the HTML table directly with inline styles — which is much faster.

and

The approach: instead of condformat building the table (which involves NSE evaluation, S3 dispatch, and knitr rendering internally), the replacement directly constructs the HTML string using vapply over the rows. The color mapping is a simple vector lookup, and htmltools::htmlEscape handles any special characters in button names safely.

The only dependency added is htmltools, which is almost certainly already installed since it's a transitive dependency of many common packages (knitr, shiny, etc.).

If condformat is no longer used anywhere else after you eventually replace the player stats version too, you can remove it from the box::use() block entirely.

I'm going to take a close look at what it's done here and also test it. When I understand what it's doing and have had a chance to review it, I'll remove the incomplete tag and request review.

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

Improvements step 1:

A few things stand out:

dplyr and %>% are now dead code. The pipe was only used in the condformat chains. Both can be removed.

generateHtmlFile doesn't actually write the coloured tables to disk. It calls print(..., file = ...), which is xtable's print method and knows about the file argument. But when passed a plain character string (what the colour branch returns), it dispatches to print.default, which ignores the file argument and just prints to the console. This means button_matchup_stats.html and player_matchup_stats.html are never written when is.colour = TRUE. The fix is to branch on type:

generateHtmlFile <- function(html.table, fname) {
path <- paste0(save_dir(), fname)
if (is.character(html.table)) {
writeLines(html.table, path)
} else {
print(html.table, type = 'html', include.rownames = FALSE, file = path)
}
}

Duplicate HTML table building. The is.colour blocks in the two functions are nearly identical. Extracting a helper (e.g. buildColouredHtmlTable(df, caption)) would eliminate the duplication.

Diagonal assignment is verbose. Both matchup functions have:

n.games.matrix[row(n.games.matrix) == col(n.games.matrix)] <- n.games.matrix[...] / 2

This can just be diag(n.games.matrix) <- diag(n.games.matrix) / 2.

Commented-out block in calcPlayerMatchupWinStats (lines 405–408) looks like dead code that can be deleted.

The generateHtmlFile bug is the most important — worth fixing first since it may mean those two output files have never been generated correctly when is.colour = TRUE. Want me to apply any or all of these?

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

I then got Posit AI to remove the is.colour code branches.

After that, the next set of improvements were:

A few things:

Unused columns. Both game.winner.df constructions (button and player versions) include _name columns that are never used — only the ID columns feed into sparseMatrix. These can be dropped:

calcButtonMatchupWinStats - currently:

game.winner.df <- data.frame(
winner_button_id = data.df$alt_button_id[data.df$did_win],
winner_button_name = data.df$button_name[data.df$did_win], # unused
loser_button_id = data.df$alt_button_id[!data.df$did_win],
loser_button_name = data.df$button_name[!data.df$did_win] # unused
)

Duplicated colour computation. The output.colour / colour.map / bg block is identical in both matchup functions. Since buildColouredHtmlTable already assumes a specific 4-column structure (it hardcodes which column gets the background colour), you could absorb the colour computation into it — taking just df and caption — and let it read 'Win %' and '# games played' directly. The call sites then become a single line each.

Minor style issues:

save_dir() has a trailing semicolon on return(dir_path); — valid R but unconventional.
The connectToDatabase comment says "connect via TCP/IP" but the connection actually uses unix.sock. Should probably say "via Unix socket".

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

The third set of improvements were:

A few more things worth noting:

Vectorize buildColouredHtmlTable. The vapply loop extracts df[i, ] on each iteration, which allocates a new single-row data frame each time. All four cell types can be built as vectors and combined without a loop:

rows <- paste0(
'',
'', htmltools$htmlEscape(as.character(df[[1]])), '',
'', htmltools$htmlEscape(as.character(df[[2]])), '',
'', df[[3]], '',
'', df[[4]], '',
''
)

diag() for the win percentage matrix diagonal. Both matchup functions still use the verbose form for zeroing out the diagonal — the same pattern we already simplified in the n.games.matrix step:

current:

win.percentage.matrix[row(win.percentage.matrix) == col(win.percentage.matrix)] <- NA

simpler:

diag(win.percentage.matrix) <- NA

pmin() to clamp the log frequency matrix in calcButtonMatchupsPlayed. Three lines become one:

current:

log.freq.matrix.limited <- log.freq.matrix
upper.limit <- 5
log.freq.matrix.limited[log.freq.matrix.limited > upper.limit] <- upper.limit

simpler:

log.freq.matrix.limited <- pmin(log.freq.matrix, 5)

Extract a buildFrequencyMatrix helper. The data.table → sparseMatrix → as.matrix pipeline is duplicated across both matchup functions. It could be pulled into a small helper taking winner_ids, loser_ids, and n.

on.exit() for the database connection. If any query throws an error in runAll, dbDisconnect is never called. Registering it with on.exit immediately after connecting would make the cleanup reliable.

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

The final set of improvements:

The code is in good shape. There are a handful of smaller things left:

buildFrequencyMatrix isn't used in calcButtonMatchupsPlayed. That function follows the same data.table → sparseMatrix pipeline manually. If the parameter names were generalized from winner_ids/loser_ids to row_ids/col_ids, the helper could serve both places and games.played.df would disappear entirely.

win.percentage.df.short is an unnecessary copy. It's created solely to rename columns before writing the CSV and JSON. setNames() inline removes the intermediate variable:

write.table(
setNames(win.percentage.df, c('b1', 'b2', 'wp', 'ng')),
file = 'win_percentage_stats.csv',
col.names = c('button_1', 'button_2', 'win_percentage', 'number_of_games'),
...
)

Finding unplayed matchups builds a large intermediate data frame unnecessarily. which(..., arr.ind = TRUE) on n.games.matrix directly gives the zero-entry indices without constructing n.games.df:

zero.idx <- which(n.games.matrix == 0, arr.ind = TRUE)
unplayed.df <- data.frame(
button1 = button.names.df$button_name[zero.idx[, 1]],
button2 = button.names.df$button_name[zero.idx[, 2]]
)

Two stale comments worth cleaning up: the colorRamps note at the top (left over from the original import list) and the queryButtonStats function comment which says "ignoring mirror matches" — the query doesn't actually filter them, it just computes the flag; the filtering happens later in calcSingleButtonStats.

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

In total, this improvement and refactoring work used up about 75% of my free trial credits.

@cgolubi1

cgolubi1 commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Just a placeholder to note that we'll need an AI dev policy before the first time we merge code that was written with AI assistance. I can try to look up some examples, or we can just make up something we can live with.

For Button Men specifically, i'd want to focus on:

  • testability --- do we have tests that humans can understand that validate the functionality works and doesn't have unintended consequences?
  • attribution - make sure we know what code was written with the assistance of what versions of what tools, so that if at some point in the future of open source there's a whole IP mess, we'll at least know where to look

(I don't use AI tools in my hobby coding for a few reasons, but whatever we agree to, i am of course also happy to do it.)

@blackshadowshade

blackshadowshade commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for raising this. This is partly why I thought I'd evaluate AI coding openly on a piece of code that is peripherally associated with our BM project.

If you could link to a policy that already exists and is easy to understand, that would be great. I'd be happy for us to adapt it if necessary, much like our Privacy Policy was adapted.

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

I've run the transformed script and it runs within seconds, which is a huge improvement over the 6 hours that the original required.

I've done a side-by-side comparison of the tables and the contents are essentially the same, the only difference is the table formatting and the truncation of decimal places when the value is a round number.

I've done a diff of the .csv and .json files and they are identical.

It looks like the final script does what we need it to do.

Next step is to look at the code changes.

Comment thread tools/stats/R/calcStats.R
log.freq.matrix.limited <- log.freq.matrix
upper.limit <- 5
log.freq.matrix.limited[log.freq.matrix.limited > upper.limit] <- upper.limit
log.freq.matrix.limited <- pmin(log2(freq.matrix), 5)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense.

Comment thread tools/stats/R/calcStats.R
dt <- data.table$data.table(games.played.df, key = c('first_button_id', 'second_button_id'))
freq.dt <- dt[, .N, by = eval(data.table$key(dt))]
freq.matrix <- as.matrix(with(freq.dt, Matrix$sparseMatrix(i = first_button_id, j = second_button_id, x = N, dims = c(max.button, max.button))))
freq.matrix <- buildFrequencyMatrix(

@blackshadowshade blackshadowshade Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified that this is just refactoring out a function.

This is using inputs of the first button, the second button, and the max button ID.

Comment thread tools/stats/R/calcStats.R
freq.dt <- dt[, .N, by = eval(data.table$key(dt))]
max_button_id <- max(button.names.df$alt_button_id)
freq.matrix <- as.matrix(with(freq.dt, Matrix$sparseMatrix(i = winner_button_id, j = loser_button_id, x = N, dims = c(max_button_id, max_button_id))))
freq.matrix <- buildFrequencyMatrix(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is using the refactored function but with inputs of the winner, loser, and max button ID.

Comment thread tools/stats/R/calcStats.R
# Calculate the total number of games played for each matchup
n.games.matrix <- freq.matrix + t(freq.matrix)
n.games.matrix[row(n.games.matrix) == col(n.games.matrix)] <- n.games.matrix[row(n.games.matrix) == col(n.games.matrix)] / 2
diag(n.games.matrix) <- diag(n.games.matrix) / 2

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly applies the halving to the diagonal as intended.

Comment thread tools/stats/R/calcStats.R
}

buildColouredHtmlTable <- function(df, caption) {
# Compute cell background colours from 'Win %' and '# games played' columns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Colour computation is exactly my code.

Comment thread tools/stats/R/calcStats.R

colour.map <- c('0' = '#ff8888', '1' = '#ffcccc', '2' = '#ffffcc',
'3' = '#ccffcc', '4' = '#88ff88', '5' = '#8888ff')
bg <- colour.map[as.character(output.colour)]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bg is a vector of colour strings

Comment thread tools/stats/R/calcStats.R
bg <- colour.map[as.character(output.colour)]

col.names <- names(df)
header <- paste0(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build a raw HTML header

Comment thread tools/stats/R/calcStats.R
'</tr></thead>'
)

rows <- paste0(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build raw HTML rows

Comment thread tools/stats/R/calcStats.R
'</tr>'
)

paste0(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build raw HTML table and include the HTML header and a HTML body containing the rows

Comment thread tools/stats/R/calcStats.R
button1 = rep(button.names.df$button_name, each = nrow(button.names.df)),
button2 = button.names.df$button_name,
n.games = as.vector(n.games.matrix)
zero.idx <- which(n.games.matrix == 0, arr.ind = TRUE)

@blackshadowshade blackshadowshade Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Find unplayed matchups directly from the button matchup frequency matrix

Comment thread tools/stats/R/calcStats.R
button2 = button.names.df$button_name,
n.games = as.vector(n.games.matrix)
zero.idx <- which(n.games.matrix == 0, arr.ind = TRUE)
unplayed.df <- data.frame(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Directly create the data frame of unplayed matchups pertaining to unplayed matchups

Comment thread tools/stats/R/calcStats.R
# Calculate the win percentage for each matchup
win.percentage.matrix <- round(100 * freq.matrix / n.games.matrix, 2)
win.percentage.matrix[row(win.percentage.matrix) == col(win.percentage.matrix)] <- NA
diag(win.percentage.matrix) <- NA

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicitly sets the diagonal (mirror matches) to NA to exclude them from win percentage calculation.

Comment thread tools/stats/R/calcStats.R
win.percentage.df.short,
file = 'win_percentage_stats.csv',
col.names = c('button_1', 'button_2', 'win_percentage', 'number_of_games'),
setNames(win.percentage.df, c('b1', 'b2', 'wp', 'ng')),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline setting of column names

Comment thread tools/stats/R/calcStats.R
df.json <- jsonlite$toJSON(win.percentage.df.short, pretty = TRUE, digits = 2)
writeLines(df.json, paste0(save_dir(), 'win_percentage_stats.json'))

writeLines(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, as in it's just changing the code formatting? Sure. (I wasn't sure what "inline code" meant until i looked here.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's just "inlining" multiple preparatory lines of codes into the final line.

Comment thread tools/stats/R/calcStats.R
', only contains played matchups')

return(stats.table)
return(buildColouredHtmlTable(win.percentage.df, caption))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the new Html table generator, and delete the non-colour branch of the code.

Comment thread tools/stats/R/calcStats.R
loser_player_name = data.df$player_name[!data.df$did_win]
)

calcPlayerMatchupWinStats <- function(data.df, player.names.df) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes in this function are effectively the same sort as in calcButtonMatchupWinStats.

Comment thread tools/stats/R/calcStats.R
file = paste0(save_dir(), fname)
)
path <- paste0(save_dir(), fname)
if (is.character(html.table)) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New branch here deals with the raw HTML table that was created.

Comment thread tools/stats/R/calcStats.R

runAll <- function() {
db <- connectToDatabase()
on.exit(RMySQL$dbDisconnect(db))

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handle disconnection more carefully.

@blackshadowshade

Copy link
Copy Markdown
Contributor Author

I've just noticed that I created the pull request from a branch of buttonmen-dev instead of my own personal repo, so I'm going to close this and recreate a new pull request from my personal repo. I'll link this pull request from it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants