3  Understanding Your Data

ggplot has a lot to offer, and you can create many, many kinds of plots. This page will focus on a select few that you can use to understand your data. During our exploration and wrangling of our data, we will ask questions like these:

We will answer these questions and more in this page, and the exercises at the bottom allow you to practice answering them on your own with another dataset.

To begin, load the libraries necessary for this page:

library(tidyverse)
library(ggbeeswarm)
library(ggrepel)
library(scales)
library(lme4) # for the sleepstudy dataset

3.1 Say cheese

The dataset we will explore for most of this page is about cheese. It has the characteristics of 922 kinds of cheese, pulled originally from cheese.com and then wrangled into a more usable format.

Download the data here by clicking here and then importing it into R with this line:

cheese <- readRDS("cheese.rds")

Or, you may import it directly into R directly with this line:

cheese <- readRDS(url("https://www.sscc.wisc.edu/sscc/pubs/dvresearch/data/cheese.rds"))

Its variables are as follows:

glimpse(cheese)
Rows: 922
Columns: 19
$ cheese              <chr> "Aarewasser", "Abbaye de Belloc", "Abbaye de Belva…
$ country             <chr> "Switzerland", "France", "France", "France", "Fran…
$ family              <chr> NA, NA, NA, NA, NA, NA, NA, "Cheddar", NA, NA, NA,…
$ fat_content_percent <dbl> NA, NA, 40, NA, NA, NA, 50, NA, 45, NA, NA, NA, 52…
$ calcium_content_mg  <dbl> NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA, NA…
$ rind                <fct> washed, natural, washed, washed, washed, washed, w…
$ color               <fct> yellow, yellow, ivory, white, white, pale yellow, …
$ hardness            <fct> semi-soft, semi-hard, semi-hard, semi-soft, soft, …
$ vegetarian          <dbl> 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, NA, NA, NA,…
$ vegan               <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, NA, NA, NA,…
$ milk_cow            <dbl> 1, 0, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 0, 1, 1, 1, 1,…
$ milk_sheep          <dbl> 0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0,…
$ milk_goat           <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0,…
$ milk_buffalo        <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0,…
$ milk_water_buffalo  <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ milk_plant_based    <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ milk_yak            <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ milk_camel          <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…
$ milk_moose          <dbl> 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,…

In this dataset, each row is a type of cheese, and the columns give characteristics of that cheese:

  • cheese: the name of the cheese
  • country: the country or countries where it is produced
  • family: the family the particular cheese belongs to (e.g., cheddar)
  • fat_content_percent: the minimum percentage of this cheese that is fat
  • calcium_content_mg: how much calcium, in mg, is present per 100 g of cheese
  • rind: the type of rind, where only the 5 most common types are shown and the others have been combined into “other”
  • color: the color of the cheese, where only the 4 most common colors are shown and the others have been combined into “other”
  • vegetarian and vegan: indicators whether the cheese is vegetarian or vegan
  • milk_*: indicators whether a certain milk can be used to make the cheese; these are not exclusive, in that a cheese may be made with any one of several types of milk, or a combination of multiple milks

When we look at our variable types, we can sort our variables into a few categories:

  • Continuous
    • The type is numeric and it can take on many values
    • fat_content_percent, calcium_content_mg
  • Categorical
    • The type can be factor, character, or numeric (for binary or integer data with few values)
    • family, rind, color, vegetarian, vegan, milk_*
  • Data only useful in identifying observations because each value is unique
    • cheese
  • Data that could be useful if we wrangle it more
    • country is comma-separated within a column. We would need to first split it up with a function like separate_wider_delim() or separate_longer_delim(). “Canada, United States” is considered distinct from both “Canada” and “United States” at this point because they are different strings. Here we might want to have a series of indicators, as we already have with the types of milk.

3.3 Break it down

The plots above use only one or two variables, mapping them to x and y, the only two dimensions in a two-dimensional plot. How do we add a third variable? A third dimension, in a 3D plot? No.

We can add a third (or fourth, or fifth…) variable in several ways:

  • Facets: splitting a single plot into multiple plots, one for each level of a categorical variable
  • Groups: using a separate geom for each level of a categorical variable
  • Other aesthetics: using a separate linetype, shape, or color for each level of a categorical variable, or shades of a color for a continuous variable

You will notice in that list that it is easiest or most common to break a plot down by a categorical variable.

3.3.1 Facets

Facets, or small multiples, are a series of plots of subsets of the data. For example, we can plot the density of fat_content_percent for each level of hardness with the facet_wrap() function.

facet_wrap() takes a formula in the format ~ var for one variable or ~ var1 + var2 + and so on for more than one.

Before faceting, our density plot has all values of hardness in a single plot:

cheese |> 
  filter(!is.na(hardness)) |> 
  ggplot(aes(x = fat_content_percent)) +
  geom_density()
Warning: Removed 674 rows containing non-finite outside the scale range
(`stat_density()`).

Simply adding on facet_wrap(~ hardness) creates six smaller plots, one for each level of hardness:

cheese |> 
  filter(!is.na(hardness)) |> 
  ggplot(aes(x = fat_content_percent)) +
  geom_density() +
  facet_wrap(~ hardness)
Warning: Removed 674 rows containing non-finite outside the scale range
(`stat_density()`).

If we have some variables for which all pairwise combinations exist in the data, we can use facet_grid().

This function takes a two-sided formula, such as milk_cow ~ hardness. The variable on the left becomes the rows, so milk_cow, a binary variable, gives us two rows. The variable on the right becomes the columns, so hardness will give us six columns. How we choose to order the formula depends on the kind of graph we are making and the space it needs to fit into. With your own data, experiment between your options and choose whichever is easiest to read.

cheese |> 
  filter(!is.na(hardness)) |> 
  ggplot(aes(x = fat_content_percent)) +
  geom_density() +
  facet_grid(milk_cow ~ hardness)
Warning: Removed 674 rows containing non-finite outside the scale range
(`stat_density()`).
Warning: Groups with fewer than two data points have been dropped.
Groups with fewer than two data points have been dropped.
Warning: Removed 2 rows containing missing values or values outside the scale range
(`geom_density()`).

The warning messages above tell us that geom_density() requires 2+ data points. The “semi-firm” level of hardness only had two data points, one for each level of milk_cow, so neither one produced its own density plot. This also means that geom_density() actually made a density plot for just two data points before it was split by milk_cow! That was not originally apparent, but plotting our data helped us to see the sparsity in our categorical variables.

3.3.2 Groups

In ggplot, the group aesthetic makes separate geoms for each level of some grouping variable, but it does not produce a legend as we would get with linetype, shape, or color (below).

A use case for group is when we have two continuous variables (or at least a pre/post variable with some continuous outcome) and want to show trends by group, such as repeated measures experimental data. This yields what is commonly called a spaghetti plot.

Use the sleepstudy dataset from lme4. In this dataset, subjects’ reaction times (Reaction) were measured after sleep deprivation for some number of Days, which started on day 3. First drop days 0-2 to get data limited to the deprivation period, and then plot Reaction against Days:

sleepstudy |> 
  filter(Days >= 3) |> 
  ggplot(aes(x = Days, 
             y = Reaction)) +
  geom_point()

We can get an overall trend line with geom_smooth(). This function has a method argument we can set to be “lm” to fit a linear line. Then, setting se = F removes the confidence band to result in a cleaner plot:

sleepstudy |> 
  filter(Days >= 3) |> 
  ggplot(aes(x = Days, 
             y = Reaction)) +
  geom_point() +
  geom_smooth(method = "lm", se = F)
`geom_smooth()` using formula = 'y ~ x'

If we add the aesthetic mapping group = Subject, geom_smooth() now gives us a separate line for each Subject:

sleepstudy |> 
  filter(Days >= 3) |> 
  ggplot(aes(x = Days, 
             y = Reaction, 
             group = Subject)) +
  geom_point() +
  geom_smooth(method = "lm", se = F)
`geom_smooth()` using formula = 'y ~ x'

We see now what has been hidden all along, that ggplot() passes its aesthetic mappings to all subsequent layers in the plot. Adding something to aes() inside of ggplot() changed the behavior of the other plot elements. We can get our overall mean line back by overriding the aesthetic inheritance off with inherit.aes = F inside of geom_smooth().

While we are doing that, change a few more things in the plot to deemphasize, but still show, individual data, and emphasize the group mean line:

  • Change geom_point() to light gray (“gray70”)
  • Change the group-specific lines in geom_smooth() to also be light gray (“gray70”) and thin (linewidth = 0.5)
    • If you submit a figure like this to a journal, check their minimum line width requirement. The numbers given to geoms are in millimeters.
  • Add another geom_smooth() for the overall trend, re-specifying x and y, turning off aesthetic inheritance (inherit.aes = F), and making the line black and thicker (linewidth = 1.5)
sleepstudy |> 
  filter(Days >= 3) |> 
  ggplot(aes(x = Days, 
             y = Reaction, 
             group = Subject)) +
  geom_point(color = "gray70") +
  geom_smooth(method = "lm", se = F, 
              color = "gray70", 
              linewidth = 0.5) +
  geom_smooth(aes(x = Days, 
                  y = Reaction), 
              inherit.aes = F, 
              method = "lm", se = F,
              color = "black",
              linewidth = 1.5)
`geom_smooth()` using formula = 'y ~ x'
`geom_smooth()` using formula = 'y ~ x'

Now, comparing the individual lines to the overall line, we see some variance in individual slopes. Most are positive, but some are steeper or flatter, and others are even negative. When modeling, we should consider a random slope or some other way to account for between-subject variation in the effect of Days on Reaction.

3.3.3 Other aesthetics

3.3.3.1 Linetype

Mapping a variable to linetype works best when that variable has only two or three levels, since many kinds of linetype can be difficult to distinguish, especially given the small “sample” of the line provided in the legend. (However, see the chapter on Useful Customizations to learn how to lengthen the sample in the legend.)

cheese |> 
  filter(calcium_content_mg < 2000,
         !is.na(vegetarian)) |> 
  ggplot(aes(x = fat_content_percent, 
             y = calcium_content_mg, 
             linetype = factor(vegetarian))) +
  geom_point() +
  geom_smooth()
`geom_smooth()` using method = 'loess' and formula = 'y ~ x'
Warning: Removed 1 row containing non-finite outside the scale range
(`stat_smooth()`).
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_point()`).

3.3.3.2 Shape

Like linetype, shape helps to distinguish a limited number of levels:

cheese |> 
  filter(calcium_content_mg < 2000,
         !is.na(vegetarian)) |> 
  ggplot(aes(x = fat_content_percent, 
             y = calcium_content_mg, 
             shape = factor(vegetarian))) +
  geom_point()
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_point()`).

3.3.3.3 Color

Color, while something we want to avoid in plots destined for publication, can still be useful for internal use as we seek to understand our data. If we do decide to use color when plotting, we need to make sure our color palettes are colorblind-friendly. A quick search online for “colorblind friendly palettes ggplot” will yield plenty of packages and advice on this topic.

For now, we will make some plots with color for exploratory purposes only.

One of the plots above used facets to see the density of fat_content_percent by hardness. As an alternative, we can map hardness onto color to see all the densities in a single plot:

ggplot(cheese,
       aes(x = fat_content_percent,
           color = hardness)) +
  geom_density()
Warning: Removed 674 rows containing non-finite outside the scale range
(`stat_density()`).
Warning: Groups with fewer than two data points have been dropped.
Warning: Removed 1 row containing missing values or values outside the scale range
(`geom_density()`).

As another example, plot the values of fat_content_percent against rind, and color them by their value of calcium_content_mg.

In this plot, we use geom_jitter() with vertical noise added to each point (height = 0.2) to separate the points, and a larger-than-default size = 2.

cheese |>
  filter(calcium_content_mg < 4000,
         !is.na(fat_content_percent),
         !is.na(calcium_content_mg),
         !is.na(rind)) |>
  ggplot(aes(x = fat_content_percent, 
             y = rind, 
             color = calcium_content_mg)) +
  geom_jitter(height = 0.2, size = 2) +
  theme_bw()

Make the plot again, but now take control over the gradient that is used for calcium_content_mg. scale_color_gradient() defines a custom gradient that goes from light gray (“gray75”) up to black.

cheese |>
  filter(calcium_content_mg < 4000,
         !is.na(fat_content_percent),
         !is.na(calcium_content_mg),
         !is.na(rind)) |>
  ggplot(aes(x = fat_content_percent, 
             y = rind, 
             color = calcium_content_mg)) +
  geom_jitter(height = 0.2, size = 2) +
  scale_color_gradient(low = "gray75", high = "black") +
  theme_bw()

This plot gives us insights into the joint distribution of these three variables. The sample size for each rind type is limited, but it is natural rind cheeses that tend to see the highest values of calcium_content_mg, and also the highest range of this variable.

3.4 Identify outliers

In the scatterplot we made earlier with calcium_content_mg by fat_content_percent, we observed an outlier in calcium_content_mg.

To identify that outlier within a plot, we can add labels to the plot. Our cheese dataset already has an ID variable in its cheese column, but if not, we could create one with the row number with the row_number() function.

We can add the cheese names to the plot by mapping them to the label aesthetic, and then adding on a layer with geom_label_repel(). This function dds a label to each point with the value from the label column, and it omits labels where there would be too many overlaps. Usually we would want to thoughtfully and intentionally add labels to a plot, but here we can take advantage of the mechanic where too-close-together labels are omitted, since the result is that it will only label outlying points.

ggplot(cheese, 
       aes(x = fat_content_percent, 
           y = calcium_content_mg, 
           label = cheese)) +
  geom_point() +
  geom_label_repel()
Warning: Removed 898 rows containing missing values or values outside the scale range
(`geom_point()`).
Warning: Removed 898 rows containing missing values or values outside the scale range
(`geom_label_repel()`).

This plot helps us identify the outlying cheese, Castelmagno.

Another use of labeling is to thoughtfully select some subset of observations with which to associate labels, and then see where they are in a plot. Imagine we are particularly interested in cheeses with a washed rind. Add a label which is the name of the cheese, but only where the rind is washed. If the rind is not washed, give it a label of NA. (Using a blank (““) would result in a bunch of empty label boxes, which is not what we want. We want no label.)

In geom_label_repel(), also add min.segment.length = 0 to force the drawing of a line even if the label is right next to the point, so we can be confident of which point goes with each label.

cheese |> 
  mutate(my_label = ifelse(rind == "washed", cheese, NA)) |> 
  ggplot(aes(x = fat_content_percent, 
             y = calcium_content_mg, 
             label = my_label)) +
  geom_point() +
  geom_label_repel(min.segment.length = 0)
Warning: Removed 898 rows containing missing values or values outside the scale range
(`geom_point()`).
Warning: Removed 919 rows containing missing values or values outside the scale range
(`geom_label_repel()`).

3.5 Exercises

Choose one of the following built-in datasets and answer the associated questions.

  • starwars
    • (Note: Ignore the list-columns.)
    • Continuous: What is the distribution of height? How about mass?
    • Continuous x continuous: What is the relationship between height and mass?
      • Label outlying characters.
    • Categorical: How do the counts of character compare by hair_color?
    • Categorical x categorical: How do the proportions of whether a character is of some gender compare across hair_color?
      • Switch which variable is x and which is fill. Which plot is more readable?
    • Continuous x categorical: Compare the distributions of height across gender.
  • diamonds
    • (Note: Treat ordinal variables as categorical.)
    • Continuous: What is the distribution of carat? How about price?
      • The distribution of carat is not smooth. Why do you think that is?
    • Continuous x continuous: What is the relationship between carat and price?
      • Break it down: Add cut as a color aesthetic. What patterns do you see?
    • Categorical: How do the counts of diamonds compare by clarity?
      • Break it down: Facet the plot by cut.
    • Categorical x categorical: How do the proportions of cut compare across color?
    • Continuous x categorical: Compare the distributions of carat across cut.
      • Should you use a beeswarm or violin here?
  • midwest
    • Continuous: What is the distribution of popdensity? How about percbelowpoverty?
    • Continuous x continuous: What is the relationship between popdensity and percbelowpoverty?
      • Label outlying counties.
    • Categorical: How do the counts of county compare by state?
    • Categorical x categorical: How do the proportions of whether a county is in a metro area (inmetro) compare across state?
    • Continuous x categorical: Compare the distributions of percbelowpoverty across state.
  • lakers
    • First filter the data to exclude plays without results: lakers <- lakers |> filter(result != "")
    • Continuous: What is the distribution of x? How about y?
    • Continuous x continuous: What is the relationship between x and y?
      • These two give the coordinates of each play. y is the distance from the endline of the court, while x is the distance from one of the sidelines.
      • Break it down: Color each point by its result or number of points. What patterns do you see?
    • Categorical: How do the counts of play compare by result?
    • Categorical x categorical: How do the proportions of whether a shot made it (result) compare across etype?
      • Which had a higher miss rate: free throw or shot?
      • Break it down: Add game_type as a facet. Did the rates vary considerably based on whether it was a home or away game?
    • Continuous x categorical: Compare the distributions of y across result.
      • Should you use a beeswarm or violin here?

  1. Weissgerber, T. L., Winham, S. J., Heinzen, E. P., Milin-Lazovic, J. S., Garcia-Valencia, O., Bukumiric, Z., Savic, M. D., Garovic, V. D., & Milic, N. M. (2019). Reveal, don’t conceal: Transforming data visualization to improve transparency. Circulation, 140(18), 1506–1518. https://doi.org/10.1161/CIRCULATIONAHA.118.037777↩︎