Course contents
Part IChapter 7R foundations and data
Visualize and Explore Data
On this page
- 1. Explore before predicting
- 2. The anatomy of a ggplot
- 3. Histogram: a numeric distribution
- 4. Bar chart: a category count
- 5. Scatter plot: two numeric variables
- 6. Compare categories carefully
- 7. Map columns to color, shape and size
- 8. Facets: small multiples
- 9. Bars: counted heights and given heights
- 10. Lines, reference lines and crowded points
- 11. Look and scales
- 12. A correlation heatmap
- 13. Missingness is part of the story
- 14. Save an image
- Try it
- 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")

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.”
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")

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")

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")

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")

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))

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

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)

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

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)")

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

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

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.00The 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)

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 31Reading 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] TRUEggsave() 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
- 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
- Name the table and map
sbpto the horizontal axis. A histogram needs onlyx, because the height is a count the geom computes. - Add
geom_histogram(binwidth = 10).na.rm = TRUEdrops the rows with nosbpfrom the drawing without a warning.
ggplot(ehr, aes(x = sbp)) +
geom_histogram(binwidth = 10, na.rm = TRUE)
- Read the plot: most readings sit in the middle bins and few are very low or very high. Each bar counts rows whose
sbpfalls in that 10 mmHg range.
- 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
- Put the category (
clinic_site) onxand the number (bmi) ony. - Add
geom_boxplot().
ggplot(ehr, aes(x = clinic_site, y = bmi)) +
geom_boxplot(na.rm = TRUE)
- Compare the thick middle lines (medians) and the box heights (the middle half of the rows). They are nearly level across the three sites.
- Make a scatter plot of age and pulse, mapping
diabetesto 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
- Map
agetoxandpulsetoy. - Add
color = diabetesinsideaes(), because the color depends on a column. - Draw the points with
geom_point().
ggplot(ehr, aes(x = age, y = pulse, color = diabetes)) +
geom_point(na.rm = TRUE)
- 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.
- 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
- A plot shows association: older rows tend to carry the “Yes” label.
- 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.
- The simulator itself linked age to the label, so the pattern is there by design.
- 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 545. The “Yes” rows are older on average, but this compares two groups. It is not evidence that age produces the label.
- 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
is.na(ehr$bmi)givesTRUEfor each missing value.sum()counts theTRUEvalues.
sum(is.na(ehr$bmi))Output
[1] 353. 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.
- (Section 7) Draw a scatter plot of age and sbp with
colormapped tosexandsizeset 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
- For the first plot, put
color = sexinsideaes()andsize = 1outside it, since the size is the same for every point. - 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)
ggplot(ehr, aes(x = age, y = sbp)) +
geom_point(size = 1, color = "darkgreen", na.rm = TRUE)
- 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.
- (Section 8) Make a histogram of BMI split into one panel per
sexwithfacet_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
- Start from a BMI histogram.
- Add
facet_wrap(~ sex).
ggplot(ehr, aes(x = bmi)) +
geom_histogram(binwidth = 2, na.rm = TRUE) +
facet_wrap(~ sex)
count(ehr, sex)Output
# A tibble: 2 x 2
sex n
<chr> <int>
1 F 360
2 M 2403. 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.
- (Section 9) Using
geom_col()on a table fromcount(ehr, diabetes), draw the number of rows per diabetes label, then draw the same thing withgeom_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
- Build the table of counts.
diab <- count(ehr, diabetes)
diabOutput
# A tibble: 2 x 2
diabetes n
<chr> <int>
1 No 546
2 Yes 542. 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()
ggplot(ehr, aes(x = diabetes)) + geom_bar()
- The two plots look identical. Use
geom_col()when you already hold the numbers,geom_bar()when you have one row per observation.
- (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
- 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.44643mean(ehr$pulse, na.rm = TRUE)Output
[1] 74.989462. 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)
- The line wiggles, but look at the y axis: the whole range is under one beat per minute, so the wiggles are small.
- (Section 11) Redraw the age-by-site boxplot with
theme_bw(), aSet2fill, the legend at the bottom, and the axes swapped withcoord_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
- Start with the boxplot of
agebyclinic_site, withfillmapped to the site. - Add the palette, flip the axes, and set the theme. Put
theme()aftertheme_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")
- Site names are now on the vertical axis and age runs along the bottom. The medians are close, so site does not separate ages.
- (Section 12) Draw a correlation heatmap of
age,bmiandsbponly, 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
- 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.002. 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)
- 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.