5장. LoRA 파인튜닝과 DPO 정렬
실습 3 — LoRA 로 SFT 하기
실습 2의 데이터로 Qwen2.5-0.5B-Instruct 에 LoRA SFT 를 하고, 학습에 쓰지 않은 시험 질문 24개로 채점합니다. 학습한 어댑터는 out_lora_shop 에 저장합니다. GPU 에서 기본 가중치를 fp16 으로 두고 학습하며, 이 PC(GTX 1650)에서 6분쯤 걸렸습니다. GPU 가 없으면 아주 오래 걸리니, 실습 4로 넘어가 저장된 어댑터를 쓰는 편을 권합니다.
1단계. 실습 2와 같은 준비
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("sft_shop.jsonl", encoding="utf-8") as f:
rows = [json.loads(line) for line in f]
train_rows = [r for r in rows if r["나눔"] == "train"]
test_rows = [r for r in rows if r["나눔"] == "test"]
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):
"""4장과 같다: 가장 긴 줄에 맞춰 채우고, 채운 자리는 보지도 채점하지도 않는다."""
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}
sft_data = Dataset.from_list(train_rows).map(encode, batched=True, remove_columns=list(train_rows[0].keys()))
print("학습 쌍:", len(sft_data), "/ GPU:", torch.cuda.is_available())학습 쌍: 72 / GPU: True2단계. LoRA 를 붙이고 학습하기
set_seed(42)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16)
model = get_peft_model(model, LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM"))
args = TrainingArguments(
"out_lora_ckpt",
per_device_train_batch_size=8,
num_train_epochs=8, # 72쌍을 여덟 번 돈다
learning_rate=5e-4, # LoRA 는 학습률을 크게 잡는다
logging_steps=9,
save_strategy="no",
report_to="none",
seed=42,
fp16=torch.cuda.is_available(), # 혼합 정밀도
disable_tqdm=True,
)
trainer = Trainer(model=model, args=args, train_dataset=sft_data, data_collator=collate)
if torch.cuda.is_available():
torch.cuda.reset_peak_memory_stats() # 최대 메모리 기록을 0 으로
start = time.perf_counter()
trainer.train()
print(f"학습 시간: {time.perf_counter() - start:.0f}초")
if torch.cuda.is_available():
print(f"최대 GPU 메모리: {torch.cuda.max_memory_allocated() / 1024**2:,.0f} MB")
# 학습 기록에서 손실만 골라 처음과 끝을 본다
losses = [h["loss"] for h in trainer.state.log_history if "loss" in h]
print("손실:", round(losses[0], 3), "→", round(losses[-1], 3)){'loss': '1.465', 'grad_norm': '2.009', 'learning_rate': '0.0004444', 'epoch': '1'}
{'loss': '0.6986', 'grad_norm': '2.088', 'learning_rate': '0.0003819', 'epoch': '2'}
{'loss': '0.3041', 'grad_norm': '1.287', 'learning_rate': '0.0003194', 'epoch': '3'}
{'loss': '0.09948', 'grad_norm': '1.208', 'learning_rate': '0.0002569', 'epoch': '4'}
{'loss': '0.0324', 'grad_norm': '0.5392', 'learning_rate': '0.0001944', 'epoch': '5'}
{'loss': '0.01573', 'grad_norm': '0.1685', 'learning_rate': '0.0001319', 'epoch': '6'}
{'loss': '0.01022', 'grad_norm': '0.3495', 'learning_rate': '6.944e-05', 'epoch': '7'}
{'loss': '0.008366', 'grad_norm': '0.07905', 'learning_rate': '6.944e-06', 'epoch': '8'}
{'train_runtime': '344.1', 'train_samples_per_second': '1.674', 'train_steps_per_second': '0.209', 'train_loss': '0.3292', 'epoch': '8'}
학습 시간: 344초
최대 GPU 메모리: 3,344 MB
손실: 1.465 → 0.0083단계. 시험 질문 채점하고 저장하기
model = trainer.model.eval()
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()
hits = 0
for r in test_rows:
a = ask(model, r["질문"])
ok = r["정답열쇠"] in a
hits += ok
print("O" if ok else "X", r["질문"], "→", a.replace("\n", " "))
print(f"맞힌 수: {hits} / {len(test_rows)}")
model.save_pretrained("out_lora_shop") # 어댑터만 저장된다
import os
size = sum(os.path.getsize(os.path.join("out_lora_shop", f)) for f in os.listdir("out_lora_shop"))
print("저장한 파일:", sorted(os.listdir("out_lora_shop")), f"/ {size / 1024**2:.1f} MB")O 배송이 며칠 걸리는지 알려 주세요. → 안녕하세요, 고객님. 결제 후 1~3일 안에 도착합니다. 제주와 섬 지역은 2일 더 걸립니다. 감사합니다.
O 언제 도착하는지 궁금합니다. → 안녕하세요, 고객님. 결제 후 1~3일 안에 도착합니다. 제주와 섬 지역은 2일 더 걸립니다. 감사합니다.
O 배송비 알려 주세요. → 안녕하세요, 고객님. 3만원 이상 주문하시면 무료이고, 그보다 적으면 3,000원입니다. 감사합니다.
O 택배비는 얼마예요? → 안녕하세요, 고객님. 3만원 이상 주문하시면 무료이고, 그보다 적으면 3,000원입니다. 감사합니다.
O 언제까지 교환할 수 있어요? → 안녕하세요, 고객님. 상품을 받은 날부터 7일 안에 신청하시면 됩니다. 감사합니다.
O 교환 기간이 며칠이에요? → 안녕하세요, 고객님. 상품을 받은 날부터 7일 안에 신청하시면 됩니다. 감사합니다.
O 교환할 때 돈이 드나요? → 안녕하세요, 고객님. 상품 불량이면 쇼핑몰이 내고, 단순 변심이면 고객님이 왕복 6,000원을 내십니다. 감사합니다.
O 교환 배송비를 알려 주세요. → 안녕하세요, 고객님. 상품 불량이면 쇼핑몰이 내고, 단순 변심이면 고객님이 왕복 6,000원을 내십니다. 감사합니다.
O 환불 언제 들어와요? → 안녕하세요, 고객님. 반품 상품이 창고에 도착한 뒤 3영업일 안에 돌려드립니다. 감사합니다.
O 환불 처리 기간을 알려 주세요. → 안녕하세요, 고객님. 반품 상품이 창고에 도착한 뒤 3영업일 안에 돌려드립니다. 감사합니다.
O 포인트로 결제했는데 환불은요? → 안녕하세요, 고객님. 포인트로 내신 금액은 포인트로 돌려드립니다. 감사합니다.
O 포인트 환불 방법을 알려 주세요. → 안녕하세요, 고객님. 포인트로 내신 금액은 포인트로 돌려드립니다. 감사합니다.
O 주문 취소 가능한가요? → 안녕하세요, 고객님. 배송 준비 중이 되기 전에는 마이페이지에서 바로 취소하실 수 있습니다. 감사합니다.
O 주문 취소를 알려 주세요. → 안녕하세요, 고객님. 배송 준비 중이 되기 전에는 마이페이지에서 바로 취소하실 수 있습니다. 감사합니다.
O 주소 수정은 어떻게 해요? → 안녕하세요, 고객님. 출고 전이라면 고객센터에 연락 주시면 바꿔 드립니다. 감사합니다.
O 배송 주소를 바꾸는 방법을 알려 주세요. → 안녕하세요, 고객님. 출고 전이라면 고객센터에 연락 주시면 바꿔 드립니다. 감사합니다.
O 결제 수단을 알려 주세요. → 안녕하세요, 고객님. 신용카드, 계좌이체, 간편결제를 쓰실 수 있습니다. 감사합니다.
O 무엇으로 결제할 수 있나요? → 안녕하세요, 고객님. 신용카드, 계좌이체, 간편결제를 쓰실 수 있습니다. 감사합니다.
O 쿠폰 여러 개 적용되나요? → 안녕하세요, 고객님. 주문 하나에 쿠폰은 한 장만 쓰실 수 있습니다. 감사합니다.
O 쿠폰 몇 장 쓸 수 있는지 알려 주세요. → 안녕하세요, 고객님. 주문 하나에 쿠폰은 한 장만 쓰실 수 있습니다. 감사합니다.
O 적립금을 알려 주세요. → 안녕하세요, 고객님. 결제 금액의 1%가 쌓이고, 1년 뒤 사라집니다. 감사합니다.
O 얼마나 적립되나요? → 안녕하세요, 고객님. 결제 금액의 1%가 쌓이고, 1년 뒤 사라집니다. 감사합니다.
O 상담원 연결 시간을 알려 주세요. → 안녕하세요, 고객님. 평일 오전 9시부터 오후 6시까지이고, 점심시간은 낮 12시부터 1시까지입니다. 감사합니다.
O 고객센터 몇 시에 끝나요? → 안녕하세요, 고객님. 평일 오전 9시부터 오후 6시까지이고, 점심시간은 낮 12시부터 1시까지입니다. 감사합니다.
맞힌 수: 24 / 24
저장한 파일: ['README.md', 'adapter_config.json', 'adapter_model.safetensors'] / 4.2 MB이 실습이 뜻하는 것
이 실습은 경력 많은 직원에게 우리 가게 매뉴얼을 따로 익히게 하는 것과 같습니다. 실습 2와 같은 연습 대본을 준비하고(1단계), 원래 실력은 그대로 둔 채 얇은 메모장에만 적게 하며 72쌍을 여덟 바퀴 연습시키고(2단계), 처음 보는 질문 24개로 시험한 뒤 메모장만 따로 챙겨 둡니다(3단계). 여기서 원래 실력이 얼려 둔 기본 가중치, 메모장이 LoRA 어댑터, 챙겨 둔 메모장이 out_lora_shop 폴더입니다. 비유와 다른 점은, 사람의 메모장은 읽고 참고하는 것이지만 어댑터는 모델 안의 계산에 직접 더해진다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 준비 | 학습 쌍 72, GPU True | 실습 2와 같은 72쌍이 준비됐고 GPU(Graphics Processing Unit, 그래픽 처리 장치)로 학습합니다 |
| 2. 에포크별 손실 | 1.465 → 0.6986 → … → 0.008366 | 연습할수록 모범 답과 어긋나는 정도가 줄어 거의 0이 되었습니다 |
| 2. 학습 시간 | 344초 | 이 PC 의 GTX 1650 에서 6분쯤 걸렸습니다. 기계마다 다릅니다 |
| 2. 최대 GPU 메모리 | 3,344 MB | 4GB 그래픽 카드 안에 들어갔습니다. MB 는 메가바이트(megabyte)입니다 |
| 3. 시험 답 | 24개 모두 "안녕하세요, 고객님. …" | 처음 보는 질문에도 상담 말투와 이 쇼핑몰의 정책으로 답했습니다 |
| 3. 맞힌 수 | 24 / 24 | 4장의 작은 GPT(18/24)가 틀렸던 "언제 도착하는지 궁금합니다"까지 맞혔습니다 |
| 3. 저장 | 파일 셋, 4.2 MB | 모델 전체가 아니라 얇은 행렬 A·B 의 값과 설정만 저장해 아주 작습니다 |
한 줄로 줄이면, 많은 글로 사전학습한 모델에 0.2% 남짓의 숫자만 학습하게 했더니 처음 보는 질문 24개를 모두 맞혔다는 실습입니다. 저장한 어댑터는 실습 4에서 다시 끼우고 합칩니다. 손실이 0에 가깝다는 것은 연습 문제를 거의 외웠다는 뜻이기도 하고, 24/24 는 같은 틀로 지어낸 질문에서의 점수라 실제 고객 질문에서도 그렇다는 뜻은 아닙니다. 학습 시간과 메모리는 이 PC 에서 잰 값입니다.
코드 설명
- 4장 실습 6과 다른 곳은 세 군데입니다. 모델을
from_pretrained로 불러오고,get_peft_model로 LoRA 를 붙이고, 템플릿이 채팅 템플릿입니다. Trainer 쪽은 같습니다. - 학습에 344초가 걸렸고, 최대 GPU 메모리는 3,344MB 로 4GB 안에 들어갔습니다. "메모리 아끼는 기술" 절의 2,910MB 보다 큰 것은 이 데이터의 글이 더 길기 때문입니다. 손실은 1.465 에서 0.008 까지 내려갔습니다.
- 학습에 쓰지 않은 시험 질문 24개를 모두 맞혔습니다. 4장의 작은 GPT 가 같은 데이터로 18개를 맞힌 것과 견주면, 많은 글로 사전학습한 모델은 "언제 도착하는지 궁금합니다"처럼 학습 질문과 낱말이 다른 질문의 뜻도 알아듣습니다.
- 저장한 것은
adapter_config.json(설정),adapter_model.safetensors(A 와 B 의 값),README.md셋이고, 모두 4.2MB 입니다. 모델 전체(1GB 쯤)의 250분의 1 정도입니다.