5장. LoRA 파인튜닝과 DPO 정렬
실습 6 — DPO 로 답변 성향 맞추기
dpo_shop.jsonl 의 72쌍으로 Qwen2.5-0.5B-Instruct 에 DPO 를 합니다. trl 의 DPOTrainer 에 LoRA 설정을 함께 넘겨, LoRA 를 끈 같은 모델을 기준 모델로 씁니다. DPO 전과 뒤에 시험 질문 24개로 "인공지능" 회피, "고객님", 정답, 답의 길이를 셉니다. 이 PC 의 GPU 에서 학습에 7분 남짓, 앞뒤의 채점까지 10분쯤 걸렸습니다.
1단계. 준비와 DPO 전의 성적
import json, time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, set_seed
from datasets import Dataset
name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
SYSTEM = "당신은 온라인 쇼핑몰 고객센터 상담원입니다."
with open("sft_shop.jsonl", encoding="utf-8") as f:
test_rows = [r for r in map(json.loads, f) if r["나눔"] == "test"]
def ask(m, q):
msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": q}]
enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True).to(m.device)
with torch.no_grad():
out = m.generate(**enc, max_new_tokens=60, do_sample=False)
return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip().replace("\n", " ")
def report(m, label):
"""시험 질문 24개에 답하게 하고 네 가지를 센다."""
answers = [ask(m, r["질문"]) for r in test_rows]
print(f"[{label}] 인공지능 {sum('인공지능' in a for a in answers)}개, 고객님 {sum('고객님' in a for a in answers)}개, "
f"정답 {sum(r['정답열쇠'] in a for r, a in zip(test_rows, answers))}/24, 평균 {sum(map(len, answers)) / 24:.0f}자")
for r, a in list(zip(test_rows, answers))[:2]:
print(" ", r["질문"], "→", a[:90])
device = "cuda" if torch.cuda.is_available() else "cpu"
set_seed(42)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16 if device == "cuda" else torch.float32).to(device)
report(model, "DPO 전")[DPO 전] 인공지능 19개, 고객님 2개, 정답 0/24, 평균 102자
배송이 며칠 걸리는지 알려 주세요. → 죄송합니다, 저는 인공지능으로서 배송 시간에 대한 정보를 제공할 수 없습니다. 하지만 대부분의 온라인 쇼핑몰에서는 고객센터에서 배송 시간을 확인할 수 있습니다.
언제 도착하는지 궁금합니다. → 죄송합니다, 저는 인공지능으로서 시간을 관리하지 못하고 있습니다. 하지만 고객센터에 직접 방문하거나 전화를 통해 도움이 필요하신 경우 언제든지 문의해 주세요.2단계. DPO 데이터를 대화 꼴로
with open("dpo_shop.jsonl", encoding="utf-8") as f:
pairs = [json.loads(line) for line in f]
# trl 은 대화 꼴(메시지 목록)의 prompt, chosen, rejected 를 받아 채팅 템플릿을 알아서 씌운다
dpo_data = Dataset.from_list([{
"prompt": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": p["prompt"]}],
"chosen": [{"role": "assistant", "content": p["chosen"]}],
"rejected": [{"role": "assistant", "content": p["rejected"]}],
} for p in pairs])
print(dpo_data)Dataset({
features: ['prompt', 'chosen', 'rejected'],
num_rows: 72
})3단계. DPO 학습
from peft import LoraConfig
from trl import DPOConfig, DPOTrainer
lora = LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM")
config = DPOConfig(
"out_dpo_ckpt",
beta=0.1, # 기준 모델에서 얼마나 멀어져도 되나
per_device_train_batch_size=4,
num_train_epochs=3,
learning_rate=1e-4,
logging_steps=6,
max_length=256, # 질문과 답을 합친 최대 토큰 수
save_strategy="no",
report_to="none",
seed=42,
fp16=torch.cuda.is_available(),
disable_tqdm=True,
)
# peft_config 를 주면 trl 이 LoRA 를 붙이고, LoRA 를 끈 같은 모델을 기준 모델로 쓴다
trainer = DPOTrainer(model=model, args=config, train_dataset=dpo_data, processing_class=tok, peft_config=lora)
start = time.perf_counter()
trainer.train()
print(f"학습 시간: {time.perf_counter() - start:.0f}초")
# 학습 기록에서 DPO 지표를 몇 줄 골라 본다
for h in trainer.state.log_history:
if "rewards/margins" in h and h["step"] in (6, 18, 36, 54):
print(f"{h['step']:2d}걸음 손실 {h['loss']:.3f} 고른 {h['rewards/chosen']:.2f} 반대 {h['rewards/rejected']:.2f} "
f"차이 {h['rewards/margins']:.2f} 정확도 {h['rewards/accuracies']:.2f}"){'loss': '0.5538', 'grad_norm': '4.651', 'learning_rate': '9.074e-05', 'entropy': '1.905', 'num_tokens': '3683', 'logits/chosen': '-2.541', 'logits/rejected': '-2.142', 'mean_token_accuracy': '0.5621', 'rewards/chosen': '0.2221', 'rewards/rejected': '-0.1273', 'rewards/accuracies': '0.5', 'rewards/margins': '0.3494', 'logps/chosen': '-80.8', 'logps/rejected': '-59.02', 'epoch': '0.3333'}
{'loss': '0.1602', 'grad_norm': '1.584', 'learning_rate': '7.963e-05', 'entropy': '1.963', 'num_tokens': '7277', 'logits/chosen': '-2.593', 'logits/rejected': '-2.261', 'mean_token_accuracy': '0.6336', 'rewards/chosen': '0.8102', 'rewards/rejected': '-1.052', 'rewards/accuracies': '1', 'rewards/margins': '1.862', 'logps/chosen': '-68.24', 'logps/rejected': '-67.17', 'epoch': '0.6667'}
{'loss': '0.02723', 'grad_norm': '0.2183', 'learning_rate': '6.852e-05', 'entropy': '1.993', 'num_tokens': '1.09e+04', 'logits/chosen': '-2.529', 'logits/rejected': '-2.312', 'mean_token_accuracy': '0.6344', 'rewards/chosen': '1.319', 'rewards/rejected': '-2.536', 'rewards/accuracies': '1', 'rewards/margins': '3.855', 'logps/chosen': '-66.06', 'logps/rejected': '-84.28', 'epoch': '1'}
{'loss': '0.004125', 'grad_norm': '0.07015', 'learning_rate': '5.741e-05', 'entropy': '1.91', 'num_tokens': '1.454e+04', 'logits/chosen': '-2.572', 'logits/rejected': '-2.322', 'mean_token_accuracy': '0.6637', 'rewards/chosen': '1.681', 'rewards/rejected': '-3.973', 'rewards/accuracies': '1', 'rewards/margins': '5.654', 'logps/chosen': '-59.93', 'logps/rejected': '-97.73', 'epoch': '1.333'}
{'loss': '0.000973', 'grad_norm': '0.03539', 'learning_rate': '4.63e-05', 'entropy': '1.914', 'num_tokens': '1.818e+04', 'logits/chosen': '-2.538', 'logits/rejected': '-2.392', 'mean_token_accuracy': '0.6536', 'rewards/chosen': '1.738', 'rewards/rejected': '-5.36', 'rewards/accuracies': '1', 'rewards/margins': '7.097', 'logps/chosen': '-61.82', 'logps/rejected': '-110.7', 'epoch': '1.667'}
{'loss': '0.0004894', 'grad_norm': '0.01221', 'learning_rate': '3.519e-05', 'entropy': '1.962', 'num_tokens': '2.18e+04', 'logits/chosen': '-2.585', 'logits/rejected': '-2.411', 'mean_token_accuracy': '0.6306', 'rewards/chosen': '1.871', 'rewards/rejected': '-5.98', 'rewards/accuracies': '1', 'rewards/margins': '7.85', 'logps/chosen': '-63.97', 'logps/rejected': '-118', 'epoch': '2'}
{'loss': '0.0002912', 'grad_norm': '0.00893', 'learning_rate': '2.407e-05', 'entropy': '1.883', 'num_tokens': '2.546e+04', 'logits/chosen': '-2.542', 'logits/rejected': '-2.335', 'mean_token_accuracy': '0.6453', 'rewards/chosen': '1.954', 'rewards/rejected': '-6.381', 'rewards/accuracies': '1', 'rewards/margins': '8.335', 'logps/chosen': '-62.38', 'logps/rejected': '-121.7', 'epoch': '2.333'}
{'loss': '0.0003054', 'grad_norm': '0.009017', 'learning_rate': '1.296e-05', 'entropy': '1.983', 'num_tokens': '2.904e+04', 'logits/chosen': '-2.552', 'logits/rejected': '-2.434', 'mean_token_accuracy': '0.6356', 'rewards/chosen': '1.836', 'rewards/rejected': '-6.434', 'rewards/accuracies': '1', 'rewards/margins': '8.27', 'logps/chosen': '-61.09', 'logps/rejected': '-122.5', 'epoch': '2.667'}
{'loss': '0.0002557', 'grad_norm': '0.01163', 'learning_rate': '1.852e-06', 'entropy': '1.847', 'num_tokens': '3.271e+04', 'logits/chosen': '-2.574', 'logits/rejected': '-2.382', 'mean_token_accuracy': '0.6861', 'rewards/chosen': '1.799', 'rewards/rejected': '-6.579', 'rewards/accuracies': '1', 'rewards/margins': '8.378', 'logps/chosen': '-59.26', 'logps/rejected': '-123.1', 'epoch': '3'}
{'train_runtime': '440.8', 'train_samples_per_second': '0.49', 'train_steps_per_second': '0.123', 'train_loss': '0.08307', 'epoch': '3'}
학습 시간: 446초
6걸음 손실 0.554 고른 0.22 반대 -0.13 차이 0.35 정확도 0.50
18걸음 손실 0.027 고른 1.32 반대 -2.54 차이 3.85 정확도 1.00
36걸음 손실 0.000 고른 1.87 반대 -5.98 차이 7.85 정확도 1.00
54걸음 손실 0.000 고른 1.80 반대 -6.58 차이 8.38 정확도 1.004단계. DPO 뒤의 성적
dpo_model = trainer.model.eval()
report(dpo_model, "DPO 뒤")
dpo_model.save_pretrained("out_dpo_shop") # DPO 어댑터 저장[DPO 뒤] 인공지능 3개, 고객님 24개, 정답 1/24, 평균 103자
배송이 며칠 걸리는지 알려 주세요. → 안녕하세요, 고객님. 배송 시간은 고객의 구매일로부터 3-5일 정도로 예상됩니다. 하지만, 이는 예상치 못한 시간이 될 수 있으니, 고객님의 구매일에 따라 실제
언제 도착하는지 궁금합니다. → 안녕하세요, 고객님. 저는 온라인 쇼핑몰의 고객 서비스를 제공하고 있습니다. 도착 시간에 대한 정보는 고객에게 직접 연락하여 확인해 주시면 감사하겠습니다. 고객님이 실습이 뜻하는 것
이 실습은 직원에게 좋은 응대와 아쉬운 응대를 짝지어 보여 주며 "이쪽처럼 말하세요" 하고 가르치는 것과 같습니다. 교육 전에 시험을 보고(1단계), 짝들을 대화 꼴의 교재로 만들고(2단계), 원래 직원을 옆에 두고 그에게서 너무 멀어지지 않게 하며 세 바퀴 교육하고(3단계), 같은 시험을 다시 봅니다(4단계). 여기서 교재가 선호 데이터, 옆에 둔 원래 직원이 기준 모델, 너무 멀어지지 않게 붙잡는 끈이 beta 입니다. 비유와 다른 점은, 원래 직원을 따로 데려오는 대신 같은 모델에서 LoRA 만 끈 것을 기준으로 쓴다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. DPO 전 | 인공지능 19개, 고객님 2개, 정답 0/24, 평균 102자 | 24개 가운데 19개를 "인공지능이라 답할 수 없다"로 피했고 맞힌 답은 없습니다 |
| 2. 데이터 | num_rows: 72 | 72쌍이 prompt, chosen, rejected 세 칸의 대화 꼴이 되었습니다 |
| 3. 6걸음 | 차이 0.35, 정확도 0.50 | 학습 초반에는 고른 답을 더 높게 본 쌍이 절반뿐이었습니다 |
| 3. 18걸음 | 차이 3.85, 정확도 1.00 | 이때부터 그 구간의 모든 쌍에서 고른 답을 더 높게 봤습니다 |
| 3. 54걸음 | 고른 1.80, 반대 -6.58, 차이 8.38 | 고른 답의 보상은 조금 오르고 반대 답의 보상은 크게 내려갔습니다. 주로 "반대 답을 피하는" 쪽으로 움직였습니다 |
| 3. 학습 시간 | 446초 | 이 PC 의 GPU 에서 7분 남짓 걸렸습니다. 기계마다 다릅니다 |
| 4. DPO 뒤 | 인공지능 3개, 고객님 24개, 정답 1/24, 평균 103자 | 피하는 답이 크게 줄고 모든 답이 "고객님"을 부르는 말투가 되었지만, 맞힌 답은 하나뿐입니다 |
| 4. 답의 예 | "배송 시간은 … 3-5일 정도로 예상됩니다" | 상담원 말투로, 이 쇼핑몰의 정책(1~3일)과 다른 기간을 지어냈습니다 |
한 줄로 줄이면, DPO 는 72쌍만으로 답의 말투를 상담원답게 바꿨지만 사실은 가르치지 못했다는 실습입니다. 사실을 가르친 것은 실습 3의 SFT(Supervised Fine-Tuning, 지도 미세조정)였고, 둘을 이어 쓰는 순서는 프로젝트에서 해 봅니다. 학습 기록의 손실이 0 에 가까워진 것은 학습용 72쌍에서의 값이라 시험 질문의 답이 좋아졌다는 뜻이 아니고, 학습 시간과 지표의 숫자는 이 PC 에서 얻은 값입니다.
코드 설명
DPOTrainer의peft_config는 실습 3에서 손으로 한get_peft_model을 대신합니다. 기준 모델을 따로 불러오지 않고 "LoRA 를 끈 같은 모델"을 쓰므로 GPU 메모리가 하나분만 듭니다.- 6걸음에서 손실 0.554, 차이 0.35, 정확도 0.50 으로 아직 반반이던 것이, 18걸음에는 정확도 1.00, 54걸음에는 차이 8.38 까지 벌어졌습니다. 고른 답의 보상은 1.80 으로 조금 오르고, 반대 답의 보상은 -6.58 로 크게 내려갔습니다. "DPO 지표 읽기" 절에서 말한 대로 반대 답을 피하는 쪽으로 크게 움직였습니다. 학습에는 446초가 걸렸습니다.
- 시험 질문 24개에서 "인공지능"으로 피한 답이 19개에서 3개로 줄고, "고객님"이 든 답이 2개에서 24개로 늘었습니다. 답의 말투가 상담원답게 바뀐 것입니다. 평균 길이는 102자에서 103자로 거의 같습니다.
- 그런데 정답은 0개에서 1개로 거의 늘지 않았습니다. "배송 시간은 … 3-5일 정도로 예상됩니다"처럼 상담원의 말투로 여전히 지어냅니다. "정렬의 위험" 절의 "말투만 배우기"입니다. 선호 데이터의 고른 답에 정책이 들어 있어도, DPO 는 "어떻게 말할지"를 주로 바꾸고 "무엇이 사실인지"는 거의 가르치지 못합니다. 사실을 가르친 것은 실습 3의 SFT 였습니다.