Fine-Tuning Large Language Models (Conceptual)
Further learning · Complete notes beyond the classroom route
- Time
- ~37 min
- Sections
- 11
- Code cells
- 14
- Labs
- 1
- Figures
- 3
Before you start
How are large language models adapted to follow instructions, and what makes that practical on real hardware?
- You will be able to
- explain the difference between a base language model and an instruction-tuned one, and what supervised fine-tuning (SFT) does;
- format training examples with a chat template;
- apply input and target masking, and compute the causal language-modelling loss on the answer only;
- apply LoRA to an LLM and count what it trains;
- explain quantisation and QLoRA, and what they make possible;
- estimate whether a given fine-tuning method fits on given hardware.
So far in Module 8 we've adapted an encoder to classify reviews: a pretrained body plus a small head that outputs two scores. Many of the most important adaptations today are different. They take a generative language model, the kind that writes text one token at a time (Lesson 6.6), and teach it to follow instructions, hold a conversation, or answer in a particular format. Chat assistants are built this way.
The ideas are the ones you already know, applied to a different output. The model is still trained with cross-entropy on examples; LoRA still freezes the big weights and trains small ones. But the details change in ways that matter: the data is formatted as conversations, the loss is computed only on the parts the model should learn to write, and the models are so large that the memory arithmetic of Lesson 8.5 decides what's even possible.
This lesson is mostly conceptual: real LLM fine-tuning needs GPUs and hours. But every idea is demonstrated in code on a small chat model, SmolLM2-135M-Instruct, ending with a short supervised fine-tuning run that you can watch change the model's behaviour.
Key question: How are large language models adapted to follow instructions, and what makes that practical on real hardware?
Prerequisites
read Lessons 8.1, 8.2 and 8.5. You'll use next-token prediction and the causal language-modelling loss (Lesson 6.6), chat templates (Lessons 8.1 and 8.2), LoRA (Lesson 8.5) and int8 quantisation (Lesson 7.6).
Setup — imports, versions and device detection used by the notebook (1 cells)
The usual imports, seeds and device check, and the same harmless warnings as before. This lesson downloads two small models, SmolLM2-135M and SmolLM2-135M-Instruct, each a few hundred megabytes. Both are stored in bfloat16, and we load them in float32, because bfloat16 is very slow on most CPUs (Lesson 7.6).
Environment and reference results. Use Python 3.12 and requirements.txt (also supplied at the root of the Module 7/8 notebook bundle). Install packages into a separate environment, restart the kernel, and run from the beginning. The saved outputs are the author's CPU reference run, with approved explanatory corrections. Seeds and artifact revisions reduce variation; numbers and timings can differ across hardware. No account or paid API is required for the public teaching models. Initial model/data downloads require internet access.
import math, time, hashlibimport numpy as npimport torchimport torch.nn.functional as Ffrom datasets import load_datasetfrom huggingface_hub import get_safetensors_metadatafrom transformers import AutoTokenizer, AutoModelForCausalLM, AutoConfigfrom peft import LoraConfig, get_peft_modeltorch.manual_seed(0)np.random.seed(0)device = "cuda" if torch.cuda.is_available() else "cpu"print("PyTorch", torch.__version__, "| device:", device)# Freeze the public artifacts used by the recorded experiments.MODEL_REVISIONS = {'openai/clip-vit-base-patch32': '3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268', 'distilbert/distilbert-base-uncased-finetuned-sst-2-english': '714eb0fa89d2f80546fda750413ed43d93601a13', 'sentence-transformers/all-MiniLM-L6-v2': '1110a243fdf4706b3f48f1d95db1a4f5529b4d41', 'HuggingFaceTB/SmolLM2-135M': '93efa2f097d58c2a74874c7e644dbc9b0cee75a2', 'distilbert/distilgpt2': '2290a62682d06624634c1f46a6ad5be0f47f38aa', 'HuggingFaceTB/SmolLM2-135M-Instruct': '12fd25f77366fa6b3b4b768ec3050bf629380bac', 'HuggingFaceTB/SmolLM2-360M-Instruct': 'a10cc1512eabd3dde888204e902eca88bddb4951', 'Qwen/Qwen2.5-0.5B': '060db6499f32faf8b98477b0a26969ef7d8b9987', 'Qwen/Qwen2.5-7B': 'd149729398750b98c0af14eb82c78cfe92750796', 'google-bert/bert-large-uncased': '6da4b6a26a1877e173fca3225479512db81a5e5b', 'distilbert/distilbert-base-uncased': '12040accade4e8a0f71eabdb258fecc2e7e948be', 'google-bert/bert-base-uncased': '86b5e0934494bd15c9632b12f734a8a67f723594', 'cardiffnlp/twitter-roberta-base-sentiment-latest': '3216a57f2a0d9c45a2e6c20157c20c49fb4bf9c7', 'distilbert/distilbert-base-cased-distilled-squad': '564e9b582944a57a3e586bbb98fd6f0a4118db7f', 'google/vit-base-patch16-224': '3f49326eb077187dfe1c2a2bb15fbd74e6ab91e3', 'sshleifer/distilbart-cnn-12-6': 'a4f8f3ea906ed274767e9906dbaede7531d660ff', 'textattack/distilbert-base-uncased-imdb': '5b0f46c2fc4b86bf21f0ec0409bed77ee142b332', 'google/gemma-2-2b': 'c5ebcd40d208330abc697524c919956e692655cf', 'meta-llama/Llama-3.2-1B': '4e20de362430cd3b72f300e6b0f18e50e7166e08'}DATASET_REVISIONS = {'stanfordnlp/imdb': 'e6281661ce1c48d982bc483cf8a173c1bbeb5d31', 'stanfordnlp/sst2': '8d51e7e4887a4caaa95b3fbebbf53c0490b58bbb'}def benchmark_clock(): """Read elapsed time after synchronising any queued CUDA work.""" if torch.cuda.is_available(): torch.cuda.synchronize() import time return time.perf_counter()def ordered_fingerprint(texts, labels): """Hash ordered text/label pairs to verify alignment for paired comparisons. Arguments: texts: Text strings in the order expected by the caller. labels: Integer targets aligned with the input examples. """ import json, hashlib rows = [[str(t), int(y)] for t, y in zip(texts, labels)] return hashlib.sha256(json.dumps(rows, ensure_ascii=False, separators=(',', ':')).encode()).hexdigest()
/Users/solomonpromise/Documents/Simplilearn Classes/Deep Learning/_work78/.venv/lib/python3.12/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm W1017 23:00:37.903000 4290 site-packages/torch/distributed/elastic/multiprocessing/redirects.py:35] NOTE: Redirects are currently not supported in MacOs. W1017 23:00:38.052000 4290 site-packages/torch/utils/_pytree.py:630] <enum 'KernelPreference'> is an Enum subclass and is now natively supported by torch.compile as an opaque value type. Calling register_constant() on Enum subclasses is deprecated and will be an error in a future release. W1017 23:00:38.072000 4290 site-packages/torch/utils/_pytree.py:630] <enum 'ScaleCalculationMode'> is an Enum subclass and is now natively supported by torch.compile as an opaque value type. Calling register_constant() on Enum subclasses is deprecated and will be an error in a future release.
PyTorch 2.14.1 | device: cpu
The Big Picture: Adapting an LLM to Follow Instructions
A base (or pretrained) language model has learned one thing: to continue text the way text on the internet continues. Ask it a question and it may answer, but it may equally continue with another question, a list or an essay. An instruction-tuned model has been further trained to treat its input as a request and to respond helpfully. The two share the same architecture and almost the same weights; the difference is a stage of fine-tuning.
We can see the difference directly. SmolLM2-135M exists in both versions. We give the base model a plain request and the instruct model the same request in its chat format, both with greedy decoding:
BASE_LM, CHAT_LM = "HuggingFaceTB/SmolLM2-135M", "HuggingFaceTB/SmolLM2-135M-Instruct"tok = AutoTokenizer.from_pretrained(CHAT_LM, revision=MODEL_REVISIONS.get(CHAT_LM))base_lm = AutoModelForCausalLM.from_pretrained(BASE_LM, dtype=torch.float32, revision=MODEL_REVISIONS.get(BASE_LM)).to(device).eval() # float32: fast on CPUchat_lm = AutoModelForCausalLM.from_pretrained(CHAT_LM, dtype=torch.float32, revision=MODEL_REVISIONS.get(CHAT_LM)).to(device).eval()question = "Give me two tips for writing a good movie review."def generate(model, text, n=30): """Generate continuation tokens under the declared decoding settings and return decoded new text. Arguments: model: Model whose current parameters are used. text: One input text string. n: Maximum number of continuation tokens to generate. """ enc = tok(text, return_tensors="pt").to(device) with torch.inference_mode(): out = model.generate(**enc, max_new_tokens=n, do_sample=False, pad_token_id=tok.eos_token_id) return tok.decode(out[0, enc["input_ids"].shape[1]:], skip_special_tokens=True)print("BASE model, plain text:\n ", repr(generate(base_lm, question)))chat_prompt = tok.apply_chat_template([{"role": "user", "content": question}], tokenize=False, add_generation_prompt=True)print("INSTRUCT model, chat format:\n ", repr(generate(chat_lm, chat_prompt)))print(f"parameters: {sum(p.numel() for p in chat_lm.parameters()):,} (same architecture for both)")del base_lm
Loading weights: 0%| | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 3950.90it/s]Loading weights: 0%| | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 3661.36it/s]
BASE model, plain text: ' First, you should always try to be as objective as possible. Second, you should always try to be as objective as possible.\n\nThe first' INSTRUCT model, chat format: 'Writing a good movie review can be a challenging task, but there are several tips that can help you craft a compelling and informative piece. Here are two' parameters: 134,515,008 (same architecture for both)
Both models have 134,515,008 parameters and the same architecture. The base model treats the request as the start of a document. Its continuation sounds plausible but immediately repeats itself ("always try to be as objective as possible", twice). The instruct model treats it as a request and starts a helpful answer ("Here are two…"). Same architecture, same pretraining; the instruction tuning made the difference.
Instruction tuning usually happens in stages:
- Pretraining on huge amounts of text: next-token prediction (Lesson 6.6).
- Supervised fine-tuning (SFT) on curated examples of instructions and good responses.
- Often, preference tuning: further training from human (or AI) judgements of which of two responses is better. You'll hear of methods such as RLHF and DPO; they're beyond this lesson.
This lesson is about stage 2, and how it's made affordable.

Figure 8.6.1. This assistant-only SFT setup keeps prompt tokens visible as context while masking their targets from loss. Causal prediction shifts targets by one; LoRA adapts selected parameters.
What is the conceptual change in instruction tuning?
Train on task-oriented conversations and desired responses.
Supervised Fine-Tuning (SFT)
Supervised fine-tuning trains a pretrained language model on pairs of (instruction, desired response). The training objective is the same next-token prediction the model was pretrained with, so nothing new has to be invented. What changes is the data: instead of arbitrary web text, every example shows the model exactly the behaviour we want, an instruction followed by a good response.
For SFT to teach this behaviour cleanly, two things must be set up carefully, and the next three sections cover them:
- Formatting (Section 3): every example must use the same conversation format, with special markers for who is speaking. This is the format the model will see at inference time.
- Masking (Sections 4–5): the loss should reward the model for writing the response, not for predicting the instruction. The user's words aren't something the model should learn to produce.
The amount of data needed is often surprisingly small compared with pretraining: thousands to hundreds of thousands of examples. That's because SFT mostly teaches a format and a behaviour the model can already almost produce; the knowledge comes from pretraining.
Key terms
- Base model: a language model after pretraining only; it continues text.
- Instruction-tuned (instruct) model: a base model further trained to follow instructions.
- Supervised fine-tuning (SFT): fine-tuning a language model on (instruction, response) examples with the next-token loss.
- Preference tuning: further training from judgements of which response is better (for example RLHF or DPO).
What does supervised fine-tuning use?
Examples with input instructions and desired responses.
Instruction-Response Formatting and Chat Templates
A chat model reads a conversation as one long token sequence, with special tokens marking where each message starts and ends and who wrote it. The exact format is the model's chat template, stored with its tokeniser, and apply_chat_template produces it. Here is a one-turn conversation with an answer, formatted for SmolLM2:
conversation = [{"role": "user", "content": "Review: Dull, predictable and far too long.\nIs this movie review positive or negative?"}, {"role": "assistant", "content": "negative"}]text = tok.apply_chat_template(conversation, tokenize=False)print(text)ids = tok(text)["input_ids"]print(len(ids), "tokens | special tokens:", {t: tok.convert_tokens_to_ids(t) for t in ("<|im_start|>", "<|im_end|>")})
<|im_start|>system
You are a helpful AI assistant named SmolLM, trained by Hugging Face<|im_end|>
<|im_start|>user
Review: Dull, predictable and far too long.
Is this movie review positive or negative?<|im_end|>
<|im_start|>assistant
negative<|im_end|>
53 tokens | special tokens: {'<|im_start|>': 1, '<|im_end|>': 2}
Each message is wrapped in <|im_start|>role … <|im_end|>. The roles are system, user and assistant, and the tokeniser added SmolLM2's default system message by itself. The markers are single special tokens, with IDs 1 and 2, so the model can recognise turn boundaries exactly. The whole conversation is 53 tokens.
Every SFT example must use this exact template, because it's what the model will see at inference time. A model fine-tuned on a slightly different format, say with "User:" and "Bot:" in plain text, learns to expect that format, and behaves worse when served with the official one (and vice versa).
Key terms
- Special tokens: tokens with a reserved meaning, such as
<|im_start|>and<|im_end|>, used to structure a conversation.- Chat template: the model-specific format for turning a list of messages into one token sequence.
- System / user / assistant roles: the instruction-setting message, the person's messages and the model's replies.
Why use the model's chat template?
Its role and boundary tokens define the expected conversation interface.
Input and Target Masking
In the causal language-modelling loss, every position predicts the next token (Lesson 6.6). But we don't want the model to learn to write the user's message. We want it to learn the response, given the message. So the loss is computed only on the response tokens. In Hugging Face, any label set to -100 is ignored by the loss. We find where the prompt ends (everything up to and including the start of the assistant's turn) and mask everything before it:
prompt_text = tok.apply_chat_template(conversation[:1], tokenize=False, add_generation_prompt=True) # everything before the answerfull_ids = tok(text)["input_ids"]n_prompt = len(tok(prompt_text)["input_ids"])labels = [-100] * n_prompt + full_ids[n_prompt:] # -100 = "don't compute the loss here"print(f"{len(full_ids)} tokens: {n_prompt} prompt tokens masked, {len(full_ids) - n_prompt} answer tokens trained on")print(f"{'token':<16} {'label':>7}")for t, l in list(zip(full_ids, labels))[-8:]: print(f"{tok.convert_ids_to_tokens(t)!r:<16} {l:>7}")
53 tokens: 50 prompt tokens masked, 3 answer tokens trained on token label 'Ċ' -100 '<|im_start|>' -100 'ass' -100 'istant' -100 'Ċ' -100 'negative' 17728 '<|im_end|>' 2 'Ċ' 198
Of the 53 tokens, 50 belong to the prompt and are masked with -100; only 3 are trained on: the answer negative, the end-of-turn marker <|im_end|>, and a newline (Ċ is how this tokeniser displays a newline). Training on <|im_end|> matters: it teaches the model to stop after answering, rather than rambling on.
Key terms
- Input (prompt) masking: excluding prompt tokens from the loss so the model learns only the response.
- Label -100: the value Hugging Face's loss functions ignore.
Common mistake: training on the whole conversation, prompt included. The model then spends much of its learning on predicting user messages, and on long prompts with short answers the answer gets only a sliver of the loss. Mask the prompt.
Visual walkthrough: Separate visible context from supervised targets.

1. Format a toy exchange. Input tokens: [USER, hi, ASSISTANT, hello, EOS]. These names stand for token IDs in a simplified assistant-only supervised fine-tuning example.
2. Keep context visible. Attention mask: [1, 1, 1, 1, 1]. All five positions are real tokens. A separate causal attention mask prevents future-token access.
3. Mask prompt targets. Labels: [−100, −100, −100, hello, EOS]. −100 means ignore this target in cross-entropy. It does not hide the prompt from the model.
4. Apply causal shifting. logits at ASSISTANT → hello; logits at hello → EOS. Causal-LM loss aligns logits[:-1] with labels[1:]. Only assistant-response targets contribute here. LoRA changes selected parameters during training.
The app adds a paused animation of this same supplied example.
Paused on arrival. Use Previous step, Play/Pause, Next step or Reset. No model runs in this walkthrough.
What does a target label of minus 100 do here?
Excludes that position from the loss.
Causal Language Modelling Loss
The causal language-modelling loss is the cross-entropy of predicting each token from all the tokens before it (Lesson 6.6). Position t's output predicts token t + 1, so the logits and the targets are shifted by one before comparing. Let's compute it by hand on our example and check it against the model's own loss:
x = torch.tensor([full_ids], device=device)y = torch.tensor([labels], device=device)with torch.inference_mode(): logits = chat_lm(input_ids=x).logits # (1, T, vocabulary) model_loss = chat_lm(input_ids=x, labels=y).loss.item()pred_logits, targets = logits[0, :-1], y[0, 1:] # position t predicts token t+1keep = targets != -100by_hand = F.cross_entropy(pred_logits[keep], targets[keep]).item()all_tokens = F.cross_entropy(pred_logits, x[0, 1:]).item() # if nothing were maskedprint(f"vocabulary size {logits.shape[-1]:,} | positions scored: {int(keep.sum())}")print(f"loss on the answer tokens: by hand {by_hand:.4f} | from the model {model_loss:.4f}")print(f"loss if every token were a target (no masking): {all_tokens:.4f}")
vocabulary size 49,152 | positions scored: 3 loss on the answer tokens: by hand 2.9850 | from the model 2.9850 loss if every token were a target (no masking): 3.7479
The model scores a vocabulary of 49,152 tokens at every position, but only 3 positions count, the answer tokens. Our hand computation, 2.9850, matches the model's exactly. Without masking, averaging over every token gives 3.7479, a different number, dominated by how well the model predicts the prompt, which isn't what we want to train.
This is the same loss as pretraining. SFT changes what the examples look like and which positions count, not the objective.
-
In a training set, the prompts average 400 tokens and the answers 20. Without masking, roughly what share of the loss would come from the answer tokens?
About 20 / 420, so under 5% of the loss would come from the answers (assuming similar per-token losses), and over 95% from predicting the prompts. The model would spend almost all of its learning on text it never needs to write. With masking, 100% of the loss comes from the answers.
Why shift logits and targets by one?
Position t predicts the next token.
Applying LoRA to an LLM
6.1 What LoRA trains in an LLM
LoRA works on a language model exactly as on DistilBERT in Lesson 8.5: freeze the weights, add trainable low-rank matrices to chosen projections. In LLMs the usual targets are the attention projections (q_proj, k_proj, v_proj, o_proj) and often the feed-forward ones (gate_proj, up_proj, down_proj):
for targets_ in (["q_proj", "v_proj"], ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]): cfg = LoraConfig(r=8, lora_alpha=16, target_modules=targets_, task_type="CAUSAL_LM") pm = get_peft_model(AutoModelForCausalLM.from_pretrained(CHAT_LM, dtype=torch.float32, revision=MODEL_REVISIONS.get(CHAT_LM)), cfg) n_t = sum(p.numel() for p in pm.parameters() if p.requires_grad) print(f"SmolLM2-135M, LoRA r=8 on {len(targets_)} matrix types: {n_t:,} trainable ({n_t / sum(p.numel() for p in pm.parameters()):.2%})")del pm
Loading weights: 0%| | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 11422.79it/s]
SmolLM2-135M, LoRA r=8 on 2 matrix types: 460,800 trainable (0.34%)
Loading weights: 0%| | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 11255.88it/s]
SmolLM2-135M, LoRA r=8 on 7 matrix types: 2,442,240 trainable (1.78%)
On SmolLM2-135M, rank 8 on the query and value projections trains 460,800 parameters (0.34%); on all seven projection types, 2,442,240 (1.78%). For a large model the fraction is far smaller. We count from Qwen2.5-7B's configuration file:
c = AutoConfig.from_pretrained("Qwen/Qwen2.5-7B", revision=MODEL_REVISIONS.get("Qwen/Qwen2.5-7B")) # configuration only, a few kBn7b = sum(get_safetensors_metadata("Qwen/Qwen2.5-7B").parameter_count.values())h, kv = c.hidden_size, c.num_key_value_heads * (c.hidden_size // c.num_attention_heads)shapes = {"q_proj": (h, h), "k_proj": (h, kv), "v_proj": (h, kv), "o_proj": (h, h), "gate_proj": (h, c.intermediate_size), "up_proj": (h, c.intermediate_size), "down_proj": (c.intermediate_size, h)}print(f"Qwen2.5-7B: {c.num_hidden_layers} layers | hidden size {h} | key/value size {kv} | {n7b:,} parameters")for name, mats in (("q_proj + v_proj", ["q_proj", "v_proj"]), ("all seven", list(shapes))): n = c.num_hidden_layers * sum(8 * (shapes[m][0] + shapes[m][1]) for m in mats) # r * (d_in + d_out) per matrix print(f" LoRA r=8 on {name:<16} {n:>12,} trainable ({n / n7b:.3%} of the model)")
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads. Parse safetensors files: 0%| | 0/4 [00:00<?, ?it/s]Parse safetensors files: 25%|██▌ | 1/4 [00:02<00:06, 2.20s/it]Parse safetensors files: 50%|█████ | 2/4 [00:02<00:01, 1.00it/s]Parse safetensors files: 75%|███████▌ | 3/4 [00:02<00:00, 1.58it/s]Parse safetensors files: 100%|██████████| 4/4 [00:02<00:00, 1.93it/s]Parse safetensors files: 100%|██████████| 4/4 [00:02<00:00, 1.38it/s]
Qwen2.5-7B: 28 layers | hidden size 3584 | key/value size 512 | 7,615,616,512 parameters LoRA r=8 on q_proj + v_proj 2,523,136 trainable (0.033% of the model) LoRA r=8 on all seven 20,185,088 trainable (0.265% of the model)
Qwen2.5-7B has 28 layers, a hidden size of 3584, and smaller key and value projections (512 wide, because many query heads share each key/value head). LoRA rank 8 on the query and value projections trains 2,523,136 parameters, just 0.033% of the 7,615,616,512; on all seven projection types, 20,185,088 (0.265%). The trainable part of a 7B model is smaller than all of DistilBERT. As Lesson 8.5 explained, the frozen weights still have to fit in memory, which Section 7 deals with.
6.2 A tiny SFT run
Let's run real SFT, small enough for a CPU. The task is sentiment as a chat task: the instruct model must answer "positive" or "negative" to a question about a review. We use the fixed IMDb split (Lesson 7.1; the fingerprints confirm the same reviews):
def load_imdb_split(seed=0, n_val=2500, n_eval=1000, verbose=True): """Load the declared IMDb revision, remove exact duplicates and construct the seeded course splits. Arguments: seed: Seed controlling the local split or sampling generator. n_val: Number of training-half reviews reserved for validation. n_eval: Size of the reproducible evaluation subset. verbose: Whether to print dataset audit counts. """ ds = load_dataset("stanfordnlp/imdb", cache_dir="data/imdb", data_files={"train": "plain_text/train-*.parquet", "test": "plain_text/test-*.parquet"}, revision=DATASET_REVISIONS.get("stanfordnlp/imdb")) def dedup(texts, labels, seen): """Keep the first occurrence of each exact text not already in the supplied seen set. Arguments: texts: Text strings in the order expected by the caller. labels: Integer targets aligned with the input examples. seen: Set of exact texts already retained. """ keep = [] for i, t in enumerate(texts): if t not in seen: # keep only the first copy of each review seen.add(t); keep.append(i) return [texts[i] for i in keep], np.array([labels[i] for i in keep]) tr_x, tr_y = dedup(list(ds["train"]["text"]), list(ds["train"]["label"]), set()) te_x, te_y = dedup(list(ds["test"]["text"]), list(ds["test"]["label"]), set(tr_x)) # also drops test reviews already in train if verbose: print(f"official split: {len(ds['train'])} train, {len(ds['test'])} test reviews") print(f"duplicates removed: {len(ds['train']) - len(tr_x)} from train, " f"{len(ds['test']) - len(te_x)} from test (repeats, or reviews that are also in train)") clean = lambda xs: [x.replace("<br />", " ") for x in xs] # remove HTML line breaks rng = np.random.default_rng(seed) p = rng.permutation(len(tr_x)); v, t = p[:n_val], p[n_val:] # validation is carved from train q = rng.permutation(len(te_x))[:n_eval] # a fixed sample of the test set split = {"train": (clean([tr_x[i] for i in t]), tr_y[t]), "val": (clean([tr_x[i] for i in v]), tr_y[v]), "test": (clean(te_x), te_y)} split["val_eval"] = (split["val"][0][:n_eval], split["val"][1][:n_eval]) split["test_eval"] = (clean([te_x[i] for i in q]), te_y[q]) return splitdef fingerprint(texts): """Hash sorted texts to identify membership; this does not check row order or labels. Arguments: texts: Text strings in the order expected by the caller. """ return hashlib.sha256("\n".join(sorted(texts)).encode()).hexdigest()[:12]
imdb = load_imdb_split()for name, (x, y) in imdb.items(): print(f"{name:<9} {len(x):>6} reviews | positive share {y.mean():.3f} | fingerprint {fingerprint(x)}")
official split: 25000 train, 25000 test reviews duplicates removed: 96 from train, 322 from test (repeats, or reviews that are also in train) train 22404 reviews | positive share 0.502 | fingerprint fb3a1229f951 val 2500 reviews | positive share 0.492 | fingerprint 07758bc36c9b test 24678 reviews | positive share 0.503 | fingerprint 429c5557b6d4 val_eval 1000 reviews | positive share 0.495 | fingerprint 9934ffeaac91 test_eval 1000 reviews | positive share 0.515 | fingerprint fc9fa98701bc
First, how does the instruct model do before fine-tuning? As in Lesson 8.1, we score each answer by comparing the model's scores for "positive" and "negative" as the first token of its reply, on the first 100 validation reviews (cut to their first 60 words to keep the run short):
POS = tok.encode("positive", add_special_tokens=False)[0]NEG = tok.encode("negative", add_special_tokens=False)[0]def ask(review, words=60): """Format the truncated review as a sentiment question using the instruction model interface. Arguments: review: A movie review to classify or format. words: Maximum review words kept in the prompt. """ return [{"role": "user", "content": f"Review: {' '.join(review.split()[:words])}\nIs this movie review positive or negative? Answer with one word."}]def evaluate(model, n=100): """Score the declared validation subset using the restricted positive-versus-negative next-token comparison. Arguments: model: Model whose current parameters are used. n: Number of generated tokens or observations, as used by this helper. """ xs, ys = imdb["val_eval"][0][:n], imdb["val_eval"][1][:n] preds = [] model.eval() with torch.inference_mode(): for r in xs: p = tok.apply_chat_template(ask(r), tokenize=False, add_generation_prompt=True) nxt = model(**tok(p, return_tensors="pt").to(device)).logits[0, -1] # scores for the first answer token preds.append(int(nxt[POS] > nxt[NEG])) preds = np.array(preds) return (preds == ys).mean(), preds.mean()acc, pos = evaluate(chat_lm)print(f"before SFT: accuracy {acc:.2f} on 100 validation reviews | answers 'positive' {pos:.2f}")
before SFT: accuracy 0.43 on 100 validation reviews | answers 'positive' 0.00
Its accuracy is 0.43, and it answers "positive" for 0.00 of the reviews: it says "negative" to everything. This small model can produce the right format, but it hasn't connected the question to the review's sentiment. (Lesson 8.1 showed that a larger, 360M-parameter model in the same family does far better without any fine-tuning.)
Now we build SFT examples from 256 training reviews, each a conversation ending in the correct one-word answer, with the prompt masked:
sft_x, sft_y = imdb["train"][0][:256], imdb["train"][1][:256] # 256 training reviews, as instruction examplesrows = []for review, label in zip(sft_x, sft_y): conv = ask(review) + [{"role": "assistant", "content": "positive" if label else "negative"}] ids_ = tok(tok.apply_chat_template(conv, tokenize=False))["input_ids"] n_p = len(tok(tok.apply_chat_template(ask(review), tokenize=False, add_generation_prompt=True))["input_ids"]) rows.append((ids_, [-100] * n_p + ids_[n_p:])) # train only on the answerprint(f"{len(rows)} examples | median length {np.median([len(r[0]) for r in rows]):.0f} tokens | " f"trained-on tokens per example: {int(np.median([sum(l != -100 for l in r[1]) for r in rows]))}")
256 examples | median length 125 tokens | trained-on tokens per example: 3
Each example is about 125 tokens, of which only 3 are trained on (the answer word, <|im_end|> and the newline). We add LoRA, rank 8 on the query and value projections, and train for one pass over the 256 examples, in batches of 8:
torch.manual_seed(0)lm = get_peft_model(chat_lm, LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["q_proj", "v_proj"], task_type="CAUSAL_LM"))lm.print_trainable_parameters()opt = torch.optim.AdamW([p for p in lm.parameters() if p.requires_grad], lr=5e-4)pad = tok.pad_token_idlosses = []t0 = benchmark_clock()lm.train()for b in torch.randperm(len(rows), generator=torch.Generator().manual_seed(0)).split(8): batch = [rows[i] for i in b]; L = max(len(r[0]) for r in batch) ids_ = torch.tensor([r[0] + [pad] * (L - len(r[0])) for r in batch], device=device) lab = torch.tensor([r[1] + [-100] * (L - len(r[1])) for r in batch], device=device) att = (torch.arange(L)[None, :] < torch.tensor([len(r[0]) for r in batch])[:, None]).long().to(device) loss = lm(input_ids=ids_, attention_mask=att, labels=lab).loss loss.backward(); opt.step(); opt.zero_grad() losses.append(loss.item())print(f"{len(losses)} steps in {benchmark_clock() - t0:.0f} s | loss: first {losses[0]:.3f}, last 8 average {np.mean(losses[-8:]):.3f}")
trainable params: 460,800 || all params: 134,975,808 || trainable%: 0.3414 32 steps in 26 s | loss: first 1.148, last 8 average 0.184
LoRA trains 460,800 of 134,975,808 parameters (0.3414%). Over the 32 steps, the loss on the answer tokens falls from 1.148 to an average of 0.184 over the last 8 steps. Did the behaviour change?
acc, pos = evaluate(lm)print(f"after SFT: accuracy {acc:.2f} on 100 validation reviews | answers 'positive' {pos:.2f}")review = imdb["val_eval"][0][1]reply = generate(lm, tok.apply_chat_template(ask(review), tokenize=False, add_generation_prompt=True), n=5)print(f"a generated answer: {reply!r} | true label: {'positive' if imdb['val_eval'][1][1] else 'negative'}")
after SFT: accuracy 0.76 on 100 validation reviews | answers 'positive' 0.71 a generated answer: 'positive' | true label: positive
Yes. After SFT on 256 examples, accuracy on the same 100 validation reviews rises from 0.43 to 0.76, and the model now answers "positive" for 0.71 of them instead of never. It has learned to connect the question to the review, though it now leans positive, and 0.76 is well below what the classifiers in Lessons 8.3–8.5 reached. A real SFT run would use far more data, a larger model and several checks on validation. The mechanics, though, are exactly these: chat-formatted examples, prompt masking, the causal LM loss and LoRA.
-
After SFT, the model answers "positive" for 0.71 of 100 reviews, about half of which are positive. What does this tell you, and what might you change in a real SFT run?
It's overcorrecting. Before SFT it always said "negative"; now it says "positive" much more often than the data warrants, so many of its errors are negative reviews called positive. It has learned the format and part of the task, but not a balanced decision. In a real run you'd use more (and balanced) training examples, check the answer distribution and accuracy on a validation set during training, tune the learning rate and number of steps on validation, and perhaps use a larger base model.
What is trained in this LLM LoRA example?
The selected low-rank adapter parameters.
Quantisation and QLoRA
7.1 The problem: the frozen weights still have to fit
LoRA shrinks the trainable part to a fraction of a percent, but the frozen weights still have to sit in memory for every forward and backward pass. For a 7B model in bfloat16 that's about 15.23 GB (Section 8), more than most consumer GPUs have. The idea of QLoRA is to store those frozen weights in 4 bits each, a quarter of bfloat16's 16. The LoRA matrices are still trained in higher precision. During the forward pass, each 4-bit weight is converted back (dequantised) when it's needed.
7.2 4-bit quantisation by hand
Lesson 7.6 quantised weights to 8-bit integers with one scale per row. Four bits can encode 16 levels; this symmetric teaching example uses 15 of them (-7 to 7), so the scale must fit the numbers more closely. QLoRA-style quantisation uses a separate scale for each block of 64 weights. Here is 8-bit and 4-bit blockwise quantisation of one of SmolLM2's feed-forward matrices:
W = chat_lm.model.layers[0].mlp.gate_proj.weight.detach().cpu() # a feed-forward matrix (not a LoRA target)def blockwise(W, bits, block=64): """Simulate uniform symmetric blockwise quantisation and estimate storage; this is not an NF4 execution. Arguments: W: Floating-point weight matrix to quantise. bits: Number of storage bits in the illustrative quantiser. block: Number of weights sharing one quantisation scale. """ levels = 2 ** (bits - 1) - 1 # 127 for 8 bits, 7 for 4 bits flat = W.flatten().reshape(-1, block) # (n_blocks, 64) scale = flat.abs().amax(dim=1, keepdim=True) / levels q = torch.round(flat / scale).clamp(-levels, levels) # small integers return (q * scale).reshape(W.shape), W.numel() * bits / 8 + flat.shape[0] * 4 # values, bytes incl. scalesprint(f"weight matrix {tuple(W.shape)} | float32 {W.numel() * 4 / 1e6:.2f} MB | typical size {W.abs().mean():.2e}")for bits in (8, 4): Wq, nbytes = blockwise(W, bits) err = (W - Wq).abs().mean() / W.abs().mean() print(f"{bits}-bit, blocks of 64: {nbytes / 1e6:.2f} MB | average error {err:.1%} of a typical weight")
weight matrix (1536, 576) | float32 3.54 MB | typical size 1.46e-01 8-bit, blocks of 64: 0.94 MB | average error 0.6% of a typical weight 4-bit, blocks of 64: 0.50 MB | average error 11.7% of a typical weight
The matrix takes 3.54 MB in float32. With 8 bits it shrinks to 0.94 MB, with an average error of only 0.6% of a typical weight's size. With 4 bits it's 0.50 MB, about a seventh of float32, but the average error jumps to 11.7%. Fifteen evenly spaced levels are coarse.
QLoRA reduces this error with two further tricks, which we describe rather than implement:
- NF4 ("4-bit NormalFloat"): neural-network weights are roughly bell-shaped, concentrated near zero, so instead of 15 evenly spaced levels, NF4 places its 16 levels where weights are most common. More levels near zero means smaller errors for most weights.
- Double quantisation: the per-block scales are themselves quantised, saving a little more memory.
The 4-bit weights are frozen and never updated. Training only adjusts the LoRA matrices, which learn to work with the quantised model, so the rounding error matters less than in pure inference quantisation. QLoRA also uses paged optimisers, which move optimiser state out of GPU memory when it runs short. In practice QLoRA is run with the bitsandbytes library on NVIDIA GPUs; that's why this lesson describes it rather than running it.
Key terms
- Blockwise quantisation: quantising in small blocks (here 64 numbers), each with its own scale.
- NF4: a 4-bit format whose levels are spaced to match the bell-shaped distribution of neural-network weights.
- QLoRA: LoRA training on top of a frozen model stored in 4-bit (NF4) precision.
- Dequantisation: converting quantised values back to higher precision for computation.
Is the uniform four-bit exercise an executed NF4 QLoRA run?
No; it illustrates quantisation arithmetic only.
Hardware Constraints and Practical Decisions
Putting it all together: which method fits on which hardware? The table below estimates memory for the weights and training state only. As a rough rule we leave 20% headroom for activations, the KV cache and framework overhead. Real usage depends on sequence length and batch size, so treat these as first estimates, not guarantees.
budget = {"inference, bfloat16": 2.0, "full fine-tuning, float32 + Adam": 16.0, "LoRA, bfloat16 weights (adapters tiny)": 2.0, "Illustrative 4-bit base layout": 0.5 + 4 / 64} # 4 bits + one float32 scale per 64for model_name, n in (("SmolLM2-135M", sum(p.numel() for n_, p in lm.named_parameters() if "lora_" not in n_)), ("Qwen2.5-7B", n7b)): print(model_name) for k, bytes_per in budget.items(): gb = n * bytes_per / 1e9 weight_only_candidates = [g for g in (8, 16, 24, 80) if gb * 1.2 < g] # illustrative 20% allowance; NOT a training-fit test print(f" {k:<40} {gb:8.2f} GB | storage estimate below: {', '.join(f'{g} GB' for g in weight_only_candidates) or 'none listed'}")
SmolLM2-135M inference, bfloat16 0.27 GB | storage estimate below: 8 GB, 16 GB, 24 GB, 80 GB full fine-tuning, float32 + Adam 2.15 GB | storage estimate below: 8 GB, 16 GB, 24 GB, 80 GB LoRA, bfloat16 weights (adapters tiny) 0.27 GB | storage estimate below: 8 GB, 16 GB, 24 GB, 80 GB Illustrative 4-bit base layout 0.08 GB | storage estimate below: 8 GB, 16 GB, 24 GB, 80 GB Qwen2.5-7B inference, bfloat16 15.23 GB | storage estimate below: 24 GB, 80 GB full fine-tuning, float32 + Adam 121.85 GB | storage estimate below: none listed LoRA, bfloat16 weights (adapters tiny) 15.23 GB | storage estimate below: 24 GB, 80 GB Illustrative 4-bit base layout 4.28 GB | storage estimate below: 8 GB, 16 GB, 24 GB, 80 GB
For SmolLM2-135M, the illustrated float32 training-state subtotal is 2.15 GB; activations and runtime allocations are additional. For Qwen2.5-7B, the picture is the one that shapes real projects:
- Full fine-tuning needs about 121.85 GB, more than even an 80 GB data-centre GPU. That exceeds the listed 80 GB device under this float32/Adam estimate; other precision, sharding and offload choices change the requirement.
- LoRA with bfloat16 weights needs about 15.23 GB, before adapters, activations and runtime workspaces; actual training fit must be measured.
- QLoRA brings the frozen weights down to about 4.28 GB, before the remaining runtime allocations; this alone does not establish an 8 GB training fit.
That's why QLoRA made fine-tuning large models practical for individuals and small teams.
A practical decision guide, building on Lesson 8.1:
- Try prompting first (including a few examples in the prompt). If a capable model does the job, there's nothing to train.
- If you need a behaviour or format the model won't reliably produce, SFT with LoRA on a model that fits your hardware is the default. Use QLoRA if the bfloat16 weights don't fit.
- Full fine-tuning deserves a clear measured benefit for its cost. Hardware needs depend on model size, precision, sequence length, batch size, sharding and offload; large GPU clusters are not a universal requirement.
- Whatever you choose, evaluate the adapted model on held-out examples, check for forgetting of the abilities you still need (Lessons 8.1 and 8.5), and compare against the prompting baseline. Lesson 8.7 turns this into a decision process.

Figure 8.6.2. Simplified storage arithmetic, not measured peak training memory: float32 full-model Adam state, bfloat16 base weights, and an illustrative four-bit layout. No bar guarantees a GPU training fit or implements NF4.
Common mistake: assuming the trainable-parameter count decides whether training fits. With LoRA, the trainable part is tiny; it's the frozen weights (and activations) that fill memory. Estimate from the full model size and its storage format.
-
You have one GPU with 16 GB of memory and want to adapt a 7B model to answer in your company's support style. Which method from the table would you choose, and why?
QLoRA is a reasonable candidate to benchmark because quantizing the frozen base can reduce storage substantially. The 15.23 GB bfloat16 base leaves insufficient room on a nominal 16 GB device for ordinary training allocations. The illustrative 4.28 GB four-bit subtotal does not guarantee a fit: measure peak memory with the intended sequence length, batch size, adapters, optimizer and implementation. Try smaller batches, shorter inputs, checkpointing or a smaller model if necessary. Compare held-out quality against prompting before deciding that adaptation is worthwhile.
The 20% margin is illustrative rather than a sufficient bound. Peak memory depends on sequence length, batch size, saved activations, checkpointing, unquantised modules, adapter/optimizer state and implementation workspaces. The hand-written uniform quantiser is not an executed NF4/QLoRA implementation. Full fine-tuning does not universally require a cluster; that statement depends on model size, hardware and the chosen precision/optimizer/offload recipe.
Does a weight subtotal guarantee a GPU training fit?
No; measure the complete workload and runtime allocations.
Putting It All Together
Let's reassemble how large language models are adapted.
A base language model continues text; an instruction-tuned model responds to requests. With the same SmolLM2-135M architecture, the base model rambled and repeated itself while the instruct model began a helpful answer. The difference comes from supervised fine-tuning: next-token training on (instruction, response) examples. Each example is written in the model's chat template, with special tokens marking each role. Masking sets the prompt's labels to -100, so the causal LM loss is computed only on the response. In our example 3 of 53 tokens, including the end-of-turn marker, and the hand-computed loss (2.9850) matched the model's exactly.
LoRA makes SFT affordable. On SmolLM2-135M it trained 460,800 parameters (0.34%); on Qwen2.5-7B the same setting would train only 0.033% of the model. A tiny real SFT run on 256 examples took accuracy on 100 validation reviews from 0.43 to 0.76, though the model then leaned positive. QLoRA stores the frozen weights in 4 bits. Done by hand with even spacing, 4-bit blockwise quantisation had an average error of 11.7% against 0.6% at 8 bits, which is why QLoRA uses NF4. It brings a 7B model's weights from 15.23 GB to about 4.28 GB, as an illustrative storage subtotal, not a guarantee of fitting training on an 8 GB GPU, while full fine-tuning would need about 121.85 GB.
| Method (Qwen2.5-7B) | Simplified storage subtotal | What this establishes |
|---|---|---|
| Full fine-tuning, float32 + Adam | 121.85 GB | Exceeds listed 80 GB under these float32 assumptions |
| LoRA, bfloat16 weights | 15.23 GB | Weight storage only; measure peak training memory |
| QLoRA, 4-bit weights | 4.28 GB | Weight storage only; no training-fit guarantee |
Summary
- A base language model continues text; an instruction-tuned model follows requests. Same architecture (SmolLM2-135M: 134,515,008 parameters), different behaviour after fine-tuning.
- SFT trains on (instruction, response) examples with the ordinary next-token loss; the data, not the objective, is what changes.
- Every example uses the model's chat template (
<|im_start|>role … <|im_end|>, special token IDs 1 and 2). - Prompt masking (labels -100) trains only on the response: 3 of 53 tokens in our example, including the end-of-turn token.
- The causal LM loss shifts logits and targets by one; by hand 2.9850, matching the model; 3.7479 without masking.
- LoRA on an LLM: 460,800 trainable parameters (0.34%) on SmolLM2-135M; 0.033% of Qwen2.5-7B for rank 8 on query and value.
- A tiny SFT run (256 examples, 32 steps) raised accuracy on 100 validation reviews from 0.43 to 0.76, with a new lean towards "positive".
- QLoRA stores frozen weights in 4-bit NF4 and trains LoRA adapters on top; uniform 4-bit blockwise quantisation had 11.7% average error against 0.6% at 8 bits.
- For a 7B model: full fine-tuning about 121.85 GB, LoRA about 15.23 GB, QLoRA about 4.28 GB in this illustrative weight-storage layout, before other training allocations.
Glossary
| Term | Meaning |
|---|---|
| Base model | A language model after pretraining only; it continues text. |
| Instruction-tuned model | A base model further trained to follow instructions. |
| SFT | Supervised fine-tuning on (instruction, response) examples. |
| Preference tuning | Training from judgements of which response is better (e.g. RLHF, DPO). |
| Chat template | The model-specific format turning messages into one token sequence. |
| Special tokens | Tokens with reserved meanings, such as turn markers. |
| Roles | System, user and assistant parts of a conversation. |
| Prompt masking | Excluding prompt tokens from the loss. |
| Label -100 | The label value ignored by Hugging Face loss functions. |
| Causal LM loss | Cross-entropy of predicting each token from those before it. |
| Blockwise quantisation | Quantising small blocks of numbers, each with its own scale. |
| NF4 | A 4-bit format with levels matched to bell-shaped weight distributions. |
| Double quantisation | Quantising the quantisation scales themselves. |
| QLoRA | LoRA on top of a frozen 4-bit (NF4) model. |
| Dequantisation | Converting quantised values back to higher precision. |
| Paged optimiser | An optimiser that moves its state out of GPU memory when needed. |
Wrap-up
Quick check
What is the conceptual change in instruction tuning?
Train on task-oriented conversations and desired responses.
Why shift logits and targets by one?
Position t predicts the next token.
Does a weight subtotal guarantee a GPU training fit?
No; measure the complete workload and runtime allocations.
Questions and discussion
Ask about this lesson, share what you found in a lab, or help someone else. Posts are public on the course's GitHub Discussions: anyone can read them, and you need a GitHub account to post.
Opening it loads giscus from giscus.app.