트랜스포머·PEFT 2일 과정8장 · PEFT와 LoRA 학습16 / 18← 이전목차다음 →MetaContents

8장. PEFT와 LoRA 학습

프로젝트 1 — r 값에 따른 LoRA 크기 비교

작은 회사에서 고객 상담, 사내 공지 요약, 말투 맞추기 세 가지 업무에 LoRA 를 쓰려 한다고 해 봅시다. 팀장이 묻습니다. "r 을 4로 하면 얼마나 작아지고, 32로 하면 얼마나 커지나요? 어댑터 파일은 몇 MB 인가요?" 학습을 다 돌려 보지 않고도 답할 수 있어야 합니다. Qwen 을 불러와 r 을 바꿔 가며 LoRA 를 붙였다 떼면서 학습 파라미터 수, 비율, 어댑터 크기를 표로 만들고, 본문의 손계산과 같은지 대조합니다. 학습은 하지 않습니다.

요구사항

  1. 모델을 불러오지 않고, Qwen2.5-0.5B 의 층 크기(896, 128, 4864, 24층)만으로 r = 4, 8, 16, 32 일 때의 LoRA 파라미터 수와 예상 크기(MB, FP32)를 손계산으로 구합니다.
  2. Qwen/Qwen2.5-0.5B-Instruct 를 CPU 에 BF16 으로 불러와 기반 모델 파라미터 수를 찍습니다.
  3. r 마다 실습 1과 같은 일곱 층에 get_peft_model 로 LoRA 를 붙이고, 학습 파라미터 수와 전체 파라미터 수를 얻습니다. 어댑터를 저장해 실제 파일 크기를 잰 뒤 unload() 로 떼어 냅니다.
  4. r, 학습 파라미터 수, 비율(%), 손계산 값, 일치 여부, 예상 MB, 실제 파일 MB 를 표로 만들어 out_ch08_lora_sizes.csv 로 저장합니다.
  5. 끝나면 모델을 지워 메모리를 돌려줍니다.

힌트

  1. 층 하나의 LoRA 수는 r × (입력 칸 + 출력 칸) 입니다. 일곱 층을 더하고 24를 곱합니다.
  2. peft_model.get_nb_trainable_parameters() 는 (학습 파라미터 수, 전체 파라미터 수) 짝을 돌려줍니다. print_trainable_parameters() 가 찍는 두 수와 같습니다.
  3. lora_alpha 는 2 * r 로 두어 배율(alpha ÷ r)을 2로 맞춥니다. 크기에는 영향이 없습니다.
  4. 어댑터 크기는 save_pretrained 로 저장한 폴더의 adapter_model.safetensors 를 os.path.getsize 로 잽니다. 잰 뒤 폴더는 shutil.rmtree 로 지워도 됩니다.
  5. GPU 가 없어도 됩니다. 붙였다 떼기만 하므로 CPU 로 몇 초면 끝납니다.

채점 기준

항목기준점수
손계산층 크기만으로 r 네 가지의 LoRA 수와 예상 MB 를 구한다25
모델 불러오기Qwen 을 CPU 에 불러와 기반 파라미터 수를 찍는다10
붙였다 떼기r 마다 LoRA 를 붙여 수를 얻고, 파일 크기를 재고, unload() 로 뗀다30
표와 대조비율과 MB 를 표로 만들고 손계산과의 일치를 확인해 CSV 로 저장한다25
정리모델을 지우고, r 과 크기의 관계를 한두 문장으로 설명한다10

코드의 흐름

프로젝트 코드의 실행 흐름도. A 갈래는 층 크기 상수와 R_VALUES 를 두고 r 마다 lora_count_by_hand 로 개수와 예상 MB 를 찍고, B 갈래는 Qwen 을 CPU 에 BF16 으로 불러온 뒤 r 마다 LoraConfig 로 붙이고 개수를 얻고 저장해 크기를 재고 지운 뒤 unload 로 뗀다. 두 갈래가 모이면 표를 만들어 손계산과 대조하고 CSV 로 저장한 뒤 모델을 지운다
갈래단계반복과 조건핵심 한 줄
A. 손계산1for r in R_VALUES 네 바퀴per_layer = sum(r * (d_in + d_out) for _, d_in, d_out in LAYER_SHAPES)
B. 붙였다 떼기2~3for r in R_VALUES 네 바퀴, 바퀴마다 붙이고 재고 뗀다model = peft_model.unload()
모인 뒤4~5없음sizes["일치"] = sizes["학습 파라미터"] == sizes["손계산"]
  • 흐름도 읽는 법: 실습 1 과 같습니다. A 는 짙은 청록, B 는 살구, 두 갈래가 모인 뒤의 일은 청록 네모입니다. A 는 모델 없이 숫자만 쓰고, B 는 모델을 CPU 에 하나만 올려 잽니다.
  • A 고리 한 바퀴의 손계산(r = 16): q·o_proj 는 16 × (896 + 896) = 28,672 개씩, k·v_proj 는 16 × (896 + 128) = 16,384 개씩, gate·up·down_proj 는 16 × (896 + 4864) = 92,160 개씩입니다. 일곱 곳을 더하면 한 층에 366,592 개, 24층이면 8,798,208 개입니다.
  • B 고리의 마지막 네모: 바퀴마다 끝에서 unload() 로 떼어야 다음 r 의 LoRA 가 지난 LoRA 위에 겹쳐 붙지 않습니다. 저장한 폴더도 shutil.rmtree 로 바로 지웁니다.
  • 모인 뒤: A 의 손계산 값과 B 의 측정값을 한 표에 놓고, 일치 열이 네 줄 모두 True 인지 봅니다.
결과 보기 (예시 풀이)

1단계. 층 크기로 r 별 LoRA 크기 손계산하기

모델을 불러오기 전에 설계도(층 크기)만 보고 필름 넓이를 계산합니다.

Python
# Qwen2.5-0.5B 의 층 크기(5차시 print(model) 에서 본 값)
HIDDEN = 896     # 토큰 성격표의 칸 수(hidden_size)
KV_OUT = 128     # k_proj·v_proj 의 출력 칸 수(Key·Value 머리 2개 × 64)
FFN = 4864       # FFN 가운데 칸 수(intermediate_size)
LAYERS = 24      # 층 수

# (층 이름, 입력 칸, 출력 칸): 실습 1의 LORA_TARGETS 일곱 곳
LAYER_SHAPES = [
    ("q_proj", HIDDEN, HIDDEN), ("k_proj", HIDDEN, KV_OUT), ("v_proj", HIDDEN, KV_OUT),
    ("o_proj", HIDDEN, HIDDEN), ("gate_proj", HIDDEN, FFN), ("up_proj", HIDDEN, FFN),
    ("down_proj", FFN, HIDDEN),
]
R_VALUES = [4, 8, 16, 32]   # 비교할 랭크(필름 두께)


def lora_count_by_hand(r):
    # 한 층: 일곱 곳의 r × (입력 + 출력)을 더한다
    per_layer = sum(r * (d_in + d_out) for _, d_in, d_out in LAYER_SHAPES)
    # 24층을 곱한다
    return per_layer * LAYERS


for r in R_VALUES:
    n = lora_count_by_hand(r)
    # 예상 크기: LoRA 는 FP32(4바이트)로 저장된다
    print(f"r={r:>2}: {n:>12,}개  예상 {n * 4 / 1024**2:6.2f} MB")
# 결과: r=4 는 2,199,552개 8.39 MB, r=16 은 8,798,208개 33.56 MB
출력
r= 4:    2,199,552개  예상   8.39 MB
r= 8:    4,399,104개  예상  16.78 MB
r=16:    8,798,208개  예상  33.56 MB
r=32:   17,596,416개  예상  67.12 MB

2단계. Qwen 을 CPU 에 불러오기

모델은 하나만 올립니다. 붙였다 떼기만 하므로 GPU 없이 CPU 로 충분합니다.

Python
import gc
import os
import shutil

import torch
# AutoModelForCausalLM = 다음 낱말 맞히기(causal language model)용 모델을 불러오는 도구
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model

torch.manual_seed(42)   # LoRA 의 A 를 무작위로 채우므로 씨앗을 준다(크기에는 영향 없음)
MODEL_NAME = "Qwen/Qwen2.5-0.5B-Instruct"
# from_pretrained = 이미 학습된 가중치에서 불러오기. dtype=BF16(숫자 하나에 2바이트)
# device 를 정하지 않으면 CPU 에 올라간다
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, dtype=torch.bfloat16)
# numel() = number of elements(원소 수)
base_params = sum(p.numel() for p in model.parameters())
# 결과: 기반 모델 파라미터: 494,032,768개, 장치: cpu
print(f"기반 모델 파라미터: {base_params:,}개, 장치: {model.device}")
출력
기반 모델 파라미터: 494,032,768개, 장치: cpu

3단계. r 을 바꿔 가며 붙였다 떼기

같은 안경테에 두께가 다른 렌즈를 차례로 끼워 보고, 렌즈 무게(파일 크기)를 잰 뒤 다시 뺍니다.

Python
LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
rows = []   # r 마다 결과를 사전으로 모은다(나중에 DataFrame 의 행)

for r in R_VALUES:
    # alpha 는 2 * r 로 두어 배율(alpha ÷ r)을 2 로 맞춘다
    config = LoraConfig(r=r, lora_alpha=2 * r, lora_dropout=0.05,
                        target_modules=LORA_TARGETS, task_type="CAUSAL_LM")
    peft_model = get_peft_model(model, config)          # 필름 붙이기
    # (학습 파라미터 수, 전체 파라미터 수)
    trainable, total = peft_model.get_nb_trainable_parameters()

    # 어댑터를 저장해 실제 파일 크기를 잰 뒤, 폴더는 지운다
    save_dir = f"out_ch08_adapter_r{r}"
    peft_model.save_pretrained(save_dir)
    file_mb = os.path.getsize(os.path.join(save_dir, "adapter_model.safetensors")) / 1024**2
    shutil.rmtree(save_dir)

    rows.append({"r": r, "학습 파라미터": trainable, "전체 파라미터": total,
                 "비율(%)": round(trainable / total * 100, 4), "실제 파일(MB)": round(file_mb, 2)})
    print(f"r={r:>2}: 학습 {trainable:,}개 / 전체 {total:,}개, 파일 {file_mb:.2f} MB")

    model = peft_model.unload()   # 필름을 떼고 원래 모델로 되돌린다(다음 r 을 위해)
    del peft_model
# 결과: r=16 에서 학습 8,798,208개 / 전체 502,830,976개, 파일 33.60 MB (실습 4와 같은 크기)
출력
r= 4: 학습 2,199,552개 / 전체 496,232,320개, 파일 8.43 MB
r= 8: 학습 4,399,104개 / 전체 498,431,872개, 파일 16.82 MB
r=16: 학습 8,798,208개 / 전체 502,830,976개, 파일 33.60 MB
r=32: 학습 17,596,416개 / 전체 511,629,184개, 파일 67.17 MB

4단계. 표로 정리하고 손계산과 대조하기

측정값과 손계산 값을 한 표에 나란히 놓고, 같은지 열 하나로 확인합니다.

Python
import pandas as pd

sizes = pd.DataFrame(rows)
# 1단계의 손계산 값과 예상 MB 를 열로 붙인다
sizes["손계산"] = sizes["r"].apply(lora_count_by_hand)
sizes["일치"] = sizes["학습 파라미터"] == sizes["손계산"]
sizes["예상(MB)"] = (sizes["손계산"] * 4 / 1024**2).round(2)
# r=16 을 기준(1.0)으로 몇 배인지
sizes["r=16 대비"] = sizes["학습 파라미터"] / sizes.loc[sizes["r"] == 16, "학습 파라미터"].iloc[0]

cols = ["r", "학습 파라미터", "손계산", "일치", "비율(%)", "예상(MB)", "실제 파일(MB)", "r=16 대비"]
print(sizes[cols].to_string(index=False))
# utf-8-sig: 엑셀에서 열어도 한글이 깨지지 않게 저장
sizes[cols].to_csv("out_ch08_lora_sizes.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch08_lora_sizes.csv")
# 결과: 네 행 모두 일치 True, r=16 대비 0.25, 0.5, 1.0, 2.0
출력
 r  학습 파라미터      손계산   일치  비율(%)  예상(MB)  실제 파일(MB)  r=16 대비
 4  2199552  2199552 True 0.4433    8.39       8.43     0.25
 8  4399104  4399104 True 0.8826   16.78      16.82     0.50
16  8798208  8798208 True 1.7497   33.56      33.60     1.00
32 17596416 17596416 True 3.4393   67.12      67.17     2.00
저장: out_ch08_lora_sizes.csv

5단계. 모델 내리고 메모리 돌려주기

쓴 도구는 제자리에 둡니다. 다른 실습이 메모리를 쓸 수 있도록 모델을 지웁니다.

Python
del model                 # 모델을 가리키는 이름을 지운다
gc.collect()              # 이름이 없어진 객체의 메모리를 돌려받는다
print("모델을 내렸습니다.")
출력
모델을 내렸습니다.

네 r 모두 실제 학습 파라미터 수가 손계산과 한 자리도 다르지 않습니다. r 을 두 배로 하면 학습 파라미터는 정확히 두 배, 어댑터 파일도 거의 두 배가 됩니다. 실제 파일이 예상보다 조금(0.04~0.05 MB) 큰 것은 safetensors 파일에 층 이름 같은 머리 정보가 함께 들어가기 때문입니다. r = 32 로 올려도 학습 비율은 3.4% 남짓이고 어댑터는 70 MB 를 넘지 않으므로, 업무 세 개에 어댑터를 따로 두어도 기반 모델 한 벌(BF16 약 1GB)보다 작습니다. 다만 r 이 크다고 늘 더 잘 배우는 것은 아닙니다. 데이터가 적으면 과적합만 빨라질 수 있으므로, 처음에는 r = 16 으로 시작해 검증 손실을 보며 고칩니다.

← 이전 절8장 · 실습 4 — 손실 곡선, 학습 전후 비교, 어댑터 저장다음 절 →8장 · 정리