Model Validation
Training error measures how closely a model fits observations it has already seen. Model validation instead estimates generalization: performance on independent data from the same target population. A low training error alone may only reveal overfitting.
SMILE separates orchestration in smile.validation from scores in smile.validation.metric. Splitters return a Bag record; validation runners fit models, score held-out rows, and collect timing, predictions, confusion matrices, and metrics.
A hard classifier returns only labels. A soft classifier also returns posterior probabilities, which enable AUC, log loss, and multiclass cross entropy. Metrics that do not apply to the classifier type or class count are represented by Double.NaN in ClassificationMetrics.
| Package | Purpose |
|---|---|
smile.validation |
Splits, hold-out evaluation, bootstrap, cross-validation, LOOCV, result records, and model-selection criteria |
smile.validation.metric |
Classification, probabilistic classification, regression, and clustering scores |
Classification Metrics
Classification metrics consume integer ground-truth and predicted labels. Import them from smile.validation.metric.*. Accuracy and error work directly for multiclass data; many rate metrics default to binary labels {0, 1}.
Accuracy and Error
Accuracy is the fraction correctly predicted; Error is the number of incorrect predictions. The Iris class column is named class and has three classes.
import smile.datasets.Iris
import smile.validation.metric.Accuracy
import smile.validation.metric.Error
var iris = new Iris()
int[] truth = iris.y()
int[] prediction = truth.clone()
prediction[0] = 1
prediction[50] = 2
prediction[100] = 0
System.out.printf("accuracy=%.2f, error=%d%n",
Accuracy.of(truth, prediction), Error.of(truth, prediction))
accuracy=0.98, error=3
import smile.datasets.Iris
import smile.validation.metric.{Accuracy, Error}
val iris = new Iris()
val truth = iris.y
val prediction = truth.clone
prediction(0) = 1
prediction(50) = 2
prediction(100) = 0
println(f"accuracy=${Accuracy.of(truth, prediction)}%.2f, " +
s"error=${Error.of(truth, prediction)}")
accuracy=0.98, error=3
import smile.datasets.Iris
import smile.validation.metric.Accuracy
import smile.validation.metric.Error
val iris = Iris()
val truth = iris.y()
val prediction = truth.clone()
prediction[0] = 1
prediction[50] = 2
prediction[100] = 0
println("accuracy=%.2f, error=%d".format(
Accuracy.of(truth, prediction), Error.of(truth, prediction)))
accuracy=0.98, error=3
Precision, Recall, F-Score, and Rates
Precision asks how many predicted positives are correct; recall (the same quantity as sensitivity) asks how many actual positives were found. Specificity is the true-negative rate, fallout is the false-positive rate, and FDR is the false-discovery rate. F-score balances precision and recall; FScore.F1.score(...) is equivalent to FScore.of(truth, prediction, 1.0, null) for binary labels.
For multiclass precision, recall, and F-score, pass Averaging.Macro, Micro, or Weighted. Macro gives each class equal weight, micro pools decisions, and weighted averaging follows class support.
import smile.validation.metric.*
int[] truth = {0, 0, 1, 1, 1, 0}
int[] prediction = {0, 1, 1, 1, 0, 0}
System.out.printf(
"P=%.3f R=%.3f F1=%.3f sensitivity=%.3f specificity=%.3f%n" +
"fallout=%.3f FDR=%.3f MCC=%.3f%n",
Precision.of(truth, prediction),
Recall.of(truth, prediction),
FScore.F1.score(truth, prediction),
Sensitivity.of(truth, prediction),
Specificity.of(truth, prediction),
Fallout.of(truth, prediction),
FDR.of(truth, prediction),
MatthewsCorrelation.of(truth, prediction))
P=0.667 R=0.667 F1=0.667
sensitivity=0.667 specificity=0.667 fallout=0.333 FDR=0.333 MCC=0.333
import smile.validation.metric.*
val truth = Array(0, 0, 1, 1, 1, 0)
val prediction = Array(0, 1, 1, 1, 0, 0)
println(f"P=${Precision.of(truth, prediction)}%.3f " +
f"R=${Recall.of(truth, prediction)}%.3f " +
f"F1=${FScore.F1.score(truth, prediction)}%.3f")
println(f"sensitivity=${Sensitivity.of(truth, prediction)}%.3f " +
f"specificity=${Specificity.of(truth, prediction)}%.3f " +
f"fallout=${Fallout.of(truth, prediction)}%.3f " +
f"FDR=${FDR.of(truth, prediction)}%.3f " +
f"MCC=${MatthewsCorrelation.of(truth, prediction)}%.3f")
P=0.667 R=0.667 F1=0.667
sensitivity=0.667 specificity=0.667 fallout=0.333 FDR=0.333 MCC=0.333
import smile.validation.metric.*
val truth = intArrayOf(0, 0, 1, 1, 1, 0)
val prediction = intArrayOf(0, 1, 1, 1, 0, 0)
println("P=%.3f R=%.3f F1=%.3f".format(
Precision.of(truth, prediction),
Recall.of(truth, prediction),
FScore.F1.score(truth, prediction)))
println("sensitivity=%.3f specificity=%.3f fallout=%.3f FDR=%.3f MCC=%.3f".format(
Sensitivity.of(truth, prediction),
Specificity.of(truth, prediction),
Fallout.of(truth, prediction),
FDR.of(truth, prediction),
MatthewsCorrelation.of(truth, prediction)))
P=0.667 R=0.667 F1=0.667
sensitivity=0.667 specificity=0.667 fallout=0.333 FDR=0.333 MCC=0.333
MCC and Confusion Matrix
Matthews correlation coefficient summarizes all four binary contingency counts and remains informative under imbalance. ConfusionMatrix.of exposes the complete class-by-class count matrix, with truth on rows and prediction on columns.
import smile.validation.metric.ConfusionMatrix
int[] truth = {0, 0, 1, 1, 1, 0}
int[] prediction = {0, 1, 1, 1, 0, 0}
System.out.println(ConfusionMatrix.of(truth, prediction))
2 x 2 confusion matrix with rows [2, 1] and [1, 2]
import smile.validation.metric.ConfusionMatrix
val truth = Array(0, 0, 1, 1, 1, 0)
val prediction = Array(0, 1, 1, 1, 0, 0)
println(ConfusionMatrix.of(truth, prediction))
2 x 2 confusion matrix with rows [2, 1] and [1, 2]
import smile.validation.metric.ConfusionMatrix
val truth = intArrayOf(0, 0, 1, 1, 1, 0)
val prediction = intArrayOf(0, 1, 1, 1, 0, 0)
println(ConfusionMatrix.of(truth, prediction))
2 x 2 confusion matrix with rows [2, 1] and [1, 2]
ClassificationMetrics Fields
Probabilistic Classification Metrics
AUC and log loss accept the probability of binary class 1. AUC measures ranking quality; log loss penalizes confident mistakes. Cross entropy generalizes log loss to a posterior-probability row for every multiclass sample. These scores require a soft classifier or explicitly supplied probabilities.
import smile.validation.metric.AUC
import smile.validation.metric.LogLoss
import smile.validation.metric.CrossEntropy
int[] binaryTruth = {0, 0, 1, 1}
double[] probability = {0.10, 0.40, 0.35, 0.80}
System.out.printf("AUC=%.2f, logloss=%.4f%n",
AUC.of(binaryTruth, probability),
LogLoss.of(binaryTruth, probability))
int[] truth = {0, 2, 1}
double[][] posterior = {
{0.80, 0.10, 0.10},
{0.05, 0.15, 0.80},
{0.10, 0.70, 0.20}
}
System.out.printf("crossEntropy=%.4f%n",
CrossEntropy.of(truth, posterior))
AUC=0.75, logloss=0.4723
crossEntropy=0.2677
import smile.validation.metric.{AUC, CrossEntropy, LogLoss}
val binaryTruth = Array(0, 0, 1, 1)
val probability = Array(0.10, 0.40, 0.35, 0.80)
println(f"AUC=${AUC.of(binaryTruth, probability)}%.2f, " +
f"logloss=${LogLoss.of(binaryTruth, probability)}%.4f")
val truth = Array(0, 2, 1)
val posterior = Array(
Array(0.80, 0.10, 0.10),
Array(0.05, 0.15, 0.80),
Array(0.10, 0.70, 0.20))
println(f"crossEntropy=${CrossEntropy.of(truth, posterior)}%.4f")
AUC=0.75, logloss=0.4723
crossEntropy=0.2677
import smile.validation.metric.AUC
import smile.validation.metric.LogLoss
import smile.validation.metric.CrossEntropy
val binaryTruth = intArrayOf(0, 0, 1, 1)
val probability = doubleArrayOf(0.10, 0.40, 0.35, 0.80)
println("AUC=%.2f, logloss=%.4f".format(
AUC.of(binaryTruth, probability),
LogLoss.of(binaryTruth, probability)))
val truth = intArrayOf(0, 2, 1)
val posterior = arrayOf(
doubleArrayOf(0.80, 0.10, 0.10),
doubleArrayOf(0.05, 0.15, 0.80),
doubleArrayOf(0.10, 0.70, 0.20))
println("crossEntropy=%.4f".format(
CrossEntropy.of(truth, posterior)))
AUC=0.75, logloss=0.4723
crossEntropy=0.2677
Regression Metrics
Regression scores compare continuous predictions with true responses. MSE and RMSE emphasize large residuals, MAD is more robust, RSS is the unnormalized squared-error total, and R² reports improvement over predicting the response mean.
import smile.validation.metric.*
double[] truth = {3.0, -0.5, 2.0, 7.0}
double[] prediction = {2.5, 0.0, 2.0, 8.0}
System.out.printf("MSE=%.3f RMSE=%.3f MAD=%.3f RSS=%.3f R2=%.3f%n",
MSE.of(truth, prediction), RMSE.of(truth, prediction),
MAD.of(truth, prediction), RSS.of(truth, prediction),
R2.of(truth, prediction))
MSE=0.375 RMSE=0.612 MAD=0.500 RSS=1.500 R2=0.949
import smile.validation.metric.*
val truth = Array(3.0, -0.5, 2.0, 7.0)
val prediction = Array(2.5, 0.0, 2.0, 8.0)
println(f"MSE=${MSE.of(truth, prediction)}%.3f " +
f"RMSE=${RMSE.of(truth, prediction)}%.3f " +
f"MAD=${MAD.of(truth, prediction)}%.3f " +
f"RSS=${RSS.of(truth, prediction)}%.3f " +
f"R2=${R2.of(truth, prediction)}%.3f")
MSE=0.375 RMSE=0.612 MAD=0.500 RSS=1.500 R2=0.949
import smile.validation.metric.*
val truth = doubleArrayOf(3.0, -0.5, 2.0, 7.0)
val prediction = doubleArrayOf(2.5, 0.0, 2.0, 8.0)
println("MSE=%.3f RMSE=%.3f MAD=%.3f RSS=%.3f R2=%.3f".format(
MSE.of(truth, prediction), RMSE.of(truth, prediction),
MAD.of(truth, prediction), RSS.of(truth, prediction),
R2.of(truth, prediction)))
MSE=0.375 RMSE=0.612 MAD=0.500 RSS=1.500 R2=0.949
Data Splitting
Splitting must happen before fitting preprocessors or models. Bag.split makes one random hold-out split; its DataFrame overload returns Tuple2<DataFrame, DataFrame>. Bag.stratify preserves class proportions.
import smile.datasets.Iris
import smile.validation.Bag
var iris = new Iris()
Bag index = Bag.split(iris.data().nrow(), 0.20)
var split = Bag.split(iris.data(), 0.20)
var stratified = Bag.stratify(iris.data(), "class", 0.20)
System.out.printf("indices=%d/%d, frames=%d/%d, stratified=%d/%d%n",
index.samples().length, index.oob().length,
split._1().nrow(), split._2().nrow(),
stratified._1().nrow(), stratified._2().nrow())
indices=120/30, frames=120/30, stratified=120/30
import smile.datasets.Iris
import smile.validation.Bag
val iris = new Iris()
val index = Bag.split(iris.data.nrow, 0.20)
val split = Bag.split(iris.data, 0.20)
val stratified = Bag.stratify(iris.data, "class", 0.20)
println(s"indices=${index.samples.length}/${index.oob.length}, " +
s"frames=${split._1.nrow}/${split._2.nrow}, " +
s"stratified=${stratified._1.nrow}/${stratified._2.nrow}")
indices=120/30, frames=120/30, stratified=120/30
import smile.datasets.Iris
import smile.validation.Bag
val iris = Iris()
val index = Bag.split(iris.data().nrow(), 0.20)
val split = Bag.split(iris.data(), 0.20)
val stratified = Bag.stratify(iris.data(), "class", 0.20)
println("indices=${index.samples().size}/${index.oob().size}, " +
"frames=${split._1().nrow()}/${split._2().nrow()}, " +
"stratified=${stratified._1().nrow()}/${stratified._2().nrow()}")
indices=120/30, frames=120/30, stratified=120/30
Bootstrap.of samples training rows with replacement and puts never-selected rows in oob(). CrossValidation.of partitions rows into k folds; stratify preserves labels and nonoverlap keeps all rows from a group in one test fold. LOOCV.of returns the training indices for every possible single-row hold-out.
import smile.datasets.Iris
import smile.validation.*
var iris = new Iris()
Bag[] bootstrap = Bootstrap.of(iris.data().nrow(), 5)
Bag[] folds = CrossValidation.of(iris.data().nrow(), 10)
Bag[] stratified = CrossValidation.stratify(iris.y(), 10)
int[] groups = new int[150]
for (int i = 0; i < groups.length; i++) groups[i] = i / 10
Bag[] grouped = CrossValidation.nonoverlap(groups, 5)
int[][] loo = LOOCV.of(iris.data().nrow())
System.out.printf("%d bootstrap, %d folds, %d stratified, %d grouped, %d LOO%n",
bootstrap.length, folds.length, stratified.length,
grouped.length, loo.length)
5 bootstrap, 10 folds, 10 stratified, 5 grouped, 150 LOO
import smile.datasets.Iris
import smile.validation.*
val iris = new Iris()
val bootstrap = Bootstrap.of(iris.data.nrow, 5)
val folds = CrossValidation.of(iris.data.nrow, 10)
val stratified = CrossValidation.stratify(iris.y, 10)
val groups = Array.tabulate(150)(_ / 10)
val grouped = CrossValidation.nonoverlap(groups, 5)
val loo = LOOCV.of(iris.data.nrow)
println(s"${bootstrap.length} bootstrap, ${folds.length} folds, " +
s"${stratified.length} stratified, ${grouped.length} grouped, ${loo.length} LOO")
5 bootstrap, 10 folds, 10 stratified, 5 grouped, 150 LOO
import smile.datasets.Iris
import smile.validation.Bootstrap
import smile.validation.CrossValidation
import smile.validation.LOOCV
val iris = Iris()
val bootstrap = Bootstrap.of(iris.data().nrow(), 5)
val folds = CrossValidation.of(iris.data().nrow(), 10)
val stratified = CrossValidation.stratify(iris.y(), 10)
val groups = IntArray(150) { it / 10 }
val grouped = CrossValidation.nonoverlap(groups, 5)
val loo = LOOCV.of(iris.data().nrow())
println("${bootstrap.size} bootstrap, ${folds.size} folds, " +
"${stratified.size} stratified, ${grouped.size} grouped, ${loo.size} LOO")
5 bootstrap, 10 folds, 10 stratified, 5 grouped, 150 LOO
Hold-out Validation
When a dataset already supplies independent train and test partitions, pass both to ClassificationValidation.of. The result retains the model, labels, predictions, posterior probabilities when available, a confusion matrix, and metrics.
import smile.classification.DecisionTree
import smile.datasets.ImageSegmentation
import smile.validation.ClassificationValidation
var segment = new ImageSegmentation()
var validation = ClassificationValidation.of(
segment.formula(), segment.train(), segment.test(), DecisionTree::fit)
System.out.println(validation.metrics().accuracy())
System.out.println(validation.confusion())
test accuracy, followed by the 7 × 7 confusion matrix
import smile.classification.DecisionTree
import smile.datasets.ImageSegmentation
import smile.validation.ClassificationValidation
val segment = new ImageSegmentation()
val validation = ClassificationValidation.of(
segment.formula, segment.train, segment.test, DecisionTree.fit)
println(validation.metrics.accuracy)
println(validation.confusion)
test accuracy, followed by the 7 × 7 confusion matrix
import smile.classification.DecisionTree
import smile.datasets.ImageSegmentation
import smile.validation.ClassificationValidation
val segment = ImageSegmentation()
val validation = ClassificationValidation.of(
segment.formula(), segment.train(), segment.test()) { f, d ->
DecisionTree.fit(f, d)
}
println(validation.metrics().accuracy())
println(validation.confusion())
test accuracy, followed by the 7 × 7 confusion matrix
Out-of-Bag Estimates
Out-of-bag evaluation is also an ensemble property. For example, a fitted RandomForest.metrics() after fit reports OOB ClassificationMetrics computed while growing the forest. That estimate is produced by the ensemble itself; it is not a separate smile.validation runner.
Cross-Validation Runners
The runners build every model and return ClassificationValidations. Use avg() and std() for aggregate records and rounds() for individual folds. Stratification is usually preferred for imbalanced classification.
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.CrossValidation
var iris = new Iris()
var cv = CrossValidation.classification(
10, iris.formula(), iris.data(), DecisionTree::fit)
var stratified = CrossValidation.stratify(
10, iris.formula(), iris.data(), DecisionTree::fit)
var repeated = CrossValidation.classification(
3, 5, iris.formula(), iris.data(), DecisionTree::fit)
System.out.printf("cv=%.3f ± %.3f, stratified=%.3f, repeated rounds=%d%n",
cv.avg().accuracy(), cv.std().accuracy(),
stratified.avg().accuracy(), repeated.rounds().size())
10-fold mean ± standard deviation; repeated rounds=15
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.CrossValidation
val iris = new Iris()
val cv = CrossValidation.classification(
10, iris.formula, iris.data, DecisionTree.fit)
val stratified = CrossValidation.stratify(
10, iris.formula, iris.data, DecisionTree.fit)
val repeated = CrossValidation.classification(
3, 5, iris.formula, iris.data, DecisionTree.fit)
println(f"cv=${cv.avg.accuracy}%.3f ± ${cv.std.accuracy}%.3f, " +
f"stratified=${stratified.avg.accuracy}%.3f, " +
s"repeated rounds=${repeated.rounds.size}")
10-fold mean ± standard deviation; repeated rounds=15
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.CrossValidation
val iris = Iris()
val cv = CrossValidation.classification(
10, iris.formula(), iris.data()) { f, d -> DecisionTree.fit(f, d) }
val stratified = CrossValidation.stratify(
10, iris.formula(), iris.data()) { f, d -> DecisionTree.fit(f, d) }
val repeated = CrossValidation.classification(
3, 5, iris.formula(), iris.data()) { f, d -> DecisionTree.fit(f, d) }
println("cv=%.3f ± %.3f, stratified=%.3f, repeated rounds=%d".format(
cv.avg().accuracy(), cv.std().accuracy(),
stratified.avg().accuracy(), repeated.rounds().size))
10-fold mean ± standard deviation; repeated rounds=15
Leave-One-Out Cross-Validation
LOOCV trains once per row and combines all left-out predictions into one metrics record. It can reduce split-size bias but is computationally expensive. LDA.fit is an array API, so the runnable Iris demo uses x() and y() rather than a Formula overload.
import smile.classification.LDA
import smile.datasets.Iris
import smile.validation.LOOCV
var iris = new Iris()
var metrics = LOOCV.classification(iris.x(), iris.y(), LDA::fit)
System.out.println(metrics.accuracy())
Iris leave-one-out accuracy
import smile.classification.LDA
import smile.datasets.Iris
import smile.validation.LOOCV
val iris = new Iris()
val metrics = LOOCV.classification(iris.x, iris.y, LDA.fit)
println(metrics.accuracy)
Iris leave-one-out accuracy
import smile.classification.LDA
import smile.datasets.Iris
import smile.validation.LOOCV
val iris = Iris()
val metrics = LOOCV.classification(iris.x(), iris.y()) { x, y ->
LDA.fit(x, y)
}
println(metrics.accuracy())
Iris leave-one-out accuracy
Bootstrap Validation
Bootstrap validation repeatedly samples a training bag with replacement and scores the observations absent from that bag. More rounds stabilize the estimate at the cost of more fits.
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.Bootstrap
var iris = new Iris()
var result = Bootstrap.classification(
100, iris.formula(), iris.data(), DecisionTree::fit)
System.out.printf("accuracy=%.3f ± %.3f%n",
result.avg().accuracy(), result.std().accuracy())
100-bootstrap mean accuracy ± standard deviation
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.Bootstrap
val iris = new Iris()
val result = Bootstrap.classification(
100, iris.formula, iris.data, DecisionTree.fit)
println(f"accuracy=${result.avg.accuracy}%.3f ± " +
f"${result.std.accuracy}%.3f")
100-bootstrap mean accuracy ± standard deviation
import smile.classification.DecisionTree
import smile.datasets.Iris
import smile.validation.Bootstrap
val iris = Iris()
val result = Bootstrap.classification(
100, iris.formula(), iris.data()) { f, d -> DecisionTree.fit(f, d) }
println("accuracy=%.3f ± %.3f".format(
result.avg().accuracy(), result.std().accuracy()))
100-bootstrap mean accuracy ± standard deviation
Regression Cross-Validation
Regression runners mirror classification runners and return RegressionValidations. This example predicts Abalone rings with a regression tree and reports held-out RMSE.
import smile.datasets.Abalone
import smile.regression.RegressionTree
import smile.validation.CrossValidation
var abalone = new Abalone()
var cv = CrossValidation.regression(
10, abalone.formula(), abalone.train(), RegressionTree::fit)
System.out.printf("RMSE=%.4f ± %.4f%n",
cv.avg().rmse(), cv.std().rmse())
10-fold RMSE mean ± standard deviation
import smile.datasets.Abalone
import smile.regression.RegressionTree
import smile.validation.CrossValidation
val abalone = new Abalone()
val cv = CrossValidation.regression(
10, abalone.formula, abalone.train, RegressionTree.fit)
println(f"RMSE=${cv.avg.rmse}%.4f ± ${cv.std.rmse}%.4f")
10-fold RMSE mean ± standard deviation
import smile.datasets.Abalone
import smile.regression.RegressionTree
import smile.validation.CrossValidation
val abalone = Abalone()
val cv = CrossValidation.regression(
10, abalone.formula(), abalone.train()) { f, d ->
RegressionTree.fit(f, d)
}
println("RMSE=%.4f ± %.4f".format(
cv.avg().rmse(), cv.std().rmse()))
10-fold RMSE mean ± standard deviation
AIC and BIC
ModelSelection.AIC(logL, k) and ModelSelection.BIC(logL, k, n) balance maximized log-likelihood against parameter count. Lower is preferred among models fit to the same response data. BIC's k log(n) penalty grows more strongly with sample size than AIC's 2k penalty.
import smile.validation.ModelSelection
double logL = -120.0
int parameters = 8
int samples = 500
System.out.printf("AIC=%.2f, BIC=%.2f%n",
ModelSelection.AIC(logL, parameters),
ModelSelection.BIC(logL, parameters, samples))
AIC=256.00, BIC=289.72
import smile.validation.ModelSelection
val logL = -120.0
val parameters = 8
val samples = 500
println(f"AIC=${ModelSelection.AIC(logL, parameters)}%.2f, " +
f"BIC=${ModelSelection.BIC(logL, parameters, samples)}%.2f")
AIC=256.00, BIC=289.72
import smile.validation.ModelSelection
val logL = -120.0
val parameters = 8
val samples = 500
println("AIC=%.2f, BIC=%.2f".format(
ModelSelection.AIC(logL, parameters),
ModelSelection.BIC(logL, parameters, samples)))
AIC=256.00, BIC=289.72
For systematic hyperparameter search around a validation objective, continue to Hyperparameter Optimization.
Validation Pitfalls
- Leakage: fit scaling, imputation, feature selection, and all learned transforms inside each training split—not once on the full dataset.
- Imbalance: accuracy can hide failure on a rare class; stratify and inspect precision, recall, MCC, and the confusion matrix.
- LOOCV cost: it trains one model per sample and is often slower than k-fold CV without a practical gain.
- NaN metrics: probability scores require a soft classifier, and binary-only rates are unavailable for multiclass results unless an averaging strategy is used.
Clustering Metrics
Rand index, adjusted Rand index, mutual information, and normalized or adjusted mutual information are covered with clustering workflows on the Clustering page.