34 Commits

Author SHA1 Message Date
Manuel Raimann 3581187cd3 chore: release v0.9.1 2026-02-12 08:17:28 +01:00
Manuel Raimann 349ffcbed3 fix: update journal file handling to support reading and writing 2026-02-12 08:13:48 +01:00
Manuel Raimann 88e2ac0ff0 chore: release v0.9.0 2026-02-12 00:01:44 +01:00
Manuel Raimann d122582bf7 Revert "feat: add SQLite storage backend for multi-process optimization"
This reverts commit ed80c59795.

# Conflicts:
#	src/lib.rs
#	src/storage/sqlite.rs
2026-02-12 00:01:27 +01:00
Manuel Raimann 705687a42e feat: add NSGA-III and MOEA/D samplers for many-objective optimization
Extract shared evolutionary algorithm infrastructure (genetic operators,
candidate management, Das-Dennis reference points) from NSGA-II into a
new genetic.rs module, then build two new multi-objective samplers on top:

- NSGA-III: reference-point-based niching for well-distributed fronts
  on 3+ objective problems (Das-Dennis structured points, normalization,
  perpendicular distance association, niching selection)

- MOEA/D: decomposition-based optimization with three scalarization
  methods (Tchebycheff, WeightedSum, PBI), weight-vector neighborhoods,
  and neighborhood-based mating selection

Both implement MultiObjectiveSampler with builder pattern, seeded RNG,
and SBX crossover / polynomial mutation via the shared genetic module.
2026-02-11 23:53:20 +01:00
Manuel Raimann 03deedd775 feat: add Zlib license to deny.toml 2026-02-11 23:40:22 +01:00
Manuel Raimann 047a788afd fix: remove unnecessary feature flag for visualization example in CI 2026-02-11 23:39:43 +01:00
Manuel Raimann d2e36ec304 refactor: move next_trial_id counter from Study into Storage trait 2026-02-11 23:32:15 +01:00
Manuel Raimann d14f4e0792 feat: add StudyBuilder for fluent study construction 2026-02-11 23:23:56 +01:00
Manuel Raimann ed80c59795 feat: add SQLite storage backend for multi-process optimization 2026-02-11 23:21:47 +01:00
Manuel Raimann ed1e9e31da fix: use atomic counter for temp paths in journal tests
On Windows, SystemTime::now().as_nanos() has ~15ms resolution,
causing parallel tests to generate identical file paths and
corrupt each other's data.
2026-02-11 23:17:09 +01:00
Manuel Raimann 36c6262dbc refactor: remove visualization and fanova feature flags
Always build visualization and fanova code unconditionally.
2026-02-11 23:06:28 +01:00
Manuel Raimann 0c295b4bc7 ci: simplify test command by removing feature matrix 2026-02-11 23:03:38 +01:00
Manuel Raimann 0a6f2345a8 feat: add Storage trait and JSONL journal backend
Replace the internal Vec<CompletedTrial<V>> with a pluggable Storage<V>
trait. MemoryStorage is the default (no behavior change for existing
users). Behind the `journal` feature flag, JournalStorage persists
trials to a JSONL file with fs2 file locking for multi-process safety.

- Storage<V> trait with push(), trials_arc(), refresh() methods
- MemoryStorage<V> wraps Arc<RwLock<Vec<CompletedTrial<V>>>>
- JournalStorage<V> appends JSON lines with exclusive file locks
- Study::with_sampler_and_storage() general constructor
- Study::with_journal() convenience constructor (journal feature)
- Refresh from storage on create_trial() for multi-process discovery
- MSRV bumped to 1.89
2026-02-11 22:58:38 +01:00
Manuel Raimann 24a0bdd473 feat: add Differential Evolution sampler
Population-based optimizer with mutation + crossover. Supports three
strategies (Rand1, Best1, CurrentToBest1), configurable F and CR,
and auto-sized populations. No extra dependencies needed.
2026-02-11 22:04:23 +01:00
Manuel Raimann 8239cc58a1 refactor: replace rand 0.10 with fastrand 2.3
fastrand is smaller, faster, and has no dependencies. Add rng_util
helper for f64 range generation since fastrand lacks a built-in
equivalent. Migrate all samplers, KDE modules, and fANOVA to use
fastrand's concrete Rng type instead of rand's trait-based generics.
2026-02-11 21:54:34 +01:00
Manuel Raimann 906e5296de fix: seed importance tests to eliminate flakiness
The no_effect_parameter test (and others) used unseeded random sampling,
causing spurious correlations between the noise parameter and the
objective in unlucky runs. Seed all importance tests with
RandomSampler::with_seed(42) for deterministic results.
2026-02-11 21:30:21 +01:00
Manuel Raimann 947701e83f feat: add Gaussian Process sampler with Expected Improvement
Implement a classical Bayesian optimization sampler using a GP surrogate
with Matérn 5/2 kernel and Expected Improvement acquisition function.
Feature-gated behind `gp = ["dep:nalgebra"]`.
2026-02-11 21:27:40 +01:00
Manuel Raimann b3bf4ccb71 chore: parallelize feature-check CI with 4 matrix partitions
Use cargo hack's --partition flag to split the feature powerset across
4 parallel jobs, reducing wall-clock time for the feature-check step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:13:39 +01:00
Manuel Raimann 7bf64d6cf6 chore: add bench-check to CI workflow 2026-02-11 21:06:56 +01:00
Manuel Raimann 624283c766 chore: release v0.8.1 2026-02-11 21:05:12 +01:00
Manuel Raimann 64c7aa2448 fix: resolve broken rustdoc intra-doc links for Error::NoCompletedTrials 2026-02-11 21:04:40 +01:00
Manuel Raimann abb1b4c229 chore: release v0.8.0 2026-02-11 20:53:30 +01:00
Manuel Raimann dff58340a4 feat: add fANOVA parameter importance via random forest
Implement functional ANOVA decomposition behind the `fanova` feature
flag. A self-contained random forest is trained on trial data, then
marginal predictions are used to compute per-parameter main effects
and pairwise interaction effects, normalized to sum to 1.0.

Adds Study::fanova() / fanova_with_config(), FanovaResult, and
FanovaConfig. Includes unit and integration tests covering dominant
parameters, interaction detection, consistency with correlation-based
importance, and error handling.
2026-02-11 20:51:24 +01:00
Manuel Raimann e359392a00 feat: add HTML visualization reports with Plotly.js charts
Add a `visualization` feature flag that generates self-contained HTML
reports with interactive Plotly.js charts for offline visualization of
optimization results. Charts include optimization history, slice plots,
parallel coordinates, parameter importance, trial timeline, and
intermediate values (learning curves).
2026-02-11 20:12:35 +01:00
Manuel Raimann 0e54356345 fix: rename variable to pass typos check 2026-02-11 20:04:51 +01:00
Manuel Raimann d851aad548 feat: add CSV and JSON data export for visualization
Add to_csv(), export_csv(), and export_json() methods to Study for
exporting trial data to external visualization tools. CSV export works
without extra dependencies; JSON export requires the serde feature.
2026-02-11 20:02:35 +01:00
Manuel Raimann 95402dc9b6 feat: add Pareto front analysis utilities
Expose public pareto module with hypervolume indicator, non-dominated
sorting, pareto front filtering, and crowding distance functions.
2026-02-11 19:58:07 +01:00
Manuel Raimann f873722763 ci: add benchmark CI jobs and missing examples
Add bench-check job (push): smoke-tests benchmarks compile and run.
Add bench-compare job (PRs): runs benchmarks on base and head commits,
compares with critcmp, and posts a comparison comment on the PR.
Add missing examples (benchmark_convergence, parameter_api) to the
examples job.
2026-02-11 19:50:24 +01:00
Manuel Raimann ba31df69c7 feat: add Multi-Objective TPE (MOTPE) sampler
Extend TPE to handle multi-objective optimization using Pareto-based
splitting. MOTPE uses non-dominated sorting to define "good" (front 0)
vs "bad" (dominated) regions for the KDE models, replacing the
single-objective gamma-based split.
2026-02-11 19:43:01 +01:00
Manuel Raimann bcc4549e66 feat: add multi-objective optimization with NSGA-II
Add MultiObjectiveStudy for optimizing multiple objectives simultaneously,
backed by NSGA-II (Non-dominated Sorting Genetic Algorithm II) with SBX
crossover, polynomial mutation, and constraint-aware dominance.

New public API:
- MultiObjectiveStudy with optimize(), pareto_front(), ask()/tell()
- MultiObjectiveTrial with get(), is_feasible(), user attributes
- MultiObjectiveSampler trait for custom MO samplers
- Nsga2Sampler with builder for population size, crossover/mutation params
- ObjectiveDimensionMismatch error variant
2026-02-11 19:35:18 +01:00
Manuel Raimann 7d79111d81 chore: release v0.7.2 2026-02-11 19:23:45 +01:00
Manuel Raimann a9ea097377 chore: add advisory ignore for unmaintained transitive dependency 2026-02-11 19:23:31 +01:00
Manuel Raimann 7d70220da0 chore: update nalgebra dependency to version 0.34 2026-02-11 19:18:42 +01:00
39 changed files with 10409 additions and 229 deletions
+112 -17
View File
@@ -8,6 +8,7 @@ on:
permissions:
contents: read
pull-requests: write
env:
CARGO_TERM_COLOR: always
@@ -41,11 +42,6 @@ jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
features:
- ""
- "async"
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -54,12 +50,7 @@ jobs:
rustup update stable
- uses: Swatinem/rust-cache@v2
- name: Run tests
run: |
if [ -z "${{ matrix.features }}" ]; then
cargo test --verbose
else
cargo test --verbose --features "${{ matrix.features }}"
fi
run: cargo test --verbose --all-features --all-targets
examples:
name: Examples
@@ -73,8 +64,14 @@ jobs:
- uses: Swatinem/rust-cache@v2
- name: Run sync example (ml_hyperparameter_tuning)
run: cargo run --example ml_hyperparameter_tuning
- name: Run sync example (benchmark_convergence)
run: cargo run --example benchmark_convergence
- name: Run derive example (parameter_api)
run: cargo run --example parameter_api --features derive
- name: Run async example (async_api_optimization)
run: cargo run --example async_api_optimization --features async
- name: Run visualization example (visualization_demo)
run: cargo run --example visualization_demo
docs:
name: Docs
@@ -91,8 +88,12 @@ jobs:
RUSTDOCFLAGS: -D warnings
feature-check:
name: Feature Combinations
name: Feature Combinations (${{ matrix.partition }}/4)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
partition: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
- name: Install Rust
@@ -103,7 +104,7 @@ jobs:
uses: taiki-e/install-action@cargo-hack
- uses: Swatinem/rust-cache@v2
- name: Check feature powerset
run: cargo hack check --feature-powerset --no-dev-deps
run: cargo hack check --feature-powerset --no-dev-deps --partition ${{ matrix.partition }}/4
coverage:
name: Coverage
@@ -128,14 +129,14 @@ jobs:
fail_ci_if_error: true
msrv:
name: MSRV (1.88)
name: MSRV (1.89)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust 1.88
- name: Install Rust 1.89
run: |
rustup override set 1.88
rustup update 1.88
rustup override set 1.89
rustup update 1.89
- uses: Swatinem/rust-cache@v2
- name: Check compilation
run: cargo check --all-features
@@ -250,6 +251,99 @@ jobs:
- name: Typos Check
run: typos src/
bench-check:
name: Benchmark Smoke Test
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Install Rust
run: |
rustup override set stable
rustup update stable
- uses: Swatinem/rust-cache@v2
- name: Run benchmarks (smoke test)
run: cargo bench --all-features --bench samplers --bench optimization
bench-compare:
name: Benchmark Comparison
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Install Rust
run: |
rustup override set stable
rustup update stable
- name: Install critcmp
run: cargo install critcmp
# --- Run benchmarks on the BASE (target) branch ---
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
clean: false
- uses: Swatinem/rust-cache@v2
with:
key: bench-base
- name: Bench baseline
run: cargo bench --all-features --bench samplers --bench optimization -- --save-baseline base
# --- Run benchmarks on the HEAD (PR) branch ---
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
clean: false
- uses: Swatinem/rust-cache@v2
with:
key: bench-head
- name: Bench PR head
run: cargo bench --all-features --bench samplers --bench optimization -- --save-baseline head
# --- Compare and post comment ---
- name: Compare benchmarks
id: compare
run: |
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "result<<$EOF" >> "$GITHUB_OUTPUT"
critcmp base head --color never >> "$GITHUB_OUTPUT"
echo "$EOF" >> "$GITHUB_OUTPUT"
- name: Find existing comment
uses: peter-evans/find-comment@v4
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
comment-author: github-actions[bot]
body-includes: "## Benchmark Comparison"
- name: Post or update PR comment
uses: peter-evans/create-or-update-comment@v5
with:
comment-id: ${{ steps.find-comment.outputs.comment-id }}
issue-number: ${{ github.event.pull_request.number }}
edit-mode: replace
body: |
## Benchmark Comparison
**Base:** `${{ github.event.pull_request.base.sha }}` | **Head:** `${{ github.event.pull_request.head.sha }}`
<details>
<summary>Click to expand full results</summary>
```
${{ steps.compare.outputs.result }}
```
</details>
> Benchmarks run on `ubuntu-latest` via [Criterion](https://github.com/bheisler/criterion.rs) + [critcmp](https://github.com/BurntSushi/critcmp).
> Results may vary due to shared CI runners. Look for consistent >5% changes.
publish:
name: Publish to crates.io
runs-on: ubuntu-latest
@@ -270,6 +364,7 @@ jobs:
- cross-platform
- cross-targets
- typos
- bench-check
steps:
- uses: actions/checkout@v6
- name: Install Rust
+15 -4
View File
@@ -3,9 +3,9 @@ members = ["optimizer-derive"]
[package]
name = "optimizer"
version = "0.7.1"
version = "0.9.1"
edition = "2024"
rust-version = "1.88"
rust-version = "1.89"
license = "MIT"
authors = ["Manuel Raimann <raimannma@outlook.de"]
description = "A Rust library for optimization algorithms."
@@ -16,7 +16,7 @@ categories = ["algorithm", "science", "data-structures"]
readme = "README.md"
[dependencies]
rand = "0.10"
fastrand = "2.3"
thiserror = "2"
parking_lot = "0.12"
tokio = { version = "1", features = ["sync", "rt-multi-thread"], optional = true }
@@ -25,16 +25,19 @@ serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1.29", optional = true }
sobol_burley = { version = "0.5", optional = true }
nalgebra = { version = "0.33", optional = true }
nalgebra = { version = "0.34", optional = true }
fs2 = { version = "0.4", optional = true }
[features]
default = []
async = ["dep:tokio"]
derive = ["dep:optimizer-derive"]
serde = ["dep:serde", "dep:serde_json"]
journal = ["dep:fs2", "serde"]
tracing = ["dep:tracing"]
sobol = ["dep:sobol_burley"]
cma-es = ["dep:nalgebra"]
gp = ["dep:nalgebra"]
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
@@ -63,3 +66,11 @@ path = "examples/ml_hyperparameter_tuning.rs"
name = "parameter_api"
path = "examples/parameter_api.rs"
required-features = ["derive"]
[[example]]
name = "visualization_demo"
path = "examples/visualization_demo.rs"
[[test]]
name = "journal_tests"
required-features = ["journal"]
+6 -2
View File
@@ -1,7 +1,11 @@
[advisories]
version = 2
db-path = "~/.cargo/advisory-db"
ignore = []
ignore = [
# paste is unmaintained but it's a transitive dep via simba -> nalgebra
# with no fix available upstream yet
"RUSTSEC-2024-0436",
]
[licenses]
version = 2
@@ -9,8 +13,8 @@ allow = [
"MIT",
"Apache-2.0",
"Apache-2.0 WITH LLVM-exception",
"BSD-2-Clause",
"Unicode-3.0",
"Zlib",
]
confidence-threshold = 0.8
+45
View File
@@ -0,0 +1,45 @@
use optimizer::prelude::*;
fn main() {
// Multi-parameter optimization with TPE sampler.
let sampler = TpeSampler::builder().seed(42).build().unwrap();
let mut study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
study.set_pruner(MedianPruner::new(Direction::Minimize));
let lr = FloatParam::new(1e-5, 1e-1)
.log_scale()
.name("learning_rate");
let n_layers = IntParam::new(1, 5).name("n_layers");
let dropout = FloatParam::new(0.0, 0.5).step(0.05).name("dropout");
let batch_size = CategoricalParam::new(vec![16, 32, 64, 128]).name("batch_size");
study
.optimize(80, |trial| {
let lr_val = lr.suggest(trial)?;
let layers = n_layers.suggest(trial)?;
let drop = dropout.suggest(trial)?;
let bs = batch_size.suggest(trial)?;
// Simulate training with intermediate reporting.
let mut loss = 1.0;
for epoch in 0..10 {
loss *= 0.7 + 0.3 * lr_val.ln().abs() / 12.0;
loss += drop * 0.05;
loss += (1.0 / bs as f64) * 0.1;
loss -= layers as f64 * 0.02;
trial.report(epoch, loss);
if trial.should_prune() {
return Err(TrialPruned.into());
}
}
Ok::<_, Error>(loss)
})
.unwrap();
println!("{}", study.summary());
let path = "optimization_report.html";
generate_html_report(&study, path).unwrap();
println!("\nReport saved to {path}");
}
+1 -1
View File
@@ -2,7 +2,7 @@
name = "optimizer-derive"
version = "0.1.0"
edition = "2024"
rust-version = "1.88"
rust-version = "1.89"
license = "MIT"
description = "Derive macros for the optimizer crate"
repository = "https://github.com/raimannma/rust-optimizer"
+14
View File
@@ -76,6 +76,15 @@ pub enum Error {
#[error("trial was pruned")]
TrialPruned,
/// Returned when the objective returns the wrong number of values.
#[error("objective dimension mismatch: expected {expected} values, got {got}")]
ObjectiveDimensionMismatch {
/// The expected number of objective values.
expected: usize,
/// The actual number of objective values returned.
got: usize,
},
/// Returned when an internal invariant is violated.
#[error("internal error: {0}")]
Internal(&'static str),
@@ -84,6 +93,11 @@ pub enum Error {
#[cfg(feature = "async")]
#[error("async task error: {0}")]
TaskError(String),
/// Returned when a storage operation fails.
#[cfg(feature = "journal")]
#[error("storage error: {0}")]
Storage(String),
}
pub type Result<T> = core::result::Result<T, Error>;
+547
View File
@@ -0,0 +1,547 @@
//! fANOVA (functional ANOVA) parameter importance via random forest.
//!
//! Decomposes the variance of the objective function into contributions
//! from individual parameters (main effects) and parameter interactions.
//!
//! The algorithm:
//! 1. Fits a random forest to `(parameters) -> objective_value`
//! 2. Applies functional ANOVA decomposition to the forest
//! 3. Computes main effects (single-parameter importance)
//! 4. Computes interaction effects (pairwise parameter importance)
/// Result of fANOVA analysis.
#[derive(Debug, Clone)]
pub struct FanovaResult {
/// Per-parameter importance (fraction of total variance explained).
/// Sorted by descending importance.
pub main_effects: Vec<(String, f64)>,
/// Pairwise interaction importance (fraction of total variance explained).
/// Sorted by descending importance.
pub interactions: Vec<((String, String), f64)>,
}
/// Configuration for fANOVA analysis.
#[derive(Debug, Clone)]
pub struct FanovaConfig {
/// Number of trees in the random forest (default: 64).
pub n_trees: usize,
/// Maximum depth of each tree. `None` for unlimited (default: `None`).
pub max_depth: Option<usize>,
/// Minimum samples required to split a node (default: 2).
pub min_samples_split: usize,
/// Minimum samples required in a leaf node (default: 1).
pub min_samples_leaf: usize,
/// Random seed for reproducibility (default: `Some(42)`).
pub seed: Option<u64>,
}
impl Default for FanovaConfig {
fn default() -> Self {
Self {
n_trees: 64,
max_depth: None,
min_samples_split: 2,
min_samples_leaf: 1,
seed: Some(42),
}
}
}
// --- Decision Tree ---
/// A node in the regression tree (arena-allocated).
#[derive(Debug, Clone)]
enum TreeNode {
Leaf {
value: f64,
n_samples: usize,
},
Split {
feature: usize,
threshold: f64,
left: usize,
right: usize,
n_samples: usize,
},
}
/// A regression decision tree for fANOVA.
#[derive(Debug, Clone)]
struct DecisionTree {
nodes: Vec<TreeNode>,
}
impl DecisionTree {
/// Build a tree from the given data using the specified bootstrap indices.
fn build(
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
config: &FanovaConfig,
rng: &mut fastrand::Rng,
) -> Self {
let mut tree = Self { nodes: Vec::new() };
tree.build_node(data, targets, indices, 0, config, rng);
tree
}
#[allow(clippy::cast_precision_loss)]
fn build_node(
&mut self,
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
depth: usize,
config: &FanovaConfig,
rng: &mut fastrand::Rng,
) -> usize {
let n = indices.len();
let mean = indices.iter().map(|&i| targets[i]).sum::<f64>() / n as f64;
// Stopping conditions
if n < config.min_samples_split || config.max_depth.is_some_and(|d| depth >= d) {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
// Pure node check (all targets identical)
#[allow(clippy::float_cmp)]
if indices.iter().all(|&i| targets[i] == targets[indices[0]]) {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let n_features = data[0].len();
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let max_features = ((n_features as f64).sqrt().ceil() as usize)
.max(1)
.min(n_features);
let candidates = partial_shuffle(n_features, max_features, rng);
// Total variance at this node
let total_var: f64 = indices.iter().map(|&i| (targets[i] - mean).powi(2)).sum();
if total_var == 0.0 {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let mut best_score = f64::NEG_INFINITY;
let mut best_feature = 0;
let mut best_threshold = 0.0;
for &feat in &candidates {
let mut values: Vec<f64> = indices.iter().map(|&i| data[i][feat]).collect();
values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal));
values.dedup();
if values.len() < 2 {
continue;
}
for w in values.windows(2) {
let threshold = f64::midpoint(w[0], w[1]);
let (l_sum, l_sq, l_n, r_sum, r_sq, r_n) =
split_stats(data, targets, indices, feat, threshold);
if l_n < config.min_samples_leaf || r_n < config.min_samples_leaf {
continue;
}
let l_var = l_sq - l_sum * l_sum / l_n as f64;
let r_var = r_sq - r_sum * r_sum / r_n as f64;
let score = total_var - l_var - r_var;
if score > best_score {
best_score = score;
best_feature = feat;
best_threshold = threshold;
}
}
}
if best_score <= 0.0 {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
let (left_indices, right_indices): (Vec<usize>, Vec<usize>) = indices
.iter()
.partition(|&&i| data[i][best_feature] <= best_threshold);
if left_indices.is_empty() || right_indices.is_empty() {
let idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: mean,
n_samples: n,
});
return idx;
}
// Reserve slot for this split node (placeholder replaced below)
let node_idx = self.nodes.len();
self.nodes.push(TreeNode::Leaf {
value: 0.0,
n_samples: 0,
});
let left = self.build_node(data, targets, &left_indices, depth + 1, config, rng);
let right = self.build_node(data, targets, &right_indices, depth + 1, config, rng);
self.nodes[node_idx] = TreeNode::Split {
feature: best_feature,
threshold: best_threshold,
left,
right,
n_samples: n,
};
node_idx
}
/// Compute marginal prediction for a given feature subset.
///
/// Features in `subset` use values from `feature_values`.
/// Features not in `subset` are marginalized by weighting branches
/// proportionally to their training-data fractions.
fn marginal_predict(&self, subset: &[usize], feature_values: &[f64]) -> f64 {
self.marginal_predict_at(0, subset, feature_values)
}
#[allow(clippy::cast_precision_loss)]
fn marginal_predict_at(&self, idx: usize, subset: &[usize], vals: &[f64]) -> f64 {
match self.nodes[idx] {
TreeNode::Leaf { value, .. } => value,
TreeNode::Split {
feature,
threshold,
left,
right,
n_samples,
} => {
if subset.contains(&feature) {
if vals[feature] <= threshold {
self.marginal_predict_at(left, subset, vals)
} else {
self.marginal_predict_at(right, subset, vals)
}
} else {
let l_n = self.n_samples(left) as f64;
let r_n = self.n_samples(right) as f64;
let total = n_samples as f64;
(l_n / total) * self.marginal_predict_at(left, subset, vals)
+ (r_n / total) * self.marginal_predict_at(right, subset, vals)
}
}
}
}
fn n_samples(&self, idx: usize) -> usize {
match self.nodes[idx] {
TreeNode::Leaf { n_samples, .. } | TreeNode::Split { n_samples, .. } => n_samples,
}
}
}
// --- Helper Functions ---
/// Select `k` random indices from `0..n` using partial Fisher-Yates shuffle.
fn partial_shuffle(n: usize, k: usize, rng: &mut fastrand::Rng) -> Vec<usize> {
let mut indices: Vec<usize> = (0..n).collect();
let k = k.min(n);
for i in 0..k {
let j = rng.usize(i..n);
indices.swap(i, j);
}
indices.truncate(k);
indices
}
/// Compute left/right split statistics for variance reduction.
#[allow(clippy::cast_precision_loss)]
fn split_stats(
data: &[Vec<f64>],
targets: &[f64],
indices: &[usize],
feature: usize,
threshold: f64,
) -> (f64, f64, usize, f64, f64, usize) {
let (mut l_sum, mut l_sq, mut l_n) = (0.0, 0.0, 0usize);
let (mut r_sum, mut r_sq, mut r_n) = (0.0, 0.0, 0usize);
for &i in indices {
let y = targets[i];
if data[i][feature] <= threshold {
l_sum += y;
l_sq += y * y;
l_n += 1;
} else {
r_sum += y;
r_sq += y * y;
r_n += 1;
}
}
(l_sum, l_sq, l_n, r_sum, r_sq, r_n)
}
/// Population variance of a slice.
#[allow(clippy::cast_precision_loss)]
fn variance(values: &[f64]) -> f64 {
if values.is_empty() {
return 0.0;
}
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n
}
// --- Public API ---
/// Run fANOVA analysis on pre-processed numerical data.
///
/// `data` is `n_samples` rows, each with `n_features` columns.
/// `targets` has one entry per sample.
/// `feature_names` maps feature index to human-readable name.
#[allow(clippy::cast_precision_loss)]
pub(crate) fn compute_fanova(
data: &[Vec<f64>],
targets: &[f64],
feature_names: &[String],
config: &FanovaConfig,
) -> FanovaResult {
let n_samples = data.len();
let n_features = data[0].len();
let mut rng: fastrand::Rng = config
.seed
.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
// Build random forest with bootstrap sampling
let trees: Vec<DecisionTree> = (0..config.n_trees)
.map(|_| {
let bootstrap: Vec<usize> = (0..n_samples).map(|_| rng.usize(0..n_samples)).collect();
DecisionTree::build(data, targets, &bootstrap, config, &mut rng)
})
.collect();
// Compute main effects: V_j = Var[E[f | x_j]]
let main_var: Vec<f64> = (0..n_features)
.map(|j| {
let subset = [j];
let preds: Vec<f64> = (0..n_samples)
.map(|i| {
trees
.iter()
.map(|t| t.marginal_predict(&subset, &data[i]))
.sum::<f64>()
/ trees.len() as f64
})
.collect();
variance(&preds)
})
.collect();
// Compute pairwise interaction effects: V_{j,k} - V_j - V_k
let mut interactions: Vec<((String, String), f64)> = Vec::new();
for j in 0..n_features {
for k in (j + 1)..n_features {
let subset = [j, k];
let preds: Vec<f64> = (0..n_samples)
.map(|i| {
trees
.iter()
.map(|t| t.marginal_predict(&subset, &data[i]))
.sum::<f64>()
/ trees.len() as f64
})
.collect();
let joint = variance(&preds);
let interaction = (joint - main_var[j] - main_var[k]).max(0.0);
if interaction > 1e-10 {
interactions.push((
(feature_names[j].clone(), feature_names[k].clone()),
interaction,
));
}
}
}
// Normalize so all importances sum to 1.0
let total: f64 =
main_var.iter().sum::<f64>() + interactions.iter().map(|(_, v)| *v).sum::<f64>();
let mut main_effects: Vec<(String, f64)> = feature_names
.iter()
.zip(&main_var)
.map(|(name, &v)| (name.clone(), if total > 0.0 { v / total } else { 0.0 }))
.collect();
main_effects.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
if total > 0.0 {
for entry in &mut interactions {
entry.1 /= total;
}
}
interactions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(core::cmp::Ordering::Equal));
FanovaResult {
main_effects,
interactions,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rng_util;
#[test]
fn single_dominant_parameter() {
// f(x, y) = x — only x matters
let mut rng = fastrand::Rng::with_seed(0);
let n = 100;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|row| row[0]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into()],
&FanovaConfig::default(),
);
assert_eq!(result.main_effects[0].0, "x");
assert!(
result.main_effects[0].1 > 0.8,
"x importance = {}",
result.main_effects[0].1
);
}
#[test]
fn interaction_detection() {
// f(x, y) = x * y — both matter and interact
let mut rng = fastrand::Rng::with_seed(42);
let n = 200;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|row| row[0] * row[1]).collect();
let config = FanovaConfig {
n_trees: 128,
..FanovaConfig::default()
};
let result = compute_fanova(&data, &targets, &["x".into(), "y".into()], &config);
assert!(
!result.interactions.is_empty(),
"should detect x*y interaction"
);
assert!(
result.interactions[0].1 > 0.05,
"interaction importance = {}",
result.interactions[0].1
);
}
#[test]
fn variance_computation() {
assert!((variance(&[1.0, 2.0, 3.0, 4.0, 5.0]) - 2.0).abs() < 1e-10);
assert!(variance(&[5.0, 5.0, 5.0]).abs() < 1e-10);
assert!(variance(&[]).abs() < 1e-10);
}
#[test]
fn three_params_one_dominant() {
// f(x, y, z) = 3*x + 0.1*y + 0*z
let mut rng = fastrand::Rng::with_seed(7);
let n = 150;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|r| 3.0 * r[0] + 0.1 * r[1]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into(), "z".into()],
&FanovaConfig::default(),
);
// x should be the most important
assert_eq!(result.main_effects[0].0, "x");
assert!(result.main_effects[0].1 > 0.5);
// z should have near-zero importance
let z_imp = result
.main_effects
.iter()
.find(|(name, _)| name == "z")
.map_or(0.0, |(_, v)| *v);
assert!(z_imp < 0.1, "z importance = {z_imp}");
}
#[test]
fn importances_sum_to_one() {
let mut rng = fastrand::Rng::with_seed(3);
let n = 100;
let data: Vec<Vec<f64>> = (0..n)
.map(|_| {
vec![
rng_util::f64_range(&mut rng, 0.0, 10.0),
rng_util::f64_range(&mut rng, 0.0, 10.0),
]
})
.collect();
let targets: Vec<f64> = data.iter().map(|r| r[0] + r[1]).collect();
let result = compute_fanova(
&data,
&targets,
&["x".into(), "y".into()],
&FanovaConfig::default(),
);
let total: f64 = result.main_effects.iter().map(|(_, v)| *v).sum::<f64>()
+ result.interactions.iter().map(|(_, v)| *v).sum::<f64>();
assert!(
(total - 1.0).abs() < 1e-10,
"importances should sum to 1.0, got {total}"
);
}
}
+12 -16
View File
@@ -5,8 +5,6 @@
//! parameter independently, the multivariate KDE models the joint distribution
//! to better capture correlations between parameters.
use rand::{Rng, RngExt};
use crate::error::{Error, Result};
/// A multivariate Gaussian kernel density estimator for joint distributions.
@@ -308,9 +306,9 @@ impl MultivariateKDE {
/// # Returns
///
/// A `Vec<f64>` of length `n_dims` representing a sample from the KDE.
pub(crate) fn sample<R: Rng>(&self, rng: &mut R) -> Vec<f64> {
pub(crate) fn sample(&self, rng: &mut fastrand::Rng) -> Vec<f64> {
// Select a random sample to center the kernel on
let idx = rng.random_range(0..self.samples.len());
let idx = rng.usize(0..self.samples.len());
let center = &self.samples[idx];
// Add independent Gaussian noise to each dimension
@@ -319,8 +317,8 @@ impl MultivariateKDE {
.iter()
.zip(self.bandwidths.iter())
.map(|(&center_j, &bandwidth_j)| {
let u1: f64 = rng.random();
let u2: f64 = rng.random();
let u1: f64 = rng.f64();
let u2: f64 = rng.f64();
// Box-Muller transform: generates standard normal variate
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos();
@@ -724,7 +722,7 @@ mod tests {
fn test_multivariate_kde_sample_basic() {
let samples = vec![vec![0.0, 0.0], vec![1.0, 1.0], vec![2.0, 2.0]];
let kde = MultivariateKDE::new(samples).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Sample should have correct dimensionality
let sample = kde.sample(&mut rng);
@@ -741,7 +739,7 @@ mod tests {
vec![4.0, 4.0],
];
let kde = MultivariateKDE::new(samples).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Samples should generally be in a reasonable range around the data
for _ in 0..100 {
@@ -766,7 +764,7 @@ mod tests {
// When KDE has only one sample, all samples should be centered around it
let samples = vec![vec![5.0, 10.0]];
let kde = MultivariateKDE::new(samples).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Generate many samples and check they cluster around (5.0, 10.0)
let n_samples = 100;
@@ -803,7 +801,7 @@ mod tests {
})
.collect();
let kde = MultivariateKDE::new(samples).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Sample should have correct dimensionality
for _ in 0..50 {
@@ -823,7 +821,7 @@ mod tests {
let data = vec![vec![0.0, 0.0], vec![0.0, 0.0], vec![0.0, 0.0]];
let bandwidths = vec![0.1, 10.0]; // Small bandwidth in x, large in y
let kde = MultivariateKDE::with_bandwidths(data, bandwidths).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Generate samples and check variance in each dimension
let n_samples = 1000;
@@ -868,7 +866,7 @@ mod tests {
vec![4.0, 4.0],
];
let kde = MultivariateKDE::new(data).unwrap();
let mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Sample many points and verify the mean is near the center
let n_samples = 500;
@@ -896,14 +894,12 @@ mod tests {
#[test]
fn test_multivariate_kde_sample_deterministic_with_seeded_rng() {
use rand::SeedableRng;
let data = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
let kde = MultivariateKDE::new(data).unwrap();
// Use a seeded RNG for reproducibility
let mut rng1 = rand::rngs::StdRng::seed_from_u64(42);
let mut rng2 = rand::rngs::StdRng::seed_from_u64(42);
let mut rng1 = fastrand::Rng::with_seed(42);
let mut rng2 = fastrand::Rng::with_seed(42);
// Same seed should produce same samples
let result1 = kde.sample(&mut rng1);
+6 -8
View File
@@ -3,8 +3,6 @@
//! This module provides a Gaussian kernel density estimator used by the TPE
//! sampler to model probability distributions over good and bad trial regions.
use rand::{Rng, RngExt};
use crate::error::{Error, Result};
/// A Gaussian kernel density estimator for continuous distributions.
@@ -26,7 +24,7 @@ use crate::error::{Error, Result};
/// assert!(density > 0.0);
///
/// // Sample from the estimated distribution
/// let mut rng = rand::rng();
/// let mut rng = fastrand::Rng::new();
/// let sample = kde.sample(&mut rng);
/// ```
#[derive(Clone, Debug)]
@@ -132,15 +130,15 @@ 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(crate) fn sample(&self, rng: &mut fastrand::Rng) -> f64 {
// Select a random sample to center the kernel on
let idx = rng.random_range(0..self.samples.len());
let idx = rng.usize(0..self.samples.len());
let center = self.samples[idx];
// Add Gaussian noise with bandwidth as standard deviation
// Using Box-Muller transform for Gaussian sampling
let u1: f64 = rng.random();
let u2: f64 = rng.random();
let u1: f64 = rng.f64();
let u2: f64 = rng.f64();
let z = (-2.0 * u1.ln()).sqrt() * (2.0 * core::f64::consts::PI * u2).cos();
center + z * self.bandwidth
@@ -211,7 +209,7 @@ mod tests {
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 mut rng = rand::rng();
let mut rng = fastrand::Rng::new();
// Samples should generally be in a reasonable range around the data
for _ in 0..100 {
+46 -2
View File
@@ -19,7 +19,13 @@
//! - **Grid Search** - Exhaustive search over a specified parameter grid
//! - **Sobol (QMC)** - Quasi-random sampling for better space coverage (requires `sobol` feature)
//! - **CMA-ES** - Covariance Matrix Adaptation Evolution Strategy for continuous optimization (requires `cma-es` feature)
//! - **DE** - Differential Evolution for population-based global optimization
//! - **GP** - Gaussian Process Bayesian optimization with Expected Improvement (requires `gp` feature)
//! - **BOHB** - Bayesian Optimization + `HyperBand` for budget-aware TPE sampling
//! - **NSGA-II** - Non-dominated Sorting Genetic Algorithm II for multi-objective optimization
//! - **NSGA-III** - Reference-point-based NSGA for many-objective (3+) optimization
//! - **MOEA/D** - Decomposition-based multi-objective with Tchebycheff, Weighted Sum, or PBI
//! - **MOTPE** - Multi-Objective Tree-Parzen Estimator for Bayesian multi-objective optimization
//!
//! Additional features include:
//!
@@ -188,6 +194,8 @@
//! - `serde`: Enable `Serialize`/`Deserialize` on public types and `Study::save()`/`Study::load()`
//! - `sobol`: Enable the Sobol quasi-random sampler for better space coverage
//! - `cma-es`: Enable the CMA-ES sampler for continuous optimization
//! - `gp`: Enable the Gaussian Process sampler for Bayesian optimization
//! - `visualization`: Generate self-contained HTML reports with interactive Plotly.js charts
//! - `tracing`: Emit structured log events via the [`tracing`](https://docs.rs/tracing) crate at key optimization points
/// Emit a `tracing::info!` event when the `tracing` feature is enabled.
@@ -216,17 +224,25 @@ macro_rules! trace_debug {
mod distribution;
mod error;
mod fanova;
mod importance;
mod kde;
pub mod multi_objective;
mod param;
pub mod parameter;
pub mod pareto;
pub mod pruner;
mod rng_util;
pub mod sampler;
pub mod storage;
mod study;
mod trial;
mod types;
mod visualization;
pub use error::{Error, Result, TrialPruned};
pub use fanova::{FanovaConfig, FanovaResult};
pub use multi_objective::{MultiObjectiveSampler, MultiObjectiveStudy, MultiObjectiveTrial};
#[cfg(feature = "derive")]
pub use optimizer_derive::Categorical;
pub use param::ParamValue;
@@ -241,16 +257,29 @@ pub use sampler::CompletedTrial;
pub use sampler::bohb::BohbSampler;
#[cfg(feature = "cma-es")]
pub use sampler::cma_es::CmaEsSampler;
pub use sampler::differential_evolution::{
DifferentialEvolutionSampler, DifferentialEvolutionStrategy,
};
#[cfg(feature = "gp")]
pub use sampler::gp::GpSampler;
pub use sampler::grid::GridSearchSampler;
pub use sampler::moead::{Decomposition, MoeadSampler};
pub use sampler::motpe::MotpeSampler;
pub use sampler::nsga2::Nsga2Sampler;
pub use sampler::nsga3::Nsga3Sampler;
pub use sampler::random::RandomSampler;
#[cfg(feature = "sobol")]
pub use sampler::sobol::SobolSampler;
pub use sampler::tpe::TpeSampler;
pub use study::Study;
#[cfg(feature = "journal")]
pub use storage::JournalStorage;
pub use storage::{MemoryStorage, Storage};
#[cfg(feature = "serde")]
pub use study::StudySnapshot;
pub use study::{Study, StudyBuilder};
pub use trial::{AttrValue, Trial};
pub use types::{Direction, TrialState};
pub use visualization::generate_html_report;
/// Convenient wildcard import for the most common types.
///
@@ -262,6 +291,8 @@ pub mod prelude {
pub use optimizer_derive::Categorical as DeriveCategory;
pub use crate::error::{Error, Result, TrialPruned};
pub use crate::fanova::{FanovaConfig, FanovaResult};
pub use crate::multi_objective::{MultiObjectiveStudy, MultiObjectiveTrial};
pub use crate::param::ParamValue;
pub use crate::parameter::{
BoolParam, Categorical, CategoricalParam, EnumParam, FloatParam, IntParam, Parameter,
@@ -274,14 +305,27 @@ pub mod prelude {
pub use crate::sampler::bohb::BohbSampler;
#[cfg(feature = "cma-es")]
pub use crate::sampler::cma_es::CmaEsSampler;
pub use crate::sampler::differential_evolution::{
DifferentialEvolutionSampler, DifferentialEvolutionStrategy,
};
#[cfg(feature = "gp")]
pub use crate::sampler::gp::GpSampler;
pub use crate::sampler::grid::GridSearchSampler;
pub use crate::sampler::moead::{Decomposition, MoeadSampler};
pub use crate::sampler::motpe::MotpeSampler;
pub use crate::sampler::nsga2::Nsga2Sampler;
pub use crate::sampler::nsga3::Nsga3Sampler;
pub use crate::sampler::random::RandomSampler;
#[cfg(feature = "sobol")]
pub use crate::sampler::sobol::SobolSampler;
pub use crate::sampler::tpe::TpeSampler;
pub use crate::study::Study;
#[cfg(feature = "journal")]
pub use crate::storage::JournalStorage;
pub use crate::storage::{MemoryStorage, Storage};
#[cfg(feature = "serde")]
pub use crate::study::StudySnapshot;
pub use crate::study::{Study, StudyBuilder};
pub use crate::trial::{AttrValue, Trial};
pub use crate::types::Direction;
pub use crate::visualization::generate_html_report;
}
+412
View File
@@ -0,0 +1,412 @@
//! Multi-objective optimization via a dedicated study type.
//!
//! [`MultiObjectiveStudy`] manages trials that return multiple objective
//! values. It supports arbitrary numbers of objectives with per-objective
//! directions (minimize or maximize). Use [`pareto_front()`](MultiObjectiveStudy::pareto_front)
//! to retrieve the Pareto-optimal solutions.
//!
//! # Examples
//!
//! ```
//! use optimizer::Direction;
//! use optimizer::multi_objective::MultiObjectiveStudy;
//! use optimizer::parameter::{FloatParam, Parameter};
//!
//! let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
//! let x = FloatParam::new(0.0, 1.0);
//!
//! study
//! .optimize(20, |trial| {
//! let xv = x.suggest(trial)?;
//! Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
//! })
//! .unwrap();
//!
//! let front = study.pareto_front();
//! assert!(!front.is_empty());
//! ```
use core::sync::atomic::{AtomicU64, Ordering};
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::RwLock;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::parameter::{ParamId, Parameter};
use crate::pruner::NopPruner;
use crate::sampler::random::RandomSampler;
use crate::sampler::{CompletedTrial, Sampler};
use crate::trial::{AttrValue, Trial};
use crate::types::{Direction, TrialState};
// ---------------------------------------------------------------------------
// MultiObjectiveTrial
// ---------------------------------------------------------------------------
/// A completed trial with multiple objective values.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct MultiObjectiveTrial {
/// The unique identifier for this trial.
pub id: u64,
/// The sampled parameter values, keyed by parameter id.
pub params: HashMap<ParamId, ParamValue>,
/// The parameter distributions used, keyed by parameter id.
pub distributions: HashMap<ParamId, Distribution>,
/// Human-readable labels for parameters, keyed by parameter id.
pub param_labels: HashMap<ParamId, String>,
/// The objective values (one per objective).
pub values: Vec<f64>,
/// The state of the trial.
pub state: TrialState,
/// User-defined attributes stored during the trial.
pub user_attrs: HashMap<String, AttrValue>,
/// Constraint values for this trial (<=0.0 means feasible).
#[cfg_attr(feature = "serde", serde(default))]
pub constraints: Vec<f64>,
}
impl MultiObjectiveTrial {
/// Returns the typed value for the given parameter.
///
/// Returns `None` if the parameter was not used in this trial.
///
/// # Panics
///
/// Panics if the stored value is incompatible with the parameter type.
pub fn get<P: Parameter>(&self, param: &P) -> Option<P::Value> {
self.params.get(&param.id()).map(|v| {
param
.cast_param_value(v)
.expect("parameter type mismatch: stored value incompatible with parameter")
})
}
/// Returns `true` if all constraints are satisfied (values <= 0.0).
///
/// A trial with no constraints is considered feasible.
#[must_use]
pub fn is_feasible(&self) -> bool {
self.constraints.iter().all(|&c| c <= 0.0)
}
/// Gets a user attribute by key.
#[must_use]
pub fn user_attr(&self, key: &str) -> Option<&AttrValue> {
self.user_attrs.get(key)
}
/// Returns all user attributes.
#[must_use]
pub fn user_attrs(&self) -> &HashMap<String, AttrValue> {
&self.user_attrs
}
}
// ---------------------------------------------------------------------------
// MultiObjectiveSampler trait
// ---------------------------------------------------------------------------
/// Trait for samplers aware of multi-objective history.
///
/// Separate from [`Sampler`] because NSGA-II needs access to
/// `&[MultiObjectiveTrial]` (with vector-valued objectives) and
/// `&[Direction]` (one direction per objective).
pub trait MultiObjectiveSampler: Send + Sync {
/// Samples a parameter value from the given distribution.
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) -> ParamValue;
}
// ---------------------------------------------------------------------------
// RandomMultiObjectiveSampler
// ---------------------------------------------------------------------------
/// Default MO sampler that delegates to [`RandomSampler`].
pub(crate) struct RandomMultiObjectiveSampler(RandomSampler);
impl RandomMultiObjectiveSampler {
pub(crate) fn new() -> Self {
Self(RandomSampler::new())
}
}
impl MultiObjectiveSampler for RandomMultiObjectiveSampler {
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
_history: &[MultiObjectiveTrial],
_directions: &[Direction],
) -> ParamValue {
self.0.sample(distribution, trial_id, &[])
}
}
// ---------------------------------------------------------------------------
// MoSamplerBridge — bridges MultiObjectiveSampler to Sampler trait
// ---------------------------------------------------------------------------
/// Bridges a [`MultiObjectiveSampler`] to the [`Sampler`] trait so that
/// `Trial::with_sampler()` can use it.
struct MoSamplerBridge {
inner: Arc<dyn MultiObjectiveSampler>,
history: Arc<RwLock<Vec<MultiObjectiveTrial>>>,
directions: Vec<Direction>,
}
impl Sampler for MoSamplerBridge {
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
_history: &[CompletedTrial],
) -> ParamValue {
let mo_history = self.history.read();
self.inner
.sample(distribution, trial_id, &mo_history, &self.directions)
}
}
// ---------------------------------------------------------------------------
// MultiObjectiveStudy
// ---------------------------------------------------------------------------
/// A study for multi-objective optimization.
///
/// Manages trials that return multiple objective values. Supports
/// arbitrary numbers of objectives with independent minimize/maximize
/// directions.
///
/// # Examples
///
/// ```
/// use optimizer::Direction;
/// use optimizer::multi_objective::MultiObjectiveStudy;
/// use optimizer::parameter::{FloatParam, Parameter};
///
/// // Bi-objective: minimize both
/// let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
/// let x = FloatParam::new(0.0, 1.0);
///
/// study
/// .optimize(30, |trial| {
/// let xv = x.suggest(trial)?;
/// Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
/// })
/// .unwrap();
///
/// let front = study.pareto_front();
/// assert!(!front.is_empty());
/// ```
pub struct MultiObjectiveStudy {
directions: Vec<Direction>,
sampler: Arc<dyn MultiObjectiveSampler>,
completed_trials: Arc<RwLock<Vec<MultiObjectiveTrial>>>,
next_trial_id: AtomicU64,
}
impl MultiObjectiveStudy {
/// Creates a new multi-objective study with the given directions.
///
/// Uses a random sampler by default.
///
/// # Arguments
///
/// * `directions` - One direction per objective (minimize or maximize).
#[must_use]
pub fn new(directions: Vec<Direction>) -> Self {
Self {
directions,
sampler: Arc::new(RandomMultiObjectiveSampler::new()),
completed_trials: Arc::new(RwLock::new(Vec::new())),
next_trial_id: AtomicU64::new(0),
}
}
/// Creates a new study with a custom multi-objective sampler.
#[must_use]
pub fn with_sampler(
directions: Vec<Direction>,
sampler: impl MultiObjectiveSampler + 'static,
) -> Self {
Self {
directions,
sampler: Arc::new(sampler),
completed_trials: Arc::new(RwLock::new(Vec::new())),
next_trial_id: AtomicU64::new(0),
}
}
/// Returns the optimization directions.
#[must_use]
pub fn directions(&self) -> &[Direction] {
&self.directions
}
/// Returns the number of objectives.
#[must_use]
pub fn n_objectives(&self) -> usize {
self.directions.len()
}
/// Returns the number of completed trials.
#[must_use]
pub fn n_trials(&self) -> usize {
self.completed_trials.read().len()
}
/// Returns all completed trials.
#[must_use]
pub fn trials(&self) -> Vec<MultiObjectiveTrial> {
self.completed_trials.read().clone()
}
/// Returns the Pareto-optimal trials (front 0).
#[must_use]
pub fn pareto_front(&self) -> Vec<MultiObjectiveTrial> {
let trials = self.completed_trials.read();
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
.collect();
if complete.is_empty() {
return Vec::new();
}
let values: Vec<Vec<f64>> = complete.iter().map(|t| t.values.clone()).collect();
let fronts = crate::pareto::fast_non_dominated_sort(&values, &self.directions);
if fronts.is_empty() {
return Vec::new();
}
fronts[0].iter().map(|&i| complete[i].clone()).collect()
}
/// Creates a new trial wired to the study's MO sampler.
fn create_trial(&self) -> Trial {
let id = self.next_trial_id.fetch_add(1, Ordering::SeqCst);
let bridge: Arc<dyn Sampler> = Arc::new(MoSamplerBridge {
inner: Arc::clone(&self.sampler),
history: Arc::clone(&self.completed_trials),
directions: self.directions.clone(),
});
// Dummy f64 history — the bridge ignores it.
let dummy_history: Arc<RwLock<Vec<CompletedTrial<f64>>>> =
Arc::new(RwLock::new(Vec::new()));
Trial::with_sampler(id, bridge, dummy_history, Arc::new(NopPruner))
}
/// Records a completed trial.
fn complete_trial(&self, mut trial: Trial, values: Vec<f64>) {
trial.set_complete();
let mo_trial = MultiObjectiveTrial {
id: trial.id(),
params: trial.params().clone(),
distributions: trial.distributions().clone(),
param_labels: trial.param_labels().clone(),
values,
state: TrialState::Complete,
user_attrs: trial.user_attrs().clone(),
constraints: trial.constraint_values().to_vec(),
};
self.completed_trials.write().push(mo_trial);
}
/// Records a failed trial (not stored in history).
fn fail_trial(trial: &mut Trial) {
trial.set_failed();
}
/// Request a new trial for the ask/tell interface.
///
/// After creating the trial, suggest parameters on it, evaluate your
/// objective externally, then pass the trial back to [`tell()`](Self::tell).
pub fn ask(&self) -> Trial {
self.create_trial()
}
/// Report the result of a trial obtained from [`ask()`](Self::ask).
///
/// Pass `Ok(values)` for a successful evaluation or `Err(reason)` for a failure.
///
/// # Errors
///
/// Returns `ObjectiveDimensionMismatch` if the number of values doesn't
/// match the number of directions.
pub fn tell(
&self,
mut trial: Trial,
result: core::result::Result<Vec<f64>, impl ToString>,
) -> crate::Result<()> {
if let Ok(values) = result {
if values.len() != self.directions.len() {
return Err(crate::Error::ObjectiveDimensionMismatch {
expected: self.directions.len(),
got: values.len(),
});
}
self.complete_trial(trial, values);
} else {
Self::fail_trial(&mut trial);
}
Ok(())
}
/// Runs multi-objective optimization for `n_trials` trials.
///
/// The objective function must return a `Vec<f64>` with one value per
/// objective.
///
/// # Errors
///
/// Returns `ObjectiveDimensionMismatch` if the objective returns the wrong
/// number of values. Returns `NoCompletedTrials` if all trials fail.
pub fn optimize<F, E>(&self, n_trials: usize, mut objective: F) -> crate::Result<()>
where
F: FnMut(&mut Trial) -> core::result::Result<Vec<f64>, E>,
E: ToString,
{
for _ in 0..n_trials {
let mut trial = self.create_trial();
match objective(&mut trial) {
Ok(values) => {
if values.len() != self.directions.len() {
return Err(crate::Error::ObjectiveDimensionMismatch {
expected: self.directions.len(),
got: values.len(),
});
}
self.complete_trial(trial, values);
}
Err(_) => {
Self::fail_trial(&mut trial);
}
}
}
let has_complete = self
.completed_trials
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
if !has_complete {
return Err(crate::Error::NoCompletedTrials);
}
Ok(())
}
}
+593
View File
@@ -0,0 +1,593 @@
//! Pareto front analysis utilities for multi-objective optimization.
//!
//! Provides functions for analyzing and working with Pareto fronts:
//!
//! - [`hypervolume`] — measure the quality of a Pareto front
//! - [`non_dominated_sort`] — rank solutions into successive fronts
//! - [`pareto_front_indices`] — filter to non-dominated solutions only
//! - [`crowding_distance`] — measure diversity within a front
//!
//! Internally also provides fast non-dominated sorting (Deb et al., 2002)
//! used by [`MultiObjectiveStudy::pareto_front()`](crate::MultiObjectiveStudy::pareto_front)
//! and [`Nsga2Sampler`](crate::Nsga2Sampler).
use crate::types::Direction;
/// Returns `true` if solution `a` Pareto-dominates solution `b`.
///
/// A solution dominates another if it is at least as good in all objectives
/// and strictly better in at least one, respecting the given directions.
#[allow(clippy::module_name_repetitions)]
pub(crate) fn dominates(a: &[f64], b: &[f64], directions: &[Direction]) -> bool {
debug_assert_eq!(a.len(), b.len());
debug_assert_eq!(a.len(), directions.len());
let mut strictly_better = false;
for ((&av, &bv), dir) in a.iter().zip(b.iter()).zip(directions.iter()) {
let better = match dir {
Direction::Minimize => av < bv,
Direction::Maximize => av > bv,
};
let worse = match dir {
Direction::Minimize => av > bv,
Direction::Maximize => av < bv,
};
if worse {
return false;
}
if better {
strictly_better = true;
}
}
strictly_better
}
/// Constrained dominance: feasible beats infeasible, among infeasible
/// prefer lower total constraint violation, among feasible use Pareto dominance.
pub(crate) fn constrained_dominates(
a_values: &[f64],
b_values: &[f64],
a_constraints: &[f64],
b_constraints: &[f64],
directions: &[Direction],
) -> bool {
let a_feasible = a_constraints.iter().all(|&c| c <= 0.0);
let b_feasible = b_constraints.iter().all(|&c| c <= 0.0);
match (a_feasible, b_feasible) {
(true, false) => true,
(false, true) => false,
(false, false) => {
let a_violation: f64 = a_constraints.iter().map(|c| c.max(0.0)).sum();
let b_violation: f64 = b_constraints.iter().map(|c| c.max(0.0)).sum();
a_violation < b_violation
}
(true, true) => dominates(a_values, b_values, directions),
}
}
/// Fast non-dominated sorting (Deb et al., 2002).
///
/// Returns `Vec<Vec<usize>>` where `fronts[0]` is the Pareto front,
/// each inner vec contains indices into `values`.
///
/// Complexity: O(M * N^2) where M = objectives, N = solutions.
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn fast_non_dominated_sort(
values: &[Vec<f64>],
directions: &[Direction],
) -> Vec<Vec<usize>> {
fast_non_dominated_sort_constrained(values, directions, &[])
}
/// Fast non-dominated sorting with constraint support.
///
/// `constraints` is either empty (no constraints) or has the same length
/// as `values`, where each entry is the constraint vector for that solution.
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn fast_non_dominated_sort_constrained(
values: &[Vec<f64>],
directions: &[Direction],
constraints: &[Vec<f64>],
) -> Vec<Vec<usize>> {
let n = values.len();
if n == 0 {
return Vec::new();
}
let has_constraints = !constraints.is_empty();
let empty_constraints: Vec<f64> = Vec::new();
// S_p: set of solutions dominated by p
let mut dominated_by: Vec<Vec<usize>> = vec![Vec::new(); n];
// n_p: domination count for p
let mut domination_count: Vec<usize> = vec![0; n];
for i in 0..n {
for j in (i + 1)..n {
let (a_c, b_c) = if has_constraints {
(&constraints[i], &constraints[j])
} else {
(&empty_constraints, &empty_constraints)
};
let i_dom_j = if has_constraints {
constrained_dominates(&values[i], &values[j], a_c, b_c, directions)
} else {
dominates(&values[i], &values[j], directions)
};
let j_dom_i = if has_constraints {
constrained_dominates(&values[j], &values[i], b_c, a_c, directions)
} else {
dominates(&values[j], &values[i], directions)
};
if i_dom_j {
dominated_by[i].push(j);
domination_count[j] += 1;
} else if j_dom_i {
dominated_by[j].push(i);
domination_count[i] += 1;
}
}
}
let mut fronts: Vec<Vec<usize>> = Vec::new();
let mut current_front: Vec<usize> = (0..n).filter(|&i| domination_count[i] == 0).collect();
while !current_front.is_empty() {
let mut next_front: Vec<usize> = Vec::new();
for &p in &current_front {
for &q in &dominated_by[p] {
domination_count[q] -= 1;
if domination_count[q] == 0 {
next_front.push(q);
}
}
}
fronts.push(current_front);
current_front = next_front;
}
fronts
}
/// Crowding distance for one front (index-based, internal API).
///
/// Boundary solutions get `f64::INFINITY`. Returns one distance value per
/// solution in the front, in the same order as `front_indices`.
#[allow(clippy::cast_precision_loss)]
pub(crate) fn crowding_distance_indexed(front_indices: &[usize], values: &[Vec<f64>]) -> Vec<f64> {
let n = front_indices.len();
if n <= 2 {
return vec![f64::INFINITY; n];
}
let m = values[front_indices[0]].len(); // number of objectives
let mut distances = vec![0.0_f64; n];
// Helper to look up objective value for a front member.
let val = |front_pos: usize, obj: usize| -> f64 { values[front_indices[front_pos]][obj] };
for obj in 0..m {
// Sort front positions by this objective
let mut sorted: Vec<usize> = (0..n).collect();
sorted.sort_by(|&a, &b| {
val(a, obj)
.partial_cmp(&val(b, obj))
.unwrap_or(core::cmp::Ordering::Equal)
});
// Boundary solutions get infinity
distances[sorted[0]] = f64::INFINITY;
distances[sorted[n - 1]] = f64::INFINITY;
let range = val(sorted[n - 1], obj) - val(sorted[0], obj);
if range > 0.0 {
for i in 1..(n - 1) {
distances[sorted[i]] += (val(sorted[i + 1], obj) - val(sorted[i - 1], obj)) / range;
}
}
}
distances
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Compute the hypervolume indicator of a Pareto front.
///
/// The hypervolume is the volume of the objective space dominated by
/// the Pareto front and bounded by a reference point. Higher values
/// indicate a better front.
///
/// Each entry in `front` is one solution's objective values.
/// `reference_point` should be worse than all front members in every
/// objective (e.g., the worst acceptable values).
///
/// # Panics
///
/// Panics (in debug) if dimensions of `front`, `reference_point`, and
/// `directions` are inconsistent.
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn hypervolume(front: &[Vec<f64>], reference_point: &[f64], directions: &[Direction]) -> f64 {
if front.is_empty() {
return 0.0;
}
let d = reference_point.len();
debug_assert!(front.iter().all(|p| p.len() == d));
debug_assert_eq!(d, directions.len());
// Normalize to minimize-space (negate maximized objectives).
let normalized: Vec<Vec<f64>> = front
.iter()
.map(|p| {
p.iter()
.zip(directions)
.map(|(&v, dir)| match dir {
Direction::Minimize => v,
Direction::Maximize => -v,
})
.collect()
})
.collect();
let ref_norm: Vec<f64> = reference_point
.iter()
.zip(directions)
.map(|(&v, dir)| match dir {
Direction::Minimize => v,
Direction::Maximize => -v,
})
.collect();
// Keep only points strictly dominated by the reference point.
let filtered: Vec<Vec<f64>> = normalized
.into_iter()
.filter(|p| p.iter().zip(&ref_norm).all(|(&pv, &rv)| pv < rv))
.collect();
if filtered.is_empty() {
return 0.0;
}
hv_recursive(&filtered, &ref_norm)
}
/// Recursive hypervolume via slicing on the last objective.
///
/// All points are in minimize-space and dominated by `reference`.
#[allow(clippy::cast_precision_loss)]
fn hv_recursive(points: &[Vec<f64>], reference: &[f64]) -> f64 {
let d = reference.len();
// Base case: 1-D hypervolume is just the gap from the best point to ref.
if d == 1 {
let min_val = points.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
return (reference[0] - min_val).max(0.0);
}
// Single point: hypervolume is the product of gaps.
if points.len() == 1 {
return points[0]
.iter()
.zip(reference)
.map(|(&p, &r)| (r - p).max(0.0))
.product();
}
// Sort by last objective ascending.
let mut sorted: Vec<&Vec<f64>> = points.iter().collect();
sorted.sort_by(|a, b| {
a[d - 1]
.partial_cmp(&b[d - 1])
.unwrap_or(core::cmp::Ordering::Equal)
});
let sub_ref: Vec<f64> = reference[..d - 1].to_vec();
let mut result = 0.0;
for i in 0..sorted.len() {
let height = if i + 1 < sorted.len() {
sorted[i + 1][d - 1] - sorted[i][d - 1]
} else {
reference[d - 1] - sorted[i][d - 1]
};
if height <= 0.0 {
continue;
}
// Project points[0..=i] onto the first d-1 dimensions and
// keep only the non-dominated subset.
let projected: Vec<Vec<f64>> = sorted[..=i].iter().map(|p| p[..d - 1].to_vec()).collect();
let non_dom = non_dominated_minimize(&projected);
if !non_dom.is_empty() {
result += height * hv_recursive(&non_dom, &sub_ref);
}
}
result
}
/// Return the non-dominated subset of `points` in minimize-space.
fn non_dominated_minimize(points: &[Vec<f64>]) -> Vec<Vec<f64>> {
let mut result = Vec::new();
'outer: for (i, p) in points.iter().enumerate() {
for (j, q) in points.iter().enumerate() {
if i == j {
continue;
}
// Check if q dominates p (all <=, at least one <).
let mut all_leq = true;
let mut any_lt = false;
for (&qv, &pv) in q.iter().zip(p.iter()) {
if qv > pv {
all_leq = false;
break;
}
if qv < pv {
any_lt = true;
}
}
if all_leq && any_lt {
continue 'outer;
}
}
result.push(p.clone());
}
result
}
/// Compute non-dominated sorting of a set of solutions.
///
/// Returns a vec of fronts, where `fronts[0]` is the Pareto front,
/// `fronts[1]` is the next best, etc. Each inner vec contains indices
/// into the original `solutions` slice.
///
/// Uses the fast non-dominated sorting algorithm from
/// Deb et al. (2002) with O(M N²) complexity.
#[must_use]
pub fn non_dominated_sort(solutions: &[Vec<f64>], directions: &[Direction]) -> Vec<Vec<usize>> {
fast_non_dominated_sort(solutions, directions)
}
/// Filter solutions to return only non-dominated (Pareto-optimal) indices.
///
/// Equivalent to `non_dominated_sort(solutions, directions)[0]` but
/// communicates the intent more clearly.
#[must_use]
pub fn pareto_front_indices(solutions: &[Vec<f64>], directions: &[Direction]) -> Vec<usize> {
let fronts = fast_non_dominated_sort(solutions, directions);
fronts.into_iter().next().unwrap_or_default()
}
/// Compute crowding distance for diversity measurement.
///
/// Returns one distance value per solution in `front` (same order).
/// Boundary solutions (best/worst in any objective) receive
/// [`f64::INFINITY`]. Interior solutions get a finite positive value
/// proportional to the gap between their neighbors.
///
/// `directions` is accepted for API consistency but does not affect
/// the result, since crowding distance measures spacing regardless of
/// optimization direction.
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::needless_range_loop)]
pub fn crowding_distance(front: &[Vec<f64>], _directions: &[Direction]) -> Vec<f64> {
let n = front.len();
if n <= 2 {
return vec![f64::INFINITY; n];
}
let m = front[0].len();
let mut distances = vec![0.0_f64; n];
for obj in 0..m {
let mut sorted: Vec<usize> = (0..n).collect();
sorted.sort_by(|&a, &b| {
front[a][obj]
.partial_cmp(&front[b][obj])
.unwrap_or(core::cmp::Ordering::Equal)
});
distances[sorted[0]] = f64::INFINITY;
distances[sorted[n - 1]] = f64::INFINITY;
let range = front[sorted[n - 1]][obj] - front[sorted[0]][obj];
if range > 0.0 {
for i in 1..(n - 1) {
distances[sorted[i]] +=
(front[sorted[i + 1]][obj] - front[sorted[i - 1]][obj]) / range;
}
}
}
distances
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dominates_basic() {
let dirs = [Direction::Minimize, Direction::Minimize];
assert!(dominates(&[1.0, 1.0], &[2.0, 2.0], &dirs));
assert!(!dominates(&[2.0, 2.0], &[1.0, 1.0], &dirs));
// Equal does not dominate
assert!(!dominates(&[1.0, 1.0], &[1.0, 1.0], &dirs));
}
#[test]
fn test_dominates_incomparable() {
let dirs = [Direction::Minimize, Direction::Minimize];
assert!(!dominates(&[1.0, 3.0], &[3.0, 1.0], &dirs));
assert!(!dominates(&[3.0, 1.0], &[1.0, 3.0], &dirs));
}
#[test]
fn test_dominates_maximize() {
let dirs = [Direction::Maximize, Direction::Minimize];
// a = (5, 1) vs b = (3, 2): a is better in both
assert!(dominates(&[5.0, 1.0], &[3.0, 2.0], &dirs));
assert!(!dominates(&[3.0, 2.0], &[5.0, 1.0], &dirs));
}
#[test]
fn test_nds_known() {
let values = vec![
vec![1.0, 5.0], // front 0
vec![5.0, 1.0], // front 0
vec![3.0, 3.0], // front 0 (non-dominated)
vec![4.0, 4.0], // front 1 (dominated by #2)
vec![6.0, 6.0], // front 2
];
let dirs = [Direction::Minimize, Direction::Minimize];
let fronts = fast_non_dominated_sort(&values, &dirs);
assert_eq!(fronts.len(), 3);
let mut f0 = fronts[0].clone();
f0.sort_unstable();
assert_eq!(f0, vec![0, 1, 2]);
assert_eq!(fronts[1], vec![3]);
assert_eq!(fronts[2], vec![4]);
}
#[test]
fn test_crowding_indexed_boundaries() {
let values = vec![vec![1.0, 5.0], vec![3.0, 3.0], vec![5.0, 1.0]];
let front = vec![0, 1, 2];
let cd = crowding_distance_indexed(&front, &values);
assert!(cd[0].is_infinite());
assert!(cd[2].is_infinite());
assert!(cd[1].is_finite());
assert!(cd[1] > 0.0);
}
// ---- Public API tests ----
#[test]
fn test_hypervolume_2d_minimize() {
// Front: (1,3), (2,2), (3,1) with ref (4,4) — all minimize
let front = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
let dirs = [Direction::Minimize, Direction::Minimize];
let hv = hypervolume(&front, &[4.0, 4.0], &dirs);
// Strip 1: x=[1,2), h=4-3=1 → area=1
// Strip 2: x=[2,3), h=4-2=2 → area=2
// Strip 3: x=[3,4], h=4-1=3 → area=3
// Total = 6
assert!((hv - 6.0).abs() < 1e-10);
}
#[test]
fn test_hypervolume_2d_maximize() {
// Front: (3,1), (2,2), (1,3) with ref (0,0) — all maximize
let front = vec![vec![3.0, 1.0], vec![2.0, 2.0], vec![1.0, 3.0]];
let dirs = [Direction::Maximize, Direction::Maximize];
let hv = hypervolume(&front, &[0.0, 0.0], &dirs);
// In negate-space: points become (-3,-1),(-2,-2),(-1,-3), ref=(0,0)
// Same geometry as minimize test above → area = 6
assert!((hv - 6.0).abs() < 1e-10);
}
#[test]
fn test_hypervolume_single_point() {
let front = vec![vec![1.0, 1.0]];
let dirs = [Direction::Minimize, Direction::Minimize];
let hv = hypervolume(&front, &[3.0, 3.0], &dirs);
// Rectangle: (3-1) * (3-1) = 4
assert!((hv - 4.0).abs() < 1e-10);
}
#[test]
fn test_hypervolume_empty_front() {
let front: Vec<Vec<f64>> = vec![];
let dirs = [Direction::Minimize];
assert!(hypervolume(&front, &[1.0], &dirs).abs() < f64::EPSILON);
}
#[test]
fn test_hypervolume_point_at_ref() {
// Point not strictly better than ref → contributes nothing
let front = vec![vec![5.0, 5.0]];
let dirs = [Direction::Minimize, Direction::Minimize];
let hv = hypervolume(&front, &[5.0, 5.0], &dirs);
assert!(hv.abs() < f64::EPSILON);
}
#[test]
fn test_hypervolume_3d() {
// Single point in 3D: (1,1,1) with ref (2,2,2)
let front = vec![vec![1.0, 1.0, 1.0]];
let dirs = [
Direction::Minimize,
Direction::Minimize,
Direction::Minimize,
];
let hv = hypervolume(&front, &[2.0, 2.0, 2.0], &dirs);
assert!((hv - 1.0).abs() < 1e-10);
}
#[test]
fn test_non_dominated_sort_public() {
let values = vec![
vec![1.0, 5.0],
vec![5.0, 1.0],
vec![3.0, 3.0],
vec![4.0, 4.0],
];
let dirs = [Direction::Minimize, Direction::Minimize];
let fronts = non_dominated_sort(&values, &dirs);
assert_eq!(fronts.len(), 2);
let mut f0 = fronts[0].clone();
f0.sort_unstable();
assert_eq!(f0, vec![0, 1, 2]);
assert_eq!(fronts[1], vec![3]);
}
#[test]
fn test_pareto_front_indices_basic() {
let values = vec![
vec![1.0, 5.0],
vec![5.0, 1.0],
vec![3.0, 3.0],
vec![4.0, 4.0],
];
let dirs = [Direction::Minimize, Direction::Minimize];
let mut idx = pareto_front_indices(&values, &dirs);
idx.sort_unstable();
assert_eq!(idx, vec![0, 1, 2]);
}
#[test]
fn test_pareto_front_indices_empty() {
let values: Vec<Vec<f64>> = vec![];
let dirs = [Direction::Minimize];
assert!(pareto_front_indices(&values, &dirs).is_empty());
}
#[test]
fn test_crowding_distance_public() {
let front = vec![vec![1.0, 5.0], vec![3.0, 3.0], vec![5.0, 1.0]];
let dirs = [Direction::Minimize, Direction::Minimize];
let cd = crowding_distance(&front, &dirs);
assert!(cd[0].is_infinite());
assert!(cd[2].is_infinite());
assert!(cd[1].is_finite());
assert!(cd[1] > 0.0);
}
#[test]
fn test_crowding_distance_single_point() {
let front = vec![vec![2.0, 3.0]];
let dirs = [Direction::Minimize, Direction::Minimize];
let cd = crowding_distance(&front, &dirs);
assert_eq!(cd.len(), 1);
assert!(cd[0].is_infinite());
}
}
+5
View File
@@ -0,0 +1,5 @@
/// Generate a random `f64` in the range `[low, high)`.
#[inline]
pub(crate) fn f64_range(rng: &mut fastrand::Rng, low: f64, high: f64) -> f64 {
low + rng.f64() * (high - low)
}
+23 -24
View File
@@ -25,11 +25,10 @@ use std::collections::HashMap;
use nalgebra::{DMatrix, DVector};
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::{CompletedTrial, Sampler};
/// CMA-ES sampler for continuous optimization.
@@ -267,7 +266,7 @@ enum Phase {
/// Top-level mutable state behind the `Mutex`.
struct CmaEsState {
/// The RNG used for sampling.
rng: StdRng,
rng: fastrand::Rng,
/// User-provided initial sigma (None = auto).
sigma0: Option<f64>,
/// User-provided population size (None = auto).
@@ -290,7 +289,7 @@ struct CmaEsState {
impl CmaEsState {
fn new(sigma0: Option<f64>, user_lambda: Option<usize>, seed: Option<u64>) -> Self {
let rng = seed.map_or_else(rand::make_rng, StdRng::seed_from_u64);
let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
Self {
rng,
sigma0,
@@ -421,7 +420,7 @@ impl CmaEsAlgorithm {
/// Generate `lambda` candidate vectors from the current distribution.
fn generate_candidates(
&self,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
) -> Vec<Candidate> {
let n = self.constants.n;
@@ -439,7 +438,7 @@ impl CmaEsAlgorithm {
/// Generate a single candidate from the current distribution.
fn generate_single_candidate(
&self,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
n: usize,
) -> Candidate {
@@ -452,7 +451,7 @@ impl CmaEsAlgorithm {
if !dim.is_continuous
&& let Distribution::Categorical(cat) = &dim.distribution
{
categorical_values.insert(i, rng.random_range(0..cat.n_choices));
categorical_values.insert(i, rng.usize(0..cat.n_choices));
}
}
@@ -465,7 +464,7 @@ impl CmaEsAlgorithm {
/// Sample a candidate vector with rejection sampling for bounds.
fn sample_with_rejection(
&self,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
n: usize,
) -> DVector<f64> {
@@ -679,36 +678,36 @@ fn internal_bounds(distribution: &Distribution) -> Option<(f64, f64)> {
}
/// Sample a value from the standard normal distribution using Box-Muller transform.
fn sample_standard_normal(rng: &mut StdRng) -> f64 {
fn sample_standard_normal(rng: &mut fastrand::Rng) -> f64 {
// Box-Muller transform
let u1: f64 = rng.random_range(f64::EPSILON..=1.0);
let u2: f64 = rng.random_range(0.0_f64..=core::f64::consts::TAU);
let u1: f64 = rng_util::f64_range(rng, f64::EPSILON, 1.0);
let u2: f64 = rng_util::f64_range(rng, 0.0_f64, core::f64::consts::TAU);
(-2.0 * u1.ln()).sqrt() * u2.cos()
}
/// Sample a categorical value randomly.
fn sample_random_categorical(rng: &mut StdRng, distribution: &Distribution) -> ParamValue {
fn sample_random_categorical(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Categorical(d) => ParamValue::Categorical(rng.random_range(0..d.n_choices)),
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
_ => unreachable!("sample_random_categorical called with non-categorical distribution"),
}
}
/// Sample a random value for any distribution (used during discovery phase).
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn sample_random(rng: &mut StdRng, distribution: &Distribution) -> ParamValue {
fn sample_random(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng.random_range(log_low..=log_high).exp()
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng.random_range(d.low..=d.high)
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
@@ -716,18 +715,18 @@ fn sample_random(rng: &mut StdRng, distribution: &Distribution) -> ParamValue {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng.random_range(log_low..=log_high).exp().round() as i64;
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.random_range(d.low..=d.high)
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.random_range(0..d.n_choices)),
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
@@ -811,7 +810,7 @@ fn finalize_discovery(state: &mut CmaEsState) {
/// Generate candidates that are purely categorical (no continuous dims).
fn generate_pure_categorical_candidates(
rng: &mut StdRng,
rng: &mut fastrand::Rng,
dimensions: &[DimensionInfo],
lambda: usize,
) -> Vec<Candidate> {
@@ -820,7 +819,7 @@ fn generate_pure_categorical_candidates(
let mut categorical_values = HashMap::new();
for (i, dim) in dimensions.iter().enumerate() {
if let Distribution::Categorical(cat) = &dim.distribution {
categorical_values.insert(i, rng.random_range(0..cat.n_choices));
categorical_values.insert(i, rng.usize(0..cat.n_choices));
}
}
Candidate {
@@ -913,7 +912,7 @@ fn generate_overflow_candidate(state: &mut CmaEsState) -> Candidate {
let mut categorical_values = HashMap::new();
for (i, dim) in state.dimensions.iter().enumerate() {
if let Distribution::Categorical(cat) = &dim.distribution {
categorical_values.insert(i, state.rng.random_range(0..cat.n_choices));
categorical_values.insert(i, state.rng.usize(0..cat.n_choices));
}
}
return Candidate {
+995
View File
@@ -0,0 +1,995 @@
//! Differential Evolution (DE) sampler.
//!
//! DE is a population-based metaheuristic that maintains a population of
//! candidate solutions and creates new candidates by combining (mutating +
//! crossing over) existing ones. It is competitive with CMA-ES on many
//! problems and simpler to implement.
//!
//! Categorical parameters are sampled uniformly at random (not part of the
//! DE vector). If all parameters are categorical, the sampler falls back to
//! pure random sampling.
//!
//! # Examples
//!
//! ```
//! use optimizer::sampler::differential_evolution::DifferentialEvolutionSampler;
//! use optimizer::{Direction, Study};
//!
//! let sampler = DifferentialEvolutionSampler::with_seed(42);
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
//! ```
use std::collections::HashMap;
use parking_lot::Mutex;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::{CompletedTrial, Sampler};
/// Differential Evolution mutation strategy.
///
/// Controls how mutant vectors are created from the current population.
#[derive(Clone, Copy, Debug, Default)]
pub enum DifferentialEvolutionStrategy {
/// DE/rand/1: `v = x_r1 + F * (x_r2 - x_r3)`
///
/// The most robust strategy. Uses three random population members.
#[default]
Rand1,
/// DE/best/1: `v = x_best + F * (x_r1 - x_r2)`
///
/// Greedier strategy that biases toward the current best solution.
Best1,
/// DE/current-to-best/1: `v = x_i + F * (x_best - x_i) + F * (x_r1 - x_r2)`
///
/// Balances exploration and exploitation by blending the current
/// individual with the best.
CurrentToBest1,
}
/// Differential Evolution sampler for continuous global optimization.
///
/// Maintains a population of candidate solutions. New candidates are
/// created by combining (mutating + crossing over) existing members.
///
/// # Examples
///
/// ```
/// use optimizer::sampler::differential_evolution::DifferentialEvolutionSampler;
/// use optimizer::{Direction, Study};
///
/// // Default configuration
/// let study: Study<f64> =
/// Study::with_sampler(Direction::Minimize, DifferentialEvolutionSampler::new());
///
/// // With seed for reproducibility
/// let study: Study<f64> = Study::with_sampler(
/// Direction::Minimize,
/// DifferentialEvolutionSampler::with_seed(42),
/// );
///
/// // Custom configuration via builder
/// use optimizer::sampler::differential_evolution::DifferentialEvolutionStrategy;
/// let sampler = DifferentialEvolutionSampler::builder()
/// .mutation_factor(0.8)
/// .crossover_rate(0.9)
/// .strategy(DifferentialEvolutionStrategy::Best1)
/// .population_size(30)
/// .seed(42)
/// .build();
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
/// ```
pub struct DifferentialEvolutionSampler {
state: Mutex<State>,
}
impl DifferentialEvolutionSampler {
/// Creates a new DE sampler with default settings and a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(State::new(
None,
0.8,
0.9,
DifferentialEvolutionStrategy::Rand1,
None,
)),
}
}
/// Creates a new DE sampler with a fixed seed for reproducibility.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(State::new(
None,
0.8,
0.9,
DifferentialEvolutionStrategy::Rand1,
Some(seed),
)),
}
}
/// Creates a builder for configuring a `DifferentialEvolutionSampler`.
#[must_use]
pub fn builder() -> DifferentialEvolutionSamplerBuilder {
DifferentialEvolutionSamplerBuilder::new()
}
}
impl Default for DifferentialEvolutionSampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for configuring a [`DifferentialEvolutionSampler`].
///
/// All options have sensible defaults:
/// - `population_size`: `max(10 * n_dims, 15)` (auto-computed from parameter count)
/// - `mutation_factor` (F): 0.8
/// - `crossover_rate` (CR): 0.9
/// - `strategy`: `Rand1`
/// - `seed`: random
///
/// # Examples
///
/// ```
/// use optimizer::sampler::differential_evolution::{
/// DifferentialEvolutionSamplerBuilder, DifferentialEvolutionStrategy,
/// };
///
/// let sampler = DifferentialEvolutionSamplerBuilder::new()
/// .mutation_factor(0.5)
/// .crossover_rate(0.7)
/// .strategy(DifferentialEvolutionStrategy::CurrentToBest1)
/// .population_size(20)
/// .seed(42)
/// .build();
/// ```
#[derive(Debug, Clone)]
pub struct DifferentialEvolutionSamplerBuilder {
population_size: Option<usize>,
mutation_factor: f64,
crossover_rate: f64,
strategy: DifferentialEvolutionStrategy,
seed: Option<u64>,
}
impl Default for DifferentialEvolutionSamplerBuilder {
fn default() -> Self {
Self::new()
}
}
impl DifferentialEvolutionSamplerBuilder {
/// Creates a new builder with default settings.
#[must_use]
pub fn new() -> Self {
Self {
population_size: None,
mutation_factor: 0.8,
crossover_rate: 0.9,
strategy: DifferentialEvolutionStrategy::Rand1,
seed: None,
}
}
/// Sets the population size.
///
/// Number of candidate solutions maintained across generations.
/// Larger populations improve robustness but require more evaluations
/// per generation.
///
/// Default: `max(10 * n_continuous_dims, 15)`.
#[must_use]
pub fn population_size(mut self, size: usize) -> Self {
self.population_size = Some(size);
self
}
/// Sets the mutation factor (F).
///
/// Controls the amplification of differential variation.
/// Typical values are in `[0.5, 1.0]`. Higher values increase
/// exploration; lower values favor exploitation.
///
/// Default: 0.8.
#[must_use]
pub fn mutation_factor(mut self, f: f64) -> Self {
self.mutation_factor = f;
self
}
/// Sets the crossover rate (CR).
///
/// Probability of each dimension being taken from the mutant vector
/// rather than the parent. Typical values are in `[0.7, 1.0]`.
///
/// Default: 0.9.
#[must_use]
pub fn crossover_rate(mut self, cr: f64) -> Self {
self.crossover_rate = cr;
self
}
/// Sets the mutation strategy.
///
/// Default: [`DifferentialEvolutionStrategy::Rand1`].
#[must_use]
pub fn strategy(mut self, strategy: DifferentialEvolutionStrategy) -> Self {
self.strategy = strategy;
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`DifferentialEvolutionSampler`].
#[must_use]
pub fn build(self) -> DifferentialEvolutionSampler {
DifferentialEvolutionSampler {
state: Mutex::new(State::new(
self.population_size,
self.mutation_factor,
self.crossover_rate,
self.strategy,
self.seed,
)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/// Describes how a parameter dimension maps into the DE internal vector.
#[derive(Clone, Debug)]
struct DimensionInfo {
/// The distribution for this dimension (stored for decoding).
distribution: Distribution,
/// Whether this dimension participates in DE (Float/Int = true, Categorical = false).
is_continuous: bool,
/// Internal-space bounds for continuous dimensions: `(low, high)`.
/// For log-scale parameters these are in log-space.
bounds: Option<(f64, f64)>,
}
/// A candidate solution produced by mutation + crossover.
#[derive(Clone, Debug)]
struct Candidate {
/// Internal-space vector (only continuous dimensions).
x: Vec<f64>,
/// Values for categorical dimensions (index in `dimensions` -> categorical index).
categorical_values: HashMap<usize, usize>,
/// Index of the population member this candidate competes against.
target_idx: usize,
}
/// Tracks per-trial sampling progress.
#[derive(Clone, Debug)]
struct TrialProgress {
/// Index of the candidate assigned to this trial.
candidate_idx: usize,
/// Next dimension to return for this trial.
next_dim: usize,
}
/// Phase of the DE state machine.
enum Phase {
/// Discovering the search space structure (first trial).
Discovery,
/// Active sampling and evolving.
Active,
}
/// Top-level mutable state behind the `Mutex`.
struct State {
/// The RNG used for sampling.
rng: fastrand::Rng,
/// User-provided population size (None = auto).
user_population_size: Option<usize>,
/// Mutation factor (F).
mutation_factor: f64,
/// Crossover rate (CR).
crossover_rate: f64,
/// Mutation strategy.
strategy: DifferentialEvolutionStrategy,
/// Current phase.
phase: Phase,
/// Discovered dimension info (populated during discovery).
dimensions: Vec<DimensionInfo>,
/// Last `trial_id` seen during discovery.
discovery_trial_id: Option<u64>,
// --- Population state ---
/// Current population (internal-space vectors, continuous dims only).
population: Vec<Vec<f64>>,
/// Categorical values for each population member.
population_categorical: Vec<HashMap<usize, usize>>,
/// Objective values for the current population.
population_values: Vec<f64>,
/// Index of the best population member.
best_idx: usize,
/// Whether the initial population has been evaluated.
initialized: bool,
/// Effective population size (resolved after discovery).
population_size: usize,
// --- Current generation ---
/// Current generation's candidates.
candidates: Vec<Candidate>,
/// Mapping from `trial_id` to its progress.
trial_progress: HashMap<u64, TrialProgress>,
/// Number of candidates assigned so far in the current generation.
assigned_count: usize,
/// Trial IDs assigned in the current generation.
generation_trial_ids: Vec<u64>,
}
impl State {
fn new(
user_population_size: Option<usize>,
mutation_factor: f64,
crossover_rate: f64,
strategy: DifferentialEvolutionStrategy,
seed: Option<u64>,
) -> Self {
let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
Self {
rng,
user_population_size,
mutation_factor,
crossover_rate,
strategy,
phase: Phase::Discovery,
dimensions: Vec::new(),
discovery_trial_id: None,
population: Vec::new(),
population_categorical: Vec::new(),
population_values: Vec::new(),
best_idx: 0,
initialized: false,
population_size: 0,
candidates: Vec::new(),
trial_progress: HashMap::new(),
assigned_count: 0,
generation_trial_ids: Vec::new(),
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Compute internal-space bounds for a distribution.
#[allow(clippy::cast_precision_loss)]
fn internal_bounds(distribution: &Distribution) -> Option<(f64, f64)> {
match distribution {
Distribution::Float(d) => {
if d.log_scale {
Some((d.low.ln(), d.high.ln()))
} else {
Some((d.low, d.high))
}
}
Distribution::Int(d) => {
if d.log_scale {
Some(((d.low as f64).ln(), (d.high as f64).ln()))
} else {
Some((d.low as f64, d.high as f64))
}
}
Distribution::Categorical(_) => None,
}
}
/// Convert an internal-space value back to a `ParamValue`.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn from_internal(value: f64, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low) / step).round();
d.low + k * step
} else {
v
};
ParamValue::Float(v.clamp(d.low, d.high))
}
Distribution::Int(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low as f64) / step as f64).round() as i64;
d.low + k * step
} else {
v.round() as i64
};
ParamValue::Int(v.clamp(d.low, d.high))
}
Distribution::Categorical(_) => {
unreachable!("from_internal should not be called for categorical distributions")
}
}
}
/// Sample a random value for any distribution.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn sample_random(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
Distribution::Int(d) => {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
/// Sample a random value in internal space for a continuous dimension.
fn sample_random_internal(rng: &mut fastrand::Rng, bounds: (f64, f64)) -> f64 {
rng_util::f64_range(rng, bounds.0, bounds.1)
}
/// Clamp a value to the given bounds.
fn clamp_to_bounds(value: f64, bounds: Option<(f64, f64)>) -> f64 {
if let Some((lo, hi)) = bounds {
value.clamp(lo, hi)
} else {
value
}
}
// ---------------------------------------------------------------------------
// DE algorithm
// ---------------------------------------------------------------------------
/// Select `count` distinct random indices from `0..n`, all different from `exclude`.
fn select_random_indices(
rng: &mut fastrand::Rng,
n: usize,
count: usize,
exclude: &[usize],
) -> Vec<usize> {
let mut selected = Vec::with_capacity(count);
while selected.len() < count {
let idx = rng.usize(0..n);
if !exclude.contains(&idx) && !selected.contains(&idx) {
selected.push(idx);
}
}
selected
}
/// Generate trial vectors (mutation + crossover) for the current population.
fn generate_trial_vectors(state: &mut State) -> Vec<Candidate> {
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let pop_size = state.population_size;
let mut candidates = Vec::with_capacity(pop_size);
for i in 0..pop_size {
// Mutation
let mutant = create_mutant_with_rng(state, i, n_continuous);
// Crossover (binomial)
let j_rand = state.rng.usize(0..n_continuous.max(1));
let trial_x: Vec<f64> = if n_continuous > 0 {
(0..n_continuous)
.map(|j| {
let use_mutant = j == j_rand || state.rng.f64() < state.crossover_rate;
let val = if use_mutant {
mutant[j]
} else {
state.population[i][j]
};
// Clamp to bounds
let dim_bounds = continuous_dim_bounds(&state.dimensions, j);
clamp_to_bounds(val, dim_bounds)
})
.collect()
} else {
Vec::new()
};
// Categorical: randomly sample (DE doesn't optimize categoricals)
let mut categorical_values = HashMap::new();
for (dim_idx, dim) in state.dimensions.iter().enumerate() {
if !dim.is_continuous
&& let Distribution::Categorical(cat) = &dim.distribution
{
categorical_values.insert(dim_idx, state.rng.usize(0..cat.n_choices));
}
}
candidates.push(Candidate {
x: trial_x,
categorical_values,
target_idx: i,
});
}
candidates
}
/// Create a mutant vector, consuming RNG from state.
fn create_mutant_with_rng(state: &mut State, target_idx: usize, n_continuous: usize) -> Vec<f64> {
if n_continuous == 0 {
return Vec::new();
}
let pop = &state.population;
let best_idx = state.best_idx;
let f = state.mutation_factor;
let pop_size = state.population_size;
match state.strategy {
DifferentialEvolutionStrategy::Rand1 => {
let indices = select_random_indices(&mut state.rng, pop_size, 3, &[target_idx]);
let (r1, r2, r3) = (indices[0], indices[1], indices[2]);
(0..n_continuous)
.map(|j| pop[r1][j] + f * (pop[r2][j] - pop[r3][j]))
.collect()
}
DifferentialEvolutionStrategy::Best1 => {
let indices = select_random_indices(&mut state.rng, pop_size, 2, &[target_idx]);
let (r1, r2) = (indices[0], indices[1]);
(0..n_continuous)
.map(|j| pop[best_idx][j] + f * (pop[r1][j] - pop[r2][j]))
.collect()
}
DifferentialEvolutionStrategy::CurrentToBest1 => {
let indices = select_random_indices(&mut state.rng, pop_size, 2, &[target_idx]);
let (r1, r2) = (indices[0], indices[1]);
(0..n_continuous)
.map(|j| {
pop[target_idx][j]
+ f * (pop[best_idx][j] - pop[target_idx][j])
+ f * (pop[r1][j] - pop[r2][j])
})
.collect()
}
}
}
/// Get the bounds for the j-th continuous dimension.
fn continuous_dim_bounds(
dimensions: &[DimensionInfo],
continuous_idx: usize,
) -> Option<(f64, f64)> {
let mut ci = 0;
for dim in dimensions {
if dim.is_continuous {
if ci == continuous_idx {
return dim.bounds;
}
ci += 1;
}
}
None
}
/// Generate the initial random population.
fn generate_initial_population(state: &mut State) -> Vec<Candidate> {
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let mut candidates = Vec::with_capacity(state.population_size);
for i in 0..state.population_size {
let x: Vec<f64> = if n_continuous > 0 {
let mut v = Vec::with_capacity(n_continuous);
for dim in &state.dimensions {
if dim.is_continuous {
let val = if let Some(bounds) = dim.bounds {
sample_random_internal(&mut state.rng, bounds)
} else {
0.0
};
v.push(val);
}
}
v
} else {
Vec::new()
};
let mut categorical_values = HashMap::new();
for (dim_idx, dim) in state.dimensions.iter().enumerate() {
if !dim.is_continuous
&& let Distribution::Categorical(cat) = &dim.distribution
{
categorical_values.insert(dim_idx, state.rng.usize(0..cat.n_choices));
}
}
candidates.push(Candidate {
x,
categorical_values,
target_idx: i,
});
}
candidates
}
// ---------------------------------------------------------------------------
// Sampler trait implementation
// ---------------------------------------------------------------------------
impl Sampler for DifferentialEvolutionSampler {
#[allow(clippy::cast_precision_loss)]
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
let mut state = self.state.lock();
match &state.phase {
Phase::Discovery => sample_discovery(&mut state, distribution, trial_id),
Phase::Active => sample_active(&mut state, distribution, trial_id, history),
}
}
}
/// Handle sampling during the discovery phase.
fn sample_discovery(state: &mut State, distribution: &Distribution, trial_id: u64) -> ParamValue {
// Check if this is a new trial (discovery phase ended for previous trial)
if let Some(prev_id) = state.discovery_trial_id
&& trial_id != prev_id
{
// First trial is done; we know the search space. Initialize DE.
finalize_discovery(state);
return sample_active(state, distribution, trial_id, &[]);
}
// Record this trial_id
state.discovery_trial_id = Some(trial_id);
// Record this dimension
let is_continuous = !matches!(distribution, Distribution::Categorical(_));
let bounds = internal_bounds(distribution);
state.dimensions.push(DimensionInfo {
distribution: distribution.clone(),
is_continuous,
bounds,
});
// Sample randomly for the discovery trial
sample_random(&mut state.rng, distribution)
}
/// Finalize discovery and transition to the active phase.
#[allow(clippy::cast_precision_loss)]
fn finalize_discovery(state: &mut State) {
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
// Resolve population size
state.population_size = state
.user_population_size
.unwrap_or_else(|| (10 * n_continuous).max(15));
// Ensure population size is at least 4 (DE needs distinct random indices)
state.population_size = state.population_size.max(4);
// Generate initial random population
state.candidates = generate_initial_population(state);
state.assigned_count = 0;
state.generation_trial_ids.clear();
state.trial_progress.clear();
state.phase = Phase::Active;
}
/// Handle sampling during the active phase.
fn sample_active(
state: &mut State,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
// Check if we need to process completed trials and start a new generation
maybe_update_generation(state, history);
// Assign a candidate to this trial if not yet done
if !state.trial_progress.contains_key(&trial_id) {
assign_candidate(state, trial_id);
}
let progress = state.trial_progress.get_mut(&trial_id).unwrap();
let dim_idx = progress.next_dim;
progress.next_dim += 1;
// Safety check
if dim_idx >= state.dimensions.len() {
return sample_random(&mut state.rng, distribution);
}
let candidate = &state.candidates[progress.candidate_idx];
let dim_info = &state.dimensions[dim_idx];
if dim_info.is_continuous {
// Map from overall dimension index to continuous index
let ci = state.dimensions[..dim_idx]
.iter()
.filter(|d| d.is_continuous)
.count();
if ci < candidate.x.len() {
from_internal(candidate.x[ci], &dim_info.distribution)
} else {
sample_random(&mut state.rng, distribution)
}
} else {
// Categorical: use pre-sampled value
if let Some(&cat_idx) = candidate.categorical_values.get(&dim_idx) {
ParamValue::Categorical(cat_idx)
} else {
sample_random(&mut state.rng, distribution)
}
}
}
/// Assign a candidate to a trial.
fn assign_candidate(state: &mut State, trial_id: u64) {
let candidate_idx = if state.assigned_count < state.candidates.len() {
let idx = state.assigned_count;
state.assigned_count += 1;
idx
} else {
// Overflow: generate an extra random candidate
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let x: Vec<f64> = (0..n_continuous)
.map(|j| {
let bounds = continuous_dim_bounds(&state.dimensions, j);
if let Some(b) = bounds {
sample_random_internal(&mut state.rng, b)
} else {
0.0
}
})
.collect();
let mut categorical_values = HashMap::new();
for (dim_idx, dim) in state.dimensions.iter().enumerate() {
if !dim.is_continuous
&& let Distribution::Categorical(cat) = &dim.distribution
{
categorical_values.insert(dim_idx, state.rng.usize(0..cat.n_choices));
}
}
state.candidates.push(Candidate {
x,
categorical_values,
target_idx: 0, // overflow candidates don't compete
});
let idx = state.candidates.len() - 1;
state.assigned_count = state.candidates.len();
idx
};
state.trial_progress.insert(
trial_id,
TrialProgress {
candidate_idx,
next_dim: 0,
},
);
state.generation_trial_ids.push(trial_id);
}
/// Check if we should process completed trials and start a new generation.
fn maybe_update_generation(state: &mut State, history: &[CompletedTrial]) {
let pop_size = state.population_size;
// Only update when at least pop_size candidates have been assigned
if state.generation_trial_ids.len() < pop_size {
return;
}
// Check if the first pop_size trial IDs are all completed
let trial_ids: Vec<u64> = state
.generation_trial_ids
.iter()
.take(pop_size)
.copied()
.collect();
let history_map: HashMap<u64, f64> = history.iter().map(|t| (t.id, t.value)).collect();
let all_completed = trial_ids.iter().all(|id| history_map.contains_key(id));
if !all_completed {
return;
}
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
if state.initialized {
// Subsequent generations: selection
perform_selection(state, &trial_ids, &history_map);
} else {
// First generation: store as initial population
initialize_population(state, &trial_ids, &history_map, n_continuous);
}
// Generate next generation's trial vectors
state.candidates = if state.initialized && n_continuous > 0 {
generate_trial_vectors(state)
} else {
generate_initial_population(state)
};
state.assigned_count = 0;
state.generation_trial_ids.clear();
state.trial_progress.clear();
}
/// Initialize the population from the first generation's results.
fn initialize_population(
state: &mut State,
trial_ids: &[u64],
history_map: &HashMap<u64, f64>,
_n_continuous: usize,
) {
state.population.clear();
state.population_categorical.clear();
state.population_values.clear();
let mut best_value = f64::INFINITY;
let mut best_idx = 0;
for (i, &trial_id) in trial_ids.iter().enumerate() {
let progress = &state.trial_progress[&trial_id];
let candidate = &state.candidates[progress.candidate_idx];
let value = history_map[&trial_id];
state.population.push(candidate.x.clone());
state
.population_categorical
.push(candidate.categorical_values.clone());
state.population_values.push(value);
if value < best_value {
best_value = value;
best_idx = i;
}
}
state.best_idx = best_idx;
state.initialized = true;
}
/// Perform DE selection: replace parent if trial vector is better.
fn perform_selection(state: &mut State, trial_ids: &[u64], history_map: &HashMap<u64, f64>) {
for &trial_id in trial_ids {
let progress = &state.trial_progress[&trial_id];
let candidate = &state.candidates[progress.candidate_idx];
let trial_value = history_map[&trial_id];
let target_idx = candidate.target_idx;
if target_idx < state.population_size && trial_value <= state.population_values[target_idx]
{
state.population[target_idx] = candidate.x.clone();
state.population_categorical[target_idx] = candidate.categorical_values.clone();
state.population_values[target_idx] = trial_value;
}
}
// Update best index
let mut best_value = f64::INFINITY;
let mut best_idx = 0;
for (i, &val) in state.population_values.iter().enumerate() {
if val < best_value {
best_value = val;
best_idx = i;
}
}
state.best_idx = best_idx;
}
#[cfg(test)]
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
mod tests {
use super::*;
use crate::distribution::FloatDistribution;
#[test]
fn test_de_sampler_basic_float() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let dist = Distribution::Float(FloatDistribution {
low: -5.0,
high: 5.0,
log_scale: false,
step: None,
});
// Sample many values and check bounds
for i in 0..100 {
let value = sampler.sample(&dist, i, &[]);
if let ParamValue::Float(v) = value {
assert!(
(-5.0..=5.0).contains(&v),
"value {v} out of bounds at trial {i}"
);
} else {
panic!("Expected Float value");
}
}
}
#[test]
fn test_de_sampler_reproducibility() {
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let sample_values = |seed: u64| {
let sampler = DifferentialEvolutionSampler::with_seed(seed);
(0..20)
.map(|i| sampler.sample(&dist, i, &[]))
.collect::<Vec<_>>()
};
let v1 = sample_values(42);
let v2 = sample_values(42);
assert_eq!(v1, v2, "same seed should produce same results");
let v3 = sample_values(99);
assert_ne!(v1, v3, "different seeds should produce different results");
}
#[test]
fn test_de_strategy_default() {
assert!(matches!(
DifferentialEvolutionStrategy::default(),
DifferentialEvolutionStrategy::Rand1
));
}
#[test]
fn test_builder_defaults() {
let builder = DifferentialEvolutionSamplerBuilder::new();
assert!(builder.population_size.is_none());
assert!((builder.mutation_factor - 0.8).abs() < f64::EPSILON);
assert!((builder.crossover_rate - 0.9).abs() < f64::EPSILON);
assert!(matches!(
builder.strategy,
DifferentialEvolutionStrategy::Rand1
));
assert!(builder.seed.is_none());
}
}
+572
View File
@@ -0,0 +1,572 @@
//! Shared types and genetic operators for evolutionary multi-objective samplers.
//!
//! This module extracts common functionality used by NSGA-II, NSGA-III, and MOEA/D:
//! candidate management, discovery/active phase logic, SBX crossover,
//! polynomial mutation, and Das-Dennis reference point generation.
use std::collections::HashMap;
use crate::distribution::Distribution;
use crate::multi_objective::MultiObjectiveTrial;
use crate::param::ParamValue;
use crate::rng_util;
/// Describes a parameter dimension discovered during the first trial.
#[derive(Clone, Debug)]
pub(crate) struct DimensionInfo {
pub distribution: Distribution,
}
/// A candidate solution: one value per dimension.
#[derive(Clone, Debug)]
pub(crate) struct Candidate {
pub params: Vec<ParamValue>,
}
/// Tracks per-trial sampling progress (which candidate, which dimension next).
#[derive(Clone, Debug)]
pub(crate) struct TrialProgress {
pub candidate_idx: usize,
pub next_dim: usize,
}
/// Phase of an evolutionary sampler.
pub(crate) enum Phase {
/// First trial reveals parameter dimensions.
Discovery,
/// Evolutionary optimisation.
Active,
}
/// Common state shared by all evolutionary multi-objective samplers.
pub(crate) struct EvolutionaryState {
pub rng: fastrand::Rng,
pub phase: Phase,
pub dimensions: Vec<DimensionInfo>,
pub population_size: usize,
pub candidates: Vec<Candidate>,
pub trial_progress: HashMap<u64, TrialProgress>,
pub assigned_count: usize,
pub generation_trial_ids: Vec<u64>,
pub discovery_trial_id: Option<u64>,
pub generation: usize,
}
impl EvolutionaryState {
pub(crate) fn new(seed: Option<u64>) -> Self {
let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
Self {
rng,
phase: Phase::Discovery,
dimensions: Vec::new(),
population_size: 4,
candidates: Vec::new(),
trial_progress: HashMap::new(),
assigned_count: 0,
generation_trial_ids: Vec::new(),
discovery_trial_id: None,
generation: 0,
}
}
}
// ---------------------------------------------------------------------------
// Discovery phase helpers
// ---------------------------------------------------------------------------
/// Handle sampling during the discovery phase.
///
/// Returns `Some(value)` if the discovery phase handled the sample,
/// or `None` if it transitioned to active phase and the caller should
/// generate candidates and sample from them.
pub(crate) fn sample_discovery(
evo: &mut EvolutionaryState,
distribution: &Distribution,
trial_id: u64,
) -> Option<ParamValue> {
if let Some(prev_id) = evo.discovery_trial_id
&& trial_id != prev_id
{
// A new trial arrived — transition to active phase
return None;
}
evo.discovery_trial_id = Some(trial_id);
evo.dimensions.push(DimensionInfo {
distribution: distribution.clone(),
});
Some(sample_random(&mut evo.rng, distribution))
}
/// Compute population size from dimensions and optional user override.
#[allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
pub(crate) fn compute_population_size(
n_dims: usize,
user_pop_size: Option<usize>,
minimum: usize,
) -> usize {
user_pop_size
.unwrap_or_else(|| (4.0 + 3.0 * (n_dims as f64).ln().max(0.0)).floor() as usize)
.max(minimum)
}
/// Transition from discovery to active phase.
pub(crate) fn finalize_discovery(evo: &mut EvolutionaryState, user_pop_size: Option<usize>) {
evo.population_size = compute_population_size(evo.dimensions.len(), user_pop_size, 4);
evo.phase = Phase::Active;
}
/// Generate `population_size` random candidates.
pub(crate) fn generate_random_candidates(evo: &mut EvolutionaryState) {
let pop = evo.population_size;
evo.candidates = (0..pop)
.map(|_| {
let params: Vec<ParamValue> = evo
.dimensions
.iter()
.map(|d| sample_random(&mut evo.rng, &d.distribution))
.collect();
Candidate { params }
})
.collect();
evo.assigned_count = 0;
evo.generation_trial_ids.clear();
evo.trial_progress.clear();
}
/// Assign a candidate to a trial and return the next dimension value.
pub(crate) fn sample_from_candidate(evo: &mut EvolutionaryState, trial_id: u64) -> ParamValue {
if !evo.trial_progress.contains_key(&trial_id) {
let candidate_idx = if evo.assigned_count < evo.candidates.len() {
let idx = evo.assigned_count;
evo.assigned_count += 1;
idx
} else {
// Overflow: generate a random candidate
let params: Vec<ParamValue> = evo
.dimensions
.iter()
.map(|d| sample_random(&mut evo.rng, &d.distribution))
.collect();
evo.candidates.push(Candidate { params });
let idx = evo.candidates.len() - 1;
evo.assigned_count = evo.candidates.len();
idx
};
evo.trial_progress.insert(
trial_id,
TrialProgress {
candidate_idx,
next_dim: 0,
},
);
evo.generation_trial_ids.push(trial_id);
}
let progress = evo.trial_progress.get_mut(&trial_id).unwrap();
let dim_idx = progress.next_dim;
progress.next_dim += 1;
if dim_idx >= evo.dimensions.len() {
return sample_random(&mut evo.rng, &evo.dimensions.last().unwrap().distribution);
}
evo.candidates[progress.candidate_idx].params[dim_idx].clone()
}
/// Extract parameter values from a trial, ordered by dimension index.
pub(crate) fn extract_trial_params(
trial: &MultiObjectiveTrial,
dimensions: &[DimensionInfo],
rng: &mut fastrand::Rng,
) -> Vec<ParamValue> {
let mut param_pairs: Vec<_> = trial.params.iter().collect();
param_pairs.sort_by_key(|(id, _)| *id);
dimensions
.iter()
.enumerate()
.map(|(dim_idx, dim_info)| {
if dim_idx < param_pairs.len() {
param_pairs[dim_idx].1.clone()
} else {
sample_random(rng, &dim_info.distribution)
}
})
.collect()
}
/// Install new offspring as the next generation's candidates.
pub(crate) fn advance_generation(evo: &mut EvolutionaryState, offspring: Vec<Candidate>) {
evo.candidates = offspring;
evo.assigned_count = 0;
evo.generation_trial_ids.clear();
evo.trial_progress.clear();
evo.generation += 1;
}
/// Check if the current generation is fully evaluated and return the
/// evaluated trials if so.
pub(crate) fn collect_evaluated_generation<'a>(
evo: &EvolutionaryState,
history: &'a [MultiObjectiveTrial],
) -> Option<Vec<&'a MultiObjectiveTrial>> {
let pop_size = evo.population_size;
if evo.generation_trial_ids.len() < pop_size {
return None;
}
let gen_ids: Vec<u64> = evo
.generation_trial_ids
.iter()
.take(pop_size)
.copied()
.collect();
let history_map: HashMap<u64, &MultiObjectiveTrial> =
history.iter().map(|t| (t.id, t)).collect();
if !gen_ids.iter().all(|id| history_map.contains_key(id)) {
return None;
}
Some(
gen_ids
.iter()
.filter_map(|id| history_map.get(id).copied())
.collect(),
)
}
// ---------------------------------------------------------------------------
// Genetic operators
// ---------------------------------------------------------------------------
/// SBX crossover for continuous params, uniform crossover for categorical.
pub(crate) fn crossover(
rng: &mut fastrand::Rng,
parent1: &[ParamValue],
parent2: &[ParamValue],
dimensions: &[DimensionInfo],
crossover_prob: f64,
eta: f64,
) -> (Vec<ParamValue>, Vec<ParamValue>) {
let n = parent1.len();
let mut child1 = parent1.to_vec();
let mut child2 = parent2.to_vec();
let u: f64 = rng_util::f64_range(rng, 0.0, 1.0);
if u > crossover_prob {
return (child1, child2);
}
for i in 0..n {
match (&parent1[i], &parent2[i], &dimensions[i].distribution) {
(ParamValue::Float(p1), ParamValue::Float(p2), Distribution::Float(d)) => {
if (p1 - p2).abs() < 1e-14 {
continue;
}
let (c1, c2) = sbx_crossover_f64(rng, *p1, *p2, d.low, d.high, eta);
child1[i] = ParamValue::Float(c1);
child2[i] = ParamValue::Float(c2);
}
(ParamValue::Int(p1), ParamValue::Int(p2), Distribution::Int(d)) => {
if p1 == p2 {
continue;
}
#[allow(clippy::cast_precision_loss)]
let (c1, c2) = sbx_crossover_f64(
rng,
*p1 as f64,
*p2 as f64,
d.low as f64,
d.high as f64,
eta,
);
#[allow(clippy::cast_possible_truncation)]
{
child1[i] = ParamValue::Int((c1.round() as i64).clamp(d.low, d.high));
child2[i] = ParamValue::Int((c2.round() as i64).clamp(d.low, d.high));
}
}
(ParamValue::Categorical(_), ParamValue::Categorical(_), _) => {
if rng_util::f64_range(rng, 0.0, 1.0) < 0.5 {
core::mem::swap(&mut child1[i], &mut child2[i]);
}
}
_ => {}
}
}
(child1, child2)
}
/// SBX crossover for a single float dimension.
pub(crate) fn sbx_crossover_f64(
rng: &mut fastrand::Rng,
p1: f64,
p2: f64,
low: f64,
high: f64,
eta: f64,
) -> (f64, f64) {
let u: f64 = rng_util::f64_range(rng, 0.0, 1.0);
let beta = if u <= 0.5 {
(2.0 * u).powf(1.0 / (eta + 1.0))
} else {
(1.0 / (2.0 * (1.0 - u))).powf(1.0 / (eta + 1.0))
};
let c1 = 0.5 * ((1.0 + beta) * p1 + (1.0 - beta) * p2);
let c2 = 0.5 * ((1.0 - beta) * p1 + (1.0 + beta) * p2);
(c1.clamp(low, high), c2.clamp(low, high))
}
/// Polynomial mutation for each dimension.
#[allow(clippy::cast_precision_loss)]
pub(crate) fn mutate(
rng: &mut fastrand::Rng,
individual: &mut [ParamValue],
dimensions: &[DimensionInfo],
eta: f64,
) {
let n = individual.len();
if n == 0 {
return;
}
let mutation_prob = 1.0 / n as f64;
for (i, value) in individual.iter_mut().enumerate() {
if rng_util::f64_range(rng, 0.0, 1.0) >= mutation_prob {
continue;
}
match (value, &dimensions[i].distribution) {
(v @ ParamValue::Float(_), Distribution::Float(d)) => {
let ParamValue::Float(x) = *v else {
unreachable!();
};
let mutated = polynomial_mutation_f64(rng, x, d.low, d.high, eta);
*v = ParamValue::Float(mutated);
}
(v @ ParamValue::Int(_), Distribution::Int(d)) => {
let ParamValue::Int(x) = *v else {
unreachable!();
};
#[allow(clippy::cast_possible_truncation)]
{
let mutated =
polynomial_mutation_f64(rng, x as f64, d.low as f64, d.high as f64, eta);
*v = ParamValue::Int((mutated.round() as i64).clamp(d.low, d.high));
}
}
(v @ ParamValue::Categorical(_), Distribution::Categorical(d)) => {
*v = ParamValue::Categorical(rng.usize(0..d.n_choices));
}
_ => {}
}
}
}
/// Polynomial mutation for a single float value.
pub(crate) fn polynomial_mutation_f64(
rng: &mut fastrand::Rng,
x: f64,
low: f64,
high: f64,
eta: f64,
) -> f64 {
let u: f64 = rng_util::f64_range(rng, 0.0, 1.0);
let range = high - low;
if range <= 0.0 {
return x;
}
let delta1 = (x - low) / range;
let delta2 = (high - x) / range;
let delta_q = if u < 0.5 {
let xy = 1.0 - delta1;
let val = 2.0 * u + (1.0 - 2.0 * u) * xy.powf(eta + 1.0);
val.powf(1.0 / (eta + 1.0)) - 1.0
} else {
let xy = 1.0 - delta2;
let val = 2.0 * (1.0 - u) + 2.0 * (u - 0.5) * xy.powf(eta + 1.0);
1.0 - val.powf(1.0 / (eta + 1.0))
};
(x + delta_q * range).clamp(low, high)
}
/// Random sampling for a single distribution.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
pub(crate) fn sample_random(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
Distribution::Int(d) => {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
// ---------------------------------------------------------------------------
// Das-Dennis reference point generation
// ---------------------------------------------------------------------------
/// Generate Das-Dennis (simplex-lattice) reference points.
///
/// Returns `C(H + M - 1, M - 1)` uniformly spaced points on the
/// `M`-dimensional unit simplex, where `M = n_objectives` and
/// `H = divisions`.
pub(crate) fn das_dennis(n_objectives: usize, divisions: usize) -> Vec<Vec<f64>> {
let mut points = Vec::new();
let mut point = vec![0.0_f64; n_objectives];
das_dennis_recursive(
n_objectives,
divisions,
0,
divisions,
&mut point,
&mut points,
);
points
}
#[allow(clippy::cast_precision_loss)]
fn das_dennis_recursive(
n_objectives: usize,
divisions: usize,
depth: usize,
remaining: usize,
current: &mut Vec<f64>,
result: &mut Vec<Vec<f64>>,
) {
if depth == n_objectives - 1 {
current[depth] = remaining as f64 / divisions as f64;
result.push(current.clone());
return;
}
for i in 0..=remaining {
current[depth] = i as f64 / divisions as f64;
das_dennis_recursive(
n_objectives,
divisions,
depth + 1,
remaining - i,
current,
result,
);
}
}
/// Choose the number of divisions for Das-Dennis to get close to a target
/// population size.
///
/// The number of reference points is `C(H + M - 1, M - 1)`. This function
/// finds the smallest `H` such that the number of points >= `target_pop`.
pub(crate) fn auto_divisions(n_objectives: usize, target_pop: usize) -> usize {
let m = n_objectives;
for h in 1..200 {
let n_points = n_combinations(h + m - 1, m - 1);
if n_points >= target_pop {
return h;
}
}
12
}
/// Compute `C(n, k)` = n! / (k! * (n-k)!).
fn n_combinations(n: usize, k: usize) -> usize {
if k > n {
return 0;
}
let k = k.min(n - k);
let mut result: usize = 1;
for i in 0..k {
result = result.saturating_mul(n - i) / (i + 1);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_das_dennis_2d() {
let points = das_dennis(2, 4);
// C(4+1, 1) = 5 points
assert_eq!(points.len(), 5);
for p in &points {
let sum: f64 = p.iter().sum();
assert!((sum - 1.0).abs() < 1e-10, "point {p:?} doesn't sum to 1");
}
}
#[test]
fn test_das_dennis_3d() {
let points = das_dennis(3, 4);
// C(4+2, 2) = 15 points
assert_eq!(points.len(), 15);
for p in &points {
let sum: f64 = p.iter().sum();
assert!((sum - 1.0).abs() < 1e-10);
}
}
#[test]
fn test_auto_divisions() {
// For 2 objectives targeting 10 points: H=9 gives C(10,1)=10
let h = auto_divisions(2, 10);
let n = n_combinations(h + 1, 1);
assert!(n >= 10);
// For 3 objectives targeting ~91 points: H=12 gives C(14,2)=91
let h3 = auto_divisions(3, 91);
let n3 = n_combinations(h3 + 2, 2);
assert!(n3 >= 91);
}
#[test]
fn test_n_combinations() {
assert_eq!(n_combinations(5, 2), 10);
assert_eq!(n_combinations(4, 0), 1);
assert_eq!(n_combinations(4, 4), 1);
assert_eq!(n_combinations(6, 3), 20);
}
}
+848
View File
@@ -0,0 +1,848 @@
//! Gaussian Process (GP) sampler with Expected Improvement acquisition.
//!
//! A classical Bayesian optimization sampler that uses a Gaussian Process
//! surrogate model with a Matérn 5/2 kernel and Expected Improvement (EI)
//! acquisition function. Best suited for small, expensive evaluations in
//! low-dimensional continuous spaces (d ≤ 20).
//!
//! Categorical parameters are sampled uniformly at random (not part of
//! the GP model). If all parameters are categorical, the sampler falls
//! back to pure random sampling.
//!
//! Requires the `gp` feature flag.
//!
//! # Examples
//!
//! ```
//! use optimizer::sampler::gp::GpSampler;
//! use optimizer::{Direction, Study};
//!
//! let sampler = GpSampler::with_seed(42);
//! let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
//! ```
use std::collections::HashMap;
use nalgebra::DMatrix;
use parking_lot::Mutex;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::{CompletedTrial, Sampler};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Gaussian Process sampler for Bayesian optimization.
///
/// Uses a GP surrogate with Matérn 5/2 kernel and Expected Improvement
/// acquisition to guide sampling toward promising regions of the search
/// space. Best suited for continuous (float/int) parameters in low
/// dimensions (up to ~20).
///
/// # Examples
///
/// ```
/// use optimizer::sampler::gp::GpSampler;
/// use optimizer::{Direction, Study};
///
/// // Default configuration
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, GpSampler::new());
///
/// // With seed for reproducibility
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, GpSampler::with_seed(42));
///
/// // Custom configuration via builder
/// let sampler = GpSampler::builder()
/// .n_startup_trials(15)
/// .n_candidates(2000)
/// .noise_variance(1e-4)
/// .seed(42)
/// .build();
/// let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
/// ```
pub struct GpSampler {
state: Mutex<GpState>,
}
impl GpSampler {
/// Creates a new GP sampler with a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(GpState::new(None, None, None, None)),
}
}
/// Creates a new GP sampler with a fixed seed for reproducibility.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(GpState::new(None, None, None, Some(seed))),
}
}
/// Creates a builder for configuring a `GpSampler`.
#[must_use]
pub fn builder() -> GpSamplerBuilder {
GpSamplerBuilder::new()
}
}
impl Default for GpSampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for configuring a [`GpSampler`].
///
/// All options have sensible defaults:
/// - `n_startup_trials`: 10
/// - `n_candidates`: 1000
/// - `noise_variance`: 1e-6
/// - `seed`: random
///
/// # Examples
///
/// ```
/// use optimizer::sampler::gp::GpSamplerBuilder;
///
/// let sampler = GpSamplerBuilder::new()
/// .n_startup_trials(15)
/// .n_candidates(2000)
/// .noise_variance(1e-4)
/// .seed(42)
/// .build();
/// ```
#[derive(Debug, Clone, Default)]
pub struct GpSamplerBuilder {
n_startup_trials: Option<usize>,
n_candidates: Option<usize>,
noise_variance: Option<f64>,
seed: Option<u64>,
}
impl GpSamplerBuilder {
/// Creates a new builder with default settings.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Sets the number of random trials before GP-guided sampling begins.
///
/// Default: 10.
#[must_use]
pub fn n_startup_trials(mut self, n: usize) -> Self {
self.n_startup_trials = Some(n);
self
}
/// Sets the number of random candidate points for acquisition optimization.
///
/// More candidates improve the quality of the acquisition maximum
/// at the cost of more GP predictions per trial.
///
/// Default: 1000.
#[must_use]
pub fn n_candidates(mut self, n: usize) -> Self {
self.n_candidates = Some(n);
self
}
/// Sets the observation noise variance added to the kernel diagonal.
///
/// Controls the assumed noise level. Larger values make the GP smoother.
///
/// Default: 1e-6 (near-noiseless).
#[must_use]
pub fn noise_variance(mut self, v: f64) -> Self {
self.noise_variance = Some(v);
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`GpSampler`].
#[must_use]
pub fn build(self) -> GpSampler {
GpSampler {
state: Mutex::new(GpState::new(
self.n_startup_trials,
self.n_candidates,
self.noise_variance,
self.seed,
)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
/// Default number of random startup trials before GP kicks in.
const DEFAULT_N_STARTUP: usize = 10;
/// Default number of candidate points for EI optimization.
const DEFAULT_N_CANDIDATES: usize = 1000;
/// Default observation noise variance.
const DEFAULT_NOISE_VAR: f64 = 1e-6;
/// Describes how a parameter dimension maps into the GP internal vector.
#[derive(Clone, Debug)]
struct DimensionInfo {
distribution: Distribution,
is_continuous: bool,
bounds: Option<(f64, f64)>,
}
/// Tracks per-trial sampling progress.
#[derive(Clone, Debug)]
struct TrialProgress {
/// The candidate values for each dimension.
values: Vec<ParamValue>,
/// Next dimension to return.
next_dim: usize,
}
/// Phase of the GP state machine.
enum GpPhase {
/// Discovering the search space (first trial).
Discovery,
/// Steady-state sampling.
Active,
}
/// A fitted GP model ready for predictions.
struct GpModel {
/// Cholesky factor L of K + σ²I.
cholesky: nalgebra::linalg::Cholesky<f64, nalgebra::Dyn>,
/// α = (K + σ²I)^{-1} y.
alpha: nalgebra::DVector<f64>,
/// Training inputs (each row is a data point, normalized to [0, 1]).
x_train: Vec<Vec<f64>>,
/// ARD lengthscales per dimension.
lengthscales: Vec<f64>,
/// Signal variance.
signal_var: f64,
/// Mean of original y values (for un-standardization, unused but kept for diagnostics).
_y_mean: f64,
/// Std dev of original y values (unused but kept for diagnostics).
_y_std: f64,
/// Best observed (standardized) y.
f_best: f64,
}
/// Top-level mutable state behind the `Mutex`.
struct GpState {
rng: fastrand::Rng,
n_startup_trials: usize,
n_candidates: usize,
noise_variance: f64,
phase: GpPhase,
dimensions: Vec<DimensionInfo>,
trial_progress: HashMap<u64, TrialProgress>,
discovery_trial_id: Option<u64>,
}
impl GpState {
fn new(
n_startup: Option<usize>,
n_candidates: Option<usize>,
noise_var: Option<f64>,
seed: Option<u64>,
) -> Self {
let rng = seed.map_or_else(fastrand::Rng::new, fastrand::Rng::with_seed);
Self {
rng,
n_startup_trials: n_startup.unwrap_or(DEFAULT_N_STARTUP),
n_candidates: n_candidates.unwrap_or(DEFAULT_N_CANDIDATES),
noise_variance: noise_var.unwrap_or(DEFAULT_NOISE_VAR),
phase: GpPhase::Discovery,
dimensions: Vec::new(),
trial_progress: HashMap::new(),
discovery_trial_id: None,
}
}
}
// ---------------------------------------------------------------------------
// Matérn 5/2 kernel
// ---------------------------------------------------------------------------
/// Matérn 5/2 kernel with ARD lengthscales.
///
/// `k(x1, x2) = σ² (1 + √5 r + 5/3 r²) exp(-√5 r)`
/// where `r = sqrt(Σ ((x1_i - x2_i) / l_i)²)`
fn matern52(x1: &[f64], x2: &[f64], lengthscales: &[f64], signal_var: f64) -> f64 {
let mut r_sq = 0.0;
for i in 0..x1.len() {
let diff = (x1[i] - x2[i]) / lengthscales[i];
r_sq += diff * diff;
}
let r = r_sq.sqrt();
let sqrt5_r = SQRT_5 * r;
signal_var * (1.0 + sqrt5_r + 5.0 / 3.0 * r_sq) * (-sqrt5_r).exp()
}
/// Build the kernel matrix `K + σ²I`.
fn kernel_matrix(
x: &[Vec<f64>],
lengthscales: &[f64],
signal_var: f64,
noise_var: f64,
) -> DMatrix<f64> {
let n = x.len();
DMatrix::from_fn(n, n, |i, j| {
let k = matern52(&x[i], &x[j], lengthscales, signal_var);
if i == j { k + noise_var } else { k }
})
}
/// Compute the kernel vector k(x*, X) for a test point.
fn kernel_vector(
x_star: &[f64],
x_train: &[Vec<f64>],
lengthscales: &[f64],
signal_var: f64,
) -> nalgebra::DVector<f64> {
nalgebra::DVector::from_fn(x_train.len(), |i, _| {
matern52(x_star, &x_train[i], lengthscales, signal_var)
})
}
/// Precomputed √5 constant.
const SQRT_5: f64 = 2.236_213_562_373_095;
// ---------------------------------------------------------------------------
// GP fitting and prediction
// ---------------------------------------------------------------------------
/// Fit a GP model to the training data.
///
/// Returns `None` if fitting fails (e.g. Cholesky decomposition failure).
#[allow(clippy::cast_precision_loss)]
fn fit_gp(x_train: &[Vec<f64>], y_train: &[f64], noise_var: f64) -> Option<GpModel> {
let n = y_train.len();
if n == 0 {
return None;
}
// Standardize y
let y_mean = y_train.iter().sum::<f64>() / n as f64;
let y_var = if n > 1 {
y_train.iter().map(|&y| (y - y_mean).powi(2)).sum::<f64>() / (n - 1) as f64
} else {
1.0
};
let y_std = y_var.sqrt().max(1e-10);
let y_standardized: Vec<f64> = y_train.iter().map(|&y| (y - y_mean) / y_std).collect();
let f_best = y_standardized.iter().copied().fold(f64::INFINITY, f64::min);
// ARD lengthscales: per-dimension std dev of training X, clamped
let d = if x_train.is_empty() {
0
} else {
x_train[0].len()
};
let lengthscales: Vec<f64> = (0..d)
.map(|j| {
let vals: Vec<f64> = x_train.iter().map(|x| x[j]).collect();
let mean_j = vals.iter().sum::<f64>() / n as f64;
let var_j = vals.iter().map(|&v| (v - mean_j).powi(2)).sum::<f64>() / n as f64;
var_j.sqrt().max(0.01)
})
.collect();
// Signal variance = 1.0 (data is standardized)
let signal_var = 1.0;
let k = kernel_matrix(x_train, &lengthscales, signal_var, noise_var);
let cholesky = nalgebra::linalg::Cholesky::new(k)?;
// α = (K + σ²I)^{-1} y
let y_vec = nalgebra::DVector::from_column_slice(&y_standardized);
let alpha = cholesky.solve(&y_vec);
Some(GpModel {
cholesky,
alpha,
x_train: x_train.to_vec(),
lengthscales,
signal_var,
_y_mean: y_mean,
_y_std: y_std,
f_best,
})
}
/// Predict mean and standard deviation at a test point.
fn predict(model: &GpModel, x: &[f64]) -> (f64, f64) {
let k_star = kernel_vector(x, &model.x_train, &model.lengthscales, model.signal_var);
// Mean: k*^T α
let mean = k_star.dot(&model.alpha);
// Variance: k(x*, x*) - k*^T (K + σ²I)^{-1} k*
let k_self = model.signal_var;
let v = model.cholesky.solve(&k_star);
let var = (k_self - k_star.dot(&v)).max(0.0);
(mean, var.sqrt())
}
// ---------------------------------------------------------------------------
// Normal distribution helpers (Abramowitz-Stegun approximation)
// ---------------------------------------------------------------------------
/// Standard normal PDF.
fn norm_pdf(x: f64) -> f64 {
const INV_SQRT_2PI: f64 = 0.398_942_280_401_432_7;
INV_SQRT_2PI * (-0.5 * x * x).exp()
}
/// Standard normal CDF (Abramowitz-Stegun rational approximation).
fn norm_cdf(x: f64) -> f64 {
// Hart approximation (higher precision than basic A&S)
if x < -8.0 {
return 0.0;
}
if x > 8.0 {
return 1.0;
}
let abs_x = x.abs();
let t = 1.0 / (1.0 + 0.231_641_9 * abs_x);
let t2 = t * t;
let t3 = t2 * t;
let t4 = t3 * t;
let t5 = t4 * t;
let poly = 0.319_381_530 * t - 0.356_563_782 * t2 + 1.781_477_937 * t3 - 1.821_255_978 * t4
+ 1.330_274_429 * t5;
let pdf = norm_pdf(abs_x);
let cdf = 1.0 - pdf * poly;
if x >= 0.0 { cdf } else { 1.0 - cdf }
}
// ---------------------------------------------------------------------------
// Expected Improvement
// ---------------------------------------------------------------------------
/// Compute Expected Improvement at a point.
///
/// `EI(x) = (f_best - mean) Φ(z) + std φ(z)`
/// where `z = (f_best - mean) / std`
fn expected_improvement(mean: f64, std: f64, f_best: f64) -> f64 {
if std < 1e-12 {
return (f_best - mean).max(0.0);
}
let z = (f_best - mean) / std;
let improvement = (f_best - mean) * norm_cdf(z) + std * norm_pdf(z);
improvement.max(0.0)
}
// ---------------------------------------------------------------------------
// Acquisition optimization
// ---------------------------------------------------------------------------
/// Find the point in [0, 1]^d that maximizes EI via multi-start random search.
fn optimize_acquisition(
model: &GpModel,
n_dims: usize,
n_candidates: usize,
rng: &mut fastrand::Rng,
) -> Vec<f64> {
let mut best_ei = f64::NEG_INFINITY;
let mut best_x = vec![0.5; n_dims];
for _ in 0..n_candidates {
let x: Vec<f64> = (0..n_dims)
.map(|_| rng_util::f64_range(rng, 0.0, 1.0))
.collect();
let (mean, std) = predict(model, &x);
let ei = expected_improvement(mean, std, model.f_best);
if ei > best_ei {
best_ei = ei;
best_x = x;
}
}
best_x
}
// ---------------------------------------------------------------------------
// Data preprocessing helpers
// ---------------------------------------------------------------------------
/// Compute internal-space bounds for a distribution.
#[allow(clippy::cast_precision_loss)]
fn internal_bounds(distribution: &Distribution) -> Option<(f64, f64)> {
match distribution {
Distribution::Float(d) => {
if d.log_scale {
Some((d.low.ln(), d.high.ln()))
} else {
Some((d.low, d.high))
}
}
Distribution::Int(d) => {
if d.log_scale {
Some(((d.low as f64).ln(), (d.high as f64).ln()))
} else {
Some((d.low as f64, d.high as f64))
}
}
Distribution::Categorical(_) => None,
}
}
/// Convert a value from internal space to a `ParamValue` in original space.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn from_internal(value: f64, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low) / step).round();
d.low + k * step
} else {
v
};
ParamValue::Float(v.clamp(d.low, d.high))
}
Distribution::Int(d) => {
let v = if d.log_scale { value.exp() } else { value };
let v = if let Some(step) = d.step {
let k = ((v - d.low as f64) / step as f64).round() as i64;
d.low + k * step
} else {
v.round() as i64
};
ParamValue::Int(v.clamp(d.low, d.high))
}
Distribution::Categorical(_) => {
unreachable!("from_internal should not be called for categorical distributions")
}
}
}
/// Convert an internal-space value to normalized [0, 1] using bounds.
fn to_normalized(value: f64, lo: f64, hi: f64) -> f64 {
if (hi - lo).abs() < 1e-15 {
0.5
} else {
(value - lo) / (hi - lo)
}
}
/// Convert a normalized [0, 1] value back to internal space.
fn from_normalized(value: f64, lo: f64, hi: f64) -> f64 {
lo + value * (hi - lo)
}
/// Convert a `ParamValue` to its internal-space representation.
#[allow(clippy::cast_precision_loss)]
fn to_internal(value: &ParamValue, distribution: &Distribution) -> f64 {
match (value, distribution) {
(ParamValue::Float(v), Distribution::Float(d)) => {
if d.log_scale {
v.ln()
} else {
*v
}
}
(ParamValue::Int(v), Distribution::Int(d)) => {
if d.log_scale {
(*v as f64).ln()
} else {
*v as f64
}
}
_ => 0.0,
}
}
/// Sample a random value for any distribution.
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
fn sample_random(rng: &mut fastrand::Rng, distribution: &Distribution) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
Distribution::Int(d) => {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
// ---------------------------------------------------------------------------
// Extract training data from history
// ---------------------------------------------------------------------------
/// Maximum number of training points to use for the GP.
/// Caps computational cost at O(`MAX_TRAIN_POINTS`^3) per trial.
const MAX_TRAIN_POINTS: usize = 100;
/// Establish a deterministic mapping from dimension index to `ParamId`
/// using the first trial in history.
///
/// Matches dimensions to params by distribution equality, consuming
/// matched params to correctly handle duplicate distributions.
fn establish_param_mapping(
trial: &CompletedTrial,
dimensions: &[DimensionInfo],
) -> Vec<Option<crate::parameter::ParamId>> {
use crate::parameter::ParamId;
let mut available: Vec<(ParamId, &Distribution)> =
trial.distributions.iter().map(|(id, d)| (*id, d)).collect();
// Sort for deterministic matching order
available.sort_by_key(|(id, _)| *id);
let mut mapping = Vec::with_capacity(dimensions.len());
for dim in dimensions {
let pos = available.iter().position(|(_, d)| **d == dim.distribution);
if let Some(pos) = pos {
mapping.push(Some(available.remove(pos).0));
} else {
mapping.push(None);
}
}
mapping
}
/// Build normalized training data from completed trials.
///
/// Returns `(x_train, y_train)` where x values are normalized to [0, 1]
/// per dimension using the bounds from `dimensions`. Only continuous
/// dimensions are included. Uses at most [`MAX_TRAIN_POINTS`] most recent
/// trials.
#[allow(clippy::cast_precision_loss)]
fn build_training_data(
history: &[CompletedTrial],
dimensions: &[DimensionInfo],
) -> (Vec<Vec<f64>>, Vec<f64>) {
if history.is_empty() {
return (Vec::new(), Vec::new());
}
// Use only the most recent trials to cap GP fitting cost
let start = history.len().saturating_sub(MAX_TRAIN_POINTS);
let recent = &history[start..];
// Establish dimension → ParamId mapping from the first trial
let param_mapping = establish_param_mapping(&recent[0], dimensions);
let continuous_indices: Vec<usize> = dimensions
.iter()
.enumerate()
.filter(|(_, d)| d.is_continuous)
.map(|(i, _)| i)
.collect();
let mut x_train = Vec::with_capacity(recent.len());
let mut y_train = Vec::with_capacity(recent.len());
for trial in recent {
let mut x_row = Vec::with_capacity(continuous_indices.len());
let mut valid = true;
for &dim_idx in &continuous_indices {
let dim_info = &dimensions[dim_idx];
if let Some(param_id) = param_mapping[dim_idx] {
if let Some(param_val) = trial.params.get(&param_id) {
let internal = to_internal(param_val, &dim_info.distribution);
let (lo, hi) = dim_info.bounds.unwrap_or((0.0, 1.0));
x_row.push(to_normalized(internal, lo, hi));
} else {
valid = false;
break;
}
} else {
valid = false;
break;
}
}
if valid && x_row.len() == continuous_indices.len() {
x_train.push(x_row);
y_train.push(trial.value);
}
}
(x_train, y_train)
}
// ---------------------------------------------------------------------------
// Sampler trait implementation
// ---------------------------------------------------------------------------
impl Sampler for GpSampler {
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
let mut state = self.state.lock();
match &state.phase {
GpPhase::Discovery => sample_discovery(&mut state, distribution, trial_id),
GpPhase::Active => sample_active(&mut state, distribution, trial_id, history),
}
}
}
/// Handle sampling during the discovery phase.
fn sample_discovery(state: &mut GpState, distribution: &Distribution, trial_id: u64) -> ParamValue {
// A new trial_id means discovery is done
if let Some(prev_id) = state.discovery_trial_id
&& trial_id != prev_id
{
finalize_discovery(state);
return sample_active(state, distribution, trial_id, &[]);
}
state.discovery_trial_id = Some(trial_id);
let is_continuous = !matches!(distribution, Distribution::Categorical(_));
let bounds = internal_bounds(distribution);
state.dimensions.push(DimensionInfo {
distribution: distribution.clone(),
is_continuous,
bounds,
});
sample_random(&mut state.rng, distribution)
}
/// Finalize discovery and transition to the active phase.
fn finalize_discovery(state: &mut GpState) {
state.phase = GpPhase::Active;
state.trial_progress.clear();
}
/// Handle sampling during the active phase.
fn sample_active(
state: &mut GpState,
distribution: &Distribution,
trial_id: u64,
history: &[CompletedTrial],
) -> ParamValue {
// If this trial already has progress, return the next pre-computed value
if let Some(progress) = state.trial_progress.get_mut(&trial_id) {
let dim_idx = progress.next_dim;
progress.next_dim += 1;
if dim_idx < progress.values.len() {
return progress.values[dim_idx].clone();
}
// Extra dimension not seen during discovery
return sample_random(&mut state.rng, distribution);
}
// New trial: compute all dimension values at once
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let use_gp = n_continuous > 0 && history.len() >= state.n_startup_trials;
let values = if use_gp {
compute_gp_candidate(state, history)
} else {
compute_random_candidate(state)
};
let first_value = values
.first()
.cloned()
.unwrap_or_else(|| sample_random(&mut state.rng, distribution));
state.trial_progress.insert(
trial_id,
TrialProgress {
values,
next_dim: 1,
},
);
first_value
}
/// Compute a candidate using the GP model.
fn compute_gp_candidate(state: &mut GpState, history: &[CompletedTrial]) -> Vec<ParamValue> {
let (x_train, y_train) = build_training_data(history, &state.dimensions);
// Try to fit GP; fall back to random if it fails
let model = fit_gp(&x_train, &y_train, state.noise_variance);
let n_continuous = state.dimensions.iter().filter(|d| d.is_continuous).count();
let normalized_candidate = if let Some(ref model) = model {
optimize_acquisition(model, n_continuous, state.n_candidates, &mut state.rng)
} else {
// GP fitting failed; use random
(0..n_continuous)
.map(|_| rng_util::f64_range(&mut state.rng, 0.0, 1.0))
.collect()
};
// Convert normalized candidate back to parameter values
let mut values = Vec::with_capacity(state.dimensions.len());
let mut ci = 0; // continuous dimension index
for dim in &state.dimensions {
if dim.is_continuous {
let (lo, hi) = dim.bounds.unwrap_or((0.0, 1.0));
let internal_val = from_normalized(normalized_candidate[ci], lo, hi);
values.push(from_internal(internal_val, &dim.distribution));
ci += 1;
} else {
values.push(sample_random(&mut state.rng, &dim.distribution));
}
}
values
}
/// Compute a random candidate for all dimensions.
fn compute_random_candidate(state: &mut GpState) -> Vec<ParamValue> {
state
.dimensions
.iter()
.map(|dim| sample_random(&mut state.rng, &dim.distribution))
.collect()
}
+8
View File
@@ -3,7 +3,15 @@
pub mod bohb;
#[cfg(feature = "cma-es")]
pub mod cma_es;
pub mod differential_evolution;
pub(crate) mod genetic;
#[cfg(feature = "gp")]
pub mod gp;
pub mod grid;
pub mod moead;
pub mod motpe;
pub mod nsga2;
pub mod nsga3;
pub mod random;
#[cfg(feature = "sobol")]
pub mod sobol;
+603
View File
@@ -0,0 +1,603 @@
//! MOEA/D (Multi-Objective Evolutionary Algorithm based on Decomposition) sampler.
//!
//! Decomposes a multi-objective problem into scalar subproblems using
//! weight vectors and solves them collaboratively. Supports Weighted Sum,
//! Tchebycheff, and Penalty-based Boundary Intersection (PBI) scalarization.
//!
//! # Examples
//!
//! ```
//! use optimizer::Direction;
//! use optimizer::multi_objective::MultiObjectiveStudy;
//! use optimizer::parameter::{FloatParam, Parameter};
//! use optimizer::sampler::moead::MoeadSampler;
//!
//! let sampler = MoeadSampler::with_seed(42);
//! let study =
//! MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
//!
//! let x = FloatParam::new(0.0, 1.0);
//! study
//! .optimize(100, |trial| {
//! let xv = x.suggest(trial)?;
//! Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
//! })
//! .unwrap();
//! ```
use parking_lot::Mutex;
use super::genetic::{
self, Candidate, EvolutionaryState, Phase, advance_generation, auto_divisions,
collect_evaluated_generation, crossover, das_dennis, extract_trial_params,
generate_random_candidates, mutate, sample_from_candidate, sample_random,
};
use crate::distribution::Distribution;
use crate::multi_objective::MultiObjectiveTrial;
use crate::param::ParamValue;
use crate::types::Direction;
/// Decomposition (scalarization) method for MOEA/D.
#[derive(Debug, Clone, Default)]
pub enum Decomposition {
/// Weighted sum: `sum(w_i * f_i)`.
WeightedSum,
/// Tchebycheff: `max(w_i * |f_i - z_i*|)`.
#[default]
Tchebycheff,
/// Penalty-based Boundary Intersection with parameter theta.
Pbi {
/// Penalty parameter controlling the balance between convergence
/// and diversity. Default: 5.0.
theta: f64,
},
}
/// MOEA/D sampler for multi-objective optimization.
///
/// Decomposes the multi-objective problem into scalar subproblems
/// using weight vectors, solving them collaboratively via
/// neighborhood-based mating and replacement.
pub struct MoeadSampler {
state: Mutex<MoeadState>,
}
impl MoeadSampler {
/// Creates a new MOEA/D sampler with a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(MoeadState::new(MoeadConfig::default(), None)),
}
}
/// Creates a new MOEA/D sampler with a fixed seed.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(MoeadState::new(MoeadConfig::default(), Some(seed))),
}
}
/// Creates a builder for configuring a `MoeadSampler`.
#[must_use]
pub fn builder() -> MoeadSamplerBuilder {
MoeadSamplerBuilder::default()
}
}
impl Default for MoeadSampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for [`MoeadSampler`].
#[derive(Debug, Clone, Default)]
pub struct MoeadSamplerBuilder {
population_size: Option<usize>,
neighborhood_size: Option<usize>,
decomposition: Decomposition,
crossover_prob: Option<f64>,
crossover_eta: Option<f64>,
mutation_eta: Option<f64>,
seed: Option<u64>,
}
impl MoeadSamplerBuilder {
/// Sets the population size. If unset, equals the number of
/// Das-Dennis weight vectors.
#[must_use]
pub fn population_size(mut self, size: usize) -> Self {
self.population_size = Some(size);
self
}
/// Sets the neighborhood size (T). Default: `min(20, pop_size)`.
#[must_use]
pub fn neighborhood_size(mut self, size: usize) -> Self {
self.neighborhood_size = Some(size);
self
}
/// Sets the decomposition method. Default: Tchebycheff.
#[must_use]
pub fn decomposition(mut self, decomp: Decomposition) -> Self {
self.decomposition = decomp;
self
}
/// Sets the crossover probability. Default: 1.0.
#[must_use]
pub fn crossover_prob(mut self, prob: f64) -> Self {
self.crossover_prob = Some(prob);
self
}
/// Sets the SBX distribution index. Default: 20.0.
#[must_use]
pub fn crossover_eta(mut self, eta: f64) -> Self {
self.crossover_eta = Some(eta);
self
}
/// Sets the polynomial mutation distribution index. Default: 20.0.
#[must_use]
pub fn mutation_eta(mut self, eta: f64) -> Self {
self.mutation_eta = Some(eta);
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`MoeadSampler`].
#[must_use]
pub fn build(self) -> MoeadSampler {
let config = MoeadConfig {
user_population_size: self.population_size,
neighborhood_size: self.neighborhood_size,
decomposition: self.decomposition,
crossover_prob: self.crossover_prob.unwrap_or(1.0),
crossover_eta: self.crossover_eta.unwrap_or(20.0),
mutation_eta: self.mutation_eta.unwrap_or(20.0),
};
MoeadSampler {
state: Mutex::new(MoeadState::new(config, self.seed)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone)]
struct MoeadConfig {
user_population_size: Option<usize>,
neighborhood_size: Option<usize>,
decomposition: Decomposition,
crossover_prob: f64,
crossover_eta: f64,
mutation_eta: f64,
}
impl Default for MoeadConfig {
fn default() -> Self {
Self {
user_population_size: None,
neighborhood_size: None,
decomposition: Decomposition::default(),
crossover_prob: 1.0,
crossover_eta: 20.0,
mutation_eta: 20.0,
}
}
}
struct MoeadState {
evo: EvolutionaryState,
config: MoeadConfig,
/// Weight vectors (Das-Dennis), one per subproblem.
weight_vectors: Vec<Vec<f64>>,
/// Neighborhoods: for each subproblem, indices of T nearest weight vectors.
neighborhoods: Vec<Vec<usize>>,
/// Ideal point z* (best per-objective in minimize-space).
ideal_point: Vec<f64>,
/// Current population's objective values in minimize-space (one per subproblem).
population_values: Vec<Vec<f64>>,
/// Current population's parameter vectors (one per subproblem).
population_params: Vec<Vec<ParamValue>>,
/// Whether the MOEA/D state has been initialized.
initialized: bool,
}
impl MoeadState {
fn new(config: MoeadConfig, seed: Option<u64>) -> Self {
Self {
evo: EvolutionaryState::new(seed),
config,
weight_vectors: Vec::new(),
neighborhoods: Vec::new(),
ideal_point: Vec::new(),
population_values: Vec::new(),
population_params: Vec::new(),
initialized: false,
}
}
}
// ---------------------------------------------------------------------------
// MultiObjectiveSampler implementation
// ---------------------------------------------------------------------------
impl crate::multi_objective::MultiObjectiveSampler for MoeadSampler {
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) -> ParamValue {
let mut state = self.state.lock();
match &state.evo.phase {
Phase::Discovery => {
if let Some(value) =
genetic::sample_discovery(&mut state.evo, distribution, trial_id)
{
return value;
}
// Transitioned to active phase
initialize_moead(&mut state, directions);
generate_random_candidates(&mut state.evo);
sample_from_candidate(&mut state.evo, trial_id)
}
Phase::Active => {
maybe_generate_new_generation(&mut state, history, directions);
sample_from_candidate(&mut state.evo, trial_id)
}
}
}
}
/// Initialize MOEA/D: weight vectors, neighborhoods, ideal point.
fn initialize_moead(state: &mut MoeadState, directions: &[Direction]) {
let n_obj = directions.len();
// Generate weight vectors
let divisions = auto_divisions(n_obj, state.config.user_population_size.unwrap_or(100));
state.weight_vectors = das_dennis(n_obj, divisions);
let pop_size = state
.config
.user_population_size
.unwrap_or(state.weight_vectors.len())
.max(4);
// Trim or pad weight vectors to match population size
state.weight_vectors.truncate(pop_size);
while state.weight_vectors.len() < pop_size {
// Duplicate random existing weight vectors
let idx = state.evo.rng.usize(0..state.weight_vectors.len());
let w = state.weight_vectors[idx].clone();
state.weight_vectors.push(w);
}
// Compute neighborhoods
let t = state
.config
.neighborhood_size
.unwrap_or_else(|| 20.min(pop_size));
let t = t.min(pop_size);
state.neighborhoods = compute_neighborhoods(&state.weight_vectors, t);
state.evo.population_size = pop_size;
state.evo.phase = Phase::Active;
state.ideal_point = vec![f64::INFINITY; n_obj];
state.initialized = true;
}
/// Compute T-nearest neighborhoods by Euclidean distance between weight vectors.
fn compute_neighborhoods(weights: &[Vec<f64>], t: usize) -> Vec<Vec<usize>> {
let n = weights.len();
weights
.iter()
.map(|wi| {
let mut distances: Vec<(usize, f64)> = (0..n)
.map(|j| {
let d: f64 = wi
.iter()
.zip(&weights[j])
.map(|(&a, &b)| (a - b).powi(2))
.sum::<f64>()
.sqrt();
(j, d)
})
.collect();
distances.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal));
distances.into_iter().take(t).map(|(idx, _)| idx).collect()
})
.collect()
}
/// Convert values to minimize-space.
fn to_minimize_space(values: &[f64], directions: &[Direction]) -> Vec<f64> {
values
.iter()
.zip(directions)
.map(|(&v, d)| match d {
Direction::Minimize => v,
Direction::Maximize => -v,
})
.collect()
}
fn maybe_generate_new_generation(
state: &mut MoeadState,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) {
if state.evo.candidates.is_empty() {
generate_random_candidates(&mut state.evo);
return;
}
if let Some(evaluated) = collect_evaluated_generation(&state.evo, history) {
let offspring = moead_generate_offspring(state, &evaluated, directions);
advance_generation(&mut state.evo, offspring);
}
}
// ---------------------------------------------------------------------------
// Scalarization functions
// ---------------------------------------------------------------------------
/// Weighted sum scalarization: `sum(w_i * f_i)`.
fn scalarize_weighted_sum(values: &[f64], weight: &[f64]) -> f64 {
values.iter().zip(weight).map(|(&v, &w)| w * v).sum()
}
/// Tchebycheff scalarization: `max(w_i * |f_i - z_i*|)`.
fn scalarize_tchebycheff(values: &[f64], weight: &[f64], ideal: &[f64]) -> f64 {
values
.iter()
.zip(weight)
.zip(ideal)
.map(|((&v, &w), &z)| {
let w = if w < 1e-6 { 1e-6 } else { w };
w * (v - z).abs()
})
.fold(f64::NEG_INFINITY, f64::max)
}
/// PBI scalarization: `d1 + theta * d2`.
///
/// d1 = projection onto weight direction, d2 = perpendicular distance.
fn scalarize_pbi(values: &[f64], weight: &[f64], ideal: &[f64], theta: f64) -> f64 {
let n = values.len();
// Direction from ideal to the point
let diff: Vec<f64> = values.iter().zip(ideal).map(|(&v, &z)| v - z).collect();
// Normalize weight vector
let w_norm: f64 = weight.iter().map(|&w| w * w).sum::<f64>().sqrt();
if w_norm < 1e-30 {
return f64::INFINITY;
}
let w_unit: Vec<f64> = weight.iter().map(|&w| w / w_norm).collect();
// d1 = projection of diff onto weight direction
let d1: f64 = diff.iter().zip(&w_unit).map(|(&d, &w)| d * w).sum();
// d2 = perpendicular distance
let d2_sq: f64 = (0..n)
.map(|i| {
let proj = d1 * w_unit[i];
(diff[i] - proj).powi(2)
})
.sum::<f64>();
d1 + theta * d2_sq.sqrt()
}
/// Evaluate scalarization for a given decomposition method.
fn scalarize(values: &[f64], weight: &[f64], ideal: &[f64], decomposition: &Decomposition) -> f64 {
match decomposition {
Decomposition::WeightedSum => scalarize_weighted_sum(values, weight),
Decomposition::Tchebycheff => scalarize_tchebycheff(values, weight, ideal),
Decomposition::Pbi { theta } => scalarize_pbi(values, weight, ideal, *theta),
}
}
// ---------------------------------------------------------------------------
// MOEA/D generation algorithm
// ---------------------------------------------------------------------------
fn moead_generate_offspring(
state: &mut MoeadState,
population: &[&MultiObjectiveTrial],
directions: &[Direction],
) -> Vec<Candidate> {
let pop_size = state.evo.population_size;
if population.len() < 2 {
return (0..pop_size)
.map(|_| {
let params = state
.evo
.dimensions
.iter()
.map(|d| sample_random(&mut state.evo.rng, &d.distribution))
.collect();
Candidate { params }
})
.collect();
}
// Extract current population parameters and objective values
let current_params: Vec<Vec<ParamValue>> = population
.iter()
.map(|t| extract_trial_params(t, &state.evo.dimensions, &mut state.evo.rng))
.collect();
let current_values: Vec<Vec<f64>> = population
.iter()
.map(|t| to_minimize_space(&t.values, directions))
.collect();
// Update ideal point
for vals in &current_values {
for (i, &v) in vals.iter().enumerate() {
if i < state.ideal_point.len() && v < state.ideal_point[i] {
state.ideal_point[i] = v;
}
}
}
// Assign each solution to its best subproblem via scalarization
// and select the best solution for each subproblem as its representative
let n_weights = state.weight_vectors.len();
let mut best_for_subproblem: Vec<usize> = Vec::with_capacity(n_weights);
for j in 0..n_weights {
let mut best_idx = 0;
let mut best_val = f64::INFINITY;
for (k, vals) in current_values.iter().enumerate() {
let s = scalarize(
vals,
&state.weight_vectors[j],
&state.ideal_point,
&state.config.decomposition,
);
if s < best_val {
best_val = s;
best_idx = k;
}
}
best_for_subproblem.push(best_idx);
}
// Store current population state
state.population_values = current_values;
state.population_params = current_params;
// Generate offspring: for each subproblem, mate from neighborhood
let mut offspring = Vec::with_capacity(pop_size);
for i in 0..pop_size.min(state.neighborhoods.len()) {
let neighborhood = &state.neighborhoods[i];
// Pick two parents from the neighborhood using subproblem assignments
let n1 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
let n2 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
let p1_idx = best_for_subproblem[n1 % best_for_subproblem.len()];
let p2_idx = best_for_subproblem[n2 % best_for_subproblem.len()];
let p1 = &state.population_params[p1_idx];
let p2 = &state.population_params[p2_idx];
let (mut child1, _child2) = crossover(
&mut state.evo.rng,
p1,
p2,
&state.evo.dimensions,
state.config.crossover_prob,
state.config.crossover_eta,
);
mutate(
&mut state.evo.rng,
&mut child1,
&state.evo.dimensions,
state.config.mutation_eta,
);
offspring.push(Candidate { params: child1 });
}
// If pop_size > neighborhoods, fill remaining with random neighborhood crossover
while offspring.len() < pop_size {
let i = state.evo.rng.usize(0..state.neighborhoods.len());
let neighborhood = &state.neighborhoods[i];
let n1 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
let n2 = neighborhood[state.evo.rng.usize(0..neighborhood.len())];
let p1_idx = best_for_subproblem[n1 % best_for_subproblem.len()];
let p2_idx = best_for_subproblem[n2 % best_for_subproblem.len()];
let (mut child1, _) = crossover(
&mut state.evo.rng,
&state.population_params[p1_idx],
&state.population_params[p2_idx],
&state.evo.dimensions,
state.config.crossover_prob,
state.config.crossover_eta,
);
mutate(
&mut state.evo.rng,
&mut child1,
&state.evo.dimensions,
state.config.mutation_eta,
);
offspring.push(Candidate { params: child1 });
}
offspring
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scalarize_weighted_sum() {
let values = [1.0, 2.0, 3.0];
let weight = [0.5, 0.3, 0.2];
let result = scalarize_weighted_sum(&values, &weight);
assert!((result - (0.5 + 0.6 + 0.6)).abs() < 1e-10);
}
#[test]
fn test_scalarize_tchebycheff() {
let values = [3.0, 2.0];
let weight = [0.5, 0.5];
let ideal = [1.0, 1.0];
let result = scalarize_tchebycheff(&values, &weight, &ideal);
// max(0.5 * |3-1|, 0.5 * |2-1|) = max(1.0, 0.5) = 1.0
assert!((result - 1.0).abs() < 1e-10);
}
#[test]
fn test_scalarize_pbi() {
let values = [2.0, 2.0];
let weight = [1.0, 1.0];
let ideal = [0.0, 0.0];
let result = scalarize_pbi(&values, &weight, &ideal, 5.0);
// d1 = projection of (2,2) onto (1/√2, 1/√2) = 2*√2
// d2 = 0 (point is on the weight direction)
let expected_d1 = 2.0 * (2.0_f64).sqrt();
assert!((result - expected_d1).abs() < 1e-10);
}
#[test]
fn test_compute_neighborhoods() {
let weights = vec![vec![1.0, 0.0], vec![0.5, 0.5], vec![0.0, 1.0]];
let neighborhoods = compute_neighborhoods(&weights, 2);
assert_eq!(neighborhoods.len(), 3);
// Each neighborhood should have 2 entries
for n in &neighborhoods {
assert_eq!(n.len(), 2);
}
// First weight [1,0] should be closest to itself and [0.5,0.5]
assert_eq!(neighborhoods[0][0], 0); // itself
assert_eq!(neighborhoods[0][1], 1); // nearest neighbor
}
}
+928
View File
@@ -0,0 +1,928 @@
//! Multi-Objective Tree-Parzen Estimator (MOTPE) sampler.
//!
//! Extends TPE to handle multi-objective optimization by using Pareto
//! non-dominated sorting to define "good" vs "bad" trial regions for
//! the KDE models, replacing the single-objective gamma-based split.
//!
//! # Algorithm
//!
//! In single-objective TPE, trials are sorted by value and split at a
//! gamma percentile into good/bad groups. MOTPE replaces this with:
//!
//! 1. Compute non-dominated sorting on all completed trials
//! 2. Use the Pareto front (rank 0) as "good" trials
//! 3. Use dominated trials as "bad" trials
//! 4. Build KDE l(x) from good, g(x) from bad
//! 5. Sample candidates and score by l(x)/g(x)
//!
//! # Examples
//!
//! ```
//! use optimizer::Direction;
//! use optimizer::multi_objective::MultiObjectiveStudy;
//! use optimizer::parameter::{FloatParam, Parameter};
//! use optimizer::sampler::motpe::MotpeSampler;
//!
//! let sampler = MotpeSampler::builder().seed(42).build();
//! let study =
//! MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
//!
//! let x = FloatParam::new(0.0, 1.0);
//! study
//! .optimize(30, |trial| {
//! let xv = x.suggest(trial)?;
//! Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
//! })
//! .unwrap();
//!
//! let front = study.pareto_front();
//! assert!(!front.is_empty());
//! ```
use parking_lot::Mutex;
use crate::distribution::Distribution;
use crate::kde::KernelDensityEstimator;
use crate::multi_objective::{MultiObjectiveSampler, MultiObjectiveTrial};
use crate::param::ParamValue;
use crate::types::{Direction, TrialState};
use crate::{pareto, rng_util};
/// Multi-Objective TPE (MOTPE) sampler for multi-objective Bayesian optimization.
///
/// Uses Pareto non-dominated sorting to split completed trials into
/// "good" (non-dominated, rank 0) and "bad" (dominated) groups, then
/// fits kernel density estimators to each group and samples new points
/// that maximize l(x)/g(x).
///
/// During the startup phase (fewer than `n_startup_trials` completed),
/// MOTPE falls back to random sampling.
///
/// # Examples
///
/// ```
/// use optimizer::Direction;
/// use optimizer::multi_objective::MultiObjectiveStudy;
/// use optimizer::parameter::{FloatParam, Parameter};
/// use optimizer::sampler::motpe::MotpeSampler;
///
/// let sampler = MotpeSampler::builder()
/// .n_startup_trials(10)
/// .n_ei_candidates(24)
/// .seed(42)
/// .build();
///
/// let study =
/// MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
/// ```
pub struct MotpeSampler {
/// Number of trials before MOTPE kicks in (uses random sampling before this).
n_startup_trials: usize,
/// Number of candidate samples to evaluate when selecting the next point.
n_ei_candidates: usize,
/// Optional fixed bandwidth for KDE. If None, uses Scott's rule.
kde_bandwidth: Option<f64>,
/// Thread-safe RNG for sampling.
rng: Mutex<fastrand::Rng>,
}
impl MotpeSampler {
/// Creates a new MOTPE sampler with default settings.
///
/// Defaults:
/// - `n_startup_trials`: 11
/// - `n_ei_candidates`: 24
/// - `kde_bandwidth`: None (Scott's rule)
#[must_use]
pub fn new() -> Self {
Self {
n_startup_trials: 11,
n_ei_candidates: 24,
kde_bandwidth: None,
rng: Mutex::new(fastrand::Rng::new()),
}
}
/// Creates a new MOTPE sampler with a fixed seed.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
n_startup_trials: 11,
n_ei_candidates: 24,
kde_bandwidth: None,
rng: Mutex::new(fastrand::Rng::with_seed(seed)),
}
}
/// Creates a builder for configuring a MOTPE sampler.
#[must_use]
pub fn builder() -> MotpeSamplerBuilder {
MotpeSamplerBuilder::new()
}
/// Splits trials into good (non-dominated) and bad (dominated) groups
/// using Pareto non-dominated sorting.
fn split_trials<'a>(
history: &'a [MultiObjectiveTrial],
directions: &[Direction],
) -> (Vec<&'a MultiObjectiveTrial>, Vec<&'a MultiObjectiveTrial>) {
let complete: Vec<(usize, &MultiObjectiveTrial)> = history
.iter()
.enumerate()
.filter(|(_, t)| t.state == TrialState::Complete)
.collect();
if complete.is_empty() {
return (vec![], vec![]);
}
let values: Vec<Vec<f64>> = complete.iter().map(|(_, t)| t.values.clone()).collect();
let constraints: Vec<Vec<f64>> = complete
.iter()
.map(|(_, t)| t.constraints.clone())
.collect();
let has_constraints = constraints.iter().any(|c| !c.is_empty());
let fronts = if has_constraints {
pareto::fast_non_dominated_sort_constrained(&values, directions, &constraints)
} else {
pareto::fast_non_dominated_sort(&values, directions)
};
if fronts.is_empty() {
return (vec![], vec![]);
}
// Front 0 = good (non-dominated), everything else = bad
let good: Vec<&MultiObjectiveTrial> = fronts[0].iter().map(|&i| complete[i].1).collect();
let bad: Vec<&MultiObjectiveTrial> = fronts[1..]
.iter()
.flatten()
.map(|&i| complete[i].1)
.collect();
(good, bad)
}
/// Samples uniformly from a distribution (used during startup phase).
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::unused_self
)]
fn sample_uniform(distribution: &Distribution, rng: &mut fastrand::Rng) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
Distribution::Int(d) => {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
/// Samples using TPE for float distributions.
#[allow(clippy::too_many_arguments)]
fn sample_tpe_float(
&self,
low: f64,
high: f64,
log_scale: bool,
step: Option<f64>,
good_values: Vec<f64>,
bad_values: Vec<f64>,
rng: &mut fastrand::Rng,
) -> f64 {
// Transform to internal space (log space if needed)
let (internal_low, internal_high, good_internal, bad_internal) = if log_scale {
let i_low = low.ln();
let i_high = high.ln();
let g: Vec<f64> = good_values.iter().map(|&v| v.ln()).collect();
let b: Vec<f64> = bad_values.iter().map(|&v| v.ln()).collect();
(i_low, i_high, g, b)
} else {
(low, high, good_values, bad_values)
};
// Fit KDEs to good and bad groups
let l_kde = match self.kde_bandwidth {
Some(bw) => KernelDensityEstimator::with_bandwidth(good_internal, bw),
None => KernelDensityEstimator::new(good_internal),
};
let g_kde = match self.kde_bandwidth {
Some(bw) => KernelDensityEstimator::with_bandwidth(bad_internal, bw),
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_util::f64_range(rng, low, high);
};
// Generate candidates from l(x) and select the one with best l(x)/g(x)
let mut best_candidate = internal_low;
let mut best_ratio = f64::NEG_INFINITY;
for _ in 0..self.n_ei_candidates {
let candidate = l_kde.sample(rng).clamp(internal_low, internal_high);
let l_density = l_kde.pdf(candidate);
let g_density = g_kde.pdf(candidate);
let ratio = if g_density < f64::EPSILON {
if l_density > f64::EPSILON {
f64::INFINITY
} else {
0.0
}
} else {
l_density / g_density
};
if ratio > best_ratio {
best_ratio = ratio;
best_candidate = candidate;
}
}
// Transform back from internal space
let mut value = if log_scale {
best_candidate.exp()
} else {
best_candidate
};
// Apply step constraint if present
if let Some(step) = step {
let k = ((value - low) / step).round();
value = low + k * step;
}
value.clamp(low, high)
}
/// Samples using TPE for integer distributions.
#[allow(
clippy::too_many_arguments,
clippy::cast_precision_loss,
clippy::cast_possible_truncation
)]
fn sample_tpe_int(
&self,
low: i64,
high: i64,
log_scale: bool,
step: Option<i64>,
good_values: &[i64],
bad_values: &[i64],
rng: &mut fastrand::Rng,
) -> i64 {
let good_floats: Vec<f64> = good_values.iter().map(|&v| v as f64).collect();
let bad_floats: Vec<f64> = bad_values.iter().map(|&v| v as f64).collect();
let float_value = self.sample_tpe_float(
low as f64,
high as f64,
log_scale,
step.map(|s| s as f64),
good_floats,
bad_floats,
rng,
);
let int_value = float_value.round() as i64;
let int_value = if let Some(step) = step {
let k = ((int_value - low) as f64 / step as f64).round() as i64;
low + k * step
} else {
int_value
};
int_value.clamp(low, high)
}
/// Samples using TPE for categorical distributions.
#[allow(clippy::cast_precision_loss)]
fn sample_tpe_categorical(
n_choices: usize,
good_indices: &[usize],
bad_indices: &[usize],
rng: &mut fastrand::Rng,
) -> usize {
let mut good_counts = vec![0usize; n_choices];
let mut bad_counts = vec![0usize; n_choices];
for &idx in good_indices {
if idx < n_choices {
good_counts[idx] += 1;
}
}
for &idx in bad_indices {
if idx < n_choices {
bad_counts[idx] += 1;
}
}
// Laplace smoothing
let good_total = good_indices.len() as f64 + n_choices as f64;
let bad_total = bad_indices.len() as f64 + n_choices as f64;
let mut weights = vec![0.0f64; n_choices];
for i in 0..n_choices {
let l_prob = (good_counts[i] as f64 + 1.0) / good_total;
let g_prob = (bad_counts[i] as f64 + 1.0) / bad_total;
weights[i] = l_prob / g_prob;
}
// Sample proportionally to weights
let total_weight: f64 = weights.iter().sum();
let threshold = rng.f64() * total_weight;
let mut cumulative = 0.0;
for (i, &w) in weights.iter().enumerate() {
cumulative += w;
if cumulative >= threshold {
return i;
}
}
n_choices - 1
}
}
impl Default for MotpeSampler {
fn default() -> Self {
Self::new()
}
}
impl MultiObjectiveSampler for MotpeSampler {
#[allow(clippy::too_many_lines)]
fn sample(
&self,
distribution: &Distribution,
_trial_id: u64,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) -> ParamValue {
let mut rng = self.rng.lock();
// Fall back to random sampling during startup phase
let n_complete = history
.iter()
.filter(|t| t.state == TrialState::Complete)
.count();
if n_complete < self.n_startup_trials {
return Self::sample_uniform(distribution, &mut rng);
}
// Split trials into good (Pareto front) and bad (dominated)
let (good_trials, bad_trials) = Self::split_trials(history, directions);
if good_trials.is_empty() || bad_trials.is_empty() {
return Self::sample_uniform(distribution, &mut rng);
}
match distribution {
Distribution::Float(d) => {
let good_values: Vec<f64> = good_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
let bad_values: Vec<f64> = bad_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
if good_values.is_empty() || bad_values.is_empty() {
return Self::sample_uniform(distribution, &mut rng);
}
let value = self.sample_tpe_float(
d.low,
d.high,
d.log_scale,
d.step,
good_values,
bad_values,
&mut rng,
);
ParamValue::Float(value)
}
Distribution::Int(d) => {
let good_values: Vec<i64> = good_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
let bad_values: Vec<i64> = bad_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.filter(|&v| v >= d.low && v <= d.high)
.collect();
if good_values.is_empty() || bad_values.is_empty() {
return Self::sample_uniform(distribution, &mut rng);
}
let value = self.sample_tpe_int(
d.low,
d.high,
d.log_scale,
d.step,
&good_values,
&bad_values,
&mut rng,
);
ParamValue::Int(value)
}
Distribution::Categorical(d) => {
let good_indices: Vec<usize> = good_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.filter(|&i| i < d.n_choices)
.collect();
let bad_indices: Vec<usize> = bad_trials
.iter()
.flat_map(|t| t.params.values())
.filter_map(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.filter(|&i| i < d.n_choices)
.collect();
if good_indices.is_empty() || bad_indices.is_empty() {
return Self::sample_uniform(distribution, &mut rng);
}
let index = Self::sample_tpe_categorical(
d.n_choices,
&good_indices,
&bad_indices,
&mut rng,
);
ParamValue::Categorical(index)
}
}
}
}
/// Builder for configuring a [`MotpeSampler`].
///
/// # Examples
///
/// ```
/// use optimizer::sampler::motpe::MotpeSamplerBuilder;
///
/// let sampler = MotpeSamplerBuilder::new()
/// .n_startup_trials(15)
/// .n_ei_candidates(32)
/// .seed(42)
/// .build();
/// ```
#[derive(Debug, Clone)]
pub struct MotpeSamplerBuilder {
n_startup_trials: usize,
n_ei_candidates: usize,
kde_bandwidth: Option<f64>,
seed: Option<u64>,
}
impl MotpeSamplerBuilder {
/// Creates a new builder with default settings.
#[must_use]
pub fn new() -> Self {
Self {
n_startup_trials: 11,
n_ei_candidates: 24,
kde_bandwidth: None,
seed: None,
}
}
/// Sets the number of startup trials before MOTPE sampling begins.
#[must_use]
pub fn n_startup_trials(mut self, n: usize) -> Self {
self.n_startup_trials = n;
self
}
/// Sets the number of EI candidates to evaluate per sample.
#[must_use]
pub fn n_ei_candidates(mut self, n: usize) -> Self {
self.n_ei_candidates = n;
self
}
/// Sets a fixed bandwidth for the kernel density estimator.
///
/// By default, Scott's rule is used for automatic bandwidth selection.
#[must_use]
pub fn kde_bandwidth(mut self, bandwidth: f64) -> Self {
self.kde_bandwidth = Some(bandwidth);
self
}
/// Sets a seed for reproducible sampling.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`MotpeSampler`].
#[must_use]
pub fn build(self) -> MotpeSampler {
let rng = match self.seed {
Some(s) => fastrand::Rng::with_seed(s),
None => fastrand::Rng::new(),
};
MotpeSampler {
n_startup_trials: self.n_startup_trials,
n_ei_candidates: self.n_ei_candidates,
kde_bandwidth: self.kde_bandwidth,
rng: Mutex::new(rng),
}
}
}
impl Default for MotpeSamplerBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(
clippy::similar_names,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
mod tests {
use std::collections::HashMap;
use super::*;
use crate::distribution::{CategoricalDistribution, FloatDistribution, IntDistribution};
use crate::parameter::ParamId;
fn create_mo_trial(
id: u64,
values: Vec<f64>,
params: Vec<(ParamId, ParamValue, Distribution)>,
) -> MultiObjectiveTrial {
let mut param_map = HashMap::new();
let mut dist_map = HashMap::new();
let label_map = HashMap::new();
for (param_id, pv, dist) in params {
param_map.insert(param_id, pv);
dist_map.insert(param_id, dist);
}
MultiObjectiveTrial {
id,
params: param_map,
distributions: dist_map,
param_labels: label_map,
values,
state: TrialState::Complete,
user_attrs: HashMap::new(),
constraints: Vec::new(),
}
}
#[test]
fn test_motpe_startup_random_sampling() {
let sampler = MotpeSampler::with_seed(42);
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let directions = [Direction::Minimize, Direction::Minimize];
// With no history, should use random sampling
let history: Vec<MultiObjectiveTrial> = vec![];
for _ in 0..50 {
let value = sampler.sample(&dist, 0, &history, &directions);
if let ParamValue::Float(v) = value {
assert!((0.0..=1.0).contains(&v));
} else {
panic!("Expected Float value");
}
}
}
#[test]
fn test_motpe_split_pareto() {
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let directions = [Direction::Minimize, Direction::Minimize];
let x_id = ParamId::new();
// Create trials: Pareto front = {(0.1, 0.9), (0.5, 0.5), (0.9, 0.1)}
// Dominated = {(0.6, 0.8), (0.8, 0.7)}
let history = vec![
create_mo_trial(
0,
vec![0.1, 0.9],
vec![(x_id, ParamValue::Float(0.1), dist.clone())],
),
create_mo_trial(
1,
vec![0.5, 0.5],
vec![(x_id, ParamValue::Float(0.5), dist.clone())],
),
create_mo_trial(
2,
vec![0.9, 0.1],
vec![(x_id, ParamValue::Float(0.9), dist.clone())],
),
create_mo_trial(
3,
vec![0.6, 0.8],
vec![(x_id, ParamValue::Float(0.6), dist.clone())],
),
create_mo_trial(
4,
vec![0.8, 0.7],
vec![(x_id, ParamValue::Float(0.8), dist.clone())],
),
];
let (good, bad) = MotpeSampler::split_trials(&history, &directions);
assert_eq!(good.len(), 3, "Pareto front should have 3 members");
assert_eq!(bad.len(), 2, "2 dominated trials");
}
#[test]
fn test_motpe_samples_float() {
let sampler = MotpeSampler::builder()
.n_startup_trials(5)
.n_ei_candidates(24)
.seed(42)
.build();
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let directions = [Direction::Minimize, Direction::Minimize];
let x_id = ParamId::new();
// Build history where values near 0.3 are on the Pareto front
let mut history = Vec::new();
for i in 0..20 {
let x = f64::from(i) / 20.0;
// Pareto front: f1 = (x - 0.3)^2, f2 = (x - 0.3)^2 + 0.1
// Best solutions cluster around x = 0.3
let f1 = (x - 0.3).powi(2);
let f2 = (x - 0.7).powi(2);
history.push(create_mo_trial(
i as u64,
vec![f1, f2],
vec![(x_id, ParamValue::Float(x), dist.clone())],
));
}
// MOTPE should produce values within [0, 1]
for i in 0..50 {
let value = sampler.sample(&dist, 100 + i, &history, &directions);
if let ParamValue::Float(v) = value {
assert!((0.0..=1.0).contains(&v), "Value {v} out of range");
} else {
panic!("Expected Float value");
}
}
}
#[test]
fn test_motpe_int_sampling() {
let sampler = MotpeSampler::builder().n_startup_trials(5).seed(42).build();
let dist = Distribution::Int(IntDistribution {
low: 0,
high: 100,
log_scale: false,
step: None,
});
let directions = [Direction::Minimize, Direction::Minimize];
let x_id = ParamId::new();
let mut history = Vec::new();
for i in 0..20 {
let x = i * 5;
let f1 = ((x as f64) - 30.0).powi(2);
let f2 = ((x as f64) - 70.0).powi(2);
history.push(create_mo_trial(
i as u64,
vec![f1, f2],
vec![(x_id, ParamValue::Int(x), dist.clone())],
));
}
for i in 0..50 {
let value = sampler.sample(&dist, 100 + i, &history, &directions);
if let ParamValue::Int(v) = value {
assert!((0..=100).contains(&v), "Value {v} out of range");
} else {
panic!("Expected Int value");
}
}
}
#[test]
fn test_motpe_categorical_sampling() {
let sampler = MotpeSampler::builder().n_startup_trials(5).seed(42).build();
let dist = Distribution::Categorical(CategoricalDistribution { n_choices: 3 });
let directions = [Direction::Minimize, Direction::Minimize];
let cat_id = ParamId::new();
// Category 1 is on the Pareto front, others are dominated
let mut history = Vec::new();
for i in 0..15 {
let category = i % 3;
let (f1, f2) = match category {
0 => (0.8, 0.8), // dominated
1 => (0.1, 0.9), // Pareto front
2 => (0.9, 0.1), // Pareto front
_ => unreachable!(),
};
history.push(create_mo_trial(
i as u64,
vec![f1, f2],
vec![(
cat_id,
ParamValue::Categorical(category as usize),
dist.clone(),
)],
));
}
let mut counts = vec![0usize; 3];
for i in 0..200 {
let value = sampler.sample(&dist, 100 + i, &history, &directions);
if let ParamValue::Categorical(idx) = value {
assert!(idx < 3, "Category {idx} out of range");
counts[idx] += 1;
} else {
panic!("Expected Categorical value");
}
}
// Categories 1 and 2 (on Pareto front) should dominate category 0
assert!(
counts[1] + counts[2] > counts[0],
"Pareto-front categories should be sampled more: {counts:?}"
);
}
#[test]
fn test_motpe_reproducibility() {
let dist = Distribution::Float(FloatDistribution {
low: 0.0,
high: 1.0,
log_scale: false,
step: None,
});
let directions = [Direction::Minimize, Direction::Minimize];
let x_id = ParamId::new();
let history: Vec<MultiObjectiveTrial> = (0..20)
.map(|i| {
let x = f64::from(i) / 20.0;
create_mo_trial(
i as u64,
vec![x, 1.0 - x],
vec![(x_id, ParamValue::Float(x), dist.clone())],
)
})
.collect();
let sampler1 = MotpeSampler::builder()
.seed(12345)
.n_startup_trials(5)
.build();
let sampler2 = MotpeSampler::builder()
.seed(12345)
.n_startup_trials(5)
.build();
for i in 0..10 {
let v1 = sampler1.sample(&dist, i, &history, &directions);
let v2 = sampler2.sample(&dist, i, &history, &directions);
assert_eq!(v1, v2, "Samples should be identical with same seed");
}
}
#[test]
fn test_motpe_with_study() {
use crate::multi_objective::MultiObjectiveStudy;
use crate::parameter::{FloatParam, Parameter};
let sampler = MotpeSampler::builder().seed(42).build();
let study = MultiObjectiveStudy::with_sampler(
vec![Direction::Minimize, Direction::Minimize],
sampler,
);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, crate::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty(), "Should have Pareto-optimal solutions");
// All front solutions should have values summing to ~1.0
for trial in &front {
let sum: f64 = trial.values.iter().sum();
assert!(
(sum - 1.0).abs() < 0.01,
"Pareto front values should sum to ~1.0, got {sum}"
);
}
}
#[test]
fn test_motpe_builder_defaults() {
let sampler = MotpeSamplerBuilder::new().build();
assert_eq!(sampler.n_startup_trials, 11);
assert_eq!(sampler.n_ei_candidates, 24);
assert!(sampler.kde_bandwidth.is_none());
}
#[test]
fn test_motpe_builder_custom() {
let sampler = MotpeSamplerBuilder::new()
.n_startup_trials(20)
.n_ei_candidates(48)
.kde_bandwidth(0.5)
.seed(99)
.build();
assert_eq!(sampler.n_startup_trials, 20);
assert_eq!(sampler.n_ei_candidates, 48);
assert_eq!(sampler.kde_bandwidth, Some(0.5));
}
}
+378
View File
@@ -0,0 +1,378 @@
//! NSGA-II (Non-dominated Sorting Genetic Algorithm II) sampler.
//!
//! Implements multi-objective optimization using non-dominated sorting,
//! crowding distance, SBX crossover, and polynomial mutation.
//!
//! # Examples
//!
//! ```
//! use optimizer::Direction;
//! use optimizer::multi_objective::MultiObjectiveStudy;
//! use optimizer::parameter::{FloatParam, Parameter};
//! use optimizer::sampler::nsga2::Nsga2Sampler;
//!
//! let sampler = Nsga2Sampler::with_seed(42);
//! let study =
//! MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
//!
//! let x = FloatParam::new(0.0, 1.0);
//! study
//! .optimize(50, |trial| {
//! let xv = x.suggest(trial)?;
//! Ok::<_, optimizer::Error>(vec![xv * xv, (xv - 1.0).powi(2)])
//! })
//! .unwrap();
//! ```
use parking_lot::Mutex;
use super::genetic::{
self, Candidate, EvolutionaryState, Phase, advance_generation, collect_evaluated_generation,
crossover, extract_trial_params, finalize_discovery, generate_random_candidates, mutate,
sample_from_candidate, sample_random,
};
use crate::distribution::Distribution;
use crate::multi_objective::MultiObjectiveTrial;
use crate::param::ParamValue;
use crate::pareto;
use crate::types::Direction;
/// NSGA-II sampler for multi-objective optimization.
///
/// Provides non-dominated sorting, crowding distance selection,
/// SBX crossover, and polynomial mutation.
pub struct Nsga2Sampler {
state: Mutex<Nsga2State>,
}
impl Nsga2Sampler {
/// Creates a new NSGA-II sampler with a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(Nsga2State::new(Nsga2Config::default(), None)),
}
}
/// Creates a new NSGA-II sampler with a fixed seed.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(Nsga2State::new(Nsga2Config::default(), Some(seed))),
}
}
/// Creates a builder for configuring an `Nsga2Sampler`.
#[must_use]
pub fn builder() -> Nsga2SamplerBuilder {
Nsga2SamplerBuilder::default()
}
}
impl Default for Nsga2Sampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for [`Nsga2Sampler`].
#[derive(Debug, Clone, Default)]
pub struct Nsga2SamplerBuilder {
population_size: Option<usize>,
crossover_prob: Option<f64>,
crossover_eta: Option<f64>,
mutation_eta: Option<f64>,
seed: Option<u64>,
}
impl Nsga2SamplerBuilder {
/// Sets the population size. Default: `4 + floor(3 * ln(n_params))`, minimum 4.
#[must_use]
pub fn population_size(mut self, size: usize) -> Self {
self.population_size = Some(size);
self
}
/// Sets the crossover probability. Default: 0.9.
#[must_use]
pub fn crossover_prob(mut self, prob: f64) -> Self {
self.crossover_prob = Some(prob);
self
}
/// Sets the SBX distribution index. Default: 20.0.
#[must_use]
pub fn crossover_eta(mut self, eta: f64) -> Self {
self.crossover_eta = Some(eta);
self
}
/// Sets the polynomial mutation distribution index. Default: 20.0.
#[must_use]
pub fn mutation_eta(mut self, eta: f64) -> Self {
self.mutation_eta = Some(eta);
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`Nsga2Sampler`].
#[must_use]
pub fn build(self) -> Nsga2Sampler {
let config = Nsga2Config {
user_population_size: self.population_size,
crossover_prob: self.crossover_prob.unwrap_or(0.9),
crossover_eta: self.crossover_eta.unwrap_or(20.0),
mutation_eta: self.mutation_eta.unwrap_or(20.0),
};
Nsga2Sampler {
state: Mutex::new(Nsga2State::new(config, self.seed)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
struct Nsga2Config {
user_population_size: Option<usize>,
crossover_prob: f64,
crossover_eta: f64,
mutation_eta: f64,
}
impl Default for Nsga2Config {
fn default() -> Self {
Self {
user_population_size: None,
crossover_prob: 0.9,
crossover_eta: 20.0,
mutation_eta: 20.0,
}
}
}
struct Nsga2State {
evo: EvolutionaryState,
config: Nsga2Config,
}
impl Nsga2State {
fn new(config: Nsga2Config, seed: Option<u64>) -> Self {
Self {
evo: EvolutionaryState::new(seed),
config,
}
}
}
// ---------------------------------------------------------------------------
// MultiObjectiveSampler implementation
// ---------------------------------------------------------------------------
impl crate::multi_objective::MultiObjectiveSampler for Nsga2Sampler {
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) -> ParamValue {
let mut state = self.state.lock();
match &state.evo.phase {
Phase::Discovery => {
if let Some(value) =
genetic::sample_discovery(&mut state.evo, distribution, trial_id)
{
return value;
}
// Transitioned to active phase
let user_pop = state.config.user_population_size;
finalize_discovery(&mut state.evo, user_pop);
generate_random_candidates(&mut state.evo);
sample_from_candidate(&mut state.evo, trial_id)
}
Phase::Active => {
maybe_generate_new_generation(&mut state, history, directions);
sample_from_candidate(&mut state.evo, trial_id)
}
}
}
}
/// Check if all candidates in the current generation have been evaluated;
/// if so, run NSGA-II selection and generate offspring.
fn maybe_generate_new_generation(
state: &mut Nsga2State,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) {
if state.evo.candidates.is_empty() {
generate_random_candidates(&mut state.evo);
return;
}
if let Some(evaluated) = collect_evaluated_generation(&state.evo, history) {
let offspring = nsga2_generate_offspring(state, &evaluated, directions);
advance_generation(&mut state.evo, offspring);
}
}
// ---------------------------------------------------------------------------
// NSGA-II generation algorithm
// ---------------------------------------------------------------------------
/// Performs NSGA-II selection: non-dominated sort + crowding distance,
/// then selects `pop_size` parents from the population.
fn nsga2_select(
state: &mut Nsga2State,
population: &[&MultiObjectiveTrial],
directions: &[Direction],
) -> (Vec<Vec<ParamValue>>, Vec<usize>, Vec<f64>) {
let pop_size = state.evo.population_size;
let values: Vec<Vec<f64>> = population.iter().map(|t| t.values.clone()).collect();
let constraints: Vec<Vec<f64>> = population.iter().map(|t| t.constraints.clone()).collect();
let has_constraints = constraints.iter().any(|c| !c.is_empty());
let fronts = if has_constraints {
pareto::fast_non_dominated_sort_constrained(&values, directions, &constraints)
} else {
pareto::fast_non_dominated_sort(&values, directions)
};
let n = population.len();
let mut rank = vec![0_usize; n];
let mut crowding = vec![0.0_f64; n];
for (front_rank, front) in fronts.iter().enumerate() {
let cd = pareto::crowding_distance_indexed(front, &values);
for (i, &idx) in front.iter().enumerate() {
rank[idx] = front_rank;
crowding[idx] = cd[i];
}
}
let mut selected: Vec<usize> = Vec::with_capacity(pop_size);
for front in &fronts {
if selected.len() + front.len() <= pop_size {
selected.extend_from_slice(front);
} else {
let remaining = pop_size - selected.len();
let mut front_sorted: Vec<usize> = front.clone();
front_sorted.sort_by(|&a, &b| {
crowding[b]
.partial_cmp(&crowding[a])
.unwrap_or(core::cmp::Ordering::Equal)
});
selected.extend_from_slice(&front_sorted[..remaining]);
break;
}
}
while selected.len() < pop_size {
selected.push(state.evo.rng.usize(0..n));
}
let parents: Vec<Vec<ParamValue>> = selected
.iter()
.map(|&idx| {
extract_trial_params(population[idx], &state.evo.dimensions, &mut state.evo.rng)
})
.collect();
let sel_rank: Vec<usize> = selected.iter().map(|&i| rank[i]).collect();
let sel_crowding: Vec<f64> = selected.iter().map(|&i| crowding[i]).collect();
(parents, sel_rank, sel_crowding)
}
/// Runs NSGA-II selection and generates offspring candidates.
fn nsga2_generate_offspring(
state: &mut Nsga2State,
population: &[&MultiObjectiveTrial],
directions: &[Direction],
) -> Vec<Candidate> {
let pop_size = state.evo.population_size;
if population.len() < 2 {
return (0..pop_size)
.map(|_| {
let params = state
.evo
.dimensions
.iter()
.map(|d| sample_random(&mut state.evo.rng, &d.distribution))
.collect();
Candidate { params }
})
.collect();
}
let (parents, sel_rank, sel_crowding) = nsga2_select(state, population, directions);
let mut offspring = Vec::with_capacity(pop_size);
while offspring.len() < pop_size {
let p1 = tournament_select(&mut state.evo.rng, &sel_rank, &sel_crowding, parents.len());
let p2 = tournament_select(&mut state.evo.rng, &sel_rank, &sel_crowding, parents.len());
let (mut child1, mut child2) = crossover(
&mut state.evo.rng,
&parents[p1],
&parents[p2],
&state.evo.dimensions,
state.config.crossover_prob,
state.config.crossover_eta,
);
mutate(
&mut state.evo.rng,
&mut child1,
&state.evo.dimensions,
state.config.mutation_eta,
);
mutate(
&mut state.evo.rng,
&mut child2,
&state.evo.dimensions,
state.config.mutation_eta,
);
offspring.push(Candidate { params: child1 });
if offspring.len() < pop_size {
offspring.push(Candidate { params: child2 });
}
}
offspring
}
/// Tournament selection: pick 2 random individuals, return index of winner.
/// Winner has lower rank; ties broken by higher crowding distance.
fn tournament_select(
rng: &mut fastrand::Rng,
ranks: &[usize],
crowding: &[f64],
n: usize,
) -> usize {
let a = rng.usize(0..n);
let b = rng.usize(0..n);
if ranks[a] < ranks[b] {
a
} else if ranks[b] < ranks[a] {
b
} else if crowding[a] >= crowding[b] {
a
} else {
b
}
}
+720
View File
@@ -0,0 +1,720 @@
//! NSGA-III (Non-dominated Sorting Genetic Algorithm III) sampler.
//!
//! Uses reference-point-based niching for better diversity in
//! many-objective (3+) optimization problems. Das-Dennis structured
//! reference points guide the search toward a well-distributed
//! Pareto front.
//!
//! # Examples
//!
//! ```
//! use optimizer::Direction;
//! use optimizer::multi_objective::MultiObjectiveStudy;
//! use optimizer::parameter::{FloatParam, Parameter};
//! use optimizer::sampler::nsga3::Nsga3Sampler;
//!
//! let sampler = Nsga3Sampler::with_seed(42);
//! let study = MultiObjectiveStudy::with_sampler(
//! vec![
//! Direction::Minimize,
//! Direction::Minimize,
//! Direction::Minimize,
//! ],
//! sampler,
//! );
//!
//! let x = FloatParam::new(0.0, 1.0);
//! let y = FloatParam::new(0.0, 1.0);
//! study
//! .optimize(100, |trial| {
//! let xv = x.suggest(trial)?;
//! let yv = y.suggest(trial)?;
//! Ok::<_, optimizer::Error>(vec![xv, yv, (1.0 - xv - yv).abs()])
//! })
//! .unwrap();
//! ```
use parking_lot::Mutex;
use super::genetic::{
self, Candidate, EvolutionaryState, Phase, advance_generation, auto_divisions,
collect_evaluated_generation, crossover, das_dennis, extract_trial_params,
generate_random_candidates, mutate, sample_from_candidate, sample_random,
};
use crate::distribution::Distribution;
use crate::multi_objective::MultiObjectiveTrial;
use crate::param::ParamValue;
use crate::pareto;
use crate::types::Direction;
/// NSGA-III sampler for multi-objective optimization.
///
/// Uses reference-point-based niching to maintain diversity,
/// especially effective for problems with 3 or more objectives.
pub struct Nsga3Sampler {
state: Mutex<Nsga3State>,
}
impl Nsga3Sampler {
/// Creates a new NSGA-III sampler with a random seed.
#[must_use]
pub fn new() -> Self {
Self {
state: Mutex::new(Nsga3State::new(Nsga3Config::default(), None)),
}
}
/// Creates a new NSGA-III sampler with a fixed seed.
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
state: Mutex::new(Nsga3State::new(Nsga3Config::default(), Some(seed))),
}
}
/// Creates a builder for configuring an `Nsga3Sampler`.
#[must_use]
pub fn builder() -> Nsga3SamplerBuilder {
Nsga3SamplerBuilder::default()
}
}
impl Default for Nsga3Sampler {
fn default() -> Self {
Self::new()
}
}
/// Builder for [`Nsga3Sampler`].
#[derive(Debug, Clone, Default)]
pub struct Nsga3SamplerBuilder {
population_size: Option<usize>,
n_divisions: Option<usize>,
crossover_prob: Option<f64>,
crossover_eta: Option<f64>,
mutation_eta: Option<f64>,
seed: Option<u64>,
}
impl Nsga3SamplerBuilder {
/// Sets the population size. If unset, equals the number of
/// Das-Dennis reference points.
#[must_use]
pub fn population_size(mut self, size: usize) -> Self {
self.population_size = Some(size);
self
}
/// Sets the number of divisions (H) for Das-Dennis reference points.
/// If unset, automatically chosen based on population size and number
/// of objectives.
#[must_use]
pub fn n_divisions(mut self, h: usize) -> Self {
self.n_divisions = Some(h);
self
}
/// Sets the crossover probability. Default: 1.0.
#[must_use]
pub fn crossover_prob(mut self, prob: f64) -> Self {
self.crossover_prob = Some(prob);
self
}
/// Sets the SBX distribution index. Default: 30.0.
#[must_use]
pub fn crossover_eta(mut self, eta: f64) -> Self {
self.crossover_eta = Some(eta);
self
}
/// Sets the polynomial mutation distribution index. Default: 20.0.
#[must_use]
pub fn mutation_eta(mut self, eta: f64) -> Self {
self.mutation_eta = Some(eta);
self
}
/// Sets the random seed for reproducibility.
#[must_use]
pub fn seed(mut self, seed: u64) -> Self {
self.seed = Some(seed);
self
}
/// Builds the configured [`Nsga3Sampler`].
#[must_use]
pub fn build(self) -> Nsga3Sampler {
let config = Nsga3Config {
user_population_size: self.population_size,
n_divisions: self.n_divisions,
crossover_prob: self.crossover_prob.unwrap_or(1.0),
crossover_eta: self.crossover_eta.unwrap_or(30.0),
mutation_eta: self.mutation_eta.unwrap_or(20.0),
};
Nsga3Sampler {
state: Mutex::new(Nsga3State::new(config, self.seed)),
}
}
}
// ---------------------------------------------------------------------------
// Internal types
// ---------------------------------------------------------------------------
#[derive(Clone, Debug)]
struct Nsga3Config {
user_population_size: Option<usize>,
n_divisions: Option<usize>,
crossover_prob: f64,
crossover_eta: f64,
mutation_eta: f64,
}
impl Default for Nsga3Config {
fn default() -> Self {
Self {
user_population_size: None,
n_divisions: None,
crossover_prob: 1.0,
crossover_eta: 30.0,
mutation_eta: 20.0,
}
}
}
struct Nsga3State {
evo: EvolutionaryState,
config: Nsga3Config,
/// Das-Dennis reference points (lazily generated once objectives are known).
reference_points: Vec<Vec<f64>>,
/// Best value seen per objective (minimize-space).
ideal_point: Vec<f64>,
/// Whether reference points have been initialized.
initialized: bool,
}
impl Nsga3State {
fn new(config: Nsga3Config, seed: Option<u64>) -> Self {
Self {
evo: EvolutionaryState::new(seed),
config,
reference_points: Vec::new(),
ideal_point: Vec::new(),
initialized: false,
}
}
}
// ---------------------------------------------------------------------------
// MultiObjectiveSampler implementation
// ---------------------------------------------------------------------------
impl crate::multi_objective::MultiObjectiveSampler for Nsga3Sampler {
fn sample(
&self,
distribution: &Distribution,
trial_id: u64,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) -> ParamValue {
let mut state = self.state.lock();
match &state.evo.phase {
Phase::Discovery => {
if let Some(value) =
genetic::sample_discovery(&mut state.evo, distribution, trial_id)
{
return value;
}
// Transitioned to active phase
initialize_nsga3(&mut state, directions);
generate_random_candidates(&mut state.evo);
sample_from_candidate(&mut state.evo, trial_id)
}
Phase::Active => {
maybe_generate_new_generation(&mut state, history, directions);
sample_from_candidate(&mut state.evo, trial_id)
}
}
}
}
/// Initialize NSGA-III: generate reference points and set population size.
fn initialize_nsga3(state: &mut Nsga3State, directions: &[Direction]) {
let n_obj = directions.len();
// Determine divisions
let divisions = state
.config
.n_divisions
.unwrap_or_else(|| auto_divisions(n_obj, state.config.user_population_size.unwrap_or(100)));
state.reference_points = das_dennis(n_obj, divisions);
let n_ref = state.reference_points.len();
// Population size = number of reference points (or user override, at least n_ref)
let pop_size = state.config.user_population_size.unwrap_or(n_ref).max(4);
state.evo.population_size = pop_size;
state.evo.phase = Phase::Active;
state.ideal_point = vec![f64::INFINITY; n_obj];
state.initialized = true;
}
fn maybe_generate_new_generation(
state: &mut Nsga3State,
history: &[MultiObjectiveTrial],
directions: &[Direction],
) {
if state.evo.candidates.is_empty() {
generate_random_candidates(&mut state.evo);
return;
}
if let Some(evaluated) = collect_evaluated_generation(&state.evo, history) {
let offspring = nsga3_generate_offspring(state, &evaluated, directions);
advance_generation(&mut state.evo, offspring);
}
}
// ---------------------------------------------------------------------------
// NSGA-III selection algorithm
// ---------------------------------------------------------------------------
/// Normalize objectives to minimize-space.
fn to_minimize_space(values: &[f64], directions: &[Direction]) -> Vec<f64> {
values
.iter()
.zip(directions)
.map(|(&v, d)| match d {
Direction::Minimize => v,
Direction::Maximize => -v,
})
.collect()
}
/// Update ideal point with new observations.
fn update_ideal_point(ideal: &mut [f64], normalized_values: &[Vec<f64>]) {
for vals in normalized_values {
for (i, &v) in vals.iter().enumerate() {
if v < ideal[i] {
ideal[i] = v;
}
}
}
}
/// Compute Achievement Scalarizing Function (ASF) for extreme point finding.
fn asf(point: &[f64], weight: &[f64], ideal: &[f64]) -> f64 {
point
.iter()
.zip(weight)
.zip(ideal)
.map(|((&p, &w), &z)| {
let w = if w < 1e-6 { 1e-6 } else { w };
(p - z) / w
})
.fold(f64::NEG_INFINITY, f64::max)
}
/// Find intercepts for normalization via extreme points.
///
/// For each objective, find the point with best ASF (using a weight vector
/// that emphasizes that objective). The intercepts are where the hyperplane
/// through the extreme points crosses each axis.
fn find_intercepts(normalized_values: &[Vec<f64>], ideal: &[f64]) -> Vec<f64> {
let n_obj = ideal.len();
let n = normalized_values.len();
if n == 0 || n_obj == 0 {
return vec![1.0; n_obj];
}
// Find extreme points (one per objective)
let mut extreme_indices = Vec::with_capacity(n_obj);
for obj in 0..n_obj {
let mut weight = vec![1e-6; n_obj];
weight[obj] = 1.0;
let mut best_idx = 0;
let mut best_asf = f64::INFINITY;
for (i, vals) in normalized_values.iter().enumerate() {
let a = asf(vals, &weight, ideal);
if a < best_asf {
best_asf = a;
best_idx = i;
}
}
extreme_indices.push(best_idx);
}
// Try to compute hyperplane intercepts
// For stability, if the extreme points are degenerate, fall back to
// max - ideal per objective
let mut intercepts = Vec::with_capacity(n_obj);
for obj in 0..n_obj {
let max_val = normalized_values
.iter()
.map(|v| v[obj])
.fold(f64::NEG_INFINITY, f64::max);
let intercept = max_val - ideal[obj];
intercepts.push(if intercept > 1e-10 { intercept } else { 1.0 });
}
intercepts
}
/// Normalize objective values: subtract ideal, divide by intercepts.
fn normalize_objectives(values: &[Vec<f64>], ideal: &[f64], intercepts: &[f64]) -> Vec<Vec<f64>> {
values
.iter()
.map(|v| {
v.iter()
.zip(ideal)
.zip(intercepts)
.map(|((&val, &z), &a)| {
let norm = if a > 1e-10 { a } else { 1.0 };
(val - z) / norm
})
.collect()
})
.collect()
}
/// Perpendicular distance from a point to a reference line (direction vector).
fn perpendicular_distance(point: &[f64], reference: &[f64]) -> f64 {
let dot: f64 = point.iter().zip(reference).map(|(&p, &r)| p * r).sum();
let ref_norm_sq: f64 = reference.iter().map(|&r| r * r).sum();
if ref_norm_sq < 1e-30 {
return f64::INFINITY;
}
let proj_scalar = dot / ref_norm_sq;
let dist_sq: f64 = point
.iter()
.zip(reference)
.map(|(&p, &r)| {
let proj = proj_scalar * r;
(p - proj).powi(2)
})
.sum();
dist_sq.sqrt()
}
/// Associate each solution with its nearest reference point.
/// Returns (`closest_ref_idx`, distance) for each solution.
fn associate_to_reference_points(
normalized: &[Vec<f64>],
reference_points: &[Vec<f64>],
) -> Vec<(usize, f64)> {
normalized
.iter()
.map(|point| {
let mut best_ref = 0;
let mut best_dist = f64::INFINITY;
for (j, rp) in reference_points.iter().enumerate() {
let d = perpendicular_distance(point, rp);
if d < best_dist {
best_dist = d;
best_ref = j;
}
}
(best_ref, best_dist)
})
.collect()
}
/// NSGA-III niching-based selection from the last front.
///
/// `already_selected` are indices into the combined population that are
/// already accepted (from fronts 0..L-1). `last_front` contains indices
/// from front L. We need to pick `remaining` more from `last_front`.
fn niching_select(
rng: &mut fastrand::Rng,
associations: &[(usize, f64)],
already_selected: &[usize],
last_front: &[usize],
n_reference_points: usize,
remaining: usize,
) -> Vec<usize> {
// Count niche per reference point for already selected
let mut niche_count = vec![0_usize; n_reference_points];
for &idx in already_selected {
niche_count[associations[idx].0] += 1;
}
// Build per-reference-point candidate lists from the last front
let mut ref_candidates: Vec<Vec<(usize, f64)>> = vec![Vec::new(); n_reference_points];
for &idx in last_front {
let (ref_idx, dist) = associations[idx];
ref_candidates[ref_idx].push((idx, dist));
}
let mut selected = Vec::with_capacity(remaining);
let mut excluded = vec![false; associations.len()];
for _ in 0..remaining {
// Find minimum niche count among reference points that still have candidates
let min_count = (0..n_reference_points)
.filter(|&j| ref_candidates[j].iter().any(|&(idx, _)| !excluded[idx]))
.map(|j| niche_count[j])
.min();
let Some(min_count) = min_count else {
break;
};
// Collect reference points with this minimum count that have candidates
let min_refs: Vec<usize> = (0..n_reference_points)
.filter(|&j| {
niche_count[j] == min_count
&& ref_candidates[j].iter().any(|&(idx, _)| !excluded[idx])
})
.collect();
if min_refs.is_empty() {
break;
}
// Pick a random reference point from the minimum set
let chosen_ref = min_refs[rng.usize(0..min_refs.len())];
// Available candidates for this reference point
let available: Vec<(usize, f64)> = ref_candidates[chosen_ref]
.iter()
.filter(|&&(idx, _)| !excluded[idx])
.copied()
.collect();
if available.is_empty() {
continue;
}
let chosen_idx = if min_count == 0 {
// Pick closest to reference line
available
.iter()
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(core::cmp::Ordering::Equal))
.unwrap()
.0
} else {
// Pick random
available[rng.usize(0..available.len())].0
};
selected.push(chosen_idx);
excluded[chosen_idx] = true;
niche_count[chosen_ref] += 1;
}
selected
}
/// Perform NSGA-III selection: non-dominated sort + reference-point niching.
fn nsga3_select(
state: &mut Nsga3State,
population: &[&MultiObjectiveTrial],
directions: &[Direction],
) -> Vec<Vec<ParamValue>> {
let pop_size = state.evo.population_size;
let n_obj = directions.len();
// Convert to minimize-space
let min_values: Vec<Vec<f64>> = population
.iter()
.map(|t| to_minimize_space(&t.values, directions))
.collect();
// Non-dominated sort
let constraints: Vec<Vec<f64>> = population.iter().map(|t| t.constraints.clone()).collect();
let has_constraints = constraints.iter().any(|c| !c.is_empty());
let fronts = if has_constraints {
pareto::fast_non_dominated_sort_constrained(
&min_values,
&vec![Direction::Minimize; n_obj],
&constraints,
)
} else {
pareto::fast_non_dominated_sort(&min_values, &vec![Direction::Minimize; n_obj])
};
// Fill front-by-front
let mut selected: Vec<usize> = Vec::with_capacity(pop_size);
let mut last_front_idx = None;
for (fi, front) in fronts.iter().enumerate() {
if selected.len() + front.len() <= pop_size {
selected.extend_from_slice(front);
} else {
last_front_idx = Some(fi);
break;
}
}
// If we filled exactly or all fronts fit, done
if selected.len() < pop_size
&& let Some(lf_idx) = last_front_idx
{
// Need niching from the last partial front
let remaining = pop_size - selected.len();
// Update ideal point
update_ideal_point(&mut state.ideal_point, &min_values);
// Find intercepts and normalize
let intercepts = find_intercepts(&min_values, &state.ideal_point);
let normalized = normalize_objectives(&min_values, &state.ideal_point, &intercepts);
// Associate all solutions with reference points
let associations = associate_to_reference_points(&normalized, &state.reference_points);
// Select from last front using niching
let last_front = &fronts[lf_idx];
let additional = niching_select(
&mut state.evo.rng,
&associations,
&selected,
last_front,
state.reference_points.len(),
remaining,
);
selected.extend(additional);
}
// Pad if needed
let n = population.len();
while selected.len() < pop_size {
selected.push(state.evo.rng.usize(0..n));
}
selected
.iter()
.map(|&idx| {
extract_trial_params(population[idx], &state.evo.dimensions, &mut state.evo.rng)
})
.collect()
}
/// Tournament selection based on rank only (no crowding distance in NSGA-III).
fn tournament_select_rank(rng: &mut fastrand::Rng, ranks: &[usize], n: usize) -> usize {
let a = rng.usize(0..n);
let b = rng.usize(0..n);
if ranks[a] <= ranks[b] { a } else { b }
}
fn nsga3_generate_offspring(
state: &mut Nsga3State,
population: &[&MultiObjectiveTrial],
directions: &[Direction],
) -> Vec<Candidate> {
let pop_size = state.evo.population_size;
if population.len() < 2 {
return (0..pop_size)
.map(|_| {
let params = state
.evo
.dimensions
.iter()
.map(|d| sample_random(&mut state.evo.rng, &d.distribution))
.collect();
Candidate { params }
})
.collect();
}
// Initialize reference points and ideal on first generation
if !state.initialized {
initialize_nsga3(state, directions);
}
let parents = nsga3_select(state, population, directions);
// Assign ranks for tournament selection
let n_obj = directions.len();
let min_values: Vec<Vec<f64>> = population
.iter()
.map(|t| to_minimize_space(&t.values, directions))
.collect();
let fronts = pareto::fast_non_dominated_sort(&min_values, &vec![Direction::Minimize; n_obj]);
let mut rank = vec![0_usize; parents.len()];
for (front_rank, front) in fronts.iter().enumerate() {
for &idx in front {
if idx < rank.len() {
rank[idx] = front_rank;
}
}
}
// Ranks for selected parents (simplified: use index order)
let parent_ranks: Vec<usize> = (0..parents.len())
.map(|i| i % (fronts.len().max(1)))
.collect();
let mut offspring = Vec::with_capacity(pop_size);
while offspring.len() < pop_size {
let p1 = tournament_select_rank(&mut state.evo.rng, &parent_ranks, parents.len());
let p2 = tournament_select_rank(&mut state.evo.rng, &parent_ranks, parents.len());
let (mut child1, mut child2) = crossover(
&mut state.evo.rng,
&parents[p1],
&parents[p2],
&state.evo.dimensions,
state.config.crossover_prob,
state.config.crossover_eta,
);
mutate(
&mut state.evo.rng,
&mut child1,
&state.evo.dimensions,
state.config.mutation_eta,
);
mutate(
&mut state.evo.rng,
&mut child2,
&state.evo.dimensions,
state.config.mutation_eta,
);
offspring.push(Candidate { params: child1 });
if offspring.len() < pop_size {
offspring.push(Candidate { params: child2 });
}
}
offspring
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_perpendicular_distance() {
// Point (1, 0) to reference line (1, 1) (45-degree line)
let d = perpendicular_distance(&[1.0, 0.0], &[1.0, 1.0]);
// Projection is (0.5, 0.5), distance = sqrt(0.25 + 0.25) = sqrt(0.5)
assert!((d - (0.5_f64).sqrt()).abs() < 1e-10);
}
#[test]
fn test_perpendicular_distance_on_line() {
// Point on the reference line
let d = perpendicular_distance(&[2.0, 2.0], &[1.0, 1.0]);
assert!(d < 1e-10);
}
#[test]
fn test_normalize_objectives() {
let values = vec![vec![2.0, 4.0], vec![4.0, 2.0]];
let ideal = vec![1.0, 1.0];
let intercepts = vec![3.0, 3.0];
let normalized = normalize_objectives(&values, &ideal, &intercepts);
assert!((normalized[0][0] - 1.0 / 3.0).abs() < 1e-10);
assert!((normalized[0][1] - 1.0).abs() < 1e-10);
}
}
+11 -12
View File
@@ -1,11 +1,10 @@
//! Random sampler implementation.
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::{CompletedTrial, Sampler};
/// A simple random sampler that samples uniformly from distributions.
@@ -26,7 +25,7 @@ use crate::sampler::{CompletedTrial, Sampler};
/// let sampler = RandomSampler::with_seed(42);
/// ```
pub struct RandomSampler {
rng: Mutex<StdRng>,
rng: Mutex<fastrand::Rng>,
}
impl RandomSampler {
@@ -34,7 +33,7 @@ impl RandomSampler {
#[must_use]
pub fn new() -> Self {
Self {
rng: Mutex::new(rand::make_rng()),
rng: Mutex::new(fastrand::Rng::new()),
}
}
@@ -44,7 +43,7 @@ impl RandomSampler {
#[must_use]
pub fn with_seed(seed: u64) -> Self {
Self {
rng: Mutex::new(StdRng::seed_from_u64(seed)),
rng: Mutex::new(fastrand::Rng::with_seed(seed)),
}
}
}
@@ -71,16 +70,16 @@ impl Sampler for RandomSampler {
// Sample uniformly in log space
let log_low = d.low.ln();
let log_high = d.high.ln();
let log_value = rng.random_range(log_low..=log_high);
let log_value = rng_util::f64_range(&mut rng, log_low, log_high);
log_value.exp()
} else if let Some(step) = d.step {
// Sample from step grid
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
// Uniform sampling
rng.random_range(d.low..=d.high)
rng_util::f64_range(&mut rng, d.low, d.high)
};
ParamValue::Float(value)
}
@@ -89,23 +88,23 @@ impl Sampler for RandomSampler {
// Sample uniformly in log space, then round
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let log_value = rng.random_range(log_low..=log_high);
let log_value = rng_util::f64_range(&mut rng, log_low, log_high);
let raw = log_value.exp().round() as i64;
// Clamp to bounds since rounding might push outside
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
// Sample from step grid
let n_steps = (d.high - d.low) / step;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
// Uniform sampling
rng.random_range(d.low..=d.high)
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => {
let index = rng.random_range(0..d.n_choices);
let index = rng.usize(0..d.n_choices);
ParamValue::Categorical(index)
}
}
+29 -37
View File
@@ -116,14 +116,13 @@ use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use super::{FixedGamma, GammaStrategy};
use crate::distribution::Distribution;
use crate::error::Result;
use crate::param::ParamValue;
use crate::parameter::ParamId;
use crate::rng_util;
use crate::sampler::{CompletedTrial, PendingTrial, Sampler};
/// Strategy for imputing objective values for pending/running trials during parallel optimization.
@@ -189,7 +188,7 @@ pub struct MultivariateTpeSampler {
/// Strategy for imputing objective values for pending trials in parallel optimization.
constant_liar: ConstantLiarStrategy,
/// Thread-safe RNG for sampling.
rng: Mutex<StdRng>,
rng: Mutex<fastrand::Rng>,
/// Cache for joint samples to maintain consistency across parameters within the same trial.
/// The tuple contains (`trial_id`, cached joint sample).
joint_sample_cache: Mutex<Option<(u64, HashMap<ParamId, ParamValue>)>>,
@@ -219,7 +218,7 @@ impl MultivariateTpeSampler {
n_ei_candidates: 24,
group: false,
constant_liar: ConstantLiarStrategy::None,
rng: Mutex::new(rand::make_rng()),
rng: Mutex::new(fastrand::Rng::new()),
joint_sample_cache: Mutex::new(None),
}
}
@@ -695,7 +694,7 @@ impl MultivariateTpeSampler {
// Generate candidates from the good distribution
let candidates: Vec<Vec<f64>> = (0..self.n_ei_candidates)
.map(|_| good_kde.sample(&mut *rng))
.map(|_| good_kde.sample(&mut rng))
.collect();
// Compute log(l(x)) - log(g(x)) for each candidate
@@ -731,7 +730,7 @@ impl MultivariateTpeSampler {
&self,
good_kde: &crate::kde::MultivariateKDE,
bad_kde: &crate::kde::MultivariateKDE,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> Vec<f64> {
// Generate candidates from the good distribution
let candidates: Vec<Vec<f64>> = (0..self.n_ei_candidates)
@@ -769,7 +768,7 @@ impl MultivariateTpeSampler {
fn sample_all_uniform(
&self,
search_space: &HashMap<ParamId, Distribution>,
rng: &mut rand::rngs::StdRng,
rng: &mut fastrand::Rng,
) -> HashMap<ParamId, ParamValue> {
search_space
.iter()
@@ -778,25 +777,22 @@ impl MultivariateTpeSampler {
}
/// Samples a single parameter uniformly at random from its distribution.
fn sample_uniform_single(
distribution: &Distribution,
rng: &mut rand::rngs::StdRng,
) -> ParamValue {
fn sample_uniform_single(distribution: &Distribution, rng: &mut fastrand::Rng) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng.random_range(log_low..=log_high).exp()
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
#[allow(clippy::cast_possible_truncation)]
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
#[allow(clippy::cast_precision_loss)]
let result = d.low + (k as f64) * step;
result
} else {
rng.random_range(d.low..=d.high)
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
@@ -806,20 +802,20 @@ impl MultivariateTpeSampler {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
#[allow(clippy::cast_possible_truncation)]
let raw = rng.random_range(log_low..=log_high).exp().round() as i64;
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
#[allow(clippy::cast_possible_truncation)]
let n_steps = (d.high - d.low) / step;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.random_range(d.low..=d.high)
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => {
let index = rng.random_range(0..d.n_choices);
let index = rng.usize(0..d.n_choices);
ParamValue::Categorical(index)
}
}
@@ -1018,7 +1014,7 @@ impl MultivariateTpeSampler {
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> HashMap<ParamId, ParamValue> {
use super::IntersectionSearchSpace;
use crate::kde::MultivariateKDE;
@@ -1220,7 +1216,7 @@ impl MultivariateTpeSampler {
_intersection: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
result: &mut HashMap<ParamId, ParamValue>,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) {
// Identify parameters not in result (and not in intersection)
let missing_params: Vec<(&ParamId, &Distribution)> = search_space
@@ -1253,7 +1249,7 @@ impl MultivariateTpeSampler {
distribution: &Distribution,
good_trials: &[&CompletedTrial],
bad_trials: &[&CompletedTrial],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> ParamValue {
match distribution {
Distribution::Float(d) => {
@@ -1370,7 +1366,7 @@ impl MultivariateTpeSampler {
step: Option<f64>,
good_values: Vec<f64>,
bad_values: Vec<f64>,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> f64 {
use crate::kde::KernelDensityEstimator;
@@ -1391,7 +1387,7 @@ impl MultivariateTpeSampler {
// 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);
return rng_util::f64_range(rng, low, high);
};
// Generate candidates from l(x) and select the one with best l(x)/g(x) ratio
@@ -1455,7 +1451,7 @@ impl MultivariateTpeSampler {
step: Option<i64>,
good_values: &[i64],
bad_values: &[i64],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> i64 {
// Convert to floats for KDE
let good_floats: Vec<f64> = good_values.iter().map(|&v| v as f64).collect();
@@ -1518,7 +1514,7 @@ impl MultivariateTpeSampler {
&self,
search_space: &HashMap<ParamId, Distribution>,
history: &[CompletedTrial],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> HashMap<ParamId, ParamValue> {
// Split trials for independent sampling
let (good_trials, bad_trials) = self.split_trials(&history.iter().collect::<Vec<_>>());
@@ -1562,7 +1558,7 @@ impl MultivariateTpeSampler {
n_choices: usize,
good_indices: &[usize],
bad_indices: &[usize],
rng: &mut rand::rngs::StdRng,
rng: &mut fastrand::Rng,
) -> usize {
// Count occurrences in good and bad groups
let mut good_counts = vec![0usize; n_choices];
@@ -1593,7 +1589,7 @@ impl MultivariateTpeSampler {
// Sample proportionally to weights
let total_weight: f64 = weights.iter().sum();
let threshold = rng.random::<f64>() * total_weight;
let threshold = rng.f64() * total_weight;
let mut cumulative = 0.0;
for (i, &w) in weights.iter().enumerate() {
@@ -2069,8 +2065,8 @@ impl MultivariateTpeSamplerBuilder {
};
let rng = match self.seed {
Some(s) => StdRng::seed_from_u64(s),
None => rand::make_rng(),
Some(s) => fastrand::Rng::with_seed(s),
None => fastrand::Rng::new(),
};
Ok(MultivariateTpeSampler {
@@ -4854,8 +4850,7 @@ mod tests {
#[test]
fn test_sample_tpe_categorical_basic() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut rng = fastrand::Rng::with_seed(42);
// Category 0 is good (appears more in good trials)
let good_indices = vec![0, 0, 0, 1];
@@ -4890,8 +4885,7 @@ mod tests {
#[test]
fn test_sample_tpe_categorical_laplace_smoothing() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut rng = fastrand::Rng::with_seed(42);
// Category 2 never appears, but should still be sampled due to Laplace smoothing
let good_indices = vec![0, 0, 1];
@@ -4919,8 +4913,7 @@ mod tests {
#[test]
fn test_sample_tpe_categorical_empty_good() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut rng = fastrand::Rng::with_seed(42);
// Empty good group - all categories should have equal probability
let good_indices: Vec<usize> = vec![];
@@ -4945,8 +4938,7 @@ mod tests {
#[test]
fn test_sample_tpe_categorical_all_indices_valid() {
use rand::SeedableRng;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut rng = fastrand::Rng::with_seed(42);
let n_choices = 4;
let good_indices = vec![0, 1, 2, 3];
+20 -23
View File
@@ -59,13 +59,12 @@ use core::fmt::Debug;
use std::sync::Arc;
use parking_lot::Mutex;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use crate::distribution::Distribution;
use crate::error::{Error, Result};
use crate::kde::KernelDensityEstimator;
use crate::param::ParamValue;
use crate::rng_util;
use crate::sampler::tpe::gamma::{FixedGamma, GammaStrategy};
use crate::sampler::{CompletedTrial, Sampler};
@@ -131,7 +130,7 @@ pub struct TpeSampler {
/// Optional fixed bandwidth for KDE. If None, uses Scott's rule.
kde_bandwidth: Option<f64>,
/// Thread-safe RNG for sampling.
rng: Mutex<StdRng>,
rng: Mutex<fastrand::Rng>,
}
impl TpeSampler {
@@ -149,7 +148,7 @@ impl TpeSampler {
n_startup_trials: 10,
n_ei_candidates: 24,
kde_bandwidth: None,
rng: Mutex::new(rand::make_rng()),
rng: Mutex::new(fastrand::Rng::new()),
}
}
@@ -250,8 +249,8 @@ impl TpeSampler {
}
let rng = match seed {
Some(s) => StdRng::seed_from_u64(s),
None => rand::make_rng(),
Some(s) => fastrand::Rng::with_seed(s),
None => fastrand::Rng::new(),
};
Ok(Self {
@@ -328,19 +327,19 @@ impl TpeSampler {
clippy::cast_precision_loss,
clippy::unused_self
)]
fn sample_uniform(&self, distribution: &Distribution, rng: &mut StdRng) -> ParamValue {
fn sample_uniform(&self, distribution: &Distribution, rng: &mut fastrand::Rng) -> ParamValue {
match distribution {
Distribution::Float(d) => {
let value = if d.log_scale {
let log_low = d.low.ln();
let log_high = d.high.ln();
rng.random_range(log_low..=log_high).exp()
rng_util::f64_range(rng, log_low, log_high).exp()
} else if let Some(step) = d.step {
let n_steps = ((d.high - d.low) / step).floor() as i64;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + (k as f64) * step
} else {
rng.random_range(d.low..=d.high)
rng_util::f64_range(rng, d.low, d.high)
};
ParamValue::Float(value)
}
@@ -348,20 +347,18 @@ impl TpeSampler {
let value = if d.log_scale {
let log_low = (d.low as f64).ln();
let log_high = (d.high as f64).ln();
let raw = rng.random_range(log_low..=log_high).exp().round() as i64;
let raw = rng_util::f64_range(rng, log_low, log_high).exp().round() as i64;
raw.clamp(d.low, d.high)
} else if let Some(step) = d.step {
let n_steps = (d.high - d.low) / step;
let k = rng.random_range(0..=n_steps);
let k = rng.i64(0..=n_steps);
d.low + k * step
} else {
rng.random_range(d.low..=d.high)
rng.i64(d.low..=d.high)
};
ParamValue::Int(value)
}
Distribution::Categorical(d) => {
ParamValue::Categorical(rng.random_range(0..d.n_choices))
}
Distribution::Categorical(d) => ParamValue::Categorical(rng.usize(0..d.n_choices)),
}
}
@@ -375,7 +372,7 @@ impl TpeSampler {
step: Option<f64>,
good_values: Vec<f64>,
bad_values: Vec<f64>,
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> f64 {
// Transform to internal space (log space if needed)
let (internal_low, internal_high, good_internal, bad_internal) = if log_scale {
@@ -400,7 +397,7 @@ impl TpeSampler {
// 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);
return rng_util::f64_range(rng, low, high);
};
// Generate candidates from l(x) and select the one with best l(x)/g(x) ratio
@@ -464,7 +461,7 @@ impl TpeSampler {
step: Option<i64>,
good_values: &[i64],
bad_values: &[i64],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> i64 {
// Convert to floats for KDE
let good_floats: Vec<f64> = good_values.iter().map(|&v| v as f64).collect();
@@ -503,7 +500,7 @@ impl TpeSampler {
n_choices: usize,
good_indices: &[usize],
bad_indices: &[usize],
rng: &mut StdRng,
rng: &mut fastrand::Rng,
) -> usize {
// Count occurrences in good and bad groups
let mut good_counts = vec![0usize; n_choices];
@@ -534,7 +531,7 @@ impl TpeSampler {
// Sample proportionally to weights
let total_weight: f64 = weights.iter().sum();
let threshold = rng.random::<f64>() * total_weight;
let threshold = rng.f64() * total_weight;
let mut cumulative = 0.0;
for (i, &w) in weights.iter().enumerate() {
@@ -856,8 +853,8 @@ impl TpeSamplerBuilder {
}
let rng = match self.seed {
Some(s) => StdRng::seed_from_u64(s),
None => rand::make_rng(),
Some(s) => fastrand::Rng::with_seed(s),
None => fastrand::Rng::new(),
};
Ok(TpeSampler {
+176
View File
@@ -0,0 +1,176 @@
//! JSONL-based journal storage backend.
use core::marker::PhantomData;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fs2::FileExt;
use parking_lot::{Mutex, RwLock};
use serde::Serialize;
use serde::de::DeserializeOwned;
use super::{MemoryStorage, Storage};
use crate::sampler::CompletedTrial;
/// A storage backend that appends completed trials as JSON lines to a file.
///
/// Trials are kept in memory for fast read access and simultaneously
/// persisted to a JSONL file. Multiple processes can safely share
/// the same file: writes use an exclusive file lock, reads use a
/// shared file lock.
///
/// The type parameter `V` is the objective value type (typically `f64`).
/// It must be serializable so that trials can be written to disk.
///
/// # Examples
///
/// ```no_run
/// use optimizer::storage::JournalStorage;
///
/// let storage: JournalStorage<f64> = JournalStorage::new("trials.jsonl");
/// ```
pub struct JournalStorage<V = f64> {
memory: MemoryStorage<V>,
path: PathBuf,
/// Serialise in-process writes so we only hold the file lock briefly.
write_lock: Mutex<()>,
_marker: PhantomData<V>,
}
impl<V: Serialize + DeserializeOwned + Send + Sync> JournalStorage<V> {
/// Creates a new journal storage that writes to the given path.
///
/// The file does not need to exist yet — it will be created on the
/// first write. Existing trials in the file are **not** loaded
/// until [`refresh`](Storage::refresh) is called (which happens
/// automatically at the start of each trial via the [`Study`](crate::Study)).
///
/// To pre-load existing trials, use [`JournalStorage::open`].
#[must_use]
pub fn new(path: impl AsRef<Path>) -> Self {
Self {
memory: MemoryStorage::new(),
path: path.as_ref().to_path_buf(),
write_lock: Mutex::new(()),
_marker: PhantomData,
}
}
/// Opens an existing journal file and loads all stored trials.
///
/// If the file does not exist, returns an empty storage (no error).
///
/// # Errors
///
/// Returns a [`Storage`](crate::Error::Storage) error if the file
/// exists but cannot be read or parsed.
pub fn open(path: impl AsRef<Path>) -> crate::Result<Self> {
let path = path.as_ref().to_path_buf();
let trials = load_trials_from_file(&path)?;
Ok(Self {
memory: MemoryStorage::with_trials(trials),
path,
write_lock: Mutex::new(()),
_marker: PhantomData,
})
}
/// Append a single trial to the JSONL file (best-effort).
fn write_to_file(&self, trial: &CompletedTrial<V>) -> crate::Result<()> {
let _guard = self.write_lock.lock();
let mut file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&self.path)
.map_err(|e| crate::Error::Storage(e.to_string()))?;
file.lock_exclusive()
.map_err(|e| crate::Error::Storage(e.to_string()))?;
file.seek(SeekFrom::End(0))
.map_err(|e| crate::Error::Storage(e.to_string()))?;
let line =
serde_json::to_string(trial).map_err(|e| crate::Error::Storage(e.to_string()))?;
writeln!(file, "{line}").map_err(|e| crate::Error::Storage(e.to_string()))?;
file.sync_data()
.map_err(|e| crate::Error::Storage(e.to_string()))?;
file.unlock()
.map_err(|e| crate::Error::Storage(e.to_string()))?;
Ok(())
}
}
impl<V: Serialize + DeserializeOwned + Send + Sync> Storage<V> for JournalStorage<V> {
fn push(&self, trial: CompletedTrial<V>) {
// Best-effort persist; the trial stays in memory regardless.
let _ = self.write_to_file(&trial);
self.memory.push(trial);
}
fn trials_arc(&self) -> &Arc<RwLock<Vec<CompletedTrial<V>>>> {
self.memory.trials_arc()
}
fn next_trial_id(&self) -> u64 {
self.memory.next_trial_id()
}
fn refresh(&self) -> bool {
let Ok(loaded) = load_trials_from_file::<V>(&self.path) else {
return false;
};
let mut guard = self.memory.trials_arc().write();
if loaded.len() > guard.len() {
if let Some(max_id) = loaded.iter().map(|t| t.id).max() {
self.memory.bump_next_id(max_id + 1);
}
*guard = loaded;
true
} else {
false
}
}
}
/// Read all trials from a JSONL file. Returns an empty vec if the
/// file does not exist.
fn load_trials_from_file<V: DeserializeOwned>(
path: &Path,
) -> crate::Result<Vec<CompletedTrial<V>>> {
let file = match File::open(path) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(crate::Error::Storage(e.to_string())),
};
file.lock_shared()
.map_err(|e| crate::Error::Storage(e.to_string()))?;
let reader = BufReader::new(&file);
let mut trials = Vec::new();
for line in reader.lines() {
let line = line.map_err(|e| crate::Error::Storage(e.to_string()))?;
let line = line.trim();
if line.is_empty() {
continue;
}
let trial: CompletedTrial<V> =
serde_json::from_str(line).map_err(|e| crate::Error::Storage(e.to_string()))?;
trials.push(trial);
}
file.unlock()
.map_err(|e| crate::Error::Storage(e.to_string()))?;
Ok(trials)
}
+61
View File
@@ -0,0 +1,61 @@
use core::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use parking_lot::RwLock;
use super::Storage;
use crate::sampler::CompletedTrial;
/// In-memory trial storage (the default).
///
/// This is a thin wrapper around `Arc<RwLock<Vec<CompletedTrial<V>>>>`.
pub struct MemoryStorage<V> {
trials: Arc<RwLock<Vec<CompletedTrial<V>>>>,
next_id: AtomicU64,
}
impl<V> MemoryStorage<V> {
/// Creates a new, empty in-memory store.
#[must_use]
pub fn new() -> Self {
Self {
trials: Arc::new(RwLock::new(Vec::new())),
next_id: AtomicU64::new(0),
}
}
/// Creates an in-memory store pre-populated with `trials`.
#[must_use]
pub fn with_trials(trials: Vec<CompletedTrial<V>>) -> Self {
let next_id = trials.iter().map(|t| t.id).max().map_or(0, |id| id + 1);
Self {
trials: Arc::new(RwLock::new(trials)),
next_id: AtomicU64::new(next_id),
}
}
/// Ensures the ID counter is at least `min_value`.
pub(crate) fn bump_next_id(&self, min_value: u64) {
self.next_id.fetch_max(min_value, Ordering::SeqCst);
}
}
impl<V> Default for MemoryStorage<V> {
fn default() -> Self {
Self::new()
}
}
impl<V: Send + Sync> Storage<V> for MemoryStorage<V> {
fn push(&self, trial: CompletedTrial<V>) {
self.trials.write().push(trial);
}
fn trials_arc(&self) -> &Arc<RwLock<Vec<CompletedTrial<V>>>> {
&self.trials
}
fn next_trial_id(&self) -> u64 {
self.next_id.fetch_add(1, Ordering::SeqCst)
}
}
+54
View File
@@ -0,0 +1,54 @@
//! Trial storage backends.
//!
//! The [`Storage`] trait defines how completed trials are stored and
//! accessed. [`MemoryStorage`] keeps trials in memory (the default).
//! With the `journal` feature enabled, [`JournalStorage`] appends
//! trials to a JSONL file with file-level locking so multiple
//! processes can safely share state.
#[cfg(feature = "journal")]
mod journal;
use std::sync::Arc;
#[cfg(feature = "journal")]
pub use journal::JournalStorage;
use parking_lot::RwLock;
mod memory;
pub use memory::MemoryStorage;
use crate::sampler::CompletedTrial;
/// Trait for storing and retrieving completed trials.
///
/// Every [`Study`](crate::Study) owns an `Arc<dyn Storage<V>>`. The
/// default implementation is [`MemoryStorage`], which keeps trials in
/// a plain `Vec` behind a read-write lock.
///
/// Implementations must be safe to use from multiple threads.
pub trait Storage<V>: Send + Sync {
/// Append a completed trial to the store.
fn push(&self, trial: CompletedTrial<V>);
/// Return a reference to the in-memory trial buffer.
///
/// All implementations must maintain an `Arc<RwLock<Vec<…>>>` that
/// reflects the current set of trials. Callers may acquire a read
/// lock for efficient, allocation-free access.
fn trials_arc(&self) -> &Arc<RwLock<Vec<CompletedTrial<V>>>>;
/// Atomically returns the next unique trial ID.
///
/// Each call increments an internal counter so that consecutive
/// calls always produce distinct IDs.
fn next_trial_id(&self) -> u64;
/// Reload from an external source (e.g. a file written by another
/// process). Returns `true` if the in-memory buffer was updated.
///
/// The default implementation is a no-op that returns `false`.
fn refresh(&self) -> bool {
false
}
}
+549 -70
View File
@@ -4,8 +4,8 @@ use core::any::Any;
use core::fmt;
#[cfg(feature = "async")]
use core::future::Future;
use core::marker::PhantomData;
use core::ops::ControlFlow;
use core::sync::atomic::{AtomicU64, Ordering};
use core::time::Duration;
use std::collections::{HashMap, VecDeque};
use std::sync::Arc;
@@ -49,10 +49,8 @@ where
sampler: Arc<dyn Sampler>,
/// The pruner used to decide whether to stop trials early.
pruner: Arc<dyn Pruner>,
/// Completed trials (wrapped in Arc for sharing with Trial).
completed_trials: Arc<RwLock<Vec<CompletedTrial<V>>>>,
/// Counter for generating unique trial IDs.
next_trial_id: AtomicU64,
/// Trial storage backend (default: [`MemoryStorage`](crate::storage::MemoryStorage)).
storage: Arc<dyn crate::storage::Storage<V>>,
/// Optional factory for creating sampler-aware trials.
/// Set automatically for `Study<f64>` so that `create_trial()` and all
/// optimization methods use the sampler without requiring `_with_sampler` suffixes.
@@ -84,11 +82,35 @@ where
#[must_use]
pub fn new(direction: Direction) -> Self
where
V: 'static,
V: Send + Sync + 'static,
{
Self::with_sampler(direction, RandomSampler::new())
}
/// Returns a [`StudyBuilder`] for constructing a study with a fluent API.
///
/// # Examples
///
/// ```
/// use optimizer::prelude::*;
///
/// let study: Study<f64> = Study::builder()
/// .minimize()
/// .sampler(TpeSampler::new())
/// .pruner(NopPruner)
/// .build();
/// ```
#[must_use]
pub fn builder() -> StudyBuilder<V> {
StudyBuilder {
direction: Direction::Minimize,
sampler: None,
pruner: None,
storage: None,
_marker: PhantomData,
}
}
/// Creates a study that minimizes the objective value.
///
/// This is a shorthand for `Study::with_sampler(Direction::Minimize, sampler)`.
@@ -109,7 +131,7 @@ where
#[must_use]
pub fn minimize(sampler: impl Sampler + 'static) -> Self
where
V: 'static,
V: Send + Sync + 'static,
{
Self::with_sampler(Direction::Minimize, sampler)
}
@@ -134,7 +156,7 @@ where
#[must_use]
pub fn maximize(sampler: impl Sampler + 'static) -> Self
where
V: 'static,
V: Send + Sync + 'static,
{
Self::with_sampler(Direction::Maximize, sampler)
}
@@ -158,40 +180,28 @@ where
/// ```
pub fn with_sampler(direction: Direction, sampler: impl Sampler + 'static) -> Self
where
V: 'static,
V: Send + Sync + 'static,
{
let sampler: Arc<dyn Sampler> = Arc::new(sampler);
let completed_trials = Arc::new(RwLock::new(Vec::new()));
let pruner: Arc<dyn Pruner> = Arc::new(NopPruner);
// For Study<f64>, set up a trial factory that provides sampler integration.
// This uses Any downcasting to check at runtime whether V = f64.
let trial_factory = Self::make_trial_factory(&sampler, &completed_trials, &pruner);
Self {
Self::with_sampler_and_storage(
direction,
sampler,
pruner,
completed_trials,
next_trial_id: AtomicU64::new(0),
trial_factory,
enqueued_params: Arc::new(Mutex::new(VecDeque::new())),
}
crate::storage::MemoryStorage::<V>::new(),
)
}
/// Builds a trial factory for sampler integration when `V = f64`.
fn make_trial_factory(
sampler: &Arc<dyn Sampler>,
completed_trials: &Arc<RwLock<Vec<CompletedTrial<V>>>>,
storage: &Arc<dyn crate::storage::Storage<V>>,
pruner: &Arc<dyn Pruner>,
) -> Option<Arc<dyn Fn(u64) -> Trial + Send + Sync>>
where
V: 'static,
{
// Try to downcast the completed_trials Arc to the f64 specialization.
// Try to downcast the storage's trial buffer to the f64 specialization.
// This succeeds only when V = f64, enabling automatic sampler integration.
let any_ref: &dyn Any = completed_trials;
let trials_arc = storage.trials_arc();
let any_ref: &dyn Any = trials_arc;
let f64_trials: Option<&Arc<RwLock<Vec<CompletedTrial<f64>>>>> = any_ref.downcast_ref();
f64_trials.map(|trials| {
@@ -210,7 +220,35 @@ where
})
}
/// Creates a study with a custom sampler and storage backend.
///
/// This is the most general constructor — all other constructors
/// delegate to this one.
pub fn with_sampler_and_storage(
direction: Direction,
sampler: impl Sampler + 'static,
storage: impl crate::storage::Storage<V> + 'static,
) -> Self
where
V: 'static,
{
let sampler: Arc<dyn Sampler> = Arc::new(sampler);
let pruner: Arc<dyn Pruner> = Arc::new(NopPruner);
let storage: Arc<dyn crate::storage::Storage<V>> = Arc::new(storage);
let trial_factory = Self::make_trial_factory(&sampler, &storage, &pruner);
Self {
direction,
sampler,
pruner,
storage,
trial_factory,
enqueued_params: Arc::new(Mutex::new(VecDeque::new())),
}
}
/// Returns the optimization direction.
#[must_use]
pub fn direction(&self) -> Direction {
self.direction
}
@@ -254,19 +292,19 @@ where
pruner: impl Pruner + 'static,
) -> Self
where
V: 'static,
V: Send + Sync + 'static,
{
let sampler: Arc<dyn Sampler> = Arc::new(sampler);
let pruner: Arc<dyn Pruner> = Arc::new(pruner);
let completed_trials = Arc::new(RwLock::new(Vec::new()));
let trial_factory = Self::make_trial_factory(&sampler, &completed_trials, &pruner);
let storage: Arc<dyn crate::storage::Storage<V>> =
Arc::new(crate::storage::MemoryStorage::<V>::new());
let trial_factory = Self::make_trial_factory(&sampler, &storage, &pruner);
Self {
direction,
sampler,
pruner,
completed_trials,
next_trial_id: AtomicU64::new(0),
storage,
trial_factory,
enqueued_params: Arc::new(Mutex::new(VecDeque::new())),
}
@@ -277,8 +315,7 @@ where
V: 'static,
{
self.sampler = Arc::new(sampler);
self.trial_factory =
Self::make_trial_factory(&self.sampler, &self.completed_trials, &self.pruner);
self.trial_factory = Self::make_trial_factory(&self.sampler, &self.storage, &self.pruner);
}
/// Sets a new pruner for the study.
@@ -291,11 +328,11 @@ where
V: 'static,
{
self.pruner = Arc::new(pruner);
self.trial_factory =
Self::make_trial_factory(&self.sampler, &self.completed_trials, &self.pruner);
self.trial_factory = Self::make_trial_factory(&self.sampler, &self.storage, &self.pruner);
}
/// Returns a reference to the study's pruner.
#[must_use]
pub fn pruner(&self) -> &dyn Pruner {
&*self.pruner
}
@@ -375,7 +412,7 @@ where
/// Generates the next unique trial ID.
pub(crate) fn next_trial_id(&self) -> u64 {
self.next_trial_id.fetch_add(1, Ordering::SeqCst)
self.storage.next_trial_id()
}
/// Creates a new trial with a unique ID.
@@ -400,7 +437,10 @@ where
/// let trial2 = study.create_trial();
/// assert_eq!(trial2.id(), 1);
/// ```
#[must_use]
pub fn create_trial(&self) -> Trial {
self.storage.refresh();
let id = self.next_trial_id();
let mut trial = if let Some(factory) = &self.trial_factory {
factory(id)
@@ -455,7 +495,8 @@ where
);
completed.state = TrialState::Complete;
completed.constraints = trial.constraint_values().to_vec();
self.completed_trials.write().push(completed);
self.storage.push(completed);
}
/// Records a failed trial with an error message.
@@ -509,6 +550,7 @@ where
/// let value = x_val * x_val;
/// study.tell(trial, Ok::<_, &str>(value));
/// ```
#[must_use]
pub fn ask(&self) -> Trial {
self.create_trial()
}
@@ -565,7 +607,8 @@ where
);
completed.state = TrialState::Pruned;
completed.constraints = trial.constraint_values().to_vec();
self.completed_trials.write().push(completed);
self.storage.push(completed);
}
/// Returns an iterator over all completed trials.
@@ -592,11 +635,12 @@ where
/// println!("Trial {} has value {:?}", completed.id, completed.value);
/// }
/// ```
#[must_use]
pub fn trials(&self) -> Vec<CompletedTrial<V>>
where
V: Clone,
{
self.completed_trials.read().clone()
self.storage.trials_arc().read().clone()
}
/// Returns the number of completed trials.
@@ -618,13 +662,16 @@ where
/// study.complete_trial(trial, 0.5);
/// assert_eq!(study.n_trials(), 1);
/// ```
#[must_use]
pub fn n_trials(&self) -> usize {
self.completed_trials.read().len()
self.storage.trials_arc().read().len()
}
/// Returns the number of pruned trials.
#[must_use]
pub fn n_pruned_trials(&self) -> usize {
self.completed_trials
self.storage
.trials_arc()
.read()
.iter()
.filter(|t| t.state == TrialState::Pruned)
@@ -703,7 +750,7 @@ where
where
V: Clone,
{
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let direction = self.direction;
let best = trials
@@ -763,11 +810,12 @@ where
/// Only includes completed trials (not failed or pruned).
///
/// If fewer than `n` completed trials exist, returns all of them.
#[must_use]
pub fn top_trials(&self, n: usize) -> Vec<CompletedTrial<V>>
where
V: Clone,
{
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let direction = self.direction;
let mut completed: Vec<_> = trials
.iter()
@@ -848,7 +896,7 @@ where
#[cfg(feature = "tracing")]
{
tracing::info!(trial_id, "trial completed");
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
if trials
.iter()
.filter(|t| t.state == TrialState::Complete)
@@ -876,7 +924,8 @@ where
// Return error if no trials completed successfully
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -975,7 +1024,8 @@ where
// Return error if no trials completed successfully
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1101,7 +1151,8 @@ where
// Return error if no trials completed successfully
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1197,7 +1248,7 @@ where
#[cfg(feature = "tracing")]
{
tracing::info!(trial_id, "trial completed");
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
if trials
.iter()
.filter(|t| t.state == TrialState::Complete)
@@ -1210,7 +1261,7 @@ where
}
// Get the just-completed trial for the callback
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let Some(completed) = trials.last() else {
return Err(crate::Error::Internal(
"completed trial not found after adding",
@@ -1243,7 +1294,8 @@ where
// Return error if no trials completed successfully
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1328,7 +1380,8 @@ where
}
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1420,7 +1473,7 @@ where
#[cfg(feature = "tracing")]
{
tracing::info!(trial_id, "trial completed");
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
if trials
.iter()
.filter(|t| t.state == TrialState::Complete)
@@ -1432,7 +1485,7 @@ where
}
}
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let Some(completed) = trials.last() else {
return Err(crate::Error::Internal(
"completed trial not found after adding",
@@ -1461,7 +1514,8 @@ where
}
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1519,7 +1573,8 @@ where
}
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1608,7 +1663,8 @@ where
}
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1716,7 +1772,8 @@ where
// Return error if no trials completed successfully
let has_complete = self
.completed_trials
.storage
.trials_arc()
.read()
.iter()
.any(|t| t.state == TrialState::Complete);
@@ -1732,6 +1789,119 @@ impl<V> Study<V>
where
V: PartialOrd + Clone + fmt::Display,
{
/// Export completed trials to CSV format.
///
/// Columns: `trial_id`, `value`, `state`, then one column per unique
/// parameter label, then one column per unique user-attribute key.
///
/// Parameters without labels use a generated name (`param_<id>`).
/// Pruned trials have an empty `value` cell.
///
/// # Errors
///
/// Returns an I/O error if writing fails.
pub fn to_csv(&self, mut writer: impl std::io::Write) -> std::io::Result<()> {
use std::collections::BTreeMap;
let trials = self.storage.trials_arc().read();
// Collect all unique parameter labels (sorted for deterministic column order).
let mut param_columns: BTreeMap<ParamId, String> = BTreeMap::new();
for trial in trials.iter() {
for &id in trial.params.keys() {
param_columns.entry(id).or_insert_with(|| {
trial
.param_labels
.get(&id)
.cloned()
.unwrap_or_else(|| id.to_string())
});
}
}
// Fill in labels from other trials that might have better labels.
for trial in trials.iter() {
for (&id, label) in &trial.param_labels {
param_columns.entry(id).or_insert_with(|| label.clone());
}
}
// Collect all unique attribute keys (sorted).
let mut attr_keys: Vec<String> = Vec::new();
for trial in trials.iter() {
for key in trial.user_attrs.keys() {
if !attr_keys.contains(key) {
attr_keys.push(key.clone());
}
}
}
attr_keys.sort();
let param_ids: Vec<ParamId> = param_columns.keys().copied().collect();
// Write header.
write!(writer, "trial_id,value,state")?;
for id in &param_ids {
write!(writer, ",{}", csv_escape(&param_columns[id]))?;
}
for key in &attr_keys {
write!(writer, ",{}", csv_escape(key))?;
}
writeln!(writer)?;
// Write one row per trial.
for trial in trials.iter() {
write!(writer, "{}", trial.id)?;
// Value: empty for pruned trials.
if trial.state == TrialState::Complete {
write!(writer, ",{}", trial.value)?;
} else {
write!(writer, ",")?;
}
write!(
writer,
",{}",
match trial.state {
TrialState::Complete => "Complete",
TrialState::Pruned => "Pruned",
TrialState::Failed => "Failed",
TrialState::Running => "Running",
}
)?;
for id in &param_ids {
if let Some(pv) = trial.params.get(id) {
write!(writer, ",{pv}")?;
} else {
write!(writer, ",")?;
}
}
for key in &attr_keys {
if let Some(attr) = trial.user_attrs.get(key) {
write!(writer, ",{}", csv_escape(&format_attr(attr)))?;
} else {
write!(writer, ",")?;
}
}
writeln!(writer)?;
}
Ok(())
}
/// Export completed trials to a CSV file.
///
/// # Errors
///
/// Returns an I/O error if the file cannot be created or written.
pub fn export_csv(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let file = std::fs::File::create(path)?;
self.to_csv(std::io::BufWriter::new(file))
}
/// Returns a human-readable summary of the study.
///
/// The summary includes:
@@ -1760,7 +1930,7 @@ where
pub fn summary(&self) -> String {
use fmt::Write;
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let n_complete = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
@@ -1807,6 +1977,7 @@ where
///
/// This clones the internal trial list, so it is suitable for
/// analysis and iteration but not for hot paths.
#[must_use]
pub fn iter(&self) -> std::vec::IntoIter<CompletedTrial<V>> {
self.trials().into_iter()
}
@@ -1854,7 +2025,7 @@ where
use crate::param::ParamValue;
use crate::types::TrialState;
let trials = self.completed_trials.read();
let trials = self.storage.trials_arc().read();
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
@@ -1914,6 +2085,108 @@ where
scores
}
/// Computes parameter importance using fANOVA (functional ANOVA) with
/// default configuration.
///
/// Fits a random forest to the trial data and decomposes variance into
/// per-parameter main effects and pairwise interaction effects. This is
/// more accurate than correlation-based importance ([`Self::param_importance`])
/// and can detect non-linear relationships and parameter interactions.
///
/// # Errors
///
/// Returns [`crate::Error::NoCompletedTrials`] if fewer than 2 trials have completed.
pub fn fanova(&self) -> crate::Result<crate::fanova::FanovaResult> {
self.fanova_with_config(&crate::fanova::FanovaConfig::default())
}
/// Computes parameter importance using fANOVA with custom configuration.
///
/// See [`Self::fanova`] for details. The [`FanovaConfig`](crate::fanova::FanovaConfig)
/// allows tuning the number of trees, tree depth, and random seed.
///
/// # Errors
///
/// Returns [`crate::Error::NoCompletedTrials`] if fewer than 2 trials have completed.
#[allow(clippy::cast_precision_loss)]
pub fn fanova_with_config(
&self,
config: &crate::fanova::FanovaConfig,
) -> crate::Result<crate::fanova::FanovaResult> {
use std::collections::BTreeSet;
use crate::fanova::compute_fanova;
use crate::param::ParamValue;
use crate::types::TrialState;
let trials = self.storage.trials_arc().read();
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
.collect();
if complete.len() < 2 {
return Err(crate::Error::NoCompletedTrials);
}
// Collect all parameter IDs in a stable order.
let all_param_ids: Vec<_> = {
let set: BTreeSet<_> = complete.iter().flat_map(|t| t.params.keys()).collect();
set.into_iter().collect()
};
if all_param_ids.is_empty() {
return Ok(crate::fanova::FanovaResult {
main_effects: Vec::new(),
interactions: Vec::new(),
});
}
// Build feature matrix (only trials that have all parameters).
let mut data = Vec::new();
let mut targets = Vec::new();
for trial in &complete {
let mut row = Vec::with_capacity(all_param_ids.len());
let mut has_all = true;
for &pid in &all_param_ids {
if let Some(pv) = trial.params.get(pid) {
row.push(match *pv {
ParamValue::Float(v) => v,
ParamValue::Int(v) => v as f64,
ParamValue::Categorical(v) => v as f64,
});
} else {
has_all = false;
break;
}
}
if has_all {
data.push(row);
targets.push(trial.value.clone().into());
}
}
if data.len() < 2 {
return Err(crate::Error::NoCompletedTrials);
}
// Build feature names from parameter labels.
let feature_names: Vec<String> = all_param_ids
.iter()
.map(|&pid| {
complete
.iter()
.find_map(|t| t.param_labels.get(pid))
.map_or_else(|| pid.to_string(), Clone::clone)
})
.collect();
Ok(compute_fanova(&data, &targets, &feature_names, config))
}
}
impl<V> IntoIterator for &Study<V>
@@ -1952,6 +2225,7 @@ impl Study<f64> {
since = "0.2.0",
note = "use `create_trial()` instead — it now uses the sampler automatically for Study<f64>"
)]
#[must_use]
pub fn create_trial_with_sampler(&self) -> Trial {
self.create_trial()
}
@@ -2041,6 +2315,175 @@ impl Study<f64> {
}
}
impl<V: PartialOrd + Send + Sync + 'static> Study<V> {
/// Creates a study with a custom sampler, pruner, and storage backend.
pub fn with_sampler_pruner_and_storage(
direction: Direction,
sampler: impl Sampler + 'static,
pruner: impl Pruner + 'static,
storage: impl crate::storage::Storage<V> + 'static,
) -> Self {
let sampler: Arc<dyn Sampler> = Arc::new(sampler);
let pruner: Arc<dyn Pruner> = Arc::new(pruner);
let storage: Arc<dyn crate::storage::Storage<V>> = Arc::new(storage);
let trial_factory = Self::make_trial_factory(&sampler, &storage, &pruner);
Self {
direction,
sampler,
pruner,
storage,
trial_factory,
enqueued_params: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
/// A builder for constructing [`Study`] instances with a fluent API.
///
/// Created via [`Study::builder()`]. Collects sampler, pruner, direction,
/// and storage options before constructing the study.
///
/// # Defaults
///
/// - Direction: [`Minimize`](Direction::Minimize)
/// - Sampler: [`RandomSampler`]
/// - Pruner: [`NopPruner`]
/// - Storage: [`MemoryStorage`](crate::storage::MemoryStorage)
///
/// # Examples
///
/// ```
/// use optimizer::prelude::*;
///
/// let study: Study<f64> = Study::builder()
/// .maximize()
/// .sampler(TpeSampler::new())
/// .pruner(MedianPruner::new(Direction::Maximize).n_warmup_steps(5))
/// .build();
///
/// assert_eq!(study.direction(), Direction::Maximize);
/// ```
pub struct StudyBuilder<V: PartialOrd = f64> {
direction: Direction,
sampler: Option<Box<dyn Sampler>>,
pruner: Option<Box<dyn Pruner>>,
storage: Option<Box<dyn crate::storage::Storage<V>>>,
_marker: PhantomData<V>,
}
impl<V: PartialOrd> StudyBuilder<V> {
/// Sets the optimization direction to minimize.
#[must_use]
pub fn minimize(mut self) -> Self {
self.direction = Direction::Minimize;
self
}
/// Sets the optimization direction to maximize.
#[must_use]
pub fn maximize(mut self) -> Self {
self.direction = Direction::Maximize;
self
}
/// Sets the optimization direction.
#[must_use]
pub fn direction(mut self, direction: Direction) -> Self {
self.direction = direction;
self
}
/// Sets the sampler used for parameter suggestions.
#[must_use]
pub fn sampler(mut self, sampler: impl Sampler + 'static) -> Self {
self.sampler = Some(Box::new(sampler));
self
}
/// Sets the pruner used for early stopping of trials.
#[must_use]
pub fn pruner(mut self, pruner: impl Pruner + 'static) -> Self {
self.pruner = Some(Box::new(pruner));
self
}
/// Sets a custom storage backend.
#[must_use]
pub fn storage(mut self, storage: impl crate::storage::Storage<V> + 'static) -> Self {
self.storage = Some(Box::new(storage));
self
}
/// Builds the [`Study`] with the configured options.
#[must_use]
pub fn build(self) -> Study<V>
where
V: Send + Sync + 'static,
{
let sampler = self
.sampler
.unwrap_or_else(|| Box::new(RandomSampler::new()));
let pruner = self.pruner.unwrap_or_else(|| Box::new(NopPruner));
let storage = self
.storage
.unwrap_or_else(|| Box::new(crate::storage::MemoryStorage::<V>::new()));
let sampler: Arc<dyn Sampler> = Arc::from(sampler);
let pruner: Arc<dyn Pruner> = Arc::from(pruner);
let storage: Arc<dyn crate::storage::Storage<V>> = Arc::from(storage);
let trial_factory = Study::make_trial_factory(&sampler, &storage, &pruner);
Study {
direction: self.direction,
sampler,
pruner,
storage,
trial_factory,
enqueued_params: Arc::new(Mutex::new(VecDeque::new())),
}
}
}
#[cfg(feature = "journal")]
impl<V> Study<V>
where
V: PartialOrd + Send + Sync + serde::Serialize + serde::de::DeserializeOwned + 'static,
{
/// Creates a study backed by a JSONL journal file.
///
/// Any existing trials in the file are loaded into memory and the
/// trial ID counter is set to one past the highest stored ID. New
/// trials are written through to the file on completion.
///
/// # Errors
///
/// Returns a [`Storage`](crate::Error::Storage) error if loading fails.
pub fn with_journal(
direction: Direction,
sampler: impl Sampler + 'static,
path: impl AsRef<std::path::Path>,
) -> crate::Result<Self> {
let storage = crate::storage::JournalStorage::<V>::open(path)?;
Ok(Self::with_sampler_and_storage(direction, sampler, storage))
}
}
impl Study<f64> {
/// Generates an HTML report with interactive Plotly.js charts.
///
/// Creates a self-contained HTML file that can be opened in any browser.
/// See [`generate_html_report`](crate::visualization::generate_html_report)
/// for details on the included charts.
///
/// # Errors
///
/// Returns an I/O error if the file cannot be created or written.
pub fn export_html(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
crate::visualization::generate_html_report(self, path)
}
}
/// A serializable snapshot of a study's state.
///
/// Since [`Study`] contains non-serializable fields (samplers, atomics, etc.),
@@ -2073,6 +2516,19 @@ pub struct StudySnapshot<V> {
#[cfg(feature = "serde")]
impl<V: PartialOrd + Clone + serde::Serialize> Study<V> {
/// Export trials as a pretty-printed JSON array to a file.
///
/// Each element in the array is a serialized [`CompletedTrial`].
///
/// # Errors
///
/// Returns an I/O error if the file cannot be created or written.
pub fn export_json(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let file = std::fs::File::create(path)?;
let trials = self.trials();
serde_json::to_writer_pretty(file, &trials).map_err(std::io::Error::other)
}
/// Saves the study state to a JSON file.
///
/// # Errors
@@ -2080,11 +2536,13 @@ impl<V: PartialOrd + Clone + serde::Serialize> Study<V> {
/// Returns an I/O error if the file cannot be created or written.
pub fn save(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let path = path.as_ref();
let trials = self.trials();
let next_trial_id = trials.iter().map(|t| t.id).max().map_or(0, |id| id + 1);
let snapshot = StudySnapshot {
version: 1,
direction: self.direction,
trials: self.trials(),
next_trial_id: self.next_trial_id.load(Ordering::Relaxed),
trials,
next_trial_id,
metadata: HashMap::new(),
};
@@ -2136,7 +2594,7 @@ impl<V: PartialOrd + Clone + Default + serde::Serialize> Study<V> {
}
#[cfg(feature = "serde")]
impl<V: PartialOrd + Clone + serde::de::DeserializeOwned + 'static> Study<V> {
impl<V: PartialOrd + Send + Sync + Clone + serde::de::DeserializeOwned + 'static> Study<V> {
/// Loads a study from a JSON file.
///
/// The loaded study uses a `RandomSampler` by default. Call
@@ -2150,12 +2608,12 @@ impl<V: PartialOrd + Clone + serde::de::DeserializeOwned + 'static> Study<V> {
let file = std::fs::File::open(path)?;
let snapshot: StudySnapshot<V> = serde_json::from_reader(file)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let study = Study::new(snapshot.direction);
*study.completed_trials.write() = snapshot.trials;
study
.next_trial_id
.store(snapshot.next_trial_id, Ordering::Relaxed);
Ok(study)
let storage = crate::storage::MemoryStorage::with_trials(snapshot.trials);
Ok(Self::with_sampler_and_storage(
snapshot.direction,
RandomSampler::new(),
storage,
))
}
}
@@ -2171,3 +2629,24 @@ fn is_trial_pruned<E: 'static>(e: &E) -> bool {
any.downcast_ref::<crate::error::TrialPruned>().is_some()
}
}
/// Escape a string for CSV output. If the value contains a comma, quote, or
/// newline, wrap it in double-quotes and double any embedded quotes.
fn csv_escape(s: &str) -> String {
if s.contains(',') || s.contains('"') || s.contains('\n') {
format!("\"{}\"", s.replace('"', "\"\""))
} else {
s.to_string()
}
}
/// Format an `AttrValue` as a string for CSV cells.
fn format_attr(attr: &crate::trial::AttrValue) -> String {
use crate::trial::AttrValue;
match attr {
AttrValue::Float(v) => v.to_string(),
AttrValue::Int(v) => v.to_string(),
AttrValue::String(v) => v.clone(),
AttrValue::Bool(v) => v.to_string(),
}
}
+458
View File
@@ -0,0 +1,458 @@
//! HTML report generation for optimization visualization.
//!
//! Generates self-contained HTML files with embedded Plotly.js charts
//! for offline visualization of optimization results.
use core::fmt::Write as _;
use std::collections::BTreeMap;
use std::path::Path;
use crate::distribution::Distribution;
use crate::param::ParamValue;
use crate::parameter::ParamId;
use crate::sampler::CompletedTrial;
use crate::types::{Direction, TrialState};
/// Generate an HTML report with interactive Plotly.js charts.
///
/// Creates a self-contained HTML file at `path` containing:
/// - **Optimization history**: Objective value vs trial number with best-so-far line
/// - **Slice plots**: Objective value vs each parameter (1D scatter)
/// - **Parallel coordinates**: Multi-parameter relationship view
/// - **Trial timeline**: Duration index of each trial (horizontal bar)
/// - **Intermediate values**: Learning curves per trial (if pruning data available)
/// - **Parameter importance**: Bar chart (if enough completed trials)
///
/// # Errors
///
/// Returns an I/O error if the file cannot be created or written.
pub fn generate_html_report(
study: &crate::Study<f64>,
path: impl AsRef<Path>,
) -> std::io::Result<()> {
let trials = study.trials();
let direction = study.direction();
let importance = study.param_importance();
let html = build_html(&trials, direction, &importance);
std::fs::write(path, html)
}
fn build_html(
trials: &[CompletedTrial<f64>],
direction: Direction,
importance: &[(String, f64)],
) -> String {
let mut html = String::with_capacity(8192);
let dir_label = match direction {
Direction::Minimize => "Minimize",
Direction::Maximize => "Maximize",
};
// Collect parameter metadata.
let param_info = collect_param_info(trials);
let has_intermediate = trials.iter().any(|t| !t.intermediate_values.is_empty());
let _ = write!(
html,
r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Optimization Report</title>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f5f6fa; color: #2c3e50; padding: 24px; }}
h1 {{ text-align: center; margin-bottom: 8px; font-size: 1.8em; }}
.subtitle {{ text-align: center; color: #7f8c8d; margin-bottom: 24px; }}
.chart {{ background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.08);
margin-bottom: 24px; padding: 16px; }}
.chart-title {{ font-size: 1.1em; font-weight: 600; margin-bottom: 8px; }}
</style>
</head>
<body>
<h1>Optimization Report</h1>
<p class="subtitle">{dir_label} &middot; {n} trials</p>
"#,
n = trials.len(),
);
// Optimization history chart.
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Optimization History</div><div id=\"history\"></div></div>\n");
write_history_chart(&mut html, trials, direction);
// Slice plots.
if !param_info.is_empty() {
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Slice Plots</div><div id=\"slices\"></div></div>\n");
write_slice_charts(&mut html, trials, &param_info);
}
// Parallel coordinates.
if param_info.len() >= 2 {
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Parallel Coordinates</div><div id=\"parcoords\"></div></div>\n");
write_parallel_coordinates(&mut html, trials, &param_info, direction);
}
// Parameter importance.
if !importance.is_empty() {
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Parameter Importance</div><div id=\"importance\"></div></div>\n");
write_importance_chart(&mut html, importance);
}
// Trial timeline.
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Trial Timeline</div><div id=\"timeline\"></div></div>\n");
write_timeline_chart(&mut html, trials);
// Intermediate values.
if has_intermediate {
html.push_str("<div class=\"chart\"><div class=\"chart-title\">Intermediate Values</div><div id=\"intermediate\"></div></div>\n");
write_intermediate_chart(&mut html, trials);
}
html.push_str("</body>\n</html>\n");
html
}
/// Metadata about each parameter seen across trials.
struct ParamMeta {
label: String,
#[allow(dead_code)]
dist: Option<Distribution>,
}
/// Collect parameter labels and distributions across all trials.
fn collect_param_info(trials: &[CompletedTrial<f64>]) -> BTreeMap<ParamId, ParamMeta> {
let mut info: BTreeMap<ParamId, ParamMeta> = BTreeMap::new();
for trial in trials {
for &id in trial.params.keys() {
info.entry(id).or_insert_with(|| {
let label = trial
.param_labels
.get(&id)
.cloned()
.unwrap_or_else(|| id.to_string());
let dist = trial.distributions.get(&id).cloned();
ParamMeta { label, dist }
});
}
}
info
}
// ---------------------------------------------------------------------------
// Chart generators
// ---------------------------------------------------------------------------
fn write_history_chart(html: &mut String, trials: &[CompletedTrial<f64>], direction: Direction) {
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
.collect();
if complete.is_empty() {
return;
}
let mut ids = Vec::with_capacity(complete.len());
let mut vals = Vec::with_capacity(complete.len());
let mut best_vals = Vec::with_capacity(complete.len());
let mut best = complete[0].value;
for t in &complete {
ids.push(t.id);
vals.push(t.value);
best = match direction {
Direction::Minimize => best.min(t.value),
Direction::Maximize => best.max(t.value),
};
best_vals.push(best);
}
let _ = write!(
html,
r##"<script>
Plotly.newPlot("history", [
{{ x: {ids:?}, y: {vals:?}, mode: "markers", name: "Objective", type: "scatter",
marker: {{ color: "#3498db", size: 6 }} }},
{{ x: {ids:?}, y: {best_vals:?}, mode: "lines", name: "Best so far", type: "scatter",
line: {{ color: "#e74c3c", width: 2 }} }}
], {{ xaxis: {{ title: "Trial" }}, yaxis: {{ title: "Objective Value" }},
margin: {{ t: 10 }}, legend: {{ x: 1, xanchor: "right", y: 1 }} }},
{{ responsive: true }});
</script>
"##,
);
}
fn write_slice_charts(
html: &mut String,
trials: &[CompletedTrial<f64>],
param_info: &BTreeMap<ParamId, ParamMeta>,
) {
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
.collect();
if complete.is_empty() {
return;
}
let n_params = param_info.len();
let cols = if n_params <= 2 { n_params } else { 2 };
let rows = n_params.div_ceil(cols);
// Build subplot titles and data.
let mut subplot_titles = Vec::new();
let mut traces = String::new();
for (i, (id, meta)) in param_info.iter().enumerate() {
subplot_titles.push(format!("\"{}\"", escape_js(&meta.label)));
let mut x_vals = Vec::new();
let mut y_vals = Vec::new();
for t in &complete {
if let Some(pv) = t.params.get(id) {
x_vals.push(param_value_to_f64(pv));
y_vals.push(t.value);
}
}
let subplot_idx = i + 1;
let xa = if subplot_idx == 1 {
"x".to_string()
} else {
format!("x{subplot_idx}")
};
let ya = if subplot_idx == 1 {
"y".to_string()
} else {
format!("y{subplot_idx}")
};
let _ = write!(
traces,
r##"{{ x: {x_vals:?}, y: {y_vals:?}, mode: "markers", type: "scatter",
xaxis: "{xa}", yaxis: "{ya}",
marker: {{ color: "#3498db", size: 5 }}, showlegend: false }},"##,
);
}
let _ = write!(
html,
r#"<script>
Plotly.newPlot("slices", [{traces}],
{{ grid: {{ rows: {rows}, columns: {cols}, pattern: "independent" }},
annotations: [{annotations}],
margin: {{ t: 30 }}, showlegend: false }},
{{ responsive: true }});
</script>
"#,
annotations = build_subplot_annotations(&subplot_titles, rows, cols),
);
}
fn write_parallel_coordinates(
html: &mut String,
trials: &[CompletedTrial<f64>],
param_info: &BTreeMap<ParamId, ParamMeta>,
direction: Direction,
) {
let complete: Vec<_> = trials
.iter()
.filter(|t| t.state == TrialState::Complete)
.collect();
if complete.is_empty() {
return;
}
let mut dimensions = String::new();
// Add objective value as the first dimension.
let obj_vals: Vec<f64> = complete.iter().map(|t| t.value).collect();
let _ = write!(
dimensions,
r#"{{ label: "Objective", values: {obj_vals:?} }},"#,
);
// Add each parameter as a dimension.
for (id, meta) in param_info {
let vals: Vec<f64> = complete
.iter()
.map(|t| t.params.get(id).map_or(f64::NAN, param_value_to_f64))
.collect();
let _ = write!(
dimensions,
r#"{{ label: "{label}", values: {vals:?} }},"#,
label = escape_js(&meta.label),
);
}
// Color by objective value: green = good, red = bad.
let (cmin, cmax) = min_max(&obj_vals);
let colorscale = match direction {
Direction::Minimize => r##"[[0,"#2ecc71"],[1,"#e74c3c"]]"##,
Direction::Maximize => r##"[[0,"#e74c3c"],[1,"#2ecc71"]]"##,
};
let _ = write!(
html,
r#"<script>
Plotly.newPlot("parcoords", [{{
type: "parcoords",
line: {{ color: {obj_vals:?}, colorscale: {colorscale},
cmin: {cmin}, cmax: {cmax}, showscale: true }},
dimensions: [{dimensions}]
}}], {{ margin: {{ t: 10 }} }}, {{ responsive: true }});
</script>
"#,
);
}
fn write_importance_chart(html: &mut String, importance: &[(String, f64)]) {
let names: Vec<_> = importance.iter().map(|(n, _)| format!("\"{n}\"")).collect();
let values: Vec<f64> = importance.iter().map(|(_, v)| *v).collect();
let _ = write!(
html,
r##"<script>
Plotly.newPlot("importance", [{{
x: {values:?}, y: [{names}], type: "bar", orientation: "h",
marker: {{ color: "#9b59b6" }}
}}], {{ xaxis: {{ title: "Importance (|Spearman correlation|)" }},
yaxis: {{ automargin: true }}, margin: {{ t: 10, l: 120 }} }},
{{ responsive: true }});
</script>
"##,
names = names.join(","),
);
}
fn write_timeline_chart(html: &mut String, trials: &[CompletedTrial<f64>]) {
let mut ids = Vec::with_capacity(trials.len());
let mut colors = Vec::with_capacity(trials.len());
let mut labels = Vec::with_capacity(trials.len());
for t in trials {
ids.push(format!("\"Trial {}\"", t.id));
let (color, label) = match t.state {
TrialState::Complete => ("#2ecc71", "Complete"),
TrialState::Pruned => ("#f39c12", "Pruned"),
TrialState::Failed => ("#e74c3c", "Failed"),
TrialState::Running => ("#3498db", "Running"),
};
colors.push(format!("\"{color}\""));
labels.push(format!("\"{label}\""));
}
// Use trial index as a proxy for duration (no wallclock data available).
let indices: Vec<usize> = (0..trials.len()).collect();
let _ = write!(
html,
r#"<script>
Plotly.newPlot("timeline", [{{
y: [{ids}], x: {indices:?}, type: "bar", orientation: "h",
text: [{labels}], textposition: "auto",
marker: {{ color: [{colors}] }}
}}], {{ xaxis: {{ title: "Trial Index" }}, yaxis: {{ automargin: true, autorange: "reversed" }},
margin: {{ t: 10, l: 80 }}, showlegend: false }},
{{ responsive: true }});
</script>
"#,
ids = ids.join(","),
colors = colors.join(","),
labels = labels.join(","),
);
}
fn write_intermediate_chart(html: &mut String, trials: &[CompletedTrial<f64>]) {
let trials_with_iv: Vec<_> = trials
.iter()
.filter(|t| !t.intermediate_values.is_empty())
.collect();
if trials_with_iv.is_empty() {
return;
}
let mut traces = String::new();
for t in &trials_with_iv {
let steps: Vec<u64> = t.intermediate_values.iter().map(|(s, _)| *s).collect();
let values: Vec<f64> = t.intermediate_values.iter().map(|(_, v)| *v).collect();
let color = match t.state {
TrialState::Pruned => "#f39c12",
_ => "#3498db",
};
let _ = write!(
traces,
r#"{{ x: {steps:?}, y: {values:?}, mode: "lines+markers", name: "Trial {id}",
line: {{ color: "{color}", width: 1 }}, marker: {{ size: 3 }} }},"#,
id = t.id,
);
}
let _ = write!(
html,
r#"<script>
Plotly.newPlot("intermediate", [{traces}],
{{ xaxis: {{ title: "Step" }}, yaxis: {{ title: "Intermediate Value" }},
margin: {{ t: 10 }}, showlegend: true }},
{{ responsive: true }});
</script>
"#,
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
#[allow(clippy::cast_precision_loss)]
fn param_value_to_f64(pv: &ParamValue) -> f64 {
match *pv {
ParamValue::Float(v) => v,
ParamValue::Int(v) => v as f64,
ParamValue::Categorical(v) => v as f64,
}
}
fn escape_js(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
fn min_max(vals: &[f64]) -> (f64, f64) {
let mut mn = f64::INFINITY;
let mut mx = f64::NEG_INFINITY;
for &v in vals {
if v < mn {
mn = v;
}
if v > mx {
mx = v;
}
}
(mn, mx)
}
/// Build Plotly annotation objects to act as subplot titles.
#[allow(clippy::cast_precision_loss)]
fn build_subplot_annotations(titles: &[String], rows: usize, cols: usize) -> String {
let mut anns = Vec::new();
for (i, title) in titles.iter().enumerate() {
let row = i / cols;
let col = i % cols;
// Compute x/y anchor in paper coordinates.
let x = if cols == 1 {
0.5
} else {
(f64::from(u32::try_from(col).unwrap_or(0))) / (cols as f64 - 1.0)
};
let y = 1.0 - (f64::from(u32::try_from(row).unwrap_or(0))) / (rows as f64).max(1.0) + 0.02;
anns.push(format!(
r#"{{ text: {title}, x: {x:.3}, y: {y:.3}, xref: "paper", yref: "paper",
showarrow: false, font: {{ size: 12 }} }}"#,
));
}
anns.join(",")
}
+350
View File
@@ -0,0 +1,350 @@
use optimizer::prelude::*;
use optimizer::sampler::differential_evolution::{
DifferentialEvolutionSampler, DifferentialEvolutionStrategy,
};
#[test]
fn sphere_function() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(200, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 1.0,
"sphere best value should be < 1.0, got {}",
best.value
);
}
#[test]
fn rosenbrock_function() {
let sampler = DifferentialEvolutionSampler::builder()
.population_size(20)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(400, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
let val = (1.0 - xv).powi(2) + 100.0 * (yv - xv * xv).powi(2);
Ok::<_, Error>(val)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 50.0,
"rosenbrock best value should be < 50.0, got {}",
best.value
);
}
#[test]
fn rastrigin_function() {
let sampler = DifferentialEvolutionSampler::builder()
.population_size(30)
.mutation_factor(0.7)
.crossover_rate(0.9)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.12, 5.12).name("x");
let y = FloatParam::new(-5.12, 5.12).name("y");
study
.optimize(500, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
let val = 20.0
+ (xv * xv - 10.0 * (2.0 * std::f64::consts::PI * xv).cos())
+ (yv * yv - 10.0 * (2.0 * std::f64::consts::PI * yv).cos());
Ok::<_, Error>(val)
})
.unwrap();
let best = study.best_trial().unwrap();
// Rastrigin is multimodal; just check reasonable convergence
assert!(
best.value < 10.0,
"rastrigin best value should be < 10.0, got {}",
best.value
);
}
#[test]
fn bounds_respected() {
let sampler = DifferentialEvolutionSampler::with_seed(123);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-2.0, 3.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv + yv)
})
.unwrap();
for trial in study.trials() {
let xv: f64 = trial.get(&x).unwrap();
let yv: f64 = trial.get(&y).unwrap();
assert!((-2.0..=3.0).contains(&xv), "x = {xv} out of bounds [-2, 3]");
assert!((0.0..=10.0).contains(&yv), "y = {yv} out of bounds [0, 10]");
}
}
#[test]
fn strategy_best1() {
let sampler = DifferentialEvolutionSampler::builder()
.strategy(DifferentialEvolutionStrategy::Best1)
.population_size(15)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(200, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 5.0,
"Best1 strategy should converge, got {}",
best.value
);
}
#[test]
fn strategy_current_to_best1() {
let sampler = DifferentialEvolutionSampler::builder()
.strategy(DifferentialEvolutionStrategy::CurrentToBest1)
.population_size(15)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(200, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 5.0,
"CurrentToBest1 strategy should converge, got {}",
best.value
);
}
#[test]
fn mixed_params_float_and_categorical() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let cat = CategoricalParam::new(vec!["a", "b", "c"]).name("cat");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let cv = cat.suggest(trial)?;
let penalty = match cv {
"a" => 0.0,
"b" => 1.0,
_ => 2.0,
};
Ok::<_, Error>(xv * xv + penalty)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 10.0,
"best value should be < 10.0, got {}",
best.value
);
}
#[test]
fn seeded_reproducibility() {
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
let run = |seed: u64| {
let sampler = DifferentialEvolutionSampler::with_seed(seed);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
study.trials().iter().map(|t| t.value).collect::<Vec<_>>()
};
let results1 = run(42);
let results2 = run(42);
assert_eq!(results1, results2, "same seed should produce same results");
}
#[test]
fn different_seeds_different_results() {
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
let run = |seed: u64| {
let sampler = DifferentialEvolutionSampler::with_seed(seed);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
study
.optimize(20, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
study.trials().iter().map(|t| t.value).collect::<Vec<_>>()
};
let results1 = run(42);
let results2 = run(99);
assert_ne!(
results1, results2,
"different seeds should produce different results"
);
}
#[test]
fn single_dimension() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-10.0, 10.0).name("x");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, Error>((xv - 3.0).powi(2))
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 1.0,
"1-D optimization should converge, got {}",
best.value
);
}
#[test]
fn integer_params() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let n = IntParam::new(1, 20).name("n");
study
.optimize(100, |trial| {
let nv = n.suggest(trial)?;
// Minimum at n = 10
Ok::<_, Error>(((nv - 10) * (nv - 10)) as f64)
})
.unwrap();
let best = study.best_trial().unwrap();
let best_n: i64 = best.get(&n).unwrap();
assert!(
(1..=20).contains(&best_n),
"integer value {best_n} out of bounds"
);
assert!(
best.value < 10.0,
"integer optimization should converge, got {}",
best.value
);
}
#[test]
fn log_scale_params() {
let sampler = DifferentialEvolutionSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let lr = FloatParam::new(1e-5, 1.0).log_scale().name("lr");
study
.optimize(100, |trial| {
let lrv = lr.suggest(trial)?;
// Minimum at lr = 0.01
Ok::<_, Error>((lrv.ln() - 0.01_f64.ln()).powi(2))
})
.unwrap();
for trial in study.trials() {
let lrv: f64 = trial.get(&lr).unwrap();
assert!(
(1e-5..=1.0).contains(&lrv),
"log-scale value {lrv} out of bounds"
);
}
}
#[test]
fn custom_mutation_and_crossover() {
let sampler = DifferentialEvolutionSampler::builder()
.mutation_factor(0.5)
.crossover_rate(0.7)
.population_size(10)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 5.0,
"custom F/CR optimization should work, got {}",
best.value
);
}
+230
View File
@@ -0,0 +1,230 @@
use optimizer::parameter::{FloatParam, IntParam, Parameter};
use optimizer::sampler::random::RandomSampler;
use optimizer::{Direction, Study};
#[test]
fn csv_empty_study_produces_header_only() {
let study: Study<f64> = Study::new(Direction::Minimize);
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
assert_eq!(csv, "trial_id,value,state\n");
}
#[test]
fn csv_includes_all_trial_data() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = IntParam::new(1, 5).name("y");
study
.optimize(3, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(xv + yv as f64)
})
.unwrap();
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = csv.lines().collect();
// Header + 3 data rows.
assert_eq!(lines.len(), 4);
// Header should contain our parameter names.
let header = lines[0];
assert!(header.starts_with("trial_id,value,state"));
assert!(header.contains("x"));
assert!(header.contains("y"));
// Each data row should have the right number of columns.
let n_cols = header.split(',').count();
for line in &lines[1..] {
assert_eq!(line.split(',').count(), n_cols);
}
// All rows should have "Complete" state.
for line in &lines[1..] {
assert!(line.contains("Complete"));
}
}
#[test]
fn csv_handles_pruned_trials() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
// First trial: complete
let mut trial = study.create_trial();
let _ = x.suggest(&mut trial).unwrap();
study.complete_trial(trial, 1.0);
// Second trial: pruned
let mut trial = study.create_trial();
let _ = x.suggest(&mut trial).unwrap();
study.prune_trial(trial);
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3); // header + 2 data rows
// Pruned trial should have empty value.
let pruned_line = lines[2];
assert!(pruned_line.contains("Pruned"));
// The value field (second column) should be empty.
let cols: Vec<&str> = pruned_line.split(',').collect();
assert_eq!(cols[2], "Pruned");
assert_eq!(cols[1], ""); // empty value for pruned
}
#[test]
fn csv_handles_different_parameter_sets() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
// First trial: only x
let mut trial = study.create_trial();
let xv = x.suggest(&mut trial).unwrap();
study.complete_trial(trial, xv);
// Second trial: only y
let mut trial = study.create_trial();
let yv = y.suggest(&mut trial).unwrap();
study.complete_trial(trial, yv);
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
let lines: Vec<&str> = csv.lines().collect();
assert_eq!(lines.len(), 3);
// Both x and y columns should exist.
let header = lines[0];
assert!(header.contains("x"));
assert!(header.contains("y"));
// Each row has the right column count (missing params are empty).
let n_cols = header.split(',').count();
for line in &lines[1..] {
assert_eq!(line.split(',').count(), n_cols);
}
}
#[test]
fn csv_output_is_parseable() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let lr = FloatParam::new(0.001, 0.1).name("learning_rate");
let layers = IntParam::new(1, 5).name("n_layers");
study
.optimize(5, |trial| {
let l = lr.suggest(trial)?;
let n = layers.suggest(trial)?;
Ok::<_, optimizer::Error>(l * n as f64)
})
.unwrap();
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
// Parse each row: every value field should be a valid f64 for complete trials.
let lines: Vec<&str> = csv.lines().collect();
for line in &lines[1..] {
let cols: Vec<&str> = line.split(',').collect();
// trial_id should be a number
cols[0].parse::<u64>().unwrap();
// value should be parseable as f64
cols[1].parse::<f64>().unwrap();
// state should be a known value
assert!(["Complete", "Pruned", "Failed", "Running"].contains(&cols[2]));
}
}
#[test]
fn export_csv_writes_file() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(3, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let dir = std::env::temp_dir().join("optimizer_export_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test_export.csv");
study.export_csv(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.starts_with("trial_id,value,state"));
assert!(contents.lines().count() == 4); // header + 3 rows
// Clean up.
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(feature = "serde")]
#[test]
fn export_json_writes_file() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(3, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let dir = std::env::temp_dir().join("optimizer_json_export_test");
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test_export.json");
study.export_json(&path).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
let arr = parsed.as_array().unwrap();
assert_eq!(arr.len(), 3);
// Each entry should have the expected fields.
for entry in arr {
assert!(entry.get("id").is_some());
assert!(entry.get("value").is_some());
assert!(entry.get("state").is_some());
assert!(entry.get("params").is_some());
}
// Clean up.
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn csv_includes_user_attributes() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(2, |trial| {
let xv = x.suggest(trial)?;
trial.set_user_attr("training_time_secs", 45.2);
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let mut buf = Vec::new();
study.to_csv(&mut buf).unwrap();
let csv = String::from_utf8(buf).unwrap();
let header = csv.lines().next().unwrap();
assert!(header.contains("training_time_secs"));
}
+86
View File
@@ -0,0 +1,86 @@
//! Integration tests for fANOVA parameter importance.
use optimizer::prelude::*;
#[test]
fn fanova_dominant_parameter() {
// f(x, y) = x^2 — x should dominate
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
let _yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv)
})
.unwrap();
let result = study.fanova().unwrap();
assert_eq!(result.main_effects[0].0, "x");
assert!(
result.main_effects[0].1 > 0.7,
"x importance = {}",
result.main_effects[0].1
);
}
#[test]
fn fanova_interaction() {
// f(x, y) = x * y — both matter and interact
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(7));
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * yv)
})
.unwrap();
let config = FanovaConfig {
n_trees: 128,
..FanovaConfig::default()
};
let result = study.fanova_with_config(&config).unwrap();
// Should detect interaction
assert!(
!result.interactions.is_empty(),
"should detect x*y interaction"
);
}
#[test]
fn fanova_consistent_with_correlation() {
// f(x, y) = 3*x + 0.5*y — x should rank higher in both methods
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(99));
study
.optimize(80, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(3.0 * xv + 0.5 * yv)
})
.unwrap();
let corr = study.param_importance();
let fanova = study.fanova().unwrap();
// Both methods should rank x above y
assert_eq!(corr[0].0, "x", "correlation should rank x first");
assert_eq!(fanova.main_effects[0].0, "x", "fanova should rank x first");
}
#[test]
fn fanova_too_few_trials() {
let study: Study<f64> = Study::new(Direction::Minimize);
let result = study.fanova();
assert!(result.is_err(), "should error with no trials");
}
+256
View File
@@ -0,0 +1,256 @@
#![cfg(feature = "gp")]
use optimizer::prelude::*;
use optimizer::sampler::gp::GpSampler;
#[test]
fn sphere_function() {
let sampler = GpSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(80, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 2.0,
"sphere best value should be < 2.0, got {}",
best.value
);
}
#[test]
fn rosenbrock_function() {
let sampler = GpSampler::builder().n_startup_trials(15).seed(42).build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
let val = (1.0 - xv).powi(2) + 100.0 * (yv - xv * xv).powi(2);
Ok::<_, Error>(val)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 100.0,
"rosenbrock best value should be < 100.0, got {}",
best.value
);
}
#[test]
fn bounds_respected() {
let sampler = GpSampler::with_seed(123);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-2.0, 3.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv + yv)
})
.unwrap();
for trial in study.trials() {
let xv: f64 = trial.get(&x).unwrap();
let yv: f64 = trial.get(&y).unwrap();
assert!((-2.0..=3.0).contains(&xv), "x = {xv} out of bounds [-2, 3]");
assert!((0.0..=10.0).contains(&yv), "y = {yv} out of bounds [0, 10]");
}
}
#[test]
fn mixed_params_float_and_categorical() {
let sampler = GpSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let cat = CategoricalParam::new(vec!["a", "b", "c"]).name("cat");
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
let cv = cat.suggest(trial)?;
let penalty = match cv {
"a" => 0.0,
"b" => 1.0,
_ => 2.0,
};
Ok::<_, Error>(xv * xv + penalty)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 10.0,
"best value should be < 10.0, got {}",
best.value
);
}
#[test]
fn seeded_reproducibility() {
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
let run = |seed: u64| {
let sampler = GpSampler::with_seed(seed);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
study.trials().iter().map(|t| t.value).collect::<Vec<_>>()
};
let results1 = run(42);
let results2 = run(42);
assert_eq!(results1, results2, "same seed should produce same results");
}
#[test]
fn different_seeds_different_results() {
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
let run = |seed: u64| {
let sampler = GpSampler::with_seed(seed);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
study
.optimize(20, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
study.trials().iter().map(|t| t.value).collect::<Vec<_>>()
};
let results1 = run(42);
let results2 = run(99);
assert_ne!(
results1, results2,
"different seeds should produce different results"
);
}
#[test]
fn single_dimension() {
let sampler = GpSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-10.0, 10.0).name("x");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, Error>((xv - 3.0).powi(2))
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 1.0,
"1-D optimization should converge, got {}",
best.value
);
}
#[test]
fn integer_params() {
let sampler = GpSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let n = IntParam::new(1, 20).name("n");
study
.optimize(100, |trial| {
let nv = n.suggest(trial)?;
Ok::<_, Error>(((nv - 10) * (nv - 10)) as f64)
})
.unwrap();
let best = study.best_trial().unwrap();
let best_n: i64 = best.get(&n).unwrap();
assert!(
(1..=20).contains(&best_n),
"integer value {best_n} out of bounds"
);
assert!(
best.value < 10.0,
"integer optimization should converge, got {}",
best.value
);
}
#[test]
fn log_scale_params() {
let sampler = GpSampler::with_seed(42);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let lr = FloatParam::new(1e-5, 1.0).log_scale().name("lr");
study
.optimize(100, |trial| {
let lrv = lr.suggest(trial)?;
Ok::<_, Error>((lrv.ln() - 0.01_f64.ln()).powi(2))
})
.unwrap();
for trial in study.trials() {
let lrv: f64 = trial.get(&lr).unwrap();
assert!(
(1e-5..=1.0).contains(&lrv),
"log-scale value {lrv} out of bounds"
);
}
}
#[test]
fn builder_configuration() {
let sampler = GpSampler::builder()
.n_startup_trials(5)
.n_candidates(500)
.noise_variance(1e-4)
.seed(42)
.build();
let study: Study<f64> = Study::with_sampler(Direction::Minimize, sampler);
let x = FloatParam::new(-5.0, 5.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(100, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, Error>(xv * xv + yv * yv)
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 5.0,
"custom config optimization should work, got {}",
best.value
);
}
+8 -7
View File
@@ -1,9 +1,10 @@
use optimizer::parameter::{CategoricalParam, FloatParam, IntParam, Parameter};
use optimizer::sampler::random::RandomSampler;
use optimizer::{Direction, Study};
#[test]
fn known_perfect_correlation() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 100.0).name("x");
// Objective = x, so perfect correlation.
@@ -24,7 +25,7 @@ fn known_perfect_correlation() {
#[test]
fn no_effect_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 100.0).name("x");
let noise = FloatParam::new(0.0, 100.0).name("noise");
@@ -51,7 +52,7 @@ fn no_effect_parameter() {
#[test]
fn multiple_parameters_varying_importance() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
@@ -86,7 +87,7 @@ fn fewer_than_two_trials_returns_empty() {
#[test]
fn int_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let n = IntParam::new(1, 100).name("n");
for _ in 0..30 {
@@ -103,7 +104,7 @@ fn int_parameter() {
#[test]
fn categorical_parameter() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let cat = CategoricalParam::new(vec!["a", "b", "c"]).name("cat");
let x = FloatParam::new(0.0, 100.0).name("x");
@@ -123,7 +124,7 @@ fn categorical_parameter() {
#[test]
fn normalization_sums_to_one() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(0.0, 10.0).name("y");
let z = FloatParam::new(0.0, 10.0).name("z");
@@ -146,7 +147,7 @@ fn normalization_sums_to_one() {
#[test]
fn label_when_unnamed_uses_debug() {
let study: Study<f64> = Study::new(Direction::Minimize);
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
// No .name() call → label defaults to Debug representation.
let x = FloatParam::new(0.0, 10.0);
+98 -6
View File
@@ -21,7 +21,7 @@ fn test_tpe_optimizes_quadratic_function() {
// Optimal: x = 3, f(3) = 0
let sampler = TpeSampler::builder()
.seed(42)
.n_startup_trials(5) // Quick startup for test
.n_startup_trials(10)
.n_ei_candidates(24)
.build()
.unwrap();
@@ -31,7 +31,7 @@ fn test_tpe_optimizes_quadratic_function() {
let x_param = FloatParam::new(-10.0, 10.0);
study
.optimize(50, |trial| {
.optimize(100, |trial| {
let x = x_param.suggest(trial)?;
Ok::<_, Error>((x - 3.0).powi(2))
})
@@ -39,11 +39,11 @@ fn test_tpe_optimizes_quadratic_function() {
let best = study.best_trial().expect("should have at least one trial");
// TPE should find a value close to optimal (x ~ 3)
// We expect the best value to be small (close to 0)
// TPE should find a reasonable value over 100 trials
// With random startup + TPE, we expect to get within a few units of optimal
assert!(
best.value < 1.0,
"TPE should find near-optimal: best value {} should be < 1.0",
best.value < 5.0,
"TPE should find near-optimal: best value {} should be < 5.0",
best.value
);
}
@@ -2245,3 +2245,95 @@ fn test_top_trials_ranks_feasible_above_infeasible() {
// Feasible sorted by objective first (5.0, 50.0), then infeasible by violation (0.5, 3.0)
assert_eq!(ids, vec![2, 1, 0, 3]);
}
// =============================================================================
// Test: StudyBuilder
// =============================================================================
#[test]
fn test_builder_defaults() {
let study: Study<f64> = Study::builder().build();
assert_eq!(study.direction(), Direction::Minimize);
}
#[test]
fn test_builder_maximize() {
let study: Study<f64> = Study::builder().maximize().build();
assert_eq!(study.direction(), Direction::Maximize);
}
#[test]
fn test_builder_minimize() {
let study: Study<f64> = Study::builder().minimize().build();
assert_eq!(study.direction(), Direction::Minimize);
}
#[test]
fn test_builder_direction() {
let study: Study<f64> = Study::builder().direction(Direction::Maximize).build();
assert_eq!(study.direction(), Direction::Maximize);
}
#[test]
fn test_builder_with_sampler() {
let x = FloatParam::new(-5.0, 5.0);
let study: Study<f64> = Study::builder().sampler(TpeSampler::new()).build();
study
.optimize(10, |trial| {
let val = x.suggest(trial)?;
Ok::<_, Error>(val * val)
})
.unwrap();
assert_eq!(study.trials().len(), 10);
}
#[test]
fn test_builder_with_pruner() {
use optimizer::NopPruner;
let study: Study<f64> = Study::builder().pruner(NopPruner).build();
assert_eq!(study.direction(), Direction::Minimize);
}
#[test]
fn test_builder_chaining() {
let study: Study<f64> = Study::builder()
.maximize()
.sampler(RandomSampler::with_seed(42))
.pruner(optimizer::NopPruner)
.build();
assert_eq!(study.direction(), Direction::Maximize);
}
#[test]
fn test_builder_with_custom_value_type() {
let study: Study<i32> = Study::builder().maximize().build();
assert_eq!(study.direction(), Direction::Maximize);
}
#[test]
fn test_builder_optimizes_correctly() {
let x = FloatParam::new(-10.0, 10.0);
let study: Study<f64> = Study::builder()
.minimize()
.sampler(TpeSampler::builder().seed(42).build().unwrap())
.build();
study
.optimize(100, |trial| {
let val = x.suggest(trial)?;
Ok::<_, Error>((val - 3.0) * (val - 3.0))
})
.unwrap();
let best = study.best_trial().unwrap();
assert!(
best.value < 5.0,
"best value should be < 5.0, got {}",
best.value
);
}
+216
View File
@@ -0,0 +1,216 @@
//! Integration tests for the journal storage backend.
use std::collections::HashMap;
use std::sync::Arc;
use optimizer::parameter::{FloatParam, Parameter};
use optimizer::sampler::CompletedTrial;
use optimizer::sampler::random::RandomSampler;
use optimizer::storage::{JournalStorage, Storage};
use optimizer::{Direction, Study};
fn temp_path() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"optimizer_journal_test_{}_{}.jsonl",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
path
}
fn sample_trial(id: u64, value: f64) -> CompletedTrial<f64> {
CompletedTrial::new(id, HashMap::new(), HashMap::new(), HashMap::new(), value)
}
#[test]
fn roundtrip_single_trial() {
let path = temp_path();
let storage = JournalStorage::new(&path);
storage.push(sample_trial(0, 42.0));
let loaded = storage.trials_arc().read().clone();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, 0);
assert_eq!(loaded[0].value, 42.0);
// Also verify via a fresh open from disk
let storage2 = JournalStorage::<f64>::open(&path).unwrap();
let loaded2 = storage2.trials_arc().read().clone();
assert_eq!(loaded2.len(), 1);
assert_eq!(loaded2[0].value, 42.0);
std::fs::remove_file(&path).ok();
}
#[test]
fn append_multiple_trials() {
let path = temp_path();
let storage = JournalStorage::new(&path);
for i in 0..5 {
storage.push(sample_trial(i, i as f64));
}
// Reload from disk
let storage2 = JournalStorage::<f64>::open(&path).unwrap();
let loaded = storage2.trials_arc().read().clone();
assert_eq!(loaded.len(), 5);
for (i, trial) in loaded.iter().enumerate() {
assert_eq!(trial.id, i as u64);
assert_eq!(trial.value, i as f64);
}
std::fs::remove_file(&path).ok();
}
#[test]
fn missing_file_returns_empty() {
let path = temp_path();
let storage = JournalStorage::<f64>::open(&path).unwrap();
let loaded = storage.trials_arc().read().clone();
assert!(loaded.is_empty());
}
#[test]
fn concurrent_writes() {
let path = temp_path();
let storage = Arc::new(JournalStorage::new(&path));
let mut handles = Vec::new();
for thread_id in 0..4u64 {
let s = Arc::clone(&storage);
handles.push(std::thread::spawn(move || {
for i in 0..25u64 {
let id = thread_id * 25 + i;
s.push(sample_trial(id, id as f64));
}
}));
}
for h in handles {
h.join().unwrap();
}
// Reload from disk to verify persistence
let storage2 = JournalStorage::<f64>::open(&path).unwrap();
let loaded = storage2.trials_arc().read().clone();
assert_eq!(loaded.len(), 100);
// Verify all IDs are present (order may vary)
let mut ids: Vec<u64> = loaded.iter().map(|t| t.id).collect();
ids.sort();
assert_eq!(ids, (0..100).collect::<Vec<_>>());
std::fs::remove_file(&path).ok();
}
#[test]
fn study_with_journal_integration() {
let path = temp_path();
let x = FloatParam::new(-10.0, 10.0);
// First "process": run some trials
{
let study =
Study::with_journal(Direction::Minimize, RandomSampler::with_seed(1), &path).unwrap();
study
.optimize(5, |trial| {
let val = x.suggest(trial)?;
Ok::<_, optimizer::Error>(val * val)
})
.unwrap();
assert_eq!(study.n_trials(), 5);
}
// Second "process": loads the same file, sees existing trials
let study2 =
Study::with_journal(Direction::Minimize, RandomSampler::with_seed(2), &path).unwrap();
assert_eq!(study2.n_trials(), 5);
// Continue optimizing
study2
.optimize(5, |trial| {
let val = x.suggest(trial)?;
Ok::<_, optimizer::Error>(val * val)
})
.unwrap();
assert_eq!(study2.n_trials(), 10);
// Verify all 10 written to disk
let storage3 = JournalStorage::<f64>::open(&path).unwrap();
let loaded = storage3.trials_arc().read().clone();
assert_eq!(loaded.len(), 10);
std::fs::remove_file(&path).ok();
}
#[test]
fn ids_are_unique_after_reload() {
let path = temp_path();
// First batch
{
let study =
Study::with_journal(Direction::Minimize, RandomSampler::with_seed(1), &path).unwrap();
study
.optimize(3, |trial| {
let _ = FloatParam::new(0.0, 1.0).suggest(trial)?;
Ok::<_, optimizer::Error>(1.0)
})
.unwrap();
}
// Second batch — IDs should continue from 3
let study =
Study::with_journal(Direction::Minimize, RandomSampler::with_seed(2), &path).unwrap();
study
.optimize(3, |trial| {
let _ = FloatParam::new(0.0, 1.0).suggest(trial)?;
Ok::<_, optimizer::Error>(1.0)
})
.unwrap();
let all = study.trials();
let mut ids: Vec<u64> = all.iter().map(|t| t.id).collect();
ids.sort();
// All 6 IDs should be unique
ids.dedup();
assert_eq!(ids.len(), 6);
std::fs::remove_file(&path).ok();
}
#[test]
fn pruned_trials_are_stored() {
let path = temp_path();
let study =
Study::with_journal(Direction::Minimize, RandomSampler::with_seed(1), &path).unwrap();
// Complete one, prune one
let x = FloatParam::new(0.0, 1.0);
study
.optimize(3, |trial| {
let _ = x.suggest(trial)?;
if trial.id() == 1 {
Err(optimizer::TrialPruned)?;
}
Ok::<_, optimizer::Error>(1.0)
})
.unwrap();
let storage2 = JournalStorage::<f64>::open(&path).unwrap();
let loaded = storage2.trials_arc().read().clone();
assert_eq!(loaded.len(), 3);
assert!(
loaded
.iter()
.any(|t| t.state == optimizer::TrialState::Pruned)
);
std::fs::remove_file(&path).ok();
}
+738
View File
@@ -0,0 +1,738 @@
//! Integration tests for multi-objective optimization.
use optimizer::multi_objective::MultiObjectiveStudy;
use optimizer::parameter::{CategoricalParam, FloatParam, Parameter};
use optimizer::sampler::moead::MoeadSampler;
use optimizer::sampler::nsga2::Nsga2Sampler;
use optimizer::sampler::nsga3::Nsga3Sampler;
use optimizer::{Decomposition, Direction};
// ---------------------------------------------------------------------------
// Pareto utility tests (via public MultiObjectiveStudy)
// ---------------------------------------------------------------------------
#[test]
fn test_basic_two_objective_random() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty(), "Pareto front should be non-empty");
// Verify no solution in the front dominates another
for a in &front {
for b in &front {
if core::ptr::eq(a, b) {
continue;
}
let a_dom_b = a.values[0] <= b.values[0]
&& a.values[1] <= b.values[1]
&& (a.values[0] < b.values[0] || a.values[1] < b.values[1]);
assert!(
!a_dom_b,
"Front solution {:?} dominates {:?}",
a.values, b.values
);
}
}
}
#[test]
fn test_dimension_mismatch_error() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 1.0);
let result = study.optimize(1, |trial| {
let xv = x.suggest(trial)?;
// Return wrong number of values
Ok::<_, optimizer::Error>(vec![xv])
});
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
optimizer::Error::ObjectiveDimensionMismatch {
expected: 2,
got: 1
}
),
"Expected ObjectiveDimensionMismatch, got: {err}"
);
}
#[test]
fn test_ask_tell() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Maximize]);
let x = FloatParam::new(0.0, 10.0);
for _ in 0..10 {
let mut trial = study.ask();
let xv = x.suggest(&mut trial).unwrap();
study
.tell(trial, Ok::<_, &str>(vec![xv, 10.0 - xv]))
.unwrap();
}
assert_eq!(study.n_trials(), 10);
let front = study.pareto_front();
assert!(!front.is_empty());
}
#[test]
fn test_ask_tell_dimension_mismatch() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let trial = study.ask();
let result = study.tell(trial, Ok::<_, &str>(vec![1.0, 2.0, 3.0]));
assert!(result.is_err());
}
#[test]
fn test_n_trials_counting() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
assert_eq!(study.n_trials(), 0);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(5, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
assert_eq!(study.n_trials(), 5);
}
#[test]
fn test_three_objectives() {
let study = MultiObjectiveStudy::new(vec![
Direction::Minimize,
Direction::Minimize,
Direction::Maximize,
]);
let x = FloatParam::new(0.0, 1.0);
let y = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, yv, 1.0 - xv - yv])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty());
assert_eq!(study.n_objectives(), 3);
}
#[test]
fn test_directions_accessor() {
let dirs = vec![Direction::Minimize, Direction::Maximize];
let study = MultiObjectiveStudy::new(dirs.clone());
assert_eq!(study.directions(), &dirs);
assert_eq!(study.n_objectives(), 2);
}
#[test]
fn test_trials_accessor() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(3, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let trials = study.trials();
assert_eq!(trials.len(), 3);
for t in &trials {
assert_eq!(t.values.len(), 2);
}
}
// ---------------------------------------------------------------------------
// NSGA-II sampler tests
// ---------------------------------------------------------------------------
#[test]
fn test_nsga2_zdt1() {
// ZDT1 benchmark: minimize both objectives
let n_vars = 5;
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = Nsga2Sampler::builder().population_size(20).seed(42).build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
study
.optimize(200, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
let f1 = xs[0];
let g = 1.0 + 9.0 * xs[1..].iter().sum::<f64>() / (n_vars - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
Ok::<_, optimizer::Error>(vec![f1, f2])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty(), "Pareto front should be non-empty");
// Verify no dominated solutions in the front
for a in &front {
for b in &front {
if core::ptr::eq(a, b) {
continue;
}
let a_dom_b = a.values[0] <= b.values[0]
&& a.values[1] <= b.values[1]
&& (a.values[0] < b.values[0] || a.values[1] < b.values[1]);
assert!(
!a_dom_b,
"Front solution {:?} dominates {:?}",
a.values, b.values
);
}
}
}
#[test]
fn test_nsga2_with_seed_reproducible() {
let x = FloatParam::new(0.0, 1.0);
let y = FloatParam::new(0.0, 1.0);
let run = |seed: u64| -> Vec<Vec<f64>> {
let sampler = Nsga2Sampler::with_seed(seed);
let study = MultiObjectiveStudy::with_sampler(
vec![Direction::Minimize, Direction::Minimize],
sampler,
);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, yv])
})
.unwrap();
study.trials().iter().map(|t| t.values.clone()).collect()
};
let r1 = run(123);
let r2 = run(123);
assert_eq!(r1, r2, "Same seed should produce same results");
let r3 = run(456);
assert_ne!(r1, r3, "Different seeds should produce different results");
}
#[test]
fn test_nsga2_builder() {
let sampler = Nsga2Sampler::builder()
.population_size(10)
.crossover_prob(0.8)
.crossover_eta(15.0)
.mutation_eta(25.0)
.seed(42)
.build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
assert_eq!(study.n_trials(), 30);
}
#[test]
fn test_nsga2_categorical_params() {
let sampler = Nsga2Sampler::with_seed(42);
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
let cat = CategoricalParam::new(vec!["a", "b", "c"]);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
let cv = cat.suggest(trial)?;
let bonus = match cv {
"a" => 0.0,
"b" => 0.5,
_ => 1.0,
};
Ok::<_, optimizer::Error>(vec![xv + bonus, 1.0 - xv])
})
.unwrap();
assert_eq!(study.n_trials(), 30);
let front = study.pareto_front();
assert!(!front.is_empty());
}
#[test]
fn test_nsga2_constraints() {
let sampler = Nsga2Sampler::with_seed(42);
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
// Constraint: x >= 0.3 (i.e. 0.3 - x <= 0)
trial.set_constraints(vec![0.3 - xv]);
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty());
// Check that feasible solutions exist on the front
let feasible_count = front.iter().filter(|t| t.is_feasible()).count();
assert!(
feasible_count > 0,
"Should have feasible solutions on front"
);
}
#[test]
fn test_multi_objective_trial_get() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(5, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 10.0 - xv])
})
.unwrap();
let front = study.pareto_front();
for t in &front {
let xv: f64 = t.get(&x).unwrap();
assert!((0.0..=10.0).contains(&xv));
}
}
#[test]
fn test_multi_objective_trial_is_feasible() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(10, |trial| {
let xv = x.suggest(trial)?;
trial.set_constraints(vec![0.5 - xv]); // feasible if x >= 0.5
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let trials = study.trials();
for t in &trials {
let xv = t.values[0];
if xv >= 0.5 {
assert!(t.is_feasible());
} else {
assert!(!t.is_feasible());
}
}
}
#[test]
fn test_multi_objective_trial_user_attrs() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(3, |trial| {
let xv = x.suggest(trial)?;
trial.set_user_attr("iteration", 42_i64);
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let trials = study.trials();
for t in &trials {
assert!(t.user_attr("iteration").is_some());
}
}
#[test]
fn test_tell_with_failure() {
let study = MultiObjectiveStudy::new(vec![Direction::Minimize, Direction::Minimize]);
let trial = study.ask();
study
.tell(trial, Err::<Vec<f64>, _>("evaluation failed"))
.unwrap();
// Failed trial not counted
assert_eq!(study.n_trials(), 0);
}
// ---------------------------------------------------------------------------
// NSGA-III sampler tests
// ---------------------------------------------------------------------------
#[test]
fn test_nsga3_zdt1() {
let n_vars = 5;
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = Nsga3Sampler::builder().population_size(20).seed(42).build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
study
.optimize(200, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
let f1 = xs[0];
let g = 1.0 + 9.0 * xs[1..].iter().sum::<f64>() / (n_vars - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
Ok::<_, optimizer::Error>(vec![f1, f2])
})
.unwrap();
let front = study.pareto_front();
assert!(
!front.is_empty(),
"NSGA-III Pareto front should be non-empty"
);
// Verify no dominated solutions in the front
for a in &front {
for b in &front {
if core::ptr::eq(a, b) {
continue;
}
let a_dom_b = a.values[0] <= b.values[0]
&& a.values[1] <= b.values[1]
&& (a.values[0] < b.values[0] || a.values[1] < b.values[1]);
assert!(
!a_dom_b,
"Front solution {:?} dominates {:?}",
a.values, b.values
);
}
}
}
#[test]
fn test_nsga3_four_objectives() {
// DTLZ2 with 4 objectives
let n_obj = 4;
let n_vars = n_obj + 4; // k = 5 decision variables beyond the first (n_obj-1)
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = Nsga3Sampler::builder().population_size(50).seed(42).build();
let directions = vec![Direction::Minimize; n_obj];
let study = MultiObjectiveStudy::with_sampler(directions, sampler);
study
.optimize(500, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
// DTLZ2 formulation
let g: f64 = xs[n_obj - 1..]
.iter()
.map(|&xi| (xi - 0.5).powi(2))
.sum::<f64>();
let mut objectives = vec![0.0_f64; n_obj];
for i in 0..n_obj {
let mut f = 1.0 + g;
for xj in &xs[..(n_obj - 1 - i)] {
f *= (xj * core::f64::consts::FRAC_PI_2).cos();
}
if i > 0 {
f *= (xs[n_obj - 1 - i] * core::f64::consts::FRAC_PI_2).sin();
}
objectives[i] = f;
}
Ok::<_, optimizer::Error>(objectives)
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty(), "4-objective front should be non-empty");
// All front solutions should have 4 objectives
for t in &front {
assert_eq!(t.values.len(), 4);
}
}
#[test]
fn test_nsga3_reproducible() {
let x = FloatParam::new(0.0, 1.0);
let y = FloatParam::new(0.0, 1.0);
let run = |seed: u64| -> Vec<Vec<f64>> {
let sampler = Nsga3Sampler::with_seed(seed);
let study = MultiObjectiveStudy::with_sampler(
vec![Direction::Minimize, Direction::Minimize],
sampler,
);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, yv])
})
.unwrap();
study.trials().iter().map(|t| t.values.clone()).collect()
};
let r1 = run(123);
let r2 = run(123);
assert_eq!(r1, r2, "Same seed should produce same results");
let r3 = run(456);
assert_ne!(r1, r3, "Different seeds should produce different results");
}
#[test]
fn test_nsga3_builder() {
let sampler = Nsga3Sampler::builder()
.population_size(12)
.n_divisions(4)
.crossover_prob(0.9)
.crossover_eta(20.0)
.mutation_eta(20.0)
.seed(42)
.build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
assert_eq!(study.n_trials(), 30);
}
#[test]
fn test_nsga3_constraints() {
let sampler = Nsga3Sampler::with_seed(42);
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(50, |trial| {
let xv = x.suggest(trial)?;
trial.set_constraints(vec![0.3 - xv]);
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty());
let feasible_count = front.iter().filter(|t| t.is_feasible()).count();
assert!(
feasible_count > 0,
"Should have feasible solutions on front"
);
}
// ---------------------------------------------------------------------------
// MOEA/D sampler tests
// ---------------------------------------------------------------------------
#[test]
fn test_moead_zdt1_tchebycheff() {
let n_vars = 5;
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = MoeadSampler::builder().population_size(20).seed(42).build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
study
.optimize(200, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
let f1 = xs[0];
let g = 1.0 + 9.0 * xs[1..].iter().sum::<f64>() / (n_vars - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
Ok::<_, optimizer::Error>(vec![f1, f2])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty(), "MOEA/D Pareto front should be non-empty");
for a in &front {
for b in &front {
if core::ptr::eq(a, b) {
continue;
}
let a_dom_b = a.values[0] <= b.values[0]
&& a.values[1] <= b.values[1]
&& (a.values[0] < b.values[0] || a.values[1] < b.values[1]);
assert!(
!a_dom_b,
"Front solution {:?} dominates {:?}",
a.values, b.values
);
}
}
}
#[test]
fn test_moead_zdt1_weighted_sum() {
let n_vars = 3;
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = MoeadSampler::builder()
.population_size(20)
.decomposition(Decomposition::WeightedSum)
.seed(42)
.build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
study
.optimize(200, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
let f1 = xs[0];
let g = 1.0 + 9.0 * xs[1..].iter().sum::<f64>() / (n_vars - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
Ok::<_, optimizer::Error>(vec![f1, f2])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty());
}
#[test]
fn test_moead_zdt1_pbi() {
let n_vars = 3;
let params: Vec<FloatParam> = (0..n_vars).map(|_| FloatParam::new(0.0, 1.0)).collect();
let sampler = MoeadSampler::builder()
.population_size(20)
.decomposition(Decomposition::Pbi { theta: 5.0 })
.seed(42)
.build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
study
.optimize(200, |trial| {
let xs: Vec<f64> = params
.iter()
.map(|p| p.suggest(trial))
.collect::<Result<_, _>>()?;
let f1 = xs[0];
let g = 1.0 + 9.0 * xs[1..].iter().sum::<f64>() / (n_vars - 1) as f64;
let f2 = g * (1.0 - (f1 / g).sqrt());
Ok::<_, optimizer::Error>(vec![f1, f2])
})
.unwrap();
let front = study.pareto_front();
assert!(!front.is_empty());
}
#[test]
fn test_moead_reproducible() {
let x = FloatParam::new(0.0, 1.0);
let y = FloatParam::new(0.0, 1.0);
let run = |seed: u64| -> Vec<Vec<f64>> {
let sampler = MoeadSampler::with_seed(seed);
let study = MultiObjectiveStudy::with_sampler(
vec![Direction::Minimize, Direction::Minimize],
sampler,
);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, yv])
})
.unwrap();
study.trials().iter().map(|t| t.values.clone()).collect()
};
let r1 = run(123);
let r2 = run(123);
assert_eq!(r1, r2, "Same seed should produce same results");
let r3 = run(456);
assert_ne!(r1, r3, "Different seeds should produce different results");
}
#[test]
fn test_moead_builder() {
let sampler = MoeadSampler::builder()
.population_size(15)
.neighborhood_size(5)
.decomposition(Decomposition::Tchebycheff)
.crossover_prob(0.9)
.crossover_eta(20.0)
.mutation_eta(20.0)
.seed(42)
.build();
let study =
MultiObjectiveStudy::with_sampler(vec![Direction::Minimize, Direction::Minimize], sampler);
let x = FloatParam::new(0.0, 1.0);
study
.optimize(30, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(vec![xv, 1.0 - xv])
})
.unwrap();
assert_eq!(study.n_trials(), 30);
}
+180
View File
@@ -0,0 +1,180 @@
use optimizer::parameter::{FloatParam, IntParam, Parameter};
use optimizer::sampler::random::RandomSampler;
use optimizer::{Direction, Study, generate_html_report};
#[test]
fn html_report_creates_file() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = IntParam::new(1, 5).name("y");
study
.optimize(10, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(xv + yv as f64)
})
.unwrap();
let path = std::env::temp_dir().join("test_report_creates_file.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<!DOCTYPE html>"));
assert!(content.contains("plotly"));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_contains_all_chart_sections() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
let y = FloatParam::new(-5.0, 5.0).name("y");
study
.optimize(20, |trial| {
let xv = x.suggest(trial)?;
let yv = y.suggest(trial)?;
Ok::<_, optimizer::Error>(xv * xv + yv * yv)
})
.unwrap();
let path = std::env::temp_dir().join("test_report_all_charts.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
// Should contain all chart divs.
assert!(content.contains("id=\"history\""));
assert!(content.contains("id=\"slices\""));
assert!(content.contains("id=\"parcoords\""));
assert!(content.contains("id=\"importance\""));
assert!(content.contains("id=\"timeline\""));
// Should contain chart titles.
assert!(content.contains("Optimization History"));
assert!(content.contains("Slice Plots"));
assert!(content.contains("Parallel Coordinates"));
assert!(content.contains("Parameter Importance"));
assert!(content.contains("Trial Timeline"));
// Should show direction and trial count.
assert!(content.contains("Minimize"));
assert!(content.contains("20 trials"));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_empty_study() {
let study: Study<f64> = Study::new(Direction::Minimize);
let path = std::env::temp_dir().join("test_report_empty.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<!DOCTYPE html>"));
assert!(content.contains("0 trials"));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_single_param_no_parcoords() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(5, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let path = std::env::temp_dir().join("test_report_single_param.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
// Should have slice plot but not parallel coordinates (needs >= 2 params).
assert!(content.contains("id=\"slices\""));
assert!(!content.contains("id=\"parcoords\""));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_maximize_direction() {
let study: Study<f64> = Study::with_sampler(Direction::Maximize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(5, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(xv)
})
.unwrap();
let path = std::env::temp_dir().join("test_report_maximize.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("Maximize"));
std::fs::remove_file(&path).ok();
}
#[test]
fn export_html_convenience_method() {
let study: Study<f64> = Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(5, |trial| {
let xv = x.suggest(trial)?;
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let path = std::env::temp_dir().join("test_export_html.html");
study.export_html(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<!DOCTYPE html>"));
assert!(content.contains("id=\"history\""));
std::fs::remove_file(&path).ok();
}
#[test]
fn html_report_with_intermediate_values() {
use optimizer::pruner::MedianPruner;
let mut study: Study<f64> =
Study::with_sampler(Direction::Minimize, RandomSampler::with_seed(42));
study.set_pruner(MedianPruner::new(Direction::Minimize));
let x = FloatParam::new(0.0, 10.0).name("x");
study
.optimize(10, |trial| {
let xv = x.suggest(trial)?;
for step in 0..5 {
let val = xv * xv + step as f64;
trial.report(step, val);
if trial.should_prune() {
return Err(optimizer::TrialPruned.into());
}
}
Ok::<_, optimizer::Error>(xv * xv)
})
.unwrap();
let path = std::env::temp_dir().join("test_report_intermediate.html");
generate_html_report(&study, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("id=\"intermediate\""));
assert!(content.contains("Intermediate Values"));
std::fs::remove_file(&path).ok();
}