feat: v0.5.0 — comprehensive documentation release

Theme: documentation and project polish. No public-API changes; this
is the v0.5 release that elevates heuropt's docs/onboarding/governance
to bar-setting status.

Adds:
- mdbook user guide at docs/book/ with intro, getting-started,
  defining-problems, choosing-an-algorithm, cookbook (7 recipes),
  comparison vs other libraries, stability/SemVer, migration guides.
  Deploys to https://swaits.github.io/heuropt/ via .github/workflows/
  docs.yml.
- Runnable rustdoc examples on every algorithm (35 of them), all
  exercised by cargo test --doc.
- Three real-world examples: portfolio.rs (multi-obj with budget
  constraint), hyperparam_tuning.rs (BO + TPE), scheduling.rs
  (permutation via SA + SwapMutation against Smith's-rule oracle).
- Governance: CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md
  (adopting builderscode.org's Builder's Code of Conduct), GitHub
  issue templates, PR template.

Polishes:
- README hero with badges + user-guide link.
- lib.rs crate-level docs.
- CHANGELOG entry for 0.5.0.

Bumps Cargo.toml to 0.5.0.
This commit is contained in:
2026-05-05 14:33:12 -06:00
parent a9edb0916f
commit fa3f2e8fb0
65 changed files with 4176 additions and 20 deletions
+148
View File
@@ -0,0 +1,148 @@
# Compare two algorithms on your problem
The harness in `examples/compare.rs` runs every applicable algorithm
against every test problem with N seeds and reports mean ± std.
You can lift the same pattern for your own problem in ~30 lines.
## The pattern
1. Wrap your problem in a struct that implements [`Problem`].
2. Pick a few candidate algorithms.
3. For each algorithm × seed, run and record the metric you care about.
4. Print mean ± std.
## Worked example
```rust,no_run
use heuropt::prelude::*;
use std::time::Instant;
struct MyProblem;
impl Problem for MyProblem {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
// your problem here
Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
}
}
const SEEDS: u64 = 10;
const DIM: usize = 5;
const BUDGET: usize = 30_000;
fn main() {
let bounds: Vec<(f64, f64)> = vec![(-5.0, 5.0); DIM];
let mut best_de = vec![];
let mut best_cmaes = vec![];
let mut best_ipop = vec![];
let mut t_de = vec![];
let mut t_cmaes = vec![];
let mut t_ipop = vec![];
for seed in 0..SEEDS {
// Differential Evolution
let t = Instant::now();
let mut de = DifferentialEvolution::new(
DifferentialEvolutionConfig {
population_size: 30,
generations: BUDGET / 30,
differential_weight: 0.5,
crossover_probability: 0.9,
seed,
},
RealBounds::new(bounds.clone()),
);
let r = de.run(&MyProblem);
t_de.push(t.elapsed().as_millis() as f64);
best_de.push(r.best.unwrap().evaluation.objectives[0]);
// CMA-ES
let t = Instant::now();
let mut cma = CmaEs::new(
CmaEsConfig {
population_size: 12,
generations: BUDGET / 12,
initial_sigma: 1.0,
eigen_decomposition_period: 1,
initial_mean: None,
seed,
},
RealBounds::new(bounds.clone()),
);
let r = cma.run(&MyProblem);
t_cmaes.push(t.elapsed().as_millis() as f64);
best_cmaes.push(r.best.unwrap().evaluation.objectives[0]);
// IPOP-CMA-ES
let t = Instant::now();
let mut ipop = IpopCmaEs::new(
IpopCmaEsConfig {
base: CmaEsConfig {
population_size: 12,
generations: BUDGET / 12 / 4,
initial_sigma: 1.0,
eigen_decomposition_period: 1,
initial_mean: None,
seed,
},
max_restarts: 3,
population_factor: 2.0,
seed,
},
RealBounds::new(bounds.clone()),
);
let r = ipop.run(&MyProblem);
t_ipop.push(t.elapsed().as_millis() as f64);
best_ipop.push(r.best.unwrap().evaluation.objectives[0]);
}
println!("{:<12} {:>14} {:>10}", "algorithm", "best f (mean±std)", "ms");
print_row("DE", &best_de, &t_de);
print_row("CMA-ES", &best_cmaes, &t_cmaes);
print_row("IPOP-CMA-ES", &best_ipop, &t_ipop);
}
fn print_row(name: &str, values: &[f64], times: &[f64]) {
let (m, s) = mean_std(values);
let (t, _) = mean_std(times);
println!("{:<12} {:>10.3e} ± {:>5.2e} {:>6.0}", name, m, s, t);
}
fn mean_std(xs: &[f64]) -> (f64, f64) {
let n = xs.len() as f64;
let m = xs.iter().sum::<f64>() / n;
let v = xs.iter().map(|x| (x - m).powi(2)).sum::<f64>() / n;
(m, v.sqrt())
}
```
## What to record
- **`best.evaluation.objectives[0]`** for single-objective.
- **`hypervolume_2d(&result.pareto_front, &space, ref_point)`** for
2-objective.
- **`spacing(&result.pareto_front, &space)`** for front uniformity.
- **`result.evaluations`** to cross-check that every algorithm got
the same evaluation budget.
- Wall-clock `Instant::now()` deltas for runtime comparison.
## Pitfalls
- **Population size matters.** Different algorithms have very
different sweet spots. Don't just give them all the same
population — the README's algorithm pages note typical defaults.
- **Different algorithms count "generations" differently.** What
matters is the total `evaluations` count. Set
`generations = BUDGET / population_size` to match across
algorithms (with caveats for steady-state algorithms like SMS-EMOA
that evaluate one offspring per generation).
- **One seed is not a comparison.** Always run ≥ 5 seeds; ≥ 10 is
better. Single-seed comparisons are noise.
- **The harness in `examples/compare.rs` is the canonical version.**
When in doubt, copy from there.
[`Problem`]: https://docs.rs/heuropt/latest/heuropt/core/problem/trait.Problem.html
+126
View File
@@ -0,0 +1,126 @@
# Constrain your search with `Repair`
heuropt models constraints with a single `constraint_violation` scalar
on each `Evaluation`. That works for soft penalties. When constraints
are *hard* and the search keeps generating infeasible decisions, the
better pattern is **repair**: project each candidate back into the
feasible region every time it leaves.
The [`Repair<D>`] trait is the abstraction. Two impls ship in the box;
you can write your own for arbitrary geometry.
## Built-in: `ClampToBounds`
For per-axis box constraints (`lo ≤ xᵢ ≤ hi`), pair `ClampToBounds`
with any `Variation` to get a bounds-aware variant for free.
```rust,no_run
use heuropt::prelude::*;
let bounds = vec![(-5.0, 5.0); 3];
// Without bounds, GaussianMutation can step outside the search box.
// ClampToBounds projects each variable back in.
let mut sigma = GaussianMutation { sigma: 0.5 };
let mut clamp = ClampToBounds::new(bounds.clone());
let mut rng = rng_from_seed(42);
let parent = vec![4.9, -4.9, 0.0];
let mut child = sigma.vary(std::slice::from_ref(&parent), &mut rng).pop().unwrap();
clamp.repair(&mut child);
// every entry of `child` is now within [-5, 5].
```
`ClampToBounds` is idempotent: applying it twice is the same as
applying it once.
For most real problems you'd just use [`BoundedGaussianMutation`]
which combines both in one operator.
## Built-in: `ProjectToSimplex`
For *budget* constraints — "the components must sum to a fixed
total and be non-negative" — `ProjectToSimplex` projects onto the
probability simplex (or any scaled simplex).
```rust,no_run
use heuropt::prelude::*;
let mut proj = ProjectToSimplex::new(1.0); // probability simplex
let mut x = vec![0.6, 0.5, -0.1, 0.3]; // sum 1.3, one negative
proj.repair(&mut x);
// x now sums to 1.0 and every entry is ≥ 0.
let s: f64 = x.iter().sum();
debug_assert!((s - 1.0).abs() < 1e-12);
debug_assert!(x.iter().all(|&v| v >= 0.0));
```
Use this for portfolio / resource-allocation problems where the
decision is a vector of weights that must sum to a budget.
## Custom repair
Anything that takes a `&mut Vec<f64>` (or any `&mut D` for your
custom decision type) and returns a feasible version is a valid
`Repair`. Implement the trait directly:
```rust,no_run
use heuropt::prelude::*;
/// Force the largest variable to be at least `min_largest`.
struct AtLeastOneActive { min_largest: f64 }
impl Repair<Vec<f64>> for AtLeastOneActive {
fn repair(&mut self, x: &mut Vec<f64>) {
let max_idx = x.iter()
.enumerate()
.fold(0, |best, (i, &v)| {
if v > x[best] { i } else { best }
});
if x[max_idx] < self.min_largest {
x[max_idx] = self.min_largest;
}
}
}
```
## Stochastic-ranking selection
When the feasible region is *narrow* — most of the search space is
infeasible — the strict "feasibles always beat infeasibles" rule
traps the search outside it. Runarsson & Yao's stochastic ranking
breaks the trap by, on each pairwise comparison, using a probabilistic
"compare by objective" instead of "compare by feasibility" with a
small probability `pf`:
```rust,ignore
use heuropt::selection::tournament::stochastic_ranking_select;
let picks = stochastic_ranking_select(
&population,
&objectives,
0.45, // pf — Runarsson & Yao's canonical value
count,
&mut rng,
);
```
This is a drop-in replacement for `tournament_select_single_objective`
in your custom optimizer or in a forked algorithm.
## When to use which
| Situation | Use |
|---|---|
| Box constraints | [`BoundedGaussianMutation`] (built-in mutation) |
| Manual repair after any mutation | [`ClampToBounds`] |
| Budget / probability-simplex constraints | [`ProjectToSimplex`] |
| Custom geometric constraints | Your own `Repair` impl |
| Narrow feasible region, frequent infeasibility | [`stochastic_ranking_select`] |
| Soft penalty, mostly feasible search | Set `constraint_violation` and let default tournament handle it |
[`Repair<D>`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Repair.html
[`ClampToBounds`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ClampToBounds.html
[`ProjectToSimplex`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.ProjectToSimplex.html
[`BoundedGaussianMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.BoundedGaussianMutation.html
[`stochastic_ranking_select`]: https://docs.rs/heuropt/latest/heuropt/selection/tournament/fn.stochastic_ranking_select.html
+146
View File
@@ -0,0 +1,146 @@
# Write your own algorithm
Implement [`Optimizer<P>`] and you're done. There are no other traits
to think about, no internal hooks to register. The example walks
through a tiny hill-climber that reads almost identically to the
canonical pseudocode.
## The trait
```rust,ignore
pub trait Optimizer<P>
where
P: Problem,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision>;
}
```
That's it. You own your config, your RNG, your main loop, and your
`OptimizationResult` construction.
## A minimal hill-climber
```rust,no_run
use heuropt::prelude::*;
pub struct MyHillClimber<I, V> {
pub iterations: usize,
pub seed: u64,
pub initializer: I,
pub variation: V,
}
impl<P, I, V> Optimizer<P> for MyHillClimber<I, V>
where
P: Problem,
P::Decision: Clone,
I: Initializer<P::Decision>,
V: Variation<P::Decision>,
{
fn run(&mut self, problem: &P) -> OptimizationResult<P::Decision> {
let mut rng = rng_from_seed(self.seed);
let objectives = problem.objectives();
assert!(objectives.is_single_objective(), "MyHillClimber is single-objective only");
// Start with one initial decision.
let init_decisions = self.initializer.initialize(1, &mut rng);
let init = init_decisions.into_iter().next().unwrap();
let mut current = Candidate::new(init.clone(), problem.evaluate(&init));
let mut evaluations: usize = 1;
for _ in 0..self.iterations {
let children = self.variation.vary(std::slice::from_ref(&current.decision), &mut rng);
for child_decision in children {
let child_eval = problem.evaluate(&child_decision);
evaluations += 1;
let child = Candidate::new(child_decision, child_eval);
if better(&child.evaluation, &current.evaluation, &objectives) {
current = child;
}
}
}
let pareto_front = vec![current.clone()];
let best = Some(current.clone());
OptimizationResult::new(
Population::new(vec![current]),
pareto_front,
best,
evaluations,
self.iterations,
)
}
}
fn better(a: &Evaluation, b: &Evaluation, objectives: &ObjectiveSpace) -> bool {
let am = objectives.as_minimization(&a.objectives);
let bm = objectives.as_minimization(&b.objectives);
am[0] < bm[0]
}
```
## Things to notice
- **`Rng` is one concrete type.** No generics — call
[`rng_from_seed`] and pass `&mut rng` everywhere it's needed.
- **`Initializer<D>`** sources the starting point(s).
- **`Variation<D>`** generates children from parents. For the
hill-climber it's called with one parent.
- **`OptimizationResult`** carries the final population, the Pareto
front (just the best for single-objective), the best candidate,
the total evaluations, and the iteration count.
- **`as_minimization`** flips maximize-axis values so your
comparison logic only ever needs to deal with "lower is better."
## Adding parallel evaluation
If your algorithm batch-evaluates candidates per generation, use the
crate's internal helper. From inside heuropt source you can call
`evaluate_batch(problem, decisions)`; from outside you'd use rayon
directly behind a feature flag, the same way the built-in algorithms
do.
```rust,ignore
#[cfg(feature = "parallel")]
fn batch_eval<P>(problem: &P, decisions: Vec<P::Decision>) -> Vec<Candidate<P::Decision>>
where P: Problem + Sync, P::Decision: Send,
{
use rayon::prelude::*;
decisions.into_par_iter()
.map(|d| Candidate::new(d.clone(), problem.evaluate(&d)))
.collect()
}
#[cfg(not(feature = "parallel"))]
fn batch_eval<P>(problem: &P, decisions: Vec<P::Decision>) -> Vec<Candidate<P::Decision>>
where P: Problem,
{
decisions.into_iter()
.map(|d| Candidate::new(d.clone(), problem.evaluate(&d)))
.collect()
}
```
To stay bit-identical between serial and parallel modes, keep the
RNG and selection on the main thread; only the *evaluations* run in
parallel.
## What's *not* in the trait
- **No iteration / step API.** The optimizer owns its loop.
- **No callbacks.** A future minor release may add an observer hook;
for now you'd run the algorithm to completion and process the
result.
- **No error type.** Invalid configuration panics with a clear
message; this matches the style of the built-in algorithms.
- **No async.** `evaluate` is synchronous; for async work, drive it
on a tokio runtime around the optimizer loop yourself.
The smallness is the point: you should be able to read a built-in
algorithm and write your own in an afternoon. See
`examples/custom_optimizer.rs` for a slightly more polished version
of the hill-climber above.
[`Optimizer<P>`]: https://docs.rs/heuropt/latest/heuropt/traits/trait.Optimizer.html
[`rng_from_seed`]: https://docs.rs/heuropt/latest/heuropt/core/rng/fn.rng_from_seed.html
@@ -0,0 +1,164 @@
# Tune a model with expensive evaluations
Population-based EAs throw thousands of evaluations at a problem. If
each evaluation costs a minute (a model training run, a CFD solve, a
real-world measurement) you can't afford that. heuropt has three
algorithms aimed at this regime.
| Algorithm | Surrogate | Best for |
|---|---|---|
| [`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 |
| [`Hyperband`] | (none — it's a multi-fidelity scheduler) | When each eval has a tunable budget (epochs, MC samples) |
## When each is right
- **Black-box, fixed cost per eval, smooth-ish landscape** → BO.
- **Black-box, fixed cost per eval, no time to tune the surrogate** → TPE.
- **Each eval has a tunable fidelity** → Hyperband.
## Bayesian Optimization
A worked example with a synthetic 5-D problem and a 60-evaluation
budget — same configuration the `compare` harness uses.
```rust,no_run
use heuropt::prelude::*;
struct Rosenbrock5D;
impl Problem for Rosenbrock5D {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
let f: f64 = x.windows(2).map(|w|
100.0 * (w[1] - w[0].powi(2)).powi(2) + (1.0 - w[0]).powi(2)
).sum();
Evaluation::new(vec![f])
}
}
let bounds = vec![(-2.048_f64, 2.048_f64); 5];
let mut opt = BayesianOpt::new(
BayesianOptConfig {
evaluations: 60,
initial_samples: 10,
length_scale: 1.0,
signal_variance: 1.0,
noise_variance: 1e-6,
seed: 42,
},
RealBounds::new(bounds),
);
let r = opt.run(&Rosenbrock5D);
println!("best f after 60 evals: {}", r.best.unwrap().evaluation.objectives[0]);
```
> **Honest disclosure.** On the comparison harness this default
> configuration produces **f ≈ 3170 ± 2920** on Rosenbrock 5-D — well
> below what a tuned BO can do. The default RBF kernel without
> per-problem hyperparameter tuning is the limitation. For real
> workloads, consider:
>
> - More evaluations (200+ instead of 60).
> - Tuning `length_scale` to a known scale of your problem
> (lower for high-frequency landscapes, higher for smooth ones).
> - TPE instead of BO if you don't want to tune the kernel.
## Tree-structured Parzen Estimator
TPE keeps two density estimates — `l(x)` over historical good points
and `g(x)` over the rest — and picks new candidates that maximize the
ratio. Cheaper per step than a GP and famously robust without
hand-tuning.
```rust,no_run
use heuropt::prelude::*;
# struct Rosenbrock5D;
# impl Problem for Rosenbrock5D {
# type Decision = Vec<f64>;
# fn objectives(&self) -> ObjectiveSpace { ObjectiveSpace::new(vec![Objective::minimize("f")]) }
# fn evaluate(&self, _x: &Vec<f64>) -> Evaluation { Evaluation::new(vec![0.0]) }
# }
let bounds = vec![(-2.048_f64, 2.048_f64); 5];
let mut opt = Tpe::new(
TpeConfig {
evaluations: 60,
initial_samples: 10,
gamma: 0.25,
candidates_per_step: 24,
bandwidth_factor: 1.06,
seed: 42,
},
RealBounds::new(bounds),
);
let _r = opt.run(&Rosenbrock5D);
```
`gamma` is the fraction of best points used as `l(x)`; `0.25` is the
canonical Bergstra value.
## Hyperband
[`Hyperband`] needs your problem to implement [`PartialProblem`] —
that is, you can evaluate at a tunable fidelity (e.g. number of
training epochs). The algorithm schedules many cheap-fidelity runs
and promotes only the survivors to higher fidelity.
```rust,no_run
use heuropt::prelude::*;
use heuropt::core::partial_problem::PartialProblem;
struct ModelTuning;
impl Problem for ModelTuning {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("val_loss")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
// Full-fidelity eval = train at max_epochs.
self.evaluate_at_budget(x, 100.0)
}
}
impl PartialProblem for ModelTuning {
fn evaluate_at_budget(&self, x: &Vec<f64>, budget: f64) -> Evaluation {
// Replace with: train your model for `budget` epochs, return val_loss.
// For demo, pretend more budget = lower noisy loss.
let lr = x[0];
let wd = x[1];
let loss = (lr - 0.001).powi(2) + (wd - 1e-4).powi(2)
+ 1.0 / (budget + 1.0);
Evaluation::new(vec![loss])
}
}
let bounds = vec![(1e-5_f64, 1e-1), (1e-6_f64, 1e-2)];
let mut hyperband = Hyperband::new(
HyperbandConfig {
max_budget: 100.0,
eta: 3.0,
seed: 42,
},
RealBounds::new(bounds),
);
let _r = hyperband.run(&ModelTuning);
```
`max_budget` is the most epochs (or whatever your fidelity unit is)
you'd ever spend on a single config. `eta` controls how aggressive
the elimination is — `3.0` is the classic value; higher means more
aggressive culling.
## Strategy: combining surrogate + multi-fidelity
The state of the art (BOHB) combines BO with Hyperband: TPE picks the
configurations Hyperband then evaluates at increasing fidelity.
heuropt doesn't ship a unified BOHB but the building blocks are
there — wrap your `PartialProblem` with a TPE-driven sampler and
feed the picks into `Hyperband`. PRs welcome.
[`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
[`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
+127
View File
@@ -0,0 +1,127 @@
# Parallelize evaluation with rayon
If a single call to your `evaluate` takes more than ~50 µs, enabling
the `parallel` feature usually pays for itself immediately on
population-based algorithms. Each generation evaluates an entire
population, and rayon parallelizes that batch.
## Enable the feature
```toml
[dependencies]
heuropt = { version = "0.5", features = ["parallel"] }
```
There's nothing else to opt into in your code. The
population-evaluation helper is feature-gated; with `parallel` on it
uses `rayon::into_par_iter` internally, with `parallel` off it falls
back to plain `into_iter`.
## Determinism still holds
Seeded runs are bit-identical between the serial and parallel modes.
The trick is that population members are evaluated in parallel but
*assembled* back into the same order. Variation, selection, and the
RNG are all driven by the main thread, so seed-stability tests still
pass.
## Which algorithms benefit
Algorithms with a per-generation `evaluate_batch`:
- [`RandomSearch`], [`Nsga2`], [`Nsga3`], [`Spea2`], [`Moead`],
[`Mopso`], [`Ibea`], [`SmsEmoa`], [`HypE`], [`PesaII`],
[`EpsilonMoea`], [`AgeMoea`], [`Knea`], [`Grea`], [`Rvea`].
- [`DifferentialEvolution`] and [`GeneticAlgorithm`] benefit on the
initial population and offspring batches.
Steady-state algorithms ([`Paes`], [`SimulatedAnnealing`],
[`HillClimber`], [`OnePlusOneEs`]) only evaluate one or a few
candidates per iteration, so the parallel feature gives them
nothing — leave it off if those are your primary optimizers.
## Worked example
The Sphere problem is too cheap to actually benefit from parallelism
— this example just shows the shape. In real workloads `evaluate` is
the expensive bit (a simulation, a model fit, an HTTP call).
```rust,no_run
use heuropt::prelude::*;
struct ExpensiveSphere;
impl Problem for ExpensiveSphere {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("f")])
}
fn evaluate(&self, x: &Vec<f64>) -> Evaluation {
// Pretend this is a 5 ms simulation.
std::thread::sleep(std::time::Duration::from_millis(5));
Evaluation::new(vec![x.iter().map(|v| v * v).sum::<f64>()])
}
}
fn main() {
let bounds = vec![(-1.0_f64, 1.0_f64); 5];
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(&ExpensiveSphere);
println!("best f = {}", r.best.unwrap().evaluation.objectives[0]);
}
```
With the `parallel` feature on, each generation's 16 evaluations run
across rayon's worker threads. On a 16-core machine the wall-clock
cost per generation drops from `16 × 5 ms = 80 ms` to roughly
`5 ms + scheduling overhead`.
## Sizing your thread pool
heuropt uses rayon's global thread pool. Override the size with:
```rust,ignore
rayon::ThreadPoolBuilder::new().num_threads(8).build_global().unwrap();
```
Run this **before** any heuropt call, or use rayon's `install` API
to scope it.
## When parallelism *doesn't* help
- Your `evaluate` is sub-microsecond (Sphere, Rastrigin, Ackley
unweighted) — the rayon scheduling overhead exceeds the work.
- You're already running multiple seeds in parallel at the harness
level (see [Compare two algorithms](./compare.md)). Stacking
parallelism rarely helps.
- The algorithm is steady-state (Paes, SA, hill climber).
[`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
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html
[`Spea2`]: https://docs.rs/heuropt/latest/heuropt/algorithms/spea2/struct.Spea2.html
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.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
[`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
[`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
[`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
+167
View File
@@ -0,0 +1,167 @@
# Optimize a permutation (TSP-style)
When your decision is "an ordering" — visiting cities, scheduling
jobs, routing — the natural representation is `Vec<usize>` and the
specialized algorithm is [`AntColonyTsp`]. Generic alternatives are
[`SimulatedAnnealing`] + [`SwapMutation`] for any permutation, and
[`TabuSearch`] when you have a custom neighbor function.
## TSP with `AntColonyTsp`
```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("length")])
}
fn evaluate(&self, tour: &Vec<usize>) -> Evaluation {
let mut len = 0.0;
for w in tour.windows(2) {
len += self.distances[w[0]][w[1]];
}
len += self.distances[*tour.last().unwrap()][tour[0]];
Evaluation::new(vec![len])
}
}
fn main() {
// 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 mut distances = vec![vec![0.0; n]; n];
for i in 0..n {
for j in 0..n {
let dx = cities[i].0 - cities[j].0;
let dy = cities[i].1 - cities[j].1;
distances[i][j] = (dx * dx + dy * dy).sqrt();
}
}
let problem = Tsp { distances: distances.clone() };
let mut opt = AntColonyTsp::new(AntColonyTspConfig {
ants: 20,
iterations: 200,
alpha: 1.0,
beta: 5.0,
evaporation: 0.5,
deposit: 1.0,
distances,
seed: 42,
});
let r = opt.run(&problem);
let best = r.best.unwrap();
println!("best tour length: {:.3}", best.evaluation.objectives[0]);
println!("tour: {:?}", best.decision);
}
```
`alpha` weights pheromone influence and `beta` weights the
heuristic (1 / distance). `evaporation` is the per-iteration decay
of pheromone trails. The classic Dorigo paper uses `alpha = 1`,
`beta = 2..5`, `evaporation = 0.1..0.5`.
## Generic permutation: SA + SwapMutation
Use this when your problem isn't TSP-shaped (no distance matrix
makes sense) but you still want to optimize an ordering.
```rust,no_run
use heuropt::prelude::*;
struct JobShop {
process_times: Vec<f64>,
}
impl Problem for JobShop {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("makespan")])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
// Pretend cumulative weighted-completion-time. Replace with your real cost.
let cost: f64 = schedule.iter().enumerate()
.map(|(i, &job)| (i as f64 + 1.0) * self.process_times[job])
.sum();
Evaluation::new(vec![cost])
}
}
fn make_initial_perm(n: usize, seed: u64) -> Vec<usize> {
use rand::seq::SliceRandom;
let mut rng = rng_from_seed(seed);
let mut perm: Vec<usize> = (0..n).collect();
perm.shuffle(&mut rng);
perm
}
let times = vec![3.0, 1.5, 4.2, 2.7, 5.1];
let problem = JobShop { process_times: times.clone() };
// SimulatedAnnealing needs a starting decision; pass a custom Initializer.
struct OnePerm(Vec<usize>);
impl Initializer<Vec<usize>> for OnePerm {
fn initialize(&mut self, _size: usize, _rng: &mut Rng) -> Vec<Vec<usize>> {
vec![self.0.clone()]
}
}
let mut opt = SimulatedAnnealing::new(
SimulatedAnnealingConfig {
iterations: 2000,
initial_temperature: 5.0,
final_temperature: 1e-3,
seed: 7,
},
OnePerm(make_initial_perm(times.len(), 7)),
SwapMutation,
);
let r = opt.run(&problem);
let best = r.best.unwrap();
println!("best makespan: {:.3}", best.evaluation.objectives[0]);
println!("schedule: {:?}", best.decision);
```
`SwapMutation` swaps two random indices in the permutation —
preserves the "every element appears once" invariant for free.
## Custom neighborhoods: `TabuSearch`
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
use heuropt::prelude::*;
let neighbors = |x: &Vec<usize>, _rng: &mut Rng| -> Vec<Vec<usize>> {
// Generate all 2-opt neighbors of x.
let mut out = Vec::new();
for i in 0..x.len() {
for j in (i + 2)..x.len() {
let mut child = x.clone();
child[i + 1..=j].reverse();
out.push(child);
}
}
out
};
// Pass `neighbors` to TabuSearch::new(...).
```
[`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
[`SwapMutation`]: https://docs.rs/heuropt/latest/heuropt/operators/struct.SwapMutation.html
[`TabuSearch`]: https://docs.rs/heuropt/latest/heuropt/algorithms/tabu_search/struct.TabuSearch.html
+127
View File
@@ -0,0 +1,127 @@
# Pick one answer off a Pareto front
A multi-objective optimizer hands you a *front* — a Pareto-optimal
trade-off curve — not a single answer. Eventually you have to pick
*one* point off it. There are several principled ways to do that;
this recipe covers the most common: the **a-posteriori weighted
decision rule**.
The pattern: optimize *without* baking your preferences into the
search, then apply your preferences as a scoring function over the
front.
This is exactly the pattern from `examples/jiggly_tuning.rs` (the
USB-jiggler firmware tuning example).
## The shape
```rust,no_run
use heuropt::prelude::*;
# struct Cost;
# impl Problem for Cost {
# type Decision = Vec<f64>;
# fn objectives(&self) -> ObjectiveSpace {
# ObjectiveSpace::new(vec![Objective::minimize("a"), Objective::minimize("b"), Objective::minimize("c")])
# }
# fn evaluate(&self, _x: &Vec<f64>) -> Evaluation { Evaluation::new(vec![0.0,0.0,0.0]) }
# }
let problem = Cost;
let mut opt = Nsga2::new(
Nsga2Config { population_size: 100, generations: 200, seed: 42 },
RealBounds::new(vec![(-1.0, 1.0); 4]),
CompositeVariation {
crossover: SimulatedBinaryCrossover::new(vec![(-1.0, 1.0); 4], 15.0, 0.5),
mutation: PolynomialMutation::new(vec![(-1.0, 1.0); 4], 20.0, 1.0),
},
);
let result = opt.run(&problem);
// 1. Get the Pareto front.
let front = &result.pareto_front;
// 2. Define your preferences as a scoring function over (oriented)
// objective values. Lower score = preferred.
let space = problem.objectives();
let weights = [1.0, 2.0, 0.5];
let scored: Vec<(f64, &Candidate<Vec<f64>>)> = front.iter()
.map(|c| {
let oriented = space.as_minimization(&c.evaluation.objectives);
let score: f64 = oriented.iter().zip(&weights)
.map(|(v, w)| v * w)
.sum();
(score, c)
})
.collect();
// 3. Pick the lowest-scoring point.
let best = scored.iter()
.min_by(|a, b| a.0.partial_cmp(&b.0).unwrap())
.unwrap();
println!("picked: {:?} with weighted score {:.3}",
best.1.evaluation.objectives, best.0);
```
`as_minimization` returns the objective vector with maximized axes
flipped to negative — so a single set of *positive* weights does
the right thing whether each axis is min or max.
## Why a-posteriori vs a-priori weighting
If you know your weights up front, you could just optimize the
weighted sum directly with a single-objective algorithm. Why bother
with the multi-objective dance?
Two reasons:
1. **Weighted sum can't reach concave parts of the Pareto front.**
Any single-objective optimization with a linear scalarization
converges to a point at the boundary of the convex hull. Concave
front segments are unreachable. The multi-objective optimizer
finds them.
2. **Weights are usually wrong on the first try.** Optimizing the
front first lets you see what's actually possible before deciding
how much each axis is worth. Run once, look at the trade-offs,
adjust weights.
## Penalty terms beyond linear weights
The jiggly example also adds a *hinge penalty* — a term that's zero
inside an acceptable region and grows quadratically once you exceed
some hard cap. Useful when one axis is "soft up to X, hard cap at Y":
```rust,no_run
fn hinge(x: f64, soft_cap: f64, hard_cap: f64) -> f64 {
if x <= soft_cap { 0.0 }
else if x >= hard_cap { f64::INFINITY }
else {
let t = (x - soft_cap) / (hard_cap - soft_cap);
100.0 * t * t
}
}
```
Compose linear weights + hinge penalties and you have a flexible
scoring function over the front without re-running the optimizer.
## Other strategies
- **Knee point.** Pick the point where small gains in one axis cost
large losses in another — the "elbow" of the trade-off curve.
[`Knea`] explicitly biases the search toward knees during the run.
- **Reference-direction.** Pick the point closest to a desired
trade-off direction (a unit vector in objective space).
[`Moead`] / [`Nsga3`] use this internally during search; you can
apply it post-hoc the same way.
- **Random / interactive selection.** Show the front to a user
(perhaps via a plotting library), let them pick.
The right pick depends on the problem; the front itself doesn't
prescribe one.
[`Knea`]: https://docs.rs/heuropt/latest/heuropt/algorithms/knea/struct.Knea.html
[`Moead`]: https://docs.rs/heuropt/latest/heuropt/algorithms/moead/struct.Moead.html
[`Nsga3`]: https://docs.rs/heuropt/latest/heuropt/algorithms/nsga3/struct.Nsga3.html