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