Skip to main content

optirs_core/regularizers/
weight_standardization.rs

1// Weight Standardization
2//
3// Weight Standardization is a technique that normalizes the weights of convolutional
4// layers by standardizing the weights along the channel dimension. This improves
5// training stability and allows for use of larger batch sizes.
6
7use scirs2_core::ndarray::{Array, Array2, Array4, ArrayBase, Data, Dimension, ScalarOperand};
8use scirs2_core::numeric::{Float, FromPrimitive};
9use std::fmt::Debug;
10
11use crate::error::{OptimError, Result};
12use crate::regularizers::Regularizer;
13
14/// Weight Standardization
15///
16/// Weight Standardization normalizes the weights along the channel dimension by
17/// adjusting them to have zero mean and unit variance. This helps with training
18/// stability, especially when used with batch normalization.
19///
20/// # Example
21///
22/// ```
23/// use scirs2_core::ndarray::array;
24/// use optirs_core::regularizers::{WeightStandardization, Regularizer};
25///
26/// let weight_std = WeightStandardization::new(1e-5);
27/// let weights = array![[1.0, 2.0], [3.0, 4.0]];
28/// let mut gradients = array![[0.1, 0.2], [0.3, 0.4]];
29///
30/// // Get standardized weights
31/// let standardized = weight_std.standardize(&weights).expect("weight_std.standardize succeeds");
32///
33/// // Apply during training (modifies gradients)
34/// let _ = weight_std.apply(&weights, &mut gradients);
35/// ```
36#[derive(Debug, Clone)]
37pub struct WeightStandardization<A: Float> {
38    /// Small constant for numerical stability
39    eps: A,
40}
41
42impl<A: Float + Debug + ScalarOperand + FromPrimitive + Send + Sync> WeightStandardization<A> {
43    /// Create a new Weight Standardization regularizer
44    ///
45    /// # Arguments
46    ///
47    /// * `eps` - Small constant for numerical stability (typically 1e-5)
48    pub fn new(eps: f64) -> Self {
49        Self {
50            eps: A::from_f64(eps).expect(
51                "WeightStandardization: eps must fit in A (f32/f64 never rejects a finite f64)",
52            ),
53        }
54    }
55
56    /// Apply weight standardization to a 2D weight matrix
57    ///
58    /// Standardizes the weights to have zero mean and unit variance.
59    ///
60    /// # Arguments
61    ///
62    /// * `weights` - 2D weight matrix
63    ///
64    /// # Returns
65    ///
66    /// Standardized weights with zero mean and unit variance
67    pub fn standardize(&self, weights: &Array2<A>) -> Result<Array2<A>> {
68        // Calculate mean for each row (output channel)
69        let n_cols = weights.ncols();
70        let n_cols_f: A = crate::regularizers::cast_scalar(n_cols)?;
71
72        // Calculate mean, subtract from weights, then calculate variance and normalize
73        let means = weights.sum_axis(scirs2_core::ndarray::Axis(1)) / n_cols_f;
74
75        // Subtract mean from weights
76        let mut centered = weights.clone();
77        for i in 0..weights.nrows() {
78            for j in 0..weights.ncols() {
79                centered[[i, j]] = centered[[i, j]] - means[i];
80            }
81        }
82
83        // Calculate variance
84        let mut var = Array::zeros(weights.nrows());
85        for i in 0..weights.nrows() {
86            let mut sum_sq = A::zero();
87            for j in 0..weights.ncols() {
88                sum_sq = sum_sq + centered[[i, j]] * centered[[i, j]];
89            }
90            var[i] = sum_sq / n_cols_f;
91        }
92
93        // Normalize
94        let mut standardized = centered.clone();
95        for i in 0..weights.nrows() {
96            let denom = (var[i] + self.eps).sqrt();
97            for j in 0..weights.ncols() {
98                standardized[[i, j]] = centered[[i, j]] / denom;
99            }
100        }
101
102        Ok(standardized)
103    }
104
105    /// Apply weight standardization to 4D convolutional weights
106    ///
107    /// # Arguments
108    ///
109    /// * `weights` - Convolutional weights with shape [out_channels, in_channels, height, width]
110    ///
111    /// # Returns
112    ///
113    /// Standardized convolutional weights
114    pub fn standardize_conv4d(&self, weights: &Array4<A>) -> Result<Array4<A>> {
115        let shape = weights.shape();
116        if shape.len() != 4 {
117            return Err(OptimError::InvalidConfig(
118                "Expected 4D weights for conv4d standardization".to_string(),
119            ));
120        }
121
122        let out_channels = shape[0];
123        let in_channels = shape[1];
124        let kernel_h = shape[2];
125        let kernel_w = shape[3];
126        let n_elements = in_channels * kernel_h * kernel_w;
127        let n_elements_f: A = crate::regularizers::cast_scalar(n_elements)?;
128
129        // Calculate mean for each output channel
130        let mut means = Array::zeros(out_channels);
131
132        for c_out in 0..out_channels {
133            let mut sum = A::zero();
134            for c_in in 0..in_channels {
135                for h in 0..kernel_h {
136                    for w in 0..kernel_w {
137                        sum = sum + weights[[c_out, c_in, h, w]];
138                    }
139                }
140            }
141            means[c_out] = sum / n_elements_f;
142        }
143
144        // Center the weights
145        let mut centered = weights.clone();
146
147        for c_out in 0..out_channels {
148            for c_in in 0..in_channels {
149                for h in 0..kernel_h {
150                    for w in 0..kernel_w {
151                        centered[[c_out, c_in, h, w]] = weights[[c_out, c_in, h, w]] - means[c_out];
152                    }
153                }
154            }
155        }
156
157        // Calculate variance for each output channel
158        let mut vars = Array::zeros(out_channels);
159
160        for c_out in 0..out_channels {
161            let mut sum_sq = A::zero();
162            for c_in in 0..in_channels {
163                for h in 0..kernel_h {
164                    for w in 0..kernel_w {
165                        sum_sq =
166                            sum_sq + centered[[c_out, c_in, h, w]] * centered[[c_out, c_in, h, w]];
167                    }
168                }
169            }
170            vars[c_out] = sum_sq / n_elements_f;
171        }
172
173        // Standardize
174        let mut standardized = centered.clone();
175
176        for c_out in 0..out_channels {
177            let std_dev = (vars[c_out] + self.eps).sqrt();
178            for c_in in 0..in_channels {
179                for h in 0..kernel_h {
180                    for w in 0..kernel_w {
181                        standardized[[c_out, c_in, h, w]] = centered[[c_out, c_in, h, w]] / std_dev;
182                    }
183                }
184            }
185        }
186
187        Ok(standardized)
188    }
189
190    /// Calculate the gradients of weight standardization
191    ///
192    /// # Arguments
193    ///
194    /// * `weights` - Original weights
195    /// * `grad_output` - Gradient from the next layer
196    ///
197    /// # Returns
198    ///
199    /// The gradient for the weights
200    fn compute_gradients<S1, S2>(
201        &self,
202        weights: &ArrayBase<S1, scirs2_core::ndarray::Ix2>,
203        grad_output: &ArrayBase<S2, scirs2_core::ndarray::Ix2>,
204    ) -> Result<Array2<A>>
205    where
206        S1: Data<Elem = A>,
207        S2: Data<Elem = A>,
208    {
209        // For simplicity, we're implementing a numerical approximation of the gradient
210        // In a real-world scenario, you would implement the analytical gradient
211
212        // Convert views to owned arrays to ensure we can modify them
213        let weights = weights.to_owned();
214        let grad_output = grad_output.to_owned();
215
216        let n_rows = weights.nrows();
217        let n_cols = weights.ncols();
218        let epsilon: A = crate::regularizers::cast_scalar(1e-6)?;
219
220        let mut gradients = Array2::zeros((n_rows, n_cols));
221        let standardized = self.standardize(&weights)?;
222
223        // Numerical gradient approximation
224        for i in 0..n_rows {
225            for j in 0..n_cols {
226                let mut weights_plus = weights.clone();
227                weights_plus[[i, j]] = weights_plus[[i, j]] + epsilon;
228
229                let standardized_plus = self.standardize(&weights_plus)?;
230
231                // Calculate the gradient using centered difference
232                let diff = &standardized_plus - &standardized;
233
234                // Element-wise multiplication with grad_output and sum
235                let mut grad_sum = A::zero();
236                for r in 0..n_rows {
237                    for c in 0..n_cols {
238                        grad_sum = grad_sum + diff[[r, c]] * grad_output[[r, c]];
239                    }
240                }
241
242                gradients[[i, j]] = grad_sum / epsilon;
243            }
244        }
245
246        Ok(gradients)
247    }
248}
249
250impl<
251        A: Float + Debug + ScalarOperand + FromPrimitive + Send + Sync,
252        D: Dimension + Send + Sync,
253    > Regularizer<A, D> for WeightStandardization<A>
254{
255    fn apply(&self, params: &Array<A, D>, gradients: &mut Array<A, D>) -> Result<A> {
256        // Check if we have 2D parameters
257        if params.ndim() != 2 {
258            // For simplicity, only handle 2D weights for gradient computation
259            // In practice, you would also handle 4D conv weights
260            return Ok(A::zero());
261        }
262
263        // Downcast to 2D
264        let params_2d = params
265            .view()
266            .into_dimensionality::<scirs2_core::ndarray::Ix2>()
267            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
268        let gradients_2d = gradients
269            .view()
270            .into_dimensionality::<scirs2_core::ndarray::Ix2>()
271            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
272
273        // Compute the gradient corrections
274        let corrections = self.compute_gradients(&params_2d, &gradients_2d)?;
275
276        // Apply the corrections to the gradients
277        let mut grad_mut = gradients
278            .view_mut()
279            .into_dimensionality::<scirs2_core::ndarray::Ix2>()
280            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
281
282        // Add the corrections to the gradients
283        grad_mut.zip_mut_with(&corrections, |g, &c| *g = *g + c);
284
285        // Weight standardization doesn't add a penalty term
286        Ok(A::zero())
287    }
288
289    fn penalty(&self, _params: &Array<A, D>) -> Result<A> {
290        // Weight standardization doesn't add a penalty term
291        Ok(A::zero())
292    }
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use approx::assert_relative_eq;
299    use scirs2_core::ndarray::array;
300
301    #[test]
302    fn test_weight_standardization_creation() {
303        let ws = WeightStandardization::<f64>::new(1e-5);
304        assert_eq!(ws.eps, 1e-5);
305    }
306
307    #[test]
308    fn test_standardize_2d() {
309        let ws = WeightStandardization::new(1e-5);
310
311        // Create a simple 2D weight matrix
312        let weights = array![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
313
314        let standardized = ws
315            .standardize(&weights)
316            .expect("ws.standardize succeeds in test_standardize_2d");
317
318        // Check shape is preserved
319        assert_eq!(standardized.shape(), weights.shape());
320
321        // Check means are close to zero
322        let mean1 = standardized.row(0).sum() / 3.0;
323        let mean2 = standardized.row(1).sum() / 3.0;
324
325        assert_relative_eq!(mean1, 0.0, epsilon = 1e-10);
326        assert_relative_eq!(mean2, 0.0, epsilon = 1e-10);
327
328        // Check variances are close to 1 (allowing for numerical precision)
329        let var1 = standardized.row(0).mapv(|x| x * x).sum() / 3.0;
330        let var2 = standardized.row(1).mapv(|x| x * x).sum() / 3.0;
331
332        println!("var1 = {}, var2 = {}", var1, var2);
333
334        // Relaxed tolerance needed due to numerical precision
335        assert!((var1 - 1.0).abs() < 2e-4);
336        assert!((var2 - 1.0).abs() < 2e-4);
337    }
338
339    #[test]
340    fn test_standardize_conv4d() {
341        let ws = WeightStandardization::new(1e-5);
342
343        // Create a simple 4D convolutional weight tensor
344        let weights = Array4::from_shape_fn((2, 2, 2, 2), |idx| {
345            let (a, b, c, d) = (idx.0, idx.1, idx.2, idx.3);
346            (a * 8 + b * 4 + c * 2 + d) as f64
347        });
348
349        let standardized = ws
350            .standardize_conv4d(&weights)
351            .expect("ws.standardize_conv4d succeeds in test_standardize_conv4d");
352
353        // Check shape is preserved
354        assert_eq!(standardized.shape(), weights.shape());
355
356        // Check means are close to zero for each output channel
357        let mut sum1 = 0.0;
358        let mut sum2 = 0.0;
359
360        for c_in in 0..2 {
361            for h in 0..2 {
362                for w in 0..2 {
363                    sum1 += standardized[[0, c_in, h, w]];
364                    sum2 += standardized[[1, c_in, h, w]];
365                }
366            }
367        }
368
369        let mean1 = sum1 / 8.0;
370        let mean2 = sum2 / 8.0;
371
372        assert_relative_eq!(mean1, 0.0, epsilon = 1e-10);
373        assert_relative_eq!(mean2, 0.0, epsilon = 1e-10);
374
375        // Check variances are close to 1 for each output channel (allowing for numerical precision)
376        let mut sum_sq1 = 0.0;
377        let mut sum_sq2 = 0.0;
378
379        for c_in in 0..2 {
380            for h in 0..2 {
381                for w in 0..2 {
382                    sum_sq1 += standardized[[0, c_in, h, w]] * standardized[[0, c_in, h, w]];
383                    sum_sq2 += standardized[[1, c_in, h, w]] * standardized[[1, c_in, h, w]];
384                }
385            }
386        }
387
388        let var1 = sum_sq1 / 8.0;
389        let var2 = sum_sq2 / 8.0;
390
391        assert!((var1 - 1.0).abs() < 1e-5);
392        assert!((var2 - 1.0).abs() < 1e-5);
393    }
394
395    #[test]
396    fn test_regularizer_trait() {
397        let ws = WeightStandardization::new(1e-5);
398        let params = array![[1.0, 2.0], [3.0, 4.0]];
399        let mut gradients = array![[0.1, 0.2], [0.3, 0.4]];
400        let orig_gradients = gradients.clone();
401
402        let penalty = ws
403            .apply(&params, &mut gradients)
404            .expect("ws.apply succeeds in test_regularizer_trait");
405
406        // Penalty should be zero
407        assert_eq!(penalty, 0.0);
408
409        // Gradients should be modified
410        assert_ne!(gradients, orig_gradients);
411    }
412}