feat: two-phase hyperopt + backtest evaluator VRAM leak fix
Two-phase hyperopt splits 31D PSO search into sequential phases: - Phase 1 (--phase fast, default): fix architecture to small network (hidden_dim=128, num_atoms=11), search learning dynamics (~15D). - Phase 2 (--phase full): fix dynamics from Phase 1 JSON, search architecture (~5D). Halves dimensionality per phase → better convergence. - Phase 1 output includes best_continuous_vector for Phase 2 consumption. GpuBacktestEvaluator Drop impl: sync forked stream, destroy CUDA graph and cuBLAS handles before CudaSlice buffers drop. Fixes 261MB/trial VRAM leak on H100 hyperopt. ml-core clippy fixes: hex literal, remove dead check_err drain, unnecessary safety comment, unused OnceLock import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -73,3 +73,14 @@ max_iterations = 50
|
||||
inertia = 0.7
|
||||
cognitive = 1.5
|
||||
social = 1.5
|
||||
|
||||
# Two-phase hyperopt: Phase 1 fixes architecture to small network,
|
||||
# searches only learning dynamics (~15D). Phase 2 fixes best dynamics
|
||||
# from Phase 1 JSON, searches architecture (~5D).
|
||||
# See: docs/superpowers/specs/2026-03-22-two-phase-hyperopt-design.md
|
||||
[phase_fast]
|
||||
hidden_dim_base = 128
|
||||
num_atoms = 11
|
||||
branch_hidden_dim = 64
|
||||
dueling_hidden_dim = 128
|
||||
v_range = 20.0
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
#![allow(unsafe_code)] // CUDA FFI requires unsafe for kernel launches.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::Arc;
|
||||
|
||||
use cudarc::driver::{CudaFunction, CudaSlice, CudaStream, LaunchConfig, PushKernelArg};
|
||||
use cudarc::nvrtc::Ptx;
|
||||
|
||||
@@ -47,10 +47,6 @@ impl MlDevice {
|
||||
let context = CudaContext::new(ordinal).map_err(|e| {
|
||||
MLError::DeviceError(format!("Failed to open CUDA device {ordinal}: {e}"))
|
||||
})?;
|
||||
// Drain any stale CUDA errors from a previous context user in the same process.
|
||||
// CudaContext::new reuses the primary context (cuDevicePrimaryCtxRetain),
|
||||
// so deferred errors from a previous test's Drop persist in error_state.
|
||||
let _ = context.check_err();
|
||||
let stream = context.new_stream().map_err(|e| {
|
||||
MLError::DeviceError(format!("Failed to create CUDA stream on device {ordinal}: {e}"))
|
||||
})?;
|
||||
|
||||
@@ -130,8 +130,6 @@ impl GpuProfile {
|
||||
|
||||
// Priority 3: embedded compile-time defaults (always available)
|
||||
let embedded_toml = Self::embedded_toml(profile_name);
|
||||
// SAFETY: embedded TOMLs are compile-time constants that we control.
|
||||
// If parsing fails, something is catastrophically wrong with the build.
|
||||
match toml::from_str::<GpuProfile>(embedded_toml) {
|
||||
Ok(profile) => {
|
||||
info!(
|
||||
@@ -209,7 +207,7 @@ impl GpuProfile {
|
||||
gpu_timesteps_per_episode: 200,
|
||||
},
|
||||
cuda: CudaProfile {
|
||||
cuda_stack_bytes: 32768,
|
||||
cuda_stack_bytes: 0x8000,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,37 +74,39 @@
|
||||
)]
|
||||
//! Hyperopt RL Runner for DQN/PPO on Real Databento Market Data
|
||||
//!
|
||||
//! Runs hyperparameter optimization using Particle Swarm Optimization (PSO) for
|
||||
//! DQN, PPO, or both models on downloaded Databento futures data. This binary is
|
||||
//! part of the walk-forward real-data training pipeline.
|
||||
//! Runs hyperparameter optimization using PSO/TPE for DQN, PPO, or both models
|
||||
//! on downloaded Databento futures data. Supports two-phase optimization:
|
||||
//!
|
||||
//! ## Usage
|
||||
//! ## Two-Phase Workflow (recommended)
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Run DQN hyperopt only (10 trials, 10 epochs each)
|
||||
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
|
||||
//! --model dqn --trials 10 --epochs 10 \
|
||||
//! --data-dir test_data/futures-baseline
|
||||
//! # Phase 1 (default): fix small architecture, search learning dynamics (~15D, ~30 min)
|
||||
//! cargo run -p ml --example hyperopt_baseline_rl --release -- \
|
||||
//! --model dqn --phase fast --trials 50 --epochs 30 \
|
||||
//! --data-dir data/futures-baseline --symbol ES.FUT \
|
||||
//! --output phase1_results.json
|
||||
//!
|
||||
//! # Run PPO hyperopt only
|
||||
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
|
||||
//! --model ppo --trials 20 --epochs 15 \
|
||||
//! --data-dir test_data/futures-baseline
|
||||
//! # Phase 2: fix best dynamics, search architecture (~5D, ~40 min)
|
||||
//! cargo run -p ml --example hyperopt_baseline_rl --release -- \
|
||||
//! --model dqn --phase full --trials 20 --epochs 50 \
|
||||
//! --hyperopt-params phase1_results.json \
|
||||
//! --data-dir data/futures-baseline --symbol ES.FUT \
|
||||
//! --output phase2_results.json
|
||||
//!
|
||||
//! # Run both models (default)
|
||||
//! SQLX_OFFLINE=true cargo run -p ml --example hyperopt_baseline --release -- \
|
||||
//! --data-dir test_data/futures-baseline
|
||||
//! # Production training with best config
|
||||
//! cargo run -p ml --example train_baseline_rl --release -- \
|
||||
//! --model dqn --epochs 100 \
|
||||
//! --hyperopt-params phase2_results.json \
|
||||
//! --data-dir data/futures-baseline --symbol ES.FUT
|
||||
//! ```
|
||||
//!
|
||||
//! ## Output
|
||||
//! ## Single-Phase (legacy)
|
||||
//!
|
||||
//! Results are written as JSON to `--output` (default: `ml/trained_models/hyperopt_results.json`).
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "dqn": { "best_objective": 0.123, "best_params": {...}, "trials": 20, "elapsed_secs": 45.3 },
|
||||
//! "ppo": { "best_objective": 0.456, "best_params": {...}, "trials": 20, "elapsed_secs": 67.8 }
|
||||
//! }
|
||||
//! ```bash
|
||||
//! # Search all 31D at once (slower convergence)
|
||||
//! cargo run -p ml --example hyperopt_baseline_rl --release -- \
|
||||
//! --model dqn --phase single --trials 20 --epochs 30 \
|
||||
//! --data-dir data/futures-baseline --symbol ES.FUT
|
||||
//! ```
|
||||
|
||||
#![allow(unused_crate_dependencies, unsafe_code)]
|
||||
@@ -119,7 +121,7 @@ use tracing::{error, info, warn};
|
||||
use ml::hyperopt::adapters::dqn::DQNTrainer;
|
||||
use ml::hyperopt::adapters::ppo::PPOTrainer;
|
||||
use ml::hyperopt::paths::{generate_run_id, TrainingPaths};
|
||||
use ml::hyperopt::ArgminOptimizer;
|
||||
use ml::hyperopt::{ArgminOptimizer, ParameterSpace};
|
||||
|
||||
use common::metrics::{server as metrics_server, training_metrics};
|
||||
|
||||
@@ -200,12 +202,25 @@ struct Args {
|
||||
/// Path to trades data for VPIN / Kyle's Lambda features
|
||||
#[arg(long)]
|
||||
trades_data_dir: Option<PathBuf>,
|
||||
|
||||
/// Two-phase hyperopt phase selection:
|
||||
/// fast — Phase 1: fix architecture, search learning dynamics (~15D). DEFAULT.
|
||||
/// full — Phase 2: fix dynamics from --hyperopt-params, search architecture (~5D).
|
||||
/// single — Legacy: search all 31D at once.
|
||||
#[arg(long, default_value = "fast")]
|
||||
phase: String,
|
||||
|
||||
/// Path to Phase 1 JSON results (required for --phase full).
|
||||
/// Contains the best continuous parameter vector from Phase 1's output.
|
||||
#[arg(long)]
|
||||
hyperopt_params: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Result entry for one model's hyperopt run
|
||||
fn build_model_result(
|
||||
best_objective: f64,
|
||||
best_params_json: Value,
|
||||
best_continuous_vector: Vec<f64>,
|
||||
top_k_params: Vec<Value>,
|
||||
num_trials: usize,
|
||||
elapsed_secs: f64,
|
||||
@@ -214,6 +229,7 @@ fn build_model_result(
|
||||
let mut result = serde_json::json!({
|
||||
"best_objective": best_objective,
|
||||
"best_params": best_params_json,
|
||||
"best_continuous_vector": best_continuous_vector,
|
||||
"top_k_params": top_k_params,
|
||||
"trials": num_trials,
|
||||
"elapsed_secs": elapsed_secs,
|
||||
@@ -348,9 +364,13 @@ fn run_dqn_hyperopt(args: &Args, parallel: usize, gpu_devices: &[ml_core::device
|
||||
|
||||
let best_metrics = sorted_trials.first().and_then(|t| t.metrics.clone());
|
||||
|
||||
// Emit continuous vector for Phase 2 consumption (--hyperopt-params).
|
||||
let best_continuous = result.best_params.to_continuous();
|
||||
|
||||
Ok(build_model_result(
|
||||
result.best_objective,
|
||||
best_params_json,
|
||||
best_continuous,
|
||||
top_k,
|
||||
result.all_trials.len(),
|
||||
elapsed,
|
||||
@@ -458,10 +478,12 @@ fn run_ppo_hyperopt(args: &Args, parallel: usize, gpu_devices: &[ml_core::device
|
||||
.collect();
|
||||
|
||||
let best_metrics = sorted_trials.first().and_then(|t| t.metrics.clone());
|
||||
let best_continuous = result.best_params.to_continuous();
|
||||
|
||||
Ok(build_model_result(
|
||||
result.best_objective,
|
||||
best_params_json,
|
||||
best_continuous,
|
||||
top_k,
|
||||
result.all_trials.len(),
|
||||
elapsed,
|
||||
@@ -499,10 +521,47 @@ fn main() -> Result<()> {
|
||||
let hyperopt_model_label = args.model.clone();
|
||||
training_metrics::set_hyperopt_mode(&hyperopt_model_label, true);
|
||||
|
||||
// Set up two-phase hyperopt before any optimization starts.
|
||||
// This must happen before continuous_bounds() is called.
|
||||
{
|
||||
use ml::training_profile::{set_hyperopt_phase, HyperoptPhase};
|
||||
let phase = match args.phase.as_str() {
|
||||
"fast" => HyperoptPhase::Fast,
|
||||
"full" => {
|
||||
let params_path = args.hyperopt_params.as_ref()
|
||||
.expect("--hyperopt-params required for --phase full");
|
||||
let params_json = std::fs::read_to_string(params_path)
|
||||
.expect("Failed to read --hyperopt-params JSON");
|
||||
let parsed: serde_json::Value = serde_json::from_str(¶ms_json)
|
||||
.expect("Failed to parse --hyperopt-params JSON");
|
||||
|
||||
// Extract the best_params continuous vector from Phase 1 output.
|
||||
// Phase 1 output format: { "dqn": { "best_params": { ... } } }
|
||||
// We need the to_continuous() representation — stored as "continuous_vector".
|
||||
let dqn_result = parsed.get("dqn")
|
||||
.expect("Phase 1 JSON missing 'dqn' key");
|
||||
let best_vec = dqn_result.get("best_continuous_vector")
|
||||
.expect("Phase 1 JSON missing 'best_continuous_vector' — was Phase 1 run with a compatible version?")
|
||||
.as_array()
|
||||
.expect("best_continuous_vector must be an array")
|
||||
.iter()
|
||||
.map(|v| v.as_f64().expect("best_continuous_vector values must be f64"))
|
||||
.collect::<Vec<f64>>();
|
||||
assert_eq!(best_vec.len(), 31, "best_continuous_vector must have 31 elements");
|
||||
HyperoptPhase::Full(best_vec)
|
||||
}
|
||||
"single" => HyperoptPhase::Single,
|
||||
other => panic!("Invalid --phase value '{}'. Must be 'fast', 'full', or 'single'.", other),
|
||||
};
|
||||
info!("Hyperopt phase: {:?}", args.phase);
|
||||
set_hyperopt_phase(phase);
|
||||
}
|
||||
|
||||
info!("========================================");
|
||||
info!(" Hyperopt Baseline Runner");
|
||||
info!("========================================");
|
||||
info!("Model: {}", args.model);
|
||||
info!("Phase: {}", args.phase);
|
||||
info!("Optimizer: {}", args.optimizer);
|
||||
info!("Symbol: {}", args.symbol);
|
||||
info!("Trials: {}", args.trials);
|
||||
|
||||
@@ -382,6 +382,25 @@ pub struct GpuBacktestEvaluator {
|
||||
chunked_rng_states: Option<CudaSlice<u32>>,
|
||||
}
|
||||
|
||||
impl Drop for GpuBacktestEvaluator {
|
||||
fn drop(&mut self) {
|
||||
// Synchronize forked stream before any CudaSlice fields drop.
|
||||
// Without this, pending GPU work (graph replay, cuBLAS, kernel launches)
|
||||
// may reference buffers that cudarc's CudaSlice::drop frees immediately.
|
||||
#[allow(unsafe_code)]
|
||||
unsafe {
|
||||
cudarc::driver::sys::cuStreamSynchronize(self.stream.cu_stream());
|
||||
}
|
||||
// Destroy CUDA graph before cuBLAS and buffer drops — graph references
|
||||
// internal device pointers that become invalid after CudaSlice drops.
|
||||
self.dqn_graph = None;
|
||||
// Drop cuBLAS handles before the stream they're attached to.
|
||||
// cuBLAS workspace is freed when the handle is destroyed.
|
||||
self.cublas_forward = None;
|
||||
self.chunked_cublas_forward = None;
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuBacktestEvaluator {
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -478,28 +478,77 @@ impl ParameterSpace for DQNParams {
|
||||
let tc = b("transaction_cost_multiplier", (0.5, 2.0));
|
||||
let pa = b("per_alpha", (0.4, 0.8));
|
||||
let pb = b("per_beta_start", (0.2, 0.6));
|
||||
let vr = b("v_range", (10.0, 50.0));
|
||||
let mut vr = b("v_range", (10.0, 50.0));
|
||||
let ns = b("noisy_sigma_init", (0.1, 1.0));
|
||||
let dh = b("dueling_hidden_dim", (128.0, 512.0));
|
||||
let mut dh = b("dueling_hidden_dim", (128.0, 512.0));
|
||||
let nst = b("n_steps", (3.0, 5.0));
|
||||
let na = b("num_atoms", (11.0, 101.0));
|
||||
let mut na = b("num_atoms", (11.0, 101.0));
|
||||
let wd = b("weight_decay", (1e-4, 1e-2));
|
||||
let kf = b("kelly_fractional", (0.25, 0.75));
|
||||
let km = b("kelly_max_fraction", (0.1, 0.5));
|
||||
let vw = b("volatility_window", (10.0, 30.0));
|
||||
let tau = b("tau", (0.005, 0.01));
|
||||
let hdim = b("hidden_dim_base", (128.0, 512.0));
|
||||
let mut hdim = b("hidden_dim_base", (128.0, 512.0));
|
||||
let cql = b("cql_alpha", (0.0, 1.0));
|
||||
let lrd = b("lr_decay_type", (0.0, 2.0));
|
||||
let dsr = b("dsr_eta", (0.001, 0.05));
|
||||
let mpf = b("minimum_profit_factor", (1.1, 2.0));
|
||||
let cb = b("count_bonus_coefficient", (0.0, 0.3));
|
||||
let sw = b("sharpe_weight", (0.0, 0.5));
|
||||
let bh = b("branch_hidden_dim", (64.0, 256.0));
|
||||
let mut bh = b("branch_hidden_dim", (64.0, 256.0));
|
||||
let ga = b("gradient_accumulation_steps", (1.0, 1.0));
|
||||
let iq = b("iqn_lambda", (0.0, 2.0));
|
||||
|
||||
vec![
|
||||
// Two-phase hyperopt: fix architecture OR dynamics params to single-point bounds.
|
||||
// Single-point bounds (v, v) make the optimizer trivially converge on those
|
||||
// dimensions, focusing search power on the free dimensions.
|
||||
use crate::training_profile::{get_hyperopt_phase, HyperoptPhase};
|
||||
let phase = get_hyperopt_phase();
|
||||
|
||||
match phase {
|
||||
HyperoptPhase::Fast => {
|
||||
// Phase 1: fix architecture to small network from [phase_fast] TOML.
|
||||
// Search only learning dynamics (~15D free, ~5D fixed).
|
||||
// All values MUST exist in [phase_fast] — no silent defaults.
|
||||
let pf = hp.phase_fast.as_ref()
|
||||
.expect("[phase_fast] section missing from dqn-hyperopt.toml — required for --phase fast");
|
||||
let fix_hdim = pf.hidden_dim_base
|
||||
.expect("phase_fast.hidden_dim_base missing from dqn-hyperopt.toml");
|
||||
let fix_na = pf.num_atoms
|
||||
.expect("phase_fast.num_atoms missing from dqn-hyperopt.toml");
|
||||
let fix_bh = pf.branch_hidden_dim
|
||||
.expect("phase_fast.branch_hidden_dim missing from dqn-hyperopt.toml");
|
||||
let fix_dh = pf.dueling_hidden_dim
|
||||
.expect("phase_fast.dueling_hidden_dim missing from dqn-hyperopt.toml");
|
||||
let fix_vr = pf.v_range
|
||||
.expect("phase_fast.v_range missing from dqn-hyperopt.toml");
|
||||
|
||||
hdim = (fix_hdim, fix_hdim);
|
||||
na = (fix_na, fix_na);
|
||||
bh = (fix_bh, fix_bh);
|
||||
dh = (fix_dh, fix_dh);
|
||||
vr = (fix_vr, fix_vr);
|
||||
|
||||
tracing::info!(
|
||||
"Phase FAST: fixed architecture (hidden_dim={}, num_atoms={}, branch_hidden={}, dueling_hidden={}, v_range={})",
|
||||
fix_hdim, fix_na, fix_bh, fix_dh, fix_vr
|
||||
);
|
||||
}
|
||||
HyperoptPhase::Full(ref best_vec) => {
|
||||
// Phase 2: fix learning dynamics from Phase 1 best params.
|
||||
// Search only architecture dims (~5D free, ~26D fixed).
|
||||
// The best_vec is the raw continuous vector from Phase 1.
|
||||
// Fix all non-architecture dims to Phase 1's best values.
|
||||
// Architecture dims (indices 10, 13, 15, 21, 28) stay free.
|
||||
tracing::info!("Phase FULL: fixing {} dynamics params from Phase 1 best", best_vec.len());
|
||||
// Bounds will be applied after vec construction below.
|
||||
}
|
||||
HyperoptPhase::Single => {
|
||||
// Legacy: search all 31D at once.
|
||||
}
|
||||
}
|
||||
|
||||
let mut bounds = vec![
|
||||
(lr.0.ln(), lr.1.ln()), // 0: learning_rate (log scale)
|
||||
bs, // 1: batch_size
|
||||
gm, // 2: gamma
|
||||
@@ -531,7 +580,21 @@ impl ParameterSpace for DQNParams {
|
||||
bh, // 28: branch_hidden_dim
|
||||
ga, // 29: gradient_accumulation_steps
|
||||
iq, // 30: iqn_lambda
|
||||
]
|
||||
];
|
||||
|
||||
// Phase FULL: fix all non-architecture dims to Phase 1 best values.
|
||||
// Architecture dims that stay FREE: 10 (v_range), 13 (dueling_hidden),
|
||||
// 15 (num_atoms), 21 (hidden_dim_base), 28 (branch_hidden_dim).
|
||||
if let HyperoptPhase::Full(ref best_vec) = phase {
|
||||
let arch_dims: &[usize] = &[10, 13, 15, 21, 28];
|
||||
for (i, bv) in best_vec.iter().enumerate() {
|
||||
if i < bounds.len() && !arch_dims.contains(&i) {
|
||||
bounds[i] = (*bv, *bv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bounds
|
||||
}
|
||||
|
||||
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
|
||||
|
||||
@@ -231,12 +231,65 @@ pub struct PsoSection {
|
||||
pub social: Option<f64>,
|
||||
}
|
||||
|
||||
/// Phase 1 ("fast") fixed architecture values.
|
||||
/// When phase=fast, these architecture params are fixed to single-point bounds
|
||||
/// so PSO searches only learning dynamics (~15D instead of 31D).
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct PhaseFastSection {
|
||||
pub hidden_dim_base: Option<f64>,
|
||||
pub num_atoms: Option<f64>,
|
||||
pub branch_hidden_dim: Option<f64>,
|
||||
pub dueling_hidden_dim: Option<f64>,
|
||||
pub v_range: Option<f64>,
|
||||
}
|
||||
|
||||
/// Two-phase hyperopt configuration.
|
||||
/// Set via `HYPEROPT_PHASE` static before running optimization.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum HyperoptPhase {
|
||||
/// Search all 31 dimensions at once (default, backward-compatible).
|
||||
Single,
|
||||
/// Phase 1: fix architecture, search learning dynamics (~15D).
|
||||
Fast,
|
||||
/// Phase 2: fix dynamics from Phase 1 JSON, search architecture (~5D).
|
||||
/// Contains the best continuous parameter vector from Phase 1.
|
||||
Full(Vec<f64>),
|
||||
}
|
||||
|
||||
impl Default for HyperoptPhase {
|
||||
fn default() -> Self {
|
||||
Self::Single
|
||||
}
|
||||
}
|
||||
|
||||
/// Global hyperopt phase configuration.
|
||||
/// Set by the CLI before optimization starts; read by `continuous_bounds()`.
|
||||
/// Uses Mutex (not OnceLock) to allow resetting in tests.
|
||||
static HYPEROPT_PHASE: std::sync::Mutex<Option<HyperoptPhase>> = std::sync::Mutex::new(None);
|
||||
|
||||
/// Set the global hyperopt phase. Must be called before optimization starts.
|
||||
pub fn set_hyperopt_phase(phase: HyperoptPhase) {
|
||||
if let Ok(mut guard) = HYPEROPT_PHASE.lock() {
|
||||
*guard = Some(phase);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current hyperopt phase (defaults to Single if not set).
|
||||
pub fn get_hyperopt_phase() -> HyperoptPhase {
|
||||
HYPEROPT_PHASE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.clone())
|
||||
.unwrap_or(HyperoptPhase::Single)
|
||||
}
|
||||
|
||||
/// Hyperopt profile with search space bounds + PSO config.
|
||||
#[derive(Debug, Clone, Deserialize, Default)]
|
||||
pub struct HyperoptProfile {
|
||||
pub search_space: Option<SearchSpaceSection>,
|
||||
pub fixed: Option<std::collections::HashMap<String, toml::Value>>,
|
||||
pub pso: Option<PsoSection>,
|
||||
pub phase_fast: Option<PhaseFastSection>,
|
||||
}
|
||||
|
||||
impl HyperoptProfile {
|
||||
@@ -750,7 +803,35 @@ mod tests {
|
||||
assert!(ss.learning_rate.is_some());
|
||||
assert!(ss.hidden_dim_base.is_some());
|
||||
let hd = ss.hidden_dim_base.unwrap();
|
||||
assert_eq!(hd, [128.0, 512.0]);
|
||||
assert_eq!(hd, [128.0, 256.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hyperopt_phase_fast_section_loads() {
|
||||
let p = HyperoptProfile::load("dqn-hyperopt");
|
||||
let pf = p.phase_fast.expect("[phase_fast] section must exist");
|
||||
assert_eq!(pf.hidden_dim_base, Some(128.0));
|
||||
assert_eq!(pf.num_atoms, Some(11.0));
|
||||
assert_eq!(pf.branch_hidden_dim, Some(64.0));
|
||||
assert_eq!(pf.dueling_hidden_dim, Some(128.0));
|
||||
assert_eq!(pf.v_range, Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_phase_fast_bounds_fix_architecture() {
|
||||
// When phase=fast, architecture dims must become single-point bounds.
|
||||
set_hyperopt_phase(HyperoptPhase::Fast);
|
||||
use crate::hyperopt::ParameterSpace;
|
||||
let bounds = crate::hyperopt::adapters::dqn::DQNParams::continuous_bounds();
|
||||
// Index 21: hidden_dim_base, 15: num_atoms, 28: branch_hidden_dim,
|
||||
// 13: dueling_hidden_dim, 10: v_range
|
||||
assert_eq!(bounds[21].0, bounds[21].1, "hidden_dim_base must be fixed");
|
||||
assert_eq!(bounds[15].0, bounds[15].1, "num_atoms must be fixed");
|
||||
assert_eq!(bounds[28].0, bounds[28].1, "branch_hidden_dim must be fixed");
|
||||
assert_eq!(bounds[13].0, bounds[13].1, "dueling_hidden_dim must be fixed");
|
||||
assert_eq!(bounds[10].0, bounds[10].1, "v_range must be fixed");
|
||||
// Learning rate (index 0) must still be a range.
|
||||
assert_ne!(bounds[0].0, bounds[0].1, "learning_rate must still be searchable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Two-Phase Hyperopt: Search Quality over Speed
|
||||
|
||||
## Goal
|
||||
|
||||
Split the 31D PSO search into two sequential phases: first optimize learning dynamics with a fixed small network, then optimize architecture with the best dynamics locked in. Better search quality from lower-dimensional spaces, with speed as a bonus.
|
||||
|
||||
## Problem
|
||||
|
||||
The current 31D search mixes learning dynamics (lr, gamma, tau) with architecture (hidden_dim, num_atoms). A bad lr makes ALL network sizes look bad — PSO wastes evaluations on redundant lr×hidden_dim combinations. Halving the dimensionality at each phase dramatically improves convergence.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Phase 1: Learning Dynamics (fast, ~30s/trial)
|
||||
|
||||
**Fixed**: `hidden_dim_base=128`, `num_atoms=11`, `branch_hidden_dim=64`
|
||||
**Search** (~15D): learning_rate, gamma, tau, batch_size, buffer_size, per_alpha, per_beta_start, entropy_coefficient, cql_alpha, weight_decay, noisy_sigma_init, n_steps, kelly_fractional, dsr_eta, iqn_lambda
|
||||
|
||||
**Config**: 50 trials × 30 epochs at ~173ms/epoch = 5s training + 30s data/backtest = ~35s/trial. Total: ~30 min.
|
||||
|
||||
**Output**: Best learning dynamics config (JSON).
|
||||
|
||||
### Phase 2: Architecture (production speed, ~2 min/trial)
|
||||
|
||||
**Fixed**: Best lr, gamma, tau, PER params from Phase 1
|
||||
**Search** (~5D): hidden_dim_base [128-512], num_atoms [11-51], branch_hidden_dim [64-256], dueling_hidden_dim [128-512], v_range [10-50]
|
||||
|
||||
**Config**: 20 trials × 50 epochs at production speed. Total: ~40 min.
|
||||
|
||||
**Output**: Best full config (JSON) for production training.
|
||||
|
||||
## Implementation
|
||||
|
||||
### CLI changes to `hyperopt_baseline_rl`
|
||||
|
||||
Add `--phase` argument:
|
||||
```
|
||||
--phase fast # Phase 1: fix architecture, search dynamics (default)
|
||||
--phase full # Phase 2: fix dynamics from --hyperopt-params, search architecture
|
||||
--phase single # Current behavior: search everything in one pass
|
||||
```
|
||||
|
||||
Add `--fix-hidden-dim <N>` to override hidden_dim_base for Phase 1.
|
||||
Add `--fix-num-atoms <N>` to override num_atoms for Phase 1.
|
||||
|
||||
### Hyperopt adapter changes
|
||||
|
||||
In `DQNParams::continuous_bounds()`:
|
||||
- If phase=fast: fix hidden_dim_base, num_atoms, branch_hidden_dim to constants. Their bounds become `(value, value)` — single points in the search space.
|
||||
- If phase=full: fix learning dynamics from the Phase 1 JSON. Load best params, set their bounds to `(best, best)`.
|
||||
|
||||
This requires NO changes to the PSO algorithm — fixed params are just single-point bounds. The optimizer trivially converges on them and focuses search on the free dimensions.
|
||||
|
||||
### Config changes
|
||||
|
||||
Add to `config/training/dqn-hyperopt.toml`:
|
||||
```toml
|
||||
[phase_fast]
|
||||
hidden_dim_base = 128
|
||||
num_atoms = 11
|
||||
branch_hidden_dim = 64
|
||||
|
||||
[phase_full]
|
||||
# Loaded from --hyperopt-params JSON (Phase 1 output)
|
||||
```
|
||||
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# Phase 1: find best learning dynamics (~30 min)
|
||||
cargo run --example hyperopt_baseline_rl -- \
|
||||
--model dqn --phase fast --trials 50 --epochs 30 \
|
||||
--data-dir data/futures-baseline --symbol ES.FUT \
|
||||
--output phase1_results.json
|
||||
|
||||
# Phase 2: find best architecture with locked dynamics (~40 min)
|
||||
cargo run --example hyperopt_baseline_rl -- \
|
||||
--model dqn --phase full --trials 20 --epochs 50 \
|
||||
--hyperopt-params phase1_results.json \
|
||||
--data-dir data/futures-baseline --symbol ES.FUT \
|
||||
--output phase2_results.json
|
||||
|
||||
# Production training with best config
|
||||
cargo run --example train_baseline_rl -- \
|
||||
--model dqn --epochs 100 \
|
||||
--hyperopt-params phase2_results.json \
|
||||
--data-dir data/futures-baseline --symbol ES.FUT
|
||||
```
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Automatic phase chaining (user runs Phase 1, reviews, then Phase 2)
|
||||
- Multi-objective optimization (Sharpe + stability)
|
||||
- Architecture search beyond hidden dims (layer count, residual connections)
|
||||
Reference in New Issue
Block a user