//! BCE forward + backward validation. //! //! Per `feedback_no_cpu_test_fallbacks.md`: no CPU oracle. The gradient //! is validated against finite-difference computed via GPU forward calls //! at perturbed inputs (the kernel itself is the reference; we only //! verify that the analytic grad matches its own numerical derivative). use approx::assert_relative_eq; use ml_alpha::trainer::loss::{bce_multi_horizon_loss_and_grad_gpu, BceInput}; use ml_core::device::MlDevice; fn test_device() -> MlDevice { MlDevice::cuda(0).expect("CUDA 0 required for ml-alpha tests") } fn make_input(probs: Vec, labels: Vec) -> BceInput { BceInput { probs, labels, n_horizons: 5, n_pos: 4 } } #[test] fn bce_loss_is_positive_and_finite() { let dev = test_device(); let probs: Vec = (0..20).map(|i| (0.1 + i as f32 * 0.04).min(0.95)).collect(); let labels: Vec = (0..20).map(|i| (i % 2) as f32).collect(); let out = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(probs, labels)).unwrap(); assert!(out.loss.is_finite()); assert!(out.loss > 0.0); assert_eq!(out.n_valid, 20); } #[test] fn bce_grad_zero_when_probs_match_labels() { let dev = test_device(); // probs ≈ labels (within clamp window): grad ≈ 0 wherever |p-y| ≈ 0 let mut probs = vec![0.5_f32; 20]; let labels: Vec = (0..20).map(|i| (i % 2) as f32).collect(); // Move probs very close to labels (but within (1e-6, 1-1e-6) clamp window). for i in 0..20 { probs[i] = if labels[i] > 0.5 { 1.0 - 1e-5 } else { 1e-5 }; } let out = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(probs, labels)).unwrap(); // Near-perfect predictions → near-zero loss assert!(out.loss < 1e-3, "loss should be small for matching probs/labels, got {}", out.loss); } #[test] fn bce_grad_matches_finite_difference() { let dev = test_device(); let probs: Vec = (0..20).map(|i| (0.1 + i as f32 * 0.04).min(0.95)).collect(); let labels: Vec = (0..20).map(|i| (i % 2) as f32).collect(); let base = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(probs.clone(), labels.clone())).unwrap(); let eps = 1e-3_f32; for i in [0usize, 5, 10, 15, 19] { let mut up = probs.clone(); up[i] += eps; let mut dn = probs.clone(); dn[i] -= eps; let lu = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(up, labels.clone())).unwrap().loss; let ld = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(dn, labels.clone())).unwrap().loss; let fd = (lu - ld) / (2.0 * eps); assert_relative_eq!( base.grad_probs[i], fd, epsilon = 5e-3, max_relative = 5e-2 ); } } #[test] fn bce_nan_labels_mask_grad_and_loss() { let dev = test_device(); let mut labels: Vec = (0..20).map(|i| (i % 2) as f32).collect(); // Mask out indices 5, 10, 15 labels[5] = f32::NAN; labels[10] = f32::NAN; labels[15] = f32::NAN; let probs: Vec = (0..20).map(|i| (0.1 + i as f32 * 0.04).min(0.95)).collect(); let out = bce_multi_horizon_loss_and_grad_gpu(&dev, &make_input(probs, labels)).unwrap(); assert_eq!(out.n_valid, 17, "should drop 3 NaN-masked entries"); for &i in &[5usize, 10, 15] { assert_eq!(out.grad_probs[i], 0.0, "masked grad should be 0 at index {i}, got {}", out.grad_probs[i]); } }