DPO — предпочтения без RL
Как из оптимальной политики RLHF за три шага получить DPO — обучение на парах «лучше/хуже» без reward model и без PPO; роль β, сравнение с PPO и обзор IPO, KTO, SimPO.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В прошлом уроке RLHF с PPO потребовал четыре модели, генерацию на каждом шаге и десяток капризных гиперпараметров. В 2023 году группа из Стэнфорда (Rafailov и др.) показала, что для той же цели — максимум награды при KL-поводке к референсной модели — reward model и RL не нужны вовсе. Достаточно одного loss по парам предпочтений, который учится так же просто, как SFT.
Метод назвали DPOпрямая оптимизация предпочтений (DPO)EN direct preference optimization (DPO)Rafailov и др., 2023. Обучение на парах предпочтений без reward model и без RL-цикла. (Direct Preference Optimization), а подзаголовок статьи объясняет главный трюк: «ваша языковая модель тайно является reward model». В 2024 году DPO стал стандартной ступенью пост-трейнинга открытых моделей, включая DeepSeek LLM Chat и Qwen2. Сегодня он остаётся одним из основных способов подогнать стиль, тон и безопасность, когда награду нельзя проверить автоматически.
flowchart LR subgraph PPO["RLHF с PPO"] direction LR A1["Пары предпочтений"] --> A2["Обучить reward model"] A2 --> A3["PPO: генерация, RM, критик, референс"] A3 --> A4["Политика"] end subgraph DPO["DPO"] direction LR B1["Пары предпочтений"] --> B2["Один loss: политика + замороженный референс"] B2 --> B4["Политика"] end
Вывод DPO за три шага
Заголовок раздела «Вывод DPO за три шага»Нам понадобятся две формулы из прошлых уроков: оптимальная политика при KL-штрафе (урок 6.3.1) и модель Брэдли–Терри (урок 6.3.2).
Шаг 1. Мы знаем оптимальную политику
Заголовок раздела «Шаг 1. Мы знаем оптимальную политику»Цель RLHF — максимизировать . Её решение мы уже видели:
Почему именно так? Распишем по определению и подставим :
Умножим обе части на и перенесём в другую сторону: цель RLHF равна . Второе слагаемое от не зависит, а KL неотрицательна и равна нулю только при (урок 1.3.3). Значит, максимум цели достигается ровно в .
Проблема в — нормировочной константе, которая делает сумму вероятностей равной 1 (в статьях её называют статсуммой, partition function): это сумма по всем возможным текстам, посчитать её нельзя. Поэтому RLHF и идёт обходным путём через PPO.
Шаг 2. Выразим награду через политику
Заголовок раздела «Шаг 2. Выразим награду через политику»Прологарифмируем формулу для и выразим из неё :
Это поворот всего вывода: любая политика задаёт свою награду, для которой она оптимальна. Награда «спрятана» в отношении вероятностей политики и референса. Её называют неявной наградойнеявная наградаEN implicit rewardВ DPO: r(x, y) = β · log(π(y|x) / π_ref(y|x)) — награда, «спрятанная» в самой политике..
Шаг 3. Подставим в Брэдли–Терри
Заголовок раздела «Шаг 3. Подставим в Брэдли–Терри»Модели предпочтений нужна только разность наград двух ответов на один и тот же промпт. Слагаемое зависит только от промпта и сокращается:
Неизвестная исчезла. Осталось заменить на обучаемую политику и максимизировать правдоподобие наблюдённых предпочтений — ровно как reward model в прошлом уроке:
Смысл: это тот же loss reward model, только награда здесь не отдельная сеть, а . Учим «reward model» — и оптимальная для неё политика получается бесплатно, потому что это она сама.
Как DPO считается на практике
Заголовок раздела «Как DPO считается на практике»Для каждой пары нужны четыре числа — лог-вероятности ответов под политикой и под референсом:
- Склеить «промпт + chosen» и «промпт + rejected», прогнать через политику, взять log-вероятности токенов.
- Сложить их только по токенам ответа — та же маска, что в SFT (урок 6.1.2). Получаем и .
- То же для референсной модели без градиентов. Референс не меняется, поэтому его лог-вероятности можно посчитать один раз заранее и не держать модель в памяти.
- Неявные награды, их разность, , среднее по батчу.
Пример с числами. , , у референса оба , . Награды: и . Отрыв , loss .
Проверка здравого смысла: на первом шаге , все награды нулевые, и loss равен
— как у reward model. TRL и другие библиотеки логируют rewards/chosen, rewards/rejected,
rewards/margins и rewards/accuracies (доля пар, где ). Смотрите на все четыре, а не только на loss.
Что делает градиент
Заголовок раздела «Что делает градиент»Поднимаем вероятность хорошего ответа, опускаем плохого, а вес пары тем больше, чем сильнее неявная награда ошибается. При отрыве (перепутан порядок) вес , при отрыве — уже . Авторы DPO показали, что без этого веса (просто «вверх chosen, вниз rejected») модель быстро деградирует.
β — длина поводка
Заголовок раздела «β — длина поводка»— тот же коэффициент KL-штрафа из RLHF. Он стоит внутри сигмоиды, поэтому задаёт, насколько сильно политика должна разойтись с референсом, чтобы loss стал маленьким. Пусть мы хотим loss 0.1 на паре, то есть (потому что ), где — разность лог-отношений, а нужное получаем делением: :
| Нужная разность (в натах) | Поведение | |
|---|---|---|
| 0.5 | 4.5 | короткий поводок: политика остаётся близко к референсу |
| 0.1 | 22.5 | стандарт (по умолчанию в статье DPO и в TRL) |
| 0.01 | 225 | длинный поводок: модель может уйти далеко и «сломаться» |
Маленький — сильнее подгонка под пары и выше риск деградации. Большой — осторожные изменения.
Проверим теорию: DPO сходится к π*
Заголовок раздела «Проверим теорию: DPO сходится к π*»Возьмём один промпт и 4 возможных ответа со скрытыми «истинными» наградами. Предпочтения сгенерируем по Брэдли–Терри, обучим DPO и сравним результат с формулой . Чтобы не зависеть от случайных сэмплов, используем «бесконечные данные»: все пары с вероятностями предпочтений.
import itertools
import torchimport torch.nn.functional as F
# Один промпт, 4 возможных ответа. Политика — просто логиты над ответами.answers = ["верно и кратко", "верно, но с водой", "уверенно неверно", "отказ"]r_true = torch.tensor([2.0, 1.5, -1.0, 0.0]) # скрытая «истинная» наградаref = torch.log_softmax(torch.tensor([0.0, 1.0, 0.5, -0.5]), 0) # π_ref (после SFT)beta = 1.0
# Все пары (i, j); вероятность, что человек предпочтёт i, — по Брэдли–Терриpairs = list(itertools.combinations(range(4), 2))i_idx = torch.tensor([i for i, _ in pairs])j_idx = torch.tensor([j for _, j in pairs])p_human = torch.sigmoid(r_true[i_idx] - r_true[j_idx])
logits = ref.clone().requires_grad_(True) # старт: π = π_refopt = torch.optim.Adam([logits], lr=0.05)for step in range(2001): logp = torch.log_softmax(logits, 0) h = beta * ((logp - ref)[i_idx] - (logp - ref)[j_idx]) # разница неявных наград loss = -(p_human * F.logsigmoid(h) + (1 - p_human) * F.logsigmoid(-h)).mean() opt.zero_grad() loss.backward() opt.step() if step in (0, 2000): print(f"шаг {step:4d}: DPO loss = {loss.item():.4f}")
pi_dpo = torch.softmax(logits, 0).detach()pi_star = torch.softmax(ref + r_true / beta, 0) # теория: π* ∝ π_ref·exp(r/β)for a, p0, p1, p2 in zip(answers, ref.exp(), pi_dpo, pi_star): print(f"{a:<18} π_ref={p0:.3f} DPO={p1:.3f} π*={p2:.3f}")шаг 0: DPO loss = 0.6931шаг 2000: DPO loss = 0.4241верно и кратко π_ref=0.167 DPO=0.356 π*=0.356верно, но с водой π_ref=0.455 DPO=0.586 π*=0.586уверенно неверно π_ref=0.276 DPO=0.029 π*=0.029отказ π_ref=0.102 DPO=0.029 π*=0.029DPO пришёл точно в , ни разу не построив reward model. Обратите внимание: «верно, но с водой» в итоге вероятнее, чем «верно и кратко», хотя его награда ниже. Референс любил этот ответ, а KL-поводок сохраняет его вкусы. Чтобы «кратко» победило, нужен меньший — или другой референс.
DPO против PPO
Заголовок раздела «DPO против PPO»| RLHF с PPO | DPO | |
|---|---|---|
| Модели | 4, две обучаются | 2, обучается одна (референс можно заменить готовыми логпробами) |
| Reward model | отдельная, обучается заранее | неявная, внутри политики |
| Генерация во время обучения | на каждом шаге (on-policy) | нет — готовые пары (offline) |
| Гиперпараметры | lr ×2, , , , , эпохи… | в основном и lr |
| Стабильность | капризный | как у SFT |
| Исследование | модель учится на своих новых ответах | только на ответах из датасета |
| Где сильнее | рассуждения, код, задачи с проверяемой наградой | стиль, тон, безопасность, «вкус» |
У простоты DPO есть цена — четыре известные слабости:
- Сдвиг распределения. Пары часто сгенерированы другими моделями. DPO смотрит только на разность двух ответов и может поднять вероятность третьих, которых нет ни в одной паре. Исследование Tsinghua (Xu и др., 2024) показало: аккуратно настроенный PPO обгоняет DPO во всех их тестах, включая соревновательное программирование. Лекарство — онлайн- или итеративный DPO: свежие пары из текущей политики, размеченные reward model.
- Обе вероятности падают. Loss зависит только от разности. Если упал с до ,
а — с до , loss не изменился, хотя хороший ответ стал в раз менее вероятен.
Такое часто видно в логах:
rewards/chosenуходит в минус. Помогает, например, добавка SFT-loss на chosen-ответах. - Переобучение на однозначных парах. Если предпочтение в паре абсолютное, оптимум Брэдли–Терри — бесконечный отрыв. DPO будет раздвигать лог-отношения без конца, и KL-поводок фактически перестаёт работать.
- Длина. Chosen-ответы в датасетах обычно длиннее, и DPO выучивает многословие — тот же reward hacking, только офлайн.
Варианты: IPO, KTO, SimPO, ORPO
Заголовок раздела «Варианты: IPO, KTO, SimPO, ORPO»Каждый вариант лечит одну из слабостей DPO:
| Метод | Нужен референс? | Данные | Идея |
|---|---|---|---|
| IPO (DeepMind, 2023) | да | пары | квадратичный loss : цель — конечный отрыв, нет переобучения (слабость № 3) |
| KTO (2024) | да | одиночные ответы с меткой «хорошо/плохо» | пары не нужны; loss построен по мотивам теории перспектив Канемана–Тверски: выигрыш и проигрыш относительно «точки отсчёта» весят по-разному |
| SimPO (2024) | нет | пары | награда — средняя лог-вероятность на токен и отступ в сигмоиде: экономит память и борется с длиной (№ 4) |
| ORPO (2024) | нет | пары | SFT и предпочтения в одном этапе через отношение шансов (odds ratio) |
SimPO целиком:
KTO особенно практичен: кнопки 👍/👎 в чат-интерфейсе дают ровно такие данные. А IPO вы реализуете в задании ниже.
Практика
Заголовок раздела «Практика»Задание: DPO loss по лог-вероятностям
Заголовок раздела «Задание: DPO loss по лог-вероятностям»Реализуйте сумму лог-вероятностей ответа с маской, устойчивый DPO loss с неявными наградами (как dpo_loss в TRL),
вес пары в градиенте и loss IPO.
import numpy as np
def sequence_logprob(token_logprobs, mask):
"""log π(y|x) для каждой строки батча.
token_logprobs — массив [B, T]: лог-вероятности токенов последовательности «промпт + ответ»;
mask — массив [B, T] из 0/1: 1 только у токенов ОТВЕТА (промпт и паддинг не считаем).
Вернуть массив [B] — сумму лог-вероятностей токенов ответа.
"""
# TODO: ваш код
...
def dpo_loss(pi_chosen, pi_rejected, ref_chosen, ref_rejected, beta=0.1):
"""DPO loss по логпробам целых ответов (массивы [B] или числа).
Неявные награды: r_c = beta * (pi_chosen - ref_chosen),
r_r = beta * (pi_rejected - ref_rejected).
loss = mean( -log sigmoid(r_c - r_r) ) — считать УСТОЙЧИВО (np.logaddexp).
Вернуть кортеж (loss: float, r_c: массив, r_r: массив) — по образцу dpo_loss в TRL
(только TRL возвращает loss каждого примера, а мы — сразу среднее).
"""
# TODO: ваш код
...
def dpo_weight(pi_chosen, pi_rejected, ref_chosen, ref_rejected, beta=0.1):
"""Вес каждого примера в градиенте DPO: sigmoid(r_r - r_c).
Близок к 1, когда неявная награда «ошибается» (r_r > r_c), и к 0, когда пара уже выучена.
"""
# TODO: ваш код
...
def ipo_loss(pi_chosen, pi_rejected, ref_chosen, ref_rejected, beta=0.1):
"""IPO (Azar и др., 2023): mean( (h - 1/(2*beta))**2 ),
где h = (pi_chosen - ref_chosen) - (pi_rejected - ref_rejected) — БЕЗ умножения на beta.
"""
# TODO: ваш код
...
# Проверьте себя кнопкой «Запустить»:
lp = np.array([[-0.5, -1.0, -2.0, -0.1]])
print(sequence_logprob(lp, [[0, 0, 1, 1]])) # ≈ [-2.1]
print(dpo_loss(-10.0, -12.0, -11.0, -11.0, beta=0.1)) # loss ≈ 0.598, награды 0.1 и -0.1
print(dpo_weight([-10.0, -12.0], [-12.0, -10.0], [-11.0, -11.0], [-11.0, -11.0], beta=0.1))
Локально: DPO на «игрушечной LLM»
Заголовок раздела «Локально: DPO на «игрушечной LLM»»Полный цикл DPO на PyTorch: модель предсказывает следующий токен, у каждой пары общий промпт из 4 токенов и ответ
из 8. Chosen-ответы состоят из «хороших» токенов 0–9, rejected — из «плохих» 40–49. Код почти без изменений переносится
на настоящую модель из transformers: меняется только model(ids) на model(ids).logits.
import copy
import torchimport torch.nn as nnimport torch.nn.functional as F
torch.manual_seed(0)V, d, B, P, T = 50, 16, 8, 4, 12 # словарь, ширина, батч, длина промпта, полная длинаpolicy = nn.Sequential(nn.Embedding(V, d), nn.Linear(d, V)) # «LLM»: логиты следующего токенаref = copy.deepcopy(policy).requires_grad_(False) # замороженная копия = π_ref
def seq_logp(model, ids, mask): """log π(y|x): сумма log-вероятностей токенов ОТВЕТА (сдвиг на 1, как в SFT).""" logits = model(ids[:, :-1]) # предсказания для позиций 1..T-1 # для каждой позиции берём log-вероятность того токена, который там действительно стоит logp = torch.log_softmax(logits, -1).gather(-1, ids[:, 1:, None]).squeeze(-1) return (logp * mask[:, 1:]).sum(-1)
def dpo_loss(chosen, rejected, mask, beta=0.1): pi_c, pi_r = seq_logp(policy, chosen, mask), seq_logp(policy, rejected, mask) with torch.no_grad(): # референс не обучается ref_c, ref_r = seq_logp(ref, chosen, mask), seq_logp(ref, rejected, mask) r_c, r_r = beta * (pi_c - ref_c), beta * (pi_r - ref_r) # неявные награды loss = -F.logsigmoid(r_c - r_r).mean() return loss, r_c.detach(), r_r.detach(), pi_c.detach(), pi_r.detach()
prompt = torch.randint(0, V, (B, P))chosen = torch.cat([prompt, torch.randint(0, 10, (B, T - P))], 1) # «хорошие» токены 0..9rejected = torch.cat([prompt, torch.randint(40, 50, (B, T - P))], 1) # «плохие» токены 40..49mask = torch.cat([torch.zeros(B, P), torch.ones(B, T - P)], 1) # loss только по ответу
opt = torch.optim.Adam(policy.parameters(), lr=1e-2)for step in range(101): loss, r_c, r_r, pi_c, pi_r = dpo_loss(chosen, rejected, mask) if step % 50 == 0: acc = (r_c > r_r).float().mean() print(f"шаг {step:3d}: loss {loss.item():.3f} | награды chosen {r_c.mean():+.2f}, " f"rejected {r_r.mean():+.2f} | точность {acc:.2f} | log π: {pi_c.mean():.1f} / {pi_r.mean():.1f}") opt.zero_grad() loss.backward() opt.step()шаг 0: loss 0.693 | награды chosen +0.00, rejected +0.00 | точность 0.00 | log π: -33.7 / -32.8шаг 50: loss 0.005 | награды chosen +1.32, rejected -3.94 | точность 1.00 | log π: -20.5 / -72.3шаг 100: loss 0.001 | награды chosen +1.37, rejected -5.15 | точность 1.00 | log π: -19.9 / -84.3Старт — ровно , точность 0, потому что награды равны (строгое «больше» не выполняется). Дальше заметна асимметрия: rejected «утопили» гораздо сильнее (награда ), чем подняли chosen (). DPO охотнее выигрывает отрыв за счёт плохих ответов. На настоящих данных, где chosen и rejected похожи, это и превращается в слабость № 2.
Проверь себя
Заголовок раздела «Проверь себя»- Оптимальная политика RLHF позволяет выразить награду через политику: . Статсумма сокращается в модели Брэдли–Терри.
- DPO — это loss reward model, где награда спрятана в самой политике. Две модели, нет генерации, обучение как SFT. Старт — .
- — длина KL-поводка. Вес пары в градиенте фокусирует обучение на ошибках.
- Слабости DPO — офлайн-данные, падение вероятности chosen, переобучение, длина. Их лечат онлайн-DPO, IPO, KTO, SimPO. Для рассуждений индустрия перешла к онлайн-RL — GRPO.
Дальше: модуль 6.4 — GRPO из DeepSeekMath и путь DeepSeek-R1: RL без критика, с проверяемыми наградами и группой ответов вместо value-модели.