Files
foxhunt/ml/src/tft/mod.rs
jgrusewski bcf9ecd07c feat(ml): complete all algorithm gaps — TFT, Mamba2, PPO, CPCV, FDR
TFT training completeness:
- Fix mod.rs::train() stub backward → real AdamW optimizer + gradient flow
- Fix TFTTrainer optimizer init, backward pass, LR scheduling, checkpointing
- Fix temporal_attention weight tracking (RwLock, stores per-head means)

Mamba2 discretization:
- Replace raw continuous-time state transition with proper discretization
- SSD layer now uses softplus(delta) step size with 2nd-order Taylor
  approximation of matrix exponential: A_bar ≈ I + A*dt + (A*dt)²/2
- Correct dtype handling (F64 SSM matrices, F32 output)

PPO entropy fix:
- Fix LSTM training path: was using constant entropy (coeff * 0.5),
  now computes real entropy from log-probabilities

Circuit breaker consolidation:
- Move canonical implementation to ml/src/common/circuit_breaker.rs
- DQN and PPO circuit_breaker.rs now re-export from common

Validation stack additions:
- Add CPCV (Combinatorial Purged Cross-Validation) with purging/embargo
- Add FDR correction (Benjamini-Hochberg + Benjamini-Yekutieli)

1922 lib tests pass, 0 failures.

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

1510 lines
52 KiB
Rust

//! # Temporal Fusion Transformer (TFT) for HFT
//!
//! State-of-the-art multi-horizon forecasting with variable selection networks,
//! temporal self-attention, gated residual networks, and uncertainty quantification.
//!
//! ## Key Features
//!
//! - Multi-horizon forecasting (1-tick to 100-tick ahead)
//! - Variable selection networks for feature importance
//! - Gated residual networks for improved gradient flow
//! - Quantile outputs for uncertainty estimation
//! - Temporal self-attention for sequential modeling
//! - Sub-50μs inference latency optimized for HFT
//!
//! ## Performance Targets
//!
//! - Inference: <50μs per prediction
//! - Accuracy improvement: +15% over baseline
//! - Memory usage: <1GB
//! - Throughput: >100K predictions/sec
use std::collections::HashMap;
use std::num::NonZeroUsize;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Instant, SystemTime};
use async_trait::async_trait;
use candle_core::{DType, Device, Module, Tensor};
use candle_nn::{linear, AdamW, Linear, Optimizer, ParamsAdamW, VarBuilder, VarMap};
use lru::LruCache;
use ndarray::{Array1, Array2};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tracing::{debug, info, instrument, warn};
use uuid::Uuid;
use crate::checkpoint::Checkpointable;
use crate::{MLError, ModelType};
// Import TFT components
pub mod gated_residual;
pub mod hft_optimizations;
pub mod lstm_encoder;
pub mod qat_tft; // Quantization-Aware Training wrapper - RE-ENABLED: Device mismatch fix applied
pub mod quantile_outputs;
pub mod quantized_attention; // Re-enabled Wave 9.12
pub mod quantized_grn;
pub mod quantized_lstm;
pub mod quantized_tft; // Re-enabled Wave 9.12
pub mod quantized_vsn;
pub mod temporal_attention;
pub mod trainable_adapter;
pub mod training;
pub mod variable_selection;
pub mod varmap_quantization;
// Public exports for TFT components
pub use gated_residual::{GRNStack, GatedResidualNetwork};
pub use lstm_encoder::LSTMEncoder;
pub use qat_tft::QATTemporalFusionTransformer; // Quantization-Aware Training wrapper - RE-ENABLED: Device mismatch fix applied
pub use quantile_outputs::QuantileLayer;
pub use quantized_attention::QuantizedTemporalAttention; // Re-enabled Wave 9.12
pub use quantized_grn::QuantizedGatedResidualNetwork;
pub use quantized_lstm::QuantizedLSTMEncoder;
pub use quantized_tft::QuantizedTemporalFusionTransformer; // Re-enabled Wave 9.12
pub use quantized_vsn::QuantizedVariableSelectionNetwork;
pub use temporal_attention::TemporalSelfAttention;
pub use trainable_adapter::TrainableTFT;
pub use variable_selection::VariableSelectionNetwork;
pub use varmap_quantization::{
load_quantized_weights, quantize_varmap, quantize_varmap_parallel, save_quantized_weights,
};
/// `TFT` Configuration
/// TFT model variant selection (F32 vs INT8)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TFTVariant {
/// Full precision (F32) model
F32,
/// INT8 quantized model (75% memory reduction)
INT8,
}
impl Default for TFTVariant {
fn default() -> Self {
Self::F32
}
}
impl TFTVariant {
/// Check if variant uses quantization
pub fn is_quantized(&self) -> bool {
matches!(self, Self::INT8)
}
/// Get expected memory reduction ratio vs F32
pub fn memory_reduction_ratio(&self) -> f64 {
match self {
Self::F32 => 1.0,
Self::INT8 => 0.25, // 75% reduction → 25% of original
}
}
}
/// `TFT` Configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TFTConfig {
// Model architecture
pub input_dim: usize,
pub hidden_dim: usize,
pub num_heads: usize,
pub num_layers: usize,
// Forecasting parameters
pub prediction_horizon: usize,
pub sequence_length: usize,
pub num_quantiles: usize,
// Feature types
pub num_static_features: usize,
pub num_known_features: usize,
pub num_unknown_features: usize,
// Training parameters
pub learning_rate: f64,
pub batch_size: usize,
pub dropout_rate: f64,
pub l2_regularization: f64,
// HFT optimization
pub use_flash_attention: bool,
pub mixed_precision: bool,
pub memory_efficient: bool,
// Performance constraints
pub max_inference_latency_us: u64,
pub target_throughput_pps: u64,
}
impl Default for TFTConfig {
fn default() -> Self {
Self {
// Wave C+D: 225 features (201 Wave C + 24 Wave D)
// Wave C: 201 features (indices 0-200)
// Wave D: 24 features (indices 201-224)
input_dim: 225,
hidden_dim: 128,
num_heads: 8,
num_layers: 3,
prediction_horizon: 10,
sequence_length: 50,
num_quantiles: 9,
// Feature split for 225 total features:
// - Static: 5 features (symbol metadata)
// - Known: 10 features (future time features)
// - Unknown: 210 features (historical OHLCV + technical + microstructure + regime)
num_static_features: 5,
num_known_features: 10,
num_unknown_features: 210,
learning_rate: 1e-3,
batch_size: 64,
dropout_rate: 0.1,
l2_regularization: 1e-4,
use_flash_attention: true,
mixed_precision: true,
memory_efficient: true,
max_inference_latency_us: 50,
target_throughput_pps: 100_000,
}
}
}
/// `TFT` Model State for incremental processing
///
/// **MEMORY SAFETY FIX (2025-10-25)**:
/// - Replaced unbounded HashMap with LRU cache (max 1000 entries)
/// - Prevents 3.6GB/hour memory leak in production inference
/// - Automatically evicts oldest cache entries when full
/// - Tested: 1-hour inference run with 10K predictions = stable 24MB memory
#[derive(Debug, Clone)]
pub struct TFTState {
pub hidden_state: Option<Tensor>,
pub attention_cache: LruCache<String, Tensor>,
pub last_update: u64,
}
impl TFTState {
/// Maximum attention cache entries (2000 = ~48MB for TFT-225, 60% training speedup)
/// Chosen to balance:
/// - Memory safety: <100MB cache overhead (acceptable for training)
/// - Hit rate: >95% for typical 50-sequence inference
/// - Eviction overhead: <0.5% latency impact (reduced by 2x cache size)
pub const MAX_CACHE_ENTRIES: usize = 2000;
pub fn zeros(_config: &TFTConfig) -> Result<Self, MLError> {
// SAFETY: MAX_CACHE_ENTRIES (2000) is non-zero by construction
let capacity =
NonZeroUsize::new(Self::MAX_CACHE_ENTRIES).expect("MAX_CACHE_ENTRIES must be non-zero");
Ok(Self {
hidden_state: None,
attention_cache: LruCache::new(capacity),
last_update: 0,
})
}
/// Clear attention cache to free memory
/// Call this after training/inference batch to prevent memory accumulation
pub fn clear_cache(&mut self) {
self.attention_cache.clear();
self.hidden_state = None;
}
}
/// `TFT` Model Metadata
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TFTMetadata {
pub model_id: String,
pub version: String,
pub input_dim: usize,
pub output_dim: usize,
pub created_at: SystemTime,
pub last_trained: Option<SystemTime>,
pub training_samples: u64,
pub performance_metrics: HashMap<String, f64>,
}
/// Multi-horizon prediction result
#[derive(Debug, Clone)]
pub struct MultiHorizonPrediction {
pub predictions: Vec<f64>, // Point predictions for each horizon
pub quantiles: Vec<Vec<f64>>, // Quantile predictions [horizon][quantile]
pub uncertainty: Vec<f64>, // Uncertainty estimates
pub confidence_intervals: Vec<(f64, f64)>, // 90% confidence intervals
pub attention_weights: HashMap<String, Vec<f64>>, // Attention interpretability
pub feature_importance: Vec<f64>, // Variable importance scores
pub latency_us: u64, // Inference latency
}
/// Complete Temporal Fusion Transformer
pub struct TemporalFusionTransformer {
pub config: TFTConfig,
pub metadata: TFTMetadata,
pub is_trained: bool,
// Core TFT components
static_variable_selection: VariableSelectionNetwork,
historical_variable_selection: VariableSelectionNetwork,
future_variable_selection: VariableSelectionNetwork,
// Encoding layers
static_encoder: GRNStack,
historical_encoder: GRNStack,
future_encoder: GRNStack,
// Temporal processing
lstm_encoder: Linear, // Simplified LSTM representation
lstm_decoder: Linear,
// Attention mechanism
temporal_attention: TemporalSelfAttention,
// Output layers
quantile_outputs: QuantileLayer,
// Performance tracking
inference_count: AtomicU64,
total_latency_us: AtomicU64,
max_latency_us: AtomicU64,
device: Device,
// Variable map for checkpointing
varmap: Arc<VarMap>,
}
impl std::fmt::Debug for TemporalFusionTransformer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TemporalFusionTransformer")
.field("config", &self.config)
.field("metadata", &self.metadata)
.field("is_trained", &self.is_trained)
.field(
"inference_count",
&self.inference_count.load(Ordering::Relaxed),
)
.field(
"total_latency_us",
&self.total_latency_us.load(Ordering::Relaxed),
)
.field(
"max_latency_us",
&self.max_latency_us.load(Ordering::Relaxed),
)
.field("device", &format!("{:?}", self.device))
.field("varmap", &"Arc<VarMap>")
.finish_non_exhaustive()
}
}
impl TemporalFusionTransformer {
pub fn new(config: TFTConfig) -> Result<Self, MLError> {
Self::new_with_device(config, Device::cuda_if_available(0).unwrap_or(Device::Cpu))
}
pub fn new_with_device(config: TFTConfig, device: Device) -> Result<Self, MLError> {
// Validate configuration
let total_features =
config.num_static_features + config.num_known_features + config.num_unknown_features;
if total_features != config.input_dim {
return Err(MLError::ConfigError {
reason: format!(
"Feature count mismatch: static({}) + known({}) + unknown({}) = {} != input_dim({})",
config.num_static_features,
config.num_known_features,
config.num_unknown_features,
total_features,
config.input_dim
)
});
}
// Log configuration for debugging
debug!(
"Creating TFT with {} input features (static: {}, known: {}, unknown: {})",
config.input_dim,
config.num_static_features,
config.num_known_features,
config.num_unknown_features
);
let varmap = Arc::new(VarMap::new());
let vs = VarBuilder::from_varmap(&varmap, DType::F32, &device);
// Create variable selection networks
let static_variable_selection = VariableSelectionNetwork::new(
config.num_static_features,
config.hidden_dim,
vs.pp("static_vsn"),
)?;
let historical_variable_selection = VariableSelectionNetwork::new(
config.num_unknown_features,
config.hidden_dim,
vs.pp("historical_vsn"),
)?;
let future_variable_selection = VariableSelectionNetwork::new(
config.num_known_features,
config.hidden_dim,
vs.pp("future_vsn"),
)?;
// Create encoding stacks
let static_encoder = GRNStack::new(
config.hidden_dim,
config.hidden_dim,
config.hidden_dim,
config.num_layers,
vs.pp("static_encoder"),
)?;
let historical_encoder = GRNStack::new(
config.hidden_dim,
config.hidden_dim,
config.hidden_dim,
config.num_layers,
vs.pp("historical_encoder"),
)?;
let future_encoder = GRNStack::new(
config.hidden_dim,
config.hidden_dim,
config.hidden_dim,
config.num_layers,
vs.pp("future_encoder"),
)?;
// Simplified LSTM layers (in practice, would use proper LSTM)
let lstm_encoder = linear(config.hidden_dim, config.hidden_dim, vs.pp("lstm_encoder"))?;
let lstm_decoder = linear(config.hidden_dim, config.hidden_dim, vs.pp("lstm_decoder"))?;
// Temporal attention
let temporal_attention = TemporalSelfAttention::new(
config.hidden_dim,
config.num_heads,
config.dropout_rate,
config.use_flash_attention,
vs.pp("temporal_attention"),
)?;
// Quantile output layer
let quantile_outputs = QuantileLayer::new(
config.hidden_dim,
config.prediction_horizon,
config.num_quantiles,
vs.pp("quantile_outputs"),
)?;
// Metadata
let metadata = TFTMetadata {
model_id: Uuid::new_v4().to_string(),
version: "1.0.0".to_string(),
input_dim: config.input_dim,
output_dim: config.prediction_horizon,
created_at: SystemTime::now(),
last_trained: None,
training_samples: 0,
performance_metrics: HashMap::new(),
};
Ok(Self {
config,
metadata,
is_trained: false,
static_variable_selection,
historical_variable_selection,
future_variable_selection,
static_encoder,
historical_encoder,
future_encoder,
lstm_encoder,
lstm_decoder,
temporal_attention,
quantile_outputs,
inference_count: AtomicU64::new(0),
total_latency_us: AtomicU64::new(0),
max_latency_us: AtomicU64::new(0),
device,
varmap,
})
}
/// Get reference to the model's VarMap for checkpointing and quantization
pub fn varmap(&self) -> &Arc<VarMap> {
&self.varmap
}
/// Get reference to the model's Device
pub fn device(&self) -> &Device {
&self.device
}
/// Validate input tensor dimensions match configuration
fn validate_input_dimensions(
&self,
static_features: &Tensor,
historical_features: &Tensor,
future_features: &Tensor,
) -> Result<(), MLError> {
// Validate static features: [batch, num_static_features]
let static_dims = static_features.dims();
if static_dims.len() != 2 {
return Err(MLError::ModelError(format!(
"Static features must be 2D [batch, features], got {} dimensions",
static_dims.len()
)));
}
if static_dims[1] != self.config.num_static_features {
return Err(MLError::ModelError(format!(
"Static features dimension mismatch: expected {}, got {}",
self.config.num_static_features, static_dims[1]
)));
}
// Validate historical features: [batch, seq_len, num_unknown_features]
let hist_dims = historical_features.dims();
if hist_dims.len() != 3 {
return Err(MLError::ModelError(format!(
"Historical features must be 3D [batch, seq, features], got {} dimensions",
hist_dims.len()
)));
}
if hist_dims[2] != self.config.num_unknown_features {
return Err(MLError::ModelError(format!(
"Historical features dimension mismatch: expected {}, got {} (Wave C+D requires 210 features)",
self.config.num_unknown_features,
hist_dims[2]
)));
}
// Validate future features: [batch, horizon, num_known_features]
let fut_dims = future_features.dims();
if fut_dims.len() != 3 {
return Err(MLError::ModelError(format!(
"Future features must be 3D [batch, horizon, features], got {} dimensions",
fut_dims.len()
)));
}
if fut_dims[2] != self.config.num_known_features {
return Err(MLError::ModelError(format!(
"Future features dimension mismatch: expected {}, got {}",
self.config.num_known_features, fut_dims[2]
)));
}
// Verify total feature count matches 225 (Wave C+D)
let total_features = self.config.num_static_features
+ self.config.num_unknown_features
+ self.config.num_known_features;
if total_features != 225 {
warn!(
"TFT configured with {} features, expected 225 for Wave C+D compatibility",
total_features
);
}
Ok(())
}
/// Forward pass through the complete `TFT` architecture
#[instrument(skip(self, static_features, historical_features, future_features))]
pub fn forward(
&mut self,
static_features: &Tensor,
historical_features: &Tensor,
future_features: &Tensor,
) -> Result<Tensor, MLError> {
self.forward_with_checkpointing(
static_features,
historical_features,
future_features,
false,
)
}
/// Forward pass with optional gradient checkpointing
///
/// When gradient checkpointing is enabled:
/// - Memory usage reduced by 30-40% (doesn't store intermediate activations)
/// - Training time increases by ~20% (recomputes activations during backprop)
///
/// # Arguments
/// * `static_features` - Static input features [batch, num_static_features]
/// * `historical_features` - Historical features [batch, seq_len, num_unknown_features]
/// * `future_features` - Future features [batch, horizon, num_known_features]
/// * `use_checkpointing` - Whether to use gradient checkpointing
#[instrument(skip(self, static_features, historical_features, future_features))]
pub fn forward_with_checkpointing(
&mut self,
static_features: &Tensor,
historical_features: &Tensor,
future_features: &Tensor,
use_checkpointing: bool,
) -> Result<Tensor, MLError> {
let start_time = Instant::now();
// Validate input dimensions
self.validate_input_dimensions(static_features, historical_features, future_features)?;
// Log device placement for debugging
debug!("Forward pass device check:");
debug!(" static_features: {:?}", static_features.device());
debug!(" historical_features: {:?}", historical_features.device());
debug!(" future_features: {:?}", future_features.device());
debug!(" model device: {:?}", self.device);
// 1. Variable Selection Networks
// CRITICAL: Add .to_device() to ensure GPU execution
let static_selected = self
.static_variable_selection
.forward(static_features, None)?;
let historical_selected = self
.historical_variable_selection
.forward(historical_features, None)?;
let future_selected = self
.future_variable_selection
.forward(future_features, None)?;
debug!(" static_selected: {:?}", static_selected.device());
debug!(" historical_selected: {:?}", historical_selected.device());
debug!(" future_selected: {:?}", future_selected.device());
// 2. Feature Encoding (checkpoint expensive layers)
// CRITICAL: Add .to_device() to ensure GPU execution
let static_encoded = if use_checkpointing {
// Detach intermediate tensors to free memory during forward pass
// They will be recomputed during backward pass
self.static_encoder
.forward(&static_selected.detach(), None)?
} else {
self.static_encoder.forward(&static_selected, None)?
};
let historical_encoded = if use_checkpointing {
self.historical_encoder
.forward(&historical_selected.detach(), None)?
} else {
self.historical_encoder
.forward(&historical_selected, None)?
};
let future_encoded = if use_checkpointing {
self.future_encoder
.forward(&future_selected.detach(), None)?
} else {
self.future_encoder.forward(&future_selected, None)?
};
debug!(" static_encoded: {:?}", static_encoded.device());
debug!(" historical_encoded: {:?}", historical_encoded.device());
debug!(" future_encoded: {:?}", future_encoded.device());
// 3. Temporal Processing (checkpoint LSTM layers - most memory intensive)
// CRITICAL: Add .to_device() to ensure GPU execution
let historical_temporal = if use_checkpointing {
self.lstm_encoder.forward(&historical_encoded.detach())?
} else {
self.lstm_encoder.forward(&historical_encoded)?
};
let future_temporal = if use_checkpointing {
self.lstm_decoder.forward(&future_encoded.detach())?
} else {
self.lstm_decoder.forward(&future_encoded)?
};
debug!(" historical_temporal: {:?}", historical_temporal.device());
debug!(" future_temporal: {:?}", future_temporal.device());
// 4. Combine temporal representations
let combined_temporal =
self.combine_temporal_features(&historical_temporal, &future_temporal)?;
debug!(" combined_temporal: {:?}", combined_temporal.device());
// 5. Self-Attention (checkpoint attention - memory intensive)
// CRITICAL: Add .to_device() to ensure GPU execution
// Use specialized attention checkpointing for maximum memory savings
let attended = self.temporal_attention.forward_with_checkpointing(
&combined_temporal,
true,
use_checkpointing,
)?;
debug!(" attended: {:?}", attended.device());
// 6. Final processing with static context
let contextualized = self.apply_static_context(&attended, &static_encoded)?;
debug!(" contextualized: {:?}", contextualized.device());
// 7. Quantile Outputs (no checkpointing on final layer)
let quantile_preds = self.quantile_outputs.forward(&contextualized)?;
debug!(" quantile_preds: {:?}", quantile_preds.device());
// Update performance metrics
let latency = start_time.elapsed().as_micros() as u64;
self.update_performance_metrics(latency);
Ok(quantile_preds)
}
fn combine_temporal_features(
&self,
historical: &Tensor,
future: &Tensor,
) -> Result<Tensor, MLError> {
// Concatenate historical and future features along the time dimension
let combined = Tensor::cat(&[historical, future], 1)?;
Ok(combined)
}
fn apply_static_context(
&self,
temporal: &Tensor,
static_context: &Tensor,
) -> Result<Tensor, MLError> {
let (batch_size, seq_len, hidden_dim) = temporal.dims3()?;
// Static context comes from variable selection + GRN encoding
// It has shape [batch, 1, hidden] (variable selection adds seq_len=1 dimension)
// We need to expand it to [batch, seq_len, hidden] to match temporal features
// First, squeeze out the seq_len=1 dimension to get [batch, hidden]
let static_squeezed = static_context.squeeze(1)?;
// Then expand to match sequence length using broadcast (zero-copy)
let static_expanded = static_squeezed
.unsqueeze(1)? // [batch, 1, hidden]
.broadcast_as((batch_size, seq_len, hidden_dim))?; // [batch, seq_len, hidden] - zero-copy broadcast
// Add static context to temporal features
let contextualized = (temporal + &static_expanded)?;
Ok(contextualized)
}
/// Multi-horizon prediction interface
pub fn predict_horizons(
&mut self,
static_features: &Array1<f64>,
historical_features: &Array2<f64>,
future_features: &Array2<f64>,
) -> Result<MultiHorizonPrediction, MLError> {
if !self.is_trained {
return Err(MLError::ModelError("Model not trained".to_string()));
}
let start_time = Instant::now();
// Convert ndarray to tensors
let static_tensor = self.array_to_tensor_1d(static_features)?;
let historical_tensor = self.array_to_tensor_2d(historical_features)?;
let future_tensor = self.array_to_tensor_2d(future_features)?;
// Add batch dimension
let static_batched = static_tensor.unsqueeze(0)?;
let historical_batched = historical_tensor.unsqueeze(0)?;
let future_batched = future_tensor.unsqueeze(0)?;
// Forward pass
let quantile_preds = self.forward(&static_batched, &historical_batched, &future_batched)?;
// Extract predictions and process outputs
let pred_data = quantile_preds.squeeze(0)?.to_vec2::<f32>()?; // [horizon, quantiles]
let mut predictions = Vec::new();
let mut quantiles = Vec::new();
let mut uncertainty = Vec::new();
let mut confidence_intervals = Vec::new();
for horizon in 0..self.config.prediction_horizon {
let horizon_quantiles = &pred_data[horizon];
// Point prediction (median)
let median_idx = self.config.num_quantiles / 2;
predictions.push(horizon_quantiles[median_idx] as f64);
// All quantiles for this horizon
quantiles.push(horizon_quantiles.iter().map(|&x| x as f64).collect());
// Uncertainty (IQR)
let q75_idx = (self.config.num_quantiles * 3) / 4;
let q25_idx = self.config.num_quantiles / 4;
let iqr = horizon_quantiles[q75_idx] - horizon_quantiles[q25_idx];
uncertainty.push(iqr as f64);
// 90% confidence interval
let lower_idx = self.config.num_quantiles / 10; // ~10th percentile
let upper_idx = (self.config.num_quantiles * 9) / 10; // ~90th percentile
let ci = (
horizon_quantiles[lower_idx] as f64,
horizon_quantiles[upper_idx] as f64,
);
confidence_intervals.push(ci);
}
// Get feature importance and attention weights
let feature_importance = self.static_variable_selection.get_importance_scores()?;
let mut attention_weights = HashMap::new();
let weights = self.temporal_attention.get_attention_weights();
for (key, weight) in weights {
attention_weights.insert(key, vec![weight]);
}
let latency = start_time.elapsed().as_micros() as u64;
Ok(MultiHorizonPrediction {
predictions,
quantiles,
uncertainty,
confidence_intervals,
attention_weights,
feature_importance,
latency_us: latency,
})
}
fn array_to_tensor_1d(&self, arr: &Array1<f64>) -> Result<Tensor, MLError> {
let data: Vec<f32> = arr.iter().map(|&x| x as f32).collect();
let tensor = Tensor::from_slice(&data, arr.len(), &self.device)?;
Ok(tensor)
}
fn array_to_tensor_2d(&self, arr: &Array2<f64>) -> Result<Tensor, MLError> {
let data: Vec<f32> = arr.iter().map(|&x| x as f32).collect();
let shape = arr.shape();
let tensor = Tensor::from_slice(&data, (shape[0], shape[1]), &self.device)?;
Ok(tensor)
}
fn update_performance_metrics(&self, latency_us: u64) {
self.inference_count.fetch_add(1, Ordering::Relaxed);
self.total_latency_us
.fetch_add(latency_us, Ordering::Relaxed);
// Update max latency atomically
let mut current_max = self.max_latency_us.load(Ordering::Relaxed);
while latency_us > current_max {
match self.max_latency_us.compare_exchange_weak(
current_max,
latency_us,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => break,
Err(new_max) => current_max = new_max,
}
}
}
/// Get performance metrics
pub fn get_metrics(&self) -> HashMap<String, f64> {
let inference_count = self.inference_count.load(Ordering::Relaxed);
let total_latency = self.total_latency_us.load(Ordering::Relaxed);
let max_latency = self.max_latency_us.load(Ordering::Relaxed);
let avg_latency = if inference_count > 0 {
total_latency as f64 / inference_count as f64
} else {
0.0
};
let throughput = if avg_latency > 0.0 {
1_000_000.0 / avg_latency // predictions per second
} else {
0.0
};
let mut metrics = HashMap::new();
metrics.insert("total_inferences".to_string(), inference_count as f64);
metrics.insert("avg_latency_us".to_string(), avg_latency);
metrics.insert("max_latency_us".to_string(), max_latency as f64);
metrics.insert("throughput_pps".to_string(), throughput);
metrics
}
/// Get reference to VarMap for weight extraction
pub fn get_varmap(&self) -> &Arc<VarMap> {
&self.varmap
}
/// Training interface with real backward pass and optimizer
pub async fn train(
&mut self,
training_data: &[(Array1<f64>, Array2<f64>, Array2<f64>, Array1<f64>)], // (static, historical, future, targets)
validation_data: &[(Array1<f64>, Array2<f64>, Array2<f64>, Array1<f64>)],
epochs: usize,
) -> Result<(), MLError> {
info!("Starting TFT training for {} epochs", epochs);
// Initialize AdamW optimizer with model parameters
let params = self.varmap.all_vars();
let num_params: usize = params.iter().map(|v| v.as_tensor().elem_count()).sum();
let lr = 1e-3;
let mut optimizer = AdamW::new(
params,
ParamsAdamW {
lr,
beta1: 0.9,
beta2: 0.999,
eps: 1e-8,
weight_decay: 1e-4,
},
)
.map_err(|e| MLError::TrainingError(format!("Failed to create optimizer: {}", e)))?;
info!(
"Initialized AdamW optimizer: lr={:.2e}, {} parameters",
lr, num_params
);
let mut best_val_loss = f64::MAX;
for epoch in 0..epochs {
let mut epoch_loss = 0.0;
for (_i, (static_feat, hist_feat, fut_feat, targets)) in
training_data.iter().enumerate()
{
// Convert to tensors
let static_tensor = self.array_to_tensor_1d(static_feat)?.unsqueeze(0)?;
let hist_tensor = self.array_to_tensor_2d(hist_feat)?.unsqueeze(0)?;
let fut_tensor = self.array_to_tensor_2d(fut_feat)?.unsqueeze(0)?;
let target_tensor = self.array_to_tensor_1d(targets)?.unsqueeze(0)?;
// Forward pass
let predictions = self.forward(&static_tensor, &hist_tensor, &fut_tensor)?;
// Compute quantile loss
let loss = self
.quantile_outputs
.quantile_loss(&predictions, &target_tensor)?;
epoch_loss += loss.to_vec0::<f32>()? as f64;
// Backward pass — compute gradients
let grads = loss.backward().map_err(|e| {
MLError::TrainingError(format!("Backward pass failed: {}", e))
})?;
// Check gradient health before stepping
let varmap_data = self.varmap.data().lock().map_err(|e| {
MLError::TrainingError(format!("Failed to lock VarMap: {}", e))
})?;
let mut grad_norm_sq = 0.0_f64;
for (_name, var) in varmap_data.iter() {
if let Some(grad) = grads.get(var.as_tensor()) {
let norm_sq = grad
.sqr()
.and_then(|t| t.sum_all())
.and_then(|t| t.to_dtype(candle_core::DType::F64))
.and_then(|t| t.to_scalar::<f64>())
.unwrap_or(0.0);
grad_norm_sq += norm_sq;
}
}
drop(varmap_data);
let grad_norm = grad_norm_sq.sqrt();
if grad_norm.is_nan() || grad_norm.is_infinite() {
warn!(
"Gradient explosion detected (norm={}), skipping update",
grad_norm
);
continue;
}
// Optimizer step — update parameters
optimizer.step(&grads).map_err(|e| {
MLError::TrainingError(format!("Optimizer step failed: {}", e))
})?;
}
let avg_epoch_loss = epoch_loss / training_data.len().max(1) as f64;
debug!("Epoch {}: Average Loss = {:.6}", epoch, avg_epoch_loss);
// Validation every 10 epochs
if epoch % 10 == 0 {
let val_loss = self.validate(validation_data).await?;
info!(
"Epoch {}: Train Loss = {:.6}, Val Loss = {:.6}",
epoch, avg_epoch_loss, val_loss
);
if val_loss < best_val_loss {
best_val_loss = val_loss;
}
}
}
self.is_trained = true;
self.metadata.last_trained = Some(SystemTime::now());
self.metadata.training_samples = training_data.len() as u64;
info!(
"TFT training completed: {} epochs, best val loss = {:.6}",
epochs, best_val_loss
);
Ok(())
}
async fn validate(
&mut self,
validation_data: &[(Array1<f64>, Array2<f64>, Array2<f64>, Array1<f64>)],
) -> Result<f64, MLError> {
let mut total_loss = 0.0;
for (static_feat, hist_feat, fut_feat, targets) in validation_data {
let static_tensor = self.array_to_tensor_1d(static_feat)?.unsqueeze(0)?;
let hist_tensor = self.array_to_tensor_2d(hist_feat)?.unsqueeze(0)?;
let fut_tensor = self.array_to_tensor_2d(fut_feat)?.unsqueeze(0)?;
let target_tensor = self.array_to_tensor_1d(targets)?.unsqueeze(0)?;
let predictions = self.forward(&static_tensor, &hist_tensor, &fut_tensor)?;
let loss = self
.quantile_outputs
.quantile_loss(&predictions, &target_tensor)?;
total_loss += loss.to_vec0::<f32>()? as f64;
}
Ok(total_loss / validation_data.len() as f64)
}
/// Compute quantile loss for training
pub fn compute_quantile_loss(
&self,
predictions: &Tensor,
targets: &Tensor,
) -> Result<Tensor, MLError> {
self.quantile_outputs.quantile_loss(predictions, targets)
}
/// HFT-optimized inference
pub fn predict_fast(
&mut self,
static_features: &[f32],
historical_features: &[f32],
future_features: &[f32],
) -> Result<Vec<f32>, MLError> {
let start = Instant::now();
// Convert to tensors (optimized path)
let static_tensor =
Tensor::from_slice(static_features, static_features.len(), &self.device)?
.unsqueeze(0)?;
let hist_len = self.config.sequence_length;
let hist_dim = self.config.num_unknown_features;
let historical_tensor =
Tensor::from_slice(historical_features, (hist_len, hist_dim), &self.device)?
.unsqueeze(0)?;
let fut_len = self.config.prediction_horizon;
let fut_dim = self.config.num_known_features;
let future_tensor =
Tensor::from_slice(future_features, (fut_len, fut_dim), &self.device)?.unsqueeze(0)?;
// Forward pass
let quantile_preds = self.forward(&static_tensor, &historical_tensor, &future_tensor)?;
// Extract median predictions
let pred_data = quantile_preds.squeeze(0)?.to_vec2::<f32>()?;
let median_idx = self.config.num_quantiles / 2;
let predictions: Vec<f32> = pred_data
.iter()
.map(|horizon_quantiles| horizon_quantiles[median_idx])
.collect();
let latency = start.elapsed().as_micros() as u64;
self.update_performance_metrics(latency);
if latency > self.config.max_inference_latency_us {
warn!(
"Inference latency {}μs exceeds target {}μs",
latency, self.config.max_inference_latency_us
);
}
Ok(predictions)
}
}
/// Implement Checkpointable trait for TFT
#[async_trait]
impl Checkpointable for TemporalFusionTransformer {
fn model_type(&self) -> ModelType {
ModelType::TFT
}
fn model_name(&self) -> &str {
&self.metadata.model_id
}
fn model_version(&self) -> &str {
&self.metadata.version
}
async fn serialize_state(&self) -> Result<Vec<u8>, MLError> {
// Save VarMap to temporary file, then read as bytes
// VarMap.save() requires a Path, not a writer
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("tft_checkpoint_{}.safetensors", Uuid::new_v4()));
// Convert temp_path to string for VarMap::save()
let temp_path_str = temp_path
.to_str()
.ok_or_else(|| MLError::ModelError("Invalid temp path".to_string()))?;
self.varmap
.save(temp_path_str)
.map_err(|e| MLError::ModelError(format!("Failed to serialize TFT state: {}", e)))?;
// Read the file into bytes
let buffer = std::fs::read(&temp_path)
.map_err(|e| MLError::ModelError(format!("Failed to read checkpoint file: {}", e)))?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
debug!("Serialized TFT state: {} bytes", buffer.len());
Ok(buffer)
}
async fn deserialize_state(&mut self, data: &[u8]) -> Result<(), MLError> {
// Write bytes to temporary file, then load VarMap
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("tft_restore_{}.safetensors", Uuid::new_v4()));
std::fs::write(&temp_path, data)
.map_err(|e| MLError::ModelError(format!("Failed to write temp checkpoint: {}", e)))?;
// Convert temp_path to string for VarMap::load()
let temp_path_str = temp_path
.to_str()
.ok_or_else(|| MLError::ModelError("Invalid temp path".to_string()))?;
// Try to get mutable access to the VarMap through Arc
let varmap_mut = Arc::get_mut(&mut self.varmap).ok_or_else(|| {
MLError::ModelError(
"Cannot load checkpoint: VarMap has multiple references. \
This indicates the model is being shared across threads. \
Clone the model before loading checkpoint."
.to_string(),
)
})?;
// Load the checkpoint into the VarMap
varmap_mut
.load(temp_path_str)
.map_err(|e| MLError::ModelError(format!("Failed to load TFT state: {}", e)))?;
// Clean up temp file
let _ = std::fs::remove_file(&temp_path);
debug!("Deserialized TFT state from {} bytes", data.len());
Ok(())
}
fn get_training_state(&self) -> (Option<u64>, Option<u64>, Option<f64>, Option<f64>) {
// TFT doesn't track epochs/steps in the current implementation
// Return metadata-based info if available
(
None, // epoch
None, // step
None, // loss
None, // accuracy
)
}
fn get_hyperparameters(&self) -> HashMap<String, Value> {
let mut params = HashMap::new();
// Core architecture params (Wave C+D: 225 features)
params.insert("input_dim".to_string(), Value::from(self.config.input_dim));
params.insert(
"hidden_dim".to_string(),
Value::from(self.config.hidden_dim),
);
params.insert("num_heads".to_string(), Value::from(self.config.num_heads));
params.insert(
"num_layers".to_string(),
Value::from(self.config.num_layers),
);
params.insert(
"prediction_horizon".to_string(),
Value::from(self.config.prediction_horizon),
);
params.insert(
"sequence_length".to_string(),
Value::from(self.config.sequence_length),
);
params.insert(
"num_quantiles".to_string(),
Value::from(self.config.num_quantiles),
);
// Feature split (critical for Wave C+D compatibility)
params.insert(
"num_static_features".to_string(),
Value::from(self.config.num_static_features),
);
params.insert(
"num_known_features".to_string(),
Value::from(self.config.num_known_features),
);
params.insert(
"num_unknown_features".to_string(),
Value::from(self.config.num_unknown_features),
);
// Training params
params.insert(
"learning_rate".to_string(),
Value::from(self.config.learning_rate),
);
params.insert(
"batch_size".to_string(),
Value::from(self.config.batch_size),
);
params.insert(
"dropout_rate".to_string(),
Value::from(self.config.dropout_rate),
);
params.insert(
"l2_regularization".to_string(),
Value::from(self.config.l2_regularization),
);
// HFT optimization flags
params.insert(
"use_flash_attention".to_string(),
Value::from(self.config.use_flash_attention),
);
params.insert(
"mixed_precision".to_string(),
Value::from(self.config.mixed_precision),
);
params.insert(
"memory_efficient".to_string(),
Value::from(self.config.memory_efficient),
);
params
}
fn get_metrics(&self) -> HashMap<String, f64> {
// Call the existing get_metrics method from TemporalFusionTransformer
let inference_count = self.inference_count.load(Ordering::Relaxed);
let total_latency = self.total_latency_us.load(Ordering::Relaxed);
let max_latency = self.max_latency_us.load(Ordering::Relaxed);
let avg_latency = if inference_count > 0 {
total_latency as f64 / inference_count as f64
} else {
0.0
};
let throughput = if avg_latency > 0.0 {
1_000_000.0 / avg_latency
} else {
0.0
};
let mut metrics = HashMap::new();
metrics.insert("total_inferences".to_string(), inference_count as f64);
metrics.insert("avg_latency_us".to_string(), avg_latency);
metrics.insert("max_latency_us".to_string(), max_latency as f64);
metrics.insert("throughput_pps".to_string(), throughput);
metrics
}
fn get_architecture_info(&self) -> HashMap<String, Value> {
let mut info = HashMap::new();
info.insert("network_type".to_string(), Value::from("TFT"));
info.insert(
"input_dim".to_string(),
Value::from(self.metadata.input_dim),
);
info.insert(
"output_dim".to_string(),
Value::from(self.metadata.output_dim),
);
info.insert(
"hidden_dim".to_string(),
Value::from(self.config.hidden_dim),
);
info.insert("num_heads".to_string(), Value::from(self.config.num_heads));
info.insert(
"num_layers".to_string(),
Value::from(self.config.num_layers),
);
info.insert(
"num_static_features".to_string(),
Value::from(self.config.num_static_features),
);
info.insert(
"num_known_features".to_string(),
Value::from(self.config.num_known_features),
);
info.insert(
"num_unknown_features".to_string(),
Value::from(self.config.num_unknown_features),
);
info
}
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
#[tokio::test]
async fn test_tft_creation() -> Result<()> {
let config = TFTConfig {
input_dim: 10,
hidden_dim: 32,
num_heads: 4,
num_quantiles: 5,
prediction_horizon: 5,
sequence_length: 20,
num_static_features: 2,
num_known_features: 3,
num_unknown_features: 5,
..Default::default()
};
let tft = TemporalFusionTransformer::new(config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
assert_eq!(tft.metadata.input_dim, 10);
assert_eq!(tft.metadata.output_dim, 5);
Ok(())
}
#[test]
fn test_tft_225_features_default() -> Result<()> {
// Test default configuration uses 225 features (Wave C+D)
let config = TFTConfig::default();
assert_eq!(
config.input_dim, 225,
"Default TFT config should use 225 features"
);
assert_eq!(config.num_static_features, 5);
assert_eq!(config.num_known_features, 10);
assert_eq!(config.num_unknown_features, 210);
let tft = TemporalFusionTransformer::new(config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT with 225 features"))?;
assert_eq!(tft.metadata.input_dim, 225);
Ok(())
}
#[test]
fn test_tft_225_features_validation() -> Result<()> {
// Test that 225-feature TFT validates input dimensions correctly
let config = TFTConfig::default(); // 225 features
let device = Device::Cpu;
let tft = TemporalFusionTransformer::new_with_device(config.clone(), device.clone())
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
// Create valid input tensors
let batch_size = 2;
let seq_len = 50;
let horizon = 10;
let static_features = Tensor::zeros(
(batch_size, config.num_static_features),
DType::F32,
&device,
)?;
let historical_features = Tensor::zeros(
(batch_size, seq_len, config.num_unknown_features),
DType::F32,
&device,
)?;
let future_features = Tensor::zeros(
(batch_size, horizon, config.num_known_features),
DType::F32,
&device,
)?;
// Should validate successfully
let result =
tft.validate_input_dimensions(&static_features, &historical_features, &future_features);
assert!(
result.is_ok(),
"Valid 225-feature input should pass validation"
);
// Test invalid historical features dimension
let invalid_hist = Tensor::zeros((batch_size, seq_len, 50), DType::F32, &device)?; // Wrong dim: 50 instead of 210
let result =
tft.validate_input_dimensions(&static_features, &invalid_hist, &future_features);
assert!(
result.is_err(),
"Invalid historical features should fail validation"
);
Ok(())
}
#[test]
fn test_tft_config_mismatch_detection() -> Result<()> {
// Test that mismatched feature counts are detected during construction
let invalid_config = TFTConfig {
input_dim: 225,
num_static_features: 5,
num_known_features: 10,
num_unknown_features: 100, // Wrong: should be 210 for 225 total
..Default::default()
};
let result = TemporalFusionTransformer::new(invalid_config);
assert!(
result.is_err(),
"Mismatched feature counts should be rejected"
);
let err_msg = format!("{:?}", result.unwrap_err());
assert!(
err_msg.contains("Feature count mismatch"),
"Error should mention feature count mismatch"
);
Ok(())
}
#[test]
fn test_tft_checkpoint_preserves_config() -> Result<()> {
// Test that checkpoint save/load preserves 225-feature configuration
let config = TFTConfig::default(); // 225 features
let tft = TemporalFusionTransformer::new(config.clone())
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
let hyperparams = tft.get_hyperparameters();
// Verify all critical config params are saved
assert_eq!(
hyperparams.get("input_dim").and_then(|v| v.as_u64()),
Some(225)
);
assert_eq!(
hyperparams
.get("num_static_features")
.and_then(|v| v.as_u64()),
Some(5)
);
assert_eq!(
hyperparams
.get("num_known_features")
.and_then(|v| v.as_u64()),
Some(10)
);
assert_eq!(
hyperparams
.get("num_unknown_features")
.and_then(|v| v.as_u64()),
Some(210)
);
Ok(())
}
#[test]
fn test_tft_wave_c_config() -> Result<()> {
// Test Wave C configuration (201 features)
let wave_c_config = TFTConfig {
input_dim: 201,
num_static_features: 5,
num_known_features: 10,
num_unknown_features: 186, // 201 - 5 - 10 = 186
..Default::default()
};
let tft = TemporalFusionTransformer::new(wave_c_config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT with 201 features"))?;
assert_eq!(tft.metadata.input_dim, 201);
Ok(())
}
#[test]
fn test_tft_state_creation() -> Result<()> {
let config = TFTConfig {
hidden_dim: 32,
sequence_length: 20,
num_heads: 4,
..Default::default()
};
let state =
TFTState::zeros(&config).map_err(|_| anyhow::anyhow!("Failed to create state"))?;
assert!(state.last_update == 0);
Ok(())
}
#[test]
fn test_tft_config_default() -> Result<()> {
let config = TFTConfig::default();
assert!(config.input_dim > 0);
assert!(config.hidden_dim > 0);
assert!(config.num_heads > 0);
Ok(())
}
#[test]
fn test_tft_performance_metrics() -> Result<()> {
let config = TFTConfig {
input_dim: 30,
hidden_dim: 32,
num_static_features: 5,
num_known_features: 10,
num_unknown_features: 15, // 30 - 5 - 10 = 15
..Default::default()
};
let tft = TemporalFusionTransformer::new(config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
let metrics = tft.get_metrics();
assert!(metrics.contains_key("total_inferences"));
assert!(metrics.contains_key("avg_latency_us"));
assert!(metrics.contains_key("max_latency_us"));
assert!(metrics.contains_key("throughput_pps"));
Ok(())
}
#[test]
fn test_tft_training_state() -> Result<()> {
let config = TFTConfig::default();
let mut tft = TemporalFusionTransformer::new(config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
assert!(!tft.is_trained);
tft.is_trained = true;
assert!(tft.is_trained);
Ok(())
}
#[test]
fn test_tft_metadata() -> Result<()> {
let config = TFTConfig {
input_dim: 30,
prediction_horizon: 12,
num_static_features: 5,
num_known_features: 10,
num_unknown_features: 15, // 30 - 5 - 10 = 15
..Default::default()
};
let tft = TemporalFusionTransformer::new(config)
.map_err(|_| anyhow::anyhow!("Failed to create TFT"))?;
assert_eq!(tft.metadata.input_dim, 30);
assert_eq!(tft.metadata.output_dim, 12);
Ok(())
}
}