//! Central finite-difference gradient check. /// A scalar loss as a function of a flat parameter vector with a given shape. /// `(data, shape) -> loss`. The closure owns how `data` becomes a `Tensor` /// (e.g. `Tensor::from_slice(data, shape).to_device(...)`) and runs forward. pub type ParamFn<'a> = dyn Fn(&[f32], &[usize]) -> f32 + 'a; #[derive(Debug, Clone, Copy)] pub struct GradCheckConfig { /// Perturbation magnitude per element. pub eps: f32, /// Max allowed relative error: `|num - ana| / (|num| + |ana| + atol)`. pub rel_tol: f32, /// Absolute floor in the denominator, so near-zero grads don't blow up the /// relative error. pub atol: f32, } impl Default for GradCheckConfig { fn default() -> Self { // eps=1e-3 balances truncation error (∝ eps²) against the ~1e-7 f32 // rounding noise on f(x±eps); rel_tol=2e-2 is the usual slack for an // f32 GPU GEMM checked against an f32 central difference. Self { eps: 1e-3, rel_tol: 2e-2, atol: 1e-4, } } } #[derive(Debug, Clone)] pub struct GradCheckResult { pub passed: bool, pub max_rel_err: f32, /// Index of the worst element (largest relative error). pub worst_index: usize, pub worst_numeric: f32, pub worst_analytic: f32, } /// Check `analytic_grad` against the central finite difference of `f` at `x`. /// /// - `x`: flat parameter values (the point at which the gradient is taken). /// - `shape`: logical shape passed through to `f`. /// - `f`: scalar loss; called `2 * x.len()` times. /// - `analytic_grad`: candidate gradient, same length as `x`. pub fn grad_check( x: &[f32], shape: &[usize], f: &ParamFn, analytic_grad: &[f32], cfg: GradCheckConfig, ) -> GradCheckResult { assert_eq!( x.len(), analytic_grad.len(), "param/grad length mismatch: {} vs {}", x.len(), analytic_grad.len() ); let mut perturbed = x.to_vec(); let mut max_rel_err = 0.0f32; let mut worst_index = 0; let mut worst_numeric = 0.0f32; let mut worst_analytic = 0.0f32; for i in 0..x.len() { let orig = x[i]; perturbed[i] = orig + cfg.eps; let f_plus = f(&perturbed, shape); perturbed[i] = orig - cfg.eps; let f_minus = f(&perturbed, shape); perturbed[i] = orig; // restore for the next element let numeric = (f_plus - f_minus) / (2.0 * cfg.eps); let analytic = analytic_grad[i]; let rel_err = (numeric - analytic).abs() / (numeric.abs() + analytic.abs() + cfg.atol); if rel_err > max_rel_err { max_rel_err = rel_err; worst_index = i; worst_numeric = numeric; worst_analytic = analytic; } } GradCheckResult { passed: max_rel_err <= cfg.rel_tol, max_rel_err, worst_index, worst_numeric, worst_analytic, } } #[cfg(test)] mod tests { use super::*; // Host-only sanity check (no GPU): loss = sum(x²), grad = 2x. #[test] fn quadratic_grad_check() { let x = vec![1.0f32, -2.0, 3.0, 0.5]; let f = |v: &[f32], _shape: &[usize]| v.iter().map(|t| t * t).sum::(); let grad: Vec = x.iter().map(|t| 2.0 * t).collect(); let res = grad_check(&x, &[4], &f, &grad, GradCheckConfig::default()); assert!(res.passed, "max_rel_err = {}", res.max_rel_err); } // A deliberately wrong gradient must be rejected. #[test] fn wrong_grad_is_rejected() { let x = vec![1.0f32, 2.0, 3.0]; let f = |v: &[f32], _shape: &[usize]| v.iter().map(|t| t * t).sum::(); let bad_grad = vec![0.0f32, 0.0, 0.0]; let res = grad_check(&x, &[3], &f, &bad_grad, GradCheckConfig::default()); assert!(!res.passed); } }