Foundation for replacing the static `--auto-horizon-weights` formula (`min(1, K/h)`) with a signal-driven per-horizon gradient scaler. Per `feedback_isv_for_adaptive_bounds.md`: adaptive bounds live in ISV, not hardcoded constants. Per `pearl_adam_normalizes_loss_weights.md`: Adam normalizes per-loss weight lifts (SP13 saw 13× aux_w produce only 0.6%/epoch divergence), so the effective lever is scaling the GRADIENT into the shared trunk, not the BCE coefficient. This commit sets up the EMA + lambda infrastructure; Phase 3 (wiring lambda into heads_bwd to actually scale the trunk gradient) is gated on the 3-fold CV results fromeb51c0f9c. Phase 1 — BCE kernel emits per-horizon UNWEIGHTED mean BCE: cuda/bce_loss_multi_horizon.cu: - New output buffer `loss_per_horizon[N_HORIZONS=5]`. - Per-horizon shared-mem accumulators (sloss_h, svalid_h) with block tree-reduce — no atomicAdd, per `feedback_no_atomicadd.md`. - Hardcoded N_HORIZONS_BCE=5; total shared-mem usage ~13 KiB (comfortable under any SM smem limit). - The aggregate `loss_out` is still the externally-weighted mean callers use for reporting; the new buffer is the UNWEIGHTED signal an EMA layer needs. Phase 2 — EMA + lambda kernel: cuda/horizon_lambda.cu (new): - Single-thread kernel (5 horizons, fixed-size loop — trivial). - First-observation bootstrap via sentinel = 0 per `pearl_first_observation_bootstrap.md`; replaces directly when `loss_ema_h <= 0` (safer than `== 0` under --use_fast_math). - Fixed α = 0.1 EMA for now; Wiener-optimal α follow-up flagged (`pearl_wiener_optimal_adaptive_alpha.md`). - lambda_h = clamp(loss_ema_h / mean(loss_ema), 0.5, 2.0). - Ratio gives natural "under-trained → boost" signal. - Clamp prevents winner-take-all per `pearl_controller_amplifies_dominant_magnitude_trap.md` and bounded-modifier safety per `pearl_audit_unboundedness_for_implicit_asymmetry.md`. Trainer wiring (trainer/perception.rs): - 3 new fields: `loss_per_horizon_d`, `loss_ema_d`, `lambda_d` (all 5-element f32 CudaSlices; pre-allocated, zero-initialised). - Cached `horizon_lambda_fn` + module handle per the `BiasKernels`-style pattern (no per-call cuModuleLoadData). - BCE callsite (train + eval paths) updated to pass `loss_per_horizon_d`. - `dispatch_train_step` launches `horizon_ema_and_lambda` right after BCE, BEFORE the K-loop backward. Inside the captured graph; per-step launch overhead is ~1 µs. - `loss_ema_snapshot()` + `lambda_snapshot()` test-only accessors (mapped-pinned readback, not for hot path) for diagnostics. Smoke test — `horizon_ema_and_lambda_track_after_training`: - Verifies pre-step EMA + lambda are zero (sentinel). - After 5 training steps: loss_ema = [0.59, 0.66, 0.45, 0.62, 0.50] — finite + positive. lambda = [1.05, 1.16, 0.80, 1.10, 0.89] — mean ≈ 1.0, all inside the [0.5, 2.0] clamp envelope. - Lower per-horizon BCE → lower lambda (de-emphasize); higher BCE → higher lambda (boost). Exactly the ISV semantics we want. Validation: 6 perception_overfit tests pass, synthetic overfit still shrinks (0.33 → 0.0006), 26 ml-alpha lib + 23 integration tests green. lambda_d is computed every step but NOT YET CONSUMED by heads_bwd; training behavior is bit-identical to3a196382f. Phase 3 (consume lambda_d in heads_bwd_batched to scale the per-horizon gradient into the trunk) follows once CV confirms the foundation is stable. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
75 lines
3.5 KiB
Plaintext
75 lines
3.5 KiB
Plaintext
// horizon_lambda.cu — ISV-driven per-horizon gradient scaler.
|
||
//
|
||
// Step 1: maintain an EMA of the UNWEIGHTED per-horizon BCE loss that
|
||
// the BCE kernel emits each training step into
|
||
// `loss_per_horizon[N_HORIZONS]`.
|
||
// Step 2: convert the EMA into a per-horizon multiplicative lambda
|
||
// used by the backward path to scale how strongly each
|
||
// horizon influences the shared trunk gradient.
|
||
//
|
||
// Why ISV: the current static `auto-horizon-weights` formula
|
||
// (`min(1, K/h)`) is a closed-form heuristic that ignores actual
|
||
// per-horizon learning difficulty. Empirically mhzs7 spent most of
|
||
// training over-weighting short horizons (whose label correlation
|
||
// within the K-snapshot window dominates the gradient signal) while
|
||
// h6000 — the deployment-relevant multi-minute horizon — stayed at
|
||
// AUC≈0.69. Tracking per-horizon BCE directly lets the lambda boost
|
||
// the horizons that the model is currently failing to learn, without
|
||
// hand-tuned constants.
|
||
//
|
||
// Why not just lift BCE coefficients: per
|
||
// `pearl_adam_normalizes_loss_weights.md`, Adam's m/sqrt(v) cancels
|
||
// per-loss weight lifts (SP13: 13× aux_w produced only 0.6%/epoch
|
||
// divergence). The effective lever is to scale the GRADIENT into the
|
||
// shared trunk, not the loss aggregate. heads_bwd will multiply the
|
||
// per-horizon `d_z` contribution by lambda[h] before accumulating
|
||
// into `grad_h`, bypassing Adam normalization.
|
||
//
|
||
// First-observation bootstrap: loss_ema is zero-initialised; the
|
||
// kernel detects `prev <= 0` and replaces (rather than blends) on
|
||
// the first step. After that it uses a fixed α — Wiener-optimal α is
|
||
// a Phase 3 follow-up; for now a conservative 0.1 keeps the EMA
|
||
// stable across training noise.
|
||
//
|
||
// Lambda safety: clamped to `[LAMBDA_FLOOR, LAMBDA_CEILING]` per
|
||
// `pearl_audit_unboundedness_for_implicit_asymmetry.md` — the ratio
|
||
// loss_ema_h / mean_loss_ema is unbounded above when one horizon
|
||
// stalls. Capping at 2× prevents winner-take-all amplification per
|
||
// `pearl_controller_amplifies_dominant_magnitude_trap.md`.
|
||
|
||
#define N_HORIZONS_LAMBDA 5
|
||
#define ALPHA_FIXED 0.1f
|
||
#define LAMBDA_FLOOR 0.5f
|
||
#define LAMBDA_CEILING 2.0f
|
||
|
||
extern "C" __global__ void horizon_ema_and_lambda(
|
||
const float* __restrict__ loss_per_horizon, // [5] — current step UNWEIGHTED BCE
|
||
float* __restrict__ loss_ema, // [5] — EMA state (read + write)
|
||
float* __restrict__ lambda // [5] — output multiplier
|
||
) {
|
||
if (threadIdx.x != 0 || blockIdx.x != 0) return;
|
||
|
||
float new_ema[N_HORIZONS_LAMBDA];
|
||
float sum = 0.0f;
|
||
#pragma unroll
|
||
for (int h = 0; h < N_HORIZONS_LAMBDA; ++h) {
|
||
const float cur = loss_per_horizon[h];
|
||
const float prev = loss_ema[h];
|
||
// Sentinel = 0 ⇒ first observation replaces directly.
|
||
// `prev <= 0` is safer than `== 0` under --use_fast_math.
|
||
new_ema[h] = (prev <= 0.0f) ? cur : (prev + ALPHA_FIXED * (cur - prev));
|
||
loss_ema[h] = new_ema[h];
|
||
sum += new_ema[h];
|
||
}
|
||
|
||
const float mean = sum / (float)N_HORIZONS_LAMBDA;
|
||
// mean > 1e-12 is essentially always true once the first step
|
||
// has run (BCE for a random init is ~0.69), but guard anyway.
|
||
const float inv_mean = (mean > 1e-12f) ? (1.0f / mean) : 0.0f;
|
||
#pragma unroll
|
||
for (int h = 0; h < N_HORIZONS_LAMBDA; ++h) {
|
||
const float ratio = (inv_mean > 0.0f) ? new_ema[h] * inv_mean : 1.0f;
|
||
lambda[h] = fmaxf(LAMBDA_FLOOR, fminf(LAMBDA_CEILING, ratio));
|
||
}
|
||
}
|