1use crate::{Module, Parameter};
7use scirs2_core::random::Random;
8use torsh_core::error::{Result, TorshError};
9use torsh_tensor::Tensor;
10
11#[cfg(feature = "std")]
13use std::{collections::HashSet, string::String, vec::Vec};
14
15#[cfg(not(feature = "std"))]
16use alloc::{string::String, vec::Vec};
17
18#[cfg(not(feature = "std"))]
19use hashbrown::{HashMap, HashSet};
20
21#[derive(Debug, Clone)]
23pub struct GradCheckConfig {
24 pub eps: f64,
26 pub rtol: f64,
28 pub atol: f64,
30 pub double_precision: bool,
32 pub max_elements: Option<usize>,
34 pub seed: Option<u64>,
36}
37
38impl Default for GradCheckConfig {
39 fn default() -> Self {
40 Self {
41 eps: 1e-6,
42 rtol: 1e-3,
43 atol: 1e-5,
44 double_precision: false,
45 max_elements: Some(100),
46 seed: Some(42),
47 }
48 }
49}
50
51#[derive(Debug, Clone)]
53pub struct ParameterGradCheckResult {
54 pub name: String,
56 pub passed: bool,
58 pub max_abs_diff: f64,
60 pub max_rel_diff: f64,
62 pub elements_checked: usize,
64 pub error: Option<String>,
66}
67
68#[derive(Debug, Clone)]
70pub struct GradCheckResult {
71 pub passed: bool,
73 pub parameter_results: Vec<ParameterGradCheckResult>,
75 pub summary: String,
77}
78
79impl GradCheckResult {
80 pub fn failed_parameters(&self) -> Vec<&ParameterGradCheckResult> {
82 self.parameter_results
83 .iter()
84 .filter(|r| !r.passed)
85 .collect()
86 }
87
88 pub fn worst_parameter(&self) -> Option<&ParameterGradCheckResult> {
95 self.parameter_results
96 .iter()
97 .max_by(|a, b| a.max_abs_diff.total_cmp(&b.max_abs_diff))
98 }
99
100 pub fn nan_parameters(&self) -> Vec<&ParameterGradCheckResult> {
105 self.parameter_results
106 .iter()
107 .filter(|result| result.max_abs_diff.is_nan() || result.max_rel_diff.is_nan())
108 .collect()
109 }
110}
111
112pub struct GradChecker {
114 config: GradCheckConfig,
115}
116
117impl GradChecker {
118 pub fn new() -> Self {
120 Self {
121 config: GradCheckConfig::default(),
122 }
123 }
124
125 pub fn with_config(config: GradCheckConfig) -> Self {
127 Self { config }
128 }
129
130 pub fn check_module<M: Module, F>(
132 &self,
133 module: &M,
134 input: &Tensor<f32>,
135 loss_fn: F,
136 ) -> Result<GradCheckResult>
137 where
138 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
139 {
140 let parameters = module.named_parameters();
141 let mut parameter_results = Vec::new();
142 let mut all_passed = true;
143
144 for (name, param) in parameters.iter() {
145 match self.check_parameter(module, input, param, &name, &loss_fn) {
146 Ok(result) => {
147 if !result.passed {
148 all_passed = false;
149 }
150 parameter_results.push(result);
151 }
152 Err(e) => {
153 all_passed = false;
154 parameter_results.push(ParameterGradCheckResult {
155 name: name.clone(),
156 passed: false,
157 max_abs_diff: f64::INFINITY,
158 max_rel_diff: f64::INFINITY,
159 elements_checked: 0,
160 error: Some(e.to_string()),
161 });
162 }
163 }
164 }
165
166 let summary = if all_passed {
167 format!(
168 "All {} parameters passed gradient check",
169 parameter_results.len()
170 )
171 } else {
172 let failed_count = parameter_results.iter().filter(|r| !r.passed).count();
173 let nan_count = parameter_results
174 .iter()
175 .filter(|r| r.max_abs_diff.is_nan() || r.max_rel_diff.is_nan())
176 .count();
177 if nan_count > 0 {
178 format!(
179 "{} out of {} parameters failed gradient check ({} produced NaN differences)",
180 failed_count,
181 parameter_results.len(),
182 nan_count
183 )
184 } else {
185 format!(
186 "{} out of {} parameters failed gradient check",
187 failed_count,
188 parameter_results.len()
189 )
190 }
191 };
192
193 Ok(GradCheckResult {
194 passed: all_passed,
195 parameter_results,
196 summary,
197 })
198 }
199
200 pub fn check_function<F>(&self, func: F, input: &Tensor<f32>) -> Result<GradCheckResult>
202 where
203 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
204 {
205 let input_with_grad = input.clone().requires_grad_(true);
207
208 let output = func(&input_with_grad)?;
210
211 output.backward()?;
213 let analytical_grad = input_with_grad
214 .grad()
215 .ok_or_else(|| TorshError::AutogradError("No gradient computed".to_string()))?;
216
217 let numerical_grad = self.compute_numerical_gradient_function(&func, input)?;
219
220 let comparison = self.compare_gradients(&analytical_grad, &numerical_grad)?;
222
223 let param_result = ParameterGradCheckResult {
224 name: "input".to_string(),
225 passed: comparison.0,
226 max_abs_diff: comparison.1,
227 max_rel_diff: comparison.2,
228 elements_checked: comparison.3,
229 error: None,
230 };
231
232 let summary = if comparison.0 {
233 "Function gradient check passed".to_string()
234 } else {
235 "Function gradient check failed".to_string()
236 };
237
238 Ok(GradCheckResult {
239 passed: comparison.0,
240 parameter_results: vec![param_result],
241 summary,
242 })
243 }
244
245 fn check_parameter<M: Module, F>(
247 &self,
248 module: &M,
249 input: &Tensor<f32>,
250 parameter: &Parameter,
251 param_name: &str,
252 loss_fn: &F,
253 ) -> Result<ParameterGradCheckResult>
254 where
255 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
256 {
257 let analytical_grad =
260 self.compute_analytical_gradient(module, input, parameter, loss_fn)?;
261
262 let numerical_grad =
264 self.compute_numerical_gradient(module, input, parameter, param_name, loss_fn)?;
265
266 let comparison = self.compare_gradients(&analytical_grad, &numerical_grad)?;
268
269 Ok(ParameterGradCheckResult {
270 name: param_name.to_string(),
271 passed: comparison.0,
272 max_abs_diff: comparison.1,
273 max_rel_diff: comparison.2,
274 elements_checked: comparison.3,
275 error: None,
276 })
277 }
278
279 fn compute_analytical_gradient<M: Module, F>(
303 &self,
304 _module: &M,
305 _input: &Tensor<f32>,
306 _parameter: &Parameter,
307 _loss_fn: &F,
308 ) -> Result<Tensor<f32>>
309 where
310 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
311 {
312 Err(TorshError::NotImplemented(
313 "module-parameter analytical gradient requires autograd backward through \
314 the module's parameters; the Module trait does not yet expose autograd-tracked \
315 parameter access, so gradcheck cannot run on modules. Use gradcheck_function \
316 for the pure-function path."
317 .to_string(),
318 ))
319 }
320
321 fn compute_numerical_gradient<M: Module, F>(
323 &self,
324 module: &M,
325 input: &Tensor<f32>,
326 parameter: &Parameter,
327 _param_name: &str,
328 loss_fn: &F,
329 ) -> Result<Tensor<f32>>
330 where
331 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
332 {
333 let param_data = parameter.tensor().read().clone();
334 let param_shape = param_data.shape().dims().to_vec();
335 let numel = param_data.numel();
336
337 let indices_to_check = self.get_indices_to_check(numel);
339
340 let mut grad_data = vec![0.0f32; numel];
342
343 for &idx in &indices_to_check {
344 let grad = self.compute_finite_difference(module, input, parameter, idx, loss_fn)?;
346 grad_data[idx] = grad;
347 }
348
349 Ok(
350 Tensor::from_data(grad_data, param_shape, param_data.device())
351 .expect("tensor creation from grad_data should succeed"),
352 )
353 }
354
355 fn compute_finite_difference<M: Module, F>(
357 &self,
358 module: &M,
359 input: &Tensor<f32>,
360 parameter: &Parameter,
361 idx: usize,
362 loss_fn: &F,
363 ) -> Result<f32>
364 where
365 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
366 {
367 let param_data = parameter.tensor().read().clone();
368 let original_value = param_data.get_item(&[idx])?;
369
370 let mut param_plus = param_data.clone();
372 param_plus.set_item(&[idx], original_value + self.config.eps as f32)?;
373
374 let output_plus = module.forward(input)?;
377 let loss_plus = loss_fn(&output_plus)?;
378 let loss_plus_scalar = loss_plus.item();
379
380 let mut param_minus = param_data.clone();
382 param_minus.set_item(&[idx], original_value - self.config.eps as f32)?;
383
384 let output_minus = module.forward(input)?;
385 let loss_minus = loss_fn(&output_minus)?;
386 let loss_minus_scalar = loss_minus.item();
387
388 let grad = (loss_plus_scalar? - loss_minus_scalar?) / (2.0 * self.config.eps as f32);
390
391 Ok(grad)
392 }
393
394 fn compute_numerical_gradient_function<F>(
396 &self,
397 func: &F,
398 input: &Tensor<f32>,
399 ) -> Result<Tensor<f32>>
400 where
401 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
402 {
403 let input_shape = input.shape().dims().to_vec();
404 let numel = input.numel();
405
406 let indices_to_check = self.get_indices_to_check(numel);
408
409 let mut grad_data = vec![0.0f32; numel];
411
412 for &idx in &indices_to_check {
413 let original_value = input.get_item(&[idx])?;
415
416 let mut input_plus = input.clone();
418 input_plus.set_item(&[idx], original_value + self.config.eps as f32)?;
419 let output_plus = func(&input_plus)?;
420 let loss_plus_scalar = output_plus.item()?;
421
422 let mut input_minus = input.clone();
424 input_minus.set_item(&[idx], original_value - self.config.eps as f32)?;
425 let output_minus = func(&input_minus)?;
426 let loss_minus_scalar = output_minus.item()?;
427
428 let grad = (loss_plus_scalar - loss_minus_scalar) / (2.0 * self.config.eps as f32);
430 grad_data[idx] = grad;
431 }
432
433 Ok(Tensor::from_data(grad_data, input_shape, input.device())
434 .expect("tensor creation from grad_data should succeed"))
435 }
436
437 fn get_indices_to_check(&self, numel: usize) -> Vec<usize> {
439 if let Some(max_elements) = self.config.max_elements {
440 if numel <= max_elements {
441 (0..numel).collect()
442 } else {
443 #[cfg(feature = "std")]
445 {
446 let mut rng = self.get_rng();
447 let mut indices = HashSet::new();
448
449 while indices.len() < max_elements {
450 let idx = rng.gen_range(0..numel);
451 indices.insert(idx);
452 }
453
454 indices.into_iter().collect()
455 }
456 #[cfg(not(feature = "std"))]
457 {
458 let mut rng = self.get_rng();
460 let mut indices = Vec::new();
461
462 for _ in 0..max_elements.min(numel) {
463 let idx = rng.gen_range(0..numel);
464 if !indices.contains(&idx) {
465 indices.push(idx);
466 }
467 }
468
469 indices
470 }
471 }
472 } else {
473 (0..numel).collect()
474 }
475 }
476
477 fn get_rng(&self) -> Random {
479 if let Some(_seed) = self.config.seed {
480 Random::default()
483 } else {
484 Random::default()
485 }
486 }
487
488 fn compare_gradients(
490 &self,
491 analytical: &Tensor<f32>,
492 numerical: &Tensor<f32>,
493 ) -> Result<(bool, f64, f64, usize)> {
494 let anal_data = analytical.data()?;
495 let num_data = numerical.data()?;
496
497 if anal_data.len() != num_data.len() {
498 return Err(TorshError::InvalidArgument(
499 "Gradient tensors have different sizes".to_string(),
500 ));
501 }
502
503 let mut max_abs_diff: f64 = 0.0;
504 let mut max_rel_diff: f64 = 0.0;
505 let mut all_within_tolerance = true;
506 let mut saw_nan = false;
510
511 for (_i, (&a, &n)) in anal_data.iter().zip(num_data.iter()).enumerate() {
512 let abs_diff = (a as f64 - n as f64).abs();
513 let rel_diff = if n.abs() > 1e-8 {
514 abs_diff / (n as f64).abs()
515 } else {
516 abs_diff
517 };
518
519 if abs_diff.is_nan() || rel_diff.is_nan() {
520 saw_nan = true;
521 all_within_tolerance = false;
522 continue;
523 }
524
525 max_abs_diff = max_abs_diff.max(abs_diff);
526 max_rel_diff = max_rel_diff.max(rel_diff);
527
528 if abs_diff > self.config.atol && rel_diff > self.config.rtol {
529 all_within_tolerance = false;
530 }
531 }
532
533 if saw_nan {
534 max_abs_diff = f64::NAN;
535 max_rel_diff = f64::NAN;
536 }
537
538 Ok((
539 all_within_tolerance,
540 max_abs_diff,
541 max_rel_diff,
542 anal_data.len(),
543 ))
544 }
545}
546
547impl Default for GradChecker {
548 fn default() -> Self {
549 Self::new()
550 }
551}
552
553pub fn gradcheck<M: Module, F>(
555 module: &M,
556 input: &Tensor<f32>,
557 loss_fn: F,
558) -> Result<GradCheckResult>
559where
560 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
561{
562 let checker = GradChecker::new();
563 checker.check_module(module, input, loss_fn)
564}
565
566pub fn fast_gradcheck<M: Module, F>(
568 module: &M,
569 input: &Tensor<f32>,
570 loss_fn: F,
571) -> Result<GradCheckResult>
572where
573 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
574{
575 let config = GradCheckConfig {
576 eps: 1e-4,
577 rtol: 1e-2,
578 atol: 1e-3,
579 max_elements: Some(10),
580 ..Default::default()
581 };
582
583 let checker = GradChecker::with_config(config);
584 checker.check_module(module, input, loss_fn)
585}
586
587pub fn precise_gradcheck<M: Module, F>(
589 module: &M,
590 input: &Tensor<f32>,
591 loss_fn: F,
592) -> Result<GradCheckResult>
593where
594 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
595{
596 let config = GradCheckConfig {
597 eps: 1e-8,
598 rtol: 1e-5,
599 atol: 1e-7,
600 double_precision: true,
601 max_elements: None,
602 ..Default::default()
603 };
604
605 let checker = GradChecker::with_config(config);
606 checker.check_module(module, input, loss_fn)
607}
608
609pub fn gradcheck_function<F>(
614 func: F,
615 input: &Tensor<f32>,
616 config: &GradCheckConfig,
617) -> Result<GradCheckResult>
618where
619 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
620{
621 let checker = GradChecker::with_config(config.clone());
622 checker.check_function(func, input)
623}
624
625pub fn fast_gradcheck_function<F>(func: F, input: &Tensor<f32>) -> Result<GradCheckResult>
627where
628 F: Fn(&Tensor<f32>) -> Result<Tensor<f32>>,
629{
630 let config = GradCheckConfig {
631 eps: 1e-4,
632 rtol: 1e-2,
633 atol: 1e-3,
634 max_elements: Some(10),
635 ..Default::default()
636 };
637 gradcheck_function(func, input, &config)
638}
639
640#[allow(dead_code)]
642trait TensorItemAccess<T> {
643 fn get_item(&self, idx: usize) -> Result<T>;
644 fn set_item(&mut self, idx: usize, value: T) -> Result<()>;
645}
646
647#[allow(dead_code)]
648impl TensorItemAccess<f32> for Tensor<f32> {
649 fn get_item(&self, idx: usize) -> Result<f32> {
650 let data = self.data()?;
651 if idx >= data.len() {
652 return Err(TorshError::InvalidArgument(format!(
653 "Index {} out of bounds for tensor with {} elements",
654 idx,
655 data.len()
656 )));
657 }
658 Ok(data[idx])
659 }
660
661 fn set_item(&mut self, _idx: usize, _value: f32) -> Result<()> {
662 Err(TorshError::UnsupportedOperation {
665 op: "set_item".to_string(),
666 dtype: "tensor mutation not yet supported".to_string(),
667 })
668 }
669}
670
671#[cfg(test)]
672mod tests {
673 use super::*;
674 use torsh_tensor::creation::*;
675
676 #[cfg(feature = "std")]
678 use std::collections::HashMap;
679
680 #[cfg(not(feature = "std"))]
681 use hashbrown::HashMap;
682
683 #[allow(dead_code)]
685 struct LinearModule {
686 weight: Tensor<f32>,
687 bias: Option<Tensor<f32>>,
688 }
689
690 impl LinearModule {
691 #[allow(dead_code)]
692 fn new(in_features: usize, out_features: usize, bias: bool) -> Result<Self> {
693 let weight = randn(&[out_features, in_features])?;
694 let bias = if bias {
695 Some(zeros(&[out_features])?)
696 } else {
697 None
698 };
699
700 Ok(Self { weight, bias })
701 }
702 }
703
704 impl Module for LinearModule {
705 fn forward(&self, input: &Tensor<f32>) -> Result<Tensor<f32>> {
706 let output = input.matmul(&self.weight.transpose(-1, -2)?)?;
707 if let Some(ref bias) = self.bias {
708 output.add_op(bias)
709 } else {
710 Ok(output)
711 }
712 }
713
714 fn parameters(&self) -> HashMap<String, Parameter> {
715 let mut params = HashMap::new();
716 params.insert("weight".to_string(), Parameter::new(self.weight.clone()));
717 if let Some(ref bias) = self.bias {
718 params.insert("bias".to_string(), Parameter::new(bias.clone()));
719 }
720 params
721 }
722
723 fn named_parameters(&self) -> HashMap<String, Parameter> {
724 self.parameters()
725 }
726
727 fn training(&self) -> bool {
728 true
729 }
730 fn train(&mut self) {}
731 fn eval(&mut self) {}
732 fn set_training(&mut self, _training: bool) {}
733 fn to_device(&mut self, _device: torsh_core::DeviceType) -> Result<()> {
734 Ok(())
735 }
736 }
737
738 #[test]
739 fn test_gradcheck_config() {
740 let config = GradCheckConfig::default();
741 assert_eq!(config.eps, 1e-6);
742 assert_eq!(config.rtol, 1e-3);
743 assert_eq!(config.atol, 1e-5);
744 assert_eq!(config.max_elements, Some(100));
745 }
746
747 #[test]
748 fn test_grad_checker_creation() {
749 let checker = GradChecker::new();
750 assert_eq!(checker.config.eps, 1e-6);
751
752 let custom_config = GradCheckConfig {
753 eps: 1e-4,
754 ..Default::default()
755 };
756 let custom_checker = GradChecker::with_config(custom_config);
757 assert_eq!(custom_checker.config.eps, 1e-4);
758 }
759
760 #[test]
761 fn test_parameter_grad_check_result() {
762 let result = ParameterGradCheckResult {
763 name: "test_param".to_string(),
764 passed: true,
765 max_abs_diff: 1e-6,
766 max_rel_diff: 1e-5,
767 elements_checked: 100,
768 error: None,
769 };
770
771 assert_eq!(result.name, "test_param");
772 assert!(result.passed);
773 assert_eq!(result.elements_checked, 100);
774 assert!(result.error.is_none());
775 }
776
777 #[test]
778 fn test_grad_check_result() {
779 let param_results = vec![
780 ParameterGradCheckResult {
781 name: "param1".to_string(),
782 passed: true,
783 max_abs_diff: 1e-6,
784 max_rel_diff: 1e-5,
785 elements_checked: 50,
786 error: None,
787 },
788 ParameterGradCheckResult {
789 name: "param2".to_string(),
790 passed: false,
791 max_abs_diff: 1e-2,
792 max_rel_diff: 1e-1,
793 elements_checked: 50,
794 error: None,
795 },
796 ];
797
798 let result = GradCheckResult {
799 passed: false,
800 parameter_results: param_results,
801 summary: "1 out of 2 parameters failed gradient check".to_string(),
802 };
803
804 assert!(!result.passed);
805 assert_eq!(result.failed_parameters().len(), 1);
806 assert_eq!(result.worst_parameter().unwrap().name, "param2");
807 }
808
809 #[test]
810 fn test_indices_selection() {
811 let checker = GradChecker::new();
812
813 let indices = checker.get_indices_to_check(50);
815 assert_eq!(indices.len(), 50);
816
817 let indices = checker.get_indices_to_check(1000);
819 assert_eq!(indices.len(), 100); }
821
822 #[test]
823 fn test_convenience_functions() {
824 assert!(true); }
827
828 #[test]
833 fn test_module_gradcheck_fails_loudly_not_silently() {
834 let module = LinearModule::new(3, 2, true).expect("module creation should succeed");
835 let input = randn(&[1, 3]).expect("input creation should succeed");
836
837 let result = gradcheck(&module, &input, |output| output.sum());
838
839 let result = result.expect("check_module aggregates results into Ok");
843 assert!(
844 !result.passed,
845 "module gradcheck must not pass while analytical gradients are unimplemented"
846 );
847 assert!(
848 !result.parameter_results.is_empty(),
849 "expected at least one parameter to be checked"
850 );
851 for param_result in &result.parameter_results {
852 assert!(
853 !param_result.passed,
854 "parameter '{}' was silently passed; a zero analytical gradient must never \
855 trivially pass a gradient check",
856 param_result.name
857 );
858 let err = param_result
859 .error
860 .as_ref()
861 .expect("a failed module parameter must carry an explanatory error");
862 assert!(
863 err.contains("autograd") || err.contains("gradcheck_function"),
864 "error should explain why module gradcheck cannot run, got: {err}"
865 );
866 }
867 }
868}