Volume 3 · eight days

A figure is an argument, not a decoration.

Reviewers look at your figures before they read your abstract, and in behavioural science the figure conventions have changed: raw data visible, distributions rather than bar charts, within-subject change shown per person, colour that survives greyscale printing. This volume teaches the grammar once, then the specific figures your field expects.

7
layers in the grammar
6
portfolio figures by day 24
300
dpi, the journal minimum
Day 17 45 minutes · learn this once, never guess again

Every plot is the same seven decisions

ggplot2 is not a list of chart types to memorise. It is a grammar: you state the data, map variables to visual properties, choose shapes to draw, and optionally adjust statistics, scales, coordinates and panels. Every figure in this course — and every figure you will ever need — is a different combination of the same seven slots.

data
One tidy data frame, usually long format. If the plot is hard, the data is the wrong shape.
aes()
Which variable becomes x, y, colour, fill, shape, size, group. Variables go inside aes; constants go outside.
geom_*()
The marks: points, lines, bars, boxes, densities. Layers stack in the order you add them.
stat_*()
Computation inside the plot: means, counts, smoothers, error bars. Every geom has a default stat.
scale_*()
How values map to the visual: axis limits, breaks, colour palettes, transformations.
coord_* / facet_*
The coordinate system, and small multiples. coord_cartesian(ylim=) zooms without deleting data.
library(tidyverse) d <- psych::bfi |> as_tibble() |> mutate(gender = factor(gender, 1:2, c("male", "female")), agree = rowMeans(cbind(7 - A1, A2, A3, A4, A5), na.rm = TRUE), neuro = rowMeans(cbind(N1, N2, N3, N4, N5), na.rm = TRUE)) |> drop_na(gender, agree, neuro) ggplot(d, aes(x = agree, y = neuro)) + # 1 data + 2 aesthetics geom_point(alpha = 0.2, size = 1.2) + # 3 a geom; alpha is constant, so OUTSIDE aes geom_smooth(method = "lm") + # 4 a stat computed for you scale_x_continuous(limits = c(1, 6)) + # 5 scales facet_wrap(~ gender) + # 6 panels labs(x = "Agreeableness", y = "Neuroticism") + theme_minimal(base_size = 11) # 7 theme#> `geom_smooth()` using formula = 'y ~ x'
The one error everybody makes
geom_point(aes(colour = "red")) gives you a legend labelled "red" and points that are not red, because anything inside aes() is treated as a variable to map. A fixed colour goes outside: geom_point(colour = "red"). Inside aes = from the data; outside aes = a constant.
Do this now · 15 minutes
Build the plot above one layer at a time, running it after each + so you see what that layer added. Then break it deliberately: put alpha inside aes() and read the legend it produces.
Day 18 50 minutes · look before you test

Distributions, because the mean hides everything

Before any test, plot the distribution. Bimodality, a floor effect, an impossible value, a subgroup — all invisible in a mean and obvious in thirty seconds of looking. This is not exploratory sloppiness; it is the assumption-checking that actually works.

# Histogram: choose the binwidth deliberately, do not accept the default silently ggplot(d, aes(x = neuro)) + geom_histogram(binwidth = 0.25, fill = "#7580fa", colour = "white") + geom_vline(xintercept = mean(d$neuro), linetype = "dashed") + labs(x = "Neuroticism", y = "Participants") # Density by group: shape comparison, not just location ggplot(d, aes(x = neuro, fill = gender)) + geom_density(alpha = 0.45, colour = NA) # Raincloud: distribution + box + raw points. The modern default. library(ggdist) ggplot(d, aes(x = gender, y = neuro)) + stat_halfeye(adjust = 0.6, width = 0.5, justification = -0.25, .width = 0) + geom_boxplot(width = 0.14, outlier.shape = NA, alpha = 0.6) + geom_jitter(width = 0.06, alpha = 0.12, size = 0.8) + coord_flip() # ECDF: the honest way to compare two whole distributions ggplot(d, aes(x = neuro, colour = gender)) + stat_ecdf(linewidth = 0.9)#> Warning: Removed 0 rows containing non-finite outside the scale range
What you seeWhat it usually meansWhat to do about it
A spike at the lowest valueFloor effect — common on symptom scales in non-clinical samples.Consider a count or zero-inflated model; do not transform and hope.
Two humpsAn unmodelled subgroup, or two testing sessions pooled.Find the grouping variable. This is a finding, not a nuisance.
A long right tailNormal for reaction times, latencies and counts.Model the distribution rather than deleting the tail.
Values outside the scale rangeData-entry or scoring error.Return to the cleaning script; never to the analysis.
Do this now · 20 minutes
Plot the distribution of every outcome variable in your own dataset — histogram and raincloud. Write one comment per variable describing its shape and any anomaly. Fix anomalies in the cleaning script, not here.
Day 19 50 minutes · your main results figure

Group comparisons, and the death of the bar chart

A bar of group means with an error bar communicates two numbers and hides a hundred. Psychological Science, Nature Human Behaviour and most APA journals now expect raw data visible. The pattern is always the same: raw points, a summary, an interval — in that layer order, so the summary sits on top.

ggplot(d3, aes(x = condition, y = bdi_post, colour = condition)) + geom_jitter(width = 0.12, alpha = 0.3, size = 1.3) + # raw data first stat_summary(fun.data = mean_cl_normal, geom = "errorbar", # 95% CI width = 0.1, linewidth = 0.8, colour = "#2d3142") + stat_summary(fun = mean, geom = "point", size = 3, colour = "#2d3142") + scale_colour_manual(values = c(control = "#7580fa", CBT = "#e26d5c", Waitlist = "#81b29a"), guide = "none") + labs(x = NULL, y = "BDI-II at post-treatment", caption = "Points are participants; markers are means with 95% CIs.") + theme_minimal(base_size = 11)#> (mean_cl_normal requires the Hmisc package — install.packages("Hmisc")) # Between-subject error bars on a within-subject effect mislead badly: # the relevant variability is WITHIN person, and a spaghetti plot shows it. ggplot(long, aes(x = factor(time), y = bdi, group = id)) + geom_line(alpha = 0.12, linewidth = 0.4) + geom_point(alpha = 0.15, size = 0.8) + stat_summary(aes(group = condition, colour = condition), fun = mean, geom = "line", linewidth = 1.2) + stat_summary(aes(group = condition, colour = condition), fun.data = mean_se, geom = "pointrange", size = 0.5) + facet_wrap(~ condition) + labs(x = "Assessment", y = "BDI-II", colour = NULL)#> `geom_line()`: each group consists of only one observation. #> ℹ Do you need to adjust the group aesthetic? ← the classic warning: set group = id

That warning is the most common ggplot confusion in longitudinal work. When x is a factor, ggplot has no idea which points belong to the same person until you tell it with group = id.

Do this now · 20 minutes
Build your own main results figure: raw points, group means, 95% CIs, no bars. If your design is within-subject, add the per-person lines. Save it as output/figures/fig2_results.png — this is portfolio figure one.
Day 20 45 minutes · associations and interactions

Relationships, without lying with a smoother

A scatter plot with a line is the most-used figure in correlational psychology and the easiest to overstate. Two disciplines: show every point, and make the line match the model you actually report.

ggplot(d, aes(x = agree, y = neuro)) + geom_point(alpha = 0.18, size = 1.1) + geom_smooth(method = "lm", se = TRUE, colour = "#e26d5c") + # matches lm() geom_smooth(method = "loess", se = FALSE, colour = "#7580fa", # diagnostic only linetype = "dashed", span = 0.9) + labs(x = "Agreeableness", y = "Neuroticism", caption = "Solid: OLS fit (as modelled). Dashed: loess, to check linearity.") # Overplotting with thousands of rows: bin instead of guessing ggplot(d, aes(x = agree, y = neuro)) + geom_hex(bins = 30) + scale_fill_viridis_c() # An interaction is a figure before it is a coefficient ggplot(d, aes(x = agree, y = neuro, colour = gender)) + geom_point(alpha = 0.12, size = 1) + geom_smooth(method = "lm", se = TRUE) + facet_grid(~ education)#> `geom_smooth()` using formula = 'y ~ x' #> Warning: Removed 223 rows containing missing values (`geom_hex()`)
Two ways a scatter plot misleads
A loess curve reported as a finding when your model was linear: the figure then shows a relationship your statistics never tested. And a truncated y-axis, which magnifies a trivial difference. Use coord_cartesian() to zoom for visibility, state the full range in the caption, and never let the axis do argumentative work the effect size cannot support.
Do this now · 15 minutes
Plot your two main continuous variables with both an OLS and a loess line. If they disagree visibly, note it — volume 4 day 37 is where that becomes a diagnostic decision rather than a curiosity.
Day 21 50 minutes · build it once, use it for years

Your own theme, and colour that survives print

Every figure you publish should look like it came from the same lab. Write the theme once, save it in a file you source, and stop making typographic decisions per figure. Two hard constraints: eight-point minimum type at final printed size, and palettes that work for colourblind readers and in greyscale.

# ---- R/theme_paper.R --------------------------------------------------- theme_paper <- function(base_size = 9, base_family = "Helvetica") { theme_minimal(base_size = base_size, base_family = base_family) + theme( panel.grid.minor = element_blank(), panel.grid.major = element_line(linewidth = 0.25, colour = "grey88"), axis.title = element_text(size = base_size), axis.text = element_text(size = base_size - 1, colour = "grey25"), strip.text = element_text(size = base_size, face = "bold", hjust = 0), strip.background = element_blank(), legend.position = "top", legend.key.size = unit(0.8, "lines"), legend.title = element_blank(), plot.title = element_text(size = base_size + 2, face = "bold"), plot.caption = element_text(size = base_size - 1.5, colour = "grey40", hjust = 0), plot.title.position = "plot", plot.caption.position = "plot" ) } # Apply to every plot in the session — one line at the top of your script theme_set(theme_paper()) # Colourblind-safe, works in greyscale, ships with ggplot2 scale_colour_viridis_d(option = "cividis", end = 0.85) scale_fill_viridis_c() # continuous # Okabe-Ito: the standard accessible categorical palette okabe <- c("#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7", "#000000") scale_colour_manual(values = okabe) # Never rely on colour ALONE to carry the distinction ggplot(d, aes(agree, neuro, colour = gender, shape = gender, linetype = gender)) + geom_point(alpha = 0.2) + geom_smooth(method = "lm", se = FALSE) # Check your figure the way a reviewer might print it colorBlindness::cvdPlot(p) # simulates deuteranopia, protanopia, greyscale
Do this now · 20 minutes
Write R/theme_paper.R with your own defaults, source() it at the top of your analysis script, and re-render yesterday's figures with theme_set(). Then run cvdPlot() on your main results figure and fix anything that collapses in greyscale.
Day 22 45 minutes · Figure 1, panels A–D

patchwork: several plots, one figure

Journals count figures, so the skill is composing four related plots into one labelled figure with a shared legend. patchwork turns that into arithmetic: + puts plots side by side, / stacks them, | groups columns.

library(patchwork) pA <- ggplot(d, aes(neuro)) + geom_histogram(binwidth = 0.25) + labs(x = "Neuroticism") pB <- ggplot(d, aes(gender, neuro, colour = gender)) + geom_jitter(width = 0.1, alpha = 0.2) + stat_summary(fun.data = mean_cl_normal, colour = "black", size = 0.4) pC <- ggplot(d, aes(agree, neuro, colour = gender)) + geom_point(alpha = 0.15) + geom_smooth(method = "lm") pD <- ggplot(d, aes(factor(education), neuro, colour = gender)) + stat_summary(fun.data = mean_cl_normal, position = position_dodge(0.4)) fig1 <- (pA + pB) / (pC + pD) + plot_layout(guides = "collect") + # one legend for all four plot_annotation(tag_levels = "A", # panel tags A, B, C, D caption = "N = 2,577. Error bars are 95% CIs.") & theme_paper() # & applies the theme to EVERY panel fig1#> `geom_smooth()` using formula = 'y ~ x'

The & operator is the detail worth remembering: + modifies the last plot, & modifies all of them. Using + where you meant & produces a figure where one panel is styled differently, which is the visual equivalent of a typo in your title.

Do this now · 20 minutes
Compose a four-panel Figure 1 for your own data — distribution, group comparison, association, and one moderator or subgroup — with collected legend, A–D tags and a caption. Portfolio figure two.
Day 23 40 minutes · a short day with an opinion in it

Significance brackets: how, and when not to

ggpubr can stack asterisks over your groups in one line. Learn it, because supervisors and some journals ask for it — and understand its two problems before you use it.

library(ggpubr) my_comparisons <- list(c("control", "CBT"), c("control", "Waitlist")) ggplot(d3, aes(condition, bdi_post)) + geom_jitter(width = 0.12, alpha = 0.25) + geom_boxplot(width = 0.3, alpha = 0.5, outlier.shape = NA) + stat_compare_means(comparisons = my_comparisons, method = "t.test", p.adjust.method = "holm", # correct for multiplicity label = "p.format") + # the number, not asterisks labs(x = NULL, y = "BDI-II at post-treatment")#> Warning: Removed 4 rows containing non-finite values (stat_signif)
The case against asterisks
First, stat_compare_means() runs its own tests, which are usually not the model you report — so the figure can disagree with your results section. Second, asterisks encode only whether p crossed a threshold, which is the least informative thing about your effect. The stronger figure shows the estimate and its interval: emmeans(m, ~ condition) |> plot() puts your actual model on the page, differences and uncertainty included. Use brackets when they are required of you; prefer intervals when the choice is yours.
library(emmeans) m <- lm(bdi_post ~ condition + bdi_pre, data = d3) em <- emmeans(m, ~ condition) plot(em, comparisons = TRUE) + # model-based means with CIs labs(x = "Adjusted BDI-II", y = NULL) + theme_paper() # The differences themselves, which is what the hypothesis was about pairs(em, adjust = "holm") |> as.data.frame() |> round(3)#> contrast estimate SE df t.ratio p.value #> 1 control - CBT 3.918 0.988 244 3.965 0.001 #> 2 control - Waitlist 0.402 1.002 244 0.401 0.689 #> 3 CBT - Waitlist -3.516 0.995 244 -3.534 0.002
Do this now · 15 minutes
Make both versions of your main comparison — one with brackets, one as model-based marginal means with CIs. Put them side by side and decide which you would defend in a viva. Keep both files; you will want the comparison when a co-author asks for asterisks.
Day 24 45 minutes · the volume's deliverable

Export at exactly the size the journal asks for

Journals specify figure widths in millimetres — typically 85 mm for a single column and 170–180 mm for a double column — and resolution at 300 dpi or higher. Design at final size from the start: a figure drawn at screen size and shrunk to 85 mm has six-point axis labels nobody can read.

# Single column, 85 mm wide. Set the size, THEN adjust base_size until it reads. ggsave("output/figures/fig1.png", fig1, width = 85, height = 70, units = "mm", dpi = 600, bg = "white") # Double column ggsave("output/figures/fig2.png", fig2, width = 170, height = 120, units = "mm", dpi = 600, bg = "white") # Vector formats: infinite resolution, smaller files, preferred where allowed ggsave("output/figures/fig1.pdf", fig1, width = 85, height = 70, units = "mm", device = cairo_pdf) # cairo_pdf embeds fonts properly # TIFF, still requested by many APA journals ggsave("output/figures/fig1.tiff", fig1, width = 85, height = 70, units = "mm", dpi = 600, compression = "lzw")#> Saving 85 x 70 mm image
RequirementTypical specHow you satisfy it
Single-column width85 mmwidth = 85, units = "mm"
Double-column width170–180 mmwidth = 170, units = "mm"
Resolution300 dpi line art, 600 preferreddpi = 600
Minimum type size7–8 pt at final sizetheme_paper(base_size = 9), then check by printing
Fonts embeddedrequired for PDFdevice = cairo_pdf
No transparent backgroundrequired by mostbg = "white"
Greyscale legibleoften requiredVary shape and linetype, not only colour
Portfolio artefact five: the figure set
Six figures, each at journal specification, each with a one-sentence caption stating the claim it supports: a distribution, a group comparison with raw data, a within-person change plot, an association with a model-matched fit, a multi-panel Figure 1, and a model-based marginal-means figure. Put them in a folder with the script that generates them. It takes an afternoon and it is the most immediately legible evidence of competence in an application.
Do this now · 25 minutes
Export your figures at 85 mm and 600 dpi, open the PNG at 100 per cent, and read the axis labels. If you cannot, raise base_size and re-export. Then write one caption per figure naming the claim it supports.
Reference

Which figure for which claim

Start from the sentence you want to write, not from the chart type.

{{ c.claim }}
{{ c.geom }}
{{ c.why }}
Reference

The figure checklist before submission

Raw data visible wherever n allows it, or the reason it does not stated in the caption.
Error bars defined in the caption — SE, SD or 95% CI are three different claims.
Axis starts where the scale starts, or the truncation is stated explicitly.
Units and scale range on every axis label, not just variable names.
n reported in the figure or its caption, after exclusions.
The fitted line is the model you report, not a smoother you did not test.
Legible in greyscale and under deuteranopia simulation.
Generated by a script that reruns from raw data — no manual edits in Illustrator.
Checkpoint

Six questions before volume 4

{{ quizCounter }}
{{ quizScore }}
{{ quizQ }}
{{ quizFb }}

Next: volume 4

Inference is the longest volume — sixteen days from what a p-value actually means, through the ANOVA family and planned contrasts, to regression, moderation, mediation and models for binary and ordinal outcomes. Every test arrives with its effect size and interval attached.

Start day 25 →