This guide is the opinionated "how to actually run a tuning job" companion to the
kit. It assumes you can already train a model and measure a validation metric;
the question is how to search the configuration space efficiently and trust the
result. Every code reference maps to a real symbol in src/hpo/.
define metric -> define search space -> pick sampler + pruner/scheduler
-> set a budget -> run study -> inspect best + importance
-> widen/narrow space -> repeat until improvements plateau
Tuning is iterative. The first run is a reconnaissance pass with wide ranges; the
second run narrows toward the promising region. Two or three short rounds almost
always beat one giant run, because you spend later budget where it matters.
Before sampling a single configuration, decide:
accuracy, AUC, RMSE), not a proxy. The kit ships common scorers in
hpo.objective (accuracy, mse, rmse, mae, r2, neg_mse); pass any
callable (y_true, y_pred) -> float for anything custom.
"maximize" for scores, "minimize" for losses. Keep itconsistent across the space, the pruner, and the study. The neg_* scorers
exist so a loss can be maximized uniformly when that is more convenient.
and easy to overfit through hundreds of trials. Prefer K-fold
cross-validation. hpo.objective.Objective wraps K-fold CV for you and returns
the mean fold score.
Pitfall — tuning on the test set. Never let the test set influence the
search. Tune against CV folds or a dedicated validation split, and evaluate the
single chosen configuration on the held-out test set exactly once.
The sampler decides *which configuration to try next*. The kit exposes the Optuna
samplers through StudyConfig(sampler=...):
| Sampler | Best when | Notes |
|---|---|---|
random | Baseline; high-dimensional or highly parallel runs | Embarrassingly parallel; surprisingly strong. Always run it first. |
tpe | The default for most problems (10–500 trials) | Bayesian; models good vs bad regions. Set n_startup_trials to seed it with random trials. |
cmaes | Continuous spaces, smooth landscapes, larger budgets | Strong on numeric-only spaces; weak with many categoricals. |
qmc | Space-filling exploration | Low-discrepancy (Sobol); great for the first exploratory round. |
grid | Small, discrete spaces where you want exhaustive coverage | Provide the grid via sampler_kwargs["search_space"]. |
Rule of thumb: start with random (or qmc) for one round to map the
terrain, then switch to tpe to exploit it. If a smart sampler cannot beat random
search by a meaningful margin, your space or metric is the problem, not the
sampler. The bundled examples/random_search_baseline.py gives you that baseline
with zero dependencies.
Most configurations are mediocre and reveal themselves early. Pruning stops their
training once the intermediate learning curve makes the outcome obvious, which can
multiply throughput several-fold. Pruning needs two things:
1. an objective that reports intermediate values (per epoch, per boosting
round, or — as in hpo.objective.Objective — per CV fold); and
2. a pruner that compares the running trial against completed ones.
The kit implements the decision logic in pure Python so you can reason about it:
MedianPruner — prune a trial whose best-so-far value is worse than themedian of completed trials at the same step. The sensible default.
PercentilePruner(percentile=p) — keep the best p%, prune the rest.MedianPruner is exactly PercentilePruner(50). Lower p is more aggressive.
ThresholdPruner(lower=..., upper=...) — a guard rail: abort trials thatcross an absolute bound (e.g. a diverged loss), independent of other trials.
EarlyStopping(patience=...) — classic in-loop stopping for a single trial;no cross-trial baseline required.
Two parameters control pruning aggressiveness:
n_startup_trials — do not prune until this many trials have *completed*, so thebaseline distribution is meaningful. Too low and you prune against noise.
n_warmup_steps — do not prune any trial before this step, so slow-starting butultimately-strong configurations are not killed prematurely. Models with warm-up
schedules or heavy regularization need a larger value here.
Convert any of these to the real Optuna pruner with to_optuna_pruner(...), or
just hand the kit pruner to StudyConfig(pruner=...) and run_study does it.
Ray Tune reframes pruning as *scheduling* across many parallel trials. The kit's
TuneConfig(scheduler=...) selects:
asha) — Async Successive Halving. Runs many trials for a shortgrace_period, then promotes the top 1/reduction_factor to train longer,
repeating up to max_t. The workhorse for parallel HPO; the Ray analogue of
median/percentile pruning.
pbt) — Population Based Training. Maintains a population, periodicallycopying weights from strong trials and perturbing their hyperparameters. Powerful
for schedules that should *change during training* (e.g. learning rate). Requires
scheduler_kwargs["hyperparam_mutations"].
median) — the simplest comparative rule, a good first try.grace_period, reduction_factor, and max_t trade exploration for speed:
larger grace_period is safer but slower; larger reduction_factor is faster but
riskier. See examples/ray_tune_torch.py for a complete ASHA run.
inspect convergence. The optimization_history helper returns the running best,
which is the convergence curve — if it has been flat for many trials, stop.
random/qmc/ASHA parallelize cleanly; tpe is partlysequential (later trials depend on earlier results) but still benefits from
modest parallelism.
study_name=..., load_if_exists=True)`. You can stop and resume, or point several
worker processes at the same database to fan trials out. examples/optuna_sklearn.py
does exactly this — re-run it and it continues the previous study.
When you care about more than one metric (accuracy *and* latency, recall *and*
precision), there is no single "best" — there is a Pareto front of
non-dominated trade-offs. To run it:
my_latency_scorer]) with directions=["maximize", "minimize"]` on the study;
study.best_trials (the front);pareto_front(records, directions) recomputes the front from backend-agnosticrecords, and you pick the trade-off that fits your product constraints.
Do not collapse multiple objectives into a hand-weighted sum unless the weights are
genuinely known — the weighting hides the trade-off you are trying to see.
After a study finishes, from_optuna_study(study) gives you TrialRecords that
feed every analysis helper:
best_trial / top_k — the winners and the runners-up (inspect the top few:if they cluster, you have found a stable region).
param_importance — a fast variance-reduction proxy telling you which knobsmoved the metric. Use it to redesign the next round: widen important params,
freeze unimportant ones at a good value to cut dimensionality.
optimization_history — the convergence curve.export_csv / export_json — dump everything for a notebook or a report.range — widen it and re-run. See search-space-design.md.
are pruned, raise n_startup_trials and n_warmup_steps.
often larger than the differences you are chasing.
configuration that is lucky, not good. Cross-validate and keep an untouched test
set.
seed on the sampler and CV so results are reproducibleand comparable across rounds.
The short version: baseline with random search, exploit with TPE/ASHA, prune the
losers, cross-validate, inspect importance, narrow the space, repeat.
The search space is the highest-leverage decision in hyperparameter optimization.
A great sampler cannot rescue a badly shaped space, and a well-shaped space makes
even random search competitive. This guide covers the design rules and shows how
to express them with the kit's declarative SearchSpace.
The kit defines one space that compiles to both backends:
from hpo import SearchSpace, FloatParameter, IntParameter, CategoricalParameter
space = SearchSpace([
FloatParameter("learning_rate", 1e-4, 1e-1, log=True),
IntParameter("n_estimators", 50, 500, log=True),
IntParameter("max_depth", 2, 12),
CategoricalParameter("booster", ["gbtree", "dart"]),
])
space.suggest(trial) # -> Optuna (or RandomTrial) values
space.to_ray() # -> Ray Tune param_space
space.sample(seed=0) # -> stdlib random drawDefining the space once is not a convenience detail — it removes the most common
source of irreproducible HPO: the Optuna study and the Ray Tune param_space
silently drifting apart so their results are no longer comparable.
Some parameters are *additive* (a step of +1 means the same thing everywhere) and
some are *multiplicative* (what matters is the ratio, e.g. 0.001 → 0.01 is the same
"distance" as 0.01 → 0.1). Sample multiplicative parameters on a log scale so
the sampler spends equal effort per order of magnitude.
Use log=True for:
FloatParameter("lr", 1e-5, 1e-1, log=True)weight_decay, alpha, CIntParameter("n_estimators", 50, 2000, log=True)Use linear (the default) for bounded, additive parameters: dropout in [0, 0.5],
subsample in [0.5, 1.0], max_depth in [2, 12].
A linear search over
lrin[1e-5, 1e-1]wastes ~90% of its samples in
[1e-2, 1e-1]and almost never tries the small values that often win.log=Truefixes this. The kit enforces
low > 0for log floats andlow >= 1for log ints.
Round one should be deliberately generous: you are locating the promising region,
not polishing it. Round two narrows around the winners.
# Round 1 — reconnaissance
SearchSpace([FloatParameter("lr", 1e-5, 1.0, log=True),
IntParameter("max_depth", 1, 16)])
# Round 2 — exploitation (best was lr≈3e-3, depth≈6)
SearchSpace([FloatParameter("lr", 1e-3, 1e-2, log=True),
IntParameter("max_depth", 4, 8)])Drive the narrowing with data: param_importance tells you which parameters
deserve a wider second-round range, and top_k shows where the good configurations
cluster.
After a round, check the best configuration against your bounds. **If the optimum
sits on an edge of the range, the true optimum is probably outside it.** Widen that
side and re-run. A space whose winner is glued to max_depth = 16 (your upper
bound) is telling you to try max_depth = 24.
This single check catches more bad spaces than any other diagnostic.
FloatParameter — genuinely continuous quantities (rates, fractions,temperatures). Add step= only when the model truly discretizes the value.
IntParameter — counts and depths. Use log=True for counts that spanorders of magnitude; step= for coarse grids (e.g. step=16 for layer widths).
CategoricalParameter — unordered choices (kernel, optimizer, activation).A subtle but important distinction: do not encode an ordered numeric quantity
as a categorical just to restrict it. `CategoricalParameter("hidden", [64, 128,
256])` throws away the ordering a sampler could exploit. Prefer
IntParameter("hidden", 64, 256, step=64) — unless the values are genuinely
unordered, in which case categorical is correct.
Every added dimension dilutes a fixed trial budget. Two tactics:
constant and drop them from the space. The remaining budget concentrates on what
matters.
and derive the other in build_model.
A focused four-parameter space with 60 trials usually beats a sprawling twelve
-parameter space with the same 60 trials.
Some parameters only matter for certain choices — degree is meaningless unless the
SVM kernel is "poly". There are two clean ways to express this with the kit:
A. Branch inside build_model. Sample everything in the flat space and ignore
irrelevant values when constructing the model:
space = SearchSpace([
CategoricalParameter("kernel", ["rbf", "poly", "linear"]),
FloatParameter("C", 1e-2, 1e2, log=True),
IntParameter("degree", 2, 5), # only used when kernel == "poly"
FloatParameter("gamma", 1e-4, 1e1, log=True), # ignored when kernel == "linear"
])
def build_model(params):
from sklearn.svm import SVC
kwargs = {"C": params["C"], "kernel": params["kernel"]}
if params["kernel"] == "poly":
kwargs["degree"] = params["degree"]
if params["kernel"] != "linear":
kwargs["gamma"] = params["gamma"]
return SVC(**kwargs)B. Conditional suggestion (Optuna). Optuna supports define-by-run spaces where
later suggest_* calls depend on earlier ones. Write a custom objective that calls
trial.suggest_categorical("kernel", ...) and only suggests degree in the poly
branch. Use this when the irrelevant dimensions would otherwise confuse the
sampler's model; the flat approach (A) is simpler and fine for small trees of
conditions.
SearchSpace.from_dict builds a space from plain dictionaries, so spaces can live
in JSON or YAML alongside your experiment config and be versioned independently of
code:
spec = {
"learning_rate": {"type": "float", "low": 1e-4, "high": 1e-1, "log": True},
"n_estimators": {"type": "int", "low": 50, "high": 500, "log": True},
"max_depth": {"type": "int", "low": 2, "high": 12},
"max_features": {"type": "categorical", "choices": ["sqrt", "log2", null]},
}
space = SearchSpace.from_dict(spec)space.describe() is the inverse — it returns exactly this dict for any space, so
you can snapshot the space that produced a result into your experiment log.
These are sensible *first-round* spaces. Widen or narrow per the rules above.
Gradient-boosted trees (XGBoost / LightGBM / sklearn):
SearchSpace([
IntParameter("n_estimators", 100, 2000, log=True),
FloatParameter("learning_rate", 1e-3, 3e-1, log=True),
IntParameter("max_depth", 3, 12),
FloatParameter("subsample", 0.5, 1.0),
FloatParameter("colsample_bytree", 0.5, 1.0),
FloatParameter("reg_lambda", 1e-3, 1e2, log=True),
])Neural network (MLP / CNN):
SearchSpace([
FloatParameter("lr", 1e-4, 1e-1, log=True),
FloatParameter("weight_decay", 1e-6, 1e-2, log=True),
IntParameter("hidden_size", 64, 512, step=64),
IntParameter("num_layers", 1, 5),
FloatParameter("dropout", 0.0, 0.5),
CategoricalParameter("optimizer", ["adam", "adamw", "sgd"]),
])Support Vector Machine:
SearchSpace([
FloatParameter("C", 1e-2, 1e3, log=True),
FloatParameter("gamma", 1e-4, 1e1, log=True),
CategoricalParameter("kernel", ["rbf", "poly", "sigmoid"]),
])log=True.Int/Float, not Categorical.build_model or define-by-run).describe() / from_dict) for reproducibility.Get the space right and the rest of HPO becomes routine.