feat(ml): add KAN architecture + TLOB/KAN trainable/hyperopt adapters

Phase 2-3 of ensemble expansion:
- KAN (Kolmogorov-Arnold Network): B-spline basis, layer, network, trainable adapter
- TLOB UnifiedTrainable adapter with 3D input support (batch, seq, features)
- Hyperopt adapters for both KAN and TLOB (ParameterSpace + metrics)
- ModelType::KAN variant registered in common, coordinator, lib.rs
- 44 new tests, all passing, zero warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 01:32:25 +01:00
parent d777d714b2
commit 4eacd4e22f
14 changed files with 2146 additions and 0 deletions

View File

@@ -0,0 +1,217 @@
//! KAN (Kolmogorov-Arnold Network) Hyperparameter Optimization Adapter
//!
//! Provides `KANParams` (implementing `ParameterSpace`) and `KANMetrics`
//! for hyperparameter optimization of the KAN model via the unified
//! optimization framework.
use crate::hyperopt::traits::ParameterSpace;
use crate::MLError;
use serde::{Deserialize, Serialize};
/// Hyperparameters for KAN hyperopt tuning.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KANParams {
/// Learning rate for AdamW optimizer (log-scale)
pub learning_rate: f64,
/// Number of B-spline grid points per activation
pub grid_size: usize,
/// B-spline order (degree + 1)
pub spline_order: usize,
/// Hidden layer width (used for all hidden layers)
pub hidden_width: usize,
/// Number of hidden layers (total layers = num_layers + 1 for output)
pub num_layers: usize,
/// L2 weight decay (log-scale)
pub weight_decay: f64,
/// Maximum gradient norm for clipping (log-scale)
pub grad_clip: f64,
/// Training batch size
pub batch_size: usize,
}
impl Default for KANParams {
fn default() -> Self {
Self {
learning_rate: 1e-3,
grid_size: 5,
spline_order: 4,
hidden_width: 32,
num_layers: 2,
weight_decay: 1e-4,
grad_clip: 1.0,
batch_size: 32,
}
}
}
/// Number of continuous parameters in the KAN parameter space.
const NUM_PARAMS: usize = 8;
impl ParameterSpace for KANParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
vec![
(1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log)
(3.0, 12.0), // grid_size (linear)
(2.0, 5.0), // spline_order (linear)
(16.0, 64.0), // hidden_width (linear)
(2.0, 4.0), // num_layers (linear)
(1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log)
(0.5_f64.ln(), 5.0_f64.ln()), // grad_clip (log)
(8.0, 64.0), // batch_size (linear)
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != NUM_PARAMS {
return Err(MLError::ConfigError {
reason: format!("Expected {} continuous parameters, got {}", NUM_PARAMS, x.len()),
});
}
Ok(Self {
learning_rate: x.first().copied().unwrap_or(0.0).exp(),
grid_size: x.get(1).copied().unwrap_or(5.0).round().max(3.0) as usize,
spline_order: x.get(2).copied().unwrap_or(4.0).round().max(2.0) as usize,
hidden_width: x.get(3).copied().unwrap_or(32.0).round().max(16.0) as usize,
num_layers: x.get(4).copied().unwrap_or(2.0).round().max(2.0) as usize,
weight_decay: x.get(5).copied().unwrap_or(0.0).exp(),
grad_clip: x.get(6).copied().unwrap_or(0.0).exp(),
batch_size: x.get(7).copied().unwrap_or(32.0).round().max(8.0) as usize,
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.learning_rate.ln(),
self.grid_size as f64,
self.spline_order as f64,
self.hidden_width as f64,
self.num_layers as f64,
self.weight_decay.ln(),
self.grad_clip.ln(),
self.batch_size as f64,
]
}
fn param_names() -> Vec<&'static str> {
vec![
"learning_rate",
"grid_size",
"spline_order",
"hidden_width",
"num_layers",
"weight_decay",
"grad_clip",
"batch_size",
]
}
}
/// Metrics returned from KAN hyperopt training.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KANMetrics {
/// Validation loss (primary objective for optimization)
pub val_loss: f64,
/// Training loss at end of run
pub train_loss: f64,
/// Number of epochs completed
pub epochs_completed: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_count_matches_param_names() {
let bounds = KANParams::continuous_bounds();
let names = KANParams::param_names();
assert_eq!(bounds.len(), names.len());
assert_eq!(bounds.len(), NUM_PARAMS);
}
#[test]
fn test_roundtrip_continuous() {
let params = KANParams::default();
let continuous = params.to_continuous();
let restored = KANParams::from_continuous(&continuous).unwrap();
assert!(
(params.learning_rate - restored.learning_rate).abs() < 1e-6,
"lr mismatch: {} vs {}",
params.learning_rate,
restored.learning_rate
);
assert_eq!(params.grid_size, restored.grid_size);
assert_eq!(params.spline_order, restored.spline_order);
assert_eq!(params.hidden_width, restored.hidden_width);
assert_eq!(params.num_layers, restored.num_layers);
assert!(
(params.weight_decay - restored.weight_decay).abs() / params.weight_decay < 1e-6,
"weight_decay mismatch"
);
assert!(
(params.grad_clip - restored.grad_clip).abs() < 1e-6,
"grad_clip mismatch"
);
assert_eq!(params.batch_size, restored.batch_size);
}
#[test]
fn test_from_continuous_wrong_length_errors() {
let result = KANParams::from_continuous(&[0.1, 0.2]);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(
err_msg.contains("Expected 8"),
"Error should mention expected count: {}",
err_msg
);
}
#[test]
fn test_bounds_are_valid() {
for (i, (min, max)) in KANParams::continuous_bounds().iter().enumerate() {
assert!(
min < max,
"Invalid bounds for param {} ({}): {} >= {}",
i,
KANParams::param_names().get(i).copied().unwrap_or("?"),
min,
max
);
}
}
#[test]
fn test_default_within_bounds() {
let params = KANParams::default();
let continuous = params.to_continuous();
let bounds = KANParams::continuous_bounds();
let names = KANParams::param_names();
for (i, (val, (min, max))) in continuous.iter().zip(bounds.iter()).enumerate() {
assert!(
*val >= *min && *val <= *max,
"Param {} ({}) = {} outside [{}, {}]",
i,
names.get(i).copied().unwrap_or("?"),
val,
min,
max
);
}
}
#[test]
fn test_metrics_default() {
let metrics = KANMetrics::default();
assert_eq!(metrics.val_loss, 0.0);
assert_eq!(metrics.train_loss, 0.0);
assert_eq!(metrics.epochs_completed, 0);
}
#[test]
fn test_continuous_length() {
let params = KANParams::default();
let continuous = params.to_continuous();
assert_eq!(continuous.len(), NUM_PARAMS);
}
}

View File

@@ -52,14 +52,17 @@
pub mod async_data_loader;
pub mod continuous_ppo;
pub mod dqn;
pub mod kan;
pub mod liquid;
pub mod mamba2;
pub mod ppo;
pub mod tft;
pub mod tggn;
pub mod tlob;
// Re-export adapters for convenience
pub use async_data_loader::AsyncDataLoader;
pub use kan::{KANMetrics, KANParams};
pub use liquid::LiquidParams;
pub use continuous_ppo::{ContinuousPPOMetrics, ContinuousPPOParams, ContinuousPPOTrainer};
pub use dqn::{DQNMetrics, DQNParams, DQNTrainer};
@@ -67,3 +70,4 @@ pub use mamba2::{Mamba2Metrics, Mamba2Params, Mamba2Trainer};
pub use ppo::{PPOMetrics, PPOParams, PPOTrainer};
pub use tft::{TFTMetrics, TFTParams, TFTTrainer as TFTHyperoptTrainer};
pub use tggn::{TGGNMetrics, TGGNParams};
pub use tlob::{TLOBMetrics, TLOBParams};

View File

@@ -0,0 +1,229 @@
//! TLOB (Time Limit Order Book) Hyperparameter Optimization Adapter
//!
//! Provides `TLOBParams` (implementing `ParameterSpace`) and `TLOBMetrics`
//! for hyperparameter optimization of the TLOB model via the unified
//! optimization framework.
use crate::hyperopt::traits::ParameterSpace;
use crate::MLError;
use serde::{Deserialize, Serialize};
/// Hyperparameters for TLOB hyperopt tuning.
///
/// Controls the TLOB transformer's architecture and training:
/// - Architecture: `d_model`, `num_heads`, `num_layers`, `seq_len`
/// - Regularization: `dropout`, `weight_decay`, `grad_clip`
/// - Training: `learning_rate`, `batch_size`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TLOBParams {
/// Learning rate for AdamW optimizer (log-scale)
pub learning_rate: f64,
/// Model / hidden dimension for projection layers
pub d_model: usize,
/// Number of attention heads
pub num_heads: usize,
/// Number of transformer/projection layers
pub num_layers: usize,
/// Sequence length (number of time steps in LOB snapshot)
pub seq_len: usize,
/// Dropout rate for regularization
pub dropout: f64,
/// Training batch size
pub batch_size: usize,
/// L2 weight decay (log-scale)
pub weight_decay: f64,
/// Maximum gradient norm for clipping (log-scale)
pub grad_clip: f64,
}
impl Default for TLOBParams {
fn default() -> Self {
Self {
learning_rate: 1e-3,
d_model: 128,
num_heads: 4,
num_layers: 2,
seq_len: 128,
dropout: 0.1,
batch_size: 32,
weight_decay: 1e-4,
grad_clip: 1.0,
}
}
}
/// Number of continuous parameters in the TLOB parameter space.
const NUM_PARAMS: usize = 9;
impl ParameterSpace for TLOBParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
vec![
(1e-5_f64.ln(), 1e-2_f64.ln()), // learning_rate (log)
(64.0, 512.0), // d_model (linear)
(2.0, 16.0), // num_heads (linear)
(1.0, 8.0), // num_layers (linear)
(32.0, 256.0), // seq_len (linear)
(0.0, 0.5), // dropout (linear)
(8.0, 64.0), // batch_size (linear)
(1e-6_f64.ln(), 1e-2_f64.ln()), // weight_decay (log)
(0.5_f64.ln(), 5.0_f64.ln()), // grad_clip (log)
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != NUM_PARAMS {
return Err(MLError::ConfigError {
reason: format!("Expected {} continuous parameters, got {}", NUM_PARAMS, x.len()),
});
}
Ok(Self {
learning_rate: x.first().copied().unwrap_or(0.0).exp(),
d_model: x.get(1).copied().unwrap_or(128.0).round().max(64.0) as usize,
num_heads: x.get(2).copied().unwrap_or(4.0).round().max(2.0) as usize,
num_layers: x.get(3).copied().unwrap_or(2.0).round().max(1.0) as usize,
seq_len: x.get(4).copied().unwrap_or(128.0).round().max(32.0) as usize,
dropout: x.get(5).copied().unwrap_or(0.1).clamp(0.0, 0.5),
batch_size: x.get(6).copied().unwrap_or(32.0).round().max(8.0) as usize,
weight_decay: x.get(7).copied().unwrap_or(0.0).exp(),
grad_clip: x.get(8).copied().unwrap_or(0.0).exp(),
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.learning_rate.ln(),
self.d_model as f64,
self.num_heads as f64,
self.num_layers as f64,
self.seq_len as f64,
self.dropout,
self.batch_size as f64,
self.weight_decay.ln(),
self.grad_clip.ln(),
]
}
fn param_names() -> Vec<&'static str> {
vec![
"learning_rate",
"d_model",
"num_heads",
"num_layers",
"seq_len",
"dropout",
"batch_size",
"weight_decay",
"grad_clip",
]
}
}
/// Metrics returned from TLOB hyperopt training.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TLOBMetrics {
/// Validation loss (primary objective for optimization)
pub val_loss: f64,
/// Training loss at end of run
pub train_loss: f64,
/// Directional accuracy on validation set
pub directional_accuracy: f64,
/// Number of epochs completed
pub epochs_completed: usize,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bounds_count_matches_param_names() {
let bounds = TLOBParams::continuous_bounds();
let names = TLOBParams::param_names();
assert_eq!(bounds.len(), names.len());
assert_eq!(bounds.len(), NUM_PARAMS);
}
#[test]
fn test_roundtrip_continuous() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
let restored = TLOBParams::from_continuous(&continuous).unwrap();
assert!(
(params.learning_rate - restored.learning_rate).abs() < 1e-6,
"lr mismatch: {} vs {}",
params.learning_rate,
restored.learning_rate
);
assert_eq!(params.d_model, restored.d_model);
assert_eq!(params.num_heads, restored.num_heads);
assert_eq!(params.num_layers, restored.num_layers);
assert_eq!(params.seq_len, restored.seq_len);
assert!((params.dropout - restored.dropout).abs() < 1e-6, "dropout mismatch");
assert_eq!(params.batch_size, restored.batch_size);
assert!(
(params.weight_decay - restored.weight_decay).abs() / params.weight_decay < 1e-6,
"weight_decay mismatch"
);
assert!(
(params.grad_clip - restored.grad_clip).abs() < 1e-6,
"grad_clip mismatch"
);
}
#[test]
fn test_from_continuous_wrong_length_errors() {
let result = TLOBParams::from_continuous(&[0.1, 0.2]);
assert!(result.is_err());
let err_msg = format!("{}", result.unwrap_err());
assert!(err_msg.contains("Expected 9"), "Error should mention expected count: {}", err_msg);
}
#[test]
fn test_bounds_are_valid() {
for (i, (min, max)) in TLOBParams::continuous_bounds().iter().enumerate() {
assert!(
min < max,
"Invalid bounds for param {} ({}): {} >= {}",
i,
TLOBParams::param_names().get(i).copied().unwrap_or("?"),
min,
max
);
}
}
#[test]
fn test_default_within_bounds() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
let bounds = TLOBParams::continuous_bounds();
let names = TLOBParams::param_names();
for (i, (val, (min, max))) in continuous.iter().zip(bounds.iter()).enumerate() {
assert!(
*val >= *min && *val <= *max,
"Param {} ({}) = {} outside [{}, {}]",
i,
names.get(i).copied().unwrap_or("?"),
val,
min,
max
);
}
}
#[test]
fn test_metrics_default() {
let metrics = TLOBMetrics::default();
assert_eq!(metrics.val_loss, 0.0);
assert_eq!(metrics.train_loss, 0.0);
assert_eq!(metrics.directional_accuracy, 0.0);
assert_eq!(metrics.epochs_completed, 0);
}
#[test]
fn test_continuous_length() {
let params = TLOBParams::default();
let continuous = params.to_continuous();
assert_eq!(continuous.len(), NUM_PARAMS);
}
}