Fine-Tuning with PEFT & TRL
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against peft 0.21.1, trl 1.14.1, transformers 5.17.0, datasets 5.0.1, bitsandbytes 0.50.2,
sentence-transformers 6.1.0 and setfit 1.2.0, as checked on 2026-09-30.
Fine-tuning adapts a Hub model to your task, your style or your data. It is the last resort on the ladder
prompting, retrieval, fine-tuning, because it adds a training pipeline, a dataset to maintain and a model to re-evaluate
every time the base model changes. This page covers when it pays off, how parameter-efficient methods make it fit on one
GPU, and how the result gets back to a phone or a laptop. Serving the result to many users is covered by
vLLM and SGLang and Running Local LLMs in Production;
formats are explained on Model Formats; downloading and licences are on
Hub Essentials and Choosing Models and Licences.
Training from scratch is out of scope.
When to fine-tune, and when not to
Start from the failure you observe, not from the technique you want to try. Each lever changes a different thing:
| Symptom | Usually fixed by | Why |
|---|---|---|
The model ignores or misreads instructions |
Better prompt, examples in the prompt (few-shot), structured output |
Cheapest change and reversible in seconds; see LLM Foundations & Prompting |
The model lacks facts: private documents, fresh data, your product catalogue |
Retrieval (RAG) |
Knowledge changes daily; weights do not. Facts are cited and deleted by editing the index, see Generating Embeddings in Practice |
Output must follow a style, format, tone or domain vocabulary consistently, and prompts get long |
Supervised fine-tuning (SFT) |
Behaviour is baked into the weights instead of repeated in every prompt |
Two acceptable answers exist and you know which one users prefer |
Preference tuning (DPO) |
Learns from ranked pairs, not from single gold answers |
A small on-device model must match a large one on one narrow task |
SFT or distillation on outputs of a larger model (check the larger model’s licence permits it) |
Narrow tasks need far fewer parameters than general chat |
Text must be classified or ranked and you have tens to thousands of labels |
Fine-tuned encoder, SetFit, or a fine-tuned embedding model |
Smaller, faster and cheaper than prompting an LLM per item |
Fine-tuning is a poor way to teach facts: a model can memorize a sentence yet still fail to use it when phrased differently, and updates require retraining. Combine the levers instead: a fine-tuned model that follows your output format, fed by retrieved context. Before investing, write the evaluation set first (even 50 to 200 labelled examples); if a good prompt already reaches the target on it, stop. The evaluation set is also how you detect that fine-tuning made the model worse on everything else (catastrophic forgetting), see LLM Foundations & Prompting for evaluation habits.
The model card of a base model states its licence, which decides whether you may fine-tune and redistribute (see Choosing Models and Licences). Official reference for the methods below: PEFT documentation and TRL documentation.
LoRA: the update and the parameter savings
Full fine-tuning updates every weight matrix . LoRA (Low-Rank Adaptation) freezes and learns a low-rank correction instead:
The forward pass of an adapted layer is . Initialization matters: gets a
small random value and starts at zero, so at step 0 and training begins exactly at the base model.
Only and receive gradients. The scalar rescales the update, so lora_alpha and r are
tuned together: doubling r with lora_alpha fixed halves the per-direction scale. PEFT also offers use_rslora=True,
which scales by so that larger ranks do not shrink the update, and use_dora=True for DoRA
(weight-decomposed LoRA); both are optional variants of the same idea.
The trainable parameters for one adapted matrix are
so the fraction trained is . For a square projection with : trainable values against , about 0.78 percent of that matrix. Adapters on all linear layers of a model are still typically well below a few percent of the model, which is why the optimizer state (the dominant training memory cost, see the estimates below) nearly disappears and why an adapter file is megabytes, not gigabytes.
from transformers import AutoModelForCausalLM
from peft import LoraConfig, get_peft_model
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B", dtype="auto")
config = LoraConfig(
r=16,
lora_alpha=32, # scale = lora_alpha / r = 2
lora_dropout=0.05,
target_modules="all-linear",
task_type="CAUSAL_LM",
)
peft_model = get_peft_model(model, config)
peft_model.print_trainable_parameters() # trainable params vs. all params
target_modules can be a list of module names (for example ["q_proj", "v_proj"]; names differ per architecture) or
"all-linear", which adapts every linear layer except the output head. Qwen/Qwen3-0.6B is Apache-2.0 and not gated on
the Hub at the time of writing. Official reference: PEFT — LoRA developer guide.
Rules of thumb, all worth validating on your evaluation set rather than trusting: a rank between 8 and 64 covers most
narrow tasks; adapting all linear layers usually helps more than raising the rank on attention projections only; and because
only new parameters are learned, adapter training uses a higher learning rate than full fine-tuning (TRL’s own guide
suggests about 1e-4 for SFT adapters against the 2e-5 default of SFTConfig).
QLoRA and NF4
QLoRA keeps the frozen base model in 4-bit precision and trains LoRA adapters in higher precision on top of it. Gradients
flow through the dequantized weights into the adapters. The 4-bit type is NF4 (NormalFloat4), whose 16 levels are placed at the
quantiles of a standard normal distribution, which suits the roughly normal distribution of pretrained weights; blockwise
scales are themselves quantized ("double quantization") to save a further fraction of a bit per weight. The construction is
described in the QLoRA paper and summarised on Quantization for Inference.
The bitsandbytes library provides the kernels; transformers exposes them through BitsAndBytesConfig. Expect a
CUDA GPU for bitsandbytes (backend support for other hardware varies by release; check its installation page).
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B", quantization_config=bnb)
model = prepare_model_for_kbit_training(model)
lora = LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM")
model = get_peft_model(model, lora)
Memory for the frozen weights falls from about bytes (bf16) to about bytes plus quantization constants, where is the parameter count; the estimates section turns this into a calculator. Two caveats: QLoRA trades memory for speed, because every step dequantizes weights on the fly; and an adapter trained against a 4-bit base is best merged into a full-precision copy of that base rather than into the quantized weights (see the merging section). Official reference: PEFT — Quantization, and the paper QLoRA: Efficient Finetuning of Quantized LLMs.
Supervised fine-tuning with TRL
TRL (Transformer Reinforcement Learning) provides trainers on top of transformers.Trainer. SFTTrainer with SFTConfig
is the standard route for instruction and chat tuning. The trainer accepts a model id or a loaded model, args=SFTConfig(…),
a train_dataset (and optional eval_dataset), a peft_config to train an adapter, a quantization_config to load the model
in 4-bit from an id, and a processing_class (the tokenizer or processor; in current TRL this name replaced the former
tokenizer= argument, and if omitted it is loaded from the model id).
from datasets import load_dataset
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
dataset = load_dataset("trl-lib/Capybara", split="train") # conversational "messages" format
args = SFTConfig(
output_dir="qwen3-0.6b-capybara-lora",
learning_rate=1e-4, # adapters tolerate a higher rate than full fine-tuning
num_train_epochs=1,
per_device_train_batch_size=4,
gradient_accumulation_steps=4,
max_length=2048, # truncation length (formerly max_seq_length)
packing=True, # pack short examples into full-length sequences
assistant_only_loss=True, # loss on assistant turns only (needs a generation-aware chat template)
logging_steps=10,
)
trainer = SFTTrainer(
model="Qwen/Qwen3-0.6B",
args=args,
train_dataset=dataset,
peft_config=LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"),
)
trainer.train()
trainer.save_model() # saves the adapter, not the merged model
Arguments worth knowing (all confirmed in the TRL 1.14.1 SFTConfig reference):
-
max_length(default 1024) truncates tokenized sequences; with packing it sets the packed sequence length. -
packingandpacking_strategy(bfdby default, plusbfd_splitandwrapped) raise throughput by removing padding. -
completion_only_lossdefaults to loss on the completion for prompt-completion datasets and on the whole sequence for plain text;assistant_only_loss=Truerestricts loss to assistant messages in conversational data and requires the chat template to mark assistant spans (TRL patches the templates of some known model families automatically and documents thegenerationmarkers for others). -
dataset_text_field(default"text") andformatting_funcselect or build the text to train on. -
chat_template_pathandeos_tokenalign a base model with a chat template and stop token. -
model_init_kwargsforwards arguments such asdtypetofrom_pretrained; when the trainer loads the model from an id the default dtype is float32 unless you set it, which differs fromfrom_pretrainedin transformers v5. -
loss_typedefaults to a chunked negative log-likelihood that lowers peak memory;use_liger_kernelswitches it to the plain variant. -
Gradient checkpointing is on by default and
bf16is enabled automatically whenfp16is not set.
Because SFTConfig extends TrainingArguments, all standard options (eval_strategy, save_steps, report_to,
push_to_hub, hub_model_id) apply. Pushing needs a write token read from the environment ($HF_TOKEN or hf auth login),
never hard-coded, see Hub Essentials. Official reference:
TRL — SFT Trainer.
Preference tuning with DPO
After SFT, Direct Preference Optimization aligns the model with pairs of a preferred (chosen) and a dispreferred
(rejected) answer to the same prompt, without training a separate reward model. With a trainable policy
, a frozen reference (the model before DPO), a preferred completion , a rejected one
, the sigmoid and the strength parameter , the loss is
so it widens the margin between the two implicit rewards while beta (default 0.1 in DPOConfig; higher means staying
closer to the reference) limits drift. When you pass no ref_model, TRL uses the initial policy (the model before DPO starts) as
the reference; with a PEFT adapter that is the base model with the adapter switched off, so no second full copy of the
weights is needed.
from datasets import load_dataset
from peft import LoraConfig
from trl import DPOConfig, DPOTrainer
dataset = load_dataset("trl-lib/ultrafeedback_binarized", split="train") # prompt / chosen / rejected
args = DPOConfig(
output_dir="qwen3-0.6b-dpo-lora",
beta=0.1,
learning_rate=1e-5, # TRL suggests about 1e-5 for adapters; default is 1e-6
max_length=1024,
per_device_train_batch_size=2,
gradient_accumulation_steps=8,
)
trainer = DPOTrainer(
model="Qwen/Qwen3-0.6B",
args=args,
train_dataset=dataset,
peft_config=LoraConfig(r=16, lora_alpha=32, target_modules="all-linear", task_type="CAUSAL_LM"),
)
trainer.train()
The preference dataset holds an explicit prompt plus chosen and rejected, either as plain strings or as lists of
chat messages (TRL applies the chat template for conversational data). DPOConfig.loss_type defaults to ["sigmoid"] and
accepts other published variants (for example hinge, ipo, sigmoid_norm for length normalization) and weighted
combinations through loss_weights. When passing your own processing_class to DPOTrainer, its padding side must be
left. Start DPO from an SFT checkpoint of the same model (merge the SFT adapter first, or continue training it as a
PeftModel); applying DPO to a base model that never saw your format rarely works. Official reference:
TRL — DPO Trainer and the paper
Direct Preference Optimization.
Preparing datasets
Data quality dominates. A few hundred clean, diverse, consistently formatted examples routinely beat tens of thousands of noisy ones for narrow tasks; pick the size by watching the evaluation set. A preparation checklist:
-
Pick the TRL format.
messages(chat turns),promptpluscompletion, or plaintextfor SFT;prompt,chosen,rejectedfor DPO. Let TRL apply the chat template (conversational formats) rather than concatenating strings by hand, and train with the same template you will serve with, or responses will not terminate properly. -
Deduplicate and filter. Remove near-duplicates, empty or truncated answers, and examples that leak your evaluation set. Decide how you treat personal data: fine-tuned weights can reproduce training text.
-
Split before you tune. Keep a held-out validation set and a separate final test set; never tune on the test set.
-
Check licences. Dataset cards carry licences and terms just like models; outputs of a hosted model may be restricted as training data by that provider’s terms.
-
Watch length. Examples longer than
max_lengthare truncated, which can cut off the answer you want to teach.
from datasets import load_dataset
raw = load_dataset("json", data_files="support_tickets.jsonl", split="train")
# each row: {"question": "...", "answer": "..."}
def to_messages(row):
return {
"prompt": [{"role": "user", "content": row["question"]}],
"completion": [{"role": "assistant", "content": row["answer"]}],
}
ds = raw.map(to_messages, remove_columns=raw.column_names)
ds = ds.shuffle(seed=42).train_test_split(test_size=0.1, seed=42)
train_ds, eval_ds = ds["train"], ds["test"]
With a prompt-completion dataset, SFT computes loss on the completion only by default. Official reference: TRL — Dataset formats and datasets documentation.
Merging adapters
A LoRA adapter is a small pair of files (adapter_config.json and the weights, in safetensors) that only makes sense next
to its base model. You have two deployment choices:
-
Keep it separate. Load the base once and swap adapters per task. Runtimes that support adapters (vLLM, llama.cpp) can do this; the cost is a little extra compute per token.
-
Merge it. Fold into once, giving a plain model with no extra latency and no dependency on PEFT at inference time. Merge into a full-precision copy of the base: merging into already-quantized weights rounds the result and is unsupported for some quantization schemes, so after QLoRA reload the base in bf16, attach the adapter, merge, then quantize the merged model for the target runtime.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-0.6B", dtype=torch.bfloat16)
model = PeftModel.from_pretrained(base, "qwen3-0.6b-capybara-lora")
merged = model.merge_and_unload() # returns the merged model; not in place
merged.save_pretrained("qwen3-0.6b-merged") # safetensors weights and config
AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B").save_pretrained("qwen3-0.6b-merged")
Merged output is a derivative of the base model: keep the base model’s licence obligations and attribution when you publish it (see Choosing Models and Licences). Official reference: PEFT — LoRA developer guide (merge_and_unload).
Exporting to GGUF and ONNX for on-device use
The merged directory is an ordinary Hugging Face checkpoint, so the conversion tools from Conversion & Quantization apply to it (formats are explained on Model Formats).
GGUF (llama.cpp, Ollama, LM Studio). llama.cpp ships convert_hf_to_gguf.py for full models and
convert_lora_to_gguf.py for PEFT adapters; the latter takes the adapter directory and a --base directory with the base
model’s config and tokenizer, and writes a GGUF adapter that llama.cpp loads with its --lora option at run time.
Quantizing is a separate step with llama-quantize, covered on Quantization for Inference
and llama.cpp.
# Option A: merged model -> f16 GGUF -> quantized GGUF
python convert_hf_to_gguf.py ./qwen3-0.6b-merged --outfile qwen3-0.6b-merged-f16.gguf --outtype f16
llama-quantize qwen3-0.6b-merged-f16.gguf qwen3-0.6b-merged-Q4_K_M.gguf Q4_K_M
# Option B: keep the adapter separate; base GGUF plus a GGUF adapter
python convert_hf_to_gguf.py ./Qwen3-0.6B --outfile Qwen3-0.6B-f16.gguf --outtype f16
python convert_lora_to_gguf.py ./qwen3-0.6b-capybara-lora --base ./Qwen3-0.6B --outtype f16 \
--outfile qwen3-0.6b-capybara-lora-f16.gguf
llama-server -m Qwen3-0.6B-f16.gguf --lora qwen3-0.6b-capybara-lora-f16.gguf
Without --outfile, convert_lora_to_gguf.py writes its default-named file next to the adapter, so pass --outfile to
control where the adapter GGUF lands (here ./Qwen3-0.6B must hold the base model’s downloaded files). Only architectures
supported by llama.cpp convert, and flags evolve, so run each script with --help. Official references:
convert_lora_to_gguf.py and
llama.cpp.
ONNX (ONNX Runtime, Transformers.js, Windows, Android). Export the merged model with Optimum ONNX; for text generation the
text-generation-with-past task keeps the key-value cache inside the graph. For on-device LLMs the ONNX Runtime GenAI
model builder produces a runtime-ready folder, including int4 weights; its README documents the current command-line
flags, which this page does not repeat because they change between releases. Classifier and embedding models export
directly as encoders.
pip install "optimum-onnx[onnxruntime]"
optimum-cli export onnx --model ./qwen3-0.6b-merged --task text-generation-with-past qwen3-0.6b-onnx/
optimum-onnx 0.1.0, the latest release on PyPI on 2026-09-30, pins optimum~=2.1.0 and
transformers>=4.36,<4.58, so installing it downgrades a transformers 5 environment; run the export in a separate
virtual environment (for example python -m venv .venv-onnx) and keep the transformers 5 baseline in your main one.
Expect to re-check accuracy on your evaluation set after every quantization or export step; each one changes the weights. Core ML, LiteRT, MLX and ExecuTorch routes start from the same merged checkpoint and are listed on Model Formats. Official references: Optimum ONNX and ONNX Runtime GenAI.
Fine-tuning embedding models with sentence-transformers
When a retrieval pipeline underperforms on your domain (legal clauses, part numbers, internal jargon), fine-tuning the
embedding model often beats a larger general one and stays cheap: encoder models with tens of millions of parameters train
in minutes. sentence-transformers provides SentenceTransformerTrainer, a Trainer subclass, with losses that take
pairs or triplets. The common starting point is MultipleNegativesRankingLoss: each batch gives (anchor, positive) pairs
and every other positive in the batch acts as a negative, so larger batches help and duplicates in a batch hurt, hence
BatchSamplers.NO_DUPLICATES.
from datasets import Dataset
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
)
from sentence_transformers.losses import MultipleNegativesRankingLoss
from sentence_transformers.sampler import BatchSamplers
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") # Apache-2.0
train = Dataset.from_dict({
"anchor": ["How do I reset my password?", "What is the refund window?"],
"positive": ["To reset your password open Settings > Security ...", "Refunds are accepted within ..."],
})
args = SentenceTransformerTrainingArguments(
output_dir="minilm-support",
num_train_epochs=1,
per_device_train_batch_size=32,
learning_rate=2e-5,
warmup_ratio=0.1,
batch_sampler=BatchSamplers.NO_DUPLICATES,
)
trainer = SentenceTransformerTrainer(
model=model,
args=args,
train_dataset=train,
loss=MultipleNegativesRankingLoss(model),
)
trainer.train()
model.save_pretrained("minilm-support/final")
Measure with a held-out set of queries and relevant documents (recall at k, MRR) using the library’s evaluators, and re-embed your whole corpus after changing the model: vectors from different models, or before and after tuning, are not comparable. Dimension and index implications are covered on Generating Embeddings in Practice. Hard negatives (passages that look relevant but are not) improve results the most and can be mined with the current model. Official reference: Sentence Transformers — Training overview.
Fine-tuning classifiers with SetFit
SetFit trains a text classifier from a handful of labelled examples per class (often 8 to 64). It first fine-tunes a
sentence-transformer contrastively on pairs generated from the labelled texts, then fits a small classification head on the
resulting embeddings. The result is a small model that runs on CPU and is easy to export. For larger labelled sets, fine-tuning
an encoder such as BERT with Trainer remains a solid alternative and is worth trying against SetFit on your evaluation set.
Check the project’s repository for recent activity before depending on it in a long-lived product.
from datasets import load_dataset
from setfit import SetFitModel, Trainer, TrainingArguments, sample_dataset
dataset = load_dataset("SetFit/sst2")
train_ds = sample_dataset(dataset["train"], label_column="label", num_samples=8) # 8 per class
test_ds = dataset["test"]
model = SetFitModel.from_pretrained("BAAI/bge-small-en-v1.5") # MIT-licensed embedding body
args = TrainingArguments(batch_size=32, num_epochs=1)
trainer = Trainer(model=model, args=args, train_dataset=train_ds)
trainer.train()
print(trainer.evaluate(test_ds))
model.save_pretrained("setfit-sst2-8-shot")
print(model.predict(["A charming and affecting journey.", "It is slow -- very, very slow."]))
Datasets need text and label columns unless you pass column_mapping. Official reference:
SetFit — Quickstart. For the task-head route see the
transformers fine-tuning guide.
Compute estimates
Estimate before renting or buying hardware. Let be the parameter count of the base model, the bytes per stored weight (2 for bf16, about 0.5 for 4-bit), the number of training tokens processed, and the number of trainable parameters.
Memory. Training memory is the sum of weights, gradients, optimizer state and activations:
For full fine-tuning with mixed precision and AdamW, , gradients are kept at 2 or 4 bytes and the optimizer keeps two moment tensors (and often an fp32 master copy of the weights), so a common planning figure is on the order of 16 bytes per parameter before activations. With LoRA, is tiny, so the last term collapses and ; with QLoRA, plus quantization constants. grows with batch size and sequence length and shrinks with gradient checkpointing (on by default in TRL) at the cost of an extra forward pass. The inference-side counterpart (KV cache) is on Hardware Sizing.
Time. A transformer forward pass costs about floating-point operations per token and a full training step about (forward plus backward). LoRA skips the weight-gradient part of the backward pass, so it is cheaper than full fine-tuning but not free, and gradient checkpointing adds a recomputed forward pass. With a sustained throughput (peak FLOP/s of your accelerator times the fraction you actually reach, typically well below one):
Read your accelerator’s peak figure from its datasheet and measure the efficiency fraction with a 50-step pilot run; this page does not quote vendor numbers because they change with every generation and depend on precision and sparsity. Token count examples average tokens per example epochs (with packing, padding does not inflate it).
def train_estimate(params_b, *, bytes_per_weight, trainable_params, tokens,
peak_flops, efficiency=0.3, c=5.0, act_gb=4.0,
grad_opt_bytes_per_trainable=12):
"""params_b in billions; peak_flops in FLOP/s; efficiency is the fraction sustained."""
P = params_b * 1e9
mem_gb = (P * bytes_per_weight + trainable_params * grad_opt_bytes_per_trainable) / 1e9 + act_gb
hours = c * P * tokens / (peak_flops * efficiency) / 3600
return mem_gb, hours
# LoRA r=16 on all linear layers of a 7B model is on the order of tens of millions of parameters
lora = train_estimate(7, bytes_per_weight=2, trainable_params=40e6, tokens=5e6, peak_flops=1e14)
qlora = train_estimate(7, bytes_per_weight=0.55, trainable_params=40e6, tokens=5e6, peak_flops=1e14)
full = train_estimate(7, bytes_per_weight=2, trainable_params=7e9, tokens=5e6, peak_flops=1e14, c=6)
print(lora, qlora, full) # (GB, hours) for each setup; peak_flops here is a placeholder, use your datasheet
The calculator is a planning tool with explicit assumptions (the 12 bytes per trainable parameter cover fp32 gradients
and two fp32 Adam moments; change it for your optimizer); it ignores fragmentation and multi-GPU overheads, so leave
headroom and confirm with a short run. trainer.train() logs real throughput, and num_tokens and mean_token_accuracy
are logged by the TRL trainers for tracking.