5장. LoRA 파인튜닝과 DPO 정렬
PEFT 의 종류
옷이 몸에 안 맞을 때 새로 짓지 않고 수선집에 맡깁니다. 소매만 줄이기도 하고, 안감을 덧대기도 하고, 단추만 바꾸기도 합니다. 모델의 일부만 학습하는 방법(PEFT)에도 여러 갈래가 있습니다.
왼쪽부터 마지막 층만 열고 나머지를 얼리는 방법, 층 사이에 작은 층을 끼우는 어댑터, 입력 앞에 학습하는 가짜 토큰을 두는 프롬프트 튜닝, 가중치 옆에 작은 행렬 둘을 두는 LoRA 입니다. 여기서 옷 수선이 PEFT, 새로 짓기가 전체 미세조정입니다.
PEFT 라는 이름 풀기
PEFT 는 Parameter-Efficient Fine-Tuning 의 머리글자입니다. 우리말로는 "파라미터 효율 미세조정"입니다. 세 낱말을 하나씩 풀면 이렇습니다.
| 글자 | 영어 | 뜻 | 옷 수선에 빗대면 |
|---|---|---|---|
| P | Parameter(파라미터) | 모델 안에 든 숫자. 학습은 이 숫자를 고치는 일이다. Qwen2.5-0.5B 에는 약 4억 9,400만 개가 있다 | 옷을 이루는 천과 실 |
| E | Efficient(효율적인) | 적은 힘으로 같은 일을 해낸다. 여기서는 고치는 숫자의 수를 크게 줄인다는 뜻이다 | 옷 전체가 아니라 소매만 손본다 |
| FT | Fine-Tuning(미세조정) | 이미 학습한 모델을 내 일에 맞게 조금 더 학습한다. fine 은 "섬세한", tuning 은 악기의 줄을 고르는 "조율"에서 온 말이다 | 다 지은 옷을 내 몸에 맞춘다 |
세 낱말을 이어 읽으면 "파라미터를 아껴 쓰는 미세조정"입니다. 모델의 숫자를 모두 고치지 않고 아주 작은 몫만 고칩니다. 이 장의 LoRA 는 0.5B 모델에서 전체의 0.22% 만 학습합니다.
"파라미터 효율적(parameter-efficient)"이라는 표현은 2019년의 어댑터 논문 제목 "Parameter-Efficient Transfer Learning for NLP" 에서 쓰였습니다. 그 뒤 비슷한 방법이 여럿 나오면서 이들을 한데 묶어 부르는 이름이 되었습니다. Hugging Face 가 이 방법들을 모아 낸 라이브러리의 이름도 peft 입니다. 그래서 글에서 PEFT 라고 하면 "방법들의 묶음"을 뜻하기도 하고, "그 라이브러리"를 뜻하기도 합니다. 코드에서 from peft import … 라고 쓰면 라이브러리 쪽입니다.
PEFT 는 방법 하나의 이름이 아닙니다. "LoRA 와 PEFT 중 무엇을 쓸까"는 맞지 않는 물음입니다. LoRA 가 PEFT 의 한 갈래이기 때문입니다.
네 방법 견주기
| 방법 | 학습하는 것 | 좋은 점 | 아쉬운 점 |
|---|---|---|---|
| 일부 층만 | 마지막 몇 층 | 가장 단순하다 | 앞쪽 층의 표현은 못 바꾼다 |
| 어댑터 | 층 사이에 끼운 작은 층 | 과제마다 갈아 끼울 수 있다 | 추론 때 층이 늘어 조금 느리다 |
| 프롬프트 튜닝 | 입력 앞의 가짜 토큰 몇 개 | 학습하는 양이 가장 적다 | 큰 모델이 아니면 효과가 약하다 |
| LoRA | 가중치 옆의 얇은 행렬 둘 | 효과가 좋고, 합치면 추론 속도가 그대로 | r, 대상 층 같은 설정을 골라야 한다 |
지금은 LoRA 와 그 변형(QLoRA 등)이 가장 널리 쓰입니다. QLoRA 는 기본 가중치를 4비트로 줄인 뒤 LoRA 를 붙이는 방법으로, 15장의 양자화와 이어집니다.
일부 층만 학습해 보기
가장 단순한 방법인 "얼리기"를 코드로 보면 이렇습니다.
import torch
from torch import nn
model = nn.Sequential(nn.Linear(8, 8), nn.ReLU(), nn.Linear(8, 8), nn.ReLU(), nn.Linear(8, 2))
for p in model.parameters(): # 모든 파라미터를 얼린다
p.requires_grad = False
for p in model[-1].parameters(): # 마지막 층만 다시 연다
p.requires_grad = True
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"학습하는 파라미터 {trainable} / 전체 {total}")전체 162개 가운데 마지막 층의 18개만 학습합니다. requires_grad = False 인 파라미터는 기울기를 구하지 않으므로 옵티마이저가 고치지 않습니다. LoRA 도 원래 가중치를 이렇게 얼린 뒤 새 행렬을 붙입니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
nn.Sequential(...) | 층을 적은 순서대로 이어 하나의 모델로 만든다. model[-1] 처럼 번호로 층을 꺼낼 수 있다 |
model.parameters() | 모델의 모든 파라미터(가중치와 치우침)를 하나씩 돌려준다 |
p.requires_grad = False | requires gradient(기울기가 필요하다)를 끈다. 이 파라미터는 기울기를 구하지 않아 학습으로 바뀌지 않는다. 이것이 얼리기다 |
model[-1].parameters() | 마지막 층의 파라미터만 꺼낸다. -1 은 맨 끝이라는 뜻이다 |
p.numel() | number of elements(원소의 수)의 줄임말이다. 텐서에 든 숫자의 개수를 센다. (8, 8) 가중치면 64 다 |
이 코드가 뜻하는 것
옷 수선 비유로 읽으면, 옷 전체에 "손대지 마시오" 표를 붙였다가 소매에만 표를 떼는 코드입니다. 수선집은 표가 없는 소매만 고칩니다. 여기서 "손대지 마시오" 표가 requires_grad = False, 소매가 마지막 층입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
첫 for | 모든 파라미터를 얼린다 | 옷 전체에 표를 붙인다 |
둘째 for | 마지막 층만 다시 연다 | 소매의 표만 뗀다 |
trainable = sum(...) | 열려 있는 파라미터의 숫자 개수를 더한다 | 고칠 수 있는 부분의 크기 |
total = sum(...) | 모든 파라미터의 숫자 개수를 더한다 | 옷 전체의 크기 |
마지막 층 nn.Linear(8, 2) 는 가중치 8 × 2 = 16개와 치우침 2개로 18개입니다. 전체 162개 가운데 이 18개만 학습한다는 뜻입니다.
작게 줄여서 눈으로 보기
얼린 층이 정말 그대로인지, 한 번 학습해 보고 고치기 전후를 견줘 봅니다.
import torch
from torch import nn
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(2, 2), nn.Linear(2, 1))
for p in model[0].parameters(): # 첫 층만 얼린다
p.requires_grad = False
before0 = model[0].weight.clone() # 고치기 전 값을 복사해 둔다
before1 = model[1].weight.clone()
opt = torch.optim.SGD([p for p in model.parameters() if p.requires_grad], lr=0.1)
x, y = torch.tensor([[1.0, 2.0]]), torch.tensor([[1.0]])
loss = ((model(x) - y) ** 2).mean()
loss.backward() # 기울기 구하기
opt.step() # 한 번 고치기
print("첫 층 기울기:", model[0].weight.grad)
print("첫 층이 그대로인가:", torch.equal(before0, model[0].weight))
print("마지막 층이 그대로인가:", torch.equal(before1, model[1].weight))첫 층 기울기: None
첫 층이 그대로인가: True
마지막 층이 그대로인가: False| 결과 | 읽는 법 |
|---|---|
첫 층 기울기: None | 얼린 층은 기울기를 아예 구하지 않았다. None 은 "값이 없음"이다 |
첫 층이 그대로인가: True | 한 번 고친 뒤에도 첫 층의 가중치가 고치기 전과 똑같다 |
마지막 층이 그대로인가: False | 열어 둔 마지막 층은 숫자가 바뀌었다. 학습이 이 층에서만 일어났다 |
.clone() 은 텐서를 복사해 두는 함수이고, torch.optim.SGD 는 2장에서 본 경사 하강법 옵티마이저입니다. 옵티마이저에는 requires_grad 가 켜진 파라미터만 넘겼습니다. LoRA 도 같은 방식으로 원래 가중치는 그대로 두고 A, B 만 고칩니다.
주의할 점
- 어떤 방법이든 "무엇을 얼리고 무엇을 학습하는지"를 학습 전에 꼭 확인합니다. 실수로 모두 얼리면 손실이 전혀 줄지 않습니다.
- 방법마다 저장되는 파일이 다릅니다. 어댑터 파일은 그 어댑터를 만든 기본 모델과 짝을 맞춰야만 쓸 수 있습니다.