5장. LoRA 파인튜닝과 DPO 정렬
프로젝트 1 — 친절한 빵집 상담 모델 만들기
가상의 동네 빵집 사장님이 손님 질문에 대한 답을 메모처럼 짧게 적어 두었습니다("매일 오전 8시에 열고 오후 9시에 닫습니다."). 이 메모로 LoRA SFT 를 해서 사실을 가르치고, DPO 로 "안녕하세요. … 맛있게 드세요." 하는 친절한 말투를 입혀 보세요. 4장의 bakery_qa.jsonl 을 씁니다. 빵집과 그 안내는 모두 지어낸 것입니다.
| 단계 | 데이터 | 가르치는 것 |
|---|---|---|
| SFT(LoRA) | 질문 → 메모 말투의 답(인사 없이 사실만) | 빵집의 사실 |
| DPO | 질문, 고른 답(친절한 말투), 반대 답(메모 말투) | 말투 |
메모 말투의 답은 bakery_qa.jsonl 의 답변에서 앞의 "안녕하세요. "와 뒤의 " 맛있게 드세요."를 떼어 만듭니다.
요구사항은 다섯 가지입니다.
- 답변에서 인사와 끝인사를 떼어 메모 말투의 답을 만듭니다.
- 학습용 60쌍(질문 → 메모)으로 Qwen2.5-0.5B-Instruct 에 LoRA SFT 를 하고, 시험 질문 20개로 정답과 친절한 말투를 셉니다.
- SFT 한 LoRA 를 합친 뒤, 학습용 질문 60개로 선호 데이터(고른 답 = 친절한 답, 반대 답 = 메모)를 만들어 DPO 를 합니다.
- DPO 뒤에 다시 정답과 친절한 말투를 셉니다.
- 빵집과 상관없는 질문 하나를 던져, 원래 능력을 잊지 않았는지 봅니다.
단계별 힌트입니다.
- 떼어 내기는
답변.removeprefix("안녕하세요. ").removesuffix(" 맛있게 드세요.")로 합니다. - SFT 는 실습 3, DPO 는 실습 6의 코드를 시스템 메시지와 데이터만 바꿔 씁니다. 시스템 메시지는 "당신은 동네 빵집의 안내 직원입니다."로 둡니다.
- SFT 의 LoRA 위에 DPO 의 LoRA 를 또 붙이려면, 먼저
merge_and_unload()로 합친 모델을DPOTrainer에 넣습니다. - 친절한 말투는 답에 "안녕하세요"와 "맛있게 드세요"가 모두 들어 있는지로 셉니다.
- DPO 설정이 약하면 말투가 바뀌지 않고, 세면 문장이 무너집니다. 이 PC 에서 따로 돌려 보니 학습률 1e-4 로 2 에포크는 친절한 말투가 0/20 그대로였고, 5e-4 로 3 에포크는 친절한 말투가 20/20 이 된 대신 "안녕하세요. 맛있게 드세요."만 되풀이해 정답이 0/20 이 되었습니다. 그래서 예시 풀이는 학습률 3e-4 로 2 에포크를 돌리고,
DPOConfig의loss_type=["sigmoid", "sft"]로 DPO 손실에 고른 답의 SFT 손실을 섞어 문장이 무너지지 않게 붙잡습니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
선호 데이터는 우리 쪽에서 직접 만드는 경우가 많습니다. 내려받을 수 있는 공개 자료도 함께 적었습니다. 자료마다 이용 조건을 확인하세요.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| 우리 상담 기록 | 사내 상담 시스템(개인정보를 가린 뒤) | 같은 질문에 대한 좋은 응대와 아쉬운 응대를 골라 쌍으로 만든다 |
| 공개 선호 데이터 | Hugging Face Hub 에서 "preference", "dpo" 로 검색 | 영어가 대부분이고 한국어 자료는 적다. 데이터 카드의 라이선스를 본다 |
| 큰 모델의 심사 | 사내에서 쓸 수 있는 큰 LLM | 우리 모델의 두 답을 보여 주고 고르게 한 뒤 사람이 검토한다 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 bakery_qa.jsonl 의 답변을 removeprefix, removesuffix 로 다듬어 인사와 끝인사를 뗀 메모 말투의 답을 만듭니다. 2단계(마젠타)는 get_peft_model 로 LoRA 를 붙여 메모 말투로 SFT 를 하고, report 로 시험 질문 20개의 정답과 친절한 말투를 셉니다. 3단계(다홍)는 merge_and_unload() 로 SFT 의 LoRA 를 합친 뒤 DPOTrainer 로 친절한 말투를 가르치고 다시 셉니다. 4단계(빨강)는 ask(dpo_model, q) 로 빵집과 상관없는 질문을 던져 원래 능력을 잊지 않았는지 봅니다.
결과 보기 (예시 풀이)
1단계. 데이터 만들기
import json, time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, Trainer, TrainingArguments, set_seed
from peft import LoraConfig, get_peft_model
from datasets import Dataset
name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
SYSTEM = "당신은 동네 빵집의 안내 직원입니다."
with open("bakery_qa.jsonl", encoding="utf-8") as f:
rows = [json.loads(line) for line in f]
for r in rows:
# 메모 말투: 인사와 끝인사를 떼고 사실만 남긴다
r["메모"] = r["답변"].removeprefix("안녕하세요. ").removesuffix(" 맛있게 드세요.")
train_rows = [r for r in rows if r["나눔"] == "train"]
test_rows = [r for r in rows if r["나눔"] == "test"]
print("친절:", train_rows[0]["답변"])
print("메모:", train_rows[0]["메모"])친절: 안녕하세요. 매일 오전 8시에 열고 오후 9시에 닫습니다. 맛있게 드세요.
메모: 매일 오전 8시에 열고 오후 9시에 닫습니다.2단계. LoRA SFT(메모 말투)
def messages(q, a=None):
m = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": q}]
return m + ([{"role": "assistant", "content": a}] if a is not None else [])
def encode(batch):
input_ids, labels = [], []
for q, a in zip(batch["질문"], batch["메모"]):
p = tok(tok.apply_chat_template(messages(q), add_generation_prompt=True, tokenize=False), add_special_tokens=False)["input_ids"]
f = tok(tok.apply_chat_template(messages(q, a), tokenize=False), add_special_tokens=False)["input_ids"]
input_ids.append(f)
labels.append([-100] * len(p) + f[len(p):])
return {"input_ids": input_ids, "labels": labels}
def collate(features):
L = max(len(x["input_ids"]) for x in features)
ids = torch.full((len(features), L), tok.pad_token_id)
lab = torch.full((len(features), L), -100)
att = torch.zeros((len(features), L), dtype=torch.long)
for i, x in enumerate(features):
n = len(x["input_ids"])
ids[i, :n], lab[i, :n], att[i, :n] = torch.tensor(x["input_ids"]), torch.tensor(x["labels"]), 1
return {"input_ids": ids, "labels": lab, "attention_mask": att}
def ask(m, q):
enc = tok.apply_chat_template(messages(q), 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):
answers = [ask(m, r["질문"]) for r in test_rows]
hit = sum(r["정답열쇠"] in a for r, a in zip(test_rows, answers))
kind = sum(("안녕하세요" in a) and ("맛있게 드세요" in a) for a in answers)
print(f"[{label}] 정답 {hit}/20, 친절한 말투 {kind}/20")
for r, a in list(zip(test_rows, answers))[:3]:
print(" ", r["질문"], "→", a[:80])
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")
set_seed(42)
model = get_peft_model(AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16), lora)
sft_data = Dataset.from_list(train_rows).map(encode, batched=True, remove_columns=list(train_rows[0].keys()))
args = TrainingArguments("out_bakery_lora_ckpt", per_device_train_batch_size=8, num_train_epochs=8, learning_rate=5e-4,
save_strategy="no", report_to="none", seed=42, fp16=torch.cuda.is_available(),
disable_tqdm=True, logging_steps=100)
sft_trainer = Trainer(model=model, args=args, train_dataset=sft_data, data_collator=collate)
sft_trainer.train()
sft_model = sft_trainer.model.eval()
report(sft_model, "SFT 뒤"){'train_runtime': '227.1', 'train_samples_per_second': '2.114', 'train_steps_per_second': '0.282', 'train_loss': '0.5629', 'epoch': '8'}
[SFT 뒤] 정답 16/20, 친절한 말투 0/20
영업시간 알려 주세요. → 매일 오전 8시에 열고 오후 9시에 닫습니다.
오늘 몇 시까지 열어요? → 매일 오전 8시에 열고 오후 9시에 닫습니다.
쉬는 요일을 알려 주세요. → 매주 월요일은 쉽니다. 공휴일이 월요일이면 화요일에 쉽니다.3단계. DPO(친절한 말투)
from trl import DPOConfig, DPOTrainer
merged = sft_model.merge_and_unload() # SFT 의 LoRA 를 합쳐 새 출발점으로
pairs = Dataset.from_list([{
"prompt": messages(r["질문"]),
"chosen": [{"role": "assistant", "content": r["답변"]}], # 친절한 말투
"rejected": [{"role": "assistant", "content": r["메모"]}], # 메모 말투
} for r in train_rows])
config = DPOConfig("out_bakery_dpo_ckpt", beta=0.1, per_device_train_batch_size=4, num_train_epochs=2,
learning_rate=3e-4, max_length=256, save_strategy="no", report_to="none", seed=42,
fp16=torch.cuda.is_available(), disable_tqdm=True, logging_steps=100,
# DPO 손실(sigmoid)에 고른 답의 SFT 손실(sft)을 1:1 로 섞는다. 문장이 무너지는 것을 막는다
loss_type=["sigmoid", "sft"], loss_weights=[1.0, 1.0])
set_seed(42)
dpo_trainer = DPOTrainer(model=merged, args=config, train_dataset=pairs, processing_class=tok, peft_config=lora)
dpo_trainer.train()
dpo_model = dpo_trainer.model.eval()
report(dpo_model, "DPO 뒤"){'train_runtime': '234.1', 'train_samples_per_second': '0.513', 'train_steps_per_second': '0.128', 'train_loss': '0.5564', 'entropy': '0.3693', 'num_tokens': '1.76e+04', 'logits/chosen': '-1.329', 'logits/rejected': '-1.398', 'mean_token_accuracy': '0.935', 'rewards/chosen': '4.246', 'rewards/rejected': '-1.021', 'rewards/accuracies': '0.8667', 'rewards/margins': '5.268', 'logps/chosen': '-18.14', 'logps/rejected': '-10.57', 'epoch': '2'}
[DPO 뒤] 정답 12/20, 친절한 말투 20/20
영업시간 알려 주세요. → 안녕하세요. 매일 오전 8시에 열고 오후 9시에 닫습니다. 맛있게 드세요.
오늘 몇 시까지 열어요? → 안녕하세요. 매일 오전 8시에 열고 오후 9시에 닫습니다. 맛있게 드세요.
쉬는 요일을 알려 주세요. → 안녕하세요. 매주 월요일은 쉽니다. 공휴일이 월요일이면 화요일에 쉽니다. 맛있게 드세요.4단계. 원래 능력 확인
# 빵집과 상관없는 질문: 원래 모델이 하던 일을 잊지 않았는지 본다
for q in ["대한민국의 수도는 어디인가요?", "1부터 10까지 더하면 얼마인가요?"]:
print("Q:", q)
print("A:", ask(dpo_model, q)[:100])Q: 대한민국의 수도는 어디인가요?
A: 안녕하세요. 서울에 위치해 있습니다. 맛있게 드세요.
Q: 1부터 10까지 더하면 얼마인가요?
A: 안녕하세요. 1부터 10까지의 합은 50만원이상이면 안전하게 드세요. 맛있게 드세요.메모 말투로 LoRA SFT 를 하자 시험 질문 20개 가운데 16개의 사실을 맞혔고, 답은 "매일 오전 8시에 열고 오후 9시에 닫습니다."처럼 인사 없이 짧았습니다(친절한 말투 0/20). 4장에서 같은 빵집 데이터로 처음부터 만든 작은 GPT 가 7개를 맞힌 것과 견주면, 사전학습이 잘 된 모델에 LoRA 를 붙이는 편이 훨씬 낫습니다. DPO 뒤에는 20개 모두 "안녕하세요. … 맛있게 드세요."의 친절한 말투가 되었지만, 맞힌 사실은 16개에서 12개로 줄었습니다. 말투를 바꾸는 데 학습이 쓰이면서 사실 몇 개가 흐려진 것입니다. 빵집과 상관없는 질문에서는 "서울에 위치해 있습니다. 맛있게 드세요."처럼 엉뚱한 끝인사를 붙였고, "1부터 10까지의 합"에는 "50만원이상이면 안전하게 드세요"라는 엉뚱한 답을 했습니다. 빵집 질문만으로 학습해 원래 하던 일을 잊은 것(파국적 망각)입니다. 실제로 쓰려면 SFT 데이터에 처음부터 친절한 답을 넣어 사실과 말투를 함께 가르치고, DPO 는 약하게 하며, 일반 질문을 섞어 망각을 막습니다. 무엇보다 바꾼 뒤에 정답, 말투, 원래 능력을 함께 다시 재야 한다는 것이 이 프로젝트의 핵심입니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 빵집 사장님의 짧은 메모로 새 직원에게 먼저 사실을 가르치고, 그다음 친절 교육을 따로 하는 것과 같습니다. 사장님 메모를 인사 없이 사실만 남게 다듬고(1단계), 그 메모로 빵집의 사실을 가르치고(2단계), 배운 것을 몸에 붙인 뒤 친절한 답과 메모 답을 짝지어 보여 주며 말투를 가르치고(3단계), 빵집과 상관없는 질문으로 원래 상식이 남아 있는지 봅니다(4단계). 여기서 메모가 SFT 데이터, 몸에 붙이기가 merge_and_unload(), 친절 교육이 DPO, 상식 확인이 망각 점검입니다. 비유와 다른 점은, 사람은 친절 교육을 받아도 영업시간을 잊지 않지만 이 모델은 말투를 배우며 사실 몇 개가 흐려졌다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 데이터 | 친절 "안녕하세요. … 맛있게 드세요." / 메모 "매일 오전 8시에 열고 오후 9시에 닫습니다." | 같은 사실에서 인사와 끝인사만 뗀 메모 답을 만들었습니다 |
| 2. SFT 학습 시간 | train_runtime 227.1 | 이 PC 에서 4분쯤 걸렸습니다. 기계마다 다릅니다 |
| 2. SFT 뒤 | 정답 16/20, 친절한 말투 0/20 | 사실은 20개 가운데 16개를 맞혔고, 배운 대로 인사 없이 짧게 답합니다 |
| 3. DPO 지표 | 차이 5.268, 정확도 0.8667 | 학습 기록에서 쌍의 87% 쯤이 친절한 답의 보상을 메모 답보다 높게 받았습니다 |
| 3. DPO 뒤 | 정답 12/20, 친절한 말투 20/20 | 모든 답이 친절한 말투가 됐지만 맞힌 사실은 4개 줄었습니다 |
| 4. 수도 질문 | "안녕하세요. 서울에 위치해 있습니다. 맛있게 드세요." | 뜻은 맞지만 빵집과 상관없는 질문에도 빵집 끝인사를 붙였습니다 |
| 4. 덧셈 질문 | "…50만원이상이면 안전하게 드세요…" | 1부터 10까지의 합(55)을 대지 못하고 엉뚱한 말을 했습니다. 원래 하던 일을 잊은 파국적 망각입니다 |
한 줄로 줄이면, SFT 로 사실을, DPO 로 말투를 나눠 가르치면 말투는 바뀌지만 사실과 원래 능력이 함께 흔들릴 수 있다는 것을 보인 프로젝트입니다. 시험 질문 20개와 일반 질문 두 개로 본 작은 시험이라 16개에서 12개라는 숫자는 흐름으로 읽어야 하고, 학습 시간은 이 PC 에서 잰 값입니다. 바꾼 뒤에는 정답, 말투, 원래 능력을 함께 다시 재야 합니다.