39  Parallel sets

Graphs in this chapter use fmggp2::theme_cuseeme(), built from the CU-SeeMe phone book window: a dithered gray background, a black frame, a white panel, black, magenta, and gray marks, and a magenta Tahoma title.


Requirements:

✅ three categorical variables

✅ a numeric variable of counts

39.1 Description

Parallel sets (also referred to as Sankey diagrams or Alluvial charts) show the counts of categorical variables connected via a two-sided parallel display (or ‘sets’). Parallel sets can also be used to show different states of paired dependent relationships (such as input vs output), or time 1 vs time 2.

The height of the connecting bands between the categories on the x axis represent the relative counts for each discrete level (displayed on the y axis). The levels within each variable are represented with color.

We can build parallel set diagrams with the ggforce package.

Also check out alluvial charts.

39.2 Set up

PACKAGES:

Install packages.

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

DATA:

CU-SeeMe was a video conferencing program developed at Cornell University starting in 1992. Early versions sent grayscale video over the internet, and groups could meet through a reflector (a server that passed each video stream to everyone in the conference). We’ll simulate 300 CU-SeeMe calls with three categorical variables:

  • connection: "28.8k modem", "ISDN", or "T1", drawn with sample()

  • video: "Clear", "Choppy", or "Dropped". Each connection has its own chances in video_probs (the T1 lines mostly get clear video, and the modems mostly get choppy video), so a call’s video depends on its connection

  • call_type: "One to one" or "Reflector". Every call has the same 70% chance of being one to one, no matter its connection or video (a fast line didn’t make anyone more likely to join a group conference)

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

show/hide
video_probs <- list( # chance of each video quality on each connection
  `28.8k modem` = c(Clear = 0.05, Choppy = 0.65, Dropped = 0.3), # modems are mostly choppy
  ISDN = c(Clear = 0.3, Choppy = 0.5, Dropped = 0.2), # ISDN is better
  T1 = c(Clear = 0.7, Choppy = 0.25, Dropped = 0.05) # T1 lines are mostly clear
  )
set.seed(1995) # same 300 calls every time the code runs
cuseeme_calls <- tibble(
  call_id = 1:300, # 300 different calls, one row each
  connection = sample(
    c("28.8k modem", "ISDN", "T1"), size = 300, # one connection for each call
    replace = TRUE,
    prob = c(0.4, 0.3, 0.3) # modems are the most common
    )
  ) |>
  mutate(
    video = vapply(connection, function(cn) {
      sample(names(video_probs[[cn]]), size = 1, prob = video_probs[[cn]]) # use that connection's chances
    }, character(1), USE.NAMES = FALSE),
    call_type = sample(
      c("One to one", "Reflector"), size = 300, # direct call or group reflector
      replace = TRUE,
      prob = c(0.7, 0.3) # most calls are one to one
      )
    )
glimpse(cuseeme_calls)
#> Rows: 300
#> Columns: 4
#> $ call_id    <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10…
#> $ connection <chr> "ISDN", "ISDN", "28.8k modem"…
#> $ video      <chr> "Choppy", "Clear", "Dropped",…
#> $ call_type  <chr> "One to one", "One to one", "…

Next we count the categorical variables (connection, video, call_type) with count() and rename its n column to value.

  • ggforce has a special gather_set_data() function that changes tidy data into a tidy(er) format

  • gather_set_data() stores the variable names in x as text (which would sort them alphabetically), so we convert x to a factor in the order we want the sets

show/hide
cuseeme_wide <- cuseeme_calls |>
  count(connection, video, call_type) |> # calls on each path through the three columns
  rename(value = n)
para_set_cuseeme <- ggforce::gather_set_data(
                            data = cuseeme_wide,
                            x = 1:3) |> # the three columns become the axes
  mutate(
    x = factor(x, levels = c("connection", "video", "call_type")) # keep the axes in this order
    )
glimpse(para_set_cuseeme)
#> Rows: 54
#> Columns: 7
#> $ connection <chr> "28.8k modem", "28.8k modem",…
#> $ video      <chr> "Choppy", "Choppy", "Clear", …
#> $ call_type  <chr> "One to one", "Reflector", "O…
#> $ value      <int> 46, 24, 8, 1, 27, 9, 34, 11, …
#> $ id         <int> 1, 2, 3, 4, 5, 6, 7, 8, 9, 10…
#> $ x          <fct> connection, connection, conne…
#> $ y          <chr> "28.8k modem", "28.8k modem",…

39.3 Distribution

Parallel sets are good at showing whether two categorical variables are related. When one variable depends on another, the bands leaving each category split up in different ways. When they’re unrelated, every category splits up the same way. Our data has one of each, so below we’ll move from the calls, to a picture, to the formula.

COUNT IT:

Start with the first five calls.

show/hide
head(cuseeme_calls, 5)

Below we use prop.table() to turn two cross tables into row percentages. Each row adds up to one, so we can compare how each connection splits.

show/hide
connection_video <- prop.table(table(cuseeme_calls$connection,
                                 cuseeme_calls$video), margin = 1)
round(connection_video, 2)
#>              
#>               Choppy Clear Dropped
#>   28.8k modem   0.61  0.08    0.31
#>   ISDN          0.50  0.31    0.19
#>   T1            0.27  0.71    0.02
connection_type <- prop.table(table(cuseeme_calls$connection,
                                cuseeme_calls$call_type), margin = 1)
round(connection_type, 2)
#>              
#>               One to one Reflector
#>   28.8k modem       0.70      0.30
#>   ISDN              0.67      0.33
#>   T1                0.69      0.31

The video rows look very different from connection to connection (61% of the modem calls had choppy video, but only 27% of the T1 calls did). The call type rows look almost the same for every connection (about 70% were one to one).

DRAW IT:

The two graphs below draw each pair of variables as parallel sets, with the bands colored by connection. On the left, the connection bands spread across the video outcomes in very different ways. On the right, every connection splits into one to one and reflector calls in about the same proportion.

show/hide
two_sets <- function(var) {
  cuseeme_calls |>
    count(connection, .data[[var]]) |>
    rename(value = n) |>
    ggforce::gather_set_data(x = 1:2) |>
    mutate(x = factor(x, levels = c("connection", var))) |>
    ggplot(aes(x = x, id = id, split = y, value = value)) +
    geom_parallel_sets(aes(fill = connection), alpha = 0.5, axis.width = 0.1) +
    geom_parallel_sets_axes(axis.width = 0.1) +
    geom_parallel_sets_labels(size = 3, color = "#ffffff") +
    labs(x = NULL, y = "Calls", fill = "Connection") +
    theme(legend.position = "bottom")
}
two_sets("video")
two_sets("call_type")

NAME IT:

Each call’s video comes from a categorical distribution with probabilities that depend on its connection, so the video outcomes within each connection follow a multinomial distribution:

\[ (N_{\text{clear}}, N_{\text{choppy}}, N_{\text{dropped}}) \mid \text{connection} \sim \text{Multinomial}\left(n_{\text{connection}}, \; P(\text{video} \mid \text{connection})\right) \]

The call type is independent of the connection, which means \(P(\text{one to one} \mid \text{connection}) = P(\text{one to one}) = 0.7\) for every connection. When two variables are independent, the count we’d expect in each pair of categories is the row total times the column share. For example, there are 115 modem calls, so we’d expect about \(115 \times 0.7 \approx 80\) of them to be one to one (and 81 were). Bands that split in the same proportions everywhere are the picture of independence, and bands that split differently are the picture of a relationship. Try giving every connection the same video_probs and the left graph in DRAW IT will look like the right one.

39.4 Grammar

CODE:

  • Create labels with labs()

  • Initialize the graph with ggplot() and provide data

  • Map x to x, id to id, y to split, and value to value

  • In the geom_parallel_sets() function, map call_type to fill and manually set the alpha (opacity) and the axis.width

  • In the geom_parallel_sets_axes() function, set the axis.width to the same value as the geom_parallel_sets() above

  • For labeling, adjust the size manually and set the color to something that stands out against the black vertical axes

  • Manually label the x axis with scale_x_discrete(), setting the labels to the variable names in the cuseeme_wide dataset

  • Finally, remove the x title with axis.title.x = element_blank()

show/hide
labs_psets <- labs(
        title = "CU-SeeMe calls",
        subtitle = "Connections, video, and call types",
        caption = "Simulated data",
        y = "Calls", fill = "Call type")

ggp2_psets <- ggplot(data = para_set_cuseeme,
    mapping = aes(x = x,
        id = id,
        split = y,
        value = value)) +
  geom_parallel_sets(aes(fill = call_type),
        alpha = 0.3,
        axis.width = 0.07)

ggp2_psets_axes <- ggp2_psets +
  geom_parallel_sets_axes(
        axis.width = 0.07)

ggp2_psets_labs <- ggp2_psets_axes +
  geom_parallel_sets_labels(
        size = 2.0,
        color = '#ffffff') +
  scale_x_discrete(
        labels = c(connection = "Connection", video = "Video",
                   call_type = "Call type")) +
  theme(axis.title.x = element_blank())

ggp2_psets_labs +
    labs_psets

GRAPH:

39.5 More info

If the categories have long names, you can move the location of the labels outside the set.

39.5.1 Labeling sets

If the categories have long names, use the angle, nudge_x/nudge_y and hjust/vjust in geom_parallel_sets_labels() to adjust the size, location, and color of the labels.

  • Manually setting the expand argument of the x axis in scale_x_discrete() will also give more room for the labels.
show/hide
ggp2_psets_axes +
  geom_parallel_sets_labels(
      size = 3.2,
      colour = '#000000',
      angle = 0,
      nudge_x = 0.1,
      hjust = 0) +
  scale_x_discrete(
        labels = c(connection = "Connection", video = "Video",
                   call_type = "Call type"),
        expand = expansion(add = c(0.1, 0.4))) +
  theme(axis.title.x = element_blank()) +
    labs_psets