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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user