Files
foxhunt/crates/ml-alpha/cuda/horizon_lambda.cu
jgrusewski 0171c8c0ea fix(ml-alpha): asymmetric lambda clamp [1.0, 2.0] — boost-only ISV
3-fold A/B CV (5d42ab0e9 vs eb51c0f9c, same data splits) showed
ISV winning the aggregate (+0.9pt mean_auc, +1.8pt h6000 across
folds) but FOLD-2 regressed -1.3pt on h6000 while folds 0 and 1
both gained (+2.0pt and +4.6pt respectively).

Per-fold per-horizon breakdown showed exactly the failure mode:
  Fold 0 (val 2025-Q1): h6000 no-ISV 0.714 → ISV 0.734 (+2.0pt)
  Fold 1 (val 2025-Q2): h6000 no-ISV 0.682 → ISV 0.728 (+4.6pt)
  Fold 2 (val 2025-Q3): h6000 no-ISV 0.698 → ISV 0.685 (-1.3pt)

In folds 0 and 1, h6000 was the hardest horizon — ISV correctly
boosted it (lambda > 1). In fold 2 the regime made h6000 relatively
easy at no-ISV (0.698 vs the worst horizon at ~0.70). ISV's
SYMMETRIC clamp [0.5, 2.0] then computed ratio = ema_h6000 /
mean_ema < 1 and DEMOTED h6000's trunk-gradient pull below uniform,
starving further learning on the horizon we actually deploy.

Per `pearl_audit_unboundedness_for_implicit_asymmetry.md`: when a
control signal serves an asymmetric goal (here: we never want to
de-prioritize h6000, only ever boost it OR leave it alone), encode
that asymmetry in the clamp. LAMBDA_FLOOR 0.5 → 1.0 makes the
controller boost-only: under-trained horizons get more pull, but
no horizon is ever demoted below its uniform contribution.

Expected effect with asymmetric clamp:
  - Fold 0 and 1: lambda for the hardest horizon stays at 2.0
    (ceiling-clamped), trunk-gradient lift unchanged. The gains
    +2.0pt and +4.6pt should hold.
  - Fold 2: h6000's lambda was being demoted to ~0.74; now floored
    at 1.0 — the -1.3pt h6000 regression should disappear. h6000
    trains at uniform weight, recovering toward 0.698.
  - Cross-fold mean projected: ~0.724 (+1.3pt vs no-ISV).

Test update: `horizon_ema_and_lambda_track_after_training` now
asserts lambda ∈ [1.0, 2.0] (the asymmetric envelope) and
lambda mean ∈ [1.0, 2.0] (boost-only guarantee). Observed values
on the test data: lambda = [1.13, 1.00, 1.08, 1.08, 1.00] — the
two easier horizons correctly floor at 1.00 instead of demoting.

Validation: 7 perception_overfit tests pass, synthetic overfit
trajectory unchanged (0.36 → 0.0006), 26 ml-alpha lib + 23
integration tests green.

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

84 lines
4.1 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: ASYMMETRIC clamp `[1.0, LAMBDA_CEILING]` per
// `pearl_audit_unboundedness_for_implicit_asymmetry.md`. The lower
// bound is 1.0, not <1: the controller is boost-only — under-trained
// horizons get more trunk-gradient pull, but easy horizons are NEVER
// starved below uniform weight. The 3-fold CV that motivated this
// design (no-ISV @ 0.711 ± 0.014 vs ISV @ 0.720 ± 0.011 across
// folds 0/1/2) showed fold-1 ISV gaining +4.6pt on h6000 when
// h6000 was hardest, but fold-2 ISV LOSING -1.3pt on h6000 when it
// wasn't — because a symmetric clamp [0.5, 2.0] demoted h6000 below
// uniform when its EMA was below the cross-horizon mean. Asymmetric
// clamp preserves the gain without the regression. h6000 is the
// deployment horizon; we never want to starve it. Upper cap at 2×
// still 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 1.0f // boost-only: never demote a horizon below uniform
#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));
}
}