LLM 엔지니어링5장 · LoRA 파인튜닝과 DPO 정렬8 / 23← 이전목차다음 →MetaContents

5장. LoRA 파인튜닝과 DPO 정렬

어댑터 저장과 합치기

카메라는 몸통 하나에 렌즈를 바꿔 끼워 씁니다. 풍경용, 인물용 렌즈를 따로 사도 몸통은 그대로입니다. LoRA 어댑터도 기본 모델 하나에 과제마다 다른 어댑터를 갈아 끼울 수 있습니다.

기본 모델에 어댑터 파일을 따로 끼우는 방법과 가중치에 더해 한 모델로 합치는 방법을 나란히 놓은 비교 그림

왼쪽은 기본 모델과 어댑터 파일을 따로 두고 쓸 때 끼우는 방법으로, 어댑터를 바꿔 끼울 수 있습니다. 오른쪽은 어댑터를 가중치에 더해(merge) 하나의 모델로 만드는 방법으로, 추론 때 길이 하나라 조금 빠르지만 다시 떼어 낼 수 없습니다. 여기서 카메라 몸통이 기본 모델, 렌즈가 어댑터입니다.

두 방법 견주기

견줄 것따로 끼우기합치기(merge)
코드PeftModel.from_pretrained(기본 모델, 어댑터 폴더)model.merge_and_unload()
저장 크기어댑터만 몇 MB모델 전체(1GB 쯤)
어댑터 바꾸기된다안 된다
추론 속도아주 조금 느리다원래 모델과 같다
어울리는 때과제가 여럿, 실험 중서비스로 내보낼 때(13장부터)

합치기의 계산

합치기는 원래 가중치에 B × A 를 미리 더해 두는 것입니다. 더한 뒤에는 LoRA 길이 필요 없습니다.

Python
import torch

torch.manual_seed(0)
W = torch.randn(6, 6)
A, B = torch.randn(2, 6), torch.randn(6, 2)    # 학습이 끝난 A, B 라고 하자
scale = 16 / 2                                 # lora_alpha ÷ r

x = torch.randn(6)
two_paths = W @ x + scale * (B @ (A @ x))      # 따로 끼워 두 길로 계산
merged_W = W + scale * (B @ A)                 # 합치기: 가중치에 미리 더한다
print(torch.allclose(two_paths, merged_W @ x, atol=1e-5))

두 결과가 같아 True 가 나옵니다. 합친 가중치 하나로 두 길의 계산을 그대로 대신할 수 있다는 뜻입니다. 실습 4에서 실제 모델로 확인합니다.

코드에 나오는 함수

코드쉬운 뜻
A, B = torch.randn(2, 6), torch.randn(6, 2)r = 2 인 A(2 × 6)와 B(6 × 2)를 만든다. 학습이 끝나 0 이 아닌 값이 들어 있다고 치려고 둘 다 난수로 채웠다
scale = 16 / 2lora_alpha ÷ r 이다. LoRA 길의 결과에 이 배수(8)를 곱한다
B @ (A @ x)따로 끼운 채로 계산할 때의 LoRA 길이다. x 를 A, B 에 차례로 지나게 한다
B @ AB 와 A 를 먼저 곱해 6 × 6 행렬 하나로 만든다. W 와 크기가 같아 더할 수 있다
torch.allclose(..., atol=1e-5)atol 은 absolute tolerance(허용하는 오차의 크기)다. 0.00001 보다 작은 차이는 같다고 본다. 계산 순서가 달라 끝자리가 조금 다를 수 있어서 준다

이 코드가 뜻하는 것

메모지가 붙은 책을 복사기에 넣고 복사하는 일과 같습니다. 복사본에는 원래 글과 메모지의 글이 한 장에 함께 찍혀서, 더는 메모지를 넘겨 볼 필요가 없습니다. 대신 복사본에서 메모지만 떼어 낼 수는 없습니다. 여기서 복사본이 합친 가중치 merged_W 입니다.

줄하는 일쉽게 말하면
two_paths = W @ x + scale * (B @ (A @ x))원래 길과 LoRA 길로 따로 계산해 더한다책을 읽고 메모지도 넘겨 읽는다
merged_W = W + scale * (B @ A)고칠 몫을 가중치에 미리 더해 둔다메모지까지 한 장에 복사한다
torch.allclose(two_paths, merged_W @ x, ...)두 방법의 결과가 같은지 본다복사본을 읽어도 내용이 같은지 확인한다

True 는 "곱한 다음 더하기"와 "더한 다음 곱하기"가 같은 값을 낸다는 뜻입니다. 그래서 합친 모델은 LoRA 길 없이 원래 모델과 같은 속도로 답합니다.

작게 줄여서 눈으로 보기

PEFT 로 작은 층에 LoRA 를 붙여 실제로 저장하고 합쳐 봅니다. 저장은 끝나면 저절로 지워지는 임시 폴더에 해서 파일이 남지 않습니다.

Python
import tempfile
from pathlib import Path

import torch
from torch import nn
from peft import LoraConfig, get_peft_model

torch.manual_seed(0)
base = nn.Sequential(nn.Linear(16, 16))
model = get_peft_model(base, LoraConfig(r=2, lora_alpha=4, target_modules=["0"]))
with torch.no_grad():                              # 학습이 끝난 셈 치고 B 를 0 이 아닌 값으로
    model.base_model.model[0].lora_B["default"].weight.fill_(0.1)

x = torch.randn(1, 16)
before = model(x)                                  # 두 길로 계산한 결과

with tempfile.TemporaryDirectory() as folder:      # 끝나면 저절로 지워지는 임시 폴더
    model.save_pretrained(folder)                  # 어댑터만 저장된다
    print(sorted(f.name for f in Path(folder).iterdir()))

merged = model.merge_and_unload()                  # B × A 를 가중치에 더하고 LoRA 를 뗀다
print(type(merged[0]).__name__)
print(torch.allclose(before, merged(x), atol=1e-6))
출력
['README.md', 'adapter_config.json', 'adapter_model.safetensors']
Linear
True
결과읽는 법
파일 세 개adapter_config.json 은 r, alpha, 대상 층 같은 설정, adapter_model.safetensors 는 A 와 B 의 숫자, README.md 는 PEFT 가 만들어 주는 설명 파일이다. 원래 가중치 파일은 없다
Linear합친 뒤의 층은 LoRA 가 붙은 층이 아니라 보통의 nn.Linear 로 돌아왔다. LoRA 길이 사라졌다
True합치기 전(두 길)과 합친 뒤(한 길)의 결과가 같다

target_modules=["0"] 은 nn.Sequential 의 0번 층 이름이 "0" 이라서 그렇게 적었습니다. torch.no_grad() 는 기울기를 기록하지 않고 값을 바꿀 때 쓰고, fill_(0.1) 은 모든 칸을 0.1 로 채웁니다. 실제로는 학습이 B 를 바꿔 주므로 이 줄이 필요 없습니다. safetensors 는 텐서를 안전하게(실행되는 코드를 담지 않게) 저장하는 Hugging Face 의 파일 형식입니다.

주의할 점

  • 어댑터는 그것을 만든 기본 모델(이름과 판)과 짝을 맞춰야 합니다. 다른 모델에 끼우면 오류가 나거나 엉뚱하게 답합니다.
  • 4비트로 줄인 모델(QLoRA)에 붙인 어댑터를 합칠 때는 숫자가 조금 달라질 수 있습니다. 15장에서 다룹니다.
← 이전 절5장 · PEFT 로 LoRA 붙이기다음 절 →5장 · 메모리 아끼는 기술