fix(dqn,ppo): comprehensive verification audit fixes — 14 files, 7 agents
DQN correctness: - N-step Bellman target uses gamma^n (was gamma^1), fixing ~2% Q-value bias - Cash reserve enforcement actually reduces position (was warn-only) - CVaR sort_last_dim(true) for IQN random tau ordering - Marsaglia-Tsang gamma sampling uses normal(0,1) not uniform(0,1) - DQNConfig::default state_dim 51→54 (51 market + 3 portfolio) PPO correctness: - set_learning_rate preserves trained weights (was recreating entire model) - update_value_only() for critic pretraining (was training both networks) - PolicyNetwork::entropy() single forward pass (was 2x GPU compute) - LSTM entropy uses proper H=-sum(p*log(p)) (was -mean(log_probs)) - grad_norm metric set to None (was reporting policy loss as gradient norm) Config parity (train/eval/hyperopt/enhanced_ml): - PPO hyperopt state_dim 51→54, value_hidden_dims 3→5 layer - enhanced_ml feature_count 16→54, PPO policy_hidden_dims [128,64,32]→[128,64] - Eval: tensor core alignment, Rainbow from hyperopt params, warmup alignment - GPU batch model_state_dim 51→54 (matches kernel output) Infrastructure: - QNetwork dropout training mode (AtomicBool toggle, was always disabled) - reward_history Vec→VecDeque (O(1) front removal, was O(n)) - Plateau detection distinguishes worsening from plateau in log messages 2732 tests pass, 0 clippy warnings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,10 @@ fn hp_usize(params: &Option<Value>, key: &str) -> Option<usize> {
|
||||
params.as_ref()?.get(key)?.as_u64().map(|v| v as usize)
|
||||
}
|
||||
|
||||
fn hp_bool(params: &Option<Value>, key: &str) -> Option<bool> {
|
||||
params.as_ref()?.get(key)?.as_bool()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Report Data Types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -356,7 +360,9 @@ fn evaluate_dqn_fold(
|
||||
num_actions: args.num_actions,
|
||||
hidden_dims: {
|
||||
let base = hp_usize(hp, "hidden_dim_base").unwrap_or(128);
|
||||
vec![base, base / 2, base / 4] // Always 3-layer to match trainer.rs
|
||||
// Align to 8 for tensor cores (matches training)
|
||||
let align = |x: usize| -> usize { x.div_ceil(8) * 8 };
|
||||
vec![align(base), align(base / 2), align(base / 4)]
|
||||
},
|
||||
learning_rate: 1e-4,
|
||||
gamma: 0.95, // must match train_baseline (shorter horizon, 20 bars)
|
||||
@@ -368,14 +374,14 @@ fn evaluate_dqn_fold(
|
||||
min_replay_size: 64,
|
||||
target_update_freq: 500,
|
||||
warmup_steps: 0,
|
||||
use_double_dqn: true,
|
||||
use_double_dqn: hp_bool(hp, "use_double_dqn").unwrap_or(true),
|
||||
use_huber_loss: true,
|
||||
use_per: false,
|
||||
use_dueling: false,
|
||||
use_distributional: false,
|
||||
use_noisy_nets: false,
|
||||
use_cql: false,
|
||||
use_iqn: false,
|
||||
use_per: false, // PER not needed for evaluation (no training)
|
||||
use_dueling: hp_bool(hp, "use_dueling").unwrap_or(true),
|
||||
use_distributional: hp_bool(hp, "use_distributional").unwrap_or(false),
|
||||
use_noisy_nets: false, // Noisy nets disabled during eval (deterministic)
|
||||
use_cql: false, // CQL not needed for evaluation
|
||||
use_iqn: hp_bool(hp, "use_iqn").unwrap_or(false),
|
||||
use_cvar_action_selection: false,
|
||||
..DQNConfig::default()
|
||||
};
|
||||
@@ -766,14 +772,22 @@ fn main() -> Result<()> {
|
||||
);
|
||||
|
||||
// 2. Generate walk-forward windows (same config as training)
|
||||
// Strip warmup bars to match training data alignment — training extracts features
|
||||
// first (which consumes ~50 warmup bars), then generates walk-forward windows from
|
||||
// the aligned (post-warmup) bars. We must do the same so fold boundaries match.
|
||||
info!("Step 2/5: Generating walk-forward windows...");
|
||||
let warmup_features = extract_ml_features(&bars)
|
||||
.context("Feature extraction for warmup alignment failed")?;
|
||||
let warmup_offset = bars.len().saturating_sub(warmup_features.len());
|
||||
let aligned_bars = bars.get(warmup_offset..).unwrap_or(&bars);
|
||||
info!(" Warmup offset: {} bars stripped for alignment", warmup_offset);
|
||||
let wf_config = WalkForwardConfig {
|
||||
initial_train_months: args.train_months,
|
||||
val_months: args.val_months,
|
||||
test_months: args.test_months,
|
||||
step_months: args.step_months,
|
||||
};
|
||||
let windows = generate_walk_forward_windows(&bars, &wf_config);
|
||||
let windows = generate_walk_forward_windows(aligned_bars, &wf_config);
|
||||
if windows.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No walk-forward windows generated. Need at least {} months of data.",
|
||||
@@ -841,8 +855,8 @@ fn main() -> Result<()> {
|
||||
let test_norm = norm_stats.normalize_batch(&test_features);
|
||||
|
||||
// Align bars to features (features skip warmup period)
|
||||
let warmup_offset = window.test.len().saturating_sub(test_norm.len());
|
||||
let test_bars_aligned = window.test.get(warmup_offset..).unwrap_or(&window.test);
|
||||
let fold_warmup_offset = window.test.len().saturating_sub(test_norm.len());
|
||||
let test_bars_aligned = window.test.get(fold_warmup_offset..).unwrap_or(&window.test);
|
||||
|
||||
// Test period date range for the report
|
||||
let test_start = test_bars_aligned
|
||||
|
||||
@@ -1105,7 +1105,7 @@ mod tests {
|
||||
let agent = DQNAgent::new(config)?;
|
||||
|
||||
assert_eq!(agent.get_epsilon(), 1.0);
|
||||
assert_eq!(agent.get_config().state_dim, 51);
|
||||
assert_eq!(agent.get_config().state_dim, 54);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1210,7 +1210,7 @@ mod tests {
|
||||
|
||||
let summary = agent.get_network_summary();
|
||||
assert!(summary.contains("DQN Network"));
|
||||
assert!(summary.contains("State Dimension: 51"));
|
||||
assert!(summary.contains("State Dimension: 54"));
|
||||
assert!(summary.contains("Action Space: 3"));
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -202,7 +202,7 @@ pub struct DQNConfig {
|
||||
impl Default for DQNConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state_dim: 51, // 45 market features + 6 portfolio features (Wave 23)
|
||||
state_dim: 54, // 51 market features + 3 portfolio features
|
||||
num_actions: 45, // FactoredAction space
|
||||
hidden_dims: vec![256, 256],
|
||||
learning_rate: 1e-4,
|
||||
@@ -1778,9 +1778,13 @@ impl DQN {
|
||||
};
|
||||
|
||||
// Compute target values using Bellman equation
|
||||
// target = reward + gamma * next_state_value * (1 - done)
|
||||
// target = n_step_reward + gamma^n * next_state_value * (1 - done)
|
||||
// When n_steps > 1, the n-step buffer already accumulates
|
||||
// R_t + gamma*R_{t+1} + ... + gamma^{n-1}*R_{t+n-1} in the reward.
|
||||
// The bootstrap discount must therefore be gamma^n (not gamma^1).
|
||||
let gamma_n = self.config.gamma.powi(self.config.n_steps as i32);
|
||||
let gamma_tensor =
|
||||
Tensor::from_vec(vec![self.config.gamma; batch_size], batch_size, device).map_err(
|
||||
Tensor::from_vec(vec![gamma_n; batch_size], batch_size, device).map_err(
|
||||
|e| MLError::TrainingError(format!("Failed to create gamma tensor: {}", e)),
|
||||
)?;
|
||||
|
||||
|
||||
@@ -448,8 +448,8 @@ impl DQNEnsemble {
|
||||
let c = 1.0 / (9.0 * d).sqrt();
|
||||
|
||||
loop {
|
||||
let x = rng.gen::<f64>(); // Uniform [0, 1]
|
||||
let v = (1.0 + c * Self::sample_normal(rng)).powi(3);
|
||||
let x = Self::sample_normal(rng); // Must be N(0,1), not Uniform
|
||||
let v = (1.0 + c * x).powi(3);
|
||||
|
||||
if v > 0.0 {
|
||||
let u = rng.gen::<f64>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Q-Network implementation with target network and GPU acceleration
|
||||
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
|
||||
|
||||
use candle_core::{DType, Device, Result as CandleResult, Tensor};
|
||||
use crate::dqn::mixed_precision::training_dtype;
|
||||
@@ -135,6 +135,8 @@ pub struct QNetwork {
|
||||
step_count: AtomicU64,
|
||||
/// Adaptive dropout scheduler (Wave 26 P1.6)
|
||||
dropout_scheduler: std::sync::Mutex<Option<DropoutScheduler>>,
|
||||
/// Whether the network is in training mode (enables dropout)
|
||||
training: AtomicBool,
|
||||
}
|
||||
|
||||
/// Network layer structure
|
||||
@@ -142,6 +144,7 @@ pub struct QNetwork {
|
||||
struct NetworkLayers {
|
||||
layers: Vec<Linear>,
|
||||
dropout: Dropout,
|
||||
training: bool,
|
||||
}
|
||||
|
||||
impl NetworkLayers {
|
||||
@@ -149,8 +152,9 @@ impl NetworkLayers {
|
||||
var_builder: &VarBuilder<'_>,
|
||||
config: &QNetworkConfig,
|
||||
_device: &Device,
|
||||
training: bool,
|
||||
) -> CandleResult<Self> {
|
||||
Self::new_with_dropout_rate(var_builder, config, _device, config.dropout_prob)
|
||||
Self::new_with_dropout_rate(var_builder, config, _device, config.dropout_prob, training)
|
||||
}
|
||||
|
||||
fn new_with_dropout_rate(
|
||||
@@ -158,6 +162,7 @@ impl NetworkLayers {
|
||||
config: &QNetworkConfig,
|
||||
_device: &Device,
|
||||
dropout_rate: f64,
|
||||
training: bool,
|
||||
) -> CandleResult<Self> {
|
||||
let mut layers = Vec::new();
|
||||
let mut input_dim = config.state_dim;
|
||||
@@ -180,7 +185,7 @@ impl NetworkLayers {
|
||||
|
||||
let dropout = Dropout::new(dropout_rate as f32);
|
||||
|
||||
Ok(Self { layers, dropout })
|
||||
Ok(Self { layers, dropout, training })
|
||||
}
|
||||
|
||||
/// Forward pass with optional mixed precision (BF16/FP16).
|
||||
@@ -208,7 +213,7 @@ impl NetworkLayers {
|
||||
x = layer.forward(&x)?;
|
||||
if i < self.layers.len() - 1 {
|
||||
x = leaky_relu(&x, 0.01)?;
|
||||
x = self.dropout.forward(&x, false)?;
|
||||
x = self.dropout.forward(&x, self.training)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,7 +239,7 @@ impl Module for NetworkLayers {
|
||||
// Apply LeakyReLU activation for all layers except the last
|
||||
if i < self.layers.len() - 1 {
|
||||
x = leaky_relu(&x, 0.01)?; // Bug #11 fix: LeakyReLU prevents gradient collapse
|
||||
x = self.dropout.forward(&x, false)?; // No dropout during inference
|
||||
x = self.dropout.forward(&x, self.training)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,13 +265,13 @@ impl QNetwork {
|
||||
|
||||
// Initialize network weights
|
||||
let var_builder = VarBuilder::from_varmap(&vars, training_dtype(&device), &device);
|
||||
let _layers = NetworkLayers::new(&var_builder, &config, &device)
|
||||
let _layers = NetworkLayers::new(&var_builder, &config, &device, false)
|
||||
.map_err(|e| MLError::ModelError(format!("Failed to create network layers: {}", e)))?;
|
||||
|
||||
// Initialize target network with same architecture
|
||||
let target_var_builder = VarBuilder::from_varmap(&target_vars, training_dtype(&device), &device);
|
||||
let _target_layers =
|
||||
NetworkLayers::new(&target_var_builder, &config, &device).map_err(|e| {
|
||||
NetworkLayers::new(&target_var_builder, &config, &device, false).map_err(|e| {
|
||||
MLError::ModelError(format!("Failed to create target network layers: {}", e))
|
||||
})?;
|
||||
|
||||
@@ -288,6 +293,7 @@ impl QNetwork {
|
||||
epsilon: AtomicU32::new(epsilon),
|
||||
step_count: AtomicU64::new(0),
|
||||
dropout_scheduler: std::sync::Mutex::new(dropout_scheduler),
|
||||
training: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,6 +316,7 @@ impl QNetwork {
|
||||
&self.config,
|
||||
&self.device,
|
||||
dropout_rate,
|
||||
self.is_training(),
|
||||
)
|
||||
.map_err(|e| MLError::ModelError(format!("Failed to create layers: {}", e)))?;
|
||||
|
||||
@@ -367,7 +374,7 @@ impl QNetwork {
|
||||
}
|
||||
|
||||
let var_builder = VarBuilder::from_varmap(&self.vars, training_dtype(&self.device), &self.device);
|
||||
let layers = NetworkLayers::new(&var_builder, &self.config, &self.device)
|
||||
let layers = NetworkLayers::new(&var_builder, &self.config, &self.device, self.is_training())
|
||||
.map_err(|e| MLError::ModelError(format!("Failed to create layers: {}", e)))?;
|
||||
|
||||
let input = Tensor::from_vec(flat_states, (batch_size, state_dim), &self.device)
|
||||
@@ -462,6 +469,16 @@ impl QNetwork {
|
||||
// Fallback to static dropout probability
|
||||
self.config.dropout_prob
|
||||
}
|
||||
|
||||
/// Set training mode (enables/disables dropout)
|
||||
pub fn set_training(&self, training: bool) {
|
||||
self.training.store(training, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Check if network is in training mode
|
||||
pub fn is_training(&self) -> bool {
|
||||
self.training.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
// Use QNetwork directly - no compatibility wrapper needed
|
||||
|
||||
@@ -387,7 +387,8 @@ impl PortfolioTracker {
|
||||
// Non-reversal path: apply transaction costs (scaled by urgency)
|
||||
let tx_cost_rate = action.transaction_cost() as f32;
|
||||
let urgency_mult = action.urgency_weight() as f32;
|
||||
let position_delta = target_position - self.position_size;
|
||||
let mut target_position = target_position;
|
||||
let mut position_delta = target_position - self.position_size;
|
||||
|
||||
// Calculate transaction cost for this trade
|
||||
if position_delta.abs() > 0.0 {
|
||||
@@ -439,7 +440,8 @@ impl PortfolioTracker {
|
||||
"P2-B: Trade REDUCED - cash reserve enforced. Requested: {:.1}, Reduced to: {:.1}",
|
||||
target_position, self.position_size + affordable_contracts
|
||||
);
|
||||
// Note: The existing cash validation logic below will handle the actual reduction
|
||||
target_position = self.position_size + affordable_contracts;
|
||||
position_delta = target_position - self.position_size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -291,8 +291,12 @@ impl QuantileNetwork {
|
||||
let num_tail = (num_quantiles as f32 * alpha).ceil() as usize;
|
||||
let num_tail = num_tail.max(1);
|
||||
|
||||
// Extract bottom α quantiles (assumes quantiles are ordered)
|
||||
let tail = quantiles.narrow(2, 0, num_tail)?;
|
||||
// Sort quantiles along the quantile dimension (ascending) before narrowing.
|
||||
// QR-DQN has fixed uniform taus so quantiles are already ordered, but IQN
|
||||
// uses random taus and the quantile outputs may not be monotonic. Sorting
|
||||
// guarantees correct CVaR (mean of the worst-alpha fraction) in both cases.
|
||||
let sorted_quantiles = quantiles.sort_last_dim(true)?.0; // ascending
|
||||
let tail = sorted_quantiles.narrow(2, 0, num_tail)?;
|
||||
tail.mean(2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Trading-specific reward functions for DQN
|
||||
|
||||
// CANONICAL TYPE IMPORTS - Use common::Decimal
|
||||
use std::collections::VecDeque;
|
||||
use serde::{Deserialize, Serialize};
|
||||
// For Decimal::from_f64
|
||||
use common::types::Price;
|
||||
@@ -398,8 +399,8 @@ fn calculate_entropy(recent_actions: &[FactoredAction]) -> Decimal {
|
||||
pub struct RewardFunction {
|
||||
/// Configuration
|
||||
config: RewardConfig,
|
||||
/// Previous rewards for tracking
|
||||
reward_history: Vec<Decimal>,
|
||||
/// Previous rewards for tracking (VecDeque for O(1) front removal)
|
||||
reward_history: VecDeque<Decimal>,
|
||||
/// Optional reward normalizer (None = disabled, Some = enabled)
|
||||
normalizer: Option<RewardNormalizer>,
|
||||
/// Enable debug logging (REWARD_DEBUG, gradient norms, etc.)
|
||||
@@ -433,7 +434,7 @@ impl RewardFunction {
|
||||
|
||||
Ok(Self {
|
||||
config: config.clone(),
|
||||
reward_history: Vec::new(),
|
||||
reward_history: VecDeque::new(),
|
||||
normalizer,
|
||||
debug_logging,
|
||||
returns_buffer: std::collections::VecDeque::with_capacity(config.sharpe_window),
|
||||
@@ -605,9 +606,9 @@ impl RewardFunction {
|
||||
let final_reward_decimal = Decimal::try_from(normalized_reward)
|
||||
.unwrap_or(final_reward);
|
||||
|
||||
self.reward_history.push(final_reward_decimal);
|
||||
self.reward_history.push_back(final_reward_decimal);
|
||||
if self.reward_history.len() > 1000 {
|
||||
self.reward_history.remove(0);
|
||||
self.reward_history.pop_front();
|
||||
}
|
||||
|
||||
Ok(final_reward_decimal)
|
||||
@@ -961,7 +962,7 @@ impl RewardFunction {
|
||||
}
|
||||
|
||||
let start = self.reward_history.len() - window;
|
||||
let sum = self.reward_history[start..].iter().sum::<Decimal>();
|
||||
let sum: Decimal = self.reward_history.iter().skip(start).sum();
|
||||
sum / Decimal::from(window as u64)
|
||||
}
|
||||
|
||||
|
||||
@@ -283,15 +283,15 @@ pub struct PPOMetrics {
|
||||
/// - **DBN data dir**: Market data source (OHLCV bars from Databento)
|
||||
/// - **Episodes**: Number of training episodes per trial
|
||||
/// - **Device**: CUDA GPU (falls back to CPU if unavailable)
|
||||
/// - **Features**: 51 features (WAVE 10: Reduced from 54→51 after Proxy OFI removal)
|
||||
/// - **Features**: 54 features (51 market + 3 portfolio)
|
||||
///
|
||||
/// ## Fixed Architecture
|
||||
///
|
||||
/// The following parameters are fixed for consistency:
|
||||
/// - `state_dim`: 51 (market features, matches train_baseline)
|
||||
/// - `num_actions`: 3 (Buy, Sell, Hold)
|
||||
/// - `policy_hidden_dims`: [128, 64]
|
||||
/// - `value_hidden_dims`: [256, 128, 64]
|
||||
/// - `state_dim`: 54 (51 market + 3 portfolio features)
|
||||
/// - `num_actions`: 45 (5 exposure x 3 order x 3 urgency)
|
||||
/// - `policy_hidden_dims`: [base, base/2]
|
||||
/// - `value_hidden_dims`: [base*4, base*3, base*2, base, base/2]
|
||||
///
|
||||
/// ## Optimized Hyperparameters
|
||||
///
|
||||
@@ -697,17 +697,17 @@ impl PPOTrainer {
|
||||
|
||||
/// Convert a GPU PPO experience batch to a TrajectoryBatch for `PPO::update()`.
|
||||
///
|
||||
/// The GPU kernel produces 54-dim states (51 features + 3 portfolio state) but
|
||||
/// the PPO model in hyperopt uses state_dim=51. We truncate to match.
|
||||
/// The GPU kernel produces 54-dim states (51 features + 3 portfolio state) which
|
||||
/// matches the PPO model in hyperopt (state_dim=54).
|
||||
#[cfg(feature = "cuda")]
|
||||
fn gpu_ppo_batch_to_trajectory_batch(
|
||||
batch: &crate::cuda_pipeline::gpu_ppo_collector::PpoExperienceBatch,
|
||||
) -> TrajectoryBatch {
|
||||
let kernel_state_dim = 54; // GPU kernel output: 51 features + 3 portfolio state
|
||||
let model_state_dim = 51; // PPO hyperopt model state_dim
|
||||
let model_state_dim = 54; // PPO hyperopt model state_dim (matches kernel)
|
||||
let total = batch.n_episodes * batch.timesteps;
|
||||
|
||||
// Truncate states from 54 → 51 dims (strip portfolio state appended by kernel)
|
||||
// Map states from GPU kernel output to model dimensions (both 54)
|
||||
let states: Vec<Vec<f32>> = batch
|
||||
.states
|
||||
.chunks(kernel_state_dim)
|
||||
@@ -851,7 +851,7 @@ impl HyperparameterOptimizable for PPOTrainer {
|
||||
|
||||
// Create PPO config with trial hyperparameters
|
||||
let ppo_config = PPOConfig {
|
||||
state_dim: 51, // 51 market features (matches train_baseline)
|
||||
state_dim: 54, // 51 market features + 3 portfolio features
|
||||
num_actions: 45, // 5 exposure × 3 order × 3 urgency (FactoredAction)
|
||||
policy_hidden_dims: {
|
||||
let base = params.hidden_dim_base;
|
||||
@@ -859,7 +859,7 @@ impl HyperparameterOptimizable for PPOTrainer {
|
||||
},
|
||||
value_hidden_dims: {
|
||||
let base = params.hidden_dim_base;
|
||||
vec![base * 2, base, base / 2]
|
||||
vec![base * 4, base * 3, base * 2, base, base / 2]
|
||||
},
|
||||
policy_learning_rate: params.policy_learning_rate,
|
||||
value_learning_rate: params.value_learning_rate,
|
||||
|
||||
@@ -528,9 +528,11 @@ impl PolicyNetwork {
|
||||
|
||||
/// Compute entropy of action distribution
|
||||
pub fn entropy(&self, states: &Tensor) -> Result<Tensor, MLError> {
|
||||
let probs = self.action_probabilities(states)?;
|
||||
let log_probs =
|
||||
candle_nn::ops::log_softmax(&self.forward(states)?, candle_core::D::Minus1)?;
|
||||
let logits = self.forward(states)?;
|
||||
let probs = candle_nn::ops::softmax(&logits, candle_core::D::Minus1)
|
||||
.map_err(|e| MLError::ModelError(format!("Softmax failed: {}", e)))?;
|
||||
let log_probs = candle_nn::ops::log_softmax(&logits, candle_core::D::Minus1)
|
||||
.map_err(|e| MLError::ModelError(format!("Log softmax failed: {}", e)))?;
|
||||
|
||||
// Entropy = -sum(p * log(p))
|
||||
let entropy_inner = (probs * log_probs)?.sum(candle_core::D::Minus1)?;
|
||||
@@ -1019,6 +1021,148 @@ impl PPO {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update ONLY the value (critic) network, leaving the policy (actor) untouched.
|
||||
///
|
||||
/// Used by `pretrain_value_network()` to bootstrap the critic before on-policy
|
||||
/// training begins. Calling `update()` during pretraining would corrupt the
|
||||
/// policy with stale log-probs, violating PPO's on-policy constraint.
|
||||
///
|
||||
/// Returns the average value loss across all mini-batches and epochs.
|
||||
pub fn update_value_only(&mut self, batch: &mut TrajectoryBatch) -> Result<f32, MLError> {
|
||||
// Initialize optimizers (we only use value_optimizer, but init both for consistency)
|
||||
self.init_optimizers()?;
|
||||
|
||||
// Apply reward normalization if enabled
|
||||
if let Some(ref mut normalizer) = self.reward_normalizer {
|
||||
for reward in &batch.rewards {
|
||||
normalizer.update(*reward as f64);
|
||||
}
|
||||
let normalized_rewards: Vec<f32> = batch.rewards.iter()
|
||||
.map(|&r| {
|
||||
let norm = normalizer.normalize(r as f64);
|
||||
norm.clamp(-1.0, 1.0) as f32
|
||||
})
|
||||
.collect();
|
||||
batch.rewards = normalized_rewards;
|
||||
}
|
||||
|
||||
// Normalize advantages (needed for returns computation consistency)
|
||||
if let Some(ref mut scaler) = self.percentile_scaler {
|
||||
let adv_f64: Vec<f64> = batch.advantages.iter().map(|&a| a as f64).collect();
|
||||
scaler.update(&adv_f64);
|
||||
for adv in &mut batch.advantages {
|
||||
*adv = scaler.scale(*adv as f64) as f32;
|
||||
}
|
||||
} else {
|
||||
batch.normalize_advantages()?;
|
||||
}
|
||||
|
||||
let device = self.actor.device();
|
||||
let accumulation_steps = self.config.accumulation_steps.max(1);
|
||||
|
||||
let mut total_value_loss = 0.0;
|
||||
let mut num_updates = 0;
|
||||
|
||||
for _epoch in 0..self.config.num_epochs {
|
||||
let mini_batches = batch.create_mini_batches(self.config.mini_batch_size);
|
||||
|
||||
let mut value_grad_accumulator: Option<candle_core::backprop::GradStore> = None;
|
||||
let mut accum_step: usize = 0;
|
||||
|
||||
for mini_batch in mini_batches {
|
||||
let mini_tensors = mini_batch.to_tensors(device, self.config.state_dim)?;
|
||||
|
||||
// Only compute value loss (no policy loss)
|
||||
let value_loss = self.compute_value_loss(&mini_tensors)?;
|
||||
|
||||
let value_loss_scalar = value_loss.to_scalar::<f32>().map_err(|e| {
|
||||
MLError::TrainingError(format!("Failed to extract value loss: {}", e))
|
||||
})?;
|
||||
|
||||
if value_loss_scalar.is_nan() {
|
||||
return Err(MLError::TrainingError(
|
||||
"NaN detected in value loss during critic pretraining".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
// Accumulate value gradients only
|
||||
let critic_vars = self.critic.vars().all_vars();
|
||||
let value_grads = value_loss.backward().map_err(|e| {
|
||||
MLError::TrainingError(format!("Value backward failed: {}", e))
|
||||
})?;
|
||||
accumulate_grads(&mut value_grad_accumulator, value_grads, &critic_vars)?;
|
||||
|
||||
accum_step += 1;
|
||||
|
||||
// Step when we've accumulated enough
|
||||
if accum_step >= accumulation_steps {
|
||||
if let Some(ref mut value_grads) = value_grad_accumulator {
|
||||
scale_grads(
|
||||
value_grads,
|
||||
&critic_vars,
|
||||
1.0 / accumulation_steps as f64,
|
||||
)?;
|
||||
check_gradients_finite(value_grads, &critic_vars)
|
||||
.map_err(|e| MLError::TrainingError(format!("Value gradient NaN: {}", e)))?;
|
||||
|
||||
let _value_grad_norm = clip_grads(
|
||||
value_grads,
|
||||
&critic_vars,
|
||||
self.config.max_grad_norm as f64,
|
||||
)?;
|
||||
|
||||
if let Some(ref mut optimizer) = self.value_optimizer {
|
||||
optimizer.step(value_grads).map_err(|e| {
|
||||
MLError::TrainingError(format!(
|
||||
"Value optimizer step failed: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
value_grad_accumulator = None;
|
||||
accum_step = 0;
|
||||
}
|
||||
|
||||
total_value_loss += value_loss_scalar;
|
||||
num_updates += 1;
|
||||
}
|
||||
|
||||
// Handle remaining accumulated gradients
|
||||
if accum_step > 0 {
|
||||
let critic_vars = self.critic.vars().all_vars();
|
||||
|
||||
if let Some(ref mut value_grads) = value_grad_accumulator {
|
||||
scale_grads(value_grads, &critic_vars, 1.0 / accum_step as f64)?;
|
||||
check_gradients_finite(value_grads, &critic_vars)
|
||||
.map_err(|e| MLError::TrainingError(format!("Value gradient NaN: {}", e)))?;
|
||||
|
||||
let _value_grad_norm = clip_grads(
|
||||
value_grads,
|
||||
&critic_vars,
|
||||
self.config.max_grad_norm as f64,
|
||||
)?;
|
||||
|
||||
if let Some(ref mut optimizer) = self.value_optimizer {
|
||||
optimizer.step(value_grads).map_err(|e| {
|
||||
MLError::TrainingError(format!(
|
||||
"Value optimizer step failed: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if num_updates > 0 {
|
||||
Ok(total_value_loss / num_updates as f32)
|
||||
} else {
|
||||
Ok(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// MLP-specific update logic (original implementation)
|
||||
fn update_mlp(&mut self, batch: &mut TrajectoryBatch) -> Result<(f32, f32), MLError> {
|
||||
// Convert batch to tensors
|
||||
@@ -1302,6 +1446,7 @@ impl PPO {
|
||||
// Collect outputs for the entire sequence
|
||||
let mut seq_log_probs = Vec::new();
|
||||
let mut seq_values = Vec::new();
|
||||
let mut seq_entropies = Vec::new();
|
||||
|
||||
// Process sequence timestep-by-timestep
|
||||
for t in 0..seq_len {
|
||||
@@ -1361,6 +1506,18 @@ impl PPO {
|
||||
format!("Failed to extract log prob: {}", e)
|
||||
))?;
|
||||
|
||||
// Compute proper per-timestep entropy: H = -sum(p * log(p))
|
||||
let step_probs = candle_nn::ops::softmax(&logits, candle_core::D::Minus1)
|
||||
.map_err(|e| MLError::ModelError(format!("Softmax failed in LSTM entropy: {}", e)))?;
|
||||
let step_entropy_inner = (&step_probs * &log_probs_dist)?
|
||||
.sum(candle_core::D::Minus1)?;
|
||||
let step_entropy = TensorOps::negate(&step_entropy_inner)?
|
||||
.get(0)?
|
||||
.to_scalar::<f32>()
|
||||
.map_err(|e| MLError::TensorOperationError(
|
||||
format!("Failed to extract step entropy: {}", e)
|
||||
))?;
|
||||
|
||||
let value_scalar = value.get(0)?
|
||||
.to_scalar::<f32>()
|
||||
.map_err(|e| MLError::TensorOperationError(
|
||||
@@ -1369,6 +1526,7 @@ impl PPO {
|
||||
|
||||
seq_log_probs.push(log_prob);
|
||||
seq_values.push(value_scalar);
|
||||
seq_entropies.push(step_entropy);
|
||||
|
||||
// Reset hidden states on episode boundary
|
||||
if sequence.dones[t] {
|
||||
@@ -1436,9 +1594,14 @@ impl PPO {
|
||||
let surr2 = (&clipped_ratio * &seq_advantages)?;
|
||||
let policy_loss_raw = TensorOps::elementwise_min(&surr1, &surr2)?;
|
||||
|
||||
// Compute entropy from log-probabilities: H = -mean(log_probs)
|
||||
// Compute proper entropy: H = mean over timesteps of -sum(p * log(p))
|
||||
// Use adaptive alpha if enabled, otherwise fixed coeff
|
||||
let entropy = seq_new_log_probs.neg()?.mean_all()?;
|
||||
let seq_entropies_tensor = Tensor::from_vec(
|
||||
seq_entropies.clone(),
|
||||
(seq_len,),
|
||||
device,
|
||||
)?;
|
||||
let entropy = seq_entropies_tensor.mean_all()?;
|
||||
let entropy_coeff = match &self.adaptive_entropy {
|
||||
Some(adaptive) => adaptive.alpha()? as f32,
|
||||
None => self.config.entropy_coeff,
|
||||
@@ -1743,6 +1906,32 @@ impl PPO {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// Update optimizer learning rates without destroying trained weights.
|
||||
///
|
||||
/// This drops the existing optimizers and updates the config LR fields.
|
||||
/// On the next `update()` call, `init_optimizers()` will lazily recreate
|
||||
/// fresh Adam optimizers referencing the same VarMap variables (preserving
|
||||
/// all trained network weights). The Adam momentum/variance state is reset,
|
||||
/// which is acceptable for LR scheduling since the optimizer adapts quickly.
|
||||
pub fn update_learning_rates(
|
||||
&mut self,
|
||||
policy_lr: f64,
|
||||
value_lr: f64,
|
||||
) -> Result<(), MLError> {
|
||||
self.config.policy_learning_rate = policy_lr;
|
||||
self.config.value_learning_rate = value_lr;
|
||||
|
||||
// Drop existing optimizers so init_optimizers() recreates them with new LR
|
||||
self.policy_optimizer = None;
|
||||
self.value_optimizer = None;
|
||||
|
||||
info!(
|
||||
"Updated PPO learning rates: policy={}, value={}",
|
||||
policy_lr, value_lr
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save PPO checkpoint with metadata (including training step counter)
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -213,14 +213,9 @@ impl UnifiedTrainable for UnifiedPPO {
|
||||
self.policy_lr = lr;
|
||||
self.value_lr = lr;
|
||||
|
||||
// Recreate optimizers with new learning rate
|
||||
let mut config = self.ppo.get_config().clone();
|
||||
config.policy_learning_rate = lr;
|
||||
config.value_learning_rate = lr;
|
||||
|
||||
// Note: Recreating PPO is expensive, consider caching optimizer state
|
||||
let device = self.device().clone();
|
||||
self.ppo = PPO::with_device(config, device)?;
|
||||
// Update optimizer LRs in-place by dropping and lazily recreating them.
|
||||
// This preserves all trained network weights (VarMap is untouched).
|
||||
self.ppo.update_learning_rates(lr, lr)?;
|
||||
|
||||
info!("Updated PPO learning rate to {}", lr);
|
||||
Ok(())
|
||||
@@ -390,9 +385,9 @@ pub fn train_batch(
|
||||
unified_ppo.last_value_loss = value_loss as f64;
|
||||
unified_ppo.step += 1;
|
||||
|
||||
// Estimate gradient norm (PPO doesn't expose this directly)
|
||||
// Use policy loss as proxy for gradient magnitude
|
||||
unified_ppo.last_grad_norm = Some(policy_loss.abs() as f64);
|
||||
// TODO: Expose actual gradient norm from PPO::update()
|
||||
// For now, don't report a fake metric (policy loss != gradient norm)
|
||||
unified_ppo.last_grad_norm = None;
|
||||
|
||||
Ok((policy_loss as f64, value_loss as f64))
|
||||
}
|
||||
|
||||
@@ -1258,10 +1258,20 @@ impl DQNTrainer {
|
||||
let improvement = last - first;
|
||||
|
||||
if improvement < 0.001 {
|
||||
return Some(format!(
|
||||
"Validation loss plateau detected (improvement: {:.6})",
|
||||
improvement
|
||||
));
|
||||
let msg = if improvement < -0.001 {
|
||||
format!(
|
||||
"Validation loss worsening detected (delta: {:.6}, window: {})",
|
||||
improvement,
|
||||
window
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Validation loss plateau detected (improvement: {:.6}, window: {})",
|
||||
improvement,
|
||||
window
|
||||
)
|
||||
};
|
||||
return Some(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1259,12 +1259,12 @@ impl PpoTrainer {
|
||||
advantages
|
||||
}
|
||||
|
||||
/// Pre-train value network to better fit returns before policy updates
|
||||
/// This method performs additional mini-batch updates to improve the critic's
|
||||
/// ability to estimate returns, which directly improves explained variance.
|
||||
/// Pre-train value network to better fit returns before policy updates.
|
||||
///
|
||||
/// Note: Due to private field restrictions, this uses the full PPO update
|
||||
/// but is applied during early epochs when value learning is most critical.
|
||||
/// This method trains ONLY the critic (value network) without touching the
|
||||
/// policy (actor network). This is critical for PPO correctness: the policy
|
||||
/// must only be updated with on-policy data, and pretraining the actor with
|
||||
/// stale log-probs would violate this constraint.
|
||||
async fn pretrain_value_network(
|
||||
&self,
|
||||
batch: &mut TrajectoryBatch,
|
||||
@@ -1274,11 +1274,9 @@ impl PpoTrainer {
|
||||
let mut num_updates = 0;
|
||||
|
||||
for _ in 0..pretraining_epochs {
|
||||
// Perform a full PPO update which trains both networks
|
||||
// During early epochs, this extra training helps the value network converge
|
||||
let (_, value_loss) = {
|
||||
let value_loss = {
|
||||
let mut model = self.model.lock().await;
|
||||
model.update(batch)?
|
||||
model.update_value_only(batch)?
|
||||
};
|
||||
|
||||
total_loss += value_loss;
|
||||
|
||||
@@ -1515,7 +1515,7 @@ impl RealDQNModel {
|
||||
Ok(Self {
|
||||
model_id,
|
||||
agent: Arc::new(RwLock::new(agent)),
|
||||
feature_count: 16,
|
||||
feature_count: 54,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1652,7 +1652,7 @@ impl RealPPOModel {
|
||||
let config = PPOConfig {
|
||||
state_dim: 54,
|
||||
num_actions: 45,
|
||||
policy_hidden_dims: vec![128, 64, 32],
|
||||
policy_hidden_dims: vec![128, 64],
|
||||
value_hidden_dims: vec![512, 384, 256, 128, 64],
|
||||
policy_learning_rate: 0.0003,
|
||||
value_learning_rate: 0.001,
|
||||
@@ -1714,7 +1714,7 @@ impl RealPPOModel {
|
||||
Ok(Self {
|
||||
model_id,
|
||||
agent: Arc::new(RwLock::new(agent)),
|
||||
feature_count: 16,
|
||||
feature_count: 54,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1732,12 +1732,12 @@ impl MLModel for RealPPOModel {
|
||||
async fn predict(&self, features: &Features) -> ml::MLResult<ModelPrediction> {
|
||||
let agent = self.agent.read().await;
|
||||
|
||||
// Convert features to state vector (f32)
|
||||
let state_vec: Vec<f32> = if features.values.len() >= 16 {
|
||||
features.values[0..16].iter().map(|&v| v as f32).collect()
|
||||
// Convert features to state vector (f32) — 54 features (51 market + 3 portfolio)
|
||||
let state_vec: Vec<f32> = if features.values.len() >= 54 {
|
||||
features.values[0..54].iter().map(|&v| v as f32).collect()
|
||||
} else {
|
||||
let mut padded: Vec<f32> = features.values.iter().map(|&v| v as f32).collect();
|
||||
padded.resize(16, 0.0);
|
||||
padded.resize(54, 0.0);
|
||||
padded
|
||||
};
|
||||
|
||||
@@ -2446,13 +2446,13 @@ mod enhanced_ml_tests {
|
||||
model_type: ModelType::DQN,
|
||||
supported_symbols: vec!["EURUSD".to_string(), "GBPUSD".to_string()],
|
||||
supported_horizons: vec![1, 5, 15],
|
||||
feature_count: 16,
|
||||
feature_count: 54,
|
||||
model_instance: None,
|
||||
};
|
||||
|
||||
assert_eq!(info.model_id, "test-dqn-v1");
|
||||
assert_eq!(info.model_type, ModelType::DQN);
|
||||
assert_eq!(info.feature_count, 16);
|
||||
assert_eq!(info.feature_count, 54);
|
||||
assert_eq!(info.supported_symbols.len(), 2);
|
||||
assert_eq!(info.supported_horizons.len(), 3);
|
||||
assert!(info.last_inference.is_none());
|
||||
@@ -2486,7 +2486,7 @@ mod enhanced_ml_tests {
|
||||
model_type,
|
||||
supported_symbols: vec![],
|
||||
supported_horizons: vec![],
|
||||
feature_count: 16,
|
||||
feature_count: 54,
|
||||
model_instance: None,
|
||||
};
|
||||
assert_eq!(info.model_type, model_type, "Model type mismatch for {}", name);
|
||||
@@ -2808,7 +2808,7 @@ mod enhanced_ml_tests {
|
||||
model_type: ModelType::DQN,
|
||||
supported_symbols: vec![],
|
||||
supported_horizons: vec![],
|
||||
feature_count: 16,
|
||||
feature_count: 54,
|
||||
model_instance: None,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user