DPO — 직접 선호 최적화(Direct Preference Optimization)

RLHF는 동작합니다. 하지만 SFT, 보상 모델(reward model), 정책(policy)이라는 세 모델을 학습하고, PPO의 불안정성을 관리하고, KL 패널티(KL penalty)를 조정해야 합니다. DPO는 이렇게 묻습니다. "이 모든 것을 건너뛸 수 있다면 어떨까?" DPO는 선호 쌍(preference pair)에서 언어 모델(language model)을 직접 최적화합니다. 보상 모델도 없고, PPO도 없습니다. 하나의 학습 루프(training loop)로 비슷한 결과를 얻습니다.

유형: Build
언어: Python, NumPy
선수 지식: Phase 10, Lesson 07(RLHF)
예상 시간: 약 90분

학습 목표

  • 별도 보상 모델 없이 선호 쌍에서 언어 모델을 직접 최적화하는 DPO 학습을 구현합니다.
  • DPO 손실 함수(DPO loss function)를 유도하고, 정책의 로그 확률(log probability)이 어떻게 보상 모델을 암묵적으로 표현하는지 설명합니다.
  • DPO와 RLHF를 학습 안정성(training stability), 계산 비용(compute cost), 필요한 모델 수 관점에서 비교합니다.
  • 베타(beta) 파라미터를 조정해 학습된 정책이 참조 모델(reference model)에서 얼마나 멀어질 수 있는지 제어합니다.

문제

Lesson 07에서 RLHF 파이프라인을 만들었습니다. 세 단계가 있었습니다. SFT 모델, 보상 모델, 그리고 PPO로 최적화하는 정책 모델입니다. 보상 모델만 해도 수천 개의 인간 선호 쌍과 별도 학습 루프가 필요했습니다. PPO는 KL 계수(KL coefficient), 학습률(learning rate), 클립 비율(clip ratio), 에포크 수(number of epochs)를 세심하게 조정해야 했습니다.

실무에서 PPO 학습은 불안정하기로 유명합니다. 작은 하이퍼파라미터 변화만으로도 학습이 발산할 수 있습니다. 보상 모델은 인간 선호의 불완전한 대리 지표(proxy)이고, 정책은 그 약점을 이용하는 방법을 찾아냅니다. KL 패널티는 도움이 되지만 자체 조정이 필요합니다. 너무 낮으면 보상 해킹(reward hacking)이 생기고, 너무 높으면 모델이 거의 배우지 못합니다.

이 복잡성 때문에 InstructGPT가 공개된 뒤에도 많은 오픈소스 모델이 몇 년 동안 RLHF 적용에 어려움을 겪었습니다. 세 단계 파이프라인은 취약합니다. 각 단계에는 고유한 실패 모드(failure mode)가 있고, 오류는 서로 누적됩니다.

2023년 5월, Stanford의 Rafael Rafailov, Archit Sharma와 동료들은 "Direct Preference Optimization: Your Language Model is Secretly a Reward Model"을 발표했습니다. 핵심 통찰은 별도 보상 모델이 필요 없다는 것입니다. 최적 보상 함수(optimal reward function)는 언어 모델 자신의 토큰 확률(token probability)로 수학적으로 결정됩니다. 보상 모델을 완전히 건너뛰고, 언어 모델을 선호 쌍에서 직접 최적화할 수 있습니다.

DPO는 RLHF를 하나의 지도학습(supervised learning) 단계로 줄입니다. 하나의 모델, 하나의 손실 함수, 하나의 학습 루프입니다. 강화학습(reinforcement learning)이 필요 없습니다. DPO를 대규모로 사용한 초기 모델 중 하나인 Zephyr-7B는 여러 벤치마크에서 전체 RLHF로 학습한 모델과 맞먹거나 더 나은 성능을 보였습니다. Meta는 Llama 3의 정렬(alignment) 파이프라인 일부로 DPO를 사용했습니다. Anthropic도 정렬 연구에서 DPO 계열 방법을 언급했습니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.DPO가 RLHF보다 갖는 주요 장점은 무엇인가요?

2.DPO에서 참조 모델(reference model)은 어떤 역할을 하나요?

0/2 답변 완료

개념

핵심 통찰

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)는 모델이 짧은 응답을 선호하지 않도록 막습니다.

방법연도메모리에 필요한 모델쌍 데이터 필요?참조 모델 필요?학습 루프
RLHF20223-4예, 보상 모델용3
DPO202322
KTO20242아니요, 짝 없는 라벨2
ORPO20241아니요1
SimPO20241아니요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_ratiorejected_ratio는 DPO 유도식에서 나온 로그 확률 비율입니다. 현재 모델이 참조 모델 대비 선호 응답에는 더 높은 확률을, 거절 응답에는 더 낮은 확률을 부여하면 logit은 양수가 되고 손실은 낮아집니다. 학습 신호는 정확히 이 방향으로 모델을 밀어줍니다.

implicit_preferred_rewardimplicit_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 중 하나를 고르는 데 도움을 줍니다. 사용할 수 있는 데이터, 계산 예산, 정렬 목표를 입력하면 적합한 방법과 학습 계획을 추천합니다.

연습문제

  1. 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와 정확도를 비교합니다.

  2. 길이 정규화 DPO(length-normalized DPO)를 구현합니다. 원시 로그 확률(raw log-probability)을 그대로 쓰는 대신 응답 토큰 수로 나눕니다. normalized_logprob = total_logprob / num_tokens입니다. 이렇게 하면 전체 로그 확률이 더 높은 짧은 응답을 모델이 선호하는 문제를 막을 수 있습니다. 정규화를 적용한 경우와 적용하지 않은 경우의 암묵적 보상 마진을 비교합니다.

  3. ORPO 스타일의 결합 손실(combined loss)을 만듭니다. 선호 응답에 대한 표준 다음 토큰 예측 손실을 DPO 손실에 더합니다. L = L_sft(preferred) + alpha * L_dpo입니다. alpha 값을 0.1, 0.5, 1.0으로 시도합니다. 결합 손실은 SFT 항을 통해 지시를 따르고, DPO 항을 통해 더 좋은 응답을 선호하는 모델을 만들어야 합니다. 이렇게 하면 별도 SFT 단계가 필요 없어집니다.

  4. 반복적 DPO(iterative DPO)를 구현합니다. DPO를 3에포크 실행한 뒤, 학습된 모델에서 새 응답을 생성합니다. 생성된 응답과 원래 선호 응답을 새 선호 쌍으로 묶고 DPO를 다시 실행합니다. 이런 "셀프 플레이(self-play)" 과정을 두 라운드 수행합니다. 1라운드와 2라운드 뒤 선호 정확도를 비교해 반복 개선이 도움이 되는지 확인합니다.

  5. 서로 다른 참조 모델로 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는 이 비용을 크게 줄입니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 1개

prompt-alignment-method-selector

Choose the right alignment method (SFT, RLHF, DPO, KTO, ORPO, SimPO) for your use case

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.DPO의 베타 파라미터(beta parameter)는 무엇을 조절하나요?

2.DPO는 보상 모델(reward model)을 어떻게 암묵적으로 표현하나요?

3.어떤 경우에 DPO보다 RLHF를 여전히 선호할 수 있나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다