teachyou.ai academy
← All posts
Fine-TuningLoRAPEFTQLoRAadapters

LoRA Fine-Tuning: A Practical Guide

Pramod Dutta · Jun 27, 2026 · 12 min read

LoRA fine-tuning is a way to adapt a large language model to your task by training a tiny set of new weights while leaving the original model frozen. Instead of updating billions of parameters, you inject small low-rank matrices into a few layers and train only those, which cuts memory and disk cost by one to two orders of magnitude. This guide shows you what LoRA is doing under the hood, gives you runnable Hugging Face code, and covers the knobs (rank, alpha, target modules, QLoRA) that actually change your results.

What LoRA fine-tuning actually does

A transformer layer is mostly big weight matrices. Full fine-tuning takes a pretrained weight matrix W of shape (d, k) and nudges every entry with gradient descent. That means you store optimizer state (with Adam, roughly two extra copies of every trainable parameter) plus gradients plus the weights themselves. For a 7B model in mixed precision that is tens of gigabytes before you have even loaded a batch.

LoRA (Low-Rank Adaptation) starts from an observation: the update you apply during fine-tuning, call it delta_W, tends to be low rank. You do not need a full (d, k) matrix of changes. So instead of learning delta_W directly, LoRA factors it into two skinny matrices:

delta_W = B @ A
A has shape (r, k)
B has shape (d, r)
r is the rank, and r is much smaller than d or k

At inference the layer computes W @ x + (B @ A) @ x, scaled by a factor. W stays frozen. Only A and B get gradients. If d = k = 4096 and you pick r = 8, the full matrix has about 16.7 million parameters, while A and B together have about 65 thousand. That is the whole trick: you train roughly 0.4 percent of one layer's parameters and get most of the adaptation.

Because W never changes, you can keep one copy of the base model on disk and ship a handful of small adapter files, often a few megabytes each, one per task. You can also merge the adapter back into W after training so there is zero inference overhead.

Why engineers reach for LoRA fine-tuning

  • Memory: you skip optimizer state for the frozen weights, which is where most VRAM goes. A model that needs a data-center GPU for full fine-tuning often fits on a single consumer or mid-range card with LoRA.
  • Speed of iteration: smaller trainable set means faster steps and quicker experiments. You can try five hyperparameter settings in the time one full fine-tune would take.
  • Storage and deployment: adapters are tiny. Hosting twenty task-specific variants means one base model plus twenty small files, not twenty full checkpoints.
  • Less catastrophic forgetting: freezing the base weights preserves general capability better than hammering every parameter, which matters when your dataset is small.

LoRA is not magic. If you are teaching the model genuinely new knowledge at scale, or doing continued pretraining on a new domain corpus, full fine-tuning or a larger adapter budget can still win. LoRA shines for style, format, instruction following, tool-call formatting, and task specialization where the base already knows the underlying material.

The core hyperparameters

Three settings decide most of your outcome.

  • r (rank): the width of the bottleneck. Common values run from 8 to 64. Higher rank means more capacity and more trainable parameters. Start at 8 or 16.
  • lora_alpha: a scaling factor. The adapter output is multiplied by alpha / r. A frequent convention is to set alpha to about twice r, so the effective scale stays stable as you change rank. If you double r you often double alpha to keep behavior comparable.
  • target_modules: which weight matrices get an adapter. The classic choice is the attention projections (q_proj, v_proj), but applying LoRA to all linear layers (including k_proj, o_proj, and the MLP gate_proj, up_proj, down_proj) usually helps and is the modern default when memory allows.

Two more you will touch:

  • lora_dropout: dropout on the adapter input, often 0.05, a light regularizer.
  • bias: whether to train bias terms. none is standard and cheapest.

A practical rule: change one thing at a time. If quality is low, first widen target_modules to cover all linear layers, then raise r. Do not blindly crank r to 256; past a point you pay memory for capacity your data cannot fill.

Runnable LoRA fine-tuning with Hugging Face PEFT

Here is a complete, minimal training script using transformers, peft, datasets, and trl. It fine-tunes an instruction model on a small chat-style dataset. Swap the model id and dataset for your own.

First, install the stack:

pip install "transformers>=4.44" peft datasets accelerate trl bitsandbytes

Now the training script:

import torch
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer, SFTConfig

model_id = "Qwen/Qwen2.5-3B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(model_id)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj",
    ],
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()

dataset = load_dataset("json", data_files="train.jsonl", split="train")

def to_text(example):
    messages = example["messages"]
    text = tokenizer.apply_chat_template(messages, tokenize=False)
    return {"text": text}

dataset = dataset.map(to_text)

training_args = SFTConfig(
    output_dir="./lora-out",
    num_train_epochs=3,
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    bf16=True,
    max_seq_length=2048,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset,
)

trainer.train()
trainer.save_model("./lora-out")

A few things worth noticing in that script:

  • print_trainable_parameters() prints something like "trainable params: 30M, all params: 3B, trainable%: 1.0". Always check this. If it says you are training the whole model, your config did not apply.
  • The learning rate 2e-4 is much higher than you would use for full fine-tuning (which is often 1e-5 to 2e-5). LoRA adapters start near zero and tolerate, in fact need, a larger learning rate.
  • Effective batch size is per_device_train_batch_size * gradient_accumulation_steps, here 2 * 8 = 16. Tune accumulation to fit memory while keeping a reasonable effective batch.
  • The training data is a JSONL file where each line looks like {"messages": [{"role": "user", "content": "..."}, {"role": "assistant", "content": "..."}]}. The chat template turns that into the exact string format the model expects.

QLoRA: fine-tuning a big model on one GPU

QLoRA is LoRA plus 4-bit quantization of the frozen base model. You load W in a compressed 4-bit format, keep the LoRA adapters in bf16, and train. The base weights take a quarter of the memory they would in bf16, and since they are frozen you never need full precision for them during the forward and backward pass. This is what lets people fine-tune a 70B model on a single high-memory GPU.

Add a quantization config to the loader:

from transformers import BitsAndBytesConfig
from peft import prepare_model_for_kbit_training

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)

model = AutoModelForCausalLM.from_pretrained(
    model_id,
    quantization_config=bnb_config,
    device_map="auto",
)

model = prepare_model_for_kbit_training(model)
model = get_peft_model(model, lora_config)

Notes on the QLoRA settings:

  • nf4 (normal float 4) is the quantization type designed for normally distributed weights. It is the standard choice over plain fp4.
  • bnb_4bit_compute_dtype=torch.bfloat16 means the actual matrix math happens in bf16 after dequantizing on the fly. The 4-bit form is storage, not compute.
  • Double quantization saves a bit more memory by quantizing the quantization constants. It is cheap and worth leaving on.
  • prepare_model_for_kbit_training casts layer norms to fp32 and enables gradient checkpointing hooks so training stays numerically stable.

The tradeoff: QLoRA trains a little slower per step because of dequantization overhead, and the quantized base is marginally less precise. In practice the quality gap versus 16-bit LoRA is small for most tasks, and the memory savings are decisive.

Loading, merging, and serving the adapter

After training you have an adapter directory with adapter_config.json and adapter_model.safetensors, usually just a few megabytes. To use it, load the base model and attach the adapter:

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

base = AutoModelForCausalLM.from_pretrained(
    "Qwen/Qwen2.5-3B-Instruct", torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./lora-out")

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-3B-Instruct")
messages = [{"role": "user", "content": "Summarize the release notes below."}]
prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=256)
print(tokenizer.decode(out[0], skip_special_tokens=True))

Keeping the adapter separate lets you hot-swap tasks: load the base once, attach adapter A, run, detach, attach adapter B. For production latency you usually merge instead. Merging folds B @ A into W so there is no extra matrix multiply at inference:

merged = model.merge_and_unload()
merged.save_pretrained("./merged-model")
tokenizer.save_pretrained("./merged-model")

The merged model is a normal full-weight checkpoint you can serve with vLLM, TGI, or any standard runtime. One caveat: you cannot cleanly merge an adapter that was trained on a 4-bit quantized base back into a 4-bit base. Merge into a 16-bit copy of the base, then re-quantize if needed. Many serving stacks (vLLM among them) can also load LoRA adapters directly and serve several at once, which is the better path when you want multi-tenant task routing without duplicating the base.

Building a dataset that LoRA can learn from

The config matters less than the data. A few field-tested guidelines:

  • Consistency beats volume. A few hundred to a few thousand clean, on-format examples usually outperform a noisy pile. LoRA has limited capacity by design, so every example should teach the pattern you want.
  • Match the inference format exactly. If production sends a system prompt, include the same system prompt in training. If it uses a specific tool-call JSON shape, every training example must use that exact shape. The model learns the format you show it, punctuation and all.
  • Mask the prompt, train on the completion. For instruction tuning you generally want the loss computed only on the assistant's response, not on the user's question. trl's SFT trainer supports a completion-only collator or a DataCollatorForCompletionOnlyLM to do this. Training on the prompt too can make the model parrot inputs.
  • Hold out a validation set. Even a small eval split catches overfitting, which with LoRA shows up as the model memorizing training answers and losing generality.

Common failure modes and how to catch them

  • Trainable percent is wrong. If print_trainable_parameters shows near 100 percent or near 0 percent, your target_modules names do not match the model's actual module names. Print the model with print(model) and read the real names; they differ across architectures.
  • Loss will not drop. Usually the learning rate is too low for LoRA. Try 1e-4 to 3e-4. If loss is unstable or NaN, lower it and confirm you are in bf16, not fp16, on hardware that supports bf16.
  • Great training loss, useless outputs. Overfitting. Reduce epochs, add lora_dropout, shrink r, or add more varied data. Three epochs is a common ceiling for small datasets.
  • Chat template mismatch. If the fine-tuned model rambles or ignores structure, verify the exact template string. Print tokenizer.apply_chat_template(messages, tokenize=False) and eyeball the special tokens.
  • Adapter has no effect at inference. Confirm you actually attached it with PeftModel.from_pretrained and did not silently load only the base, and that you are pointing at the right adapter directory.

A sensible starting recipe

If you want defaults that work more often than not on a mid-size instruction model, start here and only deviate when the metrics tell you to:

  • r = 16, lora_alpha = 32, lora_dropout = 0.05, bias = "none".
  • target_modules: all linear layers (attention plus MLP).
  • Learning rate 2e-4, cosine schedule, 3 percent warmup.
  • 2 to 3 epochs, effective batch size 16 to 32.
  • bf16 if your GPU supports it; QLoRA 4-bit if you are memory constrained.
  • Always print trainable params, always keep a validation split, always match the production prompt format.

Run that, read the eval, then change exactly one variable at a time. LoRA fine-tuning rewards disciplined iteration far more than exotic settings.

FAQ

Is LoRA fine-tuning as good as full fine-tuning? For most task-specialization, style, formatting, and instruction-following work on a model that already knows the domain, LoRA gets very close to full fine-tuning at a fraction of the cost. Full fine-tuning still has an edge when you are injecting large amounts of new knowledge or doing continued pretraining on a fresh domain corpus. Raise r and widen target_modules before concluding LoRA cannot reach your target.

What is the difference between LoRA and QLoRA? LoRA freezes the base model and trains small low-rank adapters. QLoRA does the same but also loads the frozen base in 4-bit precision to slash memory, keeping only the adapters in higher precision. QLoRA lets you fine-tune much larger models on a single GPU, at a small cost in step speed and a usually negligible quality difference.

How do I choose the rank r? Start at 8 or 16. If the model underfits (training loss plateaus high, outputs miss the pattern), first apply LoRA to more modules, then raise r to 32 or 64. Keep lora_alpha around twice r so the effective scale stays steady. Very high ranks add trainable parameters and memory without helping if your dataset is small.

Which target_modules should I use? The safe modern default is all linear layers: the attention projections (q_proj, k_proj, v_proj, o_proj) and the MLP projections (gate_proj, up_proj, down_proj). The older q_proj, v_proj only choice trains fewer parameters but usually leaves quality on the table. Confirm the exact module names by printing the model, since they vary by architecture.

Do I have to merge the adapter to use it? No. You can serve the base model with the adapter attached at runtime, which is ideal for swapping between multiple task-specific adapters. Merging folds the adapter into the base weights to remove inference overhead and produce a standard checkpoint. Merge for single-task low-latency serving; keep adapters separate for multi-task or multi-tenant setups.

How much data do I need for LoRA fine-tuning? Often less than people expect. A few hundred to a few thousand clean, consistent, correctly formatted examples can teach a task well. Quality and format consistency matter more than raw count. Always hold out a validation split so you can see overfitting before it reaches production.

Can I fine-tune on a consumer GPU? Yes, that is one of the main reasons to use LoRA. With QLoRA 4-bit quantization, gradient checkpointing, and a modest sequence length, models in the single-digit-billions range fit comfortably on a mid-range card, and larger models become reachable on a single high-memory GPU. Reduce batch size and lean on gradient accumulation if you hit out-of-memory errors.