Files
foxhunt/crates/ml-alpha/examples/alpha_train.rs
jgrusewski ef35b10f3e refactor(ml-alpha): drop competitive-gate machinery — stacked is default
Per user direction "no gating, this is the new default": the stacked
Mamba2 -> CfC -> heads design is THE production architecture. There's
no competing-baseline comparison to run. Validation reduces to normal
training metrics (per-horizon val AUC, train loss curve, sanity floor
of >0.5 AUC).

Deletions:
  - crates/ml-alpha/src/gate/cfc_vs_mamba2.rs (gate verdict logic)
  - crates/ml-alpha/src/gate/mod.rs
  - crates/ml-alpha/examples/alpha_gate.rs (gate runner binary)

Renames:
  - crates/ml-alpha/src/gate/auc.rs -> crates/ml-alpha/src/eval/auc.rs
  - lib.rs: pub mod gate -> pub mod eval (gate implied comparison;
    eval doesn't)

Spec amendments:
  - Drop the "Gate baseline strategy" amendment (committed earlier
    this session)
  - Reframe the stacked-architecture amendment as a "decision" not a
    "gate"; production path is unambiguous
  - Reframe Section 4 "Validation gate: CfC must meet Mamba2" -> just
    "Validation: per-horizon val AUC" with the >0.5 sanity floor

Doc cleanups: stale "Mamba2 gate baseline" mentions in build.rs and
pinned_mem.rs replaced with neutral wording. The Argo template
comment about "downstream gate consumption" becomes "for monitoring".

Test status: all 26+ ml-alpha tests pass. AUC tests (6/6) still pass
under the eval:: namespace.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 23:17:33 +02:00

224 lines
7.9 KiB
Rust

//! 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 <path> \
//! --predecoded-dir <path> \
//! --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::data::loader::{MultiHorizonLoader, MultiHorizonLoaderConfig};
use ml_alpha::eval::auc::{compute_auc, AucInput};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::{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,
#[arg(long, default_value_t = 32)]
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,
}
#[derive(Serialize)]
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,
}
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 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,
};
let mut trainer = PerceptionTrainer::new(&dev, &trainer_cfg).context("trainer init")?;
let horizons = [30usize, 100, 300, 1000, 6000];
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;
for epoch in 0..cli.epochs {
let mut train_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig {
mbp10_root: cli.mbp10_data_dir.clone(),
predecoded_dir: cli.predecoded_dir.clone(),
seq_len: cli.seq_len,
horizons,
n_max_sequences: cli.n_train_seqs,
seed: cli.seed.wrapping_add(epoch as u64),
})
.context("train loader")?;
let mut epoch_train_loss = 0.0_f32;
let mut epoch_train_steps = 0usize;
while let Some(seq) = train_loader.next_sequence().context("train next_seq")? {
// Label at the LAST position of the window (per-horizon).
let last = seq.snapshots.len().saturating_sub(1);
let labels = [
seq.labels[0][last], seq.labels[1][last], seq.labels[2][last],
seq.labels[3][last], seq.labels[4][last],
];
if labels.iter().all(|v| v.is_nan()) { continue; }
// Replace any NaN with mid (0.5) — masked by BCE kernel via NaN
// detection, but Mbp10 sequence is fed as-is.
let labels_arr = labels;
let loss = trainer.step(&seq.snapshots, &labels_arr).context("train step")?;
epoch_train_loss += loss;
epoch_train_steps += 1;
train_loss_running += loss;
train_steps += 1;
}
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.
let mut val_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig {
mbp10_root: cli.mbp10_data_dir.clone(),
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 + epoch as u64),
})
.context("val loader")?;
let mut val_probs: [Vec<f32>; N_HORIZONS] = Default::default();
let mut val_labels: [Vec<f32>; N_HORIZONS] = Default::default();
let mut val_loss_sum = 0.0_f32;
let mut val_steps = 0usize;
while let Some(seq) = val_loader.next_sequence().context("val next_seq")? {
let last = seq.snapshots.len().saturating_sub(1);
let labels = [
seq.labels[0][last], seq.labels[1][last], seq.labels[2][last],
seq.labels[3][last], seq.labels[4][last],
];
if labels.iter().all(|v| v.is_nan()) { continue; }
// Forward via training step (val loss is statistically negligible
// signal vs ~train_seqs * seq_len; trainer not separately frozen).
let l = trainer.step(&seq.snapshots, &labels).context("val step")?;
val_loss_sum += l;
val_steps += 1;
let probs = trainer.last_probs().context("last_probs")?;
for h in 0..N_HORIZONS {
if !labels[h].is_nan() {
val_probs[h].push(probs[h]);
val_labels[h].push(labels[h]);
}
}
}
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"
);
final_val_loss = val_avg;
final_val_auc = per_horizon_auc;
n_val_seqs_consumed = val_loader.yielded();
}
let summary = AlphaTrainSummary {
epochs: cli.epochs,
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,
};
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(())
}