fix(ml): smooth CVaR penalty, fix clip leakage, align noisy sigma, fix eval_supervised

Fixes from deep investigation audit (LOW/MEDIUM priority):

1. CVaR penalty: hard cliff (0 or 10) → smooth ramp with gradient signal
   for PSO. Formula: min(10, max(0, -cvar-0.05)*200).

2. Clip outliers leakage: data_loading.rs now computes clip bounds from
   training portion only (first 80%), then applies to full series.
   Log returns and windowed normalize are causal (no leakage).

3. Noisy sigma scheduler: hyperopt now matches conservative() defaults
   (enabled, initial=0.8, final=0.4) so hyperopt-found params
   generalize to train_best without scheduler mismatch.

4. evaluate_supervised.rs: NormStats fallback from test data (leakage)
   replaced with bail! matching evaluate_baseline.rs behavior.

5. Doc comments: stale 27D references updated to 31D (4 locations).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-05 21:14:31 +01:00
parent c033a34fec
commit 7d9808ecf0
3 changed files with 59 additions and 19 deletions

View File

@@ -632,13 +632,11 @@ fn main() -> Result<()> {
serde_json::from_str(&norm_json)
.with_context(|| format!("Failed to parse {}", norm_path.display()))?
} else {
warn!(
" NormStats not found at {}, computing from test data (degraded)",
anyhow::bail!(
"NormStats not found at {} - cannot evaluate without training-set statistics. \
Re-run training to generate norm_stats files.",
norm_path.display()
);
let test_feat = extract_ml_features(&window.test)
.context("Feature extraction failed for test bars")?;
NormStats::from_features(&test_feat)
};
// Extract and normalize test features

View File

@@ -3,7 +3,7 @@
//! This module provides a production-ready adapter for optimizing DQN
//! hyperparameters using the generic optimization framework. It implements:
//!
//! - 27D continuous parameter space with log-scale handling
//! - 31D continuous parameter space with log-scale handling
//! - Training wrapper that integrates with existing DQN pipeline
//! - Backtest-based objective using Sharpe ratio (production metric)
//! - Prioritized Experience Replay (PER) tuning for Rainbow DQN performance (+25-40% convergence speed)
@@ -153,7 +153,7 @@ const TRIAL_VRAM_MB: f64 = 7000.0;
/// Corrected: was 0.02 (20KB) — 100x too high
const MB_PER_SAMPLE: f64 = 0.0005;
/// DQN hyperparameter space (17D continuous - WAVE 11 with Rainbow booleans hardcoded to TRUE)
/// DQN hyperparameter space (31D continuous - WAVE 11 with Rainbow booleans hardcoded to TRUE)
///
/// Defines the hyperparameters to optimize for DQN training:
/// **Base Parameters (11D from Wave 1-2)**:
@@ -796,7 +796,7 @@ impl ParameterSpace for DQNParams {
batch_bound.1 = max_batch;
}
}
// Cap hidden_dim_base by VRAM (index 23 in 27D space)
// Cap hidden_dim_base by VRAM (index 23 in 31D space)
let max_base = budget.max_hidden_dim_base(4, 256, 54, 45);
if let Some(dim_bound) = bounds.get_mut(23) {
dim_bound.1 = max_base as f64;
@@ -2248,7 +2248,7 @@ impl HyperparameterOptimizable for DQNTrainer {
// Fix 1: Clamp buffer size to max (4GB GPU constraint)
let clamped_buffer_size = params.buffer_size.min(self.buffer_size_max);
info!("Training DQN with parameters (Wave 6.4: 17D search space):");
info!("Training DQN with parameters (Wave 6.4: 31D search space):");
info!(" Learning rate: {:.6}", params.learning_rate);
info!(" Batch size: {}", params.batch_size);
info!(" Gamma: {:.3}", params.gamma);
@@ -2543,10 +2543,11 @@ impl HyperparameterOptimizable for DQNTrainer {
gae_lambda: 0.95, // Default: 0.95 (standard PPO/A2C value)
// WAVE 26 P1.11: Noisy Network Sigma Scheduling
enable_noisy_sigma_scheduler: false, // Default: disabled (not in search space yet)
noisy_sigma_initial: 0.6, // Default: 60% initial noise
noisy_sigma_final: 0.4, // Default: 40% final noise
noisy_sigma_anneal_steps: 10000, // Default: 10K steps for annealing
// Must match conservative() defaults so hyperopt params generalize to train_best
enable_noisy_sigma_scheduler: true, // Match production defaults (conservative())
noisy_sigma_initial: 0.8, // Match conservative(): 80% initial noise for 45-action space
noisy_sigma_final: 0.4, // 40% final noise
noisy_sigma_anneal_steps: 10000, // 10K steps for annealing
// QR-DQN (replaces disabled C51)
use_qr_dqn: params.use_qr_dqn,
@@ -3327,8 +3328,9 @@ impl HyperparameterOptimizable for DQNTrainer {
0.2 * backtest.sharpe_ratio +
0.1 * backtest.omega_ratio;
// Tail risk penalty
let cvar_penalty = if backtest.cvar_95 < -0.05 { 10.0 } else { 0.0 };
// Tail risk penalty: smooth ramp instead of hard cliff at -5% CVaR.
// Gives PSO gradient signal near the threshold instead of a binary 0/10 jump.
let cvar_penalty = ((-backtest.cvar_95 - 0.05).max(0.0) * 200.0).min(10.0);
// Component 2: HFT activity score (25% weight)
let hft_activity = calculate_hft_activity_score_wave10(buy_pct, sell_pct, hold_pct);

View File

@@ -10,7 +10,7 @@ use candle_core::{Device, Tensor};
use tracing::{debug, info, warn};
use crate::features::extraction::OHLCVBar;
use crate::preprocessing::{preprocess_prices, PreprocessConfig};
use crate::preprocessing::{clip_outliers_with_bounds, compute_clip_bounds, compute_log_returns, windowed_normalize, PreprocessConfig};
use crate::training_pipeline::FinancialFeatures;
use crate::TrainingMetrics;
@@ -299,9 +299,49 @@ impl DQNTrainer {
info!(" • Window size: {}", preprocess_config.window_size);
info!(" • Clip sigma: ±{:.1}σ", preprocess_config.clip_sigma);
// Apply preprocessing
let preprocessed_tensor = preprocess_prices(&close_tensor, preprocess_config)
.context("Failed to preprocess prices")?;
// Split-aware preprocessing to avoid data leakage in clip bounds.
// Steps 1-2 (log returns, windowed normalize) are causal and safe
// to run on the full series. Only the clip bounds must come from
// the training portion.
let returns = if preprocess_config.use_log_returns {
compute_log_returns(&close_tensor)
.context("Failed to compute log returns")?
} else {
// Simple returns path (mirrors preprocess_prices logic)
let n = close_tensor.dims()[0];
let prev = close_tensor.narrow(0, 0, n - 1)
.context("Failed to narrow prev prices")?;
let curr = close_tensor.narrow(0, 1, n - 1)
.context("Failed to narrow curr prices")?;
let simple_ret = curr.sub(&prev)
.context("Failed to compute price diff")?
.div(&prev)
.context("Failed to compute simple returns")?;
let first_zero = Tensor::zeros((1,), simple_ret.dtype(), simple_ret.device())
.context("Failed to create first zero")?;
Tensor::cat(&[&first_zero, &simple_ret], 0)
.context("Failed to concat simple returns")?
};
let normalized = windowed_normalize(&returns, preprocess_config.window_size)
.context("Failed windowed normalization")?;
// Compute clip bounds from training portion only (80/20 split).
// The final sample count after feature extraction is roughly
// (n_bars - warmup), so align the split on the close tensor.
let n_norm = normalized.dims()[0];
let train_end = (n_norm * 80) / 100;
let train_normalized = normalized.narrow(0, 0, train_end)
.context("Failed to narrow training portion for clip bounds")?;
let clip_bounds = compute_clip_bounds(&train_normalized, preprocess_config.clip_sigma)
.context("Failed to compute clip bounds from training data")?;
info!(" • Clip bounds (train-only): [{:.4}, {:.4}]", clip_bounds.0, clip_bounds.1);
// Apply training-derived clip bounds to the full normalized series
let preprocessed_tensor = clip_outliers_with_bounds(
&normalized, clip_bounds.0, clip_bounds.1,
).context("Failed to clip outliers with training bounds")?;
let preprocessed_vec: Vec<f32> = preprocessed_tensor
.to_vec1()