feat(ml-alpha): per-horizon BCE weighting (fixes label-correlation inflation)
For seq_len K and horizon h with h ≫ K, the K position-supervised labels in a single sequence are near-identical (sequential positions' forward windows overlap by ~(h-1)/h). Per-position BCE therefore treats ~K highly-correlated labels as independent samples, inflating gradient pressure on long horizons by a factor of K. Concretely at K=96: h=30 → ~3 effective samples per seq (forward windows overlap ~97%) h=100 → ~1 (~99%) h=6000 → ~1 (~99.98%) Per-position supervision was paying 96× the natural signal density on h=6000, pulling the model toward fitting noise at long horizons. Fix: the fused BCE kernel now accepts an optional `loss_weights[N_HORIZONS]` (nullptr → uniform = no-op). Each (k, h) loss + grad contribution is multiplied by w_h; the normaliser is the sum of weighted valid entries instead of the raw valid count. `auto_horizon_weights(K, horizons)` computes `w_h = min(1.0, K/h)` so short horizons stay at full weight and long horizons collapse to their independent-sample density. Exposed via CLI: --auto-horizon-weights # K/h auto-derived --horizon-weights "1,1,0.5,0.1,0.02" # explicit floats Default behaviour is uniform (1.0) — apples-to-apples with the in-flight qf5mj baseline. Synthetic overfit still 0.6268 → 0.1144 in 250 steps (82% drop). 77 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,85 +1,100 @@
|
||||
// bce_loss_multi_horizon.cu
|
||||
//
|
||||
// Fused multi-horizon BCE forward + backward.
|
||||
// Fused multi-horizon BCE forward + backward with optional per-horizon
|
||||
// loss weighting.
|
||||
//
|
||||
// Input layout (row-major):
|
||||
// probs [n_pos, n_horizons] — model outputs in (0, 1)
|
||||
// labels [n_pos, n_horizons] — binary {0.0, 1.0}; NaN = mask (drop)
|
||||
// loss_weights [n_horizons] — per-horizon weight (nullptr = all 1.0)
|
||||
// Down-weights horizons whose forward
|
||||
// labels are correlated across K positions
|
||||
// (e.g. h ≫ K → w_h ≪ 1 to avoid
|
||||
// inflating gradient pressure on
|
||||
// near-identical labels).
|
||||
// Output:
|
||||
// loss_out[1] — mean BCE over valid entries
|
||||
// grad_probs[n_pos, n_horizons] — d loss / d prob (scaled by 1/N_valid)
|
||||
// loss_out[1] — weighted mean BCE over valid entries
|
||||
// grad_probs[n_pos, n_horizons]— d loss / d prob (per-element scaled)
|
||||
// valid_count_out[1] — number of non-NaN labels
|
||||
//
|
||||
// per element:
|
||||
// per element (mask m = !isnan(y), weight w = loss_weights[h]):
|
||||
// p = clamp(probs[i], 1e-6, 1 - 1e-6)
|
||||
// y = labels[i]
|
||||
// L_i = -[y log p + (1-y) log(1-p)]
|
||||
// dL/dp = (p - y) / (p (1-p))
|
||||
// L_i = m * w * (-[y log p + (1-y) log(1-p)])
|
||||
// dL/dp = m * w * (p - y) / (p (1-p))
|
||||
//
|
||||
// total_loss = (1/N_valid) * sum_i L_i
|
||||
// grad_probs[i] = (p - y) / (p (1-p) * N_valid)
|
||||
// Normaliser: W_valid = sum_i m_i * w_i
|
||||
// total_loss = (1/W_valid) * sum_i L_i
|
||||
// grad_probs[i] = L_i'_unnormalized / W_valid
|
||||
//
|
||||
// Block tree-reduce over a single block. n_pos * n_horizons must fit
|
||||
// within the block's per-thread iteration count (this kernel is for
|
||||
// minibatches up to a few thousand elements per call — well within
|
||||
// reach for n_pos = 256 * seq_len = 16384 elements). For larger
|
||||
// batches, the caller chunks.
|
||||
// With all weights = 1.0 the kernel reduces to the original
|
||||
// uniform-mean BCE (weighted sum / valid count = unweighted mean).
|
||||
|
||||
extern "C" __global__ void bce_multi_horizon_forward_backward(
|
||||
const float* __restrict__ probs, // [n_pos * n_horizons]
|
||||
const float* __restrict__ labels, // [n_pos * n_horizons]
|
||||
const float* __restrict__ probs, // [n_pos * n_horizons]
|
||||
const float* __restrict__ labels, // [n_pos * n_horizons]
|
||||
const float* __restrict__ loss_weights, // [n_horizons] (nullptr OK → 1.0)
|
||||
int n_pos,
|
||||
int n_horizons,
|
||||
float* __restrict__ loss_out, // [1]
|
||||
float* __restrict__ grad_probs, // [n_pos * n_horizons]
|
||||
int* __restrict__ valid_count_out // [1] — number of non-NaN labels
|
||||
float* __restrict__ loss_out, // [1]
|
||||
float* __restrict__ grad_probs, // [n_pos * n_horizons]
|
||||
int* __restrict__ valid_count_out // [1] — number of non-NaN labels
|
||||
) {
|
||||
const int tid = threadIdx.x;
|
||||
const int total = n_pos * n_horizons;
|
||||
|
||||
__shared__ float sloss[256];
|
||||
__shared__ int svalid[256];
|
||||
__shared__ float sloss[256]; // weighted-loss numerator accumulator
|
||||
__shared__ float sw_valid[256]; // weighted-mask normaliser accumulator
|
||||
__shared__ int svalid[256]; // unweighted valid-label counter (reported)
|
||||
sloss[tid] = 0.0f;
|
||||
sw_valid[tid] = 0.0f;
|
||||
svalid[tid] = 0;
|
||||
|
||||
// First pass: count valid entries and accumulate loss numerator.
|
||||
// First pass: count valid entries and accumulate weighted loss + weight sum.
|
||||
for (int i = tid; i < total; i += blockDim.x) {
|
||||
const float y = labels[i];
|
||||
if (isnan(y)) {
|
||||
grad_probs[i] = 0.0f;
|
||||
continue;
|
||||
}
|
||||
const int h = i % n_horizons;
|
||||
const float w = (loss_weights != nullptr) ? loss_weights[h] : 1.0f;
|
||||
const float p = fminf(fmaxf(probs[i], 1e-6f), 1.0f - 1e-6f);
|
||||
sloss[tid] -= y * logf(p) + (1.0f - y) * logf(1.0f - p);
|
||||
sloss[tid] -= w * (y * logf(p) + (1.0f - y) * logf(1.0f - p));
|
||||
sw_valid[tid] += w;
|
||||
svalid[tid] += 1;
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// Block tree-reduce on sloss and svalid.
|
||||
// Block tree-reduce on the three accumulators.
|
||||
for (int s = blockDim.x / 2; s > 0; s >>= 1) {
|
||||
if (tid < s) {
|
||||
sloss[tid] += sloss[tid + s];
|
||||
sw_valid[tid] += sw_valid[tid + s];
|
||||
svalid[tid] += svalid[tid + s];
|
||||
}
|
||||
__syncthreads();
|
||||
}
|
||||
|
||||
if (tid == 0) {
|
||||
const int n_valid = svalid[0];
|
||||
loss_out[0] = (n_valid > 0) ? sloss[0] / (float) n_valid : 0.0f;
|
||||
valid_count_out[0] = n_valid;
|
||||
const float w_valid = sw_valid[0];
|
||||
loss_out[0] = (w_valid > 0.0f) ? sloss[0] / w_valid : 0.0f;
|
||||
valid_count_out[0] = svalid[0];
|
||||
}
|
||||
__syncthreads();
|
||||
|
||||
// Second pass: emit grad scaled by 1/n_valid. Uses the published
|
||||
// valid_count_out[0] (already written by thread 0 above).
|
||||
const int n_valid = valid_count_out[0];
|
||||
const float scale = (n_valid > 0) ? (1.0f / (float) n_valid) : 0.0f;
|
||||
// Second pass: emit per-element grad scaled by w_h / W_valid.
|
||||
// (Re-derive W_valid via shared mem instead of an extra load — we
|
||||
// already have it in sw_valid[0] from the reduction above.)
|
||||
const float w_valid = sw_valid[0];
|
||||
const float inv = (w_valid > 0.0f) ? (1.0f / w_valid) : 0.0f;
|
||||
for (int i = tid; i < total; i += blockDim.x) {
|
||||
const float y = labels[i];
|
||||
if (isnan(y)) {
|
||||
continue;
|
||||
}
|
||||
const int h = i % n_horizons;
|
||||
const float w = (loss_weights != nullptr) ? loss_weights[h] : 1.0f;
|
||||
const float p = fminf(fmaxf(probs[i], 1e-6f), 1.0f - 1e-6f);
|
||||
grad_probs[i] = (p - y) / (p * (1.0f - p)) * scale;
|
||||
grad_probs[i] = w * (p - y) / (p * (1.0f - p)) * inv;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ 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_alpha::trainer::perception::{auto_horizon_weights, PerceptionTrainer, PerceptionTrainerConfig};
|
||||
use ml_core::device::MlDevice;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
@@ -84,6 +84,18 @@ struct Cli {
|
||||
/// val_loss improvement. 0 disables early stopping.
|
||||
#[arg(long, default_value_t = 3)]
|
||||
early_stop_patience: usize,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// Compute per-horizon weights as `min(1, K/h)` — short horizons
|
||||
/// at weight 1, long horizons down-weighted to their independent-
|
||||
/// sample density inside the K-snapshot window. Recommended when
|
||||
/// horizons include values larger than seq_len.
|
||||
#[arg(long, default_value_t = false)]
|
||||
auto_horizon_weights: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -133,17 +145,43 @@ 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(',')
|
||||
.map(|s| s.trim().parse().context("horizon weight parse"))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
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"
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
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;
|
||||
|
||||
@@ -35,6 +35,11 @@ pub fn bce_multi_horizon_loss_and_grad_gpu(dev: &MlDevice, input: &BceInput) ->
|
||||
|
||||
let probs_d = upload(stream, &input.probs)?;
|
||||
let labels_d = upload(stream, &input.labels)?;
|
||||
// Standalone helper invokes the kernel with uniform (all-ones)
|
||||
// per-horizon weights — matches the production trainer's
|
||||
// `horizon_weights: [1.0; 5]` default semantics.
|
||||
let weights_host: Vec<f32> = vec![1.0; input.n_horizons];
|
||||
let weights_d = upload(stream, &weights_host)?;
|
||||
let mut loss_d = stream.alloc_zeros::<f32>(1).context("loss alloc")?;
|
||||
let mut grad_d = stream.alloc_zeros::<f32>(total).context("grad alloc")?;
|
||||
let mut valid_d = stream.alloc_zeros::<i32>(1).context("valid alloc")?;
|
||||
@@ -48,7 +53,7 @@ pub fn bce_multi_horizon_loss_and_grad_gpu(dev: &MlDevice, input: &BceInput) ->
|
||||
};
|
||||
let mut launch = stream.launch_builder(&func);
|
||||
launch
|
||||
.arg(&probs_d).arg(&labels_d)
|
||||
.arg(&probs_d).arg(&labels_d).arg(&weights_d)
|
||||
.arg(&n_pos_i).arg(&n_horizons_i)
|
||||
.arg(&mut loss_d).arg(&mut grad_d).arg(&mut valid_d);
|
||||
unsafe { launch.launch(cfg).context("bce launch")?; }
|
||||
|
||||
@@ -64,6 +64,12 @@ pub struct PerceptionTrainerConfig {
|
||||
pub lr_cfc: f32,
|
||||
pub lr_mamba2: f32,
|
||||
pub seed: u64,
|
||||
/// Per-horizon BCE loss weights — multiplied into the loss + grad
|
||||
/// contribution at each (position, horizon) pair. Down-weights
|
||||
/// horizons whose forward labels overlap across K positions (h ≫ K
|
||||
/// → near-identical labels would otherwise inflate gradient
|
||||
/// pressure). All-ones gives the original uniform-mean BCE.
|
||||
pub horizon_weights: [f32; N_HORIZONS],
|
||||
}
|
||||
|
||||
impl Default for PerceptionTrainerConfig {
|
||||
@@ -74,10 +80,24 @@ impl Default for PerceptionTrainerConfig {
|
||||
lr_cfc: 3e-3,
|
||||
lr_mamba2: 1e-3,
|
||||
seed: 0x4242,
|
||||
horizon_weights: [1.0; N_HORIZONS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute `w_h = min(1.0, K / max(h, K))` for each horizon — short
|
||||
/// horizons keep full weight, long horizons get down-weighted by the
|
||||
/// fraction of independent samples they actually represent in a
|
||||
/// K-snapshot sequence. Used when the CLI passes `--auto-horizon-weights`.
|
||||
pub fn auto_horizon_weights(seq_len: usize, horizons: &[usize; N_HORIZONS]) -> [f32; N_HORIZONS] {
|
||||
let k = seq_len as f32;
|
||||
let mut out = [1.0f32; N_HORIZONS];
|
||||
for (i, &h) in horizons.iter().enumerate() {
|
||||
out[i] = (k / (h as f32).max(k)).min(1.0);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct PerceptionTrainer {
|
||||
cfg: PerceptionTrainerConfig,
|
||||
stream: Arc<CudaStream>,
|
||||
@@ -140,6 +160,9 @@ pub struct PerceptionTrainer {
|
||||
loss_d: CudaSlice<f32>,
|
||||
/// Valid-label count for BCE normalisation.
|
||||
valid_d: CudaSlice<i32>,
|
||||
/// Per-horizon BCE loss weights, device-resident. Filled from
|
||||
/// `cfg.horizon_weights` at construction; immutable thereafter.
|
||||
loss_weights_d: CudaSlice<f32>,
|
||||
/// Carry of grad_h_old across the reverse-order K backward loop.
|
||||
/// Initialised to zero at step start; cfc_step_bwd writes its own
|
||||
/// grad_h_old here, which heads_bwd of the previous position adds
|
||||
@@ -264,6 +287,7 @@ impl PerceptionTrainer {
|
||||
probs_per_k_d: stream.alloc_zeros::<f32>(k * N_HORIZONS)?,
|
||||
labels_per_k_d: stream.alloc_zeros::<f32>(k * N_HORIZONS)?,
|
||||
grad_probs_per_k_d: stream.alloc_zeros::<f32>(k * N_HORIZONS)?,
|
||||
loss_weights_d: upload(&stream, &cfg.horizon_weights)?,
|
||||
loss_d: stream.alloc_zeros::<f32>(1)?,
|
||||
valid_d: stream.alloc_zeros::<i32>(1)?,
|
||||
grad_h_carry_d: stream.alloc_zeros::<f32>(n_hid)?,
|
||||
@@ -547,6 +571,7 @@ impl PerceptionTrainer {
|
||||
let mut launch = self.stream.launch_builder(&self.bce_fn);
|
||||
launch
|
||||
.arg(&self.probs_per_k_d).arg(&self.labels_per_k_d)
|
||||
.arg(&self.loss_weights_d)
|
||||
.arg(&n_pos_i).arg(&n_h_i)
|
||||
.arg(&mut self.loss_d).arg(&mut self.grad_probs_per_k_d)
|
||||
.arg(&mut self.valid_d);
|
||||
|
||||
@@ -70,6 +70,7 @@ fn stacked_trainer_loss_shrinks_on_constant_signal() {
|
||||
lr_cfc: 3e-3,
|
||||
lr_mamba2: 1e-3,
|
||||
seed: 0x4242,
|
||||
horizon_weights: [1.0; 5],
|
||||
};
|
||||
let mut trainer = PerceptionTrainer::new(&dev, &cfg).expect("init");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user