docs: add unified training binaries implementation plan
8-task plan covering rename, new supervised binary, deletions, and infra updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
953
docs/plans/2026-02-25-unified-training-plan.md
Normal file
953
docs/plans/2026-02-25-unified-training-plan.md
Normal file
@@ -0,0 +1,953 @@
|
||||
# Unified Training Binaries Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Consolidate 21 training example binaries into 2 unified binaries (`train_baseline_rl`, `train_baseline_supervised`), delete the rest, and update infra.
|
||||
|
||||
**Architecture:** Rename existing `train_baseline.rs` to `train_baseline_rl.rs` for DQN/PPO RL training. Create `train_baseline_supervised.rs` that uses a model factory + generic `UnifiedTrainable` training loop for the 8 supervised models. Both share `baseline_common/mod.rs` for data loading.
|
||||
|
||||
**Tech Stack:** Rust, Candle, `UnifiedTrainable` trait, walk-forward windows, DBN data, clap CLI
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rename `train_baseline` → `train_baseline_rl`
|
||||
|
||||
**Files:**
|
||||
- Rename: `crates/ml/examples/train_baseline.rs` → `crates/ml/examples/train_baseline_rl.rs`
|
||||
- Modify: `crates/ml/Cargo.toml` (line ~208)
|
||||
|
||||
**Step 1: Copy the file to the new name**
|
||||
|
||||
```bash
|
||||
cp crates/ml/examples/train_baseline.rs crates/ml/examples/train_baseline_rl.rs
|
||||
```
|
||||
|
||||
**Step 2: Update the `#[command]` name in the new file**
|
||||
|
||||
In `crates/ml/examples/train_baseline_rl.rs`, change line 49:
|
||||
|
||||
```rust
|
||||
// Before:
|
||||
#[command(name = "train_baseline", about = "Train DQN/PPO with walk-forward windows")]
|
||||
// After:
|
||||
#[command(name = "train_baseline_rl", about = "Train DQN/PPO with walk-forward RL windows")]
|
||||
```
|
||||
|
||||
**Step 3: Update the doc comment**
|
||||
|
||||
Change line 1:
|
||||
|
||||
```rust
|
||||
// Before:
|
||||
//! Walk-forward training binary for DQN and PPO models.
|
||||
// After:
|
||||
//! Walk-forward RL training binary for DQN and PPO models.
|
||||
```
|
||||
|
||||
**Step 4: Update `Cargo.toml` explicit example entry**
|
||||
|
||||
In `crates/ml/Cargo.toml`, change the `[[example]]` entry (lines 207-209):
|
||||
|
||||
```toml
|
||||
# Before:
|
||||
[[example]]
|
||||
name = "train_baseline"
|
||||
path = "examples/train_baseline.rs"
|
||||
|
||||
# After:
|
||||
[[example]]
|
||||
name = "train_baseline_rl"
|
||||
path = "examples/train_baseline_rl.rs"
|
||||
```
|
||||
|
||||
**Step 5: Delete the old file**
|
||||
|
||||
```bash
|
||||
rm crates/ml/examples/train_baseline.rs
|
||||
```
|
||||
|
||||
**Step 6: Verify compilation**
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_rl 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: compiles with 0 errors.
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/ml/examples/train_baseline_rl.rs crates/ml/examples/train_baseline.rs crates/ml/Cargo.toml
|
||||
git commit -m "refactor(ml): rename train_baseline → train_baseline_rl"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Create `train_baseline_supervised` — CLI + model factory
|
||||
|
||||
**Files:**
|
||||
- Create: `crates/ml/examples/train_baseline_supervised.rs`
|
||||
|
||||
This is the main new file. It has 4 sections: CLI args, model factory, training loop, and main.
|
||||
|
||||
**Step 1: Create the file with CLI args and model enum**
|
||||
|
||||
Create `crates/ml/examples/train_baseline_supervised.rs` with the following content:
|
||||
|
||||
```rust
|
||||
//! Walk-forward supervised training binary for all non-RL models.
|
||||
//!
|
||||
//! Trains models using the `UnifiedTrainable` trait on real OHLCV data loaded
|
||||
//! from Databento DBN files. Supports walk-forward windows, early stopping,
|
||||
//! checkpoint saving, and z-score normalization.
|
||||
//!
|
||||
//! # Supported Models
|
||||
//!
|
||||
//! tft, mamba2, liquid, tggn, tlob, kan, xlstm, diffusion
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! SQLX_OFFLINE=true cargo run -p ml --example train_baseline_supervised --release -- \
|
||||
//! --model kan --epochs 50 --batch-size 128 \
|
||||
//! --data-dir data/cache/futures-baseline \
|
||||
//! --output-dir ml/trained_models
|
||||
//! ```
|
||||
|
||||
#![allow(unused_crate_dependencies, clippy::cognitive_complexity)]
|
||||
#![deny(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::panic,
|
||||
clippy::indexing_slicing
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use candle_core::{Device, Tensor};
|
||||
use clap::Parser;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use ml::features::extraction::extract_ml_features;
|
||||
use ml::training::unified_trainer::UnifiedTrainable;
|
||||
use ml::types::OHLCVBar;
|
||||
use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig};
|
||||
|
||||
#[allow(unreachable_pub)]
|
||||
mod baseline_common;
|
||||
use baseline_common::{load_all_bars, spread_cost_bps};
|
||||
|
||||
// Model adapter imports
|
||||
use ml::tft::TFTConfig;
|
||||
use ml::tft::trainable_adapter::TrainableTFT;
|
||||
use ml::mamba::{Mamba2Config, Mamba2SSM};
|
||||
use ml::liquid::candle_cfc::CfCTrainConfig;
|
||||
use ml::liquid::adapter::LiquidTrainableAdapter;
|
||||
use ml::tgnn::{TGGNConfig};
|
||||
use ml::tgnn::trainable_adapter::TGGNTrainableAdapter;
|
||||
use ml::tlob::trainable_adapter::{TLOBAdapterConfig, TLOBTrainableAdapter};
|
||||
use ml::kan::{KANConfig, KANTrainableAdapter};
|
||||
use ml::xlstm::config::XLSTMConfig;
|
||||
use ml::xlstm::trainable::XLSTMTrainableAdapter;
|
||||
use ml::diffusion::config::DiffusionConfig;
|
||||
use ml::diffusion::trainable::DiffusionTrainableAdapter;
|
||||
use ml::gpu::DeviceConfig;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI Arguments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Walk-forward supervised training binary for non-RL models.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "train_baseline_supervised", about = "Train supervised models with walk-forward windows")]
|
||||
struct Args {
|
||||
/// Model to train: tft, mamba2, liquid, tggn, tlob, kan, xlstm, diffusion, all
|
||||
#[arg(long)]
|
||||
model: String,
|
||||
|
||||
/// Path to directory containing .dbn.zst files (with symbol subdirectories)
|
||||
#[arg(long, default_value = "data/cache/futures-baseline")]
|
||||
data_dir: PathBuf,
|
||||
|
||||
/// Symbol subdirectory to load (e.g. "ES.FUT", "NQ.FUT")
|
||||
#[arg(long, default_value = "ES.FUT")]
|
||||
symbol: String,
|
||||
|
||||
/// Maximum training epochs per fold
|
||||
#[arg(long, default_value_t = 50)]
|
||||
epochs: usize,
|
||||
|
||||
/// Training batch size
|
||||
#[arg(long, default_value_t = 128)]
|
||||
batch_size: usize,
|
||||
|
||||
/// Learning rate
|
||||
#[arg(long, default_value_t = 1e-3)]
|
||||
learning_rate: f64,
|
||||
|
||||
/// Feature dimension (must match extract_ml_features output)
|
||||
#[arg(long, default_value_t = 51)]
|
||||
feature_dim: usize,
|
||||
|
||||
/// Max training steps per epoch (0 = use all bars)
|
||||
#[arg(long, default_value_t = 2000)]
|
||||
max_steps_per_epoch: usize,
|
||||
|
||||
/// Output directory for trained model checkpoints
|
||||
#[arg(long, default_value = "ml/trained_models")]
|
||||
output_dir: PathBuf,
|
||||
|
||||
/// Early stopping patience (epochs without improvement)
|
||||
#[arg(long, default_value_t = 10)]
|
||||
patience: usize,
|
||||
|
||||
/// Walk-forward: initial training window in months
|
||||
#[arg(long, default_value_t = 12)]
|
||||
train_months: u32,
|
||||
|
||||
/// Walk-forward: validation window in months
|
||||
#[arg(long, default_value_t = 3)]
|
||||
val_months: u32,
|
||||
|
||||
/// Walk-forward: test window in months
|
||||
#[arg(long, default_value_t = 3)]
|
||||
test_months: u32,
|
||||
|
||||
/// Walk-forward: step size in months between folds
|
||||
#[arg(long, default_value_t = 3)]
|
||||
step_months: u32,
|
||||
|
||||
/// Maximum absolute per-bar return; larger moves are clamped
|
||||
#[arg(long, default_value_t = 0.01)]
|
||||
max_bar_return: f64,
|
||||
|
||||
/// Round-trip commission cost in basis points
|
||||
#[arg(long, default_value_t = 1.0)]
|
||||
tx_cost_bps: f64,
|
||||
|
||||
/// Instrument tick size in price units (ES=0.25)
|
||||
#[arg(long, default_value_t = 0.25)]
|
||||
tick_size: f64,
|
||||
|
||||
/// Typical bid-ask spread in ticks
|
||||
#[arg(long, default_value_t = 1.0)]
|
||||
spread_ticks: f64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Supported models
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ALL_MODELS: &[&str] = &["tft", "mamba2", "liquid", "tggn", "tlob", "kan", "xlstm", "diffusion"];
|
||||
|
||||
fn validate_model(name: &str) -> Result<()> {
|
||||
if name == "all" || ALL_MODELS.contains(&name) {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"Unknown model '{}'. Valid: {} or 'all'",
|
||||
name,
|
||||
ALL_MODELS.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Create a model adapter by name with production-default configs.
|
||||
///
|
||||
/// All OHLCV models receive `feature_dim` as input dimension.
|
||||
/// The adapter's internal projection layers handle mapping to model-native dims.
|
||||
fn create_model(
|
||||
name: &str,
|
||||
feature_dim: usize,
|
||||
learning_rate: f64,
|
||||
device: &Device,
|
||||
) -> Result<Box<dyn UnifiedTrainable>> {
|
||||
match name {
|
||||
"tft" => {
|
||||
let config = TFTConfig {
|
||||
num_static_features: 0,
|
||||
num_time_varying_features: feature_dim,
|
||||
hidden_size: 128,
|
||||
num_attention_heads: 4,
|
||||
num_quantiles: 3,
|
||||
dropout_rate: 0.1,
|
||||
num_encoder_steps: 60,
|
||||
..TFTConfig::default()
|
||||
};
|
||||
let mut adapter = TrainableTFT::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create TFT: {}", e))?;
|
||||
adapter.set_learning_rate(learning_rate)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set TFT learning rate: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"mamba2" => {
|
||||
let config = Mamba2Config {
|
||||
d_model: 128,
|
||||
n_layers: 4,
|
||||
state_size: 16,
|
||||
seq_len: 60,
|
||||
d_inner: 256,
|
||||
..Mamba2Config::default()
|
||||
};
|
||||
let mut adapter = Mamba2SSM::new(config, device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Mamba2: {}", e))?;
|
||||
adapter.set_learning_rate(learning_rate)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set Mamba2 learning rate: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"liquid" => {
|
||||
let config = CfCTrainConfig {
|
||||
input_size: feature_dim,
|
||||
hidden_size: 128,
|
||||
output_size: 1,
|
||||
backbone_hidden_sizes: vec![128, 64],
|
||||
learning_rate,
|
||||
device: DeviceConfig::from_device(device),
|
||||
..CfCTrainConfig::default()
|
||||
};
|
||||
let adapter = LiquidTrainableAdapter::new(config)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Liquid: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"tggn" => {
|
||||
let config = TGGNConfig {
|
||||
node_dim: feature_dim,
|
||||
hidden_dim: 32,
|
||||
num_layers: 2,
|
||||
max_nodes: 64,
|
||||
max_edges: 128,
|
||||
edge_dim: 4,
|
||||
temporal_decay: 0.99,
|
||||
update_frequency_ns: 1_000_000,
|
||||
use_simd: false,
|
||||
};
|
||||
let mut adapter = TGGNTrainableAdapter::new(config, device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create TGGN: {}", e))?;
|
||||
adapter.set_learning_rate(learning_rate)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set TGGN learning rate: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"tlob" => {
|
||||
let config = TLOBAdapterConfig {
|
||||
d_model: 128,
|
||||
num_heads: 4,
|
||||
num_layers: 2,
|
||||
seq_len: 1,
|
||||
feature_dim,
|
||||
};
|
||||
let mut adapter = TLOBTrainableAdapter::new(config, device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create TLOB: {}", e))?;
|
||||
adapter.set_learning_rate(learning_rate)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set TLOB learning rate: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"kan" => {
|
||||
let config = KANConfig {
|
||||
grid_size: 5,
|
||||
spline_order: 4,
|
||||
layer_widths: vec![feature_dim, 32, 16, 1],
|
||||
learning_rate,
|
||||
weight_decay: 1e-4,
|
||||
grad_clip: 1.0,
|
||||
};
|
||||
let adapter = KANTrainableAdapter::new(config, device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create KAN: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"xlstm" => {
|
||||
let config = XLSTMConfig {
|
||||
input_dim: feature_dim,
|
||||
hidden_dim: 128,
|
||||
..XLSTMConfig::default()
|
||||
};
|
||||
let mut adapter = XLSTMTrainableAdapter::new(config, device)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create xLSTM: {}", e))?;
|
||||
adapter.set_learning_rate(learning_rate)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to set xLSTM learning rate: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
"diffusion" => {
|
||||
let config = DiffusionConfig {
|
||||
input_dim: feature_dim,
|
||||
hidden_dim: 128,
|
||||
..DiffusionConfig::default()
|
||||
};
|
||||
let adapter = DiffusionTrainableAdapter::new(config, device.clone())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to create Diffusion: {}", e))?;
|
||||
Ok(Box::new(adapter))
|
||||
}
|
||||
_ => anyhow::bail!("Unknown model: {}", name),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data preparation (reused from KAN example pattern)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build (input, target) tensor pairs from OHLCV bars for regression training.
|
||||
///
|
||||
/// Features are extracted via `extract_ml_features` (51-dim), z-score normalized,
|
||||
/// and the target is the next-bar return in basis points, clipped.
|
||||
fn prepare_fold_data(
|
||||
train_bars: &[OHLCVBar],
|
||||
val_bars: &[OHLCVBar],
|
||||
args: &Args,
|
||||
device: &Device,
|
||||
) -> Result<(Vec<(Tensor, Tensor)>, Vec<(Tensor, Tensor)>)> {
|
||||
let train_features = extract_ml_features(train_bars)
|
||||
.context("Failed to extract training features")?;
|
||||
let val_features = extract_ml_features(val_bars)
|
||||
.context("Failed to extract validation features")?;
|
||||
|
||||
if train_features.len() < 2 {
|
||||
anyhow::bail!("Insufficient training features: {}", train_features.len());
|
||||
}
|
||||
if val_features.len() < 2 {
|
||||
anyhow::bail!("Insufficient validation features: {}", val_features.len());
|
||||
}
|
||||
|
||||
let norm_stats = NormStats::from_features(&train_features);
|
||||
let norm_train = norm_stats.normalize_batch(&train_features);
|
||||
let norm_val = norm_stats.normalize_batch(&val_features);
|
||||
|
||||
let train_bar_offset = train_bars.len().saturating_sub(train_features.len());
|
||||
let val_bar_offset = val_bars.len().saturating_sub(val_features.len());
|
||||
|
||||
let train_pairs = build_tensor_pairs(&norm_train, train_bars, train_bar_offset, args, device)?;
|
||||
let val_pairs = build_tensor_pairs(&norm_val, val_bars, val_bar_offset, args, device)?;
|
||||
|
||||
Ok((train_pairs, val_pairs))
|
||||
}
|
||||
|
||||
/// Convert normalized features + bars into (input, target) tensor pairs.
|
||||
fn build_tensor_pairs(
|
||||
norm_features: &[[f64; 51]],
|
||||
bars: &[OHLCVBar],
|
||||
bar_offset: usize,
|
||||
args: &Args,
|
||||
device: &Device,
|
||||
) -> Result<Vec<(Tensor, Tensor)>> {
|
||||
let mut pairs = Vec::new();
|
||||
let n = norm_features.len();
|
||||
let limit = n.saturating_sub(1);
|
||||
let step_limit = if args.max_steps_per_epoch > 0 {
|
||||
args.max_steps_per_epoch.min(limit)
|
||||
} else {
|
||||
limit
|
||||
};
|
||||
|
||||
for i in 0..step_limit {
|
||||
let Some(feat) = norm_features.get(i) else { continue };
|
||||
|
||||
let bar_idx = i + bar_offset;
|
||||
let close_cur = bars.get(bar_idx).map(|b| b.close).unwrap_or(0.0);
|
||||
let close_next = bars.get(bar_idx + 1).map(|b| b.close).unwrap_or(close_cur);
|
||||
|
||||
let return_bps = if close_cur.abs() > 1e-10 {
|
||||
(close_next - close_cur) / close_cur * 10_000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let max_bps = args.max_bar_return * 10_000.0;
|
||||
let clipped = return_bps.clamp(-max_bps, max_bps);
|
||||
let spread = spread_cost_bps(close_cur, args.tick_size, args.spread_ticks);
|
||||
let net_return = clipped - (args.tx_cost_bps + spread);
|
||||
|
||||
let input_f32: Vec<f32> = feat.iter().map(|&v| v as f32).collect();
|
||||
let target_f32 = vec![net_return as f32];
|
||||
|
||||
let input_tensor = Tensor::from_vec(input_f32, &[1, args.feature_dim], device)
|
||||
.context("Failed to create input tensor")?;
|
||||
let target_tensor = Tensor::from_vec(target_f32, &[1, 1], device)
|
||||
.context("Failed to create target tensor")?;
|
||||
|
||||
pairs.push((input_tensor, target_tensor));
|
||||
}
|
||||
|
||||
Ok(pairs)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Generic training loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run one training epoch on mini-batches. Returns average loss.
|
||||
fn run_training_epoch(
|
||||
adapter: &mut dyn UnifiedTrainable,
|
||||
train_pairs: &[(Tensor, Tensor)],
|
||||
batch_size: usize,
|
||||
) -> Result<f64> {
|
||||
let mut epoch_loss_sum = 0.0_f64;
|
||||
let mut epoch_steps = 0_usize;
|
||||
let n_train = train_pairs.len();
|
||||
let mut batch_start = 0_usize;
|
||||
|
||||
while batch_start < n_train {
|
||||
let batch_end = (batch_start + batch_size).min(n_train);
|
||||
let Some(batch_slice) = train_pairs.get(batch_start..batch_end) else { break };
|
||||
|
||||
let batch_inputs: Vec<&Tensor> = batch_slice.iter().map(|(inp, _)| inp).collect();
|
||||
let batch_targets: Vec<&Tensor> = batch_slice.iter().map(|(_, tgt)| tgt).collect();
|
||||
|
||||
if batch_inputs.is_empty() {
|
||||
batch_start = batch_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
let input_cat = Tensor::cat(&batch_inputs, 0)
|
||||
.context("Failed to concatenate batch inputs")?;
|
||||
let target_cat = Tensor::cat(&batch_targets, 0)
|
||||
.context("Failed to concatenate batch targets")?;
|
||||
|
||||
adapter.zero_grad()
|
||||
.map_err(|e| anyhow::anyhow!("zero_grad failed: {}", e))?;
|
||||
let predictions = adapter.forward(&input_cat)
|
||||
.map_err(|e| anyhow::anyhow!("forward failed: {}", e))?;
|
||||
let loss = adapter.compute_loss(&predictions, &target_cat)
|
||||
.map_err(|e| anyhow::anyhow!("compute_loss failed: {}", e))?;
|
||||
let loss_val = loss.to_scalar::<f32>()
|
||||
.map_err(|e| anyhow::anyhow!("loss to_scalar failed: {}", e))?;
|
||||
|
||||
adapter.backward(&loss)
|
||||
.map_err(|e| anyhow::anyhow!("backward failed: {}", e))?;
|
||||
adapter.optimizer_step()
|
||||
.map_err(|e| anyhow::anyhow!("optimizer_step failed: {}", e))?;
|
||||
|
||||
epoch_loss_sum += loss_val as f64;
|
||||
epoch_steps += 1;
|
||||
batch_start = batch_end;
|
||||
}
|
||||
|
||||
Ok(if epoch_steps > 0 { epoch_loss_sum / epoch_steps as f64 } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Train a model on a single walk-forward fold. Returns best validation loss.
|
||||
fn train_fold(
|
||||
model_name: &str,
|
||||
fold: usize,
|
||||
train_pairs: &[(Tensor, Tensor)],
|
||||
val_pairs: &[(Tensor, Tensor)],
|
||||
args: &Args,
|
||||
device: &Device,
|
||||
output_dir: &Path,
|
||||
) -> Result<f64> {
|
||||
info!("[{}] Fold {} -- {} train, {} val pairs", model_name, fold, train_pairs.len(), val_pairs.len());
|
||||
|
||||
let mut adapter = create_model(model_name, args.feature_dim, args.learning_rate, device)?;
|
||||
|
||||
let mut best_val_loss = f64::MAX;
|
||||
let mut epochs_without_improvement = 0_usize;
|
||||
|
||||
for epoch in 0..args.epochs {
|
||||
let epoch_start = Instant::now();
|
||||
|
||||
let avg_train_loss = run_training_epoch(adapter.as_mut(), train_pairs, args.batch_size)?;
|
||||
let val_loss = adapter.validate(val_pairs)
|
||||
.map_err(|e| anyhow::anyhow!("validation failed: {}", e))?;
|
||||
let elapsed = epoch_start.elapsed();
|
||||
|
||||
info!(
|
||||
" Fold {} Epoch {}/{}: train_loss={:.6}, val_loss={:.6}, lr={:.2e}, time={:.1}s",
|
||||
fold, epoch + 1, args.epochs, avg_train_loss, val_loss,
|
||||
adapter.get_learning_rate(), elapsed.as_secs_f64(),
|
||||
);
|
||||
|
||||
if val_loss < best_val_loss {
|
||||
best_val_loss = val_loss;
|
||||
epochs_without_improvement = 0;
|
||||
let ckpt_path = output_dir.join(format!("{}_fold{}_best", model_name, fold));
|
||||
let ckpt_str = ckpt_path.to_str().unwrap_or("checkpoint");
|
||||
if let Err(e) = adapter.save_checkpoint(ckpt_str) {
|
||||
warn!(" Failed to save checkpoint: {}", e);
|
||||
} else {
|
||||
info!(" [{}] New best val_loss={:.6}, checkpoint saved", model_name, val_loss);
|
||||
}
|
||||
} else {
|
||||
epochs_without_improvement += 1;
|
||||
}
|
||||
|
||||
if epochs_without_improvement >= args.patience {
|
||||
info!(" [{}] Early stopping at epoch {} (patience {} exhausted)", model_name, epoch + 1, args.patience);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final checkpoint
|
||||
let final_path = output_dir.join(format!("{}_fold{}_final", model_name, fold));
|
||||
let final_str = final_path.to_str().unwrap_or("final");
|
||||
if let Err(e) = adapter.save_checkpoint(final_str) {
|
||||
warn!(" Failed to save final checkpoint: {}", e);
|
||||
}
|
||||
|
||||
Ok(best_val_loss)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let args = Args::parse();
|
||||
validate_model(&args.model)?;
|
||||
|
||||
let models_to_train: Vec<&str> = if args.model == "all" {
|
||||
ALL_MODELS.to_vec()
|
||||
} else {
|
||||
vec![args.model.as_str()]
|
||||
};
|
||||
|
||||
// Device selection
|
||||
let device = match Device::cuda_if_available(0) {
|
||||
Ok(d) => { info!("Using CUDA device"); d }
|
||||
Err(_) => { info!("CUDA unavailable, using CPU"); Device::Cpu }
|
||||
};
|
||||
|
||||
// Load OHLCV bars
|
||||
info!("Loading OHLCV bars for {} from {}", args.symbol, args.data_dir.display());
|
||||
let all_bars = load_all_bars(&args.data_dir, &args.symbol)?;
|
||||
info!("Loaded {} bars", all_bars.len());
|
||||
|
||||
if all_bars.len() < 100 {
|
||||
anyhow::bail!("Insufficient data: {} bars (need >= 100)", all_bars.len());
|
||||
}
|
||||
|
||||
// Walk-forward windows
|
||||
let wf_config = WalkForwardConfig {
|
||||
initial_train_months: args.train_months,
|
||||
val_months: args.val_months,
|
||||
test_months: args.test_months,
|
||||
step_months: args.step_months,
|
||||
};
|
||||
let windows = generate_walk_forward_windows(&all_bars, &wf_config);
|
||||
if windows.is_empty() {
|
||||
anyhow::bail!("No walk-forward windows generated. Data too short for configured window sizes.");
|
||||
}
|
||||
info!("Generated {} walk-forward folds", windows.len());
|
||||
|
||||
// Train each model
|
||||
for model_name in &models_to_train {
|
||||
info!("=== Training model: {} ===", model_name);
|
||||
let model_output = args.output_dir.join(model_name);
|
||||
std::fs::create_dir_all(&model_output)
|
||||
.with_context(|| format!("Failed to create {}", model_output.display()))?;
|
||||
|
||||
let mut fold_results: Vec<(usize, f64)> = Vec::new();
|
||||
|
||||
for window in &windows {
|
||||
let fold = window.fold;
|
||||
info!("--- Fold {}: train={}, val={}, test={} bars ---",
|
||||
fold, window.train.len(), window.val.len(), window.test.len());
|
||||
|
||||
let (train_pairs, val_pairs) = match prepare_fold_data(
|
||||
&window.train, &window.val, &args, &device,
|
||||
) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
warn!("Skipping fold {} -- {}", fold, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if train_pairs.is_empty() || val_pairs.is_empty() {
|
||||
warn!("Skipping fold {} -- empty data after feature extraction", fold);
|
||||
continue;
|
||||
}
|
||||
|
||||
match train_fold(model_name, fold, &train_pairs, &val_pairs, &args, &device, &model_output) {
|
||||
Ok(best_val) => fold_results.push((fold, best_val)),
|
||||
Err(e) => error!("[{}] Fold {} failed: {}", model_name, fold, e),
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
info!("=== {} Results ({} folds) ===", model_name, fold_results.len());
|
||||
for (fold, loss) in &fold_results {
|
||||
info!(" Fold {}: best_val_loss = {:.6}", fold, loss);
|
||||
}
|
||||
if !fold_results.is_empty() {
|
||||
let avg: f64 = fold_results.iter().map(|(_, l)| l).sum::<f64>() / fold_results.len() as f64;
|
||||
info!(" Average: {:.6}", avg);
|
||||
}
|
||||
info!(" Checkpoints: {}", model_output.display());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Verify compilation**
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_supervised 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: compilation errors from import paths that need adjustment. This is expected — the import paths above are best-effort based on research. The implementer MUST fix any import errors by checking the actual module paths in `crates/ml/src/lib.rs` and each model's `mod.rs`.
|
||||
|
||||
Common fixes needed:
|
||||
- `ml::tft::trainable_adapter::TrainableTFT` — may need to be `ml::tft::TrainableTFT` if re-exported
|
||||
- `ml::liquid::adapter::LiquidTrainableAdapter` — may need to be `ml::liquid::LiquidTrainableAdapter`
|
||||
- `ml::gpu::DeviceConfig` — check if `from_device()` method exists, may need different construction
|
||||
- `Device::cuda_if_available(0)` — actually returns `Result<Device>`, handle properly
|
||||
- `device.clone()` for DiffusionTrainableAdapter — Device may not implement Clone, use `Device::Cpu` or re-create
|
||||
|
||||
Fix ALL compilation errors iteratively until `cargo check` passes clean.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/ml/examples/train_baseline_supervised.rs
|
||||
git commit -m "feat(ml): add train_baseline_supervised for 8 UnifiedTrainable models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add `[[example]]` entry to Cargo.toml
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ml/Cargo.toml`
|
||||
|
||||
**Step 1: Add the example entry**
|
||||
|
||||
After the `train_baseline_rl` entry, add:
|
||||
|
||||
```toml
|
||||
[[example]]
|
||||
name = "train_baseline_supervised"
|
||||
path = "examples/train_baseline_supervised.rs"
|
||||
```
|
||||
|
||||
**Step 2: Verify both examples compile**
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_rl --example train_baseline_supervised 2>&1 | tail -5
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/ml/Cargo.toml
|
||||
git commit -m "build(ml): add train_baseline_supervised example entry"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Delete 20 old training examples
|
||||
|
||||
**Files:**
|
||||
- Delete 10 unreferenced examples
|
||||
- Delete 10 production examples (now consolidated)
|
||||
|
||||
**Step 1: Delete all 20 files**
|
||||
|
||||
```bash
|
||||
# 10 unreferenced (dead code)
|
||||
rm crates/ml/examples/train_ppo_es_fut.rs
|
||||
rm crates/ml/examples/train_tft.rs
|
||||
rm crates/ml/examples/train_dqn.rs
|
||||
rm crates/ml/examples/train_dqn_production.rs
|
||||
rm crates/ml/examples/train_ppo_extended.rs
|
||||
rm crates/ml/examples/train_rainbow.rs
|
||||
rm crates/ml/examples/train_tft_parquet.rs
|
||||
rm crates/ml/examples/train_tft_qat.rs
|
||||
rm crates/ml/examples/train_mamba2_parquet.rs
|
||||
rm crates/ml/examples/train_continuous_ppo_parquet.rs
|
||||
|
||||
# 10 production examples (consolidated into baselines)
|
||||
rm crates/ml/examples/train_dqn_es_fut.rs
|
||||
rm crates/ml/examples/train_ppo_parquet.rs
|
||||
rm crates/ml/examples/train_tft_dbn.rs
|
||||
rm crates/ml/examples/train_mamba2_dbn.rs
|
||||
rm crates/ml/examples/train_liquid_dbn.rs
|
||||
rm crates/ml/examples/train_tggn_dbn.rs
|
||||
rm crates/ml/examples/train_tlob.rs
|
||||
rm crates/ml/examples/train_kan_dbn.rs
|
||||
rm crates/ml/examples/train_xlstm_dbn.rs
|
||||
rm crates/ml/examples/train_diffusion_dbn.rs
|
||||
```
|
||||
|
||||
**Step 2: Verify remaining examples compile**
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check -p ml --example train_baseline_rl --example train_baseline_supervised --example evaluate_baseline 2>&1 | tail -5
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add -u crates/ml/examples/
|
||||
git commit -m "refactor(ml): delete 20 training examples consolidated into baselines
|
||||
|
||||
Removed 10 unreferenced examples (dead code) and 10 production
|
||||
examples now handled by train_baseline_rl and train_baseline_supervised."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Update `Dockerfile.training`
|
||||
|
||||
**Files:**
|
||||
- Modify: `infra/docker/Dockerfile.training` (lines 62-83)
|
||||
|
||||
**Step 1: Replace the build command**
|
||||
|
||||
Replace the `cargo build` block (lines 62-83) with:
|
||||
|
||||
```dockerfile
|
||||
# Build training binaries with CUDA support
|
||||
RUN cargo build --release -p ml --features ml/cuda \
|
||||
--example train_baseline_rl \
|
||||
--example train_baseline_supervised \
|
||||
--example evaluate_baseline \
|
||||
--example hyperopt_dqn_demo \
|
||||
--example hyperopt_ppo_demo \
|
||||
--example hyperopt_tft_demo \
|
||||
--example hyperopt_mamba2_demo \
|
||||
&& mkdir -p /build/out \
|
||||
&& for bin in train_baseline_rl train_baseline_supervised evaluate_baseline hyperopt_dqn_demo hyperopt_ppo_demo hyperopt_tft_demo hyperopt_mamba2_demo; do \
|
||||
cp target/release/examples/${bin} /build/out/ && strip /build/out/${bin}; \
|
||||
done
|
||||
```
|
||||
|
||||
**Step 2: Update the usage comment at the top**
|
||||
|
||||
Change line 3:
|
||||
|
||||
```dockerfile
|
||||
# Run: docker run --gpus all foxhunt-training train_baseline_supervised --model kan [args...]
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/docker/Dockerfile.training
|
||||
git commit -m "build(docker): update training image for unified baselines"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update `train.sh`
|
||||
|
||||
**Files:**
|
||||
- Modify: `infra/scripts/train.sh` (lines 36-47, 128-142)
|
||||
|
||||
**Step 1: Update `MODEL_BINARY` map**
|
||||
|
||||
Replace lines 36-47:
|
||||
|
||||
```bash
|
||||
declare -A MODEL_BINARY=(
|
||||
[dqn]=train_baseline_rl
|
||||
[ppo]=train_baseline_rl
|
||||
[tft]=train_baseline_supervised
|
||||
[mamba2]=train_baseline_supervised
|
||||
[tggn]=train_baseline_supervised
|
||||
[tlob]=train_baseline_supervised
|
||||
[liquid]=train_baseline_supervised
|
||||
[kan]=train_baseline_supervised
|
||||
[xlstm]=train_baseline_supervised
|
||||
[diffusion]=train_baseline_supervised
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Update `build_args()` to pass `--model` flag**
|
||||
|
||||
Replace the `build_args()` function (lines ~127-142):
|
||||
|
||||
```bash
|
||||
build_args() {
|
||||
local model="$1"
|
||||
local binary="${MODEL_BINARY[$model]}"
|
||||
local args=("$binary")
|
||||
|
||||
# Both binaries accept --model
|
||||
args+=("--model" "$model")
|
||||
args+=("--symbol" "$SYMBOL")
|
||||
args+=("--max-steps-per-epoch" "$MAX_STEPS")
|
||||
args+=("--data-dir" "$DATA_DIR")
|
||||
args+=("--output-dir" "/output")
|
||||
|
||||
if [[ "$PRESET" == "hyperopt" ]]; then
|
||||
args+=("--trials" "$TRIALS")
|
||||
fi
|
||||
|
||||
echo "${args[*]}"
|
||||
}
|
||||
```
|
||||
|
||||
Note: `train_baseline_rl` uses `--model dqn|ppo|both`, so passing `--model dqn` works correctly.
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/scripts/train.sh
|
||||
git commit -m "infra(train.sh): route all models through unified baselines"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Update `job-template.yaml`
|
||||
|
||||
**Files:**
|
||||
- Modify: `infra/k8s/training/job-template.yaml` (lines 33, 46)
|
||||
|
||||
**Step 1: Update the comment and default value**
|
||||
|
||||
Line 33 comment:
|
||||
|
||||
```yaml
|
||||
# train_baseline_rl (for dqn, ppo)
|
||||
# train_baseline_supervised (for tft, mamba2, tggn, tlob, liquid, kan, xlstm, diffusion)
|
||||
```
|
||||
|
||||
Line 46 default value:
|
||||
|
||||
```yaml
|
||||
value: train_baseline_supervised
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add infra/k8s/training/job-template.yaml
|
||||
git commit -m "infra(k8s): update job template for unified training binaries"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Final verification
|
||||
|
||||
**Step 1: Check that all remaining examples compile**
|
||||
|
||||
```bash
|
||||
SQLX_OFFLINE=true cargo check -p ml --examples 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 0 errors. All remaining examples (train_baseline_rl, train_baseline_supervised, evaluate_baseline, cuda_test, gpu_training_benchmark, evaluate_ppo, hyperopt_*) should compile.
|
||||
|
||||
**Step 2: Verify no stale references to deleted binaries**
|
||||
|
||||
```bash
|
||||
# Search for any remaining references to old binary names
|
||||
grep -r "train_dqn_es_fut\|train_ppo_parquet\|train_tft_dbn\|train_mamba2_dbn\|train_liquid_dbn\|train_tggn_dbn\|train_kan_dbn\|train_xlstm_dbn\|train_diffusion_dbn" \
|
||||
--include="*.rs" --include="*.yaml" --include="*.yml" --include="*.sh" --include="*.toml" --include="Dockerfile*" \
|
||||
crates/ services/ infra/ bin/ .gitlab-ci.yml 2>/dev/null || echo "Clean — no stale references"
|
||||
```
|
||||
|
||||
Expected: "Clean — no stale references" (docs/plans/ may still reference old names, that's fine — they're historical).
|
||||
|
||||
**Step 3: Commit any remaining fixes**
|
||||
|
||||
If stale references found, fix them and commit.
|
||||
Reference in New Issue
Block a user