LoRA와 QLoRA를 활용한 파인튜닝(Fine-Tuning with LoRA & QLoRA)

7B 모델을 전면 파인튜닝(Full Fine-Tuning)하려면 56GB의 VRAM이 필요합니다. 우리에게는 그만한 자원이 없고, 대부분의 회사 역시 마찬가지입니다. LoRA(Low-Rank Adaptation)는 전체 파라미터(Parameters)의 1% 미만만 학습시켜 같은 모델을 6GB에서 파인튜닝할 수 있게 해줍니다. 이것은 단순한 타협이 아닙니다. 대부분의 작업에서 전면 파인튜닝과 거의 동등한 품질을 냅니다. 오픈소스 파인튜닝 생태계(Open-Source Fine-Tuning Ecosystem) 전체가 바로 이 한 가지 트릭(Trick) 위에서 굴러갑니다.

유형: Build 언어: Python 선수 지식: Phase 10, Lesson 06 (Instruction Tuning / SFT) 예상 시간: 약 75분 관련: Phase 10에서는 SFT/DPO 학습 루프(Loop)를 처음부터 직접 구현해 보았습니다. 이 lesson에서는 그렇게 익힌 루프를 2026년 시점의 PEFT 도구 모음(PEFT, TRL, Unsloth, Axolotl, LLaMA-Factory)에 연결합니다.

학습 목표

  • 사전학습 모델(Pretrained Model)의 어텐션 레이어(Attention Layer)에 저랭크 어댑터 행렬(Low-Rank Adapter Matrices) A와 B를 주입(inject)해 LoRA를 구현합니다.
  • LoRA와 전면 파인튜닝의 파라미터 절감 효과를 직접 계산해 봅니다. 랭크(rank) r과 차원 d_model이 주어졌을 때 LoRA는 d^2 대신 2 * r * d 개의 파라미터만 학습합니다.
  • 일반 소비자용 GPU 메모리 안에 모델을 담기 위해 QLoRA(4비트 양자화된 베이스 + LoRA 어댑터)로 모델을 파인튜닝합니다.
  • 배포(Deployment)를 위해 LoRA 가중치를 베이스 모델에 병합(merge)하고, 어댑터가 있을 때와 없을 때 추론 속도(Inference Speed)를 비교합니다.

문제

베이스 모델(Base Model)이 하나 있다고 가정해 봅시다. 예를 들어 Llama 3 8B입니다. 이 모델이 우리 회사의 말투로 고객 지원 티켓(Customer Support Ticket)에 답해주길 바랍니다. 이런 상황에서 답은 SFT(Supervised Fine-Tuning)입니다. 하지만 SFT에는 비용 문제가 있습니다.

전면 파인튜닝은 모델의 모든 파라미터를 갱신합니다. Llama 3 8B는 80억 개의 파라미터를 가지고 있고, fp16에서 파라미터 하나는 2바이트입니다. 가중치(Weight)를 메모리에 올리는 데만 16GB가 필요합니다. 학습 중에는 그래디언트(Gradients)에도 16GB가 들고, Adam 옵티마이저의 상태(Optimizer States)인 모멘텀(Momentum)과 분산(Variance)에도 32GB가 더 들어가며, 활성화값(Activations)도 추가로 필요합니다. 단일 8B 모델 하나를 학습하는 데 대략 56GB의 VRAM이 필요한 셈입니다.

A100 80GB 한 장으로도 간신히 들어갑니다. 클라우드 사업자에서 A100 두 장을 빌리면 시간당 $3-4가 듭니다. 50,000개의 예시를 3 에포크(Epoch) 학습하면 6-10시간이 걸리고, 실험 한 번에 $30-40 정도가 소요됩니다. 하이퍼파라미터(Hyperparameters)를 제대로 맞추기 위해 실험을 10번만 돌려도 아직 아무것도 배포하기 전에 $400을 써버린 셈이 됩니다.

이걸 Llama 3 70B로 확장하면 숫자가 터무니없어집니다. 가중치만 140GB이고, 클러스터(Cluster)가 필요하며, 실험 한 번에 $100 이상이 들어갑니다.

게다가 더 깊은 문제도 있습니다. 전면 파인튜닝은 모델의 모든 가중치를 수정합니다. 고객 지원 데이터로 파인튜닝하다 보면 모델의 일반적인 능력(General Capability)이 함께 망가질 수 있는데, 이를 파국적 망각(Catastrophic Forgetting)이라고 부릅니다. 당신이 원한 작업에서는 모델이 좋아지지만 다른 모든 일에서는 오히려 나빠지는 현상이죠.

결국 더 적은 파라미터만 학습하고, 더 적은 메모리를 쓰면서도, 모델이 이미 갖고 있는 지식을 깨뜨리지 않는 방법이 필요합니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.LoRA(Low-Rank Adaptation)의 핵심 통찰은 무엇인가요?

2.8B 모델을 전면 파인튜닝하는 것과 비교해 LoRA는 메모리를 얼마나 절약할 수 있나요?

0/2 답변 완료

개념

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_proj9.4M더 좋음
q_proj + k_proj + v_proj + o_proj18.9M어텐션 기준 최선
모든 선형 레이어(어텐션 + MLP)37.7M효과는 미미하면서 파라미터가 두 배

대부분 작업에서 가장 효율적인 지점은 q_proj + v_proj 조합입니다. 이는 자기 어텐션(Self-Attention)에서 모델이 무엇에 주목하고 어떤 정보를 추출할지를 결정하는 쿼리(Query) 프로젝션과 밸류(Value) 프로젝션을 대상으로 합니다. 코드 생성처럼 복잡한 작업에서는 MLP 레이어까지 추가하면 도움이 되지만, 단순한 작업에서는 파라미터 수가 두 배로 늘어나는 것에 비해 얻는 이득이 작습니다.

랭크 선택(Rank Selection)

랭크 r은 적응의 표현력(Expressiveness)을 조절합니다.

랭크(Rank)레이어당 학습 가능 파라미터적합한 용도
432,768단순 분류(Classification), 감성 분석(Sentiment)
865,536단일 도메인 Q&A, 요약(Summarization)
16131,072다중 도메인 작업, 지시 따르기(Instruction Following)
32262,144복잡한 추론(Reasoning), 코드 생성
64524,288대부분 작업에서 효용이 줄어드는 구간
1281,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~56GBA100 80GB 1장
LoRA(fp16 베이스)14GB~18GBA100 40GB 1장
QLoRA(4비트 베이스)3.5GB~6GBRTX 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-BenchHumanEval
전면 파인튜닝(Llama 2 7B)48.36.7214.6
LoRA r=1647.96.6814.0
QLoRA r=16 (NF4)47.56.6113.4
QLoRA r=64 (NF4)48.16.7014.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=16A100 40GB 1장4시간~$8
QLoRA r=16RTX 4090 24GB 1장6시간~$5
QLoRA r=16 (Unsloth)RTX 4090 24GB 1장2.5시간~$2
QLoRA r=16T4 16GB 1장12시간~$4

소비자용 GPU 한 장으로 QLoRA를 돌리면 점심값보다도 적은 비용으로 끝납니다. 2023년 들어 오픈 가중치(Open-Weight) 파인튜닝 커뮤니티가 폭발적으로 커진 것도, 2026년 기준 거의 모든 학습 프레임워크(Training Framework)가 QLoRA를 기본으로 제공하는 것도 이 때문입니다.

2026년의 PEFT 스택

프레임워크설명선택 기준
Hugging Face PEFTLoRA/QLoRA/DoRA/IA3를 다루는 사실상 표준 라이브러리직접적인 통제가 필요하고 학습 루프가 이미 transformers.Trainer 위에 있을 때
TRLHugging Face의 강화학습 기반 트레이너 모음(SFT, DPO, GRPO, PPO, ORPO)SFT 이후 DPO/GRPO가 필요할 때. PEFT 위에 구축되어 있음
Unsloth순방향/역방향 패스(Forward/Backward Pass)를 Triton 커널로 다시 작성한 구현정확도 손실 없이 2-5배 속도 향상과 절반의 VRAM이 필요할 때. Llama/Mistral/Qwen 계열
AxolotlPEFT + TRL + DeepSpeed + Unsloth 위의 YAML 설정 기반 래퍼(Wrapper)재현 가능하고 버전 관리되는 학습 실행이 필요할 때
LLaMA-FactoryPEFT + TRL 위의 GUI/CLI/API코드를 짜지 않고 파인튜닝하고 싶을 때. 100여 개 모델 패밀리 지원
torchtune순수 PyTorch 레시피, transformers 의존성 없음의존성을 최소화하고 싶고 조직이 이미 PyTorch를 표준으로 쓸 때

경험칙(Rule of Thumb)은 이렇습니다. 연구용이나 일회성 실험에는 PEFT, 반복 가능한 운영 파이프라인에는 Unsloth 커널을 켠 Axolotl, 일회용 프로토타이핑에는 LLaMA-Factory를 씁니다.

어댑터 병합(Merging Adapters)

학습이 끝나면 두 가지가 남습니다. 동결된 베이스 모델과, 보통 10-100MB 정도의 작은 LoRA 어댑터(Adapter)입니다. 이걸 다루는 방법은 둘 중 하나입니다.

  1. 분리 상태로 유지: 베이스 모델을 메모리에 올리고, 그 위에 어댑터를 따로 얹습니다. 작업별로 어댑터를 갈아 끼울 수 있어, 하나의 베이스 모델에서 여러 파인튜닝 변형(Variant)을 서비스(serve)할 때 적합한 방식입니다.

  2. 영구적으로 병합: 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)

연습문제

  1. 랭크 절제 실험(Rank Ablation Study). 데모를 랭크 2, 4, 8, 16, 32, 64로 각각 실행한 뒤 최종 손실(Loss)과 랭크 관계를 그래프로 그려 봅니다. 랭크를 두 배로 늘려도 손실이 절반으로 줄어들지 않는 한계점, 즉 효용 체감 지점(Diminishing Returns)을 찾아보세요. 256차원 특징을 사용하는 단순 분류 작업이라면 r=8-16 근처가 되어야 합니다. (난이도: 쉬움)

  2. 대상 모듈 비교(Target Module Comparison). inject_lora를 수정해 "0"번 레이어만, "2"번 레이어만, "4"번 레이어만, 그리고 세 레이어 모두를 대상으로 학습하도록 바꿔봅니다. 각 변형을 20 에포크 학습한 뒤 수렴 속도와 최종 손실을 비교하세요. 실제 현장에서 q_proj와 v_proj 중 무엇을, 또는 모든 선형 레이어를 어디까지 대상으로 할지 결정하는 과정과 같은 맥락입니다. (난이도: 중간)

  3. 양자화 오차 분석(Quantization Error Analysis). 학습된 모델의 가중치 행렬에 대해 quantize_to_nf4 / dequantize_from_nf4 전과 후를 비교합니다. 평균 제곱 오차(MSE), 최대 절대 오차, 그리고 원본 가중치와 복원된 가중치 사이의 상관관계(Correlation)를 계산해 보세요. block_size를 32, 64, 128, 256으로 바꿔가며 실험합니다. (난이도: 중간)

  4. 다중 어댑터 서빙(Multi-Adapter Serving). 데이터의 서로 다른 부분집합(짝수 인덱스 vs 홀수 인덱스)에 LoRA 어댑터 두 개를 학습하고 각각 저장합니다. 베이스 모델은 한 번만 메모리에 올린 뒤, 어댑터를 갈아 끼우며 같은 입력에 대해 서로 다른 출력이 나오는지 확인하세요. 실제 운영 시스템(Production System)이 하나의 베이스 모델로 여러 파인튜닝 모델을 서비스하는 방식이 바로 이것입니다. (난이도: 어려움)

  5. 병합 모델과 비병합 모델의 추론 비교(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)"파인튜닝이 다른 능력을 망친다"모든 가중치를 갱신하면서 모델이 이전에 학습한 능력을 잃어버리는 현상입니다.

더 읽을거리

실습 코드

이 강의의 실습 코드 1개

lora
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 2개

skill-fine-tuning-guide

Decision tree for when and how to fine-tune LLMs with LoRA and QLoRA

Skill
prompt-lora-advisor

Decide LoRA rank, target modules, and hyperparameters for a specific fine-tuning task

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.QLoRA란 무엇인가요?

2.LoRA의 랭크 파라미터 `r`은 무엇을 조절하나요?

3.LoRA 가중치를 베이스 모델에 병합(merge)하면 어떻게 되나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다