stats_claw/optimizers/mod.rs
1//! Numerical optimizers minimizing an [`Objective`].
2//!
3//! Every optimizer in this module reduces a scalar objective `f: ℝⁿ → ℝ` and
4//! returns an [`OptimizeResult`] reporting the located point, its objective
5//! value, the iteration count, and a [`ConvergenceStatus`]. The families are
6//! grouped into subfolders: [`gradient`] (first-order learning-rate methods and
7//! conjugate gradient), [`second_order`] (Newton and L-BFGS), and [`stochastic`]
8//! (simulated annealing and genetic / differential evolution). The shared test
9//! objectives live in [`objectives`].
10//!
11//! ## `scipy.optimize` mapping
12//!
13//! Each optimizer is paired with the `scipy.optimize` method it is cross-checked
14//! against; methods with no faithful counterpart are documented as excluded so
15//! the comparison coverage is auditable.
16//!
17//! | stats-claw | `scipy.optimize` | agreement |
18//! |-----------------------|-----------------------------------|-----------|
19//! | `gradient_descent` | none (vanilla GD) | excluded |
20//! | `sgd` | none | excluded |
21//! | `adam` | none | excluded |
22//! | `rmsprop` | none | excluded |
23//! | `adagrad` | none | excluded |
24//! | `conjugate_gradient` | `minimize(method="CG")` | compared |
25//! | `newton` | `minimize(method="Newton-CG")` | compared |
26//! | `lbfgs` | `minimize(method="L-BFGS-B")` | compared |
27//! | `simulated_annealing` | `dual_annealing` | optimum |
28//! | `genetic` | `differential_evolution` | optimum |
29//!
30//! Deterministic optimizers (exempt from the seed-variation check):
31//! `gradient_descent`, `adam`, `rmsprop`, `adagrad`, `conjugate_gradient`,
32//! `newton`, `lbfgs`. Stochastic optimizers (seed-variation required): `sgd`,
33//! `simulated_annealing`, `genetic`.
34
35pub mod gradient;
36pub mod objectives;
37pub mod second_order;
38pub mod stochastic;
39
40/// Outcome of an optimization run: whether the stopping criterion was satisfied.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum ConvergenceStatus {
43 /// The convergence criterion (e.g. gradient norm below tolerance) was met.
44 Converged,
45 /// The iteration budget was exhausted before the criterion was met.
46 MaxIterReached,
47}
48
49/// The result of minimizing an [`Objective`].
50///
51/// The fields together answer the three questions a caller has after a run:
52/// *did it converge, how good is the result, and how much work did it take.*
53#[derive(Debug, Clone)]
54pub struct OptimizeResult {
55 /// The located minimizer (the point at which the run stopped).
56 pub x: Vec<f64>,
57 /// The objective value `f(x)` at the located point.
58 pub fx: f64,
59 /// The number of iterations actually performed (always `≥ 0`).
60 pub iterations: usize,
61 /// Whether the run converged or exhausted its iteration budget.
62 pub status: ConvergenceStatus,
63}
64
65/// A differentiable scalar objective `f: ℝⁿ → ℝ` to be minimized.
66///
67/// Implementors must supply [`value`](Objective::value) and
68/// [`grad`](Objective::grad). The Hessian defaults to a central finite-difference
69/// approximation built from `grad`, so second-order optimizers work for any
70/// objective without an analytic Hessian; objectives that have one may override
71/// [`hessian`](Objective::hessian) for accuracy.
72pub trait Objective {
73 /// Evaluates the objective at `x`.
74 ///
75 /// # Arguments
76 ///
77 /// * `x` — the point at which to evaluate; any finite coordinates.
78 ///
79 /// # Returns
80 ///
81 /// The scalar objective value `f(x)`.
82 fn value(&self, x: &[f64]) -> f64;
83
84 /// Evaluates the gradient `∇f(x)`.
85 ///
86 /// # Arguments
87 ///
88 /// * `x` — the point at which to evaluate the gradient.
89 ///
90 /// # Returns
91 ///
92 /// The gradient vector, the same length as `x`.
93 fn grad(&self, x: &[f64]) -> Vec<f64>;
94
95 /// Approximates the Hessian `∇²f(x)` by central differences of the gradient.
96 ///
97 /// The default uses a step of `√ε ≈ 1.49e-8` per coordinate and symmetrizes
98 /// the result so it is exactly symmetric (rounding can otherwise break
99 /// symmetry). Objectives with an analytic Hessian should override this.
100 ///
101 /// # Arguments
102 ///
103 /// * `x` — the point at which to approximate the Hessian.
104 ///
105 /// # Returns
106 ///
107 /// The `n × n` Hessian in row-major order (`n = x.len()`).
108 fn hessian(&self, x: &[f64]) -> Vec<Vec<f64>> {
109 let n = x.len();
110 let h = f64::EPSILON.sqrt();
111 let mut hess = vec![vec![0.0; n]; n];
112 let mut xp = x.to_vec();
113 for j in 0..n {
114 let xj = *xp.get(j).unwrap_or(&0.0);
115 set(&mut xp, j, xj + h);
116 let gp = self.grad(&xp);
117 set(&mut xp, j, xj - h);
118 let gm = self.grad(&xp);
119 set(&mut xp, j, xj);
120 for i in 0..n {
121 let dgi = gp.get(i).unwrap_or(&0.0) - gm.get(i).unwrap_or(&0.0);
122 set_mat(&mut hess, i, j, dgi / (2.0 * h));
123 }
124 }
125 symmetrize(&mut hess);
126 hess
127 }
128}
129
130/// Writes `value` into `v[i]`, ignoring an out-of-range index (cannot occur for
131/// the in-bounds indices used here, but keeps the code clear of
132/// `indexing_slicing`).
133fn set(v: &mut [f64], i: usize, value: f64) {
134 if let Some(slot) = v.get_mut(i) {
135 *slot = value;
136 }
137}
138
139/// Writes `value` into `m[i][j]`, ignoring an out-of-range index.
140fn set_mat(m: &mut [Vec<f64>], i: usize, j: usize, value: f64) {
141 if let Some(row) = m.get_mut(i)
142 && let Some(slot) = row.get_mut(j)
143 {
144 *slot = value;
145 }
146}
147
148/// Averages a square matrix with its transpose in place so it is symmetric.
149fn symmetrize(m: &mut [Vec<f64>]) {
150 let n = m.len();
151 for i in 0..n {
152 for j in (i + 1)..n {
153 let a = mat(m, i, j);
154 let b = mat(m, j, i);
155 let avg = 0.5 * (a + b);
156 set_mat(m, i, j, avg);
157 set_mat(m, j, i, avg);
158 }
159 }
160}
161
162/// Reads `m[i][j]`, returning `0.0` for an out-of-range index.
163fn mat(m: &[Vec<f64>], i: usize, j: usize) -> f64 {
164 *m.get(i).and_then(|row| row.get(j)).unwrap_or(&0.0)
165}
166
167/// Euclidean (L2) norm of a vector.
168///
169/// # Arguments
170///
171/// * `v` — the vector whose norm is taken.
172///
173/// # Returns
174///
175/// `√Σ vᵢ²`, used as the gradient-norm stopping criterion across optimizers.
176#[must_use]
177pub fn norm(v: &[f64]) -> f64 {
178 v.iter().map(|x| x * x).sum::<f64>().sqrt()
179}
180
181/// Dot product of two equal-length vectors (extra elements of the longer one are
182/// ignored, which never happens for the matched-length inputs used internally).
183///
184/// # Arguments
185///
186/// * `a`, `b` — the vectors to multiply elementwise and sum.
187///
188/// # Returns
189///
190/// `Σ aᵢ·bᵢ`.
191#[must_use]
192pub fn dot(a: &[f64], b: &[f64]) -> f64 {
193 a.iter().zip(b).map(|(x, y)| x * y).sum()
194}
195
196/// Multiplies a square matrix (row-major `Vec<Vec<f64>>`) by a vector.
197///
198/// # Arguments
199///
200/// * `m` — an `n × n` matrix.
201/// * `v` — an `n`-vector.
202///
203/// # Returns
204///
205/// The product `m·v` as an `n`-vector.
206#[must_use]
207pub fn matvec(m: &[Vec<f64>], v: &[f64]) -> Vec<f64> {
208 m.iter().map(|row| dot(row, v)).collect()
209}
210
211/// Backtracking line search satisfying the Armijo sufficient-decrease condition.
212///
213/// Starting from step `1.0`, halves the step until
214/// `f(x + α·d) ≤ f(x) + c·α·gᵀd` holds, used by the line-search optimizers
215/// (conjugate gradient, Newton, L-BFGS) to pick a stable step along `d`.
216///
217/// # Arguments
218///
219/// * `obj` — the objective being minimized.
220/// * `x` — the current point.
221/// * `dir` — the search direction (should be a descent direction).
222/// * `grad` — the gradient at `x` (so `gᵀd` need not be recomputed).
223///
224/// # Returns
225///
226/// The accepted step length `α` (at least `MIN_STEP`, so progress is bounded).
227pub(crate) fn line_search(obj: &impl Objective, x: &[f64], dir: &[f64], grad: &[f64]) -> f64 {
228 const C: f64 = 1e-4;
229 const SHRINK: f64 = 0.5;
230 /// Maximum halvings (`0.5^100 ≈ 1e-30`) before accepting the smallest step.
231 const MAX_HALVINGS: usize = 100;
232 let f0 = obj.value(x);
233 let slope = dot(grad, dir);
234 let mut alpha = 1.0_f64;
235 for _ in 0..MAX_HALVINGS {
236 let trial: Vec<f64> = x
237 .iter()
238 .zip(dir)
239 .map(|(xi, di)| alpha.mul_add(*di, *xi))
240 .collect();
241 if obj.value(&trial) <= (C * alpha).mul_add(slope, f0) {
242 return alpha;
243 }
244 alpha *= SHRINK;
245 }
246 alpha
247}
248
249/// Steps `x` to `x + α·dir`, returning the new point.
250///
251/// # Arguments
252///
253/// * `x` — the current point.
254/// * `alpha` — the step length.
255/// * `dir` — the step direction.
256///
257/// # Returns
258///
259/// The point `x + α·dir`.
260pub(crate) fn step(x: &[f64], alpha: f64, dir: &[f64]) -> Vec<f64> {
261 x.iter()
262 .zip(dir)
263 .map(|(xi, di)| alpha.mul_add(*di, *xi))
264 .collect()
265}