Deep Learning Lab
Guide
Module 8 · Efficient Fine-Tuning and Data Engineering Lesson 8.6

Fine-Tuning Large Language Models (Conceptual)

Further learning · Complete notes beyond the classroom route

37 min · 14 code cells · 1 labs

In this lesson
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.

Setup 35 lines
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()
Warnings / log
/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.
Output
PyTorch 2.14.1 | device: cpu
1

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:

Python 24 lines
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
Warnings / log
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]
Output
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:

  1. Pretraining on huge amounts of text: next-token prediction (Lesson 6.6).
  2. Supervised fine-tuning (SFT) on curated examples of instructions and good responses.
  3. 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.

From Base Model to Assistant. 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.

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.

Checkpoint Before you move on from §1

What is the conceptual change in instruction tuning?

2

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).
Checkpoint Before you move on from §2

What does supervised fine-tuning use?

3

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:

Python 6 lines
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|>")})
Output
<|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.
Checkpoint Before you move on from §3

Why use the model's chat template?

4

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:

Python 8 lines
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}")
Output
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.

Separate visible context from supervised targets; exact steps are described below.

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.

Interactive

Separate visible context from supervised targets

Step through a fixed example; each stage explains its assumptions.

Loading interactive…

Paused on arrival. Use Previous step, Play/Pause, Next step or Reset. No model runs in this walkthrough.

Checkpoint Before you move on from §4

What does a target label of minus 100 do here?

5

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:

Python 12 lines
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}")
Output
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.

Check your understandingCheck yourself
  1. 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?
Checkpoint Before you move on from §5

Why shift logits and targets by one?

6

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):

Python 6 lines
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
Warnings / log
Loading weights:   0%|          | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 11422.79it/s]
Output
SmolLM2-135M, LoRA r=8 on 2 matrix types: 460,800 trainable (0.34%)
Warnings / log
Loading weights:   0%|          | 0/272 [00:00<?, ?it/s]Loading weights: 100%|██████████| 272/272 [00:00<00:00, 11255.88it/s]
Output
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:

Python 9 lines
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)")
Warnings / log
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]
Output
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):

Python 49 lines
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]
Python 3 lines
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)}")
Output
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):

Python 31 lines
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}")
Output
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:

Python 9 lines
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]))}")
Output
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:

Python 18 lines
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}")
Output
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?

Python 5 lines
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'}")
Output
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.

Check your understandingCheck yourself
  1. 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?
Checkpoint Before you move on from §6

What is trained in this LLM LoRA example?

7

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:

Python 19 lines
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")
Output
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.
Checkpoint Before you move on from §7

Is the uniform four-bit exercise an executed NF4 QLoRA run?

8

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.

Python 10 lines
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'}")
Output
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:

  1. Try prompting first (including a few examples in the prompt). If a capable model does the job, there's nothing to train.
  2. 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.
  3. 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.
  4. 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.
What Fits Where (7B Model). 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.

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.

Check your understandingCheck yourself
  1. 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?

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.

Checkpoint Before you move on from §8

Does a weight subtotal guarantee a GPU training fit?

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

Knowledge check

Quick check

1

What is the conceptual change in instruction tuning?

2

Why shift logits and targets by one?

3

Does a weight subtotal guarantee a GPU training fit?

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.