9.2. Fine-Tuning LLMs
9.2.1. When to Fine-Tune?
Fine-tuning adapts a pre-trained model to a specific task or domain by continuing training on a curated dataset. It updates the model’s weights — unlike prompting, which only guides the model at inference time.
Before fine-tuning, always ask: can a well-crafted prompt solve this? Fine-tuning is expensive (compute, data collection, evaluation) and should be reserved for cases where prompting genuinely falls short.
| Situation | Recommendation |
|---|---|
| Task is well-covered by the base model | Prompt engineering first |
| Need a specific output format consistently | Structured output prompting |
| Domain vocabulary the model doesn’t know | Fine-tune |
| Need extreme consistency across thousands of calls | Fine-tune |
| Latency/cost requires a smaller model | Fine-tune a small model |
| Data is confidential and cannot go to an API | Fine-tune locally |
9.2.2. Types of Fine-Tuning
Full Fine-Tuning
All model parameters are updated. Maximally expressive but requires substantial GPU memory (70B model → ~140GB in fp16) and produces a separate full model copy per task.
Parameter-Efficient Fine-Tuning (PEFT)
Only a small subset of parameters — or newly added ones — are updated. The base model weights are frozen.
LoRA (Low-Rank Adaptation) is the dominant PEFT method. It inserts small trainable matrices \(A\) and \(B\) into each attention layer:
\[W' = W + \Delta W = W + BA\]
where \(B \in \mathbb{R}^{d \times r}\), \(A \in \mathbb{R}^{r \times k}\), and rank \(r \ll \min(d,k)\). Only \(A\) and \(B\) are trained — typically 0.1–1% of the original parameters.
QLoRA extends LoRA by quantizing the frozen base model to 4-bit precision, enabling fine-tuning of 70B models on a single consumer GPU.
| Method | Trainable params | GPU memory | Quality |
|---|---|---|---|
| Full fine-tuning | 100% | Very high | Best |
| LoRA | ~0.1–1% | Moderate | Near-full |
| QLoRA | ~0.1–1% | Low | Slightly below LoRA |
| Prompt tuning | <0.01% | Minimal | Task-dependent |
9.2.3. Dataset Preparation
Fine-tuning quality depends more on data quality than data quantity. A dataset of 500 carefully curated examples often outperforms 10,000 noisy ones.
The standard format for instruction fine-tuning is the prompt–completion pair:
import json
from datasets import Dataset
# Instruction fine-tuning format
examples = [
{
"instruction": "Classify the sentiment of the following review.",
"input": "The battery life is excellent but the screen is too dim.",
"output": "MIXED — positive sentiment toward battery life, negative toward display."
},
{
"instruction": "Extract all named entities from the text.",
"input": "Marie Curie was born in Warsaw in 1867.",
"output": '[{"entity": "Marie Curie", "type": "PERSON"}, {"entity": "Warsaw", "type": "GPE"}, {"entity": "1867", "type": "DATE"}]'
},
{
"instruction": "Summarize the following text in one sentence.",
"input": "Deep learning models based on the Transformer architecture have become the dominant approach in NLP. They are pre-trained on massive corpora and then fine-tuned for specific downstream tasks.",
"output": "Transformer-based deep learning models, pre-trained on large corpora and fine-tuned for specific tasks, now dominate NLP."
}
]
def format_prompt(example: dict) -> str:
if example["input"]:
return (f"### Instruction:\n{example['instruction']}\n\n"
f"### Input:\n{example['input']}\n\n"
f"### Response:\n{example['output']}")
return (f"### Instruction:\n{example['instruction']}\n\n"
f"### Response:\n{example['output']}")
for ex in examples:
print(format_prompt(ex))
print("\n" + "─"*60 + "\n")### Instruction:
Classify the sentiment of the following review.
### Input:
The battery life is excellent but the screen is too dim.
### Response:
MIXED — positive sentiment toward battery life, negative toward display.
────────────────────────────────────────────────────────────
### Instruction:
Extract all named entities from the text.
### Input:
Marie Curie was born in Warsaw in 1867.
### Response:
[{"entity": "Marie Curie", "type": "PERSON"}, ...]
DatasetDict({
train: Dataset({features: ['text'], num_rows: 2}),
test: Dataset({features: ['text'], num_rows: 1})
})
9.2.4. Fine-Tuning with LoRA and the Hugging Face Ecosystem
# pip install transformers peft accelerate bitsandbytes datasets trl
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
TrainingArguments,
BitsAndBytesConfig
)
from peft import LoraConfig, get_peft_model, TaskType
from trl import SFTTrainer
import torch
MODEL_NAME = "microsoft/phi-2" # small model (2.7B) — good for experimentation
# ── 1. Load tokenizer ──────────────────────────────────────
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token
# ── 2. Load model in 4-bit (QLoRA) ────────────────────────
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True
)
model.config.use_cache = False
# ── 3. Configure LoRA ──────────────────────────────────────
lora_config = LoraConfig(
r=16, # rank — higher = more capacity
lora_alpha=32, # scaling factor (alpha/r = effective LR scale)
target_modules=["q_proj", "v_proj"], # which layers to adapt
lora_dropout=0.05,
bias="none",
task_type=TaskType.CAUSAL_LM
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()trainable params: 2,097,152 || all params: 2,781,573,120 || trainable%: 0.0754
# ── 4. Training arguments ──────────────────────────────────
training_args = TrainingArguments(
output_dir="./nlp-lora-finetuned",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # effective batch size = 16
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_strategy="epoch",
evaluation_strategy="epoch",
warmup_ratio=0.03,
lr_scheduler_type="cosine",
report_to="none"
)
# ── 5. Train ───────────────────────────────────────────────
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
eval_dataset=dataset["test"],
tokenizer=tokenizer,
args=training_args,
dataset_text_field="text",
max_seq_length=512
)
trainer.train()
trainer.save_model("./nlp-lora-finetuned")
print("Training complete — model saved."){'loss': 2.3421, 'learning_rate': 0.0002, 'epoch': 1.0}
{'loss': 1.8934, 'learning_rate': 0.0001, 'epoch': 2.0}
{'loss': 1.5127, 'learning_rate': 0.0000, 'epoch': 3.0}
Training complete — model saved.
9.2.5. Inference with a Fine-Tuned Model
from peft import PeftModel
# Load base model + LoRA adapter
base_model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME, device_map="auto", trust_remote_code=True
)
model_ft = PeftModel.from_pretrained(base_model, "./nlp-lora-finetuned")
model_ft.eval()
def generate(prompt: str, max_new_tokens: int = 128) -> str:
inputs = tokenizer(prompt, return_tensors="pt").to(model_ft.device)
with torch.no_grad():
output = model_ft.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.1,
do_sample=True,
pad_token_id=tokenizer.eos_token_id
)
generated = output[0][inputs["input_ids"].shape[1]:]
return tokenizer.decode(generated, skip_special_tokens=True)
prompt = """### Instruction:
Classify the sentiment of the following review.
### Input:
The product arrived damaged and customer service was unhelpful.
### Response:
"""
print(generate(prompt))NEGATIVE — strongly negative sentiment toward both product quality and customer service.
9.2.6. When Fine-Tuning Fails
Fine-tuning is not a silver bullet. Common failure modes:
- Catastrophic forgetting — the model loses general capabilities while learning the task. Mitigate with a low learning rate, few epochs, and LoRA instead of full fine-tuning.
- Overfitting — the model memorizes training examples and fails to generalize. Mitigate with more diverse data, dropout, and early stopping.
- Data leakage — if test examples appear in training data, evaluation is invalid.
- Format hallucination — the model learns the task but ignores the output format. Fix by making format examples more consistent in the training data.
- Insufficient data — fewer than ~100 examples rarely produces meaningful improvement. Aim for 500–2,000 high-quality examples for most tasks.