Threshold Tuning
predict()'s default decision rule — argmax for multiclass, 0.5 for
binary — is rarely optimal for imbalanced classes or for a metric you
actually care about (F1, MCC, balanced accuracy, a custom business metric, …).
Skyulf can search for per-class decision thresholds that maximize a metric
you supply, evaluated on held-out validation data.
The functionality is available at three levels:
- Pipeline level:
SkyulfPipeline.optimize_thresholds()+predict(use_tuned_thresholds=True)— handles preprocessing andpredict_probafor you. - Array level:
skyulf.modeling.optimize_thresholds()/apply_thresholds()— plain NumPy in/out, for use outside a pipeline (e.g. with a raw sklearn estimator). - Tuning-engine integration:
TuningConfig(tune_threshold=True)— after hyperparameter tuning, the engine selects the threshold from validation data (ordinary CV) or training-only inner OOF predictions (nested CV), then applies it in every subsequent prediction automatically (binary classifiers only). See Integrated with hyperparameter tuning.
After training in the canvas
You can tune a completed classifier from Experiments → Model Evaluation → Threshold Tuning, even if Tune decision threshold was off during training. The evaluation must include class probabilities.
Available objectives are Accuracy, F1, Precision, Recall, and Balanced Accuracy. ROC AUC measures probability ranking, which a decision threshold does not change, so it is not a threshold-tuning objective. Choose Balanced Accuracy to balance recall across classes. ROC AUC remains available for model evaluation and hyperparameter search.
- Choose a metric and click Preview to calculate thresholds. This does not save them or change predictions.
- Click Save to persist the thresholds and enable them for predictions.
- Use Use tuned thresholds at prediction time to turn the saved set off or on. The switch stays disabled until a saved set exists.
- Clear deletes the saved set and restores the default prediction rule.
Preview uses validation data when available, otherwise the test split, as shown in the panel. No retraining is required. If training already saved thresholds, the panel loads that set and its enabled state automatically.
Previously saved thresholds marked roc_auc remain available to use, toggle,
or clear. Their cutoffs are preserved. To replace them, choose a supported
objective and run Preview, then Save. Save stays disabled for a loaded
set whose metric is unavailable for new tuning until a supported preview succeeds.
New preview/save API requests
using roc_auc return HTTP 400 with a supported alternative.
Pipeline usage
Tune against explicit, out-of-sample validation data — never the pipeline's internal train/test split. Carve the holdout out of the raw data before fitting, and pass those raw rows in:
from sklearn.metrics import f1_score
from sklearn.model_selection import train_test_split
# Raw holdout, carved before fit(): optimize_thresholds() preprocesses it itself.
train_raw, val_raw = train_test_split(customers, test_size=0.2, random_state=42)
X_val = val_raw.drop(columns=["purchased"])
y_val = val_raw["purchased"]
pipeline.fit(train_raw, target_column="purchased")
thresholds = pipeline.optimize_thresholds(
X_val,
y_val,
metric=lambda y_true, y_pred: f1_score(y_true, y_pred, average="macro"),
)
# thresholds: {False: 0.62, True: 0.38} (binary)
# or one entry per class (multiclass)
# Later, predict using the stored thresholds instead of the default rule:
tuned_predictions = pipeline.predict(new_customers, use_tuned_thresholds=True)
optimize_thresholds() runs the pipeline's already-fitted preprocessing on
(X_val, y_val) internally — exactly once, the same single pass predict()
makes on the features — then calls the model's predict_proba, searches
thresholds, and stores the result on the pipeline. Labels follow any row sorting
or filtering, so each probability is scored against its matching target. Raw
features and labels must have equal lengths and correspond positionally.
That one pass is why X_val has to be raw: it is what
makes the probabilities the cutoffs are fitted against the same probabilities
predict(use_tuned_thresholds=True) later reproduces.
predict(use_tuned_thresholds=True) then applies those stored thresholds; it
raises a ValueError if optimize_thresholds() was never called, and requires
a model that supports predict_proba.
Do not tune on get_fitted_split() output.
That helper returns frames the preprocessing chain has already transformed;
it exists so you can compare raw sklearn/XGBoost/CatBoost estimators against
the exact split the pipeline would use. Handing its X_test to
optimize_thresholds() preprocesses the holdout a second time, so the search
fits cutoffs against a distribution inference never reproduces. Nothing errors
— the returned dict looks plausible, and the metric can even score better on
the doubly-transformed fold, which is why the mismatch stays invisible.
If your config already contains a TrainTestSplitter, fitting on train_raw
splits it a second time internally. That is fine, and it is what keeps the
model off your tuning data: the model trains on the splitter's train part, and
val_raw is untouched by both.
SkyulfPipeline.optimize_thresholds signature
pipeline.optimize_thresholds(
X_val, # validation features, NOT yet transformed
y_val, # validation true labels
metric, # Callable (y_true, y_pred) -> float, to maximize
strategy=None, # "grid" | "nelder-mead" | None (auto-select)
grid_points=101, # candidates for the "grid" strategy
) -> dict[Any, float] # {class_label: threshold}
Array-level usage
Use these directly when you are not going through a SkyulfPipeline — for
example, tuning a raw sklearn estimator's thresholds:
from skyulf.modeling import optimize_thresholds, apply_thresholds
from sklearn.metrics import f1_score
y_proba = model.predict_proba(X_val) # shape (n_samples, n_classes)
thresholds = optimize_thresholds(
y_val,
y_proba,
metric=lambda y_true, y_pred: f1_score(y_true, y_pred, average="macro"),
)
y_pred = apply_thresholds(model.predict_proba(X_test), thresholds)
optimize_thresholds signature
optimize_thresholds(
y_true, # 1D array-like of true labels
y_proba, # (n_samples, n_classes) predicted probabilities
metric, # Callable (y_true, y_pred) -> float, to maximize
classes=None, # explicit class order; defaults to sorted(unique(y_true))
strategy=None, # "grid" | "nelder-mead" | None (auto-select)
grid_points=101, # candidates for the "grid" strategy, over (0, 1) exclusive
) -> dict[Any, float]
apply_thresholds signature
apply_thresholds(
y_proba, # (n_samples, n_classes) predicted probabilities
thresholds, # a single float (binary) or {class_label: threshold} dict
classes=None, # explicit class order; required for 3+ classes with a dict
) -> np.ndarray # 1D array of predicted class labels
Integrated with hyperparameter tuning
When a classifier is trained through the tuning engine — a canvas Training node
in Advanced mode, the "hyperparameter_tuner" pipeline model type, or
TuningCalculator directly — set tune_threshold: true to have the engine
pick the decision threshold. Ordinary tuning uses the validation split after
final refit; nested tuning uses the training-only OOF procedure below. Subsequent
predictions use that cutoff instead of the default
0.5:
config = {
"modeling": {
"type": "hyperparameter_tuner",
"base_model": {"type": "logistic_regression"},
"strategy": "random",
"search_space": {"C": [0.1, 1.0, 10.0]},
"metric": "f1",
"tune_threshold": True,
},
}
In the canvas, this is the Tune decision threshold checkbox in the Training node's Tuning Strategy section (classification models, Advanced mode).
The selected threshold is stored on the tuning result
(decision_thresholds, plus decision_threshold_metric naming the metric the
sweep maximised) and surfaces in the job metrics; the model's predictions
apply it automatically — there is no separate "use tuned thresholds" flag at
this level.
When the job runs through the backend (canvas Training node), the selected threshold is also seeded into the job's saved-threshold store — the same one the Experiments / Inference Threshold Tuning panel reads — and enabled by default. A threshold tuned at training time therefore shows up in the panel ("Optimized for …, computed from validation split", labelled seeded at training) and is active for deployed predictions immediately, and the usual panel controls apply: toggle it off, replace it with a manual preview, or clear it. Predict-time precedence is unchanged: request-level override > saved + enabled thresholds > default decision rule.
Ordinary tuning gates — the engine logs a skip in the job log and predictions keep the default decision rule when any of these don't hold:
- the model is a classifier exposing
predict_proba; - the target is binary (for multiclass, use the pipeline- or array-level APIs above);
- a validation split exists (configure a train/validation split upstream);
- the validation split contains both classes — with one class present every candidate cutoff scores identically, so there is nothing to tune against.
Probability-only metrics (roc_auc, log_loss, pr_auc, …) cannot be
computed from hard labels, so the cutoff sweep maximises balanced_accuracy
instead — the log says so, and decision_threshold_metric records it.
Ordinary validation-based threshold tuning is best-effort: an error during the
sweep never aborts the tuning run.
Nested training-only threshold selection
With cv_type="nested_cv" and tune_threshold=True, each outer training
partition runs its own parameter search. The winning recipe is then fitted on
fresh inner training folds to collect out-of-fold probabilities. The threshold
is selected only from those probabilities and their training labels, then used
for hard-label evaluation of that outer fold. Probability/ranking metrics are
unchanged by threshold selection.
A separate final search and OOF sweep produce the deployable model's threshold.
The final holdout never selects it. The stored provenance is inner_oof, and the
nested report records coverage, per-fold thresholds and the independent final
threshold. Temporal warmup rows without OOF predictions are excluded from the
sweep. Group policies retain whole-group isolation.
Nested threshold selection requires binary classification and predict_proba.
Invalid probabilities, unsupported targets or failed fold evidence fail the run;
a partial nested evaluation cannot become a successful report. Saved local
Databricks artifacts retain the threshold and apply it when replayed. Direct
pipeline callers can select it with predict(use_tuned_thresholds=True).
How the search works
| Classes | Default strategy | Decision rule |
|---|---|---|
| Binary (2) | grid |
positive class when y_proba[:, 1] >= threshold, else negative |
| Multiclass (3+) | nelder-mead |
scaled argmax: classes[argmax(y_proba / threshold_per_class)] |
- Binary uses a grid search over
(0, 1)(grid_pointscandidates, exclusive of the endpoints). Metrics like F1 are piecewise constant in the cutoff, so exactly-tied plateaus are common; ties break toward the default0.5cut. Applying the returned per-class thresholds reproduces the same>=rule the search scored with, an exact tie included. When every candidate ties — a validation split holding one class, probabilities so saturated that no cutoff straddles a boundary, or a metric that ignores its predictions — there is no signal to tune against: the search keeps the default0.5cut and logs a warning saying so, rather than silently returning cutoffs indistinguishable from a genuine optimum. - Multiclass uses Nelder–Mead optimization over a scaled-argmax rule.
Equal thresholds across all classes reduce to plain
argmax, so the tuned result can only match or beat the default rule on the tuning metric.
Pass strategy="grid" for binary classification or strategy="nelder-mead"
explicitly to override the auto-selection. Grid search requires exactly two
classes; requesting it for multiclass probabilities raises ValueError.
Notes
- The
metricis fully caller-supplied — Skyulf ships no default metric. Any callable(y_true, y_pred) -> floatthat returns a value to maximize works (wrap "lower is better" metrics accordingly). - Always tune on data that is independent of both training and the final test set. See Validation vs scikit-learn and SplitDataset & Leakage.