
39 Parallel sets
theme_cuseeme()
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.
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 manipulationDATA:
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 withsample()video:"Clear","Choppy", or"Dropped". Each connection has its own chances invideo_probs(the T1 lines mostly get clear video, and the modems mostly get choppy video), so a call’s video depends on its connectioncall_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.
ggforcehas a specialgather_set_data()function that changes tidy data into a tidy(er) formatgather_set_data()stores the variable names inxas text (which would sort them alphabetically), so we convertxto 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.31The 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 providedataMap
xtox,idtoid,ytosplit, andvaluetovalueIn the
geom_parallel_sets()function, mapcall_typetofilland manually set thealpha(opacity) and theaxis.widthIn the
geom_parallel_sets_axes()function, set theaxis.widthto the same value as thegeom_parallel_sets()aboveFor labeling, adjust the
sizemanually and set thecolorto something that stands out against the black vertical axesManually label the
xaxis withscale_x_discrete(), setting thelabelsto the variable names in thecuseeme_widedatasetFinally, remove the
xtitle withaxis.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_psetsGRAPH:

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
expandargument of thexaxis inscale_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