We often see: a model is trained, baseline accuracy seems acceptable, but hyperparameters are taken "from examples". Learning rate — because "that's what the tutorial said", batch size — "standard", dropout — "by eye". After proper HPO on the same data and architecture, we get +4–8% accuracy. This is not magic, but systematic search using Optuna, Ray Tune, and Hyperopt. Let's break down how we integrate HPO into production and save up to 5× compute resources.
Why Bayesian Optimization beats Random Search
Random Search is effective for high-dimensional spaces and small budgets. But when important hyperparameters number 3–5 (typical case), Bayesian Optimization with TPE starts winning from ~30th trial. TPE builds separate densities for "good" (top-25%) and "bad" configurations, then suggests configurations with high Expected Improvement. Grid Search today is only applicable to two hyperparameters — beyond that, combinatorial explosion.
How Optuna cuts search time
Optuna is the de-facto standard for HPO in Python. Key advantages: Pythonic API with no YAML configs, built-in pruning, integration with MLflow and Weights & Biases. The killer feature is Hyperband Pruner, which cuts bad trials early. In practice, out of 200 LightGBM trials, 40–60% are pruned after 50–100 rounds instead of full 2000. Resulting speedup: 3–5×.
Full example: LightGBM optimization with pruning
import optuna from optuna.integration import LightGBMPruningCallback import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score import numpy as np def objective(trial: optuna.Trial, X, y) -> float: params = { 'objective': 'binary', 'metric': 'auc', 'verbosity': -1, 'boosting_type': trial.suggest_categorical('boosting', ['gbdt', 'dart']), 'n_estimators': trial.suggest_int('n_estimators', 100, 2000), 'learning_rate': trial.suggest_float('learning_rate', 1e-4, 0.3, log=True), 'num_leaves': trial.suggest_int('num_leaves', 20, 300), 'max_depth': trial.suggest_int('max_depth', 3, 12), 'min_child_samples': trial.suggest_int('min_child_samples', 5, 300), 'feature_fraction': trial.suggest_float('feature_fraction', 0.4, 1.0), 'bagging_fraction': trial.suggest_float('bagging_fraction', 0.4, 1.0), 'bagging_freq': trial.suggest_int('bagging_freq', 1, 7), 'reg_alpha': trial.suggest_float('reg_alpha', 1e-9, 10.0, log=True), 'reg_lambda': trial.suggest_float('reg_lambda', 1e-9, 10.0, log=True), } cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) cv_scores = [] for fold, (train_idx, val_idx) in enumerate(cv.split(X, y)): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y[train_idx], y[val_idx] dtrain = lgb.Dataset(X_train, label=y_train) dval = lgb.Dataset(X_val, label=y_val, reference=dtrain) pruning_callback = LightGBMPruningCallback(trial, 'auc', valid_name='valid_1') model = lgb.train( params, dtrain, valid_sets=[dtrain, dval], num_boost_round=params['n_estimators'], callbacks=[ lgb.early_stopping(stopping_rounds=50, verbose=False), lgb.log_evaluation(period=-1), pruning_callback, ], ) y_pred = model.predict(X_val) cv_scores.append(roc_auc_score(y_val, y_pred)) return float(np.mean(cv_scores)) sampler = optuna.samplers.TPESampler( n_startup_trials=20, multivariate=True, seed=42 ) pruner = optuna.pruners.HyperbandPruner( min_resource=50, max_resource=2000, reduction_factor=3 ) study = optuna.create_study( direction='maximize', sampler=sampler, pruner=pruner, study_name='lgbm_credit_scoring', storage='sqlite:///optuna_studies.db', load_if_exists=True ) study.optimize( lambda trial: objective(trial, X, y), n_trials=200, n_jobs=4, timeout=3600, show_progress_bar=True ) print(f'Best AUC: {study.best_value:.4f}') print(f'Best params: {study.best_params}') Visualization and parameter importance analysis:
import optuna.visualization as vis fig = vis.plot_param_importances(study) fig.show() fig = vis.plot_optimization_history(study) fig.show() fig = vis.plot_contour(study, params=['num_leaves', 'learning_rate']) fig.show() fANOVA analysis often yields unexpected results: num_leaves and min_child_samples turn out to be more important than learning_rate for LightGBM on imbalanced data.
When to choose Ray Tune?
Ray Tune solves a different problem — parallel search on a GPU cluster. If Optuna with n_jobs=4 parallelizes on a single machine, Ray Tune scales to hundreds of nodes. Ray Tune is better suited for deep learning with distributed training, while Optuna is for classical ML on a single machine.
from ray import tune from ray.tune.schedulers import ASHAScheduler from ray.tune.search.optuna import OptunaSearch import torch def train_transformer(config: dict): model = build_model( hidden_dim=config['hidden_dim'], num_heads=config['num_heads'], num_layers=config['num_layers'], dropout=config['dropout'] ) optimizer = torch.optim.AdamW( model.parameters(), lr=config['lr'], weight_decay=config['weight_decay'] ) for epoch in range(config['max_epochs']): train_loss = train_one_epoch(model, optimizer) val_loss, val_acc = evaluate(model) tune.report(val_loss=val_loss, val_acc=val_acc, epoch=epoch) scheduler = ASHAScheduler( time_attr='epoch', max_t=100, grace_period=10, reduction_factor=3, metric='val_loss', mode='min' ) search_alg = OptunaSearch( metric='val_loss', mode='min', sampler=optuna.samplers.TPESampler(seed=42) ) search_space = { 'hidden_dim': tune.choice([128, 256, 512]), 'num_heads': tune.choice([4, 8, 16]), 'num_layers': tune.randint(2, 8), 'dropout': tune.uniform(0.0, 0.5), 'lr': tune.loguniform(1e-5, 1e-2), 'weight_decay': tune.loguniform(1e-8, 1e-3), 'max_epochs': 100 } analysis = tune.run( train_transformer, config=search_space, num_samples=100, scheduler=scheduler, search_alg=search_alg, resources_per_trial={'gpu': 1, 'cpu': 4}, storage_path='s3://my-bucket/ray-results', name='transformer_hpo_v2' ) best_config = analysis.get_best_config(metric='val_loss', mode='min') Case: HPO for a fraud detection model
Task: binary classification of transactions, imbalance 1:340 (fraud:normal), 2.1M records. Baseline XGBoost with default parameters: PR-AUC = 0.412.
Optuna, 150 trials, 4 parallel workers, ~2.5 hours:
- search space: 11 XGBoost parameters +
scale_pos_weight(1–350) - metric: PR-AUC on stratified 5-fold CV
- pruner: MedianPruner
Result: PR-AUC = 0.581 (+41% vs baseline). Most important parameters: scale_pos_weight (22%), min_child_weight (18%), subsample (15%). max_depth and n_estimators — total 14%.
| Stage | PR-AUC | Recall at Precision=0.8 |
|---|---|---|
| XGBoost default | 0.412 | 0.34 |
| Random Search (50 trials) | 0.521 | 0.47 |
| Optuna TPE (150 trials) | 0.581 | 0.56 |
| + Feature engineering | 0.634 | 0.62 |
Savings from implementation: a 23% reduction in false positives saved the client significant manual verification costs.
Optuna vs Ray Tune: when to choose what
| Criterion | Optuna | Ray Tune |
|---|---|---|
| Single machine, 1–8 GPUs | + | overkill |
| Cluster 10+ GPUs/nodes | harder | + |
| Deep learning (PyTorch/JAX) | + | + |
| Classical ML (sklearn, lgbm) | + | works |
| Integration with distributed training | via callbacks | native |
| Recovery after failure | SQLite/PostgreSQL backend | + |
| Learning curve for new team | gentle | steeper |
Integration with MLflow and Weights & Biases
import mlflow import optuna def objective_with_tracking(trial): with mlflow.start_run(nested=True): params = { 'lr': trial.suggest_float('lr', 1e-5, 1e-1, log=True), 'dropout': trial.suggest_float('dropout', 0.1, 0.5), } mlflow.log_params(params) val_acc = train_and_evaluate(params) mlflow.log_metric('val_acc', val_acc) return val_acc with mlflow.start_run(run_name='hpo_study'): study.optimize(objective_with_tracking, n_trials=100) mlflow.log_metric('best_val_acc', study.best_value) mlflow.log_params(study.best_params) Typical mistakes and how to avoid them
Data leakage in the objective: if preprocessing (StandardScaler, target encoding) is fitted on the entire train-set before CV — HPO results are optimistically inflated, production degradation guaranteed. The scaler must be fitted only on the train-fold inside CV. Another mistake: optimizing accuracy instead of a business metric in class imbalance — we find a config with 98.3% accuracy but recall on minority class 0.04.
What is included in the turnkey work
- Audit of current pipeline and tool selection (Optuna / Ray Tune / Hyperopt)
- Configuration of search space and metrics based on business goals
- Implementation of HPO with pruning and parallel trials
- Integration with MLflow for experiment tracking
- Documentation for result reproducibility
- Team training on the tool
Process
- Analytics — gather requirements, explore data, baseline models.
- Design — choose HPO framework, define search space, metrics.
- Implementation — write objective function, configure parallelism and pruning.
- Testing — run on CV, check on holdout, compare with baseline.
- Deployment — integrate best config into CI/CD, monitor in production.
Timeline and cost
Timeline: basic Optuna HPO on a single task — 2–5 days. Distributed HPO with Ray Tune and CI/CD integration — 2–4 weeks. Cost is calculated individually based on task complexity, data volume, and infrastructure requirements. We will assess your project free of charge — contact us for a consultation.
Our team has years of experience in ML production and has implemented dozens of HPO projects for clients in fintech, e-commerce, and ad tech.







