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.
211 lines
6.7 KiB
Rust
211 lines
6.7 KiB
Rust
//! 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],
|
||
);
|
||
}
|
||
}
|