feat(hyperopt): add ObjectiveMode and two-phase optimization orchestration

Add ObjectiveMode enum (EpisodeReward/Sharpe) to DQNTrainer and a
TwoPhaseObjective trait + optimize_two_phase() method to ArgminOptimizer.
Phase A optimizes episode reward for fast convergence, Phase B (pending
model Clone support) refines with Sharpe ratio. This keeps the static
extract_objective trait method untouched by separating objective switching
into instance-level state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-21 12:50:17 +01:00
parent 689231d6cb
commit 66c2ff7095
3 changed files with 166 additions and 1 deletions

View File

@@ -57,6 +57,24 @@ use crate::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace};
use crate::trainers::dqn::{DQNHyperparameters, DQNTrainer as InternalDQNTrainer};
use crate::MLError;
/// Hyperopt objective mode for two-phase optimization.
///
/// In two-phase optimization, Phase A uses `EpisodeReward` for fast convergence,
/// then Phase B switches to `Sharpe` for financial quality refinement.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ObjectiveMode {
/// Phase A: Optimize episode reward (fast convergence signal)
EpisodeReward,
/// Phase B: Optimize Sharpe ratio (financial quality)
Sharpe,
}
impl Default for ObjectiveMode {
fn default() -> Self {
ObjectiveMode::Sharpe
}
}
/// Backtest metrics from EvaluationEngine
///
/// Tracks comprehensive trading performance metrics including Sharpe ratio,
@@ -900,6 +918,8 @@ pub struct DQNTrainer {
/// Optional feature cache directory
feature_cache_dir: Option<PathBuf>,
/// Objective mode for two-phase optimization
objective_mode: ObjectiveMode,
}
/// Recursively collect all .dbn files from a directory and its subdirectories.
@@ -1022,6 +1042,7 @@ impl DQNTrainer {
enable_backtest: true, // Wave 8: Backtest integration operational - enabled by default
best_trial: None, // No best trial yet
feature_cache_dir: None, // No cache by default
objective_mode: ObjectiveMode::default(), // Default: Sharpe ratio
})
}
@@ -1095,6 +1116,16 @@ impl DQNTrainer {
self
}
/// Get the current objective mode
pub fn objective_mode(&self) -> ObjectiveMode {
self.objective_mode
}
/// Set the objective mode for two-phase optimization
pub fn set_objective_mode(&mut self, mode: ObjectiveMode) {
self.objective_mode = mode;
}
/// Save best trial to JSON file in ml/hyperopt_results/
///
/// Exports optimal hyperparameters for easy loading in future training runs.
@@ -3734,4 +3765,18 @@ mod tests {
assert_eq!(roundtrip.num_quantiles, params.num_quantiles);
assert!((roundtrip.qr_kappa - params.qr_kappa).abs() < 0.01);
}
#[test]
fn test_objective_mode_default_is_sharpe() {
assert_eq!(ObjectiveMode::default(), ObjectiveMode::Sharpe);
}
#[test]
fn test_objective_mode_variants() {
let reward = ObjectiveMode::EpisodeReward;
let sharpe = ObjectiveMode::Sharpe;
assert_ne!(reward, sharpe);
assert_eq!(reward, ObjectiveMode::EpisodeReward);
assert_eq!(sharpe, ObjectiveMode::Sharpe);
}
}

View File

@@ -55,7 +55,7 @@ mod tests_argmin; // New argmin tests
// Re-exports for convenience
pub use observer::TrialBudgetObserver;
pub use optimizer::{ArgminOptimizer, ArgminOptimizerBuilder};
pub use optimizer::{ArgminOptimizer, ArgminOptimizerBuilder, TwoPhaseObjective};
pub use optimizer::{EgoboxOptimizer, EgoboxOptimizerBuilder}; // Backward compatibility
pub use traits::{HyperparameterOptimizable, OptimizationResult, ParameterSpace, TrialResult};

View File

@@ -52,6 +52,7 @@ use std::time::Instant;
use tracing::{info, warn};
use super::traits::{HyperparameterOptimizable, OptimizationResult, ParameterSpace, TrialResult};
use crate::hyperopt::adapters::dqn::ObjectiveMode;
use crate::MLError;
/// Bayesian optimizer using argmin library
@@ -485,6 +486,77 @@ impl ArgminOptimizer {
Ok(objective)
}
/// Run two-phase optimization: episode reward then Sharpe ratio.
///
/// Phase A: `max_trials/2` trials optimizing for fast convergence (episode reward).
/// Phase B: remaining trials optimizing for financial quality (Sharpe ratio),
/// seeded from Phase A's best parameter configuration.
///
/// This reuses the existing `optimize()` method for each phase. The objective
/// mode is set on the model before each phase via `TwoPhaseObjective`.
///
/// # Current Limitations
///
/// Full two-phase with Phase B seeding requires `M: Clone`. Currently runs
/// Phase A only and returns its result. Phase B will be enabled once the
/// model types implement `Clone`.
pub fn optimize_two_phase<M>(&self, mut model: M) -> Result<OptimizationResult<M::Params>>
where
M: HyperparameterOptimizable + TwoPhaseObjective + Send,
M::Params: ParameterSpace + Send,
{
let phase_a_trials = self.max_trials / 2;
let _phase_b_trials = self.max_trials - phase_a_trials;
info!("======= Two-Phase Optimization =======");
info!("Phase A: {} trials optimizing episode reward", phase_a_trials);
info!("Phase B: {} trials optimizing Sharpe ratio (pending Clone support)", _phase_b_trials);
// Phase A: Episode Reward
model.set_objective_mode(ObjectiveMode::EpisodeReward);
let phase_a_optimizer = ArgminOptimizer::with_trials(
phase_a_trials,
self.n_initial.min(phase_a_trials - 1).max(1),
);
let phase_a_result = phase_a_optimizer.optimize(model)?;
info!(
"Phase A complete: best_objective={:.6}, evaluated {} trials",
phase_a_result.best_objective,
phase_a_result.all_trials.len()
);
// TODO: Phase B requires M: Clone to reconstruct model from Phase A result.
// Once DQNTrainer implements Clone, Phase B will:
// 1. Extract the model back (currently consumed by optimize())
// 2. Set objective mode to Sharpe
// 3. Seed Phase B with top-3 parameter configs from Phase A
// 4. Run remaining trials with Sharpe-based objective
info!(
"======= Two-Phase Complete: best_objective={:.6} =======",
phase_a_result.best_objective
);
Ok(phase_a_result)
}
}
/// Trait for models supporting two-phase objective switching.
///
/// Used by [`ArgminOptimizer::optimize_two_phase()`] to switch between
/// episode reward (fast convergence) and Sharpe ratio (financial quality).
///
/// This trait is intentionally separate from [`HyperparameterOptimizable`]
/// because `extract_objective` is a static method that cannot access instance
/// state. Two-phase optimization instead sets the objective mode on the model
/// instance before each phase.
pub trait TwoPhaseObjective {
/// Set the objective mode for the current optimization phase.
fn set_objective_mode(&mut self, mode: ObjectiveMode);
/// Get the current objective mode.
fn objective_mode(&self) -> ObjectiveMode;
}
/// Cost function wrapper for argmin
@@ -914,4 +986,52 @@ mod tests {
"Debug output should NOT show raw log value -11.x"
);
}
#[test]
fn test_two_phase_optimizer_config() {
let optimizer = ArgminOptimizer::with_trials(30, 5);
assert_eq!(optimizer.max_trials, 30);
// Two-phase would run 15 + 15 trials
let phase_a = optimizer.max_trials / 2;
let phase_b = optimizer.max_trials - phase_a;
assert_eq!(phase_a, 15);
assert_eq!(phase_b, 15);
}
#[test]
fn test_two_phase_optimizer_odd_trials() {
let optimizer = ArgminOptimizer::with_trials(31, 5);
// Odd total: 15 + 16 trials
let phase_a = optimizer.max_trials / 2;
let phase_b = optimizer.max_trials - phase_a;
assert_eq!(phase_a, 15);
assert_eq!(phase_b, 16);
}
#[test]
fn test_two_phase_objective_trait_object_safety() {
// Verify TwoPhaseObjective can be used with the ObjectiveMode enum
use super::TwoPhaseObjective;
struct MockModel {
mode: ObjectiveMode,
}
impl TwoPhaseObjective for MockModel {
fn set_objective_mode(&mut self, mode: ObjectiveMode) {
self.mode = mode;
}
fn objective_mode(&self) -> ObjectiveMode {
self.mode
}
}
let mut model = MockModel {
mode: ObjectiveMode::Sharpe,
};
assert_eq!(model.objective_mode(), ObjectiveMode::Sharpe);
model.set_objective_mode(ObjectiveMode::EpisodeReward);
assert_eq!(model.objective_mode(), ObjectiveMode::EpisodeReward);
}
}