Files
foxhunt/ml/src/hyperopt/adapters/mamba2.rs
jgrusewski 7fe064c6e0 fix: resolve all 179 clippy deny violations in ml crate
Replace .unwrap()/.expect() with safe alternatives across 51 files:
- 41 `let _ = writeln!()` → `_ = writeln!()` (wildcard assignment)
- 53 unwrap() in features/ → unwrap_or/match/early-return
- 20 expect() in inference/metrics → module-level #[allow] for static init
- 16 unwrap/expect in hyperopt/ → ?, map_err, unwrap_or
- 20 unwrap in dqn/trainers/ → ?, map_err, unwrap_or
- 28 unwrap in misc files → context-appropriate safe patterns

Zero clippy errors remain across the entire workspace.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:19:35 +01:00

1298 lines
47 KiB
Rust

//! MAMBA-2 Hyperparameter Optimization Adapter
//!
//! This module provides a production-ready adapter for optimizing MAMBA-2
//! hyperparameters using the generic optimization framework. It implements:
//!
//! - Parameter space with log-scale handling for learning rates
//! - Training wrapper that integrates with existing MAMBA-2 pipeline
//! - Metrics extraction for validation loss optimization
//!
//! ## Usage Example
//!
//! ```rust,no_run
//! use ml::hyperopt::EgoboxOptimizer;
//! use ml::hyperopt::adapters::mamba2::{Mamba2Trainer, Mamba2Params};
//!
//! # async fn example() -> anyhow::Result<()> {
//! // Create trainer
//! let trainer = Mamba2Trainer::new(
//! "test_data/ES_FUT_180d.parquet",
//! 50, // epochs per trial
//! )?;
//!
//! // Run optimization
//! let optimizer = EgoboxOptimizer::with_trials(30, 5);
//! let result = optimizer.optimize(trainer)?;
//!
//! println!("Best learning rate: {}", result.best_params.learning_rate);
//! println!("Best batch size: {}", result.best_params.batch_size);
//! println!("Best validation loss: {:.6}", result.best_objective);
//! # Ok(())
//! # }
//! ```
use anyhow::{Context, Result};
use candle_core::{Device, Tensor};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write as IoWrite;
use std::path::PathBuf;
use tracing::{info, warn};
use arrow::array::{Array, Float64Array, PrimitiveArray, UInt64Array};
use arrow::datatypes::TimestampNanosecondType;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use std::fs::File;
use crate::features::{extract_ml_features, FeatureConfig, OHLCVBar};
use crate::hyperopt::paths::TrainingPaths;
use crate::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace};
use crate::mamba::{Mamba2Config, Mamba2SSM, OptimizerType};
use crate::MLError;
/// MAMBA-2 hyperparameter space
///
/// Defines the hyperparameters to optimize for MAMBA-2 training:
/// - Learning rate (log-scale: 1e-5 to 1e-2)
/// - Batch size (linear scale: 4 to 256, optimized for GPU utilization)
/// - Dropout rate (linear scale: 0.0 to 0.5)
/// - Weight decay (log-scale: 1e-6 to 1e-2)
///
/// ## Parameter Scaling
///
/// - **Log-scale**: Learning rate, weight decay (span multiple orders of magnitude)
/// - **Linear scale**: Batch size, dropout (span single order of magnitude)
///
/// This scaling ensures efficient exploration by egobox's Gaussian Process.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Mamba2Params {
/// Learning rate for Adam optimizer (log-scale)
pub learning_rate: f64,
/// Batch size for training (linear scale, integer)
pub batch_size: usize,
/// Dropout rate for regularization (linear scale)
pub dropout: f64,
/// Weight decay for L2 regularization (log-scale)
pub weight_decay: f64,
/// Gradient clipping threshold (log-scale)
pub grad_clip: f64,
/// Warmup steps for learning rate schedule (linear scale)
pub warmup_steps: usize,
/// P0: Adam beta1 momentum parameter (linear scale)
pub adam_beta1: f64,
/// P1: Adam beta2 parameter (linear scale)
pub adam_beta2: f64,
/// P1: Adam epsilon (log-scale)
pub adam_epsilon: f64,
/// P2: Lookback window (sequence length) (linear scale, integer)
pub lookback_window: usize,
/// P2: Sequence stride for overlapping windows (linear scale, integer)
pub sequence_stride: usize,
/// P2: Normalization epsilon for layer norm (log-scale)
pub norm_eps: f64,
}
impl Default for Mamba2Params {
fn default() -> Self {
Self {
learning_rate: 1e-4,
batch_size: 32,
dropout: 0.1,
weight_decay: 1e-4,
grad_clip: 1.0,
warmup_steps: 100,
adam_beta1: 0.9,
adam_beta2: 0.999,
adam_epsilon: 1e-8,
lookback_window: 60,
sequence_stride: 1,
norm_eps: 1e-5,
}
}
}
impl ParameterSpace for Mamba2Params {
fn continuous_bounds() -> Vec<(f64, f64)> {
vec![
(1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log scale)
(4.0, 256.0), // batch_size (linear) - wide bounds, clamped by trainer config
(0.0, 0.5), // dropout (linear)
(1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log scale)
(0.5_f64.ln(), 5.0_f64.ln()), // grad_clip (log scale)
(100.0, 2000.0), // warmup_steps (linear)
(0.85, 0.95), // adam_beta1 (linear)
(0.98, 0.999), // adam_beta2 (linear)
(1e-9_f64.ln(), 1e-7_f64.ln()), // adam_epsilon (log scale)
(30.0, 120.0), // lookback_window (linear)
(1.0, 5.0), // sequence_stride (linear)
(1e-6_f64.ln(), 1e-4_f64.ln()), // norm_eps (log scale)
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != 12 {
return Err(MLError::ConfigError {
reason: format!("Expected 12 parameters, got {}", x.len()),
});
}
Ok(Self {
learning_rate: x[0].exp(),
batch_size: x[1].round().max(1.0) as usize,
dropout: x[2].clamp(0.0, 0.5),
weight_decay: x[3].exp(),
grad_clip: x[4].exp(),
warmup_steps: x[5].round().max(1.0) as usize,
adam_beta1: x[6].clamp(0.85, 0.95),
adam_beta2: x[7].clamp(0.98, 0.999),
adam_epsilon: x[8].exp(),
lookback_window: x[9].round().max(30.0).min(120.0) as usize,
sequence_stride: x[10].round().max(1.0).min(5.0) as usize,
norm_eps: x[11].exp(),
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.learning_rate.ln(),
self.batch_size as f64,
self.dropout,
self.weight_decay.ln(),
self.grad_clip.ln(),
self.warmup_steps as f64,
self.adam_beta1,
self.adam_beta2,
self.adam_epsilon.ln(),
self.lookback_window as f64,
self.sequence_stride as f64,
self.norm_eps.ln(),
]
}
fn param_names() -> Vec<&'static str> {
vec![
"learning_rate",
"batch_size",
"dropout",
"weight_decay",
"grad_clip",
"warmup_steps",
"adam_beta1",
"adam_beta2",
"adam_epsilon",
"lookback_window",
"sequence_stride",
"norm_eps",
]
}
}
/// MAMBA-2 training metrics
///
/// Contains all relevant metrics from a MAMBA-2 training run.
/// The primary optimization target is validation loss.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mamba2Metrics {
/// Final validation loss (optimization target)
pub val_loss: f64,
/// Final training loss
pub train_loss: f64,
/// Validation perplexity (exp(val_loss))
pub val_perplexity: f64,
/// Directional accuracy (% of correct direction predictions)
pub directional_accuracy: f64,
/// Mean Absolute Error
pub mae: f64,
/// Root Mean Squared Error
pub rmse: f64,
/// R² (Coefficient of Determination)
pub r_squared: f64,
/// Number of epochs completed
pub epochs_completed: usize,
}
/// MAMBA-2 trainer for hyperparameter optimization
///
/// This struct wraps the MAMBA-2 training pipeline and implements
/// `HyperparameterOptimizable` for use with `EgoboxOptimizer`.
///
/// ## Configuration
///
/// - **Parquet file**: Market data source (OHLCV bars)
/// - **Epochs**: Number of training epochs per trial
/// - **Device**: CUDA GPU (falls back to CPU if unavailable)
/// - **Features**: 51 features (WAVE 10: Reduced from 54→51 after Proxy OFI removal)
///
/// ## Fixed Architecture
///
/// The following parameters are fixed for consistency:
/// - `d_model`: 54 (WAVE 10: 51 market features + 3 portfolio features)
/// - `d_state`: 16
/// - `num_layers`: 6
/// - `sequence_length`: 60
///
/// ## Optimized Hyperparameters
///
/// The following are optimized by `Mamba2Params`:
/// - Learning rate
/// - Batch size
/// - Dropout
/// - Weight decay
#[derive(Debug)]
pub struct Mamba2Trainer {
parquet_file: PathBuf,
epochs: usize,
device: Device,
feature_config: FeatureConfig,
d_model: usize,
train_split: f64,
/// Target normalization parameters (set after data loading)
target_min: Option<f64>,
target_max: Option<f64>,
/// Minimum batch size (for GPU memory constraints)
batch_size_min: f64,
/// Maximum batch size (for GPU memory constraints)
batch_size_max: f64,
/// Enable async data loading (prefetch while GPU trains)
async_loading: bool,
/// Number of batches to prefetch (2-3 recommended)
prefetch_count: usize,
/// Training paths configuration (replaces hardcoded checkpoint_dir)
training_paths: TrainingPaths,
/// Early stopping patience (epochs without improvement before stopping)
early_stopping_patience: usize,
/// Early stopping minimum epochs (minimum epochs before early stopping can trigger)
early_stopping_min_epochs: usize,
/// Trial counter for hyperopt (tracks trial number across multiple trials)
trial_counter: usize,
}
impl Mamba2Trainer {
/// Create a new MAMBA-2 trainer
///
/// # Arguments
///
/// * `parquet_file` - Path to Parquet file with market data
/// * `epochs` - Number of training epochs per trial
///
/// # Returns
///
/// Configured trainer ready for optimization
///
/// # Errors
///
/// Returns error if:
/// - Parquet file doesn't exist
/// - CUDA device initialization fails (falls back to CPU)
pub fn new<P: Into<PathBuf>>(parquet_file: P, epochs: usize) -> Result<Self> {
let parquet_file = parquet_file.into();
if !parquet_file.exists() {
return Err(MLError::ConfigError {
reason: format!("Parquet file not found: {}", parquet_file.display()),
}
.into());
}
// Initialize device (CUDA preferred, CPU fallback)
let device = Device::new_cuda(0).unwrap_or_else(|e| {
warn!("CUDA unavailable ({}), falling back to CPU", e);
Device::Cpu
});
// Use WAVE 10 feature configuration (51 market + 3 portfolio = 54)
let feature_config = FeatureConfig::wave_d();
let d_model = feature_config.feature_count();
info!("MAMBA-2 Trainer initialized:");
info!(" Device: {:?}", device);
info!(" Features: {} (WAVE 10: 51 market + 3 portfolio)", d_model);
info!(" Epochs per trial: {}", epochs);
// Use temporary default paths - should be replaced with with_training_paths()
let training_paths = TrainingPaths::new("/tmp/ml_training", "mamba2", "default");
Ok(Self {
parquet_file,
epochs,
device,
feature_config,
d_model,
train_split: 0.8,
target_min: None,
target_max: None,
batch_size_min: 4.0, // Default minimum
batch_size_max: 96.0, // Default maximum (safe for RTX A4000 16GB)
async_loading: true, // Enable async loading by default
prefetch_count: 3, // Prefetch 3 batches (good balance)
training_paths,
early_stopping_patience: 5, // Default: 5 epochs (hyperopt optimized)
early_stopping_min_epochs: 5, // Default: 5 epochs (hyperopt optimized)
trial_counter: 0, // Start at trial 0
})
}
/// Configure early stopping parameters (overrides defaults)
pub fn with_early_stopping(mut self, patience: usize, min_epochs: usize) -> Self {
self.early_stopping_patience = patience;
self.early_stopping_min_epochs = min_epochs;
self
}
/// Set train/validation split ratio
pub fn with_train_split(mut self, split: f64) -> Self {
assert!(split > 0.0 && split < 1.0, "Split must be in (0, 1)");
self.train_split = split;
self
}
/// Set batch size bounds for GPU memory constraints
///
/// # Arguments
///
/// * `min` - Minimum batch size (must be >= 1)
/// * `max` - Maximum batch size (must be > min)
///
/// # Example
///
/// ```no_run
/// # use ml::hyperopt::adapters::mamba2::Mamba2Trainer;
/// // Configure for RTX 4090 (24GB VRAM)
/// let trainer = Mamba2Trainer::new("data.parquet", 50)?
/// .with_batch_size_bounds(4.0, 256.0);
///
/// // Configure for RTX 3050 Ti (4GB VRAM)
/// let trainer = Mamba2Trainer::new("data.parquet", 50)?
/// .with_batch_size_bounds(4.0, 32.0);
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn with_batch_size_bounds(mut self, min: f64, max: f64) -> Self {
assert!(min >= 1.0, "Minimum batch size must be >= 1");
assert!(max > min, "Maximum batch size must be > minimum");
info!("Configuring batch_size bounds: [{}, {}]", min, max);
self.batch_size_min = min;
self.batch_size_max = max;
self
}
/// Enable or disable async data loading (prefetching)
///
/// When enabled, batches are prepared on CPU and transferred to GPU in a
/// background thread while the GPU trains on the current batch. This improves
/// GPU utilization from ~78% to ~90-95% and reduces training time by 20-30%.
///
/// # Arguments
///
/// * `enabled` - Enable async loading
/// * `prefetch_count` - Number of batches to prefetch (2-3 recommended)
///
/// # Example
///
/// ```no_run
/// # use ml::hyperopt::adapters::mamba2::Mamba2Trainer;
/// // Enable async loading with 3 batch prefetch
/// let trainer = Mamba2Trainer::new("data.parquet", 50)?
/// .with_async_loading(true, 3);
///
/// // Disable async loading (sync mode)
/// let trainer = Mamba2Trainer::new("data.parquet", 50)?
/// .with_async_loading(false, 0);
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn with_async_loading(mut self, enabled: bool, prefetch_count: usize) -> Self {
if enabled {
assert!(
prefetch_count >= 2,
"Prefetch count must be >= 2 when async loading enabled"
);
assert!(
prefetch_count <= 10,
"Prefetch count must be <= 10 to avoid excessive memory"
);
}
info!(
"Configuring async data loading: enabled={}, prefetch={}",
enabled, prefetch_count
);
self.async_loading = enabled;
self.prefetch_count = prefetch_count;
self
}
/// Set checkpoint directory for saving best models
///
/// # Arguments
///
/// * `dir` - Path to checkpoint directory (will be created if it doesn't exist)
///
/// # Returns
///
/// Self for method chaining
///
/// # Deprecated
///
/// Use `with_training_paths()` instead for full path management
pub fn with_checkpoint_dir<P: Into<PathBuf>>(mut self, dir: P) -> Self {
// Update training_paths to use provided checkpoint directory
let checkpoint_dir = dir.into();
self.training_paths = TrainingPaths::new(
checkpoint_dir.parent().unwrap_or(&checkpoint_dir),
"mamba2",
"legacy",
);
info!(
"Checkpoint directory set to: {:?}",
self.training_paths.checkpoints_dir()
);
self
}
/// Set training paths configuration (recommended over with_checkpoint_dir)
///
/// # Arguments
///
/// * `paths` - Training paths configuration
///
/// # Returns
///
/// Self for method chaining
pub fn with_training_paths(mut self, paths: TrainingPaths) -> Self {
info!("Training paths configured:");
info!(" Run directory: {:?}", paths.run_dir());
info!(" Checkpoints: {:?}", paths.checkpoints_dir());
info!(" Logs: {:?}", paths.logs_dir());
info!(" Hyperopt: {:?}", paths.hyperopt_dir());
self.training_paths = paths;
self
}
/// Denormalize a prediction from [0,1] to original price scale
///
/// # Arguments
///
/// * `normalized` - Normalized prediction in [0,1] range
///
/// # Returns
///
/// `Some(price)` in original scale (e.g., $5000-6000 for ES futures),
/// or `None` if normalization params have not been set (i.e., training has not run yet).
pub fn denormalize_prediction(&self, normalized: f64) -> Option<f64> {
let min = self.target_min?;
let max = self.target_max?;
Some(normalized * (max - min) + min)
}
/// Load and prepare training data from Parquet
///
/// Reads OHLCV bars, extracts features, creates sequences.
fn load_and_prepare_data(
&self,
seq_len: usize,
_stride: usize, // P2 parameter - for future use with overlapping sequences
) -> Result<(Vec<(Tensor, Tensor)>, Vec<(Tensor, Tensor)>, f64, f64)> {
// Open Parquet file
let file = File::open(&self.parquet_file).with_context(|| {
format!(
"Failed to open Parquet file: {}",
self.parquet_file.display()
)
})?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)
.context("Failed to create Parquet reader")?;
let reader = builder.build().context("Failed to build Parquet reader")?;
// Read all OHLCV bars
let mut all_ohlcv_bars = Vec::new();
for batch_result in reader {
let batch = batch_result.context("Failed to read record batch")?;
let timestamps = batch
.column(9)
.as_any()
.downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
.context("Failed to downcast timestamp column")?;
let opens = batch
.column(3)
.as_any()
.downcast_ref::<Float64Array>()
.context("Failed to downcast open column")?;
let highs = batch
.column(4)
.as_any()
.downcast_ref::<Float64Array>()
.context("Failed to downcast high column")?;
let lows = batch
.column(5)
.as_any()
.downcast_ref::<Float64Array>()
.context("Failed to downcast low column")?;
let closes = batch
.column(6)
.as_any()
.downcast_ref::<Float64Array>()
.context("Failed to downcast close column")?;
let volumes = batch
.column(7)
.as_any()
.downcast_ref::<UInt64Array>()
.context("Failed to downcast volume column")?;
for i in 0..batch.num_rows() {
let timestamp_ns = timestamps.value(i);
let timestamp = chrono::DateTime::from_timestamp(
(timestamp_ns / 1_000_000_000) as i64,
(timestamp_ns.rem_euclid(1_000_000_000)) as u32,
)
.unwrap_or_else(|| chrono::Utc::now());
let bar = OHLCVBar {
timestamp,
open: opens.value(i),
high: highs.value(i),
low: lows.value(i),
close: closes.value(i),
volume: volumes.value(i) as f64,
};
all_ohlcv_bars.push(bar);
}
}
// P1 FIX: Validate minimum rows before processing
if all_ohlcv_bars.len() < seq_len + 1 {
return Err(MLError::ModelError(format!(
"Insufficient data: {} bars (need at least {} for seq_len={})",
all_ohlcv_bars.len(),
seq_len + 1,
seq_len
))
.into());
}
// Extract features
let features =
extract_ml_features(&all_ohlcv_bars).context("Failed to extract features")?;
if features.is_empty() {
return Err(
MLError::ModelError("No features extracted from Parquet data".to_owned()).into(),
);
}
// P0 FIX: Collect all target prices for normalization
let mut all_target_prices = Vec::new();
for window_idx in 0..features.len().saturating_sub(seq_len) {
let target_price = all_ohlcv_bars[window_idx + seq_len].close;
all_target_prices.push(target_price);
}
// Compute normalization parameters
let target_min = all_target_prices
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let target_max = all_target_prices
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
if (target_max - target_min).abs() < 1e-6 {
return Err(MLError::ModelError(
"Target prices have zero variance - cannot normalize".to_owned(),
)
.into());
}
info!(
"Target normalization: min={:.2}, max={:.2}, range={:.2}",
target_min,
target_max,
target_max - target_min
);
// FIX: Apply percentile clipping BEFORE normalization to prevent outliers
// (e.g., OBV features with extreme values) from crushing other features
let all_feature_values: Vec<f64> =
features.iter().flat_map(|f| f.iter().copied()).collect();
// Compute 1st and 99th percentiles
let mut sorted_features = all_feature_values.clone();
sorted_features.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let p1_idx = (sorted_features.len() as f64 * 0.01).round() as usize;
let p99_idx = (sorted_features.len() as f64 * 0.99).round() as usize;
let p1 = sorted_features[p1_idx.min(sorted_features.len() - 1)];
let p99 = sorted_features[p99_idx.min(sorted_features.len() - 1)];
info!("Feature percentile clipping: p1={:.2}, p99={:.2}", p1, p99);
// Clip outliers to [p1, p99] range
let clipped_feature_values: Vec<f64> = all_feature_values
.iter()
.map(|&x| x.clamp(p1, p99))
.collect();
// Now compute normalization parameters from clipped data
let feature_min = clipped_feature_values
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let feature_max = clipped_feature_values
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
if (feature_max - feature_min).abs() < 1e-6 {
return Err(MLError::ModelError(
"Features have zero variance - cannot normalize".to_owned(),
)
.into());
}
info!(
"Feature normalization (after clipping): min={:.2}, max={:.2}, range={:.2}",
feature_min,
feature_max,
feature_max - feature_min
);
// Create sequences with normalized features and targets
let mut feature_sequences = Vec::new();
for (window_idx, &target_price) in all_target_prices.iter().enumerate() {
// FIX: Apply percentile clipping + normalization to [0, 1] range
let sequence: Vec<f64> = features[window_idx..window_idx + seq_len]
.iter()
.flat_map(|f| f.iter().copied())
.map(|val| {
// Clip to percentile range, then normalize
let clipped = val.clamp(p1, p99);
(clipped - feature_min) / (feature_max - feature_min)
})
.collect();
// Normalize target to [0,1]
let normalized_target = (target_price - target_min) / (target_max - target_min);
let input_tensor = Tensor::new(sequence.as_slice(), &Device::Cpu)?.reshape((
1,
seq_len,
self.d_model,
))?;
let target_tensor =
Tensor::new(&[normalized_target], &Device::Cpu)?.reshape((1, 1, 1))?;
feature_sequences.push((input_tensor, target_tensor));
}
// Split train/validation
let split_idx = (feature_sequences.len() as f64 * self.train_split) as usize;
let train_data = feature_sequences[..split_idx].to_vec();
let val_data = feature_sequences[split_idx..].to_vec();
// P1 FIX: Validate validation set size
if val_data.len() < 10 {
return Err(MLError::ModelError(format!(
"Validation set too small: {} samples (need at least 10)",
val_data.len()
))
.into());
}
Ok((train_data, val_data, target_min, target_max))
}
/// Train model with async data loading (optimized for GPU utilization)
///
/// This method uses AsyncDataLoader to prefetch batches while GPU trains,
/// improving GPU utilization from ~78% to ~90-95% and reducing training
/// time by 20-30%.
///
/// # Architecture
///
/// ```text
/// CPU Thread (Background): GPU Thread (Main):
/// ┌─────────────────┐ ┌─────────────────┐
/// │ Load batch N+1 │ ────────> │ Train batch N │
/// │ Concat tensors │ │ Forward pass │
/// │ Transfer to GPU │ │ Backward pass │
/// └─────────────────┘ │ Optimizer step │
/// │ └─────────────────┘
/// ▼ │
/// ┌─────────────────┐ │
/// │ Load batch N+2 │ │
/// │ ... │ <───────────────────┘
/// └─────────────────┘
/// ```
///
/// # Arguments
///
/// * `model` - MAMBA-2 model to train
/// * `train_data` - Training data (already prepared tensors)
/// * `val_data` - Validation data
/// * `epochs` - Number of training epochs
/// * `batch_size` - Batch size for training
///
/// # Returns
///
/// Training history with metrics per epoch
async fn train_with_async_loading(
&self,
model: &mut Mamba2SSM,
train_data: &[(Tensor, Tensor)],
val_data: &[(Tensor, Tensor)],
epochs: usize,
batch_size: usize,
checkpoint_dir: Option<&std::path::Path>,
) -> Result<Vec<crate::mamba::TrainingEpoch>, MLError> {
info!(
"Async data loading enabled (prefetch={}, batch_size={})",
self.prefetch_count, batch_size
);
// Call the new train_async() method with AsyncDataLoader
model
.train_async(
train_data,
val_data,
epochs,
batch_size,
self.prefetch_count,
checkpoint_dir,
)
.await
}
}
/// Write a log entry to the training log file
fn write_training_log_mamba2(
logs_dir: &std::path::Path,
message: &str,
) -> Result<(), std::io::Error> {
let log_file = logs_dir.join("training.log");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(log_file)?;
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S");
writeln!(file, "[{}] {}", timestamp, message)?;
Ok(())
}
/// Write trial results to JSON file
fn write_trial_result_mamba2(
hyperopt_dir: &std::path::Path,
trial_result: &crate::hyperopt::traits::TrialResult<Mamba2Params>,
) -> Result<(), std::io::Error> {
let trials_file = hyperopt_dir.join("trials.json");
// Read existing trials (if any)
let mut all_trials = if trials_file.exists() {
let content = std::fs::read_to_string(&trials_file)?;
serde_json::from_str::<Vec<serde_json::Value>>(&content).unwrap_or_default()
} else {
Vec::new()
};
// Append new trial
let trial_json = serde_json::to_value(trial_result)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
all_trials.push(trial_json);
// Write back to file (pretty printed)
let content = serde_json::to_string_pretty(&all_trials)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(&trials_file, content)?;
Ok(())
}
impl HyperparameterOptimizable for Mamba2Trainer {
type Params = Mamba2Params;
type Metrics = Mamba2Metrics;
#[allow(clippy::unwrap_in_result)]
fn train_with_params(&mut self, mut params: Self::Params) -> Result<Self::Metrics, MLError> {
// START: Add trial timing
let trial_start = std::time::Instant::now();
// Get current trial number and increment for next trial
let current_trial = self.trial_counter;
self.trial_counter += 1;
// Clamp batch_size to configured bounds (for GPU memory constraints)
let original_batch_size = params.batch_size;
let clamped_batch_size = (params.batch_size as f64)
.clamp(self.batch_size_min, self.batch_size_max)
.round() as usize;
if clamped_batch_size != original_batch_size {
warn!(
"Batch size clamped: {} → {} (bounds: [{}, {}])",
original_batch_size, clamped_batch_size, self.batch_size_min, self.batch_size_max
);
params.batch_size = clamped_batch_size;
}
info!("Training MAMBA-2 with 12 hyperparameters:");
info!(" Learning rate: {:.6}", params.learning_rate);
info!(
" Batch size: {} (bounds: [{}, {}])",
params.batch_size, self.batch_size_min, self.batch_size_max
);
info!(" Dropout: {:.3}", params.dropout);
info!(" Weight decay: {:.6}", params.weight_decay);
info!(" P0 - Grad clip: {:.3}", params.grad_clip);
info!(" P0 - Warmup steps: {}", params.warmup_steps);
info!(" P0 - Adam beta1: {:.4}", params.adam_beta1);
info!(" P1 - Adam beta2: {:.4}", params.adam_beta2);
info!(" P1 - Adam epsilon: {:.2e}", params.adam_epsilon);
info!(" P2 - Lookback window: {}", params.lookback_window);
info!(" P2 - Sequence stride: {}", params.sequence_stride);
info!(" P2 - Norm epsilon: {:.2e}", params.norm_eps);
// Log trial start (ensure directory exists first)
std::fs::create_dir_all(self.training_paths.logs_dir()).ok();
write_training_log_mamba2(
&self.training_paths.logs_dir(),
&format!("=== Starting MAMBA-2 Trial ===\nParams: {:#?}", params),
)
.ok();
// Load and prepare data FIRST (required to calculate total_decay_steps)
let (train_data, val_data, target_min, target_max) = self
.load_and_prepare_data(params.lookback_window, params.sequence_stride)
.map_err(|e| MLError::ModelError(format!("Data loading failed: {}", e)))?;
// Calculate total_decay_steps dynamically based on actual data size
let steps_per_epoch = (train_data.len() + params.batch_size - 1) / params.batch_size;
let total_decay_steps = self.epochs * steps_per_epoch;
info!(
"Calculated total_decay_steps: {} (epochs={}, steps_per_epoch={})",
total_decay_steps, self.epochs, steps_per_epoch
);
// Create MAMBA-2 config with trial hyperparameters (ALL 12 params + calculated total_decay_steps)
let mamba_config = Mamba2Config {
d_model: self.d_model,
d_state: 16,
d_head: self.d_model / 8,
num_heads: 8,
expand: 2,
num_layers: 6,
dropout: params.dropout,
use_ssd: true,
use_selective_state: true,
hardware_aware: true,
target_latency_us: 5,
max_seq_len: 120,
learning_rate: params.learning_rate,
weight_decay: params.weight_decay,
grad_clip: params.grad_clip, // P0
warmup_steps: params.warmup_steps, // P0
adam_beta1: params.adam_beta1, // P0
adam_beta2: params.adam_beta2, // P1
adam_epsilon: params.adam_epsilon, // P1
total_decay_steps, // P1 - CALCULATED DYNAMICALLY
batch_size: params.batch_size,
seq_len: params.lookback_window, // P2
shuffle_batches: false,
optimizer_type: OptimizerType::Adam,
sgd_momentum: 0.9,
sequence_stride: params.sequence_stride, // P2
norm_eps: params.norm_eps, // P2
early_stopping_enabled: true, // Enable early stopping for hyperopt
early_stopping_patience: self.early_stopping_patience,
early_stopping_min_delta: 1e-4, // Minimum improvement threshold
early_stopping_min_epochs: self.early_stopping_min_epochs,
};
// Store normalization params for inference
self.target_min = Some(target_min);
self.target_max = Some(target_max);
if train_data.is_empty() || val_data.is_empty() {
warn!("Empty training or validation data");
return Ok(Mamba2Metrics {
val_loss: 1000.0, // Penalty
train_loss: 1000.0,
val_perplexity: f64::INFINITY,
directional_accuracy: 0.0,
mae: 1000.0,
rmse: 1000.0,
r_squared: 0.0,
epochs_completed: 0,
});
}
// Create all training directories
self.training_paths.create_all().map_err(|e| {
MLError::ModelError(format!("Failed to create training directories: {}", e))
})?;
info!("Training directories created:");
info!(" Checkpoints: {:?}", self.training_paths.checkpoints_dir());
// Create and train model
let mut model = Mamba2SSM::new(mamba_config.clone(), &self.device)
.map_err(|e| MLError::ModelError(format!("Failed to create model: {}", e)))?;
// P1 FIX: Wrap training in catch_unwind to handle CUDA OOM panics gracefully
// Run training (async or sync based on configuration)
let training_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if self.async_loading {
info!(
"Using async data loading (prefetch={})",
self.prefetch_count
);
tokio::runtime::Runtime::new()
.map_err(|e| MLError::ModelError(format!("Failed to create async runtime: {}", e)))?
.block_on(self.train_with_async_loading(
&mut model,
&train_data,
&val_data,
self.epochs,
params.batch_size,
Some(&self.training_paths.checkpoints_dir()),
))
} else {
info!("Using synchronous data loading");
tokio::runtime::Runtime::new()
.map_err(|e| MLError::ModelError(format!("Failed to create async runtime: {}", e)))?
.block_on(model.train(
&train_data,
&val_data,
self.epochs,
Some(&self.training_paths.checkpoints_dir()),
))
}
}));
let training_history = match training_result {
Ok(Ok(history)) => history,
Ok(Err(e)) => {
warn!("Training failed (possibly OOM): {}", e);
// Return penalty metrics instead of propagating error
return Ok(Mamba2Metrics {
val_loss: 1000.0,
train_loss: 1000.0,
val_perplexity: f64::INFINITY,
directional_accuracy: 0.0,
mae: 1000.0,
rmse: 1000.0,
r_squared: 0.0,
epochs_completed: 0,
});
},
Err(_) => {
warn!("Training panicked (likely CUDA OOM or GPU error)");
// Return penalty metrics for optimizer
return Ok(Mamba2Metrics {
val_loss: 1000.0,
train_loss: 1000.0,
val_perplexity: f64::INFINITY,
directional_accuracy: 0.0,
mae: 1000.0,
rmse: 1000.0,
r_squared: 0.0,
epochs_completed: 0,
});
},
};
// Extract final metrics
let final_epoch = training_history
.last()
.ok_or_else(|| MLError::TrainingError("No training history".to_owned()))?;
// Map fields from TrainingEpoch (which only has loss/accuracy)
// to Mamba2Metrics (which expects detailed metrics)
let metrics = Mamba2Metrics {
val_loss: final_epoch.loss, // Use loss as val_loss
train_loss: final_epoch.loss, // Same value for train_loss (best available)
val_perplexity: final_epoch.loss.exp(),
directional_accuracy: final_epoch.accuracy, // Use accuracy as directional_accuracy
mae: final_epoch.loss, // Approximation
rmse: final_epoch.loss.sqrt(), // Approximation
r_squared: 1.0 - final_epoch.loss.min(1.0), // Approximation
epochs_completed: training_history.len(),
};
info!("Training completed:");
info!(" Training loss: {:.6}", metrics.train_loss);
info!(" Validation loss: {:.6}", metrics.val_loss);
info!(" Perplexity: {:.4}", metrics.val_perplexity);
info!(
" Directional accuracy: {:.2}%",
metrics.directional_accuracy * 100.0
);
info!(" MAE: {:.4}", metrics.mae);
info!(" RMSE: {:.4}", metrics.rmse);
info!(" R²: {:.4}", metrics.r_squared);
// END: Add trial completion logging
let duration_secs = trial_start.elapsed().as_secs_f64();
write_training_log_mamba2(
&self.training_paths.logs_dir(),
&format!(
"Training completed in {:.2}s: val_loss={:.6}, train_loss={:.6}, accuracy={:.2}%",
duration_secs,
metrics.val_loss,
metrics.train_loss,
metrics.directional_accuracy * 100.0
),
)
.ok();
// CRITICAL FIX: Explicit memory cleanup to prevent OOM between trials
// Drop model, data loaders, and sync CUDA to free GPU/RAM
info!("Cleaning up resources...");
drop(model);
drop(train_data);
drop(val_data);
// Sync CUDA to ensure GPU memory is freed
if self.device.is_cuda() {
use candle_core::Device;
if let Device::Cuda(_) = &self.device {
// Force CUDA synchronization to release GPU memory
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
info!("Resource cleanup complete");
// Write trial result to JSON (ensure directory exists first)
let trial_result = crate::hyperopt::traits::TrialResult {
trial_num: current_trial,
params,
objective: Self::extract_objective(&metrics),
duration_secs,
};
std::fs::create_dir_all(self.training_paths.hyperopt_dir()).ok();
write_trial_result_mamba2(&self.training_paths.hyperopt_dir(), &trial_result).ok();
Ok(metrics)
}
fn extract_objective(metrics: &Self::Metrics) -> f64 {
metrics.val_loss
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mamba2_params_roundtrip() {
let params = Mamba2Params {
learning_rate: 0.001,
batch_size: 64,
dropout: 0.2,
weight_decay: 0.0001,
grad_clip: 2.5,
warmup_steps: 500,
adam_beta1: 0.9,
adam_beta2: 0.999,
adam_epsilon: 1e-8,
lookback_window: 90,
sequence_stride: 3,
norm_eps: 5e-5,
};
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 12, "Expected 12 continuous parameters");
let recovered = Mamba2Params::from_continuous(&continuous).unwrap();
assert!((recovered.learning_rate - params.learning_rate).abs() < 1e-10);
assert_eq!(recovered.batch_size, params.batch_size);
assert!((recovered.dropout - params.dropout).abs() < 1e-10);
assert!((recovered.weight_decay - params.weight_decay).abs() < 1e-10);
assert!((recovered.grad_clip - params.grad_clip).abs() < 1e-6);
assert_eq!(recovered.warmup_steps, params.warmup_steps);
assert!((recovered.adam_beta1 - params.adam_beta1).abs() < 1e-10);
assert!((recovered.adam_beta2 - params.adam_beta2).abs() < 1e-10);
assert!((recovered.adam_epsilon - params.adam_epsilon).abs() < 1e-12);
assert_eq!(recovered.lookback_window, params.lookback_window);
assert_eq!(recovered.sequence_stride, params.sequence_stride);
assert!((recovered.norm_eps - params.norm_eps).abs() < 1e-12);
}
#[test]
fn test_mamba2_params_bounds() {
let bounds = Mamba2Params::continuous_bounds();
assert_eq!(bounds.len(), 12, "Expected 12 parameter bounds");
// Check log-scale bounds are reasonable
assert!(bounds[0].0 < bounds[0].1); // learning_rate
assert!(bounds[3].0 < bounds[3].1); // weight_decay
assert!(bounds[4].0 < bounds[4].1); // grad_clip
assert!(bounds[8].0 < bounds[8].1); // adam_epsilon
assert!(bounds[11].0 < bounds[11].1); // norm_eps
// Check linear bounds
assert_eq!(bounds[1], (4.0, 256.0)); // batch_size (wide bounds for optimizer exploration)
assert_eq!(bounds[2], (0.0, 0.5)); // dropout
assert_eq!(bounds[5], (100.0, 2000.0)); // warmup_steps
assert_eq!(bounds[6], (0.85, 0.95)); // adam_beta1
assert_eq!(bounds[7], (0.98, 0.999)); // adam_beta2
assert_eq!(bounds[9], (30.0, 120.0)); // lookback_window
assert_eq!(bounds[10], (1.0, 5.0)); // sequence_stride
}
#[test]
fn test_param_names() {
let names = Mamba2Params::param_names();
assert_eq!(names.len(), 12, "Expected 12 parameter names");
assert_eq!(names[0], "learning_rate");
assert_eq!(names[1], "batch_size");
assert_eq!(names[2], "dropout");
assert_eq!(names[3], "weight_decay");
assert_eq!(names[4], "grad_clip");
assert_eq!(names[5], "warmup_steps");
assert_eq!(names[6], "adam_beta1");
assert_eq!(names[7], "adam_beta2");
assert_eq!(names[8], "adam_epsilon");
assert_eq!(names[9], "lookback_window");
assert_eq!(names[10], "sequence_stride");
assert_eq!(names[11], "norm_eps");
}
#[test]
fn test_target_normalization() {
// Test normalization/denormalization with realistic ES price ranges
let target_min = 5000.0;
let target_max = 6000.0;
// Test min value
let normalized_min: f64 = (target_min - target_min) / (target_max - target_min);
assert!(
(normalized_min - 0.0).abs() < 1e-10,
"Min should normalize to 0"
);
// Test max value
let normalized_max: f64 = (target_max - target_min) / (target_max - target_min);
assert!(
(normalized_max - 1.0).abs() < 1e-10,
"Max should normalize to 1"
);
// Test mid value
let mid_price = 5500.0;
let normalized_mid: f64 = (mid_price - target_min) / (target_max - target_min);
assert!(
(normalized_mid - 0.5).abs() < 1e-10,
"Midpoint should normalize to 0.5"
);
// Test denormalization recovers original
let denormalized: f64 = normalized_mid * (target_max - target_min) + target_min;
assert!(
(denormalized - mid_price).abs() < 1e-6,
"Denormalization should recover original price"
);
}
#[test]
fn test_denormalize_prediction() {
// Create a trainer with normalization params set
let trainer = Mamba2Trainer {
parquet_file: PathBuf::from("dummy.parquet"),
epochs: 1,
device: Device::Cpu,
feature_config: FeatureConfig::wave_d(),
d_model: 225,
train_split: 0.8,
target_min: Some(5000.0),
target_max: Some(6000.0),
batch_size_min: 4.0,
batch_size_max: 96.0,
async_loading: false,
prefetch_count: 0,
training_paths: TrainingPaths::new("/tmp/test", "mamba2", "test"),
early_stopping_patience: 10,
early_stopping_min_epochs: 5,
trial_counter: 0,
};
// Test denormalization
assert!((trainer.denormalize_prediction(0.0).unwrap_or(0.0) - 5000.0).abs() < 1e-6);
assert!((trainer.denormalize_prediction(1.0).unwrap_or(0.0) - 6000.0).abs() < 1e-6);
assert!((trainer.denormalize_prediction(0.5).unwrap_or(0.0) - 5500.0).abs() < 1e-6);
assert!((trainer.denormalize_prediction(0.25).unwrap_or(0.0) - 5250.0).abs() < 1e-6);
}
#[test]
fn test_denormalize_before_training_returns_none() {
// Create trainer without normalization params
let trainer = Mamba2Trainer {
parquet_file: PathBuf::from("dummy.parquet"),
epochs: 1,
device: Device::Cpu,
feature_config: FeatureConfig::wave_d(),
batch_size_min: 4.0,
batch_size_max: 96.0,
async_loading: false,
prefetch_count: 0,
d_model: 225,
train_split: 0.8,
target_min: None,
target_max: None,
training_paths: TrainingPaths::new("/tmp/test", "mamba2", "test"),
early_stopping_patience: 10,
early_stopping_min_epochs: 5,
trial_counter: 0,
};
// Should return None when normalization params not set
assert!(trainer.denormalize_prediction(0.5).is_none());
}
#[test]
fn test_normalized_targets_in_range() {
// Verify that normalized targets are always in [0,1]
let target_min = 5000.0;
let target_max = 6000.0;
let test_prices = vec![5000.0, 5100.0, 5500.0, 5900.0, 6000.0];
for price in test_prices {
let normalized: f64 = (price - target_min) / (target_max - target_min);
assert!(
normalized >= 0.0,
"Normalized target {} should be >= 0",
normalized
);
assert!(
normalized <= 1.0,
"Normalized target {} should be <= 1",
normalized
);
// Verify round-trip
let denormalized: f64 = normalized * (target_max - target_min) + target_min;
assert!(
(denormalized - price).abs() < 1e-6,
"Round-trip failed: {} -> {} -> {}",
price,
normalized,
denormalized
);
}
}
}