Hands-On: Fine-Tune a Small Model with LoRA
Introduction
This is it — the capstone. Across this container you learned when to fine-tune (Section 1), the mechanics (LoRA, QLoRA, memory, hyperparameters — Section 2), how to build the dataset (Section 3), and the post-training methods and evaluation (Section 4). Now you put it all together into one runnable pipeline — and fine-tune a real model, end to end, on a free GPU.
Here's the headline that would've been science fiction a few years ago: thanks to QLoRA and Unsloth, you can fine-tune a capable small model (Llama-3.2-3B, Qwen) on a free Google Colab T4 (16 GB) in a single afternoon — and walk away with a ~100 MB adapter (or a q4_k_m GGUF) you run on your laptop in Ollama. This lesson is the complete recipe: load → adapt → format → train → evaluate → ship, plus the debugging craft (OOM, loss, template bugs) that turns a notebook that errors into one that works.

The Pipeline: Seven Steps, One Afternoon
The whole hands-on is seven steps, each one a lesson you've already met — now wired into a single runnable flow:
- Environment — Unsloth (≈2× faster, ~70% less memory) on a pinned stack (Python 3.11+, PyTorch 2.5+, CUDA 12.x). Pin versions for reproducibility.
- Load a 4-bit base — QLoRA's frozen NF4 base (L191/L193):
Llama-3.2-3B-Instruct-bnb-4bit. - Attach LoRA adapters — the L193 config (
r=16,α=16, all 7 target modules), with gradient checkpointing on. - Format the dataset — apply the model's own chat template into a
textfield (the L199 formatting step — get this exactly right). - Train — TRL's
SFTTrainerwith the L194 recipe (lr 2e-4, cosine + warmup, effective batch 8), watching train/val loss. - Evaluate — the delta vs base on a held-out test + a regression check (L205).
- Save & deploy — a tiny LoRA adapter, a merged model, or a GGUF for Ollama/llama.cpp.
You'll run this exact flow — and feel the VRAM and loss gates — in the Fine-Tune Run lab. Let's see the code.
Steps 1–3: Load a 4-Bit Base & Attach LoRA
Unsloth wraps the QLoRA setup into a few lines. Load the 4-bit base (this is the L190 memory win — a 3B fits in ~2 GB), then attach LoRA adapters with the L193 config:
from unsloth import FastLanguageModel
# STEP 2 - load a 4-bit (QLoRA) base. ~2 GB for a 3B model -> fits a free T4. (L190/L191/L193)
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = 'unsloth/Llama-3.2-3B-Instruct-bnb-4bit',
max_seq_length = 2048, # lower this first if you hit OOM
load_in_4bit = True, # the QLoRA frozen NF4 base
)
# STEP 3 - attach LoRA adapters (the trainable part). (L193)
model = FastLanguageModel.get_peft_model(
model, r = 16, lora_alpha = 16, lora_dropout = 0,
target_modules = ['q_proj','k_proj','v_proj','o_proj', # all-linear:
'gate_proj','up_proj','down_proj'], # attention + MLP
use_gradient_checkpointing = 'unsloth', # big VRAM saver - keep this ON
)
# Only the LoRA adapters train: ~1-2% of the parameters.Steps 4–5: Format the Data & Train
Format every row with the model's own chat template (the L199 step that prevents the #1 silent bug), then train with TRL's SFTTrainer using the L194 recipe — and mask the loss to the responses:
from trl import SFTTrainer, SFTConfig
# STEP 4 - format with the MODEL'S OWN chat template (+ EOS). (L199)
def fmt(ex): return {'text': tokenizer.apply_chat_template(ex['messages'], tokenize=False)}
ds = dataset.map(fmt) # train.jsonl from your L200 dataset build
# STEP 5 - train with the L194 recipe; watch train + val loss.
trainer = SFTTrainer(
model = model, tokenizer = tokenizer, train_dataset = ds, eval_dataset = val_ds,
args = SFTConfig(
learning_rate = 2e-4, lr_scheduler_type = 'cosine', warmup_ratio = 0.03, # L194
per_device_train_batch_size = 2, gradient_accumulation_steps = 4, # -> eff. batch 8
num_train_epochs = 3, bf16 = True,
completion_only_loss = True, # loss-mask the prompt (L199)
eval_strategy = 'steps', eval_steps = 50,
),
)
trainer.train() # if val loss climbs -> overfitting; if loss spikes -> LR too high (L194)Steps 6–7: Evaluate, Save & Run in Ollama
Before you trust it, evaluate vs the base (L205). Then save — just the tiny adapter, a merged model, or a GGUF you can run locally. Use the same chat template at inference — the #1 cause of garbage output:
# STEP 6 - EVALUATE vs the base on a held-out test: ship on the DELTA, not the absolute. (L205)
# check task lift AND a general-capability slice (catastrophic forgetting).
# STEP 7 - SAVE & DEPLOY.
model.save_pretrained('lora_adapter') # ~100 MB - just the adapter
model.save_pretrained_merged('merged', tokenizer) # base + adapter, full model
model.save_pretrained_gguf('gguf', tokenizer, # for Ollama / llama.cpp / LM Studio
quantization_method = 'q4_k_m') # small + good quality
# model.push_to_hub_gguf('me/my-model', tokenizer, quantization_method='q4_k_m')
# $ ollama create my-model -f Modelfile && ollama run my-model
# At inference, use the SAME chat template you trained with - or you get gibberish.The Hands-On Craft: Debugging a Run
Three problems hit everyone the first time — and each maps to an earlier lesson:
- CUDA out of memory (OOM). The most common wall. Fixes, in order: keep
load_in_4bit=True(QLoRA), dropper_device_train_batch_sizetoward 1 (use grad-accum to keep the effective batch), lowermax_seq_length, and keepuse_gradient_checkpointing='unsloth'ON. This is the L190 memory math — the lab shows you exactly which lever frees the most VRAM. - Loss not decreasing (or climbing). Watch train and val loss (L194): a flat/high loss means the LR is too low (raise toward 2e-4); val loss climbing means overfitting (fewer epochs / early-stop); loss spikes/NaN means the LR is too high.
- Garbage at inference (gibberish, won't stop). Almost always the wrong chat template or a missing EOS (L199) — you must use the exact template you trained with, in Ollama/llama.cpp too. The single most common cause of "it trained fine but the output is nonsense."
Knowing these three turns a frustrating afternoon into a shipped model.
See It: The Fine-Tune Run
Configure the whole run and watch it fit, train, and ship. Pick a model and a GPU, choose 4-bit vs 16-bit, and set the LoRA rank, batch, max_seq, LR, epochs, and dataset size. First the VRAM bar tells you if it fits or OOMs (try an 8B in 16-bit on the 12 GB card — boom — then fix it with 4-bit + smaller batch + gradient checkpointing). Then watch the training outcome and the eval delta vs base, and collect your artifact — a tiny adapter + a q4_k_m GGUF for Ollama. It's the entire container in one screen.

Every knob in that lab is a lesson you learned — memory (L190), quantization (L191), LoRA (L193), the recipe (L194), the data (Section 3), and the eval (L205) — now working together.
Why This Matters: The Whole Container, Realized
You came into this container able to use models; you leave able to customize them. That's a genuine step-change in capability — the difference between a prompt engineer and an AI engineer who can reshape a model's behavior. And the barrier has collapsed: what once required a research lab and a GPU cluster is now a free notebook and an afternoon. You can take an open base model, teach it your task, your tone, or your reasoning, evaluate that it genuinely improved, and ship a laptop-sized artifact — all with the decisions and discipline this container gave you: should I even fine-tune? (S1), LoRA or full, what config? (S2), is my data clean, covered, decontaminated? (S3), did it actually get better without forgetting? (S4). That's the complete loop of fine-tuning, and you now own it.
🧪 Try It Yourself
Run the full pipeline in the Fine-Tune Run lab:
- Cause an OOM, then fix it. Put an 8B model in 16-bit on the 12 GB card. Now get it to fit by changing the fewest knobs — which lever frees the most VRAM, and why (L190)?
- Get a clean ship. Configure a 3B QLoRA run on the T4 that converges, beats the base, and doesn't overfit. What LR, epochs, and dataset size did you use (L194/L195)?
- Break it two ways. Trigger diverge (how?) and overfit (how?). For overfit, what happens to general capability, and what's that called (L205)?
Bonus: your fine-tune trains perfectly but produces gibberish in Ollama. What's the single most likely cause, and the fix (L199)?
Mental-Model Corrections
- "Fine-tuning needs a GPU cluster." Not anymore — QLoRA + Unsloth fine-tune a 3B–8B model on a free Colab T4.
- "OOM means the model's too big." Usually it's the activations: drop the batch and max_seq_length, keep 4-bit + gradient checkpointing (L190) — the model itself fits fine.
- "If training loss drops, I'm done." Check val loss (overfitting), then evaluate vs base (L205). Training loss alone proves nothing.
- "It trained, so the output will be good." Not if you use the wrong chat template at inference — the #1 cause of gibberish (L199). Use the exact template you trained with.
- "I should merge and ship the full model." Often the ~100 MB adapter (or a
q4_k_mGGUF) is all you need — smaller, swappable, and laptop-runnable.
Key Takeaways
- The full pipeline: load a 4-bit base → attach LoRA → format with the chat template → train (the L194 recipe) → evaluate vs base → save a GGUF → run in Ollama — on a free T4, in an afternoon.
- The stack: Unsloth (≈2× faster, ~70% less memory) + TRL
SFTTrainer+ QLoRA; pin your versions. - Debug by lesson: OOM → 4-bit + smaller batch/seq + grad-checkpointing (L190); loss → LR / overfit (L194); gibberish → wrong chat template / EOS (L199).
- Ship the delta, not the demo: evaluate vs the base and watch for forgetting (L205) before you deploy.
- The artifact: a ~100 MB LoRA adapter or a
q4_k_mGGUF you run locally — your own specialized model.
That completes Fine-Tuning & Model Customization. You can now decide on, build the data for, train, evaluate, and ship a custom model end to end — the full craft of reshaping a model to your will.