Testing for an ecological threshold

R
hypothesis testing
ecology tutorial
ggplot2
Deciding whether a threshold is real in base R: the naive break test over-rejects, so bootstrap the null distribution, and do not mistake a smooth curve for a kink.
Author

Tidy Ecology

Published

2026-08-05

The previous post fitted a breakpoint by profiling the residual sum of squares. That procedure always returns a minimum, so a good broken-stick fit is not by itself evidence that a threshold exists. This post asks the harder question: is the break real, or would a straight line have done?

The tempting shortcut is to fit the two-piece model, read the p-value of the slope-change term, and call anything below 0.05 a threshold. That test is not valid. Under the hypothesis of no break the breakpoint is not a parameter of the model at all, so it is estimated from the noise, and the usual F reference does not hold. The fix is to build the null distribution by simulation, and to remember that rejecting a straight line is not the same as finding a kink.

Why the naive p-value is wrong

The statistic itself is fine: at each candidate breakpoint, compare the two-piece fit with the straight line by an F ratio, and take the largest F over all candidates. This maximised value is the natural test statistic.

seg_supF <- function(x, y, grid) {
  fit0 <- lm(y ~ x); rss0 <- sum(fit0$residuals^2); n <- length(y)
  best <- Inf; psi <- NA
  for (p in grid) {
    r <- sum(lm(y ~ x + pmax(x - p, 0))$residuals^2)
    if (r < best) { best <- r; psi <- p }
  }
  list(F = ((rss0 - best) / 1) / (best / (n - 3)), psi = psi)
}
grid <- seq(4, 26, by = 0.2)

The trouble is the reference. An ordinary F statistic for one added term follows an F distribution with one and \(n - 3\) degrees of freedom. But this F was maximised over every candidate breakpoint, so it is systematically larger than a single F even when nothing is going on. Simulate straight-line data with no break, run the procedure, and read the naive p-value each time.

set.seed(4199)
Rrep <- 2000; n <- 80
naive_p <- numeric(Rrep)
for (r in seq_len(Rrep)) {
  x <- runif(n, 0, 30)
  y <- 20 - 0.15 * x + rnorm(n, 0, 3)         # a plain line, no break
  naive_p[r] <- pf(seg_supF(x, y, grid)$F, 1, n - 3, lower.tail = FALSE)
}
rej_naive <- mean(naive_p < 0.05)
Histogram of p-values under no break, strongly skewed towards zero rather than uniform, with the region below 0.05 shaded and a dashed line marking the flat height a valid test would show.
Figure 1: Naive p-values from 2000 data sets that contain no break. A valid test would give a flat histogram; instead the mass piles up near zero, and far more than 5% fall below 0.05.

The p-values in Figure 1 are not flat. They crowd towards zero, and about 18% fall below 0.05 rather than the 5% a valid test would give. Read literally, the naive break test would announce a threshold in nearly one in five data sets that have none.

A null distribution by bootstrap

The repair is to compare the observed statistic against its own null distribution rather than an off-the-shelf F. Fit the straight line, treat that as the truth, simulate new responses from it, and recompute the maximised F each time. Here is a data set with a genuine break to test.

set.seed(781)
n <- 100
x <- runif(n, 0, 30); psi0 <- 14
y <- 18 + 0.10 * x - 0.80 * pmax(x - psi0, 0) + rnorm(n, 0, 2.5)
obs <- seg_supF(x, y, grid)
naive_p_obs <- pf(obs$F, 1, n - 3, lower.tail = FALSE)

fit0 <- lm(y ~ x); sig0 <- summary(fit0)$sigma
set.seed(55)
B <- 1200
null_F <- replicate(B, seg_supF(x, fitted(fit0) + rnorm(n, 0, sig0), grid)$F)
p_boot <- (1 + sum(null_F >= obs$F)) / (B + 1)
crit_boot  <- quantile(null_F, 0.95)
crit_naive <- qf(0.95, 1, n - 3)
size_naive <- mean(null_F > crit_naive)     # the naive test's true size

The maximised F here is 57.4, which the F table would call significant at about 2.1^{-11}. The bootstrap agrees that the break is real but is far more measured: its p-value is 0.001. The difference is entirely in the threshold. A valid 5% test needs an F above 6.6 (the 95th percentile of the null distribution), whereas the naive table uses 3.9. Using that lower bar, the actual rejection rate under no break is 17%, matching the 18% from the direct simulation above. Two routes, one conclusion: the F table is roughly three to four times too generous.

A significant test is not a threshold

There is a deeper limit, and it is the honest note of the cluster. The break test asks whether a straight line is adequate. It does not ask whether the true shape is a kink. A smooth curve that bends also fails the straight-line test, and it will fail it emphatically.

set.seed(4299)
n <- 90
x <- runif(n, 0, 30)
y <- 30 * x / (6 + x) + rnorm(n, 0, 2.2)        # smooth saturation, no kink
sm <- seg_supF(x, y, grid)
naive_p_smooth <- pf(sm$F, 1, n - 3, lower.tail = FALSE)

fit_seg <- lm(y ~ x + pmax(x - sm$psi, 0))
fit_mm  <- nls(y ~ SSmicmen(x, Vm, K))          # the smooth alternative
aic_seg <- AIC(fit_seg) + 2                      # +2 for the estimated breakpoint
aic_mm  <- AIC(fit_mm)
Scatter rising and levelling off; a solid smooth saturating curve and a dashed broken-stick line lie almost on top of each other through the points.
Figure 2: Smooth saturating data. The break test rejects a straight line overwhelmingly, yet a smooth curve (solid) fits as well as the broken stick (dashed): there is no kink to find.

The data in Figure 2 are a smooth saturating curve with no breakpoint anywhere. The maximised F is 122, and the naive p-value is about 3^{-18}: an overwhelming vote against the straight line. A practitioner who compares only a line with a broken stick will report a sharp threshold. But when the smooth saturating curve is fitted as well, AIC prefers it by about 12.4 points, revealing the apparent break as a gentle bend.

The honest limit

A break test controls a single decision: is a straight line enough? Do not run it with the F table, which is calibrated for a fixed breakpoint and rejects three to four times too often once the breakpoint is estimated; build the null distribution by simulation instead. And once a line is rejected, the work is not finished. Ask AIC, or a smooth alternative, whether the response truly turns at a point or simply curves. Distinguishing a genuine threshold from a smooth bend is a choice between shapes, and the data support it only weakly. The next post carries the threshold idea into count and presence data, where the same distinction between a step and a slope has to be made explicitly.

References

  • Toms JD, Lesperance ML 2003. Ecology 84(8):2034-2041 (10.1890/02-0472)
  • Toms JD, Villard MA 2015. Avian Conservation and Ecology 10(1):2 (10.5751/ACE-00715-100102)
  • Davies RB 1987. Biometrika 74(1):33-43 (10.1093/biomet/74.1.33)
  • Andrews DWK 1993. Econometrica 61(4):821-856 (10.2307/2951764)
  • Muggeo VMR 2003. Statistics in Medicine 22(19):3055-3071 (10.1002/sim.1545)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.