See ggplot2, e.g. for Cheatsheet.

Based on the insight that you can uniquely describe any plot as a combination of:

We use mtcars dataset for demonstration.

Code basis:

ggplot(data = <DATA>) +  
    <GEOM_FUNCTION>(  
        mapping = aes(<MAPPINGS>),  
        stat = <STAT>,
        position = <POSITION>
    ) + 
    <COORDINATE_FUNCTION> +
    <FACET_FUNCTION> +
    <THEME> +
    <SCALING>

1 Preliminary Work

Load tidyverse:

library("tidyverse")
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr     1.1.4     ✔ readr     2.1.5
## ✔ forcats   1.0.0     ✔ stringr   1.5.1
## ✔ ggplot2   4.0.0     ✔ tibble    3.2.1
## ✔ lubridate 1.9.3     ✔ tidyr     1.3.1
## ✔ purrr     1.2.0     
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag()    masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors

2 Data

Data given as tibble (tidyverse) or data.frame. It can be tricky in general to get data in correct shape for plots. Often the command pivot_longer() helps. Here it is simple.

Read csv file mtcars.csv. German notation is used, so use read_csv2.

mtcars.temp <- read_csv2("mtcars.csv")
## ℹ Using "','" as decimal and "'.'" as grouping mark. Use `read_delim()` for more control.
## Rows: 28 Columns: 7
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: ";"
## chr (1): name
## dbl (6): mpg, cyl, hp, mass, am, gear
## 
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
mtcars.temp |> head(3)
## # A tibble: 3 × 7
##   name                mpg   cyl    hp  mass    am  gear
##   <chr>             <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 Datsun 710         22.8     4    93  2.32     1     4
## 2 Hornet 4 Drive     21.4     6   110  3.22     0     3
## 3 Hornet Sportabout  18.7     8   175  3.44     0     3

Get values of variable cylinder:

cyl.values <- mtcars.temp |>
  pull(cyl) |>
  unique() |>
  as.character()
cyl.values
## [1] "4" "6" "8" NA

Change ordering (maybe it was already ok) and erase NA:

cyl.values <- cyl.values[c(1, 2, 3)]
cyl.values
## [1] "4" "6" "8"

Same for variable gear:

gear.values <- mtcars.temp |>
  pull(gear) |>
  unique() |>
  as.character()
gear.values
## [1] "4" "3" "5"

Change ordering (maybe it was already ok) and erase NA

gear.values <- gear.values[c(2, 1, 3)]
gear.values
## [1] "3" "4" "5"

Load again mtcars.csv with this given information.

data.coltypes <- cols(
  name = col_character(),
  mpg = col_double(),
  cyl = col_factor(levels = cyl.values, ordered = TRUE),
  hp = col_double(),
  mass = col_double(),
  am = col_logical(),
  gear = col_factor(levels = gear.values, ordered = TRUE),
)

mtcars <- read_csv2("mtcars.csv", col_types = data.coltypes)
## ℹ Using "','" as decimal and "'.'" as grouping mark. Use `read_delim()` for more control.
mtcars |>
  head(3)
## # A tibble: 3 × 7
##   name                mpg cyl      hp  mass am    gear 
##   <chr>             <dbl> <ord> <dbl> <dbl> <lgl> <ord>
## 1 Datsun 710         22.8 4        93  2.32 TRUE  4    
## 2 Hornet 4 Drive     21.4 6       110  3.22 FALSE 3    
## 3 Hornet Sportabout  18.7 8       175  3.44 FALSE 3

Use glimpse for a quick look at data:

mtcars |>
  glimpse()
## Rows: 28
## Columns: 7
## $ name <chr> "Datsun 710", "Hornet 4 Drive", "Hornet Sportabout", "Valiant", "…
## $ mpg  <dbl> 22.8, 21.4, 18.7, 18.1, 14.3, 24.4, 22.8, 19.2, 17.8, 16.4, 17.3,…
## $ cyl  <ord> 4, 6, 8, 6, 8, 4, 4, 6, 6, 8, 8, 8, 8, 8, 8, 4, 4, 4, 4, 8, 8, 8,…
## $ hp   <dbl> 93, 110, 175, 105, 245, 62, 95, 123, 123, 180, 180, 180, 205, 215…
## $ mass <dbl> 2.320, 3.215, 3.440, 3.460, 3.570, 3.190, 3.150, 3.440, 3.440, 4.…
## $ am   <lgl> TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FALSE, FAL…
## $ gear <ord> 4, 3, 3, 3, 3, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 4, 4, 4, 3, 3, 3, 3,…

Levels of all factor variables:

mtcars |>
  select(where(is.factor)) |>
  summary()
##    cyl     gear  
##  4   : 9   3:15  
##  6   : 5   4: 9  
##  8   :12   5: 4  
##  NA's: 2

Levels of all ordered variables:

mtcars |>
  select(where(is.ordered)) |>
  summary()
##    cyl     gear  
##  4   : 9   3:15  
##  6   : 5   4: 9  
##  8   :12   5: 4  
##  NA's: 2

Full summary:

mtcars |>
  summary()
##      name                mpg          cyl           hp             mass      
##  Length:28          Min.   :10.40   4   : 9   Min.   : 52.0   Min.   :1.513  
##  Class :character   1st Qu.:15.43   6   : 5   1st Qu.: 94.0   1st Qu.:2.429  
##  Mode  :character   Median :18.95   8   :12   Median :123.0   Median :3.438  
##                     Mean   :20.16   NA's: 2   Mean   :142.8   Mean   :3.254  
##                     3rd Qu.:23.20             3rd Qu.:180.0   3rd Qu.:3.743  
##                     Max.   :33.90             Max.   :264.0   Max.   :5.424  
##                                               NA's   :1                      
##      am          gear  
##  Mode :logical   3:15  
##  FALSE:19        4: 9  
##  TRUE :9         5: 4  
##                        
##                        
##                        
## 

3 MAPPING

E.g. what is displayed on x-axis and y-axis in a scatter plot, but also we can define color, fill, shape, etc. depending on a variable (aes … aesthetics).

Here we use mass for x-axis, mpg for y-axis and the factor variable cyl for color, but the plot is empty because we did not define the geometric object, e.g. scatter plot, bar plot, etc. (see section GEOM_FUNCTION).

mtcars |>
  ggplot(mapping = aes(x = mass, y = mpg, color = cyl))

4 GEOM_FUNCTION

To get a real plot we have to define the geometric information too. E.g. for scatter plot we use geom_point or for bar chart we use geom_bar.

mtcars |>
  ggplot(mapping = aes(x = mass, y = mpg, color = cyl)) + # we can use "+" to tell ggplot, that more information is comming
  geom_point()
## Warning: Removed 2 rows containing missing values or values outside the scale range
## (`geom_point()`).

We got the above warning because there are NA entries:

mtcars |>
  filter(if_any(everything(), is.na))
## # A tibble: 3 × 7
##   name             mpg cyl      hp  mass am    gear 
##   <chr>          <dbl> <ord> <dbl> <dbl> <lgl> <ord>
## 1 Lotus Europa    30.4 <NA>    113  1.51 TRUE  5    
## 2 Ford Pantera L  15.8 <NA>    264  3.17 TRUE  5    
## 3 Ferrari Dino    19.7 6        NA  2.77 TRUE  5

We can manipulate data to get same plot without warning. But be careful here! Do not use drop_na for the whole data. Otherwise, Ferrari Dino is deleted too, which is not necessary!

mtcars |>
  select(mass, mpg, cyl) |> # first select relevant features
  drop_na() |> # then drop rows with NA-entries, the NA-entry for hp is not relevant here and Ferrari Dino will not be deleted because of select!
  ggplot(mapping = aes(x = mass, y = mpg, color = cyl)) +
  geom_point()

Same plots:

# mtcars |>
#   ggplot(aes(x = mass, y = mpg, color = cyl)) +  # we do not need to write mapping=aes(...)
#   geom_point()

# mtcars |>
#   ggplot() +
#   geom_point(aes(x = mass, y = mpg, color = cyl))  # we can use mapping/aes in each geom object

Later in section SCALING we will analyse the scaling part in more details. Till now we only have to know that:

Next we use a continuous variable as color. Resulting in a color gradient:

mtcars |>
  ggplot(aes(x = mass, y = mpg, color = hp)) +
  geom_point()

But of course we can also use a simple color for all points. Attention: color = “darkred” is outside of aesthetics.

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point(color = "darkred")

Same with:

# mtcars |>
#   ggplot() +
#   geom_point(aes(x = mass, y = mpg), color = "darkred")  # here we see that color is defined outside aesthetics

Another geom object is e.g. geom_bar. Geom_bar uses the variable cyl and automatically computes the counts.

mtcars |>
  ggplot(mapping = aes(x = cyl)) +
  geom_bar()

If you want to deliver the counts via a variable, you can use geom_col.

mtcars |>
  select(cyl) |>
  group_by(cyl) |>
  summarise(
    count = n()
  ) |>
  ggplot(mapping = aes(x = cyl, y = count)) +
  geom_col()

5 STAT

In most cases we can compute some statistics for the data and plot this information. But ggplot can also compute statistical information directly, which is quite useful.

E.g. for density plot we need the statistical information density:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_line(stat = "density")

But there is the simpler workaround which is used more commonly:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density()

We can compute the statistical information (density) outside and plot it, but this is more complicated:

density_function <- mtcars |>
  pull(mass) |>
  density(kernel = "gaussian", cut = 0) # define the range of the density by using [min, max] of variable mass

densitty_data <- tibble(
  x = density_function$x,
  y = density_function$y
)

densitty_data |>
  ggplot(aes(x = x, y = y)) +
  geom_line()

Often we want to use a statistical information for a variable in aesthetics. E.g. we want to use geom_bar with relative counts.

But this is not what we want (all values are 100%):

mtcars |>
  ggplot() +
  geom_bar(aes(x = cyl, y = after_stat(prop)))

Same problem in next plot, but here we see in more details what is happening: For each attribute of variable cyl we define value for comparison.
We get
cyl4: 9 of 9 is 1
cyl6: 5 of 5 is 1
cyl8: 12 of 12 is 1
NA: 2 of 2 is 1

mtcars |>
  ggplot() +
  geom_bar(aes(x = cyl, y = after_stat(prop), group = cyl))

But we do not want to split the comparison, we only want one “group” with 9+5+12+2=28 observations.
We get
cyl4: 9 of 28 is 31.14%
cyl6: 5 of 28 is 17.86%
cyl8: 12 of 28 is 42.86%
NA: 2 of 28 is 7.14%

mtcars |>
  ggplot() +
  geom_bar(aes(x = cyl, y = after_stat(prop), group = 1))

Again we can compute this statistical information (relative counts) manually and plot it:

mtcars |>
  select(cyl) |>
  group_by(cyl) |>
  summarise(
    count = n()
  ) |>
  mutate(
    rel_count = count / sum(count)
  ) |>
  ggplot(aes(x = cyl, y = rel_count)) +
  geom_col()

To get percentages e.g. 40% instead of 0.4, see section SCALING.

6 POSITON

E.g. show points in a boxplot. For the first time we use two geometric objects in one plot. The default for geom_point with x = 0 is not nice:

mtcars |>
  ggplot(aes(y = mass)) +
  geom_boxplot() +
  geom_point(aes(x = 0))

And here with jittered position:

mtcars |>
  ggplot(aes(y = mass)) +
  geom_boxplot() +
  geom_point(aes(x = 0), position = position_jitter(width = 0.3))

For a bar chart we get stacked bars here:

mtcars |>
  ggplot(aes(x = cyl, fill = am)) +
  geom_bar()

If we want to get the bars side by side, we can use a position argument:

mtcars |>
  ggplot(aes(x = cyl, fill = am)) +
  geom_bar(position = "dodge")

7 COORDINATE_FUNCTION

First we have a look at a scatter plot without changing coordinates:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point()

Logarithmic axis for y by changing the coordinate. We do not modify data here. The difference will be discussed in section SCALING.

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  coord_transform(y = "log10")

Next, we use a density function and zoom out/in. But first without zoom:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density()

Next with zoom out. Density exists outside of [min, max], but nothing is plotted outside. We change the x-coordinate but not the data/function.
mtcars |> pull(mass) |> min()
[1] 1.513
mtcars |> pull(mass) |> max()
[1] 5.424

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() +
  coord_cartesian(xlim = c(0, 7))

If we want to plot the function outside too, we have to scale the data/function, which can be done with scale_x_continuous or the short form xlim, see section SCALING.

Next we zoom in:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() +
  coord_cartesian(xlim = c(3, 4))

8 SCALING

Of course there is always an implicit scaling used (x-axis: mass in [1.513, 5.424] to e.g. [0, 5inch] or color: cyl with cyl4 = violet, cyl6 = turquoise, cyl8 = yellow), but we can also use explicit scaling:

mtcars |>
  ggplot(mapping = aes(x = am, y = mpg, color = cyl)) +
  geom_point() +
  scale_color_manual("cylinders", values = c("4" = "darkred", "6" = "darkblue", "8" = "darkgreen"), na.value = "black") + # scale color
  scale_y_continuous(breaks = seq(0, 40, by = 10), minor_breaks = seq(0, 40, by = 5)) + # scale y-axis (continuous) breaks
  scale_x_discrete(labels = c("FALSE" = "automatic", "TRUE" = "manual")) # scale x-axis (discrete) labels

We have already seen the following bar plot with relative values in section STAT. But now we scale to percentages, e.g. 0.2 to 20%.

mtcars |>
  ggplot() +
  geom_bar(aes(x = cyl, y = after_stat(prop), group = 1)) +
  scale_y_continuous(labels = scales::percent)

We have already seen the following plot in section COORDINATE_FUNCTION. We only change the breaks on y-axis.

Logarithmic axis for y:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  coord_transform(y = "log10") +
  scale_y_continuous(breaks = seq(10, 35, by = 5))

We get the same with the following scaling command. So a logarithmic plot can be made by modifying the coordinate or by modifying the data.

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  scale_y_log10(breaks = seq(10, 35, by = 5))

This is equivalent to (except the values on y-axis, which have to be understood as 10^…)

mtcars |>
  ggplot(aes(x = mass, y = log10(mpg))) +
  geom_point() +
  scale_y_continuous(breaks = round(log10(seq(10, 35, by = 5)), 1))

The practical difference between changing coordinate and scaling is: changing coordinate occurs after statistics (like e.g. geom_smooth). Scaling occurs before statistics (like e.g. geom_smooth).

In section COORDINATE_FUNCTION we have also zoomed in/out by changing the coordinate. Now we will change the data by scaling and see the difference. With scale_x_continuous(limits = c(0, 7)) or the short version xlim(0, 7) we scale the data/function to [0, 7], therefore the function is plotted in the full interval.

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() + # density is plotted in [0, 7]
  scale_x_continuous(limits = c(0, 7))

Compare with the above command which does not change the data/function, only the coordinates:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() +
  coord_cartesian(xlim = c(0, 7))

If we want to zoom in, we have already seen the command in section COORDINATE_FUNCTION:

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() +
  coord_cartesian(xlim = c(3, 4))

If we use scaling instead, we get something weird: We get a warning which says that from our data/function some information (rows) is removed. This is because we scale data to [3, 4] and compute the density for points inside these interval. This is not a zoom of the original plot because we use only some of the observations! Do we really want that?

mtcars |>
  ggplot(aes(x = mass)) +
  geom_density() +
  scale_x_continuous(limits = c(3, 4))
## Warning: Removed 13 rows containing non-finite outside the scale range
## (`stat_density()`).

This is the same but without warnings because we filter out the small/large values.

mtcars |>
  filter(3 <= mass & mass <= 4) |> # here we see what is happening: values outside [3, 4] are not used in the further computations/plot
  ggplot(aes(x = mass)) +
  geom_density() +
  scale_x_continuous(limits = c(3, 4))

9 FACET_FUNCTION

Multiple plots (matrix) where e.g. rows correspond to cyl and columns to am.

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  # facet_grid(cyl ~ am)  # e.g. with labels "FALSE" and "TRUE"
  facet_grid(cyl ~ am, labeller = label_both) # here e.g. with labels "am: FALSE" and "am: TRUE"

Or only with cyl in rows:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  facet_grid(. ~ cyl, labeller = label_both)

Or only with cyl in columns:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  facet_grid(cyl ~ ., labeller = label_both)

Alternatively we can get a list of 6 plots (2 were empty) in one row:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  facet_wrap(cyl ~ am, labeller = label_both, nrow = 1)

Or a list of 6 plots in two rows:

mtcars |>
  ggplot(aes(x = mass, y = mpg)) +
  geom_point() +
  facet_wrap(cyl ~ am, labeller = label_both, nrow = 2)

10 THEME

Of course we can change the theme too (here we use black/white theme with larger text size and change the legend position):

mtcars |>
  select(mass, mpg, cyl) |> # first select relevant features
  drop_na() |> # then drop rows with NA-entries, the NA-entry for hp is not relevant here and Ferrari Dino will not be deleted because of select!
  ggplot(mapping = aes(x = mass, y = mpg, color = cyl)) +
  geom_point() +
  theme_bw(base_size = 18) +
  theme(
    legend.position = "bottom"
  )

11 Annotations and labels

Change annotations and labels to make plot clearer:

my.car.x <- 2.05 # I manually define x coordinate, FALSE -> 1, TRUE -> 2, so we use 2 plus a little bit
my.car.y <- mtcars |>
  filter(name == "Ferrari Dino") |>
  pull(mpg)
my.car.text <- "my car"

mtcars |>
  ggplot(mapping = aes(x = am, y = mpg, color = cyl)) +
  geom_point() +
  scale_color_manual("cylinders", values = c("4" = "darkred", "6" = "darkblue", "8" = "darkgreen"), na.value = "black") +
  scale_y_continuous(breaks = seq(0, 40, by = 10), minor_breaks = seq(0, 40, by = 5)) +
  scale_x_discrete(labels = c("FALSE" = "automatic", "TRUE" = "manual")) +
  labs(title = "mtcars", x = "transmission", y = "miles per gallon") +
  annotate("text", x = my.car.x, y = my.car.y, label = my.car.text, hjust = 0)

Or the above bar chart with scaling to percentages and new labels.

mtcars |>
  ggplot() +
  geom_bar(aes(x = cyl, y = after_stat(prop), group = 1)) +
  scale_y_continuous(labels = scales::percent) +
  labs(x = "cylinders", y = "")

12 Data (again) and pivot_longer

Often we need the pivot_longer format to get the correct plot. E.g.

relig_income |>
  head(3)
## # A tibble: 3 × 11
##   religion `<$10k` `$10-20k` `$20-30k` `$30-40k` `$40-50k` `$50-75k` `$75-100k`
##   <chr>      <dbl>     <dbl>     <dbl>     <dbl>     <dbl>     <dbl>      <dbl>
## 1 Agnostic      27        34        60        81        76       137        122
## 2 Atheist       12        27        37        52        35        70         73
## 3 Buddhist      27        21        30        34        33        58         62
## # ℹ 3 more variables: `$100-150k` <dbl>, `>150k` <dbl>,
## #   `Don't know/refused` <dbl>

We want to plot the large groups only.

relig_income_new <- relig_income |>
  pivot_longer(
    cols = !religion,
    names_to = "income",
    values_to = "count"
  )
relig_income_new |>
  head(10)
## # A tibble: 10 × 3
##    religion income             count
##    <chr>    <chr>              <dbl>
##  1 Agnostic <$10k                 27
##  2 Agnostic $10-20k               34
##  3 Agnostic $20-30k               60
##  4 Agnostic $30-40k               81
##  5 Agnostic $40-50k               76
##  6 Agnostic $50-75k              137
##  7 Agnostic $75-100k             122
##  8 Agnostic $100-150k            109
##  9 Agnostic >150k                 84
## 10 Agnostic Don't know/refused    96

We want to plot the large groups only.

relig_used <- relig_income_new |>
  group_by(religion) |>
  summarise(total = sum(count)) |>
  filter(total > 1000) |>
  pull(religion)

relig_used
## [1] "Catholic"                "Evangelical Prot"       
## [3] "Historically Black Prot" "Mainline Prot"          
## [5] "Unaffiliated"

And the corresponding bar chart. geom_bar is used when ggplot has to count. geom_col is used, when the counts are already given in a variable.

relig_income_new |>
  filter(religion %in% relig_used) |>
  ggplot(aes(x = income, y = count, fill = religion)) +
  geom_col() +
  scale_fill_viridis_d() # viridis is a nice color palette, d stands for discrete