Mean, median, spread, and describing groups the tidy way
2026-09-10
filter(), select(), mutate(), arrange() — the core wrangling verbs%>%leaf_df — shade, mass_g, petiole_mm, thickness_mm, paper_mass_gNote
✅ Key idea from Lecture 05
You can already reshape the data into exactly the rows and columns you need. Today we learn to describe what’s in it — one number at a time, then all at once.
Biological prediction: Leaves on the shady side of a tree will be larger and heavier to capture more of the limited light.
| Hypothesis | |
|---|---|
| H₀ — null | No difference in leaf size between sunny and shady sides |
| Hₐ — alternate | Leaf size differs between sunny and shady sides |
Plan for this unit:
Statistics helps us decide which hypothesis is better supported by the data.
References:
Both W&S PDFs are in the course readings/ folder.
This lecture runs in two chunks. After each chunk you switch to the activity and type the code yourself into your R script.
For every code block, do three things:
Note
✅ Why bother?
sum(!is.na(x)) yourself, instead of pasting it, is what makes you notice why it’s there instead of length(x).Note
Install once — load every session
install.packages("skimr")library(skimr) activates it.We will cover: the mean, median, variance, SD, SE, and the length() trap.
🛑 After this chunk: Activity Parts 1–3 (load, the NA problem, and base-R stats).
\[\bar{x} = \frac{\sum_{i=1}^{n} x_i}{n}\]
Example:
Values: 5, 6, 7, 8, 50 → mean = 15.2
Is 15.2 a fair summary? Not really — the outlier dominates!
Why it matters:
📖 Whitlock & Schluter, Ch. 3 — Describing Data; R4DS §13 — Numbers
[1] 0.4000 0.4800 0.3400 0.6500 0.2700 0.4300 0.7366 0.4263 0.5682 0.7600
[11] 0.5777 0.1933 0.7912 0.4990 0.6882 0.4162 0.6185 1.0842 0.6649 0.3571
[21] 0.3484 0.5107 0.2303 0.3041 0.2817
filter(shade == "sunny") — keeps only sunny rowspull(mass_g) — extracts that column as a vectorna.rm = TRUE — removes any NA before computingKey pattern:
filter() → picks rows
pull() → extracts a column as a vector
The middle value when data are sorted
| Sorted data | Median |
|---|---|
| 5, 6, 7, 8, 50 | 7 |
| 5, 6, 7, 8, 50, 51 | (7 + 8) / 2 = 7.5 |
Median sunny: 0.48
Median shady: 0.521
\[s^2 = \frac{\sum_{i=1}^{n}(x_i - \bar{x})^2}{n - 1}\]
Dividing by \(n - 1\) gives an unbiased estimate of population variance.
Larger variance = more spread = more uncertainty
Variance sunny: 0.0442
Variance shady: 0.0219
Note
Variance is in squared units (g²) — hard to interpret. That is why we use SD.
\[s = \sqrt{s^2} = \sqrt{\frac{\sum(x_i - \bar{x})^2}{n-1}}\]
\[SE = \frac{s}{\sqrt{n}}\]
| Describes | |
|---|---|
| SD | spread of individual data points |
| SE | precision of the sample mean |
We report SE when making claims about group means.
📖 Whitlock & Schluter, Ch. 4 — Estimating with Uncertainty
n sunny = 25
SE sunny = 0.042 g
n shady = 28
SE shady = 0.028 g
length()[1] 5
[1] 2
length(x) counts all positions in the vector — including NApaper_mass_g measurementsum(!is.na())[1] FALSE FALSE TRUE FALSE TRUE
[1] TRUE TRUE FALSE TRUE FALSE
[1] 3
How it works step by step:
is.na(x) → TRUE for each NA!is.na(x) → flips it: TRUE for real valuessum(...) → counts TRUEsTip
Best practice: always use sum(!is.na(x)) when you need n — even when you think there are no NAs.
--- Sunny Leaves ---
n = 25
Mean = 0.505
Median = 0.48
SD = 0.21
SE = 0.042
na.rm = TRUEcat() prints clean labelled outputgroup_by() next🛑 Do Activity Parts 1–3 now
Load the data, work the NA problem, and compute mean / median / spread in base R. Predict each output, type it, then run it.
We will cover: group_by() + summarize(), summarizing many columns at once with across(), and skimr.
🛑 After this chunk: Activity Parts 4–6.
group_by() + summarize()Note
🔮 Predict first: How many rows will stats_df have? (Hint: how many values does shade take?) Predict the number before you run it.
# Calculate all stats for both sides at once ----------
stats_df <- leaf_df %>%
group_by(shade) %>%
summarize(
n = sum(!is.na(mass_g)),
mean_mass = round(mean(mass_g, na.rm = TRUE), 3),
med_mass = round(median(mass_g, na.rm = TRUE), 3),
sd_mass = round(sd(mass_g, na.rm = TRUE), 3),
se_mass = round(sd_mass / sqrt(n), 3)
)
stats_df# A tibble: 2 × 6
shade n mean_mass med_mass sd_mass se_mass
<chr> <int> <dbl> <dbl> <dbl> <dbl>
1 shady 28 0.523 0.521 0.148 0.028
2 sunny 25 0.505 0.48 0.21 0.042
group_by(shade) — splits the data by the shade columnsummarize() — collapses each group to one row of statssd_mass → se_mass)# One group_by() pipe summarizes all measurements -----
size_stats_df <- leaf_df %>%
group_by(shade) %>%
summarize(
n = sum(!is.na(mass_g)),
mean_mass = round(mean(mass_g, na.rm = TRUE), 3),
mean_thick = round(mean(thickness_mm, na.rm = TRUE), 3),
mean_pet = round(mean(petiole_mm, na.rm = TRUE), 1)
)
size_stats_df# A tibble: 2 × 5
shade n mean_mass mean_thick mean_pet
<chr> <int> <dbl> <dbl> <dbl>
1 shady 28 0.523 0.356 56.7
2 sunny 25 0.505 0.29 54.4
With one group_by() pipe you summarize all three measurements across both sides.
Do the numbers support our hypothesis?
mean_massmean_thickmean_petacross()# A tibble: 2 × 4
shade mass_g petiole_mm thickness_mm
<chr> <dbl> <dbl> <dbl>
1 shady 0.523 56.7 0.356
2 sunny 0.505 54.4 0.29
across(columns, function) applies one function to many columns at once.x is a stand-in for “whichever column we’re on right now”mean_mass/mean_thick/mean_pet above — just without typing each one outTip
Once you have more than 3–4 measurement columns, across() saves real typing — and real typos.
skimr| Name | Piped data |
| Number of rows | 53 |
| Number of columns | 8 |
| _______________________ | |
| Column type frequency: | |
| character | 3 |
| numeric | 4 |
| ________________________ | |
| Group variables | shade |
Variable type: character
| skim_variable | shade | n_missing | complete_rate | min | max | empty | n_unique | whitespace |
|---|---|---|---|---|---|---|---|---|
| twig_id | shady | 22 | 0.21 | 6 | 6 | 0 | 3 | 0 |
| twig_id | sunny | 19 | 0.24 | 6 | 6 | 0 | 3 | 0 |
| leaf_id | shady | 16 | 0.43 | 1 | 3 | 0 | 12 | 0 |
| leaf_id | sunny | 13 | 0.48 | 1 | 3 | 0 | 12 | 0 |
| teams | shady | 0 | 1.00 | 5 | 18 | 0 | 5 | 0 |
| teams | sunny | 0 | 1.00 | 5 | 18 | 0 | 5 | 0 |
Variable type: numeric
| skim_variable | shade | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|---|
| mass_g | shady | 0 | 1.00 | 0.52 | 0.15 | 0.34 | 0.42 | 0.52 | 0.59 | 0.92 | ▇▅▃▁▁ |
| mass_g | sunny | 0 | 1.00 | 0.51 | 0.21 | 0.19 | 0.35 | 0.48 | 0.65 | 1.08 | ▇▇▆▃▁ |
| petiole_mm | shady | 0 | 1.00 | 56.66 | 17.53 | 0.38 | 50.28 | 58.69 | 66.85 | 91.00 | ▁▁▅▇▂ |
| petiole_mm | sunny | 0 | 1.00 | 54.39 | 16.92 | 29.00 | 44.85 | 49.00 | 64.00 | 85.00 | ▆▇▂▃▅ |
| thickness_mm | shady | 1 | 0.96 | 0.36 | 0.16 | 0.12 | 0.23 | 0.36 | 0.48 | 0.67 | ▇▆▃▇▂ |
| thickness_mm | sunny | 0 | 1.00 | 0.29 | 0.19 | 0.10 | 0.14 | 0.23 | 0.40 | 0.69 | ▇▂▃▁▂ |
| paper_mass_g | shady | 7 | 0.75 | 0.28 | 0.04 | 0.20 | 0.25 | 0.27 | 0.30 | 0.36 | ▁▇▃▂▃ |
| paper_mass_g | sunny | 6 | 0.76 | 0.27 | 0.09 | 0.11 | 0.21 | 0.25 | 0.34 | 0.43 | ▂▇▃▅▅ |
Per column and per group: n_missing, mean, sd, percentiles (p0 to p100), and a mini histogram.
Tip
skim() is your fastest first look at any dataset. Pair it with group_by() to compare groups instantly.
🛑 Go to Activity 6 — Summary Statistics
Close the slides. Finish Activity Parts 4–6: group_by() + summarize(), across(), and skimr.
sum(!is.na()) — the right way to count ngroup_by() + summarize() — all stats for all groups at onceacross() — one function, many columnsskim() — instant full dataset overviewReferences:
Both W&S PDFs are in the course readings/ folder.
Up next — Lecture 07, Quarto: