Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28b1687be6 | |||
| fc0559a02d | |||
| 6fb39d17c1 |
@@ -6,9 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
@@ -198,27 +195,6 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo test --all-features
|
||||
|
||||
cross-targets:
|
||||
name: ${{ matrix.target }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target:
|
||||
- aarch64-unknown-linux-gnu
|
||||
- i686-unknown-linux-gnu
|
||||
- powerpc64le-unknown-linux-gnu
|
||||
- s390x-unknown-linux-gnu
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Install Rust
|
||||
run: |
|
||||
rustup override set stable
|
||||
rustup update stable
|
||||
rustup target add ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- name: Check compilation
|
||||
run: cargo check --all-features --target ${{ matrix.target }}
|
||||
|
||||
typos:
|
||||
name: Typos
|
||||
@@ -252,7 +228,6 @@ jobs:
|
||||
- semver
|
||||
- minimal-versions
|
||||
- cross-platform
|
||||
- cross-targets
|
||||
- typos
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -278,14 +253,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Publish
|
||||
if: steps.check.outputs.skip == 'false'
|
||||
run: |
|
||||
cargo publish --allow-dirty 2>&1 | tee publish_output.txt || {
|
||||
if grep -q "already uploaded" publish_output.txt; then
|
||||
echo "Version already published, skipping"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
if: steps.check.outputs.skip == 'false' && github.ref == 'refs/heads/master'
|
||||
run: cargo publish
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
- cron: "0 6 * * *" # Daily at 6:00 UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
|
||||
+2
-5
@@ -1,16 +1,13 @@
|
||||
[package]
|
||||
name = "optimizer"
|
||||
version = "0.3.1"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
license = "MIT"
|
||||
authors = ["Manuel Raimann <raimannma@outlook.de"]
|
||||
description = "A Rust library for optimization algorithms."
|
||||
repository = "https://github.com/raimannma/rust-optimizer"
|
||||
documentation = "https://docs.rs/optimizer"
|
||||
keywords = ["optimization", "hyperparameter", "tpe", "grid-search", "bayesian"]
|
||||
categories = ["algorithm", "science", "data-structures"]
|
||||
readme = "README.md"
|
||||
|
||||
|
||||
[dependencies]
|
||||
rand = "0.9"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# optimizer
|
||||
|
||||
A Rust library for black-box optimization with multiple sampling strategies.
|
||||
A Rust library for black-box optimization using Tree-Parzen Estimator (TPE).
|
||||
|
||||
[](https://docs.rs/optimizer)
|
||||
[](https://crates.io/crates/optimizer)
|
||||
@@ -9,10 +9,6 @@ A Rust library for black-box optimization with multiple sampling strategies.
|
||||
## Features
|
||||
|
||||
- Optuna-like API for hyperparameter optimization
|
||||
- Multiple sampling strategies:
|
||||
- **Random Search** - Simple random sampling for baseline comparisons
|
||||
- **TPE (Tree-Parzen Estimator)** - Bayesian optimization for efficient search
|
||||
- **Grid Search** - Exhaustive search over a specified parameter grid
|
||||
- Float, integer, and categorical parameter types
|
||||
- Log-scale and stepped parameter sampling
|
||||
- Sync and async optimization with parallel trial evaluation
|
||||
@@ -20,16 +16,15 @@ A Rust library for black-box optimization with multiple sampling strategies.
|
||||
## Quick Start
|
||||
|
||||
```rust
|
||||
use optimizer::{Direction, Study};
|
||||
use optimizer::sampler::tpe::TpeSampler;
|
||||
use optimizer::{Direction, Study, TpeSampler};
|
||||
|
||||
let sampler = TpeSampler::builder().seed(42).build().unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).build();
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(20, |trial| {
|
||||
let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
Ok::<_, optimizer::Error>(x * x)
|
||||
Ok::<_, optimizer::TpeError>(x * x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -37,50 +32,6 @@ let best = study.best_trial().unwrap();
|
||||
println!("Best value: {} at x={:?}", best.value, best.params);
|
||||
```
|
||||
|
||||
## Samplers
|
||||
|
||||
### Random Search
|
||||
|
||||
```rust
|
||||
use optimizer::{Direction, Study};
|
||||
use optimizer::sampler::random::RandomSampler;
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(
|
||||
Direction::Minimize,
|
||||
RandomSampler::with_seed(42),
|
||||
);
|
||||
```
|
||||
|
||||
### TPE (Tree-Parzen Estimator)
|
||||
|
||||
```rust
|
||||
use optimizer::{Direction, Study};
|
||||
use optimizer::sampler::tpe::TpeSampler;
|
||||
|
||||
let sampler = TpeSampler::builder()
|
||||
.gamma(0.15) // Quantile for good/bad split
|
||||
.n_startup_trials(20) // Random trials before TPE kicks in
|
||||
.n_ei_candidates(32) // Candidates to evaluate
|
||||
.seed(42)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
```
|
||||
|
||||
### Grid Search
|
||||
|
||||
```rust
|
||||
use optimizer::{Direction, Study};
|
||||
use optimizer::sampler::grid::GridSearchSampler;
|
||||
|
||||
let sampler = GridSearchSampler::builder()
|
||||
.n_points_per_param(10) // Number of points per parameter dimension
|
||||
.build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
```
|
||||
|
||||
## Feature Flags
|
||||
|
||||
- `async` - Enable async optimization methods (requires tokio)
|
||||
|
||||
+9
-24
@@ -1,5 +1,10 @@
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
//! Error types for the optimizer library.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// The error type for TPE operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TpeError {
|
||||
/// Returned when the lower bound is greater than the upper bound.
|
||||
#[error("invalid bounds: low ({low}) must be less than or equal to high ({high})")]
|
||||
InvalidBounds {
|
||||
@@ -33,27 +38,7 @@ pub enum Error {
|
||||
/// Returned when requesting the best trial but no trials have completed.
|
||||
#[error("no completed trials available")]
|
||||
NoCompletedTrials,
|
||||
|
||||
/// Returned when gamma is not in the valid range (0.0, 1.0).
|
||||
#[error("invalid gamma: {0} must be in (0.0, 1.0)")]
|
||||
InvalidGamma(f64),
|
||||
|
||||
/// Returned when bandwidth is not positive.
|
||||
#[error("invalid bandwidth: {0} must be positive")]
|
||||
InvalidBandwidth(f64),
|
||||
|
||||
/// Returned when KDE is created with empty samples.
|
||||
#[error("KDE requires at least one sample")]
|
||||
EmptySamples,
|
||||
|
||||
/// Returned when an internal invariant is violated.
|
||||
#[error("internal error: {0}")]
|
||||
Internal(&'static str),
|
||||
|
||||
/// Returned when an async task fails.
|
||||
#[cfg(feature = "async")]
|
||||
#[error("async task error: {0}")]
|
||||
TaskError(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = core::result::Result<T, Error>;
|
||||
/// A specialized Result type for TPE operations.
|
||||
pub type Result<T> = std::result::Result<T, TpeError>;
|
||||
|
||||
+34
-46
@@ -5,8 +5,6 @@
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// A Gaussian kernel density estimator for continuous distributions.
|
||||
///
|
||||
/// KDE estimates a probability density function from a set of samples by
|
||||
@@ -30,7 +28,7 @@ use crate::error::{Error, Result};
|
||||
/// let sample = kde.sample(&mut rng);
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct KernelDensityEstimator {
|
||||
pub struct KernelDensityEstimator {
|
||||
/// The sample points used to construct the KDE.
|
||||
samples: Vec<f64>,
|
||||
/// The bandwidth (standard deviation) of the Gaussian kernels.
|
||||
@@ -40,45 +38,37 @@ pub(crate) struct KernelDensityEstimator {
|
||||
impl KernelDensityEstimator {
|
||||
/// Creates a new KDE with automatic bandwidth selection using Scott's rule.
|
||||
///
|
||||
/// Scott's rule sets bandwidth = n^(-1/5) * `std_dev`, which works well
|
||||
/// Scott's rule sets bandwidth = n^(-1/5) * std_dev, which works well
|
||||
/// for unimodal distributions close to normal.
|
||||
///
|
||||
/// # Errors
|
||||
/// # Panics
|
||||
///
|
||||
/// Returns `Error::EmptySamples` if `samples` is empty.
|
||||
pub(crate) fn new(samples: Vec<f64>) -> Result<Self> {
|
||||
if samples.is_empty() {
|
||||
return Err(Error::EmptySamples);
|
||||
}
|
||||
/// Panics if `samples` is empty.
|
||||
pub fn new(samples: Vec<f64>) -> Self {
|
||||
assert!(!samples.is_empty(), "KDE requires at least one sample");
|
||||
|
||||
let bandwidth = Self::scotts_rule(&samples);
|
||||
Ok(Self { samples, bandwidth })
|
||||
Self { samples, bandwidth }
|
||||
}
|
||||
|
||||
/// Creates a new KDE with a specified bandwidth.
|
||||
///
|
||||
/// Use this when you want explicit control over the smoothing parameter.
|
||||
///
|
||||
/// # Errors
|
||||
/// # Panics
|
||||
///
|
||||
/// Returns `Error::EmptySamples` if `samples` is empty.
|
||||
/// Returns `Error::InvalidBandwidth` if `bandwidth` is not positive.
|
||||
pub(crate) fn with_bandwidth(samples: Vec<f64>, bandwidth: f64) -> Result<Self> {
|
||||
if samples.is_empty() {
|
||||
return Err(Error::EmptySamples);
|
||||
}
|
||||
if bandwidth <= 0.0 {
|
||||
return Err(Error::InvalidBandwidth(bandwidth));
|
||||
}
|
||||
/// Panics if `samples` is empty or `bandwidth` is not positive.
|
||||
pub(crate) fn with_bandwidth(samples: Vec<f64>, bandwidth: f64) -> Self {
|
||||
assert!(!samples.is_empty(), "KDE requires at least one sample");
|
||||
assert!(bandwidth > 0.0, "Bandwidth must be positive");
|
||||
|
||||
Ok(Self { samples, bandwidth })
|
||||
Self { samples, bandwidth }
|
||||
}
|
||||
|
||||
/// Computes bandwidth using Scott's rule.
|
||||
///
|
||||
/// Scott's rule: h = n^(-1/5) * sigma
|
||||
/// where sigma is the sample standard deviation.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn scotts_rule(samples: &[f64]) -> f64 {
|
||||
let n = samples.len() as f64;
|
||||
let std_dev = Self::sample_std_dev(samples);
|
||||
@@ -93,7 +83,6 @@ impl KernelDensityEstimator {
|
||||
}
|
||||
|
||||
/// Computes the sample standard deviation.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
fn sample_std_dev(samples: &[f64]) -> f64 {
|
||||
let n = samples.len() as f64;
|
||||
let mean = samples.iter().sum::<f64>() / n;
|
||||
@@ -106,14 +95,13 @@ impl KernelDensityEstimator {
|
||||
/// The density is computed as the average of Gaussian kernels centered
|
||||
/// at each sample point:
|
||||
///
|
||||
/// f(x) = (1/n) * `sum_i` K((x - `x_i`) / h)
|
||||
/// f(x) = (1/n) * sum_i K((x - x_i) / h)
|
||||
///
|
||||
/// where K is the standard Gaussian kernel and h is the bandwidth.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub(crate) fn pdf(&self, x: f64) -> f64 {
|
||||
pub fn pdf(&self, x: f64) -> f64 {
|
||||
let n = self.samples.len() as f64;
|
||||
let inv_bandwidth = 1.0 / self.bandwidth;
|
||||
let normalization = inv_bandwidth / (2.0 * core::f64::consts::PI).sqrt();
|
||||
let normalization = inv_bandwidth / (2.0 * std::f64::consts::PI).sqrt();
|
||||
|
||||
let density: f64 = self
|
||||
.samples
|
||||
@@ -132,7 +120,7 @@ impl KernelDensityEstimator {
|
||||
/// Sampling works by:
|
||||
/// 1. Uniformly selecting one of the kernel centers (samples)
|
||||
/// 2. Adding Gaussian noise with the bandwidth as standard deviation
|
||||
pub(crate) fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
|
||||
pub fn sample<R: Rng>(&self, rng: &mut R) -> f64 {
|
||||
// Select a random sample to center the kernel on
|
||||
let idx = rng.random_range(0..self.samples.len());
|
||||
let center = self.samples[idx];
|
||||
@@ -142,7 +130,7 @@ impl KernelDensityEstimator {
|
||||
let u1: f64 = rng.random();
|
||||
let u2: f64 = rng.random();
|
||||
|
||||
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos();
|
||||
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos();
|
||||
center + z * self.bandwidth
|
||||
}
|
||||
|
||||
@@ -160,7 +148,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_kde_pdf_basic() {
|
||||
let samples = vec![0.0, 1.0, 2.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
|
||||
// Density should be positive everywhere
|
||||
assert!(kde.pdf(0.0) > 0.0);
|
||||
@@ -176,17 +164,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_kde_pdf_integrates_to_one() {
|
||||
let samples = vec![0.0, 1.0, 2.0, 3.0, 4.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
|
||||
// Numerical integration over a wide range
|
||||
let n_points = 10000;
|
||||
let low = -10.0;
|
||||
let high = 15.0;
|
||||
let dx = (high - low) / f64::from(n_points);
|
||||
let dx = (high - low) / n_points as f64;
|
||||
|
||||
let integral: f64 = (0..n_points)
|
||||
.map(|i| {
|
||||
let x = low + (f64::from(i) + 0.5) * dx;
|
||||
let x = low + (i as f64 + 0.5) * dx;
|
||||
kde.pdf(x) * dx
|
||||
})
|
||||
.sum();
|
||||
@@ -201,16 +189,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_kde_with_bandwidth() {
|
||||
let samples = vec![0.0, 1.0, 2.0];
|
||||
let kde = KernelDensityEstimator::with_bandwidth(samples, 0.5).unwrap();
|
||||
let kde = KernelDensityEstimator::with_bandwidth(samples, 0.5);
|
||||
|
||||
assert!((kde.bandwidth() - 0.5).abs() < f64::EPSILON);
|
||||
assert_eq!(kde.bandwidth(), 0.5);
|
||||
assert!(kde.pdf(1.0) > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kde_sample_in_reasonable_range() {
|
||||
let samples = vec![0.0, 1.0, 2.0, 3.0, 4.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
let mut rng = rand::rng();
|
||||
|
||||
// Samples should generally be in a reasonable range around the data
|
||||
@@ -225,7 +213,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_kde_single_sample() {
|
||||
let samples = vec![5.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
|
||||
// Should have positive density near the sample
|
||||
assert!(kde.pdf(5.0) > 0.0);
|
||||
@@ -235,7 +223,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_kde_identical_samples() {
|
||||
let samples = vec![3.0, 3.0, 3.0, 3.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
|
||||
// Should handle degenerate case with identical samples
|
||||
assert!(kde.bandwidth() > 0.0);
|
||||
@@ -245,7 +233,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_scotts_rule_bandwidth() {
|
||||
let samples = vec![0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0];
|
||||
let kde = KernelDensityEstimator::new(samples).unwrap();
|
||||
let kde = KernelDensityEstimator::new(samples);
|
||||
|
||||
// n = 10, n^(-1/5) ≈ 0.631
|
||||
// std_dev ≈ 2.87
|
||||
@@ -258,23 +246,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "KDE requires at least one sample")]
|
||||
fn test_kde_empty_samples() {
|
||||
let samples: Vec<f64> = vec![];
|
||||
let result = KernelDensityEstimator::new(samples);
|
||||
assert!(matches!(result, Err(Error::EmptySamples)));
|
||||
KernelDensityEstimator::new(samples);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Bandwidth must be positive")]
|
||||
fn test_kde_zero_bandwidth() {
|
||||
let samples = vec![1.0, 2.0, 3.0];
|
||||
let result = KernelDensityEstimator::with_bandwidth(samples, 0.0);
|
||||
assert!(matches!(result, Err(Error::InvalidBandwidth(_))));
|
||||
KernelDensityEstimator::with_bandwidth(samples, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "Bandwidth must be positive")]
|
||||
fn test_kde_negative_bandwidth() {
|
||||
let samples = vec![1.0, 2.0, 3.0];
|
||||
let result = KernelDensityEstimator::with_bandwidth(samples, -1.0);
|
||||
assert!(matches!(result, Err(Error::InvalidBandwidth(_))));
|
||||
KernelDensityEstimator::with_bandwidth(samples, -1.0);
|
||||
}
|
||||
}
|
||||
|
||||
+14
-63
@@ -1,24 +1,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::all)]
|
||||
#![deny(unreachable_pub)]
|
||||
#![deny(clippy::correctness)]
|
||||
#![deny(clippy::suspicious)]
|
||||
#![deny(clippy::style)]
|
||||
#![deny(clippy::complexity)]
|
||||
#![deny(clippy::perf)]
|
||||
#![deny(clippy::pedantic)]
|
||||
#![deny(clippy::std_instead_of_core)]
|
||||
|
||||
//! A black-box optimization library with multiple sampling strategies.
|
||||
//! A Tree-Parzen Estimator (TPE) library for black-box optimization.
|
||||
//!
|
||||
//! This library provides an Optuna-like API for hyperparameter optimization
|
||||
//! with support for multiple sampling algorithms:
|
||||
//!
|
||||
//! - **Random Search** - Simple random sampling for baseline comparisons
|
||||
//! - **TPE (Tree-Parzen Estimator)** - Bayesian optimization for efficient search
|
||||
//! - **Grid Search** - Exhaustive search over a specified parameter grid
|
||||
//!
|
||||
//! Additional features include:
|
||||
//! using the Tree-Parzen Estimator algorithm. It supports:
|
||||
//!
|
||||
//! - Float, integer, and categorical parameter types
|
||||
//! - Log-scale and stepped parameter sampling
|
||||
@@ -28,18 +11,17 @@
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```
|
||||
//! use optimizer::sampler::tpe::TpeSampler;
|
||||
//! use optimizer::{Direction, Study};
|
||||
//! use optimizer::{Direction, Study, TpeSampler};
|
||||
//!
|
||||
//! // Create a study with TPE sampler
|
||||
//! let sampler = TpeSampler::builder().seed(42).build().unwrap();
|
||||
//! let sampler = TpeSampler::builder().seed(42).build();
|
||||
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
//!
|
||||
//! // Optimize x^2 for 20 trials
|
||||
//! study
|
||||
//! .optimize_with_sampler(20, |trial| {
|
||||
//! let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
//! Ok::<_, optimizer::Error>(x * x)
|
||||
//! Ok::<_, optimizer::TpeError>(x * x)
|
||||
//! })
|
||||
//! .unwrap();
|
||||
//!
|
||||
@@ -53,9 +35,7 @@
|
||||
//! A [`Study`] manages optimization trials. Create one with an optimization direction:
|
||||
//!
|
||||
//! ```
|
||||
//! use optimizer::sampler::random::RandomSampler;
|
||||
//! use optimizer::sampler::tpe::TpeSampler;
|
||||
//! use optimizer::{Direction, Study};
|
||||
//! use optimizer::{Direction, RandomSampler, Study, TpeSampler};
|
||||
//!
|
||||
//! // Minimize with default random sampler
|
||||
//! let study: Study<f64> = Study::new(Direction::Minimize);
|
||||
@@ -92,53 +72,24 @@
|
||||
//! let optimizer = trial.suggest_categorical("optimizer", &["sgd", "adam", "rmsprop"])?;
|
||||
//!
|
||||
//! // Return objective value
|
||||
//! Ok::<_, optimizer::Error>(x * n as f64)
|
||||
//! Ok::<_, optimizer::TpeError>(x * n as f64)
|
||||
//! })
|
||||
//! .unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! # Available Samplers
|
||||
//! # Configuring TPE
|
||||
//!
|
||||
//! ## Random Search
|
||||
//!
|
||||
//! The simplest sampling strategy, useful for baselines:
|
||||
//! The [`TpeSampler`] can be configured using the builder pattern:
|
||||
//!
|
||||
//! ```
|
||||
//! use optimizer::sampler::random::RandomSampler;
|
||||
//! use optimizer::{Direction, Study};
|
||||
//!
|
||||
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
|
||||
//! ```
|
||||
//!
|
||||
//! ## TPE (Tree-Parzen Estimator)
|
||||
//!
|
||||
//! Bayesian optimization that learns from previous trials:
|
||||
//!
|
||||
//! ```
|
||||
//! use optimizer::sampler::tpe::TpeSampler;
|
||||
//! use optimizer::TpeSampler;
|
||||
//!
|
||||
//! let sampler = TpeSampler::builder()
|
||||
//! .gamma(0.15) // Quantile for good/bad split
|
||||
//! .n_startup_trials(20) // Random trials before TPE
|
||||
//! .n_ei_candidates(32) // Candidates to evaluate
|
||||
//! .seed(42) // Reproducibility
|
||||
//! .build()
|
||||
//! .unwrap();
|
||||
//! ```
|
||||
//!
|
||||
//! ## Grid Search
|
||||
//!
|
||||
//! Exhaustive search over a discretized parameter space:
|
||||
//!
|
||||
//! ```
|
||||
//! use optimizer::sampler::grid::GridSearchSampler;
|
||||
//! use optimizer::{Direction, Study};
|
||||
//!
|
||||
//! let sampler = GridSearchSampler::builder()
|
||||
//! .n_points_per_param(10) // Points per parameter dimension
|
||||
//! .build();
|
||||
//!
|
||||
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
//! ```
|
||||
//!
|
||||
//! # Async and Parallel Optimization
|
||||
@@ -169,13 +120,13 @@ mod distribution;
|
||||
mod error;
|
||||
mod kde;
|
||||
mod param;
|
||||
pub mod sampler;
|
||||
mod sampler;
|
||||
mod study;
|
||||
mod trial;
|
||||
mod types;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use param::ParamValue;
|
||||
pub use error::{Result, TpeError};
|
||||
pub use sampler::{CompletedTrial, RandomSampler, Sampler, TpeSampler, TpeSamplerBuilder};
|
||||
pub use study::Study;
|
||||
pub use trial::{SuggestableRange, Trial};
|
||||
pub use trial::Trial;
|
||||
pub use types::{Direction, TrialState};
|
||||
|
||||
-1157
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,11 +1,13 @@
|
||||
//! Sampler trait and implementations for parameter sampling.
|
||||
|
||||
pub mod grid;
|
||||
pub mod random;
|
||||
mod random;
|
||||
pub mod tpe;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use random::RandomSampler;
|
||||
pub use tpe::{TpeSampler, TpeSamplerBuilder};
|
||||
|
||||
use crate::distribution::Distribution;
|
||||
use crate::param::ParamValue;
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::sampler::{CompletedTrial, Sampler};
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::RandomSampler;
|
||||
///
|
||||
/// // Create with default RNG
|
||||
/// let sampler = RandomSampler::new();
|
||||
@@ -31,7 +31,6 @@ pub struct RandomSampler {
|
||||
|
||||
impl RandomSampler {
|
||||
/// Creates a new random sampler with a default random seed.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rng: Mutex::new(StdRng::from_os_rng()),
|
||||
@@ -41,7 +40,6 @@ impl RandomSampler {
|
||||
/// Creates a new random sampler with a fixed seed for reproducibility.
|
||||
///
|
||||
/// Using the same seed will produce the same sequence of sampled values.
|
||||
#[must_use]
|
||||
pub fn with_seed(seed: u64) -> Self {
|
||||
Self {
|
||||
rng: Mutex::new(StdRng::seed_from_u64(seed)),
|
||||
@@ -56,7 +54,6 @@ impl Default for RandomSampler {
|
||||
}
|
||||
|
||||
impl Sampler for RandomSampler {
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
|
||||
fn sample(
|
||||
&self,
|
||||
distribution: &Distribution,
|
||||
@@ -113,7 +110,6 @@ impl Sampler for RandomSampler {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::distribution::{CategoricalDistribution, FloatDistribution, IntDistribution};
|
||||
|
||||
+99
-148
@@ -9,7 +9,6 @@ use rand::rngs::StdRng;
|
||||
use rand::{Rng, SeedableRng};
|
||||
|
||||
use crate::distribution::Distribution;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::kde::KernelDensityEstimator;
|
||||
use crate::param::ParamValue;
|
||||
use crate::sampler::{CompletedTrial, Sampler};
|
||||
@@ -28,7 +27,7 @@ use crate::sampler::{CompletedTrial, Sampler};
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSampler;
|
||||
/// use optimizer::TpeSampler;
|
||||
///
|
||||
/// // Create with default settings
|
||||
/// let sampler = TpeSampler::new();
|
||||
@@ -39,8 +38,7 @@ use crate::sampler::{CompletedTrial, Sampler};
|
||||
/// .n_startup_trials(20)
|
||||
/// .n_ei_candidates(32)
|
||||
/// .seed(42)
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
pub struct TpeSampler {
|
||||
/// Fraction of trials to consider as "good" (gamma quantile).
|
||||
@@ -60,10 +58,9 @@ impl TpeSampler {
|
||||
///
|
||||
/// Default settings:
|
||||
/// - gamma: 0.25 (top 25% of trials are considered "good")
|
||||
/// - `n_startup_trials`: 10 (random sampling for first 10 trials)
|
||||
/// - `n_ei_candidates`: 24 (evaluate 24 candidates per sample)
|
||||
/// - `kde_bandwidth`: None (uses Scott's rule for automatic bandwidth)
|
||||
#[must_use]
|
||||
/// - n_startup_trials: 10 (random sampling for first 10 trials)
|
||||
/// - n_ei_candidates: 24 (evaluate 24 candidates per sample)
|
||||
/// - kde_bandwidth: None (uses Scott's rule for automatic bandwidth)
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
gamma: 0.25,
|
||||
@@ -79,17 +76,15 @@ impl TpeSampler {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSampler;
|
||||
/// use optimizer::TpeSampler;
|
||||
///
|
||||
/// let sampler = TpeSampler::builder()
|
||||
/// .gamma(0.15)
|
||||
/// .n_startup_trials(20)
|
||||
/// .n_ei_candidates(32)
|
||||
/// .seed(42)
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn builder() -> TpeSamplerBuilder {
|
||||
TpeSamplerBuilder::new()
|
||||
}
|
||||
@@ -104,24 +99,22 @@ impl TpeSampler {
|
||||
/// * `kde_bandwidth` - Optional fixed bandwidth for KDE. If None, uses Scott's rule.
|
||||
/// * `seed` - Optional seed for reproducibility.
|
||||
///
|
||||
/// # Errors
|
||||
/// # Panics
|
||||
///
|
||||
/// Returns `Error::InvalidGamma` if gamma is not in (0.0, 1.0).
|
||||
/// Returns `Error::InvalidBandwidth` if `kde_bandwidth` is Some but not positive.
|
||||
/// Panics if gamma is not in (0.0, 1.0) or if kde_bandwidth is Some but not positive.
|
||||
pub fn with_config(
|
||||
gamma: f64,
|
||||
n_startup_trials: usize,
|
||||
n_ei_candidates: usize,
|
||||
kde_bandwidth: Option<f64>,
|
||||
seed: Option<u64>,
|
||||
) -> Result<Self> {
|
||||
if gamma <= 0.0 || gamma >= 1.0 {
|
||||
return Err(Error::InvalidGamma(gamma));
|
||||
}
|
||||
if let Some(bw) = kde_bandwidth
|
||||
&& bw <= 0.0
|
||||
{
|
||||
return Err(Error::InvalidBandwidth(bw));
|
||||
) -> Self {
|
||||
assert!(
|
||||
gamma > 0.0 && gamma < 1.0,
|
||||
"gamma must be in (0.0, 1.0), got {gamma}"
|
||||
);
|
||||
if let Some(bw) = kde_bandwidth {
|
||||
assert!(bw > 0.0, "kde_bandwidth must be positive, got {bw}");
|
||||
}
|
||||
|
||||
let rng = match seed {
|
||||
@@ -129,24 +122,19 @@ impl TpeSampler {
|
||||
None => StdRng::from_os_rng(),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
Self {
|
||||
gamma,
|
||||
n_startup_trials,
|
||||
n_ei_candidates,
|
||||
kde_bandwidth,
|
||||
rng: Mutex::new(rng),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits trials into good and bad groups based on the gamma quantile.
|
||||
///
|
||||
/// Returns (`good_trials`, `bad_trials`) where `good_trials` contains trials
|
||||
/// Returns (good_trials, bad_trials) where good_trials contains trials
|
||||
/// with values below the gamma quantile (for minimization).
|
||||
#[allow(
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss
|
||||
)]
|
||||
fn split_trials<'a>(
|
||||
&self,
|
||||
history: &'a [CompletedTrial],
|
||||
@@ -161,7 +149,7 @@ impl TpeSampler {
|
||||
history[a]
|
||||
.value
|
||||
.partial_cmp(&history[b].value)
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
// Calculate the split point (gamma quantile)
|
||||
@@ -183,11 +171,6 @@ impl TpeSampler {
|
||||
}
|
||||
|
||||
/// Samples uniformly from a distribution (used during startup phase).
|
||||
#[allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::unused_self
|
||||
)]
|
||||
fn sample_uniform(&self, distribution: &Distribution, rng: &mut StdRng) -> ParamValue {
|
||||
match distribution {
|
||||
Distribution::Float(d) => {
|
||||
@@ -258,11 +241,6 @@ impl TpeSampler {
|
||||
None => KernelDensityEstimator::new(bad_internal),
|
||||
};
|
||||
|
||||
// If KDE construction fails, fall back to uniform sampling
|
||||
let (Ok(l_kde), Ok(g_kde)) = (l_kde, g_kde) else {
|
||||
return rng.random_range(low..=high);
|
||||
};
|
||||
|
||||
// Generate candidates from l(x) and select the one with best l(x)/g(x) ratio
|
||||
let mut best_candidate = internal_low;
|
||||
let mut best_ratio = f64::NEG_INFINITY;
|
||||
@@ -311,19 +289,15 @@ impl TpeSampler {
|
||||
}
|
||||
|
||||
/// Samples using TPE for integer distributions.
|
||||
#[allow(
|
||||
clippy::too_many_arguments,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation
|
||||
)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sample_tpe_int(
|
||||
&self,
|
||||
low: i64,
|
||||
high: i64,
|
||||
log_scale: bool,
|
||||
step: Option<i64>,
|
||||
good_values: &[i64],
|
||||
bad_values: &[i64],
|
||||
good_values: Vec<i64>,
|
||||
bad_values: Vec<i64>,
|
||||
rng: &mut StdRng,
|
||||
) -> i64 {
|
||||
// Convert to floats for KDE
|
||||
@@ -357,24 +331,23 @@ impl TpeSampler {
|
||||
}
|
||||
|
||||
/// Samples using TPE for categorical distributions.
|
||||
#[allow(clippy::cast_precision_loss, clippy::unused_self)]
|
||||
fn sample_tpe_categorical(
|
||||
&self,
|
||||
n_choices: usize,
|
||||
good_indices: &[usize],
|
||||
bad_indices: &[usize],
|
||||
good_indices: Vec<usize>,
|
||||
bad_indices: Vec<usize>,
|
||||
rng: &mut StdRng,
|
||||
) -> usize {
|
||||
// Count occurrences in good and bad groups
|
||||
let mut good_counts = vec![0usize; n_choices];
|
||||
let mut bad_counts = vec![0usize; n_choices];
|
||||
|
||||
for &idx in good_indices {
|
||||
for &idx in &good_indices {
|
||||
if idx < n_choices {
|
||||
good_counts[idx] += 1;
|
||||
}
|
||||
}
|
||||
for &idx in bad_indices {
|
||||
for &idx in &bad_indices {
|
||||
if idx < n_choices {
|
||||
bad_counts[idx] += 1;
|
||||
}
|
||||
@@ -422,15 +395,14 @@ impl Default for TpeSampler {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .gamma(0.15)
|
||||
/// .n_startup_trials(20)
|
||||
/// .n_ei_candidates(32)
|
||||
/// .seed(42)
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TpeSamplerBuilder {
|
||||
@@ -446,11 +418,10 @@ impl TpeSamplerBuilder {
|
||||
///
|
||||
/// Default settings:
|
||||
/// - gamma: 0.25 (top 25% of trials are considered "good")
|
||||
/// - `n_startup_trials`: 10 (random sampling for first 10 trials)
|
||||
/// - `n_ei_candidates`: 24 (evaluate 24 candidates per sample)
|
||||
/// - `kde_bandwidth`: None (uses Scott's rule for automatic bandwidth)
|
||||
/// - n_startup_trials: 10 (random sampling for first 10 trials)
|
||||
/// - n_ei_candidates: 24 (evaluate 24 candidates per sample)
|
||||
/// - kde_bandwidth: None (uses Scott's rule for automatic bandwidth)
|
||||
/// - seed: None (use OS-provided entropy)
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
gamma: 0.25,
|
||||
@@ -470,23 +441,24 @@ impl TpeSamplerBuilder {
|
||||
///
|
||||
/// * `gamma` - Quantile value, must be in (0.0, 1.0).
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if gamma is not in (0.0, 1.0).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .gamma(0.10) // Use top 10% as "good" trials
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Validation happens at `build()` time. If gamma is not in (0.0, 1.0),
|
||||
/// `build()` will return `Err(Error::InvalidGamma)`.
|
||||
#[must_use]
|
||||
pub fn gamma(mut self, gamma: f64) -> Self {
|
||||
assert!(
|
||||
gamma > 0.0 && gamma < 1.0,
|
||||
"gamma must be in (0.0, 1.0), got {gamma}"
|
||||
);
|
||||
self.gamma = gamma;
|
||||
self
|
||||
}
|
||||
@@ -504,14 +476,12 @@ impl TpeSamplerBuilder {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .n_startup_trials(20) // Random sample first 20 trials
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn n_startup_trials(mut self, n: usize) -> Self {
|
||||
self.n_startup_trials = n;
|
||||
self
|
||||
@@ -530,14 +500,12 @@ impl TpeSamplerBuilder {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .n_ei_candidates(48) // Evaluate more candidates
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn n_ei_candidates(mut self, n: usize) -> Self {
|
||||
self.n_ei_candidates = n;
|
||||
self
|
||||
@@ -555,23 +523,24 @@ impl TpeSamplerBuilder {
|
||||
///
|
||||
/// * `bandwidth` - The fixed bandwidth (standard deviation) for Gaussian kernels.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if bandwidth is not positive.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .kde_bandwidth(0.5) // Fixed bandwidth of 0.5
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Validation happens at `build()` time. If bandwidth is not positive,
|
||||
/// `build()` will return `Err(Error::InvalidBandwidth)`.
|
||||
#[must_use]
|
||||
pub fn kde_bandwidth(mut self, bandwidth: f64) -> Self {
|
||||
assert!(
|
||||
bandwidth > 0.0,
|
||||
"kde_bandwidth must be positive, got {bandwidth}"
|
||||
);
|
||||
self.kde_bandwidth = Some(bandwidth);
|
||||
self
|
||||
}
|
||||
@@ -585,14 +554,12 @@ impl TpeSamplerBuilder {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .seed(42) // Reproducible results
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn seed(mut self, seed: u64) -> Self {
|
||||
self.seed = Some(seed);
|
||||
self
|
||||
@@ -600,25 +567,19 @@ impl TpeSamplerBuilder {
|
||||
|
||||
/// Builds the configured [`TpeSampler`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::InvalidGamma` if gamma is not in (0.0, 1.0).
|
||||
/// Returns `Error::InvalidBandwidth` if `kde_bandwidth` is Some but not positive.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
/// use optimizer::TpeSamplerBuilder;
|
||||
///
|
||||
/// let sampler = TpeSamplerBuilder::new()
|
||||
/// .gamma(0.15)
|
||||
/// .n_startup_trials(20)
|
||||
/// .n_ei_candidates(32)
|
||||
/// .seed(42)
|
||||
/// .build()
|
||||
/// .unwrap();
|
||||
/// .build();
|
||||
/// ```
|
||||
pub fn build(self) -> Result<TpeSampler> {
|
||||
pub fn build(self) -> TpeSampler {
|
||||
TpeSampler::with_config(
|
||||
self.gamma,
|
||||
self.n_startup_trials,
|
||||
@@ -636,7 +597,6 @@ impl Default for TpeSamplerBuilder {
|
||||
}
|
||||
|
||||
impl Sampler for TpeSampler {
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn sample(
|
||||
&self,
|
||||
distribution: &Distribution,
|
||||
@@ -733,8 +693,8 @@ impl Sampler for TpeSampler {
|
||||
d.high,
|
||||
d.log_scale,
|
||||
d.step,
|
||||
&good_values,
|
||||
&bad_values,
|
||||
good_values,
|
||||
bad_values,
|
||||
&mut rng,
|
||||
);
|
||||
ParamValue::Int(value)
|
||||
@@ -765,7 +725,7 @@ impl Sampler for TpeSampler {
|
||||
}
|
||||
|
||||
let index =
|
||||
self.sample_tpe_categorical(d.n_choices, &good_indices, &bad_indices, &mut rng);
|
||||
self.sample_tpe_categorical(d.n_choices, good_indices, bad_indices, &mut rng);
|
||||
ParamValue::Categorical(index)
|
||||
}
|
||||
}
|
||||
@@ -773,11 +733,6 @@ impl Sampler for TpeSampler {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(
|
||||
clippy::similar_names,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss
|
||||
)]
|
||||
mod tests {
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -801,34 +756,34 @@ mod tests {
|
||||
#[test]
|
||||
fn test_tpe_sampler_new() {
|
||||
let sampler = TpeSampler::new();
|
||||
assert!((sampler.gamma - 0.25).abs() < f64::EPSILON);
|
||||
assert_eq!(sampler.gamma, 0.25);
|
||||
assert_eq!(sampler.n_startup_trials, 10);
|
||||
assert_eq!(sampler.n_ei_candidates, 24);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tpe_sampler_with_config() {
|
||||
let sampler = TpeSampler::with_config(0.15, 20, 32, None, Some(42)).unwrap();
|
||||
assert!((sampler.gamma - 0.15).abs() < f64::EPSILON);
|
||||
let sampler = TpeSampler::with_config(0.15, 20, 32, None, Some(42));
|
||||
assert_eq!(sampler.gamma, 0.15);
|
||||
assert_eq!(sampler.n_startup_trials, 20);
|
||||
assert_eq!(sampler.n_ei_candidates, 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be in (0.0, 1.0)")]
|
||||
fn test_tpe_sampler_invalid_gamma_zero() {
|
||||
let result = TpeSampler::with_config(0.0, 10, 24, None, None);
|
||||
assert!(matches!(result, Err(Error::InvalidGamma(_))));
|
||||
TpeSampler::with_config(0.0, 10, 24, None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be in (0.0, 1.0)")]
|
||||
fn test_tpe_sampler_invalid_gamma_one() {
|
||||
let result = TpeSampler::with_config(1.0, 10, 24, None, None);
|
||||
assert!(matches!(result, Err(Error::InvalidGamma(_))));
|
||||
TpeSampler::with_config(1.0, 10, 24, None, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tpe_startup_random_sampling() {
|
||||
let sampler = TpeSampler::with_config(0.25, 10, 24, None, Some(42)).unwrap();
|
||||
let sampler = TpeSampler::with_config(0.25, 10, 24, None, Some(42));
|
||||
let dist = Distribution::Float(FloatDistribution {
|
||||
low: 0.0,
|
||||
high: 1.0,
|
||||
@@ -851,7 +806,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_split_trials() {
|
||||
let sampler = TpeSampler::with_config(0.25, 10, 24, None, Some(42)).unwrap();
|
||||
let sampler = TpeSampler::with_config(0.25, 10, 24, None, Some(42));
|
||||
|
||||
let dist = Distribution::Float(FloatDistribution {
|
||||
low: 0.0,
|
||||
@@ -865,8 +820,8 @@ mod tests {
|
||||
.map(|i| {
|
||||
create_trial(
|
||||
i as u64,
|
||||
f64::from(i),
|
||||
vec![("x", ParamValue::Float(f64::from(i) / 20.0), dist.clone())],
|
||||
i as f64,
|
||||
vec![("x", ParamValue::Float(i as f64 / 20.0), dist.clone())],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -885,7 +840,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_samples_float_with_history() {
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42)).unwrap();
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42));
|
||||
|
||||
let dist = Distribution::Float(FloatDistribution {
|
||||
low: 0.0,
|
||||
@@ -897,7 +852,7 @@ mod tests {
|
||||
// Create history where low values (near 0.2) are "good"
|
||||
let history: Vec<CompletedTrial> = (0..20)
|
||||
.map(|i| {
|
||||
let x = f64::from(i) / 20.0;
|
||||
let x = i as f64 / 20.0;
|
||||
// Objective is (x - 0.2)^2, minimized at x=0.2
|
||||
let value = (x - 0.2).powi(2);
|
||||
create_trial(
|
||||
@@ -927,7 +882,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_categorical_sampling() {
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42)).unwrap();
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42));
|
||||
|
||||
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 4 });
|
||||
|
||||
@@ -967,7 +922,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_int_sampling() {
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42)).unwrap();
|
||||
let sampler = TpeSampler::with_config(0.25, 5, 24, None, Some(42));
|
||||
|
||||
let dist = Distribution::Int(IntDistribution {
|
||||
low: 0,
|
||||
@@ -1013,14 +968,14 @@ mod tests {
|
||||
.map(|i| {
|
||||
create_trial(
|
||||
i as u64,
|
||||
f64::from(i),
|
||||
vec![("x", ParamValue::Float(f64::from(i) / 20.0), dist.clone())],
|
||||
i as f64,
|
||||
vec![("x", ParamValue::Float(i as f64 / 20.0), dist.clone())],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let sampler1 = TpeSampler::with_config(0.25, 5, 24, None, Some(12345)).unwrap();
|
||||
let sampler2 = TpeSampler::with_config(0.25, 5, 24, None, Some(12345)).unwrap();
|
||||
let sampler1 = TpeSampler::with_config(0.25, 5, 24, None, Some(12345));
|
||||
let sampler2 = TpeSampler::with_config(0.25, 5, 24, None, Some(12345));
|
||||
|
||||
for i in 0..10 {
|
||||
let v1 = sampler1.sample(&dist, i, &history);
|
||||
@@ -1032,8 +987,8 @@ mod tests {
|
||||
#[test]
|
||||
fn test_tpe_sampler_builder_default() {
|
||||
let builder = TpeSamplerBuilder::new();
|
||||
let sampler = builder.build().unwrap();
|
||||
assert!((sampler.gamma - 0.25).abs() < f64::EPSILON);
|
||||
let sampler = builder.build();
|
||||
assert_eq!(sampler.gamma, 0.25);
|
||||
assert_eq!(sampler.n_startup_trials, 10);
|
||||
assert_eq!(sampler.n_ei_candidates, 24);
|
||||
}
|
||||
@@ -1045,9 +1000,8 @@ mod tests {
|
||||
.n_startup_trials(20)
|
||||
.n_ei_candidates(32)
|
||||
.seed(42)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!((sampler.gamma - 0.15).abs() < f64::EPSILON);
|
||||
.build();
|
||||
assert_eq!(sampler.gamma, 0.15);
|
||||
assert_eq!(sampler.n_startup_trials, 20);
|
||||
assert_eq!(sampler.n_ei_candidates, 32);
|
||||
}
|
||||
@@ -1058,9 +1012,8 @@ mod tests {
|
||||
.gamma(0.10)
|
||||
.n_startup_trials(15)
|
||||
.n_ei_candidates(48)
|
||||
.build()
|
||||
.unwrap();
|
||||
assert!((sampler.gamma - 0.10).abs() < f64::EPSILON);
|
||||
.build();
|
||||
assert_eq!(sampler.gamma, 0.10);
|
||||
assert_eq!(sampler.n_startup_trials, 15);
|
||||
assert_eq!(sampler.n_ei_candidates, 48);
|
||||
}
|
||||
@@ -1068,16 +1021,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_tpe_sampler_builder_partial() {
|
||||
// Test setting only some options
|
||||
let sampler = TpeSamplerBuilder::new().gamma(0.20).build().unwrap();
|
||||
assert!((sampler.gamma - 0.20).abs() < f64::EPSILON);
|
||||
let sampler = TpeSamplerBuilder::new().gamma(0.20).build();
|
||||
assert_eq!(sampler.gamma, 0.20);
|
||||
assert_eq!(sampler.n_startup_trials, 10); // default
|
||||
assert_eq!(sampler.n_ei_candidates, 24); // default
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "gamma must be in (0.0, 1.0)")]
|
||||
fn test_tpe_sampler_builder_invalid_gamma() {
|
||||
let result = TpeSamplerBuilder::new().gamma(1.5).build();
|
||||
assert!(matches!(result, Err(Error::InvalidGamma(_))));
|
||||
TpeSamplerBuilder::new().gamma(1.5).build();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1089,12 +1042,12 @@ mod tests {
|
||||
step: None,
|
||||
});
|
||||
|
||||
let history: Vec<CompletedTrial> = (0..20u32)
|
||||
let history: Vec<CompletedTrial> = (0..20)
|
||||
.map(|i| {
|
||||
create_trial(
|
||||
u64::from(i),
|
||||
f64::from(i),
|
||||
vec![("x", ParamValue::Float(f64::from(i) / 20.0), dist.clone())],
|
||||
i as u64,
|
||||
i as f64,
|
||||
vec![("x", ParamValue::Float(i as f64 / 20.0), dist.clone())],
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1102,13 +1055,11 @@ mod tests {
|
||||
let sampler1 = TpeSampler::builder()
|
||||
.seed(99999)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
let sampler2 = TpeSampler::builder()
|
||||
.seed(99999)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
|
||||
for i in 0..10 {
|
||||
let v1 = sampler1.sample(&dist, i, &history);
|
||||
|
||||
+61
-98
@@ -1,15 +1,14 @@
|
||||
//! Study implementation for managing optimization trials.
|
||||
|
||||
#[cfg(feature = "async")]
|
||||
use core::future::Future;
|
||||
use core::ops::ControlFlow;
|
||||
use core::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::future::Future;
|
||||
use std::ops::ControlFlow;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::sampler::random::RandomSampler;
|
||||
use crate::sampler::{CompletedTrial, Sampler};
|
||||
use crate::sampler::{CompletedTrial, RandomSampler, Sampler};
|
||||
use crate::trial::Trial;
|
||||
use crate::types::Direction;
|
||||
|
||||
@@ -65,7 +64,6 @@ where
|
||||
/// let study: Study<f64> = Study::new(Direction::Minimize);
|
||||
/// assert_eq!(study.direction(), Direction::Minimize);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn new(direction: Direction) -> Self {
|
||||
Self::with_sampler(direction, RandomSampler::new())
|
||||
}
|
||||
@@ -80,8 +78,7 @@ where
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
/// let study: Study<f64> = Study::with_sampler(Direction::Maximize, sampler);
|
||||
@@ -110,8 +107,7 @@ where
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::tpe::TpeSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, Study, TpeSampler};
|
||||
///
|
||||
/// let mut study: Study<f64> = Study::new(Direction::Minimize);
|
||||
/// study.set_sampler(TpeSampler::new());
|
||||
@@ -275,7 +271,7 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if no trials have been completed.
|
||||
/// Returns `TpeError::NoCompletedTrials` if no trials have been completed.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
@@ -305,7 +301,7 @@ where
|
||||
let trials = self.completed_trials.read();
|
||||
|
||||
if trials.is_empty() {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
let best = trials
|
||||
@@ -317,15 +313,17 @@ where
|
||||
match self.direction {
|
||||
Direction::Minimize => {
|
||||
// Reverse ordering: smaller values are "greater" for max_by
|
||||
ordering.map_or(core::cmp::Ordering::Equal, core::cmp::Ordering::reverse)
|
||||
ordering
|
||||
.map(|o| o.reverse())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
Direction::Maximize => {
|
||||
// Normal ordering: larger values are "greater" for max_by
|
||||
ordering.unwrap_or(core::cmp::Ordering::Equal)
|
||||
ordering.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}
|
||||
}
|
||||
})
|
||||
.ok_or(crate::Error::NoCompletedTrials)?;
|
||||
.expect("trials is not empty");
|
||||
|
||||
Ok(best.clone())
|
||||
}
|
||||
@@ -338,7 +336,7 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if no trials have been completed.
|
||||
/// Returns `TpeError::NoCompletedTrials` if no trials have been completed.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
@@ -387,13 +385,12 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// // Minimize x^2
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
@@ -402,7 +399,7 @@ where
|
||||
/// study
|
||||
/// .optimize(10, |trial| {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// Ok::<_, optimizer::Error>(x * x)
|
||||
/// Ok::<_, optimizer::TpeError>(x * x)
|
||||
/// })
|
||||
/// .unwrap();
|
||||
///
|
||||
@@ -413,7 +410,7 @@ where
|
||||
/// ```
|
||||
pub fn optimize<F, E>(&self, n_trials: usize, mut objective: F) -> crate::Result<()>
|
||||
where
|
||||
F: FnMut(&mut Trial) -> core::result::Result<V, E>,
|
||||
F: FnMut(&mut Trial) -> std::result::Result<V, E>,
|
||||
E: ToString,
|
||||
{
|
||||
for _ in 0..n_trials {
|
||||
@@ -431,7 +428,7 @@ where
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -455,13 +452,12 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// # #[cfg(feature = "async")]
|
||||
/// # async fn example() -> optimizer::Result<()> {
|
||||
@@ -474,7 +470,7 @@ where
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// // Simulate async work (e.g., network request)
|
||||
/// let value = x * x;
|
||||
/// Ok::<_, optimizer::Error>((trial, value))
|
||||
/// Ok::<_, optimizer::TpeError>((trial, value))
|
||||
/// })
|
||||
/// .await?;
|
||||
///
|
||||
@@ -491,7 +487,7 @@ where
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: Fn(Trial) -> Fut,
|
||||
Fut: Future<Output = core::result::Result<(Trial, V), E>>,
|
||||
Fut: Future<Output = std::result::Result<(Trial, V), E>>,
|
||||
E: ToString,
|
||||
{
|
||||
for _ in 0..n_trials {
|
||||
@@ -511,7 +507,7 @@ where
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -536,14 +532,12 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `Error::TaskError` if the semaphore is closed or a spawned task panics.
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// # #[cfg(feature = "async")]
|
||||
/// # async fn example() -> optimizer::Result<()> {
|
||||
@@ -556,7 +550,7 @@ where
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// // Async objective function (e.g., network request)
|
||||
/// let value = x * x;
|
||||
/// Ok::<_, optimizer::Error>((trial, value))
|
||||
/// Ok::<_, optimizer::TpeError>((trial, value))
|
||||
/// })
|
||||
/// .await?;
|
||||
///
|
||||
@@ -574,7 +568,7 @@ where
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: Fn(Trial) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = core::result::Result<(Trial, V), E>> + Send,
|
||||
Fut: Future<Output = std::result::Result<(Trial, V), E>> + Send,
|
||||
E: ToString + Send + 'static,
|
||||
V: Send + 'static,
|
||||
{
|
||||
@@ -586,11 +580,7 @@ where
|
||||
let mut handles = Vec::with_capacity(n_trials);
|
||||
|
||||
for _ in 0..n_trials {
|
||||
let permit = semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|e| crate::Error::TaskError(e.to_string()))?;
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let trial = self.create_trial();
|
||||
let objective = Arc::clone(&objective);
|
||||
|
||||
@@ -605,10 +595,7 @@ where
|
||||
|
||||
// Wait for all tasks and record results
|
||||
for handle in handles {
|
||||
match handle
|
||||
.await
|
||||
.map_err(|e| crate::Error::TaskError(e.to_string()))?
|
||||
{
|
||||
match handle.await.unwrap() {
|
||||
Ok((trial, value)) => {
|
||||
self.complete_trial(trial, value);
|
||||
}
|
||||
@@ -620,7 +607,7 @@ where
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -643,17 +630,15 @@ where
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if no trials completed successfully
|
||||
/// Returns `TpeError::NoCompletedTrials` if no trials completed successfully
|
||||
/// before optimization stopped (either by completing all trials or early stopping).
|
||||
/// Returns `Error::Internal` if a completed trial is not found after adding (internal invariant violation).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use std::ops::ControlFlow;
|
||||
///
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// // Stop early when we find a good enough value
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
@@ -664,7 +649,7 @@ where
|
||||
/// 100,
|
||||
/// |trial| {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// Ok::<_, optimizer::Error>(x * x)
|
||||
/// Ok::<_, optimizer::TpeError>(x * x)
|
||||
/// },
|
||||
/// |_study, completed_trial| {
|
||||
/// // Stop early if we find a value less than 1.0
|
||||
@@ -688,7 +673,7 @@ where
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
V: Clone,
|
||||
F: FnMut(&mut Trial) -> core::result::Result<V, E>,
|
||||
F: FnMut(&mut Trial) -> std::result::Result<V, E>,
|
||||
C: FnMut(&Study<V>, &CompletedTrial<V>) -> ControlFlow<()>,
|
||||
E: ToString,
|
||||
{
|
||||
@@ -701,11 +686,7 @@ where
|
||||
|
||||
// Get the just-completed trial for the callback
|
||||
let trials = self.completed_trials.read();
|
||||
let Some(completed) = trials.last() else {
|
||||
return Err(crate::Error::Internal(
|
||||
"completed trial not found after adding",
|
||||
));
|
||||
};
|
||||
let completed = trials.last().expect("just added a trial");
|
||||
|
||||
// Call the callback and check if we should stop
|
||||
// Note: We need to drop the read lock before calling callback
|
||||
@@ -725,7 +706,7 @@ where
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -746,8 +727,7 @@ impl Study<f64> {
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// // With a seeded sampler for reproducibility
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
@@ -783,13 +763,12 @@ impl Study<f64> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// // Minimize x^2 with sampler integration
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
@@ -798,7 +777,7 @@ impl Study<f64> {
|
||||
/// study
|
||||
/// .optimize_with_sampler(10, |trial| {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// Ok::<_, optimizer::Error>(x * x)
|
||||
/// Ok::<_, optimizer::TpeError>(x * x)
|
||||
/// })
|
||||
/// .unwrap();
|
||||
///
|
||||
@@ -811,7 +790,7 @@ impl Study<f64> {
|
||||
mut objective: F,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: FnMut(&mut Trial) -> core::result::Result<f64, E>,
|
||||
F: FnMut(&mut Trial) -> std::result::Result<f64, E>,
|
||||
E: ToString,
|
||||
{
|
||||
for _ in 0..n_trials {
|
||||
@@ -829,7 +808,7 @@ impl Study<f64> {
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -851,16 +830,14 @@ impl Study<f64> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if no trials completed successfully.
|
||||
/// Returns `Error::Internal` if a completed trial is not found after adding (internal invariant violation).
|
||||
/// Returns `TpeError::NoCompletedTrials` if no trials completed successfully.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use std::ops::ControlFlow;
|
||||
///
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// // Optimize with sampler integration and early stopping
|
||||
/// let sampler = RandomSampler::with_seed(42);
|
||||
@@ -871,7 +848,7 @@ impl Study<f64> {
|
||||
/// 100,
|
||||
/// |trial| {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// Ok::<_, optimizer::Error>(x * x)
|
||||
/// Ok::<_, optimizer::TpeError>(x * x)
|
||||
/// },
|
||||
/// |study, _completed_trial| {
|
||||
/// // Stop after finding 5 good trials
|
||||
@@ -893,7 +870,7 @@ impl Study<f64> {
|
||||
mut callback: C,
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: FnMut(&mut Trial) -> core::result::Result<f64, E>,
|
||||
F: FnMut(&mut Trial) -> std::result::Result<f64, E>,
|
||||
C: FnMut(&Study<f64>, &CompletedTrial<f64>) -> ControlFlow<()>,
|
||||
E: ToString,
|
||||
{
|
||||
@@ -906,11 +883,7 @@ impl Study<f64> {
|
||||
|
||||
// Get the just-completed trial for the callback
|
||||
let trials = self.completed_trials.read();
|
||||
let Some(completed) = trials.last() else {
|
||||
return Err(crate::Error::Internal(
|
||||
"completed trial not found after adding",
|
||||
));
|
||||
};
|
||||
let completed = trials.last().expect("just added a trial");
|
||||
|
||||
// Call the callback and check if we should stop
|
||||
// Note: We need to drop the read lock before calling callback
|
||||
@@ -930,7 +903,7 @@ impl Study<f64> {
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -953,13 +926,12 @@ impl Study<f64> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// # #[cfg(feature = "async")]
|
||||
/// # async fn example() -> optimizer::Result<()> {
|
||||
@@ -972,7 +944,7 @@ impl Study<f64> {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// // Simulate async work (e.g., network request)
|
||||
/// let value = x * x;
|
||||
/// Ok::<_, optimizer::Error>((trial, value))
|
||||
/// Ok::<_, optimizer::TpeError>((trial, value))
|
||||
/// })
|
||||
/// .await?;
|
||||
///
|
||||
@@ -989,7 +961,7 @@ impl Study<f64> {
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: Fn(Trial) -> Fut,
|
||||
Fut: Future<Output = core::result::Result<(Trial, f64), E>>,
|
||||
Fut: Future<Output = std::result::Result<(Trial, f64), E>>,
|
||||
E: ToString,
|
||||
{
|
||||
for _ in 0..n_trials {
|
||||
@@ -1009,7 +981,7 @@ impl Study<f64> {
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -1034,14 +1006,12 @@ impl Study<f64> {
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `Error::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
/// Returns `Error::TaskError` if the semaphore is closed or a spawned task panics.
|
||||
/// Returns `TpeError::NoCompletedTrials` if all trials failed (no successful trials).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::sampler::random::RandomSampler;
|
||||
/// use optimizer::{Direction, Study};
|
||||
/// use optimizer::{Direction, RandomSampler, Study};
|
||||
///
|
||||
/// # #[cfg(feature = "async")]
|
||||
/// # async fn example() -> optimizer::Result<()> {
|
||||
@@ -1054,7 +1024,7 @@ impl Study<f64> {
|
||||
/// let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
/// // Async objective function (e.g., network request)
|
||||
/// let value = x * x;
|
||||
/// Ok::<_, optimizer::Error>((trial, value))
|
||||
/// Ok::<_, optimizer::TpeError>((trial, value))
|
||||
/// })
|
||||
/// .await?;
|
||||
///
|
||||
@@ -1072,7 +1042,7 @@ impl Study<f64> {
|
||||
) -> crate::Result<()>
|
||||
where
|
||||
F: Fn(Trial) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = core::result::Result<(Trial, f64), E>> + Send,
|
||||
Fut: Future<Output = std::result::Result<(Trial, f64), E>> + Send,
|
||||
E: ToString + Send + 'static,
|
||||
{
|
||||
use tokio::sync::Semaphore;
|
||||
@@ -1083,11 +1053,7 @@ impl Study<f64> {
|
||||
let mut handles = Vec::with_capacity(n_trials);
|
||||
|
||||
for _ in 0..n_trials {
|
||||
let permit = semaphore
|
||||
.clone()
|
||||
.acquire_owned()
|
||||
.await
|
||||
.map_err(|e| crate::Error::TaskError(e.to_string()))?;
|
||||
let permit = semaphore.clone().acquire_owned().await.unwrap();
|
||||
let trial = self.create_trial_with_sampler();
|
||||
let objective = Arc::clone(&objective);
|
||||
|
||||
@@ -1102,10 +1068,7 @@ impl Study<f64> {
|
||||
|
||||
// Wait for all tasks and record results
|
||||
for handle in handles {
|
||||
match handle
|
||||
.await
|
||||
.map_err(|e| crate::Error::TaskError(e.to_string()))?
|
||||
{
|
||||
match handle.await.unwrap() {
|
||||
Ok((trial, value)) => {
|
||||
self.complete_trial(trial, value);
|
||||
}
|
||||
@@ -1117,7 +1080,7 @@ impl Study<f64> {
|
||||
|
||||
// Return error if no trials succeeded
|
||||
if self.n_trials() == 0 {
|
||||
return Err(crate::Error::NoCompletedTrials);
|
||||
return Err(crate::TpeError::NoCompletedTrials);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
+52
-211
@@ -1,6 +1,5 @@
|
||||
//! Trial implementation for tracking sampled parameters and trial state.
|
||||
|
||||
use core::ops::{Range, RangeInclusive};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -9,69 +8,11 @@ use parking_lot::RwLock;
|
||||
use crate::distribution::{
|
||||
CategoricalDistribution, Distribution, FloatDistribution, IntDistribution,
|
||||
};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::error::{Result, TpeError};
|
||||
use crate::param::ParamValue;
|
||||
use crate::sampler::{CompletedTrial, Sampler};
|
||||
use crate::types::TrialState;
|
||||
|
||||
/// A trait for types that can be used with [`Trial::suggest_range`].
|
||||
///
|
||||
/// This trait is implemented for [`Range`] and [`RangeInclusive`] over `f64` and `i64`.
|
||||
/// It allows using Rust's range syntax directly with the optimizer.
|
||||
///
|
||||
/// # Supported Range Types
|
||||
///
|
||||
/// | Range Type | Example | Description |
|
||||
/// |------------|---------|-------------|
|
||||
/// | `Range<f64>` | `0.0..1.0` | Float range (end-exclusive, treated as inclusive for continuous sampling) |
|
||||
/// | `RangeInclusive<f64>` | `0.0..=1.0` | Float range (end-inclusive) |
|
||||
/// | `Range<i64>` | `1..10` | Integer range from 1 to 9 (end-exclusive) |
|
||||
/// | `RangeInclusive<i64>` | `1..=10` | Integer range from 1 to 10 (end-inclusive) |
|
||||
pub trait SuggestableRange {
|
||||
/// The output type when suggesting from this range.
|
||||
type Output;
|
||||
|
||||
/// Suggests a value from this range using the given trial.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the range is invalid (e.g., empty or low > high).
|
||||
fn suggest(self, trial: &mut Trial, name: String) -> Result<Self::Output>;
|
||||
}
|
||||
|
||||
impl SuggestableRange for Range<f64> {
|
||||
type Output = f64;
|
||||
|
||||
fn suggest(self, trial: &mut Trial, name: String) -> Result<f64> {
|
||||
trial.suggest_float(name, self.start, self.end)
|
||||
}
|
||||
}
|
||||
|
||||
impl SuggestableRange for RangeInclusive<f64> {
|
||||
type Output = f64;
|
||||
|
||||
fn suggest(self, trial: &mut Trial, name: String) -> Result<f64> {
|
||||
trial.suggest_float(name, *self.start(), *self.end())
|
||||
}
|
||||
}
|
||||
|
||||
impl SuggestableRange for Range<i64> {
|
||||
type Output = i64;
|
||||
|
||||
fn suggest(self, trial: &mut Trial, name: String) -> Result<i64> {
|
||||
// Range is exclusive on the end, so subtract 1
|
||||
trial.suggest_int(name, self.start, self.end.saturating_sub(1))
|
||||
}
|
||||
}
|
||||
|
||||
impl SuggestableRange for RangeInclusive<i64> {
|
||||
type Output = i64;
|
||||
|
||||
fn suggest(self, trial: &mut Trial, name: String) -> Result<i64> {
|
||||
trial.suggest_int(name, *self.start(), *self.end())
|
||||
}
|
||||
}
|
||||
|
||||
/// A trial represents a single evaluation of the objective function.
|
||||
///
|
||||
/// Each trial has a unique ID and stores the sampled parameters along with
|
||||
@@ -96,8 +37,8 @@ pub struct Trial {
|
||||
history: Option<Arc<RwLock<Vec<CompletedTrial<f64>>>>>,
|
||||
}
|
||||
|
||||
impl core::fmt::Debug for Trial {
|
||||
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||
impl std::fmt::Debug for Trial {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Trial")
|
||||
.field("id", &self.id)
|
||||
.field("state", &self.state)
|
||||
@@ -130,7 +71,6 @@ impl Trial {
|
||||
/// let trial = Trial::new(0);
|
||||
/// assert_eq!(trial.id(), 0);
|
||||
/// ```
|
||||
#[must_use]
|
||||
pub fn new(id: u64) -> Self {
|
||||
Self {
|
||||
id,
|
||||
@@ -170,7 +110,7 @@ impl Trial {
|
||||
/// Samples a value from the given distribution using the sampler.
|
||||
///
|
||||
/// If the trial has a sampler, it delegates to the sampler's sample method
|
||||
/// with the history of completed trials. Otherwise, it uses the `RandomSampler`
|
||||
/// with the history of completed trials. Otherwise, it uses the RandomSampler
|
||||
/// as a fallback.
|
||||
fn sample_value(&self, distribution: &Distribution) -> ParamValue {
|
||||
if let (Some(sampler), Some(history)) = (&self.sampler, &self.history) {
|
||||
@@ -178,32 +118,28 @@ impl Trial {
|
||||
sampler.sample(distribution, self.id, &history_guard)
|
||||
} else {
|
||||
// Fallback to RandomSampler when no sampler is configured
|
||||
use crate::sampler::random::RandomSampler;
|
||||
use crate::sampler::RandomSampler;
|
||||
let fallback = RandomSampler::new();
|
||||
fallback.sample(distribution, self.id, &[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the unique ID of this trial.
|
||||
#[must_use]
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
|
||||
/// Returns the current state of this trial.
|
||||
#[must_use]
|
||||
pub fn state(&self) -> TrialState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Returns a reference to the sampled parameters.
|
||||
#[must_use]
|
||||
pub fn params(&self) -> &HashMap<String, ParamValue> {
|
||||
&self.params
|
||||
}
|
||||
|
||||
/// Returns a reference to the parameter distributions.
|
||||
#[must_use]
|
||||
pub fn distributions(&self) -> &HashMap<String, Distribution> {
|
||||
&self.distributions
|
||||
}
|
||||
@@ -249,7 +185,7 @@ impl Trial {
|
||||
/// ```
|
||||
pub fn suggest_float(&mut self, name: impl Into<String>, low: f64, high: f64) -> Result<f64> {
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds { low, high });
|
||||
return Err(TpeError::InvalidBounds { low, high });
|
||||
}
|
||||
|
||||
let name = name.into();
|
||||
@@ -264,8 +200,8 @@ impl Trial {
|
||||
if let Some(existing_dist) = self.distributions.get(&name) {
|
||||
// Verify the distribution matches
|
||||
if let Distribution::Float(existing) = existing_dist
|
||||
&& (existing.low - low).abs() < f64::EPSILON
|
||||
&& (existing.high - high).abs() < f64::EPSILON
|
||||
&& existing.low == low
|
||||
&& existing.high == high
|
||||
&& !existing.log_scale
|
||||
&& existing.step.is_none()
|
||||
{
|
||||
@@ -275,7 +211,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -284,10 +220,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler
|
||||
let dist = Distribution::Float(distribution);
|
||||
let ParamValue::Float(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal(
|
||||
"Float distribution should return Float value",
|
||||
));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Float(v) => v,
|
||||
_ => unreachable!("Float distribution should return Float value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -302,7 +237,7 @@ impl Trial {
|
||||
/// The value is sampled uniformly in log space, which is useful for parameters
|
||||
/// that span multiple orders of magnitude (e.g., learning rates).
|
||||
///
|
||||
/// If the parameter has already been sampled with the same bounds and `log_scale=true`,
|
||||
/// If the parameter has already been sampled with the same bounds and log_scale=true,
|
||||
/// the cached value is returned. If the parameter was sampled with different configuration,
|
||||
/// a `ParameterConflict` error is returned.
|
||||
///
|
||||
@@ -342,11 +277,11 @@ impl Trial {
|
||||
high: f64,
|
||||
) -> Result<f64> {
|
||||
if low <= 0.0 {
|
||||
return Err(Error::InvalidLogBounds);
|
||||
return Err(TpeError::InvalidLogBounds);
|
||||
}
|
||||
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds { low, high });
|
||||
return Err(TpeError::InvalidBounds { low, high });
|
||||
}
|
||||
|
||||
let name = name.into();
|
||||
@@ -361,8 +296,8 @@ impl Trial {
|
||||
if let Some(existing_dist) = self.distributions.get(&name) {
|
||||
// Verify the distribution matches
|
||||
if let Distribution::Float(existing) = existing_dist
|
||||
&& (existing.low - low).abs() < f64::EPSILON
|
||||
&& (existing.high - high).abs() < f64::EPSILON
|
||||
&& existing.low == low
|
||||
&& existing.high == high
|
||||
&& existing.log_scale
|
||||
&& existing.step.is_none()
|
||||
{
|
||||
@@ -372,7 +307,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -381,10 +316,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler (sampler handles log-scale transformation)
|
||||
let dist = Distribution::Float(distribution);
|
||||
let ParamValue::Float(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal(
|
||||
"Float distribution should return Float value",
|
||||
));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Float(v) => v,
|
||||
_ => unreachable!("Float distribution should return Float value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -439,11 +373,11 @@ impl Trial {
|
||||
step: f64,
|
||||
) -> Result<f64> {
|
||||
if step <= 0.0 {
|
||||
return Err(Error::InvalidStep);
|
||||
return Err(TpeError::InvalidStep);
|
||||
}
|
||||
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds { low, high });
|
||||
return Err(TpeError::InvalidBounds { low, high });
|
||||
}
|
||||
|
||||
let name = name.into();
|
||||
@@ -458,8 +392,8 @@ impl Trial {
|
||||
if let Some(existing_dist) = self.distributions.get(&name) {
|
||||
// Verify the distribution matches
|
||||
if let Distribution::Float(existing) = existing_dist
|
||||
&& (existing.low - low).abs() < f64::EPSILON
|
||||
&& (existing.high - high).abs() < f64::EPSILON
|
||||
&& existing.low == low
|
||||
&& existing.high == high
|
||||
&& !existing.log_scale
|
||||
&& existing.step == Some(step)
|
||||
{
|
||||
@@ -469,7 +403,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -478,10 +412,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler (sampler handles step-grid)
|
||||
let dist = Distribution::Float(distribution);
|
||||
let ParamValue::Float(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal(
|
||||
"Float distribution should return Float value",
|
||||
));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Float(v) => v,
|
||||
_ => unreachable!("Float distribution should return Float value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -522,10 +455,9 @@ impl Trial {
|
||||
/// let n2 = trial.suggest_int("n_layers", 1, 10).unwrap();
|
||||
/// assert_eq!(n, n2);
|
||||
/// ```
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub fn suggest_int(&mut self, name: impl Into<String>, low: i64, high: i64) -> Result<i64> {
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds {
|
||||
return Err(TpeError::InvalidBounds {
|
||||
low: low as f64,
|
||||
high: high as f64,
|
||||
});
|
||||
@@ -554,7 +486,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -563,8 +495,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler
|
||||
let dist = Distribution::Int(distribution);
|
||||
let ParamValue::Int(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal("Int distribution should return Int value"));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Int(v) => v,
|
||||
_ => unreachable!("Int distribution should return Int value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -579,7 +512,7 @@ impl Trial {
|
||||
/// The value is sampled uniformly in log space, which is useful for parameters
|
||||
/// that span multiple orders of magnitude (e.g., batch sizes).
|
||||
///
|
||||
/// If the parameter has already been sampled with the same bounds and `log_scale=true`,
|
||||
/// If the parameter has already been sampled with the same bounds and log_scale=true,
|
||||
/// the cached value is returned. If the parameter was sampled with different configuration,
|
||||
/// a `ParameterConflict` error is returned.
|
||||
///
|
||||
@@ -608,14 +541,13 @@ impl Trial {
|
||||
/// let batch_size2 = trial.suggest_int_log("batch_size", 1, 1024).unwrap();
|
||||
/// assert_eq!(batch_size, batch_size2);
|
||||
/// ```
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub fn suggest_int_log(&mut self, name: impl Into<String>, low: i64, high: i64) -> Result<i64> {
|
||||
if low < 1 {
|
||||
return Err(Error::InvalidLogBounds);
|
||||
return Err(TpeError::InvalidLogBounds);
|
||||
}
|
||||
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds {
|
||||
return Err(TpeError::InvalidBounds {
|
||||
low: low as f64,
|
||||
high: high as f64,
|
||||
});
|
||||
@@ -644,7 +576,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -653,8 +585,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler (sampler handles log-scale transformation)
|
||||
let dist = Distribution::Int(distribution);
|
||||
let ParamValue::Int(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal("Int distribution should return Int value"));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Int(v) => v,
|
||||
_ => unreachable!("Int distribution should return Int value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -705,7 +638,6 @@ impl Trial {
|
||||
/// .unwrap();
|
||||
/// assert_eq!(n, n2);
|
||||
/// ```
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
pub fn suggest_int_step(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
@@ -714,11 +646,11 @@ impl Trial {
|
||||
step: i64,
|
||||
) -> Result<i64> {
|
||||
if step <= 0 {
|
||||
return Err(Error::InvalidStep);
|
||||
return Err(TpeError::InvalidStep);
|
||||
}
|
||||
|
||||
if low > high {
|
||||
return Err(Error::InvalidBounds {
|
||||
return Err(TpeError::InvalidBounds {
|
||||
low: low as f64,
|
||||
high: high as f64,
|
||||
});
|
||||
@@ -747,7 +679,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different bounds or type"
|
||||
.to_string(),
|
||||
@@ -756,8 +688,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler (sampler handles step-grid)
|
||||
let dist = Distribution::Int(distribution);
|
||||
let ParamValue::Int(value) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal("Int distribution should return Int value"));
|
||||
let value = match self.sample_value(&dist) {
|
||||
ParamValue::Int(v) => v,
|
||||
_ => unreachable!("Int distribution should return Int value"),
|
||||
};
|
||||
|
||||
// Store distribution and value
|
||||
@@ -813,7 +746,7 @@ impl Trial {
|
||||
choices: &[T],
|
||||
) -> Result<T> {
|
||||
if choices.is_empty() {
|
||||
return Err(Error::EmptyChoices);
|
||||
return Err(TpeError::EmptyChoices);
|
||||
}
|
||||
|
||||
let name = name.into();
|
||||
@@ -832,7 +765,7 @@ impl Trial {
|
||||
}
|
||||
}
|
||||
// Distribution exists but doesn't match
|
||||
return Err(Error::ParameterConflict {
|
||||
return Err(TpeError::ParameterConflict {
|
||||
name,
|
||||
reason: "parameter was previously sampled with different number of choices or type"
|
||||
.to_string(),
|
||||
@@ -841,10 +774,9 @@ impl Trial {
|
||||
|
||||
// Sample using the sampler
|
||||
let dist = Distribution::Categorical(distribution);
|
||||
let ParamValue::Categorical(index) = self.sample_value(&dist) else {
|
||||
return Err(Error::Internal(
|
||||
"Categorical distribution should return Categorical value",
|
||||
));
|
||||
let index = match self.sample_value(&dist) {
|
||||
ParamValue::Categorical(idx) => idx,
|
||||
_ => unreachable!("Categorical distribution should return Categorical value"),
|
||||
};
|
||||
|
||||
// Store distribution and value (store the index)
|
||||
@@ -853,95 +785,4 @@ impl Trial {
|
||||
|
||||
Ok(choices[index].clone())
|
||||
}
|
||||
|
||||
/// Suggests a boolean parameter.
|
||||
///
|
||||
/// The value is selected uniformly at random from `{false, true}`.
|
||||
/// This is equivalent to calling `suggest_categorical(name, &[false, true])`.
|
||||
///
|
||||
/// If the parameter has already been sampled, the cached value is returned.
|
||||
/// If the parameter was sampled with a different type, a `ParameterConflict` error is returned.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The name of the parameter.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `ParameterConflict` if the parameter was previously sampled with a different type.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::Trial;
|
||||
///
|
||||
/// let mut trial = Trial::new(0);
|
||||
/// let use_dropout = trial.suggest_bool("use_dropout").unwrap();
|
||||
/// assert!(use_dropout == true || use_dropout == false);
|
||||
///
|
||||
/// // Calling again returns cached value
|
||||
/// let use_dropout2 = trial.suggest_bool("use_dropout").unwrap();
|
||||
/// assert_eq!(use_dropout, use_dropout2);
|
||||
/// ```
|
||||
pub fn suggest_bool(&mut self, name: impl Into<String>) -> Result<bool> {
|
||||
self.suggest_categorical(name, &[false, true])
|
||||
}
|
||||
|
||||
/// Suggests a parameter value from a range.
|
||||
///
|
||||
/// This method accepts both [`Range`] (`..`) and [`RangeInclusive`] (`..=`)
|
||||
/// for both `f64` and `i64` types, allowing natural Rust range syntax.
|
||||
///
|
||||
/// For integer ranges, note that `Range` (`..`) is end-exclusive while
|
||||
/// `RangeInclusive` (`..=`) is end-inclusive, matching Rust's semantics.
|
||||
///
|
||||
/// If the parameter has already been sampled with the same bounds, the cached value is returned.
|
||||
/// If the parameter was sampled with different bounds, a `ParameterConflict` error is returned.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `name` - The name of the parameter.
|
||||
/// * `range` - The range to sample from.
|
||||
///
|
||||
/// # Type Parameters
|
||||
///
|
||||
/// * `R` - A range type implementing [`SuggestableRange`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `InvalidBounds` if the range is invalid (e.g., low > high or empty integer range).
|
||||
/// Returns `ParameterConflict` if the parameter was previously sampled with different bounds.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use optimizer::Trial;
|
||||
///
|
||||
/// let mut trial = Trial::new(0);
|
||||
///
|
||||
/// // Float ranges
|
||||
/// let x = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
/// assert!(x >= 0.0 && x <= 1.0);
|
||||
///
|
||||
/// let y = trial.suggest_range("y", 0.0..=1.0).unwrap();
|
||||
/// assert!(y >= 0.0 && y <= 1.0);
|
||||
///
|
||||
/// // Integer ranges
|
||||
/// let n = trial.suggest_range("n", 1_i64..10).unwrap(); // 1 to 9 inclusive
|
||||
/// assert!(n >= 1 && n <= 9);
|
||||
///
|
||||
/// let m = trial.suggest_range("m", 1_i64..=10).unwrap(); // 1 to 10 inclusive
|
||||
/// assert!(m >= 1 && m <= 10);
|
||||
///
|
||||
/// // Calling again with same range returns cached value
|
||||
/// let x2 = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
/// assert_eq!(x, x2);
|
||||
/// ```
|
||||
pub fn suggest_range<R: SuggestableRange>(
|
||||
&mut self,
|
||||
name: impl Into<String>,
|
||||
range: R,
|
||||
) -> Result<R::Output> {
|
||||
range.suggest(self, name.into())
|
||||
}
|
||||
}
|
||||
|
||||
+15
-25
@@ -4,9 +4,7 @@
|
||||
|
||||
#![cfg(feature = "async")]
|
||||
|
||||
use optimizer::sampler::random::RandomSampler;
|
||||
use optimizer::sampler::tpe::TpeSampler;
|
||||
use optimizer::{Direction, Error, Study};
|
||||
use optimizer::{Direction, RandomSampler, Study, TpeError, TpeSampler};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_optimize_async_basic() {
|
||||
@@ -16,7 +14,7 @@ async fn test_optimize_async_basic() {
|
||||
study
|
||||
.optimize_async(10, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
Ok::<_, Error>((trial, x * x))
|
||||
Ok::<_, TpeError>((trial, x * x))
|
||||
})
|
||||
.await
|
||||
.expect("async optimization should succeed");
|
||||
@@ -28,18 +26,14 @@ async fn test_optimize_async_basic() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_optimize_async_with_sampler() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_async_with_sampler(15, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
Ok::<_, Error>((trial, x * x))
|
||||
Ok::<_, TpeError>((trial, x * x))
|
||||
})
|
||||
.await
|
||||
.expect("async optimization with sampler should succeed");
|
||||
@@ -57,7 +51,7 @@ async fn test_optimize_parallel() {
|
||||
study
|
||||
.optimize_parallel(20, 4, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
Ok::<_, Error>((trial, x * x))
|
||||
Ok::<_, TpeError>((trial, x * x))
|
||||
})
|
||||
.await
|
||||
.expect("parallel optimization should succeed");
|
||||
@@ -67,11 +61,7 @@ async fn test_optimize_parallel() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_optimize_parallel_with_sampler() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -79,7 +69,7 @@ async fn test_optimize_parallel_with_sampler() {
|
||||
.optimize_parallel_with_sampler(15, 3, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
let y = trial.suggest_float("y", -5.0, 5.0)?;
|
||||
Ok::<_, Error>((trial, x * x + y * y))
|
||||
Ok::<_, TpeError>((trial, x * x + y * y))
|
||||
})
|
||||
.await
|
||||
.expect("parallel optimization with sampler should succeed");
|
||||
@@ -99,7 +89,7 @@ async fn test_optimize_async_all_failures() {
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -116,7 +106,7 @@ async fn test_optimize_async_with_sampler_all_failures() {
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -133,7 +123,7 @@ async fn test_optimize_parallel_all_failures() {
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -150,7 +140,7 @@ async fn test_optimize_parallel_with_sampler_all_failures() {
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -168,9 +158,9 @@ async fn test_optimize_async_partial_failures() {
|
||||
async move {
|
||||
if count.is_multiple_of(2) {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>((trial, x))
|
||||
Ok::<_, TpeError>((trial, x))
|
||||
} else {
|
||||
Err(Error::NoCompletedTrials) // Use as error type
|
||||
Err(TpeError::NoCompletedTrials) // Use as error type
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -190,7 +180,7 @@ async fn test_optimize_parallel_high_concurrency() {
|
||||
study
|
||||
.optimize_parallel(5, 10, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>((trial, x))
|
||||
Ok::<_, TpeError>((trial, x))
|
||||
})
|
||||
.await
|
||||
.expect("should handle high concurrency");
|
||||
@@ -207,7 +197,7 @@ async fn test_optimize_parallel_single_concurrency() {
|
||||
study
|
||||
.optimize_parallel(10, 1, |mut trial| async move {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>((trial, x))
|
||||
Ok::<_, TpeError>((trial, x))
|
||||
})
|
||||
.await
|
||||
.expect("should work with single concurrency");
|
||||
|
||||
+70
-339
@@ -1,14 +1,6 @@
|
||||
//! Integration tests for the optimizer library.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_truncation
|
||||
)]
|
||||
|
||||
use optimizer::sampler::random::RandomSampler;
|
||||
use optimizer::sampler::tpe::TpeSampler;
|
||||
use optimizer::{Direction, Error, Study, Trial};
|
||||
use optimizer::{Direction, RandomSampler, Study, TpeError, TpeSampler, Trial};
|
||||
|
||||
// =============================================================================
|
||||
// Test: optimize simple quadratic function with TPE, finds near-optimal
|
||||
@@ -22,15 +14,14 @@ fn test_tpe_optimizes_quadratic_function() {
|
||||
.seed(42)
|
||||
.n_startup_trials(5) // Quick startup for test
|
||||
.n_ei_candidates(24)
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(50, |trial| {
|
||||
let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
Ok::<_, Error>((x - 3.0).powi(2))
|
||||
Ok::<_, TpeError>((x - 3.0).powi(2))
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -49,11 +40,7 @@ fn test_tpe_optimizes_quadratic_function() {
|
||||
fn test_tpe_optimizes_multivariate_function() {
|
||||
// Minimize f(x, y) = x^2 + y^2 where x, y ∈ [-5, 5]
|
||||
// Optimal: (0, 0), f(0, 0) = 0
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(123)
|
||||
.n_startup_trials(10)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(123).n_startup_trials(10).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -61,7 +48,7 @@ fn test_tpe_optimizes_multivariate_function() {
|
||||
.optimize_with_sampler(100, |trial| {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
let y = trial.suggest_float("y", -5.0, 5.0)?;
|
||||
Ok::<_, Error>(x * x + y * y)
|
||||
Ok::<_, TpeError>(x * x + y * y)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -79,18 +66,14 @@ fn test_tpe_optimizes_multivariate_function() {
|
||||
fn test_tpe_maximization() {
|
||||
// Maximize f(x) = -(x - 2)^2 + 10 where x ∈ [-10, 10]
|
||||
// Optimal: x = 2, f(2) = 10
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(456)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(456).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Maximize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(50, |trial| {
|
||||
let x = trial.suggest_float("x", -10.0, 10.0)?;
|
||||
Ok::<_, Error>(-(x - 2.0).powi(2) + 10.0)
|
||||
Ok::<_, TpeError>(-(x - 2.0).powi(2) + 10.0)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -123,7 +106,7 @@ fn test_random_sampler_uniform_float_distribution() {
|
||||
.optimize(n_samples, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 1.0)?;
|
||||
samples.push(x);
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -152,7 +135,7 @@ fn test_random_sampler_uniform_float_distribution() {
|
||||
fn test_random_sampler_uniform_int_distribution() {
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(123));
|
||||
|
||||
let n_samples = 5000;
|
||||
let n_samples = 1000;
|
||||
let mut counts = [0u32; 10]; // counts for values 1-10
|
||||
|
||||
study
|
||||
@@ -160,18 +143,16 @@ fn test_random_sampler_uniform_int_distribution() {
|
||||
let n = trial.suggest_int("n", 1, 10)?;
|
||||
assert!((1..=10).contains(&n), "sample {n} out of range [1, 10]");
|
||||
counts[(n - 1) as usize] += 1;
|
||||
Ok::<_, Error>(n as f64)
|
||||
Ok::<_, TpeError>(n as f64)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Each value should appear roughly n_samples / 10 times
|
||||
// With 5000 samples, expected ~500 per bucket, std dev ~21
|
||||
// 20% tolerance allows for ~4.5 std devs which is very safe
|
||||
let expected = n_samples as f64 / 10.0;
|
||||
for (i, &count) in counts.iter().enumerate() {
|
||||
let diff = (count as f64 - expected).abs() / expected;
|
||||
assert!(
|
||||
diff < 0.2,
|
||||
diff < 0.3,
|
||||
"value {} appeared {} times, expected ~{}, diff = {:.1}%",
|
||||
i + 1,
|
||||
count,
|
||||
@@ -185,7 +166,7 @@ fn test_random_sampler_uniform_int_distribution() {
|
||||
fn test_random_sampler_uniform_categorical_distribution() {
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(456));
|
||||
|
||||
let n_samples = 2000;
|
||||
let n_samples = 1000;
|
||||
let mut counts = [0u32; 4];
|
||||
let choices = ["a", "b", "c", "d"];
|
||||
|
||||
@@ -194,18 +175,16 @@ fn test_random_sampler_uniform_categorical_distribution() {
|
||||
let choice = trial.suggest_categorical("cat", &choices)?;
|
||||
let idx = choices.iter().position(|&c| c == choice).unwrap();
|
||||
counts[idx] += 1;
|
||||
Ok::<_, Error>(idx as f64)
|
||||
Ok::<_, TpeError>(idx as f64)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Each category should appear roughly n_samples / 4 times
|
||||
// With 2000 samples, expected ~500 per bucket, std dev ~19
|
||||
// 15% tolerance allows for ~4 std devs which is very safe
|
||||
let expected = n_samples as f64 / 4.0;
|
||||
for (i, &count) in counts.iter().enumerate() {
|
||||
let diff = (count as f64 - expected).abs() / expected;
|
||||
assert!(
|
||||
diff < 0.15,
|
||||
diff < 0.25,
|
||||
"category {} appeared {} times, expected ~{}, diff = {:.1}%",
|
||||
i,
|
||||
count,
|
||||
@@ -231,7 +210,7 @@ fn test_random_sampler_reproducibility() {
|
||||
.optimize_with_sampler(100, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 100.0)?;
|
||||
values1.push(x);
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -239,7 +218,7 @@ fn test_random_sampler_reproducibility() {
|
||||
.optimize_with_sampler(100, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 100.0)?;
|
||||
values2.push(x);
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -371,7 +350,7 @@ fn test_parameter_conflict_float_different_bounds() {
|
||||
trial.suggest_float("x", 0.0, 1.0).unwrap();
|
||||
let result = trial.suggest_float("x", 0.0, 2.0); // Different upper bound
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -381,7 +360,7 @@ fn test_parameter_conflict_float_vs_log() {
|
||||
trial.suggest_float("x", 0.1, 1.0).unwrap();
|
||||
let result = trial.suggest_float_log("x", 0.1, 1.0); // Same bounds but log scale
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -391,7 +370,7 @@ fn test_parameter_conflict_float_vs_step() {
|
||||
trial.suggest_float("x", 0.0, 1.0).unwrap();
|
||||
let result = trial.suggest_float_step("x", 0.0, 1.0, 0.1); // Same bounds but with step
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -401,7 +380,7 @@ fn test_parameter_conflict_int_different_bounds() {
|
||||
trial.suggest_int("n", 1, 10).unwrap();
|
||||
let result = trial.suggest_int("n", 1, 20); // Different upper bound
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -411,7 +390,7 @@ fn test_parameter_conflict_int_vs_log() {
|
||||
trial.suggest_int("n", 1, 100).unwrap();
|
||||
let result = trial.suggest_int_log("n", 1, 100); // Same bounds but log scale
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -421,7 +400,7 @@ fn test_parameter_conflict_categorical_different_n_choices() {
|
||||
trial.suggest_categorical("opt", &["a", "b", "c"]).unwrap();
|
||||
let result = trial.suggest_categorical("opt", &["x", "y"]); // Different number of choices
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -431,7 +410,7 @@ fn test_parameter_conflict_float_vs_int() {
|
||||
trial.suggest_float("x", 0.0, 10.0).unwrap();
|
||||
let result = trial.suggest_int("x", 0, 10); // Different type
|
||||
|
||||
assert!(matches!(result, Err(Error::ParameterConflict { .. })));
|
||||
assert!(matches!(result, Err(TpeError::ParameterConflict { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -442,7 +421,7 @@ fn test_parameter_conflict_returns_name() {
|
||||
let result = trial.suggest_float("my_param", 0.0, 2.0);
|
||||
|
||||
match result {
|
||||
Err(Error::ParameterConflict { name, .. }) => {
|
||||
Err(TpeError::ParameterConflict { name, .. }) => {
|
||||
assert_eq!(name, "my_param");
|
||||
}
|
||||
_ => panic!("expected ParameterConflict error"),
|
||||
@@ -460,7 +439,7 @@ fn test_empty_categorical_returns_error() {
|
||||
|
||||
let result = trial.suggest_categorical("opt", empty);
|
||||
|
||||
assert!(matches!(result, Err(Error::EmptyChoices)));
|
||||
assert!(matches!(result, Err(TpeError::EmptyChoices)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -470,7 +449,7 @@ fn test_empty_categorical_vec_returns_error() {
|
||||
|
||||
let result = trial.suggest_categorical("numbers", &empty);
|
||||
|
||||
assert!(matches!(result, Err(Error::EmptyChoices)));
|
||||
assert!(matches!(result, Err(TpeError::EmptyChoices)));
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -484,7 +463,7 @@ fn test_study_basic_workflow() {
|
||||
study
|
||||
.optimize(10, |trial| {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
Ok::<_, Error>(x * x)
|
||||
Ok::<_, TpeError>(x * x)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -521,7 +500,7 @@ fn test_no_completed_trials_error() {
|
||||
let study: Study<f64> = Study::new(Direction::Minimize);
|
||||
|
||||
let result = study.best_trial();
|
||||
assert!(matches!(result, Err(Error::NoCompletedTrials)));
|
||||
assert!(matches!(result, Err(TpeError::NoCompletedTrials)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,11 +509,11 @@ fn test_invalid_bounds_errors() {
|
||||
|
||||
// low > high for float
|
||||
let result = trial.suggest_float("x", 10.0, 5.0);
|
||||
assert!(matches!(result, Err(Error::InvalidBounds { .. })));
|
||||
assert!(matches!(result, Err(TpeError::InvalidBounds { .. })));
|
||||
|
||||
// low > high for int
|
||||
let result = trial.suggest_int("n", 100, 50);
|
||||
assert!(matches!(result, Err(Error::InvalidBounds { .. })));
|
||||
assert!(matches!(result, Err(TpeError::InvalidBounds { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -543,14 +522,14 @@ fn test_invalid_log_bounds_errors() {
|
||||
|
||||
// low <= 0 for log float
|
||||
let result = trial.suggest_float_log("x", 0.0, 1.0);
|
||||
assert!(matches!(result, Err(Error::InvalidLogBounds)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidLogBounds)));
|
||||
|
||||
let result = trial.suggest_float_log("y", -1.0, 1.0);
|
||||
assert!(matches!(result, Err(Error::InvalidLogBounds)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidLogBounds)));
|
||||
|
||||
// low < 1 for log int
|
||||
let result = trial.suggest_int_log("n", 0, 100);
|
||||
assert!(matches!(result, Err(Error::InvalidLogBounds)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidLogBounds)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -559,23 +538,19 @@ fn test_invalid_step_errors() {
|
||||
|
||||
// step <= 0 for float
|
||||
let result = trial.suggest_float_step("x", 0.0, 1.0, 0.0);
|
||||
assert!(matches!(result, Err(Error::InvalidStep)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidStep)));
|
||||
|
||||
let result = trial.suggest_float_step("y", 0.0, 1.0, -0.1);
|
||||
assert!(matches!(result, Err(Error::InvalidStep)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidStep)));
|
||||
|
||||
// step <= 0 for int
|
||||
let result = trial.suggest_int_step("n", 0, 100, 0);
|
||||
assert!(matches!(result, Err(Error::InvalidStep)));
|
||||
assert!(matches!(result, Err(TpeError::InvalidStep)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tpe_with_categorical_parameter() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Maximize, sampler);
|
||||
|
||||
@@ -592,7 +567,7 @@ fn test_tpe_with_categorical_parameter() {
|
||||
"cubic" => -((x - 1.0).powi(2)) + 10.0, // peak at x=1, max value 10
|
||||
_ => unreachable!(),
|
||||
};
|
||||
Ok::<_, Error>(value)
|
||||
Ok::<_, TpeError>(value)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -607,11 +582,7 @@ fn test_tpe_with_categorical_parameter() {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_with_integer_parameters() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(789)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(789).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -619,7 +590,7 @@ fn test_tpe_with_integer_parameters() {
|
||||
study
|
||||
.optimize_with_sampler(30, |trial| {
|
||||
let n = trial.suggest_int("n", 1, 10)?;
|
||||
Ok::<_, Error>(((n - 7) as f64).powi(2))
|
||||
Ok::<_, TpeError>(((n - 7) as f64).powi(2))
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -647,7 +618,7 @@ fn test_callback_early_stopping() {
|
||||
|trial| {
|
||||
trials_run.set(trials_run.get() + 1);
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
},
|
||||
|_study, _trial| {
|
||||
// Stop after 5 trials
|
||||
@@ -670,7 +641,7 @@ fn test_study_trials_iteration() {
|
||||
study
|
||||
.optimize(5, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 1.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -762,7 +733,7 @@ fn test_best_value() {
|
||||
study
|
||||
.optimize(10, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -785,18 +756,14 @@ fn test_study_set_sampler() {
|
||||
let mut study: Study<f64> = Study::new(Direction::Minimize);
|
||||
|
||||
// Initially uses RandomSampler, now switch to TPE
|
||||
let tpe = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let tpe = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
study.set_sampler(tpe);
|
||||
|
||||
// Should work with the new sampler
|
||||
study
|
||||
.optimize_with_sampler(10, |trial| {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
Ok::<_, Error>(x * x)
|
||||
Ok::<_, TpeError>(x * x)
|
||||
})
|
||||
.expect("optimization should succeed with new sampler");
|
||||
|
||||
@@ -811,7 +778,7 @@ fn test_study_with_i32_value_type() {
|
||||
study
|
||||
.optimize(10, |trial| {
|
||||
let x = trial.suggest_int("x", -10, 10)?;
|
||||
Ok::<_, Error>(x.abs() as i32)
|
||||
Ok::<_, TpeError>(x.abs() as i32)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -828,7 +795,7 @@ fn test_optimize_all_trials_fail() {
|
||||
let result = study.optimize(5, |_trial| Err::<f64, &str>("always fails"));
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -846,7 +813,7 @@ fn test_optimize_with_callback_all_trials_fail() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -858,7 +825,7 @@ fn test_optimize_with_sampler_all_trials_fail() {
|
||||
let result = study.optimize_with_sampler(5, |_trial| Err::<f64, &str>("always fails"));
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -876,7 +843,7 @@ fn test_optimize_with_callback_sampler_all_trials_fail() {
|
||||
);
|
||||
|
||||
assert!(
|
||||
matches!(result, Err(Error::NoCompletedTrials)),
|
||||
matches!(result, Err(TpeError::NoCompletedTrials)),
|
||||
"should return NoCompletedTrials when all trials fail"
|
||||
);
|
||||
}
|
||||
@@ -896,17 +863,17 @@ fn test_trial_debug_format() {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_sampler_builder_default_trait() {
|
||||
use optimizer::sampler::tpe::TpeSamplerBuilder;
|
||||
use optimizer::TpeSamplerBuilder;
|
||||
|
||||
let builder = TpeSamplerBuilder::default();
|
||||
let sampler = builder.build().unwrap();
|
||||
let sampler = builder.build();
|
||||
|
||||
// Should have default values
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
study
|
||||
.optimize_with_sampler(5, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 1.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -921,7 +888,7 @@ fn test_tpe_sampler_default_trait() {
|
||||
study
|
||||
.optimize_with_sampler(5, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 1.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -934,15 +901,14 @@ fn test_tpe_with_fixed_kde_bandwidth() {
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.kde_bandwidth(0.5)
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(20, |trial| {
|
||||
let x = trial.suggest_float("x", -5.0, 5.0)?;
|
||||
Ok::<_, Error>(x * x)
|
||||
Ok::<_, TpeError>(x * x)
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -951,9 +917,9 @@ fn test_tpe_with_fixed_kde_bandwidth() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "kde_bandwidth must be positive")]
|
||||
fn test_tpe_sampler_invalid_kde_bandwidth() {
|
||||
let result = TpeSampler::with_config(0.25, 10, 24, Some(-1.0), None);
|
||||
assert!(matches!(result, Err(Error::InvalidBandwidth(_))));
|
||||
TpeSampler::with_config(0.25, 10, 24, Some(-1.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -962,15 +928,14 @@ fn test_tpe_split_trials_with_two_trials() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(2) // TPE kicks in after 2 trials
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(5, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.expect("optimization should succeed with small history");
|
||||
|
||||
@@ -979,11 +944,7 @@ fn test_tpe_split_trials_with_two_trials() {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_with_log_scale_int() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -991,7 +952,7 @@ fn test_tpe_with_log_scale_int() {
|
||||
.optimize_with_sampler(20, |trial| {
|
||||
let batch_size = trial.suggest_int_log("batch_size", 1, 1024)?;
|
||||
// Optimal around batch_size = 32
|
||||
Ok::<_, Error>(((batch_size as f64).log2() - 5.0).powi(2))
|
||||
Ok::<_, TpeError>(((batch_size as f64).log2() - 5.0).powi(2))
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -1001,11 +962,7 @@ fn test_tpe_with_log_scale_int() {
|
||||
|
||||
#[test]
|
||||
fn test_tpe_with_step_distributions() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
let sampler = TpeSampler::builder().seed(42).n_startup_trials(5).build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -1013,7 +970,7 @@ fn test_tpe_with_step_distributions() {
|
||||
.optimize_with_sampler(20, |trial| {
|
||||
let x = trial.suggest_float_step("x", 0.0, 10.0, 0.5)?;
|
||||
let n = trial.suggest_int_step("n", 0, 100, 10)?;
|
||||
Ok::<_, Error>((x - 5.0).powi(2) + ((n - 50) as f64).powi(2))
|
||||
Ok::<_, TpeError>((x - 5.0).powi(2) + ((n - 50) as f64).powi(2))
|
||||
})
|
||||
.expect("optimization should succeed");
|
||||
|
||||
@@ -1083,8 +1040,7 @@ fn test_tpe_empty_good_or_bad_values_fallback() {
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.gamma(0.1) // Very small gamma means few "good" trials
|
||||
.build()
|
||||
.unwrap();
|
||||
.build();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
@@ -1092,7 +1048,7 @@ fn test_tpe_empty_good_or_bad_values_fallback() {
|
||||
study
|
||||
.optimize_with_sampler(10, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1100,7 +1056,7 @@ fn test_tpe_empty_good_or_bad_values_fallback() {
|
||||
study
|
||||
.optimize_with_sampler(5, |trial| {
|
||||
let y = trial.suggest_float("y", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(y)
|
||||
Ok::<_, TpeError>(y)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1118,7 +1074,7 @@ fn test_callback_early_stopping_on_first_trial() {
|
||||
100,
|
||||
|trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
},
|
||||
|_study, _trial| {
|
||||
// Stop immediately after first trial
|
||||
@@ -1142,7 +1098,7 @@ fn test_callback_sampler_early_stopping() {
|
||||
100,
|
||||
|trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
},
|
||||
|study, _trial| {
|
||||
if study.n_trials() >= 3 {
|
||||
@@ -1178,7 +1134,7 @@ fn test_best_trial_with_nan_values() {
|
||||
study
|
||||
.optimize(5, |trial| {
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
Ok::<_, Error>(x)
|
||||
Ok::<_, TpeError>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -1186,228 +1142,3 @@ fn test_best_trial_with_nan_values() {
|
||||
let best = study.best_trial();
|
||||
assert!(best.is_ok());
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests for suggest_bool
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_suggest_bool_returns_boolean() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let flag = trial.suggest_bool("use_feature").unwrap();
|
||||
assert!(flag == true || flag == false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_bool_caching() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let b1 = trial.suggest_bool("flag").unwrap();
|
||||
let b2 = trial.suggest_bool("flag").unwrap();
|
||||
|
||||
assert_eq!(b1, b2, "repeated suggest_bool should return cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_bool_multiple_parameters() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let a = trial.suggest_bool("use_dropout").unwrap();
|
||||
let b = trial.suggest_bool("use_batchnorm").unwrap();
|
||||
let c = trial.suggest_bool("use_skip_connections").unwrap();
|
||||
|
||||
// All should be cached independently
|
||||
assert_eq!(a, trial.suggest_bool("use_dropout").unwrap());
|
||||
assert_eq!(b, trial.suggest_bool("use_batchnorm").unwrap());
|
||||
assert_eq!(c, trial.suggest_bool("use_skip_connections").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_bool_in_optimization() {
|
||||
let study: Study<f64> = Study::new(Direction::Minimize);
|
||||
|
||||
study
|
||||
.optimize(10, |trial| {
|
||||
let use_feature = trial.suggest_bool("use_feature")?;
|
||||
let x = trial.suggest_float("x", 0.0, 10.0)?;
|
||||
|
||||
// Objective depends on boolean flag
|
||||
let value = if use_feature { x } else { x * 2.0 };
|
||||
Ok::<_, Error>(value)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(study.n_trials(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_bool_with_tpe() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(20, |trial| {
|
||||
let use_large = trial.suggest_bool("use_large")?;
|
||||
let base = if use_large { 10.0 } else { 1.0 };
|
||||
let x = trial.suggest_float("x", 0.0, base)?;
|
||||
Ok::<_, Error>(x)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let best = study.best_trial().unwrap();
|
||||
// Best should prefer use_large=false for smaller range
|
||||
assert!(best.value < 5.0);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests for suggest_range
|
||||
// =============================================================================
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_float_exclusive() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let x = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
assert!(x >= 0.0 && x <= 1.0, "value {x} out of range 0.0..1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_float_inclusive() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let x = trial.suggest_range("x", 0.0..=1.0).unwrap();
|
||||
assert!(x >= 0.0 && x <= 1.0, "value {x} out of range 0.0..=1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_int_exclusive() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
// 1..10 means 1 to 9 inclusive
|
||||
let n = trial.suggest_range("n", 1_i64..10).unwrap();
|
||||
assert!(n >= 1 && n <= 9, "value {n} out of range 1..10 (should be 1-9)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_int_inclusive() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
// 1..=10 means 1 to 10 inclusive
|
||||
let n = trial.suggest_range("n", 1_i64..=10).unwrap();
|
||||
assert!(
|
||||
n >= 1 && n <= 10,
|
||||
"value {n} out of range 1..=10 (should be 1-10)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_caching_float() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let x1 = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
let x2 = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
|
||||
assert_eq!(x1, x2, "repeated suggest_range should return cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_caching_int() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let n1 = trial.suggest_range("n", 1_i64..=100).unwrap();
|
||||
let n2 = trial.suggest_range("n", 1_i64..=100).unwrap();
|
||||
|
||||
assert_eq!(n1, n2, "repeated suggest_range should return cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_multiple_parameters() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let x = trial.suggest_range("x", 0.0..1.0).unwrap();
|
||||
let y = trial.suggest_range("y", -5.0..=5.0).unwrap();
|
||||
let n = trial.suggest_range("n", 1_i64..10).unwrap();
|
||||
let m = trial.suggest_range("m", 100_i64..=200).unwrap();
|
||||
|
||||
// All should be cached independently
|
||||
assert_eq!(x, trial.suggest_range("x", 0.0..1.0).unwrap());
|
||||
assert_eq!(y, trial.suggest_range("y", -5.0..=5.0).unwrap());
|
||||
assert_eq!(n, trial.suggest_range("n", 1_i64..10).unwrap());
|
||||
assert_eq!(m, trial.suggest_range("m", 100_i64..=200).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_in_optimization() {
|
||||
let study: Study<f64> = Study::new(Direction::Minimize);
|
||||
|
||||
study
|
||||
.optimize(10, |trial| {
|
||||
let x = trial.suggest_range("x", -10.0..10.0)?;
|
||||
let n = trial.suggest_range("n", 1_i64..=5)?;
|
||||
Ok::<_, Error>(x * x + n as f64)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(study.n_trials(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_with_tpe() {
|
||||
let sampler = TpeSampler::builder()
|
||||
.seed(42)
|
||||
.n_startup_trials(5)
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
|
||||
|
||||
study
|
||||
.optimize_with_sampler(30, |trial| {
|
||||
let x = trial.suggest_range("x", -5.0..=5.0)?;
|
||||
let n = trial.suggest_range("n", 1_i64..=10)?;
|
||||
Ok::<_, Error>(x * x + (n as f64 - 5.0).powi(2))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let best = study.best_trial().unwrap();
|
||||
// TPE should find near-optimal solution
|
||||
assert!(best.value < 10.0, "TPE should find good solution");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_empty_int_range_error() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
// 5..5 is empty (no valid integers)
|
||||
let result = trial.suggest_range("n", 5_i64..5);
|
||||
assert!(
|
||||
matches!(result, Err(Error::InvalidBounds { .. })),
|
||||
"empty range should return InvalidBounds error"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_single_value_int() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
// 5..=5 has exactly one value: 5
|
||||
let n = trial.suggest_range("n", 5_i64..=5).unwrap();
|
||||
assert_eq!(n, 5, "single-value range should return that value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggest_range_single_value_float() {
|
||||
let mut trial = Trial::new(0);
|
||||
|
||||
let x = trial.suggest_range("x", 3.14..=3.14).unwrap();
|
||||
assert!(
|
||||
(x - 3.14).abs() < f64::EPSILON,
|
||||
"single-value range should return that value"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user