3 Commits

Author SHA1 Message Date
Manuel Raimann da2d367fb7 chore: release v1.0.1 2026-05-01 12:06:45 +02:00
Manuel Raimann ab74468603 fix(deps): set tracing minimum to 0.1.26 for Span::entered() 2026-05-01 12:06:45 +02:00
Manuel Raimann 4225d61027 fix(sampler): deterministic parameter matching in TPE
When two parameters share the same Distribution (e.g. two
FloatParam::new(-5.0, 5.0) in one study), TpeSampler iterated
trial.distributions (a HashMap) with find_map to extract a value
matching the target distribution. HashMap iteration order varies
across runs, so x's history could be conflated with y's history
non-deterministically — making seeded TPE runs flaky in parallel
test execution.

Pick the smallest-ParamId match instead, so the choice is stable
across runs.

Also:
- src/sampler/genetic.rs: collapse if into match guard (clippy)
- src/lib.rs: drop std_instead_of_core lint (the only remaining
  hits are std::io::Error/ErrorKind, which have no stable core
  equivalent yet)
- Cargo.toml: relax minimum versions for fastrand/tokio/tracing/
  optimizer-derive; bump dev-dep tokio to 1.50 to match in-tree usage
2026-04-30 09:27:06 +02:00
4 changed files with 44 additions and 77 deletions
+6 -6
View File
@@ -3,7 +3,7 @@ members = ["optimizer-derive"]
[package]
name = "optimizer"
version = "1.0.0"
version = "1.0.1"
edition = "2024"
rust-version = "1.89"
license = "MIT"
@@ -16,14 +16,14 @@ categories = ["algorithms", "science", "mathematics"]
readme = "README.md"
[dependencies]
fastrand = "2.3"
fastrand = "2"
thiserror = "2"
parking_lot = "0.12"
tokio = { version = "1.30", features = ["sync", "rt-multi-thread"], optional = true }
optimizer-derive = { version = "0.1.0", path = "optimizer-derive", optional = true }
tokio = { version = "1", features = ["sync", "rt-multi-thread"], optional = true }
optimizer-derive = { version = "0.1", path = "optimizer-derive", optional = true }
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
tracing = { version = "0.1.29", optional = true }
tracing = { version = "0.1.26", optional = true }
sobol_burley = { version = "0.5", optional = true }
nalgebra = { version = "0.34", optional = true }
fs2 = { version = "0.4", optional = true }
@@ -40,7 +40,7 @@ cma-es = ["dep:nalgebra"]
gp = ["dep:nalgebra"]
[dev-dependencies]
tokio = { version = "1.30", features = ["rt-multi-thread", "macros", "time"] }
tokio = { version = "1.50", features = ["rt-multi-thread", "macros", "time"] }
optimizer-derive = { version = "0.1.0", path = "optimizer-derive" }
serde_json = "1"
criterion = { version = "0.8", features = ["html_reports"] }
-1
View File
@@ -7,7 +7,6 @@
#![deny(clippy::complexity)]
#![deny(clippy::perf)]
#![deny(clippy::pedantic)]
#![deny(clippy::std_instead_of_core)]
//! Bayesian and population-based optimization library with an Optuna-like API
//! for hyperparameter tuning and black-box optimization. It ships 12 samplers
+4 -4
View File
@@ -295,10 +295,10 @@ pub(crate) fn crossover(
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]);
}
(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]);
}
_ => {}
}
+34 -66
View File
@@ -642,6 +642,22 @@ impl Default for TpeSamplerBuilder {
}
}
/// Deterministically pick a parameter value matching `target_dist` from a
/// trial. When multiple parameters share the same distribution (e.g. two
/// `FloatParam::new(-5.0, 5.0)` in the same study), the one with the
/// smallest [`ParamId`] is chosen so behavior does not depend on
/// `HashMap` iteration order.
fn find_matching_value<'t>(
t: &'t CompletedTrial,
target_dist: &Distribution,
) -> Option<&'t ParamValue> {
t.distributions
.iter()
.filter(|(_, dist)| *dist == target_dist)
.min_by_key(|(id, _)| *id)
.and_then(|(id, _)| t.params.get(id))
}
impl TpeSampler {
fn sample_float(
&self,
@@ -653,33 +669,17 @@ impl TpeSampler {
let target_dist = Distribution::Float(d.clone());
let good_values: Vec<f64> = good_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.collect();
let bad_values: Vec<f64> = bad_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Float(f) => Some(*f),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Float(f) => Some(*f),
_ => None,
})
.collect();
@@ -708,33 +708,17 @@ impl TpeSampler {
let target_dist = Distribution::Int(d.clone());
let good_values: Vec<i64> = good_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.collect();
let bad_values: Vec<i64> = bad_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Int(i) => Some(*i),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Int(i) => Some(*i),
_ => None,
})
.collect();
@@ -764,33 +748,17 @@ impl TpeSampler {
let target_dist = Distribution::Categorical(d.clone());
let good_indices: Vec<usize> = good_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.collect();
let bad_indices: Vec<usize> = bad_trials
.iter()
.filter_map(|t| {
t.distributions.iter().find_map(|(id, dist)| {
if *dist == target_dist {
t.params.get(id).and_then(|v| match v {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
} else {
None
}
})
.filter_map(|t| match find_matching_value(t, &target_dist)? {
ParamValue::Categorical(i) => Some(*i),
_ => None,
})
.collect();