Skip to main content

optirs_core/optimizers/
lookahead.rs

1// Lookahead optimizer
2//
3// Implements the Lookahead optimization algorithm from:
4// "Lookahead Optimizer: k steps forward, 1 step back" (Zhang et al., 2019)
5
6use crate::error::{OptimError, Result};
7use crate::optimizers::Optimizer;
8use scirs2_core::ndarray::{Array, Dimension, ScalarOperand};
9use scirs2_core::numeric::Float;
10use std::fmt::Debug;
11use std::marker::PhantomData;
12
13/// Lookahead optimizer
14///
15/// Implements the "Lookahead Optimizer: k steps forward, 1 step back" algorithm.
16/// This optimizer maintains two sets of weights: "fast" weights that are updated by
17/// an inner optimizer, and "slow" weights that follow behind at a controlled pace.
18///
19/// The algorithm proceeds by:
20/// 1. Starting with both sets of weights synchronized
21/// 2. Letting the fast weights explore using the inner optimizer for k steps
22/// 3. Then updating the slow weights to move partially toward the fast weights
23/// 4. Resetting the fast weights back to the slow weights
24/// 5. Repeating this process
25///
26/// This provides more stable optimization by allowing aggressive exploration while
27/// maintaining a conservative trajectory.
28///
29/// # Parameters
30///
31/// * `inner_optimizer` - The optimizer to use for fast weight updates
32/// * `alpha` - The step size for slow weight updates (default: 0.5)
33/// * `k` - The number of fast weight updates before updating slow weights (default: 5)
34///
35/// # Example
36///
37/// ```
38/// use scirs2_core::ndarray::Array1;
39/// use optirs_core::optimizers::{Lookahead, SGD};
40/// use optirs_core::Optimizer;
41///
42/// // Create an inner optimizer
43/// let sgd = SGD::new(0.01);
44///
45/// // Wrap it with Lookahead
46/// let mut optimizer = Lookahead::new(sgd);
47///
48/// // Use like any other optimizer
49/// let params = Array1::zeros(10);
50/// let gradients = Array1::ones(10);
51/// let updated_params = optimizer.step(&params, &gradients).expect("optimizer.step succeeds");
52/// ```
53pub struct Lookahead<A, O, D>
54where
55    A: Float + ScalarOperand + Debug,
56    O: Optimizer<A, D> + Clone,
57    D: Dimension,
58{
59    /// Inner optimizer for fast weights
60    inner_optimizer: O,
61    /// Step size for slow weights update (alpha)
62    alpha: A,
63    /// Synchronization period (k)
64    k: usize,
65    /// Current step counter
66    current_step: usize,
67    /// Slow weights
68    slow_weights: Option<Array<A, D>>,
69    /// Fast weights
70    fast_weights: Option<Array<A, D>>,
71    /// Use slow weights for evaluation
72    use_slow_weights: bool,
73    /// Dimension type marker
74    _phantom: PhantomData<D>,
75}
76
77impl<A, O, D> Lookahead<A, O, D>
78where
79    A: Float + ScalarOperand + Debug,
80    O: Optimizer<A, D> + Clone,
81    D: Dimension,
82{
83    /// Creates a new Lookahead optimizer with the given inner optimizer and default settings
84    pub fn new(inner_optimizer: O) -> Self {
85        Self {
86            inner_optimizer,
87            // Default alpha is 0.5
88            alpha: A::from(0.5).expect("Lookahead: default alpha (0.5) must fit in A"),
89            k: 5, // Default k is 5
90            current_step: 0,
91            slow_weights: None,
92            fast_weights: None,
93            use_slow_weights: false,
94            _phantom: PhantomData,
95        }
96    }
97
98    /// Creates a new Lookahead optimizer with the specified alpha and k values
99    pub fn with_config(inner_optimizer: O, alpha: A, k: usize) -> Self {
100        Self {
101            inner_optimizer,
102            alpha,
103            k,
104            current_step: 0,
105            slow_weights: None,
106            fast_weights: None,
107            use_slow_weights: false,
108            _phantom: PhantomData,
109        }
110    }
111
112    /// Set the alpha parameter (slow weights step size)
113    pub fn with_alpha(mut self, alpha: A) -> Self {
114        self.alpha = alpha;
115        self
116    }
117
118    /// Set the k parameter (synchronization period)
119    pub fn with_k(mut self, k: usize) -> Self {
120        self.k = k;
121        self
122    }
123
124    /// Get the inner optimizer
125    pub fn inner_optimizer(&self) -> &O {
126        &self.inner_optimizer
127    }
128
129    /// Get a mutable reference to the inner optimizer
130    pub fn inner_optimizer_mut(&mut self) -> &mut O {
131        &mut self.inner_optimizer
132    }
133
134    /// Get the alpha parameter (slow weights step size)
135    pub fn alpha(&self) -> A {
136        self.alpha
137    }
138
139    /// Get the k parameter (synchronization period)
140    pub fn k(&self) -> usize {
141        self.k
142    }
143
144    /// Switches to using slow weights for evaluation
145    /// Call this before evaluation to get better performance
146    pub fn use_slow_weights_for_eval(&mut self) {
147        self.use_slow_weights = true;
148    }
149
150    /// Switches to using fast weights for training
151    /// Call this after evaluation to resume training
152    pub fn use_fast_weights_for_train(&mut self) {
153        self.use_slow_weights = false;
154    }
155
156    /// Resets the internal state
157    pub fn reset(&mut self) {
158        self.current_step = 0;
159        self.slow_weights = None;
160        self.fast_weights = None;
161    }
162}
163
164impl<A, O, D> Clone for Lookahead<A, O, D>
165where
166    A: Float + ScalarOperand + Debug,
167    O: Optimizer<A, D> + Clone,
168    D: Dimension,
169{
170    fn clone(&self) -> Self {
171        Self {
172            inner_optimizer: self.inner_optimizer.clone(),
173            alpha: self.alpha,
174            k: self.k,
175            current_step: self.current_step,
176            slow_weights: self.slow_weights.clone(),
177            fast_weights: self.fast_weights.clone(),
178            use_slow_weights: self.use_slow_weights,
179            _phantom: PhantomData,
180        }
181    }
182}
183
184impl<A, O, D> Debug for Lookahead<A, O, D>
185where
186    A: Float + ScalarOperand + Debug,
187    O: Optimizer<A, D> + Clone + Debug,
188    D: Dimension,
189{
190    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191        f.debug_struct("Lookahead")
192            .field("inner_optimizer", &self.inner_optimizer)
193            .field("alpha", &self.alpha)
194            .field("k", &self.k)
195            .field("current_step", &self.current_step)
196            .field("use_slow_weights", &self.use_slow_weights)
197            .finish()
198    }
199}
200
201impl<A, O, D> Optimizer<A, D> for Lookahead<A, O, D>
202where
203    A: Float + ScalarOperand + Debug + Send + Sync,
204    O: Optimizer<A, D> + Clone + Send + Sync,
205    D: Dimension,
206{
207    fn step(&mut self, params: &Array<A, D>, gradients: &Array<A, D>) -> Result<Array<A, D>> {
208        // Initialize weights if first step
209        if self.slow_weights.is_none() {
210            self.slow_weights = Some(params.clone());
211            self.fast_weights = Some(params.clone());
212        }
213
214        // Get mutable references to weights
215        let fast_weights = match &mut self.fast_weights {
216            Some(w) => w,
217            None => {
218                return Err(OptimError::OptimizationError(
219                    "Fast weights not initialized".to_string(),
220                ))
221            }
222        };
223
224        let slow_weights = match &mut self.slow_weights {
225            Some(w) => w,
226            None => {
227                return Err(OptimError::OptimizationError(
228                    "Slow weights not initialized".to_string(),
229                ))
230            }
231        };
232
233        // Update fast weights using inner optimizer
234        *fast_weights = self.inner_optimizer.step(fast_weights, gradients)?;
235
236        // Increment step counter
237        self.current_step += 1;
238
239        // If we've reached k steps, update slow weights and reset fast weights
240        if self.current_step >= self.k {
241            // Update slow weights: φₜ ← φₜ₋₁ + α(θₜ,ₖ - φₜ₋₁)
242            // Compute difference between fast and slow weights
243            let diff = &*fast_weights - &*slow_weights;
244
245            // Update slow weights by moving alpha of the way toward fast weights
246            *slow_weights = &*slow_weights + &(diff * self.alpha);
247
248            // Reset fast weights to slow weights
249            *fast_weights = slow_weights.clone();
250
251            // Reset step counter
252            self.current_step = 0;
253        }
254
255        // Return the appropriate weights (slow for evaluation, fast for training)
256        if self.use_slow_weights {
257            Ok(slow_weights.clone())
258        } else {
259            Ok(fast_weights.clone())
260        }
261    }
262
263    fn set_learning_rate(&mut self, learning_rate: A) {
264        self.inner_optimizer.set_learning_rate(learning_rate);
265    }
266
267    fn get_learning_rate(&self) -> A {
268        self.inner_optimizer.get_learning_rate()
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use crate::optimizers::sgd::SGD;
276    use approx::assert_abs_diff_eq;
277    use scirs2_core::ndarray::Array1;
278
279    #[test]
280    fn test_lookahead_creation() {
281        let sgd = SGD::new(0.01);
282        let optimizer: Lookahead<f64, SGD<f64>, scirs2_core::ndarray::Ix1> = Lookahead::new(sgd);
283
284        assert_abs_diff_eq!(optimizer.alpha(), 0.5);
285        assert_eq!(optimizer.k(), 5);
286        assert_abs_diff_eq!(optimizer.get_learning_rate(), 0.01);
287    }
288
289    #[test]
290    fn test_lookahead_with_config() {
291        let sgd = SGD::new(0.01);
292        let optimizer: Lookahead<f64, SGD<f64>, scirs2_core::ndarray::Ix1> =
293            Lookahead::with_config(sgd, 0.8, 10);
294
295        assert_abs_diff_eq!(optimizer.alpha(), 0.8);
296        assert_eq!(optimizer.k(), 10);
297    }
298
299    #[test]
300    fn test_lookahead_step() {
301        let mut sgd = SGD::new(0.1);
302        sgd.set_momentum(0.0);
303        let mut optimizer: Lookahead<f64, SGD<f64>, scirs2_core::ndarray::Ix1> =
304            Lookahead::with_config(sgd, 0.5, 2);
305
306        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
307        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
308
309        // First step
310        let updated_params = optimizer
311            .step(&params, &gradients)
312            .expect("optimizer.step succeeds in test_lookahead_step");
313
314        // After first step, fast weights should be updated by SGD but slow weights unchanged
315        // SGD update: params - lr * gradients = [1.0, 2.0, 3.0] - 0.1 * [0.1, 0.2, 0.3] = [0.99, 1.98, 2.97]
316        assert_abs_diff_eq!(updated_params[0], 0.99, epsilon = 1e-6);
317        assert_abs_diff_eq!(updated_params[1], 1.98, epsilon = 1e-6);
318        assert_abs_diff_eq!(updated_params[2], 2.97, epsilon = 1e-6);
319
320        // Second step
321        let updated_params2 = optimizer
322            .step(&updated_params, &gradients)
323            .expect("step succeeds in test_lookahead_step");
324
325        // After second step (which is k), slow weights should be updated and fast weights reset to slow weights
326        // SGD update on fast weights: [0.99, 1.98, 2.97] - 0.1 * [0.1, 0.2, 0.3] = [0.98, 1.96, 2.94]
327        // Slow weights update: [1.0, 2.0, 3.0] + 0.5 * ([0.98, 1.96, 2.94] - [1.0, 2.0, 3.0])
328        //                    = [1.0, 2.0, 3.0] + 0.5 * [-0.02, -0.04, -0.06]
329        //                    = [0.99, 1.98, 2.97]
330        // Fast weights are reset to slow weights = [0.99, 1.98, 2.97]
331
332        // The returned value should be the fast weights (which are now reset to slow weights)
333        assert_abs_diff_eq!(updated_params2[0], 0.99, epsilon = 1e-6);
334        assert_abs_diff_eq!(updated_params2[1], 1.98, epsilon = 1e-6);
335        assert_abs_diff_eq!(updated_params2[2], 2.97, epsilon = 1e-6);
336
337        // Third step (starting a new cycle)
338        let updated_params3 = optimizer
339            .step(&updated_params2, &gradients)
340            .expect("step succeeds in test_lookahead_step");
341
342        // SGD update on fast weights: [0.99, 1.98, 2.97] - 0.1 * [0.1, 0.2, 0.3] = [0.98, 1.96, 2.94]
343        assert_abs_diff_eq!(updated_params3[0], 0.98, epsilon = 1e-6);
344        assert_abs_diff_eq!(updated_params3[1], 1.96, epsilon = 1e-6);
345        assert_abs_diff_eq!(updated_params3[2], 2.94, epsilon = 1e-6);
346    }
347
348    #[test]
349    fn test_slow_weights_for_eval() {
350        let mut sgd = SGD::new(0.1);
351        sgd.set_momentum(0.0);
352        let mut optimizer: Lookahead<f64, SGD<f64>, scirs2_core::ndarray::Ix1> =
353            Lookahead::with_config(sgd, 0.5, 2);
354
355        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
356        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
357
358        // First step
359        let updated_params = optimizer
360            .step(&params, &gradients)
361            .expect("optimizer.step succeeds in test_slow_weights_for_eval");
362
363        // Switch to slow weights for evaluation
364        optimizer.use_slow_weights_for_eval();
365
366        // Get the parameters when using slow weights
367        let eval_params = optimizer
368            .step(&updated_params, &gradients)
369            .expect("step succeeds in test_slow_weights_for_eval");
370
371        // First step already updated both fast and slow weights
372        // When using slow weights, we should get the slow weights which were initialized with
373        // values from params: [1.0, 2.0, 3.0] but then updated by the first step
374        assert_abs_diff_eq!(eval_params[0], 0.99, epsilon = 1e-6);
375        assert_abs_diff_eq!(eval_params[1], 1.98, epsilon = 1e-6);
376        assert_abs_diff_eq!(eval_params[2], 2.97, epsilon = 1e-6);
377
378        // Switch back to fast weights for training
379        optimizer.use_fast_weights_for_train();
380
381        // Should be back to fast weights
382        let train_params = optimizer
383            .step(&eval_params, &gradients)
384            .expect("step succeeds in test_slow_weights_for_eval");
385        assert!(train_params[0] < 1.0);
386    }
387
388    #[test]
389    fn test_reset() {
390        let sgd = SGD::new(0.1);
391        let mut optimizer: Lookahead<f64, SGD<f64>, scirs2_core::ndarray::Ix1> =
392            Lookahead::new(sgd);
393
394        let params = Array1::from_vec(vec![1.0, 2.0, 3.0]);
395        let gradients = Array1::from_vec(vec![0.1, 0.2, 0.3]);
396
397        // Do a step to initialize weights
398        let _ = optimizer
399            .step(&params, &gradients)
400            .expect("optimizer.step succeeds in test_reset");
401
402        // Reset
403        optimizer.reset();
404
405        // Both fast and slow weights should be None, verified by new initialization
406        let updated_params = optimizer
407            .step(&params, &gradients)
408            .expect("optimizer.step succeeds in test_reset");
409        // First step after reset should be equivalent to first step on a new optimizer
410        assert_abs_diff_eq!(updated_params[0], 0.99, epsilon = 1e-6);
411    }
412}