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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user