From ee59c9cdd01d07da4dd0dc80ab681bd989cb723f Mon Sep 17 00:00:00 2001 From: Manuel Raimann Date: Thu, 12 Feb 2026 10:33:51 +0100 Subject: [PATCH] refactor(examples): split multi-concept examples into focused single-topic files - Split pruning_and_callbacks into pruning and early_stopping - Split advanced_features into async_parallel, journal_storage, ask_and_tell, multi_objective - Each example now requires only its own feature flag - Trim sampler_comparison winner logic and verbose header - Update CI workflow and README to match new example names --- .github/workflows/ci.yml | 20 ++- Cargo.toml | 27 ++- README.md | 14 +- examples/advanced_features.rs | 277 ------------------------------ examples/ask_and_tell.rs | 51 ++++++ examples/async_parallel.rs | 52 ++++++ examples/early_stopping.rs | 43 +++++ examples/journal_storage.rs | 68 ++++++++ examples/multi_objective.rs | 49 ++++++ examples/pruning.rs | 67 ++++++++ examples/pruning_and_callbacks.rs | 133 -------------- examples/sampler_comparison.rs | 18 +- 12 files changed, 376 insertions(+), 443 deletions(-) delete mode 100644 examples/advanced_features.rs create mode 100644 examples/ask_and_tell.rs create mode 100644 examples/async_parallel.rs create mode 100644 examples/early_stopping.rs create mode 100644 examples/journal_storage.rs create mode 100644 examples/multi_objective.rs create mode 100644 examples/pruning.rs delete mode 100644 examples/pruning_and_callbacks.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2150e89..1f44f41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,14 +66,22 @@ jobs: - uses: Swatinem/rust-cache@v2 - name: Run basic_optimization run: cargo run --example basic_optimization - - name: Run sampler_comparison - run: cargo run --example sampler_comparison - - name: Run pruning_and_callbacks - run: cargo run --example pruning_and_callbacks - name: Run parameter_types run: cargo run --example parameter_types --features derive - - name: Run advanced_features - run: cargo run --example advanced_features --features "async,journal" + - name: Run sampler_comparison + run: cargo run --example sampler_comparison + - name: Run pruning + run: cargo run --example pruning + - name: Run early_stopping + run: cargo run --example early_stopping + - name: Run async_parallel + run: cargo run --example async_parallel --features async + - name: Run journal_storage + run: cargo run --example journal_storage --features journal + - name: Run ask_and_tell + run: cargo run --example ask_and_tell + - name: Run multi_objective + run: cargo run --example multi_objective docs: name: Docs diff --git a/Cargo.toml b/Cargo.toml index d1c11d2..93d67ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,13 +67,30 @@ name = "sampler_comparison" path = "examples/sampler_comparison.rs" [[example]] -name = "pruning_and_callbacks" -path = "examples/pruning_and_callbacks.rs" +name = "pruning" +path = "examples/pruning.rs" [[example]] -name = "advanced_features" -path = "examples/advanced_features.rs" -required-features = ["async", "journal"] +name = "early_stopping" +path = "examples/early_stopping.rs" + +[[example]] +name = "async_parallel" +path = "examples/async_parallel.rs" +required-features = ["async"] + +[[example]] +name = "journal_storage" +path = "examples/journal_storage.rs" +required-features = ["journal"] + +[[example]] +name = "ask_and_tell" +path = "examples/ask_and_tell.rs" + +[[example]] +name = "multi_objective" +path = "examples/multi_objective.rs" [[test]] name = "journal_tests" diff --git a/README.md b/README.md index 970013c..9e31556 100644 --- a/README.md +++ b/README.md @@ -52,11 +52,15 @@ println!("Best x = {:.4}, f(x) = {:.4}", best.get(&x).unwrap(), best.value); ## Examples ```sh -cargo run --example basic_optimization # Minimize a quadratic — simplest possible usage -cargo run --example sampler_comparison # Compare Random, TPE, and Grid on the same problem -cargo run --example pruning_and_callbacks # Trial pruning with MedianPruner + early stopping -cargo run --example parameter_types --features derive # All 5 param types + #[derive(Categorical)] -cargo run --example advanced_features --features async,journal # Async, journal storage, ask-and-tell, multi-objective +cargo run --example basic_optimization # Minimize a quadratic — simplest possible usage +cargo run --example parameter_types --features derive # All 5 param types + #[derive(Categorical)] +cargo run --example sampler_comparison # Compare Random, TPE, and Grid on the same problem +cargo run --example pruning # Trial pruning with MedianPruner +cargo run --example early_stopping # Halt a study when a target is reached +cargo run --example async_parallel --features async # Evaluate trials concurrently with tokio +cargo run --example journal_storage --features journal # Persist trials to disk and resume later +cargo run --example ask_and_tell # Decouple sampling from evaluation +cargo run --example multi_objective # Optimize competing objectives + Pareto front ``` ## Learn More diff --git a/examples/advanced_features.rs b/examples/advanced_features.rs deleted file mode 100644 index 41eed01..0000000 --- a/examples/advanced_features.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Advanced Features Example -//! -//! This example demonstrates four advanced capabilities of the optimizer crate: -//! -//! 1. **Async parallel optimization** — evaluate multiple trials concurrently -//! 2. **Journal storage** — persist trials to disk and resume studies later -//! 3. **Ask-and-tell interface** — decouple sampling from evaluation -//! 4. **Multi-objective optimization** — optimize competing objectives simultaneously -//! -//! Run with: `cargo run --example advanced_features --features "async,journal"` - -use std::time::Instant; - -use optimizer::multi_objective::MultiObjectiveStudy; -use optimizer::prelude::*; - -// ============================================================================ -// Section 1: Async Parallel Optimization -// ============================================================================ - -/// Runs multiple trials concurrently using tokio, reducing wall-clock time -/// when the objective function involves I/O or other async work. -async fn async_parallel_optimization() -> optimizer::Result<()> { - println!("=== Section 1: Async Parallel Optimization ===\n"); - - let sampler = TpeSampler::builder() - .n_startup_trials(5) - .seed(42) - .build() - .expect("Failed to build TPE sampler"); - - let study: Study = Study::with_sampler(Direction::Minimize, sampler); - - let x = FloatParam::new(-5.0, 5.0).name("x"); - let y = FloatParam::new(-5.0, 5.0).name("y"); - - let n_trials = 30; - let concurrency = 4; - - println!("Running {n_trials} trials with {concurrency} concurrent workers..."); - let start = Instant::now(); - - // optimize_parallel spawns up to `concurrency` trials at once. - // The closure must take ownership of Trial and return (Trial, value). - study - .optimize_parallel(n_trials, concurrency, { - let x = x.clone(); - let y = y.clone(); - move |mut trial| { - let x = x.clone(); - let y = y.clone(); - async move { - let xv = x.suggest(&mut trial)?; - let yv = y.suggest(&mut trial)?; - - // Simulate async I/O (e.g., calling an external service) - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - - // Sphere function: minimum at origin - let value = xv * xv + yv * yv; - Ok::<_, optimizer::Error>((trial, value)) - } - } - }) - .await?; - - let elapsed = start.elapsed(); - let best = study.best_trial()?; - - println!( - "Completed in {elapsed:.2?} (vs ~{:.0?} sequential)", - std::time::Duration::from_millis(10 * n_trials as u64) - ); - println!( - "Best: f({:.3}, {:.3}) = {:.6}\n", - best.get(&x).unwrap(), - best.get(&y).unwrap(), - best.value - ); - - Ok(()) -} - -// ============================================================================ -// Section 2: Journal Storage -// ============================================================================ - -/// Persists trials to a JSONL file so that a study can be resumed later. -/// Useful for long-running experiments or crash recovery. -fn journal_storage_demo() -> optimizer::Result<()> { - println!("=== Section 2: Journal Storage ===\n"); - - let path = std::env::temp_dir().join("optimizer_advanced_example.jsonl"); - - // Clean up from any previous run - let _ = std::fs::remove_file(&path); - - let x = FloatParam::new(-5.0, 5.0).name("x"); - - // --- First run: optimize 20 trials and persist to disk --- - { - let storage = JournalStorage::::new(&path); - let study: Study = Study::builder() - .minimize() - .sampler(TpeSampler::new()) - .storage(storage) - .build(); - - study.optimize(20, |trial| { - let xv = x.suggest(trial)?; - Ok::<_, optimizer::Error>(xv * xv) - })?; - - println!( - "First run: {} trials saved to {}", - study.n_trials(), - path.display() - ); - } - - // --- Second run: resume from the journal file --- - { - // JournalStorage::open loads existing trials from disk - let storage = JournalStorage::::open(&path)?; - let study: Study = Study::builder() - .minimize() - .sampler(TpeSampler::new()) - .storage(storage) - .build(); - - // The sampler sees the prior 20 trials, so it starts informed - let before = study.n_trials(); - study.optimize(10, |trial| { - let xv = x.suggest(trial)?; - Ok::<_, optimizer::Error>(xv * xv) - })?; - - let best = study.best_trial()?; - println!( - "Resumed: {} → {} trials, best f({:.4}) = {:.6}", - before, - study.n_trials(), - best.get(&x).unwrap(), - best.value - ); - } - - // Clean up the temporary file - let _ = std::fs::remove_file(&path); - - println!(); - Ok(()) -} - -// ============================================================================ -// Section 3: Ask-and-Tell Interface -// ============================================================================ - -/// Decouples trial creation from evaluation. Useful when: -/// - Evaluations happen outside the optimizer (e.g., in a separate process) -/// - You want to batch evaluations before reporting results -/// - You need custom scheduling logic -fn ask_and_tell_demo() -> optimizer::Result<()> { - println!("=== Section 3: Ask-and-Tell Interface ===\n"); - - let study: Study = Study::new(Direction::Minimize); - - let x = FloatParam::new(-5.0, 5.0).name("x"); - let y = FloatParam::new(-5.0, 5.0).name("y"); - - // Ask for a batch of trials, evaluate externally, then tell results - for batch in 0..3 { - let batch_size = 5; - let mut trials = Vec::with_capacity(batch_size); - - // ask() creates trials with sampled parameters - for _ in 0..batch_size { - let mut trial = study.ask(); - let xv = x.suggest(&mut trial)?; - let yv = y.suggest(&mut trial)?; - - // Store values alongside the trial for later evaluation - trials.push((trial, xv, yv)); - } - - // Evaluate the batch (could be sent to workers, GPUs, etc.) - for (trial, xv, yv) in trials { - let value = xv * xv + yv * yv; - // tell() reports the result back to the study - study.tell(trial, Ok::<_, &str>(value)); - } - - println!( - "Batch {}: evaluated {} trials (total: {})", - batch + 1, - batch_size, - study.n_trials() - ); - } - - let best = study.best_trial()?; - println!( - "Best: f({:.3}, {:.3}) = {:.6}\n", - best.get(&x).unwrap(), - best.get(&y).unwrap(), - best.value - ); - - Ok(()) -} - -// ============================================================================ -// Section 4: Multi-Objective Optimization -// ============================================================================ - -/// Optimizes two competing objectives simultaneously. -/// Returns the Pareto front — the set of solutions where no objective can -/// be improved without worsening the other. -fn multi_objective_demo() -> optimizer::Result<()> { - println!("=== Section 4: Multi-Objective Optimization ===\n"); - - // Two objectives, both minimized - let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]); - - let x = FloatParam::new(0.0, 1.0).name("x"); - - // Classic bi-objective problem: f1(x) = x², f2(x) = (x - 1)² - // The Pareto front is the curve where improving f1 worsens f2 and vice versa. - study.optimize(50, |trial| { - let xv = x.suggest(trial)?; - let f1 = xv * xv; - let f2 = (xv - 1.0) * (xv - 1.0); - Ok::<_, optimizer::Error>(vec![f1, f2]) - })?; - - let front = study.pareto_front(); - println!( - "Ran {} trials, Pareto front has {} solutions:", - study.n_trials(), - front.len() - ); - - // Show a few Pareto-optimal trade-offs - let mut sorted_front = front.clone(); - sorted_front.sort_by(|a, b| a.values[0].partial_cmp(&b.values[0]).unwrap()); - - for (i, trial) in sorted_front.iter().take(5).enumerate() { - println!( - " {}: x={:.3}, f1={:.4}, f2={:.4}", - i + 1, - trial.get(&x).unwrap(), - trial.values[0], - trial.values[1] - ); - } - if sorted_front.len() > 5 { - println!(" ... and {} more", sorted_front.len() - 5); - } - - println!(); - Ok(()) -} - -// ============================================================================ -// Main -// ============================================================================ - -#[tokio::main] -async fn main() -> optimizer::Result<()> { - async_parallel_optimization().await?; - journal_storage_demo()?; - ask_and_tell_demo()?; - multi_objective_demo()?; - - println!("All sections completed successfully!"); - Ok(()) -} diff --git a/examples/ask_and_tell.rs b/examples/ask_and_tell.rs new file mode 100644 index 0000000..0125c0d --- /dev/null +++ b/examples/ask_and_tell.rs @@ -0,0 +1,51 @@ +//! Ask-and-tell interface — decouple sampling from evaluation. +//! +//! Use `ask()` to get a trial with sampled parameters, evaluate it however +//! you like (workers, GPUs, external processes), then `tell()` the result. +//! This is useful for batch evaluation or custom scheduling. +//! +//! Run with: `cargo run --example ask_and_tell` + +use optimizer::prelude::*; + +fn main() -> optimizer::Result<()> { + let study: Study = Study::new(Direction::Minimize); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + for batch in 0..3 { + let batch_size = 5; + let mut trials = Vec::with_capacity(batch_size); + + // ask() creates trials with sampled parameters + for _ in 0..batch_size { + let mut trial = study.ask(); + let xv = x.suggest(&mut trial)?; + let yv = y.suggest(&mut trial)?; + trials.push((trial, xv, yv)); + } + + // Evaluate the batch (could be sent to workers, GPUs, etc.) + for (trial, xv, yv) in trials { + let value = xv * xv + yv * yv; + study.tell(trial, Ok::<_, &str>(value)); + } + + println!( + "Batch {}: evaluated {batch_size} trials (total: {})", + batch + 1, + study.n_trials(), + ); + } + + let best = study.best_trial()?; + println!( + "Best: f({:.3}, {:.3}) = {:.6}", + best.get(&x).unwrap(), + best.get(&y).unwrap(), + best.value, + ); + + Ok(()) +} diff --git a/examples/async_parallel.rs b/examples/async_parallel.rs new file mode 100644 index 0000000..f3c3949 --- /dev/null +++ b/examples/async_parallel.rs @@ -0,0 +1,52 @@ +//! Async parallel optimization — evaluate multiple trials concurrently. +//! +//! Uses `optimize_parallel` with tokio to run several trials at once, +//! reducing wall-clock time when the objective involves I/O or async work. +//! +//! Run with: `cargo run --example async_parallel --features async` + +use optimizer::prelude::*; + +#[tokio::main] +async fn main() -> optimizer::Result<()> { + let study: Study = Study::minimize(TpeSampler::new()); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + let y = FloatParam::new(-5.0, 5.0).name("y"); + + let n_trials = 30; + let concurrency = 4; + + println!("Running {n_trials} trials with {concurrency} concurrent workers..."); + + study + .optimize_parallel(n_trials, concurrency, { + let x = x.clone(); + let y = y.clone(); + move |mut trial| { + let x = x.clone(); + let y = y.clone(); + async move { + let xv = x.suggest(&mut trial)?; + let yv = y.suggest(&mut trial)?; + + // Simulate async I/O (e.g. calling an external service) + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let value = xv * xv + yv * yv; + Ok::<_, optimizer::Error>((trial, value)) + } + } + }) + .await?; + + let best = study.best_trial()?; + println!( + "Best: f({:.3}, {:.3}) = {:.6}", + best.get(&x).unwrap(), + best.get(&y).unwrap(), + best.value, + ); + + Ok(()) +} diff --git a/examples/early_stopping.rs b/examples/early_stopping.rs new file mode 100644 index 0000000..7f57c88 --- /dev/null +++ b/examples/early_stopping.rs @@ -0,0 +1,43 @@ +//! Early stopping — halt an entire study once a target is reached. +//! +//! Use `optimize_with_callback` to inspect each completed trial and return +//! `ControlFlow::Break(())` when the study should stop (e.g. a quality +//! threshold is met or a time budget is exhausted). +//! +//! Run with: `cargo run --example early_stopping` + +use std::ops::ControlFlow; + +use optimizer::prelude::*; + +fn main() -> optimizer::Result<()> { + let study: Study = Study::new(Direction::Minimize); + let x = FloatParam::new(-10.0, 10.0).name("x"); + + let target = 0.01; + + study.optimize_with_callback( + 100, // upper bound — we expect to stop much earlier + |trial| { + let xv = x.suggest(trial)?; + Ok::<_, Error>((xv - 3.0).powi(2)) + }, + |_study, completed| { + if completed.value < target { + println!("Target {target} reached at trial #{}", completed.id); + return ControlFlow::Break(()); + } + ControlFlow::Continue(()) + }, + )?; + + let best = study.best_trial()?; + println!( + "Stopped after {} trials — best f({:.4}) = {:.6}", + study.n_trials(), + best.get(&x).unwrap(), + best.value, + ); + + Ok(()) +} diff --git a/examples/journal_storage.rs b/examples/journal_storage.rs new file mode 100644 index 0000000..c85eba4 --- /dev/null +++ b/examples/journal_storage.rs @@ -0,0 +1,68 @@ +//! Journal storage — persist trials to disk and resume later. +//! +//! `JournalStorage` writes every trial to a JSONL file so that a study can +//! be resumed after a crash or across separate runs. +//! +//! Run with: `cargo run --example journal_storage --features journal` + +use optimizer::prelude::*; + +fn main() -> optimizer::Result<()> { + let path = std::env::temp_dir().join("optimizer_journal_example.jsonl"); + + // Clean up from any previous run + let _ = std::fs::remove_file(&path); + + let x = FloatParam::new(-5.0, 5.0).name("x"); + + // --- First run: optimize 20 trials and persist to disk --- + { + let storage = JournalStorage::::new(&path); + let study: Study = Study::builder() + .minimize() + .sampler(TpeSampler::new()) + .storage(storage) + .build(); + + study.optimize(20, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, optimizer::Error>(xv * xv) + })?; + + println!( + "First run: {} trials saved to {}", + study.n_trials(), + path.display(), + ); + } + + // --- Second run: resume from the journal file --- + { + let storage = JournalStorage::::open(&path)?; + let study: Study = Study::builder() + .minimize() + .sampler(TpeSampler::new()) + .storage(storage) + .build(); + + let before = study.n_trials(); + study.optimize(10, |trial| { + let xv = x.suggest(trial)?; + Ok::<_, optimizer::Error>(xv * xv) + })?; + + let best = study.best_trial()?; + println!( + "Resumed: {} → {} trials, best f({:.4}) = {:.6}", + before, + study.n_trials(), + best.get(&x).unwrap(), + best.value, + ); + } + + // Clean up + let _ = std::fs::remove_file(&path); + + Ok(()) +} diff --git a/examples/multi_objective.rs b/examples/multi_objective.rs new file mode 100644 index 0000000..3d66a8a --- /dev/null +++ b/examples/multi_objective.rs @@ -0,0 +1,49 @@ +//! Multi-objective optimization — optimize competing objectives simultaneously. +//! +//! `MultiObjectiveStudy` returns the Pareto front: the set of solutions where +//! no objective can be improved without worsening another. +//! +//! Run with: `cargo run --example multi_objective` + +use optimizer::multi_objective::MultiObjectiveStudy; +use optimizer::prelude::*; + +fn main() -> optimizer::Result<()> { + let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]); + + let x = FloatParam::new(0.0, 1.0).name("x"); + + // Classic bi-objective: f1(x) = x², f2(x) = (x-1)² + // The Pareto front is the curve where improving f1 worsens f2. + study.optimize(50, |trial| { + let xv = x.suggest(trial)?; + let f1 = xv * xv; + let f2 = (xv - 1.0) * (xv - 1.0); + Ok::<_, optimizer::Error>(vec![f1, f2]) + })?; + + let front = study.pareto_front(); + println!( + "Ran {} trials, Pareto front has {} solutions:", + study.n_trials(), + front.len(), + ); + + let mut sorted = front.clone(); + sorted.sort_by(|a, b| a.values[0].partial_cmp(&b.values[0]).unwrap()); + + for (i, trial) in sorted.iter().take(5).enumerate() { + println!( + " {}: x={:.3}, f1={:.4}, f2={:.4}", + i + 1, + trial.get(&x).unwrap(), + trial.values[0], + trial.values[1], + ); + } + if sorted.len() > 5 { + println!(" ... and {} more", sorted.len() - 5); + } + + Ok(()) +} diff --git a/examples/pruning.rs b/examples/pruning.rs new file mode 100644 index 0000000..afbfea2 --- /dev/null +++ b/examples/pruning.rs @@ -0,0 +1,67 @@ +//! Trial pruning — stop unpromising trials early with `MedianPruner`. +//! +//! When your objective involves an iterative loop (e.g. training epochs), +//! the pruner compares intermediate values across trials and kills the +//! ones that fall below the median — saving compute on bad configurations. +//! +//! Run with: `cargo run --example pruning` + +use optimizer::prelude::*; + +fn main() -> optimizer::Result<()> { + // MedianPruner prunes trials whose intermediate value falls below the + // median of previously completed trials at the same step. + let study: Study = Study::builder() + .minimize() + .sampler(RandomSampler::with_seed(42)) + .pruner( + MedianPruner::new(Direction::Minimize) + .n_warmup_steps(3) // run at least 3 epochs before pruning + .n_min_trials(3), // need 3 completed trials before pruning kicks in + ) + .build(); + + let lr = FloatParam::new(1e-4, 1.0).name("learning_rate"); + let momentum = FloatParam::new(0.0, 0.99).name("momentum"); + + let n_epochs: u64 = 20; + + study.optimize(30, |trial| { + let lr_val = lr.suggest(trial)?; + let mom = momentum.suggest(trial)?; + + // Simulated training loop — good hyperparameters converge to low loss, + // bad ones plateau high, giving the pruner something to cut. + let mut loss = 1.0; + for epoch in 0..n_epochs { + let lr_penalty = (lr_val.log10() - 0.01_f64.log10()).powi(2); + let mom_penalty = (mom - 0.8).powi(2); + let base_loss = 0.02 + 0.05 * lr_penalty + 1.5 * mom_penalty; + let progress = (epoch as f64 + 1.0) / n_epochs as f64; + loss = base_loss + (1.0 - base_loss) * (-3.5 * progress).exp(); + + // Report intermediate value so the pruner can evaluate this trial. + trial.report(epoch, loss); + + // Check whether the pruner recommends stopping early. + if trial.should_prune() { + Err(TrialPruned)?; + } + } + + Ok::<_, Error>(loss) + })?; + + // --- Results --- + let best = study.best_trial()?; + println!( + "Completed {} trials ({} pruned)", + study.n_trials(), + study.n_pruned_trials() + ); + println!("Best trial #{}: loss = {:.6}", best.id, best.value); + println!(" learning_rate = {:.6}", best.get(&lr).unwrap()); + println!(" momentum = {:.4}", best.get(&momentum).unwrap()); + + Ok(()) +} diff --git a/examples/pruning_and_callbacks.rs b/examples/pruning_and_callbacks.rs deleted file mode 100644 index 207e55b..0000000 --- a/examples/pruning_and_callbacks.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Pruning and early-stopping example — demonstrates trial pruning with `MedianPruner` -//! and early stopping via `optimize_with_callback`. -//! -//! Simulates a training loop where each trial trains for multiple "epochs". The pruner -//! stops unpromising trials early, and a callback halts the entire study once a target -//! loss is reached. -//! -//! Run with: `cargo run --example pruning_and_callbacks` - -use std::ops::ControlFlow; - -use optimizer::TrialState; -use optimizer::prelude::*; - -fn main() -> optimizer::Result<()> { - let n_trials: usize = 30; - let n_epochs: u64 = 20; - let target_loss = 0.15; - - // Build a study with a seeded random sampler and MedianPruner. - // MedianPruner compares each trial's intermediate value against the median of - // completed trials at the same step — trials performing below median are pruned. - let study: Study = Study::builder() - .minimize() - .sampler(RandomSampler::with_seed(42)) - .pruner( - MedianPruner::new(Direction::Minimize) - .n_warmup_steps(3) // let every trial run at least 3 epochs before pruning - .n_min_trials(3), // need 3 completed trials before pruning kicks in - ) - .build(); - - let learning_rate = FloatParam::new(1e-4, 1.0).name("learning_rate"); - let momentum = FloatParam::new(0.0, 0.99).name("momentum"); - - // Use optimize_with_callback to get both pruning AND early stopping. - // The callback fires after each completed (or pruned) trial and can halt the study. - study.optimize_with_callback( - n_trials, - // --- Objective function: simulated training loop with pruning --- - |trial| { - let lr = learning_rate.suggest(trial)?; - let mom = momentum.suggest(trial)?; - - // Simulate training for n_epochs, reporting intermediate loss each epoch. - // Good hyperparameters (lr ≈ 0.01, momentum ≈ 0.8) converge to low loss; - // bad combos plateau high — giving the pruner something to cut. - let mut loss = 1.0; - for epoch in 0..n_epochs { - let lr_penalty = (lr.log10() - 0.01_f64.log10()).powi(2); // 0 at lr=0.01 - let mom_penalty = (mom - 0.8).powi(2); // 0 at momentum=0.8 - let base_loss = 0.02 + 0.05 * lr_penalty + 1.5 * mom_penalty; - let progress = (epoch as f64 + 1.0) / n_epochs as f64; - // Loss decays from 1.0 toward base_loss over epochs. - loss = base_loss + (1.0 - base_loss) * (-3.5 * progress).exp(); - - // Report the intermediate value so the pruner can evaluate this trial. - trial.report(epoch, loss); - - // Check whether the pruner recommends stopping this trial early. - if trial.should_prune() { - // Signal that this trial was pruned — the study records it as Pruned. - Err(TrialPruned)?; - } - } - - Ok::<_, Error>(loss) - }, - // --- Callback: early stopping when we hit the target --- - |study, completed_trial| { - let n_complete = study.n_trials(); - let n_pruned = study - .trials() - .iter() - .filter(|t| t.state == TrialState::Pruned) - .count(); - - match completed_trial.state { - TrialState::Pruned => { - println!( - " Trial {:>3} PRUNED at epoch {} (loss = {:.4}) \ - [{n_complete} done, {n_pruned} pruned]", - completed_trial.id, - completed_trial.intermediate_values.len(), - completed_trial - .intermediate_values - .last() - .map_or(f64::NAN, |v| v.1), - ); - } - TrialState::Complete => { - println!( - " Trial {:>3} complete: loss = {:.4} \ - [{n_complete} done, {n_pruned} pruned]", - completed_trial.id, completed_trial.value, - ); - } - _ => {} - } - - // Stop the entire study once we find a good enough result. - if completed_trial.state == TrialState::Complete && completed_trial.value < target_loss - { - println!("\n Early stopping: reached target loss {target_loss}!"); - return ControlFlow::Break(()); - } - - ControlFlow::Continue(()) - }, - )?; - - // --- Results --- - let best = study.best_trial().expect("at least one completed trial"); - let total = study.n_trials(); - let pruned = study - .trials() - .iter() - .filter(|t| t.state == TrialState::Pruned) - .count(); - - println!("\n--- Results ---"); - println!(" Total trials : {total}"); - println!(" Pruned : {pruned}"); - println!(" Completed : {}", total - pruned); - println!(" Best trial #{}: loss = {:.6}", best.id, best.value); - println!( - " learning_rate = {:.6}", - best.get(&learning_rate).unwrap() - ); - println!(" momentum = {:.4}", best.get(&momentum).unwrap()); - - Ok(()) -} diff --git a/examples/sampler_comparison.rs b/examples/sampler_comparison.rs index b743e2a..3034ab3 100644 --- a/examples/sampler_comparison.rs +++ b/examples/sampler_comparison.rs @@ -40,10 +40,7 @@ fn run_study(study: Study, n_trials: usize) -> f64 { fn main() { let n_trials: usize = 100; - println!("Comparing samplers on Sphere(x, y) = x² + y²"); - println!(" Search space: x ∈ [-5, 5], y ∈ [-3, 3]"); - println!(" Known minimum: f(0, 0) = 0"); - println!(" Trials per sampler: {n_trials}"); + println!("Comparing samplers on Sphere(x, y) = x² + y² ({n_trials} trials each)"); println!(); // --- Random sampler (baseline) --- @@ -78,17 +75,4 @@ fn main() { println!(" Random : {random_best:.6}"); println!(" TPE : {tpe_best:.6}"); println!(" Grid : {grid_best:.6}"); - println!(); - - // Find the winner - let results = [ - ("Random", random_best), - ("TPE", tpe_best), - ("Grid", grid_best), - ]; - let (winner, _) = results - .iter() - .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap()) - .unwrap(); - println!("Winner: {winner} (closest to known minimum of 0.0)"); }