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(¶ms, &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(¶ms, &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(¶ms_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 = ¶ms_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(¶ms_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 = ¶ms_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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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(¶ms, &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}