본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research9 min read

DPO 원리: 보상 모델 없이 "좋은 답 vs 나쁜 답"만으로 모델을 학습시키는 방법

DPO(Direct Preference Optimization)는 사람이 고른 좋은 답(chosen)과 나쁜 답(rejected) 쌍만으로 언어 모델을 바로 학습시키는 방법이다. 별도의 Reward Model을 만들지 않고, 학습 중인 모델과 기준 모델의 확률 비율 자체를 "암묵적 보상"으로 쓴다. 오늘은 이 아이디어를 직관과 최소 수식으로 이해하고, 선호

By 송민성

오늘의 목표

DPO가 왜 Reward Model을 따로 학습하지 않고도 "사람이 더 선호하는 답" 쪽으로 모델을 움직일 수 있는지 이해한다. 그리고 그 핵심 재료인 "문장 전체의 log probability"를 policy 모델과 reference 모델에서 각각 계산하는 함수를 직접 구현한다.

개념과 직관

1. 선호 데이터(preference data)부터

같은 질문(prompt)에 대해 답을 두 개 만들고, 사람이 "이쪽이 더 낫다"고 표시한 데이터다.

  • prompt: "서울의 수도 역할을 설명해줘"
  • chosen(선택된 답, 사람이 더 좋다고 고른 답): 정확하고 친절한 답
  • rejected(거절된 답, 상대적으로 나쁘다고 판단된 답): 부정확하거나 무례한 답

절대 점수("이 답은 7점")가 아니라 비교만 들어 있다는 점이 중요하다. 사람은 점수를 매기는 것보다 두 개 중 고르는 것을 훨씬 일관되게 한다.

2. 두 모델: policy와 reference

  • policy model(정책 모델): 지금 학습 중인 모델. 파라미터가 계속 바뀐다.
  • reference model(참조 모델): 학습 시작 시점의 모델을 복사해 얼려 둔 것. 파라미터가 절대 바뀌지 않는다.

비유: 학생(policy)이 공부하면서 점점 바뀌는데, 입학 당시의 학생(reference)을 사진으로 찍어 두고 "너무 많이 변하지 않았나?"를 계속 비교하는 것이다.

3. 문장의 log probability

언어 모델은 다음 토큰의 확률을 낸다. 문장 전체가 나올 확률은 토큰별 확률의 곱이고, 곱은 너무 작아지므로 log를 씌워 합으로 바꾼다.

text
log P(답 | prompt) = log P(토큰1 | prompt)
                   + log P(토큰2 | prompt, 토큰1)
                   + ...

(log는 확률을 0 이하의 숫자로 바꿔 주고, 곱셈을 덧셈으로 바꿔 주는 함수다. 확률 1이면 0, 확률이 작을수록 큰 음수가 된다.)

DPO에서 쓰는 값은 네 개다.

  • policy의 chosen log prob, policy의 rejected log prob
  • reference의 chosen log prob, reference의 rejected log prob

4. 암묵적 보상(implicit reward)

기존 RLHF 방식은 (1) 선호 데이터로 Reward Model(답에 점수를 매기는 별도 모델)을 학습하고 (2) 그 점수를 높이도록 강화학습으로 언어 모델을 학습한다. 단계가 많고 불안정하다.

DPO의 핵심 관찰은 이렇다. "reference에서 너무 멀어지지 않으면서 보상을 최대화한다"는 목표의 최적 해를 수식으로 풀면, 보상을 모델의 확률 비율로 거꾸로 표현할 수 있다.

text
r(x, y) = β · log( π(y|x) / π_ref(y|x) )   (+ x에만 의존하는 항)
  • x: prompt, y: 답
  • π: policy가 y를 낼 확률, π_ref: reference가 y를 낼 확률
  • β(베타): 보상의 크기와 "reference에서 얼마나 못 벗어나게 할지"를 조절하는 상수

말로 풀면: "reference보다 이 답을 더 잘 내게 되었다면, 그만큼 이 답에 높은 보상을 준 것으로 본다." 별도 점수 모델이 없어도, 모델 자신이 보상 모델 역할을 한다. 그래서 "언어 모델이 사실은 비밀리에 보상 모델이다"라고 표현하기도 한다.

(위 식의 "+ x에만 의존하는 항"은 chosen과 rejected가 같은 prompt를 공유하므로, 둘을 비교할 때 서로 상쇄되어 사라진다.)

5. KL constraint는 어디에 들어가나

KL 발산(KL divergence, 두 확률분포가 얼마나 다른지 재는 값)을 이용해 "policy가 reference에서 너무 멀어지지 않게" 묶는 것이 KL constraint다. 보상만 쫓으면 모델이 점수를 속이는 이상한 답을 내거나 원래 능력을 잃을 수 있기 때문이다. DPO는 이 제약이 이미 β·log(π/π_ref) 식 안에 녹아 있다. β가 클수록 reference에 강하게 묶이고, 작을수록 자유롭게 움직인다.

6. DPO loss

선호 확률을 "chosen의 보상이 rejected보다 클수록 높아지는 값"으로 모델링하고(Bradley-Terry 모델), 그 음의 log를 손실로 쓴다.

text
margin = β · [ (logπ(chosen) - logπ_ref(chosen))
             - (logπ(rejected) - logπ_ref(rejected)) ]

loss = -log( sigmoid(margin) )

sigmoid는 어떤 숫자든 0~1 사이로 눌러 주는 함수다. margin이 크게 양수면 sigmoid가 1에 가까워져 loss가 0에 가까워진다.

직관: chosen은 reference보다 확률을 올리고, rejected는 reference보다 확률을 내리는 방향으로 파라미터를 민다. 이때 "reference 대비 상대적인 변화량"만 보기 때문에 reference에서 멀리 도망가지 못한다.

코드로 직접 해보기

오늘 구현 목표는 loss 전체가 아니라, 그 재료인 선호쌍에 대한 policy·reference의 log prob 계산 함수다. 먼저 원리를 보기 위해 작은 가짜 모델로 연습하고, 같은 함수를 실제 모델에 연결하는 흐름으로 간다.

필요한 라이브러리: torch

Step 1. logits에서 토큰별 log prob 뽑기

모델은 각 위치마다 단어장 전체에 대한 점수(logits)를 낸다. 이를 log_softmax(점수를 log 확률로 바꾸는 함수)로 바꾸고, 실제로 정답이었던 토큰의 값만 꺼낸다.

python
import torch import torch.nn.functional as F def token_log_probs(logits, labels): # logits: (배치, 길이, 단어장크기), labels: (배치, 길이) log_probs = F.log_softmax(logits, dim=-1) # 각 위치에서 실제 토큰의 log prob만 선택 return log_probs.gather(dim=-1, index=labels.unsqueeze(-1)).squeeze(-1)

Step 2. 답 부분만 합산하기

prompt 부분은 두 답이 공통이므로 점수에 넣지 않고, 답(response) 토큰만 더한다. 이를 위해 mask(1이면 합산, 0이면 무시)를 쓴다.

한 칸 밀기(shift)에도 주의한다. 위치 t의 logits는 "t+1번째 토큰"을 예측한 것이므로 logits은 마지막을 빼고, labels는 처음을 뺀다.

python
def sequence_log_prob(model, input_ids, response_mask): # input_ids: (B, L), response_mask: (B, L) 답 토큰 위치가 1 logits = model(input_ids).logits # (B, L, V) logits = logits[:, :-1, :] # 마지막 위치 제외 labels = input_ids[:, 1:] # 첫 토큰 제외 mask = response_mask[:, 1:].float() per_token = token_log_probs(logits, labels) return (per_token * mask).sum(dim=-1) # (B,)

Step 3. 선호쌍 4개 값 계산

policy는 gradient를 흘려야 하고, reference는 torch.no_grad()로 계산한다.

python
def preference_log_probs(policy, reference, batch): # batch: chosen_ids, chosen_mask, rejected_ids, rejected_mask pi_chosen = sequence_log_prob(policy, batch["chosen_ids"], batch["chosen_mask"]) pi_rejected = sequence_log_prob(policy, batch["rejected_ids"], batch["rejected_mask"]) with torch.no_grad(): ref_chosen = sequence_log_prob(reference, batch["chosen_ids"], batch["chosen_mask"]) ref_rejected = sequence_log_prob(reference, batch["rejected_ids"], batch["rejected_mask"]) return pi_chosen, pi_rejected, ref_chosen, ref_rejected

Step 4. 가짜 모델로 동작 확인

실제 LLM 없이도 shape와 흐름을 확인할 수 있다.

python
from types import SimpleNamespace import torch.nn as nn class TinyLM(nn.Module): def __init__(self, vocab=10, dim=8): super().__init__() self.emb = nn.Embedding(vocab, dim) self.out = nn.Linear(dim, vocab) def forward(self, ids): return SimpleNamespace(logits=self.out(self.emb(ids))) torch.manual_seed(0) policy, reference = TinyLM(), TinyLM() reference.load_state_dict(policy.state_dict()) # 시작은 동일 for p in reference.parameters(): p.requires_grad_(False) # 얼리기 batch = { "chosen_ids": torch.tensor([[1, 2, 3, 4]]), "chosen_mask": torch.tensor([[0, 0, 1, 1]]), "rejected_ids": torch.tensor([[1, 2, 5, 6]]), "rejected_mask": torch.tensor([[0, 0, 1, 1]]), } out = preference_log_probs(policy, reference, batch) print([round(v.item(), 4) for v in out])

확인 포인트: 시작 시점에는 policy와 reference가 같으므로 pi_chosen == ref_chosen, pi_rejected == ref_rejected여야 한다. 그러면 margin이 0이고 loss는 -log(0.5) ≈ 0.693이다. 이 값이 나오면 구현이 맞다는 좋은 신호다.

python
beta = 0.1 pc, pr, rc, rr = out margin = beta * ((pc - rc) - (pr - rr)) loss = -F.logsigmoid(margin) print(loss.item()) # 약 0.6931

오늘의 실험

오늘은 별도 실험이 없다. 대신 위 Step 4의 sanity check(시작 시 loss ≈ 0.693)만 노트에 기록해 두자. 내일 이후 실제 학습에서 loss 곡선이 이 값에서 출발하는지 비교하는 기준이 된다.

왜 이걸 배우나 (LLM / Post-training 연결)

DPO는 SFT(지도 미세조정) 다음 단계에서 모델의 응답 스타일과 안전성, 선호도를 맞추는 대표적인 Post-training 기법이며, Project 3에서 선호 데이터를 만들고 직접 학습하는 데 그대로 쓰인다. log prob 계산, reference 고정, β 조절은 이후 다른 정렬 기법을 읽을 때도 반복해서 등장하므로, 오늘 함수를 정확히 짜 두면 실무에서 학습 코드를 디버깅하는 기초 체력이 된다.

초보자가 막히는 지점

  • shift 실수: logits과 labels을 한 칸 어긋나게 맞추지 않으면 loss가 이상하게 낮거나 학습이 안 된다. "위치 t의 출력은 t+1 토큰을 예측한다"를 기억하자.
  • prompt까지 합산: prompt 토큰을 mask로 제외하지 않으면 chosen/rejected의 prompt 부분이 섞여 값이 왜곡된다.
  • padding 토큰 포함: 길이를 맞추려고 붙인 padding도 mask로 0 처리해야 한다.
  • reference가 같이 학습됨: requires_grad_(False)와 no_grad()를 빼먹으면 reference가 변하거나 메모리가 낭비된다. reference는 항상 고정이다.
  • "보상 모델이 없다 = 보상이 없다"는 오해: 보상이 사라진 것이 아니라 β·log(π/π_ref)라는 형태로 모델 안에 암묵적으로 들어 있다.
  • log prob 부호 혼동: log prob는 항상 0 이하다. 값이 "더 큰 쪽(0에 가까운 쪽)"이 더 그럴듯한 답이다.
  • 긴 답 편향: 문장 log prob는 토큰을 더한 값이라 길수록 작아지기 쉽다. 이 점은 나중에 길이 문제를 볼 때 다시 생각해 보자.

알고리즘 (병행 1시간)

LeetCode Medium 구현 문제 2문제를 푼다. (문자열·배열·시뮬레이션처럼 조건을 정확히 코드로 옮기는 유형을 고르면 오늘의 "꼼꼼한 인덱싱" 연습과도 잘 맞는다.)

각 문제마다 다음을 기록한다.

  • 접근: 문제를 어떻게 이해했고 어떤 방식으로 풀었는지 2~3줄
  • 시간복잡도 / 공간복잡도: 입력 크기 n에 대해 얼마인지, 왜 그런지
  • 틀린 이유: 처음 제출이나 직접 돌려본 테스트에서 틀렸다면, 어떤 경계 조건(빈 입력, 인덱스 끝, 중복 등)을 놓쳤는지