Skip to main content

echidna_optim/solvers/
lbfgs.rs

1use num_traits::Float;
2
3use crate::convergence::{dot, norm, ConvergenceParams};
4use crate::line_search::{backtracking_armijo, ArmijoParams};
5use crate::objective::Objective;
6use crate::result::{LbfgsDiagnostics, OptimResult, SolverDiagnostics, TerminationReason};
7
8/// Configuration for the L-BFGS solver.
9#[derive(Debug, Clone)]
10pub struct LbfgsConfig<F> {
11    /// Number of recent (s, y) pairs to store (default: 10).
12    pub memory: usize,
13    /// Convergence parameters.
14    pub convergence: ConvergenceParams<F>,
15    /// Line search parameters.
16    pub line_search: ArmijoParams<F>,
17}
18
19impl Default for LbfgsConfig<f64> {
20    fn default() -> Self {
21        LbfgsConfig {
22            memory: 10,
23            convergence: ConvergenceParams::default(),
24            line_search: ArmijoParams::default(),
25        }
26    }
27}
28
29impl Default for LbfgsConfig<f32> {
30    fn default() -> Self {
31        LbfgsConfig {
32            memory: 10,
33            convergence: ConvergenceParams::default(),
34            line_search: ArmijoParams::default(),
35        }
36    }
37}
38
39/// L-BFGS optimization.
40///
41/// Minimizes `obj` starting from `x0` using the limited-memory BFGS method
42/// with two-loop recursion and backtracking Armijo line search.
43pub fn lbfgs<F: Float, O: Objective<F>>(
44    obj: &mut O,
45    x0: &[F],
46    config: &LbfgsConfig<F>,
47) -> OptimResult<F> {
48    let n = x0.len();
49    let mut diag = LbfgsDiagnostics::default();
50
51    // Config validation
52    if config.memory == 0 || config.convergence.max_iter == 0 {
53        return OptimResult {
54            x: x0.to_vec(),
55            value: F::nan(),
56            gradient: vec![F::nan(); n],
57            gradient_norm: F::nan(),
58            iterations: 0,
59            func_evals: 0,
60            termination: TerminationReason::NumericalError,
61            diagnostics: SolverDiagnostics::Lbfgs(diag),
62        };
63    }
64
65    let mut x = x0.to_vec();
66    let (mut f_val, mut grad) = obj.eval_grad(&x);
67    let mut func_evals = 1usize;
68    let mut grad_norm = norm(&grad);
69
70    // NaN/Inf detection
71    if !grad_norm.is_finite() || !f_val.is_finite() {
72        return OptimResult {
73            x,
74            value: f_val,
75            gradient: grad,
76            gradient_norm: grad_norm,
77            iterations: 0,
78            func_evals,
79            termination: TerminationReason::NumericalError,
80            diagnostics: SolverDiagnostics::Lbfgs(diag),
81        };
82    }
83
84    // Check initial convergence
85    if grad_norm < config.convergence.grad_tol {
86        return OptimResult {
87            x,
88            value: f_val,
89            gradient: grad,
90            gradient_norm: grad_norm,
91            iterations: 0,
92            func_evals,
93            termination: TerminationReason::GradientNorm,
94            diagnostics: SolverDiagnostics::Lbfgs(diag),
95        };
96    }
97
98    // L-BFGS history buffers: store most recent `m` pairs
99    let m = config.memory;
100    let mut s_hist: Vec<Vec<F>> = Vec::with_capacity(m);
101    let mut y_hist: Vec<Vec<F>> = Vec::with_capacity(m);
102    let mut rho_hist: Vec<F> = Vec::with_capacity(m);
103
104    for iter in 0..config.convergence.max_iter {
105        // Two-loop recursion. Returns `(direction, gamma_clamp_hit)` so
106        // we can count clamps without threading a `&mut usize`.
107        let (d, gamma_clamped) = two_loop_recursion(&grad, &s_hist, &y_hist, &rho_hist);
108        if gamma_clamped {
109            diag.gamma_clamp_hits += 1;
110        }
111
112        // Line search
113        let ls = match backtracking_armijo(obj, &x, &d, f_val, &grad, &config.line_search) {
114            Some(ls) => ls,
115            None => {
116                return OptimResult {
117                    x,
118                    value: f_val,
119                    gradient: grad,
120                    gradient_norm: grad_norm,
121                    iterations: iter,
122                    func_evals,
123                    termination: TerminationReason::LineSearchFailed,
124                    diagnostics: SolverDiagnostics::Lbfgs(diag),
125                };
126            }
127        };
128        func_evals += ls.evals;
129        // `ls.evals` counts every trial point including the first (alpha = 1).
130        // A backtrack is any trial beyond the first.
131        diag.line_search_backtracks += ls.evals.saturating_sub(1);
132
133        // Compute s = x_new - x, y = g_new - g
134        let mut s = vec![F::zero(); n];
135        let mut y = vec![F::zero(); n];
136        for i in 0..n {
137            // Compute s = alpha * d directly instead of (x + alpha*d) - x
138            // to avoid cancellation when ||x|| >> alpha*||d||
139            s[i] = ls.alpha * d[i];
140            y[i] = ls.gradient[i] - grad[i];
141            x[i] = x[i] + s[i];
142        }
143
144        let f_prev = f_val;
145        f_val = ls.value;
146        grad = ls.gradient;
147        grad_norm = norm(&grad);
148
149        // Update history. Filter out pairs with near-zero curvature via
150        // `curvature_ok`. The original guard `sy > eps * yy` was dimensionally
151        // inconsistent (`[s]·[y]` LHS vs `[y]²` RHS), so it behaved differently
152        // for "tall" vs "short" `y`; the Cauchy-Schwarz-normalized `cos θ` form
153        // is magnitude-independent, and the `sqrt(eps)` threshold (vs the looser
154        // `eps`) rejects near-orthogonal pairs whose `rho = 1/sy` would blow up
155        // and corrupt the two-loop recursion.
156        let sy = dot(&s, &y);
157        let ss = dot(&s, &s);
158        let yy = dot(&y, &y);
159        if curvature_ok(sy, ss, yy) {
160            if s_hist.len() == m {
161                s_hist.remove(0);
162                y_hist.remove(0);
163                rho_hist.remove(0);
164                diag.pairs_evicted_by_memory += 1;
165            }
166            rho_hist.push(F::one() / sy);
167            s_hist.push(s);
168            y_hist.push(y);
169            diag.pairs_accepted += 1;
170        } else {
171            diag.pairs_curvature_rejected += 1;
172        }
173
174        // NaN/Inf detection
175        if !grad_norm.is_finite() || !f_val.is_finite() {
176            return OptimResult {
177                x,
178                value: f_val,
179                gradient: grad,
180                gradient_norm: grad_norm,
181                iterations: iter + 1,
182                func_evals,
183                termination: TerminationReason::NumericalError,
184                diagnostics: SolverDiagnostics::Lbfgs(diag),
185            };
186        }
187
188        // Convergence checks
189        if grad_norm < config.convergence.grad_tol {
190            return OptimResult {
191                x,
192                value: f_val,
193                gradient: grad,
194                gradient_norm: grad_norm,
195                iterations: iter + 1,
196                func_evals,
197                termination: TerminationReason::GradientNorm,
198                diagnostics: SolverDiagnostics::Lbfgs(diag),
199            };
200        }
201
202        let step_norm = norm_step(ls.alpha, &d);
203        if step_norm < config.convergence.step_tol {
204            return OptimResult {
205                x,
206                value: f_val,
207                gradient: grad,
208                gradient_norm: grad_norm,
209                iterations: iter + 1,
210                func_evals,
211                termination: TerminationReason::StepSize,
212                diagnostics: SolverDiagnostics::Lbfgs(diag),
213            };
214        }
215
216        // Relative func_tol: absolute `|f_prev - f_val| < tol` is scale-
217        // blind — a tolerance of 1e-8 means ULP-precision on large-
218        // magnitude objectives (|f| ≈ 1e8) and impossibly tight on tiny
219        // ones. Scale by `(1 + |f|)` so the criterion tracks the problem.
220        if config.convergence.func_tol > F::zero()
221            && (f_prev - f_val).abs() < config.convergence.func_tol * (F::one() + f_val.abs())
222        {
223            return OptimResult {
224                x,
225                value: f_val,
226                gradient: grad,
227                gradient_norm: grad_norm,
228                iterations: iter + 1,
229                func_evals,
230                termination: TerminationReason::FunctionChange,
231                diagnostics: SolverDiagnostics::Lbfgs(diag),
232            };
233        }
234    }
235
236    OptimResult {
237        x,
238        value: f_val,
239        gradient: grad,
240        gradient_norm: grad_norm,
241        iterations: config.convergence.max_iter,
242        func_evals,
243        termination: TerminationReason::MaxIterations,
244        diagnostics: SolverDiagnostics::Lbfgs(diag),
245    }
246}
247
248/// L-BFGS two-loop recursion: compute `d = -H_k * g_k`.
249///
250/// Returns `(direction, gamma_clamp_hit)`. The boolean is `true` when
251/// the initial gamma had to be clamped to `[1e-3, 1e3]` or replaced
252/// with `1.0` because `sy/yy` was non-finite — surfaced into
253/// `LbfgsDiagnostics::gamma_clamp_hits` by the caller.
254fn two_loop_recursion<F: Float>(
255    grad: &[F],
256    s_hist: &[Vec<F>],
257    y_hist: &[Vec<F>],
258    rho_hist: &[F],
259) -> (Vec<F>, bool) {
260    let k = s_hist.len();
261    let n = grad.len();
262    let mut gamma_clamp_hit = false;
263
264    // q = g
265    let mut q: Vec<F> = grad.to_vec();
266
267    // First loop: newest to oldest
268    let mut alpha = vec![F::zero(); k];
269    for i in (0..k).rev() {
270        alpha[i] = rho_hist[i] * dot(&s_hist[i], &q);
271        for j in 0..n {
272            q[j] = q[j] - alpha[i] * y_hist[i][j];
273        }
274    }
275
276    // Initial Hessian approximation: H_0 = gamma * I
277    // gamma = s^T y / y^T y (from the most recent pair)
278    let mut r = q;
279    if k > 0 {
280        let sy = dot(&s_hist[k - 1], &y_hist[k - 1]);
281        let yy = dot(&y_hist[k - 1], &y_hist[k - 1]);
282        if yy > F::epsilon() {
283            let raw_gamma = sy / yy;
284            // Clamp `gamma` to [1e-3, 1e3]. A curvature pair that just
285            // barely passed the acceptance filter can have `sy/yy ≈ eps`
286            // — the two-loop recursion then scales the direction by that
287            // tiny factor, Armijo backtracks to alpha_min, and the
288            // search reports LineSearchFailed. Bounded gamma keeps the
289            // direction magnitude in a line-search-friendly range.
290            let lo = F::from(1e-3).unwrap();
291            let hi = F::from(1e3).unwrap();
292            // Detect the clamp via comparison rather than `clamped != raw`
293            // so we don't rely on float-equality. Clamp boundary values
294            // (`raw_gamma == 1e-3` or `1e3` exactly) are not flagged —
295            // they pass through unchanged and don't represent the
296            // pathology the counter tracks.
297            let gamma = if raw_gamma.is_finite() {
298                if raw_gamma < lo || raw_gamma > hi {
299                    gamma_clamp_hit = true;
300                }
301                raw_gamma.max(lo).min(hi)
302            } else {
303                gamma_clamp_hit = true;
304                F::one()
305            };
306            for v in r.iter_mut() {
307                *v = *v * gamma;
308            }
309        }
310    }
311
312    // Second loop: oldest to newest
313    for i in 0..k {
314        let beta = rho_hist[i] * dot(&y_hist[i], &r);
315        for j in 0..n {
316            r[j] = r[j] + (alpha[i] - beta) * s_hist[i][j];
317        }
318    }
319
320    // Negate: d = -H * g
321    for v in r.iter_mut() {
322        *v = F::zero() - *v;
323    }
324
325    (r, gamma_clamp_hit)
326}
327
328fn norm_step<F: Float>(alpha: F, d: &[F]) -> F {
329    let mut s = F::zero();
330    for &di in d {
331        let step = alpha * di;
332        s = s + step * step;
333    }
334    s.sqrt()
335}
336
337/// Cauchy-Schwarz-normalized curvature filter for L-BFGS `(s, y)` pairs: accept a
338/// pair only when `cos θ = sy / sqrt(ss·yy)` exceeds the threshold. Rejecting
339/// near-orthogonal pairs keeps `ρ = 1/sy` bounded so the two-loop recursion cannot
340/// be corrupted into a garbage search direction.
341#[inline]
342fn curvature_ok<F: Float>(sy: F, ss: F, yy: F) -> bool {
343    sy > F::epsilon().sqrt() * (ss * yy).sqrt()
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn curvature_ok_rejects_near_orthogonal() {
352        // A near-orthogonal (s, y) pair (cos θ ≈ 1e-10) gives ρ = 1/sy ≈ 1e10,
353        // which corrupts the two-loop recursion. The tightened threshold must
354        // reject it; the loose `eps` threshold (~2.2e-16) wrongly admits it.
355        assert!(!curvature_ok(1e-10_f64, 1.0, 1.0));
356        // A genuine curvature pair stays accepted.
357        assert!(curvature_ok(0.5_f64, 1.0, 1.0));
358    }
359
360    struct Rosenbrock;
361
362    impl Objective<f64> for Rosenbrock {
363        fn dim(&self) -> usize {
364            2
365        }
366
367        fn eval_grad(&mut self, x: &[f64]) -> (f64, Vec<f64>) {
368            let a = 1.0 - x[0];
369            let b = x[1] - x[0] * x[0];
370            let f = a * a + 100.0 * b * b;
371            let g0 = -2.0 * a - 400.0 * x[0] * b;
372            let g1 = 200.0 * b;
373            (f, vec![g0, g1])
374        }
375    }
376
377    #[test]
378    fn lbfgs_rosenbrock() {
379        let mut obj = Rosenbrock;
380        let config = LbfgsConfig::default();
381        let result = lbfgs(&mut obj, &[0.0, 0.0], &config);
382
383        assert_eq!(result.termination, TerminationReason::GradientNorm);
384        assert!(
385            (result.x[0] - 1.0).abs() < 1e-6,
386            "x[0] = {}, expected 1.0",
387            result.x[0]
388        );
389        assert!(
390            (result.x[1] - 1.0).abs() < 1e-6,
391            "x[1] = {}, expected 1.0",
392            result.x[1]
393        );
394        assert!(result.gradient_norm < 1e-8);
395    }
396
397    #[test]
398    fn lbfgs_already_converged() {
399        let mut obj = Rosenbrock;
400        let config = LbfgsConfig::default();
401        let result = lbfgs(&mut obj, &[1.0, 1.0], &config);
402
403        assert_eq!(result.termination, TerminationReason::GradientNorm);
404        assert_eq!(result.iterations, 0);
405    }
406}