Baladithya Balamurugan
Wave 20: Tier-0 fidelity fixes — k1-in-reward KL + Composer-2 behavior rewards
41289bf
Raw
History Blame Contribute Delete
5.98 kB
"""Tests for k1-in-reward KL (F5 #1 fidelity fix; Composer-2 §4.1 / verl).
The load-bearing test is `test_apply_equals_fold_then_baseline`: it proves the
advantage adjustment `adv -= coef·(KL - group_mean(KL))` is EXACTLY equal to
folding the KL penalty into the reward and re-running GRPO's group-mean
baseline (with no std-norm). That equivalence is the entire justification for
adjusting advantages post-hoc instead of forking TRL's reward→advantage code.
"""
from __future__ import annotations
import pytest
import torch
from composer_replication.trainer.kl_in_reward import (
apply_kl_in_reward,
k1_kl_penalty_per_sequence,
k3_kl_penalty_per_sequence,
kl_penalty_per_sequence,
)
# ---------------------------------------------------------------------
# Per-sequence KL estimators
# ---------------------------------------------------------------------
def test_k1_penalty_sums_masked_logp_diff():
policy = torch.tensor([[0.0, -1.0, -2.0], [-0.5, -0.5, -0.5]])
ref = torch.tensor([[0.0, -0.5, -1.0], [-1.0, -1.0, -1.0]])
mask = torch.tensor([[1.0, 1.0, 0.0], [1.0, 1.0, 1.0]]) # row0 drops last token
out = k1_kl_penalty_per_sequence(policy, ref, mask)
# row0: (0-0) + (-1-(-0.5)) [+ masked 0] = -0.5
# row1: (-0.5-(-1.0))*3 = +1.5
torch.testing.assert_close(out, torch.tensor([-0.5, 1.5]))
def test_k1_can_be_negative_k3_cannot():
"""Structural difference: k1 is signed, k3 ≥ 0 (the whole reason they differ)."""
policy = torch.tensor([[0.0, 0.0]])
ref = torch.tensor([[1.0, 1.0]]) # ref > policy → Δ=ref-logp>0 → k1<0
mask = torch.ones_like(policy)
k1 = k1_kl_penalty_per_sequence(policy, ref, mask)
k3 = k3_kl_penalty_per_sequence(policy, ref, mask)
assert (k1 < 0).all(), "k1 = Σ(logp-ref) is negative when ref>logp"
assert (k3 >= -1e-6).all(), "k3 (Schulman) is always non-negative"
def test_k3_leading_order_is_half_delta_squared():
"""For small Δ, k3 ≈ Δ²/2 — the minor-delta claim in make_dr_grpo_config."""
policy = torch.tensor([[0.0, 0.0, 0.0]])
ref = torch.tensor([[0.01, -0.02, 0.005]])
mask = torch.ones_like(policy)
k3 = k3_kl_penalty_per_sequence(policy, ref, mask)
delta = ref - policy
expected = (0.5 * delta**2).sum()
torch.testing.assert_close(k3, expected.unsqueeze(0), atol=1e-4, rtol=1e-3)
def test_dispatch_and_unknown_estimator():
policy = torch.zeros(1, 2)
ref = torch.ones(1, 2)
mask = torch.ones(1, 2)
torch.testing.assert_close(
kl_penalty_per_sequence(policy, ref, mask, "k1"),
k1_kl_penalty_per_sequence(policy, ref, mask),
)
with pytest.raises(ValueError, match="Unknown KL estimator"):
kl_penalty_per_sequence(policy, ref, mask, "k2")
def test_penalty_shape_validation():
with pytest.raises(ValueError, match="identical shape"):
k1_kl_penalty_per_sequence(torch.zeros(1, 3), torch.zeros(1, 2), torch.zeros(1, 3))
with pytest.raises(ValueError, match="must match"):
k1_kl_penalty_per_sequence(torch.zeros(1, 3), torch.zeros(1, 3), torch.zeros(1, 2))
# ---------------------------------------------------------------------
# apply_kl_in_reward — the advantage adjustment
# ---------------------------------------------------------------------
def test_apply_coef_zero_is_identity():
adv = torch.tensor([1.0, -1.0, 0.5, -0.5])
kl = torch.tensor([2.0, 3.0, 1.0, 0.0])
out = apply_kl_in_reward(adv, kl, num_generations=2, coef=0.0)
torch.testing.assert_close(out, adv)
def test_apply_centers_kl_within_group():
# Two groups of 2. coef=1. adv -= (KL - group_mean(KL)).
adv = torch.zeros(4)
kl = torch.tensor([1.0, 3.0, 10.0, 20.0])
out = apply_kl_in_reward(adv, kl, num_generations=2, coef=1.0)
# group0 mean=2 → centered [-1,+1] → adv-(-1,+1)=[1,-1]
# group1 mean=15 → centered [-5,+5] → adv-(-5,+5)=[5,-5]
torch.testing.assert_close(out, torch.tensor([1.0, -1.0, 5.0, -5.0]))
def test_apply_divisibility_validation():
with pytest.raises(ValueError, match="multiple of num_generations"):
apply_kl_in_reward(torch.zeros(5), torch.zeros(5), num_generations=2, coef=1.0)
with pytest.raises(ValueError, match="identical shape"):
apply_kl_in_reward(torch.zeros(4), torch.zeros(2), num_generations=2, coef=1.0)
@pytest.mark.parametrize("num_generations", [2, 3, 4])
@pytest.mark.parametrize("n_groups", [1, 2, 5])
def test_apply_equals_fold_then_baseline(num_generations, n_groups):
"""THE load-bearing property: adjusting baselined advantages by
-coef·(KL - group_mean(KL)) equals folding -coef·KL into the reward and
re-running GRPO's group-mean baseline (scale_rewards='none').
This proves the post-hoc advantage adjustment IS exact k1-in-reward, not an
approximation — the justification for not forking TRL's scoring code.
"""
torch.manual_seed(0)
g, k = num_generations, n_groups
b = g * k
coef = 0.137
rewards = torch.randn(b)
kl = torch.randn(b).abs() # KL ≥ 0 in spirit, though sign-agnostic here
# GRPO baseline (no std-norm): adv = reward - group_mean(reward).
def group_baseline(x):
means = x.view(k, g).mean(dim=1).repeat_interleave(g) # (b,)
return x - means
advantages = group_baseline(rewards)
# Reference: fold KL into reward, THEN baseline.
folded_reward = rewards - coef * kl
adv_fold_then_baseline = group_baseline(folded_reward)
# Under test: adjust the ALREADY-baselined advantages.
adv_adjusted = apply_kl_in_reward(advantages, kl, num_generations=g, coef=coef)
torch.testing.assert_close(adv_adjusted, adv_fold_then_baseline, atol=1e-5, rtol=1e-5)
def test_apply_does_not_mutate_input():
adv = torch.tensor([1.0, 2.0])
adv_copy = adv.clone()
apply_kl_in_reward(adv, torch.tensor([0.0, 1.0]), num_generations=2, coef=1.0)
torch.testing.assert_close(adv, adv_copy) # functional, not in-place