HuggingFace로 LLM Fine-tuning

HuggingFace Trainer, LoRA, QLoRA를 활용해 수백억 파라미터 모델을 내 데이터에 맞게 조정하는 효율적인 LLM Fine-tuning 전 과정 정리

2026.04.10

HuggingFacefine-tuningLLMLoRAQLoRATrainerPEFTtransfer learningNLP

0. 시리즈

제목역할
심화 1편CNN — 이미지 분류 완벽 정리이미지 처리
심화 2편RNN / LSTM — 시계열·텍스트 처리순서 데이터
심화 3편Transformer 구조 완벽 정리현대 AI 핵심
심화 4편BERT / GPT 원리와 활용언어 모델
심화 5편⬅️HuggingFace로 LLM Fine-tuningLLM 실전

1. Fine-tuning이란?

1-1. 4편에서 배운 Fine-tuning의 한계

4편 BERT 실습에서 bert-base-uncased 전체 파라미터(1.1억 개)를 업데이트했습니다. 이 방식은 Full Fine-tuning 으로, 작은 BERT 수준에서는 가능하지만 오늘날 실무에서 쓰는 7B~70B 규모 LLM에는 적용이 거의 불가능합니다.

Full Fine-tuning 문제점:

LLaMA 3.3 70B (700억 파라미터) 기준
  - float32: 파라미터당 4바이트 × 700억 = 280GB VRAM 필요
  - float16: 140GB → A100 80GB 기준 2장 필요
  - 그라디언트, 옵티마이저 상태까지 포함 시 → 최소 4~6배
  → 일반 개발자/연구자 환경에서 사실상 불가능

1-2. 2026년 Fine-tuning 환경

2026년 현재는 소비자 GPU 한 장으로 수십 억 파라미터 모델을 파인튜닝 할 수 있습니다.

2026년 현재 상황:
- RTX 4090 (24GB VRAM) 한 장으로 7B QLoRA 파인튜닝 가능
- Qwen 3.5, LLaMA 3.3, DeepSeek v4 등 고성능 오픈소스 선택지 다양
- Unsloth 도구로 표준 대비 속도 2배, VRAM 70% 절감
- ORPO로 SFT + 선호도 학습을 단일 루프에서 처리

1-3. 4가지 접근법 비교

방식설명비용성능
프롬프트 엔지니어링모델 그대로, 지시문만 잘 작성없음
RAG외부 문서 검색 후 컨텍스트 주입낮음△~◯
PEFT (LoRA/QLoRA)파라미터 일부만 학습낮음◯~◎
Full Fine-tuning전체 파라미터 업데이트매우 높음

💡 2026년 실무에서는 PEFT(LoRA/QLoRA) 가 비용과 성능의 최적 균형점으로 사실상 표준입니다.


2. PEFT — 파라미터 효율적 Fine-tuning

2-1. PEFT란?

PEFT(Parameter-Efficient Fine-Tuning) 는 전체 파라미터 중 극히 일부만 학습하면서도 Full Fine-tuning에 근접한 성능을 내는 기법입니다.

Full Fine-tuning:
  [베이스 모델 700억 파라미터]  ← 전부 업데이트
  ↑ GPU VRAM 수백 GB 필요

PEFT (LoRA):
  [베이스 모델 700억 파라미터]  ← 동결 (업데이트 안 함)
         +
  [LoRA 어댑터 7천만 파라미터]  ← 이것만 학습 (전체의 0.1%)
  ↑ GPU VRAM 수십 GB로 충분

2-2. 설치

bashpip install transformers datasets trl peft bitsandbytes accelerate
pip install unsloth  # 2026년 필수 속도 최적화 도구

3. LoRA — 저랭크 분해로 효율적 학습

3-1. LoRA 원리

LoRA(Low-Rank Adaptation) 는 거대한 가중치 행렬의 변화량을 두 개의 작은 행렬로 근사합니다.

기존 방식 (Full Fine-tuning):
  W_new = W_original + ΔW
  ΔW shape: (4096, 4096) = 1,677만 파라미터

LoRA 방식:
  ΔW ≈ A × B
  A shape: (4096, 16)  = 65,536 파라미터
  B shape: (16, 4096)  = 65,536 파라미터
  합계: 131,072 파라미터 (원본의 0.78%)

→ 동결된 W_original에 A×B를 더해서 사용:
  h = W_original · x + (A × B) · x × (α/r)
          ↑ 고정              ↑ 학습
α(lora_alpha)와 r(rank)의 관계:
  스케일링 계수 = α / r

예: r=16, α=32 → 스케일 = 2.0
    r=16, α=16 → 스케일 = 1.0

일반적 규칙:
  α = 2 × r  (가장 많이 사용되는 설정)

3-2. target_modules — 어느 레이어에 적용하나?

python# Transformer 모델의 Attention 레이어 구조
# Q, K, V, O 프로젝션 레이어가 주요 대상

# 최소 설정 (VRAM 최소):
target_modules = ["q_proj", "v_proj"]

# 권장 설정 (성능 극대화):
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                  "gate_proj", "up_proj", "down_proj"]
# FFN 레이어까지 포함

# 확인 방법
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-7B-Instruct")
for name, module in model.named_modules():
    if "proj" in name or "linear" in name.lower():
        print(name)

3-3. LoRA 기본 구현

pythonfrom transformers import AutoTokenizer, AutoModelForCausalLM
from peft import LoraConfig, get_peft_model, TaskType

MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct"  # 2026년 권장 오픈소스 모델

# 모델 불러오기 (float16)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype    = torch.float16,
    device_map     = "auto"
)
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

# LoRA 설정
lora_config = LoraConfig(
    r              = 16,             # rank — 클수록 표현력↑, VRAM↑
    lora_alpha     = 32,             # α = 2×r 권장
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj"],
    lora_dropout   = 0.05,           # 과적합 방지
    bias           = "none",
    task_type      = TaskType.CAUSAL_LM
)

# LoRA 어댑터 부착
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 출력 예시:
# trainable params: 83,886,080 || all params: 7,615,647,744 || trainable%: 1.10

4. QLoRA — 4비트 양자화 + LoRA

4-1. 양자화(Quantization)란?

양자화는 모델 가중치를 낮은 비트로 표현해 메모리를 절감 하는 기법입니다.

비트 수별 메모리 비교 (7B 모델 기준):

float32 (32비트): 28 GB  ← 일반 학습
float16 (16비트): 14 GB  ← 표준 추론
  int8  ( 8비트):  7 GB  ← 양자화 추론
   nf4  ( 4비트):  3.5 GB  ← QLoRA 핵심

nf4 (NormalFloat4):
- 정규분포를 가정해 4비트로 최적 표현
- 일반 int4보다 정밀도 손실 적음
- double quantization 적용 시 추가 0.3 GB 절감

4-2. QLoRA 동작 원리

QLoRA =  4비트로 동결된 베이스 모델
        + float16 LoRA 어댑터 학습

베이스 모델 (nf4, 동결)  ←── 순전파 시에만 사용
     +
LoRA 어댑터 (float16, 학습) ←── 역전파는 여기서만

→ VRAM 사용량: Full Fine-tuning 대비 ~75% 절감
→ 성능 손실: 거의 없음 (논문 기준 Full의 97% 수준)

4-3. GPU VRAM별 실행 가능 모델

VRAM가능한 모델방식
8 GB (RTX 3070)3B 이하LoRA
12 GB (RTX 3060 Ti)7BQLoRA
16 GB (RTX 4060 Ti)7BQLoRA
24 GB (RTX 4090)14B QLoRA / 7B LoRAQLoRA
48 GB (A40)34B QLoRA / 14B LoRAQLoRA
80 GB (A100)70B QLoRA / 34B LoRAQLoRA

5. DoRA — 2026년 새 표준

5-1. DoRA란?

DoRA(Weight-Decomposed LoRA) 는 2024년 말 등장해 2026년 사실상 표준이 된 LoRA 개선 기법입니다.

LoRA:
  ΔW = A × B  (방향과 크기를 함께 학습)
  → Full Fine-tuning과 학습 패턴이 다름

DoRA:
  W = 크기(Magnitude) × 방향(Direction)
       ↑ 스칼라 값         ↑ 정규화된 벡터
       학습 가능           LoRA로 학습

  → Full Fine-tuning과 거의 동일한 학습 패턴
  → 같은 rank에서 LoRA보다 일관되게 높은 성능

5-2. LoRA vs DoRA 성능 비교

벤치마크LoRA (r=16)DoRA (r=16)Full FT
Commonsense Reasoning78.3%81.7%82.1%
Math (GSM8K)65.2%68.9%69.4%
코드 생성51.3%56.1%57.0%

DoRA는 Full Fine-tuning 성능의 99% 이상을 달성합니다.

5-3. rsLoRA — 학습 안정화

python# 고랭크(r=64 이상) 사용 시 학습이 불안정해지는 문제 해결
# 스케일링 계수를 α/r 대신 α/√r 로 변경

lora_config = LoraConfig(
    r            = 64,
    lora_alpha   = 64,
    use_rslora   = True,    # rsLoRA 활성화
    use_dora     = True,    # DoRA 활성화
    ...
)

5-4. 2026년 황금 설정

python# 2026년 현재 커뮤니티 검증 표준 설정
lora_config = LoraConfig(
    r              = 16,       # rank
    lora_alpha     = 32,       # α = 2×r
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj"],
    lora_dropout   = 0.05,
    bias           = "none",
    use_dora       = True,     # DoRA 활성화 ← 2026 표준
    use_rslora     = False,    # r<=16 에서는 불필요
    task_type      = TaskType.CAUSAL_LM
)

6. Unsloth — 2026년 파인튜닝 필수 도구

6-1. Unsloth란?

Unsloth 는 LoRA/QLoRA Fine-tuning을 표준 HuggingFace PEFT 대비 속도 2배, VRAM 70% 절감 으로 수행하는 최적화 라이브러리입니다.

표준 HuggingFace PEFT:
  - 7B QLoRA 학습 속도: ~1.2 it/s
  - VRAM 사용: 18 GB (RTX 4090 기준)

Unsloth:
  - 7B QLoRA 학습 속도: ~2.4 it/s  (+100%)
  - VRAM 사용: 6~8 GB  (-70%)
  - CUDA 커널 직접 최적화로 달성
  - HuggingFace와 완전 호환 (코드 거의 동일)

6-2. Unsloth 설치

bash# CUDA 12.1 기준
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps trl peft accelerate bitsandbytes

# 또는 pip 직접 설치
pip install unsloth

6-3. Unsloth로 모델 불러오기

pythonfrom unsloth import FastLanguageModel
import torch

MAX_SEQ_LENGTH = 2048  # 최대 입력 길이 (모델에 따라 8192까지 가능)

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name     = "Qwen/Qwen2.5-7B-Instruct",  # 2026 권장
    max_seq_length = MAX_SEQ_LENGTH,
    dtype          = None,    # None = 자동 감지 (bfloat16 권장)
    load_in_4bit   = True,    # QLoRA 4비트 양자화
)

print(f"모델 로드 완료: {model.__class__.__name__}")

6-4. Unsloth + QLoRA + DoRA 어댑터 설정

pythonmodel = FastLanguageModel.get_peft_model(
    model,
    r              = 16,
    lora_alpha     = 32,
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj"],
    lora_dropout   = 0.05,
    bias           = "none",
    use_gradient_checkpointing = "unsloth",  # VRAM 추가 절감
    use_dora       = True,                   # DoRA 활성화
    random_state   = 42,
    use_rslora     = False,
)

model.print_trainable_parameters()
# trainable params: 83,886,080 || all params: 7,615,647,744 || trainable%: 1.10

7. 데이터 준비 — 파인튜닝 데이터 만들기

7-1. Instruction 데이터셋 구조

LLM 파인튜닝에서 가장 많이 쓰이는 데이터 포맷입니다.

json{
    "instruction": "다음 파이썬 코드의 버그를 찾아 수정해줘",
    "input": "def add(a, b):\n    return a - b",
    "output": "버그: '-' 연산자 대신 '+' 를 사용해야 합니다.\n\n수정 코드:\ndef add(a, b):\n    return a + b"
}

데이터 품질에 대해 명심할 것이 있습니다.

[파인튜닝 황금 법칙]
데이터 품질 > 데이터 양 > 모델 크기

- 10,000개 저품질 데이터 < 1,000개 고품질 데이터
- GPT-4 생성 데이터로 소형 모델 파인튜닝
  → 훨씬 큰 모델을 능가하는 사례 다수 보고

7-2. Chat Template — 2026년 표준 포맷

2026년 현재 가장 많이 쓰이는 ChatML 포맷 입니다.

ChatML 포맷:
<|im_start|>system
당신은 친절한 한국어 AI 어시스턴트입니다.<|im_end|>
<|im_start|>user
파이썬에서 리스트 정렬하는 방법 알려줘<|im_end|>
<|im_start|>assistant
파이썬에서 리스트를 정렬하는 방법은 두 가지가 있습니다...
<|im_end|>

Alpaca 포맷 (간단한 Instruction 태스크):
### Instruction:
아래 문장을 영어로 번역하세요.

### Input:
오늘 날씨가 매우 좋습니다.

### Response:
The weather is very nice today.

7-3. HuggingFace datasets로 데이터 불러오기

pythonfrom datasets import load_dataset

# 공개 Instruction 데이터셋 예시
dataset = load_dataset("iamseungjun/korean-alpaca-gpt4", split="train")
# 또는 영어 데이터셋
dataset = load_dataset("tatsu-lab/alpaca", split="train")

print(dataset)
print(dataset[0])
# {
#   'instruction': 'Give three tips for staying healthy.',
#   'input': '',
#   'output': '1. Eat a balanced diet...'
# }

# 데이터 분포 확인
import matplotlib.pyplot as plt

lengths = [len(d["instruction"]) + len(d["output"])
           for d in dataset]
plt.figure(figsize=(10, 4))
plt.hist(lengths, bins=50, color="steelblue", edgecolor="black")
plt.title("데이터 텍스트 길이 분포")
plt.xlabel("문자 수")
plt.ylabel("샘플 수")
plt.axvline(x=2000, color="red", linestyle="--", label="MAX_SEQ 기준")
plt.legend()
plt.grid(True)
plt.show()

7-4. 커스텀 데이터셋 만들기

pythonfrom datasets import Dataset

# 직접 만든 데이터 (예: 법률 QA, 쇼핑몰 CS, 의료 상담 등)
raw_data = [
    {
        "instruction": "배송 조회 방법을 알려주세요.",
        "input": "",
        "output": "마이페이지 > 주문내역에서 운송장 번호를 확인하신 후 택배사 홈페이지에서 조회하실 수 있습니다."
    },
    {
        "instruction": "반품 신청은 어떻게 하나요?",
        "input": "구매한 지 5일이 지났습니다.",
        "output": "구매 후 7일 이내에 반품 신청이 가능합니다. 마이페이지 > 주문내역 > 반품/교환 신청을 이용해주세요."
    },
    # ... 최소 500개 이상 권장
]

# HuggingFace Dataset으로 변환
custom_dataset = Dataset.from_list(raw_data)
print(custom_dataset)

7-5. Chat Template 적용 함수

python# ChatML 포맷으로 데이터 변환
def format_instruction_chatML(example, tokenizer):
    """
    Instruction 데이터를 ChatML 포맷 문자열로 변환
    """
    messages = [
        {"role": "system",
         "content": "당신은 도움이 되는 한국어 AI 어시스턴트입니다."},
        {"role": "user",
         "content": example["instruction"] +
                    (f"\n\n{example['input']}" if example["input"] else "")},
        {"role": "assistant",
         "content": example["output"]}
    ]
    # tokenizer.apply_chat_template이 자동으로 ChatML 포맷으로 변환
    text = tokenizer.apply_chat_template(
        messages,
        tokenize          = False,
        add_generation_prompt = False
    )
    return {"text": text}


# 데이터셋 전체에 적용
formatted_dataset = dataset.map(
    lambda x: format_instruction_chatML(x, tokenizer),
    remove_columns = dataset.column_names
)

print("변환 후 샘플:")
print(formatted_dataset[0]["text"])

8. 실습 — QLoRA + DoRA + SFTTrainer 파인튜닝

8-1. 전체 설정 한눈에

pythonimport torch
from unsloth import FastLanguageModel
from transformers import TrainingArguments
from trl import SFTTrainer, SFTConfig
from peft import LoraConfig, TaskType
from datasets import load_dataset

# ──────────────────────────────────────────────────
# 설정 값 (여기만 수정하면 됨)
# ──────────────────────────────────────────────────
MODEL_NAME     = "Qwen/Qwen2.5-7B-Instruct"  # 베이스 모델
MAX_SEQ_LENGTH = 2048                          # 최대 시퀀스 길이
LORA_R         = 16                            # LoRA rank
LORA_ALPHA     = 32                            # LoRA alpha
BATCH_SIZE     = 2                             # 배치 크기
GRAD_ACCUM     = 8                             # 그라디언트 누적 (유효 배치=16)
EPOCHS         = 3                             # 에포크
LEARNING_RATE  = 2e-4                          # 학습률
OUTPUT_DIR     = "./qwen_finetuned"            # 저장 경로

8-2. 모델 및 토크나이저 불러오기

python# Unsloth로 4비트 양자화 모델 로드
model, tokenizer = FastLanguageModel.from_pretrained(
    model_name     = MODEL_NAME,
    max_seq_length = MAX_SEQ_LENGTH,
    dtype          = None,   # bfloat16 자동 감지
    load_in_4bit   = True,   # 4비트 QLoRA
)

# pad_token 설정 (없는 경우 eos_token으로 대체)
if tokenizer.pad_token is None:
    tokenizer.pad_token     = tokenizer.eos_token
    tokenizer.pad_token_id  = tokenizer.eos_token_id

print(f"모델 로드 완료")
print(f"어휘 크기: {tokenizer.vocab_size:,}")
print(f"최대 위치 임베딩: {model.config.max_position_embeddings:,}")

8-3. QLoRA + DoRA 어댑터 설정

pythonmodel = FastLanguageModel.get_peft_model(
    model,
    r              = LORA_R,
    lora_alpha     = LORA_ALPHA,
    target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
                      "gate_proj", "up_proj", "down_proj"],
    lora_dropout   = 0.05,
    bias           = "none",
    use_gradient_checkpointing = "unsloth",  # VRAM 절감
    use_dora       = True,                   # DoRA 활성화
    random_state   = 42,
)

model.print_trainable_parameters()
# trainable params: 83,886,080 || all params: 7,615,647,744 || trainable%: 1.10%

8-4. 데이터 준비

pythondataset = load_dataset("tatsu-lab/alpaca", split="train")

# 샘플 수 제한 (테스트용: 1,000개 / 실제: 전체 사용 권장)
dataset = dataset.select(range(1000))

# ChatML 포맷 변환
formatted = dataset.map(
    lambda x: format_instruction_chatML(x, tokenizer),
    remove_columns = dataset.column_names
)

# 훈련 / 검증 분리
split = formatted.train_test_split(test_size=0.05, seed=42)
train_dataset = split["train"]
eval_dataset  = split["test"]

print(f"훈련 샘플: {len(train_dataset)}")
print(f"검증 샘플: {len(eval_dataset)}")
print(f"\n샘플 텍스트:\n{train_dataset[0]['text'][:200]}...")

8-5. SFTTrainer — 학습 설정 및 실행

pythonfrom trl import SFTConfig, SFTTrainer

training_args = SFTConfig(
    output_dir              = OUTPUT_DIR,
    num_train_epochs        = EPOCHS,
    per_device_train_batch_size = BATCH_SIZE,
    gradient_accumulation_steps = GRAD_ACCUM,  # 유효 배치 = 2×8 = 16
    learning_rate           = LEARNING_RATE,
    lr_scheduler_type       = "cosine",        # 코사인 감쇠
    warmup_ratio            = 0.03,            # 전체 3% warm-up
    max_seq_length          = MAX_SEQ_LENGTH,
    bf16                    = True,            # bfloat16 사용
    gradient_checkpointing  = True,            # VRAM 절감
    optim                   = "paged_adamw_8bit",  # QLoRA용 옵티마이저
    logging_steps           = 10,
    eval_strategy           = "steps",
    eval_steps              = 50,
    save_strategy           = "epoch",
    save_total_limit        = 2,
    dataset_text_field      = "text",          # 학습에 사용할 컬럼
    packing                 = True,            # 짧은 문장 묶어 효율 극대화
    report_to               = "none",          # wandb 연동 시 "wandb"
)

trainer = SFTTrainer(
    model        = model,
    tokenizer    = tokenizer,
    train_dataset = train_dataset,
    eval_dataset  = eval_dataset,
    args          = training_args,
)

# VRAM 사용량 확인 후 학습 시작
gpu_stats = torch.cuda.get_device_properties(0)
start_gpu_memory = round(torch.cuda.max_memory_reserved() / 1024**3, 3)
print(f"GPU: {gpu_stats.name}")
print(f"최대 VRAM: {round(gpu_stats.total_memory / 1024**3, 3)} GB")
print(f"학습 전 VRAM 사용: {start_gpu_memory} GB")
print("\n학습 시작...")

trainer_stats = trainer.train()

# 학습 완료 후 VRAM 사용량
used_memory = round(torch.cuda.max_memory_reserved() / 1024**3, 3)
print(f"\n학습 완료!")
print(f"학습 소요 시간: {trainer_stats.metrics['train_runtime']:.0f}초")
print(f"학습 중 최대 VRAM: {used_memory} GB")

💡 paged_adamw_8bit — QLoRA 학습에 특화된 옵티마이저입니다. 옵티마이저 상태를 GPU/CPU 간에 페이징하여 VRAM을 추가로 절감합니다.

💡 packing=True — 짧은 학습 샘플들을 max_seq_length에 맞게 이어 붙여 패딩 낭비 없이 GPU를 풀로 활용합니다. 보통 10~30% 속도 향상.

8-6. 손실 시각화

pythonimport matplotlib.pyplot as plt

log_history = trainer.state.log_history

train_losses = [(x["step"], x["loss"])
                for x in log_history if "loss" in x]
eval_losses  = [(x["step"], x["eval_loss"])
                for x in log_history if "eval_loss" in x]

fig, ax = plt.subplots(figsize=(12, 5))

if train_losses:
    steps, losses = zip(*train_losses)
    ax.plot(steps, losses, label="훈련 손실", color="steelblue")

if eval_losses:
    steps, losses = zip(*eval_losses)
    ax.plot(steps, losses, label="검증 손실",
            color="coral", linestyle="--", marker="o")

ax.set_title("QLoRA + DoRA Fine-tuning 손실 변화")
ax.set_xlabel("학습 스텝")
ax.set_ylabel("Cross Entropy Loss")
ax.legend(); ax.grid(True)
plt.tight_layout()
plt.show()

8-7. LoRA 어댑터 저장 및 병합

python# ── 방법 1: 어댑터만 저장 (작은 용량) ─────────────────────────
# 베이스 모델 + 어댑터 각각 보관, 추론 시 병합
model.save_pretrained("./qwen_lora_adapter")
tokenizer.save_pretrained("./qwen_lora_adapter")
print("LoRA 어댑터 저장 완료 (~80MB)")

# ── 방법 2: 베이스 모델과 병합 후 저장 (독립 사용 가능) ────────
# 병합 = LoRA 가중치를 베이스 모델에 흡수
# 추론 속도 빨라짐 (어댑터 계산 제거)
model_merged = model.merge_and_unload()  # Unsloth 제공 함수
model_merged.save_pretrained("./qwen_finetuned_merged",
                              safe_serialization=True)
tokenizer.save_pretrained("./qwen_finetuned_merged")
print("병합 모델 저장 완료 (~14GB)")

# ── 어댑터 불러오기 ─────────────────────────────────────────────
from peft import PeftModel

base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME, torch_dtype=torch.float16, device_map="auto"
)
model_loaded = PeftModel.from_pretrained(base_model, "./qwen_lora_adapter")
print("LoRA 어댑터 불러오기 완료")

9. 파인튜닝 모델 평가하기

9-1. 파인튜닝 전 vs 후 응답 비교

pythondef generate_response(model, tokenizer, instruction,
                      input_text="", max_new_tokens=256):
    """파인튜닝된 모델로 응답 생성"""
    # 추론 모드 전환 (Unsloth 사용 시)
    FastLanguageModel.for_inference(model)

    messages = [
        {"role": "system",
         "content": "당신은 도움이 되는 한국어 AI 어시스턴트입니다."},
        {"role": "user",
         "content": instruction + (f"\n\n{input_text}" if input_text else "")}
    ]

    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = tokenizer(text, return_tensors="pt").to(model.device)

    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens      = max_new_tokens,
            do_sample           = True,
            top_p               = 0.9,
            temperature         = 0.7,
            repetition_penalty  = 1.1,     # 반복 억제
            pad_token_id        = tokenizer.eos_token_id
        )

    # 입력 부분 제거 → 생성된 부분만 추출
    generated = outputs[0][inputs["input_ids"].shape[1]:]
    return tokenizer.decode(generated, skip_special_tokens=True)


# 비교 테스트
test_questions = [
    "한국의 대표적인 전통 음식 3가지를 설명해줘",
    "파이썬에서 딕셔너리와 리스트의 차이점은?",
    "머신러닝과 딥러닝의 차이를 초보자도 이해하도록 설명해줘"
]

print("=" * 60)
for question in test_questions:
    print(f"\n📌 질문: {question}")
    print("-" * 40)
    response = generate_response(model, tokenizer, question)
    print(f"🤖 응답:\n{response}")
    print("=" * 60)

9-2. 자동 평가 지표 — ROUGE

pythonfrom rouge_score import rouge_scorer

# pip install rouge-score
scorer = rouge_scorer.RougeScorer(
    ["rouge1", "rouge2", "rougeL"], use_stemmer=False
)

# 예측값 vs 정답 비교
predictions = [
    generate_response(model, tokenizer, d["instruction"], d["input"])
    for d in eval_dataset.select(range(50))  # 50개 샘플 평가
]
references = [d["output"] for d in eval_dataset.select(range(50))]

scores = {"rouge1": [], "rouge2": [], "rougeL": []}
for pred, ref in zip(predictions, references):
    s = scorer.score(ref, pred)
    for k in scores:
        scores[k].append(s[k].fmeasure)

print("\n평가 결과 (ROUGE F1):")
for k, v in scores.items():
    print(f"  {k.upper():8s}: {sum(v)/len(v):.4f}")

9-3. LLM-as-Judge — GPT로 응답 품질 자동 평가

2026년 현재 가장 신뢰받는 평가 방식입니다. GPT-4에게 두 응답 중 어느 것이 더 좋은지 평가하도록 시킵니다.

pythonfrom openai import OpenAI

client = OpenAI()  # OPENAI_API_KEY 환경변수 필요

def llm_judge(question, response_a, response_b):
    """
    GPT에게 두 응답 중 어느 것이 더 좋은지 평가 요청
    파인튜닝 전(A) vs 파인튜닝 후(B) 비교
    """
    prompt = f"""다음 두 AI 응답 중 어느 것이 더 좋은지 평가해주세요.

질문: {question}

[응답 A]:
{response_a}

[응답 B]:
{response_b}

평가 기준:
1. 정확성 (사실에 맞는가?)
2. 완성도 (질문에 충분히 답했는가?)
3. 자연스러움 (한국어가 자연스러운가?)

반드시 "A가 낫다", "B가 낫다", "동등하다" 중 하나로만 답하고
이유를 한 문장으로 설명하세요."""

    response = client.chat.completions.create(
        model    = "gpt-4o",
        messages = [{"role": "user", "content": prompt}],
        max_tokens = 100
    )
    return response.choices[0].message.content


# 비교 평가 실행
test_q = "기계학습에서 과적합이란 무엇이고 어떻게 방지하나요?"

# 베이스 모델 응답 (파인튜닝 전 — 별도 로드 필요)
resp_base = "(파인튜닝 전 베이스 모델 응답)"
resp_finetuned = generate_response(model, tokenizer, test_q)

verdict = llm_judge(test_q, resp_base, resp_finetuned)
print(f"질문: {test_q}")
print(f"GPT 평가: {verdict}")

10. ORPO — 2026년 선호도 학습 최신 기법

10-1. RLHF / DPO / ORPO 발전 흐름

[SFT만 사용 시의 문제]
  모델이 "올바른 답"은 학습하지만
  "나쁜 답을 피하는 것"은 학습 못 함
  → 사람이 원하지 않는 응답 생성 가능

[해결 방법의 발전]

RLHF (2022):
  SFT → 보상 모델 학습 → PPO 강화학습
  → 효과 좋지만 파이프라인 복잡, 학습 불안정

DPO (2023):
  RLHF를 closed-form 손실로 단순화
  → 별도 보상 모델 불필요, 그러나 SFT와 별개 학습

ORPO (2024~2026 표준):
  SFT 손실 + 선호도 손실을 단일 손실로 통합
  → 1단계 학습으로 SFT + DPO 효과 동시에 달성
  → 더 빠르고, 더 안정적, 성능도 동등 이상

10-2. ORPO 원리

ORPO(Odds Ratio Preference Optimization) 는 선호 응답과 비선호 응답의 오즈비(Odds Ratio) 를 이용해 선호도를 학습합니다.

python# ORPO 손실 = SFT 손실 + λ × Odds Ratio 손실
#
# SFT 손실:
#   L_SFT = -log P(y_chosen | x)
#   → 선호 응답의 가능도를 높임
#
# Odds Ratio 손실:
#   odds(y|x) = P(y|x) / (1 - P(y|x))
#   L_OR = -log σ(log (odds(y_chosen|x) / odds(y_rejected|x)))
#   → 선호/비선호 응답의 오즈비를 직접 최대화
#
# λ는 두 손실의 균형 파라미터 (기본값 0.1)

10-3. ORPO 데이터셋 구조

python# ORPO는 선호/비선호 응답 쌍이 필요
# {"prompt": ..., "chosen": ..., "rejected": ...}

orpo_data = [
    {
        "prompt": "파이썬으로 피보나치 수열을 구현해줘",
        "chosen": (
            "피보나치 수열을 구현하는 효율적인 방법입니다:\n\n"
            "```python\n"
            "def fibonacci(n):\n"
            "    a, b = 0, 1\n"
            "    for _ in range(n):\n"
            "        a, b = b, a + b\n"
            "    return a\n"
            "```\n"
            "이 방식은 O(n) 시간복잡도로 효율적입니다."
        ),
        "rejected": (
            "피보나치는 어렵습니다. "
            "그냥 인터넷에서 찾아보세요."
        )
    },
    {
        "prompt": "머신러닝이 뭔지 쉽게 설명해줘",
        "chosen": (
            "머신러닝은 컴퓨터가 데이터로부터 스스로 패턴을 학습하는 기술입니다. "
            "예를 들어, 수만 장의 고양이 사진을 보여주면 컴퓨터가 '고양이'의 특징을 "
            "스스로 파악해서 새로운 사진에서도 고양이를 인식할 수 있게 됩니다."
        ),
        "rejected": (
            "머신러닝은 Machine Learning의 약자이며 AI의 하위 분야입니다."
        )
    }
]

from datasets import Dataset
orpo_dataset = Dataset.from_list(orpo_data)

10-4. ChatML 포맷으로 변환

pythondef format_orpo_chatML(example, tokenizer):
    """ORPO 데이터를 ChatML 포맷으로 변환"""
    prompt_messages = [
        {"role": "system",
         "content": "당신은 도움이 되는 한국어 AI 어시스턴트입니다."},
        {"role": "user",
         "content": example["prompt"]}
    ]

    # 프롬프트 (system + user)
    prompt = tokenizer.apply_chat_template(
        prompt_messages, tokenize=False, add_generation_prompt=True
    )

    # 선호 응답
    chosen_messages  = prompt_messages + [
        {"role": "assistant", "content": example["chosen"]}
    ]
    chosen = tokenizer.apply_chat_template(
        chosen_messages, tokenize=False, add_generation_prompt=False
    )

    # 비선호 응답
    rejected_messages = prompt_messages + [
        {"role": "assistant", "content": example["rejected"]}
    ]
    rejected = tokenizer.apply_chat_template(
        rejected_messages, tokenize=False, add_generation_prompt=False
    )

    return {"prompt": prompt, "chosen": chosen, "rejected": rejected}


orpo_formatted = orpo_dataset.map(
    lambda x: format_orpo_chatML(x, tokenizer),
    remove_columns = orpo_dataset.column_names
)

print("ORPO 데이터 샘플:")
print(f"prompt:   {orpo_formatted[0]['prompt'][:100]}...")
print(f"chosen:   {orpo_formatted[0]['chosen'][:100]}...")
print(f"rejected: {orpo_formatted[0]['rejected'][:100]}...")

10-5. ORPOTrainer로 학습

pythonfrom trl import ORPOConfig, ORPOTrainer

orpo_args = ORPOConfig(
    output_dir                  = "./orpo_output",
    num_train_epochs            = 3,
    per_device_train_batch_size = 2,
    gradient_accumulation_steps = 4,
    learning_rate               = 8e-6,          # ORPO는 더 낮은 학습률 사용
    lr_scheduler_type           = "cosine",
    warmup_ratio                = 0.1,
    beta                        = 0.1,            # Odds Ratio 손실 가중치 λ
    max_length                  = MAX_SEQ_LENGTH,
    max_prompt_length           = 512,
    bf16                        = True,
    gradient_checkpointing      = True,
    optim                       = "paged_adamw_8bit",
    logging_steps               = 5,
    save_strategy               = "epoch",
    remove_unused_columns       = False,
)

orpo_trainer = ORPOTrainer(
    model        = model,
    tokenizer    = tokenizer,
    train_dataset = orpo_formatted,
    args          = orpo_args,
)

print("ORPO 학습 시작...")
orpo_stats = orpo_trainer.train()
print(f"학습 완료! 총 시간: {orpo_stats.metrics['train_runtime']:.0f}초")

10-6. ORPO 학습 곡선 시각화

pythonorpo_log = orpo_trainer.state.log_history

steps         = [x["step"] for x in orpo_log if "loss" in x]
total_losses  = [x["loss"]     for x in orpo_log if "loss" in x]
sft_losses    = [x.get("sft_loss",    0) for x in orpo_log if "loss" in x]
odds_losses   = [x.get("odds_ratio_loss", 0) for x in orpo_log if "loss" in x]

fig, axes = plt.subplots(1, 3, figsize=(18, 5))

axes[0].plot(steps, total_losses, color="steelblue")
axes[0].set_title("전체 손실 (SFT + OR)")
axes[0].set_xlabel("스텝"); axes[0].grid(True)

axes[1].plot(steps, sft_losses, color="green")
axes[1].set_title("SFT 손실")
axes[1].set_xlabel("스텝"); axes[1].grid(True)

axes[2].plot(steps, odds_losses, color="coral")
axes[2].set_title("Odds Ratio 손실")
axes[2].set_xlabel("스텝"); axes[2].grid(True)

plt.suptitle("ORPO 학습 손실 변화", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.show()

11. 마무리

11-1. 오늘 배운 것 한눈에 정리

개념핵심 내용
PEFT전체 파라미터 1~3%만 학습, 비용·시간 90% 절감
LoRA가중치 변화량 ΔW를 저랭크 행렬 A×B로 근사
QLoRA베이스 모델 4비트 동결 + LoRA float16 학습
DoRA가중치를 방향·크기로 분해해 Full FT에 근접한 품질
rsLoRA고랭크 학습 안정화 (α/√r 스케일링)
Unsloth속도 2배, VRAM 70% 절감, 2026년 파인튜닝 필수
SFTTrainerpacking + Flash Attention2 + bfloat16 표준 설정
ORPOSFT + 선호도 학습을 단일 손실로 통합, 2026 표준
LLM-as-JudgeGPT-4로 응답 품질 자동 평가
LoRA 병합merge_and_unload()로 어댑터를 베이스에 흡수