Salem Nkunda Nyisingize

Generalized Additive Models: splines, penalties and smoothing

How to let the data choose the shape of a relationship — without overfitting. Animations, equations, and Python you can run directly in this page.

For recruiters (quick read)

A GAM replaces the coefficients of a linear regression with estimated smooth functions, while remaining interpretable. It is the middle ground between linear regression (too rigid) and black-box methods (unreadable). What this topic demonstrates:

  • Regularisation: the roughness penalty is ridge regression in a spline basis.
  • Bias-variance trade-off: controlled continuously by a single parameter λ, measurable through effective degrees of freedom.
  • Model selection: λ chosen by GCV or REML, without expensive cross-validation.
  • Numerical linear algebra: hat matrix, trace, well-conditioned penalised systems.
Penalised splines Regularisation GCV / REML mgcv Interpretable models

Why additive models?

Linear regression makes a strong assumption: the effect of every variable is a straight line. Most real phenomena are not that polite. Temperature raises crop yield up to an optimum, then destroys it. A drug dose helps, then harms. Traffic to a website follows a shape across the day, not a slope.

One could add \(x^2\) or \(x^3\) terms, or bin the variable into categories — but those choices are arbitrary and fragile. The generalized additive model asks the question differently: what if we let the data decide the shape of the curve, penalising only its irregularity?

01 / 06Opening titles: splines, penalisation, smoothing.

The idea in one line. A GAM is written \(\;g(\mu) = \beta_0 + \sum_j f_j(x_j)\;\): each \(f_j\) is a smooth function estimated from the data, with its flexibility controlled by a penalty rather than fixed in advance.

From linear to additive

Start from the linear model \(y = \beta_0 + \beta_1 x + \varepsilon\). Fitted by least squares to curved data, it leaves obvious structure in the residuals: they are not scattered at random about the line, they follow a pattern. That is the signature of systematic information the model failed to capture.

02 / 06 The line's residuals (orange) collapse once \(\beta_1 x\) is replaced by a smooth function \(f(x)\).

Linear model
Shape imposed
\[y_i = \beta_0 + \beta_1 x_i + \varepsilon_i\]

A single degree of freedom for the shape: the slope. High bias whenever the true relationship curves.

Additive model
Shape learned
\[y_i = \beta_0 + f(x_i) + \varepsilon_i\]

\(f\) lives in a space of smooth functions. It remains to define that space — and to stop \(f\) from passing through every point.

A smooth is a sum of bases

To estimate a function, write it as a linear combination of known elementary functions, called basis functions:

Basis representation
\[f(x) = \sum_{k=1}^{K} \beta_k\, B_k(x)\]

This step is decisive: estimating a function (an infinite-dimensional object) becomes estimating a vector of coefficients \(\beta\) — an ordinary linear regression problem on the design matrix \(B\). The usual \(B_k\) are B-splines: piecewise polynomial bumps, each non-zero only over a short interval. That locality is what makes the fit stable.

03 / 06 Basis functions appear, are scaled by their \(\beta_k\), then summed into a single curve.

Building the basis by hand

The code below really runs in your browser (Python compiled to WebAssembly). Edit the values and run it again: the first run takes a few seconds while Python loads.

PYTHON · NUMPY
Why partition of unity matters. B-splines sum to 1 at every point. A constant is therefore exactly representable, and each coefficient \(\beta_k\) reads as the local height of the curve — which makes the penalty of the next section entirely natural.

The roughness penalty and λ

With \(K\) large, ordinary least squares threads the curve through nearly every point: overfitting. The remedy is not to shrink \(K\) — a coarse, discrete choice — but to add a penalty term that charges the fit for being wiggly:

Penalised criterion
\[\hat{\beta} = \arg\min_{\beta}\; \underbrace{\|y - B\beta\|^2}_{\text{fidelity to data}} \;+\; \lambda \underbrace{\beta^{\top} S \beta}_{\text{roughness}}\]

The matrix \(S\) measures irregularity. In the P-spline approach of Eilers and Marx one takes \(S = D^{\top}D\), where \(D\) is the second-difference operator: penalising \(\beta^\top S\beta\) amounts to penalising abrupt changes between neighbouring coefficients. The solution is explicit:

Penalised solution (ridge in the basis)
\[\hat{\beta} = (B^{\top}B + \lambda S)^{-1} B^{\top} y\]

04 / 06 λ swept from \(10^{-4}\) to \(10^{4}\): the curve stiffens and the effective degrees of freedom melt from ≈ 29 down to ≈ 2.

Effective degrees of freedom

How do we quantify the complexity actually used? Through the trace of the hat matrix \(H = B(B^{\top}B + \lambda S)^{-1}B^{\top}\), which projects \(y\) onto the fit:

Effective degrees of freedom
\[\mathrm{edf}(\lambda) = \operatorname{tr}(H)\]

This is the continuous generalisation of the “number of parameters”. As \(\lambda \to 0\), \(\mathrm{edf} \to K\); as \(\lambda \to \infty\), the second-difference penalty forces a straight line and \(\mathrm{edf} \to 2\). In between, complexity varies continuously — exactly what the animation shows as a number.

PYTHON · NUMPY

Choosing λ automatically (GCV)

Leaving λ to the user's judgement would be an admission of defeat. Leave-one-out cross-validation has a remarkable closed form here: for a linear smoother, the cross-validation error can be computed without ever refitting. Its stabilised version is generalized cross-validation:

GCV criterion
\[\mathrm{GCV}(\lambda) = \frac{n\,\|y - Hy\|^2}{\left(n - \operatorname{tr}(H)\right)^2}\]

The numerator rewards fit, the denominator punishes complexity: the larger \(\operatorname{tr}(H)\), the smaller the denominator and the worse the score. We minimise over a logarithmic grid of λ. In practice mgcv defaults to REML, which is more resistant to undersmoothing, but GCV remains the easiest to understand and to code.

PYTHON · NUMPY
On this data GCV selects \(\lambda \approx 0.93\), giving \(\mathrm{edf} \approx 11.5\) out of the 29 available coefficients. The model therefore uses only a third of its flexibility: the penalty performed model selection, continuously.

Additive structure

Everything above concerned a single variable. The model's strength comes from the assembly: several smooths are summed, one per covariate, each with its own penalty \(\lambda_j\).

Additive model
\[y_i = \beta_0 + f_1(x_{1i}) + f_2(x_{2i}) + \dots + f_p(x_{pi}) + \varepsilon_i\]

05 / 06 Three partial effects, each readable on its own — the decisive advantage of GAMs over black boxes.

This is where interpretability is won. Gradient boosting or a neural network would capture these relationships too, but without letting you plot “the effect of \(x_2\), all else held equal”. Here each \(f_j\) is a standalone graphical object, with its confidence band. The price: interactions are not captured by default — they must be requested explicitly (see Limits).

In practice with mgcv

Everything we coded by hand is industrialised in the R package mgcv, written by Simon Wood himself. The code below does not run in the browser (it requires R), but it is the natural destination of this article:

# --- R / mgcv --- library(mgcv) # one smooth per covariate; k = basis dimension, bs = spline type m <- gam(y ~ s(x1, k = 20, bs = "tp") + s(x2) + x3, family = gaussian, method = "REML", data = df) summary(m) # edf per term, approximate p-values, deviance explained plot(m, pages = 1, shade = TRUE) # the partial effects, one panel each gam.check(m) # diagnostics: is k large enough? residuals sane? # count response, with a 2-D interaction and a cyclic effect m2 <- gam(n ~ te(temp, day) + s(month, bs = "cc", k = 12), family = nb, method = "REML", data = df)
ArgumentRoleGuidance
kBasis dimension (our \(K\))Be generous; the penalty sorts it out. Verify with gam.check.
bs = "tp"Thin-plate splineRobust default, rotation-invariant, works in more than one dimension.
bs = "cc"Cyclic splineFor month, hour, angle — enforces \(f(\text{start}) = f(\text{end})\).
method = "REML"Smoothness selectionPreferred over GCV: less prone to undersmoothing.
select = TRUEExtra shrinkageAllows a term to be penalised to exactly zero.

Limits and extensions

Assumption or limitIf it bites → remedy
Purely additive effectsTensor products: te(x1, x2) for a smooth interaction across different scales
Independent observationsGAMM: random effects, temporal or spatial correlation
Only the mean is modelledGAMLSS: smooths on variance, skewness and kurtosis too
Large datasetsbam(): block-wise fitting, parallelisable
p-values for smooth termsApproximate (the edf is itself estimated) — read them with care
Extrapolation beyond the dataDangerous: the penalty constrains nothing outside the observed range
The classic trap. Choosing \(k\) too small breaks everything: no penalty can rescue a basis incapable of representing the true function. Choosing \(k\) too large costs computation time and nothing else. When in doubt, raise \(k\) and let λ decide.

References

  • Wood, S. N. (2017). Generalized Additive Models: An Introduction with R, 2nd ed., Chapman & Hall/CRC — the reference, and the source of this article.
  • Eilers, P. H. C. & Marx, B. D. (1996). Flexible smoothing with B-splines and penalties, Statistical Science — the founding P-spline paper.
  • Hastie, T. & Tibshirani, R. (1990). Generalized Additive Models, Chapman & Hall — where the model originates.
  • Online resources: mgcv on CRANGAMs in R (Noam Ross)Pyodide (the Python engine of this page)

The animations in this article were produced with Manim; the source for all six scenes is available on GitHub.