---
title: "Distributional regression with location and scale"
description: "Model the spread, not just the mean. A base-R location-scale fit, why naive prediction intervals miscalibrate, and how it connects to quantile regression."
date: "2026-08-06 11:00"
categories: [distributional regression, R, ecology tutorial, ggplot2]
image: thumbnail.png
image-alt: "A scatter with a fitted mean line, a shaded prediction band that widens toward higher x, and a dashed constant-width band that does not."
---
Quantile regression describes the shape of a conditional distribution without naming it. A complementary route is to name a distribution and let more than its mean depend on the predictor. The simplest version is a location-scale model: the response is Gaussian, its mean is a function of the covariate, and so is its standard deviation. When the spread of an ecological response changes across a gradient, the mean slope is often beside the point and the variance slope is the finding. This post fits such a model in base R with `optim`, shows why a mean-only model gives the wrong prediction intervals, and connects the parametric quantiles it implies to the distribution-free ones from quantile regression.
## Mean and spread both depend on the gradient
```{r}
#| label: setup
#| echo: false
#| message: false
#| warning: false
library(ggplot2); library(dplyr); library(tidyr)
ink <- "#16241d"; body <- "#2c3a31"; forest <- "#275139"
sage <- "#93a87f"; paper <- "#f5f4ee"; line <- "#dad9ca"
faint <- "#5d6b61"; warm <- "#b5534e"; gold <- "#c9b458"
theme_te <- function(base_size = 12) {
theme_minimal(base_size = base_size) +
theme(plot.background = element_rect(fill = paper, colour = NA),
panel.background = element_rect(fill = paper, colour = NA),
panel.grid.major = element_line(colour = line, linewidth = 0.3),
panel.grid.minor = element_blank(),
axis.text = element_text(colour = faint),
axis.title = element_text(colour = body),
plot.subtitle = element_text(colour = "#46604a"),
legend.position = "top", legend.key = element_blank(),
legend.text = element_text(colour = body),
legend.title = element_text(colour = body))
}
rho <- function(u, tau) u * (tau - (u < 0))
qrfit <- function(x, y, tau) {
obj <- function(b) sum(rho(y - (b[1] + b[2] * x), tau))
optim(unname(coef(lm(y ~ x))), obj, control = list(reltol = 1e-12, maxit = 8000))$par
}
```
```{r}
#| label: data
set.seed(4204)
n <- 400
x <- runif(n, 0, 10)
a0 <- 1; a1 <- 0.8 # mean: a0 + a1 * x
c0 <- log(0.8); c1 <- 0.12 # log standard deviation: c0 + c1 * x
y <- rnorm(n, a0 + a1 * x, exp(c0 + c1 * x))
coef(lm(y ~ x))[2] # the mean slope, from ordinary least squares
```
The mean slope is easy: least squares estimates it well. The mean-only model then assumes a single standard deviation for the whole gradient, which is wrong here by construction. To recover the spread, we write the negative log-likelihood with the mean as one linear predictor and the log standard deviation as another, and minimise it:
```{r}
#| label: locscale
nll <- function(p, X, Z, y) {
k <- ncol(X); b <- p[1:k]; g <- p[(k + 1):(k + ncol(Z))]
s <- exp(as.vector(Z %*% g))
-sum(dnorm(y, as.vector(X %*% b), s, log = TRUE))
}
X <- cbind(1, x); Z <- cbind(1, x)
ols <- lm(y ~ x)
fit <- optim(c(unname(coef(ols)), log(sd(resid(ols))), 0),
nll, X = X, Z = Z, y = y, method = "BFGS", hessian = TRUE)
bML <- fit$par[1:2]; gML <- fit$par[3:4]
se <- sqrt(diag(solve(fit$hessian)))
c(mean_slope = bML[2], logsd_slope = gML[2])
```
```{r}
#| label: ls-scalars
#| echo: false
r_lm_slope <- round(unname(coef(ols)[2]), 3)
r_ls_slope <- round(bML[2], 3)
r_c1 <- round(gML[2], 3); r_c1_se <- round(se[4], 3)
sdm <- sqrt(mean(resid(ols)^2))
ll_homo <- sum(dnorm(y, fitted(ols), sdm, log = TRUE)); ll_LS <- -fit$value
r_LR <- round(2 * (ll_LS - ll_homo), 2)
r_LRp <- signif(pchisq(2 * (ll_LS - ll_homo), 1, lower.tail = FALSE), 3)
r_dAIC <- round((-2 * ll_homo + 2 * 3) - (-2 * ll_LS + 2 * 4), 1)
```
The two models agree on the mean slope (`r r_lm_slope` from least squares, `r r_ls_slope` from the location-scale fit): that is not where they differ. The location-scale fit adds the log standard deviation slope, `r r_c1` with standard error `r r_c1_se`, against a true value of `r round(c1, 3)`. A likelihood-ratio test of this one extra parameter is decisive: the statistic is `r r_LR` on one degree of freedom (p = `r r_LRp`), and the varying-spread model improves the AIC by `r r_dAIC`. The spread is the signal, and a mean-only model is blind to it.
## Why the naive prediction interval is wrong
A mean-only model builds a prediction interval of constant width from the residual standard deviation. Where the true spread is small that band is too wide; where it is large it is too narrow. We can measure this on a fresh, large sample by checking how often the response falls inside a nominal ninety per cent band.
```{r}
#| label: coverage
set.seed(55)
xt <- runif(20000, 0, 10)
yt <- rnorm(20000, a0 + a1 * xt, exp(c0 + c1 * xt))
z90 <- qnorm(0.95)
covN <- abs(yt - (coef(ols)[1] + coef(ols)[2] * xt)) <= z90 * sdm
covL <- abs(yt - (bML[1] + bML[2] * xt)) <= z90 * exp(gML[1] + gML[2] * xt)
terc <- cut(xt, quantile(xt, c(0, 1/3, 2/3, 1)), include.lowest = TRUE,
labels = c("low", "mid", "high"))
rbind(naive = tapply(covN, terc, mean), locscale = tapply(covL, terc, mean))
```
```{r}
#| label: cov-scalars
#| echo: false
r_nlo <- round(mean(covN[terc == "low"]), 3); r_nhi <- round(mean(covN[terc == "high"]), 3)
r_llo <- round(mean(covL[terc == "low"]), 3); r_lhi <- round(mean(covL[terc == "high"]), 3)
r_novr <- round(mean(covN), 3); r_lovr <- round(mean(covL), 3)
```
Averaged over the whole gradient the naive band looks passable (`r r_novr` against a target of 0.90). Split by thirds it falls apart: it covers `r r_nlo` of points in the low-spread region and only `r r_nhi` in the high-spread region. The location-scale band, whose width tracks the fitted standard deviation, stays near the target everywhere: `r r_llo` low, `r r_lhi` high, `r r_lovr` overall. Averaging hides the miscalibration; modelling the spread removes it.
```{r}
#| label: fig-bands
#| echo: false
#| fig-cap: "The location-scale band (shaded) widens with the spread; the naive constant-width band (dashed) is too wide at low x and too narrow at high x."
#| fig-alt: "Scatter with a fitted mean line, a green shaded prediction band that widens toward higher x, and a dashed constant-width band that stays the same width across the range."
#| fig-width: 7
#| fig-height: 4.6
xg <- seq(0, 10, length.out = 80)
bd <- data.frame(x = xg, muL = bML[1] + bML[2] * xg, sL = exp(gML[1] + gML[2] * xg),
muN = coef(ols)[1] + coef(ols)[2] * xg)
ggplot() +
geom_point(aes(x, y), colour = sage, alpha = 0.55, size = 1.4) +
geom_ribbon(data = bd, aes(x, ymin = muN - z90 * sdm, ymax = muN + z90 * sdm),
fill = NA, colour = faint, linetype = "dashed", linewidth = 0.7) +
geom_ribbon(data = bd, aes(x, ymin = muL - z90 * sL, ymax = muL + z90 * sL),
fill = forest, alpha = 0.16) +
geom_line(data = bd, aes(x, muL), colour = forest, linewidth = 0.9) +
labs(x = "resource gradient", y = "response",
subtitle = "shaded = location-scale 90% band; dashed = naive constant-width band") +
theme_te()
```
## The bridge to quantile regression
A location-scale model implies quantiles directly: the tau-quantile is the fitted mean plus the standard normal quantile times the fitted standard deviation. Quantile regression estimates the same curves without assuming a distribution. When the Gaussian assumption holds, the two agree closely.
```{r}
#| label: bridge
xg <- seq(0, 10, length.out = 60)
agree <- sapply(c(0.1, 0.9), function(tt) {
para <- (bML[1] + bML[2] * xg) + qnorm(tt) * exp(gML[1] + gML[2] * xg)
qc <- qrfit(x, y, tt)
cor(para, qc[1] + qc[2] * xg)
})
round(agree, 4)
```
```{r}
#| label: bridge-scalars
#| echo: false
r_c10 <- round(agree[1], 4); r_c90 <- round(agree[2], 4)
```
The fitted parametric and distribution-free quantile curves line up almost exactly, with correlations `r r_c10` at the tenth percentile and `r r_c90` at the ninetieth. Under a correct family the parametric route is more efficient: it borrows strength across the whole distribution to pin down each quantile.
## The honest limit: the family is a choice
Efficiency comes at the price of an assumption. The location-scale model treats the errors as symmetric and Gaussian, and it is the tails that pay when that is wrong. If the errors are skewed, the true ninety-fifth percentile is not the fitted mean plus 1.645 standard deviations, and the parametric quantile drifts off exactly where you most want it. Quantile regression, which makes no such assumption, holds up. We test both on right-skewed errors, checking how often the response falls below each fitted ninety-fifth-percentile line on a large sample.
```{r}
#| label: skew
set.seed(4214)
e0 <- (rgamma(n, 2, 1) - 2) / sqrt(2) # standardised right-skewed error
ys <- (a0 + a1 * x) + exp(c0 + c1 * x) * e0
fs <- optim(c(unname(coef(lm(ys ~ x))), log(sd(resid(lm(ys ~ x)))), 0),
nll, X = X, Z = Z, y = ys, method = "BFGS")
bS <- fs$par[1:2]; gS <- fs$par[3:4]
qc95 <- qrfit(x, ys, 0.95)
set.seed(77)
xts <- runif(40000, 0, 10)
yts <- (a0 + a1 * xts) + exp(c0 + c1 * xts) * (rgamma(40000, 2, 1) - 2) / sqrt(2)
gaussQ <- (bS[1] + bS[2] * xts) + qnorm(0.95) * exp(gS[1] + gS[2] * xts)
qrQ <- qc95[1] + qc95[2] * xts
c(gaussian = mean(yts < gaussQ), quantile_reg = mean(yts < qrQ))
```
```{r}
#| label: skew-scalars
#| echo: false
r_g95 <- round(mean(yts < gaussQ), 3); r_qr95 <- round(mean(yts < qrQ), 3)
```
The target coverage is 0.95. The Gaussian location-scale line reaches only `r r_g95`: it sits too low, because a right-skewed upper tail is heavier than the normal one and the model cannot see that. The distribution-free quantile line reaches `r r_qr95`, close to the mark. This is the trade. A distributional model is efficient and gives you the whole distribution at once, but only if the family is right, and the family is a choice you make, not something the data hand you. Quantile regression asks for less and, in the tails, delivers more.
```{r}
#| label: fig-skew
#| echo: false
#| fig-cap: "On right-skewed errors the Gaussian ninety-fifth-percentile line sits below the binned empirical quantile; the distribution-free fit tracks it."
#| fig-alt: "Scatter of a right-skewed response with dark diamonds marking binned empirical ninety-fifth percentiles, a warm Gaussian line sitting below them and a green quantile-regression line passing through them."
#| fig-width: 7
#| fig-height: 4.6
bins <- cut(x, seq(0, 10, 1.25), include.lowest = TRUE)
emp <- tapply(seq_along(ys), bins, function(i) quantile(ys[i], 0.95))
ebd <- data.frame(x = seq(0.625, 10, 1.25)[seq_along(emp)], q = as.numeric(emp))
ll <- data.frame(x = xg, gauss = (bS[1] + bS[2] * xg) + qnorm(0.95) * exp(gS[1] + gS[2] * xg),
qr = qc95[1] + qc95[2] * xg)
ggplot() +
geom_point(aes(x, ys), colour = sage, alpha = 0.4, size = 1.2) +
geom_point(data = ebd, aes(x, q), colour = ink, size = 2.2, shape = 18) +
geom_line(data = ll, aes(x, gauss, colour = "Gaussian location-scale"), linewidth = 1) +
geom_line(data = ll, aes(x, qr, colour = "distribution-free QR"), linewidth = 1) +
scale_colour_manual(values = c("Gaussian location-scale" = warm,
"distribution-free QR" = forest), name = NULL) +
labs(x = "resource gradient", y = "response (right-skewed error)",
subtitle = "diamonds = binned empirical 0.95 quantile") +
theme_te()
```
## References
Rigby and Stasinopoulos 2005 Journal of the Royal Statistical Society Series C 54(3):507-554 (10.1111/j.1467-9876.2005.00510.x)
Kneib 2013 Statistical Modelling 13(4):275-303 (10.1177/1471082X13494159)
Koenker and Bassett 1978 Econometrica 46(1):33-50 (10.2307/1913643)
Yu, Lu and Stander 2003 Journal of the Royal Statistical Society Series D 52(3):331-350 (10.1111/1467-9884.00363)
Koenker 2005 Quantile Regression, Cambridge University Press (ISBN 978-0-521-84573-1)
## Related tutorials
- [Quantile regression in ecology: beyond the mean](../quantile-regression-in-ecology/)
- [Heteroscedasticity and limiting factors](../heteroscedasticity-and-limiting-factors/)
- [Checking a quantile regression](../checking-a-quantile-regression/)
- [Standard errors and confidence intervals](../standard-errors-confidence-intervals/)