Fine-tuning adapts a pretrained model to specific tasks or styles. Unlike RAG (external knowledge), it trains the model's weights itself.
When Fine-Tuning Makes Sense
Good Candidates ✅
- New style/tone: "I need my brand's voice" → Fine-tune on examples
- Specific task mastery: Consistent performance on narrow task
- Cost-sensitive inference: Deploy smaller model at fraction of GPT-4 cost
- Offline/local deployment: Can't call APIs
Bad Candidates ❌
- General knowledge gaps: Use RAG instead
- Structured outputs: Use function calling / structured generation
- Constantly changing data: Use RAG with vector search
- No budget for training: Just prompt GPT-4
Fine-Tuning Methods Compared
Full Fine-Tuning
Train all model weights.
- Requirements: 140 GB model × 4 (for gradients) = 560 GB RAM
- Cost: €10,000+
- Time: Days
- Quality: Best (100%)
❌ Only for well-funded teams with massive infrastructure.
LoRA (Low-Rank Adaptation) - The Sweet Spot
Train tiny "delta matrices" instead of all weights.
Original Layer: W [4096 × 4096] = 16M parameters
LoRA Delta: A [4096 × 8] + B [8 × 4096] = 64K parameters
250× fewer parameters!
- Requirements: ~175 GB (manageable)
- Cost: €50-200
- Time: 2-4 hours on RTX 4090
- Quality: 95% of full fine-tuning
- Tool: Unsloth (highly optimized)
✅ Best for most use cases.
QLoRA (Quantized LoRA)
LoRA + quantize base model to INT4.
- Requirements: ~70 GB (RTX 4090 possible!)
- Cost: €20-50
- Time: 1-2 hours
- Quality: 93-94% (slight loss from quantization)
- Tool: Unsloth with
load_in_4bit=True
✅ Works on consumer hardware.
DoRA (Decomposed LoRA)
Separate magnitude + direction, slightly better quality.
- Quality: +1-2% vs pure LoRA
- Cost: Similar to LoRA
- Maturity: Newer, fewer tools yet
Practical: Quick-Start LoRA with Unsloth
from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
from datasets import load_dataset
# 1. Load model
model, tokenizer = FastLanguageModel.from_pretrained(
model_name="unsloth/llama-2-7b-bnb-4bit",
max_seq_length=2048,
load_in_4bit=True, # QLoRA
)
# 2. Attach LoRA
model = FastLanguageModel.get_peft_model(
model,
r=16, # Rank
lora_alpha=16,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
bias="none",
use_gradient_checkpointing=True,
)
# 3. Load dataset
dataset = load_dataset("json", data_files="training_data.json")
# 4. Train
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
args=TrainingArguments(
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
warmup_steps=100,
num_train_epochs=1,
learning_rate=2e-4,
output_dir="lora_output",
optim="adamw_8bit",
max_steps=500, # ~30 minutes
),
packing=True,
max_seq_length=2048,
dataset_text_field="text",
)
trainer.train()
model.save_pretrained("lora-weights")
Timeline: 30 minutes total on RTX 4090
Dataset Preparation
Format: Instruction-Output
[
{
"instruction": "Explain machine learning",
"output": "ML is a subset of AI..."
},
{
"instruction": "What is a transformer?",
"output": "A transformer is an architecture..."
}
]
Size Guidelines
| Goal | Examples | Time | Cost |
|---|---|---|---|
| POC | 100 | 15 min | €10 |
| Good Style | 500 | 45 min | €30 |
| Solid Domain | 1000 | 1.5h | €50 |
| Professional | 5000 | 4h | €150 |
| State-of-Art | 10000+ | 8h+ | €300+ |
Hyperparameters Guide
Learning Rate:
Full Fine-Tuning: 5e-5 (all params trained, small)
LoRA: 2e-4 (only deltas, bigger)
QLoRA: 2e-4 to 5e-4 (even bigger okay)
Epochs:
Large dataset (>1000): 1-3
Small dataset (<500): 3-5
Batch Size:
Full FT: 8-16
LoRA: 16-32
QLoRA: 32-64
Warmup:
5-10% of total training steps
Weight Decay:
Standard: 0.01
More regularization: 0.05
Less regularization: 0.001
LoRA Rank:
Small changes: r=4-8
Standard: r=16 (industry default)
Large changes: r=32-64
Common Problems & Fixes
Training Diverges (Loss → NaN)
Cause: Learning rate too high or gradient explosion Fix: Reduce LR by 50%, add gradient clipping (max_grad_norm=1.0), increase warmup steps
Overfitting (Training loss ↓ but Validation stagnates)
Fix: Early stopping, increase weight decay, reduce epochs, use smaller rank
Hallucinations After Training
Cause: Data quality bad or too aggressive training Fix: Review dataset manually, reduce epochs, lower learning rate, increase weight decay
VRAM OOM
Fix: Reduce batch size, enable gradient checkpointing, use QLoRA, reduce max_seq_length
After Training: Merge or Keep Separate?
Option A: Separate (Production flexibility)
model.safetensors (140 GB) + adapter_model.safetensors (50 MB)
Pros: Multiple LoRAs on same model, reversible
Cons: Higher RAM at inference
Option B: Merge (Single file)
from peft import AutoPeftModelForCausalLM
merged = AutoPeftModelForCausalLM.from_pretrained(
"lora_weights",
device_map="cpu",
torch_dtype=torch.float16,
).merge_and_unload()
merged.save_pretrained("merged-model")
Pros: Simple deployment, single file Cons: Can't separate, larger file (but quantizable)
Cost Comparison (2026 Reality)
For 1 million inference calls:
| Method | Training | Inference/1K | Total |
|---|---|---|---|
| Fine-Tune + Self-Host | €200 | €0.001 | €201 |
| RAG + GPT-4 | €50 | €0.15 | €200+ |
| QLoRA + Deploy | €50 | €0.002 | €52 |
| GPT-4 API Only | €0 | €0.03 | €300 |
Breakeven: At 100K+ inferences/month, fine-tuning pays for itself.
Tools Comparison
| Tool | Ease | Speed | Features | Best For |
|---|---|---|---|---|
| Unsloth | Easy | Fast | LoRA, QLoRA | Quick prototyping |
| Axolotl | Medium | Medium | LoRA, DPO, complex configs | Production |
| HF TRL | Medium | Slow | LoRA, DPO, PPO | Research |
| Together.ai | Very Easy | N/A | Cloud training | Non-technical |
Fine-tuning is worth it when domain is specific, style matters, or inference at scale. QLoRA makes it accessible on consumer hardware. Start with 500-1000 good examples, see quality improvement.
Sources and Links
- Unsloth: Fast LLM Fine-tuning
- Axolotl: Multi-adapter Fine-tuning
- Hugging Face TRL: Train Language Models with Transformers
Advanced: Full Fine-Tuning for Serious Use Cases
When you need maximum quality (95%+ of target) and have resources:
# Full fine-tuning setup (requires high-end GPU)
import deepspeed
def full_fine_tune():
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b",
torch_dtype=torch.float16,
device_map="auto", # Multi-GPU
)
training_args = TrainingArguments(
output_dir="./finetuned_model",
num_train_epochs=3,
per_device_train_batch_size=8,
per_device_eval_batch_size=8,
gradient_accumulation_steps=4,
learning_rate=5e-5, # Lower for full FT
warmup_steps=500,
weight_decay=0.01,
save_strategy="epoch",
eval_strategy="epoch",
deepspeed="ds_config.json", # Multi-GPU memory management
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
)
trainer.train()
return model
Cost for 7B model:
- A100 80GB: €8,000 upfront, ~€2/hour operating = €150-300 total
- H100: €12,000 upfront, ~€3/hour operating = €250-500 total
When it's worth it:
- Production system serving 10M+ requests/month
- Proprietary domain worth €100K+ revenue impact
- Competitive advantage from custom model
Evaluation: How Good is Your Fine-Tuned Model?
def evaluate_fine_tuned_model(model, test_dataset, base_model=None):
"""Comprehensive evaluation."""
results = {
"perplexity": 0,
"bleu_score": 0,
"human_eval": 0,
"task_accuracy": 0,
}
# 1. Perplexity (lower is better)
with torch.no_grad():
losses = []
for batch in test_dataset:
logits = model(**batch).logits
loss = torch.nn.functional.cross_entropy(
logits.view(-1, logits.shape[-1]),
batch["labels"].view(-1)
)
losses.append(loss.item())
results["perplexity"] = torch.exp(torch.tensor(losses).mean()).item()
# 2. BLEU (for generative tasks)
from nltk.translate.bleu_score import corpus_bleu
predictions = []
references = []
for batch in test_dataset:
pred = model.generate(batch["input_ids"], max_length=100)
predictions.extend(tokenizer.batch_decode(pred))
references.extend(batch["expected_output"])
results["bleu_score"] = corpus_bleu(
[[ref.split()] for ref in references],
[pred.split() for pred in predictions]
)
# 3. Task-Specific Accuracy (if classification)
correct = 0
for batch in test_dataset:
logits = model(**batch).logits
predictions = logits.argmax(dim=-1)
correct += (predictions == batch["labels"]).sum().item()
results["task_accuracy"] = correct / len(test_dataset)
# 4. Compare vs Base Model
if base_model:
base_perplexity = compute_perplexity(base_model, test_dataset)
improvement = (base_perplexity - results["perplexity"]) / base_perplexity * 100
results["improvement_vs_base"] = f"{improvement:.1f}%"
return results
Target metrics:
- Perplexity: < 50 (lower is better)
- BLEU: > 0.3 (higher is better)
- Task accuracy: > 85% (domain-dependent)
Dataset Quality Matters More Than Size
# BAD: 10K low-quality examples
[
{"instruction": "hi", "output": "hello"},
{"instruction": "test", "output": "test response"},
{"instruction": "x", "output": "y"},
... # Lots of junk
]
# GOOD: 1K curated examples
[
{
"instruction": "Explain photosynthesis in simple terms",
"output": "Photosynthesis is the process where plants convert sunlight, water, and CO2 into glucose and oxygen. This provides energy for the plant's growth..."
},
{
"instruction": "What is the capital of France?",
"output": "Paris is the capital of France. It's located in northern France and has been the political and cultural center..."
},
... # All high-quality examples
]
# Result: 1K good → Better model than 10K bad
Quality checks:
- Are examples representative of real usage?
- Is the output correct and well-formed?
- Would you be happy to receive this response from an assistant?
Hyperparameter Tuning for Your Dataset
def recommend_hyperparameters(dataset_size, domain_specificity):
"""
dataset_size: number of training examples
domain_specificity: 0.0 (general) to 1.0 (very specific)
"""
if dataset_size < 100:
return {
"method": "LoRA",
"rank": 4,
"learning_rate": 1e-4,
"epochs": 5,
"warning": "Too small! Expected 500+ examples"
}
elif dataset_size < 500:
return {
"method": "QLoRA",
"rank": 8,
"learning_rate": 2e-4,
"epochs": 3,
"batch_size": 32
}
elif dataset_size < 5000:
lr = 2e-4 if domain_specificity > 0.7 else 5e-4
return {
"method": "LoRA",
"rank": 16,
"learning_rate": lr,
"epochs": 1-2,
"batch_size": 16
}
else:
return {
"method": "Full Fine-Tuning",
"learning_rate": 5e-5,
"epochs": 2-3,
"batch_size": 8,
"warmup_ratio": 0.1
}
# Usage
config = recommend_hyperparameters(dataset_size=1000, domain_specificity=0.8)
print(config)
# → {"method": "LoRA", "rank": 16, "learning_rate": 0.0002, "epochs": 2, ...}
Production Deployment Patterns
Pattern 1: Blue-Green Deployment
# Current (old fine-tuned model)
model_blue = load_model("production/llama-7b-v1")
# New (newly fine-tuned model)
model_green = load_model("production/llama-7b-v2")
# A/B test: 10% traffic to green
if random.random() < 0.1:
response = model_green.generate(prompt)
else:
response = model_blue.generate(prompt)
# Monitor green performance for 1 week
# If good: Swap to 100% green, make green → blue
# If bad: Rollback to all blue
Pattern 2: Ensemble (Multiple Models)
# Combine multiple fine-tuned models
models = [
load_model("lora/medical-domain"),
load_model("lora/legal-domain"),
load_model("lora/general-qa"),
]
def ensemble_generate(prompt, task_type):
# Use task-appropriate model
model = models[task_type] # 0=medical, 1=legal, 2=general
return model.generate(prompt)
Common Pitfalls
Pitfall 1: Catastrophic Forgetting
# WRONG: Fine-tune on medical texts only
# Result: Model becomes good at medical Q&A
# BUT forgets how to do general tasks!
# FIX: Mix datasets
training_data = [
{"task": "medical", "examples": 500},
{"task": "general_qa", "examples": 300},
{"task": "coding", "examples": 200},
]
# Prevents catastrophic forgetting
Pitfall 2: Overfitting to Exact Phrases
# BAD: Training examples too formulaic
[
{"input": "What is X?", "output": "X is..."},
{"input": "What is Y?", "output": "Y is..."},
... # 1000 of these exact patterns
]
# GOOD: Varied phrasings
[
{"input": "What is photosynthesis?", "output": "..."},
{"input": "Explain how photosynthesis works", "output": "..."},
{"input": "Tell me about photosynthesis", "output": "..."},
]
# Model learns concepts, not memorizes patterns
Pitfall 3: Not Validating on Hold-Out Set
# WRONG
train_on_all_data() # No validation!
# RIGHT
train_data, val_data = split_dataset(dataset, test_size=0.2)
trainer = Trainer(
model=model,
train_dataset=train_data,
eval_dataset=val_data, # Evaluate during training
)
# Check validation loss doesn't increase (overfitting indicator)
