Reward Model: 두 응답 중 어느 쪽이 더 나은지를 점수 하나로 배우는 법
Reward Model은 "이 응답이 얼마나 좋은가"를 숫자 하나(scalar)로 내놓는 모델이다. 사람이 고른 chosen/rejected 쌍만 있으면, 두 점수의 차이를 시그모이드에 넣는 Bradley-Terry 가정과 pairwise ranking loss로 학습할 수 있다. 오늘은 작은 모델에 value head를 붙여 직접 학습하고 선호 정확도를
By 송민성
오늘의 목표
Reward Model(보상 모델, 응답 하나를 읽고 "얼마나 좋은지"를 숫자 하나로 돌려주는 모델)이 정확히 무엇을 입력받고, 무엇을 출력하고, 어떤 신호로 학습되는지 이해한다. 그리고 작은 모델에 직접 scalar head를 붙여 학습해 보고, 길이 편향 같은 이상 징후를 눈으로 확인한다.
개념과 직관
1. 왜 "점수"가 아니라 "비교"로 배우나
"이 답변은 7.3점"처럼 절대 점수를 매기라고 하면 사람마다 기준이 다르다. 어떤 사람은 후하고 어떤 사람은 박하다. 반면 "A와 B 중 어느 쪽이 낫나?"는 훨씬 일관되게 답할 수 있다. 와인 시음회에서 점수를 매기는 것보다 두 잔을 놓고 "이쪽이 낫다"고 고르는 게 쉬운 것과 같다.
그래서 선호 데이터(preference data)는 보통 이런 모양이다.
- prompt: 질문
- chosen: 사람이(또는 평가 기준이) 더 낫다고 고른 응답
- rejected: 덜 낫다고 판단된 응답
2. 구조: 백본 + scalar value head
- 백본(backbone, 텍스트를 숫자 벡터로 바꿔주는 본체 모델): 오늘은 작은 SFT 모델을 쓴다. 단어마다 벡터(hidden state)를 만들어 준다.
- value head(값 헤드, 벡터를 숫자 하나로 줄이는 작은 선형층): 마지막 토큰 위치의 벡터를 받아 숫자 1개를 낸다.
언어 모델은 마지막 층에서 "다음 단어 확률"(어휘 크기만큼의 숫자)을 내지만, Reward Model은 그 자리에 "숫자 1개"를 내는 층을 단다. 글 전체를 다 읽은 뒤의 마지막 토큰에 정보가 모이므로 그 위치를 쓴다. (왜 마지막 토큰이 글 전체를 볼 수 있는지는 모델 구조에서 이미 다룬 내용을 떠올리면 된다. 패딩 때문에 "마지막 토큰"의 위치를 잘못 잡기 쉽다는 점은 아래 막히는 지점에서 다룬다.)
[prompt + response] → 백본 → 마지막 토큰 벡터 → value head → r (숫자 1개)3. Bradley-Terry: 점수 차이를 확률로
Bradley-Terry 모델은 "i가 j보다 선호될 확률"을 두 점수의 차이로 정의한다.
P(i > j) = σ(s_i − s_j)
σ(x) = 1 / (1 + exp(−x))σ(시그모이드, 어떤 실수든 0~1 사이로 눌러주는 함수)를 쓰므로 해석이 쉽다.
- s_i − s_j = 0 → 확률 0.5 (우열 없음)
- s_i가 훨씬 큼 → 확률이 1에 가까움
- s_i가 훨씬 작음 → 확률이 0에 가까움
중요한 점: 점수의 절대값은 의미가 없고 차이만 의미가 있다. 모든 점수에 같은 수를 더해도 확률은 안 변한다.
4. Pairwise ranking loss
우리는 "chosen이 이겼다"는 사실을 안다. 그러니 그 사건의 확률 σ(r_chosen − r_rejected)이 커지도록 학습한다. 손실(loss, 모델이 얼마나 틀렸는지 나타내는 숫자)은 그 확률에 −log를 씌운 것이다.
loss = −log σ(r_chosen − r_rejected)감을 잡기 위한 숫자 예:
- 차이 = +3 (잘 맞힘): σ ≈ 0.953 → loss ≈ 0.05
- 차이 = 0 (모름): σ = 0.5 → loss ≈ 0.69
- 차이 = −3 (거꾸로): σ ≈ 0.047 → loss ≈ 3.05
틀릴수록 손실이 크게 커지고, 맞히면 0에 가까워진다. 경사하강(gradient descent, 손실을 줄이는 방향으로 파라미터를 조금씩 옮기는 것)으로 이 값을 줄이면 chosen의 점수는 올라가고 rejected의 점수는 내려간다.
5. 선호 정확도(preference accuracy)
평가 지표는 단순하다. 쌍마다 r_chosen > r_rejected 이면 맞힌 것으로 세고, 전체 비율을 구한다. 무작위면 약 50%다. 따라서 50% 근처라면 학습이 안 된 것이고, 데이터 노이즈가 큰 선호 데이터에서는 100%가 나오지 않는 것이 보통이다. 검증 데이터(학습에 쓰지 않은 데이터)로 재야 의미가 있다.
6. 길이 편향과 reward hacking 징후
- 길이 편향(length bias): 내용과 상관없이 긴 응답에 높은 점수를 주는 경향. 사람이 고른 chosen이 평균적으로 더 길면, 모델은 "길면 좋은 것"이라는 지름길을 배우기 쉽다.
- reward hacking(보상 해킹, 진짜 품질은 올리지 않고 점수만 높이는 방법을 찾아내는 것): 나중에 이 Reward Model을 기준으로 다른 모델을 최적화하면, 그 모델이 쓸데없이 길게 쓰는 식으로 점수를 뽑아낼 수 있다. 오늘은 그 최적화를 하지 않고, 징후만 관찰한다.
징후 체크리스트:
- 점수와 길이의 상관계수가 매우 높다 (예: 0.5 이상이면 의심. 기준은 데이터마다 다르니 절대 기준이 아니라 감지용으로 본다)
- chosen이 rejected보다 길었던 쌍에서만 정확도가 높고, 짧은 쪽이 chosen인 쌍에서는 정확도가 낮다
코드로 직접 해보기
원리를 먼저 보고, 그다음 모델에 붙인다. PyTorch가 설치되어 있다고 가정한다.
단계 1: loss를 손으로 만들어 보기
import torch
import torch.nn.functional as F
r_chosen = torch.tensor([2.0, 0.5, -1.0])
r_rejected = torch.tensor([0.0, 0.5, 1.0])
diff = r_chosen - r_rejected
loss = -F.logsigmoid(diff).mean() # -log σ(diff)의 평균
acc = (diff > 0).float().mean()
print("diff:", diff)
print("loss:", loss.item(), "acc:", acc.item())-torch.log(torch.sigmoid(x))로 써도 같은 결과지만, logsigmoid는 값이 극단적일 때 수치적으로 더 안정적이다.
단계 2: 장난감 Reward Model로 학습 루프 확인
실제 모델을 붙이기 전에, 단어 주머니(bag of words) 수준의 아주 작은 모델로 흐름을 확인한다.
import torch, torch.nn as nn, torch.nn.functional as F
torch.manual_seed(0)
class TinyRM(nn.Module):
def __init__(self, vocab=100, dim=16):
super().__init__()
self.emb = nn.Embedding(vocab, dim)
self.head = nn.Linear(dim, 1) # scalar value head
def forward(self, ids): # ids: (batch, seq)
h = self.emb(ids).mean(dim=1) # (batch, dim)
return self.head(h).squeeze(-1) # (batch,)
# 가짜 데이터: chosen에는 토큰 1~10, rejected에는 토큰 50~60이 많다고 가정
chosen = torch.randint(1, 10, (64, 8))
rejected = torch.randint(50, 60, (64, 8))
model = TinyRM()
opt = torch.optim.Adam(model.parameters(), lr=1e-2)
for step in range(50):
diff = model(chosen) - model(rejected)
loss = -F.logsigmoid(diff).mean()
opt.zero_grad(); loss.backward(); opt.step()
print("loss:", loss.item(), "acc:", (diff > 0).float().mean().item())단계 3: 작은 SFT 모델에 value head 붙이기
Hugging Face transformers를 쓴다. 모델 이름은 본인이 가진 작은 SFT 체크포인트(또는 아주 작은 공개 모델)로 바꾼다. 아래 MODEL_NAME은 자리표시자다.
import torch, torch.nn as nn, torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
MODEL_NAME = "your-small-sft-model" # 자리표시자: 본인 모델 경로로 교체
tok = AutoTokenizer.from_pretrained(MODEL_NAME)
if tok.pad_token is None:
tok.pad_token = tok.eos_token
class RewardModel(nn.Module):
def __init__(self, name):
super().__init__()
self.backbone = AutoModel.from_pretrained(name) # LM head 없는 본체
hidden = self.backbone.config.hidden_size
self.value_head = nn.Linear(hidden, 1)
def forward(self, input_ids, attention_mask):
out = self.backbone(input_ids=input_ids, attention_mask=attention_mask)
h = out.last_hidden_state # (B, T, H)
# 패딩을 건너뛰고 '진짜 마지막 토큰' 위치를 찾는다 (오른쪽 패딩 가정)
last_idx = attention_mask.sum(dim=1) - 1 # (B,)
last_h = h[torch.arange(h.size(0)), last_idx] # (B, H)
return self.value_head(last_h).squeeze(-1) # (B,)단계 4: 한 배치 학습과 정확도
def encode(prompts, responses):
texts = [p + "\n" + r for p, r in zip(prompts, responses)]
return tok(texts, return_tensors="pt", padding=True,
truncation=True, max_length=256)
def step(model, batch, opt=None):
c = encode(batch["prompt"], batch["chosen"])
r = encode(batch["prompt"], batch["rejected"])
rc = model(c["input_ids"], c["attention_mask"])
rr = model(r["input_ids"], r["attention_mask"])
loss = -F.logsigmoid(rc - rr).mean()
if opt is not None:
opt.zero_grad(); loss.backward(); opt.step()
acc = (rc > rr).float().mean().item()
return loss.item(), acc
# model = RewardModel(MODEL_NAME)
# opt = torch.optim.AdamW(model.parameters(), lr=1e-5)
# 데이터 몇백 쌍으로 몇 epoch 돌린 뒤, 학습에 안 쓴 검증 쌍으로 step(model, batch, opt=None)의 acc를 측정학습 때와 평가 때를 구분한다. 평가에서는 opt=None으로 두고, 가능하면 model.eval()과 torch.no_grad() 안에서 돌린다.
데이터는 공개 선호 데이터셋 중 작은 부분집합(수백~수천 쌍)을 써도 되고, 직접 만든 소량의 쌍을 써도 된다. 어떤 데이터셋이든 라이선스와 컬럼 이름(prompt/chosen/rejected)을 직접 확인한다.
오늘의 실험
질문: 우리의 Reward Model은 길이를 좋아하는가?
- 검증 쌍 전체에 대해 r_chosen, r_rejected, 그리고 각 응답의 길이(토큰 수)를 기록한다.
- 모든 응답(chosen과 rejected를 합쳐서)에 대해 "길이 vs 점수"의 상관계수를 구한다.
import numpy as np
# lengths, scores: 같은 길이의 1차원 리스트
corr = np.corrcoef(lengths, scores)[0, 1]
print("length-score corr:", corr)- 데이터 자체의 편향도 같이 본다: chosen이 rejected보다 긴 쌍의 비율은?
- 쌍을 두 그룹으로 나눠 정확도를 따로 잰다.
- 그룹 A: chosen이 더 긴 쌍 - 그룹 B: chosen이 더 짧은 쌍
- 선택 실험: 학습 데이터에서 chosen/rejected 길이 차이가 작은 쌍만 골라 다시 학습해 보고, 상관계수와 그룹별 정확도가 어떻게 달라지는지 비교한다.
기록 표(직접 채운다):
| 실험 | 전체 정확도 | 길이-점수 상관 | 그룹 A 정확도 | 그룹 B 정확도 | chosen이 더 긴 비율 |
|---|---|---|---|---|---|
| 기본 학습 | |||||
| 길이 차 작은 쌍만 |
해석 가이드: A가 B보다 훨씬 높다면 모델이 "길이"를 단서로 쓰고 있을 가능성이 크다. 단, 작은 데이터와 짧은 학습에서는 숫자가 흔들리므로 "확정"이 아니라 "의심"으로 적는다. 시드를 바꿔 한두 번 더 돌려 보면 좋다.
왜 이걸 배우나 (LLM / Post-training 연결)
Reward Model은 RLHF(사람 피드백으로 모델을 다듬는 방식)에서 "무엇이 좋은 응답인가"를 대신 판정하는 심판이고, Bradley-Terry 가정은 이후 배울 DPO의 손실 식을 이해하는 출발점이기도 하다. LLM Post-training 업무에서는 선호 데이터의 품질, 선호 정확도, 길이 편향 같은 해킹 징후를 점검하는 일이 실제 모델 품질을 좌우하므로, 오늘 만든 "측정하는 습관"이 그대로 쓰인다.
초보자가 막히는 지점
- 마지막 토큰 위치 오류: 패딩이 오른쪽에 붙는지 왼쪽에 붙는지에 따라 마지막 진짜 토큰의 위치가 다르다. 위 코드는 오른쪽 패딩을 가정한다. 토크나이저의
padding_side를 확인하고, 한 샘플로 위치를 직접 출력해 검증한다. - 점수 절대값에 의미 부여: "점수가 3.2니까 좋은 응답"이라고 읽으면 안 된다. 같은 쌍 안에서의 차이만 의미가 있다.
- 학습 데이터로 정확도 측정: 학습 쌍에서의 정확도는 높게 나오기 쉽다. 반드시 안 쓴 검증 쌍으로 잰다.
- chosen과 rejected를 다른 설정으로 인코딩: 두 응답을 같은 방식(같은 max_length, 같은 템플릿)으로 처리하지 않으면 모델이 형식 차이를 단서로 배운다.
- loss가 0.69 근처에서 안 내려감: 무작위와 같은 상태다. 학습률이 너무 크거나 작은지, value head 파라미터가 optimizer에 들어갔는지, 데이터 라벨(chosen/rejected)이 뒤바뀌지 않았는지 확인한다.
- 메모리 부족(OOM): 배치 크기와 max_length를 줄인다. 오늘 목표는 성능이 아니라 이해이므로 데이터와 모델을 작게 유지해도 된다.
- 상관이 높다 = 곧 고장이라는 오해: 실제로 더 긴 답이 더 좋은 경우도 있다. 상관은 의심의 신호일 뿐이고, 그룹별 정확도 비교를 함께 봐야 한다.
알고리즘 (병행 1시간)
LeetCode Medium 2문제 (그래프). 예를 들어 BFS/DFS로 연결 요소를 세는 문제와 위상 정렬 또는 최단 경로 계열 문제 중 하나씩 고르면 균형이 좋다. 구체적인 문제는 본인이 정해도 된다.
풀이 후 각 문제마다 아래를 기록한다.
- 접근: 그래프를 어떻게 표현했는가(인접 리스트 등), 어떤 탐색(BFS/DFS/위상 정렬)을 왜 골랐는가
- 시간복잡도·공간복잡도: 정점 수 V, 간선 수 E로 표현 (예: O(V + E))
- 틀린 이유: 방문 체크 누락, 방향/무방향 혼동, 시작점 처리, 사이클 처리 등 어디서 막혔고 어떻게 고쳤는지