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

Project 3 DPO 학습: Base·SFT·DPO를 같은 문제로 비교하면 무엇이 달라지나

SFT 체크포인트에서 DPO(Direct Preference Optimization)를 beta 0.05·0.1·0.3으로 학습하고, base·SFT·DPO 모델을 승률과 KL로 비교합니다. DPO가 "좋은 답과 나쁜 답의 격차"를 어떻게 키우는지, 그 대가로 reference 모델에서 얼마나 멀어지는지 숫자로 확인합니다. 응답 길이가 함께 늘어나는지도 봅

By 송민성

오늘의 목표

DPO가 정말 모델을 좋게 만드는지 숫자로 확인한다. Project 2에서 만든 SFT 체크포인트를 출발점으로 DPO를 세 가지 beta(0.05, 0.1, 0.3)로 학습하고, base·SFT·DPO 세 모델을 같은 프롬프트로 비교한다. 학습 중에는 reward margin, KL, 응답 길이를 기록하고, 학습 후에는 승률과 reference 대비 KL을 측정한다. 오늘은 "느낌상 더 좋아졌다"가 아니라 "이만큼 이겼고, 그 대가로 이만큼 멀어졌다"를 말할 수 있어야 한다.

개념과 직관

오늘은 새 이론을 배우는 날이 아니다. 앞서 배운 선호 데이터(preference data)와 DPO의 손실 함수를 실제로 돌려 보는 날이다. 필요한 개념만 짧게 복습한다.

선호 데이터(preference data): 같은 프롬프트에 대해 사람이(또는 규칙·모델이) 고른 좋은 답 chosen과 덜 좋은 답 rejected의 쌍이다.

reference 모델(reference model): DPO 학습을 시작하는 시점의 모델을 복사해 얼려 둔 것이다. 오늘은 SFT 체크포인트가 reference다. "출발선"이라고 생각하면 된다.

직관: 줄타기와 고무줄

  • DPO는 "chosen을 더 잘 말하고 rejected를 덜 말하게" 모델을 민다.
  • 그런데 너무 세게 밀면 모델이 이상해진다. 그래서 reference라는 출발점에 고무줄을 묶어 둔다.
  • beta는 이 고무줄의 세기다. beta가 크면 고무줄이 팽팽해서 reference에서 멀리 못 간다. beta가 작으면 느슨해서 멀리 갈 수 있다.

최소 수식(텍스트)

각 응답의 "암묵적 보상(implicit reward)"을 이렇게 둔다.

text
r(응답) = beta * [ log π(응답) - log π_ref(응답) ]
  • π는 지금 학습 중인 모델, π_ref는 reference 모델이 그 응답에 부여하는 확률이다.
  • 즉 "reference보다 이 응답을 얼마나 더 좋아하게 됐나"가 보상이다.

DPO 손실은 다음과 같다.

text
margin = r(chosen) - r(rejected)
loss   = -log( sigmoid(margin) )
  • reward margin: chosen의 보상에서 rejected의 보상을 뺀 값. 학습이 잘 되면 커진다.
  • reward accuracy: margin > 0인 샘플의 비율. 0.5면 찍는 수준이다.
  • sigmoid는 값을 0~1 사이로 눌러 주는 함수다. margin이 크면 sigmoid가 1에 가까워 loss가 0에 가까워진다.

KL(Kullback-Leibler divergence, 두 확률분포가 얼마나 다른지 재는 값): 오늘은 "학습한 모델이 reference에서 얼마나 멀어졌나"를 재는 용도로 쓴다. 0이면 완전히 같고, 클수록 많이 달라졌다. 정확한 KL은 계산이 무거우므로, 생성한 응답에서 log π - log π_ref의 평균으로 근사한다. 엄밀한 값이 아니라 비교용 추정치다.

코드로 직접 해보기

원리를 먼저 보고, 그다음 라이브러리를 쓴다. 아래 코드는 이미 Project 2의 토크나이저와 모델 로딩 코드가 있다고 가정한다.

1단계: 응답의 log 확률 구하기

DPO에 필요한 건 "프롬프트가 주어졌을 때 응답 전체의 log 확률"이다. 토큰별 log 확률을 응답 부분만 더한다.

python
import torch import torch.nn.functional as F def sequence_logprob(model, input_ids, attention_mask, prompt_len): """응답 토큰들의 log 확률 합. prompt_len 이전(프롬프트)은 제외.""" logits = model(input_ids=input_ids, attention_mask=attention_mask).logits # 위치 t의 logits는 t+1번째 토큰을 예측하므로 한 칸씩 어긋나게 맞춘다 logits = logits[:, :-1, :] targets = input_ids[:, 1:] logp = torch.log_softmax(logits, dim=-1) token_logp = logp.gather(-1, targets.unsqueeze(-1)).squeeze(-1) mask = attention_mask[:, 1:].clone() mask[:, : prompt_len - 1] = 0 # 프롬프트 구간 제외 (배치 내 프롬프트 길이가 같다고 가정한 단순 버전) return (token_logp * mask).sum(dim=-1)

2단계: DPO 손실을 직접 구현

python
def dpo_loss(pol_chosen, pol_rejected, ref_chosen, ref_rejected, beta): r_chosen = beta * (pol_chosen - ref_chosen) r_rejected = beta * (pol_rejected - ref_rejected) margin = r_chosen - r_rejected loss = -F.logsigmoid(margin).mean() acc = (margin > 0).float().mean() return loss, margin.mean(), acc # 숫자 감각 잡기: 아무것도 안 변했다면 margin=0, loss=log(2) z = torch.zeros(4) print(dpo_loss(z, z, z, z, beta=0.1)) # loss ≈ 0.6931

학습 시작 직후 loss가 약 0.693이 아니면 코드에 버그가 있다는 신호다. 가장 좋은 첫 번째 점검이다.

3단계: 학습 루프에서 로깅

reference의 log 확률은 학습 중 바뀌지 않으므로 torch.no_grad()로 계산한다.

python
beta = 0.1 for step, batch in enumerate(loader): with torch.no_grad(): ref_c = sequence_logprob(ref_model, batch["c_ids"], batch["c_mask"], batch["prompt_len"]) ref_r = sequence_logprob(ref_model, batch["r_ids"], batch["r_mask"], batch["prompt_len"]) pol_c = sequence_logprob(model, batch["c_ids"], batch["c_mask"], batch["prompt_len"]) pol_r = sequence_logprob(model, batch["r_ids"], batch["r_mask"], batch["prompt_len"]) loss, margin, acc = dpo_loss(pol_c, pol_r, ref_c, ref_r, beta) optimizer.zero_grad() loss.backward() optimizer.step() if step % 10 == 0: kl_est = (pol_c - ref_c).mean().item() # chosen 쪽 근사치 print(f"step {step} loss {loss.item():.4f} margin {margin.item():.3f} " f"acc {acc.item():.2f} kl~{kl_est:.2f}")

4단계: 라이브러리로 beta 세 개 돌리기

원리를 확인했으니 실전에서는 검증된 라이브러리를 쓴다. 예로 Hugging Face TRL의 DPOTrainer가 있다. 다만 인자 이름은 버전마다 바뀌므로, 설치한 버전의 문서를 확인하고 옮겨 적어라.

python
# 의사 코드 수준의 골격 (인자 이름은 설치 버전 문서에 맞출 것) for beta in [0.05, 0.1, 0.3]: # 매번 SFT 체크포인트에서 새로 시작해야 공정한 비교가 된다 # DPOConfig(beta=beta, learning_rate=5e-7 근처에서 시작, ...) 로 학습 # 출력 디렉터리는 f"ckpt/dpo_beta{beta}" 처럼 구분 pass

기록할 것:

  • 스텝별 loss, reward margin, reward accuracy
  • reference 대비 KL 추정치
  • 평가 프롬프트에 대한 평균 응답 길이

학습률은 SFT보다 훨씬 작게(예: 1e-6 이하 범위) 시작하는 것이 일반적이다. 정확한 값은 모델 크기에 따라 달라지니 직접 확인하라.

오늘의 실험

바꾸는 것: beta = 0.05, 0.1, 0.3 (나머지 학습률·데이터·스텝 수·시드는 동일하게 고정) 비교 대상: base, SFT, DPO(beta별 3개), 총 5개 모델

실험 1: 승률 측정

같은 평가 프롬프트 100개 이상(학습에 쓰지 않은 것)에 대해 각 모델이 답하게 하고 두 방식으로 판정한다.

  1. 규칙 기반: 형식 준수, 금지 표현 포함 여부, 길이 제한 만족 등 코드로 판정 가능한 기준. 재현성이 높다.
  2. LLM-judge: 다른 LLM이 A/B 두 답 중 나은 쪽을 고른다. 이때 A/B 순서를 바꿔 두 번 물어서 위치 편향(position bias, 먼저 나온 답을 선호하는 경향)을 줄인다. 두 번 판정이 엇갈리면 무승부로 처리한다.

비교 쌍은 SFT vs base, DPO vs SFT, DPO(beta별) 끼리 등이다. 승률 = (이긴 수 + 0.5 × 무승부) / 전체.

실험 2: reference 대비 KL

각 DPO 모델이 생성한 응답에 대해 log π(y) - log π_ref(y)의 평균을 구한다. reference는 SFT 모델이다.

실험 3: 응답 길이

평균 토큰 수를 측정한다. DPO 후 응답이 길어졌다면, 승률이 오른 이유가 "진짜 품질"인지 "길어서 좋아 보인 것"인지 의심해야 한다.

기록 표 예시

모델beta규칙 승률(vs SFT)Judge 승률(vs SFT)KL 추정평균 길이최종 margin
base---
SFT-0.500.500-
DPO0.05
DPO0.1
DPO0.3

관찰 질문:

  • beta가 작을수록 KL이 커지는가? 승률도 계속 오르는가, 어느 지점에서 꺾이는가?
  • margin이 커진 만큼 실제 승률도 올랐는가? (margin은 학습 데이터에서의 숫자일 뿐이다)
  • 길이가 늘었다면, 길이를 맞춰서(비슷한 길이끼리만) 다시 비교하면 승률이 어떻게 변하는가?

결과는 표와 함께 "가설 → 결과 → 해석 한두 줄"로 적는다. 예상과 달랐던 것이 가장 값진 기록이다.

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

SFT 이후 선호 정렬(preference alignment)을 하고 그 효과를 검증하는 것이 Post-training 업무의 핵심 루프다. 이때 "승률은 올랐지만 KL과 길이도 같이 커졌다"를 구분해서 보고하는 능력이 LLM Research Engineer로서 실험 결과를 신뢰할 수 있게 만든다. 이후 Reward Model 기반 방법과 비교할 때도 오늘 만든 평가 틀(규칙 + judge + KL + 길이)을 그대로 재사용한다.

초보자가 막히는 지점

  • 초기 loss가 0.693이 아니다: policy와 reference가 같은 체크포인트에서 시작했는지, reference를 eval() 모드로 두고 gradient를 끊었는지 확인한다. 프롬프트 구간 마스킹이 틀려도 어긋난다.
  • reference와 policy를 같은 객체로 쓴다: 학습이 진행되면 reference도 같이 변해서 margin이 의미를 잃는다. 반드시 복사본을 얼려 둔다.
  • GPU 메모리 부족: 모델이 두 개(policy, reference)에 chosen/rejected 두 번씩 forward가 필요하다. 배치 크기를 줄이고 gradient accumulation을 쓰거나, 작은 모델·LoRA를 고려한다.
  • loss는 내려가는데 생성이 이상하다: chosen과 rejected 둘 다의 확률이 같이 떨어지는 경우가 있다. margin만 보지 말고 chosen의 log 확률 자체도 로깅하고, 샘플 응답을 직접 읽어 본다.
  • beta별 비교가 불공정하다: 스텝 수·학습률·시드가 다르면 beta 효과인지 알 수 없다. 한 번에 하나만 바꾼다.
  • LLM-judge를 맹신한다: 순서 바꿔 두 번 묻기, 사람이 20~30개 직접 대조해 보기로 judge의 신뢰도를 점검한다.
  • 한 번 돌린 결과로 결론 내린다: 시드 하나는 우연일 수 있다. 가능하면 시드를 바꿔 한 번 더 돌려 보고, 차이가 작으면 "차이 없음"이라고 쓴다.

알고리즘 (병행 1시간)

LeetCode Medium 2문제, 주제는 그래프. 예를 들어 BFS/DFS로 연결 요소나 최단 거리를 구하는 문제, 또는 위상 정렬 문제처럼 그래프 기본기를 확인하는 문제를 골라라. (구체 문제 선택은 본인이 직접 한다.)

풀이마다 아래를 기록한다.

  • 접근: 그래프를 어떻게 표현했나(인접 리스트 등), BFS/DFS 중 무엇을 왜 골랐나
  • 시간복잡도 / 공간복잡도: 정점 수 V, 간선 수 E로 표현 (예: O(V + E))
  • 틀린 이유: 방문 체크 누락, 사이클 처리, 시작점 초기화 실수 등 막혔던 지점을 한 줄로