Skip to content

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

|

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

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
import torch

# 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
import torch

# 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
import torch

# 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
import hashlib


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
"""

import os
import torch
import wandb
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
import torch

# 베이스 모델과 어댑터 로드
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

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

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
import math

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 등으로 실제 품질 검증)

참고 자료

LLM Fine-tuning Complete Guide: Master LoRA, QLoRA, RLHF, and DPO

LLM Fine-tuning Complete Guide: Master LoRA, QLoRA, RLHF, and DPO

With powerful open-source LLMs like LLaMA 3, Mistral, and Gemma now publicly available, fine-tuning them for specific domains and tasks has become a core skill for AI engineers. This guide covers every major LLM fine-tuning technique from Full Fine-tuning through LoRA, QLoRA, RLHF, and DPO — with complete, production-ready code at every step.


1. Why Fine-tune?

1.1 Limitations of Pretrained Models

Large-scale LLMs are pretrained on vast internet text and acquire impressive general capabilities. Used directly, however, they have several practical limitations:

Domain knowledge gaps: Even GPT-4 does not know your company's internal documentation or the latest medical protocols published after its training cutoff.

No instruction-following by default: Base models are trained to predict the next token — not to follow instructions. A base model asked to "find the bug in this code" may just continue generating plausible text rather than helping.

Output format control: Making a model reliably produce a specific JSON schema or markdown structure is extremely difficult with prompting alone.

Safety and alignment issues: Pretrained models can generate harmful content or behave inconsistently when faced with edge-case inputs.

1.2 Benefits of Fine-tuning

Fine-tuning is the process of further training a pretrained model's weights on new data for a specific purpose:

  • Domain adaptation: Acquire specialized terminology, knowledge, and style
  • Task specialization: Maximize performance on classification, extraction, or generation
  • Behavior control: Learn desired response style, format, and safety boundaries
  • Cost efficiency: A fine-tuned small model can replace expensive large model API calls

1.3 Fine-tuning vs Prompt Engineering

Prompt engineering is fast and free — but limited:

CriterionPrompt EngineeringFine-tuning
Implementation effortLowMedium-High
CostRuntime token costOne-time training cost
Performance ceilingLimited by base modelCan exceed base
Output consistencyLowHigh
PrivacyData sent to APILocal execution possible
LatencyLong prompts = slowShort inputs possible

1.4 Types of Fine-tuning

Fine-tuning approaches fall into three major categories:

  1. Full Fine-tuning: Update all parameters (most powerful, most expensive)
  2. PEFT (Parameter-Efficient Fine-Tuning): Update only a small fraction of parameters
    • LoRA, QLoRA, Prefix Tuning, Adapter, etc.
  3. RLHF/DPO: Learn from human preference data (alignment/safety)

2. Full Fine-tuning

2.1 Overview

Full fine-tuning updates every model parameter on new data. Theoretically the most powerful approach, but practical limitations make it rarely the first choice.

2.2 Memory Requirements

Full fine-tuning a 7B parameter model requires approximately:

  • Model weights (BF16): 7B × 2 bytes = 14 GB
  • Gradients: 14 GB (same as weights)
  • Optimizer states (AdamW): 14 GB × 2 = 28 GB (first and second moments)
  • Activations: Several GB depending on batch size and sequence length
  • Total: 60+ GB

A single A100 80GB is barely enough for a 7B full fine-tune. A 70B model requires multiple high-end GPUs.

2.3 When to Use Full Fine-tuning

  • Domain is very different from pretraining distribution (highly specialized medical/legal text)
  • Sufficient GPU resources are available
  • Maximum performance is absolutely required
  • Continual pretraining on new text corpora

2.4 Catastrophic Forgetting

The biggest risk with full fine-tuning is catastrophic forgetting: training on new data degrades previously acquired knowledge.

Mitigation strategies:

  • Low learning rate: Use 1e-5 or below to preserve existing knowledge
  • Data mixing: Mix original pretraining data with new data
  • EWC (Elastic Weight Consolidation): Regularize changes to important weights
  • Use PEFT instead: Training only new parameters has no catastrophic forgetting
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer
from datasets import load_dataset
import torch

# Full fine-tuning example
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,          # Low LR to preserve knowledge
    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 The Core Idea

LoRA (Hu et al., 2021) is a game-changer for LLM fine-tuning. The key observation: weight changes during fine-tuning have intrinsically low rank.

Instead of updating the full weight matrix W (d×k), represent the change as the product of two small matrices B (d×r) and A (r×k):

W' = W + delta_W = W + B * A

Here, r is the rank and is chosen to be much smaller than both d and k.

Parameter count comparison:

  • Original delta_W: d × k parameters
  • LoRA B + A: r × (d + k) parameters
  • For d=k=4096, r=16: 16,777,216 vs 131,072 — a 128x reduction!

3.2 Formula Details

Initialization:

  • A: Gaussian random initialization
  • B: Zero initialization (ensures delta_W = 0 at training start)

Forward pass:

h = x * W^T + x * (B * A)^T * (alpha / r)

The alpha/r ratio acts like a learning rate multiplier for the LoRA updates.

3.3 Choosing Rank r

Rank r is LoRA's most important hyperparameter:

  • r=4 or r=8: Lightweight experiments, simple tasks
  • r=16: Good balance for most tasks — recommended starting point
  • r=32 or r=64: Complex tasks requiring more capacity
  • r=128+: When performance close to full fine-tuning is needed

Start with r=16 and increase if performance is insufficient.

3.4 The alpha Hyperparameter

Alpha scales the LoRA update magnitude. The actual scale factor applied is alpha/r:

  • alpha = r: scale factor = 1 (common choice)
  • alpha = 2r: scale factor = 2 (stronger LoRA updates)
  • Can be tuned independently of the learning rate

3.5 Which Layers to Apply LoRA To?

The original paper applied LoRA only to Q and V projection matrices. Experiments show:

  • Q, K, V, O (all attention projections): Generally good
  • + MLP layers: Often better for complex tasks
  • All linear layers: Near full fine-tuning performance

HuggingFace PEFT defaults to Q and V. For complex tasks, applying to all linear layers is recommended.

3.6 LoRA with HuggingFace PEFT

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

# LoRA configuration
lora_config = LoraConfig(
    r=16,
    lora_alpha=32,                 # alpha = 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,
)

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",
)

# Create LoRA model
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# Example output: trainable params: 41,943,040 || all params: 3,254,779,904 || trainable%: 1.29%

# Inspect trainable parameters
for name, param in model.named_parameters():
    if param.requires_grad:
        print(f"Trainable: {name}, shape: {param.shape}")

4. QLoRA (Quantized LoRA)

4.1 What Is QLoRA?

QLoRA (Dettmers et al., 2023) makes LoRA even more memory-efficient by training LoRA adapters on top of a 4-bit quantized base model.

Three core techniques:

  1. 4-bit NF4 quantization: Compress base model weights to 4 bits
  2. Double quantization: Quantize the quantization constants themselves
  3. Paged optimizers: Page optimizer states between CPU RAM and GPU

4.2 4-bit NF4 Quantization

NF4 (NormalFloat4) is a 4-bit data type optimized for normally distributed weights — which LLM weights tend to follow.

NF4 is information-theoretically optimal for normally distributed data: each quantization bin covers equal probability mass.

Memory savings:

  • FP16 → INT4: 4x compression
  • 70B model: 140GB (FP16) → 35GB (4-bit) — trainable on 2×24GB consumer GPUs

4.3 Double Quantization

Quantization constants themselves occupy memory (roughly 32 bits per group of 64 weights). Double Quantization quantizes these constants to 8 bits, saving approximately 0.37 bits per parameter.

4.4 Paged Optimizers

During training of long sequences, peak memory spikes can cause OOM errors. Paged Optimizers use NVIDIA unified memory to automatically offload optimizer states to CPU RAM when GPU memory is full, then page them back when needed.

4.5 Complete QLoRA Training Code

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
import torch

# 4-bit quantization config
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# Load model in 4-bit
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"

# Prepare model for k-bit training
# (casts LayerNorm to FP32, handles embedding layers, etc.)
model = prepare_model_for_kbit_training(model)

# LoRA config
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()

# Dataset preparation - Alpaca format
dataset = load_dataset("tatsu-lab/alpaca", split="train")

def format_instruction(sample):
    instruction = sample["instruction"]
    input_text = sample.get("input", "")
    output = sample["output"]

    if input_text:
        text = f"""### Instruction:
{instruction}

### Input:
{input_text}

### Response:
{output}"""
    else:
        text = f"""### Instruction:
{instruction}

### Response:
{output}"""

    return {"text": text}

formatted_dataset = dataset.map(format_instruction)

# Training config
training_args = TrainingArguments(
    output_dir="./qlora_output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    gradient_checkpointing=True,       # Save memory
    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",
)

# Only compute loss on response tokens
response_template = "### Response:"
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()
trainer.save_model("./qlora_adapter")
tokenizer.save_pretrained("./qlora_adapter")

5. Other PEFT Methods

5.1 Prefix Tuning

Prefix Tuning prepends learnable "virtual token" embeddings to K and V at every Transformer layer. Base model weights are frozen entirely.

from peft import PrefixTuningConfig, get_peft_model

prefix_config = PrefixTuningConfig(
    task_type="CAUSAL_LM",
    num_virtual_tokens=20,
    prefix_projection=True,   # Project prefix through MLP
)

model = get_peft_model(base_model, prefix_config)

Prefix Tuning performs well on seq2seq tasks but generally underperforms LoRA.

5.2 Prompt Tuning

The simplest PEFT method. Adds only learnable soft prompt embeddings before the input — no changes to the model itself.

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="Classify the sentiment:",
    tokenizer_name_or_path=model_name,
)

Performs better as model size increases. Extremely parameter-efficient but has a lower performance ceiling.

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

IA3 achieves LoRA-like performance with roughly 1/10 the parameters. It multiplies learned vectors into K, V, and the FFN activations.

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 layers insert small bottleneck modules inside each Transformer layer (down-projection → nonlinearity → up-projection). Proposed before LoRA, they introduce slight inference latency compared to LoRA, which can be merged into the base model weights.


6. Instruction Tuning

6.1 What Is Instruction Tuning?

A base LLM is trained to continue text — not to follow instructions. Instruction tuning fine-tunes the model on instruction-response pairs so it learns to be helpful. Stanford Alpaca (2023) popularized this approach.

6.2 Dataset Formats

Alpaca format:

{
  "instruction": "Find the greatest common divisor of two numbers.",
  "input": "24, 36",
  "output": "The GCD of 24 and 36 is 12.\n\nCalculation:\n- 24 = 2^3 * 3\n- 36 = 2^2 * 3^2\n- GCD = 2^2 * 3 = 12"
}

ChatML format (OpenAI standard):

<|im_start|>system
You are a helpful AI assistant.<|im_end|>
<|im_start|>user
Implement quicksort in Python.<|im_end|>
<|im_start|>assistant
Here's a quicksort implementation in Python...

ShareGPT format (conversational):

{
  "conversations": [
    { "from": "human", "value": "question text" },
    { "from": "gpt", "value": "answer text" },
    { "from": "human", "value": "follow-up question" },
    { "from": "gpt", "value": "follow-up answer" }
  ]
}

6.3 Data Quality Over Quantity

The LIMA paper (Less is More for Alignment, 2023) showed that just 1,000 high-quality examples are sufficient to produce strong instruction-following behavior. Quality matters far more than quantity.

High-quality instruction data criteria:

  • Diversity: Covers many task types and domains
  • Clarity: Instructions are unambiguous
  • Accuracy: Responses are factually correct
  • Consistency: Same response style across similar instructions
  • Appropriate length: Only as long as necessary

6.4 Formatting and Tokenization

def format_alpaca_prompt(sample: dict) -> str:
    instruction = sample["instruction"]
    input_text = sample.get("input", "")
    output = sample["output"]

    if input_text:
        return f"""### Instruction:
{instruction}

### Input:
{input_text}

### Response:
{output}"""
    else:
        return f"""### Instruction:
{instruction}

### Response:
{output}"""


def tokenize_with_label_masking(sample, tokenizer, max_length=2048):
    """Mask everything before the response — only compute loss on the answer"""
    full_text = sample["text"]
    tokenized = tokenizer(
        full_text, truncation=True, max_length=max_length, return_tensors="pt"
    )

    input_ids = tokenized["input_ids"][0]
    labels = input_ids.clone()

    response_start_str = "### Response:"
    response_token_ids = tokenizer.encode(response_start_str, add_special_tokens=False)

    for i in range(len(input_ids) - len(response_token_ids)):
        if input_ids[i:i+len(response_token_ids)].tolist() == response_token_ids:
            labels[:i + len(response_token_ids)] = -100
            break

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

7. RLHF (Reinforcement Learning from Human Feedback)

7.1 RLHF Overview

RLHF is the alignment technique behind ChatGPT, Claude, and Gemini. It trains models to be more helpful, harmless, and honest by learning from human preference judgments.

Three-stage pipeline:

  1. SFT (Supervised Fine-Tuning): Fine-tune on high-quality demonstration data
  2. Reward Model training: Train a model to score response quality
  3. RL optimization (PPO): Optimize the policy (LLM) using reward signals

7.2 Stage 1: Supervised Fine-Tuning

Fine-tune the base model on high-quality conversation data. This stage teaches basic instruction-following.

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()
trainer.save_model("./sft_model")

7.3 Stage 2: Reward Model Training

The Reward Model (RM) learns from human comparisons of two responses. It uses the same LLM architecture with an added linear head that outputs a scalar reward.

Preference data format:

{
  "prompt": "Explain climate change",
  "chosen": "Climate change refers to long-term shifts in global temperatures... (detailed, accurate)",
  "rejected": "It's just the Earth getting warmer. (vague, incomplete)"
}
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,    # Based on SFT model
    args=reward_config,
    train_dataset=preference_dataset,
    tokenizer=tokenizer,
    peft_config=lora_config,
)

reward_trainer.train()

7.4 Stage 3: PPO Policy Optimization

PPO (Proximal Policy Optimization) optimizes the SFT model using the reward signal.

Core objective:

L = E[r(x, y)] - beta * KL(pi_theta || pi_ref)
  • r(x, y): reward from the Reward Model
  • KL divergence penalty: prevents the policy from drifting too far from the SFT reference
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,
    ppo_epochs=4,
    seed=42,
    init_kl_coef=0.2,
    adap_kl_ctrl=True,
)

policy_model = AutoModelForCausalLMWithValueHead.from_pretrained(sft_model_path)
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 training loop
for batch in ppo_trainer.dataloader:
    query_tensors = batch["input_ids"]

    # 1. Generate responses from policy
    response_tensors = ppo_trainer.generate(
        query_tensors,
        max_new_tokens=256,
        do_sample=True,
        temperature=0.7,
    )

    # 2. Score with Reward Model
    rewards = [
        reward_model.compute_reward(q, r)
        for q, r in zip(query_tensors, response_tensors)
    ]

    # 3. PPO update
    stats = ppo_trainer.step(query_tensors, response_tensors, rewards)
    ppo_trainer.log_stats(stats, batch, rewards)

8. DPO (Direct Preference Optimization)

8.1 RLHF Complexity Problem

RLHF is powerful but complex:

  • Requires training a separate Reward Model
  • PPO has many sensitive hyperparameters
  • Training instability is common
  • Requires 4 models in memory simultaneously (policy, reference, reward, critic)

8.2 The DPO Insight

Rafailov et al. (2023) proved that you can optimize directly on preference data without a reward model. The key insight: the optimal RLHF policy can be expressed analytically in terms of the policy's own log-likelihood ratios. Substituting this back gives a loss function that operates directly on preference pairs:

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))
)]

Where:

  • y_w: preferred response (chosen)
  • y_l: dispreferred response (rejected)
  • pi: model being trained
  • pi_ref: reference policy (frozen SFT model)
  • beta: KL penalty coefficient

8.3 Preference Data Format

preference_data = {
    "prompt": "How do I sort a list in Python?",
    "chosen": "Python provides two main ways to sort lists:\n\n1. The sort() method — sorts in place:\n```python\nmy_list = [3, 1, 4, 1, 5]\nmy_list.sort()  # modifies my_list directly\n```\n\n2. The sorted() function — returns a new sorted list:\n```python\noriginal = [3, 1, 4, 1, 5]\nsorted_list = sorted(original)  # original unchanged\n```\n\nBoth support reverse=True and a key function for custom sorting.",
    "rejected": "Use list.sort() or sorted()."
}

8.4 DPO Training with trl

from trl import DPOTrainer, DPOConfig
from datasets import load_dataset

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,            # Very low LR for DPO
    bf16=True,
    beta=0.1,                      # KL penalty coefficient
    max_length=2048,
    max_prompt_length=512,
    remove_unused_columns=False,
    logging_steps=10,
    save_steps=100,
    warmup_steps=100,
    report_to="wandb",
)

# Load preference dataset
# Format: {"prompt": ..., "chosen": ..., "rejected": ...}
preference_dataset = load_dataset("Anthropic/hh-rlhf", split="train")

def format_hh_rlhf(sample):
    """Parse HH-RLHF dataset format"""
    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_hh_rlhf)

# Start DPO from SFT model
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 creates reference automatically
    args=dpo_config,
    train_dataset=formatted_dataset["train"],
    eval_dataset=formatted_dataset["test"],
    tokenizer=tokenizer,
    peft_config=lora_config,
)

dpo_trainer.train()
dpo_trainer.save_model("./dpo_final")

8.5 RLHF vs DPO Comparison

PropertyRLHF (PPO)DPO
Implementation complexityHighLow
Models in memory42
Training stabilityLowHigh
Memory usageHighModerate
Online learningYesDifficult
PerformanceGenerally higherComparable or slightly lower
Real-time feedbackYesNo

In practice, most teams start with DPO for its simplicity, then consider RLHF only if DPO is insufficient.


9. Data Preparation

9.1 Data Quality Standards

Data quality determines 80% of fine-tuning performance. High-quality data criteria:

  1. Accuracy: No factual errors
  2. Completeness: Fully answers the question
  3. Clarity: Unambiguous and easy to understand
  4. Format consistency: All examples follow the same format
  5. Non-toxic: No harmful or biased content
  6. Deduplicated: Near-duplicates removed

9.2 ChatML Format Processing

def create_chatml_prompt(conversation: list) -> str:
    """Convert multi-turn conversation to ChatML format"""
    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_with_response_masking(sample, tokenizer, max_length=2048):
    """Only compute loss on assistant turns"""
    full_text = sample["text"]
    tokenized = tokenizer(
        full_text, truncation=True, max_length=max_length, return_tensors="pt"
    )

    input_ids = tokenized["input_ids"][0]
    labels = input_ids.clone()

    assistant_token_ids = tokenizer.encode(
        "<|im_start|>assistant\n", add_special_tokens=False
    )
    end_token_ids = tokenizer.encode("<|im_end|>", add_special_tokens=False)

    in_assistant = False
    i = 0
    while i < len(input_ids):
        if input_ids[i:i+len(assistant_token_ids)].tolist() == assistant_token_ids:
            in_assistant = True
            labels[i:i+len(assistant_token_ids)] = -100
            i += len(assistant_token_ids)
            continue
        if input_ids[i:i+len(end_token_ids)].tolist() == end_token_ids and in_assistant:
            in_assistant = False
        if not in_assistant:
            labels[i] = -100
        i += 1

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

9.3 Data Cleaning Pipeline

from datasets import Dataset
import hashlib
import re


def deduplicate_dataset(dataset: Dataset, text_field: str = "text") -> Dataset:
    """Remove near-duplicates by MD5 hash"""
    seen = set()
    keep = []
    for i, sample in enumerate(dataset):
        h = hashlib.md5(sample[text_field].encode()).hexdigest()
        if h not in seen:
            seen.add(h)
            keep.append(i)
    return dataset.select(keep)


def quality_filter(sample: dict) -> bool:
    """Basic quality filtering"""
    text = sample.get("output", sample.get("text", ""))
    words = text.split()

    # Too short
    if len(words) < 10:
        return False

    # Suspiciously long
    if len(words) > 2000:
        return False

    # Mostly URLs
    url_count = sum(1 for w in words if w.startswith("http"))
    if len(words) > 0 and url_count / len(words) > 0.3:
        return False

    # Mostly numbers (unlikely to be useful instruction data)
    num_count = sum(1 for w in words if re.match(r'^\d+$', w))
    if len(words) > 0 and num_count / len(words) > 0.5:
        return False

    return True


# Run pipeline
raw_dataset = load_dataset("your_dataset")["train"]
filtered = raw_dataset.filter(quality_filter)
deduped = deduplicate_dataset(filtered)
print(f"After cleaning: {len(deduped)} examples (was {len(raw_dataset)})")

10. Production Fine-tuning Pipeline

10.1 Complete Llama 3 QLoRA Fine-tuning

#!/usr/bin/env python3
"""
Production Llama 3 QLoRA Fine-tuning Pipeline
"""

import os
import torch
import wandb
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


# ==============================
# Configuration
# ==============================
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_R = 64
LORA_ALPHA = 16
LORA_DROPOUT = 0.05

BATCH_SIZE = 4
GRAD_ACCUM = 4
LEARNING_RATE = 2e-4
NUM_EPOCHS = 3

# ==============================
# Initialize W&B
# ==============================
wandb.init(
    project="llm-finetuning",
    name=f"llama3-qlora-code",
    config={
        "model": MODEL_NAME,
        "lora_r": LORA_R,
        "lora_alpha": LORA_ALPHA,
        "lr": LEARNING_RATE,
        "epochs": NUM_EPOCHS,
    },
)

# ==============================
# 4-bit quantization
# ==============================
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

# ==============================
# Load model and tokenizer
# ==============================
print(f"Loading {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 setup
# ==============================
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
# ==============================
dataset = load_dataset(DATASET_NAME, split="train")
dataset = dataset.train_test_split(test_size=0.05, seed=42)

def format_prompt(sample):
    return {
        "text": f"""### Instruction:
{sample['instruction']}

### Input:
{sample.get('input', '')}

### Response:
{sample['output']}"""
    }

train_dataset = dataset["train"].map(format_prompt)
eval_dataset = dataset["test"].map(format_prompt)

# ==============================
# Training
# ==============================
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...")
trainer.save_model(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
wandb.finish()
print(f"Done! Saved to {OUTPUT_DIR}")

10.2 Merging LoRA Adapters

Merge LoRA weights into the base model for deployment:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# Load base model (on CPU to save VRAM)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    torch_dtype=torch.bfloat16,
    device_map="cpu",
)

peft_model = PeftModel.from_pretrained(base_model, OUTPUT_DIR)

print("Merging LoRA weights...")
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 Deploy with Ollama

# Create Modelfile
cat > Modelfile << 'EOF'
FROM ./llama3-merged

TEMPLATE """### Instruction:
{{ .Prompt }}

### Response:
"""

PARAMETER stop "### Instruction:"
PARAMETER temperature 0.7
PARAMETER top_p 0.9
EOF

# Build and test
ollama create my-llama3 -f Modelfile
ollama run my-llama3 "Implement quicksort in Python"

10.4 Deploy with vLLM

from vllm import LLM, SamplingParams

llm = LLM(
    model="./llama3-merged",
    dtype="bfloat16",
    max_model_len=4096,
    tensor_parallel_size=1,
)

sampling_params = SamplingParams(
    temperature=0.7,
    top_p=0.9,
    max_tokens=512,
)

prompts = [
    "### Instruction:\nImplement a binary search tree in Python\n\n### Response:\n",
    "### Instruction:\nExplain the CAP theorem\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

The fundamental language model metric. Lower is better.

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import math


def compute_perplexity(
    model,
    tokenizer,
    texts: list,
    max_length: int = 1024,
    stride: int = 512,
) -> float:
    """Sliding window perplexity computation"""
    combined_text = "\n\n".join(texts[:100])
    encodings = tokenizer(combined_text, 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 (Summarization)

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's open-source evaluation framework for automated benchmark evaluation:

pip install lm-eval

lm_eval --model hf \
    --model_args pretrained=./llama3-merged,dtype=bfloat16 \
    --tasks hellaswag,arc_easy,arc_challenge,winogrande,mmlu \
    --num_fewshot 0 \
    --batch_size 8 \
    --output_path ./eval_results

11.4 MT-Bench

Uses GPT-4 as a judge to score multi-turn conversation quality from 1-10:

pip install fschat

# Generate model answers
python -m fastchat.llm_judge.gen_model_answer \
    --model-path ./llama3-merged \
    --model-id llama3-finetuned \
    --bench-name mt_bench

# GPT-4 judgment
python -m fastchat.llm_judge.gen_judgment \
    --model-list llama3-finetuned \
    --judge-model gpt-4

# Show results
python -m fastchat.llm_judge.show_result \
    --model-list llama3-finetuned

Summary

The landscape of LLM fine-tuning has transformed dramatically. Training that required large GPU clusters just a few years ago can now be done on a single consumer GPU.

Core techniques covered:

  1. Full Fine-tuning: Maximum performance, maximum resources
  2. LoRA: 99%+ parameter reduction via low-rank matrix decomposition
  3. QLoRA: 4-bit quantization + LoRA — enables 7B–70B training on one GPU
  4. Instruction Tuning: Teaching instruction-following behavior
  5. RLHF: Human preference alignment via three-stage pipeline
  6. DPO: Direct preference optimization without a reward model

Practical recommendations:

  • Start with QLoRA + DPO — the most practical combination for most teams
  • Invest most of your time in data quality, not hyperparameter tuning
  • 1,000 high-quality examples outperform 10,000 low-quality ones
  • Track every experiment with Weights and Biases
  • Perplexity improvements do not always mean better user experience — validate with MT-Bench

References