Generalized Additive Models

Michael BrenndoerferOctober 25, 202645 min read

Learn generalized additive models from scratch: spline bases, difference penalties, link functions, identifiability, cross-validated smoothing, and NumPy fits.

Machine Learning from ScratchAll chapters
Part 5 of 15Linear and Generalized Linear Models
Chapter 39Generalized Additive Models
Reading the full chapter · 10 sections · 45 min10 sections · 45 min

All 10 sections · 45 min read

Generalized Additive ModelsFull chapter · Sections in this chapter1 / 10 ↓
Reading tools Definitions, links & notes

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Notes & comments

Generalized Additive Models

A regression can miss a pattern even when every predictor has a clear relationship with the response: a straight-line term cannot follow a sine-shaped association or a U-shaped one. Spline Regression gave us curves for an individual predictor. Combining several curves raises two further questions: how do we keep their contributions identifiable, and how do we choose their flexibility without judging them on the same observations used for fitting?

Generalized additive models (GAMs) fill that gap. They keep the additive structure of a linear model, replace each linear term with a smooth function, and let us pair that structure with the link functions familiar from generalized linear models. Formally, a GAM has the form

g(E[y∣x])=β0+f1(x1)+f2(x2)+⋯+fp(xp),g(\mathbb{E}[y \mid \mathbf{x}]) = \beta_0 + f_1(x_1) + f_2(x_2) + \cdots + f_p(x_p),

where:

  • gg: the link function connecting the conditional mean to the additive predictor
  • E[y∣x]\mathbb{E}[y \mid \mathbf{x}]: the conditional mean of the response given the predictors
  • β0\beta_0: the intercept
  • fjf_j: a smooth function of predictor xjx_j
  • xjx_j: the jj-th predictor value, with j=1,…,pj=1,\ldots,p and pp predictors

Two questions guide the implementation:

  • how to estimate the amount of flexibility (the smoothing parameter λ\lambda) without leaking information from validation into training,
  • how to interpret a fitted additive model, including what it cannot capture (interactions).

This chapter develops those ideas from first principles. We will build a GAM by hand using spline bases and a difference penalty, show how the fit reduces to a closed-form ridge-style solve, extend the framework to non-Gaussian responses via a link function, and demonstrate the whole pipeline on a synthetic dataset where the true component functions are known. Implementation is done in NumPy for the mechanism and compared against statsmodels' GLMGam for a reference. We will close with practical guidance on when GAMs make sense and what to watch out for.

Advantages

The additive restriction limits the number of functions that need estimating. For example, a Cartesian grid with mm positions per predictor contains mpm^p grid points, while pp separate one-dimensional grids contain pmpm positions. A GAM imposes that lower-dimensional additive structure rather than estimating an unrestricted surface. The comparison describes the representation, not a universal sample-size bound. The additive restriction represents a sum of pp one-dimensional functions instead of an unrestricted surface. Their coefficients still have to be fitted jointly, and computation grows with the total basis dimension.

Second, a GAM remains interpretable. Because the components enter additively, we can plot each fjf_j as a curve and read off how the predicted value (or the transformed mean) changes as a function of xjx_j, holding the others fixed. These are direct plots of model terms on the additive-predictor scale. With a nonlinear link, they differ from response-scale partial-dependence curves, which average predictions over other inputs; see Part XI: Model Interpretation. That makes GAMs attractive in domains where the answer "the model predicts this" is not enough and stakeholders want to know why.

Third, a GAM extends the machinery of generalized linear models to non-Gaussian responses without abandoning interpretability. We can use the same spline bases we built for continuous responses inside a logistic, Poisson, or gamma regression by adding a link function. This gives us nonlinear logistic and count regression through penalized likelihood. As a consequence, when a linear GLM is clearly misspecified, such as a binary outcome whose logit is obviously curved in a predictor, a GAM is a natural upgrade.

Finally, the smoothing penalty plays the role of a continuous regularizer. Rather than choosing the number of knots arbitrarily, we let the data guide how wiggly each curve should be via cross-validation or marginal likelihood. This is the same idea ridge regularization will formalize in the next chapter Ridge Regularization (L2 Regularization); in fact, a GAM with a difference penalty is a generalized quadratic-penalty regression on an expanded feature space; the penalty matrix differs from ordinary ridge.

Disadvantages

GAMs are not free. Their main limitation is that the additive structure itself is a strong assumption. If the true relationship involves an interaction between two predictors, say the effect of x1x_1 flips sign depending on x2x_2, an additive model cannot represent it. We can add explicit tensor-product smooths f12(x1,x2)f_{12}(x_1, x_2) to handle interactions, but each interaction term costs us both flexibility and sample size, and the resulting components are much harder to plot and interpret. In high dimensions the number of potential pairwise interactions explodes.

Second, GAMs depend on several tuning choices: the basis dimension and knot arrangement, the penalty order, and the smoothing parameter λ\lambda per component. Choosing those badly produces either an overfit curve that wiggles through noise or an over-smoothed curve that misses real structure. We can automate the choice with cross-validation or generalized cross-validation, but their estimates have sampling variability. Neither criterion guarantees that the selected model will generalize well; strong overlap between smooth terms can also complicate interpretation.

Third, extrapolation is dangerous in a way that is easy to forget. Out-of-domain behavior depends on the basis implementation: it may refuse prediction or impose an extrapolation rule. In particular, the Patsy and statsmodels B-spline transforms used here reject points beyond their boundary knots. A GAM should not be used to predict far outside the training support of any predictor.

A spline expansion also widens the design matrix relative to a linear model of the original predictors. Dense cross-products and coefficient solves then grow with the total basis dimension; specialized implementations can exploit sparsity or low-rank structure. We give the dense costs in Practical Implications.

Component plots show the fitted terms on the additive-predictor scale. They need care when predictors are dependent, because varying one predictor while holding the others fixed can leave the region supported by the data. Overlapping smooth-term spaces can also make individual components unstable.

Formula

A GAM is best read as the answer to a design question: how do we let every predictor bend, yet keep the model as tractable and interpretable as a linear one? The formula answers that question in three interlocking layers, and each layer exists to solve a specific problem.

  1. An additive predictor built from smooths replaces the single straight line of linear regression with a sum of curves. This buys flexibility while preserving the "one term per predictor" structure that makes the model readable.
  2. A link function connects that additive predictor to the mean of the response, so that the same structure can serve a Gaussian, binary, or count outcome.
  3. A smoothness penalty discourages rough fitted curves. Identifiability constraints are also needed, and the amount of smoothing still requires selection.

It helps to see each problem before its formula. Picture two failure modes we must avoid. If we let the smooths be arbitrarily wiggly, they will thread through noise; if we over-restrict them, we are back to a straight line and lose the whole point. The penalty is the dial between these extremes. And if we ignore the scale of the response, the same smooths cannot respect a probability or a count. The link is what fixes the scale. With both ideas in place, the additive predictor is the piece that ties the predictors together. We build the layers in that order of need.

Once the basis is fixed, the additive predictor is linear in its unknown coefficients. For a Gaussian response with an identity link, fitting reduces to a quadratic penalized least-squares problem. Other response families generally require iterative likelihood fitting. The following steps distinguish the predictor, link, and fitting objective.

The additive predictor sums an intercept and one component per predictor for observation ii:

ηi=β0+f1(xi1)+f2(xi2)+⋯+fp(xip),\eta_i = \beta_0 + f_1(x_{i1}) + f_2(x_{i2}) + \cdots + f_p(x_{ip}),

where:

  • i=1,…,ni = 1, \dots, n: index over observations,
  • ηi\eta_i: the value of the additive predictor for observation ii,
  • β0\beta_0: an intercept shared by all observations,
  • pp: the number of predictors,
  • xijx_{ij}: the value of predictor jj for observation ii,
  • fjf_j: a smooth function of predictor jj, not necessarily linear.

If fj(x)=βjxf_j(x)=\beta_j x for every predictor, this becomes the predictor from Multiple Linear Regression. A GAM relaxes that restriction one term at a time: each fjf_j may curve, but the terms still add, so we can still read the model one predictor at a time.

Why insist on additivity instead of fitting one flexible surface f(x1,…,xp)f(x_1, \dots, x_p)? Additivity restricts the model to a smaller family of surfaces. The additive decomposition restricts the unknown surface to a sum of one-dimensional curves, whose coefficients are fitted jointly. That trade between flexibility within each predictor and additivity across predictors is the structural bet of a GAM, and it is exactly what we will exploit when we expand each fjf_j in a basis.

There is a second gap to close. The smooths naturally produce a real number, but the response need not live on the real line: a binary outcome is zero or one, while a finite logit predictor gives a fitted mean in (0,1)(0,1). A count mean need not be an integer; a log link keeps it positive. The link function bridges that gap:

g(μi)=ηi,μi=E[yi∣xi].g(\mu_i) = \eta_i, \qquad \mu_i = \mathbb{E}[y_i \mid \mathbf{x}_i].

Here gg is a monotone, differentiable link function. Its role is to connect the permissible mean to the additive predictor. The log and logit links map their mean domains onto the real line; an inverse link has a restricted predictor domain and requires corresponding constraints. Common choices are:

  • identity link g(μ)=μg(\mu) = \mu for Gaussian regression (this is the case we will spend most time on),
  • logit link g(μ)=log⁡(μ/(1−μ))g(\mu) = \log(\mu / (1 - \mu)) for binary responses,
  • log link g(μ)=log⁡(μ)g(\mu) = \log(\mu) for counts,
  • inverse link g(μ)=1/μg(\mu) = 1/\mu for gamma or inverse-Gaussian responses.

For a Gaussian GAM with identity link, the model predicts the conditional mean directly. For a binary GAM with logit link, the model predicts the log-odds of success, and each fjf_j describes how the log-odds change as a smooth function of xjx_j. For a count GAM with log link, each fjf_j describes how the log expected count changes.

Notice the clean division of labor this creates: the smooths describe shape on the scale of η\eta, and the link describes scale. That separation is what allows one spline-and-penalty mechanism to serve radically different response types without being redesigned for each.

We are foreshadowing here because the later chapter Generalized Linear Models develops the link-function framework in full. For this chapter the important point is that the same additive machinery, namely smooths, penalties, and penalized least-squares solves, extends to suitable GLM families and links through penalized likelihood, with additional iterative fitting (iterative reweighting, which we will sketch but not build from scratch).

Spline bases and smoothness penalties

To compute the fit using finitely many coefficients, we represent each smooth fjf_j with a spline basis:

fj(x)=∑k=1Kjβjk bjk(x),f_j(x) = \sum_{k=1}^{K_j} \beta_{jk} \, b_{jk}(x),

where:

  • KjK_j is the number of basis functions we allocate to predictor jj,
  • bjk(x)b_{jk}(x) is the kk-th basis function, a known deterministic function of xx,
  • βjk\beta_{jk} are unknown coefficients we will estimate.

We use B-spline basis functions: low-degree piecewise polynomials with local support. Natural cubic spline bases are another option, but their individual basis functions need not have local support. Taken together, they form a flexible family of curves. The dimension KjK_j controls the maximum possible wiggliness: with more basis functions we can represent more complex shapes.

A mental picture makes the next step natural. Each bjkb_{jk} is a localized bump, and the fitted smooth is a weighted sum of those bumps, with the weights given by the coefficients βjk\beta_{jk}. Increasing KjK_j adds more bumps and therefore more potential detail, but it also adds more coefficients that can conspire to fit noise.

Substituting the basis expansion into the additive predictor gives

ηi=β0+∑j=1p∑k=1Kjβjk bjk(xij).\eta_i = \beta_0 + \sum_{j=1}^p \sum_{k=1}^{K_j} \beta_{jk} \, b_{jk}(x_{ij}).

This is now linear in the coefficients βjk\beta_{jk}. If we stack all coefficients into a single vector β\boldsymbol\beta and expand each predictor into its basis, the model becomes η=Bβ\boldsymbol\eta = \mathbf{B}\boldsymbol\beta for an augmented design matrix B\mathbf{B} whose columns are the basis functions evaluated at each observation.

We now reach the second problem hinted at earlier. With enough independent basis columns, a spline can interpolate a finite sample at distinct inputs. Fitting noise that closely can hurt predictions on new observations. Too small a basis can instead exclude real structure. Rather than solve the flexibility problem by choosing the basis size, we keep the basis generous and control curvature directly. We penalize it with a quadratic penalty on the (second) differences of neighboring coefficients:

Pen(fj)=λj∑k=3Kj(βjk−2βj,k−1+βj,k−2)2.\text{Pen}(\mathbf{f}_j) = \lambda_j \sum_{k=3}^{K_j} \left( \beta_{jk} - 2\beta_{j,k-1} + \beta_{j,k-2} \right)^2.

This is the coefficient-difference penalty used in P-splines. Classical P-splines pair it with regularly spaced B-splines; our later Patsy example uses quantile knots with the same coefficient-difference regularizer, so its smoothing is tied to that parameterization. Each difference measures curvature in coefficient-index space. For smooth coefficient sequences on a fine, uniform knot grid with compatible boundary treatment, appropriate knot-spacing scaling makes this a discrete approximation to a derivative penalty. It is not generally identical to integrating the squared second derivative of the curve. Squaring and summing accumulates a measure of total curvature, so the penalty grows when the fitted coefficients oscillate from one basis function to the next. The parameter λj≥0\lambda_j \ge 0 controls how strongly that penalty acts:

  • λj=0\lambda_j = 0 gives an unpenalized spline, which can be as wiggly as the basis allows;
  • λj→∞\lambda_j \to \infty forces the coefficient sequence to be affine in its index; the resulting curve depends on the knot arrangement;
  • moderate λj\lambda_j interpolates between the two, giving the data room to bend but discouraging noise.

The penalty is deliberately a local statement about neighboring coefficients. It does not insist that the whole curve be flat; it only makes sharp reversals from one coefficient to the next expensive. That is why a moderate λj\lambda_j still permits broad bends (like a sinusoid) while a huge one suppresses the penalized directions. An affine coefficient sequence need not produce a globally straight line with quantile knots and repeated boundary knots.

PythonIn [3]
CollapseExpand
k_coef = 16
index = np.arange(k_coef)
D_pen = np.diff(np.eye(k_coef), n=2, axis=0)
S_pen = D_pen.T @ D_pen
coef_sequences = {
    "constant": np.ones(k_coef),
    "affine": 0.6 * index - 2.0,
    "sinusoid": 1.5 * np.sin(2.0 * np.pi * index / (k_coef - 1)),
    "alternating": np.where(index % 2 == 0, 1.0, -1.0),
}
penalty_values = {
    name: float(np.sum((D_pen @ coef) ** 2))
    for name, coef in coef_sequences.items()
}
VisualizationOut [4]
CollapseExpand
Four coefficient patterns with annotated second-difference penalties.
Second-difference penalty across four coefficient patterns. Constant and affine sequences incur zero penalty because they lie in the null space, while the alternating sequence pays the most, showing how the penalty charges for oscillation.

In matrix form the penalty for smooth jj can be written as

Pen(fj)=λj βj⊤D⊤D βj=λj βj⊤Sj βj,\text{Pen}(\mathbf{f}_j) = \lambda_j \, \boldsymbol\beta_j^\top \mathbf{D}^\top \mathbf{D} \, \boldsymbol\beta_j = \lambda_j \, \boldsymbol\beta_j^\top \mathbf{S}_j \, \boldsymbol\beta_j,

where:

  • βj=(βj1,…,βjKj)⊤\boldsymbol\beta_j = (\beta_{j1}, \dots, \beta_{jK_j})^\top collects the smooth coefficients,
  • D\mathbf{D} is the (Kj−2)×Kj(K_j - 2) \times K_j second-difference matrix, each row of the form (0,…,0,1,−2,1,0,…,0)(0, \dots, 0, 1, -2, 1, 0, \dots, 0),
  • Sj=D⊤D\mathbf{S}_j = \mathbf{D}^\top \mathbf{D} is a Kj×KjK_j \times K_j symmetric positive semidefinite matrix that encodes the penalty.

Putting the pieces together, the Gaussian GAM is fit by minimizing

L(β)=∑i=1n(yi−β0−∑j=1p∑k=1Kjβjk bjk(xij))2+∑j=1pλj βj⊤Sjβj.\mathcal{L}(\boldsymbol\beta) = \sum_{i=1}^n \left( y_i - \beta_0 - \sum_{j=1}^p \sum_{k=1}^{K_j} \beta_{jk} \, b_{jk}(x_{ij}) \right)^2 + \sum_{j=1}^p \lambda_j \, \boldsymbol\beta_j^\top \mathbf{S}_j \boldsymbol\beta_j.

Read the two terms as a negotiation. The first term asks the curve to explain the data; the second asks it to stay smooth. The smoothing parameters λj\lambda_j set the exchange rate between those demands.

In matrix notation, augmenting B\mathbf{B} with a leading column of ones for the intercept and stacking the pp coefficient blocks,

L(β)=∥y−Bβ∥22+β⊤Sβ,\mathcal{L}(\boldsymbol\beta) = \| \mathbf{y} - \mathbf{B}\boldsymbol\beta \|_2^2 + \boldsymbol\beta^\top \mathbf{S} \boldsymbol\beta,

where S\mathbf{S} is the block-diagonal matrix with zero in the intercept block and λjSj\lambda_j \mathbf{S}_j in the block for smooth jj. After imposing centering, the following matrix formulas use the free coefficients and the correspondingly transformed design and penalty; the symbol β\boldsymbol\beta now denotes those identifiable coordinates. Setting the gradient of L\mathcal{L} with respect to β\boldsymbol\beta equal to zero and rearranging (exactly as we did for OLS in Ordinary Least Squares (OLS)) gives the closed-form solution

β^=(B⊤B+S)−1B⊤y.\hat{\boldsymbol\beta} = \left( \mathbf{B}^\top \mathbf{B} + \mathbf{S} \right)^{-1} \mathbf{B}^\top \mathbf{y}.

This is identical in form to ridge regression, with S\mathbf{S} playing the role of a feature-specific ridge penalty. The inverse exists only when the design identifies every direction left unpenalized: ker⁡(B)∩ker⁡(S)={0}\ker(\mathbf{B}) \cap \ker(\mathbf{S}) = \{0\}. Indeed, for any coefficient direction v\mathbf{v}, v⊤(B⊤B+S)v=∥Bv∥2+v⊤Sv\mathbf{v}^\top(\mathbf{B}^\top\mathbf{B}+\mathbf{S})\mathbf{v}=\|\mathbf{B}\mathbf{v}\|^2+\mathbf{v}^\top\mathbf{S}\mathbf{v}. A positive smoothing parameter alone cannot remove an unpenalized constant ambiguity. The inverse formula therefore applies after identifiability constraints; in code we solve the system rather than form an inverse. The fitted curve for predictor jj is f^j(x)=∑kβ^jk bjk(x)\hat f_j(x) = \sum_k \hat\beta_{jk} \, b_{jk}(x), and the full fitted predictor is η^i=β0+∑jf^j(xij)\hat\eta_i = \beta_0 + \sum_j \hat f_j(x_{ij}).

For many family and link choices, such as Bernoulli responses with a logit link or Poisson counts with a log link, the penalized likelihood is non-quadratic. These models are commonly fitted by iterative reweighted least squares (IRLS), which replaces one least-squares solve with a sequence of penalized weighted least-squares solves. The likelihood and link jointly determine the objective; a nonlinear inverse link alone does not determine whether it is quadratic. A common Fisher-scoring update is

β^(t+1)=(B⊤W(t)B+S)−1B⊤W(t)z(t),\hat{\boldsymbol\beta}^{(t+1)} = \left( \mathbf{B}^\top \mathbf{W}^{(t)} \mathbf{B} + \mathbf{S} \right)^{-1} \mathbf{B}^\top \mathbf{W}^{(t)} \boldsymbol{z}^{(t)},

where:

  • tt: the IRLS iteration index
  • W(t)\mathbf{W}^{(t)}: the diagonal matrix of GLM working weights at iteration tt, with entries depending on the variance function and link derivative
  • z(t)\boldsymbol{z}^{(t)}: the working response at iteration tt, formed from the current linear predictor and the link
  • S\mathbf{S}: the same block-diagonal penalty matrix as in the Gaussian case

For independent GLM observations, a common working response is zi=ηi+(yi−μi)g′(μi)z_i=\eta_i+(y_i-\mu_i)g'(\mu_i), where g′(μi)g'(\mu_i) is the derivative of the link at the current fitted mean. The corresponding working weight is wi=1/[Var⁡(yi∣xi){g′(μi)}2]w_i=1/[\operatorname{Var}(y_i\mid\mathbf{x}_i)\{g'(\mu_i)\}^2]. The response linearizes the mean relationship locally, while the weights account for conditional variance and the link's slope. These expressions require valid interior means, finite positive variances, and nonzero link derivatives. A common dispersion factor can instead be absorbed into the penalty convention. The resulting Fisher-scoring iteration may need damping and convergence checks; the displayed update is not a guarantee of convergence.

We can retain the same chosen coefficient penalty when changing the response family and link, while changing the working weights and response. Each IRLS step couples the additive coefficients through W(t)\mathbf{W}^{(t)}, and a well-defined solution requires the smooth coefficients to satisfy the identifiability conditions described next.

Mathematical properties

The difference-penalized Gaussian model has several properties worth stating:

  • The second-difference matrix has a two-dimensional null space: constant and affine coefficient sequences have zero second differences. With arbitrary knot spacing this statement concerns coefficients, not a guarantee of exact linearity in the predictor.
  • A constant can be transferred between a smooth and the intercept without changing predictions. We impose ∑ifj(xij)=0\sum_i f_j(x_{ij})=0 on the training observations for each component. This is a constraint during fitting, rather than merely subtracting a mean from a plotted curve afterward.
  • With basis and smoothing parameters fixed, the Gaussian fit is linear in the responses. Its total effective degrees of freedom are tr⁡(H)\operatorname{tr}(\mathbf{H}), where H=B(B⊤B+S)−1B⊤\mathbf{H}=\mathbf{B}(\mathbf{B}^{\top}\mathbf{B}+\mathbf{S})^{-1}\mathbf{B}^{\top} for the identifiable design. This counts the intercept too; a centered component does not generally have degrees of freedom ranging from KjK_j to 22.

The matrix H\mathbf{H} maps observed responses to fitted values: y^=Hy\hat{\mathbf{y}}=\mathbf{H}\mathbf{y}. With the basis and smoothing parameters fixed, its diagonal entry HiiH_{ii} measures how sensitive fitted value ii is to observed response ii. The trace, tr⁡(H)\operatorname{tr}(\mathbf{H}), sums these diagonal entries to give an effective parameter count. For an unpenalized, full-column-rank least-squares fit, it equals the number of fitted coefficients. Smoothing shrinks the penalized directions, so the effective count can be fractional.

The next figure isolates one unconstrained, full-rank, 12-column spline basis with no separate intercept. Its total effective degrees of freedom approach the two unpenalized coefficient directions as smoothing grows. This is a separate demonstration from the centered two-feature model, where the intercept and each constraint affect the count.

PythonIn [5]
CollapseExpand
import numpy as np
import patsy

edf_x = np.linspace(-3.0, 3.0, 120)
edf_basis = patsy.dmatrix(
    "0 + bs(edf_x, df=12, degree=3, include_intercept=True, "
    "lower_bound=-3, upper_bound=3)",
    {"edf_x": edf_x},
    return_type="matrix",
)
edf_B = np.asarray(edf_basis)
edf_D = np.diff(np.eye(edf_B.shape[1]), n=2, axis=0)
edf_S = edf_D.T @ edf_D
edf_lambdas = np.logspace(-4, 4, 80)
edf_values = np.empty_like(edf_lambdas)
for i, lam in enumerate(edf_lambdas):
    edf_A = edf_B.T @ edf_B + lam * edf_S
    edf_values[i] = np.trace(np.linalg.solve(edf_A, edf_B.T @ edf_B))
VisualizationOut [6]
CollapseExpand
Effective degrees of freedom fall from twelve toward two as lambda increases on a log scale.
Effective degrees of freedom for one penalized cubic B-spline smooth across a log-spaced grid of smoothing parameters. The curve declines from near the full basis dimension and approaches two degrees of freedom as lambda grows because constant and affine coefficient directions are unpenalized.

For independent errors of variance σ2\sigma^2 and fixed smoothing parameters, write A=B⊤B+S\mathbf{A}=\mathbf{B}^{\top}\mathbf{B}+\mathbf{S}. Then Cov⁡(β^)=σ2A−1B⊤BA−1\operatorname{Cov}(\hat{\boldsymbol\beta})=\sigma^2\mathbf{A}^{-1}\mathbf{B}^{\top}\mathbf{B}\mathbf{A}^{-1}. Here Cov⁡\operatorname{Cov} collects the sampling covariances of the estimated free coefficients. Its diagonal entries are their variances; their square roots give standard errors conditional on the fixed design and smoothing choices. The factor A−1B⊤\mathbf{A}^{-1}\mathbf{B}^{\top} maps response noise to coefficient noise. These conditional sampling variances do not include smoothing-selection uncertainty or remove smoothing bias.

Visualizing Generalized Additive Models

Before we fit anything, we need to see the shape of the problem. Suppose we generate a dataset whose two predictors each have a known non-linear effect on a Gaussian response:

yi=1.0+sin⁡(xi1)+(0.5 xi22−0.5 xi2)+εi,εi∼N(0,σ2),y_i = 1.0 + \sin(x_{i1}) + \left( 0.5 \, x_{i2}^2 - 0.5 \, x_{i2} \right) + \varepsilon_i, \qquad \varepsilon_i \sim \mathcal{N}(0, \sigma^2),

with xi1,xi2x_{i1}, x_{i2} drawn uniformly from [−3,3][-3, 3] and σ=0.4\sigma = 0.4. The first component is a sinusoid; the second is a convex quadratic (upward-opening parabola). Both are clearly not linear, and the additive structure holds exactly because the two components enter as a sum.

PythonIn [7]
CollapseExpand
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import patsy
from book_plot_style import (
    PALETTE,
    polish_axes,
    seed_book_plots,
    use_book_style,
)
from scipy import stats

n = 400
rng = np.random.default_rng(0)
x1 = rng.uniform(-3.0, 3.0, n)
x2 = rng.uniform(-3.0, 3.0, n)
f1_true = np.sin(x1)
f2_true = 0.5 * x2**2 - 0.5 * x2
y = 1.0 + f1_true + f2_true + rng.normal(0.0, 0.4, n)
order1 = np.argsort(x1)
order2 = np.argsort(x2)
partial_y1 = y - f2_true - 1.0
partial_y2 = y - f1_true - 1.0
partial_y1_centered = partial_y1 - f1_true.mean()

Two quick plots showing each predictor against the response, with the true component overlaid, make the additive structure visible and give us a reference to check the fit against later.

VisualizationsOut [8]
CollapseExpand
Scatter of y minus the x2 contribution and intercept against x1 with a sine curve overlaid.
Response with the x2 contribution and intercept removed plotted against x1, alongside the true sine curve. The clear non-linearity shows what the spline fit must recover.
Scatter of y minus the x1 contribution and intercept against x2 with a quadratic curve overlaid.
Response with the x1 contribution and intercept removed plotted against x2, alongside the true upward-opening parabola. The curvature confirms that a linear term would miss real structure in this predictor.

These are oracle partial responses: the generating components are known only because the data are synthetic. In observed data, we cannot subtract the unknown true contribution of another predictor. The model instead estimates both components jointly; finite noisy samples and smoothing mean that recovery is approximate.

The other thing to visualize is the spline basis itself, since the smooth is a linear combination of these curves. With a modest number of basis functions, each is a localized bump, and the fitted smooth is determined by the weights we put on them.

PythonIn [9]
CollapseExpand
grid = np.linspace(-3.0, 3.0, 300)
B_grid = patsy.dmatrix(
    "0 + bs(grid, df=8, degree=3, include_intercept=True)",
    {"grid": grid},
    return_type="matrix",
)
VisualizationOut [10]
CollapseExpand
Eight locally supported cubic B-spline curves across the domain, including one-sided boundary curves.
A cubic B-spline basis with 8 basis functions evaluated on a regular grid. Each basis curve is a localized bump, so a weighted sum of these curves can produce a smooth function within the knot domain.

Each basis curve contributes only to a short segment of the xx range, and they overlap smoothly so that a weighted sum produces a smooth curve within the knot domain. Increasing the number of basis functions makes the space of possible curves richer. The job of λ\lambda is to determine how aggressively we smooth the resulting weighted sum.

Example

The formula above is compact, but the algebra inside the penalized solve deserves to be seen once by hand. We now work through a tiny example where every matrix can be written out in full, using a deliberately small basis so the arithmetic stays readable. The lesson generalizes directly to cubic B-splines with many more knots; only the numbers change.

Suppose we have three points x=(0.1,0.5,0.9)x = (0.1, 0.5, 0.9) and responses y=(1.2,2.0,1.5)y = (1.2, 2.0, 1.5). On the domain 0≤x≤10 \le x \le 1, place knots at 00, 0.50.5, and 1.01.0, giving three linear hat functions:

h1(x)=max⁡(0,1−2x),h2(x)=1−∣2x−1∣,h3(x)=max⁡(0,2x−1).h_1(x) = \max(0, 1 - 2x), \quad h_2(x) = 1 - |2x - 1|, \quad h_3(x) = \max(0, 2x - 1).

Evaluating these at our three xx values gives the 3×33 \times 3 basis matrix

B0=(0.80.2001.0000.20.8).\mathbf{B}_0 = \begin{pmatrix} 0.8 & 0.2 & 0 \\ 0 & 1.0 & 0 \\ 0 & 0.2 & 0.8 \end{pmatrix}.

We will add an intercept column 1\mathbf{1} as the first column, so the full design becomes

B=(10.80.20101.00100.20.8).\mathbf{B} = \begin{pmatrix} 1 & 0.8 & 0.2 & 0 \\ 1 & 0 & 1.0 & 0 \\ 1 & 0 & 0.2 & 0.8 \end{pmatrix}.

The coefficient vector is β=(β0,β1,β2,β3)⊤\boldsymbol\beta = (\beta_0, \beta_1, \beta_2, \beta_3)^\top with β0\beta_0 the intercept and (β1,β2,β3)(\beta_1, \beta_2, \beta_3) giving the smooth.

The unpenalized normal-equation quantities are

B⊤B=(30.81.40.80.80.640.1601.40.161.080.160.800.160.64),B⊤y=(4.70.962.541.20).\mathbf{B}^\top \mathbf{B} = \begin{pmatrix} 3 & 0.8 & 1.4 & 0.8 \\ 0.8 & 0.64 & 0.16 & 0 \\ 1.4 & 0.16 & 1.08 & 0.16 \\ 0.8 & 0 & 0.16 & 0.64 \end{pmatrix}, \qquad \mathbf{B}^\top \mathbf{y} = \begin{pmatrix} 4.7 \\ 0.96 \\ 2.54 \\ 1.20 \end{pmatrix}.

These come from the same rules as OLS: each entry of B⊤B\mathbf{B}^\top \mathbf{B} is a dot product of two columns of B\mathbf{B}, and each entry of B⊤y\mathbf{B}^\top \mathbf{y} is a dot product of a column of B\mathbf{B} with the response. If we stopped here we would just be doing OLS on the basis expansion, flexible in principle, but with no safeguard against a wiggly fit. The penalty is what adds that safeguard.

The penalty comes next. Our smooth has three coefficients, so the second-difference matrix D\mathbf{D} has 3−2=13 - 2 = 1 row, D=(1,−2,1)\mathbf{D} = (1, -2, 1). The full penalty matrix has a zero row and column for the intercept (the intercept is not penalized) and D⊤D\mathbf{D}^\top \mathbf{D} in the smooth block:

P=(000001−210−24−201−21).\mathbf{P} = \begin{pmatrix} 0 & 0 & 0 & 0 \\ 0 & 1 & -2 & 1 \\ 0 & -2 & 4 & -2 \\ 0 & 1 & -2 & 1 \end{pmatrix}.

Take λ=0.1\lambda = 0.1. The penalized system is

(B⊤B+λP)β=B⊤y,\left( \mathbf{B}^\top \mathbf{B} + \lambda \mathbf{P} \right) \boldsymbol\beta = \mathbf{B}^\top \mathbf{y},

with

B⊤B+λP=(30.81.40.80.80.74−0.040.11.4−0.041.48−0.040.80.1−0.040.74).\mathbf{B}^\top \mathbf{B} + \lambda \mathbf{P} = \begin{pmatrix} 3 & 0.8 & 1.4 & 0.8 \\ 0.8 & 0.74 & -0.04 & 0.1 \\ 1.4 & -0.04 & 1.48 & -0.04 \\ 0.8 & 0.1 & -0.04 & 0.74 \end{pmatrix}.

Now compare the matrix above with the unpenalized B⊤B\mathbf{B}^\top \mathbf{B}. The off-diagonal entries (the −0.04-0.04 and 0.10.1 entries) are the change produced by the penalty. Those couplings come from second coefficient differences; they are the mechanism by which curvature is made expensive.

The displayed four-parameter system is still singular: the three hat functions sum to one, so their constant direction duplicates the intercept. Complete the fit by requiring the smooth to sum to zero at the three observations. Its constraint row is c⊤=(0,0.8,1.4,0.8)\mathbf{c}^{\top}=(0,0.8,1.4,0.8). We solve the normal equations and that constraint together, using a Lagrange multiplier (an extra unknown that enforces the constraint).

PythonIn [11]
CollapseExpand
toy_B = np.array(
    [[1.0, 0.8, 0.2, 0.0], [1.0, 0.0, 1.0, 0.0], [1.0, 0.0, 0.2, 0.8]]
)
toy_y = np.array([1.2, 2.0, 1.5])
toy_d = np.array([[0.0, 1.0, -2.0, 1.0]])
toy_P = toy_d.T @ toy_d
toy_A = toy_B.T @ toy_B + 0.1 * toy_P
toy_c = np.array([0.0, 0.8, 1.4, 0.8])
toy_system = np.block(
    [[toy_A, toy_c[:, None]], [toy_c[None, :], np.zeros((1, 1))]]
)
toy_beta = np.linalg.solve(toy_system, np.r_[toy_B.T @ toy_y, 0.0])[:4]
toy_predictions = toy_B @ toy_beta
assert np.allclose(toy_c @ toy_beta, 0.0)
ConsoleOut [12]
CollapseExpand
Constrained coefficients: [ 1.5667 -0.3832  0.2237 -0.0082]
Fitted responses: [1.3048 1.7903 1.6048]
Squared error: 0.065947

The intercept is the sample response mean because the fitted smooth is centered. The fitted responses no longer interpolate all observations at this positive penalty.

If we now think about the extreme choices:

  • With λ=0\lambda = 0 we recover the unpenalized fit, which on these three points fits every observation exactly (because the 3×43 \times 4 design, once the smooth is constrained by identifiability, leaves enough freedom to interpolate all three points), including any noise.
  • With λ→∞\lambda \to \infty the penalty forces β1−2β2+β3=0\beta_1 - 2\beta_2 + \beta_3 = 0, meaning the fitted curve is exactly a straight line (a linear trend in the hat basis).
  • For intermediate λ\lambda (here 0.10.1) the fit is a compromise that follows the data closely without allowing spurious curvature.

Both fits use a quadratic penalty, but a difference penalty couples neighboring coefficients, whereas ordinary ridge penalizes coefficient magnitudes. Their common normal-equation structure explains the connection. The next chapter Ridge Regularization (L2 Regularization) develops the ridge view of the penalty in more depth.

The worked example shows the arithmetic. The more important question is whether we can recover the sinusoid and the quadratic from the earlier synthetic data. That is what we implement next.

Implementation in NumPy and Statsmodels

We will carry out the fit in two stages. First we implement the GAM from primitives using NumPy, building the design matrix with the B-spline transformation in patsy and constructing the penalty matrix by hand. Then we compare against statsmodels.gam.api.GLMGam, which provides the same kind of model with additional diagnostics. The choice of statsmodels as reference is deliberate: it exposes both the fitted coefficients and the effective degrees of freedom for each smooth, both of which we want for interpretation.

Building the smooth design matrices

The fit proceeds by constructing a B-spline basis for each predictor, stacking them into a single design matrix (with an intercept), and solving the penalized normal equations. We start with 12 basis functions per smooth. Each training-sample centering constraint leaves 11 free smooth coefficients, so the two-feature design has 23 columns including the intercept. The transformation must be applied to the penalty as well as the basis. The bounds [−3,3][-3,3] are known from our synthetic design before seeing the data; interior knots are learned from each training fold. These example helpers deliberately use that fixed domain. They assume finite complete inputs with enough distinct predictor values to construct the basis and an identifiable penalized system. They are a teaching implementation, rather than a general-purpose missing-data or degenerate-design handler.

PythonIn [13]
CollapseExpand
def b_spline_basis(x, df=12, degree=3):
    """Fit a full basis on training values within the known synthetic domain."""
    design = patsy.dmatrix(
        f"0 + bs(x, df={df}, degree={degree}, include_intercept=True, "
        "lower_bound=-3, upper_bound=3)",
        {"x": x},
    )
    return np.asarray(design), design.design_info


def difference_penalty(k, order=2):
    """Quadratic penalty on differences of the full coefficient sequence."""
    D = np.diff(np.eye(k), n=order, axis=0)
    return D.T @ D


def fit_additive_gam(x_list, y, df=12, lam=1.0):
    """Fit centered difference-penalized splines with training basis metadata."""
    y = np.asarray(y, dtype=float)
    if lam < 0 or not np.isfinite(lam) or y.ndim != 1 or len(y) == 0:
        raise ValueError(
            "Use nonempty one-dimensional responses and finite lam >= 0"
        )
    if not x_list or any(np.asarray(x).shape != y.shape for x in x_list):
        raise ValueError("Each predictor must match the response shape")
    blocks = [np.ones((len(y), 1))]
    penalty_blocks = [np.zeros((1, 1))]
    infos, transforms = [], []
    for x in x_list:
        raw, info = b_spline_basis(x, df=df)
        means = raw.mean(axis=0)
        # Write the last coefficient in terms of the others so means @ Q = 0.
        Q = np.vstack([np.eye(df - 1), -means[:-1] / means[-1]])
        blocks.append(raw @ Q)
        penalty_blocks.append(lam * Q.T @ difference_penalty(df) @ Q)
        infos.append(info)
        transforms.append(Q)
    B = np.hstack(blocks)
    P = np.zeros((B.shape[1], B.shape[1]))
    offset = 0
    for block in penalty_blocks:
        k = len(block)
        P[offset : offset + k, offset : offset + k] = block
        offset += k
    beta_hat = np.linalg.solve(B.T @ B + P, B.T @ y)
    return B, P, beta_hat, infos, transforms


def prediction_design(x_list, infos, transforms):
    """Reuse training knots and training centering on new rows."""
    blocks = [np.ones((len(x_list[0]), 1))]
    for x, info, Q in zip(x_list, infos, transforms):
        raw = np.asarray(patsy.build_design_matrices([info], {"x": x})[0])
        blocks.append(raw @ Q)
    return np.hstack(blocks)

Now we fit to the synthetic data and extract the fitted components. Note that each B_j block is multiplied by its own set of coefficients; to obtain the fitted smooth at training points we slice the relevant block.

PythonIn [14]
CollapseExpand
B, P, beta_hat, basis_infos, center_transforms = fit_additive_gam(
    [x1, x2], y, df=12, lam=1.0
)
b0 = beta_hat[0]
n_basis = 11  # 12 full basis functions minus one centering constraint
b1 = beta_hat[1 : 1 + n_basis]
b2 = beta_hat[1 + n_basis : 1 + 2 * n_basis]

f1_hat_train = B[:, 1 : 1 + n_basis] @ b1
f2_hat_train = B[:, 1 + n_basis : 1 + 2 * n_basis] @ b2
y_hat = b0 + f1_hat_train + f2_hat_train
rmse = np.sqrt(np.mean((y - y_hat) ** 2))

We can now compare the fitted smooths against the true curves. Because each smooth is only identified up to a constant (the intercept absorbs it), we center both the fitted and true curves to have zero mean before plotting.

PythonIn [15]
CollapseExpand
f1_hat_centered = f1_hat_train - f1_hat_train.mean()
f2_hat_centered = f2_hat_train - f2_hat_train.mean()
f1_true_centered = f1_true - f1_true.mean()
f2_true_centered = f2_true - f2_true.mean()
order1 = np.argsort(x1)
order2 = np.argsort(x2)
VisualizationsOut [16]
CollapseExpand
Fitted smooth for x1 overlaid on the true centered sinusoid across the data range.
Fitted smooth for predictor x1 against the true generating sinusoid, each centered to zero mean. The fitted curve tracks the true shape closely in the interior of the data range, with some deviation at the boundaries.
Fitted smooth for x2 overlaid on the true centered quadratic across the data range.
Fitted smooth for predictor x2 against the true generating quadratic, each centered to zero mean. The fitted curve tracks the upward-opening parabola closely in the interior, with some boundary deviation.

The fitted smooths can be compared directly with the known generating curves. Deviations reflect sampling noise, the finite basis, and the smoothing penalty; they are not a guarantee of any particular boundary behavior. The relevant residual statistic is computed below.

PythonIn [17]
CollapseExpand
noise_sd = 0.4
ConsoleOut [18]
CollapseExpand
RMSE on training data: 0.366
Noise standard deviation: 0.400

Training RMSE describes the fitted residuals, but cannot establish whether the model overfits or generalizes well. We compare fitted components with the known synthetic truth here and evaluate a selected model on independent test observations below.

Choosing the smoothing parameter

The choice of λ\lambda matters a great deal. We can visualize the trade-off by refitting at several values and plotting the smooth for each. We compute the fits first, then render the figure.

PythonIn [19]
CollapseExpand
lambdas = [0.01, 1.0, 1000.0]
grid1 = np.linspace(x1.min(), x1.max(), 300)
smooth_paths = {}
for lam in lambdas:
    _, _, beta_lam, infos_lam, Q_lam = fit_additive_gam([x1, x2], y, lam=lam)
    grid_design = prediction_design(
        [grid1, np.zeros_like(grid1)], infos_lam, Q_lam
    )
    smooth_paths[lam] = (
        grid_design[:, 1 : 1 + n_basis] @ beta_lam[1 : 1 + n_basis]
    )
VisualizationOut [20]
CollapseExpand
Three curves overlaid on the same axes, showing the fitted smooth for x1 at lambda = 0.01, lambda = 1.0, and lambda = 1000.0.
The fitted smooth for predictor x1 at three values of the smoothing parameter lambda. The largest penalty suppresses the sinusoidal shape, while smaller penalties track the data more closely and retain more detail. The curves retain their training-sample centering; the oracle partial-response points subtract the true component's training mean.

The plotted paths show how the fixed basis responds to different penalties. The largest penalty removes much of the sinusoidal structure. Whether the smaller penalty is too flexible requires validation rather than a judgment from training RMSE or appearance alone.

Comparing with statsmodels

statsmodels.gam.api.GLMGam implements the same general penalized-GLM framework, but its BSplines smoother uses an integrated squared-second-derivative penalty, rather than our coefficient-difference penalty. Equal numeric values of alpha and λ\lambda do not specify equal smoothing. We fit a centered statsmodels model with an explicit intercept, then independently solve its own Gaussian penalized equations in NumPy. This comparison tests the fitting mechanism on identical inputs; it does not claim our earlier P-spline is the same model.

PythonIn [21]
CollapseExpand
from statsmodels.gam.api import BSplines, GLMGam

n_splines = 12
smoothers = BSplines(
    np.column_stack([x1, x2]),
    df=[n_splines, n_splines],
    degree=[3, 3],
    constraints="center",
)
gam_sm = GLMGam(y, exog=np.ones((n, 1)), smoother=smoothers, alpha=[0.1, 0.1])
gam_res = gam_sm.fit()
sm_fitted = gam_res.predict()
sm_rmse = np.sqrt(np.mean((y - sm_fitted) ** 2))

# statsmodels 0.14.6 PIRLS doubles the smooth penalty in its WLS solve.
B_reference = gam_sm.exog
P_reference = 2 * gam_sm.penal.penalty_matrix()
beta_reference = np.linalg.solve(
    B_reference.T @ B_reference + P_reference, B_reference.T @ y
)
np.testing.assert_allclose(beta_reference, gam_res.params, atol=1e-9, rtol=1e-9)
reference_difference = np.max(np.abs(B_reference @ beta_reference - sm_fitted))

We now overlay the NumPy solution of this reference system with the statsmodels result. Both curves use the fitted statsmodels basis and its training centering transform, evaluated on the same grid.

PythonIn [22]
CollapseExpand
reference_grid = smoothers.smoothers[0].transform(grid1)
reference_width = smoothers.smoothers[0].basis.shape[1]
smooth_scratch = reference_grid @ beta_reference[1 : 1 + reference_width]
smooth_sm = reference_grid @ gam_res.params[1 : 1 + reference_width]
VisualizationOut [23]
CollapseExpand
Two curves overlaid, one from the from-scratch solve and one from statsmodels GLMGam, showing near-identical shapes.
Fitted smooth for x1 from the from-scratch penalized solve and from statsmodels GLMGam, using the identical centered statsmodels basis and derivative penalty, including its PIRLS scaling. The two curves are nearly indistinguishable, confirming that the independent solve reproduces the library result. The curves retain their training-sample centering, and the oracle partial-response points subtract the true component's training mean.

The assertion above checks agreement of all reference coefficients. The numerical maximum prediction difference is printed below. Agreement verifies the Gaussian solve for this basis and penalty; it does not verify non-Gaussian inference or the choice of smoothing strength.

ConsoleOut [24]
CollapseExpand
From-scratch training RMSE: 0.3659
statsmodels training RMSE:  0.3653
Reference maximum prediction difference: 6.661e-15

The earlier P-spline RMSE and the statsmodels RMSE need not match: they use different bases, penalties, and smoothing parameters. The reference assertion compares the library to an independent solve of its own system, which is the controlled comparison.

Key parameters

  • Number of basis functions per smooth (df): controls maximum flexibility. A full cubic basis with df = 4 spans cubic polynomials; increasing the dimension permits more detail. The penalty cannot recover shapes excluded by a basis that is too small, so check sensitivity to basis size as well as smoothing.
  • Smoothing parameter λ\lambda: the actual knob that trades off flexibility against overfitting. Choose it by cross-validation, as we will see in the next section.
  • Penalty order: 2 for the standard second-difference penalty, 1 for a roughness penalty on first differences (encourages flatness rather than linearity), 3 for third coefficient differences. The order describes the coefficient sequence and is distinct from the spline degree.
  • Link function: identity for Gaussian, logit for binary, log for counts, inverse for gamma.

Key methods

  • GLMGam.fit() estimates coefficients and standard errors in a single call (using PIRLS by default in statsmodels 0.14.6).
  • gam_res.predict(exog=linear_values, exog_smooth=smooth_values) evaluates the fitted reference model at new inputs within its supported knot domain.
  • smoothers.transform(X) reuses the fitted spline basis for XX within its knot bounds, which is what we need to evaluate the fitted smooth on a grid.
  • gam_res.edf exposes effective degrees of freedom by coefficient; block sums describe components. gam_res.summary() reports the fitted model summary.

Practical Implications

Fitting and choosing smoothness without leakage

The smoothing parameter λ\lambda is a hyperparameter. Choose it by scoring fits on observations excluded from fitting those coefficients. An untouched test set then assesses the selected procedure. For dependent observations, choose folds that match the generalization question. To evaluate transfer to unseen subjects, keep each subject in one fold. Forecasting requires a temporal split that matches deployment, including forecasting within already observed subjects when that is the target. Random folds below are appropriate for our independently generated observations.

The basis belongs inside that procedure too. Patsy's bs and statsmodels' BSplines can learn interior knot positions from training-predictor quantiles. Constructing that basis before splitting gives the fit information about validation inputs. That is a leakage path, although the size and direction of its effect on a score depend on the problem. Keep the fitted knot specification and training centering transformation, then reuse them on validation, test, and production rows. Our synthetic bounds [−3,3][-3,3] were fixed by the data-generation design; these are not estimated from held-out inputs. Both libraries' B-spline transforms reject points outside their boundary knots rather than silently performing polynomial extrapolation.

We use five folds and a predeclared grid of nine smoothing parameters. Each fold constructs its own basis and centering constraint using only that fold's training rows. The fold errors are averaged to select a shared λ\lambda for the two components. A separate synthetic test sample is generated only after selection, then scored once. The earlier full-sample component plots illustrate fitting; their training RMSE does not select the winning parameter.

PythonIn [25]
CollapseExpand
cv_lambdas = np.logspace(-4, 4, 9)
folds = np.array_split(np.random.default_rng(24).permutation(n), 5)
cv_mse = np.empty(len(cv_lambdas))
for a, lam in enumerate(cv_lambdas):
    fold_errors = []
    for validation_rows in folds:
        training_rows = np.setdiff1d(np.arange(n), validation_rows)
        _, _, fold_beta, fold_infos, fold_Q = fit_additive_gam(
            [x1[training_rows], x2[training_rows]], y[training_rows], lam=lam
        )
        validation_design = prediction_design(
            [x1[validation_rows], x2[validation_rows]], fold_infos, fold_Q
        )
        fold_errors.append(
            np.mean((y[validation_rows] - validation_design @ fold_beta) ** 2)
        )
    cv_mse[a] = np.mean(fold_errors)
selected_lambda = cv_lambdas[np.argmin(cv_mse)]
_, _, selected_beta, selected_infos, selected_Q = fit_additive_gam(
    [x1, x2], y, lam=selected_lambda
)
test_rng = np.random.default_rng(17)
test_X = test_rng.uniform(-3.0, 3.0, (120, 2))
test_y = (
    1.0
    + np.sin(test_X[:, 0])
    + 0.5 * test_X[:, 1] ** 2
    - 0.5 * test_X[:, 1]
    + test_rng.normal(0.0, 0.4, len(test_X))
)
test_design = prediction_design(
    [test_X[:, 0], test_X[:, 1]], selected_infos, selected_Q
)
test_rmse = np.sqrt(np.mean((test_y - test_design @ selected_beta) ** 2))
ConsoleOut [26]
CollapseExpand
Smoothing grid: [1.e-04 1.e-03 1.e-02 1.e-01 1.e+00 1.e+01 1.e+02 1.e+03 1.e+04]
Cross-validation RMSE: [0.3896 0.3895 0.3891 0.387  0.3833 0.3852 0.4813 0.9822 1.3658]
Selected lambda: 1
Independent synthetic test RMSE: 0.3698

The reported test RMSE describes this one synthetic sample from the stated generating model. It is not evidence that GAMs will attain that error on another dataset. A separate λj\lambda_j per smooth allows different flexibility across predictors, at the cost of a larger tuning problem. Sharing a parameter is a simplifying restriction here, not a universal GAM default.

For a Gaussian linear smoother, generalized cross-validation (GCV) offers another selection criterion: GCV=n∥y−y^∥2/(n−tr⁡H)2\mathrm{GCV}=n\|\mathbf{y}-\hat{\mathbf{y}}\|^2/(n-\operatorname{tr}\mathbf{H})^2, provided the denominator is nonzero. Here nn is the training-sample size and tr⁡H\operatorname{tr}\mathbf{H} is the total effective degrees of freedom for that fixed basis and penalty. GCV approximates leave-one-out assessment by replacing individual leverage values with their average; it is not identical to every held-out split. Packages also provide likelihood-based smoothness selection methods. Choose a criterion that respects the data structure and evaluation question.

For a dense implementation with qq identifiable coefficients and nn rows, forming the cross-product matrix costs O(nq2)O(nq^2) and a direct solve costs O(q3)O(q^3), with O(nq+q2)O(nq+q^2) storage when the design is retained. Within a fixed fold, the basis and its cross-products can be reused across smoothing candidates. For non-Gaussian models fitted by IRLS, each iteration solves a weighted penalized system; runtime and convergence depend on the data and link. Effective degrees of freedom describe model flexibility; neither their size nor training RMSE alone diagnoses generalization.

Component interpretation, interactions, and limitations

Our constraint makes each fitted component average zero over its training observations, so the intercept and smooths have a specified reference. At two values of xjx_j, the difference in f^j\hat f_j gives the change in the fitted additive predictor while the other inputs are held fixed. With an identity link, a rise of 0.50.5 corresponds to a 0.50.5 increase in the fitted conditional mean. With a logit link it multiplies fitted odds by e0.5≈1.65e^{0.5}\approx1.65; with a log link it multiplies the fitted mean by that factor. It does not imply an additive change of 0.50.5 in probability for a logit GAM. On the response scale, a nonlinear inverse link also makes the size of a mean change depend on the other predictors' values.

Component curves describe fitted associations. Interpreting them as causal effects needs additional identification assumptions and a design that supports those assumptions. Correlation between inputs can limit which combinations the data support. Concurvity is the more specific problem in which a smooth term can be approximated by other model terms: it can make the allocation among fitted components unstable even when total predictions look adequate. Exact overlap in unpenalized directions can leave a model unidentifiable despite centering. Check supported predictor combinations and basis conditioning before interpreting a component in isolation.

Inspect residuals for patterns involving pairs of predictors, then compare a plausible interaction model using the same validation protocol. Residual plots can suggest a failure of additivity; absence of a visible pattern does not prove additivity. A tensor-product term fjk(xj,xk)f_{jk}(x_j,x_k) starts from the row-wise products of two marginal bases. Before constraints, it has KjKkK_jK_k coefficients: two 12-function bases give 144 products. Identifiability constraints and removal of overlapping main-effect directions can reduce that count. The interaction needs its own penalties and evidence that the added flexibility helps. There are p(p−1)/2p(p-1)/2 possible pairs, so domain knowledge can help limit the candidates.

Boundary regions often contain less information than the interior. The displayed example has known finite support; production data need explicit handling of out-of-domain inputs and monitoring for distribution shifts. Natural cubic splines impose linear tails, whereas the B-spline transforms used here refuse points beyond their boundary knots. Neither policy establishes that predictions beyond observed support are reliable. Rebuilding a basis on a new training window changes the fitted model and requires validation again.

For uncertainty, component intervals require a covariance calculation matched to the fitted likelihood, penalty, and centering convention. The fixed-smoothing Gaussian covariance given earlier is one such conditional calculation. It omits smoothing-selection uncertainty and does not turn an association into a causal effect. These limits matter when a curve is used to explain a prediction as well as when it is used for inference.

Summary

A generalized additive model is the natural upgrade of linear and spline regression when multiple predictors each have non-linear effects but no interaction structure. The additive predictor η=β0+∑jfj(xj)\eta = \beta_0 + \sum_j f_j(x_j) is connected to the response mean via a link function gg, allowing the same machinery to handle Gaussian, binary, count, and other response types. Each smooth fjf_j is expanded in a spline basis, and a difference penalty regularizes how wiggly the resulting curve can be, turning the Gaussian identity-link fit into a penalized least-squares problem. After identifiability constraints, an invertible penalized system has the solution

β^=(B⊤B+S)−1B⊤y,\hat{\boldsymbol\beta} = \left( \mathbf{B}^\top \mathbf{B} + \mathbf{S} \right)^{-1} \mathbf{B}^\top \mathbf{y},

which is structurally identical to ridge regression on an expanded feature space. For non-identity links, a weighted penalized system reappears inside an IRLS loop for suitable non-Gaussian GLMs.

The worked procedure selects a shared smoothing parameter by cross-validation within training data, fitting the basis and centering inside every fold, then scores once on independent synthetic test observations. Other smoothness-selection criteria answer related questions under their own assumptions. Interpretation is done by plotting each component separately after centering it to have zero mean, and reading off the effect on the scale of the link function. The additive restriction excludes interactions unless they are included explicitly. When interactions are suspected, tensor-product smooths can be added at the cost of sample size and interpretability.

A GAM extends a GLM by allowing smooth terms in its predictor. It contains a linear GLM when those terms are linear, and its component curves expose the fitted additive associations. It is a good first model to try whenever a linear regression or a linear GLM leaves obvious non-linear residuals. Its Gaussian fitting mechanism, quadratic penalized least squares on an expanded basis, is closely related to ridge regression. The next chapter Ridge Regularization (L2 Regularization) develops that connection further. Lasso instead uses an absolute-value penalty and requires a different optimization problem.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about generalized additive models.

Generalized Additive Models Quiz

Question 1 of 80 of 8 completed
In a generalized additive model, what replaces the linear term for each predictor?
Join the discussionAsk a question or share what you learned.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

No comments yet. Be the first to share your thoughts!

A different angle
All writing
Machine Learning

Quantile Regression

Learn quantile regression from first principles: pinball loss, linear programming, conditional quantiles, crossing diagnostics, and Python implementation.

66 min read
Machine Learning · Vol. 2

Logistic Regression: Math & Python

Covers logistic regression covering mathematical foundations, the logistic function, optimization algorithms, and practical implementation.

46 min read
Machine Learning · Vol. 2

Poisson Regression: Count Data Modeling with Math & Python

Covers Poisson regression for count data analysis. Topics include mathematical foundations, maximum likelihood estimation, rate ratio interpretation.

49 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2026generalizedadditive, author = {Michael Brenndoerfer}, title = {Generalized Additive Models}, year = {2026}, url = {https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-28} }
APAAcademic
Michael Brenndoerfer (2026). Generalized Additive Models. Retrieved from https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch
MLAAcademic
Michael Brenndoerfer. "Generalized Additive Models." 2026. Web. October 28, 2026. <https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch>.
CHICAGOAcademic
Michael Brenndoerfer. "Generalized Additive Models." Accessed October 28, 2026. https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch.
HARVARDAcademic
Michael Brenndoerfer (2026) 'Generalized Additive Models'. Available at: https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch (Accessed: October 28, 2026).
SimpleBasic
Michael Brenndoerfer (2026). Generalized Additive Models. https://mbrenndoerfer.com/writing/generalized-additive-models-from-scratch
Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy