Paired Before-After Plot

Authors

[Editor] Shixiang Wang;

[Contributors] ZCode (GLM) — AI-generated, independently AI-reviewed.

Modified

2026-10-31

🤖 AI Skill — Download the Bizard unified skill for your AI assistant ⬇️ Download Skill ZIP
🔌 MCP Server — Let AI agents (Claude Code, Cursor, …) query Bizard live: claude mcp add bizard -- npx @openbiox/bizard-mcp — or the free hosted instance (no install, faster from mainland China): claude mcp add --transport http bizard http://biotree.top:38123/bizard-mcp/mcp 📖 MCP Docs
📖 Cite Bizard — if Bizard helps your research, please cite: Li, K., Zheng, H., Huang, K., et al. (2026). Bizard: A Community-Driven Platform for Accelerating and Enhancing Biomedical Data Visualization. iMetaMed, e70038. 🔗 DOI / BibTeX

Paired data — the same subject measured twice (before/after treatment, left/right eye, two conditions) — must be visualized so that each subject’s change is visible. Grouped bar charts or side-by-side boxplots destroy the pairing: they show the two marginal distributions, not whether individuals improved [1]. The before-after plot connects each subject’s two measurements with a line (or arrow), instantly exposing individual trajectories, their consistency, and the outliers who move against the trend.

This tutorial shows the four most useful variants in ggplot2: connected before-after arrows, a slope chart, a paired dumbbell chart, and an individual-change panel with the paired mean difference and its confidence interval. The analysis of the paired design itself uses the paired t-test (or Wilcoxon signed-rank) — always paired, never the two-sample version.

Example

PairedPlot DEMO

Setup

  • System Requirements: Cross-platform (Linux/MacOS/Windows)

  • Programming language: R

  • Dependent packages: ggplot2; dplyr; tidyr; ggalt; patchwork

# Installing necessary packages
if (!requireNamespace("ggalt", quietly = TRUE)) {
  install.packages("ggalt")
}
if (!requireNamespace("patchwork", quietly = TRUE)) {
  install.packages("patchwork")
}

# Load packages
library(ggplot2)
library(dplyr)
library(tidyr)
library(ggalt)
library(patchwork)
sessioninfo::session_info("attached")
─ Session info ───────────────────────────────────────────────────────────────
 setting  value
 version  R version 4.6.2 (2026-10-29)
 os       Ubuntu 24.04.5 LTS
 system   x86_64, linux-gnu
 ui       X11
 language (EN)
 collate  C.UTF-8
 ctype    C.UTF-8
 tz       UTC
 date     2026-10-31
 pandoc   3.1.3 @ /usr/bin/ (via rmarkdown)
 quarto   1.10.20 @ /usr/local/bin/quarto

─ Packages ───────────────────────────────────────────────────────────────────
 ! package   * version    date (UTC) lib source
 P dplyr     * 1.2.1      2026-04-03 [?] RSPM
 P ggalt     * 0.8.0      2026-10-30 [?] Github (hrbrmstr/ggalt@7b5e9f9)
 P ggplot2   * 4.0.3.9000 2026-10-30 [?] Github (tidyverse/ggplot2@b638d65)
 P patchwork * 1.3.2      2025-08-25 [?] RSPM
 P tidyr     * 1.3.2      2025-12-19 [?] RSPM

 [1] /home/runner/.cache/R/renv/library/Bizard-5140bdc6/linux-ubuntu-noble/R-4.6/x86_64-pc-linux-gnu
 [2] /home/runner/work/_temp/Library
 [3] /opt/R/4.6.2/lib/R/library

 * ── Packages attached to the search path.
 P ── Loaded and on-disk path mismatch.

──────────────────────────────────────────────────────────────────────────────

Data Preparation

We simulate a single-arm analgesia trial: 24 patients’ pain scores (numeric rating scale 0–10) before and 4 weeks after treatment, with one non-responder included on purpose — the plot must show them, not hide them.

set.seed(2026)

n <- 24
pain <- data.frame(
  patient = paste0("P", sprintf("%02d", 1:n)),
  before  = round(rgamma(n, shape = 6, scale = 1.1), 1)
) |>
  mutate(after    = pmin(10, pmax(0, round(before - 2.2 + rnorm(n, 0, 1.6), 1))),
         change   = after - before,
         direction = ifelse(change <= 0, "Improved", "Worsened"))

pal <- c(Improved = "#1B7837", Worsened = "#B2182B")
head(pain)
  patient before after change direction
1     P01    7.5   4.5   -3.0  Improved
2     P02    3.6   1.7   -1.9  Improved
3     P03    2.1   0.0   -2.1  Improved
4     P04    4.5   2.8   -1.7  Improved
5     P05    7.4   5.5   -1.9  Improved
6     P06    9.0   8.7   -0.3  Improved

Visualization

1. Before-After Arrows

ggplot(pain, aes(x = before, xend = after, y = reorder(patient, -change),
                 color = direction)) +
  geom_segment(arrow = arrow(length = unit(0.1, "inches"), type = "closed"),
               linewidth = 0.9, alpha = 0.9) +
  geom_point(aes(x = before, y = reorder(patient, -change)),
             size = 2.4, color = "grey35") +
  geom_point(aes(x = after, y = reorder(patient, -change)),
             size = 2.4, color = "grey35") +
  scale_color_manual(values = pal, name = NULL) +
  labs(x = "Pain score (NRS 0-10)", y = "Patient", title = "Before-after plot") +
  theme_bw(base_size = 11) +
  theme(legend.position = "top")
Figure 1: Each patient’s two measurements connected by an arrow (grey dots: baseline and follow-up); the single non-responder is unmissable.

Sorting patients by their change (reorder by change) turns the plot into a response ranking; the arrow color splits responders from non-responders without any extra table.

2. Slope Chart

The slope chart is the two-timepoint special case of a parallel-coordinates plot: one column per time point, one line per subject. It is the most compact paired display for a publication figure.

long <- pain |>
  select(patient, direction, before, after) |>
  pivot_longer(-c(patient, direction), names_to = "Time", values_to = "score") |>
  mutate(Time = recode(Time, before = "Before", after = "After"))

ggplot(long, aes(Time, score, group = patient)) +
  geom_line(aes(color = direction), linewidth = 0.8, alpha = 0.85) +
  geom_point(size = 2.2, color = "grey30") +
  scale_color_manual(values = pal, guide = "none") +
  labs(x = NULL, y = "Pain score (NRS 0-10)", title = "Slope chart") +
  theme_bw(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        panel.grid.major.x = element_blank())
Figure 2: Slope chart of the same data: line direction encodes response, slope encodes magnitude.

3. Paired Dumbbell Chart

The dumbbell variant lays one subject per row — best when patient labels carry meaning (IDs, doses, sites) and readers need to look up individuals.

ggplot(pain, aes(y = reorder(patient, change))) +
  geom_dumbbell(aes(x = after, xend = before),
                colour_x = "#1B7837", colour_xend = "grey45",
                size = 1.3, size_x = 2.4, size_xend = 2.4) +
  labs(x = "Pain score (green = after treatment, grey = baseline)", y = NULL,
       title = "Paired dumbbell chart") +
  theme_bw(base_size = 11) +
  theme(panel.grid.major.y = element_blank())
Figure 3: Paired dumbbell chart: grey dot = baseline, green dot = after treatment.

4. Individual Changes with the Paired Mean Difference

The statistically honest summary of a paired design is the distribution of differences: show every subject’s change plus the mean difference with its 95% confidence interval and the paired test.

tt <- t.test(pain$after, pain$before, paired = TRUE)
md <- mean(pain$change)
se <- sd(pain$change) / sqrt(nrow(pain))
lo <- md - qt(0.975, nrow(pain) - 1) * se
hi <- md + qt(0.975, nrow(pain) - 1) * se
xlim <- range(c(pain$change, lo, hi))

p4a <- ggplot(pain, aes(x = change, y = reorder(patient, change), fill = direction)) +
  geom_col(width = 0.65, alpha = 0.85) +
  geom_vline(xintercept = 0, color = "grey25") +
  scale_fill_manual(values = pal, guide = "none") +
  scale_x_continuous(limits = xlim) +
  labs(x = NULL, y = NULL, title = "Individual changes with mean 95% CI") +
  theme_bw(base_size = 11) +
  theme(axis.text.x = element_blank(), axis.ticks.x = element_blank())

p4b <- ggplot(data.frame(x = "Mean"), aes(x = md, y = x)) +
  geom_linerange(aes(xmin = lo, xmax = hi), orientation = "y", linewidth = 0.9) +
  geom_point(size = 2.6, color = "#B2182B") +
  geom_vline(xintercept = 0, color = "grey25", linetype = "dashed") +
  scale_x_continuous(limits = xlim) +
  annotate("text", x = md, y = 1.42, vjust = 1.1, size = 3.2,
           label = sprintf("Mean %.2f (95%% CI %.2f to %.2f), paired t p = %.2g",
                           md, lo, hi, tt$p.value)) +
  labs(x = "Change in pain score (after - before)", y = NULL) +
  theme_bw(base_size = 11)

p4a / p4b + plot_layout(heights = c(7, 1.3))
Figure 4: Individual changes (top) and the mean change with its 95% CI (bottom, forest-style summary row); the paired t-test evaluates the mean against zero.
TipPractice tips
  • Test paired, always. t.test(after, before, paired = TRUE) is not the same test as the unpaired one; quoting an unpaired test on paired data throws away power and can flip conclusions.
  • Keep the x/y range honest: pain scores live on 0–10, so scale_x_continuous(limits = c(0, 10)) (or an explicit break set) beats free scaling that exaggerates the change.
  • Non-responders belong in the plot. Color-coding by direction (or annotating them) is analysis, not decoration.
  • For skewed outcomes report the median change with a Wilcoxon signed-rank test and show the same layouts.

Application

Before-after displays are the standard figure for single-arm intervention studies (rehabilitation, device training, psychological outcomes), lab-pair measurements (pre/post surgery biomarkers), and any within-subject experiment. The panel below mirrors a typical publication layout: (A) the before-after arrow plot and (B) the individual-change panel with paired mean CI.

PairedPlot Application

Reference

[1] Krzywinski M, Altman N. Points of significance: visualizing samples with bar graphs. Nat Methods. 2014;11(9):878-879. doi:10.1038/nmeth.3075

[2] Attali D. ggalt: supplemental geoms for ggplot2. https://github.com/hrbrmstr/ggalt

[3] Pedersen TL. patchwork: the composer of plots. R package version 1.3.2. 2025. https://CRAN.R-project.org/package=patchwork