37  Bubble charts

Graphs in this chapter use fmggp2::theme_seinfeld(), built from Seinfeld: a comedy club brick background, a yellow frame and title, the yellow, red, blue, and green logo colors for marks, cream text, and an American Typewriter title.


Requirements:

✅ three numeric (continuous) variables

37.1 Description

Bubble graphs display relationships between three numeric (continuous) variables.

The position on the x and y axis illustrates the relationship between the first two variables, and the size of the point (i.e., the bubble) conveys the numeric value of the third.

37.2 Set up

PACKAGES:

Install packages.

show/hide
library(ggplot2)
library(dplyr) # for tibble() and data manipulation

DATA:

Seinfeld was famously a show about nothing, and every episode got a bump whenever Kramer slid through Jerry’s door. We’ll simulate 180 episodes across nine seasons (20 per season) with three numeric variables:

  • kramer_minutes: Kramer’s screen time, drawn from a right skewed gamma distribution with rgamma()

  • laughs_per_min: the studio audience’s laughs per minute, which go up 0.3 for every minute of Kramer (plus noise from rnorm())

  • viewers: the audience in millions, which grows by about 3 million each season. We draw these on the log scale with rnorm() and convert them back with exp()

Setting the seed with set.seed() means you’ll get the same 180 episodes every time you run the code below.

show/hide
set.seed(1989) # same 180 episodes every time the code runs
seinfeld_eps <- tibble(
  episode = 1:180, # 180 episodes, one row each
  season = rep(1:9, each = 20) # nine seasons with 20 episodes each
  ) |>
  mutate(
    kramer_minutes = round(
      rgamma(n(), shape = 6, rate = 1.5), # Kramer's screen time (centered near 4 minutes)
      1 # one decimal place
      ),
    laughs_per_min = round(
      2 + 0.3 * kramer_minutes + # more Kramer means more laughs
        rnorm(n(), mean = 0, sd = 0.5), # random episode to episode noise
      1 # one decimal place
      ),
    viewers = round(
      exp(rnorm(n(), mean = log(10 + 3 * (season - 1)), # audience (millions) grows each season
                sd = 0.15)), # random episode to episode noise
      1 # one decimal place
      )
    )
glimpse(seinfeld_eps)
#> Rows: 180
#> Columns: 5
#> $ episode        <int> 1, 2, 3, 4, 5, 6, 7, 8, 9…
#> $ season         <int> 1, 1, 1, 1, 1, 1, 1, 1, 1…
#> $ kramer_minutes <dbl> 5.6, 5.6, 1.4, 2.7, 3.1, …
#> $ laughs_per_min <dbl> 3.8, 2.0, 2.4, 2.7, 3.0, …
#> $ viewers        <dbl> 13.8, 10.6, 10.8, 8.2, 10…

37.3 Distribution

A bubble chart adds a third variable to a scatter plot with the size of each point. Our eyes read the area of a bubble, not its width, so how a value gets turned into a size matters as much as the value itself. Below we’ll move from the viewers, to a picture, to the formula.

COUNT IT:

Start with the first five episodes.

show/hide
head(seinfeld_eps, 5)

Below we summarise the viewers for each season. The mean grows each season, and so does the standard deviation, but their ratio (cv) stays close to 0.15.

show/hide
seinfeld_seasons <- seinfeld_eps |>
  group_by(season) |>
  summarise(mean_viewers = mean(viewers),
            sd_viewers = sd(viewers),
            cv = sd_viewers / mean_viewers)
seinfeld_seasons

DRAW IT:

The graph below draws three bubbles for 10, 20, and 40 million viewers, two ways. On the left, scale_size() makes the area of each bubble proportional to the value (so 40 million looks four times bigger than 10 million). On the right, scale_radius() makes the width proportional to the value, and 40 million looks sixteen times bigger than 10 million.

show/hide
three_bubbles <- tibble(x = 1:3, y = 1, viewers = c(10, 20, 40))
ggplot(three_bubbles, aes(x = x, y = y, size = viewers)) +
  geom_point(color = "#F7C33C") +
  scale_size(range = c(0, 20), limits = c(0, 40)) +
  scale_x_continuous(limits = c(0.5, 3.5)) +
  labs(title = "scale_size()", x = NULL, y = NULL) +
  theme(legend.position = "none", axis.text = element_blank())
ggplot(three_bubbles, aes(x = x, y = y, size = viewers)) +
  geom_point(color = "#F7C33C") +
  scale_radius(range = c(0, 20), limits = c(0, 40)) +
  scale_x_continuous(limits = c(0.5, 3.5)) +
  labs(title = "scale_radius()", x = NULL, y = NULL) +
  theme(legend.position = "none", axis.text = element_blank())

scale_size() is the default for size in ggplot2, and it’s the right choice for bubble charts.

NAME IT:

The viewers come from a lognormal distribution (normal on the log scale), with a median that grows each season:

\[ \log(\text{viewers}) \sim \text{Normal}\left(\log(10 + 3(\text{season} - 1)), \; 0.15\right) \]

On the log scale every season has the same spread (0.15), which is why the cv stays near 0.15 in every season: a lognormal variable wobbles by about the same percent, not the same number of viewers. When we map viewers to size, scale_size() sets each bubble’s area in proportion to its value:

\[ \text{area} \propto \text{viewers}, \quad \text{radius} \propto \sqrt{\text{viewers}} \]

A season nine episode with about 34 million viewers gets a bubble about 3.2 times the area of a season one episode (10 million), but only about 1.8 times as wide. Try changing sd = 0.15 to sd = 0.5 and the bubbles within each season range from tiny to huge.

37.4 Grammar

CODE:

  • Create labels with labs()

  • Map kramer_minutes to x axis

  • Map laughs_per_min to y axis

  • Map viewers to size

  • Set alpha to 1/3

  • Add scale_size(), set the range to c(0.1, 10) and name to "Viewers (millions)"

  • Move the legend to the bottom of the graph with theme(legend.position = "bottom")

show/hide
labs_bubble <- labs(
  title = "Seinfeld episodes",
  subtitle = "Kramer's screen time vs. laughs per minute",
  caption = "Simulated data",
  x = "Kramer screen time (minutes)", y = "Laughs per minute",
  size = "Viewers (millions)")

ggp2_bubble <- seinfeld_eps |>
  ggplot(
    aes(x = kramer_minutes, y = laughs_per_min)) +
  geom_point(aes(size = viewers), alpha = 1/3) +
  scale_size(range = c(.1, 10),
             name = "Viewers (millions)") +
  theme(legend.position = "bottom")

ggp2_bubble +
  labs_bubble

GRAPH: