Skip to main content

optirs_core/schedulers/
noise_injection.rs

1// Noise injection scheduler
2//
3// This module provides a learning rate scheduler that adds noise to the learning rate
4// to help escape local minima and improve exploration during training.
5
6use scirs2_core::ndarray::ScalarOperand;
7use scirs2_core::numeric::{Float, NumCast};
8use scirs2_core::random::{rngs::StdRng, seeded_rng, thread_rng, CoreRandom};
9use std::fmt::Debug;
10
11use super::LearningRateScheduler;
12
13/// Deterministic RNG used by the noise injection scheduler.
14type NoiseRng = CoreRandom<StdRng>;
15
16/// Convert an `f64` sample into the scheduler's float type.
17fn from_f64<A: Float>(v: f64) -> A {
18    <A as NumCast>::from(v).unwrap_or_else(A::zero)
19}
20
21/// Convert a `usize` counter into the scheduler's float type.
22fn from_usize<A: Float>(v: usize) -> A {
23    <A as NumCast>::from(v).unwrap_or_else(A::zero)
24}
25
26/// Convert a `usize` denominator into the scheduler's float type.
27///
28/// Falls back to `1` so the value can never introduce a division by zero.
29fn denom_from_usize<A: Float>(v: usize) -> A {
30    match <A as NumCast>::from(v) {
31        Some(x) if x != A::zero() => x,
32        _ => A::one(),
33    }
34}
35
36/// Draw a standard normal sample using the Box-Muller transform.
37///
38/// The first uniform is drawn from the half-open range `(0, 1]` (rather than `[0, 1)`)
39/// so `ln(u1)` is always finite - `ln(0)` would otherwise yield `-inf` and poison the
40/// sample with `NaN`.
41fn standard_normal(rng: &mut NoiseRng) -> f64 {
42    let u1: f64 = 1.0 - rng.gen_range(0.0f64..1.0f64);
43    let u2: f64 = rng.gen_range(0.0f64..1.0f64);
44    (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
45}
46
47/// Noise distribution types for learning rate
48#[derive(Debug, Clone, Copy)]
49pub enum NoiseDistribution<A: Float> {
50    /// Uniform noise in the range [min, max]
51    Uniform {
52        /// Minimum noise value
53        min: A,
54        /// Maximum noise value
55        max: A,
56    },
57    /// Gaussian noise with specified mean and standard deviation
58    Gaussian {
59        /// Mean value of noise distribution
60        mean: A,
61        /// Standard deviation of noise distribution
62        std_dev: A,
63    },
64    /// Cyclical noise that oscillates according to a sine wave with specified amplitude
65    Cyclical {
66        /// Maximum amplitude of oscillation
67        amplitude: A,
68        /// Number of steps to complete one full cycle
69        period: usize,
70    },
71    /// Decaying noise that decreases over time to a minimum value
72    Decaying {
73        /// Initial scale factor for noise at step 0
74        initial_scale: A,
75        /// Final scale factor for noise after decay_steps
76        final_scale: A,
77        /// Number of steps over which to decay from initial to final scale
78        decay_steps: usize,
79    },
80}
81
82/// A learning rate scheduler that injects noise into the base learning rate
83///
84/// The noise sample is drawn **once per [`LearningRateScheduler::step`]** and cached, so
85/// [`LearningRateScheduler::get_learning_rate`] is idempotent between steps: reading the
86/// learning rate twice without stepping returns the same value.
87///
88/// The scheduler owns a seeded, reproducible RNG. Use [`NoiseInjectionScheduler::with_seed`]
89/// (or [`NoiseInjectionScheduler::new_seeded`]) to pin the seed; two schedulers built with
90/// the same seed produce byte-identical learning rate sequences.
91pub struct NoiseInjectionScheduler<A, S>
92where
93    A: Float + Debug + ScalarOperand,
94    S: LearningRateScheduler<A>,
95{
96    /// The base scheduler to add noise to
97    base_scheduler: S,
98    /// The noise distribution
99    noise_dist: NoiseDistribution<A>,
100    /// Current step number
101    step_count: usize,
102    /// Seed the RNG was built from (used by `reset`)
103    seed: u64,
104    /// Deterministic random number generator
105    rng: NoiseRng,
106    /// Noise sample for the current step (kept so reads are idempotent)
107    current_noise: A,
108    /// Minimum learning rate to ensure training stability
109    min_lr: A,
110}
111
112impl<A, S> NoiseInjectionScheduler<A, S>
113where
114    A: Float + Debug + ScalarOperand,
115    S: LearningRateScheduler<A>,
116{
117    /// Create a new noise injection scheduler with a randomly chosen seed
118    ///
119    /// # Arguments
120    ///
121    /// * `base_scheduler` - The base scheduler to add noise to
122    /// * `noise_dist` - The noise distribution to use
123    /// * `min_lr` - The minimum learning rate allowed (to ensure stability)
124    ///
125    /// # Example
126    ///
127    /// ```
128    /// use optirs_core::schedulers::{
129    ///     ExponentialDecay, NoiseDistribution, NoiseInjectionScheduler, LearningRateScheduler
130    /// };
131    ///
132    /// // Create a base scheduler
133    /// let base_scheduler = ExponentialDecay::new(0.1, 0.9, 10);
134    ///
135    /// // Create a noise injection scheduler with uniform noise
136    /// let mut scheduler = NoiseInjectionScheduler::new(
137    ///     base_scheduler,
138    ///     NoiseDistribution::Uniform { min: -0.01, max: 0.01 },
139    ///     0.001, // Minimum learning rate
140    /// );
141    ///
142    /// // Get the learning rate (will be 0.1 plus some noise)
143    /// let lr = scheduler.get_learning_rate();
144    /// assert!(lr >= 0.001); // Learning rate should be at least min_lr
145    ///
146    /// // Reading again without stepping yields exactly the same value
147    /// assert_eq!(lr, scheduler.get_learning_rate());
148    /// assert!(scheduler.step() >= 0.001);
149    /// ```
150    pub fn new(base_scheduler: S, noise_dist: NoiseDistribution<A>, min_lr: A) -> Self {
151        let seed: u64 = thread_rng().gen_range(0u64..u64::MAX);
152        Self::new_seeded(base_scheduler, noise_dist, min_lr, seed)
153    }
154
155    /// Create a new noise injection scheduler with an explicit seed
156    ///
157    /// Two schedulers created with the same seed, the same distribution and the same base
158    /// scheduler produce identical learning rate sequences.
159    ///
160    /// # Example
161    ///
162    /// ```
163    /// use optirs_core::schedulers::{
164    ///     ConstantScheduler, NoiseDistribution, NoiseInjectionScheduler, LearningRateScheduler
165    /// };
166    ///
167    /// let dist = NoiseDistribution::Uniform { min: -0.01f64, max: 0.01 };
168    /// let mut a = NoiseInjectionScheduler::new_seeded(ConstantScheduler::new(0.1), dist, 0.001, 7);
169    /// let mut b = NoiseInjectionScheduler::new_seeded(ConstantScheduler::new(0.1), dist, 0.001, 7);
170    /// for _ in 0..16 {
171    ///     assert_eq!(a.step(), b.step());
172    /// }
173    /// ```
174    pub fn new_seeded(
175        base_scheduler: S,
176        noise_dist: NoiseDistribution<A>,
177        min_lr: A,
178        seed: u64,
179    ) -> Self {
180        let mut scheduler = Self {
181            base_scheduler,
182            noise_dist,
183            step_count: 0,
184            seed,
185            rng: seeded_rng(seed),
186            current_noise: A::zero(),
187            min_lr,
188        };
189        scheduler.current_noise = scheduler.sample_noise();
190        scheduler
191    }
192
193    /// Re-seed the scheduler's RNG
194    ///
195    /// The cached noise sample is re-derived from the new seed so the very first
196    /// [`LearningRateScheduler::get_learning_rate`] is already deterministic.
197    pub fn with_seed(mut self, seed: u64) -> Self {
198        self.seed = seed;
199        self.rng = seeded_rng(seed);
200        self.current_noise = self.sample_noise();
201        self
202    }
203
204    /// Seed the scheduler's RNG was built from
205    pub fn seed(&self) -> u64 {
206        self.seed
207    }
208
209    /// Noise sample currently applied to the base learning rate
210    pub fn current_noise(&self) -> A {
211        self.current_noise
212    }
213
214    /// Sample noise from the configured distribution for the current step
215    fn sample_noise(&mut self) -> A {
216        match self.noise_dist {
217            NoiseDistribution::Uniform { min, max } => {
218                let min_f64 = min.to_f64().unwrap_or(0.0);
219                let max_f64 = max.to_f64().unwrap_or(0.0);
220                if !min_f64.is_finite() || !max_f64.is_finite() {
221                    return A::zero();
222                }
223                if min_f64 >= max_f64 {
224                    // Degenerate range: `gen_range` would panic on an empty range.
225                    return from_f64::<A>(min_f64);
226                }
227                from_f64::<A>(self.rng.gen_range(min_f64..max_f64))
228            }
229            NoiseDistribution::Gaussian { mean, std_dev } => {
230                let mean_f64 = mean.to_f64().unwrap_or(0.0);
231                let std_dev_f64 = std_dev.to_f64().unwrap_or(0.0);
232                let z0 = standard_normal(&mut self.rng);
233                from_f64::<A>(mean_f64 + std_dev_f64 * z0)
234            }
235            NoiseDistribution::Cyclical { amplitude, period } => {
236                let period_f = denom_from_usize::<A>(period.max(1));
237                let step = from_usize::<A>(self.step_count);
238                let angle =
239                    from_f64::<A>(2.0) * from_f64::<A>(std::f64::consts::PI) * (step / period_f);
240                amplitude * angle.sin()
241            }
242            NoiseDistribution::Decaying {
243                initial_scale,
244                final_scale,
245                decay_steps,
246            } => {
247                let decay_steps = decay_steps.max(1);
248                let decay_steps_a = denom_from_usize::<A>(decay_steps);
249                let step = from_usize::<A>(self.step_count.min(decay_steps));
250                let scale = initial_scale - (step / decay_steps_a) * (initial_scale - final_scale);
251
252                // Sample from a symmetric uniform distribution and scale by the decaying factor
253                scale * from_f64::<A>(self.rng.gen_range(-1.0f64..1.0f64))
254            }
255        }
256    }
257}
258
259impl<A, S> LearningRateScheduler<A> for NoiseInjectionScheduler<A, S>
260where
261    A: Float + Debug + ScalarOperand,
262    S: LearningRateScheduler<A>,
263{
264    fn get_learning_rate(&self) -> A {
265        // The noise sample is fixed for the current step, so repeated reads agree.
266        let base_lr = self.base_scheduler.get_learning_rate();
267        (base_lr + self.current_noise).max(self.min_lr)
268    }
269
270    fn step(&mut self) -> A {
271        // Step the base scheduler
272        self.base_scheduler.step();
273
274        // Advance and draw exactly one noise sample for the new step
275        self.step_count = self.step_count.saturating_add(1);
276        self.current_noise = self.sample_noise();
277
278        self.get_learning_rate()
279    }
280
281    fn reset(&mut self) {
282        self.base_scheduler.reset();
283        self.step_count = 0;
284        self.rng = seeded_rng(self.seed);
285        self.current_noise = self.sample_noise();
286    }
287}
288
289// Only implement Clone for NoiseInjectionScheduler when S is Clone
290impl<A, S> Clone for NoiseInjectionScheduler<A, S>
291where
292    A: Float + Debug + ScalarOperand,
293    S: LearningRateScheduler<A> + Clone,
294{
295    fn clone(&self) -> Self {
296        Self {
297            base_scheduler: self.base_scheduler.clone(),
298            noise_dist: self.noise_dist,
299            step_count: self.step_count,
300            seed: self.seed,
301            // The underlying RNG is not `Clone`, so the clone restarts the noise stream
302            // from the configured seed. The clone is still fully deterministic - it just
303            // does not share the original's stream position.
304            rng: seeded_rng(self.seed),
305            current_noise: self.current_noise,
306            min_lr: self.min_lr,
307        }
308    }
309}
310
311impl<A, S> Debug for NoiseInjectionScheduler<A, S>
312where
313    A: Float + Debug + ScalarOperand,
314    S: LearningRateScheduler<A> + Debug,
315{
316    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
317        f.debug_struct("NoiseInjectionScheduler")
318            .field("base_scheduler", &self.base_scheduler)
319            .field("noise_dist", &self.noise_dist)
320            .field("step_count", &self.step_count)
321            .field("seed", &self.seed)
322            .field("current_noise", &self.current_noise)
323            .field("min_lr", &self.min_lr)
324            .finish()
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::schedulers::ConstantScheduler;
332
333    #[test]
334    fn test_uniform_noise() {
335        // Create a constant base scheduler
336        let base_scheduler = ConstantScheduler::new(0.1);
337
338        // Create a noise injection scheduler with uniform noise
339        let mut scheduler = NoiseInjectionScheduler::new(
340            base_scheduler,
341            NoiseDistribution::Uniform {
342                min: -0.02,
343                max: 0.02,
344            },
345            0.001,
346        );
347
348        // Get multiple learning rates and check they are within expected range
349        let mut rates = Vec::with_capacity(100);
350        for _ in 0..100 {
351            rates.push(scheduler.step());
352        }
353
354        // Check that learning rates are within expected range
355        for &rate in &rates {
356            assert!((0.08..=0.12).contains(&rate));
357        }
358
359        // Check that there is some variation in the learning rates
360        let mean = rates.iter().sum::<f64>() / rates.len() as f64;
361        let variance = rates.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / rates.len() as f64;
362
363        // Variance should be non-zero if noise is being added
364        assert!(variance > 0.0);
365    }
366
367    #[test]
368    fn test_gaussian_noise() {
369        let base_scheduler = ConstantScheduler::new(0.1);
370        let mut scheduler = NoiseInjectionScheduler::new(
371            base_scheduler,
372            NoiseDistribution::Gaussian {
373                mean: 0.0,
374                std_dev: 0.01,
375            },
376            0.001,
377        );
378
379        // Collect samples
380        let mut rates = Vec::with_capacity(1000);
381        for _ in 0..1000 {
382            rates.push(scheduler.step());
383        }
384
385        // Every sample must be finite (Box-Muller must never see ln(0)).
386        assert!(rates.iter().all(|r| r.is_finite()));
387
388        // Statistical checks (basic, just to ensure it's working)
389        let mean = rates.iter().sum::<f64>() / rates.len() as f64;
390
391        // Mean should be close to 0.1 (base learning rate)
392        assert!((mean - 0.1).abs() < 0.01);
393    }
394
395    #[test]
396    fn test_cyclical_noise() {
397        let base_scheduler = ConstantScheduler::new(0.1);
398        let mut scheduler = NoiseInjectionScheduler::new(
399            base_scheduler,
400            NoiseDistribution::Cyclical {
401                amplitude: 0.05,
402                period: 10,
403            },
404            0.001,
405        );
406
407        // Step for 20 steps (2 complete cycles)
408        let mut rates = Vec::with_capacity(20);
409        for _ in 0..20 {
410            rates.push(scheduler.step());
411        }
412
413        // Check that the pattern repeats
414        for i in 0..10 {
415            // The rate at i should be similar to the rate at i+10 (one period later)
416            // Due to how sinusoidal functions work
417            assert!((rates[i] - rates[i + 10]).abs() < 1e-10);
418        }
419    }
420
421    #[test]
422    fn test_decaying_noise() {
423        let base_scheduler = ConstantScheduler::new(0.1);
424        let mut scheduler = NoiseInjectionScheduler::new(
425            base_scheduler,
426            NoiseDistribution::Decaying {
427                initial_scale: 0.05,
428                final_scale: 0.001,
429                decay_steps: 100,
430            },
431            0.001,
432        );
433
434        // Check that noise magnitude decreases over time
435
436        // Calculate variance for early steps
437        let mut early_rates = Vec::with_capacity(50);
438        for _ in 0..50 {
439            early_rates.push(scheduler.step());
440        }
441        let early_mean = early_rates.iter().sum::<f64>() / early_rates.len() as f64;
442        let early_variance = early_rates
443            .iter()
444            .map(|&r| (r - early_mean).powi(2))
445            .sum::<f64>()
446            / early_rates.len() as f64;
447
448        // Calculate variance for later steps
449        let mut late_rates = Vec::with_capacity(50);
450        for _ in 0..50 {
451            late_rates.push(scheduler.step());
452        }
453        let late_mean = late_rates.iter().sum::<f64>() / late_rates.len() as f64;
454        let late_variance = late_rates
455            .iter()
456            .map(|&r| (r - late_mean).powi(2))
457            .sum::<f64>()
458            / late_rates.len() as f64;
459
460        // The variance should decrease over time
461        assert!(early_variance > late_variance);
462    }
463
464    #[test]
465    fn test_min_lr() {
466        let base_scheduler = ConstantScheduler::new(0.01);
467        let mut scheduler = NoiseInjectionScheduler::new(
468            base_scheduler,
469            NoiseDistribution::Uniform {
470                min: -0.1, // This would make the learning rate negative
471                max: 0.0,
472            },
473            0.005, // Minimum learning rate
474        );
475
476        // All learning rates should be at least min_lr
477        for _ in 0..100 {
478            assert!(scheduler.step() >= 0.005);
479        }
480    }
481
482    #[test]
483    fn test_get_learning_rate_is_idempotent() {
484        let mut scheduler = NoiseInjectionScheduler::new(
485            ConstantScheduler::new(0.1),
486            NoiseDistribution::Uniform {
487                min: -0.02,
488                max: 0.02,
489            },
490            0.001,
491        );
492
493        assert_eq!(scheduler.get_learning_rate(), scheduler.get_learning_rate());
494        for _ in 0..32 {
495            let stepped = scheduler.step();
496            assert_eq!(stepped, scheduler.get_learning_rate());
497            assert_eq!(stepped, scheduler.get_learning_rate());
498        }
499    }
500
501    #[test]
502    fn test_same_seed_same_sequence() {
503        let dist = NoiseDistribution::Gaussian {
504            mean: 0.0f64,
505            std_dev: 0.01,
506        };
507        let mut a =
508            NoiseInjectionScheduler::new_seeded(ConstantScheduler::new(0.1), dist, 0.001, 12345);
509        let mut b =
510            NoiseInjectionScheduler::new(ConstantScheduler::new(0.1), dist, 0.001).with_seed(12345);
511
512        assert_eq!(a.get_learning_rate(), b.get_learning_rate());
513        let seq_a: Vec<f64> = (0..64).map(|_| a.step()).collect();
514        let seq_b: Vec<f64> = (0..64).map(|_| b.step()).collect();
515        assert_eq!(seq_a, seq_b);
516
517        // A different seed produces a different stream.
518        let mut c =
519            NoiseInjectionScheduler::new_seeded(ConstantScheduler::new(0.1), dist, 0.001, 999);
520        let seq_c: Vec<f64> = (0..64).map(|_| c.step()).collect();
521        assert_ne!(seq_a, seq_c);
522    }
523
524    #[test]
525    fn test_reset_restores_deterministic_stream() {
526        let dist = NoiseDistribution::Uniform {
527            min: -0.02f64,
528            max: 0.02,
529        };
530        let mut scheduler =
531            NoiseInjectionScheduler::new_seeded(ConstantScheduler::new(0.1), dist, 0.001, 77);
532
533        let first: Vec<f64> = (0..16).map(|_| scheduler.step()).collect();
534        scheduler.reset();
535        let second: Vec<f64> = (0..16).map(|_| scheduler.step()).collect();
536        assert_eq!(first, second);
537    }
538
539    #[test]
540    fn test_degenerate_distributions_are_finite() {
541        // Zero-width uniform range must not panic.
542        let mut uniform = NoiseInjectionScheduler::new_seeded(
543            ConstantScheduler::new(0.1f64),
544            NoiseDistribution::Uniform { min: 0.0, max: 0.0 },
545            0.001,
546            1,
547        );
548        for _ in 0..10 {
549            assert!(uniform.step().is_finite());
550        }
551
552        // Zero period / zero decay_steps must not divide by zero.
553        let mut cyclical = NoiseInjectionScheduler::new_seeded(
554            ConstantScheduler::new(0.1f64),
555            NoiseDistribution::Cyclical {
556                amplitude: 0.01,
557                period: 0,
558            },
559            0.001,
560            2,
561        );
562        for _ in 0..10 {
563            assert!(cyclical.step().is_finite());
564        }
565
566        let mut decaying = NoiseInjectionScheduler::new_seeded(
567            ConstantScheduler::new(0.1f64),
568            NoiseDistribution::Decaying {
569                initial_scale: 0.05,
570                final_scale: 0.001,
571                decay_steps: 0,
572            },
573            0.001,
574            3,
575        );
576        for _ in 0..10 {
577            assert!(decaying.step().is_finite());
578        }
579    }
580}