set.seed(4206)
n <- 200
x <- sort(runif(n))
f <- function(x) sin(2 * pi * x) * (0.8 + 0.9 * x)
sigma <- 0.45
y <- f(x) + rnorm(n, 0, sigma)
## cubic B-spline basis with K functions, equally spaced knots
K <- 20; deg <- 3
rng <- range(x)
nik <- K - deg - 1
dx <- diff(rng) / (nik + 1)
knots <- c(rng[1] - dx * (deg:1),
seq(rng[1], rng[2], length = nik + 2),
rng[2] + dx * (1:deg))
B <- splineDesign(knots, x, ord = deg + 1, outer.ok = TRUE)
## second-order difference penalty on adjacent coefficients
D <- diff(diag(K), differences = 2)
S <- crossprod(D)Penalised regression splines from scratch
Fitting a smooth curve to an ecological gradient looks like a choice of flexibility: a straight line is too stiff, a high-order polynomial bends to every point. Neither extreme is satisfying. A straight line misses real structure; a flexible polynomial chases noise and behaves wildly near the edges of the data. The usual reflex is to tune the polynomial degree, but degree is a coarse dial, and every step adds global wiggliness that shows up worst where you have least data.
Penalised regression splines take a different route. You lay down a generous set of local basis functions, more than you think you need, and then discourage the fitted coefficients from varying too quickly between neighbours. A single continuous penalty replaces the discrete degree, and the data decide how much of the available flexibility to spend. This post builds the machinery from a B-spline basis and a difference penalty, selects the penalty by generalised cross-validation, and confirms the hand-coded fit against mgcv::gam.
A basis plus a penalty
We simulate a response along a gradient x on the unit interval. The truth is a growing-amplitude cycle, smooth but not a polynomial, so a fixed-degree polynomial is always slightly wrong.
The basis B has 20 columns, one per local bump. The penalty matrix S measures how fast the coefficients change from one bump to the next. Fitting minimises the usual residual sum of squares plus a multiple of that roughness, so the smoothing parameter lambda trades fit against smoothness continuously.
BtB <- crossprod(B); Bty <- crossprod(B, y)
fit_ps <- function(lam) {
A <- BtB + lam * S
Ai <- solve(A)
beta <- Ai %*% Bty
fit <- as.vector(B %*% beta)
H <- B %*% Ai %*% t(B) # influence matrix
edf <- sum(diag(H)) # effective degrees of freedom
rss <- sum((y - fit)^2)
gcv <- n * rss / (n - edf)^2 # generalised cross-validation
list(beta = beta, fit = fit, edf = edf, gcv = gcv)
}
## select lambda by GCV over a log-spaced grid
lgrid <- 10^seq(-4, 6, length = 200)
gcvv <- sapply(lgrid, function(l) fit_ps(l)$gcv)
p6_lam <- lgrid[which.min(gcvv)]
ps_best <- fit_ps(p6_lam)
ps_unpen <- fit_ps(1e-8)
p6_edf <- ps_best$edf
p6_edfun <- ps_unpen$edfGeneralised cross-validation picks a penalty of 7.49. At that penalty the fit spends 7.1 effective degrees of freedom, well short of the 20 it would use with the penalty switched off. Effective degrees of freedom count how many free parameters the fit behaves as if it has: the penalty has folded twenty basis functions down to about seven worth of flexibility.
Does the hand-coded fit match mgcv?
The point of coding the penalty by hand is to see that nothing is hidden. To check it, we fit the same P-spline through mgcv, which places the knots slightly differently and applies its own identifiability constraint, then compare fitted values.
d <- data.frame(x = x, y = y)
m_gcv <- gam(y ~ s(x, bs = "ps", k = 20, m = c(2, 2)), data = d, method = "GCV.Cp")
m_reml <- gam(y ~ s(x, bs = "ps", k = 20, m = c(2, 2)), data = d, method = "REML")
p6_edfmg <- sum(m_gcv$edf) # total, including intercept
p6_edfreml <- summary(m_reml)$edf # smooth term, REML
p6_maxdiff <- max(abs(ps_best$fit - as.vector(predict(m_gcv))))The two fits agree to 0.0035 at every point: the hand-coded total of 7.1 effective degrees of freedom sits alongside mgcv’s 7.2. Swapping generalised cross-validation for restricted maximum likelihood, the penalty selection recommended by Wood (2011) because it is less prone to under-smoothing, gives a slightly smoother 7.4 degrees of freedom for the smooth term. The machinery is the same either way; only the criterion for lambda differs.
Too stiff, too flexible, or penalised
The three fitting strategies fail and succeed in instructive ways. We compare each against the known truth by root mean squared error, and we ask what each does just outside the data, at x equal to 1.15.
poly4 <- lm(y ~ poly(x, 4)); poly12 <- lm(y ~ poly(x, 12))
truef <- f(x)
rmse <- function(fit) sqrt(mean((fit - truef)^2))
p6_r_p4 <- rmse(fitted(poly4))
p6_r_p12 <- rmse(fitted(poly12))
p6_r_un <- rmse(ps_unpen$fit)
p6_r_pen <- rmse(ps_best$fit)
xe <- 1.15
Be <- splineDesign(knots, xe, ord = deg + 1, outer.ok = TRUE)
p6_ext_pen <- as.vector(Be %*% ps_best$beta)
p6_ext_p12 <- as.vector(predict(poly12, data.frame(x = xe)))Against the truth, the penalised spline reaches an error of 0.082, beating both the low-order polynomial (0.118) and the unpenalised spline (0.152). The ranking is the whole story in miniature. The degree-4 polynomial is too stiff and misses the shape; the unpenalised spline is too flexible and fits the noise; the penalty lands between them without anyone choosing where. The high-order polynomial is worst of all outside the data: at x of 1.15 it predicts -147, a runaway swing, while the spline stays bounded near 0.00.
What the penalty does and does not buy
The penalty turns a discrete modelling decision into an estimated quantity. You no longer commit to a degree; you provide enough basis functions and let generalised cross-validation or restricted maximum likelihood set the effective flexibility. That removes the worst failure of global polynomials, the wild boundary behaviour, and it adapts the fit to the data at hand.
It does not make extrapolation safe. The penalised spline stayed bounded outside the data, but bounded is not correct: near the boundary it drifts towards a straight line, not towards the true continuation. The penalty controls behaviour inside the data, where there is information; outside, the fit follows the basis, not the process. Nothing here tells you how the curve behaves where you have no points, and the smoothness itself is only weakly identified from a single sample. Those limits are the subject of the rest of this cluster: how large the basis should be, what goes wrong when smooth terms compete for the same signal, and how to check a fitted smooth before trusting it.
References
Eilers PHC, Marx BD 1996. Statistical Science 11(2):89-121 (10.1214/ss/1038425655).
Hastie T, Tibshirani R 1986. Statistical Science 1(3):297-310 (10.1214/ss/1177013604).
Craven P, Wahba G 1979. Numerische Mathematik 31(4):377-403 (10.1007/BF01404567).
Wood SN 2011. Journal of the Royal Statistical Society Series B 73(1):3-36 (10.1111/j.1467-9868.2010.00749.x).
Wood SN 2017. 2nd edn. CRC Press. ISBN 978-1-4987-2833-1.