Skip to main content

optirs_core/optimizers/
lbfgs.rs

1// L-BFGS optimizer implementation
2//
3// Based on the Limited-memory Broyden-Fletcher-Goldfarb-Shanno algorithm.
4
5use scirs2_core::ndarray::{Array, Array1, Dimension, ScalarOperand};
6use scirs2_core::numeric::Float;
7use std::collections::VecDeque;
8use std::fmt::Debug;
9
10use crate::error::{OptimError, Result};
11use crate::optimizers::Optimizer;
12
13/// L-BFGS optimizer
14///
15/// Implements the Limited-memory Broyden-Fletcher-Goldfarb-Shanno (L-BFGS) algorithm.
16/// This is a quasi-Newton method that approximates the Hessian inverse using a limited
17/// amount of memory by storing only a few vectors from previous iterations.
18///
19/// # Curvature pairs
20///
21/// The optimizer stores the previous parameters *and* the previous gradient, so the
22/// curvature pair is the true `s = x_k - x_{k-1}`, `y = g_k - g_{k-1}` even when the
23/// caller post-processes the returned parameters (projection, clipping, weight decay,
24/// a different step size, ...). Pairs with `y·s <= 0` are skipped so the implicit
25/// inverse-Hessian stays positive definite.
26///
27/// # Step size and line search
28///
29/// [`Optimizer::step`] applies a **fixed** step size (the learning rate) along the
30/// two-loop direction: it has no access to the objective, so it cannot run a line
31/// search. Use [`LBFGS::step_with_loss`] to get a backtracking Armijo line search that
32/// uses the configured `c1` and `max_ls` parameters.
33///
34/// # Examples
35///
36/// ```no_run
37/// use scirs2_core::ndarray::Array1;
38/// use optirs_core::optimizers::{LBFGS, Optimizer};
39///
40/// // Initialize parameters and gradients
41/// let params = Array1::zeros(5);
42/// let gradients = Array1::from_vec(vec![0.1, 0.2, -0.3, 0.0, 0.5]);
43///
44/// // Create an L-BFGS optimizer
45/// let mut optimizer = LBFGS::new(1.0);
46///
47/// // Update parameters
48/// let new_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
49/// ```
50#[derive(Debug, Clone)]
51pub struct LBFGS<A: Float + ScalarOperand + Debug> {
52    /// Learning rate (also the initial trial step size of the line search)
53    learning_rate: A,
54    /// History size (number of vectors to store)
55    history_size: usize,
56    /// Tolerance for gradient norm
57    tolerance_grad: A,
58    /// Armijo (sufficient-decrease) line search parameter c1
59    c1: A,
60    /// Wolfe curvature line search parameter c2
61    ///
62    /// Retained for configuration compatibility and for callers that inspect it via
63    /// [`LBFGS::c2`]. The backtracking Armijo search in [`LBFGS::step_with_loss`] only
64    /// shrinks the trial step, so it cannot enforce the curvature condition; doing so
65    /// requires gradient evaluations at trial points, which this API does not have.
66    c2: A,
67    /// Maximum number of line search iterations
68    max_ls: usize,
69    /// Backtracking contraction factor applied to the trial step size
70    ls_contraction: A,
71    /// History of gradient differences (y = grad_new - grad_old)
72    old_dirs: VecDeque<Array1<A>>,
73    /// History of step vectors (s = params_new - params_old)
74    old_stps: VecDeque<Array1<A>>,
75    /// History of 1/(y·s) values
76    ro: VecDeque<A>,
77    /// Previous parameters (flattened), used to form the true `s = x_k - x_{k-1}`
78    prev_params: Option<Array1<A>>,
79    /// Previous gradient (flattened), used to form `y = g_k - g_{k-1}`
80    prev_grad: Option<Array1<A>>,
81    /// Initial Hessian diagonal value
82    h_diag: A,
83    /// Step counter
84    n_iter: usize,
85    /// Temporary alpha values for two-loop recursion
86    alpha: Vec<A>,
87}
88
89impl<A: Float + ScalarOperand + Debug + Send + Sync> LBFGS<A> {
90    /// Creates a new L-BFGS optimizer with the given learning rate
91    ///
92    /// # Arguments
93    ///
94    /// * `learning_rate` - The learning rate for parameter updates
95    pub fn new(learning_rate: A) -> Self {
96        Self::new_with_config(
97            learning_rate,
98            100,                                      // history_size
99            A::from(1e-7).unwrap_or_else(A::epsilon), // tolerance_grad
100            A::from(1e-4).unwrap_or_else(A::epsilon), // c1
101            A::from(0.9).unwrap_or_else(|| A::one()), // c2
102            25,                                       // max_ls
103        )
104    }
105
106    /// Creates a new L-BFGS optimizer with full configuration
107    ///
108    /// # Arguments
109    ///
110    /// * `learning_rate` - The learning rate for parameter updates
111    /// * `history_size` - Number of past gradients/steps to store (default: 100)
112    /// * `tolerance_grad` - Gradient norm tolerance for convergence (default: 1e-7)
113    /// * `c1` - Wolfe line search parameter for Armijo condition (default: 1e-4)
114    /// * `c2` - Wolfe line search parameter for curvature condition (default: 0.9)
115    /// * `max_ls` - Maximum line search iterations (default: 25)
116    pub fn new_with_config(
117        learning_rate: A,
118        history_size: usize,
119        tolerance_grad: A,
120        c1: A,
121        c2: A,
122        max_ls: usize,
123    ) -> Self {
124        let ls_contraction = A::from(0.5).unwrap_or_else(|| A::one() / (A::one() + A::one()));
125        Self {
126            learning_rate,
127            history_size,
128            tolerance_grad,
129            c1,
130            c2,
131            max_ls,
132            ls_contraction,
133            old_dirs: VecDeque::with_capacity(history_size),
134            old_stps: VecDeque::with_capacity(history_size),
135            ro: VecDeque::with_capacity(history_size),
136            prev_params: None,
137            prev_grad: None,
138            h_diag: A::one(),
139            n_iter: 0,
140            alpha: vec![A::zero(); history_size],
141        }
142    }
143
144    /// Gets the current learning rate
145    pub fn learning_rate(&self) -> A {
146        self.learning_rate
147    }
148
149    /// Sets the learning rate
150    pub fn set_lr(&mut self, lr: A) {
151        self.learning_rate = lr;
152    }
153
154    /// Armijo (sufficient-decrease) line search parameter `c1`
155    pub fn c1(&self) -> A {
156        self.c1
157    }
158
159    /// Wolfe curvature line search parameter `c2`
160    pub fn c2(&self) -> A {
161        self.c2
162    }
163
164    /// Maximum number of line search iterations
165    pub fn max_ls(&self) -> usize {
166        self.max_ls
167    }
168
169    /// Number of curvature pairs currently stored
170    pub fn history_len(&self) -> usize {
171        self.old_stps.len()
172    }
173
174    /// The most recently stored curvature pair `(s, y)`, if any.
175    ///
176    /// `s` is the true parameter difference `x_k - x_{k-1}` (as observed across two
177    /// consecutive calls) and `y` is the corresponding gradient difference
178    /// `g_k - g_{k-1}`.
179    pub fn last_curvature_pair(&self) -> Option<(&Array1<A>, &Array1<A>)> {
180        match (self.old_stps.back(), self.old_dirs.back()) {
181            (Some(s), Some(y)) => Some((s, y)),
182            _ => None,
183        }
184    }
185
186    /// The current initial inverse-Hessian scaling `gamma_k = (s·y) / (y·y)`.
187    pub fn initial_hessian_scale(&self) -> A {
188        self.h_diag
189    }
190
191    /// Sets the backtracking contraction factor used by [`LBFGS::step_with_loss`].
192    ///
193    /// Must lie strictly between 0 and 1; other values are rejected.
194    pub fn set_line_search_contraction(&mut self, rho: A) -> Result<()> {
195        if rho <= A::zero() || rho >= A::one() {
196            return Err(OptimError::InvalidConfig(
197                "line search contraction factor must lie in (0, 1)".to_string(),
198            ));
199        }
200        self.ls_contraction = rho;
201        Ok(())
202    }
203
204    /// Resets the internal state of the optimizer
205    pub fn reset(&mut self) {
206        self.old_dirs.clear();
207        self.old_stps.clear();
208        self.ro.clear();
209        self.prev_params = None;
210        self.prev_grad = None;
211        self.h_diag = A::one();
212        self.n_iter = 0;
213        self.alpha.fill(A::zero());
214    }
215
216    /// Performs the two-loop recursion to compute the search direction `-H·g`
217    fn compute_direction(&mut self, gradient: &Array1<A>) -> Array1<A> {
218        let num_old = self.old_dirs.len();
219
220        // Without curvature pairs the approximation is the identity: steepest descent.
221        if num_old == 0 {
222            return gradient.mapv(|x| -x);
223        }
224
225        // First loop: compute alpha values and initial direction
226        let mut q = gradient.mapv(|x| -x);
227
228        for i in (0..num_old).rev() {
229            self.alpha[i] = self.old_stps[i].dot(&q) * self.ro[i];
230            q = &q - &self.old_dirs[i] * self.alpha[i];
231        }
232
233        // Scale by initial Hessian
234        let mut r = q * self.h_diag;
235
236        // Second loop: compute final direction
237        for i in 0..num_old {
238            let beta = self.old_dirs[i].dot(&r) * self.ro[i];
239            r = &r + &self.old_stps[i] * (self.alpha[i] - beta);
240        }
241
242        r
243    }
244
245    /// Updates the history with a new curvature pair.
246    ///
247    /// Returns `true` when the pair passed the curvature test and was stored.
248    fn update_history(&mut self, y: Array1<A>, s: Array1<A>) -> bool {
249        if self.history_size == 0 || y.len() != s.len() {
250            return false;
251        }
252
253        let ys = y.dot(&s);
254
255        // Scale-invariant curvature test: y·s > eps·||s||·||y|| keeps the implicit
256        // inverse-Hessian approximation positive definite.
257        let eps = A::from(1e-10).unwrap_or_else(A::epsilon);
258        let threshold = eps * s.dot(&s).sqrt() * y.dot(&y).sqrt();
259        if !(ys.is_finite() && ys > A::zero() && ys > threshold) {
260            return false;
261        }
262
263        // Remove oldest entries if at capacity
264        while self.old_dirs.len() >= self.history_size {
265            self.old_dirs.pop_front();
266            self.old_stps.pop_front();
267            self.ro.pop_front();
268        }
269
270        // Add new entries
271        let yy = y.dot(&y);
272        self.old_dirs.push_back(y);
273        self.old_stps.push_back(s);
274        self.ro.push_back(A::one() / ys);
275
276        // Update initial Hessian approximation: gamma_k = (s·y) / (y·y)
277        if yy > A::zero() {
278            self.h_diag = ys / yy;
279        }
280        true
281    }
282
283    /// Flattens an array into a 1-D copy, mapping shape failures onto an error.
284    fn flatten<D: Dimension>(array: &Array<A, D>, what: &str) -> Result<Array1<A>> {
285        array
286            .to_owned()
287            .into_shape_with_order(array.len())
288            .map_err(|e| {
289                OptimError::DimensionMismatch(format!(
290                    "failed to flatten {} of shape {:?}: {}",
291                    what,
292                    array.shape(),
293                    e
294                ))
295            })
296    }
297
298    /// Reshapes a flat vector back into the shape of `like`.
299    fn unflatten<D: Dimension>(flat: Array1<A>, like: &Array<A, D>) -> Result<Array<A, D>> {
300        flat.into_shape_with_order(like.raw_dim()).map_err(|e| {
301            OptimError::DimensionMismatch(format!(
302                "failed to reshape update into {:?}: {}",
303                like.shape(),
304                e
305            ))
306        })
307    }
308
309    /// Records the curvature pair implied by the caller-visible parameters and
310    /// gradients, then returns the L-BFGS search direction for `gradients_flat`.
311    fn prepare_direction(
312        &mut self,
313        params_flat: &Array1<A>,
314        gradients_flat: &Array1<A>,
315    ) -> Array1<A> {
316        // True curvature pair: s = x_k - x_{k-1}, y = g_k - g_{k-1}.
317        //
318        // Both endpoints come from what the caller actually used, so an externally
319        // modified parameter vector (projection, clipping, a different step size, a
320        // scheduler) yields the correct `s` instead of a value reconstructed from the
321        // optimizer's own assumptions.
322        if let (Some(prev_params), Some(prev_grad)) = (&self.prev_params, &self.prev_grad) {
323            if prev_params.len() == params_flat.len() && prev_grad.len() == gradients_flat.len() {
324                let s = params_flat - prev_params;
325                let y = gradients_flat - prev_grad;
326                let _accepted = self.update_history(y, s);
327            }
328        }
329
330        self.compute_direction(gradients_flat)
331    }
332
333    /// Performs an L-BFGS step with a backtracking Armijo line search.
334    ///
335    /// Unlike [`Optimizer::step`], which has no access to the objective and therefore
336    /// applies a fixed step size, this method evaluates `loss_fn` at trial points and
337    /// accepts the first step size satisfying the Armijo sufficient-decrease condition
338    ///
339    /// ```text
340    /// f(x + alpha * d) <= f(x) + c1 * alpha * g^T d
341    /// ```
342    ///
343    /// starting from `alpha = learning_rate` and contracting by the line search
344    /// contraction factor (default `0.5`) for at most `max_ls` iterations. If no trial
345    /// step satisfies the condition, the trial with the lowest objective value is used
346    /// when it improves on `f(x)`; otherwise the parameters are returned unchanged.
347    ///
348    /// If the two-loop direction is not a descent direction (which can only happen
349    /// through numerical error, since non-positive curvature pairs are never stored),
350    /// the search falls back to steepest descent for this step.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`OptimError::DimensionMismatch`] if `params` and `gradients` have
355    /// different shapes, and [`OptimError::InvalidConfig`] if the objective is not
356    /// finite at the current parameters.
357    ///
358    /// # Examples
359    ///
360    /// ```
361    /// use scirs2_core::ndarray::Array1;
362    /// use optirs_core::optimizers::LBFGS;
363    ///
364    /// let mut optimizer = LBFGS::new(1.0);
365    /// let mut params = Array1::from_vec(vec![2.0_f64, -3.0]);
366    /// let loss = |x: &Array1<f64>| x.iter().map(|v| v * v).sum::<f64>();
367    ///
368    /// for _ in 0..30 {
369    ///     let grads = params.mapv(|v| 2.0 * v);
370    ///     params = optimizer
371    ///         .step_with_loss(&params, &grads, loss)
372    ///         .expect("step succeeds");
373    /// }
374    /// assert!(params.iter().all(|v| v.abs() < 1e-6));
375    /// ```
376    pub fn step_with_loss<D, F>(
377        &mut self,
378        params: &Array<A, D>,
379        gradients: &Array<A, D>,
380        mut loss_fn: F,
381    ) -> Result<Array<A, D>>
382    where
383        D: Dimension,
384        F: FnMut(&Array<A, D>) -> A,
385    {
386        if params.shape() != gradients.shape() {
387            return Err(OptimError::DimensionMismatch(format!(
388                "parameters have shape {:?} but gradients have shape {:?}",
389                params.shape(),
390                gradients.shape()
391            )));
392        }
393
394        let params_flat = Self::flatten(params, "parameters")?;
395        let gradients_flat = Self::flatten(gradients, "gradients")?;
396
397        let grad_norm = gradients_flat.dot(&gradients_flat).sqrt();
398        if grad_norm <= self.tolerance_grad {
399            self.prev_params = Some(params_flat);
400            self.prev_grad = Some(gradients_flat);
401            return Ok(params.clone());
402        }
403
404        let mut direction = self.prepare_direction(&params_flat, &gradients_flat);
405
406        // Directional derivative g^T d must be negative for a descent direction.
407        // A NaN (or otherwise incomparable) `gtd` is not a valid descent direction
408        // either, so it must fall into this branch alongside non-negative values.
409        let mut gtd = gradients_flat.dot(&direction);
410        if !matches!(gtd.partial_cmp(&A::zero()), Some(std::cmp::Ordering::Less)) {
411            direction = gradients_flat.mapv(|x| -x);
412            gtd = -gradients_flat.dot(&gradients_flat);
413        }
414
415        let f0 = loss_fn(params);
416        if !f0.is_finite() {
417            return Err(OptimError::InvalidConfig(
418                "objective is not finite at the current parameters".to_string(),
419            ));
420        }
421
422        let mut alpha = self.learning_rate;
423        let mut best: Option<(A, A, Array<A, D>)> = None; // (loss, alpha, candidate)
424        let mut accepted: Option<(A, Array<A, D>)> = None; // (alpha, candidate)
425
426        for _ in 0..self.max_ls.max(1) {
427            let candidate_flat = &params_flat + &(&direction * alpha);
428            let candidate = Self::unflatten(candidate_flat, params)?;
429            let f = loss_fn(&candidate);
430
431            if f.is_finite() && f <= f0 + self.c1 * alpha * gtd {
432                accepted = Some((alpha, candidate));
433                break;
434            }
435
436            if f.is_finite() && f < f0 {
437                let improves = match &best {
438                    Some((best_f, _, _)) => f < *best_f,
439                    None => true,
440                };
441                if improves {
442                    best = Some((f, alpha, candidate));
443                }
444            }
445
446            alpha = alpha * self.ls_contraction;
447            // Stop on a non-positive (or NaN) step size rather than looping forever.
448            if !matches!(
449                alpha.partial_cmp(&A::zero()),
450                Some(std::cmp::Ordering::Greater)
451            ) {
452                break;
453            }
454        }
455
456        let (step_size, new_params) = match accepted {
457            Some((a, candidate)) => (a, candidate),
458            None => match best {
459                // No Armijo-acceptable step, but some trial did reduce the objective.
460                Some((_, a, candidate)) => (a, candidate),
461                // Nothing improved: stay put rather than move to a worse point.
462                None => (A::zero(), params.clone()),
463            },
464        };
465
466        // Record the point the gradient was evaluated at, together with that gradient:
467        // the next call forms s = x_{k+1} - x_k and y = g_{k+1} - g_k from them.
468        self.prev_params = Some(params_flat);
469        self.prev_grad = Some(gradients_flat);
470        if step_size > A::zero() {
471            self.n_iter += 1;
472        }
473
474        Ok(new_params)
475    }
476}
477
478impl<A, D> Optimizer<A, D> for LBFGS<A>
479where
480    A: Float + ScalarOperand + Debug + Send + Sync,
481    D: Dimension,
482{
483    /// Performs an L-BFGS step with a **fixed** step size.
484    ///
485    /// This trait method has no access to the objective, so no line search is possible;
486    /// the two-loop direction is scaled by the learning rate (reduced by
487    /// `1 / (1 + ||g||)` on the very first step, before any curvature information
488    /// exists). Use [`LBFGS::step_with_loss`] for the backtracking Armijo line search
489    /// that uses the configured `c1` and `max_ls`.
490    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
491        if params.shape() != gradients.shape() {
492            return Err(OptimError::DimensionMismatch(format!(
493                "parameters have shape {:?} but gradients have shape {:?}",
494                params.shape(),
495                gradients.shape()
496            )));
497        }
498
499        // Convert to 1D for computation
500        let params_flat = Self::flatten(params, "parameters")?;
501        let gradients_flat = Self::flatten(gradients, "gradients")?;
502
503        // Check convergence
504        let grad_norm = gradients_flat.dot(&gradients_flat).sqrt();
505        if grad_norm <= self.tolerance_grad {
506            self.prev_params = Some(params_flat);
507            self.prev_grad = Some(gradients_flat);
508            return Ok(params.clone());
509        }
510
511        // Record the true curvature pair from the previous iterate and compute the
512        // search direction for the current gradient.
513        let direction = self.prepare_direction(&params_flat, &gradients_flat);
514
515        // Fixed step size: without an objective there is nothing to line search on.
516        let step_size = if self.old_stps.is_empty() {
517            // No curvature information yet: damp the raw steepest-descent step.
518            self.learning_rate / (A::one() + grad_norm)
519        } else {
520            self.learning_rate
521        };
522
523        // Update parameters
524        let new_params_flat = &params_flat + &(&direction * step_size);
525
526        // Store the current iterate and gradient for the next curvature pair.
527        self.prev_params = Some(params_flat);
528        self.prev_grad = Some(gradients_flat);
529        self.n_iter += 1;
530
531        // Reshape back to original dimensions
532        Self::unflatten(new_params_flat, params)
533    }
534
535    fn get_learning_rate(&self) -> A {
536        self.learning_rate
537    }
538
539    fn set_learning_rate(&mut self, learning_rate: A) {
540        self.learning_rate = learning_rate;
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use approx::assert_abs_diff_eq;
548    use scirs2_core::ndarray::Array1;
549
550    #[test]
551    fn test_lbfgs_basic_creation() {
552        let optimizer: LBFGS<f64> = LBFGS::new(1.0);
553        assert_abs_diff_eq!(optimizer.learning_rate(), 1.0);
554        assert_eq!(optimizer.history_size, 100);
555        assert_abs_diff_eq!(optimizer.tolerance_grad, 1e-7);
556    }
557
558    #[test]
559    fn test_lbfgs_convergence() {
560        let mut optimizer: LBFGS<f64> = LBFGS::new(0.1);
561
562        // Minimize f(x) = x^2
563        let mut params = Array1::from_vec(vec![10.0]);
564
565        for _ in 0..50 {
566            let gradients = Array1::from_vec(vec![2.0 * params[0]]);
567            params = optimizer
568                .step(&params, &gradients)
569                .expect("optimizer.step succeeds in test_lbfgs_convergence");
570        }
571
572        // Should converge close to 0
573        assert!(params[0].abs() < 0.1);
574    }
575
576    #[test]
577    fn test_lbfgs_2d() {
578        let mut optimizer: LBFGS<f64> = LBFGS::new(0.1);
579
580        // Minimize f(x,y) = x^2 + y^2
581        let mut params = Array1::from_vec(vec![5.0, 3.0]);
582
583        for _ in 0..50 {
584            let gradients = Array1::from_vec(vec![2.0 * params[0], 2.0 * params[1]]);
585            params = optimizer
586                .step(&params, &gradients)
587                .expect("optimizer.step succeeds in test_lbfgs_2d");
588        }
589
590        // Should converge close to (0, 0)
591        assert!(params[0].abs() < 0.1);
592        assert!(params[1].abs() < 0.1);
593    }
594
595    #[test]
596    fn test_lbfgs_reset() {
597        let mut optimizer: LBFGS<f64> = LBFGS::new(0.1);
598
599        // Perform some steps
600        let mut params = Array1::from_vec(vec![1.0]);
601        let gradients = Array1::from_vec(vec![2.0]);
602        params = optimizer
603            .step(&params, &gradients)
604            .expect("optimizer.step succeeds in test_lbfgs_reset");
605
606        // Need one more step to actually update history
607        let gradients2 = Array1::from_vec(vec![1.5]);
608        params = optimizer
609            .step(&params, &gradients2)
610            .expect("optimizer.step succeeds in test_lbfgs_reset");
611
612        // Third step to populate history
613        let gradients3 = Array1::from_vec(vec![1.0]);
614        let _ = optimizer
615            .step(&params, &gradients3)
616            .expect("optimizer.step succeeds in test_lbfgs_reset");
617
618        // Verify state exists
619        assert!(!optimizer.old_dirs.is_empty());
620        assert!(optimizer.n_iter > 0);
621
622        // Reset
623        optimizer.reset();
624
625        // Verify state is cleared
626        assert!(optimizer.old_dirs.is_empty());
627        assert!(optimizer.old_stps.is_empty());
628        assert!(optimizer.ro.is_empty());
629        assert!(optimizer.prev_grad.is_none());
630        assert_eq!(optimizer.n_iter, 0);
631    }
632}