Skip to main content

mini_ode/
optimizers.rs

1//! Nonlinear optimization algorithms which are required by some ODE solvers
2//!
3//! The user may create objects containing optimizer configuration and pass it to ODE solver.
4
5use anyhow::anyhow;
6use std::fmt;
7use tch::{IndexOp, Tensor};
8
9#[cfg(feature = "warnings")]
10use tracing::warn;
11
12#[cfg(not(feature = "warnings"))]
13macro_rules! warn {
14    ($($arg:tt)*) => {};
15}
16
17/// Optimizer interface common for any optimizer in the library
18pub trait Optimizer: Send + Sync + fmt::Display {
19    /// Solves the problem of optimization of function `function` starting from point `x0`
20    ///
21    /// # Arguments
22    /// * `function` - A closure that takes a 1D tensor `x` and returns a scalar tensor.
23    ///   representing the objective value to minimize.
24    /// * `x0` - Initial guess, 1D tensor accepted by `function`.
25    ///
26    /// # Returns
27    /// Optimal `x`, or error if optimization fails.
28    ///
29    /// # Panics
30    /// May panic if libtorch tensor operations fail.
31    fn optimize(&self, function: &dyn Fn(&Tensor) -> Tensor, x0: &Tensor)
32    -> anyhow::Result<Tensor>;
33}
34
35/// Helper function to validate that optimizer output is finite
36fn validate_optimizer_output(tensor: &Tensor, optimizer_name: &str) -> anyhow::Result<()> {
37    if tensor.isfinite().f_all()?.f_int64_value(&[])? == 0 {
38        anyhow::bail!(
39            "Optimizer {} produced non-finite result (NaN/Inf)",
40            optimizer_name
41        );
42    }
43    Ok(())
44}
45
46/// Newton optimization algorithm
47///
48/// This struct configures the Newton method, a second-order optimizer that uses the
49/// Hessian matrix for quadratic approximations.
50///
51/// # Fields
52/// * `max_steps` - Maximum number of optimization steps.
53/// * `gtol` - Optional tolerance for gradient norm (stop if ||grad|| < gtol).
54/// * `ftol` - Optional tolerance for change in objective value (stop if |f - prev_f| < ftol).
55pub struct Newton {
56    // Maximum number of optimization steps
57    max_steps: usize,
58    // Minimum gradient
59    gtol: Option<f64>,
60    // minimum change in the objective function between iterations
61    ftol: Option<f64>,
62}
63
64/// Broyden-Fletcher-Goldfarb-Shanno optimization algorithm
65///
66/// This struct configures the BFGS quasi-Newton method, which approximates the inverse
67/// Hessian using rank-2 updates. It is as memory-intensive as regular Newton method
68/// (O(n^2) storage) but it does not require double differentiation.
69///
70/// # Fields
71/// * `max_steps` - Maximum number of optimization steps.
72/// * `gtol` - Optional tolerance for gradient norm (stop if ||grad|| < gtol).
73/// * `ftol` - Optional tolerance for change in objective value (stop if |f - prev_f| < ftol).
74pub struct BFGS {
75    // Maximum number of optimization steps
76    max_steps: usize,
77    // Minimum gradient
78    gtol: Option<f64>,
79    // minimum change in the objective function between iterations
80    ftol: Option<f64>,
81}
82
83/// Halley optimization algorithm
84///
85/// This struct configures the Halley method, a third-order optimizer that uses
86/// tensor of third order derivatives.
87///
88/// # Fields
89/// * `max_steps` - Maximum number of optimization steps.
90/// * `gtol` - Optional tolerance for gradient norm (stop if ||grad|| < gtol).
91/// * `ftol` - Optional tolerance for change in objective value (stop if |f - prev_f| < ftol).
92pub struct Halley {
93    // Maximum number of optimization steps
94    max_steps: usize,
95    // Minimum gradient
96    gtol: Option<f64>,
97    // minimum change in the objective function between iterations
98    ftol: Option<f64>,
99}
100
101/// Conjugate Gradient optimization algorithm
102///
103/// This struct configures the nonlinear conjugate gradient method with Polak-Ribiere+
104/// (PR+) beta and orthogonality-based restarts. It is gradient-only (first-order) and
105/// memory-efficient, suitable for large-scale problems.
106///
107/// # Fields
108/// * `max_steps` - Maximum number of optimization steps.
109/// * `gtol` - Optional tolerance for gradient norm (stop if ||grad|| < gtol).
110/// * `ftol` - Optional tolerance for change in objective value (stop if |f - prev_f| < ftol).
111pub struct CG {
112    // Maximum number of optimization steps
113    max_steps: usize,
114    // Minimum gradient
115    gtol: Option<f64>,
116    // Minimum change in the objective function between iterations
117    ftol: Option<f64>,
118}
119
120/// Computes the gradient of `function` at `x` using automatic differentiation.
121///
122/// # Arguments
123/// * `function` - A closure that takes a 1D tensor `x` and returns a scalar tensor.
124/// * `x` - Evaluation point (1D tensor).
125///
126/// # Returns
127/// Gradient tensor at `x`.
128pub(crate) fn differentiate(
129    function: &dyn Fn(&Tensor) -> Tensor,
130    x: &Tensor,
131) -> anyhow::Result<Tensor> {
132    let x_with_grad = x.f_detach()?.copy().set_requires_grad(true);
133    let y = function(&x_with_grad);
134
135    if y.size() != [] as [i64; 0] {
136        return Err(anyhow!(
137            "Bad shape of `y`. Expected [], but got {:?}",
138            y.size()
139        ));
140    }
141
142    if !y.requires_grad() {
143        return Ok(tch::Tensor::f_zeros(x.size(), (x.kind(), x.device()))?);
144    }
145
146    let gradient = tch::Tensor::f_run_backward(&[y], &[x_with_grad], false, false)?[0].copy();
147
148    // Note: We don't validate here because gradients can legitimately be non-finite
149    // during exploration; caller will validate final result
150
151    Ok(gradient)
152}
153
154/// Computes the gradient and Hessian of `function` at `x` using automatic differentiation.
155///
156/// # Arguments
157/// * `function` - A closure that takes a 1D tensor `x` and returns a scalar tensor.
158/// * `x` - Evaluation point (1D tensor).
159/// # Returns
160/// Tuple `(grad, hessian)`, both detached tensors. `grad` is 1D, `hessian` is 2D.
161pub(crate) fn gradient_and_hessian(
162    function: &dyn Fn(&Tensor) -> Tensor,
163    x: &Tensor,
164) -> anyhow::Result<(Tensor, Tensor)> {
165    let x_with_grad = x.f_detach()?.copy().set_requires_grad(true);
166    let y = function(&x_with_grad);
167
168    if y.size() != [] as [i64; 0] {
169        return Err(anyhow!(
170            "Bad shape of `y`. Expected [], but got {:?}",
171            y.size()
172        ));
173    }
174
175    if !y.requires_grad() {
176        return Ok((
177            tch::Tensor::f_zeros(x.size(), (x.kind(), x.device()))?,
178            tch::Tensor::f_zeros([x.size()[0], x.size()[0]], (x.kind(), x.device()))?,
179        ));
180    }
181
182    // keep_graph = true (this graph is needed for some functions during second differentiation)
183    // create_graph = true (allow calculating second derivatives)
184    let grad = Tensor::f_run_backward(&[y], &[&x_with_grad], true, true)?[0].copy();
185    let grad_len = grad.size()[0];
186    let grad_kind = grad.kind();
187    let grad_device = grad.device();
188
189    // If gradient is constant, immediately return gradient and zero hessian
190    // It is not possible to differentiate constants in torch
191    if !grad.requires_grad() {
192        return Ok((
193            grad,
194            Tensor::f_zeros([grad_len, grad_len], (grad_kind, grad_device))?,
195        ));
196    }
197
198    let mut vectors = Vec::<Tensor>::with_capacity(grad_len as usize);
199    for i in 0..grad_len {
200        // keep_graph = true (we need to run backward pass multiple times - in each iteration of the loop)
201        // create_graph = false (we don't need to differentiate three times)
202        vectors.append(&mut Tensor::f_run_backward(
203            &[grad.i(i)],
204            &[&x_with_grad],
205            true,
206            false,
207        )?);
208    }
209
210    // Detach autograd computation graph
211    let grad = grad.f_detach()?;
212    // Stack slices of the Hessian matrix and detach autograd computation graph
213    let hessian = Tensor::f_stack(&vectors, 0)?.f_detach()?;
214
215    Ok((grad, hessian))
216}
217
218/// Computes the gradient, Hessian and third derivatives tensor of `function` at `x` using automatic differentiation.
219///
220/// # Arguments
221/// * `function` - A closure that takes a 1D tensor `x` and returns a scalar tensor.
222/// * `x` - Evaluation point (1D tensor).
223/// # Returns
224/// Tuple `(grad, hessian, d3_tensor)`, both detached tensors. `grad` is 1D, `hessian` is 2D, `d3_tensor` is 3D.
225pub(crate) fn derivative_tensors_123(
226    function: &dyn Fn(&Tensor) -> Tensor,
227    x: &Tensor,
228) -> anyhow::Result<(Tensor, Tensor, Tensor)> {
229    let x_with_grad = x.f_detach()?.copy().set_requires_grad(true);
230    let y = function(&x_with_grad);
231
232    if y.size() != [] as [i64; 0] {
233        return Err(anyhow!(
234            "Bad shape of `y`. Expected [], but got {:?}",
235            y.size()
236        ));
237    }
238
239    if !y.requires_grad() {
240        return Ok((
241            tch::Tensor::f_zeros(x.size(), (x.kind(), x.device()))?,
242            tch::Tensor::f_zeros([x.size()[0], x.size()[0]], (x.kind(), x.device()))?,
243            tch::Tensor::f_zeros(
244                [x.size()[0], x.size()[0], x.size()[0]],
245                (x.kind(), x.device()),
246            )?,
247        ));
248    }
249
250    // keep_graph = true (this graph is needed for some functions during second differentiation)
251    // create_graph = true (allow calculating second derivatives)
252    let grad = Tensor::f_run_backward(&[y], &[&x_with_grad], true, true)?[0].copy();
253    let grad_len = grad.size()[0];
254    let grad_kind = grad.kind();
255    let grad_device = grad.device();
256
257    // If gradient is constant, immediately return gradient zero hessian and
258    // zero tensor of third order derivatives
259    // It is not possible to differentiate constants in torch
260    if !grad.requires_grad() {
261        return Ok((
262            grad,
263            Tensor::f_zeros([grad_len, grad_len], (grad_kind, grad_device))?,
264            Tensor::f_zeros([grad_len, grad_len, grad_len], (grad_kind, grad_device))?,
265        ));
266    }
267
268    let mut vectors = Vec::<Tensor>::with_capacity(grad_len as usize);
269    for i in 0..grad_len {
270        // keep_graph = true (we need to run backward pass multiple times - in each iteration of the loop)
271        // create_graph = true (we need to differentiate three times)
272        vectors.append(&mut Tensor::f_run_backward(
273            &[grad.i(i)],
274            &[&x_with_grad],
275            true,
276            true,
277        )?);
278    }
279
280    // Stack slices of the Hessian matrix
281    let hessian = Tensor::f_stack(&vectors, 0)?;
282
283    // If gradient is constant, immediately return gradient zero hessian and
284    // zero tensor of third order derivatives
285    // It is not possible to differentiate constants in torch
286    if !hessian.requires_grad() {
287        return Ok((
288            grad,
289            hessian,
290            Tensor::f_zeros([grad_len, grad_len, grad_len], (grad_kind, grad_device))?,
291        ));
292    }
293
294    let mut vectors2 = Vec::<Tensor>::with_capacity(grad_len as usize);
295    for i in 0..grad_len {
296        let mut vectors1 = Vec::<Tensor>::with_capacity(grad_len as usize);
297        for j in 0..grad_len {
298            vectors1.append(&mut Tensor::f_run_backward(
299                &[hessian.i((i, j))],
300                &[&x_with_grad],
301                true,
302                false,
303            )?);
304        }
305        vectors2.push(Tensor::f_stack(&vectors1, 0)?);
306    }
307
308    // Detach autograd computation graph
309    let grad = grad.f_detach()?;
310    let hessian = hessian.f_detach()?;
311    // Stack slices of the tensor of third derivatives and detach autograd computation graph
312    let d3_tensor = Tensor::f_stack(&vectors2, 0)?.f_detach()?;
313
314    Ok((grad, hessian, d3_tensor))
315}
316
317/// Minimum step value.
318const P0: f64 = 0.0000000001f64;
319
320/// Golden ratio squared (phi^2)
321const PHI2: f64 = 2.618033988749894848207f64;
322/// Reciprocal of golden ratio (1/phi)
323const RPHI: f64 = 0.618033988749894848207f64;
324
325/// Performs a golden section line search to find a step size that approximately minimizes
326/// `function` along `direction` from `x0`, subject to tolerance `atol`.
327///
328/// # Arguments
329/// * `x0` - Starting point (1D tensor).
330/// * `direction` - Search direction (1D tensor).
331/// * `function` - Objective function.
332/// * `atol` - Absolute tolerance for step size convergence.
333///
334/// # Returns
335/// Optimal step tensor.
336fn choose_step_golden_section(
337    x0: &Tensor,
338    direction: &Tensor,
339    function: &dyn Fn(&Tensor) -> Tensor,
340    atol: f64,
341) -> anyhow::Result<Tensor> {
342    let (mut x1, mut x2, mut x3, mut x4): (f64, f64, f64, f64);
343    let (fx1, mut fx3, mut fx4): (f64, f64, f64);
344
345    fx1 = function(&x0).f_double_value(&[])?;
346
347    x1 = 0.;
348    // Heuristics: Try to set x2 based on atol value. If we succeed, we can
349    //             skip some forward search iterations.
350    let fx_guess = function(&(x0 + direction * atol * 15.)).f_double_value(&[])?;
351    x2 = if !fx_guess.is_finite() || fx_guess > fx1 {
352        P0
353    } else {
354        atol * 15.
355    };
356    // Forward search - continues even if non-finite encountered (gentle handling)
357    let mut fx = function(&(x0 + direction * x2)).f_double_value(&[])?;
358    let mut forward_iters: u32 = 0;
359    while fx <= fx1 {
360        let new_x2 = x1 + (x2 - x1) * PHI2;
361        fx = function(&(x0 + direction * new_x2)).f_double_value(&[])?;
362        if !fx.is_finite() {
363            break;
364        }
365        x2 = new_x2;
366        forward_iters += 1;
367    }
368
369    x3 = x2 - (x2 - x1) * RPHI;
370    x4 = x1 + (x2 - x1) * RPHI;
371    fx3 = function(&(x0 + direction * x3)).f_double_value(&[])?;
372    fx4 = function(&(x0 + direction * x4)).f_double_value(&[])?;
373
374    let mut refine_iters: u32 = 0;
375    while x2 - x1 > atol && refine_iters < 500 {
376        if fx3 < fx4 {
377            x2 = x4;
378
379            fx4 = fx3;
380            x3 = x2 - (x2 - x1) * RPHI;
381            x4 = x1 + (x2 - x1) * RPHI;
382            fx3 = function(&(x0 + direction * x3)).f_double_value(&[])?;
383        } else {
384            x1 = x3;
385
386            fx3 = fx4;
387            x3 = x2 - (x2 - x1) * RPHI;
388            x4 = x1 + (x2 - x1) * RPHI;
389            fx4 = function(&(x0 + direction * x4)).f_double_value(&[])?;
390        }
391        refine_iters += 1;
392    }
393
394    // Warnings after line search completes
395    if forward_iters >= 100 {
396        warn!(
397            "golden section line search: forward search took {} iterations without bracketing a minimum; direction may be poor",
398            forward_iters
399        );
400    }
401
402    if refine_iters >= 200 {
403        warn!(
404            "golden section line search: refinement took {} iterations; atol may be too small or objective flat",
405            refine_iters
406        );
407    }
408
409    Ok(direction * ((x1 + x2) / 2.))
410}
411
412/// Performs a backtracking line search to find a step size satisfying the Armijo condition.
413///
414/// # Arguments
415/// * `x0` - Starting point (1D tensor).
416/// * `direction` - Descent direction (1D tensor).
417/// * `function` - Objective function.
418/// * `grad` - Gradient at `x0`.
419/// * `alpha` - Armijo parameter (0 < alpha < 1, 0.1 is recommended).
420/// * `beta` - Backtracking factor (0 < beta < 1, 0.9 is recommended).
421///
422/// # Returns
423/// Step tensor.
424fn choose_step_backtracking(
425    x0: &Tensor,
426    direction: &Tensor,
427    function: &dyn Fn(&Tensor) -> Tensor,
428    grad: &Tensor,
429    alpha: f64,
430    beta: f64,
431) -> anyhow::Result<Tensor> {
432    let fx0 = function(&x0).f_double_value(&[])?;
433
434    let mut t = 1f64;
435    let mut backtrack_iters: u32 = 0;
436
437    while {
438        let fx = function(&(x0 + direction * t)).f_double_value(&[])?;
439
440        if !fx.is_finite() {
441            true
442        } else {
443            fx > fx0
444                + grad
445                    .f_reshape([-1])?
446                    .f_dot(&direction.f_reshape([-1])?)?
447                    .f_double_value(&[])?
448                    * alpha
449                    * t
450        }
451    } {
452        t *= beta;
453        backtrack_iters += 1;
454        if t < 1e-30 {
455            break;
456        }
457    }
458
459    // Warnings after line search completes
460    if backtrack_iters >= 100 {
461        warn!(
462            "backtracking line search: {} iterations without satisfying Armijo condition; direction may not be a descent direction",
463            backtrack_iters
464        );
465    }
466
467    if t < 1e-30 {
468        warn!(
469            "backtracking line search: step size collapsed to {:.3e}; optimizer may be stuck",
470            t
471        );
472    }
473
474    Ok(direction.copy() * t)
475}
476
477impl CG {
478    pub fn new(max_steps: usize, gtol: Option<f64>, ftol: Option<f64>) -> Self {
479        Self {
480            max_steps,
481            gtol,
482            ftol,
483        }
484    }
485}
486
487impl Optimizer for CG {
488    /// Creates a new CG optimizer with the given parameters.
489    ///
490    /// # Arguments
491    /// * `max_steps` - Maximum iterations.
492    /// * `gtol` - Optional gradient tolerance.
493    /// * `ftol` - Optional function value change tolerance.
494    ///
495    /// # Returns
496    /// Configured CG instance.
497    fn optimize(
498        &self,
499        function: &dyn Fn(&Tensor) -> Tensor,
500        x0: &Tensor,
501    ) -> anyhow::Result<Tensor> {
502        // Ensure that rank of the initital guess tensor is 1
503        if x0.size().len() != 1 {
504            return Err(anyhow!("`x0` must have rank 1"));
505        }
506
507        let mut prev3_step_norm = 0f64;
508        let mut prev2_step_norm = 0f64;
509        let mut prev_step_norm = 0f64;
510
511        let mut prev_grad = Tensor::f_zeros_like(&x0)?;
512        let mut prev_direction = Tensor::f_zeros_like(&x0)?;
513        let mut prev_y: Option<Tensor> = None;
514        let mut x = x0.copy();
515
516        let mut warned_nonfinite_grad = false;
517        let mut warned_beta_clamp = false;
518        let mut warned_nonfinite_iter = false;
519
520        for step_num in 0..self.max_steps {
521            let grad = match differentiate(function, &x) {
522                Ok(grad) => grad,
523                Err(e) => {
524                    return Err(anyhow!(
525                        "Runtime error: Differentiation failed in CG optimizer: {}",
526                        e
527                    ));
528                }
529            };
530
531            // Warning: non-finite gradient
532            if !warned_nonfinite_grad && grad.isfinite().f_all()?.f_int64_value(&[])? == 0 {
533                warn!("CG: non-finite gradient detected; function may be ill-defined");
534                warned_nonfinite_grad = true;
535            }
536
537            // Stop if gradient is smaller than `gtol`
538            if let Some(gtol) = self.gtol {
539                if grad.norm().f_double_value(&[])? < gtol {
540                    // Final result validation
541                    validate_optimizer_output(&x, "CG")?;
542                    return Ok(x);
543                }
544            } else {
545                // This check is necessary. Continuation of the algorithm
546                // with gradient equal to exactly zero leads to NaN appearing
547                // in the result.
548                if grad.norm().f_double_value(&[])? == 0. {
549                    // Final result validation
550                    validate_optimizer_output(&x, "CG")?;
551                    return Ok(x);
552                }
553            }
554
555            // Calculate direction with PR+ and orthogonality-based restart
556            let direction = match step_num {
557                0 => -&grad,
558                _ => {
559                    let orthogonality_measure = grad
560                        .f_reshape([-1])?
561                        .f_dot(&prev_grad.f_reshape([-1])?)?
562                        .f_abs()?
563                        / grad.f_reshape([-1])?.f_dot(&grad.f_reshape([-1])?)?;
564                    if orthogonality_measure.f_double_value(&[])? > 0.2 {
565                        // Restart
566                        -&grad
567                    } else {
568                        let beta = grad
569                            .f_reshape([-1])?
570                            .f_dot(&(&grad - &prev_grad).f_reshape([-1])?)?
571                            / prev_grad
572                                .f_reshape([-1])?
573                                .f_dot(&prev_grad.f_reshape([-1])?)?;
574                        // Clamp beta to be nonnegative (PR+)
575                        let beta = if beta.f_double_value(&[])? > 0. {
576                            beta
577                        } else {
578                            tch::Tensor::f_zeros_like(&beta)?
579                        };
580                        // Clamp beta to not be too large (this may result in numerical instability)
581                        let beta = if beta.f_double_value(&[])? > 1e12 {
582                            if !warned_beta_clamp {
583                                warn!("CG: beta clamped to 1e12; optimizer may be diverging");
584                                warned_beta_clamp = true;
585                            }
586                            tch::Tensor::f_ones_like(&beta)? * 1e12
587                        } else {
588                            beta
589                        };
590
591                        -&grad + beta * &prev_direction
592                    }
593                }
594            };
595
596            // Calculate linesearch_atol based on previous step norms
597            let linesearch_atol =
598                P0.max(prev_step_norm.min(prev2_step_norm).min(prev3_step_norm) / 1000.);
599
600            // Choose step in direction `direction`
601            // Note: golden section handles non-finite gracefully during search
602            let step = choose_step_golden_section(&x, &direction, &function, linesearch_atol)?;
603
604            // Update previous step norms
605            prev3_step_norm = prev2_step_norm;
606            prev2_step_norm = prev_step_norm;
607            prev_step_norm = step.f_norm()?.f_double_value(&[])?;
608
609            // Apply step
610            x = x + step;
611
612            // Warning: non-finite iterate
613            if !warned_nonfinite_iter && x.isfinite().f_all()?.f_int64_value(&[])? == 0 {
614                warn!("CG: non-finite iterate detected; step size may be too large");
615                warned_nonfinite_iter = true;
616            }
617
618            // Stop if change in function value is smaller than `ftol`
619            let y = function(&x);
620            if let (Some(prev_y), Some(ftol)) = (prev_y, self.ftol) {
621                if (&prev_y - &y).f_double_value(&[])? < ftol {
622                    // Final result validation
623                    validate_optimizer_output(&x, "CG")?;
624                    return Ok(x);
625                }
626            }
627            prev_y = Some(y);
628
629            // Update previous gradient value and previous direction value
630            prev_grad = grad;
631            prev_direction = direction;
632        }
633
634        // Final result validation
635        validate_optimizer_output(&x, "CG")?;
636        Ok(x)
637    }
638}
639
640impl fmt::Display for CG {
641    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
642        let mut string = String::from("CG(");
643
644        string = string + "max_steps=" + self.max_steps.to_string().as_str();
645        if let Some(gtol) = self.gtol {
646            string = string + ", gtol=" + gtol.to_string().as_str();
647        }
648        if let Some(ftol) = self.ftol {
649            string = string + ", ftol=" + ftol.to_string().as_str();
650        }
651        string = string + ")";
652
653        write!(f, "{}", string)
654    }
655}
656
657impl BFGS {
658    /// Creates a new BFGS optimizer with the given parameters.
659    ///
660    /// # Arguments
661    /// * `max_steps` - Maximum iterations.
662    /// * `gtol` - Optional gradient tolerance.
663    /// * `ftol` - Optional function value change tolerance.
664    ///
665    /// # Returns
666    /// Configured BFGS instance.
667    pub fn new(max_steps: usize, gtol: Option<f64>, ftol: Option<f64>) -> Self {
668        Self {
669            max_steps,
670            gtol,
671            ftol,
672        }
673    }
674}
675
676impl Optimizer for BFGS {
677    fn optimize(
678        &self,
679        function: &dyn Fn(&Tensor) -> Tensor,
680        x0: &Tensor,
681    ) -> anyhow::Result<Tensor> {
682        // Ensure that rank of the initital guess tensor is 1
683        if x0.size().len() != 1 {
684            return Err(anyhow!("`x0` must have rank 1"));
685        }
686
687        // Determine the device and kind for use in the function
688        let kind = x0.kind();
689        let device = x0.device();
690
691        let mut prev3_step_norm = 0f64;
692        let mut prev2_step_norm = 0f64;
693        let mut prev_step_norm = 0f64;
694
695        let x0_length = x0.size()[0];
696        let identity = match Tensor::f_eye(x0_length, (kind, device)) {
697            Ok(matrix) => matrix,
698            // BFGS requires a lot of resources.
699            // Give knowledgable error message to the user
700            // when BFGS fails due to unsufficient memory.
701            Err(tch::TchError::Torch(_)) => {
702                return Err(anyhow!(
703                    "Could not allocate {}x{} matrix. Maybe try less resourcefull algorithm.",
704                    x0_length,
705                    x0_length
706                ));
707            }
708            e => e.unwrap(),
709        };
710        let mut x = x0.copy();
711        let mut appr_inv_h = identity.copy();
712        let mut curr_grad = match differentiate(function, &x) {
713            Ok(grad) => grad,
714            Err(e) => {
715                return Err(anyhow!(
716                    "Runtime error: Differentiation failed in BFGS optimizer: {}",
717                    e
718                ));
719            }
720        };
721        let mut curr_y = function(&x);
722
723        // Ensure that output of `function` is a scalar
724        if curr_y.size() != Vec::<i64>::new() {
725            return Err(anyhow!("Output of function `function` must be scalar"));
726        }
727
728        let mut warned_inv_hess_large = false;
729
730        for _ in 0..self.max_steps {
731            // Check for stop condition
732            if let Some(gtol) = self.gtol {
733                if curr_grad.f_norm()?.f_double_value(&[])? < gtol {
734                    // Final result validation
735                    validate_optimizer_output(&x, "BFGS")?;
736                    return Ok(x);
737                }
738            } else {
739                // This check is necessary. Continuation of the algorithm
740                // with gradient equal to exactly zero leads to NaN appearing
741                // in the result.
742                if curr_grad.f_norm()?.f_double_value(&[])? == 0. {
743                    // Final result validation
744                    validate_optimizer_output(&x, "BFGS")?;
745                    return Ok(x);
746                }
747            }
748
749            // Calculate step direction base on the gradient and approximate hessian
750            let direction = (-appr_inv_h.f_mm(&curr_grad.f_reshape([-1, 1])?)?).f_reshape([-1])?;
751
752            // Calculate linesearch_atol based on previous step norms
753            let linesearch_atol =
754                P0.max(prev_step_norm.min(prev2_step_norm).min(prev3_step_norm) / 100.);
755
756            // Choose optimal step in given direction using line search
757            // Line search handles non-finite gracefully during search
758            let step = choose_step_golden_section(&x, &direction, function, linesearch_atol)?;
759
760            // Update previous step norms
761            prev3_step_norm = prev2_step_norm;
762            prev2_step_norm = prev_step_norm;
763            prev_step_norm = step.f_norm()?.f_double_value(&[])?;
764
765            // Apply step
766            x = x + &step;
767
768            // Check for stop contition
769            let y = function(&x);
770            if let Some(ftol) = self.ftol {
771                if (curr_y.f_double_value(&[])? - y.f_double_value(&[])?) < ftol {
772                    // Final result validation
773                    validate_optimizer_output(&x, "BFGS")?;
774                    return Ok(x);
775                }
776            }
777            curr_y = y;
778
779            let grad = match differentiate(function, &x) {
780                Ok(grad) => grad,
781                Err(e) => {
782                    return Err(anyhow!(
783                        "Runtime error: Differentiation failed in BFGS optimizer: {}",
784                        e
785                    ));
786                }
787            };
788            let gdiff = &grad - &curr_grad;
789
790            // Use Powell's dampening for gamma computation
791            // This prevents gamma from blowing up. Normal formula for gamma is 1/step.dot(gdiff)
792            let gamma = {
793                let delta = 0.0001;
794
795                let sty = step.f_dot(&gdiff)?.f_double_value(&[])?;
796                let step_norm_sq = step.f_dot(&step)?.f_double_value(&[])?;
797
798                let theta = if sty >= delta * step_norm_sq {
799                    1.
800                } else {
801                    let numerator = (1. - delta) * step_norm_sq;
802                    let denominator = step_norm_sq - sty;
803
804                    if denominator.abs() < 1e-10 {
805                        1.
806                    } else {
807                        (numerator / denominator).min(1.)
808                    }
809                };
810
811                let projection_factor = if step_norm_sq < 1e-10 {
812                    0.
813                } else {
814                    sty / step_norm_sq
815                };
816                let gdiff_prime = &gdiff * theta + &step * ((1. - theta) * projection_factor);
817                let sty_prime = step.f_dot(&gdiff_prime)?.f_double_value(&[])?;
818
819                if sty_prime.abs() < 1e-10 {
820                    1. / (delta * step_norm_sq + 1e-10)
821                } else {
822                    1. / sty_prime
823                }
824            };
825
826            // Compute approximation of inverse Hessian
827            appr_inv_h = (&identity
828                - gamma * step.f_reshape([-1, 1])?.f_mm(&gdiff.f_reshape([1, -1])?)?)
829            .f_mm(&appr_inv_h)?
830            .f_mm(
831                &(&identity - gamma * gdiff.f_reshape([-1, 1])?.f_mm(&step.f_reshape([1, -1])?)?),
832            )? + gamma * step.f_reshape([-1, 1])?.f_mm(&step.f_reshape([1, -1])?)?;
833
834            // Warning: large inverse Hessian norm
835            let inv_h_norm = appr_inv_h.f_norm()?.f_double_value(&[])?;
836            if !warned_inv_hess_large && inv_h_norm > 1e10 {
837                warn!(
838                    "BFGS: inverse Hessian approximation norm reached {:.3e}; problem may be ill-conditioned",
839                    inv_h_norm
840                );
841                warned_inv_hess_large = true;
842            }
843
844            curr_grad = grad;
845        }
846
847        // Final result validation
848        validate_optimizer_output(&x, "BFGS")?;
849        Ok(x)
850    }
851}
852
853impl fmt::Display for BFGS {
854    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
855        let mut string = String::from("BFGS(");
856
857        string = string + "max_steps=" + self.max_steps.to_string().as_str();
858        if let Some(gtol) = self.gtol {
859            string = string + ", gtol=" + gtol.to_string().as_str();
860        }
861        if let Some(ftol) = self.ftol {
862            string = string + ", ftol=" + ftol.to_string().as_str();
863        }
864
865        string = string + ")";
866
867        write!(f, "{}", string)
868    }
869}
870
871impl Newton {
872    /// Creates a new Newton optimizer with the given parameters.
873    ///
874    /// # Arguments
875    /// * `max_steps` - Maximum iterations.
876    /// * `gtol` - Optional gradient tolerance.
877    /// * `ftol` - Optional function value change tolerance.
878    ///
879    /// # Returns
880    /// Configured Newton instance.
881    pub fn new(max_steps: usize, gtol: Option<f64>, ftol: Option<f64>) -> Self {
882        Self {
883            max_steps,
884            gtol,
885            ftol,
886        }
887    }
888}
889
890impl Optimizer for Newton {
891    fn optimize(
892        &self,
893        function: &dyn Fn(&Tensor) -> Tensor,
894        x0: &Tensor,
895    ) -> anyhow::Result<Tensor> {
896        // Ensure that rank of the initital guess tensor is 1
897        if x0.size().len() != 1 {
898            return Err(anyhow!("`x0` must have rank 1"));
899        }
900
901        // Determine the device and kind for use in the function
902        let kind = x0.kind();
903        let device = x0.device();
904
905        let x0_length = x0.size()[0];
906
907        // Test for sufficient resources for storing Hessian
908        let _ = match Tensor::f_eye(x0_length, (kind, device)) {
909            Ok(matrix) => matrix,
910            // Give knowledgable error message to the user
911            // when there is unsufficient memory.
912            Err(tch::TchError::Torch(_)) => {
913                return Err(anyhow!(
914                    "Could not allocate {}x{} matrix. Maybe try less resourcefull algorithm.",
915                    x0_length,
916                    x0_length
917                ));
918            }
919            e => e.unwrap(),
920        };
921
922        let mut x = x0.copy();
923        let mut curr_y = function(&x);
924
925        // Ensure that output of `function` is a scalar
926        if curr_y.size() != Vec::<i64>::new() {
927            return Err(anyhow!("Output of function `function` must be scalar"));
928        }
929
930        let mut warned_damping_moderate = false;
931        let mut warned_damping_severe = false;
932
933        for _ in 0..self.max_steps {
934            let (curr_grad, curr_hessian) = match gradient_and_hessian(function, &x) {
935                Ok(gh) => gh,
936                Err(e) => {
937                    return Err(anyhow!(
938                        "Runtime error: Differentiation failed in Newton optimizer: {}",
939                        e
940                    ));
941                }
942            };
943
944            // Check for stop condition
945            if let Some(gtol) = self.gtol {
946                if curr_grad.f_norm()?.f_double_value(&[])? < gtol {
947                    // Final result validation
948                    validate_optimizer_output(&x, "Newton")?;
949                    return Ok(x);
950                }
951            } else {
952                // This check is necessary. Continuation of the algorithm
953                // with gradient equal to exactly zero leads to NaN appearing
954                // in the result.
955                if curr_grad.f_norm()?.f_double_value(&[])? == 0. {
956                    // Final result validation
957                    validate_optimizer_output(&x, "Newton")?;
958                    return Ok(x);
959                }
960            }
961
962            // Calculate step direction
963            let negative_grad = -curr_grad.f_reshape([-1, 1])?; // Negative gradient direction
964            let mut lambda = (negative_grad.f_norm()?.f_double_value(&[])? * 1e-3).max(1e-8); // Initial dampening factor
965            let direction = loop {
966                // We damp hessian until it is positive definite.
967                // For non-positive definite Hessian, Newton method may give unwanted results.
968                let damped_hessian =
969                    &curr_hessian + Tensor::f_eye(x0_length, (kind, device))? * lambda;
970
971                // Try to perform Banach-Cholesky decomposition of damped hessian
972                match damped_hessian.f_linalg_cholesky(false) {
973                    Ok(lower_triangular) => {
974                        // Hessian is positive-definite. Solve system with Banach-Cholesky decomposition
975                        let y = lower_triangular.f_linalg_solve_triangular(
976                            &negative_grad,
977                            false,
978                            true,
979                            false,
980                        )?;
981                        break lower_triangular
982                            .f_transpose(0, 1)?
983                            .f_linalg_solve_triangular(&y, true, true, false)?
984                            .reshape([-1]);
985                    }
986                    Err(_) => {
987                        // Hessian is not positive-definite. Try increasing dampening factor.
988                        lambda *= 10.;
989
990                        // Warnings for damping levels
991                        if !warned_damping_moderate && lambda >= 1e3 && lambda < 1e7 {
992                            warn!(
993                                "Newton: Hessian required damping factor {:.3e}; problem may be ill-conditioned",
994                                lambda
995                            );
996                            warned_damping_moderate = true;
997                        }
998
999                        if !warned_damping_severe && lambda >= 1e10 {
1000                            warn!(
1001                                "Newton: Hessian damping factor reached {:.3e}; falling back to pseudoinverse (Hessian is severely ill-conditioned)",
1002                                lambda
1003                            );
1004                            warned_damping_severe = true;
1005                        }
1006
1007                        if lambda > 1e10 {
1008                            // Dampening factor (lambda) exceeded maximum value. Fallback to pseudoinverse.
1009                            break curr_hessian
1010                                .f_linalg_pinv(1e-14, false)?
1011                                .f_mm(&negative_grad)?
1012                                .f_reshape([-1])?;
1013                        }
1014                    }
1015                }
1016            };
1017
1018            // Choose optimal step in given direction using line search
1019            // Backtracking handles non-finite gracefully during search
1020            let step = choose_step_backtracking(&x, &direction, function, &curr_grad, 0.1, 0.9)?;
1021
1022            // Apply step
1023            x = x + &step;
1024
1025            // Check for stop contition
1026            let y = function(&x);
1027            if let Some(ftol) = self.ftol {
1028                if (curr_y.f_double_value(&[])? - y.f_double_value(&[])?) < ftol {
1029                    // Final result validation
1030                    validate_optimizer_output(&x, "Newton")?;
1031                    return Ok(x);
1032                }
1033            }
1034            curr_y = y;
1035        }
1036
1037        // Final result validation
1038        validate_optimizer_output(&x, "Newton")?;
1039        Ok(x)
1040    }
1041}
1042
1043impl fmt::Display for Newton {
1044    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1045        let mut string = String::from("Newton(");
1046
1047        string = string + "max_steps=" + self.max_steps.to_string().as_str();
1048        if let Some(gtol) = self.gtol {
1049            string = string + ", gtol=" + gtol.to_string().as_str();
1050        }
1051        if let Some(ftol) = self.ftol {
1052            string = string + ", ftol=" + ftol.to_string().as_str();
1053        }
1054
1055        string = string + ")";
1056
1057        write!(f, "{}", string)
1058    }
1059}
1060
1061impl Halley {
1062    /// Creates a new Halley optimizer with the given parameters.
1063    ///
1064    /// # Arguments
1065    /// * `max_steps` - Maximum iterations.
1066    /// * `gtol` - Optional gradient tolerance.
1067    /// * `ftol` - Optional function value change tolerance.
1068    ///
1069    /// # Returns
1070    /// Configured Halley instance.
1071    pub fn new(max_steps: usize, gtol: Option<f64>, ftol: Option<f64>) -> Self {
1072        Self {
1073            max_steps,
1074            gtol,
1075            ftol,
1076        }
1077    }
1078}
1079
1080impl Optimizer for Halley {
1081    fn optimize(
1082        &self,
1083        function: &dyn Fn(&Tensor) -> Tensor,
1084        x0: &Tensor,
1085    ) -> anyhow::Result<Tensor> {
1086        // Ensure that rank of the initital guess tensor is 1
1087        if x0.size().len() != 1 {
1088            return Err(anyhow!("`x0` must have rank 1"));
1089        }
1090
1091        // Determine the device and kind for use in the function
1092        let kind = x0.kind();
1093        let device = x0.device();
1094
1095        let x0_length = x0.size()[0];
1096
1097        // Test for sufficient resources for storing tensor of third order derivatives
1098        let _ = match Tensor::f_zeros([x0_length, x0_length, x0_length], (kind, device)) {
1099            Ok(matrix) => matrix,
1100            // Give knowledgable error message to the user
1101            // when there is unsufficient memory.
1102            Err(tch::TchError::Torch(_)) => {
1103                return Err(anyhow!(
1104                    "Could not allocate {}x{}x{} tensor. Maybe try less resourcefull algorithm.",
1105                    x0_length,
1106                    x0_length,
1107                    x0_length
1108                ));
1109            }
1110            e => e.unwrap(),
1111        };
1112
1113        let mut x = x0.copy();
1114        let mut curr_y = function(&x);
1115
1116        // Ensure that output of `function` is a scalar
1117        if curr_y.size() != Vec::<i64>::new() {
1118            return Err(anyhow!("Output of function `function` must be scalar"));
1119        }
1120
1121        let mut warned_pinv_large = false;
1122
1123        for _ in 0..self.max_steps {
1124            let (curr_grad, curr_hessian, curr_d3_tensor) =
1125                match derivative_tensors_123(function, &x) {
1126                    Ok(ghd3) => ghd3,
1127                    Err(e) => {
1128                        return Err(anyhow!(
1129                            "Runtime error: Differentiation failed in Halley optimizer: {}",
1130                            e
1131                        ));
1132                    }
1133                };
1134
1135            // Check for stop condition
1136            if let Some(gtol) = self.gtol {
1137                if curr_grad.f_norm()?.f_double_value(&[])? < gtol {
1138                    // Final result validation
1139                    validate_optimizer_output(&x, "Halley")?;
1140                    return Ok(x);
1141                }
1142            } else {
1143                // This check is necessary. Continuation of the algorithm
1144                // with gradient equal to exactly zero leads to NaN appearing
1145                // in the result.
1146                if curr_grad.f_norm()?.f_double_value(&[])? == 0. {
1147                    // Final result validation
1148                    validate_optimizer_output(&x, "Halley")?;
1149                    return Ok(x);
1150                }
1151            }
1152
1153            // Calculate step direction
1154            let hessian_pinv = curr_hessian.f_linalg_pinv(1e-14, false)?;
1155
1156            // Warning: large pseudoinverse norm
1157            let pinv_norm = hessian_pinv.f_norm()?.f_double_value(&[])?;
1158            if !warned_pinv_large && pinv_norm > 1e8 {
1159                warn!(
1160                    "Halley: Hessian pseudoinverse norm is {:.3e}; Hessian may be ill-conditioned",
1161                    pinv_norm
1162                );
1163                warned_pinv_large = true;
1164            }
1165
1166            let neg_newton_dir = hessian_pinv.f_mm(&curr_grad.f_reshape([-1, 1])?)?;
1167            let direction = -hessian_pinv
1168                .f_mm(
1169                    &(curr_grad.f_reshape([-1, 1])?
1170                        + curr_d3_tensor
1171                            .f_matmul(&neg_newton_dir)?
1172                            .f_reshape([x0_length, x0_length])?
1173                            .f_mm(&neg_newton_dir)?
1174                            * 0.5),
1175                )?
1176                .f_reshape([-1])?;
1177
1178            // Choose optimal step in given direction using line search
1179            // Backtracking handles non-finite gracefully during search
1180            let step = choose_step_backtracking(&x, &direction, function, &curr_grad, 0.1, 0.9)?;
1181
1182            // Apply step
1183            x = x + &step;
1184
1185            // Check for stop contition
1186            let y = function(&x);
1187            if let Some(ftol) = self.ftol {
1188                if (curr_y.f_double_value(&[])? - y.f_double_value(&[])?) < ftol {
1189                    // Final result validation
1190                    validate_optimizer_output(&x, "Halley")?;
1191                    return Ok(x);
1192                }
1193            }
1194            curr_y = y;
1195        }
1196
1197        // Final result validation
1198        validate_optimizer_output(&x, "Halley")?;
1199        Ok(x)
1200    }
1201}
1202
1203impl fmt::Display for Halley {
1204    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1205        let mut string = String::from("Halley(");
1206
1207        string = string + "max_steps=" + self.max_steps.to_string().as_str();
1208        if let Some(gtol) = self.gtol {
1209            string = string + ", gtol=" + gtol.to_string().as_str();
1210        }
1211        if let Some(ftol) = self.ftol {
1212            string = string + ", ftol=" + ftol.to_string().as_str();
1213        }
1214
1215        string = string + ")";
1216
1217        write!(f, "{}", string)
1218    }
1219}