Skip to main content

mini_ode/optimizers/
bfgs.rs

1use anyhow::anyhow;
2use std::fmt;
3use tch::Tensor;
4
5use super::Optimizer;
6
7use crate::utils::differentiation;
8use crate::utils::linesearch;
9use crate::utils::validation;
10use crate::utils::warnings::warn;
11
12/// Broyden-Fletcher-Goldfarb-Shanno optimization algorithm
13///
14/// This struct configures the BFGS quasi-Newton method, which approximates the inverse
15/// Hessian using rank-2 updates. It is as memory-intensive as regular Newton method
16/// (O(n^2) storage) but it does not require double differentiation.
17///
18/// # Fields
19/// * `max_steps` - Maximum number of optimization steps.
20/// * `gtol` - Optional tolerance for gradient norm (stop if ||grad|| < gtol).
21/// * `ftol` - Optional tolerance for change in objective value (stop if |f - prev_f| < ftol).
22pub struct BFGS {
23    // Maximum number of optimization steps
24    max_steps: usize,
25    // Minimum gradient
26    gtol: Option<f64>,
27    // minimum change in the objective function between iterations
28    ftol: Option<f64>,
29}
30
31impl BFGS {
32    /// Creates a new BFGS optimizer with the given parameters.
33    ///
34    /// # Arguments
35    /// * `max_steps` - Maximum iterations.
36    /// * `gtol` - Optional gradient tolerance.
37    /// * `ftol` - Optional function value change tolerance.
38    ///
39    /// # Returns
40    /// Configured BFGS instance.
41    pub fn new(max_steps: usize, gtol: Option<f64>, ftol: Option<f64>) -> Self {
42        Self {
43            max_steps,
44            gtol,
45            ftol,
46        }
47    }
48}
49
50impl Optimizer for BFGS {
51    fn optimize(
52        &self,
53        function: &dyn Fn(&Tensor) -> Tensor,
54        x0: &Tensor,
55    ) -> anyhow::Result<Tensor> {
56        // Ensure that rank of the initital guess tensor is 1
57        if x0.size().len() != 1 {
58            return Err(anyhow!("`x0` must have rank 1"));
59        }
60
61        // Determine the device and kind for use in the function
62        let kind = x0.kind();
63        let device = x0.device();
64
65        let mut prev3_step_norm = 0f64;
66        let mut prev2_step_norm = 0f64;
67        let mut prev_step_norm = 0f64;
68
69        let x0_length = x0.size()[0];
70        let identity = match Tensor::f_eye(x0_length, (kind, device)) {
71            Ok(matrix) => matrix,
72            // BFGS requires a lot of resources.
73            // Give knowledgable error message to the user
74            // when BFGS fails due to unsufficient memory.
75            Err(tch::TchError::Torch(_)) => {
76                return Err(anyhow!(
77                    "Could not allocate {}x{} matrix. Maybe try less resourcefull algorithm.",
78                    x0_length,
79                    x0_length
80                ));
81            }
82            e => e.unwrap(),
83        };
84        let mut x = x0.copy();
85        let mut appr_inv_h = identity.copy();
86        let mut curr_grad = match differentiation::differentiate(function, &x) {
87            Ok(grad) => grad,
88            Err(e) => {
89                return Err(anyhow!(
90                    "Runtime error: Differentiation failed in BFGS optimizer: {}",
91                    e
92                ));
93            }
94        };
95        let mut curr_y = function(&x);
96
97        // Ensure that output of `function` is a scalar
98        if curr_y.size() != Vec::<i64>::new() {
99            return Err(anyhow!("Output of function `function` must be scalar"));
100        }
101
102        let mut warned_inv_hess_large = false;
103
104        for _ in 0..self.max_steps {
105            // Check for stop condition
106            if let Some(gtol) = self.gtol {
107                if curr_grad.f_norm()?.f_double_value(&[])? < gtol {
108                    // Final result validation
109                    validation::validate_optimizer_output(&x, "BFGS")?;
110                    return Ok(x);
111                }
112            } else {
113                // This check is necessary. Continuation of the algorithm
114                // with gradient equal to exactly zero leads to NaN appearing
115                // in the result.
116                if curr_grad.f_norm()?.f_double_value(&[])? == 0. {
117                    // Final result validation
118                    validation::validate_optimizer_output(&x, "BFGS")?;
119                    return Ok(x);
120                }
121            }
122
123            // Calculate step direction base on the gradient and approximate hessian
124            let direction = (-appr_inv_h.f_mm(&curr_grad.f_reshape([-1, 1])?)?).f_reshape([-1])?;
125
126            // Calculate linesearch_atol based on previous step norms
127            let linesearch_atol =
128                linesearch::P0.max(prev_step_norm.min(prev2_step_norm).min(prev3_step_norm) / 100.);
129
130            // Choose optimal step in given direction using line search
131            // Line search handles non-finite gracefully during search
132            let step =
133                linesearch::choose_step_golden_section(&x, &direction, function, linesearch_atol)?;
134
135            // Update previous step norms
136            prev3_step_norm = prev2_step_norm;
137            prev2_step_norm = prev_step_norm;
138            prev_step_norm = step.f_norm()?.f_double_value(&[])?;
139
140            // Apply step
141            x = x + &step;
142
143            // Check for stop contition
144            let y = function(&x);
145            if let Some(ftol) = self.ftol {
146                if (curr_y.f_double_value(&[])? - y.f_double_value(&[])?) < ftol {
147                    // Final result validation
148                    validation::validate_optimizer_output(&x, "BFGS")?;
149                    return Ok(x);
150                }
151            }
152            curr_y = y;
153
154            let grad = match differentiation::differentiate(function, &x) {
155                Ok(grad) => grad,
156                Err(e) => {
157                    return Err(anyhow!(
158                        "Runtime error: Differentiation failed in BFGS optimizer: {}",
159                        e
160                    ));
161                }
162            };
163            let gdiff = &grad - &curr_grad;
164
165            // Use Powell's dampening for gamma computation
166            // This prevents gamma from blowing up. Normal formula for gamma is 1/step.dot(gdiff)
167            let gamma = {
168                let delta = 0.0001;
169
170                let sty = step.f_dot(&gdiff)?.f_double_value(&[])?;
171                let step_norm_sq = step.f_dot(&step)?.f_double_value(&[])?;
172
173                let theta = if sty >= delta * step_norm_sq {
174                    1.
175                } else {
176                    let numerator = (1. - delta) * step_norm_sq;
177                    let denominator = step_norm_sq - sty;
178
179                    if denominator.abs() < 1e-10 {
180                        1.
181                    } else {
182                        (numerator / denominator).min(1.)
183                    }
184                };
185
186                let projection_factor = if step_norm_sq < 1e-10 {
187                    0.
188                } else {
189                    sty / step_norm_sq
190                };
191                let gdiff_prime = &gdiff * theta + &step * ((1. - theta) * projection_factor);
192                let sty_prime = step.f_dot(&gdiff_prime)?.f_double_value(&[])?;
193
194                if sty_prime.abs() < 1e-10 {
195                    1. / (delta * step_norm_sq + 1e-10)
196                } else {
197                    1. / sty_prime
198                }
199            };
200
201            // Compute approximation of inverse Hessian
202            appr_inv_h = (&identity
203                - gamma * step.f_reshape([-1, 1])?.f_mm(&gdiff.f_reshape([1, -1])?)?)
204            .f_mm(&appr_inv_h)?
205            .f_mm(
206                &(&identity - gamma * gdiff.f_reshape([-1, 1])?.f_mm(&step.f_reshape([1, -1])?)?),
207            )? + gamma * step.f_reshape([-1, 1])?.f_mm(&step.f_reshape([1, -1])?)?;
208
209            // Warning: large inverse Hessian norm
210            let inv_h_norm = appr_inv_h.f_norm()?.f_double_value(&[])?;
211            if !warned_inv_hess_large && inv_h_norm > 1e10 {
212                warn!(
213                    "BFGS: inverse Hessian approximation norm reached {:.3e}; problem may be ill-conditioned",
214                    inv_h_norm
215                );
216                warned_inv_hess_large = true;
217            }
218
219            curr_grad = grad;
220        }
221
222        // Final result validation
223        validation::validate_optimizer_output(&x, "BFGS")?;
224        Ok(x)
225    }
226}
227
228impl fmt::Display for BFGS {
229    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
230        let mut string = String::from("BFGS(");
231
232        string = string + "max_steps=" + self.max_steps.to_string().as_str();
233        if let Some(gtol) = self.gtol {
234            string = string + ", gtol=" + gtol.to_string().as_str();
235        }
236        if let Some(ftol) = self.ftol {
237            string = string + ", ftol=" + ftol.to_string().as_str();
238        }
239
240        string = string + ")";
241
242        write!(f, "{}", string)
243    }
244}