Boxplots, group comparisons, and saving a publication-ready figure
Last time you:
summarize()length() trap with sum(!is.na())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
ggplot grammar, recappedscale_color_manual()ggsave(), for real this timeggplot Grammar, Recappedggplot Grammar, RecappedEvery ggplot is three pieces joined with +:
Note
✅ Key idea
Everything today is the same three pieces — we’re just adding more layers and more polish on top of them.
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 anatomy:
📖 R4DS §9 — Layers
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
position_jitter(width = 0.15) spreads points sideways so they don’t overlapseed = 42 — same jitter layout every time you renderalpha = 0.6 — semi-transparent, so overlapping points are still visibleTip
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.
labs() — Never Ship This
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 Plotggplot(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")
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 TrapWarning
⚠️ Watch out!
The legend title comes from whatever you write for the aesthetic you mapped — color = "Field team" in labs() relabels a color = mapping. Write fill = "..." instead and it does nothing, because this plot never mapped fill.
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
color = group maps a third variable onto the plot — no new geom neededggplot auto-builds the legend and picks colors for youaes() 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.
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?
binwidth sets the width of each bar in data units (here, 2mm)Tip
🖐 Try it yourself
Change binwidth = 2 to binwidth = 0.5 and to binwidth = 5. Which tells the real story?
fill and facet_wrap() TogetherNote
✅ 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.
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.
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
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.
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
facet_wrap(~group) gives each field team its own small panelTip
🖐 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?”
theme_minimal() — light, few gridlines (what we’ve used so far)theme_bw() — white background, black-and-white frametheme_classic() — just x/y axis lines, no gridlines at allNote
✅ Key idea
A theme changes appearance only — never the data or the statistics underneath. Pick one and use it consistently across a report.
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.
scale_color_manual()scale_color_manual() — Choosing Your Own ColorsTip
🚀 Advanced / extra work — ggplot’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)
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 WrongWarning
⚠️ Watch out!
valueslabels, if you supply it), spelled exactly as it appears in the columnunique(pine_df$n_s) first if you’re not surescale_fill_manual() — the fill Twinggplot(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)
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.
Today you:
labs() — never shipped a default column namecolor = / fill = inside aes()stat_summary() — no separate summary table neededfacet_wrap() to check every group at onceggsave()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.
When code breaks — and it will, that is normal:
library(tidyverse) loaded? Is color/fill inside aes() when it should be??function_name opens the help pageNote
✅ Key idea
Every working scientist googles error messages daily. Getting stuck is not failing — it is the job.