Cross-Validation#
Cross-validation is used for two purposes in multivariate analysis:
Component selection - choosing the right number of components, for both PCA and PLS.
Coefficient uncertainty - obtaining error bars for PLS beta coefficients.
Selecting the Number of Components#
Choosing the right number of components is critical. Too few components underfit (miss important structure), too many overfit (model noise).
Element-wise Cross-Validation (PCA)#
PCA.select_n_components() evaluates every component count from 1 to
max_components, measures the Predicted Residual Error Sum of Squares
(PRESS) of each, and recommends one. The default scheme is the
element-wise k-fold (ekf) algorithm of Bro et al. (2008):
Split the individual cells of
Xinto K folds, so that every cell is held out exactly once.For each fold and each component count: mask that fold’s cells, impute them EM-style from a model fitted on the cells that remain, and add the squared error of those predictions to PRESS.
Recommend a component count from the PRESS curve using
selection_rule.
Holding out individual cells, rather than whole rows, is what keeps a
prediction independent of the value being predicted. When a whole row is held
out and projected back through transform(), its own values flow into its
prediction, so PRESS shrinks monotonically and reaches zero once the
components equal the variables. That measures compression rather than
prediction and cannot select a component count, which is why the
cv_scheme="row_wise" scheme that did this was removed in 2.0.
Choosing among the schemes#
Four schemes keep the prediction independent of the value predicted. They differ in what they hold out and what they cost.
|
What is held out |
Cost |
Use it when |
|---|---|---|---|
|
Scattered cells, imputed by EM from a model that never saw them |
|
The default. The only one that takes a block with missing cells. |
|
Nothing directly: a score comes from a model without the cell’s column, a loading from a model without its row |
|
A number has to line up with Simca-P or with |
|
Nothing. Each residual is inflated by the leverage of the cell that produced it, approximating leave-one-cell-out |
One decomposition |
The block is large and |
|
Nothing. One averaged leverage instead of one per cell |
One decomposition |
Comparing against |
The three new schemes factorise the matrix directly, so they raise on a block
with missing cells, and they ignore scale_inside_folds, n_repeats,
ekf_max_iter and ekf_tol. They hold nothing out in folds that could disagree, so
they report no per-fold spread and selection_rule="1se" has nothing to work
with.
Both leverage schemes are first-order approximations that degrade as the
component count approaches the number of variables, because a column’s leverage
approaches one and the divisor approaches zero with it. Read them well below
that ceiling; FactoMineR defaults to five components for the same reason.
At the ceiling itself no cell has a defined leave-one-out residual at all, and
both schemes report NaN rather than a number there.
They also lean on the residual having something left in it. On the LDPE data
of 54 rows and 19 variables, whose fit reaches 99.98% by eleven components,
"ekf" and "ek" both turn over at two components, which is where Simca-P
turns over on the same data. Neither "sacv" nor "gcv" turns over at all
within the first five: their curves rise at every count, so the number they
return is the largest one evaluated rather than an optimum. Any scheme that
does that now says so, through a SpecificationWarning. Treat the
warning as the answer: the criterion failed on that data, and a scheme that
holds values out should be used instead.
from process_improve.multivariate.methods import PCA
# Pass the raw, unscaled X: with the default ``scale_inside_folds=True``
# the centring and scaling are fit inside each fold, so nothing about the
# held-out cells leaks into the model that predicts them.
result = PCA.select_n_components(
X,
max_components=10,
cv=7, # 7 element-folds
)
print(f"Recommended components: {result.n_components}")
print(result.press) # PRESS per component count
print(result.q2) # cross-validated R2 of X
The result is a Bunch with:
n_components: recommended number of componentspress: PRESS for each number of componentspress_input_units: the same curve in the units of the matrix that was passed in, for comparing the prediction error against a known instrument errorq2: cross-validated \(R^2_X\) per component count, on the same scale as the calibrationr2_cumulative_of a fitted modelq2_per_variable: that same quantity for each column on its ownper_fold_press,se_pressandq2_se: the per-fold PRESS contributions and the standard error built from them, which is what the 1-SE rule needspress_ratio: the ratioPRESS_a / PRESS_{a-1}, for inspectioncv_scores: per-fold scores (an alias ofper_fold_pressunder ekf)cv_schemeandselection_rule: which scheme and rule were used
What PRESS is measured in#
With scale_inside_folds=True each fold centres and scales the matrix
before fitting it, and the error is measured in that same space. Every
variable therefore contributes to press in proportion to how much of its
own variation the model predicts, not in proportion to its units. This
matters whenever the columns are on different scales: on the LDPE data used
in the book, the Mw column carries 99.5% of the raw sum of squares, so a
PRESS accumulated in the raw units would be Mw’s prediction error and
almost nothing else.
q2 is that PRESS divided by what a null model would have got wrong on the
same held-out cells, measured the same way. The null model predicts each
held-out cell by the mean of the cells that were not held out, so
\(Q^2 = 0\) is “no better than the column mean” and \(Q^2 = 1\) is
exact prediction, the same reading as r2_cumulative_.
Two consequences are worth knowing. Re-expressing a column in different units (kilograms instead of grams, say) leaves the whole curve unchanged. And passing the raw block gives the same curve as passing a mean-centred, unit-variance block, so the recommendation in the paragraph above costs nothing.
When a single number in the original units is what you need, for instance to
compare the prediction error against a known instrument error, read
press_input_units instead. To see whether one column is carrying the
pooled figure, read q2_per_variable, which splits q2 by variable.
scale_inside_folds=False is the opt-out for callers who have scaled their
own block. There is then no in-fold scale, so press is in the units of
whatever matrix was passed and the two PRESS fields coincide.
n_repeats runs the whole pass again with a fresh fold permutation.
Each repeat still covers every cell exactly once; more repeats narrow
se_press, which helps when the 1-SE rule sits on a borderline.
Selection Rules#
selection_rule decides which count is recommended from the error curve.
PCA defaults to "min"; PLS defaults to "1se".
Rule |
Recommends |
|---|---|
|
The component count with the lowest cross-validated error. This is the GlobalMin criterion that Bro et al. pair with ekf. |
|
The smallest count whose error is within one standard error of that
minimum, so it is never less parsimonious than |
|
Keeps a component only while it lifts the cumulative \(Q^2\) by
at least |
|
PLS only. Van der Voet’s (1994) permutation test: the smallest model
whose predictive ability is statistically indistinguishable from the
lowest-RMSECV one, at significance level |
Note
The original Wold PRESS-ratio cutoff, the threshold argument of
PCA.select_n_components, is deprecated: passing it emits a
DeprecationWarning and the value is ignored. Use
selection_rule="q2_increment", tuned with min_q2_increase, for a
comparable preference for parsimony.
PLS Component Selection#
PLS.select_n_components() cross-validates a PLS model and reports how it
performs on unseen data, in contrast to the calibration statistics stored on a
fitted model (rmse_, r2_cumulative_), which always improve as
components are added.
from process_improve.multivariate.methods import PLS
# Raw, unscaled blocks: each training fold fits its own MCUVScaler and
# RMSECV is reported on the original Y scale.
result = PLS.select_n_components(X, Y, max_components=8, cv=5)
print(f"Recommended components: {result.n_components}")
print(result.rmsecv["total"]) # RMSECV per component count
print(result.r2y_validated["total"]) # Validated R2 of Y
Do not scale the blocks yourself before calling either selector. In-fold
re-standardisation overwrites whatever scaling you applied, so two
deliberately different choices (autoscale versus Pareto, say) become the
same model and a comparison between them shows no difference. Both
selectors emit a SpecificationWarning when they receive an X that
is already centred and unit-variance scaled. If you must keep your own
scaling, pass scale_inside_folds=False, and for PLS scale=False as
well, since a PLS(scale=True) fold model autoscales its own rows; the
scaling then leaks from the full dataset into every fold, and a warning says so.
The result is a Bunch with:
n_components: recommended count, chosen byselection_rule(the 1-SE rule by default, not the lowest RMSECV; see Selection Rules)rmsecv: root-mean-square error of cross-validation, per Y variable and overallse_rmsecv/q2_se: the standard error of that curve, on the RMSECV and the \(Q^2\) scale respectivelyr2y_validated/r2x_validated: validated explained variance, per variable and overall.r2y_validatedcarries two overall columns:"total"pools the targets on the original Y scale, and"scaled_total"gives every target equal weight (see Comparing the fitted and the validated R2)press: overall Y prediction error sum of squares per component countcv_predictions: out-of-fold Y predictions at the recommended countselection_rule: the rule that producedn_components
The cv argument accepts an integer (K-fold) or any scikit-learn splitter
object, such as KFold or LeaveOneOut. When it is an integer, the
split is repeated n_repeats times (10 by default) with a fresh shuffle,
which is what gives the 1-SE rule a usable standard error. A splitter object
is used as-is and n_repeats is then ignored.
Comparing the fitted and the validated R2#
A fitted model’s r2_y_cumulative_ is computed on the scaled Y, where every
target carries equal weight. r2y_validated["total"] is computed on the
original Y scale, where a target with a wide range carries more weight than a
narrow one. Read side by side on targets of unequal spread, the two can differ
by tens of percent, or disagree in sign, without the model having changed.
r2y_validated["scaled_total"] is the held-out number on the fitted model’s
footing: every target weighted equally, which on autoscaled Y is the arithmetic
mean of the per-target values. Compare fitted against held-out with those two,
and keep "total" for the question it answers, which is how much of the Y
variation in its own units the model predicts.
fitted = PLS(n_components=result.n_components).fit(X, Y)
print(fitted.r2_y_cumulative_) # fitted, equal weight
print(result.r2y_validated["scaled_total"]) # held out, equal weight
print(result.r2y_validated["total"]) # held out, original Y scale
MBPLS.select_n_components returns the same two columns, on the same footing.
Validating the latent structure, not only the predictions#
A PLS model can predict Y well while some of its components are not a reproducible
property of the process, and a component can be real and stable without improving
the prediction. \(Q^2\) only answers the first question. A model used for SPE
and \(T^2\) monitoring, or inverted to find operating conditions, also depends
on the second. compare_cv_criteria runs one cross-validation and reports both,
component by component, with the number of components each criterion recommends.
from process_improve.multivariate import compare_cv_criteria, cv_criteria_plot
result = compare_cv_criteria(X, Y, max_components=6, cv=7, random_state=0)
print(result.recommendations[["n_components", "question"]])
print(result.table[["q2y", "r_cv", "r_cv_p", "slope_ratio", "cov_perm_p", "angle_subspace_deg"]])
cv_criteria_plot(result)
The same function is available as PLS.compare_cv_criteria, which fits with
the class it is called on.
Each selection rule answers one question.
Rule |
Question |
Evidence |
|---|---|---|
|
Does the model predict Y? |
Held-out residuals of Y. |
|
Does each component’s inner relation hold on new rows? |
Correlation of the held-out \(t_a\) and \(u_a\) scores, against a null from permuted Y. |
|
Is each component’s covariance larger than chance? |
Largest singular value of the deflated \(\mathbf{X}_a^\top\mathbf{Y}_a\), against permuted rows of \(\mathbf{Y}_a\). |
|
Does the latent space survive a change of rows? |
Largest principal angle between the fold and the full-data weights. |
The predictive rules never return fewer than one component. The structural rules
count the leading components that pass and return 0 when none does. Two components
of nearly equal strength can swap from fold to fold: the span of the first is then
unstable while the span of the pair is not, and subspace_stability counts the
pair as two.
The monitoring question, whether the SPE limit is right for rows the model has not
seen, is answered by a limit rather than by a component count.
result.spe_limits holds, per component count, the SPE limit fitted to the
training residuals (full_data, the one the model reports) and the limit fitted
to the Procrustes pseudo-validation SPE (pseudo_validation). Training residuals
are smaller than the residuals of new rows, so the first limit is too tight. On
simulated data with two latent variables and 16 variables, the full-data limit at
95% flagged 17% of fresh rows with 30 training rows and 10% with 60, while the
pseudo-validation limit flagged 4% to 5%. The pv_spe_limit_ratio column is the
ratio of the two limits.
With missing values, a row’s SPE is summed over its observed cells only, so it is smaller than the SPE of the same row with nothing missing. The pseudo-validation limit is a limit for complete rows: each held-out row’s squared SPE is scaled by \(K / K_\text{observed}\) before the limit is fitted, and the SPE of a new row with gaps should be scaled the same way before the comparison. On the simulated data with 60 rows and 15% or 30% of the cells missing, the model’s own limit flagged 19% or 34% of fresh complete rows at a nominal 5%, and the refitted limit 3.5% or 2.8%.
Points to keep in mind when reading the table#
The held-out correlation is calibrated by permutation (
n_cv_permutations). The normal approximation \(N(0, 1/N)\) is too narrow for a pooled cross-validated correlation, because every held-out score is a weighted sum of the other folds’ responses. On an X with two strong latent variables it fired about three times as often as its nominal 5% under a null Y.The angles are scaled by the delete-d jackknife, \(\tan\theta \to \sqrt{G-1}\,\mathrm{rms}(\tan\theta_k)\) for \(G\) folds. Raw fold angles shrink as the number of folds grows, because the fold models share most of their rows with the full-data model; the scaled angles agree between 7-fold and leave-one-out cross-validation.
A stable direction is not the same as a direction related to Y. With a noise Y, the first weight vector \(\mathbf{w} \propto \mathbf{X}^\top\mathbf{y}\) is still drawn towards the dominant directions of X, so
subspace_stabilitycan pass where the two tests of a Y relationship do not.The SPE limits concern the X model only; they say nothing about whether a component predicts Y.
Strong variation in X that Y does not see leaks into the first PLS weight vector in a finite sample, and a second component is then needed to cancel it. On simulated data with 60 rows, one Y-related latent variable and a stronger Y-orthogonal one, the first weight vector was tilted by a median of 9 degrees towards the Y-orthogonal direction, and two components predicted 4000 fresh rows better than one in 25 of 30 data sets. The predictive rules and the covariance test mostly chose two components, which is the right answer for prediction, though the process has one Y-related latent variable. This is the motivation for O-PLS, which moves the Y-orthogonal variation out of the predictive component.
Missing values (NaN) are handled as the NIPALS fit handles them: each held-out row is scored from its observed cells the way NIPALS scores an incomplete training row, and a missing Y cell is left out of every sum. A row whose X is entirely missing, or a column that is, raises
ValueError.CV-ANOVA (
cv_anova_p, one response) is a monotone function of \(Q^2\) for fixed degrees of freedom, so it ranks models as \(Q^2\) does.
Checking the criteria on a process with a known structure#
The simulated results quoted in this section come from
LatentStructure, which draws data from a linear
latent-variable process whose structure is set by the caller, and from fresh rows of the
same process, which are the ground truth for any cross-validated estimate. The best that
any linear model can do on those fresh rows is known in closed form.
from process_improve.multivariate import compare_cv_criteria
from process_improve.simulation import LatentStructure
# Two latent variables drive y, so a PLS model should need two components.
process = LatentStructure(x_sd=[3.0, 1.0], y_coefficients=[1.0, 1.0])
train = process.sample(60, random_state=0)
result = compare_cv_criteria(train.X, train.Y, max_components=5, random_state=0)
print(result.recommendations["n_components"]) # 2 from every rule
print(process.population_r2()) # the Q2 a perfect linear model reaches
The script scripts/cv_criteria_recovery.py repeats this for every scenario quoted
above, 30 data sets each, and prints how often each rule recovers the simulated count.
Procrustes pseudo-validation set#
Ordinary cross-validation assesses the fold models, not the model fitted to all
rows. pseudo_validation_set (Kucheryavskiy, Rodionova and Pomerantsev, 2023)
converts the variation between the fold models into a data set, X_pv, that the
full-data model can be applied to as if it were an independent test set.
from process_improve.multivariate import pseudo_validation_set
pv = pseudo_validation_set(X, y, n_components=2, random_state=0)
diagnostics = pv.global_model.diagnose(pv.X_pv)
alarm_rate = (diagnostics.spe > pv.global_model.spe_limit(0.95)).mean()
For every row, the full-data model gives X_pv the row’s fold-model SPE and its
fold-model scores rescaled per component by the D ratio
\(\mathbf{c}_{ka}^\top\mathbf{c}_a / \mathbf{c}_a^\top\mathbf{c}_a\). The SPE of
X_pv is therefore the row-wise cross-validated X residual. That residual
decreases almost monotonically with the number of components (see the note on
row-wise cross-validation of X above), so the pseudo-validation set is used for
alarm rates against the full-data limits, not to choose the number of components
from its SPE. See Permutation nulls: is there anything here at all? for permutation tests of whether a model
predicts anything at all.
PLS Beta Coefficient Error Bars#
For PLS models, model.cross_validate() refits the model on data subsets
and computes confidence intervals for the regression coefficients. This answers
the question: “How reliable is each beta coefficient?”
Three resampling strategies are supported:
Jackknife (
cv="loo", default) - leave-one-out resampling. Uses the jackknife variance formula with t-distribution critical values.K-fold (
cv=5) - K-fold cross-validation. Faster for large datasets.Bootstrap (
n_bootstrap=200) - resample with replacement. Uses percentile confidence intervals.
from process_improve.multivariate.methods import PLS, MCUVScaler
scaler_x = MCUVScaler().fit(X)
scaler_y = MCUVScaler().fit(Y)
X_s, Y_s = scaler_x.transform(X), scaler_y.transform(Y)
pls = PLS(n_components=2).fit(X_s, Y_s)
# Jackknife (leave-one-out) cross-validation
cv = pls.cross_validate(X_s, Y_s, cv="loo")
print(cv.significant) # Which betas have CIs excluding zero
print(cv.beta_ci_lower) # Lower 95% CI
print(cv.beta_ci_upper) # Upper 95% CI
print(cv.q_squared) # Cross-validated R² (Q²)
print(cv.rmse_cv) # Cross-validated RMSE
The result is a Bunch with:
beta_mean,beta_std: mean and standard error of betas across resamplesbeta_ci_lower,beta_ci_upper: confidence interval boundssignificant: boolean mask -Truewhere the CI excludes zerobeta_samples: raw betas from every resample (n_resamples × K × M)y_hat_cv: out-of-fold Y predictions (jackknife / K-fold only)press: Prediction Error Sum of Squaresrmse_cv: cross-validated RMSE per Y variableq_squared: cross-validated R² (Q²) per Y variable
See Projection to Latent Structures (PLS) for detailed documentation and additional examples.