LLM이 문장을 자연스럽게 생성하는 것과 사람이 원하는 답변을 만드는 것은 다른 문제다. 사람의 선호를 반영하려면, 어떤 답변이 더 좋은지 학습하는 과정이 필요하다.
대표적인 방법으로 보상 모델과 PPO를 사용하는 RLHF, 그리고 선호 데이터로 모델을 직접 학습하는 DPO가 있다.
같은 질문에 대한 두 답변 중 사람이 더 좋은 답변을 선택한다.
{
"prompt": "제공된 자료만으로 결론을 확정할 수 있어?",
"chosen": "예외 조건이 빠져 있어 추가 확인이 필요합니다.",
"rejected": "네, 확정할 수 있습니다."
}
chosen은 선호 답변, rejected는 비선호 답변이다. 실제 데이터에는 판단에 필요한 자료도 질문과 함께 포함해야 한다.
두 방법은 이 데이터를 사용하는 방식이 다르다.
여기서는 이미 지도 미세조정(SFT)이 된 답변 모델을 시작점으로 가정한다.
선호 데이터 → 보상 모델 학습
↓
답변 생성 → 채점 → 보상 조정 → PPO 업데이트 → 반복
이 구성에는 네 가지 모델 역할이 등장한다.
| 모델 | 역할 | PPO 중 학습 여부 |
|---|---|---|
| Policy | 실제 답변 생성 | 학습 |
| Reward | 완성된 답변 평가 | 고정 |
| Value | 생성 도중 미래 누적 보상 예측 | 학습 |
| Reference | 학습 시작 시점의 기준 policy | 고정 |
Policy와 value는 내부 Transformer를 공유하고 출력층만 다르게 구성할 수도 있다.
보상 모델은 질문과 답변을 입력받아 실수 점수 하나를 출력한다. 선호 답변의 점수가 더 높아지도록 학습한다.
chosen_score = reward_model(prompt, chosen)
rejected_score = reward_model(prompt, rejected)
loss = -F.logsigmoid(chosen_score - rejected_score).mean()
수식은 다음과 같다.
점수 차이가 커질수록 loss는 작아진다. 보상 점수 자체는 확률이 아니므로 0~1로 제한하지 않는다. logsigmoid는 시그모이드와 로그를 따로 계산할 때 생길 수 있는 수치 문제를 피한다.
이후 PPO 단계에서는 학습된 보상 모델을 고정한다.
보상 모델은 완벽하지 않다. 예를 들어 긴 답변을 과하게 높게 평가한다면, policy는 내용보다 길이를 늘리는 방향으로 학습할 수 있다.
이를 억제하기 위해 기준 모델과의 차이에 비용을 부여한다.
직관적으로는 다음과 같다.
학습_목표 = 평균_보상 - 기준_모델에서_벗어난_정도의_페널티
실행 시에는 policy가 생성한 답변을 reference에도 넣고, 같은 토큰에 대한 로그 확률을 비교해 KL 항을 추정할 수 있다.
token_log_ratio = policy_log_probs - reference_log_probs
토큰별 페널티를 주고, 답변 마지막 위치에 보상 모델 점수를 더하는 구성이 흔하다.
token_rewards = -beta * token_log_ratio
token_rewards[last_token] += reward_score
두 모델의 평균 토큰 확률을 비교하는 계산이 아니다. 또한 개별 토큰의 로그 비율은 음수일 수 있지만, policy에서 샘플링한 답변들에 대한 로그 비율의 기댓값은 KL에 해당한다.
Value는 각 시점에서 앞으로 받을 누적 보상을 예상한다. 토큰 자체의 좋고 나쁨을 채점하는 모델은 아니다.
최종 보상만 있고 중간 보상과 할인이 없다고 단순화하면:
final_reward = 3.0
values = [1.0, 2.0, 2.5]
returns = [3.0, 3.0, 3.0]
advantages = [2.0, 1.0, 0.5] # returns - values
최종 보상은 하나여도 학습 목표는 토큰 위치마다 구성된다. Value들을 전부 더하는 것은 아니다.
실제 PPO에서는 보통 GAE로 advantage를 추정한다. 이 경우 value 학습에 쓰는 return 목표는 가치 추정을 섞어 구성하므로, 단순한 실제 보상 합과 항상 같지는 않다.
PPO는 Proximal Policy Optimization의 약자다. 보상에서 계산한 advantage로 policy를 업데이트하는 강화학습 알고리즘이다.
먼저 이미 생성한 답변을 현재 policy에 다시 넣어 토큰별 로그 확률을 계산한다.
old_log_probs = rollout.log_probs.detach()
new_log_probs = policy_log_probs(policy, prompts, responses)
ratio = torch.exp(new_log_probs - old_log_probs)
rollout은 답변 생성 기록이다. old_log_probs는 생성 당시의 고정된 확률이며, new_log_probs는 현재 가중치와 연결되어 역전파할 수 있는 값이다.
이 비율에 advantage를 반영하고 clipping을 적용한다.
advantages = advantages.detach()
objective = ratio * advantages
clipped = torch.clamp(ratio, 0.8, 1.2) * advantages
policy_loss = -masked_mean(torch.minimum(objective, clipped))
예상보다 좋은 선택은 확률을 높이고, 나쁜 선택은 낮추는 방향으로 학습한다. Clipping은 특정 방향으로 확률 비율을 더 바꿔도 추가 이득을 주지 않게 한다. 비율이 반드시 해당 범위 안에 머문다는 강제 제한은 아니다.
Value는 예측이 return 목표에 가까워지도록 학습한다.
value_loss = masked_mean((predicted_values - returns.detach()) ** 2)
KL과 PPO clipping은 비교 기준이 다르다.
| 장치 | 비교 기준 |
|---|---|
| Reference KL | 학습 시작 때 고정한 모델 |
| PPO clipping | 이번 답변을 생성한 당시의 policy |
DPO는 Direct Preference Optimization이다. 기본 DPO에는 별도의 reward 모델이나 value 모델이 필요 없다. 학습 중 새 답변을 생성하는 과정도 필요하지 않다.
대신 질문에 대한 chosen, rejected의 로그 확률을 policy와 고정 reference에서 계산한다.
policy_gap = policy_logprob(chosen) - policy_logprob(rejected)
reference_gap = reference_logprob(chosen) - reference_logprob(rejected)
z = beta * (policy_gap - reference_gap)
loss = -F.logsigmoid(z).mean()
위 코드는 질문 입력 등을 생략한 의사코드다. 답변의 로그 확률은 질문을 조건으로 계산한 답변 토큰들의 로그 확률 합이며, 질문 토큰과 패딩은 제외한다.
학습 방향은 다음과 같다.
기준 모델과 비교해, 비선호 답변보다 선호 답변을 상대적으로 더 선호하게 만든다.
Beta가 0.1일 때 다음 두 경우를 비교해보자.
| Policy gap | Reference gap | z | Loss |
|---|---|---|---|
| -2 | 0 | -0.2 | 약 0.798 |
| -2 | -5 | 0.3 | 약 0.554 |
두 번째는 여전히 rejected의 확률이 더 높지만, 기준 모델보다는 chosen 쪽으로 기울었다.
Policy gap 자체의 부호보다, reference gap을 뺀 결과가 중요하다. 이 결과가 커질수록 loss가 작아진다. 이는 상대적인 선호를 학습하는 것으로, chosen의 절대 확률이 매번 증가한다는 보장은 아니다.
| 항목 | PPO 기반 RLHF | 기본 DPO |
|---|---|---|
| 학습 신호 | 보상에서 계산한 advantage | 선호·비선호 답변 쌍 |
| 별도 reward 모델 | 필요 | 불필요 |
| Value 모델 | 사용 | 불필요 |
| 학습 중 답변 생성 | 필요 | 불필요 |
| 고정 reference | 사용 | 사용 |
| 구현·운영 복잡도 | 상대적으로 높음 | 상대적으로 낮음 |