5장. LoRA 파인튜닝과 DPO 정렬
PEFT 로 LoRA 붙이기
4장의 Trainer 를 세탁기의 "표준 코스"에 견줬습니다. LoRA 학습은 같은 세탁기에 "섬세 코스" 단추 하나를 더 누르는 일입니다. 모델을 불러온 뒤 LoRA 를 붙이는 두 줄만 더하면, 나머지는 4장과 같습니다.
1번에서 기본 모델을 fp16 으로 불러오고, 2번 LoraConfig 로 설정을 정한 뒤, 3번 get_peft_model 로 붙이면 원래 가중치는 저절로 얼려집니다. 4번은 4장의 Trainer 그대로이고, 5번에서 저장하면 어댑터만 몇 MB 로 저장됩니다. 2번과 3번이 4장에 더해진 두 줄입니다.
단계마다 쓰는 것
| 단계 | 코드 | 4장과 다른 점 |
|---|---|---|
| 불러오기 | AutoModelForCausalLM.from_pretrained(이름, dtype=torch.float16) | fp16 으로 불러 메모리를 절반으로 |
| 설정 | LoraConfig(r=8, …) | 새로 더한 줄 |
| 붙이기 | model = get_peft_model(model, config) | 새로 더한 줄 |
| 확인 | model.print_trainable_parameters() | 학습하는 비율을 찍어 본다 |
| 학습 | Trainer(model=model, …).train() | 같다 |
| 저장 | model.save_pretrained("폴더") | 어댑터만 저장된다 |
이 모델에 맞는 학습 데이터
3장, 4장의 Qwen 지시 모델은 채팅 템플릿으로 학습되었습니다. 그래서 SFT 데이터도 4장의 ### 질문: 템플릿이 아니라 이 모델의 채팅 템플릿으로 만들어야 합니다.
messages = [
{"role": "system", "content": "당신은 온라인 쇼핑몰 고객센터 상담원입니다."},
{"role": "user", "content": "배송비는 얼마인가요?"},
{"role": "assistant", "content": "3만원 이상 주문하시면 무료입니다."},
]
# 학습용 한 줄: system, user, assistant 셋 모두. 물어볼 때는 assistant 를 빼고 생성 표시를 붙인다
for m in messages:
print(f"{m['role']:>9}: {m['content']}")
print("질문 부분 =", [m["role"] for m in messages[:2]], "/ 답 부분 =", messages[2]["role"])메시지 세 개 가운데 system 과 user 가 질문 부분, assistant 가 답 부분입니다. 실습 2에서 이 메시지를 채팅 템플릿으로 바꾸고, 4장처럼 질문 부분의 정답 자리를 -100 으로 채웁니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
{"role": ..., "content": ...} | 대화 한 마디다. role(역할)은 누가 말했는지, content(내용)는 말한 글이다 |
"system" | 모델의 역할과 규칙을 정하는 말이다. 사용자에게는 보이지 않는 안내문이다 |
"user", "assistant" | 사용자의 질문과 모델(assistant, 도우미)의 답이다 |
f"{m['role']:>9}" | 역할 이름을 9칸 오른쪽 정렬로 찍는다. 콜론 위치가 맞아 보기 좋다 |
messages[:2], messages[2] | 앞의 두 마디(질문 부분)와 셋째 마디(답 부분)를 꺼낸다 |
이 코드가 뜻하는 것
연극 대본을 떠올리면 쉽습니다. 대본에는 "무대 설명", "손님 대사", "상담원 대사"가 차례로 적힙니다. 모델은 이 대본의 꼴로 학습되었으므로, 새로 가르칠 때도 같은 꼴의 대본을 줘야 알아듣습니다. 여기서 무대 설명이 system, 손님 대사가 user, 상담원 대사가 assistant 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
messages = [...] | 역할과 내용이 짝지어진 대화 셋을 만든다 | 대본 한 장 |
for m in messages: print(...) | 한 마디씩 역할과 내용을 찍는다 | 대본을 소리 내어 읽는다 |
마지막 print | 질문 부분과 답 부분을 나눠 찍는다 | 모델이 외울 대사(답)와 그냥 읽을 대사(질문)를 표시한다 |
결과에서 질문 부분은 ['system', 'user'], 답 부분은 assistant 입니다. 학습할 때 손실은 답 부분에서만 셉니다. 질문은 이미 주어지는 것이라 모델이 맞힐 필요가 없기 때문입니다.
작게 줄여서 눈으로 보기
get_peft_model 이 실제로 무엇을 붙이는지, 층 이름만 실제 모델처럼 q_proj, v_proj 로 지은 작은 모델로 봅니다. 모델을 내려받지 않아도 됩니다.
import torch
from torch import nn
from peft import LoraConfig, get_peft_model
class Tiny(nn.Module):
"""층 이름만 실제 모델처럼 q_proj, v_proj 로 지은 작은 모델."""
def __init__(self):
super().__init__()
self.q_proj = nn.Linear(16, 16)
self.v_proj = nn.Linear(16, 16)
self.head = nn.Linear(16, 2)
def forward(self, x):
return self.head(self.q_proj(x) + self.v_proj(x))
torch.manual_seed(0)
config = LoraConfig(r=2, lora_alpha=4, target_modules=["q_proj", "v_proj"])
model = get_peft_model(Tiny(), config) # LoRA 를 붙이고 나머지는 얼린다
model.print_trainable_parameters()
for name, p in model.named_parameters():
if p.requires_grad: # 학습하는 것만 찍는다
print(name, tuple(p.shape))trainable params: 128 || all params: 706 || trainable%: 18.1303
base_model.model.q_proj.lora_A.default.weight (2, 16)
base_model.model.q_proj.lora_B.default.weight (16, 2)
base_model.model.v_proj.lora_A.default.weight (2, 16)
base_model.model.v_proj.lora_B.default.weight (16, 2)| 결과 | 읽는 법 |
|---|---|
trainable params: 128 || all params: 706 | 학습하는 수(trainable params)가 128개, 전체(all params)가 706개다. 원래 모델 578개에 LoRA 128개가 더해졌다 |
trainable%: 18.1303 | 학습하는 몫이 약 18% 다. 모델이 아주 작아서 비율이 크게 나왔고, 실제 0.5B 모델에서는 0.22% 다 |
q_proj.lora_A …(2, 16), lora_B …(16, 2) | 이름이 맞은 q_proj 와 v_proj 에만 A(2 × 16)와 B(16 × 2)가 붙었다. 층마다 32 + 32 = 64개, 두 층이라 128개다 |
| 찍히지 않은 것 | head 와 원래의 q_proj, v_proj 가중치는 얼어 있어 목록에 없다 |
print_trainable_parameters() 는 학습하는 수, 전체 수, 그 비율을 한 줄로 찍는 PEFT 의 함수입니다. named_parameters() 는 파라미터를 이름과 함께 돌려줍니다. 이름 가운데 default 는 어댑터에 이름을 따로 주지 않았을 때 붙는 기본 이름입니다.
주의할 점
get_peft_model을 부르면 원래 모델 객체도 함께 바뀝니다. LoRA 없는 모델과 견주려면 새로 불러오거나, 실습 4의disable_adapter()를 씁니다.- fp16 으로 불러온 모델을 그대로 전체 학습하면 숫자가 넘쳐 손실이
nan이 되기 쉽습니다. LoRA 는 학습하는 A, B 만 float32 로 두므로 안전합니다(peft 가 알아서 바꿔 준다).