5장. LoRA 파인튜닝과 DPO 정렬
선호 데이터
식당의 손님 의견 카드를 떠올려 보세요. "오늘의 요리 A 와 B 가운데 어느 쪽이 나았나요?"라고만 물으면 손님은 쉽게 답합니다. 맛을 점수로 매기라고 하면 사람마다 기준이 달라 어렵지만, 둘 중 하나를 고르는 것은 쉽고 일관됩니다. DPO 는 이런 "둘 중 고르기"를 모은 데이터로 배웁니다.
선호 데이터를 만드는 길은 셋입니다. 사람이 두 답을 보고 고르는 방법이 가장 정확하지만 비쌉니다. 이 장의 데이터는 두 번째 길로, 정확하고 친절한 답을 고른 답으로, 정해진 흠(회피, 무뚝뚝, 두루뭉술)이 있는 답을 반대 답으로 만들었습니다. 큰 모델에게 심사를 맡기고 사람이 검토하는 방법도 많이 씁니다. 여기서 손님의 선택이 선호 데이터입니다.
DPO 라는 이름 풀기
DPO 는 Direct Preference Optimization 의 머리글자입니다. 우리말로는 "직접 선호 최적화"입니다.
| 글자 | 영어 | 뜻 | 손님 의견 카드로 |
|---|---|---|---|
| D | Direct(직접) | 중간 단계를 거치지 않고 바로 한다. 아래의 보상 모델을 따로 만들지 않는다는 뜻이다 | 카드를 점수로 바꾸지 않고 바로 반영한다 |
| P | Preference(선호) | 둘 가운데 더 마음에 드는 쪽. 선호 데이터의 "고른 답"이다 | 손님이 고른 요리 |
| O | Optimization(최적화) | 어떤 값이 가장 좋아지도록 숫자를 고친다. 여기서는 DPO 손실을 줄인다 | 다음 메뉴를 손님 입맛에 맞춘다 |
어디서 나왔나
DPO 가 나오기 전에는 4장에서 본 RLHF(Reinforcement Learning from Human Feedback, 사람 피드백을 받는 강화학습)가 정렬의 대표 방법이었습니다. 2022년 OpenAI 의 InstructGPT 가 이 방법으로 ChatGPT 의 바탕을 만들었습니다. RLHF 는 단계가 셋입니다. 사람의 선택으로 점수를 매기는 보상 모델(reward model)을 먼저 학습합니다. 그다음 그 점수가 높아지도록 PPO(Proximal Policy Optimization, 근접 정책 최적화)라는 강화학습으로 모델을 고칩니다. 이때 모델 여러 개를 함께 GPU 에 올려야 하고, 학습이 불안정해 설정을 맞추기 어렵습니다.
2023년 Stanford 대학의 연구진(Rafael Rafailov 등)이 논문 "Direct Preference Optimization: Your Language Model is Secretly a Reward Model" 을 냈습니다. 부제는 "언어모델이 사실은 보상 모델이다"라는 뜻입니다. 이 논문은 수식을 정리하면 보상 모델과 강화학습 없이 선호 쌍만으로 같은 목표를 학습할 수 있다는 것을 보였습니다. 그래서 이름에 "직접(Direct)"이 붙었습니다. 다루기 쉬워서 공개 모델의 정렬에 널리 쓰이게 되었습니다.
| 견줄 것 | RLHF(PPO) | DPO |
|---|---|---|
| 보상 모델 | 따로 학습한다 | 없다 |
| 학습 방법 | 강화학습. 모델이 답을 만들어 보고 점수를 받는다 | 보통의 지도 학습처럼 손실을 줄인다 |
| GPU 에 올릴 모델 | 학습 모델, 기준 모델, 보상 모델 등 여럿 | 학습 모델과 기준 모델. LoRA 와 함께면 하나분 |
| 쓰는 데이터 | 선호 데이터(보상 모델용)와 질문 | 선호 데이터(prompt, chosen, rejected) |
더 읽을 곳
| 무엇 | 어디 | 내용 |
|---|---|---|
| 원래 논문 | arXiv 2305.18290 | DPO 를 처음 낸 논문(영어) |
| trl 의 DPO 설명서 | huggingface.co/docs/trl | 이 장에서 쓰는 DPOTrainer 와 DPOConfig 의 설명서 |
| 이 교안 | 4장 「정렬」 | 정렬의 세 가지 방법과 RLHF 를 처음 소개한다 |
| 이 교안 | 5장 「DPO 의 원리」, 5장 「DPO 지표 읽기」 | DPO 손실의 계산과 학습 기록 읽는 법 |
이 장의 선호 데이터: dpo_shop.jsonl
실습 5, 6에서 쓰는 선호 데이터는 data/dpo_shop.jsonl 입니다. tools/make_data.py 가 씨앗을 고정해 만든 연습용 가상 데이터이고, 크기는 24KB 쯤입니다.
| 항목 | 내용 |
|---|---|
| 꼴 | JSON Lines. 한 줄에 선호 쌍 하나가 JSON 으로 들어 있다 |
| 줄 수 | 72줄. 가상 쇼핑몰의 질문 의도 12가지(배송 기간, 교환, 환불 등)마다 6개 |
질문(prompt) | 4장의 sft_shop.jsonl 에서 학습용 질문만 가져왔다. 의도마다 시험용 질문 둘은 넣지 않아, 실습 6의 시험 질문 24개와 겹치지 않는다 |
고른 답(chosen) | "안녕하세요, 고객님. (정확한 답) 감사합니다." 꼴. 답의 내용은 4장의 쇼핑몰 답과 같다 |
반대 답(rejected) | 흠이 있는 답 세 문장 가운데 하나를 씨앗으로 골랐다. 회피("저는 인공지능으로서…"), 무뚝뚝("직접 찾아보세요"), 두루뭉술("상황에 따라 다르기 때문에…") |
의도 | 질문의 종류. 학습에는 쓰지 않고 살펴볼 때만 쓴다 |
이 데이터는 사람이 고른 것이 아니라, 위의 세 길 가운데 두 번째인 "규칙으로 만들기"로 만들었습니다. 그래서 고른 답과 반대 답의 차이가 아주 뚜렷합니다. 모델이 DPO 로 무엇을 배우는지 눈으로 확인하기 좋도록 일부러 그렇게 만들었습니다. 파일을 다시 만드는 법과 열 설명은 실습 자료 쪽(data.md)에 있습니다.
한 줄의 생김새
| 열 | 뜻 | dpo_shop.jsonl 의 예 |
|---|---|---|
prompt | 질문 | 배송은 얼마나 걸리나요? |
chosen | 고른 답 | 안녕하세요, 고객님. 결제 후 1~3일 안에 도착합니다. … 감사합니다. |
rejected | 반대 답 | 그건 누리집에 다 나와 있어요. 직접 찾아보세요. |
반대 답의 종류 세기
import json
from collections import Counter
with open("dpo_shop.jsonl", encoding="utf-8") as f:
pairs = [json.loads(line) for line in f]
print("쌍의 수:", len(pairs))
# 반대 답이 어떤 흠인지 앞 몇 글자로 묶어 센다
kinds = Counter(p["rejected"][:12] for p in pairs)
for text, n in kinds.most_common():
print(n, text, "…")72쌍의 반대 답은 세 가지 흠으로 나뉩니다. 앞 12글자로 묶으면 "상황에 따라 다르기 때"(두루뭉술) 27개, "그건 누리집에 다 나와"(무뚝뚝) 24개, "죄송합니다, 저는 인공"(회피) 21개입니다. 데이터를 학습에 쓰기 전에 이렇게 흠의 종류가 고루 섞였는지 봅니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
open("dpo_shop.jsonl", encoding="utf-8") | 실습 자료 파일을 한글이 깨지지 않게 UTF-8 로 연다. jsonl 은 JSON Lines(한 줄에 JSON 하나)의 줄임말이다 |
json.loads(line) | load string(글자에서 읽기)의 줄임말이다. JSON 꼴의 글 한 줄을 파이썬 사전으로 바꾼다 |
p["rejected"][:12] | 쌍 하나의 반대 답에서 앞 12글자만 잘라 낸다 |
Counter(...) | collections 의 셈하는 도구다. 같은 값이 몇 번 나왔는지 센다 |
kinds.most_common() | 센 결과를 많이 나온 것부터 (값, 횟수) 꼴로 늘어놓는다 |
이 코드가 뜻하는 것
손님 의견 카드 72장을 받아 "아쉬웠던 요리" 칸만 훑어보고, 같은 요리끼리 쌓아 몇 장인지 세는 일과 같습니다. 여기서 카드 한 장이 선호 쌍 하나, "아쉬웠던 요리" 칸이 반대 답입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
pairs = [json.loads(line) for line in f] | 파일의 줄마다 사전 하나로 바꿔 목록에 모은다 | 카드 72장을 모두 꺼낸다 |
Counter(p["rejected"][:12] for p in pairs) | 반대 답의 앞부분이 같은 것끼리 센다 | 같은 요리끼리 쌓는다 |
for text, n in kinds.most_common() | 많은 묶음부터 횟수와 앞부분을 찍는다 | 높은 더미부터 장수를 읽는다 |
이 장의 반대 답은 세 가지 흠 문장 가운데 하나로 시작하도록 만들었기 때문에 앞 12글자만 봐도 흠의 종류가 갈립니다. 사람이 모은 데이터라면 반대 답이 제각각이라 이렇게 쉽게 묶이지 않습니다.
주의할 점
- 고른 답과 반대 답이 너무 쉽게 갈리면(예: 반대 답이 늘 같은 문장) 모델은 그 문장만 피하는 법을 배웁니다. 실제로는 미묘하게 다른 두 답을 모으는 것이 좋습니다.
- 사람이 고를 때는 기준(정확성 먼저, 그다음 친절함 등)을 미리 적어 두어야 사람마다 고르는 것이 일관됩니다.