Lecture: Graphing Effectively

Boxplots, group comparisons, and saving a publication-ready figure

Bill Perry

Where We Left Off

Last time you:

  • Computed mean, median, SD, and SE — by hand and with summarize()
  • Fixed the length() trap with sum(!is.na())
  • Got tidy per-group tables with group_by() + summarize()

Note

✅ Key idea from Describing Your Data

You now have real numbers describing shady vs. sunny needles. Today we make those numbers visible — and save the result properly.

Note

Today’s roadmap

  1. The ggplot grammar, recapped
  2. Boxplots done right — with raw points
  3. Axis labels and titles, done right
  4. Mapping color by group
  5. Histograms and density curves
  6. Plotting mean ± SE directly
  7. Faceting and a quick theme tour
  8. Custom colors and legends with scale_color_manual()
  9. ggsave(), for real this time

Part 1 · The ggplot Grammar, Recapped

The ggplot Grammar, Recapped

Every ggplot is three pieces joined with +:

  1. data — which data frame
  2. aes() — which columns map to x, y, color, fill…
  3. geom — how to draw it
library(tidyverse)
library(readxl)

pine_df <- read_csv("data/pine_needles.csv")

Note

✅ Key idea

Everything today is the same three pieces — we’re just adding more layers and more polish on top of them.

Tip

🖐 Recap from Getting Started

ggplot(pine_df, aes(x = n_s, y = length_mm)) +
  geom_point()

Scatterplot of pine needle length in millimeters by side of tree (n_s), with individual points shown for each side.

Part 2 · Boxplot Anatomy

Boxplot Anatomy

pine_box_plot <- ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side (n = shady, s = sunny)",
    y = "Needle length (mm)",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_box_plot

Boxplot of needle length by side of tree (shady vs. sunny), filled by side, with outlier points hidden. Titled 'Needle Length by Side of Tree'.

Boxplot anatomy:

  • Middle line = median
  • Box edges = 25th and 75th percentile (IQR)
  • Whiskers = 1.5 × IQR
  • Dots beyond whiskers = outliers

📖 R4DS §9 — Layers

Boxplot + Raw Points

Note

🔮 Predict first: With only 6 needles per side per tree, does a boxplot alone show you everything? What might it hide?

pine_jitter_plot <- ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.5, outlier.shape = NA) +
  geom_point(
    position = position_jitter(width = 0.15, seed = 42),
    alpha = 0.6,
    size = 2
  ) +
  labs(
    title = "Needle Length by Side of Tree",
    x = "Side of tree",
    y = "Needle length (mm)",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_jitter_plot

Boxplot of needle length by side of tree with individual jittered data points overlaid on top of each box, so every raw observation is visible alongside the summary.

  • position_jitter(width = 0.15) spreads points sideways so they don’t overlap
  • seed = 42 — same jitter layout every time you render
  • alpha = 0.6 — semi-transparent, so overlapping points are still visible

Tip

Always show the raw points alongside a boxplot when n is small — every point matters, and a box alone can hide a bimodal or lopsided pattern.

Part 2b · Axis Labels and Titles, Done Right

Before labs() — Never Ship This

ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Boxplot of needle length by side of tree with no labs() applied, so the axes are labeled with the raw column names n_s and length_mm instead of readable text.

Important

The x-axis reads n_s and the y-axis reads length_mm — the literal column names, not something a reader outside your lab would understand.

Warning

⚠️ Watch out!

A plot with default column-name axes is a plot that only makes sense to you, today. Six months from now, even you may not remember what n_s meant.

labs() — Every Piece of Text on the Plot

ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  labs(
    title = "Needle Length by Side of Tree",
    subtitle = "Four field teams, four trees",
    x = "Side of tree (shady = n, sunny = s)",
    y = "Needle length (mm)",
    fill = "Side",
    caption = "Data: pine_needles.csv"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

Boxplot of needle length by side of tree with a full title, subtitle, readable axis labels, legend title, and data-source caption added via labs().

One function, five jobs:

Argument Controls
title main title
subtitle smaller text under the title
x / y axis labels
caption small text, bottom-right
color/fill legend title for that aesthetic

Note

✅ Key idea

labs() never touches the data or the statistics — only what a reader sees written on the plot. Change it freely.

labs() — the Legend Title Trap

# Whichever aesthetic name you mapped, use that same name in labs()
ggplot(pine_df, aes(x = n_s, y = length_mm, color = group)) +
  geom_point(position = position_jitter(width = 0.15, seed = 42)) +
  labs(color = "Field team") # matches aes(color = group)

Jittered scatterplot of needle length by side of tree, colored by field team, with the legend relabeled 'Field team'.

Warning

⚠️ Watch out!

The legend title comes from whatever you write for the aesthetic you mappedcolor = "Field team" in labs() relabels a color = mapping. Write fill = "..." instead and it does nothing, because this plot never mapped fill.

Part 3 · Mapping Color by Group

Mapping Color by Group

pine_team_plot <- ggplot(pine_df, aes(x = n_s, y = length_mm, color = group)) +
  geom_point(
    position = position_jitter(width = 0.15, seed = 42),
    size = 2.5,
    alpha = 0.8
  ) +
  labs(
    title = "Needle Length by Side, Colored by Team",
    x = "Side of tree",
    y = "Needle length (mm)",
    color = "Field team"
  ) +
  theme_minimal(base_size = 9)

pine_team_plot

Jittered scatterplot of needle length by side of tree, with points colored by field team, titled 'Needle Length by Side, Colored by Team'.

  • color = group maps a third variable onto the plot — no new geom needed
  • ggplot auto-builds the legend and picks colors for you
  • Mapping inside aes() always means “vary this by data” — not “make everything this color”

Warning

⚠️ Watch out!

color = "darkblue" outside aes() sets one fixed color. color = group inside aes() maps colors to a variable. Mixing these up is one of the most common ggplot mistakes.

Part 3b · Histograms and Density — Seeing the Whole Distribution

Histograms — the Shape Behind the Summary

Note

🔮 Predict first: We found mean(length_mm) ≈ 17.7 in Describing Your Data, with a fairly small gap from the median. What shape do you expect the full distribution to have — symmetric, or skewed?

ggplot(pine_df, aes(x = length_mm)) +
  geom_histogram(binwidth = 2, fill = "darkblue", color = "white") +
  labs(
    title = "Distribution of Needle Length",
    x = "Needle length (mm)",
    y = "Count"
  ) +
  theme_minimal(base_size = 9)

Histogram of needle length in millimeters across all needles, with 2mm bins, showing the overall shape of the distribution.

  • binwidth sets the width of each bar in data units (here, 2mm)
  • Too wide hides structure; too narrow shows noise — try a few values

Tip

🖐 Try it yourself

Change binwidth = 2 to binwidth = 0.5 and to binwidth = 5. Which tells the real story?

Histograms by Group — fill and facet_wrap() Together

ggplot(pine_df, aes(x = length_mm, fill = n_s)) +
  geom_histogram(binwidth = 2, color = "white", alpha = 0.7) +
  facet_wrap(~n_s, ncol = 1) +
  labs(x = "Needle length (mm)", y = "Count", fill = "Side") +
  theme_minimal(base_size = 8) +
  theme(legend.position = "none")

Two stacked histograms of needle length, one panel for the shady side and one for the sunny side, faceted so each group's distribution is shown separately.

Note

✅ Key idea

Overlapping histograms on one panel are hard to read. Faceting stacks them into separate panels sharing an x-axis — same comparison, much clearer.

Density Curves — a Smoothed Alternative

ggplot(pine_df, aes(x = length_mm, fill = n_s)) +
  geom_density(alpha = 0.5) +
  labs(
    title = "Needle Length Density by Side",
    x = "Needle length (mm)",
    y = "Density",
    fill = "Side"
  ) +
  theme_minimal(base_size = 9)

Overlaid density curves of needle length for the shady and sunny sides, semi-transparent so both distributions are visible on one panel.

Tip

📖 Histogram vs. density

A density curve is a smoothed histogram, rescaled so the area under each curve sums to 1. It overlays cleanly for group comparisons — no faceting required, at the cost of hiding the raw bin counts.

Part 4 · Mean ± SE — Plotted Directly

Mean ± SE — Plotted Directly

pine_mean_se_plot <- ggplot(pine_df, aes(x = n_s, y = length_mm, color = n_s)) +
  geom_point(
    position = position_jitter(width = 0.15, seed = 42),
    alpha = 0.3,
    size = 2
  ) +
  stat_summary(fun = mean, geom = "point", size = 4) +
  stat_summary(
    fun.data = mean_se,
    geom = "errorbar",
    width = 0.15,
    linewidth = 0.9
  ) +
  labs(
    title = "Mean ± SE Needle Length by Side",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  theme_minimal(base_size = 9) +
  theme(legend.position = "none")

pine_mean_se_plot

Scatterplot of jittered raw needle-length points by side of tree, with a large point marking the mean and an error bar showing ± 1 standard error for each side.

Two stat_summary() layers:

call draws
fun = mean one large point at the mean
fun.data = mean_se error bars for ± 1 SE

stat_summary() computes the mean and SE for you, straight from the raw data — no summarize() step required first.

Note

✅ Key idea

This is the exact same mean and SE you calculated by hand in Describing Your Data — now you can see them.

Part 5 · Faceting — One Panel Per Group

Faceting — One Panel Per Group

pine_facet_plot <- ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.6, outlier.shape = NA) +
  facet_wrap(~group) +
  labs(
    title = "Needle Length by Side, Faceted by Team",
    x = "Side of tree",
    y = "Needle length (mm)"
  ) +
  theme_minimal(base_size = 7) +
  theme(legend.position = "none")

pine_facet_plot

Boxplots of needle length by side of tree, faceted into a separate small panel for each field team, to compare whether the shady-vs-sunny pattern holds across teams.

  • facet_wrap(~group) gives each field team its own small panel
  • Same x/y scale across panels — easy to compare
  • Useful the moment “does every group show the same pattern?” is the real question

Tip

🖐 Notice

A faceted plot answers a different question than a single colored plot: not just “is there an overall pattern,” but “does the pattern hold for everyone?”

Part 6 · A Quick Theme Tour

A Quick Theme Tour

pine_jitter_plot + theme_bw(base_size = 9)

The boxplot-with-jittered-points figure re-rendered using theme_bw, a white background with a black-and-white frame.

pine_jitter_plot + theme_classic(base_size = 9)

The boxplot-with-jittered-points figure re-rendered using theme_classic, showing only x and y axis lines with no gridlines.

  • theme_minimal() — light, few gridlines (what we’ve used so far)
  • theme_bw() — white background, black-and-white frame
  • theme_classic() — just x/y axis lines, no gridlines at all

Note

✅ Key idea

A theme changes appearance only — never the data or the statistics underneath. Pick one and use it consistently across a report.

Part 7 · Saving a Real Figure

Saving a Real Figure

ggsave(
  "figures/pine_needle_boxplot.png",
  plot = pine_jitter_plot,
  width = 3,
  height = 3,
  units = "in",
  dpi = 300
)

Warning

⚠️ Watch out!

ggsave() wants the filename first, then plot =. If you skip plot =, it saves whatever plot was drawn last — not necessarily the one you meant.

Note

✅ Key idea

Always set width, height, units, and dpi explicitly. Letting ggsave() guess gives you a plot sized for your screen, not for a report or a poster.

Part 8 · Custom Colors and Legends — scale_color_manual()

scale_color_manual() — Choosing Your Own Colors

Tip

🚀 Advanced / extra workggplot’s default colors are fine for exploring data, but a real figure often needs specific colors (journal style, colorblind-safe palettes, matching a poster). This is how you take control.

ggplot(pine_df, aes(x = n_s, y = length_mm, color = n_s)) +
  geom_point(
    position = position_jitter(width = 0.15, seed = 42),
    size = 2.5
  ) +
  stat_summary(fun = mean, geom = "point", size = 4, color = "black") +
  scale_color_manual(
    name = "Side of tree",
    labels = c(n = "Shady (sheltered)", s = "Sunny"),
    values = c(n = "#2c7fb8", s = "#d95f0e")
  ) +
  labs(x = "Side of tree", y = "Needle length (mm)") +
  theme_minimal(base_size = 9)

Jittered scatterplot of needle length by side of tree with a black point marking each side's mean, using custom colors (blue for shady, orange for sunny) and a relabeled legend set via scale_color_manual().

Three arguments, matched by name:

Argument Does what
name the legend title
labels text shown for each level
values the actual color for each level

Note

✅ Key idea

labels and values are both named vectors, keyed by the raw values in your data (n, s) — not by the pretty text you want to display. ggplot looks up each raw value and substitutes the label/color you gave it.

scale_color_manual() — Getting the Names Wrong

# Typo: "sun" instead of the real value "s"
ggplot(pine_df, aes(x = n_s, y = length_mm, color = n_s)) +
  geom_point() +
  scale_color_manual(
    values = c(n = "#2c7fb8", sun = "#d95f0e")
  )

Warning

⚠️ Watch out!

  • Every level that appears in your data must get an entry in values
  • (and in labels, if you supply it), spelled exactly as it appears in the column
  • check with unique(pine_df$n_s) first if you’re not sure
  • A missing or misspelled level draws as grey with a warning.

scale_fill_manual() — the fill Twin

ggplot(pine_df, aes(x = n_s, y = length_mm, fill = n_s)) +
  geom_boxplot(alpha = 0.7, outlier.shape = NA) +
  scale_fill_manual(
    name = "Side of tree",
    labels = c(n = "Shady (sheltered)", s = "Sunny"),
    values = c(n = "#2c7fb8", s = "#d95f0e")
  ) +
  labs(x = "Side of tree", y = "Needle length (mm)") +
  theme_minimal(base_size = 9)

Boxplot of needle length by side of tree using custom fill colors (blue for shady, orange for sunny) and a relabeled legend set via scale_fill_manual().

Tip

🖐 Notice

Same three arguments, same pattern — scale_fill_manual() is scale_color_manual() for the fill aesthetic instead of color. A boxplot’s box is fill; its jittered points (if you added geom_point(color = ...)) would be color. A plot can need both at once.

Wrap-up

Today you:

  • Built boxplots with raw points overlaid, not hidden
  • Wrote real axis labels, titles, and legend titles with labs() — never shipped a default column name
  • Mapped a third variable with color = / fill = inside aes()
  • Built histograms and density curves to see a whole distribution’s shape
  • Plotted mean ± SE directly with stat_summary() — no separate summary table needed
  • Faceted a plot with facet_wrap() to check every group at once
  • Compared themes, and saved a real, correctly-sized figure with ggsave()
  • (Advanced) Set exact colors and legend text by hand with scale_color_manual() / scale_fill_manual()

Tip

🖐 Before next class

Finish the worksheet: build a mean ± SE plot faceted by group, pick a theme, and save it to figures/ at 3×3in, 300 dpi.

Note

Where this leads

You can now import, wrangle, describe, and visualize a dataset end to end. That full pipeline — raw data → clean → summarize → visualize — is the foundation every later statistical test builds on.

Getting unstuck

When code breaks — and it will, that is normal:

  1. Read the error message out loud; it usually names the line
  2. Check the usual suspects: library(tidyverse) loaded? Is color/fill inside aes() when it should be?
  3. ?function_name opens the help page
  4. Bring the exact error (copy-paste it) to class or office hours

Note

✅ Key idea

Every working scientist googles error messages daily. Getting stuck is not failing — it is the job.