Hyperparameter Tuning with Optuna¶
⚠️ Planned Feature: MLflow experiment tracking described in this document is not yet implemented in the current release. Progress is currently saved to JSON logs only.
Complete guide to hyperparameter optimization using Optuna for GA-optimized decision trees.
Overview¶
Hyperparameter tuning finds optimal GA and fitness parameters to maximize performance. The framework provides:
- Bayesian optimization with TPE (Tree-structured Parzen Estimator)
- Early stopping via MedianPruner
- Optimization presets for common scenarios
- Progress tracking with MLflow/JSON logging
- Automated config export in YAML format
Quick Start¶
# Quick optimization (10 trials, ~10 minutes)
python scripts/hyperopt_with_optuna.py --preset fast --dataset iris
# Balanced optimization (30 trials, ~30 minutes)
python scripts/hyperopt_with_optuna.py --preset balanced --dataset breast_cancer
# Thorough optimization (100 trials, ~2 hours)
python scripts/hyperopt_with_optuna.py --preset thorough --dataset wine
Optimization Presets¶
Available Presets¶
| Preset | Trials | Timeout | CV Folds | Use Case |
|---|---|---|---|---|
fast |
10 | 10 min | 3 | Quick testing |
balanced |
30 | 30 min | 5 | Standard optimization |
thorough |
100 | 2 hours | 5 | Comprehensive search |
interpretability_focused |
50 | 1 hour | 5 | Small tree priority |
accuracy_focused |
50 | 1 hour | 5 | Max accuracy priority |
Preset Definitions¶
Fast Preset (--preset fast):
{
"n_trials": 10,
"timeout": 600, # 10 minutes
"cv_folds": 3,
"search_space": {
"population_size": {"type": "categorical", "choices": [50, 80, 100]},
"n_generations": {"type": "categorical", "choices": [20, 30, 40]},
"crossover_prob": {"type": "float", "low": 0.6, "high": 0.8},
"mutation_prob": {"type": "float", "low": 0.1, "high": 0.25},
"accuracy_weight": {"type": "float", "low": 0.6, "high": 0.75},
},
}
Balanced Preset (--preset balanced):
{
"n_trials": 30,
"timeout": 1800, # 30 minutes
"cv_folds": 5,
"search_space": {
"population_size": {"type": "int", "low": 50, "high": 150, "step": 10},
"n_generations": {"type": "int", "low": 20, "high": 60, "step": 10},
"crossover_prob": {"type": "float", "low": 0.5, "high": 0.9},
"mutation_prob": {"type": "float", "low": 0.1, "high": 0.3},
"tournament_size": {"type": "int", "low": 2, "high": 5},
"max_depth": {"type": "int", "low": 3, "high": 7},
"accuracy_weight": {"type": "float", "low": 0.6, "high": 0.9},
"node_complexity_weight": {"type": "float", "low": 0.4, "high": 0.7},
},
}
Basic Usage¶
1. Simple Optimization¶
# Optimize on Breast Cancer dataset
python scripts/hyperopt_with_optuna.py --dataset breast_cancer --n-trials 50
# With specific preset
python scripts/hyperopt_with_optuna.py --preset balanced --dataset wine
# Custom timeout and CV folds
python scripts/hyperopt_with_optuna.py --dataset iris --n-trials 40 --timeout 1200 --cv-folds 10
2. Resume Previous Study¶
# First run
python scripts/hyperopt_with_optuna.py --study-name my_optimization --dataset breast_cancer --n-trials 50
# Resume later (adds 30 more trials)
python scripts/hyperopt_with_optuna.py --study-name my_optimization --resume --n-trials 30
3. Persistent Storage¶
# Save to SQLite database for persistence
python scripts/hyperopt_with_optuna.py \
--dataset breast_cancer \
--storage sqlite:///optuna_studies.db \
--study-name breast_cancer_opt \
--n-trials 100
Optimization Process¶
How It Works¶
- Define Search Space: Specify ranges for hyperparameters
- Bayesian Sampling: TPE suggests promising configurations
- Cross-Validation: Evaluate on CV folds
- Early Stopping: Prune unpromising trials
- Select Best: Return configuration with highest score
- Export Config: Save to YAML for reproducibility
Objective Function¶
The optimization maximizes a composite score:
def objective(trial):
# Suggest hyperparameters
population_size = trial.suggest_int("population_size", 50, 150, step=10)
n_generations = trial.suggest_int("n_generations", 20, 60, step=10)
accuracy_weight = trial.suggest_float("accuracy_weight", 0.6, 0.9)
# ... more parameters ...
# Train with CV
scores = []
for train_idx, val_idx in cv.split(X, y):
X_train, X_val = X[train_idx], X[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
# Train GA tree
tree = train_ga_tree(X_train, y_train, params)
# Evaluate
score = accuracy_score(y_val, tree.predict(X_val))
scores.append(score)
# Report intermediate value for pruning
trial.report(np.mean(scores), fold)
if trial.should_prune():
raise optuna.TrialPruned()
# Composite score: 80% accuracy + 20% size penalty
mean_accuracy = np.mean(scores)
mean_tree_size = np.mean(tree_sizes)
size_penalty = 1.0 - min(mean_tree_size / 50.0, 1.0)
return 0.8 * mean_accuracy + 0.2 * size_penalty
Custom Optimization¶
1. Define Custom Search Space¶
import optuna
# Custom search space
def custom_objective(trial):
# GA parameters
params = {
"population_size": trial.suggest_int("population_size", 30, 200, step=10),
"n_generations": trial.suggest_int("n_generations", 10, 100, step=5),
"crossover_prob": trial.suggest_float("crossover_prob", 0.5, 0.95),
"mutation_prob": trial.suggest_float("mutation_prob", 0.05, 0.35),
"tournament_size": trial.suggest_int("tournament_size", 2, 6),
"elitism_ratio": trial.suggest_float("elitism_ratio", 0.05, 0.2),
# Tree constraints
"max_depth": trial.suggest_int("max_depth", 3, 8),
"min_samples_split": trial.suggest_int("min_samples_split", 5, 20),
"min_samples_leaf": trial.suggest_int("min_samples_leaf", 2, 10),
# Fitness weights
"accuracy_weight": trial.suggest_float("accuracy_weight", 0.5, 0.95),
"node_complexity_weight": trial.suggest_float(
"node_complexity_weight", 0.3, 0.8
),
"feature_coherence_weight": trial.suggest_float(
"feature_coherence_weight", 0.05, 0.35
),
}
# Train and evaluate
score = evaluate_params(params, X, y, cv_folds=5)
return score
# Run optimization
study = optuna.create_study(direction="maximize")
study.optimize(custom_objective, n_trials=50)
print(f"Best trial: {study.best_trial.number}")
print(f"Best value: {study.best_value:.4f}")
print(f"Best params: {study.best_params}")
2. Multi-Objective Optimization¶
Optimize accuracy AND interpretability simultaneously:
def multi_objective(trial):
# ... define params ...
# Train tree
tree = train_ga_tree(X_train, y_train, params)
# Evaluate both objectives
accuracy = accuracy_score(y_test, tree.predict(X_test))
interpretability = 1.0 / (1.0 + tree.get_num_nodes() / 20.0)
return accuracy, interpretability # Return tuple
# Multi-objective study
study = optuna.create_study(directions=["maximize", "maximize"])
study.optimize(multi_objective, n_trials=100)
# Get Pareto front
pareto_trials = [t for t in study.best_trials]
print(f"Found {len(pareto_trials)} Pareto-optimal solutions")
3. Conditional Search Spaces¶
Different parameters based on conditions:
def conditional_objective(trial):
# Choose fitness mode
fitness_mode = trial.suggest_categorical("fitness_mode", ["weighted_sum", "pareto"])
if fitness_mode == "weighted_sum":
# Weighted sum specific parameters
accuracy_weight = trial.suggest_float("accuracy_weight", 0.5, 0.95)
interp_weight = 1.0 - accuracy_weight
else:
# Pareto mode doesn't need weights
accuracy_weight = None
interp_weight = None
# Choose mutation strategy
mutation_focus = trial.suggest_categorical(
"mutation_focus", ["balanced", "pruning", "exploration"]
)
if mutation_focus == "pruning":
mutation_types = {
"threshold_perturbation": 0.30,
"feature_replacement": 0.20,
"prune_subtree": 0.45, # High pruning
"expand_leaf": 0.05,
}
elif mutation_focus == "exploration":
mutation_types = {
"threshold_perturbation": 0.30,
"feature_replacement": 0.40, # High exploration
"prune_subtree": 0.20,
"expand_leaf": 0.10,
}
else: # balanced
mutation_types = {
"threshold_perturbation": 0.45,
"feature_replacement": 0.25,
"prune_subtree": 0.25,
"expand_leaf": 0.05,
}
# Evaluate with conditional params
# ...
Analyzing Results¶
1. View Optimization Progress¶
import optuna
# Load study
study = optuna.load_study(
study_name="my_optimization", storage="sqlite:///optuna_studies.db"
)
# Print summary
print(f"Number of trials: {len(study.trials)}")
print(f"Best trial: {study.best_trial.number}")
print(f"Best value: {study.best_value:.4f}")
print("\nBest hyperparameters:")
for key, value in study.best_params.items():
print(f" {key:25s}: {value}")
# Get statistics
if study.best_trial.user_attrs:
print("\nPerformance metrics:")
print(f" Mean Accuracy: {study.best_trial.user_attrs['mean_accuracy']:.4f}")
print(f" Std Accuracy: {study.best_trial.user_attrs['std_accuracy']:.4f}")
print(f" Mean Tree Size: {study.best_trial.user_attrs['mean_tree_size']:.1f}")
2. Visualize Optimization History¶
from optuna.visualization import (
plot_optimization_history,
plot_param_importances,
plot_parallel_coordinate,
plot_slice,
)
# Optimization history
fig = plot_optimization_history(study)
fig.write_html("results/figures/optuna_history.html")
# Parameter importance
fig = plot_param_importances(study)
fig.write_html("results/figures/optuna_importance.html")
# Parallel coordinate plot
fig = plot_parallel_coordinate(study)
fig.write_html("results/figures/optuna_parallel.html")
# Slice plot (parameter effects)
fig = plot_slice(study)
fig.write_html("results/figures/optuna_slice.html")
3. Parameter Importance¶
import matplotlib.pyplot as plt
# Get parameter importances
importances = optuna.importance.get_param_importances(study)
# Sort by importance
params = list(importances.keys())
values = list(importances.values())
sorted_pairs = sorted(zip(values, params), reverse=True)
values, params = zip(*sorted_pairs)
# Plot
fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(range(len(params)), values, color="skyblue", edgecolor="black")
ax.set_yticks(range(len(params)))
ax.set_yticklabels(params)
ax.set_xlabel("Importance", fontsize=12, fontweight="bold")
ax.set_title("Hyperparameter Importance", fontsize=14, fontweight="bold")
ax.grid(axis="x", alpha=0.3)
plt.tight_layout()
plt.savefig("results/figures/param_importance.png", dpi=300)
Best Practices¶
1. Start with Fast Preset¶
# Quick exploration (5-10 minutes)
python scripts/hyperopt_with_optuna.py --preset fast --dataset iris
# Analyze results
# If promising, scale up to balanced
python scripts/hyperopt_with_optuna.py --preset balanced --dataset iris
2. Use Proper Search Spaces¶
Good search spaces:
- Reasonable ranges based on domain knowledge
- Logarithmic for learning rates:
suggest_float('lr', 1e-5, 1e-2, log=True) - Discrete steps for integers:
suggest_int('pop', 50, 150, step=10)
Bad search spaces:
- Too wide:
suggest_int('population', 10, 1000)→ slow convergence - Too narrow:
suggest_float('mutation', 0.18, 0.22)→ no diversity
3. Monitor Progress¶
# Custom callback for progress tracking
class ProgressCallback:
def __init__(self):
self.best_value = -np.inf
def __call__(self, study, trial):
if trial.value > self.best_value:
self.best_value = trial.value
print(f"Trial {trial.number}: New best value = {trial.value:.4f}")
print(f" Params: {trial.params}")
# Use callback
study.optimize(objective, n_trials=50, callbacks=[ProgressCallback()])
4. Handle Failed Trials¶
def robust_objective(trial):
try:
# ... training code ...
return score
except Exception as e:
print(f"Trial {trial.number} failed: {e}")
return 0.0 # Return low score for failed trials
# Or use catch parameter
study.optimize(objective, n_trials=50, catch=(Exception,))
5. Warm Start from Good Config¶
# Load good starting point
import yaml
with open("configs/paper.yaml", "r") as f:
good_config = yaml.safe_load(f)
# Use as first trial
def objective_with_warmstart(trial):
if trial.number == 0:
# Use known good config as first trial
params = {
"population_size": good_config["ga"]["population_size"],
"n_generations": good_config["ga"]["n_generations"],
# ... other params ...
}
else:
# Sample normally
params = {
"population_size": trial.suggest_int("population_size", 50, 150, step=10),
# ...
}
return evaluate_params(params)
Exporting Results¶
1. Save Best Configuration¶
After optimization, the best config is automatically saved:
Output (configs/optimized.yaml):
ga:
population_size: 140
n_generations: 50
crossover_prob: 0.6733843409158413
mutation_prob: 0.15297790351647228
tournament_size: 2
elitism_ratio: 0.12
tree:
max_depth: 6
min_samples_split: 18
min_samples_leaf: 5
fitness:
mode: weighted_sum
weights:
accuracy: 0.8619364624278322
interpretability: 0.13806353757216783
interpretability_weights:
node_complexity: 0.50
feature_coherence: 0.10
tree_balance: 0.10
semantic_coherence: 0.30
optimization:
study_name: ga_opt_breast_cancer_20250101_120000
n_trials: 100
best_value: 0.9245
best_trial_number: 67
optimization_date: "2025-01-01T12:34:56"
2. Export Trial History¶
import pandas as pd
# Convert trials to DataFrame
trials_df = study.trials_dataframe()
# Save to CSV
trials_df.to_csv("results/optimization/optuna_trials.csv", index=False)
# Key columns:
# - number: Trial number
# - value: Objective value
# - params_*: Hyperparameter values
# - state: COMPLETE, PRUNED, FAIL
3. Visualize Results¶
This will:
- Load optimized config
- Train with 5-fold CV
- Compare against CART baseline
- Show statistical tests
- Save comparison results
Troubleshooting¶
Issue: Optimization Too Slow¶
Solutions:
- Reduce
n_trials(100 → 30) - Reduce
cv_folds(5 → 3) - Use smaller search space
- Enable pruning aggressively
# Aggressive pruning
pruner = optuna.pruners.MedianPruner(
n_startup_trials=2, # Prune after 2 folds
n_warmup_steps=1, # Start pruning quickly
interval_steps=1,
)
Issue: Poor Convergence¶
Solutions:
- Increase
n_trials(30 → 100) - Widen search space
- Check for bugs in objective function
- Use different sampler
# Try RandomSampler for comparison
sampler = optuna.samplers.RandomSampler(seed=42)
study = optuna.create_study(sampler=sampler, direction="maximize")
Issue: All Trials Pruned¶
Solutions:
- Disable pruning temporarily
- Increase
n_startup_trials - Check objective function returns valid values
# Disable pruning for debugging
study = optuna.create_study(
pruner=optuna.pruners.NopPruner(), direction="maximize" # No pruning
)
Issue: Memory Errors¶
Solutions:
- Reduce population size
- Reduce CV folds
- Clear population after each trial
def memory_efficient_objective(trial):
# ... training code ...
# Clear memory
import gc
gc.collect()
return score
Advanced Topics¶
1. Distributed Optimization¶
Run trials in parallel across machines:
# Machine 1
python scripts/hyperopt_with_optuna.py \
--storage mysql://user:pass@server/db \
--study-name distributed_study \
--n-trials 50
# Machine 2 (same study)
python scripts/hyperopt_with_optuna.py \
--storage mysql://user:pass@server/db \
--study-name distributed_study \
--n-trials 50
2. Dynamic Search Space¶
Adjust search space during optimization:
class DynamicCallback:
def __init__(self, study):
self.study = study
def __call__(self, study, trial):
# After 20 trials, narrow search space
if trial.number == 20:
# Analyze best trials
best_trials = sorted(study.trials, key=lambda t: t.value, reverse=True)[:5]
# Compute mean of best params
best_pop = np.mean([t.params["population_size"] for t in best_trials])
print(f"Narrowing search around population_size={best_pop:.0f}")
# Next trials will focus around best_pop
# (requires custom sampling logic)
3. Constraint Handling¶
Enforce constraints on hyperparameters:
def constrained_objective(trial):
accuracy_weight = trial.suggest_float("accuracy_weight", 0.5, 0.95)
interp_weight = 1.0 - accuracy_weight # Constraint: must sum to 1.0
node_complexity = trial.suggest_float("node_complexity_weight", 0.3, 0.8)
feature_coherence = trial.suggest_float("feature_coherence_weight", 0.05, 0.35)
# Constraint: sub-weights must sum to 1.0
remaining = 1.0 - node_complexity - feature_coherence
if remaining < 0.1:
# Infeasible, return low score
return 0.0
tree_balance = remaining * 0.5
semantic_coherence = remaining * 0.5
# ... train and evaluate ...
Next Steps¶
- Test Optimized Config: See Testing Guide
- Compare Configurations: Run Experiments
- Analyze Results: Use Visualization
- Production Deployment: See Model Export