Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57a43c260e
|
||
|
|
cbfedd85fa
|
||
|
|
d1288aa623
|
||
|
|
af226e3d3b
|
||
|
|
cfd5207fb6
|
||
|
|
ae1daf687d
|
||
|
|
c1bc3b0528
|
||
|
|
d564f862d7
|
||
|
|
5b5fe50df3
|
||
|
|
6368ca5f3d
|
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -11,6 +13,8 @@ permissions:
|
|||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
|
# Only one Pages deploy at a time. Don't cancel a running deploy
|
||||||
|
# (otherwise we can leave the Pages site partially updated).
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -38,6 +42,9 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to GitHub Pages
|
name: Deploy to GitHub Pages
|
||||||
|
# Only deploy on pushes to main / tag pushes / manual runs.
|
||||||
|
# PR builds get the build-and-upload step but no deploy.
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
+72
-62
@@ -7,78 +7,88 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.6.0] — 2026-05-05
|
## [0.8.0] — 2026-05-06
|
||||||
|
|
||||||
Theme: production lifecycle. heuropt becomes deployable for long-
|
Theme: async evaluation, plus the docs / governance / CI catch-up
|
||||||
running, real-world optimization workloads — callbacks, stop
|
that came with finalizing the release.
|
||||||
conditions, tracing, and two new performance indicators.
|
|
||||||
|
|
||||||
No breaking changes to the public API. Existing `Optimizer<P>` impls
|
heuropt now supports problems where each evaluation is a
|
||||||
keep compiling — `run_with` is added as a default-impl method that
|
`.await`-able operation — HTTP services, RPC clients, spawned
|
||||||
falls back to `run` plus a single final notification.
|
subprocesses. This is the differentiating capability vs.
|
||||||
|
pymoo / hyperopt / optuna / DEAP / MOEA Framework, none of which
|
||||||
|
ship first-class async support at the *evaluation* level.
|
||||||
|
|
||||||
|
No public-API breaks for synchronous users. The new surface is
|
||||||
|
gated behind a new `async` feature flag.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
#### Observer + stop-conditions API
|
#### Async evaluation (the headline feature)
|
||||||
|
|
||||||
A new module `heuropt::observer` introduces:
|
- New optional feature `async`, gated on
|
||||||
|
[`futures`](https://crates.io/crates/futures).
|
||||||
|
- `core::async_problem::AsyncProblem` trait — mirrors `Problem` but
|
||||||
|
with `async fn evaluate_async(&self, decision)`. Adapt an
|
||||||
|
existing sync `Problem` with a one-line wrapper.
|
||||||
|
- `core::async_problem::AsyncPartialProblem` trait — mirrors
|
||||||
|
`PartialProblem` for multi-fidelity (Hyperband) workloads with
|
||||||
|
`async fn evaluate_at_budget_async(decision, budget)`.
|
||||||
|
- Per-algorithm `run_async(&problem, concurrency).await` methods on
|
||||||
|
**every** algorithm in the catalog — all 33 of them — driving
|
||||||
|
evaluations through whichever async runtime the caller is using
|
||||||
|
(typically tokio). `concurrency` bounds in-flight evaluations.
|
||||||
|
Population-based algorithms (NSGA-II, NSGA-III, SPEA2, MOEA/D,
|
||||||
|
CMA-ES, DE, GA, PSO, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II,
|
||||||
|
AGE-MOEA, KnEA, GrEA, RVEA, MOPSO, TLBO, IPOP-CMA-ES, sNES, UMDA,
|
||||||
|
Ant Colony, GA, Random Search) fan out per generation. Steady-state
|
||||||
|
algorithms (Hill Climber, SA, (1+1)-ES, PAES, Nelder-Mead, Tabu
|
||||||
|
Search) await each step sequentially. Surrogate algorithms (BO,
|
||||||
|
TPE) batch the initial design and then await per-iteration
|
||||||
|
acquisitions. Hyperband fans out each Successive-Halving rung
|
||||||
|
through `AsyncPartialProblem`.
|
||||||
|
- Internal `algorithms::parallel_eval_async::evaluate_batch_async`
|
||||||
|
and `evaluate_batch_at_budget_async` helpers — use
|
||||||
|
`futures::stream::FuturesOrdered` with concurrency-bounded chunks,
|
||||||
|
preserve input order so seeded determinism is preserved when
|
||||||
|
evaluations are themselves deterministic.
|
||||||
|
- `examples/async_eval.rs` — worked example with a simulated 20 ms
|
||||||
|
remote service. At concurrency = 1 it's serial; at concurrency = 4
|
||||||
|
it's 2× faster; demonstrates `DifferentialEvolution` under tokio.
|
||||||
|
|
||||||
- `Snapshot<'a, D>` — per-generation observation payload with
|
#### Documentation
|
||||||
`iteration`, `evaluations`, `elapsed`, `population`,
|
|
||||||
`pareto_front`, `best`, and `objectives`.
|
|
||||||
- `Observer<D>` trait — single method `observe(&Snapshot) ->
|
|
||||||
ControlFlow<()>`. Closures of the right shape implement it
|
|
||||||
automatically. `()` is the no-op observer.
|
|
||||||
- `Optimizer::run_with(problem, observer)` — new method on the
|
|
||||||
`Optimizer` trait with a default impl that falls back to `run`.
|
|
||||||
Algorithms that override `run_with` (so far: `Nsga2`,
|
|
||||||
`RandomSearch`, `DifferentialEvolution`) call the observer once
|
|
||||||
per generation; others call it once at the end. Returning
|
|
||||||
`ControlFlow::Break` halts the optimizer and returns the partial
|
|
||||||
result.
|
|
||||||
|
|
||||||
#### Built-in observers (`observer::builtin`)
|
- New cookbook recipe **[Async evaluation](docs/book/src/cookbook/async.md)**
|
||||||
|
— implementing `AsyncProblem`, picking concurrency, determinism
|
||||||
|
guarantees, async vs. `parallel`.
|
||||||
|
- Comparison-with-other-libraries chapter updated: `heuropt 0.8`
|
||||||
|
row, `Async ✅ AsyncProblem + run_async` column, "When to pick
|
||||||
|
heuropt" gains an explicit IO-bound bullet.
|
||||||
|
- Stability chapter rewritten: removes the speculative "Observer /
|
||||||
|
Checkpoint planned" bullet (those didn't ship), documents the new
|
||||||
|
`async` feature flag.
|
||||||
|
- Migration guide: new "To 0.8" section covering both
|
||||||
|
`0.5.x → 0.8` (feature-additive — opt in by enabling the `async`
|
||||||
|
feature) and `0.7 → 0.8` (the partial async surface from 0.7 is
|
||||||
|
superseded by complete coverage; existing `run_async` callers
|
||||||
|
keep working).
|
||||||
|
- Runnable `cargo test --doc` examples added to every public
|
||||||
|
operator (10), metric (3), and Pareto utility (7) — every
|
||||||
|
public item across the crate now ships with at least one
|
||||||
|
example. 55 doctests in total (was 45).
|
||||||
|
|
||||||
- `MaxTime(Duration)` — wall-clock cap.
|
#### CI / build
|
||||||
- `MaxIterations(usize)` — generation cap.
|
|
||||||
- `TargetFitness(f64)` — direction-aware single-objective target.
|
|
||||||
- `Stagnation { window, tolerance }` — halt when the best fitness
|
|
||||||
hasn't improved by `tolerance` over `window` generations.
|
|
||||||
- `Periodic::new(every, |snap| { … })` — call a user closure every
|
|
||||||
`every` generations.
|
|
||||||
- `AnyOf` / `AllOf` plus `Observer::or` / `Observer::and` for
|
|
||||||
composition.
|
|
||||||
- `TracingObserver` (behind the new `tracing` feature) — emits
|
|
||||||
structured `debug!` events per generation.
|
|
||||||
|
|
||||||
#### Tracing feature
|
- `.github/workflows/docs.yml` builds the mdbook user guide on
|
||||||
|
every push and deploys to GitHub Pages on `main` /
|
||||||
|
tag pushes.
|
||||||
|
- `mdbook` book now uses `[rust] edition = "2021"` to satisfy
|
||||||
|
`mdbook 0.4.40`.
|
||||||
|
- `clamp_to_bounds` cargo-fuzz target tolerance loosened to
|
||||||
|
`1e-4 · max(simplex_total, max_abs_x, 1)` so the fuzzer doesn't
|
||||||
|
flag ULP-level slop in the simplex projection's
|
||||||
|
`max(x_i − τ, 0)` clamp boundary.
|
||||||
|
|
||||||
New optional feature `tracing`, gated on the
|
[0.8.0]: https://github.com/swaits/heuropt/releases/tag/v0.8.0
|
||||||
[`tracing`](https://crates.io/crates/tracing) crate. Adds
|
|
||||||
`TracingObserver` to the prelude when enabled.
|
|
||||||
|
|
||||||
#### Performance indicators
|
|
||||||
|
|
||||||
- `metrics::igd::igd` — Inverted Generational Distance against a
|
|
||||||
reference set (typically the true Pareto front).
|
|
||||||
- `metrics::igd::igd_plus` — Pareto-compliant IGD+ variant; adding
|
|
||||||
a dominated point never improves the score.
|
|
||||||
- `metrics::r2::r2` — R2 indicator using the weighted Tchebycheff
|
|
||||||
utility. Pair with `pareto::das_dennis` for the canonical weight
|
|
||||||
set.
|
|
||||||
|
|
||||||
#### Constrained example
|
|
||||||
|
|
||||||
`examples/constrained.rs` — solves the BNH constrained 2-objective
|
|
||||||
problem (Binh & Korn 1996) with NSGA-II + the new observer API,
|
|
||||||
demonstrating `Periodic` progress logging and `MaxTime` /
|
|
||||||
composition.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `Population::as_slice()` — new convenience accessor.
|
|
||||||
|
|
||||||
[0.6.0]: https://github.com/swaits/heuropt/releases/tag/v0.6.0
|
|
||||||
|
|
||||||
## [0.5.0] — 2026-05-05
|
## [0.5.0] — 2026-05-05
|
||||||
|
|
||||||
@@ -542,5 +552,5 @@ Initial release.
|
|||||||
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
|
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
|
||||||
bit-identical to serial mode.
|
bit-identical to serial mode.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.6.0...HEAD
|
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.8.0...HEAD
|
||||||
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "heuropt"
|
name = "heuropt"
|
||||||
version = "0.6.0"
|
version = "0.8.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
@@ -17,23 +17,28 @@ categories = ["algorithms", "science", "mathematics", "simulation"]
|
|||||||
default = []
|
default = []
|
||||||
serde = ["dep:serde"]
|
serde = ["dep:serde"]
|
||||||
parallel = ["dep:rayon"]
|
parallel = ["dep:rayon"]
|
||||||
tracing = ["dep:tracing"]
|
async = ["dep:futures"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
futures = { version = "0.3", optional = true, default-features = false, features = ["std", "async-await"] }
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
rand_distr = "0.5"
|
rand_distr = "0.5"
|
||||||
rayon = { version = "1", optional = true }
|
rayon = { version = "1", optional = true }
|
||||||
serde = { version = "1", features = ["derive"], optional = true }
|
serde = { version = "1", features = ["derive"], optional = true }
|
||||||
tracing = { version = "0.1", optional = true, default-features = false, features = ["std", "attributes"] }
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gungraun = "0.18"
|
gungraun = "0.18"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "hot_paths"
|
name = "hot_paths"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "async_eval"
|
||||||
|
required-features = ["async"]
|
||||||
|
|
||||||
# Tighten release codegen for the compare harness and downstream binaries
|
# Tighten release codegen for the compare harness and downstream binaries
|
||||||
# that build heuropt directly (i.e. when this crate is the workspace root).
|
# that build heuropt directly (i.e. when this crate is the workspace root).
|
||||||
# When heuropt is used as a dependency the consumer's profile wins.
|
# When heuropt is used as a dependency the consumer's profile wins.
|
||||||
|
|||||||
@@ -7,85 +7,184 @@
|
|||||||
[](https://github.com/swaits/heuropt/actions/workflows/ci.yml)
|
[](https://github.com/swaits/heuropt/actions/workflows/ci.yml)
|
||||||
|
|
||||||
**A practical Rust toolkit for heuristic optimization.** Single-objective.
|
**A practical Rust toolkit for heuristic optimization.** Single-objective.
|
||||||
Multi-objective. Many-objective. 35 algorithms. One small set of traits.
|
Multi-objective. Many-objective. 33 algorithms — every one of them with a
|
||||||
Bit-identical seeded determinism. No trait objects, no GATs, no generic-RNG
|
sync `run` and an async `run_async`. One small set of traits. Bit-identical
|
||||||
plumbing in the public API.
|
seeded determinism. No trait objects, no GATs, no generic-RNG plumbing in
|
||||||
|
the public API.
|
||||||
|
|
||||||
If you can write a `Problem` impl and read `RandomSearch`, you can write your
|
If you can write a `Problem` impl and read `RandomSearch`, you can write your
|
||||||
own optimizer. That's the whole pitch.
|
own optimizer. That's the whole pitch.
|
||||||
|
|
||||||
- 📖 **Read the [user guide](https://swaits.github.io/heuropt/)** for tutorials,
|
Docs: [user guide](https://swaits.github.io/heuropt/) · [API reference](https://docs.rs/heuropt).
|
||||||
cookbook recipes, comparison with pymoo / hyperopt / MOEA Framework, and
|
|
||||||
stability policy.
|
|
||||||
- 🔧 **[API reference on docs.rs](https://docs.rs/heuropt)** has runnable
|
|
||||||
` ```rust ` examples on every algorithm.
|
|
||||||
- 🧪 Tested with **316+ unit / integration / property tests** plus 8
|
|
||||||
cargo-fuzz targets running on every PR.
|
|
||||||
- ⚡ Hot paths heavily optimized — comparison harness 3.27× faster as of
|
|
||||||
v0.4.0, all bit-identical to the reference output.
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = "0.5"
|
heuropt = "0.8"
|
||||||
|
|
||||||
# Optional features:
|
# Optional features:
|
||||||
# - "serde": derive Serialize/Deserialize on the core data types.
|
# - "serde": derive Serialize/Deserialize on the core data types.
|
||||||
# - "parallel": evaluate populations across rayon's thread pool.
|
# - "parallel": evaluate populations across rayon's thread pool.
|
||||||
# Seeded runs stay bit-identical to serial mode.
|
# Seeded runs stay bit-identical to serial mode.
|
||||||
# heuropt = { version = "0.5", features = ["serde", "parallel"] }
|
# - "async": AsyncProblem / AsyncPartialProblem traits and a
|
||||||
|
# run_async(&problem, concurrency).await method on
|
||||||
|
# every algorithm — for IO-bound evaluations.
|
||||||
|
# heuropt = { version = "0.8", features = ["serde", "parallel", "async"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Define a problem
|
## Define a problem and run an optimizer
|
||||||
|
|
||||||
|
You're designing a car. Three things you can pick: **engine
|
||||||
|
displacement** (1.0–6.0 L), **curb weight** (1100–2200 kg, where
|
||||||
|
going lighter requires aluminum/carbon and costs money), and
|
||||||
|
**aerodynamic drag** (Cd from 0.20 to 0.40, where slipperier needs
|
||||||
|
expensive aero R&D). Four things you want to optimize: **price**,
|
||||||
|
**0-60 acceleration**, **fuel consumption**, **idle noise** — all
|
||||||
|
in tension.
|
||||||
|
|
||||||
|
The relationships between decisions and objectives are nonlinear
|
||||||
|
and coupled: engine cost grows superlinearly with displacement,
|
||||||
|
weight reduction below 1500 kg costs a quadratic premium, drag
|
||||||
|
reduction below 0.35 Cd costs a 1.5-power premium, and 0-60 depends
|
||||||
|
on weight × engine in a non-trivial way. You can't just sweep one
|
||||||
|
slider — the Pareto front is a genuine surface in 3D decision space,
|
||||||
|
and finding it by hand is hopeless.
|
||||||
|
|
||||||
|
NSGA-III is the canonical many-objective (4+) optimizer; it uses
|
||||||
|
Das–Dennis reference points to keep the front well-spread.
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
struct SchafferN1;
|
struct PickACar;
|
||||||
|
|
||||||
impl Problem for SchafferN1 {
|
impl Problem for PickACar {
|
||||||
type Decision = Vec<f64>;
|
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
ObjectiveSpace::new(vec![
|
ObjectiveSpace::new(vec![
|
||||||
Objective::minimize("f1"),
|
Objective::minimize("price_thousand_dollars"),
|
||||||
Objective::minimize("f2"),
|
Objective::minimize("seconds_to_60mph"),
|
||||||
|
Objective::minimize("fuel_gallons_per_100mi"),
|
||||||
|
Objective::minimize("noise_db_at_idle"),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
let v = x[0];
|
let displacement = x[0]; // liters
|
||||||
Evaluation::new(vec![v * v, (v - 2.0).powi(2)])
|
let weight = x[1]; // kg
|
||||||
|
let drag = x[2]; // dimensionless Cd
|
||||||
|
|
||||||
|
// Price ($k): engine cost grows superlinearly; weight reduction
|
||||||
|
// below 1500 kg and drag reduction below 0.35 Cd both cost extra.
|
||||||
|
let engine_cost = 3.0 * displacement.powf(1.6);
|
||||||
|
let weight_cost = ((1500.0 - weight).max(0.0) / 100.0).powi(2) * 2.0;
|
||||||
|
let aero_cost = ((0.35 - drag).max(0.0) * 100.0).powf(1.5) * 0.4;
|
||||||
|
let price = 10.0 + engine_cost + weight_cost + aero_cost;
|
||||||
|
|
||||||
|
// 0-60 (s): heavier = slower; bigger engine = quicker but with
|
||||||
|
// diminishing returns.
|
||||||
|
let weight_factor = (weight - 1100.0) / 1000.0;
|
||||||
|
let engine_factor = ((displacement - 1.0) / 5.0).max(0.0).powf(0.7);
|
||||||
|
let zero_to_sixty = 5.0 + 5.0 * weight_factor - 4.0 * engine_factor;
|
||||||
|
|
||||||
|
// Fuel consumption (gal/100 mi): all three matter.
|
||||||
|
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
|
||||||
|
|
||||||
|
// Idle noise (dB): engine dominates, mildly nonlinear.
|
||||||
|
let noise = 60.0 + 3.0 * displacement.powf(1.2);
|
||||||
|
|
||||||
|
Evaluation::new(vec![price, zero_to_sixty, fuel, noise])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let bounds = vec![
|
||||||
|
(1.0_f64, 6.0_f64), // engine
|
||||||
|
(1100.0_f64, 2200.0_f64), // weight
|
||||||
|
(0.20_f64, 0.40_f64), // drag
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut optimizer = Nsga3::new(
|
||||||
|
Nsga3Config {
|
||||||
|
population_size: 100,
|
||||||
|
generations: 200,
|
||||||
|
reference_divisions: 5,
|
||||||
|
seed: 42,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds.clone()),
|
||||||
|
CompositeVariation {
|
||||||
|
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.9),
|
||||||
|
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 3.0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let result = optimizer.run(&PickACar);
|
||||||
|
|
||||||
|
let mut front: Vec<_> = result.pareto_front.iter().collect();
|
||||||
|
front.sort_by(|a, b| {
|
||||||
|
a.evaluation.objectives[0]
|
||||||
|
.partial_cmp(&b.evaluation.objectives[0]).unwrap()
|
||||||
|
});
|
||||||
|
println!("{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
|
||||||
|
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB");
|
||||||
|
for c in &front {
|
||||||
|
let d = &c.decision;
|
||||||
|
let o = &c.evaluation.objectives;
|
||||||
|
println!("{:>5.2} {:>5.0} {:>4.2} {:>6.1} {:>5.1} {:>5.2} {:>5.1}",
|
||||||
|
d[0], d[1], d[2], o[0], o[1], o[2], o[3]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run NSGA-II
|
Run it (`cargo run --release`) and you get 100 cars on the front.
|
||||||
|
A representative slice from the actual output, hand-picked across
|
||||||
|
the spectrum:
|
||||||
|
|
||||||
```rust
|
```text
|
||||||
use heuropt::prelude::*;
|
L kg Cd $k 0-60 fuel dB ← role
|
||||||
|
1.00 1505 0.35 13.0 7.0 3.17 63.0 cheap baseline
|
||||||
# struct SchafferN1;
|
2.00 1370 0.35 22.4 5.1 3.56 66.7 sensible sport sedan
|
||||||
# impl Problem for SchafferN1 {
|
2.45 1330 0.38 28.5 4.5 3.92 68.8 quicker midprice
|
||||||
# type Decision = Vec<f64>;
|
1.00 1430 0.21 35.8 6.6 2.54 63.0 fuel-saver (small + slippery)
|
||||||
# fn objectives(&self) -> ObjectiveSpace {
|
3.50 1300 0.25 52.9 3.5 3.88 73.3 genuine sports car
|
||||||
# ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
5.27 1100 0.20 108.1 1.4 4.48 82.0 hypercar corner
|
||||||
# }
|
|
||||||
# fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
# Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)])
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
let initializer = RealBounds::new(vec![(-5.0, 5.0)]);
|
|
||||||
let variation = GaussianMutation { sigma: 0.2 };
|
|
||||||
let config = Nsga2Config { population_size: 60, generations: 80, seed: 42 };
|
|
||||||
let mut optimizer = Nsga2::new(config, initializer, variation);
|
|
||||||
let result = optimizer.run(&SchafferN1);
|
|
||||||
|
|
||||||
println!("Pareto front size: {}", result.pareto_front.len());
|
|
||||||
```
|
```
|
||||||
|
|
||||||
See `examples/toy_nsga2.rs` for the full version.
|
### Reading the result
|
||||||
|
|
||||||
|
Every row is **non-dominated** — no row is strictly better than
|
||||||
|
another on every metric. The interesting part is what each one does
|
||||||
|
*differently*:
|
||||||
|
|
||||||
|
- The **cheap baseline** ($13k) takes the path of least resistance:
|
||||||
|
smallest engine, no weight reduction, average drag. Slow but
|
||||||
|
affordable.
|
||||||
|
- The **sensible sedan** ($22k) trades $9k for **2 seconds off
|
||||||
|
0-60** by running a 2.0L engine with mild weight reduction.
|
||||||
|
- The **fuel-saver** is interesting: it's a 1.0L econobox engine,
|
||||||
|
but it spends $22k *just on aero* (0.21 Cd) to push fuel
|
||||||
|
consumption down to **2.54 gal/100mi**. The optimizer figured
|
||||||
|
out that aero matters more than displacement at this fuel point.
|
||||||
|
No human would pick this combo by intuition.
|
||||||
|
- The **sports car** ($53k) doesn't blow money on the lightest
|
||||||
|
possible weight — it picks 1300 kg, because dropping further
|
||||||
|
costs disproportionately and the 3.5L engine is doing most of
|
||||||
|
the acceleration work.
|
||||||
|
- The **hypercar corner** ($108k) is the optimizer pushing every
|
||||||
|
decision to its ceiling: minimum weight (1100 kg), minimum
|
||||||
|
drag (0.20 Cd), big engine (5.3L). Sub-1.5 second 0-60, but
|
||||||
|
you pay for it on every other axis except fuel (because the
|
||||||
|
weight + aero savings partly cancel the V8's thirst).
|
||||||
|
|
||||||
|
That last point is the kind of insight a Pareto front gives you
|
||||||
|
that no single-objective optimizer would: **the cheapest fuel-
|
||||||
|
efficient car is not the smallest engine alone**, it's a small
|
||||||
|
engine + aggressive aero. **The lightest sports car is not the
|
||||||
|
lightest possible**, it's the point where weight cost stops paying
|
||||||
|
back in 0-60. The optimizer doesn't tell you what to buy — it
|
||||||
|
hands you the frontier of *every defensible compromise* and lets
|
||||||
|
you pick by your own priorities.
|
||||||
|
|
||||||
## Implement a custom optimizer
|
## Implement a custom optimizer
|
||||||
|
|
||||||
@@ -105,13 +204,7 @@ where
|
|||||||
// Evaluate them with `problem.evaluate(...)`.
|
// Evaluate them with `problem.evaluate(...)`.
|
||||||
// Keep the best, or maintain a Pareto archive.
|
// Keep the best, or maintain a Pareto archive.
|
||||||
// Return an OptimizationResult.
|
// Return an OptimizationResult.
|
||||||
# OptimizationResult::new(
|
todo!()
|
||||||
# Population::new(Vec::new()),
|
|
||||||
# Vec::new(),
|
|
||||||
# None,
|
|
||||||
# 0,
|
|
||||||
# 0,
|
|
||||||
# )
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -8,8 +8,8 @@ needed.
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|--------------------|
|
|---------|--------------------|
|
||||||
| 0.5.x | ✅ |
|
| 0.8.x | ✅ |
|
||||||
| ≤ 0.4.x | ❌ (please upgrade) |
|
| ≤ 0.7.x | ❌ (please upgrade) |
|
||||||
|
|
||||||
heuropt is pre-1.0; the public API may change between minor versions.
|
heuropt is pre-1.0; the public API may change between minor versions.
|
||||||
Once 1.0.0 ships, the support window will be at least the latest two
|
Once 1.0.0 ships, the support window will be at least the latest two
|
||||||
|
|||||||
+1
-1
@@ -31,4 +31,4 @@ use-boolean-and = true
|
|||||||
enable = true
|
enable = true
|
||||||
|
|
||||||
[rust]
|
[rust]
|
||||||
edition = "2024"
|
edition = "2021"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
- [Recipes](./cookbook.md)
|
- [Recipes](./cookbook.md)
|
||||||
- [Parallelize evaluation with rayon](./cookbook/parallel.md)
|
- [Parallelize evaluation with rayon](./cookbook/parallel.md)
|
||||||
|
- [Async evaluation (HTTP / RPC / subprocess)](./cookbook/async.md)
|
||||||
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
||||||
- [Compare two algorithms on your problem](./cookbook/compare.md)
|
- [Compare two algorithms on your problem](./cookbook/compare.md)
|
||||||
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md)
|
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md)
|
||||||
|
|||||||
@@ -222,9 +222,15 @@ evaluate via rayon when the feature is on. **Seeded runs stay
|
|||||||
bit-identical** to serial mode.
|
bit-identical** to serial mode.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
heuropt = { version = "0.5", features = ["parallel"] }
|
heuropt = { version = "0.8", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If your evaluation is **IO-bound** (HTTP request, RPC, subprocess)
|
||||||
|
rather than CPU-bound, use the `async` feature instead — it gives
|
||||||
|
you `AsyncProblem` and a `run_async(&problem, concurrency).await`
|
||||||
|
method on every algorithm in the catalog. See the
|
||||||
|
[Async evaluation cookbook recipe](./cookbook/async.md).
|
||||||
|
|
||||||
## TL;DR table
|
## TL;DR table
|
||||||
|
|
||||||
| Situation | Pick |
|
| Situation | Pick |
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ The columns:
|
|||||||
|
|
||||||
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| **heuropt 0.5** | Rust | 35 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | ⏳ planned |
|
| **heuropt 0.8** | Rust | 33 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | ✅ `AsyncProblem` + `run_async` on every algorithm |
|
||||||
| pymoo | Python | ~25 | ✅ extensive | partial (BO via plug-ins) | ✅ | ❌ |
|
| pymoo | Python | ~25 | ✅ extensive | partial (BO via plug-ins) | ✅ | ❌ |
|
||||||
| DEAP | Python | flexible toolbox | ✅ | ❌ | ✅ | ❌ |
|
| DEAP | Python | flexible toolbox | ✅ | ❌ | ✅ | ❌ |
|
||||||
| hyperopt | Python | TPE-focused | ❌ | ✅ TPE | partial | partial |
|
| hyperopt | Python | TPE-focused | ❌ | ✅ TPE | partial | partial |
|
||||||
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | ✅ |
|
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | partial (study-level, not eval-level) |
|
||||||
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
|
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
|
||||||
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
|
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
|
||||||
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
|
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
|
||||||
@@ -38,12 +38,13 @@ The columns:
|
|||||||
written for clarity, no trait-object plumbing, no GATs in user-
|
written for clarity, no trait-object plumbing, no GATs in user-
|
||||||
facing APIs. Reading `RandomSearch` should be enough to write a
|
facing APIs. Reading `RandomSearch` should be enough to write a
|
||||||
new optimizer.
|
new optimizer.
|
||||||
|
- You have **IO-bound evaluations** — calling an HTTP service, an
|
||||||
|
RPC, or a subprocess — and want first-class `async fn evaluate`
|
||||||
|
support. heuropt is the only mainstream optimization library that
|
||||||
|
ships this (see [Async evaluation](./cookbook/async.md)).
|
||||||
|
|
||||||
## When *not* to pick heuropt
|
## When *not* to pick heuropt
|
||||||
|
|
||||||
- You need **first-class async / await** for evaluations that talk to
|
|
||||||
HTTP services or spawn subprocesses. heuropt is sync; that's on
|
|
||||||
the roadmap but not shipping yet.
|
|
||||||
- You need **gradient-based** optimization. Use `argmin` (Rust) or
|
- You need **gradient-based** optimization. Use `argmin` (Rust) or
|
||||||
`scipy.optimize` (Python) — heuropt is gradient-free by design.
|
`scipy.optimize` (Python) — heuropt is gradient-free by design.
|
||||||
- You need **GPU-accelerated** evaluations. heuropt's `evaluate`
|
- You need **GPU-accelerated** evaluations. heuropt's `evaluate`
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ project.
|
|||||||
## Recipes
|
## Recipes
|
||||||
|
|
||||||
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
|
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
|
||||||
your `evaluate` is non-trivial, the `parallel` feature pays for
|
your `evaluate` is non-trivial CPU work, the `parallel` feature
|
||||||
itself almost immediately.
|
pays for itself almost immediately.
|
||||||
|
- [Async evaluation](./cookbook/async.md) — when your `evaluate` is
|
||||||
|
IO-bound (HTTP / RPC / subprocess), the `async` feature lets the
|
||||||
|
optimizer await many evaluations concurrently. The differentiating
|
||||||
|
feature vs other optimization libraries.
|
||||||
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
||||||
— `BayesianOpt`, `Tpe`, and `Hyperband` for the 50–500-eval
|
— `BayesianOpt`, `Tpe`, and `Hyperband` for the 50–500-eval
|
||||||
regime.
|
regime.
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Async evaluation
|
||||||
|
|
||||||
|
When your `evaluate` does **IO** — calls an HTTP service, sends an
|
||||||
|
RPC, spawns a subprocess — `await`-ing it from the optimizer is
|
||||||
|
much more efficient than blocking a thread per evaluation. heuropt
|
||||||
|
ships first-class async support behind the `async` feature flag.
|
||||||
|
|
||||||
|
This is the differentiating capability vs pymoo / hyperopt /
|
||||||
|
optuna / DEAP / MOEA Framework — none of those have a native async
|
||||||
|
evaluation path.
|
||||||
|
|
||||||
|
## Enable the feature
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
heuropt = { version = "0.8", features = ["async"] }
|
||||||
|
|
||||||
|
# Pick whatever async runtime you want; heuropt itself depends only on
|
||||||
|
# `futures`. The example below uses tokio.
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implement `AsyncProblem`
|
||||||
|
|
||||||
|
It mirrors the regular [`Problem`] trait one-for-one — same
|
||||||
|
`Decision` type, same `objectives()`, but `evaluate` is replaced
|
||||||
|
with `evaluate_async` returning a future.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
use heuropt::core::async_problem::AsyncProblem;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
|
struct RemoteService;
|
||||||
|
|
||||||
|
impl AsyncProblem for RemoteService {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
// Real workload: HTTP call to a model-scoring service, an RPC,
|
||||||
|
// a subprocess. Here we just sleep to model 20 ms latency.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
|
let loss: f64 = x.iter().map(|v| v * v).sum();
|
||||||
|
Evaluation::new(vec![loss])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run the optimizer with `run_async`
|
||||||
|
|
||||||
|
`run_async(&problem, concurrency).await` is provided by **every**
|
||||||
|
algorithm in the catalog as of v0.8. `concurrency` caps how many
|
||||||
|
evaluations are in-flight at once.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use heuropt::core::async_problem::AsyncProblem;
|
||||||
|
# use heuropt::prelude::*;
|
||||||
|
# struct RemoteService;
|
||||||
|
# impl AsyncProblem for RemoteService {
|
||||||
|
# type Decision = Vec<f64>;
|
||||||
|
# fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
# ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
||||||
|
# }
|
||||||
|
# async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
|
||||||
|
let mut opt = DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 16,
|
||||||
|
generations: 50,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed: 42,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds),
|
||||||
|
);
|
||||||
|
let r = opt.run_async(&RemoteService, /* concurrency */ 8).await;
|
||||||
|
println!("best: {}", r.best.unwrap().evaluation.objectives[0]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Picking `concurrency`
|
||||||
|
|
||||||
|
Concurrency is the maximum in-flight evaluation count. Tradeoffs:
|
||||||
|
|
||||||
|
| Setting | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `1` | Sequential; equivalent to a sync run with extra overhead |
|
||||||
|
| `pop_size` | Full per-generation parallelism; fastest if your service tolerates it |
|
||||||
|
| `< pop_size` | Bounded — useful if your downstream service has a rate limit or finite worker pool |
|
||||||
|
|
||||||
|
The bigger you go, the more memory the in-flight futures hold and
|
||||||
|
the more load you put on the downstream service. A reasonable
|
||||||
|
starting point is `min(pop_size, 16)` and increase only if the
|
||||||
|
downstream service is comfortable.
|
||||||
|
|
||||||
|
## Determinism
|
||||||
|
|
||||||
|
Same seed produces the same final result whether you use `run` or
|
||||||
|
`run_async`, **provided your async `evaluate_async` is itself
|
||||||
|
deterministic**. heuropt drives the RNG and selection on the main
|
||||||
|
task; only the evaluations are concurrent, and the
|
||||||
|
`evaluate_batch_async` helper preserves input order before feeding
|
||||||
|
results back to the algorithm.
|
||||||
|
|
||||||
|
## What the worked example shows
|
||||||
|
|
||||||
|
`examples/async_eval.rs` runs `RandomSearch` (200 evaluations × 20 ms
|
||||||
|
each) at `concurrency = 1, 4, 16` and `DifferentialEvolution` at
|
||||||
|
`concurrency = 8`. On a recent machine:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RandomSearch with 200 evaluations (20 ms each)
|
||||||
|
|
||||||
|
concurrency = 1 elapsed ≈ 4250 ms (sequential 200 × 20 ms)
|
||||||
|
concurrency = 4 elapsed ≈ 2100 ms (2× speedup, batch_size=2 caps it)
|
||||||
|
concurrency = 16 elapsed ≈ 2100 ms (same — batch_size dominates)
|
||||||
|
|
||||||
|
DifferentialEvolution at concurrency=8
|
||||||
|
elapsed ≈ 230 ms (8 ants run in parallel each generation)
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it yourself: `cargo run --release --features async --example async_eval`.
|
||||||
|
|
||||||
|
## Which algorithms support `run_async`?
|
||||||
|
|
||||||
|
**All 33** algorithms in the catalog. The shape of the async path
|
||||||
|
depends on the algorithm:
|
||||||
|
|
||||||
|
- **Population-based / batch-evaluating** — NSGA-II, NSGA-III, SPEA2,
|
||||||
|
MOEA/D, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA,
|
||||||
|
GrEA, RVEA, MOPSO, GA, DE, PSO, CMA-ES, IPOP-CMA-ES, sNES, TLBO,
|
||||||
|
UMDA, Ant Colony, Random Search. Each generation's offspring
|
||||||
|
evaluations are fanned out concurrently up to `concurrency`.
|
||||||
|
- **Steady-state (one-eval-per-step)** — Hill Climber, Simulated
|
||||||
|
Annealing, (1+1)-ES, PAES, Nelder-Mead. The `concurrency`
|
||||||
|
parameter is accepted for API uniformity but evaluation order is
|
||||||
|
inherently sequential.
|
||||||
|
- **Tabu Search** — fans out the K-neighbor batch each step.
|
||||||
|
- **Surrogate (BO, TPE)** — fans out the initial design batch, then
|
||||||
|
awaits per-iteration acquisitions sequentially (the surrogate
|
||||||
|
must update before the next point is chosen).
|
||||||
|
- **Hyperband** — uses the separate
|
||||||
|
[`AsyncPartialProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncPartialProblem.html)
|
||||||
|
trait (multi-fidelity); each Successive-Halving rung's evaluations
|
||||||
|
fan out concurrently.
|
||||||
|
|
||||||
|
## Async vs `parallel`
|
||||||
|
|
||||||
|
| If your `evaluate` is… | Use |
|
||||||
|
|---|---|
|
||||||
|
| CPU-bound (math, simulation) | `parallel` feature → see [Parallelize evaluation](./parallel.md) |
|
||||||
|
| IO-bound (HTTP, RPC, subprocess) | `async` feature (this recipe) |
|
||||||
|
|
||||||
|
Both can be on at once if your evaluation does *both* substantial
|
||||||
|
CPU work *and* IO. The two features are independent.
|
||||||
|
|
||||||
|
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
|
||||||
@@ -134,8 +134,11 @@ parallel.
|
|||||||
result.
|
result.
|
||||||
- **No error type.** Invalid configuration panics with a clear
|
- **No error type.** Invalid configuration panics with a clear
|
||||||
message; this matches the style of the built-in algorithms.
|
message; this matches the style of the built-in algorithms.
|
||||||
- **No async.** `evaluate` is synchronous; for async work, drive it
|
- **No async on the trait.** `Optimizer<P>` is synchronous. For
|
||||||
on a tokio runtime around the optimizer loop yourself.
|
async evaluation, implement [`AsyncProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html)
|
||||||
|
on your problem and use the `run_async(&problem, concurrency)`
|
||||||
|
method that comes with the `async` feature. See the
|
||||||
|
[Async evaluation cookbook recipe](./async.md).
|
||||||
|
|
||||||
The smallness is the point: you should be able to read a built-in
|
The smallness is the point: you should be able to read a built-in
|
||||||
algorithm and write your own in an afternoon. See
|
algorithm and write your own in an afternoon. See
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ population, and rayon parallelizes that batch.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = { version = "0.5", features = ["parallel"] }
|
heuropt = { version = "0.8", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
There's nothing else to opt into in your code. The
|
There's nothing else to opt into in your code. The
|
||||||
@@ -104,6 +104,16 @@ to scope it.
|
|||||||
parallelism rarely helps.
|
parallelism rarely helps.
|
||||||
- The algorithm is steady-state (Paes, SA, hill climber).
|
- The algorithm is steady-state (Paes, SA, hill climber).
|
||||||
|
|
||||||
|
## `parallel` vs `async`
|
||||||
|
|
||||||
|
| If your `evaluate` is… | Use |
|
||||||
|
|---|---|
|
||||||
|
| CPU-bound (math, simulation) | `parallel` feature (this recipe) |
|
||||||
|
| IO-bound (HTTP, RPC, subprocess) | `async` feature → see [Async evaluation](./async.md) |
|
||||||
|
|
||||||
|
Both can be on at once if your evaluation does *both* substantial
|
||||||
|
CPU work *and* IO. The two features are independent.
|
||||||
|
|
||||||
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
||||||
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
||||||
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ The shortest path from a fresh project to a working optimizer.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = "0.5"
|
heuropt = "0.8"
|
||||||
```
|
```
|
||||||
|
|
||||||
The default feature set is small. Optional features:
|
The default feature set is small. Optional features:
|
||||||
@@ -14,58 +14,59 @@ The default feature set is small. Optional features:
|
|||||||
- `parallel` — rayon-backed parallel population evaluation.
|
- `parallel` — rayon-backed parallel population evaluation.
|
||||||
- `serde` — `Serialize` / `Deserialize` derives on the core data
|
- `serde` — `Serialize` / `Deserialize` derives on the core data
|
||||||
types.
|
types.
|
||||||
|
- `async` — `AsyncProblem` trait + per-algorithm `run_async` for
|
||||||
|
IO-bound evaluations.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
heuropt = { version = "0.5", features = ["parallel"] }
|
heuropt = { version = "0.8", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Define a problem
|
## 2. Define a problem and run an optimizer
|
||||||
|
|
||||||
A problem is a struct that implements the [`Problem`] trait. You tell
|
A problem is a struct that implements the [`Problem`] trait. You tell
|
||||||
heuropt what kind of decision your problem takes (`Vec<f64>`,
|
heuropt what kind of decision your problem takes (`Vec<f64>`,
|
||||||
`Vec<bool>`, …), what objectives it has (minimize or maximize), and
|
`Vec<bool>`, …), what objectives it has (minimize or maximize), and
|
||||||
how to score one decision.
|
how to score one decision.
|
||||||
|
|
||||||
|
We'll fit a straight line to a handful of `(x, y)` data points by
|
||||||
|
finding the slope and intercept that minimize the sum of squared
|
||||||
|
errors — same objective as least-squares regression. For a smooth
|
||||||
|
single-objective continuous problem like this, [`CmaEs`] is a strong
|
||||||
|
default.
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
struct Sphere;
|
struct LineFit {
|
||||||
|
points: Vec<(f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
impl Problem for Sphere {
|
impl Problem for LineFit {
|
||||||
type Decision = Vec<f64>;
|
type Decision = Vec<f64>; // [slope, intercept]
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
ObjectiveSpace::new(vec![Objective::minimize("sum_squared_error")])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
let f: f64 = x.iter().map(|v| v * v).sum();
|
let (slope, intercept) = (x[0], x[1]);
|
||||||
Evaluation::new(vec![f])
|
let sse: f64 = self
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.map(|(px, py)| (py - (slope * px + intercept)).powi(2))
|
||||||
|
.sum();
|
||||||
|
Evaluation::new(vec![sse])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
The Sphere function is a single-objective continuous problem: minimize
|
fn main() {
|
||||||
`f(x) = Σ xᵢ²`. The optimum is `x = 0`, `f = 0`.
|
// Five noisy points roughly on the line y = 2x + 1.
|
||||||
|
let problem = LineFit {
|
||||||
|
points: vec![(0.0, 1.1), (1.0, 2.9), (2.0, 5.1), (3.0, 6.8), (4.0, 9.2)],
|
||||||
|
};
|
||||||
|
|
||||||
## 3. Pick an algorithm and run it
|
// Search box: slope and intercept each in [-10, 10].
|
||||||
|
let bounds = RealBounds::new(vec![(-10.0, 10.0); 2]);
|
||||||
For a smooth single-objective continuous problem, [`CmaEs`] is a
|
|
||||||
strong default. Configure it, build it, run it.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
# use heuropt::prelude::*;
|
|
||||||
# struct Sphere;
|
|
||||||
# impl Problem for Sphere {
|
|
||||||
# type Decision = Vec<f64>;
|
|
||||||
# fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
# ObjectiveSpace::new(vec![Objective::minimize("f")])
|
|
||||||
# }
|
|
||||||
# fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
let bounds = RealBounds::new(vec![(-5.0, 5.0); 5]); // 5-dim search box
|
|
||||||
|
|
||||||
let mut opt = CmaEs::new(
|
let mut opt = CmaEs::new(
|
||||||
CmaEsConfig {
|
CmaEsConfig {
|
||||||
@@ -79,21 +80,60 @@ let mut opt = CmaEs::new(
|
|||||||
bounds,
|
bounds,
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = opt.run(&Sphere);
|
let result = opt.run(&problem);
|
||||||
|
|
||||||
let best = result.best.expect("at least one feasible candidate");
|
let best = result.best.expect("at least one feasible candidate");
|
||||||
println!("best f = {:.3e} at x = {:?}", best.evaluation.objectives[0], best.decision);
|
let (slope, intercept) = (best.decision[0], best.decision[1]);
|
||||||
|
println!(
|
||||||
|
"best fit: y = {:.4} x + {:.4} (sse = {:.4e}, evaluations = {})",
|
||||||
|
slope, intercept, best.evaluation.objectives[0], result.evaluations,
|
||||||
|
);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("predictions vs actual:");
|
||||||
|
for (px, py) in &problem.points {
|
||||||
|
let pred = slope * px + intercept;
|
||||||
|
println!(
|
||||||
|
" x = {:.1} actual = {:.2} predicted = {:.4} residual = {:+.4}",
|
||||||
|
px, py, pred, py - pred,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with `cargo run --release` — heuristic optimization is allergic
|
Run with `cargo run --release` — heuristic optimization is allergic
|
||||||
to debug builds. Expect output like:
|
to debug builds. The actual output:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
best f = 1.4e-29 at x = [-1.6e-15, 4.5e-16, ...]
|
best fit: y = 2.0100 x + 1.0000 (sse = 1.0700e-1, evaluations = 960)
|
||||||
|
|
||||||
|
predictions vs actual:
|
||||||
|
x = 0.0 actual = 1.10 predicted = 1.0000 residual = +0.1000
|
||||||
|
x = 1.0 actual = 2.90 predicted = 3.0100 residual = -0.1100
|
||||||
|
x = 2.0 actual = 5.10 predicted = 5.0200 residual = +0.0800
|
||||||
|
x = 3.0 actual = 6.80 predicted = 7.0300 residual = -0.2300
|
||||||
|
x = 4.0 actual = 9.20 predicted = 9.0400 residual = +0.1600
|
||||||
```
|
```
|
||||||
|
|
||||||
CMA-ES drops to machine epsilon on the Sphere in well under 80
|
### Reading the result
|
||||||
generations.
|
|
||||||
|
CMA-ES recovered **slope ≈ 2.01, intercept ≈ 1.00** — within
|
||||||
|
hundredths of the underlying line `y = 2x + 1` that the data was
|
||||||
|
sampled from. The residuals are evenly distributed in sign (3
|
||||||
|
positive, 2 negative) and small in magnitude (the largest is 0.23
|
||||||
|
at `x = 3`), which means the fit is balancing the noise rather than
|
||||||
|
chasing any single point.
|
||||||
|
|
||||||
|
The total **sum of squared errors is 0.107** — that is the value
|
||||||
|
the optimizer was actually minimizing, and it matches the answer
|
||||||
|
you'd get from running `numpy.polyfit` or solving the normal
|
||||||
|
equations directly. CMA-ES is overkill for a two-parameter problem
|
||||||
|
(closed-form least-squares does it in one step), but the **same
|
||||||
|
code shape** scales straight up to nonlinear models, robust loss
|
||||||
|
functions, or constrained variants where there is no closed form.
|
||||||
|
|
||||||
|
It used 960 evaluations to get there. That's `population_size × generations`
|
||||||
|
= 12 × 80 = 960, and CMA-ES converges to machine epsilon on
|
||||||
|
problems this clean in well under that budget.
|
||||||
|
|
||||||
## 4. What just happened
|
## 4. What just happened
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
|
|||||||
|
|
||||||
## What's in the box
|
## What's in the box
|
||||||
|
|
||||||
heuropt v0.5 ships **35 algorithms** spanning:
|
heuropt v0.8 ships **33 algorithms** spanning:
|
||||||
|
|
||||||
- Single-objective continuous: `RandomSearch`, `HillClimber`,
|
- Single-objective continuous: `RandomSearch`, `HillClimber`,
|
||||||
`OnePlusOneEs`, `SimulatedAnnealing`, `GeneticAlgorithm`,
|
`OnePlusOneEs`, `SimulatedAnnealing`, `GeneticAlgorithm`,
|
||||||
@@ -65,6 +65,15 @@ ProjectToSimplex), the metrics (hypervolume, spacing), and the Pareto
|
|||||||
utilities (dominance, fronts, crowding distance, Das–Dennis reference
|
utilities (dominance, fronts, crowding distance, Das–Dennis reference
|
||||||
points, the `ParetoArchive`) that you'd expect.
|
points, the `ParetoArchive`) that you'd expect.
|
||||||
|
|
||||||
|
**Async evaluation** (since v0.8, behind the `async` feature flag):
|
||||||
|
when your `evaluate` function is IO-bound — calling an HTTP service,
|
||||||
|
an RPC, or a subprocess — implement [`AsyncProblem`] and use
|
||||||
|
`run_async(&problem, concurrency).await` on any algorithm in the
|
||||||
|
catalog. heuropt is the only mainstream optimization library with
|
||||||
|
first-class async support across its entire algorithm set.
|
||||||
|
|
||||||
|
[`AsyncProblem`]: https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html
|
||||||
|
|
||||||
## How to use this guide
|
## How to use this guide
|
||||||
|
|
||||||
If you're new to heuropt, read it linearly:
|
If you're new to heuropt, read it linearly:
|
||||||
|
|||||||
@@ -3,6 +3,64 @@
|
|||||||
Per-release notes for upgrading between heuropt versions. Skip the
|
Per-release notes for upgrading between heuropt versions. Skip the
|
||||||
sections that don't apply to your starting version.
|
sections that don't apply to your starting version.
|
||||||
|
|
||||||
|
## To 0.8
|
||||||
|
|
||||||
|
### From 0.5.x
|
||||||
|
|
||||||
|
**Additive feature only.** Bumping `heuropt = "0.8"` is enough for
|
||||||
|
any code that doesn't need async evaluation. To opt into async,
|
||||||
|
enable the new feature flag:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
heuropt = { version = "0.8", features = ["async"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
|
||||||
|
- New `async` feature flag, gated on the
|
||||||
|
[`futures`](https://crates.io/crates/futures) crate.
|
||||||
|
- New `core::async_problem::AsyncProblem` trait — mirrors `Problem`
|
||||||
|
but with `async fn evaluate_async`.
|
||||||
|
- New `core::async_problem::AsyncPartialProblem` trait — mirrors
|
||||||
|
`PartialProblem` for multi-fidelity (Hyperband) workloads.
|
||||||
|
- `run_async(&problem, concurrency).await` on **every** algorithm in
|
||||||
|
the catalog (33 of them) for IO-bound evaluations.
|
||||||
|
- New cookbook recipe: [Async evaluation](./cookbook/async.md).
|
||||||
|
|
||||||
|
### From 0.7.x
|
||||||
|
|
||||||
|
`0.7.0` introduced an experimental observability layer (`Snapshot`,
|
||||||
|
`Observer`, `run_with`, `MaxTime`, `TargetFitness`, `Stagnation`,
|
||||||
|
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`) and three
|
||||||
|
additional Pareto metrics (`igd`, `igd_plus`, `r2`). All of those
|
||||||
|
were rolled back in `0.8.0` — the design didn't bake long enough
|
||||||
|
and they shipped half-wired (`run_with` was overridden on only 3 of
|
||||||
|
35 algorithms). The `tracing` feature flag is also gone.
|
||||||
|
|
||||||
|
If your code uses any of those APIs, the migration is:
|
||||||
|
|
||||||
|
- Remove all `run_with(&problem, &mut observer)` calls and replace
|
||||||
|
with `run(&problem)`.
|
||||||
|
- Remove all uses of `Observer`, `Snapshot`, `ControlFlow`,
|
||||||
|
`MaxTime`, `MaxIterations`, `TargetFitness`, `Stagnation`,
|
||||||
|
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`.
|
||||||
|
- Remove all uses of `metrics::igd::igd`, `metrics::igd::igd_plus`,
|
||||||
|
`metrics::r2::r2`.
|
||||||
|
- Remove `Population::as_slice()` calls (the method is gone).
|
||||||
|
- Drop the `tracing` feature from your `Cargo.toml` if you had it.
|
||||||
|
|
||||||
|
Stop conditions can still be implemented by wrapping `run` in a
|
||||||
|
loop with a custom RNG-driven termination, or by wrapping
|
||||||
|
the algorithm yourself; observers may return as a public API in a
|
||||||
|
future release once the design has settled.
|
||||||
|
|
||||||
|
The async work introduced in 0.7.0 (`AsyncProblem` + `run_async`)
|
||||||
|
**survived** and is broadened in 0.8: every algorithm in the catalog
|
||||||
|
now has a `run_async` (0.7.0 only had it on three of them), and
|
||||||
|
multi-fidelity problems get a parallel `AsyncPartialProblem` trait
|
||||||
|
that Hyperband's `run_async` consumes. Existing call sites continue
|
||||||
|
to work unchanged.
|
||||||
|
|
||||||
## To 0.5
|
## To 0.5
|
||||||
|
|
||||||
### From 0.4.x
|
### From 0.4.x
|
||||||
|
|||||||
+12
-13
@@ -18,10 +18,10 @@ versions — use them at your own risk.
|
|||||||
|
|
||||||
While we are pre-1.0:
|
While we are pre-1.0:
|
||||||
|
|
||||||
- **Minor bumps (`0.5 → 0.6`) may break the public API.** The
|
- **Minor bumps (`0.8 → 0.9`) may break the public API.** The
|
||||||
CHANGELOG calls out everything that changed, and a **migration
|
CHANGELOG calls out everything that changed, and a **migration
|
||||||
guide** in this book documents the move.
|
guide** in this book documents the move.
|
||||||
- **Patch bumps (`0.5.0 → 0.5.1`) only contain bug fixes,
|
- **Patch bumps (`0.8.0 → 0.8.1`) only contain bug fixes,
|
||||||
performance improvements, and additive non-breaking features.**
|
performance improvements, and additive non-breaking features.**
|
||||||
No deprecations, no removals.
|
No deprecations, no removals.
|
||||||
|
|
||||||
@@ -29,24 +29,20 @@ While we are pre-1.0:
|
|||||||
|
|
||||||
In rough order of likelihood:
|
In rough order of likelihood:
|
||||||
|
|
||||||
1. **`Optimizer<P>` may grow new optional methods** for callbacks,
|
1. **Algorithm config structs may gain fields.** All current configs
|
||||||
stop conditions, and save/resume support. These will land as
|
|
||||||
methods with default implementations so existing trait impls
|
|
||||||
keep compiling, but the trait shape will be different.
|
|
||||||
2. **Algorithm config structs may gain fields.** All current configs
|
|
||||||
are public-field structs; adding a non-`Default` field is a
|
are public-field structs; adding a non-`Default` field is a
|
||||||
breaking change. We may switch to builder patterns to avoid this
|
breaking change. We may switch to builder patterns to avoid this
|
||||||
class of break, or we may add `#[non_exhaustive]`.
|
class of break, or we may add `#[non_exhaustive]`.
|
||||||
3. **The `Snapshot`, `Observer`, and `Checkpoint` types** (planned
|
2. **Some operators may move between `operators` and `pareto`** as
|
||||||
for a future release) will land as new public surfaces.
|
|
||||||
4. **Some operators may move between `operators` and `pareto`** as
|
|
||||||
the boundary between "things that produce candidates" and "Pareto
|
the boundary between "things that produce candidates" and "Pareto
|
||||||
utilities" gets clearer.
|
utilities" gets clearer.
|
||||||
|
|
||||||
What is **not** likely to change:
|
What is **not** likely to change:
|
||||||
|
|
||||||
- The `Problem` trait shape.
|
- The `Problem` trait shape.
|
||||||
|
- The `AsyncProblem` / `AsyncPartialProblem` trait shapes.
|
||||||
- The `Variation` / `Initializer` / `Repair` traits.
|
- The `Variation` / `Initializer` / `Repair` traits.
|
||||||
|
- The `Optimizer<P>` trait — single `run` method, no callbacks.
|
||||||
- The `Evaluation` / `Candidate` / `Population` / `OptimizationResult`
|
- The `Evaluation` / `Candidate` / `Population` / `OptimizationResult`
|
||||||
data types.
|
data types.
|
||||||
- The seeded determinism property.
|
- The seeded determinism property.
|
||||||
@@ -60,12 +56,12 @@ Across minor versions, output may change if an algorithm's
|
|||||||
implementation changes (e.g. a perf rewrite that reorders
|
implementation changes (e.g. a perf rewrite that reorders
|
||||||
floating-point operations, or a new feature that changes the
|
floating-point operations, or a new feature that changes the
|
||||||
RNG-consumption pattern). The CHANGELOG calls this out explicitly
|
RNG-consumption pattern). The CHANGELOG calls this out explicitly
|
||||||
when it happens. As of v0.5, the entire history of perf optimizations
|
when it happens. As of v0.8, the entire history of perf
|
||||||
has been bit-identical against the v0.3.0 reference.
|
optimizations has been bit-identical against the v0.3.0 reference.
|
||||||
|
|
||||||
## MSRV (minimum supported Rust version)
|
## MSRV (minimum supported Rust version)
|
||||||
|
|
||||||
heuropt's MSRV is **1.85** as of v0.5. This is tested in CI against
|
heuropt's MSRV is **1.85** as of v0.8. This is tested in CI against
|
||||||
every PR.
|
every PR.
|
||||||
|
|
||||||
MSRV bumps are treated as patch-bump-eligible (they don't break the
|
MSRV bumps are treated as patch-bump-eligible (they don't break the
|
||||||
@@ -79,6 +75,9 @@ The current optional features:
|
|||||||
- `serde` — adds `Serialize` / `Deserialize` derives on the core data
|
- `serde` — adds `Serialize` / `Deserialize` derives on the core data
|
||||||
types.
|
types.
|
||||||
- `parallel` — rayon-backed parallel population evaluation.
|
- `parallel` — rayon-backed parallel population evaluation.
|
||||||
|
- `async` — `AsyncProblem` + `AsyncPartialProblem` traits, plus a
|
||||||
|
`run_async` method on every algorithm in the catalog, for
|
||||||
|
IO-bound evaluations.
|
||||||
|
|
||||||
Features added in 0.x can be renamed or removed in any minor bump
|
Features added in 0.x can be renamed or removed in any minor bump
|
||||||
that documents the change. Removing a feature is treated like a
|
that documents the change. Removing a feature is treated like a
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//! Async evaluation example: optimize hyperparameters where each
|
||||||
|
//! evaluation is an awaitable (simulated HTTP) call.
|
||||||
|
//!
|
||||||
|
//! Demonstrates:
|
||||||
|
//! - Implementing [`AsyncProblem`].
|
||||||
|
//! - Driving the optimizer through `tokio` with bounded concurrency.
|
||||||
|
//! - Comparing wall-clock time at concurrency = 1 vs 8.
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo run --release --features async --example async_eval`
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use heuropt::core::async_problem::AsyncProblem;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
|
struct RemoteService;
|
||||||
|
|
||||||
|
impl AsyncProblem for RemoteService {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
// Simulate a 20 ms remote-service round-trip per evaluation.
|
||||||
|
// The compute itself is ~free; the latency is the bottleneck.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||||
|
let loss: f64 = x.iter().map(|v| v * v).sum();
|
||||||
|
Evaluation::new(vec![loss])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
|
||||||
|
let problem = RemoteService;
|
||||||
|
|
||||||
|
println!("RandomSearch with 200 evaluations (20 ms each)");
|
||||||
|
println!();
|
||||||
|
|
||||||
|
for &concurrency in &[1_usize, 4, 16] {
|
||||||
|
let mut opt = RandomSearch::new(
|
||||||
|
RandomSearchConfig {
|
||||||
|
iterations: 100,
|
||||||
|
batch_size: 2,
|
||||||
|
seed: 42,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds.clone()),
|
||||||
|
);
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = opt.run_async(&problem, concurrency).await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
println!(
|
||||||
|
"concurrency = {:>2} elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
|
||||||
|
concurrency,
|
||||||
|
elapsed.as_millis(),
|
||||||
|
result.best.unwrap().evaluation.objectives[0],
|
||||||
|
result.evaluations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("DifferentialEvolution at concurrency=8");
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut de = DifferentialEvolution::new(
|
||||||
|
DifferentialEvolutionConfig {
|
||||||
|
population_size: 8,
|
||||||
|
generations: 10,
|
||||||
|
differential_weight: 0.5,
|
||||||
|
crossover_probability: 0.9,
|
||||||
|
seed: 42,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds.clone()),
|
||||||
|
);
|
||||||
|
let result = de.run_async(&problem, 8).await;
|
||||||
|
let elapsed = started.elapsed();
|
||||||
|
println!(
|
||||||
|
"elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
|
||||||
|
elapsed.as_millis(),
|
||||||
|
result.best.unwrap().evaluation.objectives[0],
|
||||||
|
result.evaluations,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
//! Constrained multi-objective optimization (BNH problem) plus a
|
|
||||||
//! demo of the observer / stop-condition API.
|
|
||||||
//!
|
|
||||||
//! BNH (Binh & Korn 1996) is a 2-variable / 2-objective / 2-constraint
|
|
||||||
//! multi-objective problem:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! minimize f1 = 4·x1² + 4·x2²
|
|
||||||
//! f2 = (x1 − 5)² + (x2 − 5)²
|
|
||||||
//! subject to
|
|
||||||
//! g1: (x1 − 5)² + x2² ≤ 25
|
|
||||||
//! g2: (x1 − 8)² + (x2 + 3)² ≥ 7.7
|
|
||||||
//! 0 ≤ x1 ≤ 5, 0 ≤ x2 ≤ 3
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Demonstrates:
|
|
||||||
//! - Constraint handling via `Evaluation::constrained` (heuropt's
|
|
||||||
//! default tournament/Pareto comparators prefer feasibles).
|
|
||||||
//! - The Observer API: a `Stagnation` observer that halts the run
|
|
||||||
//! once the front stops improving, plus a `Periodic` observer that
|
|
||||||
//! prints progress every 25 generations.
|
|
||||||
//! - Composing observers with `.or()`.
|
|
||||||
//!
|
|
||||||
//! Run with: `cargo run --release --example constrained`
|
|
||||||
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct Bnh;
|
|
||||||
|
|
||||||
impl Problem for Bnh {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
let f1 = 4.0 * x[0] * x[0] + 4.0 * x[1] * x[1];
|
|
||||||
let f2 = (x[0] - 5.0).powi(2) + (x[1] - 5.0).powi(2);
|
|
||||||
|
|
||||||
// g1: (x1 − 5)² + x2² ≤ 25 → violation = max(0, lhs − 25)
|
|
||||||
let g1 = ((x[0] - 5.0).powi(2) + x[1].powi(2) - 25.0).max(0.0);
|
|
||||||
// g2: (x1 − 8)² + (x2 + 3)² ≥ 7.7 → violation = max(0, 7.7 − lhs)
|
|
||||||
let g2 = (7.7 - ((x[0] - 8.0).powi(2) + (x[1] + 3.0).powi(2))).max(0.0);
|
|
||||||
|
|
||||||
let total_violation = g1 + g2;
|
|
||||||
Evaluation::constrained(vec![f1, f2], total_violation)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let bounds = vec![(0.0_f64, 5.0_f64), (0.0_f64, 3.0_f64)];
|
|
||||||
|
|
||||||
// Compose stop conditions: halt after 5 s OR (via .or()) print
|
|
||||||
// periodic progress every 25 generations. The Periodic observer
|
|
||||||
// never breaks; it only logs.
|
|
||||||
let stop = MaxTime::new(std::time::Duration::from_secs(5));
|
|
||||||
let progress = Periodic::new(25, |snap: &Snapshot<'_, Vec<f64>>| {
|
|
||||||
let feasible_in_pop = snap
|
|
||||||
.population
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.evaluation.is_feasible())
|
|
||||||
.count();
|
|
||||||
let front_size = snap.pareto_front.map(|f| f.len()).unwrap_or(0);
|
|
||||||
println!(
|
|
||||||
"gen {:>4} evaluations = {:>6} feasible/pop = {}/{} front = {}",
|
|
||||||
snap.iteration,
|
|
||||||
snap.evaluations,
|
|
||||||
feasible_in_pop,
|
|
||||||
snap.population.len(),
|
|
||||||
front_size,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
let mut observer = <_ as Observer<Vec<f64>>>::or(stop, progress);
|
|
||||||
|
|
||||||
let mut opt = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 100,
|
|
||||||
generations: 250,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds.clone()),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 2.0),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = opt.run_with(&Bnh, &mut observer);
|
|
||||||
|
|
||||||
let total_feasible = result
|
|
||||||
.population
|
|
||||||
.iter()
|
|
||||||
.filter(|c| c.evaluation.is_feasible())
|
|
||||||
.count();
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("Final state after {} generations:", result.generations);
|
|
||||||
println!(" total evaluations: {}", result.evaluations);
|
|
||||||
println!(
|
|
||||||
" feasible / total pop: {} / {}",
|
|
||||||
total_feasible,
|
|
||||||
result.population.len()
|
|
||||||
);
|
|
||||||
println!(" pareto front size: {}", result.pareto_front.len());
|
|
||||||
println!();
|
|
||||||
println!("Sample of the front (f1, f2):");
|
|
||||||
let mut sorted = result.pareto_front.clone();
|
|
||||||
sorted.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
let n = sorted.len();
|
|
||||||
if n > 0 {
|
|
||||||
for k in (0..n).step_by((n / 5).max(1)) {
|
|
||||||
let c = &sorted[k];
|
|
||||||
println!(
|
|
||||||
" f1 = {:>7.3}, f2 = {:>7.3}, violation = {:.3}",
|
|
||||||
c.evaluation.objectives[0],
|
|
||||||
c.evaluation.objectives[1],
|
|
||||||
c.evaluation.constraint_violation,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1
-1
@@ -66,7 +66,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heuropt"
|
name = "heuropt"
|
||||||
version = "0.3.0"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand",
|
||||||
"rand_distr",
|
"rand_distr",
|
||||||
|
|||||||
@@ -77,10 +77,17 @@ fuzz_target!(|input: Input| {
|
|||||||
);
|
);
|
||||||
let after = y.clone();
|
let after = y.clone();
|
||||||
proj.repair(&mut y);
|
proj.repair(&mut y);
|
||||||
|
// The simplex projection's `τ` computation operates on values
|
||||||
|
// up to `simplex_total · 1e6` (per the filter above), so its FP
|
||||||
|
// precision floor is ~1e-4 of the input scale. Outputs near the
|
||||||
|
// `max(x_i − τ, 0)` clamp boundary can flip between 0 and a
|
||||||
|
// small positive value across re-applications. The fuzzer is
|
||||||
|
// checking for *gross* non-idempotence (all-zeros vs valid),
|
||||||
|
// not ULP-level slop.
|
||||||
|
let scale = input.simplex_total.max(max_abs).max(1.0);
|
||||||
for (a, b) in after.iter().zip(y.iter()) {
|
for (a, b) in after.iter().zip(y.iter()) {
|
||||||
let scale = a.abs().max(b.abs()).max(1.0);
|
|
||||||
assert!(
|
assert!(
|
||||||
(a - b).abs() < 1e-9 * scale,
|
(a - b).abs() < 1e-4 * scale,
|
||||||
"project not idempotent: {a} vs {b}",
|
"project not idempotent: {a} vs {b}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,80 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> AgeMoea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"AgeMoea population_size must be > 0"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"AgeMoea variation returned no children"
|
||||||
|
);
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
population = environmental_selection(combined, &objectives, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -241,6 +241,119 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl AntColonyTsp {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per generation.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<usize>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<usize>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(self.config.ants >= 1, "AntColonyTsp ants must be >= 1");
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"AntColonyTsp requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let n = self.distances.len();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let eta: Vec<Vec<f64>> = self
|
||||||
|
.distances
|
||||||
|
.iter()
|
||||||
|
.map(|row| {
|
||||||
|
row.iter()
|
||||||
|
.map(|&d| if d > 0.0 { 1.0 / d } else { 0.0 })
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
|
||||||
|
|
||||||
|
let mut best_decision: Option<Vec<usize>> = None;
|
||||||
|
let mut best_eval: Option<crate::core::evaluation::Evaluation> = None;
|
||||||
|
let mut evaluations = 0usize;
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
|
||||||
|
for _ in 0..self.config.ants {
|
||||||
|
let start = rng.random_range(0..n);
|
||||||
|
let tour = build_tour(
|
||||||
|
n,
|
||||||
|
start,
|
||||||
|
&pheromone,
|
||||||
|
&eta,
|
||||||
|
self.config.alpha,
|
||||||
|
self.config.beta,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
tours.push(tour);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cands = evaluate_batch_async(problem, tours.clone(), concurrency).await;
|
||||||
|
evaluations += cands.len();
|
||||||
|
let tour_evals: Vec<crate::core::evaluation::Evaluation> =
|
||||||
|
cands.into_iter().map(|c| c.evaluation).collect();
|
||||||
|
|
||||||
|
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
|
||||||
|
let beats = match &best_eval {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than_so(eval, b, direction),
|
||||||
|
};
|
||||||
|
if beats {
|
||||||
|
best_decision = Some(tour.clone());
|
||||||
|
best_eval = Some(eval.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in pheromone.iter_mut() {
|
||||||
|
for v in row.iter_mut() {
|
||||||
|
*v *= 1.0 - self.config.evaporation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
|
||||||
|
let length = eval
|
||||||
|
.objectives
|
||||||
|
.first()
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(f64::INFINITY)
|
||||||
|
.max(1e-12);
|
||||||
|
let deposit = self.config.deposit / length;
|
||||||
|
for w in tour.windows(2) {
|
||||||
|
let (i, j) = (w[0], w[1]);
|
||||||
|
pheromone[i][j] += deposit;
|
||||||
|
pheromone[j][i] += deposit;
|
||||||
|
}
|
||||||
|
let (i, j) = (*tour.last().unwrap(), tour[0]);
|
||||||
|
pheromone[i][j] += deposit;
|
||||||
|
pheromone[j][i] += deposit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = Candidate::new(best_decision.unwrap(), best_eval.unwrap());
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_tour(
|
fn build_tour(
|
||||||
n: usize,
|
n: usize,
|
||||||
start: usize,
|
start: usize,
|
||||||
|
|||||||
@@ -396,6 +396,150 @@ fn erf(x: f64) -> f64 {
|
|||||||
sign * y
|
sign * y
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl BayesianOpt {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations during the initial
|
||||||
|
/// uniform-sample design; the sequential BO loop runs one
|
||||||
|
/// evaluation per iteration regardless.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.initial_samples >= 2,
|
||||||
|
"BayesianOpt initial_samples must be >= 2",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.signal_variance > 0.0,
|
||||||
|
"BayesianOpt signal_variance must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.noise_variance > 0.0,
|
||||||
|
"BayesianOpt noise_variance must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.acquisition_samples >= 1,
|
||||||
|
"BayesianOpt acquisition_samples must be >= 1",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"BayesianOpt requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
if let Some(ls) = &self.config.length_scales {
|
||||||
|
assert_eq!(
|
||||||
|
ls.len(),
|
||||||
|
dim,
|
||||||
|
"BayesianOpt length_scales.len() must equal dim"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let length_scales: Vec<f64> = self.config.length_scales.clone().unwrap_or_else(|| {
|
||||||
|
self.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.2 * (hi - lo).max(1e-9))
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
// Initial random design: sample all decisions first (consuming
|
||||||
|
// RNG in the same order as the sync `run`), then evaluate
|
||||||
|
// concurrently.
|
||||||
|
let mut decisions: Vec<Vec<f64>> =
|
||||||
|
Vec::with_capacity(self.config.initial_samples + self.config.iterations);
|
||||||
|
let mut targets: Vec<f64> = Vec::with_capacity(decisions.capacity());
|
||||||
|
let mut evaluations: Vec<Evaluation> = Vec::with_capacity(decisions.capacity());
|
||||||
|
|
||||||
|
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
|
||||||
|
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
|
||||||
|
.collect();
|
||||||
|
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
for c in initial_cands {
|
||||||
|
let t = oriented_target(&c.evaluation, direction);
|
||||||
|
decisions.push(c.decision);
|
||||||
|
targets.push(t);
|
||||||
|
evaluations.push(c.evaluation);
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let posterior = match GpPosterior::fit(
|
||||||
|
&decisions,
|
||||||
|
&targets,
|
||||||
|
&length_scales,
|
||||||
|
self.config.signal_variance,
|
||||||
|
self.config.noise_variance,
|
||||||
|
) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(_) => {
|
||||||
|
let x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
||||||
|
let e = problem.evaluate_async(&x).await;
|
||||||
|
targets.push(oriented_target(&e, direction));
|
||||||
|
decisions.push(x);
|
||||||
|
evaluations.push(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
|
||||||
|
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
||||||
|
let mut best_ei = -f64::INFINITY;
|
||||||
|
for _ in 0..self.config.acquisition_samples {
|
||||||
|
let cand = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
||||||
|
let (mu, sigma) = posterior.predict(&cand);
|
||||||
|
let ei = expected_improvement(mu, sigma, best_target);
|
||||||
|
if ei > best_ei {
|
||||||
|
best_ei = ei;
|
||||||
|
best_x = cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let e = problem.evaluate_async(&best_x).await;
|
||||||
|
targets.push(oriented_target(&e, direction));
|
||||||
|
decisions.push(best_x);
|
||||||
|
evaluations.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
||||||
|
.into_iter()
|
||||||
|
.zip(evaluations)
|
||||||
|
.map(|(d, e)| Candidate::new(d, e))
|
||||||
|
.collect();
|
||||||
|
let mut best_idx = 0;
|
||||||
|
for i in 1..final_pop.len() {
|
||||||
|
if better(
|
||||||
|
&final_pop[i].evaluation,
|
||||||
|
&final_pop[best_idx].evaluation,
|
||||||
|
direction,
|
||||||
|
) {
|
||||||
|
best_idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total_evaluations = final_pop.len();
|
||||||
|
let best = final_pop[best_idx].clone();
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
total_evaluations,
|
||||||
|
self.config.iterations + self.config.initial_samples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -366,6 +366,237 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl CmaEs {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per generation.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 4,
|
||||||
|
"CmaEs population_size must be >= 4",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.initial_sigma > 0.0,
|
||||||
|
"CmaEs initial_sigma must be positive",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.eigen_decomposition_period >= 1,
|
||||||
|
"CmaEs eigen_decomposition_period must be >= 1",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"CmaEs only supports single-objective problems",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
|
||||||
|
let n = self.bounds.bounds.len();
|
||||||
|
let n_f = n as f64;
|
||||||
|
let lambda = self.config.population_size;
|
||||||
|
let lambda_f = lambda as f64;
|
||||||
|
let mu = lambda / 2;
|
||||||
|
assert!(mu >= 1, "CmaEs derived mu (= lambda/2) must be >= 1");
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let raw_weights: Vec<f64> = (0..mu)
|
||||||
|
.map(|i| ((lambda_f + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
|
||||||
|
.collect();
|
||||||
|
let sum_w: f64 = raw_weights.iter().sum();
|
||||||
|
let weights: Vec<f64> = raw_weights.iter().map(|w| w / sum_w).collect();
|
||||||
|
let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
|
||||||
|
|
||||||
|
let c_sigma = (mu_eff + 2.0) / (n_f + mu_eff + 5.0);
|
||||||
|
let d_sigma = 1.0 + 2.0 * ((mu_eff - 1.0) / (n_f + 1.0)).sqrt().max(0.0) + c_sigma;
|
||||||
|
let c_c = (4.0 + mu_eff / n_f) / (n_f + 4.0 + 2.0 * mu_eff / n_f);
|
||||||
|
let c_1 = 2.0 / ((n_f + 1.3).powi(2) + mu_eff);
|
||||||
|
let c_mu = ((1.0 - c_1) * 2.0 * (mu_eff - 2.0 + 1.0 / mu_eff)
|
||||||
|
/ ((n_f + 2.0).powi(2) + mu_eff))
|
||||||
|
.min(1.0 - c_1);
|
||||||
|
let chi_n = n_f.sqrt() * (1.0 - 1.0 / (4.0 * n_f) + 1.0 / (21.0 * n_f * n_f));
|
||||||
|
|
||||||
|
let mut mean: Vec<f64> = if let Some(provided) = self.config.initial_mean.clone() {
|
||||||
|
assert_eq!(
|
||||||
|
provided.len(),
|
||||||
|
self.bounds.bounds.len(),
|
||||||
|
"CmaEs initial_mean.len() must equal the bounds dimension",
|
||||||
|
);
|
||||||
|
provided
|
||||||
|
.into_iter()
|
||||||
|
.zip(self.bounds.bounds.iter())
|
||||||
|
.map(|(v, &(lo, hi))| v.clamp(lo, hi))
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
self.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let mut sigma = self.config.initial_sigma;
|
||||||
|
let mut c_matrix: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
|
||||||
|
.collect();
|
||||||
|
let mut b: Vec<Vec<f64>> = c_matrix.to_vec();
|
||||||
|
let mut d: Vec<f64> = vec![1.0; n];
|
||||||
|
let mut p_sigma = vec![0.0_f64; n];
|
||||||
|
let mut p_c = vec![0.0_f64; n];
|
||||||
|
let mut evaluations = 0usize;
|
||||||
|
|
||||||
|
let normal = Normal::new(0.0, 1.0).expect("Normal::new(0, 1)");
|
||||||
|
let mut best_candidate_seen: Option<Candidate<Vec<f64>>> = None;
|
||||||
|
|
||||||
|
for generation in 0..self.config.generations {
|
||||||
|
if generation % self.config.eigen_decomposition_period == 0 {
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for i in 0..n {
|
||||||
|
for j in (i + 1)..n {
|
||||||
|
let avg = 0.5 * (c_matrix[i][j] + c_matrix[j][i]);
|
||||||
|
c_matrix[i][j] = avg;
|
||||||
|
c_matrix[j][i] = avg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (eigenvalues, eigenvectors) = symmetric_eigen(&c_matrix, 1e-14, 100);
|
||||||
|
d = eigenvalues.iter().map(|&v| v.max(1e-20).sqrt()).collect();
|
||||||
|
b = (0..n)
|
||||||
|
.map(|r| (0..n).map(|c| eigenvectors[c][r]).collect())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
||||||
|
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
||||||
|
for _ in 0..lambda {
|
||||||
|
let z: Vec<f64> = (0..n).map(|_| normal.sample(&mut rng)).collect();
|
||||||
|
let bd_z: Vec<f64> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| b[i][j] * d[j] * z[j]).sum::<f64>())
|
||||||
|
.collect();
|
||||||
|
let x: Vec<f64> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let v = mean[i] + sigma * bd_z[i];
|
||||||
|
let (lo, hi) = self.bounds.bounds[i];
|
||||||
|
v.clamp(lo, hi)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
z_samples.push(z);
|
||||||
|
x_samples.push(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
let evaluated = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
|
||||||
|
evaluations += evaluated.len();
|
||||||
|
|
||||||
|
for c in &evaluated {
|
||||||
|
let beats_best = match &best_candidate_seen {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats_best {
|
||||||
|
best_candidate_seen = Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut order: Vec<usize> = (0..lambda).collect();
|
||||||
|
order.sort_by(|&a, &b_| {
|
||||||
|
compare_so(
|
||||||
|
&evaluated[a].evaluation,
|
||||||
|
&evaluated[b_].evaluation,
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
let old_mean = mean.clone();
|
||||||
|
let mut new_mean = vec![0.0_f64; n];
|
||||||
|
for k in 0..mu {
|
||||||
|
let xk = &x_samples[order[k]];
|
||||||
|
let wk = weights[k];
|
||||||
|
for i in 0..n {
|
||||||
|
new_mean[i] += wk * xk[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mean = new_mean;
|
||||||
|
|
||||||
|
let mut z_weighted = vec![0.0_f64; n];
|
||||||
|
for k in 0..mu {
|
||||||
|
let zk = &z_samples[order[k]];
|
||||||
|
let wk = weights[k];
|
||||||
|
for i in 0..n {
|
||||||
|
z_weighted[i] += wk * zk[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let factor_p_sigma = (c_sigma * (2.0 - c_sigma) * mu_eff).sqrt();
|
||||||
|
let bz: Vec<f64> = (0..n)
|
||||||
|
.map(|i| (0..n).map(|j| b[i][j] * z_weighted[j]).sum::<f64>())
|
||||||
|
.collect();
|
||||||
|
for i in 0..n {
|
||||||
|
p_sigma[i] = (1.0 - c_sigma) * p_sigma[i] + factor_p_sigma * bz[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_sigma_norm = p_sigma.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||||
|
sigma *= ((c_sigma / d_sigma) * (p_sigma_norm / chi_n - 1.0)).exp();
|
||||||
|
|
||||||
|
let h_sigma = if p_sigma_norm
|
||||||
|
/ (1.0 - (1.0 - c_sigma).powi(2 * (generation as i32 + 1))).sqrt()
|
||||||
|
< (1.4 + 2.0 / (n_f + 1.0)) * chi_n
|
||||||
|
{
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
|
||||||
|
let factor_p_c = h_sigma * (c_c * (2.0 - c_c) * mu_eff).sqrt();
|
||||||
|
for i in 0..n {
|
||||||
|
p_c[i] = (1.0 - c_c) * p_c[i] + factor_p_c * (mean[i] - old_mean[i]) / sigma;
|
||||||
|
}
|
||||||
|
|
||||||
|
let delta_h = (1.0 - h_sigma) * c_c * (2.0 - c_c);
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for i in 0..n {
|
||||||
|
for j in 0..n {
|
||||||
|
let mut update = (1.0 - c_1 - c_mu) * c_matrix[i][j]
|
||||||
|
+ c_1 * (p_c[i] * p_c[j] + delta_h * c_matrix[i][j]);
|
||||||
|
let mut rank_mu_term = 0.0;
|
||||||
|
for k in 0..mu {
|
||||||
|
let xk = &x_samples[order[k]];
|
||||||
|
let yi = (xk[i] - old_mean[i]) / sigma;
|
||||||
|
let yj = (xk[j] - old_mean[j]) / sigma;
|
||||||
|
rank_mu_term += weights[k] * yi * yj;
|
||||||
|
}
|
||||||
|
update += c_mu * rank_mu_term;
|
||||||
|
c_matrix[i][j] = update;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, m) in mean.iter_mut().enumerate() {
|
||||||
|
let (lo, hi) = self.bounds.bounds[i];
|
||||||
|
*m = m.clamp(lo, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_candidate_seen.expect("at least one generation evaluated");
|
||||||
|
let final_pop = vec![best.clone()];
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
let best_opt = best_candidate(&final_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best_opt,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn compare_so(
|
fn compare_so(
|
||||||
a: &crate::core::evaluation::Evaluation,
|
a: &crate::core::evaluation::Evaluation,
|
||||||
b: &crate::core::evaluation::Evaluation,
|
b: &crate::core::evaluation::Evaluation,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use rand::Rng as _;
|
use rand::Rng as _;
|
||||||
|
|
||||||
use crate::algorithms::parallel_eval::evaluate_batch;
|
use crate::algorithms::parallel_eval::evaluate_batch;
|
||||||
|
use crate::core::candidate::Candidate;
|
||||||
use crate::core::objective::Direction;
|
use crate::core::objective::Direction;
|
||||||
use crate::core::population::Population;
|
use crate::core::population::Population;
|
||||||
use crate::core::problem::Problem;
|
use crate::core::problem::Problem;
|
||||||
@@ -94,16 +95,6 @@ where
|
|||||||
P: Problem<Decision = Vec<f64>> + Sync,
|
P: Problem<Decision = Vec<f64>> + Sync,
|
||||||
{
|
{
|
||||||
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
||||||
self.run_with(problem, &mut ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
O: crate::observer::Observer<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::observer::Snapshot;
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
self.config.population_size >= 4,
|
self.config.population_size >= 4,
|
||||||
"DifferentialEvolution requires population_size >= 4 (DE/rand/1 needs three distinct donors plus the target)",
|
"DifferentialEvolution requires population_size >= 4 (DE/rand/1 needs three distinct donors plus the target)",
|
||||||
@@ -119,7 +110,6 @@ where
|
|||||||
"DifferentialEvolution only supports single-objective problems",
|
"DifferentialEvolution only supports single-objective problems",
|
||||||
);
|
);
|
||||||
let direction = objectives.objectives[0].direction;
|
let direction = objectives.objectives[0].direction;
|
||||||
let started = std::time::Instant::now();
|
|
||||||
|
|
||||||
let dim = self.bounds.bounds.len();
|
let dim = self.bounds.bounds.len();
|
||||||
let n = self.config.population_size;
|
let n = self.config.population_size;
|
||||||
@@ -132,39 +122,12 @@ where
|
|||||||
};
|
};
|
||||||
let initial_pop = evaluate_batch(problem, decisions.clone());
|
let initial_pop = evaluate_batch(problem, decisions.clone());
|
||||||
let mut evaluations = initial_pop.len();
|
let mut evaluations = initial_pop.len();
|
||||||
let mut current_pop = initial_pop;
|
let mut evals: Vec<f64> = initial_pop
|
||||||
let mut evals: Vec<f64> = current_pop
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.evaluation.objectives[0])
|
.map(|c| c.evaluation.objectives[0])
|
||||||
.collect();
|
.collect();
|
||||||
let mut completed_generations: usize = 0;
|
|
||||||
|
|
||||||
// Initial snapshot.
|
for _gen in 0..self.config.generations {
|
||||||
{
|
|
||||||
let best = best_candidate(¤t_pop, &objectives);
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration: 0,
|
|
||||||
evaluations,
|
|
||||||
elapsed: started.elapsed(),
|
|
||||||
population: ¤t_pop,
|
|
||||||
pareto_front: None,
|
|
||||||
best: best.as_ref(),
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
|
||||||
let front = pareto_front(¤t_pop, &objectives);
|
|
||||||
let best = best_candidate(¤t_pop, &objectives);
|
|
||||||
return OptimizationResult::new(
|
|
||||||
Population::new(current_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
completed_generations,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for generation in 1..=self.config.generations {
|
|
||||||
// Phase 1 (serial): construct one trial per target. RNG state is
|
// Phase 1 (serial): construct one trial per target. RNG state is
|
||||||
// consumed in deterministic order so seeded runs reproduce
|
// consumed in deterministic order so seeded runs reproduce
|
||||||
// exactly regardless of the `parallel` feature.
|
// exactly regardless of the `parallel` feature.
|
||||||
@@ -193,6 +156,107 @@ where
|
|||||||
evaluations += trial_cands.len();
|
evaluations += trial_cands.len();
|
||||||
|
|
||||||
// Phase 3 (serial): greedy replacement.
|
// Phase 3 (serial): greedy replacement.
|
||||||
|
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
||||||
|
let trial_obj = trial_cand.evaluation.objectives[0];
|
||||||
|
let target_obj = evals[i];
|
||||||
|
let trial_better = match direction {
|
||||||
|
Direction::Minimize => trial_obj <= target_obj,
|
||||||
|
Direction::Maximize => trial_obj >= target_obj,
|
||||||
|
};
|
||||||
|
if trial_better {
|
||||||
|
decisions[i] = trial_cand.decision;
|
||||||
|
evals[i] = trial_obj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions);
|
||||||
|
evaluations += final_pop.len();
|
||||||
|
let front = pareto_front(&final_pop, &objectives);
|
||||||
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl DifferentialEvolution {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch (initial
|
||||||
|
/// population and per-generation trials).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use rand::Rng as _;
|
||||||
|
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
use crate::traits::Initializer as _;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 4,
|
||||||
|
"DifferentialEvolution requires population_size >= 4",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(0.0..=1.0).contains(&self.config.crossover_probability),
|
||||||
|
"DifferentialEvolution crossover_probability must be in [0.0, 1.0]",
|
||||||
|
);
|
||||||
|
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"DifferentialEvolution only supports single-objective problems",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut decisions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
|
||||||
|
let initial_pop = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
||||||
|
let mut evaluations = initial_pop.len();
|
||||||
|
let mut current_pop = initial_pop;
|
||||||
|
let mut evals: Vec<f64> = current_pop
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.evaluation.objectives[0])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for _generation in 0..self.config.generations {
|
||||||
|
let trials: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let (r1, r2, r3) = pick_three_distinct(n, i, &mut rng);
|
||||||
|
let j_rand = rng.random_range(0..dim);
|
||||||
|
let mut trial = decisions[i].clone();
|
||||||
|
for j in 0..dim {
|
||||||
|
let take_donor =
|
||||||
|
rng.random_bool(self.config.crossover_probability) || j == j_rand;
|
||||||
|
if take_donor {
|
||||||
|
let mutant = decisions[r1][j]
|
||||||
|
+ self.config.differential_weight
|
||||||
|
* (decisions[r2][j] - decisions[r3][j]);
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
trial[j] = mutant.clamp(lo, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trial
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let trial_cands: Vec<Candidate<Vec<f64>>> =
|
||||||
|
evaluate_batch_async(problem, trials, concurrency).await;
|
||||||
|
evaluations += trial_cands.len();
|
||||||
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
||||||
let trial_obj = trial_cand.evaluation.objectives[0];
|
let trial_obj = trial_cand.evaluation.objectives[0];
|
||||||
let target_obj = evals[i];
|
let target_obj = evals[i];
|
||||||
@@ -206,25 +270,8 @@ where
|
|||||||
current_pop[i] = trial_cand;
|
current_pop[i] = trial_cand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
completed_generations = generation;
|
|
||||||
|
|
||||||
// Per-generation snapshot.
|
|
||||||
let best = best_candidate(¤t_pop, &objectives);
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration: generation,
|
|
||||||
evaluations,
|
|
||||||
elapsed: started.elapsed(),
|
|
||||||
population: ¤t_pop,
|
|
||||||
pareto_front: None,
|
|
||||||
best: best.as_ref(),
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-evaluate to make sure final population is consistent (current_pop is already current).
|
|
||||||
let front = pareto_front(¤t_pop, &objectives);
|
let front = pareto_front(¤t_pop, &objectives);
|
||||||
let best = best_candidate(¤t_pop, &objectives);
|
let best = best_candidate(¤t_pop, &objectives);
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(
|
||||||
@@ -232,7 +279,7 @@ where
|
|||||||
front,
|
front,
|
||||||
best,
|
best,
|
||||||
evaluations,
|
evaluations,
|
||||||
completed_generations,
|
self.config.generations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,98 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> EpsilonMoea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations of the initial
|
||||||
|
/// population. Per-step evaluations are sequential because the
|
||||||
|
/// algorithm is steady-state (one offspring per step).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"EpsilonMoea population_size must be > 0"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert_eq!(
|
||||||
|
self.config.epsilon.len(),
|
||||||
|
objectives.len(),
|
||||||
|
"EpsilonMoea epsilon.len() must equal number of objectives",
|
||||||
|
);
|
||||||
|
for (i, &e) in self.config.epsilon.iter().enumerate() {
|
||||||
|
assert!(e > 0.0, "EpsilonMoea epsilon[{i}] must be > 0.0");
|
||||||
|
}
|
||||||
|
let epsilon = self.config.epsilon.clone();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
|
||||||
|
for c in &population {
|
||||||
|
insert_into_epsilon_archive(&mut archive, c.clone(), &objectives, &epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_evals = self.config.evaluations.max(evaluations);
|
||||||
|
while evaluations < total_evals {
|
||||||
|
let p1_idx = rng.random_range(0..population.len());
|
||||||
|
let parent_a = population[p1_idx].decision.clone();
|
||||||
|
let parent_b = if !archive.is_empty() {
|
||||||
|
let j = rng.random_range(0..archive.len());
|
||||||
|
archive[j].decision.clone()
|
||||||
|
} else {
|
||||||
|
let j = rng.random_range(0..population.len());
|
||||||
|
population[j].decision.clone()
|
||||||
|
};
|
||||||
|
let parents = vec![parent_a, parent_b];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"EpsilonMoea variation returned no children"
|
||||||
|
);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
let child = Candidate::new(child_decision, child_eval);
|
||||||
|
|
||||||
|
update_population(&mut population, &child, &objectives, &mut rng);
|
||||||
|
|
||||||
|
insert_into_epsilon_archive(&mut archive, child, &objectives, &epsilon);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_pop: Vec<Candidate<P::Decision>> = if !archive.is_empty() {
|
||||||
|
archive.clone()
|
||||||
|
} else {
|
||||||
|
population
|
||||||
|
};
|
||||||
|
let front = pareto_front(&final_pop, &objectives);
|
||||||
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.evaluations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Standard ε-MOEA population update: if the child is dominated by some
|
/// Standard ε-MOEA population update: if the child is dominated by some
|
||||||
/// member, drop it; if it dominates a member, replace that member; if
|
/// member, drop it; if it dominates a member, replace that member; if
|
||||||
/// non-dominated wrt all, replace a random member.
|
/// non-dominated wrt all, replace a random member.
|
||||||
|
|||||||
@@ -181,6 +181,94 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> GeneticAlgorithm<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch (initial
|
||||||
|
/// population and per-generation offspring).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 2,
|
||||||
|
"GeneticAlgorithm population_size must be >= 2",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.tournament_size >= 1,
|
||||||
|
"GeneticAlgorithm tournament_size must be >= 1",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.elitism < self.config.population_size,
|
||||||
|
"GeneticAlgorithm elitism must be < population_size",
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"GeneticAlgorithm requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let parents_decisions = tournament_select_single_objective(
|
||||||
|
&population,
|
||||||
|
&objectives,
|
||||||
|
self.config.tournament_size,
|
||||||
|
2,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
let children = self.variation.vary(&parents_decisions, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"GeneticAlgorithm variation returned no children"
|
||||||
|
);
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
population =
|
||||||
|
survival_selection(&population, offspring, direction, n, self.config.elitism);
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
let front: Vec<Candidate<P::Decision>> = best.iter().cloned().collect();
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn survival_selection<D: Clone>(
|
fn survival_selection<D: Clone>(
|
||||||
parents: &[Candidate<D>],
|
parents: &[Candidate<D>],
|
||||||
offspring: Vec<Candidate<D>>,
|
offspring: Vec<Candidate<D>>,
|
||||||
|
|||||||
@@ -160,6 +160,82 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Grea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Grea population_size must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.grid_divisions >= 1,
|
||||||
|
"Grea grid_divisions must be >= 1"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "Grea variation returned no children");
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
population =
|
||||||
|
environmental_selection(combined, &objectives, n, self.config.grid_divisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -146,6 +146,84 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> HillClimber<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` is mostly inert here because HillClimber evaluates
|
||||||
|
/// one child per iteration; it's accepted for API parity with other
|
||||||
|
/// algorithms.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
let _ = concurrency;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"HillClimber requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut initial = self.initializer.initialize(1, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!initial.is_empty(),
|
||||||
|
"HillClimber initializer returned no decisions"
|
||||||
|
);
|
||||||
|
let mut current_decision = initial.remove(0);
|
||||||
|
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
||||||
|
let mut evaluations = 1usize;
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let parents = vec![current_decision.clone()];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"HillClimber variation returned no children"
|
||||||
|
);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
let child_better = match (child_eval.is_feasible(), current_eval.is_feasible()) {
|
||||||
|
(true, false) => true,
|
||||||
|
(false, true) => false,
|
||||||
|
(false, false) => {
|
||||||
|
child_eval.constraint_violation < current_eval.constraint_violation
|
||||||
|
}
|
||||||
|
(true, true) => match direction {
|
||||||
|
Direction::Minimize => child_eval.objectives[0] < current_eval.objectives[0],
|
||||||
|
Direction::Maximize => child_eval.objectives[0] > current_eval.objectives[0],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if child_better {
|
||||||
|
current_decision = child_decision;
|
||||||
|
current_eval = child_eval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = Candidate::new(current_decision, current_eval);
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -226,6 +226,131 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Hype<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Hype population_size must be > 0"
|
||||||
|
);
|
||||||
|
assert!(self.config.mc_samples > 0, "Hype mc_samples must be > 0");
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert_eq!(
|
||||||
|
self.config.reference_point.len(),
|
||||||
|
objectives.len(),
|
||||||
|
"Hype reference_point.len() must equal number of objectives",
|
||||||
|
);
|
||||||
|
let reference = self.config.reference_point.clone();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let fitness = hype_fitness(
|
||||||
|
&population,
|
||||||
|
&objectives,
|
||||||
|
&reference,
|
||||||
|
self.config.mc_samples,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = binary_tournament(&fitness, &mut rng);
|
||||||
|
let p2 = binary_tournament(&fitness, &mut rng);
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "Hype variation returned no children");
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
|
||||||
|
let fronts = non_dominated_sort(&combined, &objectives);
|
||||||
|
let mut keep_indices: Vec<usize> = Vec::with_capacity(n);
|
||||||
|
let mut splitting: &[usize] = &[];
|
||||||
|
for f in &fronts {
|
||||||
|
if keep_indices.len() + f.len() <= n {
|
||||||
|
keep_indices.extend(f.iter().copied());
|
||||||
|
} else {
|
||||||
|
splitting = f;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if keep_indices.len() == n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keep_indices.len() < n {
|
||||||
|
let pool: Vec<&Candidate<P::Decision>> =
|
||||||
|
splitting.iter().map(|&i| &combined[i]).collect();
|
||||||
|
let contributions = estimate_contributions(
|
||||||
|
&pool,
|
||||||
|
&objectives,
|
||||||
|
&reference,
|
||||||
|
self.config.mc_samples,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
let mut order: Vec<usize> = (0..splitting.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
contributions[b]
|
||||||
|
.partial_cmp(&contributions[a])
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
for k in order.into_iter().take(n - keep_indices.len()) {
|
||||||
|
keep_indices.push(splitting[k]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
population = keep_indices
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| combined[i].clone())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn hype_fitness<D>(
|
fn hype_fitness<D>(
|
||||||
pool: &[Candidate<D>],
|
pool: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -206,6 +206,106 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, D> Hyperband<I, D>
|
||||||
|
where
|
||||||
|
D: Clone,
|
||||||
|
I: Initializer<D>,
|
||||||
|
{
|
||||||
|
/// Async version of [`Hyperband::run`] — evaluates each
|
||||||
|
/// Successive-Halving rung's configurations concurrently through the
|
||||||
|
/// caller's async runtime. Available only with the `async` feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per rung.
|
||||||
|
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncPartialProblem<Decision = D>,
|
||||||
|
D: Send + Sync,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_at_budget_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.max_budget > 0.0,
|
||||||
|
"Hyperband max_budget must be > 0"
|
||||||
|
);
|
||||||
|
assert!(self.config.eta > 1.0, "Hyperband eta must be > 1");
|
||||||
|
assert!(
|
||||||
|
self.config.max_brackets >= 1,
|
||||||
|
"Hyperband max_brackets must be >= 1"
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"Hyperband requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let s_max = (self.config.max_budget.ln() / self.config.eta.ln()).floor() as i64;
|
||||||
|
let s_max = (s_max as usize).min(self.config.max_brackets);
|
||||||
|
|
||||||
|
let mut total_evaluations = 0usize;
|
||||||
|
let mut total_iterations = 0usize;
|
||||||
|
let mut best_seen: Option<Candidate<D>> = None;
|
||||||
|
|
||||||
|
for s in (0..=s_max).rev() {
|
||||||
|
let s_f = s as f64;
|
||||||
|
let n =
|
||||||
|
((s_max as f64 + 1.0) / (s_f + 1.0) * self.config.eta.powf(s_f)).ceil() as usize;
|
||||||
|
let r = self.config.max_budget / self.config.eta.powf(s_f);
|
||||||
|
|
||||||
|
let mut configs: Vec<D> = self.initializer.initialize(n, &mut rng);
|
||||||
|
for i in 0..=s {
|
||||||
|
let n_i = (n as f64 / self.config.eta.powi(i as i32)).floor() as usize;
|
||||||
|
let r_i = r * self.config.eta.powi(i as i32);
|
||||||
|
if configs.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let evals: Vec<Evaluation> =
|
||||||
|
evaluate_batch_at_budget_async(problem, &configs, r_i, concurrency).await;
|
||||||
|
total_evaluations += configs.len();
|
||||||
|
|
||||||
|
for (cfg, e) in configs.iter().zip(evals.iter()) {
|
||||||
|
let beats = match &best_seen {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better(e, &b.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats {
|
||||||
|
best_seen = Some(Candidate::new(cfg.clone(), e.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
total_iterations += 1;
|
||||||
|
|
||||||
|
let next_size = (n_i / self.config.eta as usize).max(1);
|
||||||
|
if next_size >= configs.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut order: Vec<usize> = (0..configs.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
||||||
|
let keep: std::collections::HashSet<usize> =
|
||||||
|
order.into_iter().take(next_size).collect();
|
||||||
|
let new_configs: Vec<D> = configs
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, c)| if keep.contains(&idx) { Some(c) } else { None })
|
||||||
|
.collect();
|
||||||
|
configs = new_configs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_seen.expect("at least one bracket ran");
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
total_evaluations,
|
||||||
|
total_iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering {
|
fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering {
|
||||||
match (a.is_feasible(), b.is_feasible()) {
|
match (a.is_feasible(), b.is_feasible()) {
|
||||||
(true, false) => std::cmp::Ordering::Less,
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
|
|||||||
@@ -159,6 +159,80 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Ibea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Ibea population_size must be > 0"
|
||||||
|
);
|
||||||
|
assert!(self.config.kappa > 0.0, "Ibea kappa must be > 0");
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let fitness = compute_fitness(&population, &objectives, self.config.kappa);
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = binary_tournament(&fitness, &mut rng);
|
||||||
|
let p2 = binary_tournament(&fitness, &mut rng);
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "Ibea variation returned no children");
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
population = environmental_selection(combined, &objectives, n, self.config.kappa);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Iteratively remove the worst-fitness member from `pool` until `n` remain.
|
/// Iteratively remove the worst-fitness member from `pool` until `n` remain.
|
||||||
///
|
///
|
||||||
/// IBEA's standard "subtract the dropped member's contribution from every
|
/// IBEA's standard "subtract the dropped member's contribution from every
|
||||||
|
|||||||
@@ -187,6 +187,94 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl IpopCmaEs {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations within each restart's
|
||||||
|
/// CMA-ES generation.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
self.config.initial_population_size >= 4,
|
||||||
|
"IpopCmaEs initial_population_size must be >= 4",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"IpopCmaEs requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut remaining_gens = self.config.total_generations;
|
||||||
|
let mut pop_size = self.config.initial_population_size;
|
||||||
|
let mut total_evaluations = 0usize;
|
||||||
|
let mut total_iterations = 0usize;
|
||||||
|
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
|
||||||
|
let _ = self.config.stall_generations;
|
||||||
|
|
||||||
|
let mut restart_counter = 0u64;
|
||||||
|
while remaining_gens > 0 {
|
||||||
|
let this_gens = (remaining_gens / 2).max(20).min(remaining_gens);
|
||||||
|
let inner_seed = self
|
||||||
|
.config
|
||||||
|
.seed
|
||||||
|
.wrapping_add(restart_counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
||||||
|
let restart_mean: Vec<f64> = self
|
||||||
|
.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| lo + (hi - lo) * rng.random::<f64>())
|
||||||
|
.collect();
|
||||||
|
let cfg = CmaEsConfig {
|
||||||
|
population_size: pop_size,
|
||||||
|
generations: this_gens,
|
||||||
|
initial_sigma: self.config.initial_sigma,
|
||||||
|
eigen_decomposition_period: self.config.eigen_decomposition_period,
|
||||||
|
initial_mean: Some(restart_mean),
|
||||||
|
seed: inner_seed,
|
||||||
|
};
|
||||||
|
let mut inner = CmaEs::new(cfg, RealBounds::new(self.bounds.bounds.clone()));
|
||||||
|
|
||||||
|
let result = inner.run_async(problem, concurrency).await;
|
||||||
|
total_evaluations += result.evaluations;
|
||||||
|
total_iterations += result.generations;
|
||||||
|
if let Some(b) = result.best.clone() {
|
||||||
|
let beats = match &best_seen {
|
||||||
|
None => true,
|
||||||
|
Some(prev) => better(&b.evaluation, &prev.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats {
|
||||||
|
best_seen = Some(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining_gens = remaining_gens.saturating_sub(this_gens);
|
||||||
|
pop_size = pop_size.saturating_mul(2);
|
||||||
|
restart_counter = restart_counter.wrapping_add(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_seen.expect("at least one restart ran");
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
total_evaluations,
|
||||||
|
total_iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
||||||
match (a.is_feasible(), b.is_feasible()) {
|
match (a.is_feasible(), b.is_feasible()) {
|
||||||
(true, false) => true,
|
(true, false) => true,
|
||||||
|
|||||||
@@ -149,6 +149,77 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Knea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Knea population_size must be > 0"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "Knea variation returned no children");
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
population = environmental_selection(combined, &objectives, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub mod nsga3;
|
|||||||
pub mod one_plus_one_es;
|
pub mod one_plus_one_es;
|
||||||
pub mod paes;
|
pub mod paes;
|
||||||
pub(crate) mod parallel_eval;
|
pub(crate) mod parallel_eval;
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
pub(crate) mod parallel_eval_async;
|
||||||
pub mod particle_swarm;
|
pub mod particle_swarm;
|
||||||
pub mod pesa2;
|
pub mod pesa2;
|
||||||
pub mod random_search;
|
pub mod random_search;
|
||||||
|
|||||||
@@ -219,6 +219,126 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Moead<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations of the initial
|
||||||
|
/// population. Per-generation evaluations are sequential because
|
||||||
|
/// each child's outcome feeds back into the same generation's
|
||||||
|
/// neighborhood updates.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let m = objectives.len();
|
||||||
|
let weights = das_dennis(m, self.config.reference_divisions);
|
||||||
|
assert!(
|
||||||
|
!weights.is_empty(),
|
||||||
|
"Moead weight set is empty — increase reference_divisions",
|
||||||
|
);
|
||||||
|
let n = weights.len();
|
||||||
|
let t = self.config.neighborhood_size.min(n);
|
||||||
|
assert!(t >= 2, "Moead neighborhood_size must be >= 2");
|
||||||
|
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
assert_eq!(
|
||||||
|
initial_decisions.len(),
|
||||||
|
n,
|
||||||
|
"MOEA/D initializer must return exactly {n} decisions",
|
||||||
|
);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
let mut ideal = vec![f64::INFINITY; m];
|
||||||
|
for c in &population {
|
||||||
|
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
||||||
|
for (k, v) in oriented.iter().enumerate() {
|
||||||
|
if *v < ideal[k] {
|
||||||
|
ideal[k] = *v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let neighborhoods: Vec<Vec<usize>> = (0..n)
|
||||||
|
.map(|i| {
|
||||||
|
let mut idx: Vec<usize> = (0..n).collect();
|
||||||
|
idx.sort_by(|&a, &b| {
|
||||||
|
let da = weight_distance(&weights[i], &weights[a]);
|
||||||
|
let db = weight_distance(&weights[i], &weights[b]);
|
||||||
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
idx.into_iter().take(t).collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for i in 0..n {
|
||||||
|
let nbh = &neighborhoods[i];
|
||||||
|
let p1 = *nbh.choose(&mut rng).unwrap();
|
||||||
|
let mut p2 = *nbh.choose(&mut rng).unwrap();
|
||||||
|
while p2 == p1 && nbh.len() > 1 {
|
||||||
|
p2 = *nbh.choose(&mut rng).unwrap();
|
||||||
|
}
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"MOEA/D variation returned no children"
|
||||||
|
);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
let oriented_child = objectives.as_minimization(&child_eval.objectives);
|
||||||
|
for (k, v) in oriented_child.iter().enumerate() {
|
||||||
|
if *v < ideal[k] {
|
||||||
|
ideal[k] = *v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for &j in nbh {
|
||||||
|
let cur_oriented =
|
||||||
|
objectives.as_minimization(&population[j].evaluation.objectives);
|
||||||
|
let g_cur = tchebycheff(&cur_oriented, &weights[j], &ideal);
|
||||||
|
let g_new = tchebycheff(&oriented_child, &weights[j], &ideal);
|
||||||
|
if g_new <= g_cur {
|
||||||
|
population[j] = Candidate::new(child_decision.clone(), child_eval.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Tchebycheff scalarization: `max_k w_k * |f_k - z*_k|`.
|
/// Tchebycheff scalarization: `max_k w_k * |f_k - z*_k|`.
|
||||||
///
|
///
|
||||||
/// `weight` components that are zero are floored to `1e-6` so every axis
|
/// `weight` components that are zero are floored to `1e-6` so every axis
|
||||||
|
|||||||
@@ -212,6 +212,124 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl Mopso {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
use crate::traits::Initializer as _;
|
||||||
|
|
||||||
|
assert!(self.config.swarm_size >= 1, "Mopso swarm_size must be >= 1");
|
||||||
|
assert!(
|
||||||
|
self.config.archive_size >= 1,
|
||||||
|
"Mopso archive_size must be >= 1"
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_multi_objective(),
|
||||||
|
"Mopso requires multi-objective problems (use ParticleSwarm for single-objective)",
|
||||||
|
);
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
let n = self.config.swarm_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut positions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
|
||||||
|
let mut velocities: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| {
|
||||||
|
self.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
|
||||||
|
|
||||||
|
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
||||||
|
let mut evaluations = initial_pop.len();
|
||||||
|
|
||||||
|
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
|
||||||
|
let mut pbest_evals: Vec<crate::core::evaluation::Evaluation> =
|
||||||
|
initial_pop.iter().map(|c| c.evaluation.clone()).collect();
|
||||||
|
|
||||||
|
let mut archive = ParetoArchive::new(objectives.clone());
|
||||||
|
for c in initial_pop {
|
||||||
|
archive.insert(c);
|
||||||
|
}
|
||||||
|
archive.truncate(self.config.archive_size);
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
for i in 0..n {
|
||||||
|
let leader = archive
|
||||||
|
.members()
|
||||||
|
.choose(&mut rng)
|
||||||
|
.map(|c| c.decision.clone())
|
||||||
|
.unwrap_or_else(|| positions[i].clone());
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for j in 0..dim {
|
||||||
|
let r1: f64 = rng.random();
|
||||||
|
let r2: f64 = rng.random();
|
||||||
|
let cognitive_term =
|
||||||
|
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
|
||||||
|
let social_term = self.config.social * r2 * (leader[j] - positions[i][j]);
|
||||||
|
let mut v =
|
||||||
|
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
|
||||||
|
if v > v_max[j] {
|
||||||
|
v = v_max[j];
|
||||||
|
} else if v < -v_max[j] {
|
||||||
|
v = -v_max[j];
|
||||||
|
}
|
||||||
|
velocities[i][j] = v;
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
||||||
|
evaluations += evaluated.len();
|
||||||
|
|
||||||
|
for (i, cand) in evaluated.iter().enumerate() {
|
||||||
|
let dominance = pareto_compare(&cand.evaluation, &pbest_evals[i], &objectives);
|
||||||
|
let replace = match dominance {
|
||||||
|
Dominance::Dominates => true,
|
||||||
|
Dominance::DominatedBy => false,
|
||||||
|
Dominance::Equal | Dominance::NonDominated => rng.random_bool(0.5),
|
||||||
|
};
|
||||||
|
if replace {
|
||||||
|
pbest_decisions[i] = cand.decision.clone();
|
||||||
|
pbest_evals[i] = cand.evaluation.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in evaluated {
|
||||||
|
archive.insert(c);
|
||||||
|
}
|
||||||
|
archive.truncate(self.config.archive_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
let members = archive.into_vec();
|
||||||
|
let front = pareto_front(&members, &objectives);
|
||||||
|
let best = best_candidate(&members, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(members),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -295,6 +295,161 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
compare(a, b, direction) == std::cmp::Ordering::Less
|
compare(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl NelderMead {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` is largely inert here because Nelder-Mead
|
||||||
|
/// evaluates one or two new vertices per iteration sequentially
|
||||||
|
/// (the next decision depends on the previous evaluation); it's
|
||||||
|
/// accepted for API parity with other algorithms.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
let _ = concurrency;
|
||||||
|
assert!(
|
||||||
|
self.config.reflection > 0.0,
|
||||||
|
"NelderMead reflection must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.expansion > 1.0,
|
||||||
|
"NelderMead expansion must be > 1",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.contraction > 0.0 && self.config.contraction < 1.0,
|
||||||
|
"NelderMead contraction must be in (0, 1)",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.shrinkage > 0.0 && self.config.shrinkage < 1.0,
|
||||||
|
"NelderMead shrinkage must be in (0, 1)",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.initial_step > 0.0,
|
||||||
|
"NelderMead initial_step must be > 0",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"NelderMead requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let n = self.bounds.bounds.len();
|
||||||
|
|
||||||
|
let mut vertices: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
|
||||||
|
let start: Vec<f64> = self
|
||||||
|
.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
||||||
|
.collect();
|
||||||
|
vertices.push(start.clone());
|
||||||
|
for j in 0..n {
|
||||||
|
let mut v = start.clone();
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
let step = self.config.initial_step.min(0.5 * (hi - lo));
|
||||||
|
v[j] = (v[j] + step).clamp(lo, hi);
|
||||||
|
vertices.push(v);
|
||||||
|
}
|
||||||
|
let mut evals: Vec<Evaluation> = Vec::with_capacity(vertices.len());
|
||||||
|
for v in &vertices {
|
||||||
|
evals.push(problem.evaluate_async(v).await);
|
||||||
|
}
|
||||||
|
let mut evaluations = evals.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let mut order: Vec<usize> = (0..vertices.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
||||||
|
let best_idx = order[0];
|
||||||
|
let worst_idx = order[order.len() - 1];
|
||||||
|
let second_worst_idx = order[order.len() - 2];
|
||||||
|
|
||||||
|
let mut centroid = vec![0.0_f64; n];
|
||||||
|
for &idx in &order[..order.len() - 1] {
|
||||||
|
for j in 0..n {
|
||||||
|
centroid[j] += vertices[idx][j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for c in centroid.iter_mut() {
|
||||||
|
*c /= (order.len() - 1) as f64;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reflected = self.reflect(¢roid, &vertices[worst_idx], self.config.reflection);
|
||||||
|
let r_eval = problem.evaluate_async(&reflected).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
if better(&r_eval, &evals[best_idx], direction) {
|
||||||
|
let expanded = self.reflect(¢roid, &vertices[worst_idx], self.config.expansion);
|
||||||
|
let e_eval = problem.evaluate_async(&expanded).await;
|
||||||
|
evaluations += 1;
|
||||||
|
if better(&e_eval, &r_eval, direction) {
|
||||||
|
vertices[worst_idx] = expanded;
|
||||||
|
evals[worst_idx] = e_eval;
|
||||||
|
} else {
|
||||||
|
vertices[worst_idx] = reflected;
|
||||||
|
evals[worst_idx] = r_eval;
|
||||||
|
}
|
||||||
|
} else if better(&r_eval, &evals[second_worst_idx], direction) {
|
||||||
|
vertices[worst_idx] = reflected;
|
||||||
|
evals[worst_idx] = r_eval;
|
||||||
|
} else {
|
||||||
|
let contraction_target = if better(&r_eval, &evals[worst_idx], direction) {
|
||||||
|
self.contract(¢roid, &reflected, self.config.contraction)
|
||||||
|
} else {
|
||||||
|
self.contract(¢roid, &vertices[worst_idx], self.config.contraction)
|
||||||
|
};
|
||||||
|
let c_eval = problem.evaluate_async(&contraction_target).await;
|
||||||
|
evaluations += 1;
|
||||||
|
if better(&c_eval, &evals[worst_idx], direction) {
|
||||||
|
vertices[worst_idx] = contraction_target;
|
||||||
|
evals[worst_idx] = c_eval;
|
||||||
|
} else {
|
||||||
|
let best_pt = vertices[best_idx].clone();
|
||||||
|
for &idx in &order {
|
||||||
|
if idx == best_idx {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for j in 0..n {
|
||||||
|
vertices[idx][j] = best_pt[j]
|
||||||
|
+ self.config.shrinkage * (vertices[idx][j] - best_pt[j]);
|
||||||
|
}
|
||||||
|
for (j, x) in vertices[idx].iter_mut().enumerate() {
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
*x = x.clamp(lo, hi);
|
||||||
|
}
|
||||||
|
evals[idx] = problem.evaluate_async(&vertices[idx]).await;
|
||||||
|
evaluations += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best_idx = 0;
|
||||||
|
for i in 1..vertices.len() {
|
||||||
|
if better(&evals[i], &evals[best_idx], direction) {
|
||||||
|
best_idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let best = Candidate::new(vertices[best_idx].clone(), evals[best_idx].clone());
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+119
-64
@@ -108,16 +108,6 @@ where
|
|||||||
V: Variation<P::Decision>,
|
V: Variation<P::Decision>,
|
||||||
{
|
{
|
||||||
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
||||||
self.run_with(problem, &mut ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
O: crate::observer::Observer<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::observer::Snapshot;
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
self.config.population_size > 0,
|
self.config.population_size > 0,
|
||||||
"Nsga2 population_size must be greater than 0",
|
"Nsga2 population_size must be greater than 0",
|
||||||
@@ -125,7 +115,6 @@ where
|
|||||||
let n = self.config.population_size;
|
let n = self.config.population_size;
|
||||||
let objectives = problem.objectives();
|
let objectives = problem.objectives();
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
let started = std::time::Instant::now();
|
|
||||||
|
|
||||||
// Initial population.
|
// Initial population.
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
@@ -141,27 +130,7 @@ where
|
|||||||
// round of tournament selection has data to compare on.
|
// round of tournament selection has data to compare on.
|
||||||
let mut annotated = annotate(population, &objectives);
|
let mut annotated = annotate(population, &objectives);
|
||||||
|
|
||||||
// Observer: notify after the initial population.
|
for _ in 0..self.config.generations {
|
||||||
let mut completed_generations: usize = 0;
|
|
||||||
let pop_view: Vec<Candidate<P::Decision>> =
|
|
||||||
annotated.iter().map(|e| e.candidate.clone()).collect();
|
|
||||||
let front_view = pareto_front(&pop_view, &objectives);
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration: 0,
|
|
||||||
evaluations,
|
|
||||||
elapsed: started.elapsed(),
|
|
||||||
population: &pop_view,
|
|
||||||
pareto_front: Some(&front_view),
|
|
||||||
best: None,
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
|
||||||
return finalize_nsga2(annotated, &objectives, evaluations, completed_generations);
|
|
||||||
}
|
|
||||||
drop(pop_view);
|
|
||||||
drop(front_view);
|
|
||||||
|
|
||||||
for generation in 1..=self.config.generations {
|
|
||||||
// --- Phase 1: serial parent selection + variation ---
|
// --- Phase 1: serial parent selection + variation ---
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
while offspring_decisions.len() < n {
|
while offspring_decisions.len() < n {
|
||||||
@@ -220,47 +189,22 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
annotated = annotate(next, &objectives);
|
annotated = annotate(next, &objectives);
|
||||||
completed_generations = generation;
|
|
||||||
|
|
||||||
// Per-generation observation.
|
|
||||||
let pop_view: Vec<Candidate<P::Decision>> =
|
|
||||||
annotated.iter().map(|e| e.candidate.clone()).collect();
|
|
||||||
let front_view = pareto_front(&pop_view, &objectives);
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration: generation,
|
|
||||||
evaluations,
|
|
||||||
elapsed: started.elapsed(),
|
|
||||||
population: &pop_view,
|
|
||||||
pareto_front: Some(&front_view),
|
|
||||||
best: None,
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
|
||||||
return finalize_nsga2(annotated, &objectives, evaluations, completed_generations);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
finalize_nsga2(annotated, &objectives, evaluations, self.config.generations)
|
// Return final state.
|
||||||
}
|
let final_pop: Vec<Candidate<P::Decision>> =
|
||||||
}
|
annotated.into_iter().map(|e| e.candidate).collect();
|
||||||
|
let front = pareto_front(&final_pop, &objectives);
|
||||||
fn finalize_nsga2<D: Clone>(
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
annotated: Vec<Nsga2Entry<D>>,
|
|
||||||
objectives: &crate::core::objective::ObjectiveSpace,
|
|
||||||
evaluations: usize,
|
|
||||||
generations: usize,
|
|
||||||
) -> OptimizationResult<D> {
|
|
||||||
let final_pop: Vec<Candidate<D>> = annotated.into_iter().map(|e| e.candidate).collect();
|
|
||||||
let front = pareto_front(&final_pop, objectives);
|
|
||||||
let best = best_candidate(&final_pop, objectives);
|
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(
|
||||||
Population::new(final_pop),
|
Population::new(final_pop),
|
||||||
front,
|
front,
|
||||||
best,
|
best,
|
||||||
evaluations,
|
evaluations,
|
||||||
generations,
|
self.config.generations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn annotate<D: Clone>(
|
fn annotate<D: Clone>(
|
||||||
population: Vec<Candidate<D>>,
|
population: Vec<Candidate<D>>,
|
||||||
@@ -288,6 +232,117 @@ fn annotate<D: Clone>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Nsga2<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch (initial
|
||||||
|
/// population and per-generation offspring).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Nsga2 population_size must be greater than 0",
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
assert_eq!(
|
||||||
|
initial_decisions.len(),
|
||||||
|
n,
|
||||||
|
"NSGA-II initializer must return exactly population_size decisions",
|
||||||
|
);
|
||||||
|
let population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
let mut annotated = annotate(population, &objectives);
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = binary_tournament(&annotated, &mut rng);
|
||||||
|
let p2 = binary_tournament(&annotated, &mut rng);
|
||||||
|
let parents = vec![
|
||||||
|
annotated[p1].candidate.decision.clone(),
|
||||||
|
annotated[p2].candidate.decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"NSGA-II variation returned no children",
|
||||||
|
);
|
||||||
|
for child_decision in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child_decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(annotated.into_iter().map(|e| e.candidate));
|
||||||
|
combined.extend(offspring);
|
||||||
|
|
||||||
|
let fronts = non_dominated_sort(&combined, &objectives);
|
||||||
|
let mut next: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
|
||||||
|
for front in &fronts {
|
||||||
|
if next.len() + front.len() <= n {
|
||||||
|
for &idx in front {
|
||||||
|
next.push(combined[idx].clone());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let dist = crowding_distance(&combined, front, &objectives);
|
||||||
|
let mut order: Vec<usize> = (0..front.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
dist[b]
|
||||||
|
.partial_cmp(&dist[a])
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
let needed = n - next.len();
|
||||||
|
for &k in order.iter().take(needed) {
|
||||||
|
next.push(combined[front[k]].clone());
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if next.len() == n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
annotated = annotate(next, &objectives);
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_pop: Vec<Candidate<P::Decision>> =
|
||||||
|
annotated.into_iter().map(|e| e.candidate).collect();
|
||||||
|
let front = pareto_front(&final_pop, &objectives);
|
||||||
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
|
fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
|
||||||
let n = entries.len();
|
let n = entries.len();
|
||||||
let a = rng.random_range(0..n);
|
let a = rng.random_range(0..n);
|
||||||
|
|||||||
@@ -180,6 +180,92 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Nsga3<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Nsga3 population_size must be greater than 0",
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let m = objectives.len();
|
||||||
|
let reference_points = das_dennis(m, self.config.reference_divisions);
|
||||||
|
assert!(
|
||||||
|
!reference_points.is_empty(),
|
||||||
|
"Nsga3 reference set is empty — check reference_divisions",
|
||||||
|
);
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
assert_eq!(
|
||||||
|
initial_decisions.len(),
|
||||||
|
n,
|
||||||
|
"NSGA-III initializer must return exactly population_size decisions",
|
||||||
|
);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"NSGA-III variation returned no children",
|
||||||
|
);
|
||||||
|
for child_decision in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child_decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
population =
|
||||||
|
environmental_selection(&combined, &objectives, &reference_points, n, &mut rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// NSGA-III environmental selection: front-by-front + reference-point niching
|
/// NSGA-III environmental selection: front-by-front + reference-point niching
|
||||||
/// on the splitting front.
|
/// on the splitting front.
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
|
|||||||
@@ -191,6 +191,100 @@ fn worse_than(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl OnePlusOneEs {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` is mostly inert here because (1+1)-ES evaluates
|
||||||
|
/// one child per iteration; it's accepted for API parity with
|
||||||
|
/// other algorithms.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
let _ = concurrency;
|
||||||
|
assert!(
|
||||||
|
self.config.initial_sigma > 0.0,
|
||||||
|
"OnePlusOneEs initial_sigma must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.step_increase > 1.0,
|
||||||
|
"OnePlusOneEs step_increase must be > 1",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.adaptation_period >= 1,
|
||||||
|
"OnePlusOneEs adaptation_period must be >= 1",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"OnePlusOneEs requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut parent: Vec<f64> = self
|
||||||
|
.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
||||||
|
.collect();
|
||||||
|
let mut parent_eval = problem.evaluate_async(&parent).await;
|
||||||
|
let mut evaluations = 1usize;
|
||||||
|
|
||||||
|
let mut sigma = self.config.initial_sigma;
|
||||||
|
let mut window = std::collections::VecDeque::with_capacity(self.config.adaptation_period);
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let normal = Normal::new(0.0, sigma).expect("Normal::new(0, sigma)");
|
||||||
|
let mut child = parent.clone();
|
||||||
|
for (j, x) in child.iter_mut().enumerate() {
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
*x = (*x + normal.sample(&mut rng)).clamp(lo, hi);
|
||||||
|
}
|
||||||
|
let child_eval = problem.evaluate_async(&child).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
let accepted = !worse_than(&child_eval, &parent_eval, direction);
|
||||||
|
if accepted {
|
||||||
|
parent = child;
|
||||||
|
parent_eval = child_eval;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.push_back(if accepted { 1u8 } else { 0u8 });
|
||||||
|
if window.len() > self.config.adaptation_period {
|
||||||
|
window.pop_front();
|
||||||
|
}
|
||||||
|
if window.len() == self.config.adaptation_period {
|
||||||
|
let success_count: usize = window.iter().map(|&b| b as usize).sum();
|
||||||
|
let rate = success_count as f64 / window.len() as f64;
|
||||||
|
if rate > 0.2 {
|
||||||
|
sigma *= self.config.step_increase;
|
||||||
|
} else if rate < 0.2 {
|
||||||
|
sigma /= self.config.step_increase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = Candidate::new(parent, parent_eval);
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -156,6 +156,92 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Paes<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` is mostly inert here because PAES evaluates one
|
||||||
|
/// child per iteration; it's accepted for API parity with other
|
||||||
|
/// algorithms.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
let _ = concurrency;
|
||||||
|
assert!(
|
||||||
|
self.config.archive_size > 0,
|
||||||
|
"PAES archive_size must be greater than 0",
|
||||||
|
);
|
||||||
|
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut initial = self.initializer.initialize(1, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!initial.is_empty(),
|
||||||
|
"PAES initializer returned no decisions",
|
||||||
|
);
|
||||||
|
let mut current_decision = initial.remove(0);
|
||||||
|
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
||||||
|
let mut evaluations = 1usize;
|
||||||
|
|
||||||
|
let mut archive = ParetoArchive::new(objectives.clone());
|
||||||
|
archive.insert(Candidate::new(
|
||||||
|
current_decision.clone(),
|
||||||
|
current_eval.clone(),
|
||||||
|
));
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let parents = vec![current_decision.clone()];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "PAES variation returned no children",);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
match pareto_compare(&child_eval, ¤t_eval, &objectives) {
|
||||||
|
Dominance::Dominates => {
|
||||||
|
current_decision = child_decision.clone();
|
||||||
|
current_eval = child_eval.clone();
|
||||||
|
}
|
||||||
|
Dominance::DominatedBy => {
|
||||||
|
// Stay at current.
|
||||||
|
}
|
||||||
|
Dominance::NonDominated | Dominance::Equal => {
|
||||||
|
current_decision = child_decision.clone();
|
||||||
|
current_eval = child_eval.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
archive.insert(Candidate::new(child_decision, child_eval));
|
||||||
|
archive.insert(Candidate::new(
|
||||||
|
current_decision.clone(),
|
||||||
|
current_eval.clone(),
|
||||||
|
));
|
||||||
|
archive.truncate(self.config.archive_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
let members = archive.into_vec();
|
||||||
|
let front = pareto_front(&members, &objectives);
|
||||||
|
let best = best_candidate(&members, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(members),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
//! Async population evaluator.
|
||||||
|
//!
|
||||||
|
//! Available only with the `async` feature. Used by the `run_async`
|
||||||
|
//! method on algorithms that support async problems.
|
||||||
|
|
||||||
|
use futures::stream::{FuturesOrdered, StreamExt};
|
||||||
|
|
||||||
|
use crate::core::async_problem::{AsyncPartialProblem, AsyncProblem};
|
||||||
|
use crate::core::candidate::Candidate;
|
||||||
|
use crate::core::evaluation::Evaluation;
|
||||||
|
|
||||||
|
/// Evaluate every decision concurrently against `problem`, preserving
|
||||||
|
/// input order in the returned vector. Concurrency is bounded by
|
||||||
|
/// `concurrency` (≥ 1) — too high a value wastes memory and may
|
||||||
|
/// overload downstream services; too low forfeits parallelism.
|
||||||
|
///
|
||||||
|
/// Returns a future that the caller drives via their preferred
|
||||||
|
/// runtime (typically tokio).
|
||||||
|
pub async fn evaluate_batch_async<P>(
|
||||||
|
problem: &P,
|
||||||
|
decisions: Vec<P::Decision>,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> Vec<Candidate<P::Decision>>
|
||||||
|
where
|
||||||
|
P: AsyncProblem,
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
concurrency >= 1,
|
||||||
|
"evaluate_batch_async concurrency must be >= 1"
|
||||||
|
);
|
||||||
|
let mut out: Vec<Candidate<P::Decision>> = Vec::with_capacity(decisions.len());
|
||||||
|
|
||||||
|
// Process in concurrency-bounded chunks to keep peak memory low
|
||||||
|
// and avoid blasting downstream services. Each chunk uses
|
||||||
|
// FuturesOrdered to preserve per-chunk order, and chunks are
|
||||||
|
// emitted in their natural order.
|
||||||
|
let mut iter = decisions.into_iter();
|
||||||
|
loop {
|
||||||
|
let mut futs = FuturesOrdered::new();
|
||||||
|
for _ in 0..concurrency {
|
||||||
|
match iter.next() {
|
||||||
|
Some(d) => {
|
||||||
|
futs.push_back(async move {
|
||||||
|
let e = problem.evaluate_async(&d).await;
|
||||||
|
Candidate::new(d, e)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if futs.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
while let Some(c) = futs.next().await {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evaluate every decision at the given `budget` concurrently against a
|
||||||
|
/// multi-fidelity `problem`, preserving input order. Hyperband's async
|
||||||
|
/// path uses this for each Successive-Halving rung.
|
||||||
|
pub async fn evaluate_batch_at_budget_async<P>(
|
||||||
|
problem: &P,
|
||||||
|
decisions: &[P::Decision],
|
||||||
|
budget: f64,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> Vec<Evaluation>
|
||||||
|
where
|
||||||
|
P: AsyncPartialProblem,
|
||||||
|
{
|
||||||
|
assert!(
|
||||||
|
concurrency >= 1,
|
||||||
|
"evaluate_batch_at_budget_async concurrency must be >= 1"
|
||||||
|
);
|
||||||
|
let mut out: Vec<Evaluation> = Vec::with_capacity(decisions.len());
|
||||||
|
let mut idx = 0usize;
|
||||||
|
while idx < decisions.len() {
|
||||||
|
let mut futs = FuturesOrdered::new();
|
||||||
|
let end = (idx + concurrency).min(decisions.len());
|
||||||
|
for d in &decisions[idx..end] {
|
||||||
|
futs.push_back(async move { problem.evaluate_at_budget_async(d, budget).await });
|
||||||
|
}
|
||||||
|
while let Some(e) = futs.next().await {
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
idx = end;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -220,6 +220,129 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl ParticleSwarm {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch (initial
|
||||||
|
/// swarm, per-generation positions, and the final evaluation pass).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.swarm_size >= 1,
|
||||||
|
"ParticleSwarm swarm_size must be >= 1",
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"ParticleSwarm requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
let n = self.config.swarm_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut positions: Vec<Vec<f64>> = {
|
||||||
|
use crate::traits::Initializer as _;
|
||||||
|
self.bounds.initialize(n, &mut rng)
|
||||||
|
};
|
||||||
|
let mut velocities: Vec<Vec<f64>> = (0..n)
|
||||||
|
.map(|_| {
|
||||||
|
self.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
|
||||||
|
|
||||||
|
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
||||||
|
let mut evaluations = initial_pop.len();
|
||||||
|
|
||||||
|
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
|
||||||
|
let mut pbest_evals: Vec<f64> = initial_pop
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.evaluation.objectives[0])
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut gbest_idx = best_index(&pbest_evals, direction);
|
||||||
|
let mut gbest_decision = pbest_decisions[gbest_idx].clone();
|
||||||
|
let mut gbest_eval = pbest_evals[gbest_idx];
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
for i in 0..n {
|
||||||
|
#[allow(clippy::needless_range_loop)]
|
||||||
|
for j in 0..dim {
|
||||||
|
let r1: f64 = rng.random();
|
||||||
|
let r2: f64 = rng.random();
|
||||||
|
let cognitive_term =
|
||||||
|
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
|
||||||
|
let social_term =
|
||||||
|
self.config.social * r2 * (gbest_decision[j] - positions[i][j]);
|
||||||
|
let mut v =
|
||||||
|
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
|
||||||
|
if v > v_max[j] {
|
||||||
|
v = v_max[j];
|
||||||
|
} else if v < -v_max[j] {
|
||||||
|
v = -v_max[j];
|
||||||
|
}
|
||||||
|
velocities[i][j] = v;
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
||||||
|
evaluations += evaluated.len();
|
||||||
|
|
||||||
|
for (i, cand) in evaluated.iter().enumerate() {
|
||||||
|
let f = cand.evaluation.objectives[0];
|
||||||
|
let improves = match direction {
|
||||||
|
Direction::Minimize => f < pbest_evals[i],
|
||||||
|
Direction::Maximize => f > pbest_evals[i],
|
||||||
|
};
|
||||||
|
if improves {
|
||||||
|
pbest_decisions[i] = positions[i].clone();
|
||||||
|
pbest_evals[i] = f;
|
||||||
|
gbest_idx = i;
|
||||||
|
let beats_global = match direction {
|
||||||
|
Direction::Minimize => f < gbest_eval,
|
||||||
|
Direction::Maximize => f > gbest_eval,
|
||||||
|
};
|
||||||
|
if beats_global {
|
||||||
|
gbest_decision = pbest_decisions[i].clone();
|
||||||
|
gbest_eval = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = gbest_idx;
|
||||||
|
|
||||||
|
let final_pop = evaluate_batch_async(problem, positions, concurrency).await;
|
||||||
|
evaluations += final_pop.len();
|
||||||
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
|
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn best_index(values: &[f64], direction: Direction) -> usize {
|
fn best_index(values: &[f64], direction: Direction) -> usize {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
for i in 1..values.len() {
|
for i in 1..values.len() {
|
||||||
|
|||||||
@@ -204,6 +204,109 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> PesaII<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations of the initial
|
||||||
|
/// population. Per-step evaluations are sequential to preserve the
|
||||||
|
/// algorithm's exact RNG sequencing.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"PesaII population_size must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.archive_size > 0,
|
||||||
|
"PesaII archive_size must be > 0"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.grid_divisions >= 1,
|
||||||
|
"PesaII grid_divisions must be >= 1"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut internal: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = internal.len();
|
||||||
|
|
||||||
|
let mut archive = ParetoArchive::new(objectives.clone());
|
||||||
|
for c in &internal {
|
||||||
|
archive.insert(c.clone());
|
||||||
|
}
|
||||||
|
truncate_by_grid(
|
||||||
|
&mut archive,
|
||||||
|
self.config.archive_size,
|
||||||
|
self.config.grid_divisions,
|
||||||
|
);
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let (boxes, counts) = build_grid(&archive, &objectives, self.config.grid_divisions);
|
||||||
|
|
||||||
|
let mut offspring: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
|
||||||
|
while offspring.len() < n {
|
||||||
|
let p1 = region_tournament(&archive, &boxes, &counts, &mut rng);
|
||||||
|
let p2 = region_tournament(&archive, &boxes, &counts, &mut rng);
|
||||||
|
let parents = vec![
|
||||||
|
archive.members()[p1].decision.clone(),
|
||||||
|
archive.members()[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"PesaII variation returned no children"
|
||||||
|
);
|
||||||
|
for child in children {
|
||||||
|
if offspring.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let eval = problem.evaluate_async(&child).await;
|
||||||
|
evaluations += 1;
|
||||||
|
offspring.push(Candidate::new(child, eval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for c in &offspring {
|
||||||
|
archive.insert(c.clone());
|
||||||
|
}
|
||||||
|
truncate_by_grid(
|
||||||
|
&mut archive,
|
||||||
|
self.config.archive_size,
|
||||||
|
self.config.grid_divisions,
|
||||||
|
);
|
||||||
|
internal = offspring;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = internal;
|
||||||
|
let members = archive.into_vec();
|
||||||
|
let front = pareto_front(&members, &objectives);
|
||||||
|
let best = best_candidate(&members, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(members),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute per-member box index (M-tuple of grid coordinates) and the
|
/// Compute per-member box index (M-tuple of grid coordinates) and the
|
||||||
/// population count of each occupied box.
|
/// population count of each occupied box.
|
||||||
fn build_grid<D: Clone>(
|
fn build_grid<D: Clone>(
|
||||||
|
|||||||
@@ -88,49 +88,73 @@ where
|
|||||||
I: Initializer<P::Decision>,
|
I: Initializer<P::Decision>,
|
||||||
{
|
{
|
||||||
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
|
||||||
self.run_with(problem, &mut ())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
O: crate::observer::Observer<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::observer::Snapshot;
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
let objectives = problem.objectives();
|
let objectives = problem.objectives();
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
|
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
|
||||||
let mut evaluations = 0usize;
|
let mut evaluations = 0usize;
|
||||||
let started = std::time::Instant::now();
|
|
||||||
let mut completed: usize = 0;
|
|
||||||
|
|
||||||
for iteration in 1..=self.config.iterations {
|
for _ in 0..self.config.iterations {
|
||||||
let decisions = self
|
let decisions = self
|
||||||
.initializer
|
.initializer
|
||||||
.initialize(self.config.batch_size, &mut rng);
|
.initialize(self.config.batch_size, &mut rng);
|
||||||
evaluations += decisions.len();
|
evaluations += decisions.len();
|
||||||
all.extend(evaluate_batch(problem, decisions));
|
all.extend(evaluate_batch(problem, decisions));
|
||||||
completed = iteration;
|
|
||||||
|
|
||||||
let best = best_candidate(&all, &objectives);
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration,
|
|
||||||
evaluations,
|
|
||||||
elapsed: started.elapsed(),
|
|
||||||
population: &all,
|
|
||||||
pareto_front: None,
|
|
||||||
best: best.as_ref(),
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let front = pareto_front(&all, &objectives);
|
let front = pareto_front(&all, &objectives);
|
||||||
let best = best_candidate(&all, &objectives);
|
let best = best_candidate(&all, &objectives);
|
||||||
OptimizationResult::new(Population::new(all), front, best, evaluations, completed)
|
OptimizationResult::new(
|
||||||
|
Population::new(all),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I> RandomSearch<I> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime (typically tokio). Useful when
|
||||||
|
/// `evaluate` is IO-bound (HTTP, RPC, subprocess).
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds how many evaluations are in-flight at once;
|
||||||
|
/// `1` is sequential, larger values push more load to the
|
||||||
|
/// downstream service.
|
||||||
|
///
|
||||||
|
/// Available only with the `async` feature.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
|
||||||
|
let mut evaluations = 0usize;
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let decisions = self
|
||||||
|
.initializer
|
||||||
|
.initialize(self.config.batch_size, &mut rng);
|
||||||
|
evaluations += decisions.len();
|
||||||
|
let cands = evaluate_batch_async(problem, decisions, concurrency).await;
|
||||||
|
all.extend(cands);
|
||||||
|
}
|
||||||
|
let front = pareto_front(&all, &objectives);
|
||||||
|
let best = best_candidate(&all, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(all),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,6 +261,163 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Rvea<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Rvea population_size must be > 0"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let m = objectives.len();
|
||||||
|
let raw_refs = das_dennis(m, self.config.reference_divisions);
|
||||||
|
let references: Vec<Vec<f64>> = raw_refs.into_iter().map(unit_normalize).collect();
|
||||||
|
assert!(
|
||||||
|
!references.is_empty(),
|
||||||
|
"Rvea: no reference vectors generated"
|
||||||
|
);
|
||||||
|
|
||||||
|
let theta_max = smallest_neighbor_angle(&references);
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for gen_idx in 0..self.config.generations {
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
||||||
|
while offspring_decisions.len() < n {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "Rvea variation returned no children");
|
||||||
|
for child in children {
|
||||||
|
if offspring_decisions.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += offspring.len();
|
||||||
|
|
||||||
|
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
||||||
|
combined.extend(population);
|
||||||
|
combined.extend(offspring);
|
||||||
|
|
||||||
|
let m_dim = m;
|
||||||
|
let mut ideal = vec![f64::INFINITY; m_dim];
|
||||||
|
for c in &combined {
|
||||||
|
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
||||||
|
for (k, v) in oriented.iter().enumerate() {
|
||||||
|
if *v < ideal[k] {
|
||||||
|
ideal[k] = *v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let translated: Vec<Vec<f64>> = combined
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
||||||
|
oriented
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(k, v)| v - ideal[k])
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut assoc: Vec<usize> = vec![0; combined.len()];
|
||||||
|
let mut angles: Vec<f64> = vec![0.0; combined.len()];
|
||||||
|
for (i, t) in translated.iter().enumerate() {
|
||||||
|
let (best_ref, best_angle) = closest_reference(t, &references);
|
||||||
|
assoc[i] = best_ref;
|
||||||
|
angles[i] = best_angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
let alpha_t = (gen_idx as f64 / (self.config.generations as f64).max(1.0))
|
||||||
|
.powf(self.config.alpha);
|
||||||
|
let mut keep: Vec<Option<(usize, f64)>> = vec![None; references.len()];
|
||||||
|
for i in 0..combined.len() {
|
||||||
|
let r = assoc[i];
|
||||||
|
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||||
|
let theta_max_safe = theta_max.max(1e-12);
|
||||||
|
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
|
||||||
|
let apd = penalty * length;
|
||||||
|
match keep[r] {
|
||||||
|
None => keep[r] = Some((i, apd)),
|
||||||
|
Some((_, current)) if apd < current => keep[r] = Some((i, apd)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut next: Vec<Candidate<P::Decision>> = keep
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.map(|(i, _)| combined[i].clone())
|
||||||
|
.collect();
|
||||||
|
if next.len() < n {
|
||||||
|
let mut all_apds: Vec<(usize, f64)> = (0..combined.len())
|
||||||
|
.map(|i| {
|
||||||
|
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
|
||||||
|
let theta_max_safe = theta_max.max(1e-12);
|
||||||
|
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
|
||||||
|
(i, penalty * length)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
all_apds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
for (i, _) in all_apds {
|
||||||
|
if next.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if !next
|
||||||
|
.iter()
|
||||||
|
.any(|c| std::ptr::eq(c as *const _, &combined[i] as *const _))
|
||||||
|
{
|
||||||
|
next.push(combined[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if next.len() > n {
|
||||||
|
next.truncate(n);
|
||||||
|
}
|
||||||
|
population = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn unit_normalize(mut v: Vec<f64>) -> Vec<f64> {
|
fn unit_normalize(mut v: Vec<f64>) -> Vec<f64> {
|
||||||
let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||||
if n > 1e-12 {
|
if n > 1e-12 {
|
||||||
|
|||||||
@@ -215,6 +215,124 @@ fn better_than(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> SimulatedAnnealing<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` is mostly inert here because SA evaluates one
|
||||||
|
/// child per iteration; it's accepted for API parity with other
|
||||||
|
/// algorithms.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
let _ = concurrency;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"SimulatedAnnealing requires exactly one objective",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.initial_temperature > 0.0,
|
||||||
|
"SimulatedAnnealing initial_temperature must be positive",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.final_temperature > 0.0,
|
||||||
|
"SimulatedAnnealing final_temperature must be positive",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.final_temperature <= self.config.initial_temperature,
|
||||||
|
"SimulatedAnnealing final_temperature must be <= initial_temperature",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut initial = self.initializer.initialize(1, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!initial.is_empty(),
|
||||||
|
"SimulatedAnnealing initializer returned no decisions",
|
||||||
|
);
|
||||||
|
let mut current_decision = initial.remove(0);
|
||||||
|
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
||||||
|
let mut best_decision = current_decision.clone();
|
||||||
|
let mut best_eval = current_eval.clone();
|
||||||
|
let mut evaluations = 1usize;
|
||||||
|
|
||||||
|
let cooling = if self.config.iterations <= 1 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
(self.config.final_temperature / self.config.initial_temperature)
|
||||||
|
.powf(1.0 / (self.config.iterations as f64 - 1.0))
|
||||||
|
};
|
||||||
|
let mut temperature = self.config.initial_temperature;
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let parents = vec![current_decision.clone()];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"SimulatedAnnealing variation returned no children"
|
||||||
|
);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
|
||||||
|
let accept = match (child_eval.is_feasible(), current_eval.is_feasible()) {
|
||||||
|
(true, false) => true,
|
||||||
|
(false, true) => false,
|
||||||
|
(false, false) => {
|
||||||
|
child_eval.constraint_violation <= current_eval.constraint_violation
|
||||||
|
}
|
||||||
|
(true, true) => {
|
||||||
|
let delta = match direction {
|
||||||
|
Direction::Minimize => {
|
||||||
|
child_eval.objectives[0] - current_eval.objectives[0]
|
||||||
|
}
|
||||||
|
Direction::Maximize => {
|
||||||
|
current_eval.objectives[0] - child_eval.objectives[0]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if delta <= 0.0 {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let prob = (-delta / temperature).exp();
|
||||||
|
rng.random::<f64>() < prob
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if accept {
|
||||||
|
current_decision = child_decision;
|
||||||
|
current_eval = child_eval;
|
||||||
|
if better_than(¤t_eval, &best_eval, direction) {
|
||||||
|
best_decision = current_decision.clone();
|
||||||
|
best_eval = current_eval.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
temperature *= cooling;
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = Candidate::new(best_decision, best_eval);
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -173,6 +173,80 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> SmsEmoa<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations of the initial
|
||||||
|
/// population. Per-generation evaluations are sequential because
|
||||||
|
/// SMS-EMOA is a steady-state algorithm (one child per generation).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"SmsEmoa population_size must be > 0"
|
||||||
|
);
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert_eq!(
|
||||||
|
self.config.reference_point.len(),
|
||||||
|
objectives.len(),
|
||||||
|
"SmsEmoa reference_point.len() must equal number of objectives",
|
||||||
|
);
|
||||||
|
let reference = self.config.reference_point.clone();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let p1 = rng.random_range(0..population.len());
|
||||||
|
let p2 = rng.random_range(0..population.len());
|
||||||
|
let parents = vec![
|
||||||
|
population[p1].decision.clone(),
|
||||||
|
population[p2].decision.clone(),
|
||||||
|
];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!children.is_empty(),
|
||||||
|
"SmsEmoa variation returned no children"
|
||||||
|
);
|
||||||
|
let child_decision = children.into_iter().next().unwrap();
|
||||||
|
let child_eval = problem.evaluate_async(&child_decision).await;
|
||||||
|
evaluations += 1;
|
||||||
|
let child = Candidate::new(child_decision, child_eval);
|
||||||
|
|
||||||
|
population.push(child);
|
||||||
|
let drop_idx = pick_drop_index(&population, &objectives, &reference);
|
||||||
|
population.swap_remove(drop_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&population, &objectives);
|
||||||
|
let best = best_candidate(&population, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(population),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Choose the index in `pool` whose removal is preferred per SMS-EMOA's
|
/// Choose the index in `pool` whose removal is preferred per SMS-EMOA's
|
||||||
/// rules: drop from the worst non-dominated front; within that front,
|
/// rules: drop from the worst non-dominated front; within that front,
|
||||||
/// drop the member whose removal increases hypervolume the most (= the
|
/// drop the member whose removal increases hypervolume the most (= the
|
||||||
|
|||||||
@@ -222,6 +222,137 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl SeparableNes {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per generation.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 2,
|
||||||
|
"SeparableNes population_size must be >= 2",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.initial_sigma > 0.0,
|
||||||
|
"SeparableNes initial_sigma must be > 0"
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"SeparableNes requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let n = self.bounds.bounds.len();
|
||||||
|
let lambda = self.config.population_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut mean: Vec<f64> = self
|
||||||
|
.bounds
|
||||||
|
.bounds
|
||||||
|
.iter()
|
||||||
|
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
||||||
|
.collect();
|
||||||
|
let mut sigma = vec![self.config.initial_sigma; n];
|
||||||
|
|
||||||
|
let eta_sigma = self
|
||||||
|
.config
|
||||||
|
.sigma_learning_rate
|
||||||
|
.unwrap_or_else(|| (3.0 + (n as f64).ln()) / (5.0 * (n as f64).sqrt()));
|
||||||
|
let eta_mean = self.config.mean_learning_rate;
|
||||||
|
|
||||||
|
let utilities = nes_utilities(lambda);
|
||||||
|
|
||||||
|
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
|
||||||
|
let mut total_evaluations = 0usize;
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
// Sample λ offspring; matches the sync RNG draw order so seeded
|
||||||
|
// runs reproduce exactly.
|
||||||
|
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
||||||
|
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
||||||
|
for _ in 0..lambda {
|
||||||
|
let z: Vec<f64> = (0..n)
|
||||||
|
.map(|_| Normal::new(0.0, 1.0).unwrap().sample(&mut rng))
|
||||||
|
.collect();
|
||||||
|
let x: Vec<f64> = (0..n)
|
||||||
|
.map(|j| {
|
||||||
|
let v = mean[j] + sigma[j] * z[j];
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
v.clamp(lo, hi)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
z_samples.push(z);
|
||||||
|
x_samples.push(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cands = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
|
||||||
|
total_evaluations += cands.len();
|
||||||
|
let evals: Vec<Evaluation> = cands.iter().map(|c| c.evaluation.clone()).collect();
|
||||||
|
for c in &cands {
|
||||||
|
let beats_best = match &best_seen {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better(&c.evaluation, &b.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats_best {
|
||||||
|
best_seen = Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut order: Vec<usize> = (0..lambda).collect();
|
||||||
|
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
||||||
|
|
||||||
|
let mut grad_mean = vec![0.0_f64; n];
|
||||||
|
for k in 0..lambda {
|
||||||
|
let u = utilities[k];
|
||||||
|
let z = &z_samples[order[k]];
|
||||||
|
for j in 0..n {
|
||||||
|
grad_mean[j] += u * z[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for j in 0..n {
|
||||||
|
mean[j] += eta_mean * sigma[j] * grad_mean[j];
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
mean[j] = mean[j].clamp(lo, hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
for j in 0..n {
|
||||||
|
let mut grad_sigma_j = 0.0;
|
||||||
|
for k in 0..lambda {
|
||||||
|
let u = utilities[k];
|
||||||
|
let z = &z_samples[order[k]];
|
||||||
|
grad_sigma_j += u * (z[j] * z[j] - 1.0);
|
||||||
|
}
|
||||||
|
sigma[j] *= (0.5 * eta_sigma * grad_sigma_j).exp();
|
||||||
|
if !sigma[j].is_finite() || sigma[j] < 1e-30 {
|
||||||
|
sigma[j] = 1e-30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_seen.expect("at least one generation evaluated");
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
total_evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn nes_utilities(lambda: usize) -> Vec<f64> {
|
fn nes_utilities(lambda: usize) -> Vec<f64> {
|
||||||
let half = lambda as f64 / 2.0 + 1.0;
|
let half = lambda as f64 / 2.0 + 1.0;
|
||||||
let raw: Vec<f64> = (0..lambda)
|
let raw: Vec<f64> = (0..lambda)
|
||||||
|
|||||||
@@ -169,6 +169,91 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<I, V> Spea2<I, V> {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<P::Decision>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem,
|
||||||
|
I: Initializer<P::Decision>,
|
||||||
|
V: Variation<P::Decision>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size > 0,
|
||||||
|
"Spea2 population_size must be greater than 0",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.archive_size > 0,
|
||||||
|
"Spea2 archive_size must be greater than 0",
|
||||||
|
);
|
||||||
|
let n_pop = self.config.population_size;
|
||||||
|
let n_arc = self.config.archive_size;
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let initial_decisions = self.initializer.initialize(n_pop, &mut rng);
|
||||||
|
assert_eq!(
|
||||||
|
initial_decisions.len(),
|
||||||
|
n_pop,
|
||||||
|
"SPEA2 initializer must return exactly population_size decisions",
|
||||||
|
);
|
||||||
|
let mut population: Vec<Candidate<P::Decision>> =
|
||||||
|
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut pool: Vec<Candidate<P::Decision>> =
|
||||||
|
Vec::with_capacity(population.len() + archive.len());
|
||||||
|
pool.append(&mut population);
|
||||||
|
pool.append(&mut archive);
|
||||||
|
let fitness = compute_fitness(&pool, &objectives);
|
||||||
|
|
||||||
|
archive = build_archive(&pool, &fitness, &objectives, n_arc);
|
||||||
|
|
||||||
|
let archive_fitness = compute_fitness(&archive, &objectives);
|
||||||
|
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n_pop);
|
||||||
|
while offspring_decisions.len() < n_pop {
|
||||||
|
let p1 = binary_tournament(&archive_fitness, &mut rng);
|
||||||
|
let p2 = binary_tournament(&archive_fitness, &mut rng);
|
||||||
|
let parents = vec![archive[p1].decision.clone(), archive[p2].decision.clone()];
|
||||||
|
let children = self.variation.vary(&parents, &mut rng);
|
||||||
|
assert!(!children.is_empty(), "SPEA2 variation returned no children");
|
||||||
|
for child_decision in children {
|
||||||
|
if offspring_decisions.len() >= n_pop {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
offspring_decisions.push(child_decision);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let new_population =
|
||||||
|
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
||||||
|
evaluations += new_population.len();
|
||||||
|
population = new_population;
|
||||||
|
}
|
||||||
|
|
||||||
|
let front = pareto_front(&archive, &objectives);
|
||||||
|
let best = best_candidate(&archive, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(archive),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// SPEA2 fitness: `R(i) + D(i)`, where lower is better.
|
/// SPEA2 fitness: `R(i) + D(i)`, where lower is better.
|
||||||
///
|
///
|
||||||
/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the
|
/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the
|
||||||
|
|||||||
@@ -209,6 +209,128 @@ fn better_than(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl<D, I, N> TabuSearch<D, I, N>
|
||||||
|
where
|
||||||
|
D: Clone + Hash + Eq,
|
||||||
|
I: Initializer<D>,
|
||||||
|
N: FnMut(&D, &mut Rng) -> Vec<D>,
|
||||||
|
{
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// Each iteration evaluates the K neighbors of the current
|
||||||
|
/// incumbent concurrently (bounded by `concurrency`), then picks
|
||||||
|
/// the best non-tabu (or aspiration-passing) move.
|
||||||
|
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = D>,
|
||||||
|
D: Send + Sync,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"TabuSearch requires exactly one objective",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.tabu_tenure >= 1,
|
||||||
|
"TabuSearch tabu_tenure must be >= 1",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut initial = self.initializer.initialize(1, &mut rng);
|
||||||
|
assert!(
|
||||||
|
!initial.is_empty(),
|
||||||
|
"TabuSearch initializer returned no decisions"
|
||||||
|
);
|
||||||
|
let mut current_decision = initial.remove(0);
|
||||||
|
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
||||||
|
let mut best_decision = current_decision.clone();
|
||||||
|
let mut best_eval = current_eval.clone();
|
||||||
|
let mut evaluations = 1usize;
|
||||||
|
|
||||||
|
let mut tabu_queue: VecDeque<D> = VecDeque::with_capacity(self.config.tabu_tenure);
|
||||||
|
let mut tabu_set: HashSet<D> = HashSet::new();
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let candidates = (self.neighbors)(¤t_decision, &mut rng);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cand_results = evaluate_batch_async(problem, candidates.clone(), concurrency).await;
|
||||||
|
let mut cand_evals: Vec<crate::core::evaluation::Evaluation> =
|
||||||
|
cand_results.into_iter().map(|c| c.evaluation).collect();
|
||||||
|
evaluations += candidates.len();
|
||||||
|
|
||||||
|
let mut best_idx: Option<usize> = None;
|
||||||
|
let mut best_cand_eval: Option<crate::core::evaluation::Evaluation> = None;
|
||||||
|
|
||||||
|
for (i, c) in candidates.iter().enumerate() {
|
||||||
|
let is_tabu = tabu_set.contains(c);
|
||||||
|
let aspires = is_tabu && better_than(&cand_evals[i], &best_eval, direction);
|
||||||
|
if is_tabu && !aspires {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let eligible = match &best_cand_eval {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than(&cand_evals[i], b, direction),
|
||||||
|
};
|
||||||
|
if eligible {
|
||||||
|
best_idx = Some(i);
|
||||||
|
best_cand_eval = Some(cand_evals[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_idx.is_none() {
|
||||||
|
for (i, _) in candidates.iter().enumerate() {
|
||||||
|
let eligible = match &best_cand_eval {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than(&cand_evals[i], b, direction),
|
||||||
|
};
|
||||||
|
if eligible {
|
||||||
|
best_idx = Some(i);
|
||||||
|
best_cand_eval = Some(cand_evals[i].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let chosen_idx = best_idx.expect("non-empty candidate list");
|
||||||
|
let chosen_decision = candidates[chosen_idx].clone();
|
||||||
|
current_eval = cand_evals.remove(chosen_idx);
|
||||||
|
current_decision = chosen_decision.clone();
|
||||||
|
|
||||||
|
if better_than(¤t_eval, &best_eval, direction) {
|
||||||
|
best_decision = current_decision.clone();
|
||||||
|
best_eval = current_eval.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
tabu_queue.push_back(chosen_decision.clone());
|
||||||
|
tabu_set.insert(chosen_decision);
|
||||||
|
if tabu_queue.len() > self.config.tabu_tenure {
|
||||||
|
if let Some(old) = tabu_queue.pop_front() {
|
||||||
|
tabu_set.remove(&old);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = Candidate::new(best_decision, best_eval);
|
||||||
|
let population = Population::new(vec![best.clone()]);
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
population,
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
evaluations,
|
||||||
|
self.config.iterations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -187,6 +187,122 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl Tlbo {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations within batched phases
|
||||||
|
/// (only the initial population uses a batch; the teacher and learner
|
||||||
|
/// phases evaluate sequentially because each accept/reject step
|
||||||
|
/// depends on the previous one).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 2,
|
||||||
|
"Tlbo population_size must be >= 2"
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"Tlbo requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut decisions: Vec<Vec<f64>> = {
|
||||||
|
use crate::traits::Initializer as _;
|
||||||
|
self.bounds.initialize(n, &mut rng)
|
||||||
|
};
|
||||||
|
let initial = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
||||||
|
let mut evals: Vec<Evaluation> = initial.iter().map(|c| c.evaluation.clone()).collect();
|
||||||
|
let mut evaluations = initial.len();
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let teacher_idx = best_index(&evals, direction);
|
||||||
|
let teacher = decisions[teacher_idx].clone();
|
||||||
|
let mut mean = vec![0.0_f64; dim];
|
||||||
|
for d in &decisions {
|
||||||
|
for j in 0..dim {
|
||||||
|
mean[j] += d[j];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in mean.iter_mut() {
|
||||||
|
*v /= n as f64;
|
||||||
|
}
|
||||||
|
let tf = if rng.random_bool(0.5) { 1.0 } else { 2.0 };
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let mut candidate = decisions[i].clone();
|
||||||
|
for j in 0..dim {
|
||||||
|
let r: f64 = rng.random();
|
||||||
|
candidate[j] += r * (teacher[j] - tf * mean[j]);
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
candidate[j] = candidate[j].clamp(lo, hi);
|
||||||
|
}
|
||||||
|
let cand_eval = problem.evaluate_async(&candidate).await;
|
||||||
|
evaluations += 1;
|
||||||
|
if better(&cand_eval, &evals[i], direction) {
|
||||||
|
decisions[i] = candidate;
|
||||||
|
evals[i] = cand_eval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..n {
|
||||||
|
let mut k = rng.random_range(0..n);
|
||||||
|
while k == i && n > 1 {
|
||||||
|
k = rng.random_range(0..n);
|
||||||
|
}
|
||||||
|
let partner_better = better(&evals[k], &evals[i], direction);
|
||||||
|
let mut candidate = decisions[i].clone();
|
||||||
|
for j in 0..dim {
|
||||||
|
let r: f64 = rng.random();
|
||||||
|
let delta = if partner_better {
|
||||||
|
r * (decisions[k][j] - decisions[i][j])
|
||||||
|
} else {
|
||||||
|
r * (decisions[i][j] - decisions[k][j])
|
||||||
|
};
|
||||||
|
candidate[j] += delta;
|
||||||
|
let (lo, hi) = self.bounds.bounds[j];
|
||||||
|
candidate[j] = candidate[j].clamp(lo, hi);
|
||||||
|
}
|
||||||
|
let cand_eval = problem.evaluate_async(&candidate).await;
|
||||||
|
evaluations += 1;
|
||||||
|
if better(&cand_eval, &evals[i], direction) {
|
||||||
|
decisions[i] = candidate;
|
||||||
|
evals[i] = cand_eval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
||||||
|
.into_iter()
|
||||||
|
.zip(evals)
|
||||||
|
.map(|(d, e)| Candidate::new(d, e))
|
||||||
|
.collect();
|
||||||
|
let best = best_candidate(&final_pop, &objectives);
|
||||||
|
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn best_index(evals: &[Evaluation], direction: Direction) -> usize {
|
fn best_index(evals: &[Evaluation], direction: Direction) -> usize {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
for i in 1..evals.len() {
|
for i in 1..evals.len() {
|
||||||
|
|||||||
@@ -356,6 +356,129 @@ fn scott_bandwidths(decisions: &[Vec<f64>], support: &[usize], factor: f64) -> V
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl Tpe {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations during the initial
|
||||||
|
/// uniform-sample design; the sequential TPE loop runs one
|
||||||
|
/// evaluation per iteration regardless.
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<f64>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.initial_samples >= 2,
|
||||||
|
"Tpe initial_samples must be >= 2"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.good_fraction > 0.0 && self.config.good_fraction < 1.0,
|
||||||
|
"Tpe good_fraction must be in (0, 1)",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.candidate_samples >= 1,
|
||||||
|
"Tpe candidate_samples must be >= 1",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.bandwidth_factor > 0.0,
|
||||||
|
"Tpe bandwidth_factor must be > 0"
|
||||||
|
);
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"Tpe requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let dim = self.bounds.bounds.len();
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut decisions: Vec<Vec<f64>> = Vec::new();
|
||||||
|
let mut targets: Vec<f64> = Vec::new();
|
||||||
|
let mut evals: Vec<Evaluation> = Vec::new();
|
||||||
|
|
||||||
|
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
|
||||||
|
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
|
||||||
|
.collect();
|
||||||
|
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
||||||
|
for c in initial_cands {
|
||||||
|
targets.push(oriented_target(&c.evaluation, direction));
|
||||||
|
decisions.push(c.decision);
|
||||||
|
evals.push(c.evaluation);
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..self.config.iterations {
|
||||||
|
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
|
||||||
|
|
||||||
|
let mut best_x: Option<Vec<f64>> = None;
|
||||||
|
let mut best_ratio = f64::NEG_INFINITY;
|
||||||
|
for _ in 0..self.config.candidate_samples {
|
||||||
|
let cand = sample_from_kde(
|
||||||
|
&decisions,
|
||||||
|
&good_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
let l = log_kde_density(
|
||||||
|
&cand,
|
||||||
|
&decisions,
|
||||||
|
&good_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
);
|
||||||
|
let g = log_kde_density(
|
||||||
|
&cand,
|
||||||
|
&decisions,
|
||||||
|
&bad_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
);
|
||||||
|
let ratio = l - g;
|
||||||
|
if ratio > best_ratio {
|
||||||
|
best_ratio = ratio;
|
||||||
|
best_x = Some(cand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let x = best_x.expect("at least one candidate sampled");
|
||||||
|
let _ = dim;
|
||||||
|
let e = problem.evaluate_async(&x).await;
|
||||||
|
targets.push(oriented_target(&e, direction));
|
||||||
|
decisions.push(x);
|
||||||
|
evals.push(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut best_idx = 0;
|
||||||
|
for i in 1..evals.len() {
|
||||||
|
if better(&evals[i], &evals[best_idx], direction) {
|
||||||
|
best_idx = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let total_evals = evals.len();
|
||||||
|
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
||||||
|
.into_iter()
|
||||||
|
.zip(evals)
|
||||||
|
.map(|(d, e)| Candidate::new(d, e))
|
||||||
|
.collect();
|
||||||
|
let best = final_pop[best_idx].clone();
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
Some(best),
|
||||||
|
total_evals,
|
||||||
|
self.config.iterations + self.config.initial_samples,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -202,6 +202,125 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
impl Umda {
|
||||||
|
/// Async version of [`Optimizer::run`] — drives evaluations through
|
||||||
|
/// the user-chosen async runtime. Available only with the `async`
|
||||||
|
/// feature.
|
||||||
|
///
|
||||||
|
/// `concurrency` bounds in-flight evaluations per batch (initial
|
||||||
|
/// population and per-generation samples).
|
||||||
|
pub async fn run_async<P>(
|
||||||
|
&mut self,
|
||||||
|
problem: &P,
|
||||||
|
concurrency: usize,
|
||||||
|
) -> OptimizationResult<Vec<bool>>
|
||||||
|
where
|
||||||
|
P: crate::core::async_problem::AsyncProblem<Decision = Vec<bool>>,
|
||||||
|
{
|
||||||
|
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
self.config.population_size >= 2,
|
||||||
|
"Umda population_size must be >= 2"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.selected_size >= 1,
|
||||||
|
"Umda selected_size must be >= 1",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
self.config.selected_size <= self.config.population_size,
|
||||||
|
"Umda selected_size must be <= population_size",
|
||||||
|
);
|
||||||
|
assert!(self.config.bits >= 1, "Umda bits must be >= 1");
|
||||||
|
let objectives = problem.objectives();
|
||||||
|
assert!(
|
||||||
|
objectives.is_single_objective(),
|
||||||
|
"Umda requires exactly one objective",
|
||||||
|
);
|
||||||
|
let direction = objectives.objectives[0].direction;
|
||||||
|
let n = self.config.population_size;
|
||||||
|
let bits = self.config.bits;
|
||||||
|
let mu = self.config.selected_size;
|
||||||
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
|
let mut decisions: Vec<Vec<bool>> = (0..n)
|
||||||
|
.map(|_| (0..bits).map(|_| rng.random_bool(0.5)).collect())
|
||||||
|
.collect();
|
||||||
|
let mut population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
||||||
|
let mut evaluations = population.len();
|
||||||
|
|
||||||
|
let smoothing = 1.0 / (2.0 * mu as f64);
|
||||||
|
let prob_min = smoothing;
|
||||||
|
let prob_max = 1.0 - smoothing;
|
||||||
|
|
||||||
|
let mut best_seen: Option<Candidate<Vec<bool>>> = None;
|
||||||
|
for c in &population {
|
||||||
|
let beats = match &best_seen {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats {
|
||||||
|
best_seen = Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..self.config.generations {
|
||||||
|
let mut order: Vec<usize> = (0..population.len()).collect();
|
||||||
|
order.sort_by(|&a, &b| {
|
||||||
|
compare_so(
|
||||||
|
&population[a].evaluation,
|
||||||
|
&population[b].evaluation,
|
||||||
|
direction,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let selected: Vec<&Candidate<Vec<bool>>> =
|
||||||
|
order.iter().take(mu).map(|&i| &population[i]).collect();
|
||||||
|
|
||||||
|
let mut probs = vec![0.0_f64; bits];
|
||||||
|
for c in &selected {
|
||||||
|
for (i, b) in c.decision.iter().enumerate() {
|
||||||
|
if *b {
|
||||||
|
probs[i] += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for p in probs.iter_mut() {
|
||||||
|
*p = (*p / mu as f64).clamp(prob_min, prob_max);
|
||||||
|
}
|
||||||
|
|
||||||
|
decisions = (0..n)
|
||||||
|
.map(|_| probs.iter().map(|&p| rng.random_bool(p)).collect())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
||||||
|
evaluations += population.len();
|
||||||
|
|
||||||
|
for c in &population {
|
||||||
|
let beats = match &best_seen {
|
||||||
|
None => true,
|
||||||
|
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
||||||
|
};
|
||||||
|
if beats {
|
||||||
|
best_seen = Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let best = best_seen.expect("at least one generation evaluated");
|
||||||
|
let final_pop = vec![best.clone()];
|
||||||
|
let front = vec![best.clone()];
|
||||||
|
let best_opt = best_candidate(&final_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(final_pop),
|
||||||
|
front,
|
||||||
|
best_opt,
|
||||||
|
evaluations,
|
||||||
|
self.config.generations,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn compare_so(
|
fn compare_so(
|
||||||
a: &crate::core::evaluation::Evaluation,
|
a: &crate::core::evaluation::Evaluation,
|
||||||
b: &crate::core::evaluation::Evaluation,
|
b: &crate::core::evaluation::Evaluation,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Async-evaluable problems for IO-bound workloads.
|
||||||
|
//!
|
||||||
|
//! Most heuropt algorithms operate synchronously: their `Problem::evaluate`
|
||||||
|
//! returns immediately. For workloads where evaluation is *IO-bound* — calling
|
||||||
|
//! an HTTP service, querying a remote model, spawning a subprocess —
|
||||||
|
//! awaiting an async fn is much more efficient than blocking a worker
|
||||||
|
//! thread.
|
||||||
|
//!
|
||||||
|
//! [`AsyncProblem`] mirrors [`Problem`](crate::core::Problem) but its
|
||||||
|
//! `evaluate_async` returns a future. Every algorithm in heuropt exposes
|
||||||
|
//! a `run_async` method that drives evaluations through a user-chosen
|
||||||
|
//! async runtime (typically tokio). Hyperband uses
|
||||||
|
//! [`AsyncPartialProblem`] instead, which mirrors
|
||||||
|
//! [`PartialProblem`](crate::core::partial_problem::PartialProblem) for
|
||||||
|
//! multi-fidelity workloads.
|
||||||
|
//!
|
||||||
|
//! Available only with the `async` feature.
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
|
||||||
|
use crate::core::evaluation::Evaluation;
|
||||||
|
use crate::core::objective::ObjectiveSpace;
|
||||||
|
|
||||||
|
/// A problem whose evaluation is async — useful when `evaluate` does
|
||||||
|
/// IO (HTTP, RPC, subprocess) rather than pure CPU work.
|
||||||
|
///
|
||||||
|
/// Mirrors [`Problem`](crate::core::Problem) one-for-one except that
|
||||||
|
/// `evaluate_async` returns a future. The returned future must be
|
||||||
|
/// `Send` so the algorithm can run many evaluations concurrently
|
||||||
|
/// across a runtime's worker pool.
|
||||||
|
///
|
||||||
|
/// Implementors who already have a synchronous `Problem` can adapt
|
||||||
|
/// to `AsyncProblem` with a one-line wrapper:
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// impl AsyncProblem for MyProblem {
|
||||||
|
/// type Decision = <Self as Problem>::Decision;
|
||||||
|
/// fn objectives(&self) -> ObjectiveSpace { Problem::objectives(self) }
|
||||||
|
/// async fn evaluate_async(&self, x: &Self::Decision) -> Evaluation {
|
||||||
|
/// Problem::evaluate(self, x)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
pub trait AsyncProblem: Sync {
|
||||||
|
/// The thing the optimizer changes. Same constraints as
|
||||||
|
/// [`Problem::Decision`](crate::core::Problem::Decision).
|
||||||
|
type Decision: Clone + Send + Sync;
|
||||||
|
|
||||||
|
/// Return the objectives for this problem.
|
||||||
|
fn objectives(&self) -> ObjectiveSpace;
|
||||||
|
|
||||||
|
/// Evaluate `decision` asynchronously. The returned future is
|
||||||
|
/// driven by whichever runtime the algorithm's `run_async` is
|
||||||
|
/// invoked from.
|
||||||
|
fn evaluate_async(&self, decision: &Self::Decision) -> impl Future<Output = Evaluation> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async equivalent of [`PartialProblem`](crate::core::partial_problem::PartialProblem)
|
||||||
|
/// for multi-fidelity workloads — used by Hyperband's `run_async`.
|
||||||
|
///
|
||||||
|
/// Like [`AsyncProblem`], `evaluate_at_budget_async` returns a future
|
||||||
|
/// so callers can fan out budgeted evaluations across an async runtime.
|
||||||
|
pub trait AsyncPartialProblem: Sync {
|
||||||
|
/// The thing the optimizer changes. Same constraints as
|
||||||
|
/// [`PartialProblem::Decision`](crate::core::partial_problem::PartialProblem::Decision).
|
||||||
|
type Decision: Clone + Send + Sync;
|
||||||
|
|
||||||
|
/// Return the objectives for this problem.
|
||||||
|
fn objectives(&self) -> ObjectiveSpace;
|
||||||
|
|
||||||
|
/// Evaluate `decision` at the given fidelity `budget` asynchronously.
|
||||||
|
///
|
||||||
|
/// Same monotonicity contract as
|
||||||
|
/// [`PartialProblem::evaluate_at_budget`](crate::core::partial_problem::PartialProblem::evaluate_at_budget):
|
||||||
|
/// higher budget should give a more accurate estimate.
|
||||||
|
fn evaluate_at_budget_async(
|
||||||
|
&self,
|
||||||
|
decision: &Self::Decision,
|
||||||
|
budget: f64,
|
||||||
|
) -> impl Future<Output = Evaluation> + Send;
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
//! Concrete data types and the `Problem` trait that the rest of the crate is built on.
|
//! Concrete data types and the `Problem` trait that the rest of the crate is built on.
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
pub mod async_problem;
|
||||||
pub mod candidate;
|
pub mod candidate;
|
||||||
pub mod evaluation;
|
pub mod evaluation;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
@@ -9,6 +11,8 @@ pub mod problem;
|
|||||||
pub mod result;
|
pub mod result;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
pub use async_problem::AsyncProblem;
|
||||||
pub use candidate::*;
|
pub use candidate::*;
|
||||||
pub use evaluation::*;
|
pub use evaluation::*;
|
||||||
pub use objective::*;
|
pub use objective::*;
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ impl<D> Population<D> {
|
|||||||
self.candidates.iter()
|
self.candidates.iter()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// View the candidates as a slice.
|
|
||||||
pub fn as_slice(&self) -> &[Candidate<D>] {
|
|
||||||
&self.candidates
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Unwrap into the inner `Vec<Candidate<D>>`.
|
/// Unwrap into the inner `Vec<Candidate<D>>`.
|
||||||
pub fn into_vec(self) -> Vec<Candidate<D>> {
|
pub fn into_vec(self) -> Vec<Candidate<D>> {
|
||||||
self.candidates
|
self.candidates
|
||||||
|
|||||||
+7
-2
@@ -4,7 +4,7 @@
|
|||||||
//! The crate aims to make three things obvious:
|
//! The crate aims to make three things obvious:
|
||||||
//!
|
//!
|
||||||
//! 1. **Define a problem** by implementing [`Problem`](crate::core::Problem).
|
//! 1. **Define a problem** by implementing [`Problem`](crate::core::Problem).
|
||||||
//! 2. **Run a built-in optimizer** — pick from 35 algorithms in
|
//! 2. **Run a built-in optimizer** — pick from 33 algorithms in
|
||||||
//! [`algorithms`] covering single-objective continuous (CMA-ES,
|
//! [`algorithms`] covering single-objective continuous (CMA-ES,
|
||||||
//! Differential Evolution, Nelder-Mead, …), multi-objective
|
//! Differential Evolution, Nelder-Mead, …), multi-objective
|
||||||
//! (NSGA-II, MOPSO, IBEA, MOEA/D, …), many-objective (NSGA-III,
|
//! (NSGA-II, MOPSO, IBEA, MOEA/D, …), many-objective (NSGA-III,
|
||||||
@@ -32,6 +32,12 @@
|
|||||||
//! - `parallel` — rayon-backed parallel population evaluation in
|
//! - `parallel` — rayon-backed parallel population evaluation in
|
||||||
//! every population-based algorithm. Seeded runs stay bit-
|
//! every population-based algorithm. Seeded runs stay bit-
|
||||||
//! identical to serial mode.
|
//! identical to serial mode.
|
||||||
|
//! - `async` — adds the
|
||||||
|
//! [`AsyncProblem`](crate::core::async_problem::AsyncProblem) and
|
||||||
|
//! [`AsyncPartialProblem`](crate::core::async_problem::AsyncPartialProblem)
|
||||||
|
//! traits and a `run_async(&problem, concurrency).await` method on
|
||||||
|
//! every algorithm. Use this when your `evaluate` does IO (HTTP,
|
||||||
|
//! RPC, subprocess) — see the [Async evaluation cookbook recipe](https://swaits.github.io/heuropt/cookbook/async.html).
|
||||||
//!
|
//!
|
||||||
//! # Quick example
|
//! # Quick example
|
||||||
//!
|
//!
|
||||||
@@ -68,7 +74,6 @@ pub mod algorithms;
|
|||||||
pub mod core;
|
pub mod core;
|
||||||
pub(crate) mod internal;
|
pub(crate) mod internal;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod observer;
|
|
||||||
pub mod operators;
|
pub mod operators;
|
||||||
pub mod pareto;
|
pub mod pareto;
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|||||||
@@ -14,6 +14,26 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// If `objectives` does not have exactly two objectives.
|
/// If `objectives` does not have exactly two objectives.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
/// use heuropt::metrics::hypervolume_2d;
|
||||||
|
///
|
||||||
|
/// let space = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// // Reference (4, 4); front at (1,3), (2,2), (3,1) → dominated area = 6.
|
||||||
|
/// let front = [
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![1.0, 3.0])),
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![3.0, 1.0])),
|
||||||
|
/// ];
|
||||||
|
/// let hv = hypervolume_2d(&front, &space, [4.0, 4.0]);
|
||||||
|
/// assert!((hv - 6.0).abs() < 1e-12);
|
||||||
|
/// ```
|
||||||
pub fn hypervolume_2d<D>(
|
pub fn hypervolume_2d<D>(
|
||||||
front: &[Candidate<D>],
|
front: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -147,6 +167,24 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// If `objectives.len() != reference_point.len()`, or if either is zero.
|
/// If `objectives.len() != reference_point.len()`, or if either is zero.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
/// use heuropt::metrics::hypervolume_nd;
|
||||||
|
///
|
||||||
|
/// let space = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// Objective::minimize("f3"),
|
||||||
|
/// ]);
|
||||||
|
/// // Single corner point at the origin against a unit-cube reference:
|
||||||
|
/// // dominated volume = 1.
|
||||||
|
/// let front = [Candidate::new((), Evaluation::new(vec![0.0, 0.0, 0.0]))];
|
||||||
|
/// let hv = hypervolume_nd(&front, &space, &[1.0, 1.0, 1.0]);
|
||||||
|
/// assert!((hv - 1.0).abs() < 1e-12);
|
||||||
|
/// ```
|
||||||
pub fn hypervolume_nd<D>(
|
pub fn hypervolume_nd<D>(
|
||||||
front: &[Candidate<D>],
|
front: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
//! Inverted Generational Distance (IGD) and IGD+ performance indicators.
|
|
||||||
//!
|
|
||||||
//! Both quantify how well an approximation set covers a reference set
|
|
||||||
//! (typically the true Pareto front). Smaller values are better.
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::ObjectiveSpace;
|
|
||||||
|
|
||||||
/// Inverted Generational Distance.
|
|
||||||
///
|
|
||||||
/// For each point in the `reference` set, compute the Euclidean distance
|
|
||||||
/// to its nearest neighbor in the `approximation` set (in minimization-
|
|
||||||
/// oriented objective space), then average:
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// IGD(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖a − r‖₂
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Lower is better. IGD captures both convergence (close to the front)
|
|
||||||
/// and spread (the approximation must cover the reference).
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// If `reference` is empty.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use heuropt::metrics::igd::igd;
|
|
||||||
///
|
|
||||||
/// let space = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// // Approximation: a sparse 2-point front.
|
|
||||||
/// let approx = [
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
|
||||||
/// ];
|
|
||||||
/// // Reference: a dense 3-point sample of the true front.
|
|
||||||
/// let reference = [
|
|
||||||
/// Evaluation::new(vec![0.0, 1.0]),
|
|
||||||
/// Evaluation::new(vec![0.5, 0.5]),
|
|
||||||
/// Evaluation::new(vec![1.0, 0.0]),
|
|
||||||
/// ];
|
|
||||||
/// let v = igd(&approx, &reference, &space);
|
|
||||||
/// // The middle reference point is unfortunately distance √(0.5²+0.5²) = 0.707
|
|
||||||
/// // from each approximation point; the boundary points are 0 away.
|
|
||||||
/// // IGD = (0 + 0.707 + 0) / 3 ≈ 0.236.
|
|
||||||
/// assert!((v - 0.2357).abs() < 1e-3);
|
|
||||||
/// ```
|
|
||||||
pub fn igd<D>(
|
|
||||||
approximation: &[Candidate<D>],
|
|
||||||
reference: &[Evaluation],
|
|
||||||
objectives: &ObjectiveSpace,
|
|
||||||
) -> f64 {
|
|
||||||
assert!(
|
|
||||||
!reference.is_empty(),
|
|
||||||
"igd: reference set must not be empty"
|
|
||||||
);
|
|
||||||
let approx_oriented: Vec<Vec<f64>> = approximation
|
|
||||||
.iter()
|
|
||||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
|
||||||
.collect();
|
|
||||||
if approx_oriented.is_empty() {
|
|
||||||
return f64::INFINITY;
|
|
||||||
}
|
|
||||||
let mut total = 0.0_f64;
|
|
||||||
for r in reference {
|
|
||||||
let r_oriented = objectives.as_minimization(&r.objectives);
|
|
||||||
let mut min_d = f64::INFINITY;
|
|
||||||
for a in &approx_oriented {
|
|
||||||
let d: f64 = a
|
|
||||||
.iter()
|
|
||||||
.zip(r_oriented.iter())
|
|
||||||
.map(|(x, y)| (x - y).powi(2))
|
|
||||||
.sum::<f64>()
|
|
||||||
.sqrt();
|
|
||||||
if d < min_d {
|
|
||||||
min_d = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += min_d;
|
|
||||||
}
|
|
||||||
total / reference.len() as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
/// IGD+ — a dominance-respecting variant of IGD.
|
|
||||||
///
|
|
||||||
/// For each reference point `r`, the distance to an approximation
|
|
||||||
/// point `a` is computed only on objectives where `a` is *worse than*
|
|
||||||
/// `r` — i.e. on the "violation" component of the gap. This makes
|
|
||||||
/// IGD+ a Pareto-compliant indicator: adding a dominated point to the
|
|
||||||
/// approximation never improves the score.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// IGD+(A) = (1 / |R|) · Σ_{r ∈ R} min_{a ∈ A} ‖max(a − r, 0)‖₂
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// Lower is better.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// If `reference` is empty.
|
|
||||||
pub fn igd_plus<D>(
|
|
||||||
approximation: &[Candidate<D>],
|
|
||||||
reference: &[Evaluation],
|
|
||||||
objectives: &ObjectiveSpace,
|
|
||||||
) -> f64 {
|
|
||||||
assert!(
|
|
||||||
!reference.is_empty(),
|
|
||||||
"igd_plus: reference set must not be empty"
|
|
||||||
);
|
|
||||||
let approx_oriented: Vec<Vec<f64>> = approximation
|
|
||||||
.iter()
|
|
||||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
|
||||||
.collect();
|
|
||||||
if approx_oriented.is_empty() {
|
|
||||||
return f64::INFINITY;
|
|
||||||
}
|
|
||||||
let mut total = 0.0_f64;
|
|
||||||
for r in reference {
|
|
||||||
let r_oriented = objectives.as_minimization(&r.objectives);
|
|
||||||
let mut min_d = f64::INFINITY;
|
|
||||||
for a in &approx_oriented {
|
|
||||||
let d: f64 = a
|
|
||||||
.iter()
|
|
||||||
.zip(r_oriented.iter())
|
|
||||||
.map(|(x, y)| (x - y).max(0.0).powi(2))
|
|
||||||
.sum::<f64>()
|
|
||||||
.sqrt();
|
|
||||||
if d < min_d {
|
|
||||||
min_d = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += min_d;
|
|
||||||
}
|
|
||||||
total / reference.len() as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::core::objective::Objective;
|
|
||||||
|
|
||||||
fn space_min2() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cand(obj: Vec<f64>) -> Candidate<()> {
|
|
||||||
Candidate::new((), Evaluation::new(obj))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn igd_perfect_match_is_zero() {
|
|
||||||
let s = space_min2();
|
|
||||||
let approx = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
|
|
||||||
let reference = [
|
|
||||||
Evaluation::new(vec![0.0, 1.0]),
|
|
||||||
Evaluation::new(vec![1.0, 0.0]),
|
|
||||||
];
|
|
||||||
let v = igd(&approx, &reference, &s);
|
|
||||||
assert!(v < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn igd_known_value() {
|
|
||||||
let s = space_min2();
|
|
||||||
let approx = [cand(vec![0.0, 0.0])];
|
|
||||||
let reference = [Evaluation::new(vec![1.0, 1.0])];
|
|
||||||
let v = igd(&approx, &reference, &s);
|
|
||||||
assert!((v - 2.0_f64.sqrt()).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn igd_plus_dominated_point_does_not_improve() {
|
|
||||||
let s = space_min2();
|
|
||||||
let reference = [
|
|
||||||
Evaluation::new(vec![0.0, 1.0]),
|
|
||||||
Evaluation::new(vec![1.0, 0.0]),
|
|
||||||
];
|
|
||||||
let base = vec![cand(vec![0.5, 0.5])];
|
|
||||||
let with_dominated = vec![cand(vec![0.5, 0.5]), cand(vec![1.0, 1.0])];
|
|
||||||
let v_base = igd_plus(&base, &reference, &s);
|
|
||||||
let v_with = igd_plus(&with_dominated, &reference, &s);
|
|
||||||
// Adding a dominated point should not improve the score.
|
|
||||||
assert!(v_with >= v_base - 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn igd_empty_approximation_is_infinity() {
|
|
||||||
let s = space_min2();
|
|
||||||
let approx: [Candidate<()>; 0] = [];
|
|
||||||
let reference = [Evaluation::new(vec![0.0, 1.0])];
|
|
||||||
assert!(igd(&approx, &reference, &s).is_infinite());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "reference set must not be empty")]
|
|
||||||
fn igd_empty_reference_panics() {
|
|
||||||
let s = space_min2();
|
|
||||||
let approx = [cand(vec![0.0, 1.0])];
|
|
||||||
let _ = igd::<()>(&approx, &[], &s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
//! Quality metrics for Pareto fronts.
|
//! Quality metrics for Pareto fronts.
|
||||||
|
|
||||||
pub mod hypervolume;
|
pub mod hypervolume;
|
||||||
pub mod igd;
|
|
||||||
pub mod r2;
|
|
||||||
pub mod spacing;
|
pub mod spacing;
|
||||||
|
|
||||||
pub use hypervolume::*;
|
pub use hypervolume::*;
|
||||||
pub use igd::{igd, igd_plus};
|
|
||||||
pub use r2::r2;
|
|
||||||
pub use spacing::*;
|
pub use spacing::*;
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
//! R2 indicator — a unary quality measure for Pareto fronts.
|
|
||||||
//!
|
|
||||||
//! For each weight vector `λ` in a user-supplied set, find the
|
|
||||||
//! best (smallest) weighted Tchebycheff value across the front;
|
|
||||||
//! average over all weight vectors. Lower is better.
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::objective::ObjectiveSpace;
|
|
||||||
|
|
||||||
/// R2 indicator using the weighted Tchebycheff utility.
|
|
||||||
///
|
|
||||||
/// ```text
|
|
||||||
/// R2(A) = (1 / |Λ|) · Σ_{λ ∈ Λ} min_{a ∈ A} max_i { λ_i · |a_i − z*_i| }
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// where `z*` is the ideal point (per-axis minimum across the
|
|
||||||
/// approximation, in minimization-oriented coordinates) and `Λ` is
|
|
||||||
/// a set of unit-simplex weight vectors. Lower is better.
|
|
||||||
///
|
|
||||||
/// Use [`das_dennis`](crate::pareto::das_dennis) to generate the
|
|
||||||
/// canonical structured weight set.
|
|
||||||
///
|
|
||||||
/// # Panics
|
|
||||||
///
|
|
||||||
/// If the approximation is empty, or any weight vector has wrong
|
|
||||||
/// length / negative entries / zero sum.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use heuropt::metrics::r2::r2;
|
|
||||||
///
|
|
||||||
/// let space = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// let approx = [
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
|
||||||
/// ];
|
|
||||||
/// // Two weight vectors: (1, 0) and (0, 1) — extreme directions.
|
|
||||||
/// let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
/// let v = r2(&approx, &weights, &space);
|
|
||||||
/// // For each direction, the best front member matches that axis exactly.
|
|
||||||
/// // R2 = 0 since the ideal point is achieved on each direction.
|
|
||||||
/// assert!(v < 1e-12);
|
|
||||||
/// ```
|
|
||||||
pub fn r2<D>(
|
|
||||||
approximation: &[Candidate<D>],
|
|
||||||
weights: &[Vec<f64>],
|
|
||||||
objectives: &ObjectiveSpace,
|
|
||||||
) -> f64 {
|
|
||||||
assert!(
|
|
||||||
!approximation.is_empty(),
|
|
||||||
"r2: approximation must not be empty"
|
|
||||||
);
|
|
||||||
assert!(!weights.is_empty(), "r2: weight set must not be empty");
|
|
||||||
let m = objectives.len();
|
|
||||||
for (i, w) in weights.iter().enumerate() {
|
|
||||||
assert_eq!(
|
|
||||||
w.len(),
|
|
||||||
m,
|
|
||||||
"r2: weight {i} has wrong length ({} vs {m})",
|
|
||||||
w.len()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
w.iter().all(|&v| v >= 0.0),
|
|
||||||
"r2: weight {i} has a negative entry"
|
|
||||||
);
|
|
||||||
assert!(w.iter().sum::<f64>() > 0.0, "r2: weight {i} has zero sum");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert all approximation members to minimization orientation once.
|
|
||||||
let oriented: Vec<Vec<f64>> = approximation
|
|
||||||
.iter()
|
|
||||||
.map(|c| objectives.as_minimization(&c.evaluation.objectives))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Ideal point z* (per-axis minimum).
|
|
||||||
let mut z_star = vec![f64::INFINITY; m];
|
|
||||||
for o in &oriented {
|
|
||||||
for k in 0..m {
|
|
||||||
if o[k] < z_star[k] {
|
|
||||||
z_star[k] = o[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut total = 0.0_f64;
|
|
||||||
for w in weights {
|
|
||||||
let mut best = f64::INFINITY;
|
|
||||||
for o in &oriented {
|
|
||||||
// Weighted Tchebycheff: max_i { w_i · |o_i − z*_i| }
|
|
||||||
let mut t = 0.0_f64;
|
|
||||||
for k in 0..m {
|
|
||||||
let dk = (o[k] - z_star[k]).abs() * w[k];
|
|
||||||
if dk > t {
|
|
||||||
t = dk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if t < best {
|
|
||||||
best = t;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total += best;
|
|
||||||
}
|
|
||||||
total / weights.len() as f64
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Objective;
|
|
||||||
use crate::pareto::das_dennis;
|
|
||||||
|
|
||||||
fn space_min2() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cand(obj: Vec<f64>) -> Candidate<()> {
|
|
||||||
Candidate::new((), Evaluation::new(obj))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn r2_extremes_are_perfect_at_endpoints() {
|
|
||||||
let s = space_min2();
|
|
||||||
let front = [cand(vec![0.0, 1.0]), cand(vec![1.0, 0.0])];
|
|
||||||
let weights = [vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
assert!(r2(&front, &weights, &s) < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn r2_dense_dasdennis_finite_for_uniform_front() {
|
|
||||||
let s = space_min2();
|
|
||||||
let weights = das_dennis(2, 5);
|
|
||||||
let front: Vec<Candidate<()>> = (0..=10)
|
|
||||||
.map(|i| {
|
|
||||||
let t = i as f64 / 10.0;
|
|
||||||
cand(vec![t, 1.0 - t])
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let v = r2(&front, &weights, &s);
|
|
||||||
assert!(v.is_finite());
|
|
||||||
assert!(v >= 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "approximation must not be empty")]
|
|
||||||
fn r2_empty_approximation_panics() {
|
|
||||||
let s = space_min2();
|
|
||||||
let weights = vec![vec![1.0, 0.0]];
|
|
||||||
let _: f64 = r2::<()>(&[], &weights, &s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "weight set must not be empty")]
|
|
||||||
fn r2_empty_weights_panics() {
|
|
||||||
let s = space_min2();
|
|
||||||
let front = [cand(vec![0.0, 1.0])];
|
|
||||||
let _ = r2(&front, &[], &s);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "wrong length")]
|
|
||||||
fn r2_wrong_dim_weight_panics() {
|
|
||||||
let s = space_min2();
|
|
||||||
let front = [cand(vec![0.0, 1.0])];
|
|
||||||
let weights = vec![vec![1.0, 0.0, 0.0]];
|
|
||||||
let _ = r2(&front, &weights, &s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,26 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// uniform front has spacing 0.
|
/// uniform front has spacing 0.
|
||||||
///
|
///
|
||||||
/// Returns `0.0` for empty or single-point fronts (spec §14.1).
|
/// Returns `0.0` for empty or single-point fronts (spec §14.1).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
/// use heuropt::metrics::spacing;
|
||||||
|
///
|
||||||
|
/// let space = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// // Five points evenly spaced on a line — spacing should be 0.
|
||||||
|
/// let front: Vec<Candidate<()>> = (0..5)
|
||||||
|
/// .map(|i| {
|
||||||
|
/// let t = i as f64;
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![t, 4.0 - t]))
|
||||||
|
/// })
|
||||||
|
/// .collect();
|
||||||
|
/// assert!(spacing(&front, &space) < 1e-12);
|
||||||
|
/// ```
|
||||||
pub fn spacing<D>(front: &[Candidate<D>], objectives: &ObjectiveSpace) -> f64 {
|
pub fn spacing<D>(front: &[Candidate<D>], objectives: &ObjectiveSpace) -> f64 {
|
||||||
let n = front.len();
|
let n = front.len();
|
||||||
if n < 2 {
|
if n < 2 {
|
||||||
|
|||||||
@@ -1,418 +0,0 @@
|
|||||||
//! Built-in observers covering the common stop conditions.
|
|
||||||
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use super::{Observer, Snapshot};
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
/// Halt after a fixed wall-clock duration since `run_with` started.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use std::time::Duration;
|
|
||||||
///
|
|
||||||
/// let stop = MaxTime::new(Duration::from_millis(50));
|
|
||||||
/// // pass `&mut stop` to `Optimizer::run_with`.
|
|
||||||
/// # let _ = stop;
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct MaxTime {
|
|
||||||
pub limit: Duration,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaxTime {
|
|
||||||
pub fn new(limit: Duration) -> Self {
|
|
||||||
Self { limit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Observer<D> for MaxTime {
|
|
||||||
#[inline]
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
if snap.elapsed >= self.limit {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Halt after a target number of generations.
|
|
||||||
///
|
|
||||||
/// Most algorithms already take a `generations` count in their config,
|
|
||||||
/// so this is mostly useful for capping algorithms whose configured
|
|
||||||
/// loop is open-ended (or for testing).
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct MaxIterations {
|
|
||||||
pub limit: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MaxIterations {
|
|
||||||
pub fn new(limit: usize) -> Self {
|
|
||||||
Self { limit }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Observer<D> for MaxIterations {
|
|
||||||
#[inline]
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
if snap.iteration >= self.limit {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Halt as soon as the best single-objective fitness reaches `target`.
|
|
||||||
///
|
|
||||||
/// Direction-aware: for `Minimize` axes the target is reached when
|
|
||||||
/// `best ≤ target`; for `Maximize`, when `best ≥ target`.
|
|
||||||
///
|
|
||||||
/// Multi-objective snapshots (where `Snapshot::best` is `None` or the
|
|
||||||
/// problem has more than one objective) are silently ignored — this
|
|
||||||
/// observer never breaks them.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct TargetFitness {
|
|
||||||
pub target: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TargetFitness {
|
|
||||||
pub fn new(target: f64) -> Self {
|
|
||||||
Self { target }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Observer<D> for TargetFitness {
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
if !snap.objectives.is_single_objective() {
|
|
||||||
return ControlFlow::Continue(());
|
|
||||||
}
|
|
||||||
let direction = snap.objectives.objectives[0].direction;
|
|
||||||
if let Some(best) = snap.best
|
|
||||||
&& let Some(&v) = best.evaluation.objectives.first()
|
|
||||||
{
|
|
||||||
let hit = match direction {
|
|
||||||
Direction::Minimize => v <= self.target,
|
|
||||||
Direction::Maximize => v >= self.target,
|
|
||||||
};
|
|
||||||
if hit {
|
|
||||||
return ControlFlow::Break(());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Halt when the best single-objective fitness has not improved by
|
|
||||||
/// more than `tolerance` over the last `window` generations.
|
|
||||||
///
|
|
||||||
/// Multi-objective snapshots are silently ignored.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Stagnation {
|
|
||||||
pub window: usize,
|
|
||||||
pub tolerance: f64,
|
|
||||||
history: std::collections::VecDeque<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Stagnation {
|
|
||||||
pub fn new(window: usize, tolerance: f64) -> Self {
|
|
||||||
assert!(window > 0, "Stagnation window must be > 0");
|
|
||||||
assert!(
|
|
||||||
tolerance >= 0.0,
|
|
||||||
"Stagnation tolerance must be non-negative"
|
|
||||||
);
|
|
||||||
Self {
|
|
||||||
window,
|
|
||||||
tolerance,
|
|
||||||
history: std::collections::VecDeque::with_capacity(window + 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D> Observer<D> for Stagnation {
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
if !snap.objectives.is_single_objective() {
|
|
||||||
return ControlFlow::Continue(());
|
|
||||||
}
|
|
||||||
let direction = snap.objectives.objectives[0].direction;
|
|
||||||
let v = match snap
|
|
||||||
.best
|
|
||||||
.and_then(|c| c.evaluation.objectives.first().copied())
|
|
||||||
{
|
|
||||||
Some(v) => v,
|
|
||||||
None => return ControlFlow::Continue(()),
|
|
||||||
};
|
|
||||||
// Push to history; cap at window+1 so we always have 1 + window samples.
|
|
||||||
self.history.push_back(v);
|
|
||||||
while self.history.len() > self.window + 1 {
|
|
||||||
self.history.pop_front();
|
|
||||||
}
|
|
||||||
if self.history.len() <= self.window {
|
|
||||||
return ControlFlow::Continue(());
|
|
||||||
}
|
|
||||||
let oldest = self.history.front().copied().unwrap();
|
|
||||||
let newest = self.history.back().copied().unwrap();
|
|
||||||
let improvement = match direction {
|
|
||||||
Direction::Minimize => oldest - newest,
|
|
||||||
Direction::Maximize => newest - oldest,
|
|
||||||
};
|
|
||||||
if improvement <= self.tolerance {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compose two observers — break if **either** signals a break.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct AnyOf<A, B> {
|
|
||||||
pub a: A,
|
|
||||||
pub b: B,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D, A, B> Observer<D> for AnyOf<A, B>
|
|
||||||
where
|
|
||||||
A: Observer<D>,
|
|
||||||
B: Observer<D>,
|
|
||||||
{
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
// Always poll both so stateful observers (Stagnation) update
|
|
||||||
// their history, then OR the results.
|
|
||||||
let ra = self.a.observe(snap);
|
|
||||||
let rb = self.b.observe(snap);
|
|
||||||
if ra.is_break() || rb.is_break() {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compose two observers — break only if **both** signal a break in
|
|
||||||
/// the same call.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct AllOf<A, B> {
|
|
||||||
pub a: A,
|
|
||||||
pub b: B,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D, A, B> Observer<D> for AllOf<A, B>
|
|
||||||
where
|
|
||||||
A: Observer<D>,
|
|
||||||
B: Observer<D>,
|
|
||||||
{
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
let ra = self.a.observe(snap);
|
|
||||||
let rb = self.b.observe(snap);
|
|
||||||
if ra.is_break() && rb.is_break() {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Call a user closure every `every` generations (default 1 = every
|
|
||||||
/// generation). Useful for periodic logging without bloating callback
|
|
||||||
/// frequency.
|
|
||||||
pub struct Periodic<F> {
|
|
||||||
pub every: usize,
|
|
||||||
counter: usize,
|
|
||||||
pub callback: F,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<F> Periodic<F> {
|
|
||||||
pub fn new(every: usize, callback: F) -> Self {
|
|
||||||
assert!(every >= 1, "Periodic every must be >= 1");
|
|
||||||
Self {
|
|
||||||
every,
|
|
||||||
counter: 0,
|
|
||||||
callback,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D, F> Observer<D> for Periodic<F>
|
|
||||||
where
|
|
||||||
F: FnMut(&Snapshot<'_, D>),
|
|
||||||
{
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
self.counter += 1;
|
|
||||||
if self.counter >= self.every {
|
|
||||||
self.counter = 0;
|
|
||||||
(self.callback)(snap);
|
|
||||||
}
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tracing-backed observer — emits a structured `debug!` event per
|
|
||||||
/// generation with iteration / evaluations / elapsed / best fitness.
|
|
||||||
///
|
|
||||||
/// Available only with the `tracing` feature.
|
|
||||||
#[cfg(feature = "tracing")]
|
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
|
||||||
pub struct TracingObserver;
|
|
||||||
|
|
||||||
#[cfg(feature = "tracing")]
|
|
||||||
impl<D> Observer<D> for TracingObserver {
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
let best = snap
|
|
||||||
.best
|
|
||||||
.and_then(|c| c.evaluation.objectives.first().copied());
|
|
||||||
tracing::debug!(
|
|
||||||
iteration = snap.iteration,
|
|
||||||
evaluations = snap.evaluations,
|
|
||||||
elapsed_ms = snap.elapsed.as_millis() as u64,
|
|
||||||
best = ?best,
|
|
||||||
front_size = snap.pareto_front.map(|f| f.len()),
|
|
||||||
"heuropt generation",
|
|
||||||
);
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::{Objective, ObjectiveSpace};
|
|
||||||
|
|
||||||
fn snap_with_best<'a>(
|
|
||||||
iteration: usize,
|
|
||||||
elapsed_ms: u64,
|
|
||||||
best: Option<&'a Candidate<()>>,
|
|
||||||
objectives: &'a ObjectiveSpace,
|
|
||||||
empty_pop: &'a [Candidate<()>],
|
|
||||||
) -> Snapshot<'a, ()> {
|
|
||||||
Snapshot {
|
|
||||||
iteration,
|
|
||||||
evaluations: 0,
|
|
||||||
elapsed: Duration::from_millis(elapsed_ms),
|
|
||||||
population: empty_pop,
|
|
||||||
pareto_front: None,
|
|
||||||
best,
|
|
||||||
objectives,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn max_time_breaks_after_limit() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let mut o = MaxTime::new(Duration::from_millis(100));
|
|
||||||
let s = snap_with_best(0, 50, None, &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_continue());
|
|
||||||
let s = snap_with_best(1, 100, None, &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_break());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_fitness_minimize() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let cand = Candidate::new((), Evaluation::new(vec![0.005]));
|
|
||||||
let mut o = TargetFitness::new(0.01);
|
|
||||||
let s = snap_with_best(0, 0, Some(&cand), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_break());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn target_fitness_maximize() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::maximize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let cand_below = Candidate::new((), Evaluation::new(vec![0.5]));
|
|
||||||
let cand_above = Candidate::new((), Evaluation::new(vec![1.5]));
|
|
||||||
let mut o = TargetFitness::new(1.0);
|
|
||||||
let s = snap_with_best(0, 0, Some(&cand_below), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_continue());
|
|
||||||
let s = snap_with_best(1, 0, Some(&cand_above), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_break());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stagnation_breaks_on_no_improvement() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let mut o = Stagnation::new(3, 1e-6);
|
|
||||||
|
|
||||||
// Five generations of "no improvement" — same value every time.
|
|
||||||
for i in 0..3 {
|
|
||||||
let cand = Candidate::new((), Evaluation::new(vec![1.0]));
|
|
||||||
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
|
|
||||||
// First `window` calls just fill history; should not break.
|
|
||||||
assert!(o.observe(&s).is_continue());
|
|
||||||
}
|
|
||||||
let cand = Candidate::new((), Evaluation::new(vec![1.0]));
|
|
||||||
let s = snap_with_best(3, 0, Some(&cand), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_break());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn stagnation_does_not_break_on_improvement() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let mut o = Stagnation::new(2, 1e-6);
|
|
||||||
let values = [1.0, 0.9, 0.8, 0.7];
|
|
||||||
for (i, &v) in values.iter().enumerate() {
|
|
||||||
let cand = Candidate::new((), Evaluation::new(vec![v]));
|
|
||||||
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_continue(), "iter {i}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn anyof_breaks_when_either_breaks() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let cand = Candidate::new((), Evaluation::new(vec![5.0]));
|
|
||||||
let mut o =
|
|
||||||
<MaxIterations as Observer<()>>::or(MaxIterations::new(3), TargetFitness::new(1.0));
|
|
||||||
for i in 0..3 {
|
|
||||||
let s = snap_with_best(i, 0, Some(&cand), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_continue(), "iter {i}");
|
|
||||||
}
|
|
||||||
// iteration = 3 hits MaxIterations limit → break
|
|
||||||
let s = snap_with_best(3, 0, Some(&cand), &space, &pop);
|
|
||||||
assert!(o.observe(&s).is_break());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn periodic_calls_callback_every_n() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let mut count = 0_usize;
|
|
||||||
{
|
|
||||||
let mut o = Periodic::new(3, |_: &Snapshot<'_, ()>| count += 1);
|
|
||||||
for i in 0..10 {
|
|
||||||
let s = snap_with_best(i, 0, None, &space, &pop);
|
|
||||||
let _ = o.observe(&s);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert_eq!(count, 3); // every 3rd of 10 = generations 2, 5, 8
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closure_implements_observer() {
|
|
||||||
let space = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
|
||||||
let pop: Vec<Candidate<()>> = vec![];
|
|
||||||
let mut count = 0_usize;
|
|
||||||
let mut closure = |_: &Snapshot<'_, ()>| -> ControlFlow<()> {
|
|
||||||
count += 1;
|
|
||||||
if count >= 2 {
|
|
||||||
ControlFlow::Break(())
|
|
||||||
} else {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let s = snap_with_best(0, 0, None, &space, &pop);
|
|
||||||
assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_continue());
|
|
||||||
assert!(<_ as Observer<()>>::observe(&mut closure, &s).is_break());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
//! Per-generation observation, callbacks, and stop conditions.
|
|
||||||
//!
|
|
||||||
//! Algorithms accept an [`Observer`] via [`Optimizer::run_with`] and call
|
|
||||||
//! it once per generation (where "generation" makes sense for that
|
|
||||||
//! algorithm — see each algorithm's docs). Returning
|
|
||||||
//! [`std::ops::ControlFlow::Break`] from an observer halts the optimizer
|
|
||||||
//! and the partial [`OptimizationResult`] is returned to the caller.
|
|
||||||
//!
|
|
||||||
//! Observers can be composed with [`builtin::AnyOf`] / [`builtin::AllOf`].
|
|
||||||
//!
|
|
||||||
//! [`OptimizationResult`]: crate::core::result::OptimizationResult
|
|
||||||
//! [`Optimizer::run_with`]: crate::traits::Optimizer::run_with
|
|
||||||
|
|
||||||
pub mod builtin;
|
|
||||||
mod snapshot;
|
|
||||||
|
|
||||||
pub use snapshot::Snapshot;
|
|
||||||
|
|
||||||
use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::objective::ObjectiveSpace;
|
|
||||||
|
|
||||||
/// A callback invoked by an [`Optimizer`](crate::traits::Optimizer)
|
|
||||||
/// after every generation. Return [`ControlFlow::Break`] to halt
|
|
||||||
/// the optimizer; [`ControlFlow::Continue`] to keep going.
|
|
||||||
///
|
|
||||||
/// Implement directly for stateful observers that need to track
|
|
||||||
/// history (e.g. stagnation detection, convergence trace logging).
|
|
||||||
/// For simple stop conditions, use the helpers in
|
|
||||||
/// [`builtin`](crate::observer::builtin).
|
|
||||||
pub trait Observer<D> {
|
|
||||||
/// Inspect the latest snapshot. Return [`ControlFlow::Break`] to
|
|
||||||
/// halt the run; [`ControlFlow::Continue`] to keep going.
|
|
||||||
fn observe(&mut self, snapshot: &Snapshot<'_, D>) -> ControlFlow<()>;
|
|
||||||
|
|
||||||
/// Compose with another observer that fires when *either* of them
|
|
||||||
/// signals a break.
|
|
||||||
fn or<O: Observer<D>>(self, other: O) -> builtin::AnyOf<Self, O>
|
|
||||||
where
|
|
||||||
Self: Sized,
|
|
||||||
{
|
|
||||||
builtin::AnyOf { a: self, b: other }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compose with another observer that fires when *both* of them
|
|
||||||
/// signal a break in the same call.
|
|
||||||
fn and<O: Observer<D>>(self, other: O) -> builtin::AllOf<Self, O>
|
|
||||||
where
|
|
||||||
Self: Sized,
|
|
||||||
{
|
|
||||||
builtin::AllOf { a: self, b: other }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `()` is the no-op observer. Used as the default when callers don't
|
|
||||||
/// want any callbacks (it's what `run` uses internally).
|
|
||||||
impl<D> Observer<D> for () {
|
|
||||||
#[inline]
|
|
||||||
fn observe(&mut self, _: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
ControlFlow::Continue(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Closures of the right shape implement Observer too — short-form
|
|
||||||
/// for one-liner callbacks.
|
|
||||||
impl<D, F> Observer<D> for F
|
|
||||||
where
|
|
||||||
F: FnMut(&Snapshot<'_, D>) -> ControlFlow<()>,
|
|
||||||
{
|
|
||||||
#[inline]
|
|
||||||
fn observe(&mut self, snap: &Snapshot<'_, D>) -> ControlFlow<()> {
|
|
||||||
self(snap)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a snapshot for the "final notification" path of the default
|
|
||||||
/// `run_with` impl on [`Optimizer`](crate::traits::Optimizer).
|
|
||||||
///
|
|
||||||
/// Algorithm impls that override `run_with` to call the observer per
|
|
||||||
/// generation should construct their own snapshots inline rather than
|
|
||||||
/// using this helper, because they have richer per-generation state.
|
|
||||||
pub fn finalize_snapshot<'a, D>(
|
|
||||||
iteration: usize,
|
|
||||||
evaluations: usize,
|
|
||||||
elapsed: std::time::Duration,
|
|
||||||
population: &'a [Candidate<D>],
|
|
||||||
pareto_front: Option<&'a [Candidate<D>]>,
|
|
||||||
best: Option<&'a Candidate<D>>,
|
|
||||||
objectives: &'a ObjectiveSpace,
|
|
||||||
) -> Snapshot<'a, D> {
|
|
||||||
Snapshot {
|
|
||||||
iteration,
|
|
||||||
evaluations,
|
|
||||||
elapsed,
|
|
||||||
population,
|
|
||||||
pareto_front,
|
|
||||||
best,
|
|
||||||
objectives,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
//! Per-generation observation payload passed to [`Observer`](super::Observer).
|
|
||||||
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::objective::ObjectiveSpace;
|
|
||||||
|
|
||||||
/// A view of an optimizer's state at one generation boundary.
|
|
||||||
///
|
|
||||||
/// Borrowed (`&'a ...`) rather than owned so the algorithm doesn't
|
|
||||||
/// have to clone the whole population on every call. Observers that
|
|
||||||
/// need to retain values across calls should clone what they need
|
|
||||||
/// out of the snapshot.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Snapshot<'a, D> {
|
|
||||||
/// Zero-indexed generation count. The first call is `iteration = 0`
|
|
||||||
/// for "after the initial population was built and evaluated";
|
|
||||||
/// subsequent calls are after generation 1, 2, …
|
|
||||||
pub iteration: usize,
|
|
||||||
|
|
||||||
/// Total `Problem::evaluate` calls so far, including the initial
|
|
||||||
/// population.
|
|
||||||
pub evaluations: usize,
|
|
||||||
|
|
||||||
/// Wall-clock time since `run_with` started.
|
|
||||||
pub elapsed: Duration,
|
|
||||||
|
|
||||||
/// The current population (whatever the algorithm considers the
|
|
||||||
/// "live" set this generation). For steady-state algorithms this
|
|
||||||
/// is the post-replacement population.
|
|
||||||
pub population: &'a [Candidate<D>],
|
|
||||||
|
|
||||||
/// The current Pareto front, if the algorithm tracks one. `None`
|
|
||||||
/// for single-objective algorithms.
|
|
||||||
pub pareto_front: Option<&'a [Candidate<D>]>,
|
|
||||||
|
|
||||||
/// The current best candidate. `Some` for single-objective
|
|
||||||
/// algorithms; `None` for multi-objective unless the algorithm
|
|
||||||
/// tracks a notion of best (some don't).
|
|
||||||
pub best: Option<&'a Candidate<D>>,
|
|
||||||
|
|
||||||
/// The objective space, useful for observers that need to convert
|
|
||||||
/// raw objective values to minimization-oriented form.
|
|
||||||
pub objectives: &'a ObjectiveSpace,
|
|
||||||
}
|
|
||||||
@@ -9,6 +9,19 @@ use crate::traits::Variation;
|
|||||||
///
|
///
|
||||||
/// Always returns exactly one child (spec §11.3). Panics if `probability` is
|
/// Always returns exactly one child (spec §11.3). Panics if `probability` is
|
||||||
/// outside `[0.0, 1.0]` or if no parents are provided.
|
/// outside `[0.0, 1.0]` or if no parents are provided.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let mut m = BitFlipMutation { probability: 0.5 };
|
||||||
|
/// let parent = vec![true, false, true, false];
|
||||||
|
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// assert_eq!(children[0].len(), parent.len());
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BitFlipMutation {
|
pub struct BitFlipMutation {
|
||||||
/// Per-bit flip probability. Must lie in `[0.0, 1.0]`.
|
/// Per-bit flip probability. Must lie in `[0.0, 1.0]`.
|
||||||
|
|||||||
@@ -8,6 +8,22 @@ use crate::traits::Variation;
|
|||||||
/// Swap two distinct random indices in the first parent (spec §11.4).
|
/// Swap two distinct random indices in the first parent (spec §11.4).
|
||||||
///
|
///
|
||||||
/// If the parent has length `< 2` the child is returned unchanged.
|
/// If the parent has length `< 2` the child is returned unchanged.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let mut m = SwapMutation;
|
||||||
|
/// let parent: Vec<usize> = (0..6).collect();
|
||||||
|
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// // Still a permutation of [0, 1, 2, 3, 4, 5]:
|
||||||
|
/// let mut sorted = children[0].clone();
|
||||||
|
/// sorted.sort();
|
||||||
|
/// assert_eq!(sorted, vec![0, 1, 2, 3, 4, 5]);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
#[derive(Debug, Clone, Copy, Default)]
|
||||||
pub struct SwapMutation;
|
pub struct SwapMutation;
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ use crate::traits::{Initializer, Variation};
|
|||||||
///
|
///
|
||||||
/// Bounds are inclusive `(lo, hi)` ranges per dimension. Panics if any bound
|
/// Bounds are inclusive `(lo, hi)` ranges per dimension. Panics if any bound
|
||||||
/// has `lo > hi` (spec §11.1).
|
/// has `lo > hi` (spec §11.1).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let mut init = RealBounds::new(vec![(-1.0, 1.0); 3]);
|
||||||
|
/// let decisions = init.initialize(5, &mut rng);
|
||||||
|
/// assert_eq!(decisions.len(), 5);
|
||||||
|
/// for d in &decisions {
|
||||||
|
/// assert_eq!(d.len(), 3);
|
||||||
|
/// for &v in d {
|
||||||
|
/// assert!(v >= -1.0 && v <= 1.0);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RealBounds {
|
pub struct RealBounds {
|
||||||
/// Per-variable inclusive bounds in decision order.
|
/// Per-variable inclusive bounds in decision order.
|
||||||
@@ -54,6 +71,19 @@ impl Initializer<Vec<f64>> for RealBounds {
|
|||||||
/// Add `Normal(0, sigma)` noise to every variable of the first parent.
|
/// Add `Normal(0, sigma)` noise to every variable of the first parent.
|
||||||
///
|
///
|
||||||
/// Always returns exactly one child. Does not enforce bounds in v1 (spec §11.2).
|
/// Always returns exactly one child. Does not enforce bounds in v1 (spec §11.2).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let mut m = GaussianMutation { sigma: 0.1 };
|
||||||
|
/// let parent = vec![0.0; 4];
|
||||||
|
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// assert_eq!(children[0].len(), parent.len());
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GaussianMutation {
|
pub struct GaussianMutation {
|
||||||
/// Standard deviation of the Gaussian noise. Must be positive.
|
/// Standard deviation of the Gaussian noise. Must be positive.
|
||||||
@@ -88,6 +118,26 @@ impl Variation<Vec<f64>> for GaussianMutation {
|
|||||||
///
|
///
|
||||||
/// Panics on construction if any bound has `lo > hi`, or at run time if
|
/// Panics on construction if any bound has `lo > hi`, or at run time if
|
||||||
/// `parents.len() < 2` or any parent length differs from `bounds.len()`.
|
/// `parents.len() < 2` or any parent length differs from `bounds.len()`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let bounds = vec![(-1.0, 1.0); 3];
|
||||||
|
/// let mut sbx = SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5);
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let parents = [vec![-0.5, 0.0, 0.5], vec![0.5, 0.5, -0.5]];
|
||||||
|
/// let children = sbx.vary(&parents, &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 2);
|
||||||
|
/// // Children stay in bounds.
|
||||||
|
/// for c in &children {
|
||||||
|
/// for (j, &v) in c.iter().enumerate() {
|
||||||
|
/// let (lo, hi) = bounds[j];
|
||||||
|
/// assert!(v >= lo && v <= hi);
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SimulatedBinaryCrossover {
|
pub struct SimulatedBinaryCrossover {
|
||||||
/// Per-variable inclusive bounds. Length must match the parent decisions.
|
/// Per-variable inclusive bounds. Length must match the parent decisions.
|
||||||
@@ -180,6 +230,23 @@ impl Variation<Vec<f64>> for SimulatedBinaryCrossover {
|
|||||||
///
|
///
|
||||||
/// This is the simple bound-rescale form; the bound-aware `δ_q` variant from
|
/// This is the simple bound-rescale form; the bound-aware `δ_q` variant from
|
||||||
/// the full paper is left as a future refinement.
|
/// the full paper is left as a future refinement.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let bounds = vec![(-1.0, 1.0); 3];
|
||||||
|
/// let mut pm = PolynomialMutation::new(bounds.clone(), 20.0, 1.0 / 3.0);
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let parent = vec![0.0, 0.5, -0.5];
|
||||||
|
/// let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
/// let (lo, hi) = bounds[j];
|
||||||
|
/// assert!(v >= lo && v <= hi);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PolynomialMutation {
|
pub struct PolynomialMutation {
|
||||||
/// Per-variable inclusive bounds. Length must match the parent decision.
|
/// Per-variable inclusive bounds. Length must match the parent decision.
|
||||||
@@ -254,6 +321,23 @@ impl Variation<Vec<f64>> for PolynomialMutation {
|
|||||||
/// Always returns exactly one child. Use this when you want feasibility
|
/// Always returns exactly one child. Use this when you want feasibility
|
||||||
/// maintained across generations without leaning on
|
/// maintained across generations without leaning on
|
||||||
/// clamp-inside-`Problem::evaluate`.
|
/// clamp-inside-`Problem::evaluate`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let bounds = vec![(-1.0, 1.0); 3];
|
||||||
|
/// let mut m = BoundedGaussianMutation::new(0.3, bounds.clone());
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let parent = vec![0.0; 3];
|
||||||
|
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
/// let (lo, hi) = bounds[j];
|
||||||
|
/// assert!(v >= lo && v <= hi);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BoundedGaussianMutation {
|
pub struct BoundedGaussianMutation {
|
||||||
/// Standard deviation of the Gaussian noise. Must be positive.
|
/// Standard deviation of the Gaussian noise. Must be positive.
|
||||||
@@ -315,6 +399,23 @@ impl Variation<Vec<f64>> for BoundedGaussianMutation {
|
|||||||
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
|
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
|
||||||
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
|
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
|
||||||
/// heavy); `2.0` collapses to the Normal.
|
/// heavy); `2.0` collapses to the Normal.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let bounds = vec![(-1.0, 1.0); 3];
|
||||||
|
/// let mut m = LevyMutation::new(1.5, 0.1, bounds.clone());
|
||||||
|
/// let mut rng = rng_from_seed(42);
|
||||||
|
/// let parent = vec![0.0; 3];
|
||||||
|
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
||||||
|
/// assert_eq!(children.len(), 1);
|
||||||
|
/// for (j, &v) in children[0].iter().enumerate() {
|
||||||
|
/// let (lo, hi) = bounds[j];
|
||||||
|
/// assert!(v >= lo && v <= hi);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LevyMutation {
|
pub struct LevyMutation {
|
||||||
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
|
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ use crate::traits::Repair;
|
|||||||
/// The simplest possible repair — pair with `GaussianMutation` (which
|
/// The simplest possible repair — pair with `GaussianMutation` (which
|
||||||
/// doesn't enforce bounds in v1) to produce a bounds-respecting variant
|
/// doesn't enforce bounds in v1) to produce a bounds-respecting variant
|
||||||
/// without writing a custom Variation impl.
|
/// without writing a custom Variation impl.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut r = ClampToBounds::new(vec![(-1.0, 1.0); 3]);
|
||||||
|
/// let mut x = vec![-2.0, 0.5, 5.0];
|
||||||
|
/// r.repair(&mut x);
|
||||||
|
/// assert_eq!(x, vec![-1.0, 0.5, 1.0]);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ClampToBounds {
|
pub struct ClampToBounds {
|
||||||
/// Per-variable inclusive bounds.
|
/// Per-variable inclusive bounds.
|
||||||
@@ -46,6 +57,19 @@ impl Repair<Vec<f64>> for ClampToBounds {
|
|||||||
/// Perpiñán 2013. Useful for portfolio-style problems where the
|
/// Perpiñán 2013. Useful for portfolio-style problems where the
|
||||||
/// decision must sum to a budget, and for normalizing reference
|
/// decision must sum to a budget, and for normalizing reference
|
||||||
/// directions onto the unit simplex.
|
/// directions onto the unit simplex.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let mut r = ProjectToSimplex::new(1.0);
|
||||||
|
/// let mut x = vec![0.6, 0.5, -0.1, 0.3];
|
||||||
|
/// r.repair(&mut x);
|
||||||
|
/// let sum: f64 = x.iter().sum();
|
||||||
|
/// assert!((sum - 1.0).abs() < 1e-12);
|
||||||
|
/// assert!(x.iter().all(|&v| v >= 0.0));
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProjectToSimplex {
|
pub struct ProjectToSimplex {
|
||||||
/// Target sum (the simplex's "size"). Standard probability simplex
|
/// Target sum (the simplex's "size"). Standard probability simplex
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// archive insert/extend operations maintain the non-domination property among
|
/// archive insert/extend operations maintain the non-domination property among
|
||||||
/// members; `truncate` enforces a maximum size by simple tail-truncation in
|
/// members; `truncate` enforces a maximum size by simple tail-truncation in
|
||||||
/// v1.
|
/// v1.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// let mut a: ParetoArchive<u32> = ParetoArchive::new(s);
|
||||||
|
/// a.insert(Candidate::new(1, Evaluation::new(vec![1.0, 4.0])));
|
||||||
|
/// a.insert(Candidate::new(2, Evaluation::new(vec![3.0, 2.0])));
|
||||||
|
/// // Dominated by both — should be discarded:
|
||||||
|
/// a.insert(Candidate::new(3, Evaluation::new(vec![5.0, 5.0])));
|
||||||
|
/// assert_eq!(a.members().len(), 2);
|
||||||
|
/// ```
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ParetoArchive<D> {
|
pub struct ParetoArchive<D> {
|
||||||
/// The current approximate non-dominated set.
|
/// The current approximate non-dominated set.
|
||||||
|
|||||||
@@ -11,6 +11,28 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// `f64::INFINITY`. If the front has 0 entries an empty vector is returned;
|
/// `f64::INFINITY`. If the front has 0 entries an empty vector is returned;
|
||||||
/// 1 or 2 entries return all `f64::INFINITY`. All comparisons happen on
|
/// 1 or 2 entries return all `f64::INFINITY`. All comparisons happen on
|
||||||
/// minimization-oriented objective values (spec §9.6).
|
/// minimization-oriented objective values (spec §9.6).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// // Three points along a Pareto-like trade-off; the interior point gets
|
||||||
|
/// // a finite crowding distance, the boundaries get +∞.
|
||||||
|
/// let pop = [
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![0.0, 4.0])),
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![4.0, 0.0])),
|
||||||
|
/// ];
|
||||||
|
/// let d = crowding_distance(&pop, &[0, 1, 2], &s);
|
||||||
|
/// assert!(d[0].is_infinite());
|
||||||
|
/// assert!(d[1].is_finite() && d[1] > 0.0);
|
||||||
|
/// assert!(d[2].is_infinite());
|
||||||
|
/// ```
|
||||||
pub fn crowding_distance<D>(
|
pub fn crowding_distance<D>(
|
||||||
population: &[Candidate<D>],
|
population: &[Candidate<D>],
|
||||||
front: &[usize],
|
front: &[usize],
|
||||||
|
|||||||
@@ -29,6 +29,21 @@ pub enum Dominance {
|
|||||||
/// `constraint_violation` dominates.
|
/// `constraint_violation` dominates.
|
||||||
/// 3. Otherwise compare objective values after converting both to
|
/// 3. Otherwise compare objective values after converting both to
|
||||||
/// minimization orientation via [`ObjectiveSpace::as_minimization`].
|
/// minimization orientation via [`ObjectiveSpace::as_minimization`].
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// let a = Evaluation::new(vec![1.0, 1.0]);
|
||||||
|
/// let b = Evaluation::new(vec![2.0, 2.0]);
|
||||||
|
/// assert_eq!(pareto_compare(&a, &b, &s), Dominance::Dominates);
|
||||||
|
/// assert_eq!(pareto_compare(&b, &a, &s), Dominance::DominatedBy);
|
||||||
|
/// ```
|
||||||
pub fn pareto_compare(a: &Evaluation, b: &Evaluation, objectives: &ObjectiveSpace) -> Dominance {
|
pub fn pareto_compare(a: &Evaluation, b: &Evaluation, objectives: &ObjectiveSpace) -> Dominance {
|
||||||
let a_feasible = a.is_feasible();
|
let a_feasible = a.is_feasible();
|
||||||
let b_feasible = b.is_feasible();
|
let b_feasible = b.is_feasible();
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ use crate::pareto::dominance::{Dominance, pareto_compare};
|
|||||||
///
|
///
|
||||||
/// O(N²·M) in v1 (spec §9.3). Input order is preserved among returned
|
/// O(N²·M) in v1 (spec §9.3). Input order is preserved among returned
|
||||||
/// candidates.
|
/// candidates.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// let pop = [
|
||||||
|
/// Candidate::new(1u32, Evaluation::new(vec![1.0, 4.0])), // non-dominated
|
||||||
|
/// Candidate::new(2u32, Evaluation::new(vec![3.0, 2.0])), // non-dominated
|
||||||
|
/// Candidate::new(3u32, Evaluation::new(vec![5.0, 5.0])), // dominated
|
||||||
|
/// ];
|
||||||
|
/// let front = pareto_front(&pop, &s);
|
||||||
|
/// let kept: Vec<u32> = front.iter().map(|c| c.decision).collect();
|
||||||
|
/// assert_eq!(kept, vec![1, 2]);
|
||||||
|
/// ```
|
||||||
pub fn pareto_front<D: Clone>(
|
pub fn pareto_front<D: Clone>(
|
||||||
population: &[Candidate<D>],
|
population: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -34,6 +53,21 @@ pub fn pareto_front<D: Clone>(
|
|||||||
///
|
///
|
||||||
/// Returns `None` if there is not exactly one objective, if the population is
|
/// Returns `None` if there is not exactly one objective, if the population is
|
||||||
/// empty, or if every candidate is infeasible (spec §9.4).
|
/// empty, or if every candidate is infeasible (spec §9.4).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![Objective::minimize("f")]);
|
||||||
|
/// let pop = [
|
||||||
|
/// Candidate::new(1u32, Evaluation::new(vec![3.0])),
|
||||||
|
/// Candidate::new(2u32, Evaluation::new(vec![1.0])),
|
||||||
|
/// Candidate::new(3u32, Evaluation::new(vec![2.0])),
|
||||||
|
/// ];
|
||||||
|
/// let best = best_candidate(&pop, &s).unwrap();
|
||||||
|
/// assert_eq!(best.decision, 2);
|
||||||
|
/// ```
|
||||||
pub fn best_candidate<D: Clone>(
|
pub fn best_candidate<D: Clone>(
|
||||||
population: &[Candidate<D>],
|
population: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
@@ -10,6 +10,21 @@
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// If `num_objectives == 0`.
|
/// If `num_objectives == 0`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// // 3 objectives, 4 divisions → binomial(6, 2) = 15 points.
|
||||||
|
/// let pts = das_dennis(3, 4);
|
||||||
|
/// assert_eq!(pts.len(), 15);
|
||||||
|
/// for w in &pts {
|
||||||
|
/// assert_eq!(w.len(), 3);
|
||||||
|
/// let sum: f64 = w.iter().sum();
|
||||||
|
/// assert!((sum - 1.0).abs() < 1e-12);
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
pub fn das_dennis(num_objectives: usize, divisions: usize) -> Vec<Vec<f64>> {
|
pub fn das_dennis(num_objectives: usize, divisions: usize) -> Vec<Vec<f64>> {
|
||||||
assert!(
|
assert!(
|
||||||
num_objectives > 0,
|
num_objectives > 0,
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// non-dominated after removing `fronts[0]`, and so on. Each entry is an index
|
/// non-dominated after removing `fronts[0]`, and so on. Each entry is an index
|
||||||
/// into the input population. Equal-objective candidates land on the same
|
/// into the input population. Equal-objective candidates land on the same
|
||||||
/// front. O(N²·M) is acceptable for v1 (spec §9.5).
|
/// front. O(N²·M) is acceptable for v1 (spec §9.5).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use heuropt::prelude::*;
|
||||||
|
///
|
||||||
|
/// let s = ObjectiveSpace::new(vec![
|
||||||
|
/// Objective::minimize("f1"),
|
||||||
|
/// Objective::minimize("f2"),
|
||||||
|
/// ]);
|
||||||
|
/// let pop = [
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![1.0, 5.0])), // front 0
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![2.0, 3.0])), // front 0
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![4.0, 1.0])), // front 0
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![3.0, 4.0])), // front 1
|
||||||
|
/// Candidate::new((), Evaluation::new(vec![5.0, 6.0])), // front 2
|
||||||
|
/// ];
|
||||||
|
/// let fronts = non_dominated_sort(&pop, &s);
|
||||||
|
/// assert_eq!(fronts.len(), 3);
|
||||||
|
/// ```
|
||||||
pub fn non_dominated_sort<D>(
|
pub fn non_dominated_sort<D>(
|
||||||
population: &[Candidate<D>],
|
population: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
|
|||||||
+2
-8
@@ -4,6 +4,8 @@
|
|||||||
//! use heuropt::prelude::*;
|
//! use heuropt::prelude::*;
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
#[cfg(feature = "async")]
|
||||||
|
pub use crate::core::async_problem::AsyncProblem;
|
||||||
pub use crate::core::{
|
pub use crate::core::{
|
||||||
Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult,
|
Candidate, Direction, Evaluation, Objective, ObjectiveSpace, OptimizationResult,
|
||||||
PartialProblem, Population, Problem, Rng, rng_from_seed,
|
PartialProblem, Population, Problem, Rng, rng_from_seed,
|
||||||
@@ -11,14 +13,6 @@ pub use crate::core::{
|
|||||||
|
|
||||||
pub use crate::traits::{Initializer, Optimizer, Repair, Variation};
|
pub use crate::traits::{Initializer, Optimizer, Repair, Variation};
|
||||||
|
|
||||||
#[cfg(feature = "tracing")]
|
|
||||||
pub use crate::observer::builtin::TracingObserver;
|
|
||||||
pub use crate::observer::{
|
|
||||||
Observer, Snapshot,
|
|
||||||
builtin::{AllOf, AnyOf, MaxIterations, MaxTime, Periodic, Stagnation, TargetFitness},
|
|
||||||
};
|
|
||||||
pub use std::ops::ControlFlow;
|
|
||||||
|
|
||||||
pub use crate::pareto::{
|
pub use crate::pareto::{
|
||||||
Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort,
|
Dominance, ParetoArchive, best_candidate, crowding_distance, das_dennis, non_dominated_sort,
|
||||||
pareto_compare, pareto_front,
|
pareto_compare, pareto_front,
|
||||||
|
|||||||
+3
-56
@@ -1,71 +1,18 @@
|
|||||||
//! The single trait users implement to add a new optimizer.
|
//! The single trait users implement to add a new optimizer.
|
||||||
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
use crate::core::problem::Problem;
|
use crate::core::problem::Problem;
|
||||||
use crate::core::result::OptimizationResult;
|
use crate::core::result::OptimizationResult;
|
||||||
use crate::observer::{Observer, Snapshot};
|
|
||||||
|
|
||||||
/// An optimizer that runs to completion in a single call.
|
/// An optimizer that runs to completion in a single call.
|
||||||
///
|
///
|
||||||
/// Implementations own their main loop, manage their own state, and return an
|
/// Implementations own their main loop, manage their own state, and return an
|
||||||
/// [`OptimizationResult`]. Invalid configuration panics with a clear
|
/// [`OptimizationResult`]. v1 deliberately does not expose a step-by-step API
|
||||||
/// message rather than returning a `Result`.
|
/// or an associated error type — invalid configuration may panic with a clear
|
||||||
|
/// message.
|
||||||
pub trait Optimizer<P>
|
pub trait Optimizer<P>
|
||||||
where
|
where
|
||||||
P: Problem,
|
P: Problem,
|
||||||
{
|
{
|
||||||
/// Run the optimizer to completion against `problem`.
|
/// Run the optimizer to completion against `problem`.
|
||||||
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision>;
|
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision>;
|
||||||
|
|
||||||
/// Run with an [`Observer`] called after each generation.
|
|
||||||
///
|
|
||||||
/// The observer can halt the run by returning
|
|
||||||
/// [`std::ops::ControlFlow::Break`]; the partial result is still
|
|
||||||
/// returned. Built-in observers in
|
|
||||||
/// [`heuropt::observer::builtin`](crate::observer::builtin) cover
|
|
||||||
/// the common stop conditions (`MaxTime`, `TargetFitness`,
|
|
||||||
/// `Stagnation`, …).
|
|
||||||
///
|
|
||||||
/// **Default impl:** falls back to `run` plus a single final
|
|
||||||
/// notification. Algorithms that override this method get true
|
|
||||||
/// per-generation observation; algorithms that don't get a single
|
|
||||||
/// notification at the end. The trait-level docstring on each
|
|
||||||
/// algorithm calls out which behavior it supports.
|
|
||||||
fn run_with<O>(&mut self, problem: &P, observer: &mut O) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
O: Observer<P::Decision>,
|
|
||||||
{
|
|
||||||
let started = Instant::now();
|
|
||||||
let result = self.run(problem);
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
notify_final(&result, elapsed, problem, observer);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper used by the default `run_with` impl: build a single final-
|
|
||||||
/// state snapshot and hand it to the observer once. Algorithms that
|
|
||||||
/// override `run_with` for per-generation reporting don't go through
|
|
||||||
/// this path — they construct their own per-iteration snapshots.
|
|
||||||
fn notify_final<P, O>(
|
|
||||||
result: &OptimizationResult<P::Decision>,
|
|
||||||
elapsed: Duration,
|
|
||||||
problem: &P,
|
|
||||||
observer: &mut O,
|
|
||||||
) where
|
|
||||||
P: Problem,
|
|
||||||
O: Observer<P::Decision>,
|
|
||||||
{
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let snap = Snapshot {
|
|
||||||
iteration: result.generations,
|
|
||||||
evaluations: result.evaluations,
|
|
||||||
elapsed,
|
|
||||||
population: result.population.as_slice(),
|
|
||||||
pareto_front: Some(result.pareto_front.as_slice()),
|
|
||||||
best: result.best.as_ref(),
|
|
||||||
objectives: &objectives,
|
|
||||||
};
|
|
||||||
let _ = observer.observe(&snap);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user