mirror of https://github.com/razor-ai/soup.git
440 lines
17 KiB
Python
440 lines
17 KiB
Python
"""Tests for v0.40.0 Part C — KL-controlled DPO variants.
|
|
|
|
Adds two opt-in DPO controls:
|
|
* ``dpo_beta_schedule``: anneal β over training (linear / cosine / exponential).
|
|
* ``dpo_ref_regen_epochs``: replace the frozen ref model with the current
|
|
student every N epochs.
|
|
|
|
Both are SFT-trainer-style additive flags; the existing constant-β,
|
|
constant-ref-model path is unchanged when both flags are unset.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from soup_cli.config.schema import SoupConfig
|
|
|
|
# ─── Schema bounds ──────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDPOVariantsConfig:
|
|
def _base(self, **training):
|
|
return SoupConfig(
|
|
base="some-model",
|
|
task="dpo",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"dpo_beta": 0.1, **training},
|
|
)
|
|
|
|
@pytest.mark.parametrize("sched", ["linear", "cosine", "exponential"])
|
|
def test_beta_schedule_accepted(self, sched):
|
|
cfg = self._base(dpo_beta_schedule=sched, dpo_beta_end=0.01)
|
|
assert cfg.training.dpo_beta_schedule == sched
|
|
assert cfg.training.dpo_beta_end == pytest.approx(0.01)
|
|
|
|
def test_beta_schedule_unknown_rejected(self):
|
|
with pytest.raises(ValidationError, match="dpo_beta_schedule"):
|
|
self._base(dpo_beta_schedule="random", dpo_beta_end=0.01)
|
|
|
|
def test_beta_schedule_requires_end(self):
|
|
with pytest.raises(ValidationError, match="dpo_beta_end"):
|
|
self._base(dpo_beta_schedule="linear")
|
|
|
|
def test_beta_end_must_be_positive(self):
|
|
with pytest.raises(ValidationError, match="dpo_beta_end"):
|
|
self._base(dpo_beta_schedule="linear", dpo_beta_end=0)
|
|
|
|
def test_beta_end_alone_rejected(self):
|
|
"""Setting end without schedule is meaningless."""
|
|
with pytest.raises(ValidationError, match="dpo_beta_schedule"):
|
|
self._base(dpo_beta_end=0.01)
|
|
|
|
def test_ref_regen_epochs_positive(self):
|
|
cfg = self._base(dpo_ref_regen_epochs=2)
|
|
assert cfg.training.dpo_ref_regen_epochs == 2
|
|
|
|
def test_ref_regen_epochs_zero_rejected(self):
|
|
with pytest.raises(ValidationError, match="dpo_ref_regen_epochs"):
|
|
self._base(dpo_ref_regen_epochs=0)
|
|
|
|
def test_ref_regen_epochs_negative_rejected(self):
|
|
with pytest.raises(ValidationError, match="dpo_ref_regen_epochs"):
|
|
self._base(dpo_ref_regen_epochs=-1)
|
|
|
|
def test_ref_regen_epochs_too_large_rejected(self):
|
|
"""Bound at 1000 — runaway values are almost certainly typos."""
|
|
with pytest.raises(ValidationError, match="dpo_ref_regen_epochs"):
|
|
self._base(dpo_ref_regen_epochs=10_000)
|
|
|
|
def test_dpo_variants_only_for_dpo_family(self):
|
|
"""β-schedule + ref-regen require a DPO-family trainer."""
|
|
with pytest.raises(ValidationError, match="dpo|ipo|preference"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="sft",
|
|
data={"train": "./data.jsonl"},
|
|
training={
|
|
"dpo_beta_schedule": "linear",
|
|
"dpo_beta_end": 0.01,
|
|
},
|
|
)
|
|
|
|
def test_ref_regen_only_for_dpo_family(self):
|
|
with pytest.raises(ValidationError, match="dpo|ipo|preference"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="orpo",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"dpo_ref_regen_epochs": 2},
|
|
)
|
|
|
|
def test_dpo_variants_allowed_on_ipo(self):
|
|
cfg = SoupConfig(
|
|
base="some-model",
|
|
task="ipo",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"dpo_beta_schedule": "cosine", "dpo_beta_end": 0.01},
|
|
)
|
|
assert cfg.training.dpo_beta_schedule == "cosine"
|
|
|
|
def test_dpo_variants_allowed_on_preference_dpo(self):
|
|
cfg = SoupConfig(
|
|
base="some-model",
|
|
task="preference",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={
|
|
"preference_loss": "dpo",
|
|
"dpo_beta_schedule": "linear",
|
|
"dpo_beta_end": 0.05,
|
|
},
|
|
)
|
|
assert cfg.training.dpo_beta_end == pytest.approx(0.05)
|
|
|
|
def test_dpo_variants_rejected_on_preference_orpo(self):
|
|
with pytest.raises(ValidationError, match="dpo|ipo"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="preference",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={
|
|
"preference_loss": "orpo",
|
|
"dpo_beta_schedule": "linear",
|
|
"dpo_beta_end": 0.05,
|
|
},
|
|
)
|
|
|
|
def test_dpo_variants_rejected_on_mlx(self):
|
|
with pytest.raises(ValidationError, match="mlx"):
|
|
SoupConfig(
|
|
base="some-model",
|
|
task="dpo",
|
|
backend="mlx",
|
|
data={"train": "./data.jsonl", "format": "dpo"},
|
|
training={"dpo_beta_schedule": "linear", "dpo_beta_end": 0.01},
|
|
)
|
|
|
|
|
|
# ─── β schedule math ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestBetaSchedule:
|
|
def test_linear_endpoints(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=100, schedule="linear",
|
|
) == pytest.approx(0.1)
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=100, total_steps=100, schedule="linear",
|
|
) == pytest.approx(0.01)
|
|
|
|
def test_linear_midpoint(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
# Schema requires beta_end > 0; mid of (0.1, 0.02) = 0.06.
|
|
mid = compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.02, step=50, total_steps=100, schedule="linear",
|
|
)
|
|
assert mid == pytest.approx(0.06)
|
|
|
|
def test_cosine_endpoints(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=100, schedule="cosine",
|
|
) == pytest.approx(0.1)
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=100, total_steps=100, schedule="cosine",
|
|
) == pytest.approx(0.01)
|
|
|
|
def test_cosine_midpoint(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
# Cosine: at midpoint we expect (start+end)/2.
|
|
mid = compute_beta_at_step(
|
|
beta_start=0.2, beta_end=0.02, step=50, total_steps=100, schedule="cosine",
|
|
)
|
|
assert mid == pytest.approx(0.11, abs=1e-6)
|
|
|
|
def test_exponential_endpoints(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=100,
|
|
schedule="exponential",
|
|
) == pytest.approx(0.1)
|
|
end = compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=100, total_steps=100,
|
|
schedule="exponential",
|
|
)
|
|
assert end == pytest.approx(0.01, rel=1e-4)
|
|
|
|
def test_clamps_at_total_steps(self):
|
|
"""Step beyond total_steps clamps to beta_end (not extrapolation)."""
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=200, total_steps=100, schedule="linear",
|
|
) == pytest.approx(0.01)
|
|
|
|
def test_negative_step_clamps_to_start(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=-5, total_steps=100, schedule="linear",
|
|
) == pytest.approx(0.1)
|
|
|
|
def test_invalid_schedule_raises(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
with pytest.raises(ValueError, match="schedule"):
|
|
compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=100,
|
|
schedule="garbage",
|
|
)
|
|
|
|
def test_zero_total_steps_returns_end(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
assert compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=0, schedule="linear",
|
|
) == pytest.approx(0.01)
|
|
|
|
def test_negative_total_steps_rejected(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
with pytest.raises(ValueError, match="total_steps"):
|
|
compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=0, total_steps=-1,
|
|
schedule="linear",
|
|
)
|
|
|
|
def test_non_finite_betas_rejected(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
for bad in (float("nan"), float("inf"), -1.0):
|
|
with pytest.raises(ValueError, match="finite|> 0"):
|
|
compute_beta_at_step(
|
|
beta_start=bad, beta_end=0.01, step=0, total_steps=100,
|
|
schedule="linear",
|
|
)
|
|
|
|
def test_step_bool_rejected(self):
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
with pytest.raises(ValueError, match="step"):
|
|
compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=True, total_steps=100,
|
|
schedule="linear",
|
|
)
|
|
|
|
|
|
# ─── BetaScheduleCallback ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestBetaScheduleCallback:
|
|
def test_on_step_begin_writes_beta_on_trainer(self):
|
|
from soup_cli.utils.dpo_variants import BetaScheduleCallback
|
|
|
|
cb = BetaScheduleCallback(
|
|
beta_start=0.1, beta_end=0.01, total_steps=100, schedule="linear",
|
|
)
|
|
trainer = MagicMock()
|
|
trainer.beta = 0.1
|
|
state = MagicMock(global_step=50)
|
|
cb.on_step_begin(args=None, state=state, control=None, model=None)
|
|
cb.attach(trainer)
|
|
cb.on_step_begin(args=None, state=state, control=None, model=None)
|
|
# After attachment, β is updated.
|
|
assert trainer.beta == pytest.approx(0.055, abs=1e-6)
|
|
|
|
def test_callback_no_trainer_attached_is_noop(self):
|
|
"""Without a trainer, callback updates nothing."""
|
|
from soup_cli.utils.dpo_variants import BetaScheduleCallback
|
|
|
|
cb = BetaScheduleCallback(
|
|
beta_start=0.1, beta_end=0.01, total_steps=100, schedule="linear",
|
|
)
|
|
state = MagicMock(global_step=50)
|
|
# Should not raise.
|
|
cb.on_step_begin(args=None, state=state, control=None, model=None)
|
|
|
|
|
|
# ─── RefModelRegenCallback ─────────────────────────────────────────────────
|
|
|
|
|
|
class TestRefModelRegenCallback:
|
|
def test_fires_on_target_epoch(self):
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
cb = RefModelRegenCallback(every_n_epochs=2)
|
|
trainer = MagicMock()
|
|
cb.attach(trainer)
|
|
state = MagicMock(epoch=2.0)
|
|
cb.on_epoch_end(args=None, state=state, control=None, model=None)
|
|
assert cb.regen_count == 1
|
|
|
|
def test_does_not_fire_off_target(self):
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
cb = RefModelRegenCallback(every_n_epochs=2)
|
|
trainer = MagicMock()
|
|
cb.attach(trainer)
|
|
state = MagicMock(epoch=1.0)
|
|
cb.on_epoch_end(args=None, state=state, control=None, model=None)
|
|
assert cb.regen_count == 0
|
|
|
|
def test_skip_at_epoch_zero(self):
|
|
"""Regen at epoch 0 would copy untrained student → undesirable."""
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
cb = RefModelRegenCallback(every_n_epochs=1)
|
|
trainer = MagicMock()
|
|
cb.attach(trainer)
|
|
state = MagicMock(epoch=0.0)
|
|
cb.on_epoch_end(args=None, state=state, control=None, model=None)
|
|
assert cb.regen_count == 0
|
|
|
|
def test_invalid_period_int_below_one_rejected(self):
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
for bad in (0, -1):
|
|
with pytest.raises(ValueError, match="every_n_epochs"):
|
|
RefModelRegenCallback(every_n_epochs=bad)
|
|
|
|
def test_invalid_period_non_int_rejected(self):
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
for bad in (0.5, "2", True):
|
|
with pytest.raises(TypeError, match="every_n_epochs"):
|
|
RefModelRegenCallback(every_n_epochs=bad)
|
|
|
|
def test_no_trainer_attached_is_noop(self):
|
|
from soup_cli.utils.dpo_variants import RefModelRegenCallback
|
|
|
|
cb = RefModelRegenCallback(every_n_epochs=2)
|
|
state = MagicMock(epoch=2.0)
|
|
cb.on_epoch_end(args=None, state=state, control=None, model=None)
|
|
assert cb.regen_count == 0
|
|
|
|
|
|
# ─── build_dpo_variant_callbacks ────────────────────────────────────────────
|
|
|
|
|
|
class TestBuildDPOVariantCallbacks:
|
|
def test_returns_empty_list_when_no_variants(self):
|
|
from soup_cli.utils.dpo_variants import build_dpo_variant_callbacks
|
|
|
|
cbs = build_dpo_variant_callbacks(
|
|
beta_start=0.1, beta_end=None, schedule=None,
|
|
total_steps=0, ref_regen_epochs=None,
|
|
)
|
|
assert cbs == []
|
|
|
|
def test_returns_beta_only(self):
|
|
from soup_cli.utils.dpo_variants import (
|
|
BetaScheduleCallback,
|
|
build_dpo_variant_callbacks,
|
|
)
|
|
|
|
cbs = build_dpo_variant_callbacks(
|
|
beta_start=0.1, beta_end=0.01, schedule="linear",
|
|
total_steps=0, ref_regen_epochs=None,
|
|
)
|
|
assert len(cbs) == 1
|
|
assert isinstance(cbs[0], BetaScheduleCallback)
|
|
|
|
def test_returns_regen_only(self):
|
|
from soup_cli.utils.dpo_variants import (
|
|
RefModelRegenCallback,
|
|
build_dpo_variant_callbacks,
|
|
)
|
|
|
|
cbs = build_dpo_variant_callbacks(
|
|
beta_start=0.1, beta_end=None, schedule=None,
|
|
total_steps=0, ref_regen_epochs=2,
|
|
)
|
|
assert len(cbs) == 1
|
|
assert isinstance(cbs[0], RefModelRegenCallback)
|
|
|
|
def test_returns_both_callbacks(self):
|
|
from soup_cli.utils.dpo_variants import (
|
|
BetaScheduleCallback,
|
|
RefModelRegenCallback,
|
|
build_dpo_variant_callbacks,
|
|
)
|
|
|
|
cbs = build_dpo_variant_callbacks(
|
|
beta_start=0.1, beta_end=0.01, schedule="linear",
|
|
total_steps=0, ref_regen_epochs=2,
|
|
)
|
|
assert len(cbs) == 2
|
|
kinds = {type(cb) for cb in cbs}
|
|
assert kinds == {BetaScheduleCallback, RefModelRegenCallback}
|
|
|
|
|
|
class TestBetaScheduleLazyTotalSteps:
|
|
def test_on_train_begin_resolves_total_steps_from_state(self):
|
|
from soup_cli.utils.dpo_variants import BetaScheduleCallback
|
|
|
|
cb = BetaScheduleCallback(
|
|
beta_start=0.1, beta_end=0.01, total_steps=0, schedule="linear",
|
|
)
|
|
# Initially 0 — sentinel.
|
|
assert cb.total_steps == 0
|
|
state = MagicMock(max_steps=200)
|
|
cb.on_train_begin(args=None, state=state, control=None)
|
|
assert cb.total_steps == 200
|
|
|
|
def test_on_step_begin_skips_when_total_steps_unresolvable(self):
|
|
"""No max_steps available → don't fall through to compute_beta_at_step."""
|
|
from soup_cli.utils.dpo_variants import BetaScheduleCallback
|
|
|
|
cb = BetaScheduleCallback(
|
|
beta_start=0.1, beta_end=0.01, total_steps=0, schedule="linear",
|
|
)
|
|
trainer = MagicMock()
|
|
trainer.beta = 0.1
|
|
cb.attach(trainer)
|
|
state = MagicMock(global_step=10)
|
|
cb.on_step_begin(args=None, state=state, control=None)
|
|
# beta unchanged because total_steps still 0.
|
|
assert trainer.beta == 0.1
|
|
|
|
def test_math_module_used_in_cosine(self):
|
|
"""Sanity: math module imported at top is exercised by cosine schedule."""
|
|
from soup_cli.utils.dpo_variants import compute_beta_at_step
|
|
|
|
# Cosine at progress=0 should equal beta_start exactly (cos(0)=1).
|
|
result = compute_beta_at_step(
|
|
beta_start=0.1, beta_end=0.01, step=1, total_steps=10**9,
|
|
schedule="cosine",
|
|
)
|
|
# Very near beta_start since progress ~ 1e-9.
|
|
assert math.isclose(result, 0.1, rel_tol=1e-6)
|