MCMC for ‘Big Data’ with Stan

Faster sampling with CmdStan using within-chain parallelization

R
Statistics
ML
Bayesian Modeling
Stan
Big Data
First Published

June 5, 2022

Summary

This post is an extension (and a translation to R) of PyMC-Labs’ benchmarking of MCMC for “Big Data”.

The Stan code was updated to use within-chain parallelization and compiler optimization for faster CPU sampling. Stan was able to achieve similar sampling speeds as PyMC’s JAX + GPU solution, purely on CPU.


Caution

By design, this post contains very few explanations.

Its goal was simply to translate PyMC’s blog post to R, and update their Stan code to use within-chain parallelization and compiler optimizations, for a fairer speed comparison with PyMC.

Feel free to read the original blog post to better understand what the code is doing.

Tip

You can check the page’s source code by clicking on the </> Code button at the top-right.

Setup


library(here) # Working directory management
library(fs) # File & folder manipulation
library(pipebind) # Piping goodies
library(archive) # Memory efficient object storage

library(cmdstanr) # Lightweight R interface for Stan
library(posterior) # Wrangling Stan models' output

library(data.table) # Fast data manipulation (in-RAM)

library(tibble) # Extending data.frames             (Tidyverse)
library(readr) # Reading data from files           (Tidyverse)
library(dplyr) # Manipulating data.frames - core   (Tidyverse)
library(tidyr) # Manipulating data.frames - extras (Tidyverse)
library(stringr) # Manipulating strings              (Tidyverse)
library(purrr) # Manipulating lists                (Tidyverse)
library(lubridate) # Manipulating date/time            (Tidyverse)
library(ggplot2) # Best plotting library             (Tidyverse)

library(bayesplot) # PPC/Diagnostic plots for Stan models
library(patchwork) # Combining plots

options(
    mc.cores = max(1L, parallel::detectCores(logical = TRUE)),
    scipen = 999L,
    digits = 4L,
    ggplot2.discrete.colour = \() scale_color_viridis_d(),
    ggplot2.discrete.fill = \() scale_fill_viridis_d()
)

nrows_print <- 10

setDTthreads(parallel::detectCores(logical = FALSE))
Adding custom knitr hooks
#---------------------------#
####🔺knitr custom hooks ####
#---------------------------#

library(knitr)

## Adding the `time_it` code chunk option
knitr::knit_hooks$set(
    time_it = local({
        assign("TIMES", list(), .GlobalEnv)
        start <- NULL
        function(before, options) {
            if (before) {
                start <<- Sys.time()
            } else {
                TIMES[[options$label]] <<- difftime(Sys.time(), start)
            }
        }
    })
)
Applying a custom theme to all ggplot objects, both light and dark versions
#---------------------------#
####🔺ggplot knit_prints ####
#---------------------------#

library(knitr)
library(ggplot2)

## Inspired by: https://debruine.github.io/quarto_demo/dark_mode.html
knit_print.ggplot <- function(x, options, ...) {
    if (any(grepl("patchwork", class(x)))) {
        plot_dark <- x & dark_addon_mar
        plot_light <- x & light_addon_mar
    } else {
        plot_dark <- x + dark_addon_mar
        plot_light <- x + light_addon_mar
    }

    cat('\n<div class="light-mode">\n')
    print(plot_light)
    cat('</div>\n')
    cat('<div class="dark-mode">\n')
    print(plot_dark)
    cat('</div>\n\n')
}

registerS3method("knit_print", "ggplot", knit_print.ggplot)
Applying a custom theme to all gt tables
#-----------------------#
####🔺gt knit_prints ####
#-----------------------#

library(knitr)
library(gt)

knit_print.grouped_df <- function(x, options, ...) {
    if ("grouped_df" %in% class(x)) {
        x <- ungroup(x)
    }

    cl <- intersect(class(x), c("data.table", "data.frame"))[1]
    nrows <- ifelse(
        !is.null(options$total_rows),
        as.numeric(options$total_rows),
        dim(x)[1]
    )
    is_open <- ifelse(
        !is.null(options[["details-open"]]),
        as.logical(options[["details-open"]]),
        FALSE
    )

    cat(str_glue("\n<details{ifelse(is_open, ' open', '')}>\n"))
    cat("<summary>\n")
    cat(str_glue("\n*{cl} [{scales::label_comma()(nrows)} x {dim(x)[2]}]*\n"))
    cat("</summary>\n<br>\n")
    print(gt::as_raw_html(style_table(x, nrows)))
    cat("</details>\n\n")
}

registerS3method("knit_print", "grouped_df", knit_print.grouped_df)

knit_print.data.frame <- function(x, options, ...) {
    cl <- intersect(class(x), c("data.table", "data.frame"))[1]
    nrows <- ifelse(
        !is.null(options$total_rows),
        as.numeric(options$total_rows),
        dim(x)[1]
    )
    is_open <- ifelse(
        !is.null(options[["details-open"]]),
        as.logical(options[["details-open"]]),
        FALSE
    )

    cat(str_glue("\n<details{ifelse(is_open, ' open', '')}>\n"))
    cat("<summary>\n")
    cat(str_glue("\n*{cl} [{scales::label_comma()(nrows)} x {dim(x)[2]}]*\n"))
    cat("</summary>\n<br>\n")
    print(gt::as_raw_html(style_table(x, nrows)))
    cat("</details>\n\n")
}

registerS3method("knit_print", "data.frame", knit_print.data.frame)
Functions for interactive data presentation
#----------------------------------#
####🔺knitr interactive display ####
#----------------------------------#

library(htmltools)
library(reactable)

## Getting list to display nicely in rendered documents
make_list_reactable <- function(list_dat) {
    list_name <- deparse(substitute(list_dat))

    get_list_elt_dim <- function(elt) {
        list_elt <- list_dat[[elt]]
        list_elt_dim <- if (any(c("data.frame", "matrix") %in% class(list_elt))) {
            dim(list_elt)
        } else {
            length(list_elt)
        }

        return(paste0(list_elt_dim, collapse = ", "))
    }

    dat <- data.frame(names(list_dat)) |>
        set_names(list_name) |>
        mutate(
            Type = unlist(pick(list_name)) |>
                map_chr(\(x) class(list_dat[[x]]) |> paste0(collapse = ", ")),
            Dimensions = unlist(pick(list_name)) |> map_chr(get_list_elt_dim)
        )

    get_list_details <- function(dat, idx, max_print = 200, max_digits = 3) {
        Element <- dat[[idx]]
        style <- "padding: 0.5rem"

        if (any(c("data.frame", "matrix") %in% class(Element))) {
            reactable(
                data.frame(Element),
                outlined = TRUE,
                striped = TRUE,
                highlight = TRUE,
                compact = TRUE
            ) |>
                htmltools::div(style = style)
        } else if ("list" %in% class(Element)) {
            make_list_reactable(Element)
        } else if (length(Element) > max_print) {
            htmltools::div(
                htmltools::p(
                    head(Element, max_print) |>
                        round(max_digits) |>
                        paste0(collapse = ", ") |>
                        paste("...", sep = ", ")
                ),
                htmltools::p(
                    stringr::str_glue(
                        "[ omitted {length(Element) - max_print} entries ]"
                    ),
                    style = "font-style: italic"
                ),
                style = style
            )
        } else {
            htmltools::div(
                round(Element, max_digits) |> paste0(collapse = ", "),
                style = style
            )
        }
    }

    reactable(
        dat,
        defaultColDef = colDef(vAlign = "center", headerVAlign = "center"),
        details = \(idx) get_list_details(list_dat, idx),
        outlined = TRUE,
        striped = TRUE,
        highlight = TRUE,
        compact = FALSE,
        fullWidth = TRUE,
        defaultPageSize = 15
    )
}
Installing CmdStan
## Skip this step if CmdStan is already installed

cmdstanr::check_cmdstan_toolchain(fix = TRUE, quiet = TRUE)

cpp_opts <- list(
    stan_threads = TRUE,
    STAN_CPP_OPTIMS = TRUE,
    STAN_NO_RANGE_CHECKS = TRUE, # WARN: remove this if you haven't tested the model
    PRECOMPILED_HEADERS = TRUE,
    # , CXXFLAGS_OPTIM = "-march=native -mtune=native"
    CXXFLAGS_OPTIM_TBB = "-mtune=native -march=native",
    CXXFLAGS_OPTIM_SUNDIALS = "-mtune=native -march=native"
)

cmdstanr::install_cmdstan(cpp_options = cpp_opts, quiet = TRUE)
Loading CmdStan (if already installed)
highest_cmdstan_version <- fs::dir_ls(Sys.getenv("CMDSTAN_PATH")) |>
    fs::path_file() |>
    purrr::keep(\(e) stringr::str_detect(e, "cmdstan-")) |>
    stringr::str_split_i('-', 2) |>
    purrr::reduce(\(x, y) ifelse(utils::compareVersion(x, y) == 1, x, y))

set_cmdstan_path(str_glue("{Sys.getenv('CMDSTAN_PATH')}cmdstan-{highest_cmdstan_version}"))
Setting up knitr’s engine for CmdStan
## Inspired by: https://mpopov.com/blog/2020/07/30/replacing-the-knitr-engine-for-stan/

## Note: We could haved use cmdstanr::register_knitr_engine(),
##       but it wouldn't include compiler optimizations & multi-threading by default

knitr::knit_engines$set(
    cmdstan = function(options) {
        output_var <- options$output.var
        if (!is.character(output_var) || length(output_var) != 1L) {
            stop(
                "The chunk option output.var must be a character string ",
                "providing a name for the returned `CmdStanModel` object."
            )
        }
        if (options$eval) {
            if (options$cache) {
                cache_path <- options$cache.path
                if (length(cache_path) == 0L || is.na(cache_path) || cache_path == "NA") {
                    cache_path <- ""
                }
                dir <- paste0(cache_path, options$label)
            } else {
                dir <- tempdir()
            }
            file <- cmdstanr::write_stan_file(
                options$code,
                dir = dir,
                force_overwrite = TRUE
            )
            mod <- cmdstanr::cmdstan_model(
                stan_file = file,
                cpp_options = list(
                    stan_threads = TRUE,
                    STAN_CPP_OPTIMS = TRUE,
                    STAN_NO_RANGE_CHECKS = TRUE, # The model was already tested
                    PRECOMPILED_HEADERS = TRUE,
                    # , CXXFLAGS_OPTIM = "-march=native -mtune=native"
                    CXXFLAGS_OPTIM_TBB = "-mtune=native -march=native",
                    CXXFLAGS_OPTIM_SUNDIALS = "-mtune=native -march=native"
                ),
                stanc_options = list("O1"),
                force_recompile = TRUE
            )
            assign(output_var, mod, envir = knitr::knit_global())
        }
        options$engine <- "stan"
        code <- paste(options$code, collapse = "\n")
        knitr::engine_output(options, code, '')
    }
)

1 Data


1.1 Matches data

Download the matches’ data

Loading the matches’ data:

matches_data_raw <- read_csv(dir_ls(matches_data_path, regexp = "atp_matches_(.*).csv"), show_col_types = FALSE) |>
    select(tourney_date, tourney_level, round, winner_id, winner_name, loser_id, loser_name, score) |>
    mutate(tourney_date = lubridate::ymd(tourney_date))

Filtering and cleaning the combined matches’ data (based on the original post’s data processing):

round_numbers <- list(
    "R128" = 1,
    "RR" = 1,
    "R64" = 2,
    "R32" = 3,
    "R16" = 4,
    "QF" = 5,
    "SF" = 6,
    "F" = 7
)

matches_data_clean <- matches_data_raw |>
    filter(
        tourney_date %between% c("1968-01-01", "2021-06-20"),
        str_detect(score, "RET|W/O|DEF|nbsp|Def.", negate = TRUE),
        str_length(score) > 4,
        tourney_level != "D",
        round %in% names(round_numbers)
    ) |>
    mutate(
        round_number = recode(round, !!!round_numbers),
        label = 1
    ) |>
    arrange(tourney_date, round_number) |>
    select(-round, -tourney_level)

matches_data_clean
data.frame [160,399 x 8]
tourney_date winner_id winner_name loser_id loser_name score round_number label
1968-01-19 110.023K Richard Coulthard 107.76K Max Senior 12-10 7-5 4-6 7-5 2 1
1968-01-19 109.803K John Brown 106.964K Ernie Mccabe 6-3 6-2 6-4 2 1
1968-01-19 100.257K Ross Case 110.024K Gondo Widjojo 6-4 3-6 6-3 7-5 2 1
1968-01-19 100.105K Allan Stone 110.025K Robert Layton 6-4 6-2 6-1 2 1
1968-01-19 109.966K Warren Jacques 110.026K Bert Kearney 6-4 6-1 7-5 2 1
1968-01-19 107.759K Max Pettman 110.027K Takesji Tsujimoto 6-4 6-1 6-2 2 1
1968-01-19 100.101K Mike Belkin 110.028K M Marchment 6-2 3-6 6-4 9-7 2 1
1968-01-19 100.025K Barry Phillips Moore 108.43K Tony Dawson 6-3 6-0 6-3 2 1
1968-01-19 108.519K William Coghlan 110.029K Peter Oatey 6-0 6-2 9-11 6-3 2 1
1968-01-19 109.799K Geoff Pollard 110.03K Christian Janssens 6-4 6-2 6-4 2 1
1968-01-19 100.146K Jun Kamiwazumi 110.031K Brian Connor 8-6 6-4 6-2 2 1
1968-01-19 100.18K Bill Lloyd 110.032K H Nielson 6-2 7-5 6-4 2 1
1968-01-19 100.013K Neale Fraser 110.033K R Harvey 6-1 6-1 6-0 2 1
1968-01-19 110.034K Merv Guse 110.035K J May 6-1 6-2 6-2 2 1
1968-01-19 100.174K Manuel Orantes 110.036K L Weatherhog 6-4 6-0 6-2 2 1
[ omitted 160,384 entries ]

1.2 Player data

Download the players’ data

Loading the raw player data:

player_data_path <- here("res", "data", "tennis", "atp_players.csv")

player_data_raw <- read_csv(player_data_path, show_col_types = FALSE) |>
    mutate(player_name = str_c(name_first, name_last, sep = " ")) |>
    select(player_id, player_name)

player_data_raw
data.frame [55,649 x 2]
player_id player_name
100.001K Gardnar Mulloy
100.002K Pancho Segura
100.003K Frank Sedgman
100.004K Giuseppe Merlo
100.005K Richard Gonzalez
100.006K Grant Golden
100.007K Abe Segal
100.008K Kurt Nielsen
100.009K Istvan Gulyas
100.01K Luis Ayala
100.011K Torben Ulrich
100.012K Nicola Pietrangeli
100.013K Neale Fraser
100.014K Trevor Fancutt
100.015K Sammy Giammalva
[ omitted 55,634 entries ]

Filtering player_data to only keep the players actually present in our data, and updating their IDs:

player_data <- with(matches_data_clean, tibble(player_id = union(winner_id, loser_id))) |>
    arrange(player_id) |>
    summarize(player_idx = cur_group_id(), .by = player_id) |>
    left_join(player_data_raw, join_by(player_id))

player_data
data.frame [4,830 x 3]
player_id player_idx player_name
100.001K 1 Gardnar Mulloy
100.002K 2 Pancho Segura
100.003K 3 Frank Sedgman
100.004K 4 Giuseppe Merlo
100.005K 5 Richard Gonzalez
100.006K 6 Grant Golden
100.007K 7 Abe Segal
100.009K 8 Istvan Gulyas
100.01K 9 Luis Ayala
100.011K 10 Torben Ulrich
100.012K 11 Nicola Pietrangeli
100.013K 12 Neale Fraser
100.014K 13 Trevor Fancutt
100.015K 14 Sammy Giammalva
100.016K 15 Ken Rosewall
[ omitted 4,815 entries ]

1.3 Matches + Player data

Allocating the new player IDs (player_idx) to the winner_id and loser_id from matches_data:

matches_data <- left_join(matches_data_clean, player_data, by = c("winner_id" = "player_id")) |>
    rename(winner_idx = player_idx) |>
    relocate(winner_idx, .after = winner_id) |>
    left_join(player_data, by = c("loser_id" = "player_id")) |>
    rename(loser_idx = player_idx) |>
    relocate(loser_idx, .after = loser_id) |>
    drop_na(winner_idx, loser_idx) |>
    select(-matches("player_name"))

matches_data
data.frame [160,399 x 10]
tourney_date winner_id winner_idx winner_name loser_id loser_idx loser_name score round_number label
1968-01-19 110.023K 3.655K Richard Coulthard 107.76K 3.129K Max Senior 12-10 7-5 4-6 7-5 2 1
1968-01-19 109.803K 3.44K John Brown 106.964K 2.909K Ernie Mccabe 6-3 6-2 6-4 2 1
1968-01-19 100.257K 253 Ross Case 110.024K 3.656K Gondo Widjojo 6-4 3-6 6-3 7-5 2 1
1968-01-19 100.105K 103 Allan Stone 110.025K 3.657K Robert Layton 6-4 6-2 6-1 2 1
1968-01-19 109.966K 3.6K Warren Jacques 110.026K 3.658K Bert Kearney 6-4 6-1 7-5 2 1
1968-01-19 107.759K 3.128K Max Pettman 110.027K 3.659K Takesji Tsujimoto 6-4 6-1 6-2 2 1
1968-01-19 100.101K 99 Mike Belkin 110.028K 3.66K M Marchment 6-2 3-6 6-4 9-7 2 1
1968-01-19 100.025K 24 Barry Phillips Moore 108.43K 3.325K Tony Dawson 6-3 6-0 6-3 2 1
1968-01-19 108.519K 3.349K William Coghlan 110.029K 3.661K Peter Oatey 6-0 6-2 9-11 6-3 2 1
1968-01-19 109.799K 3.436K Geoff Pollard 110.03K 3.662K Christian Janssens 6-4 6-2 6-4 2 1
1968-01-19 100.146K 144 Jun Kamiwazumi 110.031K 3.663K Brian Connor 8-6 6-4 6-2 2 1
1968-01-19 100.18K 178 Bill Lloyd 110.032K 3.664K H Nielson 6-2 7-5 6-4 2 1
1968-01-19 100.013K 12 Neale Fraser 110.033K 3.665K R Harvey 6-1 6-1 6-0 2 1
1968-01-19 110.034K 3.666K Merv Guse 110.035K 3.667K J May 6-1 6-2 6-2 2 1
1968-01-19 100.174K 172 Manuel Orantes 110.036K 3.668K L Weatherhog 6-4 6-0 6-2 2 1
[ omitted 160,384 entries ]

2 Model


2.1 Stan code

Updated Stan code with within-chain parallelization

tennis_model
functions {
  array[] int sequence(int start, int end) {
    array[end - start + 1] int seq;
    for (n in 1 : num_elements(seq)) {
      seq[n] = n + start - 1;
    }
    return seq;
  }

  // Compute partial sums of the log-likelihood
  real partial_log_lik_lpmf(array[] int seq, int start, int end,
                            data array[] int labels, 
                            data array[] int winner_ids, 
                            data array[] int loser_ids, 
                            vector player_skills) {
    real ptarget = 0;
    int N = end - start + 1;

    vector[N] mu = rep_vector(0.0, N);
    for (n in 1 : N) {
      int nn = n + start - 1;
      mu[n] += player_skills[winner_ids[nn]] - player_skills[loser_ids[nn]];
    }
    ptarget += bernoulli_logit_lpmf(labels[start : end] | mu);
    return ptarget;
  }
}
data {
    int n_players;
    int n_matches;
    
    array[n_matches] int<lower=1, upper=n_players> winner_ids; // Winner of game n
    array[n_matches] int<lower=1, upper=n_players> loser_ids;  // Loser of game n
    array[n_matches] int<lower=0, upper=1> labels;             // Always 1 in this model
    
    int grainsize;
}
transformed data {
    array[n_matches] int seq = sequence(1, n_matches);
}
parameters {
    real<lower=0> player_sd;          // Scale of ability variation (hierarchical prior)
    vector[n_players] player_skills;  // Ability of player k
}
model {   
  player_sd ~ std_normal();
  player_skills ~ normal(0, player_sd);
    
  target += reduce_sum(
    partial_log_lik_lpmf, seq, grainsize, 
    labels, winner_ids, loser_ids, player_skills
  );
}

2.2 Stan data

tennis_stan_data <- list(
    n_matches = nrow(matches_data),
    n_players = with(matches_data, length(union(winner_id, loser_id))),
    winner_ids = matches_data$winner_idx,
    loser_ids = matches_data$loser_idx,
    labels = matches_data$label,
    grainsize = max(100L, round(nrow(matches_data) / 60))
)

2.3 Model fit

tennis_mod_fit <- tennis_model$sample(
    data = tennis_stan_data,
    seed = 256,
    iter_warmup = 1000,
    iter_sampling = 1000,
    refresh = 0,
    chains = 4,
    parallel_chains = 4,
    threads_per_chain = 7
)
Note

Sampling takes ~2.81 minutes on my CPU (Ryzen 5950X, 16 Cores/32 Threads), on WSL2 (Ubuntu 22)

data.table [4 x 2]
Chain Time
1 169.554s (~2.83 minutes)
2 169.567s (~2.83 minutes)
3 166.78s (~2.78 minutes)
4 168.79s (~2.81 minutes)

3 Model diagnostics


mcmc_neff_hist(neff_ratio(tennis_mod_fit))
mcmc_rhat_hist(rhat(tennis_mod_fit))

Plotting the traces & acf for a random subsets of players:

players_subset <- paste0("player_skills[", sample(player_data$player_idx, 5), "]")

tennis_draws_subset <- tennis_mod_fit$draws(variables = players_subset, format = "draws_list")
wrap_plots(
    mcmc_hist(tennis_draws_subset, facet_args = list(nrow = length(tennis_draws_subset[[1]]))),
    mcmc_trace(tennis_draws_subset, facet_args = list(nrow = length(tennis_draws_subset[[1]]))),
    widths = c(1, 1.5)
)
mcmc_acf(tennis_draws_subset)

Everything seems good enough.

4 Posterior Predictions


4.1 Posterior data

Getting our Posterior Predictions into long format and joining the result with player_data:

player_skills <- tennis_mod_fit$draws(variables = "player_skills") |>
    bind(x, subset_draws(x, "player_skills", regex = TRUE, draw = sample.int(ndraws(x), size = 500))) |>
    as.data.table() |>
    _[, .(player_skills = list(value)), by = variable][, let(
        player_idx = as.integer(str_extract(variable, "\\d{1,4}")),
        variable = NULL
    )][, let(skill_mean = sapply(player_skills, mean), skill_sd = sapply(player_skills, sd))][
        as.data.table(player_data),
        on = "player_idx",
        nomatch = NULL
    ][order(-skill_mean), .(player_name, player_id, player_idx, skill_mean, skill_sd, player_skills)]

player_skills
data.table [4,830 x 6]
player_name player_id player_idx skill_mean skill_sd player_skills
Novak Djokovic 104.925K 2.415K 3.536 0.099 <numeric [500]>
Rafael Nadal 104.745K 2.367K 3.426 0.09 <numeric [500]>
Roger Federer 103.819K 2.082K 3.315 0.081 <numeric [500]>
Bjorn Borg 100.437K 423 3.248 0.102 <numeric [500]>
Ivan Lendl 100.656K 613 3.223 0.086 <numeric [500]>
John McEnroe 100.581K 553 3.186 0.093 <numeric [500]>
Jimmy Connors 100.284K 280 3.166 0.078 <numeric [500]>
Rod Laver 100.029K 28 3.015 0.111 <numeric [500]>
Andy Murray 104.918K 2.413K 2.93 0.102 <numeric [500]>
Pete Sampras 101.948K 1.408K 2.927 0.086 <numeric [500]>
Boris Becker 101.414K 1.127K 2.839 0.089 <numeric [500]>
Andre Agassi 101.736K 1.31K 2.775 0.088 <numeric [500]>
Stefan Edberg 101.222K 993 2.731 0.086 <numeric [500]>
Andy Roddick 104.053K 2.157K 2.696 0.099 <numeric [500]>
Juan Martin del Potro 105.223K 2.503K 2.682 0.111 <numeric [500]>
[ omitted 4,815 entries ]

4.2 Posterior plots

ridgeline_plot
ridgeline_plot <- function(dat, var) {
    dat <- dat[, .(player_skills = unlist(player_skills)), by = setdiff(names(dat), 'player_skills')][,
        player_name := factor(player_name, levels = rev(unique(player_name)))
    ]

    return(
        ggplot(dat, aes(player_skills, y = {{ var }}, fill = {{ var }})) +
            geom_ribbon(
                stat = "density",
                outline.type = "upper",
                color = "grey30",
                aes(
                    fill = stage({{ var }}, after_scale = alpha(fill, 0.5)),
                    ymin = after_stat(group),
                    ymax = after_stat(group + ndensity * 1.6)
                )
            ) *
                ggblend::blend("multiply") +
            labs(x = "Player Skills", y = "") +
            scale_y_discrete(position = "right", labels = \(x) str_replace_all(x, "\\s", "\n")) +
            theme(legend.position = "none", axis.line.y = element_blank())
    )
}

Plotting the player_skills posteriors of the top 10 players:

head(player_skills, 10) |> ridgeline_plot(player_name)

─ Session info ───────────────────────────────────────────────────────────────
 setting        value
 version        R version 4.6.1 (2026-06-24)
 os             Ubuntu 22.04.5 LTS
 system         x86_64, linux-gnu
 ui             X11
 language       (EN)
 collate        C.UTF-8
 ctype          C.UTF-8
 tz             Europe/Berlin
 date           2026-07-13
 pandoc         3.8.2.1
 quarto         1.10.14 @ /usr/local/bin/quarto
 Quarto         1.10.14
 Stan (CmdStan) 2.39.0

─ Packages ───────────────────────────────────────────────────────────────────
 package    * version    date (UTC) lib source
 archive    * 1.1.13     2026-04-12 [1] RSPM
 bayesplot  * 1.15.0     2025-12-12 [1] RSPM
 cmdstanr   * 0.9.0.9001 2026-07-11 [1] Github (stan-dev/cmdstanr@2763c0f)
 crayon     * 1.5.3      2024-06-20 [1] RSPM
 data.table * 1.18.99    2026-07-11 [1] Github (Rdatatable/data.table@806ef72)
 dplyr      * 1.2.1      2026-04-03 [1] RSPM
 fs         * 2.1.0      2026-04-18 [1] RSPM
 ggplot2    * 4.0.3      2026-04-22 [1] RSPM
 gt         * 1.3.0      2026-01-22 [1] RSPM
 here       * 1.0.2      2025-09-15 [1] RSPM
 htmltools  * 0.5.9      2025-12-04 [1] RSPM
 knitr      * 1.51       2025-12-20 [1] RSPM
 lubridate  * 1.9.5      2026-02-04 [1] RSPM
 patchwork  * 1.3.2      2025-08-25 [1] RSPM
 pipebind   * 0.1.2      2023-08-30 [1] RSPM
 posterior  * 1.7.0      2026-04-01 [1] RSPM
 purrr      * 1.2.2      2026-04-10 [1] RSPM
 reactable  * 0.4.5      2025-12-01 [1] RSPM
 readr      * 2.2.0      2026-02-19 [1] RSPM
 stringr    * 1.6.0      2025-11-04 [1] RSPM
 tibble     * 3.3.1      2026-01-11 [1] RSPM
 tidyr      * 1.3.2      2025-12-19 [1] RSPM

 [1] /home/mar/dev/projects/quarto/ma-riviere.com/renv/profiles/dev-4.6/renv/library/linux-ubuntu-jammy/R-4.6/x86_64-pc-linux-gnu
 [2] /opt/R/4.6.1/lib/R/library
 * ── Packages attached to the search path.

──────────────────────────────────────────────────────────────────────────────
Back to top

Citation

BibTeX citation:
@online{rivière2022,
  author = {Rivière, Marc-Aurèle},
  title = {MCMC for “{Big} {Data}” with {Stan}},
  date = {2022-06-05},
  url = {https://ma-riviere.com/content/blog/posts/big-bayes/},
  langid = {en},
  abstract = {This post is an extension (and a translation to R) of
    {[}PyMC-Labs’
    benchmarking{]}(https://www.pymc-labs.io/blog-posts/pymc-stan-benchmark/)
    of MCMC for “Big Data”. The Stan code was updated to use
    {[}within-chain
    parallelization{]}(https://mc-stan.org/docs/2\_30/stan-users-guide/reduce-sum.html)
    and {[}compiler
    optimization{]}(https://mc-stan.org/docs/2\_30/stan-users-guide/optimization.html)
    for faster CPU sampling. Stan was able to achieve similar sampling
    speeds as PyMC’s JAX + GPU solution, purely on CPU.}
}
For attribution, please cite this work as:
Rivière, M.-A. (2022, June 5). MCMC for “Big Data” with Stan. https://ma-riviere.com/content/blog/posts/big-bayes/