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
+124
View File
@@ -0,0 +1,124 @@
//! Tune a synthetic ML model's hyperparameters with Bayesian Optimization
//! and (separately) Tree-structured Parzen Estimator.
//!
//! The "model" here is a deterministic function over `(learning_rate,
//! weight_decay, depth)` that mimics the shape of a real validation-loss
//! surface — a noisy minimum near sensible hyperparameters with sharp
//! penalties as you stray. It's compute-cheap so the example runs in
//! seconds, but the *workflow* is exactly what you'd use on a real
//! 30-second-per-eval model.
//!
//! Demonstrates:
//! - Sample-efficient optimization: 60 evaluations total, not 60,000.
//! - Comparing BO vs TPE on the same problem with the same budget.
//! - Decoding decision vectors with mixed scales (log-uniform learning
//! rate, integer-valued depth) using transforms inside `evaluate`.
//!
//! Run with: `cargo run --release --example hyperparam_tuning`
use heuropt::prelude::*;
/// A pretend deep-learning model whose validation loss is a
/// reproducible analytic function of three hyperparameters.
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 {
// The decision vector is in [0, 1] per dim; we decode each axis
// into the "real" hyperparameter space.
let lr = log_uniform(x[0], 1e-5, 1e-1); // learning rate
let wd = log_uniform(x[1], 1e-6, 1e-2); // weight decay
let depth = scale_to_int(x[2], 2, 12); // num layers
// Synthetic validation loss surface:
// * minimum at lr ≈ 1e-3, wd ≈ 1e-4, depth = 6
// * log-quadratic in lr / wd (typical hyperparameter shape)
// * mild penalty for depth far from 6
// * tiny deterministic "noise" so flat regions don't all tie
let lr_term = (lr.log10() - (-3.0)).powi(2);
let wd_term = (wd.log10() - (-4.0)).powi(2);
let depth_term = 0.05 * ((depth as f64 - 6.0).abs());
let noise = 0.02 * ((10.0 * x[0] + 17.0 * x[1] + 23.0 * x[2]).sin());
let val_loss = 0.05 + 0.3 * lr_term + 0.2 * wd_term + depth_term + noise;
Evaluation::new(vec![val_loss])
}
}
fn log_uniform(unit: f64, lo: f64, hi: f64) -> f64 {
let log_lo = lo.ln();
let log_hi = hi.ln();
(log_lo + unit * (log_hi - log_lo)).exp()
}
fn scale_to_int(unit: f64, lo: i32, hi: i32) -> i32 {
let span = (hi - lo + 1) as f64;
let i = (unit * span).floor() as i32;
(lo + i).min(hi)
}
fn run_bo(seed: u64) -> OptimizationResult<Vec<f64>> {
let mut opt = BayesianOpt::new(
BayesianOptConfig {
initial_samples: 10,
iterations: 50, // 60 total evals
length_scales: None,
signal_variance: 1.0,
noise_variance: 1e-6,
acquisition_samples: 200,
seed,
},
RealBounds::new(vec![(0.0, 1.0); 3]),
);
opt.run(&ModelTuning)
}
fn run_tpe(seed: u64) -> OptimizationResult<Vec<f64>> {
let mut opt = Tpe::new(
TpeConfig {
initial_samples: 10,
iterations: 50, // 60 total evals
good_fraction: 0.25,
candidate_samples: 64,
bandwidth_factor: 1.0,
seed,
},
RealBounds::new(vec![(0.0, 1.0); 3]),
);
opt.run(&ModelTuning)
}
fn report(name: &str, r: &OptimizationResult<Vec<f64>>) {
let best = r.best.as_ref().expect("at least one feasible candidate");
let lr = log_uniform(best.decision[0], 1e-5, 1e-1);
let wd = log_uniform(best.decision[1], 1e-6, 1e-2);
let depth = scale_to_int(best.decision[2], 2, 12);
println!(
"{:<8} val_loss = {:>7.4} | lr = {:>10.2e} wd = {:>10.2e} depth = {} | evals = {}",
name, best.evaluation.objectives[0], lr, wd, depth, r.evaluations,
);
}
fn main() {
println!("Tuning ModelTuning (synthetic 3-D loss surface)");
println!("Optimum: lr ≈ 1e-3, wd ≈ 1e-4, depth = 6, val_loss ≈ 0.03");
println!();
println!(
"{:<8} {:<26} {:<24} {:<24}",
"alg", "best", "(decoded hyperparams)", "(eval budget)"
);
for seed in 0..5 {
println!();
println!("seed {}:", seed);
let bo = run_bo(seed);
let tpe = run_tpe(seed);
report("BO", &bo);
report("TPE", &tpe);
}
}
+210
View File
@@ -0,0 +1,210 @@
//! Multi-objective portfolio optimization with a budget constraint.
//!
//! Real-world flavor: pick a portfolio over five synthetic assets that
//! trades off **return** (maximize) against **risk** (minimize). Weights
//! must be non-negative and sum to 1.0 (the standard probability-simplex
//! budget constraint).
//!
//! Demonstrates:
//! - Multi-objective formulation with a maximize axis (return) and a
//! minimize axis (variance-based risk).
//! - The `ProjectToSimplex` repair operator wired into a `Repair`-aware
//! variation pipeline so every offspring respects the budget.
//! - NSGA-II producing a Pareto front of trade-offs.
//! - Picking one answer off the front via a-posteriori weighting (see
//! `docs/book/src/cookbook/pick-one.md`).
//!
//! Run with: `cargo run --release --example portfolio`
use heuropt::prelude::*;
/// Five-asset toy market. Means and a covariance matrix you'd estimate
/// from real returns; here they're synthetic but realistic-shape.
struct Portfolio {
/// Expected per-period returns (one per asset).
expected_returns: [f64; 5],
/// Symmetric 5×5 covariance matrix.
covariance: [[f64; 5]; 5],
}
impl Problem for Portfolio {
type Decision = Vec<f64>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![
Objective::maximize("return"),
Objective::minimize("risk"),
])
}
fn evaluate(&self, weights: &Vec<f64>) -> Evaluation {
// Expected return: w · μ
let r: f64 = weights
.iter()
.zip(self.expected_returns.iter())
.map(|(w, m)| w * m)
.sum();
// Risk (portfolio variance): w · Σ · w
let mut risk = 0.0;
for i in 0..5 {
for j in 0..5 {
risk += weights[i] * self.covariance[i][j] * weights[j];
}
}
Evaluation::new(vec![r, risk])
}
}
/// Variation pipeline that respects the simplex constraint: SBX +
/// PolyMut produce real-valued children, then `ProjectToSimplex` projects
/// them back onto `{ w : w ≥ 0, Σw = 1 }`.
struct SimplexVariation {
crossover: SimulatedBinaryCrossover,
mutation: PolynomialMutation,
repair: ProjectToSimplex,
}
impl Variation<Vec<f64>> for SimplexVariation {
fn vary(&mut self, parents: &[Vec<f64>], rng: &mut Rng) -> Vec<Vec<f64>> {
let crossed = self.crossover.vary(parents, rng);
let mut out = Vec::with_capacity(crossed.len());
for child in crossed {
let mut mutated = self
.mutation
.vary(std::slice::from_ref(&child), rng)
.pop()
.expect("PolynomialMutation returned no child");
self.repair.repair(&mut mutated);
out.push(mutated);
}
out
}
}
/// `Initializer` that uniformly samples points on the simplex via the
/// standard "log-and-normalize" trick. Every initial member is feasible
/// by construction.
struct SimplexInit {
dim: usize,
}
impl Initializer<Vec<f64>> for SimplexInit {
fn initialize(&mut self, size: usize, rng: &mut Rng) -> Vec<Vec<f64>> {
use rand::Rng as _;
let mut out = Vec::with_capacity(size);
for _ in 0..size {
// Sample exponentials, normalize → uniform on simplex.
let mut e: Vec<f64> = (0..self.dim)
.map(|_| -(1.0_f64 - rng.random::<f64>()).ln())
.collect();
let s: f64 = e.iter().sum();
for v in e.iter_mut() {
*v /= s;
}
out.push(e);
}
out
}
}
fn main() {
let problem = Portfolio {
// Synthetic but plausible: 8% / 12% / 5% / 15% / 3% expected
// returns. The two "stocks" (B, D) have higher expected return
// and higher variance than the bonds / cash equivalents.
expected_returns: [0.08, 0.12, 0.05, 0.15, 0.03],
covariance: [
[0.04, 0.02, 0.01, 0.03, 0.005],
[0.02, 0.10, 0.01, 0.05, 0.005],
[0.01, 0.01, 0.02, 0.01, 0.005],
[0.03, 0.05, 0.01, 0.16, 0.005],
[0.005, 0.005, 0.005, 0.005, 0.001],
],
};
let bounds = vec![(0.0_f64, 1.0_f64); 5];
let variation = SimplexVariation {
crossover: SimulatedBinaryCrossover::new(bounds.clone(), 15.0, 1.0),
mutation: PolynomialMutation::new(bounds.clone(), 20.0, 1.0 / 5.0),
repair: ProjectToSimplex::new(1.0),
};
let mut opt = Nsga2::new(
Nsga2Config {
population_size: 100,
generations: 200,
seed: 42,
},
SimplexInit { dim: 5 },
variation,
);
let result = opt.run(&problem);
println!("Pareto front size: {}", result.pareto_front.len());
println!("Total evaluations: {}", result.evaluations);
// Pick one: a-posteriori weighted decision favoring return slightly.
// Lower score = preferred. We compare in oriented space (maximize
// axis already flipped to negative by `as_minimization`).
let space = problem.objectives();
let weights = [1.0, 1.5]; // weight risk a bit more than -return
let chosen = result
.pareto_front
.iter()
.min_by(|a, b| {
let ax: f64 = space
.as_minimization(&a.evaluation.objectives)
.iter()
.zip(&weights)
.map(|(v, w)| v * w)
.sum();
let bx: f64 = space
.as_minimization(&b.evaluation.objectives)
.iter()
.zip(&weights)
.map(|(v, w)| v * w)
.sum();
ax.partial_cmp(&bx).unwrap_or(std::cmp::Ordering::Equal)
})
.expect("non-empty front");
println!();
println!(
"Picked portfolio: weights = [{:.3}, {:.3}, {:.3}, {:.3}, {:.3}]",
chosen.decision[0],
chosen.decision[1],
chosen.decision[2],
chosen.decision[3],
chosen.decision[4],
);
println!(
" expected return: {:>6.4}",
chosen.evaluation.objectives[0]
);
println!(
" risk (variance): {:>6.4}",
chosen.evaluation.objectives[1]
);
// Print 5 representative points across the front.
println!();
println!("Sample of the front (return, risk):");
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();
for k in (0..n).step_by((n / 5).max(1)) {
let c = &sorted[k];
println!(
" return = {:.4}, risk = {:.4}",
c.evaluation.objectives[0], c.evaluation.objectives[1],
);
}
}
+132
View File
@@ -0,0 +1,132 @@
//! Single-machine job-shop scheduling: minimize total weighted
//! completion time given per-job processing times and due-date weights.
//!
//! The decision is a permutation `Vec<usize>` — the order in which
//! jobs are processed. We use `SimulatedAnnealing` paired with
//! `SwapMutation` (the standard generic-permutation pair).
//!
//! Demonstrates:
//! - Permutation decisions (`Vec<usize>`).
//! - Simulated annealing with a custom `Initializer` that produces a
//! randomly shuffled identity permutation.
//! - `SwapMutation` preserving the permutation invariant for free.
//!
//! Run with: `cargo run --release --example scheduling`
use heuropt::prelude::*;
/// Single-machine weighted-completion-time problem (1 || Σwᵢ Cᵢ).
struct Scheduling {
/// Processing time for each job.
process_times: Vec<f64>,
/// Importance weight for each job. Higher weight = more
/// punishing if the job finishes late.
weights: Vec<f64>,
}
impl Problem for Scheduling {
type Decision = Vec<usize>;
fn objectives(&self) -> ObjectiveSpace {
ObjectiveSpace::new(vec![Objective::minimize("total_wct")])
}
fn evaluate(&self, schedule: &Vec<usize>) -> Evaluation {
// Compute each job's completion time as the running sum of
// processing times in the chosen order.
let mut clock = 0.0_f64;
let mut total_wct = 0.0_f64;
for &job in schedule {
clock += self.process_times[job];
total_wct += self.weights[job] * clock;
}
Evaluation::new(vec![total_wct])
}
}
/// Initializer that produces a single randomly-shuffled permutation
/// `[0, 1, …, n-1]`. Simulated annealing only needs one initial decision.
struct ShuffledPerm {
n: usize,
}
impl Initializer<Vec<usize>> for ShuffledPerm {
fn initialize(&mut self, _size: usize, rng: &mut Rng) -> Vec<Vec<usize>> {
use rand::seq::SliceRandom;
let mut perm: Vec<usize> = (0..self.n).collect();
perm.shuffle(rng);
vec![perm]
}
}
fn main() {
// 12 jobs. The optimal policy is the Smith's-rule order: sort by
// p_i / w_i ascending (shortest weighted processing time first).
// We can compute that directly to compare against the search result.
let jobs = [
(3.0_f64, 2.0_f64),
(5.0, 1.0),
(2.0, 4.0),
(8.0, 3.0),
(4.0, 5.0),
(1.0, 2.0),
(7.0, 6.0),
(6.0, 1.0),
(3.0, 3.0),
(5.0, 4.0),
(2.0, 2.0),
(4.0, 1.0),
];
let process_times: Vec<f64> = jobs.iter().map(|j| j.0).collect();
let weights: Vec<f64> = jobs.iter().map(|j| j.1).collect();
let n = jobs.len();
let problem = Scheduling {
process_times: process_times.clone(),
weights: weights.clone(),
};
// Smith's rule oracle: sort jobs by p / w ascending.
let mut smith_order: Vec<usize> = (0..n).collect();
smith_order.sort_by(|&a, &b| {
let ra = process_times[a] / weights[a];
let rb = process_times[b] / weights[b];
ra.partial_cmp(&rb).unwrap_or(std::cmp::Ordering::Equal)
});
let smith_score = problem.evaluate(&smith_order).objectives[0];
// Search via simulated annealing with swap mutation.
let mut opt = SimulatedAnnealing::new(
SimulatedAnnealingConfig {
iterations: 5_000,
initial_temperature: 50.0,
final_temperature: 1e-3,
seed: 42,
},
ShuffledPerm { n },
SwapMutation,
);
let result = opt.run(&problem);
let best = result.best.unwrap();
println!("Single-machine weighted completion time, {} jobs", n);
println!();
println!(
"Smith's-rule oracle: {:>8.2} order = {:?}",
smith_score, smith_order
);
println!(
"Simulated annealing best: {:>8.2} order = {:?}",
best.evaluation.objectives[0], best.decision,
);
println!(
"Random initial schedule: {:>8.2} order = {:?}",
problem.evaluate(&(0..n).collect()).objectives[0],
(0..n).collect::<Vec<usize>>(),
);
println!();
println!(
"SA reached optimum (Smith): {}",
(best.evaluation.objectives[0] - smith_score).abs() < 1e-9
);
}