fastPLS combines partial least squares (PLS) modelling
with truncated singular value decomposition (SVD), compiled numerical
implementations, and optional accelerator execution. This vignette first
introduces the mathematical methods, then explains their CPU, CUDA, and
Metal implementations, and finally presents the public analysis
workflow.
fastPLS separates the PLS model from the singular-vector
algorithm used inside the fit. fastPLS uses its native randomized SVD
(rSVD) implementation following the randomized range-finder strategy of
Halko, Martinsson, and Tropp (2011). In the compiled CPU implementation,
a Gaussian test matrix is generated, the target matrix is sketched,
optional power iterations are applied, the sketch is orthonormalized by
an economic QR decomposition, and the final singular vectors are
obtained from a much smaller projected matrix. When the randomized
sketch dimension reaches the smaller matrix dimension, or when one
matrix dimension is very small, the code falls back to an exact SVD
because a truncated randomized approximation would no longer reduce the
computation.
Native rSVD is the only public SVD solver. It is approximate: finite
factors alone do not establish accurate singular directions or
predictive agreement. Standalone fastsvd() reports
singular-triplet checks and effective controls. If ordinary CPU sketches
fail their checks, native rSVD retries with wider fresh sketches and
additional power iterations. Failed or over-budget recovery raises an
error. The recovery budget concerns major numerical buffers, not
complete-process memory.
Sequential PLS fitting reports its seed, effective controls,
execution profile, finite-factor checks and direction rule. These
diagnostics do not establish equivalence to an independent PLS
implementation. Compare several seeds and, when feasible, an independent
reference before interpreting sensitive coefficients or latent
subspaces. Increasing power or oversample can
improve an approximation but is not a guarantee of agreement. Native
rSVD is the only solver exposed by this package. PLS-SVD and standalone
fastsvd() use oversample = 32,
power = 5 by default. Accelerated SIMPLS, OPLS, and
kernel-PLS use 32 oversampling directions and five power iterations for
ordinary matrix shapes. Numeric responses with at least 64 columns and a
response-to-sample ratio of at least 0.2 use 48 directions and six
iterations. Classification with at least 32 classes and no more than 20
samples per class uses 64 directions and seven iterations. When the
explicit predictor-response cross-covariance would exceed 512 MiB, the
massive-matrix profile requests oversample = 12 and
power = 1; executed controls and refresh width are recorded
separately. CPU, Metal, and ordinary CUDA SIMPLS-family routes generate
a seeded sketch from the current deflated cross-covariance. On a massive
problem, CPU, Metal, and CUDA float64 use a rank-one randomized
direction at each refresh. CUDA float32 can obtain up to eight
independently initialized candidates per refresh through component 64
before returning to rank-one refreshes; CUDA keeps the sequential state
on the device. For eligible large dummy-coded classification, CPU, CUDA,
and Metal can instead obtain a candidate block of up to 64 directions.
Candidates are accepted sequentially through the SIMPLS
orthogonalization and deflation equations. Because the cross-covariance
changes after every accepted component, the block route is an
approximate sequential estimator and is not claimed to equal a fresh
dominant-direction solve at every step. The explicit and matrix-free
routes evaluate the same cross-covariance operator.
For large predictor-response cross-products, fastPLS can
avoid forming S = X^T Y. This matrix-free route is called
xprod in the code. The SVD backend evaluates products with
S and S^T only when needed, for example
Sv = X^T(Yv) and S^Tu = Y^T(Xu). This
preserves the same mathematical operator while reducing memory pressure.
For classification, labels can be used as PLS-DA responses without
explicitly materializing a large dense one-hot matrix in the label-aware
paths.
method = "plssvd" implements the direct PLS-SVD
formulation, in which the dominant singular subspace of the
predictor-response cross-covariance matrix is computed once and reused
for all requested component counts. This makes PLS-SVD efficient when
the response rank is the limiting dimension. The implementation adds
compact latent prediction factors: instead of always storing and
applying a full coefficient array, prediction can be performed through
low-rank factors based on the latent projection and response-side
coefficients. The cached score-Gram route forms the score cross-product
once and reuses its leading blocks for the requested prefixes. If the
centred cross-covariance is approximated by \(S \approx UDV^\top\), scores are \(T_a=XU_a\) and the response map solves
\((T_a^\top T_a)H_a=D_aV_a^\top\).
Predictions are \(X_{\mathrm{new}}U_aH_a\), with the training
response mean restored. Singular directions alone are therefore not
regression coefficients. PLS-SVD can also use the matrix-free
xprod route for randomized SVD when the cross-covariance
matrix would be large.
method = "simpls" is the optimized SIMPLS implementation
and is the default general-purpose PLS core. The statistical structure
follows de Jong’s SIMPLS algorithm (de Jong, 1993): each component is
obtained from the current cross-covariance state, converted into a score
and loading direction, and followed by SIMPLS orthogonalization and
deflation. The rSVD route generates candidates from a newly randomized
sketch at each refresh of the current deflated operator; a refresh can
supply one or several candidates. Like pls::simpls.fit, one
fit provides the sequential path through all components up to
ncomp; fastPLS does not claim this standard path
construction as a novelty. Cross-covariance deflation is updated after
each accepted component. The component-wise CPU rSVD route derives a
reproducible component-specific seed and generates a new Gaussian sketch
at each refresh. Metal initializes one reproducible random sequence per
fit and draws a new sketch for each component. CUDA generates newly
initialized randomized candidates in resident device workspaces.
Eligible large dummy-coded classification fits can generate up to 64
directions per CPU, CUDA, or Metal decomposition and consume them
sequentially. The CPU route also evaluates candidate scores and
predictor loadings in matrix blocks. Eligibility depends on predictor,
response and sample dimensions, and the requested component count; it is
not determined by sample count alone. This batched approximate route
need not reproduce a fresh dominant-direction solve after each
deflation. The ordinary CPU regression route remains component-wise;
CUDA float32 additionally has a bounded massive-response block route.
The C++ core also caches the rank-one deflation product, updates
coefficient and fitted-value paths incrementally, and uses compact
prediction factors. For low-dimensional high-sample problems, the code
can cache cross-products such as X^T X and
X^T Y; for large response or predictor dimensions, it can
instead use matrix-free xprod products. These routes are
selected by matrix shape: they reduce avoidable work or storage in their
intended regimes but are not asserted to make every SIMPLS problem
faster.
When the response dimension is no larger than the predictor dimension and is at most 512, the randomized direction calculation can use the smaller response-side Gram matrix \(S^\top S\). After each accepted component, fastPLS updates this matrix with the corresponding rank-one deflation product. This avoids repeatedly sketching the full tall cross-covariance while leaving the subsequent SIMPLS score, loading, orthogonalization, and deflation steps unchanged. The CPU implementation uses BLAS/LAPACK operations, CUDA keeps the Gram matrix and its updates in device memory for float32 and float64, and Metal uses its native float32 matrix operators.
method = "opls" implements orthogonal PLS following the
orthogonal signal correction idea of Trygg and Wold (2002). It estimates
response-orthogonal variation in X, subtracts this
structured variation, stores the orthogonal filter, and then fits the
optimized PLS core to the filtered matrix. The same filter is applied to
new samples at prediction time.
method = "kernelpls" follows kernel PLS as described by
Rosipal and Trejo (2001). Linear kernel PLS is dispatched to the
ordinary linear PLS core to avoid unnecessary kernel materialization.
For radial-basis and polynomial kernels, the training kernel is centered
and the selected PLS core is fitted in kernel space. The same
user-facing pls() function therefore covers linear PLS-SVD,
SIMPLS, OPLS, and kernel PLS while sharing the accelerated low-rank
machinery.
Classification is handled as PLS-DA followed by one of two decoding
heads. classifier = "argmax" uses the largest predicted
dummy-response score and is the standard PLS-DA decoding rule.
classifier = "lda" fits a regularized linear discriminant
analysis model in the PLS score space following Fisher’s discriminant
principle (Fisher, 1936).
The backend argument selects where the main numerical
work is performed. backend = "cpu" uses the package’s
compiled, dependency-free C++ core with native rSVD. It is usually best
for small and moderate datasets, low-dimensional problems, and machines
without GPU support. The CPU kernels use Apple Accelerate by default on
macOS. Linux and Windows prefer OpenBLAS when configuration finds it
through OPENBLAS_ROOT, pkg-config, or Rtools;
otherwise the package uses the BLAS/LAPACK supplied by R. Set
FASTPLS_USE_OPENBLAS=1 before installation to require
OpenBLAS. fastPLS_blas() reports the library selected when
the installed package was compiled. Linux and Windows performance
benchmarks should proceed only when this function returns
"OpenBLAS". Linking against multithreaded OpenBLAS enables
eligible matrix operations to use multiple CPU cores. For example,
options(backend = "cpu", n.cores = 4L) selects CPU
execution and requests four threads from the linked BLAS/OpenMP runtime.
Explicit backend and n.cores arguments
override their respective session options. The same resolved
n.cores value is retained by nested folds, refits,
permutations, and predictions. The sequential PLS deflation steps remain
serial, so additional cores do not guarantee a speed improvement for
every matrix shape. Additional BLAS threads help only when the matrix
products are sufficiently large; sequential updates and
thread-management overhead can limit or reverse the benefit. Compare
timings on the intended workload rather than assuming that more threads
are always faster.
For CPU self-products, fastPLS selects GEMM or the symmetric rank-k operation SYRK from the linked numerical library according to platform, precision, and matrix dimensions. SYRK computes one triangle; the other triangle is generated only when required by a later operation. Owned workspaces are aligned, and wide response matrices gathered for repeated fold computations use 64-byte column alignment. Compiled classification fitting and cross-validation products operate on compact labels and class sums, so they do not allocate a dense one-hot training-response matrix.
For eligible wide-response SIMPLS cross-validation, the compiled
engine forms the uncentred sample-response Gram matrix once before the
fold loop. Each training-fold Gram matrix is recovered from its
principal submatrix and exactly double-centred, avoiding a materialized
centred response matrix. CPU execution uses direct SSYRK or
DSYRK; float32 CUDA uses cublasSsyrk; and the
hybrid Metal route uses Accelerate SYRK for this host-visible operation.
The raw Gram matrix retains only its lower triangle, and fold, output,
and device workspaces are reused across the cross-validation call.
backend = "cuda" uses CUDA-native routines when the
package is compiled with CUDA and a compatible NVIDIA runtime is
available. After input transfer, CUDA paths perform preprocessing,
cross-products, randomized direction extraction, component updates,
low-rank prediction, and LDA scoring on the GPU for PLS-SVD, SIMPLS,
OPLS, and kernel PLS. OPLS filtering and application of the stored
orthogonal filter remain device-resident. Nonlinear kernel PLS
constructs and centers both training and test Gram matrices on the GPU.
Returned arrays and R model-object assembly are host operations. CUDA is
most useful when dense matrix work offsets transfer and kernel-launch
overhead. Nonlinear kernels are also subject to an explicit
device-memory guard because their Gram matrix uses quadratic storage in
the training sample count.
backend = "metal" provides a float32 route for PLS-SVD,
SIMPLS, OPLS, and kernel PLS on Apple silicon unified-memory systems. It
always enters the same operation-split algorithm rather than selecting a
complete CPU or Metal estimator from the dataset dimensions. The CPU
performs preprocessing, QR and reduced decompositions, sequential
orthogonalization, deflation bookkeeping, model assembly, LDA, and
prediction. Persistent Metal workspaces perform fitting products
involving the training sample matrix. These include explicit
cross-covariance formation, fused score/loading products, and randomized
range-finder products when the cross-covariance is represented
implicitly. In the transpose part of that implicit operator, consecutive
products X V and Y^T (X V) share one Metal
command buffer and one final synchronization before the CPU applies the
response-centering correction. Reusable buffers and Metal matrix objects
are retained for this sequence during the fit. The smaller
cross-covariance and component-state operations remain on the CPU. This
mathematical assignment is fixed and does not switch according to
dataset dimensions. The route requires Metal support and float32 input
and never silently converts a float64 request to CPU.
CPU is the default backend. After choosing a mathematical model, users can set one execution backend for the R session when several calls should use the same hardware:
options(backend = "cuda")
fit <- pls(X, y, method = "simpls") # CUDA session default
fit.cpu <- pls(X, y, backend = "cpu") # explicit overrideBatch jobs may use FASTPLS_BACKEND=cpu|cuda|metal.
Precedence is an explicit function argument,
options(backend = ...), FASTPLS_BACKEND, and
finally CPU. For CPU execution, options(n.cores = 4L)
requests four threads from the linked BLAS/OpenMP runtime. Eligible
matrix operations may use these threads, but sequential PLS deflation
remains serial and multicore acceleration depends on matrix shape and
the installed numerical library. An explicit n.cores =
argument takes precedence over the option. The first operation that
resolves an unavailable option or environment value raises an error; CPU
is never substituted. An explicitly requested
backend = "auto" in prediction retains model-aware
prediction dispatch and errors if the fitted model requires an
unavailable accelerator.
The terms in this table distinguish implementation coverage and data
residency. Tested denotes a route covered by fixed-seed
numerical tests. Approximate identifies rSVD execution.
Native means that preprocessing, cross-products,
decomposition, component updates, prediction, and an optional LDA head
execute on the selected device after input transfer.
Unavailable combinations stop; they do not silently fall
back to CPU.
The table uses T for tested, A for approximate, N for device-native, H for an explicit host/device hybrid, and U for unavailable.
| Model or operation | CPU | CUDA | Metal |
|---|---|---|---|
| PLS-SVD, float64 | T/A | T/A/N | U |
| SIMPLS, float64 | T/A | T/A/N | U |
| OPLS, float64 | T/A | T/A/N | U |
| Linear kernel PLS, float64 | T/A | T/A/N | U |
| Nonlinear kernel PLS, float64 | T/A | T/A/N | U |
| PLS-SVD, float32 | T/A | T/A/N | T/A/H |
| SIMPLS, float32 | T/A | T/A/N | T/A/H |
| OPLS, float32 | T/A | T/A/N | T/A/H |
| Linear kernel PLS, float32 | T/A | T/A/N | T/A/H |
| Nonlinear kernel PLS, float32 | T/A | T/A/N | T/A/H |
| Argmax PLS-DA | T | T/N | T/H |
| Latent-space LDA | T | T/N | T/H |
Float32 support is route and platform dependent, as described in the Float32 Input section. CUDA uses a device-native route, whereas Metal uses the fixed CPU/Metal operation split. Metal float64 requests stop because the hardware does not provide native double-precision arithmetic. Nonlinear kernel workloads that exceed the guarded device-memory budget stop before allocation and never fall back to another method or backend.
Users select the mathematical method and implementation through the public functions; the internal C++, CUDA, Metal, and benchmarking helpers are not called directly.
| Function | Purpose |
|---|---|
pls() |
Fit PLS models for regression or classification. |
predict() |
Predict from fitted fastPLS, OPLS, or kernel PLS
models. |
plot() |
Plot PLS scores and optional ellipses. |
plot.permutation() |
Plot R2/Q2 diagnostics from a PLS permutation test. |
pls.single.cv() |
Select components by grouped cross-validation. |
pls.double.cv() |
Run nested cross-validation. |
evaluate() |
Evaluate classification or regression predictions. |
fastsvd() |
Run the stand-alone native CPU randomized SVD. |
fastcor() |
Compute fast Pearson-style correlations. |
ViP() |
Compute variable-importance-in-projection trajectories. |
fastPLS_blas() |
Report the CPU numerical library selected at compilation. |
has_cuda() |
Check whether CUDA-native fastPLS support is available. |
has_metal() |
Check for Apple Metal support. |
pls() and predict() accept a
Biobase::ExpressionSet directly. Expression measurements
are stored as variables by samples in an ExpressionSet, so
fastPLS transposes the assay internally to its samples-by-variables
layout. The following example predicts the case-control phenotype in the
Biobase example data while keeping the test samples separate from model
fitting.
data("sample.ExpressionSet", package = "Biobase")
y_bioc <- factor(Biobase::pData(sample.ExpressionSet)$type)
set.seed(42)
test_bioc <- sample(seq_len(ncol(sample.ExpressionSet)), 8)
fit_bioc <- pls(
sample.ExpressionSet[, -test_bioc],
y_bioc[-test_bioc],
sample.ExpressionSet[, test_bioc],
y_bioc[test_bioc],
ncomp = 1:2,
method = "simpls",
backend = "cpu",
seed = 42
)
fit_bioc$accuracy
#> ncomp=1 ncomp=2
#> 0.875 0.750| Goal | Suggested call |
|---|---|
| Fast exploratory modelling | SIMPLS with CPU rSVD |
| Repeatable CPU analysis | backend = "cpu", seed = 1 |
| Direct cross-covariance PLS-SVD | method = "plssvd" |
| Remove response-unrelated structured variation | method = "opls" |
| Nonlinear relationships | Kernel PLS with an RBF or polynomial kernel |
| Classification: argmax PLS-DA decoding | classifier = "argmax" |
| Classification: latent-space discriminant analysis | classifier = "lda" |
| Small or moderate data | backend = "cpu" |
| Large dense matrices | CUDA when available |
| Apple Silicon acceleration | Metal when available |
| Float32 CPU input | float::fl(X), backend = "cpu" |
The public classification choices are "argmax" and
"lda"; regression ignores classifier. LDA uses
the fixed scale-normalized Cholesky fallback sequence described above
and does not expose a ridge-tuning argument.
For classification, responses are supplied as factors.
fastPLS handles the PLS-DA response encoding internally and
returns predicted class labels. The classifier argument is
used only for this type of task and selects the classification head:
argmax or lda. These options are not
regression models and are not used for numeric responses. The examples
in this section use iris for compact multiclass
classification and sample.ExpressionSet from Biobase for a
biomedical two-class score plot.
set.seed(100)
X <- as.matrix(iris[, 1:4])
Y_cls <- iris$Species
cls_test_id <- sample(seq_len(nrow(X)), 30)
Xtrain <- X[-cls_test_id, , drop = FALSE]
Xtest <- X[cls_test_id, , drop = FALSE]
Ytrain_cls <- Y_cls[-cls_test_id]
Ytest_cls <- Y_cls[cls_test_id]The method argument selects the PLS algorithm,
backend selects the implementation, and
classifier selects the classification head. The default
classification head is classifier = "argmax", which
predicts the class with the largest PLS-DA response score.
fit_cls <- pls(
Xtrain,
Ytrain_cls,
Xtest,
Ytest_cls,
ncomp = 1:2,
fit = TRUE,
return_variance = FALSE,
seed = 101
)
fit_cls$accuracy
#> ncomp=1 ncomp=2
#> 0.6666667 0.7666667Classification models can also be fitted once and predicted later.
predict() returns only the predicted class by default and
can optionally return ranked classes by setting top to a
positive integer. The ordinary prediction in Ypred is
always the rank-1 class. When top > 1,
Ypred_top contains the ordered candidate labels for each
sample: rank1 is the predicted class, rank2 is
the next most likely class, and so on. If available,
Ypred_top_score contains the corresponding class scores
used to create that ranking. For both float64 and float32 models, the
default raw_scores = FALSE evaluates ranked predictions in
bounded row blocks and retains only the requested ranks. Set
raw_scores = TRUE only when the complete class-score arrays
are needed, because those arrays can be substantially larger.
fit_cls_train_only <- pls(
Xtrain,
Ytrain_cls,
ncomp = 1:2,
classifier = "lda",
fit = TRUE,
return_variance = FALSE,
seed = 101
)
pred_cls_later <- predict(
fit_cls_train_only,
Xtest,
Ytest = Ytest_cls,
top = 2,
raw_scores = TRUE
)
pred_cls_later$accuracy
#> ncomp=1 ncomp=2
#> 1 1
pred_cls_later$metrics$metrics
#> n accuracy no_information_rate lift_accuracy
#> ncomp=1 30 1 0.3333333 3
#> ncomp=2 30 1 0.3333333 3
#> balanced_accuracy macro_precision macro_recall macro_f1
#> ncomp=1 1 1 1 1
#> ncomp=2 1 1 1 1
#> kappa
#> ncomp=1 1
#> ncomp=2 1
head(pred_cls_later$Ypred_top[["ncomp=2"]])
#> rank1 rank2
#> [1,] "virginica" "versicolor"
#> [2,] "virginica" "versicolor"
#> [3,] "setosa" "versicolor"
#> [4,] "versicolor" "virginica"
#> [5,] "versicolor" "virginica"
#> [6,] "versicolor" "virginica"Use evaluate() to summarize predicted class labels,
ranked labels, class-score matrices, or a complete object returned by
predict(). The task and requested rank are inferred from
these inputs. For classification, lift_accuracy is accuracy
divided by the no_information_rate, the accuracy obtained
by always predicting the most frequent observed class. Values above one
therefore improve on this simple majority-class baseline. For
classification, the complete output includes global metrics, per-class
metrics, and the confusion matrix.
eval_cls_path <- evaluate(
observed = Ytest_cls,
predicted = pred_cls_later
)
eval_cls <- eval_cls_path$by_component[["ncomp=2"]]
eval_cls
#> $task
#> [1] "classification"
#>
#> $metrics
#> n accuracy no_information_rate lift_accuracy balanced_accuracy
#> 1 30 1 0.3333333 3 1
#> macro_precision macro_recall macro_f1 kappa
#> 1 1 1 1 1
#>
#> $metric_definitions
#> $metric_definitions$accuracy
#> [1] "Proportion of observed labels predicted correctly."
#>
#> $metric_definitions$balanced_accuracy
#> [1] "Unweighted mean of class-specific recalls."
#>
#>
#> $per_class
#> class support precision recall f1
#> 1 setosa 10 1 1 1
#> 2 versicolor 10 1 1 1
#> 3 virginica 10 1 1 1
#>
#> $confusion
#> observed
#> predicted setosa versicolor virginica
#> setosa 10 0 0
#> versicolor 0 10 0
#> virginica 0 0 10
#>
#> $topk
#> k accuracy
#> 1 1 1
#> 2 2 1The same complete evaluation is also stored automatically in a fitted PLS object when observed test responses are supplied. Results are grouped first by data role and then by component count.
fit_cls$metrics$test[["ncomp=2"]]$metrics
#> n accuracy no_information_rate lift_accuracy balanced_accuracy
#> 1 30 0.7666667 0.3333333 2.3 0.7666667
#> macro_precision macro_recall macro_f1 kappa
#> 1 0.7765568 0.7666667 0.7612958 0.65Rows of the confusion matrix are predicted labels and columns are observed labels. The confusion matrix is returned as an ordinary R table.
eval_cls$confusion
#> observed
#> predicted setosa versicolor virginica
#> setosa 10 0 0
#> versicolor 0 10 0
#> virginica 0 0 10When class-score matrices are available, evaluate() can
also report top-k accuracy. Top-k accuracy asks whether the true class
appears anywhere among the first k ranked labels. Thus
top-1 accuracy is ordinary classification accuracy, while top-5 accuracy
gives credit when the correct class appears among the five
highest-scoring alternatives.
score_last <- pred_cls_later$LDA_scores[
, , dim(pred_cls_later$LDA_scores)[3L]
]
evaluate(
observed = Ytest_cls,
predicted = score_last
)
#> $task
#> [1] "classification"
#>
#> $metrics
#> n accuracy no_information_rate lift_accuracy balanced_accuracy
#> 1 30 1 0.3333333 3 1
#> macro_precision macro_recall macro_f1 kappa
#> 1 1 1 1 1
#>
#> $metric_definitions
#> $metric_definitions$accuracy
#> [1] "Proportion of observed labels predicted correctly."
#>
#> $metric_definitions$balanced_accuracy
#> [1] "Unweighted mean of class-specific recalls."
#>
#>
#> $per_class
#> class support precision recall f1
#> 1 setosa 10 1 1 1
#> 2 versicolor 10 1 1 1
#> 3 virginica 10 1 1 1
#>
#> $confusion
#> observed
#> predicted setosa versicolor virginica
#> setosa 10 0 0
#> versicolor 0 10 0
#> virginica 0 0 10
#>
#> $topk
#> k accuracy
#> 1 1 1
#> 2 2 1
#> 3 3 1The same pls() interface exposes two
classification-specific heads for factor responses: argmax PLS-DA and
latent-space LDA. They are decoders applied after the PLS model has
produced class-response scores or latent scores. For regression, leave
classifier at its default; numeric responses are predicted
directly as continuous values.
fit_cls_plssvd <- pls(
Xtrain,
Ytrain_cls,
Xtest,
Ytest_cls,
ncomp = 1:2,
method = "plssvd",
seed = 100
)
head(fit_cls_plssvd$Ypred)
#> ncomp=1 ncomp=2
#> 1 virginica versicolor
#> 2 virginica virginica
#> 3 setosa setosa
#> 4 virginica virginica
#> 5 virginica versicolor
#> 6 virginica virginica
evaluate(
observed = Ytest_cls,
predicted = fit_cls_plssvd$Ypred[["ncomp=2"]]
)$confusion
#> observed
#> predicted setosa versicolor virginica
#> setosa 10 0 0
#> versicolor 0 5 2
#> virginica 0 5 8For PLS-DA with an LDA prediction head, use
classifier = "lda". On systems with GPU support, the
backend is selected through backend = "cuda" or
backend = "metal" where available:
fit_cls_lda_gpu <- pls(
Xtrain,
Ytrain_cls,
Xtest,
Ytest_cls,
ncomp = 1:2,
method = "plssvd",
backend = "cuda",
classifier = "lda"
)An unavailable CUDA or Metal selection raises an error; fastPLS never changes the request silently to CPU. To run on the CPU, select it explicitly:
fit_cls_lda_cpu <- pls(
Xtrain,
Ytrain_cls,
Xtest,
Ytest_cls,
ncomp = 1:2,
method = "plssvd",
backend = "cpu",
seed = 100,
classifier = "lda"
)
head(fit_cls_lda_cpu$Ypred)
#> ncomp=1 ncomp=2
#> 1 virginica virginica
#> 2 virginica virginica
#> 3 setosa setosa
#> 4 versicolor versicolor
#> 5 versicolor versicolor
#> 6 versicolor versicolorKernel PLS changes the representation of the samples before the inner
PLS fit. The linear kernel is equivalent to an ordinary
inner-product representation and is useful as a fast baseline. The
rbf kernel uses a radial-basis similarity; the
poly kernel uses polynomial interactions among
features.
kernel_fits <- lapply(c("linear", "rbf", "poly"), function(k) {
pls(
Xtrain, Ytrain_cls, Xtest, Ytest_cls,
ncomp = 1:2,
method = "kernelpls",
kernel = k,
degree = 2,
seed = 102
)
})
names(kernel_fits) <- c("linear", "rbf", "poly")
kernel_accuracy <- vapply(kernel_fits, function(fit) {
mean(fit$Ypred[["ncomp=2"]] == Ytest_cls)
}, numeric(1))
kernel_accuracy
#> linear rbf poly
#> 0.7666667 0.9000000 0.9333333plot() can visualize stored PLS score maps. Refit with
fit = TRUE to store training scores, or predict with
proj = TRUE to store test scores. Ellipses can be ordinary
confidence ellipses or Hotelling T2 ellipses.
The next example uses the two-class gene-expression data from the
Biobase sample.ExpressionSet object introduced above. The
same plot() call works for method = "plssvd",
"simpls", "opls", and
"kernelpls".
X_two <- t(Biobase::exprs(sample.ExpressionSet))
y_two <- factor(Biobase::pData(sample.ExpressionSet)$type)
train_two <- setdiff(seq_along(y_two), test_bioc)
fit_two <- pls(
X_two[train_two, , drop = FALSE], y_two[train_two],
X_two[test_bioc, , drop = FALSE], y_two[test_bioc],
ncomp = 1:2, fit = TRUE, proj = TRUE, seed = 200
)
plot(fit_two, score.set = "train", groups = y_two[train_two],
ellipse = TRUE, main = "Biobase expression data")The second score-plot example fits OPLS to the three-class
iris split defined at the beginning of this section, with
two orthogonal components (north = 2).
opls_three <- pls(
Xtrain,
Ytrain_cls,
Xtest,
Ytest_cls,
ncomp = 1:2,
method = "opls",
fit = TRUE,
proj = TRUE,
north = 2L,
seed = 201
)
old_par <- par(mfrow = c(1, 2), mar = c(4.2, 4.2, 2.2, 0.8))
plot(
opls_three,
score.set = "train",
groups = Ytrain_cls,
ellipse = TRUE,
ellipse.type = "hotelling",
main = "OPLS training, north = 2"
)
plot(
opls_three,
score.set = "test",
groups = opls_three$Ypred[["ncomp=2"]],
xlim = c(-0.16, 0.42),
main = "OPLS test prediction"
)For regression, responses are supplied as numeric vectors or
matrices. The examples in this section use mtcars to
demonstrate univariate regression and OPLS. Regression does not use
classifier = "argmax" or classifier = "lda":
those are classification heads for factor responses. For numeric
responses, pls() returns continuous predictions and
regression metrics such as R2Y, Q2Y, and
RMSD.
set.seed(100)
Xreg <- as.matrix(mtcars[, c("disp", "hp", "wt", "qsec", "drat")])
Y_reg <- matrix(mtcars$mpg, ncol = 1)
reg_test_id <- sample(seq_len(nrow(Xreg)), 8)
Xreg_train <- Xreg[-reg_test_id, , drop = FALSE]
Xreg_test <- Xreg[reg_test_id, , drop = FALSE]
Ytrain_reg <- Y_reg[-reg_test_id, , drop = FALSE]
Ytest_reg <- Y_reg[reg_test_id, , drop = FALSE]The simplest workflow is to provide both the training data and an
independent test set directly to pls(). In this case the
fitted object already contains test-set predictions and, if
Ytest is supplied, predictive metrics.
pls() also accepts predictor and response matrices from
the float package. Supplying a float::float32
object requests single-precision execution. CUDA keeps PLS-SVD, SIMPLS,
OPLS, nonlinear kernel construction, prediction, and LDA on the selected
GPU. Metal accepts float32 only and uses the fixed CPU/Metal operation
split described above; CUDA supports float32 and float64. Nonlinear
kernel PLS requires an explicit n by n Gram
matrix, so the package checks the estimated live device or
unified-memory storage before fitting. Extreme-response and
nonlinear-kernel routes should be checked against float64 when the
scientific conclusion is sensitive to small numerical differences.
Float64 remains the baseline precision. Float32 approximately halves raw matrix storage, but paired tests show that runtime, incremental host RSS, and device memory may increase or decrease depending on the route. The package therefore warns once for measured-risk regimes. This includes precision-sensitive SIMPLS and linear kernel-PLS classification, nonlinear kernels and multivariate regression with at least 10,000 response columns and 50 components. In that last regime, runtime, memory, and numerical behavior must be checked against float64 before the float32 result is used for scientific interpretation. Unsupported combinations stop before allocation and are never silently promoted to float64.
On Windows, the standard R toolchain does not expose the
single-precision BLAS/LAPACK symbols used by the Unix-like compiled
kernels. The Windows CPU route therefore combines float-package rSVD and
Cholesky operations with portable C++ float kernels. It supports
PLS-SVD, SIMPLS, OPLS, linear and nonlinear kernel PLS, argmax, and
latent-space LDA when backend = "cpu" and rSVD is selected
automatically. Model factors, LDA buffers, kernel matrices, and
predictions remain float32, but this portable route can be slower than
the native Unix-like implementation. Windows float32 accelerator
requests stop with an unsupported-combination error rather than silently
converting data to float64.
Xreg32 <- float::fl(as.matrix(Xreg_train))
Yreg32 <- float::fl(matrix(Ytrain_reg, ncol = 1))
fit_reg32 <- pls(
Xreg32,
Yreg32,
float::fl(as.matrix(Xreg_test)),
float::fl(matrix(Ytest_reg, ncol = 1)),
ncomp = 1:2
)
fit_reg32$Q2Y
#> ncomp=1 ncomp=2
#> 0.7564006 0.7593962For standard double-precision regression, Ypred is a
numeric prediction array with one slice for each requested number of
components. For float32 input, Ypred is kept as a named
list of float::float32 prediction matrices. In the standard
example below, the last array slice corresponds to the largest requested
component count (ncomp = 3). The metric vectors are named
by component count, for example fit_reg$Q2Y["ncomp=3"].
reg_component <- dim(fit_reg$Ypred)[3L]
pred_mpg <- fit_reg$Ypred[, , reg_component]
plot(
Ytest_reg,
pred_mpg,
pch = 21,
bg = "#4E79A7",
col = "black",
xlab = "Observed mpg",
ylab = "Predicted mpg",
main = "Regression: observed vs predicted"
)
abline(0, 1, col = "#D55E00", lwd = 2)The alternative workflow is to fit the model once without a test set,
then call predict() later. This is useful when the same
model must be applied to several independent datasets.
predict() automatically applies the centering/scaling
stored in the fitted object. When Ytest is supplied, it
also passes the predictions to evaluate() and stores the
complete evaluation under metrics.
fit_reg_train_only <- pls(
Xreg_train,
Ytrain_reg,
ncomp = 1:3,
fit = TRUE,
return_variance = FALSE
)
pred_reg_later <- predict(
fit_reg_train_only,
Xreg_test,
Ytest = Ytest_reg,
proj = TRUE
)
pred_reg_later$Q2Y
#> ncomp=1 ncomp=2 ncomp=3
#> 0.7564006 0.7593961 0.8634626
pred_reg_later$metrics$metrics
#> n R2 Q2 RMSD RMSE MAE
#> component=1 8 0.7505150 0.7564006 3.066116 3.066116 2.739049
#> component=2 8 0.7535829 0.7593961 3.047205 3.047205 2.765666
#> component=3 8 0.8601637 0.8634626 2.295494 2.295494 1.929671
#> bias MRE_percent MAPE_percent RPD Pearson_r
#> component=1 0.4938232 16.70827 14.92070 2.140295 0.8700688
#> component=2 0.6250298 17.87218 15.26995 2.153578 0.8743339
#> component=3 0.6784321 12.48956 10.76821 2.858815 0.9340810
#> Spearman_r
#> component=1 0.8072875
#> component=2 0.8072875
#> component=3 0.8072875
head(pred_reg_later$Ttest)
#> [,1] [,2] [,3]
#> [1,] 0.09841041 -0.01046739 -0.1900955
#> [2,] -0.10644825 0.19820127 0.1563451
#> [3,] 0.03394295 0.21190609 -0.2208999
#> [4,] -0.36597585 0.27972780 -0.2105406
#> [5,] 0.27550586 0.09182286 0.3494802
#> [6,] -0.25506247 0.31745909 0.2627156For numeric regression, evaluate() reports R2, Q2,
RMSD/RMSE, MAE, bias, median relative error percentage, RPD, and
correlations. If ytrain is supplied, Q2 is calculated
relative to the training-set response mean, which is the preferred
setting for independent test-set evaluation. For a response matrix, each
response column is centered on its corresponding training mean before
the denominator sums are aggregated. If ytrain is omitted,
Q2 is returned as NA; it is not silently replaced by an R2
calculation.
For wide multivariate responses, pls(),
pls.single.cv(), and pls.double.cv() return
aggregate evaluation metrics by default. Set bycol = TRUE
to also calculate the full response-wise metric table; this is useful
for inspecting individual spectral bins, but can be expensive for NMR
data with many response columns.
eval_reg <- evaluate(
observed = Ytest_reg,
predicted = pred_mpg,
ytrain = Ytrain_reg
)
eval_reg$task
#> [1] "regression"
eval_reg$metrics
#> n R2 Q2 RMSD RMSE MAE bias
#> 1 8 0.8601637 0.8634626 2.295494 2.295494 1.929671 0.6784321
#> MRE_percent MAPE_percent RPD Pearson_r Spearman_r
#> 1 12.48956 10.76821 2.858815 0.934081 0.8072875
lapply(eval_reg$metric_definitions, strwrap, width = 56)
#> $R2
#> [1] "Observed-set R2; each response is centered on its"
#> [2] "observed mean before sums of squares are aggregated."
#>
#> $Q2
#> [1] "Independent-test Q2; each response is centered on its"
#> [2] "training-response mean before sums of squares are"
#> [3] "aggregated."
eval_reg$per_response
#> response n R2 Q2 RMSD RMSE MAE
#> 1 Y1 8 0.8601637 0.8634626 2.295494 2.295494 1.929671
#> bias MRE_percent MAPE_percent RPD Pearson_r Spearman_r
#> 1 0.6784321 12.48956 10.76821 2.858815 0.934081 0.8072875OPLS is accessed through the same pls() function with
method = "opls". It is often used to separate predictive
variation from response-orthogonal variation. ncomp counts
predictive components, separately from the orthogonal components removed
by north. Each removed direction reduces the available
predictor rank. For example, with eight independent predictors and one
removed direction, at most seven predictive components remain. A request
exceeding the remaining rank raises an error; the same restriction
applies within each CV training fold.
fastPLS provides two cross-validation helpers. Use
pls.single.cv() for grouped k-fold or leave-one-group-out
validation; pass a scalar ncomp for a fixed-component CV or
a vector of candidates when the number of components should be selected
from a grid. Use pls.double.cv() for nested validation,
where an inner CV chooses the number of components and an outer CV
estimates predictive performance. Both helpers support regression and
classification and dispatch to compiled CPU or accelerator routes when
available. CUDA SIMPLS CV keeps task data and fold workspaces on the
device. CUDA library handles are created once per CV call and reused
across folds rather than reconstructed for every fit. CUDA PLS-SVD CV
also uses a resident route for large multivariate regression responses,
while classification and smaller-response tasks retain the faster
fold-local CUDA route. This choice is automatic and does not change fold
assignment, fold-specific preprocessing, requested components, or
prediction semantics. For large multivariate responses, predictor and
response marginal moments are computed once. Each training fold obtains
its own moments by subtracting the held-out contribution before
fold-specific centering and scaling. The large cross-covariance remains
matrix-free, so this reuse does not introduce a dense
predictor-by-response cache. When its cost and storage model is
favorable, the compiled CPU engine can also form a bounded response Gram
matrix once for wide-response SIMPLS problems and extract an exactly
double-centred training submatrix for each fold. The Gram kernel is
selected between GEMM and SYRK by platform, precision, and matrix shape;
a SYRK route retains one triangle until a complete fold matrix is
required. Fold-specific preprocessing, seeds, and model fitting remain
unchanged. Aggregate regression metrics are computed directly from the
native out-of-fold predictions before those predictions are formatted
for R.
In ordinary k-fold CV, samples are split directly into
kfold folds. The example below validates a fixed
two-component SIMPLS classifier with five folds.
cv_kfold <- pls.single.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
ncomp = 2,
kfold = 5,
seed = 103
)
cv_kfold$metrics$cross_validated[["ncomp=2"]]$metrics
#> n accuracy no_information_rate lift_accuracy balanced_accuracy
#> 1 120 0.8333333 0.3333333 2.5 0.8333333
#> macro_precision macro_recall macro_f1 kappa
#> 1 0.8422619 0.8333333 0.8320269 0.75metrics$cross_validated contains the complete
evaluate() output for each tested component count, while
selection_metrics is the compact internal table used to
select the best setting. When fit = TRUE,
metrics$fitted evaluates the corresponding model fitted to
the full dataset.
constrainThe constrain argument controls grouped splitting. It is
a vector with one entry per sample; samples with the same value are
assigned to the same fold. In practice, this prevents leakage when
multiple rows come from the same patient, subject, batch, or technical
replicate. For example, if two spectra come from the same patient,
giving them the same patient identifier in constrain
ensures that both spectra are placed either in the training set or in
the test set, never one in each.
patient_id <- rep(
seq_len(ceiling(nrow(Xtrain) / 2)), each = 2
)[seq_len(nrow(Xtrain))]
cv_grouped <- pls.single.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
constrain = patient_id,
ncomp = 2,
kfold = 4,
seed = 104
)
c(n_folds = length(unique(cv_grouped$fold)),
n_patient_groups = length(unique(patient_id)))
#> n_folds n_patient_groups
#> 4 60Leave-one-out CV is requested with kfold = "loocv". When
constrain is not supplied, each sample is held out once.
When constrain is supplied, LOOCV becomes
leave-one-constraint-group-out CV, so a whole patient, subject, batch,
or replicate group is held out together at each iteration. Numeric
kfold values greater than or equal to the number of
constraint groups are also treated as leave-one-group-out CV.
pls.single.cv() repeats the same CV splitting strategy
over several candidate component counts and returns the best value
according to the predictive metric: accuracy for classification and RMSD
for regression. Fold-aware Q2Y, full-data fitted R2Y, balanced accuracy,
and the other task-specific metrics documented below can instead be
requested explicitly through selection. Predictive
arguments can also be supplied as vectors to tune a compact grid.
Fold-control arguments such as kfold remain single settings
for the whole CV run.
For LDA classification, a rare class may be absent from an inner training fold. fastPLS fits LDA to the classes represented in that fold and maps the predictions back to the original factor levels. Held-out observations from an absent class are retained in the accuracy and balanced-accuracy calculations, although that class cannot be predicted by a model that did not observe it. When otherwise identical argmax and LDA configurations are requested together, fastPLS calculates the PLS component path and fold projection once. The two classification heads retain separate predictions, metric paths, and selected component counts. For sufficiently tall SIMPLS classification problems, full-data predictor and class moments are also computed once. Each training fold obtains its moments by subtracting the held-out contribution before applying fold-specific centering and scaling; the PLS model, LDA model, and predictions are still fitted independently within every fold. This avoids repeatedly materializing large training-score matrices without leaking held-out information into model fitting. The same sufficient statistics can assemble eligible PLS-SVD folds through the fold-specific predictor Gram and class cross-product, without materializing the training-score matrix. On the Metal route, small reduced products are evaluated with Accelerate while the large sample-matrix products assigned to Metal remain on the GPU.
R2Y, Q2Y, and
RMSDFor regression models, R2Y, Q2Y, and
RMSD answer different questions and should not usually be
identical. In pls(), R2Y is training-set R2
and independent-test Q2Y uses the training-response mean in
its denominator. For multivariate responses, each response is centered
separately before the sums of squares are aggregated. In
pls.single.cv(), each held-out prediction is evaluated
relative to the corresponding fold-training response means.
pls.double.cv() applies the same rule to the outer folds.
RMSD is also calculated from held-out predictions and is
reported on the response scale, so lower values are better.
R2Y is a training-set explained-variance estimate from one
additional model fitted on the full dataset; set
fit = FALSE to skip this extra fit when only
cross-validated performance is needed. For classification,
Q2Y is calculated from held-out dummy-coded PLS-DA response
scores using fold-training class proportions, accuracy
reports decoded-label accuracy, and R2Y is calculated from
the full-data PLS-DA fit on the dummy-coded response scores.
Dummy-response Q2Y and R2Y are not
classification accuracy. The exact convention used by each function is
also reported in its metrics$definitions element.
RMSD is not used for classification.
Use selection = "R2Y" to select from the full-data
fitted-response path or selection = "Q2Y" to select from
out-of-fold predictions standardized by each fold’s training-response
mean. Selecting R2Y automatically enables and returns the fitted path
even if fit = FALSE; because it is a training criterion,
Q2Y or another held-out metric is usually preferable for choosing model
complexity. The former names "r2" and "q2" are
rejected because they did not identify these two different quantities
unambiguously.
Classification settings can be selected by accuracy,
balanced_accuracy, lift_accuracy,
macro_precision, macro_recall,
macro_f1, kappa, R2Y, or Q2Y. Use balanced
accuracy when unequal class frequencies make majority-class accuracy
misleading. Regression settings can be selected by R2Y, Q2Y,
RMSD, MAE, MAPE_percent,
RPD, Pearson_r, or Spearman_r.
These additional aggregate metrics are useful for high-dimensional
multivariate responses such as spectra. RMSD, MAE, and MAPE_percent are
minimized; the other criteria are maximized. Signed bias and signed
MRE_percent remain available from evaluate()
but are not tuning criteria because they have no unambiguous one-sided
optimization direction. The definitions match evaluate(),
and an incompatible choice such as classification accuracy for a
regression response raises an error before model fitting.
fastPLS provides two permutation-test procedures. In
pls(), the permutation test is a single train/test
procedure: the rows of Xtrain are randomly permuted, the
model is refitted, and the permuted test-set Q2Y values are
compared with the observed Q2Y values component by
component. The returned pval is the corrected Monte Carlo
upper-tail value (b + 1) / (B + 1), where b
counts successful null fits at least as extreme as observed and
B counts successful null fits. It can therefore never be
zero. Because pls() has no grouping argument, one training
row is the permutation unit. The full permutation table is stored in
permutation and can be visualized with
plot.permutation(), where the x-axis is the correlation
between the original and permuted response structure and the y-axis
shows R2 and Q2.
perm_fit <- pls(
Xtrain = Xreg_train,
Ytrain = Ytrain_reg,
Xtest = Xreg_test,
Ytest = Ytest_reg,
ncomp = 2,
fit = TRUE,
perm.test = TRUE,
return_variance = FALSE,
seed = 108
)
perm_fit$pval
#> ncomp=2
#> 0.00990099
plot.permutation(perm_fit, ncomp = 2)In pls.double.cv(), the permutation test repeats the
complete nested cross-validation workflow. Independent rows are
exchanged individually. When constrain identifies repeated
observations, complete constraint blocks are exchanged only between
groups with the same number of rows. Thus group sizes, within-group
response structure, and class frequencies are preserved exactly. The
observed and permuted analyses use identical outer and inner folds and
the same randomized-SVD seeds, so null variation reflects the exchange
operation rather than new folds or sketches. Model selection and
permutation inference use the same selection. For
imbalanced classification, selection = "balanced_accuracy"
therefore selects the model by mean class-specific recall and tests that
statistic against its permutation distribution; it does not substitute
dummy-response Q2. The output records the statistic in
permutation_metric, permutation_observed, and
permutation_sampled. Larger predictive metrics use the
upper permutation tail, whereas losses such as RMSD use the lower tail.
Both tails use (b + 1) / (B + 1). Failed null fits are
stored in permutation_errors, omitted from B,
and summarized by permutation_completed and
permutation_failed.
dcv_perm <- pls.double.cv(
Xdata = Xreg_train[1:20, ],
Ydata = Ytrain_reg[1:20],
ncomp = 1:2,
kfold_inner = 2,
kfold_outer = 2,
perm.test = TRUE,
seed = 109
)
data.frame(
permutation_metric = dcv_perm$permutation_metric,
observed = dcv_perm$permutation_observed,
p_value = dcv_perm$p.value,
completed = dcv_perm$permutation_completed,
failed = dcv_perm$permutation_failed
)
#> permutation_metric observed p_value completed failed
#> 1 RMSD 3.293571 0.00990099 100 0For an imbalanced classification analysis, the corresponding call is:
dcv_balanced <- pls.double.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
ncomp = 1:5,
kfold_inner = 5,
kfold_outer = 5,
constrain = patient_id,
selection = "balanced_accuracy",
perm.test = TRUE,
times = 100,
seed = 109
)
dcv_balanced$balanced_accuracy
dcv_balanced$metrics$permutationThe selected CV object can then be passed directly to
pls(). The final model is refitted on the full training set
using the selected component count and selected tuning settings, then
applied to the independent test set.
cv_select <- pls.single.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
ncomp = 1:3,
kfold = 5,
seed = 106
)
fit_selected <- pls(
cv_select,
Xtest = Xtest,
Ytest = Ytest_cls,
return_variance = FALSE
)
data.frame(
best_ncomp = cv_select$best_ncomp,
test_accuracy = mean(fit_selected$Ypred[[1]] == Ytest_cls)
)
#> best_ncomp test_accuracy
#> 1 3 0.8333333For example, kernelpls can select the best combination
of component count and kernel setting. The selected values are returned
in best_parameters, and the same CV object can be passed to
pls() for the final refit.
cv_kernel <- pls.single.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
ncomp = 1:3,
kfold = 5,
method = "kernelpls",
kernel = c("linear", "rbf"),
gamma = c(0.1, 1),
seed = 107
)
fit_kernel <- pls(
cv_kernel,
Xtest = Xtest,
Ytest = Ytest_cls,
return_variance = FALSE
)
data.frame(
best_ncomp = cv_kernel$best_parameters$ncomp,
best_kernel = cv_kernel$best_parameters$kernel,
test_accuracy = mean(fit_kernel$Ypred[[1]] == Ytest_cls)
)
#> best_ncomp best_kernel test_accuracy
#> 1 3 rbf 1Double cross-validation is a nested validation design for separating
model optimization from the final estimate of predictive performance.
This separation is especially important for PLS-DA because the number of
latent variables and other modelling choices can otherwise be tuned on
the same samples used to report performance, producing optimistic
accuracy or Q2 estimates. In the terminology of Szymanska et al. (2012),
the inner CV loop (CV1) is used to optimize model
complexity, such as the number of latent variables, whereas the outer CV
loop (CV2) holds out samples that are not used during
optimization and therefore provides the performance estimate of the
complete modelling strategy. In fastPLS,
pls.double.cv() follows this idea using a reproducible
grouped- fold plan constructed in R. CPU and Metal use a compiled
coordinator for inner selection, outer refitting, prediction, and metric
accumulation. CUDA uses an R coordinator around native CUDA single-CV
and outer-fit kernels. Both kfold_inner and
kfold_outer can be ordinary fold counts or
"loocv", and both respect constrain.
cv_double <- pls.double.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
constrain = patient_id,
ncomp = 1:2,
kfold_inner = 3,
kfold_outer = 3,
seed = 104
)
data.frame(
selected_ncomp_mode = cv_double$bcomp,
outer_metric = cv_double$metric_name,
outer_accuracy = cv_double$accuracy,
outer_Q2Y = cv_double$Q2Y,
outer_R2Y = cv_double$R2Y
)
#> selected_ncomp_mode outer_metric outer_accuracy outer_Q2Y
#> 1 2 accuracy 0.8083333 0.5477306
#> outer_R2Y
#> 1 0.5684508The same interface can tune more than the component count. In this
example the inner loop chooses the best combination of
ncomp, kernel, and gamma for
kernelpls. The outer loop then uses the selected
combination for each held-out outer fold.
cv_double_kernel <- pls.double.cv(
Xdata = Xtrain,
Ydata = Ytrain_cls,
constrain = patient_id,
ncomp = 1:2,
kfold_inner = 3,
kfold_outer = 3,
method = "kernelpls",
kernel = c("linear", "rbf"),
gamma = c(0.1, 1),
seed = 108
)
cv_double_kernel$results[[1]]$best_parameters
#> [[1]]
#> [[1]]$ncomp
#> [1] 2
#>
#> [[1]]$kernel
#> [1] "rbf"
#>
#> [[1]]$gamma
#> [1] 1
#>
#>
#> [[2]]
#> [[2]]$ncomp
#> [1] 2
#>
#> [[2]]$kernel
#> [1] "rbf"
#>
#> [[2]]$gamma
#> [1] 1
#>
#>
#> [[3]]
#> [[3]]$ncomp
#> [1] 2
#>
#> [[3]]$kernel
#> [1] "rbf"
#>
#> [[3]]$gamma
#> [1] 1The most commonly used pls.double.cv() output fields
are:
| Field | Meaning |
|---|---|
bcomp |
Most frequently selected number of components. |
accuracy, Q2Y, RMSD |
Outer-CV predictive performance. |
Ypred |
Final cross-validated prediction for each sample. |
conf |
Classification confusion matrix. |
metrics |
Detailed outer-CV evaluation results. |
results |
Detailed per-run and per-fold information. |
Important elements returned by pls.double.cv() are:
results: one entry per repeated outer CV run. Each
entry stores Ypred and pred for that run, the
outer fold assignment, the best_ncomp selected
inside each outer fold, the full best_parameters selected
inside each outer fold, the complete inner-CV objects in
inner, the run-level metric_name and
metric_value, and the fitted backend and
method.Ypred: the final cross-validated prediction for each
sample. For classification, repeated runs are combined by voting; for
regression, predictions are averaged across repeated runs.acc_tot: classification-only text summary of the total
number and percentage of correctly classified samples.conf: classification-only confusion matrix. Entries are
printed as counts and column percentages so that class-wise errors can
be inspected.vote_counts: classification-only matrix with one row
per sample and one column per class, showing how many repeated outer-CV
runs voted for each class.accuracy, Q2Y, RMSD, and
R2Y: one value per repeated outer CV run. For
classification, accuracy reports decoded-label accuracy,
Q2Y reports held-out Q2 on dummy-coded PLS-DA response
scores, R2Y reports the mean training-fit R2 of the
selected outer-fold PLS-DA models, and RMSD is not used.
For regression, Q2Y reports held-out Q2, RMSD
reports held-out RMSD, and R2Y reports the mean
training-fit R2 of the selected outer-fold models.metric_name: the metric used for inner/outer model
selection. It is based on held-out predictions except when
selection = "R2Y" explicitly requests the fitted-response
criterion.medianQ2Y, CI95Q2Y,
medianR2Y, CI95R2Y, medianRMSD,
and CI95RMSD: summaries across repeated outer CV runs,
returned only when runn > 1. They are omitted for the
default runn = 1 output.bcomp: the most frequently selected number of
components across all outer folds and repeated runs.backend and method: the default backend
and PLS method used by the call. If vector-valued methods or backends
are tuned in the inner loop, the selected fold-level values are stored
in results[[run]]$best_parameters.selection_metric: the criterion used by the inner CV
loop. The default "auto" means accuracy for classification
and RMSD-style prediction error for multivariate regression. All
task-compatible selection criteria listed above can also drive the
nested permutation test.fastsvd() provides direct access to the native CPU
randomized SVD used by PLS. It returns the left singular vectors
(u), singular values (d), and right singular
vectors (v) for users who want a truncated decomposition
outside a PLS model. A standard R numeric matrix selects float64
computation. A matrix created with float::fl() selects the
native float32 path automatically; its left and right singular vectors
remain in float32 format. The function exposes no solver selector
because rSVD is its only algorithm. Stand-alone CUDA and Metal SVD
routes are not exposed; their reduced decompositions are not
device-native for every matrix shape.
s64 <- fastsvd(Xtrain, ncomp = 3, seed = 104)
s32 <- fastsvd(float::fl(Xtrain), ncomp = 3, seed = 104)
names(s64)
#> [1] "d" "u" "v" "method"
#> [5] "backend" "svd.method" "elapsed" "ncomp"
#> [9] "precision" "diagnostics"
s64$d
#> [1] 85.839617 16.119396 3.117594
inherits(s32$u, "float32")
#> [1] TRUECUDA and Apple Metal are optional. Use has_cuda() before
selecting backend = "cuda" and has_metal()
before selecting backend = "metal". An unavailable
accelerator request stops with an informative error and is not
redirected to CPU.
fastcor() computes fast Pearson-style correlations.
ViP() returns variable importance in projection
trajectories for fitted PLS models, following the standard VIP
interpretation used for PLS variable ranking (Wold, Sjostrom, and
Eriksson, 2001; Chong and Jun, 2005). VIP is most useful when the
columns of X are interpretable predictors, such as genes,
metabolites, spectral bins, or clinical variables; larger values
indicate stronger contribution to the fitted latent predictive model.
Their matrix calculations, like the metrics returned by
evaluate(), are implemented in the dependency-free C++ core
and are independent of CUDA or Metal availability; the R functions
validate inputs and format the returned objects.
The PLS algorithms in fastPLS are separated from the
inner singular-vector backend. The compiled implementation uses native
randomized SVD, estimating the leading singular triplets needed by the
requested PLS components. The randomized SVD backend follows the
randomized range-finder framework described by Halko, Martinsson, and
Tropp (2011): it draws a Gaussian test matrix, forms a low-dimensional
sketch of the target matrix, optionally applies power iterations,
orthonormalizes the sketch, and solves the final SVD in the small
projected space. In pls() and the cross-validation helpers,
these controls are supplied directly through ...; in
stand-alone SVD calls they are supplied directly to
fastsvd().
Standalone accelerator fastsvd() routes are unavailable
because their reduced QR and SVD stages are not fully device-native for
every matrix shape. The function raises an error rather than returning a
partial result. CUDA acceleration remains device resident within PLS,
while Metal acceleration uses the fixed CPU/Metal operation split
described below.
The PLS Metal backend, pls(..., backend = "metal"),
supports float32 PLS-SVD, SIMPLS, OPLS, and kernel PLS with Metal
Performance Shaders. It returns host-accessible model factors and always
uses a fixed mathematical division: Metal performs fitting products
involving the training sample matrix, including explicit
cross-covariance formation, fused score/loading products, and implicit
randomized range-finder products. Where the implicit transpose operator
requires consecutive X V and Y^T (X V)
products, both are encoded in one command buffer and synchronized once
before the CPU response-centering correction. CPU code performs the
reduced decomposition, sequential PLS algebra, LDA, prediction, and
model assembly. Dataset dimensions never select a complete CPU or Metal
route. The executed path, storage decision, and randomized controls are
reported in diagnostics$metal_operation_split. Ordinary R
double matrices produce an explicit error rather than silently falling
back to the CPU; use float::fl() to request Metal float32
execution. Repeated fits in one R session reuse initialized device and
pipeline state, whereas an isolated first call includes that setup
cost.
For CUDA builds, GPU execution is selected with
backend = "cuda", so the numerical fitting and prediction
stages are performed by resident GPU-native PLS routines after input
transfer. When the cross-covariance \(S = X^T
Y\) would be large, selected backends can use the matrix-free
xprod route. In that route, the code avoids explicitly
storing S and evaluates the products required by the SVD
through identities such as \(Sv =
X^T(Yv)\) and \(S^Tu =
Y^T(Xu)\). For classification problems with many classes,
label-aware products can avoid materializing a dense one-hot response
matrix. Compact prediction instead retains the PLS latent factors and
evaluates only the requested test-score blocks, avoiding dense
coefficient and prediction paths when they are unnecessary. fastPLS
chooses the matrix-free route automatically only when the cross-product
is large enough to justify it.
For pls.single.cv(), seeded grouped fold construction
and the numerical CV path are compiled. For
pls.double.cv(), CPU and Metal nested loops are compiled
after R constructs the fixed fold plan; CUDA retains R-level nested
coordination around native CUDA kernels. Final R-object assembly remains
host-side. CUDA SIMPLS CV uploads the task once and keeps fold
gathering, training-only standardization, fitting, prediction, and
metric reduction on the device. CUDA PLS-SVD uses the same resident
strategy for large multivariate regression responses; classification and
smaller responses use a fold-local CUDA route because it is faster for
those workloads. Metal applies the fixed CPU/Metal operation split used
by pls(). Unsupported accelerator combinations stop rather
than falling back to a CPU fit.
method = "plssvd" implements the direct cross-covariance
SVD formulation of PLS, within the same broad PLS regression and PLS-DA
framework described by Wold, Sjostrom, and Eriksson (2001), Barker and
Rayens (2003), and Boulesteix and Strimmer (2007). After preprocessing,
the algorithm computes the dominant singular subspace of \(X^T Y\) and reuses that subspace to
generate the requested component path. The maximum meaningful number of
PLS-SVD components is capped by
min(nrow(X), ncol(X), ncol(Y)); if a larger value is
requested, the package uses the valid internal rank and records the
capped component count. For an implicit cross-covariance, numerical rank
is determined on the same singular-value scale as the direct
decomposition. When a reduced Gram shortcut does not contain the
complete requested retained rank, PLS-SVD uses the direct reduced
decomposition rather than silently returning fewer directions.
The main innovation relative to a simple textbook PLS-SVD
implementation is the execution strategy. fastPLS combines randomized
SVD, compact low-rank prediction factors, CUDA-native fitting, and the
matrix-free xprod route. For large classification tasks,
the label-aware PLS-SVD path computes class-wise cross-products from the
factor labels and avoids dense one-hot Y unless the legacy
dense route is explicitly needed.
method = "simpls" is the optimized fastPLS SIMPLS core.
Statistically, it keeps the de Jong (1993) SIMPLS idea: one supervised
latent direction is appended at a time, the score and loading vectors
are computed, an orthogonal loading direction is constructed, and the
cross-covariance is deflated before the next component.
At each refresh, fastPLS extracts fresh candidates from the current
deflated cross-covariance. Depending on the active compiled route, a
refresh produces one direction or a small candidate block. Accepted
candidates are always applied one at a time through the standard SIMPLS
orthogonalization and deflation equations, and the next refresh uses the
resulting deflated operator. CUDA keeps the SIMPLS state on the device.
PLS-SVD uses 32 oversampling directions and five power iterations.
Accelerated SIMPLS, OPLS, and kernel-PLS use 32 directions and five
iterations for ordinary matrix shapes; when the explicit
cross-covariance would exceed 512 MiB, their automatic massive-matrix
profile requests oversample = 12 and
power = 1, while the executed controls and refresh width
are recorded separately. Explicit controls supplied through
... take precedence.
The accepted direction is normalized, converted into a SIMPLS component, orthogonalized, and followed by SIMPLS deflation. A candidate block is consumed before the next sketch is generated. The innovation is therefore not a claim of exact de Jong estimator preservation: it is a backend-specialized randomized sequential estimator that trades strict component equivalence for lower computational cost while preserving the supervised deflation structure.
The retained optimizations reorganize work without altering this sequence. The cached-deflation update computes \(v^T S\) once and applies \(S <- S - v(v^T S)\). Coefficients are updated as \(B_a = B_{a-1} + r_a q_a^T\), and fitted values are updated similarly when requested. Cross-products can be cached for suitable matrix shapes, prediction can use compact latent factors, and matrix-free products avoid materializing a large cross-covariance. PLS-SVD instead uses a one-shot cross-covariance SVD; OPLS and kernel PLS reuse the same sequential SIMPLS core where applicable.
The complete component path is also a standard feature of reference
SIMPLS software such as pls::simpls.fit, which returns
coefficient and fitted-value arrays for components 1 through
ncomp. The fastPLS contribution is therefore not the
existence of that path, but its compiled incremental execution, compact
prediction, matrix-free products, and backend-specific storage strategy.
Incremental updates avoid rebuilding \(R_a
Q_a^T\) and \(T_a Q_a^T\) from
scratch at requested prefixes; compact prediction stores latent factors
instead of all dense coefficient prefixes; conditional \(X^T X\) caching can benefit tall matrices;
and matrix-free products trade repeated operator applications for
avoiding an explicit \(p\)-by-\(q\) cross-covariance. Because these
trade-offs depend on \(n\), \(p\), \(q\), and the requested component set, they
are memory and execution optimizations rather than a claim of universal
acceleration.
method = "opls" follows the orthogonal PLS idea
introduced by Trygg and Wold (2002). It first removes
response-orthogonal variation from X and then fits the
optimized PLS core to the filtered matrix. The number of orthogonal
components is controlled by north. The filtering step
estimates orthogonal scores and loadings, subtracts the corresponding
variation from X, and stores the orthogonal filter so the
same transformation is applied at prediction time. With float32 input,
stored OPLS matrices use single precision. CUDA performs OPLS in float32
or float64, while Metal performs it in float32. CUDA keeps orthogonal
filtering, the predictive core, and prediction on device. Metal uses GPU
sample-matrix products and CPU orthogonal filtering, reduced
factorizations, sequential PLS updates, and prediction. Users select
OPLS with method = "opls"; the lower-level implementation
details are handled internally.
method = "kernelpls" follows the kernel PLS formulation
of Rosipal and Trejo (2001). It constructs a kernel representation
before applying the selected inner PLS model. The supported kernels are
"linear", "rbf", and "poly". For
nonlinear kernels, the training kernel matrix is centered and the PLS
model is fitted in kernel space. For the linear kernel, fastPLS avoids
unnecessary kernel materialization and uses the ordinary linear PLS
path. For RBF and polynomial kernels, CUDA constructs, centers, and
retains the Gram representation on device through fitting and
prediction. Metal evaluates Gram and sample-matrix products through
Metal and completes reduced factorizations, sequential updates, and
prediction on the CPU. Because this representation requires quadratic
storage in the number of training observations, an explicit memory guard
rejects unsafe workloads before device allocation.
For classification, factor responses are treated as PLS-DA targets.
The following heads are classification-specific decoders and are not
used for numeric regression responses. The default classifier,
classifier = "argmax", predicts the class with the largest
predicted dummy-response score. This is the classical PLS-DA decision
rule described in chemometric discrimination work such as Barker and
Rayens (2003) and reviewed by Boulesteix and Strimmer (2007).
The LDA head, classifier = "lda", fits a regularized
linear discriminant model on the PLS latent scores, following the linear
discriminant principle of Fisher (1936). For each class, fastPLS
estimates a score-space mean, an empirical prior, and a common pooled
within-class covariance matrix. If T contains the training
scores, the pooled covariance is computed without materializing
class-centered score blocks as
(T^T T - sum_c n_c mu_c mu_c^T) / max(1, n - C). The class
systems are solved by Cholesky factorization and triangular solves;
fastPLS neither explicitly inverts the covariance nor uses Gauss-Jordan
elimination. Numerical recovery is deterministic. With
s = trace(Sigma) / q (or one when this scale is invalid),
the implementation tries lambda = rho * s for
rho = 1e-8, 1e-6, 1e-5, 1e-4, 1e-3, 1e-2, increasing the
value only when Cholesky fails. Prediction uses the standard
discriminant t^T w_c - 0.5 mu_c^T w_c + log(n_c / n).
Float32 inputs keep LDA moments, factorization, coefficients, and
prediction buffers in float32. CUDA retains the score-space covariance,
Cholesky factors, triangular-solve workspaces, coefficients, and
prediction buffers on device across component prefixes. The Metal
operation split performs LDA on the CPU using the host-accessible latent
scores returned by the fitting route.
The implementation in fastPLS is not a line-by-line copy
of the papers below; rather, these papers define the statistical
algorithms or numerical building blocks that the package implements and
accelerates.
sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods
#> [7] base
#>
#> other attached packages:
#> [1] fastPLS_0.99.67 BiocStyle_2.41.0
#>
#> loaded via a namespace (and not attached):
#> [1] cli_3.6.6 knitr_1.52 rlang_1.3.0
#> [4] xfun_0.61 otel_0.2.0 generics_0.1.4
#> [7] jsonlite_2.0.0 buildtools_1.0.0 htmltools_0.5.9
#> [10] maketools_1.3.2 sys_3.4.3 sass_0.4.10
#> [13] rmarkdown_2.32 Biobase_2.73.2 evaluate_1.0.5
#> [16] jquerylib_0.1.4 fastmap_1.2.0 yaml_2.3.12
#> [19] lifecycle_1.0.5 BiocManager_1.30.27 compiler_4.6.1
#> [22] float_0.3-4 digest_0.6.39 R6_2.6.1
#> [25] bslib_0.12.0 tools_4.6.1 BiocGenerics_0.59.12
#> [28] cachem_1.1.0