Skip to main content

manopt_rs/
optimizers.rs

1//! Riemannian optimizers for manifold-constrained optimization.
2//!
3//! This module provides Riemannian optimization algorithms that work on manifolds,
4//! extending classical optimization methods to handle geometric constraints.
5use crate::prelude::*;
6use burn::module::AutodiffModule;
7use burn::optim::{adaptor::OptimizerAdaptor, LrDecayState, SimpleOptimizer};
8use burn::record::Record;
9use burn::tensor::backend::AutodiffBackend;
10use burn::optim::LearningRate;
11use std::marker::PhantomData;
12pub mod hessian_optimizer;
13pub mod many_steps;
14pub mod multiple;
15pub use many_steps::LessSimpleOptimizer;
16
17#[derive(Debug)]
18pub struct ManifoldRGDConfig<M: Manifold<B>, B: Backend> {
19    _manifold: PhantomData<M>,
20    _backend: PhantomData<B>,
21}
22
23impl<M, B> Default for ManifoldRGDConfig<M, B>
24where
25    M: Manifold<B>,
26    B: Backend,
27{
28    fn default() -> Self {
29        Self {
30            _manifold: PhantomData,
31            _backend: PhantomData,
32        }
33    }
34}
35
36#[derive(Debug, Clone)]
37pub struct ManifoldRGD<M: Manifold<B>, B: Backend> {
38    _manifold: PhantomData<M>,
39    _backend: PhantomData<B>,
40}
41
42impl<M, B> Default for ManifoldRGD<M, B>
43where
44    M: Manifold<B>,
45    B: Backend,
46{
47    fn default() -> Self {
48        Self {
49            _manifold: PhantomData,
50            _backend: PhantomData,
51        }
52    }
53}
54
55#[derive(Record, Clone)]
56pub struct ManifoldRGDState<B: Backend, const D: usize> {
57    lr_decay: LrDecayState<B, D>,
58}
59
60impl<M, B> SimpleOptimizer<B> for ManifoldRGD<M, B>
61where
62    M: Manifold<B>,
63    B: Backend,
64{
65    type State<const D: usize> = ManifoldRGDState<B, D>;
66
67    fn step<const D: usize>(
68        &self,
69        lr: LearningRate,
70        tensor: Tensor<B, D>,
71        grad: Tensor<B, D>,
72        state: Option<Self::State<D>>,
73    ) -> (Tensor<B, D>, Option<Self::State<D>>) {
74        let direction = M::project(tensor.clone(), -grad);
75        let result = M::retract(tensor, direction * lr);
76        (result, state)
77    }
78
79    fn to_device<const D: usize>(
80        state: Self::State<D>,
81        device: &<B as Backend>::Device,
82    ) -> Self::State<D> {
83        const DECAY_STATE_TO_DEVICE: bool = false;
84        if DECAY_STATE_TO_DEVICE {
85            ManifoldRGDState {
86                lr_decay: state.lr_decay.to_device(device),
87            }
88        } else {
89            state
90        }
91    }
92}
93
94impl<M, B> ManifoldRGDConfig<M, B>
95where
96    M: Manifold<B>,
97    B: Backend,
98{
99    #[must_use]
100    pub fn init<Back: AutodiffBackend, Mod: AutodiffModule<Back>>(
101        &self,
102    ) -> OptimizerAdaptor<ManifoldRGD<M, Back::InnerBackend>, Mod, Back>
103    where
104        M: Manifold<Back::InnerBackend>,
105    {
106        let optim = ManifoldRGD::<M, Back::InnerBackend>::default();
107
108        OptimizerAdaptor::from(optim)
109    }
110}
111
112/// Configuration for the Riemannian Adam optimizer.
113///
114/// This optimizer extends the Adam algorithm to work on Riemannian manifolds,
115/// following the approach described in "Riemannian adaptive optimization methods"
116/// (Bécigneul & Ganea, 2018).
117///
118/// # Example
119///
120/// ```rust
121/// use manopt_rs::prelude::*;
122///
123/// let config = RiemannianAdamConfig::<Euclidean, burn::backend::NdArray>::new()
124///     .with_lr(0.001)
125///     .with_beta1(0.9)
126///     .with_beta2(0.999)
127///     .with_eps(1e-8)
128///     .with_amsgrad(true);
129/// ```
130#[derive(Debug, Clone)]
131pub struct RiemannianAdamConfig<M: Manifold<B>, B: Backend> {
132    pub lr: f64,
133    pub beta1: f64,
134    pub beta2: f64,
135    pub eps: f64,
136    pub weight_decay: f64,
137    pub amsgrad: bool,
138    pub stabilize: Option<usize>,
139    _manifold: PhantomData<M>,
140    _backend: PhantomData<B>,
141}
142
143impl<M, B> Default for RiemannianAdamConfig<M, B>
144where
145    M: Manifold<B>,
146    B: Backend,
147{
148    fn default() -> Self {
149        Self {
150            lr: 1e-3,
151            beta1: 0.9,
152            beta2: 0.999,
153            eps: 1e-8,
154            weight_decay: 0.0,
155            amsgrad: false,
156            stabilize: None,
157            _manifold: PhantomData,
158            _backend: PhantomData,
159        }
160    }
161}
162
163impl<M, B> RiemannianAdamConfig<M, B>
164where
165    M: Manifold<B>,
166    B: Backend,
167{
168    #[must_use]
169    pub fn new() -> Self {
170        Self::default()
171    }
172
173    #[must_use]
174    pub fn with_lr(mut self, lr: f64) -> Self {
175        self.lr = lr;
176        self
177    }
178
179    #[must_use]
180    pub fn with_beta1(mut self, beta1: f64) -> Self {
181        self.beta1 = beta1;
182        self
183    }
184
185    #[must_use]
186    pub fn with_beta2(mut self, beta2: f64) -> Self {
187        self.beta2 = beta2;
188        self
189    }
190
191    #[must_use]
192    pub fn with_eps(mut self, eps: f64) -> Self {
193        self.eps = eps;
194        self
195    }
196
197    #[must_use]
198    pub fn with_weight_decay(mut self, weight_decay: f64) -> Self {
199        self.weight_decay = weight_decay;
200        self
201    }
202
203    #[must_use]
204    pub fn with_amsgrad(mut self, amsgrad: bool) -> Self {
205        self.amsgrad = amsgrad;
206        self
207    }
208
209    #[must_use]
210    pub fn with_stabilize(mut self, stabilize: Option<usize>) -> Self {
211        self.stabilize = stabilize;
212        self
213    }
214}
215
216/// Riemannian Adam optimizer
217#[derive(Debug, Clone)]
218pub struct RiemannianAdam<M: Manifold<B>, B: Backend> {
219    config: RiemannianAdamConfig<M, B>,
220}
221
222impl<M, B> RiemannianAdam<M, B>
223where
224    M: Manifold<B>,
225    B: Backend,
226{
227    #[must_use]
228    pub fn new(config: RiemannianAdamConfig<M, B>) -> Self {
229        Self { config }
230    }
231}
232
233/// State for Riemannian Adam optimizer
234#[derive(Record, Clone)]
235pub struct RiemannianAdamState<B: Backend, const D: usize> {
236    pub step: usize,
237    pub exp_avg: Tensor<B, D>,
238    pub exp_avg_sq: Tensor<B, D>,
239    pub max_exp_avg_sq: Option<Tensor<B, D>>,
240    lr_decay: LrDecayState<B, D>,
241}
242
243impl<M, B> SimpleOptimizer<B> for RiemannianAdam<M, B>
244where
245    M: Manifold<B>,
246    B: Backend,
247{
248    type State<const D: usize> = RiemannianAdamState<B, D>;
249
250    fn step<const D: usize>(
251        &self,
252        _lr: LearningRate,
253        tensor: Tensor<B, D>,
254        grad: Tensor<B, D>,
255        state: Option<Self::State<D>>,
256    ) -> (Tensor<B, D>, Option<Self::State<D>>) {
257        let learning_rate = self.config.lr;
258
259        // Apply weight decay if specified
260        let grad = if self.config.weight_decay > 0.0 {
261            grad + tensor.clone() * self.config.weight_decay
262        } else {
263            grad
264        };
265
266        // Convert Euclidean gradient to Riemannian gradient
267        let rgrad = M::egrad2rgrad(tensor.clone(), grad);
268
269        let mut state = match state {
270            Some(mut state) => {
271                state.step += 1;
272                state
273            }
274            None => RiemannianAdamState {
275                step: 1,
276                exp_avg: Tensor::zeros_like(&tensor),
277                exp_avg_sq: Tensor::zeros_like(&tensor),
278                max_exp_avg_sq: if self.config.amsgrad {
279                    Some(Tensor::zeros_like(&tensor))
280                } else {
281                    None
282                },
283                lr_decay: LrDecayState::new(0, tensor.clone()),
284            },
285        };
286
287        // Update exponential moving averages
288        state.exp_avg =
289            state.exp_avg.clone() * self.config.beta1 + rgrad.clone() * (1.0 - self.config.beta1);
290
291        let inner_product = M::inner::<D>(tensor.clone(), rgrad.clone(), rgrad.clone());
292        state.exp_avg_sq = state.exp_avg_sq.clone() * self.config.beta2
293            + inner_product * (1.0 - self.config.beta2);
294
295        // Compute denominator
296        let denom = if self.config.amsgrad {
297            let max_exp_avg_sq = state.max_exp_avg_sq.as_ref().expect(
298                "On an initial None state, having config.amsgrad be True makes this maximum field set to 0. \
299                If there was an input state then it will be present because of earlier steps");
300            let new_max = Tensor::max_pair(max_exp_avg_sq.clone(), state.exp_avg_sq.clone());
301            state.max_exp_avg_sq = Some(new_max.clone());
302            new_max.sqrt() + self.config.eps
303        } else {
304            state.exp_avg_sq.clone().sqrt() + self.config.eps
305        };
306
307        // Bias correction
308        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
309        let bias_correction1 = 1.0 - self.config.beta1.powi(state.step as i32);
310        #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
311        let bias_correction2 = 1.0 - self.config.beta2.powi(state.step as i32);
312        let step_size = learning_rate * bias_correction2.sqrt() / bias_correction1;
313
314        // Compute direction
315        let direction = state.exp_avg.clone() / denom;
316
317        // Move on manifold using exponential map
318        let new_point = M::expmap(tensor.clone(), direction.clone() * (-step_size));
319        let new_point = M::proj(new_point);
320
321        // Parallel transport the exponential average to the new point
322        let exp_avg_new = M::parallel_transport(tensor, new_point.clone(), state.exp_avg);
323        state.exp_avg = exp_avg_new;
324
325        (new_point, Some(state))
326    }
327
328    fn to_device<const D: usize>(
329        mut state: Self::State<D>,
330        device: &<B as Backend>::Device,
331    ) -> Self::State<D> {
332        state.exp_avg = state.exp_avg.to_device(device);
333        state.exp_avg_sq = state.exp_avg_sq.to_device(device);
334        if let Some(ref max_exp_avg_sq) = state.max_exp_avg_sq {
335            state.max_exp_avg_sq = Some(max_exp_avg_sq.clone().to_device(device));
336        }
337        state.lr_decay = LrDecayState::to_device(state.lr_decay, device);
338        state
339    }
340}
341
342impl<M, B> RiemannianAdamConfig<M, B>
343where
344    M: Manifold<B>,
345    B: Backend,
346{
347    #[must_use]
348    pub fn init<Back: AutodiffBackend, Mod: AutodiffModule<Back>>(
349        &self,
350    ) -> OptimizerAdaptor<RiemannianAdam<M, Back::InnerBackend>, Mod, Back>
351    where
352        M: Manifold<Back::InnerBackend>,
353    {
354        let optim = RiemannianAdam::<M, Back::InnerBackend>::new(RiemannianAdamConfig {
355            lr: self.lr,
356            beta1: self.beta1,
357            beta2: self.beta2,
358            eps: self.eps,
359            weight_decay: self.weight_decay,
360            amsgrad: self.amsgrad,
361            stabilize: self.stabilize,
362            _manifold: PhantomData,
363            _backend: PhantomData,
364        });
365
366        OptimizerAdaptor::from(optim)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use burn::backend::NdArray;
374    use burn::optim::SimpleOptimizer;
375
376    type TestBackend = NdArray;
377
378    #[test]
379    fn test_riemannian_adam_basic() {
380        let config = RiemannianAdamConfig::<Euclidean, TestBackend>::new()
381            .with_lr(0.1)
382            .with_beta1(0.9)
383            .with_beta2(0.999);
384
385        let optimizer = RiemannianAdam::new(config);
386
387        // Create test tensors
388        let tensor = Tensor::<TestBackend, 1>::zeros([3], &Default::default());
389        let grad = Tensor::<TestBackend, 1>::ones([3], &Default::default());
390
391        // Perform one step
392        let (new_tensor, state) = optimizer.step(1.0, tensor.clone(), grad, None);
393
394        // Check that the tensor moved in the negative gradient direction
395        let scalar_value = new_tensor.slice([0; 1]).into_scalar();
396        assert!(
397            scalar_value < 0.0,
398            "Should move in negative gradient direction"
399        );
400        assert!(state.is_some(), "State should be initialized");
401    }
402
403    #[test]
404    fn test_riemannian_adam_convergence() {
405        let config = RiemannianAdamConfig::<Euclidean, TestBackend>::new().with_lr(0.1);
406
407        let optimizer = RiemannianAdam::new(config);
408
409        // Target optimization: minimize ||x - target||^2
410        let target = Tensor::<TestBackend, 1>::from_floats([1.0, -0.5, 2.0], &Default::default());
411        let mut x = Tensor::<TestBackend, 1>::zeros([3], &Default::default());
412        let mut state = None;
413
414        let initial_loss = (x.clone() - target.clone()).powf_scalar(2.0).sum();
415
416        // Run optimization for several steps
417        for _ in 0..50 {
418            let grad = (x.clone() - target.clone()) * 2.0;
419            let (new_x, new_state) = optimizer.step(1.0, x, grad, state);
420            x = new_x;
421            state = new_state;
422        }
423
424        let final_loss = (x.clone() - target.clone()).powf_scalar(2.0).sum();
425
426        // Check convergence
427        assert!(
428            final_loss.into_scalar() < initial_loss.into_scalar(),
429            "Loss should decrease"
430        );
431    }
432
433    #[test]
434    fn test_riemannian_adam_amsgrad() {
435        let config = RiemannianAdamConfig::<Euclidean, TestBackend>::new()
436            .with_lr(0.1)
437            .with_amsgrad(true);
438
439        let optimizer = RiemannianAdam::new(config);
440
441        let tensor = Tensor::<TestBackend, 1>::zeros([2], &Default::default());
442        let grad = Tensor::<TestBackend, 1>::ones([2], &Default::default());
443
444        let (_, state) = optimizer.step(1.0, tensor, grad, None);
445
446        // Check that AMSGrad state is initialized
447        assert!(state.is_some());
448        let state =
449            state.expect("RiemannianAdam optimizer always gives back an initialized state on step");
450        assert!(
451            state.max_exp_avg_sq.is_some(),
452            "AMSGrad should initialize max_exp_avg_sq. See the explanation around the compute denominator part of step"
453        );
454    }
455
456    #[test]
457    fn test_riemannian_adam_weight_decay() {
458        let config = RiemannianAdamConfig::<Euclidean, TestBackend>::new()
459            .with_lr(0.1)
460            .with_weight_decay(0.1);
461
462        let optimizer = RiemannianAdam::new(config);
463
464        let tensor = Tensor::<TestBackend, 1>::ones([2], &Default::default());
465        let grad = Tensor::<TestBackend, 1>::zeros([2], &Default::default());
466
467        let (new_tensor, _) = optimizer.step(1.0, tensor.clone(), grad, None);
468
469        // With weight decay and zero gradient, the tensor should shrink
470        let original_norm = tensor.powf_scalar(2.0).sum().sqrt();
471        let new_norm = new_tensor.powf_scalar(2.0).sum().sqrt();
472
473        assert!(
474            new_norm.into_scalar() < original_norm.into_scalar(),
475            "Weight decay should reduce tensor magnitude"
476        );
477    }
478
479    #[test]
480    fn test_riemannian_adam_state_persistence() {
481        let config = RiemannianAdamConfig::<Euclidean, TestBackend>::new().with_lr(0.1);
482
483        let optimizer = RiemannianAdam::new(config);
484
485        let tensor = Tensor::<TestBackend, 1>::zeros([2], &Default::default());
486        let grad = Tensor::<TestBackend, 1>::ones([2], &Default::default());
487
488        // First step
489        let (tensor1, state1) = optimizer.step(1.0, tensor, grad.clone(), None);
490        assert!(state1.is_some());
491        let state1 = state1.expect(
492            "RiemannianAdam optimizer always gives back an initialized state on step even with None initial state");
493        assert_eq!(state1.step, 1);
494
495        // Second step with state
496        let (_, state2) = optimizer.step(1.0, tensor1, grad, Some(state1));
497        assert!(state2.is_some());
498        let state2 = state2.expect(
499            "There was an input state so RiemannianAdam optimizer's step modifies that and returns it"
500        );
501        assert_eq!(state2.step, 2);
502    }
503}