Fine-tuning Models
Fine-tuning adapts a pre-trained language model to a specific task or domain by continuing the training process on a curated dataset. Unlike prompting or RAG, fine-tuning modifies the model weights themselves, resulting in permanent behavioral changes that persist across all inputs.
Fine-tuning is the most effective way to imbue a model with deep domain knowledge, enforce complex formatting rules, or achieve consistent output style. However, it requires careful data curation, significant compute resources, and thorough evaluation to avoid catastrophic forgetting or overfitting.
This guide covers the full fine-tuning lifecycle: deciding when to fine-tune, preparing datasets, choosing between full fine-tuning and parameter-efficient methods, training with SFT, tuning hyperparameters, evaluating results, and deploying the final model.
Choosing the right adaptation strategy depends on your use case, data availability, latency requirements, and desired behavior change.
| Approach | Best For | Cost | When to Use |
|---|---|---|---|
| Prompt Engineering | Simple instruction following | Free โ no training | Quick prototypes, well-defined tasks |
| RAG | Knowledge-intensive tasks | Low โ embedding + search | Dynamic data, large corpora, factual accuracy |
| Fine-tuning | Behavioral change, style, format | High โ GPU training | Consistent output format, domain adaptation, tone |
best practice
Full fine-tuning updates all model parameters during training. It achieves the highest quality adaptation but is extremely expensive โ training a 7B parameter model requires ~60 GB of GPU memory per batch. Parameter-efficient methods reduce this dramatically.
| Method | Memory | Speed | Quality | Best For |
|---|---|---|---|---|
| Full Fine-Tune | Very high | Slow | Highest | Maximum quality, large compute budget |
| LoRA | Low | Fast | Comparable | Most practical scenarios |
| QLoRA | Very low | Moderate | Near full | Consumer GPUs, large models (70B+) |
LoRA (Low-Rank Adaptation)
LoRA freezes the pre-trained weights and injects trainable low-rank decomposition matrices into attention layers. This reduces trainable parameters by 10,000x while maintaining 90-95% of full fine-tuning quality. The LoRA weights are typically merged into the base model at inference for zero additional latency.
| 1 | from transformers import AutoModelForCausalLM, AutoTokenizer |
| 2 | from peft import LoraConfig, get_peft_model |
| 3 | |
| 4 | model = AutoModelForCausalLM.from_pretrained( |
| 5 | "meta-llama/Llama-3.2-3B", |
| 6 | torch_dtype="auto", |
| 7 | device_map="auto" |
| 8 | ) |
| 9 | |
| 10 | lora_config = LoraConfig( |
| 11 | r=16, |
| 12 | lora_alpha=32, |
| 13 | target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], |
| 14 | lora_dropout=0.05, |
| 15 | bias="none", |
| 16 | task_type="CAUSAL_LM" |
| 17 | ) |
| 18 | |
| 19 | model = get_peft_model(model, lora_config) |
| 20 | print(f"Trainable params: {model.num_parameters(only_trainable=True):,}") |
QLoRA โ Quantized LoRA
QLoRA extends LoRA by quantizing the base model to 4-bit using NormalFloat4 (NF4) quantization. This enables fine-tuning models like Llama 3.2 70B on a single 48 GB GPU. QLoRA introduces paged optimizers to handle gradient checkpointing memory spikes and double quantization to reduce the memory overhead of quantization constants.
| 1 | from transformers import BitsAndBytesConfig |
| 2 | import torch |
| 3 | |
| 4 | bnb_config = BitsAndBytesConfig( |
| 5 | load_in_4bit=True, |
| 6 | bnb_4bit_quant_type="nf4", |
| 7 | bnb_4bit_compute_dtype=torch.bfloat16, |
| 8 | bnb_4bit_use_double_quant=True |
| 9 | ) |
| 10 | |
| 11 | model = AutoModelForCausalLM.from_pretrained( |
| 12 | "meta-llama/Llama-3.2-70B", |
| 13 | quantization_config=bnb_config, |
| 14 | device_map="auto", |
| 15 | torch_dtype=torch.bfloat16 |
| 16 | ) |
| 17 | |
| 18 | # Apply LoRA on top of quantized model |
| 19 | model = get_peft_model(model, lora_config) |
pro tip
Supervised Fine-Tuning (SFT) trains the model on input-output pairs to learn desired behavior. The training objective is standard autoregressive language modeling โ cross-entropy loss on the target tokens. SFT is the most common fine-tuning paradigm and forms the foundation for instruction-tuning, chat fine-tuning, and task-specific adaptation.
The training loop processes batches of examples where each example consists of a prompt (input) and a completion (target). The loss is computed only on the completion tokens, not the prompt tokens, preventing the model from being penalized for not predicting the input.
| 1 | from transformers import TrainingArguments, Trainer |
| 2 | from datasets import Dataset |
| 3 | |
| 4 | training_args = TrainingArguments( |
| 5 | output_dir="./lora-fine-tuned", |
| 6 | per_device_train_batch_size=4, |
| 7 | gradient_accumulation_steps=4, |
| 8 | learning_rate=2e-4, |
| 9 | warmup_steps=100, |
| 10 | num_train_epochs=3, |
| 11 | logging_steps=25, |
| 12 | save_strategy="epoch", |
| 13 | evaluation_strategy="epoch", |
| 14 | bf16=True, |
| 15 | gradient_checkpointing=True, |
| 16 | remove_unused_columns=False, |
| 17 | report_to="wandb" |
| 18 | ) |
| 19 | |
| 20 | trainer = Trainer( |
| 21 | model=model, |
| 22 | args=training_args, |
| 23 | train_dataset=train_dataset, |
| 24 | eval_dataset=eval_dataset, |
| 25 | tokenizer=tokenizer, |
| 26 | data_collator=default_data_collator, |
| 27 | ) |
| 28 | |
| 29 | trainer.train() |
| 30 | trainer.save_model() |
warning
SFT Training Loop with Label Masking
| 1 | import torch |
| 2 | from transformers import AutoTokenizer |
| 3 | |
| 4 | tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-3B") |
| 5 | tokenizer.pad_token = tokenizer.eos_token |
| 6 | |
| 7 | def format_sft_example(example): |
| 8 | prompt = example["instruction"] |
| 9 | response = example["output"] |
| 10 | messages = [ |
| 11 | {"role": "user", "content": prompt}, |
| 12 | {"role": "assistant", "content": response} |
| 13 | ] |
| 14 | formatted = tokenizer.apply_chat_template( |
| 15 | messages, tokenize=False |
| 16 | ) |
| 17 | return {"text": formatted} |
| 18 | |
| 19 | def tokenize_with_mask(examples): |
| 20 | tokenized = tokenizer( |
| 21 | examples["text"], |
| 22 | truncation=True, |
| 23 | max_length=2048, |
| 24 | padding="max_length" |
| 25 | ) |
| 26 | |
| 27 | labels = tokenized["input_ids"].copy() |
| 28 | assistant_token_id = tokenizer.encode( |
| 29 | "assistant", add_special_tokens=False |
| 30 | )[0] |
| 31 | |
| 32 | for i, input_ids in enumerate(tokenized["input_ids"]): |
| 33 | found_assistant = False |
| 34 | for j, tid in enumerate(input_ids): |
| 35 | if tid == assistant_token_id: |
| 36 | found_assistant = True |
| 37 | if not found_assistant: |
| 38 | labels[i][j] = -100 |
| 39 | |
| 40 | tokenized["labels"] = labels |
| 41 | return tokenized |
The quality of your fine-tuning dataset is the single most important factor in the success of fine-tuning. A small, high-quality dataset consistently outperforms a large, noisy one. The canonical recommendation is 500-5,000 high-quality examples for most SFT tasks.
Data Quality Criteria
ChatML Format
Most modern LLMs use chat templates (ChatML, ShareGPT, or model-specific formats) to structure multi-turn conversations. The tokenizer's apply_chat_template method handles this automatically.
| 1 | # ChatML format (applied by tokenizer) |
| 2 | messages = [ |
| 3 | {"role": "system", "content": "You are a helpful assistant."}, |
| 4 | {"role": "user", "content": "What is transfer learning?"}, |
| 5 | {"role": "assistant", "content": "Transfer learning reuses a model trained on one task..."} |
| 6 | ] |
| 7 | |
| 8 | # Apply model-specific chat template |
| 9 | formatted = tokenizer.apply_chat_template( |
| 10 | messages, |
| 11 | tokenize=False, |
| 12 | add_generation_prompt=False |
| 13 | ) |
| 14 | print(formatted) |
| 15 | # <|begin_of_text|><|start_header_id|>system<|end_header_id|> |
| 16 | # You are a helpful assistant.<|eot_id|> |
| 17 | # <|start_header_id|>user<|end_header_id|> |
| 18 | # What is transfer learning?<|eot_id|> |
| 19 | # <|start_header_id|>assistant<|end_header_id|> |
| 20 | # Transfer learning reuses...<|eot_id|> |
Fine-tuning hyperparameters significantly impact convergence speed, model quality, and training stability. The following table shows recommended starting values for LoRA fine-tuning of 1-13B parameter models.
| Hyperparameter | Recommended Value | Notes |
|---|---|---|
| learning_rate | 1e-4 to 3e-4 | Higher than pre-training; LoRA needs higher LR |
| LoRA rank (r) | 8 to 32 | Higher rank = more expressive, more memory |
| LoRA alpha | 16 to 64 | Scaling factor; alpha = 2 * r is common |
| batch_size | 4-16 (effective) | Use gradient accumulation for large effective batch |
| num_epochs | 2 to 5 | Watch for overfitting; eval per epoch |
| warmup_steps | 50 to 200 | Linear warmup; ~5-10% of total steps |
| weight_decay | 0.01 to 0.1 | Prevents overfitting on small datasets |
| max_seq_length | 512 to 2048 | Balance context needs with memory |
info
Evaluating a fine-tuned model requires more than tracking loss curves. You need task-specific metrics, human evaluation, and comparison to the base model. Always evaluate on a held-out test set that was not seen during training.
| 1 | from transformers import pipeline |
| 2 | import evaluate |
| 3 | |
| 4 | # Load fine-tuned model |
| 5 | pipe = pipeline( |
| 6 | "text-generation", |
| 7 | model="./lora-fine-tuned", |
| 8 | tokenizer=tokenizer, |
| 9 | device_map="auto" |
| 10 | ) |
| 11 | |
| 12 | # Compare base vs fine-tuned on test set |
| 13 | for example in test_set[:5]: |
| 14 | base_output = base_pipe(example["prompt"], max_new_tokens=128)[0]["generated_text"] |
| 15 | ft_output = pipe(example["prompt"], max_new_tokens=128)[0]["generated_text"] |
| 16 | print(f"Prompt: {example['prompt']}") |
| 17 | print(f"Expected: {example['expected']}") |
| 18 | print(f"Base: {base_output}") |
| 19 | print(f"Fine-tuned: {ft_output}") |
| 20 | print("---") |
best practice
Fine-tuned models can be deployed via inference endpoints, serverless GPU functions, or embedded in applications. For LoRA adapters, you can merge weights into the base model for zero-overhead inference or keep them separate for modular switching.
| 1 | # Merge LoRA weights into base model for deployment |
| 2 | from peft import PeftModel |
| 3 | |
| 4 | base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.2-3B") |
| 5 | ft_model = PeftModel.from_pretrained(base_model, "./lora-fine-tuned") |
| 6 | merged_model = ft_model.merge_and_unload() |
| 7 | merged_model.save_pretrained("./merged-fine-tuned") |
| 8 | |
| 9 | # Or keep LoRA adapter separate for multi-adapter serving |
| 10 | # Load different adapters for different tasks at inference time |
| 11 | ft_model.load_adapter("./lora-adapter-1", adapter_name="code-gen") |
| 12 | ft_model.load_adapter("./lora-adapter-2", adapter_name="chat") |
| 13 | ft_model.set_adapter("code-gen") |
warning