Activity: Nested ANOVA
Nested designs
Worksheet: Nested ANOVA
How to use this worksheet
Work through each part in order, at your own pace. Type every line of code yourself into a plain R script — do not copy-paste. Blocks marked ▶ Run this are code you should type and execute. Blocks marked ✏️ Your turn ask you to write, modify, or answer something. Boxes marked 🚀 If you finish early are optional bonus material that goes a bit further than what we covered in lecture.
This worksheet uses the sea urchin grazing / algae cover dataset from lecture, where
patchis nested withintreat(each patch received only one urchin-density treatment). Next class, the Linear Mixed Models activity revisits this exact same dataset with a different (and more flexible) modeling approach — this worksheet stays entirely with the traditional method.
Part 1 · Setup and Data
▶ Run this in your Script:
library(janitor)
library(afex)
library(emmeans) # For estimated marginal means
library(tidyverse)
options(scipen = 999)
options(contrasts = c("contr.treatment", "contr.poly"))
u_df <- read_csv("data/andrew.csv") %>% clean_names()
u_df <- u_df %>%
mutate(treat = case_when(
treat == "removal" ~ "none",
treat == "control" ~ "high",
treat == "dens_33" ~ "low",
treat == "dens_66" ~ "medium",
TRUE ~ "other")) %>%
mutate(treat = factor(treat,
levels = c("none", "low", "medium", "high"),
labels = c("none", "low", "medium", "high")),
patch = as_factor(patch))
head(u_df)
levels(u_df$treat)u_df contains: patch (random patches 1–16 where treatments were applied), treat (urchin density treatment — none, low, medium, high), quad (replicate quadrats within each patch:treatment combination), algae (percentage cover of filamentous algae — the response variable).
▶ Run this — summary statistics and a first plot:
summary_stats <- u_df %>%
group_by(treat) %>%
summarise(
n = sum(!is.na(algae)),
mean = mean(algae, na.rm = TRUE),
sd = sd(algae, na.rm = TRUE),
se = sd / sqrt(n),
min = min(algae, na.rm = TRUE),
max = max(algae, na.rm = TRUE),
.groups = 'drop'
)
summary_stats
dodge_position <- position_dodge(width = 0.3)
u_df %>%
ggplot(aes(treat, algae, color = patch)) +
stat_summary(fun = "mean", geom = "point", position = dodge_position) +
stat_summary(fun.data = "mean_se", geom = "errorbar", width = 0.2, position = dodge_position)✏️ Your turn: How many patches are nested within each treatment? ________________________
Part 2 · Fitting the Nested Model — Base R and afex
Since patch is nested within treat, we need the correct error term — the traditional aov() approach lets us specify this directly.
▶ Run this:
# This gives you the correct F-test using PATCH within TREAT as error term
nested_model <- aov(algae ~ treat + Error(treat:patch), data = u_df)
summary(nested_model)▶ Run this — the afex package, recommended for unbalanced designs:
model_afx <- aov_car(algae ~ treat + Error(patch),
data = u_df)
summary(model_afx)✏️ Your turn: Do nested_model and model_afx agree on whether the treatment effect is significant? ________________________
Part 3 · Post-Hoc Comparisons
▶ Run this:
emm <- emmeans(nested_model, ~ treat)
summary(emm)
pairs_result <- pairs(emm, adjust = "sidak")
pairs_result
cld_result <- multcomp::cld(emm, alpha = 0.05, Letters = letters)
cld_result✏️ Your turn: Mean algae cover for Control (1.30%) looks much lower than the reduced-density treatments (66%: 21.55%, 33%: 19.00%, Removed: 39.20%). Based on your compact letter display, are any of those differences actually statistically significant at α = 0.05? ________________________
Part 4 · Visualization and Reporting
▶ Run this — a publication-style boxplot:
ggplot(u_df, aes(x = treat, y = algae, fill = treat)) +
geom_boxplot(alpha = 0.7, outlier.shape = NA) +
geom_jitter(width = 0.2, alpha = 0.4, size = 1) +
scale_fill_viridis_d(option = "D", end = 0.85) +
labs(
title = "Effect of Urchin Density on Filamentous Algae Cover",
x = "Urchin Density Treatment",
y = "Filamentous Algae Cover (%)",
caption = "Boxplots showing the distribution of algal cover across urchin density treatments."
) +
theme(
legend.position = "none",
plot.title = element_text(face = "bold", size = 14),
axis.title = element_text(face = "bold", size = 12),
plot.caption = element_text(hjust = 0, face = "italic", size = 10)
)▶ Run this — a mean ± SE plot:
ggplot(summary_stats, aes(x = treat, y = mean, group = 1)) +
geom_point(size = 3, shape = 21, fill = "white") +
geom_errorbar(aes(ymin = mean - se, ymax = mean + se), width = 0.2) +
labs(
title = "Mean Algae Cover by Urchin Density Treatment",
x = "Urchin Density Treatment",
y = "Mean Filamentous Algae Cover (%)",
caption = "Mean (+/- SE) percentage cover of filamentous algae across urchin density treatments."
) +
theme(
plot.title = element_text(face = "bold", size = 14),
axis.title = element_text(face = "bold", size = 12),
plot.caption = element_text(hjust = 0, face = "italic", size = 10)
)Scientific interpretation
This nested ANOVA reveals substantial spatial heterogeneity in algae cover, with significant variation among patches within each treatment. The substantial patch-to-patch variability shown in Part 2’s ANOVA table is exactly why patch needed to be modeled explicitly, rather than being pooled into the residual — a good reminder that pseudoreplication doesn’t just bias your F-test, it hides real biological variation.
✏️ Your turn: Write a 2–3 sentence results paragraph for this analysis, using your own F-statistic, df, and p-value from Part 2.
________________________________________________________
________________________________________________________
Review and checkpoint
At this point you can:
📤 What to turn in before next class
Upload both of these to the course management system:
- Your code — the
scripts/folder (or just14_nested_anova.R) - This worksheet, with your written answers
Getting unstuck
When code breaks — and it will, that is normal:
- Read the error message out loud. R usually names the line and the problem.
- Check the usual suspects: did you run
library(janitor),library(afex),library(emmeans), andlibrary(tidyverse)? Spelling? A missing)or%>%at the start of a line? ?function_nameopens the built-in help page.- Bring the exact error (copy-paste it) to class or office hours.
💡 Key idea: Every working scientist googles error messages daily. Getting stuck is not failing — it is the job.