BIO 202, Spring 2026 — draft v1. "No trend" is a distribution, not a single number. How big a trend does it take before you can tell it apart from chance?
You fit a line to forty years of specimen data and read off a slope. That slope, by itself, tells you almost nothing. It could be noise. The question is not "what is the slope?" but "how different is the slope I got from the slopes I would have gotten under no trend at all?"
Note that the pedagogy is the same as Lesson 0 — your estimate is wiggling around a truth, and the wiggle has a shape. This time the estimate is a slope, and the wiggle is what "no trend" actually looks like.
Forty years, one hundred specimens per year, all drawn from the same unchanging distribution. Fit a line through each simulated dataset and collect the slopes. The histogram of those slopes is the null.
For this stage: say out loud what "no trend" means as a distribution, not as a single number. Name what controls the width of that distribution (sample size, σ, the number of years) and what does not (the true value of the slope, since it is fixed at zero here).
A museum sits underneath one of the busiest migration corridors in North America. Every year, hundreds of birds strike a lit building, and for forty years a curator has measured each one. Body size.
Question: are the birds getting smaller?
Before you look at the real data, simulate the world where the answer is no. Body mass is drawn every year from the same fixed Normal:
yi,t ~ Normal(μ, σ) for every year t, every specimen i
No time subscript on μ. Nothing is changing. Now fit lm(y ~ year). The fitted slope will almost never be exactly zero — the sample mean wobbles from year to year, and a line drawn through those wobbles will tilt one way or the other. The tilt is the signature of noise, not of change.
Repeat this simulation a thousand times with different seeds. Each replicate gives one fitted slope. The histogram of those slopes is what "no trend" actually looks like: centered on zero, with a width.
Note that the width of the null slope distribution has a closed form: SE(β̂) ≈ σ / (SD(year) · √Ntotal). That is the reason tripling n shrinks the null by about √3. You do not have to memorize the formula — you can see it move when you drag the sliders.
Someone runs a single 10-year study on a stable lake-trout population and reports a fitted slope of +0.03 kg/year. Before reading the paper further, the best interpretation is…
# Lesson 1, Stage A — the null has a widthset.seed(42)n_per_year <- 80n_years <- 40mu <- 20 # grand mean (g)sigma <- 2.0simulate_years <- function(n_per_year, n_years, mu, sigma, slope = 0) { year <- rep(1:n_years, each = n_per_year) mean_t <- mu + slope * (year - mean(1:n_years)) y <- rnorm(n_per_year*n_years, mean_t, sigma) data.frame(year, y)}dat <- simulate_years(n_per_year, n_years, mu, sigma, slope = 0)fit <- lm(y ~ year, data = dat)coef(fit)[2] # beta_hat — rarely zero, even though true slope is zero# the null distribution of beta_hatnull_betas <- replicate(1000, { d <- simulate_years(n_per_year, n_years, mu, sigma, slope = 0) coef(lm(y ~ year, data = d))[2]})hist(null_betas, breaks = 40, col = "gray80", border = "white", xlab = "fitted slope (g / year)", main = "")abline(v = 0, col = "#b23a48", lwd = 2, lty = 2)
A feature is a parameter value, not a switch. Drag the slope away from zero and watch the fitted-slope distribution slide off the null.
For this stage: watch two histograms at once — the frozen null from Stage A, and the current (true-slope) distribution. Detectability is not about whether the line tilts; it is about whether the two histograms stop overlapping.
Turn on a real trend. Each year's mean now shifts slightly:
yi,t ~ Normal(μ + β·(t − t̄), σ)
β is the slope — grams per year. Negative β means the population is shrinking over time. Set β = −0.05 g/year and the true mean drops by 2 grams over 40 years. That is less than σ, which means you cannot see the trend in a single year's sample. But a line fit across all forty years will tilt.
Watch two histograms together. The gray one is the null from Stage A — slopes you would get if nothing were happening. The red one is slopes from your current simulation, with β as you set it. When β = 0 the two histograms coincide. As β moves off zero, the red histogram slides across the null and eventually stops overlapping it.
The trick is that the null does not move. The null is a property of the sampling design — how many birds, how many years, how much individual noise. The true slope shifts the current distribution without widening or narrowing it; the width is set by design.
Two research groups report slopes of −0.08 g/year on the same species. Group 1 used 10 specimens per year for 20 years; Group 2 used 400 specimens per year for 40 years. Both fit a simple linear regression. Which study has more power to distinguish the trend from the null of no change?
# Lesson 1, Stage B — layer a real slope onto the nullset.seed(42)n_per_year <- 80n_years <- 40mu <- 20sigma <- 2.0beta <- -0.05# same simulator, this time with slope != 0null_betas <- replicate(1000, { d <- simulate_years(n_per_year, n_years, mu, sigma, slope = 0) coef(lm(y ~ year, data = d))[2]})curr_betas <- replicate(1000, { d <- simulate_years(n_per_year, n_years, mu, sigma, slope = beta) coef(lm(y ~ year, data = d))[2]})hist(null_betas, breaks = 40, col = rgb(0.3, 0.3, 0.3, 0.5), border = "white", xlab = "fitted slope", main = "")hist(curr_betas, breaks = 40, col = rgb(0.7, 0.23, 0.28, 0.6), border = "white", add = TRUE)abline(v = 0, col = "gray40", lty = 2)
You have one study and one fitted slope. Compare it to the null you built in A and B. Read the one-sided tail as an empirical p-value.
For this stage: say what an empirical p-value is in plain English — "the fraction of null replicates at least this extreme." Say what it is not — not the probability the true slope is zero, not the probability the trend is real.
A curator walks into your office with forty years of data on one species and one fitted slope. Your question is not "is the slope zero?" — it is almost never exactly zero. Your question is "does the slope sit in the bulk of the null, or out in its tail?"
The recipe: simulate the null once (hundreds of replicates with β = 0, matched to the real study's design), then count the fraction of null slopes at least as extreme as the one you observed. That fraction is the empirical p-value. Mechanically:
p̂ = (1 + #{|β̂null| ≥ |β̂obs|}) / (Nreplicates + 1)
Sliding the true β on the controls panel is how you would ask "what slope magnitude would this study detect?" — sweep β and plot the fraction of current replicates whose |β̂| lands past the null's 2.5th / 97.5th percentiles. That is a power curve.
Note that p̂ is a property of the study. Same slope, same p̂ only if the design matches. A big slope in a small study can fail to clear the null; a tiny slope in a large study can sit far out in the tail. Design is part of the evidence.
A paper reports β̂ = +0.02 with p = 0.04 and concludes "the trait is increasing." A reviewer asks about a negative slope of the same magnitude. What is the empirical p-value for β̂ = −0.02 under the same null?
# Lesson 1, Stage C — test an observed slope against the nullset.seed(42)n_per_year <- 80n_years <- 40sigma <- 2.0beta <- -0.05mu <- 20# one observed datasetobs <- simulate_years(n_per_year, n_years, mu, sigma, slope = beta)beta_obs <- coef(lm(y ~ year, data = obs))[2]# null distribution (matched design, slope = 0)null_betas <- replicate(2000, { d <- simulate_years(n_per_year, n_years, mu, sigma, slope = 0) coef(lm(y ~ year, data = d))[2]})# two-sided empirical pp_emp <- (1 + sum(abs(null_betas) >= abs(beta_obs))) / (length(null_betas) + 1)cat("beta_obs =", beta_obs, "p_emp =", p_emp, "\n")# power curve: sweep beta, compute fraction rejected at alpha=0.05betas_try <- seq(-0.2, 0.2, length.out = 21)crit <- quantile(null_betas, c(0.025, 0.975))power <- sapply(betas_try, function(b) { curr <- replicate(200, { d <- simulate_years(n_per_year, n_years, mu, sigma, slope = b) coef(lm(y ~ year, data = d))[2] }) mean(curr < crit[1] | curr > crit[2])})plot(betas_try, power, type = "b", pch = 16, ylim = c(0, 1), xlab = "true slope", ylab = "P(reject null)")abline(h = 0.8, lty = 2, col = "#b23a48")
Forty years of annual mean beak measurements on Geospiza fortis and G. scandens, Daphne Major, from Grant & Grant (2014). One series is one long time course. Fit a line. Compute one slope. Drop it on top of a null cloud built the same way Stage C built yours.
Peter and Rosemary Grant measured the beaks of every breeding finch on Daphne Major for four decades. Each year's point on this graph is a mean over dozens to hundreds of individuals. The annual means are the observations — forty of them, one per year. That is a small-n problem, which is exactly what a null cloud is for.
ȳt = α + β · t + εt εt ~ Normal(0, σ̂)
We estimate σ̂ from the residuals of the fit itself — the typical size of how far a year's mean sits from the straight-line trend. Then we simulate 2000 "no-trend" series of 40 years each with the same σ̂ and fit a slope to each. That is your null cloud. The observed slope from the real data lands somewhere in it.
Three traits are available: beak length, beak depth, beak width. Two species: fortis (smaller overall) and scandens (longer beak, relatively stable depth). The 1977 drought jump in fortis depth is real: it is the most-cited selection event in evolutionary biology. A single linear slope is not the right model for it — which is the point.
Note that a slope that is "clearly nonzero" under a straight-line null does not mean the world is straight. It means "the world is not flat." The fortis beak depth trajectory has a 1977 jump and a 2004 drop; a straight line through it is a bad summary but a detectable one. Stage D tells you whether the change is bigger than noise. Lesson 2 asks whether it is the right shape.
A colleague sends you 20 years of annual mean snout-vent length for a lizard population. She fits a line, gets β̂ = −0.008 mm/yr, and writes "the lizards are shrinking." Which reading is best?
# Lesson 1, Stage D — Grant finch annual means, real slope vs null cloudset.seed(42)# Source: Grant & Grant 2014, "40 Years of Evolution" (Fig. 01-06, 01-07).dat <- read.csv("data/clean/finch_beak.csv") # year, species, trait columnssub <- subset(dat, species == "fortis")y <- sub$beak_depth; t <- sub$year# one fit on real datafit <- lm(y ~ t)beta_obs <- coef(fit)[2]sigma_hat <- sigma(fit) # residual SD — the noise scale we simulate at# null cloud: matched design, slope = 0, same sigma_hatreps <- 2000null_betas <- replicate(reps, { y_null <- mean(y) + rnorm(length(y), 0, sigma_hat) coef(lm(y_null ~ t))[2]})# two-sided empirical pp_emp <- (1 + sum(abs(null_betas) >= abs(beta_obs))) / (reps + 1)cat("beta_obs =", beta_obs, "p_emp =", p_emp, "\n")par(mfrow = c(2, 1))plot(t, y, pch = 16, col = "gray30", xlab = "year", ylab = "mean beak depth")abline(fit, col = "#b23a48", lwd = 2)hist(null_betas, breaks = 40, col = "gray80", border = "white", xlab = "null slope", main = "")abline(v = beta_obs, col = "#b23a48", lwd = 2)