Project 3 마무리: Ablation으로 "무엇이 DPO 결과를 바꿨는지" 증명하고 실패 사례를 태깅하기
Project 3의 마지막 날입니다. DPO 학습에서 beta, reference 모델 고정 여부, 데이터 규모를 하나씩만 바꿔 가며 결과를 비교하고(ablation), 모델이 틀린 사례를 과도한 회피·장황·포맷 붕괴로 분류(error analysis)합니다. 그 결과를 표와 README로 정리해 "가장 중요한 ablation"이 무엇인지 근거와 함께 말할
By 송민성
오늘의 목표
Project 3(선호 데이터 → 보상 모델 → DPO)를 "돌아가는 코드"에서 "남에게 보여줄 수 있는 결과물"로 마무리합니다. 오늘 할 일은 세 가지입니다.
- beta, reference 고정 여부, 데이터 규모를 바꿔 가며 ablation 표를 만든다.
- 모델의 실패 사례를 과도한 회피, 장황, 포맷 붕괴로 태깅한다.
- README에 결과를 정리하고, "이 프로젝트에서 가장 중요했던 ablation은 무엇인가"를 한 가지 지목한다.
새로 배우는 이론은 없습니다. 이미 만든 것을 실험으로 검증하고 설명하는 날입니다.
개념과 直관
Ablation(어블레이션, 구성 요소를 하나씩 바꾸거나 빼서 그 요소의 기여도를 확인하는 실험)
요리에 비유하겠습니다. 국이 맛있어졌는데 소금, 불 세기, 육수 중 무엇 때문인지 알고 싶다고 합시다. 세 가지를 한꺼번에 바꾸면 원인을 알 수 없습니다. 하나만 바꾸고 나머지는 그대로 두어야 합니다. 이것이 ablation의 핵심 규칙입니다: 한 번에 하나만 바꾼다.
오늘 바꿀 세 가지 축은 다음과 같습니다.
| 축 | 바꾸는 값 | 직관 |
|---|---|---|
| beta | 예: 0.05 / 0.1 / 0.5 | reference 모델에서 얼마나 멀리 가도 되는지를 정하는 "고삐". 크면 보수적, 작으면 과감하게 움직임 |
| reference 고정 여부 | 고정 / 학습 중 갱신 | 기준점이 가만히 있는지, 같이 움직이는지 |
| 데이터 규모 | 예: 500 / 2,000 / 전체 | 선호 쌍이 많을수록 신호가 안정적인지 |
여기서 reference 모델은 DPO 손실을 계산할 때 "원래 모델은 이 답변에 얼마의 확률을 줬는가"를 비교하는 기준입니다. 보통 학습 시작 시점의 모델을 복사해 고정합니다. 갱신하는 설정은 기준점이 계속 움직여서 고삐의 의미가 흐려지므로, 고정과 비교하면 reference의 역할이 드러납니다.
DPO 손실의 모양을 최소한으로 텍스트로 적으면 다음과 같습니다.
margin = beta * [ (logp_policy(chosen) - logp_ref(chosen))
- (logp_policy(rejected) - logp_ref(rejected)) ]
loss = -log( sigmoid(margin) )- logp는 해당 답변이 나올 로그 확률입니다.
- chosen은 사람이 더 선호한 답변, rejected는 덜 선호한 답변입니다.
- margin이 커질수록 loss가 줄어듭니다. 즉 "reference보다 chosen의 확률은 더 올리고 rejected는 더 내리는" 방향으로 학습합니다.
- beta는 이 margin의 배율입니다. 같은 변화량이어도 beta가 크면 margin이 빨리 커집니다.
Error analysis(에러 분석, 평균 점수 뒤에 숨은 실패의 종류를 직접 읽고 분류하는 작업)
점수가 올랐다고 끝이 아닙니다. 평균 승률이 같아도 한 모델은 "잘 모르겠습니다"를 남발하고, 다른 모델은 같은 말을 늘어놓을 수 있습니다. 실패를 종류별로 세어야 다음에 무엇을 고칠지 보입니다. 오늘 쓸 태그는 세 가지입니다.
- 과도한 회피: 답할 수 있는 질문에도 "도와드릴 수 없습니다", "전문가와 상담하세요"로 빠짐
- 장황: 핵심 없이 길고 반복적임. 선호 데이터에서 긴 답변이 더 자주 chosen이었다면 모델이 길이를 따라 배울 수 있음
- 포맷 붕괴: 요구한 형식(JSON, 번호 목록, 한 줄 답 등)을 지키지 못하거나, 문장이 끊기거나, 같은 구절이 반복됨
코드로 직접 해보기
라이브러리를 쓰기 전에, 위 DPO 손실을 직접 계산해 beta의 효과를 눈으로 확인합니다. (PyTorch가 설치되어 있다고 가정합니다.)
1단계: DPO 손실을 손으로 구현
import torch
import torch.nn.functional as F
def dpo_loss(pol_chosen, pol_rejected, ref_chosen, ref_rejected, beta):
# 각 인자는 "답변 전체의 로그 확률 합" 텐서, shape: (배치,)
margin = beta * ((pol_chosen - ref_chosen) - (pol_rejected - ref_rejected))
loss = -F.logsigmoid(margin)
return loss.mean(), margin.detach()
# 장난감 숫자: 정책이 chosen을 2.0 올리고 rejected를 1.0 내렸다고 가정
pc, pr = torch.tensor([-10.0]), torch.tensor([-13.0])
rc, rr = torch.tensor([-12.0]), torch.tensor([-12.0])
for beta in [0.05, 0.1, 0.5]:
loss, m = dpo_loss(pc, pr, rc, rr, beta)
print(f"beta={beta}: margin={m.item():.3f}, loss={loss.item():.4f}")정책이 같은 만큼 움직여도 beta가 클수록 margin이 커지고 loss가 빨리 줄어듭니다. 이 사실을 알고 있으면 beta 실험 결과를 해석하기 쉽습니다.
2단계: 실험 설정을 한 곳에 모으기
BASE = dict(beta=0.1, ref_frozen=True, n_pairs=2000, seed=0)
def make_runs():
runs = [("base", BASE)]
for b in [0.05, 0.5]:
runs.append((f"beta={b}", {**BASE, "beta": b}))
runs.append(("ref_updating", {**BASE, "ref_frozen": False}))
for n in [500, 5000]:
runs.append((f"n={n}", {**BASE, "n_pairs": n}))
return runs
for name, cfg in make_runs():
print(name, cfg){**BASE, "beta": b}는 BASE를 복사하고 한 항목만 덮어씁니다. 이렇게 하면 "하나만 바꾼다"는 규칙이 코드 구조로 지켜집니다. 데이터 규모 중 실제 보유량보다 큰 값은 쓸 수 없으니, 자신의 데이터 크기에 맞게 숫자를 조정하세요.
3단계: 실패 사례 태깅 자동 보조
사람이 읽는 것이 기본이지만, 후보를 먼저 걸러주는 간단한 규칙을 만들어 두면 빠릅니다.
import json
REFUSAL_HINTS = ["도와드릴 수 없", "답변드리기 어렵", "전문가와 상담"]
def auto_tags(prompt, answer, need_json=False, len_limit=600):
tags = []
if any(h in answer for h in REFUSAL_HINTS):
tags.append("refusal")
if len(answer) > len_limit:
tags.append("verbose")
if need_json:
try:
json.loads(answer)
except Exception:
tags.append("format_break")
# 같은 문장이 3번 이상 반복되면 포맷 붕괴 후보
sents = [s.strip() for s in answer.split(".") if s.strip()]
if sents and max(sents.count(s) for s in set(sents)) >= 3:
tags.append("format_break")
return sorted(set(tags))
print(auto_tags("질문", "도와드릴 수 없습니다.", False))자동 규칙은 후보를 거르는 용도일 뿐입니다. 최종 태그는 반드시 직접 읽고 확정하세요. 한 사례에 태그가 여러 개 붙어도 됩니다.
4단계: 결과 집계
from collections import Counter
def summarize(records):
# records: [{"run": "base", "tags": ["verbose"]}, ...]
table = {}
for r in records:
c = table.setdefault(r["run"], Counter())
c["total"] += 1
for t in r["tags"]:
c[t] += 1
for run, c in table.items():
n = c["total"]
print(run, {k: f"{v/n:.0%}" for k, v in c.items() if k != "total"})오늘의 실험
목표: 세 축의 ablation 표를 채우고, 그중 결과를 가장 크게 바꾼 축을 지목합니다.
절차
- 고정할 것을 먼저 정합니다: 같은 base 모델, 같은 평가 프롬프트 세트(예: 100~200개), 같은 seed, 같은 학습 스텝 수.
- 위
make_runs()의 6개 설정을 학습합니다. 시간이 부족하면 설정 수를 줄이되, 줄였다면 README에 그렇게 적습니다. - 각 모델로 같은 프롬프트에 답변을 생성합니다.
- 지표를 기록합니다. 예를 들면 다음과 같습니다.
- 승률: base 모델 또는 SFT 모델 대비 보상 모델 점수가 높은 비율. 단, 보상 모델로 학습하고 같은 보상 모델로 평가하면 점수가 부풀려질 수 있으므로 별도의 사람 확인 샘플을 함께 봅니다. - 평균 답변 길이 - 실패 태그 비율(회피 / 장황 / 포맷 붕괴)
- 아래 형태의 표를 만듭니다.
| 실행 | beta | ref | 데이터 | 승률 | 평균 길이 | 회피% | 장황% | 포맷붕괴% |
|---|---|---|---|---|---|---|---|---|
| base | 0.1 | 고정 | 2000 | |||||
| beta=0.05 | 0.05 | 고정 | 2000 | |||||
| beta=0.5 | 0.5 | 고정 | 2000 | |||||
| ref_updating | 0.1 | 갱신 | 2000 | |||||
| n=500 | 0.1 | 고정 | 500 | |||||
| n=5000 | 0.1 | 고정 | 5000 |
- 가장 중요한 ablation 지목: base 대비 지표 변화가 가장 큰 행을 고르고, 이유를 2~3문장으로 씁니다. 예시 문장 틀: "승률 변화는 beta에서 가장 컸고, beta가 작을수록 평균 길이와 장황 비율이 함께 늘었다. 따라서 이 프로젝트에서는 beta가 가장 중요한 하이퍼파라미터라고 판단한다."
- 실패 사례는 태그별로 대표 예시 2~3개를 골라 README에 원문 그대로 붙입니다.
주의할 점: seed를 하나만 쓰면 작은 차이는 우연일 수 있습니다. 가능하면 base만이라도 seed를 2~3개 돌려 흔들림 크기를 확인하고, 그보다 작은 차이는 "차이가 있다"고 단정하지 마세요. 불가능했다면 한계로 명시합니다.
README 구성안
# Project 3: Preference Data → Reward Model → DPO
1. 한 줄 요약 (무엇을, 어떤 데이터로, 어떤 결과)
2. 파이프라인 그림 또는 단계 목록
3. 재현 방법 (환경, 실행 명령, seed)
4. Ablation 표 + "가장 중요한 ablation" 한 단락
5. Error analysis (태그 정의, 비율, 대표 예시)
6. 한계와 다음 단계 (측정하지 못한 것, 의심되는 편향)왜 이걸 배우나 (LLM / Post-training 연결)
Post-training 업무에서는 "점수가 올랐다"보다 "무엇 때문에 올랐고, 어디가 망가졌는가"를 설명하는 일이 훨씬 자주 요구됩니다. beta와 reference 설정은 DPO 계열 학습에서 항상 다시 만나는 선택지이고, 회피·장황·포맷 붕괴는 선호 최적화 후 실제로 점검하게 되는 대표적인 실패 유형입니다. 이 README는 면접이나 포트폴리오에서 실험 설계 능력을 보여주는 증거가 됩니다.
초보자가 막히는 지점
- 여러 개를 동시에 바꾸기: beta와 데이터 수를 같이 바꾸면 원인을 분리할 수 없습니다. 반드시 base에서 하나만 바꿉니다.
- 평가 세트가 매번 다름: 프롬프트나 생성 설정(temperature 등)이 달라지면 비교가 무의미합니다. 평가 세트와 생성 설정을 고정하고 파일로 저장하세요.
- 보상 점수만 믿기: 학습에 쓴 신호로 평가하면 모델이 그 신호의 허점을 파고든 결과(예: 길이를 늘려 점수를 얻기)도 "개선"으로 보입니다. 길이 지표와 직접 읽기를 병행하세요.
- loss가 내려가면 성공이라고 생각하기: DPO loss가 줄어도 생성 품질이 나빠질 수 있습니다. loss와 생성 결과는 따로 확인합니다.
- 태그 기준이 흔들림: 어제와 오늘 같은 사례에 다른 태그를 붙이게 됩니다. 태그 정의를 README에 한 줄씩 먼저 적고 시작하세요.
- 실패한 실험을 지우기: 안 된 설정도 표에 남깁니다. "beta=0.5는 변화가 거의 없었다"도 정보입니다.
- 표만 있고 결론이 없음: 숫자를 나열하는 데 그치지 말고 "가장 중요한 ablation은 ○○, 근거는 ○○"를 반드시 문장으로 씁니다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제 (구현 중심). 문제 선택은 자유이며, 배열·문자열·해시맵·스택처럼 "요구사항을 코드로 옮기는" 유형을 권장합니다. 문제당 약 30분을 한도로 하고, 막히면 25분쯤에 풀이를 보되 반드시 다시 혼자 구현합니다.
풀이마다 다음을 기록하세요.
- 접근: 처음 떠올린 방법과 최종 방법을 각각 두세 줄로
- 시간복잡도 / 공간복잡도: 입력 크기 n에 대해 Big-O로 쓰고, 왜 그렇게 되는지 한 줄
- 틀린 이유: 오답이나 시간 초과가 났다면 원인(경계 조건 누락, 인덱스 실수, 자료구조 선택 등)과 다음에 확인할 체크 포인트