Wave 11: Rainbow DQN integration + 23/23 tests passing
CRITICAL FINDINGS from 3-trial validation: - 85,120 gradient clipping warnings (81.6% of logs) - REGRESSION - Rainbow features DISABLED: use_dueling=false, use_distributional=false, use_noisy_nets=false - Negative Q-values confirmed: HOLD -1000 to -3250 - Performance: Sharpe 0.29 (target 0.77) Changes: - Fixed N-Step compilation (7/7 tests passing) - Fixed Distributional compilation (6/6 tests passing) - Fixed Dueling CUDA errors (10/10 tests passing) - Added TDD validation for state_dim=225 - Total: 23/23 Wave 11 tests passing (100%) Issues requiring investigation: 1. Why are Dueling/Distributional/Noisy disabled in hyperopt? 2. Why gradient explosion despite previous fixes? 3. Test coverage gaps - unit tests pass but integration fails 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,402 +1,236 @@
|
||||
<GENERATED-CODE>
|
||||
Here are the step-by-step instructions to fix the training instability and improve reward scaling.
|
||||
I will create the core flow matching loss function in a new, self-contained module.
|
||||
|
||||
1. **Update `ml/src/trainers/dqn.rs`**: Modify the training loop to break the positive feedback loop in the risk-adjusted reward calculation. We will update the PnL history tracker with the raw reward *before* applying the Sharpe ratio adjustment.
|
||||
1. Create a new directory `ml/src/ppo/flow_policy/` to house components related to the flow-based policy.
|
||||
2. Create a new module file `ml/src/ppo/flow_policy/mod.rs` to declare the `flow_matching` submodule.
|
||||
3. Create the implementation file `ml/src/ppo/flow_policy/flow_matching.rs`, which contains the PPO clipped surrogate objective adapted for flow-based policies.
|
||||
|
||||
2. **Update `ml/src/dqn/reward.rs`**: Overhaul the reward logic to be more statistically robust. This involves three key changes:
|
||||
* Refactor the P&L reward to be based on percentage portfolio change, which is a more stationary signal.
|
||||
* Introduce a `RewardNormalizer` struct that uses Welford's algorithm to compute running mean and standard deviation.
|
||||
* Integrate the normalizer into the `RewardFunction` to automatically scale all rewards before they are used for training.
|
||||
|
||||
<UPDATED_EXISTING_FILE: ml/src/trainers/dqn.rs>
|
||||
<NEWFILE: ml/src/ppo/flow_policy/mod.rs>
|
||||
```rust
|
||||
// ... (code before line 1169)
|
||||
let raw_reward = reward_decimal.to_string().parse::<f32>().unwrap_or(0.0) as f64;
|
||||
//! # Flow-Based Policy Module
|
||||
//!
|
||||
//! This module contains components for implementing a flow-based policy within the PPO framework,
|
||||
//! including the flow matching objective function.
|
||||
|
||||
// Calculate price return for volatility tracking
|
||||
// Add safety check for division by zero
|
||||
let price_return = if current_close.abs() > 1e-9 {
|
||||
(next_close - current_close) / current_close
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// CRITICAL FIX: Update adaptive risk trackers with the raw, unadjusted reward.
|
||||
// This breaks the positive feedback loop where amplified rewards were used
|
||||
// to calculate the next amplification factor (Sharpe ratio).
|
||||
self.update_risk_trackers(raw_reward, price_return);
|
||||
|
||||
// WAVE 16S: Apply risk-adjusted rewards (Sharpe ratio) using history of raw rewards
|
||||
let risk_adjusted_reward = self.calculate_risk_adjusted_reward(raw_reward);
|
||||
|
||||
// Convert back to f32 for experience storage
|
||||
let reward = risk_adjusted_reward as f32;
|
||||
|
||||
// WAVE 16S: Log adaptive features periodically
|
||||
if i % 100 == 0 && i > 0 {
|
||||
// ... (code from line 1185 to 2677)
|
||||
/// Update adaptive risk trackers with new market data
|
||||
fn update_risk_trackers(&mut self, reward: f64, price_return: f64) {
|
||||
// Update PnL history
|
||||
self.pnl_history.push_back(reward);
|
||||
if self.pnl_history.len() > 1000 {
|
||||
// ... (rest of the file is unchanged)
|
||||
pub mod flow_matching;
|
||||
```
|
||||
</UPDATED_EXISTING_FILE>
|
||||
</NEWFILE>
|
||||
|
||||
<UPDATED_EXISTING_FILE: ml/src/dqn/reward.rs>
|
||||
Next, here is the core implementation of the flow matching objective.
|
||||
|
||||
<NEWFILE: ml/src/ppo/flow_policy/flow_matching.rs>
|
||||
```rust
|
||||
//! Trading-specific reward functions for DQN
|
||||
//! # Flow Matching Objective for Flow-Based Policy Optimization (FPO)
|
||||
//!
|
||||
//! This module implements the clipped surrogate objective for PPO using a flow-based policy.
|
||||
//! Instead of using the ratio of likelihoods (log probabilities), it uses the ratio of
|
||||
//! the determinants of the Jacobian of the flow transformation. This approach, detailed in
|
||||
//! "Flow Matching Policy Optimization," helps mitigate gradient explosion issues common
|
||||
//! with traditional policy gradient methods in continuous action spaces.
|
||||
//!
|
||||
//! The core idea is to replace the likelihood ratio `r(θ) = π_θ(a|s) / π_θ_old(a|s)` with
|
||||
//! a flow ratio based on log-determinants: `r_flow = exp(log_det_new - log_det_old)`.
|
||||
//! This ratio is then used in the standard PPO clipped surrogate objective.
|
||||
|
||||
// CANONICAL TYPE IMPORTS - Use common::Decimal
|
||||
use serde::{Deserialize, Serialize};
|
||||
// For Decimal::from_f64
|
||||
use common::types::Price;
|
||||
use rust_decimal::Decimal;
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
use super::action_space::FactoredAction;
|
||||
use super::agent::{TradingAction, TradingState};
|
||||
use crate::MLError;
|
||||
use crate::{tensor_ops::TensorOps, MLError};
|
||||
|
||||
/// Configuration for reward function
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RewardConfig {
|
||||
/// Weight for P&L component
|
||||
pub pnl_weight: Decimal,
|
||||
/// Weight for risk penalty
|
||||
pub risk_weight: Decimal,
|
||||
/// Weight for transaction cost penalty
|
||||
pub cost_weight: Decimal,
|
||||
/// Weight for hold reward (to reduce over-trading)
|
||||
pub hold_reward: Decimal,
|
||||
/// Price movement threshold for dynamic HOLD reward (as fraction, e.g., 0.02 = 2%)
|
||||
pub movement_threshold: Decimal,
|
||||
/// Weight for HOLD action penalty during high volatility (negative value applied)
|
||||
pub hold_penalty_weight: Decimal,
|
||||
/// Weight for diversity penalty (negative value to penalize low entropy, -0.1 default)
|
||||
pub diversity_weight: Decimal,
|
||||
/// Enable running normalization of rewards
|
||||
pub enable_reward_normalization: bool,
|
||||
/// Configuration for the Flow Matching loss function.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FlowMatchingConfig {
|
||||
/// The clipping parameter (epsilon) for the PPO surrogate objective.
|
||||
/// Typically set to 0.2.
|
||||
pub clip_epsilon: f32,
|
||||
/// The minimum value for clipping the log flow ratio to prevent underflow in `exp()`.
|
||||
pub log_ratio_clip_min: f32,
|
||||
/// The maximum value for clipping the log flow ratio to prevent overflow in `exp()`.
|
||||
pub log_ratio_clip_max: f32,
|
||||
}
|
||||
|
||||
impl Default for RewardConfig {
|
||||
impl Default for FlowMatchingConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pnl_weight: Decimal::ONE,
|
||||
risk_weight: Decimal::try_from(0.1).unwrap_or(Decimal::ZERO),
|
||||
cost_weight: Decimal::try_from(0.05).unwrap_or(Decimal::ZERO),
|
||||
hold_reward: Decimal::try_from(0.001).unwrap_or(Decimal::ZERO),
|
||||
movement_threshold: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), // 1% matches data distribution
|
||||
hold_penalty_weight: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), // 1% default penalty
|
||||
diversity_weight: Decimal::try_from(-0.1).unwrap_or(Decimal::ZERO), // -0.1 default (100x stronger than hold_reward)
|
||||
enable_reward_normalization: true, // Default to enabled for robustness
|
||||
clip_epsilon: 0.2,
|
||||
log_ratio_clip_min: -20.0,
|
||||
log_ratio_clip_max: 20.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A running normalizer for rewards using Welford's online algorithm.
|
||||
/// This adaptively scales rewards to have a mean of ~0 and stddev of ~1,
|
||||
/// which stabilizes DQN training with non-stationary reward distributions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RewardNormalizer {
|
||||
count: u64,
|
||||
mean: f64,
|
||||
m2: f64, // Sum of squares of differences from the current mean
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl RewardNormalizer {
|
||||
/// Creates a new `RewardNormalizer`.
|
||||
pub fn new(enabled: bool) -> Self {
|
||||
Self {
|
||||
count: 0,
|
||||
mean: 0.0,
|
||||
m2: 0.0,
|
||||
enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the running statistics with a new reward value using Welford's algorithm.
|
||||
pub fn update(&mut self, reward: Decimal) {
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
if let Ok(reward_f64) = reward.try_into::<f64>() {
|
||||
self.count += 1;
|
||||
let delta = reward_f64 - self.mean;
|
||||
self.mean += delta / self.count as f64;
|
||||
let delta2 = reward_f64 - self.mean;
|
||||
self.m2 += delta * delta2;
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalizes a reward value based on the current running statistics.
|
||||
pub fn normalize(&self, reward: Decimal) -> Decimal {
|
||||
// Do not normalize until we have seen at least a few samples
|
||||
if !self.enabled || self.count < 10 {
|
||||
return reward;
|
||||
}
|
||||
|
||||
let variance = self.m2 / self.count as f64;
|
||||
let std_dev = variance.sqrt();
|
||||
|
||||
if let Ok(reward_f64) = reward.try_into::<f64>() {
|
||||
// Clip std_dev to avoid division by zero or near-zero values, which can cause explosions.
|
||||
let safe_std_dev = std_dev.max(1e-8);
|
||||
let normalized_reward = (reward_f64 - self.mean) / safe_std_dev;
|
||||
Decimal::try_from(normalized_reward).unwrap_or(reward)
|
||||
} else {
|
||||
reward
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Risk metrics for trading state
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RiskMetrics {
|
||||
/// Value at Risk (95%)
|
||||
pub var_95: Decimal,
|
||||
/// Maximum drawdown
|
||||
pub max_drawdown: Decimal,
|
||||
/// Sharpe ratio
|
||||
pub sharpe_ratio: Decimal,
|
||||
/// Volatility
|
||||
pub volatility: Decimal,
|
||||
}
|
||||
|
||||
/// Market data snapshot
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MarketData {
|
||||
/// Current bid price
|
||||
pub bid: Price,
|
||||
/// Current ask price
|
||||
pub ask: Price,
|
||||
/// Bid-ask spread
|
||||
pub spread: Price,
|
||||
/// Volume
|
||||
pub volume: Decimal,
|
||||
}
|
||||
|
||||
/// Calculate Shannon entropy for action distribution
|
||||
/// Computes the PPO clipped surrogate objective using the flow matching ratio.
|
||||
///
|
||||
/// This function is the core of the FPO update rule. It takes the log-determinants from the
|
||||
/// current and old policies, computes the flow ratio, and then calculates the PPO loss.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `recent_actions` - Sliding window of recent actions (last 100 actions)
|
||||
/// * `new_log_dets` - A tensor of log-determinants from the current policy network for the actions in the batch. Shape: `[batch_size]`.
|
||||
/// * `old_log_dets` - A tensor of log-determinants from the policy network used to collect the trajectory data. Shape: `[batch_size]`.
|
||||
/// * `advantages` - A tensor of normalized advantages. Shape: `[batch_size]`.
|
||||
/// * `config` - Configuration for the loss computation, including clipping parameters.
|
||||
/// * `device` - The device on which to perform tensor operations.
|
||||
///
|
||||
/// # Returns
|
||||
/// Shannon entropy H = -Σ(p_i * log2(p_i)) where p_i is frequency of action i
|
||||
/// Range: [0.0, 1.585] (0 = all same action, 1.585 = perfectly balanced)
|
||||
///
|
||||
/// # Note
|
||||
/// For FactoredAction, we convert to legacy TradingAction (Buy/Sell/Hold) for entropy calculation
|
||||
/// to maintain backward compatibility with the original 3-action entropy range.
|
||||
fn calculate_entropy(recent_actions: &[FactoredAction]) -> Decimal {
|
||||
if recent_actions.is_empty() {
|
||||
return Decimal::ONE; // Default to high entropy if no history
|
||||
}
|
||||
/// A scalar `Tensor` representing the final policy loss, ready for backpropagation.
|
||||
/// The loss is negated because optimizers perform minimization, while PPO aims to maximize the objective.
|
||||
pub fn compute_flow_matching_loss(
|
||||
new_log_dets: &Tensor,
|
||||
old_log_dets: &Tensor,
|
||||
advantages: &Tensor,
|
||||
config: &FlowMatchingConfig,
|
||||
device: &Device,
|
||||
) -> Result<Tensor, MLError> {
|
||||
// 1. Compute the log of the flow ratio.
|
||||
// log_flow_ratio = log(det_new / det_old) = log_det_new - log_det_old
|
||||
let log_flow_ratio = (new_log_dets - old_log_dets)?;
|
||||
|
||||
// Convert to legacy actions and count
|
||||
let mut counts = [0, 0, 0]; // BUY, SELL, HOLD
|
||||
for action in recent_actions {
|
||||
let legacy_action = action.to_legacy_action();
|
||||
counts[legacy_action as usize] += 1;
|
||||
}
|
||||
// 2. Clip the log ratio to prevent numerical instability (overflow/underflow) when exponentiating.
|
||||
// A range of [-20, 20] is a safe default, as exp(20) is large but manageable,
|
||||
// while exp(>~80) can lead to f32 infinity. This is a critical step for stability.
|
||||
let log_ratio_min = Tensor::full(config.log_ratio_clip_min, log_flow_ratio.dims(), device)
|
||||
.map_err(|e| MLError::TrainingError(format!("Failed to create log_ratio min tensor: {}", e)))?;
|
||||
let log_ratio_max = Tensor::full(config.log_ratio_clip_max, log_flow_ratio.dims(), device)
|
||||
.map_err(|e| MLError::TrainingError(format!("Failed to create log_ratio max tensor: {}", e)))?;
|
||||
let clipped_log_ratio = log_flow_ratio.clamp(&log_ratio_min, &log_ratio_max)?;
|
||||
|
||||
let total = recent_actions.len() as f64;
|
||||
let mut entropy = Decimal::ZERO;
|
||||
// 3. Compute the flow ratio.
|
||||
let ratio = clipped_log_ratio.exp()?;
|
||||
|
||||
for &count in &counts {
|
||||
if count > 0 {
|
||||
let p = Decimal::try_from(count as f64 / total).unwrap_or(Decimal::ZERO);
|
||||
// Shannon entropy: -p * log2(p)
|
||||
// Use natural log and convert: log2(x) = ln(x) / ln(2)
|
||||
if let Ok(p_f64) = TryInto::<f64>::try_into(p) {
|
||||
if p_f64 > 0.0 {
|
||||
let log2_p = p_f64.ln() / 2.0_f64.ln();
|
||||
let term = p * Decimal::try_from(log2_p).unwrap_or(Decimal::ZERO);
|
||||
entropy -= term;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4. Compute the clipped surrogate objective, as in standard PPO.
|
||||
// surrogate1 = ratio * advantage
|
||||
let surr1 = (&ratio * advantages)?;
|
||||
|
||||
entropy
|
||||
// surrogate2 = clamp(ratio, 1 - ε, 1 + ε) * advantage
|
||||
let clip_min = 1.0 - config.clip_epsilon;
|
||||
let clip_max = 1.0 + config.clip_epsilon;
|
||||
let clipped_ratio = ratio.clamp(clip_min, clip_max)?;
|
||||
let surr2 = (&clipped_ratio * advantages)?;
|
||||
|
||||
// 5. The PPO objective is the minimum of the two surrogates.
|
||||
let policy_loss_raw = TensorOps::elementwise_min(&surr1, &surr2)?;
|
||||
|
||||
// 6. The final loss is the negative mean of the objective function.
|
||||
// We negate it because we want to maximize the objective via gradient ascent,
|
||||
// which is equivalent to minimizing the negative objective.
|
||||
let policy_loss_mean = policy_loss_raw.mean_all()?;
|
||||
let final_loss = TensorOps::negate(&policy_loss_mean)?;
|
||||
|
||||
Ok(final_loss)
|
||||
}
|
||||
|
||||
/// Reward function for `DQN` training
|
||||
#[derive(Debug)]
|
||||
pub struct RewardFunction {
|
||||
/// Configuration
|
||||
config: RewardConfig,
|
||||
/// Previous rewards for tracking
|
||||
reward_history: Vec<Decimal>,
|
||||
/// Running normalizer for rewards
|
||||
normalizer: RewardNormalizer,
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use candle_core::{Device, Tensor};
|
||||
|
||||
impl RewardFunction {
|
||||
/// Create a new reward function
|
||||
pub fn new(config: RewardConfig) -> Self {
|
||||
Self {
|
||||
normalizer: RewardNormalizer::new(config.enable_reward_normalization),
|
||||
config,
|
||||
reward_history: Vec::new(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn test_flow_matching_loss_computation() -> Result<(), MLError> {
|
||||
let device = Device::Cpu;
|
||||
let config = FlowMatchingConfig::default();
|
||||
|
||||
// Batch size of 4
|
||||
let new_log_dets = Tensor::from_vec(vec![1.2, 0.8, -0.5, 2.0], 4, &device)?;
|
||||
let old_log_dets = Tensor::from_vec(vec![1.0, 1.0, -0.4, 1.5], 4, &device)?;
|
||||
let advantages = Tensor::from_vec(vec![1.5, -0.5, 2.0, 0.8], 4, &device)?;
|
||||
|
||||
// Expected log_flow_ratio = [0.2, -0.2, -0.1, 0.5]
|
||||
// Expected ratio = [1.2214, 0.8187, 0.9048, 1.6487]
|
||||
|
||||
// Expected clipped_ratio (epsilon=0.2, range=[0.8, 1.2])
|
||||
// clipped_ratio = [1.2, 0.8187, 0.9048, 1.2]
|
||||
|
||||
// surr1 = [1.8321, -0.4093, 1.8096, 1.3189]
|
||||
// surr2 = [1.8, -0.4093, 1.8096, 0.96]
|
||||
|
||||
// min(surr1, surr2) = [1.8, -0.4093, 1.8096, 0.96]
|
||||
// mean = (1.8 - 0.4093 + 1.8096 + 0.96) / 4 = 4.1603 / 4 = 1.040075
|
||||
// final_loss = -1.040075
|
||||
|
||||
let loss =
|
||||
compute_flow_matching_loss(&new_log_dets, &old_log_dets, &advantages, &config, &device)?;
|
||||
let loss_val = loss.to_scalar::<f32>()?;
|
||||
|
||||
assert!((loss_val - (-1.040075)).abs() < 1e-4);
|
||||
|
||||
/// Validate that a TradingState has the required portfolio features
|
||||
///
|
||||
/// # Required Structure
|
||||
/// Portfolio features must have length >= 3:
|
||||
/// - [0]: Portfolio value (cash + unrealized P&L)
|
||||
/// - [1]: Position size (signed: +Long, -Short, 0=flat)
|
||||
/// - [2]: Bid-ask spread
|
||||
///
|
||||
/// # Returns
|
||||
/// Ok(()) if valid, Err(MLError) with descriptive message if invalid
|
||||
fn validate_portfolio_features(state: &TradingState) -> Result<(), MLError> {
|
||||
if state.portfolio_features.len() < 3 {
|
||||
return Err(MLError::InvalidInput(
|
||||
format!(
|
||||
"TradingState portfolio_features must have length >= 3 (got {}). Expected: [portfolio_value, position_size, spread]",
|
||||
state.portfolio_features.len()
|
||||
)
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate reward for a state transition
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `action` - Trading action taken (FactoredAction with exposure, order type, urgency)
|
||||
/// * `current_state` - Current trading state (128-dim: 4 price + 121 technical + 3 portfolio)
|
||||
/// * `next_state` - Next trading state after action (128-dim structure)
|
||||
/// * `recent_actions` - Sliding window of last 100 actions for diversity penalty
|
||||
///
|
||||
/// # Validation
|
||||
/// Validates that both states have proper portfolio_features (length >= 3).
|
||||
/// Logs warnings if features are missing but continues with default values.
|
||||
///
|
||||
/// # Note
|
||||
/// Converts FactoredAction to legacy TradingAction for reward calculation logic.
|
||||
pub fn calculate_reward(
|
||||
&mut self,
|
||||
action: FactoredAction,
|
||||
current_state: &TradingState,
|
||||
next_state: &TradingState,
|
||||
recent_actions: &[FactoredAction],
|
||||
) -> Result<Decimal, MLError> {
|
||||
// Validate portfolio features (non-fatal, logs warnings)
|
||||
if let Err(e) = Self::validate_portfolio_features(current_state) {
|
||||
tracing::warn!("Current state validation: {}", e);
|
||||
}
|
||||
if let Err(e) = Self::validate_portfolio_features(next_state) {
|
||||
tracing::warn!("Next state validation: {}", e);
|
||||
}
|
||||
|
||||
// Convert to legacy action for reward logic
|
||||
let legacy_action = action.to_legacy_action();
|
||||
|
||||
let base_reward = match legacy_action {
|
||||
TradingAction::Buy | TradingAction::Sell => {
|
||||
// Calculate P&L-based reward
|
||||
let pnl_reward = self.calculate_pnl_reward(current_state, next_state)?;
|
||||
|
||||
// Calculate risk penalty
|
||||
let risk_penalty = self.calculate_risk_penalty(next_state);
|
||||
|
||||
// Calculate transaction cost penalty
|
||||
let cost_penalty = self.calculate_cost_penalty(current_state, next_state);
|
||||
|
||||
self.config.pnl_weight * pnl_reward
|
||||
- self.config.risk_weight * risk_penalty
|
||||
- self.config.cost_weight * cost_penalty
|
||||
},
|
||||
TradingAction::Hold => {
|
||||
// Dynamic HOLD reward based on price movement
|
||||
self.calculate_hold_reward(current_state, next_state)?
|
||||
},
|
||||
#[test]
|
||||
fn test_ratio_clipping() -> Result<(), MLError> {
|
||||
let device = Device::Cpu;
|
||||
let config = FlowMatchingConfig {
|
||||
clip_epsilon: 0.2,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Calculate diversity bonus (in-training regularization)
|
||||
// Entropy threshold: 0.5 (50% of max entropy for 3 actions = 1.585)
|
||||
// Low entropy → action bias → apply penalty (-0.1)
|
||||
let entropy = calculate_entropy(recent_actions);
|
||||
let entropy_threshold = Decimal::try_from(0.5).unwrap_or(Decimal::ZERO);
|
||||
let diversity_bonus = if entropy < entropy_threshold {
|
||||
self.config.diversity_weight // -0.1 (penalty for low diversity)
|
||||
} else {
|
||||
Decimal::ZERO // No penalty for balanced actions
|
||||
// This log_det difference will produce a large ratio that should be clipped
|
||||
let new_log_dets = Tensor::from_vec(vec![2.0], 1, &device)?;
|
||||
let old_log_dets = Tensor::from_vec(vec![0.0], 1, &device)?;
|
||||
let advantages = Tensor::from_vec(vec![10.0], 1, &device)?; // Positive advantage
|
||||
|
||||
// log_flow_ratio = 2.0
|
||||
// ratio = exp(2.0) ≈ 7.389
|
||||
// clipped_ratio = clamp(7.389, 0.8, 1.2) = 1.2
|
||||
// surr1 = 7.389 * 10 ≈ 73.89
|
||||
// surr2 = 1.2 * 10 = 12.0
|
||||
// min = 12.0
|
||||
// loss = -12.0
|
||||
|
||||
let loss =
|
||||
compute_flow_matching_loss(&new_log_dets, &old_log_dets, &advantages, &config, &device)?;
|
||||
let loss_val = loss.to_scalar::<f32>()?;
|
||||
assert!((loss_val - (-12.0)).abs() < 1e-4);
|
||||
|
||||
// Test with negative advantage
|
||||
let advantages_neg = Tensor::from_vec(vec![-10.0], 1, &device)?;
|
||||
// surr1 = 7.389 * -10 ≈ -73.89
|
||||
// surr2 = 1.2 * -10 = -12.0
|
||||
// min(surr1, surr2) is surr1 because we want to decrease the probability of this action
|
||||
// min ≈ -73.89
|
||||
// loss = -(-73.89) ≈ 73.89
|
||||
let loss_neg_adv = compute_flow_matching_loss(
|
||||
&new_log_dets,
|
||||
&old_log_dets,
|
||||
&advantages_neg,
|
||||
&config,
|
||||
&device,
|
||||
)?;
|
||||
let loss_val_neg_adv = loss_neg_adv.to_scalar::<f32>()?;
|
||||
assert!((loss_val_neg_adv - 73.8905).abs() < 1e-4);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_log_ratio_clipping() -> Result<(), MLError> {
|
||||
let device = Device::Cpu;
|
||||
let config = FlowMatchingConfig {
|
||||
log_ratio_clip_min: -1.0,
|
||||
log_ratio_clip_max: 1.0,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let final_reward = base_reward + diversity_bonus;
|
||||
// This log_det difference is large and should be clipped before exp()
|
||||
let new_log_dets = Tensor::from_vec(vec![10.0], 1, &device)?;
|
||||
let old_log_dets = Tensor::from_vec(vec![0.0], 1, &device)?;
|
||||
let advantages = Tensor::from_vec(vec![1.0], 1, &device)?;
|
||||
|
||||
// Update normalizer with the raw, unclamped reward
|
||||
self.normalizer.update(final_reward);
|
||||
// log_flow_ratio = 10.0
|
||||
// clipped_log_ratio = 1.0
|
||||
// ratio = exp(1.0) ≈ 2.718
|
||||
// clipped_ratio = clamp(2.718, 0.8, 1.2) = 1.2
|
||||
// surr1 ≈ 2.718
|
||||
// surr2 = 1.2
|
||||
// min = 1.2
|
||||
// loss = -1.2
|
||||
|
||||
// Normalize the reward before clamping
|
||||
let normalized_reward = self.normalizer.normalize(final_reward);
|
||||
let loss =
|
||||
compute_flow_matching_loss(&new_log_dets, &old_log_dets, &advantages, &config, &device)?;
|
||||
let loss_val = loss.to_scalar::<f32>()?;
|
||||
assert!((loss_val - (-1.2)).abs() < 1e-4);
|
||||
|
||||
// Clamp reward to prevent cumulative explosions. This is a final safeguard.
|
||||
let clamped_reward = normalized_reward.clamp(Decimal::from(-1), Decimal::ONE);
|
||||
|
||||
// Store the original, unclamped reward for statistical analysis
|
||||
self.reward_history.push(final_reward);
|
||||
if self.reward_history.len() > 1000 {
|
||||
self.reward_history.remove(0);
|
||||
}
|
||||
|
||||
Ok(clamped_reward)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate P&L-based reward component as a percentage of portfolio value.
|
||||
///
|
||||
/// This approach creates a more stationary reward signal, as the reward is
|
||||
/// proportional to the percentage gain/loss rather than the absolute dollar amount.
|
||||
/// A 0.5% gain yields a similar reward regardless of whether the portfolio is $100K or $1M.
|
||||
fn calculate_pnl_reward(
|
||||
&self,
|
||||
current_state: &TradingState,
|
||||
next_state: &TradingState,
|
||||
) -> Result<Decimal, MLError> {
|
||||
// Validate portfolio_features length (defensive check)
|
||||
if current_state.portfolio_features.is_empty() {
|
||||
tracing::warn!("Current state portfolio_features is empty, using 0.0 for P&L reward");
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
if next_state.portfolio_features.is_empty() {
|
||||
tracing::warn!("Next state portfolio_features is empty, using 0.0 for P&L reward");
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
|
||||
let current_value =
|
||||
Decimal::try_from(*current_state.portfolio_features.get(0).unwrap_or(&100000.0) as f64)
|
||||
.unwrap_or_else(|_| Decimal::from(100000));
|
||||
let next_value =
|
||||
Decimal::try_from(*next_state.portfolio_features.get(0).unwrap_or(&100000.0) as f64)
|
||||
.unwrap_or(current_value);
|
||||
|
||||
// Avoid division by zero if portfolio value is somehow zero.
|
||||
if current_value.is_zero() {
|
||||
return Ok(Decimal::ZERO);
|
||||
}
|
||||
|
||||
let pnl_change = next_value - current_value;
|
||||
|
||||
// Calculate P&L as a percentage of portfolio value to create a stationary reward signal.
|
||||
let pnl_percentage = pnl_change / current_value;
|
||||
|
||||
// Scale percentage to a more intuitive reward magnitude.
|
||||
// A 1% portfolio gain (pnl_percentage = 0.01) becomes a reward of 1.0.
|
||||
// A 0.1% gain (0.001) becomes a reward of 0.1.
|
||||
let scaled_pnl = pnl_percentage * Decimal::from(100);
|
||||
|
||||
Ok(scaled_pnl)
|
||||
}
|
||||
|
||||
/// Calculate risk penalty
|
||||
///
|
||||
// ... (rest of the file is unchanged)
|
||||
}
|
||||
```
|
||||
</UPDATED_EXISTING_FILE>
|
||||
</GENERATED-CODE>
|
||||
|
||||
Reference in New Issue
Block a user