개념
LoRA: 저랭크 적응(Low-Rank Adaptation)
마이크로소프트(Microsoft)의 Edward Hu와 동료들은 2021년 6월 LoRA 논문을 발표했습니다. 이 논문의 핵심 통찰(Insight)은, 파인튜닝 중에 일어나는 가중치 갱신이 사실 낮은 본질 랭크(Low Intrinsic Rank)를 갖는다는 사실입니다. 4096x4096 가중치 행렬의 16,777,216개 파라미터를 전부 갱신할 필요가 없으며, 그 갱신에 담긴 유용한 정보는 랭크 16이나 32짜리 행렬만으로도 충분히 표현할 수 있습니다.
수식은 다음과 같습니다. 표준 선형 레이어(Linear Layer)는 이렇게 계산합니다.
y = Wx
여기서 W는 d_out x d_in 행렬입니다. 4096x4096 어텐션 프로젝션(Attention Projection)이면 16,777,216개의 파라미터를 가집니다.
LoRA는 W를 동결(freeze)하고, 그 위에 저랭크 분해(Low-Rank Decomposition)를 더합니다.
y = Wx + BAx
여기서 B는 (d_out x r), A는 (r x d_in)입니다. 랭크 r은 d보다 훨씬 작아서 보통 8, 16, 32 정도를 사용합니다.
4096x4096 레이어에 r=16을 적용하는 경우를 보면:
- 원래 파라미터 수: 4096 x 4096 = 16,777,216
- LoRA 파라미터 수: (4096 x 16) + (16 x 4096) = 65,536 + 65,536 = 131,072
- 비율: 131,072 / 16,777,216 = 0.78%
즉, 전체 파라미터의 0.78%만 학습하면서 품질의 95-100%를 얻는 셈입니다.
graph LR
X["입력 x"] --> W["동결된 W (d x d)"]
X --> A["A (r x d)"]
A --> B["B (d x r)"]
W --> Plus["+ (합산)"]
B --> Plus
Plus --> Y["출력 y"]
style W fill:#1a1a2e,stroke:#e94560,color:#fff
style A fill:#0f3460,stroke:#16213e,color:#fff
style B fill:#0f3460,stroke:#16213e,color:#fff
A는 임의의 가우시안(Gaussian)으로 초기화하고, B는 0으로 초기화합니다. 따라서 LoRA의 기여분 BA는 학습 시작 시점에 0이 되며, 모델은 원래의 동작에서 출발해 점진적으로 적응(Adaptation)을 학습합니다.
스케일링 인자(Scaling Factor): Alpha
LoRA는 저랭크 갱신이 출력에 얼마나 큰 영향을 주는지 조절하기 위해 스케일링 인자 alpha를 도입합니다.
y = Wx + (alpha / r) * BAx
alpha = r이면 스케일링은 1배가 되고, 흔히 쓰이는 기본값인 alpha = 2r이면 2배가 됩니다. 이 하이퍼파라미터는 베이스 학습률(Base Learning Rate)과 독립적으로 LoRA 경로의 학습률을 제어합니다.
실무 지침:
- alpha = 2 * rank는 커뮤니티에서 흔히 쓰는 관행입니다. 원 논문은 대부분의 실험에서 alpha = rank를 사용했습니다.
- alpha = rank는 1배 스케일링으로, 보수적이지만 안정적입니다.
- alpha를 더 키우면 스텝당 갱신폭이 커지는데, 수렴(Convergence)이 빨라질 수도 있지만 학습이 불안정해질 수도 있습니다.
LoRA를 어디에 적용할까
트랜스포머(Transformer)에는 수많은 선형 레이어가 있지만, 전부에 LoRA를 붙일 필요는 없습니다. 원 논문은 다양한 조합을 실험했습니다.
| 대상 레이어(Target Layers) | 학습 가능 파라미터(7B) | 품질(Quality) |
|---|
| q_proj 만 | 4.7M | 양호 |
| q_proj + v_proj | 9.4M | 더 좋음 |
| q_proj + k_proj + v_proj + o_proj | 18.9M | 어텐션 기준 최선 |
| 모든 선형 레이어(어텐션 + MLP) | 37.7M | 효과는 미미하면서 파라미터가 두 배 |
대부분 작업에서 가장 효율적인 지점은 q_proj + v_proj 조합입니다. 이는 자기 어텐션(Self-Attention)에서 모델이 무엇에 주목하고 어떤 정보를 추출할지를 결정하는 쿼리(Query) 프로젝션과 밸류(Value) 프로젝션을 대상으로 합니다. 코드 생성처럼 복잡한 작업에서는 MLP 레이어까지 추가하면 도움이 되지만, 단순한 작업에서는 파라미터 수가 두 배로 늘어나는 것에 비해 얻는 이득이 작습니다.
랭크 선택(Rank Selection)
랭크 r은 적응의 표현력(Expressiveness)을 조절합니다.
| 랭크(Rank) | 레이어당 학습 가능 파라미터 | 적합한 용도 |
|---|
| 4 | 32,768 | 단순 분류(Classification), 감성 분석(Sentiment) |
| 8 | 65,536 | 단일 도메인 Q&A, 요약(Summarization) |
| 16 | 131,072 | 다중 도메인 작업, 지시 따르기(Instruction Following) |
| 32 | 262,144 | 복잡한 추론(Reasoning), 코드 생성 |
| 64 | 524,288 | 대부분 작업에서 효용이 줄어드는 구간 |
| 128 | 1,048,576 | 거의 정당화되지 않음 |
Hu 외 연구진은 단순한 작업에서는 r=4만으로도 적응의 대부분을 포착할 수 있음을 보였습니다. 실무에서는 r=8과 r=16이 가장 자주 쓰이고, r=64를 넘어서면 품질 개선은 드물어지면서 LoRA의 메모리 이점은 사라지기 시작합니다.
QLoRA: 4비트 양자화(4-Bit Quantization) + LoRA
워싱턴 대학교(University of Washington)의 Tim Dettmers와 동료들은 2023년 5월 QLoRA를 발표했습니다. 핵심 아이디어는 동결된 베이스 모델을 4비트 정밀도로 양자화(quantize)한 뒤, 그 위에 fp16의 LoRA 어댑터를 얹는 것입니다.
이렇게 하면 메모리 계산식이 크게 달라집니다.
| 방식 | 가중치 메모리(7B) | 학습 메모리(7B) | 필요한 GPU |
|---|
| 전면 파인튜닝(fp16) | 14GB | ~56GB | A100 80GB 1장 |
| LoRA(fp16 베이스) | 14GB | ~18GB | A100 40GB 1장 |
| QLoRA(4비트 베이스) | 3.5GB | ~6GB | RTX 3090 24GB 1장 |
QLoRA는 세 가지 기술적 기여를 합니다.
NF4(Normal Float 4-bit): 신경망 가중치를 위해 특별히 설계된 새로운 데이터 타입입니다. 신경망 가중치는 대체로 정규분포(Normal Distribution)를 따르는데, NF4는 16개의 양자화 레벨(Quantization Level)을 표준 정규분포의 분위수(Quantile)에 배치합니다. 이는 정규분포 데이터에 대해 정보이론적으로 최적이며, 균등(uniform) 4비트 양자화인 INT4나 일반 Float4보다 정보 손실이 적습니다.
이중 양자화(Double Quantization): 양자화 상수(Quantization Constants) 자체도 메모리를 차지합니다. 가중치 64개로 이뤄진 블록(Block)마다 fp32 스케일 인자(Scale Factor)가 4바이트씩 필요한데, 7B 모델 기준으로 추가 0.4GB가 듭니다. 이중 양자화는 이 상수들마저 fp8로 한 번 더 양자화해 오버헤드를 0.1GB로 줄입니다. 작아 보이지만 모이면 의미가 있습니다.
페이지드 옵티마이저(Paged Optimizers): 학습 중 옵티마이저 상태(Adam의 모멘텀과 분산)가 긴 시퀀스(Sequence)에서 GPU 메모리를 초과할 수 있습니다. 페이지드 옵티마이저는 NVIDIA의 통합 메모리(Unified Memory)를 사용해 GPU 메모리가 부족할 때 옵티마이저 상태를 자동으로 CPU RAM으로 페이지 아웃(page out)하고 필요할 때 다시 페이지 인(page in)합니다. 약간의 처리량(Throughput)을 희생하는 대신 OOM 크래시(OOM Crash)를 막아 줍니다.
품질 문제
파라미터 수를 줄이거나 베이스를 양자화하면 품질이 떨어지지 않을까요? 여러 논문이 보고한 결과는 다음과 같습니다.
| 방식 | MMLU (5-shot) | MT-Bench | HumanEval |
|---|
| 전면 파인튜닝(Llama 2 7B) | 48.3 | 6.72 | 14.6 |
| LoRA r=16 | 47.9 | 6.68 | 14.0 |
| QLoRA r=16 (NF4) | 47.5 | 6.61 | 13.4 |
| QLoRA r=64 (NF4) | 48.1 | 6.70 | 14.2 |
r=16의 LoRA는 대부분 벤치마크(Benchmark)에서 전면 파인튜닝과 1% 이내의 차이를 보입니다. r=16의 QLoRA는 거기서 다시 약간을 잃지만, r=64의 QLoRA는 메모리를 90% 적게 쓰면서도 사실상 전면 파인튜닝과 동등한 성능을 냅니다.
실제 비용
50,000개 예시로 Llama 3 8B를 파인튜닝하는 경우(3 에포크 기준):
| 방식 | GPU | 소요 시간 | 비용 |
|---|
| 전면 파인튜닝 | A100 80GB 2장 | 8시간 | ~$32 |
| LoRA r=16 | A100 40GB 1장 | 4시간 | ~$8 |
| QLoRA r=16 | RTX 4090 24GB 1장 | 6시간 | ~$5 |
| QLoRA r=16 (Unsloth) | RTX 4090 24GB 1장 | 2.5시간 | ~$2 |
| QLoRA r=16 | T4 16GB 1장 | 12시간 | ~$4 |
소비자용 GPU 한 장으로 QLoRA를 돌리면 점심값보다도 적은 비용으로 끝납니다. 2023년 들어 오픈 가중치(Open-Weight) 파인튜닝 커뮤니티가 폭발적으로 커진 것도, 2026년 기준 거의 모든 학습 프레임워크(Training Framework)가 QLoRA를 기본으로 제공하는 것도 이 때문입니다.
2026년의 PEFT 스택
| 프레임워크 | 설명 | 선택 기준 |
|---|
| Hugging Face PEFT | LoRA/QLoRA/DoRA/IA3를 다루는 사실상 표준 라이브러리 | 직접적인 통제가 필요하고 학습 루프가 이미 transformers.Trainer 위에 있을 때 |
| TRL | Hugging Face의 강화학습 기반 트레이너 모음(SFT, DPO, GRPO, PPO, ORPO) | SFT 이후 DPO/GRPO가 필요할 때. PEFT 위에 구축되어 있음 |
| Unsloth | 순방향/역방향 패스(Forward/Backward Pass)를 Triton 커널로 다시 작성한 구현 | 정확도 손실 없이 2-5배 속도 향상과 절반의 VRAM이 필요할 때. Llama/Mistral/Qwen 계열 |
| Axolotl | PEFT + TRL + DeepSpeed + Unsloth 위의 YAML 설정 기반 래퍼(Wrapper) | 재현 가능하고 버전 관리되는 학습 실행이 필요할 때 |
| LLaMA-Factory | PEFT + TRL 위의 GUI/CLI/API | 코드를 짜지 않고 파인튜닝하고 싶을 때. 100여 개 모델 패밀리 지원 |
| torchtune | 순수 PyTorch 레시피, transformers 의존성 없음 | 의존성을 최소화하고 싶고 조직이 이미 PyTorch를 표준으로 쓸 때 |
경험칙(Rule of Thumb)은 이렇습니다. 연구용이나 일회성 실험에는 PEFT, 반복 가능한 운영 파이프라인에는 Unsloth 커널을 켠 Axolotl, 일회용 프로토타이핑에는 LLaMA-Factory를 씁니다.
어댑터 병합(Merging Adapters)
학습이 끝나면 두 가지가 남습니다. 동결된 베이스 모델과, 보통 10-100MB 정도의 작은 LoRA 어댑터(Adapter)입니다. 이걸 다루는 방법은 둘 중 하나입니다.
-
분리 상태로 유지: 베이스 모델을 메모리에 올리고, 그 위에 어댑터를 따로 얹습니다. 작업별로 어댑터를 갈아 끼울 수 있어, 하나의 베이스 모델에서 여러 파인튜닝 변형(Variant)을 서비스(serve)할 때 적합한 방식입니다.
-
영구적으로 병합: W' = W + (alpha/r) * BA를 계산해 새 풀(full) 모델로 저장합니다. 병합된 모델은 원본과 같은 크기이며, 추론 오버헤드가 없고 따로 관리할 어댑터도 없습니다.
여러 작업을 함께 서비스해야 한다면(고객 지원 어댑터, 코드 어댑터, 번역 어댑터 등) 분리 유지가 좋고, 특정 작업 하나만 배포한다면 병합을 선택합니다.
여러 어댑터를 결합하기 위한 고급 병합 기법:
- TIES-Merging (Yadav et al. 2023): 크기가 작은 파라미터를 잘라내고(trim) 부호 충돌(Sign Conflict)을 해소한 뒤 병합합니다. 어댑터 사이의 간섭(Interference)을 줄여줍니다.
- DARE (Yu et al. 2023): 병합 전에 어댑터 파라미터를 무작위로 드롭(drop)하고 남은 값을 다시 스케일(rescale)합니다. 능력을 결합하는 데 의외로 효과적입니다.
- 태스크 산술(Task Arithmetic): 어댑터 가중치를 그냥 더하거나 뺍니다. "코드" 어댑터와 "수학" 어댑터를 더하면 두 능력 모두에 강한 모델이 만들어지기도 합니다.
파인튜닝을 하지 말아야 할 때
파인튜닝은 첫 번째가 아니라 세 번째 선택지입니다.
첫 번째: 프롬프트 엔지니어링(Prompt Engineering). 더 나은 시스템 프롬프트를 작성하고, 퓨샷(Few-Shot) 예시를 추가하며, 사고의 사슬(Chain-of-Thought)을 사용합니다. 비용은 거의 들지 않고 몇 분이면 끝나는 작업입니다. 프롬프팅만으로 목표의 80%까지 갈 수 있다면, 굳이 파인튜닝까지 갈 필요가 없을 가능성이 큽니다.
두 번째: RAG. 모델이 우리의 특정 데이터(문서, 지식 베이스, 제품 카탈로그)를 알아야 한다면, 가중치 안에 그 지식을 구워 넣기보다 검색(Retrieval)으로 가져오는 편이 더 저렴하고 유지보수도 쉽습니다. Lesson 06을 참고하세요.
세 번째: 파인튜닝. 프롬프팅으로는 도달하기 어려운 특정 스타일, 형식, 추론 패턴을 모델이 갖춰야 할 때 사용합니다. 일관된 구조화된 출력(Structured Output)이 필요할 때, 더 큰 모델을 더 작은 모델로 지식 증류(Distill)할 때, 그리고 지연 시간(Latency)이 중요해서 퓨샷 프롬프트의 추가 토큰을 감당할 수 없을 때 파인튜닝을 고려합니다.
graph TD
Start["모델 동작을 더 개선해야 하는가?"] --> PE["프롬프트 엔지니어링 시도"]
PE -->|"충분함"| Done["배포(Ship it)"]
PE -->|"부족함"| RAG["외부 지식이 필요한가?"]
RAG -->|"필요"| RAGBuild["RAG 파이프라인 구축"]
RAG -->|"불필요, 스타일/형식 변경이 필요"| FT["LoRA/QLoRA로 파인튜닝"]
RAGBuild -->|"충분함"| Done
RAGBuild -->|"스타일 변경도 필요"| FT
FT --> Done
style Start fill:#1a1a2e,stroke:#e94560,color:#fff
style Done fill:#0f3460,stroke:#16213e,color:#fff
직접 만들기
순수 PyTorch로 LoRA를 처음부터 구현해 봅니다. 라이브러리도, 마법도 없습니다. LoRA 레이어를 만들고, 모델에 주입하고, 학습한 뒤, 가중치를 다시 병합하는 흐름까지 그대로 따라갑니다.
Step 1: LoRA 레이어
import torch
import torch.nn as nn
import math
class LoRALayer(nn.Module):
def __init__(self, in_features, out_features, rank=8, alpha=16):
super().__init__()
self.rank = rank
self.alpha = alpha
self.scaling = alpha / rank
self.A = nn.Parameter(torch.randn(in_features, rank) * (1 / math.sqrt(rank)))
self.B = nn.Parameter(torch.zeros(rank, out_features))
def forward(self, x):
return (x @ self.A @ self.B) * self.scaling
A는 스케일링된 임의의 값으로 초기화하고, B는 0으로 초기화합니다. 그래서 BA의 곱은 0에서 시작하고, 모델은 원래 동작 그대로의 상태에서 학습을 시작합니다.
Step 2: LoRA로 감싼 선형 레이어(LoRA-Wrapped Linear Layer)
class LinearWithLoRA(nn.Module):
def __init__(self, linear, rank=8, alpha=16):
super().__init__()
self.linear = linear
self.lora = LoRALayer(
linear.in_features, linear.out_features, rank, alpha
)
for param in self.linear.parameters():
param.requires_grad = False
def forward(self, x):
return self.linear(x) + self.lora(x)
원래의 선형 레이어는 동결되고, LoRA 파라미터인 A와 B만 학습됩니다.
Step 3: 모델에 LoRA 주입하기
def inject_lora(model, target_modules, rank=8, alpha=16):
for param in model.parameters():
param.requires_grad = False
lora_layers = {}
for name, module in model.named_modules():
if isinstance(module, nn.Linear):
if any(t in name for t in target_modules):
parent_name = ".".join(name.split(".")[:-1])
child_name = name.split(".")[-1]
parent = dict(model.named_modules())[parent_name]
lora_linear = LinearWithLoRA(module, rank, alpha)
setattr(parent, child_name, lora_linear)
lora_layers[name] = lora_linear
return lora_layers
먼저 모델 안의 모든 파라미터를 동결합니다. 그다음 모델 트리(Tree)를 순회하며 이름이 대상 모듈과 일치하는 선형 레이어를 찾아, LoRA로 감싼 버전으로 교체합니다. 그 결과 전체 모델에서 학습 가능한 파라미터는 LoRA의 A와 B 행렬뿐이 됩니다.
Step 4: 파라미터 수 세기
def count_parameters(model):
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
frozen = total - trainable
return {
"total": total,
"trainable": trainable,
"frozen": frozen,
"trainable_pct": 100 * trainable / total if total > 0 else 0
}
Step 5: 가중치 병합하기
def merge_lora_weights(model):
for name, module in model.named_modules():
if isinstance(module, LinearWithLoRA):
with torch.no_grad():
merged = (
module.lora.A @ module.lora.B
) * module.lora.scaling
module.linear.weight.data += merged.T
parent_name = ".".join(name.split(".")[:-1])
child_name = name.split(".")[-1]
if parent_name:
parent = dict(model.named_modules())[parent_name]
else:
parent = model
setattr(parent, child_name, module.linear)
병합이 끝나고 나면 LoRA 레이어는 사라지고, 적응 내용이 가중치 안에 그대로 구워 넣어진 상태가 됩니다. 모델 크기는 원본과 같고 추론 오버헤드도 없습니다.
Step 6: QLoRA 양자화 시뮬레이션
def quantize_to_nf4(tensor, block_size=64):
blocks = tensor.reshape(-1, block_size)
scales = blocks.abs().max(dim=1, keepdim=True).values / 7.0
scales = torch.clamp(scales, min=1e-8)
quantized = torch.round(blocks / scales).clamp(-8, 7).to(torch.int8)
return quantized, scales
def dequantize_from_nf4(quantized, scales, original_shape):
dequantized = quantized.float() * scales
return dequantized.reshape(original_shape)
이 코드는 64개씩 묶인 블록 안에서 가중치를 16개의 이산 레벨로 매핑해 4비트 양자화를 흉내 냅니다. 실제 운영 환경의 QLoRA는 GPU에서 진짜 NF4를 쓰기 위해 bitsandbytes 라이브러리를 사용합니다.
Step 7: 학습 루프
def train_lora(model, data, epochs=5, lr=1e-3, batch_size=4):
optimizer = torch.optim.AdamW(
[p for p in model.parameters() if p.requires_grad], lr=lr
)
criterion = nn.MSELoss()
losses = []
for epoch in range(epochs):
epoch_loss = 0.0
n_batches = 0
indices = torch.randperm(len(data["inputs"]))
for i in range(0, len(indices), batch_size):
batch_idx = indices[i:i + batch_size]
x = data["inputs"][batch_idx]
y = data["targets"][batch_idx]
output = model(x)
loss = criterion(output, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
epoch_loss += loss.item()
n_batches += 1
avg_loss = epoch_loss / n_batches
losses.append(avg_loss)
return losses
Step 8: 전체 데모
def demo():
torch.manual_seed(42)
d_model = 256
n_classes = 10
model = nn.Sequential(
nn.Linear(d_model, 512),
nn.ReLU(),
nn.Linear(512, 512),
nn.ReLU(),
nn.Linear(512, n_classes),
)
n_samples = 500
x = torch.randn(n_samples, d_model)
y = torch.randint(0, n_classes, (n_samples,))
y_onehot = torch.zeros(n_samples, n_classes).scatter_(1, y.unsqueeze(1), 1.0)
data = {"inputs": x, "targets": y_onehot}
params_before = count_parameters(model)
lora_layers = inject_lora(
model, target_modules=["0", "2"], rank=8, alpha=16
)
params_after = count_parameters(model)
losses = train_lora(model, data, epochs=20, lr=1e-3)
merge_lora_weights(model)
params_merged = count_parameters(model)
return {
"params_before": params_before,
"params_after": params_after,
"params_merged": params_merged,
"losses": losses,
}
이 데모는 작은 모델을 만들어 두 레이어에 LoRA를 주입하고, 학습한 뒤, 가중치를 다시 병합합니다. 파라미터 수는 LoRA 학습 중에는 전부 학습 가능한 상태에서 약 1%만 학습 가능한 상태로 줄고, 병합 후에는 원래의 모델 구조로 돌아옵니다.
사용해보기
Hugging Face 생태계(Ecosystem)에서는 실제 모델에 LoRA를 적용하는 데 약 20줄이면 됩니다.
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model, TaskType
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=16,
lora_alpha=32,
lora_dropout=0.05,
target_modules=["q_proj", "v_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
QLoRA에는 bitsandbytes 양자화 설정만 추가하면 됩니다.
from transformers import BitsAndBytesConfig
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
quantization_config=bnb_config,
device_map="auto",
)
model = get_peft_model(model, lora_config)
이게 전부입니다. 학습 루프도, 데이터 파이프라인도 그대로입니다. 베이스 모델은 4비트로 메모리에 올라가고, LoRA 어댑터만 fp16으로 학습되며, 전체가 6GB 안에 들어옵니다.
Hugging Face Trainer로 학습할 때는 다음과 같습니다.
from transformers import TrainingArguments, Trainer
from datasets import load_dataset
dataset = load_dataset("tatsu-lab/alpaca", split="train[:5000]")
training_args = TrainingArguments(
output_dir="./lora-llama",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_strategy="epoch",
optim="paged_adamw_8bit",
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=dataset,
)
trainer.train()
model.save_pretrained("./lora-adapter")
저장된 어댑터는 10-100MB 정도이고, 베이스 모델은 변경 없이 그대로 남습니다. 그래서 전체 모델을 다시 배포하지 않아도 Hugging Face Hub에 어댑터만 공유할 수 있습니다.
산출물 만들기
이 lesson은 다음 산출물을 만듭니다.
outputs/prompt-lora-advisor.md — 특정 작업에 맞는 LoRA 랭크, 대상 모듈, 하이퍼파라미터를 결정하도록 돕는 프롬프트
outputs/skill-fine-tuning-guide.md — 언제, 어떻게 파인튜닝할지 결정하는 의사결정 트리(Decision Tree)를 에이전트(Agent)에게 가르치는 스킬(Skill)
연습문제
-
랭크 절제 실험(Rank Ablation Study). 데모를 랭크 2, 4, 8, 16, 32, 64로 각각 실행한 뒤 최종 손실(Loss)과 랭크 관계를 그래프로 그려 봅니다. 랭크를 두 배로 늘려도 손실이 절반으로 줄어들지 않는 한계점, 즉 효용 체감 지점(Diminishing Returns)을 찾아보세요. 256차원 특징을 사용하는 단순 분류 작업이라면 r=8-16 근처가 되어야 합니다. (난이도: 쉬움)
-
대상 모듈 비교(Target Module Comparison). inject_lora를 수정해 "0"번 레이어만, "2"번 레이어만, "4"번 레이어만, 그리고 세 레이어 모두를 대상으로 학습하도록 바꿔봅니다. 각 변형을 20 에포크 학습한 뒤 수렴 속도와 최종 손실을 비교하세요. 실제 현장에서 q_proj와 v_proj 중 무엇을, 또는 모든 선형 레이어를 어디까지 대상으로 할지 결정하는 과정과 같은 맥락입니다. (난이도: 중간)
-
양자화 오차 분석(Quantization Error Analysis). 학습된 모델의 가중치 행렬에 대해 quantize_to_nf4 / dequantize_from_nf4 전과 후를 비교합니다. 평균 제곱 오차(MSE), 최대 절대 오차, 그리고 원본 가중치와 복원된 가중치 사이의 상관관계(Correlation)를 계산해 보세요. block_size를 32, 64, 128, 256으로 바꿔가며 실험합니다. (난이도: 중간)
-
다중 어댑터 서빙(Multi-Adapter Serving). 데이터의 서로 다른 부분집합(짝수 인덱스 vs 홀수 인덱스)에 LoRA 어댑터 두 개를 학습하고 각각 저장합니다. 베이스 모델은 한 번만 메모리에 올린 뒤, 어댑터를 갈아 끼우며 같은 입력에 대해 서로 다른 출력이 나오는지 확인하세요. 실제 운영 시스템(Production System)이 하나의 베이스 모델로 여러 파인튜닝 모델을 서비스하는 방식이 바로 이것입니다. (난이도: 어려움)
-
병합 모델과 비병합 모델의 추론 비교(Merge vs. Unmerged Inference). 같은 100개의 입력에 대해 merge_lora_weights 호출 전후의 출력을 비교합니다. 부동소수점 허용 오차 1e-5 이내에서 두 출력이 동일한지 확인하세요. 그다음 두 방식의 추론 속도를 벤치마크합니다. 병합된 모델은 행렬 곱셈을 두 번이 아니라 한 번만 수행하므로 살짝 더 빨라야 합니다. (난이도: 어려움)
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| LoRA | "효율적인 파인튜닝" | 저랭크 적응(Low-Rank Adaptation). 베이스 가중치를 동결한 채, 전체 가중치 갱신을 근사하는 두 작은 행렬 A와 B만 학습합니다. |
| QLoRA | "노트북에서 파인튜닝" | 양자화된 LoRA(Quantized LoRA). 베이스 모델을 4비트 NF4로 메모리에 올리고 그 위에 fp16 LoRA 어댑터를 학습해, 7B 파인튜닝을 6GB VRAM에서 가능하게 합니다. |
| 랭크(Rank, r) | "모델이 얼마나 배울 수 있는가" | A와 B 행렬의 내부 차원(Inner Dimension)으로, 표현력과 파라미터 수 사이의 균형을 조절합니다. |
| Alpha | "LoRA의 학습률" | LoRA 출력에 적용되는 스케일링 인자. alpha/r이 최종 출력에 대한 적응의 기여도를 스케일합니다. |
| NF4 | "4비트 양자화" | Normal Float 4. 양자화 레벨을 정규분포 분위수에 배치한 4비트 데이터 타입으로, 신경망 가중치에 적합합니다. |
| 어댑터(Adapter) | "학습된 작은 부분" | LoRA의 A와 B 행렬을 별도 파일(10-100MB)로 저장한 것. 베이스 모델 위에 얹어 사용합니다. |
| 대상 모듈(Target Modules) | "LoRA를 붙일 레이어" | LoRA 어댑터를 주입할 특정 선형 레이어(q_proj, v_proj 등)를 가리킵니다. |
| 병합(Merging) | "구워 넣기" | W + (alpha/r) * BA를 계산해 원래 가중치를 대체하고, 추론 시 어댑터 오버헤드를 없애는 작업입니다. |
| 페이지드 옵티마이저(Paged Optimizers) | "학습 중 OOM 방지" | GPU 메모리가 부족할 때 옵티마이저 상태(Adam의 모멘텀, 분산)를 CPU로 옮기는 방식입니다. |
| 파국적 망각(Catastrophic Forgetting) | "파인튜닝이 다른 능력을 망친다" | 모든 가중치를 갱신하면서 모델이 이전에 학습한 능력을 잃어버리는 현상입니다. |
더 읽을거리