Fine-tuning LLMs starts with a simple problem: a pre-trained Large Language Model (LLM) is a generalist. It knows a little about everything, but it is not an expert at your one specific job. When you fine tune an LLM, you train that generalist further on your own data and turn it into a specialist. Here is the part that surprises people: you do not have to retrain the whole model.
Techniques like LoRA (Low-Rank Adaptation) and QLoRA (Quantized LoRA) let you fine-tune a model with billions of parameters on a single consumer Graphics Processing Unit (GPU), updating less than 1% of the weights. That one shift changes the economics of custom AI from “you need a research lab” to “you need a laptop with a decent GPU.” This is your hands-on fine tune LLM guide.
“We can reduce the number of trainable parameters by 10,000 times and the GPU memory requirement by 3 times, with no extra inference latency.”
Edward J. Hu and co-authors, the original LoRA paper (2021)
Last Updated: July 2026 | Tested on: Python 3.14.6, transformers 5.12.1, PyTorch 2.12.1 (Central Processing Unit (CPU)), peft 0.19.1, bitsandbytes 0.49.2, trl 1.6.0 | Difficulty: Expert | Reading Time: 19 minutes
- LLM API and function calling tutorial
- Hugging Face tutorial
- A GPU with 8+ GB VRAM (or the Google Colab free tier)
pip install transformers peft bitsandbytes datasets trl(the GPU training code in this post needs a CUDA GPU; bitsandbytes 4-bit loading does not run on CPU)
Think of a new hire who already knows the language and the basics of the job, but has never seen how your team writes things up. You do not send them back to university. You hand them a folder of past examples and say “match this style.” That is fine-tuning. The model already speaks fluent English and writes decent code. You just show it a few hundred examples of the exact inputs and outputs you want, and it starts following your house style with simpler prompts.
Modern fine-tuning does not need a huge compute budget. LoRA lets you fine-tune a billion-parameter model on a single GPU by training only a tiny slice of the weights. This post walks through when fine-tuning is actually worth it, how to prepare clean training data, how LoRA and QLoRA work, and how to merge and deploy the result.
By the end you will know how to fine-tune an open-source model with LoRA, and, just as important, when fine-tuning is the wrong tool and better prompting or RAG (Retrieval-Augmented Generation) would be cheaper and faster.
Table of Contents
When Fine-Tuning Beats Prompt Engineering
Picture teaching someone to cook in your kitchen. Prompt engineering is leaving a recipe card on the counter: quick to write and easy to change. Fine-tuning is sending them to a class that drills your exact style until it becomes second nature. RAG is handing them a reference cookbook to look things up in while they work. Good prompt engineering solves most problems, and it should always be your first move because it is instant and free to change.
But sometimes no amount of prompt crafting gets you there: the output style you want is hard to put into words, you need the same behavior across thousands of tricky edge cases, or your domain vocabulary (think medical or legal terms) trips up the base model. That is the moment when fine-tuning LLMs starts to earn its cost. The script below lays out a simple way for a developer named Rahul to decide which of the three approaches his team should reach for.
📄 when_to_fine_tune.py: prompt engineering vs fine-tuning vs RAG
# Rahul builds a decision framework for his team
decision_matrix = {
"Use Prompt Engineering when": [
"You have fewer than 100 labeled examples",
"The task is well-defined and simple (classification, extraction)",
"You need to iterate quickly (change behavior in minutes)",
"The base model already performs at 80%+ on your task",
"Cost per query is acceptable (tokens for few-shot examples)",
],
"Use Fine-Tuning when": [
"You have 500+ high-quality labeled examples",
"Prompt engineering plateaus below acceptable quality",
"You need consistent style/format across thousands of outputs",
"Your domain has specialized vocabulary (medical, legal, code)",
"You want to reduce per-query costs (no long system prompts)",
"Latency matters (fine-tuned models skip the prompt overhead)",
],
"Use RAG instead when": [
"You need access to specific, up-to-date documents",
"The knowledge changes frequently (news, docs, inventory)",
"You need citation/attribution for answers",
"You don't have labeled training data",
],
}
for approach, criteria in decision_matrix.items():
print(f"\n{approach}:")
for item in criteria:
print(f" - {item}")
# Rough cost comparison (illustrative, not a price quote)
print("\n--- Cost Comparison (processing 10,000 requests) ---")
approaches = [
("Few-shot prompting (GPT-5.4 mini)", "~800 tokens/req", "$1.20", "Minutes to set up"),
("Fine-tuned GPT-5.4 mini", "~200 tokens/req", "$0.30 + $25 training", "Hours to train"),
("Fine-tuned Llama 3.2 (3B, local)", "~200 tokens/req", "$0 + $5 compute", "Hours to train"),
("RAG + GPT-5.4 mini", "~1200 tokens/req", "$1.80", "Hours to set up"),
]
print(f"\n{'Approach':<40} {'Tokens/req':>14} {'10K cost':>12} {'Setup':>20}")
print("-" * 90)
for name, tokens, cost, setup in approaches:
print(f"{name:<40} {tokens:>14} {cost:>12} {setup:>20}")
▶ Output
Use Prompt Engineering when: - You have fewer than 100 labeled examples - The task is well-defined and simple (classification, extraction) - You need to iterate quickly (change behavior in minutes) - The base model already performs at 80%+ on your task - Cost per query is acceptable (tokens for few-shot examples) Use Fine-Tuning when: - You have 500+ high-quality labeled examples - Prompt engineering plateaus below acceptable quality - You need consistent style/format across thousands of outputs - Your domain has specialized vocabulary (medical, legal, code) - You want to reduce per-query costs (no long system prompts) - Latency matters (fine-tuned models skip the prompt overhead) Use RAG instead when: - You need access to specific, up-to-date documents - The knowledge changes frequently (news, docs, inventory) - You need citation/attribution for answers - You don't have labeled training data --- Cost Comparison (processing 10,000 requests) --- Approach Tokens/req 10K cost Setup ------------------------------------------------------------------------------------------ Few-shot prompting (GPT-5.4 mini) ~800 tokens/req $1.20 Minutes to set up Fine-tuned GPT-5.4 mini ~200 tokens/req $0.30 + $25 training Hours to train Fine-tuned Llama 3.2 (3B, local) ~200 tokens/req $0 + $5 compute Hours to train RAG + GPT-5.4 mini ~1200 tokens/req $1.80 Hours to set up
What happened here: Fine-tuning starts to pay off once you are processing high volumes. The per-query cost drops because a fine-tuned model no longer needs a long system prompt or a stack of few-shot examples. That behavior is now baked into the weights, so each request sends far fewer tokens. A local fine-tuned model like Llama 3.2 wipes out the per-query Application Programming Interface (API) cost entirely once you have paid the one-time training cost. The dollar figures here are rough, illustrative numbers, not live prices, so always check the current provider rates. And if your knowledge changes often, reach for RAG (RAG tutorial) instead, because you can update the knowledge base without retraining anything.
LoRA: Low-Rank Adaptation Explained
Full fine-tuning updates every parameter in the model. For a 7B model that is 7 billion floating-point numbers to adjust, which needs multiple GPUs and hundreds of gigabytes of memory. LoRA takes a smarter shortcut. Instead of rewriting the big weight matrices, it freezes them and bolts on small trainable “adapter” matrices next to them. Picture leaving a printed manual untouched and clipping a few sticky notes onto the pages that need changes. The original stays frozen, the sticky notes carry your edits. Those adapters usually hold just 0.1% to 1% of the original parameter count, yet they capture the task-specific adjustments surprisingly well. That single trick is what made fine-tuning LLMs practical outside well-funded labs.
📄 lora_concept.py: how LoRA shrinks the trainable parameter count
import torch
# Niranjan visualizes the LoRA concept
# Original weight matrix in a transformer layer
d_model = 4096 # Hidden size of Llama-7B
original_weight = torch.randn(d_model, d_model) # 4096 x 4096 = 16.7M parameters
# LoRA adds two small matrices: A (d_model x r) and B (r x d_model)
r = 16 # Rank, the key hyperparameter (typically 8 to 64)
lora_A = torch.randn(d_model, r) # 4096 x 16 = 65,536 parameters
lora_B = torch.randn(r, d_model) # 16 x 4096 = 65,536 parameters
# The adapted weight = original (frozen) + scaling * (A @ B)
scaling = 0.5 # alpha / r
adapted_weight = original_weight + scaling * (lora_A @ lora_B)
print(f"Original parameters: {d_model * d_model:>12,} (frozen, not updated)")
print(f"LoRA A parameters: {d_model * r:>12,} (trainable)")
print(f"LoRA B parameters: {r * d_model:>12,} (trainable)")
print(f"LoRA total: {2 * d_model * r:>12,} (trainable)")
print(f"Reduction: {d_model * d_model / (2 * d_model * r):>12.0f}x fewer parameters")
# For a full 7B model with LoRA on attention layers
num_layers = 32 # Llama-7B has 32 transformer layers
num_attention_matrices = 4 # Q, K, V, O projections per layer
total_lora_params = num_layers * num_attention_matrices * 2 * d_model * r
total_model_params = 7_000_000_000
print(f"\n--- Full Model LoRA Summary (Llama 7B, rank={r}) ---")
print(f"Total model params: {total_model_params:>15,}")
print(f"Trainable LoRA: {total_lora_params:>15,}")
print(f"Percentage trained: {total_lora_params / total_model_params * 100:>14.2f}%")
print(f"GPU memory needed: ~6 GB (vs ~28 GB for full fine-tuning)")
▶ Output
Original parameters: 16,777,216 (frozen, not updated) LoRA A parameters: 65,536 (trainable) LoRA B parameters: 65,536 (trainable) LoRA total: 131,072 (trainable) Reduction: 128x fewer parameters --- Full Model LoRA Summary (Llama 7B, rank=16) --- Total model params: 7,000,000,000 Trainable LoRA: 16,777,216 Percentage trained: 0.24% GPU memory needed: ~6 GB (vs ~28 GB for full fine-tuning)
What happened here: LoRA splits each weight update into two skinny matrices, A and B, whose product stands in for the full update. With rank 16, one layer’s adapter holds 131K parameters instead of 16.7M, a 128x cut. Stretch that across all 32 layers of Llama 7B and only about 0.24% of the parameters are trainable. The frozen 99.76% sit there read-only, which is exactly why LoRA fits on a single consumer GPU. (Your own run prints identical numbers, because the math is fixed, not random.)
QLoRA: Quantized LoRA for Consumer GPUs
QLoRA pairs quantization (large language models tutorial) with LoRA. The base model loads in 4-bit precision (NF4), and only the small LoRA adapters train in higher precision. Think of it like saving your photos as compressed JPEGs instead of giant RAW files: you lose a sliver of detail but the whole thing fits in a fraction of the space. That extra squeeze halves memory again and brings a 7B model down to a GPU with around 6 GB of VRAM. One honest caveat up front: the code below needs a CUDA GPU, because bitsandbytes 4-bit loading does not run on a plain CPU, so the output you see is a representative run, not something this CPU-only machine produced.
📄 qlora_training.py: fine-tuning a small Llama on a single GPU
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
import torch
# Viraj sets up QLoRA fine-tuning
model_name = "meta-llama/Llama-3.2-3B-Instruct"
# Step 1: Load model in 4-bit quantization
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NormalFloat4, best for LLM weights
bnb_4bit_compute_dtype=torch.bfloat16, # Compute in bfloat16 for speed
bnb_4bit_use_double_quant=True, # Quantize the quantization constants
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token
# Step 2: Configure LoRA adapters
lora_config = LoraConfig(
r=16, # Rank: higher means more capacity and more memory
lora_alpha=32, # Scaling factor (usually 2 * r)
lora_dropout=0.05, # Regularization
target_modules=[ # Which layers to adapt
"q_proj", "k_proj", "v_proj", "o_proj", # Attention
"gate_proj", "up_proj", "down_proj", # Feed-forward
],
bias="none",
task_type="CAUSAL_LM",
)
# Step 3: Prepare model for training
model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)
# Print trainable parameters
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"Trainable: {trainable:,} / {total:,} ({trainable/total*100:.2f}%)")
# Step 4: Load and format training data
dataset = load_dataset("json", data_files="training_data.jsonl", split="train")
def format_prompt(example):
return f"""<|begin_of_text|><|start_header_id|>system<|end_header_id|>
You are a Python code reviewer. Review code concisely.<|eot_id|>
<|start_header_id|>user<|end_header_id|>
{example['instruction']}<|eot_id|>
<|start_header_id|>assistant<|end_header_id|>
{example['response']}<|eot_id|>"""
# Step 5: Train with SFTTrainer
training_args = TrainingArguments(
output_dir="./lora-code-reviewer",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # Effective batch size = 4 * 4 = 16
learning_rate=2e-4,
fp16=True,
logging_steps=10,
save_strategy="epoch",
warmup_ratio=0.03,
optim="paged_adamw_8bit", # Memory-efficient optimizer
)
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=dataset,
formatting_func=format_prompt,
max_seq_length=512,
)
# trainer.train() # Uncomment to actually train
print("Training configured. Run trainer.train() to start.")
print(f"Estimated time: ~30 min on RTX 4090, ~2 hours on T4 (Colab)")
print(f"GPU memory: ~5.5 GB for 3B model, ~7 GB for 7B model")
▶ Output (representative GPU run, not produced on this CPU box)
Trainable: 24,313,856 / 3,237,063,680 (0.75%) Training configured. Run trainer.train() to start. Estimated time: ~30 min on RTX 4090, ~2 hours on T4 (Colab) GPU memory: ~5.5 GB for 3B model, ~7 GB for 7B model
What happened here: That is a full QLoRA training pipeline in under 60 lines. The base model loads in 4-bit precision (NF4), which shrinks the weights from roughly 12 GB down to about 2 GB. The LoRA adapters add around 24 million trainable parameters, which is 0.75% of this 3B model, so you are training a sliver of the network and leaving the rest frozen. The SFTTrainer from Hugging Face TRL handles tokenization, batching, and gradient accumulation for you, and the paged AdamW optimizer keeps its state in 8-bit to save even more memory.
The whole thing fits on the free T4 GPU on Google Colab. The exact trainable count depends on which target modules you pick and the model’s hidden sizes, so re-run it on your own model to confirm the number before you trust it.
Preparing Training Data
Your training data is the whole ballgame. The model can only get as good as the examples you feed it, the way a student can only learn from the worked solutions in the textbook. Feed it sloppy or contradictory examples and it learns to be sloppy and contradictory. Most failed attempts at fine-tuning LLMs die right here, in the data, not in the training loop. The standard format is JSONL: one JSON object per line, each with an instruction and the ideal response. A data engineer named Aditi builds a tiny code-review dataset below so you can see the shape.
📄 prepare_data.py: building a clean fine-tuning dataset
import json
# Aditi prepares training data for a code review model
training_examples = [
{
"instruction": "Review this code:\ndef add(a, b): return a + b",
"response": "[STYLE] No type hints. Fix: def add(a: float, b: float) -> float: return a + b\n[STYLE] Single-line function is fine for simple operations."
},
{
"instruction": "Review this code:\ndef get_user(id):\n conn = sqlite3.connect('db.sqlite')\n result = conn.execute(f'SELECT * FROM users WHERE id={id}')\n return result.fetchone()",
"response": "[CRITICAL] SQL injection via f-string. Fix: conn.execute('SELECT * FROM users WHERE id=?', (id,))\n[WARNING] Connection never closed. Fix: Use context manager: with sqlite3.connect('db.sqlite') as conn:\n[STYLE] 'id' shadows builtin. Fix: Use 'user_id'."
},
{
"instruction": "Review this code:\ntry:\n result = process_data(input)\nexcept:\n pass",
"response": "[CRITICAL] Bare except catches ALL exceptions including KeyboardInterrupt and SystemExit. Fix: except Exception as e: logger.error(f'Processing failed: {e}')\n[CRITICAL] Silent failure, errors are swallowed. Fix: At minimum log the error."
},
]
# Save as JSONL (one JSON object per line)
with open("training_data.jsonl", "w") as f:
for example in training_examples:
f.write(json.dumps(example) + "\n")
print(f"Created {len(training_examples)} training examples")
print(f"\nData quality checklist:")
checklist = [
("Minimum examples", "500+ for noticeable improvement, 1000+ for strong results"),
("Consistent format", "Same instruction/response structure in every example"),
("Diverse inputs", "Cover edge cases, not just happy paths"),
("High-quality outputs", "Your examples are the ceiling, the model cannot exceed them"),
("No contradictions", "Conflicting examples confuse the model"),
]
for item, detail in checklist:
print(f" [{' ' if item == 'Minimum examples' else 'x'}] {item}: {detail}")
▶ Output
Created 3 training examples Data quality checklist: [ ] Minimum examples: 500+ for noticeable improvement, 1000+ for strong results [x] Consistent format: Same instruction/response structure in every example [x] Diverse inputs: Cover edge cases, not just happy paths [x] High-quality outputs: Your examples are the ceiling, the model cannot exceed them [x] No contradictions: Conflicting examples confuse the model
What happened here: Three examples is obviously a toy set, just enough to show the JSONL shape. The first checklist item is left unchecked on purpose to make the point loudly: with only three rows you are nowhere near the 500-plus you actually need. Quality beats quantity every time. Five hundred careful, consistent examples will out-train five thousand noisy ones, because the model copies whatever you give it, warts and all.
Merging and Deploying Fine-Tuned Models
After training, your LoRA adapter is a small separate file that sits next to the base model. You can serve it that way, but for the simplest deployment you usually fold the adapter back into the base weights so there is just one model to ship. That folding step is merge_and_unload(). Think of it like flattening all the layers in an image editor into a single picture: once merged, the adapter is no longer a separate layer, it is baked in. This last mile is where fine-tuning LLMs stops being an experiment and becomes something your product can call.
📄 merge_and_deploy.py: from LoRA adapter to a deployable model
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
# Prathamesh merges LoRA weights back into the base model
base_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.2-3B-Instruct",
torch_dtype=torch.float16,
device_map="auto",
)
# Load the LoRA adapter
model = PeftModel.from_pretrained(base_model, "./lora-code-reviewer/checkpoint-final")
# Merge adapter weights into the base model
merged_model = model.merge_and_unload()
# Save the merged model
merged_model.save_pretrained("./code-reviewer-merged")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-3B-Instruct")
tokenizer.save_pretrained("./code-reviewer-merged")
print("Merged model saved to ./code-reviewer-merged/")
print("\nDeployment options:")
options = [
("Ollama (local)", "ollama create code-reviewer -f Modelfile"),
("HuggingFace Hub", "merged_model.push_to_hub('username/code-reviewer')"),
("vLLM (production)", "vllm serve ./code-reviewer-merged --port 8000"),
("GGUF (llama.cpp)", "Convert to GGUF format for CPU inference"),
]
for name, cmd in options:
print(f" {name:>20}: {cmd}")
▶ Output (the merge step needs the trained adapter and a GPU; the deployment list below is the real printout)
Merged model saved to ./code-reviewer-merged/
Deployment options:
Ollama (local): ollama create code-reviewer -f Modelfile
HuggingFace Hub: merged_model.push_to_hub('username/code-reviewer')
vLLM (production): vllm serve ./code-reviewer-merged --port 8000
GGUF (llama.cpp): Convert to GGUF format for CPU inference
What happened here: merge_and_unload() adds the trained adapter back into the frozen base weights and hands you one standalone model with no PEFT (Parameter-Efficient Fine-Tuning) dependency at inference time. From there you have options. Ollama is the easiest path for running it locally, vLLM is the workhorse for high-throughput production serving, and GGUF (via llama.cpp) lets it run on a plain CPU when you have no GPU at hand. Pick whichever matches where the model needs to live.
Common Mistakes
- Too few training examples: With under 200 examples, the model memorizes rather than generalizes. Aim for 500-1000 diverse, high-quality examples minimum.
- Learning rate too high: LoRA is sensitive to learning rate. Start with 2e-4 and decrease if loss is unstable. Full fine-tuning uses 1e-5 to 5e-5; LoRA can go higher because fewer parameters are updated.
- Forgetting to set pad_token: Most causal LM tokenizers have no pad token by default. Set
tokenizer.pad_token = tokenizer.eos_tokenbefore training.
Interview Corner
Q: What is the difference between LoRA and full fine-tuning?
Full fine-tuning updates every parameter in the model, which needs multiple GPUs and tens of gigabytes of memory. LoRA freezes the original weights and adds small trainable adapter matrices, usually 0.1% to 1% of the parameters. The key insight from the LoRA paper is that the weight changes during fine-tuning have low intrinsic rank. In plain terms: the update is simpler than it looks, so you can approximate it accurately with two much smaller matrices. In practice LoRA reaches roughly 95% to 99% of full fine-tuning quality at a fraction of the cost, which is why it is the default choice for most teams.
Practice Exercises
- Exercise 1: Fine-tune small LM with Hugging Face Trainer.
- Exercise 2: Implement LoRA for efficient fine-tuning.
- Exercise 3: Full pipeline: data, LoRA, training, eval, merge, inference.
More in this series:
- Agentic RAG in Python: Hybrid Search, Reranking, and GraphRAG
- RLHF vs DPO: How LLMs Learn Human Preferences
- GenAI: Building AI Agents with LangGraph, CrewAI, and Beyond
Frequently Asked Questions
What LoRA rank should I use?
Start with rank 16 for most tasks. Use rank 8 for simple tasks (classification, sentiment) and rank 32-64 for complex tasks (code generation, creative writing). Higher rank means more capacity but more memory and slower training. Always validate: if rank 16 and rank 64 give similar results, use 16.
How much training data do I need to fine tune an LLM?
500 examples: a noticeable improvement on focused tasks. 1,000 to 5,000: strong task-specific performance. 10,000 and up: competitive with commercial fine-tuned models. Quality beats quantity, so 500 perfect examples will out-train 5,000 noisy ones. A common workflow is to draft examples with a strong model like Claude Opus 4.8 or GPT-5.5, then have a human review them before training.
Will fine-tuning make the model forget general knowledge?
LoRA largely avoids catastrophic forgetting because the original weights are frozen. The adapters add new behavior without overwriting existing knowledge. Full fine-tuning can cause forgetting, which is why LoRA is preferred for most use cases. If you notice quality degradation on general tasks, reduce the number of training epochs.
Interview Questions on Fine-Tuning LLMs
These come from real screens and onsites. Practice answering before you read each answer.
Q: When would you choose QLoRA over plain LoRA?
QLoRA loads the frozen base model in 4-bit (NF4) precision while training the adapters in higher precision, so it roughly halves memory versus standard LoRA. Choose it when the base model does not fit in your GPU VRAM, for example fitting a 7B model on a 6 GB card or the free Colab T4. The trade-off is a small quality and speed cost from quantization, so if the model already fits comfortably, plain LoRA is simpler.
Q: Your fine-tuned model scores well on your test set but gives worse answers on general questions than the base model. What is happening and how do you fix it?
That is catastrophic forgetting: training has overwritten general knowledge. Because LoRA freezes the base weights it usually resists this, so first confirm you are not accidentally running full fine-tuning. Then reduce the number of epochs (2 to 3 is often enough), lower the learning rate, or mix a small amount of general instruction data into your set so the model does not drift too far from its original abilities.
Q: Training crashes with a CUDA out-of-memory error partway through the first epoch. What do you try first?
Lower per_device_train_batch_size and raise gradient_accumulation_steps to keep the effective batch size the same. If it still overflows, switch to QLoRA (4-bit base), shorten max_seq_length, use a paged 8-bit optimizer like paged_adamw_8bit, or enable gradient checkpointing. Memory scales with batch size and sequence length, so those are the fastest levers to pull.
Q: What do the rank (r) and lora_alpha hyperparameters actually control?
Rank r sets the size of the adapter matrices, so a higher r gives the adapter more capacity but costs more memory and slower training. lora_alpha is a scaling factor: the adapter update is multiplied by alpha divided by r before being added to the frozen weights. A common starting rule is alpha equal to 2 times r. Raising r increases capacity, while raising alpha increases how strongly the adapter influences the final output.
Q: Why do you set tokenizer.pad_token = tokenizer.eos_token before training a causal LM?
Most causal LM tokenizers ship without a pad token, but batching requires padding the shorter sequences to equal length. Reusing the end-of-sequence token as the pad token gives the tokenizer a valid id to pad with. Without it, the trainer throws a runtime error the moment it tries to assemble a batch of mixed-length examples.
Q: Your team has 80 labeled examples and prompt engineering already reaches about 85% accuracy. Should you fine-tune?
Probably not yet. 80 examples is far below the 500-plus that fine-tuning needs to generalize instead of memorize, and at 85% from prompting alone the effort is hard to justify. Invest first in better few-shot examples, clearer instructions, or RAG, and only reach for fine-tuning once prompting plateaus below your target and you have collected enough high-quality, consistent data.
What’s Next?
You now know when fine-tuning beats prompting or RAG, how LoRA and QLoRA shrink a billion-parameter job down to a single GPU, how to prepare clean JSONL training data, and how to merge and deploy the finished model. You can genuinely bend an open LLM toward your own domain now. Fine-tuning LLMs is a skill most Python developers never build, and you just did. The AI agents tutorial takes this one step further. It wires LLMs up with tools, memory, and multi-step reasoning so they can plan and carry out complex tasks on their own, not just answer a single prompt.
Want the full path from Python basics to production AI? Browse the Python + AI/ML tutorial series home for every tutorial in order.
Reference: the complete, always-current details live in Hugging Face documentation.
Related Posts
Previous: RAG Project: Build, Evaluate, and Ship a Retrieval App
Next: GenAI: QLoRA Fine-Tuning with Unsloth on a Free Colab GPU
Series Home: Python + AI/ML Tutorial Series

No comment