Data visualization using ggplot2

Published

September 15, 2026

Introduction

In this practical, we will learn how to create data visualisations using the grammar of graphics, implemented in R through the ggplot2 package (part of the tidyverse). We will build up the grammar step by step in Part 1, using baseball data from the ISLR package and a small simulated dataset of student grades. In Part 2, we put those pieces to work on a real research question: how are serving size, item type, and calorie content associated?

library(ISLR)
library(tidyverse)

An excellent reference manual for ggplot can be found on the tidyverse website: https://ggplot2.tidyverse.org/reference/

Part 1: The grammar of graphics

What is ggplot?

Plots can be made in R without the use of ggplot using plot(), hist() or barplot() and related functions. Here is an example of each on the Hitters dataset from ISLR:

# Get an idea of what the Hitters dataset looks like
head(Hitters)
                  AtBat Hits HmRun Runs RBI Walks Years CAtBat CHits CHmRun
-Andy Allanson      293   66     1   30  29    14     1    293    66      1
-Alan Ashby         315   81     7   24  38    39    14   3449   835     69
-Alvin Davis        479  130    18   66  72    76     3   1624   457     63
-Andre Dawson       496  141    20   65  78    37    11   5628  1575    225
-Andres Galarraga   321   87    10   39  42    30     2    396   101     12
-Alfredo Griffin    594  169     4   74  51    35    11   4408  1133     19
                  CRuns CRBI CWalks League Division PutOuts Assists Errors
-Andy Allanson       30   29     14      A        E     446      33     20
-Alan Ashby         321  414    375      N        W     632      43     10
-Alvin Davis        224  266    263      A        W     880      82     14
-Andre Dawson       828  838    354      N        E     200      11      3
-Andres Galarraga    48   46     33      N        E     805      40      4
-Alfredo Griffin    501  336    194      A        W     282     421     25
                  Salary NewLeague
-Andy Allanson        NA         A
-Alan Ashby        475.0         N
-Alvin Davis       480.0         A
-Andre Dawson      500.0         N
-Andres Galarraga   91.5         N
-Alfredo Griffin   750.0         A
# histogram of the distribution of salary
hist(Hitters$Salary, xlab = "Salary in thousands of dollars")

# barplot of how many members in each league
barplot(table(Hitters$League))

# Number of career hits versus number of career home runs
plot(x = Hitters$Hits, y = Hitters$HmRun, 
     xlab = "Hits", ylab = "Home runs")

These plots are informative and useful for visually inspecting the dataset, and they each have a specific syntax associated with them. ggplot has a more unified approach to plotting, where you build up a plot layer by layer using the + operator:

homeruns_plot <- 
  ggplot(Hitters, aes(x = Hits, y = HmRun)) +
  geom_point() +
  labs(x = "Hits", y = "Home runs")

homeruns_plot

As introduced in the lecture, a ggplot object is built up in different layers:

  1. input the dataset to a ggplot() function call
  2. construct aesthetic mappings
  3. add (geometric) components to your plot that use these mappings
  4. add labels, themes, visuals.

Because of this layered syntax, it is then easy to add elements like these fancy density lines, a title, and a different theme:

homeruns_plot + 
  geom_density_2d() +
  labs(title = "Cool density and scatter plot of baseball data") +
  theme_minimal()

1. Name the aesthetics, geoms, scales, and facets of the above visualisation. Also name any statistical transformations or special coordinate systems.

Aesthetics and data preparation

The first step in constructing a ggplot is the preparation of your data and the mapping of variables to aesthetics. ggplot() always expects a data frame with correctly typed columns: numbers as numeric, categories as factor, identifiers as character.

2. Run the code below to generate data, then put the three vectors into a data frame using tibble(). Give informative names and make sure the types are correct. Name the result gg_students.
set.seed(1234)
student_grade  <- rnorm(32, 7)
student_number <- round(runif(32) * 2e6 + 5e6)
programme      <- sample(c("Science", "Social Science"), 32, replace = TRUE)

(we will use this dataset for the rest of Part 1)

Mapping aesthetics is usually done in the main ggplot() call, as the second argument after the data.

3. Plot homeruns_plot again, but map Hits to the y-axis and HmRun to the x-axis instead.
4. Recreate the same plot once more, but now also map League to the colour aesthetic and Salary to the size aesthetic.

Geoms

The geoms in ggplot2 are added via geom_<geomtype>() functions, each with its own required aesthetic mapping (see ?geom_<geomtype>, or the reference website). Some geoms transform the data before plotting (e.g. geom_density_2d() calculates contour lines); others, like geom_point(), use the aesthetic mapping directly.

We now switch from the scatter plots above to distributional and comparative geoms, using the gg_students data.

5. Use geom_histogram() to create a histogram of grade in gg_students. Play around with the binwidth argument.
6. Use geom_density() to create a density plot of grade, with fill = "light seagreen". Then clean it up: add raw-data rug marks with geom_rug() (colour = "light seagreen"), switch to theme_minimal(), drop the y-axis label, and restrict the x-axis to the plausible grade range of 0–10 with xlim().

The density/histogram is an abstraction of the raw data and can hide things (e.g. a grade between 8.5 and 9 might not actually be possible). The rug marks restore a view of the raw data points underneath the smoothed curve.

Boxplot, Sina plot and bar plot

A common task is comparing distributions across groups. The boxplot (geom_boxplot()) does this through summary statistics; the bar plot (geom_bar()) compares counts of a categorical variable.

7. Create a boxplot of grade per prog in gg_students: map prog to the x position, grade to the y position, and (optionally) prog to the fill aesthetic.
8. What do each of the horizontal lines in the boxplot mean? What do the vertical lines (whiskers) mean?
8a. Use ggforce::geom_sina() to plot grade per prog in gg_students. What can you see that the boxplot hides? Which plot shows individual observations?

Keep the same x and y variables, and map prog to colour. If needed, install the package once with install.packages("ggforce").

9. Create a bar plot of the variable Years from the Hitters dataset.

geom_bar() automatically transforms variables to counts (see ?stat_count), similar to how table() works.

We have now covered the core grammar: aesthetics, geoms, scales, and themes. In Part 2 we add one more piece, i.e., the facets, while applying everything to a real dataset.

Part 2: Exploratory data analysis of the McDonald’s menu

Here we investigate the relationship between portion size and nutritional value of items on the McDonald’s menu. The dataset can be downloaded here (original source: Kaggle); put it in your data folder.

Right question

What association do you expect between serving size and calories? Sketch the expected pattern. What would weaken your expectation?

menu <- read_csv("data/menu.csv")

You can also load data directly from a URL: https://infomdwr.nl/labs/week_5/1_data_visualization_2/data/menu.csv

Before analysing, use the relevant parts of Peng’s checklist to check that the data are suitable for the question. Start with the basics:

menu |>
  summarise(
    rows = n(),
    columns = ncol(menu),
    missing_cells = sum(is.na(menu)),
    duplicate_rows = sum(duplicated(menu))
  )
# A tibble: 1 × 4
   rows columns missing_cells duplicate_rows
  <int>   <int>         <int>          <int>
1   260      24             0              0
menu |>
  count(Category, sort = TRUE)
# A tibble: 9 × 2
  Category               n
  <chr>              <int>
1 Coffee & Tea          95
2 Breakfast             42
3 Smoothies & Shakes    28
4 Beverages             27
5 Chicken & Fish        27
6 Beef & Pork           15
7 Snacks & Sides        13
8 Desserts               7
9 Salads                 6
glimpse(menu)
Rows: 260
Columns: 24
$ Category                        <chr> "Breakfast", "Breakfast", "Breakfast",…
$ Item                            <chr> "Egg McMuffin", "Egg White Delight", "…
$ `Serving Size`                  <chr> "4.8 oz (136 g)", "4.8 oz (135 g)", "3…
$ Calories                        <dbl> 300, 250, 370, 450, 400, 430, 460, 520…
$ `Calories from Fat`             <dbl> 120, 70, 200, 250, 210, 210, 230, 270,…
$ `Total Fat`                     <dbl> 13, 8, 23, 28, 23, 23, 26, 30, 20, 25,…
$ `Total Fat (% Daily Value)`     <dbl> 20, 12, 35, 43, 35, 36, 40, 47, 32, 38…
$ `Saturated Fat`                 <dbl> 5, 3, 8, 10, 8, 9, 13, 14, 11, 12, 12,…
$ `Saturated Fat (% Daily Value)` <dbl> 25, 15, 42, 52, 42, 46, 65, 68, 56, 59…
$ `Trans Fat`                     <dbl> 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0…
$ Cholesterol                     <dbl> 260, 25, 45, 285, 50, 300, 250, 250, 3…
$ `Cholesterol (% Daily Value)`   <dbl> 87, 8, 15, 95, 16, 100, 83, 83, 11, 11…
$ Sodium                          <dbl> 750, 770, 780, 860, 880, 960, 1300, 14…
$ `Sodium (% Daily Value)`        <dbl> 31, 32, 33, 36, 37, 40, 54, 59, 54, 59…
$ Carbohydrates                   <dbl> 31, 30, 29, 30, 30, 31, 38, 43, 36, 42…
$ `Carbohydrates (% Daily Value)` <dbl> 10, 10, 10, 10, 10, 10, 13, 14, 12, 14…
$ `Dietary Fiber`                 <dbl> 4, 4, 4, 4, 4, 4, 2, 3, 2, 3, 2, 3, 2,…
$ `Dietary Fiber (% Daily Value)` <dbl> 17, 17, 17, 17, 17, 18, 7, 12, 7, 12, …
$ Sugars                          <dbl> 3, 3, 2, 2, 2, 3, 3, 4, 3, 4, 2, 3, 2,…
$ Protein                         <dbl> 17, 18, 14, 21, 21, 26, 19, 19, 20, 20…
$ `Vitamin A (% Daily Value)`     <dbl> 10, 6, 8, 15, 6, 15, 10, 15, 2, 6, 0, …
$ `Vitamin C (% Daily Value)`     <dbl> 0, 0, 0, 0, 0, 2, 8, 8, 8, 8, 0, 0, 0,…
$ `Calcium (% Daily Value)`       <dbl> 25, 25, 25, 30, 25, 30, 15, 20, 15, 15…
$ `Iron (% Daily Value)`          <dbl> 15, 8, 10, 15, 10, 20, 15, 20, 10, 15,…
Checkpoint: do we have the right data?

Before choosing a plot, answer these questions:

  1. What does one row represent?
  2. Which variable is the outcome, or \(Y\)? Which variables could be useful predictors, or \(X\)?
  3. Is Serving Size ready to compare across rows?
  4. What is represented in this dataset, and what is not?
  5. What kinds of conclusions can these data support, and what can they not establish?

The variable indicating serving size is in an inconvenient form (text, mixing grams and fluid ounces). The code below makes it numeric and adds a Type column distinguishing food from drinks. Food serving sizes remain in grams; drink serving sizes are in millilitres. These are different units, so interpret serving-size comparisons within each type.

# Transformation drinks
drink_fl <- menu |> 
  filter(str_detect(`Serving Size`, " fl oz.*")) |> 
  mutate(`Serving Size` = str_remove(`Serving Size`, " fl oz.*")) |> 
  mutate(`Serving Size` = as.numeric(`Serving Size`) * 29.5735)

drink_carton <- menu |> 
  filter(str_detect(`Serving Size`, "carton")) |> 
  mutate(`Serving Size` = str_extract(`Serving Size`, "[0-9]{2,3}")) |> 
  mutate(`Serving Size` = as.numeric(`Serving Size`))

# Transformation food
food <-  menu |> 
  filter(str_detect(`Serving Size`, "g")) |> 
  mutate(`Serving Size` = str_extract(`Serving Size`, "(?<=\\()[0-9]{2,4}")) |> 
  mutate(`Serving Size` = as.numeric(`Serving Size`))

# Add Type variable indicating whether an item is food or a drink 
menu_tidy <-  
  bind_rows(drink_fl, drink_carton, food) |> 
  mutate(
   Type = case_when(
     as.character(Category) == 'Beverages' ~ 'Drinks',
     as.character(Category) == 'Coffee & Tea' ~ 'Drinks',
     as.character(Category) == 'Smoothies & Shakes' ~ 'Drinks',
     TRUE ~ 'Food'
   )
  )
10. After running the code, what type of variable is Serving Size now, and what was it before?

Right data: inspect variation

11. Plot the distribution of Calories in menu_tidy using geom_histogram(). Describe the distribution, do you see anything notable?

There is a clear outlier, but which Category does it belong to? This is where facets come in: facet_wrap() splits one plot into a panel per group of a categorical variable, so you can compare distributions across groups at a glance instead of overlaying them.

12. Plot the distribution of Calories for each Category using geom_density() combined with facet_wrap(). Which Category does the outlier fall into?

Right data: investigate unusual values

13. Use geom_col() to visualise the Calories of each item in the Chicken & Fish category (filter first with filter(), then pipe into ggplot()). What item is it, and why geom_col() instead of geom_bar() here?

Feasible solution: explore covariation

14. Create a scatter plot of Serving Size (x) versus Calories (y), using alpha = 0.5 in geom_point() to handle overplotting. Are serving size and calories related? Did you expect this?
15. Recreate the scatter plot but map colour to Type, and add a regression line with geom_smooth(method = "lm"). Does this change your conclusion?
16. Why do you think the relationship is so much affected by Type? Investigate further with a visualisation.

The phenomenon where an association changes or even reverses once you account for a grouping variable is called Simpson’s paradox.

Checkpoint: challenge the results

Use the plots from Questions 14–16 as your starting point.

  1. Does the relationship hold for both food and drinks?
  2. Repeat the plot from Question 15 without the highest-calorie item. Compare it with the original: does your conclusion change? What do the plots from Question 16 suggest about zero-calorie drinks?
  3. What is the strongest conclusion supported by these data?
  4. What can these data not establish?
  5. What additional data would most improve the analysis?
  6. How would you refine your original question now?

Finish these two sentences:

The evidence supports:

The evidence does not establish:

17. OPTIONAL (if time permits): Use ggpairs() from GGally to visualise the association between at least 4 variables at once (Serving Size, Calories, and macronutrients), coloured by Type.