Skip to main content

optirs_core/schedulers/
one_cycle.rs

1// One-cycle learning rate policy
2//
3// This module implements the one-cycle learning rate policy as described by Leslie N. Smith
4// in "A disciplined approach to neural network hyper-parameters: Part 1 -- learning rate,
5// batch size, momentum, and weight decay"
6
7use crate::error::{OptimError, Result};
8use crate::schedulers::LearningRateScheduler;
9use scirs2_core::ndarray::ScalarOperand;
10use scirs2_core::numeric::Float;
11use std::fmt::{self, Debug};
12
13/// Convert an `f64` constant into the scheduler's float type.
14fn from_f64<A: Float>(v: f64) -> A {
15    A::from(v).unwrap_or_else(A::zero)
16}
17
18/// Convert a `usize` counter into the scheduler's float type.
19fn from_usize<A: Float>(v: usize) -> A {
20    A::from(v).unwrap_or_else(A::zero)
21}
22
23/// Convert a `usize` denominator into the scheduler's float type.
24///
25/// Falls back to `1` so the value can never introduce a division by zero.
26fn denom_from_usize<A: Float>(v: usize) -> A {
27    match A::from(v) {
28        Some(x) if x != A::zero() => x,
29        _ => A::one(),
30    }
31}
32
33/// One-cycle learning rate policy
34///
35/// The one-cycle policy combines triangular learning rate policy with momentum cycling.
36/// It consists of two phases:
37/// 1. A warm-up phase where learning rate increases and momentum decreases
38/// 2. A cool-down phase where learning rate decreases and momentum increases
39///
40/// The schedule is *saturating*: once `total_steps` have been taken the learning rate
41/// stays at its final value instead of continuing past the end of the cycle (which used
42/// to produce negative learning rates).
43///
44/// # Example
45///
46/// ```
47/// use optirs_core::schedulers::{OneCycle, LearningRateScheduler};
48///
49/// let mut scheduler = OneCycle::new(
50///     0.0001,  // initial learning rate
51///     0.001,   // max learning rate
52///     1000,    // total steps
53///     0.25,    // warm-up percentage
54/// );
55///
56/// // The learning rate will increase from 0.0001 to 0.001 in first 250 steps,
57/// // then decrease to a value lower than initial in remaining 750 steps
58/// for _ in 0..1000 {
59///     let lr = scheduler.get_learning_rate();
60///     // Use lr for optimization
61///     scheduler.step();
62/// }
63/// ```
64pub struct OneCycle<A: Float> {
65    initial_lr: A,
66    max_lr: A,
67    final_lr: Option<A>,
68    /// Total number of steps in the cycle (always >= 1)
69    total_steps: usize,
70    /// Number of warm-up steps (always < `total_steps`)
71    warmup_steps: usize,
72    current_step: usize,
73    max_momentum: Option<A>,
74    min_momentum: Option<A>,
75    base_momentum: Option<A>,
76    anneal_strategy: AnnealStrategy,
77    final_div_factor: A,
78}
79
80/// Annealing strategy for the cool-down phase
81#[derive(Debug, Clone, Copy)]
82pub enum AnnealStrategy {
83    /// Linear annealing
84    Linear,
85    /// Cosine annealing
86    Cosine,
87}
88
89impl<A: Float + ScalarOperand + std::fmt::Debug + Send + Sync> OneCycle<A> {
90    /// Create a new one-cycle scheduler
91    ///
92    /// # Arguments
93    ///
94    /// * `initial_lr` - Starting learning rate
95    /// * `max_lr` - Maximum learning rate reached after warm-up
96    /// * `total_steps` - Total number of training steps. `0` is invalid and is clamped
97    ///   to `1`; use [`OneCycle::try_new`] to reject it instead.
98    /// * `warmup_frac` - Fraction of total steps used for warm-up (typically 0.2-0.3).
99    ///   Values outside `(0, 1)` (and non-finite values) are clamped so the resulting
100    ///   schedule always has at least one cool-down step.
101    pub fn new(initial_lr: A, max_lr: A, total_steps: usize, warmup_frac: f64) -> Self {
102        let total_steps = total_steps.max(1);
103        let frac = if warmup_frac.is_finite() {
104            warmup_frac.clamp(0.0, 1.0)
105        } else {
106            0.0
107        };
108        // `as usize` saturates at 0 for negative/NaN inputs, which `frac` already excludes.
109        let warmup_steps = ((total_steps as f64) * frac) as usize;
110        // Always leave at least one cool-down step so `total_steps - warmup_steps > 0`.
111        let warmup_steps = warmup_steps.min(total_steps.saturating_sub(1));
112
113        let final_div_factor = from_f64::<A>(10000.0); // Very small final LR
114
115        Self {
116            initial_lr,
117            max_lr,
118            final_lr: None,
119            total_steps,
120            warmup_steps,
121            current_step: 0,
122            max_momentum: None,
123            min_momentum: None,
124            base_momentum: None,
125            anneal_strategy: AnnealStrategy::Cosine,
126            final_div_factor,
127        }
128    }
129
130    /// Create a new one-cycle scheduler, validating the configuration
131    ///
132    /// # Errors
133    ///
134    /// Returns [`OptimError::InvalidConfig`] when
135    /// * `total_steps == 0`,
136    /// * `warmup_frac` is not finite or is outside the open interval `(0, 1)`,
137    /// * `initial_lr` or `max_lr` is not finite, or
138    /// * `initial_lr <= 0` or `max_lr < initial_lr`.
139    pub fn try_new(initial_lr: A, max_lr: A, total_steps: usize, warmup_frac: f64) -> Result<Self> {
140        if total_steps == 0 {
141            return Err(OptimError::InvalidConfig(
142                "OneCycle requires total_steps > 0".to_string(),
143            ));
144        }
145        if !warmup_frac.is_finite() || warmup_frac <= 0.0 || warmup_frac >= 1.0 {
146            return Err(OptimError::InvalidConfig(format!(
147                "OneCycle requires pct_start (warmup_frac) in the open interval (0, 1), got {warmup_frac}"
148            )));
149        }
150        if !initial_lr.is_finite() || !max_lr.is_finite() {
151            return Err(OptimError::InvalidConfig(
152                "OneCycle requires finite initial_lr and max_lr".to_string(),
153            ));
154        }
155        if initial_lr <= A::zero() {
156            return Err(OptimError::InvalidConfig(
157                "OneCycle requires initial_lr > 0".to_string(),
158            ));
159        }
160        if max_lr < initial_lr {
161            return Err(OptimError::InvalidConfig(
162                "OneCycle requires max_lr >= initial_lr".to_string(),
163            ));
164        }
165
166        Ok(Self::new(initial_lr, max_lr, total_steps, warmup_frac))
167    }
168
169    /// Create with specific final learning rate
170    pub fn with_final_lr(mut self, final_lr: A) -> Self {
171        self.final_lr = Some(final_lr);
172        self.final_div_factor = if final_lr == A::zero() {
173            from_f64::<A>(10000.0)
174        } else {
175            self.initial_lr / final_lr
176        };
177        self
178    }
179
180    /// Set momentum cycling parameters
181    pub fn with_momentum(mut self, min_momentum: A, max_momentum: A, base_momentum: A) -> Self {
182        self.min_momentum = Some(min_momentum);
183        self.max_momentum = Some(max_momentum);
184        self.base_momentum = Some(base_momentum);
185        self
186    }
187
188    /// Set annealing strategy for cool-down phase
189    pub fn with_anneal_strategy(mut self, strategy: AnnealStrategy) -> Self {
190        self.anneal_strategy = strategy;
191        self
192    }
193
194    /// Total number of steps in the cycle (always >= 1)
195    pub fn total_steps(&self) -> usize {
196        self.total_steps
197    }
198
199    /// Number of warm-up steps (always < `total_steps`)
200    pub fn warmup_steps(&self) -> usize {
201        self.warmup_steps
202    }
203
204    /// Progress through the warm-up phase, or `None` once warm-up is finished.
205    ///
206    /// The step counter is clamped to `total_steps` first, so the returned progress is
207    /// always in `[0, 1]`.
208    fn warmup_progress(&self) -> Option<A> {
209        let step = self.current_step.min(self.total_steps);
210        if self.warmup_steps == 0 || step >= self.warmup_steps {
211            return None;
212        }
213        Some(from_usize::<A>(step) / denom_from_usize::<A>(self.warmup_steps))
214    }
215
216    /// Progress through the cool-down phase, clamped to `[0, 1]`.
217    fn cooldown_progress(&self) -> A {
218        let step = self.current_step.min(self.total_steps);
219        // `warmup_steps < total_steps` is guaranteed by the constructor.
220        let remaining_steps = self.total_steps.saturating_sub(self.warmup_steps);
221        let cooled = step.saturating_sub(self.warmup_steps).min(remaining_steps);
222        from_usize::<A>(cooled) / denom_from_usize::<A>(remaining_steps)
223    }
224
225    /// Get current momentum value
226    pub fn get_momentum(&self) -> Option<A> {
227        match (self.min_momentum, self.max_momentum) {
228            (Some(min_mom), Some(max_mom)) => match self.warmup_progress() {
229                Some(progress) => {
230                    // During warm-up: momentum decreases
231                    Some(max_mom - (max_mom - min_mom) * progress)
232                }
233                None => {
234                    // During cool-down: momentum increases
235                    let cool_progress = self.cooldown_progress();
236                    match self.anneal_strategy {
237                        AnnealStrategy::Linear => {
238                            Some(min_mom + (max_mom - min_mom) * cool_progress)
239                        }
240                        AnnealStrategy::Cosine => {
241                            let cos_out = ((cool_progress * from_f64::<A>(std::f64::consts::PI))
242                                .cos()
243                                + A::one())
244                                / from_f64::<A>(2.0);
245                            Some(min_mom + (max_mom - min_mom) * (A::one() - cos_out))
246                        }
247                    }
248                }
249            },
250            _ => self.base_momentum,
251        }
252    }
253
254    /// Get fraction of the cycle that has been completed, clamped to `[0, 1]`
255    pub fn get_percentage_complete(&self) -> A {
256        let step = self.current_step.min(self.total_steps);
257        from_usize::<A>(step) / denom_from_usize::<A>(self.total_steps)
258    }
259}
260
261impl<A: Float + ScalarOperand + Debug + Send + Sync> LearningRateScheduler<A> for OneCycle<A> {
262    fn get_learning_rate(&self) -> A {
263        match self.warmup_progress() {
264            Some(progress) => {
265                // Warm-up phase: increase from initial to max
266                self.initial_lr + (self.max_lr - self.initial_lr) * progress
267            }
268            None => {
269                // Cool-down phase: decrease from max to final
270                let cool_progress = self.cooldown_progress();
271                let final_lr = match self.final_lr {
272                    Some(lr) => lr,
273                    None => self.initial_lr / self.final_div_factor,
274                };
275
276                match self.anneal_strategy {
277                    AnnealStrategy::Linear => {
278                        self.max_lr - (self.max_lr - final_lr) * cool_progress
279                    }
280                    AnnealStrategy::Cosine => {
281                        let cos_out = ((cool_progress * from_f64::<A>(std::f64::consts::PI)).cos()
282                            + A::one())
283                            / from_f64::<A>(2.0);
284                        final_lr + (self.max_lr - final_lr) * cos_out
285                    }
286                }
287            }
288        }
289    }
290
291    fn step(&mut self) -> A {
292        // Saturate at `total_steps`: the one-cycle policy has a defined end.
293        self.current_step = self.current_step.saturating_add(1).min(self.total_steps);
294        self.get_learning_rate()
295    }
296
297    fn reset(&mut self) {
298        self.current_step = 0;
299    }
300}
301
302impl<A: Float + Debug + Send + Sync> fmt::Debug for OneCycle<A> {
303    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
304        f.debug_struct("OneCycle")
305            .field("initial_lr", &self.initial_lr)
306            .field("max_lr", &self.max_lr)
307            .field("final_lr", &self.final_lr)
308            .field("total_steps", &self.total_steps)
309            .field("warmup_steps", &self.warmup_steps)
310            .field("current_step", &self.current_step)
311            .field("anneal_strategy", &self.anneal_strategy)
312            .finish()
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use approx::assert_relative_eq;
320
321    #[test]
322    fn test_one_cycle_basic() {
323        let mut scheduler = OneCycle::new(0.0001, 0.001, 100, 0.25);
324
325        // Initial learning rate
326        assert_relative_eq!(scheduler.get_learning_rate(), 0.0001, epsilon = 1e-6);
327
328        // At end of warm-up (25% = 25 steps)
329        for _ in 0..25 {
330            scheduler.step();
331        }
332        assert_relative_eq!(scheduler.get_learning_rate(), 0.001, epsilon = 1e-6);
333
334        // Final learning rate should be very small
335        for _ in 25..100 {
336            scheduler.step();
337        }
338        assert!(scheduler.get_learning_rate() < 0.0001);
339    }
340
341    #[test]
342    fn test_one_cycle_momentum() {
343        let mut scheduler = OneCycle::new(0.0001, 0.001, 100, 0.25).with_momentum(0.85, 0.95, 0.9);
344
345        // Initial momentum (max during warm-up)
346        assert_relative_eq!(
347            scheduler.get_momentum().unwrap_or(f64::NAN),
348            0.95,
349            epsilon = 1e-6
350        );
351
352        // At end of warm-up (min momentum)
353        for _ in 0..25 {
354            scheduler.step();
355        }
356        assert_relative_eq!(
357            scheduler.get_momentum().unwrap_or(f64::NAN),
358            0.85,
359            epsilon = 1e-6
360        );
361
362        // Final momentum (back to max)
363        for _ in 25..100 {
364            scheduler.step();
365        }
366        let final_momentum = scheduler.get_momentum().unwrap_or(f64::NAN);
367        assert!(final_momentum > 0.94); // Should be close to max
368    }
369
370    #[test]
371    fn test_one_cycle_linear_anneal() {
372        let mut scheduler = OneCycle::new(0.0001, 0.001, 100, 0.25)
373            .with_anneal_strategy(AnnealStrategy::Linear)
374            .with_final_lr(0.00001);
375
376        // Move past warm-up
377        for _ in 0..25 {
378            scheduler.step();
379        }
380
381        let lr_at_warmup = scheduler.get_learning_rate();
382        assert_relative_eq!(lr_at_warmup, 0.001, epsilon = 1e-6);
383
384        // Check linear decrease
385        for _ in 0..37 {
386            // Halfway through cool-down
387            scheduler.step();
388        }
389
390        let lr_halfway = scheduler.get_learning_rate();
391        assert!(lr_halfway < 0.001);
392        assert!(lr_halfway > 0.00001);
393
394        // Should decrease linearly
395        let expected = 0.001 - (0.001 - 0.00001) * 0.5;
396        assert_relative_eq!(lr_halfway, expected, epsilon = 1e-4);
397    }
398
399    #[test]
400    fn test_percentage_complete() {
401        let mut scheduler = OneCycle::new(0.0001, 0.001, 100, 0.25);
402
403        assert_relative_eq!(scheduler.get_percentage_complete(), 0.0, epsilon = 1e-6);
404
405        for _ in 0..50 {
406            scheduler.step();
407        }
408        assert_relative_eq!(scheduler.get_percentage_complete(), 0.5, epsilon = 1e-6);
409
410        for _ in 50..100 {
411            scheduler.step();
412        }
413        assert_relative_eq!(scheduler.get_percentage_complete(), 1.0, epsilon = 1e-6);
414
415        // Stepping past the end keeps the percentage clamped.
416        for _ in 0..50 {
417            scheduler.step();
418        }
419        assert_relative_eq!(scheduler.get_percentage_complete(), 1.0, epsilon = 1e-6);
420    }
421
422    #[test]
423    fn test_reset() {
424        let mut scheduler = OneCycle::new(0.0001, 0.001, 100, 0.25);
425
426        // Advance scheduler
427        for _ in 0..50 {
428            scheduler.step();
429        }
430
431        let lr_mid = scheduler.get_learning_rate();
432        assert!(lr_mid != 0.0001);
433
434        // Reset
435        scheduler.reset();
436        assert_eq!(scheduler.current_step, 0);
437        assert_relative_eq!(scheduler.get_learning_rate(), 0.0001, epsilon = 1e-6);
438    }
439
440    #[test]
441    fn test_degenerate_configs_are_clamped() {
442        // total_steps == 0 must not divide by zero.
443        let mut zero = OneCycle::new(0.0001, 0.001, 0, 0.25);
444        assert_eq!(zero.total_steps(), 1);
445        for _ in 0..5 {
446            assert!(zero.step().is_finite());
447        }
448
449        // warmup_frac > 1 must not underflow `total_steps - warmup_steps`.
450        let mut over = OneCycle::new(0.0001, 0.001, 100, 1.5);
451        assert!(over.warmup_steps() < over.total_steps());
452        for _ in 0..200 {
453            let lr = over.step();
454            assert!(lr.is_finite() && lr >= 0.0);
455        }
456
457        // Non-finite fractions fall back to "no warm-up".
458        let nan = OneCycle::new(0.0001, 0.001, 100, f64::NAN);
459        assert_eq!(nan.warmup_steps(), 0);
460    }
461
462    #[test]
463    fn test_try_new_validates() {
464        assert!(OneCycle::try_new(0.0001f64, 0.001, 0, 0.25).is_err());
465        assert!(OneCycle::try_new(0.0001f64, 0.001, 100, 0.0).is_err());
466        assert!(OneCycle::try_new(0.0001f64, 0.001, 100, 1.0).is_err());
467        assert!(OneCycle::try_new(0.0001f64, 0.001, 100, f64::NAN).is_err());
468        assert!(OneCycle::try_new(0.0f64, 0.001, 100, 0.25).is_err());
469        assert!(OneCycle::try_new(0.001f64, 0.0001, 100, 0.25).is_err());
470        assert!(OneCycle::try_new(0.0001f64, 0.001, 100, 0.25).is_ok());
471    }
472}