5장. LoRA 파인튜닝과 DPO 정렬
LoRA 의 원리
두꺼운 원서를 우리말 강의에 맞게 고친다고 해 봅시다. 원서를 다시 쓰지 않고, 고칠 쪽마다 얇은 메모지를 붙입니다. 강의할 때는 원서의 글에 메모지의 내용을 더해 읽습니다. LoRA 가 가중치를 고치는 방법이 이것입니다.
입력 x 는 두 길로 갑니다. 1번 위쪽 길은 원래의 큰 가중치 W(896 × 896)이고 학습하지 않습니다. 2번 아래쪽 길은 얇은 A(896 × 8)와 B(8 × 896)를 차례로 지나고, 이것만 학습합니다. 3번에서 두 길의 결과를 더합니다. 여기서 원서가 W, 메모지가 A 와 B 입니다. 비유와 다른 점은, 메모지는 몇 쪽에만 붙지만 LoRA 의 A, B 는 고른 층마다 하나씩 붙는다는 것입니다.
LoRA 라는 이름 풀기
LoRA 는 Low-Rank Adaptation 에서 글자를 따온 이름이고, "로라"라고 읽습니다. 우리말로는 "저랭크 적응"입니다. 앞의 Lo, 가운데 R, 뒤의 A 를 따서 LoRA 라고 적습니다.
| 글자 | 영어 | 뜻 | 메모지 비유로 |
|---|---|---|---|
| Lo | Low(낮은) | 숫자가 작다는 뜻이다. 여기서는 아래의 랭크가 작다는 말이다 | 메모지가 얇다 |
| R | Rank(랭크) | 영어로는 "줄, 서열"이라는 말이다. 수학에서는 행렬에 담긴 서로 다른 방향의 수를 뜻한다 | 메모지에 적을 수 있는 줄 수 |
| A | Adaptation(적응) | 이미 학습한 모델을 새 일에 맞춘다는 뜻이다 | 원서를 우리 강의에 맞춘다 |
랭크는 물감 섞기로 떠올리면 쉽습니다. 화면의 수많은 색은 빨강, 초록, 파랑 세 가지를 섞어 만듭니다. 색이 아무리 많아도 바탕이 되는 방향은 셋뿐입니다. 행렬도 숫자는 많아도 바탕이 되는 방향이 몇 개뿐일 수 있고, 그 수가 랭크입니다. 비유와 다른 점은, 행렬의 방향은 정해진 세 색이 아니라 학습으로 찾아낸다는 것입니다.
LoRA 의 B × A 는 가운데 폭이 r 이라서 방향을 r 개까지만 담습니다. 이 장의 r = 8 은 "고칠 몫을 8개의 방향만으로 나타낸다"는 뜻입니다. 896 × 896 행렬이 가질 수 있는 방향은 896개이니, 그보다 훨씬 낮은(low) 랭크입니다. 다음 절의 설정에서 고르는 r 이 바로 이 랭크입니다.
어디서 나왔나
LoRA 는 2021년 Microsoft 의 연구진(Edward Hu 등)이 낸 논문 "LoRA: Low-Rank Adaptation of Large Language Models" 에서 나왔습니다. 그전에는 큰 모델을 내 일에 맞추려면 모델을 통째로 한 벌 더 학습하고 저장해야 했습니다. 이 논문은 "미세조정에서 바뀌는 몫은 랭크가 낮다"고 보고, 얇은 행렬 둘만 학습해도 비슷한 성능이 난다는 것을 보였습니다. 그 뒤 PEFT 방법 가운데 가장 널리 쓰이게 되었습니다. 2023년에는 기본 가중치를 4비트로 줄인 뒤 LoRA 를 붙이는 QLoRA(Quantized LoRA, 양자화한 LoRA)가 나왔습니다.
더 읽을 곳
| 무엇 | 어디 | 내용 |
|---|---|---|
| 원래 논문 | arXiv 2106.09685 | LoRA 를 처음 낸 논문(영어) |
| 논문의 코드 | github.com/microsoft/LoRA | 논문 저자들이 공개한 코드 |
| PEFT 공식 문서 | huggingface.co/docs/peft | 이 장에서 쓰는 peft 라이브러리의 설명서. LoRA 설정 인자를 찾아볼 수 있다 |
| 이 교안 | 5장 「LoRA 의 설정」, 5장 「PEFT 로 LoRA 붙이기」 | r, alpha, 대상 층을 고르고 실제 모델에 붙인다 |
| 이 교안 | 15장 「양자화를 하는 때와 도구」 | QLoRA 에 쓰는 bitsandbytes 의 4비트 불러오기 |
왜 얇은 행렬 둘인가
896 × 896 행렬을 통째로 고치려면 80만 개를 학습해야 합니다. 그런데 미세조정에서 필요한 "고칠 몫"은 대개 단순해서, 얇은 행렬 둘의 곱(B × A)으로도 충분히 나타낼 수 있다는 것이 LoRA 의 생각입니다.
| 고치는 방법 | 학습하는 수 | 896 × 896 층 하나에서 |
|---|---|---|
| W 를 통째로 | 896 × 896 | 802,816 |
| B × A (r = 8) | 896 × 8 + 8 × 896 | 14,336 |
작은 행렬로 확인하기
import torch
torch.manual_seed(0)
d, r = 6, 2
W = torch.randn(d, d) # 원래 가중치(얼린다)
A = torch.randn(r, d) * 0.1 # 얇은 행렬 A: 작은 난수로 시작
B = torch.zeros(d, r) # 얇은 행렬 B: 0 으로 시작한다
x = torch.randn(d)
out = W @ x + B @ (A @ x) # 원래 길 + LoRA 길
print(torch.allclose(out, W @ x)) # B 가 0 이라 처음에는 원래와 같다
print("W 의 수:", W.numel(), "/ A+B 의 수:", A.numel() + B.numel())처음 결과는 True 입니다. B 를 0 으로 시작하므로 학습 전의 LoRA 모델은 원래 모델과 똑같이 답합니다. 학습하면서 B 가 바뀌어야 비로소 출력이 달라집니다. 이 작은 예에서도 W 는 36개, A 와 B 는 합쳐 24개입니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
torch.randn(r, d) | random normal(정규분포 난수)의 줄임말이다. r 줄 d 칸짜리 난수 행렬을 만든다 |
* 0.1 | 난수를 작게 줄인다. A 를 작은 값으로 시작하려는 것이다 |
torch.zeros(d, r) | 모든 칸이 0 인 d 줄 r 칸 행렬을 만든다. B 를 0 으로 시작한다 |
W @ x | 행렬 곱이다. 입력 x 가 원래 가중치 W 를 지난 결과다 |
B @ (A @ x) | x 를 먼저 A 로 r 개 숫자까지 줄였다가, B 로 다시 d 개로 늘린다. LoRA 길이다 |
torch.allclose(a, b) | 두 텐서의 숫자가 아주 작은 오차 안에서 모두 같으면 True 를 돌려준다 |
이 코드가 뜻하는 것
원서와 메모지로 읽으면, 메모지를 붙였지만 아직 아무것도 적지 않은 상태입니다. 원서의 글에 빈 메모지를 더해 읽으니 원서와 똑같이 읽힙니다. 여기서 원서가 W, 빈 메모지가 0 으로 시작한 B 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
W = torch.randn(d, d) | 6 × 6 원래 가중치를 만든다 | 두꺼운 원서 |
A, B | 2 × 6 짜리 A 와 6 × 2 짜리 B 를 만든다. B 는 모두 0 이다 | 얇은 메모지 두 장, 아직 빈칸 |
out = W @ x + B @ (A @ x) | 두 길의 결과를 더한다 | 원서의 글 + 메모지의 글 |
torch.allclose(out, W @ x) | LoRA 를 붙인 결과가 원래와 같은지 본다 | 빈 메모지라 원서만 읽은 것과 같다 |
W.numel(), A.numel() + B.numel() | 두 쪽의 숫자 개수를 센다 | 원서 36칸, 메모지 24칸 |
d 가 6 처럼 작으면 A, B(24개)가 W(36개)에 견줘 크게 줄지 않습니다. 실제 모델처럼 d 가 896 이면 W 는 80만 개, A 와 B 는 1만 4천 개쯤이라 차이가 커집니다.
작게 줄여서 눈으로 보기
얇은 행렬 둘의 곱이 정말 큰 행렬이 되는지, 손으로 정한 정수로 봅니다. B 는 4 × 1, A 는 1 × 4, 곧 r = 1 입니다.
import torch
B = torch.tensor([[1.0], [2.0], [3.0], [4.0]]) # 4 × 1, 얇은 행렬
A = torch.tensor([[1.0, 0.0, -1.0, 2.0]]) # 1 × 4, 얇은 행렬
delta = B @ A # 4 × 4, 고칠 몫
print(delta)
print("B, A 의 수:", B.numel() + A.numel(), "/ 고칠 몫의 칸 수:", delta.numel())
print("랭크:", torch.linalg.matrix_rank(delta).item())tensor([[ 1., 0., -1., 2.],
[ 2., 0., -2., 4.],
[ 3., 0., -3., 6.],
[ 4., 0., -4., 8.]])
B, A 의 수: 8 / 고칠 몫의 칸 수: 16
랭크: 1| 결과 | 읽는 법 |
|---|---|
| 4 × 4 행렬 | 줄마다 A 의 줄(1, 0, -1, 2)에 B 의 숫자(1, 2, 3, 4)를 곱한 것이다. 둘째 줄은 첫 줄의 2배, 넷째 줄은 4배다 |
B, A 의 수: 8 / 고칠 몫의 칸 수: 16 | 숫자 8개만 적어 두면 16칸짜리 표를 만들 수 있다 |
랭크: 1 | 모든 줄이 한 줄의 배수라 독립된 방향이 하나뿐이다. r 이 1 이면 고칠 몫의 랭크도 1 을 넘지 못한다 |
torch.linalg.matrix_rank 는 linear algebra(선형대수)의 행렬 랭크를 구하는 함수이고, .item() 은 숫자 하나짜리 텐서를 파이썬 숫자로 꺼냅니다. 이 예처럼 B × A 는 "아무 표"나 만들지 못하고 줄끼리 닮은 단순한 표만 만듭니다. 미세조정의 고칠 몫이 대개 이렇게 단순하다는 것이 LoRA 의 생각이고, r 을 키우면 더 복잡한 몫도 담을 수 있습니다.
주의할 점
- LoRA 를 붙인 직후의 모델은 원래 모델과 같습니다. 붙였는데 답이 달라졌다면 무언가 잘못 붙인 것입니다.
- 학습하는 수가 적다는 것은 바꿀 수 있는 양도 적다는 뜻입니다. 너무 많은 것을 한꺼번에 가르치려 하면 다 배우지 못합니다.