Skip to main content

burn_optim/optim/
lbfgs.rs

1#![allow(clippy::excessive_precision)]
2
3use burn_core as burn;
4
5use super::GradientsParams;
6use crate::{LearningRate, OptimizerRecord};
7use crate::{RecordState, StateSink, StateSource};
8use burn::config::Config;
9use burn::module::{AutodiffModule, Module, ModuleMapper, ModuleVisitor, Param};
10use burn::store::RecordError;
11use burn::tensor::{Bytes, Device, Tensor, TensorData};
12use serde::{Deserialize, Serialize};
13
14use alloc::vec;
15use alloc::vec::Vec;
16#[cfg(not(feature = "std"))]
17#[allow(unused_imports)]
18use num_traits::Float as _;
19
20/// Cubic Interpolate
21///
22/// Uses two points (x1, f1), (x2, f2) and their first derivatives g1,g2 to construct
23/// a cubic interpolant and return its minimum within the given bounds.
24fn cubic_interpolate(
25    x1: f64,
26    f1: f64,
27    g1: f64,
28    x2: f64,
29    f2: f64,
30    g2: f64,
31    bounds: Option<(f64, f64)>,
32) -> f64 {
33    // Compute bounds of interpolation area
34    let (min_bound, max_bound) = bounds.unwrap_or(if x1 <= x2 { (x1, x2) } else { (x2, x1) });
35    // Code for most common case: cubic interpolation of 2 points
36    // with function and derivative values for both
37    // Solution in this case (where x2 is the farthest point)
38    // d1 = g1 + g2 - 3*(f1 - f2) / (x1-x2);
39    // d2 = sqrt(d1^2 - g1 * g2);
40    // min_pos = x2 - (x2 - x1)*((g2 + d2 - d1)/(g2 - g1 + 2*d2));
41    // t_new = min(max(min_pos,min_bound), max_bound);
42    let d1 = g1 + g2 - 3.0 * (f1 - f2) / (x1 - x2);
43    let d2_square = d1 * d1 - g1 * g2;
44
45    if d2_square >= 0.0 {
46        let d2 = d2_square.sqrt();
47        let min_pos = if x1 <= x2 {
48            x2 - (x2 - x1) * ((g2 + d2 - d1) / (g2 - g1 + 2.0 * d2))
49        } else {
50            x1 - (x1 - x2) * ((g1 + d2 - d1) / (g1 - g2 + 2.0 * d2))
51        };
52        min_pos.max(min_bound).min(max_bound)
53    } else {
54        (min_bound + max_bound) / 2.0
55    }
56}
57/// Auxiliary Struct For Strong_Wolfe
58struct LineSearchSample {
59    // step size
60    t: f64,
61    // loss
62    f: f64,
63    // gradient
64    g: Tensor<1>,
65    // directional derivative
66    gtd: f64,
67}
68
69#[allow(clippy::too_many_arguments)]
70fn strong_wolfe<F>(
71    // obj_func(x,step size,direction) -> (loss,grad)
72    obj_func: &mut F,
73    x: &Tensor<1>,
74    // initial step size
75    mut t: f64,
76    d: &Tensor<1>,
77    f: f64,
78    g: Tensor<1>,
79    gtd: f64,
80    c1: f64,
81    c2: f64,
82    tolerance_change: f64,
83    max_ls: usize,
84) -> (f64, Tensor<1>, f64, usize)
85where
86    F: FnMut(&Tensor<1>, f64, &Tensor<1>) -> (f64, Tensor<1>),
87{
88    let d_norm: f64 = d.clone().abs().max().into_scalar();
89
90    // evaluate objective and gradient using initial step
91    let (mut f_new, mut g_new) = obj_func(x, t, d);
92    let mut ls_func_evals = 1;
93    let mut gtd_new = g_new.clone().dot(d.clone()).into_scalar();
94
95    // bracket an interval [t_prev,t] containing a point satisfying the Wolfe criteria
96    let (mut t_prev, mut f_prev, mut g_prev, mut gtd_prev) = (0.0, f, g.clone(), gtd);
97    let mut done = false;
98    let mut ls_iter = 0;
99
100    // the interval [low,high] using for Zoom phase
101    let mut bracket: Option<[LineSearchSample; 2]> = None;
102    // point which satisfy the wolfe condition
103    let mut wolfe_bracket: Option<LineSearchSample> = None;
104    while ls_iter < max_ls {
105        // Checking Conditions.
106
107        // Checking the Armijo Condition and function value increasing condition.
108        // Armijo: f(x+t*d) <= f(x) + c_1 t gtd
109        if f_new > (f + c1 * t * gtd) || (ls_iter > 1 && f_new >= f_prev) {
110            bracket = Some([
111                LineSearchSample {
112                    t: t_prev,
113                    f: f_prev,
114                    g: g_prev,
115                    gtd: gtd_prev,
116                },
117                LineSearchSample {
118                    t,
119                    f: f_new,
120                    g: g_new.clone(),
121                    gtd: gtd_new,
122                },
123            ]);
124            break;
125        }
126
127        // Checking Strong Wolfe Condition
128        // |gtd_new| <= -c_2 gtd
129        if gtd_new.abs() <= -c2 * gtd {
130            wolfe_bracket = Some(LineSearchSample {
131                t,
132                f: f_new,
133                g: g_new.clone(),
134                gtd: gtd_new,
135            });
136            done = true;
137            break;
138        }
139
140        // gtd_new >=0 , there must be a local minimum in the interval.
141        if gtd_new >= 0.0 {
142            bracket = Some([
143                LineSearchSample {
144                    t: t_prev,
145                    f: f_prev,
146                    g: g_prev,
147                    gtd: gtd_prev,
148                },
149                LineSearchSample {
150                    t,
151                    f: f_new,
152                    g: g_new.clone(),
153                    gtd: gtd_new,
154                },
155            ]);
156            break;
157        }
158
159        // interpolate
160        let min_step = t + 0.01 * (t - t_prev);
161        let max_step = t * 10.0;
162        let t_next = cubic_interpolate(
163            t_prev,
164            f_prev,
165            gtd_prev,
166            t,
167            f_new,
168            gtd_new,
169            Some((min_step, max_step)),
170        );
171        t_prev = t;
172        f_prev = f_new;
173        g_prev = g_new;
174        gtd_prev = gtd_new;
175
176        // next step
177        t = t_next;
178        (f_new, g_new) = obj_func(x, t, d);
179        ls_func_evals += 1;
180        gtd_new = g_new.clone().dot(d.clone()).into_scalar();
181        ls_iter += 1;
182    }
183    if let Some(sample) = wolfe_bracket {
184        return (sample.f, sample.g, sample.t, ls_func_evals);
185    }
186
187    let mut bracket = bracket.unwrap_or_else(|| {
188        [
189            LineSearchSample {
190                t: 0.0,
191                f,
192                g: g.clone(),
193                gtd,
194            },
195            LineSearchSample {
196                t,
197                f: f_new,
198                g: g_new.clone(),
199                gtd: gtd_new,
200            },
201        ]
202    });
203
204    // zoom phase
205    let mut insuf_progress = false;
206
207    // find high and low points in bracket
208    let (mut low_idx, mut high_idx) = if bracket[0].f <= bracket[1].f {
209        (0, 1)
210    } else {
211        (1, 0)
212    };
213
214    while !done && ls_iter < max_ls {
215        let diff = (bracket[1].t - bracket[0].t).abs();
216        // line-search bracket is so small
217        if diff * d_norm < tolerance_change {
218            break;
219        }
220
221        // compute new trial value
222        t = cubic_interpolate(
223            bracket[0].t,
224            bracket[0].f,
225            bracket[0].gtd,
226            bracket[1].t,
227            bracket[1].f,
228            bracket[1].gtd,
229            None,
230        );
231
232        let b_min = bracket[0].t.min(bracket[1].t);
233        let b_max = bracket[0].t.max(bracket[1].t);
234        let eps = 0.1 * (b_max - b_min);
235
236        if (b_max - t).min(t - b_min) < eps {
237            // interpolation close to boundary
238            if insuf_progress || t >= b_max || t <= b_min {
239                t = if (t - b_max).abs() < (t - b_min).abs() {
240                    b_max - eps
241                } else {
242                    b_min + eps
243                };
244                insuf_progress = false;
245            } else {
246                insuf_progress = true;
247            }
248        } else {
249            insuf_progress = false;
250        }
251
252        // Evaluate new point
253        (f_new, g_new) = obj_func(x, t, d);
254
255        ls_func_evals += 1;
256        gtd_new = g_new.clone().dot(d.clone()).into_scalar();
257        ls_iter += 1;
258
259        let armijo_holds = f_new <= (f + c1 * t * gtd) && f_new < bracket[low_idx].f;
260
261        if !armijo_holds {
262            bracket[high_idx] = LineSearchSample {
263                t,
264                f: f_new,
265                g: g_new,
266                gtd: gtd_new,
267            };
268        } else {
269            if gtd_new.abs() <= -c2 * gtd {
270                return (f_new, g_new, t, ls_func_evals);
271            }
272
273            if gtd_new * (bracket[high_idx].t - bracket[low_idx].t) >= 0.0 {
274                bracket[high_idx] = LineSearchSample {
275                    t: bracket[low_idx].t,
276                    f: bracket[low_idx].f,
277                    g: bracket[low_idx].g.clone(),
278                    gtd: bracket[low_idx].gtd,
279                };
280            }
281            bracket[low_idx] = LineSearchSample {
282                t,
283                f: f_new,
284                g: g_new,
285                gtd: gtd_new,
286            };
287        }
288
289        if bracket[0].f <= bracket[1].f {
290            low_idx = 0;
291            high_idx = 1;
292        } else {
293            low_idx = 1;
294            high_idx = 0;
295        }
296    }
297    // return stuff
298    (
299        bracket[low_idx].f,
300        bracket[low_idx].g.clone(),
301        bracket[low_idx].t,
302        ls_func_evals,
303    )
304}
305
306/// Strategy for the line search optimization phase
307#[derive(Clone, Default, Debug, Copy, PartialEq, Eq, Serialize, Deserialize)]
308pub enum LineSearchFn {
309    /// No line search performed
310    #[default]
311    None,
312    /// strong wolfe conditions
313    ///
314    /// See: <https://en.wikipedia.org/wiki/Wolfe_conditions>
315    StrongWolfe,
316}
317
318/// LBFGS Configuration.
319#[derive(Config, Debug)]
320pub struct LBFGSConfig {
321    /// Maximal number of iterations per optimization step (default: 20)
322    #[config(default = 20)]
323    pub max_iter: usize,
324    /// Update history size (default: 100).
325    #[config(default = 100)]
326    pub history_size: usize,
327    /// Termination tolerance on first order optimality (default: 1e-7).
328    #[config(default = 1e-7)]
329    pub tolerance_grad: f64,
330    /// Termination tolerance on function value/parameter changes (default: 1e-9).
331    #[config(default = 1e-9)]
332    pub tolerance_change: f64,
333    /// Maximal number of function evaluations per optimization step (default: max_iter * 1.25).
334    #[config(default = "None")]
335    pub max_eval: Option<usize>,
336    /// Either ‘strong_wolfe’ or None (default: None).
337    #[config(default = "LineSearchFn::None")]
338    pub line_search_fn: LineSearchFn,
339}
340
341impl LBFGSConfig {
342    /// Initialize AdamW optimizer
343    ///
344    /// # Returns
345    ///
346    /// Returns an optimizer that can be used to optimize a module
347    pub fn init(&self) -> LBFGS {
348        // by default max_eval = max_iter * 5/4
349        let max_eval = self.max_eval.unwrap_or(self.max_iter * 5 / 4);
350        LBFGS {
351            config: LBFGSConfig {
352                max_iter: self.max_iter,
353                history_size: self.history_size,
354                tolerance_grad: self.tolerance_grad,
355                tolerance_change: self.tolerance_change,
356                max_eval: Some(max_eval),
357                line_search_fn: self.line_search_fn,
358            },
359            state: Default::default(),
360        }
361    }
362}
363
364/// Collects gradients in module visit order.
365struct FlattenGradsVisitorInner<'a> {
366    grads: &'a GradientsParams,
367    tensors: &'a mut Vec<Tensor<1>>,
368}
369
370impl ModuleVisitor for FlattenGradsVisitorInner<'_> {
371    fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
372        if let Some(g) = self.grads.get::<D>(param.id) {
373            let numel = g.shape().num_elements();
374            self.tensors.push(g.reshape([numel]));
375        }
376    }
377}
378
379/// Flatten params to inner backend 1D tensor.
380fn flatten_params_inner<M: Module>(module: &M) -> Tensor<1> {
381    let mut tensors = Vec::new();
382    let mut visitor = FlattenParamsVisitorInner {
383        tensors: &mut tensors,
384    };
385    module.visit(&mut visitor);
386    if tensors.is_empty() {
387        return Tensor::empty([0], &module.devices()[0].clone().inner());
388    }
389    Tensor::cat(tensors, 0)
390}
391
392struct FlattenParamsVisitorInner<'a> {
393    tensors: &'a mut Vec<Tensor<1>>,
394}
395
396impl ModuleVisitor for FlattenParamsVisitorInner<'_> {
397    fn visit_float<const D: usize>(&mut self, param: &Param<Tensor<D>>) {
398        let t = param.val().inner();
399        let numel = t.shape().num_elements();
400        self.tensors.push(t.reshape([numel]));
401    }
402}
403
404/// Flatten gradients for a module.
405fn flatten_grads_inner<M: Module>(module: &M, grads: &GradientsParams) -> Tensor<1> {
406    let mut tensors = Vec::new();
407    let mut visitor = FlattenGradsVisitorInner {
408        grads,
409        tensors: &mut tensors,
410    };
411    module.visit(&mut visitor);
412    if tensors.is_empty() {
413        return Tensor::empty([0], &module.devices()[0].clone().inner());
414    }
415    Tensor::cat(tensors, 0)
416}
417
418/// Mapper that assigns each float param from a flat inner-backend 1D tensor.
419struct ParamsFromFlatMapperInner<'a> {
420    flat: &'a Tensor<1>,
421    offset: &'a mut usize,
422}
423
424impl ParamsFromFlatMapperInner<'_> {
425    fn take_slice(&mut self, numel: usize) -> Tensor<1> {
426        let start = *self.offset;
427        *self.offset += numel;
428        self.flat.clone().slice(start..*self.offset)
429    }
430}
431
432impl ModuleMapper for ParamsFromFlatMapperInner<'_> {
433    fn map_float<const D: usize>(&mut self, param: Param<Tensor<D>>) -> Param<Tensor<D>> {
434        let (id, tensor, mapper) = param.consume();
435        let numel = tensor.shape().num_elements();
436        let slice_1d = self.take_slice(numel);
437        let new_inner = slice_1d.reshape(tensor.shape());
438        let new_tensor = Tensor::from_inner(new_inner).require_grad();
439        Param::from_mapped_value(id, new_tensor, mapper)
440    }
441}
442
443/// Overwrite module parameters from a flat inner-backend 1D tensor
444fn set_params_from_flat_inner<M: Module>(module: M, flat: Tensor<1>) -> M {
445    let mut offset = 0;
446    let mut mapper = ParamsFromFlatMapperInner {
447        flat: &flat,
448        offset: &mut offset,
449    };
450    module.map(&mut mapper)
451}
452
453/// L-BFGS optimizer state
454#[derive(Clone, RecordState)]
455pub struct LBFGSState {
456    /// Historical displacement vectors
457    pub history_s: Vec<Tensor<1>>,
458    /// Historical gradient difference vectors
459    pub history_y: Vec<Tensor<1>>,
460    /// Search direction
461    pub d: Option<Tensor<1>>,
462    /// Step size from the previous iteration
463    pub t: Option<f64>,
464    /// Flattened gradient from the previous iteration
465    pub prev_flat_grad: Option<Tensor<1>>,
466    /// Loss value from the previous iteration
467    pub prev_loss: Option<f64>,
468    /// Global iteration count
469    pub g_iter: usize,
470}
471
472impl LBFGSState {
473    /// The device of the state's tensors, if any have been populated.
474    fn current_device(&self) -> Option<Device> {
475        self.prev_flat_grad
476            .as_ref()
477            .or(self.d.as_ref())
478            .or(self.history_s.first())
479            .map(|t| t.device())
480    }
481
482    /// Moves all historical tensors to the target device.
483    pub fn to_device(self, device: &Device) -> Self {
484        Self {
485            history_s: self
486                .history_s
487                .into_iter()
488                .map(|t| t.to_device(device))
489                .collect(),
490            history_y: self
491                .history_y
492                .into_iter()
493                .map(|t| t.to_device(device))
494                .collect(),
495            d: self.d.map(|t| t.to_device(device)),
496            t: self.t,
497            prev_flat_grad: self.prev_flat_grad.map(|t| t.to_device(device)),
498            prev_loss: self.prev_loss,
499            g_iter: self.g_iter,
500        }
501    }
502}
503impl Default for LBFGSState {
504    fn default() -> Self {
505        Self {
506            history_s: Vec::new(),
507            history_y: Vec::new(),
508            d: None,
509            t: Some(1.0),
510            prev_flat_grad: None,
511            prev_loss: None,
512            g_iter: 0,
513        }
514    }
515}
516
517/// L-BFGS optimizer.
518///
519/// Ported from [pytorch](https://github.com/pytorch/pytorch/torch/optim/lbfgs.py). Heavily inspired by [miniFunc](https://www.cs.ubc.ca/~schmidtm/Software/minFunc.html)
520///
521/// See also:
522/// - [L-BFGS](https://en.wikipedia.org/wiki/Limited-memory_BFGS)
523///
524/// # Note
525/// This optimizer is memory intensive
526#[derive(Clone)]
527pub struct LBFGS {
528    config: LBFGSConfig,
529    state: LBFGSState,
530}
531
532impl LBFGS {
533    /// Decompose the optimizer state into a serializable [`OptimizerRecord`] (burnpack format).
534    ///
535    /// L-BFGS keeps a single global state rather than per-parameter state, so its tensors are
536    /// named directly (e.g. `history_s.0`) and carry no parameter id.
537    pub fn to_record(&self) -> OptimizerRecord {
538        let mut sink = StateSink::default();
539        RecordState::state_flatten(&self.state, "", &mut sink);
540
541        let tensors = sink
542            .tensors
543            .into_iter()
544            .map(|(name, data)| {
545                burn_pack::Tensor::new(name, data.dtype, data.shape, None, data.bytes)
546            })
547            .collect();
548        let scalars = sink.scalars.into_iter().collect();
549
550        OptimizerRecord {
551            tensors,
552            scalars,
553            paths: Default::default(),
554        }
555    }
556
557    /// Load the optimizer state from an [`OptimizerRecord`].
558    ///
559    /// State tensors are materialized on the default device; the state is migrated to the gradient
560    /// device on the next [`step`](LBFGS::step), so no device argument is needed.
561    pub fn load_record(mut self, record: OptimizerRecord) -> Self {
562        let device = Device::default();
563        let mut source = StateSource::new(record.scalars);
564        for tensor in record.tensors {
565            let data = TensorData::from_bytes(tensor.bytes, tensor.shape, tensor.dtype);
566            source.insert_tensor(tensor.name, data);
567        }
568        if let Some(state) = LBFGSState::state_unflatten("", &mut source, &device) {
569            self.state = state;
570        }
571        self
572    }
573
574    /// Serialize the optimizer state to an in-memory burnpack byte buffer.
575    pub fn into_bytes(&self) -> Result<Bytes, RecordError> {
576        self.to_record().into_bytes()
577    }
578
579    /// Load the optimizer state from an in-memory burnpack byte buffer.
580    pub fn from_bytes(self, bytes: Bytes) -> Result<Self, RecordError> {
581        Ok(self.load_record(OptimizerRecord::from_bytes(bytes)?))
582    }
583
584    /// Save the optimizer state to a burnpack file on disk.
585    #[cfg(feature = "std")]
586    pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), RecordError> {
587        self.to_record().save(path)
588    }
589
590    /// Load the optimizer state from a burnpack file on disk.
591    #[cfg(feature = "std")]
592    pub fn load<P: AsRef<std::path::Path>>(self, path: P) -> Result<Self, RecordError> {
593        Ok(self.load_record(OptimizerRecord::load(path)?))
594    }
595
596    /// A single optimization step for any tensor that represents the parameters of a model.
597    pub fn step<M, F>(&mut self, lr: LearningRate, mut module: M, mut closure: F) -> (M, f64)
598    where
599        M: AutodiffModule + Clone,
600        F: FnMut(M) -> (f64, GradientsParams),
601    {
602        // evaluate initial f(x) and df/dx
603        let (mut loss, grads) = closure(module.clone());
604        let mut current_evals = 1;
605
606        let mut flat_grad = flatten_grads_inner::<M>(&module, &grads);
607        let mut x_flat = flatten_params_inner::<M>(&module);
608
609        // Migrate the state to the gradient's device when they differ (e.g. just after loading a
610        // record on the default device). This is a no-op once the state is built from gradients.
611        let device = flat_grad.device();
612        if self.state.current_device().is_some_and(|d| d != device) {
613            self.state = core::mem::take(&mut self.state).to_device(&device);
614        }
615
616        let opt_cond =
617            flat_grad.clone().abs().max().into_scalar::<f64>() <= self.config.tolerance_grad;
618        // optimal condition
619        if opt_cond {
620            return (module, loss);
621        }
622
623        // tensors cached in state
624        let mut d = self
625            .state
626            .d
627            .take()
628            .unwrap_or_else(|| flat_grad.clone().neg());
629        let mut t = self.state.t.unwrap_or(lr);
630        let mut prev_flat_grad = self.state.prev_flat_grad.take();
631
632        let mut n_iter = 0;
633
634        // optimize for a max of max_iter iterations
635        while n_iter < self.config.max_iter {
636            // keep track of nb of iterations
637            n_iter += 1;
638            self.state.g_iter += 1;
639
640            // compute gradient descent direction
641            if self.state.g_iter == 1 {
642                d = flat_grad.clone().neg();
643                self.state.history_s.clear();
644                self.state.history_y.clear();
645            } else {
646                // do lbfgs update (update memory)
647                if let Some(pg) = prev_flat_grad.as_ref() {
648                    let y = flat_grad.clone().sub(pg.clone());
649                    let s = d.clone().mul_scalar(t);
650
651                    let ys: f64 = y.clone().dot(s.clone()).into_scalar();
652
653                    if ys > 1e-10 {
654                        // updating memory
655                        if self.state.history_s.len() >= self.config.history_size {
656                            // shift history by one (limited-memory)
657                            self.state.history_s.remove(0);
658                            self.state.history_y.remove(0);
659                        }
660                        self.state.history_s.push(s);
661                        self.state.history_y.push(y);
662                    }
663                }
664
665                // compute the approximate (L-BFGS) inverse Hessian
666                // multiplied by the gradient
667                let num_old = self.state.history_s.len();
668                let mut q = flat_grad.clone().neg();
669                let mut alphas: Vec<Tensor<1>> =
670                    vec![Tensor::zeros([1], &flat_grad.device().inner()); num_old];
671
672                if num_old > 0 {
673                    // multiply by initial Hessian
674                    // r/d is the final direction
675                    for i in (0..num_old).rev() {
676                        let s = &self.state.history_s[i];
677                        let y = &self.state.history_y[i];
678                        let rho = y.clone().dot(s.clone()).powf_scalar(-1.0);
679                        let alpha = rho.clone().mul(s.clone().dot(q.clone()));
680                        alphas[i] = alpha.clone();
681                        q = q.sub(y.clone().mul(alpha));
682                    }
683
684                    let last_s = &self.state.history_s[num_old - 1];
685                    let last_y = &self.state.history_y[num_old - 1];
686                    let ys = last_y.clone().dot(last_s.clone());
687                    let yy = last_y.clone().dot(last_y.clone());
688                    let h_diag = ys.div(yy);
689
690                    let mut r = q.mul(h_diag);
691
692                    for ((s, y), alpha) in self
693                        .state
694                        .history_s
695                        .iter()
696                        .zip(self.state.history_y.iter())
697                        .zip(alphas)
698                        .take(num_old)
699                    {
700                        let rho = y.clone().dot(s.clone()).powf_scalar(-1.0);
701
702                        let beta = rho.mul(y.clone().dot(r.clone()));
703
704                        r = r.add(s.clone().mul(alpha.sub(beta)));
705                    }
706                    d = r;
707                } else {
708                    d = q;
709                }
710            }
711
712            prev_flat_grad = Some(flat_grad.clone());
713            let prev_loss_iter = loss;
714
715            // compute step len
716            if self.state.g_iter == 1 {
717                let grad_l1: f64 = flat_grad.clone().abs().sum().into_scalar();
718                t = (1.0f64 / grad_l1).min(1.0) * lr;
719            } else {
720                t = lr;
721            }
722
723            // directional derivative
724            let gtd = flat_grad.clone().dot(d.clone()).into_scalar();
725
726            if gtd > -self.config.tolerance_change {
727                break;
728            }
729
730            let ls_func_evals;
731
732            if let LineSearchFn::StrongWolfe = self.config.line_search_fn {
733                // perform line search, using user function
734                let mut obj_func = |current_x: &Tensor<1>, step: f64, dir: &Tensor<1>| {
735                    let update = dir.clone().mul_scalar(step);
736                    let new_x = current_x.clone().add(update);
737                    let tmp_module = set_params_from_flat_inner::<M>(module.clone(), new_x);
738                    let (l, g) = closure(tmp_module);
739                    (l, flatten_grads_inner::<M>(&module, &g))
740                };
741
742                let (ls_f, ls_g, ls_t, evals) = strong_wolfe(
743                    &mut obj_func,
744                    &x_flat,
745                    t,
746                    &d,
747                    loss,
748                    flat_grad.clone(),
749                    gtd,
750                    1e-4,
751                    0.9,
752                    self.config.tolerance_change,
753                    self.config.max_eval.unwrap() - current_evals,
754                );
755
756                loss = ls_f;
757                flat_grad = ls_g;
758                t = ls_t;
759                ls_func_evals = evals;
760
761                x_flat = x_flat.add(d.clone().mul_scalar(t));
762                module = set_params_from_flat_inner::<M>(module, x_flat.clone());
763            } else {
764                // no line search, simply move with fixed-step
765                let step_vec = d.clone().mul_scalar(t);
766                x_flat = x_flat.add(step_vec);
767                module = set_params_from_flat_inner::<M>(module, x_flat.clone());
768                // re-evaluate function only if not in last iteration
769                // the reason we do this: in a stochastic setting,
770                // no use to re-evaluate that function here
771                let (new_loss, new_grads) = closure(module.clone());
772                loss = new_loss;
773                flat_grad = flatten_grads_inner::<M>(&module, &new_grads);
774                ls_func_evals = 1;
775            }
776
777            // update func eval
778            current_evals += ls_func_evals;
779
780            // check conditions
781
782            if current_evals >= self.config.max_eval.unwrap() {
783                break;
784            }
785
786            if flat_grad.clone().abs().max().into_scalar::<f64>() <= self.config.tolerance_grad {
787                break;
788            }
789
790            if d.clone().mul_scalar(t).abs().max().into_scalar::<f64>()
791                <= self.config.tolerance_change
792            {
793                break;
794            }
795
796            if (loss - prev_loss_iter).abs() < self.config.tolerance_change {
797                break;
798            }
799        }
800        self.state.d = Some(d);
801        self.state.t = Some(t);
802        self.state.prev_flat_grad = prev_flat_grad;
803        self.state.prev_loss = Some(loss);
804        (module, loss)
805    }
806    /// Moves the optimizer state to the specified device.
807    pub fn to_device(self, device: &Device) -> Self {
808        Self {
809            config: self.config,
810            // History tensors reside in InnerBackend, so we convert the device accordingly
811            state: self.state.to_device(device),
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818
819    use super::*;
820    use crate::GradientsParams;
821    use burn::module::Param;
822    use burn::tensor::{Tensor, TensorData};
823    use burn_nn::Linear;
824
825    fn given_linear_layer(weight: TensorData, bias: TensorData, device: &Device) -> Linear {
826        Linear {
827            weight: Param::from_data(weight, device),
828            bias: Some(Param::from_data(bias, device)),
829        }
830    }
831    #[test]
832    fn test_cubic_interpolate() {
833        let tolerance = 1e-8;
834
835        // basic
836        let (x1, f1, g1, x2, f2, g2) = (-1.0, 1.0, -2.0, 1.0, 1.0, 2.0);
837        let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, None);
838        assert!(
839            (result - 0.00000).abs() < tolerance,
840            "Basic: Result {} should be close to 0.0",
841            result
842        );
843
844        // bound
845        let (x1, f1, g1, x2, f2, g2) = (0.0, 0.25, -1.0, 1.0, 0.25, 1.0);
846        let bounds = Some((0.6, 1.0));
847        let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, bounds);
848        assert!(
849            (result - 0.6000000000).abs() < tolerance,
850            "Bound: Result {} should be clamped to 0.6",
851            result
852        );
853
854        // d2_square < 0,should return mid value
855        let (x1, f1, g1, x2, f2, g2) = (0.0, 0.0, 10.0, 1.0, 5.0, 10.0);
856        let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, Some((0.0, 1.0)));
857        assert!(
858            (result - 0.5000000).abs() < tolerance,
859            "Fallback: Result {} should be midpoint 0.5",
860            result
861        );
862
863        // asymmetric
864        let (x1, f1, g1, x2, f2, g2) = (0.0, 1.0, -5.0, 1.0, 0.5, 1.0);
865        let result = cubic_interpolate(x1, f1, g1, x2, f2, g2, None);
866        assert!(
867            (result - 0.4606553370833684).abs() < tolerance,
868            "Asymmetric: Result {} should be 0.4606553370833684",
869            result
870        );
871
872        // not good value
873        let (x1, f1, g1, x2, f2, g2) = (
874            1.231232145,
875            -0.12567458754,
876            9.1231243007,
877            8.239105015,
878            -100.9012398021,
879            123201321.0293982,
880        );
881        let result_1 = cubic_interpolate(x1, f1, g1, x2, f2, g2, None);
882        let result_2 = cubic_interpolate(x1, f1, g1, x2, f2, g2, Some((-4.4, 4.4)));
883        assert!(
884            (result_1 - 5.9031480234724434).abs() < tolerance,
885            "not good value 1: Result {} should be 5.9031480234724434",
886            result
887        );
888        assert!(
889            (result_2 - 4.4000000000000004).abs() < tolerance,
890            "not good value 2: Result {} should be 4.4000000000000004",
891            result
892        );
893    }
894    #[test]
895    fn test_strong_wolfe_direct_comparison() {
896        let device = Device::default().autodiff();
897        let tol = 1e-6;
898
899        {
900            let x = Tensor::<1>::from_floats([2.1321912957_f64], &device);
901            let d = Tensor::<1>::from_floats([0.91312321_f64], &device);
902            let t_initial = 1.213132_f64;
903            fn func(x_base: &Tensor<1>, t_val: f64, d_vec: &Tensor<1>) -> (f64, Tensor<1>) {
904                let curr_x = x_base.clone().add(d_vec.clone().mul_scalar(t_val));
905                let x2 = curr_x.clone().mul(curr_x.clone());
906                let x3 = x2.clone().mul(curr_x.clone());
907                let x4 = x2.clone().mul(x2.clone());
908
909                // f(x) = x^4 - 2*x^2 + x
910                let f_elements = x4 - x2.mul_scalar(2.0) + curr_x.clone();
911
912                let f_val = f_elements.sum().into_scalar();
913
914                // g(x) = 4*x^3 - 4*x + 1
915                let g = x3.mul_scalar(4.0) - curr_x.clone().mul_scalar(4.0)
916                    + Tensor::ones_like(&curr_x);
917
918                (f_val, g)
919            }
920            let (f_init, g_init) = func(&x, 0.0, &d);
921            let gtd_init = g_init.clone().dot(d.clone()).into_scalar::<f64>();
922            println!("Initial State: f={},gtd = {}", f_init, gtd_init);
923            assert!((f_init - 13.7080059052).abs() < tol);
924            assert!((gtd_init - 28.5305728912).abs() < tol);
925            let mut obj_func = |xb: &Tensor<1>, tv: f64, dv: &Tensor<1>| func(xb, tv, dv);
926
927            let (f_final, _g_final, t_final, evals) = strong_wolfe(
928                &mut obj_func,
929                &x,
930                t_initial,
931                &d,
932                f_init,
933                g_init,
934                gtd_init,
935                1e-4, // c1
936                0.9,  // c2
937                1e-9, // tolerance_change
938                10,   // max_ls
939            );
940            let g_f = _g_final.into_scalar::<f64>();
941            println!(
942                "f_final:{:?},_g_final:{:?},t_final:{:?},evals:{:?}",
943                f_final, g_f, t_final, evals
944            );
945            assert!((f_final - 13.708005905151367).abs() < tol);
946            assert!((g_f - 31.2450428009).abs() < tol);
947            assert!((t_final - 0.0).abs() < tol);
948            assert!((evals == 11));
949        }
950    }
951    #[test]
952    fn test_lbfgs_strong_wolfe_comparison() {
953        let device = Device::default().autodiff();
954        let tol = 1e-5;
955        let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device);
956        let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device);
957        let weight = TensorData::from([[0.5f64]]);
958        let bias = TensorData::from([0.1f64]);
959        let module = given_linear_layer(weight, bias, &device);
960
961        let mut optimizer = LBFGSConfig::new()
962            .with_line_search_fn(LineSearchFn::StrongWolfe)
963            .init();
964        let mut closure = |mod_in: Linear| {
965            let output = mod_in.forward(x_data.clone());
966            let loss = burn_nn::loss::MseLoss::new().forward(
967                output,
968                y_true.clone(),
969                burn_nn::loss::Reduction::Sum,
970            );
971
972            let grads = loss.backward();
973            let grads_params = GradientsParams::from_grads(grads, &mod_in);
974
975            (loss.into_scalar::<f64>(), grads_params)
976        };
977        let initial_loss = closure(module.clone()).0;
978        assert!((initial_loss - 50.1300048828).abs() < tol);
979        let (updated_module, final_loss) = optimizer.step(0.001, module, &mut closure);
980        assert!((final_loss - 0.0234732367).abs() < tol);
981        let optimized_data: f64 = updated_module.weight.val().into_scalar();
982        let optimized_bias: f64 = updated_module.bias.as_ref().unwrap().val().into_scalar();
983        assert!((optimized_data - 2.0570652485).abs() < tol);
984        assert!((optimized_bias - 0.8106800914).abs() < tol);
985    }
986
987    // A burnpack round-trip of the L-BFGS state (which holds `Vec<Tensor>` history buffers, optional
988    // tensors and optional scalars) must restore enough that a further step agrees with the original.
989    #[test]
990    fn test_lbfgs_burnpack_round_trip() {
991        let device = Device::default().autodiff();
992        let tol = 1e-6;
993        let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device);
994        let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device);
995        let module = given_linear_layer(
996            TensorData::from([[0.5f64]]),
997            TensorData::from([0.1f64]),
998            &device,
999        );
1000
1001        let make_closure = || {
1002            let x = x_data.clone();
1003            let y = y_true.clone();
1004            move |mod_in: Linear| {
1005                let output = mod_in.forward(x.clone());
1006                let loss = burn_nn::loss::MseLoss::new().forward(
1007                    output,
1008                    y.clone(),
1009                    burn_nn::loss::Reduction::Sum,
1010                );
1011                let grads = loss.backward();
1012                let grads_params = GradientsParams::from_grads(grads, &mod_in);
1013                (loss.into_scalar::<f64>(), grads_params)
1014            }
1015        };
1016
1017        let mut optimizer = LBFGSConfig::new()
1018            .with_line_search_fn(LineSearchFn::StrongWolfe)
1019            .init();
1020        let (module, _) = optimizer.step(0.001, module, &mut make_closure());
1021
1022        // Round-trip the optimizer state. State tensors live on the inner (non-autodiff) backend.
1023        let bytes = optimizer.into_bytes().unwrap();
1024        let mut reloaded = LBFGSConfig::new()
1025            .with_line_search_fn(LineSearchFn::StrongWolfe)
1026            .init()
1027            .from_bytes(bytes)
1028            .unwrap();
1029
1030        // A further identical step on each optimizer must agree — exercising the restored history.
1031        let (_, loss_original) = optimizer.step(0.001, module.clone(), &mut make_closure());
1032        let (_, loss_reloaded) = reloaded.step(0.001, module, &mut make_closure());
1033        assert!(
1034            (loss_original - loss_reloaded).abs() < tol,
1035            "losses differ after burnpack round-trip: {loss_original} vs {loss_reloaded}"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_lbfgs_no_strong_wolfe_comparison() {
1041        let device = Device::default().autodiff();
1042        let tol = 1e-5;
1043        let x_data = Tensor::<2>::from_data([[1.0], [2.0], [3.0]], &device);
1044        let y_true = Tensor::<2>::from_data([[3.0], [5.0], [7.0]], &device);
1045        let weight = TensorData::from([[0.5f64]]);
1046        let bias = TensorData::from([0.1f64]);
1047        let module = given_linear_layer(weight, bias, &device);
1048
1049        let mut optimizer = LBFGSConfig::new()
1050            .with_line_search_fn(LineSearchFn::None)
1051            .init();
1052        let mut closure = |mod_in: Linear| {
1053            let output = mod_in.forward(x_data.clone());
1054            let loss = burn_nn::loss::MseLoss::new().forward(
1055                output,
1056                y_true.clone(),
1057                burn_nn::loss::Reduction::Sum,
1058            );
1059
1060            let grads = loss.backward();
1061            let grads_params = GradientsParams::from_grads(grads, &mod_in);
1062
1063            (loss.into_scalar::<f64>(), grads_params)
1064        };
1065        let initial_loss = closure(module.clone()).0;
1066        assert!((initial_loss - 50.1300048828).abs() < tol);
1067        let (updated_module, final_loss) = optimizer.step(0.001, module, &mut closure);
1068        assert!((final_loss - 48.2181930542).abs() < tol);
1069        let optimized_data: f64 = updated_module.weight.val().into_scalar();
1070        let optimized_bias: f64 = updated_module.bias.as_ref().unwrap().val().into_scalar();
1071
1072        assert!((optimized_data - 0.5302446192).abs() < tol);
1073        assert!((optimized_bias - 0.1142520783).abs() < tol);
1074    }
1075}