Codeseys's picture
Wave 15: 4-angle multi-model self-critique caught 2 math BLOCKERs in primary loss kernels; fixed against upstream byte-for-byte + GSM8K example + ergonomics
e5add15
Raw
History Blame Contribute Delete
11 kB
"""Distillation-loss unit tests — SimPO + TAID + Entropy-Aware OPD."""
from __future__ import annotations
import math
import pytest
import torch
import torch.nn.functional as F
from composer_replication.distillation import (
entropy_aware_opd_loss,
simpo_loss,
taid_loss,
)
from composer_replication.distillation.simpo import avg_sequence_logprob
from composer_replication.distillation.taid import TAIDScheduler
from composer_replication.distillation.entropy_aware_opd import teacher_entropy
# ---------------------------------------------------------------------
# SimPO
# ---------------------------------------------------------------------
def test_simpo_loss_returns_scalar():
chosen = torch.tensor([0.5, 0.4, 0.3])
rejected = torch.tensor([0.1, 0.0, -0.2])
loss = simpo_loss(chosen, rejected, beta=2.0, gamma=1.0)
assert loss.dim() == 0
assert torch.isfinite(loss)
def test_simpo_loss_lower_for_better_separation():
"""Larger margin between chosen and rejected → lower loss."""
# Same setup, two batches with different separations
small_sep_loss = simpo_loss(
torch.tensor([0.1]), torch.tensor([0.05]),
)
large_sep_loss = simpo_loss(
torch.tensor([1.0]), torch.tensor([-1.0]),
)
assert large_sep_loss < small_sep_loss, (
f"large separation should give smaller loss; "
f"got small_sep={small_sep_loss}, large_sep={large_sep_loss}"
)
def test_simpo_loss_differentiable():
chosen = torch.tensor([0.5], requires_grad=True)
rejected = torch.tensor([0.0], requires_grad=True)
loss = simpo_loss(chosen, rejected)
loss.backward()
assert chosen.grad is not None
assert rejected.grad is not None
assert torch.isfinite(chosen.grad).all()
assert torch.isfinite(rejected.grad).all()
def test_simpo_loss_shape_mismatch_raises():
with pytest.raises(ValueError, match="same shape"):
simpo_loss(torch.zeros(3), torch.zeros(5))
def test_avg_sequence_logprob():
"""Helper averages over response tokens, ignoring prompt + padding."""
# B=2, T=4
logprobs = torch.tensor([
[-10.0, -10.0, -1.0, -2.0], # response is last 2 tokens, avg=-1.5
[-1.0, -3.0, -1.0, -10.0], # response is first 3 tokens, avg=-5/3
])
mask = torch.tensor([
[0, 0, 1, 1],
[1, 1, 1, 0],
])
avg = avg_sequence_logprob(logprobs, mask)
expected = torch.tensor([-1.5, -5.0 / 3.0])
torch.testing.assert_close(avg, expected, atol=1e-5, rtol=1e-5)
# ---------------------------------------------------------------------
# TAID
# ---------------------------------------------------------------------
def test_taid_loss_returns_scalar_and_differentiable():
"""Basic shape + grad check at t=0.5."""
B, T, V = 2, 4, 8
student_logits = torch.randn(B, T, V, requires_grad=True)
teacher_logits = torch.randn(B, T, V)
mask = torch.ones(B, T)
loss = taid_loss(student_logits, teacher_logits, mask, t=0.5)
assert loss.dim() == 0
assert torch.isfinite(loss)
loss.backward()
assert student_logits.grad is not None
assert torch.isfinite(student_logits.grad).all()
def test_taid_loss_t_zero_target_matches_detached_student():
"""At t=0, p_t = softmax(student.detach()), so the forward-KL target is
the detached student. The loss is then the entropy of that detached
distribution against itself — finite, but more importantly the gradient
flowing into student_logits comes only through the log_softmax term, not
through the target (because of the .detach()).
"""
B, T, V = 1, 2, 4
s1 = torch.randn(B, T, V, requires_grad=True)
teacher_a = torch.zeros(B, T, V); teacher_a[..., 0] = 10.0
teacher_b = torch.zeros(B, T, V); teacher_b[..., 3] = 10.0
mask = torch.ones(B, T)
# At t=0 the teacher is completely ignored — same student detach anchor.
loss_a = taid_loss(s1, teacher_a, mask, t=0.0)
loss_b = taid_loss(s1, teacher_b, mask, t=0.0)
assert abs(float(loss_a) - float(loss_b)) < 1e-6
def test_taid_loss_t_one_is_pure_forward_kl():
"""At t=1, target = softmax(teacher_logits), so taid_loss reduces to
upstream forward_kl on the masked tokens.
"""
B, T, V = 2, 3, 5
student = torch.randn(B, T, V, requires_grad=True)
teacher = torch.randn(B, T, V)
mask = torch.ones(B, T)
loss_taid = taid_loss(student, teacher, mask, t=1.0)
# Reference forward-KL: -mean_token sum_v p_teacher(v) * log_q(v)
p_teacher = F.softmax(teacher, dim=-1, dtype=torch.float32)
log_q = F.log_softmax(student, dim=-1, dtype=torch.float32)
per_token = -(p_teacher * log_q).sum(dim=-1)
ref = per_token.mean()
torch.testing.assert_close(loss_taid, ref, atol=1e-5, rtol=1e-5)
def test_taid_loss_mask_is_token_mean():
"""Mask zeros out tokens; loss = sum(per_token * mask) / sum(mask)."""
B, T, V = 1, 4, 6
s = torch.randn(B, T, V)
t_logits = torch.randn(B, T, V)
full_mask = torch.ones(B, T)
half_mask = torch.tensor([[1.0, 1.0, 0.0, 0.0]])
loss_full = taid_loss(s, t_logits, full_mask, t=0.7)
loss_half = taid_loss(s, t_logits, half_mask, t=0.7)
# Manually: token-mean over only the first 2 positions
blended = (1 - 0.7) * s.detach() + 0.7 * t_logits
p_t = F.softmax(blended, dim=-1, dtype=torch.float32)
log_q = F.log_softmax(s, dim=-1, dtype=torch.float32)
per_token = -(p_t * log_q).sum(dim=-1)
expected_half = per_token[:, :2].mean()
torch.testing.assert_close(loss_half, expected_half, atol=1e-5, rtol=1e-5)
# Sanity: full vs half differ when teacher has structure.
assert not torch.allclose(loss_full, loss_half)
def test_taid_loss_shape_mismatch_raises():
s = torch.randn(2, 3, 5)
t_logits = torch.randn(2, 3, 6)
with pytest.raises(ValueError, match="shape mismatch"):
taid_loss(s, t_logits, t=0.5)
def test_taid_loss_invalid_mask_raises():
s = torch.randn(2, 3, 5)
t_logits = torch.randn(2, 3, 5)
bogus_mask = torch.ones(2, 4) # wrong T
with pytest.raises(ValueError, match="mask shape"):
taid_loss(s, t_logits, bogus_mask, t=0.5)
# ---------------------------------------------------------------------
# TAIDScheduler
# ---------------------------------------------------------------------
def test_taid_scheduler_initial_state():
sched = TAIDScheduler(num_train_steps=1000, t_start=0.4)
assert sched.t == pytest.approx(0.4)
def test_taid_scheduler_first_update_seeds():
"""First update_t() with finite loss only sets prev_loss, returns None,
leaves t at t_start.
"""
sched = TAIDScheduler(num_train_steps=100, t_start=0.4)
delta = sched.update_t(torch.tensor(2.0), global_step=0)
assert delta is None
assert sched.t == pytest.approx(0.4)
def test_taid_scheduler_monotonic_non_decreasing():
"""Even with noisy/oscillating loss, t is non-decreasing."""
sched = TAIDScheduler(num_train_steps=1000, t_start=0.4)
losses = [3.0, 2.5, 2.7, 2.3, 2.4, 2.0, 1.8, 1.85, 1.7, 1.5]
prev_t = sched.t
for step, loss in enumerate(losses):
sched.update_t(torch.tensor(loss), global_step=step)
assert sched.t >= prev_t - 1e-6, (
f"t decreased at step {step}: {prev_t} -> {sched.t}"
)
prev_t = sched.t
def test_taid_scheduler_t_end_clamp():
"""t never exceeds t_end."""
sched = TAIDScheduler(num_train_steps=10, t_start=0.4, t_end=0.9)
# Push global_step past num_train_steps so the linear floor would exceed t_end.
for step in range(0, 100):
sched.update_t(torch.tensor(2.0 - 0.01 * step), global_step=step)
assert sched.t <= 0.9 + 1e-6
def test_taid_scheduler_disable_adaptive_is_linear():
"""With disable_adaptive=True, t = t_start + progress * (t_end - t_start)."""
sched = TAIDScheduler(
num_train_steps=100, t_start=0.0, t_end=1.0, disable_adaptive=True
)
# Seed prev_loss
sched.update_t(torch.tensor(2.0), global_step=0)
sched.update_t(torch.tensor(1.5), global_step=50)
assert sched.t == pytest.approx(0.5, abs=1e-6)
sched.update_t(torch.tensor(1.0), global_step=100)
assert sched.t == pytest.approx(1.0, abs=1e-6)
# ---------------------------------------------------------------------
# Entropy-Aware OPD
# ---------------------------------------------------------------------
def test_teacher_entropy_one_hot_is_zero():
"""Argmax-1 distribution has entropy 0."""
logits = torch.zeros(1, 1, 4)
logits[..., 0] = 100.0 # essentially one-hot
H = teacher_entropy(logits)
assert float(H[0, 0]) < 1e-3
def test_teacher_entropy_uniform_is_log_v():
"""Uniform distribution over V symbols has entropy = log(V)."""
logits = torch.zeros(1, 1, 5)
H = teacher_entropy(logits)
assert float(H[0, 0]) == pytest.approx(math.log(5), rel=1e-5)
def test_entropy_aware_opd_returns_scalar_and_differentiable():
B, T, V = 2, 3, 8
student_logits = torch.randn(B, T, V, requires_grad=True)
teacher_logits = torch.randn(B, T, V)
loss = entropy_aware_opd_loss(student_logits, teacher_logits)
assert loss.dim() == 0
assert torch.isfinite(loss)
loss.backward()
assert student_logits.grad is not None
assert torch.isfinite(student_logits.grad).all()
def test_entropy_aware_opd_with_label_mask():
"""Label mask should zero out per-token loss on labels==0 positions."""
B, T, V = 1, 4, 6
student_logits = torch.randn(B, T, V, requires_grad=True)
teacher_logits = torch.randn(B, T, V)
full_loss = entropy_aware_opd_loss(student_logits, teacher_logits)
half_mask = torch.tensor([[1, 1, 0, 0]])
half_loss = entropy_aware_opd_loss(
student_logits, teacher_logits, labels=half_mask,
)
# half_loss should be ~half of the unmasked sum (modulo the entropy gating
# being position-dependent — but it should at least be < full_loss)
assert float(half_loss) < float(full_loss)
def test_entropy_aware_opd_zero_when_distributions_match():
"""When student and teacher are identical, both KLs are 0 → loss is 0."""
logits = torch.randn(1, 2, 4)
loss = entropy_aware_opd_loss(logits, logits)
assert float(loss) < 1e-5
def test_entropy_aware_opd_reduction_modes():
student_logits = torch.randn(2, 3, 4, requires_grad=True)
teacher_logits = torch.randn(2, 3, 4)
none_loss = entropy_aware_opd_loss(student_logits, teacher_logits, reduction="none")
mean_loss = entropy_aware_opd_loss(student_logits, teacher_logits, reduction="mean")
sum_loss = entropy_aware_opd_loss(student_logits, teacher_logits, reduction="sum")
batchmean_loss = entropy_aware_opd_loss(student_logits, teacher_logits, reduction="batchmean")
assert none_loss.shape == (2, 3)
assert mean_loss.dim() == 0
assert sum_loss.dim() == 0
assert batchmean_loss.dim() == 0
# batchmean = sum / batch_size
assert abs(float(batchmean_loss) - float(sum_loss) / 2) < 1e-4