R Data Visualization

Plotting with ggplot2

Brendan Chapuis

March 1, 2027

Course Home Page

Today

  1. The grammar of graphics: data, aesthetics, geoms, and layers.
  2. Scales, labels, and themes: control how every part of the plot looks.
  3. Facets and combining plots: many panels in one figure.
  4. Saving plots: raster vs. vector formats.
  5. Plotting regression results: coefficient plots from the models we ran last class.

ggplot2

ggplot2 Package

One of the most used packages in R1.

  • Developed by Hadley Wickham (we’ve seen his name before).
  • “gg” stands for “grammar” of “graphics”.

It is part of the tidyverse, so it is already installed. We just need to load it.

library(ggplot2)

An Example Dataset

We’ve seen this built-in dataset mtcars before. It has values for 32 different cars.

head(mtcars)
                   mpg cyl disp  hp drat    wt  qsec vs am gear carb
Mazda RX4         21.0   6  160 110 3.90 2.620 16.46  0  1    4    4
Mazda RX4 Wag     21.0   6  160 110 3.90 2.875 17.02  0  1    4    4
Datsun 710        22.8   4  108  93 3.85 2.320 18.61  1  1    4    1
Hornet 4 Drive    21.4   6  258 110 3.08 3.215 19.44  1  0    3    1
Hornet Sportabout 18.7   8  360 175 3.15 3.440 17.02  0  0    3    2
Valiant           18.1   6  225 105 2.76 3.460 20.22  1  0    3    1

An Example Plot

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

Let’s look in detail at how this works.

Elements of a ggplot

  1. Data
  1. Aesthetic Mapping
  1. Geoms (geometric objects)
  1. Layers

We will show each of these in our example plot.

1. Data

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

data = mtcars

  • Data is always the first argument for a ggplot, so you will often see it piped in:
mtcars |>
  ggplot(mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

2. Aesthetic Mapping

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

mapping = aes(x = hp, y = mpg, color = cyl)

  • Aesthetics on the plot (x, y, color) are linked to columns in your dataset (hp, mpg, cyl).
  • This mapping translates our data variables into the “grammar of graphics”.

Once we have that, we can add a “geom”…

3. Geoms

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

geom_point()

  • “Geom”s take our aesthetic mapping and draw objects to represent them on the chart.

  • In this case, we draw points.

Instead, we could have drawn a line…

3. Geoms

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_line()

But it would have looked pretty silly.

4. Layers

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

+ geom_point()

  • All ggplots are built in layers, one for each geometry.

  • In this case, we have only one layer: geom_point().

What happens if we add two?

Multiple layers

ggplot(data = mtcars, mapping = aes(x = hp, y = mpg, color = cyl)) +
  geom_point() +
  geom_line()

Both are drawn, and geom_line() also uses the color aesthetic. What if we wanted it to be black?

Setting aesthetics by layer

ggplot(data = mtcars, aes(x = hp, y = mpg)) +
  geom_point(aes(color = cyl)) +
  geom_line()

Set the common aesthetics in ggplot(), and add color only for geom_point().

Overriding by layer

ggplot(data = mtcars, aes(x = hp, y = mpg, color = cyl)) +
  geom_point() +
  geom_line(color = "black")

Or override a plot-wide aesthetic for one layer. Notice that we don’t use aes(color = "black"): a fixed value goes outside aes().

Geoms

Possible geom_s and the aesthetics they require:

  • geom_point(), geom_line(): x, y

  • geom_histogram(), geom_density(): x

  • geom_col(): x, y

  • geom_ribbon(): x, ymin, ymax

  • geom_pointrange(): x, y, ymin, ymax (we will use this later!)

  • geom_text(), geom_label(): x, y, label

And many more. See the ggplot2 reference page.

Scales, Labels, and Themes

Scales

Every ggplot aesthetic has a scale.

p <- mtcars |>
  ggplot(aes(x = hp, y = mpg, color = cyl)) +
  geom_point()

This plot has three scales: x-axis, y-axis, and color.

I’ve saved the plot to a variable p, which we can print later and add more layers, scales, or themes to without repeating the initial construction.

X and Y scales

Use _continuous() scales for numeric data, _discrete() for categories.

p + scale_y_continuous(limits = c(0, 50)) +
  scale_x_continuous(limits = c(0, 500))

scale_*_continuous() options

  • limits = c(0, 50): start and end values
  • expand = c(0, 0): buffer space at each end (default is 5%)
  • breaks = c(0, 50, 100): where the tick marks go
  • labels = c("0", "50", "100"): text for each break

scale_*_continuous() example

p + scale_x_continuous(
  limits = c(0, 200), expand = c(0, 0),
  breaks = c(0, 50, 100, 200),
  labels = c("0", "50", "text", "anything you want")
)

Color scales

cyl is numeric, so the color scale is continuous.

p + scale_color_gradient(low = "red", high = "black")

For low-mid-high color scales use scale_color_gradient2(). For n-level color scales, use scale_color_gradientn().

But is a continuous scale the right one for this data?

Discrete color scales

Convert cyl to a factor, then pick colors with scale_color_manual().

p_discrete <- mtcars |>
  ggplot(aes(x = hp, y = mpg, color = as.factor(cyl))) +
  geom_point()
p_discrete +
  scale_color_manual(values = c(`4` = "steelblue", `6` = "red", `8` = "#0F0F0F"))

A named vector matches each color to a specific value.

Ready-made palettes

Picking colors by hand is hard. Built-in palettes are a good default.

library(patchwork)
(p_discrete + scale_color_viridis_d()) | (p_discrete + scale_color_brewer(palette = "Set1"))
  • scale_color_viridis_d() (discrete) and _c() (continuous) are colorblind-friendly and print well in black and white.
  • scale_color_brewer() uses the ColorBrewer palettes.
  • The | between plots comes from patchwork; more on that later.

Plot Labels

Axis and legend labels default to your variable names. Title, subtitle, and caption are optional.

p_labeled <- p_discrete + labs(
  title = "Car Fuel Efficiency",
  subtitle = "More horsepower means less fuel efficient",
  caption = "Source: built-in R dataset: mtcars",
  x = "Horsepower (hp)",
  y = "Miles Per Gallon (mpg)",
  color = "Cylinders"
)
p_labeled

Plot Labels

Themes

Themes control every non-data part of the plot.

p_labeled + theme_bw() + theme(legend.position = "top")
  • Built in: theme_gray() (default), theme_bw(), theme_minimal(), theme_classic()
  • Edit any element with + theme(); see ?theme.

Other Themes

Other packages share their own themes. ggthemes is a large collection.

library(ggthemes)
(p_labeled + theme_economist()) | (p_labeled + theme_stata())

The Economist, and… Stata. Much of what changes is the fonts and backgrounds.

Theme Takeaways

With themes you can change just about any non-data part of your plot.

But that means all the options can be hard to figure out.

Start from a pre-built theme that is close to what you want, then adjust with + theme().

My own theming preferences

  • No background color
  • No grid lines, except at 0, which should always have a line
  • Start and end ticks, if possible
  • A box around the plot (top and right axis lines)
  • Legends within the plot, if possible. Better yet, label the lines directly.
  • Colorblind-friendly colors that also print well in black and white

This is pretty close to ggthemes::theme_few().

My version of our example plot

mtcars |>
  ggplot(aes(x = hp, y = mpg, color = as.factor(cyl))) +
  geom_point(size = 3) +
  scale_y_continuous(limits = c(0, 40), expand = c(0, 0)) +
  scale_x_continuous(limits = c(40, 360), expand = c(0, 0)) +
  scale_color_manual(values = c(
    `4` = "steelblue", `6` = "mediumorchid1", `8` = "orange"
  )) +
  labs(
    title = "Fuel Efficiency of Cars",
    x = "Horsepower", y = "Miles Per Gallon (mpg)", color = "Cylinders"
  ) +
  theme_bw(base_size = 14) +
  theme(
    panel.grid = element_blank(),
    plot.title = element_text(hjust = 0.5),
    plot.margin = margin(5, 10, 5, 5, "pt"),
    legend.position = "inside",
    legend.position.inside = c(0.8, 0.8)
  )

My version of our example plot

Facets and Combining Plots

Facets

Facets plot multiple cuts of the data: another “z” dimension.

facet_wrap() makes one panel per value of a variable.

mtcars |>
  ggplot(aes(x = hp, y = mpg)) +
  geom_point() +
  facet_wrap(vars(cyl))

By default every panel shares the same axes, so they are easy to compare.

Free scales

scales = "free" lets the axes vary by panel.

mtcars |>
  ggplot(aes(x = hp, y = mpg)) +
  geom_point() +
  facet_wrap(vars(cyl), scales = "free")

Easier to see the pattern within each panel, harder to compare across them. Also "free_x" and "free_y".

2-D facets

facet_grid() makes a grid of panels from two variables.

mtcars |>
  ggplot(aes(x = hp, y = mpg)) +
  geom_point() +
  facet_grid(rows = vars(cyl), cols = vars(gear))

Combining Plots with patchwork

To put different charts in one image, we use patchwork.

p1 <- mtcars |> ggplot(aes(x = hp, y = mpg)) + geom_point()
p2 <- mtcars |> ggplot(aes(x = hp)) + geom_density()
p3 <- mtcars |> ggplot(aes(x = gear, y = mpg)) + geom_col()
p4 <- mtcars |> ggplot(aes(x = gear, y = hp, group = gear)) + geom_boxplot()
  • p1 | p2 places plots side by side
  • p1 / p2 stacks them in a column
  • plot_spacer() leaves an empty space

Side by side

p1 | p2

Combining Plots with patchwork

Mix and match for more complicated layouts.

p1 / (p2 | p3 | p4)

patchwork can also add panel tags (“A”, “B”), shared titles, and merged legends. See the layout guide.

Saving Plots

Saving Plots

ggsave("filename.pdf", plot, width = 6, height = 4, units = "in")
  • The file extension sets the format.
  • width, height, and units set the size.

Two kinds of formats:

  • Raster (.png, .jpeg): a fixed grid of pixels.
  • Vector (.pdf, .svg): instructions to draw shapes, so it never loses resolution.

Use a vector format if possible.

Raster vs. Vector Zoom

Raster (.png)

Vector (.svg)

The same plot at 5x zoom.

Plotting Regression Results

From last class

Last class we ran one regression per group with nest(), map(), and tidy().

Setup code (click to show)
library(tidyverse)
library(broom)
library(fixest)
library(gapminder)

set.seed(42)
tb <- tibble(
  x = sample(1:10, 100, replace = TRUE) + rnorm(100),
  y = 5 * x + 20 * rnorm(100),
  group = sample(c("A", "B", "C"), 100, replace = TRUE)
)
stb <- tb |>
  nest(data = c(x, y)) |>
  mutate(
    model = map(data, \(d) lm(y ~ x, data = d)),
    results = map(model, tidy)
  )
gapminder <- gapminder |> mutate(pop = pop / 1e6, gdpPercap = gdpPercap / 1e3)

Same simulated data, same models, and the same rescaled gapminder.

From last class

stb has one row per group, with the model and its tidy results stored in list columns.

stb
# A tibble: 3 × 4
  group data              model  results         
  <chr> <list>            <list> <list>          
1 A     <tibble [29 × 2]> <lm>   <tibble [2 × 5]>
2 B     <tibble [37 × 2]> <lm>   <tibble [2 × 5]>
3 C     <tibble [34 × 2]> <lm>   <tibble [2 × 5]>
stb |> unnest(results) |> select(group, term, estimate, std.error)
# A tibble: 6 × 4
  group term        estimate std.error
  <chr> <chr>          <dbl>     <dbl>
1 A     (Intercept)     5.42      7.90
2 A     x               3.74      1.38
3 B     (Intercept)     5.33      6.92
4 B     x               4.10      1.05
5 C     (Intercept)     3.88      6.36
6 C     x               4.27      1.07

Coefficient plots

Tidy results are a tibble, so ggplot2 can plot them.

stb |>
  unnest(results) |>
  ggplot(aes(
    x = estimate, y = group, color = group,
    xmin = estimate - 1.96 * std.error,
    xmax = estimate + 1.96 * std.error
  )) +
  geom_pointrange() +
  geom_vline(xintercept = 0) +
  facet_wrap(vars(term), scales = "free_x")

Coefficient plots

Coefficient plots from fixest

tidy() works on fixest models too, and conf.int = TRUE adds confidence intervals.

m_cont <- gapminder |>
  filter(continent != "Oceania") |> ## only two countries
  nest(data = -continent) |>
  mutate(
    model = map(data, \(d) feols(lifeExp ~ log(gdpPercap) | year, cluster = ~ country, data = d)),
    results = map(model, \(m) tidy(m, conf.int = TRUE))
  ) |>
  unnest(results)
m_cont |> select(continent, term, estimate, conf.low, conf.high)
# A tibble: 4 × 5
  continent term           estimate conf.low conf.high
  <fct>     <chr>             <dbl>    <dbl>     <dbl>
1 Asia      log(gdpPercap)     4.81     3.01      6.60
2 Europe    log(gdpPercap)     4.98     3.03      6.93
3 Africa    log(gdpPercap)     4.79     2.97      6.61
4 Americas  log(gdpPercap)     7.32     5.21      9.44

Coefficient plots from fixest

m_cont |>
  ggplot(aes(x = estimate, y = continent, xmin = conf.low, xmax = conf.high)) +
  geom_pointrange() +
  geom_vline(xintercept = 0) +
  labs(x = "Coefficient on log GDP per capita (years of life expectancy)", y = NULL) +
  theme_bw()

Each point is one regression, with year fixed effects and SEs clustered by country.

Class Activity

Class Activity

Using gapminder, how has the relationship between income and life expectancy changed over time?

  1. Nest gapminder by year.

  2. For each year, use feols() to regress lifeExp on log(gdpPercap) with continent fixed effects and heteroskedasticity-robust standard errors (vcov = "hetero").

  3. Tidy the results with conf.int = TRUE and unnest them.

  4. Plot the coefficient against year with geom_pointrange() (or geom_line() plus geom_ribbon()).

  5. Bonus: apply your own theme and labels, and save it as a PDF with ggsave().

One possible solution

gapminder |>
  nest(data = -year) |>
  mutate(
    model = map(data, \(d) feols(lifeExp ~ log(gdpPercap) | continent, vcov = "hetero", data = d)),
    results = map(model, \(m) tidy(m, conf.int = TRUE))
  ) |>
  unnest(results) |>
  ggplot(aes(x = year, y = estimate, ymin = conf.low, ymax = conf.high)) +
  geom_ribbon(alpha = 0.2) +
  geom_line() +
  geom_point() +
  labs(x = NULL, y = "Coefficient on log GDP per capita") +
  theme_bw()

One possible solution