Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3581187cd3 | |||
| 349ffcbed3 | |||
| 88e2ac0ff0 | |||
| d122582bf7 | |||
| 705687a42e | |||
| 03deedd775 | |||
| 047a788afd | |||
| d2e36ec304 | |||
| d14f4e0792 | |||
| ed80c59795 | |||
| ed1e9e31da | |||
| 36c6262dbc | |||
| 0c295b4bc7 | |||
| 0a6f2345a8 | |||
| 24a0bdd473 | |||
| 8239cc58a1 | |||
| 906e5296de | |||
| 947701e83f | |||
| b3bf4ccb71 | |||
| 7bf64d6cf6 | |||
| 624283c766 | |||
| 64c7aa2448 | |||
| abb1b4c229 | |||
| dff58340a4 | |||
| e359392a00 | |||
| 0e54356345 | |||
| d851aad548 | |||
| 95402dc9b6 | |||
| f873722763 | |||
| ba31df69c7 | |||
| bcc4549e66 | |||
| 7d79111d81 | |||
| a9ea097377 | |||
| 7d70220da0 | |||
| b5c0d6353b | |||
| 08fde0c707 |
+112
-17
@@ -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
|
||||
|
||||
+16
-5
@@ -3,9 +3,9 @@ members = ["optimizer-derive"]
|
||||
|
||||
[package]
|
||||
name = "optimizer"
|
||||
version = "0.7.0"
|
||||
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,25 +16,28 @@ 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 }
|
||||
optimizer-derive = { version = "0.1.0", path = "optimizer-derive", optional = true }
|
||||
serde = { version = "1", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1", optional = true }
|
||||
tracing = { version = "0.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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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(|(¢er_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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¶m.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
@@ -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 ¤t_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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(¶m_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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ¤t_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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 ¶m_ids {
|
||||
write!(writer, ",{}", csv_escape(¶m_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 ¶m_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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} · {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, ¶m_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, ¶m_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(",")
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user