A next-token model can assign probabilities to two completions without preferring the one your dataset marks as chosen. DPO turns that pairwise label into a parameter update while comparing the policy with a fixed reference. This lesson executes that update on the tiny synthetic-vocabulary autoregressive transformer from the tensor bridge.
When you need this. You want to inspect how an authored chosen/rejected pair changes model parameters before attempting alignment on a pretrained model. First make the completion boundary, fixed reference and loss sign visible; a lower scalar loss alone would not reveal a prompt-scoring mistake.
A completion probability is conditional on the prompt and its own earlier tokens. Summing its token log probabilities gives its sequence log probability. For each pair, this code subtracts rejected from chosen scores, then subtracts the same margin under a frozen reference. It minimizes -logsigmoid(beta * (policy_margin - reference_margin)), averaged over pairs. The DPO paper, equation 7, gives the policy-relative preference objective; the local labels here are authored token choices, not human annotations.
The driver creates a fresh tiny policy, clones and detaches its parameters for the reference, and constructs SGD over the policy parameters only. Each pair supplies the same prompt to chosen and rejected scoring. Reference scores run under no_grad. Backpropagation changes the policy; the reference remains fixed throughout the loop. No separately trained reward model is used.
Download the complete lab and enter preference-policy-updates/. Create an isolated Python environment and install its pinned dependency with python -m pip install -r requirements.txt. The computation uses CPU tensors; no pretrained model download, GPU or paid service is needed. The PyTorch installation guide covers platform-specific installation choices. Another library version requires rerunning the checks.
1. Align the completion with the prediction position
For prompt [1, 3] and chosen completion [6, 2], the input is [1, 3, 6]. The logit after prompt token 3 predicts completion token 6; the next logit predicts completion token 2. token_logps() starts at len(prompt)-1 and gathers only the completion targets. Prompt tokens condition the answer but are not added to its preference score.
The second pair changes the prompt and rejected completion. The raw record retains both exact pairs: [{"prompt":[1,3],"chosen":[6,2],"rejected":[4,2]},{"prompt":[2,5],"chosen":[6,2],"rejected":[7,2]}]. This is a nine-token synthetic vocabulary with freshly initialized parameters; token 6 has no assigned natural-language meaning. We are testing the objective mechanism, not reproducing the paper’s alignment experiments.
2. Follow the margin through the update
When policy and reference begin equal, their margins cancel and the loss starts at log(2). Run the activity and inspect the full loss history, not just the final value:
python run.py dpoThe saved run performs 30 updates with beta 0.3 and learning rate 0.1. Loss changes from 0.693147 to 0.151303. The reference-gradient flag is false. These values describe fitting the supplied pairs; there is no held-out task evaluation.
Read the complete implementation
"""DPO and online group-relative updates on a tiny synthetic-vocabulary LM.
This is an objective/mechanism experiment, not pretrained-model alignment.
"""
import copy
import json
import math
from pathlib import Path
import torch
import torch.nn.functional as F
import decoder
HERE = Path(__file__).resolve().parent
PAIRS = [{'prompt': [1, 3], 'chosen': [6, 2], 'rejected': [4, 2]},
{'prompt': [2, 5], 'chosen': [6, 2], 'rejected': [7, 2]}]
def policy():
return decoder.tensors(decoder.params())
def frozen(parameters):
return {key: value.detach().clone() for key, value in parameters.items()}
def token_logps(parameters, prompt, completion):
if not isinstance(prompt, list) or not isinstance(completion, list) or not prompt or not completion:
raise ValueError('prompt and completion must be nonempty')
ids = prompt + completion[:-1]
decoder.inputs(parameters, ids, [0] * len(ids))
decoder.inputs(parameters, completion, [0] * len(completion))
logits, _ = decoder.forward(parameters, ids, [0] * len(ids))
selected = logits[len(prompt)-1:].log_softmax(-1)
return selected.gather(1, torch.tensor(completion).unsqueeze(1)).squeeze(1)
def dpo_loss(parameters, reference, pairs, beta=0.3):
if not math.isfinite(beta) or beta <= 0 or not pairs:
raise ValueError('positive beta and preference pairs required')
losses = []
for pair in pairs:
if not isinstance(pair, dict) or set(pair) != {'prompt', 'chosen', 'rejected'} or pair['chosen'] == pair['rejected']:
raise ValueError('distinct chosen and rejected completions required')
prompt, chosen, rejected = (pair[key] for key in ('prompt', 'chosen', 'rejected'))
margin = token_logps(parameters, prompt, chosen).sum() - token_logps(parameters, prompt, rejected).sum()
with torch.no_grad():
ref_margin = token_logps(reference, prompt, chosen).sum() - token_logps(reference, prompt, rejected).sum()
losses.append(-F.logsigmoid(beta * (margin - ref_margin)))
return torch.stack(losses).mean()
def sample(parameters, prompt, count, length, generator):
if type(count) is not int or count < 2 or type(length) is not int or length < 1:
raise ValueError('group size >=2 and positive completion length required')
completions = []
with torch.no_grad():
for _ in range(count):
generated = []
for _ in range(length):
ids = prompt + generated
decoder.inputs(parameters, ids, [0] * len(ids))
logits, _ = decoder.forward(parameters, ids, [0] * len(ids))
token = torch.multinomial(logits[-1].softmax(-1), 1, generator=generator).item()
generated.append(token)
completions.append(generated)
return completions
def advantages(rewards):
values = torch.tensor(rewards, dtype=torch.float64)
if values.ndim != 1 or len(values) < 2 or not torch.isfinite(values).all():
raise ValueError('finite reward group with at least two members required')
std = values.std(unbiased=False)
if std.item() == 0:
return torch.zeros_like(values)
return (values - values.mean()) / (std + 1e-8)
def grpo_loss(parameters, reference, old_logps, prompt, completions, rewards, clip=0.2, beta=0.04):
if not 0 < clip < 1 or not math.isfinite(beta) or beta < 0:
raise ValueError('clip in (0,1) and nonnegative finite KL coefficient required')
if not (len(completions) == len(rewards) == len(old_logps)) or len({len(c) for c in completions}) != 1:
raise ValueError('aligned equal-length completion group required')
if any(not isinstance(old, torch.Tensor) or old.shape != (len(completion),) or not torch.isfinite(old).all() for old, completion in zip(old_logps, completions)):
raise ValueError('old-policy token log-probabilities must align with completions')
advantage = advantages(rewards)
losses = []
for i, completion in enumerate(completions):
current = token_logps(parameters, prompt, completion)
ratio = (current - old_logps[i].detach()).exp()
with torch.no_grad():
ref = token_logps(reference, prompt, completion)
delta = ref - current
kl = delta.exp() - delta - 1
surrogate = torch.minimum(ratio * advantage[i], ratio.clamp(1-clip, 1+clip) * advantage[i])
losses.append((-surrogate + beta * kl).mean())
return torch.stack(losses).mean()
def run_dpo():
parameters = policy(); reference = frozen(parameters)
optimizer = torch.optim.SGD(parameters.values(), lr=0.1)
before = dpo_loss(parameters, reference, PAIRS).item()
history = []
for _ in range(30):
optimizer.zero_grad();loss = dpo_loss(parameters, reference, PAIRS)
loss.backward();optimizer.step();history.append(loss.item())
after = dpo_loss(parameters, reference, PAIRS).item()
return {'mode': 'fresh CPU parameter updates on a tiny synthetic-vocabulary language model',
'method': 'DPO sigmoid loss; frozen reference; completion log-probability sums',
'pairs': PAIRS, 'beta': 0.3, 'steps': len(history), 'learning_rate': 0.1,
'loss_before': before, 'loss_after': after, 'loss_history': history,
'reference_has_gradients': any(t.grad is not None for t in reference.values()),
'scope': 'Authored token preferences, not human annotation or pretrained-model alignment.'}
def run_grpo():
parameters = policy(); reference = frozen(parameters)
optimizer = torch.optim.SGD(parameters.values(), lr=0.02)
generator = torch.Generator().manual_seed(47)
prompt = [1, 3]; trace = []
def desired_probability():
with torch.no_grad():
logits, _ = decoder.forward(parameters, prompt, [0] * len(prompt))
return logits[-1].softmax(-1)[6].item()
before = desired_probability()
for step in range(8):
completions = sample(parameters, prompt, 16, 2, generator)
# Authored syntactic reward: first generated token equals token 6.
rewards = [float(completion[0] == 6) for completion in completions]
old = [token_logps(parameters, prompt, completion).detach() for completion in completions]
a = advantages(rewards)
losses = []
for _ in range(2):
optimizer.zero_grad();loss = grpo_loss(parameters, reference, old, prompt, completions, rewards)
loss.backward();optimizer.step();losses.append(loss.item())
trace.append({'step': step, 'completions': completions, 'rewards': rewards,
'advantages': a.tolist(), 'losses': losses, 'zero_variance': bool(a.eq(0).all())})
return {'mode': 'fresh online CPU sampling and parameter updates on a tiny synthetic-vocabulary LM',
'method': 'fixed-length token-level clipped group-relative objective with sampled KL penalty',
'prompt': prompt, 'seed': 47, 'group_size': 16, 'completion_length': 2,
'groups': len(trace), 'inner_updates': 2, 'clip': 0.2, 'kl_beta': 0.04,
'reward': '1 if the first generated token equals 6; otherwise 0',
'desired_token_probability_before': before, 'desired_token_probability_after': desired_probability(),
'sampled_completions': sum(len(group['completions']) for group in trace), 'trace': trace,
'scope': 'No semantic quality, human preference, reasoning or held-out capability claim.'}{
"mode": "fresh CPU parameter updates on a tiny synthetic-vocabulary language model",
"method": "DPO sigmoid loss; frozen reference; completion log-probability sums",
"pairs": [
{
"prompt": [
1,
3
],
"chosen": [
6,
2
],
"rejected": [
4,
2
]
},
{
"prompt": [
2,
5
],
"chosen": [
6,
2
],
"rejected": [
7,
2
]
}
],
"beta": 0.3,
"steps": 30,
"learning_rate": 0.1,
"loss_before": 0.6931471805599453,
"loss_after": 0.1513033481428186,
"loss_history": [
0.6931471805599453,
0.6785139087244103,
0.6636265041932342,
0.6328846300805822,
0.5999378358421323,
0.5586964171496265,
0.5230459778075551,
0.49329939340557705,
0.4896008910560762,
0.4983428009101378,
0.4181841107721719,
0.3969950887427858,
0.3778802687988367,
0.35795544527638645,
0.33848440737025043,
0.31833017621626836,
0.3000008925010669,
0.2825992262196214,
0.26690079026931857,
0.2523868307424034,
0.23917157721955895,
0.22700759290218503,
0.21584463917979668,
0.2055461725569271,
0.19603201054824548,
0.1872119029961149,
0.1790143710333849,
0.17137497974937604,
0.16423977268768425,
0.15756244566787808
],
"reference_has_gradients": false,
"scope": "Authored token preferences, not human annotation or pretrained-model alignment.",
"refusal": "distinct chosen and rejected completions required",
"command": [
"python",
"run.py",
"dpo",
"--record"
],
"exit_status": 0,
"environment": {
"python": "3.12.8",
"torch": "2.14.1",
"platform": "macOS-27.0.1-arm64-arm-64bit"
},
"sources": {
"updates.py": "471ac4ec96c3b092c0c7845a2f812d50dfaac4619fcc2a4bac19c8bd41992f0e",
"decoder.py": "5706ca21af551b17ee54ca00f5a6011981b327e03b1e880b6712c06dd7d1c39a",
"vendor/model.py": "0d3d469afd5f35ef19b04e0b0587143c39f8fbca37d6154e6435c4ed4e8b80fc",
"run.py": "bb3708aaf674c9cd55bd1a21276fb4d4a40e4cd4d15b239f1f994803142d50ce",
"test_updates.py": "bf71137ae859b1a405941645cdb0db4378fcf55478c548364570778d52186552"
}
}
run.py dpo performs new local computation and leaves saved files unchanged. --record deliberately replaces this stage’s results and report. The raw record retains the command, exit status, runtime and implementation hashes.
Failure cases
| Boundary checked | Required behavior |
|---|---|
| Identical preferences or invalid beta | Refuse identical completions and invalid beta instead of treating them as usable ordering data. |
| Prompt token alignment | Score only completion targets at their exact predecessor positions. |
The chosen and rejected completion are identical. The recorded refusal is distinct chosen and rejected completions required. Identical completions provide no ordering contrast. test_identical_pairs_and_invalid_beta_refused also rejects empty pairs and nonpositive or nonfinite beta. Fix the pair or configuration rather than interpreting the refusal as a model outcome.
The prompt boundary is scored incorrectly. test_completion_scoring_excludes_prompt_positions checks the first completion log probability against its exact predecessor logit. test_equal_policy_reference_starts_at_log_two checks the initial relative margin. These protect the objective’s input and baseline, while test_dpo_updates_reduce_loss_without_reference_gradients checks the actual update and isolation.
Run python -m unittest -v test_updates.py. In a separate copy, reverse chosen and rejected. Predict how the margin’s meaning changes before executing the update. Do not reinterpret reversed labels as an independent quality evaluation.
Interpret the result at its actual scope
The policy is trained autoregressively on completion probabilities, but this fixture has no pretrained knowledge or human preference collection. DPO changes the specified pairwise objective. Before using a similar update on real text, define a separate evaluation and retain the tokenizer, response masks, dataset split and reference model identity. This lesson supplies none of those large-model results.
Check your understanding
Read the explanation
The input contains prompt plus completion except its last token. Scoring starts at the predecessor of the first completion target.
References
Primary sources checked October 24, 2026. The saved local execution, rather than the cited research results, supplies the measurements above.