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::Tensor;
8
9/// Optimizer interface common for any optimizer in the library
10pub trait Optimizer: Send + Sync + fmt::Display {
11    /// Solves the problem of optimization of function `function` starting from point `x0`
12    fn optimize(&self, function: &dyn Fn(&Tensor) -> Tensor, x0: &Tensor)
13    -> anyhow::Result<Tensor>;
14}
15
16/// Broyden-Fletcher-Goldfarb-Shanno optimization algorithm
17pub struct BFGS {
18    // Maximum number of optimization steps
19    max_steps: usize,
20    // Minimum gradient
21    gtol: Option<f64>,
22    // minimum change in the objective function between iterations
23    ftol: Option<f64>,
24    // Absolute error tolerance for line search
25    linesearch_atol: f64,
26}
27
28/// Conjugate Gradient optimization algorithm
29pub struct CG {
30    // Maximum number of optimization steps
31    max_steps: usize,
32    // Minimum gradient
33    gtol: Option<f64>,
34    // Minimum change in the objective function between iterations
35    ftol: Option<f64>,
36    // Absolute error tolerance for line search
37    linesearch_atol: f64,
38}
39
40fn differentiate(function: &dyn Fn(&Tensor) -> Tensor, x: &Tensor) -> Tensor {
41    let x_with_grad = x.detach().copy().set_requires_grad(true);
42    let y = function(&x_with_grad);
43
44    tch::Tensor::run_backward(&[y], &[x_with_grad], false, false)[0].copy()
45}
46
47const P0: f64 = 0.000001f64;
48
49const PHI2: f64 = 2.618033988749894848207f64;
50const RPHI: f64 = 0.618033988749894848207f64;
51
52fn choose_step(
53    x0: &Tensor,
54    direction: &Tensor,
55    function: &dyn Fn(&Tensor) -> Tensor,
56    atol: f64,
57) -> Tensor {
58    let (mut x1, mut x2, mut x3, mut x4): (Tensor, Tensor, Tensor, Tensor);
59    let (fx1, mut fx3, mut fx4): (Tensor, Tensor, Tensor);
60
61    let kind = x0.kind();
62
63    fx1 = function(&x0);
64
65    x1 = Tensor::from(0.).to_kind(kind);
66    x2 = Tensor::from(P0).to_kind(kind);
67    while function(&(x0 + direction * &x2)).double_value(&[]) <= fx1.double_value(&[]) {
68        x2 = &x1 + (&x2 - &x1) * PHI2;
69    }
70
71    x3 = x2.copy() - (x2.copy() - &x1) * RPHI;
72    x4 = x1.copy() + (x2.copy() - &x1) * RPHI;
73    fx3 = function(&(x0 + direction * &x3));
74    fx4 = function(&(x0 + direction * &x4));
75    while (x1.copy() - &x2).abs().double_value(&[]) > atol {
76        if fx3.double_value(&[]) < fx4.double_value(&[]) {
77            x2 = x4.copy();
78
79            fx4 = fx3.copy();
80            x3 = x2.copy() - (x2.copy() - &x1) * RPHI;
81            x4 = x1.copy() + (x2.copy() - &x1) * RPHI;
82            fx3 = function(&(x0 + direction * &x3));
83        } else {
84            x1 = x3.copy();
85
86            fx3 = fx4.copy();
87            x3 = x2.copy() - (x2.copy() - &x1) * RPHI;
88            x4 = x1.copy() + (x2.copy() - &x1) * RPHI;
89            fx4 = function(&(x0 + direction * &x4));
90        }
91    }
92
93    direction * ((&x1 + &x2) / 2.)
94}
95
96impl CG {
97    pub fn new(
98        max_steps: usize,
99        gtol: Option<f64>,
100        ftol: Option<f64>,
101        linesearch_atol: Option<f64>,
102    ) -> Self {
103        Self {
104            max_steps,
105            gtol,
106            ftol,
107            linesearch_atol: if let Some(linesearch_atol) = linesearch_atol {
108                linesearch_atol
109            } else {
110                P0
111            },
112        }
113    }
114}
115
116impl Optimizer for CG {
117    fn optimize(
118        &self,
119        function: &dyn Fn(&Tensor) -> Tensor,
120        x0: &Tensor,
121    ) -> anyhow::Result<Tensor> {
122        // Ensure that rank of the initital guess tensor is 1
123        if x0.size().len() != 1 {
124            return Err(anyhow!("`x0` must have rank 1"));
125        }
126
127        let iters_to_reset = x0.size().len();
128        let mut prev_grad = Tensor::zeros_like(&x0);
129        let mut prev_direction = Tensor::zeros_like(&x0);
130        let mut prev_y: Option<Tensor> = None;
131        let mut x = x0.copy();
132
133        for step_num in 0..self.max_steps {
134            let grad = differentiate(function, &x);
135
136            // Stop if gradient is smaller than `gtol`
137            if let Some(gtol) = self.gtol {
138                if grad.norm().double_value(&[]) < gtol {
139                    return Ok(x);
140                }
141            } else {
142                // This check is necessary. Continuation of the algorithm
143                // with gradient equal to exactly zero leads to NaN appearing
144                // in the result.
145                if grad.norm().double_value(&[]) == 0. {
146                    return Ok(x);
147                }
148            }
149
150            // Calculate direction according to the Polak-Ribiere formula
151            let direction = match step_num % iters_to_reset {
152                0 => -&grad,
153                _ => {
154                    let beta = grad.squeeze().dot(&(&grad - &prev_grad).squeeze())
155                        / prev_grad.squeeze().dot(&prev_grad.squeeze());
156
157                    -&grad + beta * &prev_direction
158                }
159            };
160
161            // Choose step in direction `direction`
162            let step = choose_step(&x, &direction, &function, self.linesearch_atol);
163
164            // Apply step
165            x = x + step;
166
167            // Stop if change in function value is smaller than `ftol`
168            let y = function(&x);
169            if let (Some(prev_y), Some(ftol)) = (prev_y, self.ftol) {
170                if (&prev_y - &y).double_value(&[]) < ftol {
171                    return Ok(x);
172                }
173            }
174            prev_y = Some(y);
175
176            // Update previous gradient value and previous direction value
177            prev_grad = grad;
178            prev_direction = direction;
179        }
180
181        Ok(x)
182    }
183}
184
185impl fmt::Display for CG {
186    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
187        let mut string = String::from("CG(");
188
189        string = string + "max_steps=" + self.max_steps.to_string().as_str();
190        if let Some(gtol) = self.gtol {
191            string = string + ", gtol=" + gtol.to_string().as_str();
192        }
193        if let Some(ftol) = self.ftol {
194            string = string + ", ftol=" + ftol.to_string().as_str();
195        }
196
197        string = string + ", linesearch_atol=" + self.linesearch_atol.to_string().as_str() + ")";
198
199        write!(f, "{}", string)
200    }
201}
202
203impl BFGS {
204    pub fn new(
205        max_steps: usize,
206        gtol: Option<f64>,
207        ftol: Option<f64>,
208        linesearch_atol: Option<f64>,
209    ) -> Self {
210        Self {
211            max_steps,
212            gtol,
213            ftol,
214            linesearch_atol: if let Some(linesearch_atol) = linesearch_atol {
215                linesearch_atol
216            } else {
217                P0
218            },
219        }
220    }
221}
222
223impl Optimizer for BFGS {
224    fn optimize(
225        &self,
226        function: &dyn Fn(&Tensor) -> Tensor,
227        x0: &Tensor,
228    ) -> anyhow::Result<Tensor> {
229        // Ensure that rank of the initital guess tensor is 1
230        if x0.size().len() != 1 {
231            return Err(anyhow!("`x0` must have rank 1"));
232        }
233
234        // Determine the device and kind for use in the function
235        let kind = x0.kind();
236        let device = x0.device();
237
238        let x0_length = x0.size()[0];
239        let identity = match Tensor::f_eye(x0_length, (kind, device)) {
240            Ok(matrix) => matrix,
241            // BFGS requires a lot of resources.
242            // Give knowledgable error message to the user
243            // when BFGS fails due to unsufficient memory.
244            Err(tch::TchError::Torch(_)) => {
245                return Err(anyhow!(
246                    "Could not allocate {}x{} matrix. Maybe try less resourcefull algorithm.",
247                    x0_length,
248                    x0_length
249                ));
250            }
251            e => e.unwrap(),
252        };
253        let mut x = x0.copy();
254        let mut appr_inv_h = identity.copy();
255        let mut curr_grad = differentiate(function, &x);
256        let mut curr_y = function(&x);
257
258        // Ensure that output of `function` is a scalar
259        if curr_y.size() != Vec::<i64>::new() {
260            return Err(anyhow!("Output of function `function` must be scalar"));
261        }
262
263        for _ in 0..self.max_steps {
264            // Check for stop condition
265            if let Some(gtol) = self.gtol {
266                if curr_grad.norm().double_value(&[]) < gtol {
267                    return Ok(x);
268                }
269            } else {
270                // This check is necessary. Continuation of the algorithm
271                // with gradient equal to exactly zero leads to NaN appearing
272                // in the result.
273                if curr_grad.norm().double_value(&[]) == 0. {
274                    return Ok(x);
275                }
276            }
277
278            // Calculate step direction base on the gradient and approximate hessian
279            let direction = (-appr_inv_h.mm(&curr_grad.unsqueeze(1))).squeeze();
280
281            // Choose optimal step in given direction using line search
282            let step = choose_step(&x, &direction, function, self.linesearch_atol);
283
284            // Apply step
285            x = x + &step;
286
287            // Check for stop contition
288            let y = function(&x);
289            if let Some(ftol) = self.ftol {
290                if (curr_y.double_value(&[]) - y.double_value(&[])) < ftol {
291                    return Ok(x);
292                }
293            }
294            curr_y = y;
295
296            let grad = differentiate(function, &x);
297            let gdiff = &grad - &curr_grad;
298
299            // Use Powell's dampening for gamma computation
300            // This prevents gamma from blowing up. Normal formula for gamma is 1/step.dot(gdiff)
301            let gamma = {
302                let delta = 0.0001;
303
304                let sty = step.dot(&gdiff).double_value(&[]);
305                let step_norm_sq = step.dot(&step).double_value(&[]);
306
307                let theta = if sty >= delta * step_norm_sq {
308                    1.
309                } else {
310                    let numerator = (1. - delta) * step_norm_sq;
311                    let denominator = step_norm_sq - sty;
312
313                    if denominator.abs() < 1e-10 {
314                        1.
315                    } else {
316                        (numerator / denominator).min(1.)
317                    }
318                };
319
320                let projection_factor = if step_norm_sq < 1e-10 {
321                    0.
322                } else {
323                    sty / step_norm_sq
324                };
325                let gdiff_prime = &gdiff * theta + &step * ((1. - theta) * projection_factor);
326                let sty_prime = step.dot(&gdiff_prime).double_value(&[]);
327
328                if sty_prime.abs() < 1e-10 {
329                    1. / (delta * step_norm_sq + 1e-10)
330                } else {
331                    1. / sty_prime
332                }
333            };
334
335            // Compute approximation of inverse Hessian
336            appr_inv_h = (&identity - gamma * step.reshape([-1, 1]).mm(&gdiff.reshape([1, -1])))
337                .mm(&appr_inv_h)
338                .mm(&(&identity - gamma * gdiff.reshape([-1, 1]).mm(&step.reshape([1, -1]))))
339                + gamma * step.reshape([-1, 1]).mm(&step.reshape([1, -1]));
340
341            curr_grad = grad;
342        }
343
344        Ok(x)
345    }
346}
347
348impl fmt::Display for BFGS {
349    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
350        let mut string = String::from("BFGS(");
351
352        string = string + "max_steps=" + self.max_steps.to_string().as_str();
353        if let Some(gtol) = self.gtol {
354            string = string + ", gtol=" + gtol.to_string().as_str();
355        }
356        if let Some(ftol) = self.ftol {
357            string = string + ", ftol=" + ftol.to_string().as_str();
358        }
359
360        string = string + ", linesearch_atol=" + self.linesearch_atol.to_string().as_str() + ")";
361
362        write!(f, "{}", string)
363    }
364}