1use std::f64::consts::PI;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
16pub enum ScheduleType {
17 Constant,
19 StepDecay,
21 ExponentialDecay,
23 CosineAnnealing,
25 WarmupLinear,
27 OneCycleLR,
29}
30
31impl std::fmt::Display for ScheduleType {
32 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33 match self {
34 Self::Constant => write!(f, "Constant"),
35 Self::StepDecay => write!(f, "StepDecay"),
36 Self::ExponentialDecay => write!(f, "ExponentialDecay"),
37 Self::CosineAnnealing => write!(f, "CosineAnnealing"),
38 Self::WarmupLinear => write!(f, "WarmupLinear"),
39 Self::OneCycleLR => write!(f, "OneCycleLR"),
40 }
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct LRSchedulerConfig {
47 pub schedule_type: ScheduleType,
49 pub initial_lr: f64,
51 pub min_lr: f64,
53 pub gamma: f64,
55 pub step_size: usize,
57 pub total_steps: usize,
59 pub warmup_steps: usize,
61 pub max_lr: f64,
63}
64
65impl Default for LRSchedulerConfig {
66 fn default() -> Self {
67 Self {
68 schedule_type: ScheduleType::Constant,
69 initial_lr: 0.01,
70 min_lr: 1e-6,
71 gamma: 0.1,
72 step_size: 30,
73 total_steps: 1000,
74 warmup_steps: 100,
75 max_lr: 0.1,
76 }
77 }
78}
79
80#[derive(Debug, Clone)]
82pub struct LRSchedulerStats {
83 pub schedule_type: ScheduleType,
85 pub current_step: usize,
87 pub current_lr: f64,
89 pub initial_lr: f64,
91}
92
93#[derive(Debug, Clone)]
95pub struct TensorLRScheduler {
96 config: LRSchedulerConfig,
97 current_step: usize,
98 current_lr: f64,
99}
100
101impl TensorLRScheduler {
102 pub fn new(config: LRSchedulerConfig) -> Self {
105 let lr = Self::compute_lr(&config, 0);
106 Self {
107 config,
108 current_step: 0,
109 current_lr: lr,
110 }
111 }
112
113 pub fn step(&mut self) -> f64 {
115 self.current_step += 1;
116 self.current_lr = Self::compute_lr(&self.config, self.current_step);
117 self.current_lr
118 }
119
120 pub fn get_lr(&self) -> f64 {
122 self.current_lr
123 }
124
125 pub fn set_step(&mut self, step: usize) {
127 self.current_step = step;
128 self.current_lr = Self::compute_lr(&self.config, step);
129 }
130
131 pub fn remaining_steps(&self) -> Option<usize> {
134 match self.config.schedule_type {
135 ScheduleType::CosineAnnealing | ScheduleType::OneCycleLR => {
136 Some(self.config.total_steps.saturating_sub(self.current_step))
137 }
138 ScheduleType::WarmupLinear => {
139 Some(self.config.warmup_steps.saturating_sub(self.current_step))
140 }
141 ScheduleType::Constant | ScheduleType::StepDecay | ScheduleType::ExponentialDecay => {
142 None
143 }
144 }
145 }
146
147 pub fn reset(&mut self) {
149 self.current_step = 0;
150 self.current_lr = Self::compute_lr(&self.config, 0);
151 }
152
153 pub fn stats(&self) -> LRSchedulerStats {
155 LRSchedulerStats {
156 schedule_type: self.config.schedule_type,
157 current_step: self.current_step,
158 current_lr: self.current_lr,
159 initial_lr: self.config.initial_lr,
160 }
161 }
162
163 pub fn config(&self) -> &LRSchedulerConfig {
165 &self.config
166 }
167
168 fn compute_lr(cfg: &LRSchedulerConfig, step: usize) -> f64 {
173 match cfg.schedule_type {
174 ScheduleType::Constant => cfg.initial_lr,
175
176 ScheduleType::StepDecay => {
177 let exponent = (step / cfg.step_size.max(1)) as f64;
178 cfg.initial_lr * cfg.gamma.powf(exponent)
179 }
180
181 ScheduleType::ExponentialDecay => cfg.initial_lr * cfg.gamma.powf(step as f64),
182
183 ScheduleType::CosineAnnealing => {
184 let total = cfg.total_steps.max(1) as f64;
185 let t = (step as f64).min(total);
186 cfg.min_lr + 0.5 * (cfg.initial_lr - cfg.min_lr) * (1.0 + (PI * t / total).cos())
187 }
188
189 ScheduleType::WarmupLinear => {
190 if cfg.warmup_steps == 0 {
191 return cfg.initial_lr;
192 }
193 if step < cfg.warmup_steps {
194 cfg.initial_lr * (step as f64) / (cfg.warmup_steps as f64)
195 } else {
196 cfg.initial_lr
197 }
198 }
199
200 ScheduleType::OneCycleLR => {
201 let warmup = cfg.warmup_steps.max(1);
202 let total = cfg.total_steps.max(warmup + 1);
203 if step < warmup {
204 let frac = step as f64 / warmup as f64;
206 cfg.min_lr + frac * (cfg.max_lr - cfg.min_lr)
207 } else {
208 let decay_steps = (total - warmup).max(1) as f64;
210 let t = ((step - warmup) as f64).min(decay_steps);
211 cfg.min_lr
212 + 0.5 * (cfg.max_lr - cfg.min_lr) * (1.0 + (PI * t / decay_steps).cos())
213 }
214 }
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn approx_eq(a: f64, b: f64, tol: f64) -> bool {
225 (a - b).abs() < tol
226 }
227
228 #[test]
230 fn constant_always_returns_initial_lr() {
231 let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
232 schedule_type: ScheduleType::Constant,
233 initial_lr: 0.05,
234 ..Default::default()
235 });
236 for _ in 0..50 {
237 let lr = sched.step();
238 assert!(approx_eq(lr, 0.05, 1e-12));
239 }
240 }
241
242 #[test]
243 fn constant_get_lr_matches_step() {
244 let mut sched = TensorLRScheduler::new(LRSchedulerConfig {
245 schedule_type: ScheduleType::Constant,
246 initial_lr: 0.02,
247 ..Default::default()
248 });
249 sched.step();
250 assert!(approx_eq(sched.get_lr(), 0.02, 1e-12));
251 }
252
253 #[test]
255 fn step_decay_decays_at_boundary() {
256 let cfg = LRSchedulerConfig {
257 schedule_type: ScheduleType::StepDecay,
258 initial_lr: 1.0,
259 gamma: 0.5,
260 step_size: 10,
261 ..Default::default()
262 };
263 let mut sched = TensorLRScheduler::new(cfg);
264 assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
266 for _ in 0..9 {
268 sched.step();
269 }
270 assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
271 sched.step();
273 assert!(approx_eq(sched.get_lr(), 0.5, 1e-12));
274 for _ in 0..10 {
276 sched.step();
277 }
278 assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
279 }
280
281 #[test]
282 fn step_decay_within_interval_is_flat() {
283 let cfg = LRSchedulerConfig {
284 schedule_type: ScheduleType::StepDecay,
285 initial_lr: 0.1,
286 gamma: 0.1,
287 step_size: 5,
288 ..Default::default()
289 };
290 let mut sched = TensorLRScheduler::new(cfg);
291 let lr0 = sched.get_lr();
292 for i in 1..5 {
293 sched.step();
294 assert!(
295 approx_eq(sched.get_lr(), lr0, 1e-12),
296 "LR changed at step {i} within interval"
297 );
298 }
299 }
300
301 #[test]
303 fn exponential_monotone_decrease() {
304 let cfg = LRSchedulerConfig {
305 schedule_type: ScheduleType::ExponentialDecay,
306 initial_lr: 1.0,
307 gamma: 0.95,
308 ..Default::default()
309 };
310 let mut sched = TensorLRScheduler::new(cfg);
311 let mut prev = sched.get_lr();
312 for _ in 0..100 {
313 let lr = sched.step();
314 assert!(lr < prev + 1e-15, "LR did not decrease");
315 prev = lr;
316 }
317 }
318
319 #[test]
320 fn exponential_decay_formula() {
321 let cfg = LRSchedulerConfig {
322 schedule_type: ScheduleType::ExponentialDecay,
323 initial_lr: 2.0,
324 gamma: 0.9,
325 ..Default::default()
326 };
327 let mut sched = TensorLRScheduler::new(cfg);
328 for step in 1..=5 {
329 let lr = sched.step();
330 let expected = 2.0 * 0.9_f64.powf(step as f64);
331 assert!(
332 approx_eq(lr, expected, 1e-10),
333 "step {step}: got {lr}, expected {expected}"
334 );
335 }
336 }
337
338 #[test]
340 fn cosine_reaches_min_at_total_steps() {
341 let cfg = LRSchedulerConfig {
342 schedule_type: ScheduleType::CosineAnnealing,
343 initial_lr: 0.1,
344 min_lr: 1e-5,
345 total_steps: 200,
346 ..Default::default()
347 };
348 let mut sched = TensorLRScheduler::new(cfg);
349 for _ in 0..200 {
350 sched.step();
351 }
352 assert!(
353 approx_eq(sched.get_lr(), 1e-5, 1e-10),
354 "LR at total_steps should be min_lr, got {}",
355 sched.get_lr()
356 );
357 }
358
359 #[test]
360 fn cosine_starts_at_initial_lr() {
361 let cfg = LRSchedulerConfig {
362 schedule_type: ScheduleType::CosineAnnealing,
363 initial_lr: 0.05,
364 min_lr: 0.001,
365 total_steps: 500,
366 ..Default::default()
367 };
368 let sched = TensorLRScheduler::new(cfg);
369 assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
370 }
371
372 #[test]
373 fn cosine_midpoint_value() {
374 let cfg = LRSchedulerConfig {
375 schedule_type: ScheduleType::CosineAnnealing,
376 initial_lr: 1.0,
377 min_lr: 0.0,
378 total_steps: 100,
379 ..Default::default()
380 };
381 let mut sched = TensorLRScheduler::new(cfg);
382 for _ in 0..50 {
384 sched.step();
385 }
386 assert!(
387 approx_eq(sched.get_lr(), 0.5, 1e-10),
388 "cosine midpoint: got {}",
389 sched.get_lr()
390 );
391 }
392
393 #[test]
395 fn warmup_linear_ramp() {
396 let cfg = LRSchedulerConfig {
397 schedule_type: ScheduleType::WarmupLinear,
398 initial_lr: 0.1,
399 warmup_steps: 10,
400 ..Default::default()
401 };
402 let mut sched = TensorLRScheduler::new(cfg);
403 assert!(approx_eq(sched.get_lr(), 0.0, 1e-12));
405 for _ in 0..5 {
407 sched.step();
408 }
409 assert!(approx_eq(sched.get_lr(), 0.05, 1e-12));
410 for _ in 0..5 {
412 sched.step();
413 }
414 assert!(approx_eq(sched.get_lr(), 0.1, 1e-12));
415 }
416
417 #[test]
418 fn warmup_holds_after_warmup() {
419 let cfg = LRSchedulerConfig {
420 schedule_type: ScheduleType::WarmupLinear,
421 initial_lr: 0.01,
422 warmup_steps: 5,
423 ..Default::default()
424 };
425 let mut sched = TensorLRScheduler::new(cfg);
426 for _ in 0..20 {
427 sched.step();
428 }
429 assert!(approx_eq(sched.get_lr(), 0.01, 1e-12));
430 }
431
432 #[test]
433 fn warmup_zero_steps_returns_initial() {
434 let cfg = LRSchedulerConfig {
435 schedule_type: ScheduleType::WarmupLinear,
436 initial_lr: 0.03,
437 warmup_steps: 0,
438 ..Default::default()
439 };
440 let sched = TensorLRScheduler::new(cfg);
441 assert!(approx_eq(sched.get_lr(), 0.03, 1e-12));
442 }
443
444 #[test]
446 fn one_cycle_starts_at_min_lr() {
447 let cfg = LRSchedulerConfig {
448 schedule_type: ScheduleType::OneCycleLR,
449 initial_lr: 0.01,
450 min_lr: 1e-4,
451 max_lr: 0.1,
452 warmup_steps: 50,
453 total_steps: 200,
454 ..Default::default()
455 };
456 let sched = TensorLRScheduler::new(cfg);
457 assert!(
458 approx_eq(sched.get_lr(), 1e-4, 1e-12),
459 "OneCycle should start at min_lr, got {}",
460 sched.get_lr()
461 );
462 }
463
464 #[test]
465 fn one_cycle_reaches_peak() {
466 let cfg = LRSchedulerConfig {
467 schedule_type: ScheduleType::OneCycleLR,
468 initial_lr: 0.01,
469 min_lr: 0.0,
470 max_lr: 0.2,
471 warmup_steps: 100,
472 total_steps: 500,
473 ..Default::default()
474 };
475 let mut sched = TensorLRScheduler::new(cfg);
476 for _ in 0..100 {
477 sched.step();
478 }
479 assert!(
482 approx_eq(sched.get_lr(), 0.2, 1e-10),
483 "OneCycle should reach max_lr at warmup end, got {}",
484 sched.get_lr()
485 );
486 }
487
488 #[test]
489 fn one_cycle_ends_at_min_lr() {
490 let cfg = LRSchedulerConfig {
491 schedule_type: ScheduleType::OneCycleLR,
492 initial_lr: 0.01,
493 min_lr: 1e-5,
494 max_lr: 0.1,
495 warmup_steps: 50,
496 total_steps: 300,
497 ..Default::default()
498 };
499 let mut sched = TensorLRScheduler::new(cfg);
500 for _ in 0..300 {
501 sched.step();
502 }
503 assert!(
504 approx_eq(sched.get_lr(), 1e-5, 1e-10),
505 "OneCycle should end at min_lr, got {}",
506 sched.get_lr()
507 );
508 }
509
510 #[test]
512 fn reset_restores_step_zero() {
513 let cfg = LRSchedulerConfig {
514 schedule_type: ScheduleType::ExponentialDecay,
515 initial_lr: 1.0,
516 gamma: 0.5,
517 ..Default::default()
518 };
519 let mut sched = TensorLRScheduler::new(cfg);
520 for _ in 0..10 {
521 sched.step();
522 }
523 sched.reset();
524 assert_eq!(sched.stats().current_step, 0);
525 assert!(approx_eq(sched.get_lr(), 1.0, 1e-12));
526 }
527
528 #[test]
529 fn set_step_jumps_correctly() {
530 let cfg = LRSchedulerConfig {
531 schedule_type: ScheduleType::StepDecay,
532 initial_lr: 1.0,
533 gamma: 0.5,
534 step_size: 10,
535 ..Default::default()
536 };
537 let mut sched = TensorLRScheduler::new(cfg);
538 sched.set_step(25);
539 assert_eq!(sched.stats().current_step, 25);
540 assert!(approx_eq(sched.get_lr(), 0.25, 1e-12));
542 }
543
544 #[test]
545 fn remaining_steps_cosine() {
546 let cfg = LRSchedulerConfig {
547 schedule_type: ScheduleType::CosineAnnealing,
548 total_steps: 100,
549 ..Default::default()
550 };
551 let mut sched = TensorLRScheduler::new(cfg);
552 assert_eq!(sched.remaining_steps(), Some(100));
553 for _ in 0..30 {
554 sched.step();
555 }
556 assert_eq!(sched.remaining_steps(), Some(70));
557 }
558
559 #[test]
560 fn remaining_steps_none_for_constant() {
561 let sched = TensorLRScheduler::new(LRSchedulerConfig {
562 schedule_type: ScheduleType::Constant,
563 ..Default::default()
564 });
565 assert_eq!(sched.remaining_steps(), None);
566 }
567
568 #[test]
569 fn remaining_steps_none_for_step_decay() {
570 let sched = TensorLRScheduler::new(LRSchedulerConfig {
571 schedule_type: ScheduleType::StepDecay,
572 ..Default::default()
573 });
574 assert_eq!(sched.remaining_steps(), None);
575 }
576
577 #[test]
578 fn remaining_steps_none_for_exponential() {
579 let sched = TensorLRScheduler::new(LRSchedulerConfig {
580 schedule_type: ScheduleType::ExponentialDecay,
581 ..Default::default()
582 });
583 assert_eq!(sched.remaining_steps(), None);
584 }
585
586 #[test]
587 fn remaining_steps_warmup() {
588 let cfg = LRSchedulerConfig {
589 schedule_type: ScheduleType::WarmupLinear,
590 warmup_steps: 50,
591 ..Default::default()
592 };
593 let mut sched = TensorLRScheduler::new(cfg);
594 assert_eq!(sched.remaining_steps(), Some(50));
595 for _ in 0..50 {
596 sched.step();
597 }
598 assert_eq!(sched.remaining_steps(), Some(0));
599 }
600
601 #[test]
602 fn remaining_steps_one_cycle() {
603 let cfg = LRSchedulerConfig {
604 schedule_type: ScheduleType::OneCycleLR,
605 warmup_steps: 20,
606 total_steps: 100,
607 ..Default::default()
608 };
609 let mut sched = TensorLRScheduler::new(cfg);
610 assert_eq!(sched.remaining_steps(), Some(100));
611 for _ in 0..40 {
612 sched.step();
613 }
614 assert_eq!(sched.remaining_steps(), Some(60));
615 }
616
617 #[test]
619 fn stats_snapshot() {
620 let cfg = LRSchedulerConfig {
621 schedule_type: ScheduleType::CosineAnnealing,
622 initial_lr: 0.1,
623 total_steps: 200,
624 ..Default::default()
625 };
626 let mut sched = TensorLRScheduler::new(cfg);
627 for _ in 0..10 {
628 sched.step();
629 }
630 let s = sched.stats();
631 assert_eq!(s.schedule_type, ScheduleType::CosineAnnealing);
632 assert_eq!(s.current_step, 10);
633 assert!(approx_eq(s.initial_lr, 0.1, 1e-12));
634 assert!(approx_eq(s.current_lr, sched.get_lr(), 1e-15));
635 }
636
637 #[test]
639 fn step_advances_lr() {
640 let cfg = LRSchedulerConfig {
641 schedule_type: ScheduleType::ExponentialDecay,
642 initial_lr: 1.0,
643 gamma: 0.9,
644 ..Default::default()
645 };
646 let mut sched = TensorLRScheduler::new(cfg);
647 let lr0 = sched.get_lr();
648 let lr1 = sched.step();
649 assert!(lr1 < lr0, "step should change LR for decay schedules");
650 }
651
652 #[test]
654 fn default_config_values() {
655 let cfg = LRSchedulerConfig::default();
656 assert_eq!(cfg.schedule_type, ScheduleType::Constant);
657 assert!(approx_eq(cfg.initial_lr, 0.01, 1e-15));
658 assert!(approx_eq(cfg.min_lr, 1e-6, 1e-15));
659 assert!(approx_eq(cfg.gamma, 0.1, 1e-15));
660 assert_eq!(cfg.step_size, 30);
661 assert_eq!(cfg.total_steps, 1000);
662 assert_eq!(cfg.warmup_steps, 100);
663 assert!(approx_eq(cfg.max_lr, 0.1, 1e-15));
664 }
665
666 #[test]
668 fn schedule_type_display() {
669 assert_eq!(format!("{}", ScheduleType::Constant), "Constant");
670 assert_eq!(format!("{}", ScheduleType::OneCycleLR), "OneCycleLR");
671 }
672
673 #[test]
675 fn step_decay_zero_step_size_no_panic() {
676 let cfg = LRSchedulerConfig {
677 schedule_type: ScheduleType::StepDecay,
678 initial_lr: 1.0,
679 gamma: 0.5,
680 step_size: 0,
681 ..Default::default()
682 };
683 let mut sched = TensorLRScheduler::new(cfg);
684 let _ = sched.step();
686 }
687
688 #[test]
689 fn cosine_beyond_total_steps_clamps() {
690 let cfg = LRSchedulerConfig {
691 schedule_type: ScheduleType::CosineAnnealing,
692 initial_lr: 0.1,
693 min_lr: 0.001,
694 total_steps: 50,
695 ..Default::default()
696 };
697 let mut sched = TensorLRScheduler::new(cfg);
698 for _ in 0..100 {
699 sched.step();
700 }
701 assert!(
703 approx_eq(sched.get_lr(), 0.001, 1e-10),
704 "cosine should clamp at min_lr beyond total_steps, got {}",
705 sched.get_lr()
706 );
707 }
708
709 #[test]
710 fn one_cycle_warmup_phase_is_monotone_increasing() {
711 let cfg = LRSchedulerConfig {
712 schedule_type: ScheduleType::OneCycleLR,
713 min_lr: 0.0,
714 max_lr: 1.0,
715 warmup_steps: 50,
716 total_steps: 200,
717 ..Default::default()
718 };
719 let mut sched = TensorLRScheduler::new(cfg);
720 let mut prev = sched.get_lr();
721 for _ in 0..50 {
722 let lr = sched.step();
723 assert!(
724 lr >= prev - 1e-15,
725 "warmup should be monotonically increasing"
726 );
727 prev = lr;
728 }
729 }
730
731 #[test]
732 fn config_accessor() {
733 let cfg = LRSchedulerConfig {
734 schedule_type: ScheduleType::StepDecay,
735 gamma: 0.3,
736 ..Default::default()
737 };
738 let sched = TensorLRScheduler::new(cfg);
739 assert!(approx_eq(sched.config().gamma, 0.3, 1e-15));
740 }
741}
742
743#[derive(Debug, Clone)]
751pub enum SchedulerStrategy {
752 Constant {
754 lr: f64,
756 },
757 StepDecay {
759 initial_lr: f64,
761 decay_factor: f64,
763 step_size: u64,
765 },
766 ExponentialDecay {
768 initial_lr: f64,
770 decay_rate: f64,
772 },
773 CosineAnnealing {
775 initial_lr: f64,
777 min_lr: f64,
779 t_max: u64,
781 },
782 WarmupCosine {
784 warmup_epochs: u64,
786 initial_lr: f64,
788 peak_lr: f64,
790 min_lr: f64,
792 t_max: u64,
794 },
795 CyclicLR {
797 base_lr: f64,
799 max_lr: f64,
801 step_size: u64,
803 },
804 ReduceOnPlateau {
806 initial_lr: f64,
808 factor: f64,
810 patience: u64,
812 min_lr: f64,
814 threshold: f64,
816 },
817}
818
819#[derive(Debug, Clone)]
821pub struct LrSchedulerState {
822 pub current_epoch: u64,
824 pub current_lr: f64,
826 pub best_loss: f64,
828 pub plateau_count: u64,
830 pub cycles_completed: u64,
832}
833
834impl LrSchedulerState {
835 fn new(initial_lr: f64) -> Self {
836 Self {
837 current_epoch: 0,
838 current_lr: initial_lr,
839 best_loss: f64::INFINITY,
840 plateau_count: 0,
841 cycles_completed: 0,
842 }
843 }
844}
845
846#[derive(Debug, Clone)]
848pub struct LrHistory {
849 pub epoch: u64,
851 pub lr: f64,
853 pub loss: Option<f64>,
855}
856
857#[derive(Debug, Clone)]
859pub struct LrStats {
860 pub min_lr_seen: f64,
862 pub max_lr_seen: f64,
864 pub plateau_reductions: u64,
866 pub epochs_trained: u64,
868}
869
870const MAX_HISTORY: usize = 1000;
871
872#[derive(Debug, Clone)]
877pub struct LearningRateScheduler {
878 pub strategy: SchedulerStrategy,
880 pub state: LrSchedulerState,
882 history: Vec<LrHistory>,
883 plateau_reductions: u64,
884}
885
886impl LearningRateScheduler {
887 pub fn new(strategy: SchedulerStrategy) -> Self {
890 let initial_lr = Self::extract_initial_lr(&strategy);
891 Self {
892 state: LrSchedulerState::new(initial_lr),
893 strategy,
894 history: Vec::new(),
895 plateau_reductions: 0,
896 }
897 }
898
899 pub fn step(&mut self, epoch: u64) -> f64 {
910 let lr = self.compute_lr(epoch);
911 self.state.current_epoch = epoch;
912 self.state.current_lr = lr;
913 self.push_history(epoch, lr, None);
914 lr
915 }
916
917 pub fn step_with_loss(&mut self, epoch: u64, loss: f64) -> f64 {
921 let lr = match &self.strategy {
922 SchedulerStrategy::ReduceOnPlateau {
923 factor,
924 patience,
925 min_lr,
926 threshold,
927 ..
928 } => {
929 let factor = *factor;
930 let patience = *patience;
931 let floor = *min_lr;
932 let threshold = *threshold;
933
934 let improved = loss < self.state.best_loss - threshold;
935 if improved {
936 self.state.best_loss = loss;
937 self.state.plateau_count = 0;
938 } else {
939 self.state.plateau_count += 1;
940 }
941
942 if self.state.plateau_count >= patience {
943 let reduced = (self.state.current_lr * factor).max(floor);
944 if reduced < self.state.current_lr {
945 self.state.current_lr = reduced;
946 self.plateau_reductions += 1;
947 }
948 self.state.plateau_count = 0;
949 }
950 self.state.current_lr
951 }
952 _ => self.compute_lr(epoch),
953 };
954
955 self.state.current_epoch = epoch;
956 self.state.current_lr = lr;
957 self.push_history(epoch, lr, Some(loss));
958 lr
959 }
960
961 pub fn current_lr(&self) -> f64 {
963 self.state.current_lr
964 }
965
966 pub fn reset(&mut self) {
968 let initial_lr = Self::extract_initial_lr(&self.strategy);
969 self.state = LrSchedulerState::new(initial_lr);
970 self.history.clear();
971 self.plateau_reductions = 0;
972 }
973
974 pub fn history(&self) -> &[LrHistory] {
976 &self.history
977 }
978
979 pub fn stats(&self) -> LrStats {
981 let (min_lr_seen, max_lr_seen) = if self.history.is_empty() {
982 (self.state.current_lr, self.state.current_lr)
983 } else {
984 let min = self
985 .history
986 .iter()
987 .map(|h| h.lr)
988 .fold(f64::INFINITY, f64::min);
989 let max = self
990 .history
991 .iter()
992 .map(|h| h.lr)
993 .fold(f64::NEG_INFINITY, f64::max);
994 (min, max)
995 };
996
997 LrStats {
998 min_lr_seen,
999 max_lr_seen,
1000 plateau_reductions: self.plateau_reductions,
1001 epochs_trained: self.history.len() as u64,
1002 }
1003 }
1004
1005 pub fn warmup_factor(epoch: u64, warmup_epochs: u64) -> f64 {
1015 if warmup_epochs == 0 {
1016 return 1.0;
1017 }
1018 (epoch as f64 / warmup_epochs as f64).min(1.0)
1019 }
1020
1021 pub fn cosine_factor(epoch: u64, t_max: u64) -> f64 {
1025 if t_max == 0 {
1026 return 0.0;
1027 }
1028 let t = (epoch as f64).min(t_max as f64);
1029 (1.0 + (PI * t / t_max as f64).cos()) / 2.0
1030 }
1031
1032 fn extract_initial_lr(strategy: &SchedulerStrategy) -> f64 {
1037 match strategy {
1038 SchedulerStrategy::Constant { lr } => *lr,
1039 SchedulerStrategy::StepDecay { initial_lr, .. } => *initial_lr,
1040 SchedulerStrategy::ExponentialDecay { initial_lr, .. } => *initial_lr,
1041 SchedulerStrategy::CosineAnnealing { initial_lr, .. } => *initial_lr,
1042 SchedulerStrategy::WarmupCosine { initial_lr, .. } => *initial_lr,
1043 SchedulerStrategy::CyclicLR { base_lr, .. } => *base_lr,
1044 SchedulerStrategy::ReduceOnPlateau { initial_lr, .. } => *initial_lr,
1045 }
1046 }
1047
1048 fn compute_lr(&mut self, epoch: u64) -> f64 {
1049 match &self.strategy {
1050 SchedulerStrategy::Constant { lr } => *lr,
1051
1052 SchedulerStrategy::StepDecay {
1053 initial_lr,
1054 decay_factor,
1055 step_size,
1056 } => {
1057 let exponent = if *step_size == 0 {
1058 epoch
1059 } else {
1060 epoch / step_size
1061 };
1062 initial_lr * decay_factor.powi(exponent as i32)
1063 }
1064
1065 SchedulerStrategy::ExponentialDecay {
1066 initial_lr,
1067 decay_rate,
1068 } => initial_lr * (-decay_rate * epoch as f64).exp(),
1069
1070 SchedulerStrategy::CosineAnnealing {
1071 initial_lr,
1072 min_lr,
1073 t_max,
1074 } => {
1075 let factor = Self::cosine_factor(epoch, *t_max);
1076 min_lr + (initial_lr - min_lr) * factor
1077 }
1078
1079 SchedulerStrategy::WarmupCosine {
1080 warmup_epochs,
1081 initial_lr,
1082 peak_lr,
1083 min_lr,
1084 t_max,
1085 } => {
1086 let warmup = *warmup_epochs;
1087 let floor = *min_lr;
1088 let peak = *peak_lr;
1089 let start = *initial_lr;
1090 let period = *t_max;
1091
1092 if epoch < warmup {
1093 let w = Self::warmup_factor(epoch, warmup);
1094 start + w * (peak - start)
1095 } else {
1096 let cosine_epoch = epoch - warmup;
1097 let factor = Self::cosine_factor(cosine_epoch, period);
1098 floor + (peak - floor) * factor
1099 }
1100 }
1101
1102 SchedulerStrategy::CyclicLR {
1103 base_lr,
1104 max_lr,
1105 step_size,
1106 } => {
1107 let base = *base_lr;
1108 let peak = *max_lr;
1109 let half = (*step_size).max(1);
1110 let cycle_len = 2 * half;
1111 let cycle_epoch = epoch % cycle_len;
1112 let frac = if cycle_epoch < half {
1113 cycle_epoch as f64 / half as f64
1114 } else {
1115 (cycle_len - cycle_epoch) as f64 / half as f64
1116 };
1117 base + frac * (peak - base)
1119 }
1120
1121 SchedulerStrategy::ReduceOnPlateau { .. } => {
1122 self.state.current_lr
1124 }
1125 }
1126 }
1127
1128 fn push_history(&mut self, epoch: u64, lr: f64, loss: Option<f64>) {
1129 if self.history.len() >= MAX_HISTORY {
1130 self.history.remove(0);
1131 }
1132 self.history.push(LrHistory { epoch, lr, loss });
1133 }
1134}
1135
1136#[cfg(test)]
1141mod lr_scheduler_tests {
1142 use crate::lr_scheduler::{LearningRateScheduler, SchedulerStrategy};
1143
1144 const TOL: f64 = 1e-10;
1145
1146 fn approx(a: f64, b: f64) -> bool {
1147 (a - b).abs() < TOL
1148 }
1149
1150 #[test]
1153 fn constant_lr_never_changes() {
1154 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.03 });
1155 for epoch in 0..50 {
1156 let lr = sched.step(epoch);
1157 assert!(approx(lr, 0.03), "epoch {epoch}: expected 0.03, got {lr}");
1158 }
1159 }
1160
1161 #[test]
1162 fn constant_current_lr_matches_step() {
1163 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1164 sched.step(0);
1165 assert!(approx(sched.current_lr(), 0.01));
1166 }
1167
1168 #[test]
1169 fn constant_initial_lr_from_new() {
1170 let sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.05 });
1171 assert!(approx(sched.current_lr(), 0.05));
1172 }
1173
1174 #[test]
1177 fn step_decay_flat_within_interval() {
1178 let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1179 initial_lr: 1.0,
1180 decay_factor: 0.5,
1181 step_size: 10,
1182 });
1183 for epoch in 0..10 {
1184 let lr = sched.step(epoch);
1185 assert!(approx(lr, 1.0), "epoch {epoch}: expected 1.0, got {lr}");
1186 }
1187 }
1188
1189 #[test]
1190 fn step_decay_applies_at_boundary() {
1191 let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1192 initial_lr: 1.0,
1193 decay_factor: 0.5,
1194 step_size: 10,
1195 });
1196 let lr10 = sched.step(10);
1197 assert!(approx(lr10, 0.5), "expected 0.5 at epoch 10, got {lr10}");
1198 let lr20 = sched.step(20);
1199 assert!(approx(lr20, 0.25), "expected 0.25 at epoch 20, got {lr20}");
1200 }
1201
1202 #[test]
1203 fn step_decay_zero_step_size_no_panic() {
1204 let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1205 initial_lr: 1.0,
1206 decay_factor: 0.5,
1207 step_size: 0,
1208 });
1209 let _ = sched.step(5); }
1211
1212 #[test]
1215 fn exponential_decay_formula() {
1216 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1217 initial_lr: 1.0,
1218 decay_rate: 0.1,
1219 });
1220 for epoch in 0u64..=5 {
1221 let lr = sched.step(epoch);
1222 let expected = (-0.1_f64 * epoch as f64).exp();
1223 assert!(
1224 approx(lr, expected),
1225 "epoch {epoch}: expected {expected}, got {lr}"
1226 );
1227 }
1228 }
1229
1230 #[test]
1231 fn exponential_decay_monotone_decreasing() {
1232 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1233 initial_lr: 2.0,
1234 decay_rate: 0.05,
1235 });
1236 let mut prev = sched.step(0);
1237 for epoch in 1..100 {
1238 let lr = sched.step(epoch);
1239 assert!(lr < prev + 1e-15, "epoch {epoch}: LR did not decrease");
1240 prev = lr;
1241 }
1242 }
1243
1244 #[test]
1245 fn exponential_decay_at_epoch_zero_equals_initial() {
1246 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ExponentialDecay {
1247 initial_lr: 0.5,
1248 decay_rate: 0.2,
1249 });
1250 assert!(approx(sched.step(0), 0.5));
1251 }
1252
1253 #[test]
1256 fn cosine_annealing_starts_at_initial_lr() {
1257 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1258 initial_lr: 0.1,
1259 min_lr: 0.001,
1260 t_max: 100,
1261 });
1262 let lr = sched.step(0);
1263 assert!(approx(lr, 0.1), "expected 0.1, got {lr}");
1264 }
1265
1266 #[test]
1267 fn cosine_annealing_reaches_min_at_t_max() {
1268 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1269 initial_lr: 0.1,
1270 min_lr: 1e-4,
1271 t_max: 100,
1272 });
1273 let lr = sched.step(100);
1274 assert!(approx(lr, 1e-4), "expected min_lr at t_max, got {lr}");
1275 }
1276
1277 #[test]
1278 fn cosine_annealing_midpoint() {
1279 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1280 initial_lr: 1.0,
1281 min_lr: 0.0,
1282 t_max: 100,
1283 });
1284 let lr = sched.step(50);
1285 assert!(approx(lr, 0.5), "cosine midpoint should be 0.5, got {lr}");
1286 }
1287
1288 #[test]
1289 fn cosine_annealing_clamps_beyond_t_max() {
1290 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CosineAnnealing {
1291 initial_lr: 0.1,
1292 min_lr: 0.001,
1293 t_max: 50,
1294 });
1295 let lr_at_50 = sched.step(50);
1296 let lr_at_200 = sched.step(200);
1297 assert!(approx(lr_at_50, lr_at_200), "beyond t_max should clamp");
1298 }
1299
1300 #[test]
1303 fn warmup_cosine_starts_at_initial_lr() {
1304 let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1305 warmup_epochs: 10,
1306 initial_lr: 0.0,
1307 peak_lr: 0.1,
1308 min_lr: 1e-5,
1309 t_max: 90,
1310 });
1311 let lr = sched.step(0);
1312 assert!(approx(lr, 0.0), "expected 0.0 at epoch 0, got {lr}");
1313 }
1314
1315 #[test]
1316 fn warmup_cosine_reaches_peak_after_warmup() {
1317 let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1318 warmup_epochs: 10,
1319 initial_lr: 0.0,
1320 peak_lr: 0.1,
1321 min_lr: 1e-5,
1322 t_max: 90,
1323 });
1324 let lr = sched.step(10);
1327 assert!(approx(lr, 0.1), "expected peak_lr at warmup end, got {lr}");
1328 }
1329
1330 #[test]
1331 fn warmup_cosine_linear_during_warmup() {
1332 let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1333 warmup_epochs: 10,
1334 initial_lr: 0.0,
1335 peak_lr: 0.1,
1336 min_lr: 1e-5,
1337 t_max: 90,
1338 });
1339 let lr5 = sched.step(5);
1340 assert!(
1342 approx(lr5, 0.05),
1343 "expected 0.05 at warmup midpoint, got {lr5}"
1344 );
1345 }
1346
1347 #[test]
1348 fn warmup_cosine_descends_after_peak() {
1349 let mut sched = LearningRateScheduler::new(SchedulerStrategy::WarmupCosine {
1350 warmup_epochs: 5,
1351 initial_lr: 0.0,
1352 peak_lr: 0.1,
1353 min_lr: 0.0,
1354 t_max: 100,
1355 });
1356 let lr_peak = sched.step(5);
1357 let lr_later = sched.step(55); assert!(lr_later < lr_peak, "LR should decrease after warmup");
1359 }
1360
1361 #[test]
1364 fn cyclic_lr_starts_at_base() {
1365 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1366 base_lr: 0.001,
1367 max_lr: 0.01,
1368 step_size: 10,
1369 });
1370 let lr = sched.step(0);
1371 assert!(approx(lr, 0.001), "expected base_lr at epoch 0, got {lr}");
1372 }
1373
1374 #[test]
1375 fn cyclic_lr_reaches_max_at_step_size() {
1376 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1377 base_lr: 0.0,
1378 max_lr: 1.0,
1379 step_size: 10,
1380 });
1381 let lr = sched.step(10);
1382 assert!(approx(lr, 1.0), "expected max_lr at step_size, got {lr}");
1383 }
1384
1385 #[test]
1386 fn cyclic_lr_returns_to_base_at_full_cycle() {
1387 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1388 base_lr: 0.001,
1389 max_lr: 0.01,
1390 step_size: 10,
1391 });
1392 let lr = sched.step(20);
1393 assert!(
1394 approx(lr, 0.001),
1395 "expected base_lr after full cycle, got {lr}"
1396 );
1397 }
1398
1399 #[test]
1400 fn cyclic_lr_is_symmetric() {
1401 let mut sched = LearningRateScheduler::new(SchedulerStrategy::CyclicLR {
1402 base_lr: 0.0,
1403 max_lr: 1.0,
1404 step_size: 10,
1405 });
1406 let lr5 = sched.step(5);
1407 let lr15 = sched.step(15);
1408 assert!(approx(lr5, lr15), "triangular cycle should be symmetric");
1409 }
1410
1411 #[test]
1414 fn reduce_on_plateau_decreases_after_patience() {
1415 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1416 initial_lr: 0.1,
1417 factor: 0.5,
1418 patience: 3,
1419 min_lr: 1e-6,
1420 threshold: 1e-4,
1421 });
1422 sched.step_with_loss(0, 1.0);
1424 sched.step_with_loss(1, 1.0);
1426 sched.step_with_loss(2, 1.0);
1427 sched.step_with_loss(3, 1.0);
1428 let lr = sched.current_lr();
1429 assert!(lr < 0.1, "LR should decrease after plateau, got {lr}");
1430 }
1431
1432 #[test]
1433 fn reduce_on_plateau_does_not_reduce_when_improving() {
1434 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1435 initial_lr: 0.1,
1436 factor: 0.5,
1437 patience: 3,
1438 min_lr: 1e-6,
1439 threshold: 1e-4,
1440 });
1441 for i in 0u64..20 {
1443 sched.step_with_loss(i, 1.0 / (i as f64 + 1.0));
1444 }
1445 assert!(
1446 approx(sched.current_lr(), 0.1),
1447 "LR should not change when loss keeps improving, got {}",
1448 sched.current_lr()
1449 );
1450 }
1451
1452 #[test]
1453 fn reduce_on_plateau_respects_min_lr() {
1454 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1455 initial_lr: 0.1,
1456 factor: 0.1,
1457 patience: 1,
1458 min_lr: 0.05,
1459 threshold: 1e-4,
1460 });
1461 sched.step_with_loss(0, 1.0);
1462 sched.step_with_loss(1, 1.0); let lr = sched.current_lr();
1464 assert!(lr >= 0.05, "LR should not go below min_lr, got {lr}");
1465 }
1466
1467 #[test]
1468 fn reduce_on_plateau_stats_count_reductions() {
1469 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1470 initial_lr: 0.1,
1471 factor: 0.5,
1472 patience: 2,
1473 min_lr: 1e-9,
1474 threshold: 1e-4,
1475 });
1476 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!(
1482 sched.stats().plateau_reductions,
1483 2,
1484 "expected 2 plateau reductions"
1485 );
1486 }
1487
1488 #[test]
1491 fn reset_clears_history_and_state() {
1492 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1493 for i in 0..10 {
1494 sched.step(i);
1495 }
1496 sched.reset();
1497 assert_eq!(sched.history().len(), 0);
1498 assert_eq!(sched.state.current_epoch, 0);
1499 assert!(approx(sched.current_lr(), 0.01));
1500 }
1501
1502 #[test]
1503 fn reset_clears_plateau_reductions() {
1504 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1505 initial_lr: 0.1,
1506 factor: 0.5,
1507 patience: 1,
1508 min_lr: 1e-9,
1509 threshold: 1e-4,
1510 });
1511 sched.step_with_loss(0, 1.0);
1512 sched.step_with_loss(1, 1.0); sched.reset();
1514 assert_eq!(sched.stats().plateau_reductions, 0);
1515 }
1516
1517 #[test]
1520 fn history_records_each_step() {
1521 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.02 });
1522 for i in 0..5u64 {
1523 sched.step(i);
1524 }
1525 assert_eq!(sched.history().len(), 5);
1526 }
1527
1528 #[test]
1529 fn history_capped_at_1000() {
1530 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1531 for i in 0..1200u64 {
1532 sched.step(i);
1533 }
1534 assert_eq!(sched.history().len(), 1000);
1535 }
1536
1537 #[test]
1538 fn history_loss_recorded_with_step_with_loss() {
1539 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1540 sched.step_with_loss(0, 0.42);
1541 let entry = &sched.history()[0];
1542 assert_eq!(entry.loss, Some(0.42));
1543 }
1544
1545 #[test]
1546 fn history_no_loss_for_plain_step() {
1547 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1548 sched.step(0);
1549 assert_eq!(sched.history()[0].loss, None);
1550 }
1551
1552 #[test]
1555 fn stats_min_max_lr() {
1556 let mut sched = LearningRateScheduler::new(SchedulerStrategy::StepDecay {
1557 initial_lr: 1.0,
1558 decay_factor: 0.5,
1559 step_size: 10,
1560 });
1561 sched.step(0); sched.step(10); sched.step(20); let s = sched.stats();
1565 assert!(approx(s.max_lr_seen, 1.0));
1566 assert!(approx(s.min_lr_seen, 0.25));
1567 }
1568
1569 #[test]
1570 fn stats_epochs_trained() {
1571 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.1 });
1572 for i in 0..7u64 {
1573 sched.step(i);
1574 }
1575 assert_eq!(sched.stats().epochs_trained, 7);
1576 }
1577
1578 #[test]
1581 fn warmup_factor_zero_epochs_returns_one() {
1582 assert!((LearningRateScheduler::warmup_factor(5, 0) - 1.0).abs() < 1e-15);
1583 }
1584
1585 #[test]
1586 fn warmup_factor_linear_interpolation() {
1587 let f = LearningRateScheduler::warmup_factor(5, 10);
1588 assert!((f - 0.5).abs() < 1e-15, "expected 0.5, got {f}");
1589 }
1590
1591 #[test]
1592 fn warmup_factor_clamps_at_one() {
1593 let f = LearningRateScheduler::warmup_factor(20, 10);
1594 assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
1595 }
1596
1597 #[test]
1598 fn cosine_factor_zero_t_max_returns_zero() {
1599 let f = LearningRateScheduler::cosine_factor(5, 0);
1600 assert!((f - 0.0).abs() < 1e-15, "expected 0.0, got {f}");
1601 }
1602
1603 #[test]
1604 fn cosine_factor_at_epoch_zero_returns_one() {
1605 let f = LearningRateScheduler::cosine_factor(0, 100);
1606 assert!((f - 1.0).abs() < 1e-15, "expected 1.0, got {f}");
1607 }
1608
1609 #[test]
1610 fn cosine_factor_at_t_max_returns_zero() {
1611 let f = LearningRateScheduler::cosine_factor(100, 100);
1612 assert!((f - 0.0).abs() < TOL, "expected 0.0, got {f}");
1613 }
1614
1615 #[test]
1616 fn cosine_factor_midpoint_returns_half() {
1617 let f = LearningRateScheduler::cosine_factor(50, 100);
1618 assert!((f - 0.5).abs() < TOL, "expected 0.5, got {f}");
1619 }
1620
1621 #[test]
1624 fn state_plateau_count_tracked() {
1625 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1626 initial_lr: 0.1,
1627 factor: 0.5,
1628 patience: 5,
1629 min_lr: 1e-6,
1630 threshold: 1e-4,
1631 });
1632 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);
1636 }
1637
1638 #[test]
1639 fn state_best_loss_updated_on_improvement() {
1640 let mut sched = LearningRateScheduler::new(SchedulerStrategy::ReduceOnPlateau {
1641 initial_lr: 0.1,
1642 factor: 0.5,
1643 patience: 5,
1644 min_lr: 1e-6,
1645 threshold: 1e-4,
1646 });
1647 sched.step_with_loss(0, 2.0);
1648 sched.step_with_loss(1, 0.5);
1649 assert!(approx(sched.state.best_loss, 0.5));
1650 }
1651
1652 #[test]
1655 fn lr_history_epoch_field_correct() {
1656 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.01 });
1657 sched.step(42);
1658 assert_eq!(sched.history()[0].epoch, 42);
1659 }
1660
1661 #[test]
1662 fn lr_history_lr_field_correct() {
1663 let mut sched = LearningRateScheduler::new(SchedulerStrategy::Constant { lr: 0.07 });
1664 sched.step(0);
1665 assert!(approx(sched.history()[0].lr, 0.07));
1666 }
1667}