use std::f32::consts::PI;
pub trait LrSchedule: Send + Sync {
fn lr_at(&self, step: usize) -> f32;
}
pub struct Constant(pub f32);
impl LrSchedule for Constant {
fn lr_at(&self, _step: usize) -> f32 {
self.0
}
}
pub struct StepDecay {
pub base: f32,
pub every: usize,
pub gamma: f32,
}
impl StepDecay {
pub fn new(base: f32, every: usize, gamma: f32) -> StepDecay {
assert!(every > 0, "every must be > 0");
StepDecay { base, every, gamma }
}
}
impl LrSchedule for StepDecay {
fn lr_at(&self, step: usize) -> f32 {
assert!(self.every > 0, "every must be > 0");
self.base * self.gamma.powi((step / self.every) as i32)
}
}
pub struct CosineAnnealing {
pub base: f32,
pub floor: f32,
pub total: usize,
}
impl CosineAnnealing {
pub fn new(base: f32, total: usize) -> CosineAnnealing {
CosineAnnealing {
base,
floor: 0.0,
total,
}
}
pub fn floor(mut self, floor: f32) -> CosineAnnealing {
self.floor = floor;
self
}
}
impl LrSchedule for CosineAnnealing {
fn lr_at(&self, step: usize) -> f32 {
let progress = (step as f32 / self.total.max(1) as f32).min(1.0);
self.floor + 0.5 * (self.base - self.floor) * (1.0 + (PI * progress).cos())
}
}
pub struct Warmup<S> {
inner: S,
steps: usize,
}
impl<S: LrSchedule> Warmup<S> {
pub fn new(inner: S, steps: usize) -> Warmup<S> {
Warmup { inner, steps }
}
}
impl<S: LrSchedule> LrSchedule for Warmup<S> {
fn lr_at(&self, step: usize) -> f32 {
let target = self.inner.lr_at(step);
if step < self.steps {
target * (step + 1) as f32 / self.steps as f32
} else {
target
}
}
}
pub struct OneCycle {
pub peak: f32,
pub total: usize,
pub warmup_fraction: f32,
pub start_divisor: f32,
pub final_divisor: f32,
}
const DEFAULT_WARMUP_FRACTION: f32 = 0.3;
const DEFAULT_START_DIVISOR: f32 = 25.0;
const DEFAULT_FINAL_DIVISOR: f32 = 10_000.0;
impl OneCycle {
pub fn new(peak: f32, total: usize) -> OneCycle {
OneCycle {
peak,
total,
warmup_fraction: DEFAULT_WARMUP_FRACTION,
start_divisor: DEFAULT_START_DIVISOR,
final_divisor: DEFAULT_FINAL_DIVISOR,
}
}
}
impl LrSchedule for OneCycle {
fn lr_at(&self, step: usize) -> f32 {
assert!(
(0.0..=1.0).contains(&self.warmup_fraction),
"warmup_fraction must be in [0, 1], got {}",
self.warmup_fraction
);
let warmup = ((self.warmup_fraction * self.total as f32) as usize).min(self.total);
let start = self.peak / self.start_divisor;
let end = self.peak / self.final_divisor;
if step < warmup {
start + (self.peak - start) * (step as f32 / warmup.max(1) as f32)
} else {
let remaining = (self.total - warmup).max(1);
let progress = ((step - warmup) as f32 / remaining as f32).min(1.0);
end + 0.5 * (self.peak - end) * (1.0 + (PI * progress).cos())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cosine_spans_base_to_floor() {
let schedule = CosineAnnealing::new(1.0, 100);
assert!((schedule.lr_at(0) - 1.0).abs() < 1e-6);
assert!(schedule.lr_at(100).abs() < 1e-6);
assert!((schedule.lr_at(50) - 0.5).abs() < 1e-6);
}
#[test]
fn warmup_ramps_then_defers() {
let schedule = Warmup::new(Constant(1.0), 10);
assert!((schedule.lr_at(0) - 0.1).abs() < 1e-6);
assert!((schedule.lr_at(9) - 1.0).abs() < 1e-6);
assert!((schedule.lr_at(50) - 1.0).abs() < 1e-6);
}
#[test]
fn step_decay_halves_on_schedule() {
let schedule = StepDecay::new(1.0, 10, 0.5);
assert_eq!(schedule.lr_at(9), 1.0);
assert_eq!(schedule.lr_at(10), 0.5);
assert_eq!(schedule.lr_at(20), 0.25);
}
#[test]
fn step_decay_accepts_an_interval_of_one() {
let schedule = StepDecay::new(1.0, 1, 0.5);
assert_eq!(schedule.lr_at(1), 0.5);
assert_eq!(schedule.lr_at(2), 0.25);
}
#[test]
#[should_panic(expected = "every")]
fn step_decay_rejects_zero_every() {
StepDecay::new(1.0, 0, 0.5);
}
#[test]
#[should_panic(expected = "every")]
fn step_decay_rejects_zero_every_set_after_new() {
let mut schedule = StepDecay::new(1.0, 1, 0.5);
schedule.every = 0;
schedule.lr_at(0);
}
fn close(actual: f32, expected: f32) {
assert!(
(actual - expected).abs() < 1e-6,
"expected {expected}, got {actual}"
);
}
fn one_cycle(total: usize, warmup_fraction: f32) -> OneCycle {
OneCycle {
warmup_fraction,
..OneCycle::new(1.0, total)
}
}
#[test]
fn one_cycle_goes_from_start_to_peak_to_floor() {
let schedule = OneCycle::new(1.0, 100);
close(schedule.lr_at(0), 0.04);
close(schedule.lr_at(30), 1.0);
close(schedule.lr_at(65), 0.50005);
close(schedule.lr_at(100), 0.0001);
close(schedule.lr_at(101), 0.0001);
}
#[test]
fn one_cycle_accepts_both_ends_of_the_fraction_range() {
for zero in [0.0, -0.0] {
let schedule = one_cycle(100, zero);
close(schedule.lr_at(0), 1.0);
close(schedule.lr_at(100), 0.0001);
}
let schedule = one_cycle(100, 1.0);
close(schedule.lr_at(0), 0.04);
close(schedule.lr_at(50), 0.52);
close(schedule.lr_at(100), 1.0);
close(schedule.lr_at(101), 0.0001);
}
#[test]
fn one_cycle_handles_tiny_totals() {
for fraction in [0.0, 0.3, 1.0] {
let schedule = one_cycle(0, fraction);
close(schedule.lr_at(0), 1.0);
close(schedule.lr_at(1), 0.0001);
}
let schedule = one_cycle(1, 1.0);
close(schedule.lr_at(0), 0.04);
close(schedule.lr_at(1), 1.0);
close(schedule.lr_at(2), 0.0001);
}
#[test]
fn one_cycle_survives_a_total_that_rounds_up_in_f32() {
let total = 16_777_219;
close(one_cycle(total, 1.0).lr_at(total + 1), 0.0001);
}
#[test]
#[should_panic(expected = "warmup_fraction")]
fn one_cycle_rejects_a_fraction_above_one() {
one_cycle(10, 2.0).lr_at(0);
}
#[test]
#[should_panic(expected = "warmup_fraction")]
fn one_cycle_rejects_a_negative_fraction() {
one_cycle(10, -0.1).lr_at(0);
}
#[test]
#[should_panic(expected = "warmup_fraction")]
fn one_cycle_rejects_nan() {
one_cycle(10, f32::NAN).lr_at(0);
}
#[test]
#[should_panic(expected = "warmup_fraction")]
fn one_cycle_rejects_infinity() {
one_cycle(10, f32::INFINITY).lr_at(0);
}
}