
30 Bump charts
theme_visor()
Graphs in this chapter use fmggp2::theme_visor(), built from a green Handspring Visor Deluxe: a pale lavender background, a translucent green frame, a gray LCD panel, green, gray, mint, and silver marks, and a Trebuchet MS title.
30.1 Description
Bump charts show how the ranks of different categories (or groups) change over time. Differences are represented with connecting lines (along the y axis) that cover the full timescale (along the x axis).
We can build bump charts in ggplot2 with the ggbump package:
30.2 Set up
PACKAGES:
Install packages.
show/hide
# pak::pak("davidsjoberg/ggbump")
library(ggbump)
library(ggplot2)
library(dplyr) # for data manipulation
library(tidyr) # for expand_grid()DATA:
Handspring launched the Visor in 1999, and the Springboard expansion slot on its back turned the handheld into whatever module you slid in. By the end of 2000 there was a modem, a games cartridge, an MP3 player, a GPS receiver, a camera, and the new VisorPhone (which turned the Visor into a cell phone).
We’ll simulate 12 weeks of module sales (the number of units sold across a panel of retailers) for six Springboard modules. Each module gets a weekly average (avg_sales) that rises, falls, or holds steady, and rpois() draws each week’s actual sales around that average. Then we rank the modules within each week (rank), with 1 for the most units sold. Setting the seed with set.seed() means you’ll get the same 72 module weeks every time you run the code below.
show/hide
set.seed(2000) # same 12 weeks of sales every time the code runs
springboard_sales <- expand_grid(
week = 1:12, # 12 weeks of sales
module = c("Modem", "VisorPhone", "Games", # six Springboard modules
"MP3 Player", "GPS", "Camera") # (every module appears in every week)
) |>
mutate(
module = factor(module, levels = unique(module)), # keep modules in the order above
week_of = as.Date("2000-11-04") + 7 * (week - 1), # first day of each week
avg_sales = case_when( # average weekly sales for each module
module == "Modem" ~ 420 - 8 * (week - 1), # starts on top, then slowly falls
module == "VisorPhone" ~ 300 + 15 * (week - 1), # climbs each week
module == "Games" ~ 330, # holds steady
module == "MP3 Player" ~ 360 - 5 * (week - 1), # slowly falls
module == "GPS" ~ 200 + 20 * (week - 1), # starts last, climbs fastest
module == "Camera" ~ 280 - 10 * (week - 1) # falls each week
),
sales = rpois(n(), lambda = avg_sales) # weekly sales counts around each average
) |>
group_by(week) |> # rank the modules within each week
mutate(
rank = rank(-sales, ties.method = "random") # 1 = best seller (ties broken at random)
) |>
ungroup()
glimpse(springboard_sales)
#> Rows: 72
#> Columns: 6
#> $ week <int> 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, …
#> $ module <fct> Modem, VisorPhone, Games, MP3 …
#> $ week_of <date> 2000-11-04, 2000-11-04, 2000-…
#> $ avg_sales <dbl> 420, 300, 330, 360, 200, 280, …
#> $ sales <int> 402, 295, 361, 377, 183, 255, …
#> $ rank <int> 1, 4, 3, 2, 6, 5, 1, 4, 3, 2, …30.3 Distribution
A bump chart throws away the raw numbers and keeps only the order. That makes it easy to see who passed whom, but it also hides how close the race was. When two modules have similar sales, random week to week noise can swap their ranks even though nothing real changed. Below we’ll move from the sales, to a picture, to the formula.
COUNT IT:
Start with the first week.
show/hide
springboard_sales |>
filter(week == 1) |>
select(module, avg_sales, sales, rank) |>
arrange(rank)Every module landed within a few dozen units of its average, and the ranks match the order of the averages. Below we count how many times each module changed rank from one week to the next.
show/hide
rank_changes <- springboard_sales |>
group_by(module) |>
summarise(rank_changes = sum(rank != lag(rank), na.rm = TRUE),
first_rank = first(rank), last_rank = last(rank))
rank_changesThe VisorPhone and GPS climb because their averages climb, and the Modem and Camera slide because theirs slide. But Games changes rank 4 times even though its average never changes.
DRAW IT:
The graph below plots the raw sales for Games and the MP3 Player. The ribbon around each line covers the average plus or minus two standard deviations (more on that below). Wherever the two ribbons overlap, either module could come out ahead in a given week.
show/hide
two_modules <- springboard_sales |>
filter(module %in% c("Games", "MP3 Player"))
ggplot(two_modules, aes(x = week_of, color = module, fill = module)) +
geom_ribbon(aes(ymin = avg_sales - 2 * sqrt(avg_sales),
ymax = avg_sales + 2 * sqrt(avg_sales)),
alpha = 1 / 4, color = NA) +
geom_line(aes(y = sales), linewidth = 1) +
geom_point(aes(y = sales), size = 2) +
scale_color_manual(values = c("Games" = "#808284", "MP3 Player" = "#21786B"),
aesthetics = c("color", "fill")) +
labs(title = "Games and MP3 Player sales",
subtitle = "Bands show each week's mean plus or minus 2 SD",
caption = "Simulated data",
x = "Week of", y = "Units sold", color = "Module", fill = "Module")
The ribbons overlap for all 12 weeks, which is why these two modules trade places in the bump chart.
NAME IT:
Each module’s weekly sales follow a Poisson distribution with its own average for that week (\(\lambda\)):
\[ \text{sales}_{m,w} \sim \text{Poisson}(\lambda_{m,w}) \]
A Poisson count has a standard deviation of \(\sqrt{\lambda}\), so a module averaging 330 units wobbles by about \(\sqrt{330} \approx 18\) units a week. The ribbons above reach twice that (about 36 units) above and below the average. The gap between two modules is approximately normal, with a mean of \(\lambda_1 - \lambda_2\) and a standard deviation of \(\sqrt{\lambda_1 + \lambda_2}\). In week one, the MP3 Player averages 360 units and Games averages 330, so the chance that Games comes out ahead anyway is:
\[ P(\text{swap}) \approx \Phi\left(\frac{-(360 - 330)}{\sqrt{360 + 330}}\right) \approx 0.13 \]
By week seven, both modules average 330 units and the chance is a coin flip (0.5). Rank charts are great for showing who’s on top, but small gaps between ranks are often just Poisson noise. Try making the averages twice as far apart in case_when() and the bump chart gets a lot calmer.
30.4 Grammar
CODE:
Create labels with
labs()Map
week_ofto thex,rankto they, andmoduletocolorAdd
ggbump::geom_bump()and setlinewidthto2Flip the
yaxis withscale_y_reverse()so rank1is on topSet six line colors with
scale_color_manual()(the theme’s discrete palette only has four)Label each line at its last week with
geom_text(), and make room for the labels withscale_x_date()Remove the legend with
theme(legend.position = "none")
show/hide
labs_bump <- labs(title = "Springboard module sales",
caption = "Simulated data",
subtitle = "Weekly rank, winter 2000 to 2001",
x = "Week of", y = "Rank",
color = "Module")
ggp2_bump <- ggplot(springboard_sales,
aes(x = week_of,
y = rank,
color = module)) +
ggbump::geom_bump(linewidth = 2) +
geom_text(data = filter(springboard_sales, week == 12),
aes(label = module), color = "#3E3F3A",
hjust = 0, nudge_x = 3) +
scale_x_date(expand = expansion(mult = c(0.05, 0.2))) +
scale_y_reverse(breaks = 1:6) +
scale_color_manual(values = c("#50ADA0", "#21786B", "#808284",
"#3E3F3A", "#B4BBAF", "#5CB8AC")) +
theme(legend.position = "none")
ggp2_bump +
labs_bumpGRAPH:
