Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cf518200a
|
||
|
|
41122b7d48
|
||
|
|
b0f580841d
|
+117
-1
@@ -7,6 +7,122 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`heuropt-plot` companion crate (v0.1.0)** at `heuropt-plot/`,
|
||||||
|
published independently. Lightweight SVG-only plotter for Pareto
|
||||||
|
fronts (`pareto_front_svg`) and convergence traces
|
||||||
|
(`convergence_svg`) — hand-rolled SVG output, no `plotters` /
|
||||||
|
`tiny-skia` dep so the crate stays a tiny optional addition.
|
||||||
|
- `examples/visualize.rs` — runs NSGA-II on Schaffer N.1 with a
|
||||||
|
closure observer that records hypervolume per generation, then
|
||||||
|
emits `pareto_front.svg` + `convergence.svg` via `heuropt-plot`.
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-05-05
|
||||||
|
|
||||||
|
Theme: async evaluation. heuropt now supports problems where each
|
||||||
|
evaluation is a `.await`-able operation — HTTP services, RPC clients,
|
||||||
|
spawned subprocesses. This is the differentiating capability vs.
|
||||||
|
pymoo / hyperopt / MOEA Framework, none of which ship first-class
|
||||||
|
async support.
|
||||||
|
|
||||||
|
No public-API breaks for synchronous users. The new surface is
|
||||||
|
gated behind a new `async` feature flag.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- Per-algorithm `run_async(&problem, concurrency).await` methods on
|
||||||
|
`RandomSearch` and `DifferentialEvolution` — drives evaluations
|
||||||
|
through whichever async runtime the caller is using (typically
|
||||||
|
tokio). `concurrency` bounds in-flight evaluations.
|
||||||
|
- Internal `algorithms::parallel_eval_async::evaluate_batch_async`
|
||||||
|
helper — uses `futures::stream::FuturesOrdered` with concurrency-
|
||||||
|
bounded chunks, preserves 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.
|
||||||
|
|
||||||
|
[0.7.0]: https://github.com/swaits/heuropt/releases/tag/v0.7.0
|
||||||
|
|
||||||
|
## [0.6.0] — 2026-05-05
|
||||||
|
|
||||||
|
Theme: production lifecycle. heuropt becomes deployable for long-
|
||||||
|
running, real-world optimization workloads — callbacks, stop
|
||||||
|
conditions, tracing, and two new performance indicators.
|
||||||
|
|
||||||
|
No breaking changes to the public API. Existing `Optimizer<P>` impls
|
||||||
|
keep compiling — `run_with` is added as a default-impl method that
|
||||||
|
falls back to `run` plus a single final notification.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
#### Observer + stop-conditions API
|
||||||
|
|
||||||
|
A new module `heuropt::observer` introduces:
|
||||||
|
|
||||||
|
- `Snapshot<'a, D>` — per-generation observation payload with
|
||||||
|
`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`)
|
||||||
|
|
||||||
|
- `MaxTime(Duration)` — wall-clock cap.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
New optional feature `tracing`, gated on the
|
||||||
|
[`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
|
||||||
|
|
||||||
Theme: comprehensive documentation and project polish. No public-API
|
Theme: comprehensive documentation and project polish. No public-API
|
||||||
@@ -469,5 +585,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.5.0...HEAD
|
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.7.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
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,9 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [".", "heuropt-plot"]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "heuropt"
|
name = "heuropt"
|
||||||
version = "0.5.0"
|
version = "0.7.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,21 +20,31 @@ 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"
|
||||||
|
heuropt-plot = { path = "heuropt-plot" }
|
||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450" font-family="system-ui, sans-serif" font-size="12">
|
||||||
|
<rect x="0" y="0" width="700" height="450" fill="white"/>
|
||||||
|
<text x="70" y="22" font-size="16" font-weight="bold">NSGA-II on Schaffer N.1 — hypervolume per generation</text>
|
||||||
|
<rect x="70" y="40" width="610" height="360" fill="none" stroke="#888" />
|
||||||
|
<line x1="70" y1="400" x2="70" y2="405" stroke="#888" />
|
||||||
|
<text x="70" y="418" text-anchor="middle">0</text>
|
||||||
|
<line x1="222.5" y1="400" x2="222.5" y2="405" stroke="#888" />
|
||||||
|
<text x="222.5" y="418" text-anchor="middle">25</text>
|
||||||
|
<line x1="375" y1="400" x2="375" y2="405" stroke="#888" />
|
||||||
|
<text x="375" y="418" text-anchor="middle">50</text>
|
||||||
|
<line x1="527.5" y1="400" x2="527.5" y2="405" stroke="#888" />
|
||||||
|
<text x="527.5" y="418" text-anchor="middle">75</text>
|
||||||
|
<line x1="680" y1="400" x2="680" y2="405" stroke="#888" />
|
||||||
|
<text x="680" y="418" text-anchor="middle">100</text>
|
||||||
|
<line x1="65" y1="400" x2="70" y2="400" stroke="#888" />
|
||||||
|
<text x="62" y="400" text-anchor="end" dominant-baseline="middle">9.552e1</text>
|
||||||
|
<line x1="65" y1="279.999999999999" x2="70" y2="279.999999999999" stroke="#888" />
|
||||||
|
<text x="62" y="279.999999999999" text-anchor="end" dominant-baseline="middle">9.608e1</text>
|
||||||
|
<line x1="65" y1="160.00000000000102" x2="70" y2="160.00000000000102" stroke="#888" />
|
||||||
|
<text x="62" y="160.00000000000102" text-anchor="end" dominant-baseline="middle">9.664e1</text>
|
||||||
|
<line x1="65" y1="40" x2="70" y2="40" stroke="#888" />
|
||||||
|
<text x="62" y="40" text-anchor="end" dominant-baseline="middle">9.721e1</text>
|
||||||
|
<text x="375" y="438" text-anchor="middle">generation</text>
|
||||||
|
<text x="15" y="220" text-anchor="middle" transform="rotate(-90 15 220)">hypervolume</text>
|
||||||
|
<polyline points="70.00,400.00 76.10,113.85 82.20,59.99 88.30,44.22 94.40,45.84 100.50,47.93 106.60,45.55 112.70,40.78 118.80,43.34 124.90,41.17 131.00,42.71 137.10,44.97 143.20,43.62 149.30,43.69 155.40,46.67 161.50,40.97 167.60,41.76 173.70,41.27 179.80,46.13 185.90,42.18 192.00,44.17 198.10,42.62 204.20,41.90 210.30,42.69 216.40,40.00 222.50,42.66 228.60,48.64 234.70,47.22 240.80,42.14 246.90,45.35 253.00,48.46 259.10,42.98 265.20,44.96 271.30,44.55 277.40,44.30 283.50,43.54 289.60,43.88 295.70,42.70 301.80,44.10 307.90,46.88 314.00,46.56 320.10,42.39 326.20,44.45 332.30,45.03 338.40,46.44 344.50,44.44 350.60,45.79 356.70,49.23 362.80,42.47 368.90,44.42 375.00,50.51 381.10,44.45 387.20,42.54 393.30,42.50 399.40,42.83 405.50,40.56 411.60,49.78 417.70,47.03 423.80,44.17 429.90,45.73 436.00,42.66 442.10,44.31 448.20,49.69 454.30,41.19 460.40,41.14 466.50,41.87 472.60,41.88 478.70,41.41 484.80,43.24 490.90,45.61 497.00,45.55 503.10,45.16 509.20,46.10 515.30,42.66 521.40,42.46 527.50,48.78 533.60,46.56 539.70,42.49 545.80,43.23 551.90,47.51 558.00,44.28 564.10,45.05 570.20,43.75 576.30,44.15 582.40,42.29 588.50,41.54 594.60,43.25 600.70,41.56 606.80,48.07 612.90,42.87 619.00,42.68 625.10,43.69 631.20,45.47 637.30,42.93 643.40,40.77 649.50,42.53 655.60,42.89 661.70,44.33 667.80,41.27 673.90,44.06 680.00,41.55" fill="none" stroke="#1f77b4" stroke-width="1.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,73 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450" font-family="system-ui, sans-serif" font-size="12">
|
||||||
|
<rect x="0" y="0" width="700" height="450" fill="white"/>
|
||||||
|
<text x="60" y="22" font-size="16" font-weight="bold">NSGA-II on Schaffer N.1 — final Pareto front</text>
|
||||||
|
<rect x="60" y="40" width="620" height="360" fill="none" stroke="#888" />
|
||||||
|
<line x1="60" y1="400" x2="60" y2="405" stroke="#888" />
|
||||||
|
<text x="60" y="418" text-anchor="middle">0.000</text>
|
||||||
|
<line x1="266.66666666666663" y1="400" x2="266.66666666666663" y2="405" stroke="#888" />
|
||||||
|
<text x="266.66666666666663" y="418" text-anchor="middle">1.333</text>
|
||||||
|
<line x1="473.3333333333333" y1="400" x2="473.3333333333333" y2="405" stroke="#888" />
|
||||||
|
<text x="473.3333333333333" y="418" text-anchor="middle">2.667</text>
|
||||||
|
<line x1="680" y1="400" x2="680" y2="405" stroke="#888" />
|
||||||
|
<text x="680" y="418" text-anchor="middle">4.000</text>
|
||||||
|
<line x1="55" y1="400" x2="60" y2="400" stroke="#888" />
|
||||||
|
<text x="52" y="400" text-anchor="end" dominant-baseline="middle">0.000</text>
|
||||||
|
<line x1="55" y1="280" x2="60" y2="280" stroke="#888" />
|
||||||
|
<text x="52" y="280" text-anchor="end" dominant-baseline="middle">1.333</text>
|
||||||
|
<line x1="55" y1="160" x2="60" y2="160" stroke="#888" />
|
||||||
|
<text x="52" y="160" text-anchor="end" dominant-baseline="middle">2.666</text>
|
||||||
|
<line x1="55" y1="40" x2="60" y2="40" stroke="#888" />
|
||||||
|
<text x="52" y="40" text-anchor="end" dominant-baseline="middle">3.999</text>
|
||||||
|
<text x="370" y="438" text-anchor="middle">f1</text>
|
||||||
|
<text x="15" y="220" text-anchor="middle" transform="rotate(-90 15 220)">f2</text>
|
||||||
|
<circle cx="680.00" cy="400.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="60.00" cy="40.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="544.53" cy="395.16" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="596.60" cy="398.25" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="147.76" cy="259.91" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="77.82" cy="151.68" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="166.09" cy="276.21" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="512.44" cy="392.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="457.04" cy="385.63" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="122.47" cy="232.25" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="133.03" cy="244.68" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="92.63" cy="186.20" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="277.90" cy="340.31" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="329.99" cy="358.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="86.30" cy="173.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="83.00" cy="165.28" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="479.72" cy="388.69" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="71.88" cy="132.73" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="177.45" cy="285.16" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="60.12" cy="49.89" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="139.30" cy="251.43" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="228.69" cy="317.60" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="661.27" cy="399.92" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="247.24" cy="326.94" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="97.14" cy="194.62" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="291.01" cy="345.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="188.72" cy="293.31" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="237.44" cy="322.14" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="62.32" cy="82.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="400.75" cy="375.91" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="314.75" cy="353.60" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="206.51" cy="304.92" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="436.65" cy="382.48" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="263.18" cy="334.19" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="344.89" cy="362.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="67.81" cy="116.23" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="61.00" cy="68.34" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="60.73" cy="64.24" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="641.54" cy="399.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="115.80" cy="223.58" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="216.38" cy="310.79" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="169.95" cy="279.34" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="384.40" cy="372.44" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="65.45" cy="104.28" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="633.74" cy="399.48" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="64.21" cy="96.82" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="107.71" cy="212.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="414.47" cy="378.59" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="616.88" cy="399.02" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
<circle cx="419.20" cy="379.46" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.2 KiB |
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//! 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Visualize an NSGA-II run on Schaffer N.1 — produces two SVGs:
|
||||||
|
//! `pareto_front.svg` (scatter plot of the final front) and
|
||||||
|
//! `convergence.svg` (best-so-far hypervolume per generation).
|
||||||
|
//!
|
||||||
|
//! Uses the `heuropt-plot` companion crate plus the v0.6 observer
|
||||||
|
//! API (`Periodic`) to record per-generation hypervolume into a Vec
|
||||||
|
//! during the run.
|
||||||
|
//!
|
||||||
|
//! Run with: `cargo run --release --example visualize`
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::ops::ControlFlow;
|
||||||
|
|
||||||
|
use heuropt::metrics::hypervolume_2d;
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
use heuropt_plot::{convergence_svg, pareto_front_svg};
|
||||||
|
|
||||||
|
struct Schaffer;
|
||||||
|
|
||||||
|
impl Problem for Schaffer {
|
||||||
|
type Decision = Vec<f64>;
|
||||||
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
||||||
|
}
|
||||||
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let problem = Schaffer;
|
||||||
|
let bounds = vec![(-5.0_f64, 5.0_f64)];
|
||||||
|
let space = problem.objectives();
|
||||||
|
let ref_point = [10.0, 10.0];
|
||||||
|
|
||||||
|
// Per-generation hypervolume trace, recorded by the observer.
|
||||||
|
let history: RefCell<Vec<f64>> = RefCell::new(Vec::new());
|
||||||
|
|
||||||
|
let mut recorder = |snap: &Snapshot<'_, Vec<f64>>| -> ControlFlow<()> {
|
||||||
|
let hv = match snap.pareto_front {
|
||||||
|
Some(front) => hypervolume_2d(front, snap.objectives, ref_point),
|
||||||
|
None => 0.0,
|
||||||
|
};
|
||||||
|
history.borrow_mut().push(hv);
|
||||||
|
ControlFlow::Continue(())
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut opt = Nsga2::new(
|
||||||
|
Nsga2Config {
|
||||||
|
population_size: 50,
|
||||||
|
generations: 100,
|
||||||
|
seed: 42,
|
||||||
|
},
|
||||||
|
RealBounds::new(bounds.clone()),
|
||||||
|
CompositeVariation {
|
||||||
|
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
||||||
|
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = opt.run_with(&problem, &mut recorder);
|
||||||
|
|
||||||
|
let front_svg = pareto_front_svg(
|
||||||
|
&result.pareto_front,
|
||||||
|
&space,
|
||||||
|
700,
|
||||||
|
450,
|
||||||
|
"NSGA-II on Schaffer N.1 — final Pareto front",
|
||||||
|
);
|
||||||
|
std::fs::write("pareto_front.svg", front_svg).expect("write pareto_front.svg");
|
||||||
|
|
||||||
|
let trace = history.borrow();
|
||||||
|
let conv_svg = convergence_svg(
|
||||||
|
&trace,
|
||||||
|
700,
|
||||||
|
450,
|
||||||
|
"NSGA-II on Schaffer N.1 — hypervolume per generation",
|
||||||
|
"hypervolume",
|
||||||
|
false, // higher is better
|
||||||
|
);
|
||||||
|
std::fs::write("convergence.svg", conv_svg).expect("write convergence.svg");
|
||||||
|
|
||||||
|
println!("Final front size: {}", result.pareto_front.len());
|
||||||
|
println!(
|
||||||
|
"Final hypervolume: {:.4}",
|
||||||
|
trace.last().copied().unwrap_or(0.0)
|
||||||
|
);
|
||||||
|
println!("Wrote pareto_front.svg and convergence.svg");
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "heuropt-plot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.85"
|
||||||
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
|
description = "Lightweight SVG visualization for heuropt Pareto fronts and convergence traces."
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://github.com/swaits/heuropt"
|
||||||
|
homepage = "https://github.com/swaits/heuropt"
|
||||||
|
documentation = "https://docs.rs/heuropt-plot"
|
||||||
|
keywords = ["optimization", "pareto", "svg", "plotting", "heuropt"]
|
||||||
|
categories = ["algorithms", "visualization"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
heuropt = { version = "0.7", path = ".." }
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# heuropt-plot
|
||||||
|
|
||||||
|
[](https://crates.io/crates/heuropt-plot)
|
||||||
|
[](https://docs.rs/heuropt-plot)
|
||||||
|
[](../LICENSE)
|
||||||
|
|
||||||
|
Lightweight SVG plotting helpers for [`heuropt`](https://crates.io/crates/heuropt)
|
||||||
|
results.
|
||||||
|
|
||||||
|
Hand-rolled SVG output (no `plotters`, no `tiny-skia`, no
|
||||||
|
heavyweight dependency) so adding `heuropt-plot` to your project
|
||||||
|
costs ~20 KB of compiled code.
|
||||||
|
|
||||||
|
## What's in the box
|
||||||
|
|
||||||
|
- `pareto_front_svg` — render a 2-objective Pareto front as an SVG
|
||||||
|
scatter plot with axes and labels.
|
||||||
|
- `convergence_svg` — render a "best fitness so far" trace as an
|
||||||
|
SVG line plot.
|
||||||
|
|
||||||
|
Output is a `String` of valid SVG. Write it to a file, embed it in
|
||||||
|
HTML, or pipe it to a browser.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use heuropt::prelude::*;
|
||||||
|
use heuropt_plot::pareto_front_svg;
|
||||||
|
|
||||||
|
let space = ObjectiveSpace::new(vec![
|
||||||
|
Objective::minimize("f1"),
|
||||||
|
Objective::minimize("f2"),
|
||||||
|
]);
|
||||||
|
let front = vec![
|
||||||
|
Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
||||||
|
Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
|
||||||
|
Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
||||||
|
];
|
||||||
|
|
||||||
|
let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
|
||||||
|
std::fs::write("front.svg", svg).unwrap();
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see [LICENSE](../LICENSE) at the repo root.
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//! Lightweight SVG plotting helpers for `heuropt` results.
|
||||||
|
//!
|
||||||
|
//! Two core primitives:
|
||||||
|
//!
|
||||||
|
//! - [`pareto_front_svg`] — render a 2-objective Pareto front as an
|
||||||
|
//! SVG scatter plot with axes and labels.
|
||||||
|
//! - [`convergence_svg`] — render a per-generation "best-fitness so
|
||||||
|
//! far" trace as an SVG line plot.
|
||||||
|
//!
|
||||||
|
//! Hand-rolled SVG output (no `plotters` / `tiny-skia` dep) so the
|
||||||
|
//! crate stays a tiny optional dependency. Output is a `String` of
|
||||||
|
//! valid SVG — write it to a file, embed it in HTML, or pipe it to a
|
||||||
|
//! browser.
|
||||||
|
//!
|
||||||
|
//! # Example
|
||||||
|
//!
|
||||||
|
//! ```
|
||||||
|
//! use heuropt::prelude::*;
|
||||||
|
//! use heuropt_plot::pareto_front_svg;
|
||||||
|
//!
|
||||||
|
//! let space = ObjectiveSpace::new(vec![
|
||||||
|
//! Objective::minimize("f1"),
|
||||||
|
//! Objective::minimize("f2"),
|
||||||
|
//! ]);
|
||||||
|
//! let front = vec![
|
||||||
|
//! Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
||||||
|
//! Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
|
||||||
|
//! Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
||||||
|
//! ];
|
||||||
|
//! let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
|
||||||
|
//! assert!(svg.starts_with("<svg"));
|
||||||
|
//! assert!(svg.contains("</svg>"));
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use heuropt::core::candidate::Candidate;
|
||||||
|
use heuropt::core::objective::ObjectiveSpace;
|
||||||
|
|
||||||
|
/// Render a 2-objective Pareto front as an SVG scatter plot.
|
||||||
|
///
|
||||||
|
/// `width` and `height` are the SVG viewport dimensions in pixels.
|
||||||
|
/// `title` is rendered at the top.
|
||||||
|
///
|
||||||
|
/// Points are plotted in minimization-oriented coordinates.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If `objectives.len() != 2`.
|
||||||
|
pub fn pareto_front_svg<D>(
|
||||||
|
front: &[Candidate<D>],
|
||||||
|
objectives: &ObjectiveSpace,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
title: &str,
|
||||||
|
) -> String {
|
||||||
|
assert_eq!(
|
||||||
|
objectives.len(),
|
||||||
|
2,
|
||||||
|
"pareto_front_svg requires exactly 2 objectives",
|
||||||
|
);
|
||||||
|
let oriented: Vec<[f64; 2]> = front
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
let m = objectives.as_minimization(&c.evaluation.objectives);
|
||||||
|
[m[0], m[1]]
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let (xs_label, ys_label) = (
|
||||||
|
objectives.objectives[0].name.as_str(),
|
||||||
|
objectives.objectives[1].name.as_str(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let (xmin, xmax) = bounds(oriented.iter().map(|p| p[0]));
|
||||||
|
let (ymin, ymax) = bounds(oriented.iter().map(|p| p[1]));
|
||||||
|
let xspan = (xmax - xmin).max(1e-12);
|
||||||
|
let yspan = (ymax - ymin).max(1e-12);
|
||||||
|
|
||||||
|
// Margins so axes/labels have room.
|
||||||
|
let m_left = 60.0_f64;
|
||||||
|
let m_right = 20.0_f64;
|
||||||
|
let m_top = 40.0_f64;
|
||||||
|
let m_bot = 50.0_f64;
|
||||||
|
let plot_w = width as f64 - m_left - m_right;
|
||||||
|
let plot_h = height as f64 - m_top - m_bot;
|
||||||
|
|
||||||
|
let to_x = |v: f64| m_left + (v - xmin) / xspan * plot_w;
|
||||||
|
// Y is inverted: lower minimization value → higher pixel.
|
||||||
|
let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
|
||||||
|
font-family=\"system-ui, sans-serif\" font-size=\"12\">",
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
|
||||||
|
x = m_left,
|
||||||
|
title = escape_xml(title),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Axes box.
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
|
||||||
|
m_left, m_top, plot_w, plot_h,
|
||||||
|
);
|
||||||
|
|
||||||
|
// X-axis ticks (3 ticks).
|
||||||
|
for i in 0..=3 {
|
||||||
|
let t = i as f64 / 3.0;
|
||||||
|
let v = xmin + t * xspan;
|
||||||
|
let x = to_x(v);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
|
||||||
|
y0 = m_top + plot_h,
|
||||||
|
y1 = m_top + plot_h + 5.0,
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{v:.3}</text>",
|
||||||
|
y = m_top + plot_h + 18.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Y-axis ticks.
|
||||||
|
for i in 0..=3 {
|
||||||
|
let t = i as f64 / 3.0;
|
||||||
|
let v = ymin + t * yspan;
|
||||||
|
let y = to_y(v);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
|
||||||
|
x0 = m_left - 5.0,
|
||||||
|
x1 = m_left,
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3}</text>",
|
||||||
|
x = m_left - 8.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels.
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{xs_label}</text>",
|
||||||
|
x = m_left + plot_w / 2.0,
|
||||||
|
y = height as f64 - 12.0,
|
||||||
|
xs_label = escape_xml(xs_label),
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
|
||||||
|
transform=\"rotate(-90 15 {y})\">{ys_label}</text>",
|
||||||
|
y = m_top + plot_h / 2.0,
|
||||||
|
ys_label = escape_xml(ys_label),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Points.
|
||||||
|
for p in &oriented {
|
||||||
|
let cx = to_x(p[0]);
|
||||||
|
let cy = to_y(p[1]);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"3\" fill=\"#1f77b4\" \
|
||||||
|
stroke=\"#0d4a8a\" stroke-width=\"0.5\" />",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push_str("</svg>");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a per-generation "best fitness so far" trace as an SVG line
|
||||||
|
/// plot. `bests[i]` is the best fitness *after* generation `i`.
|
||||||
|
///
|
||||||
|
/// `direction_minimize` controls which way is "improvement": `true`
|
||||||
|
/// for minimize problems, `false` for maximize.
|
||||||
|
pub fn convergence_svg(
|
||||||
|
bests: &[f64],
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
title: &str,
|
||||||
|
y_axis_label: &str,
|
||||||
|
_direction_minimize: bool,
|
||||||
|
) -> String {
|
||||||
|
let n = bests.len();
|
||||||
|
if n == 0 {
|
||||||
|
return format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">\
|
||||||
|
<text x=\"10\" y=\"20\">{}</text></svg>",
|
||||||
|
escape_xml(title)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (ymin, ymax) = bounds(bests.iter().copied());
|
||||||
|
let yspan = (ymax - ymin).max(1e-12);
|
||||||
|
let xspan = (n - 1).max(1) as f64;
|
||||||
|
|
||||||
|
let m_left = 70.0_f64;
|
||||||
|
let m_right = 20.0_f64;
|
||||||
|
let m_top = 40.0_f64;
|
||||||
|
let m_bot = 50.0_f64;
|
||||||
|
let plot_w = width as f64 - m_left - m_right;
|
||||||
|
let plot_h = height as f64 - m_top - m_bot;
|
||||||
|
|
||||||
|
let to_x = |i: usize| m_left + (i as f64) / xspan * plot_w;
|
||||||
|
let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
|
||||||
|
|
||||||
|
let mut out = String::new();
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
|
||||||
|
font-family=\"system-ui, sans-serif\" font-size=\"12\">",
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
|
||||||
|
x = m_left,
|
||||||
|
title = escape_xml(title),
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
|
||||||
|
m_left, m_top, plot_w, plot_h,
|
||||||
|
);
|
||||||
|
|
||||||
|
// X axis: generation index.
|
||||||
|
for i in 0..=4 {
|
||||||
|
let t = i as f64 / 4.0;
|
||||||
|
let g = (t * (n - 1) as f64).round() as usize;
|
||||||
|
let x = to_x(g);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
|
||||||
|
y0 = m_top + plot_h,
|
||||||
|
y1 = m_top + plot_h + 5.0,
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{g}</text>",
|
||||||
|
y = m_top + plot_h + 18.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Y ticks.
|
||||||
|
for i in 0..=3 {
|
||||||
|
let t = i as f64 / 3.0;
|
||||||
|
let v = ymin + t * yspan;
|
||||||
|
let y = to_y(v);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
|
||||||
|
x0 = m_left - 5.0,
|
||||||
|
x1 = m_left,
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3e}</text>",
|
||||||
|
x = m_left - 8.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Axis labels.
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">generation</text>",
|
||||||
|
x = m_left + plot_w / 2.0,
|
||||||
|
y = height as f64 - 12.0,
|
||||||
|
);
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
|
||||||
|
transform=\"rotate(-90 15 {y})\">{label}</text>",
|
||||||
|
y = m_top + plot_h / 2.0,
|
||||||
|
label = escape_xml(y_axis_label),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Polyline.
|
||||||
|
let mut points = String::new();
|
||||||
|
for (i, &v) in bests.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
points.push(' ');
|
||||||
|
}
|
||||||
|
let _ = write!(points, "{:.2},{:.2}", to_x(i), to_y(v));
|
||||||
|
}
|
||||||
|
let _ = writeln!(
|
||||||
|
out,
|
||||||
|
" <polyline points=\"{points}\" fill=\"none\" stroke=\"#1f77b4\" stroke-width=\"1.5\" />",
|
||||||
|
);
|
||||||
|
|
||||||
|
out.push_str("</svg>");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounds<I: IntoIterator<Item = f64>>(it: I) -> (f64, f64) {
|
||||||
|
let mut lo = f64::INFINITY;
|
||||||
|
let mut hi = f64::NEG_INFINITY;
|
||||||
|
for v in it {
|
||||||
|
if v.is_finite() {
|
||||||
|
if v < lo {
|
||||||
|
lo = v;
|
||||||
|
}
|
||||||
|
if v > hi {
|
||||||
|
hi = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lo.is_infinite() {
|
||||||
|
(0.0, 1.0)
|
||||||
|
} else if (hi - lo).abs() < f64::EPSILON {
|
||||||
|
// All points equal — give a small artificial span.
|
||||||
|
(lo - 0.5, hi + 0.5)
|
||||||
|
} else {
|
||||||
|
(lo, hi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_xml(s: &str) -> String {
|
||||||
|
s.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use heuropt::core::evaluation::Evaluation;
|
||||||
|
use heuropt::core::objective::Objective;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pareto_svg_well_formed() {
|
||||||
|
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
|
||||||
|
let front = vec![
|
||||||
|
Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
|
||||||
|
Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
|
||||||
|
];
|
||||||
|
let svg = pareto_front_svg(&front, &space, 400, 300, "test");
|
||||||
|
assert!(svg.starts_with("<svg"));
|
||||||
|
assert!(svg.contains("</svg>"));
|
||||||
|
assert!(svg.contains("<circle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergence_svg_well_formed() {
|
||||||
|
let bests = vec![10.0, 5.0, 2.0, 1.0, 0.5];
|
||||||
|
let svg = convergence_svg(&bests, 400, 300, "convergence", "best", true);
|
||||||
|
assert!(svg.starts_with("<svg"));
|
||||||
|
assert!(svg.contains("polyline"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convergence_empty_returns_valid_svg() {
|
||||||
|
let svg = convergence_svg(&[], 200, 100, "empty", "y", true);
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
assert!(svg.contains("</svg>"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
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;
|
||||||
@@ -95,6 +94,16 @@ 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)",
|
||||||
@@ -110,6 +119,7 @@ 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;
|
||||||
@@ -122,12 +132,39 @@ 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 evals: Vec<f64> = initial_pop
|
let mut current_pop = 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;
|
||||||
|
|
||||||
for _gen in 0..self.config.generations {
|
// Initial snapshot.
|
||||||
|
{
|
||||||
|
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.
|
||||||
@@ -164,18 +201,135 @@ where
|
|||||||
Direction::Maximize => trial_obj >= target_obj,
|
Direction::Maximize => trial_obj >= target_obj,
|
||||||
};
|
};
|
||||||
if trial_better {
|
if trial_better {
|
||||||
decisions[i] = trial_cand.decision;
|
decisions[i] = trial_cand.decision.clone();
|
||||||
evals[i] = trial_obj;
|
evals[i] = trial_obj;
|
||||||
|
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 best = best_candidate(¤t_pop, &objectives);
|
||||||
|
OptimizationResult::new(
|
||||||
|
Population::new(current_pop),
|
||||||
|
front,
|
||||||
|
best,
|
||||||
|
evaluations,
|
||||||
|
completed_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::core::candidate::Candidate;
|
||||||
|
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() {
|
||||||
|
let trial_obj = trial_cand.evaluation.objectives[0];
|
||||||
|
let target_obj = evals[i];
|
||||||
|
let trial_better = match direction {
|
||||||
|
crate::core::objective::Direction::Minimize => trial_obj <= target_obj,
|
||||||
|
crate::core::objective::Direction::Maximize => trial_obj >= target_obj,
|
||||||
|
};
|
||||||
|
if trial_better {
|
||||||
|
decisions[i] = trial_cand.decision.clone();
|
||||||
|
evals[i] = trial_obj;
|
||||||
|
current_pop[i] = trial_cand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions);
|
let front = pareto_front(¤t_pop, &objectives);
|
||||||
evaluations += final_pop.len();
|
let best = best_candidate(¤t_pop, &objectives);
|
||||||
let front = pareto_front(&final_pop, &objectives);
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(
|
||||||
Population::new(final_pop),
|
Population::new(current_pop),
|
||||||
front,
|
front,
|
||||||
best,
|
best,
|
||||||
evaluations,
|
evaluations,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+64
-8
@@ -108,6 +108,16 @@ 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",
|
||||||
@@ -115,6 +125,7 @@ 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);
|
||||||
@@ -130,7 +141,27 @@ 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);
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
// Observer: notify after the initial population.
|
||||||
|
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 {
|
||||||
@@ -189,21 +220,46 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return final state.
|
finalize_nsga2(annotated, &objectives, evaluations, self.config.generations)
|
||||||
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);
|
fn finalize_nsga2<D: Clone>(
|
||||||
|
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,
|
||||||
self.config.generations,
|
generations,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn annotate<D: Clone>(
|
fn annotate<D: Clone>(
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
//! 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::AsyncProblem;
|
||||||
|
use crate::core::candidate::Candidate;
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
@@ -88,19 +88,85 @@ 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 _ in 0..self.config.iterations {
|
for iteration in 1..=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 best = best_candidate(&all, &objectives);
|
||||||
|
OptimizationResult::new(Population::new(all), front, best, evaluations, completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 front = pareto_front(&all, &objectives);
|
||||||
let best = best_candidate(&all, &objectives);
|
let best = best_candidate(&all, &objectives);
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! 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. Algorithms that support async
|
||||||
|
//! evaluation (NSGA-II, DE, RandomSearch as of v0.7.0; others land
|
||||||
|
//! incrementally) expose a `run_async` method that drives evaluations
|
||||||
|
//! through a user-chosen async runtime (typically tokio).
|
||||||
|
//!
|
||||||
|
//! 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;
|
||||||
|
}
|
||||||
@@ -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,6 +34,11 @@ 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
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ 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;
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
//! 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,7 +1,11 @@
|
|||||||
//! 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::*;
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
//! 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
//! 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
//! 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,
|
||||||
|
}
|
||||||
@@ -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,6 +13,14 @@ 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,
|
||||||
|
|||||||
+56
-3
@@ -1,18 +1,71 @@
|
|||||||
//! 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`]. v1 deliberately does not expose a step-by-step API
|
/// [`OptimizationResult`]. Invalid configuration panics with a clear
|
||||||
/// or an associated error type — invalid configuration may panic with a clear
|
/// message rather than returning a `Result`.
|
||||||
/// 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