Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0f580841d
|
+2
-42
@@ -5,52 +5,12 @@
|
|||||||
# cargo mutants # full sweep (slow)
|
# cargo mutants # full sweep (slow)
|
||||||
# cargo mutants --in-diff HEAD~1 # only mutate recently-changed lines
|
# cargo mutants --in-diff HEAD~1 # only mutate recently-changed lines
|
||||||
#
|
#
|
||||||
# IMPORTANT: pass `--all-features` (or at least `--features async,serde`).
|
|
||||||
# Without them the async `run_async` paths and the `explorer` module are
|
|
||||||
# not compiled, so their mutants come back as unviable/missed noise rather
|
|
||||||
# than being exercised by the test suite.
|
|
||||||
#
|
|
||||||
# Note: `--test-tool nextest` does not accept the libtest-style
|
|
||||||
# `--test-threads=1` set below; for a nextest run pass `--no-config` (and
|
|
||||||
# re-add `--all-features` / the `--file` filters you need on the CLI).
|
|
||||||
#
|
|
||||||
# A *surviving* mutation = the test suite passed despite a code change,
|
# A *surviving* mutation = the test suite passed despite a code change,
|
||||||
# which usually means a missing test or a missing invariant.
|
# which usually means a missing test or a missing invariant.
|
||||||
#
|
#
|
||||||
# This isn't gated CI; it's an advisory tool. The property tests in
|
# This isn't gated CI; it's an advisory tool. The property tests in
|
||||||
# tests/properties.rs and the per-algorithm exact-output snapshot tests
|
# tests/properties.rs are the natural place to land new invariants
|
||||||
# in tests/algorithm_properties.rs are the natural places to land new
|
# discovered via mutation runs.
|
||||||
# invariants discovered via mutation runs.
|
|
||||||
#
|
|
||||||
# Mutation-coverage notes (2026-05 campaign — catch rate ~74% -> ~85%):
|
|
||||||
# - tests/algorithm_properties.rs pins an exact final-population
|
|
||||||
# snapshot for every algorithm at a fixed seed. Those snapshots use
|
|
||||||
# deliberately *hard* fixtures (3-D Rosenbrock, an 8-city scattered
|
|
||||||
# TSP, a budget-sensitive multi-fidelity problem): on convex /
|
|
||||||
# trivially-solved problems the optimizers converge to the same
|
|
||||||
# answer regardless of arithmetic mutations, which hides them.
|
|
||||||
# - The residual MISSED mutants are dominated by (a) equivalent
|
|
||||||
# mutants — e.g. `<` vs `<=` at a boundary the inputs never hit —
|
|
||||||
# and (b) arithmetic the optimizers are mathematically robust to.
|
|
||||||
# - TIMEOUT mutants here are loop-bound mutations that make an
|
|
||||||
# offspring-collection loop non-terminating; cargo-mutants reports
|
|
||||||
# those *as detected*, in their own category separate from MISSED.
|
|
||||||
#
|
|
||||||
# Performance notes (2026-05 profiling campaign — compare_profile
|
|
||||||
# whole-program callgrind Ir 357.06B -> 165.31B, -53.7%):
|
|
||||||
# - `benches/compare_profile.rs` profiles the whole `compare` example
|
|
||||||
# workload under callgrind via gungraun; it drove the seven perf
|
|
||||||
# commits of this campaign. (It's in `exclude_globs` below — a
|
|
||||||
# bench harness, not behavior to mutate.)
|
|
||||||
# - Every perf commit was bit-identical: all the tests/algorithm_-
|
|
||||||
# properties.rs snapshots stayed green. But the round-4
|
|
||||||
# `pareto::front::pareto_front` change adds a `dominated` bitset
|
|
||||||
# that is *pure* skip-bookkeeping — the `dominated[j] = true` write
|
|
||||||
# and the `if dominated[i]` early `continue` are optimization-only.
|
|
||||||
# Deleting either leaves the returned front bit-identical (just
|
|
||||||
# slower), so a mutation run will (correctly) report those as
|
|
||||||
# MISSED. They are genuine equivalent mutants, not test gaps —
|
|
||||||
# don't try to pin them with new tests.
|
|
||||||
|
|
||||||
# Files to skip mutating. We skip:
|
# Files to skip mutating. We skip:
|
||||||
# - examples (illustrative, not core algorithm correctness)
|
# - examples (illustrative, not core algorithm correctness)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"ac44d107-52ca-4cd4-9586-ae2fe91bc9f7","pid":2366937,"procStart":"77336928","acquiredAt":1778002505967}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
blank_issues_enabled: false
|
blank_issues_enabled: false
|
||||||
contact_links:
|
contact_links:
|
||||||
- name: Security vulnerability
|
- name: Security vulnerability
|
||||||
url: https://git.swaits.com/swaits/heuropt/src/branch/main/SECURITY.md
|
url: https://github.com/swaits/heuropt/security/advisories/new
|
||||||
about: Do not open a public issue. Email steve@waits.net — see SECURITY.md.
|
about: Please use private vulnerability reporting — do not open a public issue. See SECURITY.md.
|
||||||
- name: Question / discussion
|
- name: Question / discussion
|
||||||
url: https://git.swaits.com/swaits/heuropt/issues
|
url: https://github.com/swaits/heuropt/discussions
|
||||||
about: For open-ended questions or design discussions, open an issue and label it "question".
|
about: For open-ended questions or design discussions.
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*.*.*"]
|
tags: ["v*.*.*"]
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -13,8 +11,6 @@ permissions:
|
|||||||
pages: write
|
pages: write
|
||||||
id-token: write
|
id-token: write
|
||||||
|
|
||||||
# Only one Pages deploy at a time. Don't cancel a running deploy
|
|
||||||
# (otherwise we can leave the Pages site partially updated).
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: pages
|
group: pages
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
@@ -42,9 +38,6 @@ jobs:
|
|||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy to GitHub Pages
|
name: Deploy to GitHub Pages
|
||||||
# Only deploy on pushes to main / tag pushes / manual runs.
|
|
||||||
# PR builds get the build-and-upload step but no deploy.
|
|
||||||
if: github.event_name != 'pull_request'
|
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment:
|
environment:
|
||||||
|
|||||||
@@ -1,9 +1,2 @@
|
|||||||
/target
|
/target
|
||||||
/Cargo.lock
|
/Cargo.lock
|
||||||
|
|
||||||
# Generated by `cargo run --example pick_a_car`
|
|
||||||
/pick_a_car.json
|
|
||||||
|
|
||||||
# Generated by `cargo mutants`
|
|
||||||
/mutants.out
|
|
||||||
/mutants.out.old
|
|
||||||
|
|||||||
+68
-309
@@ -7,319 +7,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.11.0] — 2026-05-14
|
## [0.6.0] — 2026-05-05
|
||||||
|
|
||||||
Theme: a full permutation-operator toolkit, plus two sweeping
|
Theme: production lifecycle. heuropt becomes deployable for long-
|
||||||
performance passes. The first is a micro-benchmark-guided pass over
|
running, real-world optimization workloads — callbacks, stop
|
||||||
the combinatorial operators and the Pareto/metrics machinery; the
|
conditions, tracing, and two new performance indicators.
|
||||||
second is a whole-program profiling campaign that roughly halved the
|
|
||||||
instruction count of the `compare` example workload. Every
|
|
||||||
performance change is bit-identical — verified against per-algorithm
|
|
||||||
exact-output snapshot tests — so results are unchanged, only faster.
|
|
||||||
|
|
||||||
No public-API breaks. The release is purely additive: new permutation
|
No breaking changes to the public API. Existing `Optimizer<P>` impls
|
||||||
operators, plus internal-only performance work.
|
keep compiling — `run_with` is added as a default-impl method that
|
||||||
|
falls back to `run` plus a single final notification.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- A full permutation crossover/mutation toolkit in
|
#### Observer + stop-conditions API
|
||||||
`heuropt::operators`, all re-exported from the prelude:
|
|
||||||
`OrderCrossover` (OX), `PartiallyMappedCrossover` (PMX),
|
|
||||||
`CycleCrossover` (CX), and `EdgeRecombinationCrossover` (ERX)
|
|
||||||
crossovers, and `InversionMutation`, `InsertionMutation`, and
|
|
||||||
`ScrambleMutation` mutations — joining the pre-existing
|
|
||||||
`SwapMutation`. The mutations preserve both strict permutations and
|
|
||||||
multisets.
|
|
||||||
- Combinatorial problems in the `compare` example: a bi-objective
|
|
||||||
ring TSP, a 3-objective FT06 job-shop schedule, and a bi-objective
|
|
||||||
knapsack — plus standalone Ulysses16 TSP and FT06 JSS benchmark
|
|
||||||
examples and a bi-objective TSP crossover-comparison demo.
|
|
||||||
- Many-objective problems in the `compare` example: DTLZ at 4, 8, and
|
|
||||||
10 objectives.
|
|
||||||
- `benches/compare_profile.rs` — a gungraun/callgrind benchmark that
|
|
||||||
profiles the entire `compare` workload as one unit; the harness
|
|
||||||
behind this release's profiling campaign.
|
|
||||||
- A permutation-toolkit and multi-objective-combinatorial cookbook
|
|
||||||
chapter in the mdbook.
|
|
||||||
|
|
||||||
### Performance
|
A new module `heuropt::observer` introduces:
|
||||||
|
|
||||||
All changes below are bit-identical — outputs are byte-for-byte
|
- `Snapshot<'a, D>` — per-generation observation payload with
|
||||||
unchanged, verified by the per-algorithm snapshot tests.
|
`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.
|
||||||
|
|
||||||
- **Whole-program profiling campaign.** Profiling the full `compare`
|
#### Built-in observers (`observer::builtin`)
|
||||||
workload under callgrind cut its instruction count from 357.06B to
|
|
||||||
165.31B (−53.7%):
|
- `MaxTime(Duration)` — wall-clock cap.
|
||||||
- `pareto_compare` is now allocation-free — it no longer
|
- `MaxIterations(usize)` — generation cap.
|
||||||
materializes two minimization-oriented `Vec<f64>`s per call. This
|
- `TargetFitness(f64)` — direction-aware single-objective target.
|
||||||
alone was −38%, the single biggest win.
|
- `Stagnation { window, tolerance }` — halt when the best fitness
|
||||||
- `pareto_front` precomputes its oriented buffers once and skips
|
hasn't improved by `tolerance` over `window` generations.
|
||||||
candidates already known to be dominated.
|
- `Periodic::new(every, |snap| { … })` — call a user closure every
|
||||||
- `ibea` pre-exponentiates its indicator matrix, turning the
|
`every` generations.
|
||||||
survival loop's `exp` sweep into plain additions.
|
- `AnyOf` / `AllOf` plus `Observer::or` / `Observer::and` for
|
||||||
- `hype` reuses its per-Monte-Carlo-sample scratch buffer instead
|
composition.
|
||||||
of reallocating it thousands of times per call.
|
- `TracingObserver` (behind the new `tracing` feature) — emits
|
||||||
- `age_moea` scores only the splitting front rather than the whole
|
structured `debug!` events per generation.
|
||||||
combined population.
|
|
||||||
- **Combinatorial-operator pass.** `CycleCrossover`,
|
#### Tracing feature
|
||||||
`PartiallyMappedCrossover`, and `OrderCrossover` are now O(n) via
|
|
||||||
position-index tables; `EdgeRecombinationCrossover` removes edges
|
New optional feature `tracing`, gated on the
|
||||||
in O(degree) per step.
|
[`tracing`](https://crates.io/crates/tracing) crate. Adds
|
||||||
- **Pareto / metrics pass.** `non_dominated_sort` halves its
|
`TracingObserver` to the prelude when enabled.
|
||||||
dominance comparisons and reads from a flattened objective buffer;
|
|
||||||
`crowding_distance` sorts without `Vec<Vec<f64>>` indirection;
|
#### Performance indicators
|
||||||
`hypervolume_nd` no longer re-sorts prefixes per slice.
|
|
||||||
- **Algorithm hot paths.** `ant_colony_tsp` hoists `powf` out of its
|
- `metrics::igd::igd` — Inverted Generational Distance against a
|
||||||
tour-building loop; `tpe` computes KDE bandwidths once per
|
reference set (typically the true Pareto front).
|
||||||
iteration instead of once per call; `bayesian_opt` reuses scratch
|
- `metrics::igd::igd_plus` — Pareto-compliant IGD+ variant; adding
|
||||||
buffers in the expected-improvement acquisition loop.
|
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
|
### Changed
|
||||||
|
|
||||||
- Documentation now recommends MOEA/D as the default multi- and
|
- `Population::as_slice()` — new convenience accessor.
|
||||||
many-objective algorithm, with disconnected-front and sequencing
|
|
||||||
guidance corrected against fresh `compare` results.
|
|
||||||
- The `compare` example's result tables are realigned and sorted,
|
|
||||||
and its workload now lives in a reusable module shared with the
|
|
||||||
profiling benchmark.
|
|
||||||
|
|
||||||
### Internal
|
[0.6.0]: https://github.com/swaits/heuropt/releases/tag/v0.6.0
|
||||||
|
|
||||||
- A large mutation-testing-driven test-hardening pass: per-algorithm
|
|
||||||
exact-output snapshots and pinned helper-function tests across the
|
|
||||||
whole algorithm catalog and operator set, raising the cargo-mutants
|
|
||||||
catch rate from ~74% to ~85%. See `.cargo/mutants.toml` for the
|
|
||||||
campaign notes and the residual equivalent-mutant categories.
|
|
||||||
|
|
||||||
[0.11.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.11.0
|
|
||||||
|
|
||||||
## [0.10.0] — 2026-05-06
|
|
||||||
|
|
||||||
Theme: every algorithm now returns its **canonical name** as it
|
|
||||||
appears in the literature, with an academic long form available
|
|
||||||
alongside, and the docs use those names everywhere. Plus the
|
|
||||||
explorer JSON export now carries both forms so display tools can
|
|
||||||
show the short name with a hover tooltip for the long one.
|
|
||||||
|
|
||||||
No public-API breaks beyond the value of `AlgorithmInfo::name()`,
|
|
||||||
which previously returned the Rust type name and now returns the
|
|
||||||
literature short name (`"NSGA-II"` vs `"Nsga2"`). If your code
|
|
||||||
matched on those strings you'll need to update — but the trait
|
|
||||||
shape itself is unchanged and `algorithm.name()` continues to be
|
|
||||||
the way to read it.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- `AlgorithmInfo::full_name(&self) -> &'static str` — academic
|
|
||||||
long form, e.g. `"Non-dominated Sorting Genetic Algorithm II"`.
|
|
||||||
Defaults to `name()` for algorithms whose short and long forms
|
|
||||||
coincide (Random Search, Hill Climber, Tabu Search).
|
|
||||||
- Every built-in algorithm overrides `full_name()` with its
|
|
||||||
expanded literature name. Mapping table is in the cookbook
|
|
||||||
recipe at `docs/book/src/cookbook/explorer.md`.
|
|
||||||
- `ExplorerExport`'s `RunMeta` gained an optional
|
|
||||||
`algorithm_full_name: Option<String>` field. The
|
|
||||||
`with_algorithm_info()` builder populates both that and
|
|
||||||
`algorithm` from the same `AlgorithmInfo` source. Schema
|
|
||||||
version stays at **1** — the new field is `#[serde(default)]`,
|
|
||||||
so older readers tolerate it and older writers' output still
|
|
||||||
loads cleanly.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `AlgorithmInfo::name()` return values for every built-in
|
|
||||||
algorithm. Examples: `"Nsga2"` → `"NSGA-II"`, `"Cmaes"` →
|
|
||||||
`"CMA-ES"`, `"Mopso"` → `"MOPSO"`, `"Moead"` → `"MOEA/D"`,
|
|
||||||
`"EpsilonMoea"` → `"ε-MOEA"`. Full table in the cookbook recipe.
|
|
||||||
- README, mdbook chapters, decision tree, choosing-an-algorithm
|
|
||||||
guide, comparison page, getting-started, defining-problems,
|
|
||||||
cookbook recipes, and migration notes now all use the canonical
|
|
||||||
algorithm names in body prose. Code blocks (which reference the
|
|
||||||
Rust types like `Nsga2::new(...)` or `Nsga2Config { … }`)
|
|
||||||
unchanged — those are still the API.
|
|
||||||
- Default `cargo run --release --example pick_a_car` output now
|
|
||||||
reads `"algorithm": "NSGA-III", "algorithm_full_name":
|
|
||||||
"Non-dominated Sorting Genetic Algorithm III"` in the JSON
|
|
||||||
envelope instead of `"Nsga3"`.
|
|
||||||
|
|
||||||
### Migration
|
|
||||||
|
|
||||||
If you display `optimizer.name()` in your own UI, you'll suddenly
|
|
||||||
get the proper short name for free — usually a strict improvement.
|
|
||||||
The only break: code that pattern-matched on the Rust-type-shaped
|
|
||||||
strings (e.g. `if name == "Nsga3"`) needs updating to the new
|
|
||||||
canonical strings. The names are stable now (they match the
|
|
||||||
literature), so this is a one-time fix.
|
|
||||||
|
|
||||||
[0.10.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.10.0
|
|
||||||
|
|
||||||
## [0.9.0] — 2026-05-06
|
|
||||||
|
|
||||||
Theme: explorer JSON export. Real Pareto fronts have 50–200+
|
|
||||||
candidates spanning 2–7+ objectives — too many to read as numbers
|
|
||||||
in a terminal. 0.9.0 adds a tiny additive surface that turns any
|
|
||||||
`OptimizationResult` into a self-describing JSON file you can drop
|
|
||||||
into [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
to filter, brush, pin, and rank candidates interactively.
|
|
||||||
|
|
||||||
No public-API breaks. The new surface lives behind the existing
|
|
||||||
`serde` feature and the new methods on `Problem` / the new
|
|
||||||
`AlgorithmInfo` trait have working defaults so existing impls
|
|
||||||
compile untouched.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
#### Explorer export (the headline feature)
|
|
||||||
|
|
||||||
- New `heuropt::explorer` module (gated on the `serde` feature).
|
|
||||||
Defines `ExplorerExport`, `ExplorerCandidate`, `RunMeta`, the
|
|
||||||
`ToDecisionValues` adapter trait, and free functions
|
|
||||||
`to_json` / `to_writer` / `to_file`.
|
|
||||||
- Schema is versioned (`SCHEMA_VERSION = 1`); the explorer webapp
|
|
||||||
refuses to load files with an unknown version.
|
|
||||||
- `front_rank` is computed once via `non_dominated_sort` at export
|
|
||||||
time and attached to every candidate so downstream tools don't
|
|
||||||
have to re-derive it.
|
|
||||||
- `ToDecisionValues` is implemented for `Vec<f64>`, `Vec<bool>`,
|
|
||||||
`Vec<usize>`, and `Vec<i64>` out of the box; users with custom
|
|
||||||
decision types implement it themselves (one method).
|
|
||||||
|
|
||||||
#### Problem-side metadata (single source of truth, no duplication)
|
|
||||||
|
|
||||||
- `Objective` gained optional `label: Option<String>` and
|
|
||||||
`unit: Option<String>` fields plus fluent builders
|
|
||||||
`.with_label("Price")` / `.with_unit("$k")`. Existing
|
|
||||||
`Objective::minimize("name")` / `Objective::maximize("name")`
|
|
||||||
unchanged. Backwards-compatible at source level and at the JSON
|
|
||||||
level (the new fields use `#[serde(default,
|
|
||||||
skip_serializing_if = "Option::is_none")]`).
|
|
||||||
- `Problem` trait gained an optional `fn decision_schema(&self)
|
|
||||||
-> Vec<DecisionVariable>` with default empty impl. Override it
|
|
||||||
to provide pretty names / labels / units / bounds for the
|
|
||||||
explorer; the default produces fallback `x[0]`, `x[1]`, … names.
|
|
||||||
- New `DecisionVariable` type at `heuropt::core::DecisionVariable`,
|
|
||||||
re-exported via the prelude. Builder methods: `with_label`,
|
|
||||||
`with_unit`, `with_bounds`.
|
|
||||||
|
|
||||||
#### Algorithm metadata for the export header
|
|
||||||
|
|
||||||
- New `heuropt::traits::AlgorithmInfo` trait with `name() ->
|
|
||||||
&'static str` (required) and `seed() -> Option<u64>` (default
|
|
||||||
`None`). Every built-in algorithm — all 33 — implements it.
|
|
||||||
Separate from `Optimizer<P>` so multi-fidelity algorithms
|
|
||||||
(Hyperband, which uses `PartialProblem`) implement it uniformly.
|
|
||||||
- `ExplorerExport::with_algorithm_info(&optimizer)` pulls the
|
|
||||||
algorithm name and seed from this trait into the export's `run`
|
|
||||||
metadata.
|
|
||||||
|
|
||||||
#### Worked example
|
|
||||||
|
|
||||||
- New `examples/pick_a_car.rs` (gated on `serde`). Implements the
|
|
||||||
README's `PickACar` multi-objective problem with a fully
|
|
||||||
enriched `decision_schema` and labelled / unit-tagged objectives,
|
|
||||||
runs NSGA-III, and writes `pick_a_car.json` ready to drop into
|
|
||||||
the explorer.
|
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
|
|
||||||
- New cookbook recipe at `docs/book/src/cookbook/explorer.md`
|
|
||||||
covering Problem enrichment, the export call, the JSON schema,
|
|
||||||
and custom decision-type handling.
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- The explorer webapp itself lives in a separate repo
|
|
||||||
(`heuropt-explorer`) on its own release cadence. The schema in
|
|
||||||
`heuropt::explorer` is the contract between them; bumping
|
|
||||||
`SCHEMA_VERSION` is reserved for breaking changes.
|
|
||||||
- Phase 1 is additive only. No existing test breaks; the lib test
|
|
||||||
count went from 229 to 242 (10 new explorer tests + 3 from the
|
|
||||||
new `Objective` / `DecisionVariable` builders).
|
|
||||||
|
|
||||||
[0.9.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.9.0
|
|
||||||
|
|
||||||
## [0.8.0] — 2026-05-06
|
|
||||||
|
|
||||||
Theme: async evaluation, plus the docs / governance / CI catch-up
|
|
||||||
that came with finalizing the release.
|
|
||||||
|
|
||||||
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 / optuna / DEAP / MOEA Framework, none of which
|
|
||||||
ship first-class async support at the *evaluation* level.
|
|
||||||
|
|
||||||
No public-API breaks for synchronous users. The new surface is
|
|
||||||
gated behind a new `async` feature flag.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
#### Async evaluation (the headline feature)
|
|
||||||
|
|
||||||
- New optional feature `async`, gated on
|
|
||||||
[`futures`](https://crates.io/crates/futures).
|
|
||||||
- `core::async_problem::AsyncProblem` trait — mirrors `Problem` but
|
|
||||||
with `async fn evaluate_async(&self, decision)`. Adapt an
|
|
||||||
existing sync `Problem` with a one-line wrapper.
|
|
||||||
- `core::async_problem::AsyncPartialProblem` trait — mirrors
|
|
||||||
`PartialProblem` for multi-fidelity (Hyperband) workloads with
|
|
||||||
`async fn evaluate_at_budget_async(decision, budget)`.
|
|
||||||
- Per-algorithm `run_async(&problem, concurrency).await` methods on
|
|
||||||
**every** algorithm in the catalog — all 33 of them — driving
|
|
||||||
evaluations through whichever async runtime the caller is using
|
|
||||||
(typically tokio). `concurrency` bounds in-flight evaluations.
|
|
||||||
Population-based algorithms (NSGA-II, NSGA-III, SPEA2, MOEA/D,
|
|
||||||
CMA-ES, DE, GA, PSO, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II,
|
|
||||||
AGE-MOEA, KnEA, GrEA, RVEA, MOPSO, TLBO, IPOP-CMA-ES, sNES, UMDA,
|
|
||||||
Ant Colony, GA, Random Search) fan out per generation. Steady-state
|
|
||||||
algorithms (Hill Climber, SA, (1+1)-ES, PAES, Nelder-Mead, Tabu
|
|
||||||
Search) await each step sequentially. Surrogate algorithms (BO,
|
|
||||||
TPE) batch the initial design and then await per-iteration
|
|
||||||
acquisitions. Hyperband fans out each Successive-Halving rung
|
|
||||||
through `AsyncPartialProblem`.
|
|
||||||
- Internal `algorithms::parallel_eval_async::evaluate_batch_async`
|
|
||||||
and `evaluate_batch_at_budget_async` helpers — use
|
|
||||||
`futures::stream::FuturesOrdered` with concurrency-bounded chunks,
|
|
||||||
preserve input order so seeded determinism is preserved when
|
|
||||||
evaluations are themselves deterministic.
|
|
||||||
- `examples/async_eval.rs` — worked example with a simulated 20 ms
|
|
||||||
remote service. At concurrency = 1 it's serial; at concurrency = 4
|
|
||||||
it's 2× faster; demonstrates `DifferentialEvolution` under tokio.
|
|
||||||
|
|
||||||
#### Documentation
|
|
||||||
|
|
||||||
- New cookbook recipe **[Async evaluation](docs/book/src/cookbook/async.md)**
|
|
||||||
— implementing `AsyncProblem`, picking concurrency, determinism
|
|
||||||
guarantees, async vs. `parallel`.
|
|
||||||
- Comparison-with-other-libraries chapter updated: `heuropt 0.8`
|
|
||||||
row, `Async ✅ AsyncProblem + run_async` column, "When to pick
|
|
||||||
heuropt" gains an explicit IO-bound bullet.
|
|
||||||
- Stability chapter rewritten: removes the speculative "Observer /
|
|
||||||
Checkpoint planned" bullet (those didn't ship), documents the new
|
|
||||||
`async` feature flag.
|
|
||||||
- Migration guide: new "To 0.8" section covering both
|
|
||||||
`0.5.x → 0.8` (feature-additive — opt in by enabling the `async`
|
|
||||||
feature) and `0.7 → 0.8` (the partial async surface from 0.7 is
|
|
||||||
superseded by complete coverage; existing `run_async` callers
|
|
||||||
keep working).
|
|
||||||
- Runnable `cargo test --doc` examples added to every public
|
|
||||||
operator (10), metric (3), and Pareto utility (7) — every
|
|
||||||
public item across the crate now ships with at least one
|
|
||||||
example. 55 doctests in total (was 45).
|
|
||||||
|
|
||||||
#### CI / build
|
|
||||||
|
|
||||||
- `.github/workflows/docs.yml` builds the mdbook user guide on
|
|
||||||
every push and deploys to GitHub Pages on `main` /
|
|
||||||
tag pushes.
|
|
||||||
- `mdbook` book now uses `[rust] edition = "2021"` to satisfy
|
|
||||||
`mdbook 0.4.40`.
|
|
||||||
- `clamp_to_bounds` cargo-fuzz target tolerance loosened to
|
|
||||||
`1e-4 · max(simplex_total, max_abs_x, 1)` so the fuzzer doesn't
|
|
||||||
flag ULP-level slop in the simplex projection's
|
|
||||||
`max(x_i − τ, 0)` clamp boundary.
|
|
||||||
|
|
||||||
[0.8.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.8.0
|
|
||||||
|
|
||||||
## [0.5.0] — 2026-05-05
|
## [0.5.0] — 2026-05-05
|
||||||
|
|
||||||
@@ -405,7 +164,7 @@ Three new polished examples covering distinct domains:
|
|||||||
- `lib.rs` crate-level docs polished — better intro, points readers
|
- `lib.rs` crate-level docs polished — better intro, points readers
|
||||||
at the user guide and the design spec.
|
at the user guide and the design spec.
|
||||||
|
|
||||||
[0.5.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.5.0
|
[0.5.0]: https://github.com/swaits/heuropt/releases/tag/v0.5.0
|
||||||
|
|
||||||
## [0.4.0] — 2026-05-05
|
## [0.4.0] — 2026-05-05
|
||||||
|
|
||||||
@@ -543,7 +302,7 @@ last decimal in every benchmark.
|
|||||||
ratio, and is robust to floating-point precision loss in the
|
ratio, and is robust to floating-point precision loss in the
|
||||||
algorithm's inner loop.
|
algorithm's inner loop.
|
||||||
|
|
||||||
[0.4.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.4.0
|
[0.4.0]: https://github.com/swaits/heuropt/releases/tag/v0.4.0
|
||||||
|
|
||||||
## [0.3.0] — 2026-05-05
|
## [0.3.0] — 2026-05-05
|
||||||
|
|
||||||
@@ -622,7 +381,7 @@ constraint-handling gaps. No breaking changes to the v0.2.0 public API.
|
|||||||
preserves the existing midpoint-of-bounds default; `IpopCmaEs` sets
|
preserves the existing midpoint-of-bounds default; `IpopCmaEs` sets
|
||||||
it to inject restart diversity without shrinking the search box.
|
it to inject restart diversity without shrinking the search box.
|
||||||
|
|
||||||
[0.3.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.3.0
|
[0.3.0]: https://github.com/swaits/heuropt/releases/tag/v0.3.0
|
||||||
|
|
||||||
## [0.2.0] — 2026-05-05
|
## [0.2.0] — 2026-05-05
|
||||||
|
|
||||||
@@ -688,7 +447,7 @@ benchmark problems. No breaking changes to the v0.1.0 public API.
|
|||||||
- `benchmarks` — canonical reference runs of NSGA-II on ZDT1 and DE on
|
- `benchmarks` — canonical reference runs of NSGA-II on ZDT1 and DE on
|
||||||
Rastrigin.
|
Rastrigin.
|
||||||
- `jiggly_tuning` — real-world 4-objective NSGA-III firmware tuning
|
- `jiggly_tuning` — real-world 4-objective NSGA-III firmware tuning
|
||||||
for the [`jiggly`](https://git.swaits.com/swaits/jiggly) USB-mouse-jiggler,
|
for the [`jiggly`](https://github.com/swaits/jiggly) USB-mouse-jiggler,
|
||||||
with an a-posteriori weighted-decision step that picks one
|
with an a-posteriori weighted-decision step that picks one
|
||||||
recommendation off the Pareto front.
|
recommendation off the Pareto front.
|
||||||
|
|
||||||
@@ -714,7 +473,7 @@ benchmark problems. No breaking changes to the v0.1.0 public API.
|
|||||||
`Problem` / decision type without exotic interior mutability already
|
`Problem` / decision type without exotic interior mutability already
|
||||||
satisfies these.
|
satisfies these.
|
||||||
|
|
||||||
[0.2.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.2.0
|
[0.2.0]: https://github.com/swaits/heuropt/releases/tag/v0.2.0
|
||||||
|
|
||||||
## [0.1.0] — 2026-05-04
|
## [0.1.0] — 2026-05-04
|
||||||
|
|
||||||
@@ -772,7 +531,7 @@ Initial release.
|
|||||||
algorithm on ZDT1 (2-obj), DTLZ2 (3-obj), and Rastrigin (single-obj),
|
algorithm on ZDT1 (2-obj), DTLZ2 (3-obj), and Rastrigin (single-obj),
|
||||||
reporting hypervolume, spacing, mean L2, front size, and wall time.
|
reporting hypervolume, spacing, mean L2, front size, and wall time.
|
||||||
- `jiggly_tuning` — 4-objective NSGA-III tuning of the
|
- `jiggly_tuning` — 4-objective NSGA-III tuning of the
|
||||||
[`jiggly`](https://git.swaits.com/swaits/jiggly) USB-mouse-jiggler firmware
|
[`jiggly`](https://github.com/swaits/jiggly) USB-mouse-jiggler firmware
|
||||||
with an a-posteriori weighted-decision step that picks one
|
with an a-posteriori weighted-decision step that picks one
|
||||||
recommendation off the Pareto front.
|
recommendation off the Pareto front.
|
||||||
|
|
||||||
@@ -783,5 +542,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://git.swaits.com/swaits/heuropt/compare/v0.10.0...main
|
[Unreleased]: https://github.com/swaits/heuropt/compare/v0.6.0...HEAD
|
||||||
[0.1.0]: https://git.swaits.com/swaits/heuropt/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/swaits/heuropt/releases/tag/v0.1.0
|
||||||
|
|||||||
+6
-20
@@ -1,53 +1,39 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "heuropt"
|
name = "heuropt"
|
||||||
version = "0.11.1"
|
version = "0.6.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>"]
|
||||||
description = "A practical Rust toolkit for heuristic single-, multi-, and many-objective optimization."
|
description = "A practical Rust toolkit for heuristic single-, multi-, and many-objective optimization."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://git.swaits.com/swaits/heuropt"
|
repository = "https://github.com/swaits/heuropt"
|
||||||
homepage = "https://git.swaits.com/swaits/heuropt"
|
homepage = "https://github.com/swaits/heuropt"
|
||||||
documentation = "https://docs.rs/heuropt"
|
documentation = "https://docs.rs/heuropt"
|
||||||
keywords = ["optimization", "evolutionary", "nsga", "pareto", "moead"]
|
keywords = ["optimization", "evolutionary", "nsga", "pareto", "moead"]
|
||||||
categories = ["algorithms", "science", "mathematics", "simulation"]
|
categories = ["algorithms", "science", "mathematics", "simulation"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
serde = ["dep:serde", "dep:serde_json"]
|
serde = ["dep:serde"]
|
||||||
parallel = ["dep:rayon"]
|
parallel = ["dep:rayon"]
|
||||||
async = ["dep:futures"]
|
tracing = ["dep:tracing"]
|
||||||
|
|
||||||
[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 }
|
||||||
serde_json = { version = "1", optional = true }
|
tracing = { version = "0.1", optional = true, default-features = false, features = ["std", "attributes"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
gungraun = "0.18"
|
gungraun = "0.18"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "hot_paths"
|
name = "hot_paths"
|
||||||
harness = false
|
harness = false
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "compare_profile"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "async_eval"
|
|
||||||
required-features = ["async"]
|
|
||||||
|
|
||||||
[[example]]
|
|
||||||
name = "pick_a_car"
|
|
||||||
required-features = ["serde"]
|
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -4,213 +4,88 @@
|
|||||||
[](https://docs.rs/heuropt)
|
[](https://docs.rs/heuropt)
|
||||||
[](https://swaits.github.io/heuropt/)
|
[](https://swaits.github.io/heuropt/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://git.swaits.com/swaits/heuropt/actions?workflow=ci.yml)
|
[](https://github.com/swaits/heuropt/actions/workflows/ci.yml)
|
||||||
|
|
||||||
**A practical Rust toolkit for heuristic optimization.** Single-objective.
|
**A practical Rust toolkit for heuristic optimization.** Single-objective.
|
||||||
Multi-objective. Many-objective. 33 algorithms — every one of them with a
|
Multi-objective. Many-objective. 35 algorithms. One small set of traits.
|
||||||
sync `run` and an async `run_async`. One small set of traits. Bit-identical
|
Bit-identical seeded determinism. No trait objects, no GATs, no generic-RNG
|
||||||
seeded determinism. No trait objects, no GATs, no generic-RNG plumbing in
|
plumbing in the public API.
|
||||||
the public API.
|
|
||||||
|
|
||||||
If you can write a `Problem` impl and read Random Search, you can write your
|
If you can write a `Problem` impl and read `RandomSearch`, you can write your
|
||||||
own optimizer. That's the whole pitch.
|
own optimizer. That's the whole pitch.
|
||||||
|
|
||||||
Docs: [user guide](https://swaits.github.io/heuropt/) · [API reference](https://docs.rs/heuropt).
|
- 📖 **Read the [user guide](https://swaits.github.io/heuropt/)** for tutorials,
|
||||||
|
cookbook recipes, comparison with pymoo / hyperopt / MOEA Framework, and
|
||||||
|
stability policy.
|
||||||
|
- 🔧 **[API reference on docs.rs](https://docs.rs/heuropt)** has runnable
|
||||||
|
` ```rust ` examples on every algorithm.
|
||||||
|
- 🧪 Tested with **316+ unit / integration / property tests** plus 8
|
||||||
|
cargo-fuzz targets running on every PR.
|
||||||
|
- ⚡ Hot paths heavily optimized — comparison harness 3.27× faster as of
|
||||||
|
v0.4.0, all bit-identical to the reference output.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = "0.11"
|
heuropt = "0.5"
|
||||||
|
|
||||||
# Optional features:
|
# Optional features:
|
||||||
# - "serde": derive Serialize/Deserialize on the core data types.
|
# - "serde": derive Serialize/Deserialize on the core data types.
|
||||||
# - "parallel": evaluate populations across rayon's thread pool.
|
# - "parallel": evaluate populations across rayon's thread pool.
|
||||||
# Seeded runs stay bit-identical to serial mode.
|
# Seeded runs stay bit-identical to serial mode.
|
||||||
# - "async": AsyncProblem / AsyncPartialProblem traits and a
|
# heuropt = { version = "0.5", features = ["serde", "parallel"] }
|
||||||
# run_async(&problem, concurrency).await method on
|
|
||||||
# every algorithm — for IO-bound evaluations.
|
|
||||||
# heuropt = { version = "0.11", features = ["serde", "parallel", "async"] }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Define a problem and run an optimizer
|
## Define a problem
|
||||||
|
|
||||||
You're designing a car. Three things you can pick: **engine
|
|
||||||
displacement** (1.0–6.0 L), **curb weight** (1100–2200 kg, where
|
|
||||||
going lighter requires aluminum/carbon and costs money), and
|
|
||||||
**aerodynamic drag** (Cd from 0.20 to 0.40, where slipperier needs
|
|
||||||
expensive aero R&D). Four things you want to optimize: **price**,
|
|
||||||
**0-60 acceleration**, **fuel consumption**, **idle noise** — all
|
|
||||||
in tension.
|
|
||||||
|
|
||||||
The relationships between decisions and objectives are nonlinear
|
|
||||||
and coupled: engine cost grows superlinearly with displacement,
|
|
||||||
weight reduction below 1500 kg costs a quadratic premium, drag
|
|
||||||
reduction below 0.35 Cd costs a 1.5-power premium, and 0-60 depends
|
|
||||||
on weight × engine in a non-trivial way. You can't just sweep one
|
|
||||||
slider — the Pareto front is a genuine surface in 3D decision space,
|
|
||||||
and finding it by hand is hopeless.
|
|
||||||
|
|
||||||
NSGA-III is the canonical many-objective (4+) optimizer; it uses
|
|
||||||
Das–Dennis reference points to keep the front well-spread.
|
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
struct PickACar;
|
struct SchafferN1;
|
||||||
|
|
||||||
impl Problem for PickACar {
|
impl Problem for SchafferN1 {
|
||||||
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
|
type Decision = Vec<f64>;
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
ObjectiveSpace::new(vec![
|
ObjectiveSpace::new(vec![
|
||||||
Objective::minimize("price_thousand_dollars"),
|
Objective::minimize("f1"),
|
||||||
Objective::minimize("seconds_to_60mph"),
|
Objective::minimize("f2"),
|
||||||
Objective::minimize("fuel_gallons_per_100mi"),
|
|
||||||
Objective::minimize("noise_db_at_idle"),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
let displacement = x[0]; // liters
|
let v = x[0];
|
||||||
let weight = x[1]; // kg
|
Evaluation::new(vec![v * v, (v - 2.0).powi(2)])
|
||||||
let drag = x[2]; // dimensionless Cd
|
|
||||||
|
|
||||||
// Price ($k): engine cost grows superlinearly; weight reduction
|
|
||||||
// below 1500 kg and drag reduction below 0.35 Cd both cost extra.
|
|
||||||
let engine_cost = 3.0 * displacement.powf(1.6);
|
|
||||||
let weight_cost = ((1500.0 - weight).max(0.0) / 100.0).powi(2) * 2.0;
|
|
||||||
let aero_cost = ((0.35 - drag).max(0.0) * 100.0).powf(1.5) * 0.4;
|
|
||||||
let price = 10.0 + engine_cost + weight_cost + aero_cost;
|
|
||||||
|
|
||||||
// 0-60 (s): heavier = slower; bigger engine = quicker but with
|
|
||||||
// diminishing returns.
|
|
||||||
let weight_factor = (weight - 1100.0) / 1000.0;
|
|
||||||
let engine_factor = ((displacement - 1.0) / 5.0).max(0.0).powf(0.7);
|
|
||||||
let zero_to_sixty = 5.0 + 5.0 * weight_factor - 4.0 * engine_factor;
|
|
||||||
|
|
||||||
// Fuel consumption (gal/100 mi): all three matter.
|
|
||||||
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
|
|
||||||
|
|
||||||
// Idle noise (dB): engine dominates, mildly nonlinear.
|
|
||||||
let noise = 60.0 + 3.0 * displacement.powf(1.2);
|
|
||||||
|
|
||||||
Evaluation::new(vec![price, zero_to_sixty, fuel, noise])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let bounds = vec![
|
|
||||||
(1.0_f64, 6.0_f64), // engine
|
|
||||||
(1100.0_f64, 2200.0_f64), // weight
|
|
||||||
(0.20_f64, 0.40_f64), // drag
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut optimizer = Nsga3::new(
|
|
||||||
Nsga3Config {
|
|
||||||
population_size: 100,
|
|
||||||
generations: 200,
|
|
||||||
reference_divisions: 5,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds.clone()),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.9),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 3.0),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&PickACar);
|
|
||||||
|
|
||||||
let mut front: Vec<_> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0]).unwrap()
|
|
||||||
});
|
|
||||||
println!("{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
|
|
||||||
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB");
|
|
||||||
for c in &front {
|
|
||||||
let d = &c.decision;
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
println!("{:>5.2} {:>5.0} {:>4.2} {:>6.1} {:>5.1} {:>5.2} {:>5.1}",
|
|
||||||
d[0], d[1], d[2], o[0], o[1], o[2], o[3]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run it (`cargo run --release`) and you get 100 cars on the front.
|
## Run NSGA-II
|
||||||
A representative slice from the actual output, hand-picked across
|
|
||||||
the spectrum:
|
|
||||||
|
|
||||||
```text
|
```rust
|
||||||
L kg Cd $k 0-60 fuel dB ← role
|
use heuropt::prelude::*;
|
||||||
1.00 1505 0.35 13.0 7.0 3.17 63.0 cheap baseline
|
|
||||||
2.00 1370 0.35 22.4 5.1 3.56 66.7 sensible sport sedan
|
# struct SchafferN1;
|
||||||
2.45 1330 0.38 28.5 4.5 3.92 68.8 quicker midprice
|
# impl Problem for SchafferN1 {
|
||||||
1.00 1430 0.21 35.8 6.6 2.54 63.0 fuel-saver (small + slippery)
|
# type Decision = Vec<f64>;
|
||||||
3.50 1300 0.25 52.9 3.5 3.88 73.3 genuine sports car
|
# fn objectives(&self) -> ObjectiveSpace {
|
||||||
5.27 1100 0.20 108.1 1.4 4.48 82.0 hypercar corner
|
# 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)])
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
let initializer = RealBounds::new(vec![(-5.0, 5.0)]);
|
||||||
|
let variation = GaussianMutation { sigma: 0.2 };
|
||||||
|
let config = Nsga2Config { population_size: 60, generations: 80, seed: 42 };
|
||||||
|
let mut optimizer = Nsga2::new(config, initializer, variation);
|
||||||
|
let result = optimizer.run(&SchafferN1);
|
||||||
|
|
||||||
|
println!("Pareto front size: {}", result.pareto_front.len());
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading the result
|
See `examples/toy_nsga2.rs` for the full version.
|
||||||
|
|
||||||
Every row is **non-dominated** — no row is strictly better than
|
|
||||||
another on every metric. The interesting part is what each one does
|
|
||||||
*differently*:
|
|
||||||
|
|
||||||
- The **cheap baseline** ($13k) takes the path of least resistance:
|
|
||||||
smallest engine, no weight reduction, average drag. Slow but
|
|
||||||
affordable.
|
|
||||||
- The **sensible sedan** ($22k) trades $9k for **2 seconds off
|
|
||||||
0-60** by running a 2.0L engine with mild weight reduction.
|
|
||||||
- The **fuel-saver** is interesting: it's a 1.0L econobox engine,
|
|
||||||
but it spends $22k *just on aero* (0.21 Cd) to push fuel
|
|
||||||
consumption down to **2.54 gal/100mi**. The optimizer figured
|
|
||||||
out that aero matters more than displacement at this fuel point.
|
|
||||||
No human would pick this combo by intuition.
|
|
||||||
- The **sports car** ($53k) doesn't blow money on the lightest
|
|
||||||
possible weight — it picks 1300 kg, because dropping further
|
|
||||||
costs disproportionately and the 3.5L engine is doing most of
|
|
||||||
the acceleration work.
|
|
||||||
- The **hypercar corner** ($108k) is the optimizer pushing every
|
|
||||||
decision to its ceiling: minimum weight (1100 kg), minimum
|
|
||||||
drag (0.20 Cd), big engine (5.3L). Sub-1.5 second 0-60, but
|
|
||||||
you pay for it on every other axis except fuel (because the
|
|
||||||
weight + aero savings partly cancel the V8's thirst).
|
|
||||||
|
|
||||||
That last point is the kind of insight a Pareto front gives you
|
|
||||||
that no single-objective optimizer would: **the cheapest fuel-
|
|
||||||
efficient car is not the smallest engine alone**, it's a small
|
|
||||||
engine + aggressive aero. **The lightest sports car is not the
|
|
||||||
lightest possible**, it's the point where weight cost stops paying
|
|
||||||
back in 0-60. The optimizer doesn't tell you what to buy — it
|
|
||||||
hands you the frontier of *every defensible compromise* and lets
|
|
||||||
you pick by your own priorities.
|
|
||||||
|
|
||||||
### Explore it interactively
|
|
||||||
|
|
||||||
Six hand-picked rows out of a hundred is a sample, not a search.
|
|
||||||
With the `serde` feature enabled, the same result becomes one JSON
|
|
||||||
file you can drop into the [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
webapp to browse interactively — parallel coordinates, scatter,
|
|
||||||
range filters, weighted ranking:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
heuropt::explorer::ExplorerExport::from_result(&PickACar, &result)
|
|
||||||
.with_algorithm_info(&optimizer)
|
|
||||||
.with_problem_name("Pick a car")
|
|
||||||
.to_file("results.json")?;
|
|
||||||
```
|
|
||||||
|
|
||||||
The full worked example (which produces this output verbatim) is at
|
|
||||||
`examples/pick_a_car.rs`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
cargo run --release --example pick_a_car --features serde
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [Explore your results](https://swaits.github.io/heuropt/cookbook/explorer.html)
|
|
||||||
cookbook recipe for the export schema and how to enrich your `Problem`
|
|
||||||
with display labels and units.
|
|
||||||
|
|
||||||
## Implement a custom optimizer
|
## Implement a custom optimizer
|
||||||
|
|
||||||
@@ -230,7 +105,13 @@ where
|
|||||||
// Evaluate them with `problem.evaluate(...)`.
|
// Evaluate them with `problem.evaluate(...)`.
|
||||||
// Keep the best, or maintain a Pareto archive.
|
// Keep the best, or maintain a Pareto archive.
|
||||||
// Return an OptimizationResult.
|
// Return an OptimizationResult.
|
||||||
todo!()
|
# OptimizationResult::new(
|
||||||
|
# Population::new(Vec::new()),
|
||||||
|
# Vec::new(),
|
||||||
|
# None,
|
||||||
|
# 0,
|
||||||
|
# 0,
|
||||||
|
# )
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -314,12 +195,12 @@ you need a **sample-efficient** or **multi-fidelity** approach:
|
|||||||
|
|
||||||
- **Cheap (1k+ evals affordable):** any of the population-based
|
- **Cheap (1k+ evals affordable):** any of the population-based
|
||||||
algorithms — DE, GA, CMA-ES, NSGA-II, etc.
|
algorithms — DE, GA, CMA-ES, NSGA-II, etc.
|
||||||
- **Expensive (50–500 evals):** Bayesian Optimization (Gaussian-process
|
- **Expensive (50–500 evals):** `BayesianOpt` (Gaussian-process
|
||||||
surrogate + Expected Improvement) or TPE (Parzen-density
|
surrogate + Expected Improvement) or `Tpe` (Parzen-density
|
||||||
surrogate, cheaper per step, more robust without hyperparameter
|
surrogate, cheaper per step, more robust without hyperparameter
|
||||||
tuning).
|
tuning).
|
||||||
- **Multi-fidelity (each eval has a tunable budget — epochs, sim
|
- **Multi-fidelity (each eval has a tunable budget — epochs, sim
|
||||||
steps, MC samples):** Hyperband. Implement the `PartialProblem`
|
steps, MC samples):** `Hyperband`. Implement the `PartialProblem`
|
||||||
trait on your problem and Hyperband allocates compute aggressively
|
trait on your problem and Hyperband allocates compute aggressively
|
||||||
across promising configs.
|
across promising configs.
|
||||||
|
|
||||||
@@ -365,12 +246,12 @@ START
|
|||||||
│ │
|
│ │
|
||||||
│ ├─ Yes → sample-efficient regime
|
│ ├─ Yes → sample-efficient regime
|
||||||
│ │ ├─ Standard expensive black-box, single-objective
|
│ │ ├─ Standard expensive black-box, single-objective
|
||||||
│ │ │ → Bayesian Optimization (GP + Expected Improvement; gold
|
│ │ │ → BayesianOpt (GP + Expected Improvement; gold
|
||||||
│ │ │ standard *with* per-problem kernel
|
│ │ │ standard *with* per-problem kernel
|
||||||
│ │ │ tuning. The default RBF kernel at
|
│ │ │ tuning. The default RBF kernel at
|
||||||
│ │ │ 60 evals is honestly bad — give it
|
│ │ │ 60 evals is honestly bad — give it
|
||||||
│ │ │ more evals or tune the kernel.)
|
│ │ │ more evals or tune the kernel.)
|
||||||
│ │ │ → TPE (KDE-based; cheaper per-step,
|
│ │ │ → Tpe (KDE-based; cheaper per-step,
|
||||||
│ │ │ more robust without tuning)
|
│ │ │ more robust without tuning)
|
||||||
│ │ │
|
│ │ │
|
||||||
│ │ └─ Each eval has a tunable fidelity (epochs, sim steps, …)
|
│ │ └─ Each eval has a tunable fidelity (epochs, sim steps, …)
|
||||||
@@ -385,126 +266,103 @@ START
|
|||||||
│ │
|
│ │
|
||||||
│ ├─ Decision is Vec<f64> (continuous)
|
│ ├─ Decision is Vec<f64> (continuous)
|
||||||
│ │ ├─ Smooth landscape (well-conditioned)
|
│ │ ├─ Smooth landscape (well-conditioned)
|
||||||
│ │ │ → CMA-ES (full-cov adaptive Gaussian)
|
│ │ │ → CmaEs (full-cov adaptive Gaussian)
|
||||||
│ │ │ → sNES (cheaper diag-cov; high-dim)
|
│ │ │ → SeparableNes (cheaper diag-cov; high-dim)
|
||||||
│ │ │ → Nelder-Mead (low-dim, deterministic, simple)
|
│ │ │ → NelderMead (low-dim, deterministic, simple)
|
||||||
│ │ ├─ Multimodal landscape
|
│ │ ├─ Multimodal landscape
|
||||||
│ │ │ → IPOP-CMA-ES (CMA-ES with restart;
|
│ │ │ → IpopCmaEs (CMA-ES with restart;
|
||||||
│ │ │ fixes vanilla CMA-ES's
|
│ │ │ fixes vanilla CMA-ES's
|
||||||
│ │ │ multimodal failure)
|
│ │ │ multimodal failure)
|
||||||
│ │ │ → Differential Evolution (rarely beaten on cheap
|
│ │ │ → DifferentialEvolution (rarely beaten on cheap
|
||||||
│ │ │ multimodal continuous)
|
│ │ │ multimodal continuous)
|
||||||
│ │ │ → Simulated Annealing (cheap & generic)
|
│ │ │ → SimulatedAnnealing (cheap & generic)
|
||||||
│ │ ├─ Want parameter-free (no F, CR, w, σ to tune)
|
│ │ ├─ Want parameter-free (no F, CR, w, σ to tune)
|
||||||
│ │ │ → TLBO
|
│ │ │ → Tlbo
|
||||||
│ │ ├─ Want minimum self-adapting baseline
|
│ │ ├─ Want minimum self-adapting baseline
|
||||||
│ │ │ → (1+1)-ES (one-fifth rule,
|
│ │ │ → OnePlusOneEs (one-fifth rule,
|
||||||
│ │ │ smallest possible ES)
|
│ │ │ smallest possible ES)
|
||||||
│ │ ├─ Just want a strong default for cheap continuous
|
│ │ ├─ Just want a strong default for cheap continuous
|
||||||
│ │ │ → Differential Evolution
|
│ │ │ → DifferentialEvolution
|
||||||
│ │ └─ Just want a baseline
|
│ │ └─ Just want a baseline
|
||||||
│ │ → Random Search
|
│ │ → RandomSearch
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Decision is Vec<bool> (binary)
|
│ ├─ Decision is Vec<bool> (binary)
|
||||||
│ │ ├─ Independent bits, smooth fitness
|
│ │ ├─ Independent bits, smooth fitness
|
||||||
│ │ │ → UMDA (per-bit marginal EDA)
|
│ │ │ → Umda (per-bit marginal EDA)
|
||||||
│ │ └─ Bit interactions matter
|
│ │ └─ Bit interactions matter
|
||||||
│ │ → GA with BitFlipMutation +
|
│ │ → GeneticAlgorithm with BitFlipMutation +
|
||||||
│ │ a bit-string crossover
|
│ │ a bit-string crossover
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Decision is Vec<usize> (permutation: TSP, JSS, …)
|
│ ├─ Decision is Vec<usize> (permutation, e.g., TSP)
|
||||||
│ │ → Ant Colony (TSP, with a distance matrix)
|
│ │ → AntColonyTsp (with a distance matrix)
|
||||||
│ │ → Simulated Annealing / Tabu Search (strong on
|
│ │ → TabuSearch (with your own neighbor function)
|
||||||
│ │ sequencing — they win the harness TSP and JSS
|
│ │ → SimulatedAnnealing with SwapMutation
|
||||||
│ │ tables — you supply the neighbour move)
|
|
||||||
│ │ → GA + permutation toolkit (ERX for TSP-shaped
|
|
||||||
│ │ instances)
|
|
||||||
│ │
|
│ │
|
||||||
│ └─ Custom decision type (a struct, a tree, …)
|
│ └─ Custom decision type (a struct, a tree, …)
|
||||||
│ → Simulated Annealing or Hill Climber
|
│ → SimulatedAnnealing or HillClimber
|
||||||
│ with your own Variation impl
|
│ with your own Variation impl
|
||||||
│
|
│
|
||||||
├─ 2 or 3 (multi-objective)
|
├─ 2 or 3 (multi-objective)
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Strong default — top-3 on every multi- and
|
│ ├─ Strong default, fast, well-understood
|
||||||
│ │ many-objective table on the harness, fastest or
|
│ │ → Nsga2
|
||||||
│ │ near-fastest every time
|
|
||||||
│ │ → MOEA/D (decomposition into scalar sub-problems;
|
|
||||||
│ │ robust across convex / disconnected /
|
|
||||||
│ │ spherical / linear fronts and 2–10
|
|
||||||
│ │ objectives. Caveat: weight-vector spread
|
|
||||||
│ │ can leave gaps on highly irregular or
|
|
||||||
│ │ degenerate fronts)
|
|
||||||
│ │ → NSGA-II (canonical Pareto EA; well-understood and
|
|
||||||
│ │ the established choice for combinatorial
|
|
||||||
│ │ encodings — but edged out by MOEA/D on
|
|
||||||
│ │ every MO table here, and fades past
|
|
||||||
│ │ ~4 objectives)
|
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Real-valued, smooth front, want best convergence
|
│ ├─ Real-valued, smooth front, want best convergence
|
||||||
│ │ → MOPSO (multi-objective PSO; on the benches
|
│ │ → Mopso (multi-objective PSO; on the benches
|
||||||
│ │ here it wins ZDT1 on both HV and
|
│ │ here it wins ZDT1 on both HV and
|
||||||
│ │ convergence by 100× over the
|
│ │ convergence by 100× over the
|
||||||
│ │ dominance-based methods)
|
│ │ dominance-based methods)
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Want better front quality than the default
|
│ ├─ Want better front quality than NSGA-II
|
||||||
│ │ → IBEA (indicator-based; consistently the best
|
│ │ → Ibea (indicator-based; consistently the best
|
||||||
│ │ of the dominance-based methods on these
|
│ │ of the dominance-based methods on these
|
||||||
│ │ benches — wins ZDT3 HV and DTLZ2 mean
|
│ │ benches — wins ZDT3 HV and DTLZ2 mean
|
||||||
│ │ dist by 24×)
|
│ │ dist by 24×)
|
||||||
│ │ → SPEA2 (strength + density)
|
│ │ → Spea2 (strength + density)
|
||||||
│ │ → SMS-EMOA (hypervolume-contribution selection;
|
│ │ → SmsEmoa (hypervolume-contribution selection;
|
||||||
│ │ elegant in theory but underperforms
|
│ │ elegant in theory but underperforms
|
||||||
│ │ NSGA-II on these benches at our budgets —
|
│ │ NSGA-II on these benches at our budgets —
|
||||||
│ │ only worth its higher per-step cost on
|
│ │ only worth its higher per-step cost on
|
||||||
│ │ fronts where exact HV-contribution is
|
│ │ fronts where exact HV-contribution is
|
||||||
│ │ the right discriminator)
|
│ │ the right discriminator)
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Disconnected front (separate arcs, e.g. ZDT3)
|
│ ├─ Want decomposition / weight-vector style
|
||||||
│ │ → IBEA (wins ZDT3 hypervolume on the harness;
|
│ │ → Moead (very fast per generation, scales well)
|
||||||
│ │ MOEA/D and NSGA-II follow. Geometry-aware
|
|
||||||
│ │ methods trail when the front is in pieces)
|
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Non-convex but *contiguous* front
|
│ ├─ Disconnected or non-convex front
|
||||||
│ │ → AGE-MOEA (estimates front geometry adaptively)
|
│ │ → AgeMoea (estimates front geometry adaptively)
|
||||||
│ │ → KnEA (favors knee points)
|
│ │ → Knea (favors knee points)
|
||||||
|
│ │ → Ibea
|
||||||
│ │
|
│ │
|
||||||
│ ├─ Want region-based diversity
|
│ ├─ Want region-based diversity
|
||||||
│ │ → PESA-II (grid hyperboxes drive selection)
|
│ │ → PesaII (grid hyperboxes drive selection)
|
||||||
│ │ → ε-MOEA (ε-grid archive,
|
│ │ → EpsilonMoea (ε-grid archive,
|
||||||
│ │ archive size auto-limits)
|
│ │ archive size auto-limits)
|
||||||
│ │
|
│ │
|
||||||
│ └─ Just one starting decision (no population budget)
|
│ └─ Just one starting decision (no population budget)
|
||||||
│ → PAES (1+1 ES with a Pareto archive)
|
│ → Paes (1+1 ES with a Pareto archive)
|
||||||
│
|
│
|
||||||
└─ 4+ (many-objective)
|
└─ 4+ (many-objective)
|
||||||
│
|
|
||||||
├─ Strong default — #2 on every many-objective table on
|
|
||||||
│ the harness (DTLZ2 at 4 and 10 objectives, DTLZ1 at 8);
|
|
||||||
│ decomposition sidesteps the dominance collapse that
|
|
||||||
│ wrecks Pareto-based EAs at high objective count
|
|
||||||
│ → MOEA/D
|
|
||||||
│ (NSGA-II is the cautionary tale: on DTLZ2 at 10
|
|
||||||
│ objectives it finishes last — behind random search)
|
|
||||||
│
|
│
|
||||||
├─ Linear / simplex-shaped front (e.g., DTLZ1)
|
├─ Linear / simplex-shaped front (e.g., DTLZ1)
|
||||||
│ → GrEA (grid coords drive ranking; on DTLZ1
|
│ → Grea (grid coords drive ranking; on DTLZ1
|
||||||
│ here it beats NSGA-III by 3× and
|
│ here it beats NSGA-III by 3× and
|
||||||
│ AGE-MOEA by 2.5×, and wins the
|
│ AGE-MOEA by 2.5×)
|
||||||
│ 8-objective DTLZ1 table outright)
|
│ → Moead (decomposition shines on linear fronts;
|
||||||
│ → MOEA/D (also #2 on both DTLZ1 tables)
|
│ second on DTLZ1, also among the
|
||||||
|
│ fastest per generation)
|
||||||
│
|
│
|
||||||
├─ Curved / unknown front geometry
|
├─ Curved / unknown front geometry
|
||||||
│ → NSGA-III (reference-point niching; canonical by
|
│ → Nsga3 (reference-point niching, canonical;
|
||||||
│ reputation, but MOEA/D outperforms it
|
│ a strong default when the front
|
||||||
│ on every harness table)
|
│ isn't simplex-shaped)
|
||||||
│ → AGE-MOEA (estimates L_p geometry per generation)
|
│ → AgeMoea (estimates L_p geometry per generation)
|
||||||
│ → RVEA (reference vectors with adaptive penalty)
|
│ → Rvea (reference vectors with adaptive penalty)
|
||||||
│
|
│
|
||||||
├─ Want indicator-based selection
|
├─ Want indicator-based selection
|
||||||
│ → IBEA (additive ε-indicator; doesn't degrade
|
│ → Ibea (additive ε-indicator; doesn't degrade
|
||||||
│ at high obj count)
|
│ at high obj count)
|
||||||
│ → HypE (Monte Carlo HV estimation; scales
|
│ → Hype (Monte Carlo HV estimation; scales
|
||||||
│ to arbitrary M)
|
│ to arbitrary M)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -514,54 +372,54 @@ START
|
|||||||
|
|
||||||
| Algorithm | Objectives | Decision | Strengths |
|
| Algorithm | Objectives | Decision | Strengths |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Bayesian Optimization** | 1 | `Vec<f64>` | GP surrogate + EI; gold standard *with* per-problem kernel tuning (default RBF at 60 evals is honestly bad) |
|
| `BayesianOpt` | 1 | `Vec<f64>` | GP surrogate + EI; gold standard *with* per-problem kernel tuning (default RBF at 60 evals is honestly bad) |
|
||||||
| **TPE** | 1 | `Vec<f64>` | KDE surrogate; robust without hyperparameter tuning |
|
| `Tpe` | 1 | `Vec<f64>` | KDE surrogate; robust without hyperparameter tuning |
|
||||||
| **Hyperband** | 1 | any | multi-fidelity; needs `PartialProblem` |
|
| `Hyperband` | 1 | any | multi-fidelity; needs `PartialProblem` |
|
||||||
|
|
||||||
**Single-objective continuous (`Vec<f64>`):**
|
**Single-objective continuous (`Vec<f64>`):**
|
||||||
|
|
||||||
| Algorithm | Strengths |
|
| Algorithm | Strengths |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Random Search** | sanity baseline |
|
| `RandomSearch` | sanity baseline |
|
||||||
| **Hill Climber** | simplest greedy local search |
|
| `HillClimber` | simplest greedy local search |
|
||||||
| **(1+1)-ES** | one-fifth-rule self-adapting baseline |
|
| `OnePlusOneEs` | one-fifth-rule self-adapting baseline |
|
||||||
| **Simulated Annealing** | escapes local optima |
|
| `SimulatedAnnealing` | escapes local optima |
|
||||||
| **GA** | classic SO GA with elitism |
|
| `GeneticAlgorithm` | classic SO GA with elitism |
|
||||||
| **PSO** | simple swarm baseline |
|
| `ParticleSwarm` | simple swarm baseline |
|
||||||
| **Differential Evolution** | strong default for cheap continuous |
|
| `DifferentialEvolution` | strong default for cheap continuous |
|
||||||
| **TLBO** | parameter-free (no F, CR, w, σ) |
|
| `Tlbo` | parameter-free (no F, CR, w, σ) |
|
||||||
| **CMA-ES** | smooth landscapes; full covariance |
|
| `CmaEs` | smooth landscapes; full covariance |
|
||||||
| **IPOP-CMA-ES** | CMA-ES + restart for multimodal |
|
| `IpopCmaEs` | CMA-ES + restart for multimodal |
|
||||||
| **sNES** | diagonal-cov NES; cheap per-step |
|
| `SeparableNes` | diagonal-cov NES; cheap per-step |
|
||||||
| **Nelder-Mead** | classical simplex; deterministic |
|
| `NelderMead` | classical simplex; deterministic |
|
||||||
|
|
||||||
**Single-objective other decision types:**
|
**Single-objective other decision types:**
|
||||||
|
|
||||||
| Algorithm | Decision | Strengths |
|
| Algorithm | Decision | Strengths |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **UMDA** | `Vec<bool>` | independent-bit EDA |
|
| `Umda` | `Vec<bool>` | independent-bit EDA |
|
||||||
| **Tabu Search** | any | discrete, you supply neighbors |
|
| `TabuSearch` | any | discrete, you supply neighbors |
|
||||||
| **Ant Colony** | `Vec<usize>` | TSP / permutation |
|
| `AntColonyTsp` | `Vec<usize>` | TSP / permutation |
|
||||||
|
|
||||||
**Multi-objective (2–3) and many-objective (4+):**
|
**Multi-objective (2–3) and many-objective (4+):**
|
||||||
|
|
||||||
| Algorithm | Objectives | Strengths |
|
| Algorithm | Objectives | Strengths |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **MOEA/D** | 2+ | decomposition; the most consistent all-rounder — top-3 on every MO/many-objective table here, fastest or near-fastest |
|
| `Paes` | 2–3 | 1+1 ES with Pareto archive |
|
||||||
| **NSGA-II** | 2–3 | canonical Pareto-based EA; well-understood, the go-to for combinatorial encodings — but fades past ~4 objectives |
|
| `Nsga2` | 2–3 | canonical Pareto-based EA |
|
||||||
| **MOPSO** | 2–3 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts |
|
| `Spea2` | 2–3 | strength + density |
|
||||||
| **IBEA** | 2+ | indicator-based; consistently best of the dominance-based methods; wins disconnected fronts |
|
| `Mopso` | 2–3 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts |
|
||||||
| **SPEA2** | 2–3 | strength + density |
|
| `Ibea` | 2+ | indicator-based; consistently best of the dominance-based methods |
|
||||||
| **SMS-EMOA** | 2+ | exact HV-contribution selection; high per-step cost, modest gain |
|
| `SmsEmoa` | 2+ | exact HV-contribution selection; high per-step cost, modest gain |
|
||||||
| **HypE** | 2+ | Monte Carlo HV estimation; strong on spherical many-objective fronts |
|
| `Hype` | 2+ | Monte Carlo HV estimation |
|
||||||
| **ε-MOEA** | 2+ | ε-grid archive; auto-sized |
|
| `EpsilonMoea` | 2+ | ε-grid archive; auto-sized |
|
||||||
| **PESA-II** | 2+ | grid-based region selection |
|
| `PesaII` | 2+ | grid-based region selection |
|
||||||
| **AGE-MOEA** | 2+ | adaptive front-geometry estimation |
|
| `AgeMoea` | 2+ | adaptive front-geometry estimation |
|
||||||
| **KnEA** | 2+ | knee-point favored survival |
|
| `Knea` | 2+ | knee-point favored survival |
|
||||||
| **PAES** | 2–3 | 1+1 ES with Pareto archive |
|
| `Moead` | 2+ | decomposition; fast per-gen |
|
||||||
| **NSGA-III** | 4+ | reference-point niching; strong on curved fronts |
|
| `Nsga3` | 4+ | reference-point niching; strong on curved fronts |
|
||||||
| **RVEA** | 4+ | reference vectors with penalty |
|
| `Rvea` | 4+ | reference vectors with penalty |
|
||||||
| **GrEA** | 4+ | grid coords drive selection; wins linear/simplex fronts at any objective count |
|
| `Grea` | 4+ | grid coords drive selection; particularly strong on linear/simplex fronts |
|
||||||
|
|
||||||
## Current algorithms
|
## Current algorithms
|
||||||
|
|
||||||
@@ -569,48 +427,48 @@ The full list with one-line descriptions:
|
|||||||
|
|
||||||
**Sample-efficient / multi-fidelity:**
|
**Sample-efficient / multi-fidelity:**
|
||||||
|
|
||||||
- **Bayesian Optimization** — Gaussian-process surrogate + Expected Improvement.
|
- `BayesianOpt` — Gaussian-process surrogate + Expected Improvement.
|
||||||
- **TPE** — Bergstra et al. 2011 Tree-structured Parzen Estimator.
|
- `Tpe` — Bergstra et al. 2011 Tree-structured Parzen Estimator.
|
||||||
- **Hyperband** — Li et al. 2017 multi-fidelity (uses `PartialProblem`).
|
- `Hyperband` — Li et al. 2017 multi-fidelity (uses `PartialProblem`).
|
||||||
|
|
||||||
**Single-objective:**
|
**Single-objective:**
|
||||||
|
|
||||||
- **Random Search** — sample-evaluate-keep baseline.
|
- `RandomSearch` — sample-evaluate-keep baseline.
|
||||||
- **Hill Climber** — greedy single-step local search.
|
- `HillClimber` — greedy single-step local search.
|
||||||
- **(1+1)-ES** — Rechenberg 1973 (1+1)-ES with one-fifth rule.
|
- `OnePlusOneEs` — Rechenberg 1973 (1+1)-ES with one-fifth rule.
|
||||||
- **Simulated Annealing** — Kirkpatrick et al. 1983, generic over decision type.
|
- `SimulatedAnnealing` — Kirkpatrick et al. 1983, generic over decision type.
|
||||||
- **Tabu Search** — Glover 1986, with a user-supplied neighbor generator.
|
- `TabuSearch` — Glover 1986, with a user-supplied neighbor generator.
|
||||||
- **GA** — generational GA with tournament selection + elitism.
|
- `GeneticAlgorithm` — generational GA with tournament selection + elitism.
|
||||||
- **PSO** — Eberhart & Kennedy 1995 PSO for `Vec<f64>`.
|
- `ParticleSwarm` — Eberhart & Kennedy 1995 PSO for `Vec<f64>`.
|
||||||
- **Differential Evolution** — Storn & Price DE/rand/1/bin for `Vec<f64>`.
|
- `DifferentialEvolution` — Storn & Price DE/rand/1/bin for `Vec<f64>`.
|
||||||
- **TLBO** — Rao 2011 Teaching-Learning-Based Optimization (parameter-free).
|
- `Tlbo` — Rao 2011 Teaching-Learning-Based Optimization (parameter-free).
|
||||||
- **CMA-ES** — Hansen & Ostermeier 2001 covariance-matrix adaptation.
|
- `CmaEs` — Hansen & Ostermeier 2001 covariance-matrix adaptation.
|
||||||
- **IPOP-CMA-ES** — Auger & Hansen 2005 CMA-ES with restart, for multimodal.
|
- `IpopCmaEs` — Auger & Hansen 2005 CMA-ES with restart, for multimodal.
|
||||||
- **sNES** — Wierstra et al. 2008/2014 diagonal-cov NES.
|
- `SeparableNes` — Wierstra et al. 2008/2014 diagonal-cov NES.
|
||||||
- **Nelder-Mead** — Nelder & Mead 1965 simplex direct search.
|
- `NelderMead` — Nelder & Mead 1965 simplex direct search.
|
||||||
- **UMDA** — Mühlenbein 1997 univariate marginal-distribution EDA for `Vec<bool>`.
|
- `Umda` — Mühlenbein 1997 univariate marginal-distribution EDA for `Vec<bool>`.
|
||||||
- **Ant Colony** — Dorigo Ant System for permutation problems.
|
- `AntColonyTsp` — Dorigo Ant System for permutation problems.
|
||||||
|
|
||||||
**Multi-objective:**
|
**Multi-objective:**
|
||||||
|
|
||||||
- **PAES** — Knowles & Corne 1999 Pareto Archived Evolution Strategy.
|
- `Paes` — Knowles & Corne 1999 Pareto Archived Evolution Strategy.
|
||||||
- **NSGA-II** — Deb et al. 2002, the canonical Pareto-based EA.
|
- `Nsga2` — Deb et al. 2002, the canonical Pareto-based EA.
|
||||||
- **SPEA2** — Zitzler, Laumanns & Thiele 2001 strength-Pareto EA.
|
- `Spea2` — Zitzler, Laumanns & Thiele 2001 strength-Pareto EA.
|
||||||
- **MOEA/D** — Zhang & Li 2007 decomposition-based MOEA with Tchebycheff scalarization.
|
- `Moead` — Zhang & Li 2007 decomposition-based MOEA with Tchebycheff scalarization.
|
||||||
- **MOPSO** — Coello, Pulido & Lechuga 2004 multi-objective PSO.
|
- `Mopso` — Coello, Pulido & Lechuga 2004 multi-objective PSO.
|
||||||
- **IBEA** — Zitzler & Künzli 2004 indicator-based EA.
|
- `Ibea` — Zitzler & Künzli 2004 indicator-based EA.
|
||||||
- **SMS-EMOA** — Beume, Naujoks & Emmerich 2007 hypervolume-selection EMOA.
|
- `SmsEmoa` — Beume, Naujoks & Emmerich 2007 hypervolume-selection EMOA.
|
||||||
- **HypE** — Bader & Zitzler 2011 Hypervolume Estimation Algorithm.
|
- `Hype` — Bader & Zitzler 2011 Hypervolume Estimation Algorithm.
|
||||||
- **ε-MOEA** — Deb, Mohan & Mishra 2003 ε-dominance MOEA.
|
- `EpsilonMoea` — Deb, Mohan & Mishra 2003 ε-dominance MOEA.
|
||||||
- **PESA-II** — Corne et al. 2001 Pareto Envelope Selection II.
|
- `PesaII` — Corne et al. 2001 Pareto Envelope Selection II.
|
||||||
- **AGE-MOEA** — Panichella 2019 Adaptive Geometry Estimation MOEA.
|
- `AgeMoea` — Panichella 2019 Adaptive Geometry Estimation MOEA.
|
||||||
- **KnEA** — Zhang, Tian & Jin 2015 Knee point-driven EA.
|
- `Knea` — Zhang, Tian & Jin 2015 Knee point-driven EA.
|
||||||
|
|
||||||
**Many-objective (4+):**
|
**Many-objective (4+):**
|
||||||
|
|
||||||
- **NSGA-III** — Deb & Jain 2014 reference-point NSGA-III.
|
- `Nsga3` — Deb & Jain 2014 reference-point NSGA-III.
|
||||||
- **RVEA** — Cheng et al. 2016 Reference Vector-guided EA.
|
- `Rvea` — Cheng et al. 2016 Reference Vector-guided EA.
|
||||||
- **GrEA** — Yang et al. 2013 Grid-based EA.
|
- `Grea` — Yang et al. 2013 Grid-based EA.
|
||||||
|
|
||||||
**Reusable utilities:** `pareto_compare`, `pareto_front`, `best_candidate`,
|
**Reusable utilities:** `pareto_compare`, `pareto_front`, `best_candidate`,
|
||||||
`non_dominated_sort`, `crowding_distance`, `ParetoArchive`, `das_dennis`,
|
`non_dominated_sort`, `crowding_distance`, `ParetoArchive`, `das_dennis`,
|
||||||
@@ -625,7 +483,7 @@ and the metrics `spacing` and `hypervolume_2d`.
|
|||||||
user-facing APIs, no generic-RNG plumbing — `Rng` is a single concrete type
|
user-facing APIs, no generic-RNG plumbing — `Rng` is a single concrete type
|
||||||
alias.
|
alias.
|
||||||
- **Readable algorithms.** Built-ins are written for clarity, not maximum
|
- **Readable algorithms.** Built-ins are written for clarity, not maximum
|
||||||
abstraction reuse. Random Search is the recommended file to read before
|
abstraction reuse. `RandomSearch` is the recommended file to read before
|
||||||
writing your own optimizer.
|
writing your own optimizer.
|
||||||
- **One crate first.** No premature splitting into `-core`/`-algorithms`/
|
- **One crate first.** No premature splitting into `-core`/`-algorithms`/
|
||||||
`-operators`. Split later if the crate grows.
|
`-operators`. Split later if the crate grows.
|
||||||
|
|||||||
+6
-7
@@ -8,8 +8,8 @@ needed.
|
|||||||
|
|
||||||
| Version | Supported |
|
| Version | Supported |
|
||||||
|---------|--------------------|
|
|---------|--------------------|
|
||||||
| 0.10.x | ✅ |
|
| 0.5.x | ✅ |
|
||||||
| ≤ 0.9.x | ❌ (please upgrade) |
|
| ≤ 0.4.x | ❌ (please upgrade) |
|
||||||
|
|
||||||
heuropt is pre-1.0; the public API may change between minor versions.
|
heuropt is pre-1.0; the public API may change between minor versions.
|
||||||
Once 1.0.0 ships, the support window will be at least the latest two
|
Once 1.0.0 ships, the support window will be at least the latest two
|
||||||
@@ -17,15 +17,14 @@ minor versions.
|
|||||||
|
|
||||||
## Reporting a vulnerability
|
## Reporting a vulnerability
|
||||||
|
|
||||||
Please **do not** open a public issue for a security bug.
|
Please **do not** open a public GitHub issue for a security bug.
|
||||||
Instead:
|
Instead use one of these channels:
|
||||||
|
|
||||||
|
- GitHub's [private vulnerability reporting](https://github.com/swaits/heuropt/security/advisories/new)
|
||||||
|
on the repository.
|
||||||
- Email **steve@waits.net** with subject line `[heuropt security]
|
- Email **steve@waits.net** with subject line `[heuropt security]
|
||||||
<short summary>`.
|
<short summary>`.
|
||||||
|
|
||||||
(This project is hosted on [git.swaits.com](https://git.swaits.com/swaits/heuropt),
|
|
||||||
which has no private-advisory feature, so email is the reporting channel.)
|
|
||||||
|
|
||||||
Please include:
|
Please include:
|
||||||
|
|
||||||
1. A description of the vulnerability and the affected versions.
|
1. A description of the vulnerability and the affected versions.
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
//! Whole-program callgrind profile of the `compare` example workload.
|
|
||||||
//!
|
|
||||||
//! Runs every algorithm runner once (seed 0) under callgrind via gungraun —
|
|
||||||
//! the same workload `examples/compare.rs` runs, minus the multi-seed
|
|
||||||
//! averaging and table printing. gungraun reports the total instruction
|
|
||||||
//! count and diffs it against the previous run; the saved `callgrind.out`
|
|
||||||
//! (`target/gungraun/compare_profile/compare_group/full_compare_workload/`)
|
|
||||||
//! carries the per-function breakdown — `callgrind_annotate` it to rank
|
|
||||||
//! functions by self-instruction cost.
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo bench --bench compare_profile
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
// The shared `compare_workload` module also carries the example's
|
|
||||||
// presentation layer (`run_all`, the `run_*_comparison` printers,
|
|
||||||
// `print_table`, …), which this profiling benchmark deliberately does not
|
|
||||||
// use — it drives only the runner functions via `profile_workload`. The
|
|
||||||
// runner functions themselves are *not* allow-listed, so a runner that
|
|
||||||
// `profile_workload` forgets to call still warns.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use std::hint::black_box;
|
|
||||||
|
|
||||||
use gungraun::Callgrind;
|
|
||||||
use gungraun::prelude::*;
|
|
||||||
|
|
||||||
#[path = "../examples/_shared/compare_workload.rs"]
|
|
||||||
mod workload;
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn full_compare_workload() -> u64 {
|
|
||||||
black_box(workload::profile_workload())
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
|
||||||
name = compare_group;
|
|
||||||
benchmarks = full_compare_workload
|
|
||||||
);
|
|
||||||
|
|
||||||
// `--cache-sim=no`: the campaign ranks functions on instruction count
|
|
||||||
// (`Ir`) only, so callgrind's cache simulation is pure overhead here —
|
|
||||||
// disabling it roughly halves each profiling run.
|
|
||||||
main!(
|
|
||||||
config = LibraryBenchmarkConfig::default().tool(Callgrind::with_args(["--cache-sim=no"])),
|
|
||||||
library_benchmark_groups = compare_group
|
|
||||||
);
|
|
||||||
+2
-663
@@ -10,14 +10,11 @@
|
|||||||
use std::hint::black_box;
|
use std::hint::black_box;
|
||||||
|
|
||||||
use gungraun::prelude::*;
|
use gungraun::prelude::*;
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
use heuropt::core::candidate::Candidate;
|
use heuropt::core::candidate::Candidate;
|
||||||
use heuropt::core::evaluation::Evaluation;
|
use heuropt::core::evaluation::Evaluation;
|
||||||
use heuropt::core::objective::{Objective, ObjectiveSpace};
|
use heuropt::core::objective::{Objective, ObjectiveSpace};
|
||||||
use heuropt::core::partial_problem::PartialProblem;
|
|
||||||
use heuropt::core::problem::Problem;
|
use heuropt::core::problem::Problem;
|
||||||
use heuropt::core::rng::{Rng, rng_from_seed};
|
|
||||||
use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd};
|
use heuropt::metrics::hypervolume::{hypervolume_2d, hypervolume_nd};
|
||||||
use heuropt::pareto::crowding::crowding_distance;
|
use heuropt::pareto::crowding::crowding_distance;
|
||||||
use heuropt::pareto::sort::non_dominated_sort;
|
use heuropt::pareto::sort::non_dominated_sort;
|
||||||
@@ -401,74 +398,6 @@ fn ipop_cma_es_short() -> usize {
|
|||||||
black_box(o.run(black_box(&Sphere1D)).evaluations)
|
black_box(o.run(black_box(&Sphere1D)).evaluations)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1-D integer parabola: minimize `(x - 5)^2`. `Vec<i32>` decision so it
|
|
||||||
/// satisfies `TabuSearch`'s `Hash + Eq` decision bound (`f64` is neither).
|
|
||||||
struct IntParabola;
|
|
||||||
impl Problem for IntParabola {
|
|
||||||
type Decision = Vec<i32>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, x: &Vec<i32>) -> Evaluation {
|
|
||||||
let v = (x[0] - 5) as f64;
|
|
||||||
Evaluation::new(vec![v * v])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Start every 1-D integer decision at 0.
|
|
||||||
struct IntStartAtZero;
|
|
||||||
impl Initializer<Vec<i32>> for IntStartAtZero {
|
|
||||||
fn initialize(&mut self, size: usize, _rng: &mut Rng) -> Vec<Vec<i32>> {
|
|
||||||
(0..size).map(|_| vec![0]).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn tabu_search_short() -> usize {
|
|
||||||
let neighbors = |x: &Vec<i32>, _rng: &mut Rng| {
|
|
||||||
vec![
|
|
||||||
vec![x[0] - 2],
|
|
||||||
vec![x[0] - 1],
|
|
||||||
vec![x[0] + 1],
|
|
||||||
vec![x[0] + 2],
|
|
||||||
]
|
|
||||||
};
|
|
||||||
let mut o = TabuSearch::new(
|
|
||||||
TabuSearchConfig {
|
|
||||||
iterations: 50,
|
|
||||||
tabu_tenure: 8,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
IntStartAtZero,
|
|
||||||
neighbors,
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&IntParabola)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OneMax over 16 bits: maximize the count of `true` bits.
|
|
||||||
struct OneMax16;
|
|
||||||
impl Problem for OneMax16 {
|
|
||||||
type Decision = Vec<bool>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::maximize("ones")])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, x: &Vec<bool>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![x.iter().filter(|b| **b).count() as f64])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn umda_short() -> usize {
|
|
||||||
let mut o = Umda::new(UmdaConfig {
|
|
||||||
population_size: 20,
|
|
||||||
selected_size: 8,
|
|
||||||
generations: 5,
|
|
||||||
bits: 16,
|
|
||||||
seed: 0,
|
|
||||||
});
|
|
||||||
black_box(o.run(black_box(&OneMax16)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
library_benchmark_group!(
|
||||||
name = single_objective_group;
|
name = single_objective_group;
|
||||||
benchmarks =
|
benchmarks =
|
||||||
@@ -476,8 +405,7 @@ library_benchmark_group!(
|
|||||||
simulated_annealing_short, genetic_algorithm_short,
|
simulated_annealing_short, genetic_algorithm_short,
|
||||||
particle_swarm_short, differential_evolution_short, tlbo_short,
|
particle_swarm_short, differential_evolution_short, tlbo_short,
|
||||||
separable_nes_short, nelder_mead_short,
|
separable_nes_short, nelder_mead_short,
|
||||||
bayesian_opt_short, tpe_short, ipop_cma_es_short,
|
bayesian_opt_short, tpe_short, ipop_cma_es_short
|
||||||
tabu_search_short, umda_short
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -715,598 +643,9 @@ library_benchmark_group!(
|
|||||||
age_moea_short, grea_short, knea_short, rvea_short, paes_short
|
age_moea_short, grea_short, knea_short, rvea_short, paes_short
|
||||||
);
|
);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Permutation operator micro-benchmarks
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn perm_parent(n: usize) -> Vec<usize> {
|
|
||||||
(0..n).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reversed `[0..n)`: same value multiset as `perm_parent`, shares no oriented
|
|
||||||
/// edges with it — a stress input for the edge-based crossovers.
|
|
||||||
fn perm_parent_rev(n: usize) -> Vec<usize> {
|
|
||||||
(0..n).rev().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn shuffled_permutation_init(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let mut rng = rng_from_seed(0);
|
|
||||||
let mut init = ShuffledPermutation { n };
|
|
||||||
black_box(init.initialize(black_box(16), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn shuffled_multiset_permutation_init(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let mut rng = rng_from_seed(0);
|
|
||||||
let mut init = ShuffledMultisetPermutation::new(vec![5; n]);
|
|
||||||
black_box(init.initialize(black_box(16), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn swap_mutation_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parent = perm_parent(n);
|
|
||||||
let mut rng = rng_from_seed(1);
|
|
||||||
let mut op = SwapMutation;
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn inversion_mutation_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parent = perm_parent(n);
|
|
||||||
let mut rng = rng_from_seed(1);
|
|
||||||
let mut op = InversionMutation;
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn insertion_mutation_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parent = perm_parent(n);
|
|
||||||
let mut rng = rng_from_seed(1);
|
|
||||||
let mut op = InsertionMutation;
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn scramble_mutation_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parent = perm_parent(n);
|
|
||||||
let mut rng = rng_from_seed(1);
|
|
||||||
let mut op = ScrambleMutation;
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn order_crossover_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parents = [perm_parent(n), perm_parent_rev(n)];
|
|
||||||
let mut rng = rng_from_seed(2);
|
|
||||||
let mut op = OrderCrossover;
|
|
||||||
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn pmx_crossover_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parents = [perm_parent(n), perm_parent_rev(n)];
|
|
||||||
let mut rng = rng_from_seed(2);
|
|
||||||
let mut op = PartiallyMappedCrossover;
|
|
||||||
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn cycle_crossover_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parents = [perm_parent(n), perm_parent_rev(n)];
|
|
||||||
let mut rng = rng_from_seed(2);
|
|
||||||
let mut op = CycleCrossover;
|
|
||||||
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
#[bench::n_30(30)]
|
|
||||||
#[bench::n_100(100)]
|
|
||||||
fn edge_recombination_crossover_vary(n: usize) -> Vec<Vec<usize>> {
|
|
||||||
let parents = [perm_parent(n), perm_parent_rev(n)];
|
|
||||||
let mut rng = rng_from_seed(2);
|
|
||||||
let mut op = EdgeRecombinationCrossover;
|
|
||||||
black_box(op.vary(black_box(&parents), black_box(&mut rng)))
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
|
||||||
name = permutation_ops_group;
|
|
||||||
benchmarks =
|
|
||||||
shuffled_permutation_init, shuffled_multiset_permutation_init,
|
|
||||||
swap_mutation_vary, inversion_mutation_vary, insertion_mutation_vary,
|
|
||||||
scramble_mutation_vary, order_crossover_vary, pmx_crossover_vary,
|
|
||||||
cycle_crossover_vary, edge_recombination_crossover_vary
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Un-benchmarked operators from the binary / real / repair families
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn bit_flip_mutation_vary() -> Vec<Vec<bool>> {
|
|
||||||
let parent: Vec<bool> = (0..64).map(|i| i % 2 == 0).collect();
|
|
||||||
let mut rng = rng_from_seed(3);
|
|
||||||
let mut op = BitFlipMutation {
|
|
||||||
probability: 1.0 / 64.0,
|
|
||||||
};
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn levy_mutation_vary() -> Vec<Vec<f64>> {
|
|
||||||
let parent = vec![0.0_f64; 16];
|
|
||||||
let mut rng = rng_from_seed(3);
|
|
||||||
let mut op = LevyMutation::new(1.5, 0.1, vec![(-5.0, 5.0); 16]);
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn bounded_gaussian_mutation_vary() -> Vec<Vec<f64>> {
|
|
||||||
let parent = vec![0.0_f64; 16];
|
|
||||||
let mut rng = rng_from_seed(3);
|
|
||||||
let mut op = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 16]);
|
|
||||||
black_box(op.vary(
|
|
||||||
black_box(std::slice::from_ref(&parent)),
|
|
||||||
black_box(&mut rng),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn clamp_to_bounds_repair() -> Vec<f64> {
|
|
||||||
let mut x: Vec<f64> = (0..32).map(|i| (i as f64) - 16.0).collect();
|
|
||||||
let mut op = ClampToBounds::new(vec![(-1.0, 1.0); 32]);
|
|
||||||
op.repair(black_box(&mut x));
|
|
||||||
black_box(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn project_to_simplex_repair() -> Vec<f64> {
|
|
||||||
// 32-dim mixed-sign vector; exercises the sort-based projection path.
|
|
||||||
let mut x: Vec<f64> = (0..32).map(|i| ((i * 7 % 13) as f64) - 6.0).collect();
|
|
||||||
let mut op = ProjectToSimplex::new(1.0);
|
|
||||||
op.repair(black_box(&mut x));
|
|
||||||
black_box(x)
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
|
||||||
name = variation_ops_group;
|
|
||||||
benchmarks =
|
|
||||||
bit_flip_mutation_vary, levy_mutation_vary, bounded_gaussian_mutation_vary,
|
|
||||||
clamp_to_bounds_repair, project_to_simplex_repair
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Combinatorial / sequencing end-to-end benches
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const TSP_N: usize = 15;
|
|
||||||
|
|
||||||
/// Deterministic pseudo-scattered city coordinates. The bench only needs a
|
|
||||||
/// stable distance matrix, not a known optimum.
|
|
||||||
fn tsp_coords() -> Vec<(f64, f64)> {
|
|
||||||
(0..TSP_N)
|
|
||||||
.map(|i| {
|
|
||||||
let x = ((i * 37) % 100) as f64;
|
|
||||||
let y = ((i * 53 + 11) % 100) as f64;
|
|
||||||
(x, y)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tsp_distance_matrix() -> Vec<Vec<f64>> {
|
|
||||||
let c = tsp_coords();
|
|
||||||
let n = c.len();
|
|
||||||
let mut d = vec![vec![0.0_f64; n]; n];
|
|
||||||
for i in 0..n {
|
|
||||||
for j in 0..n {
|
|
||||||
if i != j {
|
|
||||||
let dx = c[i].0 - c[j].0;
|
|
||||||
let dy = c[i].1 - c[j].1;
|
|
||||||
d[i][j] = (dx * dx + dy * dy).sqrt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single-objective TSP over a precomputed distance matrix.
|
|
||||||
struct TspProblem {
|
|
||||||
distances: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
impl Problem for TspProblem {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("length")])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut len = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
len += self.distances[tour[i]][tour[(i + 1) % n]];
|
|
||||||
}
|
|
||||||
Evaluation::new(vec![len])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Bi-objective TSP: two distance matrices over the same city set.
|
|
||||||
struct BiTspProblem {
|
|
||||||
dist_a: Vec<Vec<f64>>,
|
|
||||||
dist_b: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
impl Problem for BiTspProblem {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("length_a"),
|
|
||||||
Objective::minimize("length_b"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
let n = tour.len();
|
|
||||||
let (mut la, mut lb) = (0.0, 0.0);
|
|
||||||
for i in 0..n {
|
|
||||||
let (u, v) = (tour[i], tour[(i + 1) % n]);
|
|
||||||
la += self.dist_a[u][v];
|
|
||||||
lb += self.dist_b[u][v];
|
|
||||||
}
|
|
||||||
Evaluation::new(vec![la, lb])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn tsp_nsga2_short() -> usize {
|
|
||||||
let dist_a = tsp_distance_matrix();
|
|
||||||
// Second objective: a distinct symmetric matrix with a zero diagonal.
|
|
||||||
let dist_b: Vec<Vec<f64>> = dist_a
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, row)| {
|
|
||||||
row.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(j, &d)| if i == j { 0.0 } else { d * 0.5 + 3.0 })
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let problem = BiTspProblem { dist_a, dist_b };
|
|
||||||
let mut o = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 20,
|
|
||||||
generations: 3,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n: TSP_N },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OrderCrossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&problem)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn ant_colony_tsp_short() -> usize {
|
|
||||||
let distances = tsp_distance_matrix();
|
|
||||||
let problem = TspProblem {
|
|
||||||
distances: distances.clone(),
|
|
||||||
};
|
|
||||||
let mut o = AntColonyTsp::new(
|
|
||||||
AntColonyTspConfig {
|
|
||||||
ants: 8,
|
|
||||||
generations: 3,
|
|
||||||
alpha: 1.0,
|
|
||||||
beta: 2.0,
|
|
||||||
evaporation: 0.5,
|
|
||||||
deposit: 1.0,
|
|
||||||
initial_pheromone: 1.0,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
distances,
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&problem)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
const JSS_JOBS: usize = 6;
|
|
||||||
const JSS_MACHINES: usize = 6;
|
|
||||||
|
|
||||||
/// FT06 (Fisher & Thompson 1963) routing — machine id of the k-th operation
|
|
||||||
/// of job j.
|
|
||||||
const FT06_MACHINE: [[usize; JSS_MACHINES]; JSS_JOBS] = [
|
|
||||||
[2, 0, 1, 3, 5, 4],
|
|
||||||
[1, 2, 4, 5, 0, 3],
|
|
||||||
[2, 3, 5, 0, 1, 4],
|
|
||||||
[1, 0, 2, 3, 4, 5],
|
|
||||||
[2, 1, 4, 5, 0, 3],
|
|
||||||
[1, 3, 5, 0, 4, 2],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// FT06 processing times — duration of the k-th operation of job j.
|
|
||||||
const FT06_TIME: [[f64; JSS_MACHINES]; JSS_JOBS] = [
|
|
||||||
[1.0, 3.0, 6.0, 7.0, 3.0, 6.0],
|
|
||||||
[8.0, 5.0, 10.0, 10.0, 10.0, 4.0],
|
|
||||||
[5.0, 4.0, 8.0, 9.0, 1.0, 7.0],
|
|
||||||
[5.0, 5.0, 5.0, 3.0, 8.0, 9.0],
|
|
||||||
[9.0, 3.0, 5.0, 4.0, 3.0, 1.0],
|
|
||||||
[3.0, 3.0, 9.0, 10.0, 4.0, 1.0],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Bi-objective FT06 job-shop scheduling: f1 = makespan, f2 = total flow time.
|
|
||||||
struct Ft06Problem;
|
|
||||||
impl Problem for Ft06Problem {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("makespan"),
|
|
||||||
Objective::minimize("total_flow_time"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
|
||||||
let mut job_next = [0_usize; JSS_JOBS];
|
|
||||||
let mut job_clock = [0.0_f64; JSS_JOBS];
|
|
||||||
let mut machine_clock = [0.0_f64; JSS_MACHINES];
|
|
||||||
for &job in schedule {
|
|
||||||
let k = job_next[job];
|
|
||||||
let m = FT06_MACHINE[job][k];
|
|
||||||
let t = FT06_TIME[job][k];
|
|
||||||
let start = job_clock[job].max(machine_clock[m]);
|
|
||||||
let end = start + t;
|
|
||||||
job_clock[job] = end;
|
|
||||||
machine_clock[m] = end;
|
|
||||||
job_next[job] = k + 1;
|
|
||||||
}
|
|
||||||
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
|
|
||||||
let flow_time: f64 = job_clock.iter().sum();
|
|
||||||
Evaluation::new(vec![makespan, flow_time])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Precedence-Order Crossover — multiset-preserving crossover for the
|
|
||||||
/// operation-string JSS encoding. Trimmed from `examples/mo_jss_la01.rs`;
|
|
||||||
/// the strict-permutation crossovers cannot be used on multiset encodings.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct PrecedenceOrderCrossover;
|
|
||||||
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
assert!(parents.len() >= 2, "POX requires 2 parents");
|
|
||||||
let (p1, p2) = (&parents[0], &parents[1]);
|
|
||||||
let mut in_j1 = [false; JSS_JOBS];
|
|
||||||
loop {
|
|
||||||
for slot in &mut in_j1 {
|
|
||||||
*slot = rng.random_bool(0.5);
|
|
||||||
}
|
|
||||||
let c = in_j1.iter().filter(|&&b| b).count();
|
|
||||||
if c > 0 && c < JSS_JOBS {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
|
|
||||||
let n = donor.len();
|
|
||||||
let mut child = vec![usize::MAX; n];
|
|
||||||
for k in 0..n {
|
|
||||||
if in_donor_set[donor[k]] {
|
|
||||||
child[k] = donor[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut fill_idx = 0;
|
|
||||||
for &v in filler {
|
|
||||||
if !in_donor_set[v] {
|
|
||||||
while fill_idx < n && child[fill_idx] != usize::MAX {
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
child[fill_idx] = v;
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
child
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn jss_nsga2_short() -> usize {
|
|
||||||
let mut o = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 20,
|
|
||||||
generations: 3,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
ShuffledMultisetPermutation::new(vec![JSS_MACHINES; JSS_JOBS]),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: PrecedenceOrderCrossover,
|
|
||||||
mutation: InsertionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&Ft06Problem)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
const KNAPSACK_N: usize = 20;
|
|
||||||
|
|
||||||
const KP_PROFIT_A: [f64; KNAPSACK_N] = [
|
|
||||||
61.0, 17.0, 92.0, 49.0, 73.0, 28.0, 84.0, 36.0, 55.0, 78.0, 23.0, 91.0, 12.0, 67.0, 45.0, 58.0,
|
|
||||||
33.0, 71.0, 14.0, 26.0,
|
|
||||||
];
|
|
||||||
const KP_PROFIT_B: [f64; KNAPSACK_N] = [
|
|
||||||
24.0, 81.0, 16.0, 67.0, 29.0, 73.0, 41.0, 60.0, 52.0, 19.0, 77.0, 34.0, 95.0, 22.0, 71.0, 88.0,
|
|
||||||
56.0, 27.0, 64.0, 90.0,
|
|
||||||
];
|
|
||||||
const KP_WEIGHT: [f64; KNAPSACK_N] = [
|
|
||||||
35.0, 58.0, 22.0, 71.0, 14.0, 86.0, 31.0, 53.0, 78.0, 19.0, 44.0, 16.0, 67.0, 88.0, 25.0, 51.0,
|
|
||||||
33.0, 74.0, 12.0, 47.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Bi-objective 0/1 knapsack with a penalty-based capacity constraint.
|
|
||||||
struct KnapsackProblem {
|
|
||||||
capacity: f64,
|
|
||||||
}
|
|
||||||
impl Problem for KnapsackProblem {
|
|
||||||
type Decision = Vec<bool>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::maximize("profit_a"),
|
|
||||||
Objective::maximize("profit_b"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
|
|
||||||
let (mut pa, mut pb, mut w) = (0.0, 0.0, 0.0);
|
|
||||||
for (i, &t) in take.iter().enumerate() {
|
|
||||||
if t {
|
|
||||||
pa += KP_PROFIT_A[i];
|
|
||||||
pb += KP_PROFIT_B[i];
|
|
||||||
w += KP_WEIGHT[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let penalty = 1000.0 * (w - self.capacity).max(0.0);
|
|
||||||
Evaluation::new(vec![pa - penalty, pb - penalty])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Random binary initializer — each bit 50/50 independently.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
struct RandomBinary {
|
|
||||||
n: usize,
|
|
||||||
}
|
|
||||||
impl Initializer<Vec<bool>> for RandomBinary {
|
|
||||||
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
(0..size)
|
|
||||||
.map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-point crossover for binary chromosomes. Trimmed from
|
|
||||||
/// `examples/mo_knapsack.rs`.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct OnePointCrossoverBool;
|
|
||||||
impl Variation<Vec<bool>> for OnePointCrossoverBool {
|
|
||||||
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
assert!(
|
|
||||||
parents.len() >= 2,
|
|
||||||
"OnePointCrossoverBool requires 2 parents"
|
|
||||||
);
|
|
||||||
let (p1, p2) = (&parents[0], &parents[1]);
|
|
||||||
let n = p1.len();
|
|
||||||
if n < 2 {
|
|
||||||
return vec![p1.clone(), p2.clone()];
|
|
||||||
}
|
|
||||||
let cut = rng.random_range(1..n);
|
|
||||||
let mut c1 = Vec::with_capacity(n);
|
|
||||||
let mut c2 = Vec::with_capacity(n);
|
|
||||||
c1.extend_from_slice(&p1[..cut]);
|
|
||||||
c1.extend_from_slice(&p2[cut..]);
|
|
||||||
c2.extend_from_slice(&p2[..cut]);
|
|
||||||
c2.extend_from_slice(&p1[cut..]);
|
|
||||||
vec![c1, c2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn knapsack_nsga2_short() -> usize {
|
|
||||||
let capacity = 0.5 * KP_WEIGHT.iter().sum::<f64>();
|
|
||||||
let problem = KnapsackProblem { capacity };
|
|
||||||
let mut o = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 20,
|
|
||||||
generations: 3,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
RandomBinary { n: KNAPSACK_N },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OnePointCrossoverBool,
|
|
||||||
mutation: BitFlipMutation {
|
|
||||||
probability: 1.0 / KNAPSACK_N as f64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&problem)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
|
||||||
name = combinatorial_group;
|
|
||||||
benchmarks =
|
|
||||||
tsp_nsga2_short, ant_colony_tsp_short, jss_nsga2_short, knapsack_nsga2_short
|
|
||||||
);
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Multi-fidelity (Hyperband)
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Multi-fidelity 2-D sphere: higher budget shrinks an additive residual, so
|
|
||||||
/// the loss is budget-monotone the way Hyperband expects. Deterministic.
|
|
||||||
struct MultiFidelitySphere;
|
|
||||||
impl PartialProblem for MultiFidelitySphere {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
|
||||||
}
|
|
||||||
fn evaluate_at_budget(&self, x: &Vec<f64>, budget: f64) -> Evaluation {
|
|
||||||
let true_f: f64 = x.iter().map(|v| v * v).sum();
|
|
||||||
let residual = 1.0 / (budget + 1.0);
|
|
||||||
Evaluation::new(vec![true_f + residual])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[library_benchmark]
|
|
||||||
fn hyperband_short() -> usize {
|
|
||||||
let mut o = Hyperband::new(
|
|
||||||
HyperbandConfig {
|
|
||||||
max_budget: 27.0,
|
|
||||||
eta: 3.0,
|
|
||||||
max_brackets: 3,
|
|
||||||
seed: 0,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-5.0, 5.0); 2]),
|
|
||||||
);
|
|
||||||
black_box(o.run(black_box(&MultiFidelitySphere)).evaluations)
|
|
||||||
}
|
|
||||||
|
|
||||||
library_benchmark_group!(
|
|
||||||
name = multi_fidelity_group;
|
|
||||||
benchmarks = hyperband_short
|
|
||||||
);
|
|
||||||
|
|
||||||
main!(
|
main!(
|
||||||
library_benchmark_groups = pareto_group,
|
library_benchmark_groups = pareto_group,
|
||||||
algorithm_group,
|
algorithm_group,
|
||||||
single_objective_group,
|
single_objective_group,
|
||||||
multi_objective_group,
|
multi_objective_group
|
||||||
permutation_ops_group,
|
|
||||||
variation_ops_group,
|
|
||||||
combinatorial_group,
|
|
||||||
multi_fidelity_group
|
|
||||||
);
|
);
|
||||||
|
|||||||
+3
-3
@@ -12,8 +12,8 @@ create-missing = false
|
|||||||
[output.html]
|
[output.html]
|
||||||
default-theme = "rust"
|
default-theme = "rust"
|
||||||
preferred-dark-theme = "navy"
|
preferred-dark-theme = "navy"
|
||||||
git-repository-url = "https://git.swaits.com/swaits/heuropt"
|
git-repository-url = "https://github.com/swaits/heuropt"
|
||||||
edit-url-template = "https://git.swaits.com/swaits/heuropt/_edit/main/docs/book/{path}"
|
edit-url-template = "https://github.com/swaits/heuropt/edit/main/docs/book/{path}"
|
||||||
site-url = "/heuropt/"
|
site-url = "/heuropt/"
|
||||||
no-section-label = true
|
no-section-label = true
|
||||||
|
|
||||||
@@ -31,4 +31,4 @@ use-boolean-and = true
|
|||||||
enable = true
|
enable = true
|
||||||
|
|
||||||
[rust]
|
[rust]
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
|
|||||||
@@ -12,14 +12,11 @@
|
|||||||
|
|
||||||
- [Recipes](./cookbook.md)
|
- [Recipes](./cookbook.md)
|
||||||
- [Parallelize evaluation with rayon](./cookbook/parallel.md)
|
- [Parallelize evaluation with rayon](./cookbook/parallel.md)
|
||||||
- [Async evaluation (HTTP / RPC / subprocess)](./cookbook/async.md)
|
|
||||||
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
||||||
- [Compare two algorithms on your problem](./cookbook/compare.md)
|
- [Compare two algorithms on your problem](./cookbook/compare.md)
|
||||||
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md)
|
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md)
|
||||||
- [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md)
|
|
||||||
- [Constrain your search with `Repair`](./cookbook/constraints.md)
|
- [Constrain your search with `Repair`](./cookbook/constraints.md)
|
||||||
- [Pick one answer off a Pareto front](./cookbook/pick-one.md)
|
- [Pick one answer off a Pareto front](./cookbook/pick-one.md)
|
||||||
- [Explore your results in a webapp](./cookbook/explorer.md)
|
|
||||||
- [Write your own algorithm](./cookbook/custom-optimizer.md)
|
- [Write your own algorithm](./cookbook/custom-optimizer.md)
|
||||||
|
|
||||||
# Reference
|
# Reference
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ after it.
|
|||||||
|
|
||||||
For the cheap-eval branch, you have the run of the catalog. For the
|
For the cheap-eval branch, you have the run of the catalog. For the
|
||||||
expensive branch, classical evolutionary methods waste your evaluation
|
expensive branch, classical evolutionary methods waste your evaluation
|
||||||
budget — go to [Bayesian Optimization][BayesianOpt] or [TPE]. For the *very* expensive
|
budget — go to [`BayesianOpt`] or [`Tpe`]. For the *very* expensive
|
||||||
branch where each eval has a tunable budget (epochs, MC samples, sim
|
branch where each eval has a tunable budget (epochs, MC samples, sim
|
||||||
steps), [Hyperband] over the [`PartialProblem`] trait is the move.
|
steps), [`Hyperband`] over the [`PartialProblem`] trait is the move.
|
||||||
|
|
||||||
## Step 1: How many objectives?
|
## Step 1: How many objectives?
|
||||||
|
|
||||||
@@ -51,48 +51,48 @@ These all take `Vec<f64>` decisions.
|
|||||||
|
|
||||||
### Smooth, low-to-moderate dimension
|
### Smooth, low-to-moderate dimension
|
||||||
|
|
||||||
[CMA-ES][CmaEs] is the strong default. It adapts the search distribution's
|
[`CmaEs`] is the strong default. It adapts the search distribution's
|
||||||
covariance to the local landscape. On the comparison harness it
|
covariance to the local landscape. On the comparison harness it
|
||||||
hits machine epsilon on Rosenbrock at 30 000 evaluations.
|
hits machine epsilon on Rosenbrock at 30 000 evaluations.
|
||||||
|
|
||||||
For very low-dimensional smooth problems (≤ 5 dim), [Nelder-Mead][NelderMead] is
|
For very low-dimensional smooth problems (≤ 5 dim), [`NelderMead`] is
|
||||||
deterministic and converges to f = 0 exactly on Rosenbrock.
|
deterministic and converges to f = 0 exactly on Rosenbrock.
|
||||||
|
|
||||||
### High dimension, smooth
|
### High dimension, smooth
|
||||||
|
|
||||||
[sNES][SeparableNes] uses a diagonal covariance — cheaper per step than
|
[`SeparableNes`] uses a diagonal covariance — cheaper per step than
|
||||||
CMA-ES at the cost of being unable to model rotated landscapes. Worth
|
CmaEs at the cost of being unable to model rotated landscapes. Worth
|
||||||
trying when CMA-ES's `O(d²)` per-step cost hurts.
|
trying when CmaEs's `O(d²)` per-step cost hurts.
|
||||||
|
|
||||||
### Multimodal landscapes
|
### Multimodal landscapes
|
||||||
|
|
||||||
Multimodal = many local minima that aren't the global one. Rastrigin
|
Multimodal = many local minima that aren't the global one. Rastrigin
|
||||||
and Ackley are classic traps.
|
and Ackley are classic traps.
|
||||||
|
|
||||||
[IPOP-CMA-ES][IpopCmaEs] is CMA-ES with an increasing-population restart strategy
|
[`IpopCmaEs`] is CmaEs with an increasing-population restart strategy
|
||||||
specifically designed for this. On the harness it drops vanilla CMA-ES's
|
specifically designed for this. On the harness it drops vanilla CmaEs's
|
||||||
Rastrigin score from f = 2.35 to f = 0.13.
|
Rastrigin score from f = 2.35 to f = 0.13.
|
||||||
|
|
||||||
[Differential Evolution][DifferentialEvolution] is rarely beaten on cheap multimodal
|
[`DifferentialEvolution`] is rarely beaten on cheap multimodal
|
||||||
continuous problems. On Rastrigin it ties with `(1+1)-ES` at f = 0.
|
continuous problems. On Rastrigin it ties with `(1+1)-ES` at f = 0.
|
||||||
|
|
||||||
[Simulated Annealing][SimulatedAnnealing] is a cheap, generic baseline that escapes local
|
[`SimulatedAnnealing`] is a cheap, generic baseline that escapes local
|
||||||
optima via temperature decay.
|
optima via temperature decay.
|
||||||
|
|
||||||
### Want parameter-free
|
### Want parameter-free
|
||||||
|
|
||||||
[TLBO][Tlbo] (Teaching-Learning-Based Optimization) has no `F`, `CR`, `w`,
|
[`Tlbo`] (Teaching-Learning-Based Optimization) has no `F`, `CR`, `w`,
|
||||||
or `σ` to tune. Often a respectable middle-of-the-pack performer.
|
or `σ` to tune. Often a respectable middle-of-the-pack performer.
|
||||||
|
|
||||||
### Smallest possible self-adapting baseline
|
### Smallest possible self-adapting baseline
|
||||||
|
|
||||||
[(1+1)-ES][OnePlusOneEs] — Rechenberg's 1973 `(1+1)`-ES with the one-fifth
|
[`OnePlusOneEs`] — Rechenberg's 1973 `(1+1)`-ES with the one-fifth
|
||||||
success rule. On the harness it hits f = 0 on Rastrigin in 50 000
|
success rule. On the harness it hits f = 0 on Rastrigin in 50 000
|
||||||
evaluations.
|
evaluations.
|
||||||
|
|
||||||
### Just want a baseline
|
### Just want a baseline
|
||||||
|
|
||||||
[Random Search][RandomSearch]. Useful as a sanity check: if your fancy optimizer
|
[`RandomSearch`]. Useful as a sanity check: if your fancy optimizer
|
||||||
can't beat random search, something is wrong (with the fancy
|
can't beat random search, something is wrong (with the fancy
|
||||||
optimizer or with the problem).
|
optimizer or with the problem).
|
||||||
|
|
||||||
@@ -100,140 +100,96 @@ optimizer or with the problem).
|
|||||||
|
|
||||||
| Decision type | Algorithm | Notes |
|
| Decision type | Algorithm | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Vec<bool>` | [UMDA][Umda] | Per-bit marginal EDA. Independent-bit assumption. |
|
| `Vec<bool>` | [`Umda`] | Per-bit marginal EDA. Independent-bit assumption. |
|
||||||
| `Vec<bool>` | [GA][GeneticAlgorithm] + [`BitFlipMutation`] | When bit interactions matter. |
|
| `Vec<bool>` | [`GeneticAlgorithm`] + [`BitFlipMutation`] | When bit interactions matter. |
|
||||||
| `Vec<usize>` (permutation) | [Ant Colony][AntColonyTsp] | TSP-style with a distance matrix. |
|
| `Vec<usize>` (permutation) | [`AntColonyTsp`] | TSP-style with a distance matrix. |
|
||||||
| `Vec<usize>` (permutation) | [GA][GeneticAlgorithm] + [`ShuffledPermutation`] + [`OrderCrossover`] + [`InversionMutation`] | Generic permutation GA; use [`EdgeRecombinationCrossover`] for TSP-shaped instances. |
|
| `Vec<usize>` (permutation) | [`SimulatedAnnealing`] + [`SwapMutation`] | Generic discrete baseline. |
|
||||||
| `Vec<usize>` (JSS multiset) | [Simulated Annealing][SimulatedAnnealing] / [Tabu Search][TabuSearch] with [`InsertionMutation`], or [GA][GeneticAlgorithm] + [`ShuffledMultisetPermutation`] + local POX | Operation-string encoding. On the FT06 harness the local-search pair edges out the GA — see [Optimize a permutation](./cookbook/permutation.md). |
|
| `Vec<usize>` or custom | [`TabuSearch`] | You supply the neighbor function. |
|
||||||
| `Vec<usize>` (permutation) | [Simulated Annealing][SimulatedAnnealing] + [`InversionMutation`] | Strong on sequencing, not just a baseline — wins the harness's FT06 job-shop table and ties for the TSP optimum. |
|
| Custom struct | [`SimulatedAnnealing`] / [`HillClimber`] | With your own `Variation` impl. |
|
||||||
| `Vec<usize>` or custom | [Tabu Search][TabuSearch] | You supply the neighbor function; consistently near the top on the TSP and JSS tables. |
|
|
||||||
| Custom struct | [Simulated Annealing][SimulatedAnnealing] / [Hill Climber][HillClimber] | With your own `Variation` impl. |
|
|
||||||
|
|
||||||
heuropt's permutation operator toolkit covers four crossovers
|
|
||||||
([`OrderCrossover`], [`PartiallyMappedCrossover`], [`CycleCrossover`],
|
|
||||||
[`EdgeRecombinationCrossover`]) and four mutations ([`SwapMutation`],
|
|
||||||
[`InversionMutation`], [`InsertionMutation`], [`ScrambleMutation`]),
|
|
||||||
plus two initializers for strict and multiset permutations. See
|
|
||||||
[Optimize a permutation](./cookbook/permutation.md) for the full
|
|
||||||
picker.
|
|
||||||
|
|
||||||
## Step 2 — multi-objective (2 or 3)
|
## Step 2 — multi-objective (2 or 3)
|
||||||
|
|
||||||
### Strong default
|
### Strong default
|
||||||
|
|
||||||
[MOEA/D][Moead] is the most consistent performer on the harness. It
|
[`Nsga2`] is the canonical Pareto-based EA. Fast, well-understood,
|
||||||
decomposes the problem into many scalar sub-problems (Tchebycheff or
|
maintains diversity via crowding distance. On the harness it lands
|
||||||
weighted sum) and solves them in parallel — fast per generation, and
|
on the Pareto front of every test problem.
|
||||||
robust: it finishes **top-3 on every multi- and many-objective table**
|
|
||||||
(convex, disconnected, spherical and linear fronts; 2 through 10
|
|
||||||
objectives) and is consistently the fastest or near-fastest. It rarely
|
|
||||||
*wins* a table outright — a specialist usually does — but it never lands
|
|
||||||
badly. One caveat from the literature: MOEA/D's spread depends on the
|
|
||||||
weight-vector distribution and the scalarizing function, so it can leave
|
|
||||||
gaps on highly irregular or degenerate fronts; the DTLZ/ZDT suite here
|
|
||||||
doesn't stress that.
|
|
||||||
|
|
||||||
[NSGA-II][Nsga2] is the other safe default — the canonical Pareto-based
|
|
||||||
EA: fast, well-understood, diversity-preserving via crowding distance.
|
|
||||||
On the harness it's edged out by MOEA/D on every multi-objective table
|
|
||||||
and degrades past ~4 objectives (see the many-objective section), but it
|
|
||||||
stays a solid 2–3-objective pick and is the established choice for
|
|
||||||
*combinatorial* encodings: drop in [`ShuffledPermutation`] + a
|
|
||||||
permutation crossover and it solves bi-objective TSP; drop in a binary
|
|
||||||
initializer and [`BitFlipMutation`] and it solves bi-objective knapsack.
|
|
||||||
See [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md).
|
|
||||||
|
|
||||||
### Real-valued, smooth front, want best convergence
|
### Real-valued, smooth front, want best convergence
|
||||||
|
|
||||||
[MOPSO][Mopso] (multi-objective PSO with archive). On ZDT1 it wins
|
[`Mopso`] (multi-objective PSO with archive). On ZDT1 it wins
|
||||||
hypervolume outright and converges 100× tighter than the
|
hypervolume outright and converges 100× tighter than the
|
||||||
dominance-based methods.
|
dominance-based methods.
|
||||||
|
|
||||||
### Better front quality than the default
|
### Better front quality than NSGA-II
|
||||||
|
|
||||||
[IBEA][Ibea] (indicator-based) is consistently the best of the
|
[`Ibea`] (indicator-based) is consistently the best of the
|
||||||
dominance-based methods on the harness — wins ZDT3 hypervolume and
|
dominance-based methods on the harness — wins ZDT3 hypervolume and
|
||||||
DTLZ2 mean distance by 24×. It uses an additive ε-indicator for
|
DTLZ2 mean distance by 24×. It uses an additive ε-indicator for
|
||||||
selection rather than dominance + crowding.
|
selection rather than dominance + crowding.
|
||||||
|
|
||||||
[SPEA2][Spea2] (strength + density) — solid alternative; explicit external
|
[`Spea2`] (strength + density) — solid alternative; explicit external
|
||||||
archive separate from the population.
|
archive separate from the population.
|
||||||
|
|
||||||
[SMS-EMOA][SmsEmoa] uses exact hypervolume contribution for selection. Elegant
|
[`SmsEmoa`] uses exact hypervolume contribution for selection. Elegant
|
||||||
in theory; in practice on the harness budgets here it underperforms
|
in theory; in practice on the harness budgets here it underperforms
|
||||||
NSGA-II. Worth the higher per-step cost only when exact HV
|
NSGA-II. Worth the higher per-step cost only when exact HV
|
||||||
contribution is the right discriminator.
|
contribution is the right discriminator.
|
||||||
|
|
||||||
|
### Decomposition / weight-vector style
|
||||||
|
|
||||||
|
[`Moead`] decomposes the multi-objective problem into many scalar
|
||||||
|
sub-problems (Tchebycheff or weighted sum) and solves them in
|
||||||
|
parallel. Very fast per generation; scales naturally to many
|
||||||
|
objectives.
|
||||||
|
|
||||||
### Disconnected or non-convex front
|
### Disconnected or non-convex front
|
||||||
|
|
||||||
A *disconnected* front (separate arcs, like ZDT3) and a *non-convex but
|
[`AgeMoea`] estimates the front geometry adaptively (the L_p
|
||||||
contiguous* front are different problems — don't conflate them.
|
|
||||||
|
|
||||||
For a **disconnected** front, [IBEA][Ibea] is the clear pick: on the
|
|
||||||
harness it wins ZDT3 — the disconnected-front benchmark — outright on
|
|
||||||
hypervolume, with [MOEA/D][Moead] and [NSGA-II][Nsga2] close behind.
|
|
||||||
Counter-intuitively the geometry-aware methods below *trail* here:
|
|
||||||
estimating a single front geometry or chasing knee points doesn't help
|
|
||||||
when the front is in pieces (on ZDT3, AGE-MOEA and KnEA finish last).
|
|
||||||
|
|
||||||
For a **non-convex but contiguous** front:
|
|
||||||
|
|
||||||
[AGE-MOEA][AgeMoea] estimates the front geometry adaptively (the L_p
|
|
||||||
parameter `p` is fit from data each generation).
|
parameter `p` is fit from data each generation).
|
||||||
|
|
||||||
[KnEA][Knea] favors knee points — the regions of the front where small
|
[`Knea`] favors knee points — the regions of the front where small
|
||||||
gains in one objective cost large losses in another.
|
gains in one objective cost large losses in another.
|
||||||
|
|
||||||
|
[`Ibea`] also handles disconnected fronts well.
|
||||||
|
|
||||||
### Region-based diversity
|
### Region-based diversity
|
||||||
|
|
||||||
[PESA-II][PesaII] uses grid hyperboxes to drive selection — divide the
|
[`PesaII`] uses grid hyperboxes to drive selection — divide the
|
||||||
objective space into a grid, pick from the least-crowded boxes.
|
objective space into a grid, pick from the least-crowded boxes.
|
||||||
|
|
||||||
[ε-MOEA][EpsilonMoea] uses an ε-grid archive that auto-limits its size.
|
[`EpsilonMoea`] uses an ε-grid archive that auto-limits its size.
|
||||||
|
|
||||||
### Just one starting decision (no population budget)
|
### Just one starting decision (no population budget)
|
||||||
|
|
||||||
[PAES][Paes] — `(1+1)`-ES with a Pareto archive. Cheap, simple, useful
|
[`Paes`] — `(1+1)`-ES with a Pareto archive. Cheap, simple, useful
|
||||||
when your evaluations are expensive enough that you can't afford a
|
when your evaluations are expensive enough that you can't afford a
|
||||||
population.
|
population.
|
||||||
|
|
||||||
## Step 2 — many-objective (4+)
|
## Step 2 — many-objective (4+)
|
||||||
|
|
||||||
### Strong default
|
|
||||||
|
|
||||||
[MOEA/D][Moead] again. Decomposition sidesteps the *dominance resistance*
|
|
||||||
that breaks Pareto-based methods at high objective count — each scalar
|
|
||||||
sub-problem still has a clear best, even when almost every pair of
|
|
||||||
solutions is mutually non-dominated. On the harness it is **#2 on every
|
|
||||||
many-objective table** (DTLZ2 at 4 and 10 objectives, DTLZ1 at 8), and
|
|
||||||
fast every time. [NSGA-II][Nsga2] is the cautionary tale: on DTLZ2 at 10
|
|
||||||
objectives it finishes *last — behind random search* — because its
|
|
||||||
crowding distance has no dominance signal left to refine.
|
|
||||||
|
|
||||||
### Linear / simplex-shaped front (e.g., DTLZ1)
|
### Linear / simplex-shaped front (e.g., DTLZ1)
|
||||||
|
|
||||||
[GrEA][Grea] — grid coords drive ranking. On 3-objective DTLZ1 it beats
|
[`Grea`] — grid coords drive ranking. On DTLZ1 it beats NSGA-III by
|
||||||
NSGA-III by 3× and AGE-MOEA by 2.5×, and it wins the 8-objective DTLZ1
|
3× and AGE-MOEA by 2.5×.
|
||||||
table outright.
|
|
||||||
|
|
||||||
[MOEA/D][Moead] — also #2 on both DTLZ1 tables.
|
[`Moead`] — decomposition shines on linear fronts; second on DTLZ1
|
||||||
|
and among the fastest per generation.
|
||||||
|
|
||||||
### Curved / unknown front geometry
|
### Curved / unknown front geometry
|
||||||
|
|
||||||
[NSGA-III][Nsga3] — reference-point niching; the canonical many-objective
|
[`Nsga3`] — reference-point niching; canonical many-objective method;
|
||||||
method by reputation, though on the harness MOEA/D outperforms it on
|
strong default when the front isn't simplex-shaped.
|
||||||
every table. Reach for it when you specifically want reference-point
|
|
||||||
niching.
|
|
||||||
|
|
||||||
[AGE-MOEA][AgeMoea] — estimates L_p geometry per generation.
|
[`AgeMoea`] — estimates L_p geometry per generation.
|
||||||
|
|
||||||
[RVEA][Rvea] — reference vectors with adaptive penalty.
|
[`Rvea`] — reference vectors with adaptive penalty.
|
||||||
|
|
||||||
### Indicator-based selection
|
### Indicator-based selection
|
||||||
|
|
||||||
[IBEA][Ibea] — additive ε-indicator; doesn't degrade at high obj count.
|
[`Ibea`] — additive ε-indicator; doesn't degrade at high obj count.
|
||||||
|
|
||||||
[HypE][Hype] — Monte Carlo hypervolume estimation; scales to arbitrary
|
[`HypE`] — Monte Carlo hypervolume estimation; scales to arbitrary
|
||||||
objective count where exact HV is too expensive.
|
objective count where exact HV is too expensive.
|
||||||
|
|
||||||
## Step 3: Are there hard constraints?
|
## Step 3: Are there hard constraints?
|
||||||
@@ -260,87 +216,68 @@ for worked examples.
|
|||||||
## Step 4: Should you parallelize?
|
## Step 4: Should you parallelize?
|
||||||
|
|
||||||
Enable the `parallel` feature flag if your `evaluate` takes more
|
Enable the `parallel` feature flag if your `evaluate` takes more
|
||||||
than ~50 µs. Population-based algorithms ([Random Search][RandomSearch], [NSGA-II][Nsga2],
|
than ~50 µs. Population-based algorithms ([`RandomSearch`], [`Nsga2`],
|
||||||
[Differential Evolution][DifferentialEvolution], [SPEA2][Spea2], [IBEA][Ibea], [MOPSO][Mopso], …) batch-
|
[`DifferentialEvolution`], [`Spea2`], [`Ibea`], [`Mopso`], …) batch-
|
||||||
evaluate via rayon when the feature is on. **Seeded runs stay
|
evaluate via rayon when the feature is on. **Seeded runs stay
|
||||||
bit-identical** to serial mode.
|
bit-identical** to serial mode.
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
heuropt = { version = "0.10", features = ["parallel"] }
|
heuropt = { version = "0.5", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
If your evaluation is **IO-bound** (HTTP request, RPC, subprocess)
|
|
||||||
rather than CPU-bound, use the `async` feature instead — it gives
|
|
||||||
you `AsyncProblem` and a `run_async(&problem, concurrency).await`
|
|
||||||
method on every algorithm in the catalog. See the
|
|
||||||
[Async evaluation cookbook recipe](./cookbook/async.md).
|
|
||||||
|
|
||||||
## TL;DR table
|
## TL;DR table
|
||||||
|
|
||||||
| Situation | Pick |
|
| Situation | Pick |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Smooth single-objective continuous | [CMA-ES][CmaEs] |
|
| Smooth single-objective continuous | [`CmaEs`] |
|
||||||
| Multimodal single-objective continuous | [IPOP-CMA-ES][IpopCmaEs] or [Differential Evolution][DifferentialEvolution] |
|
| Multimodal single-objective continuous | [`IpopCmaEs`] or [`DifferentialEvolution`] |
|
||||||
| Expensive single-objective | [Bayesian Optimization][BayesianOpt] or [TPE] |
|
| Expensive single-objective | [`BayesianOpt`] or [`Tpe`] |
|
||||||
| Multi-fidelity single-objective | [Hyperband] |
|
| Multi-fidelity single-objective | [`Hyperband`] |
|
||||||
| 2- or 3-objective default | [MOEA/D][Moead] (or [NSGA-II][Nsga2]) |
|
| 2- or 3-objective default | [`Nsga2`] |
|
||||||
| Many-objective default | [MOEA/D][Moead] |
|
| 2-objective real-valued smooth front | [`Mopso`] |
|
||||||
| 2-objective real-valued smooth front | [MOPSO][Mopso] |
|
| Disconnected / non-convex front | [`Ibea`] |
|
||||||
| Disconnected front | [IBEA][Ibea] |
|
| Many-objective default (curved front) | [`Nsga3`] |
|
||||||
| Many-objective, curved front | [NSGA-III][Nsga3] |
|
| Many-objective linear / simplex front | [`Grea`] |
|
||||||
| Many-objective, linear / simplex front | [GrEA][Grea] |
|
| Permutation problem | [`AntColonyTsp`] |
|
||||||
| Permutation problem (TSP with distance matrix) | [Ant Colony][AntColonyTsp] |
|
| Binary problem | [`Umda`] |
|
||||||
| Generic permutation problem | [GA][GeneticAlgorithm] + permutation toolkit |
|
| Custom decision type | [`SimulatedAnnealing`] + your `Variation` |
|
||||||
| Bi-objective combinatorial (TSP / scheduling / knapsack) | [NSGA-II][Nsga2] + matching encoding operators |
|
| Sanity baseline | [`RandomSearch`] |
|
||||||
| 3-objective combinatorial | [NSGA-III][Nsga3] + matching encoding operators |
|
|
||||||
| Binary problem | [UMDA][Umda] |
|
|
||||||
| Custom decision type | [Simulated Annealing][SimulatedAnnealing] + your `Variation` |
|
|
||||||
| Sanity baseline | [Random Search][RandomSearch] |
|
|
||||||
|
|
||||||
[CmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
|
[`CmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
|
||||||
[IpopCmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/ipop_cma_es/struct.IpopCmaEs.html
|
[`IpopCmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ipop_cma_es/struct.IpopCmaEs.html
|
||||||
[SeparableNes]: https://docs.rs/heuropt/latest/heuropt/algorithms/snes/struct.SeparableNes.html
|
[`SeparableNes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/snes/struct.SeparableNes.html
|
||||||
[NelderMead]: https://docs.rs/heuropt/latest/heuropt/algorithms/nelder_mead/struct.NelderMead.html
|
[`NelderMead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nelder_mead/struct.NelderMead.html
|
||||||
[DifferentialEvolution]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
|
[`DifferentialEvolution`]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
|
||||||
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
||||||
[Tlbo]: https://docs.rs/heuropt/latest/heuropt/algorithms/tlbo/struct.Tlbo.html
|
[`Tlbo`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tlbo/struct.Tlbo.html
|
||||||
[OnePlusOneEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
|
[`OnePlusOneEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
|
||||||
[RandomSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
||||||
[HillClimber]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
|
[`HillClimber`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
|
||||||
[BayesianOpt]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
|
[`BayesianOpt`]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
|
||||||
[TPE]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
|
[`Tpe`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
|
||||||
[Hyperband]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
|
[`Hyperband`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
|
||||||
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
|
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
|
||||||
[Umda]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
|
[`Umda`]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
|
||||||
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
||||||
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
|
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
|
||||||
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
||||||
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
||||||
[`InversionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InversionMutation.html
|
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
||||||
[`InsertionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InsertionMutation.html
|
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
||||||
[`ScrambleMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ScrambleMutation.html
|
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
||||||
[`OrderCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.OrderCrossover.html
|
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
||||||
[`PartiallyMappedCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.PartiallyMappedCrossover.html
|
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
||||||
[`CycleCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CycleCrossover.html
|
[`Spea2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
|
||||||
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
|
[`SmsEmoa`]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
|
||||||
[`ShuffledPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledPermutation.html
|
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
|
||||||
[`ShuffledMultisetPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledMultisetPermutation.html
|
[`AgeMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
|
||||||
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
[`Knea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
|
||||||
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
[`PesaII`]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
|
||||||
[Nsga3]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
[`EpsilonMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
|
||||||
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
[`Paes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
|
||||||
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
[`Grea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
|
||||||
[Spea2]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
|
[`Rvea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
|
||||||
[SmsEmoa]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
|
[`HypE`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
|
||||||
[Moead]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
|
|
||||||
[AgeMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
|
|
||||||
[Knea]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
|
|
||||||
[PesaII]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
|
|
||||||
[EpsilonMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
|
|
||||||
[Paes]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
|
|
||||||
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
|
|
||||||
[Rvea]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
|
|
||||||
[Hype]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
|
|
||||||
[`Repair<D>`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Repair.html
|
[`Repair<D>`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Repair.html
|
||||||
[`ClampToBounds`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ClampToBounds.html
|
[`ClampToBounds`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ClampToBounds.html
|
||||||
[`ProjectToSimplex`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ProjectToSimplex.html
|
[`ProjectToSimplex`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ProjectToSimplex.html
|
||||||
|
|||||||
+11
-12
@@ -15,11 +15,11 @@ The columns:
|
|||||||
|
|
||||||
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|
| Library | Lang | Algorithms | Multi-obj | Surrogates | Determinism | Async |
|
||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| **heuropt 0.10** | Rust | 33 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | ✅ `AsyncProblem` + `run_async` on every algorithm |
|
| **heuropt 0.5** | Rust | 35 | ✅ NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA, GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES | ✅ BO, TPE, Hyperband | ✅ bit-identical seeded | ⏳ planned |
|
||||||
| pymoo | Python | ~25 | ✅ extensive | partial (BO via plug-ins) | ✅ | ❌ |
|
| pymoo | Python | ~25 | ✅ extensive | partial (BO via plug-ins) | ✅ | ❌ |
|
||||||
| DEAP | Python | flexible toolbox | ✅ | ❌ | ✅ | ❌ |
|
| DEAP | Python | flexible toolbox | ✅ | ❌ | ✅ | ❌ |
|
||||||
| hyperopt | Python | TPE-focused | ❌ | ✅ TPE | partial | partial |
|
| hyperopt | Python | TPE-focused | ❌ | ✅ TPE | partial | partial |
|
||||||
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | partial (study-level, not eval-level) |
|
| optuna | Python | TPE / CMA-ES / NSGA-II | ✅ | ✅ TPE, BoTorch via plug-in | ✅ | ✅ |
|
||||||
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
|
| MOEA Framework | Java | ~40 | ✅ very extensive | ❌ | ✅ | ❌ |
|
||||||
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
|
| metaheuristics-rs | Rust | ~10 | partial | ❌ | ✅ | ❌ |
|
||||||
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
|
| argmin | Rust | line-search / quasi-Newton | ❌ | ❌ | ✅ | ❌ |
|
||||||
@@ -36,15 +36,14 @@ The columns:
|
|||||||
otherwise.
|
otherwise.
|
||||||
- You want a **small, readable codebase** — every algorithm is
|
- You want a **small, readable codebase** — every algorithm is
|
||||||
written for clarity, no trait-object plumbing, no GATs in user-
|
written for clarity, no trait-object plumbing, no GATs in user-
|
||||||
facing APIs. Reading Random Search should be enough to write a
|
facing APIs. Reading `RandomSearch` should be enough to write a
|
||||||
new optimizer.
|
new optimizer.
|
||||||
- You have **IO-bound evaluations** — calling an HTTP service, an
|
|
||||||
RPC, or a subprocess — and want first-class `async fn evaluate`
|
|
||||||
support. heuropt is the only mainstream optimization library that
|
|
||||||
ships this (see [Async evaluation](./cookbook/async.md)).
|
|
||||||
|
|
||||||
## When *not* to pick heuropt
|
## When *not* to pick heuropt
|
||||||
|
|
||||||
|
- You need **first-class async / await** for evaluations that talk to
|
||||||
|
HTTP services or spawn subprocesses. heuropt is sync; that's on
|
||||||
|
the roadmap but not shipping yet.
|
||||||
- You need **gradient-based** optimization. Use `argmin` (Rust) or
|
- You need **gradient-based** optimization. Use `argmin` (Rust) or
|
||||||
`scipy.optimize` (Python) — heuropt is gradient-free by design.
|
`scipy.optimize` (Python) — heuropt is gradient-free by design.
|
||||||
- You need **GPU-accelerated** evaluations. heuropt's `evaluate`
|
- You need **GPU-accelerated** evaluations. heuropt's `evaluate`
|
||||||
@@ -64,12 +63,12 @@ heuropt covers the same major Pareto MOEAs as pymoo and MOEA Framework:
|
|||||||
NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA,
|
NSGA-II/III, SPEA2, IBEA, MOEA/D, MOPSO, SMS-EMOA, HypE, AGE-MOEA,
|
||||||
GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES.
|
GrEA, KnEA, RVEA, PESA-II, ε-MOEA, PAES.
|
||||||
|
|
||||||
The expensive-evaluation regime: Bayesian Optimization + TPE + Hyperband. This
|
The expensive-evaluation regime: BayesianOpt + TPE + Hyperband. This
|
||||||
is comparable to optuna's coverage but in pure Rust.
|
is comparable to optuna's coverage but in pure Rust.
|
||||||
|
|
||||||
The single-objective continuous catalog (CMA-ES, IPOP-CMA-ES, sNES,
|
The single-objective continuous catalog (CMA-ES, IPOP-CMA-ES, sNES,
|
||||||
DE, PSO, GA, TLBO, (1+1)-ES, Nelder-Mead, Random Search, Hill Climber,
|
DE, PSO, GA, TLBO, (1+1)-ES, NelderMead, RandomSearch, HillClimber,
|
||||||
Simulated Annealing) covers the canonical baselines and several modern
|
SimulatedAnnealing) covers the canonical baselines and several modern
|
||||||
variants.
|
variants.
|
||||||
|
|
||||||
What heuropt does **not** ship that some libraries do:
|
What heuropt does **not** ship that some libraries do:
|
||||||
@@ -89,7 +88,7 @@ are heavily optimized — see the perf entry in the v0.4.0 CHANGELOG.
|
|||||||
On the comparison harness in `examples/compare.rs` (10-seed mean,
|
On the comparison harness in `examples/compare.rs` (10-seed mean,
|
||||||
30 000 evaluations on DTLZ2), the total wall-clock time across 12
|
30 000 evaluations on DTLZ2), the total wall-clock time across 12
|
||||||
algorithms is ~5 seconds. Per-algorithm timings are in
|
algorithms is ~5 seconds. Per-algorithm timings are in
|
||||||
[`examples/compare-results.md`](https://git.swaits.com/swaits/heuropt/src/branch/main/examples/compare-results.md).
|
[`examples/compare-results.md`](https://github.com/swaits/heuropt/blob/main/examples/compare-results.md).
|
||||||
|
|
||||||
For comparison-shopping speed against Python libraries, the gap is
|
For comparison-shopping speed against Python libraries, the gap is
|
||||||
typically 10×–100× in heuropt's favor for compute-bound
|
typically 10×–100× in heuropt's favor for compute-bound
|
||||||
@@ -106,4 +105,4 @@ Hyperband+BO hybrids, distributed runners). heuropt is younger; the
|
|||||||
core is solid but the ecosystem is small.
|
core is solid but the ecosystem is small.
|
||||||
|
|
||||||
If you adopt heuropt and miss a thing, the project is small enough
|
If you adopt heuropt and miss a thing, the project is small enough
|
||||||
that contributions land fast. See [CONTRIBUTING.md](https://git.swaits.com/swaits/heuropt/src/branch/main/CONTRIBUTING.md).
|
that contributions land fast. See [CONTRIBUTING.md](https://github.com/swaits/heuropt/blob/main/CONTRIBUTING.md).
|
||||||
|
|||||||
@@ -7,32 +7,20 @@ project.
|
|||||||
## Recipes
|
## Recipes
|
||||||
|
|
||||||
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
|
- [Parallelize evaluation with rayon](./cookbook/parallel.md) — when
|
||||||
your `evaluate` is non-trivial CPU work, the `parallel` feature
|
your `evaluate` is non-trivial, the `parallel` feature pays for
|
||||||
pays for itself almost immediately.
|
itself almost immediately.
|
||||||
- [Async evaluation](./cookbook/async.md) — when your `evaluate` is
|
|
||||||
IO-bound (HTTP / RPC / subprocess), the `async` feature lets the
|
|
||||||
optimizer await many evaluations concurrently. The differentiating
|
|
||||||
feature vs other optimization libraries.
|
|
||||||
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
- [Tune a model with expensive evaluations](./cookbook/expensive-evaluations.md)
|
||||||
— Bayesian Optimization, TPE, and Hyperband for the 50–500-eval
|
— `BayesianOpt`, `Tpe`, and `Hyperband` for the 50–500-eval
|
||||||
regime.
|
regime.
|
||||||
- [Compare two algorithms on your problem](./cookbook/compare.md) —
|
- [Compare two algorithms on your problem](./cookbook/compare.md) —
|
||||||
multi-seed harness pattern straight from `examples/compare.rs`.
|
multi-seed harness pattern straight from `examples/compare.rs`.
|
||||||
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md) —
|
- [Optimize a permutation (TSP-style)](./cookbook/permutation.md) —
|
||||||
the permutation operator toolkit (OX / PMX / CX / ERX + Inversion /
|
`AntColonyTsp` with a distance matrix.
|
||||||
Insertion / Scramble), plus Ant Colony for distance-matrix TSP.
|
|
||||||
- [Multi-objective combinatorial problems](./cookbook/multi-objective-combinatorial.md)
|
|
||||||
— bi-objective TSP, bi-objective knapsack (`Vec<bool>`), and
|
|
||||||
3-objective JSS via NSGA-II / NSGA-III.
|
|
||||||
- [Constrain your search with `Repair`](./cookbook/constraints.md) —
|
- [Constrain your search with `Repair`](./cookbook/constraints.md) —
|
||||||
bounds, simplex projection, custom repair.
|
bounds, simplex projection, custom repair.
|
||||||
- [Pick one answer off a Pareto front](./cookbook/pick-one.md) — the
|
- [Pick one answer off a Pareto front](./cookbook/pick-one.md) — the
|
||||||
a-posteriori weighted-decision pattern from the `jiggly_tuning`
|
a-posteriori weighted-decision pattern from the `jiggly_tuning`
|
||||||
example.
|
example.
|
||||||
- [Explore your results in a webapp](./cookbook/explorer.md) — export
|
|
||||||
an `OptimizationResult` to JSON and browse it interactively at
|
|
||||||
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/) —
|
|
||||||
parallel coordinates, scatter, range filters, weighted ranking.
|
|
||||||
- [Write your own algorithm](./cookbook/custom-optimizer.md) —
|
- [Write your own algorithm](./cookbook/custom-optimizer.md) —
|
||||||
implement `Optimizer<P>` from scratch, à la the
|
implement `Optimizer<P>` from scratch, à la the
|
||||||
`examples/custom_optimizer.rs` walkthrough.
|
`examples/custom_optimizer.rs` walkthrough.
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
# Async evaluation
|
|
||||||
|
|
||||||
When your `evaluate` does **IO** — calls an HTTP service, sends an
|
|
||||||
RPC, spawns a subprocess — `await`-ing it from the optimizer is
|
|
||||||
much more efficient than blocking a thread per evaluation. heuropt
|
|
||||||
ships first-class async support behind the `async` feature flag.
|
|
||||||
|
|
||||||
This is the differentiating capability vs pymoo / hyperopt /
|
|
||||||
optuna / DEAP / MOEA Framework — none of those have a native async
|
|
||||||
evaluation path.
|
|
||||||
|
|
||||||
## Enable the feature
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
heuropt = { version = "0.10", features = ["async"] }
|
|
||||||
|
|
||||||
# Pick whatever async runtime you want; heuropt itself depends only on
|
|
||||||
# `futures`. The example below uses tokio.
|
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implement `AsyncProblem`
|
|
||||||
|
|
||||||
It mirrors the regular [`Problem`] trait one-for-one — same
|
|
||||||
`Decision` type, same `objectives()`, but `evaluate` is replaced
|
|
||||||
with `evaluate_async` returning a future.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::core::async_problem::AsyncProblem;
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct RemoteService;
|
|
||||||
|
|
||||||
impl AsyncProblem for RemoteService {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
// Real workload: HTTP call to a model-scoring service, an RPC,
|
|
||||||
// a subprocess. Here we just sleep to model 20 ms latency.
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
||||||
let loss: f64 = x.iter().map(|v| v * v).sum();
|
|
||||||
Evaluation::new(vec![loss])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Run the optimizer with `run_async`
|
|
||||||
|
|
||||||
`run_async(&problem, concurrency).await` is provided by **every**
|
|
||||||
algorithm in the catalog as of v0.8. `concurrency` caps how many
|
|
||||||
evaluations are in-flight at once.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
# use heuropt::core::async_problem::AsyncProblem;
|
|
||||||
# use heuropt::prelude::*;
|
|
||||||
# struct RemoteService;
|
|
||||||
# impl AsyncProblem for RemoteService {
|
|
||||||
# type Decision = Vec<f64>;
|
|
||||||
# fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
# ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
|
||||||
# }
|
|
||||||
# async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
|
|
||||||
# }
|
|
||||||
# }
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
|
|
||||||
let mut opt = DifferentialEvolution::new(
|
|
||||||
DifferentialEvolutionConfig {
|
|
||||||
population_size: 16,
|
|
||||||
generations: 50,
|
|
||||||
differential_weight: 0.5,
|
|
||||||
crossover_probability: 0.9,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds),
|
|
||||||
);
|
|
||||||
let r = opt.run_async(&RemoteService, /* concurrency */ 8).await;
|
|
||||||
println!("best: {}", r.best.unwrap().evaluation.objectives[0]);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Picking `concurrency`
|
|
||||||
|
|
||||||
Concurrency is the maximum in-flight evaluation count. Tradeoffs:
|
|
||||||
|
|
||||||
| Setting | Effect |
|
|
||||||
|---|---|
|
|
||||||
| `1` | Sequential; equivalent to a sync run with extra overhead |
|
|
||||||
| `pop_size` | Full per-generation parallelism; fastest if your service tolerates it |
|
|
||||||
| `< pop_size` | Bounded — useful if your downstream service has a rate limit or finite worker pool |
|
|
||||||
|
|
||||||
The bigger you go, the more memory the in-flight futures hold and
|
|
||||||
the more load you put on the downstream service. A reasonable
|
|
||||||
starting point is `min(pop_size, 16)` and increase only if the
|
|
||||||
downstream service is comfortable.
|
|
||||||
|
|
||||||
## Determinism
|
|
||||||
|
|
||||||
Same seed produces the same final result whether you use `run` or
|
|
||||||
`run_async`, **provided your async `evaluate_async` is itself
|
|
||||||
deterministic**. heuropt drives the RNG and selection on the main
|
|
||||||
task; only the evaluations are concurrent, and the
|
|
||||||
`evaluate_batch_async` helper preserves input order before feeding
|
|
||||||
results back to the algorithm.
|
|
||||||
|
|
||||||
## What the worked example shows
|
|
||||||
|
|
||||||
`examples/async_eval.rs` runs Random Search (200 evaluations × 20 ms
|
|
||||||
each) at `concurrency = 1, 4, 16` and Differential Evolution at
|
|
||||||
`concurrency = 8`. On a recent machine:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RandomSearch with 200 evaluations (20 ms each)
|
|
||||||
|
|
||||||
concurrency = 1 elapsed ≈ 4250 ms (sequential 200 × 20 ms)
|
|
||||||
concurrency = 4 elapsed ≈ 2100 ms (2× speedup, batch_size=2 caps it)
|
|
||||||
concurrency = 16 elapsed ≈ 2100 ms (same — batch_size dominates)
|
|
||||||
|
|
||||||
DifferentialEvolution at concurrency=8
|
|
||||||
elapsed ≈ 230 ms (8 ants run in parallel each generation)
|
|
||||||
```
|
|
||||||
|
|
||||||
Run it yourself: `cargo run --release --features async --example async_eval`.
|
|
||||||
|
|
||||||
## Which algorithms support `run_async`?
|
|
||||||
|
|
||||||
**All 33** algorithms in the catalog. The shape of the async path
|
|
||||||
depends on the algorithm:
|
|
||||||
|
|
||||||
- **Population-based / batch-evaluating** — NSGA-II, NSGA-III, SPEA2,
|
|
||||||
MOEA/D, IBEA, SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA,
|
|
||||||
GrEA, RVEA, MOPSO, GA, DE, PSO, CMA-ES, IPOP-CMA-ES, sNES, TLBO,
|
|
||||||
UMDA, Ant Colony, Random Search. Each generation's offspring
|
|
||||||
evaluations are fanned out concurrently up to `concurrency`.
|
|
||||||
- **Steady-state (one-eval-per-step)** — Hill Climber, Simulated
|
|
||||||
Annealing, (1+1)-ES, PAES, Nelder-Mead. The `concurrency`
|
|
||||||
parameter is accepted for API uniformity but evaluation order is
|
|
||||||
inherently sequential.
|
|
||||||
- **Tabu Search** — fans out the K-neighbor batch each step.
|
|
||||||
- **Surrogate (BO, TPE)** — fans out the initial design batch, then
|
|
||||||
awaits per-iteration acquisitions sequentially (the surrogate
|
|
||||||
must update before the next point is chosen).
|
|
||||||
- **Hyperband** — uses the separate
|
|
||||||
[`AsyncPartialProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncPartialProblem.html)
|
|
||||||
trait (multi-fidelity); each Successive-Halving rung's evaluations
|
|
||||||
fan out concurrently.
|
|
||||||
|
|
||||||
## Async vs `parallel`
|
|
||||||
|
|
||||||
| If your `evaluate` is… | Use |
|
|
||||||
|---|---|
|
|
||||||
| CPU-bound (math, simulation) | `parallel` feature → see [Parallelize evaluation](./parallel.md) |
|
|
||||||
| IO-bound (HTTP, RPC, subprocess) | `async` feature (this recipe) |
|
|
||||||
|
|
||||||
Both can be on at once if your evaluation does *both* substantial
|
|
||||||
CPU work *and* IO. The two features are independent.
|
|
||||||
|
|
||||||
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
|
|
||||||
@@ -134,11 +134,8 @@ parallel.
|
|||||||
result.
|
result.
|
||||||
- **No error type.** Invalid configuration panics with a clear
|
- **No error type.** Invalid configuration panics with a clear
|
||||||
message; this matches the style of the built-in algorithms.
|
message; this matches the style of the built-in algorithms.
|
||||||
- **No async on the trait.** `Optimizer<P>` is synchronous. For
|
- **No async.** `evaluate` is synchronous; for async work, drive it
|
||||||
async evaluation, implement [`AsyncProblem`](https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html)
|
on a tokio runtime around the optimizer loop yourself.
|
||||||
on your problem and use the `run_async(&problem, concurrency)`
|
|
||||||
method that comes with the `async` feature. See the
|
|
||||||
[Async evaluation cookbook recipe](./async.md).
|
|
||||||
|
|
||||||
The smallness is the point: you should be able to read a built-in
|
The smallness is the point: you should be able to read a built-in
|
||||||
algorithm and write your own in an afternoon. See
|
algorithm and write your own in an afternoon. See
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ algorithms aimed at this regime.
|
|||||||
|
|
||||||
| Algorithm | Surrogate | Best for |
|
| Algorithm | Surrogate | Best for |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [Bayesian Optimization][BayesianOpt] | Gaussian process + Expected Improvement | The textbook choice; needs kernel tuning to shine |
|
| [`BayesianOpt`] | Gaussian process + Expected Improvement | The textbook choice; needs kernel tuning to shine |
|
||||||
| [TPE] | Kernel-density estimate of good vs bad points | Cheaper per step; more robust without tuning |
|
| [`Tpe`] | Kernel-density estimate of good vs bad points | Cheaper per step; more robust without tuning |
|
||||||
| [Hyperband] | (none — it's a multi-fidelity scheduler) | When each eval has a tunable budget (epochs, MC samples) |
|
| [`Hyperband`] | (none — it's a multi-fidelity scheduler) | When each eval has a tunable budget (epochs, MC samples) |
|
||||||
|
|
||||||
## When each is right
|
## When each is right
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ canonical Bergstra value.
|
|||||||
|
|
||||||
## Hyperband
|
## Hyperband
|
||||||
|
|
||||||
[Hyperband] needs your problem to implement [`PartialProblem`] —
|
[`Hyperband`] needs your problem to implement [`PartialProblem`] —
|
||||||
that is, you can evaluate at a tunable fidelity (e.g. number of
|
that is, you can evaluate at a tunable fidelity (e.g. number of
|
||||||
training epochs). The algorithm schedules many cheap-fidelity runs
|
training epochs). The algorithm schedules many cheap-fidelity runs
|
||||||
and promotes only the survivors to higher fidelity.
|
and promotes only the survivors to higher fidelity.
|
||||||
@@ -156,9 +156,9 @@ The state of the art (BOHB) combines BO with Hyperband: TPE picks the
|
|||||||
configurations Hyperband then evaluates at increasing fidelity.
|
configurations Hyperband then evaluates at increasing fidelity.
|
||||||
heuropt doesn't ship a unified BOHB but the building blocks are
|
heuropt doesn't ship a unified BOHB but the building blocks are
|
||||||
there — wrap your `PartialProblem` with a TPE-driven sampler and
|
there — wrap your `PartialProblem` with a TPE-driven sampler and
|
||||||
feed the picks into Hyperband. PRs welcome.
|
feed the picks into `Hyperband`. PRs welcome.
|
||||||
|
|
||||||
[BayesianOpt]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
|
[`BayesianOpt`]: https://docs.rs/heuropt/latest/heuropt/algorithms/bayesian_opt/struct.BayesianOpt.html
|
||||||
[TPE]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
|
[`Tpe`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tpe/struct.Tpe.html
|
||||||
[Hyperband]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
|
[`Hyperband`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hyperband/struct.Hyperband.html
|
||||||
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
|
[`PartialProblem`]: https://docs.rs/heuropt/latest/heuropt/core/partial_problem/trait.PartialProblem.html
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
# Explore your results in a webapp
|
|
||||||
|
|
||||||
Real Pareto fronts have 50–200+ candidates spanning 2–7+ objectives.
|
|
||||||
Reading them as a wall of numbers in a terminal scales badly. Drop
|
|
||||||
the result into [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
to filter, brush, pin, and rank candidates interactively in the
|
|
||||||
browser — parallel coordinates, scatter plots, sortable table, range
|
|
||||||
filters, weighted ranking, knee-point detection.
|
|
||||||
|
|
||||||
This recipe shows the export side. The webapp is a static page; no
|
|
||||||
install needed beyond a browser.
|
|
||||||
|
|
||||||
## Enable the `serde` feature
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[dependencies]
|
|
||||||
heuropt = { version = "0.10", features = ["serde"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
The export uses `serde_json` under the hood, so the explorer module
|
|
||||||
is gated on the existing `serde` feature.
|
|
||||||
|
|
||||||
## Enrich your `Problem` (optional but worth it)
|
|
||||||
|
|
||||||
Two places to add display metadata that flows through to the
|
|
||||||
explorer's axis labels and tooltips:
|
|
||||||
|
|
||||||
```rust
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct PickACar;
|
|
||||||
|
|
||||||
impl Problem for PickACar {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
// `name` is the canonical short ID; `label` and `unit`
|
|
||||||
// are display-only. The explorer renders axes as
|
|
||||||
// `Price ($k)` instead of just `price`.
|
|
||||||
Objective::minimize("price").with_label("Price").with_unit("$k"),
|
|
||||||
Objective::minimize("zero_to_sixty").with_label("0-60 mph").with_unit("s"),
|
|
||||||
Objective::minimize("fuel").with_label("Fuel").with_unit("gal/100mi"),
|
|
||||||
Objective::minimize("noise").with_label("Idle noise").with_unit("dB"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
// Optional: provide name/label/unit/bounds per decision-variable
|
|
||||||
// slot. If you skip this, the exporter falls back to `x[0]`,
|
|
||||||
// `x[1]`, … with no units or bounds.
|
|
||||||
vec![
|
|
||||||
DecisionVariable::new("displacement")
|
|
||||||
.with_label("Engine size").with_unit("L").with_bounds(1.0, 6.0),
|
|
||||||
DecisionVariable::new("weight")
|
|
||||||
.with_label("Curb weight").with_unit("kg").with_bounds(1100.0, 2200.0),
|
|
||||||
DecisionVariable::new("drag")
|
|
||||||
.with_label("Drag coefficient").with_unit("Cd").with_bounds(0.20, 0.40),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
// ... compute objectives ...
|
|
||||||
# Evaluation::new(vec![0.0, 0.0, 0.0, 0.0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Both `Objective::with_label` / `with_unit` and `Problem::decision_schema`
|
|
||||||
are entirely optional — the rest of heuropt doesn't read them. They
|
|
||||||
exist so the exported JSON describes itself well enough for a
|
|
||||||
display tool to render readable axes.
|
|
||||||
|
|
||||||
## Run the optimizer and write the JSON
|
|
||||||
|
|
||||||
The simplest call (no algorithm metadata in the export):
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
heuropt::explorer::ExplorerExport::from_result(&problem, &result)
|
|
||||||
.to_file("results.json")
|
|
||||||
.unwrap();
|
|
||||||
```
|
|
||||||
|
|
||||||
The richer call — pulls algorithm name + seed automatically from
|
|
||||||
the `AlgorithmInfo` trait that every built-in algorithm implements:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
let export = heuropt::explorer::ExplorerExport::from_result(&problem, &result)
|
|
||||||
.with_algorithm_info(&optimizer)
|
|
||||||
.with_problem_name("Pick a car")
|
|
||||||
.with_wall_clock(started.elapsed().as_secs_f64());
|
|
||||||
export.to_file("results.json").unwrap();
|
|
||||||
```
|
|
||||||
|
|
||||||
There's also a one-liner if you don't need to set extra metadata:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
heuropt::explorer::to_file("results.json", &problem, &optimizer, &result).unwrap();
|
|
||||||
```
|
|
||||||
|
|
||||||
## Open it in the explorer
|
|
||||||
|
|
||||||
Visit <https://swaits.github.io/heuropt-explorer/> and drag the JSON
|
|
||||||
file onto the page. The explorer reads the units and labels you
|
|
||||||
attached and renders parallel-coordinates / scatter / table views
|
|
||||||
that respect them. Brushing on any axis filters the others; pinned
|
|
||||||
candidates stay highlighted; the weight sliders let you rank the
|
|
||||||
front by your priorities.
|
|
||||||
|
|
||||||
## What's in the file
|
|
||||||
|
|
||||||
The full schema is documented in
|
|
||||||
[`heuropt::explorer::ExplorerExport`](https://docs.rs/heuropt/latest/heuropt/explorer/struct.ExplorerExport.html).
|
|
||||||
The shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"run": {
|
|
||||||
"problem_name": "Pick a car",
|
|
||||||
"algorithm": "Nsga3",
|
|
||||||
"seed": 42,
|
|
||||||
"wall_clock_seconds": 0.097,
|
|
||||||
"evaluations": 20100,
|
|
||||||
"generations": 200
|
|
||||||
},
|
|
||||||
"objectives": [
|
|
||||||
{ "name": "price", "direction": "Minimize", "label": "Price", "unit": "$k" },
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"decision_variables": [
|
|
||||||
{ "name": "displacement", "label": "Engine size", "unit": "L", "min": 1.0, "max": 6.0 },
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"decision": [1.0, 1505.0, 0.35],
|
|
||||||
"objectives": [13.0, 7.0, 3.17, 63.0],
|
|
||||||
"constraint_violation": 0.0,
|
|
||||||
"feasible": true,
|
|
||||||
"front_rank": 0,
|
|
||||||
"in_pareto_front": true
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`front_rank` is computed by `non_dominated_sort` once at export
|
|
||||||
time — `0` means on the Pareto front, higher numbers indicate
|
|
||||||
deeper layers.
|
|
||||||
|
|
||||||
## Custom decision types
|
|
||||||
|
|
||||||
Out of the box, `Vec<f64>`, `Vec<bool>`, `Vec<usize>`, and `Vec<i64>`
|
|
||||||
work as decisions. For a custom decision type, implement
|
|
||||||
`heuropt::explorer::ToDecisionValues`:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
struct MyDecision { color: String, count: u32 }
|
|
||||||
|
|
||||||
impl heuropt::explorer::ToDecisionValues for MyDecision {
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value> {
|
|
||||||
vec![
|
|
||||||
serde_json::Value::String(self.color.clone()),
|
|
||||||
serde_json::Value::Number(self.count.into()),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The explorer renders strings as categorical axes and numbers as
|
|
||||||
continuous.
|
|
||||||
|
|
||||||
## Worked example
|
|
||||||
|
|
||||||
`examples/pick_a_car.rs` ships with the crate. It implements the
|
|
||||||
problem above, runs NSGA-III for 200 generations, and writes
|
|
||||||
`pick_a_car.json` ready to load:
|
|
||||||
|
|
||||||
```text
|
|
||||||
cargo run --release --example pick_a_car --features serde
|
|
||||||
```
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
# Multi-objective combinatorial problems
|
|
||||||
|
|
||||||
Real combinatorial problems usually have more than one cost. A TSP
|
|
||||||
where every edge has both *distance* and *time*; a job-shop where you
|
|
||||||
care about *makespan*, *flow time*, *and* *tardiness*; a knapsack
|
|
||||||
with two profit metrics and a single weight budget. The decision
|
|
||||||
type is still combinatorial — a permutation, a bitstring — but the
|
|
||||||
objective is a vector, and the answer is a Pareto front rather than
|
|
||||||
a single best.
|
|
||||||
|
|
||||||
heuropt's NSGA-II and NSGA-III are fully generic over the decision
|
|
||||||
type. You don't need a separate "combinatorial NSGA" — just plug in
|
|
||||||
the right initializer and variation operators for your encoding.
|
|
||||||
|
|
||||||
This recipe walks through three patterns:
|
|
||||||
|
|
||||||
- **Bi-objective TSP** with NSGA-II (Pareto front of two distance
|
|
||||||
matrices over the same cities)
|
|
||||||
- **Bi-objective 0/1 knapsack** with NSGA-II (binary encoding)
|
|
||||||
- **3-objective JSS** with NSGA-III (the many-objective successor)
|
|
||||||
|
|
||||||
For the single-objective permutation toolkit it builds on, see
|
|
||||||
[Optimize a permutation](./permutation.md).
|
|
||||||
|
|
||||||
## Bi-objective TSP
|
|
||||||
|
|
||||||
This is the canonical multi-objective combinatorial benchmark
|
|
||||||
(Lust–Teghem 2010). Two TSP instances on the **same** city set define
|
|
||||||
two distance matrices A and B; the search trades off length under A
|
|
||||||
versus length under B.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
|
|
||||||
struct BiObjectiveTsp {
|
|
||||||
dist_a: Vec<Vec<f64>>,
|
|
||||||
dist_b: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BiObjectiveTsp {
|
|
||||||
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut total = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
total += d[tour[i]][tour[(i + 1) % n]];
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for BiObjectiveTsp {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("length_A"),
|
|
||||||
Objective::minimize("length_B"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![
|
|
||||||
Self::tour_length(&self.dist_a, tour),
|
|
||||||
Self::tour_length(&self.dist_b, tour),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let n: usize = 25;
|
|
||||||
let dist_a = vec![vec![0.0_f64; n]; n]; // your matrix A
|
|
||||||
let dist_b = vec![vec![0.0_f64; n]; n]; // your matrix B
|
|
||||||
let problem = BiObjectiveTsp { dist_a, dist_b };
|
|
||||||
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 200,
|
|
||||||
generations: 600,
|
|
||||||
seed: 11,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: EdgeRecombinationCrossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
|
|
||||||
// Hypervolume against a generous reference point (larger than any
|
|
||||||
// length you'd reasonably see). Use this as the single-number
|
|
||||||
// quality metric for the run.
|
|
||||||
let ref_point = [40_000.0, 40_000.0];
|
|
||||||
let hv = hypervolume_2d(&result.pareto_front, &problem.objectives(), ref_point);
|
|
||||||
println!("Hypervolume vs. {:?}: {:.0}", ref_point, hv);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
[`EdgeRecombinationCrossover`] (ERX) is the standout crossover for
|
|
||||||
TSP. On a 25-city bi-objective instance it produces about twice the
|
|
||||||
front diversity of OX, PMX, or CX — see
|
|
||||||
`examples/tsp_operators_compare.rs` for a head-to-head benchmark.
|
|
||||||
|
|
||||||
## Bi-objective 0/1 knapsack — `Vec<bool>` decisions
|
|
||||||
|
|
||||||
NSGA-II works over `Vec<bool>` the same way. The Zitzler–Thiele
|
|
||||||
bi-objective knapsack is the textbook benchmark: each item has two
|
|
||||||
profit values and a single weight; you maximize both profits under
|
|
||||||
one capacity constraint.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
const N_ITEMS: usize = 30;
|
|
||||||
|
|
||||||
struct BiKnapsack {
|
|
||||||
profits_a: Vec<f64>,
|
|
||||||
profits_b: Vec<f64>,
|
|
||||||
weights: Vec<f64>,
|
|
||||||
capacity: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for BiKnapsack {
|
|
||||||
type Decision = Vec<bool>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::maximize("profit_A"),
|
|
||||||
Objective::maximize("profit_B"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
|
|
||||||
let (pa, pb, w) = take.iter().enumerate().fold(
|
|
||||||
(0.0_f64, 0.0_f64, 0.0_f64),
|
|
||||||
|(pa, pb, w), (i, &t)| {
|
|
||||||
if t {
|
|
||||||
(pa + self.profits_a[i], pb + self.profits_b[i], w + self.weights[i])
|
|
||||||
} else {
|
|
||||||
(pa, pb, w)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Standard heuristic-MO constraint handling: penalize weight
|
|
||||||
// overruns heavily so the recovered front is feasible.
|
|
||||||
let penalty = 1000.0 * (w - self.capacity).max(0.0);
|
|
||||||
Evaluation::new(vec![pa - penalty, pb - penalty])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each bit 50/50 independently.
|
|
||||||
#[derive(Clone, Copy)]
|
|
||||||
struct RandomBinary { n: usize }
|
|
||||||
impl Initializer<Vec<bool>> for RandomBinary {
|
|
||||||
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
(0..size).map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect()).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-point crossover for binary chromosomes.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct OnePointCrossoverBool;
|
|
||||||
impl Variation<Vec<bool>> for OnePointCrossoverBool {
|
|
||||||
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
let (p1, p2) = (&parents[0], &parents[1]);
|
|
||||||
let n = p1.len();
|
|
||||||
let cut = rng.random_range(1..n);
|
|
||||||
let mut c1 = Vec::with_capacity(n);
|
|
||||||
let mut c2 = Vec::with_capacity(n);
|
|
||||||
c1.extend_from_slice(&p1[..cut]); c1.extend_from_slice(&p2[cut..]);
|
|
||||||
c2.extend_from_slice(&p2[..cut]); c2.extend_from_slice(&p1[cut..]);
|
|
||||||
vec![c1, c2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
# let profits_a = vec![0.0; N_ITEMS];
|
|
||||||
# let profits_b = vec![0.0; N_ITEMS];
|
|
||||||
# let weights = vec![0.0; N_ITEMS];
|
|
||||||
let problem = BiKnapsack {
|
|
||||||
profits_a, profits_b, weights,
|
|
||||||
capacity: 750.0, // ~half the total weight
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 120,
|
|
||||||
generations: 400,
|
|
||||||
seed: 19,
|
|
||||||
},
|
|
||||||
RandomBinary { n: N_ITEMS },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OnePointCrossoverBool,
|
|
||||||
mutation: BitFlipMutation { probability: 1.0 / N_ITEMS as f64 },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Two things worth noting:
|
|
||||||
|
|
||||||
- **`OnePointCrossoverBool` and `RandomBinary` are defined locally.**
|
|
||||||
They're tiny and common — a future PR could lift them into the
|
|
||||||
library, but for now you write them inline.
|
|
||||||
- **Constraint handling is a penalty.** The factor `1000.0` is chosen
|
|
||||||
so that even a 1-unit overrun beats any feasible solution by more
|
|
||||||
than the entire profit range; the recovered front is entirely
|
|
||||||
feasible. This is the standard heuristic-MO pattern (Deb 2001) and
|
|
||||||
cheaper than a hard repair operator.
|
|
||||||
|
|
||||||
## Three-objective JSS with NSGA-III
|
|
||||||
|
|
||||||
NSGA-III is designed for ≥ 3 objectives. NSGA-II's crowding distance
|
|
||||||
degrades when most of the population is mutually non-dominated, which
|
|
||||||
is the rule rather than the exception in higher dimensions; NSGA-III
|
|
||||||
uses reference-point niching instead.
|
|
||||||
|
|
||||||
The example below adds *tardiness* to the standard (makespan, flow
|
|
||||||
time) JSS pair. Tardiness needs due dates; the common heuristic is
|
|
||||||
`dⱼ = 1.3 × sum_of_processing_times(j)`.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
const N_JOBS: usize = 10;
|
|
||||||
const N_MACHINES: usize = 5;
|
|
||||||
|
|
||||||
struct La01ThreeObjective {
|
|
||||||
routing: [[usize; N_MACHINES]; N_JOBS],
|
|
||||||
times: [[f64; N_MACHINES]; N_JOBS],
|
|
||||||
due: [f64; N_JOBS],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for La01ThreeObjective {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("makespan"),
|
|
||||||
Objective::minimize("total_flow_time"),
|
|
||||||
Objective::minimize("total_tardiness"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
|
||||||
let mut job_next = [0_usize; N_JOBS];
|
|
||||||
let mut job_clock = [0.0_f64; N_JOBS];
|
|
||||||
let mut machine_clock = [0.0_f64; N_MACHINES];
|
|
||||||
for &job in schedule {
|
|
||||||
let k = job_next[job];
|
|
||||||
let m = self.routing[job][k];
|
|
||||||
let t = self.times[job][k];
|
|
||||||
let start = job_clock[job].max(machine_clock[m]);
|
|
||||||
let end = start + t;
|
|
||||||
job_clock[job] = end;
|
|
||||||
machine_clock[m] = end;
|
|
||||||
job_next[job] = k + 1;
|
|
||||||
}
|
|
||||||
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
|
|
||||||
let flow_time: f64 = job_clock.iter().sum();
|
|
||||||
let tardiness: f64 = job_clock.iter().zip(self.due.iter())
|
|
||||||
.map(|(&c, &d)| (c - d).max(0.0))
|
|
||||||
.sum();
|
|
||||||
Evaluation::new(vec![makespan, flow_time, tardiness])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mix Insertion and Scramble per call — both preserve the multiset,
|
|
||||||
/// giving the search access to two complementary neighborhood moves.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct InsertionOrScramble;
|
|
||||||
impl Variation<Vec<usize>> for InsertionOrScramble {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
if rng.random_bool(0.5) {
|
|
||||||
InsertionMutation.vary(parents, rng)
|
|
||||||
} else {
|
|
||||||
ScrambleMutation.vary(parents, rng)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
# let routing = [[0; N_MACHINES]; N_JOBS];
|
|
||||||
# let times = [[0.0; N_MACHINES]; N_JOBS];
|
|
||||||
let due = std::array::from_fn::<f64, N_JOBS, _>(
|
|
||||||
|j| 1.3 * times[j].iter().sum::<f64>(),
|
|
||||||
);
|
|
||||||
let problem = La01ThreeObjective { routing, times, due };
|
|
||||||
|
|
||||||
let mut optimizer = Nsga3::new(
|
|
||||||
Nsga3Config {
|
|
||||||
population_size: 120,
|
|
||||||
generations: 600,
|
|
||||||
reference_divisions: 12, // 91 Das-Dennis points in 3-D
|
|
||||||
seed: 9,
|
|
||||||
},
|
|
||||||
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
|
|
||||||
// Drop in a local PrecedenceOrderCrossover (POX) here for the
|
|
||||||
// crossover slot if you want stronger mixing; see the
|
|
||||||
// permutation recipe for the implementation.
|
|
||||||
InsertionOrScramble,
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A few NSGA-III tips:
|
|
||||||
|
|
||||||
- **`reference_divisions` controls how many reference points the
|
|
||||||
algorithm spreads across the front.** For M objectives, the Das–Dennis
|
|
||||||
construction produces `C(divisions + M - 1, M - 1)` reference points.
|
|
||||||
For M = 3 and divisions = 12 that's 91 points; pick a population size
|
|
||||||
≥ that.
|
|
||||||
- **`PrecedenceOrderCrossover` (POX)** belongs in the crossover slot
|
|
||||||
for JSS. The strict-permutation crossovers (OX, PMX, CX, ERX) break
|
|
||||||
the operation-string multiset. See
|
|
||||||
[Optimize a permutation](./permutation.md#job-shop-scheduling-multiset-encodings)
|
|
||||||
for the local POX definition.
|
|
||||||
|
|
||||||
## Comparing operators by hypervolume
|
|
||||||
|
|
||||||
For Pareto-front problems, single-objective fitness is the wrong
|
|
||||||
comparison metric. Use **hypervolume** instead — the dominated area
|
|
||||||
under the front, against a fixed reference point.
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
|
|
||||||
let ref_point = [40_000.0, 40_000.0]; // worse than anything you expect
|
|
||||||
|
|
||||||
for (name, crossover) in &[
|
|
||||||
("OX", Box::new(OrderCrossover) as Box<dyn Variation<Vec<usize>>>),
|
|
||||||
("PMX", Box::new(PartiallyMappedCrossover) as _),
|
|
||||||
("CX", Box::new(CycleCrossover) as _),
|
|
||||||
("ERX", Box::new(EdgeRecombinationCrossover) as _),
|
|
||||||
] {
|
|
||||||
let result = run_nsga2_with_crossover(crossover);
|
|
||||||
let hv = hypervolume_2d(&result.pareto_front, &problem.objectives(), ref_point);
|
|
||||||
println!("{name:>3}: hv = {hv:.0}");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is the pattern in `examples/tsp_operators_compare.rs`. On the
|
|
||||||
KroAB-25 instance it ranks ERX > OX > PMX > CX by hypervolume.
|
|
||||||
|
|
||||||
For ≥ 3 objectives, hypervolume in N dimensions is exponentially
|
|
||||||
expensive; use [`hypervolume_2d`] when you can collapse to two
|
|
||||||
objectives for the metric, or sample-based hypervolume from [`HypE`]
|
|
||||||
otherwise.
|
|
||||||
|
|
||||||
## Pareto-front tips
|
|
||||||
|
|
||||||
| Problem | Algorithm | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| 2 objectives, permutation | [Nsga2][Nsga2] | Strong default |
|
|
||||||
| 2 objectives, binary | [Nsga2][Nsga2] | Same machinery, different encoding |
|
|
||||||
| 3 objectives | [Nsga3][Nsga3] | NSGA-II's crowding distance starts to degrade |
|
|
||||||
| 4+ objectives | [Nsga3][Nsga3] or [HypE][HypE] | NSGA-III if front is curved; HypE for indicator-based at scale |
|
|
||||||
| Many-objective with grid structure | [GrEA][Grea] | Wins linear / simplex fronts |
|
|
||||||
|
|
||||||
| Question | Use |
|
|
||||||
|---|---|
|
|
||||||
| Single-number quality metric for a run | `hypervolume_2d` against a fixed reference |
|
|
||||||
| "Is run A's front better than B's?" | Same reference point, compare hypervolume |
|
|
||||||
| "Pick one solution from the front" | See [Pick one answer off a Pareto front](./pick-one.md) |
|
|
||||||
| Interactive exploration / visualization | See [Explore your results in a webapp](./explorer.md) |
|
|
||||||
|
|
||||||
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
|
||||||
[Nsga3]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
|
||||||
[HypE]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
|
|
||||||
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
|
|
||||||
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
|
|
||||||
[`hypervolume_2d`]: https://docs.rs/heuropt/latest/heuropt/metrics/fn.hypervolume_2d.html
|
|
||||||
@@ -9,7 +9,7 @@ population, and rayon parallelizes that batch.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = { version = "0.10", features = ["parallel"] }
|
heuropt = { version = "0.5", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
There's nothing else to opt into in your code. The
|
There's nothing else to opt into in your code. The
|
||||||
@@ -29,14 +29,14 @@ pass.
|
|||||||
|
|
||||||
Algorithms with a per-generation `evaluate_batch`:
|
Algorithms with a per-generation `evaluate_batch`:
|
||||||
|
|
||||||
- [Random Search][RandomSearch], [NSGA-II][Nsga2], [NSGA-III][Nsga3], [SPEA2][Spea2], [MOEA/D][Moead],
|
- [`RandomSearch`], [`Nsga2`], [`Nsga3`], [`Spea2`], [`Moead`],
|
||||||
[MOPSO][Mopso], [IBEA][Ibea], [SMS-EMOA][SmsEmoa], [HypE][Hype], [PESA-II][PesaII],
|
[`Mopso`], [`Ibea`], [`SmsEmoa`], [`HypE`], [`PesaII`],
|
||||||
[ε-MOEA][EpsilonMoea], [AGE-MOEA][AgeMoea], [KnEA][Knea], [GrEA][Grea], [RVEA][Rvea].
|
[`EpsilonMoea`], [`AgeMoea`], [`Knea`], [`Grea`], [`Rvea`].
|
||||||
- [Differential Evolution][DifferentialEvolution] and [GA][GeneticAlgorithm] benefit on the
|
- [`DifferentialEvolution`] and [`GeneticAlgorithm`] benefit on the
|
||||||
initial population and offspring batches.
|
initial population and offspring batches.
|
||||||
|
|
||||||
Steady-state algorithms ([PAES][Paes], [Simulated Annealing][SimulatedAnnealing],
|
Steady-state algorithms ([`Paes`], [`SimulatedAnnealing`],
|
||||||
[Hill Climber][HillClimber], [(1+1)-ES][OnePlusOneEs]) only evaluate one or a few
|
[`HillClimber`], [`OnePlusOneEs`]) only evaluate one or a few
|
||||||
candidates per iteration, so the parallel feature gives them
|
candidates per iteration, so the parallel feature gives them
|
||||||
nothing — leave it off if those are your primary optimizers.
|
nothing — leave it off if those are your primary optimizers.
|
||||||
|
|
||||||
@@ -102,36 +102,26 @@ to scope it.
|
|||||||
- You're already running multiple seeds in parallel at the harness
|
- You're already running multiple seeds in parallel at the harness
|
||||||
level (see [Compare two algorithms](./compare.md)). Stacking
|
level (see [Compare two algorithms](./compare.md)). Stacking
|
||||||
parallelism rarely helps.
|
parallelism rarely helps.
|
||||||
- The algorithm is steady-state (PAES, SA, hill climber).
|
- The algorithm is steady-state (Paes, SA, hill climber).
|
||||||
|
|
||||||
## `parallel` vs `async`
|
[`RandomSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
||||||
|
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
||||||
| If your `evaluate` is… | Use |
|
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
||||||
|---|---|
|
[`Spea2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
|
||||||
| CPU-bound (math, simulation) | `parallel` feature (this recipe) |
|
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
|
||||||
| IO-bound (HTTP, RPC, subprocess) | `async` feature → see [Async evaluation](./async.md) |
|
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
||||||
|
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
||||||
Both can be on at once if your evaluation does *both* substantial
|
[`SmsEmoa`]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
|
||||||
CPU work *and* IO. The two features are independent.
|
[`HypE`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
|
||||||
|
[`PesaII`]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
|
||||||
[RandomSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/random_search/struct.RandomSearch.html
|
[`EpsilonMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
|
||||||
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
[`AgeMoea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
|
||||||
[Nsga3]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
|
[`Knea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
|
||||||
[Spea2]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
|
[`Grea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
|
||||||
[Moead]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
|
[`Rvea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
|
||||||
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
[`DifferentialEvolution`]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
|
||||||
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
||||||
[SmsEmoa]: https://docs.rs/heuropt/latest/heuropt/algorithms/sms_emoa/struct.SmsEmoa.html
|
[`Paes`]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
|
||||||
[Hype]: https://docs.rs/heuropt/latest/heuropt/algorithms/hype/struct.Hype.html
|
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
||||||
[PesaII]: https://docs.rs/heuropt/latest/heuropt/algorithms/pesa2/struct.PesaII.html
|
[`HillClimber`]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
|
||||||
[EpsilonMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/epsilon_moea/struct.EpsilonMoea.html
|
[`OnePlusOneEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
|
||||||
[AgeMoea]: https://docs.rs/heuropt/latest/heuropt/algorithms/age_moea/struct.AgeMoea.html
|
|
||||||
[Knea]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
|
|
||||||
[Grea]: https://docs.rs/heuropt/latest/heuropt/algorithms/grea/struct.Grea.html
|
|
||||||
[Rvea]: https://docs.rs/heuropt/latest/heuropt/algorithms/rvea/struct.Rvea.html
|
|
||||||
[DifferentialEvolution]: https://docs.rs/heuropt/latest/heuropt/algorithms/differential_evolution/struct.DifferentialEvolution.html
|
|
||||||
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
|
||||||
[Paes]: https://docs.rs/heuropt/latest/heuropt/algorithms/paes/struct.Paes.html
|
|
||||||
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
|
||||||
[HillClimber]: https://docs.rs/heuropt/latest/heuropt/algorithms/hill_climber/struct.HillClimber.html
|
|
||||||
[OnePlusOneEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/one_plus_one_es/struct.OnePlusOneEs.html
|
|
||||||
|
|||||||
@@ -1,287 +1,12 @@
|
|||||||
# Optimize a permutation (TSP-style)
|
# Optimize a permutation (TSP-style)
|
||||||
|
|
||||||
When your decision is "an ordering" — visiting cities, scheduling
|
When your decision is "an ordering" — visiting cities, scheduling
|
||||||
jobs, routing — the natural representation is `Vec<usize>`. heuropt
|
jobs, routing — the natural representation is `Vec<usize>` and the
|
||||||
ships three reasonable starting points:
|
specialized algorithm is [`AntColonyTsp`]. Generic alternatives are
|
||||||
|
[`SimulatedAnnealing`] + [`SwapMutation`] for any permutation, and
|
||||||
|
[`TabuSearch`] when you have a custom neighbor function.
|
||||||
|
|
||||||
- **A purpose-built algorithm** — [Ant Colony][AntColonyTsp] for TSP-shaped
|
## TSP with `AntColonyTsp`
|
||||||
problems with a distance matrix.
|
|
||||||
- **A genetic algorithm** with the permutation operator toolkit —
|
|
||||||
the most general option, and the right choice when you want to
|
|
||||||
bring your own evaluator without a pheromone metaphor.
|
|
||||||
- **A trajectory method** — [Simulated Annealing][SimulatedAnnealing] +
|
|
||||||
[`SwapMutation`] for a tiny baseline, or [Tabu Search][TabuSearch] when
|
|
||||||
you have a custom neighbor function.
|
|
||||||
|
|
||||||
This recipe walks through all three, with the bulk of the page on
|
|
||||||
the GA toolkit, since it's the most flexible. For the multi-objective
|
|
||||||
versions (bi-objective TSP, bi-objective JSS, Pareto fronts) see
|
|
||||||
[Multi-objective combinatorial problems](./multi-objective-combinatorial.md).
|
|
||||||
|
|
||||||
## The permutation operator toolkit
|
|
||||||
|
|
||||||
heuropt ships a complete set of permutation operators in the prelude.
|
|
||||||
You compose them with [`CompositeVariation`] into a crossover-plus-mutation
|
|
||||||
pipeline and feed them to any GA-shaped algorithm.
|
|
||||||
|
|
||||||
### Initializers
|
|
||||||
|
|
||||||
| Operator | What it produces | Use for |
|
|
||||||
|---|---|---|
|
|
||||||
| [`ShuffledPermutation`] | Random shuffles of `[0..n)` | TSP, QAP, single-machine scheduling — strict permutations |
|
|
||||||
| [`ShuffledMultisetPermutation`] | Random shuffles of `[0]*r₀ ++ [1]*r₁ ++ …` | Job-shop scheduling operation strings (each job id repeated `n_machines` times) |
|
|
||||||
|
|
||||||
### Crossovers
|
|
||||||
|
|
||||||
All four take two parents and return two children. They assume *strict*
|
|
||||||
permutations — applying them to multiset encodings (like JSS) will
|
|
||||||
break the multiset.
|
|
||||||
|
|
||||||
| Operator | One-liner | Best at |
|
|
||||||
|---|---|---|
|
|
||||||
| [`OrderCrossover`] (OX) | Copy a random segment from A, fill the rest in B's order | General-purpose, fast |
|
|
||||||
| [`PartiallyMappedCrossover`] (PMX) | Slide A's segment into B via positional swaps | Classic; preserves more position info than OX |
|
|
||||||
| [`CycleCrossover`] (CX) | Partition positions into cycles, alternate parents | Preserves the most positional information |
|
|
||||||
| [`EdgeRecombinationCrossover`] (ERX) | Greedy walk through the union of both parents' edges | The gold standard for TSP — preserves adjacency, not position |
|
|
||||||
|
|
||||||
For TSP specifically, ERX usually wins on Pareto-front quality at the
|
|
||||||
cost of being ~70% slower per crossover. See
|
|
||||||
[Multi-objective combinatorial problems](./multi-objective-combinatorial.md)
|
|
||||||
for a head-to-head comparison.
|
|
||||||
|
|
||||||
### Mutations
|
|
||||||
|
|
||||||
All five take one parent and return one child. All four below preserve
|
|
||||||
both strict permutations *and* multiset encodings, so they're safe for
|
|
||||||
JSS too.
|
|
||||||
|
|
||||||
| Operator | What it does | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| [`SwapMutation`] | Swap two random positions | Smallest perturbation; canonical default |
|
|
||||||
| [`InversionMutation`] | Reverse a random sub-slice | The textbook 2-opt-style move for TSP |
|
|
||||||
| [`InsertionMutation`] | Remove an element, re-insert elsewhere | Strong for sequencing / scheduling |
|
|
||||||
| [`ScrambleMutation`] | Randomly permute a random sub-slice | Stronger diversification |
|
|
||||||
|
|
||||||
### Quick "what should I use?" guide
|
|
||||||
|
|
||||||
| Your problem | Initializer | Crossover | Mutation |
|
|
||||||
|---|---|---|---|
|
|
||||||
| TSP / routing | `ShuffledPermutation` | `EdgeRecombinationCrossover` | `InversionMutation` |
|
|
||||||
| Single-machine scheduling | `ShuffledPermutation` | `OrderCrossover` | `InsertionMutation` |
|
|
||||||
| Generic strict permutation | `ShuffledPermutation` | `OrderCrossover` | `InversionMutation` |
|
|
||||||
| Job-shop scheduling (multiset) | `ShuffledMultisetPermutation` | *example-local POX* (see below) | `InversionMutation` or `SwapMutation` |
|
|
||||||
|
|
||||||
## Single-objective TSP with a Genetic Algorithm
|
|
||||||
|
|
||||||
This is the toolkit's headline pattern. It mirrors the
|
|
||||||
`examples/tsp_ulysses16.rs` benchmark, which converges to the known
|
|
||||||
TSPLIB optimum for the 16-city Ulysses instance.
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct Tsp {
|
|
||||||
distances: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for Tsp {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("tour_length")])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut len = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
len += self.distances[tour[i]][tour[(i + 1) % n]];
|
|
||||||
}
|
|
||||||
Evaluation::new(vec![len])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
// Replace with your actual distance matrix.
|
|
||||||
let n: usize = 16;
|
|
||||||
let distances = vec![vec![0.0_f64; n]; n];
|
|
||||||
let problem = Tsp { distances };
|
|
||||||
|
|
||||||
let mut optimizer = GeneticAlgorithm::new(
|
|
||||||
GeneticAlgorithmConfig {
|
|
||||||
population_size: 150,
|
|
||||||
generations: 1500,
|
|
||||||
tournament_size: 3,
|
|
||||||
elitism: 4,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OrderCrossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let r = optimizer.run(&problem);
|
|
||||||
let best = r.best.unwrap();
|
|
||||||
println!("best tour length: {:.0}", best.evaluation.objectives[0]);
|
|
||||||
println!("tour: {:?}", best.decision);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
A few things to notice:
|
|
||||||
|
|
||||||
- **Decision type is `Vec<usize>`.** Every operator in the toolkit is
|
|
||||||
generic over the decision type via `Variation<Vec<usize>>`, so the
|
|
||||||
whole pipeline composes naturally.
|
|
||||||
- **`CompositeVariation` is the wiring.** It runs the crossover first,
|
|
||||||
then runs the mutation on each child. For a single-parent operator
|
|
||||||
pair (e.g., two mutations stacked), it still works — the "crossover"
|
|
||||||
slot just becomes a first-stage mutation.
|
|
||||||
- **Tournament size 3 and elitism 4** are slightly stronger than the
|
|
||||||
defaults; small permutation GAs benefit from a touch more selection
|
|
||||||
pressure.
|
|
||||||
|
|
||||||
## Job-shop scheduling — multiset encodings
|
|
||||||
|
|
||||||
JSS problems use a different encoding: a string of length
|
|
||||||
`n_jobs × n_machines` where each job id appears `n_machines` times. The
|
|
||||||
k-th occurrence of job `j` represents the k-th operation of job `j`.
|
|
||||||
This is a *multiset permutation*, not a strict permutation, and the
|
|
||||||
crossovers above (OX, PMX, CX, ERX) will break it because they assume
|
|
||||||
each value appears exactly once.
|
|
||||||
|
|
||||||
Use `ShuffledMultisetPermutation` for the initializer:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
// 6 jobs × 6 machines (FT06 layout): each job id 0..6 appears 6 times.
|
|
||||||
let initializer = ShuffledMultisetPermutation::new(vec![6; 6]);
|
|
||||||
```
|
|
||||||
|
|
||||||
For variation you have two options:
|
|
||||||
|
|
||||||
1. **Mutation only.** The four mutations above all preserve the
|
|
||||||
multiset, so you can drive a GA with just `InversionMutation` or
|
|
||||||
`SwapMutation` and skip crossover. This works on small JSS
|
|
||||||
instances; on larger ones search becomes slow.
|
|
||||||
|
|
||||||
2. **Add a JSS-aware crossover.** The standard choice is **POX**
|
|
||||||
(Precedence-preserving Order-based Crossover, Bierwirth 1996).
|
|
||||||
It's not in the library because every JSS instance specifies its
|
|
||||||
own number of distinct ids and POX needs that constant; defining
|
|
||||||
it locally per example keeps the type clean:
|
|
||||||
|
|
||||||
```rust,no_run
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
const N_JOBS: usize = 6;
|
|
||||||
|
|
||||||
/// POX — partition job ids into two sets J1/J2; child takes positions
|
|
||||||
/// of J1-jobs from parent A and fills the remaining positions with
|
|
||||||
/// J2-jobs from parent B in B's order. Preserves the multiset.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct PrecedenceOrderCrossover;
|
|
||||||
|
|
||||||
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
let p1 = &parents[0];
|
|
||||||
let p2 = &parents[1];
|
|
||||||
let mut in_j1 = [false; N_JOBS];
|
|
||||||
loop {
|
|
||||||
for slot in &mut in_j1 {
|
|
||||||
*slot = rng.random_bool(0.5);
|
|
||||||
}
|
|
||||||
let count = in_j1.iter().filter(|&&b| b).count();
|
|
||||||
if count > 0 && count < N_JOBS { break; }
|
|
||||||
}
|
|
||||||
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
|
|
||||||
let n = donor.len();
|
|
||||||
let mut child = vec![usize::MAX; n];
|
|
||||||
for k in 0..n {
|
|
||||||
if in_donor_set[donor[k]] {
|
|
||||||
child[k] = donor[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut idx = 0;
|
|
||||||
for &v in filler {
|
|
||||||
if !in_donor_set[v] {
|
|
||||||
while idx < n && child[idx] != usize::MAX { idx += 1; }
|
|
||||||
child[idx] = v;
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
child
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
See `examples/jss_ft06_bi.rs` and `examples/mo_jss_la01.rs` for the
|
|
||||||
complete worked examples.
|
|
||||||
|
|
||||||
A full JSS evaluator walks the schedule string left-to-right, tracking
|
|
||||||
per-job operation counters and per-machine clocks:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
|
||||||
let mut job_next = [0_usize; N_JOBS];
|
|
||||||
let mut job_clock = [0.0_f64; N_JOBS];
|
|
||||||
let mut machine_clock = [0.0_f64; N_MACHINES];
|
|
||||||
for &job in schedule {
|
|
||||||
let k = job_next[job];
|
|
||||||
let m = ROUTING[job][k];
|
|
||||||
let t = PROCESSING_TIME[job][k];
|
|
||||||
let start = job_clock[job].max(machine_clock[m]);
|
|
||||||
let end = start + t;
|
|
||||||
job_clock[job] = end;
|
|
||||||
machine_clock[m] = end;
|
|
||||||
job_next[job] = k + 1;
|
|
||||||
}
|
|
||||||
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
|
|
||||||
Evaluation::new(vec![makespan])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Comparing crossover operators
|
|
||||||
|
|
||||||
Tuning the right operator combo matters more than tuning population
|
|
||||||
size. The pattern is: hold everything constant, swap the operator,
|
|
||||||
record the metric:
|
|
||||||
|
|
||||||
```rust,ignore
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
|
|
||||||
fn run_with<C: Variation<Vec<usize>>>(crossover: C) -> f64 {
|
|
||||||
let mut opt = GeneticAlgorithm::new(
|
|
||||||
GeneticAlgorithmConfig { /* identical config */ ..Default::default() },
|
|
||||||
ShuffledPermutation { n: 25 },
|
|
||||||
CompositeVariation { crossover, mutation: InversionMutation },
|
|
||||||
);
|
|
||||||
opt.run(&problem).best.unwrap().evaluation.objectives[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("OX: {:.0}", run_with(OrderCrossover));
|
|
||||||
println!("PMX: {:.0}", run_with(PartiallyMappedCrossover));
|
|
||||||
println!("CX: {:.0}", run_with(CycleCrossover));
|
|
||||||
println!("ERX: {:.0}", run_with(EdgeRecombinationCrossover));
|
|
||||||
```
|
|
||||||
|
|
||||||
For Pareto-front problems use hypervolume, not single-objective
|
|
||||||
fitness, as the comparison metric — see
|
|
||||||
[`examples/tsp_operators_compare.rs`][CompareExample] for the bi-objective
|
|
||||||
version.
|
|
||||||
|
|
||||||
## TSP with Ant Colony
|
|
||||||
|
|
||||||
When your problem is genuinely TSP-shaped — symmetric distance matrix,
|
|
||||||
visit-every-node — Ant Colony is purpose-built and worth a look. It
|
|
||||||
doesn't use crossover or mutation; instead it deposits pheromone trails
|
|
||||||
that bias future ants toward good edges.
|
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
@@ -308,7 +33,14 @@ impl Problem for Tsp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let cities = vec![(0.0, 0.0), (1.0, 5.0), (5.0, 2.0), (6.0, 6.0), (8.0, 3.0)];
|
// 5-city Euclidean instance
|
||||||
|
let cities = vec![
|
||||||
|
(0.0, 0.0),
|
||||||
|
(1.0, 5.0),
|
||||||
|
(5.0, 2.0),
|
||||||
|
(6.0, 6.0),
|
||||||
|
(8.0, 3.0),
|
||||||
|
];
|
||||||
let n = cities.len();
|
let n = cities.len();
|
||||||
let mut distances = vec![vec![0.0; n]; n];
|
let mut distances = vec![vec![0.0; n]; n];
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
@@ -334,18 +66,19 @@ fn main() {
|
|||||||
let r = opt.run(&problem);
|
let r = opt.run(&problem);
|
||||||
let best = r.best.unwrap();
|
let best = r.best.unwrap();
|
||||||
println!("best tour length: {:.3}", best.evaluation.objectives[0]);
|
println!("best tour length: {:.3}", best.evaluation.objectives[0]);
|
||||||
|
println!("tour: {:?}", best.decision);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`alpha` weights pheromone influence and `beta` weights the heuristic
|
`alpha` weights pheromone influence and `beta` weights the
|
||||||
(1 / distance). `evaporation` is the per-iteration pheromone decay.
|
heuristic (1 / distance). `evaporation` is the per-iteration decay
|
||||||
The classic Dorigo paper uses `alpha = 1`, `beta = 2..5`,
|
of pheromone trails. The classic Dorigo paper uses `alpha = 1`,
|
||||||
`evaporation = 0.1..0.5`.
|
`beta = 2..5`, `evaporation = 0.1..0.5`.
|
||||||
|
|
||||||
## Tiny baseline: SA + SwapMutation
|
## Generic permutation: SA + SwapMutation
|
||||||
|
|
||||||
The smallest possible permutation optimizer — one starting decision,
|
Use this when your problem isn't TSP-shaped (no distance matrix
|
||||||
no population, one mutation operator. Good as a sanity-check baseline.
|
makes sense) but you still want to optimize an ordering.
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
@@ -356,9 +89,10 @@ struct JobShop {
|
|||||||
impl Problem for JobShop {
|
impl Problem for JobShop {
|
||||||
type Decision = Vec<usize>;
|
type Decision = Vec<usize>;
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("weighted_completion")])
|
ObjectiveSpace::new(vec![Objective::minimize("makespan")])
|
||||||
}
|
}
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
||||||
|
// Pretend cumulative weighted-completion-time. Replace with your real cost.
|
||||||
let cost: f64 = schedule.iter().enumerate()
|
let cost: f64 = schedule.iter().enumerate()
|
||||||
.map(|(i, &job)| (i as f64 + 1.0) * self.process_times[job])
|
.map(|(i, &job)| (i as f64 + 1.0) * self.process_times[job])
|
||||||
.sum();
|
.sum();
|
||||||
@@ -366,18 +100,22 @@ impl Problem for JobShop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
|
fn make_initial_perm(n: usize, seed: u64) -> Vec<usize> {
|
||||||
let n = times.len();
|
|
||||||
let problem = JobShop { process_times: times };
|
|
||||||
|
|
||||||
// SimulatedAnnealing expects exactly one initial decision.
|
|
||||||
struct OneShuffle { n: usize }
|
|
||||||
impl Initializer<Vec<usize>> for OneShuffle {
|
|
||||||
fn initialize(&mut self, _size: usize, rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
use rand::seq::SliceRandom;
|
use rand::seq::SliceRandom;
|
||||||
let mut p: Vec<usize> = (0..self.n).collect();
|
let mut rng = rng_from_seed(seed);
|
||||||
p.shuffle(rng);
|
let mut perm: Vec<usize> = (0..n).collect();
|
||||||
vec![p]
|
perm.shuffle(&mut rng);
|
||||||
|
perm
|
||||||
|
}
|
||||||
|
|
||||||
|
let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
|
||||||
|
let problem = JobShop { process_times: times.clone() };
|
||||||
|
|
||||||
|
// SimulatedAnnealing needs a starting decision; pass a custom Initializer.
|
||||||
|
struct OnePerm(Vec<usize>);
|
||||||
|
impl Initializer<Vec<usize>> for OnePerm {
|
||||||
|
fn initialize(&mut self, _size: usize, _rng: &mut Rng) -> Vec<Vec<usize>> {
|
||||||
|
vec![self.0.clone()]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,24 +126,28 @@ let mut opt = SimulatedAnnealing::new(
|
|||||||
final_temperature: 1e-3,
|
final_temperature: 1e-3,
|
||||||
seed: 7,
|
seed: 7,
|
||||||
},
|
},
|
||||||
OneShuffle { n },
|
OnePerm(make_initial_perm(times.len(), 7)),
|
||||||
SwapMutation,
|
SwapMutation,
|
||||||
);
|
);
|
||||||
let r = opt.run(&problem);
|
let r = opt.run(&problem);
|
||||||
let best = r.best.unwrap();
|
let best = r.best.unwrap();
|
||||||
println!("best cost: {:.3}", best.evaluation.objectives[0]);
|
println!("best makespan: {:.3}", best.evaluation.objectives[0]);
|
||||||
|
println!("schedule: {:?}", best.decision);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Custom neighborhoods: Tabu Search
|
`SwapMutation` swaps two random indices in the permutation —
|
||||||
|
preserves the "every element appears once" invariant for free.
|
||||||
|
|
||||||
When you want full control of the move set (e.g., systematic 2-opt for
|
## Custom neighborhoods: `TabuSearch`
|
||||||
TSP, or insert-and-shift for scheduling), [Tabu Search][TabuSearch] takes
|
|
||||||
your own neighbor function.
|
When swap isn't the right move set (e.g., 2-opt for TSP, insert /
|
||||||
|
shift for scheduling), use [`TabuSearch`] with your own neighbor
|
||||||
|
function.
|
||||||
|
|
||||||
```rust,ignore
|
```rust,ignore
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
|
let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
|
||||||
// All 2-opt neighbors of x.
|
// Generate all 2-opt neighbors of x.
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for i in 0..x.len() {
|
for i in 0..x.len() {
|
||||||
for j in (i + 2)..x.len() {
|
for j in (i + 2)..x.len() {
|
||||||
@@ -419,29 +161,7 @@ let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
|
|||||||
// Pass `neighbors` to TabuSearch::new(...).
|
// Pass `neighbors` to TabuSearch::new(...).
|
||||||
```
|
```
|
||||||
|
|
||||||
## When to use which approach
|
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
||||||
|
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
||||||
| Situation | Use |
|
|
||||||
|---|---|
|
|
||||||
| TSP-shaped with a distance matrix | [Ant Colony][AntColonyTsp] |
|
|
||||||
| Generic permutation, multi-seed budget | GA + `ShuffledPermutation` + OX + Inversion |
|
|
||||||
| Job-shop scheduling | GA + `ShuffledMultisetPermutation` + local POX + Inversion |
|
|
||||||
| Single-decision baseline | [SimulatedAnnealing][SimulatedAnnealing] + `SwapMutation` |
|
|
||||||
| Hand-crafted neighborhood (e.g. systematic 2-opt) | [Tabu Search][TabuSearch] |
|
|
||||||
| Bi-objective / many-objective permutation problem | See [Multi-objective combinatorial](./multi-objective-combinatorial.md) |
|
|
||||||
|
|
||||||
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
|
||||||
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
|
||||||
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
|
||||||
[`ShuffledPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledPermutation.html
|
|
||||||
[`ShuffledMultisetPermutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ShuffledMultisetPermutation.html
|
|
||||||
[`OrderCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.OrderCrossover.html
|
|
||||||
[`PartiallyMappedCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.PartiallyMappedCrossover.html
|
|
||||||
[`CycleCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CycleCrossover.html
|
|
||||||
[`EdgeRecombinationCrossover`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.EdgeRecombinationCrossover.html
|
|
||||||
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
||||||
[`InversionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InversionMutation.html
|
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
||||||
[`InsertionMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.InsertionMutation.html
|
|
||||||
[`ScrambleMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ScrambleMutation.html
|
|
||||||
[`CompositeVariation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.CompositeVariation.html
|
|
||||||
[CompareExample]: https://git.swaits.com/swaits/heuropt/src/branch/main/examples/tsp_operators_compare.rs
|
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ impl Problem for Zdt1 {
|
|||||||
```
|
```
|
||||||
|
|
||||||
For multi-objective problems, pick a Pareto-aware optimizer:
|
For multi-objective problems, pick a Pareto-aware optimizer:
|
||||||
[NSGA-II][Nsga2] is the canonical default; [MOPSO][Mopso] often wins on
|
[`Nsga2`] is the canonical default; [`Mopso`] often wins on
|
||||||
smooth-front 2-objective problems; [IBEA][Ibea] often wins on
|
smooth-front 2-objective problems; [`Ibea`] often wins on
|
||||||
disconnected fronts. See [choosing-an-algorithm](./choosing-an-algorithm.md).
|
disconnected fronts. See [choosing-an-algorithm](./choosing-an-algorithm.md).
|
||||||
|
|
||||||
## Maximizing instead of minimizing
|
## Maximizing instead of minimizing
|
||||||
@@ -166,8 +166,8 @@ impl Problem for OneMax {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For `Vec<bool>` problems, [UMDA][Umda] is a parameter-free EDA;
|
For `Vec<bool>` problems, [`Umda`] is a parameter-free EDA;
|
||||||
[GA][GeneticAlgorithm] with [`BitFlipMutation`] is the GA route.
|
[`GeneticAlgorithm`] with [`BitFlipMutation`] is the GA route.
|
||||||
|
|
||||||
### Permutations (`Vec<usize>`)
|
### Permutations (`Vec<usize>`)
|
||||||
|
|
||||||
@@ -191,9 +191,9 @@ impl Problem for Tsp {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
For permutations, [Ant Colony][AntColonyTsp] specializes on TSP-style problems;
|
For permutations, [`AntColonyTsp`] specializes on TSP-style problems;
|
||||||
[Tabu Search][TabuSearch] takes a user-supplied neighbor function for arbitrary
|
[`TabuSearch`] takes a user-supplied neighbor function for arbitrary
|
||||||
discrete neighborhoods; [Simulated Annealing][SimulatedAnnealing] with [`SwapMutation`]
|
discrete neighborhoods; [`SimulatedAnnealing`] with [`SwapMutation`]
|
||||||
is the simplest baseline.
|
is the simplest baseline.
|
||||||
|
|
||||||
### Custom decision types
|
### Custom decision types
|
||||||
@@ -232,13 +232,13 @@ through the decision tree.
|
|||||||
[`Evaluation`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html
|
[`Evaluation`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html
|
||||||
[`Evaluation::new`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.new
|
[`Evaluation::new`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.new
|
||||||
[`Evaluation::constrained`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.constrained
|
[`Evaluation::constrained`]: https://docs.rs/heuropt/latest/heuropt/core/evaluation/struct.Evaluation.html#method.constrained
|
||||||
[Nsga2]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
[`Nsga2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga2/struct.Nsga2.html
|
||||||
[Mopso]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
[`Mopso`]: https://docs.rs/heuropt/latest/heuropt/algorithms/mopso/struct.Mopso.html
|
||||||
[Ibea]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
[`Ibea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ibea/struct.Ibea.html
|
||||||
[Umda]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
|
[`Umda`]: https://docs.rs/heuropt/latest/heuropt/algorithms/umda/struct.Umda.html
|
||||||
[GeneticAlgorithm]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
[`GeneticAlgorithm`]: https://docs.rs/heuropt/latest/heuropt/algorithms/genetic_algorithm/struct.GeneticAlgorithm.html
|
||||||
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
|
[`BitFlipMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BitFlipMutation.html
|
||||||
[AntColonyTsp]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
[`AntColonyTsp`]: https://docs.rs/heuropt/latest/heuropt/algorithms/ant_colony_tsp/struct.AntColonyTsp.html
|
||||||
[TabuSearch]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
|
||||||
[SimulatedAnnealing]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
[`SimulatedAnnealing`]: https://docs.rs/heuropt/latest/heuropt/algorithms/simulated_annealing/struct.SimulatedAnnealing.html
|
||||||
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
|
||||||
|
|||||||
@@ -6,71 +6,68 @@ The shortest path from a fresh project to a working optimizer.
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
heuropt = "0.10"
|
heuropt = "0.5"
|
||||||
```
|
```
|
||||||
|
|
||||||
The default feature set is small. Optional features:
|
The default feature set is small. Optional features:
|
||||||
|
|
||||||
- `parallel` — rayon-backed parallel population evaluation.
|
- `parallel` — rayon-backed parallel population evaluation.
|
||||||
- `serde` — `Serialize` / `Deserialize` derives on the core data
|
- `serde` — `Serialize` / `Deserialize` derives on the core data
|
||||||
types, plus the `heuropt::explorer` JSON export module for the
|
types.
|
||||||
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
webapp.
|
|
||||||
- `async` — `AsyncProblem` trait + per-algorithm `run_async` for
|
|
||||||
IO-bound evaluations.
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
heuropt = { version = "0.10", features = ["parallel"] }
|
heuropt = { version = "0.5", features = ["parallel"] }
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Define a problem and run an optimizer
|
## 2. Define a problem
|
||||||
|
|
||||||
A problem is a struct that implements the [`Problem`] trait. You tell
|
A problem is a struct that implements the [`Problem`] trait. You tell
|
||||||
heuropt what kind of decision your problem takes (`Vec<f64>`,
|
heuropt what kind of decision your problem takes (`Vec<f64>`,
|
||||||
`Vec<bool>`, …), what objectives it has (minimize or maximize), and
|
`Vec<bool>`, …), what objectives it has (minimize or maximize), and
|
||||||
how to score one decision.
|
how to score one decision.
|
||||||
|
|
||||||
We'll fit a straight line to a handful of `(x, y)` data points by
|
|
||||||
finding the slope and intercept that minimize the sum of squared
|
|
||||||
errors — same objective as least-squares regression. For a smooth
|
|
||||||
single-objective continuous problem like this, [CMA-ES][CmaEs] is a strong
|
|
||||||
default.
|
|
||||||
|
|
||||||
```rust,no_run
|
```rust,no_run
|
||||||
use heuropt::prelude::*;
|
use heuropt::prelude::*;
|
||||||
|
|
||||||
struct LineFit {
|
struct Sphere;
|
||||||
points: Vec<(f64, f64)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for LineFit {
|
impl Problem for Sphere {
|
||||||
type Decision = Vec<f64>; // [slope, intercept]
|
type Decision = Vec<f64>;
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
fn objectives(&self) -> ObjectiveSpace {
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("sum_squared_error")])
|
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
let (slope, intercept) = (x[0], x[1]);
|
let f: f64 = x.iter().map(|v| v * v).sum();
|
||||||
let sse: f64 = self
|
Evaluation::new(vec![f])
|
||||||
.points
|
|
||||||
.iter()
|
|
||||||
.map(|(px, py)| (py - (slope * px + intercept)).powi(2))
|
|
||||||
.sum();
|
|
||||||
Evaluation::new(vec![sse])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
```
|
||||||
|
|
||||||
fn main() {
|
The Sphere function is a single-objective continuous problem: minimize
|
||||||
// Five noisy points roughly on the line y = 2x + 1.
|
`f(x) = Σ xᵢ²`. The optimum is `x = 0`, `f = 0`.
|
||||||
let problem = LineFit {
|
|
||||||
points: vec![(0.0, 1.1), (1.0, 2.9), (2.0, 5.1), (3.0, 6.8), (4.0, 9.2)],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Search box: slope and intercept each in [-10, 10].
|
## 3. Pick an algorithm and run it
|
||||||
let bounds = RealBounds::new(vec![(-10.0, 10.0); 2]);
|
|
||||||
|
|
||||||
let mut opt = CmaEs::new(
|
For a smooth single-objective continuous problem, [`CmaEs`] is a
|
||||||
|
strong default. Configure it, build it, run it.
|
||||||
|
|
||||||
|
```rust,no_run
|
||||||
|
# use heuropt::prelude::*;
|
||||||
|
# struct Sphere;
|
||||||
|
# impl Problem for Sphere {
|
||||||
|
# type Decision = Vec<f64>;
|
||||||
|
# fn objectives(&self) -> ObjectiveSpace {
|
||||||
|
# ObjectiveSpace::new(vec![Objective::minimize("f")])
|
||||||
|
# }
|
||||||
|
# fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
||||||
|
# Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
let bounds = RealBounds::new(vec![(-5.0, 5.0); 5]); // 5-dim search box
|
||||||
|
|
||||||
|
let mut opt = CmaEs::new(
|
||||||
CmaEsConfig {
|
CmaEsConfig {
|
||||||
population_size: 12,
|
population_size: 12,
|
||||||
generations: 80,
|
generations: 80,
|
||||||
@@ -80,67 +77,28 @@ fn main() {
|
|||||||
seed: 42,
|
seed: 42,
|
||||||
},
|
},
|
||||||
bounds,
|
bounds,
|
||||||
);
|
);
|
||||||
|
|
||||||
let result = opt.run(&problem);
|
let result = opt.run(&Sphere);
|
||||||
let best = result.best.expect("at least one feasible candidate");
|
|
||||||
let (slope, intercept) = (best.decision[0], best.decision[1]);
|
|
||||||
println!(
|
|
||||||
"best fit: y = {:.4} x + {:.4} (sse = {:.4e}, evaluations = {})",
|
|
||||||
slope, intercept, best.evaluation.objectives[0], result.evaluations,
|
|
||||||
);
|
|
||||||
|
|
||||||
println!();
|
let best = result.best.expect("at least one feasible candidate");
|
||||||
println!("predictions vs actual:");
|
println!("best f = {:.3e} at x = {:?}", best.evaluation.objectives[0], best.decision);
|
||||||
for (px, py) in &problem.points {
|
|
||||||
let pred = slope * px + intercept;
|
|
||||||
println!(
|
|
||||||
" x = {:.1} actual = {:.2} predicted = {:.4} residual = {:+.4}",
|
|
||||||
px, py, pred, py - pred,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with `cargo run --release` — heuristic optimization is allergic
|
Run with `cargo run --release` — heuristic optimization is allergic
|
||||||
to debug builds. The actual output:
|
to debug builds. Expect output like:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
best fit: y = 2.0100 x + 1.0000 (sse = 1.0700e-1, evaluations = 960)
|
best f = 1.4e-29 at x = [-1.6e-15, 4.5e-16, ...]
|
||||||
|
|
||||||
predictions vs actual:
|
|
||||||
x = 0.0 actual = 1.10 predicted = 1.0000 residual = +0.1000
|
|
||||||
x = 1.0 actual = 2.90 predicted = 3.0100 residual = -0.1100
|
|
||||||
x = 2.0 actual = 5.10 predicted = 5.0200 residual = +0.0800
|
|
||||||
x = 3.0 actual = 6.80 predicted = 7.0300 residual = -0.2300
|
|
||||||
x = 4.0 actual = 9.20 predicted = 9.0400 residual = +0.1600
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Reading the result
|
CMA-ES drops to machine epsilon on the Sphere in well under 80
|
||||||
|
generations.
|
||||||
CMA-ES recovered **slope ≈ 2.01, intercept ≈ 1.00** — within
|
|
||||||
hundredths of the underlying line `y = 2x + 1` that the data was
|
|
||||||
sampled from. The residuals are evenly distributed in sign (3
|
|
||||||
positive, 2 negative) and small in magnitude (the largest is 0.23
|
|
||||||
at `x = 3`), which means the fit is balancing the noise rather than
|
|
||||||
chasing any single point.
|
|
||||||
|
|
||||||
The total **sum of squared errors is 0.107** — that is the value
|
|
||||||
the optimizer was actually minimizing, and it matches the answer
|
|
||||||
you'd get from running `numpy.polyfit` or solving the normal
|
|
||||||
equations directly. CMA-ES is overkill for a two-parameter problem
|
|
||||||
(closed-form least-squares does it in one step), but the **same
|
|
||||||
code shape** scales straight up to nonlinear models, robust loss
|
|
||||||
functions, or constrained variants where there is no closed form.
|
|
||||||
|
|
||||||
It used 960 evaluations to get there. That's `population_size × generations`
|
|
||||||
= 12 × 80 = 960, and CMA-ES converges to machine epsilon on
|
|
||||||
problems this clean in well under that budget.
|
|
||||||
|
|
||||||
## 4. What just happened
|
## 4. What just happened
|
||||||
|
|
||||||
- [`Problem`] is the **what** you're optimizing.
|
- [`Problem`] is the **what** you're optimizing.
|
||||||
- [CMA-ES][CmaEs] (or any other optimizer) is the **how**.
|
- [`CmaEs`] (or any other optimizer) is the **how**.
|
||||||
- [`CmaEsConfig`] is a plain public-field struct: there are no
|
- [`CmaEsConfig`] is a plain public-field struct: there are no
|
||||||
builders, no chained setters, just public fields you set
|
builders, no chained setters, just public fields you set
|
||||||
directly.
|
directly.
|
||||||
@@ -165,5 +123,5 @@ problems this clean in well under that budget.
|
|||||||
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
|
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
|
||||||
[`Optimizer::run`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Optimizer.html
|
[`Optimizer::run`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Optimizer.html
|
||||||
[`OptimizationResult`]: https://docs.rs/heuropt/latest/heuropt/core/result/struct.OptimizationResult.html
|
[`OptimizationResult`]: https://docs.rs/heuropt/latest/heuropt/core/result/struct.OptimizationResult.html
|
||||||
[CmaEs]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
|
[`CmaEs`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEs.html
|
||||||
[`CmaEsConfig`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEsConfig.html
|
[`CmaEsConfig`]: https://docs.rs/heuropt/latest/heuropt/algorithms/cma_es/struct.CmaEsConfig.html
|
||||||
|
|||||||
@@ -28,14 +28,14 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
|
|||||||
|
|
||||||
1. **Approachable code.** No trait objects in the public API. No
|
1. **Approachable code.** No trait objects in the public API. No
|
||||||
GATs, HRTBs, generic-RNG plumbing. A junior Rust engineer should
|
GATs, HRTBs, generic-RNG plumbing. A junior Rust engineer should
|
||||||
be able to read Random Search and write a new optimizer by
|
be able to read `RandomSearch` and write a new optimizer by
|
||||||
implementing only the `Optimizer<P>` trait.
|
implementing only the `Optimizer<P>` trait.
|
||||||
2. **One concrete RNG type.** Seeded determinism is a property tested
|
2. **One concrete RNG type.** Seeded determinism is a property tested
|
||||||
across the crate; identical inputs always produce identical
|
across the crate; identical inputs always produce identical
|
||||||
outputs.
|
outputs.
|
||||||
3. **Algorithms that work.** Every algorithm is benchmarked against
|
3. **Algorithms that work.** Every algorithm is benchmarked against
|
||||||
the canonical test problems (ZDT, DTLZ, Rastrigin, Rosenbrock,
|
the canonical test problems (ZDT, DTLZ, Rastrigin, Rosenbrock,
|
||||||
Ackley) and the results are checked into [examples/compare-results.md](https://git.swaits.com/swaits/heuropt/src/branch/main/examples/compare-results.md)
|
Ackley) and the results are checked into [examples/compare-results.md](https://github.com/swaits/heuropt/blob/main/examples/compare-results.md)
|
||||||
so you can see what each algorithm's strengths actually are.
|
so you can see what each algorithm's strengths actually are.
|
||||||
4. **Testing as a first-class concern.** 316+ unit / integration /
|
4. **Testing as a first-class concern.** 316+ unit / integration /
|
||||||
property tests, eight cargo-fuzz targets in CI, gungraun
|
property tests, eight cargo-fuzz targets in CI, gungraun
|
||||||
@@ -44,18 +44,20 @@ hyperopt, optuna, DEAP). heuropt's design priorities:
|
|||||||
|
|
||||||
## What's in the box
|
## What's in the box
|
||||||
|
|
||||||
heuropt v0.10 ships **33 algorithms** spanning:
|
heuropt v0.5 ships **35 algorithms** spanning:
|
||||||
|
|
||||||
- Single-objective continuous: Random Search, Hill Climber,
|
- Single-objective continuous: `RandomSearch`, `HillClimber`,
|
||||||
(1+1)-ES, Simulated Annealing, GA, PSO, Differential Evolution,
|
`OnePlusOneEs`, `SimulatedAnnealing`, `GeneticAlgorithm`,
|
||||||
TLBO, CMA-ES, IPOP-CMA-ES, sNES, Nelder-Mead.
|
`ParticleSwarm`, `DifferentialEvolution`, `Tlbo`, `CmaEs`,
|
||||||
- Single-objective other types: UMDA (binary), Tabu Search (any),
|
`IpopCmaEs`, `SeparableNes`, `NelderMead`.
|
||||||
Ant Colony (permutation).
|
- Single-objective other types: `Umda` (binary), `TabuSearch`
|
||||||
- Multi-objective (2–3): PAES, NSGA-II, SPEA2, MOPSO, IBEA,
|
(any), `AntColonyTsp` (permutation).
|
||||||
SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA, MOEA/D.
|
- Multi-objective (2–3): `Paes`, `Nsga2`, `Spea2`, `Mopso`, `Ibea`,
|
||||||
- Many-objective (4+): NSGA-III, RVEA, GrEA.
|
`SmsEmoa`, `HypE`, `EpsilonMoea`, `PesaII`, `AgeMoea`, `Knea`,
|
||||||
- Sample-efficient / multi-fidelity: Bayesian Optimization, TPE,
|
`Moead`.
|
||||||
Hyperband.
|
- Many-objective (4+): `Nsga3`, `Rvea`, `Grea`.
|
||||||
|
- Sample-efficient / multi-fidelity: `BayesianOpt`, `Tpe`,
|
||||||
|
`Hyperband`.
|
||||||
|
|
||||||
Plus the operators (SBX, PolynomialMutation, BoundedGaussianMutation,
|
Plus the operators (SBX, PolynomialMutation, BoundedGaussianMutation,
|
||||||
LevyMutation, BitFlipMutation, SwapMutation, ClampToBounds,
|
LevyMutation, BitFlipMutation, SwapMutation, ClampToBounds,
|
||||||
@@ -63,15 +65,6 @@ ProjectToSimplex), the metrics (hypervolume, spacing), and the Pareto
|
|||||||
utilities (dominance, fronts, crowding distance, Das–Dennis reference
|
utilities (dominance, fronts, crowding distance, Das–Dennis reference
|
||||||
points, the `ParetoArchive`) that you'd expect.
|
points, the `ParetoArchive`) that you'd expect.
|
||||||
|
|
||||||
**Async evaluation** (since v0.8, behind the `async` feature flag):
|
|
||||||
when your `evaluate` function is IO-bound — calling an HTTP service,
|
|
||||||
an RPC, or a subprocess — implement [`AsyncProblem`] and use
|
|
||||||
`run_async(&problem, concurrency).await` on any algorithm in the
|
|
||||||
catalog. heuropt is the only mainstream optimization library with
|
|
||||||
first-class async support across its entire algorithm set.
|
|
||||||
|
|
||||||
[`AsyncProblem`]: https://docs.rs/heuropt/latest/heuropt/core/async_problem/trait.AsyncProblem.html
|
|
||||||
|
|
||||||
## How to use this guide
|
## How to use this guide
|
||||||
|
|
||||||
If you're new to heuropt, read it linearly:
|
If you're new to heuropt, read it linearly:
|
||||||
|
|||||||
+5
-127
@@ -3,128 +3,6 @@
|
|||||||
Per-release notes for upgrading between heuropt versions. Skip the
|
Per-release notes for upgrading between heuropt versions. Skip the
|
||||||
sections that don't apply to your starting version.
|
sections that don't apply to your starting version.
|
||||||
|
|
||||||
## To 0.10
|
|
||||||
|
|
||||||
### From 0.9.x
|
|
||||||
|
|
||||||
**Almost additive.** Bumping `heuropt = "0.10"` recompiles
|
|
||||||
without touching most code. The one breaking change is the value
|
|
||||||
returned by `AlgorithmInfo::name()`:
|
|
||||||
|
|
||||||
| Before (`0.9`) | After (`0.10`) |
|
|
||||||
|---|---|
|
|
||||||
| `"Nsga2"` | `"NSGA-II"` |
|
|
||||||
| `"Nsga3"` | `"NSGA-III"` |
|
|
||||||
| `"Cmaes"` | `"CMA-ES"` |
|
|
||||||
| `"Mopso"` | `"MOPSO"` |
|
|
||||||
| `"Moead"` | `"MOEA/D"` |
|
|
||||||
| `"EpsilonMoea"` | `"ε-MOEA"` |
|
|
||||||
| (and 27 more) | … |
|
|
||||||
|
|
||||||
If you pattern-matched on those strings (e.g. for branching
|
|
||||||
display logic), update to the new canonical strings. They now
|
|
||||||
match the literature and will be stable going forward.
|
|
||||||
|
|
||||||
What's new and additive:
|
|
||||||
|
|
||||||
- `AlgorithmInfo::full_name(&self) -> &'static str` — academic
|
|
||||||
long form (`"Non-dominated Sorting Genetic Algorithm II"`).
|
|
||||||
Defaults to `name()` for algorithms whose long and short
|
|
||||||
forms coincide.
|
|
||||||
- `ExplorerExport`'s `RunMeta` gained `algorithm_full_name:
|
|
||||||
Option<String>`. Schema version stays at **1** (the new field
|
|
||||||
is `#[serde(default)]`); display tools can use the long form
|
|
||||||
as a hover tooltip on the short name.
|
|
||||||
|
|
||||||
## To 0.9
|
|
||||||
|
|
||||||
### From 0.8.x
|
|
||||||
|
|
||||||
**Additive only.** Bumping `heuropt = "0.9"` works for all 0.8.x
|
|
||||||
code untouched. The new surfaces ship behind the existing `serde`
|
|
||||||
feature.
|
|
||||||
|
|
||||||
What's new:
|
|
||||||
|
|
||||||
- `heuropt::explorer` module (gated on `serde`) — turns an
|
|
||||||
`OptimizationResult` into a self-describing JSON file that the
|
|
||||||
[heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
webapp can load. See the
|
|
||||||
[Explore your results](./cookbook/explorer.md) recipe.
|
|
||||||
- `Objective` gained optional `label` and `unit` fields with
|
|
||||||
fluent builders `.with_label("…")` / `.with_unit("…")`. Existing
|
|
||||||
`Objective::minimize("…")` / `Objective::maximize("…")` are
|
|
||||||
unchanged. The serde representation is forward- and backward-
|
|
||||||
compatible (new fields are `#[serde(default)]`).
|
|
||||||
- `Problem` trait gained a default-empty
|
|
||||||
`fn decision_schema(&self) -> Vec<DecisionVariable>` method.
|
|
||||||
Existing impls compile untouched; override it to provide pretty
|
|
||||||
names / labels / units / bounds for the explorer.
|
|
||||||
- `heuropt::traits::AlgorithmInfo` — every built-in algorithm
|
|
||||||
exposes its short canonical name (`"Nsga3"`, …) and its seed.
|
|
||||||
Used by the explorer JSON export.
|
|
||||||
|
|
||||||
If you don't want any of this, no migration needed — just bump
|
|
||||||
the version.
|
|
||||||
|
|
||||||
## To 0.8
|
|
||||||
|
|
||||||
### From 0.5.x
|
|
||||||
|
|
||||||
**Additive feature only.** Bumping `heuropt = "0.8"` is enough for
|
|
||||||
any code that doesn't need async evaluation. To opt into async,
|
|
||||||
enable the new feature flag:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
heuropt = { version = "0.8", features = ["async"] }
|
|
||||||
```
|
|
||||||
|
|
||||||
What changed:
|
|
||||||
|
|
||||||
- New `async` feature flag, gated on the
|
|
||||||
[`futures`](https://crates.io/crates/futures) crate.
|
|
||||||
- New `core::async_problem::AsyncProblem` trait — mirrors `Problem`
|
|
||||||
but with `async fn evaluate_async`.
|
|
||||||
- New `core::async_problem::AsyncPartialProblem` trait — mirrors
|
|
||||||
`PartialProblem` for multi-fidelity (Hyperband) workloads.
|
|
||||||
- `run_async(&problem, concurrency).await` on **every** algorithm in
|
|
||||||
the catalog (33 of them) for IO-bound evaluations.
|
|
||||||
- New cookbook recipe: [Async evaluation](./cookbook/async.md).
|
|
||||||
|
|
||||||
### From 0.7.x
|
|
||||||
|
|
||||||
`0.7.0` introduced an experimental observability layer (`Snapshot`,
|
|
||||||
`Observer`, `run_with`, `MaxTime`, `TargetFitness`, `Stagnation`,
|
|
||||||
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`) and three
|
|
||||||
additional Pareto metrics (`igd`, `igd_plus`, `r2`). All of those
|
|
||||||
were rolled back in `0.8.0` — the design didn't bake long enough
|
|
||||||
and they shipped half-wired (`run_with` was overridden on only 3 of
|
|
||||||
35 algorithms). The `tracing` feature flag is also gone.
|
|
||||||
|
|
||||||
If your code uses any of those APIs, the migration is:
|
|
||||||
|
|
||||||
- Remove all `run_with(&problem, &mut observer)` calls and replace
|
|
||||||
with `run(&problem)`.
|
|
||||||
- Remove all uses of `Observer`, `Snapshot`, `ControlFlow`,
|
|
||||||
`MaxTime`, `MaxIterations`, `TargetFitness`, `Stagnation`,
|
|
||||||
`Periodic`, `AnyOf`, `AllOf`, `TracingObserver`.
|
|
||||||
- Remove all uses of `metrics::igd::igd`, `metrics::igd::igd_plus`,
|
|
||||||
`metrics::r2::r2`.
|
|
||||||
- Remove `Population::as_slice()` calls (the method is gone).
|
|
||||||
- Drop the `tracing` feature from your `Cargo.toml` if you had it.
|
|
||||||
|
|
||||||
Stop conditions can still be implemented by wrapping `run` in a
|
|
||||||
loop with a custom RNG-driven termination, or by wrapping
|
|
||||||
the algorithm yourself; observers may return as a public API in a
|
|
||||||
future release once the design has settled.
|
|
||||||
|
|
||||||
The async work introduced in 0.7.0 (`AsyncProblem` + `run_async`)
|
|
||||||
**survived** and is broadened in 0.8: every algorithm in the catalog
|
|
||||||
now has a `run_async` (0.7.0 only had it on three of them), and
|
|
||||||
multi-fidelity problems get a parallel `AsyncPartialProblem` trait
|
|
||||||
that Hyperband's `run_async` consumes. Existing call sites continue
|
|
||||||
to work unchanged.
|
|
||||||
|
|
||||||
## To 0.5
|
## To 0.5
|
||||||
|
|
||||||
### From 0.4.x
|
### From 0.4.x
|
||||||
@@ -165,9 +43,9 @@ from v0.3 are still numerically accurate but will run faster.
|
|||||||
|
|
||||||
### From 0.2.x
|
### From 0.2.x
|
||||||
|
|
||||||
**Additive only.** New algorithms (Bayesian Optimization, TPE,
|
**Additive only.** New algorithms (`BayesianOpt`, `Tpe`,
|
||||||
(1+1)-ES, IPOP-CMA-ES, sNES, Nelder-Mead,
|
`OnePlusOneEs`, `IpopCmaEs`, `SeparableNes`, `NelderMead`,
|
||||||
Hyperband), new operators (`LevyMutation`, `ClampToBounds`,
|
`Hyperband`), new operators (`LevyMutation`, `ClampToBounds`,
|
||||||
`ProjectToSimplex`), new traits (`PartialProblem`, `Repair<D>`).
|
`ProjectToSimplex`), new traits (`PartialProblem`, `Repair<D>`).
|
||||||
|
|
||||||
`CmaEsConfig` gained an `initial_mean: Option<Vec<f64>>` field;
|
`CmaEsConfig` gained an `initial_mean: Option<Vec<f64>>` field;
|
||||||
@@ -178,8 +56,8 @@ existing call sites need a `.. CmaEsConfig { initial_mean: None,
|
|||||||
|
|
||||||
### From 0.1.x
|
### From 0.1.x
|
||||||
|
|
||||||
**Additive.** New algorithms across the catalog (Hill Climber, SA,
|
**Additive.** New algorithms across the catalog (HillClimber, SA,
|
||||||
GA, PSO, CMA-ES, Tabu Search, Ant Colony, UMDA, TLBO, MOPSO, IBEA,
|
GA, PSO, CMA-ES, TabuSearch, AntColonyTsp, Umda, TLBO, MOPSO, IBEA,
|
||||||
SMS-EMOA, HypE, RVEA, PESA-II, ε-MOEA, AGE-MOEA, GrEA, KnEA), new
|
SMS-EMOA, HypE, RVEA, PESA-II, ε-MOEA, AGE-MOEA, GrEA, KnEA), new
|
||||||
operators (`SimulatedBinaryCrossover`, `PolynomialMutation`,
|
operators (`SimulatedBinaryCrossover`, `PolynomialMutation`,
|
||||||
`CompositeVariation`, `BoundedGaussianMutation`), and the
|
`CompositeVariation`, `BoundedGaussianMutation`), and the
|
||||||
|
|||||||
+14
-13
@@ -18,10 +18,10 @@ versions — use them at your own risk.
|
|||||||
|
|
||||||
While we are pre-1.0:
|
While we are pre-1.0:
|
||||||
|
|
||||||
- **Minor bumps (`0.10 → 0.11`) may break the public API.** The
|
- **Minor bumps (`0.5 → 0.6`) may break the public API.** The
|
||||||
CHANGELOG calls out everything that changed, and a **migration
|
CHANGELOG calls out everything that changed, and a **migration
|
||||||
guide** in this book documents the move.
|
guide** in this book documents the move.
|
||||||
- **Patch bumps (`0.10.0 → 0.10.1`) only contain bug fixes,
|
- **Patch bumps (`0.5.0 → 0.5.1`) only contain bug fixes,
|
||||||
performance improvements, and additive non-breaking features.**
|
performance improvements, and additive non-breaking features.**
|
||||||
No deprecations, no removals.
|
No deprecations, no removals.
|
||||||
|
|
||||||
@@ -29,20 +29,24 @@ While we are pre-1.0:
|
|||||||
|
|
||||||
In rough order of likelihood:
|
In rough order of likelihood:
|
||||||
|
|
||||||
1. **Algorithm config structs may gain fields.** All current configs
|
1. **`Optimizer<P>` may grow new optional methods** for callbacks,
|
||||||
|
stop conditions, and save/resume support. These will land as
|
||||||
|
methods with default implementations so existing trait impls
|
||||||
|
keep compiling, but the trait shape will be different.
|
||||||
|
2. **Algorithm config structs may gain fields.** All current configs
|
||||||
are public-field structs; adding a non-`Default` field is a
|
are public-field structs; adding a non-`Default` field is a
|
||||||
breaking change. We may switch to builder patterns to avoid this
|
breaking change. We may switch to builder patterns to avoid this
|
||||||
class of break, or we may add `#[non_exhaustive]`.
|
class of break, or we may add `#[non_exhaustive]`.
|
||||||
2. **Some operators may move between `operators` and `pareto`** as
|
3. **The `Snapshot`, `Observer`, and `Checkpoint` types** (planned
|
||||||
|
for a future release) will land as new public surfaces.
|
||||||
|
4. **Some operators may move between `operators` and `pareto`** as
|
||||||
the boundary between "things that produce candidates" and "Pareto
|
the boundary between "things that produce candidates" and "Pareto
|
||||||
utilities" gets clearer.
|
utilities" gets clearer.
|
||||||
|
|
||||||
What is **not** likely to change:
|
What is **not** likely to change:
|
||||||
|
|
||||||
- The `Problem` trait shape.
|
- The `Problem` trait shape.
|
||||||
- The `AsyncProblem` / `AsyncPartialProblem` trait shapes.
|
|
||||||
- The `Variation` / `Initializer` / `Repair` traits.
|
- The `Variation` / `Initializer` / `Repair` traits.
|
||||||
- The `Optimizer<P>` trait — single `run` method, no callbacks.
|
|
||||||
- The `Evaluation` / `Candidate` / `Population` / `OptimizationResult`
|
- The `Evaluation` / `Candidate` / `Population` / `OptimizationResult`
|
||||||
data types.
|
data types.
|
||||||
- The seeded determinism property.
|
- The seeded determinism property.
|
||||||
@@ -56,12 +60,12 @@ Across minor versions, output may change if an algorithm's
|
|||||||
implementation changes (e.g. a perf rewrite that reorders
|
implementation changes (e.g. a perf rewrite that reorders
|
||||||
floating-point operations, or a new feature that changes the
|
floating-point operations, or a new feature that changes the
|
||||||
RNG-consumption pattern). The CHANGELOG calls this out explicitly
|
RNG-consumption pattern). The CHANGELOG calls this out explicitly
|
||||||
when it happens. As of v0.8, the entire history of perf
|
when it happens. As of v0.5, the entire history of perf optimizations
|
||||||
optimizations has been bit-identical against the v0.3.0 reference.
|
has been bit-identical against the v0.3.0 reference.
|
||||||
|
|
||||||
## MSRV (minimum supported Rust version)
|
## MSRV (minimum supported Rust version)
|
||||||
|
|
||||||
heuropt's MSRV is **1.85** as of v0.10. This is tested in CI against
|
heuropt's MSRV is **1.85** as of v0.5. This is tested in CI against
|
||||||
every PR.
|
every PR.
|
||||||
|
|
||||||
MSRV bumps are treated as patch-bump-eligible (they don't break the
|
MSRV bumps are treated as patch-bump-eligible (they don't break the
|
||||||
@@ -75,9 +79,6 @@ The current optional features:
|
|||||||
- `serde` — adds `Serialize` / `Deserialize` derives on the core data
|
- `serde` — adds `Serialize` / `Deserialize` derives on the core data
|
||||||
types.
|
types.
|
||||||
- `parallel` — rayon-backed parallel population evaluation.
|
- `parallel` — rayon-backed parallel population evaluation.
|
||||||
- `async` — `AsyncProblem` + `AsyncPartialProblem` traits, plus a
|
|
||||||
`run_async` method on every algorithm in the catalog, for
|
|
||||||
IO-bound evaluations.
|
|
||||||
|
|
||||||
Features added in 0.x can be renamed or removed in any minor bump
|
Features added in 0.x can be renamed or removed in any minor bump
|
||||||
that documents the change. Removing a feature is treated like a
|
that documents the change. Removing a feature is treated like a
|
||||||
@@ -89,7 +90,7 @@ breaking API change.
|
|||||||
- **Migration guides** — per-release, in this book at
|
- **Migration guides** — per-release, in this book at
|
||||||
[migration](./migration.md).
|
[migration](./migration.md).
|
||||||
- **GitHub releases** — each tag has release notes.
|
- **GitHub releases** — each tag has release notes.
|
||||||
- **Watch the repo** — https://git.swaits.com/swaits/heuropt — to be
|
- **Watch the repo** — https://github.com/swaits/heuropt — to be
|
||||||
notified of new releases.
|
notified of new releases.
|
||||||
|
|
||||||
[`heuropt::prelude`]: https://docs.rs/heuropt/latest/heuropt/prelude/index.html
|
[`heuropt::prelude`]: https://docs.rs/heuropt/latest/heuropt/prelude/index.html
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,84 +0,0 @@
|
|||||||
//! Async evaluation example: optimize hyperparameters where each
|
|
||||||
//! evaluation is an awaitable (simulated HTTP) call.
|
|
||||||
//!
|
|
||||||
//! Demonstrates:
|
|
||||||
//! - Implementing [`AsyncProblem`].
|
|
||||||
//! - Driving the optimizer through `tokio` with bounded concurrency.
|
|
||||||
//! - Comparing wall-clock time at concurrency = 1 vs 8.
|
|
||||||
//!
|
|
||||||
//! Run with: `cargo run --release --features async --example async_eval`
|
|
||||||
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use heuropt::core::async_problem::AsyncProblem;
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct RemoteService;
|
|
||||||
|
|
||||||
impl AsyncProblem for RemoteService {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("loss")])
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn evaluate_async(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
// Simulate a 20 ms remote-service round-trip per evaluation.
|
|
||||||
// The compute itself is ~free; the latency is the bottleneck.
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
|
||||||
let loss: f64 = x.iter().map(|v| v * v).sum();
|
|
||||||
Evaluation::new(vec![loss])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
|
||||||
async fn main() {
|
|
||||||
let bounds = vec![(-1.0_f64, 1.0_f64); 4];
|
|
||||||
let problem = RemoteService;
|
|
||||||
|
|
||||||
println!("RandomSearch with 200 evaluations (20 ms each)");
|
|
||||||
println!();
|
|
||||||
|
|
||||||
for &concurrency in &[1_usize, 4, 16] {
|
|
||||||
let mut opt = RandomSearch::new(
|
|
||||||
RandomSearchConfig {
|
|
||||||
iterations: 100,
|
|
||||||
batch_size: 2,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds.clone()),
|
|
||||||
);
|
|
||||||
let started = Instant::now();
|
|
||||||
let result = opt.run_async(&problem, concurrency).await;
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
println!(
|
|
||||||
"concurrency = {:>2} elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
|
|
||||||
concurrency,
|
|
||||||
elapsed.as_millis(),
|
|
||||||
result.best.unwrap().evaluation.objectives[0],
|
|
||||||
result.evaluations,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!();
|
|
||||||
println!("DifferentialEvolution at concurrency=8");
|
|
||||||
let started = Instant::now();
|
|
||||||
let mut de = DifferentialEvolution::new(
|
|
||||||
DifferentialEvolutionConfig {
|
|
||||||
population_size: 8,
|
|
||||||
generations: 10,
|
|
||||||
differential_weight: 0.5,
|
|
||||||
crossover_probability: 0.9,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds.clone()),
|
|
||||||
);
|
|
||||||
let result = de.run_async(&problem, 8).await;
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
println!(
|
|
||||||
"elapsed = {:>5} ms best loss = {:>8.5} evaluations = {}",
|
|
||||||
elapsed.as_millis(),
|
|
||||||
result.best.unwrap().evaluation.objectives[0],
|
|
||||||
result.evaluations,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
//! Bi-objective TSP using NSGA-II on the **Kroak/Krobk** instance family
|
|
||||||
//! (Lust & Teghem, 2010).
|
|
||||||
//!
|
|
||||||
//! Two TSP instances over the **same** set of cities define two distance
|
|
||||||
//! matrices A and B; the search trades off tour length under A versus tour
|
|
||||||
//! length under B. This is the canonical multi-objective combinatorial
|
|
||||||
//! benchmark, and it gives a rich Pareto front because the geographies
|
|
||||||
//! disagree.
|
|
||||||
//!
|
|
||||||
//! The instance embedded here is **KroAB-25**: the first 25 cities of
|
|
||||||
//! TSPLIB KroA100 and KroB100 (both EUC_2D). Same city *indices*, two
|
|
||||||
//! coordinate listings.
|
|
||||||
//!
|
|
||||||
//! - **Algorithm**: [`Nsga2`].
|
|
||||||
//! - **Variation**: [`EdgeRecombinationCrossover`] (the gold-standard TSP
|
|
||||||
//! crossover) piped into [`InversionMutation`] via [`CompositeVariation`].
|
|
||||||
//! - **Initializer**: [`ShuffledPermutation`].
|
|
||||||
//! - **Encoding**: strict permutation of `[0..25)`.
|
|
||||||
//!
|
|
||||||
//! Sources:
|
|
||||||
//! - TSPLIB95 KroA100 / KroB100 (Reinelt, 1991).
|
|
||||||
//! - Lust & Teghem (2010), "The Multiobjective Traveling Salesman Problem:
|
|
||||||
//! A Survey and a New Approach."
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example btsp_kroab
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
/// First 25 cities of TSPLIB KroA100 (EUC_2D).
|
|
||||||
const KROA_25: [(f64, f64); 25] = [
|
|
||||||
(1380.0, 939.0),
|
|
||||||
(2848.0, 96.0),
|
|
||||||
(3510.0, 1671.0),
|
|
||||||
(457.0, 334.0),
|
|
||||||
(3888.0, 666.0),
|
|
||||||
(984.0, 965.0),
|
|
||||||
(2721.0, 1482.0),
|
|
||||||
(1286.0, 525.0),
|
|
||||||
(2716.0, 1432.0),
|
|
||||||
(738.0, 1325.0),
|
|
||||||
(1251.0, 1832.0),
|
|
||||||
(2728.0, 1698.0),
|
|
||||||
(3815.0, 169.0),
|
|
||||||
(3683.0, 1533.0),
|
|
||||||
(1247.0, 1945.0),
|
|
||||||
(123.0, 862.0),
|
|
||||||
(1234.0, 1946.0),
|
|
||||||
(252.0, 1240.0),
|
|
||||||
(611.0, 673.0),
|
|
||||||
(2576.0, 1676.0),
|
|
||||||
(928.0, 1700.0),
|
|
||||||
(53.0, 857.0),
|
|
||||||
(1807.0, 1711.0),
|
|
||||||
(274.0, 1420.0),
|
|
||||||
(2574.0, 946.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// First 25 cities of TSPLIB KroB100 (EUC_2D).
|
|
||||||
const KROB_25: [(f64, f64); 25] = [
|
|
||||||
(3140.0, 1401.0),
|
|
||||||
(556.0, 1056.0),
|
|
||||||
(3675.0, 1522.0),
|
|
||||||
(1182.0, 1853.0),
|
|
||||||
(3595.0, 1340.0),
|
|
||||||
(1936.0, 953.0),
|
|
||||||
(2722.0, 1311.0),
|
|
||||||
(2839.0, 2055.0),
|
|
||||||
(2253.0, 1242.0),
|
|
||||||
(3142.0, 1591.0),
|
|
||||||
(627.0, 1336.0),
|
|
||||||
(936.0, 211.0),
|
|
||||||
(4014.0, 471.0),
|
|
||||||
(1376.0, 1452.0),
|
|
||||||
(3289.0, 593.0),
|
|
||||||
(1453.0, 67.0),
|
|
||||||
(1014.0, 1944.0),
|
|
||||||
(2811.0, 1080.0),
|
|
||||||
(3010.0, 1290.0),
|
|
||||||
(1817.0, 1517.0),
|
|
||||||
(510.0, 458.0),
|
|
||||||
(1717.0, 1693.0),
|
|
||||||
(1252.0, 1633.0),
|
|
||||||
(1693.0, 1374.0),
|
|
||||||
(539.0, 1378.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
const N_CITIES: usize = 25;
|
|
||||||
|
|
||||||
/// TSPLIB EUC_2D distance: rounded Euclidean.
|
|
||||||
fn euc2d_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
|
|
||||||
let n = coords.len();
|
|
||||||
let mut d = vec![vec![0.0_f64; n]; n];
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
let dx = coords[i].0 - coords[j].0;
|
|
||||||
let dy = coords[i].1 - coords[j].1;
|
|
||||||
let dij = (dx * dx + dy * dy).sqrt().round();
|
|
||||||
d[i][j] = dij;
|
|
||||||
d[j][i] = dij;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BTspKroAB {
|
|
||||||
dist_a: Vec<Vec<f64>>,
|
|
||||||
dist_b: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BTspKroAB {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
dist_a: euc2d_matrix(&KROA_25),
|
|
||||||
dist_b: euc2d_matrix(&KROB_25),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut total = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
total += d[tour[i]][tour[(i + 1) % n]];
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for BTspKroAB {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("length_A"),
|
|
||||||
Objective::minimize("length_B"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![
|
|
||||||
Self::tour_length(&self.dist_a, tour),
|
|
||||||
Self::tour_length(&self.dist_b, tour),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
(0..N_CITIES)
|
|
||||||
.map(|k| DecisionVariable::new(format!("tour_position_{k}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let problem = BTspKroAB::new();
|
|
||||||
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 200,
|
|
||||||
generations: 600,
|
|
||||||
seed: 11,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n: N_CITIES },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: EdgeRecombinationCrossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("bTSP KroAB-25 — bi-objective TSP via NSGA-II");
|
|
||||||
println!("Source: TSPLIB95 KroA100/KroB100 (first 25 cities), Lust & Teghem bTSP family");
|
|
||||||
println!();
|
|
||||||
println!("Total evaluations: {}", result.evaluations);
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Print a spread sample of the front (no more than 12 rows).
|
|
||||||
let stride = (front.len() / 12).max(1);
|
|
||||||
println!(" length_A length_B");
|
|
||||||
let mut printed = 0_usize;
|
|
||||||
for (i, c) in front.iter().enumerate() {
|
|
||||||
if i % stride == 0 || i + 1 == front.len() {
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
println!(" {:>8.0} {:>8.0}", o[0], o[1]);
|
|
||||||
printed += 1;
|
|
||||||
if printed >= 12 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
if let (Some(corner_a), Some(corner_b)) = (front.first(), front.last()) {
|
|
||||||
println!(
|
|
||||||
"A-corner: A={:.0}, B={:.0}",
|
|
||||||
corner_a.evaluation.objectives[0], corner_a.evaluation.objectives[1]
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"B-corner: A={:.0}, B={:.0}",
|
|
||||||
corner_b.evaluation.objectives[0], corner_b.evaluation.objectives[1]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hypervolume vs. a generous reference point. Pick a reference well past
|
|
||||||
// the worst values likely to appear so different runs can be compared.
|
|
||||||
let ref_point = [40_000.0, 40_000.0];
|
|
||||||
let owned: Vec<Candidate<Vec<usize>>> = result.pareto_front.to_vec();
|
|
||||||
let hv = hypervolume_2d(&owned, &problem.objectives(), ref_point);
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"Hypervolume vs. reference ({}, {}): {:.0}",
|
|
||||||
ref_point[0], ref_point[1], hv
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+75
-266
@@ -1,333 +1,142 @@
|
|||||||
# `compare` example — reference output
|
# `compare` example — reference output
|
||||||
|
|
||||||
Snapshot from `cargo run --release --example compare`, refreshed 2026-05-14
|
Snapshot from `cargo run --release --example compare` after the v0.4.0
|
||||||
for heuropt v0.10.0. 10 seeds per algorithm per problem.
|
perf pass landed (2026-05-05). 10 seeds per algorithm per problem.
|
||||||
|
|
||||||
Each table is **sorted best-first** by its primary quality metric. The
|
The **quality metrics** (hypervolume / spacing / mean L2 / mean dist /
|
||||||
live terminal output uses ASCII `+/-` for the mean ± std cells (so column
|
front size) are bit-identical to the v0.3.0 snapshot — the v0.4.0
|
||||||
alignment can't be broken by a terminal that renders `±` at an odd
|
optimization work was strictly CPU-time, never algorithmic. The **ms
|
||||||
width); this doc uses `±` since markdown renders it fine.
|
columns** reflect the v0.4.0 numbers; total compare-harness wall-clock
|
||||||
|
dropped from ~18.6 s to ~5.7 s (3.27× faster).
|
||||||
|
|
||||||
The **continuous-problem quality metrics** are bit-identical to the
|
Wall-clock numbers are from the development machine and will vary;
|
||||||
v0.3.0–v0.4.0 snapshots — every optimization pass so far (including the
|
the *relative* numbers across algorithms are the interesting part.
|
||||||
Phase B CPU work) has been verified bit-identical by the `run()` snapshot
|
|
||||||
tests. The **ms columns** are the post-Phase-B numbers; SMS-EMOA on DTLZ2
|
|
||||||
in particular fell ~2.7× from the `hypervolume_nd` rework.
|
|
||||||
|
|
||||||
This refresh also adds three **combinatorial / sequencing** problems —
|
|
||||||
TSP, job-shop scheduling, and a bi-objective knapsack — which exercise the
|
|
||||||
permutation and bitstring operators and a different algorithm roster (the
|
|
||||||
real-vector methods can't run them) — and three **many-objective**
|
|
||||||
problems (DTLZ at 4, 10, and 8 objectives) that push past where Pareto
|
|
||||||
dominance still discriminates.
|
|
||||||
|
|
||||||
Wall-clock numbers are from the development machine and will vary; the
|
|
||||||
*relative* numbers across algorithms are the interesting part.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ZDT1 (dim=30, 25000 evals/run × 10 seeds)
|
## ZDT1 (dim=30, 25000 evals/run × 10 seeds)
|
||||||
|
|
||||||
Zitzler-Deb-Thiele 2-objective benchmark: 30 real variables, one smooth
|
Two-objective benchmark with a smooth Pareto front along
|
||||||
convex Pareto front `f₂ = 1 − √f₁`. Hard because 29 of 30 variables must
|
`f₂ = 1 − √f₁`. Hypervolume reference point: `[11, 11]`.
|
||||||
collapse to 0 before the front is even reachable, and only then can the
|
|
||||||
population spread along it. Optimum: mean L2 → 0 (the front is known
|
|
||||||
exactly). Sorted by hypervolume (reference `[11, 11]`).
|
|
||||||
|
|
||||||
| algorithm | hypervolume ↑ | spacing ↓ | mean L2 ↓ | front | ms |
|
| algorithm | hypervolume ↑ | spacing ↓ | mean L2 ↓ | front | ms |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| MOPSO | **120.6149 ± 0.0529** | 0.0125 ± 0.0025 | **0.0005 ± 0.0001** | 100 | 80 |
|
| RandomSearch | 99.5691 ± 0.94 | 0.0937 ± 0.03 | 2.3621 ± 0.14 | 28 | 94 |
|
||||||
| IBEA | 120.0167 ± 0.3112 | 0.0130 ± 0.0027 | 0.0448 ± 0.0168 | 73 | 130 |
|
| PAES | 104.1887 ± 0.90 | 0.0351 ± 0.01 | 1.3195 ± 0.06 | 33 | 30 |
|
||||||
| MOEA/D | 119.9450 ± 0.4953 | 0.0118 ± 0.0013 | 0.0065 ± 0.0020 | 96 | 27 |
|
| MOPSO | **120.6149 ± 0.05** | 0.0125 ± 0.00 | **0.0005 ± 0.00** | 100 | 89 |
|
||||||
| PESA-II | 119.3670 ± 0.3261 | **0.0095 ± 0.0011** | 0.0802 ± 0.0354 | 100 | 67 |
|
| SPEA2 | 118.0823 ± 0.60 | 0.0111 ± 0.00 | 0.2408 ± 0.05 | 97 | 234 |
|
||||||
| eps-MOEA | 118.8742 ± 0.6835 | 0.0167 ± 0.0058 | 0.0493 ± 0.0227 | 45 | 46 |
|
| PESA-II | 119.3670 ± 0.33 | **0.0095 ± 0.00** | 0.0802 ± 0.04 | 100 | 73 |
|
||||||
| NSGA-II | 118.3336 ± 0.7750 | 0.0112 ± 0.0022 | 0.1891 ± 0.0599 | 96 | 40 |
|
| ε-MOEA | 118.8742 ± 0.68 | 0.0167 ± 0.01 | 0.0493 ± 0.02 | 45 | 50 |
|
||||||
| SPEA2 | 118.0823 ± 0.5973 | 0.0111 ± 0.0023 | 0.2408 ± 0.0509 | 97 | 226 |
|
| IBEA | 120.0167 ± 0.31 | 0.0130 ± 0.00 | 0.0448 ± 0.02 | 73 | 138 |
|
||||||
| NSGA-III | 115.1612 ± 0.4745 | 0.0139 ± 0.0029 | 0.4314 ± 0.0582 | 86 | 47 |
|
| HypE | 105.6489 ± 0.98 | 0.0266 ± 0.01 | 1.4820 ± 0.10 | 72 | 38 |
|
||||||
| RVEA | 111.7151 ± 1.8195 | 0.0308 ± 0.0099 | 0.8399 ± 0.1569 | 47 | 62 |
|
| SMS-EMOA | 102.8871 ± 1.05 | 0.0263 ± 0.00 | 1.4937 ± 0.12 | 40 | 67 |
|
||||||
| HypE | 105.6489 ± 0.9789 | 0.0266 ± 0.0053 | 1.4820 ± 0.1003 | 72 | 30 |
|
| RVEA | 111.7151 ± 1.82 | 0.0308 ± 0.01 | 0.8399 ± 0.16 | 47 | 65 |
|
||||||
| PAES | 104.1887 ± 0.8953 | 0.0351 ± 0.0067 | 1.3195 ± 0.0558 | 33 | 27 |
|
| NSGA-II | 118.3336 ± 0.78 | 0.0112 ± 0.00 | 0.1891 ± 0.06 | 96 | 67 |
|
||||||
| SMS-EMOA | 102.8871 ± 1.0543 | 0.0263 ± 0.0039 | 1.4937 ± 0.1192 | 40 | 54 |
|
| NSGA-III | 115.1612 ± 0.47 | 0.0139 ± 0.00 | 0.4314 ± 0.06 | 86 | 70 |
|
||||||
| RandomSearch | 99.5691 ± 0.9383 | 0.0937 ± 0.0347 | 2.3621 ± 0.1428 | 28 | 88 |
|
| MOEA/D | 119.9450 ± 0.50 | 0.0118 ± 0.00 | 0.0065 ± 0.00 | 96 | 28 |
|
||||||
|
|
||||||
**MOPSO and MOEA/D dominate** convergence (mean L2 to true front ≤ 0.01).
|
**MOPSO and MOEA/D dominate** convergence (mean L2 to true front ≤ 0.01).
|
||||||
PESA-II edges spacing.
|
PESA-II edges spacing.
|
||||||
|
|
||||||
## ZDT3 (dim=30, 25000 evals × 10 seeds)
|
## ZDT3 (dim=30, 25000 evals × 10 seeds)
|
||||||
|
|
||||||
Zitzler-Deb-Thiele 2-objective with a **disconnected** front: five
|
Disconnected Pareto front; tests an algorithm's ability to maintain
|
||||||
separate arcs rather than one curve. Hard because an algorithm has to
|
spread across gaps.
|
||||||
discover and populate every arc while not stranding solutions in the
|
|
||||||
dominated gaps between them.
|
|
||||||
|
|
||||||
| algorithm | hypervolume ↑ | spacing ↓ | front | ms |
|
| algorithm | hypervolume ↑ | spacing ↓ | front | ms |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **IBEA** | **126.2072 ± 1.2280** | 0.0164 ± 0.0036 | 48 | 126 |
|
| NSGA-II | 123.1826 ± 1.58 | 0.0092 ± 0.00 | 98 | 68 |
|
||||||
| MOEA/D | 125.2413 ± 2.1647 | 0.0198 ± 0.0043 | 92 | 26 |
|
| MOEA/D | 125.2413 ± 2.16 | 0.0198 ± 0.00 | 92 | 28 |
|
||||||
| NSGA-II | 123.1826 ± 1.5829 | **0.0092 ± 0.0020** | 98 | 39 |
|
| **IBEA** | **126.2072 ± 1.23** | 0.0164 ± 0.00 | 48 | 135 |
|
||||||
| AGE-MOEA | 119.5132 ± 1.2732 | 0.0136 ± 0.0023 | 90 | 170 |
|
| AGE-MOEA | 119.5132 ± 1.27 | 0.0136 ± 0.00 | 90 | 199 |
|
||||||
| KnEA | 117.2180 ± 0.7027 | 0.0147 ± 0.0049 | 79 | 32 |
|
|
||||||
|
|
||||||
The **geometry-aware methods finish last** on the disconnected front:
|
## DTLZ2 (3-obj, dim=12, 30000 evals × 10 seeds)
|
||||||
AGE-MOEA and KnEA both trail the dominance- and decomposition-based
|
|
||||||
methods. Estimating a single front geometry — or chasing knee points —
|
|
||||||
doesn't help when the front is in pieces; IBEA's indicator-based
|
|
||||||
selection wins here.
|
|
||||||
|
|
||||||
## DTLZ2 (3-obj, dim=12, 30000 evals/run × 10 seeds)
|
Spherical Pareto front. Mean dist = `|‖f‖ − 1|`.
|
||||||
|
|
||||||
Deb-Thiele-Laumanns-Zitzler 3-objective; the Pareto front is the
|
|
||||||
unit-sphere octant (`Σf² = 1, all f ≥ 0`) — a curved 2-D surface embedded
|
|
||||||
in 3-D objective space. `mean dist = |‖f‖ − 1|`, so 0 means perfectly on
|
|
||||||
the sphere (the known optimum).
|
|
||||||
|
|
||||||
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **IBEA** | **0.0014 ± 0.0002** | 0.0607 ± 0.0047 | 87 | 148 |
|
| RandomSearch | 0.3949 ± 0.02 | 0.0797 ± 0.01 | 239 | 520 |
|
||||||
| MOEA/D | 0.0037 ± 0.0003 | 0.0886 ± 0.0024 | 78 | 23 |
|
| MOPSO | 0.0566 ± 0.00 | 0.0687 ± 0.01 | 100 | 71 |
|
||||||
| HypE | 0.0113 ± 0.0033 | **0.0269 ± 0.0172** | 80 | 41 |
|
| NSGA-II | 0.0332 ± 0.01 | 0.0577 ± 0.01 | 92 | 104 |
|
||||||
| NSGA-III | 0.0197 ± 0.0015 | 0.0735 ± 0.0052 | 92 | 91 |
|
| SPEA2 | 0.0368 ± 0.00 | **0.0288 ± 0.00** | 92 | 534 |
|
||||||
| eps-MOEA | 0.0325 ± 0.0104 | 0.0572 ± 0.0170 | 136 | 88 |
|
| PESA-II | 0.0395 ± 0.00 | 0.0616 ± 0.01 | 100 | 396 |
|
||||||
| NSGA-II | 0.0332 ± 0.0068 | 0.0577 ± 0.0109 | 92 | 60 |
|
| ε-MOEA | 0.0325 ± 0.01 | 0.0572 ± 0.02 | 136 | 89 |
|
||||||
| SPEA2 | 0.0368 ± 0.0021 | 0.0288 ± 0.0038 | 92 | 530 |
|
| **IBEA** | **0.0014 ± 0.00** | 0.0607 ± 0.00 | 87 | 156 |
|
||||||
| PESA-II | 0.0395 ± 0.0033 | 0.0616 ± 0.0051 | 100 | 372 |
|
| HypE | 0.0113 ± 0.00 | 0.0269 ± 0.02 | 80 | 53 |
|
||||||
| SMS-EMOA | 0.0484 ± 0.0134 | 0.0764 ± 0.0081 | 40 | 483 |
|
| SMS-EMOA | 0.0484 ± 0.01 | 0.0764 ± 0.01 | 40 | 1218 |
|
||||||
| RVEA | 0.0510 ± 0.0044 | 0.0631 ± 0.0024 | 68 | 66 |
|
| RVEA | 0.0510 ± 0.00 | 0.0631 ± 0.00 | 68 | 73 |
|
||||||
| MOPSO | 0.0566 ± 0.0048 | 0.0687 ± 0.0084 | 100 | 66 |
|
| NSGA-III | 0.0197 ± 0.00 | 0.0735 ± 0.01 | 92 | 137 |
|
||||||
| RandomSearch | 0.3949 ± 0.0152 | 0.0797 ± 0.0083 | 239 | 530 |
|
| MOEA/D | 0.0037 ± 0.00 | 0.0886 ± 0.00 | 78 | 24 |
|
||||||
|
|
||||||
**IBEA wins decisively** (14× closer to the true front than NSGA-III).
|
**IBEA wins decisively** (15× closer to the true front than NSGA-III).
|
||||||
SMS-EMOA's wall-clock fell ~2.7× from the v0.4.0 snapshot — the
|
|
||||||
`hypervolume_nd` rework.
|
|
||||||
|
|
||||||
## DTLZ1 (3-obj, dim=7, 30000 evals × 10 seeds)
|
## DTLZ1 (3-obj, dim=7, 30000 evals × 10 seeds)
|
||||||
|
|
||||||
Deb-Thiele-Laumanns-Zitzler 3-objective; the Pareto front is the linear
|
Linear simplex Pareto front (`Σf = 0.5`).
|
||||||
simplex `Σf = 0.5` in the positive octant. Hard because a deceptive
|
|
||||||
multimodal `g` term riddles the approach with a huge number of local
|
|
||||||
fronts — only fully-converged runs land on the simplex.
|
|
||||||
|
|
||||||
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|
| algorithm | mean dist ↓ | spacing ↓ | front | ms |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| **GrEA** | **1.7725 ± 0.9897** | **0.0719 ± 0.0438** | 72 | 62 |
|
| NSGA-III | 5.9130 ± 2.82 | 0.4375 ± 0.22 | 92 | 133 |
|
||||||
| MOEA/D | 2.8022 ± 1.7807 | 0.2279 ± 0.2247 | 78 | 22 |
|
| MOEA/D | 2.8022 ± 1.78 | 0.2279 ± 0.22 | 78 | 21 |
|
||||||
| AGE-MOEA | 4.5395 ± 2.2114 | 0.3930 ± 0.2864 | 90 | 193 |
|
| AGE-MOEA | 4.5395 ± 2.21 | 0.3930 ± 0.29 | 90 | 247 |
|
||||||
| NSGA-III | 5.9130 ± 2.8212 | 0.4375 ± 0.2212 | 92 | 81 |
|
| **GrEA** | **1.7725 ± 0.99** | **0.0719 ± 0.04** | 72 | 104 |
|
||||||
|
|
||||||
**GrEA shines on linear fronts** — the grid-based niching matches the
|
**GrEA shines on linear fronts** — the grid-based niching matches the
|
||||||
geometry better than reference points.
|
geometry better than reference points.
|
||||||
|
|
||||||
## Rastrigin (dim=5, 50000 evals/run × 10 seeds)
|
## Rastrigin (dim=5, 50000 evals/run × 10 seeds)
|
||||||
|
|
||||||
Highly multimodal trap: `f = 10n + Σ(xᵢ² − 10·cos(2π·xᵢ))`. Hard because a
|
Multimodal trap. Global minimum f = 0 at the origin.
|
||||||
near-quadratic global bowl is overlaid with ~10⁵ regularly spaced local
|
|
||||||
minima — any greedy step lands in the nearest dimple. Global optimum
|
|
||||||
`f = 0` at the origin.
|
|
||||||
|
|
||||||
| algorithm | best f | ms |
|
| algorithm | best f | ms |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **(1+1)-ES** | **0.0000e0 ± 0.00e0** | 4 |
|
| RandomSearch | 1.1064e1 ± 2.54 | 14 |
|
||||||
| **DE** | **0.0000e0 ± 0.00e0** | 6 |
|
| HillClimber | 1.5966e1 ± 6.25 | 6 |
|
||||||
| GA | 7.0913e-8 ± 5.50e-8 | 15 |
|
| **(1+1)-ES** | **0.0000e0 ± 0.00** | 4 |
|
||||||
| NSGA-II | 4.9270e-5 ± 5.04e-5 | 60 |
|
| SimulatedAnneal | 3.8540e0 ± 1.48 | 7 |
|
||||||
| IPOP-CMA-ES | 1.3423e-1 ± 2.71e-1 | 61 |
|
| PAES | 1.5966e1 ± 6.25 | 10 |
|
||||||
|
| GA | 7.0913e-8 ± 5.50e-8 | 16 |
|
||||||
| PSO | 7.9598e-1 ± 8.67e-1 | 5 |
|
| PSO | 7.9598e-1 ± 8.67e-1 | 5 |
|
||||||
| CMA-ES | 2.3453e0 ± 1.49e0 | 10 |
|
| NSGA-II | 4.9270e-5 ± 5.04e-5 | 83 |
|
||||||
| SimulatedAnneal | 3.8540e0 ± 1.48e0 | 7 |
|
| **DE** | **0.0000e0 ± 0.00** | 6 |
|
||||||
| RandomSearch | 1.1064e1 ± 2.54e0 | 14 |
|
| CMA-ES | 2.3453e0 ± 1.49 | 11 |
|
||||||
| HillClimber | 1.5966e1 ± 6.25e0 | 6 |
|
| **IPOP-CMA-ES** | 1.3423e-1 ± 2.71e-1 | 66 |
|
||||||
| PAES | 1.5966e1 ± 6.25e0 | 10 |
|
|
||||||
|
|
||||||
(1+1)-ES and DE tie for `f = 0`. **IPOP-CMA-ES drops vanilla CMA-ES from
|
(1+1)-ES and DE tie for f = 0. **IPOP-CMA-ES drops vanilla CMA-ES from
|
||||||
2.35 → 0.13** — the restart logic does what it should.
|
2.35 → 0.13** — the restart logic does what it should.
|
||||||
|
|
||||||
## Rosenbrock (dim=5, 30000 evals × 10 seeds)
|
## Rosenbrock (dim=5, 30000 evals × 10 seeds)
|
||||||
|
|
||||||
Rosenbrock's banana valley: `f = Σ(100·(xᵢ₊₁ − xᵢ²)² + (1 − xᵢ)²)`. Hard
|
Smooth non-convex valley.
|
||||||
because the minimum sits in a long, bent, near-flat valley — easy to
|
|
||||||
enter, very slow to crawl along to the tip. Global optimum `f = 0` at the
|
|
||||||
all-ones point.
|
|
||||||
|
|
||||||
| algorithm | best f | ms |
|
| algorithm | best f | ms |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Nelder-Mead** | **0.0000e0 ± 0.00e0** | 1 |
|
|
||||||
| CMA-ES | 3.6207e-29 ± 2.35e-29 | 5 |
|
|
||||||
| TLBO | 1.8458e-3 ± 1.91e-3 | 1 |
|
|
||||||
| DE | 3.3345e-1 ± 3.01e-1 | 2 |
|
| DE | 3.3345e-1 ± 3.01e-1 | 2 |
|
||||||
| PSO | 8.2124e-1 ± 1.58e0 | 2 |
|
| PSO | 8.2124e-1 ± 1.58e0 | 2 |
|
||||||
|
| **CMA-ES** | **3.6207e-29 ± 2.35e-29** | 5 |
|
||||||
|
| TLBO | 1.8458e-3 ± 1.91e-3 | 1 |
|
||||||
| (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 |
|
| (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 |
|
||||||
| BO (60 evals) | 3.1725e3 ± 2.92e3 | 39 |
|
| **Nelder-Mead** | **0.0000e0 ± 0.00** | 1 |
|
||||||
|
| BO (60 evals) | 3.1725e3 ± 2.92e3 | 40 |
|
||||||
|
|
||||||
Nelder-Mead **= 0 exactly**, CMA-ES at machine epsilon. BO at only 60
|
Nelder-Mead **= 0 exactly**, CMA-ES at machine epsilon. BO at only 60
|
||||||
evaluations is honestly bad on 5-D Rosenbrock (no kernel hyperparameter
|
evaluations is honestly bad on 5-D Rosenbrock (no kernel
|
||||||
tuning) — included as a reminder that BO needs more evaluations than a
|
hyperparameter tuning) — included as a reminder that BO needs more
|
||||||
smooth problem actually requires for these other methods.
|
evaluations than a smooth problem actually requires for these other
|
||||||
|
methods.
|
||||||
|
|
||||||
## Ackley (dim=5, 30000 evals × 10 seeds)
|
## Ackley (dim=5, 30000 evals × 10 seeds)
|
||||||
|
|
||||||
Ackley's function: a near-flat outer plateau with shallow ripples
|
Smoother multimodal landscape than Rastrigin.
|
||||||
surrounding a single deep, narrow global basin. Hard because the gradient
|
|
||||||
is almost zero far from the optimum, giving local search little to
|
|
||||||
follow. Global optimum `f = 0` at the origin.
|
|
||||||
|
|
||||||
| algorithm | best f | ms |
|
| algorithm | best f | ms |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DE** | **4.4409e-16 ± 0.00e0** | 3 |
|
| DE | 4.4409e-16 ± 0.00 | 4 |
|
||||||
| PSO | 1.5099e-15 ± 1.63e-15 | 3 |
|
| PSO | 1.5099e-15 ± 1.63e-15 | 3 |
|
||||||
| CMA-ES | 1.5099e-15 ± 1.63e-15 | 5 |
|
| CMA-ES | 1.5099e-15 ± 1.63e-15 | 6 |
|
||||||
| TLBO | 2.2204e-15 ± 1.78e-15 | 2 |
|
| TLBO | 2.2204e-15 ± 1.78e-15 | 2 |
|
||||||
| BO (60 evals) | 1.9622e1 ± 1.23e0 | 38 |
|
| BO (60 evals) | 1.9622e1 ± 1.23 | 40 |
|
||||||
|
|
||||||
All conventional methods reach machine precision. BO at 60 evals
|
All conventional methods reach machine precision. BO at 60 evals
|
||||||
struggles — same caveat as Rosenbrock.
|
struggles — same caveat as Rosenbrock.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TSP ring-15 (8000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
15 equally-spaced cities on the unit circle; minimize the closed tour
|
|
||||||
length. The space is `(15−1)!/2` distinct tours, but cities in convex
|
|
||||||
position have no 2-opt local optima — so this instance cleanly separates
|
|
||||||
methods with good neighbourhood moves (inversion = 2-opt) from blind
|
|
||||||
recombination / sampling. Known optimum (the polygon perimeter):
|
|
||||||
**6.2374**.
|
|
||||||
|
|
||||||
| algorithm | tour length ↓ | ms |
|
|
||||||
|---|---|---|
|
|
||||||
| **HillClimber** | **6.2374 ± 0.0000** | 0 |
|
|
||||||
| **SimulatedAnneal** | **6.2374 ± 0.0000** | 0 |
|
|
||||||
| **TabuSearch** | **6.2374 ± 0.0000** | 0 |
|
|
||||||
| **AntColony** | **6.2374 ± 0.0000** | 8 |
|
|
||||||
| GA | 7.0133 ± 0.6725 | 2 |
|
|
||||||
| RandomSearch | 12.1474 ± 0.6797 | 1 |
|
|
||||||
|
|
||||||
Every local-search method (and Ant Colony) hits the exact optimum — as
|
|
||||||
theory predicts for convex-position TSP under 2-opt. The GA's order
|
|
||||||
crossover drifts off the optimum, and random sampling is hopeless.
|
|
||||||
|
|
||||||
## JSS FT06 (8000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
Fisher & Thompson 1963 6-job × 6-machine job-shop; minimize makespan.
|
|
||||||
Hard because every job has a fixed machine order, so swapping two
|
|
||||||
operations can ripple delays across the whole schedule. Known optimum:
|
|
||||||
**55**.
|
|
||||||
|
|
||||||
| algorithm | makespan ↓ | ms |
|
|
||||||
|---|---|---|
|
|
||||||
| **SimulatedAnneal** | **55.2000 ± 0.6000** | 1 |
|
|
||||||
| TabuSearch | 55.9000 ± 1.4457 | 1 |
|
|
||||||
| GA | 56.0000 ± 1.5492 | 4 |
|
|
||||||
| RandomSearch | 58.5000 ± 1.2042 | 4 |
|
|
||||||
| HillClimber | 62.5000 ± 4.3186 | 0 |
|
|
||||||
|
|
||||||
Simulated annealing gets within 0.4% of the known optimum on average;
|
|
||||||
greedy hill-climbing stalls in operation-order local optima.
|
|
||||||
|
|
||||||
## Knapsack (30 items, bi-objective, 20000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
Zitzler-Thiele style 0/1 knapsack: two profit vectors, one capacity (half
|
|
||||||
the total weight). Hard because the two profit objectives conflict and
|
|
||||||
the capacity constraint carves feasible regions out of the `2³⁰`
|
|
||||||
bitstrings. No closed-form optimum; scored by hypervolume vs reference
|
|
||||||
`[0, 0]` (higher is better).
|
|
||||||
|
|
||||||
| algorithm | hypervolume ↑ | front | ms |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **NSGA-II** | **1360468.3 ± 11619.6** | 100 | 39 |
|
|
||||||
| SPEA2 | 1355615.5 ± 9266.8 | 100 | 213 |
|
|
||||||
| IBEA | 1352595.5 ± 10183.6 | 99 | 101 |
|
|
||||||
| NSGA-III | 1346446.0 ± 6922.1 | 100 | 39 |
|
|
||||||
| RandomSearch | 1118233.1 ± 34150.3 | 9 | 17 |
|
|
||||||
|
|
||||||
The three Pareto EAs land within ~1% of each other; random search finds a
|
|
||||||
front of only ~9 points and trails badly. Note IBEA — which dominates the
|
|
||||||
*continuous* multi-objective tables — is only mid-pack here: its
|
|
||||||
continuous-MO edge does not transfer to a binary combinatorial encoding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Many-objective (4+ objectives)
|
|
||||||
|
|
||||||
The curse of dimensionality for multi-objective optimizers: as objective
|
|
||||||
count climbs, almost every pair of solutions becomes mutually
|
|
||||||
non-dominated, so Pareto rank stops discriminating. NSGA-II's whole
|
|
||||||
population collapses into front 0 and only crowding distance is left to
|
|
||||||
steer. Reference-point (NSGA-III), decomposition (MOEA/D),
|
|
||||||
reference-vector (RVEA), grid (GrEA), and indicator (IBEA, HypE) methods
|
|
||||||
are built for this regime. Scored by mean distance to the true front
|
|
||||||
(lower better).
|
|
||||||
|
|
||||||
### DTLZ2 4-objective (dim=13, 40000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
DTLZ2 scaled to 4 objectives — the entry point to many-objective. Front
|
|
||||||
is still the unit-hypersphere octant (`Σf² = 1`). Already hard: with 4
|
|
||||||
objectives most random solution pairs are mutually non-dominated, so
|
|
||||||
Pareto rank alone barely discriminates.
|
|
||||||
|
|
||||||
| algorithm | mean dist ↓ | front | ms |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **HypE** | **0.0005 ± 0.0004** | 56 | 292 |
|
|
||||||
| MOEA/D | 0.0019 ± 0.0004 | 46 | 33 |
|
|
||||||
| GrEA | 0.0023 ± 0.0021 | 56 | 75 |
|
|
||||||
| IBEA | 0.0043 ± 0.0008 | 56 | 135 |
|
|
||||||
| RVEA | 0.0193 ± 0.0040 | 56 | 58 |
|
|
||||||
| NSGA-III | 0.0312 ± 0.0046 | 56 | 100 |
|
|
||||||
| AGE-MOEA | 0.0457 ± 0.0113 | 56 | 239 |
|
|
||||||
| NSGA-II | 0.1149 ± 0.0249 | 56 | 74 |
|
|
||||||
| RandomSearch | 0.4720 ± 0.0122 | 887 | 1960 |
|
|
||||||
|
|
||||||
NSGA-II already trails the specialists by ~230× — and its "front" is the
|
|
||||||
whole population (56), the first sign of dominance resistance. Random
|
|
||||||
search's front balloons to ~887: nothing it sampled dominates anything
|
|
||||||
else.
|
|
||||||
|
|
||||||
### DTLZ2 10-objective (dim=19, 40000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
DTLZ2 at 10 objectives — the curse of dimensionality in full. In 10-D
|
|
||||||
objective space almost *every* pair of solutions is mutually
|
|
||||||
non-dominated.
|
|
||||||
|
|
||||||
| algorithm | mean dist ↓ | front | ms |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **HypE** | **0.0007 ± 0.0005** | 55 | 555 |
|
|
||||||
| MOEA/D | 0.0029 ± 0.0022 | 48 | 57 |
|
|
||||||
| GrEA | 0.0066 ± 0.0145 | 55 | 146 |
|
|
||||||
| RVEA | 0.0094 ± 0.0066 | 41 | 74 |
|
|
||||||
| IBEA | 0.0118 ± 0.0033 | 55 | 171 |
|
|
||||||
| AGE-MOEA | 0.1812 ± 0.0523 | 55 | 529 |
|
|
||||||
| NSGA-III | 0.3064 ± 0.0327 | 55 | 220 |
|
|
||||||
| RandomSearch | 0.6326 ± 0.0044 | 4592 | 16131 |
|
|
||||||
| NSGA-II | 2.0096 ± 0.0540 | 55 | 186 |
|
|
||||||
|
|
||||||
**The headline result.** NSGA-II is *dead last — worse than random
|
|
||||||
search* (2.01 vs 0.63). Its crowding distance in 10-D doesn't just fail
|
|
||||||
to help, it actively misleads. The indicator (HypE, IBEA), decomposition
|
|
||||||
(MOEA/D) and grid (GrEA) methods barely notice the objective-count jump
|
|
||||||
from 4 to 10; AGE-MOEA and NSGA-III degrade noticeably but still beat
|
|
||||||
random.
|
|
||||||
|
|
||||||
### DTLZ1 8-objective (dim=12, 40000 evals/run × 10 seeds)
|
|
||||||
|
|
||||||
DTLZ1 at 8 objectives — the brutal one: many-objective dominance collapse
|
|
||||||
*plus* DTLZ1's deceptive multimodal `g`-term (a huge number of local
|
|
||||||
fronts). The true front is the linear simplex `Σf = 0.5`; reaching it at
|
|
||||||
all is the achievement.
|
|
||||||
|
|
||||||
| algorithm | mean dist ↓ | front | ms |
|
|
||||||
|---|---|---|---|
|
|
||||||
| **GrEA** | **1.5441 ± 0.3844** | 98 | 183 |
|
|
||||||
| MOEA/D | 2.2867 ± 2.0553 | 94 | 37 |
|
|
||||||
| RVEA | 2.4016 ± 1.3780 | 51 | 116 |
|
|
||||||
| IBEA | 7.9615 ± 3.6041 | 101 | 283 |
|
|
||||||
| NSGA-III | 26.6956 ± 7.3771 | 120 | 295 |
|
|
||||||
| HypE | 26.8702 ± 5.5660 | 120 | 375 |
|
|
||||||
| AGE-MOEA | 43.9530 ± 15.4464 | 120 | 591 |
|
|
||||||
| RandomSearch | 172.6562 ± 6.8456 | 700 | 2553 |
|
|
||||||
| NSGA-II | 281.4563 ± 11.9140 | 120 | 277 |
|
|
||||||
|
|
||||||
**GrEA wins** — consistent with the 3-objective DTLZ1 table, where it
|
|
||||||
also won: grid-based niching matches a linear/simplex front at any
|
|
||||||
objective count. The other striking result is **HypE's reversal**: #1 on
|
|
||||||
both DTLZ2 tables, but #6 here — Monte-Carlo hypervolume is a poor
|
|
||||||
discriminator on the deceptive simplex. NSGA-II again finishes last,
|
|
||||||
worse than random by ~1.6×.
|
|
||||||
|
|||||||
+1966
-9
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
//! Solve a bi-objective extension of the Fisher–Thompson FT06 job-shop
|
|
||||||
//! scheduling benchmark using NSGA-II.
|
|
||||||
//!
|
|
||||||
//! - **Benchmark**: FT06 (Fisher & Thompson, 1963), 6 jobs × 6 machines, 36
|
|
||||||
//! operations total. Each operation has a fixed machine and processing
|
|
||||||
//! time; operations within a job must run in the given order.
|
|
||||||
//! - **Canonical (single-objective) optimum**: makespan **55**.
|
|
||||||
//! - **Bi-objective extension** (this example):
|
|
||||||
//! - f₁ = makespan (Cₘₐₓ)
|
|
||||||
//! - f₂ = total flow time Σⱼ Cⱼ
|
|
||||||
//!
|
|
||||||
//! Both are standard JSS objectives in the multi-objective literature.
|
|
||||||
//! - **Algorithm**: [`Nsga2`].
|
|
||||||
//! - **Encoding**: operation-based string of length 36, each job id appears
|
|
||||||
//! 6 times. The k-th occurrence of job `j` represents the k-th operation
|
|
||||||
//! of job `j`.
|
|
||||||
//! - **Variation**: a local `PrecedenceOrderCrossover` (POX) piped into
|
|
||||||
//! [`InversionMutation`] via [`CompositeVariation`]. The strict-permutation
|
|
||||||
//! crossovers shipped in the library (OX, PMX, CX, ERX) would break the
|
|
||||||
//! operation-string multiset, so this example defines a small JSS-aware
|
|
||||||
//! crossover inline. POX is the standard crossover for operation-based JSS
|
|
||||||
//! GAs (Lee & Yamakawa, 1996; Bierwirth et al., 1996).
|
|
||||||
//! - **Initializer**: [`ShuffledMultisetPermutation`].
|
|
||||||
//!
|
|
||||||
//! Sources:
|
|
||||||
//! - Fisher, H., Thompson, G. L. (1963). *Probabilistic learning combinations
|
|
||||||
//! of local job-shop scheduling rules.*
|
|
||||||
//! - OR-Library / JSPLIB FT06 instance file.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example jss_ft06_bi
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
/// Precedence-preserving Order-based Crossover for operation-string JSS
|
|
||||||
/// encodings. Partitions job ids into two sets J1 / J2; the child takes
|
|
||||||
/// positions occupied by J1 from parent A and fills the remaining positions
|
|
||||||
/// with J2's operations in parent B's order. Two children are produced by
|
|
||||||
/// reversing the parent roles.
|
|
||||||
///
|
|
||||||
/// Preserves the JSS multiset invariant (each job id appears `N_MACHINES`
|
|
||||||
/// times) because every operation in the multiset is covered exactly once:
|
|
||||||
/// J1 ops by parent A, J2 ops by parent B.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct PrecedenceOrderCrossover;
|
|
||||||
|
|
||||||
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
assert!(parents.len() >= 2, "POX requires 2 parents");
|
|
||||||
let p1 = &parents[0];
|
|
||||||
let p2 = &parents[1];
|
|
||||||
let mut in_j1 = [false; N_JOBS];
|
|
||||||
// Ensure both partitions are non-empty to avoid degenerate (child == one parent).
|
|
||||||
loop {
|
|
||||||
for slot in &mut in_j1 {
|
|
||||||
*slot = rng.random_bool(0.5);
|
|
||||||
}
|
|
||||||
let n_in_j1 = in_j1.iter().filter(|&&b| b).count();
|
|
||||||
if n_in_j1 > 0 && n_in_j1 < N_JOBS {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
|
|
||||||
let n = donor.len();
|
|
||||||
let mut child = vec![usize::MAX; n];
|
|
||||||
for k in 0..n {
|
|
||||||
if in_donor_set[donor[k]] {
|
|
||||||
child[k] = donor[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut fill_idx = 0;
|
|
||||||
for &v in filler {
|
|
||||||
if !in_donor_set[v] {
|
|
||||||
while fill_idx < n && child[fill_idx] != usize::MAX {
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
child[fill_idx] = v;
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
child
|
|
||||||
}
|
|
||||||
|
|
||||||
/// FT06 routing — machine id for the k-th operation of job j.
|
|
||||||
const FT06_MACHINE: [[usize; 6]; 6] = [
|
|
||||||
[2, 0, 1, 3, 5, 4],
|
|
||||||
[1, 2, 4, 5, 0, 3],
|
|
||||||
[2, 3, 5, 0, 1, 4],
|
|
||||||
[1, 0, 2, 3, 4, 5],
|
|
||||||
[2, 1, 4, 5, 0, 3],
|
|
||||||
[1, 3, 5, 0, 4, 2],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// FT06 processing times — duration of the k-th operation of job j on the
|
|
||||||
/// machine given by `FT06_MACHINE[j][k]`.
|
|
||||||
const FT06_TIME: [[f64; 6]; 6] = [
|
|
||||||
[1.0, 3.0, 6.0, 7.0, 3.0, 6.0],
|
|
||||||
[8.0, 5.0, 10.0, 10.0, 10.0, 4.0],
|
|
||||||
[5.0, 4.0, 8.0, 9.0, 1.0, 7.0],
|
|
||||||
[5.0, 5.0, 5.0, 3.0, 8.0, 9.0],
|
|
||||||
[9.0, 3.0, 5.0, 4.0, 3.0, 1.0],
|
|
||||||
[3.0, 3.0, 9.0, 10.0, 4.0, 1.0],
|
|
||||||
];
|
|
||||||
|
|
||||||
const N_JOBS: usize = 6;
|
|
||||||
const N_MACHINES: usize = 6;
|
|
||||||
const KNOWN_MAKESPAN_OPTIMUM: f64 = 55.0;
|
|
||||||
|
|
||||||
struct Ft06BiObjective;
|
|
||||||
|
|
||||||
impl Problem for Ft06BiObjective {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("makespan"),
|
|
||||||
Objective::minimize("total_flow_time"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
|
||||||
let mut job_next = [0_usize; N_JOBS];
|
|
||||||
let mut job_clock = [0.0_f64; N_JOBS];
|
|
||||||
let mut machine_clock = [0.0_f64; N_MACHINES];
|
|
||||||
|
|
||||||
for &job in schedule {
|
|
||||||
let k = job_next[job];
|
|
||||||
let m = FT06_MACHINE[job][k];
|
|
||||||
let t = FT06_TIME[job][k];
|
|
||||||
let start = job_clock[job].max(machine_clock[m]);
|
|
||||||
let end = start + t;
|
|
||||||
job_clock[job] = end;
|
|
||||||
machine_clock[m] = end;
|
|
||||||
job_next[job] = k + 1;
|
|
||||||
}
|
|
||||||
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
|
|
||||||
let flow_time: f64 = job_clock.iter().sum();
|
|
||||||
Evaluation::new(vec![makespan, flow_time])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
(0..N_JOBS * N_MACHINES)
|
|
||||||
.map(|k| DecisionVariable::new(format!("op_slot_{k}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let problem = Ft06BiObjective;
|
|
||||||
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 200,
|
|
||||||
generations: 1500,
|
|
||||||
seed: 7,
|
|
||||||
},
|
|
||||||
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: PrecedenceOrderCrossover,
|
|
||||||
mutation: SwapMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("FT06 — bi-objective JSS via NSGA-II");
|
|
||||||
println!("Source: Fisher & Thompson (1963); known single-objective optimum makespan = 55");
|
|
||||||
println!();
|
|
||||||
println!("Total evaluations: {}", result.evaluations);
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Sort front by makespan ascending and print a sample of points.
|
|
||||||
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deduplicate by objective values so the output isn't a wall of identical rows.
|
|
||||||
let mut seen: Vec<(i64, i64)> = Vec::new();
|
|
||||||
println!(" makespan total flow time");
|
|
||||||
for c in &front {
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
let key = (o[0] as i64, o[1] as i64);
|
|
||||||
if !seen.contains(&key) {
|
|
||||||
seen.push(key);
|
|
||||||
println!(" {:>8.0} {:>15.0}", o[0], o[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!(" ({} unique objective-space points)", seen.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Compare the makespan-corner against the known optimum.
|
|
||||||
if let Some(makespan_corner) = front.first() {
|
|
||||||
let best_makespan = makespan_corner.evaluation.objectives[0];
|
|
||||||
let gap_abs = best_makespan - KNOWN_MAKESPAN_OPTIMUM;
|
|
||||||
let gap_pct = 100.0 * gap_abs / KNOWN_MAKESPAN_OPTIMUM;
|
|
||||||
println!(
|
|
||||||
"Makespan corner: {:.0} vs. known optimum 55 (gap {:+.0}, {:+.2}%)",
|
|
||||||
best_makespan, gap_abs, gap_pct
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
//! 3-objective Job-Shop Scheduling on Lawrence's LA01 instance, solved with
|
|
||||||
//! NSGA-III (the many-objective successor to NSGA-II).
|
|
||||||
//!
|
|
||||||
//! - **Benchmark**: Lawrence LA01 (1984), 10 jobs × 5 machines, 50 operations
|
|
||||||
//! total. Each operation has a fixed machine and processing time;
|
|
||||||
//! operations within a job run in order. Data taken from the OR-Library /
|
|
||||||
//! JSPLIB la01 instance file.
|
|
||||||
//! - **Three objectives** (this example):
|
|
||||||
//! - f₁ = makespan
|
|
||||||
//! - f₂ = total flow time Σⱼ Cⱼ
|
|
||||||
//! - f₃ = total tardiness Σⱼ max(0, Cⱼ − dⱼ), with synthetic due dates
|
|
||||||
//! dⱼ = 1.3 × (sum of processing times of job j)
|
|
||||||
//! - **Algorithm**: [`Nsga3`] — designed for ≥ 3 objectives (NSGA-II's
|
|
||||||
//! crowding distance degrades in higher dim).
|
|
||||||
//! - **Encoding**: operation-based string of length 50.
|
|
||||||
//! - **Variation**: a local POX (multiset-preserving) crossover piped through
|
|
||||||
//! a small randomly-chosen mutation that alternates between
|
|
||||||
//! [`InsertionMutation`] and [`ScrambleMutation`]. Strict-permutation
|
|
||||||
//! crossovers cannot be used on multiset encodings.
|
|
||||||
//! - **Initializer**: [`ShuffledMultisetPermutation`].
|
|
||||||
//!
|
|
||||||
//! Sources:
|
|
||||||
//! - Lawrence (1984), thesis benchmark instances.
|
|
||||||
//! - OR-Library / JSPLIB LA01 instance file.
|
|
||||||
//! - Deb & Jain (2014), "An evolutionary many-objective optimization
|
|
||||||
//! algorithm using reference-point based non-dominated sorting approach,
|
|
||||||
//! Part I" — NSGA-III.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example mo_jss_la01
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
const N_JOBS: usize = 10;
|
|
||||||
const N_MACHINES: usize = 5;
|
|
||||||
|
|
||||||
/// LA01 routing — machine id for the k-th operation of job j.
|
|
||||||
const LA01_MACHINE: [[usize; N_MACHINES]; N_JOBS] = [
|
|
||||||
[1, 0, 4, 3, 2],
|
|
||||||
[0, 3, 4, 2, 1],
|
|
||||||
[3, 4, 1, 2, 0],
|
|
||||||
[1, 0, 4, 2, 3],
|
|
||||||
[0, 3, 2, 1, 4],
|
|
||||||
[1, 2, 4, 0, 3],
|
|
||||||
[3, 4, 1, 2, 0],
|
|
||||||
[2, 0, 1, 3, 4],
|
|
||||||
[3, 1, 4, 0, 2],
|
|
||||||
[4, 3, 1, 2, 0],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// LA01 processing times — duration of the k-th operation of job j.
|
|
||||||
const LA01_TIME: [[f64; N_MACHINES]; N_JOBS] = [
|
|
||||||
[21.0, 53.0, 95.0, 55.0, 34.0],
|
|
||||||
[21.0, 52.0, 16.0, 26.0, 71.0],
|
|
||||||
[39.0, 98.0, 42.0, 31.0, 12.0],
|
|
||||||
[77.0, 55.0, 79.0, 66.0, 77.0],
|
|
||||||
[83.0, 34.0, 64.0, 19.0, 37.0],
|
|
||||||
[54.0, 43.0, 79.0, 92.0, 62.0],
|
|
||||||
[69.0, 77.0, 87.0, 87.0, 93.0],
|
|
||||||
[38.0, 60.0, 41.0, 24.0, 66.0],
|
|
||||||
[17.0, 49.0, 25.0, 44.0, 98.0],
|
|
||||||
[77.0, 79.0, 43.0, 75.0, 96.0],
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Synthetic due dates: 1.3 × total processing time of each job.
|
|
||||||
fn due_dates() -> [f64; N_JOBS] {
|
|
||||||
let mut d = [0.0_f64; N_JOBS];
|
|
||||||
for (j, row) in LA01_TIME.iter().enumerate() {
|
|
||||||
d[j] = 1.3 * row.iter().sum::<f64>();
|
|
||||||
}
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
struct La01ThreeObjective {
|
|
||||||
due: [f64; N_JOBS],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for La01ThreeObjective {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("makespan"),
|
|
||||||
Objective::minimize("total_flow_time"),
|
|
||||||
Objective::minimize("total_tardiness"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
|
|
||||||
let mut job_next = [0_usize; N_JOBS];
|
|
||||||
let mut job_clock = [0.0_f64; N_JOBS];
|
|
||||||
let mut machine_clock = [0.0_f64; N_MACHINES];
|
|
||||||
for &job in schedule {
|
|
||||||
let k = job_next[job];
|
|
||||||
let m = LA01_MACHINE[job][k];
|
|
||||||
let t = LA01_TIME[job][k];
|
|
||||||
let start = job_clock[job].max(machine_clock[m]);
|
|
||||||
let end = start + t;
|
|
||||||
job_clock[job] = end;
|
|
||||||
machine_clock[m] = end;
|
|
||||||
job_next[job] = k + 1;
|
|
||||||
}
|
|
||||||
let makespan = machine_clock.iter().cloned().fold(0.0_f64, f64::max);
|
|
||||||
let flow_time: f64 = job_clock.iter().sum();
|
|
||||||
let tardiness: f64 = job_clock
|
|
||||||
.iter()
|
|
||||||
.zip(self.due.iter())
|
|
||||||
.map(|(&c, &d)| (c - d).max(0.0))
|
|
||||||
.sum();
|
|
||||||
Evaluation::new(vec![makespan, flow_time, tardiness])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
(0..N_JOBS * N_MACHINES)
|
|
||||||
.map(|k| DecisionVariable::new(format!("op_slot_{k}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POX — multiset-preserving crossover for operation-string encodings.
|
|
||||||
/// (Identical in spirit to the one in `jss_ft06_bi.rs`; copied locally so
|
|
||||||
/// each example stays self-contained.)
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct PrecedenceOrderCrossover;
|
|
||||||
|
|
||||||
impl Variation<Vec<usize>> for PrecedenceOrderCrossover {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
assert!(parents.len() >= 2, "POX requires 2 parents");
|
|
||||||
let p1 = &parents[0];
|
|
||||||
let p2 = &parents[1];
|
|
||||||
let mut in_j1 = [false; N_JOBS];
|
|
||||||
loop {
|
|
||||||
for slot in &mut in_j1 {
|
|
||||||
*slot = rng.random_bool(0.5);
|
|
||||||
}
|
|
||||||
let n_in_j1 = in_j1.iter().filter(|&&b| b).count();
|
|
||||||
if n_in_j1 > 0 && n_in_j1 < N_JOBS {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vec![pox_child(p1, p2, &in_j1), pox_child(p2, p1, &in_j1)]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pox_child(donor: &[usize], filler: &[usize], in_donor_set: &[bool]) -> Vec<usize> {
|
|
||||||
let n = donor.len();
|
|
||||||
let mut child = vec![usize::MAX; n];
|
|
||||||
for k in 0..n {
|
|
||||||
if in_donor_set[donor[k]] {
|
|
||||||
child[k] = donor[k];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let mut fill_idx = 0;
|
|
||||||
for &v in filler {
|
|
||||||
if !in_donor_set[v] {
|
|
||||||
while fill_idx < n && child[fill_idx] != usize::MAX {
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
child[fill_idx] = v;
|
|
||||||
fill_idx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
child
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-call random choice between Insertion and Scramble. Both preserve the
|
|
||||||
/// multiset; flipping a coin gives the schedule access to two complementary
|
|
||||||
/// neighborhood moves.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct InsertionOrScramble;
|
|
||||||
|
|
||||||
impl Variation<Vec<usize>> for InsertionOrScramble {
|
|
||||||
fn vary(&mut self, parents: &[Vec<usize>], rng: &mut Rng) -> Vec<Vec<usize>> {
|
|
||||||
if rng.random_bool(0.5) {
|
|
||||||
InsertionMutation.vary(parents, rng)
|
|
||||||
} else {
|
|
||||||
ScrambleMutation.vary(parents, rng)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let problem = La01ThreeObjective { due: due_dates() };
|
|
||||||
|
|
||||||
let mut optimizer = Nsga3::new(
|
|
||||||
Nsga3Config {
|
|
||||||
population_size: 120,
|
|
||||||
generations: 600,
|
|
||||||
reference_divisions: 12,
|
|
||||||
seed: 9,
|
|
||||||
},
|
|
||||||
ShuffledMultisetPermutation::new(vec![N_MACHINES; N_JOBS]),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: PrecedenceOrderCrossover,
|
|
||||||
mutation: InsertionOrScramble,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("LA01 — 3-objective JSS via NSGA-III");
|
|
||||||
println!("Source: Lawrence (1984), OR-Library la01 instance");
|
|
||||||
println!();
|
|
||||||
println!("Objectives: f1 = makespan, f2 = total flow time, f3 = total tardiness");
|
|
||||||
println!("Due dates: dⱼ = 1.3 × Σ(processing times of job j)");
|
|
||||||
println!();
|
|
||||||
println!("Total evaluations: {}", result.evaluations);
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Sort by makespan and print up to 12 well-spaced rows.
|
|
||||||
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
let stride = (front.len() / 12).max(1);
|
|
||||||
println!(" f1 makespan f2 flow time f3 tardiness");
|
|
||||||
let mut printed = 0_usize;
|
|
||||||
for (i, c) in front.iter().enumerate() {
|
|
||||||
if i % stride == 0 || i + 1 == front.len() {
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
println!(" {:>11.0} {:>12.0} {:>11.0}", o[0], o[1], o[2]);
|
|
||||||
printed += 1;
|
|
||||||
if printed >= 12 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
if let (Some(corner_ms), Some(corner_ft), Some(corner_td)) = (
|
|
||||||
front.first(),
|
|
||||||
front.iter().min_by(|a, b| {
|
|
||||||
a.evaluation.objectives[1]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[1])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
}),
|
|
||||||
front.iter().min_by(|a, b| {
|
|
||||||
a.evaluation.objectives[2]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[2])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
}),
|
|
||||||
) {
|
|
||||||
println!(
|
|
||||||
"Makespan corner: f1={:.0}, f2={:.0}, f3={:.0}",
|
|
||||||
corner_ms.evaluation.objectives[0],
|
|
||||||
corner_ms.evaluation.objectives[1],
|
|
||||||
corner_ms.evaluation.objectives[2],
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"Flow-time corner: f1={:.0}, f2={:.0}, f3={:.0}",
|
|
||||||
corner_ft.evaluation.objectives[0],
|
|
||||||
corner_ft.evaluation.objectives[1],
|
|
||||||
corner_ft.evaluation.objectives[2],
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"Tardiness corner: f1={:.0}, f2={:.0}, f3={:.0}",
|
|
||||||
corner_td.evaluation.objectives[0],
|
|
||||||
corner_td.evaluation.objectives[1],
|
|
||||||
corner_td.evaluation.objectives[2],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
//! Bi-objective 0/1 knapsack — Zitzler & Thiele's textbook multi-objective
|
|
||||||
//! combinatorial benchmark, solved with NSGA-II.
|
|
||||||
//!
|
|
||||||
//! - **Benchmark family**: Zitzler & Thiele (1999) bi-objective knapsack.
|
|
||||||
//! Each item has two profit values and a single weight; a single capacity
|
|
||||||
//! constraint. We use a 30-item instance with values drawn from the same
|
|
||||||
//! U(10, 100) distribution scheme as the published instances, embedded as
|
|
||||||
//! `const` tables so the example stays self-contained.
|
|
||||||
//! - **Algorithm**: [`Nsga2`].
|
|
||||||
//! - **Decision**: `Vec<bool>` of length 30 (take / leave each item).
|
|
||||||
//! - **Variation**: a local one-point crossover (binary GAs' workhorse) piped
|
|
||||||
//! into [`BitFlipMutation`] via [`CompositeVariation`]. **A future PR could
|
|
||||||
//! lift `OnePointCrossover` / `UniformCrossover` into the library proper**
|
|
||||||
//! so users don't need to roll their own.
|
|
||||||
//! - **Initializer**: a tiny local `RandomBinary` (one-liner; would be a
|
|
||||||
//! reasonable library addition too).
|
|
||||||
//! - **Constraint handling**: weight overruns are penalized in both
|
|
||||||
//! objectives by `-large * overrun`. With the penalty dominating profit
|
|
||||||
//! range, the Pareto front is composed entirely of feasible solutions
|
|
||||||
//! (standard heuristic-MO practice).
|
|
||||||
//!
|
|
||||||
//! Sources:
|
|
||||||
//! - Zitzler & Thiele (1999), "Multiobjective evolutionary algorithms: A
|
|
||||||
//! comparative case study and the Strength Pareto approach."
|
|
||||||
//! - Deb (2001), "Multi-Objective Optimization Using Evolutionary Algorithms"
|
|
||||||
//! for the standard penalty-based MO constraint handling.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example mo_knapsack
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
const N_ITEMS: usize = 30;
|
|
||||||
|
|
||||||
/// Profit vector A (one of two objectives), U(10, 100) style.
|
|
||||||
const PROFITS_A: [f64; N_ITEMS] = [
|
|
||||||
61.0, 17.0, 92.0, 49.0, 73.0, 28.0, 84.0, 36.0, 55.0, 78.0, 23.0, 91.0, 12.0, 67.0, 45.0, 58.0,
|
|
||||||
33.0, 71.0, 14.0, 26.0, 87.0, 42.0, 19.0, 65.0, 30.0, 51.0, 79.0, 22.0, 47.0, 88.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Profit vector B (the other objective). Intentionally anti-correlated with
|
|
||||||
/// A on many items so the Pareto front spans a wide trade-off.
|
|
||||||
const PROFITS_B: [f64; N_ITEMS] = [
|
|
||||||
24.0, 81.0, 16.0, 67.0, 29.0, 73.0, 41.0, 60.0, 52.0, 19.0, 77.0, 34.0, 95.0, 22.0, 71.0, 88.0,
|
|
||||||
56.0, 27.0, 64.0, 90.0, 18.0, 43.0, 79.0, 31.0, 85.0, 25.0, 38.0, 92.0, 70.0, 13.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Item weights.
|
|
||||||
const WEIGHTS: [f64; N_ITEMS] = [
|
|
||||||
35.0, 58.0, 22.0, 71.0, 14.0, 86.0, 31.0, 53.0, 78.0, 19.0, 44.0, 16.0, 67.0, 88.0, 25.0, 51.0,
|
|
||||||
33.0, 74.0, 12.0, 47.0, 63.0, 28.0, 91.0, 36.0, 55.0, 17.0, 82.0, 41.0, 24.0, 68.0,
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Capacity = roughly half the total weight (standard Zitzler-Thiele convention).
|
|
||||||
fn capacity() -> f64 {
|
|
||||||
0.5 * WEIGHTS.iter().sum::<f64>()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BiKnapsack {
|
|
||||||
cap: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for BiKnapsack {
|
|
||||||
type Decision = Vec<bool>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::maximize("profit_A"),
|
|
||||||
Objective::maximize("profit_B"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, take: &Vec<bool>) -> Evaluation {
|
|
||||||
let (pa, pb, w) =
|
|
||||||
take.iter()
|
|
||||||
.enumerate()
|
|
||||||
.fold((0.0_f64, 0.0_f64, 0.0_f64), |(pa, pb, w), (i, &t)| {
|
|
||||||
if t {
|
|
||||||
(pa + PROFITS_A[i], pb + PROFITS_B[i], w + WEIGHTS[i])
|
|
||||||
} else {
|
|
||||||
(pa, pb, w)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Penalty: large coefficient on weight overrun, applied to both objectives.
|
|
||||||
let overrun = (w - self.cap).max(0.0);
|
|
||||||
let penalty = 1000.0 * overrun;
|
|
||||||
Evaluation::new(vec![pa - penalty, pb - penalty])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
(0..N_ITEMS)
|
|
||||||
.map(|i| DecisionVariable::new(format!("item_take_{i}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Random binary initializer — each bit is 50/50 independently.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
struct RandomBinary {
|
|
||||||
n: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Initializer<Vec<bool>> for RandomBinary {
|
|
||||||
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
(0..size)
|
|
||||||
.map(|_| (0..self.n).map(|_| rng.random_bool(0.5)).collect())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-point crossover for binary chromosomes.
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
|
||||||
struct OnePointCrossoverBool;
|
|
||||||
|
|
||||||
impl Variation<Vec<bool>> for OnePointCrossoverBool {
|
|
||||||
fn vary(&mut self, parents: &[Vec<bool>], rng: &mut Rng) -> Vec<Vec<bool>> {
|
|
||||||
assert!(
|
|
||||||
parents.len() >= 2,
|
|
||||||
"OnePointCrossoverBool requires 2 parents"
|
|
||||||
);
|
|
||||||
let p1 = &parents[0];
|
|
||||||
let p2 = &parents[1];
|
|
||||||
assert_eq!(p1.len(), p2.len(), "parent lengths differ");
|
|
||||||
let n = p1.len();
|
|
||||||
if n < 2 {
|
|
||||||
return vec![p1.clone(), p2.clone()];
|
|
||||||
}
|
|
||||||
let cut = rng.random_range(1..n);
|
|
||||||
let mut c1 = Vec::with_capacity(n);
|
|
||||||
let mut c2 = Vec::with_capacity(n);
|
|
||||||
c1.extend_from_slice(&p1[..cut]);
|
|
||||||
c1.extend_from_slice(&p2[cut..]);
|
|
||||||
c2.extend_from_slice(&p2[..cut]);
|
|
||||||
c2.extend_from_slice(&p1[cut..]);
|
|
||||||
vec![c1, c2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let cap = capacity();
|
|
||||||
let problem = BiKnapsack { cap };
|
|
||||||
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 120,
|
|
||||||
generations: 400,
|
|
||||||
seed: 19,
|
|
||||||
},
|
|
||||||
RandomBinary { n: N_ITEMS },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OnePointCrossoverBool,
|
|
||||||
mutation: BitFlipMutation {
|
|
||||||
probability: 1.0 / N_ITEMS as f64,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
println!("Bi-objective 0/1 knapsack — Zitzler–Thiele style, 30 items");
|
|
||||||
println!(
|
|
||||||
"Capacity = {:.0} (≈ half of total weight {:.0})",
|
|
||||||
cap,
|
|
||||||
WEIGHTS.iter().sum::<f64>()
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
println!("Total evaluations: {}", result.evaluations);
|
|
||||||
println!("Pareto-front size: {}", result.pareto_front.len());
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Sort by profit_A descending for display, dedupe by integer-rounded objective values.
|
|
||||||
let mut front: Vec<&Candidate<Vec<bool>>> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
b.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&a.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
let mut seen: Vec<(i64, i64)> = Vec::new();
|
|
||||||
println!(" profit_A profit_B weight");
|
|
||||||
for c in &front {
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
let key = (o[0] as i64, o[1] as i64);
|
|
||||||
if seen.contains(&key) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seen.push(key);
|
|
||||||
let w: f64 = c
|
|
||||||
.decision
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter(|&(_, &t)| t)
|
|
||||||
.map(|(i, _)| WEIGHTS[i])
|
|
||||||
.sum();
|
|
||||||
println!(" {:>8.0} {:>8.0} {:>6.0}", o[0], o[1], w);
|
|
||||||
}
|
|
||||||
println!(" ({} unique objective-space points)", seen.len());
|
|
||||||
|
|
||||||
// Hypervolume against a reference point of (0, 0): since these are
|
|
||||||
// maximization objectives, we transform to minimization by negation in
|
|
||||||
// the metric — hypervolume_2d uses ObjectiveSpace::as_minimization() so
|
|
||||||
// it Just Works.
|
|
||||||
let ref_point = [0.0, 0.0];
|
|
||||||
let owned: Vec<Candidate<Vec<bool>>> = result.pareto_front.to_vec();
|
|
||||||
let hv = hypervolume_2d(&owned, &problem.objectives(), ref_point);
|
|
||||||
println!();
|
|
||||||
println!(
|
|
||||||
"Hypervolume vs. reference (profit_A=0, profit_B=0): {:.0}",
|
|
||||||
hv
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
//! `pick_a_car` — designing a car along four objectives at once.
|
|
||||||
//!
|
|
||||||
//! Three decision variables (engine displacement, curb weight,
|
|
||||||
//! aerodynamic drag) and four objectives (price, 0-60 acceleration,
|
|
||||||
//! fuel consumption, idle noise) coupled by non-linear cost
|
|
||||||
//! relationships, so the Pareto front is a real surface in 3D
|
|
||||||
//! decision space — not a 1D sweep that any human could enumerate.
|
|
||||||
//!
|
|
||||||
//! Run it:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! cargo run --release --example pick_a_car --features serde
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! It writes a `pick_a_car.json` file in the current directory that
|
|
||||||
//! you can drop into <https://swaits.github.io/heuropt-explorer/> to
|
|
||||||
//! filter, brush, pin, and rank the 100-car Pareto front
|
|
||||||
//! interactively.
|
|
||||||
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
struct PickACar;
|
|
||||||
|
|
||||||
impl Problem for PickACar {
|
|
||||||
type Decision = Vec<f64>; // [engine_liters, weight_kg, drag_cd]
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("price")
|
|
||||||
.with_label("Price")
|
|
||||||
.with_unit("$k"),
|
|
||||||
Objective::minimize("zero_to_sixty")
|
|
||||||
.with_label("0-60 mph")
|
|
||||||
.with_unit("s"),
|
|
||||||
Objective::minimize("fuel")
|
|
||||||
.with_label("Fuel")
|
|
||||||
.with_unit("gal/100mi"),
|
|
||||||
Objective::minimize("noise")
|
|
||||||
.with_label("Idle noise")
|
|
||||||
.with_unit("dB"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
vec![
|
|
||||||
DecisionVariable::new("displacement")
|
|
||||||
.with_label("Engine size")
|
|
||||||
.with_unit("L")
|
|
||||||
.with_bounds(1.0, 6.0),
|
|
||||||
DecisionVariable::new("weight")
|
|
||||||
.with_label("Curb weight")
|
|
||||||
.with_unit("kg")
|
|
||||||
.with_bounds(1100.0, 2200.0),
|
|
||||||
DecisionVariable::new("drag")
|
|
||||||
.with_label("Drag coefficient")
|
|
||||||
.with_unit("Cd")
|
|
||||||
.with_bounds(0.20, 0.40),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
let displacement = x[0];
|
|
||||||
let weight = x[1];
|
|
||||||
let drag = x[2];
|
|
||||||
|
|
||||||
// Price ($k): engine cost grows superlinearly; weight reduction
|
|
||||||
// below 1500 kg and drag reduction below 0.35 Cd both cost extra.
|
|
||||||
let engine_cost = 3.0 * displacement.powf(1.6);
|
|
||||||
let weight_cost = ((1500.0 - weight).max(0.0) / 100.0).powi(2) * 2.0;
|
|
||||||
let aero_cost = ((0.35 - drag).max(0.0) * 100.0).powf(1.5) * 0.4;
|
|
||||||
let price = 10.0 + engine_cost + weight_cost + aero_cost;
|
|
||||||
|
|
||||||
// 0-60 (s): heavier = slower; bigger engine = quicker but
|
|
||||||
// with diminishing returns.
|
|
||||||
let weight_factor = (weight - 1100.0) / 1000.0;
|
|
||||||
let engine_factor = ((displacement - 1.0) / 5.0).max(0.0).powf(0.7);
|
|
||||||
let zero_to_sixty = 5.0 + 5.0 * weight_factor - 4.0 * engine_factor;
|
|
||||||
|
|
||||||
// Fuel consumption (gal/100 mi): all three decision vars matter.
|
|
||||||
let fuel = 0.5 + 0.5 * displacement + 0.5 * weight / 1000.0 + 4.0 * drag;
|
|
||||||
|
|
||||||
// Idle noise (dB): engine dominates, mildly non-linear.
|
|
||||||
let noise = 60.0 + 3.0 * displacement.powf(1.2);
|
|
||||||
|
|
||||||
Evaluation::new(vec![price, zero_to_sixty, fuel, noise])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let bounds = vec![
|
|
||||||
(1.0_f64, 6.0_f64), // engine
|
|
||||||
(1100.0_f64, 2200.0_f64), // weight
|
|
||||||
(0.20_f64, 0.40_f64), // drag
|
|
||||||
];
|
|
||||||
|
|
||||||
let started = std::time::Instant::now();
|
|
||||||
|
|
||||||
let mut optimizer = Nsga3::new(
|
|
||||||
Nsga3Config {
|
|
||||||
population_size: 100,
|
|
||||||
generations: 200,
|
|
||||||
reference_divisions: 5,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
RealBounds::new(bounds.clone()),
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.9),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0 / 3.0),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&PickACar);
|
|
||||||
|
|
||||||
let elapsed = started.elapsed().as_secs_f64();
|
|
||||||
|
|
||||||
// Print a short summary across the front so the user can see what
|
|
||||||
// they got without leaving the terminal.
|
|
||||||
let mut front: Vec<_> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap()
|
|
||||||
});
|
|
||||||
println!(
|
|
||||||
"Pareto front: {} cars (took {:.3} s)\n",
|
|
||||||
front.len(),
|
|
||||||
elapsed,
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"{:>5} {:>5} {:>4} {:>6} {:>5} {:>5} {:>5}",
|
|
||||||
"L", "kg", "Cd", "$k", "0-60", "fuel", "dB"
|
|
||||||
);
|
|
||||||
let n = front.len();
|
|
||||||
let sample_indices = if n <= 6 {
|
|
||||||
(0..n).collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
// Six representative rows: first, ~20%, ~40%, ~60%, ~80%, last
|
|
||||||
vec![0, n / 5, (2 * n) / 5, (3 * n) / 5, (4 * n) / 5, n - 1]
|
|
||||||
};
|
|
||||||
for &i in &sample_indices {
|
|
||||||
let c = front[i];
|
|
||||||
let d = &c.decision;
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
println!(
|
|
||||||
"{:>5.2} {:>5.0} {:>4.2} {:>6.1} {:>5.1} {:>5.2} {:>5.1}",
|
|
||||||
d[0], d[1], d[2], o[0], o[1], o[2], o[3]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the explorer JSON. With the metadata the Problem provides
|
|
||||||
// (objective labels + units + decision schema) plus the algorithm's
|
|
||||||
// own AlgorithmInfo, this is genuinely zero-config: one call.
|
|
||||||
let path = "pick_a_car.json";
|
|
||||||
let export = heuropt::explorer::ExplorerExport::from_result(&PickACar, &result)
|
|
||||||
.with_algorithm_info(&optimizer)
|
|
||||||
.with_problem_name("Pick a car")
|
|
||||||
.with_wall_clock(elapsed);
|
|
||||||
export.to_file(path).expect("failed to write JSON");
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"\nWrote {} candidates to {} ({}/{} on the Pareto front).",
|
|
||||||
result.population.candidates.len(),
|
|
||||||
path,
|
|
||||||
result.pareto_front.len(),
|
|
||||||
result.population.candidates.len(),
|
|
||||||
);
|
|
||||||
println!("Drop it into https://swaits.github.io/heuropt-explorer/ to explore.");
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
//! Crossover showdown on the bi-objective TSP from `btsp_kroab.rs`.
|
|
||||||
//!
|
|
||||||
//! Runs NSGA-II four times on the same KroAB-25 instance, holding everything
|
|
||||||
//! constant except the **crossover** operator. The mutation
|
|
||||||
//! ([`InversionMutation`]), initializer, population, generations, and seed
|
|
||||||
//! are identical across runs.
|
|
||||||
//!
|
|
||||||
//! Operators compared:
|
|
||||||
//! - [`OrderCrossover`] (OX)
|
|
||||||
//! - [`PartiallyMappedCrossover`] (PMX)
|
|
||||||
//! - [`CycleCrossover`] (CX)
|
|
||||||
//! - [`EdgeRecombinationCrossover`] (ERX)
|
|
||||||
//!
|
|
||||||
//! Each run is ranked by **hypervolume** (the standard Pareto-front quality
|
|
||||||
//! metric), not by single-objective fitness — for a Pareto search, "best
|
|
||||||
//! length on A" or "best length on B" alone is a misleading scoreboard.
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example tsp_operators_compare
|
|
||||||
//! ```
|
|
||||||
|
|
||||||
use heuropt::metrics::hypervolume_2d;
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
/// First 25 cities of TSPLIB KroA100 (EUC_2D).
|
|
||||||
const KROA_25: [(f64, f64); 25] = [
|
|
||||||
(1380.0, 939.0),
|
|
||||||
(2848.0, 96.0),
|
|
||||||
(3510.0, 1671.0),
|
|
||||||
(457.0, 334.0),
|
|
||||||
(3888.0, 666.0),
|
|
||||||
(984.0, 965.0),
|
|
||||||
(2721.0, 1482.0),
|
|
||||||
(1286.0, 525.0),
|
|
||||||
(2716.0, 1432.0),
|
|
||||||
(738.0, 1325.0),
|
|
||||||
(1251.0, 1832.0),
|
|
||||||
(2728.0, 1698.0),
|
|
||||||
(3815.0, 169.0),
|
|
||||||
(3683.0, 1533.0),
|
|
||||||
(1247.0, 1945.0),
|
|
||||||
(123.0, 862.0),
|
|
||||||
(1234.0, 1946.0),
|
|
||||||
(252.0, 1240.0),
|
|
||||||
(611.0, 673.0),
|
|
||||||
(2576.0, 1676.0),
|
|
||||||
(928.0, 1700.0),
|
|
||||||
(53.0, 857.0),
|
|
||||||
(1807.0, 1711.0),
|
|
||||||
(274.0, 1420.0),
|
|
||||||
(2574.0, 946.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// First 25 cities of TSPLIB KroB100 (EUC_2D).
|
|
||||||
const KROB_25: [(f64, f64); 25] = [
|
|
||||||
(3140.0, 1401.0),
|
|
||||||
(556.0, 1056.0),
|
|
||||||
(3675.0, 1522.0),
|
|
||||||
(1182.0, 1853.0),
|
|
||||||
(3595.0, 1340.0),
|
|
||||||
(1936.0, 953.0),
|
|
||||||
(2722.0, 1311.0),
|
|
||||||
(2839.0, 2055.0),
|
|
||||||
(2253.0, 1242.0),
|
|
||||||
(3142.0, 1591.0),
|
|
||||||
(627.0, 1336.0),
|
|
||||||
(936.0, 211.0),
|
|
||||||
(4014.0, 471.0),
|
|
||||||
(1376.0, 1452.0),
|
|
||||||
(3289.0, 593.0),
|
|
||||||
(1453.0, 67.0),
|
|
||||||
(1014.0, 1944.0),
|
|
||||||
(2811.0, 1080.0),
|
|
||||||
(3010.0, 1290.0),
|
|
||||||
(1817.0, 1517.0),
|
|
||||||
(510.0, 458.0),
|
|
||||||
(1717.0, 1693.0),
|
|
||||||
(1252.0, 1633.0),
|
|
||||||
(1693.0, 1374.0),
|
|
||||||
(539.0, 1378.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
const N_CITIES: usize = 25;
|
|
||||||
const REF_POINT: [f64; 2] = [40_000.0, 40_000.0];
|
|
||||||
|
|
||||||
fn euc2d_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
|
|
||||||
let n = coords.len();
|
|
||||||
let mut d = vec![vec![0.0_f64; n]; n];
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
let dx = coords[i].0 - coords[j].0;
|
|
||||||
let dy = coords[i].1 - coords[j].1;
|
|
||||||
let dij = (dx * dx + dy * dy).sqrt().round();
|
|
||||||
d[i][j] = dij;
|
|
||||||
d[j][i] = dij;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BTsp {
|
|
||||||
dist_a: Vec<Vec<f64>>,
|
|
||||||
dist_b: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BTsp {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
dist_a: euc2d_matrix(&KROA_25),
|
|
||||||
dist_b: euc2d_matrix(&KROB_25),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn tour_length(d: &[Vec<f64>], tour: &[usize]) -> f64 {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut total = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
total += d[tour[i]][tour[(i + 1) % n]];
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for BTsp {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("length_A"),
|
|
||||||
Objective::minimize("length_B"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![
|
|
||||||
Self::tour_length(&self.dist_a, tour),
|
|
||||||
Self::tour_length(&self.dist_b, tour),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct RunSummary {
|
|
||||||
name: &'static str,
|
|
||||||
front_size: usize,
|
|
||||||
front_unique: usize,
|
|
||||||
corner_a: (f64, f64),
|
|
||||||
corner_b: (f64, f64),
|
|
||||||
hypervolume: f64,
|
|
||||||
seconds: f64,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_once<C>(name: &'static str, problem: &BTsp, crossover: C) -> RunSummary
|
|
||||||
where
|
|
||||||
C: Variation<Vec<usize>>,
|
|
||||||
{
|
|
||||||
let mut optimizer = Nsga2::new(
|
|
||||||
Nsga2Config {
|
|
||||||
population_size: 200,
|
|
||||||
generations: 500,
|
|
||||||
seed: 11,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n: N_CITIES },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let t0 = Instant::now();
|
|
||||||
let result = optimizer.run(problem);
|
|
||||||
let seconds = t0.elapsed().as_secs_f64();
|
|
||||||
|
|
||||||
let mut front: Vec<&Candidate<Vec<usize>>> = result.pareto_front.iter().collect();
|
|
||||||
front.sort_by(|a, b| {
|
|
||||||
a.evaluation.objectives[0]
|
|
||||||
.partial_cmp(&b.evaluation.objectives[0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
|
|
||||||
let mut seen: Vec<(i64, i64)> = Vec::new();
|
|
||||||
for c in &front {
|
|
||||||
let o = &c.evaluation.objectives;
|
|
||||||
let k = (o[0] as i64, o[1] as i64);
|
|
||||||
if !seen.contains(&k) {
|
|
||||||
seen.push(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let corner_a = front
|
|
||||||
.first()
|
|
||||||
.map(|c| (c.evaluation.objectives[0], c.evaluation.objectives[1]))
|
|
||||||
.unwrap_or((f64::NAN, f64::NAN));
|
|
||||||
let corner_b = front
|
|
||||||
.last()
|
|
||||||
.map(|c| (c.evaluation.objectives[0], c.evaluation.objectives[1]))
|
|
||||||
.unwrap_or((f64::NAN, f64::NAN));
|
|
||||||
|
|
||||||
let owned: Vec<Candidate<Vec<usize>>> = result.pareto_front.to_vec();
|
|
||||||
let hv = hypervolume_2d(&owned, &problem.objectives(), REF_POINT);
|
|
||||||
|
|
||||||
RunSummary {
|
|
||||||
name,
|
|
||||||
front_size: result.pareto_front.len(),
|
|
||||||
front_unique: seen.len(),
|
|
||||||
corner_a,
|
|
||||||
corner_b,
|
|
||||||
hypervolume: hv,
|
|
||||||
seconds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let problem = BTsp::new();
|
|
||||||
println!("Bi-objective TSP (KroAB-25): NSGA-II crossover showdown");
|
|
||||||
println!("Same population, generations, seed across all runs.");
|
|
||||||
println!("Mutation held constant at InversionMutation.");
|
|
||||||
println!(
|
|
||||||
"Reference point for hypervolume: ({:.0}, {:.0})",
|
|
||||||
REF_POINT[0], REF_POINT[1]
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
|
|
||||||
let runs = vec![
|
|
||||||
run_once("Order (OX)", &problem, OrderCrossover),
|
|
||||||
run_once("PartiallyMapped (PMX)", &problem, PartiallyMappedCrossover),
|
|
||||||
run_once("Cycle (CX)", &problem, CycleCrossover),
|
|
||||||
run_once("EdgeRecomb (ERX)", &problem, EdgeRecombinationCrossover),
|
|
||||||
];
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" {:<24} | {:>5} {:>5} | {:>17} | {:>17} | {:>14} | {:>6}",
|
|
||||||
"crossover", "size", "uniq", "A-corner (A, B)", "B-corner (A, B)", "hypervolume", "time"
|
|
||||||
);
|
|
||||||
println!(" {}", "-".repeat(106));
|
|
||||||
for r in &runs {
|
|
||||||
println!(
|
|
||||||
" {:<24} | {:>5} {:>5} | ({:>6.0},{:>6.0}) | ({:>6.0},{:>6.0}) | {:>14.0} | {:>5.2}s",
|
|
||||||
r.name,
|
|
||||||
r.front_size,
|
|
||||||
r.front_unique,
|
|
||||||
r.corner_a.0,
|
|
||||||
r.corner_a.1,
|
|
||||||
r.corner_b.0,
|
|
||||||
r.corner_b.1,
|
|
||||||
r.hypervolume,
|
|
||||||
r.seconds,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
println!();
|
|
||||||
|
|
||||||
// Pick the winner by hypervolume (largest dominated area = best front).
|
|
||||||
let winner = runs
|
|
||||||
.iter()
|
|
||||||
.max_by(|a, b| {
|
|
||||||
a.hypervolume
|
|
||||||
.partial_cmp(&b.hypervolume)
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
})
|
|
||||||
.expect("non-empty runs");
|
|
||||||
println!(
|
|
||||||
"Best by hypervolume: {} ({:.0})",
|
|
||||||
winner.name, winner.hypervolume
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
//! Solve the Ulysses16 TSP benchmark from TSPLIB using a Genetic Algorithm
|
|
||||||
//! with the new permutation-toolkit operators.
|
|
||||||
//!
|
|
||||||
//! - **Benchmark**: Ulysses16 (Groetschel/Padberg "Odyssey of Ulysses"),
|
|
||||||
//! 16 cities, GEO distance metric (TSPLIB-95).
|
|
||||||
//! - **Known optimum**: tour length **6859**.
|
|
||||||
//! - **Algorithm**: [`GeneticAlgorithm`] with elitism.
|
|
||||||
//! - **Variation**: [`OrderCrossover`] (OX) → [`InversionMutation`], piped
|
|
||||||
//! via [`CompositeVariation`].
|
|
||||||
//! - **Initializer**: [`ShuffledPermutation`].
|
|
||||||
//!
|
|
||||||
//! Source: TSPLIB95
|
|
||||||
//! <http://comopt.ifi.uni-heidelberg.de/software/TSPLIB95/tsp/>
|
|
||||||
//!
|
|
||||||
//! Run with:
|
|
||||||
//!
|
|
||||||
//! ```bash
|
|
||||||
//! cargo run --release --example tsp_ulysses16
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The GA reliably converges to within a few percent of the known optimum on
|
|
||||||
//! this instance; on most seeds it hits 6859 exactly.
|
|
||||||
|
|
||||||
use heuropt::prelude::*;
|
|
||||||
|
|
||||||
/// TSPLIB Ulysses16 coordinates as `(lat, lon)` in TSPLIB DD.MM format.
|
|
||||||
///
|
|
||||||
/// The "decimal" part is *minutes* (out of 60), not a true decimal fraction;
|
|
||||||
/// the GEO distance formula handles the conversion.
|
|
||||||
const ULYSSES16: [(f64, f64); 16] = [
|
|
||||||
(38.24, 20.42),
|
|
||||||
(39.57, 26.15),
|
|
||||||
(40.56, 25.32),
|
|
||||||
(36.26, 23.12),
|
|
||||||
(33.48, 10.54),
|
|
||||||
(37.56, 12.19),
|
|
||||||
(38.42, 13.11),
|
|
||||||
(37.52, 20.44),
|
|
||||||
(41.23, 9.10),
|
|
||||||
(41.17, 13.05),
|
|
||||||
(36.08, -5.21),
|
|
||||||
(38.47, 15.13),
|
|
||||||
(38.15, 15.35),
|
|
||||||
(37.51, 15.17),
|
|
||||||
(35.49, 14.32),
|
|
||||||
(39.36, 19.56),
|
|
||||||
];
|
|
||||||
|
|
||||||
const KNOWN_OPTIMUM: f64 = 6859.0;
|
|
||||||
|
|
||||||
/// TSPLIB-95 GEO distance metric.
|
|
||||||
///
|
|
||||||
/// Coordinates are interpreted as latitude/longitude in DD.MM (decimal-degrees
|
|
||||||
/// with the fractional part being minutes/100), converted to radians, and the
|
|
||||||
/// arc length between the two points on a sphere of radius `RRR = 6378.388`
|
|
||||||
/// is rounded to the next integer (`floor(d + 1)`).
|
|
||||||
fn geo_distance_matrix(coords: &[(f64, f64)]) -> Vec<Vec<f64>> {
|
|
||||||
const RRR: f64 = 6378.388;
|
|
||||||
let to_radians = |x: f64| {
|
|
||||||
let deg = x.trunc();
|
|
||||||
let min = x - deg;
|
|
||||||
std::f64::consts::PI * (deg + 5.0 * min / 3.0) / 180.0
|
|
||||||
};
|
|
||||||
let radians: Vec<(f64, f64)> = coords
|
|
||||||
.iter()
|
|
||||||
.map(|&(la, lo)| (to_radians(la), to_radians(lo)))
|
|
||||||
.collect();
|
|
||||||
let n = radians.len();
|
|
||||||
let mut d = vec![vec![0.0_f64; n]; n];
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
let (la_i, lo_i) = radians[i];
|
|
||||||
let (la_j, lo_j) = radians[j];
|
|
||||||
let q1 = (lo_i - lo_j).cos();
|
|
||||||
let q2 = (la_i - la_j).cos();
|
|
||||||
let q3 = (la_i + la_j).cos();
|
|
||||||
let dij = (RRR * (0.5 * ((1.0 + q1) * q2 - (1.0 - q1) * q3)).acos() + 1.0).trunc();
|
|
||||||
d[i][j] = dij;
|
|
||||||
d[j][i] = dij;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
d
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Ulysses16Tsp {
|
|
||||||
dist: Vec<Vec<f64>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Ulysses16Tsp {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
dist: geo_distance_matrix(&ULYSSES16),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tour_length(&self, tour: &[usize]) -> f64 {
|
|
||||||
let n = tour.len();
|
|
||||||
let mut total = 0.0;
|
|
||||||
for i in 0..n {
|
|
||||||
let a = tour[i];
|
|
||||||
let b = tour[(i + 1) % n];
|
|
||||||
total += self.dist[a][b];
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Problem for Ulysses16Tsp {
|
|
||||||
type Decision = Vec<usize>;
|
|
||||||
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("tour_length")])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![self.tour_length(tour)])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
(0..ULYSSES16.len())
|
|
||||||
.map(|k| DecisionVariable::new(format!("tour_position_{k}")))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let problem = Ulysses16Tsp::new();
|
|
||||||
let n = ULYSSES16.len();
|
|
||||||
|
|
||||||
let mut optimizer = GeneticAlgorithm::new(
|
|
||||||
GeneticAlgorithmConfig {
|
|
||||||
population_size: 150,
|
|
||||||
generations: 1500,
|
|
||||||
tournament_size: 3,
|
|
||||||
elitism: 4,
|
|
||||||
seed: 42,
|
|
||||||
},
|
|
||||||
ShuffledPermutation { n },
|
|
||||||
CompositeVariation {
|
|
||||||
crossover: OrderCrossover,
|
|
||||||
mutation: InversionMutation,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let result = optimizer.run(&problem);
|
|
||||||
|
|
||||||
let best = result.best.expect("GA always returns a best candidate");
|
|
||||||
let best_len = best.evaluation.objectives[0];
|
|
||||||
let gap_abs = best_len - KNOWN_OPTIMUM;
|
|
||||||
let gap_pct = 100.0 * gap_abs / KNOWN_OPTIMUM;
|
|
||||||
|
|
||||||
println!("TSPLIB Ulysses16 — single-objective TSP via Genetic Algorithm");
|
|
||||||
println!("Source: TSPLIB95 (Groetschel/Padberg)");
|
|
||||||
println!();
|
|
||||||
println!("Known optimum: {:>8.0}", KNOWN_OPTIMUM);
|
|
||||||
println!(
|
|
||||||
"GA best found: {:>8.0} (gap {:+.0}, {:+.2}%)",
|
|
||||||
best_len, gap_abs, gap_pct
|
|
||||||
);
|
|
||||||
println!();
|
|
||||||
println!("Total evaluations: {}", result.evaluations);
|
|
||||||
println!("Final population: {}", result.population.len());
|
|
||||||
println!();
|
|
||||||
println!("Tour (city indices, returning to start):");
|
|
||||||
for (i, c) in best.decision.iter().enumerate() {
|
|
||||||
print!("{:>3}", c);
|
|
||||||
if i + 1 < best.decision.len() {
|
|
||||||
print!(" → ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
println!(" → {}", best.decision[0]);
|
|
||||||
}
|
|
||||||
Generated
+1
-1
@@ -66,7 +66,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heuropt"
|
name = "heuropt"
|
||||||
version = "0.8.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rand",
|
"rand",
|
||||||
"rand_distr",
|
"rand_distr",
|
||||||
|
|||||||
@@ -77,17 +77,10 @@ fuzz_target!(|input: Input| {
|
|||||||
);
|
);
|
||||||
let after = y.clone();
|
let after = y.clone();
|
||||||
proj.repair(&mut y);
|
proj.repair(&mut y);
|
||||||
// The simplex projection's `τ` computation operates on values
|
|
||||||
// up to `simplex_total · 1e6` (per the filter above), so its FP
|
|
||||||
// precision floor is ~1e-4 of the input scale. Outputs near the
|
|
||||||
// `max(x_i − τ, 0)` clamp boundary can flip between 0 and a
|
|
||||||
// small positive value across re-applications. The fuzzer is
|
|
||||||
// checking for *gross* non-idempotence (all-zeros vs valid),
|
|
||||||
// not ULP-level slop.
|
|
||||||
let scale = input.simplex_total.max(max_abs).max(1.0);
|
|
||||||
for (a, b) in after.iter().zip(y.iter()) {
|
for (a, b) in after.iter().zip(y.iter()) {
|
||||||
|
let scale = a.abs().max(b.abs()).max(1.0);
|
||||||
assert!(
|
assert!(
|
||||||
(a - b).abs() < 1e-4 * scale,
|
(a - b).abs() < 1e-9 * scale,
|
||||||
"project not idempotent: {a} vs {b}",
|
"project not idempotent: {a} vs {b}",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-331
@@ -155,80 +155,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> AgeMoea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"AgeMoea population_size must be > 0"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"AgeMoea variation returned no children"
|
|
||||||
);
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
population = environmental_selection(combined, &objectives, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -306,17 +232,12 @@ fn environmental_selection<D: Clone>(
|
|||||||
// once per (remaining, pick) pair instead of per (remaining, all-keep).
|
// once per (remaining, pick) pair instead of per (remaining, all-keep).
|
||||||
let mut keep = selected.clone();
|
let mut keep = selected.clone();
|
||||||
let mut remaining: Vec<usize> = splitting.clone();
|
let mut remaining: Vec<usize> = splitting.clone();
|
||||||
// `prox` and `nearest` are only ever read for splitting-front members
|
let prox: Vec<f64> = (0..combined.len())
|
||||||
// (the `remaining` set) — the scoring loop never touches the entries
|
.map(|i| lp_norm(&translated[i], p))
|
||||||
// for `selected` or discarded members. Filling only the `remaining`
|
.collect();
|
||||||
// entries skips `lp_norm` / `lp_distance` work on the rest of
|
let mut nearest: Vec<f64> = (0..combined.len())
|
||||||
// `combined`; bit-identical, since those entries were never used.
|
.map(|i| nearest_neighbor_distance(i, &translated, &keep, p))
|
||||||
let mut prox: Vec<f64> = vec![0.0; combined.len()];
|
.collect();
|
||||||
let mut nearest: Vec<f64> = vec![f64::INFINITY; combined.len()];
|
|
||||||
for &i in &remaining {
|
|
||||||
prox[i] = lp_norm(&translated[i], p);
|
|
||||||
nearest[i] = nearest_neighbor_distance(i, &translated, &keep, p);
|
|
||||||
}
|
|
||||||
while keep.len() < n {
|
while keep.len() < n {
|
||||||
// Pick the remaining candidate with the largest score.
|
// Pick the remaining candidate with the largest score.
|
||||||
let mut best_idx: Option<usize> = None;
|
let mut best_idx: Option<usize> = None;
|
||||||
@@ -429,18 +350,6 @@ fn estimate_p(front_indices: &[usize], translated: &[Vec<f64>], m: usize) -> f64
|
|||||||
best_p
|
best_p
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for AgeMoea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"AGE-MOEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Adaptive Geometry Estimation Multi-Objective Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -496,240 +405,6 @@ mod tests {
|
|||||||
assert_eq!(oa, ob);
|
assert_eq!(oa, ob);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Direct pin tests for the L_p geometry helpers --------------------
|
|
||||||
//
|
|
||||||
// lp_norm / lp_distance / nearest_neighbor_distance / estimate_p are
|
|
||||||
// file-private fns wired into AGE-MOEA's environmental_selection. The
|
|
||||||
// tests below pin their exact numerical outputs on small inputs so the
|
|
||||||
// arithmetic-flip mutants in each function fail.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_norm_l2_of_unit_vector() {
|
|
||||||
let v = [1.0, 0.0, 0.0];
|
|
||||||
assert!((lp_norm(&v, 2.0) - 1.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_norm_l1_of_three_ones() {
|
|
||||||
let v = [1.0, 1.0, 1.0];
|
|
||||||
assert!((lp_norm(&v, 1.0) - 3.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_norm_l2_of_pythagorean_3_4() {
|
|
||||||
let v = [3.0, 4.0];
|
|
||||||
assert!((lp_norm(&v, 2.0) - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_norm_p_one_handles_signed_via_abs() {
|
|
||||||
// lp_norm uses x.abs().powf(p), so signs don't matter — pinning the
|
|
||||||
// .abs() catches `delete -` or `replace * with +` mutants in the
|
|
||||||
// norm body.
|
|
||||||
let v_pos = [1.0, 2.0, 3.0];
|
|
||||||
let v_mixed = [-1.0, 2.0, -3.0];
|
|
||||||
let n_pos = lp_norm(&v_pos, 1.0);
|
|
||||||
let n_mixed = lp_norm(&v_mixed, 1.0);
|
|
||||||
assert!((n_pos - n_mixed).abs() < 1e-12);
|
|
||||||
assert!((n_pos - 6.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_distance_l2_unit_axis() {
|
|
||||||
let a = [0.0, 0.0];
|
|
||||||
let b = [3.0, 4.0];
|
|
||||||
assert!((lp_distance(&a, &b, 2.0) - 5.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_distance_l1_simple() {
|
|
||||||
let a = [1.0, 2.0, 3.0];
|
|
||||||
let b = [4.0, 6.0, 8.0];
|
|
||||||
// |1-4| + |2-6| + |3-8| = 3 + 4 + 5 = 12
|
|
||||||
assert!((lp_distance(&a, &b, 1.0) - 12.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_distance_symmetric() {
|
|
||||||
let a = [1.0, 2.0, -3.0];
|
|
||||||
let b = [-4.0, 5.0, 6.0];
|
|
||||||
assert!((lp_distance(&a, &b, 2.0) - lp_distance(&b, &a, 2.0)).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn lp_distance_zero_to_itself() {
|
|
||||||
let a = [1.0, 2.0, 3.0];
|
|
||||||
assert_eq!(lp_distance(&a, &a, 2.0), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nearest_neighbor_distance_empty_selected_is_infinity() {
|
|
||||||
let translated = vec![vec![0.0, 0.0]];
|
|
||||||
let selected: Vec<usize> = vec![];
|
|
||||||
let d = nearest_neighbor_distance(0, &translated, &selected, 2.0);
|
|
||||||
assert_eq!(d, f64::INFINITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nearest_neighbor_distance_skips_self() {
|
|
||||||
// i == j is skipped, so a point's distance to "itself" alone is ∞.
|
|
||||||
let translated = vec![vec![1.0, 2.0]];
|
|
||||||
let selected = vec![0];
|
|
||||||
let d = nearest_neighbor_distance(0, &translated, &selected, 2.0);
|
|
||||||
assert_eq!(d, f64::INFINITY);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nearest_neighbor_distance_picks_closest() {
|
|
||||||
// Point 0 is at the origin; 1 is far, 2 is near. Expect distance to 2.
|
|
||||||
let translated = vec![vec![0.0, 0.0], vec![10.0, 0.0], vec![1.0, 0.0]];
|
|
||||||
let d = nearest_neighbor_distance(0, &translated, &[1, 2], 2.0);
|
|
||||||
assert!((d - 1.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn estimate_p_empty_front_falls_back_to_two() {
|
|
||||||
// Documented fallback: empty front or zero objectives → p = 2.
|
|
||||||
let translated: Vec<Vec<f64>> = Vec::new();
|
|
||||||
assert_eq!(estimate_p(&[], &translated, 0), 2.0);
|
|
||||||
assert_eq!(estimate_p(&[], &translated, 3), 2.0);
|
|
||||||
assert_eq!(estimate_p(&[0], &translated, 0), 2.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn estimate_p_axis_aligned_extremes_pick_smallest_candidate() {
|
|
||||||
// For axis-aligned unit extremes (1,0) and (0,1), every L_p norm
|
|
||||||
// equals 1, so the CV is 0 across the full candidate sweep. The
|
|
||||||
// function returns the first candidate (0.25). Pins the iteration
|
|
||||||
// direction and the loss tie-breaking.
|
|
||||||
let translated = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
let p = estimate_p(&[0, 1], &translated, 2);
|
|
||||||
assert!(
|
|
||||||
(p - 0.25).abs() < 1e-12,
|
|
||||||
"expected smallest candidate, got {p}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn estimate_p_corner_vs_diagonal_prefers_large_p() {
|
|
||||||
// Extreme points (1, 0) and (1, 1) have lp_norms = 1 and 2^(1/p),
|
|
||||||
// which converge as p → ∞. The candidate sweep covers [0.25, 10],
|
|
||||||
// so the CV-minimizing p lands at the upper end.
|
|
||||||
let translated = vec![vec![1.0, 0.0], vec![1.0, 1.0]];
|
|
||||||
let p = estimate_p(&[0, 1], &translated, 2);
|
|
||||||
assert!(p > 5.0, "expected large p, got {p}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pin the exact pareto-front objectives produced by a 10-generation
|
|
||||||
/// AGE-MOEA run on SchafferN1 at seed 7. Any arithmetic / comparison
|
|
||||||
/// flip inside `run` or `environmental_selection` perturbs at least
|
|
||||||
/// one front objective enough to break the exact-equality assertion.
|
|
||||||
#[test]
|
|
||||||
fn pinned_pareto_front_seed_7_schaffer() {
|
|
||||||
let bounds = vec![(-5.0, 5.0)];
|
|
||||||
let initializer = RealBounds::new(bounds.clone());
|
|
||||||
let variation = CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
|
||||||
};
|
|
||||||
let mut opt = AgeMoea::new(
|
|
||||||
AgeMoeaConfig {
|
|
||||||
population_size: 8,
|
|
||||||
generations: 10,
|
|
||||||
seed: 7,
|
|
||||||
},
|
|
||||||
initializer,
|
|
||||||
variation,
|
|
||||||
);
|
|
||||||
let r = opt.run(&SchafferN1);
|
|
||||||
assert_eq!(r.population.len(), 8);
|
|
||||||
// Snapshot the front: this is a regression pin — if you change the
|
|
||||||
// algorithm intentionally, regenerate. If a mutation changes one
|
|
||||||
// bit of arithmetic, the value below will not match.
|
|
||||||
let mut got: Vec<Vec<f64>> = r
|
|
||||||
.pareto_front
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives.clone())
|
|
||||||
.collect();
|
|
||||||
got.sort_by(|a, b| a[0].partial_cmp(&b[0]).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
assert!(
|
|
||||||
!got.is_empty(),
|
|
||||||
"pareto front empty — likely run() degenerate-mutant survived"
|
|
||||||
);
|
|
||||||
// The recovered front must have at least one point where both
|
|
||||||
// objectives are nonneg and finite — sanity check.
|
|
||||||
for o in &got {
|
|
||||||
assert!(o[0].is_finite() && o[1].is_finite());
|
|
||||||
assert!(o[0] >= 0.0 && o[1] >= 0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `environmental_selection` reduces a 2N-sized combined population
|
|
||||||
/// down to N. Pin that exact count post-survival so any mutant that
|
|
||||||
/// skips selection rounds (e.g., a comparison flip in the while-loop
|
|
||||||
/// that breaks the truncation) gets caught.
|
|
||||||
#[test]
|
|
||||||
fn final_population_size_matches_config() {
|
|
||||||
let bounds = vec![(-5.0, 5.0)];
|
|
||||||
let initializer = RealBounds::new(bounds.clone());
|
|
||||||
let variation = CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
|
||||||
};
|
|
||||||
for pop in [4_usize, 12, 30] {
|
|
||||||
let mut opt = AgeMoea::new(
|
|
||||||
AgeMoeaConfig {
|
|
||||||
population_size: pop,
|
|
||||||
generations: 5,
|
|
||||||
seed: 13,
|
|
||||||
},
|
|
||||||
initializer.clone(),
|
|
||||||
variation.clone(),
|
|
||||||
);
|
|
||||||
let r = opt.run(&SchafferN1);
|
|
||||||
assert_eq!(r.population.len(), pop, "pop size mismatch at config={pop}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `run()` must record at least one evaluation per individual per
|
|
||||||
/// generation. Pin the count so mutants flipping the offspring loop's
|
|
||||||
/// comparisons (e.g., `>=` ↔ `<`) are caught when they cause skipped
|
|
||||||
/// evaluations.
|
|
||||||
#[test]
|
|
||||||
fn evaluation_count_at_least_pop_times_gens_plus_init() {
|
|
||||||
let bounds = vec![(-5.0, 5.0)];
|
|
||||||
let initializer = RealBounds::new(bounds.clone());
|
|
||||||
let variation = CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
|
||||||
};
|
|
||||||
let pop = 6_usize;
|
|
||||||
let gens = 4_usize;
|
|
||||||
let mut opt = AgeMoea::new(
|
|
||||||
AgeMoeaConfig {
|
|
||||||
population_size: pop,
|
|
||||||
generations: gens,
|
|
||||||
seed: 13,
|
|
||||||
},
|
|
||||||
initializer,
|
|
||||||
variation,
|
|
||||||
);
|
|
||||||
let r = opt.run(&SchafferN1);
|
|
||||||
// Initial pop (6) + per-gen offspring (≤ 6 each gen).
|
|
||||||
assert!(
|
|
||||||
r.evaluations >= pop,
|
|
||||||
"evals = {} < initial pop {}",
|
|
||||||
r.evaluations,
|
|
||||||
pop,
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
r.evaluations <= pop * (gens + 1),
|
|
||||||
"evals = {} > pop*(gens+1) = {}",
|
|
||||||
r.evaluations,
|
|
||||||
pop * (gens + 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[should_panic(expected = "population_size must be > 0")]
|
#[should_panic(expected = "population_size must be > 0")]
|
||||||
fn zero_pop_panics() {
|
fn zero_pop_panics() {
|
||||||
|
|||||||
@@ -147,46 +147,41 @@ where
|
|||||||
let n = self.distances.len();
|
let n = self.distances.len();
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
let mut rng = rng_from_seed(self.config.seed);
|
||||||
|
|
||||||
// Heuristic desirability 1/distance, pre-raised to β. η is constant
|
// Heuristic desirability: 1 / distance (with a small floor to avoid
|
||||||
// for the whole run, so β is applied exactly once here instead of
|
// division by zero for very-close cities).
|
||||||
// once per ant per step inside `build_tour`.
|
let eta: Vec<Vec<f64>> = self
|
||||||
let eta_pow: Vec<Vec<f64>> = self
|
|
||||||
.distances
|
.distances
|
||||||
.iter()
|
.iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
row.iter()
|
row.iter()
|
||||||
.map(|&d| {
|
.map(|&d| if d > 0.0 { 1.0 / d } else { 0.0 })
|
||||||
let e = if d > 0.0 { 1.0 / d } else { 0.0 };
|
|
||||||
e.powf(self.config.beta)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Pheromone matrix, plus a reused buffer holding τ pre-raised to α.
|
// Pheromone matrix.
|
||||||
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
|
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
|
||||||
let mut pheromone_pow: Vec<Vec<f64>> = vec![vec![0.0_f64; n]; n];
|
|
||||||
|
|
||||||
let mut best_decision: Option<Vec<usize>> = None;
|
let mut best_decision: Option<Vec<usize>> = None;
|
||||||
let mut best_eval: Option<crate::core::evaluation::Evaluation> = None;
|
let mut best_eval: Option<crate::core::evaluation::Evaluation> = None;
|
||||||
let mut evaluations = 0usize;
|
let mut evaluations = 0usize;
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
for _ in 0..self.config.generations {
|
||||||
// τ is constant across the ant loop, so raise it to α once per
|
|
||||||
// generation rather than once per ant per step per candidate.
|
|
||||||
for (src, dst) in pheromone.iter().zip(pheromone_pow.iter_mut()) {
|
|
||||||
for (&t, p) in src.iter().zip(dst.iter_mut()) {
|
|
||||||
*p = t.max(0.0).powf(self.config.alpha);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
|
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
|
||||||
let mut tour_evals: Vec<crate::core::evaluation::Evaluation> =
|
let mut tour_evals: Vec<crate::core::evaluation::Evaluation> =
|
||||||
Vec::with_capacity(self.config.ants);
|
Vec::with_capacity(self.config.ants);
|
||||||
|
|
||||||
for _ in 0..self.config.ants {
|
for _ in 0..self.config.ants {
|
||||||
let start = rng.random_range(0..n);
|
let start = rng.random_range(0..n);
|
||||||
let tour = build_tour(n, start, &pheromone_pow, &eta_pow, &mut rng);
|
let tour = build_tour(
|
||||||
|
n,
|
||||||
|
start,
|
||||||
|
&pheromone,
|
||||||
|
&eta,
|
||||||
|
self.config.alpha,
|
||||||
|
self.config.beta,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
let eval = problem.evaluate(&tour);
|
let eval = problem.evaluate(&tour);
|
||||||
evaluations += 1;
|
evaluations += 1;
|
||||||
tours.push(tour);
|
tours.push(tour);
|
||||||
@@ -246,126 +241,13 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl AntColonyTsp {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per generation.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<usize>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<usize>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(self.config.ants >= 1, "AntColonyTsp ants must be >= 1");
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"AntColonyTsp requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let n = self.distances.len();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let eta_pow: Vec<Vec<f64>> = self
|
|
||||||
.distances
|
|
||||||
.iter()
|
|
||||||
.map(|row| {
|
|
||||||
row.iter()
|
|
||||||
.map(|&d| {
|
|
||||||
let e = if d > 0.0 { 1.0 / d } else { 0.0 };
|
|
||||||
e.powf(self.config.beta)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut pheromone: Vec<Vec<f64>> = vec![vec![self.config.initial_pheromone; n]; n];
|
|
||||||
let mut pheromone_pow: Vec<Vec<f64>> = vec![vec![0.0_f64; n]; n];
|
|
||||||
|
|
||||||
let mut best_decision: Option<Vec<usize>> = None;
|
|
||||||
let mut best_eval: Option<crate::core::evaluation::Evaluation> = None;
|
|
||||||
let mut evaluations = 0usize;
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
for (src, dst) in pheromone.iter().zip(pheromone_pow.iter_mut()) {
|
|
||||||
for (&t, p) in src.iter().zip(dst.iter_mut()) {
|
|
||||||
*p = t.max(0.0).powf(self.config.alpha);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut tours: Vec<Vec<usize>> = Vec::with_capacity(self.config.ants);
|
|
||||||
for _ in 0..self.config.ants {
|
|
||||||
let start = rng.random_range(0..n);
|
|
||||||
let tour = build_tour(n, start, &pheromone_pow, &eta_pow, &mut rng);
|
|
||||||
tours.push(tour);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cands = evaluate_batch_async(problem, tours.clone(), concurrency).await;
|
|
||||||
evaluations += cands.len();
|
|
||||||
let tour_evals: Vec<crate::core::evaluation::Evaluation> =
|
|
||||||
cands.into_iter().map(|c| c.evaluation).collect();
|
|
||||||
|
|
||||||
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
|
|
||||||
let beats = match &best_eval {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than_so(eval, b, direction),
|
|
||||||
};
|
|
||||||
if beats {
|
|
||||||
best_decision = Some(tour.clone());
|
|
||||||
best_eval = Some(eval.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for row in pheromone.iter_mut() {
|
|
||||||
for v in row.iter_mut() {
|
|
||||||
*v *= 1.0 - self.config.evaporation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (tour, eval) in tours.iter().zip(tour_evals.iter()) {
|
|
||||||
let length = eval
|
|
||||||
.objectives
|
|
||||||
.first()
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(f64::INFINITY)
|
|
||||||
.max(1e-12);
|
|
||||||
let deposit = self.config.deposit / length;
|
|
||||||
for w in tour.windows(2) {
|
|
||||||
let (i, j) = (w[0], w[1]);
|
|
||||||
pheromone[i][j] += deposit;
|
|
||||||
pheromone[j][i] += deposit;
|
|
||||||
}
|
|
||||||
let (i, j) = (*tour.last().unwrap(), tour[0]);
|
|
||||||
pheromone[i][j] += deposit;
|
|
||||||
pheromone[j][i] += deposit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = Candidate::new(best_decision.unwrap(), best_eval.unwrap());
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_tour(
|
fn build_tour(
|
||||||
n: usize,
|
n: usize,
|
||||||
start: usize,
|
start: usize,
|
||||||
pheromone_pow: &[Vec<f64>],
|
pheromone: &[Vec<f64>],
|
||||||
eta_pow: &[Vec<f64>],
|
eta: &[Vec<f64>],
|
||||||
|
alpha: f64,
|
||||||
|
beta: f64,
|
||||||
rng: &mut crate::core::rng::Rng,
|
rng: &mut crate::core::rng::Rng,
|
||||||
) -> Vec<usize> {
|
) -> Vec<usize> {
|
||||||
let mut tour = Vec::with_capacity(n);
|
let mut tour = Vec::with_capacity(n);
|
||||||
@@ -375,13 +257,11 @@ fn build_tour(
|
|||||||
|
|
||||||
for _ in 1..n {
|
for _ in 1..n {
|
||||||
let current = *tour.last().unwrap();
|
let current = *tour.last().unwrap();
|
||||||
// Build a probability vector over the unvisited candidates. Both
|
// Build a probability vector over the unvisited candidates.
|
||||||
// matrices are already raised to α / β by the caller, so the per-
|
|
||||||
// candidate weight is a single multiply — no `powf` in the hot loop.
|
|
||||||
let probs: Vec<(usize, f64)> = (0..n)
|
let probs: Vec<(usize, f64)> = (0..n)
|
||||||
.filter(|&j| !visited[j])
|
.filter(|&j| !visited[j])
|
||||||
.map(|j| {
|
.map(|j| {
|
||||||
let p = pheromone_pow[current][j] * eta_pow[current][j];
|
let p = pheromone[current][j].max(0.0).powf(alpha) * eta[current][j].powf(beta);
|
||||||
(j, p)
|
(j, p)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -436,18 +316,6 @@ fn better_than_so(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for AntColonyTsp {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Ant Colony"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Ant Colony System for TSP"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -572,125 +440,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&DummyMo);
|
let _ = opt.run(&DummyMo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
|
|
||||||
/// Raise every matrix entry to `p` — mirrors the α / β pre-raising the
|
|
||||||
/// `run` loop now does before calling `build_tour`.
|
|
||||||
fn raise(m: &[Vec<f64>], p: f64) -> Vec<Vec<f64>> {
|
|
||||||
m.iter()
|
|
||||||
.map(|row| row.iter().map(|&v| v.powf(p)).collect())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `better_than_so` follows the feasibility-first / objective-second
|
|
||||||
/// tournament rule. Pin each of the four feasibility-cross-product
|
|
||||||
/// branches so the `<` and `>` comparisons cannot flip silently.
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_feasible_beats_infeasible() {
|
|
||||||
let mut a = Evaluation::new(vec![10.0]);
|
|
||||||
a.constraint_violation = 0.0; // feasible
|
|
||||||
let mut b = Evaluation::new(vec![1.0]);
|
|
||||||
b.constraint_violation = 1.0; // infeasible
|
|
||||||
assert!(better_than_so(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_two_infeasible_compares_violation() {
|
|
||||||
let mut a = Evaluation::new(vec![0.0]);
|
|
||||||
a.constraint_violation = 0.5;
|
|
||||||
let mut b = Evaluation::new(vec![0.0]);
|
|
||||||
b.constraint_violation = 1.0;
|
|
||||||
// a has smaller constraint_violation → "better".
|
|
||||||
assert!(better_than_so(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_two_feasible_compares_objective_under_min() {
|
|
||||||
let a = Evaluation::new(vec![1.0]); // feasible (default cv=0)
|
|
||||||
let b = Evaluation::new(vec![2.0]); // feasible
|
|
||||||
assert!(better_than_so(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_two_feasible_compares_objective_under_max() {
|
|
||||||
let a = Evaluation::new(vec![2.0]);
|
|
||||||
let b = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(better_than_so(&a, &b, Direction::Maximize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Maximize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_equal_objectives_neither_strictly_better() {
|
|
||||||
let a = Evaluation::new(vec![1.0]);
|
|
||||||
let b = Evaluation::new(vec![1.0]);
|
|
||||||
// Equal objectives → strict `<` is false both directions.
|
|
||||||
assert!(!better_than_so(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `build_tour` must produce a permutation of `[0..n)` starting at the
|
|
||||||
/// given start city. Pin both invariants across many seeds.
|
|
||||||
#[test]
|
|
||||||
fn build_tour_is_permutation_starting_at_start() {
|
|
||||||
let n = 6;
|
|
||||||
let pher = raise(&vec![vec![1.0; n]; n], 1.0);
|
|
||||||
let eta = raise(&vec![vec![1.0; n]; n], 2.0);
|
|
||||||
for seed in 0..20 {
|
|
||||||
for start in 0..n {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
let tour = build_tour(n, start, &pher, &eta, &mut rng);
|
|
||||||
assert_eq!(tour.len(), n);
|
|
||||||
assert_eq!(tour[0], start, "tour must start at the given city");
|
|
||||||
let mut sorted = tour.clone();
|
|
||||||
sorted.sort();
|
|
||||||
let expected: Vec<usize> = (0..n).collect();
|
|
||||||
assert_eq!(sorted, expected, "tour must visit every city exactly once");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With a high `beta` and a heuristic that strongly prefers the next
|
|
||||||
/// city, `build_tour` chooses that next city with near-certainty.
|
|
||||||
/// Pins the heuristic-weighting arithmetic.
|
|
||||||
#[test]
|
|
||||||
fn build_tour_follows_strong_heuristic() {
|
|
||||||
let n = 4;
|
|
||||||
let pher = raise(&vec![vec![1.0; n]; n], 1.0);
|
|
||||||
// Heuristic strongly favors city (i+1) % n: 1000x preferred.
|
|
||||||
let mut eta = vec![vec![1.0; n]; n];
|
|
||||||
for i in 0..n {
|
|
||||||
eta[i][(i + 1) % n] = 1000.0;
|
|
||||||
}
|
|
||||||
let eta = raise(&eta, 5.0);
|
|
||||||
let mut rng = rng_from_seed(0);
|
|
||||||
let tour = build_tour(n, 0, &pher, &eta, &mut rng);
|
|
||||||
// With beta=5 and 1000× heuristic, the path 0→1→2→3 has overwhelming
|
|
||||||
// probability.
|
|
||||||
assert_eq!(tour, vec![0, 1, 2, 3]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `build_tour` with zero alpha + zero beta degenerates to uniform
|
|
||||||
/// random over unvisited cities; the result is still a permutation.
|
|
||||||
#[test]
|
|
||||||
fn build_tour_zero_weights_still_produces_permutation() {
|
|
||||||
let n = 5;
|
|
||||||
let pher = raise(&vec![vec![1.0; n]; n], 0.0);
|
|
||||||
let eta = raise(&vec![vec![1.0; n]; n], 0.0);
|
|
||||||
let mut rng = rng_from_seed(42);
|
|
||||||
let tour = build_tour(n, 2, &pher, &eta, &mut rng);
|
|
||||||
// With alpha=beta=0, every term is 1.0 so the result is uniform but
|
|
||||||
// still a permutation.
|
|
||||||
assert_eq!(tour.len(), n);
|
|
||||||
assert_eq!(tour[0], 2);
|
|
||||||
let mut sorted = tour.clone();
|
|
||||||
sorted.sort();
|
|
||||||
assert_eq!(sorted, vec![0, 1, 2, 3, 4]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-330
@@ -194,22 +194,16 @@ where
|
|||||||
|
|
||||||
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
|
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||||
|
|
||||||
// Maximize EI by best-of-N random sampling. `cand` and the two
|
// Maximize EI by best-of-N random sampling.
|
||||||
// GP-prediction scratch buffers are reused across all samples
|
|
||||||
// so the inner loop allocates nothing.
|
|
||||||
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
||||||
let mut best_ei = -f64::INFINITY;
|
let mut best_ei = -f64::INFINITY;
|
||||||
let mut cand: Vec<f64> = Vec::with_capacity(dim);
|
|
||||||
let mut k_star_buf: Vec<f64> = Vec::new();
|
|
||||||
let mut v_temp_buf: Vec<f64> = Vec::new();
|
|
||||||
for _ in 0..self.config.acquisition_samples {
|
for _ in 0..self.config.acquisition_samples {
|
||||||
sample_uniform_in_bounds_into(&self.bounds, &mut rng, &mut cand);
|
let cand = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
||||||
let (mu, sigma) = posterior.predict_into(&cand, &mut k_star_buf, &mut v_temp_buf);
|
let (mu, sigma) = posterior.predict(&cand);
|
||||||
let ei = expected_improvement(mu, sigma, best_target);
|
let ei = expected_improvement(mu, sigma, best_target);
|
||||||
if ei > best_ei {
|
if ei > best_ei {
|
||||||
best_ei = ei;
|
best_ei = ei;
|
||||||
best_x.clear();
|
best_x = cand;
|
||||||
best_x.extend_from_slice(&cand);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,23 +270,18 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sample a uniform-in-bounds point into `out` (reused across calls).
|
fn sample_uniform_in_bounds(bounds: &RealBounds, rng: &mut Rng) -> Vec<f64> {
|
||||||
fn sample_uniform_in_bounds_into(bounds: &RealBounds, rng: &mut Rng, out: &mut Vec<f64>) {
|
bounds
|
||||||
out.clear();
|
.bounds
|
||||||
for &(lo, hi) in &bounds.bounds {
|
.iter()
|
||||||
let v = if lo == hi {
|
.map(|&(lo, hi)| {
|
||||||
|
if lo == hi {
|
||||||
lo
|
lo
|
||||||
} else {
|
} else {
|
||||||
lo + (hi - lo) * rng.random::<f64>()
|
lo + (hi - lo) * rng.random::<f64>()
|
||||||
};
|
|
||||||
out.push(v);
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
.collect()
|
||||||
fn sample_uniform_in_bounds(bounds: &RealBounds, rng: &mut Rng) -> Vec<f64> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
sample_uniform_in_bounds_into(bounds, rng, &mut out);
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Anisotropic RBF kernel: `k(x, y) = σ² · exp(-0.5 · Σ ((x_i - y_i)/ℓ_i)²)`.
|
/// Anisotropic RBF kernel: `k(x, y) = σ² · exp(-0.5 · Σ ((x_i - y_i)/ℓ_i)²)`.
|
||||||
@@ -344,23 +333,26 @@ impl GpPosterior {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Predict `(mean, std)` at `x`, using caller-owned scratch buffers
|
fn predict(&self, x: &[f64]) -> (f64, f64) {
|
||||||
/// (`k_star`, `v_temp`) so the hot acquisition loop allocates nothing.
|
|
||||||
fn predict_into(&self, x: &[f64], k_star: &mut Vec<f64>, v_temp: &mut Vec<f64>) -> (f64, f64) {
|
|
||||||
let n = self.decisions.len();
|
let n = self.decisions.len();
|
||||||
k_star.clear();
|
let mut k_star = vec![0.0_f64; n];
|
||||||
k_star.reserve(n);
|
for (i, k_star_i) in k_star.iter_mut().enumerate() {
|
||||||
for d in &self.decisions {
|
*k_star_i = rbf_kernel(
|
||||||
k_star.push(rbf_kernel(x, d, &self.length_scales, self.signal_variance));
|
x,
|
||||||
|
&self.decisions[i],
|
||||||
|
&self.length_scales,
|
||||||
|
self.signal_variance,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
let _ = n;
|
||||||
let mu: f64 = k_star
|
let mu: f64 = k_star
|
||||||
.iter()
|
.iter()
|
||||||
.zip(self.alpha.iter())
|
.zip(self.alpha.iter())
|
||||||
.map(|(a, b)| a * b)
|
.map(|(a, b)| a * b)
|
||||||
.sum();
|
.sum();
|
||||||
// Var = k(x,x) - k_star^T · K^{-1} · k_star; the squared norm of
|
// Var = k(x,x) - k_star^T · K^{-1} · k_star
|
||||||
// `solve_lower(L, k_star)` is exactly `k_star^T · K^{-1} · k_star`.
|
// Compute K^{-1}·k_star = solve_upper_transpose(L, solve_lower(L, k_star))
|
||||||
crate::internal::cholesky::solve_lower_into(&self.chol_l, k_star, v_temp);
|
let v_temp = crate::internal::cholesky::solve_lower(&self.chol_l, &k_star);
|
||||||
let v: f64 = v_temp.iter().map(|x| x * x).sum();
|
let v: f64 = v_temp.iter().map(|x| x * x).sum();
|
||||||
let var = (self.signal_variance - v).max(0.0);
|
let var = (self.signal_variance - v).max(0.0);
|
||||||
(mu, var.sqrt())
|
(mu, var.sqrt())
|
||||||
@@ -404,166 +396,6 @@ fn erf(x: f64) -> f64 {
|
|||||||
sign * y
|
sign * y
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl BayesianOpt {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations during the initial
|
|
||||||
/// uniform-sample design; the sequential BO loop runs one
|
|
||||||
/// evaluation per iteration regardless.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.initial_samples >= 2,
|
|
||||||
"BayesianOpt initial_samples must be >= 2",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.signal_variance > 0.0,
|
|
||||||
"BayesianOpt signal_variance must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.noise_variance > 0.0,
|
|
||||||
"BayesianOpt noise_variance must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.acquisition_samples >= 1,
|
|
||||||
"BayesianOpt acquisition_samples must be >= 1",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"BayesianOpt requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
if let Some(ls) = &self.config.length_scales {
|
|
||||||
assert_eq!(
|
|
||||||
ls.len(),
|
|
||||||
dim,
|
|
||||||
"BayesianOpt length_scales.len() must equal dim"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let length_scales: Vec<f64> = self.config.length_scales.clone().unwrap_or_else(|| {
|
|
||||||
self.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.2 * (hi - lo).max(1e-9))
|
|
||||||
.collect()
|
|
||||||
});
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
// Initial random design: sample all decisions first (consuming
|
|
||||||
// RNG in the same order as the sync `run`), then evaluate
|
|
||||||
// concurrently.
|
|
||||||
let mut decisions: Vec<Vec<f64>> =
|
|
||||||
Vec::with_capacity(self.config.initial_samples + self.config.iterations);
|
|
||||||
let mut targets: Vec<f64> = Vec::with_capacity(decisions.capacity());
|
|
||||||
let mut evaluations: Vec<Evaluation> = Vec::with_capacity(decisions.capacity());
|
|
||||||
|
|
||||||
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
|
|
||||||
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
|
|
||||||
.collect();
|
|
||||||
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
for c in initial_cands {
|
|
||||||
let t = oriented_target(&c.evaluation, direction);
|
|
||||||
decisions.push(c.decision);
|
|
||||||
targets.push(t);
|
|
||||||
evaluations.push(c.evaluation);
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let posterior = match GpPosterior::fit(
|
|
||||||
&decisions,
|
|
||||||
&targets,
|
|
||||||
&length_scales,
|
|
||||||
self.config.signal_variance,
|
|
||||||
self.config.noise_variance,
|
|
||||||
) {
|
|
||||||
Ok(p) => p,
|
|
||||||
Err(_) => {
|
|
||||||
let x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
|
||||||
let e = problem.evaluate_async(&x).await;
|
|
||||||
targets.push(oriented_target(&e, direction));
|
|
||||||
decisions.push(x);
|
|
||||||
evaluations.push(e);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let best_target = targets.iter().cloned().fold(f64::INFINITY, f64::min);
|
|
||||||
|
|
||||||
let mut best_x = sample_uniform_in_bounds(&self.bounds, &mut rng);
|
|
||||||
let mut best_ei = -f64::INFINITY;
|
|
||||||
let mut cand: Vec<f64> = Vec::with_capacity(dim);
|
|
||||||
let mut k_star_buf: Vec<f64> = Vec::new();
|
|
||||||
let mut v_temp_buf: Vec<f64> = Vec::new();
|
|
||||||
for _ in 0..self.config.acquisition_samples {
|
|
||||||
sample_uniform_in_bounds_into(&self.bounds, &mut rng, &mut cand);
|
|
||||||
let (mu, sigma) = posterior.predict_into(&cand, &mut k_star_buf, &mut v_temp_buf);
|
|
||||||
let ei = expected_improvement(mu, sigma, best_target);
|
|
||||||
if ei > best_ei {
|
|
||||||
best_ei = ei;
|
|
||||||
best_x.clear();
|
|
||||||
best_x.extend_from_slice(&cand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let e = problem.evaluate_async(&best_x).await;
|
|
||||||
targets.push(oriented_target(&e, direction));
|
|
||||||
decisions.push(best_x);
|
|
||||||
evaluations.push(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
|
||||||
.into_iter()
|
|
||||||
.zip(evaluations)
|
|
||||||
.map(|(d, e)| Candidate::new(d, e))
|
|
||||||
.collect();
|
|
||||||
let mut best_idx = 0;
|
|
||||||
for i in 1..final_pop.len() {
|
|
||||||
if better(
|
|
||||||
&final_pop[i].evaluation,
|
|
||||||
&final_pop[best_idx].evaluation,
|
|
||||||
direction,
|
|
||||||
) {
|
|
||||||
best_idx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let total_evaluations = final_pop.len();
|
|
||||||
let best = final_pop[best_idx].clone();
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
total_evaluations,
|
|
||||||
self.config.iterations + self.config.initial_samples,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for BayesianOpt {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Bayesian Optimization"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Gaussian Process Bayesian Optimization with Expected Improvement"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -635,141 +467,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
//
|
|
||||||
// BayesianOpt's GP / EI machinery has many pure helpers (rbf_kernel,
|
|
||||||
// expected_improvement, normal_pdf/cdf, erf, oriented_target, better).
|
|
||||||
// The tests below pin their exact numerical outputs.
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rbf_kernel_x_equals_y_is_signal_variance() {
|
|
||||||
let x = vec![0.5_f64, -1.0, 2.0];
|
|
||||||
let lengths = vec![1.0_f64; 3];
|
|
||||||
assert!((rbf_kernel(&x, &x, &lengths, 1.5) - 1.5).abs() < 1e-12);
|
|
||||||
// Different signal variance scales the result.
|
|
||||||
assert!((rbf_kernel(&x, &x, &lengths, 4.0) - 4.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rbf_kernel_unit_distance_unit_length() {
|
|
||||||
// k = exp(-0.5 * (1)^2) = exp(-0.5) ≈ 0.6065
|
|
||||||
let got = rbf_kernel(&[0.0], &[1.0], &[1.0], 1.0);
|
|
||||||
let expected = (-0.5_f64).exp();
|
|
||||||
assert!(
|
|
||||||
(got - expected).abs() < 1e-12,
|
|
||||||
"got {got}, expected {expected}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rbf_kernel_far_points_approach_zero() {
|
|
||||||
let got = rbf_kernel(&[0.0], &[100.0], &[1.0], 1.0);
|
|
||||||
assert!((0.0..1e-12).contains(&got), "got {got}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rbf_kernel_length_scale_widens_kernel() {
|
|
||||||
// Same distance, larger length scale → larger kernel value.
|
|
||||||
let small_l = rbf_kernel(&[0.0], &[1.0], &[1.0], 1.0);
|
|
||||||
let large_l = rbf_kernel(&[0.0], &[1.0], &[10.0], 1.0);
|
|
||||||
assert!(large_l > small_l, "small_l={small_l} large_l={large_l}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_pdf_at_zero_is_inverse_sqrt_2pi() {
|
|
||||||
let got = normal_pdf(0.0);
|
|
||||||
let expected = 1.0 / (2.0 * std::f64::consts::PI).sqrt();
|
|
||||||
assert!((got - expected).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_pdf_symmetric_about_zero() {
|
|
||||||
for z in [0.5_f64, 1.0, 2.5] {
|
|
||||||
assert!((normal_pdf(z) - normal_pdf(-z)).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_cdf_at_zero_is_one_half() {
|
|
||||||
assert!((normal_cdf(0.0) - 0.5).abs() < 1e-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn normal_cdf_sums_to_one_at_symmetric_points() {
|
|
||||||
for z in [0.5_f64, 1.0, 2.5] {
|
|
||||||
let s = normal_cdf(z) + normal_cdf(-z);
|
|
||||||
assert!((s - 1.0).abs() < 1e-9, "z={z} sum={s}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn erf_zero_is_zero() {
|
|
||||||
// The Numerical-Recipes-style rational approximation has ~1e-7 accuracy.
|
|
||||||
assert!(erf(0.0).abs() < 1e-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn erf_odd_function() {
|
|
||||||
for x in [0.1_f64, 0.5, 1.0, 2.0] {
|
|
||||||
assert!((erf(x) + erf(-x)).abs() < 1e-9, "x={x}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_improvement_zero_sigma_is_zero() {
|
|
||||||
assert_eq!(expected_improvement(0.0, 0.0, 1.0), 0.0);
|
|
||||||
assert_eq!(expected_improvement(-5.0, 1e-13, 1.0), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_improvement_grows_with_sigma() {
|
|
||||||
// At μ = f_best, EI is proportional to σ.
|
|
||||||
let lo = expected_improvement(1.0, 0.1, 1.0);
|
|
||||||
let hi = expected_improvement(1.0, 1.0, 1.0);
|
|
||||||
assert!(hi > lo, "lo={lo} hi={hi}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn expected_improvement_positive_when_mu_below_fbest() {
|
|
||||||
// μ < f_best means improvement is expected → EI > 0.
|
|
||||||
let ei = expected_improvement(0.5, 0.5, 1.0);
|
|
||||||
assert!(ei > 0.0, "ei = {ei}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oriented_target_flips_sign_under_maximize() {
|
|
||||||
let e = Evaluation::new(vec![3.0]);
|
|
||||||
assert!((oriented_target(&e, Direction::Minimize) - 3.0).abs() < 1e-12);
|
|
||||||
assert!((oriented_target(&e, Direction::Maximize) - (-3.0)).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oriented_target_penalizes_infeasible() {
|
|
||||||
let mut e = Evaluation::new(vec![1.0]);
|
|
||||||
e.constraint_violation = 0.5;
|
|
||||||
// base 1.0 + 1e6 * 0.5 = 500001.0
|
|
||||||
let got = oriented_target(&e, Direction::Minimize);
|
|
||||||
assert!((got - 500_001.0).abs() < 1e-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_helper_feasibility_first() {
|
|
||||||
let mut a = Evaluation::new(vec![10.0]);
|
|
||||||
a.constraint_violation = 0.0;
|
|
||||||
let mut b = Evaluation::new(vec![1.0]);
|
|
||||||
b.constraint_violation = 1.0;
|
|
||||||
assert!(better(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better(&b, &a, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_helper_two_feasible_under_min_and_max() {
|
|
||||||
let a = Evaluation::new(vec![1.0]);
|
|
||||||
let b = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better(&b, &a, Direction::Minimize));
|
|
||||||
assert!(better(&b, &a, Direction::Maximize));
|
|
||||||
assert!(!better(&a, &b, Direction::Maximize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,237 +366,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl CmaEs {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per generation.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 4,
|
|
||||||
"CmaEs population_size must be >= 4",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.initial_sigma > 0.0,
|
|
||||||
"CmaEs initial_sigma must be positive",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.eigen_decomposition_period >= 1,
|
|
||||||
"CmaEs eigen_decomposition_period must be >= 1",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"CmaEs only supports single-objective problems",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
|
|
||||||
let n = self.bounds.bounds.len();
|
|
||||||
let n_f = n as f64;
|
|
||||||
let lambda = self.config.population_size;
|
|
||||||
let lambda_f = lambda as f64;
|
|
||||||
let mu = lambda / 2;
|
|
||||||
assert!(mu >= 1, "CmaEs derived mu (= lambda/2) must be >= 1");
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let raw_weights: Vec<f64> = (0..mu)
|
|
||||||
.map(|i| ((lambda_f + 1.0) / 2.0).ln() - ((i + 1) as f64).ln())
|
|
||||||
.collect();
|
|
||||||
let sum_w: f64 = raw_weights.iter().sum();
|
|
||||||
let weights: Vec<f64> = raw_weights.iter().map(|w| w / sum_w).collect();
|
|
||||||
let mu_eff = 1.0 / weights.iter().map(|w| w * w).sum::<f64>();
|
|
||||||
|
|
||||||
let c_sigma = (mu_eff + 2.0) / (n_f + mu_eff + 5.0);
|
|
||||||
let d_sigma = 1.0 + 2.0 * ((mu_eff - 1.0) / (n_f + 1.0)).sqrt().max(0.0) + c_sigma;
|
|
||||||
let c_c = (4.0 + mu_eff / n_f) / (n_f + 4.0 + 2.0 * mu_eff / n_f);
|
|
||||||
let c_1 = 2.0 / ((n_f + 1.3).powi(2) + mu_eff);
|
|
||||||
let c_mu = ((1.0 - c_1) * 2.0 * (mu_eff - 2.0 + 1.0 / mu_eff)
|
|
||||||
/ ((n_f + 2.0).powi(2) + mu_eff))
|
|
||||||
.min(1.0 - c_1);
|
|
||||||
let chi_n = n_f.sqrt() * (1.0 - 1.0 / (4.0 * n_f) + 1.0 / (21.0 * n_f * n_f));
|
|
||||||
|
|
||||||
let mut mean: Vec<f64> = if let Some(provided) = self.config.initial_mean.clone() {
|
|
||||||
assert_eq!(
|
|
||||||
provided.len(),
|
|
||||||
self.bounds.bounds.len(),
|
|
||||||
"CmaEs initial_mean.len() must equal the bounds dimension",
|
|
||||||
);
|
|
||||||
provided
|
|
||||||
.into_iter()
|
|
||||||
.zip(self.bounds.bounds.iter())
|
|
||||||
.map(|(v, &(lo, hi))| v.clamp(lo, hi))
|
|
||||||
.collect()
|
|
||||||
} else {
|
|
||||||
self.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
let mut sigma = self.config.initial_sigma;
|
|
||||||
let mut c_matrix: Vec<Vec<f64>> = (0..n)
|
|
||||||
.map(|i| (0..n).map(|j| if i == j { 1.0 } else { 0.0 }).collect())
|
|
||||||
.collect();
|
|
||||||
let mut b: Vec<Vec<f64>> = c_matrix.to_vec();
|
|
||||||
let mut d: Vec<f64> = vec![1.0; n];
|
|
||||||
let mut p_sigma = vec![0.0_f64; n];
|
|
||||||
let mut p_c = vec![0.0_f64; n];
|
|
||||||
let mut evaluations = 0usize;
|
|
||||||
|
|
||||||
let normal = Normal::new(0.0, 1.0).expect("Normal::new(0, 1)");
|
|
||||||
let mut best_candidate_seen: Option<Candidate<Vec<f64>>> = None;
|
|
||||||
|
|
||||||
for generation in 0..self.config.generations {
|
|
||||||
if generation % self.config.eigen_decomposition_period == 0 {
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for i in 0..n {
|
|
||||||
for j in (i + 1)..n {
|
|
||||||
let avg = 0.5 * (c_matrix[i][j] + c_matrix[j][i]);
|
|
||||||
c_matrix[i][j] = avg;
|
|
||||||
c_matrix[j][i] = avg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let (eigenvalues, eigenvectors) = symmetric_eigen(&c_matrix, 1e-14, 100);
|
|
||||||
d = eigenvalues.iter().map(|&v| v.max(1e-20).sqrt()).collect();
|
|
||||||
b = (0..n)
|
|
||||||
.map(|r| (0..n).map(|c| eigenvectors[c][r]).collect())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
|
||||||
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
|
||||||
for _ in 0..lambda {
|
|
||||||
let z: Vec<f64> = (0..n).map(|_| normal.sample(&mut rng)).collect();
|
|
||||||
let bd_z: Vec<f64> = (0..n)
|
|
||||||
.map(|i| (0..n).map(|j| b[i][j] * d[j] * z[j]).sum::<f64>())
|
|
||||||
.collect();
|
|
||||||
let x: Vec<f64> = (0..n)
|
|
||||||
.map(|i| {
|
|
||||||
let v = mean[i] + sigma * bd_z[i];
|
|
||||||
let (lo, hi) = self.bounds.bounds[i];
|
|
||||||
v.clamp(lo, hi)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
z_samples.push(z);
|
|
||||||
x_samples.push(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
let evaluated = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
|
|
||||||
evaluations += evaluated.len();
|
|
||||||
|
|
||||||
for c in &evaluated {
|
|
||||||
let beats_best = match &best_candidate_seen {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats_best {
|
|
||||||
best_candidate_seen = Some(c.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut order: Vec<usize> = (0..lambda).collect();
|
|
||||||
order.sort_by(|&a, &b_| {
|
|
||||||
compare_so(
|
|
||||||
&evaluated[a].evaluation,
|
|
||||||
&evaluated[b_].evaluation,
|
|
||||||
direction,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let old_mean = mean.clone();
|
|
||||||
let mut new_mean = vec![0.0_f64; n];
|
|
||||||
for k in 0..mu {
|
|
||||||
let xk = &x_samples[order[k]];
|
|
||||||
let wk = weights[k];
|
|
||||||
for i in 0..n {
|
|
||||||
new_mean[i] += wk * xk[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mean = new_mean;
|
|
||||||
|
|
||||||
let mut z_weighted = vec![0.0_f64; n];
|
|
||||||
for k in 0..mu {
|
|
||||||
let zk = &z_samples[order[k]];
|
|
||||||
let wk = weights[k];
|
|
||||||
for i in 0..n {
|
|
||||||
z_weighted[i] += wk * zk[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let factor_p_sigma = (c_sigma * (2.0 - c_sigma) * mu_eff).sqrt();
|
|
||||||
let bz: Vec<f64> = (0..n)
|
|
||||||
.map(|i| (0..n).map(|j| b[i][j] * z_weighted[j]).sum::<f64>())
|
|
||||||
.collect();
|
|
||||||
for i in 0..n {
|
|
||||||
p_sigma[i] = (1.0 - c_sigma) * p_sigma[i] + factor_p_sigma * bz[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
let p_sigma_norm = p_sigma.iter().map(|x| x * x).sum::<f64>().sqrt();
|
|
||||||
sigma *= ((c_sigma / d_sigma) * (p_sigma_norm / chi_n - 1.0)).exp();
|
|
||||||
|
|
||||||
let h_sigma = if p_sigma_norm
|
|
||||||
/ (1.0 - (1.0 - c_sigma).powi(2 * (generation as i32 + 1))).sqrt()
|
|
||||||
< (1.4 + 2.0 / (n_f + 1.0)) * chi_n
|
|
||||||
{
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
let factor_p_c = h_sigma * (c_c * (2.0 - c_c) * mu_eff).sqrt();
|
|
||||||
for i in 0..n {
|
|
||||||
p_c[i] = (1.0 - c_c) * p_c[i] + factor_p_c * (mean[i] - old_mean[i]) / sigma;
|
|
||||||
}
|
|
||||||
|
|
||||||
let delta_h = (1.0 - h_sigma) * c_c * (2.0 - c_c);
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for i in 0..n {
|
|
||||||
for j in 0..n {
|
|
||||||
let mut update = (1.0 - c_1 - c_mu) * c_matrix[i][j]
|
|
||||||
+ c_1 * (p_c[i] * p_c[j] + delta_h * c_matrix[i][j]);
|
|
||||||
let mut rank_mu_term = 0.0;
|
|
||||||
for k in 0..mu {
|
|
||||||
let xk = &x_samples[order[k]];
|
|
||||||
let yi = (xk[i] - old_mean[i]) / sigma;
|
|
||||||
let yj = (xk[j] - old_mean[j]) / sigma;
|
|
||||||
rank_mu_term += weights[k] * yi * yj;
|
|
||||||
}
|
|
||||||
update += c_mu * rank_mu_term;
|
|
||||||
c_matrix[i][j] = update;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, m) in mean.iter_mut().enumerate() {
|
|
||||||
let (lo, hi) = self.bounds.bounds[i];
|
|
||||||
*m = m.clamp(lo, hi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_candidate_seen.expect("at least one generation evaluated");
|
|
||||||
let final_pop = vec![best.clone()];
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
let best_opt = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best_opt,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_so(
|
fn compare_so(
|
||||||
a: &crate::core::evaluation::Evaluation,
|
a: &crate::core::evaluation::Evaluation,
|
||||||
b: &crate::core::evaluation::Evaluation,
|
b: &crate::core::evaluation::Evaluation,
|
||||||
@@ -628,18 +397,6 @@ fn better_than_so(
|
|||||||
compare_so(a, b, direction) == std::cmp::Ordering::Less
|
compare_so(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for CmaEs {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"CMA-ES"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Covariance Matrix Adaptation Evolution Strategy"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -757,94 +514,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_so_feasibility_first_under_min() {
|
|
||||||
let mut a = Evaluation::new(vec![10.0]);
|
|
||||||
a.constraint_violation = 0.0;
|
|
||||||
let mut b = Evaluation::new(vec![1.0]);
|
|
||||||
b.constraint_violation = 1.0;
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&a, &b, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&b, &a, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_so_two_feasible_under_min_and_max() {
|
|
||||||
let a = Evaluation::new(vec![1.0]);
|
|
||||||
let b = Evaluation::new(vec![2.0]);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&a, &b, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&b, &a, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
// Maximize inverts.
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&a, &b, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&b, &a, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_so_two_infeasible_compares_violation() {
|
|
||||||
let mut a = Evaluation::new(vec![0.0]);
|
|
||||||
a.constraint_violation = 0.5;
|
|
||||||
let mut b = Evaluation::new(vec![0.0]);
|
|
||||||
b.constraint_violation = 1.0;
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&a, &b, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&b, &a, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_matches_compare_so() {
|
|
||||||
let a = Evaluation::new(vec![1.0]);
|
|
||||||
let b = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better_than_so(&a, &b, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&b, &a, Direction::Minimize));
|
|
||||||
assert!(better_than_so(&b, &a, Direction::Maximize));
|
|
||||||
// Equal: not strictly better.
|
|
||||||
let c = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better_than_so(&a, &c, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pin the final population size and at least one improvement step.
|
|
||||||
#[test]
|
|
||||||
fn cmaes_decreases_sphere_objective_over_generations() {
|
|
||||||
let mut opt = CmaEs::new(
|
|
||||||
CmaEsConfig {
|
|
||||||
population_size: 8,
|
|
||||||
generations: 30,
|
|
||||||
initial_sigma: 0.5,
|
|
||||||
eigen_decomposition_period: 1,
|
|
||||||
initial_mean: None,
|
|
||||||
seed: 7,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-3.0, 3.0); 2]),
|
|
||||||
);
|
|
||||||
let r = opt.run(&Sphere1D);
|
|
||||||
let best = r.best.unwrap().evaluation.objectives[0];
|
|
||||||
// After 30 gens × 8 pop on a 2-D sphere starting σ=0.5, best should
|
|
||||||
// be much smaller than initial random sampling (variance bound = 9).
|
|
||||||
assert!(best < 1.0, "best = {best}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -156,107 +193,6 @@ where
|
|||||||
evaluations += trial_cands.len();
|
evaluations += trial_cands.len();
|
||||||
|
|
||||||
// Phase 3 (serial): greedy replacement.
|
// Phase 3 (serial): greedy replacement.
|
||||||
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
|
||||||
let trial_obj = trial_cand.evaluation.objectives[0];
|
|
||||||
let target_obj = evals[i];
|
|
||||||
let trial_better = match direction {
|
|
||||||
Direction::Minimize => trial_obj <= target_obj,
|
|
||||||
Direction::Maximize => trial_obj >= target_obj,
|
|
||||||
};
|
|
||||||
if trial_better {
|
|
||||||
decisions[i] = trial_cand.decision;
|
|
||||||
evals[i] = trial_obj;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions);
|
|
||||||
evaluations += final_pop.len();
|
|
||||||
let front = pareto_front(&final_pop, &objectives);
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl DifferentialEvolution {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch (initial
|
|
||||||
/// population and per-generation trials).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use rand::Rng as _;
|
|
||||||
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
use crate::traits::Initializer as _;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 4,
|
|
||||||
"DifferentialEvolution requires population_size >= 4",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
(0.0..=1.0).contains(&self.config.crossover_probability),
|
|
||||||
"DifferentialEvolution crossover_probability must be in [0.0, 1.0]",
|
|
||||||
);
|
|
||||||
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"DifferentialEvolution only supports single-objective problems",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut decisions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
|
|
||||||
let initial_pop = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
|
||||||
let mut evaluations = initial_pop.len();
|
|
||||||
let mut current_pop = initial_pop;
|
|
||||||
let mut evals: Vec<f64> = current_pop
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives[0])
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for _generation in 0..self.config.generations {
|
|
||||||
let trials: Vec<Vec<f64>> = (0..n)
|
|
||||||
.map(|i| {
|
|
||||||
let (r1, r2, r3) = pick_three_distinct(n, i, &mut rng);
|
|
||||||
let j_rand = rng.random_range(0..dim);
|
|
||||||
let mut trial = decisions[i].clone();
|
|
||||||
for j in 0..dim {
|
|
||||||
let take_donor =
|
|
||||||
rng.random_bool(self.config.crossover_probability) || j == j_rand;
|
|
||||||
if take_donor {
|
|
||||||
let mutant = decisions[r1][j]
|
|
||||||
+ self.config.differential_weight
|
|
||||||
* (decisions[r2][j] - decisions[r3][j]);
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
trial[j] = mutant.clamp(lo, hi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
trial
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let trial_cands: Vec<Candidate<Vec<f64>>> =
|
|
||||||
evaluate_batch_async(problem, trials, concurrency).await;
|
|
||||||
evaluations += trial_cands.len();
|
|
||||||
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
for (i, trial_cand) in trial_cands.into_iter().enumerate() {
|
||||||
let trial_obj = trial_cand.evaluation.objectives[0];
|
let trial_obj = trial_cand.evaluation.objectives[0];
|
||||||
let target_obj = evals[i];
|
let target_obj = evals[i];
|
||||||
@@ -270,8 +206,25 @@ impl DifferentialEvolution {
|
|||||||
current_pop[i] = trial_cand;
|
current_pop[i] = trial_cand;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
completed_generations = generation;
|
||||||
|
|
||||||
|
// Per-generation snapshot.
|
||||||
|
let best = best_candidate(¤t_pop, &objectives);
|
||||||
|
let snap = Snapshot {
|
||||||
|
iteration: generation,
|
||||||
|
evaluations,
|
||||||
|
elapsed: started.elapsed(),
|
||||||
|
population: ¤t_pop,
|
||||||
|
pareto_front: None,
|
||||||
|
best: best.as_ref(),
|
||||||
|
objectives: &objectives,
|
||||||
|
};
|
||||||
|
if let ControlFlow::Break(()) = observer.observe(&snap) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-evaluate to make sure final population is consistent (current_pop is already current).
|
||||||
let front = pareto_front(¤t_pop, &objectives);
|
let front = pareto_front(¤t_pop, &objectives);
|
||||||
let best = best_candidate(¤t_pop, &objectives);
|
let best = best_candidate(¤t_pop, &objectives);
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(
|
||||||
@@ -279,7 +232,7 @@ impl DifferentialEvolution {
|
|||||||
front,
|
front,
|
||||||
best,
|
best,
|
||||||
evaluations,
|
evaluations,
|
||||||
self.config.generations,
|
completed_generations,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,18 +256,6 @@ fn pick_three_distinct(
|
|||||||
(a, b, c)
|
(a, b, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for DifferentialEvolution {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"DE"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Differential Evolution"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -384,39 +325,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pick_three_distinct_returns_distinct_indices_not_equal_to_exclude() {
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
for seed in 0..20 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
let (a, b, c) = pick_three_distinct(10, 3, &mut rng);
|
|
||||||
assert_ne!(a, 3);
|
|
||||||
assert_ne!(b, 3);
|
|
||||||
assert_ne!(c, 3);
|
|
||||||
assert_ne!(a, b);
|
|
||||||
assert_ne!(a, c);
|
|
||||||
assert_ne!(b, c);
|
|
||||||
assert!(a < 10 && b < 10 && c < 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn de_decreases_sphere_objective_over_generations() {
|
|
||||||
let mut opt = DifferentialEvolution::new(
|
|
||||||
DifferentialEvolutionConfig {
|
|
||||||
population_size: 12,
|
|
||||||
generations: 40,
|
|
||||||
differential_weight: 0.5,
|
|
||||||
crossover_probability: 0.9,
|
|
||||||
seed: 11,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-3.0, 3.0); 2]),
|
|
||||||
);
|
|
||||||
let r = opt.run(&Sphere1D);
|
|
||||||
let best = r.best.unwrap().evaluation.objectives[0];
|
|
||||||
assert!(best < 0.5, "best = {best}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,98 +190,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> EpsilonMoea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations of the initial
|
|
||||||
/// population. Per-step evaluations are sequential because the
|
|
||||||
/// algorithm is steady-state (one offspring per step).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"EpsilonMoea population_size must be > 0"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert_eq!(
|
|
||||||
self.config.epsilon.len(),
|
|
||||||
objectives.len(),
|
|
||||||
"EpsilonMoea epsilon.len() must equal number of objectives",
|
|
||||||
);
|
|
||||||
for (i, &e) in self.config.epsilon.iter().enumerate() {
|
|
||||||
assert!(e > 0.0, "EpsilonMoea epsilon[{i}] must be > 0.0");
|
|
||||||
}
|
|
||||||
let epsilon = self.config.epsilon.clone();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
|
|
||||||
for c in &population {
|
|
||||||
insert_into_epsilon_archive(&mut archive, c.clone(), &objectives, &epsilon);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_evals = self.config.evaluations.max(evaluations);
|
|
||||||
while evaluations < total_evals {
|
|
||||||
let p1_idx = rng.random_range(0..population.len());
|
|
||||||
let parent_a = population[p1_idx].decision.clone();
|
|
||||||
let parent_b = if !archive.is_empty() {
|
|
||||||
let j = rng.random_range(0..archive.len());
|
|
||||||
archive[j].decision.clone()
|
|
||||||
} else {
|
|
||||||
let j = rng.random_range(0..population.len());
|
|
||||||
population[j].decision.clone()
|
|
||||||
};
|
|
||||||
let parents = vec![parent_a, parent_b];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"EpsilonMoea variation returned no children"
|
|
||||||
);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
let child = Candidate::new(child_decision, child_eval);
|
|
||||||
|
|
||||||
update_population(&mut population, &child, &objectives, &mut rng);
|
|
||||||
|
|
||||||
insert_into_epsilon_archive(&mut archive, child, &objectives, &epsilon);
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<P::Decision>> = if !archive.is_empty() {
|
|
||||||
archive.clone()
|
|
||||||
} else {
|
|
||||||
population
|
|
||||||
};
|
|
||||||
let front = pareto_front(&final_pop, &objectives);
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.evaluations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Standard ε-MOEA population update: if the child is dominated by some
|
/// Standard ε-MOEA population update: if the child is dominated by some
|
||||||
/// member, drop it; if it dominates a member, replace that member; if
|
/// member, drop it; if it dominates a member, replace that member; if
|
||||||
/// non-dominated wrt all, replace a random member.
|
/// non-dominated wrt all, replace a random member.
|
||||||
@@ -396,18 +304,6 @@ fn box_dominates(a: &[i64], b: &[i64]) -> bool {
|
|||||||
strictly_less
|
strictly_less
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for EpsilonMoea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"ε-MOEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"ε-dominance Multi-Objective Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -507,64 +403,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Objective;
|
|
||||||
|
|
||||||
fn space2() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn box_coords_floors_each_axis() {
|
|
||||||
let s = space2();
|
|
||||||
let e = Evaluation::new(vec![2.7, 5.2]);
|
|
||||||
// floor(2.7 / 1.0) = 2, floor(5.2 / 2.0) = floor(2.6) = 2
|
|
||||||
let coords = box_coords(&e, &s, &[1.0, 2.0]);
|
|
||||||
assert_eq!(coords, vec![2, 2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn box_coords_zero_lands_in_box_zero() {
|
|
||||||
let s = space2();
|
|
||||||
let e = Evaluation::new(vec![0.0, 0.999]);
|
|
||||||
let coords = box_coords(&e, &s, &[1.0, 1.0]);
|
|
||||||
assert_eq!(coords, vec![0, 0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn corner_distance_is_euclidean_to_box_corner() {
|
|
||||||
let s = space2();
|
|
||||||
// Point (2.5, 5.5), box (2, 2), epsilon (1, 2):
|
|
||||||
// corner = (2*1, 2*2) = (2, 4). delta = (0.5, 1.5).
|
|
||||||
// distance = sqrt(0.25 + 2.25) = sqrt(2.5).
|
|
||||||
let e = Evaluation::new(vec![2.5, 5.5]);
|
|
||||||
let d = corner_distance(&e, &s, &[1.0, 2.0], &[2, 2]);
|
|
||||||
assert!((d - 2.5_f64.sqrt()).abs() < 1e-12, "d = {d}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn corner_distance_zero_at_exact_corner() {
|
|
||||||
let s = space2();
|
|
||||||
// Point exactly at the box corner → distance 0.
|
|
||||||
let e = Evaluation::new(vec![2.0, 4.0]);
|
|
||||||
let d = corner_distance(&e, &s, &[1.0, 2.0], &[2, 2]);
|
|
||||||
assert!(d.abs() < 1e-12, "d = {d}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn box_dominates_strict_and_boundary() {
|
|
||||||
// a strictly less on both axes → dominates.
|
|
||||||
assert!(box_dominates(&[1, 1], &[2, 2]));
|
|
||||||
// reverse → does not dominate.
|
|
||||||
assert!(!box_dominates(&[2, 2], &[1, 1]));
|
|
||||||
// equal boxes → no strict improvement → no domination.
|
|
||||||
assert!(!box_dominates(&[1, 1], &[1, 1]));
|
|
||||||
// less on one axis, equal on the other → dominates.
|
|
||||||
assert!(box_dominates(&[1, 2], &[2, 2]));
|
|
||||||
// less on one, greater on the other → no domination.
|
|
||||||
assert!(!box_dominates(&[1, 3], &[2, 2]));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,94 +181,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> GeneticAlgorithm<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch (initial
|
|
||||||
/// population and per-generation offspring).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 2,
|
|
||||||
"GeneticAlgorithm population_size must be >= 2",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.tournament_size >= 1,
|
|
||||||
"GeneticAlgorithm tournament_size must be >= 1",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.elitism < self.config.population_size,
|
|
||||||
"GeneticAlgorithm elitism must be < population_size",
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"GeneticAlgorithm requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let parents_decisions = tournament_select_single_objective(
|
|
||||||
&population,
|
|
||||||
&objectives,
|
|
||||||
self.config.tournament_size,
|
|
||||||
2,
|
|
||||||
&mut rng,
|
|
||||||
);
|
|
||||||
let children = self.variation.vary(&parents_decisions, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"GeneticAlgorithm variation returned no children"
|
|
||||||
);
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
population =
|
|
||||||
survival_selection(&population, offspring, direction, n, self.config.elitism);
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
let front: Vec<Candidate<P::Decision>> = best.iter().cloned().collect();
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn survival_selection<D: Clone>(
|
fn survival_selection<D: Clone>(
|
||||||
parents: &[Candidate<D>],
|
parents: &[Candidate<D>],
|
||||||
offspring: Vec<Candidate<D>>,
|
offspring: Vec<Candidate<D>>,
|
||||||
@@ -317,18 +229,6 @@ fn compare_for_fitness<D>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for GeneticAlgorithm<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"GA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Genetic Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -415,81 +315,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
|
|
||||||
fn fc(obj: f64) -> Candidate<u32> {
|
|
||||||
Candidate::new(0, Evaluation::new(vec![obj]))
|
|
||||||
}
|
|
||||||
fn fc_cv(obj: f64, cv: f64) -> Candidate<u32> {
|
|
||||||
Candidate::new(0, Evaluation::constrained(vec![obj], cv))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_for_fitness_feasibility_first() {
|
|
||||||
let feasible = fc(100.0);
|
|
||||||
let infeasible = fc_cv(0.0, 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare_for_fitness(&feasible, &infeasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_for_fitness(&infeasible, &feasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Greater,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_for_fitness_two_feasible_min_and_max() {
|
|
||||||
let lo = fc(1.0);
|
|
||||||
let hi = fc(2.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare_for_fitness(&lo, &hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_for_fitness(&lo, &hi, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_for_fitness_two_infeasible_lower_violation_wins() {
|
|
||||||
let low = fc_cv(0.0, 0.3);
|
|
||||||
let high = fc_cv(0.0, 0.9);
|
|
||||||
assert_eq!(
|
|
||||||
compare_for_fitness(&low, &high, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `survival_selection` carries `elitism` parents and `n - elitism`
|
|
||||||
/// offspring, each set sorted best-first. Pin the exact composition.
|
|
||||||
#[test]
|
|
||||||
fn survival_selection_keeps_elites_and_best_offspring() {
|
|
||||||
// Parents: objectives 5, 1, 9 → best is 1.
|
|
||||||
let parents = vec![fc(5.0), fc(1.0), fc(9.0)];
|
|
||||||
// Offspring: objectives 4, 2, 8 → best two are 2, 4.
|
|
||||||
let offspring = vec![fc(4.0), fc(2.0), fc(8.0)];
|
|
||||||
let next = survival_selection(&parents, offspring, Direction::Minimize, 3, 1);
|
|
||||||
assert_eq!(next.len(), 3);
|
|
||||||
// 1 elite (best parent = 1.0) + 2 best offspring (2.0, 4.0).
|
|
||||||
assert_eq!(next[0].evaluation.objectives[0], 1.0);
|
|
||||||
assert_eq!(next[1].evaluation.objectives[0], 2.0);
|
|
||||||
assert_eq!(next[2].evaluation.objectives[0], 4.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn survival_selection_zero_elitism_is_all_offspring() {
|
|
||||||
let parents = vec![fc(1.0)];
|
|
||||||
let offspring = vec![fc(9.0), fc(3.0)];
|
|
||||||
let next = survival_selection(&parents, offspring, Direction::Minimize, 2, 0);
|
|
||||||
assert_eq!(next.len(), 2);
|
|
||||||
// No elites — both slots come from offspring, best-first.
|
|
||||||
assert_eq!(next[0].evaluation.objectives[0], 3.0);
|
|
||||||
assert_eq!(next[1].evaluation.objectives[0], 9.0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,82 +160,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Grea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Grea population_size must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.grid_divisions >= 1,
|
|
||||||
"Grea grid_divisions must be >= 1"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "Grea variation returned no children");
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
population =
|
|
||||||
environmental_selection(combined, &objectives, n, self.config.grid_divisions);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -334,18 +258,6 @@ fn environmental_selection<D: Clone>(
|
|||||||
selected.into_iter().map(|i| combined[i].clone()).collect()
|
selected.into_iter().map(|i| combined[i].clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Grea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"GrEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Grid-based Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -400,33 +312,4 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(oa, ob);
|
assert_eq!(oa, ob);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `environmental_selection` truncates the combined 2N pool down to
|
|
||||||
/// exactly N. Pin the final population size across several configs so
|
|
||||||
/// the grid-coordinate arithmetic / front-peeling comparisons can't
|
|
||||||
/// silently mis-count survivors.
|
|
||||||
#[test]
|
|
||||||
fn final_population_size_matches_config() {
|
|
||||||
for pop in [4_usize, 12, 20] {
|
|
||||||
let bounds = vec![(-5.0, 5.0)];
|
|
||||||
let initializer = RealBounds::new(bounds.clone());
|
|
||||||
let variation = CompositeVariation {
|
|
||||||
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
|
|
||||||
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
|
|
||||||
};
|
|
||||||
let mut opt = Grea::new(
|
|
||||||
GreaConfig {
|
|
||||||
population_size: pop,
|
|
||||||
generations: 5,
|
|
||||||
grid_divisions: 8,
|
|
||||||
seed: 3,
|
|
||||||
},
|
|
||||||
initializer,
|
|
||||||
variation,
|
|
||||||
);
|
|
||||||
let r = opt.run(&SchafferN1);
|
|
||||||
assert_eq!(r.population.len(), pop, "config pop = {pop}");
|
|
||||||
assert!(!r.pareto_front.is_empty());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,96 +146,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> HillClimber<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` is mostly inert here because HillClimber evaluates
|
|
||||||
/// one child per iteration; it's accepted for API parity with other
|
|
||||||
/// algorithms.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
let _ = concurrency;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"HillClimber requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut initial = self.initializer.initialize(1, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!initial.is_empty(),
|
|
||||||
"HillClimber initializer returned no decisions"
|
|
||||||
);
|
|
||||||
let mut current_decision = initial.remove(0);
|
|
||||||
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
|
||||||
let mut evaluations = 1usize;
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let parents = vec![current_decision.clone()];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"HillClimber variation returned no children"
|
|
||||||
);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
let child_better = match (child_eval.is_feasible(), current_eval.is_feasible()) {
|
|
||||||
(true, false) => true,
|
|
||||||
(false, true) => false,
|
|
||||||
(false, false) => {
|
|
||||||
child_eval.constraint_violation < current_eval.constraint_violation
|
|
||||||
}
|
|
||||||
(true, true) => match direction {
|
|
||||||
Direction::Minimize => child_eval.objectives[0] < current_eval.objectives[0],
|
|
||||||
Direction::Maximize => child_eval.objectives[0] > current_eval.objectives[0],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if child_better {
|
|
||||||
current_decision = child_decision;
|
|
||||||
current_eval = child_eval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = Candidate::new(current_decision, current_eval);
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for HillClimber<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Hill Climber"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Hill Climbing"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -283,41 +193,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HillClimber must never *worsen* the best objective — the accept rule
|
|
||||||
/// only moves to strictly-better neighbors. Pin that the final best is
|
|
||||||
/// at least as good as the initial decision's objective.
|
|
||||||
#[test]
|
|
||||||
fn hill_climber_never_worsens_objective() {
|
|
||||||
let mut opt = HillClimber::new(
|
|
||||||
HillClimberConfig {
|
|
||||||
iterations: 200,
|
|
||||||
seed: 5,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-3.0, 3.0); 2]),
|
|
||||||
GaussianMutation { sigma: 0.3 },
|
|
||||||
);
|
|
||||||
let r = opt.run(&Sphere1D);
|
|
||||||
let best = r.best.unwrap().evaluation.objectives[0];
|
|
||||||
// The worst point in a [-3,3]^2 box has objective up to ~9 for the
|
|
||||||
// first coordinate squared; a hill climber from any start should be
|
|
||||||
// well below that ceiling after 200 steps.
|
|
||||||
assert!(best <= 9.0);
|
|
||||||
assert!(best.is_finite() && best >= 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn hill_climber_decreases_sphere() {
|
|
||||||
let mut opt = HillClimber::new(
|
|
||||||
HillClimberConfig {
|
|
||||||
iterations: 500,
|
|
||||||
seed: 11,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-3.0, 3.0)]),
|
|
||||||
GaussianMutation { sigma: 0.2 },
|
|
||||||
);
|
|
||||||
let r = opt.run(&Sphere1D);
|
|
||||||
let best = r.best.unwrap().evaluation.objectives[0];
|
|
||||||
assert!(best < 1.0, "best = {best}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-192
@@ -226,131 +226,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Hype<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Hype population_size must be > 0"
|
|
||||||
);
|
|
||||||
assert!(self.config.mc_samples > 0, "Hype mc_samples must be > 0");
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert_eq!(
|
|
||||||
self.config.reference_point.len(),
|
|
||||||
objectives.len(),
|
|
||||||
"Hype reference_point.len() must equal number of objectives",
|
|
||||||
);
|
|
||||||
let reference = self.config.reference_point.clone();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let fitness = hype_fitness(
|
|
||||||
&population,
|
|
||||||
&objectives,
|
|
||||||
&reference,
|
|
||||||
self.config.mc_samples,
|
|
||||||
&mut rng,
|
|
||||||
);
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = binary_tournament(&fitness, &mut rng);
|
|
||||||
let p2 = binary_tournament(&fitness, &mut rng);
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "Hype variation returned no children");
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
|
|
||||||
let fronts = non_dominated_sort(&combined, &objectives);
|
|
||||||
let mut keep_indices: Vec<usize> = Vec::with_capacity(n);
|
|
||||||
let mut splitting: &[usize] = &[];
|
|
||||||
for f in &fronts {
|
|
||||||
if keep_indices.len() + f.len() <= n {
|
|
||||||
keep_indices.extend(f.iter().copied());
|
|
||||||
} else {
|
|
||||||
splitting = f;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if keep_indices.len() == n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if keep_indices.len() < n {
|
|
||||||
let pool: Vec<&Candidate<P::Decision>> =
|
|
||||||
splitting.iter().map(|&i| &combined[i]).collect();
|
|
||||||
let contributions = estimate_contributions(
|
|
||||||
&pool,
|
|
||||||
&objectives,
|
|
||||||
&reference,
|
|
||||||
self.config.mc_samples,
|
|
||||||
&mut rng,
|
|
||||||
);
|
|
||||||
let mut order: Vec<usize> = (0..splitting.len()).collect();
|
|
||||||
order.sort_by(|&a, &b| {
|
|
||||||
contributions[b]
|
|
||||||
.partial_cmp(&contributions[a])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
for k in order.into_iter().take(n - keep_indices.len()) {
|
|
||||||
keep_indices.push(splitting[k]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
population = keep_indices
|
|
||||||
.into_iter()
|
|
||||||
.map(|i| combined[i].clone())
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hype_fitness<D>(
|
fn hype_fitness<D>(
|
||||||
pool: &[Candidate<D>],
|
pool: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -416,9 +291,6 @@ fn estimate_contributions<D>(
|
|||||||
|
|
||||||
let mut contrib = vec![0.0_f64; n];
|
let mut contrib = vec![0.0_f64; n];
|
||||||
let mut sample = vec![0.0_f64; m];
|
let mut sample = vec![0.0_f64; m];
|
||||||
// Reused across samples — previously heap-allocated once per Monte
|
|
||||||
// Carlo sample (thousands of allocations per call).
|
|
||||||
let mut dominators: Vec<usize> = Vec::with_capacity(n);
|
|
||||||
for _ in 0..samples {
|
for _ in 0..samples {
|
||||||
for k in 0..m {
|
for k in 0..m {
|
||||||
let u: f64 = rng.random();
|
let u: f64 = rng.random();
|
||||||
@@ -426,7 +298,7 @@ fn estimate_contributions<D>(
|
|||||||
}
|
}
|
||||||
// Count and identify candidates that dominate this sample (point
|
// Count and identify candidates that dominate this sample (point
|
||||||
// in the box).
|
// in the box).
|
||||||
dominators.clear();
|
let mut dominators: Vec<usize> = Vec::with_capacity(n);
|
||||||
for (i, o) in oriented.iter().enumerate() {
|
for (i, o) in oriented.iter().enumerate() {
|
||||||
if o.iter().zip(sample.iter()).all(|(p, s)| *p <= *s) {
|
if o.iter().zip(sample.iter()).all(|(p, s)| *p <= *s) {
|
||||||
dominators.push(i);
|
dominators.push(i);
|
||||||
@@ -439,7 +311,7 @@ fn estimate_contributions<D>(
|
|||||||
// dominators. (This generalizes "exactly-one dominator" to
|
// dominators. (This generalizes "exactly-one dominator" to
|
||||||
// arbitrary multiplicities.)
|
// arbitrary multiplicities.)
|
||||||
let weight = 1.0 / dominators.len() as f64;
|
let weight = 1.0 / dominators.len() as f64;
|
||||||
for &i in &dominators {
|
for i in dominators {
|
||||||
contrib[i] += weight;
|
contrib[i] += weight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,18 +334,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Hype<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"HypE"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Hypervolume Estimation Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -553,54 +413,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `binary_tournament` picks the index with the higher fitness; on a
|
|
||||||
/// tie it coin-flips. Pin the deterministic-winner case (no tie).
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_picks_higher_fitness() {
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
// fitness[1] is strictly highest; both random draws will be in
|
|
||||||
// 0..3, and whenever a != b the higher-fitness index must win.
|
|
||||||
let fitness = vec![0.1_f64, 0.9, 0.5];
|
|
||||||
for seed in 0..50 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
let winner = binary_tournament(&fitness, &mut rng);
|
|
||||||
// The winner's fitness must be >= the other's — i.e. it can
|
|
||||||
// never be a strictly-dominated index when the draws differ.
|
|
||||||
assert!(winner < 3);
|
|
||||||
}
|
|
||||||
// Degenerate: all-equal fitness — winner is always a valid index.
|
|
||||||
let flat = vec![1.0_f64; 4];
|
|
||||||
let mut rng = rng_from_seed(7);
|
|
||||||
assert!(binary_tournament(&flat, &mut rng) < 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With a two-element fitness vector where element 0 strictly beats
|
|
||||||
/// element 1, binary_tournament must return 0 whenever the two random
|
|
||||||
/// draws land on {0, 1} — verify across many seeds it never returns
|
|
||||||
/// the strictly-worse index when the draws differ.
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_never_picks_strictly_worse() {
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
let fitness = vec![10.0_f64, 1.0];
|
|
||||||
for seed in 0..100 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
// Re-derive the two draws is not possible without touching the
|
|
||||||
// rng; instead just assert the winner is a valid index and,
|
|
||||||
// statistically, index 0 wins far more often.
|
|
||||||
let _ = binary_tournament(&fitness, &mut rng);
|
|
||||||
}
|
|
||||||
// Statistical check: index 0 should win the clear majority.
|
|
||||||
let mut wins0 = 0;
|
|
||||||
for seed in 0..200 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if binary_tournament(&fitness, &mut rng) == 0 {
|
|
||||||
wins0 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
wins0 > 130,
|
|
||||||
"index 0 won only {wins0}/200 — comparison likely flipped"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,106 +206,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, D> Hyperband<I, D>
|
|
||||||
where
|
|
||||||
D: Clone,
|
|
||||||
I: Initializer<D>,
|
|
||||||
{
|
|
||||||
/// Async version of [`Hyperband::run`] — evaluates each
|
|
||||||
/// Successive-Halving rung's configurations concurrently through the
|
|
||||||
/// caller's async runtime. Available only with the `async` feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per rung.
|
|
||||||
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncPartialProblem<Decision = D>,
|
|
||||||
D: Send + Sync,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_at_budget_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.max_budget > 0.0,
|
|
||||||
"Hyperband max_budget must be > 0"
|
|
||||||
);
|
|
||||||
assert!(self.config.eta > 1.0, "Hyperband eta must be > 1");
|
|
||||||
assert!(
|
|
||||||
self.config.max_brackets >= 1,
|
|
||||||
"Hyperband max_brackets must be >= 1"
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"Hyperband requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let s_max = (self.config.max_budget.ln() / self.config.eta.ln()).floor() as i64;
|
|
||||||
let s_max = (s_max as usize).min(self.config.max_brackets);
|
|
||||||
|
|
||||||
let mut total_evaluations = 0usize;
|
|
||||||
let mut total_iterations = 0usize;
|
|
||||||
let mut best_seen: Option<Candidate<D>> = None;
|
|
||||||
|
|
||||||
for s in (0..=s_max).rev() {
|
|
||||||
let s_f = s as f64;
|
|
||||||
let n =
|
|
||||||
((s_max as f64 + 1.0) / (s_f + 1.0) * self.config.eta.powf(s_f)).ceil() as usize;
|
|
||||||
let r = self.config.max_budget / self.config.eta.powf(s_f);
|
|
||||||
|
|
||||||
let mut configs: Vec<D> = self.initializer.initialize(n, &mut rng);
|
|
||||||
for i in 0..=s {
|
|
||||||
let n_i = (n as f64 / self.config.eta.powi(i as i32)).floor() as usize;
|
|
||||||
let r_i = r * self.config.eta.powi(i as i32);
|
|
||||||
if configs.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let evals: Vec<Evaluation> =
|
|
||||||
evaluate_batch_at_budget_async(problem, &configs, r_i, concurrency).await;
|
|
||||||
total_evaluations += configs.len();
|
|
||||||
|
|
||||||
for (cfg, e) in configs.iter().zip(evals.iter()) {
|
|
||||||
let beats = match &best_seen {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better(e, &b.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats {
|
|
||||||
best_seen = Some(Candidate::new(cfg.clone(), e.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total_iterations += 1;
|
|
||||||
|
|
||||||
let next_size = (n_i / self.config.eta as usize).max(1);
|
|
||||||
if next_size >= configs.len() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut order: Vec<usize> = (0..configs.len()).collect();
|
|
||||||
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
|
||||||
let keep: std::collections::HashSet<usize> =
|
|
||||||
order.into_iter().take(next_size).collect();
|
|
||||||
let new_configs: Vec<D> = configs
|
|
||||||
.into_iter()
|
|
||||||
.enumerate()
|
|
||||||
.filter_map(|(idx, c)| if keep.contains(&idx) { Some(c) } else { None })
|
|
||||||
.collect();
|
|
||||||
configs = new_configs;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_seen.expect("at least one bracket ran");
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
total_evaluations,
|
|
||||||
total_iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering {
|
fn compare(a: &Evaluation, b: &Evaluation, direction: Direction) -> std::cmp::Ordering {
|
||||||
match (a.is_feasible(), b.is_feasible()) {
|
match (a.is_feasible(), b.is_feasible()) {
|
||||||
(true, false) => std::cmp::Ordering::Less,
|
(true, false) => std::cmp::Ordering::Less,
|
||||||
@@ -329,22 +229,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
compare(a, b, direction) == std::cmp::Ordering::Less
|
compare(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, D> crate::traits::AlgorithmInfo for Hyperband<I, D>
|
|
||||||
where
|
|
||||||
D: Clone,
|
|
||||||
I: Initializer<D>,
|
|
||||||
{
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Hyperband"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Hyperband multi-fidelity bandit search"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -442,50 +326,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&MultiObj);
|
let _ = opt.run(&MultiObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![10.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&feasible, &infeasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&infeasible, &feasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
// two infeasible: smaller violation is "Less" (better).
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&v_lo, &v_hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_is_compare_equals_less() {
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(!better(&hi, &lo, Direction::Minimize));
|
|
||||||
// equal → not strictly better.
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better(&lo, &eq, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-153
@@ -159,80 +159,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Ibea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Ibea population_size must be > 0"
|
|
||||||
);
|
|
||||||
assert!(self.config.kappa > 0.0, "Ibea kappa must be > 0");
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let fitness = compute_fitness(&population, &objectives, self.config.kappa);
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = binary_tournament(&fitness, &mut rng);
|
|
||||||
let p2 = binary_tournament(&fitness, &mut rng);
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "Ibea variation returned no children");
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
population = environmental_selection(combined, &objectives, n, self.config.kappa);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Iteratively remove the worst-fitness member from `pool` until `n` remain.
|
/// Iteratively remove the worst-fitness member from `pool` until `n` remain.
|
||||||
///
|
///
|
||||||
/// IBEA's standard "subtract the dropped member's contribution from every
|
/// IBEA's standard "subtract the dropped member's contribution from every
|
||||||
@@ -280,24 +206,14 @@ fn environmental_selection<D: Clone>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-exponentiate the indicator matrix once. Every later use of
|
|
||||||
// `indicator[j][i]` is `exp(-indicator[j][i] / scale)` — in the initial
|
|
||||||
// fitness sum and, identically, in the per-removal fitness update — so
|
|
||||||
// computing it here turns the removal loop's O((pool-n) · pool) `exp`
|
|
||||||
// calls into plain additions.
|
|
||||||
let scale = max_abs * kappa;
|
|
||||||
let exp_terms: Vec<Vec<f64>> = indicator
|
|
||||||
.into_iter()
|
|
||||||
.map(|row| row.into_iter().map(|v| (-v / scale).exp()).collect())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Fitness F(i) = -Σ_{j≠i} exp(-indicator[j][i] / (max_abs · kappa)).
|
// Fitness F(i) = -Σ_{j≠i} exp(-indicator[j][i] / (max_abs · kappa)).
|
||||||
// (Higher is better — so a candidate dominated by many is heavily negative.)
|
// (Higher is better — so a candidate dominated by many is heavily negative.)
|
||||||
|
let scale = max_abs * kappa;
|
||||||
let mut fitness: Vec<f64> = (0..pool.len())
|
let mut fitness: Vec<f64> = (0..pool.len())
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
(0..pool.len())
|
(0..pool.len())
|
||||||
.filter(|&j| j != i)
|
.filter(|&j| j != i)
|
||||||
.map(|j| -exp_terms[j][i])
|
.map(|j| -(-indicator[j][i] / scale).exp())
|
||||||
.sum()
|
.sum()
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -320,7 +236,7 @@ fn environmental_selection<D: Clone>(
|
|||||||
if !alive[i] || i == worst {
|
if !alive[i] || i == worst {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
fitness[i] += exp_terms[worst][i];
|
fitness[i] += (-indicator[worst][i] / scale).exp();
|
||||||
}
|
}
|
||||||
alive[worst] = false;
|
alive[worst] = false;
|
||||||
alive_count -= 1;
|
alive_count -= 1;
|
||||||
@@ -395,18 +311,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Ibea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"IBEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Indicator-Based Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -484,58 +388,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::{Objective, ObjectiveSpace};
|
|
||||||
|
|
||||||
fn ibea_space() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
fn ibea_cand(o: Vec<f64>) -> Candidate<u32> {
|
|
||||||
Candidate::new(0, Evaluation::new(o))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_fitness_empty_pool_is_empty() {
|
|
||||||
let pool: Vec<Candidate<u32>> = Vec::new();
|
|
||||||
assert!(compute_fitness(&pool, &ibea_space(), 0.05).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_fitness_dominating_point_has_higher_fitness() {
|
|
||||||
// (1,1) dominates (2,2). IBEA fitness (sum of -exp(-I/scale)) is
|
|
||||||
// less negative — i.e. larger — for the dominating point.
|
|
||||||
let pool = vec![ibea_cand(vec![1.0, 1.0]), ibea_cand(vec![2.0, 2.0])];
|
|
||||||
let fit = compute_fitness(&pool, &ibea_space(), 0.05);
|
|
||||||
assert_eq!(fit.len(), 2);
|
|
||||||
assert!(
|
|
||||||
fit[0] > fit[1],
|
|
||||||
"dominating point should score higher: {fit:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compute_fitness_symmetric_tradeoff_pair_is_equal() {
|
|
||||||
// (1,3) and (3,1) are a symmetric trade-off — equal fitness.
|
|
||||||
let pool = vec![ibea_cand(vec![1.0, 3.0]), ibea_cand(vec![3.0, 1.0])];
|
|
||||||
let fit = compute_fitness(&pool, &ibea_space(), 0.05);
|
|
||||||
assert!((fit[0] - fit[1]).abs() < 1e-9, "{fit:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_prefers_higher_fitness() {
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
let fitness = vec![-10.0_f64, -1.0]; // index 1 is fitter
|
|
||||||
let mut wins1 = 0;
|
|
||||||
for seed in 0..200 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if binary_tournament(&fitness, &mut rng) == 1 {
|
|
||||||
wins1 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(wins1 > 130, "fitter index won only {wins1}/200");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,94 +187,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl IpopCmaEs {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations within each restart's
|
|
||||||
/// CMA-ES generation.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
assert!(
|
|
||||||
self.config.initial_population_size >= 4,
|
|
||||||
"IpopCmaEs initial_population_size must be >= 4",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"IpopCmaEs requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut remaining_gens = self.config.total_generations;
|
|
||||||
let mut pop_size = self.config.initial_population_size;
|
|
||||||
let mut total_evaluations = 0usize;
|
|
||||||
let mut total_iterations = 0usize;
|
|
||||||
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
|
|
||||||
let _ = self.config.stall_generations;
|
|
||||||
|
|
||||||
let mut restart_counter = 0u64;
|
|
||||||
while remaining_gens > 0 {
|
|
||||||
let this_gens = (remaining_gens / 2).max(20).min(remaining_gens);
|
|
||||||
let inner_seed = self
|
|
||||||
.config
|
|
||||||
.seed
|
|
||||||
.wrapping_add(restart_counter.wrapping_mul(0x9E37_79B9_7F4A_7C15));
|
|
||||||
let restart_mean: Vec<f64> = self
|
|
||||||
.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| lo + (hi - lo) * rng.random::<f64>())
|
|
||||||
.collect();
|
|
||||||
let cfg = CmaEsConfig {
|
|
||||||
population_size: pop_size,
|
|
||||||
generations: this_gens,
|
|
||||||
initial_sigma: self.config.initial_sigma,
|
|
||||||
eigen_decomposition_period: self.config.eigen_decomposition_period,
|
|
||||||
initial_mean: Some(restart_mean),
|
|
||||||
seed: inner_seed,
|
|
||||||
};
|
|
||||||
let mut inner = CmaEs::new(cfg, RealBounds::new(self.bounds.bounds.clone()));
|
|
||||||
|
|
||||||
let result = inner.run_async(problem, concurrency).await;
|
|
||||||
total_evaluations += result.evaluations;
|
|
||||||
total_iterations += result.generations;
|
|
||||||
if let Some(b) = result.best.clone() {
|
|
||||||
let beats = match &best_seen {
|
|
||||||
None => true,
|
|
||||||
Some(prev) => better(&b.evaluation, &prev.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats {
|
|
||||||
best_seen = Some(b);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
remaining_gens = remaining_gens.saturating_sub(this_gens);
|
|
||||||
pop_size = pop_size.saturating_mul(2);
|
|
||||||
restart_counter = restart_counter.wrapping_add(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_seen.expect("at least one restart ran");
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
total_evaluations,
|
|
||||||
total_iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
||||||
match (a.is_feasible(), b.is_feasible()) {
|
match (a.is_feasible(), b.is_feasible()) {
|
||||||
(true, false) => true,
|
(true, false) => true,
|
||||||
@@ -287,18 +199,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for IpopCmaEs {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"IPOP-CMA-ES"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Increasing-Population CMA-ES with Restarts"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -396,26 +296,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert!(better(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
assert!(!better(&infeasible, &feasible, Direction::Minimize));
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(better(&hi, &lo, Direction::Maximize));
|
|
||||||
// equal → not strictly better in either direction.
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better(&lo, &eq, Direction::Minimize));
|
|
||||||
assert!(!better(&lo, &eq, Direction::Maximize));
|
|
||||||
// two infeasible: smaller violation wins.
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert!(better(&v_lo, &v_hi, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,77 +149,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Knea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Knea population_size must be > 0"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "Knea variation returned no children");
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
population = environmental_selection(combined, &objectives, n);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
combined: Vec<Candidate<D>>,
|
combined: Vec<Candidate<D>>,
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -328,18 +257,6 @@ fn perpendicular_distance(point: &[f64], extremes: &[usize], oriented: &[Vec<f64
|
|||||||
(dot - b).abs() / norm
|
(dot - b).abs() / norm
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Knea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"KnEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Knee point-driven Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -393,32 +310,4 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert_eq!(oa, ob);
|
assert_eq!(oa, ob);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn perpendicular_distance_to_simplex_hyperplane() {
|
|
||||||
// Two extremes (1,0) and (0,1) define the line x + y = 1.
|
|
||||||
// The point (1,1) has signed distance |2 - 1| / sqrt(2) = 1/sqrt(2).
|
|
||||||
let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![1.0, 1.0]];
|
|
||||||
let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented);
|
|
||||||
assert!((d - 1.0 / 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn perpendicular_distance_zero_on_hyperplane() {
|
|
||||||
// (0.5, 0.5) lies exactly on x + y = 1 → distance 0.
|
|
||||||
let oriented = vec![vec![1.0, 0.0], vec![0.0, 1.0], vec![0.5, 0.5]];
|
|
||||||
let d = perpendicular_distance(&oriented[2], &[0, 1], &oriented);
|
|
||||||
assert!(d.abs() < 1e-12, "d = {d}");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn perpendicular_distance_degenerate_too_few_extremes() {
|
|
||||||
// Only one extreme for a 2-D point → falls back to L2 from that
|
|
||||||
// extreme. (1,1) to (0,0) = sqrt(2).
|
|
||||||
let oriented = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
|
|
||||||
let d = perpendicular_distance(&oriented[1], &[0], &oriented);
|
|
||||||
assert!((d - 2.0_f64.sqrt()).abs() < 1e-12, "d = {d}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ pub mod nsga3;
|
|||||||
pub mod one_plus_one_es;
|
pub mod one_plus_one_es;
|
||||||
pub mod paes;
|
pub mod paes;
|
||||||
pub(crate) mod parallel_eval;
|
pub(crate) mod parallel_eval;
|
||||||
#[cfg(feature = "async")]
|
|
||||||
pub(crate) mod parallel_eval_async;
|
|
||||||
pub mod particle_swarm;
|
pub mod particle_swarm;
|
||||||
pub mod pesa2;
|
pub mod pesa2;
|
||||||
pub mod random_search;
|
pub mod random_search;
|
||||||
|
|||||||
@@ -219,126 +219,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Moead<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations of the initial
|
|
||||||
/// population. Per-generation evaluations are sequential because
|
|
||||||
/// each child's outcome feeds back into the same generation's
|
|
||||||
/// neighborhood updates.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let m = objectives.len();
|
|
||||||
let weights = das_dennis(m, self.config.reference_divisions);
|
|
||||||
assert!(
|
|
||||||
!weights.is_empty(),
|
|
||||||
"Moead weight set is empty — increase reference_divisions",
|
|
||||||
);
|
|
||||||
let n = weights.len();
|
|
||||||
let t = self.config.neighborhood_size.min(n);
|
|
||||||
assert!(t >= 2, "Moead neighborhood_size must be >= 2");
|
|
||||||
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
assert_eq!(
|
|
||||||
initial_decisions.len(),
|
|
||||||
n,
|
|
||||||
"MOEA/D initializer must return exactly {n} decisions",
|
|
||||||
);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
let mut ideal = vec![f64::INFINITY; m];
|
|
||||||
for c in &population {
|
|
||||||
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
|
||||||
for (k, v) in oriented.iter().enumerate() {
|
|
||||||
if *v < ideal[k] {
|
|
||||||
ideal[k] = *v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let neighborhoods: Vec<Vec<usize>> = (0..n)
|
|
||||||
.map(|i| {
|
|
||||||
let mut idx: Vec<usize> = (0..n).collect();
|
|
||||||
idx.sort_by(|&a, &b| {
|
|
||||||
let da = weight_distance(&weights[i], &weights[a]);
|
|
||||||
let db = weight_distance(&weights[i], &weights[b]);
|
|
||||||
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
idx.into_iter().take(t).collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for i in 0..n {
|
|
||||||
let nbh = &neighborhoods[i];
|
|
||||||
let p1 = *nbh.choose(&mut rng).unwrap();
|
|
||||||
let mut p2 = *nbh.choose(&mut rng).unwrap();
|
|
||||||
while p2 == p1 && nbh.len() > 1 {
|
|
||||||
p2 = *nbh.choose(&mut rng).unwrap();
|
|
||||||
}
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"MOEA/D variation returned no children"
|
|
||||||
);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
let oriented_child = objectives.as_minimization(&child_eval.objectives);
|
|
||||||
for (k, v) in oriented_child.iter().enumerate() {
|
|
||||||
if *v < ideal[k] {
|
|
||||||
ideal[k] = *v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for &j in nbh {
|
|
||||||
let cur_oriented =
|
|
||||||
objectives.as_minimization(&population[j].evaluation.objectives);
|
|
||||||
let g_cur = tchebycheff(&cur_oriented, &weights[j], &ideal);
|
|
||||||
let g_new = tchebycheff(&oriented_child, &weights[j], &ideal);
|
|
||||||
if g_new <= g_cur {
|
|
||||||
population[j] = Candidate::new(child_decision.clone(), child_eval.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tchebycheff scalarization: `max_k w_k * |f_k - z*_k|`.
|
/// Tchebycheff scalarization: `max_k w_k * |f_k - z*_k|`.
|
||||||
///
|
///
|
||||||
/// `weight` components that are zero are floored to `1e-6` so every axis
|
/// `weight` components that are zero are floored to `1e-6` so every axis
|
||||||
@@ -363,18 +243,6 @@ fn weight_distance(a: &[f64], b: &[f64]) -> f64 {
|
|||||||
.sqrt()
|
.sqrt()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Moead<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"MOEA/D"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Multi-Objective Evolutionary Algorithm based on Decomposition"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -452,38 +320,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tchebycheff_is_max_weighted_deviation() {
|
|
||||||
// ideal = (0, 0), weights = (1, 1): g = max(|f0|, |f1|).
|
|
||||||
let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[0.0, 0.0]);
|
|
||||||
assert!((g - 5.0).abs() < 1e-12);
|
|
||||||
// weights skew which axis dominates.
|
|
||||||
let g2 = tchebycheff(&[3.0, 5.0], &[10.0, 1.0], &[0.0, 0.0]);
|
|
||||||
assert!((g2 - 30.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tchebycheff_uses_distance_from_ideal() {
|
|
||||||
// ideal = (2, 2): deviations are |3-2|=1, |5-2|=3 → g = 3.
|
|
||||||
let g = tchebycheff(&[3.0, 5.0], &[1.0, 1.0], &[2.0, 2.0]);
|
|
||||||
assert!((g - 3.0).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tchebycheff_zero_at_ideal() {
|
|
||||||
let g = tchebycheff(&[2.0, 2.0], &[1.0, 1.0], &[2.0, 2.0]);
|
|
||||||
assert!(g.abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn weight_distance_is_euclidean() {
|
|
||||||
// (0,0) to (3,4) = 5.
|
|
||||||
assert!((weight_distance(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-12);
|
|
||||||
// symmetric and zero-to-self.
|
|
||||||
assert!((weight_distance(&[3.0, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12);
|
|
||||||
assert_eq!(weight_distance(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,136 +212,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl Mopso {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
use crate::traits::Initializer as _;
|
|
||||||
|
|
||||||
assert!(self.config.swarm_size >= 1, "Mopso swarm_size must be >= 1");
|
|
||||||
assert!(
|
|
||||||
self.config.archive_size >= 1,
|
|
||||||
"Mopso archive_size must be >= 1"
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_multi_objective(),
|
|
||||||
"Mopso requires multi-objective problems (use ParticleSwarm for single-objective)",
|
|
||||||
);
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
let n = self.config.swarm_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut positions: Vec<Vec<f64>> = self.bounds.initialize(n, &mut rng);
|
|
||||||
let mut velocities: Vec<Vec<f64>> = (0..n)
|
|
||||||
.map(|_| {
|
|
||||||
self.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
|
|
||||||
|
|
||||||
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
|
||||||
let mut evaluations = initial_pop.len();
|
|
||||||
|
|
||||||
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
|
|
||||||
let mut pbest_evals: Vec<crate::core::evaluation::Evaluation> =
|
|
||||||
initial_pop.iter().map(|c| c.evaluation.clone()).collect();
|
|
||||||
|
|
||||||
let mut archive = ParetoArchive::new(objectives.clone());
|
|
||||||
for c in initial_pop {
|
|
||||||
archive.insert(c);
|
|
||||||
}
|
|
||||||
archive.truncate(self.config.archive_size);
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
for i in 0..n {
|
|
||||||
let leader = archive
|
|
||||||
.members()
|
|
||||||
.choose(&mut rng)
|
|
||||||
.map(|c| c.decision.clone())
|
|
||||||
.unwrap_or_else(|| positions[i].clone());
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for j in 0..dim {
|
|
||||||
let r1: f64 = rng.random();
|
|
||||||
let r2: f64 = rng.random();
|
|
||||||
let cognitive_term =
|
|
||||||
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
|
|
||||||
let social_term = self.config.social * r2 * (leader[j] - positions[i][j]);
|
|
||||||
let mut v =
|
|
||||||
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
|
|
||||||
if v > v_max[j] {
|
|
||||||
v = v_max[j];
|
|
||||||
} else if v < -v_max[j] {
|
|
||||||
v = -v_max[j];
|
|
||||||
}
|
|
||||||
velocities[i][j] = v;
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
|
||||||
evaluations += evaluated.len();
|
|
||||||
|
|
||||||
for (i, cand) in evaluated.iter().enumerate() {
|
|
||||||
let dominance = pareto_compare(&cand.evaluation, &pbest_evals[i], &objectives);
|
|
||||||
let replace = match dominance {
|
|
||||||
Dominance::Dominates => true,
|
|
||||||
Dominance::DominatedBy => false,
|
|
||||||
Dominance::Equal | Dominance::NonDominated => rng.random_bool(0.5),
|
|
||||||
};
|
|
||||||
if replace {
|
|
||||||
pbest_decisions[i] = cand.decision.clone();
|
|
||||||
pbest_evals[i] = cand.evaluation.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for c in evaluated {
|
|
||||||
archive.insert(c);
|
|
||||||
}
|
|
||||||
archive.truncate(self.config.archive_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
let members = archive.into_vec();
|
|
||||||
let front = pareto_front(&members, &objectives);
|
|
||||||
let best = best_candidate(&members, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(members),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for Mopso {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"MOPSO"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Multi-Objective Particle Swarm Optimization"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -394,30 +264,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// MOPSO must return a population of the configured swarm size and a
|
|
||||||
/// non-empty Pareto front on a 2-objective problem. Pins the run-loop
|
|
||||||
/// bookkeeping against degenerate mutants.
|
|
||||||
#[test]
|
|
||||||
fn final_population_and_front_sized() {
|
|
||||||
let mut opt = make_optimizer(7);
|
|
||||||
let r = opt.run(&SchafferN1);
|
|
||||||
assert!(!r.pareto_front.is_empty());
|
|
||||||
// The archive should hold no more than its configured cap.
|
|
||||||
assert!(r.pareto_front.len() <= r.population.len().max(r.pareto_front.len()));
|
|
||||||
// Determinism cross-check.
|
|
||||||
let mut opt2 = make_optimizer(7);
|
|
||||||
let r2 = opt2.run(&SchafferN1);
|
|
||||||
let f1: Vec<Vec<f64>> = r
|
|
||||||
.pareto_front
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives.clone())
|
|
||||||
.collect();
|
|
||||||
let f2: Vec<Vec<f64>> = r2
|
|
||||||
.pareto_front
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives.clone())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(f1, f2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,170 +295,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
compare(a, b, direction) == std::cmp::Ordering::Less
|
compare(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl NelderMead {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` is largely inert here because Nelder-Mead
|
|
||||||
/// evaluates one or two new vertices per iteration sequentially
|
|
||||||
/// (the next decision depends on the previous evaluation); it's
|
|
||||||
/// accepted for API parity with other algorithms.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
let _ = concurrency;
|
|
||||||
assert!(
|
|
||||||
self.config.reflection > 0.0,
|
|
||||||
"NelderMead reflection must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.expansion > 1.0,
|
|
||||||
"NelderMead expansion must be > 1",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.contraction > 0.0 && self.config.contraction < 1.0,
|
|
||||||
"NelderMead contraction must be in (0, 1)",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.shrinkage > 0.0 && self.config.shrinkage < 1.0,
|
|
||||||
"NelderMead shrinkage must be in (0, 1)",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.initial_step > 0.0,
|
|
||||||
"NelderMead initial_step must be > 0",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"NelderMead requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let n = self.bounds.bounds.len();
|
|
||||||
|
|
||||||
let mut vertices: Vec<Vec<f64>> = Vec::with_capacity(n + 1);
|
|
||||||
let start: Vec<f64> = self
|
|
||||||
.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
|
||||||
.collect();
|
|
||||||
vertices.push(start.clone());
|
|
||||||
for j in 0..n {
|
|
||||||
let mut v = start.clone();
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
let step = self.config.initial_step.min(0.5 * (hi - lo));
|
|
||||||
v[j] = (v[j] + step).clamp(lo, hi);
|
|
||||||
vertices.push(v);
|
|
||||||
}
|
|
||||||
let mut evals: Vec<Evaluation> = Vec::with_capacity(vertices.len());
|
|
||||||
for v in &vertices {
|
|
||||||
evals.push(problem.evaluate_async(v).await);
|
|
||||||
}
|
|
||||||
let mut evaluations = evals.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let mut order: Vec<usize> = (0..vertices.len()).collect();
|
|
||||||
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
|
||||||
let best_idx = order[0];
|
|
||||||
let worst_idx = order[order.len() - 1];
|
|
||||||
let second_worst_idx = order[order.len() - 2];
|
|
||||||
|
|
||||||
let mut centroid = vec![0.0_f64; n];
|
|
||||||
for &idx in &order[..order.len() - 1] {
|
|
||||||
for j in 0..n {
|
|
||||||
centroid[j] += vertices[idx][j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for c in centroid.iter_mut() {
|
|
||||||
*c /= (order.len() - 1) as f64;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reflected = self.reflect(¢roid, &vertices[worst_idx], self.config.reflection);
|
|
||||||
let r_eval = problem.evaluate_async(&reflected).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
if better(&r_eval, &evals[best_idx], direction) {
|
|
||||||
let expanded = self.reflect(¢roid, &vertices[worst_idx], self.config.expansion);
|
|
||||||
let e_eval = problem.evaluate_async(&expanded).await;
|
|
||||||
evaluations += 1;
|
|
||||||
if better(&e_eval, &r_eval, direction) {
|
|
||||||
vertices[worst_idx] = expanded;
|
|
||||||
evals[worst_idx] = e_eval;
|
|
||||||
} else {
|
|
||||||
vertices[worst_idx] = reflected;
|
|
||||||
evals[worst_idx] = r_eval;
|
|
||||||
}
|
|
||||||
} else if better(&r_eval, &evals[second_worst_idx], direction) {
|
|
||||||
vertices[worst_idx] = reflected;
|
|
||||||
evals[worst_idx] = r_eval;
|
|
||||||
} else {
|
|
||||||
let contraction_target = if better(&r_eval, &evals[worst_idx], direction) {
|
|
||||||
self.contract(¢roid, &reflected, self.config.contraction)
|
|
||||||
} else {
|
|
||||||
self.contract(¢roid, &vertices[worst_idx], self.config.contraction)
|
|
||||||
};
|
|
||||||
let c_eval = problem.evaluate_async(&contraction_target).await;
|
|
||||||
evaluations += 1;
|
|
||||||
if better(&c_eval, &evals[worst_idx], direction) {
|
|
||||||
vertices[worst_idx] = contraction_target;
|
|
||||||
evals[worst_idx] = c_eval;
|
|
||||||
} else {
|
|
||||||
let best_pt = vertices[best_idx].clone();
|
|
||||||
for &idx in &order {
|
|
||||||
if idx == best_idx {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for j in 0..n {
|
|
||||||
vertices[idx][j] = best_pt[j]
|
|
||||||
+ self.config.shrinkage * (vertices[idx][j] - best_pt[j]);
|
|
||||||
}
|
|
||||||
for (j, x) in vertices[idx].iter_mut().enumerate() {
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
*x = x.clamp(lo, hi);
|
|
||||||
}
|
|
||||||
evals[idx] = problem.evaluate_async(&vertices[idx]).await;
|
|
||||||
evaluations += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut best_idx = 0;
|
|
||||||
for i in 1..vertices.len() {
|
|
||||||
if better(&evals[i], &evals[best_idx], direction) {
|
|
||||||
best_idx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let best = Candidate::new(vertices[best_idx].clone(), evals[best_idx].clone());
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for NelderMead {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Nelder-Mead"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Nelder-Mead simplex direct search"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -551,44 +387,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![10.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&feasible, &infeasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&v_lo, &v_hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_is_strict_less() {
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(!better(&hi, &lo, Direction::Minimize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better(&lo, &eq, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-191
@@ -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>(
|
||||||
@@ -232,117 +288,6 @@ fn annotate<D: Clone>(
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Nsga2<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch (initial
|
|
||||||
/// population and per-generation offspring).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Nsga2 population_size must be greater than 0",
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
assert_eq!(
|
|
||||||
initial_decisions.len(),
|
|
||||||
n,
|
|
||||||
"NSGA-II initializer must return exactly population_size decisions",
|
|
||||||
);
|
|
||||||
let population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
let mut annotated = annotate(population, &objectives);
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = binary_tournament(&annotated, &mut rng);
|
|
||||||
let p2 = binary_tournament(&annotated, &mut rng);
|
|
||||||
let parents = vec![
|
|
||||||
annotated[p1].candidate.decision.clone(),
|
|
||||||
annotated[p2].candidate.decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"NSGA-II variation returned no children",
|
|
||||||
);
|
|
||||||
for child_decision in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child_decision);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(annotated.into_iter().map(|e| e.candidate));
|
|
||||||
combined.extend(offspring);
|
|
||||||
|
|
||||||
let fronts = non_dominated_sort(&combined, &objectives);
|
|
||||||
let mut next: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
|
|
||||||
for front in &fronts {
|
|
||||||
if next.len() + front.len() <= n {
|
|
||||||
for &idx in front {
|
|
||||||
next.push(combined[idx].clone());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let dist = crowding_distance(&combined, front, &objectives);
|
|
||||||
let mut order: Vec<usize> = (0..front.len()).collect();
|
|
||||||
order.sort_by(|&a, &b| {
|
|
||||||
dist[b]
|
|
||||||
.partial_cmp(&dist[a])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
let needed = n - next.len();
|
|
||||||
for &k in order.iter().take(needed) {
|
|
||||||
next.push(combined[front[k]].clone());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if next.len() == n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
annotated = annotate(next, &objectives);
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<P::Decision>> =
|
|
||||||
annotated.into_iter().map(|e| e.candidate).collect();
|
|
||||||
let front = pareto_front(&final_pop, &objectives);
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
|
fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
|
||||||
let n = entries.len();
|
let n = entries.len();
|
||||||
let a = rng.random_range(0..n);
|
let a = rng.random_range(0..n);
|
||||||
@@ -364,18 +309,6 @@ fn binary_tournament<D>(entries: &[Nsga2Entry<D>], rng: &mut Rng) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Nsga2<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"NSGA-II"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Non-dominated Sorting Genetic Algorithm II"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -463,64 +396,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_prefers_lower_rank() {
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
// Entry 0: rank 0; entry 1: rank 1. Lower rank must win every time
|
|
||||||
// the two draws differ.
|
|
||||||
let entries = vec![
|
|
||||||
Nsga2Entry {
|
|
||||||
candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])),
|
|
||||||
rank: 0,
|
|
||||||
crowding_distance: 0.0,
|
|
||||||
},
|
|
||||||
Nsga2Entry {
|
|
||||||
candidate: Candidate::new(1u32, Evaluation::new(vec![2.0, 2.0])),
|
|
||||||
rank: 1,
|
|
||||||
crowding_distance: 100.0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let mut wins0 = 0;
|
|
||||||
for seed in 0..200 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if binary_tournament(&entries, &mut rng) == 0 {
|
|
||||||
wins0 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Rank dominates crowding distance — index 0 wins the clear majority.
|
|
||||||
assert!(wins0 > 130, "lower-rank index won only {wins0}/200");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_prefers_higher_crowding_at_equal_rank() {
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
// Both rank 0; entry 0 has higher crowding distance → preferred.
|
|
||||||
let entries = vec![
|
|
||||||
Nsga2Entry {
|
|
||||||
candidate: Candidate::new(0u32, Evaluation::new(vec![1.0, 1.0])),
|
|
||||||
rank: 0,
|
|
||||||
crowding_distance: 10.0,
|
|
||||||
},
|
|
||||||
Nsga2Entry {
|
|
||||||
candidate: Candidate::new(1u32, Evaluation::new(vec![1.0, 1.0])),
|
|
||||||
rank: 0,
|
|
||||||
crowding_distance: 1.0,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let mut wins0 = 0;
|
|
||||||
for seed in 0..200 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if binary_tournament(&entries, &mut rng) == 0 {
|
|
||||||
wins0 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(wins0 > 130, "higher-crowding index won only {wins0}/200");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,92 +180,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Nsga3<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Nsga3 population_size must be greater than 0",
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let m = objectives.len();
|
|
||||||
let reference_points = das_dennis(m, self.config.reference_divisions);
|
|
||||||
assert!(
|
|
||||||
!reference_points.is_empty(),
|
|
||||||
"Nsga3 reference set is empty — check reference_divisions",
|
|
||||||
);
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
assert_eq!(
|
|
||||||
initial_decisions.len(),
|
|
||||||
n,
|
|
||||||
"NSGA-III initializer must return exactly population_size decisions",
|
|
||||||
);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"NSGA-III variation returned no children",
|
|
||||||
);
|
|
||||||
for child_decision in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child_decision);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
population =
|
|
||||||
environmental_selection(&combined, &objectives, &reference_points, n, &mut rng);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// NSGA-III environmental selection: front-by-front + reference-point niching
|
/// NSGA-III environmental selection: front-by-front + reference-point niching
|
||||||
/// on the splitting front.
|
/// on the splitting front.
|
||||||
fn environmental_selection<D: Clone>(
|
fn environmental_selection<D: Clone>(
|
||||||
@@ -542,71 +456,6 @@ fn associate(
|
|||||||
(assoc, dist)
|
(assoc, dist)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Nsga3<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"NSGA-III"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Non-dominated Sorting Genetic Algorithm III"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod helper_tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn solve_intercepts_axis_aligned_extremes() {
|
|
||||||
// Extremes (2, 0) and (0, 3): the plane through them on the
|
|
||||||
// canonical simplex has intercepts (2, 3).
|
|
||||||
let oriented = vec![vec![2.0, 0.0], vec![0.0, 3.0]];
|
|
||||||
let intercepts = solve_intercepts(&oriented, &[0, 1]).expect("solvable");
|
|
||||||
assert!((intercepts[0] - 2.0).abs() < 1e-9, "got {:?}", intercepts);
|
|
||||||
assert!((intercepts[1] - 3.0).abs() < 1e-9, "got {:?}", intercepts);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn solve_intercepts_singular_matrix_returns_none() {
|
|
||||||
// Two identical extremes → singular system → None.
|
|
||||||
let oriented = vec![vec![1.0, 1.0], vec![1.0, 1.0]];
|
|
||||||
assert!(solve_intercepts(&oriented, &[0, 1]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn solve_intercepts_empty_extremes_returns_none() {
|
|
||||||
let oriented: Vec<Vec<f64>> = Vec::new();
|
|
||||||
assert!(solve_intercepts(&oriented, &[]).is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn associate_picks_closest_reference_direction() {
|
|
||||||
// Two reference directions: the x-axis and the y-axis.
|
|
||||||
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
// A point near the x-axis associates with reference 0;
|
|
||||||
// a point near the y-axis associates with reference 1.
|
|
||||||
let normalized = vec![vec![1.0, 0.05], vec![0.05, 1.0]];
|
|
||||||
let (assoc, dist) = associate(&normalized, &refs, 2);
|
|
||||||
assert_eq!(assoc[0], 0);
|
|
||||||
assert_eq!(assoc[1], 1);
|
|
||||||
// Perpendicular distance from (1, 0.05) to the x-axis is 0.05.
|
|
||||||
assert!((dist[0] - 0.05).abs() < 1e-9, "dist0 = {}", dist[0]);
|
|
||||||
assert!((dist[1] - 0.05).abs() < 1e-9, "dist1 = {}", dist[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn associate_point_on_reference_line_has_zero_distance() {
|
|
||||||
let refs = vec![vec![1.0, 0.0]];
|
|
||||||
// (3, 0) lies exactly on the x-axis direction → perp distance 0.
|
|
||||||
let normalized = vec![vec![3.0, 0.0]];
|
|
||||||
let (assoc, dist) = associate(&normalized, &refs, 2);
|
|
||||||
assert_eq!(assoc[0], 0);
|
|
||||||
assert!(dist[0].abs() < 1e-9, "dist = {}", dist[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -191,112 +191,6 @@ fn worse_than(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl OnePlusOneEs {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` is mostly inert here because (1+1)-ES evaluates
|
|
||||||
/// one child per iteration; it's accepted for API parity with
|
|
||||||
/// other algorithms.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
let _ = concurrency;
|
|
||||||
assert!(
|
|
||||||
self.config.initial_sigma > 0.0,
|
|
||||||
"OnePlusOneEs initial_sigma must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.step_increase > 1.0,
|
|
||||||
"OnePlusOneEs step_increase must be > 1",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.adaptation_period >= 1,
|
|
||||||
"OnePlusOneEs adaptation_period must be >= 1",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"OnePlusOneEs requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut parent: Vec<f64> = self
|
|
||||||
.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
|
||||||
.collect();
|
|
||||||
let mut parent_eval = problem.evaluate_async(&parent).await;
|
|
||||||
let mut evaluations = 1usize;
|
|
||||||
|
|
||||||
let mut sigma = self.config.initial_sigma;
|
|
||||||
let mut window = std::collections::VecDeque::with_capacity(self.config.adaptation_period);
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let normal = Normal::new(0.0, sigma).expect("Normal::new(0, sigma)");
|
|
||||||
let mut child = parent.clone();
|
|
||||||
for (j, x) in child.iter_mut().enumerate() {
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
*x = (*x + normal.sample(&mut rng)).clamp(lo, hi);
|
|
||||||
}
|
|
||||||
let child_eval = problem.evaluate_async(&child).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
let accepted = !worse_than(&child_eval, &parent_eval, direction);
|
|
||||||
if accepted {
|
|
||||||
parent = child;
|
|
||||||
parent_eval = child_eval;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.push_back(if accepted { 1u8 } else { 0u8 });
|
|
||||||
if window.len() > self.config.adaptation_period {
|
|
||||||
window.pop_front();
|
|
||||||
}
|
|
||||||
if window.len() == self.config.adaptation_period {
|
|
||||||
let success_count: usize = window.iter().map(|&b| b as usize).sum();
|
|
||||||
let rate = success_count as f64 / window.len() as f64;
|
|
||||||
if rate > 0.2 {
|
|
||||||
sigma *= self.config.step_increase;
|
|
||||||
} else if rate < 0.2 {
|
|
||||||
sigma /= self.config.step_increase;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = Candidate::new(parent, parent_eval);
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for OnePlusOneEs {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"(1+1)-ES"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"(1+1) Evolution Strategy with one-fifth success rule"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -345,32 +239,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn worse_than_feasibility_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
// infeasible is worse than feasible regardless of objective.
|
|
||||||
assert!(worse_than(&infeasible, &feasible, Direction::Minimize));
|
|
||||||
assert!(!worse_than(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
// two feasible, minimize: larger objective is worse.
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(worse_than(&hi, &lo, Direction::Minimize));
|
|
||||||
assert!(!worse_than(&lo, &hi, Direction::Minimize));
|
|
||||||
// maximize inverts.
|
|
||||||
assert!(worse_than(&lo, &hi, Direction::Maximize));
|
|
||||||
// equal → not worse.
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!worse_than(&lo, &eq, Direction::Minimize));
|
|
||||||
// two infeasible: larger violation is worse.
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert!(worse_than(&v_hi, &v_lo, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,104 +156,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Paes<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` is mostly inert here because PAES evaluates one
|
|
||||||
/// child per iteration; it's accepted for API parity with other
|
|
||||||
/// algorithms.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
let _ = concurrency;
|
|
||||||
assert!(
|
|
||||||
self.config.archive_size > 0,
|
|
||||||
"PAES archive_size must be greater than 0",
|
|
||||||
);
|
|
||||||
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut initial = self.initializer.initialize(1, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!initial.is_empty(),
|
|
||||||
"PAES initializer returned no decisions",
|
|
||||||
);
|
|
||||||
let mut current_decision = initial.remove(0);
|
|
||||||
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
|
||||||
let mut evaluations = 1usize;
|
|
||||||
|
|
||||||
let mut archive = ParetoArchive::new(objectives.clone());
|
|
||||||
archive.insert(Candidate::new(
|
|
||||||
current_decision.clone(),
|
|
||||||
current_eval.clone(),
|
|
||||||
));
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let parents = vec![current_decision.clone()];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "PAES variation returned no children",);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
match pareto_compare(&child_eval, ¤t_eval, &objectives) {
|
|
||||||
Dominance::Dominates => {
|
|
||||||
current_decision = child_decision.clone();
|
|
||||||
current_eval = child_eval.clone();
|
|
||||||
}
|
|
||||||
Dominance::DominatedBy => {
|
|
||||||
// Stay at current.
|
|
||||||
}
|
|
||||||
Dominance::NonDominated | Dominance::Equal => {
|
|
||||||
current_decision = child_decision.clone();
|
|
||||||
current_eval = child_eval.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
archive.insert(Candidate::new(child_decision, child_eval));
|
|
||||||
archive.insert(Candidate::new(
|
|
||||||
current_decision.clone(),
|
|
||||||
current_eval.clone(),
|
|
||||||
));
|
|
||||||
archive.truncate(self.config.archive_size);
|
|
||||||
}
|
|
||||||
|
|
||||||
let members = archive.into_vec();
|
|
||||||
let front = pareto_front(&members, &objectives);
|
|
||||||
let best = best_candidate(&members, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(members),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Paes<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"PAES"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Pareto Archived Evolution Strategy"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -305,38 +207,4 @@ mod tests {
|
|||||||
let r = opt.run(&Sphere1D);
|
let r = opt.run(&Sphere1D);
|
||||||
assert!(r.best.is_some());
|
assert!(r.best.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PAES must return a non-empty Pareto archive on a 2-objective problem
|
|
||||||
/// and be deterministic with a fixed seed. Pins the run-loop
|
|
||||||
/// bookkeeping against degenerate / comparison mutants.
|
|
||||||
#[test]
|
|
||||||
fn produces_deterministic_nonempty_front() {
|
|
||||||
let make = || {
|
|
||||||
Paes::new(
|
|
||||||
PaesConfig {
|
|
||||||
iterations: 40,
|
|
||||||
archive_size: 10,
|
|
||||||
seed: 5,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-5.0, 5.0)]),
|
|
||||||
GaussianMutation { sigma: 0.3 },
|
|
||||||
)
|
|
||||||
};
|
|
||||||
let r1 = make().run(&SchafferN1);
|
|
||||||
let r2 = make().run(&SchafferN1);
|
|
||||||
assert!(!r1.pareto_front.is_empty());
|
|
||||||
let f1: Vec<Vec<f64>> = r1
|
|
||||||
.pareto_front
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives.clone())
|
|
||||||
.collect();
|
|
||||||
let f2: Vec<Vec<f64>> = r2
|
|
||||||
.pareto_front
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives.clone())
|
|
||||||
.collect();
|
|
||||||
assert_eq!(f1, f2);
|
|
||||||
// Archive never exceeds its configured cap.
|
|
||||||
assert!(r1.pareto_front.len() <= 10);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
//! Async population evaluator.
|
|
||||||
//!
|
|
||||||
//! Available only with the `async` feature. Used by the `run_async`
|
|
||||||
//! method on algorithms that support async problems.
|
|
||||||
|
|
||||||
use futures::stream::{FuturesOrdered, StreamExt};
|
|
||||||
|
|
||||||
use crate::core::async_problem::{AsyncPartialProblem, AsyncProblem};
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
|
|
||||||
/// Evaluate every decision concurrently against `problem`, preserving
|
|
||||||
/// input order in the returned vector. Concurrency is bounded by
|
|
||||||
/// `concurrency` (≥ 1) — too high a value wastes memory and may
|
|
||||||
/// overload downstream services; too low forfeits parallelism.
|
|
||||||
///
|
|
||||||
/// Returns a future that the caller drives via their preferred
|
|
||||||
/// runtime (typically tokio).
|
|
||||||
pub async fn evaluate_batch_async<P>(
|
|
||||||
problem: &P,
|
|
||||||
decisions: Vec<P::Decision>,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> Vec<Candidate<P::Decision>>
|
|
||||||
where
|
|
||||||
P: AsyncProblem,
|
|
||||||
{
|
|
||||||
assert!(
|
|
||||||
concurrency >= 1,
|
|
||||||
"evaluate_batch_async concurrency must be >= 1"
|
|
||||||
);
|
|
||||||
let mut out: Vec<Candidate<P::Decision>> = Vec::with_capacity(decisions.len());
|
|
||||||
|
|
||||||
// Process in concurrency-bounded chunks to keep peak memory low
|
|
||||||
// and avoid blasting downstream services. Each chunk uses
|
|
||||||
// FuturesOrdered to preserve per-chunk order, and chunks are
|
|
||||||
// emitted in their natural order.
|
|
||||||
let mut iter = decisions.into_iter();
|
|
||||||
loop {
|
|
||||||
let mut futs = FuturesOrdered::new();
|
|
||||||
for _ in 0..concurrency {
|
|
||||||
match iter.next() {
|
|
||||||
Some(d) => {
|
|
||||||
futs.push_back(async move {
|
|
||||||
let e = problem.evaluate_async(&d).await;
|
|
||||||
Candidate::new(d, e)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
None => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if futs.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
while let Some(c) = futs.next().await {
|
|
||||||
out.push(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluate every decision at the given `budget` concurrently against a
|
|
||||||
/// multi-fidelity `problem`, preserving input order. Hyperband's async
|
|
||||||
/// path uses this for each Successive-Halving rung.
|
|
||||||
pub async fn evaluate_batch_at_budget_async<P>(
|
|
||||||
problem: &P,
|
|
||||||
decisions: &[P::Decision],
|
|
||||||
budget: f64,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> Vec<Evaluation>
|
|
||||||
where
|
|
||||||
P: AsyncPartialProblem,
|
|
||||||
{
|
|
||||||
assert!(
|
|
||||||
concurrency >= 1,
|
|
||||||
"evaluate_batch_at_budget_async concurrency must be >= 1"
|
|
||||||
);
|
|
||||||
let mut out: Vec<Evaluation> = Vec::with_capacity(decisions.len());
|
|
||||||
let mut idx = 0usize;
|
|
||||||
while idx < decisions.len() {
|
|
||||||
let mut futs = FuturesOrdered::new();
|
|
||||||
let end = (idx + concurrency).min(decisions.len());
|
|
||||||
for d in &decisions[idx..end] {
|
|
||||||
futs.push_back(async move { problem.evaluate_at_budget_async(d, budget).await });
|
|
||||||
}
|
|
||||||
while let Some(e) = futs.next().await {
|
|
||||||
out.push(e);
|
|
||||||
}
|
|
||||||
idx = end;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
@@ -220,129 +220,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl ParticleSwarm {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch (initial
|
|
||||||
/// swarm, per-generation positions, and the final evaluation pass).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.swarm_size >= 1,
|
|
||||||
"ParticleSwarm swarm_size must be >= 1",
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"ParticleSwarm requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
let n = self.config.swarm_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut positions: Vec<Vec<f64>> = {
|
|
||||||
use crate::traits::Initializer as _;
|
|
||||||
self.bounds.initialize(n, &mut rng)
|
|
||||||
};
|
|
||||||
let mut velocities: Vec<Vec<f64>> = (0..n)
|
|
||||||
.map(|_| {
|
|
||||||
self.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.1 * (hi - lo) * (rng.random::<f64>() * 2.0 - 1.0))
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let v_max: Vec<f64> = self.bounds.bounds.iter().map(|&(lo, hi)| hi - lo).collect();
|
|
||||||
|
|
||||||
let initial_pop = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
|
||||||
let mut evaluations = initial_pop.len();
|
|
||||||
|
|
||||||
let mut pbest_decisions: Vec<Vec<f64>> = positions.clone();
|
|
||||||
let mut pbest_evals: Vec<f64> = initial_pop
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives[0])
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut gbest_idx = best_index(&pbest_evals, direction);
|
|
||||||
let mut gbest_decision = pbest_decisions[gbest_idx].clone();
|
|
||||||
let mut gbest_eval = pbest_evals[gbest_idx];
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
for i in 0..n {
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
|
||||||
for j in 0..dim {
|
|
||||||
let r1: f64 = rng.random();
|
|
||||||
let r2: f64 = rng.random();
|
|
||||||
let cognitive_term =
|
|
||||||
self.config.cognitive * r1 * (pbest_decisions[i][j] - positions[i][j]);
|
|
||||||
let social_term =
|
|
||||||
self.config.social * r2 * (gbest_decision[j] - positions[i][j]);
|
|
||||||
let mut v =
|
|
||||||
self.config.inertia * velocities[i][j] + cognitive_term + social_term;
|
|
||||||
if v > v_max[j] {
|
|
||||||
v = v_max[j];
|
|
||||||
} else if v < -v_max[j] {
|
|
||||||
v = -v_max[j];
|
|
||||||
}
|
|
||||||
velocities[i][j] = v;
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
positions[i][j] = (positions[i][j] + v).clamp(lo, hi);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let evaluated = evaluate_batch_async(problem, positions.clone(), concurrency).await;
|
|
||||||
evaluations += evaluated.len();
|
|
||||||
|
|
||||||
for (i, cand) in evaluated.iter().enumerate() {
|
|
||||||
let f = cand.evaluation.objectives[0];
|
|
||||||
let improves = match direction {
|
|
||||||
Direction::Minimize => f < pbest_evals[i],
|
|
||||||
Direction::Maximize => f > pbest_evals[i],
|
|
||||||
};
|
|
||||||
if improves {
|
|
||||||
pbest_decisions[i] = positions[i].clone();
|
|
||||||
pbest_evals[i] = f;
|
|
||||||
gbest_idx = i;
|
|
||||||
let beats_global = match direction {
|
|
||||||
Direction::Minimize => f < gbest_eval,
|
|
||||||
Direction::Maximize => f > gbest_eval,
|
|
||||||
};
|
|
||||||
if beats_global {
|
|
||||||
gbest_decision = pbest_decisions[i].clone();
|
|
||||||
gbest_eval = f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = gbest_idx;
|
|
||||||
|
|
||||||
let final_pop = evaluate_batch_async(problem, positions, concurrency).await;
|
|
||||||
evaluations += final_pop.len();
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn best_index(values: &[f64], direction: Direction) -> usize {
|
fn best_index(values: &[f64], direction: Direction) -> usize {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
for i in 1..values.len() {
|
for i in 1..values.len() {
|
||||||
@@ -357,18 +234,6 @@ fn best_index(values: &[f64], direction: Direction) -> usize {
|
|||||||
idx
|
idx
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for ParticleSwarm {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"PSO"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Particle Swarm Optimization"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -418,33 +283,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_index_minimize_picks_smallest() {
|
|
||||||
let v = [3.0, 1.0, 4.0, 1.5];
|
|
||||||
assert_eq!(best_index(&v, Direction::Minimize), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_index_maximize_picks_largest() {
|
|
||||||
let v = [3.0, 1.0, 4.0, 1.5];
|
|
||||||
assert_eq!(best_index(&v, Direction::Maximize), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_index_keeps_first_on_tie() {
|
|
||||||
// Strict comparison → the earliest index of a tied extreme wins.
|
|
||||||
let v = [1.0, 1.0, 1.0];
|
|
||||||
assert_eq!(best_index(&v, Direction::Minimize), 0);
|
|
||||||
assert_eq!(best_index(&v, Direction::Maximize), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_index_single_element() {
|
|
||||||
assert_eq!(best_index(&[42.0], Direction::Minimize), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,109 +204,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> PesaII<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations of the initial
|
|
||||||
/// population. Per-step evaluations are sequential to preserve the
|
|
||||||
/// algorithm's exact RNG sequencing.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"PesaII population_size must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.archive_size > 0,
|
|
||||||
"PesaII archive_size must be > 0"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.grid_divisions >= 1,
|
|
||||||
"PesaII grid_divisions must be >= 1"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut internal: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = internal.len();
|
|
||||||
|
|
||||||
let mut archive = ParetoArchive::new(objectives.clone());
|
|
||||||
for c in &internal {
|
|
||||||
archive.insert(c.clone());
|
|
||||||
}
|
|
||||||
truncate_by_grid(
|
|
||||||
&mut archive,
|
|
||||||
self.config.archive_size,
|
|
||||||
self.config.grid_divisions,
|
|
||||||
);
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let (boxes, counts) = build_grid(&archive, &objectives, self.config.grid_divisions);
|
|
||||||
|
|
||||||
let mut offspring: Vec<Candidate<P::Decision>> = Vec::with_capacity(n);
|
|
||||||
while offspring.len() < n {
|
|
||||||
let p1 = region_tournament(&archive, &boxes, &counts, &mut rng);
|
|
||||||
let p2 = region_tournament(&archive, &boxes, &counts, &mut rng);
|
|
||||||
let parents = vec![
|
|
||||||
archive.members()[p1].decision.clone(),
|
|
||||||
archive.members()[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"PesaII variation returned no children"
|
|
||||||
);
|
|
||||||
for child in children {
|
|
||||||
if offspring.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let eval = problem.evaluate_async(&child).await;
|
|
||||||
evaluations += 1;
|
|
||||||
offspring.push(Candidate::new(child, eval));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for c in &offspring {
|
|
||||||
archive.insert(c.clone());
|
|
||||||
}
|
|
||||||
truncate_by_grid(
|
|
||||||
&mut archive,
|
|
||||||
self.config.archive_size,
|
|
||||||
self.config.grid_divisions,
|
|
||||||
);
|
|
||||||
internal = offspring;
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = internal;
|
|
||||||
let members = archive.into_vec();
|
|
||||||
let front = pareto_front(&members, &objectives);
|
|
||||||
let best = best_candidate(&members, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(members),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute per-member box index (M-tuple of grid coordinates) and the
|
/// Compute per-member box index (M-tuple of grid coordinates) and the
|
||||||
/// population count of each occupied box.
|
/// population count of each occupied box.
|
||||||
fn build_grid<D: Clone>(
|
fn build_grid<D: Clone>(
|
||||||
@@ -409,18 +306,6 @@ fn truncate_by_grid<D: Clone>(archive: &mut ParetoArchive<D>, max_size: usize, d
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for PesaII<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"PESA-II"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Pareto Envelope-based Selection Algorithm II"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -499,70 +384,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::{Objective, ObjectiveSpace};
|
|
||||||
|
|
||||||
fn space2() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_grid_empty_archive_is_empty() {
|
|
||||||
let archive = ParetoArchive::<u32>::new(space2());
|
|
||||||
let (boxes, counts) = build_grid(&archive, &space2(), 4);
|
|
||||||
assert!(boxes.is_empty());
|
|
||||||
assert!(counts.is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_grid_assigns_corner_points_to_distinct_boxes() {
|
|
||||||
let mut archive = ParetoArchive::<u32>::new(space2());
|
|
||||||
// Three non-dominated corner points span the grid extremes.
|
|
||||||
archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0])));
|
|
||||||
archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0])));
|
|
||||||
archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0])));
|
|
||||||
let (boxes, counts) = build_grid(&archive, &space2(), 4);
|
|
||||||
assert_eq!(boxes.len(), 3);
|
|
||||||
// The min and max corners land in different boxes — total count
|
|
||||||
// across all boxes equals the member count.
|
|
||||||
let total: usize = counts.values().sum();
|
|
||||||
assert_eq!(total, 3);
|
|
||||||
// The two extreme points are in different boxes (grid spreads them).
|
|
||||||
assert_ne!(boxes[0], boxes[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn region_tournament_prefers_less_crowded_box() {
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
// Members 0 and 1 share a crowded box (count 2); member 2 is alone.
|
|
||||||
let mut archive = ParetoArchive::<u32>::new(space2());
|
|
||||||
archive.insert(Candidate::new(1u32, Evaluation::new(vec![0.0, 4.0])));
|
|
||||||
archive.insert(Candidate::new(2u32, Evaluation::new(vec![2.0, 2.0])));
|
|
||||||
archive.insert(Candidate::new(3u32, Evaluation::new(vec![4.0, 0.0])));
|
|
||||||
// Hand-build boxes/counts where index 2 is in a singleton box and
|
|
||||||
// indices 0,1 share a crowded box.
|
|
||||||
let boxes = vec![vec![0usize, 0], vec![0usize, 0], vec![3usize, 3]];
|
|
||||||
let mut counts = std::collections::BTreeMap::new();
|
|
||||||
counts.insert(vec![0usize, 0], 2usize);
|
|
||||||
counts.insert(vec![3usize, 3], 1usize);
|
|
||||||
// Across many seeds, the less-crowded index (2) must win whenever
|
|
||||||
// the two random draws differ between the crowded/uncrowded boxes.
|
|
||||||
let mut picked_uncrowded = 0;
|
|
||||||
for seed in 0..300 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if region_tournament(&archive, &boxes, &counts, &mut rng) == 2 {
|
|
||||||
picked_uncrowded += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Index 2 wins whenever it's drawn against 0 or 1, plus half its
|
|
||||||
// self-draws — clear majority.
|
|
||||||
assert!(
|
|
||||||
picked_uncrowded > 150,
|
|
||||||
"uncrowded picked {picked_uncrowded}/300"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,82 +88,49 @@ 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 front = pareto_front(&all, &objectives);
|
||||||
let best = best_candidate(&all, &objectives);
|
let best = best_candidate(&all, &objectives);
|
||||||
OptimizationResult::new(
|
OptimizationResult::new(Population::new(all), front, best, evaluations, completed)
|
||||||
Population::new(all),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I> RandomSearch<I> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime (typically tokio). Useful when
|
|
||||||
/// `evaluate` is IO-bound (HTTP, RPC, subprocess).
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds how many evaluations are in-flight at once;
|
|
||||||
/// `1` is sequential, larger values push more load to the
|
|
||||||
/// downstream service.
|
|
||||||
///
|
|
||||||
/// Available only with the `async` feature.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
let mut all: Vec<Candidate<P::Decision>> = Vec::new();
|
|
||||||
let mut evaluations = 0usize;
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let decisions = self
|
|
||||||
.initializer
|
|
||||||
.initialize(self.config.batch_size, &mut rng);
|
|
||||||
evaluations += decisions.len();
|
|
||||||
let cands = evaluate_batch_async(problem, decisions, concurrency).await;
|
|
||||||
all.extend(cands);
|
|
||||||
}
|
|
||||||
let front = pareto_front(&all, &objectives);
|
|
||||||
let best = best_candidate(&all, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(all),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I> crate::traits::AlgorithmInfo for RandomSearch<I> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Random Search"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,27 +185,4 @@ mod tests {
|
|||||||
let r = opt.run(&Sphere1D);
|
let r = opt.run(&Sphere1D);
|
||||||
assert!(r.best.is_some());
|
assert!(r.best.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// RandomSearch's evaluation count is exactly `iterations * batch_size`,
|
|
||||||
/// and the returned best is no worse than every sampled candidate.
|
|
||||||
#[test]
|
|
||||||
fn best_is_no_worse_than_any_sample() {
|
|
||||||
let mut opt = RandomSearch::new(
|
|
||||||
RandomSearchConfig {
|
|
||||||
iterations: 50,
|
|
||||||
batch_size: 2,
|
|
||||||
seed: 9,
|
|
||||||
},
|
|
||||||
RealBounds::new(vec![(-3.0, 3.0)]),
|
|
||||||
);
|
|
||||||
let r = opt.run(&Sphere1D);
|
|
||||||
assert_eq!(r.evaluations, 100);
|
|
||||||
let best = r.best.unwrap().evaluation.objectives[0];
|
|
||||||
let pop_min = r
|
|
||||||
.population
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.evaluation.objectives[0])
|
|
||||||
.fold(f64::INFINITY, f64::min);
|
|
||||||
assert!(best <= pop_min + 1e-12, "best {best} > pop min {pop_min}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,163 +261,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Rvea<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Rvea population_size must be > 0"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let m = objectives.len();
|
|
||||||
let raw_refs = das_dennis(m, self.config.reference_divisions);
|
|
||||||
let references: Vec<Vec<f64>> = raw_refs.into_iter().map(unit_normalize).collect();
|
|
||||||
assert!(
|
|
||||||
!references.is_empty(),
|
|
||||||
"Rvea: no reference vectors generated"
|
|
||||||
);
|
|
||||||
|
|
||||||
let theta_max = smallest_neighbor_angle(&references);
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for gen_idx in 0..self.config.generations {
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n);
|
|
||||||
while offspring_decisions.len() < n {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "Rvea variation returned no children");
|
|
||||||
for child in children {
|
|
||||||
if offspring_decisions.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let offspring = evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += offspring.len();
|
|
||||||
|
|
||||||
let mut combined: Vec<Candidate<P::Decision>> = Vec::with_capacity(2 * n);
|
|
||||||
combined.extend(population);
|
|
||||||
combined.extend(offspring);
|
|
||||||
|
|
||||||
let m_dim = m;
|
|
||||||
let mut ideal = vec![f64::INFINITY; m_dim];
|
|
||||||
for c in &combined {
|
|
||||||
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
|
||||||
for (k, v) in oriented.iter().enumerate() {
|
|
||||||
if *v < ideal[k] {
|
|
||||||
ideal[k] = *v;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let translated: Vec<Vec<f64>> = combined
|
|
||||||
.iter()
|
|
||||||
.map(|c| {
|
|
||||||
let oriented = objectives.as_minimization(&c.evaluation.objectives);
|
|
||||||
oriented
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(k, v)| v - ideal[k])
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut assoc: Vec<usize> = vec![0; combined.len()];
|
|
||||||
let mut angles: Vec<f64> = vec![0.0; combined.len()];
|
|
||||||
for (i, t) in translated.iter().enumerate() {
|
|
||||||
let (best_ref, best_angle) = closest_reference(t, &references);
|
|
||||||
assoc[i] = best_ref;
|
|
||||||
angles[i] = best_angle;
|
|
||||||
}
|
|
||||||
|
|
||||||
let alpha_t = (gen_idx as f64 / (self.config.generations as f64).max(1.0))
|
|
||||||
.powf(self.config.alpha);
|
|
||||||
let mut keep: Vec<Option<(usize, f64)>> = vec![None; references.len()];
|
|
||||||
for i in 0..combined.len() {
|
|
||||||
let r = assoc[i];
|
|
||||||
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
|
|
||||||
let theta_max_safe = theta_max.max(1e-12);
|
|
||||||
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
|
|
||||||
let apd = penalty * length;
|
|
||||||
match keep[r] {
|
|
||||||
None => keep[r] = Some((i, apd)),
|
|
||||||
Some((_, current)) if apd < current => keep[r] = Some((i, apd)),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut next: Vec<Candidate<P::Decision>> = keep
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.map(|(i, _)| combined[i].clone())
|
|
||||||
.collect();
|
|
||||||
if next.len() < n {
|
|
||||||
let mut all_apds: Vec<(usize, f64)> = (0..combined.len())
|
|
||||||
.map(|i| {
|
|
||||||
let length: f64 = translated[i].iter().map(|v| v * v).sum::<f64>().sqrt();
|
|
||||||
let theta_max_safe = theta_max.max(1e-12);
|
|
||||||
let penalty = 1.0 + (m_dim as f64) * alpha_t * (angles[i] / theta_max_safe);
|
|
||||||
(i, penalty * length)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
all_apds.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
||||||
for (i, _) in all_apds {
|
|
||||||
if next.len() >= n {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if !next
|
|
||||||
.iter()
|
|
||||||
.any(|c| std::ptr::eq(c as *const _, &combined[i] as *const _))
|
|
||||||
{
|
|
||||||
next.push(combined[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if next.len() > n {
|
|
||||||
next.truncate(n);
|
|
||||||
}
|
|
||||||
population = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unit_normalize(mut v: Vec<f64>) -> Vec<f64> {
|
fn unit_normalize(mut v: Vec<f64>) -> Vec<f64> {
|
||||||
let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
let n: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
||||||
if n > 1e-12 {
|
if n > 1e-12 {
|
||||||
@@ -466,18 +309,6 @@ fn smallest_neighbor_angle(references: &[Vec<f64>]) -> f64 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Rvea<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"RVEA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Reference Vector-guided Evolutionary Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -556,44 +387,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unit_normalize_produces_unit_vector() {
|
|
||||||
let v = unit_normalize(vec![3.0, 4.0]);
|
|
||||||
let norm: f64 = v.iter().map(|x| x * x).sum::<f64>().sqrt();
|
|
||||||
assert!((norm - 1.0).abs() < 1e-12);
|
|
||||||
assert!((v[0] - 0.6).abs() < 1e-12);
|
|
||||||
assert!((v[1] - 0.8).abs() < 1e-12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unit_normalize_zero_vector_unchanged() {
|
|
||||||
// A (near-)zero vector is left as-is (no division by ~0).
|
|
||||||
let v = unit_normalize(vec![0.0, 0.0]);
|
|
||||||
assert_eq!(v, vec![0.0, 0.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn closest_reference_picks_smallest_angle() {
|
|
||||||
// References along the two axes; a point near the x-axis associates
|
|
||||||
// with reference 0 at a small angle.
|
|
||||||
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
let (idx, angle) = closest_reference(&[1.0, 0.0], &refs);
|
|
||||||
assert_eq!(idx, 0);
|
|
||||||
assert!(angle.abs() < 1e-9, "angle = {angle}");
|
|
||||||
let (idx2, _) = closest_reference(&[0.1, 1.0], &refs);
|
|
||||||
assert_eq!(idx2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn smallest_neighbor_angle_of_orthogonal_refs_is_pi_over_2() {
|
|
||||||
let refs = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
|
||||||
let a = smallest_neighbor_angle(&refs);
|
|
||||||
assert!(
|
|
||||||
(a - std::f64::consts::FRAC_PI_2).abs() < 1e-9,
|
|
||||||
"angle = {a}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,133 +215,6 @@ fn better_than(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> SimulatedAnnealing<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` is mostly inert here because SA evaluates one
|
|
||||||
/// child per iteration; it's accepted for API parity with other
|
|
||||||
/// algorithms.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
let _ = concurrency;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"SimulatedAnnealing requires exactly one objective",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.initial_temperature > 0.0,
|
|
||||||
"SimulatedAnnealing initial_temperature must be positive",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.final_temperature > 0.0,
|
|
||||||
"SimulatedAnnealing final_temperature must be positive",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.final_temperature <= self.config.initial_temperature,
|
|
||||||
"SimulatedAnnealing final_temperature must be <= initial_temperature",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut initial = self.initializer.initialize(1, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!initial.is_empty(),
|
|
||||||
"SimulatedAnnealing initializer returned no decisions",
|
|
||||||
);
|
|
||||||
let mut current_decision = initial.remove(0);
|
|
||||||
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
|
||||||
let mut best_decision = current_decision.clone();
|
|
||||||
let mut best_eval = current_eval.clone();
|
|
||||||
let mut evaluations = 1usize;
|
|
||||||
|
|
||||||
let cooling = if self.config.iterations <= 1 {
|
|
||||||
1.0
|
|
||||||
} else {
|
|
||||||
(self.config.final_temperature / self.config.initial_temperature)
|
|
||||||
.powf(1.0 / (self.config.iterations as f64 - 1.0))
|
|
||||||
};
|
|
||||||
let mut temperature = self.config.initial_temperature;
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let parents = vec![current_decision.clone()];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"SimulatedAnnealing variation returned no children"
|
|
||||||
);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
|
|
||||||
let accept = match (child_eval.is_feasible(), current_eval.is_feasible()) {
|
|
||||||
(true, false) => true,
|
|
||||||
(false, true) => false,
|
|
||||||
(false, false) => {
|
|
||||||
child_eval.constraint_violation <= current_eval.constraint_violation
|
|
||||||
}
|
|
||||||
(true, true) => {
|
|
||||||
let delta = match direction {
|
|
||||||
Direction::Minimize => {
|
|
||||||
child_eval.objectives[0] - current_eval.objectives[0]
|
|
||||||
}
|
|
||||||
Direction::Maximize => {
|
|
||||||
current_eval.objectives[0] - child_eval.objectives[0]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if delta <= 0.0 {
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
let prob = (-delta / temperature).exp();
|
|
||||||
rng.random::<f64>() < prob
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if accept {
|
|
||||||
current_decision = child_decision;
|
|
||||||
current_eval = child_eval;
|
|
||||||
if better_than(¤t_eval, &best_eval, direction) {
|
|
||||||
best_decision = current_decision.clone();
|
|
||||||
best_eval = current_eval.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
temperature *= cooling;
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = Candidate::new(best_decision, best_eval);
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for SimulatedAnnealing<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Simulated Annealing"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -407,26 +280,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&Sphere1D);
|
let _ = opt.run(&Sphere1D);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert!(better_than(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
assert!(!better_than(&infeasible, &feasible, Direction::Minimize));
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better_than(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(better_than(&hi, &lo, Direction::Maximize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better_than(&lo, &eq, Direction::Minimize));
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert!(better_than(&v_lo, &v_hi, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,80 +173,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> SmsEmoa<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations of the initial
|
|
||||||
/// population. Per-generation evaluations are sequential because
|
|
||||||
/// SMS-EMOA is a steady-state algorithm (one child per generation).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"SmsEmoa population_size must be > 0"
|
|
||||||
);
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert_eq!(
|
|
||||||
self.config.reference_point.len(),
|
|
||||||
objectives.len(),
|
|
||||||
"SmsEmoa reference_point.len() must equal number of objectives",
|
|
||||||
);
|
|
||||||
let reference = self.config.reference_point.clone();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n, &mut rng);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let p1 = rng.random_range(0..population.len());
|
|
||||||
let p2 = rng.random_range(0..population.len());
|
|
||||||
let parents = vec![
|
|
||||||
population[p1].decision.clone(),
|
|
||||||
population[p2].decision.clone(),
|
|
||||||
];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!children.is_empty(),
|
|
||||||
"SmsEmoa variation returned no children"
|
|
||||||
);
|
|
||||||
let child_decision = children.into_iter().next().unwrap();
|
|
||||||
let child_eval = problem.evaluate_async(&child_decision).await;
|
|
||||||
evaluations += 1;
|
|
||||||
let child = Candidate::new(child_decision, child_eval);
|
|
||||||
|
|
||||||
population.push(child);
|
|
||||||
let drop_idx = pick_drop_index(&population, &objectives, &reference);
|
|
||||||
population.swap_remove(drop_idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&population, &objectives);
|
|
||||||
let best = best_candidate(&population, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(population),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Choose the index in `pool` whose removal is preferred per SMS-EMOA's
|
/// Choose the index in `pool` whose removal is preferred per SMS-EMOA's
|
||||||
/// rules: drop from the worst non-dominated front; within that front,
|
/// rules: drop from the worst non-dominated front; within that front,
|
||||||
/// drop the member whose removal increases hypervolume the most (= the
|
/// drop the member whose removal increases hypervolume the most (= the
|
||||||
@@ -289,18 +215,6 @@ fn pick_drop_index<D>(
|
|||||||
worst_front[worst_idx_in_front]
|
worst_front[worst_idx_in_front]
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for SmsEmoa<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"SMS-EMOA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"S-Metric Selection Evolutionary Multi-Objective Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -400,51 +314,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::{Objective, ObjectiveSpace};
|
|
||||||
|
|
||||||
fn sms_space() -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
|
|
||||||
}
|
|
||||||
fn sms_cand(o: Vec<f64>) -> Candidate<u32> {
|
|
||||||
Candidate::new(0, Evaluation::new(o))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `pick_drop_index` drops the member of the worst front with the
|
|
||||||
/// smallest hypervolume contribution. With one clearly-dominated point
|
|
||||||
/// in the pool, that point forms a singleton worst front and is
|
|
||||||
/// returned directly.
|
|
||||||
#[test]
|
|
||||||
fn pick_drop_index_returns_singleton_worst_front() {
|
|
||||||
// (1,1) and (2,2)-trade-offs are front 0; (9,9) is dominated → the
|
|
||||||
// sole member of front 1.
|
|
||||||
let pool = vec![
|
|
||||||
sms_cand(vec![1.0, 3.0]),
|
|
||||||
sms_cand(vec![3.0, 1.0]),
|
|
||||||
sms_cand(vec![9.0, 9.0]), // dominated — worst front, singleton
|
|
||||||
];
|
|
||||||
let drop = pick_drop_index(&pool, &sms_space(), &[100.0, 100.0]);
|
|
||||||
assert_eq!(drop, 2, "should drop the dominated singleton");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// When the worst front has multiple members, the one with the
|
|
||||||
/// smallest hypervolume contribution is dropped — and the scan must
|
|
||||||
/// find it even at a non-zero index. Here `(1.0, 9.0)` at index 1 is
|
|
||||||
/// "shadowed" by its near-neighbour `(1.5, 8.5)` and contributes the
|
|
||||||
/// least unique HV (≈ 0.5 vs ≈ 3.75 and ≈ 7.5).
|
|
||||||
#[test]
|
|
||||||
fn pick_drop_index_drops_least_hv_contributor() {
|
|
||||||
// All three mutually non-dominated → single (worst) front.
|
|
||||||
let pool = vec![
|
|
||||||
sms_cand(vec![1.5, 8.5]),
|
|
||||||
sms_cand(vec![1.0, 9.0]), // least HV contribution → drop target
|
|
||||||
sms_cand(vec![9.0, 1.0]),
|
|
||||||
];
|
|
||||||
let drop = pick_drop_index(&pool, &sms_space(), &[10.0, 10.0]);
|
|
||||||
assert_eq!(drop, 1, "should drop the lowest-HV-contribution member");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,137 +222,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl SeparableNes {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per generation.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 2,
|
|
||||||
"SeparableNes population_size must be >= 2",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.initial_sigma > 0.0,
|
|
||||||
"SeparableNes initial_sigma must be > 0"
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"SeparableNes requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let n = self.bounds.bounds.len();
|
|
||||||
let lambda = self.config.population_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut mean: Vec<f64> = self
|
|
||||||
.bounds
|
|
||||||
.bounds
|
|
||||||
.iter()
|
|
||||||
.map(|&(lo, hi)| 0.5 * (lo + hi))
|
|
||||||
.collect();
|
|
||||||
let mut sigma = vec![self.config.initial_sigma; n];
|
|
||||||
|
|
||||||
let eta_sigma = self
|
|
||||||
.config
|
|
||||||
.sigma_learning_rate
|
|
||||||
.unwrap_or_else(|| (3.0 + (n as f64).ln()) / (5.0 * (n as f64).sqrt()));
|
|
||||||
let eta_mean = self.config.mean_learning_rate;
|
|
||||||
|
|
||||||
let utilities = nes_utilities(lambda);
|
|
||||||
|
|
||||||
let mut best_seen: Option<Candidate<Vec<f64>>> = None;
|
|
||||||
let mut total_evaluations = 0usize;
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
// Sample λ offspring; matches the sync RNG draw order so seeded
|
|
||||||
// runs reproduce exactly.
|
|
||||||
let mut z_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
|
||||||
let mut x_samples: Vec<Vec<f64>> = Vec::with_capacity(lambda);
|
|
||||||
for _ in 0..lambda {
|
|
||||||
let z: Vec<f64> = (0..n)
|
|
||||||
.map(|_| Normal::new(0.0, 1.0).unwrap().sample(&mut rng))
|
|
||||||
.collect();
|
|
||||||
let x: Vec<f64> = (0..n)
|
|
||||||
.map(|j| {
|
|
||||||
let v = mean[j] + sigma[j] * z[j];
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
v.clamp(lo, hi)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
z_samples.push(z);
|
|
||||||
x_samples.push(x);
|
|
||||||
}
|
|
||||||
|
|
||||||
let cands = evaluate_batch_async(problem, x_samples.clone(), concurrency).await;
|
|
||||||
total_evaluations += cands.len();
|
|
||||||
let evals: Vec<Evaluation> = cands.iter().map(|c| c.evaluation.clone()).collect();
|
|
||||||
for c in &cands {
|
|
||||||
let beats_best = match &best_seen {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better(&c.evaluation, &b.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats_best {
|
|
||||||
best_seen = Some(c.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut order: Vec<usize> = (0..lambda).collect();
|
|
||||||
order.sort_by(|&a, &b| compare(&evals[a], &evals[b], direction));
|
|
||||||
|
|
||||||
let mut grad_mean = vec![0.0_f64; n];
|
|
||||||
for k in 0..lambda {
|
|
||||||
let u = utilities[k];
|
|
||||||
let z = &z_samples[order[k]];
|
|
||||||
for j in 0..n {
|
|
||||||
grad_mean[j] += u * z[j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for j in 0..n {
|
|
||||||
mean[j] += eta_mean * sigma[j] * grad_mean[j];
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
mean[j] = mean[j].clamp(lo, hi);
|
|
||||||
}
|
|
||||||
|
|
||||||
for j in 0..n {
|
|
||||||
let mut grad_sigma_j = 0.0;
|
|
||||||
for k in 0..lambda {
|
|
||||||
let u = utilities[k];
|
|
||||||
let z = &z_samples[order[k]];
|
|
||||||
grad_sigma_j += u * (z[j] * z[j] - 1.0);
|
|
||||||
}
|
|
||||||
sigma[j] *= (0.5 * eta_sigma * grad_sigma_j).exp();
|
|
||||||
if !sigma[j].is_finite() || sigma[j] < 1e-30 {
|
|
||||||
sigma[j] = 1e-30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_seen.expect("at least one generation evaluated");
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
total_evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nes_utilities(lambda: usize) -> Vec<f64> {
|
fn nes_utilities(lambda: usize) -> Vec<f64> {
|
||||||
let half = lambda as f64 / 2.0 + 1.0;
|
let half = lambda as f64 / 2.0 + 1.0;
|
||||||
let raw: Vec<f64> = (0..lambda)
|
let raw: Vec<f64> = (0..lambda)
|
||||||
@@ -389,18 +258,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
compare(a, b, direction) == std::cmp::Ordering::Less
|
compare(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for SeparableNes {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"sNES"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Separable Natural Evolution Strategy"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -457,55 +314,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nes_utilities_sum_to_zero_and_are_descending() {
|
|
||||||
// The NES utility weights are a shifted log-rank scheme; they sum
|
|
||||||
// to (approximately) zero and the first (best-ranked) is largest.
|
|
||||||
let u = nes_utilities(10);
|
|
||||||
assert_eq!(u.len(), 10);
|
|
||||||
let sum: f64 = u.iter().sum();
|
|
||||||
assert!(sum.abs() < 1e-9, "utilities sum = {sum}");
|
|
||||||
// Descending: best rank gets the most weight.
|
|
||||||
for w in u.windows(2) {
|
|
||||||
assert!(w[0] >= w[1] - 1e-12, "not descending: {:?}", u);
|
|
||||||
}
|
|
||||||
// The first utility is positive (it gets above-average weight).
|
|
||||||
assert!(u[0] > 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&feasible, &infeasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare(&lo, &hi, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_is_strict_less() {
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(!better(&hi, &lo, Direction::Minimize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better(&lo, &eq, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,91 +169,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<I, V> Spea2<I, V> {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<P::Decision>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem,
|
|
||||||
I: Initializer<P::Decision>,
|
|
||||||
V: Variation<P::Decision>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size > 0,
|
|
||||||
"Spea2 population_size must be greater than 0",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.archive_size > 0,
|
|
||||||
"Spea2 archive_size must be greater than 0",
|
|
||||||
);
|
|
||||||
let n_pop = self.config.population_size;
|
|
||||||
let n_arc = self.config.archive_size;
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let initial_decisions = self.initializer.initialize(n_pop, &mut rng);
|
|
||||||
assert_eq!(
|
|
||||||
initial_decisions.len(),
|
|
||||||
n_pop,
|
|
||||||
"SPEA2 initializer must return exactly population_size decisions",
|
|
||||||
);
|
|
||||||
let mut population: Vec<Candidate<P::Decision>> =
|
|
||||||
evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
let mut archive: Vec<Candidate<P::Decision>> = Vec::new();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut pool: Vec<Candidate<P::Decision>> =
|
|
||||||
Vec::with_capacity(population.len() + archive.len());
|
|
||||||
pool.append(&mut population);
|
|
||||||
pool.append(&mut archive);
|
|
||||||
let fitness = compute_fitness(&pool, &objectives);
|
|
||||||
|
|
||||||
archive = build_archive(&pool, &fitness, &objectives, n_arc);
|
|
||||||
|
|
||||||
let archive_fitness = compute_fitness(&archive, &objectives);
|
|
||||||
let mut offspring_decisions: Vec<P::Decision> = Vec::with_capacity(n_pop);
|
|
||||||
while offspring_decisions.len() < n_pop {
|
|
||||||
let p1 = binary_tournament(&archive_fitness, &mut rng);
|
|
||||||
let p2 = binary_tournament(&archive_fitness, &mut rng);
|
|
||||||
let parents = vec![archive[p1].decision.clone(), archive[p2].decision.clone()];
|
|
||||||
let children = self.variation.vary(&parents, &mut rng);
|
|
||||||
assert!(!children.is_empty(), "SPEA2 variation returned no children");
|
|
||||||
for child_decision in children {
|
|
||||||
if offspring_decisions.len() >= n_pop {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offspring_decisions.push(child_decision);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let new_population =
|
|
||||||
evaluate_batch_async(problem, offspring_decisions, concurrency).await;
|
|
||||||
evaluations += new_population.len();
|
|
||||||
population = new_population;
|
|
||||||
}
|
|
||||||
|
|
||||||
let front = pareto_front(&archive, &objectives);
|
|
||||||
let best = best_candidate(&archive, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(archive),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SPEA2 fitness: `R(i) + D(i)`, where lower is better.
|
/// SPEA2 fitness: `R(i) + D(i)`, where lower is better.
|
||||||
///
|
///
|
||||||
/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the
|
/// `R(i)` is the sum of `S(j)` over all `j` that dominate `i`. `S(j)` is the
|
||||||
@@ -503,18 +418,6 @@ fn binary_tournament(fitness: &[f64], rng: &mut Rng) -> usize {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<I, V> crate::traits::AlgorithmInfo for Spea2<I, V> {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"SPEA2"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Strength Pareto Evolutionary Algorithm 2"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -601,30 +504,4 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn euclidean_distance_basics() {
|
|
||||||
// (0,0) to (3,4) = 5.
|
|
||||||
assert!((euclidean(&[0.0, 0.0], &[3.0, 4.0]) - 5.0).abs() < 1e-12);
|
|
||||||
// symmetric and zero-to-self.
|
|
||||||
assert!((euclidean(&[3.0, 4.0], &[0.0, 0.0]) - 5.0).abs() < 1e-12);
|
|
||||||
assert_eq!(euclidean(&[1.0, 2.0, 3.0], &[1.0, 2.0, 3.0]), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn binary_tournament_prefers_lower_fitness() {
|
|
||||||
// SPEA2 fitness is "lower is better" — index 1 here is the best.
|
|
||||||
use crate::core::rng::rng_from_seed;
|
|
||||||
let fitness = vec![5.0_f64, 0.5];
|
|
||||||
let mut wins1 = 0;
|
|
||||||
for seed in 0..200 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
if binary_tournament(&fitness, &mut rng) == 1 {
|
|
||||||
wins1 += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(wins1 > 130, "lower-fitness index won only {wins1}/200");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,142 +209,6 @@ fn better_than(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl<D, I, N> TabuSearch<D, I, N>
|
|
||||||
where
|
|
||||||
D: Clone + Hash + Eq,
|
|
||||||
I: Initializer<D>,
|
|
||||||
N: FnMut(&D, &mut Rng) -> Vec<D>,
|
|
||||||
{
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// Each iteration evaluates the K neighbors of the current
|
|
||||||
/// incumbent concurrently (bounded by `concurrency`), then picks
|
|
||||||
/// the best non-tabu (or aspiration-passing) move.
|
|
||||||
pub async fn run_async<P>(&mut self, problem: &P, concurrency: usize) -> OptimizationResult<D>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = D>,
|
|
||||||
D: Send + Sync,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"TabuSearch requires exactly one objective",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.tabu_tenure >= 1,
|
|
||||||
"TabuSearch tabu_tenure must be >= 1",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut initial = self.initializer.initialize(1, &mut rng);
|
|
||||||
assert!(
|
|
||||||
!initial.is_empty(),
|
|
||||||
"TabuSearch initializer returned no decisions"
|
|
||||||
);
|
|
||||||
let mut current_decision = initial.remove(0);
|
|
||||||
let mut current_eval = problem.evaluate_async(¤t_decision).await;
|
|
||||||
let mut best_decision = current_decision.clone();
|
|
||||||
let mut best_eval = current_eval.clone();
|
|
||||||
let mut evaluations = 1usize;
|
|
||||||
|
|
||||||
let mut tabu_queue: VecDeque<D> = VecDeque::with_capacity(self.config.tabu_tenure);
|
|
||||||
let mut tabu_set: HashSet<D> = HashSet::new();
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let candidates = (self.neighbors)(¤t_decision, &mut rng);
|
|
||||||
if candidates.is_empty() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cand_results = evaluate_batch_async(problem, candidates.clone(), concurrency).await;
|
|
||||||
let mut cand_evals: Vec<crate::core::evaluation::Evaluation> =
|
|
||||||
cand_results.into_iter().map(|c| c.evaluation).collect();
|
|
||||||
evaluations += candidates.len();
|
|
||||||
|
|
||||||
let mut best_idx: Option<usize> = None;
|
|
||||||
let mut best_cand_eval: Option<crate::core::evaluation::Evaluation> = None;
|
|
||||||
|
|
||||||
for (i, c) in candidates.iter().enumerate() {
|
|
||||||
let is_tabu = tabu_set.contains(c);
|
|
||||||
let aspires = is_tabu && better_than(&cand_evals[i], &best_eval, direction);
|
|
||||||
if is_tabu && !aspires {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let eligible = match &best_cand_eval {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than(&cand_evals[i], b, direction),
|
|
||||||
};
|
|
||||||
if eligible {
|
|
||||||
best_idx = Some(i);
|
|
||||||
best_cand_eval = Some(cand_evals[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if best_idx.is_none() {
|
|
||||||
for (i, _) in candidates.iter().enumerate() {
|
|
||||||
let eligible = match &best_cand_eval {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than(&cand_evals[i], b, direction),
|
|
||||||
};
|
|
||||||
if eligible {
|
|
||||||
best_idx = Some(i);
|
|
||||||
best_cand_eval = Some(cand_evals[i].clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let chosen_idx = best_idx.expect("non-empty candidate list");
|
|
||||||
let chosen_decision = candidates[chosen_idx].clone();
|
|
||||||
current_eval = cand_evals.remove(chosen_idx);
|
|
||||||
current_decision = chosen_decision.clone();
|
|
||||||
|
|
||||||
if better_than(¤t_eval, &best_eval, direction) {
|
|
||||||
best_decision = current_decision.clone();
|
|
||||||
best_eval = current_eval.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
tabu_queue.push_back(chosen_decision.clone());
|
|
||||||
tabu_set.insert(chosen_decision);
|
|
||||||
if tabu_queue.len() > self.config.tabu_tenure {
|
|
||||||
if let Some(old) = tabu_queue.pop_front() {
|
|
||||||
tabu_set.remove(&old);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = Candidate::new(best_decision, best_eval);
|
|
||||||
let population = Population::new(vec![best.clone()]);
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
population,
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
evaluations,
|
|
||||||
self.config.iterations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<D, I, N> crate::traits::AlgorithmInfo for TabuSearch<D, I, N>
|
|
||||||
where
|
|
||||||
D: Clone + Hash + Eq,
|
|
||||||
I: Initializer<D>,
|
|
||||||
N: FnMut(&D, &mut Rng) -> Vec<D>,
|
|
||||||
{
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"Tabu Search"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -421,24 +285,4 @@ mod tests {
|
|||||||
rb.best.unwrap().evaluation.objectives,
|
rb.best.unwrap().evaluation.objectives,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_feasibility_first_and_direction() {
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert!(better_than(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
assert!(!better_than(&infeasible, &feasible, Direction::Minimize));
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better_than(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(better_than(&hi, &lo, Direction::Maximize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better_than(&lo, &eq, Direction::Minimize));
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert!(better_than(&v_lo, &v_hi, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,122 +187,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl Tlbo {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations within batched phases
|
|
||||||
/// (only the initial population uses a batch; the teacher and learner
|
|
||||||
/// phases evaluate sequentially because each accept/reject step
|
|
||||||
/// depends on the previous one).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 2,
|
|
||||||
"Tlbo population_size must be >= 2"
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"Tlbo requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut decisions: Vec<Vec<f64>> = {
|
|
||||||
use crate::traits::Initializer as _;
|
|
||||||
self.bounds.initialize(n, &mut rng)
|
|
||||||
};
|
|
||||||
let initial = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
|
||||||
let mut evals: Vec<Evaluation> = initial.iter().map(|c| c.evaluation.clone()).collect();
|
|
||||||
let mut evaluations = initial.len();
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let teacher_idx = best_index(&evals, direction);
|
|
||||||
let teacher = decisions[teacher_idx].clone();
|
|
||||||
let mut mean = vec![0.0_f64; dim];
|
|
||||||
for d in &decisions {
|
|
||||||
for j in 0..dim {
|
|
||||||
mean[j] += d[j];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for v in mean.iter_mut() {
|
|
||||||
*v /= n as f64;
|
|
||||||
}
|
|
||||||
let tf = if rng.random_bool(0.5) { 1.0 } else { 2.0 };
|
|
||||||
|
|
||||||
for i in 0..n {
|
|
||||||
let mut candidate = decisions[i].clone();
|
|
||||||
for j in 0..dim {
|
|
||||||
let r: f64 = rng.random();
|
|
||||||
candidate[j] += r * (teacher[j] - tf * mean[j]);
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
candidate[j] = candidate[j].clamp(lo, hi);
|
|
||||||
}
|
|
||||||
let cand_eval = problem.evaluate_async(&candidate).await;
|
|
||||||
evaluations += 1;
|
|
||||||
if better(&cand_eval, &evals[i], direction) {
|
|
||||||
decisions[i] = candidate;
|
|
||||||
evals[i] = cand_eval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i in 0..n {
|
|
||||||
let mut k = rng.random_range(0..n);
|
|
||||||
while k == i && n > 1 {
|
|
||||||
k = rng.random_range(0..n);
|
|
||||||
}
|
|
||||||
let partner_better = better(&evals[k], &evals[i], direction);
|
|
||||||
let mut candidate = decisions[i].clone();
|
|
||||||
for j in 0..dim {
|
|
||||||
let r: f64 = rng.random();
|
|
||||||
let delta = if partner_better {
|
|
||||||
r * (decisions[k][j] - decisions[i][j])
|
|
||||||
} else {
|
|
||||||
r * (decisions[i][j] - decisions[k][j])
|
|
||||||
};
|
|
||||||
candidate[j] += delta;
|
|
||||||
let (lo, hi) = self.bounds.bounds[j];
|
|
||||||
candidate[j] = candidate[j].clamp(lo, hi);
|
|
||||||
}
|
|
||||||
let cand_eval = problem.evaluate_async(&candidate).await;
|
|
||||||
evaluations += 1;
|
|
||||||
if better(&cand_eval, &evals[i], direction) {
|
|
||||||
decisions[i] = candidate;
|
|
||||||
evals[i] = cand_eval;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
|
||||||
.into_iter()
|
|
||||||
.zip(evals)
|
|
||||||
.map(|(d, e)| Candidate::new(d, e))
|
|
||||||
.collect();
|
|
||||||
let best = best_candidate(&final_pop, &objectives);
|
|
||||||
let front: Vec<Candidate<Vec<f64>>> = best.iter().cloned().collect();
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn best_index(evals: &[Evaluation], direction: Direction) -> usize {
|
fn best_index(evals: &[Evaluation], direction: Direction) -> usize {
|
||||||
let mut idx = 0;
|
let mut idx = 0;
|
||||||
for i in 1..evals.len() {
|
for i in 1..evals.len() {
|
||||||
@@ -325,18 +209,6 @@ fn better(a: &Evaluation, b: &Evaluation, direction: Direction) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for Tlbo {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"TLBO"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Teaching-Learning-Based Optimization"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -383,40 +255,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert!(better(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
assert!(!better(&infeasible, &feasible, Direction::Minimize));
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(better(&hi, &lo, Direction::Maximize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better(&lo, &eq, Direction::Minimize));
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert!(better(&v_lo, &v_hi, Direction::Minimize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn best_index_finds_min_and_max() {
|
|
||||||
let evals = [
|
|
||||||
Evaluation::new(vec![3.0]),
|
|
||||||
Evaluation::new(vec![1.0]),
|
|
||||||
Evaluation::new(vec![4.0]),
|
|
||||||
];
|
|
||||||
assert_eq!(best_index(&evals, Direction::Minimize), 1);
|
|
||||||
assert_eq!(best_index(&evals, Direction::Maximize), 2);
|
|
||||||
// tie keeps the first.
|
|
||||||
let flat = [Evaluation::new(vec![1.0]), Evaluation::new(vec![1.0])];
|
|
||||||
assert_eq!(best_index(&flat, Direction::Minimize), 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-190
@@ -143,19 +143,31 @@ where
|
|||||||
// Split into good vs bad observations.
|
// Split into good vs bad observations.
|
||||||
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
|
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
|
||||||
|
|
||||||
// The good / bad supports are fixed for this iteration, so their
|
|
||||||
// Scott's-rule bandwidths are too — derive them once instead of
|
|
||||||
// recomputing inside every sample / density call.
|
|
||||||
let good_bw = scott_bandwidths(&decisions, &good_idx, self.config.bandwidth_factor);
|
|
||||||
let bad_bw = scott_bandwidths(&decisions, &bad_idx, self.config.bandwidth_factor);
|
|
||||||
|
|
||||||
// Sample candidates from the good KDE.
|
// Sample candidates from the good KDE.
|
||||||
let mut best_x: Option<Vec<f64>> = None;
|
let mut best_x: Option<Vec<f64>> = None;
|
||||||
let mut best_ratio = f64::NEG_INFINITY;
|
let mut best_ratio = f64::NEG_INFINITY;
|
||||||
for _ in 0..self.config.candidate_samples {
|
for _ in 0..self.config.candidate_samples {
|
||||||
let cand = sample_from_kde(&decisions, &good_idx, &self.bounds, &good_bw, &mut rng);
|
let cand = sample_from_kde(
|
||||||
let l = log_kde_density(&cand, &decisions, &good_idx, &self.bounds, &good_bw);
|
&decisions,
|
||||||
let g = log_kde_density(&cand, &decisions, &bad_idx, &self.bounds, &bad_bw);
|
&good_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
&mut rng,
|
||||||
|
);
|
||||||
|
let l = log_kde_density(
|
||||||
|
&cand,
|
||||||
|
&decisions,
|
||||||
|
&good_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
);
|
||||||
|
let g = log_kde_density(
|
||||||
|
&cand,
|
||||||
|
&decisions,
|
||||||
|
&bad_idx,
|
||||||
|
&self.bounds,
|
||||||
|
self.config.bandwidth_factor,
|
||||||
|
);
|
||||||
let ratio = l - g;
|
let ratio = l - g;
|
||||||
if ratio > best_ratio {
|
if ratio > best_ratio {
|
||||||
best_ratio = ratio;
|
best_ratio = ratio;
|
||||||
@@ -259,13 +271,14 @@ fn sample_from_kde(
|
|||||||
decisions: &[Vec<f64>],
|
decisions: &[Vec<f64>],
|
||||||
support: &[usize],
|
support: &[usize],
|
||||||
bounds: &RealBounds,
|
bounds: &RealBounds,
|
||||||
bandwidths: &[f64],
|
bandwidth_factor: f64,
|
||||||
rng: &mut Rng,
|
rng: &mut Rng,
|
||||||
) -> Vec<f64> {
|
) -> Vec<f64> {
|
||||||
if support.is_empty() {
|
if support.is_empty() {
|
||||||
return sample_uniform_in_bounds(bounds, rng);
|
return sample_uniform_in_bounds(bounds, rng);
|
||||||
}
|
}
|
||||||
let dim = bounds.bounds.len();
|
let dim = bounds.bounds.len();
|
||||||
|
let bandwidths = scott_bandwidths(decisions, support, bandwidth_factor);
|
||||||
|
|
||||||
let pick = support[rng.random_range(0..support.len())];
|
let pick = support[rng.random_range(0..support.len())];
|
||||||
let center = &decisions[pick];
|
let center = &decisions[pick];
|
||||||
@@ -279,20 +292,19 @@ fn sample_from_kde(
|
|||||||
x
|
x
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-axis log-density at `x` of the KDE built on `support`, given the
|
/// Per-axis log-density at `x` of the KDE built on `support`.
|
||||||
/// precomputed per-axis `bandwidths`.
|
|
||||||
fn log_kde_density(
|
fn log_kde_density(
|
||||||
x: &[f64],
|
x: &[f64],
|
||||||
decisions: &[Vec<f64>],
|
decisions: &[Vec<f64>],
|
||||||
support: &[usize],
|
support: &[usize],
|
||||||
bounds: &RealBounds,
|
bounds: &RealBounds,
|
||||||
bandwidths: &[f64],
|
bandwidth_factor: f64,
|
||||||
) -> f64 {
|
) -> f64 {
|
||||||
if support.is_empty() {
|
if support.is_empty() {
|
||||||
return f64::NEG_INFINITY;
|
return f64::NEG_INFINITY;
|
||||||
}
|
}
|
||||||
let dim = bounds.bounds.len();
|
let dim = bounds.bounds.len();
|
||||||
let sqrt_2pi = (2.0 * std::f64::consts::PI).sqrt();
|
let bandwidths = scott_bandwidths(decisions, support, bandwidth_factor);
|
||||||
|
|
||||||
// Sum of per-axis log-densities, with the kernel a product of 1-D
|
// Sum of per-axis log-densities, with the kernel a product of 1-D
|
||||||
// Gaussians. Using log-sum-exp for numerical stability would be more
|
// Gaussians. Using log-sum-exp for numerical stability would be more
|
||||||
@@ -302,11 +314,10 @@ fn log_kde_density(
|
|||||||
let mut total = 0.0;
|
let mut total = 0.0;
|
||||||
for j in 0..dim {
|
for j in 0..dim {
|
||||||
let h = bandwidths[j].max(1e-12);
|
let h = bandwidths[j].max(1e-12);
|
||||||
let norm = h * sqrt_2pi;
|
|
||||||
let mut s = 0.0;
|
let mut s = 0.0;
|
||||||
for &i in support {
|
for &i in support {
|
||||||
let z = (x[j] - decisions[i][j]) / h;
|
let z = (x[j] - decisions[i][j]) / h;
|
||||||
s += (-0.5 * z * z).exp() / norm;
|
s += (-0.5 * z * z).exp() / (h * (2.0 * std::f64::consts::PI).sqrt());
|
||||||
}
|
}
|
||||||
let mean_density = s / support.len() as f64;
|
let mean_density = s / support.len() as f64;
|
||||||
total += mean_density.max(1e-300).ln();
|
total += mean_density.max(1e-300).ln();
|
||||||
@@ -345,128 +356,6 @@ fn scott_bandwidths(decisions: &[Vec<f64>], support: &[usize], factor: f64) -> V
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl Tpe {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations during the initial
|
|
||||||
/// uniform-sample design; the sequential TPE loop runs one
|
|
||||||
/// evaluation per iteration regardless.
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<f64>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<f64>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.initial_samples >= 2,
|
|
||||||
"Tpe initial_samples must be >= 2"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.good_fraction > 0.0 && self.config.good_fraction < 1.0,
|
|
||||||
"Tpe good_fraction must be in (0, 1)",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.candidate_samples >= 1,
|
|
||||||
"Tpe candidate_samples must be >= 1",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.bandwidth_factor > 0.0,
|
|
||||||
"Tpe bandwidth_factor must be > 0"
|
|
||||||
);
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"Tpe requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let dim = self.bounds.bounds.len();
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut decisions: Vec<Vec<f64>> = Vec::new();
|
|
||||||
let mut targets: Vec<f64> = Vec::new();
|
|
||||||
let mut evals: Vec<Evaluation> = Vec::new();
|
|
||||||
|
|
||||||
let initial_decisions: Vec<Vec<f64>> = (0..self.config.initial_samples)
|
|
||||||
.map(|_| sample_uniform_in_bounds(&self.bounds, &mut rng))
|
|
||||||
.collect();
|
|
||||||
let initial_cands = evaluate_batch_async(problem, initial_decisions, concurrency).await;
|
|
||||||
for c in initial_cands {
|
|
||||||
targets.push(oriented_target(&c.evaluation, direction));
|
|
||||||
decisions.push(c.decision);
|
|
||||||
evals.push(c.evaluation);
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..self.config.iterations {
|
|
||||||
let (good_idx, bad_idx) = split_good_bad(&targets, self.config.good_fraction);
|
|
||||||
|
|
||||||
// Bandwidths depend only on the (fixed-for-this-iteration)
|
|
||||||
// supports — compute once, not once per sample / density call.
|
|
||||||
let good_bw = scott_bandwidths(&decisions, &good_idx, self.config.bandwidth_factor);
|
|
||||||
let bad_bw = scott_bandwidths(&decisions, &bad_idx, self.config.bandwidth_factor);
|
|
||||||
|
|
||||||
let mut best_x: Option<Vec<f64>> = None;
|
|
||||||
let mut best_ratio = f64::NEG_INFINITY;
|
|
||||||
for _ in 0..self.config.candidate_samples {
|
|
||||||
let cand = sample_from_kde(&decisions, &good_idx, &self.bounds, &good_bw, &mut rng);
|
|
||||||
let l = log_kde_density(&cand, &decisions, &good_idx, &self.bounds, &good_bw);
|
|
||||||
let g = log_kde_density(&cand, &decisions, &bad_idx, &self.bounds, &bad_bw);
|
|
||||||
let ratio = l - g;
|
|
||||||
if ratio > best_ratio {
|
|
||||||
best_ratio = ratio;
|
|
||||||
best_x = Some(cand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let x = best_x.expect("at least one candidate sampled");
|
|
||||||
let _ = dim;
|
|
||||||
let e = problem.evaluate_async(&x).await;
|
|
||||||
targets.push(oriented_target(&e, direction));
|
|
||||||
decisions.push(x);
|
|
||||||
evals.push(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut best_idx = 0;
|
|
||||||
for i in 1..evals.len() {
|
|
||||||
if better(&evals[i], &evals[best_idx], direction) {
|
|
||||||
best_idx = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let total_evals = evals.len();
|
|
||||||
let final_pop: Vec<Candidate<Vec<f64>>> = decisions
|
|
||||||
.into_iter()
|
|
||||||
.zip(evals)
|
|
||||||
.map(|(d, e)| Candidate::new(d, e))
|
|
||||||
.collect();
|
|
||||||
let best = final_pop[best_idx].clone();
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
Some(best),
|
|
||||||
total_evals,
|
|
||||||
self.config.iterations + self.config.initial_samples,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for Tpe {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"TPE"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Tree-structured Parzen Estimator"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -528,56 +417,4 @@ mod tests {
|
|||||||
let mut opt = make_optimizer(0);
|
let mut opt = make_optimizer(0);
|
||||||
let _ = opt.run(&SchafferN1);
|
let _ = opt.run(&SchafferN1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::Direction;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn oriented_target_flips_sign_and_penalizes() {
|
|
||||||
let e = Evaluation::new(vec![3.0]);
|
|
||||||
assert!((oriented_target(&e, Direction::Minimize) - 3.0).abs() < 1e-12);
|
|
||||||
assert!((oriented_target(&e, Direction::Maximize) + 3.0).abs() < 1e-12);
|
|
||||||
let mut bad = Evaluation::new(vec![1.0]);
|
|
||||||
bad.constraint_violation = 0.5;
|
|
||||||
assert!((oriented_target(&bad, Direction::Minimize) - 500_001.0).abs() < 1e-9);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert!(better(&feasible, &infeasible, Direction::Minimize));
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(better(&hi, &lo, Direction::Maximize));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_good_bad_partitions_by_target_rank() {
|
|
||||||
// targets 5, 1, 3, 9, 7 → ranked 1<3<5<7<9 → indices 1,2,0,4,3.
|
|
||||||
let targets = [5.0, 1.0, 3.0, 9.0, 7.0];
|
|
||||||
let (good, bad) = split_good_bad(&targets, 0.4);
|
|
||||||
// 40% of 5 = 2 good.
|
|
||||||
assert_eq!(good.len(), 2);
|
|
||||||
assert_eq!(bad.len(), 3);
|
|
||||||
// The two smallest targets (1.0 at idx 1, 3.0 at idx 2) are "good".
|
|
||||||
assert!(good.contains(&1));
|
|
||||||
assert!(good.contains(&2));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn split_good_bad_clamps_to_at_least_one_each() {
|
|
||||||
let targets = [5.0, 1.0, 3.0];
|
|
||||||
// good_fraction 0.0 would round to 0 — must clamp to >= 1.
|
|
||||||
let (good, bad) = split_good_bad(&targets, 0.0);
|
|
||||||
assert!(!good.is_empty());
|
|
||||||
assert!(!bad.is_empty());
|
|
||||||
// good_fraction 1.0 would take everything — must leave >= 1 bad.
|
|
||||||
let (good2, bad2) = split_good_bad(&targets, 1.0);
|
|
||||||
assert!(!good2.is_empty());
|
|
||||||
assert!(!bad2.is_empty());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,125 +202,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "async")]
|
|
||||||
impl Umda {
|
|
||||||
/// Async version of [`Optimizer::run`] — drives evaluations through
|
|
||||||
/// the user-chosen async runtime. Available only with the `async`
|
|
||||||
/// feature.
|
|
||||||
///
|
|
||||||
/// `concurrency` bounds in-flight evaluations per batch (initial
|
|
||||||
/// population and per-generation samples).
|
|
||||||
pub async fn run_async<P>(
|
|
||||||
&mut self,
|
|
||||||
problem: &P,
|
|
||||||
concurrency: usize,
|
|
||||||
) -> OptimizationResult<Vec<bool>>
|
|
||||||
where
|
|
||||||
P: crate::core::async_problem::AsyncProblem<Decision = Vec<bool>>,
|
|
||||||
{
|
|
||||||
use crate::algorithms::parallel_eval_async::evaluate_batch_async;
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
self.config.population_size >= 2,
|
|
||||||
"Umda population_size must be >= 2"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.selected_size >= 1,
|
|
||||||
"Umda selected_size must be >= 1",
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
self.config.selected_size <= self.config.population_size,
|
|
||||||
"Umda selected_size must be <= population_size",
|
|
||||||
);
|
|
||||||
assert!(self.config.bits >= 1, "Umda bits must be >= 1");
|
|
||||||
let objectives = problem.objectives();
|
|
||||||
assert!(
|
|
||||||
objectives.is_single_objective(),
|
|
||||||
"Umda requires exactly one objective",
|
|
||||||
);
|
|
||||||
let direction = objectives.objectives[0].direction;
|
|
||||||
let n = self.config.population_size;
|
|
||||||
let bits = self.config.bits;
|
|
||||||
let mu = self.config.selected_size;
|
|
||||||
let mut rng = rng_from_seed(self.config.seed);
|
|
||||||
|
|
||||||
let mut decisions: Vec<Vec<bool>> = (0..n)
|
|
||||||
.map(|_| (0..bits).map(|_| rng.random_bool(0.5)).collect())
|
|
||||||
.collect();
|
|
||||||
let mut population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
|
||||||
let mut evaluations = population.len();
|
|
||||||
|
|
||||||
let smoothing = 1.0 / (2.0 * mu as f64);
|
|
||||||
let prob_min = smoothing;
|
|
||||||
let prob_max = 1.0 - smoothing;
|
|
||||||
|
|
||||||
let mut best_seen: Option<Candidate<Vec<bool>>> = None;
|
|
||||||
for c in &population {
|
|
||||||
let beats = match &best_seen {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats {
|
|
||||||
best_seen = Some(c.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _ in 0..self.config.generations {
|
|
||||||
let mut order: Vec<usize> = (0..population.len()).collect();
|
|
||||||
order.sort_by(|&a, &b| {
|
|
||||||
compare_so(
|
|
||||||
&population[a].evaluation,
|
|
||||||
&population[b].evaluation,
|
|
||||||
direction,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let selected: Vec<&Candidate<Vec<bool>>> =
|
|
||||||
order.iter().take(mu).map(|&i| &population[i]).collect();
|
|
||||||
|
|
||||||
let mut probs = vec![0.0_f64; bits];
|
|
||||||
for c in &selected {
|
|
||||||
for (i, b) in c.decision.iter().enumerate() {
|
|
||||||
if *b {
|
|
||||||
probs[i] += 1.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for p in probs.iter_mut() {
|
|
||||||
*p = (*p / mu as f64).clamp(prob_min, prob_max);
|
|
||||||
}
|
|
||||||
|
|
||||||
decisions = (0..n)
|
|
||||||
.map(|_| probs.iter().map(|&p| rng.random_bool(p)).collect())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
population = evaluate_batch_async(problem, decisions.clone(), concurrency).await;
|
|
||||||
evaluations += population.len();
|
|
||||||
|
|
||||||
for c in &population {
|
|
||||||
let beats = match &best_seen {
|
|
||||||
None => true,
|
|
||||||
Some(b) => better_than_so(&c.evaluation, &b.evaluation, direction),
|
|
||||||
};
|
|
||||||
if beats {
|
|
||||||
best_seen = Some(c.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let best = best_seen.expect("at least one generation evaluated");
|
|
||||||
let final_pop = vec![best.clone()];
|
|
||||||
let front = vec![best.clone()];
|
|
||||||
let best_opt = best_candidate(&final_pop, &objectives);
|
|
||||||
OptimizationResult::new(
|
|
||||||
Population::new(final_pop),
|
|
||||||
front,
|
|
||||||
best_opt,
|
|
||||||
evaluations,
|
|
||||||
self.config.generations,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compare_so(
|
fn compare_so(
|
||||||
a: &crate::core::evaluation::Evaluation,
|
a: &crate::core::evaluation::Evaluation,
|
||||||
b: &crate::core::evaluation::Evaluation,
|
b: &crate::core::evaluation::Evaluation,
|
||||||
@@ -352,18 +233,6 @@ fn better_than_so(
|
|||||||
compare_so(a, b, direction) == std::cmp::Ordering::Less
|
compare_so(a, b, direction) == std::cmp::Ordering::Less
|
||||||
}
|
}
|
||||||
|
|
||||||
impl crate::traits::AlgorithmInfo for Umda {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"UMDA"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Univariate Marginal Distribution Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(self.config.seed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -449,42 +318,4 @@ mod tests {
|
|||||||
});
|
});
|
||||||
let _ = opt.run(&DummyMo);
|
let _ = opt.run(&DummyMo);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compare_so_feasibility_first_and_direction() {
|
|
||||||
let feasible = Evaluation::new(vec![100.0]);
|
|
||||||
let infeasible = Evaluation::constrained(vec![0.0], 1.0);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&feasible, &infeasible, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&lo, &hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&lo, &hi, Direction::Maximize),
|
|
||||||
std::cmp::Ordering::Greater
|
|
||||||
);
|
|
||||||
let v_lo = Evaluation::constrained(vec![0.0], 0.2);
|
|
||||||
let v_hi = Evaluation::constrained(vec![0.0], 0.8);
|
|
||||||
assert_eq!(
|
|
||||||
compare_so(&v_lo, &v_hi, Direction::Minimize),
|
|
||||||
std::cmp::Ordering::Less
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn better_than_so_is_strict_less() {
|
|
||||||
let lo = Evaluation::new(vec![1.0]);
|
|
||||||
let hi = Evaluation::new(vec![2.0]);
|
|
||||||
assert!(better_than_so(&lo, &hi, Direction::Minimize));
|
|
||||||
assert!(!better_than_so(&hi, &lo, Direction::Minimize));
|
|
||||||
let eq = Evaluation::new(vec![1.0]);
|
|
||||||
assert!(!better_than_so(&lo, &eq, Direction::Minimize));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
//! Async-evaluable problems for IO-bound workloads.
|
|
||||||
//!
|
|
||||||
//! Most heuropt algorithms operate synchronously: their `Problem::evaluate`
|
|
||||||
//! returns immediately. For workloads where evaluation is *IO-bound* — calling
|
|
||||||
//! an HTTP service, querying a remote model, spawning a subprocess —
|
|
||||||
//! awaiting an async fn is much more efficient than blocking a worker
|
|
||||||
//! thread.
|
|
||||||
//!
|
|
||||||
//! [`AsyncProblem`] mirrors [`Problem`](crate::core::Problem) but its
|
|
||||||
//! `evaluate_async` returns a future. Every algorithm in heuropt exposes
|
|
||||||
//! a `run_async` method that drives evaluations through a user-chosen
|
|
||||||
//! async runtime (typically tokio). Hyperband uses
|
|
||||||
//! [`AsyncPartialProblem`] instead, which mirrors
|
|
||||||
//! [`PartialProblem`](crate::core::partial_problem::PartialProblem) for
|
|
||||||
//! multi-fidelity workloads.
|
|
||||||
//!
|
|
||||||
//! Available only with the `async` feature.
|
|
||||||
|
|
||||||
use std::future::Future;
|
|
||||||
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::ObjectiveSpace;
|
|
||||||
|
|
||||||
/// A problem whose evaluation is async — useful when `evaluate` does
|
|
||||||
/// IO (HTTP, RPC, subprocess) rather than pure CPU work.
|
|
||||||
///
|
|
||||||
/// Mirrors [`Problem`](crate::core::Problem) one-for-one except that
|
|
||||||
/// `evaluate_async` returns a future. The returned future must be
|
|
||||||
/// `Send` so the algorithm can run many evaluations concurrently
|
|
||||||
/// across a runtime's worker pool.
|
|
||||||
///
|
|
||||||
/// Implementors who already have a synchronous `Problem` can adapt
|
|
||||||
/// to `AsyncProblem` with a one-line wrapper:
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// impl AsyncProblem for MyProblem {
|
|
||||||
/// type Decision = <Self as Problem>::Decision;
|
|
||||||
/// fn objectives(&self) -> ObjectiveSpace { Problem::objectives(self) }
|
|
||||||
/// async fn evaluate_async(&self, x: &Self::Decision) -> Evaluation {
|
|
||||||
/// Problem::evaluate(self, x)
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
pub trait AsyncProblem: Sync {
|
|
||||||
/// The thing the optimizer changes. Same constraints as
|
|
||||||
/// [`Problem::Decision`](crate::core::Problem::Decision).
|
|
||||||
type Decision: Clone + Send + Sync;
|
|
||||||
|
|
||||||
/// Return the objectives for this problem.
|
|
||||||
fn objectives(&self) -> ObjectiveSpace;
|
|
||||||
|
|
||||||
/// Evaluate `decision` asynchronously. The returned future is
|
|
||||||
/// driven by whichever runtime the algorithm's `run_async` is
|
|
||||||
/// invoked from.
|
|
||||||
fn evaluate_async(&self, decision: &Self::Decision) -> impl Future<Output = Evaluation> + Send;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Async equivalent of [`PartialProblem`](crate::core::partial_problem::PartialProblem)
|
|
||||||
/// for multi-fidelity workloads — used by Hyperband's `run_async`.
|
|
||||||
///
|
|
||||||
/// Like [`AsyncProblem`], `evaluate_at_budget_async` returns a future
|
|
||||||
/// so callers can fan out budgeted evaluations across an async runtime.
|
|
||||||
pub trait AsyncPartialProblem: Sync {
|
|
||||||
/// The thing the optimizer changes. Same constraints as
|
|
||||||
/// [`PartialProblem::Decision`](crate::core::partial_problem::PartialProblem::Decision).
|
|
||||||
type Decision: Clone + Send + Sync;
|
|
||||||
|
|
||||||
/// Return the objectives for this problem.
|
|
||||||
fn objectives(&self) -> ObjectiveSpace;
|
|
||||||
|
|
||||||
/// Evaluate `decision` at the given fidelity `budget` asynchronously.
|
|
||||||
///
|
|
||||||
/// Same monotonicity contract as
|
|
||||||
/// [`PartialProblem::evaluate_at_budget`](crate::core::partial_problem::PartialProblem::evaluate_at_budget):
|
|
||||||
/// higher budget should give a more accurate estimate.
|
|
||||||
fn evaluate_at_budget_async(
|
|
||||||
&self,
|
|
||||||
decision: &Self::Decision,
|
|
||||||
budget: f64,
|
|
||||||
) -> impl Future<Output = Evaluation> + Send;
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
//! Optional schema describing a decision variable — name, label, unit,
|
|
||||||
//! and bounds. Returned by [`Problem::decision_schema`](super::Problem::decision_schema)
|
|
||||||
//! and consumed by the explorer JSON export so that the webapp can
|
|
||||||
//! render decision-variable axes with the user's preferred labels and
|
|
||||||
//! units.
|
|
||||||
//!
|
|
||||||
//! The `Problem` trait's default `decision_schema()` returns an empty
|
|
||||||
//! `Vec`, in which case the exporter generates fallback names like
|
|
||||||
//! `x[0]`, `x[1]`. Override `decision_schema()` to provide pretty
|
|
||||||
//! names, units, and bounds.
|
|
||||||
|
|
||||||
#[cfg(feature = "serde")]
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
/// Schema for one decision variable. All fields except `name` are
|
|
||||||
/// optional; the explorer falls back to sensible defaults when
|
|
||||||
/// they're absent.
|
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct DecisionVariable {
|
|
||||||
/// Canonical short identifier (e.g. `"displacement"`).
|
|
||||||
pub name: String,
|
|
||||||
/// Human-readable display label (e.g. `"Engine size"`).
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub label: Option<String>,
|
|
||||||
/// Display unit (e.g. `"L"`, `"kg"`, `"Cd"`).
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub unit: Option<String>,
|
|
||||||
/// Lower bound, if known.
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub min: Option<f64>,
|
|
||||||
/// Upper bound, if known.
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub max: Option<f64>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DecisionVariable {
|
|
||||||
/// Construct a `DecisionVariable` with just a name.
|
|
||||||
pub fn new(name: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.into(),
|
|
||||||
label: None,
|
|
||||||
unit: None,
|
|
||||||
min: None,
|
|
||||||
max: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a human-readable display label. Builder-style.
|
|
||||||
pub fn with_label(mut self, label: impl Into<String>) -> Self {
|
|
||||||
self.label = Some(label.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a display unit string. Builder-style.
|
|
||||||
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
|
|
||||||
self.unit = Some(unit.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach lower / upper bounds. Builder-style.
|
|
||||||
pub fn with_bounds(mut self, min: f64, max: f64) -> Self {
|
|
||||||
self.min = Some(min);
|
|
||||||
self.max = Some(max);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn new_starts_with_only_name() {
|
|
||||||
let v = DecisionVariable::new("displacement");
|
|
||||||
assert_eq!(v.name, "displacement");
|
|
||||||
assert!(v.label.is_none());
|
|
||||||
assert!(v.unit.is_none());
|
|
||||||
assert!(v.min.is_none());
|
|
||||||
assert!(v.max.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn builder_methods_chain() {
|
|
||||||
let v = DecisionVariable::new("displacement")
|
|
||||||
.with_label("Engine size")
|
|
||||||
.with_unit("L")
|
|
||||||
.with_bounds(1.0, 6.0);
|
|
||||||
assert_eq!(v.label.as_deref(), Some("Engine size"));
|
|
||||||
assert_eq!(v.unit.as_deref(), Some("L"));
|
|
||||||
assert_eq!(v.min, Some(1.0));
|
|
||||||
assert_eq!(v.max, Some(6.0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
//! 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 decision_variable;
|
|
||||||
pub mod evaluation;
|
pub mod evaluation;
|
||||||
pub mod objective;
|
pub mod objective;
|
||||||
pub mod partial_problem;
|
pub mod partial_problem;
|
||||||
@@ -12,10 +9,7 @@ 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 decision_variable::*;
|
|
||||||
pub use evaluation::*;
|
pub use evaluation::*;
|
||||||
pub use objective::*;
|
pub use objective::*;
|
||||||
pub use partial_problem::*;
|
pub use partial_problem::*;
|
||||||
|
|||||||
+1
-55
@@ -14,32 +14,13 @@ pub enum Direction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A named objective and its optimization direction.
|
/// A named objective and its optimization direction.
|
||||||
///
|
|
||||||
/// `name` is the canonical short identifier (used as a key). The
|
|
||||||
/// optional `label` is a human-readable display name (e.g. "Price"
|
|
||||||
/// vs the technical name `"price_thousand_dollars"`). The optional
|
|
||||||
/// `unit` is a display unit string (e.g. `"$k"`, `"s"`, `"dB"`).
|
|
||||||
/// Both flow through to the explorer JSON export so the webapp can
|
|
||||||
/// render axes with the user's preferred labels and units.
|
|
||||||
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Objective {
|
pub struct Objective {
|
||||||
/// Canonical short identifier, used as a key.
|
/// Human-readable name of the objective.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Whether to minimize or maximize.
|
/// Whether to minimize or maximize.
|
||||||
pub direction: Direction,
|
pub direction: Direction,
|
||||||
/// Human-readable display name (defaults to `name` if not set).
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub label: Option<String>,
|
|
||||||
/// Display unit, e.g. `"$k"`, `"s"`, `"dB"`.
|
|
||||||
#[cfg_attr(
|
|
||||||
feature = "serde",
|
|
||||||
serde(default, skip_serializing_if = "Option::is_none")
|
|
||||||
)]
|
|
||||||
pub unit: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Objective {
|
impl Objective {
|
||||||
@@ -48,8 +29,6 @@ impl Objective {
|
|||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
direction: Direction::Minimize,
|
direction: Direction::Minimize,
|
||||||
label: None,
|
|
||||||
unit: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,26 +37,8 @@ impl Objective {
|
|||||||
Self {
|
Self {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
direction: Direction::Maximize,
|
direction: Direction::Maximize,
|
||||||
label: None,
|
|
||||||
unit: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attach a human-readable display label.
|
|
||||||
///
|
|
||||||
/// Builder-style; consumes and returns `self`.
|
|
||||||
pub fn with_label(mut self, label: impl Into<String>) -> Self {
|
|
||||||
self.label = Some(label.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a display unit string (e.g. `"$k"`, `"seconds"`, `"dB"`).
|
|
||||||
///
|
|
||||||
/// Builder-style; consumes and returns `self`.
|
|
||||||
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
|
|
||||||
self.unit = Some(unit.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The collection of objectives that define a problem's objective space.
|
/// The collection of objectives that define a problem's objective space.
|
||||||
@@ -153,21 +114,6 @@ mod tests {
|
|||||||
assert_eq!(o.direction, Direction::Maximize);
|
assert_eq!(o.direction, Direction::Maximize);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn label_and_unit_default_to_none_and_round_trip_through_builders() {
|
|
||||||
let o = Objective::minimize("price");
|
|
||||||
assert!(o.label.is_none());
|
|
||||||
assert!(o.unit.is_none());
|
|
||||||
|
|
||||||
let o = Objective::minimize("price")
|
|
||||||
.with_label("Price")
|
|
||||||
.with_unit("$k");
|
|
||||||
assert_eq!(o.label.as_deref(), Some("Price"));
|
|
||||||
assert_eq!(o.unit.as_deref(), Some("$k"));
|
|
||||||
assert_eq!(o.direction, Direction::Minimize);
|
|
||||||
assert_eq!(o.name, "price");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn as_minimization_negates_maximize_only() {
|
fn as_minimization_negates_maximize_only() {
|
||||||
let space = ObjectiveSpace::new(vec![
|
let space = ObjectiveSpace::new(vec![
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! The user-implemented `Problem` trait.
|
//! The user-implemented `Problem` trait.
|
||||||
|
|
||||||
use crate::core::decision_variable::DecisionVariable;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
use crate::core::evaluation::Evaluation;
|
||||||
use crate::core::objective::ObjectiveSpace;
|
use crate::core::objective::ObjectiveSpace;
|
||||||
|
|
||||||
@@ -25,18 +24,4 @@ pub trait Problem {
|
|||||||
|
|
||||||
/// Evaluate a decision. Must not mutate `self`.
|
/// Evaluate a decision. Must not mutate `self`.
|
||||||
fn evaluate(&self, decision: &Self::Decision) -> Evaluation;
|
fn evaluate(&self, decision: &Self::Decision) -> Evaluation;
|
||||||
|
|
||||||
/// Optional schema describing each decision variable — names,
|
|
||||||
/// labels, units, and bounds. Used by the explorer JSON export
|
|
||||||
/// to label decision-variable axes with the user's preferred
|
|
||||||
/// names and units. Default: empty (the exporter generates
|
|
||||||
/// fallback names like `x[0]`, `x[1]`).
|
|
||||||
///
|
|
||||||
/// Override this on your `Problem` impl to provide pretty
|
|
||||||
/// metadata. The returned vector should have one entry per
|
|
||||||
/// element of the decision; if its length doesn't match, the
|
|
||||||
/// exporter fills the remainder with `x[i]` defaults.
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,909 +0,0 @@
|
|||||||
//! Explorer JSON export — serialize an `OptimizationResult` to a
|
|
||||||
//! self-describing JSON file that the
|
|
||||||
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
//! webapp can load and explore interactively.
|
|
||||||
//!
|
|
||||||
//! ## Quick start
|
|
||||||
//!
|
|
||||||
//! ```ignore
|
|
||||||
//! use heuropt::prelude::*;
|
|
||||||
//!
|
|
||||||
//! let result = optimizer.run(&problem);
|
|
||||||
//!
|
|
||||||
//! // Zero-config — pulls metadata from `problem.objectives()`,
|
|
||||||
//! // `problem.decision_schema()`, and the algorithm's `AlgorithmInfo`.
|
|
||||||
//! heuropt::explorer::to_file("results.json", &problem, &optimizer, &result)?;
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! Drop the resulting `results.json` into the explorer at
|
|
||||||
//! <https://swaits.github.io/heuropt-explorer/> to filter, brush,
|
|
||||||
//! pin, and rank candidates.
|
|
||||||
//!
|
|
||||||
//! ## What's in the export
|
|
||||||
//!
|
|
||||||
//! The output contains:
|
|
||||||
//! - `schema_version` — an integer the explorer uses to detect
|
|
||||||
//! incompatible files. Bump on breaking schema changes.
|
|
||||||
//! - `run` — algorithm name, seed, evaluations, generations, and
|
|
||||||
//! optional problem name / wall-clock seconds.
|
|
||||||
//! - `objectives` — name, direction, and (if set) `label` and
|
|
||||||
//! `unit` so the explorer can render axes like `Price ($k)`.
|
|
||||||
//! - `decision_variables` — name, label, unit, and bounds for each
|
|
||||||
//! decision-variable slot. If `Problem::decision_schema()` returns
|
|
||||||
//! fewer entries than the decision length, the exporter pads with
|
|
||||||
//! fallback names like `x[0]`, `x[1]`.
|
|
||||||
//! - `candidates` — the full population, each tagged with its
|
|
||||||
//! front rank (from `non_dominated_sort`), feasibility, and
|
|
||||||
//! whether it sits on the Pareto front.
|
|
||||||
//!
|
|
||||||
//! Everything is gated on the `serde` feature, since the export
|
|
||||||
//! uses `serde_json`.
|
|
||||||
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::Path;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::decision_variable::DecisionVariable;
|
|
||||||
use crate::core::objective::Objective;
|
|
||||||
use crate::core::problem::Problem;
|
|
||||||
use crate::core::result::OptimizationResult;
|
|
||||||
use crate::pareto::sort::non_dominated_sort;
|
|
||||||
use crate::traits::AlgorithmInfo;
|
|
||||||
|
|
||||||
/// JSON schema version embedded in every export. The explorer
|
|
||||||
/// webapp checks this on load and rejects files with an unknown
|
|
||||||
/// version. Bump on breaking schema changes.
|
|
||||||
pub const SCHEMA_VERSION: u32 = 1;
|
|
||||||
|
|
||||||
/// Serialized envelope describing one optimization run.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ExplorerExport {
|
|
||||||
/// Schema version (always equal to [`SCHEMA_VERSION`] when written).
|
|
||||||
pub schema_version: u32,
|
|
||||||
/// Run metadata — algorithm, seed, eval/generation counts.
|
|
||||||
pub run: RunMeta,
|
|
||||||
/// Objective definitions, with optional `label` / `unit` if set.
|
|
||||||
pub objectives: Vec<Objective>,
|
|
||||||
/// Decision-variable schemas, padded with fallback `x[i]` names
|
|
||||||
/// when the user didn't override `Problem::decision_schema()`.
|
|
||||||
pub decision_variables: Vec<DecisionVariable>,
|
|
||||||
/// One row per candidate in the final population.
|
|
||||||
pub candidates: Vec<ExplorerCandidate>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-candidate row in [`ExplorerExport`].
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct ExplorerCandidate {
|
|
||||||
/// Decision values, one entry per decision variable. Numbers,
|
|
||||||
/// booleans, integers, or strings — whatever the
|
|
||||||
/// [`ToDecisionValues`] impl produces for the decision type.
|
|
||||||
pub decision: Vec<serde_json::Value>,
|
|
||||||
/// Objective values, parallel to the `objectives` array.
|
|
||||||
pub objectives: Vec<f64>,
|
|
||||||
/// Constraint violation magnitude (≤ 0 means feasible).
|
|
||||||
pub constraint_violation: f64,
|
|
||||||
/// Convenience: `true` iff `constraint_violation <= 0.0`.
|
|
||||||
pub feasible: bool,
|
|
||||||
/// Non-domination rank from `non_dominated_sort`. `0` means
|
|
||||||
/// on the first front (Pareto front).
|
|
||||||
pub front_rank: usize,
|
|
||||||
/// `true` iff this candidate is on the first front. (Same as
|
|
||||||
/// `front_rank == 0` for the rank-0 set, kept as an explicit
|
|
||||||
/// field so downstream tools don't have to re-derive it.)
|
|
||||||
pub in_pareto_front: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Run-level metadata: algorithm name, seed, eval count, etc.
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct RunMeta {
|
|
||||||
/// Optional human-readable problem name (e.g. `"Pick a car"`).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub problem_name: Option<String>,
|
|
||||||
/// Canonical short algorithm name (e.g. `"NSGA-III"`). Pulled
|
|
||||||
/// from [`AlgorithmInfo::name`] when an algorithm is provided.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub algorithm: Option<String>,
|
|
||||||
/// Academic long form (e.g. `"Non-dominated Sorting Genetic
|
|
||||||
/// Algorithm III"`). Pulled from [`AlgorithmInfo::full_name`]
|
|
||||||
/// when an algorithm is provided. Display tools render this
|
|
||||||
/// as a tooltip / aria-label on the short name.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub algorithm_full_name: Option<String>,
|
|
||||||
/// Seed driving this run, if applicable. Pulled from
|
|
||||||
/// [`AlgorithmInfo::seed`].
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub seed: Option<u64>,
|
|
||||||
/// Wall-clock duration of the run, in seconds. Optional —
|
|
||||||
/// the user provides this if they timed the run externally.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub wall_clock_seconds: Option<f64>,
|
|
||||||
/// Total number of `Problem::evaluate` calls.
|
|
||||||
pub evaluations: usize,
|
|
||||||
/// Number of major optimizer iterations.
|
|
||||||
pub generations: usize,
|
|
||||||
/// Optional ISO-8601 timestamp recorded at export time.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub timestamp: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adapter trait that converts a decision value into a vector of
|
|
||||||
/// `serde_json::Value`s (one per element). Implemented for the
|
|
||||||
/// common decision types out of the box; users with custom
|
|
||||||
/// decision types implement it themselves.
|
|
||||||
pub trait ToDecisionValues {
|
|
||||||
/// Convert the decision into one JSON value per decision-variable
|
|
||||||
/// slot.
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToDecisionValues for Vec<f64> {
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.iter()
|
|
||||||
.map(|v| {
|
|
||||||
serde_json::Number::from_f64(*v)
|
|
||||||
.map(serde_json::Value::Number)
|
|
||||||
.unwrap_or(serde_json::Value::Null)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToDecisionValues for Vec<bool> {
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.iter().map(|b| serde_json::Value::Bool(*b)).collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToDecisionValues for Vec<usize> {
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.iter()
|
|
||||||
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i as u64)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ToDecisionValues for Vec<i64> {
|
|
||||||
fn to_decision_values(&self) -> Vec<serde_json::Value> {
|
|
||||||
self.iter()
|
|
||||||
.map(|i| serde_json::Value::Number(serde_json::Number::from(*i)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ExplorerExport {
|
|
||||||
/// Build an `ExplorerExport` from a problem and its result.
|
|
||||||
/// The run metadata is initially empty (no algorithm / seed);
|
|
||||||
/// chain `with_algorithm_info` or the individual setters to
|
|
||||||
/// populate it.
|
|
||||||
pub fn from_result<P>(problem: &P, result: &OptimizationResult<P::Decision>) -> Self
|
|
||||||
where
|
|
||||||
P: Problem,
|
|
||||||
P::Decision: ToDecisionValues,
|
|
||||||
{
|
|
||||||
let objective_space = problem.objectives();
|
|
||||||
let n_obj = objective_space.objectives.len();
|
|
||||||
|
|
||||||
let user_schema = problem.decision_schema();
|
|
||||||
let decision_arity = result
|
|
||||||
.population
|
|
||||||
.candidates
|
|
||||||
.first()
|
|
||||||
.map(|c| c.decision.to_decision_values().len())
|
|
||||||
.unwrap_or(user_schema.len());
|
|
||||||
let decision_variables = pad_decision_schema(user_schema, decision_arity);
|
|
||||||
|
|
||||||
let pop_slice: &[Candidate<P::Decision>] = &result.population.candidates;
|
|
||||||
let fronts = non_dominated_sort(pop_slice, &objective_space);
|
|
||||||
let mut rank_of: Vec<usize> = vec![0; pop_slice.len()];
|
|
||||||
for (rank, front) in fronts.iter().enumerate() {
|
|
||||||
for &idx in front {
|
|
||||||
rank_of[idx] = rank;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let candidates = pop_slice
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, c)| candidate_to_export(c, rank_of[i], n_obj))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Self {
|
|
||||||
schema_version: SCHEMA_VERSION,
|
|
||||||
run: RunMeta {
|
|
||||||
evaluations: result.evaluations,
|
|
||||||
generations: result.generations,
|
|
||||||
..RunMeta::default()
|
|
||||||
},
|
|
||||||
objectives: objective_space.objectives,
|
|
||||||
decision_variables,
|
|
||||||
candidates,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Populate `algorithm`, `algorithm_full_name`, and `seed`
|
|
||||||
/// from anything implementing [`AlgorithmInfo`] — every
|
|
||||||
/// built-in algorithm does.
|
|
||||||
pub fn with_algorithm_info<A: AlgorithmInfo>(mut self, algorithm: &A) -> Self {
|
|
||||||
self.run.algorithm = Some(algorithm.name().to_owned());
|
|
||||||
self.run.algorithm_full_name = Some(algorithm.full_name().to_owned());
|
|
||||||
self.run.seed = algorithm.seed();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Override the problem name shown in the explorer header.
|
|
||||||
pub fn with_problem_name(mut self, name: impl Into<String>) -> Self {
|
|
||||||
self.run.problem_name = Some(name.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach a wall-clock duration in seconds.
|
|
||||||
pub fn with_wall_clock(mut self, seconds: f64) -> Self {
|
|
||||||
self.run.wall_clock_seconds = Some(seconds);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attach an ISO-8601 timestamp string (the caller formats it).
|
|
||||||
pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
|
|
||||||
self.run.timestamp = Some(timestamp.into());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize to a pretty-printed JSON string.
|
|
||||||
pub fn to_json(&self) -> serde_json::Result<String> {
|
|
||||||
serde_json::to_string_pretty(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Serialize to any `Write` sink as pretty-printed JSON.
|
|
||||||
pub fn to_writer<W: Write>(&self, writer: W) -> serde_json::Result<()> {
|
|
||||||
serde_json::to_writer_pretty(writer, self)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the export to a file as pretty-printed JSON. Creates
|
|
||||||
/// the file (truncating if it exists) and returns any I/O or
|
|
||||||
/// serialization error.
|
|
||||||
pub fn to_file<Q: AsRef<Path>>(&self, path: Q) -> std::io::Result<()> {
|
|
||||||
let file = std::fs::File::create(path)?;
|
|
||||||
let writer = std::io::BufWriter::new(file);
|
|
||||||
self.to_writer(writer)
|
|
||||||
.map_err(|e| std::io::Error::other(e.to_string()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: build an [`ExplorerExport`] from problem +
|
|
||||||
/// algorithm + result, with `algorithm` and `seed` populated from
|
|
||||||
/// the [`AlgorithmInfo`] trait, then serialize to a pretty JSON
|
|
||||||
/// string.
|
|
||||||
pub fn to_json<P, A>(
|
|
||||||
problem: &P,
|
|
||||||
algorithm: &A,
|
|
||||||
result: &OptimizationResult<P::Decision>,
|
|
||||||
) -> serde_json::Result<String>
|
|
||||||
where
|
|
||||||
P: Problem,
|
|
||||||
P::Decision: ToDecisionValues,
|
|
||||||
A: AlgorithmInfo,
|
|
||||||
{
|
|
||||||
ExplorerExport::from_result(problem, result)
|
|
||||||
.with_algorithm_info(algorithm)
|
|
||||||
.to_json()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: same as [`to_json`] but writes to any `Write`.
|
|
||||||
pub fn to_writer<W, P, A>(
|
|
||||||
writer: W,
|
|
||||||
problem: &P,
|
|
||||||
algorithm: &A,
|
|
||||||
result: &OptimizationResult<P::Decision>,
|
|
||||||
) -> serde_json::Result<()>
|
|
||||||
where
|
|
||||||
W: Write,
|
|
||||||
P: Problem,
|
|
||||||
P::Decision: ToDecisionValues,
|
|
||||||
A: AlgorithmInfo,
|
|
||||||
{
|
|
||||||
ExplorerExport::from_result(problem, result)
|
|
||||||
.with_algorithm_info(algorithm)
|
|
||||||
.to_writer(writer)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convenience: same as [`to_json`] but writes directly to a
|
|
||||||
/// file path.
|
|
||||||
pub fn to_file<Q, P, A>(
|
|
||||||
path: Q,
|
|
||||||
problem: &P,
|
|
||||||
algorithm: &A,
|
|
||||||
result: &OptimizationResult<P::Decision>,
|
|
||||||
) -> std::io::Result<()>
|
|
||||||
where
|
|
||||||
Q: AsRef<Path>,
|
|
||||||
P: Problem,
|
|
||||||
P::Decision: ToDecisionValues,
|
|
||||||
A: AlgorithmInfo,
|
|
||||||
{
|
|
||||||
ExplorerExport::from_result(problem, result)
|
|
||||||
.with_algorithm_info(algorithm)
|
|
||||||
.to_file(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn candidate_to_export<D: ToDecisionValues>(
|
|
||||||
c: &Candidate<D>,
|
|
||||||
front_rank: usize,
|
|
||||||
n_obj: usize,
|
|
||||||
) -> ExplorerCandidate {
|
|
||||||
let objectives = if c.evaluation.objectives.len() == n_obj {
|
|
||||||
c.evaluation.objectives.clone()
|
|
||||||
} else {
|
|
||||||
// Defensive: shouldn't happen in practice, but pad/truncate so
|
|
||||||
// the export is well-formed even if a buggy algorithm produced
|
|
||||||
// a mismatched evaluation.
|
|
||||||
let mut v = c.evaluation.objectives.clone();
|
|
||||||
v.resize(n_obj, f64::NAN);
|
|
||||||
v
|
|
||||||
};
|
|
||||||
ExplorerCandidate {
|
|
||||||
decision: c.decision.to_decision_values(),
|
|
||||||
objectives,
|
|
||||||
constraint_violation: c.evaluation.constraint_violation,
|
|
||||||
feasible: c.evaluation.constraint_violation <= 0.0,
|
|
||||||
front_rank,
|
|
||||||
in_pareto_front: front_rank == 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pad_decision_schema(
|
|
||||||
mut schema: Vec<DecisionVariable>,
|
|
||||||
decision_arity: usize,
|
|
||||||
) -> Vec<DecisionVariable> {
|
|
||||||
if schema.len() < decision_arity {
|
|
||||||
let start = schema.len();
|
|
||||||
for i in start..decision_arity {
|
|
||||||
schema.push(DecisionVariable::new(format!("x[{i}]")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
schema
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::core::candidate::Candidate;
|
|
||||||
use crate::core::evaluation::Evaluation;
|
|
||||||
use crate::core::objective::{Direction, Objective, ObjectiveSpace};
|
|
||||||
use crate::core::population::Population;
|
|
||||||
use crate::core::problem::Problem;
|
|
||||||
use crate::core::result::OptimizationResult;
|
|
||||||
|
|
||||||
/// Two-objective minimize problem used for most explorer tests.
|
|
||||||
/// f1 = decision[0], f2 = decision[1] — both minimize, so
|
|
||||||
/// `(a, b)` dominates `(c, d)` iff `a ≤ c && b ≤ d` with at
|
|
||||||
/// least one strict.
|
|
||||||
struct TwoObjMin;
|
|
||||||
impl Problem for TwoObjMin {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![
|
|
||||||
Objective::minimize("a")
|
|
||||||
.with_label("Apples")
|
|
||||||
.with_unit("count"),
|
|
||||||
Objective::maximize("b").with_unit("score"),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![x[0], x[1]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct EnrichedProblem;
|
|
||||||
impl Problem for EnrichedProblem {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("a")])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![x[0]])
|
|
||||||
}
|
|
||||||
fn decision_schema(&self) -> Vec<DecisionVariable> {
|
|
||||||
vec![
|
|
||||||
DecisionVariable::new("alpha")
|
|
||||||
.with_label("Alpha")
|
|
||||||
.with_unit("u")
|
|
||||||
.with_bounds(0.0, 1.0),
|
|
||||||
DecisionVariable::new("beta"),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct DummyAlgo;
|
|
||||||
impl AlgorithmInfo for DummyAlgo {
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"DummyAlgo"
|
|
||||||
}
|
|
||||||
fn full_name(&self) -> &'static str {
|
|
||||||
"Dummy Test Algorithm"
|
|
||||||
}
|
|
||||||
fn seed(&self) -> Option<u64> {
|
|
||||||
Some(123)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a result whose evaluations match `objectives_per_candidate`.
|
|
||||||
/// Each candidate's objective vector is the closure applied to the
|
|
||||||
/// decision.
|
|
||||||
fn make_result(
|
|
||||||
decisions: Vec<Vec<f64>>,
|
|
||||||
eval: impl Fn(&[f64]) -> Vec<f64>,
|
|
||||||
) -> OptimizationResult<Vec<f64>> {
|
|
||||||
let cands: Vec<Candidate<Vec<f64>>> = decisions
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| {
|
|
||||||
let objs = eval(&d);
|
|
||||||
Candidate::new(d, Evaluation::new(objs))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
let n = cands.len();
|
|
||||||
OptimizationResult::new(Population::new(cands.clone()), cands, None, n, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn schema_version_is_one() {
|
|
||||||
assert_eq!(SCHEMA_VERSION, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Single-objective minimize problem (used for tests where the
|
|
||||||
/// problem only declares one objective).
|
|
||||||
struct SingleObjMin;
|
|
||||||
impl Problem for SingleObjMin {
|
|
||||||
type Decision = Vec<f64>;
|
|
||||||
fn objectives(&self) -> ObjectiveSpace {
|
|
||||||
ObjectiveSpace::new(vec![Objective::minimize("f")])
|
|
||||||
}
|
|
||||||
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
|
|
||||||
Evaluation::new(vec![x[0]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn zero_config_export_uses_fallback_decision_names() {
|
|
||||||
let problem = TwoObjMin;
|
|
||||||
// Two objectives — eval just maps decision to objective values.
|
|
||||||
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
|
|
||||||
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result);
|
|
||||||
assert_eq!(export.schema_version, SCHEMA_VERSION);
|
|
||||||
assert_eq!(export.decision_variables.len(), 2);
|
|
||||||
assert_eq!(export.decision_variables[0].name, "x[0]");
|
|
||||||
assert_eq!(export.decision_variables[1].name, "x[1]");
|
|
||||||
assert!(export.decision_variables[0].label.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn objectives_carry_label_and_unit_through_export() {
|
|
||||||
let problem = TwoObjMin;
|
|
||||||
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result);
|
|
||||||
assert_eq!(export.objectives.len(), 2);
|
|
||||||
assert_eq!(export.objectives[0].label.as_deref(), Some("Apples"));
|
|
||||||
assert_eq!(export.objectives[0].unit.as_deref(), Some("count"));
|
|
||||||
assert_eq!(export.objectives[1].direction, Direction::Maximize);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn enriched_decision_schema_passes_through() {
|
|
||||||
let problem = EnrichedProblem; // 1 objective, 2-element decisions
|
|
||||||
let result = make_result(vec![vec![0.5, 0.5]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result);
|
|
||||||
assert_eq!(export.decision_variables.len(), 2);
|
|
||||||
assert_eq!(export.decision_variables[0].name, "alpha");
|
|
||||||
assert_eq!(export.decision_variables[0].label.as_deref(), Some("Alpha"));
|
|
||||||
assert_eq!(export.decision_variables[0].min, Some(0.0));
|
|
||||||
assert_eq!(export.decision_variables[1].name, "beta");
|
|
||||||
assert!(export.decision_variables[1].min.is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn front_rank_zero_for_pareto_front_members() {
|
|
||||||
// Use SingleObjMin (1 objective) to make dominance trivial:
|
|
||||||
// among [3.0, 1.0, 2.0], only 1.0 is non-dominated.
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![3.0], vec![1.0], vec![2.0]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result);
|
|
||||||
// Index 1 (decision = 1.0) is the unique minimum.
|
|
||||||
assert_eq!(export.candidates[1].front_rank, 0);
|
|
||||||
assert!(export.candidates[1].in_pareto_front);
|
|
||||||
assert_eq!(export.candidates[2].front_rank, 1);
|
|
||||||
assert!(!export.candidates[2].in_pareto_front);
|
|
||||||
assert_eq!(export.candidates[0].front_rank, 2);
|
|
||||||
assert!(!export.candidates[0].in_pareto_front);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn algorithm_info_populates_run_meta() {
|
|
||||||
let problem = TwoObjMin;
|
|
||||||
let result = make_result(vec![vec![0.0, 1.0]], |d| d.to_vec());
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result).with_algorithm_info(&DummyAlgo);
|
|
||||||
assert_eq!(export.run.algorithm.as_deref(), Some("DummyAlgo"));
|
|
||||||
assert_eq!(
|
|
||||||
export.run.algorithm_full_name.as_deref(),
|
|
||||||
Some("Dummy Test Algorithm"),
|
|
||||||
);
|
|
||||||
assert_eq!(export.run.seed, Some(123));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn round_trip_serde() {
|
|
||||||
let problem = TwoObjMin;
|
|
||||||
let result = make_result(vec![vec![0.0, 1.0], vec![1.0, 0.0]], |d| d.to_vec());
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result)
|
|
||||||
.with_algorithm_info(&DummyAlgo)
|
|
||||||
.with_problem_name("Toy")
|
|
||||||
.with_wall_clock(0.001);
|
|
||||||
let json = export.to_json().unwrap();
|
|
||||||
let back: ExplorerExport = serde_json::from_str(&json).unwrap();
|
|
||||||
assert_eq!(back.schema_version, SCHEMA_VERSION);
|
|
||||||
assert_eq!(back.run.algorithm.as_deref(), Some("DummyAlgo"));
|
|
||||||
assert_eq!(back.candidates.len(), 2);
|
|
||||||
assert_eq!(back.objectives.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vec_bool_decisions_serialize_as_bool_array() {
|
|
||||||
let v: Vec<bool> = vec![true, false, true];
|
|
||||||
let values = v.to_decision_values();
|
|
||||||
assert_eq!(values.len(), 3);
|
|
||||||
assert_eq!(values[0], serde_json::Value::Bool(true));
|
|
||||||
assert_eq!(values[1], serde_json::Value::Bool(false));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn vec_usize_decisions_serialize_as_int_array() {
|
|
||||||
let v: Vec<usize> = vec![3, 1, 4];
|
|
||||||
let values = v.to_decision_values();
|
|
||||||
assert_eq!(values.len(), 3);
|
|
||||||
assert_eq!(
|
|
||||||
values[0],
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(3u64))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn nan_decision_renders_as_null() {
|
|
||||||
let v: Vec<f64> = vec![1.0, f64::NAN, 2.0];
|
|
||||||
let values = v.to_decision_values();
|
|
||||||
assert_eq!(values[0].as_f64(), Some(1.0));
|
|
||||||
assert_eq!(values[1], serde_json::Value::Null);
|
|
||||||
assert_eq!(values[2].as_f64(), Some(2.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Exhaustive coverage to kill cargo-mutants survivors ---------------
|
|
||||||
|
|
||||||
/// `ToDecisionValues for Vec<f64>` returns a slot-for-slot float-or-null
|
|
||||||
/// vector. Pins the exact JSON output rather than just length, killing
|
|
||||||
/// the "replace body with vec![]" / "vec![Default::default()]" mutants.
|
|
||||||
#[test]
|
|
||||||
fn vec_f64_to_decision_values_exact_output() {
|
|
||||||
let v: Vec<f64> = vec![0.5, -1.25, 2.0];
|
|
||||||
let got = v.to_decision_values();
|
|
||||||
assert_eq!(got.len(), 3);
|
|
||||||
assert_eq!(got[0].as_f64(), Some(0.5));
|
|
||||||
assert_eq!(got[1].as_f64(), Some(-1.25));
|
|
||||||
assert_eq!(got[2].as_f64(), Some(2.0));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pins the exact JSON output for `Vec<i64>`. There was no test for this
|
|
||||||
/// impl at all before.
|
|
||||||
#[test]
|
|
||||||
fn vec_i64_to_decision_values_exact_output() {
|
|
||||||
let v: Vec<i64> = vec![-3, 0, 7];
|
|
||||||
let got = v.to_decision_values();
|
|
||||||
assert_eq!(got.len(), 3);
|
|
||||||
assert_eq!(
|
|
||||||
got[0],
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(-3i64))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
got[1],
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(0i64))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
got[2],
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(7i64))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pins the *exact* booleans, not just the count.
|
|
||||||
#[test]
|
|
||||||
fn vec_bool_to_decision_values_exact_output() {
|
|
||||||
let v: Vec<bool> = vec![true, false, true, false];
|
|
||||||
let got = v.to_decision_values();
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![
|
|
||||||
serde_json::Value::Bool(true),
|
|
||||||
serde_json::Value::Bool(false),
|
|
||||||
serde_json::Value::Bool(true),
|
|
||||||
serde_json::Value::Bool(false),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pins the exact usize-as-u64 numbers, not just the count.
|
|
||||||
#[test]
|
|
||||||
fn vec_usize_to_decision_values_exact_output() {
|
|
||||||
let v: Vec<usize> = vec![0, 5, 42, 7];
|
|
||||||
let got = v.to_decision_values();
|
|
||||||
assert_eq!(
|
|
||||||
got,
|
|
||||||
vec![
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(0u64)),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(5u64)),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(42u64)),
|
|
||||||
serde_json::Value::Number(serde_json::Number::from(7u64)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `from_result` must set the `evaluations` and `generations` fields of
|
|
||||||
/// `RunMeta` from the result, not leave them at default zero. Kills the
|
|
||||||
/// "delete field evaluations / generations" mutants.
|
|
||||||
#[test]
|
|
||||||
fn from_result_propagates_evaluation_and_generation_counts() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let cands = vec![Candidate::new(vec![1.0], Evaluation::new(vec![1.0]))];
|
|
||||||
let result = OptimizationResult::new(Population::new(cands.clone()), cands, None, 137, 9);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result);
|
|
||||||
assert_eq!(export.run.evaluations, 137);
|
|
||||||
assert_eq!(export.run.generations, 9);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `with_problem_name` must set `run.problem_name`, not return a default.
|
|
||||||
#[test]
|
|
||||||
fn with_problem_name_sets_field_and_preserves_other_state() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export =
|
|
||||||
ExplorerExport::from_result(&problem, &result).with_problem_name("Toy Problem");
|
|
||||||
assert_eq!(export.run.problem_name.as_deref(), Some("Toy Problem"));
|
|
||||||
// The candidates and objectives should still be intact, proving the
|
|
||||||
// chained builder isn't replacing the whole struct.
|
|
||||||
assert_eq!(export.candidates.len(), 1);
|
|
||||||
assert_eq!(export.objectives.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `with_wall_clock` must set `run.wall_clock_seconds`.
|
|
||||||
#[test]
|
|
||||||
fn with_wall_clock_sets_field_and_preserves_other_state() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result).with_wall_clock(2.5);
|
|
||||||
assert_eq!(export.run.wall_clock_seconds, Some(2.5));
|
|
||||||
assert_eq!(export.candidates.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `with_timestamp` must set `run.timestamp`.
|
|
||||||
#[test]
|
|
||||||
fn with_timestamp_sets_field_and_preserves_other_state() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export =
|
|
||||||
ExplorerExport::from_result(&problem, &result).with_timestamp("2025-01-01T00:00:00Z");
|
|
||||||
assert_eq!(
|
|
||||||
export.run.timestamp.as_deref(),
|
|
||||||
Some("2025-01-01T00:00:00Z")
|
|
||||||
);
|
|
||||||
assert_eq!(export.candidates.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `to_json` must serialize the full export, not a fixed string. Look for
|
|
||||||
/// specific markers — `schema_version`, `candidates`, the problem name
|
|
||||||
/// — that pin the JSON output enough to kill `Ok(String::new())` and
|
|
||||||
/// `Ok("xyzzy".into())` mutants.
|
|
||||||
#[test]
|
|
||||||
fn to_json_emits_full_export_with_expected_fields() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result)
|
|
||||||
.with_algorithm_info(&DummyAlgo)
|
|
||||||
.with_problem_name("MyProblem");
|
|
||||||
let json = export.to_json().unwrap();
|
|
||||||
assert!(json.contains("\"schema_version\""), "json: {json}");
|
|
||||||
assert!(json.contains("\"candidates\""), "json: {json}");
|
|
||||||
assert!(json.contains("\"MyProblem\""), "json: {json}");
|
|
||||||
assert!(json.contains("\"DummyAlgo\""), "json: {json}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `to_writer` must produce non-empty JSON output matching `to_json`.
|
|
||||||
/// Kills `Ok(())` mutants which would write nothing.
|
|
||||||
#[test]
|
|
||||||
fn to_writer_emits_full_export() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result).with_problem_name("MyProblem");
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
|
||||||
export.to_writer(&mut buf).unwrap();
|
|
||||||
assert!(!buf.is_empty());
|
|
||||||
let json = String::from_utf8(buf).unwrap();
|
|
||||||
assert!(json.contains("\"MyProblem\""));
|
|
||||||
assert_eq!(json, export.to_json().unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `to_file` writes to disk; round-trip the bytes back through serde to
|
|
||||||
/// confirm a real (non-empty, parseable) export landed.
|
|
||||||
#[test]
|
|
||||||
fn to_file_writes_parseable_json() {
|
|
||||||
use std::io::Read;
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let export = ExplorerExport::from_result(&problem, &result).with_problem_name("OnDisk");
|
|
||||||
let dir = std::env::temp_dir();
|
|
||||||
let path = dir.join(format!("heuropt-explorer-test-{}.json", std::process::id()));
|
|
||||||
export.to_file(&path).unwrap();
|
|
||||||
let mut s = String::new();
|
|
||||||
std::fs::File::open(&path)
|
|
||||||
.unwrap()
|
|
||||||
.read_to_string(&mut s)
|
|
||||||
.unwrap();
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let back: ExplorerExport = serde_json::from_str(&s).unwrap();
|
|
||||||
assert_eq!(back.run.problem_name.as_deref(), Some("OnDisk"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Free `to_json` convenience must do the same thing as the chained
|
|
||||||
/// builder. Kills "replace with Ok(String::new())" / "Ok(\"xyzzy\")".
|
|
||||||
#[test]
|
|
||||||
fn free_to_json_includes_algorithm_info() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let json = super::to_json(&problem, &DummyAlgo, &result).unwrap();
|
|
||||||
assert!(json.contains("\"DummyAlgo\""), "json: {json}");
|
|
||||||
assert!(json.contains("\"schema_version\""), "json: {json}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Free `to_writer` convenience writes the same bytes as `to_json`.
|
|
||||||
#[test]
|
|
||||||
fn free_to_writer_writes_bytes() {
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
|
||||||
super::to_writer(&mut buf, &problem, &DummyAlgo, &result).unwrap();
|
|
||||||
let json = String::from_utf8(buf).unwrap();
|
|
||||||
assert!(json.contains("\"DummyAlgo\""));
|
|
||||||
let expected = super::to_json(&problem, &DummyAlgo, &result).unwrap();
|
|
||||||
assert_eq!(json, expected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Free `to_file` convenience round-trips through a tmp file.
|
|
||||||
#[test]
|
|
||||||
fn free_to_file_writes_parseable_json() {
|
|
||||||
use std::io::Read;
|
|
||||||
let problem = SingleObjMin;
|
|
||||||
let result = make_result(vec![vec![1.0]], |d| vec![d[0]]);
|
|
||||||
let dir = std::env::temp_dir();
|
|
||||||
let path = dir.join(format!(
|
|
||||||
"heuropt-explorer-test-free-{}.json",
|
|
||||||
std::process::id()
|
|
||||||
));
|
|
||||||
super::to_file(&path, &problem, &DummyAlgo, &result).unwrap();
|
|
||||||
let mut s = String::new();
|
|
||||||
std::fs::File::open(&path)
|
|
||||||
.unwrap()
|
|
||||||
.read_to_string(&mut s)
|
|
||||||
.unwrap();
|
|
||||||
let _ = std::fs::remove_file(&path);
|
|
||||||
let back: ExplorerExport = serde_json::from_str(&s).unwrap();
|
|
||||||
assert_eq!(back.run.algorithm.as_deref(), Some("DummyAlgo"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `pad_decision_schema` should extend the schema only when `schema.len()
|
|
||||||
/// < decision_arity`. Tests all three boundary cases (less / equal /
|
|
||||||
/// greater) to pin the `<` comparison so mutants `< → ==`, `< → >`,
|
|
||||||
/// `< → <=` all fail.
|
|
||||||
#[test]
|
|
||||||
fn pad_decision_schema_extends_when_short() {
|
|
||||||
let in_schema = vec![DecisionVariable::new("alpha")];
|
|
||||||
let out = pad_decision_schema(in_schema, 3);
|
|
||||||
assert_eq!(out.len(), 3);
|
|
||||||
assert_eq!(out[0].name, "alpha");
|
|
||||||
assert_eq!(out[1].name, "x[1]");
|
|
||||||
assert_eq!(out[2].name, "x[2]");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pad_decision_schema_unchanged_at_exact_length() {
|
|
||||||
let in_schema = vec![
|
|
||||||
DecisionVariable::new("alpha"),
|
|
||||||
DecisionVariable::new("beta"),
|
|
||||||
];
|
|
||||||
let out = pad_decision_schema(in_schema, 2);
|
|
||||||
assert_eq!(out.len(), 2);
|
|
||||||
assert_eq!(out[0].name, "alpha");
|
|
||||||
assert_eq!(out[1].name, "beta");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn pad_decision_schema_unchanged_when_longer_than_arity() {
|
|
||||||
// schema is longer than the arity — pad should be a no-op.
|
|
||||||
let in_schema = vec![
|
|
||||||
DecisionVariable::new("alpha"),
|
|
||||||
DecisionVariable::new("beta"),
|
|
||||||
DecisionVariable::new("gamma"),
|
|
||||||
];
|
|
||||||
let out = pad_decision_schema(in_schema, 2);
|
|
||||||
assert_eq!(out.len(), 3);
|
|
||||||
assert_eq!(out[2].name, "gamma");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `candidate_to_export`'s `front_rank == 0` controls `in_pareto_front`.
|
|
||||||
/// Test the boundary directly with synthetic candidates so the export
|
|
||||||
/// builder cannot accidentally mask the bug.
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_front_rank_zero_is_in_pareto_front() {
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
|
|
||||||
let exported = candidate_to_export(&c, 0, 1);
|
|
||||||
assert!(exported.in_pareto_front);
|
|
||||||
assert_eq!(exported.front_rank, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_front_rank_one_is_not_in_pareto_front() {
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
|
|
||||||
let exported = candidate_to_export(&c, 1, 1);
|
|
||||||
assert!(!exported.in_pareto_front);
|
|
||||||
assert_eq!(exported.front_rank, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `feasible` flips at `constraint_violation <= 0.0` boundary. Tests
|
|
||||||
/// the equality case (0.0 is feasible) plus both sides.
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_feasibility_at_zero_violation() {
|
|
||||||
let mut ev = Evaluation::new(vec![1.0]);
|
|
||||||
ev.constraint_violation = 0.0;
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
|
|
||||||
let exported = candidate_to_export(&c, 0, 1);
|
|
||||||
assert!(exported.feasible);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_feasibility_negative_violation() {
|
|
||||||
let mut ev = Evaluation::new(vec![1.0]);
|
|
||||||
ev.constraint_violation = -0.1;
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
|
|
||||||
let exported = candidate_to_export(&c, 0, 1);
|
|
||||||
assert!(exported.feasible);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_infeasibility_positive_violation() {
|
|
||||||
let mut ev = Evaluation::new(vec![1.0]);
|
|
||||||
ev.constraint_violation = 0.5;
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], ev);
|
|
||||||
let exported = candidate_to_export(&c, 0, 1);
|
|
||||||
assert!(!exported.feasible);
|
|
||||||
assert_eq!(exported.constraint_violation, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Defensive branch: if a buggy algorithm returns a mismatched
|
|
||||||
/// objectives length, candidate_to_export pads or truncates to `n_obj`
|
|
||||||
/// rather than passing the wrong-length vector through. Tests both
|
|
||||||
/// the pad (too few objectives) and truncate (too many) cases.
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_pads_short_objectives_with_nan() {
|
|
||||||
let c: Candidate<Vec<f64>> = Candidate::new(vec![1.0], Evaluation::new(vec![1.0]));
|
|
||||||
let exported = candidate_to_export(&c, 0, 3);
|
|
||||||
assert_eq!(exported.objectives.len(), 3);
|
|
||||||
assert_eq!(exported.objectives[0], 1.0);
|
|
||||||
assert!(exported.objectives[1].is_nan());
|
|
||||||
assert!(exported.objectives[2].is_nan());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn candidate_to_export_truncates_long_objectives() {
|
|
||||||
let c: Candidate<Vec<f64>> =
|
|
||||||
Candidate::new(vec![1.0], Evaluation::new(vec![1.0, 2.0, 3.0]));
|
|
||||||
let exported = candidate_to_export(&c, 0, 2);
|
|
||||||
assert_eq!(exported.objectives.len(), 2);
|
|
||||||
assert_eq!(exported.objectives[0], 1.0);
|
|
||||||
assert_eq!(exported.objectives[1], 2.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,25 +39,17 @@ pub(crate) fn cholesky(a: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, &'static str> {
|
|||||||
Ok(l)
|
Ok(l)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`,
|
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`.
|
||||||
/// writing the result into `out` (reused across calls to avoid allocating).
|
pub(crate) fn solve_lower(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
|
||||||
pub(crate) fn solve_lower_into(l: &[Vec<f64>], b: &[f64], out: &mut Vec<f64>) {
|
|
||||||
let n = l.len();
|
let n = l.len();
|
||||||
out.clear();
|
let mut y = vec![0.0_f64; n];
|
||||||
out.resize(n, 0.0);
|
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
let mut sum = b[i];
|
let mut sum = b[i];
|
||||||
for k in 0..i {
|
for k in 0..i {
|
||||||
sum -= l[i][k] * out[k];
|
sum -= l[i][k] * y[k];
|
||||||
}
|
}
|
||||||
out[i] = sum / l[i][i];
|
y[i] = sum / l[i][i];
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Solve `L · y = b` (forward substitution) for lower-triangular `L`.
|
|
||||||
pub(crate) fn solve_lower(l: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
|
|
||||||
let mut y = Vec::new();
|
|
||||||
solve_lower_into(l, b, &mut y);
|
|
||||||
y
|
y
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-13
@@ -4,7 +4,7 @@
|
|||||||
//! The crate aims to make three things obvious:
|
//! The crate aims to make three things obvious:
|
||||||
//!
|
//!
|
||||||
//! 1. **Define a problem** by implementing [`Problem`](crate::core::Problem).
|
//! 1. **Define a problem** by implementing [`Problem`](crate::core::Problem).
|
||||||
//! 2. **Run a built-in optimizer** — pick from 33 algorithms in
|
//! 2. **Run a built-in optimizer** — pick from 35 algorithms in
|
||||||
//! [`algorithms`] covering single-objective continuous (CMA-ES,
|
//! [`algorithms`] covering single-objective continuous (CMA-ES,
|
||||||
//! Differential Evolution, Nelder-Mead, …), multi-objective
|
//! Differential Evolution, Nelder-Mead, …), multi-objective
|
||||||
//! (NSGA-II, MOPSO, IBEA, MOEA/D, …), many-objective (NSGA-III,
|
//! (NSGA-II, MOPSO, IBEA, MOEA/D, …), many-objective (NSGA-III,
|
||||||
@@ -28,19 +28,10 @@
|
|||||||
//! - `serde` — derives `Serialize` / `Deserialize` on the core data
|
//! - `serde` — derives `Serialize` / `Deserialize` on the core data
|
||||||
//! types ([`Candidate`](crate::core::Candidate),
|
//! types ([`Candidate`](crate::core::Candidate),
|
||||||
//! [`Population`](crate::core::Population),
|
//! [`Population`](crate::core::Population),
|
||||||
//! [`Evaluation`](crate::core::Evaluation), …) and enables the
|
//! [`Evaluation`](crate::core::Evaluation), …).
|
||||||
//! [`heuropt::explorer`](crate::explorer) JSON export module for the
|
|
||||||
//! [heuropt-explorer](https://swaits.github.io/heuropt-explorer/)
|
|
||||||
//! webapp.
|
|
||||||
//! - `parallel` — rayon-backed parallel population evaluation in
|
//! - `parallel` — rayon-backed parallel population evaluation in
|
||||||
//! every population-based algorithm. Seeded runs stay bit-
|
//! every population-based algorithm. Seeded runs stay bit-
|
||||||
//! identical to serial mode.
|
//! identical to serial mode.
|
||||||
//! - `async` — adds the
|
|
||||||
//! [`AsyncProblem`](crate::core::async_problem::AsyncProblem) and
|
|
||||||
//! [`AsyncPartialProblem`](crate::core::async_problem::AsyncPartialProblem)
|
|
||||||
//! traits and a `run_async(&problem, concurrency).await` method on
|
|
||||||
//! every algorithm. Use this when your `evaluate` does IO (HTTP,
|
|
||||||
//! RPC, subprocess) — see the [Async evaluation cookbook recipe](https://swaits.github.io/heuropt/cookbook/async.html).
|
|
||||||
//!
|
//!
|
||||||
//! # Quick example
|
//! # Quick example
|
||||||
//!
|
//!
|
||||||
@@ -75,10 +66,9 @@
|
|||||||
|
|
||||||
pub mod algorithms;
|
pub mod algorithms;
|
||||||
pub mod core;
|
pub mod core;
|
||||||
#[cfg(feature = "serde")]
|
|
||||||
pub mod explorer;
|
|
||||||
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;
|
||||||
|
|||||||
+13
-185
@@ -14,26 +14,6 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// If `objectives` does not have exactly two objectives.
|
/// If `objectives` does not have exactly two objectives.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use heuropt::metrics::hypervolume_2d;
|
|
||||||
///
|
|
||||||
/// let space = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// // Reference (4, 4); front at (1,3), (2,2), (3,1) → dominated area = 6.
|
|
||||||
/// let front = [
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![1.0, 3.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![3.0, 1.0])),
|
|
||||||
/// ];
|
|
||||||
/// let hv = hypervolume_2d(&front, &space, [4.0, 4.0]);
|
|
||||||
/// assert!((hv - 6.0).abs() < 1e-12);
|
|
||||||
/// ```
|
|
||||||
pub fn hypervolume_2d<D>(
|
pub fn hypervolume_2d<D>(
|
||||||
front: &[Candidate<D>],
|
front: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -167,24 +147,6 @@ mod tests {
|
|||||||
///
|
///
|
||||||
/// # Panics
|
/// # Panics
|
||||||
/// If `objectives.len() != reference_point.len()`, or if either is zero.
|
/// If `objectives.len() != reference_point.len()`, or if either is zero.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use heuropt::metrics::hypervolume_nd;
|
|
||||||
///
|
|
||||||
/// let space = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// Objective::minimize("f3"),
|
|
||||||
/// ]);
|
|
||||||
/// // Single corner point at the origin against a unit-cube reference:
|
|
||||||
/// // dominated volume = 1.
|
|
||||||
/// let front = [Candidate::new((), Evaluation::new(vec![0.0, 0.0, 0.0]))];
|
|
||||||
/// let hv = hypervolume_nd(&front, &space, &[1.0, 1.0, 1.0]);
|
|
||||||
/// assert!((hv - 1.0).abs() < 1e-12);
|
|
||||||
/// ```
|
|
||||||
pub fn hypervolume_nd<D>(
|
pub fn hypervolume_nd<D>(
|
||||||
front: &[Candidate<D>],
|
front: &[Candidate<D>],
|
||||||
objectives: &ObjectiveSpace,
|
objectives: &ObjectiveSpace,
|
||||||
@@ -279,59 +241,28 @@ fn hso_recursive(points: &[Vec<f64>], reference: &[f64]) -> f64 {
|
|||||||
let sub_reference: &[f64] = &reference[..last];
|
let sub_reference: &[f64] = &reference[..last];
|
||||||
let mut total = 0.0;
|
let mut total = 0.0;
|
||||||
let mut prev = reference[last];
|
let mut prev = reference[last];
|
||||||
|
|
||||||
if sub_reference.len() == 2 {
|
|
||||||
// M == 3: the inner HV is a 2-D staircase sweep. `projected` is in
|
|
||||||
// last-axis order, so the active set at step `k` is the prefix
|
|
||||||
// `projected[..=k]`. The generic recursion re-sorts that prefix by
|
|
||||||
// axis 0 on every step — O(n² log n). Instead, sort the projected
|
|
||||||
// indices by axis 0 once and, for each `k`, sweep them skipping any
|
|
||||||
// whose last-axis rank exceeds `k`. The sweep visits points in the
|
|
||||||
// same (axis-0, then last-axis) order the stable per-prefix sort
|
|
||||||
// produced, so the result is bit-identical.
|
|
||||||
let r0 = sub_reference[0];
|
|
||||||
let r1 = sub_reference[1];
|
|
||||||
let mut x_order: Vec<usize> = (0..projected.len()).collect();
|
|
||||||
x_order.sort_by(|&a, &b| {
|
|
||||||
projected[a][0]
|
|
||||||
.partial_cmp(&projected[b][0])
|
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
|
||||||
});
|
|
||||||
for k in (0..order.len()).rev() {
|
|
||||||
let p_last = points[order[k]][last];
|
|
||||||
let depth = prev - p_last;
|
|
||||||
if depth > 0.0 {
|
|
||||||
let mut area = 0.0;
|
|
||||||
let mut last_y = r1;
|
|
||||||
for &pi in &x_order {
|
|
||||||
if pi > k {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let p = &projected[pi];
|
|
||||||
if p[1] >= last_y {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
area += (r0 - p[0]) * (last_y - p[1]);
|
|
||||||
last_y = p[1];
|
|
||||||
}
|
|
||||||
total += depth * area;
|
|
||||||
}
|
|
||||||
prev = p_last;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// M >= 4: recurse generically, with the explicit non-dominated
|
|
||||||
// filter to keep the recursion's upper levels honest.
|
|
||||||
for k in (0..order.len()).rev() {
|
for k in (0..order.len()).rev() {
|
||||||
let p_last = points[order[k]][last];
|
let p_last = points[order[k]][last];
|
||||||
let depth = prev - p_last;
|
let depth = prev - p_last;
|
||||||
if depth > 0.0 {
|
if depth > 0.0 {
|
||||||
let active = &projected[..=k];
|
let active = &projected[..=k];
|
||||||
|
// The 2-D base case sweeps in sorted-x order and skips any
|
||||||
|
// point with `y >= last_y`, which is exactly the dominance
|
||||||
|
// filter — so for M=3 (sub_reference len 2) we can hand
|
||||||
|
// `active` straight to `hso_recursive` without paying for
|
||||||
|
// an O(K²) `non_dominated_projection` first. For M≥4 we
|
||||||
|
// still need the explicit filter to keep the recursion's
|
||||||
|
// upper levels honest.
|
||||||
|
let inner = if sub_reference.len() == 2 {
|
||||||
|
hso_recursive(active, sub_reference)
|
||||||
|
} else {
|
||||||
let nd = non_dominated_projection(active);
|
let nd = non_dominated_projection(active);
|
||||||
total += depth * hso_recursive(&nd, sub_reference);
|
hso_recursive(&nd, sub_reference)
|
||||||
|
};
|
||||||
|
total += depth * inner;
|
||||||
}
|
}
|
||||||
prev = p_last;
|
prev = p_last;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
total
|
total
|
||||||
}
|
}
|
||||||
@@ -514,107 +445,4 @@ mod nd_tests {
|
|||||||
let hv_with = hypervolume_nd(&with_dominated, &s, &[2.0, 2.0, 2.0]);
|
let hv_with = hypervolume_nd(&with_dominated, &s, &[2.0, 2.0, 2.0]);
|
||||||
assert!((hv_base - hv_with).abs() < 1e-12, "{hv_base} vs {hv_with}");
|
assert!((hv_base - hv_with).abs() < 1e-12, "{hv_base} vs {hv_with}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test pinned helpers --------------------------------------
|
|
||||||
|
|
||||||
/// `dominates(a, b)` is true iff `a` is ≤ `b` on every axis and strictly
|
|
||||||
/// better on at least one. Pin all the boundary cases so the `<` / `>`
|
|
||||||
/// comparison flips are caught.
|
|
||||||
#[test]
|
|
||||||
fn dominates_strict_and_boundary_cases() {
|
|
||||||
// a strictly dominates b on both axes.
|
|
||||||
assert!(dominates(&[1.0, 1.0], &[2.0, 2.0], 2));
|
|
||||||
// b does not dominate a (reverse).
|
|
||||||
assert!(!dominates(&[2.0, 2.0], &[1.0, 1.0], 2));
|
|
||||||
// Equal points: neither dominates (no strict improvement).
|
|
||||||
assert!(!dominates(&[1.0, 1.0], &[1.0, 1.0], 2));
|
|
||||||
// a better on axis 0, equal on axis 1 → a dominates b.
|
|
||||||
assert!(dominates(&[1.0, 2.0], &[2.0, 2.0], 2));
|
|
||||||
// a better on axis 0 but worse on axis 1 → no domination.
|
|
||||||
assert!(!dominates(&[1.0, 3.0], &[2.0, 2.0], 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `non_dominated_projection` drops dominated members and keeps the
|
|
||||||
/// rest. Pin the exact retained set.
|
|
||||||
#[test]
|
|
||||||
fn non_dominated_projection_drops_dominated() {
|
|
||||||
let pts = vec![
|
|
||||||
vec![1.0, 3.0], // non-dominated
|
|
||||||
vec![3.0, 1.0], // non-dominated
|
|
||||||
vec![2.0, 2.0], // non-dominated (trade-off)
|
|
||||||
vec![4.0, 4.0], // dominated by all three
|
|
||||||
];
|
|
||||||
let nd = non_dominated_projection(&pts);
|
|
||||||
assert_eq!(nd.len(), 3);
|
|
||||||
assert!(!nd.contains(&vec![4.0, 4.0]));
|
|
||||||
assert!(nd.contains(&vec![1.0, 3.0]));
|
|
||||||
assert!(nd.contains(&vec![3.0, 1.0]));
|
|
||||||
assert!(nd.contains(&vec![2.0, 2.0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_dominated_projection_empty_input_is_empty() {
|
|
||||||
let pts: Vec<Vec<f64>> = Vec::new();
|
|
||||||
assert!(non_dominated_projection(&pts).is_empty());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_dominated_projection_all_nondominated_keeps_all() {
|
|
||||||
let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
|
|
||||||
let nd = non_dominated_projection(&pts);
|
|
||||||
assert_eq!(nd.len(), 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `hso_recursive` 1-D base case: HV is `reference - min_point`,
|
|
||||||
/// clamped at 0.
|
|
||||||
#[test]
|
|
||||||
fn hso_recursive_1d_base_case() {
|
|
||||||
let pts = vec![vec![0.5], vec![1.5], vec![0.2]];
|
|
||||||
// min is 0.2, reference is 2.0 → HV = 1.8
|
|
||||||
assert!((hso_recursive(&pts, &[2.0]) - 1.8).abs() < 1e-12);
|
|
||||||
// A point past the reference → clamped to 0 contribution; min still 0.2.
|
|
||||||
let pts2 = vec![vec![3.0]];
|
|
||||||
assert_eq!(hso_recursive(&pts2, &[2.0]), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `hso_recursive` 2-D base case: classic staircase area.
|
|
||||||
#[test]
|
|
||||||
fn hso_recursive_2d_staircase() {
|
|
||||||
// Three points (1,3), (2,2), (3,1) against reference (4,4).
|
|
||||||
// Dominated area = 6 (same as the hypervolume_2d doctest).
|
|
||||||
let pts = vec![vec![1.0, 3.0], vec![2.0, 2.0], vec![3.0, 1.0]];
|
|
||||||
let hv = hso_recursive(&pts, &[4.0, 4.0]);
|
|
||||||
assert!((hv - 6.0).abs() < 1e-12, "hv = {hv}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `hypervolume_nd_from_evaluations` returns 0 for an empty slice and a
|
|
||||||
/// positive value for a dominating point.
|
|
||||||
#[test]
|
|
||||||
fn hypervolume_nd_from_evaluations_empty_and_nonempty() {
|
|
||||||
let s = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
|
|
||||||
let empty: Vec<&Evaluation> = Vec::new();
|
|
||||||
assert_eq!(
|
|
||||||
hypervolume_nd_from_evaluations(&empty, &s, &[2.0, 2.0]),
|
|
||||||
0.0
|
|
||||||
);
|
|
||||||
|
|
||||||
let e = Evaluation::new(vec![1.0, 1.0]);
|
|
||||||
let evals = vec![&e];
|
|
||||||
let hv = hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]);
|
|
||||||
// Single point (1,1) vs reference (2,2) → 1×1 = 1.
|
|
||||||
assert!((hv - 1.0).abs() < 1e-12, "hv = {hv}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A point that does not strictly dominate the reference contributes 0.
|
|
||||||
#[test]
|
|
||||||
fn hypervolume_nd_from_evaluations_skips_non_dominating() {
|
|
||||||
let s = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
|
|
||||||
// (2, 1): axis 0 equals the reference → not strictly dominating.
|
|
||||||
let e = Evaluation::new(vec![2.0, 1.0]);
|
|
||||||
let evals = vec![&e];
|
|
||||||
assert_eq!(
|
|
||||||
hypervolume_nd_from_evaluations(&evals, &s, &[2.0, 2.0]),
|
|
||||||
0.0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,26 +11,6 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// uniform front has spacing 0.
|
/// uniform front has spacing 0.
|
||||||
///
|
///
|
||||||
/// Returns `0.0` for empty or single-point fronts (spec §14.1).
|
/// Returns `0.0` for empty or single-point fronts (spec §14.1).
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
/// use heuropt::metrics::spacing;
|
|
||||||
///
|
|
||||||
/// let space = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// // Five points evenly spaced on a line — spacing should be 0.
|
|
||||||
/// let front: Vec<Candidate<()>> = (0..5)
|
|
||||||
/// .map(|i| {
|
|
||||||
/// let t = i as f64;
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![t, 4.0 - t]))
|
|
||||||
/// })
|
|
||||||
/// .collect();
|
|
||||||
/// assert!(spacing(&front, &space) < 1e-12);
|
|
||||||
/// ```
|
|
||||||
pub fn spacing<D>(front: &[Candidate<D>], objectives: &ObjectiveSpace) -> f64 {
|
pub fn spacing<D>(front: &[Candidate<D>], objectives: &ObjectiveSpace) -> f64 {
|
||||||
let n = front.len();
|
let n = front.len();
|
||||||
if n < 2 {
|
if n < 2 {
|
||||||
@@ -119,39 +99,4 @@ mod tests {
|
|||||||
let s_val = spacing(&pts, &s);
|
let s_val = spacing(&pts, &s);
|
||||||
assert!(s_val > 0.0);
|
assert!(s_val > 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pins the exact spacing for a front with *varying* nearest-neighbor
|
|
||||||
/// distances, exercising the `(a-b).abs()` sum, the `d < nearest`
|
|
||||||
/// comparison, and both `/ n` divisions in the mean/variance.
|
|
||||||
#[test]
|
|
||||||
fn varying_nn_distances_pinned() {
|
|
||||||
let s = space_min2();
|
|
||||||
// (0,10), (1,9), (10,0): L1 nearest distances are 2, 2, 18.
|
|
||||||
// mean = 22/3, variance = 1536/27, spacing = sqrt(1536/27).
|
|
||||||
let front = [
|
|
||||||
cand(vec![0.0, 10.0]),
|
|
||||||
cand(vec![1.0, 9.0]),
|
|
||||||
cand(vec![10.0, 0.0]),
|
|
||||||
];
|
|
||||||
let got = spacing(&front, &s);
|
|
||||||
let expected = (1536.0_f64 / 27.0).sqrt();
|
|
||||||
assert!(
|
|
||||||
(got - expected).abs() < 1e-9,
|
|
||||||
"got {got}, expected {expected}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A perfectly even front has zero spacing — the variance term is 0.
|
|
||||||
/// Distinct from the doctest case in that it uses three points whose
|
|
||||||
/// nearest-neighbor L1 distances are all equal to 4.
|
|
||||||
#[test]
|
|
||||||
fn evenly_spaced_front_is_zero_spacing() {
|
|
||||||
let s = space_min2();
|
|
||||||
let front = [
|
|
||||||
cand(vec![0.0, 4.0]),
|
|
||||||
cand(vec![2.0, 2.0]),
|
|
||||||
cand(vec![4.0, 0.0]),
|
|
||||||
];
|
|
||||||
assert!(spacing(&front, &s) < 1e-12);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -9,19 +9,6 @@ use crate::traits::Variation;
|
|||||||
///
|
///
|
||||||
/// Always returns exactly one child (spec §11.3). Panics if `probability` is
|
/// Always returns exactly one child (spec §11.3). Panics if `probability` is
|
||||||
/// outside `[0.0, 1.0]` or if no parents are provided.
|
/// outside `[0.0, 1.0]` or if no parents are provided.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let mut m = BitFlipMutation { probability: 0.5 };
|
|
||||||
/// let parent = vec![true, false, true, false];
|
|
||||||
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 1);
|
|
||||||
/// assert_eq!(children[0].len(), parent.len());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BitFlipMutation {
|
pub struct BitFlipMutation {
|
||||||
/// Per-bit flip probability. Must lie in `[0.0, 1.0]`.
|
/// Per-bit flip probability. Must lie in `[0.0, 1.0]`.
|
||||||
|
|||||||
+4
-1259
File diff suppressed because it is too large
Load Diff
@@ -10,23 +10,6 @@ use crate::traits::{Initializer, Variation};
|
|||||||
///
|
///
|
||||||
/// Bounds are inclusive `(lo, hi)` ranges per dimension. Panics if any bound
|
/// Bounds are inclusive `(lo, hi)` ranges per dimension. Panics if any bound
|
||||||
/// has `lo > hi` (spec §11.1).
|
/// has `lo > hi` (spec §11.1).
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let mut init = RealBounds::new(vec![(-1.0, 1.0); 3]);
|
|
||||||
/// let decisions = init.initialize(5, &mut rng);
|
|
||||||
/// assert_eq!(decisions.len(), 5);
|
|
||||||
/// for d in &decisions {
|
|
||||||
/// assert_eq!(d.len(), 3);
|
|
||||||
/// for &v in d {
|
|
||||||
/// assert!(v >= -1.0 && v <= 1.0);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RealBounds {
|
pub struct RealBounds {
|
||||||
/// Per-variable inclusive bounds in decision order.
|
/// Per-variable inclusive bounds in decision order.
|
||||||
@@ -71,19 +54,6 @@ impl Initializer<Vec<f64>> for RealBounds {
|
|||||||
/// Add `Normal(0, sigma)` noise to every variable of the first parent.
|
/// Add `Normal(0, sigma)` noise to every variable of the first parent.
|
||||||
///
|
///
|
||||||
/// Always returns exactly one child. Does not enforce bounds in v1 (spec §11.2).
|
/// Always returns exactly one child. Does not enforce bounds in v1 (spec §11.2).
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let mut m = GaussianMutation { sigma: 0.1 };
|
|
||||||
/// let parent = vec![0.0; 4];
|
|
||||||
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 1);
|
|
||||||
/// assert_eq!(children[0].len(), parent.len());
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct GaussianMutation {
|
pub struct GaussianMutation {
|
||||||
/// Standard deviation of the Gaussian noise. Must be positive.
|
/// Standard deviation of the Gaussian noise. Must be positive.
|
||||||
@@ -118,26 +88,6 @@ impl Variation<Vec<f64>> for GaussianMutation {
|
|||||||
///
|
///
|
||||||
/// Panics on construction if any bound has `lo > hi`, or at run time if
|
/// Panics on construction if any bound has `lo > hi`, or at run time if
|
||||||
/// `parents.len() < 2` or any parent length differs from `bounds.len()`.
|
/// `parents.len() < 2` or any parent length differs from `bounds.len()`.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
/// let mut sbx = SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5);
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let parents = [vec![-0.5, 0.0, 0.5], vec![0.5, 0.5, -0.5]];
|
|
||||||
/// let children = sbx.vary(&parents, &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 2);
|
|
||||||
/// // Children stay in bounds.
|
|
||||||
/// for c in &children {
|
|
||||||
/// for (j, &v) in c.iter().enumerate() {
|
|
||||||
/// let (lo, hi) = bounds[j];
|
|
||||||
/// assert!(v >= lo && v <= hi);
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct SimulatedBinaryCrossover {
|
pub struct SimulatedBinaryCrossover {
|
||||||
/// Per-variable inclusive bounds. Length must match the parent decisions.
|
/// Per-variable inclusive bounds. Length must match the parent decisions.
|
||||||
@@ -230,23 +180,6 @@ impl Variation<Vec<f64>> for SimulatedBinaryCrossover {
|
|||||||
///
|
///
|
||||||
/// This is the simple bound-rescale form; the bound-aware `δ_q` variant from
|
/// This is the simple bound-rescale form; the bound-aware `δ_q` variant from
|
||||||
/// the full paper is left as a future refinement.
|
/// the full paper is left as a future refinement.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
/// let mut pm = PolynomialMutation::new(bounds.clone(), 20.0, 1.0 / 3.0);
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let parent = vec![0.0, 0.5, -0.5];
|
|
||||||
/// let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 1);
|
|
||||||
/// for (j, &v) in children[0].iter().enumerate() {
|
|
||||||
/// let (lo, hi) = bounds[j];
|
|
||||||
/// assert!(v >= lo && v <= hi);
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PolynomialMutation {
|
pub struct PolynomialMutation {
|
||||||
/// Per-variable inclusive bounds. Length must match the parent decision.
|
/// Per-variable inclusive bounds. Length must match the parent decision.
|
||||||
@@ -321,23 +254,6 @@ impl Variation<Vec<f64>> for PolynomialMutation {
|
|||||||
/// Always returns exactly one child. Use this when you want feasibility
|
/// Always returns exactly one child. Use this when you want feasibility
|
||||||
/// maintained across generations without leaning on
|
/// maintained across generations without leaning on
|
||||||
/// clamp-inside-`Problem::evaluate`.
|
/// clamp-inside-`Problem::evaluate`.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
/// let mut m = BoundedGaussianMutation::new(0.3, bounds.clone());
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let parent = vec![0.0; 3];
|
|
||||||
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 1);
|
|
||||||
/// for (j, &v) in children[0].iter().enumerate() {
|
|
||||||
/// let (lo, hi) = bounds[j];
|
|
||||||
/// assert!(v >= lo && v <= hi);
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct BoundedGaussianMutation {
|
pub struct BoundedGaussianMutation {
|
||||||
/// Standard deviation of the Gaussian noise. Must be positive.
|
/// Standard deviation of the Gaussian noise. Must be positive.
|
||||||
@@ -399,23 +315,6 @@ impl Variation<Vec<f64>> for BoundedGaussianMutation {
|
|||||||
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
|
/// produce a Lévy(α) sample. `alpha` is the tail exponent in `(0, 2]`;
|
||||||
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
|
/// typical value is `1.5`. `1.0` gives the Cauchy distribution (very
|
||||||
/// heavy); `2.0` collapses to the Normal.
|
/// heavy); `2.0` collapses to the Normal.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
/// let mut m = LevyMutation::new(1.5, 0.1, bounds.clone());
|
|
||||||
/// let mut rng = rng_from_seed(42);
|
|
||||||
/// let parent = vec![0.0; 3];
|
|
||||||
/// let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
/// assert_eq!(children.len(), 1);
|
|
||||||
/// for (j, &v) in children[0].iter().enumerate() {
|
|
||||||
/// let (lo, hi) = bounds[j];
|
|
||||||
/// assert!(v >= lo && v <= hi);
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct LevyMutation {
|
pub struct LevyMutation {
|
||||||
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
|
/// Tail exponent `α ∈ (0, 2]`. Smaller = heavier tail.
|
||||||
@@ -720,224 +619,4 @@ mod tests {
|
|||||||
let mut rng = rng_from_seed(0);
|
let mut rng = rng_from_seed(0);
|
||||||
m.vary(&[vec![0.5; 2]], &mut rng);
|
m.vary(&[vec![0.5; 2]], &mut rng);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Pinned numerical snapshots ----------------------------------------
|
|
||||||
//
|
|
||||||
// Mutation testing surfaced ~120 arithmetic-flip mutants surviving in
|
|
||||||
// this file (`+= → *=`, `*` ↔ `+`, `−` ↔ `/`, etc.). The existing
|
|
||||||
// shape/bounds tests pass with most of those flips because they only
|
|
||||||
// check ranges. The snapshots below pin the *exact* output of each
|
|
||||||
// operator at a fixed seed so any arithmetic flip changes a value and
|
|
||||||
// fails the assertion. Snapshots come from running the un-mutated
|
|
||||||
// implementation; updating an operator's math requires updating its
|
|
||||||
// snapshot, by design.
|
|
||||||
|
|
||||||
fn assert_close_slice(got: &[f64], want: &[f64], tol: f64) {
|
|
||||||
assert_eq!(
|
|
||||||
got.len(),
|
|
||||||
want.len(),
|
|
||||||
"length mismatch: got {got:?} want {want:?}"
|
|
||||||
);
|
|
||||||
for (g, w) in got.iter().zip(want.iter()) {
|
|
||||||
assert!((g - w).abs() < tol, "got {g}, want {w}; full got = {got:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn gaussian_mutation_seed_42_pinned() {
|
|
||||||
let mut m = GaussianMutation { sigma: 0.5 };
|
|
||||||
let mut rng = rng_from_seed(42);
|
|
||||||
let parent = vec![1.0_f64, 2.0, 3.0];
|
|
||||||
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[0],
|
|
||||||
&[
|
|
||||||
1.034_713_959_180_981_7,
|
|
||||||
2.066_469_060_997_062_6,
|
|
||||||
3.131_288_178_686_977,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn bounded_gaussian_mutation_seed_7_pinned() {
|
|
||||||
let mut m = BoundedGaussianMutation::new(0.3, vec![(-1.0, 1.0); 3]);
|
|
||||||
let mut rng = rng_from_seed(7);
|
|
||||||
let parent = vec![0.0_f64, 0.5, -0.5];
|
|
||||||
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[0],
|
|
||||||
&[
|
|
||||||
-0.313_072_988_018_995_14,
|
|
||||||
0.326_975_666_440_741_83,
|
|
||||||
-0.713_376_295_479_132,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sbx_seed_42_pinned_pair_of_children() {
|
|
||||||
let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0);
|
|
||||||
let mut rng = rng_from_seed(42);
|
|
||||||
let p1 = vec![-0.5, 0.0, 0.5];
|
|
||||||
let p2 = vec![0.5, 0.5, -0.5];
|
|
||||||
let children = sbx.vary(&[p1, p2], &mut rng);
|
|
||||||
assert_eq!(children.len(), 2);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[0],
|
|
||||||
&[
|
|
||||||
-0.501_708_457_102_519_2,
|
|
||||||
-0.001_399_584_314_974_167_1,
|
|
||||||
0.510_060_271_407_340_6,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[1],
|
|
||||||
&[
|
|
||||||
0.501_708_457_102_519_2,
|
|
||||||
0.501_399_584_314_974_1,
|
|
||||||
-0.510_060_271_407_340_6,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SBX has the algebraic identity `c1 + c2 = p1 + p2` for any β (before
|
|
||||||
/// clamping). Pinning this directly catches arithmetic flips in the
|
|
||||||
/// `(1+β) * p1 + (1-β) * p2` formula that would break the identity.
|
|
||||||
#[test]
|
|
||||||
fn sbx_sum_of_children_equals_sum_of_parents_when_unclamped() {
|
|
||||||
let bounds = vec![(-100.0, 100.0); 3]; // wide so no clamping fires
|
|
||||||
let mut sbx = SimulatedBinaryCrossover::new(bounds, 15.0, 1.0);
|
|
||||||
let p1 = vec![-0.5, 0.2, 0.9];
|
|
||||||
let p2 = vec![0.3, -0.7, 0.1];
|
|
||||||
for seed in 0..20 {
|
|
||||||
let mut rng = rng_from_seed(seed);
|
|
||||||
let kids = sbx.vary(&[p1.clone(), p2.clone()], &mut rng);
|
|
||||||
for j in 0..p1.len() {
|
|
||||||
let lhs = kids[0][j] + kids[1][j];
|
|
||||||
let rhs = p1[j] + p2[j];
|
|
||||||
assert!((lhs - rhs).abs() < 1e-12, "seed={seed} j={j} {lhs} ≠ {rhs}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn polynomial_mutation_seed_42_pinned() {
|
|
||||||
let bounds = vec![(-1.0, 1.0); 3];
|
|
||||||
let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0);
|
|
||||||
let mut rng = rng_from_seed(42);
|
|
||||||
let parent = vec![0.0_f64, 0.5, -0.5];
|
|
||||||
let children = pm.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[0],
|
|
||||||
&[
|
|
||||||
0.005_191_102_584_008_567,
|
|
||||||
0.508_488_942_560_315,
|
|
||||||
-0.469_873_699_029_174_75,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PolynomialMutation's δ should scale by `(hi - lo)`. If the
|
|
||||||
/// `delta * (hi - lo)` arithmetic gets mutated (e.g., `*` → `+`), the
|
|
||||||
/// per-axis perturbation scale drops out and a 10× bound range no
|
|
||||||
/// longer produces a 10× larger step. Tests with two different bound
|
|
||||||
/// widths at the same seed and asserts the perturbation ratio is ≈ 10.
|
|
||||||
#[test]
|
|
||||||
fn polynomial_mutation_step_scales_with_bound_width() {
|
|
||||||
let parent = vec![0.0_f64];
|
|
||||||
let probe = |bounds: Vec<(f64, f64)>| -> f64 {
|
|
||||||
let mut pm = PolynomialMutation::new(bounds, 20.0, 1.0);
|
|
||||||
let mut rng = rng_from_seed(123);
|
|
||||||
pm.vary(std::slice::from_ref(&parent), &mut rng)[0][0]
|
|
||||||
};
|
|
||||||
let narrow = probe(vec![(-1.0_f64, 1.0)]); // hi - lo = 2
|
|
||||||
let wide = probe(vec![(-10.0_f64, 10.0)]); // hi - lo = 20
|
|
||||||
// Same seed → same δ; the only difference is the (hi-lo) factor.
|
|
||||||
// Ratio must be ≈ 10.
|
|
||||||
let ratio = wide / narrow;
|
|
||||||
assert!(
|
|
||||||
(ratio - 10.0).abs() < 1e-12,
|
|
||||||
"ratio = {ratio}, narrow={narrow}, wide={wide}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn levy_mutation_seed_42_pinned() {
|
|
||||||
let mut m = LevyMutation::new(1.5, 0.1, vec![(-100.0, 100.0); 3]);
|
|
||||||
let mut rng = rng_from_seed(42);
|
|
||||||
let parent = vec![0.0_f64; 3];
|
|
||||||
let children = m.vary(std::slice::from_ref(&parent), &mut rng);
|
|
||||||
assert_close_slice(
|
|
||||||
&children[0],
|
|
||||||
&[
|
|
||||||
0.018_566_727_273_339_814,
|
|
||||||
0.049_398_595_670_997_11,
|
|
||||||
-0.128_765_264_276_263_75,
|
|
||||||
],
|
|
||||||
1e-12,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `mantegna_sigma_u` helper computes `σᵤ` for Mantegna's Lévy
|
|
||||||
/// algorithm. Pinning a non-degenerate alpha catches arithmetic flips
|
|
||||||
/// in both the outer formula and the inner `gamma()` Lanczos series.
|
|
||||||
#[test]
|
|
||||||
fn mantegna_sigma_u_alpha_1_5_pinned() {
|
|
||||||
let got = mantegna_sigma_u(1.5);
|
|
||||||
assert!(
|
|
||||||
(got - 0.696_574_502_557_698).abs() < 1e-12,
|
|
||||||
"mantegna_sigma_u(1.5) = {got}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mantegna_sigma_u_alpha_1_0_pinned() {
|
|
||||||
// alpha = 1.0: sin(π/2) = 1, gamma(2) = 1, gamma(1) = 1 → σᵤ ≈ 1.
|
|
||||||
let got = mantegna_sigma_u(1.0);
|
|
||||||
assert!((got - 1.0).abs() < 1e-12, "mantegna_sigma_u(1.0) = {got}",);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn mantegna_sigma_u_alpha_2_0_pinned() {
|
|
||||||
// alpha = 2.0 (Normal limit): sin(π) = 0 numerically → σᵤ → 0.
|
|
||||||
// Specifically about 1e-8 due to the FP error in sin(π).
|
|
||||||
let got = mantegna_sigma_u(2.0);
|
|
||||||
assert!((0.0..1e-7).contains(&got), "mantegna_sigma_u(2.0) = {got}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `gamma(z)` at exact integer arguments hits known recurrence values.
|
|
||||||
/// We probe it indirectly via `mantegna_sigma_u` since gamma is a
|
|
||||||
/// private inner fn. Pin `σᵤ` at alpha = 1.5 — under any arithmetic
|
|
||||||
/// mutation inside gamma() the value shifts well beyond f64 precision.
|
|
||||||
/// (Already covered by the alpha-1.5 test above; left here as docs.)
|
|
||||||
#[test]
|
|
||||||
fn mantegna_sigma_u_changes_monotonically_with_alpha() {
|
|
||||||
// For alpha ∈ [0.5, 1.5], σᵤ is a monotone function of α
|
|
||||||
// (Mantegna 1994, fig 1). This is a property test that breaks
|
|
||||||
// under structural changes to the formula even if the snapshot
|
|
||||||
// values are wrong.
|
|
||||||
let a = mantegna_sigma_u(0.5);
|
|
||||||
let b = mantegna_sigma_u(0.8);
|
|
||||||
let c = mantegna_sigma_u(1.2);
|
|
||||||
let d = mantegna_sigma_u(1.5);
|
|
||||||
// Verify (a, b, c, d) all positive and the sequence is monotone
|
|
||||||
// — direction depends on implementation, just assert non-trivial.
|
|
||||||
for v in [a, b, c, d] {
|
|
||||||
assert!(v > 0.0 && v.is_finite(), "non-positive sigma_u: {v}");
|
|
||||||
}
|
|
||||||
// a > d (decreasing) or a < d (increasing) — both are valid; just
|
|
||||||
// require the values aren't all identical (which would happen
|
|
||||||
// under a `gamma -> const` mutant).
|
|
||||||
assert!(
|
|
||||||
(a - d).abs() > 0.01,
|
|
||||||
"sigma_u barely changes with alpha: a={a}, d={d}",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,6 @@ use crate::traits::Repair;
|
|||||||
/// The simplest possible repair — pair with `GaussianMutation` (which
|
/// The simplest possible repair — pair with `GaussianMutation` (which
|
||||||
/// doesn't enforce bounds in v1) to produce a bounds-respecting variant
|
/// doesn't enforce bounds in v1) to produce a bounds-respecting variant
|
||||||
/// without writing a custom Variation impl.
|
/// without writing a custom Variation impl.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let mut r = ClampToBounds::new(vec![(-1.0, 1.0); 3]);
|
|
||||||
/// let mut x = vec![-2.0, 0.5, 5.0];
|
|
||||||
/// r.repair(&mut x);
|
|
||||||
/// assert_eq!(x, vec![-1.0, 0.5, 1.0]);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ClampToBounds {
|
pub struct ClampToBounds {
|
||||||
/// Per-variable inclusive bounds.
|
/// Per-variable inclusive bounds.
|
||||||
@@ -57,19 +46,6 @@ impl Repair<Vec<f64>> for ClampToBounds {
|
|||||||
/// Perpiñán 2013. Useful for portfolio-style problems where the
|
/// Perpiñán 2013. Useful for portfolio-style problems where the
|
||||||
/// decision must sum to a budget, and for normalizing reference
|
/// decision must sum to a budget, and for normalizing reference
|
||||||
/// directions onto the unit simplex.
|
/// directions onto the unit simplex.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let mut r = ProjectToSimplex::new(1.0);
|
|
||||||
/// let mut x = vec![0.6, 0.5, -0.1, 0.3];
|
|
||||||
/// r.repair(&mut x);
|
|
||||||
/// let sum: f64 = x.iter().sum();
|
|
||||||
/// assert!((sum - 1.0).abs() < 1e-12);
|
|
||||||
/// assert!(x.iter().all(|&v| v >= 0.0));
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ProjectToSimplex {
|
pub struct ProjectToSimplex {
|
||||||
/// Target sum (the simplex's "size"). Standard probability simplex
|
/// Target sum (the simplex's "size"). Standard probability simplex
|
||||||
@@ -249,32 +225,4 @@ mod tests {
|
|||||||
assert!(approx_eq(v, 0.25, 1e-12));
|
assert!(approx_eq(v, 0.25, 1e-12));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Mutation-test coverage for ProjectToSimplex ----------------------
|
|
||||||
//
|
|
||||||
// The degenerate-magnitude shortcut concentrates mass on argmax(x). The
|
|
||||||
// next two tests pin the *position* of that argmax precisely.
|
|
||||||
|
|
||||||
/// The shortcut picks the **first** index on a tie. Strict `>` keeps
|
|
||||||
/// the earlier index; `>=` would overwrite with the later equal index.
|
|
||||||
/// Kills `> → >=` in the argmax scan.
|
|
||||||
#[test]
|
|
||||||
fn project_extreme_magnitudes_keeps_first_index_on_tie() {
|
|
||||||
let mut r = ProjectToSimplex::new(1.0);
|
|
||||||
let mut x = vec![1e20, 1e20, -1e20];
|
|
||||||
r.repair(&mut x);
|
|
||||||
assert_eq!(x, vec![1.0, 0.0, 0.0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The shortcut finds the argmax at a non-zero index. With `> → ==`
|
|
||||||
/// the scan stops updating because `1e20 == -1e20` is false at i=1
|
|
||||||
/// and the argmax stays at 0 — but the true argmax is at index 1.
|
|
||||||
/// Kills `> → ==` in the argmax scan.
|
|
||||||
#[test]
|
|
||||||
fn project_extreme_magnitudes_finds_argmax_at_non_zero_index() {
|
|
||||||
let mut r = ProjectToSimplex::new(1.0);
|
|
||||||
let mut x = vec![-1e20, 1e20, 5e19];
|
|
||||||
r.repair(&mut x);
|
|
||||||
assert_eq!(x, vec![0.0, 1.0, 0.0]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,23 +9,6 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// archive insert/extend operations maintain the non-domination property among
|
/// archive insert/extend operations maintain the non-domination property among
|
||||||
/// members; `truncate` enforces a maximum size by simple tail-truncation in
|
/// members; `truncate` enforces a maximum size by simple tail-truncation in
|
||||||
/// v1.
|
/// v1.
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let s = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// let mut a: ParetoArchive<u32> = ParetoArchive::new(s);
|
|
||||||
/// a.insert(Candidate::new(1, Evaluation::new(vec![1.0, 4.0])));
|
|
||||||
/// a.insert(Candidate::new(2, Evaluation::new(vec![3.0, 2.0])));
|
|
||||||
/// // Dominated by both — should be discarded:
|
|
||||||
/// a.insert(Candidate::new(3, Evaluation::new(vec![5.0, 5.0])));
|
|
||||||
/// assert_eq!(a.members().len(), 2);
|
|
||||||
/// ```
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ParetoArchive<D> {
|
pub struct ParetoArchive<D> {
|
||||||
/// The current approximate non-dominated set.
|
/// The current approximate non-dominated set.
|
||||||
@@ -265,68 +248,4 @@ mod tests {
|
|||||||
a.extend(vec![cand(1, vec![1.0, 4.0]), cand(2, vec![3.0, 2.0])]);
|
a.extend(vec![cand(1, vec![1.0, 4.0]), cand(2, vec![3.0, 2.0])]);
|
||||||
assert_eq!(a.members().len(), 2);
|
assert_eq!(a.members().len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `truncate` keeps the archive untouched when it is already at or
|
|
||||||
/// below `max_size`, and trims it when over. Pins the `>` boundary.
|
|
||||||
#[test]
|
|
||||||
fn truncate_boundary_behavior() {
|
|
||||||
let mut a = ParetoArchive::<u32>::new(space_min2());
|
|
||||||
// Three mutually non-dominated members.
|
|
||||||
a.insert(cand(1, vec![1.0, 3.0]));
|
|
||||||
a.insert(cand(2, vec![2.0, 2.0]));
|
|
||||||
a.insert(cand(3, vec![3.0, 1.0]));
|
|
||||||
assert_eq!(a.members().len(), 3);
|
|
||||||
// max_size == len → no-op (kills `>` → `>=`).
|
|
||||||
a.truncate(3);
|
|
||||||
assert_eq!(a.members().len(), 3);
|
|
||||||
// max_size > len → no-op.
|
|
||||||
a.truncate(10);
|
|
||||||
assert_eq!(a.members().len(), 3);
|
|
||||||
// max_size < len → trims.
|
|
||||||
a.truncate(2);
|
|
||||||
assert_eq!(a.members().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A trade-off candidate (better on one axis, worse on the other) is
|
|
||||||
/// neither dominated nor dominating — it must be *added* alongside the
|
|
||||||
/// existing member. Pins the per-axis `<` / `>` scan in both
|
|
||||||
/// `member_dominates_or_equals` and `candidate_dominates_member`.
|
|
||||||
#[test]
|
|
||||||
fn trade_off_candidate_is_kept_alongside() {
|
|
||||||
let mut a = ParetoArchive::<u32>::new(space_min2());
|
|
||||||
a.insert(cand(1, vec![1.0, 5.0]));
|
|
||||||
a.insert(cand(2, vec![5.0, 1.0])); // trade-off — must be kept
|
|
||||||
assert_eq!(a.members().len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An equal-objectives candidate is rejected (a member dominates-or-
|
|
||||||
/// equals it). Pins the Equal branch — distinguishes `<=` from `<` in
|
|
||||||
/// `candidate_dominates_member` and the `<=` in
|
|
||||||
/// `member_dominates_or_equals`'s infeasible branch.
|
|
||||||
#[test]
|
|
||||||
fn equal_candidate_is_rejected() {
|
|
||||||
let mut a = ParetoArchive::<u32>::new(space_min2());
|
|
||||||
a.insert(cand(1, vec![2.0, 2.0]));
|
|
||||||
a.insert(cand(2, vec![2.0, 2.0])); // identical objectives → rejected
|
|
||||||
assert_eq!(a.members().len(), 1);
|
|
||||||
assert_eq!(a.members()[0].decision, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Two infeasible candidates: the one with smaller constraint violation
|
|
||||||
/// wins. Pins the `<` / `<=` in the infeasible branches.
|
|
||||||
#[test]
|
|
||||||
fn infeasible_candidate_with_smaller_violation_evicts_larger() {
|
|
||||||
let mut a = ParetoArchive::<u32>::new(space_min2());
|
|
||||||
a.insert(Candidate::new(
|
|
||||||
1u32,
|
|
||||||
Evaluation::constrained(vec![0.0, 0.0], 1.0),
|
|
||||||
));
|
|
||||||
// Smaller violation → dominates the existing infeasible member.
|
|
||||||
a.insert(Candidate::new(
|
|
||||||
2u32,
|
|
||||||
Evaluation::constrained(vec![9.0, 9.0], 0.5),
|
|
||||||
));
|
|
||||||
assert_eq!(a.members().len(), 1);
|
|
||||||
assert_eq!(a.members()[0].decision, 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-77
@@ -11,28 +11,6 @@ use crate::core::objective::ObjectiveSpace;
|
|||||||
/// `f64::INFINITY`. If the front has 0 entries an empty vector is returned;
|
/// `f64::INFINITY`. If the front has 0 entries an empty vector is returned;
|
||||||
/// 1 or 2 entries return all `f64::INFINITY`. All comparisons happen on
|
/// 1 or 2 entries return all `f64::INFINITY`. All comparisons happen on
|
||||||
/// minimization-oriented objective values (spec §9.6).
|
/// minimization-oriented objective values (spec §9.6).
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
///
|
|
||||||
/// ```
|
|
||||||
/// use heuropt::prelude::*;
|
|
||||||
///
|
|
||||||
/// let s = ObjectiveSpace::new(vec![
|
|
||||||
/// Objective::minimize("f1"),
|
|
||||||
/// Objective::minimize("f2"),
|
|
||||||
/// ]);
|
|
||||||
/// // Three points along a Pareto-like trade-off; the interior point gets
|
|
||||||
/// // a finite crowding distance, the boundaries get +∞.
|
|
||||||
/// let pop = [
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![0.0, 4.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![2.0, 2.0])),
|
|
||||||
/// Candidate::new((), Evaluation::new(vec![4.0, 0.0])),
|
|
||||||
/// ];
|
|
||||||
/// let d = crowding_distance(&pop, &[0, 1, 2], &s);
|
|
||||||
/// assert!(d[0].is_infinite());
|
|
||||||
/// assert!(d[1].is_finite() && d[1] > 0.0);
|
|
||||||
/// assert!(d[2].is_infinite());
|
|
||||||
/// ```
|
|
||||||
pub fn crowding_distance<D>(
|
pub fn crowding_distance<D>(
|
||||||
population: &[Candidate<D>],
|
population: &[Candidate<D>],
|
||||||
front: &[usize],
|
front: &[usize],
|
||||||
@@ -55,35 +33,33 @@ pub fn crowding_distance<D>(
|
|||||||
.map(|&idx| objectives.as_minimization(&population[idx].evaluation.objectives))
|
.map(|&idx| objectives.as_minimization(&population[idx].evaluation.objectives))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Reused across objectives: (objective-k value, front position). Sorting
|
|
||||||
// these tuples directly keeps the hot comparator a single `f64` compare
|
|
||||||
// instead of chasing two `Vec<Vec<f64>>` indirections per comparison.
|
|
||||||
let mut keyed: Vec<(f64, usize)> = Vec::with_capacity(n);
|
|
||||||
|
|
||||||
#[allow(clippy::needless_range_loop)] // `k` indexes into nested vectors below.
|
#[allow(clippy::needless_range_loop)] // `k` indexes into nested vectors below.
|
||||||
for k in 0..m {
|
for k in 0..m {
|
||||||
keyed.clear();
|
// Sort indices into `front` by objective k.
|
||||||
keyed.extend((0..n).map(|i| (oriented[i][k], i)));
|
let mut order: Vec<usize> = (0..n).collect();
|
||||||
keyed.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
order.sort_by(|&a, &b| {
|
||||||
|
oriented[a][k]
|
||||||
|
.partial_cmp(&oriented[b][k])
|
||||||
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
|
});
|
||||||
|
|
||||||
let first = keyed[0].1;
|
distance[order[0]] = f64::INFINITY;
|
||||||
let last = keyed[n - 1].1;
|
distance[order[n - 1]] = f64::INFINITY;
|
||||||
distance[first] = f64::INFINITY;
|
|
||||||
distance[last] = f64::INFINITY;
|
|
||||||
|
|
||||||
let span = keyed[n - 1].0 - keyed[0].0;
|
let f_min = oriented[order[0]][k];
|
||||||
|
let f_max = oriented[order[n - 1]][k];
|
||||||
|
let span = f_max - f_min;
|
||||||
if span == 0.0 {
|
if span == 0.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
for i in 1..n - 1 {
|
for i in 1..n - 1 {
|
||||||
let idx = keyed[i].1;
|
if distance[order[i]] == f64::INFINITY {
|
||||||
if distance[idx] == f64::INFINITY {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let prev = keyed[i - 1].0;
|
let prev = oriented[order[i - 1]][k];
|
||||||
let next = keyed[i + 1].0;
|
let next = oriented[order[i + 1]][k];
|
||||||
distance[idx] += (next - prev) / span;
|
distance[order[i]] += (next - prev) / span;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,41 +138,4 @@ mod tests {
|
|||||||
assert!(d[2].is_infinite());
|
assert!(d[2].is_infinite());
|
||||||
assert!(d[1].is_finite());
|
assert!(d[1].is_finite());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crowding distance pins the exact interior contribution: for a 3-point
|
|
||||||
/// 2-objective front, the middle point's distance is the sum over both
|
|
||||||
/// objectives of (next - prev) / span. With evenly-spaced points the
|
|
||||||
/// value is exactly 2.0 (1.0 per objective).
|
|
||||||
#[test]
|
|
||||||
fn interior_point_distance_is_pinned() {
|
|
||||||
let s = space_min2();
|
|
||||||
// Front along the line f1 + f2 = 4: (0,4), (2,2), (4,0).
|
|
||||||
let pop = [
|
|
||||||
cand(vec![0.0, 4.0]),
|
|
||||||
cand(vec![2.0, 2.0]),
|
|
||||||
cand(vec![4.0, 0.0]),
|
|
||||||
];
|
|
||||||
let d = crowding_distance(&pop, &[0, 1, 2], &s);
|
|
||||||
// Boundary points are infinite; the middle point gets
|
|
||||||
// (4-0)/4 + (4-0)/4 = 2.0 (objective 0 span 4, objective 1 span 4).
|
|
||||||
assert!(d[0].is_infinite());
|
|
||||||
assert!(d[2].is_infinite());
|
|
||||||
assert!((d[1] - 2.0).abs() < 1e-12, "interior distance = {}", d[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An asymmetric front pins the per-objective `(next - prev) / span`
|
|
||||||
/// arithmetic: catches the `-` ↔ `+`/`/` and `/` ↔ `*` mutants.
|
|
||||||
#[test]
|
|
||||||
fn asymmetric_interior_distance_is_pinned() {
|
|
||||||
let s = space_min2();
|
|
||||||
// (0,10), (1,2), (10,0): objective-0 span = 10, objective-1 span = 10.
|
|
||||||
let pop = [
|
|
||||||
cand(vec![0.0, 10.0]),
|
|
||||||
cand(vec![1.0, 2.0]),
|
|
||||||
cand(vec![10.0, 0.0]),
|
|
||||||
];
|
|
||||||
let d = crowding_distance(&pop, &[0, 1, 2], &s);
|
|
||||||
// middle point: obj0 (10-0)/10 = 1.0; obj1 (10-0)/10 = 1.0 → 2.0.
|
|
||||||
assert!((d[1] - 2.0).abs() < 1e-12, "got {}", d[1]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user