Author SHA1 Message Date
swaits 8cf518200a feat(heuropt-plot): v0.1.0 — SVG visualization companion crate
Adds heuropt-plot, a tiny SVG-only plotter that takes heuropt
results and emits scatter plots (pareto_front_svg) and line plots
(convergence_svg). No heavy 'plotters' or 'tiny-skia' dep — hand-
rolled SVG so the crate adds <100 KB to a build.

Workspace setup: root Cargo.toml gains [workspace] with members =
['.', 'heuropt-plot']. heuropt-plot has its own version (0.1.0) and
publishes independently against heuropt 0.7+.

Adds examples/visualize.rs that wires it all up: NSGA-II on Schaffer
N.1, observer closure recording per-generation hypervolume, two SVGs
written to disk.
2026-05-05 15:26:22 -06:00
swaits 41122b7d48 feat: v0.7.0 — async evaluation
Theme: async/await for IO-bound evaluations. Adds the differentiating
capability vs pymoo / hyperopt / MOEA Framework, none of which ship
first-class async support.

No public-API breaks for synchronous users — the new surface is gated
behind a new `async` feature flag.

Adds:
- core::async_problem::AsyncProblem trait (async fn evaluate_async)
- async fn run_async on RandomSearch and DifferentialEvolution; other
  algorithms follow incrementally
- algorithms::parallel_eval_async::evaluate_batch_async helper using
  futures::stream::FuturesOrdered with concurrency-bounded chunks
- examples/async_eval.rs worked example with simulated 20 ms remote
  service: concurrency=1 → 4.2 s, concurrency=4 → 2.1 s (2× speedup)

Bumps Cargo.toml to 0.7.0; CHANGELOG entry covers the above. Existing
247 unit + 38 doctest tests all pass; no async tests yet (deferred to
a v0.7.x patch with tokio dev-deps wired in).
2026-05-05 15:22:30 -06:00
swaits b0f580841d feat: v0.6.0 — observer / stop-conditions / tracing / IGD / R2
Theme: production lifecycle. heuropt becomes deployable for long-
running, real-world workloads. No breaking changes — Optimizer trait
gains a default-impl run_with method that falls back to run.

Adds:
- src/observer/ module: Snapshot, Observer trait, ControlFlow, plus
  built-in MaxTime / MaxIterations / TargetFitness / Stagnation /
  Periodic / AnyOf / AllOf and a closure impl.
- Optimizer::run_with(problem, observer): default-impl on the trait,
  overridden for full per-gen visibility on Nsga2, RandomSearch, and
  DifferentialEvolution. Other algorithms inherit a final-only
  notification — full per-gen support follows incrementally.
- New 'tracing' optional feature plus TracingObserver that emits
  structured debug! events per generation.
- src/metrics/igd.rs: IGD + IGD+ performance indicators against a
  reference set.
- src/metrics/r2.rs: R2 indicator using the weighted Tchebycheff
  utility; pair with das_dennis for the canonical weight set.
- examples/constrained.rs: BNH constrained 2-objective problem
  solved with NSGA-II + observer composition (MaxTime.or(Periodic)).

Bumps Cargo.toml to 0.6.0; CHANGELOG entry consolidates the above.
Existing 247 unit + 38 doctest + 32 algorithm-property + property /
metric / numerical-stability tests all pass; bit-identical compare
output verified post-DE refactor.
2026-05-05 15:07:05 -06:00
114 changed files with 4757 additions and 19430 deletions
+2 -42
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
{"sessionId":"ac44d107-52ca-4cd4-9586-ae2fe91bc9f7","pid":2366937,"procStart":"77336928","acquiredAt":1778002505967}
+4 -4
View File
@@ -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.
-7
View File
@@ -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:
-7
View File
@@ -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
+99 -297
View File
@@ -7,319 +7,121 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.11.0] — 2026-05-14
Theme: a full permutation-operator toolkit, plus two sweeping
performance passes. The first is a micro-benchmark-guided pass over
the combinatorial operators and the Pareto/metrics machinery; the
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
operators, plus internal-only performance work.
### Added ### Added
- A full permutation crossover/mutation toolkit in - **`heuropt-plot` companion crate (v0.1.0)** at `heuropt-plot/`,
`heuropt::operators`, all re-exported from the prelude: published independently. Lightweight SVG-only plotter for Pareto
`OrderCrossover` (OX), `PartiallyMappedCrossover` (PMX), fronts (`pareto_front_svg`) and convergence traces
`CycleCrossover` (CX), and `EdgeRecombinationCrossover` (ERX) (`convergence_svg`) — hand-rolled SVG output, no `plotters` /
crossovers, and `InversionMutation`, `InsertionMutation`, and `tiny-skia` dep so the crate stays a tiny optional addition.
`ScrambleMutation` mutations — joining the pre-existing - `examples/visualize.rs` — runs NSGA-II on Schaffer N.1 with a
`SwapMutation`. The mutations preserve both strict permutations and closure observer that records hypervolume per generation, then
multisets. emits `pareto_front.svg` + `convergence.svg` via `heuropt-plot`.
- 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 ## [0.7.0] — 2026-05-05
All changes below are bit-identical — outputs are byte-for-byte Theme: async evaluation. heuropt now supports problems where each
unchanged, verified by the per-algorithm snapshot tests. evaluation is a `.await`-able operation — HTTP services, RPC clients,
spawned subprocesses. This is the differentiating capability vs.
- **Whole-program profiling campaign.** Profiling the full `compare` pymoo / hyperopt / MOEA Framework, none of which ship first-class
workload under callgrind cut its instruction count from 357.06B to async support.
165.31B (53.7%):
- `pareto_compare` is now allocation-free — it no longer
materializes two minimization-oriented `Vec<f64>`s per call. This
alone was 38%, the single biggest win.
- `pareto_front` precomputes its oriented buffers once and skips
candidates already known to be dominated.
- `ibea` pre-exponentiates its indicator matrix, turning the
survival loop's `exp` sweep into plain additions.
- `hype` reuses its per-Monte-Carlo-sample scratch buffer instead
of reallocating it thousands of times per call.
- `age_moea` scores only the splitting front rather than the whole
combined population.
- **Combinatorial-operator pass.** `CycleCrossover`,
`PartiallyMappedCrossover`, and `OrderCrossover` are now O(n) via
position-index tables; `EdgeRecombinationCrossover` removes edges
in O(degree) per step.
- **Pareto / metrics pass.** `non_dominated_sort` halves its
dominance comparisons and reads from a flattened objective buffer;
`crowding_distance` sorts without `Vec<Vec<f64>>` indirection;
`hypervolume_nd` no longer re-sorts prefixes per slice.
- **Algorithm hot paths.** `ant_colony_tsp` hoists `powf` out of its
tour-building loop; `tpe` computes KDE bandwidths once per
iteration instead of once per call; `bayesian_opt` reuses scratch
buffers in the expected-improvement acquisition loop.
### Changed
- Documentation now recommends MOEA/D as the default multi- and
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
- 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 50200+
candidates spanning 27+ 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 No public-API breaks for synchronous users. The new surface is
gated behind a new `async` feature flag. gated behind a new `async` feature flag.
### Added ### Added
#### Async evaluation (the headline feature)
- New optional feature `async`, gated on - New optional feature `async`, gated on
[`futures`](https://crates.io/crates/futures). [`futures`](https://crates.io/crates/futures).
- `core::async_problem::AsyncProblem` trait — mirrors `Problem` but - `core::async_problem::AsyncProblem` trait — mirrors `Problem` but
with `async fn evaluate_async(&self, decision)`. Adapt an with `async fn evaluate_async(&self, decision)`. Adapt an
existing sync `Problem` with a one-line wrapper. 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 - Per-algorithm `run_async(&problem, concurrency).await` methods on
**every** algorithm in the catalog — all 33 of them — driving `RandomSearch` and `DifferentialEvolution` — drives evaluations
evaluations through whichever async runtime the caller is using through whichever async runtime the caller is using (typically
(typically tokio). `concurrency` bounds in-flight evaluations. 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` - Internal `algorithms::parallel_eval_async::evaluate_batch_async`
and `evaluate_batch_at_budget_async` helpers — use helper — uses `futures::stream::FuturesOrdered` with concurrency-
`futures::stream::FuturesOrdered` with concurrency-bounded chunks, bounded chunks, preserves input order so seeded determinism is
preserve input order so seeded determinism is preserved when preserved when evaluations are themselves deterministic.
evaluations are themselves deterministic.
- `examples/async_eval.rs` — worked example with a simulated 20 ms - `examples/async_eval.rs` — worked example with a simulated 20 ms
remote service. At concurrency = 1 it's serial; at concurrency = 4 remote service. At concurrency = 1 it's serial; at concurrency = 4
it's 2× faster; demonstrates `DifferentialEvolution` under tokio. it's 2× faster; demonstrates DifferentialEvolution under tokio.
#### Documentation [0.7.0]: https://github.com/swaits/heuropt/releases/tag/v0.7.0
- New cookbook recipe **[Async evaluation](docs/book/src/cookbook/async.md)** ## [0.6.0] — 2026-05-05
— 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 Theme: production lifecycle. heuropt becomes deployable for long-
running, real-world optimization workloads — callbacks, stop
conditions, tracing, and two new performance indicators.
- `.github/workflows/docs.yml` builds the mdbook user guide on No breaking changes to the public API. Existing `Optimizer<P>` impls
every push and deploys to GitHub Pages on `main` / keep compiling — `run_with` is added as a default-impl method that
tag pushes. falls back to `run` plus a single final notification.
- `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 ### Added
#### Observer + stop-conditions API
A new module `heuropt::observer` introduces:
- `Snapshot<'a, D>` — per-generation observation payload with
`iteration`, `evaluations`, `elapsed`, `population`,
`pareto_front`, `best`, and `objectives`.
- `Observer<D>` trait — single method `observe(&Snapshot) ->
ControlFlow<()>`. Closures of the right shape implement it
automatically. `()` is the no-op observer.
- `Optimizer::run_with(problem, observer)` — new method on the
`Optimizer` trait with a default impl that falls back to `run`.
Algorithms that override `run_with` (so far: `Nsga2`,
`RandomSearch`, `DifferentialEvolution`) call the observer once
per generation; others call it once at the end. Returning
`ControlFlow::Break` halts the optimizer and returns the partial
result.
#### Built-in observers (`observer::builtin`)
- `MaxTime(Duration)` — wall-clock cap.
- `MaxIterations(usize)` — generation cap.
- `TargetFitness(f64)` — direction-aware single-objective target.
- `Stagnation { window, tolerance }` — halt when the best fitness
hasn't improved by `tolerance` over `window` generations.
- `Periodic::new(every, |snap| { … })` — call a user closure every
`every` generations.
- `AnyOf` / `AllOf` plus `Observer::or` / `Observer::and` for
composition.
- `TracingObserver` (behind the new `tracing` feature) — emits
structured `debug!` events per generation.
#### Tracing feature
New optional feature `tracing`, gated on the
[`tracing`](https://crates.io/crates/tracing) crate. Adds
`TracingObserver` to the prelude when enabled.
#### Performance indicators
- `metrics::igd::igd` — Inverted Generational Distance against a
reference set (typically the true Pareto front).
- `metrics::igd::igd_plus` — Pareto-compliant IGD+ variant; adding
a dominated point never improves the score.
- `metrics::r2::r2` — R2 indicator using the weighted Tchebycheff
utility. Pair with `pareto::das_dennis` for the canonical weight
set.
#### Constrained example
`examples/constrained.rs` — solves the BNH constrained 2-objective
problem (Binh & Korn 1996) with NSGA-II + the new observer API,
demonstrating `Periodic` progress logging and `MaxTime` /
composition.
### Changed
- `Population::as_slice()` — new convenience accessor.
[0.6.0]: https://github.com/swaits/heuropt/releases/tag/v0.6.0
## [0.5.0] — 2026-05-05 ## [0.5.0] — 2026-05-05
@@ -405,7 +207,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 +345,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 +424,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 +490,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 +516,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 +574,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 +585,5 @@ Initial release.
`RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay `RandomSearch`, `Nsga2`, and `DifferentialEvolution`. Seeded runs stay
bit-identical to serial mode. bit-identical to serial mode.
[Unreleased]: https://git.swaits.com/swaits/heuropt/compare/v0.10.0...main [Unreleased]: https://github.com/swaits/heuropt/compare/v0.7.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
+10 -13
View File
@@ -1,22 +1,26 @@
[workspace]
members = [".", "heuropt-plot"]
[package] [package]
name = "heuropt" name = "heuropt"
version = "0.11.1" version = "0.7.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"] authors = ["Stephen Waits <steve@waits.net>"]
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"]
tracing = ["dep:tracing"]
async = ["dep:futures"] async = ["dep:futures"]
[dependencies] [dependencies]
@@ -25,10 +29,11 @@ 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"
heuropt-plot = { path = "heuropt-plot" }
proptest = "1" proptest = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
@@ -36,18 +41,10 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
name = "hot_paths" name = "hot_paths"
harness = false harness = false
[[bench]]
name = "compare_profile"
harness = false
[[example]] [[example]]
name = "async_eval" name = "async_eval"
required-features = ["async"] 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.
+171 -313
View File
@@ -4,213 +4,88 @@
[![Documentation](https://docs.rs/heuropt/badge.svg)](https://docs.rs/heuropt) [![Documentation](https://docs.rs/heuropt/badge.svg)](https://docs.rs/heuropt)
[![Book](https://img.shields.io/badge/book-online-blue.svg)](https://swaits.github.io/heuropt/) [![Book](https://img.shields.io/badge/book-online-blue.svg)](https://swaits.github.io/heuropt/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![CI](https://git.swaits.com/swaits/heuropt/actions/workflows/ci.yml/badge.svg)](https://git.swaits.com/swaits/heuropt/actions?workflow=ci.yml) [![CI](https://github.com/swaits/heuropt/actions/workflows/ci.yml/badge.svg)](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.06.0 L), **curb weight** (11002200 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
DasDennis 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 (50500 evals):** Bayesian Optimization (Gaussian-process - **Expensive (50500 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 210
│ │ 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 (23) and many-objective (4+):** **Multi-objective (23) 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` | 23 | 1+1 ES with Pareto archive |
| **NSGA-II** | 23 | canonical Pareto-based EA; well-understood, the go-to for combinatorial encodings — but fades past ~4 objectives | | `Nsga2` | 23 | canonical Pareto-based EA |
| **MOPSO** | 23 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts | | `Spea2` | 23 | strength + density |
| **IBEA** | 2+ | indicator-based; consistently best of the dominance-based methods; wins disconnected fronts | | `Mopso` | 23 | multi-objective PSO; best convergence on smooth real-valued 2-obj fronts |
| **SPEA2** | 23 | 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** | 23 | 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
View File
@@ -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.
-47
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+26
View File
@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450" font-family="system-ui, sans-serif" font-size="12">
<rect x="0" y="0" width="700" height="450" fill="white"/>
<text x="70" y="22" font-size="16" font-weight="bold">NSGA-II on Schaffer N.1 — hypervolume per generation</text>
<rect x="70" y="40" width="610" height="360" fill="none" stroke="#888" />
<line x1="70" y1="400" x2="70" y2="405" stroke="#888" />
<text x="70" y="418" text-anchor="middle">0</text>
<line x1="222.5" y1="400" x2="222.5" y2="405" stroke="#888" />
<text x="222.5" y="418" text-anchor="middle">25</text>
<line x1="375" y1="400" x2="375" y2="405" stroke="#888" />
<text x="375" y="418" text-anchor="middle">50</text>
<line x1="527.5" y1="400" x2="527.5" y2="405" stroke="#888" />
<text x="527.5" y="418" text-anchor="middle">75</text>
<line x1="680" y1="400" x2="680" y2="405" stroke="#888" />
<text x="680" y="418" text-anchor="middle">100</text>
<line x1="65" y1="400" x2="70" y2="400" stroke="#888" />
<text x="62" y="400" text-anchor="end" dominant-baseline="middle">9.552e1</text>
<line x1="65" y1="279.999999999999" x2="70" y2="279.999999999999" stroke="#888" />
<text x="62" y="279.999999999999" text-anchor="end" dominant-baseline="middle">9.608e1</text>
<line x1="65" y1="160.00000000000102" x2="70" y2="160.00000000000102" stroke="#888" />
<text x="62" y="160.00000000000102" text-anchor="end" dominant-baseline="middle">9.664e1</text>
<line x1="65" y1="40" x2="70" y2="40" stroke="#888" />
<text x="62" y="40" text-anchor="end" dominant-baseline="middle">9.721e1</text>
<text x="375" y="438" text-anchor="middle">generation</text>
<text x="15" y="220" text-anchor="middle" transform="rotate(-90 15 220)">hypervolume</text>
<polyline points="70.00,400.00 76.10,113.85 82.20,59.99 88.30,44.22 94.40,45.84 100.50,47.93 106.60,45.55 112.70,40.78 118.80,43.34 124.90,41.17 131.00,42.71 137.10,44.97 143.20,43.62 149.30,43.69 155.40,46.67 161.50,40.97 167.60,41.76 173.70,41.27 179.80,46.13 185.90,42.18 192.00,44.17 198.10,42.62 204.20,41.90 210.30,42.69 216.40,40.00 222.50,42.66 228.60,48.64 234.70,47.22 240.80,42.14 246.90,45.35 253.00,48.46 259.10,42.98 265.20,44.96 271.30,44.55 277.40,44.30 283.50,43.54 289.60,43.88 295.70,42.70 301.80,44.10 307.90,46.88 314.00,46.56 320.10,42.39 326.20,44.45 332.30,45.03 338.40,46.44 344.50,44.44 350.60,45.79 356.70,49.23 362.80,42.47 368.90,44.42 375.00,50.51 381.10,44.45 387.20,42.54 393.30,42.50 399.40,42.83 405.50,40.56 411.60,49.78 417.70,47.03 423.80,44.17 429.90,45.73 436.00,42.66 442.10,44.31 448.20,49.69 454.30,41.19 460.40,41.14 466.50,41.87 472.60,41.88 478.70,41.41 484.80,43.24 490.90,45.61 497.00,45.55 503.10,45.16 509.20,46.10 515.30,42.66 521.40,42.46 527.50,48.78 533.60,46.56 539.70,42.49 545.80,43.23 551.90,47.51 558.00,44.28 564.10,45.05 570.20,43.75 576.30,44.15 582.40,42.29 588.50,41.54 594.60,43.25 600.70,41.56 606.80,48.07 612.90,42.87 619.00,42.68 625.10,43.69 631.20,45.47 637.30,42.93 643.40,40.77 649.50,42.53 655.60,42.89 661.70,44.33 667.80,41.27 673.90,44.06 680.00,41.55" fill="none" stroke="#1f77b4" stroke-width="1.5" />
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+73
View File
@@ -0,0 +1,73 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 700 450" font-family="system-ui, sans-serif" font-size="12">
<rect x="0" y="0" width="700" height="450" fill="white"/>
<text x="60" y="22" font-size="16" font-weight="bold">NSGA-II on Schaffer N.1 — final Pareto front</text>
<rect x="60" y="40" width="620" height="360" fill="none" stroke="#888" />
<line x1="60" y1="400" x2="60" y2="405" stroke="#888" />
<text x="60" y="418" text-anchor="middle">0.000</text>
<line x1="266.66666666666663" y1="400" x2="266.66666666666663" y2="405" stroke="#888" />
<text x="266.66666666666663" y="418" text-anchor="middle">1.333</text>
<line x1="473.3333333333333" y1="400" x2="473.3333333333333" y2="405" stroke="#888" />
<text x="473.3333333333333" y="418" text-anchor="middle">2.667</text>
<line x1="680" y1="400" x2="680" y2="405" stroke="#888" />
<text x="680" y="418" text-anchor="middle">4.000</text>
<line x1="55" y1="400" x2="60" y2="400" stroke="#888" />
<text x="52" y="400" text-anchor="end" dominant-baseline="middle">0.000</text>
<line x1="55" y1="280" x2="60" y2="280" stroke="#888" />
<text x="52" y="280" text-anchor="end" dominant-baseline="middle">1.333</text>
<line x1="55" y1="160" x2="60" y2="160" stroke="#888" />
<text x="52" y="160" text-anchor="end" dominant-baseline="middle">2.666</text>
<line x1="55" y1="40" x2="60" y2="40" stroke="#888" />
<text x="52" y="40" text-anchor="end" dominant-baseline="middle">3.999</text>
<text x="370" y="438" text-anchor="middle">f1</text>
<text x="15" y="220" text-anchor="middle" transform="rotate(-90 15 220)">f2</text>
<circle cx="680.00" cy="400.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="60.00" cy="40.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="544.53" cy="395.16" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="596.60" cy="398.25" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="147.76" cy="259.91" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="77.82" cy="151.68" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="166.09" cy="276.21" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="512.44" cy="392.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="457.04" cy="385.63" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="122.47" cy="232.25" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="133.03" cy="244.68" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="92.63" cy="186.20" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="277.90" cy="340.31" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="329.99" cy="358.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="86.30" cy="173.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="83.00" cy="165.28" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="479.72" cy="388.69" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="71.88" cy="132.73" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="177.45" cy="285.16" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="60.12" cy="49.89" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="139.30" cy="251.43" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="228.69" cy="317.60" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="661.27" cy="399.92" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="247.24" cy="326.94" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="97.14" cy="194.62" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="291.01" cy="345.35" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="188.72" cy="293.31" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="237.44" cy="322.14" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="62.32" cy="82.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="400.75" cy="375.91" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="314.75" cy="353.60" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="206.51" cy="304.92" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="436.65" cy="382.48" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="263.18" cy="334.19" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="344.89" cy="362.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="67.81" cy="116.23" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="61.00" cy="68.34" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="60.73" cy="64.24" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="641.54" cy="399.64" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="115.80" cy="223.58" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="216.38" cy="310.79" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="169.95" cy="279.34" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="384.40" cy="372.44" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="65.45" cy="104.28" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="633.74" cy="399.48" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="64.21" cy="96.82" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="107.71" cy="212.00" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="414.47" cy="378.59" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="616.88" cy="399.02" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
<circle cx="419.20" cy="379.46" r="3" fill="#1f77b4" stroke="#0d4a8a" stroke-width="0.5" />
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

-3
View File
@@ -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
+100 -163
View File
@@ -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 23-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
View File
@@ -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).
+4 -16
View File
@@ -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 50500-eval `BayesianOpt`, `Tpe`, and `Hyperband` for the 50500-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.
-165
View File
@@ -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
+2 -5
View File
@@ -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
-191
View File
@@ -1,191 +0,0 @@
# Explore your results in a webapp
Real Pareto fronts have 50200+ candidates spanning 27+ 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
(LustTeghem 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 ZitzlerThiele
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 DasDennis
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
+29 -39
View File
@@ -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
+52 -332
View File
@@ -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(); use rand::seq::SliceRandom;
let problem = JobShop { process_times: times }; let mut rng = rng_from_seed(seed);
let mut perm: Vec<usize> = (0..n).collect();
perm.shuffle(&mut rng);
perm
}
// SimulatedAnnealing expects exactly one initial decision. let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
struct OneShuffle { n: usize } let problem = JobShop { process_times: times.clone() };
impl Initializer<Vec<usize>> for OneShuffle {
fn initialize(&mut self, _size: usize, rng: &mut Rng) -> Vec<Vec<usize>> { // SimulatedAnnealing needs a starting decision; pass a custom Initializer.
use rand::seq::SliceRandom; struct OnePerm(Vec<usize>);
let mut p: Vec<usize> = (0..self.n).collect(); impl Initializer<Vec<usize>> for OnePerm {
p.shuffle(rng); fn initialize(&mut self, _size: usize, _rng: &mut Rng) -> Vec<Vec<usize>> {
vec![p] 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
+15 -15
View File
@@ -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
+51 -93
View File
@@ -6,141 +6,99 @@ 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
CmaEsConfig { strong default. Configure it, build it, run it.
population_size: 12,
generations: 80,
initial_sigma: 1.0,
eigen_decomposition_period: 1,
initial_mean: None,
seed: 42,
},
bounds,
);
let result = opt.run(&problem); ```rust,no_run
let best = result.best.expect("at least one feasible candidate"); # use heuropt::prelude::*;
let (slope, intercept) = (best.decision[0], best.decision[1]); # struct Sphere;
println!( # impl Problem for Sphere {
"best fit: y = {:.4} x + {:.4} (sse = {:.4e}, evaluations = {})", # type Decision = Vec<f64>;
slope, intercept, best.evaluation.objectives[0], result.evaluations, # 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
println!(); let mut opt = CmaEs::new(
println!("predictions vs actual:"); CmaEsConfig {
for (px, py) in &problem.points { population_size: 12,
let pred = slope * px + intercept; generations: 80,
println!( initial_sigma: 1.0,
" x = {:.1} actual = {:.2} predicted = {:.4} residual = {:+.4}", eigen_decomposition_period: 1,
px, py, pred, py - pred, initial_mean: None,
); seed: 42,
} },
} bounds,
);
let result = opt.run(&Sphere);
let best = result.best.expect("at least one feasible candidate");
println!("best f = {:.3e} at x = {:?}", best.evaluation.objectives[0], best.decision);
``` ```
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
+15 -22
View File
@@ -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 (23): PAES, NSGA-II, SPEA2, MOPSO, IBEA, (any), `AntColonyTsp` (permutation).
SMS-EMOA, HypE, ε-MOEA, PESA-II, AGE-MOEA, KnEA, MOEA/D. - Multi-objective (23): `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, DasDennis reference utilities (dominance, fronts, crowding distance, DasDennis 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
View File
@@ -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
View File
@@ -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
-225
View File
@@ -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
);
}
+85 -276
View File
@@ -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.0v0.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 | algorithm | mean dist ↓ | spacing ↓ | front | ms |
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 |
|---|---|---|---|---| |---|---|---|---|---|
| **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 |
| PSO | 7.9598e-1 ± 8.67e-1 | 5 | | GA | 7.0913e-8 ± 5.50e-8 | 16 |
| CMA-ES | 2.3453e0 ± 1.49e0 | 10 | | PSO | 7.9598e-1 ± 8.67e-1 | 5 |
| SimulatedAnneal | 3.8540e0 ± 1.48e0 | 7 | | NSGA-II | 4.9270e-5 ± 5.04e-5 | 83 |
| RandomSearch | 1.1064e1 ± 2.54e0 | 14 | | **DE** | **0.0000e0 ± 0.00** | 6 |
| HillClimber | 1.5966e1 ± 6.25e0 | 6 | | CMA-ES | 2.3453e0 ± 1.49 | 11 |
| PAES | 1.5966e1 ± 6.25e0 | 10 | | **IPOP-CMA-ES** | 1.3423e-1 ± 2.71e-1 | 66 |
(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 | | DE | 3.3345e-1 ± 3.01e-1 | 2 |
| CMA-ES | 3.6207e-29 ± 2.35e-29 | 5 | | PSO | 8.2124e-1 ± 1.58e0 | 2 |
| TLBO | 1.8458e-3 ± 1.91e-3 | 1 | | **CMA-ES** | **3.6207e-29 ± 2.35e-29** | 5 |
| DE | 3.3345e-1 ± 3.01e-1 | 2 | | TLBO | 1.8458e-3 ± 1.91e-3 | 1 |
| PSO | 8.2124e-1 ± 1.58e0 | 2 | | (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 |
| (1+1)-ES | 2.2115e0 ± 2.70e0 | 1 | | **Nelder-Mead** | **0.0000e0 ± 0.00** | 1 |
| BO (60 evals) | 3.1725e3 ± 2.92e3 | 39 | | 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 `(151)!/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
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -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,
);
}
}
}
-212
View File
@@ -1,212 +0,0 @@
//! Solve a bi-objective extension of the FisherThompson 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
);
}
}
-269
View File
@@ -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],
);
}
}
-214
View File
@@ -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 — ZitzlerThiele 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
);
}
-167
View File
@@ -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.");
}
-263
View File
@@ -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
);
}
-171
View File
@@ -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]);
}
+89
View File
@@ -0,0 +1,89 @@
//! Visualize an NSGA-II run on Schaffer N.1 — produces two SVGs:
//! `pareto_front.svg` (scatter plot of the final front) and
//! `convergence.svg` (best-so-far hypervolume per generation).
//!
//! Uses the `heuropt-plot` companion crate plus the v0.6 observer
//! API (`Periodic`) to record per-generation hypervolume into a Vec
//! during the run.
//!
//! Run with: `cargo run --release --example visualize`
use std::cell::RefCell;
use std::ops::ControlFlow;
use heuropt::metrics::hypervolume_2d;
use heuropt::prelude::*;
use heuropt_plot::{convergence_svg, pareto_front_svg};
struct Schaffer;
impl Problem for Schaffer {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
Evaluation::new(vec![x[0] * x[0], (x[0] - 2.0).powi(2)])
}
}
fn main() {
let problem = Schaffer;
let bounds = vec![(-5.0_f64, 5.0_f64)];
let space = problem.objectives();
let ref_point = [10.0, 10.0];
// Per-generation hypervolume trace, recorded by the observer.
let history: RefCell<Vec<f64>> = RefCell::new(Vec::new());
let mut recorder = |snap: &Snapshot<'_, Vec<f64>>| -> ControlFlow<()> {
let hv = match snap.pareto_front {
Some(front) => hypervolume_2d(front, snap.objectives, ref_point),
None => 0.0,
};
history.borrow_mut().push(hv);
ControlFlow::Continue(())
};
let mut opt = Nsga2::new(
Nsga2Config {
population_size: 50,
generations: 100,
seed: 42,
},
RealBounds::new(bounds.clone()),
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 0.5),
mutation: PolynomialMutation::new(bounds, 20.0, 1.0),
},
);
let result = opt.run_with(&problem, &mut recorder);
let front_svg = pareto_front_svg(
&result.pareto_front,
&space,
700,
450,
"NSGA-II on Schaffer N.1 — final Pareto front",
);
std::fs::write("pareto_front.svg", front_svg).expect("write pareto_front.svg");
let trace = history.borrow();
let conv_svg = convergence_svg(
&trace,
700,
450,
"NSGA-II on Schaffer N.1 — hypervolume per generation",
"hypervolume",
false, // higher is better
);
std::fs::write("convergence.svg", conv_svg).expect("write convergence.svg");
println!("Final front size: {}", result.pareto_front.len());
println!(
"Final hypervolume: {:.4}",
trace.last().copied().unwrap_or(0.0)
);
println!("Wrote pareto_front.svg and convergence.svg");
}
+1 -1
View File
@@ -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",
+2 -9
View File
@@ -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}",
); );
} }
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "heuropt-plot"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
authors = ["Stephen Waits <steve@waits.net>"]
description = "Lightweight SVG visualization for heuropt Pareto fronts and convergence traces."
license = "MIT"
readme = "README.md"
repository = "https://github.com/swaits/heuropt"
homepage = "https://github.com/swaits/heuropt"
documentation = "https://docs.rs/heuropt-plot"
keywords = ["optimization", "pareto", "svg", "plotting", "heuropt"]
categories = ["algorithms", "visualization"]
[dependencies]
heuropt = { version = "0.7", path = ".." }
+46
View File
@@ -0,0 +1,46 @@
# heuropt-plot
[![Crates.io](https://img.shields.io/crates/v/heuropt-plot.svg)](https://crates.io/crates/heuropt-plot)
[![Documentation](https://docs.rs/heuropt-plot/badge.svg)](https://docs.rs/heuropt-plot)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](../LICENSE)
Lightweight SVG plotting helpers for [`heuropt`](https://crates.io/crates/heuropt)
results.
Hand-rolled SVG output (no `plotters`, no `tiny-skia`, no
heavyweight dependency) so adding `heuropt-plot` to your project
costs ~20 KB of compiled code.
## What's in the box
- `pareto_front_svg` — render a 2-objective Pareto front as an SVG
scatter plot with axes and labels.
- `convergence_svg` — render a "best fitness so far" trace as an
SVG line plot.
Output is a `String` of valid SVG. Write it to a file, embed it in
HTML, or pipe it to a browser.
## Example
```rust
use heuropt::prelude::*;
use heuropt_plot::pareto_front_svg;
let space = ObjectiveSpace::new(vec![
Objective::minimize("f1"),
Objective::minimize("f2"),
]);
let front = vec![
Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
];
let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
std::fs::write("front.svg", svg).unwrap();
```
## License
MIT — see [LICENSE](../LICENSE) at the repo root.
+368
View File
@@ -0,0 +1,368 @@
//! Lightweight SVG plotting helpers for `heuropt` results.
//!
//! Two core primitives:
//!
//! - [`pareto_front_svg`] — render a 2-objective Pareto front as an
//! SVG scatter plot with axes and labels.
//! - [`convergence_svg`] — render a per-generation "best-fitness so
//! far" trace as an SVG line plot.
//!
//! Hand-rolled SVG output (no `plotters` / `tiny-skia` dep) so the
//! crate stays a tiny optional dependency. Output is a `String` of
//! valid SVG — write it to a file, embed it in HTML, or pipe it to a
//! browser.
//!
//! # Example
//!
//! ```
//! use heuropt::prelude::*;
//! use heuropt_plot::pareto_front_svg;
//!
//! let space = ObjectiveSpace::new(vec![
//! Objective::minimize("f1"),
//! Objective::minimize("f2"),
//! ]);
//! let front = vec![
//! Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
//! Candidate::new((), Evaluation::new(vec![0.5, 0.5])),
//! Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
//! ];
//! let svg = pareto_front_svg(&front, &space, 600, 400, "Sample front");
//! assert!(svg.starts_with("<svg"));
//! assert!(svg.contains("</svg>"));
//! ```
use std::fmt::Write as _;
use heuropt::core::candidate::Candidate;
use heuropt::core::objective::ObjectiveSpace;
/// Render a 2-objective Pareto front as an SVG scatter plot.
///
/// `width` and `height` are the SVG viewport dimensions in pixels.
/// `title` is rendered at the top.
///
/// Points are plotted in minimization-oriented coordinates.
///
/// # Panics
///
/// If `objectives.len() != 2`.
pub fn pareto_front_svg<D>(
front: &[Candidate<D>],
objectives: &ObjectiveSpace,
width: u32,
height: u32,
title: &str,
) -> String {
assert_eq!(
objectives.len(),
2,
"pareto_front_svg requires exactly 2 objectives",
);
let oriented: Vec<[f64; 2]> = front
.iter()
.map(|c| {
let m = objectives.as_minimization(&c.evaluation.objectives);
[m[0], m[1]]
})
.collect();
let (xs_label, ys_label) = (
objectives.objectives[0].name.as_str(),
objectives.objectives[1].name.as_str(),
);
let (xmin, xmax) = bounds(oriented.iter().map(|p| p[0]));
let (ymin, ymax) = bounds(oriented.iter().map(|p| p[1]));
let xspan = (xmax - xmin).max(1e-12);
let yspan = (ymax - ymin).max(1e-12);
// Margins so axes/labels have room.
let m_left = 60.0_f64;
let m_right = 20.0_f64;
let m_top = 40.0_f64;
let m_bot = 50.0_f64;
let plot_w = width as f64 - m_left - m_right;
let plot_h = height as f64 - m_top - m_bot;
let to_x = |v: f64| m_left + (v - xmin) / xspan * plot_w;
// Y is inverted: lower minimization value → higher pixel.
let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
let mut out = String::new();
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
font-family=\"system-ui, sans-serif\" font-size=\"12\">",
);
let _ = writeln!(
out,
" <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
x = m_left,
title = escape_xml(title),
);
// Axes box.
let _ = writeln!(
out,
" <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
m_left, m_top, plot_w, plot_h,
);
// X-axis ticks (3 ticks).
for i in 0..=3 {
let t = i as f64 / 3.0;
let v = xmin + t * xspan;
let x = to_x(v);
let _ = writeln!(
out,
" <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
y0 = m_top + plot_h,
y1 = m_top + plot_h + 5.0,
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{v:.3}</text>",
y = m_top + plot_h + 18.0,
);
}
// Y-axis ticks.
for i in 0..=3 {
let t = i as f64 / 3.0;
let v = ymin + t * yspan;
let y = to_y(v);
let _ = writeln!(
out,
" <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
x0 = m_left - 5.0,
x1 = m_left,
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3}</text>",
x = m_left - 8.0,
);
}
// Axis labels.
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{xs_label}</text>",
x = m_left + plot_w / 2.0,
y = height as f64 - 12.0,
xs_label = escape_xml(xs_label),
);
let _ = writeln!(
out,
" <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
transform=\"rotate(-90 15 {y})\">{ys_label}</text>",
y = m_top + plot_h / 2.0,
ys_label = escape_xml(ys_label),
);
// Points.
for p in &oriented {
let cx = to_x(p[0]);
let cy = to_y(p[1]);
let _ = writeln!(
out,
" <circle cx=\"{cx:.2}\" cy=\"{cy:.2}\" r=\"3\" fill=\"#1f77b4\" \
stroke=\"#0d4a8a\" stroke-width=\"0.5\" />",
);
}
out.push_str("</svg>");
out
}
/// Render a per-generation "best fitness so far" trace as an SVG line
/// plot. `bests[i]` is the best fitness *after* generation `i`.
///
/// `direction_minimize` controls which way is "improvement": `true`
/// for minimize problems, `false` for maximize.
pub fn convergence_svg(
bests: &[f64],
width: u32,
height: u32,
title: &str,
y_axis_label: &str,
_direction_minimize: bool,
) -> String {
let n = bests.len();
if n == 0 {
return format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\">\
<text x=\"10\" y=\"20\">{}</text></svg>",
escape_xml(title)
);
}
let (ymin, ymax) = bounds(bests.iter().copied());
let yspan = (ymax - ymin).max(1e-12);
let xspan = (n - 1).max(1) as f64;
let m_left = 70.0_f64;
let m_right = 20.0_f64;
let m_top = 40.0_f64;
let m_bot = 50.0_f64;
let plot_w = width as f64 - m_left - m_right;
let plot_h = height as f64 - m_top - m_bot;
let to_x = |i: usize| m_left + (i as f64) / xspan * plot_w;
let to_y = |v: f64| m_top + plot_h - (v - ymin) / yspan * plot_h;
let mut out = String::new();
let _ = writeln!(
out,
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {width} {height}\" \
font-family=\"system-ui, sans-serif\" font-size=\"12\">",
);
let _ = writeln!(
out,
" <rect x=\"0\" y=\"0\" width=\"{width}\" height=\"{height}\" fill=\"white\"/>",
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"22\" font-size=\"16\" font-weight=\"bold\">{title}</text>",
x = m_left,
title = escape_xml(title),
);
let _ = writeln!(
out,
" <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"none\" stroke=\"#888\" />",
m_left, m_top, plot_w, plot_h,
);
// X axis: generation index.
for i in 0..=4 {
let t = i as f64 / 4.0;
let g = (t * (n - 1) as f64).round() as usize;
let x = to_x(g);
let _ = writeln!(
out,
" <line x1=\"{x}\" y1=\"{y0}\" x2=\"{x}\" y2=\"{y1}\" stroke=\"#888\" />",
y0 = m_top + plot_h,
y1 = m_top + plot_h + 5.0,
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">{g}</text>",
y = m_top + plot_h + 18.0,
);
}
// Y ticks.
for i in 0..=3 {
let t = i as f64 / 3.0;
let v = ymin + t * yspan;
let y = to_y(v);
let _ = writeln!(
out,
" <line x1=\"{x0}\" y1=\"{y}\" x2=\"{x1}\" y2=\"{y}\" stroke=\"#888\" />",
x0 = m_left - 5.0,
x1 = m_left,
);
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"end\" dominant-baseline=\"middle\">{v:.3e}</text>",
x = m_left - 8.0,
);
}
// Axis labels.
let _ = writeln!(
out,
" <text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\">generation</text>",
x = m_left + plot_w / 2.0,
y = height as f64 - 12.0,
);
let _ = writeln!(
out,
" <text x=\"15\" y=\"{y}\" text-anchor=\"middle\" \
transform=\"rotate(-90 15 {y})\">{label}</text>",
y = m_top + plot_h / 2.0,
label = escape_xml(y_axis_label),
);
// Polyline.
let mut points = String::new();
for (i, &v) in bests.iter().enumerate() {
if i > 0 {
points.push(' ');
}
let _ = write!(points, "{:.2},{:.2}", to_x(i), to_y(v));
}
let _ = writeln!(
out,
" <polyline points=\"{points}\" fill=\"none\" stroke=\"#1f77b4\" stroke-width=\"1.5\" />",
);
out.push_str("</svg>");
out
}
fn bounds<I: IntoIterator<Item = f64>>(it: I) -> (f64, f64) {
let mut lo = f64::INFINITY;
let mut hi = f64::NEG_INFINITY;
for v in it {
if v.is_finite() {
if v < lo {
lo = v;
}
if v > hi {
hi = v;
}
}
}
if lo.is_infinite() {
(0.0, 1.0)
} else if (hi - lo).abs() < f64::EPSILON {
// All points equal — give a small artificial span.
(lo - 0.5, hi + 0.5)
} else {
(lo, hi)
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
#[cfg(test)]
mod tests {
use super::*;
use heuropt::core::evaluation::Evaluation;
use heuropt::core::objective::Objective;
#[test]
fn pareto_svg_well_formed() {
let space = ObjectiveSpace::new(vec![Objective::minimize("f1"), Objective::minimize("f2")]);
let front = vec![
Candidate::new((), Evaluation::new(vec![0.0, 1.0])),
Candidate::new((), Evaluation::new(vec![1.0, 0.0])),
];
let svg = pareto_front_svg(&front, &space, 400, 300, "test");
assert!(svg.starts_with("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("<circle"));
}
#[test]
fn convergence_svg_well_formed() {
let bests = vec![10.0, 5.0, 2.0, 1.0, 0.5];
let svg = convergence_svg(&bests, 400, 300, "convergence", "best", true);
assert!(svg.starts_with("<svg"));
assert!(svg.contains("polyline"));
}
#[test]
fn convergence_empty_returns_valid_svg() {
let svg = convergence_svg(&[], 200, 100, "empty", "y", true);
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
}
+6 -331
View File
@@ -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() {
+20 -273
View File
@@ -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]);
}
} }
+28 -333
View File
@@ -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_into(bounds: &RealBounds, rng: &mut Rng, out: &mut Vec<f64>) {
out.clear();
for &(lo, hi) in &bounds.bounds {
let v = if lo == hi {
lo
} else {
lo + (hi - lo) * rng.random::<f64>()
};
out.push(v);
}
}
fn sample_uniform_in_bounds(bounds: &RealBounds, rng: &mut Rng) -> Vec<f64> { fn sample_uniform_in_bounds(bounds: &RealBounds, rng: &mut Rng) -> Vec<f64> {
let mut out = Vec::new(); bounds
sample_uniform_in_bounds_into(bounds, rng, &mut out); .bounds
out .iter()
.map(|&(lo, hi)| {
if lo == hi {
lo
} else {
lo + (hi - lo) * rng.random::<f64>()
}
})
.collect()
} }
/// 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));
}
} }
-333
View File
@@ -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}");
}
} }
+66 -59
View File
@@ -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(&current_pop, &objectives);
let snap = Snapshot {
iteration: 0,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
let front = pareto_front(&current_pop, &objectives);
let best = best_candidate(&current_pop, &objectives);
return OptimizationResult::new(
Population::new(current_pop),
front,
best,
evaluations,
completed_generations,
);
}
}
for generation in 1..=self.config.generations {
// Phase 1 (serial): construct one trial per target. RNG state is // Phase 1 (serial): construct one trial per target. RNG state is
// consumed in deterministic order so seeded runs reproduce // consumed in deterministic order so seeded runs reproduce
// exactly regardless of the `parallel` feature. // exactly regardless of the `parallel` feature.
@@ -164,22 +201,38 @@ where
Direction::Maximize => trial_obj >= target_obj, Direction::Maximize => trial_obj >= target_obj,
}; };
if trial_better { if trial_better {
decisions[i] = trial_cand.decision; decisions[i] = trial_cand.decision.clone();
evals[i] = trial_obj; evals[i] = trial_obj;
current_pop[i] = trial_cand;
} }
} }
completed_generations = generation;
// Per-generation snapshot.
let best = best_candidate(&current_pop, &objectives);
let snap = Snapshot {
iteration: generation,
evaluations,
elapsed: started.elapsed(),
population: &current_pop,
pareto_front: None,
best: best.as_ref(),
objectives: &objectives,
};
if let ControlFlow::Break(()) = observer.observe(&snap) {
break;
}
} }
let final_pop: Vec<Candidate<Vec<f64>>> = evaluate_batch(problem, decisions); // Re-evaluate to make sure final population is consistent (current_pop is already current).
evaluations += final_pop.len(); let front = pareto_front(&current_pop, &objectives);
let front = pareto_front(&final_pop, &objectives); let best = best_candidate(&current_pop, &objectives);
let best = best_candidate(&final_pop, &objectives);
OptimizationResult::new( OptimizationResult::new(
Population::new(final_pop), Population::new(current_pop),
front, front,
best, best,
evaluations, evaluations,
self.config.generations, completed_generations,
) )
} }
} }
@@ -203,6 +256,7 @@ impl DifferentialEvolution {
use rand::Rng as _; use rand::Rng as _;
use crate::algorithms::parallel_eval_async::evaluate_batch_async; use crate::algorithms::parallel_eval_async::evaluate_batch_async;
use crate::core::candidate::Candidate;
use crate::traits::Initializer as _; use crate::traits::Initializer as _;
assert!( assert!(
@@ -261,8 +315,8 @@ impl DifferentialEvolution {
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];
let trial_better = match direction { let trial_better = match direction {
Direction::Minimize => trial_obj <= target_obj, crate::core::objective::Direction::Minimize => trial_obj <= target_obj,
Direction::Maximize => trial_obj >= target_obj, crate::core::objective::Direction::Maximize => trial_obj >= target_obj,
}; };
if trial_better { if trial_better {
decisions[i] = trial_cand.decision.clone(); decisions[i] = trial_cand.decision.clone();
@@ -303,18 +357,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 +426,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}");
}
} }
-164
View File
@@ -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]));
}
} }
-177
View File
@@ -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);
}
} }
-117
View File
@@ -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());
}
}
} }
-127
View File
@@ -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(&current_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
View File
@@ -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"
);
}
} }
-162
View File
@@ -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
View File
@@ -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");
}
} }
-122
View File
@@ -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));
}
} }
-111
View File
@@ -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}");
}
} }
-166
View File
@@ -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);
}
} }
-156
View File
@@ -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);
}
} }
-204
View File
@@ -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(&centroid, &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(&centroid, &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(&centroid, &reflected, self.config.contraction)
} else {
self.contract(&centroid, &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));
}
} }
+69 -196
View File
@@ -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,23 +220,48 @@ 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);
OptimizationResult::new(
Population::new(final_pop),
front,
best,
evaluations,
self.config.generations,
)
} }
} }
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(
Population::new(final_pop),
front,
best,
evaluations,
generations,
)
}
fn annotate<D: Clone>( fn annotate<D: Clone>(
population: Vec<Candidate<D>>, population: Vec<Candidate<D>>,
objectives: &crate::core::objective::ObjectiveSpace, objectives: &crate::core::objective::ObjectiveSpace,
@@ -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");
}
} }
-151
View File
@@ -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::*;
-134
View File
@@ -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));
}
} }
-132
View File
@@ -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(&current_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, &current_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 -34
View File
@@ -5,9 +5,8 @@
use futures::stream::{FuturesOrdered, StreamExt}; use futures::stream::{FuturesOrdered, StreamExt};
use crate::core::async_problem::{AsyncPartialProblem, AsyncProblem}; use crate::core::async_problem::AsyncProblem;
use crate::core::candidate::Candidate; use crate::core::candidate::Candidate;
use crate::core::evaluation::Evaluation;
/// Evaluate every decision concurrently against `problem`, preserving /// Evaluate every decision concurrently against `problem`, preserving
/// input order in the returned vector. Concurrency is bounded by /// input order in the returned vector. Concurrency is bounded by
@@ -57,35 +56,3 @@ where
} }
out 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
}
-164
View File
@@ -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);
}
} }
-181
View File
@@ -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"
);
}
} }
+29 -40
View File
@@ -88,28 +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,
)
} }
} }
@@ -158,15 +179,6 @@ impl<I> RandomSearch<I> {
} }
} }
impl<I> crate::traits::AlgorithmInfo for RandomSearch<I> {
fn name(&self) -> &'static str {
"Random Search"
}
fn seed(&self) -> Option<u64> {
Some(self.config.seed)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -218,27 +230,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}");
}
} }
-209
View File
@@ -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}"
);
}
} }
-149
View File
@@ -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(&current_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(&current_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));
}
} }
-133
View File
@@ -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");
}
} }
-194
View File
@@ -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));
}
} }
-123
View File
@@ -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");
}
} }
-156
View File
@@ -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(&current_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)(&current_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(&current_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));
}
} }
-164
View File
@@ -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
View File
@@ -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());
}
} }
-169
View File
@@ -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));
}
} }
+4 -31
View File
@@ -7,12 +7,10 @@
//! thread. //! thread.
//! //!
//! [`AsyncProblem`] mirrors [`Problem`](crate::core::Problem) but its //! [`AsyncProblem`] mirrors [`Problem`](crate::core::Problem) but its
//! `evaluate_async` returns a future. Every algorithm in heuropt exposes //! `evaluate_async` returns a future. Algorithms that support async
//! a `run_async` method that drives evaluations through a user-chosen //! evaluation (NSGA-II, DE, RandomSearch as of v0.7.0; others land
//! async runtime (typically tokio). Hyperband uses //! incrementally) expose a `run_async` method that drives evaluations
//! [`AsyncPartialProblem`] instead, which mirrors //! through a user-chosen async runtime (typically tokio).
//! [`PartialProblem`](crate::core::partial_problem::PartialProblem) for
//! multi-fidelity workloads.
//! //!
//! Available only with the `async` feature. //! Available only with the `async` feature.
@@ -54,28 +52,3 @@ pub trait AsyncProblem: Sync {
/// invoked from. /// invoked from.
fn evaluate_async(&self, decision: &Self::Decision) -> impl Future<Output = Evaluation> + Send; 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;
}
-106
View File
@@ -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));
}
}
-2
View File
@@ -3,7 +3,6 @@
#[cfg(feature = "async")] #[cfg(feature = "async")]
pub mod async_problem; 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;
@@ -15,7 +14,6 @@ pub mod rng;
#[cfg(feature = "async")] #[cfg(feature = "async")]
pub use async_problem::AsyncProblem; 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
View File
@@ -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![
+5
View File
@@ -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
-15
View File
@@ -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()
}
} }
-909
View File
@@ -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);
}
}
+5 -13
View File
@@ -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
View File
@@ -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;
+19 -191
View File
@@ -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,58 +241,27 @@ 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];
for k in (0..order.len()).rev() {
if sub_reference.len() == 2 { let p_last = points[order[k]][last];
// M == 3: the inner HV is a 2-D staircase sweep. `projected` is in let depth = prev - p_last;
// last-axis order, so the active set at step `k` is the prefix if depth > 0.0 {
// `projected[..=k]`. The generic recursion re-sorts that prefix by let active = &projected[..=k];
// axis 0 on every step — O(n² log n). Instead, sort the projected // The 2-D base case sweeps in sorted-x order and skips any
// indices by axis 0 once and, for each `k`, sweep them skipping any // point with `y >= last_y`, which is exactly the dominance
// whose last-axis rank exceeds `k`. The sweep visits points in the // filter — so for M=3 (sub_reference len 2) we can hand
// same (axis-0, then last-axis) order the stable per-prefix sort // `active` straight to `hso_recursive` without paying for
// produced, so the result is bit-identical. // an O(K²) `non_dominated_projection` first. For M≥4 we
let r0 = sub_reference[0]; // still need the explicit filter to keep the recursion's
let r1 = sub_reference[1]; // upper levels honest.
let mut x_order: Vec<usize> = (0..projected.len()).collect(); let inner = if sub_reference.len() == 2 {
x_order.sort_by(|&a, &b| { hso_recursive(active, sub_reference)
projected[a][0] } else {
.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() {
let p_last = points[order[k]][last];
let depth = prev - p_last;
if depth > 0.0 {
let active = &projected[..=k];
let nd = non_dominated_projection(active); let nd = non_dominated_projection(active);
total += depth * hso_recursive(&nd, sub_reference); hso_recursive(&nd, sub_reference)
} };
prev = p_last; total += depth * inner;
} }
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
);
}
} }
+207
View File
@@ -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);
}
}
+4
View File
@@ -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::*;
+173
View File
@@ -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);
}
}
-55
View File
@@ -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);
}
} }
+418
View File
@@ -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());
}
}
+101
View File
@@ -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,
}
}
+45
View File
@@ -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,
}
-13
View File
@@ -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]`.
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More