use std::f64::consts::PI;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScheduleType {
Constant,
StepDecay,
ExponentialDecay,
CosineAnnealing,
WarmupLinear,
OneCycleLR,
}
impl std::fmt::Display for ScheduleType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Constant => write!(f, "Constant"),
Self::StepDecay => write!(f, "StepDecay"),
Self::ExponentialDecay => write!(f, "ExponentialDecay"),
Self::CosineAnnealing => write!(f, "CosineAnnealing"),
Self::WarmupLinear => write!(f, "WarmupLinear"),
Self::OneCycleLR => write!(f, "OneCycleLR"),
}
}
}
#[derive(Debug, Clone)]
pub struct LRSchedulerConfig {
pub schedule_type: ScheduleType,
pub initial_lr: f64,
pub min_lr: f64,
pub gamma: f64,
pub step_size: usize,
pub total_steps: usize,
pub warmup_steps: usize,
pub max_lr: f64,
}
impl Default for LRSchedulerConfig {
fn default() -> Self {
Self {
schedule_type: ScheduleType::Constant,
initial_lr: 0.01,
min_lr: 1e-6,
gamma: 0.1,
step_size: 30,
total_steps: 1000,
warmup_steps: 100,
max_lr: 0.1,
}
}
}
#[derive(Debug, Clone)]
pub struct LRSchedulerStats {
pub schedule_type: ScheduleType,
pub current_step: usize,
pub current_lr: f64,
pub initial_lr: f64,
}
#[derive(Debug, Clone)]
pub struct TensorLRScheduler {
config: LRSchedulerConfig,
current_step: usize,
current_lr: f64,
}
impl TensorLRScheduler {
pub fn new(config: LRSchedulerConfig) -> Self {
let lr = Self::compute_lr(&config, 0);
Self {
config,
current_step: 0,
current_lr: lr,
}
}
pub fn step(&mut self) -> f64 {
self.current_step += 1;
self.current_lr = Self::compute_lr(&self.config, self.current_step);
self.current_lr
}
pub fn get_lr(&self) -> f64 {
self.current_lr
}
pub fn set_step(&mut self, step: usize) {
self.current_step = step;
self.current_lr = Self::compute_lr(&self.config, step);
}
pub fn remaining_steps(&self) -> Option<usize> {
match self.config.schedule_type {
ScheduleType::CosineAnnealing | ScheduleType::OneCycleLR => {
Some(self.config.total_steps.saturating_sub(self.current_step))
}
ScheduleType::WarmupLinear => {
Some(self.config.warmup_steps.saturating_sub(self.current_step))
}
ScheduleType::Constant | ScheduleType::StepDecay | ScheduleType::ExponentialDecay => {
None
}
}
}
pub fn reset(&mut self) {
self.current_step = 0;
self.current_lr = Self::compute_lr(&self.config, 0);
}
pub fn stats(&self) -> LRSchedulerStats {
LRSchedulerStats {
schedule_type: self.config.schedule_type,
current_step: self.current_step,
current_lr: self.current_lr,
initial_lr: self.config.initial_lr,
}
}
pub fn config(&self) -> &LRSchedulerConfig {
&self.config
}
fn compute_lr(cfg: &LRSchedulerConfig, step: usize) -> f64 {
match cfg.schedule_type {
ScheduleType::Constant => cfg.initial_lr,
ScheduleType::StepDecay => {
let exponent = (step / cfg.step_size.max(1)) as f64;
cfg.initial_lr * cfg.gamma.powf(exponent)
}
ScheduleType::ExponentialDecay => cfg.initial_lr * cfg.gamma.powf(step as f64),
ScheduleType::CosineAnnealing => {
let total = cfg.total_steps.max(1) as f64;
let t = (step as f64).min(total);
cfg.min_lr + 0.5 * (cfg.initial_lr - cfg.min_lr) * (1.0 + (PI * t / total).cos())
}
ScheduleType::WarmupLinear => {
if cfg.warmup_steps == 0 {
return cfg.initial_lr;
}
if step < cfg.warmup_steps {
cfg.initial_lr * (step as f64) / (cfg.warmup_steps as f64)
} else {
cfg.initial_lr
}
}
ScheduleType::OneCycleLR => {
let warmup = cfg.warmup_steps.max(1);
let total = cfg.total_steps.max(warmup + 1);
if step < warmup {
let frac = step as f64 / warmup as f64;
cfg.min_lr + frac * (cfg.max_lr - cfg.min_lr)
} else {
let decay_steps = (total - warmup).max(1) as f64;
let t = ((step - warmup) as f64).min(decay_steps);
cfg.min_lr
+ 0.5 * (cfg.max_lr - cfg.min_lr) * (1.0 + (PI * t / decay_steps).cos())
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() < tol
}
#[test]
fn constant_always_returns_initial_lr() {
let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
schedule_type: ScheduleType::Constant,
initial_lr: 0.05,
..Default::default()
});
for _ in 0..50 {
let lr = sched.step();
assert!(approx_eq(lr, 0.05, 1e-12));
}
}
#[test]
fn constant_get_lr_matches_step() {
let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
schedule_type: ScheduleType::Constant,
initial_lr: 0.02,
..Default::default()
});
sched.step();
assert!(approx_eq(sched.get_lr(), 0.02, 1e-12));
}
#[test]
fn step_decay_decays_at_boundary() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
initial_lr: 1.0,
gamma: 0.5,
step_size: 10,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
for _ in 0..9 {
sched.step();
}
assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
sched.step();
assert!(approx_eq(sched.get_lr(), 0.5, 1e-12));
for _ in 0..10 {
sched.step();
}
assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
}
#[test]
fn step_decay_within_interval_is_flat() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
initial_lr: 0.1,
gamma: 0.1,
step_size: 5,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
let lr0 = sched.get_lr();
for i in 1..5 {
sched.step();
assert!(
approx_eq(sched.get_lr(), lr0, 1e-12),
"LR changed at step {i} within interval"
);
}
}
#[test]
fn exponential_monotone_decrease() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::ExponentialDecay,
initial_lr: 1.0,
gamma: 0.95,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
let mut prev = sched.get_lr();
for _ in 0..100 {
let lr = sched.step();
assert!(lr < prev + 1e-15, "LR did not decrease");
prev = lr;
}
}
#[test]
fn exponential_decay_formula() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::ExponentialDecay,
initial_lr: 2.0,
gamma: 0.9,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for step in 1..=5 {
let lr = sched.step();
let expected = 2.0 * 0.9_f64.powf(step as f64);
assert!(
approx_eq(lr, expected, 1e-10),
"step {step}: got {lr}, expected {expected}"
);
}
}
#[test]
fn cosine_reaches_min_at_total_steps() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
initial_lr: 0.1,
min_lr: 1e-5,
total_steps: 200,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..200 {
sched.step();
}
assert!(
approx_eq(sched.get_lr(), 1e-5, 1e-10),
"LR at total_steps should be min_lr, got {}",
sched.get_lr()
);
}
#[test]
fn cosine_starts_at_initial_lr() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
initial_lr: 0.05,
min_lr: 0.001,
total_steps: 500,
..Default::default()
};
let sched = TensorLRScheduler::new(cfg);
assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
}
#[test]
fn cosine_midpoint_value() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
initial_lr: 1.0,
min_lr: 0.0,
total_steps: 100,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..50 {
sched.step();
}
assert!(
approx_eq(sched.get_lr(), 0.5, 1e-10),
"cosine midpoint: got {}",
sched.get_lr()
);
}
#[test]
fn warmup_linear_ramp() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::WarmupLinear,
initial_lr: 0.1,
warmup_steps: 10,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
assert!(approx_eq(sched.get_lr(), 0.0, 1e-12));
for _ in 0..5 {
sched.step();
}
assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
for _ in 0..5 {
sched.step();
}
assert!(approx_eq(sched.get_lr(), 0.1, 1e-12));
}
#[test]
fn warmup_holds_after_warmup() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::WarmupLinear,
initial_lr: 0.01,
warmup_steps: 5,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..20 {
sched.step();
}
assert!(approx_eq(sched.get_lr(), 0.01, 1e-12));
}
#[test]
fn warmup_zero_steps_returns_initial() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::WarmupLinear,
initial_lr: 0.03,
warmup_steps: 0,
..Default::default()
};
let sched = TensorLRScheduler::new(cfg);
assert!(approx_eq(sched.get_lr(), 0.03, 1e-12));
}
#[test]
fn one_cycle_starts_at_min_lr() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::OneCycleLR,
initial_lr: 0.01,
min_lr: 1e-4,
max_lr: 0.1,
warmup_steps: 50,
total_steps: 200,
..Default::default()
};
let sched = TensorLRScheduler::new(cfg);
assert!(
approx_eq(sched.get_lr(), 1e-4, 1e-12),
"OneCycle should start at min_lr, got {}",
sched.get_lr()
);
}
#[test]
fn one_cycle_reaches_peak() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::OneCycleLR,
initial_lr: 0.01,
min_lr: 0.0,
max_lr: 0.2,
warmup_steps: 100,
total_steps: 500,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..100 {
sched.step();
}
assert!(
approx_eq(sched.get_lr(), 0.2, 1e-10),
"OneCycle should reach max_lr at warmup end, got {}",
sched.get_lr()
);
}
#[test]
fn one_cycle_ends_at_min_lr() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::OneCycleLR,
initial_lr: 0.01,
min_lr: 1e-5,
max_lr: 0.1,
warmup_steps: 50,
total_steps: 300,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..300 {
sched.step();
}
assert!(
approx_eq(sched.get_lr(), 1e-5, 1e-10),
"OneCycle should end at min_lr, got {}",
sched.get_lr()
);
}
#[test]
fn reset_restores_step_zero() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::ExponentialDecay,
initial_lr: 1.0,
gamma: 0.5,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..10 {
sched.step();
}
sched.reset();
assert_eq!(sched.stats().current_step, 0);
assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
}
#[test]
fn set_step_jumps_correctly() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
initial_lr: 1.0,
gamma: 0.5,
step_size: 10,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
sched.set_step(25);
assert_eq!(sched.stats().current_step, 25);
assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
}
#[test]
fn remaining_steps_cosine() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
total_steps: 100,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
assert_eq!(sched.remaining_steps(), Some(100));
for _ in 0..30 {
sched.step();
}
assert_eq!(sched.remaining_steps(), Some(70));
}
#[test]
fn remaining_steps_none_for_constant() {
let sched = TensorLRScheduler::new(LRSchedulerConfig {
schedule_type: ScheduleType::Constant,
..Default::default()
});
assert_eq!(sched.remaining_steps(), None);
}
#[test]
fn remaining_steps_none_for_step_decay() {
let sched = TensorLRScheduler::new(LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
..Default::default()
});
assert_eq!(sched.remaining_steps(), None);
}
#[test]
fn remaining_steps_none_for_exponential() {
let sched = TensorLRScheduler::new(LRSchedulerConfig {
schedule_type: ScheduleType::ExponentialDecay,
..Default::default()
});
assert_eq!(sched.remaining_steps(), None);
}
#[test]
fn remaining_steps_warmup() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::WarmupLinear,
warmup_steps: 50,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
assert_eq!(sched.remaining_steps(), Some(50));
for _ in 0..50 {
sched.step();
}
assert_eq!(sched.remaining_steps(), Some(0));
}
#[test]
fn remaining_steps_one_cycle() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::OneCycleLR,
warmup_steps: 20,
total_steps: 100,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
assert_eq!(sched.remaining_steps(), Some(100));
for _ in 0..40 {
sched.step();
}
assert_eq!(sched.remaining_steps(), Some(60));
}
#[test]
fn stats_snapshot() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
initial_lr: 0.1,
total_steps: 200,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..10 {
sched.step();
}
let s = sched.stats();
assert_eq!(s.schedule_type, ScheduleType::CosineAnnealing);
assert_eq!(s.current_step, 10);
assert!(approx_eq(s.initial_lr, 0.1, 1e-12));
assert!(approx_eq(s.current_lr, sched.get_lr(), 1e-15));
}
#[test]
fn step_advances_lr() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::ExponentialDecay,
initial_lr: 1.0,
gamma: 0.9,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
let lr0 = sched.get_lr();
let lr1 = sched.step();
assert!(lr1 < lr0, "step should change LR for decay schedules");
}
#[test]
fn default_config_values() {
let cfg = LRSchedulerConfig::default();
assert_eq!(cfg.schedule_type, ScheduleType::Constant);
assert!(approx_eq(cfg.initial_lr, 0.01, 1e-15));
assert!(approx_eq(cfg.min_lr, 1e-6, 1e-15));
assert!(approx_eq(cfg.gamma, 0.1, 1e-15));
assert_eq!(cfg.step_size, 30);
assert_eq!(cfg.total_steps, 1000);
assert_eq!(cfg.warmup_steps, 100);
assert!(approx_eq(cfg.max_lr, 0.1, 1e-15));
}
#[test]
fn schedule_type_display() {
assert_eq!(format!("{}", ScheduleType::Constant), "Constant");
assert_eq!(format!("{}", ScheduleType::OneCycleLR), "OneCycleLR");
}
#[test]
fn step_decay_zero_step_size_no_panic() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
initial_lr: 1.0,
gamma: 0.5,
step_size: 0,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
let _ = sched.step();
}
#[test]
fn cosine_beyond_total_steps_clamps() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::CosineAnnealing,
initial_lr: 0.1,
min_lr: 0.001,
total_steps: 50,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
for _ in 0..100 {
sched.step();
}
assert!(
approx_eq(sched.get_lr(), 0.001, 1e-10),
"cosine should clamp at min_lr beyond total_steps, got {}",
sched.get_lr()
);
}
#[test]
fn one_cycle_warmup_phase_is_monotone_increasing() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::OneCycleLR,
min_lr: 0.0,
max_lr: 1.0,
warmup_steps: 50,
total_steps: 200,
..Default::default()
};
let mut sched = TensorLRScheduler::new(cfg);
let mut prev = sched.get_lr();
for _ in 0..50 {
let lr = sched.step();
assert!(
lr >= prev - 1e-15,
"warmup should be monotonically increasing"
);
prev = lr;
}
}
#[test]
fn config_accessor() {
let cfg = LRSchedulerConfig {
schedule_type: ScheduleType::StepDecay,
gamma: 0.3,
..Default::default()
};
let sched = TensorLRScheduler::new(cfg);
assert!(approx_eq(sched.config().gamma, 0.3, 1e-15));
}
}
#[derive(Debug, Clone)]
pub enum SchedulerStrategy {
Constant {
lr: f64,
},
StepDecay {
initial_lr: f64,
decay_factor: f64,
step_size: u64,
},
ExponentialDecay {
initial_lr: f64,
decay_rate: f64,
},
CosineAnnealing {
initial_lr: f64,
min_lr: f64,
t_max: u64,
},
WarmupCosine {
warmup_epochs: u64,
initial_lr: f64,
peak_lr: f64,
min_lr: f64,
t_max: u64,
},
CyclicLR {
base_lr: f64,
max_lr: f64,
step_size: u64,
},
ReduceOnPlateau {
initial_lr: f64,
factor: f64,
patience: u64,
min_lr: f64,
threshold: f64,
},
}
#[derive(Debug, Clone)]
pub struct LrSchedulerState {
pub current_epoch: u64,
pub current_lr: f64,
pub best_loss: f64,
pub plateau_count: u64,
pub cycles_completed: u64,
}
impl LrSchedulerState {
fn new(initial_lr: f64) -> Self {
Self {
current_epoch: 0,
current_lr: initial_lr,
best_loss: f64::INFINITY,
plateau_count: 0,
cycles_completed: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct LrHistory {
pub epoch: u64,
pub lr: f64,
pub loss: Option<f64>,
}
#[derive(Debug, Clone)]
pub struct LrStats {
pub min_lr_seen: f64,
pub max_lr_seen: f64,
pub plateau_reductions: u64,
pub epochs_trained: u64,
}
const MAX_HISTORY: usize = 1000;
#[derive(Debug, Clone)]
pub struct LearningRateScheduler {
pub strategy: SchedulerStrategy,
pub state: LrSchedulerState,
history: Vec<LrHistory>,
plateau_reductions: u64,
}
impl LearningRateScheduler {
pub fn new(strategy: SchedulerStrategy) -> Self {
let initial_lr = Self::extract_initial_lr(&strategy);
Self {
state: LrSchedulerState::new(initial_lr),
strategy,
history: Vec::new(),
plateau_reductions: 0,
}
}
pub fn step(&mut self, epoch: u64) -> f64 {
let lr = self.compute_lr(epoch);
self.state.current_epoch = epoch;
self.state.current_lr = lr;
self.push_history(epoch, lr, None);
lr
}
pub fn step_with_loss(&mut self, epoch: u64, loss: f64) -> f64 {
let lr = match &self.strategy {
SchedulerStrategy::ReduceOnPlateau {
factor,
patience,
min_lr,
threshold,
..
} => {
let factor = *factor;
let patience = *patience;
let floor = *min_lr;
let threshold = *threshold;
let improved = loss < self.state.best_loss - threshold;
if improved {
self.state.best_loss = loss;
self.state.plateau_count = 0;
} else {
self.state.plateau_count += 1;
}
if self.state.plateau_count >= patience {
let reduced = (self.state.current_lr * factor).max(floor);
if reduced < self.state.current_lr {
self.state.current_lr = reduced;
self.plateau_reductions += 1;
}
self.state.plateau_count = 0;
}
self.state.current_lr
}
_ => self.compute_lr(epoch),
};
self.state.current_epoch = epoch;
self.state.current_lr = lr;
self.push_history(epoch, lr, Some(loss));
lr
}
pub fn current_lr(&self) -> f64 {
self.state.current_lr
}
pub fn reset(&mut self) {
let initial_lr = Self::extract_initial_lr(&self.strategy);
self.state = LrSchedulerState::new(initial_lr);
self.history.clear();
self.plateau_reductions = 0;
}
pub fn history(&self) -> &[LrHistory] {
&self.history
}
pub fn stats(&self) -> LrStats {
let (min_lr_seen, max_lr_seen) = if self.history.is_empty() {
(self.state.current_lr, self.state.current_lr)
} else {
let min = self
.history
.iter()
.map(|h| h.lr)
.fold(f64::INFINITY, f64::min);
let max = self
.history
.iter()
.map(|h| h.lr)
.fold(f64::NEG_INFINITY, f64::max);
(min, max)
};
LrStats {
min_lr_seen,
max_lr_seen,
plateau_reductions: self.plateau_reductions,
epochs_trained: self.history.len() as u64,
}
}
pub fn warmup_factor(epoch: u64, warmup_epochs: u64) -> f64 {
if warmup_epochs == 0 {
return 1.0;
}
(epoch as f64 / warmup_epochs as f64).min(1.0)
}
pub fn cosine_factor(epoch: u64, t_max: u64) -> f64 {
if t_max == 0 {
return 0.0;
}
let t = (epoch as f64).min(t_max as f64);
(1.0 + (PI * t / t_max as f64).cos()) / 2.0
}
fn extract_initial_lr(strategy: &SchedulerStrategy) -> f64 {
match strategy {
SchedulerStrategy::Constant { lr } => *lr,
SchedulerStrategy::StepDecay { initial_lr, .. } => *initial_lr,
SchedulerStrategy::ExponentialDecay { initial_lr, .. } => *initial_lr,
SchedulerStrategy::CosineAnnealing { initial_lr, .. } => *initial_lr,
SchedulerStrategy::WarmupCosine { initial_lr, .. } => *initial_lr,
SchedulerStrategy::CyclicLR { base_lr, .. } => *base_lr,
SchedulerStrategy::ReduceOnPlateau { initial_lr, .. } => *initial_lr,
}
}
fn compute_lr(&mut self, epoch: u64) -> f64 {
match &self.strategy {
SchedulerStrategy::Constant { lr } => *lr,
SchedulerStrategy::StepDecay {
initial_lr,
decay_factor,
step_size,
} => {
let exponent = if *step_size == 0 {
epoch
} else {
epoch / step_size
};
initial_lr * decay_factor.powi(exponent as i32)
}
SchedulerStrategy::ExponentialDecay {
initial_lr,
decay_rate,
} => initial_lr * (-decay_rate * epoch as f64).exp(),
SchedulerStrategy::CosineAnnealing {
initial_lr,
min_lr,
t_max,
} => {
let factor = Self::cosine_factor(epoch, *t_max);
min_lr + (initial_lr - min_lr) * factor
}
SchedulerStrategy::WarmupCosine {
warmup_epochs,
initial_lr,
peak_lr,
min_lr,
t_max,
} => {
let warmup = *warmup_epochs;
let floor = *min_lr;
let peak = *peak_lr;
let start = *initial_lr;
let period = *t_max;
if epoch < warmup {
let w = Self::warmup_factor(epoch, warmup);
start + w * (peak - start)
} else {
let cosine_epoch = epoch - warmup;
let factor = Self::cosine_factor(cosine_epoch, period);
floor + (peak - floor) * factor
}
}
SchedulerStrategy::CyclicLR {
base_lr,
max_lr,
step_size,
} => {
let base = *base_lr;
let peak = *max_lr;
let half = (*step_size).max(1);
let cycle_len = 2 * half;
let cycle_epoch = epoch % cycle_len;
let frac = if cycle_epoch < half {
cycle_epoch as f64 / half as f64
} else {
(cycle_len - cycle_epoch) as f64 / half as f64
};
base + frac * (peak - base)
}
SchedulerStrategy::ReduceOnPlateau { .. } => {
self.state.current_lr
}
}
}
fn push_history(&mut self, epoch: u64, lr: f64, loss: Option<f64>) {
if self.history.len() >= MAX_HISTORY {
self.history.remove(0);
}
self.history.push(LrHistory { epoch, lr, loss });
}
}
#[cfg(test)]
mod lr_scheduler_tests {
use crate::lr_scheduler::{LearningRateScheduler, SchedulerStrategy};
const TOL: f64 = 1e-10;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < TOL
}
#[test]
fn constant_lr_never_changes() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.03 });
for epoch in 0..50 {
let lr = sched.step(epoch);
assert!(approx(lr, 0.03), "epoch {epoch}: expected 0.03, got {lr}");
}
}
#[test]
fn constant_current_lr_matches_step() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
sched.step(0);
assert!(approx(sched.current_lr(), 0.01));
}
#[test]
fn constant_initial_lr_from_new() {
let sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.05 });
assert!(approx(sched.current_lr(), 0.05));
}
#[test]
fn step_decay_flat_within_interval() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
initial_lr: 1.0,
decay_factor: 0.5,
step_size: 10,
});
for epoch in 0..10 {
let lr = sched.step(epoch);
assert!(approx(lr, 1.0), "epoch {epoch}: expected 1.0, got {lr}");
}
}
#[test]
fn step_decay_applies_at_boundary() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
initial_lr: 1.0,
decay_factor: 0.5,
step_size: 10,
});
let lr10 = sched.step(10);
assert!(approx(lr10, 0.5), "expected 0.5 at epoch 10, got {lr10}");
let lr20 = sched.step(20);
assert!(approx(lr20, 0.25), "expected 0.25 at epoch 20, got {lr20}");
}
#[test]
fn step_decay_zero_step_size_no_panic() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
initial_lr: 1.0,
decay_factor: 0.5,
step_size: 0,
});
let _ = sched.step(5); }
#[test]
fn exponential_decay_formula() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
initial_lr: 1.0,
decay_rate: 0.1,
});
for epoch in 0u64..=5 {
let lr = sched.step(epoch);
let expected = (-0.1_f64 * epoch as f64).exp();
assert!(
approx(lr, expected),
"epoch {epoch}: expected {expected}, got {lr}"
);
}
}
#[test]
fn exponential_decay_monotone_decreasing() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
initial_lr: 2.0,
decay_rate: 0.05,
});
let mut prev = sched.step(0);
for epoch in 1..100 {
let lr = sched.step(epoch);
assert!(lr < prev + 1e-15, "epoch {epoch}: LR did not decrease");
prev = lr;
}
}
#[test]
fn exponential_decay_at_epoch_zero_equals_initial() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
initial_lr: 0.5,
decay_rate: 0.2,
});
assert!(approx(sched.step(0), 0.5));
}
#[test]
fn cosine_annealing_starts_at_initial_lr() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
initial_lr: 0.1,
min_lr: 0.001,
t_max: 100,
});
let lr = sched.step(0);
assert!(approx(lr, 0.1), "expected 0.1, got {lr}");
}
#[test]
fn cosine_annealing_reaches_min_at_t_max() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
initial_lr: 0.1,
min_lr: 1e-4,
t_max: 100,
});
let lr = sched.step(100);
assert!(approx(lr, 1e-4), "expected min_lr at t_max, got {lr}");
}
#[test]
fn cosine_annealing_midpoint() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
initial_lr: 1.0,
min_lr: 0.0,
t_max: 100,
});
let lr = sched.step(50);
assert!(approx(lr, 0.5), "cosine midpoint should be 0.5, got {lr}");
}
#[test]
fn cosine_annealing_clamps_beyond_t_max() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
initial_lr: 0.1,
min_lr: 0.001,
t_max: 50,
});
let lr_at_50 = sched.step(50);
let lr_at_200 = sched.step(200);
assert!(approx(lr_at_50, lr_at_200), "beyond t_max should clamp");
}
#[test]
fn warmup_cosine_starts_at_initial_lr() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
warmup_epochs: 10,
initial_lr: 0.0,
peak_lr: 0.1,
min_lr: 1e-5,
t_max: 90,
});
let lr = sched.step(0);
assert!(approx(lr, 0.0), "expected 0.0 at epoch 0, got {lr}");
}
#[test]
fn warmup_cosine_reaches_peak_after_warmup() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
warmup_epochs: 10,
initial_lr: 0.0,
peak_lr: 0.1,
min_lr: 1e-5,
t_max: 90,
});
let lr = sched.step(10);
assert!(approx(lr, 0.1), "expected peak_lr at warmup end, got {lr}");
}
#[test]
fn warmup_cosine_linear_during_warmup() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
warmup_epochs: 10,
initial_lr: 0.0,
peak_lr: 0.1,
min_lr: 1e-5,
t_max: 90,
});
let lr5 = sched.step(5);
assert!(
approx(lr5, 0.05),
"expected 0.05 at warmup midpoint, got {lr5}"
);
}
#[test]
fn warmup_cosine_descends_after_peak() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
warmup_epochs: 5,
initial_lr: 0.0,
peak_lr: 0.1,
min_lr: 0.0,
t_max: 100,
});
let lr_peak = sched.step(5);
let lr_later = sched.step(55); assert!(lr_later < lr_peak, "LR should decrease after warmup");
}
#[test]
fn cyclic_lr_starts_at_base() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
base_lr: 0.001,
max_lr: 0.01,
step_size: 10,
});
let lr = sched.step(0);
assert!(approx(lr, 0.001), "expected base_lr at epoch 0, got {lr}");
}
#[test]
fn cyclic_lr_reaches_max_at_step_size() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
base_lr: 0.0,
max_lr: 1.0,
step_size: 10,
});
let lr = sched.step(10);
assert!(approx(lr, 1.0), "expected max_lr at step_size, got {lr}");
}
#[test]
fn cyclic_lr_returns_to_base_at_full_cycle() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
base_lr: 0.001,
max_lr: 0.01,
step_size: 10,
});
let lr = sched.step(20);
assert!(
approx(lr, 0.001),
"expected base_lr after full cycle, got {lr}"
);
}
#[test]
fn cyclic_lr_is_symmetric() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
base_lr: 0.0,
max_lr: 1.0,
step_size: 10,
});
let lr5 = sched.step(5);
let lr15 = sched.step(15);
assert!(approx(lr5, lr15), "triangular cycle should be symmetric");
}
#[test]
fn reduce_on_plateau_decreases_after_patience() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 3,
min_lr: 1e-6,
threshold: 1e-4,
});
sched.step_with_loss(0, 1.0);
sched.step_with_loss(1, 1.0);
sched.step_with_loss(2, 1.0);
sched.step_with_loss(3, 1.0);
let lr = sched.current_lr();
assert!(lr < 0.1, "LR should decrease after plateau, got {lr}");
}
#[test]
fn reduce_on_plateau_does_not_reduce_when_improving() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 3,
min_lr: 1e-6,
threshold: 1e-4,
});
for i in 0u64..20 {
sched.step_with_loss(i, 1.0 / (i as f64 + 1.0));
}
assert!(
approx(sched.current_lr(), 0.1),
"LR should not change when loss keeps improving, got {}",
sched.current_lr()
);
}
#[test]
fn reduce_on_plateau_respects_min_lr() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.1,
patience: 1,
min_lr: 0.05,
threshold: 1e-4,
});
sched.step_with_loss(0, 1.0);
sched.step_with_loss(1, 1.0); let lr = sched.current_lr();
assert!(lr >= 0.05, "LR should not go below min_lr, got {lr}");
}
#[test]
fn reduce_on_plateau_stats_count_reductions() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 2,
min_lr: 1e-9,
threshold: 1e-4,
});
sched.step_with_loss(0, 1.0); sched.step_with_loss(1, 1.0); sched.step_with_loss(2, 1.0); sched.step_with_loss(3, 1.0); sched.step_with_loss(4, 1.0); assert_eq!(
sched.stats().plateau_reductions,
2,
"expected 2 plateau reductions"
);
}
#[test]
fn reset_clears_history_and_state() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
for i in 0..10 {
sched.step(i);
}
sched.reset();
assert_eq!(sched.history().len(), 0);
assert_eq!(sched.state.current_epoch, 0);
assert!(approx(sched.current_lr(), 0.01));
}
#[test]
fn reset_clears_plateau_reductions() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 1,
min_lr: 1e-9,
threshold: 1e-4,
});
sched.step_with_loss(0, 1.0);
sched.step_with_loss(1, 1.0); sched.reset();
assert_eq!(sched.stats().plateau_reductions, 0);
}
#[test]
fn history_records_each_step() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.02 });
for i in 0..5u64 {
sched.step(i);
}
assert_eq!(sched.history().len(), 5);
}
#[test]
fn history_capped_at_1000() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
for i in 0..1200u64 {
sched.step(i);
}
assert_eq!(sched.history().len(), 1000);
}
#[test]
fn history_loss_recorded_with_step_with_loss() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
sched.step_with_loss(0, 0.42);
let entry = &sched.history()[0];
assert_eq!(entry.loss, Some(0.42));
}
#[test]
fn history_no_loss_for_plain_step() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
sched.step(0);
assert_eq!(sched.history()[0].loss, None);
}
#[test]
fn stats_min_max_lr() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
initial_lr: 1.0,
decay_factor: 0.5,
step_size: 10,
});
sched.step(0); sched.step(10); sched.step(20); let s = sched.stats();
assert!(approx(s.max_lr_seen, 1.0));
assert!(approx(s.min_lr_seen, 0.25));
}
#[test]
fn stats_epochs_trained() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.1 });
for i in 0..7u64 {
sched.step(i);
}
assert_eq!(sched.stats().epochs_trained, 7);
}
#[test]
fn warmup_factor_zero_epochs_returns_one() {
assert!((LearningRateScheduler::warmup_factor(5, 0) - 1.0).abs() < 1e-15);
}
#[test]
fn warmup_factor_linear_interpolation() {
let f = LearningRateScheduler::warmup_factor(5, 10);
assert!((f - 0.5).abs() < 1e-15, "expected 0.5, got {f}");
}
#[test]
fn warmup_factor_clamps_at_one() {
let f = LearningRateScheduler::warmup_factor(20, 10);
assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
}
#[test]
fn cosine_factor_zero_t_max_returns_zero() {
let f = LearningRateScheduler::cosine_factor(5, 0);
assert!((f - 0.0).abs() < 1e-15, "expected 0.0, got {f}");
}
#[test]
fn cosine_factor_at_epoch_zero_returns_one() {
let f = LearningRateScheduler::cosine_factor(0, 100);
assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
}
#[test]
fn cosine_factor_at_t_max_returns_zero() {
let f = LearningRateScheduler::cosine_factor(100, 100);
assert!((f - 0.0).abs() < TOL, "expected 0.0, got {f}");
}
#[test]
fn cosine_factor_midpoint_returns_half() {
let f = LearningRateScheduler::cosine_factor(50, 100);
assert!((f - 0.5).abs() < TOL, "expected 0.5, got {f}");
}
#[test]
fn state_plateau_count_tracked() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 5,
min_lr: 1e-6,
threshold: 1e-4,
});
sched.step_with_loss(0, 1.0); sched.step_with_loss(1, 1.0); sched.step_with_loss(2, 1.0); assert_eq!(sched.state.plateau_count, 2);
}
#[test]
fn state_best_loss_updated_on_improvement() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
initial_lr: 0.1,
factor: 0.5,
patience: 5,
min_lr: 1e-6,
threshold: 1e-4,
});
sched.step_with_loss(0, 2.0);
sched.step_with_loss(1, 0.5);
assert!(approx(sched.state.best_loss, 0.5));
}
#[test]
fn lr_history_epoch_field_correct() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
sched.step(42);
assert_eq!(sched.history()[0].epoch, 42);
}
#[test]
fn lr_history_lr_field_correct() {
let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.07 });
sched.step(0);
assert!(approx(sched.history()[0].lr, 0.07));
}
}