File size: 8,471 Bytes
201dc77 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | """DPO loss adapter: batch plumbing, metric contract, ref-logprob refusal.
Run: uv run pytest tests/test_dpo_loss.py -m "not gpu" -v
(venv equivalent on this box: .venv/bin/python -m pytest tests/test_dpo_loss.py -m "not gpu" -v)
The kernel's own tests (`test_fused_dpo.py`) cover the objective. This file
covers the layer between the Trainer and the kernel: unpacking a paired batch,
the caller-side label shift, the mandatory-metric contract, and the refusals
that must not be silent.
"""
from __future__ import annotations
import math
import pytest
import torch
from align.config import ModelConfig
from align.kernels.fused_dpo import MANDATORY_METRIC_KEYS
from align.train.dpo_loss import (
IGNORE_INDEX,
DpoLossConfig,
fused_dpo_available,
make_dpo_loss_adapter,
)
def tiny_model(consts: dict):
from align.model.mistral import NmoMistralForCausalLM
cfg = ModelConfig(
name="tiny-mistral-dpo",
n_layers=consts["n_layers"],
hidden_size=consts["hidden_size"],
intermediate_size=consts["intermediate_size"],
n_heads=consts["n_heads"],
n_kv_heads=consts["n_kv_heads"],
head_dim=consts["head_dim"],
vocab_size=consts["vocab_size"],
max_position_embeddings=consts["max_seq_len"],
rope_theta=consts["rope_theta"],
precision="fp32",
)
cfg.n_params = -1
torch.manual_seed(consts["seed"])
return NmoMistralForCausalLM(cfg, attn_implementation="eager"), cfg
def paired_batch(cfg, pairs=3, seq=9, prompt_len=3, seed=0):
g = torch.Generator().manual_seed(seed)
out = {}
for side in ("chosen", "rejected"):
ids = torch.randint(0, cfg.vocab_size, (pairs, seq), generator=g)
labels = ids.clone()
labels[:, :prompt_len] = IGNORE_INDEX
out[f"input_ids_{side}"] = ids
out[f"labels_{side}"] = labels
out["ref_logp_chosen"] = torch.randn(pairs, generator=g) - 20.0
out["ref_logp_rejected"] = torch.randn(pairs, generator=g) - 22.0
return out
def test_adapter_returns_loss_output_with_mandatory_metrics(tiny_model_constants):
"""The Trainer reads `loss`, `n_tokens` and `metrics` off a LossOutput, and
section-12's dashboard reads the mandatory metric keys off `metrics` by
name. Both are checked here so a rename fails at the adapter rather than
at analysis time."""
model, cfg = tiny_model(tiny_model_constants)
batch = paired_batch(cfg, seed=1)
out = make_dpo_loss_adapter()(model, batch)
assert torch.isfinite(out.loss) and out.loss.dim() == 0
for key in MANDATORY_METRIC_KEYS:
assert key in out.metrics, key
assert not math.isnan(out.metrics[key]), key
# 3 pairs x (9 - 1 shifted - 3 prompt + 1) scored tokens on each side.
expected = 2 * 3 * (9 - 1 - 3 + 1)
assert out.n_tokens == expected, out.n_tokens
def test_adapter_shifts_labels_exactly_once(tiny_model_constants):
"""Position t predicts token t+1, and the shift lives on THIS side of the
boundary (matching sft_loss). A second shift inside the kernel would be
invisible: it produces a slightly worse but entirely plausible loss. The
token count is the observable that pins it."""
model, cfg = tiny_model(tiny_model_constants)
batch = paired_batch(cfg, pairs=2, seq=10, prompt_len=4, seed=2)
out = make_dpo_loss_adapter()(model, batch)
per_side = 10 - 1 - 4 + 1
assert out.n_tokens == 2 * 2 * per_side
def test_adapter_gradients_reach_the_policy_but_not_the_reference(tiny_model_constants):
"""End-to-end through the real tiny model: the policy trunk and the LM head
receive gradient, and a requires_grad reference tensor does not. This is
the section-12 contract exercised through the adapter rather than the
kernel, because the adapter is where a stray graph edge would be
introduced."""
model, cfg = tiny_model(tiny_model_constants)
batch = paired_batch(cfg, seed=3)
batch["ref_logp_chosen"] = batch["ref_logp_chosen"].clone().requires_grad_(True)
out = make_dpo_loss_adapter()(model, batch)
out.loss.backward()
assert batch["ref_logp_chosen"].grad is None
assert model.lm_head.weight.grad is not None
assert torch.isfinite(model.lm_head.weight.grad).all()
trunk = model.model.layers[0].attn.q_proj.weight
assert trunk.grad is not None and trunk.grad.abs().sum() > 0, (
"no gradient reached the trunk: the loss is not training the model"
)
def test_missing_reference_logprobs_raise(tiny_model_constants):
"""Guard both directions. A batch without reference log-probs is refused;
substituting zeros would turn DPO into likelihood maximisation on the
chosen side, which trains and converges and is not DPO. A complete batch
is accepted silently."""
model, cfg = tiny_model(tiny_model_constants)
adapter = make_dpo_loss_adapter()
full = paired_batch(cfg, seed=4)
for key in ("ref_logp_chosen", "ref_logp_rejected", "labels_chosen", "input_ids_rejected"):
broken = {k: v for k, v in full.items() if k != key}
with pytest.raises(TypeError, match=key):
adapter(model, broken)
assert torch.isfinite(adapter(model, full).loss)
def test_adapter_rejects_unknown_variant():
"""Guard both directions: a typo'd variant raises when the adapter is
BUILT, not at the first step, and every real variant builds."""
from align.kernels.fused_dpo import DPO_VARIANTS
with pytest.raises(ValueError, match="variant"):
make_dpo_loss_adapter(DpoLossConfig(variant="sigmiod"))
for variant in DPO_VARIANTS:
assert make_dpo_loss_adapter(DpoLossConfig(variant=variant)) is not None
def test_token_chunk_size_default_is_not_none():
"""INCIDENT this default prevents: pairs are the chunking unit, and the
pre-registered config is micro_batch 1 -- ONE pair per step. With
`token_chunk_size=None` the fused DPO loss would materialise the whole
[1024, 32768] logit tensor while reporting a healthy loss and passing
every parity test. The trainer-facing default must therefore set it."""
cfg = DpoLossConfig()
assert cfg.token_chunk_size is not None and cfg.token_chunk_size > 0
assert cfg.beta == 0.1 and cfg.variant == "sigmoid"
assert cfg.nll_coeff == 0.0, (
"the NLL auxiliary is a tripwire RESPONSE; it must be off by default"
)
def test_adapter_config_reaches_the_kernel(tiny_model_constants):
"""The config is not decorative: changing beta must change the loss, and
the dpop variant must differ from sigmoid on a batch where its hinge is
open. An adapter that dropped its config on the floor would run every arm
under the default objective."""
model, cfg = tiny_model(tiny_model_constants)
batch = paired_batch(cfg, seed=5)
lo = make_dpo_loss_adapter(DpoLossConfig(beta=0.1))(model, batch).loss
hi = make_dpo_loss_adapter(DpoLossConfig(beta=0.9))(model, batch).loss
assert abs(float(lo.detach()) - float(hi.detach())) > 1e-6
# reference far above the policy -> the dpop hinge is open
open_hinge = dict(batch, ref_logp_chosen=torch.zeros_like(batch["ref_logp_chosen"]))
sig = make_dpo_loss_adapter(DpoLossConfig(variant="sigmoid"))(model, open_hinge).loss
dpop = make_dpo_loss_adapter(DpoLossConfig(variant="dpop"))(model, open_hinge).loss
assert float(dpop.detach()) > float(sig.detach()) + 1e-6
def test_fused_dpo_is_available_and_required(tiny_model_constants):
"""Both directions on the require_fused guard: the kernel IS importable
here, so building with require_fused=True must succeed. The refusal branch
is what the 7B stage scripts rely on -- a run must not quietly fall back to
a reference path that no A10G survives."""
assert fused_dpo_available() is True
assert make_dpo_loss_adapter(require_fused=True).uses_fused is True
def test_dpo_loss_module_builds_no_reference_model():
"""Structural: nothing in this section constructs or loads a second model.
The reference log-probs are data. An import that pulled a model in here
would silently double resident weights at DPO time."""
import pathlib
src = pathlib.Path(__file__).resolve().parents[1] / "align" / "train" / "dpo_loss.py"
text = src.read_text()
for forbidden in ("from_pretrained", "load_state_dict", "AutoModel", "NmoMistralForCausalLM"):
assert forbidden not in text, f"dpo_loss.py must not reference {forbidden!r}"
|