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_lstm_model_arch::TimeSeriesLstm;
use crate::constants;
use crate::util::model_utils;
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,
}
}
}
pub fn mse_loss<B: Backend>(predictions: Tensor<B, 2>, _targets: Tensor<B, 2>) -> Tensor<B, 1> {
let batch_size = predictions.dims()[0];
Tensor::<B, 1>::zeros([batch_size], &B::Device::default())
}
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<(TimeSeriesLstm<BurnBackend>, Vec<f64>)> {
println!("Starting 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 = 64; let num_layers = 2; let bidirectional = true; let dropout = config.dropout;
let mut model = TimeSeriesLstm::<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 _current_lr = config.learning_rate;
let mut optimizer = AdamConfig::new().init();
let loss_history = Vec::new();
let model_name = format!("{}{}", ticker, constants::MODEL_FILE_NAME);
for epoch in 1..=config.epochs {
_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 diff = predictions.clone() - batch_targets.clone();
let loss_tensor = (diff.clone() * diff.clone()).mean();
let loss = loss_tensor.clone().into_scalar() as f64;
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;
let val_preds = model.forward(_test_features.clone());
let val_diff = val_preds - _test_targets.clone();
let val_mse_tensor = (val_diff.clone() * val_diff.clone()).mean();
let val_mse_data = val_mse_tensor.to_data().convert::<f32>();
let val_mse_slice = val_mse_data.as_slice::<f32>().unwrap();
let val_mse = val_mse_slice[0] as f64;
let val_rmse = val_mse.sqrt();
if best_val_rmse - val_rmse > config.min_delta {
best_val_rmse = val_rmse;
best_model = model.clone();
epochs_no_improve = 0;
} 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;
}
}
if epoch % 5 == 0 {
let _ = model_utils::save_model_checkpoint(
&model,
ticker,
model_type,
&model_name,
epoch,
input_size,
hidden_size,
output_size,
num_layers,
bidirectional,
dropout,
);
}
}
let _ = model_utils::save_trained_model(
&model,
ticker,
model_type,
&model_name,
input_size,
hidden_size,
output_size,
num_layers,
bidirectional,
dropout,
);
println!("Training completed and model saved.");
Ok((model, loss_history))
}
pub fn evaluate_model<B: Backend>(
model: &TimeSeriesLstm<B>,
test_df: DataFrame,
device: &B::Device,
forecast_horizon: usize,
) -> Result<f64> {
if test_df.is_empty() {
return Ok(0.0);
}
let (features, 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(features);
let diff = predictions - targets;
let mse_tensor = (diff.clone() * diff.clone()).mean();
let mse_data = mse_tensor.to_data().convert::<f32>();
let mse_slice = mse_data.as_slice::<f32>().unwrap();
let mse = mse_slice[0] as f64;
let rmse = mse.sqrt();
Ok(rmse)
}