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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user