22  Beeswarm plots

Graphs in this chapter use fmggp2::theme_tetris(), modeled on Tetris: a black well, the tetromino block colors as marks, and the pixel font Press Start 2P (bundled with the package) for the title.


Requirements:

✅ a categorical variable

✅ a numeric (continuous) variable

22.1 Description

A beeswarm plot shows each number as its own point, so we can see where the numbers fall in each group.1

The points for each group stay close together and fall to the left and right of the group’s place on the x axis. The form the points make in each group is called a swarm.2

We can make a beeswarm plot with the ggbeeswarm code.3

22.2 Set up

PACKAGES:

Bring in the code we’ll use.4

show/hide
# pak::pak("eclarke/ggbeeswarm")
library(ggbeeswarm)
library(ggplot2)
library(dplyr) # for tibble() and data manipulation

DATA:

The Game Boy is a small computer for playing games that you hold in your hands. Tetris is a game where small pieces fall from the top, and you fit them together to make a full line from side to side and clear it. Tetris came with every Game Boy,5 and by the 90s everyone had a best game to talk about. We’ll make up6 the number of lines cleared in 80 games by each of three players. rnbinom() picks how many lines each game clears from a rule for numbers that can fall far from their average,7 with a different average (mu) for each player: 25 lines for a casual player, 60 for a regular, and 120 for an expert. Starting with set.seed()8 means you’ll get the same 240 games every time you run the code below.

show/hide
set.seed(1989) # same 240 games every time the code runs
tetris_games <- tibble(
  game_id = 1:240, # 240 different games of Tetris, one row each
  player = rep(c("Casual", "Regular", "Expert"), each = 80) # 80 games for each player type
  ) |>
  mutate(
    player = factor(player, levels = c("Casual", "Regular", "Expert")), # least to most skilled
    lines_cleared = rnbinom(n(),
      size = 5, # small size spreads the counts out
      mu = c(Casual = 25, Regular = 60, # average lines cleared
             Expert = 120)[as.character(player)] # (looked up for each player type)
      )
    )
glimpse(tetris_games)
#> Rows: 240
#> Columns: 3
#> $ game_id       <int> 1, 2, 3, 4, 5, 6, 7, 8, 9,…
#> $ player        <fct> Casual, Casual, Casual, Ca…
#> $ lines_cleared <dbl> 42, 6, 21, 30, 15, 36, 31,…

22.3 Distribution

A beeswarm plot shows every game as its own point, so we can see how far the numbers in each group fall from each other.9 A count is how many times something happens. Lines cleared are counts, but they fall much farther from their average than the counts we saw before: one bad drop can end a game early, and one good run can keep it going for a long time. Below we’ll move from the games, to a graph, to the rule behind it.10

COUNT IT:

Start with the first ten games (all from the casual player).

show/hide
head(tetris_games$lines_cleared, 10)
#>  [1] 42  6 21 30 15 36 31  9 26 30

The casual player clears about 25 lines on average, but these games run from 6 to 42. The variance is a number for how far the numbers fall from the mean.11 The numbers below show the variance for each player.12 For counts that come at a set rate (like the number of books kids read, which we saw before),13 the variance would be close to the mean. Here the variance is many times larger than the mean, and the difference grows as the player gets better.

show/hide
tetris_games |> 
  group_by(player) |> 
  summarise(mean = mean(lines_cleared), variance = var(lines_cleared),
            min = min(lines_cleared), max = max(lines_cleared))

DRAW IT:

The beeswarms below compare our regular player’s 80 games to 80 games picked from the set rate rule with the same average (60 lines).14 The Poisson swarm stays close together around 60. The Negative binomial swarm runs from under 10 to more than two times the average, with a long line of great games at the top.15

show/hide
regular_games <- tibble(
  model = rep(c("Negative binomial", "Poisson"), each = 80),
  lines_cleared = c(
    tetris_games$lines_cleared[tetris_games$player == "Regular"],
    rpois(80, lambda = 60)))

ggplot(regular_games, aes(x = model, y = lines_cleared)) + 
  ggbeeswarm::geom_beeswarm(size = 1.6, color = "#00F0F0") + 
  geom_hline(yintercept = 60, linetype = "dashed", color = "#F5F5F5") + 
  annotate("text", x = 0.5, y = 60, label = "Average: 60 lines",
           hjust = 0, vjust = -0.5, color = "#F5F5F5") + 
  labs(title = "Same average, wider spread",
       x = NULL, y = "Lines cleared")

NAME IT:

The rule we used16 is often used for counts that fall farther from their average than the set rate rule allows.17 It has two numbers in it,18 an average (\(\mu\)) and a size (\(r\)), and its variance is:

\[ X \sim \text{NB}(\mu, \; r = 5), \quad \text{Var}(X) = \mu + \frac{\mu^2}{r} \]

The first part (\(\mu\)) is the variance of the set rate rule, and the second part (\(\mu^2 / r\)) adds more on top.19 For the regular player that’s \(60 + 3600 / 5 = 780\), but only 60 for the set rate rule. This extra part grows with \(\mu^2\), so the expert’s games (\(\mu = 120\)) fall much farther from their average than the casual player’s (\(\mu = 25\)). As \(r\) gets larger, the extra part gets smaller and our rule turns into the set rate rule.20 Try size = 500 in rnbinom() and the three swarms come in close.

22.4 Grammar

CODE:

  • Make the words around the graph21 with labs()

  • Start the graph with ggplot() and give it data

  • Put player on the x axis

  • Put lines_cleared on the y axis

  • Add ggbeeswarm::geom_beeswarm() on top22 (with alpha, so we can see through the points)

show/hide
labs_beeswarm <- labs(
    title = "Tetris",
    caption = "Simulated data",
    subtitle = "Lines cleared in 80 games by each player",
    x = "Player",
    y = "Lines cleared")
ggp2_beeswarm <- ggplot(data = tetris_games,
    aes(x = player,
        y = lines_cleared)) +
    ggbeeswarm::geom_beeswarm(alpha = 2 / 3, color = "#00F0F0")
ggp2_beeswarm +
    labs_beeswarm

GRAPH:

Below we change the form of the swarm with method. The ggbeeswarm code also has geom_quasirandom(), another way to place the points.23

22.5 More info

Below are a few more things we can set24 and ways to make beeswarm plots.

22.5.1 method


Use method to change the form of the beeswarm (swarm, compactswarm, hex, square, center, or centre)

Set the point shape to 21 so you can choose both its fill and its color

show/hide
ggp2_compact_swarm <- ggplot(data = tetris_games,
    mapping = aes(x = player,
                  y = lines_cleared)) +
    ggbeeswarm::geom_beeswarm(
      aes(fill = player),
        method = 'compactswarm',
        shape = 21,
        color = "#ffffff",
        alpha = 2/3, size = 1.7,
        show.legend = FALSE)
ggp2_compact_swarm +
    # add labels
    labs_beeswarm

22.5.2 side


To put the points on one side of each group only, use side.25

show/hide
ggp2_rside_swarm <- ggplot(data = tetris_games,
    mapping = aes(x = player,
                  y = lines_cleared)) +
    ggbeeswarm::geom_beeswarm(
      aes(fill = player),
        side = 1, # right side only
        shape = 21,
        color = "#ffffff",
        alpha = 2/3,
        size = 1.7,
        show.legend = FALSE)
ggp2_rside_swarm +
    # add labels
    labs_beeswarm

22.5.3 cex


The cex setting26 controls the “scaling for adjusting point spacing”

show/hide
ggp2_beeswarm_cex <- ggplot(data = tetris_games,
    mapping = aes(x = player,
                  y = lines_cleared)) +
    ggbeeswarm::geom_beeswarm(
      aes(fill = player),
        cex = 1.6,
        shape = 21,
        color = "#ffffff",
        alpha = 2/3,
        size = 1.7,
        show.legend = FALSE)
ggp2_beeswarm_cex +
    # add labels
    labs_beeswarm

22.5.4 geom_jitter()


geom_jitter() does not make a beeswarm. It moves each point to the side by a small amount picked by chance, so points can still land on top of each other. We set width to move the points to the side, and height = 0 so no count moves up or down.27

show/hide
ggp2_jitter_swarm <- ggplot(data = tetris_games,
    mapping = aes(x = player,
        y = lines_cleared)) +
    geom_jitter(
        aes(fill = player),
        height = 0,
        width = 0.11,
        shape = 21,
        color = "#ffffff",
        alpha = 2 / 3,
        size = 1.7,
        show.legend = FALSE)
ggp2_jitter_swarm +
    # add labels
    labs_beeswarm +
    labs(caption = "Simulated data; points jittered to the side at random")


  1. The beeswarm plot displays the distribution of a continuous variable across the levels of a categorical variable.↩︎

  2. The points are grouped by level and spread sideways along the categorical (player) axis, on both sides of each group by default, so the swarm takes the shape of the distribution (similar to a violin plot).↩︎

  3. The ggbeeswarm package.↩︎

  4. Install and load the packages.↩︎

  5. Tetris came bundled with the Game Boy.↩︎

  6. Simulate.↩︎

  7. A negative binomial distribution.↩︎

  8. Setting the random seed.↩︎

  9. The spread of each group, shown directly.↩︎

  10. The formula.↩︎

  11. The variance: the average squared distance from the mean (var()).↩︎

  12. Summary statistics: the mean, variance, minimum, and maximum for each player.↩︎

  13. Poisson counts, like the Goosebumps books in Chapter 21.↩︎

  14. A Poisson distribution with \(\lambda = 60\) (rpois(80, lambda = 60)).↩︎

  15. The negative binomial swarm stretches from single digits to more than twice the average, with a long right tail.↩︎

  16. The negative binomial distribution.↩︎

  17. Overdispersion: counts more spread out than a Poisson distribution allows.↩︎

  18. Its parameters: the mean \(\mu\) and the size (or dispersion) parameter \(r\).↩︎

  19. The first term is the Poisson variance; the second term is the extra variance.↩︎

  20. As \(r \to \infty\), the negative binomial converges to the Poisson.↩︎

  21. Labels.↩︎

  22. Add the geom_beeswarm() layer.↩︎

  23. geom_quasirandom() spreads the points with a quasirandom sequence instead of placing them one by one; we don’t show it here.↩︎

  24. Additional arguments.↩︎

  25. The side argument: 0 (the default) uses both sides, 1 the right side only, and -1 the left side only.↩︎

  26. The cex argument.↩︎

  27. geom_jitter() adds random noise to each point’s position (width sideways, height up and down). It is a contrast to a beeswarm, not a beeswarm, and the jitter is noted in the caption.↩︎