#![allow( clippy::assertions_on_constants, clippy::assertions_on_result_states, clippy::clone_on_copy, clippy::decimal_literal_representation, clippy::doc_markdown, clippy::empty_line_after_doc_comments, clippy::field_reassign_with_default, clippy::get_unwrap, clippy::identity_op, clippy::inconsistent_digit_grouping, clippy::indexing_slicing, clippy::integer_division, clippy::len_zero, clippy::let_underscore_must_use, clippy::manual_div_ceil, clippy::manual_let_else, clippy::manual_range_contains, clippy::modulo_arithmetic, clippy::needless_range_loop, clippy::non_ascii_literal, clippy::redundant_clone, clippy::shadow_reuse, clippy::shadow_same, clippy::shadow_unrelated, clippy::single_match_else, clippy::str_to_string, clippy::string_slice, clippy::tests_outside_test_module, clippy::too_many_lines, clippy::unnecessary_wraps, clippy::unseparated_literal_suffix, clippy::use_debug, clippy::useless_vec, clippy::wildcard_enum_match_arm, clippy::else_if_without_else, clippy::expect_used, clippy::missing_const_for_fn, clippy::similar_names, clippy::type_complexity, clippy::collapsible_else_if, clippy::doc_lazy_continuation, clippy::items_after_test_module, clippy::map_clone, clippy::multiple_unsafe_ops_per_block, clippy::unwrap_or_default, clippy::assign_op_pattern, clippy::needless_borrow, clippy::println_empty_string, clippy::unnecessary_cast, clippy::used_underscore_binding, clippy::create_dir, clippy::implicit_saturating_sub, clippy::exit, clippy::expect_fun_call, clippy::too_many_arguments, clippy::unnecessary_map_or, clippy::unwrap_used, dead_code, unused_imports, unused_variables, clippy::cloned_ref_to_slice_refs, clippy::neg_multiply, clippy::while_let_loop, clippy::bool_assert_comparison, clippy::excessive_precision, clippy::trivially_copy_pass_by_ref, clippy::op_ref, clippy::redundant_closure, clippy::unnecessary_lazy_evaluations, clippy::if_then_some_else_none, clippy::unnecessary_to_owned, clippy::single_component_path_imports, )] //! Huber Loss Tests //! //! Test suite for Huber loss implementation in DQN. //! Huber loss = MSE for small errors, L1 for large errors (robust to outliers). //! //! Formula: //! ``` //! L(x) = { //! 0.5 * x² if |x| <= delta //! delta * (|x| - 0.5 * delta) otherwise //! } //! ``` use anyhow::Result; use tracing::info; /// Huber loss: quadratic for small errors, linear for large errors /// More robust to outliers than MSE /// /// This is a host-side implementation operating on plain f32 slices. /// Suitable for correctness tests (not a GPU hot path). fn huber_loss(predictions: &[f32], targets: &[f32], delta: f32) -> Result { assert_eq!( predictions.len(), targets.len(), "predictions and targets must have the same length" ); assert!(!predictions.is_empty(), "inputs must be non-empty"); let mut total_loss = 0.0_f32; for (pred, tgt) in predictions.iter().zip(targets.iter()) { let error = pred - tgt; let abs_error = error.abs(); let loss = if abs_error <= delta { // Quadratic region 0.5 * error * error } else { // Linear region delta * (abs_error - 0.5 * delta) }; total_loss += loss; } // Return mean loss Ok(total_loss / predictions.len() as f32) } #[test] fn test_huber_small_error_quadratic() -> Result<()> { // Test 1: Small error (0.5, delta=1.0) -> quadratic behavior // Expected: 0.5 * 0.5^2 = 0.5 * 0.25 = 0.125 let predictions = [1.5_f32]; let targets = [1.0_f32]; let delta = 1.0; let loss_value = huber_loss(&predictions, &targets, delta)?; let expected = 0.125_f32; assert!( (loss_value - expected).abs() < 1e-5, "Small error loss incorrect: got {}, expected {}", loss_value, expected ); info!(loss_value, "Test 1 passed: Small error (0.5) quadratic behavior"); Ok(()) } #[test] fn test_huber_large_error_linear() -> Result<()> { // Test 2: Large error (5.0, delta=1.0) -> linear behavior // Expected: 1.0 * (5.0 - 0.5 * 1.0) = 1.0 * 4.5 = 4.5 let predictions = [6.0_f32]; let targets = [1.0_f32]; let delta = 1.0; let loss_value = huber_loss(&predictions, &targets, delta)?; let expected = 4.5_f32; assert!( (loss_value - expected).abs() < 1e-5, "Large error loss incorrect: got {}, expected {}", loss_value, expected ); info!(loss_value, "Test 2 passed: Large error (5.0) linear behavior"); Ok(()) } #[test] fn test_huber_threshold_smooth_transition() -> Result<()> { // Test 3: Error at threshold (1.0, delta=1.0) -> smooth transition // Quadratic: 0.5 * 1.0^2 = 0.5 // Linear: 1.0 * (1.0 - 0.5 * 1.0) = 1.0 * 0.5 = 0.5 // Both formulas should give same result at threshold let predictions = [2.0_f32]; let targets = [1.0_f32]; let delta = 1.0; let loss_value = huber_loss(&predictions, &targets, delta)?; let expected = 0.5_f32; assert!( (loss_value - expected).abs() < 1e-5, "Threshold error loss incorrect: got {}, expected {}", loss_value, expected ); info!(loss_value, "Test 3 passed: Error at threshold (1.0) smooth transition"); Ok(()) } #[test] fn test_huber_negative_errors() -> Result<()> { // Test 4: Negative errors handled correctly (symmetry) // Error of -5.0 should give same loss as +5.0 // Positive error let pred_pos = [6.0_f32]; let target_pos = [1.0_f32]; let loss_pos_value = huber_loss(&pred_pos, &target_pos, 1.0)?; // Negative error let pred_neg = [-4.0_f32]; let target_neg = [1.0_f32]; let loss_neg_value = huber_loss(&pred_neg, &target_neg, 1.0)?; assert!( (loss_pos_value - loss_neg_value).abs() < 1e-5, "Negative error loss asymmetric: pos={}, neg={}", loss_pos_value, loss_neg_value ); info!(loss_pos_value, loss_neg_value, "Test 4 passed: Negative errors handled correctly"); Ok(()) } #[test] fn test_huber_batch_mixed_errors() -> Result<()> { // Test 5: Batch of errors (mixed small/large) // Errors: [0.5, 2.0, 5.0, 0.1] with delta=1.0 // Expected losses: // 0.5: 0.5 * 0.5^2 = 0.125 (quadratic) // 2.0: 1.0 * (2.0 - 0.5) = 1.5 (linear) // 5.0: 1.0 * (5.0 - 0.5) = 4.5 (linear) // 0.1: 0.5 * 0.1^2 = 0.005 (quadratic) // Average: (0.125 + 1.5 + 4.5 + 0.005) / 4 = 6.13 / 4 = 1.5325 let predictions = [1.5_f32, 3.0, 6.0, 1.1]; let targets = [1.0_f32, 1.0, 1.0, 1.0]; let delta = 1.0; let loss_value = huber_loss(&predictions, &targets, delta)?; let expected = 1.5325_f32; assert!( (loss_value - expected).abs() < 1e-3, "Batch loss incorrect: got {}, expected {}", loss_value, expected ); info!(loss_value, "Test 5 passed: Batch of mixed errors average loss"); Ok(()) } #[test] fn test_huber_gradient_bounded() -> Result<()> { // Test 6: Gradient is bounded for large errors // For MSE: gradient = 2 * error (unbounded, grows linearly) // For Huber: gradient = delta for |error| > delta (bounded) // // Error of 100.0 with delta=1.0: // MSE gradient would be 200 (2 * 100) // Huber gradient is bounded by delta=1.0 // // We verify this by checking that large errors don't cause // disproportionately large losses (which would indicate large gradients) // Small outlier: error=10 let pred_small = [11.0_f32]; let target_small = [1.0_f32]; let loss_small_value = huber_loss(&pred_small, &target_small, 1.0)?; // Large outlier: error=100 let pred_large = [101.0_f32]; let target_large = [1.0_f32]; let loss_large_value = huber_loss(&pred_large, &target_large, 1.0)?; // Huber loss should grow linearly with error size (not quadratically) // loss_large / loss_small should be approximately 100/10 = 10 (linear growth) // For MSE it would be (100^2)/(10^2) = 100 (quadratic growth) let ratio = loss_large_value / loss_small_value; assert!( ratio > 8.0 && ratio < 12.0, "Gradient not bounded: loss ratio {} (expected ~10 for linear growth, ~100 for quadratic)", ratio ); info!(ratio, "Test 6 passed: Gradient bounded for large errors, linear growth confirmed"); Ok(()) } #[test] fn test_huber_vs_mse_convergence() -> Result<()> { // Test 7: Huber converges faster than MSE on outlier data // Simulate training data with outliers: [1.0, 1.1, 0.9, 10.0, 1.05] // The outlier (10.0) should have less influence on Huber loss let predictions = [2.0_f32, 2.1, 1.9, 11.0, 2.05]; let targets = [1.0_f32, 1.1, 0.9, 10.0, 1.05]; let delta = 1.0; // Huber loss let huber_value = huber_loss(&predictions, &targets, delta)?; // MSE loss (computed manually) let mse_value: f32 = predictions .iter() .zip(targets.iter()) .map(|(p, t)| (p - t) * (p - t)) .sum::() / predictions.len() as f32; // Huber should be significantly smaller than MSE due to outlier robustness // The outlier (error=1.0) contributes: // MSE: 1.0^2 = 1.0 // Huber: 1.0 * (1.0 - 0.5) = 0.5 // So Huber should be approximately half of MSE assert!( huber_value < mse_value, "Huber loss should be smaller than MSE for outlier data: huber={}, mse={}", huber_value, mse_value ); let reduction = (mse_value - huber_value) / mse_value * 100.0; info!(mse_value, huber_value, reduction, "Test 7 passed: Huber converges better on outliers"); Ok(()) } #[test] fn test_huber_different_deltas() -> Result<()> { // Additional test: Different delta values affect transition point let predictions = [3.0_f32]; let targets = [1.0_f32]; // Error = 2.0 // With delta=1.0: error=2.0 is large (linear) let loss1 = huber_loss(&predictions, &targets, 1.0)?; // With delta=3.0: error=2.0 is small (quadratic) let loss3 = huber_loss(&predictions, &targets, 3.0)?; // delta=1.0: 1.0 * (2.0 - 0.5 * 1.0) = 1.5 (linear) // delta=3.0: 0.5 * 2.0^2 = 2.0 (quadratic) assert!( (loss1 - 1.5).abs() < 1e-5, "Delta=1.0 loss incorrect: got {}, expected 1.5", loss1 ); assert!( (loss3 - 2.0).abs() < 1e-5, "Delta=3.0 loss incorrect: got {}, expected 2.0", loss3 ); info!(loss_delta1 = loss1, loss_delta3 = loss3, "Additional test passed: Different deltas work correctly"); Ok(()) }