Generalized Additive Models
Learn generalized additive models from scratch: spline bases, difference penalties, link functions, identifiability, cross-validated smoothing, and NumPy fits.
Machine Learning from ScratchAll chapters 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
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
where:
- : the link function connecting the conditional mean to the additive predictor
- : the conditional mean of the response given the predictors
- : the intercept
- : a smooth function of predictor
- : the -th predictor value, with and predictors
Two questions guide the implementation:
- how to estimate the amount of flexibility (the smoothing parameter ) 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 positions per predictor contains grid points, while separate one-dimensional grids contain 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 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 as a curve and read off how the predicted value (or the transformed mean) changes as a function of , 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 flips sign depending on , an additive model cannot represent it. We can add explicit tensor-product smooths 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 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.
- 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.
- 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.
- 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.
Additive predictors and link functions
The additive predictor sums an intercept and one component per predictor for observation :
where:
- : index over observations,
- : the value of the additive predictor for observation ,
- : an intercept shared by all observations,
- : the number of predictors,
- : the value of predictor for observation ,
- : a smooth function of predictor , not necessarily linear.
If for every predictor, this becomes the predictor from Multiple Linear Regression. A GAM relaxes that restriction one term at a time: each 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 ? 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 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 . A count mean need not be an integer; a log link keeps it positive. The link function bridges that gap:
Here 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 for Gaussian regression (this is the case we will spend most time on),
- logit link for binary responses,
- log link for counts,
- inverse link 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 describes how the log-odds change as a smooth function of . For a count GAM with log link, each describes how the log expected count changes.
Notice the clean division of labor this creates: the smooths describe shape on the scale of , 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 with a spline basis:
where:
- is the number of basis functions we allocate to predictor ,
- is the -th basis function, a known deterministic function of ,
- 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 controls the maximum possible wiggliness: with more basis functions we can represent more complex shapes.
A mental picture makes the next step natural. Each is a localized bump, and the fitted smooth is a weighted sum of those bumps, with the weights given by the coefficients . Increasing 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
This is now linear in the coefficients . If we stack all coefficients into a single vector and expand each predictor into its basis, the model becomes for an augmented design matrix 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:
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 controls how strongly that penalty acts:
- gives an unpenalized spline, which can be as wiggly as the basis allows;
- forces the coefficient sequence to be affine in its index; the resulting curve depends on the knot arrangement;
- moderate 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 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]Expand
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]Expand

In matrix form the penalty for smooth can be written as
where:
- collects the smooth coefficients,
- is the second-difference matrix, each row of the form ,
- is a symmetric positive semidefinite matrix that encodes the penalty.
Putting the pieces together, the Gaussian GAM is fit by minimizing
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 set the exchange rate between those demands.
In matrix notation, augmenting with a leading column of ones for the intercept and stacking the coefficient blocks,
where is the block-diagonal matrix with zero in the intercept block and in the block for smooth . After imposing centering, the following matrix formulas use the free coefficients and the correspondingly transformed design and penalty; the symbol now denotes those identifiable coordinates. Setting the gradient of with respect to equal to zero and rearranging (exactly as we did for OLS in Ordinary Least Squares (OLS)) gives the closed-form solution
This is identical in form to ridge regression, with playing the role of a feature-specific ridge penalty. The inverse exists only when the design identifies every direction left unpenalized: . Indeed, for any coefficient direction , . 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 is , and the full fitted predictor is .
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
where:
- : the IRLS iteration index
- : the diagonal matrix of GLM working weights at iteration , with entries depending on the variance function and link derivative
- : the working response at iteration , formed from the current linear predictor and the link
- : the same block-diagonal penalty matrix as in the Gaussian case
For independent GLM observations, a common working response is , where is the derivative of the link at the current fitted mean. The corresponding working weight is . 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 , 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 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 , where for the identifiable design. This counts the intercept too; a centered component does not generally have degrees of freedom ranging from to .
The matrix maps observed responses to fitted values: . With the basis and smoothing parameters fixed, its diagonal entry measures how sensitive fitted value is to observed response . The trace, , 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]Expand
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]Expand

For independent errors of variance and fixed smoothing parameters, write . Then . Here 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 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:
with drawn uniformly from and . 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]Expand
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]Expand


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]Expand
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]Expand

Each basis curve contributes only to a short segment of the 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 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 and responses . On the domain , place knots at , , and , giving three linear hat functions:
Evaluating these at our three values gives the basis matrix
We will add an intercept column as the first column, so the full design becomes
The coefficient vector is with the intercept and giving the smooth.
The unpenalized normal-equation quantities are
These come from the same rules as OLS: each entry of is a dot product of two columns of , and each entry of is a dot product of a column of 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 has row, . The full penalty matrix has a zero row and column for the intercept (the intercept is not penalized) and in the smooth block:
Take . The penalized system is
with
Now compare the matrix above with the unpenalized . The off-diagonal entries (the and 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 . We solve the normal equations and that constraint together, using a Lagrange multiplier (an extra unknown that enforces the constraint).
PythonIn [11]Expand
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]Expand
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 we recover the unpenalized fit, which on these three points fits every observation exactly (because the design, once the smooth is constrained by identifiability, leaves enough freedom to interpolate all three points), including any noise.
- With the penalty forces , meaning the fitted curve is exactly a straight line (a linear trend in the hat basis).
- For intermediate (here ) 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 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]Expand
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]Expand
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]Expand
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]Expand


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]Expand
noise_sd = 0.4ConsoleOut [18]Expand
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 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]Expand
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]Expand

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 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]Expand
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]Expand
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]Expand

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]Expand
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 withdf = 4spans 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 : 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 within its knot bounds, which is what we need to evaluate the fitted smooth on a grid.gam_res.edfexposes 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 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 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 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]Expand
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]Expand
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 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: , provided the denominator is nonzero. Here is the training-sample size and 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 identifiable coefficients and rows, forming the cross-product matrix costs and a direct solve costs , with 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 , the difference in gives the change in the fitted additive predictor while the other inputs are held fixed. With an identity link, a rise of corresponds to a increase in the fitted conditional mean. With a logit link it multiplies fitted odds by ; with a log link it multiplies the fitted mean by that factor. It does not imply an additive change of 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 starts from the row-wise products of two marginal bases. Before constraints, it has 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 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 is connected to the response mean via a link function , allowing the same machinery to handle Gaussian, binary, count, and other response types. Each smooth 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
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
Join the discussionAsk a question or share what you learned.
Keep exploring
Reference
Citation details
Cite or share this article.


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!