Skip to main content

optirs_core/regularizers/
orthogonal.rs

1// Orthogonal regularization
2//
3// Orthogonal regularization encourages weight matrices to be orthogonal,
4// which helps with gradient flow and prevents vanishing/exploding gradients.
5
6use scirs2_core::ndarray::{Array, ArrayBase, Data, Dimension, Ix2, ScalarOperand};
7use scirs2_core::numeric::{Float, FromPrimitive};
8use std::fmt::Debug;
9
10use crate::error::{OptimError, Result};
11use crate::regularizers::Regularizer;
12
13/// Orthogonal regularization
14///
15/// Encourages weight matrices to be orthogonal by penalizing the difference
16/// between W^T * W and the identity matrix.
17///
18/// # Example
19///
20/// ```
21/// use scirs2_core::ndarray::array;
22/// use optirs_core::regularizers::{OrthogonalRegularization, Regularizer};
23///
24/// let ortho_reg = OrthogonalRegularization::new(0.01);
25/// let weights = array![[1.0, 0.0], [0.0, 1.0]];
26/// let mut gradient = array![[0.1, 0.2], [0.3, 0.4]];
27///
28/// // Apply orthogonal regularization  
29/// let penalty = ortho_reg.apply(&weights, &mut gradient).expect("ortho_reg.apply succeeds");
30/// ```
31#[derive(Debug, Clone)]
32pub struct OrthogonalRegularization<A: Float> {
33    /// Regularization strength
34    lambda: A,
35}
36
37impl<A: Float + Debug + ScalarOperand + FromPrimitive + Send + Sync> OrthogonalRegularization<A> {
38    /// Create a new orthogonal regularization
39    ///
40    /// # Arguments
41    ///
42    /// * `lambda` - Regularization strength
43    pub fn new(lambda: A) -> Self {
44        Self { lambda }
45    }
46
47    /// Compute orthogonal penalty for a 2D weight matrix
48    pub fn compute_penalty_2d<S: Data<Elem = A>>(&self, weights: &ArrayBase<S, Ix2>) -> A {
49        let n = weights.nrows().min(weights.ncols());
50        let eye = Array::<A, Ix2>::eye(n);
51
52        // Compute W^T * W
53        let wtw = weights.t().dot(weights);
54
55        // Compute Frobenius norm of (W^T * W - I)
56        let mut penalty = A::zero();
57        for i in 0..n {
58            for j in 0..n {
59                let diff = wtw[[i, j]] - eye[[i, j]];
60                penalty = penalty + diff * diff;
61            }
62        }
63
64        // For non-square matrices, add penalty for off-diagonal elements
65        if weights.nrows() != weights.ncols() {
66            let (rows, cols) = wtw.dim();
67            for i in 0..rows {
68                for j in 0..cols {
69                    if i >= n || j >= n {
70                        penalty = penalty + wtw[[i, j]] * wtw[[i, j]];
71                    }
72                }
73            }
74        }
75
76        self.lambda * penalty
77    }
78
79    /// Compute gradient of orthogonal penalty
80    fn compute_gradient_2d<S: Data<Elem = A>>(&self, weights: &ArrayBase<S, Ix2>) -> Array<A, Ix2> {
81        let n = weights.nrows().min(weights.ncols());
82
83        // Compute W^T * W
84        let wtw = weights.t().dot(weights);
85
86        // Compute gradient: 2 * lambda * W * (W^T * W - I)
87        let mut diff = wtw.clone();
88        for i in 0..n {
89            diff[[i, i]] = diff[[i, i]] - A::one();
90        }
91
92        let two =
93            A::from_f64(2.0).expect("OrthogonalRegularization: integer literal 2.0 must fit in A");
94        weights.dot(&diff) * (two * self.lambda)
95    }
96}
97
98// Implement Regularizer trait
99impl<
100        A: Float + Debug + ScalarOperand + FromPrimitive + Send + Sync,
101        D: Dimension + Send + Sync,
102    > Regularizer<A, D> for OrthogonalRegularization<A>
103{
104    fn apply(&self, params: &Array<A, D>, gradients: &mut Array<A, D>) -> Result<A> {
105        if params.ndim() != 2 {
106            // Only apply to 2D weight matrices
107            return Ok(A::zero());
108        }
109
110        // Downcast to 2D
111        let params_2d = params
112            .view()
113            .into_dimensionality::<Ix2>()
114            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
115
116        let gradient_update = self.compute_gradient_2d(&params_2d);
117
118        // Add orthogonal regularization gradient
119        let mut gradients_2d = gradients
120            .view_mut()
121            .into_dimensionality::<Ix2>()
122            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
123
124        // Manual element-wise addition
125        gradients_2d.zip_mut_with(&gradient_update, |g, &u| *g = *g + u);
126
127        // Return penalty
128        Ok(self.compute_penalty_2d(&params_2d))
129    }
130
131    fn penalty(&self, params: &Array<A, D>) -> Result<A> {
132        if params.ndim() != 2 {
133            // Only apply to 2D weight matrices
134            return Ok(A::zero());
135        }
136
137        // Downcast to 2D
138        let params_2d = params
139            .view()
140            .into_dimensionality::<Ix2>()
141            .map_err(|_| OptimError::InvalidConfig("Expected 2D array".to_string()))?;
142
143        Ok(self.compute_penalty_2d(&params_2d))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use approx::assert_relative_eq;
151    use scirs2_core::ndarray::{array, Array3};
152
153    #[test]
154    fn test_orthogonal_creation() {
155        let ortho = OrthogonalRegularization::<f64>::new(0.01);
156        assert_eq!(ortho.lambda, 0.01);
157    }
158
159    #[test]
160    fn test_identity_matrix_penalty() {
161        let ortho = OrthogonalRegularization::new(0.01);
162
163        // Identity matrix is already orthogonal, penalty should be 0
164        let weights = array![[1.0, 0.0], [0.0, 1.0]];
165        let penalty = ortho.compute_penalty_2d(&weights);
166
167        assert_relative_eq!(penalty, 0.0, epsilon = 1e-10);
168    }
169
170    #[test]
171    fn test_non_orthogonal_penalty() {
172        let ortho = OrthogonalRegularization::new(0.01);
173
174        // Non-orthogonal matrix should have non-zero penalty
175        let weights = array![[1.0, 0.5], [0.5, 1.0]];
176        let penalty = ortho.compute_penalty_2d(&weights);
177
178        assert!(penalty > 0.0);
179    }
180
181    #[test]
182    fn test_rectangular_matrix() {
183        let ortho = OrthogonalRegularization::new(0.01);
184
185        // Rectangular matrix
186        let weights = array![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
187        let penalty = ortho.compute_penalty_2d(&weights);
188
189        // First 2x2 block is identity, rest should contribute to penalty
190        assert!(penalty >= 0.0);
191    }
192
193    #[test]
194    fn test_gradient_computation() {
195        let ortho = OrthogonalRegularization::new(0.1);
196
197        let weights = array![[1.0, 0.5], [0.5, 1.0]];
198        let gradient = ortho.compute_gradient_2d(&weights);
199
200        // Gradient should not be zero for non-orthogonal matrix
201        assert!(gradient.abs().sum() > 0.0);
202    }
203
204    #[test]
205    fn test_regularizer_trait() {
206        let ortho = OrthogonalRegularization::new(0.01);
207
208        let params = array![[1.0, 0.5], [0.5, 1.0]];
209        let mut gradient = array![[0.1, 0.2], [0.3, 0.4]];
210        let original_gradient = gradient.clone();
211
212        let penalty = ortho
213            .apply(&params, &mut gradient)
214            .expect("ortho.apply succeeds in test_regularizer_trait");
215
216        // Penalty should be positive
217        assert!(penalty > 0.0);
218
219        // Gradient should be modified
220        assert_ne!(gradient, original_gradient);
221
222        // Penalty from apply should match penalty method
223        let penalty2 = ortho
224            .penalty(&params)
225            .expect("ortho.penalty succeeds in test_regularizer_trait");
226        assert_relative_eq!(penalty, penalty2, epsilon = 1e-10);
227    }
228
229    #[test]
230    fn test_non_2d_array() {
231        let ortho = OrthogonalRegularization::new(0.01);
232
233        // 3D array - should return zero penalty
234        let params = Array3::<f64>::zeros((2, 2, 2));
235        let mut gradient = Array3::<f64>::zeros((2, 2, 2));
236
237        let penalty = ortho
238            .apply(&params, &mut gradient)
239            .expect("ortho.apply succeeds in test_non_2d_array");
240        assert_eq!(penalty, 0.0);
241
242        // Gradient should be unchanged
243        assert_eq!(gradient, Array3::<f64>::zeros((2, 2, 2)));
244    }
245}