Preference Data: 같은 질문에 대한 두 답변(chosen·rejected)으로 "더 나은 답"을 가르치는 법
선호 데이터는 하나의 질문(prompt)에 대해 "더 좋은 답(chosen)"과 "덜 좋은 답(rejected)"을 짝지어 놓은 데이터다. 오늘은 이 데이터를 사람·AI·규칙으로 어떻게 만드는지, 노이즈와 다양성이 왜 문제인지 배운다. 규칙 기반으로 합성 선호쌍을 직접 만들고 공개 선호 데이터셋도 불러와 구조를 확인한다.
By 송민성
오늘의 목표
선호 데이터(preference data)가 어떤 모양이고 누가 어떻게 만드는지 이해한다. 그리고 규칙 기반으로 합성 선호쌍을 직접 만들어 보고, 공개 선호 데이터셋을 불러와 실제 구조를 눈으로 확인한다.
개념과 직관
선호 데이터란
선호 데이터(preference data)는 "이 질문에는 A 답이 B 답보다 낫다"는 판단을 모아 놓은 데이터다. 한 행(row)은 보통 세 칸이다.
- prompt: 모델에게 던진 질문이나 지시
- chosen: 두 응답 중 더 낫다고 고른 것
- rejected: 두 응답 중 덜 낫다고 판단한 것
예시는 이렇다.
prompt: "서울의 수도 위치를 알려줘" 가 아니라, "대한민국의 수도는?"
chosen: "대한민국의 수도는 서울입니다."
rejected: "음... 부산이었나? 아무튼 큰 도시일 거예요."비유하면 시험 채점이다. 정답 하나만 주고 "이걸 외워"라고 하는 방식(지도학습)과 달리, 선호 데이터는 "이 답안이 저 답안보다 낫다"고 비교만 해준다. 절대 점수를 매기기는 어렵지만 둘 중 어느 쪽이 나은지는 사람이 훨씬 쉽게, 일관되게 판단하기 때문에 이 방식이 널리 쓰인다.
누가 chosen/rejected를 정하나
- 사람 어노테이션(human annotation, 사람이 직접 두 응답을 보고 고르는 작업)
- 장점: 미묘한 뉘앙스와 실제 사용자 취향을 반영한다. - 단점: 비싸고 느리다. 사람마다 기준이 달라 의견이 갈린다.
- AI 피드백(AI feedback, 다른 LLM이 두 응답을 비교해 판정하는 방식)
- 장점: 싸고 빠르게 대량 생산한다. - 단점: 판정 모델의 편향이 데이터에 그대로 들어온다. 예를 들어 긴 답을 무조건 좋게 보는 경향이 생길 수 있다.
- 규칙 기반(rule-based, 정해 둔 규칙으로 자동 판정)
- 예: 정답이 확인되는 수학 문제에서 맞힌 답을 chosen, 틀린 답을 rejected로. - 장점: 공짜에 가깝고 판정이 일관된다. - 단점: 규칙이 포착하는 것만 가르친다. 규칙이 허술하면 엉뚱한 습관을 가르친다.
노이즈(noise)
노이즈는 라벨이 잘못되었거나 애매한 경우다. 사람 둘이 같은 쌍을 보고 서로 다른 쪽을 고를 수 있고, 두 응답이 사실상 비슷해서 우열이 무의미할 때도 있다. 노이즈가 많으면 모델은 "무엇이 좋은 답인지" 대신 모순된 신호를 배운다. 그래서 일치도가 낮은 쌍을 걸러내거나, 두 응답의 차이가 분명한 쌍을 우선 쓰는 식의 정제가 필요하다.
다양성(diversity)
데이터가 한 종류의 질문, 한 가지 말투, 한 가지 길이에 몰려 있으면 모델은 그 안에서만 "좋은 답"을 배운다. 점검할 축은 다음과 같다.
- prompt의 종류(질문, 요약, 코드, 안전 관련 요청 등)
- 응답의 길이 분포
- chosen과 rejected 사이의 차이가 어디서 오는지
특히 "chosen이 항상 더 길다" 같은 우연한 패턴이 있으면, 모델은 내용이 아니라 길이를 보고 좋은 답이라 배운다. 오늘 코드에서 이 패턴을 직접 확인해 볼 것이다.
최소한의 수식
선호 데이터 한 건을 수식으로 쓰면 이 정도다.
(x, y_w, y_l)
x = prompt
y_w = chosen (win)
y_l = rejected (lose)데이터셋은 이런 세 쌍의 모음이다. 오늘은 이 구조만 알면 된다. 이 쌍으로 무엇을 학습하는지는 앞으로 이어질 Reward Model과 DPO에서 다룬다.
코드로 직접 해보기
1단계: 라이브러리 없이 선호쌍 구조 만들기
파이썬 딕셔너리 리스트로 충분하다. 먼저 구조를 손으로 만들어 본다.
pairs = [
{
"prompt": "2 + 3은?",
"chosen": "5입니다.",
"rejected": "음, 아마 6 정도일 것 같아요. 확실하진 않지만요.",
},
{
"prompt": "대한민국의 수도는?",
"chosen": "서울입니다.",
"rejected": "부산입니다.",
},
]
for p in pairs:
print(p["prompt"], "→", p["chosen"], "|", p["rejected"])2단계: 규칙 기반으로 합성 선호쌍 만들기
정답을 알고 있는 산수 문제를 자동으로 만든다. 맞은 답은 chosen, 틀린 답(또는 장황하게 얼버무린 답)은 rejected로 둔다.
import random
random.seed(0)
def make_pair():
a = random.randint(1, 20)
b = random.randint(1, 20)
answer = a + b
prompt = f"{a} + {b}은?"
chosen = f"{answer}입니다."
# 규칙: 정답에서 1~3 어긋난 값에 군더더기 설명을 붙여 rejected로 쓴다
wrong = answer + random.choice([-3, -2, -1, 1, 2, 3])
rejected = f"음, 아마 {wrong} 정도일 것 같아요. 확실하진 않아요."
return {"prompt": prompt, "chosen": chosen, "rejected": rejected}
dataset = [make_pair() for _ in range(5)]
for row in dataset:
print(row)여기서 쓴 규칙은 "정확하고 간결한 답이 chosen"이다. 정답 여부는 계산으로 보장되므로 chosen의 품질은 확실하다.
3단계: 데이터의 편향 점검하기
규칙으로 만든 데이터는 편향이 생기기 쉽다. 위 코드에서는 chosen이 항상 짧고 rejected가 항상 길다. 길이 차이를 확인해 보자.
dataset = [make_pair() for _ in range(200)]
longer_chosen = sum(len(r["chosen"]) > len(r["rejected"]) for r in dataset)
print("chosen이 더 긴 비율:", longer_chosen / len(dataset))결과는 0에 가깝게 나올 것이다. 이 데이터로 학습하면 "짧으면 좋은 답"이라는 엉뚱한 기준을 배울 수 있다. 길이를 섞어서 이를 완화해 본다.
def make_pair_v2():
a = random.randint(1, 20)
b = random.randint(1, 20)
answer = a + b
wrong = answer + random.choice([-3, -2, -1, 1, 2, 3])
prompt = f"{a} + {b}은?"
# 정답 쪽도 길게 쓰는 변형을 섞는다
chosen = random.choice([
f"{answer}입니다.",
f"{a}에 {b}를 더하면 {answer}입니다.",
])
# 오답 쪽도 짧게 쓰는 변형을 섞는다
rejected = random.choice([
f"{wrong}입니다.",
f"음, 아마 {wrong} 정도일 것 같아요.",
])
return {"prompt": prompt, "chosen": chosen, "rejected": rejected}
dataset = [make_pair_v2() for _ in range(200)]
longer_chosen = sum(len(r["chosen"]) > len(r["rejected"]) for r in dataset)
print("chosen이 더 긴 비율:", longer_chosen / len(dataset))이제 길이가 정답 여부와 상관없이 섞인다. 데이터의 "차이가 나는 이유"를 내용(정답 여부)에만 남기는 것이 핵심이다.
4단계: 공개 선호 데이터셋 불러오기
Hugging Face의 datasets 라이브러리를 쓴다. 설치는 pip install datasets.
from datasets import load_dataset
# 데이터셋 이름은 예시다. 접근 가능한지, 컬럼 이름이 무엇인지 직접 확인하자.
ds = load_dataset("Anthropic/hh-rlhf", split="train[:100]")
print(ds)
print(ds.column_names)
print(ds[0])데이터셋마다 컬럼 구조가 다르다. 어떤 것은 prompt / chosen / rejected로 나뉘어 있고, 어떤 것은 대화 전체가 chosen, rejected 문자열에 통째로 들어 있다. 위 이름의 데이터셋이 현재 그대로 로드되지 않으면 Hugging Face Hub에서 "preference" 또는 "dpo"로 검색해 다른 데이터셋으로 바꿔도 된다. 확인할 것은 세 가지다.
- 컬럼 이름이 무엇인가
- prompt가 따로 있는가, 아니면 대화에 섞여 있는가
- chosen과 rejected가 어디서부터 갈라지는가
row = ds[0]
print("CHOSEN:\n", row["chosen"][:300])
print("\nREJECTED:\n", row["rejected"][:300])직접 읽어 보고 "왜 이게 chosen일까?"를 스스로 판단해 보자. 납득이 안 되는 쌍이 꽤 나올 것이다. 그것이 노이즈다.
오늘의 실험
오늘은 별도 실험이 없다. 대신 위 3단계의 길이 편향 점검 결과를 노트에 한 줄 남겨 두면 나중에 Reward Model 단계에서 비교하기 좋다.
왜 이걸 배우나 (LLM / Post-training 연결)
Post-training에서 모델이 "어떤 답이 더 좋은가"를 배우는 재료가 바로 선호 데이터이고, 이후 Reward Model 학습과 DPO가 모두 이 (prompt, chosen, rejected) 구조를 입력으로 쓴다. 데이터의 노이즈, 길이 편향, 다양성 부족은 학습 알고리즘을 바꿔도 해결되지 않고 그대로 모델에 반영되기 때문에, LLM Research Engineer는 알고리즘만큼 데이터 품질을 점검하는 데 시간을 쓴다.
초보자가 막히는 지점
- "chosen = 정답"이라는 오해: chosen은 절대적 정답이 아니라 "비교 대상보다 낫다"는 뜻이다. 둘 다 별로일 수도 있다.
- 데이터셋마다 구조가 다르다:
prompt컬럼이 없고 대화 전체가 한 문자열인 경우가 흔하다. 코드를 짜기 전에ds[0]을 반드시 출력해서 눈으로 확인한다. - 로드 에러: 네트워크 문제나 데이터셋 이름 변경, 접근 권한 문제일 수 있다. 에러 메시지의 데이터셋 이름을 Hub에서 다시 검색해 본다.
- 규칙 기반 데이터의 숨은 편향: 규칙이 단순할수록 chosen/rejected 사이에 규칙 외의 우연한 차이(길이, 말투)가 생긴다. 3단계처럼 통계로 확인하는 습관을 들인다.
- 랜덤 시드: 결과를 재현하려면
random.seed()를 고정한다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제(DP)를 푼다. 문제 선택은 DP 유형(1차원 점화식, 2차원 격자·문자열 등) 중 하나씩 고르면 좋다.
풀이 후 다음을 기록한다.
- 접근: 상태를 무엇으로 정의했고, 점화식이 무엇인지 한두 문장으로
- 시간·공간 복잡도: 상태 수 × 전이 비용으로 계산한 값
- 틀린 이유: 초기값(base case) 실수, 인덱스 범위, 점화식 누락 등 어디서 틀렸는지 구체적으로