--- title: "PCA and PLS charts: synthetic examples and held-out prediction" output: rmarkdown::html_vignette: toc: true vignette: > %\VignetteIndexEntry{PCA and PLS charts: synthetic examples and held-out prediction} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(collapse=TRUE, comment="#>", fig.width=7.2, fig.height=4.5, dpi=120) library(geosmooth) recompute <- isTRUE(getOption("geosmooth.recompute.vignettes", FALSE)) ``` `fit.local.likelihood()` can fit a local model in a small number of local coordinates, called a chart, computed either by principal component analysis (PCA) or by partial least squares (PLS). PCA finds the directions in which the predictors vary most. PLS also uses the response of the training data, so it can keep a direction in which the predictors vary little but the response changes strongly. That advantage is not guaranteed. With a noisy binary response the directions found by PLS can be unstable, and with either method a chart of low dimension may fail to capture a nonlinear response. This vignette compares the two methods on simulated binary data for which the true probabilities are known. It asks two separate questions. Does unfolding the simplex remove a bend in the geometry? And does a chart that uses the response improve prediction? All computations use the exported functions `fit.local.likelihood()` and `unfold.simplex.chart()`. The [local simplex charts guide](local-simplex-charts.html) explains the mathematics and the implementation. ## First inspect the geometry The data lie on the max-normalized simplex: the surface of points with nonnegative coordinates of which the largest equals one. Distance on this surface is the length of the shortest path that stays on it. We place points on two adjacent faces. A latent coordinate `t` runs from -0.2 to 0.2 and is mapped to `(1, 1+t)` on one face and to `(1-t, 1)` on the other, and the remaining coordinates stay below one. ```{r seam-geometry, fig.cap="A straight latent coordinate appears bent in ambient coordinates. Unfolding rotates the adjacent face into the query face; both axes use the same physical scale.", fig.alt="Two panels show a right-angle seam before unfolding and a straight line afterward."} t.line <- c(seq(-.2, -.005, length.out=30), seq(.005, .2, length.out=30)) seam <- cbind(ifelse(t.line < 0, 1, 1-t.line), ifelse(t.line < 0, 1+t.line, 1), .2) flat <- unfold.simplex.chart(seam, c(1, .95, .2)) stopifnot(flat$certified) old.par <- par(mfrow=c(1,2), mar=c(4,4,2,1)) plot(seam[,1:2], asp=1, pch=16, col="#0072B2", xlim=c(.76,1.24), ylim=c(.76,1.24), xlab="First coordinate", ylab="Second coordinate", main="Ambient coordinates") plot(flat$coordinates[,1:2], asp=1, pch=16, col="#009E73", xlim=c(.76,1.24), ylim=c(.76,1.24), xlab="First coordinate", ylab="Unfolded second coordinate", main="Unfolded coordinates") par(old.par) variance.one <- function(x) { values <- svd(scale(x, center=TRUE, scale=FALSE), nu=0, nv=0)$d^2 values[1]/sum(values) } knitr::kable(data.frame(Chart=c("Ambient", "Unfolded"), `Variance retained by one PC (%)`=100*c(variance.one(seam), variance.one(flat$coordinates)), check.names=FALSE), digits=1) ``` That the unfolded chart retains more variance is a statement about the geometry. It is not evidence that the outcome is predicted better; the examples below examine prediction separately. The charts with and without unfolding use the same neighbors and the same weights. Unfolding is refused near a corner, when two coordinates are tied for the maximum, or when a neighborhood extends over more than two faces. Such cases are counted and reported; they are not forced into a chart. ## Define examples before comparing methods Each observation has six coordinates. The seam coordinate `t` and a broad coordinate `u3` vary substantially. A narrow coordinate `u4` has a much smaller range; the remaining two coordinates are nuisance features. The table gives the true log odds. The true probability is `plogis(eta)` and the observed response is a Bernoulli draw with that probability. | Example | True log odds, eta | Question | |:--|:--|:--| | Seam | -1 + 2 t/0.2 | Does a chart retain variation across the bend? | | High variance | -1 + 2 (u3-0.3)/0.2 | Can PCA capture a signal aligned with broad variation? | | Low variance | -1 + 2 (u4-0.12)/0.02 | Does supervised PLS recover a narrow signal direction? | | Constant | -1 | Does adaptation to binary noise hurt prediction? | | Nonlinear | -1 + 2.5 ((t/0.2)^2 - 1/3) | How do local linear charts handle a symmetric nonlinear signal? | The narrow coordinate varies only from 0.10 to 0.14, yet in the low-variance example its effect on the log odds is made as large as the effect of the broad coordinate in the high-variance example. This is a deliberately hard case for dimension reduction based on variance. It is not a claim that such signals are common in real data. The predictors are not standardized before the directions are computed, because standardizing would change the geometry and remove part of the contrast under study. The component scores are scaled within the local logistic fit. ```{r generator} scenarios <- c("Seam", "High variance", "Low variance", "Constant", "Nonlinear") make.example <- function(seed, scenario, n=360L) { set.seed(seed) t <- runif(n, -.2, .2) X <- cbind(ifelse(t < 0, 1, 1-t), ifelse(t < 0, 1+t, 1), runif(n, .1, .5), runif(n, .10, .14), runif(n, .02, .06), runif(n, .02, .06)) eta <- switch(scenario, "Seam"=-1+2*t/.2, "High variance"=-1+2*(X[,3]-.3)/.2, "Low variance"=-1+2*(X[,4]-.12)/.02, "Constant"=rep(-1,n), "Nonlinear"=-1+2.5*((t/.2)^2-1/3)) probability <- plogis(eta) list(X=X, y=rbinom(n,1,probability), probability=probability, t=t) } ``` ## Fit on training observations; choose dimension on tuning observations Each of five independent repetitions per example has **180 training**, **60 tuning** and **120 test** observations. The observations are independent in this simulation. For repeated specimens or matched sets, splitting must respect those groups instead. We compare three charts: PCA in the original coordinates without unfolding (ambient PCA), PCA after unfolding, and PLS after unfolding. All three use 60 training neighbors, Gaussian weights, a ridge penalty of 1 on the slopes, and pseudo-counts of half a success and half a failure for the intercept. The neighborhood size and the ridge penalty are held fixed so that differences can be attributed to the charts; no claim is made that these values are optimal. For each chart we report two results: one with a chart of dimension one, and one with the dimension chosen among 1, 2 and 3 as the one with the lowest Brier loss on the tuning observations (the smaller dimension wins a tie). The predictions for the tuning observations are made from the training observations only. The predictions for the test observations come from the same fits; the tuning observations are not added to the training set. Neither the test outcomes nor the true probabilities play any part in choosing a model. ```{r fit-helper} methods <- c("Ambient PCA", "Unfolded PCA", "Unfolded PLS") fit.chart <- function(X, y, query, method, dimension, degree=1L) { fit.local.likelihood(X, y, X.eval=query, likelihood.family="bernoulli", chart.geometry="linf.simplex", unfold.faces=method != "Ambient PCA", coordinate.method=if (method == "Unfolded PLS") "local.pls" else "local.pca", support.size=60L, chart.dim=dimension, degree=degree, kernel="gaussian", lambda.ridge=1, intercept.mass=.5, max.iter=70L, tol=1e-7, fallback="degree0") } train <- 1:180 tune <- 181:240 test <- 241:360 ``` Here is a complete fit for the low-variance example. Replacing `"Unfolded PLS"` with `"Unfolded PCA"` changes the directions while retaining the same geometry and likelihood settings. ```{r single-fit, eval=FALSE} dat <- make.example(271301, "Low variance") fit <- fit.chart(dat$X[train,], dat$y[train], dat$X[test,], "Unfolded PLS", dimension=1L) head(fit$fitted.values) fit$diagnostics$status.counts ``` The loop below carries out the comparison. It includes two baselines. The local constant uses the same neighbors and weights as the charts but no directions, and the global constant is the smoothed proportion of successes in the whole training set. Both are useful for reference, especially in the example whose true probability is constant. The results shown are from all 25 repetitions (five examples, five repetitions each). To keep the vignette quick to build, they are read from a file stored with the package instead of being recomputed. The last section explains how to rerun the loop with the same seeds, sample sizes and settings. The geometry demonstration and the probability slices are computed each time the vignette is built. ```{r comparison, eval=recompute} rows <- list() illustrations <- list() append.result <- function(scenario, repetition, method, mode, dimension, prediction, truth, response, fallbacks, tuning.fallbacks=NA_integer_) { stopifnot(all(is.finite(prediction)), all(prediction > 0 & prediction < 1)) data.frame(scenario=scenario, repetition=repetition, method=method, mode=mode, dimension=dimension, probability.mse=mean((prediction-truth)^2), brier=mean((prediction-response)^2), log.loss=-mean(response*log(prediction)+(1-response)*log1p(-prediction)), fallbacks=fallbacks, tuning.fallbacks=tuning.fallbacks) } for (s in seq_along(scenarios)) for (repetition in 1:5) { scenario <- scenarios[s] dat <- make.example(271000+100*s+repetition, scenario) example.predictions <- list() for (method in methods) { fits <- lapply(1:3, function(d) fit.chart(dat$X[train,], dat$y[train], dat$X[c(tune,test),], method, d)) tuning.loss <- vapply(fits, function(f) mean((f$fitted.values[1:60]-dat$y[tune])^2), numeric(1)) best <- which.min(tuning.loss) for (mode in c("One dimension", "Tuned dimension")) { d <- if (mode == "One dimension") 1L else best chosen <- fits[[d]] prediction <- chosen$fitted.values[61:180] rows[[length(rows)+1L]] <- append.result(scenario, repetition, method, mode, d, prediction, dat$probability[test], dat$y[test], sum(chosen$diagnostics$per.eval$fallback[61:180]), sum(chosen$diagnostics$per.eval$fallback[1:60])) if (repetition == 1L) example.predictions[[paste(method,mode)]] <- prediction } } local <- fit.chart(dat$X[train,], dat$y[train], dat$X[test,], "Ambient PCA", dimension=1L, degree=0L) constants <- list("Local constant"=local$fitted.values, "Global constant"=rep((sum(dat$y[train])+.5)/(length(train)+1), length(test))) for (method in names(constants)) { rows[[length(rows)+1L]] <- append.result(scenario, repetition, method, "Constant", 0L, constants[[method]], dat$probability[test], dat$y[test], 0L) if (repetition == 1L) example.predictions[[method]] <- constants[[method]] } if (repetition == 1L) illustrations[[scenario]] <- list(data=dat, predictions=example.predictions) } results <- do.call(rbind, rows) stopifnot(nrow(results) == 200L) ``` ```{r stored-comparison, include=FALSE} if (!recompute) { stored <- readRDS(system.file("extdata", "vignette-results", "pca-pls-comparison.rds", package="geosmooth")) stopifnot(identical(stored$provenance$vignette.sha256, digest::digest(file=knitr::current_input(dir=TRUE), algo="sha256"))) results <- stored$results illustrations <- stored$illustrations } stopifnot(nrow(results) == 200L, length(illustrations) == 5L, all(table(results$scenario, results$repetition) == 8L)) ``` ## Measure estimation and prediction separately Two measures are reported, and lower is better for both. The **probability mean squared error (MSE)** compares the predicted probability with the true probability, which is known here, and so measures the accuracy of estimation directly. The **Brier loss** compares the predicted probability with the observed binary response, and therefore also contains the variation of the response itself, which no method can remove. The log loss on the test observations is stored in `results` as well. A low MSE, or an optimizer that converged, does not show that the predicted probabilities are well calibrated. The table gives means over the five repetitions. The figures show each repetition as well as the mean, so that a favorable average cannot conceal repetitions in which the order of the methods is reversed. Five repetitions are enough for an illustration. They do not make a simulation study with adequate power, and they do not rank the methods in general. ```{r performance-table} summary.table <- aggregate(cbind(probability.mse,brier) ~ scenario+method+mode, results, mean) summary.table <- summary.table[order(match(summary.table$scenario, scenarios), match(summary.table$mode,c("One dimension","Tuned dimension","Constant")), match(summary.table$method,c(methods,"Local constant","Global constant"))),] knitr::kable(summary.table, digits=4, row.names=FALSE, col.names=c("Example","Method","Chart","Probability MSE","Brier loss")) ``` ```{r performance-figure, fig.height=8, fig.cap="Held-out probability error for all five repetitions. Small points are independent repetitions; diamonds are their means. Each panel uses its own labelled vertical scale. Lower is better. The constant baselines are identical in the two columns.", fig.alt="Ten panels compare probability mean squared error across methods, with all repetitions and means shown for fixed and tuned chart dimensions."} colors <- c("Ambient PCA"="#0072B2", "Unfolded PCA"="#009E73", "Unfolded PLS"="#D55E00", "Local constant"="#666666", "Global constant"="#AA4499") labels <- c("PCA", "Unfolded\nPCA", "Unfolded\nPLS", "Local\nconstant", "Global\nconstant") old.par <- par(mfrow=c(5,2), mar=c(3.2,4,2,1), oma=c(0,0,1,0), cex=.8) for (scenario in scenarios) for (mode in c("One dimension","Tuned dimension")) { tab <- results[results$scenario==scenario & results$mode %in% c(mode,"Constant"),] order.methods <- names(colors) plot(NA, xlim=c(.7,5.3), ylim=c(0, max(tab$probability.mse)*1.08+1e-6), xaxt="n", xlab="", ylab="Probability MSE", main=paste(scenario,mode,sep=" — ")) axis(1, at=1:5, labels=labels, cex.axis=.8) for (j in 1:5) { values <- tab$probability.mse[tab$method==order.methods[j]] points(j+seq(-.12,.12,length.out=5), values, pch=16, cex=.7, col=adjustcolor(colors[j],alpha.f=.6)) points(j, mean(values), pch=18, cex=1.3, col=colors[j]) } } par(old.par) ``` ## Inspect the first repetition, without choosing favorable seeds The next plot shows predicted against true probabilities for the test observations of the **first repetition** of each example. The first repetition was designated in advance and was not chosen for its results. Each point is one test observation. On the diagonal, the prediction equals the true probability; the diagonal is not a fitted line. In the constant example all observations have the same true probability, so their vertical spread shows how much each method follows the noise. ```{r predictions-figure, fig.height=8, fig.cap="Predicted versus known true probabilities for first-repetition test observations. Columns compare unfolded PCA and PLS, each with one dimension. Points near the diagonal recover the true probabilities; all panels share the same axes.", fig.alt="Ten scatterplots show true versus predicted test probabilities for unfolded PCA and unfolded PLS, including constant and nonlinear cases."} old.par <- par(mfrow=c(5,2), mar=c(3,3.3,2,1), cex=.8) for (scenario in scenarios) for (method in c("Unfolded PCA","Unfolded PLS")) { item <- illustrations[[scenario]] prediction <- item$predictions[[paste(method,"One dimension")]] plot(item$data$probability[test], prediction, xlim=c(0,1), ylim=c(0,1), xlab="True probability", ylab="Predicted probability", pch=16, cex=.65, col=adjustcolor(colors[method],alpha.f=.55), main=paste(scenario,method,sep=" — ")) abline(0,1,lty=2,col="grey35") } par(old.par) ``` ## View the estimated probability functions The curves below are fitted to the training observations of the first repetition. In each panel the coordinate that carries the signal varies over its range while the other coordinates are fixed at the centers of their ranges. The curves are the fitted probabilities at those points; they are not averages of the points in the scatterplots above. The black curve is the true probability. All methods use charts of dimension one, and nothing was tuned again for these plots. ```{r slice-figure, fig.height=7.5, fig.cap="Conditional probability slices through the same first-repetition training fits. Nuisance coordinates are fixed at their generating centers. Black is the known truth; colored curves use one-dimensional charts. All probability axes run from zero to one.", fig.alt="Five probability curves compare the known truth with ambient PCA, unfolded PCA and unfolded PLS as the signal coordinate changes."} slices <- list() old.par <- par(mfrow=c(3,2), mar=c(4,4,2,1), cex=.85) for (scenario in scenarios) { grid <- seq(-1,1,length.out=60) t <- if (scenario %in% c("Seam","Nonlinear","Constant")) .2*grid else rep(.08,60) X.slice <- cbind(ifelse(t < 0,1,1-t), ifelse(t < 0,1+t,1), .3,.12,.04,.04) if (scenario=="High variance") X.slice[,3] <- .3+.2*grid if (scenario=="Low variance") X.slice[,4] <- .12+.02*grid eta <- if (scenario=="Constant") rep(-1,60) else if (scenario=="Nonlinear") -1+2.5*(grid^2-1/3) else -1+2*grid truth <- plogis(eta) dat <- illustrations[[scenario]]$data prediction <- vapply(methods, function(method) fit.chart(dat$X[train,],dat$y[train],X.slice,method,1L)$fitted.values, numeric(60)) axis.value <- if (scenario=="High variance") X.slice[,3] else if (scenario=="Low variance") X.slice[,4] else t axis.label <- if (scenario=="High variance") "Broad coordinate u3" else if (scenario=="Low variance") "Narrow coordinate u4" else "Seam coordinate t" plot(axis.value,truth,type="l",lwd=2,col="black",ylim=c(0,1), xlab=axis.label,ylab="Probability",main=scenario) for (j in seq_along(methods)) lines(axis.value,prediction[,j],col=colors[j],lwd=1.5,lty=j) slices[[scenario]] <- list(X=X.slice,truth=truth,prediction=prediction) } plot.new() legend("center",c("Truth",methods),col=c("black",colors[methods]), lty=c(1,1,2,3),lwd=c(2,1.5,1.5,1.5),bty="n",cex=.9) par(old.par) ``` ## Check selected dimensions and fallbacks ```{r diagnostic-tables} tuned <- results[results$mode=="Tuned dimension",] selection <- as.data.frame(xtabs(~scenario+method+dimension, tuned)) selection <- selection[selection$Freq>0,] knitr::kable(selection, row.names=FALSE, col.names=c("Example","Method","Selected dimension","Repetitions")) knitr::kable(aggregate(cbind(fallbacks,tuning.fallbacks) ~ scenario+method+mode, results, sum), row.names=FALSE, col.names=c("Example","Method","Chart","Test fallbacks","Tuning fallbacks")) ``` A fallback occurs when a chart cannot be certified or the optimizer fails; the prediction is then the smoothed local proportion. These predictions are kept in the scores above and are not removed to make a method look better. The table counts fallbacks only among the fits that are reported, namely those with dimension one and those with the selected dimension. Some fits are counted under both headings, so the two counts should not be added. `results` also contains the selected dimension and the test log loss of each fit. ## Reading the comparison The low-variance example shows that using the response to choose directions is not sufficient in itself. With one dimension, unfolded PCA and PLS have mean probability MSEs of `r sprintf("%.4f", subset(summary.table, scenario=="Low variance" & method=="Unfolded PCA" & mode=="One dimension")$probability.mse)` and `r sprintf("%.4f", subset(summary.table, scenario=="Low variance" & method=="Unfolded PLS" & mode=="One dimension")$probability.mse)`, respectively. After selecting dimension on the tuning observations, these become `r sprintf("%.4f", subset(summary.table, scenario=="Low variance" & method=="Unfolded PCA" & mode=="Tuned dimension")$probability.mse)` and `r sprintf("%.4f", subset(summary.table, scenario=="Low variance" & method=="Unfolded PLS" & mode=="Tuned dimension")$probability.mse)`. Weighted PLS is based on covariances, so it remains sensitive to the scale of the predictors, to the noise in a binary response, and to the number of directions kept. In this example its advantage for the narrow signal appears only after the dimension has been selected; its first direction alone does not provide it. In the constant example the global constant has the lowest probability MSE. In the high-variance example PCA with a tuned dimension has a lower mean probability MSE than PLS with a tuned dimension. Tuning the dimension sometimes makes the test results worse than the fit of dimension one, because 60 tuning observations give a noisy criterion for the choice. This is why the vignette shows the baselines, both the fixed and the selected dimension, and every repetition. Each example was designed to isolate one issue. The seam example separates the correction of a fold in the geometry from the learning of a direction related to the response. The high-variance example suits the criterion of PCA. The low-variance example puts the information about the response in a direction that PCA is likely to discard; a chart of higher dimension lets PCA recover it, at the price of more variable estimates. PLS can help at low dimension, provided its directions are computed from the training data alone. The constant example is a necessary control, since it contains no direction to be found. The nonlinear example tests whether a nonlinear function is close enough to linear within a neighborhood. Where the signal is symmetric about a point, the local covariance on which PLS relies can be weak even though the response depends strongly on position. None of the methods is expected to be best throughout, and the variation among repetitions matters as much as the averages. Before relying on such a comparison in a scientific analysis, extend the simulation to realistic sample sizes, class balance, dependence among observations, missing data and geometry. Tune the neighborhood size and the penalties within the training data, assess the calibration of the probabilities on a separate hold-out sample, and examine the results near corners of the simplex and where the data are sparse. Under case-control sampling, the probabilities estimated in the selected sample do not give the prevalence in the population unless the sampling fractions are known. ## Reproduce and adapt The stored results were produced by the code shown above. They contain all 25 seeds, all 200 rows of results, and the predictions of the first repetition of each example, and nothing else: no real data and no reference predictions from other software. They also record the package versions and the SHA-256 checksum of this vignette. When the vignette is built, it checks that the stored results were produced from the present version of its source. To recompute every comparison while rendering, use: ```{r reproduce-comparison, eval=FALSE} options(geosmooth.recompute.vignettes=TRUE) rmarkdown::render("vignettes/pca-pls-chart-comparison.Rmd") ``` After changing this vignette, regenerate the stored results by running `Rscript vignettes/precompute-pca-pls.R` from the package source directory. The computation runs serially and needs only single-threaded BLAS and OpenMP. The package versions used for the stored results are listed below. `?fit.local.likelihood` and `?unfold.simplex.chart` describe the fitting options and the diagnostics. ```{r save-validation, include=FALSE} # A developer may request a machine-readable audit copy; ordinary vignette # builds do not write outside knitr's output directory. audit.path <- Sys.getenv("GEOSMOOTH_VIGNETTE_AUDIT", unset="") if (nzchar(audit.path)) saveRDS(list(results=results, illustrations=illustrations, slices=slices, summary=summary.table, session=sessionInfo()), audit.path) ``` ```{r session, echo=FALSE} if (!recompute) { stored$provenance$package.versions } else { c(geosmooth=as.character(packageVersion("geosmooth")), dgraphs=as.character(packageVersion("dgraphs"))) } ```