Files
foxhunt/ml/tests/dqn_hyperopt_multiobjective_test.rs
jgrusewski 17d94e654c feat(dqn): Wave 10 - Architectural improvements and bug fixes
Wave 10 Summary:
- A1-A4: Architecture upgrades (4x network, LeakyReLU, Xavier init, diagnostics)
- A5-A6: Integration testing and production validation
- A7: Research hyperopt vs manual tuning (manual recommended)
- A8-A12: HOLD penalty tuning and critical bug fixes

Architecture Changes:
- Network expansion: [128,64,32] → [256,128,64] (2.5x parameters)
- LeakyReLU activation (alpha=0.01) to prevent dead neurons
- Xavier/Glorot initialization for better gradient flow
- Real-time diagnostic monitoring (Q-values, dead neurons, gradients)

Critical Bugs Fixed:
- Bug #1: HOLD penalty not wired to reward calculation
- Bug #2: Zero price error in calculate_hold_reward (velocity-based fix)
- Huber loss default enabled (Wave 9)
- Shape mismatch fix (Wave 8)

Test Results:
- Integration tests: 149/152 passing (98%)
- New tests: 40+ tests added across 15 files
- Xavier init: 5/5 tests passing
- HOLD penalty wiring: 4/4 tests passing
- Zero price fix: 4/4 tests passing

Known Issues:
- HOLD bias persists at ~100% despite penalties
- Gradient collapse: 217 instances per training run (norm=0.0)
- Reversed penalty effect: Higher penalties → worse Q-spread
- Root cause: Gradient clipping bottleneck (max_norm=10.0 vs penalty signal)

Phase 1 Trials (all completed without crashes):
- Penalty 0.5: Q-spread 250 pts, HOLD 100%
- Penalty 1.0: Q-spread 251 pts, HOLD 100%
- Penalty 2.0: Q-spread 255 pts, HOLD 100% (+ Q-value explosion)

Next Steps: Architectural investigation via parallel agent debugging

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 00:38:23 +01:00

580 lines
19 KiB
Rust

//! DQN Hyperopt Multi-Objective Function Tests
//!
//! This module tests the multi-objective optimization function that combines:
//! - Reward component (80% weight): Primary metric - episode reward
//! - Diversity penalty (10% weight): Action distribution entropy
//! - Stability penalty (5% weight): Q-value health and gradient norms
//! - Completion penalty (5% weight): Epochs completed vs target
//!
//! ## Test Philosophy
//!
//! These tests validate the objective function BEFORE implementation (TDD).
//! They will fail initially and pass once the implementation is complete.
//!
//! ## Expected Behavior
//!
//! 1. **Hard Constraints** (trial rejection → objective = +1e6):
//! - Q-value floor: > -10.0
//! - Training loss ceiling: < 1000.0
//! - Minimum epochs: ≥ 20
//!
//! 2. **Soft Penalties** (weighted composite):
//! - Reward: -avg_episode_reward (negated for minimization)
//! - Diversity: Entropy-based action distribution penalty
//! - Stability: Q-value range + gradient norm penalties
//! - Completion: Penalty for early stopping
//!
//! ## Note on DQNMetrics Structure
//!
//! This test file uses the PROPOSED DQNMetrics structure with added fields:
//! - `action_distribution: [f64; 3]` - [BUY%, SELL%, HOLD%]
//! - `avg_gradient_norm: f64` - Average gradient norm during training
//!
//! These fields need to be added to `ml/src/hyperopt/adapters/dqn.rs` before
//! the tests can compile. See `DQN_HYPEROPT_OBJECTIVE_ANALYSIS.md` Section 6.
// NOTE: This will not compile until DQNMetrics is extended with:
// - action_distribution: [f64; 3]
// - avg_gradient_norm: f64
//
// Temporary struct definition for test-driven development
#[derive(Debug, Clone)]
struct DQNMetrics {
pub train_loss: f64,
pub val_loss: f64,
pub avg_q_value: f64,
pub final_epsilon: f64,
pub epochs_completed: usize,
pub avg_episode_reward: f64,
pub action_distribution: [f64; 3], // ✅ NEW: [BUY%, SELL%, HOLD%]
pub avg_gradient_norm: f64, // ✅ NEW: Average gradient norm
}
// ============================================================================
// Test Helpers
// ============================================================================
/// Create test DQNMetrics with default "healthy" values
fn create_healthy_metrics() -> DQNMetrics {
DQNMetrics {
train_loss: 0.1,
val_loss: 0.15,
avg_q_value: 5.0, // Healthy Q-value (0.5 to 10.0)
final_epsilon: 0.01,
epochs_completed: 50, // Full training
avg_episode_reward: 0.001, // Positive reward
action_distribution: [0.33, 0.33, 0.34], // Balanced actions
avg_gradient_norm: 2.0, // Healthy gradient norm (< 10.0)
}
}
/// Create test DQNMetrics with Q-value collapse
fn create_q_collapse_metrics() -> DQNMetrics {
DQNMetrics {
train_loss: 0.1,
val_loss: 0.15,
avg_q_value: -681.92, // CATASTROPHIC collapse
final_epsilon: 0.01,
epochs_completed: 10, // Early stop
avg_episode_reward: 0.001,
action_distribution: [0.994, 0.003, 0.003], // Degenerate (99.4% BUY)
avg_gradient_norm: 0.01,
}
}
/// Create test DQNMetrics with high loss (numerical explosion)
fn create_high_loss_metrics() -> DQNMetrics {
DQNMetrics {
train_loss: 5000.0, // EXPLOSION
val_loss: 5500.0,
avg_q_value: 5.0,
final_epsilon: 0.01,
epochs_completed: 5, // Early stop due to explosion
avg_episode_reward: 0.0001,
action_distribution: [0.50, 0.30, 0.20],
avg_gradient_norm: 150.0, // Gradient explosion
}
}
/// Create test DQNMetrics with early stopping
fn create_early_stop_metrics() -> DQNMetrics {
DQNMetrics {
train_loss: 0.1,
val_loss: 0.15,
avg_q_value: 5.0,
final_epsilon: 0.50, // High epsilon (not enough decay)
epochs_completed: 15, // Stopped at 15/50 epochs
avg_episode_reward: 0.0005,
action_distribution: [0.40, 0.35, 0.25],
avg_gradient_norm: 3.0,
}
}
/// Create test DQNMetrics with extreme HOLD bias
fn create_hold_bias_metrics() -> DQNMetrics {
DQNMetrics {
train_loss: 0.05,
val_loss: 0.06,
avg_q_value: 3.0,
final_epsilon: 0.01,
epochs_completed: 50,
avg_episode_reward: 0.0003, // Low reward (agent not acting)
action_distribution: [0.05, 0.05, 0.90], // 90% HOLD
avg_gradient_norm: 1.5,
}
}
// ============================================================================
// Component Tests (6 tests)
// ============================================================================
#[test]
fn test_reward_component_normalization() {
// Test that reward component is correctly negated and scaled
let metrics = create_healthy_metrics();
// Reward component should be -avg_episode_reward
// For avg_episode_reward = 0.001, reward_component = -0.001
let reward_component = calculate_reward_component(&metrics);
assert_eq!(
reward_component,
-0.001,
"Reward component should be negated avg_episode_reward"
);
// Test with higher reward
let high_reward_metrics = DQNMetrics {
avg_episode_reward: 0.005,
..create_healthy_metrics()
};
let high_reward_component = calculate_reward_component(&high_reward_metrics);
assert_eq!(
high_reward_component,
-0.005,
"Higher reward should result in more negative component (better for minimization)"
);
}
#[test]
fn test_diversity_penalty_balanced_actions() {
// Test that balanced action distribution (33/33/33) results in zero/low penalty
let metrics = create_healthy_metrics();
let penalty = calculate_diversity_penalty(&metrics);
// Balanced distribution should have entropy close to max_entropy = ln(3) ≈ 1.099
// Diversity score = entropy / max_entropy ≈ 1.0
// Penalty = 1.0 - diversity_score ≈ 0.0
assert!(
penalty < 0.05,
"Balanced action distribution should have low penalty: {}",
penalty
);
}
#[test]
fn test_diversity_penalty_extreme_hold() {
// Test that extreme HOLD bias (5/5/90) results in high penalty
let metrics = create_hold_bias_metrics();
let penalty = calculate_diversity_penalty(&metrics);
// Degenerate distribution (90% HOLD) should have low entropy
// Diversity score should be low
// Penalty should be high (close to 1.0)
assert!(
penalty > 0.5,
"Extreme HOLD bias should have high penalty: {}",
penalty
);
}
#[test]
fn test_stability_penalty_good_qvalues() {
// Test that Q-values in healthy range (0.5 to 10.0) result in zero penalty
let metrics = create_healthy_metrics();
let penalty = calculate_stability_penalty(&metrics);
// Q-value = 5.0 is in healthy range [0.5, 10.0]
// Gradient norm = 2.0 is below threshold (10.0)
// Penalty should be zero or very low
assert!(
penalty < 0.1,
"Healthy Q-values and gradients should have low penalty: {}",
penalty
);
}
#[test]
fn test_stability_penalty_gradient_explosion() {
// Test that gradient explosion (grad_norm > 10) results in high penalty
let metrics = create_high_loss_metrics();
let penalty = calculate_stability_penalty(&metrics);
// Gradient norm = 150.0 >> 10.0 threshold
// Should result in significant penalty
assert!(
penalty > 5.0,
"Gradient explosion should have high penalty: {}",
penalty
);
}
#[test]
fn test_completion_penalty_early_stop() {
// Test that early stopping (30/100 epochs) results in penalty
let metrics = create_early_stop_metrics();
let penalty = calculate_completion_penalty(&metrics, 50);
// Completed 15/50 epochs = 30% completion
// Should have penalty proportional to missing epochs
assert!(
penalty > 0.5,
"Early stopping should have significant penalty: {}",
penalty
);
}
// ============================================================================
// Integration Tests (3 tests)
// ============================================================================
#[test]
fn test_multiobjective_optimal_trial() {
// Test that a "perfect" trial gets a good (low) objective score
let metrics = create_healthy_metrics();
let objective = calculate_multiobjective(&metrics, 50);
// Expected calculation:
// reward_component = -0.001
// diversity_penalty = ~0.0 (balanced actions)
// stability_penalty = ~0.0 (healthy Q-values)
// completion_penalty = ~0.0 (50/50 epochs)
//
// objective = 0.8 * (-0.001) + 0.1 * 0.0 + 0.05 * 0.0 + 0.05 * 0.0
// = -0.0008
assert!(
objective < -0.0005,
"Optimal trial should have low (negative) objective: {}",
objective
);
// Verify all constraints pass
assert!(
!violates_hard_constraints(&metrics),
"Optimal trial should not violate hard constraints"
);
}
#[test]
fn test_multiobjective_broken_trial() {
// Test that a broken trial (99% HOLD) gets a bad (high) objective score
let metrics = create_hold_bias_metrics();
let objective = calculate_multiobjective(&metrics, 50);
// Expected calculation:
// reward_component = -0.0003 (low reward due to inaction)
// diversity_penalty = ~0.6 (90% HOLD bias)
// stability_penalty = ~0.0 (Q-values OK)
// completion_penalty = ~0.0 (50/50 epochs)
//
// objective = 0.8 * (-0.0003) + 0.1 * 0.6 + 0.05 * 0.0 + 0.05 * 0.0
// = -0.00024 + 0.06
// = +0.05976
assert!(
objective > 0.03,
"Broken trial (HOLD bias) should have high (positive) objective: {}",
objective
);
}
#[test]
fn test_weight_sensitivity() {
// Test that weight changes affect trial ranking correctly
let good_metrics = create_healthy_metrics();
let hold_bias_metrics = create_hold_bias_metrics();
// Calculate objectives with standard weights (reward=80%, diversity=10%, stability=5%, completion=5%)
let good_objective = calculate_multiobjective(&good_metrics, 50);
let hold_objective = calculate_multiobjective(&hold_bias_metrics, 50);
// Good trial should have LOWER objective (minimization)
assert!(
good_objective < hold_objective,
"Good trial (balanced actions) should rank better than HOLD-biased trial: {} vs {}",
good_objective,
hold_objective
);
// Verify ranking is correct
let ranking_correct = good_objective < hold_objective;
assert!(
ranking_correct,
"Weight sensitivity failed: good={}, hold={}",
good_objective,
hold_objective
);
}
// ============================================================================
// Hard Constraint Tests (3 tests)
// ============================================================================
#[test]
fn test_hard_constraint_q_value_floor() {
// Test that Q-value < -10.0 triggers hard constraint violation
let metrics = create_q_collapse_metrics();
let objective = calculate_multiobjective(&metrics, 50);
// Hard constraint violation should return penalty = +1e6
assert_eq!(
objective,
1e6,
"Q-value floor violation should return penalty objective"
);
}
#[test]
fn test_hard_constraint_training_loss_ceiling() {
// Test that training loss > 1000.0 triggers hard constraint violation
let metrics = create_high_loss_metrics();
let objective = calculate_multiobjective(&metrics, 50);
// Hard constraint violation should return penalty = +1e6
assert_eq!(
objective,
1e6,
"Training loss ceiling violation should return penalty objective"
);
}
#[test]
fn test_hard_constraint_minimum_epochs() {
// Test that epochs_completed < 20 triggers hard constraint violation
let metrics = DQNMetrics {
epochs_completed: 15, // Below minimum of 20
..create_healthy_metrics()
};
let objective = calculate_multiobjective(&metrics, 50);
// Hard constraint violation should return penalty = +1e6
assert_eq!(
objective,
1e6,
"Minimum epochs violation should return penalty objective"
);
}
// ============================================================================
// Edge Case Tests (3 additional tests)
// ============================================================================
#[test]
fn test_edge_case_zero_entropy() {
// Test that 100% single action (zero entropy) results in maximum penalty
let metrics = DQNMetrics {
train_loss: 0.1,
val_loss: 0.15,
avg_q_value: 5.0,
final_epsilon: 0.01,
epochs_completed: 50,
avg_episode_reward: 0.001,
action_distribution: [1.0, 0.0, 0.0], // 100% BUY (degenerate)
avg_gradient_norm: 2.0,
};
let penalty = calculate_diversity_penalty(&metrics);
// Zero entropy → diversity_score = 0 → penalty = 1.0 (maximum)
assert!(
penalty > 0.95,
"Zero entropy should have maximum penalty: {}",
penalty
);
}
#[test]
fn test_edge_case_q_value_boundaries() {
// Test Q-value penalty at exact boundaries (0.5 and 10.0)
// Test 1: Q-value = 0.5 (lower boundary of healthy range)
let metrics_lower = DQNMetrics {
avg_q_value: 0.5,
avg_gradient_norm: 2.0,
..create_healthy_metrics()
};
let penalty_lower = calculate_stability_penalty(&metrics_lower);
assert!(
penalty_lower < 0.01,
"Q-value at lower boundary (0.5) should have minimal penalty: {}",
penalty_lower
);
// Test 2: Q-value = 10.0 (upper boundary of healthy range)
let metrics_upper = DQNMetrics {
avg_q_value: 10.0,
avg_gradient_norm: 2.0,
..create_healthy_metrics()
};
let penalty_upper = calculate_stability_penalty(&metrics_upper);
assert!(
penalty_upper < 0.01,
"Q-value at upper boundary (10.0) should have minimal penalty: {}",
penalty_upper
);
// Test 3: Q-value = 0.49 (just below lower boundary)
let metrics_below = DQNMetrics {
avg_q_value: 0.49,
avg_gradient_norm: 2.0,
..create_healthy_metrics()
};
let penalty_below = calculate_stability_penalty(&metrics_below);
assert!(
penalty_below > 0.0,
"Q-value below lower boundary (0.49) should have penalty: {}",
penalty_below
);
// Test 4: Q-value = 10.01 (just above upper boundary)
let metrics_above = DQNMetrics {
avg_q_value: 10.01,
avg_gradient_norm: 2.0,
..create_healthy_metrics()
};
let penalty_above = calculate_stability_penalty(&metrics_above);
assert!(
penalty_above > 0.0,
"Q-value above upper boundary (10.01) should have penalty: {}",
penalty_above
);
}
#[test]
fn test_edge_case_gradient_norm_threshold() {
// Test gradient norm penalty at exact threshold (10.0)
// Test 1: grad_norm = 10.0 (at threshold)
let metrics_at = DQNMetrics {
avg_q_value: 5.0,
avg_gradient_norm: 10.0,
..create_healthy_metrics()
};
let penalty_at = calculate_stability_penalty(&metrics_at);
assert!(
penalty_at < 0.1,
"Gradient norm at threshold (10.0) should have minimal penalty: {}",
penalty_at
);
// Test 2: grad_norm = 9.99 (just below threshold)
let metrics_below = DQNMetrics {
avg_q_value: 5.0,
avg_gradient_norm: 9.99,
..create_healthy_metrics()
};
let penalty_below = calculate_stability_penalty(&metrics_below);
assert!(
penalty_below < 0.1,
"Gradient norm below threshold (9.99) should have minimal penalty: {}",
penalty_below
);
// Test 3: grad_norm = 10.01 (just above threshold)
let metrics_above = DQNMetrics {
avg_q_value: 5.0,
avg_gradient_norm: 10.01,
..create_healthy_metrics()
};
let penalty_above = calculate_stability_penalty(&metrics_above);
assert!(
penalty_above > 0.0,
"Gradient norm above threshold (10.01) should have penalty: {}",
penalty_above
);
}
// ============================================================================
// Component Helper Functions (NOT IMPLEMENTED YET)
// ============================================================================
/// Calculate reward component (negated for minimization)
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
/// It will be implemented in `ml/src/hyperopt/adapters/dqn.rs` as part of the multi-objective function.
fn calculate_reward_component(_metrics: &DQNMetrics) -> f64 {
panic!("calculate_reward_component() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}
/// Calculate diversity penalty using entropy
///
/// Entropy = -Σ(p_i * ln(p_i)) for i in [BUY, SELL, HOLD]
/// Max entropy = ln(3) ≈ 1.099 (uniform distribution)
/// Diversity score = entropy / max_entropy
/// Penalty = 1.0 - diversity_score
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
fn calculate_diversity_penalty(_metrics: &DQNMetrics) -> f64 {
panic!("calculate_diversity_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}
/// Calculate stability penalty from Q-value health and gradient norms
///
/// Q-value penalty:
/// - If Q < 0.5: penalty = |0.5 - Q|
/// - If Q > 10.0: penalty = (Q - 10.0) * 0.1
/// - Else: penalty = 0.0
///
/// Gradient penalty:
/// - If grad_norm > 10.0: penalty = (grad_norm - 10.0) * 0.5
/// - Else: penalty = 0.0
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
fn calculate_stability_penalty(_metrics: &DQNMetrics) -> f64 {
panic!("calculate_stability_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}
/// Calculate completion penalty from epochs completed vs target
///
/// Penalty = (target_epochs - completed_epochs) / target_epochs
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
fn calculate_completion_penalty(_metrics: &DQNMetrics, _target_epochs: usize) -> f64 {
panic!("calculate_completion_penalty() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}
/// Check if metrics violate any hard constraints
///
/// Hard constraints:
/// 1. Q-value floor: avg_q_value > -10.0
/// 2. Training loss ceiling: train_loss < 1000.0
/// 3. Minimum epochs: epochs_completed >= 20
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
fn violates_hard_constraints(_metrics: &DQNMetrics) -> bool {
panic!("violates_hard_constraints() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}
/// Calculate multi-objective function
///
/// If hard constraints violated: return +1e6
/// Else: return weighted composite of components
///
/// **NOT IMPLEMENTED**: This function is a placeholder for the actual implementation.
fn calculate_multiobjective(_metrics: &DQNMetrics, _target_epochs: usize) -> f64 {
panic!("calculate_multiobjective() not implemented yet - implement in ml/src/hyperopt/adapters/dqn.rs");
}