Skip to content

필사 모드: LLM 파인튜닝 완전 가이드: LoRA, QLoRA, RLHF, DPO 마스터하기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

LLM 파인튜닝 완전 가이드: LoRA, QLoRA, RLHF, DPO 마스터하기

LLaMA 3, Mistral, Gemma 같은 강력한 오픈소스 LLM이 공개된 지금, 이를 특정 도메인이나 태스크에 맞게 파인튜닝하는 기술은 AI 엔지니어의 핵심 역량이 되었습니다. 이 가이드는 Full Fine-tuning의 기초부터 LoRA, QLoRA, RLHF, DPO까지 LLM 파인튜닝의 모든 기법을 실전 코드와 함께 완전히 다룹니다.

1. 왜 파인튜닝인가?

1.1 사전학습 모델의 한계

대규모 LLM은 인터넷의 방대한 텍스트로 사전학습되어 놀라운 일반 능력을 보유합니다. 하지만 그대로 사용하면 다음과 같은 한계가 있습니다:

**도메인 특화 지식 부족:** GPT-4도 특정 기업의 내부 문서나 최신 의학 프로토콜을 모릅니다. 사전학습 데이터에 없는 정보는 알 수 없습니다.

**지시 따르기 능력 부재:** 베이스 모델은 지시를 따르도록 학습되지 않았습니다. "이 코드의 버그를 찾아라"라는 요청에 도움이 되는 답변보다 텍스트를 계속 생성할 수 있습니다.

**출력 형식 제어 어려움:** 특정 JSON 스키마나 마크다운 형식으로 항상 출력하도록 만들기 어렵습니다.

**안전성/정렬 문제:** 사전학습된 모델은 유해한 콘텐츠를 생성하거나 부적절한 응답을 할 수 있습니다.

1.2 파인튜닝의 이점

파인튜닝은 사전학습된 모델의 가중치를 특정 목적에 맞게 추가 학습하는 과정입니다:

- **도메인 적응:** 특정 분야의 전문 용어, 지식, 스타일 습득

- **태스크 특화:** 분류, 추출, 생성 등 특정 태스크 성능 극대화

- **행동 제어:** 원하는 응답 방식, 형식, 안전 기준 학습

- **비용 효율:** 작은 파인튜닝 모델이 큰 모델 API 호출을 대체

1.3 파인튜닝 vs 프롬프트 엔지니어링

프롬프트 엔지니어링은 빠르고 비용이 없지만 한계가 있습니다:

| 기준 | 프롬프트 엔지니어링 | 파인튜닝 |

| ----------- | ------------------- | ------------------ |

| 구현 난이도 | 낮음 | 중~높음 |

| 비용 | 런타임 토큰 비용 | 학습 비용 (일회성) |

| 성능 상한 | 모델 능력에 제한 | 더 높은 성능 가능 |

| 응답 일관성 | 낮음 | 높음 |

| 개인정보 | API에 데이터 전송 | 로컬 실행 가능 |

| 지연 시간 | 긴 프롬프트 → 느림 | 짧은 입력 가능 |

1.4 파인튜닝의 종류

파인튜닝 방법은 크게 세 가지로 분류됩니다:

1. **Full Fine-tuning:** 모든 파라미터 업데이트 (가장 강력, 가장 비쌈)

2. **PEFT (Parameter-Efficient Fine-Tuning):** 일부 파라미터만 업데이트

- LoRA, QLoRA, Prefix Tuning, Adapter 등

3. **RLHF/DPO:** 인간 선호도 학습 (정렬/안전성)

2. Full Fine-tuning

2.1 개요

Full Fine-tuning은 모든 모델 파라미터를 새 데이터로 업데이트합니다. 이론적으로 가장 강력하지만, 실용적 한계가 많습니다.

2.2 메모리 요구사항 계산

7B 파라미터 모델을 Full FT 할 때 필요한 메모리:

- **모델 가중치 (BF16):** 7B × 2 bytes = 14 GB

- **기울기:** 14 GB (가중치와 동일)

- **옵티마이저 상태 (AdamW):** 14 GB × 2 = 28 GB (1차, 2차 모멘텀)

- **활성화 값:** 배치 크기, 시퀀스 길이에 따라 수 GB

- **총합:** 약 60 GB 이상

A100 80GB 1장으로도 7B 모델 Full FT가 빠듯합니다. 70B 모델은 여러 장의 고급 GPU가 필요합니다.

2.3 언제 Full Fine-tuning을 쓸까?

- 도메인이 사전학습 분포와 매우 다를 때 (예: 고도로 특화된 의학/법률 언어)

- 충분한 GPU 리소스가 있을 때

- 최대 성능이 절대적으로 필요할 때

- 연속 사전학습 (Continual Pretraining)

2.4 Catastrophic Forgetting

Full FT의 가장 큰 문제는 파국적 망각(Catastrophic Forgetting)입니다. 새 데이터로 학습하면 기존에 알던 지식이 손상됩니다.

완화 방법:

- **낮은 학습률:** 기존 지식 보존을 위해 1e-5 이하 사용

- **데이터 혼합:** 원본 사전학습 데이터와 새 데이터를 혼합

- **EWC (Elastic Weight Consolidation):** 중요한 가중치 변화를 규제

- **PEFT 사용:** 새 파라미터만 학습하면 Catastrophic Forgetting이 없음

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer

from datasets import load_dataset

Full Fine-tuning 예시

model_name = "meta-llama/Llama-3.2-1B"

tokenizer = AutoTokenizer.from_pretrained(model_name)

model = AutoModelForCausalLM.from_pretrained(

model_name,

torch_dtype=torch.bfloat16,

device_map="auto",

)

데이터셋 준비

dataset = load_dataset("your_dataset")

def tokenize_function(examples):

return tokenizer(

examples["text"],

truncation=True,

max_length=2048,

padding="max_length",

)

tokenized_dataset = dataset.map(tokenize_function, batched=True)

훈련 설정

training_args = TrainingArguments(

output_dir="./full_ft_output",

num_train_epochs=3,

per_device_train_batch_size=4,

gradient_accumulation_steps=4,

learning_rate=2e-5, # 낮은 학습률

weight_decay=0.01,

bf16=True,

logging_steps=100,

save_steps=500,

evaluation_strategy="steps",

eval_steps=500,

warmup_ratio=0.03,

lr_scheduler_type="cosine",

)

trainer = Trainer(

model=model,

args=training_args,

train_dataset=tokenized_dataset["train"],

eval_dataset=tokenized_dataset["validation"],

)

trainer.train()

3. LoRA (Low-Rank Adaptation)

3.1 핵심 아이디어

LoRA (Hu et al., 2021)는 LLM 파인튜닝의 게임 체인저입니다. 핵심 관찰은 "파인튜닝 중 가중치 변화는 사실 저랭크(low-rank) 구조를 가진다"는 것입니다.

원래 가중치 행렬 W (d×k 크기)를 직접 업데이트하는 대신, 두 개의 작은 행렬 B (d×r)와 A (r×k)의 곱으로 변화량을 표현합니다:

W' = W + ΔW = W + BA

여기서 `r`은 랭크(rank)이며 d와 k보다 훨씬 작게 설정됩니다.

**파라미터 수 비교:**

- 원래 ΔW: d × k 파라미터

- LoRA: B(d×r) + A(r×k) = r(d+k) 파라미터

- d=k=4096, r=16이면: 16,777,216 vs 131,072 (128배 감소!)

3.2 수식 상세

초기화:

- A: 가우시안 분포로 초기화 (랜덤)

- B: 0으로 초기화 (학습 시작 시 ΔW=0 보장)

Forward pass:

h = xW^T + x(BA)^T * (alpha/r)

여기서 alpha는 스케일링 팩터입니다. alpha/r을 학습률처럼 생각할 수 있습니다.

3.3 rank r의 선택

rank r은 LoRA의 가장 중요한 하이퍼파라미터입니다:

- **r=4 또는 r=8:** 가볍고 빠른 실험용. 간단한 태스크에 충분

- **r=16:** 대부분 태스크에서 좋은 균형. 권장 시작점

- **r=32 또는 r=64:** 복잡한 태스크, 더 많은 능력 필요 시

- **r=128 이상:** Full FT에 가까운 성능이 필요할 때

일반적으로 r=16에서 시작하고, 충분하지 않으면 늘립니다.

3.4 alpha 하이퍼파라미터

alpha는 LoRA 업데이트의 스케일을 조정합니다. 실제로는 alpha/r이 스케일 팩터로 작용합니다:

- alpha = r이면 스케일 팩터 = 1 (일반적인 선택)

- alpha = 2r이면 스케일 팩터 = 2 (더 강한 LoRA)

- 학습률과 독립적으로 조정 가능

3.5 어떤 레이어에 LoRA를 적용할까?

원래 논문에서는 Attention의 Q, V 행렬에만 적용했습니다. 하지만 실험에 따르면:

- **Q, K, V, O (모든 Attention 행렬):** 일반적으로 좋음

- **+ MLP (FFN 레이어):** 더 좋은 경우 많음

- **모든 레이어:** 거의 Full FT 성능

HuggingFace PEFT의 기본값은 Q, V 행렬입니다. 복잡한 태스크에서는 모든 선형 레이어에 적용하는 것을 권장합니다.

3.6 HuggingFace PEFT로 LoRA 구현

from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

from peft import LoraConfig, get_peft_model, TaskType, PeftModel

from trl import SFTTrainer, SFTConfig

LoRA 설정

lora_config = LoraConfig(

r=16, # LoRA rank

lora_alpha=32, # alpha = 2*r

target_modules=[ # LoRA 적용 레이어

"q_proj",

"k_proj",

"v_proj",

"o_proj",

"gate_proj",

"up_proj",

"down_proj",

],

lora_dropout=0.05, # Dropout

bias="none", # bias 처리 방법

task_type=TaskType.CAUSAL_LM, # 태스크 타입

)

모델 로드

model_name = "meta-llama/Llama-3.2-3B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_name)

tokenizer.pad_token = tokenizer.eos_token

tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(

model_name,

torch_dtype=torch.bfloat16,

device_map="auto",

)

LoRA 모델 생성

model = get_peft_model(model, lora_config)

model.print_trainable_parameters()

출력 예시: trainable params: 41,943,040 || all params: 3,254,779,904 || trainable%: 1.29%

학습 가능한 파라미터 확인

for name, param in model.named_parameters():

if param.requires_grad:

print(f"Trainable: {name}, shape: {param.shape}")

4. QLoRA (Quantized LoRA)

4.1 QLoRA란?

QLoRA (Dettmers et al., 2023)는 LoRA를 더욱 메모리 효율적으로 만든 기법입니다. 4비트로 양자화된 베이스 모델 위에 LoRA 어댑터를 학습합니다.

핵심 기술 세 가지:

1. **4비트 NF4 양자화:** 베이스 모델 가중치를 4비트로 압축

2. **이중 양자화 (Double Quantization):** 양자화 상수도 추가 양자화

3. **Paged Optimizers:** 옵티마이저 상태를 CPU RAM과 GPU 간 페이징

4.2 4비트 NF4 양자화

NF4 (NormalFloat4)는 표준 정규 분포에 최적화된 4비트 데이터 타입입니다. 일반적인 LLM 가중치가 정규 분포에 가깝다는 사실을 이용합니다.

NF4는 정보 이론적으로 최적: 각 양자화 bin이 동일한 확률 질량을 가집니다.

**메모리 절약 효과:**

- FP16 → INT4: 4배 압축

- 70B 모델: 140GB (FP16) → 35GB (4bit) → 24GB GPU 2장으로 학습 가능

4.3 이중 양자화

양자화 상수(quantization constants) 자체도 메모리를 차지합니다. Double Quantization은 이 양자화 상수를 8비트로 추가 양자화합니다. 파라미터당 약 0.37비트를 추가로 절약합니다.

4.4 Paged Optimizers

긴 시퀀스 처리 시 피크 메모리가 OOM(Out of Memory)을 일으킬 수 있습니다. Paged Optimizers는 NVIDIA 통합 메모리를 사용하여 옵티마이저 상태를 CPU RAM으로 자동 오프로드합니다.

4.5 완전한 QLoRA 학습 코드

from transformers import (

AutoModelForCausalLM,

AutoTokenizer,

BitsAndBytesConfig,

TrainingArguments,

)

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training

from trl import SFTTrainer, DataCollatorForCompletionOnlyLM

from datasets import load_dataset

4비트 양자화 설정

bnb_config = BitsAndBytesConfig(

load_in_4bit=True, # 4비트 로드

bnb_4bit_quant_type="nf4", # NF4 양자화 타입

bnb_4bit_compute_dtype=torch.bfloat16, # 계산 시 BF16 사용

bnb_4bit_use_double_quant=True, # 이중 양자화 활성화

)

모델 로드 (4비트)

model_name = "meta-llama/Llama-3.1-8B-Instruct"

model = AutoModelForCausalLM.from_pretrained(

model_name,

quantization_config=bnb_config,

device_map="auto",

trust_remote_code=True,

)

tokenizer = AutoTokenizer.from_pretrained(model_name)

tokenizer.pad_token = tokenizer.eos_token

tokenizer.padding_side = "right"

kbit 학습을 위한 모델 준비

(레이어 정규화를 FP32로 캐스팅, 임베딩 레이어 처리 등)

model = prepare_model_for_kbit_training(model)

LoRA 설정

lora_config = LoraConfig(

r=64,

lora_alpha=16,

target_modules=[

"q_proj", "k_proj", "v_proj", "o_proj",

"gate_proj", "up_proj", "down_proj",

],

lora_dropout=0.1,

bias="none",

task_type="CAUSAL_LM",

)

model = get_peft_model(model, lora_config)

model.print_trainable_parameters()

데이터셋 준비 - Alpaca 형식

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

def format_instruction(sample):

"""Alpaca 형식으로 데이터 포맷팅"""

instruction = sample["instruction"]

input_text = sample.get("input", "")

output = sample["output"]

if input_text:

text = f"""### 지시사항:

{instruction}

입력:

{input_text}

응답:

{output}"""

else:

text = f"""### 지시사항:

{instruction}

응답:

{output}"""

return {"text": text}

formatted_dataset = dataset.map(format_instruction)

학습 설정

training_args = TrainingArguments(

output_dir="./qlora_output",

num_train_epochs=3,

per_device_train_batch_size=4,

gradient_accumulation_steps=4,

gradient_checkpointing=True, # 메모리 절약

optim="paged_adamw_32bit", # Paged Optimizer!

logging_steps=25,

save_strategy="epoch",

learning_rate=2e-4,

bf16=True,

max_grad_norm=0.3,

warmup_ratio=0.03,

lr_scheduler_type="cosine",

report_to="wandb",

run_name="llama-3-qlora",

)

SFT 트레이너 (응답 부분만 손실 계산)

response_template = "### 응답:"

collator = DataCollatorForCompletionOnlyLM(

response_template,

tokenizer=tokenizer,

)

trainer = SFTTrainer(

model=model,

args=training_args,

train_dataset=formatted_dataset,

data_collator=collator,

tokenizer=tokenizer,

max_seq_length=2048,

dataset_text_field="text",

packing=False, # 시퀀스 패킹 (효율성 향상)

)

학습 시작

trainer.train()

모델 저장 (LoRA 어댑터만)

trainer.save_model("./qlora_adapter")

tokenizer.save_pretrained("./qlora_adapter")

5. 다른 PEFT 방법들

5.1 Prefix Tuning

Prefix Tuning은 각 Transformer 레이어의 K, V에 학습 가능한 "프리픽스" 토큰을 추가합니다. 모델 가중치는 전혀 변경하지 않습니다.

from peft import PrefixTuningConfig, get_peft_model

prefix_config = PrefixTuningConfig(

task_type="CAUSAL_LM",

num_virtual_tokens=20, # 프리픽스 토큰 수

prefix_projection=True, # MLP로 프리픽스 투영

)

model = get_peft_model(base_model, prefix_config)

Prefix Tuning은 원래 seq2seq 태스크에 좋은 성능을 보였지만, LoRA보다 일반적으로 성능이 낮아 잘 쓰이지 않습니다.

5.2 Prompt Tuning

가장 간단한 PEFT 방법. 입력 임베딩 앞에 학습 가능한 소프트 프롬프트만 추가합니다.

from peft import PromptTuningConfig, PromptTuningInit

prompt_config = PromptTuningConfig(

task_type="CAUSAL_LM",

prompt_tuning_init=PromptTuningInit.TEXT,

num_virtual_tokens=8,

prompt_tuning_init_text="이것은 특화 작업입니다:",

tokenizer_name_or_path=model_name,

)

모델이 클수록 성능이 좋아지는 특성이 있습니다. 파라미터 수가 극히 적어 매우 효율적이지만, 성능 상한이 낮습니다.

5.3 IA3 (Infused Adapter by Inhibiting and Amplifying Inner Activations)

IA3는 매우 적은 파라미터(LoRA의 1/10)로 LoRA와 유사한 성능을 달성합니다. Attention의 K, V와 FFN의 활성화 값에 학습 가능한 벡터를 곱합니다.

from peft import IA3Config

ia3_config = IA3Config(

task_type="CAUSAL_LM",

target_modules=["k_proj", "v_proj", "down_proj"],

feedforward_modules=["down_proj"],

)

5.4 Adapter

Adapter는 각 Transformer 레이어 내부에 작은 보틀넥 레이어를 삽입합니다. 다운-투영(down-projection), 비선형 활성화, 업-투영(up-projection) 구조입니다. LoRA보다 먼저 제안된 방법으로, 추론 시 레이턴시가 약간 증가하는 단점이 있습니다.

6. Instruction Tuning

6.1 Instruction Tuning이란?

베이스 LLM은 텍스트를 계속 생성하도록 학습되었습니다. Instruction Tuning은 모델이 지시를 따르도록 (Instruction-following) 지도학습합니다. Stanford Alpaca (2023)가 이 방식을 대중화했습니다.

6.2 데이터셋 형식

**Alpaca 형식:**

{

"instruction": "주어진 두 수의 최대공약수를 구하시오.",

"input": "24, 36",

"output": "24와 36의 최대공약수는 12입니다.\n\n계산 과정:\n- 24 = 2^3 × 3\n- 36 = 2^2 × 3^2\n- GCD = 2^2 × 3 = 12"

}

**ChatML 형식 (OpenAI 표준):**

<|im_start|>system

당신은 유능한 AI 어시스턴트입니다.<|im_end|>

<|im_start|>user

파이썬으로 피보나치 수열을 구현해줘.<|im_end|>

<|im_start|>assistant

피보나치 수열을 구현하는 방법은 여러 가지가 있습니다...

**ShareGPT 형식 (대화형):**

{

"conversations": [

{ "from": "human", "value": "질문 텍스트" },

{ "from": "gpt", "value": "답변 텍스트" },

{ "from": "human", "value": "후속 질문" },

{ "from": "gpt", "value": "후속 답변" }

]

}

6.3 고품질 데이터의 중요성

LIMA (Less is More for Alignment, 2023) 연구는 1,000개의 고품질 예시만으로도 강력한 instruction-following 능력을 달성할 수 있음을 보여줬습니다. 데이터 양보다 질이 중요합니다.

고품질 Instruction 데이터 기준:

- **다양성:** 다양한 태스크와 도메인 커버

- **명확성:** 지시가 모호하지 않음

- **정확성:** 응답이 사실적으로 정확

- **일관성:** 동일 유형 지시에 일관된 응답 스타일

- **응답 길이:** 필요한 만큼만 (과도하게 길지 않음)

6.4 한국어 Instruction 데이터셋

한국어 파인튜닝에 활용 가능한 공개 데이터셋:

- **KoAlpaca-v1.1:** 21,155개 한국어 Alpaca 형식 데이터

- **OIG-small-chip2-ko:** 번역된 Instruction 데이터

- **KULLM-Polyglot-12.8B-v2 training data:** 고품질 한국어 Instructions

- **Open-Platypus-Ko:** 수학/과학 중심 고품질 한국어 데이터

from datasets import load_dataset

KoAlpaca 데이터셋 로드

dataset = load_dataset("beomi/KoAlpaca-v1.1a")

def format_koalpaca(sample):

"""KoAlpaca 형식 포맷팅"""

instruction = sample["instruction"]

output = sample["output"]

text = f"""### 질문:

{instruction}

답변:

{output}"""

return {"text": text}

formatted = dataset.map(format_koalpaca)

print(formatted["train"][0]["text"])

7. RLHF (Reinforcement Learning from Human Feedback)

7.1 RLHF 개요

RLHF는 ChatGPT, Claude, Gemini 등 현대 AI 어시스턴트의 정렬 기법입니다. 인간의 선호도를 학습하여 모델이 더 유용하고, 안전하고, 솔직하게 응답하도록 만듭니다.

3단계 파이프라인:

1. **SFT (Supervised Fine-Tuning):** 고품질 시연 데이터로 지도학습

2. **Reward Model 학습:** 인간 선호도 데이터로 보상 모델 훈련

3. **RL 최적화 (PPO):** 보상 신호로 정책(LLM) 최적화

7.2 1단계: Supervised Fine-Tuning (SFT)

베이스 모델을 고품질 대화 데이터로 파인튜닝합니다. 이 단계에서 모델이 지시 따르기를 기본적으로 학습합니다.

from trl import SFTTrainer, SFTConfig

from peft import LoraConfig

sft_config = SFTConfig(

output_dir="./sft_model",

max_seq_length=2048,

num_train_epochs=1,

per_device_train_batch_size=4,

gradient_accumulation_steps=8,

learning_rate=2e-4,

bf16=True,

optim="adamw_torch_fused",

logging_steps=10,

save_steps=100,

warmup_ratio=0.05,

dataset_text_field="text",

)

trainer = SFTTrainer(

model=model,

args=sft_config,

train_dataset=sft_dataset,

peft_config=lora_config,

tokenizer=tokenizer,

)

trainer.train()

sft_model_path = "./sft_model"

trainer.save_model(sft_model_path)

7.3 2단계: Reward Model 학습

Reward Model (RM)은 두 응답 중 어느 것이 더 선호되는지 인간이 레이블링한 데이터를 학습합니다. 같은 LLM 아키텍처에 선형 헤드를 추가하여 스칼라 보상값을 예측합니다.

**선호도 데이터 형식:**

{

"prompt": "기후변화에 대해 설명해줘",

"chosen": "기후변화는 인간 활동으로 인한 온실가스 증가로...(자세하고 정확한 답변)",

"rejected": "기후변화요? 그냥 지구가 더워지는 거예요 (간단하고 부정확한 답변)"

}

from trl import RewardTrainer, RewardConfig

reward_config = RewardConfig(

output_dir="./reward_model",

num_train_epochs=1,

per_device_train_batch_size=4,

gradient_accumulation_steps=4,

learning_rate=1e-5,

bf16=True,

max_length=2048,

logging_steps=10,

remove_unused_columns=False,

)

reward_trainer = RewardTrainer(

model=reward_model, # SFT 모델 기반

args=reward_config,

train_dataset=preference_dataset,

tokenizer=tokenizer,

peft_config=lora_config,

)

reward_trainer.train()

7.4 3단계: PPO로 정책 최적화

PPO (Proximal Policy Optimization)로 SFT 모델을 보상 신호로 최적화합니다.

**핵심 손실 함수:**

L = E[r(x,y)] - beta * KL(pi_theta || pi_ref)

- r(x,y): Reward Model이 부여한 보상

- KL 발산 페널티: SFT 모델(참조)에서 너무 멀리 벗어나지 못하게 제어

from trl import PPOTrainer, PPOConfig, AutoModelForCausalLMWithValueHead

ppo_config = PPOConfig(

model_name=sft_model_path,

learning_rate=1.41e-5,

batch_size=128,

mini_batch_size=4,

gradient_accumulation_steps=1,

optimize_cuda_cache=True,

early_stopping=True,

target_kl=0.1, # KL 발산 목표

ppo_epochs=4,

seed=42,

init_kl_coef=0.2,

adap_kl_ctrl=True,

)

정책 모델 (학습 대상)

policy_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)

참조 모델 (KL 계산용, 고정)

ref_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)

ppo_trainer = PPOTrainer(

config=ppo_config,

model=policy_model,

ref_model=ref_model,

tokenizer=tokenizer,

dataset=rl_dataset,

)

PPO 학습 루프

for batch in ppo_trainer.dataloader:

1. 정책 모델로 응답 생성

query_tensors = batch["input_ids"]

response_tensors = ppo_trainer.generate(

query_tensors,

max_new_tokens=256,

do_sample=True,

temperature=0.7,

)

2. Reward Model로 보상 계산

rewards = [

reward_model.compute_reward(q, r)

for q, r in zip(query_tensors, response_tensors)

]

3. PPO 업데이트

stats = ppo_trainer.step(query_tensors, response_tensors, rewards)

ppo_trainer.log_stats(stats, batch, rewards)

8. DPO (Direct Preference Optimization)

8.1 RLHF의 복잡성 문제

RLHF는 강력하지만 구현이 복잡합니다:

- 별도의 Reward Model 학습 필요

- PPO 튜닝이 까다로움 (많은 하이퍼파라미터)

- 학습 불안정성

- 4개의 모델을 동시에 메모리에 유지 (정책, 참조, 보상, 비평가)

8.2 DPO의 아이디어

Rafailov et al. (2023)의 DPO는 "보상 모델 없이도 선호도 데이터로 직접 최적화할 수 있다"는 것을 증명했습니다.

**핵심 인사이트:** RLHF의 최적 정책을 분석적으로 구하면, 보상 모델이 정책 자체의 로그 우도 비율로 표현됩니다. 이를 대입하면 선호도 데이터에 직접 적용할 수 있는 손실 함수를 얻습니다:

L_DPO = -E[log sigma(beta * (log pi(y_w|x) - log pi_ref(y_w|x)) -

beta * (log pi(y_l|x) - log pi_ref(y_l|x)))]

여기서:

- y_w: 선호되는 응답 (chosen)

- y_l: 선호되지 않는 응답 (rejected)

- pi: 학습 중인 정책

- pi_ref: 참조 정책 (SFT 모델, 고정)

- beta: KL 페널티 계수

8.3 선호도 데이터 형식

preference_data = {

"prompt": "파이썬에서 리스트 정렬하는 방법",

"chosen": "파이썬 리스트를 정렬하는 주요 방법:\n\n1. sort() 메서드: 원본 리스트를 제자리에서 정렬\n```python\nmy_list = [3, 1, 4, 1, 5]\nmy_list.sort() # [1, 1, 3, 4, 5]\n```\n\n2. sorted() 함수: 새 정렬된 리스트 반환...",

"rejected": "list.sort()를 쓰면 됩니다."

}

8.4 trl로 DPO 학습

from trl import DPOTrainer, DPOConfig

from datasets import load_dataset

DPO 설정

dpo_config = DPOConfig(

output_dir="./dpo_model",

num_train_epochs=3,

per_device_train_batch_size=4,

gradient_accumulation_steps=4,

learning_rate=5e-7, # 매우 낮은 학습률

bf16=True,

beta=0.1, # KL 페널티 계수

max_length=2048,

max_prompt_length=512,

remove_unused_columns=False,

logging_steps=10,

save_steps=100,

warmup_steps=100,

report_to="wandb",

)

선호도 데이터셋 로드

형식: {"prompt": ..., "chosen": ..., "rejected": ...}

preference_dataset = load_dataset("Anthropic/hh-rlhf", split="train")

def format_preference(sample):

HH-RLHF 형식 처리

return {

"prompt": sample["chosen"].rsplit("\nAssistant:", 1)[0] + "\nAssistant:",

"chosen": sample["chosen"].rsplit("\nAssistant:", 1)[1].strip(),

"rejected": sample["rejected"].rsplit("\nAssistant:", 1)[1].strip(),

}

formatted_dataset = preference_dataset.map(format_preference)

SFT 모델에서 DPO 시작

sft_model = AutoModelForCausalLM.from_pretrained(

sft_model_path,

torch_dtype=torch.bfloat16,

device_map="auto",

)

sft_model = get_peft_model(sft_model, lora_config)

dpo_trainer = DPOTrainer(

model=sft_model,

ref_model=None, # None이면 자동으로 참조 모델 생성

args=dpo_config,

train_dataset=formatted_dataset["train"],

eval_dataset=formatted_dataset["test"],

tokenizer=tokenizer,

peft_config=lora_config, # LoRA로 효율적 DPO

)

dpo_trainer.train()

dpo_trainer.save_model("./dpo_final")

8.5 RLHF vs DPO 비교

| 특성 | RLHF (PPO) | DPO |

| ------------- | ------------------ | -------------------- |

| 구현 복잡도 | 높음 | 낮음 |

| 필요 모델 수 | 4개 | 2개 |

| 학습 안정성 | 낮음 | 높음 |

| 메모리 사용 | 많음 | 보통 |

| 온라인 학습 | 가능 | 어려움 |

| 성능 | 일반적으로 더 좋음 | 비슷하거나 약간 낮음 |

| 실시간 피드백 | 가능 | 불가능 |

실용적으로 대부분의 팀에서 DPO를 먼저 시도하고, 충분하지 않을 때 RLHF를 고려합니다.

9. 데이터 준비

9.1 데이터 품질 기준

데이터 품질이 학습 성능의 80%를 결정합니다. 고품질 데이터의 기준:

1. **정확성:** 사실적 오류가 없어야 함

2. **완전성:** 질문에 완전히 답하는가

3. **명확성:** 모호하지 않고 이해하기 쉬운가

4. **형식 일관성:** 모든 예시가 동일한 형식 준수

5. **독성 없음:** 유해하거나 편향된 내용 없음

6. **중복 제거:** Near-duplicate 제거

9.2 ChatML 형식 처리

def create_chatml_prompt(conversation: list) -> str:

"""다중 턴 대화를 ChatML 형식으로 변환"""

messages = []

for turn in conversation:

role = turn["role"] # system, user, assistant

content = turn["content"]

messages.append(f"<|im_start|>{role}\n{content}<|im_end|>")

return "\n".join(messages) + "\n<|im_start|>assistant\n"

def tokenize_chatml_dataset(sample, tokenizer, max_length=2048):

"""응답 부분만 손실 계산하도록 레이블 마스킹"""

full_text = sample["text"]

tokenized = tokenizer(

full_text,

truncation=True,

max_length=max_length,

return_tensors="pt",

)

input_ids = tokenized["input_ids"][0]

레이블: 응답 부분만, 나머지는 -100 (손실 무시)

labels = input_ids.clone()

"assistant\n" 이전의 모든 토큰을 -100으로 마스킹

assistant_token_ids = tokenizer.encode(

"<|im_start|>assistant\n", add_special_tokens=False

)

assistant_start = None

for i in range(len(input_ids) - len(assistant_token_ids)):

if input_ids[i:i+len(assistant_token_ids)].tolist() == assistant_token_ids:

assistant_start = i + len(assistant_token_ids)

break

if assistant_start is not None:

labels[:assistant_start] = -100

return {"input_ids": input_ids, "labels": labels}

9.3 데이터 정제 파이프라인

from datasets import Dataset

def dedup_dataset(dataset: Dataset, text_field: str = "text") -> Dataset:

"""Near-duplicate 제거"""

seen_hashes = set()

filtered_indices = []

for i, sample in enumerate(dataset):

text_hash = hashlib.md5(sample[text_field].encode()).hexdigest()

if text_hash not in seen_hashes:

seen_hashes.add(text_hash)

filtered_indices.append(i)

return dataset.select(filtered_indices)

def filter_quality(sample: dict) -> bool:

"""기본 품질 필터링"""

text = sample.get("output", sample.get("text", ""))

너무 짧은 응답 제거

if len(text.split()) < 10:

return False

너무 긴 응답 제거 (이상 감지)

if len(text.split()) > 2000:

return False

URL만 있는 응답 제거

words = text.split()

url_count = sum(1 for w in words if w.startswith("http"))

if url_count / len(words) > 0.3:

return False

return True

파이프라인 실행

dataset = load_dataset("your_dataset")["train"]

dataset = dataset.filter(filter_quality)

dataset = dedup_dataset(dataset)

print(f"정제 후 데이터 수: {len(dataset)}")

10. 실전 파인튜닝 파이프라인

10.1 완전한 Llama 3 QLoRA 파인튜닝 코드

#!/usr/bin/env python3

"""

Llama 3 QLoRA Fine-tuning Pipeline

"""

from datasets import load_dataset

from transformers import (

AutoModelForCausalLM,

AutoTokenizer,

BitsAndBytesConfig,

EarlyStoppingCallback,

)

from peft import LoraConfig, prepare_model_for_kbit_training, get_peft_model

from trl import SFTTrainer, SFTConfig, DataCollatorForCompletionOnlyLM

==============================

설정

==============================

MODEL_NAME = "meta-llama/Meta-Llama-3.1-8B-Instruct"

OUTPUT_DIR = "./llama3-qlora-output"

DATASET_NAME = "iamtarun/python_code_instructions_18k_alpaca"

MAX_SEQ_LENGTH = 2048

LoRA 설정

LORA_R = 64

LORA_ALPHA = 16

LORA_DROPOUT = 0.05

학습 설정

BATCH_SIZE = 4

GRAD_ACCUM = 4

LEARNING_RATE = 2e-4

NUM_EPOCHS = 3

==============================

wandb 초기화

==============================

wandb.init(

project="llm-finetuning",

name=f"llama3-qlora-{DATASET_NAME.split('/')[-1]}",

config={

"model": MODEL_NAME,

"lora_r": LORA_R,

"lora_alpha": LORA_ALPHA,

"lr": LEARNING_RATE,

"epochs": NUM_EPOCHS,

},

)

==============================

4비트 양자화 설정

==============================

bnb_config = BitsAndBytesConfig(

load_in_4bit=True,

bnb_4bit_quant_type="nf4",

bnb_4bit_compute_dtype=torch.bfloat16,

bnb_4bit_use_double_quant=True,

)

==============================

모델 & 토크나이저 로드

==============================

print(f"Loading model: {MODEL_NAME}")

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

tokenizer.pad_token = tokenizer.eos_token

tokenizer.padding_side = "right"

model = AutoModelForCausalLM.from_pretrained(

MODEL_NAME,

quantization_config=bnb_config,

device_map="auto",

torch_dtype=torch.bfloat16,

)

model.config.use_cache = False

model.config.pretraining_tp = 1

model = prepare_model_for_kbit_training(model)

==============================

LoRA 설정

==============================

lora_config = LoraConfig(

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=LORA_DROPOUT,

bias="none",

task_type="CAUSAL_LM",

)

model = get_peft_model(model, lora_config)

model.print_trainable_parameters()

==============================

데이터셋 로드 & 처리

==============================

dataset = load_dataset(DATASET_NAME, split="train")

dataset = dataset.train_test_split(test_size=0.05, seed=42)

def format_alpaca_prompt(sample):

"""Python code 데이터셋 포맷팅"""

return {

"text": f"""### Instruction:

{sample['instruction']}

Input:

{sample.get('input', '')}

Response:

{sample['output']}"""

}

train_dataset = dataset["train"].map(format_alpaca_prompt)

eval_dataset = dataset["test"].map(format_alpaca_prompt)

==============================

학습 설정

==============================

training_config = SFTConfig(

output_dir=OUTPUT_DIR,

num_train_epochs=NUM_EPOCHS,

per_device_train_batch_size=BATCH_SIZE,

per_device_eval_batch_size=BATCH_SIZE,

gradient_accumulation_steps=GRAD_ACCUM,

gradient_checkpointing=True,

optim="paged_adamw_32bit",

learning_rate=LEARNING_RATE,

bf16=True,

max_grad_norm=0.3,

warmup_ratio=0.03,

lr_scheduler_type="cosine",

logging_steps=25,

save_strategy="epoch",

evaluation_strategy="epoch",

load_best_model_at_end=True,

metric_for_best_model="eval_loss",

report_to="wandb",

max_seq_length=MAX_SEQ_LENGTH,

dataset_text_field="text",

packing=False,

group_by_length=True, # 비슷한 길이끼리 배치 → 효율 향상

)

응답 부분만 손실 계산

response_template = "### Response:"

collator = DataCollatorForCompletionOnlyLM(

response_template, tokenizer=tokenizer

)

trainer = SFTTrainer(

model=model,

args=training_config,

train_dataset=train_dataset,

eval_dataset=eval_dataset,

data_collator=collator,

tokenizer=tokenizer,

callbacks=[EarlyStoppingCallback(early_stopping_patience=3)],

)

==============================

학습 실행

==============================

print("Starting training...")

trainer.train()

==============================

모델 저장

==============================

print("Saving model...")

trainer.save_model(OUTPUT_DIR)

tokenizer.save_pretrained(OUTPUT_DIR)

wandb.finish()

print(f"Training complete! Model saved to {OUTPUT_DIR}")

10.2 모델 병합 (LoRA 어댑터 병합)

배포를 위해 LoRA 어댑터를 베이스 모델에 병합합니다:

from peft import PeftModel

from transformers import AutoModelForCausalLM, AutoTokenizer

베이스 모델과 어댑터 로드

base_model = AutoModelForCausalLM.from_pretrained(

MODEL_NAME,

torch_dtype=torch.bfloat16,

device_map="cpu", # CPU에서 병합 (메모리 절약)

)

peft_model = PeftModel.from_pretrained(base_model, OUTPUT_DIR)

병합

print("Merging LoRA weights into base model...")

merged_model = peft_model.merge_and_unload()

저장

MERGED_DIR = "./llama3-merged"

merged_model.save_pretrained(MERGED_DIR, safe_serialization=True)

tokenizer = AutoTokenizer.from_pretrained(OUTPUT_DIR)

tokenizer.save_pretrained(MERGED_DIR)

print(f"Merged model saved to {MERGED_DIR}")

10.3 Ollama로 배포

Ollama Modelfile 생성

cat > Modelfile << 'EOF'

FROM ./llama3-merged

TEMPLATE """### Instruction:

{{ .Prompt }}

Response:

"""

PARAMETER stop "### Instruction:"

PARAMETER temperature 0.7

PARAMETER top_p 0.9

EOF

Ollama 모델 생성

ollama create my-llama3 -f Modelfile

테스트

ollama run my-llama3 "파이썬으로 퀵소트를 구현해줘"

10.4 vLLM으로 배포

from vllm import LLM, SamplingParams

병합된 모델 로드

llm = LLM(

model="./llama3-merged",

dtype="bfloat16",

max_model_len=4096,

tensor_parallel_size=1, # GPU 수

)

sampling_params = SamplingParams(

temperature=0.7,

top_p=0.9,

max_tokens=512,

)

배치 추론

prompts = [

"### Instruction:\n파이썬으로 피보나치 수열을 구현해줘\n\n### Response:\n",

"### Instruction:\nSQL 인젝션 공격에 대해 설명해줘\n\n### Response:\n",

]

outputs = llm.generate(prompts, sampling_params)

for output in outputs:

print(output.outputs[0].text)

print("---")

11. 평가 (Evaluation)

11.1 Perplexity

언어 모델의 기본 평가 지표. 낮을수록 좋습니다.

from transformers import AutoModelForCausalLM, AutoTokenizer

from datasets import load_dataset

def compute_perplexity(model, tokenizer, dataset, max_length=1024, stride=512):

"""슬라이딩 윈도우 방식의 Perplexity 계산"""

encodings = tokenizer(

"\n\n".join(dataset["text"][:100]),

return_tensors="pt"

)

seq_len = encodings.input_ids.size(1)

nlls = []

for begin_loc in range(0, seq_len, stride):

end_loc = min(begin_loc + max_length, seq_len)

trg_len = end_loc - begin_loc - (stride if begin_loc > 0 else 0)

input_ids = encodings.input_ids[:, begin_loc:end_loc].to(model.device)

target_ids = input_ids.clone()

target_ids[:, :-trg_len] = -100

with torch.no_grad():

outputs = model(input_ids, labels=target_ids)

neg_log_likelihood = outputs.loss * trg_len

nlls.append(neg_log_likelihood)

if end_loc == seq_len:

break

ppl = torch.exp(torch.stack(nlls).sum() / end_loc)

return ppl.item()

11.2 ROUGE (요약 평가)

from rouge_score import rouge_scorer

def evaluate_rouge(predictions: list, references: list) -> dict:

scorer = rouge_scorer.RougeScorer(

['rouge1', 'rouge2', 'rougeL'], use_stemmer=True

)

scores = {"rouge1": [], "rouge2": [], "rougeL": []}

for pred, ref in zip(predictions, references):

score = scorer.score(ref, pred)

scores["rouge1"].append(score["rouge1"].fmeasure)

scores["rouge2"].append(score["rouge2"].fmeasure)

scores["rougeL"].append(score["rougeL"].fmeasure)

return {k: sum(v)/len(v) for k, v in scores.items()}

11.3 LM-Eval Harness

OpenLM Research의 공개 평가 프레임워크로 다양한 벤치마크를 자동 평가합니다:

설치

pip install lm-eval

평가 실행

lm_eval --model hf \

--model_args pretrained=./llama3-merged,dtype=bfloat16 \

--tasks hellaswag,arc_easy,arc_challenge,winogrande \

--num_fewshot 0 \

--batch_size 8 \

--output_path ./eval_results

11.4 MT-Bench (대화 품질 평가)

GPT-4를 심사위원으로 사용하여 모델의 다중 턴 대화 품질을 1-10점으로 평가합니다:

FastChat 설치

pip install fschat

MT-Bench 평가

python -m fastchat.llm_judge.gen_model_answer \

--model-path ./llama3-merged \

--model-id llama3-finetuned \

--bench-name mt_bench

python -m fastchat.llm_judge.gen_judgment \

--model-list llama3-finetuned \

--judge-model gpt-4

12. 마치며

LLM 파인튜닝 기법의 발전은 놀랍습니다. 불과 몇 년 전만 해도 수백억 파라미터 모델의 파인튜닝은 초대형 GPU 클러스터에서만 가능했지만, 이제는 소비자용 GPU 한 장으로도 가능합니다.

이 가이드에서 다룬 핵심 기법 정리:

1. **Full Fine-tuning:** 최대 성능, 높은 리소스

2. **LoRA:** 저랭크 행렬 분해로 99%+ 파라미터 절약

3. **QLoRA:** 4비트 양자화 + LoRA, 단일 GPU로 7B~70B 학습

4. **Instruction Tuning:** 지시 따르기 능력 학습

5. **RLHF:** 인간 선호도로 정렬, 3단계 파이프라인

6. **DPO:** 보상 모델 없이 선호도 직접 최적화

**실용적인 권장사항:**

- 처음엔 QLoRA + DPO 조합이 가장 실용적

- 데이터 품질에 가장 많은 시간을 투자할 것

- 1,000개 고품질 예시 > 10,000개 저품질 예시

- Wandb로 모든 실험을 추적할 것

- Perplexity 하락이 반드시 품질 향상은 아님 (MT-Bench 등으로 실제 품질 검증)

참고 자료

- Hu et al. (2021). "LoRA: Low-Rank Adaptation of Large Language Models." — https://arxiv.org/abs/2106.09685

- Dettmers et al. (2023). "QLoRA: Efficient Finetuning of Quantized LLMs." — https://arxiv.org/abs/2305.14314

- Rafailov et al. (2023). "Direct Preference Optimization." — https://arxiv.org/abs/2305.18290

- Ouyang et al. (2022). "Training language models to follow instructions with human feedback." — https://arxiv.org/abs/2203.02155

- Zhou et al. (2023). "LIMA: Less Is More for Alignment." — https://arxiv.org/abs/2305.11206

- HuggingFace PEFT — https://huggingface.co/docs/peft/

- HuggingFace TRL — https://huggingface.co/docs/trl/

- PEFT GitHub — https://github.com/huggingface/peft

- LM Evaluation Harness — https://github.com/EleutherAI/lm-evaluation-harness

현재 단락 (1/807)

LLaMA 3, Mistral, Gemma 같은 강력한 오픈소스 LLM이 공개된 지금, 이를 특정 도메인이나 태스크에 맞게 파인튜닝하는 기술은 AI 엔지니어의 핵심 역량이 되었습니...

작성 글자: 0원문 글자: 25,881작성 단락: 0/807