feat(rl): KL-augmented reward aligns Q and π + cleanup dead code

Add KL bonus to reward: r += 0.02 × log(π_ref(a_taken)). This makes
Q learn the same KL-regularized objective as π, aligning their action
rankings (qpa positive when both value Hold). Reward written back to
rewards_d so PER replay also sees the augmented signal.

Decoupled from the adaptive gradient β: reward uses fixed 0.02,
gradient uses adaptive β from the hold_frac controller. Prevents
compounding that caused Hold=100% when both used the same β.

Removed dead code: normalize_advantages kernel (#if 0 block),
RL_HOLD_PRIOR_INDEX references.

Results at reward_kl_β=0.02: Hold 25-88% (oscillating around target),
entropy 1.19-1.45 (healthy), wr=0.35, qpa oscillates ±0.4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-27 09:20:25 +02:00
parent ee6bb6b7e8
commit 7894585ff4

View File

@@ -23,12 +23,12 @@
// reduction, no atomics. Per `feedback_no_atomicadd` not needed.
#define RL_GAMMA_INDEX 400
#define RL_HOLD_PRIOR_INDEX 574
#define N_ACTIONS 11
#define HOLD_ACTION 2
extern "C" __global__ void compute_advantage_return(
const float* __restrict__ isv, // ISV bus
const float* __restrict__ rewards, // [b_size]
float* __restrict__ rewards, // [b_size] IN/OUT (augmented in place)
const float* __restrict__ dones, // [b_size] 0.0 / 1.0
const float* __restrict__ v_t, // [b_size] V(s_t)
const float* __restrict__ v_tp1, // [b_size] V(s_{t+1})
@@ -41,112 +41,35 @@ extern "C" __global__ void compute_advantage_return(
if (b >= b_size) return;
const float gamma = isv[RL_GAMMA_INDEX];
const float r = rewards[b];
const float r_env = rewards[b];
const float done = dones[b];
const float vt = v_t[b];
const float vtp1 = v_tp1[b];
// KL-augmented reward: r = r_env + β × log(π_ref(a_taken))
// This makes Q and V learn the SAME augmented objective as π,
// aligning their action rankings (positive qpa). Hold gets a
// bonus because π_ref(Hold) = 0.5 → log(0.5) = -0.69, while
// trading actions get π_ref = 0.05 → log(0.05) = -3.0.
// Net effect: Hold penalty is -0.69β, trading penalty is -3.0β.
// The RELATIVE bonus for Hold over trading = 2.3β.
// Fixed small β for reward augmentation (decoupled from the adaptive
// gradient β). This gives Q a gentle Hold preference without the
// compounding effect of the adaptive KL gradient controller.
const float REWARD_KL_BETA = 0.02f;
const int a = actions[b];
const float pi_ref = (a == HOLD_ACTION) ? 0.5f : (1.0f / (float)(N_ACTIONS - 1)) * 0.5f;
const float kl_bonus = REWARD_KL_BETA * logf(fmaxf(pi_ref, 1e-7f));
const float r = r_env + kl_bonus;
rewards[b] = r; // write back so PER push captures augmented reward
const float ret = r + gamma * (1.0f - done) * vtp1;
returns[b] = ret;
if (done > 0.5f) {
// Done-step: real reward signal from trade closing.
advantages[b] = ret - vt;
} else {
// Non-done step: surfer Hold prior.
// Small positive advantage for Hold, small negative for trading.
// This gives π continuous gradient that reinforces waiting as
// the default. Without this, done-gating makes Hold invisible
// to π (zero gradient → abandoned).
//
// ISV-driven magnitude so it adapts with reward_scale.
const float hold_prior = isv[RL_HOLD_PRIOR_INDEX];
const int a = actions[b];
advantages[b] = (a == HOLD_ACTION) ? hold_prior : -hold_prior;
advantages[b] = 0.0f;
}
}
// Advantage normalization: zero mean, unit variance across the batch.
// Two-phase: phase 1 computes mean+var via block tree-reduce,
// phase 2 normalizes in-place. Single block covers the full batch.
//
// Standard PPO practice — without normalization, Q-advantage can be
// all-positive or all-negative, causing PPO to reinforce/suppress ALL
// actions uniformly instead of discriminating between them.
// DISABLED: advantage normalization causes issues with sparse rewards
// (5% of batch has real rewards, 95% is noise). The /B gradient
// normalization in ppo_clipped_surrogate_bwd already handles batch-
// size invariance. Raw V-advantages are bounded by reward_scale.
//
// Kept for reference but not launched from Rust.
#if 0
// Normalize advantages using only done-steps (where dones > 0.5) for
// mean/variance computation. Non-done steps have reward=0 and advantage
// ≈ γV(s')-V(s) ≈ noise. Normalizing over the full batch dilutes the
// sparse reward signal (5-6% of batch has real rewards) with 94% noise.
//
// Non-done steps still get their advantage normalized (centered/scaled),
// but the centering point and scale come from the DONE steps' advantage
// distribution — so the real reward signal drives the normalization.
// If zero done steps in a batch, fall back to full-batch normalization.
extern "C" __global__ void normalize_advantages(
float* __restrict__ advantages, // [b_size] IN/OUT
const float* __restrict__ dones, // [b_size] 0.0/1.0
int b_size
) {
extern __shared__ float smem[]; // [3 * blockDim.x]
float* s_sum = smem;
float* s_sum2 = smem + blockDim.x;
float* s_count = smem + 2 * blockDim.x;
const int tid = threadIdx.x;
float val = 0.0f;
float is_done = 0.0f;
if (tid < b_size) {
val = advantages[tid];
is_done = (dones[tid] > 0.5f) ? 1.0f : 0.0f;
}
s_sum[tid] = val * is_done;
s_sum2[tid] = val * val * is_done;
s_count[tid] = is_done;
__syncthreads();
for (int stride = blockDim.x / 2; stride > 0; stride >>= 1) {
if (tid < stride) {
s_sum[tid] += s_sum[tid + stride];
s_sum2[tid] += s_sum2[tid + stride];
s_count[tid] += s_count[tid + stride];
}
__syncthreads();
}
__shared__ float s_mean, s_std;
if (tid == 0) {
float n = s_count[0];
if (n < 1.0f) {
// No done steps — fall back to full-batch stats.
// Recompute from all elements (rare path).
float sum_all = 0.0f, sum2_all = 0.0f;
for (int i = 0; i < b_size; i++) {
sum_all += advantages[i];
sum2_all += advantages[i] * advantages[i];
}
s_mean = sum_all / (float)b_size;
float var = sum2_all / (float)b_size - s_mean * s_mean;
s_std = sqrtf(fmaxf(var, 1e-8f));
} else {
s_mean = s_sum[0] / n;
float var = s_sum2[0] / n - s_mean * s_mean;
s_std = sqrtf(fmaxf(var, 1e-8f));
}
}
__syncthreads();
if (tid < b_size) {
advantages[tid] = (advantages[tid] - s_mean) / s_std;
}
}
#endif