//! Stacked Mamba2 -> CfC -> heads trainer CLI. //! //! Reads predecoded MBP-10 sidecars via `MultiHorizonLoader`, drives //! `PerceptionTrainer` end-to-end. Each training step consumes ONE //! seq_len-snapshot window and emits one BCE loss against the labels //! at the window's last position (per-horizon). //! //! Emits `alpha_train_summary.json` on exit for downstream gate //! consumption. //! //! Usage: //! alpha_train \ //! --mbp10-data-dir \ //! --predecoded-dir \ //! --epochs 5 \ //! --seq-len 32 \ //! --mamba2-state-dim 16 \ //! --lr-cfc 3e-3 \ //! --lr-mamba2 1e-3 \ //! --n-train-seqs 8000 \ //! --n-val-seqs 1000 \ //! --out artifacts/alpha-train/ use anyhow::{Context, Result}; 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::trainer::perception::{auto_horizon_weights, PerceptionTrainer, PerceptionTrainerConfig}; use ml_core::device::MlDevice; use serde::Serialize; use std::path::PathBuf; #[derive(Parser)] #[command(name = "alpha_train")] struct Cli { /// Directory containing MBP-10 .dbn.zst files. #[arg(long)] mbp10_data_dir: PathBuf, /// Directory holding predecoded sidecar caches (created on miss). #[arg(long)] predecoded_dir: PathBuf, /// Output directory for weights + summary JSON. #[arg(long)] out: PathBuf, #[arg(long, default_value_t = 5)] epochs: usize, /// 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). #[arg(long, default_value_t = 64)] seq_len: usize, #[arg(long, default_value_t = 16)] mamba2_state_dim: usize, #[arg(long, default_value_t = 3e-3)] lr_cfc: f32, #[arg(long, default_value_t = 1e-3)] lr_mamba2: f32, #[arg(long, default_value_t = 8000)] n_train_seqs: usize, #[arg(long, default_value_t = 1000)] n_val_seqs: usize, #[arg(long, default_value_t = 0x4242)] seed: u64, /// Linear LR warmup over this many training steps from 0 → configured /// lr_cfc / lr_mamba2. 0 disables warmup. #[arg(long, default_value_t = 200)] lr_warmup_steps: usize, /// Floor for the cosine-decay schedule: final LR = lr_max * this factor. /// 1.0 disables decay (constant LR after warmup). #[arg(long, default_value_t = 0.1)] lr_min_factor: f32, /// Early-stop training after this many consecutive epochs without /// improvement on `early_stop_metric`. 0 disables early stopping. #[arg(long, default_value_t = 5)] early_stop_patience: usize, /// 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 /// 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. #[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". #[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 /// 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 /// rebalancing happens — cleaner than splitting between BCE /// coefficients and the trunk lambda. Flag preserved for backward /// compat; passing `--horizon-weights` still overrides. #[arg(long, default_value_t = false)] auto_horizon_weights: bool, /// Mini-batch size for training. Each step processes B sequences /// in parallel via batched CUDA kernels. Default 1 matches the /// previous unbatched behavior. Validation always runs at B=1. #[arg(long, default_value_t = 1)] batch_size: usize, /// Sliding-window cross-validation: index of THIS fold (0-indexed). /// Combined with `--cv-n-folds` and `--cv-train-window` to split /// the discovered MBP-10 files chronologically. Default 0 means /// "no CV — use the first fold of a 1-fold split" (legacy behavior: /// train = all-but-last file, val = last file). #[arg(long, default_value_t = 0)] cv_fold: usize, /// Total number of sliding-window CV folds. 1 disables CV (single /// train/val split). When >1, fold k trains on the W files starting /// at `cv_fold_offset(k)` and validates on the file immediately /// after that window. Folds are submitted as independent runs. #[arg(long, default_value_t = 1)] cv_n_folds: usize, /// Size of each fold's TRAIN window in files. 0 means "use all /// files except the val file" (expanding-window mode at 1 fold). /// >0 enables sliding-window mode where the training data has a /// fixed temporal extent. #[arg(long, default_value_t = 0)] cv_train_window: usize, /// Decision-stride sampling: yield every S-th snapshot per training /// sequence. S=1 (default) preserves current consecutive-snapshot /// behaviour. S>1 expands the effective time-window covered by each /// sequence (window = (seq_len-1) * S + 1 snapshots) at the same /// compute cost. Pairs with Mamba2 dt_s scaling so the SSM's state /// decay reflects the actual elapsed time between K-positions. /// Recommended S=4 with K=64 for h6000 deployment (256-tick window). #[arg(long, default_value_t = 1)] decision_stride: usize, } #[derive(Serialize, serde::Deserialize, Default)] struct AlphaTrainSummary { epochs: usize, seq_len: usize, horizons: [usize; N_HORIZONS], final_train_loss: f32, final_val_loss: f32, final_val_auc: [f32; N_HORIZONS], n_train_seqs_consumed: usize, n_val_seqs_consumed: usize, /// Epoch index that achieved the lowest val_loss. best_epoch: usize, /// Lowest val_loss observed across all epochs. 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. /// 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. best_mean_auc_epoch: usize, /// Mean AUC at the best-mean-AUC epoch. 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). /// 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], /// 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. #[serde(default, skip_serializing_if = "Option::is_none")] best_h6000_ckpt_path: Option, } /// Linear warmup then cosine decay to `lr_min`. `step_idx` is the /// global training-step counter (0-indexed). `total_steps` is the /// budget the cosine targets. fn lr_schedule(lr_max: f32, lr_min: f32, step_idx: usize, warmup_steps: usize, total_steps: usize) -> f32 { if step_idx < warmup_steps { return lr_max * (step_idx as f32 + 1.0) / warmup_steps as f32; } if total_steps <= warmup_steps { return lr_max; } let progress = (step_idx - warmup_steps) as f32 / (total_steps - warmup_steps) as f32; let progress = progress.clamp(0.0, 1.0); let cos = 0.5 * (1.0 + (std::f32::consts::PI * progress).cos()); lr_min + (lr_max - lr_min) * cos } fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); std::fs::create_dir_all(&cli.out).with_context(|| format!("mkdir {}", cli.out.display()))?; 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(',') .map(|s| s.trim().parse().context("horizon weight parse")) .collect::>>()?; anyhow::ensure!( parts.len() == N_HORIZONS, "--horizon-weights expected {} floats, got {}", N_HORIZONS, parts.len() ); let mut w = [0.0; N_HORIZONS]; w.copy_from_slice(&parts); w } else if cli.auto_horizon_weights { 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], "per-horizon BCE weights" ); anyhow::ensure!(cli.batch_size >= 1, "batch_size must be >= 1"); let trainer_cfg = PerceptionTrainerConfig { seq_len: cli.seq_len, mamba2_state_dim: cli.mamba2_state_dim, lr_cfc: cli.lr_cfc, lr_mamba2: cli.lr_mamba2, seed: cli.seed, horizon_weights, n_batch: cli.batch_size, decision_stride: cli.decision_stride, }; let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?; let mut train_loss_running = 0.0_f32; let mut train_steps = 0usize; let mut final_val_loss = 0.0_f32; let mut final_val_auc = [0.5_f32; N_HORIZONS]; let mut n_val_seqs_consumed = 0usize; // 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 `{}`)", early_stop_metric ); // Best-checkpoint tracking (by val_loss). let mut best_val_loss = f32::INFINITY; let mut best_epoch = 0usize; let mut best_val_auc = [0.5_f32; N_HORIZONS]; let mut val_loss_no_improvement = 0usize; let mut mean_auc_no_improvement = 0usize; let mut early_stopped = false; let mut epochs_completed = 0usize; // Independent tracker for best mean-AUC across horizons. val_loss and // ranking quality (AUC) can disagree — e.g. when the model improves // probability calibration on common cases (lower BCE) but loses // ranking sharpness on the regime tails. For downstream trading the // AUC profile usually matters more, so we publish both bests. let mut best_mean_auc = f32::NEG_INFINITY; 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; let lr_min_cfc = cli.lr_cfc * cli.lr_min_factor; let lr_min_m2 = cli.lr_mamba2 * cli.lr_min_factor; // Approximate the total step budget: epochs × n_train_seqs (each // sequence yields one optimizer step). Used by cosine decay. let total_steps_budget = cli.epochs * cli.n_train_seqs; // ── Walk-forward CV file split ──────────────────────────────────── // Files are discovered in chronological order (filenames follow // ES.FUT_-Q.dbn.zst so sort = chronological). Then we // slice train_files / val_files based on the CV flags. Train and // val NEVER share a file — every val sample is strictly OOS in // time vs every train sample. let all_files = ml_alpha::data::loader::discover_mbp10_files_sorted(&cli.mbp10_data_dir) .context("discover MBP-10 files")?; anyhow::ensure!( cli.cv_n_folds >= 1, "--cv-n-folds must be >= 1 (got {})", cli.cv_n_folds ); anyhow::ensure!( cli.cv_fold < cli.cv_n_folds, "--cv-fold {} must be < --cv-n-folds {}", cli.cv_fold, cli.cv_n_folds ); let n_files = all_files.len(); let (train_files, val_files): (Vec, Vec) = if cli.cv_n_folds == 1 { // Single fold: train on all files except the LAST, val on the LAST file. // Legacy callers that didn't think about CV at all get a temporal split // by default instead of the old "same files for train and val" bug. anyhow::ensure!(n_files >= 2, "need >= 2 files for single-fold temporal split, found {} under {}", n_files, cli.mbp10_data_dir.display()); let val_idx = n_files - 1; ( all_files[..val_idx].to_vec(), vec![all_files[val_idx].clone()], ) } else { // Sliding-window CV. Train window has fixed extent `W`; val is the // single file immediately after. Fold k uses files [k .. k+W] for // train and file [k+W] for val. let w = if cli.cv_train_window > 0 { cli.cv_train_window } else { // Default: leave room for n_folds val files at the tail. n_files.saturating_sub(cli.cv_n_folds) }; anyhow::ensure!(w >= 1, "cv_train_window resolves to 0 (n_files={n_files}, n_folds={})", cli.cv_n_folds); anyhow::ensure!( cli.cv_fold + w + 1 <= n_files, "CV layout overflows: cv_fold={} + window={} + 1 val file > {} files available", cli.cv_fold, w, n_files ); let train_start = cli.cv_fold; let train_end = train_start + w; // exclusive let val_idx = train_end; ( all_files[train_start..train_end].to_vec(), vec![all_files[val_idx].clone()], ) }; tracing::info!( cv_fold = cli.cv_fold, cv_n_folds = cli.cv_n_folds, cv_train_window = cli.cv_train_window, n_train_files = train_files.len(), n_val_files = val_files.len(), train_files = ?train_files.iter().map(|p| p.file_name().unwrap().to_string_lossy().to_string()).collect::>(), val_files = ?val_files.iter().map(|p| p.file_name().unwrap().to_string_lossy().to_string()).collect::>(), "walk-forward CV file split", ); // PRELOAD: construct loaders ONCE (each preloads its files into RAM). // Per-epoch we call reset(seed) to re-shuffle anchor sampling — no // disk IO. Cuts wall time ~5× on a 6-epoch run, ~7× on 15 epochs. tracing::info!("preloading train + val MBP-10 files into RAM (slow startup, fast per-epoch)…"); let preload_start = std::time::Instant::now(); let mut train_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig { files: train_files, predecoded_dir: cli.predecoded_dir.clone(), seq_len: cli.seq_len, horizons, n_max_sequences: cli.n_train_seqs, seed: cli.seed, decision_stride: cli.decision_stride, inference_only: false, }) .context("train loader")?; let mut val_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig { files: val_files, predecoded_dir: cli.predecoded_dir.clone(), seq_len: cli.seq_len, horizons, n_max_sequences: cli.n_val_seqs, seed: cli.seed.wrapping_add(0xC0FFEE), decision_stride: cli.decision_stride, inference_only: false, }) .context("val loader")?; tracing::info!( elapsed_s = preload_start.elapsed().as_secs(), "preload complete", ); for epoch in 0..cli.epochs { // Re-seed loaders for this epoch (anchor sampling differs per epoch). train_loader.reset(cli.seed.wrapping_add(epoch as u64)); val_loader.reset(cli.seed.wrapping_add(0xC0FFEE + epoch as u64)); let mut epoch_train_loss = 0.0_f32; let mut epoch_train_steps = 0usize; // Accumulate B sequences per optimizer step. Sequences with no // finite labels at any position are skipped (continue) so they // don't pollute the batch. let mut snap_batch: Vec> = Vec::with_capacity(cli.batch_size); let mut label_batch: Vec> = Vec::with_capacity(cli.batch_size); while let Some(seq) = train_loader.next_sequence().context("train next_seq")? { 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], ]; if row.iter().any(|v| v.is_finite()) { any_finite = true; } labels_per_pos.push(row); } if !any_finite { continue; } snap_batch.push(seq.snapshots); label_batch.push(labels_per_pos); if snap_batch.len() < cli.batch_size { continue; } // Full batch ready — apply LR schedule, fire step. let lr_cfc_now = lr_schedule(cli.lr_cfc, lr_min_cfc, train_steps, cli.lr_warmup_steps, total_steps_budget); let lr_m2_now = lr_schedule(cli.lr_mamba2, lr_min_m2, train_steps, cli.lr_warmup_steps, total_steps_budget); trainer.set_lr_cfc(lr_cfc_now); trainer.set_lr_mamba2(lr_m2_now); let snap_refs: Vec<&[Mbp10RawInput]> = snap_batch.iter().map(|v| v.as_slice()).collect(); let label_refs: Vec<&[[f32; N_HORIZONS]]> = label_batch.iter().map(|v| v.as_slice()).collect(); let loss = trainer.step_batched(&snap_refs, &label_refs).context("train step_batched")?; epoch_train_loss += loss; epoch_train_steps += 1; train_loss_running += loss; train_steps += 1; // Periodic per-step log (every 500 steps) for liveness + // intra-epoch trajectory monitoring. Cheap (~1 line/min on // L40S at 8000 steps/epoch / 36s = 220 steps/s ⇒ ~16 lines // per 500-step interval per epoch). Consumed by // /tmp/alpha_monitor.py. if epoch_train_steps % 500 == 0 { tracing::info!( epoch, step = epoch_train_steps, loss = loss, "step" ); } snap_batch.clear(); label_batch.clear(); } let epoch_avg = if epoch_train_steps > 0 { epoch_train_loss / epoch_train_steps as f32 } else { 0.0 }; tracing::info!(epoch, train_loss = epoch_avg, train_steps = epoch_train_steps, "epoch complete"); // Validation: accumulate (probs, labels) per horizon, compute AUC. // val_loader was preloaded above and reset at top of loop. let mut val_probs: [Vec; N_HORIZONS] = Default::default(); let mut val_labels: [Vec; N_HORIZONS] = Default::default(); let mut val_loss_sum = 0.0_f32; let mut val_steps = 0usize; let mut val_snap_batch: Vec> = Vec::with_capacity(cli.batch_size); let mut val_label_batch: Vec> = Vec::with_capacity(cli.batch_size); let mut val_last_label_batch: Vec<[f32; N_HORIZONS]> = Vec::with_capacity(cli.batch_size); while let Some(seq) = val_loader.next_sequence().context("val next_seq")? { 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], ]; 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], ]; val_snap_batch.push(seq.snapshots); val_label_batch.push(labels_per_pos); val_last_label_batch.push(last_labels); if val_snap_batch.len() < cli.batch_size { continue; } // FORWARD-ONLY batched evaluation — no backward, no AdamW. let snap_refs: Vec<&[Mbp10RawInput]> = val_snap_batch.iter().map(|v| v.as_slice()).collect(); let label_refs: Vec<&[[f32; N_HORIZONS]]> = val_label_batch.iter().map(|v| v.as_slice()).collect(); let (l, probs_all) = trainer.evaluate_batched(&snap_refs, &label_refs).context("val eval_batched")?; val_loss_sum += l; val_steps += 1; // probs_all is [K, B, N_HORIZONS] row-major. Score AUC from // the LAST-position predictions for each sample in the batch. let last = cli.seq_len - 1; for (b_idx, last_lbl) in val_last_label_batch.iter().enumerate() { let off = (last * cli.batch_size + b_idx) * N_HORIZONS; let last_probs = &probs_all[off..off + N_HORIZONS]; for h in 0..N_HORIZONS { if last_lbl[h].is_finite() { val_probs[h].push(last_probs[h]); val_labels[h].push(last_lbl[h]); } } } val_snap_batch.clear(); val_label_batch.clear(); val_last_label_batch.clear(); } let mut per_horizon_auc = [0.5_f32; N_HORIZONS]; for h in 0..N_HORIZONS { per_horizon_auc[h] = compute_auc(&AucInput { probs: val_probs[h].clone(), labels: val_labels[h].clone(), }).context("AUC")?; } let val_avg = if val_steps > 0 { val_loss_sum / val_steps as f32 } else { 0.0 }; tracing::info!( epoch, val_loss = val_avg, auc_h30 = 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], "validation" ); // Snapshot the ISV controller's per-horizon BCE EMA + lambda // multipliers — diagnostic on what the horizon-aware gradient // scaler is doing each epoch. Reads happen via mapped-pinned // (not in the training hot path; we're already mid-validation // sync window). Surfaces fold-specific lambda trajectories so // we can diagnose why some folds gain and others regress. 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], "isv snapshot" ), (Err(e), _) | (_, Err(e)) => { tracing::warn!(epoch, error = %e, "isv snapshot failed (continuing)"); } } final_val_loss = val_avg; final_val_auc = per_horizon_auc; n_val_seqs_consumed = val_loader.yielded(); epochs_completed = epoch + 1; // Best-checkpoint trackers — independent for each metric. let val_loss_improved = val_avg < best_val_loss; if val_loss_improved { best_val_loss = val_avg; best_epoch = epoch; best_val_auc = per_horizon_auc; val_loss_no_improvement = 0; tracing::info!(epoch, val_loss = val_avg, "new best val_loss"); } else { val_loss_no_improvement += 1; } let mean_auc = per_horizon_auc.iter().sum::() / per_horizon_auc.len() as f32; let mean_auc_improved = mean_auc > best_mean_auc; if mean_auc_improved { best_mean_auc = mean_auc; best_mean_auc_epoch = epoch; best_mean_auc_per_horizon = per_horizon_auc; mean_auc_no_improvement = 0; tracing::info!(epoch, mean_auc, "new best mean_auc"); } else { 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; // 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"); trainer.save_checkpoint(&ckpt_path) .context("save_checkpoint(trunk_best_h6000.bin)")?; best_h6000_ckpt_path = Some("trunk_best_h6000.bin".to_string()); tracing::info!( epoch, auc_h6000, path = %ckpt_path.display(), "new best auc_h6000 (saved checkpoint)" ); } else { auc_h6000_no_improvement += 1; } // Early-stop decision based on selected metric. if cli.early_stop_patience > 0 { 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), _ => (0, "none", 0.0), // disabled }; if counter >= cli.early_stop_patience && metric_label != "none" { tracing::warn!( epoch, metric = metric_label, best = best_val, patience = cli.early_stop_patience, "early stopping", ); early_stopped = true; break; } } } let summary = AlphaTrainSummary { epochs: epochs_completed, seq_len: cli.seq_len, horizons, final_train_loss: if train_steps > 0 { train_loss_running / train_steps as f32 } else { 0.0 }, final_val_loss, final_val_auc, n_train_seqs_consumed: train_steps, n_val_seqs_consumed, best_epoch, best_val_loss, best_val_auc, best_mean_auc_epoch, best_mean_auc, best_mean_auc_per_horizon, best_auc_h6000_epoch, best_auc_h6000, best_auc_h6000_per_horizon, early_stopped, best_h6000_ckpt_path, }; let summary_path = cli.out.join("alpha_train_summary.json"); std::fs::write(&summary_path, serde_json::to_vec_pretty(&summary).context("serialize summary")?) .with_context(|| format!("write {}", summary_path.display()))?; tracing::info!(summary_path = %summary_path.display(), "wrote summary"); Ok(()) }