Skip to main content

thrust_rl/utils/
normalize.rs

1//! Observation normalization for stable RL training
2//!
3//! This module provides running mean and standard deviation normalization,
4//! which is crucial for stable training in many RL environments.
5
6/// Running mean and standard deviation normalizer
7///
8/// Tracks mean and variance using Welford's online algorithm
9/// for numerical stability.
10#[derive(Debug, Clone)]
11pub struct RunningMeanStd {
12    mean: Vec<f32>,
13    var: Vec<f32>,
14    count: f64,
15    epsilon: f32,
16}
17
18impl RunningMeanStd {
19    /// Create a new normalizer
20    ///
21    /// # Arguments
22    /// * `size` - Dimension of observations
23    /// * `epsilon` - Small constant for numerical stability
24    pub fn new(size: usize, epsilon: f32) -> Self {
25        Self { mean: vec![0.0; size], var: vec![1.0; size], count: 1e-4, epsilon }
26    }
27
28    /// Update statistics with a batch of observations
29    ///
30    /// # Arguments
31    /// * `observations` - Batch of observations `[batch_size][obs_dim]`
32    // The mean/variance update loops index four parallel arrays
33    // (`mean`, `var`, `delta`, `batch_var`) by the same `i`; enumerate() over one
34    // would force awkward zip chains for the rest.
35    #[allow(clippy::needless_range_loop)]
36    pub fn update(&mut self, observations: &[Vec<f32>]) {
37        if observations.is_empty() {
38            return;
39        }
40
41        let batch_size = observations.len() as f64;
42        let obs_dim = self.mean.len();
43
44        // Compute batch statistics
45        let mut batch_mean = vec![0.0; obs_dim];
46        for obs in observations {
47            for (i, &val) in obs.iter().enumerate() {
48                batch_mean[i] += val as f64;
49            }
50        }
51        for val in &mut batch_mean {
52            *val /= batch_size;
53        }
54
55        let mut batch_var = vec![0.0; obs_dim];
56        for obs in observations {
57            for (i, &val) in obs.iter().enumerate() {
58                let diff = val as f64 - batch_mean[i];
59                batch_var[i] += diff * diff;
60            }
61        }
62        for val in &mut batch_var {
63            *val /= batch_size;
64        }
65
66        // Update running statistics using parallel axis theorem
67        let delta = batch_mean
68            .iter()
69            .zip(&self.mean)
70            .map(|(b, m)| b - *m as f64)
71            .collect::<Vec<_>>();
72
73        let total_count = self.count + batch_size;
74
75        // Update mean
76        for i in 0..obs_dim {
77            self.mean[i] = (self.mean[i] as f64 + delta[i] * batch_size / total_count) as f32;
78        }
79
80        // Update variance
81        for i in 0..obs_dim {
82            let m_a = self.var[i] as f64 * self.count;
83            let m_b = batch_var[i] * batch_size;
84            let m2 = m_a + m_b + delta[i].powi(2) * self.count * batch_size / total_count;
85            self.var[i] = (m2 / total_count) as f32;
86        }
87
88        self.count = total_count;
89    }
90
91    /// Normalize observations
92    ///
93    /// # Arguments
94    /// * `observations` - Observations to normalize `[obs_dim]`
95    ///
96    /// # Returns
97    /// * Normalized observations (mean=0, std=1)
98    pub fn normalize(&self, observations: &[f32]) -> Vec<f32> {
99        observations
100            .iter()
101            .zip(&self.mean)
102            .zip(&self.var)
103            .map(|((&obs, &mean), &var)| (obs - mean) / (var.sqrt() + self.epsilon))
104            .collect()
105    }
106
107    /// Normalize a batch of observations in-place
108    ///
109    /// # Arguments
110    /// * `observations` - Batch of observations `[batch_size][obs_dim]`
111    pub fn normalize_batch(&self, observations: &mut [Vec<f32>]) {
112        for obs in observations {
113            *obs = self.normalize(obs);
114        }
115    }
116
117    /// Get current mean
118    pub fn mean(&self) -> &[f32] {
119        &self.mean
120    }
121
122    /// Get current standard deviation
123    pub fn std(&self) -> Vec<f32> {
124        self.var.iter().map(|v| (v + self.epsilon).sqrt()).collect()
125    }
126
127    /// Get number of samples seen
128    pub fn count(&self) -> f64 {
129        self.count
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn test_normalize_basic() {
139        let mut normalizer = RunningMeanStd::new(2, 1e-8);
140
141        // Update with some data
142        let data = vec![vec![1.0, 2.0], vec![2.0, 4.0], vec![3.0, 6.0]];
143        normalizer.update(&data);
144
145        // Mean should be approximately [2.0, 4.0]
146        assert!((normalizer.mean()[0] - 2.0).abs() < 0.1);
147        assert!((normalizer.mean()[1] - 4.0).abs() < 0.1);
148
149        // Normalize a new observation
150        let obs = vec![2.0, 4.0];
151        let normalized = normalizer.normalize(&obs);
152
153        // Should be close to zero (at the mean)
154        assert!(normalized[0].abs() < 0.5);
155        assert!(normalized[1].abs() < 0.5);
156    }
157
158    #[test]
159    fn test_normalize_batch() {
160        let mut normalizer = RunningMeanStd::new(2, 1e-8);
161
162        let mut batch = vec![vec![0.0, 0.0], vec![1.0, 1.0]];
163
164        normalizer.update(&batch);
165        normalizer.normalize_batch(&mut batch);
166
167        // After normalization, mean should be close to 0
168        let sum: f32 = batch.iter().flatten().sum();
169        assert!(sum.abs() < 0.5);
170    }
171
172    #[test]
173    fn test_incremental_update() {
174        let mut normalizer = RunningMeanStd::new(1, 1e-8);
175
176        // Add observations incrementally
177        normalizer.update(&[vec![1.0]]);
178        normalizer.update(&[vec![2.0]]);
179        normalizer.update(&[vec![3.0]]);
180
181        // Mean should be approximately 2.0
182        assert!((normalizer.mean()[0] - 2.0).abs() < 0.1);
183    }
184}