42  Hexagon bins

Graphs in this chapter use fmggp2::theme_surge(), built from Surge soda: a black background, a Surge green frame and title, green, red, orange, yellow, and white marks, a dark to bright green continuous scale, and a DIN Alternate title.


Requirements:

✅ two numeric (continuous) variable

42.1 Description

Hexagon bins (or hex-bins) are very similar to 2-D histograms, but instead of dividing the graph area into a grid, it’s divided into hexagons. The number of points inside each hexagon determines its color.

42.2 Set up

PACKAGES:

Install packages.

show/hide
install.packages(c("hexbin", "colorspace"))
library(colorspace)
library(ggplot2)
library(dplyr) # for tibble() and data manipulation

DATA:

Surge launched in 1997 as Coca-Cola’s answer to Mountain Dew, and it was marketed straight at teenagers who played a lot of video games. We’ll simulate 3,000 students with four variables:

  • school and console: drawn with sample() (Nintendo 64 or PlayStation)

  • cans_per_week: how many cans of Surge each student drinks in a week. Most students drink a few and a few drink a lot, so we draw these from a right skewed gamma distribution with rgamma(). College students average 6 cans, high school students 4, and middle school students 2

  • game_hours: hours of video games per week, which go up 1.5 hours for every can of Surge, plus 2 hours for PlayStation owners, plus noise from rnorm()

Setting the seed with set.seed() means you’ll get the same 3,000 students every time you run the code below.

show/hide
set.seed(1996) # same 3,000 teens every time the code runs
surge_teens <- tibble(
  teen_id = 1:3000, # 3,000 different teens, one row each
  school = sample(
    c("Middle school", "High school", "College"), # school level
    size = 3000, replace = TRUE, # one level for each teen
    prob = c(0.3, 0.4, 0.3) # high school is the largest group
    ),
  console = sample(
    c("Nintendo 64", "PlayStation"), # each teen's console
    size = 3000, replace = TRUE # equal chance of either
    )
  ) |>
  mutate(
    avg_cans = c(`Middle school` = 2, `High school` = 4, # average cans of Surge per week
                 College = 6)[school], # (looked up for each school level)
    cans_per_week = round(
      rgamma(n(), shape = 2, rate = 2 / avg_cans), # cans per week (skewed right)
      1 # one decimal place
      ),
    game_hours = round(
      pmax(0, 3 + 1.5 * cans_per_week + # more Surge, more gaming
             if_else(console == "PlayStation", 2, 0) + # PlayStation owners play 2 hours more
             rnorm(n(), mean = 0, sd = 3)), # random noise (no negative hours)
      1 # one decimal place
      )
    ) |>
  select(-avg_cans) # drop the helper column
glimpse(surge_teens)
#> Rows: 3,000
#> Columns: 5
#> $ teen_id       <int> 1, 2, 3, 4, 5, 6, 7, 8, 9,…
#> $ school        <chr> "Middle school", "High sch…
#> $ console       <chr> "Nintendo 64", "PlayStatio…
#> $ cans_per_week <dbl> 1.8, 0.9, 1.1, 0.8, 5.2, 6…
#> $ game_hours    <dbl> 7.1, 6.1, 2.8, 8.2, 10.5, …

42.3 Distribution

Every hexagon in a hex bin plot is a count, and the size of the hexagons controls how big those counts are. Big hexagons hold lots of students each, so their colors are steady but blurry. Small hexagons show more detail, but each one holds only a few students, so their colors are noisy. Below we’ll move from the counts, to a picture, to the formula.

COUNT IT:

Start with the first five students.

show/hide
head(surge_teens, 5)

layer_data() returns the data behind a ggplot2 layer, including the count in every hexagon. Below we compare the counts for 15 bins and 40 bins.

show/hide
hex_counts <- function(bins) {
  hex_plot <- ggplot(surge_teens, aes(x = cans_per_week, y = game_hours)) +
    geom_hex(bins = bins)
  layer_data(hex_plot) |>
    summarise(bins = bins, hexagons = n(), max_count = max(count),
              median_count = median(count),
              under_5 = mean(count < 5))
}
hex_compare <- bind_rows(hex_counts(15), hex_counts(40))
hex_compare

With 15 bins, 88 hexagons hold the 3,000 students, and the median hexagon holds 7.5 of them. With 40 bins, there are 364 hexagons, the median hexagon holds only 3 students, and 59% of the hexagons hold fewer than five.

DRAW IT:

The two graphs below draw the same students with 15 and 40 bins. Both show the same rising cloud (more Surge, more video games) that’s crowded in the lower left. The 15 bin version is smooth. The 40 bin version shows more of the shape, but it speckles at the edges, where neighboring hexagons with counts of 1, 2, and 3 sit side by side.

show/hide
ggplot(surge_teens, aes(x = cans_per_week, y = game_hours)) +
  geom_hex(bins = 15) +
  labs(title = "15 bins", x = "Cans per week", y = "Hours of games") +
  theme(legend.position = "bottom")
ggplot(surge_teens, aes(x = cans_per_week, y = game_hours)) +
  geom_hex(bins = 40) +
  labs(title = "40 bins", x = "Cans per week", y = "Hours of games") +
  theme(legend.position = "bottom")

NAME IT:

The cans come from a gamma distribution (right skewed), and the hours come from a normal distribution centered on a straight line:

\[ \text{cans} \sim \text{Gamma}\left(k = 2, \; \theta = \frac{\text{avg cans}}{2}\right), \quad \text{hours} \mid \text{cans} \sim \text{Normal}(3 + 1.5 \times \text{cans} + 2 \times \text{PlayStation}, \; 3) \]

The skew in the cans is why the hexagons pile up in the lower left and thin out to the right. The count in each hexagon is approximately Poisson with a mean of \(\lambda = 3000 \times p\), where \(p\) is the chance a student lands in that hexagon. A Poisson count wobbles by about \(\sqrt{\lambda}\), so its relative noise is \(\sqrt{\lambda} / \lambda = 1 / \sqrt{\lambda}\). A hexagon expecting 100 students is off by about 10%, but a hexagon expecting 4 students is off by about 50%. Smaller hexagons mean smaller \(\lambda\), and that’s where the speckles come from. Try changing size = 3000 to size = 30000 and the 40 bin graph smooths out.

42.4 Grammar

CODE:

  • Create labels with labs()

  • Initialize the graph with ggplot() and provide data

  • Map cans_per_week to the x and game_hours to the y

  • Add the geom_hex() layer

show/hide
labs_hex <- labs(
    title = "Surge soda and video games",
    subtitle = "Cans per week vs. hours of video games per week",
    caption = "Simulated data",
    x = "Cans of Surge per week",
    y = "Gaming hours per week")
# graph
ggp2_hex <- ggplot(data = surge_teens,
    aes(x = cans_per_week, y = game_hours)) +
    geom_hex()

ggp2_hex +
    labs_hex

GRAPH:

42.5 More info

42.5.1 Bins

Below we change the bins to 20 and 15 and save these layers as ggp2_hex_b20 and ggp2_hex_b15.

Decreasing the number of bins increases the size of the hexagons.

show/hide
ggp2_hex_b20 <- ggplot(data = surge_teens,
    aes(x = cans_per_week, y = game_hours)) +
    geom_hex(bins = 20)
ggp2_hex_b20 +
    labs_hex


ggp2_hex_b15 <- ggplot(data = surge_teens,
    aes(x = cans_per_week, y = game_hours)) +
    geom_hex(bins = 15)
ggp2_hex_b15 +
    labs_hex

42.5.2 Scale

  • We can adjust the color scale using scale_color_discrete_sequential() from the colorspace package and setting aesthetics to "fill".

  • If the hexagons overlap, we can use the alpha to make them slightly transparent.

show/hide
labs_hex2 <- labs(
    title = "Surge soda and video games",
    caption = "Simulated data",
    x = "Cans of Surge per week",
    y = "Gaming hours per week",
    fill = "Console")
ggplot(data = surge_teens,
    aes(x = cans_per_week,
        y = game_hours)) +
    geom_hex(aes(fill = console),
             bins = 15,
             alpha = 3/4) +
    scale_color_discrete_sequential(
        aesthetics = "fill",
        rev = FALSE,
        palette = "Viridis") +
    labs_hex2

Get a full list of available color palettes using hcl_palettes(type = "sequential")

42.5.3 Options

  • binwidth allows us to manually adjust the size of the hexagons.

  • linewidth is also helpful when using alpha for overlapping values.

show/hide
labs_hex3 <- labs(
    title = "Surge soda and video games",
    caption = "Simulated data",
    x = "Cans of Surge per week",
    y = "Gaming hours per week",
    fill = "School")

ggplot(data = surge_teens,
    aes(x = cans_per_week,
        y = game_hours,
        fill = school)) +
    geom_hex(binwidth = c(1.5, 3),
        linewidth = 0.5,
        alpha = 3/4,
        color = "#000000") +
    scale_color_discrete_sequential(
        aesthetics = "fill",
        palette = "Dark Mint") +
    labs_hex3

Bins can be set with bins (a single number) or binwidth (a numeric vector of c(x, y))