Numerical Optimisation
symplex::optimize is a small, dependable set of f64 routines — bracketed root finding, derivative-free minimisation, global search in a box, least-squares fitting, the trapezoidal rule — plus Ex methods that compile an expression with compile and hand the closure to the matching routine. It fills the gap between “I have an exact symbolic answer” and “I need a number now and the equation has no closed form”.
Three conventions hold everywhere in the module:
- Deterministic and bounded. Every routine has an explicit iteration budget;
differential_evolutiondraws its random numbers from a local SplitMix64 generator seeded byDeOpts::seed, so identical inputs give bit-identical results. - Nothing panics. Bad input (a bracket without a sign change,
degree ≥ len, reversed bounds) isInvalidArgument; running out of iterations or meeting a non-finite value isComputationFailed. The minimisers that return aMinimizeResultreport an exhausted budget throughconverged == falseinstead of an error, so the best point found is never thrown away. - Polynomial coefficients are ascending:
[c₀, c₁, …, c_d]meansc₀ + c₁x + … + c_d xᵈ. (NumPy’spolyfitis highest-degree first.)
Bracketing roots
brent_root(f, a, b, &opts) is Brent–Dekker: inverse quadratic interpolation, secant and bisection steps chosen adaptively, so it converges superlinearly on smooth functions and never slower than bisection. bisect is the bullet-proof fallback. Both require f(a)·f(b) < 0 and return a point within xtol + rtol·|x| of a sign change (RootOpts::default() is xtol = 2e-12, rtol = 4ε, max_iter = 100). newton_root(f, df, x0, &opts) polishes from a point and detects divergence instead of looping.
On an Ex, find_root_bracket(&x, a, b) compiles and brackets in one call; free symbols other than x are a FreeSymbol error, not a silent NaN.
use std::f64::consts::PI;
use symplex::prelude::*;
use symplex::optimize::{RootOpts, bisect, brent_root, newton_root};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
println!("{:.15}", brent_root(|t| t * t - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap()); // 1.414213562373136
println!("{:.15}", bisect(|t| t * t - 2.0, 0.0, 2.0, &RootOpts::default()).unwrap()); // 1.414213562372424
println!("{:.15}", newton_root(|t| t * t * t - 2.0, |t| 3.0 * t * t, 1.0, &RootOpts::default()).unwrap());
// 1.259921049894873
println!("{}", brent_root(|t| t * t + 1.0, -1.0, 1.0, &RootOpts::default()).unwrap_err());
// brent_root: invalid argument: f(a) and f(b) must have opposite signs: f(-1) = 2, f(1) = 2
// Newton on atan(x) from x₀ = 2 diverges — reported, not looped:
assert!(newton_root(f64::atan, |t| 1.0 / (1.0 + t * t), 2.0, &RootOpts::default()).is_err());
// Kepler's equation E − 0.3·sin E = 1, defined symbolically.
let kepler = &x - x.sin() * ctx.rational(3, 10) - 1;
let e = kepler.find_root_bracket(&x, 0.0, PI).unwrap();
println!("{e:.15}"); // 1.288091313212269
println!("{:.2e}", kepler.compile(&["x"]).unwrap().call(&[e])); // 3.95e-13 (residual)
// Newton with a *symbolically* differentiated derivative agrees:
let f = kepler.compile(&["x"]).unwrap();
let df = kepler.diff(&x).compile(&["x"]).unwrap();
let n = newton_root(|t| f.call(&[t]), |t| df.call(&[t]), 1.0, &RootOpts::default()).unwrap();
println!("{:.1e}", (n - e).abs()); // 4.3e-13
let loose = RootOpts { xtol: 1e-6, ..RootOpts::default() };
println!("{:.7}", (x.cos() - &x).find_root_bracket_with(&x, 0.0, 1.0, &loose).unwrap()); // 0.7390851
let a = ctx.symbol("a");
println!("{}", (&x.powi(2) - &a).find_root_bracket(&x, 0.0, 2.0).unwrap_err());
// expression contains free symbol 'a'
}
For a system of equations, solve_numeric_system (damped Newton with a symbolic Jacobian) is in Solving Equations.
Nelder–Mead
nelder_mead(f, &x0, &opts) is the downhill-simplex method with the standard reflect/expand/contract/shrink steps; for more than two variables it uses the dimension-adaptive coefficients that keep the method usable in higher dimensions. NaN objective values are treated as +∞, so the simplex simply moves away from regions where f is undefined. It returns a MinimizeResult { x, fun, iterations, evaluations, converged }.
MinimizeOpts::default() is xtol = 1e-8, ftol = 1e-12, max_iter = 0 (meaning 200·n) and initial_step = 0.0 (SciPy’s 5 % perturbation of each coordinate of x0).
use symplex::prelude::*;
use symplex::optimize::{MinimizeOpts, nelder_mead};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
let rosen = |p: &[f64]| (1.0 - p[0]).powi(2) + 100.0 * (p[1] - p[0] * p[0]).powi(2);
let opts = MinimizeOpts { max_iter: 2000, ..MinimizeOpts::default() };
let r = nelder_mead(rosen, &[-1.2, 1.0], &opts).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}", r.x[0], r.x[1], r.fun); // x = (1.000000, 1.000000), f = 1.10e-18
println!("{} {} {}", r.iterations, r.evaluations, r.converged); // 116 219 true
// Exhausting the budget is not an error: you still get the best vertex.
let tight = MinimizeOpts { max_iter: 20, ..MinimizeOpts::default() };
let r = nelder_mead(rosen, &[-1.2, 1.0], &tight).unwrap();
println!("{} {:.4}", r.converged, r.fun); // false 2.0022
// The same problem as an Ex: `minimize_numeric` / `minimize_numeric_with`.
let rosen_ex = (1 - &x).powi(2) + 100 * (&y - &x.powi(2)).powi(2);
let r = rosen_ex.minimize_numeric_with(&[&x, &y], &[-1.2, 1.0], &opts).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}", r.x[0], r.x[1], r.fun); // x = (1.000000, 1.000000), f = 1.10e-18
let bowl = (&x - 1).powi(2) + (&y + 2).powi(2);
let r = bowl.minimize_numeric(&[&x, &y], &[0.0, 0.0]).unwrap();
println!("x = ({:.6}, {:.6}), f = {:.2e}, converged = {}", r.x[0], r.x[1], r.fun, r.converged);
// x = (1.000000, -2.000000), f = 5.36e-18, converged = true
println!("{}", bowl.minimize_numeric(&[&x], &[0.0]).unwrap_err()); // expression contains free symbol 'y'
}
Scalar minimisation
minimize_scalar(f, a, b, &opts) is Brent’s localmin (golden-section steps plus parabolic interpolation) and golden_section is the pure golden-section search — slower but immune to parabolic mis-steps. Both return a ScalarMinimum { x, value } (the minimiser and the objective there); the interval may be reversed. On an Ex: minimize_scalar_numeric(&x, a, b).
use symplex::prelude::*;
use symplex::optimize::{MinimizeOpts, golden_section, minimize_scalar};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
// x·ln x has its minimum −1/e at x = 1/e.
let g = |t: f64| t * t.ln();
let brent = minimize_scalar(g, 0.1, 2.0, &MinimizeOpts::default()).unwrap();
let golden = golden_section(g, 0.1, 2.0, &MinimizeOpts::default()).unwrap();
println!("{:.10} {:.12}", brent.x, brent.value); // 0.3678794418 -0.367879441171
println!("{:.10} {:.12}", golden.x, golden.value); // 0.3678794415 -0.367879441171
println!("{:.10}", (-1.0f64).exp()); // 0.3678794412
// Γ has its minimum on (0, ∞) near 1.4616.
let m = x.gamma().minimize_scalar_numeric(&x, 1.0, 2.0).unwrap();
println!("{:.8} {:.10}", m.x, m.value); // 1.46163212 0.8856031944
}
The location is only resolved to about √ε·|x| ≈ 1e-8 relative — the objective is flat to rounding on that scale, which is why x above agrees with 1/e to ten digits but not fifteen, while value is correct to twelve.
Differential evolution (deterministic)
differential_evolution(f, &bounds, &opts) is DE/rand/1/bin — Latin-hypercube initialisation, one trial vector per member from three distinct others, binomial crossover, clipping to the box — followed by a Nelder–Mead polish of the best member. bounds is a slice of closed Interval<f64>s, one per coordinate (Interval::closed(lo, hi) or (lo..=hi).into(); an open or half-open kind is rejected, since trial points are clamped onto the endpoints). Every evaluation point, including during the polish, lies inside bounds. DeOpts::default() is population max(15n, 8), 300 generations, CR = 0.7, F = 0.8, tol = 1e-8, seed = 0. On an Ex: minimize_global_numeric(&vars, &bounds, &opts).
use std::f64::consts::PI;
use symplex::prelude::*;
use symplex::optimize::{DeOpts, differential_evolution};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x, y);
// Rastrigin: many local minima, global minimum 0 at the origin.
let rastrigin = |p: &[f64]| {
10.0 * p.len() as f64 + p.iter().map(|v| v * v - 10.0 * (2.0 * PI * v).cos()).sum::<f64>()
};
let bounds = [Interval::closed(-5.12, 5.12), Interval::closed(-5.12, 5.12)];
let r = differential_evolution(rastrigin, &bounds, &DeOpts::default()).unwrap();
println!("f = {:.2e}, |x| < 1e-6: {}, generations {}, evaluations {}, converged {}",
r.fun, r.x.iter().all(|v| v.abs() < 1e-6), r.iterations, r.evaluations, r.converged);
// f = 3.55e-15, |x| < 1e-6: true, generations 85, evaluations 2629, converged true
// Same seed, same inputs → identical result (MinimizeResult is PartialEq).
let seeded = DeOpts { seed: 7, ..DeOpts::default() };
let a = differential_evolution(rastrigin, &bounds, &seeded).unwrap();
let b = differential_evolution(rastrigin, &bounds, &seeded).unwrap();
println!("{}", a == b); // true
// Himmelblau's function has four global minima with f = 0.
let h = (&x.powi(2) + &y - 11).powi(2) + (&x + &y.powi(2) - 7).powi(2);
let square = [Interval::closed(-5.0, 5.0), Interval::closed(-5.0, 5.0)];
let r = h.minimize_global_numeric(&[&x, &y], &square, &DeOpts::default()).unwrap();
println!("f = {:.2e} at ({:.4}, {:.4})", r.fun, r.x[0], r.x[1]); // f = 4.52e-16 at (3.0000, 2.0000)
println!("{}", differential_evolution(rastrigin, &[Interval::closed(1.0, -1.0)], &DeOpts::default()).unwrap_err());
// differential_evolution: invalid argument: each bound must be a finite interval with lower <= upper, got [1, -1]
println!("{}", differential_evolution(rastrigin, &[Interval::open(-1.0, 1.0)], &DeOpts::default()).unwrap_err());
// differential_evolution: invalid argument: each bound must be a closed interval [lower, upper], got (-1, 1)
}
Which of Himmelblau’s four minima is found depends on the seed; the values printed above are for seed = 0. Floating-point transcendental functions can differ in the last bit between platforms, so the trajectory is reproducible on one machine rather than universally — the converged optimum is the same.
Fitting: floating point versus exact
poly_fit(&xs, &ys, degree) is a backward-stable least-squares fit (column-scaled Vandermonde, Householder QR; the normal equations are never formed) returning ascending coefficients; eval_poly(&c, x) evaluates them by Horner’s rule and linear_fit returns a LinearFit { slope, intercept }. poly_fit_exact(&points, degree) solves the normal equations over ℚ, so for consistent data it recovers the exact polynomial, and for inconsistent data the exact least-squares solution. Ex::poly_fit_points(&ctx, &points, &x, degree) is the same thing returning an Ex.
use num_bigint::BigInt;
use num_rational::Ratio;
use symplex::prelude::*;
use symplex::optimize::{LinearFit, eval_poly, linear_fit, poly_fit, poly_fit_exact};
fn main() {
let ctx = Context::new();
symplex::syms!(ctx; x);
// Six samples of x²/3 − x/2 + 1/7.
let xs: Vec<f64> = (-2..=3).map(f64::from).collect();
let ys: Vec<f64> = xs.iter().map(|t| t * t / 3.0 - t / 2.0 + 1.0 / 7.0).collect();
let c = poly_fit(&xs, &ys, 2).unwrap();
println!("[{:.12}, {:.12}, {:.12}]", c[0], c[1], c[2]);
// [0.142857142857, -0.500000000000, 0.333333333333] ← c₀, c₁, c₂ (ascending)
println!("{:.6}", eval_poly(&c, 10.0)); // 28.476190
println!("{}", poly_fit(&[0.0, 1.0], &[0.0, 1.0], 2).unwrap_err());
// poly_fit: invalid argument: degree 2 needs at least 3 points, got 2
let LinearFit { slope, intercept } = linear_fit(&[0.0, 1.0, 2.0, 3.0], &[1.0, 0.0, 4.0, 2.0]).unwrap();
println!("{slope:.12} {intercept:.12}"); // 0.700000000000 0.700000000000
// The same six samples over ℚ: exact recovery.
let q = |n: i64, d: i64| Ratio::new(BigInt::from(n), BigInt::from(d));
let pts: Vec<(Ratio<BigInt>, Ratio<BigInt>)> = (-2..=3)
.map(|i| {
let t = q(i, 1);
(t.clone(), &t * &t / q(3, 1) - &t / q(2, 1) + q(1, 7))
})
.collect();
let c = poly_fit_exact(&pts, 2).unwrap();
println!("{} {} {}", c[0], c[1], c[2]); // 1/7 -1/2 1/3
// …and as an Ex.
let pts_ex: Vec<(Ex, Ex)> = (-2..=3)
.map(|i| {
let xi = ctx.int(i);
let yi = &xi.powi(2) * ctx.rational(1, 3) - &xi * ctx.rational(1, 2) + ctx.rational(1, 7);
(xi, yi.eval())
})
.collect();
println!("{}", Ex::poly_fit_points(&ctx, &pts_ex, &x, 2).unwrap()); // 1/3*x^2 - 1/2*x + 1/7
// Inconsistent data: the exact least-squares line, and the interpolating cubic.
let noisy = [(ctx.int(0), ctx.int(1)), (ctx.int(1), ctx.int(0)), (ctx.int(2), ctx.int(4)), (ctx.int(3), ctx.int(2))];
println!("{}", Ex::poly_fit_points(&ctx, &noisy, &x, 1).unwrap()); // 7/10*x + 7/10
println!("{}", Ex::poly_fit_points(&ctx, &noisy, &x, 3).unwrap()); // -11/6*x^3 + 8*x^2 - 43/6*x + 1
}
Use the exact fit when the data are exact (tabulated values, coefficients recovered from a known-degree polynomial, interpolation) and the floating-point fit when the data are measurements. Ex::poly_interpolate (Algebra) is the special case degree + 1 == points.len().
Trapezoidal rule
trapezoid(&ys, &xs) integrates sampled data on an arbitrary (non-uniform) grid: Σ ½·(xᵢ₊₁ − xᵢ)·(yᵢ + yᵢ₊₁).
use std::f64::consts::PI;
use symplex::optimize::trapezoid;
fn main() {
let grid: Vec<f64> = (0..=1000).map(|i| i as f64 / 1000.0).collect();
let samples: Vec<f64> = grid.iter().map(|t| t * t).collect();
println!("{:.9}", trapezoid(&samples, &grid).unwrap()); // 0.333333500
let sin_samples: Vec<f64> = grid.iter().map(|t| (PI * t).sin()).collect();
println!("{:.9} {:.9}", trapezoid(&sin_samples, &grid).unwrap(), 2.0 / PI); // 0.636619249 0.636619772
println!("{}", trapezoid(&[1.0], &[0.0, 1.0]).unwrap_err());
// trapezoid: invalid argument: ys and xs must have the same length, got 1 and 2
}
When you have the integrand as an expression rather than samples, integrate_numeric (adaptive Gauss–Kronrod, Definite Integration) is both faster and far more accurate.
When to prefer the symbolic solvers
Reach for symplex::optimize when the problem is genuinely numerical: a transcendental equation with no closed form, a black-box objective, measured data. Prefer the exact machinery when it applies, because it answers a different (better) question:
| You want | Numeric | Exact |
|---|---|---|
| Roots of a polynomial | find_root_bracket (one root, needs a bracket) | solve (all roots, radicals/RootOf), real_roots_isolate, nroots |
| Roots of a transcendental equation | find_root_bracket, newton_root | solve (Lambert W, inversion), solve_general for families |
| Systems of equations | solve_numeric_system | linsolve, polysys::solve_system_ex |
| A minimum of a differentiable function | nelder_mead, minimize_scalar | diff + solve, hessian for classification |
| A global minimum in a box | differential_evolution | poly_is_nonnegative_on for proving a bound in 1-D; LP certificates in several |
| A feasible point / optimum of a linear program | — | linprog (Exact Linear Programming) |
| A polynomial through points | poly_fit | poly_fit_exact, poly_interpolate |
| An integral | trapezoid (samples), integrate_numeric (expression) | integrate_definite |
A numeric answer tells you where a root is to twelve digits; the exact answer tells you how many roots there are and that none was missed. When both are available, use the exact form to decide and the numeric form to display.
See cargo run --example numeric_optimization for the complete program.