개념
핵심 통찰
RLHF는 다음 목적 함수(objective)를 최적화합니다.
maximize: E[R(x, y)] - beta * KL(pi || pi_ref)
여기서 R은 보상 모델, pi는 정책, pi_ref는 참조 모델, beta는 KL 계수입니다.
DPO 논문은 이 목적 함수에 닫힌 형태(closed-form)의 최적해가 있음을 보였습니다. 임의의 보상 함수 R에 대해 최적 정책은 다음과 같습니다.
pi*(y | x) = pi_ref(y | x) * exp(R(x, y) / beta) / Z(x)
여기서 Z(x)는 정규화 상수(normalizing constant)입니다. 이를 다시 정리하면 다음과 같습니다.
R(x, y) = beta * log(pi*(y | x) / pi_ref(y | x)) + beta * log Z(x)
이 식이 돌파구입니다. 보상이 정책 모델의 확률과 참조 모델의 확률만으로 표현됩니다. 별도의 보상 모델을 학습할 필요가 없습니다. 보상은 확률 비율(probability ratio)에 암묵적으로 들어 있습니다.
이를 브래들리-테리 선호 모델(Bradley-Terry preference model)에 대입하면 다음과 같습니다.
P(y_w > y_l | x) = sigmoid(R(x, y_w) - R(x, y_l))
= sigmoid(beta * (log pi(y_w|x)/pi_ref(y_w|x) - log pi(y_l|x)/pi_ref(y_l|x)))
두 응답 모두 같은 프롬프트 x를 조건으로 하므로 Z(x) 항은 서로 소거됩니다. 남는 것은 선호 응답(preferred response)과 거절 응답(rejected response)에 대한 정책 모델과 참조 모델의 로그 확률뿐입니다.
DPO 손실
L_DPO = -log(sigmoid(beta * (log pi(y_w|x)/pi_ref(y_w|x) - log pi(y_l|x)/pi_ref(y_l|x))))
각 기호를 풀어보면 다음과 같습니다.
- y_w = 선호된, 이긴 응답(preferred or winning response)
- y_l = 거절된, 진 응답(rejected or losing response)
- x = 프롬프트(prompt)
- pi = 현재 학습 중인 모델(current model)
- pi_ref = 참조 모델(reference model), 보통 고정된 SFT 체크포인트(frozen SFT checkpoint)
- beta = 참조 모델에서 벗어나는 정도를 제어하는 온도 파라미터(temperature parameter), 보통 0.1에서 0.5 사이
log pi(y|x) / pi_ref(y|x) 비율은 로그 확률 비율(log-probability ratio)입니다. 이 값이 양수이면 현재 모델이 참조 모델보다 응답 y에 더 높은 확률을 부여한다는 뜻입니다. 음수이면 현재 모델이 더 낮은 확률을 부여한다는 뜻입니다.
DPO 손실은 선호 응답의 로그 확률 비율을 높이고, 거절 응답의 로그 확률 비율을 낮추도록 모델을 밀어줍니다. beta 파라미터는 모델이 참조 모델에서 얼마나 공격적으로 벗어날 수 있는지 조절합니다. 작은 beta는 큰 이탈을 허용하고, 큰 beta는 모델을 참조 모델 가까이에 둡니다.
graph TD
subgraph DPO["DPO 학습(DPO Training)"]
direction TB
D["선호 데이터셋\n(prompt, winner, loser)"] --> P1["현재 모델에서\nlog P(winner) 계산"]
D --> P2["현재 모델에서\nlog P(loser) 계산"]
D --> R1["참조 모델에서\nlog P(winner) 계산"]
D --> R2["참조 모델에서\nlog P(loser) 계산"]
P1 --> RATIO_W["로그 비율(winner)\nlog pi/pi_ref"]
R1 --> RATIO_W
P2 --> RATIO_L["로그 비율(loser)\nlog pi/pi_ref"]
R2 --> RATIO_L
RATIO_W --> DIFF["beta * (ratio_w - ratio_l)"]
RATIO_L --> DIFF
DIFF --> LOSS["-log sigmoid(diff)"]
LOSS --> UPDATE["현재 모델에\n그래디언트 업데이트"]
end
subgraph Models["모델"]
PI["현재 모델(pi)\n매 단계 업데이트"]
REF["참조 모델(pi_ref)\n고정된 SFT 체크포인트"]
end
Models --> DPO
style PI fill:#1a1a2e,stroke:#0f3460,color:#fff
style REF fill:#1a1a2e,stroke:#0f3460,color:#fff
style LOSS fill:#1a1a2e,stroke:#e94560,color:#fff
style DIFF fill:#1a1a2e,stroke:#e94560,color:#fff
DPO가 더 단순한 이유
| 항목 | RLHF(PPO) | DPO |
|---|
| 학습할 모델 | 3개(SFT + 보상 + 정책) | 1개(정책만) |
| 학습 루프 | 3개(SFT, 보상 모델 학습, PPO) | 2개(SFT, DPO) |
| 하이퍼파라미터 | lr, KL 계수, 클립 비율, 보상 모델 lr, 각 단계 에포크 | lr, beta, epochs |
| 보상 모델 | 필요함, 별도 학습 | 모델 확률에 암묵적으로 포함 |
| RL 알고리즘 | PPO, 복잡하고 불안정함 | 지도학습, 안정적임 |
| GPU 메모리 | PPO 중 3-4개 모델 | 2개 모델(현재 + 참조) |
| 학습 안정성 | 하이퍼파라미터에 민감함 | SFT와 비슷하게 견고함 |
DPO는 학습 중 현재 모델과 고정된 참조 모델 두 개가 필요합니다. RLHF는 정책, 참조 모델, 보상 모델, 선택적으로 가치 함수 기준선(value function baseline)까지 세 개 또는 네 개가 필요합니다. 70B 모델은 FP16에서 복사본 하나가 140GB를 차지합니다. 보상 모델을 제거하면 메모리 절감 효과가 큽니다.
DPO가 RLHF보다 유리한 경우
작은 데이터셋. 5,000-20,000개의 선호 쌍이 있을 때 DPO는 RLHF와 비슷하거나 더 나은 결과를 자주 냅니다. RLHF의 보상 모델은 일반화할 만큼 충분한 데이터가 필요합니다. 데이터가 제한적이면 과적합(overfitting)되고 불안정한 보상 신호를 만듭니다. DPO는 보상 모델이 필요 없으므로 이 문제를 우회합니다.
제한된 계산 자원. DPO는 전체 RLHF의 약 3분의 1 정도의 계산량을 요구합니다. 학습 루프가 세 개가 아니라 하나이기 때문입니다. 큰 GPU 클러스터가 없는 팀에게 실용적인 선택입니다.
빠른 반복. 어떤 선호 데이터셋이 가장 좋은 모델을 만드는지 확인하려고 10개 데이터셋을 시도하고 싶다면 DPO는 각 실험을 몇 시간 단위로 돌릴 수 있습니다. RLHF는 데이터셋마다 보상 모델을 다시 학습해야 합니다.
RLHF가 DPO보다 유리한 경우
대규모 학습. GPT-4나 Claude 규모에서는 별도 보상 모델이 더 미묘한 선호 신호를 포착할 수 있습니다. 보상 모델은 복잡한 품질 기준에 적응하는 학습된 손실 함수(learned loss function)처럼 동작합니다.
복잡한 보상 신호. "더 좋다"가 유용성(helpfulness), 무해성(harmlessness), 정직성(honesty) 같은 여러 차원을 포함할 때, 보상 모델은 이 다목적 절충(multi-objective tradeoff)을 학습할 수 있습니다. DPO는 각 선호 쌍을 한쪽이 더 좋고 다른 한쪽이 더 나쁜 이진 신호(binary signal)로 다룹니다. 왜 더 좋은지는 모델링하지 않습니다.
반복적 정렬(iterative alignment). RLHF 파이프라인은 현재 정책으로 새 응답을 생성하고, 사람이 평가하고, 보상 모델을 온라인 루프(online loop)에서 다시 학습할 수 있습니다. DPO는 고정된 선호 쌍 데이터셋에서 동작합니다. Anthropic의 Constitutional AI는 RLHF의 이 반복적 특성을 폭넓게 사용합니다.
DPO 이후: KTO, ORPO, SimPO
DPO는 단순화된 정렬 방법들의 계열을 만들었습니다.
KTO(Kahneman-Tversky Optimization, 2024): 쌍(pair)조차 필요 없습니다. KTO는 짝이 없는 피드백(unpaired feedback), 즉 각 응답을 "좋음(good)" 또는 "나쁨(bad)"으로만 라벨링한 데이터로 동작합니다. 이는 데이터 수집을 크게 단순화합니다. 주석자(annotator)에게 두 응답을 보여주고 "어느 쪽이 더 좋은가?"라고 묻는 대신, 하나의 응답을 보여주고 "이 응답이 좋은가?"라고 묻습니다. 손실 함수는 전망 이론(prospect theory)의 손실 회피(loss aversion)를 적용합니다. 나쁜 응답은 좋은 응답이 보상받는 것보다 더 강하게 벌점을 받습니다.
ORPO(Odds Ratio Preference Optimization, 2024): SFT와 정렬을 하나의 학습 단계로 결합합니다. 먼저 SFT를 하고 그다음 DPO를 하는 대신, ORPO는 SFT 손실을 수정해 선호 신호를 포함합니다. 손실은 두 항을 가집니다. 선호 응답에 대한 표준 다음 토큰 예측 손실(next-token prediction loss)과, 선호 응답과 거절 응답의 확률 차이를 키우는 오즈 비율(odds ratio) 항입니다. 두 개의 학습 루프 대신 하나의 학습 루프입니다.
SimPO(Simple Preference Optimization, 2024): 참조 모델을 완전히 제거합니다. 고정된 참조와 로그 확률 비율을 계산하는 대신, SimPO는 응답의 평균 로그 확률(길이로 정규화)을 암묵적 보상으로 사용합니다. 참조 모델이 필요 없으므로 메모리를 절약하고 학습을 단순화합니다. 길이 정규화(length normalization)는 모델이 짧은 응답을 선호하지 않도록 막습니다.
| 방법 | 연도 | 메모리에 필요한 모델 | 쌍 데이터 필요? | 참조 모델 필요? | 학습 루프 |
|---|
| RLHF | 2022 | 3-4 | 예, 보상 모델용 | 예 | 3 |
| DPO | 2023 | 2 | 예 | 예 | 2 |
| KTO | 2024 | 2 | 아니요, 짝 없는 라벨 | 예 | 2 |
| ORPO | 2024 | 1 | 예 | 아니요 | 1 |
| SimPO | 2024 | 1 | 예 | 아니요 | 1 |
흐름은 분명합니다. 각 방법은 복잡성을 하나씩 더 제거합니다. RLHF에는 보상 모델과 PPO가 필요했습니다. DPO는 둘 다 제거했습니다. KTO는 쌍 데이터 요구를 제거했습니다. ORPO는 별도 SFT 단계를 제거했습니다. SimPO는 참조 모델을 제거했습니다. 정렬 비용(alignment tax), 즉 기반 모델(base model)을 정렬된 모델(aligned model)로 만드는 데 필요한 계산, 데이터, 복잡성의 비용이 계속 낮아지고 있습니다.
실제 DPO 적용 사례
Zephyr-7B(Hugging Face, 2023년 10월): Mistral 7B base를 UltraChat 200K 예제로 SFT한 뒤, UltraFeedback 60K 선호 쌍으로 DPO를 수행했습니다. MT-Bench에서 6.47점을 기록했으며, 당시 7B 모델 중 최고였습니다. 비교를 위해 Llama 2 Chat 70B는 6.86점이었습니다. Zephyr는 DPO 정렬만으로 자신보다 10배 큰 모델의 6% 이내 성능에 도달했습니다.
Llama 3(Meta, 2024년 4월): 초기 RLHF 단계 이후 DPO를 사용했습니다. 이 조합은 DPO와 RLHF가 서로 보완적일 수 있음을 보여줍니다. RLHF는 넓은 정렬(broad alignment)에, DPO는 목표 지향적 개선(targeted refinement)에 사용할 수 있습니다.
Neural Magic / nm-chat(2024): 여러 오픈소스 모델에 DPO를 적용했고, SFT만 사용한 기준선(baseline)보다 정렬 벤치마크에서 일관되게 5-15% 향상을 보였습니다.
직접 만들기
Step 1: 선호 데이터셋
RLHF와 같은 형식인 (prompt, preferred, rejected) 삼중항(triple)을 사용합니다. DPO는 중간 보상 모델 없이 이 데이터를 직접 소비합니다.
import numpy as np
import sys
import os
sys.path.insert(
0,
os.path.join(
os.path.dirname(__file__), "..", "..", "04-pre-training-mini-gpt", "code"
),
)
from main import MiniGPT, LayerNorm, Embedding, TransformerBlock
PREFERENCE_DATA = [
{
"prompt": "What is the capital of France?",
"preferred": "The capital of France is Paris.",
"rejected": "France is a country in Europe. It has many cities. The capital is Paris. Paris is known for the Eiffel Tower.",
},
{
"prompt": "Explain gravity in one sentence.",
"preferred": "Gravity is the force that attracts objects with mass toward each other.",
"rejected": "Gravity is something that makes things fall down when you drop them.",
},
{
"prompt": "What is 15 times 7?",
"preferred": "15 times 7 is 105.",
"rejected": "Let me think about this. 15 times 7. Well, 10 times 7 is 70, and 5 times 7 is 35, so the answer might be around 105.",
},
{
"prompt": "Name three programming languages.",
"preferred": "Python, Rust, and TypeScript.",
"rejected": "There are many programming languages. Some popular ones include various languages like Python and others.",
},
{
"prompt": "What year did World War II end?",
"preferred": "World War II ended in 1945.",
"rejected": "World War II was a major global conflict. It involved many countries. The war ended in the mid-1940s, specifically in 1945.",
},
{
"prompt": "Define machine learning.",
"preferred": "Machine learning is a field where algorithms learn patterns from data to make predictions without being explicitly programmed.",
"rejected": "Machine learning is a type of AI. AI stands for artificial intelligence. Machine learning uses data to learn.",
},
]
Step 2: 시퀀스 로그 확률
DPO 손실에는 프롬프트가 주어졌을 때 응답의 전체 로그 확률(total log-probability)을 계산하는 과정이 필요합니다. 모델에 전체 (prompt + response) 시퀀스를 넣고 각 응답 토큰의 로그 확률을 합산한다는 뜻입니다.
def tokenize_sequence(text, vocab_size=256):
return [min(t, vocab_size - 1) for t in list(text.encode("utf-8"))]
def compute_sequence_log_prob(model, prompt_tokens, response_tokens, max_seq_len=128):
full_sequence = prompt_tokens + response_tokens
if len(full_sequence) > max_seq_len:
full_sequence = full_sequence[:max_seq_len]
if len(full_sequence) < 2:
return 0.0
input_ids = np.array(full_sequence[:-1]).reshape(1, -1)
target_ids = np.array(full_sequence[1:])
logits = model.forward(input_ids)
logits = logits[0]
max_logits = logits.max(axis=-1, keepdims=True)
log_probs = logits - max_logits - np.log(
np.exp(logits - max_logits).sum(axis=-1, keepdims=True)
)
prompt_len = len(prompt_tokens)
response_start = max(0, prompt_len - 1)
response_end = len(target_ids)
if response_start >= response_end:
return 0.0
response_log_probs = log_probs[response_start:response_end, :]
response_targets = target_ids[response_start:response_end]
total_log_prob = 0.0
for i, target in enumerate(response_targets):
total_log_prob += response_log_probs[i, target]
return total_log_prob
이 함수는 DPO의 핵심 작업자(workhorse)입니다. 각 선호 쌍마다 네 번 실행됩니다. 현재 모델에서 선호 응답, 현재 모델에서 거절 응답, 참조 모델에서 선호 응답, 참조 모델에서 거절 응답입니다. 즉 학습 예제 하나당 4번의 순전파(forward pass)를 수행합니다. RLHF의 생성, 보상 점수 계산, 가치 추정(value estimation), PPO 업데이트보다 단순하고 빠르며 안정적입니다.
Step 3: DPO 손실
논문의 핵심을 코드로 옮기면 하나의 함수, 하나의 손실입니다. 보상 모델은 없습니다.
def sigmoid(x):
return np.where(
x >= 0,
1.0 / (1.0 + np.exp(-x)),
np.exp(x) / (1.0 + np.exp(x))
)
def dpo_loss(policy_logprob_preferred, policy_logprob_rejected,
ref_logprob_preferred, ref_logprob_rejected, beta=0.1):
preferred_ratio = policy_logprob_preferred - ref_logprob_preferred
rejected_ratio = policy_logprob_rejected - ref_logprob_rejected
logit = beta * (preferred_ratio - rejected_ratio)
loss = -np.log(sigmoid(logit) + 1e-8)
preferred_reward = beta * preferred_ratio
rejected_reward = beta * rejected_ratio
return loss, {
"preferred_ratio": float(preferred_ratio),
"rejected_ratio": float(rejected_ratio),
"logit": float(logit),
"implicit_preferred_reward": float(preferred_reward),
"implicit_rejected_reward": float(rejected_reward),
"reward_margin": float(preferred_reward - rejected_reward),
}
preferred_ratio와 rejected_ratio는 DPO 유도식에서 나온 로그 확률 비율입니다. 현재 모델이 참조 모델 대비 선호 응답에는 더 높은 확률을, 거절 응답에는 더 낮은 확률을 부여하면 logit은 양수가 되고 손실은 낮아집니다. 학습 신호는 정확히 이 방향으로 모델을 밀어줍니다.
implicit_preferred_reward와 implicit_rejected_reward는 DPO 손실이 암묵적으로 부여하는 보상입니다. 이를 꺼내 보면 학습이 잘 진행되는지 확인할 수 있습니다. 학습이 진행될수록 선호 응답과 거절 응답 사이의 보상 마진(reward margin)이 커져야 합니다.
Step 4: DPO 학습 루프
표준 지도학습 루프입니다. PPO도 없고, 보상 모델도 없습니다. 순전파와 그래디언트 업데이트만 있습니다.
def copy_model_weights(source, target):
target.embedding.token_embed = source.embedding.token_embed.copy()
target.embedding.pos_embed = source.embedding.pos_embed.copy()
target.ln_f.gamma = source.ln_f.gamma.copy()
target.ln_f.beta = source.ln_f.beta.copy()
for s_block, t_block in zip(source.blocks, target.blocks):
t_block.attn.W_q = s_block.attn.W_q.copy()
t_block.attn.W_k = s_block.attn.W_k.copy()
t_block.attn.W_v = s_block.attn.W_v.copy()
t_block.attn.W_out = s_block.attn.W_out.copy()
t_block.ffn.W1 = s_block.ffn.W1.copy()
t_block.ffn.W2 = s_block.ffn.W2.copy()
t_block.ffn.b1 = s_block.ffn.b1.copy()
t_block.ffn.b2 = s_block.ffn.b2.copy()
t_block.ln1.gamma = s_block.ln1.gamma.copy()
t_block.ln1.beta = s_block.ln1.beta.copy()
t_block.ln2.gamma = s_block.ln2.gamma.copy()
t_block.ln2.beta = s_block.ln2.beta.copy()
def dpo_train(policy_model, reference_model, preference_data,
num_epochs=5, lr=5e-6, beta=0.1, max_seq_len=128):
print(f"DPO 학습(DPO Training): {len(preference_data)}개 선호 쌍, "
f"{num_epochs} 에포크, lr={lr}, beta={beta}")
print()
losses = []
margins = []
for epoch in range(num_epochs):
epoch_loss = 0.0
epoch_margin = 0.0
num_examples = 0
indices = np.random.permutation(len(preference_data))
for idx in indices:
pair = preference_data[idx]
prompt_tokens = tokenize_sequence(pair["prompt"])
preferred_tokens = tokenize_sequence(pair["preferred"])
rejected_tokens = tokenize_sequence(pair["rejected"])
pi_logprob_w = compute_sequence_log_prob(
policy_model, prompt_tokens, preferred_tokens, max_seq_len
)
pi_logprob_l = compute_sequence_log_prob(
policy_model, prompt_tokens, rejected_tokens, max_seq_len
)
ref_logprob_w = compute_sequence_log_prob(
reference_model, prompt_tokens, preferred_tokens, max_seq_len
)
ref_logprob_l = compute_sequence_log_prob(
reference_model, prompt_tokens, rejected_tokens, max_seq_len
)
loss, metrics = dpo_loss(
pi_logprob_w, pi_logprob_l,
ref_logprob_w, ref_logprob_l, beta
)
update_direction = 1.0 if metrics["logit"] < 0 else -0.1
for block in policy_model.blocks:
block.ffn.W1 += lr * update_direction * np.random.randn(*block.ffn.W1.shape) * 0.01
block.ffn.W2 += lr * update_direction * np.random.randn(*block.ffn.W2.shape) * 0.01
epoch_loss += loss
epoch_margin += metrics["reward_margin"]
num_examples += 1
losses.append(float(loss))
margins.append(metrics["reward_margin"])
avg_loss = epoch_loss / max(num_examples, 1)
avg_margin = epoch_margin / max(num_examples, 1)
print(f" 에포크 {epoch + 1}/{num_epochs} | 손실(Loss): {avg_loss:.4f} | "
f"평균 마진(Avg Margin): {avg_margin:.4f}")
return policy_model, losses, margins
학습 루프는 RLHF와 비교하면 놀랄 만큼 단순합니다. 각 선호 쌍마다 네 개의 로그 확률을 계산합니다. 두 모델, 두 응답입니다. 그 값을 DPO 손실에 넣고, 그래디언트를 계산하고, 정책을 업데이트합니다. 생성 단계도 없고, 보상 모델 추론도 없고, 어드밴티지 추정(advantage estimation)도 없고, 클리핑도 없습니다.
Step 5: DPO와 RLHF 비교
암묵적 보상 마진과 로그 확률 변화를 측정해 Lesson 07의 RLHF 모델과 DPO를 비교합니다.
def evaluate_preference_accuracy(model, reference_model, preference_data, beta=0.1, max_seq_len=128):
correct = 0
total = 0
for pair in preference_data:
prompt_tokens = tokenize_sequence(pair["prompt"])
preferred_tokens = tokenize_sequence(pair["preferred"])
rejected_tokens = tokenize_sequence(pair["rejected"])
pi_w = compute_sequence_log_prob(model, prompt_tokens, preferred_tokens, max_seq_len)
pi_l = compute_sequence_log_prob(model, prompt_tokens, rejected_tokens, max_seq_len)
ref_w = compute_sequence_log_prob(reference_model, prompt_tokens, preferred_tokens, max_seq_len)
ref_l = compute_sequence_log_prob(reference_model, prompt_tokens, rejected_tokens, max_seq_len)
preferred_reward = beta * (pi_w - ref_w)
rejected_reward = beta * (pi_l - ref_l)
if preferred_reward > rejected_reward:
correct += 1
total += 1
return correct / max(total, 1)
def analyze_implicit_rewards(model, reference_model, preference_data, beta=0.1, max_seq_len=128):
print("암묵적 보상 분석(Implicit Reward Analysis):")
print("-" * 65)
print(f" {'프롬프트(Prompt)':<30} {'선호 보상':>12} {'거절 보상':>12} {'마진':>10}")
print(" " + "-" * 60)
for pair in preference_data:
prompt_tokens = tokenize_sequence(pair["prompt"])
preferred_tokens = tokenize_sequence(pair["preferred"])
rejected_tokens = tokenize_sequence(pair["rejected"])
pi_w = compute_sequence_log_prob(model, prompt_tokens, preferred_tokens, max_seq_len)
pi_l = compute_sequence_log_prob(model, prompt_tokens, rejected_tokens, max_seq_len)
ref_w = compute_sequence_log_prob(reference_model, prompt_tokens, preferred_tokens, max_seq_len)
ref_l = compute_sequence_log_prob(reference_model, prompt_tokens, rejected_tokens, max_seq_len)
pref_reward = beta * (pi_w - ref_w)
rej_reward = beta * (pi_l - ref_l)
margin = pref_reward - rej_reward
truncated = pair["prompt"][:28] + ".." if len(pair["prompt"]) > 30 else pair["prompt"]
print(f" {truncated:<30} {pref_reward:>12.4f} {rej_reward:>12.4f} {margin:>10.4f}")
print()
Step 6: 베타 민감도 분석
beta 파라미터는 RLHF에서 KL 계수와 비슷한 역할을 합니다. 모델이 참조 모델에서 얼마나 벗어날 수 있는지 제어합니다. 이 실험은 그 효과를 보여줍니다.
def beta_sensitivity_analysis(sft_model, preference_data, betas, max_seq_len=128):
print("베타 민감도 분석(Beta Sensitivity Analysis)")
print("-" * 60)
print(f" {'Beta':>8} {'최종 손실':>12} {'최종 마진':>14} {'정확도':>10}")
print(" " + "-" * 55)
results = []
for beta in betas:
policy = MiniGPT(
vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, max_seq_len=max_seq_len, ff_dim=512
)
reference = MiniGPT(
vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, max_seq_len=max_seq_len, ff_dim=512
)
copy_model_weights(sft_model, policy)
copy_model_weights(sft_model, reference)
policy, losses, margins_list = dpo_train(
policy, reference, preference_data,
num_epochs=3, lr=5e-6, beta=beta, max_seq_len=max_seq_len
)
accuracy = evaluate_preference_accuracy(
policy, reference, preference_data, beta, max_seq_len
)
final_loss = losses[-1] if losses else 0
final_margin = margins_list[-1] if margins_list else 0
print(f" {beta:>8.3f} {final_loss:>12.4f} {final_margin:>14.4f} {accuracy:>10.1%}")
results.append({
"beta": beta,
"final_loss": final_loss,
"final_margin": final_margin,
"accuracy": accuracy,
})
print()
return results
작은 beta, 예를 들어 0.01은 모델이 참조 모델에서 자유롭게 벗어나도록 합니다. 빠르게 배울 수 있지만 퇴화된 해(degenerate solution)에 빠질 위험이 있습니다. 큰 beta, 예를 들어 1.0은 모델을 참조 모델 가까이에 둡니다. 안정적이지만 학습은 느립니다. 대부분의 애플리케이션에서는 0.1에서 0.3 사이가 적절한 시작점입니다.
사용해보기
전체 DPO 파이프라인 데모
if __name__ == "__main__":
np.random.seed(42)
print("=" * 70)
print("DPO: 직접 선호 최적화(Direct Preference Optimization)")
print("=" * 70)
print()
print("STEP 1: SFT 모델 초기화(06번 레슨 기반)")
print("-" * 50)
sft_model = MiniGPT(
vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, max_seq_len=128, ff_dim=512
)
print(f" 파라미터 수(Parameters): {sft_model.count_parameters():,}")
print()
print("STEP 2: DPO 학습")
print("-" * 50)
policy_model = MiniGPT(
vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, max_seq_len=128, ff_dim=512
)
reference_model = MiniGPT(
vocab_size=256, embed_dim=128, num_heads=4,
num_layers=4, max_seq_len=128, ff_dim=512
)
copy_model_weights(sft_model, policy_model)
copy_model_weights(sft_model, reference_model)
policy_model, losses, margins = dpo_train(
policy_model, reference_model, PREFERENCE_DATA,
num_epochs=5, lr=5e-6, beta=0.1
)
print()
print("=" * 70)
print("STEP 3: 평가")
print("=" * 70)
print()
pre_accuracy = evaluate_preference_accuracy(
sft_model, reference_model, PREFERENCE_DATA, beta=0.1
)
post_accuracy = evaluate_preference_accuracy(
policy_model, reference_model, PREFERENCE_DATA, beta=0.1
)
print(f" 선호 정확도(DPO 전): {pre_accuracy:.1%}")
print(f" 선호 정확도(DPO 후): {post_accuracy:.1%}")
print()
analyze_implicit_rewards(policy_model, reference_model, PREFERENCE_DATA, beta=0.1)
print("=" * 70)
print("STEP 4: 학습 동역학(Training Dynamics)")
print("=" * 70)
print()
if losses:
print(" 손실 곡선(Loss curve):")
window = max(1, len(losses) // 5)
for i in range(0, len(losses), window):
chunk = losses[i:i + window]
avg = sum(chunk) / len(chunk)
print(f" 단계 {i:3d}-{i + len(chunk) - 1:3d}: loss = {avg:.4f}")
print()
if margins:
print(" 보상 마진 곡선(Reward margin curve):")
window = max(1, len(margins) // 5)
for i in range(0, len(margins), window):
chunk = margins[i:i + window]
avg = sum(chunk) / len(chunk)
print(f" 단계 {i:3d}-{i + len(chunk) - 1:3d}: margin = {avg:.4f}")
print()
print("=" * 70)
print("STEP 5: 베타 민감도")
print("=" * 70)
print()
beta_results = beta_sensitivity_analysis(
sft_model, PREFERENCE_DATA, betas=[0.01, 0.1, 0.3, 1.0]
)
print("=" * 70)
print("DPO vs RLHF 비교")
print("=" * 70)
print()
print(" DPO 장점:")
print(" - 학습 루프 1개(RLHF는 3개)")
print(" - 메모리 안의 모델 2개(RLHF는 3-4개)")
print(" - 강화학습보다 안정적인 지도학습")
print(" - 학습하거나 유지할 보상 모델이 없음")
print()
print(" RLHF 장점:")
print(" - 별도 보상 모델이 복잡한 선호를 포착함")
print(" - 온라인 학습: 생성, 평가, 재학습 가능")
print(" - 다목적 정렬에 더 적합함")
print(" - 가장 큰 규모의 모델에서 검증됨(GPT-4, Claude)")
print()
print(" 실무 가이드:")
print(" - DPO부터 시작합니다. 더 단순하고 자주 충분합니다.")
print(" - DPO가 평가 지표에서 정체되면 RLHF로 전환합니다.")
print(" - 많은 프로덕션 시스템은 둘 다 씁니다. 먼저 RLHF, 그다음 DPO로 세밀하게 개선합니다.")
산출물 만들기
이 레슨은 outputs/prompt-alignment-method-selector.md를 만듭니다. 이 프롬프트는 사용 사례에 맞는 정렬 방법, 즉 SFT, RLHF, DPO, KTO, ORPO, SimPO 중 하나를 고르는 데 도움을 줍니다. 사용할 수 있는 데이터, 계산 예산, 정렬 목표를 입력하면 적합한 방법과 학습 계획을 추천합니다.
연습문제
-
KTO(Kahneman-Tversky Optimization)를 구현합니다. KTO는 쌍이 필요 없습니다. 각 응답을 "좋음(good)" 또는 "나쁨(bad)"으로만 라벨링하면 됩니다. 좋은 응답의 손실은 -log(sigmoid(beta * log_ratio))이고, 나쁜 응답의 손실은 -log(1 - sigmoid(beta * log_ratio))입니다. 나쁜 응답 손실에는 보통 1.5배의 손실 회피 배수(loss aversion multiplier)를 적용합니다. 같은 데이터를 사용하되 preferred는 독립적인 "good", rejected는 독립적인 "bad"로 취급해 학습하고, DPO와 정확도를 비교합니다.
-
길이 정규화 DPO(length-normalized DPO)를 구현합니다. 원시 로그 확률(raw log-probability)을 그대로 쓰는 대신 응답 토큰 수로 나눕니다. normalized_logprob = total_logprob / num_tokens입니다. 이렇게 하면 전체 로그 확률이 더 높은 짧은 응답을 모델이 선호하는 문제를 막을 수 있습니다. 정규화를 적용한 경우와 적용하지 않은 경우의 암묵적 보상 마진을 비교합니다.
-
ORPO 스타일의 결합 손실(combined loss)을 만듭니다. 선호 응답에 대한 표준 다음 토큰 예측 손실을 DPO 손실에 더합니다. L = L_sft(preferred) + alpha * L_dpo입니다. alpha 값을 0.1, 0.5, 1.0으로 시도합니다. 결합 손실은 SFT 항을 통해 지시를 따르고, DPO 항을 통해 더 좋은 응답을 선호하는 모델을 만들어야 합니다. 이렇게 하면 별도 SFT 단계가 필요 없어집니다.
-
반복적 DPO(iterative DPO)를 구현합니다. DPO를 3에포크 실행한 뒤, 학습된 모델에서 새 응답을 생성합니다. 생성된 응답과 원래 선호 응답을 새 선호 쌍으로 묶고 DPO를 다시 실행합니다. 이런 "셀프 플레이(self-play)" 과정을 두 라운드 수행합니다. 1라운드와 2라운드 뒤 선호 정확도를 비교해 반복 개선이 도움이 되는지 확인합니다.
-
서로 다른 참조 모델로 DPO를 비교합니다. SFT 체크포인트 대신 (a) 기반 모델(base model, SFT 전), (b) DPO 1에포크 체크포인트, (c) 정책 모델의 지수이동평균(exponential moving average)을 참조 모델로 사용해 봅니다. 어떤 참조 모델이 가장 높은 선호 정확도와 가장 안정적인 학습 곡선을 만드는지 보고합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| DPO | "RL 없는 RLHF" | Direct Preference Optimization. 보상 모델과 PPO를 우회하고, 선호 쌍에서 언어 모델을 직접 최적화하는 지도학습 알고리즘입니다. |
| 암묵적 보상(implicit reward) | "보상이 모델 안에 있다" | 보상 함수가 정책 모델과 참조 모델의 로그 확률 비율로 결정됩니다. 별도 보상 모델이 필요 없습니다. |
| 베타(beta, DPO) | "온도" | 정책이 참조 모델에서 얼마나 멀어질 수 있는지 제어합니다. 작은 beta는 큰 이탈을 허용하고, 큰 beta는 모델을 가깝게 유지합니다. |
| 로그 확률 비율(log-probability ratio) | "모델이 얼마나 바뀌었는가" | `log pi(y |
| 참조 모델(reference model) | "고정된 체크포인트" | 가중치가 절대 바뀌지 않는 SFT 모델 복사본입니다. 확률 비율을 계산할 때 기준점(anchor) 역할을 합니다. |
| KTO | "쌍 없는 DPO" | Kahneman-Tversky Optimization. 선호 쌍 대신 짝 없는 "좋음" 또는 "나쁨" 라벨로 동작합니다. |
| ORPO | "한 단계 정렬" | Odds Ratio Preference Optimization. SFT 손실에 선호 항을 더해 SFT와 정렬을 하나의 루프로 결합합니다. |
| SimPO | "참조 모델이 필요 없음" | Simple Preference Optimization. 길이 정규화 평균 로그 확률을 암묵적 보상으로 사용해 참조 모델을 제거합니다. |
| 정렬 비용(alignment tax) | "모델을 안전하게 만드는 비용" | 기반 모델에서 정렬된 모델로 가기 위해 추가로 필요한 계산, 데이터, 복잡성입니다. DPO는 이 비용을 크게 줄입니다. |
더 읽을거리