//! TFT Quantization-Aware Training (QAT) Example //! //! Demonstrates how to use QAT to train a TFT model with INT8 quantization //! for better accuracy compared to post-training quantization (PTQ). //! //! # QAT vs PTQ //! //! - **PTQ (Post-Training Quantization)**: Quantize weights after training //! - Pros: Fast, no retraining required //! - Cons: Can lose 2-5% accuracy on complex models //! //! - **QAT (Quantization-Aware Training)**: Train with simulated quantization //! - Pros: 1-2% better accuracy than PTQ, model learns to compensate //! - Cons: Slower training (adds fake quantization ops) //! //! # Three-Phase QAT Process //! //! 1. **Calibration Phase**: Collect activation statistics (100 batches) //! 2. **Training Phase**: Train with fake quantization (simulates INT8 ops) //! 3. **Conversion Phase**: Convert FP32 model to true INT8 model //! //! # Usage //! //! ```bash //! # Basic QAT training //! cargo run -p ml --example train_tft_qat --release --features cuda //! //! # Custom calibration batches (higher = better accuracy, slower training) //! cargo run -p ml --example train_tft_qat --release --features cuda -- \ //! --parquet-file test_data/ES_FUT_180d.parquet \ //! --epochs 50 \ //! --qat-calibration-batches 200 //! //! # Compare FP32 vs PTQ vs QAT accuracy //! cargo run -p ml --example train_tft_qat --release --features cuda -- \ //! --compare-accuracy //! ``` //! //! # Expected Results //! //! - Training time: 1.2-1.5x slower than FP32 (due to fake quantization) //! - Memory usage: Same as FP32 during training, 3-8x reduction after conversion //! - Accuracy: 1-2% better than PTQ, within 0.5% of FP32 //! - Final model: INT8 quantized (~125MB vs ~1GB FP32) // Suppress warnings for unused dependencies in this example #![allow(unused_crate_dependencies)] use anyhow::{Context, Result}; use clap::Parser; use std::path::PathBuf; use tokio::sync::mpsc; use tracing::info; use tracing_subscriber::FmtSubscriber; use ml::checkpoint::FileSystemStorage; use ml::trainers::tft::{TFTTrainer, TFTTrainerConfig}; #[derive(Debug, Parser)] #[command( name = "train_tft_qat", about = "Train TFT with Quantization-Aware Training (QAT) for better INT8 accuracy" )] struct Opts { /// Parquet file path containing OHLCV bars (Databento schema) #[arg(long, default_value = "test_data/ES_FUT_small.parquet")] parquet_file: String, /// Number of training epochs #[arg(long, default_value = "20")] epochs: usize, /// Learning rate #[arg(long, default_value = "0.001")] learning_rate: f64, /// Batch size (max 32 for 4GB VRAM) #[arg(long, default_value = "32")] batch_size: usize, /// Number of batches for QAT calibration (default: 100) /// Higher values improve accuracy but increase training time /// Recommended range: 50-500 batches #[arg(long, default_value = "100")] qat_calibration_batches: usize, /// Output directory for trained model checkpoints #[arg(long, default_value = "ml/trained_models")] output_dir: String, /// Use GPU for training (CUDA required) #[arg(long)] use_gpu: bool, /// Compare FP32 vs PTQ vs QAT accuracy (trains 3 models) #[arg(long)] compare_accuracy: bool, /// Verbose logging (debug level) #[arg(short, long)] verbose: bool, } #[tokio::main] async fn main() -> Result<()> { // Parse CLI options let opts = Opts::parse(); // Setup logging let level = if opts.verbose { tracing::Level::DEBUG } else { tracing::Level::INFO }; let subscriber = FmtSubscriber::builder().with_max_level(level).finish(); tracing::subscriber::set_global_default(subscriber) .context("Failed to set tracing subscriber")?; info!("πŸš€ TFT Quantization-Aware Training (QAT) Example"); info!(""); info!("This example demonstrates the three-phase QAT process:"); info!( " 1. Calibration: Collect activation statistics ({} batches)", opts.qat_calibration_batches ); info!(" 2. Training: Train with fake quantization (simulates INT8)"); info!(" 3. Conversion: Convert FP32 model to true INT8 model"); info!(""); if opts.compare_accuracy { // Train 3 models and compare accuracy info!("πŸ“Š Running accuracy comparison: FP32 vs PTQ vs QAT"); info!(""); run_accuracy_comparison(&opts).await?; } else { // Train single QAT model info!("🧠 Training QAT model..."); info!(""); run_qat_training(&opts).await?; } Ok(()) } /// Train a single QAT model and show detailed phase logging async fn run_qat_training(opts: &Opts) -> Result<()> { info!("Configuration:"); info!(" β€’ Parquet file: {}", opts.parquet_file); info!(" β€’ Epochs: {}", opts.epochs); info!(" β€’ Learning rate: {}", opts.learning_rate); info!(" β€’ Batch size: {}", opts.batch_size); info!( " β€’ QAT calibration batches: {}", opts.qat_calibration_batches ); info!(" β€’ GPU enabled: {}", opts.use_gpu); info!(" β€’ Output directory: {}", opts.output_dir); info!(""); // Verify Parquet file exists let parquet_path = PathBuf::from(&opts.parquet_file); if !parquet_path.exists() { return Err(anyhow::anyhow!( "Parquet file not found: {}", opts.parquet_file )); } // Create output directory let output_path = PathBuf::from(&opts.output_dir); if !output_path.exists() { std::fs::create_dir_all(&output_path).context("Failed to create output directory")?; info!("βœ… Created output directory: {}", opts.output_dir); } // Configure TFT trainer with QAT enabled let trainer_config = TFTTrainerConfig { epochs: opts.epochs, learning_rate: opts.learning_rate, batch_size: opts.batch_size, validation_batch_size: opts.batch_size, hidden_dim: 256, num_attention_heads: 8, dropout_rate: 0.1, lstm_layers: 2, quantiles: vec![0.1, 0.5, 0.9], lookback_window: 60, forecast_horizon: 10, use_gpu: opts.use_gpu, use_int8_quantization: true, // Enable INT8 quantization use_qat: true, // Enable QAT (the key difference!) qat_calibration_batches: opts.qat_calibration_batches, checkpoint_dir: opts.output_dir.clone(), }; // Create checkpoint storage let storage = std::sync::Arc::new(FileSystemStorage::new(output_path.clone())); // Create TFT trainer let mut trainer = TFTTrainer::new(trainer_config.clone(), storage).context("Failed to create TFT trainer")?; info!("βœ… TFT trainer initialized with QAT enabled"); info!(""); info!("πŸ“‹ QAT Training Process:"); info!(""); info!( "Phase 1: Calibration ({} batches)", opts.qat_calibration_batches ); info!(" β€’ Insert fake quantization nodes in model graph"); info!(" β€’ Run forward passes to collect activation statistics"); info!(" β€’ Compute optimal scale/zero-point for each layer"); info!(" β€’ No gradient updates (calibration only)"); info!(""); info!("Phase 2: Training with Fake Quantization"); info!(" β€’ Forward pass: Simulate INT8 operations (FP32β†’INT8β†’FP32)"); info!(" β€’ Backward pass: Standard FP32 gradients"); info!(" β€’ Model learns to compensate for quantization errors"); info!(" β€’ Training time: ~1.2-1.5x slower than FP32"); info!(""); info!("Phase 3: Conversion to True INT8"); info!(" β€’ Extract FP32 weights from trained model"); info!(" β€’ Quantize weights using calibrated scales"); info!(" β€’ Create INT8 model (3-8x memory reduction)"); info!(" β€’ Expect 1-2% better accuracy than PTQ"); info!(""); // Setup progress callback let (progress_tx, mut progress_rx) = mpsc::unbounded_channel(); trainer.set_progress_callback(progress_tx); // Spawn progress monitor task let monitor_task = tokio::spawn(async move { while let Some(progress) = progress_rx.recv().await { info!("{}", progress.message); if let Some(loss) = progress.metrics.get("train_loss") { info!(" β€’ Train loss: {:.6}", loss); } if let Some(val_loss) = progress.metrics.get("val_loss") { info!(" β€’ Val loss: {:.6}", val_loss); } if let Some(quantile_loss) = progress.metrics.get("quantile_loss") { info!(" β€’ Quantile loss: {:.6}", quantile_loss); } if let Some(rmse) = progress.metrics.get("rmse") { info!(" β€’ RMSE: {:.6}", rmse); } } }); // Train the model with QAT info!("πŸ‹οΈ Starting QAT training..."); info!(""); let start_time = std::time::Instant::now(); let final_metrics = trainer .train_from_parquet(&opts.parquet_file) .await .context("QAT training failed")?; let training_duration = start_time.elapsed(); // Wait for progress monitor to finish drop(trainer); let _ = monitor_task.await; // Print final metrics info!(""); info!("βœ… QAT Training completed successfully!"); info!(""); info!("πŸ“Š Final Metrics:"); info!(" β€’ Training loss: {:.6}", final_metrics.train_loss); info!(" β€’ Validation loss: {:.6}", final_metrics.val_loss); info!(" β€’ Quantile loss: {:.6}", final_metrics.quantile_loss); info!(" β€’ RMSE: {:.6}", final_metrics.rmse); info!( " β€’ Attention entropy: {:.4}", final_metrics.attention_entropy ); info!( " β€’ Training duration: {:.1}s ({:.1} min)", training_duration.as_secs_f64(), training_duration.as_secs_f64() / 60.0 ); info!(""); info!("πŸ’Ύ Quantized model saved to: {}", opts.output_dir); info!(" Memory footprint: ~125MB (vs ~1GB FP32)"); info!(" Expected accuracy: Within 0.5% of FP32 model"); info!(""); info!("πŸŽ‰ QAT training complete!"); Ok(()) } /// Train 3 models (FP32, PTQ, QAT) and compare their accuracy async fn run_accuracy_comparison(opts: &Opts) -> Result<()> { info!("Training 3 models for accuracy comparison:"); info!(" 1. FP32 Baseline (no quantization)"); info!(" 2. PTQ (Post-Training Quantization)"); info!(" 3. QAT (Quantization-Aware Training)"); info!(""); // Verify Parquet file exists let parquet_path = PathBuf::from(&opts.parquet_file); if !parquet_path.exists() { return Err(anyhow::anyhow!( "Parquet file not found: {}", opts.parquet_file )); } let output_path = PathBuf::from(&opts.output_dir); if !output_path.exists() { std::fs::create_dir_all(&output_path)?; } // 1. Train FP32 baseline info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!("1️⃣ Training FP32 Baseline Model"); info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!(""); let fp32_config = TFTTrainerConfig { epochs: opts.epochs, learning_rate: opts.learning_rate, batch_size: opts.batch_size, validation_batch_size: opts.batch_size, hidden_dim: 256, num_attention_heads: 8, dropout_rate: 0.1, lstm_layers: 2, quantiles: vec![0.1, 0.5, 0.9], lookback_window: 60, forecast_horizon: 10, use_gpu: opts.use_gpu, use_int8_quantization: false, // FP32 only use_qat: false, qat_calibration_batches: 0, checkpoint_dir: format!("{}/fp32", opts.output_dir), }; let storage = std::sync::Arc::new(FileSystemStorage::new(PathBuf::from( fp32_config.checkpoint_dir.clone(), ))); let mut fp32_trainer = TFTTrainer::new(fp32_config.clone(), storage)?; let fp32_start = std::time::Instant::now(); let fp32_metrics = fp32_trainer.train_from_parquet(&opts.parquet_file).await?; let fp32_duration = fp32_start.elapsed(); info!(""); info!("βœ… FP32 training complete!"); info!(" Val loss: {:.6}", fp32_metrics.val_loss); info!(" RMSE: {:.6}", fp32_metrics.rmse); info!(" Time: {:.1}s", fp32_duration.as_secs_f64()); info!(""); // 2. Train PTQ model info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!("2️⃣ Training PTQ Model (Post-Training Quantization)"); info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!(""); let ptq_config = TFTTrainerConfig { use_int8_quantization: true, // PTQ enabled use_qat: false, // No QAT qat_calibration_batches: 0, checkpoint_dir: format!("{}/ptq", opts.output_dir), ..fp32_config.clone() }; let storage = std::sync::Arc::new(FileSystemStorage::new(PathBuf::from( ptq_config.checkpoint_dir.clone(), ))); let mut ptq_trainer = TFTTrainer::new(ptq_config.clone(), storage)?; let ptq_start = std::time::Instant::now(); let ptq_metrics = ptq_trainer.train_from_parquet(&opts.parquet_file).await?; let ptq_duration = ptq_start.elapsed(); info!(""); info!("βœ… PTQ training complete!"); info!(" Val loss: {:.6}", ptq_metrics.val_loss); info!(" RMSE: {:.6}", ptq_metrics.rmse); info!(" Time: {:.1}s", ptq_duration.as_secs_f64()); info!(""); // 3. Train QAT model info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!("3️⃣ Training QAT Model (Quantization-Aware Training)"); info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!(""); let qat_config = TFTTrainerConfig { use_int8_quantization: true, // INT8 enabled use_qat: true, // QAT enabled (the key difference!) qat_calibration_batches: opts.qat_calibration_batches, checkpoint_dir: format!("{}/qat", opts.output_dir), ..fp32_config.clone() }; let storage = std::sync::Arc::new(FileSystemStorage::new(PathBuf::from( qat_config.checkpoint_dir.clone(), ))); let mut qat_trainer = TFTTrainer::new(qat_config.clone(), storage)?; let qat_start = std::time::Instant::now(); let qat_metrics = qat_trainer.train_from_parquet(&opts.parquet_file).await?; let qat_duration = qat_start.elapsed(); info!(""); info!("βœ… QAT training complete!"); info!(" Val loss: {:.6}", qat_metrics.val_loss); info!(" RMSE: {:.6}", qat_metrics.rmse); info!(" Time: {:.1}s", qat_duration.as_secs_f64()); info!(""); // Print comparison table info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!("πŸ“Š Accuracy Comparison Results"); info!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); info!(""); info!("β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”"); info!("β”‚ Model β”‚ Val Loss β”‚ RMSE β”‚ Time β”‚ Memory β”‚"); info!("β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€"); info!( "β”‚ FP32 β”‚ {:.6} β”‚ {:.6} β”‚ {:>6.1}s β”‚ ~1000MB β”‚", fp32_metrics.val_loss, fp32_metrics.rmse, fp32_duration.as_secs_f64() ); info!( "β”‚ PTQ β”‚ {:.6} β”‚ {:.6} β”‚ {:>6.1}s β”‚ ~125MB β”‚", ptq_metrics.val_loss, ptq_metrics.rmse, ptq_duration.as_secs_f64() ); info!( "β”‚ QAT β”‚ {:.6} β”‚ {:.6} β”‚ {:>6.1}s β”‚ ~125MB β”‚", qat_metrics.val_loss, qat_metrics.rmse, qat_duration.as_secs_f64() ); info!("β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜"); info!(""); // Calculate improvements let ptq_loss_delta = ((ptq_metrics.val_loss - fp32_metrics.val_loss) / fp32_metrics.val_loss) * 100.0; let qat_loss_delta = ((qat_metrics.val_loss - fp32_metrics.val_loss) / fp32_metrics.val_loss) * 100.0; let qat_vs_ptq_improvement = ((ptq_metrics.val_loss - qat_metrics.val_loss) / ptq_metrics.val_loss) * 100.0; info!("πŸ“ˆ Analysis:"); info!(""); info!(" PTQ vs FP32:"); info!(" β€’ Loss degradation: {:.2}%", ptq_loss_delta); info!(" β€’ Memory reduction: 8x (1000MB β†’ 125MB)"); info!(" β€’ Training time: Same as FP32"); info!(""); info!(" QAT vs FP32:"); info!(" β€’ Loss degradation: {:.2}%", qat_loss_delta); info!(" β€’ Memory reduction: 8x (1000MB β†’ 125MB)"); info!( " β€’ Training time: {:.1}x slower", qat_duration.as_secs_f64() / fp32_duration.as_secs_f64() ); info!(""); info!(" QAT vs PTQ:"); info!(" β€’ Accuracy improvement: {:.2}%", qat_vs_ptq_improvement); info!(" β€’ Same memory footprint (~125MB)"); info!(" β€’ Training overhead: Worth it for production models!"); info!(""); info!("πŸ’‘ Recommendation:"); if qat_vs_ptq_improvement > 1.0 { info!( " βœ… Use QAT for production - {:.1}% better accuracy is worth the training time", qat_vs_ptq_improvement ); } else { info!( " ⚠️ PTQ may be sufficient - QAT improvement is only {:.1}%", qat_vs_ptq_improvement ); } info!(""); info!("πŸŽ‰ Comparison complete!"); Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn test_cli_parsing() { let args = vec!["train_tft_qat"]; let opts = Opts::try_parse_from(args).expect("Failed to parse default args"); assert_eq!(opts.parquet_file, "test_data/ES_FUT_small.parquet"); assert_eq!(opts.epochs, 20); assert_eq!(opts.qat_calibration_batches, 100); assert!(!opts.compare_accuracy); assert!(!opts.use_gpu); } #[test] fn test_cli_with_qat_options() { let args = vec![ "train_tft_qat", "--qat-calibration-batches", "200", "--compare-accuracy", "--use-gpu", ]; let opts = Opts::try_parse_from(args).expect("Failed to parse QAT args"); assert_eq!(opts.qat_calibration_batches, 200); assert!(opts.compare_accuracy); assert!(opts.use_gpu); } }