Course contents

Part IChapter 7R foundations and data

Visualize and Explore Data

On this page
  1. 1. Explore before predicting
  2. 2. The anatomy of a ggplot
  3. 3. Histogram: a numeric distribution
  4. 4. Bar chart: a category count
  5. 5. Scatter plot: two numeric variables
  6. 6. Compare categories carefully
  7. 7. Map columns to color, shape and size
  8. 8. Facets: small multiples
  9. 9. Bars: counted heights and given heights
  10. 10. Lines, reference lines and crowded points
  11. 11. Look and scales
  12. 12. A correlation heatmap
  13. 13. Missingness is part of the story
  14. 14. Save an image
  15. Try it
  16. Checkpoint

1. Explore before predicting

Exploratory data analysis (EDA) means looking for patterns, anomalies, missing values, and useful questions before fitting a model. Plotting does not prove cause and effect. In particular, the fictional simulator deliberately relates age and BMI to its invented label; that relationship does not estimate a real diabetes effect.

library(ggplot2)
library(readr)
library(dplyr)
ehr <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
glimpse(ehr)

Output

Rows: 600
Columns: 10
$ patient_id     <chr> "S0001", "S0002", "S0003", "S0004", "S0005", "S0006", "~
$ visit_date     <date> 2025-01-10, 2025-01-19, 2025-01-28, 2025-02-06, 2025-0~
$ clinic_site    <chr> "South", "Mobile", "North", "South", "Mobile", "North",~
$ age            <dbl> 55, 26, 63, 34, 71, 42, 79, 50, 21, 58, 29, 66, 37, 74,~
$ sex            <chr> "F", "M", "F", "F", "M", "F", "M", "F", "F", "M", "F", ~
$ bmi            <dbl> 20.4, 22.7, 24.7, 26.3, 27.6, 28.7, 29.7, 30.7, 31.9, 3~
$ sbp            <dbl> 120, 110, 133, 120, 141, 126, 145, 128, 111, 128, 112, ~
$ pulse          <dbl> 79, 92, 65, 77, 89, 63, 75, 87, 60, 72, 84, 58, 71, 84,~
$ family_history <dbl> 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0~
$ diabetes       <chr> "No", "No", "No", "No", "No", "No", "No", "No", "No", "~

Example: glimpse() shows 600 rows and 10 columns, one line per column with its type. Numeric columns (age, bmi, sbp) can go in a histogram or on a scatter plot axis; text columns (clinic_site, sex) are categories for bars and boxplot groups. Choosing a plot starts with reading these types.

2. The anatomy of a ggplot

In ggplot(), data says which table to use. aes() maps a variable to an axis or color. A geom_...() layer says how to draw the result.

ggplot(ehr, aes(x = age)) +
  geom_histogram(binwidth = 10, boundary = 0, color = "white") +
  labs(title = "Ages in our synthetic dataset",
       x = "Age in years", y = "Number of records")
Histogram of age in years for the synthetic records, in ten-year bins. The bars are about equal in height from the 20s through the 70s and shorter at the youngest and oldest ends.
Histogram of age in years for the synthetic records, in ten-year bins. The bars are about equal in height from the 20s through the 70s and shorter at the youngest and oldest ends.
Two histograms of the same sixty made-up ages: bins ten years wide give a few tall bars, and bins three years wide give many short, jagged bars.
Two histograms of the same sixty made-up ages: bins ten years wide give a few tall bars, and bins three years wide give many short, jagged bars.

With the same ages, narrower bins give more bars and more detail; wider bins give fewer, smoother bars.

Read the code aloud: “Use table ehr, map age to the horizontal axis, draw bars for 10-year bins, and give the chart clear labels.”

Four stacked layers, data, aesthetics, geom and labels, joined by plus signs, build one scatter plot of made-up points.
Four stacked layers, data, aesthetics, geom and labels, joined by plus signs, build one scatter plot of made-up points.

A ggplot is built by adding layers: the data, the aesthetic mappings, a geom, and then labels.

3. Histogram: a numeric distribution

ggplot(ehr, aes(x = bmi)) +
  geom_histogram(binwidth = 2, na.rm = TRUE) +
  labs(x = expression(BMI~(kg/m^2)), y = "Nonmissing records")
Histogram of BMI for the synthetic records, in bins two units wide. Counts are fairly flat across the middle of the range and fall away at both extremes, showing how the BMI values are spread.
Histogram of BMI for the synthetic records, in bins two units wide. Counts are fairly flat across the middle of the range and fall away at both extremes, showing how the BMI values are spread.

Reading the plot: BMI values run from about 17 to 44. Most 2-unit bins hold roughly 40 to 50 rows between BMI 22 and 40, and the counts fall away to the edges. A histogram’s bars represent ranges of a numeric value. The na.rm option removes missing BMI rows from the graph only. Tell readers how many were missing; otherwise the denominator is unclear.

4. Bar chart: a category count

ggplot(ehr, aes(x = clinic_site)) +
  geom_bar() +
  labs(x = "Fictional clinic site", y = "Rows")
Bar chart counting records at three fictional clinic sites: Mobile, North and South. The three bars are the same height, so each site has the same number of rows.
Bar chart counting records at three fictional clinic sites: Mobile, North and South. The three bars are the same height, so each site has the same number of rows.

The three bars are equal because each site has 200 of the 600 rows. A bar graph groups discrete categories, unlike a histogram that groups numeric values into ranges.

5. Scatter plot: two numeric variables

ggplot(ehr, aes(x = bmi, y = sbp)) +
  geom_point(alpha = 0.35, na.rm = TRUE) +
  labs(x = "BMI", y = "Systolic BP (mmHg)",
       title = "Two measurements, not a causal claim")
Scatter plot of BMI on the horizontal axis against systolic blood pressure on the vertical axis, one faint dot per synthetic record. The cloud drifts upward as BMI rises but is wide, so it shows an association in made-up data and not a cause.
Scatter plot of BMI on the horizontal axis against systolic blood pressure on the vertical axis, one faint dot per synthetic record. The cloud drifts upward as BMI rises but is wide, so it shows an association in made-up data and not a cause.

One dot is one row with both values present. An upward trend could be due to many factors; do not claim that BMI causes higher blood pressure just from a scatter plot.

6. Compare categories carefully

ggplot(ehr, aes(x = diabetes, y = age)) +
  geom_boxplot() +
  labs(x = "Fabricated diabetes label", y = "Age")
Side-by-side boxplots of age for the fabricated diabetes label, No and Yes. The Yes box and its median sit higher than the No box, a difference that was partly designed into the simulation and not a real finding.
Side-by-side boxplots of age for the fabricated diabetes label, No and Yes. The Yes box and its median sit higher than the No box, a difference that was partly designed into the simulation and not a real finding.

A boxplot shows medians and spread. The label is invented; any apparent difference was partly designed into the simulation.

7. Map columns to color, shape and size

An aesthetic is anything you can see that a column can control: x, y, color, shape, size. Writing color = sex inside aes() tells ggplot to pick a color for each value of sex and add a legend. You need this whenever you want one plot to show a third or fourth column.

ggplot(ehr, aes(x = bmi, y = sbp)) +
  geom_point(aes(color = sex, shape = diabetes, size = pulse),
             alpha = 0.5, na.rm = TRUE) +
  geom_smooth(method = "lm", se = FALSE, color = "black",
              linewidth = 0.6, na.rm = TRUE, formula = y ~ x) +
  scale_size(range = c(0.5, 2.5))
Scatter plot of BMI against systolic blood pressure. Points are colored by sex, drawn as circles or triangles by the fabricated diabetes label, and sized by pulse. One black straight line rises across all points together.
Scatter plot of BMI against systolic blood pressure. Points are colored by sex, drawn as circles or triangles by the fabricated diabetes label, and sized by pulse. One black straight line rises across all points together.

aes() can sit in two places. A mapping inside ggplot() is global: every layer inherits it. A mapping inside one geom is local: only that layer uses it. Here x and y are global, so the points and the line both use them. color, shape and size are local to geom_point(), so the line is one line for everyone. Had color = sex been global, geom_smooth() would have drawn one line per sex.

Now the classic mistake. Inside aes(), a word such as "blue" is treated as data, not as a color. Outside aes(), it is a fixed setting.

library(patchwork)
wrong <- ggplot(ehr, aes(x = bmi, y = sbp, color = "blue")) +
  geom_point(alpha = 0.4, na.rm = TRUE) + labs(title = "Inside aes()")
right <- ggplot(ehr, aes(x = bmi, y = sbp)) +
  geom_point(color = "blue", alpha = 0.4, na.rm = TRUE) +
  labs(title = "Outside aes()")
wrong + right
Two scatter plots of BMI against systolic blood pressure side by side. The left plot colors every point salmon red and shows a legend entry named blue. The right plot colors every point blue and has no legend.
Two scatter plots of BMI against systolic blood pressure side by side. The left plot colors every point salmon red and shows a legend entry named blue. The right plot colors every point blue and has no legend.

On the left, ggplot made a one-value column called "blue" and gave it its own default color (not blue) plus a legend. On the right, the color is a fixed setting for the whole layer. The rule: if the look depends on a column, put it in aes(); if you just want it that way, put it outside. This is also why alpha = 0.35 and color = "white" earlier in the chapter sit outside aes().

8. Facets: small multiples

A facet splits one plot into a panel per group, all drawn on the same scales. It is often clearer than packing many colors into one panel. facet_wrap(~ column) lays the panels out in a row that wraps. facet_grid(rows ~ columns) makes a grid from two columns.

ggplot(ehr, aes(x = sbp)) +
  geom_histogram(binwidth = 10, na.rm = TRUE) +
  facet_wrap(~ clinic_site)

This code makes three small histograms, one per site; run it to see them. The panels share the same axes, so heights compare directly.

ggplot(ehr, aes(x = bmi, y = sbp)) +
  geom_point(alpha = 0.4, size = 0.8, na.rm = TRUE) +
  facet_grid(sex ~ clinic_site)
A grid of six scatter plots of BMI against systolic blood pressure, with rows for sex F and M and columns for the Mobile, North and South sites. Each panel shows a loose upward drift.
A grid of six scatter plots of BMI against systolic blood pressure, with rows for sex F and M and columns for the Mobile, North and South sites. Each panel shows a loose upward drift.

In the grid, the gray strip labels name the group: sex runs down the side and site runs across the top. A loose upward drift appears in every panel, so site and sex do not change the picture much here.

9. Bars: counted heights and given heights

geom_bar() counts rows for you, as in section 4. geom_col() draws bars whose heights you already have in a column, for example a summary table. Use geom_col() when you computed the numbers yourself.

To compare groups of different sizes, after_stat(prop) asks ggplot to plot a statistic it computes on the way, here the proportion of rows in each bar. group = 1 tells it that all bars are parts of one whole.

The example first cuts BMI into groups with cut().

ehr <- ehr |>
  mutate(bmi_group = cut(bmi, breaks = c(0, 18.5, 25, 30, Inf), right = FALSE,
                         labels = c("Under", "Normal", "Over", "Obese")))

p_prop <- ggplot(filter(ehr, !is.na(bmi_group)), aes(x = bmi_group)) +
  geom_bar(aes(y = after_stat(prop), group = 1)) +
  labs(x = "BMI group", y = "Proportion", title = "geom_bar()")

counts <- count(filter(ehr, !is.na(bmi_group)), bmi_group)
p_col <- ggplot(counts, aes(x = reorder(bmi_group, -n), y = n)) +
  geom_col() +
  geom_text(aes(label = n), vjust = -0.3) +
  scale_y_continuous(expand = expansion(mult = c(0, 0.1))) +
  labs(x = "BMI group, most common first", y = "Rows", title = "geom_col()")

p_prop + p_col
Two bar charts of fictional BMI groups. Left: proportion of rows for the BMI groups Under, Normal, Over and Obese, in that order, with Obese the tallest bar at about 0.5. Right: counts printed above each bar, with the axis labeled BMI group, most common first: Obese 285, Normal 144, Over 121, Under 15.
Two bar charts of fictional BMI groups. Left: proportion of rows for the BMI groups Under, Normal, Over and Obese, in that order, with Obese the tallest bar at about 0.5. Right: counts printed above each bar, with the axis labeled BMI group, most common first: Obese 285, Normal 144, Over 121, Under 15.

Reading it: the left panel keeps the order of the factor levels (Under, Normal, Over, Obese) and shows shares that add to 1. The right panel uses the counts from a table, put in order with reorder(), and geom_text() prints each count (add check_overlap = TRUE to skip labels that would collide). reorder(x, -n) sorts the categories from the largest n to the smallest. reorder(x, n) would sort in the opposite direction. (Rows with missing BMI are left out of both plots.)

To set the order by hand, use factor(x, levels = c(...)). Check it by printing the levels:

levels(factor(counts$bmi_group, levels = c("Obese", "Over", "Normal", "Under")))

Output

[1] "Obese"  "Over"   "Normal" "Under" 

The forcats package has friendlier helpers for the same jobs: fct_reorder() sorts by another column, fct_infreq() sorts by how often each level occurs, and fct_rev() reverses the order.

library(forcats)
levels(fct_infreq(ehr$bmi_group))

Output

[1] "Obese"  "Normal" "Over"   "Under" 
levels(fct_rev(fct_infreq(ehr$bmi_group)))

Output

[1] "Under"  "Over"   "Normal" "Obese" 

fct_infreq() lists the most common group first (Obese, then Normal, Over, Under), and fct_rev() flips that list.

10. Lines, reference lines and crowded points

Use geom_line() to join points in order, usually over time. The next plot gives mean systolic BP by visit month. The group aesthetic says which points belong to one line; without it, ggplot would try to join every point into one zig-zag. geom_hline() draws a horizontal reference line; geom_vline() draws a vertical one.

by_month <- ehr |>
  mutate(month = as.integer(format(as.Date(visit_date), "%m"))) |>
  group_by(month, sex) |>
  summarise(mean_sbp = mean(sbp, na.rm = TRUE), .groups = "drop")

ggplot(by_month, aes(x = month, y = mean_sbp, group = sex)) +
  geom_line(color = "grey50") +
  geom_point(aes(color = sex)) +
  geom_hline(yintercept = mean(ehr$sbp, na.rm = TRUE), linetype = "dashed") +
  scale_x_continuous(breaks = 1:12) +
  labs(x = "Visit month (2025)", y = "Mean systolic BP (mmHg)")
Line chart of mean systolic blood pressure by visit month, January to December, with one gray line per sex and colored points. Both lines peak in March and again in September, and dip lowest around June and July. A dashed horizontal line marks the overall mean.
Line chart of mean systolic blood pressure by visit month, January to December, with one gray line per sex and colored points. Both lines peak in March and again in September, and dip lowest around June and July. A dashed horizontal line marks the overall mean.

The two gray lines are separate because of group = sex; the colored points tell you which is which. The male line rests on fewer visits (240 rows against 360 for females, so about 20 against 30 a month), which is why it bounces more. Both lines peak in September, and the male line stops at November because that group has no December visits in this table.

To see a distribution, geom_freqpoly() draws the heights of a histogram as a line, and geom_density() draws a smoothed curve. A density curve has total area 1, so after_stat(density) puts the freqpoly on the same scale and the two can be overlaid. geom_vline() marks the mean.

ggplot(ehr, aes(x = sbp)) +
  geom_freqpoly(aes(y = after_stat(density)), binwidth = 5, na.rm = TRUE) +
  geom_density(color = "grey40", na.rm = TRUE) +
  geom_vline(xintercept = mean(ehr$sbp, na.rm = TRUE), linetype = "dashed") +
  scale_x_continuous(breaks = seq(80, 200, by = 20))

Run it to see a jagged line and a smooth curve with the same single hump, and a dashed line at the mean (about 122).

Overplotting means many points land on the same spot, so you cannot tell how many there are. geom_jitter() nudges each point by a small random amount. geom_count() instead makes each point bigger the more rows share it. Left: ages and blood pressures are whole numbers, so jitter spreads the stacked dots; geom_abline() draws a line from an intercept and a slope, here a straight-line fit of sbp on age. Right: only four combinations exist, so a count is more useful.

fit <- coef(lm(sbp ~ age, data = ehr))
p_jit <- ggplot(ehr, aes(x = age, y = sbp)) +
  geom_jitter(width = 0.4, height = 0.4, alpha = 0.4, size = 0.8, na.rm = TRUE) +
  geom_abline(intercept = fit[1], slope = fit[2], color = "red")
p_cnt <- ggplot(ehr, aes(x = factor(family_history), y = diabetes)) +
  geom_count() +
  labs(x = "Family history (0/1)")
p_jit + p_cnt
Left: scatter of age against systolic blood pressure with jittered dots and a red straight line rising from lower left to upper right. Right: four circles at the combinations of family history 0 or 1 and diabetes No or Yes; the circles for No are much larger than those for Yes.
Left: scatter of age against systolic blood pressure with jittered dots and a red straight line rising from lower left to upper right. Right: four circles at the combinations of family history 0 or 1 and diabetes No or Yes; the circles for No are much larger than those for Yes.

11. Look and scales

Everything on the page can be adjusted. labs() sets titles and axis names (you have used it). A theme controls the non-data look: theme_minimal() and theme_bw() are two ready-made ones, and theme() changes single items such as text size and legend position. A scale controls how a value is turned into a position or color: scale_y_continuous(limits = , breaks = ) sets the range and tick marks of an axis, and scale_x_continuous() does the same for x.

For color, RColorBrewer palettes are chosen with care. scale_color_brewer() and scale_fill_brewer() take categories; scale_fill_distiller() and scale_color_distiller() take numbers. "Dark2" is safe for most color-blind readers (and so is "Set2"), and the diverging "RdBu" is a good choice for numbers that run both ways. RColorBrewer::display.brewer.all(colorblindFriendly = TRUE) shows all the safe ones.

coord_flip() swaps the axes, which helps when category names are long. coord_polar() bends the plot into a circle; a pie chart is a stacked bar bent this way. Treat pie-style charts with caution, because people judge angles and areas poorly, and a bar chart almost always reads better.

p_flip <- ggplot(ehr, aes(x = clinic_site, y = sbp, fill = clinic_site)) +
  geom_boxplot(na.rm = TRUE) +
  scale_fill_brewer(palette = "Dark2") +
  scale_y_continuous(limits = c(80, 180), breaks = seq(80, 180, by = 20)) +
  coord_flip() +
  labs(x = NULL, y = "Systolic BP (mmHg)", fill = "Site") +
  theme_minimal(base_size = 10) +
  theme(legend.position = "bottom")

p_polar <- ggplot(counts, aes(x = bmi_group, y = n, fill = bmi_group)) +
  geom_col(show.legend = FALSE) +
  coord_polar() +
  theme_bw(base_size = 10) +
  labs(x = NULL, y = NULL)

p_flip + p_polar
Left: horizontal boxplots of systolic blood pressure for the Mobile, North and South sites, filled in three color-blind-safe colors, with a minimal theme and the legend at the bottom. The three boxes overlap closely. Right: the BMI group counts bent into a circular chart, where the Obese wedge is largest and the others are hard to compare.
Left: horizontal boxplots of systolic blood pressure for the Mobile, North and South sites, filled in three color-blind-safe colors, with a minimal theme and the legend at the bottom. The three boxes overlap closely. Right: the BMI group counts bent into a circular chart, where the Obese wedge is largest and the others are hard to compare.

Compare the two panels: the flipped boxplot makes site names easy to read and the medians easy to compare, while in the circular chart it is hard to say which group is largest. Note that scale_y_continuous(limits = ) drops any value outside the range, with a warning; coord_flip() does not remove data, so the scale is set on y even though it is drawn sideways.

12. A correlation heatmap

A correlation is a number between -1 and 1 that says how closely two numeric columns move together. cor() computes it for every pair of columns and returns a square table (a matrix). A heatmap colors each cell by its value, which lets you scan a whole table at once. Chapter 23 checks correlations like these with cor() before PCA.

num_cols <- ehr[, c("age", "bmi", "sbp", "pulse", "family_history")]
cm <- cor(num_cols, use = "pairwise.complete.obs")
round(cm, 2)

Output

                 age   bmi   sbp pulse family_history
age             1.00 -0.02  0.75  0.07           0.01
bmi            -0.02  1.00  0.36  0.02          -0.01
sbp             0.75  0.36  1.00  0.07          -0.02
pulse           0.07  0.02  0.07  1.00           0.00
family_history  0.01 -0.01 -0.02  0.00           1.00

The diagonal is 1 because each column matches itself perfectly. use = "pairwise.complete.obs" lets each pair use the rows where both values exist, so missing values do not turn everything into NA. To plot it, reshape the matrix into one row per pair. as.data.frame(as.table(cm)) does that in base R; tidyr::pivot_longer() is the tidyverse way.

cm_long <- as.data.frame(as.table(cm))
names(cm_long) <- c("var1", "var2", "r")

ggplot(cm_long, aes(x = var1, y = var2, fill = r)) +
  geom_tile() +
  geom_text(aes(label = sprintf("%.2f", r)), size = 3) +
  scale_fill_distiller(palette = "RdBu", limits = c(-1, 1), direction = -1) +
  labs(x = NULL, y = NULL, fill = "Correlation") +
  theme_minimal(base_size = 10)
Heatmap of correlations among age, BMI, systolic blood pressure, pulse and family history. The diagonal is dark red at 1. The age and systolic blood pressure cell is the strongest off-diagonal, 0.75, and BMI with systolic blood pressure is 0.36. All other cells are near white, close to 0.
Heatmap of correlations among age, BMI, systolic blood pressure, pulse and family history. The diagonal is dark red at 1. The age and systolic blood pressure cell is the strongest off-diagonal, 0.75, and BMI with systolic blood pressure is 0.36. All other cells are near white, close to 0.

Red means the columns rise together, blue means one rises as the other falls, and white means no straight-line link. Here age and sbp stand out at 0.75 and bmi and sbp at 0.36, which is how the simulator was built, not a clinical finding. Correlation only measures straight-line association among numbers and says nothing about cause. The corrplot package draws this kind of figure in a single call.

13. Missingness is part of the story

ehr |>
  summarise(
    n = n(),
    bmi_missing = sum(is.na(bmi)),
    sbp_missing = sum(is.na(sbp)),
    pulse_missing = sum(is.na(pulse))
  )

Output

# A tibble: 1 x 4
      n bmi_missing sbp_missing pulse_missing
  <int>       <int>       <int>         <int>
1   600          35          20            31

Reading the table: of 600 rows, 35 have no bmi, 20 have no sbp and 31 have no pulse. A BMI plot therefore shows 565 rows, and the caption should say so.

Always pair a figure with a sentence: who or what is shown, how many rows are included, what is missing, and what the image cannot establish.

14. Save an image

p <- ggplot(ehr, aes(x = age)) + geom_histogram(binwidth = 10)
dir.create("outputs", showWarnings = FALSE)
ggsave("outputs/age_histogram.png", plot = p, width = 7, height = 4)
file.exists("outputs/age_histogram.png")

Output

[1] TRUE

ggsave() writes the plot to a file; the file type comes from the extension (.png, .pdf, .svg), and width and height are in inches. file.exists() printing TRUE confirms the image was written to the outputs folder.

Try it

  1. Make a histogram of systolic BP with a bin width of 10.
Show solution to question 1

Answer: ggplot(ehr, aes(x = sbp)) + geom_histogram(binwidth = 10, na.rm = TRUE) draws one bar per 10 mmHg range, with a single hump in the middle.

Step by step

  1. Name the table and map sbp to the horizontal axis. A histogram needs only x, because the height is a count the geom computes.
  2. Add geom_histogram(binwidth = 10). na.rm = TRUE drops the rows with no sbp from the drawing without a warning.
ggplot(ehr, aes(x = sbp)) +
  geom_histogram(binwidth = 10, na.rm = TRUE)
Histogram of systolic blood pressure for the synthetic records in ten-unit bins. The bars rise to a peak in the middle of the range and taper at both ends.
Histogram of systolic blood pressure for the synthetic records in ten-unit bins. The bars rise to a peak in the middle of the range and taper at both ends.
  1. Read the plot: most readings sit in the middle bins and few are very low or very high. Each bar counts rows whose sbp falls in that 10 mmHg range.
  1. Draw a boxplot of BMI by fictional site.
Show solution to question 2

Answer: ggplot(ehr, aes(x = clinic_site, y = bmi)) + geom_boxplot() draws three boxes with close medians, so the sites look alike on BMI.

Step by step

  1. Put the category (clinic_site) on x and the number (bmi) on y.
  2. Add geom_boxplot().
ggplot(ehr, aes(x = clinic_site, y = bmi)) +
  geom_boxplot(na.rm = TRUE)
Side-by-side boxplots of BMI at the Mobile, North and South fictional clinic sites. The three boxes and medians are close together, so the sites look similar on BMI.
Side-by-side boxplots of BMI at the Mobile, North and South fictional clinic sites. The three boxes and medians are close together, so the sites look similar on BMI.
  1. Compare the thick middle lines (medians) and the box heights (the middle half of the rows). They are nearly level across the three sites.
  1. Make a scatter plot of age and pulse, mapping diabetes to color.
Show solution to question 3

Answer: ggplot(ehr, aes(x = age, y = pulse, color = diabetes)) + geom_point(). Because color = diabetes is inside aes(), ggplot picks one color per label and adds a legend.

Step by step

  1. Map age to x and pulse to y.
  2. Add color = diabetes inside aes(), because the color depends on a column.
  3. Draw the points with geom_point().
ggplot(ehr, aes(x = age, y = pulse, color = diabetes)) +
  geom_point(na.rm = TRUE)
Scatter plot of age against pulse, colored by the fabricated diabetes label. Pulse falls in three horizontal bands, and the Yes points are mostly at older ages.
Scatter plot of age against pulse, colored by the fabricated diabetes label. Pulse falls in three horizontal bands, and the Yes points are mostly at older ages.
  1. Read the plot: pulse falls in bands rather than a smooth cloud, and the “Yes” points sit mostly toward the older ages, which the simulator built in.
  1. Explain why you cannot conclude that age causes the simulated diagnosis from any of these graphs.
Show solution to question 4

Answer: A graph shows that two variables move together, not that one causes the other. The data are simulated, other factors are not controlled, and the graphs show no time order.

Step by step

  1. A plot shows association: older rows tend to carry the “Yes” label.
  2. Cause needs more: the cause must come before the effect, the design must rule out other explanations, and simulated data cannot show a real cause. None of that holds here.
  3. The simulator itself linked age to the label, so the pattern is there by design.
  4. A quick numeric check shows only the same association:
ehr |>
  group_by(diabetes) |>
  summarise(mean_age = mean(age), n = n())

Output

# A tibble: 2 x 3
  diabetes mean_age     n
  <chr>       <dbl> <int>
1 No           48.8   546
2 Yes          67.0    54

5. The “Yes” rows are older on average, but this compares two groups. It is not evidence that age produces the label.

  1. Count missing observations that the BMI plot omitted.
Show solution to question 5

Answer: 35 of the 600 rows have no BMI, so the BMI plots leave out 35 rows.

Step by step

  1. is.na(ehr$bmi) gives TRUE for each missing value.
  2. sum() counts the TRUE values.
sum(is.na(ehr$bmi))

Output

[1] 35

3. The printed number is the count of rows that na.rm = TRUE dropped from the picture. Say it beside the figure so readers know the denominator is 565, not 600.

  1. (Section 7) Draw a scatter plot of age and sbp with color mapped to sex and size set to a fixed value of 1. Then change it so the points are all "darkgreen", and explain which version has a legend and why.
Show solution to question 6

Answer: The color = sex version has a legend, because the color depends on a column and sits inside aes(). The color = "darkgreen" version has no legend, because it is one fixed setting outside aes().

Step by step

  1. For the first plot, put color = sex inside aes() and size = 1 outside it, since the size is the same for every point.
  2. For the second plot, move the color outside aes() and give it a fixed value.
ggplot(ehr, aes(x = age, y = sbp, color = sex)) +
  geom_point(size = 1, na.rm = TRUE)
Scatter plot of age against systolic blood pressure with points colored by sex, and a legend. The cloud rises from lower left to upper right.
Scatter plot of age against systolic blood pressure with points colored by sex, and a legend. The cloud rises from lower left to upper right.
ggplot(ehr, aes(x = age, y = sbp)) +
  geom_point(size = 1, color = "darkgreen", na.rm = TRUE)
Scatter plot of age against systolic blood pressure with every point dark green and no legend. The cloud rises from lower left to upper right.
Scatter plot of age against systolic blood pressure with every point dark green and no legend. The cloud rises from lower left to upper right.
  1. Both plots show a cloud rising to the right. Only the first has a legend, since a legend explains a mapping from a column and a fixed color has nothing to explain.
  1. (Section 8) Make a histogram of BMI split into one panel per sex with facet_wrap().
Show solution to question 7

Answer: facet_wrap(~ sex) gives two panels, F and M, on the same axes. The M panel is lower because it has fewer rows.

Step by step

  1. Start from a BMI histogram.
  2. Add facet_wrap(~ sex).
ggplot(ehr, aes(x = bmi)) +
  geom_histogram(binwidth = 2, na.rm = TRUE) +
  facet_wrap(~ sex)
Two histograms of BMI side by side, one panel for F and one for M, on the same axes. The M panel has lower bars than the F panel.
Two histograms of BMI side by side, one panel for F and one for M, on the same axes. The M panel has lower bars than the F panel.
count(ehr, sex)

Output

# A tibble: 2 x 2
  sex       n
  <chr> <int>
1 F       360
2 M       240

3. The count() line prints 360 F and 240 M. Both panels have a similar spread. The M panel is lower because it has fewer rows (360 female, 240 male), not because its BMI values are smaller.

  1. (Section 9) Using geom_col() on a table from count(ehr, diabetes), draw the number of rows per diabetes label, then draw the same thing with geom_bar() and compare.
Show solution to question 8

Answer: The table shows 546 No and 54 Yes. Both plots draw a tall No bar and a short Yes bar. geom_col() uses the counts you supply, while geom_bar() counts the rows itself.

Step by step

  1. Build the table of counts.
diab <- count(ehr, diabetes)
diab

Output

# A tibble: 2 x 2
  diabetes     n
  <chr>    <int>
1 No         546
2 Yes         54

2. The table has one row per label and the counts in n. Give geom_col() that column as y, then draw the same thing with geom_bar() on the raw data.

ggplot(diab, aes(x = diabetes, y = n)) + geom_col()
Bar chart made with geom_col of rows per diabetes label: a tall bar for No and a short bar for Yes.
Bar chart made with geom_col of rows per diabetes label: a tall bar for No and a short bar for Yes.
ggplot(ehr, aes(x = diabetes)) + geom_bar()
Bar chart made with geom_bar of rows per diabetes label: the same tall No bar and short Yes bar.
Bar chart made with geom_bar of rows per diabetes label: the same tall No bar and short Yes bar.
  1. The two plots look identical. Use geom_col() when you already hold the numbers, geom_bar() when you have one row per observation.
  1. (Section 10) Plot mean pulse by visit month as a line, with a dashed geom_hline() at the overall mean pulse.
Show solution to question 9

Answer: The monthly means stay within about half a beat of the overall mean, roughly 74.6 to 75.4 against 75.0, so pulse shows no large seasonal swing in this table.

Step by step

  1. Make a month column and average the pulse by month.
by_month_p <- ehr |>
  mutate(month = as.integer(format(as.Date(visit_date), "%m"))) |>
  group_by(month) |>
  summarise(mean_pulse = mean(pulse, na.rm = TRUE), .groups = "drop")
range(by_month_p$mean_pulse)

Output

[1] 74.57143 75.44643
mean(ehr$pulse, na.rm = TRUE)

Output

[1] 74.98946

2. The range() call printed two numbers, the lowest and highest monthly mean (74.57 and 75.45). The separate mean() call printed the overall mean, 74.98946. Draw the line and add the reference line at that mean.

ggplot(by_month_p, aes(x = month, y = mean_pulse)) +
  geom_line() +
  geom_hline(yintercept = mean(ehr$pulse, na.rm = TRUE), linetype = "dashed") +
  scale_x_continuous(breaks = 1:12)
Line chart of mean pulse by visit month, January to December, wiggling slightly around a dashed horizontal line at the overall mean pulse.
Line chart of mean pulse by visit month, January to December, wiggling slightly around a dashed horizontal line at the overall mean pulse.
  1. The line wiggles, but look at the y axis: the whole range is under one beat per minute, so the wiggles are small.
  1. (Section 11) Redraw the age-by-site boxplot with theme_bw(), a Set2 fill, the legend at the bottom, and the axes swapped with coord_flip().
Show solution to question 10

Answer: Map fill = clinic_site, add scale_fill_brewer(palette = "Set2"), coord_flip(), theme_bw(), and theme(legend.position = "bottom"). The result is three horizontal boxes with similar medians.

Step by step

  1. Start with the boxplot of age by clinic_site, with fill mapped to the site.
  2. Add the palette, flip the axes, and set the theme. Put theme() after theme_bw(), otherwise the complete theme resets your legend setting.
ggplot(ehr, aes(x = clinic_site, y = age, fill = clinic_site)) +
  geom_boxplot() +
  scale_fill_brewer(palette = "Set2") +
  coord_flip() +
  theme_bw() +
  theme(legend.position = "bottom")
Three horizontal boxplots of age, one per clinic site, filled in soft Set2 colors with the legend below. The medians are close together.
Three horizontal boxplots of age, one per clinic site, filled in soft Set2 colors with the legend below. The medians are close together.
  1. Site names are now on the vertical axis and age runs along the bottom. The medians are close, so site does not separate ages.
  1. (Section 12) Draw a correlation heatmap of age, bmi and sbp only, with the values printed in each tile.
Show solution to question 11

Answer: The age and sbp tile is the strongest off the diagonal at 0.75; bmi and sbp is 0.36 and age and bmi is near 0.

Step by step

  1. Take the three columns and compute their correlations.
cm3 <- cor(ehr[, c("age", "bmi", "sbp")], use = "pairwise.complete.obs")
round(cm3, 2)

Output

      age   bmi  sbp
age  1.00 -0.02 0.75
bmi -0.02  1.00 0.36
sbp  0.75  0.36 1.00

2. The diagonal is 1 and the table is symmetric. Reshape it to one row per pair, then draw tiles and print each value with geom_text().

cm3_long <- as.data.frame(as.table(cm3))
names(cm3_long) <- c("var1", "var2", "r")
ggplot(cm3_long, aes(x = var1, y = var2, fill = r)) +
  geom_tile() +
  geom_text(aes(label = sprintf("%.2f", r))) +
  scale_fill_distiller(palette = "RdBu", limits = c(-1, 1), direction = -1)
Heatmap of correlations among age, BMI and systolic blood pressure with the value printed in each tile. The age and systolic blood pressure tile is the darkest off the diagonal at 0.75.
Heatmap of correlations among age, BMI and systolic blood pressure with the value printed in each tile. The age and systolic blood pressure tile is the darkest off the diagonal at 0.75.
  1. Read the tiles: red is a positive link, white is none. Patterns in this simulated dataset are not clinical findings.

Checkpoint

You can match a visual to a variable type, map columns to color, shape and size (and know when a setting belongs outside aes()), split a plot into facets, order and label bars, adjust themes, scales and colors, draw a correlation heatmap, label axes, explain missingness, and distinguish association from causation.