refactor(per-horizon): N_HORIZONS 5→3 — alpha_train example

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 01:01:18 +02:00
parent 1e8188c947
commit 81e9970d4c

View File

@@ -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<String>,
/// 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<String>,
best_h1000_ckpt_path: Option<String>,
/// 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<f32> = 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<String> = 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<String> = 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");