5장. LoRA 파인튜닝과 DPO 정렬
DPO 의 원리
운전 연수를 떠올려 보세요. 강사가 "이렇게 하세요, 저렇게는 하지 마세요"를 짝지어 보여 줍니다. 연수생은 좋은 운전은 더 자주, 나쁜 운전은 덜 하게 되지만, 원래의 운전 습관에서 너무 멀어지지는 않습니다. DPO 가 모델을 바꾸는 방법이 이것입니다.
1번에서 같은 질문에 고른 답과 반대 답을 함께 넣습니다. 2번에서 학습 중인 모델과 기준 모델(학습 전의 모델)이 두 답을 낼 확률을 각각 잽니다. 3번에서 "기준 모델보다 고른 답의 확률은 얼마나 올렸고, 반대 답의 확률은 얼마나 내렸나"를 견주고, 4번에서 그 차이가 커지도록 LoRA 만 조금 고칩니다. 여기서 강사의 "이렇게, 저렇게는 말고"가 선호 데이터, 원래 운전 습관이 기준 모델입니다.
DPO 의 손실
| 기호 | 뜻 |
|---|---|
| 고른 답의 보상 | beta × (학습 모델의 로그 확률 - 기준 모델의 로그 확률), 고른 답에서 |
| 반대 답의 보상 | 같은 계산, 반대 답에서 |
| 차이(margin) | 고른 답의 보상 - 반대 답의 보상 |
| 손실 | -log(sigmoid(차이)). 차이가 클수록 0 에 가까워진다 |
기준 모델과 견주는 까닭은, 모델이 반대 답을 피하려고 엉뚱한 말을 지어내지 않게 붙잡아 두기 위해서입니다. beta 가 작을수록 기준 모델에서 멀리 갈 수 있습니다.
import math
def dpo_loss(policy_c, ref_c, policy_r, ref_r, beta=0.1):
"""로그 확률 넷으로 DPO 손실을 계산한다."""
reward_c = beta * (policy_c - ref_c) # 고른 답을 기준보다 얼마나 더 좋아하나
reward_r = beta * (policy_r - ref_r) # 반대 답을 기준보다 얼마나 더 좋아하나
margin = reward_c - reward_r
return -math.log(1 / (1 + math.exp(-margin))), margin
print("학습 전(기준과 같음):", [round(v, 3) for v in dpo_loss(-40, -40, -30, -30)])
print("학습 뒤:", [round(v, 3) for v in dpo_loss(-30, -40, -60, -30)])학습 전에는 학습 모델과 기준 모델이 같아 차이가 0 이고 손실은 0.693(log 2)입니다. 학습 뒤 고른 답의 로그 확률이 10 오르고 반대 답이 30 내려가면 차이는 4.0, 손실은 0.018 로 줄어듭니다. 실습 6의 학습 기록에서 이 숫자들이 실제로 어떻게 움직이는지 봅니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
dpo_loss(policy_c, ref_c, policy_r, ref_r, beta=0.1) | 로그 확률 넷을 받아 손실과 차이를 돌려준다. policy 는 학습 중인 모델, ref 는 reference(기준 모델), c 는 chosen(고른 답), r 은 rejected(반대 답)다 |
beta * (policy_c - ref_c) | 고른 답의 보상이다. 학습 모델이 기준 모델보다 고른 답의 로그 확률을 얼마나 올렸는지에 beta 를 곱한다 |
1 / (1 + math.exp(-margin)) | sigmoid(시그모이드, S 자 곡선) 함수다. 어떤 수든 0 과 1 사이로 바꾼다. 차이가 클수록 1 에 가깝다 |
-math.log(...) | 확률에 로그를 씌우고 부호를 바꾼다. 1 에 가까운 값은 손실 0 쪽, 0 에 가까운 값은 큰 손실이 된다 |
round(v, 3) | 손실과 차이를 소수 셋째 자리까지 줄여 찍는다 |
이 코드가 뜻하는 것
운전 연수의 점수판으로 읽으면, 연수생이 처음보다 좋은 운전(고른 답)을 얼마나 더 하게 됐고 나쁜 운전(반대 답)을 얼마나 덜 하게 됐는지 재서, 그 간격이 클수록 벌점(손실)을 적게 주는 계산입니다. 여기서 처음 습관이 기준 모델, 벌점이 DPO 손실입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
reward_c = beta * (policy_c - ref_c) | 고른 답을 기준보다 얼마나 더 좋아하는지 잰다 | 좋은 운전이 처음보다 얼마나 늘었나 |
reward_r = beta * (policy_r - ref_r) | 반대 답을 기준보다 얼마나 더 좋아하는지 잰다 | 나쁜 운전이 처음보다 얼마나 늘었나 |
margin = reward_c - reward_r | 두 보상의 차이다 | 좋은 운전과 나쁜 운전의 간격 |
return -math.log(sigmoid(margin)), margin | 간격을 벌점으로 바꾼다 | 간격이 넓을수록 벌점이 작다 |
dpo_loss(-40, -40, -30, -30) | 학습 모델과 기준 모델이 똑같은 경우 | 연수 첫날 |
dpo_loss(-30, -40, -60, -30) | 고른 답은 -40 에서 -30 으로 오르고, 반대 답은 -30 에서 -60 으로 내려간 경우 | 연수를 마친 뒤 |
학습 뒤의 차이 4.0 은 0.1 × (10) - 0.1 × (-30) = 1.0 + 3.0 으로 나옵니다. 손실은 이 차이만 보고 정해지므로, 고른 답을 올리든 반대 답을 내리든 간격만 벌어지면 줄어듭니다.
작게 줄여서 눈으로 보기
차이(margin)에 따라 손실이 어떻게 바뀌는지, 그리고 beta 가 차이를 어떻게 키우는지 숫자로 늘어놓아 봅니다.
import math
def loss_from_margin(m):
return -math.log(1 / (1 + math.exp(-m))) # -log(sigmoid(차이))
for m in [-2, -1, 0, 1, 2, 4]:
print(f"차이 {m:>2} → 손실 {loss_from_margin(m):.3f}")
# 같은 로그 확률 변화(고른 답 +10, 반대 답 -30)라도 beta 에 따라 차이가 달라진다
for beta in [0.05, 0.1, 0.5]:
margin = beta * 10 - beta * (-30)
print(f"beta {beta}: 차이 {margin:.1f}, 손실 {loss_from_margin(margin):.3f}")차이 -2 → 손실 2.127
차이 -1 → 손실 1.313
차이 0 → 손실 0.693
차이 1 → 손실 0.313
차이 2 → 손실 0.127
차이 4 → 손실 0.018
beta 0.05: 차이 2.0, 손실 0.127
beta 0.1: 차이 4.0, 손실 0.018
beta 0.5: 차이 20.0, 손실 0.000| 결과 | 읽는 법 |
|---|---|
| 차이 -2, -1 | 반대 답 쪽으로 기울어 있으면 손실이 0.693 보다 크다. 학습이 이 쌍을 강하게 고치려 한다 |
| 차이 0 | 두 답을 똑같이 보는 상태로 손실은 0.693 이다. 학습을 시작할 때의 값이다 |
| 차이 1, 2, 4 | 고른 답 쪽으로 기울수록 손실이 0.313, 0.127, 0.018 로 0 에 가까워진다. 이미 잘 고른 쌍은 더 고칠 것이 적다 |
| beta 줄 | 같은 변화라도 beta 가 0.05 면 차이 2.0, 0.5 면 20.0 이다. beta 가 크면 조금만 움직여도 손실이 바로 0 에 가까워져서 더 멀리 갈 까닭이 없다. 그래서 beta 가 작을수록 기준 모델에서 멀리 간다 |
beta 0.5 의 손실은 0 이 아니라 0 에 아주 가까운 수인데, 소수 셋째 자리까지 줄여 찍어 0.000 으로 보입니다.
주의할 점
- DPO 는 기준 모델을 따로 GPU 에 올려야 하지만, LoRA 와 함께 쓰면 "LoRA 를 끈 같은 모델"을 기준으로 써서 메모리를 아낍니다. trl 이 알아서 해 줍니다.
- 학습 전 모델이 고른 답을 거의 내지 못하는 질문에서는 DPO 만으로는 한계가 있습니다. 그럴 때는 SFT 를 먼저 합니다(프로젝트의 순서).