---
title: "Checking an allometric analysis"
description: "Three diagnostics for a scaling exponent: where the leverage sits, how much size range separates 2/3 from 3/4, and what shared ancestry does to the test."
date: "2026-07-27 14:00"
categories: [R, allometry, body size, model diagnostics, ecology tutorial]
image: thumbnail.png
image-alt: "Power curve rising with the number of orders of magnitude of body mass in the sample"
---
The three previous posts built an allometric analysis: log both variables, choose a line, test the exponent. This one asks what could still be wrong with the answer.
The three checks below are not residual plots. They are questions about the design of the sample, and each has a number attached that you can compute before you write anything down: how much of the fit rests on a handful of animals, whether your size range can support the comparison you want to make, and what shared evolutionary history does to a test that assumes independent species.
## Check one: where does the leverage sit?
Field allometry datasets have a characteristic shape. Small individuals are easy to catch, weigh and measure, so you get a lot of them. Large ones are rare, slow to process, and sometimes need a different method entirely. The result is many points in a narrow cluster and a few far out on the size axis.
On the log scale, leverage is a function of distance from the mean of x, so those few far points carry the fit.
```{r leverage}
sma_slope <- function(x, y) sign(cor(x, y)) * sd(y) / sd(x)
set.seed(43826)
n_small <- 140
n_large <- 10
b_true <- 0.75
sd_ex <- 0.16
size_true <- c(rnorm(n_small, log10(12), 0.16), rnorm(n_large, log10(210), 0.10))
log_mass <- size_true + rnorm(n_small + n_large, 0, sd_ex)
log_trait <- b_true * size_true + rnorm(n_small + n_large, 0, b_true * sd_ex)
hat <- 1 / length(log_mass) +
(log_mass - mean(log_mass))^2 / sum((log_mass - mean(log_mass))^2)
big <- order(log_mass, decreasing = TRUE)[seq_len(n_large)]
c(pct_of_sample = 100 * n_large / length(log_mass),
pct_of_leverage = 100 * sum(hat[big]) / sum(hat),
largest_hat_over_mean = hat[order(log_mass, decreasing = TRUE)[1]] / mean(hat))
```
The `r sprintf("%.1f", 100*n_large/length(log_mass))` percent of the sample that sits in the upper cluster carries `r sprintf("%.1f", 100*sum(hat[big])/sum(hat))` percent of the leverage, and the single largest animal has `r sprintf("%.1f", hat[order(log_mass, decreasing=TRUE)[1]]/mean(hat))` times the average. That is not a defect. Spread on the x axis is what makes an exponent estimable, and those animals are earning their place.
The risk is specific: those points have no near neighbours to contradict them. A transcription error on a common small animal is absorbed. The same error on the largest one is not.
```{r single-error}
biggest <- order(log_mass, decreasing = TRUE)[1]
smallest <- order(log_mass)[1]
b_base <- sma_slope(log_mass, log_trait)
trait_big_wrong <- log_trait; trait_big_wrong[biggest] <- trait_big_wrong[biggest] + log10(2)
trait_small_wrong <- log_trait; trait_small_wrong[smallest] <- trait_small_wrong[smallest] + log10(2)
c(baseline = b_base,
largest_doubled = sma_slope(log_mass, trait_big_wrong),
smallest_doubled = sma_slope(log_mass, trait_small_wrong),
pct_change_largest = 100 * (sma_slope(log_mass, trait_big_wrong) / b_base - 1),
pct_change_smallest = 100 * (sma_slope(log_mass, trait_small_wrong) / b_base - 1))
```
One animal recorded at twice its true trait value moves the exponent by `r sprintf("%+.1f", 100*(sma_slope(log_mass, trait_big_wrong)/b_base - 1))` percent when it is the largest in the sample, and `r sprintf("%+.1f", 100*(sma_slope(log_mass, trait_small_wrong)/b_base - 1))` percent when it is the smallest. The asymmetry is roughly the leverage ratio, and it is why the practical version of this check is not a statistic at all: go back and re-read the datasheet for your five largest animals.
```{r fig-leverage}
#| echo: false
#| fig-cap: "Leverage against log body mass for a typical field sample. Most of the animals sit in a low-leverage cluster, and a handful at the top of the size range carry a third of the total."
#| fig-alt: "Scatter of leverage values against log body mass. A dense band of low points on the left, and a small group of much higher points on the right."
#| fig-width: 6.6
#| fig-height: 4
library(ggplot2)
theme_te <- function(base_size = 11) {
theme_minimal(base_size = base_size) +
theme(
panel.grid.minor = element_blank(),
panel.grid.major = element_line(colour = "#dad9ca", linewidth = 0.3),
plot.background = element_rect(fill = "white", colour = NA),
panel.background = element_rect(fill = "white", colour = NA),
axis.title = element_text(colour = "#2c3a31"),
axis.text = element_text(colour = "#5d6b61"),
legend.text = element_text(colour = "#2c3a31"), legend.title = element_blank(),
plot.title = element_text(colour = "#16241d", face = "bold", size = base_size + 1),
plot.subtitle = element_text(colour = "#5d6b61", size = base_size - 1)
)
}
dl <- data.frame(log_mass = log_mass, hat = hat,
grp = ifelse(seq_along(log_mass) %in% big, "upper cluster", "main cluster"))
ggplot(dl, aes(log_mass, hat, colour = grp)) +
geom_hline(yintercept = mean(hat), colour = "#93a87f", linetype = "dashed", linewidth = 0.5) +
geom_point(size = 2, alpha = 0.7) +
scale_colour_manual(values = c(`main cluster` = "#275139", `upper cluster` = "#b5534e")) +
labs(x = "log body mass", y = "leverage",
title = "A third of the fit rests on ten animals",
subtitle = "dashed line: mean leverage") +
theme_te() + theme(legend.position = "top")
```
## Check two: is your size range wide enough for the question?
Metabolic scaling has spent decades arguing over whether the exponent is 2/3 or 3/4. Those values differ by `r sprintf("%.4f", 0.75 - 2/3)`, which is a small target, and whether your dataset can hit it depends almost entirely on how many orders of magnitude of body mass it covers.
This is a power calculation, and it is worth doing before fieldwork rather than after.
```{r range-power}
b_alt <- 0.75
b_h0 <- 2 / 3
n_sp <- 60
decades <- c(1, 2, 3, 4, 6, 8)
power_grid <- do.call(rbind, lapply(c(0.15, 0.25), function(sd_res) {
do.call(rbind, lapply(decades, function(dec) {
set.seed(4200 + dec + round(sd_res * 100))
p <- replicate(1500, {
lx <- runif(n_sp, 0, dec)
ly <- b_alt * lx + rnorm(n_sp, 0, sd_res)
co <- summary(lm(ly ~ lx))$coefficients[2, ]
2 * pt(-abs((co[1] - b_h0) / co[2]), n_sp - 2)
})
data.frame(resid_sd = sd_res, orders_of_magnitude = dec,
power = round(mean(p < 0.05), 3))
}))
}))
power_grid
```
With `r n_sp` species and a residual standard deviation of 0.15 on the log10 scale, a single order of magnitude of body mass gives `r sprintf("%.0f", 100*power_grid$power[power_grid$resid_sd==0.15 & power_grid$orders_of_magnitude==1])` percent power to distinguish 3/4 from 2/3, and you need `r power_grid$orders_of_magnitude[power_grid$resid_sd==0.15 & power_grid$power >= 0.8][1]` orders to clear 80 percent. Loosen the scatter to 0.25, which is not unusual for interspecific data, and one order of magnitude gives `r sprintf("%.0f", 100*power_grid$power[power_grid$resid_sd==0.25 & power_grid$orders_of_magnitude==1])` percent: the study cannot answer the question it was designed around, and no amount of careful line fitting will change that.
```{r fig-power}
#| echo: false
#| fig-cap: "Power to distinguish an exponent of 3/4 from 2/3, as a function of the orders of magnitude of body mass covered and the residual scatter. A narrow size range cannot separate the two hypotheses at any realistic sample size."
#| fig-alt: "Two rising curves of power against orders of magnitude of body mass, one for each level of residual scatter, with a dashed horizontal line at eighty percent."
#| fig-width: 6.6
#| fig-height: 4
power_grid$scatter <- factor(power_grid$resid_sd,
labels = c("residual sd 0.15", "residual sd 0.25"))
ggplot(power_grid, aes(orders_of_magnitude, power, colour = scatter)) +
geom_hline(yintercept = 0.8, colour = "#93a87f", linetype = "dashed", linewidth = 0.5) +
geom_line(linewidth = 0.9) + geom_point(size = 2) +
scale_colour_manual(values = c("#275139", "#c9b458")) +
labs(x = "orders of magnitude of body mass in the sample", y = "power at the five percent level",
title = "How wide does the size range have to be?",
subtitle = sprintf("testing an exponent of %.2f against %.4f with %d species",
b_alt, b_h0, n_sp)) +
theme_te() + theme(legend.position = "top")
```
## Check three: are your species independent?
Interspecific allometry regresses one species trait on another, and the standard tests treat each species as an independent observation. Species are not independent. Two closely related mammals share most of their evolutionary history, so they share most of whatever has pushed their metabolic rate away from the line.
Build a random ultrametric tree, express it as a shared-ancestry covariance matrix, and simulate both traits as Brownian motion on it. Then test a hypothesis that is *true*.
```{r phylo-setup}
make_vcv <- function(n_tips) {
V <- matrix(0, n_tips, n_tips)
build <- function(idx, height) {
V[idx, idx] <<- height
if (length(idx) < 2) return(invisible(NULL))
deeper <- height + (1 - height) * runif(1, 0.15, 0.6)
k <- sample.int(length(idx) - 1, 1)
build(idx[seq_len(k)], deeper)
build(idx[-seq_len(k)], deeper)
}
build(seq_len(n_tips), 0)
diag(V) <- 1
V
}
pgls_slope <- function(x, y, V, b0) {
Vi <- chol2inv(chol(V)); X <- cbind(1, x); n <- length(x)
bhat <- solve(t(X) %*% Vi %*% X, t(X) %*% Vi %*% y)
rg <- y - X %*% bhat
s2 <- as.numeric(t(rg) %*% Vi %*% rg / (n - 2))
se <- sqrt(s2 * solve(t(X) %*% Vi %*% X)[2, 2])
c(slope = bhat[2], p = 2 * pt(-abs((bhat[2] - b0) / se), n - 2))
}
```
The residual in this simulation evolves on the same tree as the trait, which is the situation the phylogenetic comparative literature was built for: the departures from the line are themselves inherited.
```{r phylo-mc}
n_tips <- 60
b_phy <- 0.75
set.seed(4383)
phy <- t(vapply(seq_len(1200), function(i) {
V <- make_vcv(n_tips); L <- t(chol(V))
x <- as.vector(L %*% rnorm(n_tips)) * 1.2
y <- b_phy * x + as.vector(L %*% rnorm(n_tips)) * 0.35
co <- summary(lm(y ~ x))$coefficients[2, ]
p_ols <- 2 * pt(-abs((co[1] - b_phy) / co[2]), n_tips - 2)
pg <- pgls_slope(x, y, V, b_phy)
c(p_ols, pg["p"], co[1], pg["slope"])
}, numeric(4)))
c(ols_reject = mean(phy[, 1] < 0.05), pgls_reject = mean(phy[, 2] < 0.05),
mean_slope_ols = mean(phy[, 3]), mean_slope_pgls = mean(phy[, 4]))
```
The hypothesis is true in every one of these simulated clades. The ordinary least squares test rejects it `r sprintf("%.1f", 100*mean(phy[,1] < 0.05))` percent of the time; generalised least squares on the phylogenetic covariance holds at `r sprintf("%.3f", mean(phy[,2] < 0.05))`.
Look at the last two numbers before drawing the wrong lesson. The mean least squares slope is `r sprintf("%.4f", mean(phy[,3]))` and the mean phylogenetic one is `r sprintf("%.4f", mean(phy[,4]))`, both on top of the truth. Ignoring the phylogeny did not bias the exponent. It made the standard error far too small, because the effective number of independent data points is much less than the number of species. The point estimate was fine and the confidence interval was fiction.
## What these three checks add up to
A published scaling exponent is a joint product of the line you chose, the size range you sampled, the taxa you happened to include, and the handful of large individuals that anchor the top of the range. The 2/3 against 3/4 argument has run as long as it has partly because those four things vary between studies, and each of them can move an exponent by more than the difference under debate.
The checks are cheap. Compute the leverage distribution and re-read the datasheets for the top few. Run the power grid against the specific alternative you care about, using your own scatter and sample size. If the points are species rather than individuals, put the tree in the model, and quote the interval that comes out rather than the one that does not.
## Honest limits
None of this bounds the error from a wrong functional form: every simulation here assumed a genuine power law, and a relationship that bends on the log scale will defeat all three checks. The phylogenetic check assumed Brownian motion on a known tree, and real analyses have neither; a mis-specified branch length model produces its own intervals, correctly calibrated for a process that did not happen. And the power grid is a statement about detecting a difference in the fitted exponent, which is only the biological exponent under the error model the line assumes. These checks constrain the answer. They do not certify it.
## References
Warton, Wright, Falster, Westoby 2006 Biological Reviews 81(2):259-291 (10.1017/S1464793106007007)
Felsenstein 1985 The American Naturalist 125(1):1-15 (10.1086/284325)
Xiao, White, Hooten, Durham 2011 Ecology 92(10):1887-1894 (10.1890/11-0538.1)
## Related tutorials
- [Testing isometry and comparing slopes](../testing-isometry-and-comparing-slopes/) - the tests these diagnostics are checking.
- [SMA or OLS for a scaling exponent?](../sma-versus-ols-for-scaling-exponents/) - the line choice that sets the exponent in the first place.
- [Phylogenetic generalised least squares](../phylogenetic-generalised-least-squares/) - the correction in check three, at full length.
- [Allometry and log-log regression in R](../allometry-and-log-log-regression/) - the cluster opener.