27  Raincloud plots

Graphs in this chapter use fmggp2::theme_rzone(), built from the Tiger R-Zone headset (1995): black plastic, a red frame and title, red LCD, purple logo, and pink marks, a pale blue visor text color, and an Arial Narrow title.


Requirements:

✅ a categorical variable

✅ a numeric (continuous) variable

27.1 Description

Raincloud plots are a combination of a density graph, a box plot, and a beeswarm (or jitter) plot. They’re used to compare distributions of quantitative/numerical variables across the levels of a categorical (or discrete) grouping variable.

We can use the raincloudplots package to create raincloud plots, or they can be built using the ggdist package and geoms from ggplot2.

27.2 Set up

PACKAGES:

Install packages.

show/hide
remotes::install_github('jorvlan/raincloudplots')
remotes::install_github('mjskay/ggdist')
install.packages("faux")
library(raincloudplots)
library(ggdist)
library(faux) # for rnorm_multi()
library(ggplot2)
library(dplyr) # for tibble() and data manipulation

DATA:

Tiger’s R-Zone (1995) was a game console you wore on your head: a cartridge projected red LCD graphics onto a small visor in front of one eye. The games were simple, so most players cleared the first level much faster on their second try (if their eyes held out). We’ll simulate how many seconds it took 100 players to clear the first level on their first and second tries.

faux::rnorm_multi() draws two correlated columns from a normal distribution: the log of the first try (centered on 40 seconds) and the log of the second try (centered on 20 seconds). The correlation (r) of 0.7 means players who are slow the first time tend to be slow the second time too. exp() turns the logs back into seconds. Setting the seed with set.seed() means you’ll get the same 100 players every time you run the code below.

The raincloudplots package has a data_1x1() function we can use to build the dataset for a 1x1 repeated measure graph (rzone_1x1). This function takes two array arguments (array_1 and array_2), which we create from the first_try and second_try columns. The jit_distance and jit_seed refer to the points in the plot.

show/hide
set.seed(1993) # same 100 players every time the code runs
rzone_tries <- faux::rnorm_multi(n = 100, # 100 different players
    mu = c(log(40), log(20)), # first try averages 40 seconds, second try 20 (on the log scale)
    sd = 0.5, # spread on the log scale
    r = 0.7, # players who are slow the first time are slow the second time
    varnames = c("log_first", "log_second")) |>
  as_tibble() |>
  mutate(
    player_id = 1:100, # one row per player
    first_try = round(exp(log_first), 1), # back to seconds
    second_try = round(exp(log_second), 1) # back to seconds
    ) |>
  select(player_id, first_try, second_try)
rzone_1x1 <- raincloudplots::data_1x1(
  array_1 = rzone_tries$first_try, # first group of points
  array_2 = rzone_tries$second_try, # second group of points
  jit_distance = 0.2, # distance between points
  jit_seed = 2736) # used in set.seed()
glimpse(rzone_1x1)
#> Rows: 200
#> Columns: 4
#> $ y_axis <dbl> 37.1, 36.8, 19.1, 29.1, 64.5, 56.…
#> $ x_axis <dbl> 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, …
#> $ id     <fct> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11…
#> $ jit    <dbl> 1.0200375, 0.9498208, 0.8820888, …

27.3 Distribution

A raincloud plot shows a whole distribution three ways at once (the cloud, the box, and the rain). The R-Zone data adds a twist: the two groups are the same 100 players measured twice, so each first try has a partner second try. Comparing the two clouds only tells part of the story; the pairs tell the rest. Below we’ll move from the players, to a picture, to the formula.

COUNT IT:

Start with the first five players.

show/hide
head(rzone_tries, 5)

Four of these five players cleared the level much faster on their second try, but player 3 took more than twice as long. The summary below compares the two tries for all 100 players. The median drops by about half, almost every player was faster the second time, and the correlation shows that slow first tries go with slow second tries.

show/hide
rzone_tries |>
  summarise(median_first = median(first_try),
            median_second = median(second_try),
            share_faster = mean(second_try < first_try),
            correlation = cor(log(first_try), log(second_try)))

DRAW IT:

The graph below plots each player’s two tries against each other on log scales. The solid line is “no change” (second try = first try), and the dashed line is “twice as fast” (second try = half the first try). The points cluster around the dashed line, and they fall along a band rather than a blob because of the correlation.

show/hide
guide_lines <- tibble(
  line = c("No change", "Two times as fast"),
  intercept = c(0, log10(0.5)))
ggplot(rzone_tries, aes(x = first_try, y = second_try)) +
  geom_abline(data = guide_lines,
    aes(slope = 1, intercept = intercept, color = line, linetype = line)) +
  geom_point(shape = 21, fill = "#FF2E93", color = "#ffffff",
    size = 2.2, alpha = 0.8) +
  scale_x_log10() +
  scale_y_log10() +
  scale_color_manual(values = c("#F2F0FF", "#14D8C4")) +
  scale_linetype_manual(values = c("solid", "dashed")) +
  labs(x = "First try (seconds, log10 scale)",
       y = "Second try (seconds, log10 scale)",
       color = NULL, linetype = NULL) +
  theme(legend.position = "top")

NAME IT:

The two log times come from a bivariate normal distribution, which makes each try log normal on the seconds scale:

\[ \begin{pmatrix} \log(\text{first}) \\ \log(\text{second}) \end{pmatrix} \sim \text{Normal}\left(\begin{pmatrix} \log 40 \\ \log 20 \end{pmatrix}, \; \sigma = 0.5, \; \rho = 0.7\right) \]

The difference between the two logs is the log of the speed up, \(\log(\text{second} / \text{first})\). It’s centered on \(\log(20 / 40) = \log(0.5)\) (twice as fast), and its standard deviation is \(\sigma\sqrt{2(1 - \rho)} = 0.5\sqrt{0.6} \approx 0.39\). The correlation shrinks that spread: if the two tries were independent (\(\rho = 0\)), the spread would be \(0.5\sqrt{2} \approx 0.71\), and many more players would have been slower the second time. That’s why paired data (and raincloud plots that connect the pairs) can show a difference that two separate clouds hide. Try changing r to 0 in rnorm_multi() and the band of points spreads into a cloud.

27.4 Grammar (raincloudplots)

CODE:

Create labels with labs()

Use the raincloudplots::raincloud_1x1_repmes() to build the plot, assigning rzone_1x1 to data_1x1

  • assign colors and fills

  • set the line_color and line_alpha (for the lines that connect each player’s two tries)

  • set the size (of the points) and alpha (for opacity)

show/hide
ggp2_raincloud <- raincloudplots::raincloud_1x1_repmes(
    data_1x1 = rzone_1x1,
    colors = (c('#FF2E93', '#14D8C4')),
    fills = (c('#FF2E93', '#14D8C4')),
    line_color = "#F2F0FF",
    line_alpha = 0.3,
    size = 0.8,
    alpha = 3/4)
ggp2_raincloud_x <- ggp2_raincloud +
    ggplot2::scale_x_continuous(
        breaks = c(1, 2),
        labels = c("First try", "Second try"),
        limits = c(0, 3))
ggp2_raincloud_x +
    ggplot2::labs(
        title = "Tiger R-Zone",
        caption = "Simulated data",
        subtitle = "100 players, two tries each",
        x = NULL,
        y = "Seconds to clear the first level")

GRAPH:

27.5 More info

DATA:

We’ll simulate first tries for 80 players on each of three kinds of R-Zone games (rzone_games). Each kind of game gets its own typical time (15, 30, or 60 seconds), and rlnorm() draws each player’s time.

show/hide
set.seed(1994) # same 240 players every time the code runs
rzone_games <- tibble(
  player_id = 1:240, # 240 different players, one row each
  game = rep(c("Racing", "Fighting", "Shooter"), each = 80) # 80 players per game
  ) |>
  mutate(
    game = factor(game,
      levels = c("Racing", "Fighting", "Shooter")), # shortest to longest
    seconds = round(
      rlnorm(n(), sdlog = 0.5, # seconds to finish the first level (skewed right)
        meanlog = log(c(Racing = 15, Fighting = 30, # typical seconds
                        Shooter = 60)[as.character(game)])), # (looked up for each game)
      1 # one decimal place
      )
    )
glimpse(rzone_games)
#> Rows: 240
#> Columns: 3
#> $ player_id <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,…
#> $ game      <fct> Racing, Racing, Racing, Racing…
#> $ seconds   <dbl> 7.9, 17.3, 35.1, 30.2, 15.8, 7…

27.5.1 Box-plot

  • Create labels with labs()

  • Initialize the graph with ggplot() and provide data

  • For the first layer, we create a box plot with geom_boxplot(), but include notches and remove the outliers (the points we add below still show them).

show/hide
labs_raincloud_2 <- ggplot2::labs(
    title = "Tiger R-Zone",
    caption = "Simulated data",
    x = "Seconds to clear the first level",
    y = "Game")
ggp2_box <- ggplot(rzone_games,
    aes(x = seconds, y = game)) +
  geom_boxplot(aes(fill = game),
    notch = TRUE,
    notchwidth = 0.9,
    width = 0.15,
    outlier.shape = NA,
    show.legend = FALSE)
ggp2_box +
    labs_raincloud_2

27.5.2 Density

We then add a horizontal density curve with ggdist::stat_halfeye(), mapping game to fill, and adjusting the size and shape of the density curve and shifting it slightly above the box plot.

show/hide
ggp2_stat_halfeye <- ggp2_box +
  ggdist::stat_halfeye(aes(fill = game),
    adjust = 0.6, # shape = adjust * density estimator
    .width = 0, # can use probabilities or 0
    point_colour = NA, # removes the point in center
    orientation = "horizontal", # like the box plot
    height = 0.5, # height of curve
    justification = -0.3, # shift vertically above box
    show.legend = FALSE # don't need this
)
ggp2_stat_halfeye +
    labs_raincloud_2

27.5.3 Points

The final layer is a geom_point(), mapping fill to game and setting position to position_jitter(). Additional adjustments to the points include:

  • Using shape = 21, we can color the outside of the point (white makes it appear to glow).

  • Manually set the height, which refers to the vertical area for the points, and set width = 0 so each point stays at its true number of seconds

show/hide
ggp2_jitter <- ggp2_stat_halfeye +
    geom_point(aes(fill = game),
        position = position_jitter(
          seed = 321,
          width = 0,
          height = .07),
            shape = 21,
            color = "#ffffff",
            alpha = 1/3,
            size = 1.8,
            show.legend = FALSE)
ggp2_jitter +
    labs_raincloud_2

27.6 More examples & resources

27.6.1 Point shape

Cédric Scherer covered raincloud plots in this great write-up for #TidyTuesday.

Cédric also covers some alternative methods for plotting the points (I particularly like using bands instead of points when displaying the rainclouds vertically).

We can switch to this layout by applying ggplot2::coord_flip() to the ggp2_stat_halfeye layer, then adding geom_point() with shape set to 95

show/hide
ggp2_stat_halfeye +
    ggplot2::coord_flip() +
ggplot2::geom_point(
    shape = 95,
    size = 8,
    alpha = 0.2) +
    theme(legend.position = "none") +
    labs_raincloud_2

27.6.2 #TidyTuesday example

The code to re-create the #TidyTuesday graph is contained in this gist.

27.6.3 More resources

MORE RESOURCES

  1. Raincloud plots: a multi-platform tool for robust data visualization. 1

  2. Shape and point sizes in R. 2

  3. RainCloudPlots package on GitHub. 3