The Developer's Guide to Fine-Tuning Open Source Models
A practical guide to fine-tuning open-source LLMs using QLoRA, Hugging Face, and PyTorch. Learn how to train models on custom data formats.

Developers starting out with Large Language Models often face a critical question: Should I build a Retrieval-Augmented Generation (RAG) pipeline or should I fine-tune a model?
While RAG is excellent for giving your model access to dynamic knowledge, documents, and real-time facts, fine-tuning is the correct choice when you need to change the model's behavior, teach it a specific output syntax (like custom JSON schemas), align its tone, or train it on a highly specialized programming language.
Historically, fine-tuning was reserved for companies with large clusters of high-end GPUs. However, the introduction of parameter-efficient training methods has democratized the process.
In this guide, we will break down the mechanics of LoRA and QLoRA, outline dataset formats, and walk through a complete Python script using Hugging Face's ecosystem to train a model.
The Magic of QLoRA: Low-Rank Adaptation
Fine-tuning all parameters of a 7-billion parameter model (FP16) requires updating billions of numbers, demanding massive GPU memory because of gradient tracking.
LoRA (Low-Rank Adaptation) solves this by freezing the original weights of the model and injecting small trainable rank decomposition matrices (adapters) into the attention layers. This reduces the number of trainable parameters by up to 99%.
graph LR
Input[Input Vector] --> Base[Frozen Base Weights W]
Input --> AdapterA[Adapter A: Down Projection]
AdapterA --> AdapterB[Adapter B: Up Projection]
Base --> Combine[+]
AdapterB --> Combine
Combine --> Output[Output Vector]
QLoRA (Quantized LoRA) takes this a step further by quantizing the base model down to 4-bit precision using a specialized format called NormalFloat4 (NF4). It then trains the small LoRA adapters on top of this compressed model. This allows you to fine-tune a 7B or 8B model on a single consumer GPU with 16GB-24GB VRAM.
Structuring the Training Dataset
LLMs learn by example. The dataset must be structured as instruction-response pairs. Two standard formats are Alpaca format and ShareGPT format.
Alpaca Format (Simple Instruction-Response)
This format is perfect for single-turn Q&A tasks:
[
{
"instruction": "Convert the following temperature from Fahrenheit to Celsius.",
"input": "98.6 F",
"output": "To convert Fahrenheit to Celsius, subtract 32 from the temperature, multiply by 5, and divide by 9. Calculation: (98.6 - 32) * 5 / 9 = 37 C."
}
]
ShareGPT Format (Conversational Multi-Turn)
Choose this format if you are building conversational chat agents:
[
{
"conversations": [
{ "from": "human", "value": "Hello, can you explain what an API is?" },
{ "from": "gpt", "value": "An API, or Application Programming Interface, acts as an intermediary..." },
{ "from": "human", "value": "Thanks! Can you give a quick example?" }
]
}
]
Python Implementation: Fine-Tuning with QLoRA
Here is a complete, production-ready Python training script. It utilizes PyTorch, Transformers, Accelerate, PEFT, and TRL libraries to load a base model in 4-bit precision, construct LoRA layers, and execute Supervised Fine-Tuning (SFTTrainer).
Installing Dependencies
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install transformers peft trl accelerate bitsandbytes datasets
The Fine-Tuning Script (train.py)
import torch
from datasets import load_dataset
from transformers import (
AutoModelForCausalLM,
AutoTokenizer,
BitsAndBytesConfig,
TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
def run_fine_tuning():
model_id = "meta-llama/Meta-Llama-3-8B-Instruct"
output_dir = "./llama-3-8b-custom-adapter"
# 1. Configure bitsandbytes for 4-bit quantization (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
)
# 2. Load model and tokenizer
print("[*] Loading base model and tokenizer...")
model = AutoModelForCausalLM.from_pretrained(
model_id,
quantization_config=bnb_config,
device_map="auto"
)
model.config.use_cache = False # Disable for training to save memory
tokenizer = AutoTokenizer.from_pretrained(model_id)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"
# 3. Prepare model for k-bit training
model = prepare_model_for_kbit_training(model)
# 4. Configure LoRA parameters
# Target projection layers inside Llama's Self-Attention block
peft_config = LoraConfig(
r=16, # Rank: higher value allows learning more complex patterns
lora_alpha=32, # Scaling factor
target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, peft_config)
print(f"[*] Trainable parameters: ")
model.print_trainable_parameters()
# 5. Load Dataset (Mocking standard Hugging Face dataset load)
# Ensure your JSON files match the format required (e.g. keying off "text" or instructing SFTTrainer)
print("[*] Loading dataset...")
dataset = load_dataset("json", data_files={"train": "training_data.json"})
# 6. Configure Training Arguments
training_args = TrainingArguments(
output_dir=output_dir,
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # Simulates batch size of 16
optim="paged_adamw_32bit", # Optimizes memory layout
save_steps=100,
logging_steps=10,
learning_rate=2e-4, # standard initial rate for LoRA
weight_decay=0.001,
fp16=True,
max_grad_norm=0.3,
warmup_ratio=0.03,
lr_scheduler_type="constant"
)
# 7. Initialize SFTTrainer
trainer = SFTTrainer(
model=model,
train_dataset=dataset["train"],
peft_config=peft_config,
dataset_text_field="text", # Expects instruction and response formatted together in a single "text" field
max_seq_length=1024,
tokenizer=tokenizer,
args=training_args,
)
# 8. Start training loop
print("[*] Launching training execution loop...")
trainer.train()
# 9. Save the adapter weights
print(f"[*] Saving adapter weights to {output_dir}...")
trainer.model.save_pretrained(output_dir)
tokenizer.save_pretrained(output_dir)
print("[✔] Fine-tuning complete!")
if __name__ == "__main__":
run_fine_tuning()
Merging LoRA Adapters with Base Model
When training is complete, the SFTTrainer output directories will only contain the small weight changes (the adapter config and .safetensors files), not the full model weights.
To use the fine-tuned model inside inference servers like Ollama or vLLM, you must merge the adapter back into the base model:
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base_model_id = "meta-llama/Meta-Llama-3-8B-Instruct"
adapter_dir = "./llama-3-8b-custom-adapter"
# Load the base model in FP16 (not quantized for merging)
base_model = AutoModelForCausalLM.from_pretrained(
base_model_id,
torch_dtype=torch.float16,
device_map="cpu" # Run on CPU to avoid using up precious GPU VRAM during merge
)
# Load the adapter
model = PeftModel.from_pretrained(base_model, adapter_dir)
# Merge layers and save
merged_model = model.merge_and_unload()
merged_model.save_pretrained("./llama-3-8b-merged-production")
Checklist for Fine-Tuning Execution
- Clean Dataset: 100 perfectly clean, formatted instructions will produce better results than 10,000 messy inputs containing HTML tags or syntax errors.
- GPU Monitoring: Keep a separate terminal window open with
nvidia-smi -l 1to monitor VRAM allocation. If you experience Out-Of-Memory (OOM) exceptions, reduceper_device_train_batch_sizeor scale down themax_seq_length. - Loss Inspection: Watch the loss metric during log printouts. It should start around
2.0 - 2.5and steadily decline to0.5 - 1.0. If it drops to0.1immediately, your model is likely overfitting.