- Reduce CI GPU test datasets 16x for walltime reduction - Reduce early-stop epochs 50→10, add --test-threads=1 - Serialize all GPU lib tests to prevent cuBLAS init race - Align state_dim to 16 for BF16 tensor core HMMA dispatch - BF16 precision tolerance in ml-dqn tests - Enable branching DQN + tracing subscriber in smoke tests - Prevent min_replay_size > buffer_size deadlock in early-stop tests - Prevent AutoReplaySizer from breaking gradient collapse warmup - Replace racy tokio::spawn checkpoint counter with AtomicUsize - Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests - RealDataLoader respects TEST_DATA_DIR for CI PVC layout - Add collapse_warmup_capacity to gpu_smoketest DQNConfig - Drain CUDA context between test binaries - Detached HEAD checkout prevents local branch corruption - GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions - OOD input handling tests use use_gpu: true Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
494 lines
16 KiB
Rust
494 lines
16 KiB
Rust
#![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,
|
|
)]
|
|
//! DQN Diversity Penalty Unit Tests
|
|
//!
|
|
//! Comprehensive tests for Fix #4: Entropy-based diversity penalty in hyperopt objective function.
|
|
//!
|
|
//! This test suite validates:
|
|
//! 1. Shannon entropy calculation accuracy (uniform, extreme, moderate bias)
|
|
//! 2. Diversity penalty application (threshold at entropy < 0.5)
|
|
//! 3. Edge cases (empty distribution, boundary conditions)
|
|
//!
|
|
//! ## Context
|
|
//!
|
|
//! Fix #4 addresses the critical issue of 99.4% SELL bias discovered in DQN training.
|
|
//! The diversity penalty penalizes configurations where action entropy falls below 0.5
|
|
//! (indicating extreme action homogeneity).
|
|
//!
|
|
//! ## Penalty Formula
|
|
//!
|
|
//! ```text
|
|
//! if entropy < 0.5:
|
|
//! penalty = -10.0 (catastrophic)
|
|
//! else:
|
|
//! penalty = 0.0 (acceptable diversity)
|
|
//! ```
|
|
//!
|
|
//! ## Shannon Entropy Formula
|
|
//!
|
|
//! ```text
|
|
//! H(X) = -Σ p(x) * log2(p(x))
|
|
//! ```
|
|
//!
|
|
//! Where:
|
|
//! - p(x) = probability of action x (count / total)
|
|
//! - log2(p) = logarithm base 2
|
|
//! - H(X) = entropy in bits
|
|
//!
|
|
//! Expected ranges:
|
|
//! - Uniform (33%, 33%, 33%): H ≈ 1.585 bits (max for 3 actions)
|
|
//! - Extreme bias (100%, 0%, 0%): H = 0.0 bits (min)
|
|
//! - Moderate bias (70%, 20%, 10%): H ≈ 1.16 bits
|
|
|
|
#[cfg(test)]
|
|
mod dqn_diversity_penalty_tests {
|
|
use tracing::info;
|
|
|
|
/// Helper function to calculate Shannon entropy
|
|
///
|
|
/// This mirrors the entropy calculation used in the hyperopt objective function.
|
|
/// Calculates entropy in bits using base-2 logarithm.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `action_counts` - Array of action counts [BUY, SELL, HOLD]
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Entropy in bits (0.0 = all same action, 1.585 ≈ uniform distribution)
|
|
fn calculate_action_entropy(action_counts: &[usize]) -> f64 {
|
|
let total: usize = action_counts.iter().sum();
|
|
if total == 0 {
|
|
return 0.0;
|
|
}
|
|
|
|
let mut entropy = 0.0;
|
|
for &count in action_counts {
|
|
if count > 0 {
|
|
let p = count as f64 / total as f64;
|
|
entropy -= p * p.log2();
|
|
}
|
|
}
|
|
entropy
|
|
}
|
|
|
|
/// Helper to calculate diversity penalty
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `entropy` - Shannon entropy of action distribution
|
|
///
|
|
/// # Returns
|
|
///
|
|
/// Penalty value (-10.0 if entropy < 0.5, else 0.0)
|
|
fn calculate_diversity_penalty(entropy: f64) -> f64 {
|
|
if entropy < 0.5 {
|
|
-10.0
|
|
} else {
|
|
0.0
|
|
}
|
|
}
|
|
|
|
// ========================================================================
|
|
// Entropy Calculation Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_entropy_uniform_distribution() {
|
|
// BUY=100, SELL=100, HOLD=100 (33.3% each)
|
|
let action_counts = [100, 100, 100];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
|
|
// Uniform distribution: entropy = log2(3) ≈ 1.585
|
|
assert!(
|
|
(entropy - 1.585).abs() < 0.001,
|
|
"Expected entropy ≈ 1.585 for uniform distribution, got {:.4}",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_entropy_extreme_bias() {
|
|
// SELL=1000, BUY=0, HOLD=0 (100% SELL)
|
|
let action_counts = [0, 1000, 0];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
|
|
// All actions the same: entropy = 0.0
|
|
assert_eq!(
|
|
entropy, 0.0,
|
|
"Expected entropy = 0.0 for 100% bias, got {:.4}",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_entropy_moderate_bias() {
|
|
// SELL=70, BUY=20, HOLD=10 (70% SELL dominance)
|
|
let action_counts = [20, 70, 10];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
|
|
// Expected: entropy ≈ 1.157 (calculated manually)
|
|
// H = -(0.2*log2(0.2) + 0.7*log2(0.7) + 0.1*log2(0.1))
|
|
// H = -(0.2*(-2.322) + 0.7*(-0.515) + 0.1*(-3.322))
|
|
// H = -(-0.464 - 0.361 - 0.332) = 1.157
|
|
assert!(
|
|
(entropy - 1.157).abs() < 0.02,
|
|
"Expected entropy ≈ 1.16 for 70/20/10 distribution, got {:.4}",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_entropy_two_action_distribution() {
|
|
// BUY=50, SELL=50, HOLD=0 (50/50 split)
|
|
let action_counts = [50, 50, 0];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
|
|
// Two equal actions: entropy = log2(2) = 1.0
|
|
assert!(
|
|
(entropy - 1.0).abs() < 0.001,
|
|
"Expected entropy = 1.0 for 50/50 split, got {:.4}",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Penalty Application Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_penalty_balanced_trial() {
|
|
// Balanced distribution (BUY=30%, SELL=30%, HOLD=40%)
|
|
let action_counts = [30, 30, 40];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
let penalty = calculate_diversity_penalty(entropy);
|
|
|
|
assert!(
|
|
entropy >= 0.5,
|
|
"Balanced trial should have entropy ≥ 0.5, got {:.4}",
|
|
entropy
|
|
);
|
|
assert_eq!(
|
|
penalty, 0.0,
|
|
"Balanced trial should have NO penalty (entropy={:.4})",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_penalty_moderately_biased_trial() {
|
|
// Moderate bias (SELL=60%, BUY=25%, HOLD=15%)
|
|
let action_counts = [25, 60, 15];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
let penalty = calculate_diversity_penalty(entropy);
|
|
|
|
assert!(
|
|
entropy >= 0.5,
|
|
"Moderate bias (60%) should have entropy ≥ 0.5, got {:.4}",
|
|
entropy
|
|
);
|
|
assert_eq!(
|
|
penalty, 0.0,
|
|
"Moderate bias should have NO penalty (entropy={:.4})",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_penalty_highly_biased_trial() {
|
|
// High bias (SELL=95%, BUY=3%, HOLD=2%)
|
|
// This produces entropy ≈ 0.33, well below 0.5 threshold
|
|
let action_counts = [3, 95, 2];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
let penalty = calculate_diversity_penalty(entropy);
|
|
|
|
assert!(
|
|
entropy < 0.5,
|
|
"High bias (95%) should have entropy < 0.5, got {:.4}",
|
|
entropy
|
|
);
|
|
assert_eq!(
|
|
penalty, -10.0,
|
|
"High bias should receive -10.0 penalty (entropy={:.4})",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_penalty_extreme_bias_trial() {
|
|
// Extreme bias (SELL=99.4%, BUY=0.6%, HOLD=0%)
|
|
// This mirrors the actual bug discovered in Wave 3-A1
|
|
let action_counts = [6, 994, 0];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
let penalty = calculate_diversity_penalty(entropy);
|
|
|
|
assert!(
|
|
entropy < 0.5,
|
|
"Extreme bias (99.4%) should have entropy < 0.5, got {:.4}",
|
|
entropy
|
|
);
|
|
assert_eq!(
|
|
penalty, -10.0,
|
|
"Extreme bias should receive -10.0 penalty (entropy={:.4})",
|
|
entropy
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Edge Case Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_entropy_empty_distribution() {
|
|
// Edge case: no actions recorded
|
|
let action_counts = [0, 0, 0];
|
|
let entropy = calculate_action_entropy(&action_counts);
|
|
let penalty = calculate_diversity_penalty(entropy);
|
|
|
|
assert_eq!(entropy, 0.0, "Empty distribution should have entropy = 0.0");
|
|
assert_eq!(
|
|
penalty, -10.0,
|
|
"Empty distribution should receive -10.0 penalty"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_penalty_boundary_case() {
|
|
// Find action counts that produce entropy ≈ 0.5 (boundary)
|
|
// Through trial and error:
|
|
// SELL=92%, BUY=6%, HOLD=2% gives entropy ≈ 0.47 (below threshold)
|
|
// SELL=85%, BUY=10%, HOLD=5% gives entropy ≈ 0.71 (above threshold)
|
|
|
|
// Test below threshold
|
|
let action_counts_below = [6, 92, 2];
|
|
let entropy_below = calculate_action_entropy(&action_counts_below);
|
|
let penalty_below = calculate_diversity_penalty(entropy_below);
|
|
|
|
info!(entropy = entropy_below, penalty = penalty_below, "Boundary case (below)");
|
|
|
|
assert!(
|
|
entropy_below < 0.5,
|
|
"92/6/2 distribution should have entropy < 0.5, got {:.4}",
|
|
entropy_below
|
|
);
|
|
assert_eq!(
|
|
penalty_below, -10.0,
|
|
"Below threshold: should penalize (entropy={:.4})",
|
|
entropy_below
|
|
);
|
|
|
|
// Test above threshold
|
|
let action_counts_above = [10, 85, 5];
|
|
let entropy_above = calculate_action_entropy(&action_counts_above);
|
|
let penalty_above = calculate_diversity_penalty(entropy_above);
|
|
|
|
info!(entropy = entropy_above, penalty = penalty_above, "Boundary case (above)");
|
|
|
|
assert!(
|
|
entropy_above >= 0.5,
|
|
"85/10/5 distribution should have entropy ≥ 0.5, got {:.4}",
|
|
entropy_above
|
|
);
|
|
assert_eq!(
|
|
penalty_above, 0.0,
|
|
"At/above threshold: should not penalize (entropy={:.4})",
|
|
entropy_above
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_entropy_exact_threshold() {
|
|
// Test with entropy exactly at 0.5 threshold
|
|
// We need to find action counts that produce entropy ≈ 0.5
|
|
// Through calculation: SELL=93%, BUY=5%, HOLD=2% gives entropy ≈ 0.48
|
|
|
|
// Test just below threshold (should penalize)
|
|
let action_counts_below = [5, 93, 2];
|
|
let entropy_below = calculate_action_entropy(&action_counts_below);
|
|
|
|
info!(entropy = entropy_below, "Below threshold test");
|
|
|
|
// Verify entropy is close to but below 0.5
|
|
assert!(
|
|
entropy_below < 0.5,
|
|
"Expected entropy < 0.5 for 93/5/2 distribution, got {:.4}",
|
|
entropy_below
|
|
);
|
|
|
|
let penalty_below = calculate_diversity_penalty(entropy_below);
|
|
assert_eq!(
|
|
penalty_below, -10.0,
|
|
"Just below threshold (entropy={:.4}): should penalize",
|
|
entropy_below
|
|
);
|
|
|
|
// Test just above threshold (should not penalize)
|
|
// SELL=87%, BUY=9%, HOLD=4% gives entropy ≈ 0.61
|
|
let action_counts_above = [9, 87, 4];
|
|
let entropy_above = calculate_action_entropy(&action_counts_above);
|
|
|
|
info!(entropy = entropy_above, "Above threshold test");
|
|
|
|
assert!(
|
|
entropy_above >= 0.5,
|
|
"Expected entropy >= 0.5 for 87/9/4 distribution, got {:.4}",
|
|
entropy_above
|
|
);
|
|
|
|
let penalty_above = calculate_diversity_penalty(entropy_above);
|
|
assert_eq!(
|
|
penalty_above, 0.0,
|
|
"Just above threshold (entropy={:.4}): should not penalize",
|
|
entropy_above
|
|
);
|
|
}
|
|
|
|
// ========================================================================
|
|
// Additional Validation Tests
|
|
// ========================================================================
|
|
|
|
#[test]
|
|
fn test_entropy_mathematical_properties() {
|
|
// Test 1: Entropy is symmetric (order doesn't matter)
|
|
let counts_1 = [30, 40, 30];
|
|
let counts_2 = [40, 30, 30];
|
|
let counts_3 = [30, 30, 40];
|
|
|
|
let entropy_1 = calculate_action_entropy(&counts_1);
|
|
let entropy_2 = calculate_action_entropy(&counts_2);
|
|
let entropy_3 = calculate_action_entropy(&counts_3);
|
|
|
|
assert!(
|
|
(entropy_1 - entropy_2).abs() < 1e-10,
|
|
"Entropy should be symmetric: {:.4} != {:.4}",
|
|
entropy_1,
|
|
entropy_2
|
|
);
|
|
assert!(
|
|
(entropy_1 - entropy_3).abs() < 1e-10,
|
|
"Entropy should be symmetric: {:.4} != {:.4}",
|
|
entropy_1,
|
|
entropy_3
|
|
);
|
|
|
|
// Test 2: Maximum entropy for 3 actions is log2(3) ≈ 1.585
|
|
let uniform = [100, 100, 100];
|
|
let max_entropy = calculate_action_entropy(&uniform);
|
|
|
|
assert!(
|
|
max_entropy <= 1.585 + 0.001,
|
|
"Maximum entropy for 3 actions should be ≤ log2(3) ≈ 1.585, got {:.4}",
|
|
max_entropy
|
|
);
|
|
|
|
// Test 3: Minimum entropy is 0.0 (all same action)
|
|
let extreme = [0, 1000, 0];
|
|
let min_entropy = calculate_action_entropy(&extreme);
|
|
|
|
assert_eq!(
|
|
min_entropy, 0.0,
|
|
"Minimum entropy should be 0.0 for uniform action, got {:.4}",
|
|
min_entropy
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_penalty_consistency() {
|
|
// Test that penalty is consistent across different action counts with same proportions
|
|
|
|
// 90/7/3 proportions
|
|
let small_scale = [7, 90, 3];
|
|
let large_scale = [70, 900, 30];
|
|
|
|
let entropy_small = calculate_action_entropy(&small_scale);
|
|
let entropy_large = calculate_action_entropy(&large_scale);
|
|
|
|
assert!(
|
|
(entropy_small - entropy_large).abs() < 1e-10,
|
|
"Entropy should be scale-invariant: {:.4} != {:.4}",
|
|
entropy_small,
|
|
entropy_large
|
|
);
|
|
|
|
let penalty_small = calculate_diversity_penalty(entropy_small);
|
|
let penalty_large = calculate_diversity_penalty(entropy_large);
|
|
|
|
assert_eq!(
|
|
penalty_small, penalty_large,
|
|
"Penalty should be consistent across scales"
|
|
);
|
|
}
|
|
}
|