Categorical data can be represented in three fundamentally different forms, each with advantages for different purposes. Understanding these forms and how to convert between them is essential for effective visualization and analysis with mosaic plots. This vignette illustrates:
geom_mosaic() with each
formWe’ll use the classic HairEyeColor dataset throughout to
demonstrate these concepts with both two-way and three-way tables.
For a comprehensive treatment of these forms and conversions, see the vcdExtra vignette by Friendly & Meyer (2016).
Table form represents categorical data as an array or table object where: - Dimensions correspond to categorical variables - Elements contain cell frequencies - Dimension names provide variable and level information
This is the most compact representation and the natural output from
table() and xtabs().
# HairEyeColor is already in table form
str(HairEyeColor)
#> 'table' num [1:4, 1:4, 1:2] 32 53 10 3 11 50 10 30 10 25 ...
#> - attr(*, "dimnames")=List of 3
#> ..$ Hair: chr [1:4] "Black" "Brown" "Red" "Blond"
#> ..$ Eye : chr [1:4] "Brown" "Blue" "Hazel" "Green"
#> ..$ Sex : chr [1:2] "Male" "Female"
class(HairEyeColor)
#> [1] "table"
# Examine the 3-way table structure
HairEyeColor
#> , , Sex = Male
#>
#> Eye
#> Hair Brown Blue Hazel Green
#> Black 32 11 10 3
#> Brown 53 50 25 15
#> Red 10 10 7 7
#> Blond 3 30 5 8
#>
#> , , Sex = Female
#>
#> Eye
#> Hair Brown Blue Hazel Green
#> Black 36 9 5 2
#> Brown 66 34 29 14
#> Red 16 7 7 7
#> Blond 4 64 5 8
# Total observations
sum(HairEyeColor)
#> [1] 592The HairEyeColor dataset is a 4 × 4 × 2 contingency
table with 592 observations classified by hair color, eye color, and
sex.
Table form can also be displayed as a heatmap-style color
table, using vcdExtra::color_table(), which shades
each cell background by its frequency. This gives a quick visual sense
of where the data are concentrated, as a complement to the mosaic plots
introduced below – useful for readers less familiar with reading mosaic
displays.
|
Eye
|
Total | |||||
|---|---|---|---|---|---|---|
| Brown | Blue | Hazel | Green | |||
| Black | Male | 32 | 11 | 10 | 3 | 56 |
| Female | 36 | 9 | 5 | 2 | 52 | |
| Brown | Male | 53 | 50 | 25 | 15 | 143 |
| Female | 66 | 34 | 29 | 14 | 143 | |
| Red | Male | 10 | 10 | 7 | 7 | 34 |
| Female | 16 | 7 | 7 | 7 | 37 | |
| Blond | Male | 3 | 30 | 5 | 8 | 46 |
| Female | 4 | 64 | 5 | 8 | 81 | |
| Total | 220 | 215 | 93 | 64 | 592 | |
Frequency form is a data frame where: - Each row
represents one cell of the contingency table - Factor columns specify
the cell’s coordinates - A frequency column (typically
Freq) contains the count for that cell - Total observations
= sum(df$Freq)
This form is convenient for modeling and is the output from
as.data.frame() applied to tables.
# Convert table to frequency form
hair_freq <- as.data.frame(HairEyeColor)
head(hair_freq, 10)
#> Hair Eye Sex Freq
#> 1 Black Brown Male 32
#> 2 Brown Brown Male 53
#> 3 Red Brown Male 10
#> 4 Blond Brown Male 3
#> 5 Black Blue Male 11
#> 6 Brown Blue Male 50
#> 7 Red Blue Male 10
#> 8 Blond Blue Male 30
#> 9 Black Hazel Male 10
#> 10 Brown Hazel Male 25
nrow(hair_freq) # 32 cells in the 4 × 4 × 2 table
#> [1] 32
# Verify totals match
sum(hair_freq$Freq)
#> [1] 592Frequency form is ideal for: - Input to modeling functions
(glm(), loglm(), etc.) - Filtering specific
cells or combinations - Adding derived variables - Use with
ggplot2 via the weight aesthetic
Case form represents data as: - Each row is one
individual observation - Factor columns contain the variable values for
that individual - No frequency column needed - Total observations =
nrow(df)
This is the “raw data” format and most intuitive for those familiar with data collection.
# Convert frequency form to case form using vcdExtra::expand.dft()
hair_case <- expand.dft(hair_freq, freq = "Freq")
head(hair_case, 10)
#> Hair Eye Sex
#> 1 Black Brown Male
#> 2 Black Brown Male
#> 3 Black Brown Male
#> 4 Black Brown Male
#> 5 Black Brown Male
#> 6 Black Brown Male
#> 7 Black Brown Male
#> 8 Black Brown Male
#> 9 Black Brown Male
#> 10 Black Brown Male
nrow(hair_case) # 592 individual observations
#> [1] 592
# Structure
str(hair_case)
#> 'data.frame': 592 obs. of 3 variables:
#> $ Hair: chr "Black" "Black" "Black" "Black" ...
#> $ Eye : chr "Brown" "Brown" "Brown" "Brown" ...
#> $ Sex : chr "Male" "Male" "Male" "Male" ...Case form is most natural when: - Working with individual-level data - Each observation represents one subject - No need to aggregate
The table below summarizes conversion functions:
| From → To | Table | Frequency | Case |
|---|---|---|---|
| Table | — | as.data.frame() |
expand.dft() |
| Frequency | xtabs(Freq ~ ...) |
— | expand.dft() |
| Case | table(), xtabs() |
count(), xtabs() |
— |
Note: These conversions are clearly untidy and hard
to remember which to use. They have now been implemented in a collection
of vcdExtra::as_*() functions which take whatever you give
it and return the desired form.
# Case → Frequency (count occurrences)
hair_case |>
count(Hair, Eye, Sex, name = "Freq") |>
head()
#> Hair Eye Sex Freq
#> 1 Black Blue Female 9
#> 2 Black Blue Male 11
#> 3 Black Brown Female 36
#> 4 Black Brown Male 32
#> 5 Black Green Female 2
#> 6 Black Green Male 3
# Frequency → Table
hair_table <- xtabs(Freq ~ Hair + Eye + Sex, data = hair_freq)
identical(hair_table, HairEyeColor)
#> [1] FALSE
# Table → Frequency (already shown)
# Frequency → Case (already shown)geom_mosaic()Let’s demonstrate how to use each of the three forms with
geom_mosaic() using the full HairEyeColor
dataset.
# From frequency form (most common)
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye, Sex), fill = Eye)) +
geom_mosaic() +
labs(title = "Three-Way Mosaic: Hair × Eye × Sex",
subtitle = "Frequency form with weight aesthetic")# From case form
ggplot(data = hair_case,
aes(x = product(Hair, Eye, Sex), fill = Eye)) +
geom_mosaic() +
labs(title = "Three-Way Mosaic: Hair × Eye × Sex",
subtitle = "Case form without weight")Both forms produce identical plots. The key difference: -
Frequency form: Use weight = Freq in the
aesthetic - Case form: No weight needed (each row
counts as 1)
Note: Table form data must first be converted to frequency form using
as.data.frame() before use with
geom_mosaic().
The conds aesthetic allows you to condition on one or
more variables:
# Condition on Sex
ggplot(data = hair_freq,
aes(weight = Freq,
x = product(Hair, Eye),
conds = product(Sex),
fill = Eye)) +
geom_mosaic() +
labs(title = "Hair × Eye | Sex",
subtitle = "Conditioned on Sex")One of the most powerful features of mosaic plots is residual-based shading, which highlights deviations from a statistical model. This allows us to see not just the data, but whether patterns are statistically meaningful.
By default, geom_mosaic() uses the fill
aesthetic to color tiles, typically showing one of the categorical
variables:
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye), fill = Eye)) +
geom_mosaic() +
labs(title = "Hair × Eye Color: Default Shading",
subtitle = "Fill shows Eye color levels")This clearly shows the marginal and conditional distributions, but doesn’t tell us whether associations are statistically significant.
Residual-based shading fits a loglinear model to the data and colors tiles according to Pearson residuals:
\[ r_{ij} = \frac{\text{observed}_{ij} - \text{expected}_{ij}}{\sqrt{\text{expected}_{ij}}} \]
where “expected” frequencies come from the fitted model.
For a two-way table, we can visualize just Hair and Eye color. The most common model is independence (main effects only):
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye))) +
geom_mosaic(expected = "independence") +
scale_fill_residual() +
labs(title = "Hair × Eye Color: Independence Model",
subtitle = "Blue = more than expected, Red = fewer than expected")The residual shading reveals: - Blue tiles (positive residuals): Black hair with brown eyes, blond hair with blue eyes occur more frequently than independence would predict - Red tiles (negative residuals): Black hair with blue eyes, blond hair with brown eyes occur less frequently - This indicates a significant association between hair and eye color
We can display the actual residual values in each cell using
geom_mosaic_text():
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye))) +
mosaic_settings(expected = "independence") +
geom_mosaic() +
scale_fill_residual() +
geom_mosaic_text(display_values = "residual",
format_digits = 1,
size = 3.5,
colour = "black") +
labs(title = "Hair × Eye Independence Model with Residual Values",
subtitle = "Numbers show Pearson residuals")Values greater than 2 in absolute value indicate significant departures from independence.
For the three-way table, the independence model tests whether all three variables are mutually independent:
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye, Sex))) +
geom_mosaic(expected = "independence") +
scale_fill_residual() +
labs(title = "Hair × Eye × Sex: Complete Independence",
subtitle = "Model: ~ Hair + Eye + Sex (no interactions)")You can specify custom loglinear models using formula syntax. For example, testing whether the Hair-Eye association differs by Sex:
# Model: Hair and Eye are associated, but independent of Sex
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye, Sex))) +
geom_mosaic(expected = ~ Hair * Eye + Sex) +
scale_fill_residual() +
labs(title = "Model: (Hair × Eye) Independent of Sex",
subtitle = "~ Hair * Eye + Sex")Residuals close to zero suggest this model fits reasonably well—the Hair-Eye association is similar for males and females.
We can also display the expected counts under a model:
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye))) +
mosaic_settings(expected = "independence") +
geom_mosaic() +
scale_fill_residual() +
geom_mosaic_text(display_values = "expected",
format_digits = 1,
size = 3.5,
colour = "black") +
labs(title = "Expected Frequencies Under Independence",
subtitle = "Compare with observed to see associations")And the observed counts:
ggplot(data = hair_freq,
aes(weight = Freq, x = product(Hair, Eye))) +
geom_mosaic(aes(fill = Eye)) +
geom_mosaic_text(display_values = "observed",
format_digits = 0,
size = 3.5,
colour = "white") +
labs(title = "Observed Frequencies",
subtitle = "Actual counts in each cell")ggplot2as.data.frame() for table → frequencyexpand.dft() for frequency → casextabs() or table() for case/frequency
→ tablegeom_mosaic():
weight = Freqfill aesthetic to show
variable levelsexpected
parameter to fit models and shade by Pearson residualsweight aesthetic is most flexibleglm(), loglm(), etc.scale_fill_residual() and consider adding
geom_mosaic_text() with
display_values = "residual"sessionInfo()
#> R version 4.6.1 (2026-06-24 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 22631)
#>
#> Matrix products: default
#> LAPACK version 3.12.1
#>
#> locale:
#> [1] LC_COLLATE=C LC_CTYPE=English_Canada.utf8
#> [3] LC_MONETARY=English_Canada.utf8 LC_NUMERIC=C
#> [5] LC_TIME=English_Canada.utf8
#>
#> time zone: America/Toronto
#> tzcode source: internal
#>
#> attached base packages:
#> [1] grid stats graphics grDevices utils datasets methods
#> [8] base
#>
#> other attached packages:
#> [1] dplyr_1.2.1 vcdExtra_0.9.8 gnm_1.1-5 vcd_1.4-14
#> [5] ggmosaic2_0.5.1 ggplot2_4.0.3
#>
#> loaded via a namespace (and not attached):
#> [1] gt_1.3.0 plotly_4.12.1 sass_0.4.10
#> [4] generics_0.1.4 tidyr_1.3.2 productplots_0.1.2
#> [7] xml2_1.6.0 lattice_0.23-1 digest_0.6.39
#> [10] magrittr_2.0.5 evaluate_1.0.5 RColorBrewer_1.1-3
#> [13] fastmap_1.2.0 Matrix_1.7-6 plyr_1.8.9
#> [16] jsonlite_2.0.0 processx_3.9.0 ggrepel_0.9.8
#> [19] nnet_7.3-21 chromote_0.5.1 promises_1.5.0
#> [22] httr_1.4.9 purrr_1.2.2 viridisLite_0.4.3
#> [25] scales_1.4.0 jquerylib_0.1.4 cli_3.6.6
#> [28] rlang_1.3.0 withr_3.0.3 cachem_1.1.0
#> [31] yaml_2.3.12 otel_0.2.0 tools_4.6.1
#> [34] colorspace_2.1-3 forcats_1.0.1 ca_0.71.1
#> [37] vctrs_0.7.3 R6_2.6.1 zoo_1.9-0
#> [40] lifecycle_1.0.5 fs_2.1.0 relimp_1.0-5
#> [43] htmlwidgets_1.6.4 MASS_7.3-66 pkgconfig_2.0.3
#> [46] later_1.4.8 pillar_1.11.1 bslib_0.12.0
#> [49] gtable_0.3.6 data.table_1.18.6.1 glue_1.8.1
#> [52] Rcpp_1.1.2 xfun_0.60 tibble_3.3.1
#> [55] lmtest_0.9-40 tidyselect_1.2.1 rstudioapi_0.19.0
#> [58] knitr_1.52 dichromat_2.0-1 qvcalc_1.0.4
#> [61] farver_2.1.2 websocket_1.4.4 htmltools_0.5.9
#> [64] webshot2_0.1.2.9000 rmarkdown_2.32 compiler_4.6.1
#> [67] S7_0.2.2