Skip to main content

tenflowers_core/
numerical_gradient.rs

1//! Numerical Gradient Validation Utilities
2//!
3//! This module provides tools for validating automatic differentiation implementations
4//! by comparing analytical gradients against numerical gradients computed via finite
5//! differences. This is essential for ensuring correctness of custom operations and
6//! gradient implementations.
7//!
8//! # Overview
9//!
10//! Numerical gradient checking uses the definition of a derivative:
11//!
12//! ```text
13//! f'(x) ≈ [f(x + ε) - f(x - ε)] / (2ε)
14//! ```
15//!
16//! By comparing this numerical approximation with the analytical gradient computed
17//! via automatic differentiation, we can detect bugs in gradient implementations.
18//!
19//! # Example
20//!
21//! ```rust,ignore
22//! use tenflowers_core::numerical_gradient::{check_gradients, GradientCheckConfig};
23//! use tenflowers_core::Tensor;
24//!
25//! // Define a function to test
26//! fn square(x: &Tensor<f32>) -> Tensor<f32> {
27//!     x * x
28//! }
29//!
30//! // Analytical gradient: 2x
31//! fn square_grad(x: &Tensor<f32>) -> Tensor<f32> {
32//!     x * 2.0
33//! }
34//!
35//! let config = GradientCheckConfig::default();
36//! let x = Tensor::from_vec(vec![1.0, 2.0, 3.0], &[3]);
37//!
38//! // Verify gradient correctness
39//! let result = check_gradients(&x, square, square_grad, &config);
40//! assert!(result.is_ok());
41//! ```
42
43use crate::{Result, Tensor, TensorError};
44use scirs2_core::numeric::{Float, FromPrimitive};
45use std::marker::PhantomData;
46
47/// Configuration for numerical gradient checking
48#[derive(Debug, Clone)]
49pub struct GradientCheckConfig {
50    /// Epsilon for finite difference computation
51    pub epsilon: f64,
52    /// Relative tolerance for gradient comparison
53    pub rtol: f64,
54    /// Absolute tolerance for gradient comparison
55    pub atol: f64,
56    /// Check gradients element-wise (slower but more detailed)
57    pub check_elementwise: bool,
58    /// Use central differences (more accurate but 2x slower)
59    pub use_central_difference: bool,
60    /// Maximum number of samples to check (for large tensors)
61    pub max_samples: Option<usize>,
62    /// Random seed for sampling (if max_samples is set)
63    pub random_seed: Option<u64>,
64}
65
66impl Default for GradientCheckConfig {
67    fn default() -> Self {
68        Self {
69            epsilon: 1e-5,
70            rtol: 1e-3,
71            atol: 1e-5,
72            check_elementwise: false,
73            use_central_difference: true,
74            max_samples: None,
75            random_seed: None,
76        }
77    }
78}
79
80impl GradientCheckConfig {
81    /// Create a configuration for strict checking (tighter tolerances)
82    pub fn strict() -> Self {
83        Self {
84            epsilon: 1e-6,
85            rtol: 1e-4,
86            atol: 1e-6,
87            check_elementwise: true,
88            use_central_difference: true,
89            max_samples: None,
90            random_seed: None,
91        }
92    }
93
94    /// Create a configuration for relaxed checking (looser tolerances)
95    pub fn relaxed() -> Self {
96        Self {
97            epsilon: 1e-4,
98            rtol: 1e-2,
99            atol: 1e-4,
100            check_elementwise: false,
101            use_central_difference: true,
102            max_samples: Some(100),
103            random_seed: Some(42),
104        }
105    }
106
107    /// Create a configuration for fast checking (forward differences, sampling)
108    pub fn fast() -> Self {
109        Self {
110            epsilon: 1e-5,
111            rtol: 1e-3,
112            atol: 1e-5,
113            check_elementwise: false,
114            use_central_difference: false,
115            max_samples: Some(50),
116            random_seed: Some(42),
117        }
118    }
119}
120
121/// Result of gradient checking
122#[derive(Debug, Clone)]
123pub struct GradientCheckResult {
124    /// Whether the gradient check passed
125    pub passed: bool,
126    /// Maximum relative error found
127    pub max_relative_error: f64,
128    /// Maximum absolute error found
129    pub max_absolute_error: f64,
130    /// Number of elements checked
131    pub num_elements_checked: usize,
132    /// Number of elements that failed tolerance check
133    pub num_failures: usize,
134    /// Indices of failed elements (if check_elementwise is true)
135    pub failed_indices: Vec<usize>,
136    /// Detailed error message if check failed
137    pub error_message: Option<String>,
138}
139
140impl GradientCheckResult {
141    /// Check if the gradient validation passed
142    pub fn is_ok(&self) -> bool {
143        self.passed
144    }
145
146    /// Get failure rate (percentage of elements that failed)
147    pub fn failure_rate(&self) -> f64 {
148        if self.num_elements_checked == 0 {
149            0.0
150        } else {
151            (self.num_failures as f64 / self.num_elements_checked as f64) * 100.0
152        }
153    }
154
155    /// Generate a summary report
156    pub fn summary(&self) -> String {
157        if self.passed {
158            format!(
159                "✓ Gradient check passed\n\
160                 Elements checked: {}\n\
161                 Max relative error: {:.2e}\n\
162                 Max absolute error: {:.2e}",
163                self.num_elements_checked, self.max_relative_error, self.max_absolute_error
164            )
165        } else {
166            format!(
167                "✗ Gradient check FAILED\n\
168                 Elements checked: {}\n\
169                 Failures: {} ({:.2}%)\n\
170                 Max relative error: {:.2e}\n\
171                 Max absolute error: {:.2e}\n\
172                 {}",
173                self.num_elements_checked,
174                self.num_failures,
175                self.failure_rate(),
176                self.max_relative_error,
177                self.max_absolute_error,
178                self.error_message.as_deref().unwrap_or("")
179            )
180        }
181    }
182}
183
184/// Numerical gradient checker
185pub struct NumericalGradientChecker<T> {
186    config: GradientCheckConfig,
187    _phantom: PhantomData<T>,
188}
189
190impl<T> NumericalGradientChecker<T>
191where
192    T: Float + FromPrimitive + Clone + Send + Sync + Default + 'static,
193{
194    /// Create a new gradient checker with the given configuration
195    pub fn new(config: GradientCheckConfig) -> Self {
196        Self {
197            config,
198            _phantom: PhantomData,
199        }
200    }
201
202    /// Compute numerical gradient using finite differences
203    pub fn compute_numerical_gradient<F>(&self, input: &Tensor<T>, func: F) -> Result<Tensor<T>>
204    where
205        F: Fn(&Tensor<T>) -> Result<Tensor<T>>,
206    {
207        let input_data = input.data();
208        let input_shape = input.shape();
209        let mut gradient_data = Vec::with_capacity(input_data.len());
210
211        let epsilon = T::from_f64(self.config.epsilon).ok_or_else(|| {
212            TensorError::invalid_operation_simple("Failed to convert epsilon".to_string())
213        })?;
214
215        for i in 0..input_data.len() {
216            let grad = if self.config.use_central_difference {
217                // Central difference: [f(x + ε) - f(x - ε)] / (2ε)
218                let mut input_plus = input_data.to_vec();
219                let mut input_minus = input_data.to_vec();
220
221                input_plus[i] = input_plus[i] + epsilon;
222                input_minus[i] = input_minus[i] - epsilon;
223
224                let x_plus = Tensor::from_array(
225                    scirs2_core::ndarray::Array::from_shape_vec(
226                        input_shape.dims().to_vec(),
227                        input_plus.to_vec(),
228                    )
229                    .map_err(|e| TensorError::invalid_argument(format!("Shape mismatch: {}", e)))?
230                    .into_dyn(),
231                );
232
233                let x_minus = Tensor::from_array(
234                    scirs2_core::ndarray::Array::from_shape_vec(
235                        input_shape.dims().to_vec(),
236                        input_minus.to_vec(),
237                    )
238                    .map_err(|e| TensorError::invalid_argument(format!("Shape mismatch: {}", e)))?
239                    .into_dyn(),
240                );
241
242                let f_plus = func(&x_plus)?;
243                let f_minus = func(&x_minus)?;
244
245                // For element-wise functions, derivative at position i
246                let two_epsilon = epsilon + epsilon;
247                let diff = f_plus.data()[i] - f_minus.data()[i];
248                diff / two_epsilon
249            } else {
250                // Forward difference: [f(x + ε) - f(x)] / ε
251                let mut input_plus = input_data.to_vec();
252                input_plus[i] = input_plus[i] + epsilon;
253
254                let x_plus = Tensor::from_array(
255                    scirs2_core::ndarray::Array::from_shape_vec(
256                        input_shape.dims().to_vec(),
257                        input_plus.to_vec(),
258                    )
259                    .map_err(|e| TensorError::invalid_argument(format!("Shape mismatch: {}", e)))?
260                    .into_dyn(),
261                );
262
263                let f_plus = func(&x_plus)?;
264                let f_x = func(input)?;
265
266                // For element-wise functions, derivative at position i
267                let diff = f_plus.data()[i] - f_x.data()[i];
268                diff / epsilon
269            };
270
271            gradient_data.push(grad);
272        }
273
274        let gradient_array =
275            scirs2_core::ndarray::Array::from_shape_vec(input_shape.dims().to_vec(), gradient_data)
276                .map_err(|e| TensorError::invalid_argument(format!("Shape mismatch: {}", e)))?
277                .into_dyn();
278
279        Ok(Tensor::from_array(gradient_array))
280    }
281
282    /// Compare analytical and numerical gradients
283    pub fn compare_gradients(
284        &self,
285        numerical: &Tensor<T>,
286        analytical: &Tensor<T>,
287    ) -> Result<GradientCheckResult> {
288        if numerical.shape() != analytical.shape() {
289            return Err(TensorError::invalid_argument(format!(
290                "Shape mismatch: numerical {:?} vs analytical {:?}",
291                numerical.shape(),
292                analytical.shape()
293            )));
294        }
295
296        let num_data = numerical.data();
297        let ana_data = analytical.data();
298
299        let rtol = self.config.rtol;
300        let atol = self.config.atol;
301
302        let mut max_rel_error = 0.0;
303        let mut max_abs_error = 0.0;
304        let mut num_failures = 0;
305        let mut failed_indices = Vec::new();
306
307        for i in 0..num_data.len() {
308            let num_val = num_data[i].to_f64().unwrap_or(0.0);
309            let ana_val = ana_data[i].to_f64().unwrap_or(0.0);
310
311            let abs_error = (num_val - ana_val).abs();
312            let rel_error = if ana_val.abs() > 1e-10 {
313                abs_error / ana_val.abs()
314            } else {
315                abs_error
316            };
317
318            max_rel_error = max_rel_error.max(rel_error);
319            max_abs_error = max_abs_error.max(abs_error);
320
321            if rel_error > rtol && abs_error > atol {
322                num_failures += 1;
323                if self.config.check_elementwise {
324                    failed_indices.push(i);
325                }
326            }
327        }
328
329        let passed = num_failures == 0;
330        let error_message = if !passed {
331            Some(format!(
332                "Gradient mismatch: {} of {} elements exceed tolerance (rtol={}, atol={})",
333                num_failures,
334                num_data.len(),
335                rtol,
336                atol
337            ))
338        } else {
339            None
340        };
341
342        Ok(GradientCheckResult {
343            passed,
344            max_relative_error: max_rel_error,
345            max_absolute_error: max_abs_error,
346            num_elements_checked: num_data.len(),
347            num_failures,
348            failed_indices,
349            error_message,
350        })
351    }
352
353    /// Full gradient check: compute numerical gradient and compare
354    pub fn check<F, G>(
355        &self,
356        input: &Tensor<T>,
357        forward: F,
358        gradient: G,
359    ) -> Result<GradientCheckResult>
360    where
361        F: Fn(&Tensor<T>) -> Result<Tensor<T>>,
362        G: Fn(&Tensor<T>) -> Result<Tensor<T>>,
363    {
364        let numerical_grad = self.compute_numerical_gradient(input, forward)?;
365        let analytical_grad = gradient(input)?;
366
367        self.compare_gradients(&numerical_grad, &analytical_grad)
368    }
369}
370
371/// Convenience function for quick gradient checking
372pub fn check_gradients<T, F, G>(
373    input: &Tensor<T>,
374    forward: F,
375    gradient: G,
376    config: &GradientCheckConfig,
377) -> Result<GradientCheckResult>
378where
379    T: Float + FromPrimitive + Clone + Send + Sync + Default + 'static,
380    F: Fn(&Tensor<T>) -> Result<Tensor<T>>,
381    G: Fn(&Tensor<T>) -> Result<Tensor<T>>,
382{
383    let checker = NumericalGradientChecker::new(config.clone());
384    checker.check(input, forward, gradient)
385}
386
387/// Convenience function with default configuration
388pub fn quick_check_gradients<T, F, G>(
389    input: &Tensor<T>,
390    forward: F,
391    gradient: G,
392) -> Result<GradientCheckResult>
393where
394    T: Float + FromPrimitive + Clone + Send + Sync + Default + 'static,
395    F: Fn(&Tensor<T>) -> Result<Tensor<T>>,
396    G: Fn(&Tensor<T>) -> Result<Tensor<T>>,
397{
398    check_gradients(input, forward, gradient, &GradientCheckConfig::default())
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use scirs2_core::ndarray::array;
405
406    #[test]
407    fn test_gradient_check_linear() {
408        // Test f(x) = 2x, f'(x) = 2
409        let input = Tensor::from_array(array![1.0, 2.0, 3.0].into_dyn());
410
411        let forward = |x: &Tensor<f32>| {
412            let data: Vec<f32> = x.data().iter().map(|&v| v * 2.0).collect();
413            let result_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
414            Ok(Tensor::from_array(result_array))
415        };
416
417        let gradient = |_x: &Tensor<f32>| {
418            let grad = array![2.0, 2.0, 2.0].into_dyn();
419            Ok(Tensor::from_array(grad))
420        };
421
422        // Use relaxed config due to f32 precision limits
423        let config = GradientCheckConfig::relaxed();
424        let result = check_gradients(&input, forward, gradient, &config)
425            .expect("test: check_gradients should succeed");
426
427        assert!(
428            result.passed,
429            "Gradient check should pass for linear function: {}",
430            result.summary()
431        );
432    }
433
434    #[test]
435    fn test_gradient_check_square() {
436        // Test f(x) = x^2, f'(x) = 2x
437        let input = Tensor::from_array(array![1.0, 2.0, 3.0].into_dyn());
438
439        let forward = |x: &Tensor<f32>| {
440            let data: Vec<f32> = x.data().iter().map(|&v| v * v).collect();
441            let result_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
442            Ok(Tensor::from_array(result_array))
443        };
444
445        let gradient = |x: &Tensor<f32>| {
446            let data: Vec<f32> = x.data().iter().map(|&v| 2.0 * v).collect();
447            let grad_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
448            Ok(Tensor::from_array(grad_array))
449        };
450
451        // Use relaxed config due to f32 precision limits
452        let config = GradientCheckConfig::relaxed();
453        let result = check_gradients(&input, forward, gradient, &config)
454            .expect("test: check_gradients should succeed");
455
456        assert!(
457            result.passed,
458            "Gradient check should pass for square function: {}",
459            result.summary()
460        );
461    }
462
463    #[test]
464    fn test_gradient_check_incorrect_gradient() {
465        // Test with intentionally incorrect gradient
466        let input = Tensor::from_array(array![1.0, 2.0, 3.0].into_dyn());
467
468        let forward = |x: &Tensor<f32>| {
469            let data: Vec<f32> = x.data().iter().map(|&v| v * v).collect();
470            let result_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
471            Ok(Tensor::from_array(result_array))
472        };
473
474        let wrong_gradient = |x: &Tensor<f32>| {
475            // Incorrect: should be 2x, but we return 3x
476            let data: Vec<f32> = x.data().iter().map(|&v| 3.0 * v).collect();
477            let grad_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
478            Ok(Tensor::from_array(grad_array))
479        };
480
481        let config = GradientCheckConfig::default();
482        let result = check_gradients(&input, forward, wrong_gradient, &config)
483            .expect("test: check_gradients should succeed");
484
485        assert!(
486            !result.passed,
487            "Gradient check should fail for incorrect gradient"
488        );
489        assert!(result.num_failures > 0);
490    }
491
492    #[test]
493    fn test_gradient_check_config_tolerances() {
494        let input = Tensor::from_array(array![1.0].into_dyn());
495
496        let forward = |x: &Tensor<f32>| {
497            let data: Vec<f32> = x.data().iter().map(|&v| v * v).collect();
498            let result_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
499            Ok(Tensor::from_array(result_array))
500        };
501
502        let slightly_off_gradient = |x: &Tensor<f32>| {
503            // Slightly incorrect: 2x * 1.01
504            let data: Vec<f32> = x.data().iter().map(|&v| 2.0 * v * 1.01).collect();
505            let grad_array = scirs2_core::ndarray::Array::from_vec(data).into_dyn();
506            Ok(Tensor::from_array(grad_array))
507        };
508
509        // Should pass with relaxed config
510        let relaxed = GradientCheckConfig::relaxed();
511        let result = check_gradients(&input, forward, slightly_off_gradient, &relaxed)
512            .expect("test: check_gradients should succeed");
513        assert!(result.passed, "Should pass with relaxed tolerances");
514
515        // Should fail with strict config
516        let strict = GradientCheckConfig::strict();
517        let result = check_gradients(&input, forward, slightly_off_gradient, &strict)
518            .expect("test: check_gradients should succeed");
519        assert!(!result.passed, "Should fail with strict tolerances");
520    }
521
522    #[test]
523    fn test_gradient_check_result_summary() {
524        let result = GradientCheckResult {
525            passed: false,
526            max_relative_error: 0.05,
527            max_absolute_error: 0.01,
528            num_elements_checked: 100,
529            num_failures: 10,
530            failed_indices: vec![],
531            error_message: Some("Test error".to_string()),
532        };
533
534        let summary = result.summary();
535        assert!(summary.contains("FAILED"));
536        assert!(summary.contains("10.00%"));
537
538        assert_eq!(result.failure_rate(), 10.0);
539    }
540}