fix(dqn): walk-forward feature order mismatch — portfolio at correct indices

The gather kernel places live portfolio features at [feat_dim..feat_dim+3].
Training builds states as [market(42), portfolio(3), OFI(8)] with portfolio
at indices 42-44. The hyperopt adapter was setting feat_dim=50 (raw_state_dim
minus 3), which placed live portfolio at indices 50-52 — invisible to the
model. The model saw zeros for position/value/spread during walk-forward,
making random decisions and producing -775% return with 0% win rate.

Fix: Pass only 42 market features (strip portfolio zeros from val_data).
For OFI-enabled models, pad to 50 and shuffle the state tensor before
forward pass to maintain [market, portfolio, OFI, pad] order.

Also fixes pre-existing clippy warnings in ml-dqn (doc_markdown, cognitive_complexity).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-12 17:14:08 +01:00
parent 2a71f6bf4c
commit 82b52a0230
2 changed files with 61 additions and 14 deletions

View File

@@ -1590,6 +1590,7 @@ impl DQN {
}
/// Select action using epsilon-greedy policy
#[allow(clippy::cognitive_complexity)]
pub fn select_action(&mut self, state: &[f32]) -> Result<FactoredAction, MLError> {
// Reset noise for exploration (Noisy Networks / Rainbow DQN)
if self.config.use_noisy_nets {
@@ -4227,7 +4228,7 @@ impl DQN {
/// Get the effective epsilon for exploration.
///
/// With noisy nets enabled, maintains a minimum epsilon floor to prevent
/// action collapse. NoisyNets provide learned exploration but their noise
/// action collapse. `NoisyNets` provide learned exploration but their noise
/// magnitude can become smaller than the Q-value gap between actions,
/// causing the agent to converge to a single action (e.g., Short100 only).
/// The floor ensures at least 10% random actions (2% per exposure level).

View File

@@ -1667,6 +1667,18 @@ impl DQNTrainer {
let val_data = internal_trainer.get_val_data();
// FIX: The gather kernel places live portfolio at [feat_dim..feat_dim+3].
// Training builds states as [market(42), portfolio(3), OFI(8)], so portfolio
// is at indices 42-44. We must pass only market features (42 dims) as the
// "feature" part so the kernel inserts portfolio at exactly index 42.
//
// For OFI-enabled models, we pad features to 50 (42 market + 8 OFI placeholder)
// so state_dim = (50+3+7)&!7 = 56 (matching training). The forward closure
// then shuffles [market,ofi_pad,portfolio,pad] → [market,portfolio,ofi_pad,pad].
let ofi_enabled = self.mbp10_data_dir.is_some();
let market_dim: usize = 42;
let feature_dim: usize = if ofi_enabled { 50 } else { market_dim };
let mut window_prices = Vec::with_capacity(window_count);
let mut window_features = Vec::with_capacity(window_count);
@@ -1690,17 +1702,25 @@ impl DQNTrainer {
val_data.len()
))
})?;
let fv_f32: Vec<f32> = fv.iter().map(|&v| v as f32).collect();
// Take only the 42 market features, strip portfolio zeros at 42-44.
let fv_slice = fv.get(..market_dim).ok_or_else(|| {
MLError::ConfigError(format!(
"feature vector too short: {} < {market_dim}",
fv.len()
))
})?;
let mut fv_f32: Vec<f32> = fv_slice.iter().map(|&v| v as f32).collect();
// For OFI models, zero-pad from 42 to 50. The model was trained with
// real OFI at 45-52 but zeros are the safe fallback (matches the
// `ofi_enabled && data_missing` path in feature_vector_to_state_with_ofi).
if ofi_enabled {
fv_f32.resize(feature_dim, 0.0);
}
features.push(fv_f32);
}
window_prices.push(prices);
window_features.push(features);
}
// Market features only -- portfolio features (3) added by evaluator's gather_states.
// raw_state_dim = 53 (with OFI) or 45 (without), subtract 3 portfolio dims.
let raw_state_dim: usize = if self.mbp10_data_dir.is_some() { 53 } else { 45 };
let feature_dim = raw_state_dim - 3;
let config = GpuBacktestConfig {
max_position: 1.0,
tx_cost_bps: self.tx_cost_bps as f32,
@@ -1728,13 +1748,39 @@ impl DQNTrainer {
// TODO: Switch to evaluator.evaluate_dqn() with DuelingWeightSet extraction
// when the InternalDQNTrainer exposes the dueling VarMap. The closure path
// through agent_guard.batch_q_values() still works but has candle dispatch overhead.
let metrics = evaluator.evaluate(
&|states: &candle_core::Tensor| -> Result<candle_core::Tensor, MLError> {
agent_guard.batch_q_values(states)
},
3, // portfolio_dim
device,
)?;
let metrics = if ofi_enabled {
// OFI case: kernel produces [market(42), ofi_pad(8), portfolio(3), pad(3)] = 56.
// Model expects [market(42), portfolio(3), OFI(8), pad(3)] = 56.
// Shuffle the state tensor to place portfolio at the correct indices.
evaluator.evaluate(
&|states: &candle_core::Tensor| -> Result<candle_core::Tensor, MLError> {
let market = states.narrow(1, 0, 42)
.map_err(|e| MLError::ModelError(format!("narrow market: {e}")))?;
let ofi_block = states.narrow(1, 42, 8)
.map_err(|e| MLError::ModelError(format!("narrow ofi: {e}")))?;
let portfolio = states.narrow(1, 50, 3)
.map_err(|e| MLError::ModelError(format!("narrow portfolio: {e}")))?;
let padding = states.narrow(1, 53, 3)
.map_err(|e| MLError::ModelError(format!("narrow padding: {e}")))?;
let shuffled = candle_core::Tensor::cat(
&[&market, &portfolio, &ofi_block, &padding], 1,
).map_err(|e| MLError::ModelError(format!("cat shuffle: {e}")))?;
agent_guard.batch_q_values(&shuffled)
},
3, // portfolio_dim
device,
)?
} else {
// Non-OFI: kernel produces [market(42), portfolio(3), pad(3)] = 48.
// Matches training layout exactly — no shuffle needed.
evaluator.evaluate(
&|states: &candle_core::Tensor| -> Result<candle_core::Tensor, MLError> {
agent_guard.batch_q_values(states)
},
3, // portfolio_dim
device,
)?
};
drop(agent_guard);