use anyhow::Result;
use burn::optim::AdamConfig;
use burn::optim::GradientsParams;
use burn::optim::Optimizer;
use burn::tensor::{backend::Backend, Tensor};
use polars::prelude::*;
use super::step_1_tensor_preparation;
use super::step_3_cnn_lstm_model_arch::TimeSeriesCnnLstm;
use crate::constants;
use burn_autodiff::Autodiff;
use burn_ndarray::NdArray;
type BurnBackend = Autodiff<NdArray<f32>>;
#[derive(Debug, Clone)]
pub struct TrainingConfig {
pub learning_rate: f64,
pub batch_size: usize,
pub epochs: usize,
pub test_split: f64,
pub patience: usize,
pub min_delta: f64,
pub dropout: f64,
pub use_huber_loss: bool,
pub display_metrics: bool,
pub display_interval: usize,
}
impl Default for TrainingConfig {
fn default() -> Self {
Self {
learning_rate: 0.001,
batch_size: 32,
epochs: 10,
test_split: 0.2,
patience: 3, min_delta: 0.001, dropout: 0.3, use_huber_loss: true, display_metrics: true,
display_interval: 1,
}
}
}
fn tensor_to_f64<B: Backend, const D: usize>(tensor: &Tensor<B, D>) -> Result<f64> {
let tensor_data = tensor.to_data();
let tensor_slice = tensor_data.as_slice::<f32>()
.map_err(|_| anyhow::anyhow!("Failed to convert tensor to scalar"))?;
if tensor_slice.is_empty() {
return Err(anyhow::anyhow!("Empty tensor data"));
}
Ok(tensor_slice[0] as f64)
}
pub fn train_model(
df: DataFrame,
config: TrainingConfig,
device: &<BurnBackend as burn::tensor::backend::Backend>::Device,
ticker: &str,
_model_type: &str,
forecast_horizon: usize,
) -> Result<(TimeSeriesCnnLstm<BurnBackend>, Vec<f64>)> {
println!("Starting CNN-LSTM model training...");
let (features, targets) = step_1_tensor_preparation::dataframe_to_tensors::<BurnBackend>(
&df,
crate::constants::SEQUENCE_LENGTH,
forecast_horizon,
device,
false,
None,
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
println!(
"Data prepared: features shape: {:?}, targets shape: {:?}",
features.dims(),
targets.dims()
);
let num_samples = features.dims()[0];
let test_size = (num_samples as f64 * config.test_split).round() as usize;
let train_size = num_samples - test_size;
let input_size = features.dims()[2];
let output_size = forecast_horizon;
let train_features = features.clone().narrow(0, 0, train_size);
let train_targets = targets.clone().narrow(0, 0, train_size);
let test_features = features.clone().narrow(0, train_size, test_size);
let test_targets = targets.clone().narrow(0, train_size, test_size);
println!(
"Data split: train samples: {}, test samples: {}",
train_size, test_size
);
fn get_batches<B: Backend, const D: usize>(
data: &Tensor<B, D>,
batch_size: usize,
) -> Vec<Tensor<B, D>> {
let num_samples = data.dims()[0];
let mut batches = Vec::new();
let mut start = 0;
while start < num_samples {
let end = usize::min(start + batch_size, num_samples);
let batch = data.clone().narrow(0, start, end - start);
batches.push(batch);
start = end;
}
batches
}
let hidden_size = 32; let num_layers = 1; let bidirectional = false; let dropout = config.dropout;
println!("Creating lightweight CNN-LSTM model: hidden_size={}, layers={}, bidirectional={}",
hidden_size, num_layers, bidirectional);
let mut model = TimeSeriesCnnLstm::<BurnBackend>::new(
input_size,
hidden_size,
output_size,
num_layers,
bidirectional,
dropout,
device,
);
let mut best_model = model.clone();
let mut best_val_rmse = f64::INFINITY;
let mut epochs_no_improve = 0;
let mut optimizer = AdamConfig::new().init();
let mut loss_history = Vec::new();
let _model_name = format!("{}{}", ticker, constants::MODEL_FILE_NAME);
println!("Training CNN-LSTM model with {} layers", num_layers);
for epoch in 1..=config.epochs {
let mut current_lr = config.learning_rate * (1.0 - (epoch as f64) / (config.epochs as f64));
if current_lr < 1e-8 {
current_lr = 1e-8;
}
let feature_batches = get_batches(&train_features, config.batch_size);
let target_batches = get_batches(&train_targets, config.batch_size);
let mut epoch_loss = 0.0;
for (batch_features, batch_targets) in feature_batches.iter().zip(target_batches.iter()) {
let predictions = model.forward(batch_features.clone());
let loss_tensor = if config.use_huber_loss {
model.huber_loss(predictions.clone(), batch_targets.clone(), 1.0)
} else {
model.mse_loss(predictions.clone(), batch_targets.clone())
};
let loss = match tensor_to_f64(&loss_tensor) {
Ok(val) => val,
Err(e) => {
println!("Warning: Failed to extract loss value: {}", e);
0.0 }
};
epoch_loss += loss;
let grads = loss_tensor.backward();
let grads = GradientsParams::from_grads(grads, &model);
model = optimizer.step(current_lr, model, grads);
}
let avg_loss = epoch_loss / feature_batches.len() as f64;
loss_history.push(avg_loss);
if config.display_metrics && epoch % config.display_interval == 0 {
println!("Epoch {}/{} - Loss: {:.6}", epoch, config.epochs, avg_loss);
}
let val_preds = model.forward(test_features.clone());
let val_diff = val_preds - test_targets.clone();
let val_squared_diff = val_diff.clone() * val_diff;
let val_mse_tensor = val_squared_diff.mean().reshape([1]);
let val_mse = match tensor_to_f64(&val_mse_tensor) {
Ok(val) => val,
Err(e) => {
println!("Warning: Failed to extract validation MSE value: {}", e);
f64::MAX }
};
let val_rmse = val_mse.sqrt();
if config.display_metrics && epoch % config.display_interval == 0 {
println!("Validation RMSE: {:.6}", val_rmse);
}
if best_val_rmse - val_rmse > config.min_delta {
best_val_rmse = val_rmse;
best_model = model.clone();
epochs_no_improve = 0;
if config.display_metrics {
println!("New best model (RMSE: {:.6})", val_rmse);
}
} else {
epochs_no_improve += 1;
if epochs_no_improve >= config.patience {
println!(
"Early stopping triggered at epoch {} (best val RMSE = {:.6})",
epoch, best_val_rmse
);
model = best_model.clone();
break;
}
}
}
println!("Training completed. Best validation RMSE: {:.6}", best_val_rmse);
Ok((model, loss_history))
}
pub fn evaluate_model<B: Backend>(
model: &TimeSeriesCnnLstm<B>,
test_df: DataFrame,
device: &B::Device,
forecast_horizon: usize,
) -> Result<f64> {
let (test_features, test_targets) = step_1_tensor_preparation::dataframe_to_tensors::<B>(
&test_df,
crate::constants::SEQUENCE_LENGTH,
forecast_horizon,
device,
false,
None,
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let predictions = model.forward(test_features);
let diff = predictions - test_targets;
let mse_tensor = (diff.clone() * diff).mean();
let mse = tensor_to_f64(&mse_tensor)?;
let rmse = mse.sqrt();
Ok(rmse)
}