Wrangling, descriptive statistics, and first visualizations
2026-07-05
data/, scripts/, figures/)<-# commentsread_excel() and learned how read_csv() differsglimpse(), head(), dim()%>% to chain stepsggplot as a PNG (dpi = 300)filter(), select(), mutate(), and arrange() to wrangle dataNA values require special handling with sum(!is.na())filter() and pull()group_by() + summarize() to compute stats the tidy wayskimrstat_summary()How to use this worksheet
Work through each part at your own pace. Type the code into a new R script in Positron and run it line by line. Code blocks marked ▶ Run this should be executed exactly as written. Blocks marked ✏️ Your turn ask you to write or modify something. The Going further section at the end is optional — work through it if you finish early.
🔮 Predict before you run — and type, don’t paste
Before you run any ▶ Run this block, cover the output and predict what R will print. Jot your guess down, then run it and compare. Two reasons it is worth the extra few seconds:
- Predicting makes it stick. Guessing forces you to retrieve what you already know, and the little jolt of being wrong is when real learning happens. Reading code you never predicted feels easy but fades fast.
- Typing beats pasting. Typing every line builds muscle memory and trains your eye to catch the small errors (
=vs==, a missing comma) that you will otherwise spend real time hunting. Copy-paste skips the part that actually teaches you.
🧩 Chunk 1 — Wrangling verbs (matches lecture Chunk 1)
Parts 1–3 below. Do these right after the lecture’s Chunk 1, then go back for Chunk 2.
Always load all packages at the very top of your script.
▶ Run this at the top of your script:
⚠️ Watch out! If R says “could not find function
read_excel” you forgotlibrary(readxl). You must reload libraries every time you restart R.
▶ Run this:
▶ Run each of these:
✏️ Your turn: Fill in the table below from the glimpse() output.
Column name | Data type (<chr> / <dbl>) | Example value
----------------|----------------------------|---------------
index | |
side | |
weight_g | |
width_cm | |
height_cm | |
✏️ Your turn: How many leaves are from the sunny side and how many from the shady side? Use table(tree_df$side).
Sunny leaves:
Shady leaves:
Is the design balanced (equal n per group)? Y / N
These four functions do most of the work in data wrangling. All take a data frame and return a data frame.
filter() — keeping rows you want▶ Run this:
🔮 Predict first: There are 20 leaves in total. Before running, guess how many rows
filter(side == "sunny")will return. Write your number, then check.
⚠️ Watch out!
=assigns a value.==tests equality. Always use==insidefilter().
✏️ Your turn: Use filter() to find all leaves where height_cm is greater than 25. How many rows does the result have?
Number of leaves with height_cm > 25:
Are they mostly sunny or shady side?
select() — keeping columns you want▶ Run this:
✏️ Your turn: Create a data frame called lean_df that has only side and height_cm. Then run glimpse(lean_df) to verify.
How many columns does lean_df have?
What are they?
mutate() — adding new columnsmutate() adds new columns (or changes existing ones) without removing anything.
▶ Run this:
🔮 Predict first: Will
mutate(weight_mg = weight_g * 1000)replaceweight_gor add a new column? How many columns will the result have — more, fewer, or the same?
✏️ Your turn: Use mutate() to add a column called height_mm (height converted to millimetres — multiply by 10). What is the maximum height in mm?
Column name you added:
Maximum height in mm:
arrange() — sorting rows▶ Run this:
# arrange() sorts rows by a column ---------------------
# Lightest leaves first (ascending — default)
tree_df %>% arrange(weight_g)
# Heaviest leaves first (descending — use desc())
tree_df %>% arrange(desc(weight_g))
# Sort by side, then by weight within each side
tree_df %>% arrange(side, desc(weight_g))✏️ Your turn: Sort the data by height_cm descending. What is the index number and side of the tallest leaf?
Index of tallest leaf:
Side (sunny or shady):
Height (cm):
▶ Run this:
# Chain all four verbs — read it as a recipe ----------
tree_clean_df <- tree_df %>%
filter(weight_g > 0) %>% # remove any zeros
select(side, weight_g, height_cm) %>% # keep three columns
mutate(
weight_mg = weight_g * 1000,
size_class = if_else(weight_g > 5, "large", "small")
) %>%
arrange(side, desc(weight_g)) # sort by side, then weight
head(tree_clean_df)✏️ Your turn: Read the pipeline out loud, one %>% step at a time. Write in plain English what each step does:
Step 1 (filter):
Step 2 (select):
Step 3 (mutate):
Step 4 (arrange):
🧩 Chunks 2–3 — Describing the data (matches lecture Chunks 2 & 3)
Part 4 (counting correctly with NAs) and Part 5 (mean, median, SD, SE). Do these after the lecture’s Chunks 2 and 3.
NA stands for “Not Available” — it marks a missing value. Missing data are common in real ecological studies.
length()▶ Run this:
✏️ Your turn: What did length(x) return? How many of those positions are real data values?
length(x) returned:
Number of real (non-NA) values:
is.na() step by step▶ Run each line one at a time:
✏️ Your turn: Trace through what each step does to c(4, 3, NA, 7, NA):
is.na(x) produces:
!is.na(x) produces:
sum(!is.na(x)) produces:
⚠️ Watch out! Any stats function on a vector containing
NAreturnsNA— not an error. R will not tell you something went wrong. Always includena.rm = TRUE.
filter() and pull()▶ Run this:
✏️ Your turn: Write the code to extract the shady leaf weights as a vector called shady_wt.
▶ Run this:
✏️ Your turn: Calculate the mean for the shady side. Store it as mean_shady and print it.
Mean shady leaf weight (g):
Is the shady mean larger than sunny? Y / N
Does this match the hypothesis? Y / N
▶ Run this:
✏️ Your turn: Compare the mean and median for the sunny side. If they are about equal, what does that tell you about the shape of the distribution?
Mean sunny:
Median sunny:
What it suggests about distribution shape:
▶ Run this:
✏️ Your turn: Which side has more variable leaf weights? What does a larger SD tell you biologically?
More variable side:
Biological interpretation:
▶ Run this:
🔮 Predict first: SE = SD / √n. Since n is larger than 1, will the SE be larger or smaller than the SD you just calculated? Predict, then run.
# SE = SD / sqrt(n) — must use the correct n ----------
n_sun <- sum(!is.na(sunny_wt))
n_sha <- sum(!is.na(shady_wt))
se_sunny <- sd_sunny / sqrt(n_sun)
se_shady <- sd_shady / sqrt(n_sha)
cat("n sunny =", n_sun, "\n")
cat("SE sunny =", round(se_sunny, 2), "g\n\n")
cat("n shady =", n_sha, "\n")
cat("SE shady =", round(se_shady, 2), "g\n")✏️ Your turn: In your own words, what does the SE tell you that the SD does not?
Your answer:
✏️ Your turn: If you collected 40 leaves per side instead of 10, would the SE get larger or smaller? Why?
Your answer:
🧩 Chunk 4 — Tidy stats & plots (matches lecture Chunk 4)
Parts 6–9:
group_by()+summarize(),skimr, and both plots. Do these after the lecture’s Chunk 4.
group_by() + summarize()▶ Run this:
🔮 Predict first: How many rows will
stats_dfhave? (How many values doessidetake?) Predict the number before running.
# group_by() + summarize() — the core tidyverse pattern ------
stats_df <- tree_df %>%
group_by(side) %>%
summarize(
n = sum(!is.na(weight_g)),
mean_wt = round(mean(weight_g, na.rm = TRUE), 2),
med_wt = round(median(weight_g, na.rm = TRUE), 2),
sd_wt = round(sd(weight_g, na.rm = TRUE), 2),
se_wt = round(sd_wt / sqrt(n), 2)
)
stats_df💡 Key idea:
group_by(side)splits the data by thesidecolumn. Everything after it happens within each group. You get one output row per group.
✏️ Your turn: The se_wt line references sd_wt and n that were just created in the same summarize() call. Rewrite the SE formula in plain words:
SE equals:
✏️ Your turn: Do the numbers in stats_df match what you calculated by hand in Part 5?
Y / N — if not, explain any differences:
▶ Run this:
✏️ Your turn: Looking at size_stats_df, fill in the table:
Measurement | Sunny mean | Shady mean | Shady larger? (Y/N)
---------------|------------|------------|---------------------
weight_g | | |
height_cm | | |
width_cm | | |
Overall: does the data support the hypothesis?
skimr▶ Run this:
✏️ Your turn: Look at the n_missing row for weight_g. How many values are missing per group?
n_missing sunny weight_g:
n_missing shady weight_g:
✏️ Your turn: Look at the mean and p50 (50th percentile = median) values for weight_g. Are mean and median close for each group? What does that suggest about skewness?
Your answer:
💡 Key idea:
skim()is the fastest first look at any dataset. Use it on every new dataset you encounter — it takes one line and shows everything.
Because our data is already in long format, we can plot directly — no reshaping required.
▶ Run this:
# Boxplot — data is already long, plot it directly ----
tree_box_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, fill = side)) +
geom_boxplot(alpha = 0.6, outlier.shape = NA) +
labs(
title = "Leaf Weight by Tree Side",
x = "Side of Tree",
y = "Leaf Weight (g)",
fill = "Side"
) +
theme_minimal() +
theme(legend.position = "none")
tree_box_plot✏️ Your turn: Label the four key parts of a boxplot from memory:
The line inside the box represents:
The top and bottom of the box represent:
The whiskers represent:
Dots beyond the whiskers represent:
▶ Run this:
# Add jittered raw data points over the boxplot ------
tree_jitter_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, fill = side)) +
geom_boxplot(alpha = 0.5, outlier.shape = NA) +
geom_point(
position = position_jitter(width = 0.15, seed = 42),
alpha = 0.6,
size = 2.5
) +
labs(
title = "Leaf Weight by Tree Side",
x = "Side of Tree",
y = "Leaf Weight (g)",
fill = "Side"
) +
theme_minimal() +
theme(legend.position = "none")
tree_jitter_plot✏️ Your turn: In geom_point(), change alpha = 0.6 to alpha = 1.0. Then try alpha = 0.1. What does alpha control?
What alpha controls:
Which alpha value looks best for 10 points?
✏️ Your turn: Change width = 0.15 inside position_jitter() to width = 0.8. What happens? Is 0.15 or 0.8 more appropriate?
What changed:
Better choice and why:
▶ Run this:
stat_summary()▶ Run this:
# stat_summary() computes and plots mean and SE ------
tree_mean_se_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, color = side)) +
geom_point(
position = position_jitter(width = 0.15, seed = 42),
alpha = 0.35,
size = 2
) +
stat_summary(fun = mean, geom = "point", size = 4) +
stat_summary(fun.data = mean_se, geom = "errorbar",
width = 0.15, linewidth = 0.9) +
labs(
title = "Mean ± SE Leaf Weight by Tree Side",
x = "Side of Tree",
y = "Leaf Weight (g)",
color = "Side"
) +
theme_minimal() +
theme(legend.position = "none")
tree_mean_se_plot✏️ Your turn: The large dot shows the mean. The vertical bars show ± 1 SE. Do the error bars overlap? What does non-overlapping bars suggest (informally)?
Do the error bars overlap? Y / N
What non-overlap suggests:
✏️ Your turn: Change fun = mean in the first stat_summary() to fun = median. What changes in the plot?
What changed:
▶ Run this:
At this point you should be able to:
✏️ Your turn — before you move on: Run your entire script with Ctrl/Cmd + Shift + Enter. Does it complete without errors?
Ran cleanly? Y / N
If not, what error appeared:
This section is optional — work through it if you finish early.
height_cm and width_cm▶ Try this for height_cm:
tree_height_plot <- tree_df %>%
ggplot(aes(x = side, y = height_cm, fill = side)) +
geom_boxplot(alpha = 0.5, outlier.shape = NA) +
geom_point(position = position_jitter(width = 0.15, seed = 42),
alpha = 0.6, size = 2.5) +
labs(title = "Leaf Height by Tree Side",
x = "Side of Tree", y = "Leaf Height (cm)") +
theme_minimal() +
theme(legend.position = "none")
tree_height_plot✏️ Your turn: Make the same plot for width_cm. Copy and adapt the code above.
mutate() to add a column, then plot it▶ Try this:
# Add size_class column, then make a bar chart
tree_df %>%
mutate(size_class = if_else(weight_g > 5, "large", "small")) %>%
ggplot(aes(x = side, fill = size_class)) +
geom_bar(position = "dodge") +
labs(title = "Number of large vs small leaves by side",
x = "Side", y = "Count", fill = "Size class") +
theme_minimal()✏️ Your turn: What does position = "dodge" do to the bars? What happens if you change it to position = "fill"?
dodge does:
fill does:
▶ Try this:
tree_violin_plot <- tree_df %>%
ggplot(aes(x = side, y = weight_g, fill = side)) +
geom_violin(alpha = 0.5) +
geom_point(position = position_jitter(width = 0.1, seed = 42),
alpha = 0.6, size = 2) +
labs(title = "Violin Plot of Leaf Weight",
x = "Side of Tree", y = "Leaf Weight (g)") +
theme_minimal() +
theme(legend.position = "none")
tree_violin_plot✏️ Your turn: What does the width of the violin at any given weight value show you, compared to a boxplot?
Your observation:
figures/ folder should containAfter completing this worksheet:
tree_project/
├── data/
│ └── 2026_06_25_tree_experiment_raw_data.xlsx <- never edit this
├── figures/
│ ├── leaf_weight_boxplot.png <- Part 8
│ └── leaf_weight_mean_se.png <- Part 9
└── scripts/
└── 03_leaf_descriptions.R <- your script
library(readxl), library(tidyverse), and library(skimr)?"Sunny" ≠ "sunny". Check with names(tree_df).na.rm = TRUE missing? Any stat function on a column with NAs returns NA without it — no error, just a silent wrong answer.getwd() and confirm the data/ folder is inside your project.💡 Key idea: Getting stuck is not failing — every working data scientist googles error messages daily.
End of Worksheet 03. Next: Worksheet 04 will cover the two-sample Welch’s t-test — formally testing whether the difference in leaf weight between sunny and shady sides is statistically significant.