13  Cleveland dot plots

Graphs in this chapter use fmggp2::theme_walkman(), modeled on the Sony Sports Walkman: a sports yellow body with a blue button frame, a silver plot area, a charcoal title and text, blue, charcoal, orange, gold, and silver marks, and a DIN Condensed title.


Requirements:

✅ a numeric (continuous) variable

✅ at least one categorical variable (with two levels)

13.1 Description

Cleveland dot plots compare numbers with dots on a line and are more efficient than bar graphs. The graph lists the categories on the side and shows the data with dots along a line.

Typically, each row contains two points representing the numerical values on the x axis, differentiated by color. A line connecting the two points represents the difference between the two categorical levels (the length of the line is the size of the difference).

13.2 Set up

PACKAGES:

Install packages.

show/hide
install.packages("faux")
library(faux) # for sim_design()
library(ggplot2)
library(dplyr) # for data manipulation
library(forcats) # for fct_rev()

DATA:

In the 90s, a Sony Walkman went everywhere, and the batteries never seemed to last long enough. We’ll simulate a battery test: 20 tapes played until the batteries died for each combination of three Walkman models and two battery types.

The faux package’s sim_design() function builds this kind of experiment for us. We give it the two factors (between), the number of tests in each combination (n), the average battery life for each combination (mu), and the standard deviation (sd). Then we calculate the median battery life for each model and battery (med_hours). Setting the seed with set.seed() means you’ll get the same 120 tests every time you run the code below.

show/hide
set.seed(1979) # same battery tests every time the code runs
walkman_tests <- faux::sim_design(
  between = list(
    model = c("WM2", "Sports", "MegaBass"), # three Walkman models
    battery = c("Alkaline", "Rechargeable") # two battery types
    ),
  n = 20, # 20 tests for each model and battery combination
  mu = c(WM2_Alkaline = 10, WM2_Rechargeable = 7, # average hours of play
         Sports_Alkaline = 16, Sports_Rechargeable = 12, # (alkaline always lasts longer)
         MegaBass_Alkaline = 22, MegaBass_Rechargeable = 17),
  sd = 2, # tests vary about 2 hours around each average
  dv = "hours", # name of the measured column
  long = TRUE, # one row per test
  plot = FALSE # skip the design plot
  ) |>
  as_tibble() |>
  mutate(
    model = factor(model,
      levels = c("WM2", "Sports", "MegaBass"), # oldest to newest model
      labels = c("WM-2", "WM-F45 Sports", "WM-EX194 Mega Bass") # full model names
      )
    )
walkman_clev_dots <- walkman_tests |>
  group_by(battery, model) |> # one group for each model and battery
  summarise(
    med_hours = median(hours), # median battery life in each group
    .groups = "drop" # return an ungrouped tibble
    )
glimpse(walkman_clev_dots)
#> Rows: 6
#> Columns: 3
#> $ battery   <fct> Alkaline, Alkaline, Alkaline, …
#> $ model     <fct> WM-2, WM-F45 Sports, WM-EX194 …
#> $ med_hours <dbl> 9.555329, 16.469916, 22.640003…

13.3 Distribution

A Cleveland dot plot shows two summaries per row and the gap between them. Here each dot is the median of 20 battery tests, and each line is the difference between alkaline and rechargeable batteries for one model. Below we’ll move from the tests, to a picture, to the formula.

COUNT IT:

Start with the first five tests.

show/hide
head(walkman_tests, 5)

These are alkaline batteries in the WM-2, and they lasted between about 8 and 13 hours. The table below puts the two battery types side by side for each model and calculates the gap.

show/hide
walkman_clev_dots |> 
  tidyr::pivot_wider(names_from = battery, values_from = med_hours) |> 
  mutate(difference = Alkaline - Rechargeable)

Alkaline batteries last longer in every model, and the gap grows from about 2.5 hours in the WM-2 to about 5 hours in the Mega Bass.

DRAW IT:

The graph below puts all 120 tests behind the dots. Each small point is one test, and the big dots are the medians. The medians sit in the middle of their clouds of tests, and the lines connect the two medians for each model.

show/hide
ggplot(data = walkman_clev_dots, 
    aes(x = med_hours, y = fct_rev(model))) + 
  geom_jitter(data = walkman_tests, 
    aes(x = hours, color = battery), 
    height = 0.15, width = 0, alpha = 0.5, size = 1) + 
  geom_line(aes(group = model), linewidth = 0.75) + 
  geom_point(aes(color = battery), size = 3.5) + 
  labs(x = "Battery life (hours)", y = "Model", color = "Battery")

NAME IT:

sim_design() draws every test from a normal distribution with the mean for its model and battery type:

\[ \text{hours} \sim \text{Normal}(\mu_{\text{model, battery}}, \; \sigma = 2) \]

The line in each row estimates the difference between two of these means, \(\mu_{\text{alkaline}} - \mu_{\text{rechargeable}}\), which we set to 3, 4, and 5 hours. Each median is built from only 20 tests, so it wobbles. For a normal distribution, the median of \(n\) values has a standard error of about \(1.25\,\sigma / \sqrt{n} = 1.25 \times 2 / \sqrt{20} \approx 0.56\) hours, and the difference between two medians wobbles by about \(\sqrt{2} \times 0.56 \approx 0.8\) hours. That’s why the WM-2 gap came out at about 2.5 hours instead of 3. Try raising n in sim_design() and the gaps settle closer to 3, 4, and 5.

13.4 Grammar

CODE:

  • Create labels with labs()

  • Initialize the graph with ggplot() and provide data

  • Map the med_hours to the x axis, and model to the y axis, but wrap model in forcats::fct_rev().

  • Add geom_line(), and map model to the group aesthetic. Set the linewidth to 0.75

  • Add geom_point() and map battery to color aesthetic. Set the size to 2.25

show/hide
labs_clev_dots <- labs(
    title = "Sony Walkman battery tests",
    caption = "Simulated data",
    subtitle = "Alkaline and rechargeable batteries",
    x = "Median battery life (hours)",
    y = "Model",
    color = "Battery")

ggp2_clev_dots <- ggplot(data = walkman_clev_dots,
                mapping = aes(x = med_hours, 
                              y = fct_rev(model))) +
                geom_line(aes(group = model), 
                    linewidth = 0.75) + 
                geom_point(aes(color = battery), 
                    size = 2.25) 

ggp2_clev_dots +
    labs_clev_dots

GRAPH:

13.5 More info

Cleveland dot plots are also helpful when comparing multiple differences on a common scale.

13.5.1 Common scale

SCALE:

  • Simulate a second measurement for every test: the time it takes to rewind a 60 minute tape (rewind_seconds). Rewinding uses the motor, so weaker rechargeable batteries rewind more slowly.

  • Calculate the median battery life and the median rewind time for each battery and model. These variables need to have ‘showtime-ready’ names because they’ll be used in our facets.

  • Pivot the new columns into a long (tidy) format with median_measure containing the name of the variable, and median_value containing the numbers.

  • Finally, convert median_measure into a factor.

show/hide
set.seed(1980) # same rewind tests every time the code runs
walkman_rewind <- faux::sim_design(
  between = list(
    model = c("WM2", "Sports", "MegaBass"), # same three Walkman models
    battery = c("Alkaline", "Rechargeable") # same two battery types
    ),
  n = 20, # 20 tests for each model and battery combination
  mu = c(WM2_Alkaline = 110, WM2_Rechargeable = 125, # average seconds to rewind a tape
         Sports_Alkaline = 95, Sports_Rechargeable = 105, # (rechargeable is always slower)
         MegaBass_Alkaline = 80, MegaBass_Rechargeable = 90),
  sd = 8, # tests vary about 8 seconds around each average
  dv = "rewind_seconds", # name of the measured column
  long = TRUE, # one row per test
  plot = FALSE # skip the design plot
  ) |>
  as_tibble()
walkman_clev_dots2 <- walkman_tests |>
    dplyr::mutate(rewind_seconds = walkman_rewind$rewind_seconds) |> # add rewind times to the battery tests
    dplyr::group_by(battery, model) |> # one group for each model and battery
    dplyr::summarise(
        `Median battery life (hours)` = median(hours), # first measure
        `Median rewind time (seconds)` = median(rewind_seconds)) |> # second measure
    dplyr::ungroup() |>
    tidyr::pivot_longer(
        cols = starts_with("Med"), # stack both median columns
        names_to = "median_measure", # which measure each row holds
        values_to = "median_value") |> # the median for that measure
    dplyr::mutate(median_measure = factor(median_measure))
#> `summarise()` has regrouped the output.
#> ℹ Summaries were computed grouped by battery and
#>   model.
#> ℹ Output is grouped by battery.
#> ℹ Use `summarise(.groups = "drop_last")` to
#>   silence this message.
#> ℹ Use `summarise(.by = c(battery, model))` for
#>   per-operation grouping (`?dplyr::dplyr_by`)
#>   instead.
glimpse(walkman_clev_dots2)
#> Rows: 12
#> Columns: 4
#> $ battery        <fct> Alkaline, Alkaline, Alkal…
#> $ model          <fct> WM-2, WM-2, WM-F45 Sports…
#> $ median_measure <fct> Median battery life (hour…
#> $ median_value   <dbl> 9.555329, 113.696775, 16.…

13.5.2 Scales

scales:

  • Re-create labels

  • Initialize the graph with ggplot() and provide data

  • Map the median_value to the x axis, and model to the y axis, but wrap model in forcats::fct_rev().

  • Add geom_line(), and map model to the group aesthetic. Set the linewidth to 0.75

  • Add geom_point() and map battery to color aesthetic. Set the size to 2.25

  • Add facet_wrap() and facet by median_measure, setting shrink to TRUE

  • Move the legend with theme(legend.position = "top")

show/hide
labs_clev_dots2 <- labs(
    title = "Sony Walkman tests",
    caption = "Simulated data",
    subtitle = "Alkaline and rechargeable batteries",
    x = "Median (hours or seconds)",
    y = "Model",
    color = "Battery")

ggp2_clev_dots2 <- ggplot(data = walkman_clev_dots2,
    mapping = aes(x = median_value,
        y = fct_rev(model))) +
    geom_line(aes(group = model),
        linewidth = 0.55) +
    geom_point(aes(color = battery),
        size = 2) +
    facet_wrap(. ~ median_measure,
        shrink = TRUE, nrow = 2) +
    theme(legend.position = "top")

ggp2_clev_dots2 +
    labs_clev_dots2

On a shared scale, the battery life differences (a few hours) are tiny next to the rewind times (about 80 to 120 seconds). The battery life dots get squeezed against the left side of the axis.

CAUTION when using scales = "free_x": The graph below gives each measure its own x axis. Alkaline batteries last longer and rewind faster in every model. However, the magnitude of the differences should be interpreted with caution, because the length of the lines can’t be directly compared across the two panels!

show/hide
ggp2_clev_dots2_free_x <- ggplot(data = walkman_clev_dots2,
    mapping = aes(x = median_value,
        y = fct_rev(model))) +
    geom_line(aes(group = model),
        linewidth = 0.55) +
    geom_point(aes(color = battery),
        size = 2) +
    facet_wrap(. ~ median_measure,
        shrink = TRUE, nrow = 2,
        scales = "free_x") +
    theme(legend.position = "top")

ggp2_clev_dots2_free_x +
    labs_clev_dots2