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}