---
title: "Allometry and log-log regression in R"
description: "Fit an allometric exponent on the log scale, then back-transform without losing biomass: retransformation bias, and which error model your data support."
date: "2026-07-27 11:00"
categories: [R, allometry, body size, regression, ecology tutorial]
image: thumbnail.png
image-alt: "Log-log scatter of tree biomass against stem diameter with a fitted straight line"
---
Body size is the variable that most other ecological quantities hang off. Metabolic rate, home range, clutch size, leaf area, tree biomass: each one tends to follow a power law of the form `Y = a * M^b`, and the exponent `b` is usually the quantity of interest. The standard move is to take logs of both sides, which turns the power law into a straight line, and read the exponent off the slope.
That move is sound. What goes wrong is the trip back. If you fit on the log scale and then exponentiate your predictions, you do not get the average biomass you were trying to predict. You get something systematically smaller, and the gap is large enough to matter for a stand-level carbon estimate.
This post builds a small allometric dataset, fits the exponent, measures the size of the back-transformation gap, and fixes it two ways. Then it asks the question that decides whether you should have logged anything at all: where does the error live?
## A power law on the log scale
Take a stand of trees. Each one has a stem diameter and an above-ground dry biomass. The biomass grows faster than linearly with diameter, because a wider stem is also a taller stem carrying a broader crown.
```{r setup-data}
set.seed(379)
n_tree <- 240
b_true <- 2.6
a_true <- 0.062
s_true <- 0.32
diam <- exp(rnorm(n_tree, log(28), 0.55))
biomass <- a_true * diam^b_true * exp(rnorm(n_tree, 0, s_true))
fit_log <- lm(log(biomass) ~ log(diam))
b_hat <- unname(coef(fit_log)[2])
a_hat <- unname(exp(coef(fit_log)[1]))
sigma_hat <- summary(fit_log)$sigma
c(exponent = b_hat, coefficient = a_hat, resid_sd = sigma_hat)
```
The scatter is generated with a multiplicative error term, `exp(rnorm(...))`, so a tree is a fixed *percentage* above or below its expected biomass rather than a fixed number of kilograms. That is the usual situation in biology, and it is exactly what the log transform is built for: on the log scale, a multiplicative error becomes an additive one with constant variance.
The fitted exponent is `r sprintf("%.3f", b_hat)` against a true value of `r sprintf("%.2f", b_true)`, and the residual standard deviation on the log scale is `r sprintf("%.3f", sigma_hat)`.
```{r fig-loglog}
#| echo: false
#| fig-cap: "Tree biomass against stem diameter on the raw scale (left) and after taking natural logs of both variables (right). The power law becomes a straight line, and the scatter becomes even."
#| fig-alt: "Two panels. The left panel shows a steeply curving cloud of points rising with diameter and fanning out. The right panel shows the same data on log axes as a straight band of points around a fitted line."
#| fig-width: 8
#| fig-height: 3.6
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"),
plot.title = element_text(colour = "#16241d", face = "bold", size = base_size + 1),
plot.subtitle = element_text(colour = "#5d6b61", size = base_size - 1)
)
}
dd <- data.frame(diam = diam, biomass = biomass)
pA <- ggplot(dd, aes(diam, biomass)) +
geom_point(colour = "#275139", alpha = 0.45, size = 1.5) +
labs(x = "stem diameter (cm)", y = "biomass (kg)", title = "Raw scale") +
theme_te()
pB <- ggplot(dd, aes(log(diam), log(biomass))) +
geom_point(colour = "#275139", alpha = 0.45, size = 1.5) +
geom_smooth(method = "lm", formula = y ~ x, se = FALSE,
colour = "#b5534e", linewidth = 0.8) +
labs(x = "log stem diameter", y = "log biomass", title = "Log scale") +
theme_te()
grid::grid.newpage()
grid::pushViewport(grid::viewport(layout = grid::grid.layout(1, 2)))
print(pA, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 1))
print(pB, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
```
## The trip back costs you biomass
Now predict. You want each tree's expected biomass in kilograms, so you exponentiate the fitted values and add them up.
```{r retransform}
pred_naive <- exp(predict(fit_log))
total_true <- sum(biomass)
total_naive <- sum(pred_naive)
c(true_total = total_true, naive_total = total_naive,
shortfall_pct = 100 * (total_naive / total_true - 1))
```
The predicted stand total falls short of the real one by `r sprintf("%.1f", abs(100 * (total_naive / total_true - 1)))` percent. Nothing is wrong with the fit. The gap comes from what `exp()` does to a symmetric distribution.
On the log scale the residuals sit symmetrically around the fitted line, so the fitted value is the mean *of the logs*. Exponentiate it and you get the median biomass, not the mean, and for a right-skewed quantity the median sits below the mean. Under lognormal residuals the ratio is exactly `exp(-s^2/2)`, which here is `r sprintf("%.4f", exp(-sigma_hat^2/2))`.
Two corrections exist, and both are one line.
```{r corrections}
cf_baskerville <- exp(sigma_hat^2 / 2)
cf_smearing <- mean(exp(residuals(fit_log)))
c(baskerville = cf_baskerville, smearing = cf_smearing,
baskerville_total = sum(pred_naive) * cf_baskerville,
smearing_total = sum(pred_naive) * cf_smearing,
true_total = total_true)
```
Baskerville's factor `exp(s^2/2)` inverts the lognormal shift analytically. Duan's smearing estimator does the same job without assuming a distribution: it averages `exp(residual)` across the sample, letting the observed residuals speak for themselves. Here they give `r sprintf("%.4f", cf_baskerville)` and `r sprintf("%.4f", cf_smearing)`, close enough that the choice does not matter on these data.
One realisation proves nothing, so repeat the whole exercise.
```{r mc-retransform}
set.seed(3790)
n_rep <- 2000
mc <- t(vapply(seq_len(n_rep), function(i) {
d <- exp(rnorm(n_tree, log(28), 0.55))
y <- a_true * d^b_true * exp(rnorm(n_tree, 0, s_true))
f <- lm(log(y) ~ log(d))
p <- exp(predict(f))
s2 <- summary(f)$sigma^2
c(sum(p) / sum(y),
sum(p) * exp(s2 / 2) / sum(y),
sum(p) * mean(exp(residuals(f))) / sum(y))
}, numeric(3)))
mc_mean <- colMeans(mc)
names(mc_mean) <- c("naive", "baskerville", "smearing")
round(c(mc_mean, theory = exp(-s_true^2 / 2)), 4)
```
Across `r format(n_rep, big.mark = ",")` simulated stands the naive back-transformation returns `r sprintf("%.4f", mc_mean[1])` of the true total, against the theoretical `r sprintf("%.4f", exp(-s_true^2/2))`. Both corrections land on `r sprintf("%.3f", mc_mean[2])` and `r sprintf("%.3f", mc_mean[3])`. The shortfall is a bias, not bad luck, and it scales with the residual spread: doubling the log-scale scatter roughly quadruples the exponent in `exp(-s^2/2)`.
## Which error model do your data support?
The log transform is not a neutral convenience. Logging the response asserts that the error is multiplicative. Fitting `Y = a * M^b` directly with `nls()` asserts that it is additive and of constant size in kilograms. These are different models of the same mean function, and they will not agree.
The tempting question is which one gets the exponent right. Measure it.
```{r mc-error-model}
set.seed(3793)
n_em <- 1200
em_mult <- t(vapply(seq_len(n_em), function(i) {
d <- exp(rnorm(n_tree, log(28), 0.55))
y <- a_true * d^b_true * exp(rnorm(n_tree, 0, 0.32))
c(unname(coef(lm(log(y) ~ log(d)))[2]),
tryCatch(unname(coef(nls(y ~ a * d^b, start = list(a = 0.05, b = 2.5)))[2]),
error = function(e) NA_real_))
}, numeric(2)))
rmse <- function(v) sqrt(mean((v - b_true)^2, na.rm = TRUE))
c(loglog_mean = mean(em_mult[, 1]), nls_mean = mean(em_mult[, 2], na.rm = TRUE),
loglog_rmse = rmse(em_mult[, 1]), nls_rmse = rmse(em_mult[, 2]),
rmse_ratio = rmse(em_mult[, 2]) / rmse(em_mult[, 1]))
```
Both estimators are close to the truth on average: `r sprintf("%.3f", mean(em_mult[,1]))` for the log-log fit and `r sprintf("%.3f", mean(em_mult[,2], na.rm=TRUE))` for `nls()`, against `r sprintf("%.2f", b_true)`. The cost of picking the wrong error model is not bias. It is spread. The root mean squared error of the `nls()` exponent is `r sprintf("%.1f", rmse(em_mult[,2])/rmse(em_mult[,1]))` times the log-log one, and the middle 95 percent of `nls()` exponents runs from `r sprintf("%.2f", quantile(em_mult[,2], 0.025, na.rm=TRUE))` to `r sprintf("%.2f", quantile(em_mult[,2], 0.975, na.rm=TRUE))` while the log-log fit stays inside `r sprintf("%.2f", quantile(em_mult[,1], 0.025))` to `r sprintf("%.2f", quantile(em_mult[,1], 0.975))`.
The reason is visible in the data. With multiplicative error, the largest trees carry the largest absolute scatter, so a least-squares fit on the raw scale spends almost all of its attention on a handful of big stems. On the log scale that scatter is even, and every tree contributes.
The comparison runs the other way too. Generate additive, constant-variance error over a narrow diameter range where negative biomass never arises, and `nls()` becomes the efficient choice.
```{r mc-additive}
set.seed(3794)
em_add <- t(vapply(seq_len(n_em), function(i) {
d <- runif(n_tree, 20, 60)
mu <- a_true * d^b_true
y <- mu + rnorm(n_tree, 0, 0.25 * min(mu))
c(unname(coef(lm(log(y) ~ log(d)))[2]),
tryCatch(unname(coef(nls(y ~ a * d^b, start = list(a = 0.05, b = 2.5)))[2]),
error = function(e) NA_real_))
}, numeric(2)))
c(loglog_rmse = rmse(em_add[, 1]), nls_rmse = rmse(em_add[, 2]),
ratio = rmse(em_add[, 1]) / rmse(em_add[, 2]))
```
On additive data the log-log fit has `r sprintf("%.1f", rmse(em_add[,1])/rmse(em_add[,2]))` times the root mean squared error of `nls()`. Neither method is better in general. The error distribution decides, and this is the point Xiao and colleagues made after a long argument in the literature about whether log transformation invalidates published power laws.
```{r fig-resid}
#| echo: false
#| fig-cap: "Residuals from the same multiplicative dataset, plotted on the raw scale (left) and the log scale (right). The raw-scale funnel is the signature of multiplicative error, and it is what the log transform removes."
#| fig-alt: "Two residual panels. The left panel shows residuals fanning out sharply as fitted biomass increases. The right panel shows residuals in an even horizontal band across fitted log biomass."
#| fig-width: 8
#| fig-height: 3.4
fit_nls <- nls(biomass ~ a * diam^b, start = list(a = 0.05, b = 2.5))
rr <- data.frame(fit_raw = fitted(fit_nls), res_raw = residuals(fit_nls),
fit_log = fitted(fit_log), res_log = residuals(fit_log))
p1 <- ggplot(rr, aes(fit_raw, res_raw)) +
geom_hline(yintercept = 0, colour = "#93a87f", linewidth = 0.4) +
geom_point(colour = "#275139", alpha = 0.45, size = 1.5) +
labs(x = "fitted biomass (kg)", y = "residual (kg)", title = "Raw scale") +
theme_te()
p2 <- ggplot(rr, aes(fit_log, res_log)) +
geom_hline(yintercept = 0, colour = "#93a87f", linewidth = 0.4) +
geom_point(colour = "#275139", alpha = 0.45, size = 1.5) +
labs(x = "fitted log biomass", y = "residual (log units)", title = "Log scale") +
theme_te()
grid::grid.newpage()
grid::pushViewport(grid::viewport(layout = grid::grid.layout(1, 2)))
print(p1, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 1))
print(p2, vp = grid::viewport(layout.pos.row = 1, layout.pos.col = 2))
```
The residual plot is the diagnostic. A funnel on the raw scale and an even band on the log scale says multiplicative, and the log-log fit is the efficient one. The reverse pattern says additive, and `nls()` is. If you have never looked, you have chosen by habit.
## Where to go next
The workflow for an allometric equation is short: log both variables, fit, check the residual scale, and apply a correction factor before you convert anything back to kilograms. Report the factor you used, because a reader cannot reconstruct it from the coefficients.
Two things this post left open. First, the corrections above assume the log-scale residuals are homoscedastic; if they widen with size, the smearing factor is an average over unlike things and a size-specific correction is needed. Second, and larger, the whole post fitted the slope with ordinary least squares, which assumes the x variable is measured without error. Diameter tape readings are not error free, and body masses even less so. That assumption changes which line you should fit, and it changes the exponent.
## Honest limits
Baskerville's factor is exactly right only under lognormal residuals. The smearing estimator drops that assumption but still needs the residuals to be identically distributed across the size range, and it is noisier in small samples because it averages `exp()` of a handful of numbers. Neither correction rescues a fit whose functional form is wrong: if the relationship bends on the log scale, no scalar multiplier will repair it.
## References
Baskerville 1972 Canadian Journal of Forest Research 2(1):49-53 (10.1139/x72-009)
Duan 1983 Journal of the American Statistical Association 78(383):605-610 (10.1080/01621459.1983.10478017)
Xiao, White, Hooten, Durham 2011 Ecology 92(10):1887-1894 (10.1890/11-0538.1)
Warton, Wright, Falster, Westoby 2006 Biological Reviews 81(2):259-291 (10.1017/S1464793106007007)
## Related tutorials
- [SMA or OLS for a scaling exponent?](../sma-versus-ols-for-scaling-exponents/) - which line to fit when both variables carry error.
- [Nonlinear regression with nls](../nonlinear-regression-with-nls/) - fitting a curve on its own scale instead of linearising it.
- [Species-area relationships](../species-area-relationships/) - another ecological power law, and the log-versus-power choice it forces.
- [Checking an allometric analysis](../checking-an-allometric-analysis/) - leverage, size range and phylogeny.