Files
foxhunt/ml/tests/dqn_gradient_flow_isolation_test.rs
jgrusewski 27ada2ff58 fix(ml): fix test files using wrong foxhunt_ml:: crate name
Replaced foxhunt_ml:: with ml:: in 4 test files:
- dqn_full_gradient_flow_integration_test.rs
- dqn_gradient_flow_isolation_test.rs
- tft_int8_forward_integration_test.rs
- tft_int8_integration_test.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 13:40:25 +01:00

433 lines
15 KiB
Rust

// Test suite to isolate DQN gradient collapse root cause
// Purpose: Systematically verify gradient flow through individual components
// Created: 2025-11-21 (Test-Driven Development Campaign)
use candle_core::{DType, Device, Tensor, Var};
use candle_nn::{linear, AdamW, Linear, Optimizer, VarBuilder, VarMap};
use ml::dqn::dqn::{DistributionalDuelingConfig, DistributionalDuelingQNetwork};
use ml::error::MLError;
/// Test 1: Network Forward Pass Gradient Flow
/// Goal: Verify gradients flow through DistributionalDuelingQNetwork.forward()
/// Expected: grad_norm ∈ [0.01, 1.0]
#[test]
fn test_network_forward_has_gradients() -> Result<(), MLError> {
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// Create network with minimal config
let config = DistributionalDuelingConfig {
input_dim: 54, // Standard DQN feature count
hidden_dims: vec![128, 64], // Small network for fast test
n_actions: 45,
n_atoms: 51,
v_min: -2.0,
v_max: 2.0,
};
let network = DistributionalDuelingQNetwork::new(config, device.clone())?;
// Create dummy input [batch=8, features=54]
let batch_size = 8;
let input = Tensor::randn(0f32, 1.0, (batch_size, 54), &device)?;
// Forward pass
let output = network.forward(&input)?;
// Compute simple loss: mean of all outputs
let loss = output.mean_all()?;
println!("[Test 1] Loss value: {:?}", loss.to_scalar::<f32>()?);
// Backward pass
let grads = loss.backward()?;
// Check all network parameters have non-zero gradients
let vars = network.vars();
let all_vars = vars.all_vars();
let mut total_grad_norm = 0.0f32;
let mut zero_grad_count = 0;
let mut total_param_count = 0;
for (name, var) in all_vars.iter() {
total_param_count += 1;
if let Some(grad) = grads.get(var) {
let grad_norm = grad.sqr()?.sum_all()?.sqrt()?.to_scalar::<f32>()?;
total_grad_norm += grad_norm;
if grad_norm < 1e-9 {
zero_grad_count += 1;
println!("[Test 1] WARNING: Zero gradient for parameter: {}", name);
} else {
println!("[Test 1] Parameter: {}, grad_norm: {:.6}", name, grad_norm);
}
} else {
zero_grad_count += 1;
println!("[Test 1] ERROR: No gradient for parameter: {}", name);
}
}
let avg_grad_norm = total_grad_norm / total_param_count as f32;
println!("[Test 1] Total parameters: {}", total_param_count);
println!("[Test 1] Zero gradient count: {}", zero_grad_count);
println!("[Test 1] Average grad_norm: {:.6}", avg_grad_norm);
// ASSERTION: ALL parameters should have non-zero gradients
assert_eq!(
zero_grad_count, 0,
"Test 1 FAILED: {} parameters have zero gradients (expected 0)",
zero_grad_count
);
// ASSERTION: Average gradient norm should be in expected range
assert!(
avg_grad_norm >= 0.01 && avg_grad_norm <= 1.0,
"Test 1 FAILED: avg_grad_norm {:.6} outside expected range [0.01, 1.0]",
avg_grad_norm
);
println!("[Test 1] ✅ PASSED: All parameters have non-zero gradients");
Ok(())
}
/// Test 2: Categorical Loss Gradient Flow
/// Goal: Verify categorical cross-entropy preserves gradients
/// Expected: grad_norm ∈ [0.1, 1.0]
#[test]
fn test_categorical_loss_gradients() -> Result<(), MLError> {
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// Create simple linear layer
let vars = VarMap::new();
let vb = VarBuilder::from_varmap(&vars, DType::F32, &device);
let input_dim = 64;
let output_dim = 51; // 51 atoms (C51 distribution)
let batch_size = 32;
let linear_layer = linear(input_dim, output_dim, vb.pp("test_layer"))?;
// Create dummy input
let input = Tensor::randn(0f32, 1.0, (batch_size, input_dim), &device)?;
// Forward pass
let logits = linear_layer.forward(&input)?;
// Apply softmax to get distribution
let probs = candle_nn::ops::softmax(&logits, 1)?;
// Create target distribution (uniform for simplicity)
let target = Tensor::ones((batch_size, output_dim), DType::F32, &device)?
.broadcast_div(&Tensor::new(output_dim as f32, &device)?)?;
// Categorical cross-entropy loss: -sum(target * log(probs))
let log_probs = probs.log()?;
let loss = target.mul(&log_probs)?.sum_all()?.neg()?;
println!("[Test 2] Loss value: {:?}", loss.to_scalar::<f32>()?);
// Backward pass
let grads = loss.backward()?;
// Check linear layer parameters have non-zero gradients
let all_vars = vars.all_vars();
let mut total_grad_norm = 0.0f32;
let mut zero_grad_count = 0;
let mut total_param_count = 0;
for (name, var) in all_vars.iter() {
total_param_count += 1;
if let Some(grad) = grads.get(var) {
let grad_norm = grad.sqr()?.sum_all()?.sqrt()?.to_scalar::<f32>()?;
total_grad_norm += grad_norm;
if grad_norm < 1e-9 {
zero_grad_count += 1;
println!("[Test 2] WARNING: Zero gradient for parameter: {}", name);
} else {
println!("[Test 2] Parameter: {}, grad_norm: {:.6}", name, grad_norm);
}
} else {
zero_grad_count += 1;
println!("[Test 2] ERROR: No gradient for parameter: {}", name);
}
}
let avg_grad_norm = total_grad_norm / total_param_count as f32;
println!("[Test 2] Total parameters: {}", total_param_count);
println!("[Test 2] Zero gradient count: {}", zero_grad_count);
println!("[Test 2] Average grad_norm: {:.6}", avg_grad_norm);
// ASSERTION: ALL parameters should have non-zero gradients
assert_eq!(
zero_grad_count, 0,
"Test 2 FAILED: {} parameters have zero gradients (expected 0)",
zero_grad_count
);
// ASSERTION: Average gradient norm should be in expected range
assert!(
avg_grad_norm >= 0.001 && avg_grad_norm <= 10.0, // Wider range for categorical loss
"Test 2 FAILED: avg_grad_norm {:.6} outside expected range [0.001, 10.0]",
avg_grad_norm
);
println!("[Test 2] ✅ PASSED: Categorical loss preserves gradients");
Ok(())
}
/// Test 3: PER Weight Application
/// Goal: Verify importance sampling weights don't kill gradients
/// Expected: grad_norm identical ±10% WITH/WITHOUT IS weights
#[test]
fn test_per_weights_preserve_gradients() -> Result<(), MLError> {
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// Create simple network
let vars = VarMap::new();
let vb = VarBuilder::from_varmap(&vars, DType::F32, &device);
let linear_layer = linear(64, 1, vb.pp("test_layer"))?;
let batch_size = 32;
let input = Tensor::randn(0f32, 1.0, (batch_size, 64), &device)?;
// === WITHOUT IS WEIGHTS ===
let output1 = linear_layer.forward(&input)?;
let loss1 = output1.sqr()?.mean_all()?;
let grads1 = loss1.backward()?;
let all_vars = vars.all_vars();
let var1 = &all_vars[0].1;
let grad1_norm = grads1.get(var1)
.unwrap()
.sqr()?
.sum_all()?
.sqrt()?
.to_scalar::<f32>()?;
println!("[Test 3] WITHOUT IS weights - grad_norm: {:.6}", grad1_norm);
// === WITH IS WEIGHTS (DETACHED) ===
// Re-create network for fresh computation graph
let vars2 = VarMap::new();
let vb2 = VarBuilder::from_varmap(&vars2, DType::F32, &device);
let linear_layer2 = linear(64, 1, vb2.pp("test_layer"))?;
let output2 = linear_layer2.forward(&input)?;
// Create IS weights (should be detached)
let is_weights = Tensor::ones((batch_size, 1), DType::F32, &device)?
.mul(&Tensor::new(0.5f32, &device)?)?
.detach(); // CRITICAL: Must detach weights
// Apply IS weights
let weighted_output = output2.broadcast_mul(&is_weights)?;
let loss2 = weighted_output.sqr()?.mean_all()?;
let grads2 = loss2.backward()?;
let all_vars2 = vars2.all_vars();
let var2 = &all_vars2[0].1;
let grad2_norm = grads2.get(var2)
.unwrap()
.sqr()?
.sum_all()?
.sqrt()?
.to_scalar::<f32>()?;
println!("[Test 3] WITH IS weights (detached) - grad_norm: {:.6}", grad2_norm);
// Calculate percentage difference
let pct_diff = ((grad2_norm - grad1_norm) / grad1_norm).abs() * 100.0;
println!("[Test 3] Percentage difference: {:.2}%", pct_diff);
// ASSERTION: Gradients should exist for both cases
assert!(
grad1_norm > 1e-6 && grad2_norm > 1e-6,
"Test 3 FAILED: Gradients too small (grad1: {:.6}, grad2: {:.6})",
grad1_norm, grad2_norm
);
// ASSERTION: Gradient norms should be similar (accounting for weight scaling)
// Since weights are 0.5, grad2 should be ~0.5 * grad1
let expected_ratio = 0.5;
let actual_ratio = grad2_norm / grad1_norm;
let ratio_diff = ((actual_ratio - expected_ratio) / expected_ratio).abs() * 100.0;
println!("[Test 3] Expected ratio: {:.2}, Actual ratio: {:.2}, Diff: {:.2}%",
expected_ratio, actual_ratio, ratio_diff);
assert!(
ratio_diff < 50.0, // Allow 50% deviation due to implementation details
"Test 3 FAILED: Gradient ratio {:.2} differs from expected {:.2} by {:.2}%",
actual_ratio, expected_ratio, ratio_diff
);
println!("[Test 3] ✅ PASSED: IS weights preserve gradient flow");
Ok(())
}
/// Test 4: Target Network Isolation
/// Goal: Verify target network doesn't receive gradients
/// Expected: main grad_norm > 0.001, target grad_norm = 0.0
#[test]
fn test_target_network_isolation() -> Result<(), MLError> {
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// Create main network
let main_vars = VarMap::new();
let main_vb = VarBuilder::from_varmap(&main_vars, DType::F32, &device);
let main_layer = linear(64, 1, main_vb.pp("main_layer"))?;
// Create target network
let target_vars = VarMap::new();
let target_vb = VarBuilder::from_varmap(&target_vars, DType::F32, &device);
let target_layer = linear(64, 1, target_vb.pp("target_layer"))?;
let batch_size = 32;
let input = Tensor::randn(0f32, 1.0, (batch_size, 64), &device)?;
// Forward through both networks
let main_output = main_layer.forward(&input)?;
let target_output = target_layer.forward(&input)?.detach(); // CRITICAL: Detach target
// Compute TD error
let td_error = (&main_output - &target_output)?;
let loss = td_error.sqr()?.mean_all()?;
println!("[Test 4] Loss value: {:?}", loss.to_scalar::<f32>()?);
// Backward pass
let grads = loss.backward()?;
// Check main network gradients (should be non-zero)
let main_all_vars = main_vars.all_vars();
let main_var = &main_all_vars[0].1;
let main_grad_norm = if let Some(grad) = grads.get(main_var) {
grad.sqr()?.sum_all()?.sqrt()?.to_scalar::<f32>()?
} else {
0.0
};
println!("[Test 4] Main network grad_norm: {:.6}", main_grad_norm);
// Check target network gradients (should be zero)
let target_all_vars = target_vars.all_vars();
let target_var = &target_all_vars[0].1;
let target_grad_norm = if let Some(grad) = grads.get(target_var) {
grad.sqr()?.sum_all()?.sqrt()?.to_scalar::<f32>()?
} else {
0.0
};
println!("[Test 4] Target network grad_norm: {:.6}", target_grad_norm);
// ASSERTION: Main network should have gradients
assert!(
main_grad_norm > 0.001,
"Test 4 FAILED: Main network grad_norm {:.6} too small (expected > 0.001)",
main_grad_norm
);
// ASSERTION: Target network should have ZERO gradients
assert!(
target_grad_norm < 1e-9,
"Test 4 FAILED: Target network grad_norm {:.6} not zero (expected < 1e-9)",
target_grad_norm
);
println!("[Test 4] ✅ PASSED: Target network properly isolated");
Ok(())
}
/// Test 5: Optimizer Parameter Consistency
/// Goal: Verify optimizer updates same params as forward pass
/// Expected: max_param_delta > 1e-6
#[test]
fn test_optimizer_parameter_consistency() -> Result<(), MLError> {
let device = Device::cuda_if_available(0).unwrap_or(Device::Cpu);
// Create network
let vars = VarMap::new();
let vb = VarBuilder::from_varmap(&vars, DType::F32, &device);
let layer = linear(64, 1, vb.pp("test_layer"))?;
// Create optimizer with network's VarMap
let learning_rate = 0.001;
let mut optimizer = AdamW::new(vars.all_vars(), learning_rate)?;
// Clone parameters BEFORE training
let all_vars = vars.all_vars();
let before_params: Vec<Tensor> = all_vars.iter()
.map(|(_, var)| var.as_tensor().clone())
.collect();
// Training step
let batch_size = 32;
let input = Tensor::randn(0f32, 1.0, (batch_size, 64), &device)?;
let output = layer.forward(&input)?;
let loss = output.sqr()?.mean_all()?;
println!("[Test 5] Loss before step: {:?}", loss.to_scalar::<f32>()?);
// Backward pass
let grads = loss.backward()?;
// Optimizer step
optimizer.step(&grads)?;
// Get parameters AFTER training
let after_params: Vec<Tensor> = all_vars.iter()
.map(|(_, var)| var.as_tensor().clone())
.collect();
// Calculate parameter changes
let mut max_delta = 0.0f32;
let mut total_delta = 0.0f32;
let mut unchanged_count = 0;
for (i, (before, after)) in before_params.iter().zip(after_params.iter()).enumerate() {
let delta = (after - before)?;
let delta_norm = delta.sqr()?.sum_all()?.sqrt()?.to_scalar::<f32>()?;
total_delta += delta_norm;
max_delta = max_delta.max(delta_norm);
println!("[Test 5] Parameter {}: delta_norm = {:.9}", i, delta_norm);
if delta_norm < 1e-9 {
unchanged_count += 1;
}
}
let avg_delta = total_delta / before_params.len() as f32;
println!("[Test 5] Max parameter delta: {:.9}", max_delta);
println!("[Test 5] Avg parameter delta: {:.9}", avg_delta);
println!("[Test 5] Unchanged parameters: {}/{}", unchanged_count, before_params.len());
// ASSERTION: ALL parameters should have changed
assert_eq!(
unchanged_count, 0,
"Test 5 FAILED: {} parameters unchanged after optimizer step",
unchanged_count
);
// ASSERTION: Max delta should be significant
assert!(
max_delta > 1e-6,
"Test 5 FAILED: max_param_delta {:.9} too small (expected > 1e-6)",
max_delta
);
println!("[Test 5] ✅ PASSED: Optimizer updates correct parameters");
Ok(())
}