From 81e9970d4c0925cd75a6d0f87cbb58d8f0e9ebca Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 22 May 2026 01:01:18 +0200 Subject: [PATCH] =?UTF-8?q?refactor(per-horizon):=20N=5FHORIZONS=205?= =?UTF-8?q?=E2=86=923=20=E2=80=94=20alpha=5Ftrain=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames all *_h6000 references to *_h1000 (new longest horizon): - AlphaTrainSummary struct fields: best_auc_h6000_* → best_auc_h1000_*, best_h6000_ckpt_path → best_h1000_ckpt_path - State vars: best_auc_h6000*, h6000_no_improvement → h1000 equivalents - Checkpoint filename: trunk_best_h6000.bin → trunk_best_h1000.bin - CLI early-stop metric: "auc_h6000" → "auc_h1000" - Tracing log fields: w_h30..w_h6000 → w_h10/w_h100/w_h1000 - 4 hardcoded [seq.labels[0..4][k]] literals → std::array::from_fn - MultiHorizonLoaderConfig.horizons + AlphaTrainSummary.horizons: literal [30,100,300,1000,6000] → HORIZONS constant - Doc comments: "5 horizons", "h6000-snapshot" → "N_HORIZONS", "longest-horizon" KNOWN FOLLOW-UP (Task 7): 5 sweep YAMLs reference trunk_best_h6000.bin filename — must update atomically when applying workflow template changes. KNOWN FOLLOW-UP (Task 8): crates/ml-alpha/src/gpu_log.rs:176-192 still decodes 5-horizon JSON fields (raw_h30..h6000, ema_h30..h6000, etc.). cargo check --example alpha_train: PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ml-alpha/examples/alpha_train.rs | 164 +++++++++++------------- 1 file changed, 72 insertions(+), 92 deletions(-) diff --git a/crates/ml-alpha/examples/alpha_train.rs b/crates/ml-alpha/examples/alpha_train.rs index 575aacafa..5a01f26e8 100644 --- a/crates/ml-alpha/examples/alpha_train.rs +++ b/crates/ml-alpha/examples/alpha_train.rs @@ -26,7 +26,7 @@ use clap::Parser; use ml_alpha::cfc::snap_features::Mbp10RawInput; use ml_alpha::data::loader::{MultiHorizonLoader, MultiHorizonLoaderConfig}; use ml_alpha::eval::auc::{compute_auc, AucInput}; -use ml_alpha::heads::N_HORIZONS; +use ml_alpha::heads::{HORIZONS, N_HORIZONS}; use ml_alpha::trainer::perception::{auto_horizon_weights, PerceptionTrainer, PerceptionTrainerConfig}; use ml_core::device::MlDevice; use serde::Serialize; @@ -52,12 +52,11 @@ struct Cli { /// Snapshots per training sequence (Mamba2 + CfC in-window context). /// Default 64 — empirically the 3-fold ISV CV ran K=32 and observed - /// h6000 (6000-snapshot horizon) saturating around 0.70-0.74. K=32 - /// is only 0.5% of the h6000 prediction window; K=64 doubles - /// in-window context and gives Mamba2's SSM state more material - /// to build long-horizon predictions from. Kernel maximum is 96 - /// (MAMBA2_KERNEL_SEQ_MAX). Per-epoch wall scales ~K (more - /// K-loop launches in the captured graph). + /// the longest-horizon AUC saturating around 0.70-0.74. K=64 doubles + /// in-window context relative to K=32 and gives Mamba2's SSM state + /// more material to build long-horizon predictions from. Kernel + /// maximum is 96 (MAMBA2_KERNEL_SEQ_MAX). Per-epoch wall scales ~K + /// (more K-loop launches in the captured graph). #[arg(long, default_value_t = 64)] seq_len: usize, @@ -96,26 +95,24 @@ struct Cli { /// Which metric drives early stopping. Options: /// - "val_loss" : stable, tracks probability calibration. - /// - "mean_auc" : noisier, average ranking across all 5 horizons. - /// - "auc_h6000" : ranking on the long horizon ONLY — recommended + /// - "mean_auc" : noisier, average ranking across all N_HORIZONS horizons. + /// - "auc_h1000" : ranking on the long horizon ONLY — recommended /// for deployment-oriented runs where the saved checkpoint is /// used to trade at multi-minute horizon. The 3-fold ISV CV - /// showed mean_auc-best-epoch and h6000-best-epoch can differ - /// by 2-6pt on h6000 within a single run (e.g. fblb2 fold-1 - /// saved h6000=0.681 at mean_auc-best E10 but the trajectory - /// peaked at h6000=0.739 one epoch later). Using auc_h6000 - /// directly saves the deployment-relevant checkpoint. + /// showed mean_auc-best-epoch and long-horizon-best-epoch can differ + /// by 2-6pt within a single run. Using auc_h1000 directly saves + /// the deployment-relevant checkpoint. #[arg(long, default_value = "mean_auc")] early_stop_metric: String, - /// Custom per-horizon BCE weights (5 floats, comma-separated). When - /// set, overrides the auto/uniform defaults. Example: "1.0,1.0,0.5,0.1,0.02". + /// Custom per-horizon BCE weights (N_HORIZONS floats, comma-separated). + /// When set, overrides the auto/uniform defaults. Example: "1.0,1.0,0.5". #[arg(long)] horizon_weights: Option, /// Use the canonical auto-derived per-horizon BCE weight schedule. - /// Currently identical to `--horizon-weights 1,1,1,1,1` (uniform): - /// the prior `min(1, K/h)` formula down-weighted h6000 to 0.5% of + /// Currently identical to uniform weights: the prior `min(1, K/h)` + /// formula down-weighted the longest horizon to a tiny fraction of /// the loss which suppressed long-horizon training. With uniform /// weights, ISV's lambda controller (per-horizon trunk-gradient /// scaler driven by BCE EMA) is the single place where per-horizon @@ -201,7 +198,7 @@ struct AlphaTrainSummary { best_val_loss: f32, /// Per-horizon AUCs at the best-val-loss epoch. best_val_auc: [f32; N_HORIZONS], - /// Epoch index that achieved the highest mean AUC across the 5 horizons. + /// Epoch index that achieved the highest mean AUC across the N_HORIZONS horizons. /// Tracked independently of best_epoch because val_loss + AUC can /// disagree — a calibration-flat / ranking-sharp epoch beats a /// calibration-sharp / ranking-flat one for downstream trading. @@ -210,22 +207,22 @@ struct AlphaTrainSummary { best_mean_auc: f32, /// Per-horizon AUCs at the best-mean-AUC epoch. best_mean_auc_per_horizon: [f32; N_HORIZONS], - /// Epoch with the highest h6000 AUC (long-horizon, deployment-relevant). + /// Epoch with the highest long-horizon AUC (deployment-relevant). /// Saved independently of best_mean_auc_epoch because the two - /// frequently disagree by 1-2 epochs and h6000 is the metric we - /// care about for multi-minute trading deployment. - best_auc_h6000_epoch: usize, - /// Highest h6000 AUC observed across all epochs. - best_auc_h6000: f32, - /// Per-horizon AUCs at the best-h6000 epoch. - best_auc_h6000_per_horizon: [f32; N_HORIZONS], + /// frequently disagree by 1-2 epochs and the long horizon is the + /// metric we care about for multi-minute trading deployment. + best_auc_h1000_epoch: usize, + /// Highest long-horizon AUC observed across all epochs. + best_auc_h1000: f32, + /// Per-horizon AUCs at the best-long-horizon epoch. + best_auc_h1000_per_horizon: [f32; N_HORIZONS], /// True if training stopped early (patience exceeded). early_stopped: bool, - /// Relative path (within `out_dir`) to the best-h6000 trunk - /// checkpoint file, or None if no h6000 improvement was ever recorded. - /// Written by the X14 wiring inside the `auc_h6000_improved` block. + /// Relative path (within `out_dir`) to the best-long-horizon trunk + /// checkpoint file, or None if no long-horizon improvement was ever recorded. + /// Written by the X14 wiring inside the `auc_h1000_improved` block. #[serde(default, skip_serializing_if = "Option::is_none")] - best_h6000_ckpt_path: Option, + best_h1000_ckpt_path: Option, /// Per-horizon raw mean-squared adjacent-prob diff at the final epoch /// (CRT.train intervention A telemetry). All zeros when /// `--smoothness-base-lambda` is unset (default). @@ -260,8 +257,6 @@ fn main() -> Result<()> { let dev = MlDevice::cuda(0).context("CUDA 0 init")?; tracing::info!(?dev, "MlDevice initialized"); - let horizons = [30usize, 100, 300, 1000, 6000]; - // Resolve per-horizon BCE weights: explicit > auto > uniform. let horizon_weights: [f32; N_HORIZONS] = if let Some(spec) = &cli.horizon_weights { let parts: Vec = spec.split(',') @@ -276,14 +271,14 @@ fn main() -> Result<()> { w.copy_from_slice(&parts); w } else if cli.auto_horizon_weights { - auto_horizon_weights(cli.seq_len, &horizons) + auto_horizon_weights(cli.seq_len, &HORIZONS) } else { [1.0; N_HORIZONS] }; tracing::info!( - w_h30 = horizon_weights[0], w_h100 = horizon_weights[1], - w_h300 = horizon_weights[2], w_h1000 = horizon_weights[3], - w_h6000 = horizon_weights[4], + w_h10 = horizon_weights[0], + w_h100 = horizon_weights[1], + w_h1000 = horizon_weights[2], "per-horizon BCE weights" ); @@ -311,8 +306,8 @@ fn main() -> Result<()> { // Validate early-stop metric choice upfront. let early_stop_metric = cli.early_stop_metric.as_str(); anyhow::ensure!( - matches!(early_stop_metric, "val_loss" | "mean_auc" | "auc_h6000" | "none"), - "--early-stop-metric must be one of: val_loss, mean_auc, auc_h6000, none (got `{}`)", + matches!(early_stop_metric, "val_loss" | "mean_auc" | "auc_h1000" | "none"), + "--early-stop-metric must be one of: val_loss, mean_auc, auc_h1000, none (got `{}`)", early_stop_metric ); @@ -334,17 +329,15 @@ fn main() -> Result<()> { let mut best_mean_auc_epoch = 0usize; let mut best_mean_auc_per_horizon = [0.5_f32; N_HORIZONS]; - // Independent tracker for best h6000 AUC — the deployment-relevant - // long-horizon metric. The 3-fold ISV CV showed that mean_auc-best - // and h6000-best epochs frequently differ (within fblb2 fold-1, the - // saved E10 had h6000=0.681 while E11 had h6000=0.739). For - // multi-minute trading deployment we want the h6000-best checkpoint - // directly, not the mean_auc proxy. - let mut best_auc_h6000 = f32::NEG_INFINITY; - let mut best_auc_h6000_epoch = 0usize; - let mut best_auc_h6000_per_horizon = [0.5_f32; N_HORIZONS]; - let mut best_h6000_ckpt_path: Option = None; - let mut auc_h6000_no_improvement = 0usize; + // Independent tracker for best long-horizon AUC — the deployment-relevant + // metric. The 3-fold ISV CV showed that mean_auc-best and long-horizon-best + // epochs frequently differ. For multi-minute trading deployment we want + // the long-horizon-best checkpoint directly, not the mean_auc proxy. + let mut best_auc_h1000 = f32::NEG_INFINITY; + let mut best_auc_h1000_epoch = 0usize; + let mut best_auc_h1000_per_horizon = [0.5_f32; N_HORIZONS]; + let mut best_h1000_ckpt_path: Option = None; + let mut auc_h1000_no_improvement = 0usize; let lr_min_cfc = cli.lr_cfc * cli.lr_min_factor; let lr_min_m2 = cli.lr_mamba2 * cli.lr_min_factor; @@ -425,7 +418,7 @@ fn main() -> Result<()> { files: train_files, predecoded_dir: cli.predecoded_dir.clone(), seq_len: cli.seq_len, - horizons, + horizons: HORIZONS, n_max_sequences: cli.n_train_seqs, seed: cli.seed, inference_only: false, @@ -435,7 +428,7 @@ fn main() -> Result<()> { files: val_files, predecoded_dir: cli.predecoded_dir.clone(), seq_len: cli.seq_len, - horizons, + horizons: HORIZONS, n_max_sequences: cli.n_val_seqs, seed: cli.seed.wrapping_add(0xC0FFEE), inference_only: false, @@ -462,10 +455,7 @@ fn main() -> Result<()> { let mut labels_per_pos: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(seq.snapshots.len()); let mut any_finite = false; for k in 0..seq.snapshots.len() { - let row = [ - seq.labels[0][k], seq.labels[1][k], seq.labels[2][k], - seq.labels[3][k], seq.labels[4][k], - ]; + let row: [f32; N_HORIZONS] = std::array::from_fn(|h| seq.labels[h][k]); if row.iter().any(|v| v.is_finite()) { any_finite = true; } labels_per_pos.push(row); } @@ -522,19 +512,13 @@ fn main() -> Result<()> { let mut labels_per_pos: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(seq.snapshots.len()); let mut any_finite = false; for k in 0..seq.snapshots.len() { - let row = [ - seq.labels[0][k], seq.labels[1][k], seq.labels[2][k], - seq.labels[3][k], seq.labels[4][k], - ]; + let row: [f32; N_HORIZONS] = std::array::from_fn(|h| seq.labels[h][k]); if row.iter().any(|v| v.is_finite()) { any_finite = true; } labels_per_pos.push(row); } if !any_finite { continue; } let last = seq.snapshots.len().saturating_sub(1); - let last_labels = [ - seq.labels[0][last], seq.labels[1][last], seq.labels[2][last], - seq.labels[3][last], seq.labels[4][last], - ]; + let last_labels: [f32; N_HORIZONS] = std::array::from_fn(|h| seq.labels[h][last]); val_snap_batch.push(seq.snapshots); val_label_batch.push(labels_per_pos); val_last_label_batch.push(last_labels); @@ -574,11 +558,9 @@ fn main() -> Result<()> { tracing::info!( epoch, val_loss = val_avg, - auc_h30 = per_horizon_auc[0], + auc_h10 = per_horizon_auc[0], auc_h100 = per_horizon_auc[1], - auc_h300 = per_horizon_auc[2], - auc_h1000 = per_horizon_auc[3], - auc_h6000 = per_horizon_auc[4], + auc_h1000 = per_horizon_auc[2], "validation" ); @@ -591,10 +573,8 @@ fn main() -> Result<()> { match (trainer.loss_ema_snapshot(), trainer.lambda_snapshot()) { (Ok(ema), Ok(lam)) => tracing::info!( epoch, - ema_h30 = ema[0], ema_h100 = ema[1], ema_h300 = ema[2], - ema_h1000 = ema[3], ema_h6000 = ema[4], - lam_h30 = lam[0], lam_h100 = lam[1], lam_h300 = lam[2], - lam_h1000 = lam[3], lam_h6000 = lam[4], + ema_h10 = ema[0], ema_h100 = ema[1], ema_h1000 = ema[2], + lam_h10 = lam[0], lam_h100 = lam[1], lam_h1000 = lam[2], "isv snapshot" ), (Err(e), _) | (_, Err(e)) => { @@ -630,26 +610,26 @@ fn main() -> Result<()> { mean_auc_no_improvement += 1; } - // h6000 — deployment-relevant long-horizon ranking. - let auc_h6000 = per_horizon_auc[N_HORIZONS - 1]; - let auc_h6000_improved = auc_h6000 > best_auc_h6000; - if auc_h6000_improved { - best_auc_h6000 = auc_h6000; - best_auc_h6000_epoch = epoch; - best_auc_h6000_per_horizon = per_horizon_auc; - auc_h6000_no_improvement = 0; + // h1000 — deployment-relevant long-horizon ranking. + let auc_h1000 = per_horizon_auc[N_HORIZONS - 1]; + let auc_h1000_improved = auc_h1000 > best_auc_h1000; + if auc_h1000_improved { + best_auc_h1000 = auc_h1000; + best_auc_h1000_epoch = epoch; + best_auc_h1000_per_horizon = per_horizon_auc; + auc_h1000_no_improvement = 0; // X14: emit a Checkpoint file alongside summary.json so // fxt-backtest --checkpoint can load the trained trunk. - let ckpt_path = cli.out.join("trunk_best_h6000.bin"); + let ckpt_path = cli.out.join("trunk_best_h1000.bin"); trainer.save_checkpoint(&ckpt_path) - .context("save_checkpoint(trunk_best_h6000.bin)")?; - best_h6000_ckpt_path = Some("trunk_best_h6000.bin".to_string()); + .context("save_checkpoint(trunk_best_h1000.bin)")?; + best_h1000_ckpt_path = Some("trunk_best_h1000.bin".to_string()); tracing::info!( - epoch, auc_h6000, path = %ckpt_path.display(), - "new best auc_h6000 (saved checkpoint)" + epoch, auc_h1000, path = %ckpt_path.display(), + "new best auc_h1000 (saved checkpoint)" ); } else { - auc_h6000_no_improvement += 1; + auc_h1000_no_improvement += 1; } // Early-stop decision based on selected metric. @@ -657,7 +637,7 @@ fn main() -> Result<()> { let (counter, metric_label, best_val) = match early_stop_metric { "val_loss" => (val_loss_no_improvement, "val_loss", best_val_loss), "mean_auc" => (mean_auc_no_improvement, "mean_auc", best_mean_auc), - "auc_h6000" => (auc_h6000_no_improvement, "auc_h6000", best_auc_h6000), + "auc_h1000" => (auc_h1000_no_improvement, "auc_h1000", best_auc_h1000), _ => (0, "none", 0.0), // disabled }; if counter >= cli.early_stop_patience && metric_label != "none" { @@ -675,7 +655,7 @@ fn main() -> Result<()> { let summary = AlphaTrainSummary { epochs: epochs_completed, seq_len: cli.seq_len, - horizons, + horizons: HORIZONS, final_train_loss: if train_steps > 0 { train_loss_running / train_steps as f32 } else { 0.0 }, final_val_loss, final_val_auc, @@ -687,11 +667,11 @@ fn main() -> Result<()> { best_mean_auc_epoch, best_mean_auc, best_mean_auc_per_horizon, - best_auc_h6000_epoch, - best_auc_h6000, - best_auc_h6000_per_horizon, + best_auc_h1000_epoch, + best_auc_h1000, + best_auc_h1000_per_horizon, early_stopped, - best_h6000_ckpt_path, + best_h1000_ckpt_path, final_smooth_loss_per_horizon: trainer.last_smoothness_loss_per_horizon(), }; let summary_path = cli.out.join("alpha_train_summary.json");