set.seed(4208)
n <- 400
x1 <- runif(n)
x2 <- cos(2 * pi * x1) + rnorm(n, 0, 0.25) # a curved function of x1
g <- function(z) 1.6 * cos(2 * pi * z) # true effect of x1 only
y <- g(x1) + rnorm(n, 0, 0.6) # x2 has no direct effect
d <- data.frame(x1 = x1, x2 = x2, y = y)
p8_cor <- cor(x1, x2)
p8_vif <- 1 / (1 - summary(lm(x2 ~ x1))$r.squared) # linear VIF of x2 given x1Concurvity in additive models
Collinearity is a hazard everyone learns to watch for in linear models: when predictors move together, their coefficients become unstable and their standard errors inflate. Additive models have a subtler version of the same problem, and the usual collinearity checks miss it. Concurvity is the situation where one smooth term can be closely reproduced by a smooth function of the others. When that happens the model cannot tell which term deserves the credit, the individual smooths wobble from sample to sample, and their confidence bands are too narrow.
The reason the standard checks miss it is that concurvity lives in curved relationships. Two variables can have almost no linear correlation, so a variance inflation factor sees nothing, while one is a clean curved function of the other. This post builds exactly that case, shows the harm it does to a fitted smooth, and reproduces the mgcv::concurvity measure by hand.
Zero correlation, high concurvity
We take a covariate x1 on the unit interval and set x2 to a cosine of x1 plus a little noise. Over this range the cosine is symmetric enough that x1 and x2 are almost uncorrelated. The response depends only on x1, through a cosine effect; x2 has no direct role at all.
The linear diagnostics report nothing wrong. The correlation between x1 and x2 is 0.04, and the variance inflation factor of x2 given x1 is 1.00, the value you would see for two unrelated predictors. A collinearity screen based on correlation or variance inflation, the standard advice for linear models, gives this pair a clean pass.
The harm to the fitted smooth
Now fit the additive model two ways: the naive model with both smooths, and the correct model with x1 alone. Because x2 carries the same cosine information as the true effect, the naive model has two routes to the signal and splits it between them.
naive <- gam(y ~ s(x1) + s(x2), data = d, method = "REML")
corr <- gam(y ~ s(x1), data = d, method = "REML")
sm <- summary(naive)
p8_edf_x1 <- sm$edf[1]; p8_p_x1 <- sm$s.pv[1]
p8_edf_x2 <- sm$edf[2]; p8_p_x2 <- sm$s.pv[2]
## distortion of the x1 smooth: compare each fit to the true effect on a grid
xg <- seq(0.02, 0.98, length = 100)
gt <- g(xg) - mean(g(x1)) # centred truth
pn <- predict(naive, data.frame(x1 = xg, x2 = 0), type = "terms")[, "s(x1)"]
pc <- predict(corr, data.frame(x1 = xg), type = "terms")[, "s(x1)"]
p8_rmse_naive <- sqrt(mean((pn - gt)^2))
p8_rmse_corr <- sqrt(mean((pc - gt)^2))The x1 smooth is the term we care about, and concurvity distorts it. Measured against the true effect, the x1 smooth from the naive model sits 0.223 away, more than twice the 0.104 from the x1-only model. Adjusting for a curved copy of the signal has pulled the estimate of interest off target. Meanwhile the noise variable x2 picks up a smooth of 1.0 effective degrees of freedom with a p-value of 0.09: not quite flagged here, but as the simulation below shows, flagged far more often than it should be.
Reproducing the concurvity measure
The concurvity function in mgcv quantifies the overlap. For each smooth it reports how much of that term can be reproduced by the others, on a scale from zero to one. We can reproduce its estimate directly: build the model matrix, project the x2 block onto the space spanned by the intercept and the x1 block, and take the fraction of the x2 block that projection explains.
cfull <- concurvity(naive, full = TRUE)
p8_cw <- cfull["worst", "s(x2)"]; p8_ce <- cfull["estimate", "s(x2)"]
X <- predict(naive, type = "lpmatrix")
idx <- lapply(naive$smooth, function(s) s$first.para:s$last.para)
X2 <- X[, idx[[2]], drop = FALSE]
Xother <- cbind(1, X[, idx[[1]], drop = FALSE])
proj <- Xother %*% solve(crossprod(Xother), t(Xother))
p8_hand <- sum(diag(t(X2) %*% proj %*% X2)) / sum(diag(crossprod(X2)))The hand projection gives 0.805, matching mgcv’s estimate of 0.805 exactly. Its worst-case measure is higher still, 0.91: almost the whole of the x2 smooth lives inside the space the x1 smooth already spans. A concurvity that high is a warning that the split between the two terms is close to arbitrary.
How often does the noise slip through?
A single fit understates the instability. We resample the data and refit, tracking the magnitude the model assigns to each smooth, and we run a separate simulation counting how often the noise variable is declared significant when it has no effect.
set.seed(7)
Bb <- 300; s2mag <- numeric(Bb)
for (b in 1:Bb) {
ii <- sample(n, replace = TRUE); db <- d[ii, ]
mb <- gam(y ~ s(x1) + s(x2), data = db, method = "REML")
s2mag[b] <- sd(predict(mb, type = "terms")[, "s(x2)"])
}
p8_boot_med <- median(s2mag)
p8_boot_lo <- quantile(s2mag, 0.05); p8_boot_hi <- quantile(s2mag, 0.95)
set.seed(2020)
Bm <- 500; sig <- 0
for (b in 1:Bm) {
a1 <- runif(n); a2 <- cos(2 * pi * a1) + rnorm(n, 0, 0.25)
yy <- g(a1) + rnorm(n, 0, 0.6)
if (summary(gam(yy ~ s(a1) + s(a2), method = "REML"))$s.pv[2] < 0.05) sig <- sig + 1
}
p8_type1 <- sig / BmAcross resamples the size the model gives to the x2 smooth ranges from 0.03 to 0.30, with a median of 0.17. The partition is unstable: some samples hand the noise variable a real share of the signal. And in the null simulation the noise variable is flagged at the 0.05 level 9% of the time, close to twice the nominal rate. This is the same anti-conservative failure Ramsay and colleagues described for concurvity in air pollution time series: the standard errors are too small, so the tests reject too often.
Living with concurvity
Concurvity, like collinearity, is a property of the design, not a flaw in the estimator. No cleverer fitting method separates the effects of x1 and x2 when the data cannot: if one smooth is a curved copy of another, their individual contributions are not identifiable, and the worst-case concurvity measures exactly how badly. The remedies are the same as for collinearity in spirit. If x2 is a proxy or a nuisance, drop it, as the x1-only fit shows the effect of interest is then recovered cleanly. If both are of scientific interest and genuinely interact, model that interaction directly rather than as two competing marginal smooths. If neither is possible, report the concurvity and treat the separate effects as what they are, weakly identified.
The practical habit is to run concurvity alongside k.check whenever a model has several smooths, and to remember that a clean correlation matrix is not evidence of independence. The dependence that hurts an additive model is curved, and only a measure built for curves will see it.
References
Ramsay TO, Burnett RT, Krewski D 2003. Epidemiology 14(1):18-23 (10.1097/00001648-200301000-00009).
Buja A, Hastie T, Tibshirani R 1989. The Annals of Statistics 17(2):453-510 (10.1214/aos/1176347115).
Dormann CF, Elith J, Bacher S, Buchmann C, Carl G, et al. 2013. Ecography 36(1):27-46 (10.1111/j.1600-0587.2012.07348.x).
Wood SN 2017. 2nd edn. CRC Press. ISBN 978-1-4987-2833-1.
Zuur AF, Ieno EN, Walker NJ, Saveliev AA, Smith GM 2009. Springer. ISBN 978-0-387-87457-9.