Skip to main content

optirs_core/schedulers/
linear_warmup_decay.rs

1// Linear warmup with decay learning rate scheduler
2//
3// This scheduler combines linear warmup with a decay strategy.
4// It first linearly increases the learning rate from a minimum value to the
5// initial learning rate during the warmup phase, then applies a decay strategy.
6
7use scirs2_core::ndarray::ScalarOperand;
8use scirs2_core::numeric::Float;
9use std::fmt::Debug;
10
11use crate::schedulers::{
12    CosineAnnealing, ExponentialDecay, LearningRateScheduler, LinearDecay, StepDecay,
13};
14
15/// Decay strategy to use after the warmup phase
16#[derive(Debug, Clone)]
17pub enum DecayStrategy<A: Float + Debug> {
18    /// Linear decay to a final value
19    Linear {
20        /// Final learning rate
21        final_lr: A,
22    },
23    /// Exponential decay with a given decay rate
24    Exponential {
25        /// Decay rate per step
26        decay_rate: A,
27    },
28    /// Step decay with a given step size and decay rate
29    Step {
30        /// Decay rate at each step
31        decay_rate: A,
32        /// Step size (number of steps between decays)
33        step_size: usize,
34    },
35    /// Cosine annealing to a minimum value
36    Cosine {
37        /// Minimum learning rate
38        min_lr: A,
39    },
40    /// No decay - constant learning rate after warmup
41    Constant,
42}
43
44/// Linear warmup with decay learning rate scheduler
45///
46/// This scheduler first linearly increases the learning rate from a minimum value to the
47/// initial learning rate during the warmup phase, then applies a decay strategy.
48///
49/// Formula during warmup:
50/// lr = min_lr + (initial_lr - min_lr) * (step / warmup_steps)
51///
52/// After warmup, the specified decay strategy is applied.
53///
54/// # Examples
55///
56/// ```
57/// use optirs_core::schedulers::{LinearWarmupDecay, DecayStrategy, LearningRateScheduler};
58///
59/// // Create a scheduler with linear warmup for 10 steps, followed by linear decay
60/// // to 0.001 over 90 steps
61/// let mut scheduler = LinearWarmupDecay::new(
62///     0.1f64,                          // initial_lr (peak learning rate)
63///     0.01,                            // min_lr (starting learning rate)
64///     10,                              // warmup_steps
65///     90,                              // total_decay_steps
66///     DecayStrategy::Linear { final_lr: 0.001 }
67/// );
68///
69/// // Verify initial learning rate is the minimum
70/// assert_eq!(scheduler.get_learning_rate(), 0.01);
71///
72/// // Run for warmup period
73/// for _ in 0..10 {
74///     let lr = scheduler.step();
75///     // Learning rate should increase during warmup
76///     println!("Warmup LR: {}", lr);
77/// }
78///
79/// // After warmup, we should be at the initial (peak) learning rate
80/// assert_eq!(scheduler.get_learning_rate(), 0.1);
81///
82/// // Run for decay period
83/// for _ in 0..90 {
84///     let lr = scheduler.step();
85///     // Learning rate should decrease during decay
86///     println!("Decay LR: {}", lr);
87/// }
88///
89/// // Verify learning rate has decayed to the target
90/// assert!(scheduler.get_learning_rate() <= 0.001 + 1e-6);
91/// ```
92#[derive(Debug)]
93pub struct LinearWarmupDecay<A: Float + Debug> {
94    /// Initial learning rate (the peak learning rate after warmup)
95    initial_lr: A,
96    /// Minimum learning rate (starting point for warmup)
97    min_lr: A,
98    /// Number of warmup steps
99    warmup_steps: usize,
100    /// Number of decay steps after warmup
101    total_decay_steps: usize,
102    /// Current step
103    step: usize,
104    /// Current learning rate
105    current_lr: A,
106    /// Decay strategy to use after warmup
107    decay_strategy: DecayStrategy<A>,
108    /// Whether warmup phase is complete
109    warmup_complete: bool,
110    /// Inner scheduler for decay phase (initialized after warmup)
111    inner_scheduler: Option<InnerScheduler<A>>,
112}
113
114/// Inner scheduler types for LinearWarmupDecay
115#[derive(Debug)]
116enum InnerScheduler<A: Float + Debug> {
117    /// Linear decay scheduler
118    Linear(LinearDecay<A>),
119    /// Exponential decay scheduler
120    Exponential(ExponentialDecay<A>),
121    /// Step decay scheduler
122    Step(StepDecay<A>),
123    /// Cosine annealing scheduler
124    Cosine(CosineAnnealing<A>),
125}
126
127impl<A: Float + Debug + ScalarOperand + Send + Sync> LinearWarmupDecay<A> {
128    /// Create a new linear warmup with decay scheduler
129    ///
130    /// # Arguments
131    ///
132    /// * `initial_lr` - Initial learning rate (the peak learning rate after warmup)
133    /// * `min_lr` - Minimum learning rate (starting point for warmup)
134    /// * `warmup_steps` - Number of warmup steps
135    /// * `total_decay_steps` - Number of decay steps after warmup
136    /// * `decay_strategy` - Decay strategy to use after warmup
137    pub fn new(
138        initial_lr: A,
139        min_lr: A,
140        warmup_steps: usize,
141        total_decay_steps: usize,
142        decay_strategy: DecayStrategy<A>,
143    ) -> Self {
144        Self {
145            initial_lr,
146            min_lr,
147            warmup_steps,
148            total_decay_steps,
149            step: 0,
150            current_lr: min_lr,
151            decay_strategy,
152            warmup_complete: false,
153            inner_scheduler: None,
154        }
155    }
156
157    /// Initialize the inner decay scheduler once warmup is complete
158    fn initialize_decay_scheduler(&mut self) {
159        let scheduler = match self.decay_strategy {
160            DecayStrategy::Linear { final_lr } => InnerScheduler::Linear(LinearDecay::new(
161                self.initial_lr,
162                final_lr,
163                self.total_decay_steps,
164            )),
165            DecayStrategy::Exponential { decay_rate } => InnerScheduler::Exponential(
166                ExponentialDecay::new(self.initial_lr, decay_rate, self.total_decay_steps),
167            ),
168            DecayStrategy::Step {
169                decay_rate,
170                step_size,
171            } => InnerScheduler::Step(StepDecay::new(self.initial_lr, decay_rate, step_size)),
172            DecayStrategy::Cosine { min_lr } => InnerScheduler::Cosine(CosineAnnealing::new(
173                self.initial_lr,
174                min_lr,
175                self.total_decay_steps,
176                false, // No warm restarts in this scheduler
177            )),
178            DecayStrategy::Constant => {
179                // For constant strategy, we use a linear decay with the same start and end values
180                InnerScheduler::Linear(LinearDecay::new(
181                    self.initial_lr,
182                    self.initial_lr,
183                    self.total_decay_steps,
184                ))
185            }
186        };
187
188        self.inner_scheduler = Some(scheduler);
189    }
190}
191
192impl<A: Float + Debug + ScalarOperand + Send + Sync> LearningRateScheduler<A>
193    for LinearWarmupDecay<A>
194{
195    fn get_learning_rate(&self) -> A {
196        self.current_lr
197    }
198
199    fn step(&mut self) -> A {
200        self.step += 1;
201
202        // Special case: if warmup_steps is 0, go straight to decay
203        if self.warmup_steps == 0 && !self.warmup_complete {
204            self.warmup_complete = true;
205            self.initialize_decay_scheduler();
206        }
207
208        if !self.warmup_complete && self.step <= self.warmup_steps {
209            // Warmup phase: linear increase from min_lr to initial_lr
210            let progress = if self.warmup_steps > 0 {
211                A::from(self.step).expect("LinearWarmupDecay: step must fit in A (f32/f64)")
212                    / A::from(self.warmup_steps)
213                        .expect("LinearWarmupDecay: warmup_steps must fit in A (f32/f64)")
214            } else {
215                A::one()
216            };
217
218            self.current_lr = self.min_lr + (self.initial_lr - self.min_lr) * progress;
219
220            // Check if warmup is complete after this step
221            if self.step == self.warmup_steps {
222                self.warmup_complete = true;
223                self.initialize_decay_scheduler();
224            }
225        } else if self.warmup_complete {
226            // Decay phase: use inner scheduler
227            if let Some(scheduler) = &mut self.inner_scheduler {
228                self.current_lr = match scheduler {
229                    InnerScheduler::Linear(s) => s.step(),
230                    InnerScheduler::Exponential(s) => s.step(),
231                    InnerScheduler::Step(s) => s.step(),
232                    InnerScheduler::Cosine(s) => s.step(),
233                };
234            }
235        }
236
237        self.current_lr
238    }
239
240    fn reset(&mut self) {
241        self.step = 0;
242        self.current_lr = self.min_lr;
243        self.warmup_complete = false;
244        self.inner_scheduler = None;
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use approx::assert_abs_diff_eq;
252
253    #[test]
254    fn test_linear_warmup_linear_decay() {
255        // Create scheduler with 10 warmup steps and 90 decay steps
256        let mut scheduler = LinearWarmupDecay::new(
257            0.1f64,
258            0.01,
259            10,
260            90,
261            DecayStrategy::Linear { final_lr: 0.001 },
262        );
263
264        // Check initial learning rate
265        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.01);
266
267        // Test warmup phase
268        let mut warmup_lrs = Vec::new();
269        for _ in 0..10 {
270            warmup_lrs.push(scheduler.step());
271        }
272
273        // After warmup, we should be at the initial (peak) learning rate
274        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
275
276        // Verify warmup is linearly increasing
277        for i in 1..warmup_lrs.len() {
278            assert!(warmup_lrs[i] > warmup_lrs[i - 1]);
279        }
280
281        // Test decay phase
282        let mut decay_lrs = Vec::new();
283        for _ in 0..90 {
284            decay_lrs.push(scheduler.step());
285        }
286
287        // Verify final learning rate
288        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.001, epsilon = 1e-6);
289
290        // Verify decay is linearly decreasing
291        for i in 1..decay_lrs.len() {
292            assert!(decay_lrs[i] < decay_lrs[i - 1]);
293        }
294    }
295
296    #[test]
297    fn test_linear_warmup_exponential_decay() {
298        // Create scheduler with 10 warmup steps and 10 decay steps
299        // Using a very extreme decay rate to make the effect very obvious in few steps
300        let mut scheduler = LinearWarmupDecay::new(
301            0.1f64,
302            0.01,
303            10,
304            10,
305            DecayStrategy::Exponential { decay_rate: 0.1 },
306        );
307
308        // Check warmup phase
309        for _ in 0..10 {
310            scheduler.step();
311        }
312
313        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
314
315        // Collect learning rates for the entire decay phase
316        let mut lrs = Vec::new();
317        for _ in 0..10 {
318            lrs.push(scheduler.step());
319        }
320
321        // Verify the final learning rate is significantly lower than initial
322        let final_lr = *lrs
323            .last()
324            .expect("lrs.last succeeds in test_linear_warmup_exponential_decay");
325        assert!(
326            final_lr < 0.05,
327            "Final learning rate {:.6} should be significantly less than initial 0.1",
328            final_lr
329        );
330    }
331
332    #[test]
333    fn test_linear_warmup_step_decay() {
334        // Create scheduler with 10 warmup steps and 40 decay steps
335        let mut scheduler = LinearWarmupDecay::new(
336            0.1f64,
337            0.01,
338            10,
339            40,
340            DecayStrategy::Step {
341                decay_rate: 0.5,
342                step_size: 10,
343            },
344        );
345
346        // Check warmup phase
347        for _ in 0..10 {
348            scheduler.step();
349        }
350
351        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
352
353        // First 10 steps of decay should maintain the learning rate
354        for _ in 0..9 {
355            scheduler.step();
356        }
357        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
358
359        // After step_size, the learning rate should decay by decay_rate
360        scheduler.step(); // Step 10 of decay (20 overall)
361        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.05);
362
363        // Another 10 steps should maintain 0.05
364        for _ in 0..9 {
365            scheduler.step();
366        }
367        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.05);
368
369        // Another decay step
370        scheduler.step(); // Step 20 of decay (30 overall)
371        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.025);
372    }
373
374    #[test]
375    fn test_linear_warmup_cosine_decay() {
376        // Create scheduler with 10 warmup steps and 90 decay steps
377        let mut scheduler = LinearWarmupDecay::new(
378            0.1f64,
379            0.01,
380            10,
381            90,
382            DecayStrategy::Cosine { min_lr: 0.001 },
383        );
384
385        // Check warmup phase
386        for _ in 0..10 {
387            scheduler.step();
388        }
389
390        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
391
392        // Run for a while and collect learning rates
393        let mut lrs = Vec::new();
394        for _ in 0..90 {
395            lrs.push(scheduler.step());
396        }
397
398        // Verify the learning rate is decreasing
399        assert!(lrs[0] < 0.1); // Should decrease from initial
400
401        // The curve should eventually approach the minimum
402        let min_lr = lrs.iter().fold(1.0, |a, &b| a.min(b));
403        assert_abs_diff_eq!(min_lr, 0.001, epsilon = 1e-2);
404    }
405
406    #[test]
407    fn test_linear_warmup_constant() {
408        // Create scheduler with 10 warmup steps and constant after
409        let mut scheduler = LinearWarmupDecay::new(0.1f64, 0.01, 10, 90, DecayStrategy::Constant);
410
411        // Check warmup phase
412        for _ in 0..10 {
413            scheduler.step();
414        }
415
416        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
417
418        // After decay, we should still have the initial_lr
419        for _ in 0..90 {
420            scheduler.step();
421        }
422
423        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
424    }
425
426    #[test]
427    fn test_reset() {
428        // Create scheduler with 5 warmup steps and 15 decay steps
429        let mut scheduler = LinearWarmupDecay::new(
430            0.1f64,
431            0.01,
432            5,
433            15,
434            DecayStrategy::Linear { final_lr: 0.001 },
435        );
436
437        // Go through a few steps
438        for _ in 0..15 {
439            scheduler.step();
440        }
441
442        // Reset the scheduler
443        scheduler.reset();
444
445        // Verify state has been reset
446        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.01);
447        assert_eq!(scheduler.step, 0);
448        assert!(!scheduler.warmup_complete);
449        assert!(scheduler.inner_scheduler.is_none());
450
451        // Check we can perform warmup again
452        for _ in 0..5 {
453            scheduler.step();
454        }
455        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.1);
456    }
457
458    #[test]
459    fn test_zero_warmup() {
460        // Create scheduler with 0 warmup steps
461        let mut scheduler = LinearWarmupDecay::new(
462            0.1f64,
463            0.01,
464            0,
465            10,
466            DecayStrategy::Linear { final_lr: 0.001 },
467        );
468
469        // Initial learning rate should be the peak rate
470        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.01);
471
472        // First step should go directly to decay phase
473        scheduler.step();
474        assert!(scheduler.warmup_complete);
475
476        // Continue decay for 9 more steps
477        for _ in 0..9 {
478            scheduler.step();
479        }
480
481        // Verify final learning rate
482        assert_abs_diff_eq!(scheduler.get_learning_rate(), 0.001, epsilon = 1e-6);
483    }
484}