Bayesian Nonparametric Prediction (R) — mgcv · kernlab¶

Gaussian processes and additive splines on the two datasets¶

The R cross-check for the Python BNP benchmark: kernlab::gausspr (Gaussian process regression and classification) and mgcv (Simon Wood's package — the reference implementation of penalized additive splines / GAMs, and the same package behind Bayesian Penalised Splines & Additive Models). Same Taiwan credit-default (classification) and California-housing (regression) data as the whole ML arc. The question is the Python notebook's: how do Bayesian nonparametric predictors fare, and what do they add? GPs are $O(n^3)$ so they use a subsample (as BART — Bayesian Additive Regression Trees did); GAMs use the full data. ROC-AUC is defined in the CART notebook.

In [1]:
options(repr.plot.width=13, repr.plot.height=4.6)
.libPaths(c("C:/Users/user/R/win-library/4.6", .libPaths()))
suppressMessages({library(kernlab); library(mgcv)})
auc <- function(y,p){ r<-rank(p); n1<-sum(y==1); n0<-sum(y==0); (sum(r[y==1])-n1*(n1+1)/2)/(n1*n0) }
rmse <- function(a,b) sqrt(mean((a-b)^2))
cat("kernlab", as.character(packageVersion("kernlab")), "| mgcv", as.character(packageVersion("mgcv")), "\n")
kernlab 0.9.33 | mgcv 1.9.4 

1. Gaussian process regression — California¶

kernlab::gausspr fits a GP with an RBF kernel, choosing the kernel width automatically. On a 1,200-point subsample it returns a posterior mean for every block group; we score out-of-sample RMSE and plot predicted vs actual with decile means. The GP is the Bayesian kernel machine — its posterior mean is the kernel ridge regression of Support Vector Machines & Kernel Methods, where the two are shown to agree to 5.5×10⁻¹⁴ using kernlab::gausspr and a hand-built kernel-ridge solve.

In [2]:
h<-read.csv("cali_housing.csv"); hf<-setdiff(names(h),"MedHouseVal")
set.seed(0); j<-sample(nrow(h),0.7*nrow(h)); htr<-h[j,]; hte<-h[-j,]
ctr<-colMeans(htr[,hf]); scl<-apply(htr[,hf],2,sd)
Xtr<-scale(htr[,hf],ctr,scl); Xte<-scale(hte[,hf],ctr,scl)
set.seed(1); s<-sample(nrow(Xtr),1200)
gpr<-gausspr(Xtr[s,], htr$MedHouseVal[s], kernel="rbfdot", kpar="automatic")
mu<-as.numeric(predict(gpr, Xte)); gp_rmse<-rmse(hte$MedHouseVal, mu)
cat(sprintf("GP regression (subsample 1200): test RMSE %.3f\n", gp_rmse))
options(repr.plot.width=6.4, repr.plot.height=5)
plot(mu, hte$MedHouseVal, pch=".", col="#a0aec0", xlab="GP predicted value ($100k)", ylab="actual value",
     main=sprintf("GP regression -- predicted vs actual (RMSE %.3f)", gp_rmse)); abline(0,1,lty=2)
qb<-quantile(mu,seq(0,1,length=11)); bb<-cut(mu,unique(qb),include.lowest=TRUE)
points(tapply(mu,bb,mean), tapply(hte$MedHouseVal,bb,mean), type="b", pch=19, col="#c53030", lwd=2)
legend("topleft", c("perfect","decile means"), col=c("black","#c53030"), lty=c(2,1), pch=c(NA,19), lwd=2, bty="n")
Using automatic sigma estimation (sigest) for RBF or laplace kernel 
GP regression (subsample 1200): test RMSE 0.611
No description has been provided for this image

2. Gaussian process classification — credit default¶

gausspr with a factor response fits GP classification (a latent GP squashed through a link, Laplace-approximated). On a 1,200-client subsample we score out-of-sample AUC and the reliability curve. It should beat the linear logistic baseline (0.715) despite the tiny training sample, the kernel supplying nonlinearity.

In [3]:
d<-read.csv("credit_default.csv"); feat<-setdiff(names(d),"default")
set.seed(0); i<-sample(nrow(d),0.7*nrow(d)); tr<-d[i,]; te<-d[-i,]
cc<-colMeans(tr[,feat]); cs<-apply(tr[,feat],2,sd)
Ctr<-scale(tr[,feat],cc,cs); Cte<-scale(te[,feat],cc,cs)
set.seed(1); s2<-sample(nrow(Ctr),1200)
gpc<-gausspr(Ctr[s2,], factor(tr$default[s2]), kernel="rbfdot", kpar="automatic")
pg<-predict(gpc, Cte, type="probabilities")[,"1"]; gp_auc<-auc(te$default, pg)
cat(sprintf("GP classification (subsample 1200): test AUC %.3f\n", gp_auc))
options(repr.plot.width=13, repr.plot.height=4.6); par(mfrow=c(1,2), mar=c(4,4,3,1))
# ROC
th<-sort(unique(pg)); tpr<-sapply(th,function(t)mean(pg[te$default==1]>=t)); fpr<-sapply(th,function(t)mean(pg[te$default==0]>=t))
plot(fpr,tpr,type="l",col="#2b6cb0",lwd=2,xlab="false positive rate",ylab="true positive rate",main="Credit -- ROC"); abline(0,1,lty=3)
legend("bottomright", sprintf("GP classifier (AUC %.3f)",gp_auc), col="#2b6cb0", lwd=2, bty="n")
# reliability
q<-quantile(pg,seq(0,1,length=11)); b<-cut(pg,unique(q),include.lowest=TRUE)
plot(tapply(pg,b,mean), tapply(te$default,b,mean), type="b", pch=19, col="#c53030", lwd=2,
     xlab="predicted P(default) [decile bins]", ylab="observed default proportion", main="Credit -- reliability")
abline(0,1,lty=2); par(mfrow=c(1,1))
cat(sprintf("GP classification AUC %.3f beats the logistic baseline (0.715) from only 1200 of %d training rows.\n", gp_auc, nrow(tr)))
Using automatic sigma estimation (sigest) for RBF or laplace kernel 
GP classification (subsample 1200): test AUC 0.739
GP classification AUC 0.739 beats the logistic baseline (0.715) from only 1200 of 21000 training rows.
No description has been provided for this image

3. Additive splines — mgcv GAM¶

mgcv fits a GAM as a sum of penalized smooths $g(E[y])=\beta_0+\sum_j f_j(x_j)$, choosing the smoothness by (restricted) marginal likelihood — the penalty is a Bayesian random-walk prior on the spline coefficients, so mgcv reports genuine credible bands. We fit gam on the full California data (Gaussian) and a bam binomial GAM on credit, then read each feature's effect straight off its smooth, drawn over the central 99% of each covariate so that a handful of extreme block groups cannot compress the informative range. This is the reference version of the Python notebook's pyGAM.

In [4]:
# California: additive smooths on all 8 features
fH<-as.formula(paste("MedHouseVal ~", paste(sprintf("s(%s)",hf),collapse="+")))
gamH<-bam(fH, data=htr); gam_rmse<-rmse(hte$MedHouseVal, predict(gamH, hte))
# credit: smooths on continuous features, linear terms for discrete
cont<-c("LIMIT_BAL","AGE",paste0("BILL_AMT",1:6),paste0("PAY_AMT",1:6))
disc<-setdiff(feat,cont)
fC<-as.formula(paste("default ~", paste(c(sprintf("s(%s)",cont),disc),collapse="+")))
gamC<-bam(fC, data=tr, family=binomial); gam_auc<-auc(te$default, predict(gamC, te, type="response"))
cat(sprintf("mgcv GAM  -- California test RMSE %.3f | credit test AUC %.3f\n", gam_rmse, gam_auc))
# Drawn from predict(type="terms") rather than plot.gam. Two reasons: plot.gam's scale=-1 default puts
# every smooth on ONE shared y-axis, which here spans [-38, 43] because the AveRooms and AveBedrms bands
# explode out in the sparse tail -- on that axis MedInc occupies 4% of the height and looks flat. And its
# `shade` argument is not a formal of plot.gam in mgcv 1.9.4, so shade=TRUE is silently ignored.
smooth_band <- function(model, var, data, col, main, ylab, hi=0.99, n=200) {
  nd <- data[rep(1, n), , drop=FALSE]
  for (v in names(nd)) if (is.numeric(nd[[v]])) nd[[v]] <- median(data[[v]])
  nd[[var]] <- seq(min(data[[var]]), quantile(data[[var]], hi), length.out=n)
  pr <- predict(model, nd, type="terms", se.fit=TRUE); k <- paste0("s(", var, ")")
  fit <- pr$fit[,k]; se <- pr$se.fit[,k]; lo <- fit-1.96*se; up <- fit+1.96*se
  plot(nd[[var]], fit, type="n", ylim=range(lo,up), xlab=var, ylab=ylab, main=main)
  polygon(c(nd[[var]], rev(nd[[var]])), c(up, rev(lo)), col=paste0(col,"33"), border=NA)
  lines(nd[[var]], fit, col=col, lwd=2); abline(h=0, lty=3, col="grey50")
  rug(quantile(data[[var]], seq(0, hi, length.out=40)), col="grey60")
  invisible(data.frame(x=nd[[var]], fit=fit))
}
options(repr.plot.width=14, repr.plot.height=4.2); par(mfrow=c(1,3), mar=c(4,4,3,1))
s1<-smooth_band(gamH,"MedInc",   htr,"#2b6cb0","GAM smooth: MedInc (California)",  "partial effect on value")
s2<-smooth_band(gamH,"AveRooms", htr,"#2b6cb0","GAM smooth: AveRooms (California)","partial effect on value")
s3<-smooth_band(gamC,"LIMIT_BAL",tr, "#c53030","GAM smooth: credit limit (credit)","partial effect on log-odds")
par(mfrow=c(1,1))
rmin<-s2$x[which.min(s2$fit)]; occ_ratio<-max(htr$AveOccup)/quantile(htr$AveOccup,0.99)
cat(sprintf("Each smooth is a readable curve with a credible band. MedInc climbs steeply and flattens above ~8; AveRooms\n"))
cat(sprintf("is U-shaped with its minimum near %.1f rooms, so both the smallest and the largest units carry a premium over\n", rmin))
cat(sprintf("the middle once income is held fixed; and the credit-limit effect is near-linear (edf %.1f), higher limits\n", summary(gamC)$edf[which(cont=="LIMIT_BAL")]))
cat(sprintf("going with lower default odds. The x-axes stop at the 99th percentile deliberately: AveOccup's maximum is\n"))
cat(sprintf("%.0fx its p99, and letting a handful of such blocks set the range compresses every curve into a flat line.\n", occ_ratio))
mgcv GAM  -- California test RMSE 0.607 | credit test AUC 0.743
Each smooth is a readable curve with a credible band. MedInc climbs steeply and flattens above ~8; AveRooms
is U-shaped with its minimum near 5.0 rooms, so both the smallest and the largest units carry a premium over
the middle once income is held fixed; and the credit-limit effect is near-linear (edf 1.4), higher limits
going with lower default odds. The x-axes stop at the 99th percentile deliberately: AveOccup's maximum is
234x its p99, and letting a handful of such blocks set the range compresses every curve into a flat line.
No description has been provided for this image

4. Scoreboard and summary¶

The Bayesian nonparametric predictors, computed here, beside the frequentist field. The logistic and linear baselines are refitted in this notebook; the forest, XGBoost and BART figures are carried from the R tree notebooks, which share this notebook's set.seed(0) 70/30 split. They are deliberately not the Python notebook's numbers — that split is a different one, and mixing the two would make the bars incomparable.

In [5]:
# baselines computed HERE on this notebook's split; ensemble numbers carried from the R tree notebooks,
# which use the same set.seed(0) 70/30 split -- Python's figures are NOT interchangeable with these.
glm_auc<-auc(te$default, predict(glm(default~., tr, family=binomial), te, type="response"))
lin_rmse<-rmse(hte$MedHouseVal, predict(lm(MedHouseVal~., htr), hte))
credit<-c("logistic"=glm_auc,"random forest"=0.774,"XGBoost"=0.776,"BART (dbarts)"=0.783,
          "GP (kernlab)"=gp_auc,"GAM (mgcv)"=gam_auc)
cali  <-c("linear"=lin_rmse,"random forest"=0.491,"XGBoost"=0.484,"BART (dbarts)"=0.526,
          "GP (kernlab)"=gp_rmse,"GAM (mgcv)"=gam_rmse)
bnp<-c("GP (kernlab)","GAM (mgcv)","BART (dbarts)")
options(repr.plot.width=14, repr.plot.height=4.6); par(mfrow=c(1,2), mar=c(4,7,3,1))
barplot(rev(credit),horiz=TRUE,las=1,xlim=c(0.5,0.8),col=rev(ifelse(names(credit)%in%bnp,"#6b46c1","#a0aec0")),main="Credit -- test AUC (higher better)")
barplot(rev(cali),horiz=TRUE,las=1,col=rev(ifelse(names(cali)%in%bnp,"#6b46c1","#a0aec0")),main="California -- test RMSE (lower better)")
par(mfrow=c(1,1))
cat(sprintf("On California all three Bayesian nonparametric methods (purple) clear the linear model decisively: GAM %.3f and\n", gam_rmse))
cat(sprintf("GP %.3f against %.3f, with dbarts BART at 0.526 -- close enough to the forest's 0.491 to be a fair fight. Credit is\n", gp_rmse, lin_rmse))
cat(sprintf("the harder verdict: the mgcv GAM reaches %.3f and the GP %.3f against ensembles at 0.774-0.776, so they trail by\n", gam_auc, gp_auc))
cat("0.03 rather than matching. Part of that is a modelling choice -- the smooths here are fitted only to the continuous\n")
cat("features, with the discrete PAY_* and demographic terms entered linearly, where pyGAM splines every column and gets\n")
cat("0.765. What all three carry and none of the frequentist winners do is calibrated uncertainty: credible bands on every\n")
cat("smooth, a posterior variance at every input. Boosting still leads raw accuracy at full scale.\n")
Warning message:
"glm.fit: fitted probabilities numerically 0 or 1 occurred"
On California all three Bayesian nonparametric methods (purple) clear the linear model decisively: GAM 0.607 and
GP 0.611 against 0.734, with dbarts BART at 0.526 -- close enough to the forest's 0.491 to be a fair fight. Credit is
the harder verdict: the mgcv GAM reaches 0.743 and the GP 0.739 against ensembles at 0.774-0.776, so they trail by
0.03 rather than matching. Part of that is a modelling choice -- the smooths here are fitted only to the continuous
features, with the discrete PAY_* and demographic terms entered linearly, where pyGAM splines every column and gets
0.765. What all three carry and none of the frequentist winners do is calibrated uncertainty: credible bands on every
smooth, a posterior variance at every input. Boosting still leads raw accuracy at full scale.
No description has been provided for this image

Summary¶

kernlab and mgcv reproduce the Python benchmark: a Gaussian process (the Bayesian kernel machine, posterior mean = kernel ridge) and an additive-spline GAM (penalized smooths = a Bayesian random-walk prior, hence credible bands) are competitive with the tree ensembles — clearing the linear model decisively on California, and trailing the ensembles by about 0.03 AUC on credit — while adding the calibrated uncertainty the frequentist models lack. Their cost is the GP's $O(n^3)$ scaling (hence subsampling), the reason boosting remains the default for large tabular data. This is the package mirror of bnp_benchmark_python.ipynb, cross-linking the ML arc to the Bayesian Nonparametrics section — Gaussian-Process Regression, GP Classification & Log-Gaussian Cox Processes, Bayesian Penalised Splines & Additive Models — and to BART — Bayesian Additive Regression Trees.