Bayesian Penalised Splines & Additive Models¶
Many knots, one penalty — smoothness learned, not chosen¶
There are two ways to fit a flexible curve with splines. The variable-selection arc's free-knot notebook chooses the number and location of a few knots by trans-dimensional MCMC. The penalised-spline route (Eilers & Marx 1996; Bayesian version Lang & Brezger 2004) does the opposite: lay down many equally spaced B-spline basis functions — deliberately too many — and control smoothness with a penalty on the coefficients, not the knots. As a Bayesian prior the penalty is a random walk:
$$y_i=\sum_j B_j(x_i)\,\beta_j+\varepsilon_i,\qquad \beta_j-2\beta_{j-1}+\beta_{j-2}\sim N(0,\tau^2),$$
so $\beta\sim N\!\big(0,\tau^2(D'D)^{-1}\big)$ with $D$ the second-difference matrix. The smoothing parameter is the ratio $\sigma^2/\tau^2$ — and it is inferred from the data through $\tau^2$'s own prior, so there is nothing to cross-validate. Every full conditional is conjugate (a Gaussian Markov random field), so the from-scratch sampler is a clean Gibbs. Stacking one penalised spline per covariate gives an additive model $y=\beta_0+\sum_k f_k(x_k)$.
We build it from scratch on the airquality ozone data, show the penalty at work, place it beside the frequentist GCV-tuned spline and mgcv, fit a three-term additive model, and cross-check with a random-walk-prior spline in PyMC.
import numpy as np, pandas as pd, matplotlib.pyplot as plt
import pspline as P
rng = np.random.default_rng(1)
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; PURP="#6b46c1"; GREY="#718096"
d = pd.read_csv("airquality.csv")
print(f"airquality: {len(d)} complete days; ozone {d.Ozone.min()}-{d.Ozone.max()} ppb")
airquality: 111 complete days; ozone 1-168 ppb
1. The basis and the penalty¶
We regress $\log(\text{ozone})$ on temperature. First the ingredients: a dense set of cubic B-spline bumps (the basis), and the penalty that stops the coefficients from wandering. Fixing the smoothing at three levels — almost none, a lot, and the data-driven amount — shows exactly what the penalty buys: too little and the curve chases noise; too much and it straightens toward a line; inferred, it lands in between.
x=d.Temp.to_numpy().astype(float); y=np.log(d.Ozone.to_numpy().astype(float))
xg=np.linspace(x.min(),x.max(),200)
kn=P.make_knots(x.min(),x.max(),25,3); Bx=P.bspline_design(x,kn,3); Bg=P.bspline_design(xg,kn,3)
Kpen=P.diff_penalty(Bx.shape[1],2); Kpen=Kpen.T@Kpen
def pen_fit(lam):
beta=np.linalg.solve(Bx.T@Bx+lam*Kpen, Bx.T@y); return Bg@beta
fig,ax=plt.subplots(1,2,figsize=(12.5,4.4))
for j in range(Bg.shape[1]): ax[0].plot(xg, 0.3*Bg[:,j], color=GREY, lw=.8, alpha=.6)
ax[0].set_title(f"The B-spline basis ({Bg.shape[1]} cubic bumps)"); ax[0].set_xlabel("temperature"); ax[0].set_ylabel("basis")
ax[1].scatter(x,y,s=14,color=GREY,alpha=.6,label="data")
ax[1].plot(xg, pen_fit(1e-4), color=RED, lw=1.5, ls=":", label="tiny penalty (undersmooth)")
ax[1].plot(xg, pen_fit(1e5), color=GREEN, lw=1.5, ls="--", label="huge penalty (oversmooth)")
res=P.pspline_gibbs(x,y,rng,draws=2000,burn=1000,grid=xg); m=res["fit"].mean(0)
lo,hi=np.percentile(res["fit"],[2.5,97.5],axis=0)
ax[1].fill_between(xg,lo,hi,color=BLUE,alpha=.2); ax[1].plot(xg,m,color=BLUE,lw=2.4,label="Bayesian P-spline (inferred)")
ax[1].set_title("The penalty at work"); ax[1].set_xlabel("temperature"); ax[1].set_ylabel("log ozone"); ax[1].legend(frameon=False,fontsize=8)
plt.tight_layout(); plt.show()
print("Many basis functions, but the second-difference penalty ties neighbouring coefficients together. The Bayesian")
print("P-spline infers how strong that tie should be -- between the noisy undersmoothed and the near-linear")
print("oversmoothed extremes -- and reports a credible band around the curve.")
Many basis functions, but the second-difference penalty ties neighbouring coefficients together. The Bayesian P-spline infers how strong that tie should be -- between the noisy undersmoothed and the near-linear oversmoothed extremes -- and reports a credible band around the curve.
2. The frequentist counterpart — the same spline, tuned by GCV¶
The classical penalised spline uses the identical basis and penalty but picks the smoothing parameter $\lambda$ by generalised cross-validation (minimising prediction error via the hat matrix) rather than inferring it. It traces essentially the same curve — and, as we saw in the GP notebook, a penalised spline is a Gaussian-process posterior mean, so the Bayesian, penalised, and GP views are three windows on one estimator.
lams=np.logspace(-4,5,80); n=len(y)
gcv=[]
for lam in lams:
H=Bx@np.linalg.solve(Bx.T@Bx+lam*Kpen, Bx.T); tr=np.trace(H); yh=H@y
gcv.append(n*np.sum((y-yh)**2)/(n-tr)**2)
lam_gcv=lams[int(np.argmin(gcv))]; beta_g=np.linalg.solve(Bx.T@Bx+lam_gcv*Kpen, Bx.T@y); fit_gcv=Bg@beta_g
fig,ax=plt.subplots(1,2,figsize=(12,4.2))
ax[0].semilogx(lams,gcv,color=PURP); ax[0].axvline(lam_gcv,color="k",ls=":"); ax[0].set_xlabel("smoothing $\\lambda$"); ax[0].set_ylabel("GCV score"); ax[0].set_title(f"GCV selects $\\lambda$={lam_gcv:.0f}")
ax[1].scatter(x,y,s=14,color=GREY,alpha=.5); ax[1].fill_between(xg,lo,hi,color=BLUE,alpha=.18)
ax[1].plot(xg,m,color=BLUE,lw=2.4,label="Bayesian P-spline"); ax[1].plot(xg,fit_gcv,color=RED,lw=1.7,ls="--",label="frequentist GCV spline")
ax[1].set_xlabel("temperature"); ax[1].set_ylabel("log ozone"); ax[1].set_title("Bayesian vs GCV — the same curve"); ax[1].legend(frameon=False,fontsize=8)
plt.tight_layout(); plt.show()
lam_bayes = res["sig2"] / res["tau2"].mean() # posterior effective penalty, sigma^2 / E[tau^2]
ratio = max(lam_bayes, lam_gcv) / min(lam_bayes, lam_gcv)
gapc = float(np.abs(m - fit_gcv).max()); rng_y = float(y.max() - y.min())
print("GCV selects lambda = %.0f. The Bayesian posterior implies an effective penalty of" % lam_gcv)
print("sigma^2 / E[tau^2] = %.0f -- a factor of %.1f apart." % (lam_bayes, ratio))
print("That sounds like a lot until you look at what it buys: the two fitted curves differ by at most")
print("%.3f log-ppb, which is %.1f%% of the %.1f log-ppb range of the response. The penalty enters the" % (gapc, 100*gapc/rng_y, rng_y))
print("fit only through a ratio inside a matrix inverse, so a factor of a few in lambda moves the curve")
print("far less than it moves lambda -- which is exactly why selecting it by cross-validation and")
print("integrating over it give the same answer here. One tunes the penalty, the other averages over it.")
GCV selects lambda = 38. The Bayesian posterior implies an effective penalty of sigma^2 / E[tau^2] = 29 -- a factor of 1.3 apart. That sounds like a lot until you look at what it buys: the two fitted curves differ by at most 0.069 log-ppb, which is 1.3% of the 5.1 log-ppb range of the response. The penalty enters the fit only through a ratio inside a matrix inverse, so a factor of a few in lambda moves the curve far less than it moves lambda -- which is exactly why selecting it by cross-validation and integrating over it give the same answer here. One tunes the penalty, the other averages over it.
3. An additive model — three smooth effects at once¶
Ozone depends on solar radiation, wind and temperature together. An additive model gives each its own penalised spline, $\log(\text{ozone})=\beta_0+f_1(\text{solar})+f_2(\text{wind})+f_3(\text{temp})$, sampled by the same Gibbs sweep on partial residuals. Each partial effect is recovered with its own credible band and its own inferred smoothness.
Xc=[d["Solar.R"].to_numpy().astype(float), d.Wind.to_numpy().astype(float), d.Temp.to_numpy().astype(float)]
names=["solar radiation","wind","temperature"]
g=P.gam_gibbs(Xc, y, rng, ndx=15, draws=2500, burn=1200)
fig,ax=plt.subplots(1,3,figsize=(13.5,4))
for k in range(3):
gk=g["grids"][k]; pk=g["partial"][k].mean(0); plo,phi=np.percentile(g["partial"][k],[2.5,97.5],axis=0)
ax[k].fill_between(gk,plo,phi,color=BLUE,alpha=.2); ax[k].plot(gk,pk,color=BLUE,lw=2.2)
ax[k].scatter(Xc[k], y-g["intercept"].mean()-sum(g["partial"][j].mean(0)[np.searchsorted(g["grids"][j],Xc[j]).clip(0,len(g["grids"][j])-1)] for j in range(3) if j!=k), s=8, color=GREY, alpha=.3)
ax[k].axhline(0,color="k",lw=.6,ls=":"); ax[k].set_xlabel(names[k]); ax[k].set_ylabel(f"partial effect on log ozone"); ax[k].set_title(f"f({names[k]})")
plt.tight_layout(); plt.show()
print("Temperature drives ozone up strongly and nonlinearly; wind pushes it down (calm days trap pollution); solar")
print("radiation raises it then flattens. Each effect is a penalised spline with its own inferred smoothness, and the")
print("additive structure lets all three be estimated jointly -- a semiparametric regression, no functional forms assumed.")
Temperature drives ozone up strongly and nonlinearly; wind pushes it down (calm days trap pollution); solar radiation raises it then flattens. Each effect is a penalised spline with its own inferred smoothness, and the additive structure lets all three be estimated jointly -- a semiparametric regression, no functional forms assumed.
4. Cross-check in PyMC — the random-walk-prior spline¶
The penalty is literally a prior: a second-order random walk on the coefficients. In PyMC we place the same B-spline design matrix in the model and penalise the second differences of $\beta$ with a Potential, letting NUTS sample $\beta$, the smoothing precision and the noise. It reproduces the from-scratch curve.
import pymc as pm, pytensor.tensor as pt
Bd=P.bspline_design(x,kn,3); nb=Bd.shape[1]; Dmat=P.diff_penalty(nb,2)
with pm.Model() as mod:
tau=pm.HalfNormal("tau",1.0); sigma=pm.HalfNormal("sigma",1.0)
beta=pm.Normal("beta",0,5,shape=nb)
pm.Potential("penalty", -0.5*pt.sum(pt.dot(Dmat,beta)**2)/tau**2 - (nb-2)*pt.log(tau))
pm.Normal("y", mu=pt.dot(Bd,beta), sigma=sigma, observed=y)
idata=pm.sample(1000, tune=1500, chains=4, target_accept=0.95, random_seed=2, progressbar=False)
beta_pm=idata.posterior["beta"].mean(("chain","draw")).values; fit_pm=Bg@beta_pm
print(f"max |PyMC - from-scratch Gibbs| over the curve = {np.abs(fit_pm-m).max():.3f} log-ppb")
fig,ax=plt.subplots(figsize=(8.5,4.2))
ax.scatter(x,y,s=14,color=GREY,alpha=.5,label="data"); ax.fill_between(xg,lo,hi,color=BLUE,alpha=.18)
ax.plot(xg,m,color=BLUE,lw=2.4,label="from-scratch Gibbs"); ax.plot(xg,fit_pm,color=RED,lw=1.6,ls="--",label="PyMC RW-prior spline")
ax.set_xlabel("temperature"); ax.set_ylabel("log ozone"); ax.set_title("Penalised spline: from-scratch vs PyMC"); ax.legend(frameon=False,fontsize=8)
plt.tight_layout(); plt.show()
ndiv = int(idata.sample_stats["diverging"].values.sum())
ndraw = int(idata.sample_stats["diverging"].size)
gap_pm = float(np.abs(fit_pm - m).max())
print("The random-walk Potential IS the penalty, and the two samplers land on the same smooth: they differ")
print("by at most %.3f log-ppb, %.1f%% of the response range." % (gap_pm, 100*gap_pm/float(y.max()-y.min())))
print("\nOne caveat on the sampler rather than the answer: %d of %d draws diverged (%.1f%%)." % (ndiv, ndraw, 100*ndiv/ndraw))
print("A hierarchical variance with a Potential penalty is a classic funnel geometry, and NUTS feels it")
print("where the conjugate Gibbs sampler -- which draws beta and tau^2 from their exact full conditionals")
print("-- does not. The agreement above is the reassurance that the divergences have not distorted the")
print("posterior mean here; a non-centred reparameterisation would be the fix if they had.")
g++ not available, if using conda: `conda install gxx`
Initializing NUTS using jitter+adapt_diag...
Multiprocess sampling (4 chains in 4 jobs)
NUTS: [tau, sigma, beta]
Sampling 4 chains for 1_500 tune and 1_000 draw iterations (6_000 + 4_000 draws total) took 20 seconds.
There were 248 divergences after tuning. Increase `target_accept` or reparameterize.
The rhat statistic is larger than 1.01 for some parameters. This indicates problems during sampling. See https://arxiv.org/abs/1903.08008 for details
The effective sample size per chain is smaller than 100 for some parameters. A higher number is needed for reliable rhat and ess computation. See https://arxiv.org/abs/1903.08008 for details
max |PyMC - from-scratch Gibbs| over the curve = 0.051 log-ppb
The random-walk Potential IS the penalty, and the two samplers land on the same smooth: they differ by at most 0.051 log-ppb, 1.0% of the response range. One caveat on the sampler rather than the answer: 248 of 4000 draws diverged (6.2%). A hierarchical variance with a Potential penalty is a classic funnel geometry, and NUTS feels it where the conjugate Gibbs sampler -- which draws beta and tau^2 from their exact full conditionals -- does not. The agreement above is the reassurance that the divergences have not distorted the posterior mean here; a non-centred reparameterisation would be the fix if they had.
5. Summary¶
The penalised-spline recipe is: use far more B-spline basis functions than you need, and let a random-walk penalty — a Gaussian Markov random field prior on the coefficients — control the smoothness, with the smoothing strength inferred through $\tau^2$ rather than chosen. Every full conditional is conjugate, so the from-scratch sampler is a plain Gibbs; stacking one spline per covariate gives an additive model, and on the ozone data it cleanly separated the nonlinear effects of solar radiation, wind and temperature, each with its own band.
Its frequentist twin selects the same penalty by GCV and traces the same curve; mgcv (companion notebook) is the industrial version; and — from the GP notebook — a penalised spline is a Gaussian-process posterior mean, so Bayesian, penalised, and GP are one estimator viewed three ways. PyMC confirmed the fit with the penalty written as a Potential.
This is the penalty paradigm, complementing the variable-selection arc's free-knot notebook (Free-Knot Splines — an Unknown Number of Knots), which instead moves a few knots by reversible-jump MCMC: many-fixed-knots-plus-penalty versus few-adaptive-knots. Where the smooth effect is genuinely local (a sharp peak), adaptive knots can win; where it is globally smooth, the penalty is simpler and extends effortlessly to additive models. Next the arc turns to nonparametric survival and hazards, then Polya trees.