Files
foxhunt/ml/tests/dqn_gradient_clipping_bug32.rs
jgrusewski c645e6222d Wave 11: Rainbow DQN integration + 23/23 tests passing
CRITICAL FINDINGS from 3-trial validation:
- 85,120 gradient clipping warnings (81.6% of logs) - REGRESSION
- Rainbow features DISABLED: use_dueling=false, use_distributional=false, use_noisy_nets=false
- Negative Q-values confirmed: HOLD -1000 to -3250
- Performance: Sharpe 0.29 (target 0.77)

Changes:
- Fixed N-Step compilation (7/7 tests passing)
- Fixed Distributional compilation (6/6 tests passing)
- Fixed Dueling CUDA errors (10/10 tests passing)
- Added TDD validation for state_dim=225
- Total: 23/23 Wave 11 tests passing (100%)

Issues requiring investigation:
1. Why are Dueling/Distributional/Noisy disabled in hyperopt?
2. Why gradient explosion despite previous fixes?
3. Test coverage gaps - unit tests pass but integration fails

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:53:59 +01:00

335 lines
12 KiB
Rust

// Bug #32: DQN Gradient Clipping - TDD Test Suite
//
// CRITICAL BUG: Gradient clipping is completely disabled (NO-OP stub)
// - Current: `fn clip_gradients(&self, max_norm: f32) -> Result<(), MLError> { Ok(()) }`
// - Reality: Gradients exploding to 20K-25K instead of being clipped to 10.0
// - Impact: Weight corruption → Q-value collapse → training failure
//
// These tests will FAIL with current code, PASS after implementing:
// `candle_nn::optim::clip_grad_norm(&varmap.all_vars(), max_norm as f64)?`
use candle_core::{Device, Tensor};
use candle_nn::{AdamW, Optimizer, VarBuilder, VarMap, optim, Init};
use ml::gradient_utils::clip_grad_norm;
use ml::MLError;
/// Test 1: Verify gradient norm calculation is working
///
/// This test ensures we can correctly compute the global L2 norm of all gradients.
/// With NO-OP clipping: This test will PASS (norm calculation is separate)
/// After fix: This test will PASS (norm calculation unchanged)
#[test]
fn test_gradient_norm_calculation() -> Result<(), MLError> {
let device = Device::Cpu;
// Create simple VarMap with known parameters
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Create a 2x2 weight tensor initialized to 1.0 to ensure non-zero gradients
let weight = vb.get_with_hints((2, 2), "weight", Init::Const(1.0))?;
// Create a simple loss: sum of squares
let loss = weight.sqr()?.sum_all()?;
// Compute gradients
let mut grads = loss.backward()?;
// Calculate gradient norm manually
// gradient of sum(w^2) w.r.t. w = 2w
// If w is initialized to ~0.01, gradient ~0.02, norm should be small
let (grad_norm, _) = clip_grad_norm(&varmap.all_vars(), &mut grads, 999999.0)?;
// Gradient norm should be positive and finite
assert!(grad_norm > 0.0, "Gradient norm should be positive, got {}", grad_norm);
assert!(grad_norm.is_finite(), "Gradient norm should be finite, got {}", grad_norm);
assert!(grad_norm < 100.0, "Gradient norm should be reasonable for this simple case, got {}", grad_norm);
println!("✓ Test 1 PASS: Gradient norm = {:.6}", grad_norm);
Ok(())
}
/// Test 2: Verify gradient clipping enforcement (CRITICAL - will FAIL with NO-OP)
///
/// This test creates artificially large gradients and verifies they get clipped.
/// With NO-OP clipping: This test will FAIL (gradients unchanged)
/// After fix: This test will PASS (gradients scaled down)
#[test]
fn test_gradient_clipping_enforcement() -> Result<(), MLError> {
let device = Device::Cpu;
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Create weight initialized to large values to produce large gradients
let weight = vb.get((10, 10), "large_weight")?;
// Create loss that will produce gradients ~100x larger than clip threshold
// Loss = 1000 * sum(w^2), gradient = 2000 * w
let loss = (weight.sqr()? * 1000.0)?.sum_all()?;
// Compute gradients
let mut grads = loss.backward()?;
// Measure gradient norm BEFORE clipping (use very large max_norm to just measure, not clip)
let (norm_before, _) = clip_grad_norm(&varmap.all_vars(), &mut grads, 999999.0)?;
println!("Gradient norm before clipping: {:.2}", norm_before);
// Re-compute gradients since we need to clip them fresh
// (backward() consumes the computational graph, so we need a fresh loss)
let loss2 = (weight.sqr()? * 1000.0)?.sum_all()?;
let mut grads2 = loss2.backward()?;
// Now clip to max_norm = 10.0
let max_norm = 10.0;
let (_, norm_after) = clip_grad_norm(&varmap.all_vars(), &mut grads2, max_norm)?;
println!("Gradient norm after clipping to {}: {:.2}", max_norm, norm_after);
// CRITICAL ASSERTION: After clipping, norm should be <= max_norm
assert!(
norm_after <= max_norm + 1e-5,
"FAIL: Gradient norm ({}) exceeds max_norm ({}) - clipping not working!",
norm_after,
max_norm
);
// If norm_before was > max_norm, then norm_after should be reduced
if norm_before > max_norm {
assert!(
norm_after < norm_before,
"FAIL: Gradient norm should decrease after clipping (before: {}, after: {})",
norm_before,
norm_after
);
}
println!("✓ Test 2 PASS: Gradients clipped from {:.2} to {:.2}", norm_before, norm_after);
Ok(())
}
/// Test 3: Verify no clipping when below threshold
///
/// This test ensures gradients below max_norm are NOT modified.
/// With NO-OP clipping: This test will PASS (no clipping occurs)
/// After fix: This test will PASS (correct behavior)
#[test]
fn test_no_clipping_when_below_threshold() -> Result<(), MLError> {
let device = Device::Cpu;
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Create small weight to produce small gradients
let weight = vb.get((2, 2), "small_weight")?;
// Small loss → small gradients
let loss = (weight.sqr()? * 0.01)?.sum_all()?;
let mut grads = loss.backward()?;
// Measure gradient norm (use very large max_norm to just measure, not clip)
let (norm_before, _) = clip_grad_norm(&varmap.all_vars(), &mut grads, 999999.0)?;
println!("Gradient norm (small): {:.6}", norm_before);
// Re-compute gradients for second measurement
let loss2 = (weight.sqr()? * 0.01)?.sum_all()?;
let mut grads2 = loss2.backward()?;
// Clip with large max_norm (should have no effect)
let max_norm = 100.0;
let (norm_after, _) = clip_grad_norm(&varmap.all_vars(), &mut grads2, max_norm)?;
// Norm should be unchanged (within floating point tolerance)
let tolerance = 1e-6;
assert!(
(norm_after - norm_before).abs() < tolerance,
"FAIL: Gradient norm changed when it shouldn't have (before: {}, after: {}, diff: {})",
norm_before,
norm_after,
(norm_after - norm_before).abs()
);
println!("✓ Test 3 PASS: Small gradients unchanged ({:.6})", norm_after);
Ok(())
}
/// Test 4: Edge case - zero gradients
///
/// This test ensures clipping doesn't break when gradients are zero.
/// With NO-OP clipping: This test will PASS (no operation = no crash)
/// After fix: This test will PASS (clip_grad_norm handles zero gracefully)
#[test]
fn test_zero_gradients_edge_case() -> Result<(), MLError> {
let device = Device::Cpu;
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Create weight
let weight = vb.get((3, 3), "weight")?;
// Zero loss → zero gradients
let loss = (weight * 0.0)?.sum_all()?;
let mut grads = loss.backward()?;
// Clip should handle zero gradients gracefully
let (norm, _) = clip_grad_norm(&varmap.all_vars(), &mut grads, 10.0)?;
// Norm should be zero or very close
assert!(
norm < 1e-10,
"FAIL: Expected near-zero gradient norm, got {}",
norm
);
assert!(norm.is_finite(), "FAIL: Gradient norm should be finite, got {}", norm);
println!("✓ Test 4 PASS: Zero gradients handled correctly (norm = {:.10})", norm);
Ok(())
}
/// Test 5: Integration - verify clipping in DQN training loop order
///
/// This test ensures gradient clipping happens in the correct order:
/// 1. loss.backward() - compute gradients
/// 2. clip_gradients() - clip gradients IN-PLACE
/// 3. optimizer.step() - apply clipped gradients
///
/// With NO-OP clipping: This test will FAIL (large update will occur)
/// After fix: This test will PASS (update size constrained)
#[test]
fn test_clipping_integration_order() -> Result<(), MLError> {
let device = Device::Cpu;
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Create weight
let weight = vb.get((5, 5), "integration_weight")?;
// Get initial weight values
let weight_before = weight.to_vec2::<f32>()?;
// Create large loss → large gradients
let loss = (weight.sqr()? * 5000.0)?.sum_all()?;
// Step 1: Backward pass (compute gradients)
let mut grads = loss.backward()?;
// Step 2: Clip gradients (max_norm = 1.0, very strict)
let max_norm = 1.0;
let (_, grad_norm) = clip_grad_norm(&varmap.all_vars(), &mut grads, max_norm)?;
println!("Gradient norm after clipping: {:.6}", grad_norm);
// Verify clipping occurred
assert!(
grad_norm <= max_norm + 1e-5,
"FAIL: Gradient norm ({}) exceeds max_norm ({})",
grad_norm,
max_norm
);
// Step 3: Optimizer step (apply clipped gradients)
let learning_rate = 0.1;
let mut optimizer = AdamW::new(varmap.all_vars(), optim::ParamsAdamW {
lr: learning_rate,
..Default::default()
})?;
optimizer.step(&grads)?;
// Get updated weight values
let weight_after = weight.to_vec2::<f32>()?;
// Calculate max absolute change
let mut max_change = 0.0f32;
for i in 0..weight_before.len() {
for j in 0..weight_before[i].len() {
let change = (weight_after[i][j] - weight_before[i][j]).abs();
max_change = max_change.max(change);
}
}
println!("Max weight change: {:.6}", max_change);
// With gradient clipping, weight changes should be bounded
// max_change ≈ learning_rate * max_norm / sqrt(num_params)
// For our case: ~0.1 * 1.0 / sqrt(25) = ~0.02
let expected_max_change = (learning_rate * max_norm * 2.0) as f32; // 2x safety factor
assert!(
max_change < expected_max_change,
"FAIL: Weight change ({}) too large, suggests clipping not working (expected < {})",
max_change,
expected_max_change
);
println!("✓ Test 5 PASS: Integration test - clipping constrained weight updates");
Ok(())
}
/// Test 6: Regression test - verify fix for Bug #32
///
/// This test simulates the exact bug scenario: gradients exploding to 20K+
/// With NO-OP clipping: This test will FAIL (gradients > 10K)
/// After fix: This test will PASS (gradients clipped to ≤10.0)
#[test]
fn test_bug32_regression_gradient_explosion() -> Result<(), MLError> {
let device = Device::Cpu;
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, candle_core::DType::F32, &device);
// Simulate DQN network size (3 layers, ~1000 params total)
// Initialize to 1.0 to ensure non-zero gradients for this test
let layer1 = vb.get_with_hints((20, 20), "layer1", Init::Const(1.0))?;
let layer2 = vb.get_with_hints((20, 10), "layer2", Init::Const(1.0))?;
let layer3 = vb.get_with_hints((10, 5), "layer3", Init::Const(1.0))?;
// Create loss that produces exploding gradients (simulates large TD-error)
// In real DQN, this happens when Q-values are unstable
let huge_multiplier = 10000.0; // Simulate TD-error of 10K
let loss = ((layer1.sqr()?.sum_all()? + layer2.sqr()?.sum_all()? + layer3.sqr()?.sum_all()?)? * huge_multiplier)?;
// Compute gradients
let mut grads = loss.backward()?;
// Measure gradient norm before clipping (use very large max_norm to just measure)
let (norm_before, _) = clip_grad_norm(&varmap.all_vars(), &mut grads, 999999.0)?;
println!("Bug #32 scenario - Gradient norm before clipping: {:.2}", norm_before);
// This should be HUGE (>1000) simulating the bug
assert!(
norm_before > 100.0,
"Test setup error: Expected large gradients to simulate bug, got {}",
norm_before
);
// Re-compute gradients for clipping test
let loss2 = ((layer1.sqr()?.sum_all()? + layer2.sqr()?.sum_all()? + layer3.sqr()?.sum_all()?)? * huge_multiplier)?;
let mut grads2 = loss2.backward()?;
// Apply DQN's gradient clipping threshold
let dqn_max_norm = 10.0;
let (_, norm_after) = clip_grad_norm(&varmap.all_vars(), &mut grads2, dqn_max_norm)?;
println!("Bug #32 scenario - Gradient norm after clipping: {:.2}", norm_after);
// CRITICAL: This is the bug fix verification
assert!(
norm_after <= dqn_max_norm + 1e-5,
"BUG #32 NOT FIXED: Gradient norm ({}) exceeds DQN max_norm ({})",
norm_after,
dqn_max_norm
);
// Verify significant reduction occurred
let reduction_ratio = norm_after / norm_before;
assert!(
reduction_ratio < 0.1,
"BUG #32 NOT FIXED: Gradient norm reduction insufficient ({}x → {}x, ratio: {:.4})",
norm_before,
norm_after,
reduction_ratio
);
println!(
"✓ Test 6 PASS: BUG #32 FIXED - Gradients clipped from {:.2} to {:.2} ({:.2}% reduction)",
norm_before,
norm_after,
(1.0 - reduction_ratio) * 100.0
);
Ok(())
}